From 349917f0194e7fba17342b523eb6d39bbd58edc3 Mon Sep 17 00:00:00 2001 From: kent Date: Fri, 9 Jan 2026 08:32:44 +0900 Subject: [PATCH] =?UTF-8?q?refactor(work-orders):=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EA=B8=B0=EB=B0=98=20=EC=A0=84=EB=A9=B4=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Critical 수정 - Multi-tenancy: WorkOrderItem, BendingDetail, Issue에 BelongsToTenant 적용 - 감사 로그: 상태변경, 품목수정, 이슈 등록/해결 시 로깅 추가 - 상태 전이 규칙: STATUS_TRANSITIONS + canTransitionTo() 구현 ## High 수정 - 다중 담당자: work_order_assignees 피벗 테이블 및 관계 추가 - 부분 수정: 품목 ID 기반 upsert/delete 로직 구현 ## 변경 파일 - Models: WorkOrder, WorkOrderAssignee(신규), 하위 모델들 - Services: WorkOrderService (assign, update 메서드 개선) - Migrations: tenant_id 추가, assignees 테이블 생성 Co-Authored-By: Claude --- app/Models/Production/WorkOrder.php | 68 ++++- app/Models/Production/WorkOrderAssignee.php | 64 +++++ .../Production/WorkOrderBendingDetail.php | 4 + app/Models/Production/WorkOrderIssue.php | 4 + app/Models/Production/WorkOrderItem.php | 4 + app/Services/WorkOrderService.php | 239 ++++++++++++++++-- ...add_tenant_id_to_work_order_sub_tables.php | 88 +++++++ ...0000_create_work_order_assignees_table.php | 41 +++ 8 files changed, 490 insertions(+), 22 deletions(-) create mode 100644 app/Models/Production/WorkOrderAssignee.php create mode 100644 database/migrations/2025_01_08_100000_add_tenant_id_to_work_order_sub_tables.php create mode 100644 database/migrations/2025_01_09_100000_create_work_order_assignees_table.php diff --git a/app/Models/Production/WorkOrder.php b/app/Models/Production/WorkOrder.php index bf3d72b..9100f1c 100644 --- a/app/Models/Production/WorkOrder.php +++ b/app/Models/Production/WorkOrder.php @@ -98,6 +98,19 @@ class WorkOrder extends Model self::STATUS_SHIPPED, ]; + /** + * 상태 전이 규칙 + * [현재 상태 => [허용되는 다음 상태들]] + */ + public const STATUS_TRANSITIONS = [ + self::STATUS_UNASSIGNED => [self::STATUS_PENDING], + self::STATUS_PENDING => [self::STATUS_UNASSIGNED, self::STATUS_WAITING], + self::STATUS_WAITING => [self::STATUS_PENDING, self::STATUS_IN_PROGRESS], + self::STATUS_IN_PROGRESS => [self::STATUS_WAITING, self::STATUS_COMPLETED], + self::STATUS_COMPLETED => [self::STATUS_IN_PROGRESS, self::STATUS_SHIPPED], + self::STATUS_SHIPPED => [self::STATUS_COMPLETED], + ]; + // ────────────────────────────────────────────────────────────── // 관계 // ────────────────────────────────────────────────────────────── @@ -111,13 +124,29 @@ public function salesOrder(): BelongsTo } /** - * 담당자 + * 담당자 (주 담당자 - 하위 호환) */ public function assignee(): BelongsTo { return $this->belongsTo(User::class, 'assignee_id'); } + /** + * 담당자들 (다중 담당자) + */ + public function assignees(): HasMany + { + return $this->hasMany(WorkOrderAssignee::class); + } + + /** + * 주 담당자 (다중 담당자 중) + */ + public function primaryAssignee(): HasMany + { + return $this->hasMany(WorkOrderAssignee::class)->where('is_primary', true); + } + /** * 팀 */ @@ -291,4 +320,41 @@ public function getOpenIssuesCountAttribute(): int { return $this->issues()->where('status', '!=', 'resolved')->count(); } + + /** + * 특정 상태로 전이 가능한지 확인 + */ + public function canTransitionTo(string $newStatus): bool + { + $allowedTransitions = self::STATUS_TRANSITIONS[$this->status] ?? []; + + return in_array($newStatus, $allowedTransitions); + } + + /** + * 현재 상태에서 허용되는 다음 상태 목록 조회 + */ + public function getAllowedTransitions(): array + { + return self::STATUS_TRANSITIONS[$this->status] ?? []; + } + + /** + * 상태 전이 실행 (유효성 검증 포함) + * + * @throws \InvalidArgumentException 허용되지 않은 전이인 경우 + */ + public function transitionTo(string $newStatus): bool + { + if (! $this->canTransitionTo($newStatus)) { + $allowed = implode(', ', $this->getAllowedTransitions()); + throw new \InvalidArgumentException( + "상태를 '{$this->status}'에서 '{$newStatus}'(으)로 변경할 수 없습니다. 허용된 상태: {$allowed}" + ); + } + + $this->status = $newStatus; + + return true; + } } diff --git a/app/Models/Production/WorkOrderAssignee.php b/app/Models/Production/WorkOrderAssignee.php new file mode 100644 index 0000000..b9429a2 --- /dev/null +++ b/app/Models/Production/WorkOrderAssignee.php @@ -0,0 +1,64 @@ + 'boolean', + ]; + + // ────────────────────────────────────────────────────────────── + // 관계 + // ────────────────────────────────────────────────────────────── + + /** + * 작업지시 + */ + public function workOrder(): BelongsTo + { + return $this->belongsTo(WorkOrder::class); + } + + /** + * 담당자 + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + // ────────────────────────────────────────────────────────────── + // 스코프 + // ────────────────────────────────────────────────────────────── + + /** + * 주 담당자만 + */ + public function scopePrimary($query) + { + return $query->where('is_primary', true); + } +} diff --git a/app/Models/Production/WorkOrderBendingDetail.php b/app/Models/Production/WorkOrderBendingDetail.php index 501acf1..733fea4 100644 --- a/app/Models/Production/WorkOrderBendingDetail.php +++ b/app/Models/Production/WorkOrderBendingDetail.php @@ -2,6 +2,7 @@ namespace App\Models\Production; +use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -12,9 +13,12 @@ */ class WorkOrderBendingDetail extends Model { + use BelongsToTenant; + protected $table = 'work_order_bending_details'; protected $fillable = [ + 'tenant_id', 'work_order_id', 'shaft_cutting', 'bearing', diff --git a/app/Models/Production/WorkOrderIssue.php b/app/Models/Production/WorkOrderIssue.php index 7036a69..bc3de57 100644 --- a/app/Models/Production/WorkOrderIssue.php +++ b/app/Models/Production/WorkOrderIssue.php @@ -3,6 +3,7 @@ namespace App\Models\Production; use App\Models\Members\User; +use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -13,9 +14,12 @@ */ class WorkOrderIssue extends Model { + use BelongsToTenant; + protected $table = 'work_order_issues'; protected $fillable = [ + 'tenant_id', 'work_order_id', 'title', 'description', diff --git a/app/Models/Production/WorkOrderItem.php b/app/Models/Production/WorkOrderItem.php index 849b71a..9667b46 100644 --- a/app/Models/Production/WorkOrderItem.php +++ b/app/Models/Production/WorkOrderItem.php @@ -3,6 +3,7 @@ namespace App\Models\Production; use App\Models\Items\Item; +use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -11,9 +12,12 @@ */ class WorkOrderItem extends Model { + use BelongsToTenant; + protected $table = 'work_order_items'; protected $fillable = [ + 'tenant_id', 'work_order_id', 'item_id', 'item_name', diff --git a/app/Services/WorkOrderService.php b/app/Services/WorkOrderService.php index 988337e..b5e95ed 100644 --- a/app/Services/WorkOrderService.php +++ b/app/Services/WorkOrderService.php @@ -3,13 +3,21 @@ namespace App\Services; use App\Models\Production\WorkOrder; +use App\Models\Production\WorkOrderAssignee; use App\Models\Production\WorkOrderBendingDetail; +use App\Services\Audit\AuditLogger; use Illuminate\Support\Facades\DB; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class WorkOrderService extends Service { + private const AUDIT_TARGET = 'work_order'; + + public function __construct( + private readonly AuditLogger $auditLogger + ) {} + /** * 목록 조회 (검색/필터링/페이징) */ @@ -29,7 +37,7 @@ public function index(array $params) $query = WorkOrder::query() ->where('tenant_id', $tenantId) - ->with(['assignee:id,name', 'team:id,name', 'salesOrder:id,order_no']); + ->with(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'salesOrder:id,order_no']); // 검색어 if ($q !== '') { @@ -106,6 +114,7 @@ public function show(int $id) $workOrder = WorkOrder::where('tenant_id', $tenantId) ->with([ 'assignee:id,name', + 'assignees.user:id,name', 'team:id,name', 'salesOrder:id,order_no,project_name', 'items', @@ -149,16 +158,28 @@ public function store(array $data) // 품목 저장 foreach ($items as $index => $item) { + $item['tenant_id'] = $tenantId; $item['sort_order'] = $index; $workOrder->items()->create($item); } // 벤딩 상세 저장 (벤딩 공정인 경우) if ($data['process_type'] === WorkOrder::PROCESS_BENDING && $bendingDetail) { + $bendingDetail['tenant_id'] = $tenantId; $workOrder->bendingDetail()->create($bendingDetail); } - return $workOrder->load(['assignee:id,name', 'team:id,name', 'items', 'bendingDetail']); + // 감사 로그 + $this->auditLogger->log( + $tenantId, + self::AUDIT_TARGET, + $workOrder->id, + 'created', + null, + $workOrder->toArray() + ); + + return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'items', 'bendingDetail']); }); } @@ -175,33 +196,89 @@ public function update(int $id, array $data) throw new NotFoundHttpException(__('error.not_found')); } - return DB::transaction(function () use ($workOrder, $data, $userId) { + $beforeData = $workOrder->toArray(); + + return DB::transaction(function () use ($workOrder, $data, $userId, $beforeData) { $data['updated_by'] = $userId; $items = $data['items'] ?? null; $bendingDetail = $data['bending_detail'] ?? null; unset($data['items'], $data['bending_detail'], $data['work_order_no']); // 번호 변경 불가 + // 품목 수정 시 기존 품목 기록 + $oldItems = null; + if ($items !== null) { + $oldItems = $workOrder->items()->get()->toArray(); + } + $workOrder->update($data); - // 품목 교체 (있는 경우) + // 품목 부분 수정 (ID 기반 upsert/delete) if ($items !== null) { - $workOrder->items()->delete(); + $existingIds = $workOrder->items()->pluck('id')->toArray(); + $incomingIds = []; + foreach ($items as $index => $item) { - $item['sort_order'] = $index; - $workOrder->items()->create($item); + $itemData = [ + 'tenant_id' => $workOrder->tenant_id, + 'item_name' => $item['item_name'] ?? null, + 'specification' => $item['specification'] ?? null, + 'quantity' => $item['quantity'] ?? 1, + 'unit' => $item['unit'] ?? null, + 'sort_order' => $index, + ]; + + if (isset($item['id']) && $item['id']) { + // ID가 있으면 업데이트 + $existingItem = $workOrder->items()->find($item['id']); + if ($existingItem) { + $existingItem->update($itemData); + $incomingIds[] = (int) $item['id']; + } + } else { + // ID가 없으면 신규 생성 + $newItem = $workOrder->items()->create($itemData); + $incomingIds[] = $newItem->id; + } } + + // 요청에 없는 기존 품목 삭제 + $toDelete = array_diff($existingIds, $incomingIds); + if (! empty($toDelete)) { + $workOrder->items()->whereIn('id', $toDelete)->delete(); + } + + // 품목 수정 감사 로그 + $this->auditLogger->log( + $workOrder->tenant_id, + self::AUDIT_TARGET, + $workOrder->id, + 'items_updated', + ['items' => $oldItems], + ['items' => $workOrder->items()->get()->toArray()] + ); } // 벤딩 상세 업데이트 if ($bendingDetail !== null && $workOrder->process_type === WorkOrder::PROCESS_BENDING) { + $bendingDetail['tenant_id'] = $workOrder->tenant_id; $workOrder->bendingDetail()->updateOrCreate( ['work_order_id' => $workOrder->id], $bendingDetail ); } - return $workOrder->load(['assignee:id,name', 'team:id,name', 'items', 'bendingDetail']); + // 수정 감사 로그 + $this->auditLogger->log( + $workOrder->tenant_id, + self::AUDIT_TARGET, + $workOrder->id, + 'updated', + $beforeData, + $workOrder->fresh()->toArray() + ); + + return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'items', 'bendingDetail']); }); } @@ -226,8 +303,19 @@ public function destroy(int $id) throw new BadRequestHttpException(__('error.work_order.cannot_delete_in_progress')); } + $beforeData = $workOrder->toArray(); $workOrder->delete(); + // 삭제 감사 로그 + $this->auditLogger->log( + $tenantId, + self::AUDIT_TARGET, + $workOrder->id, + 'deleted', + $beforeData, + null + ); + return 'success'; } @@ -249,6 +337,19 @@ public function updateStatus(int $id, string $status) throw new BadRequestHttpException(__('error.invalid_status')); } + // 상태 전이 규칙 검증 + if (! $workOrder->canTransitionTo($status)) { + $allowed = implode(', ', $workOrder->getAllowedTransitions()); + throw new BadRequestHttpException( + __('error.work_order.invalid_transition', [ + 'from' => $workOrder->status, + 'to' => $status, + 'allowed' => $allowed, + ]) + ); + } + + $oldStatus = $workOrder->status; $workOrder->status = $status; $workOrder->updated_by = $userId; @@ -267,11 +368,24 @@ public function updateStatus(int $id, string $status) $workOrder->save(); - return $workOrder->load(['assignee:id,name', 'team:id,name']); + // 상태 변경 감사 로그 + $this->auditLogger->log( + $tenantId, + self::AUDIT_TARGET, + $workOrder->id, + 'status_changed', + ['status' => $oldStatus], + ['status' => $status] + ); + + return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name']); } /** - * 담당자 배정 + * 담당자 배정 (다중 담당자 지원) + * + * @param int $id 작업지시 ID + * @param array $data 배정 데이터 (assignee_ids: int[], team_id?: int) */ public function assign(int $id, array $data) { @@ -283,18 +397,65 @@ public function assign(int $id, array $data) throw new NotFoundHttpException(__('error.not_found')); } - $workOrder->assignee_id = $data['assignee_id']; - $workOrder->team_id = $data['team_id'] ?? $workOrder->team_id; - $workOrder->updated_by = $userId; + return DB::transaction(function () use ($workOrder, $data, $tenantId, $userId) { + // 이전 상태 기록 + $beforeAssignees = $workOrder->assignees()->pluck('user_id')->toArray(); + $beforePrimaryAssignee = $workOrder->assignee_id; + $beforeTeam = $workOrder->team_id; - // 미배정이었으면 대기로 변경 - if ($workOrder->status === WorkOrder::STATUS_UNASSIGNED) { - $workOrder->status = WorkOrder::STATUS_PENDING; - } + // 담당자 ID 배열 처리 (단일 값도 배열로 변환) + $assigneeIds = $data['assignee_ids'] ?? []; + if (isset($data['assignee_id']) && ! empty($data['assignee_id'])) { + // 하위 호환: 단일 assignee_id도 지원 + $assigneeIds = is_array($data['assignee_id']) ? $data['assignee_id'] : [$data['assignee_id']]; + } + $assigneeIds = array_unique(array_filter($assigneeIds)); - $workOrder->save(); + // 기존 담당자 삭제 후 새로 추가 + $workOrder->assignees()->delete(); - return $workOrder->load(['assignee:id,name', 'team:id,name']); + foreach ($assigneeIds as $index => $assigneeId) { + WorkOrderAssignee::create([ + 'tenant_id' => $tenantId, + 'work_order_id' => $workOrder->id, + 'user_id' => $assigneeId, + 'is_primary' => $index === 0, // 첫 번째가 주 담당자 + ]); + } + + // 주 담당자는 work_orders 테이블에도 설정 (하위 호환) + $primaryAssigneeId = $assigneeIds[0] ?? null; + $workOrder->assignee_id = $primaryAssigneeId; + $workOrder->team_id = $data['team_id'] ?? $workOrder->team_id; + $workOrder->updated_by = $userId; + + // 미배정이었으면 대기로 변경 + if ($workOrder->status === WorkOrder::STATUS_UNASSIGNED && $primaryAssigneeId) { + $workOrder->status = WorkOrder::STATUS_PENDING; + } + + $workOrder->save(); + + // 배정 감사 로그 + $this->auditLogger->log( + $tenantId, + self::AUDIT_TARGET, + $workOrder->id, + 'assigned', + [ + 'assignee_id' => $beforePrimaryAssignee, + 'assignee_ids' => $beforeAssignees, + 'team_id' => $beforeTeam, + ], + [ + 'assignee_id' => $workOrder->assignee_id, + 'assignee_ids' => $assigneeIds, + 'team_id' => $workOrder->team_id, + ] + ); + + return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name']); + }); } /** @@ -315,15 +476,28 @@ public function toggleBendingField(int $id, string $field) $detail = $workOrder->bendingDetail; if (! $detail) { - $detail = $workOrder->bendingDetail()->create([]); + $detail = $workOrder->bendingDetail()->create([ + 'tenant_id' => $workOrder->tenant_id, + ]); } if (! in_array($field, WorkOrderBendingDetail::PROCESS_FIELDS)) { throw new BadRequestHttpException(__('error.invalid_field')); } + $beforeValue = $detail->{$field}; $detail->toggleField($field); + // 벤딩 토글 감사 로그 + $this->auditLogger->log( + $workOrder->tenant_id, + self::AUDIT_TARGET, + $workOrder->id, + 'bending_toggled', + [$field => $beforeValue], + [$field => $detail->{$field}] + ); + return $detail; } @@ -340,9 +514,22 @@ public function addIssue(int $workOrderId, array $data) throw new NotFoundHttpException(__('error.not_found')); } + $data['tenant_id'] = $tenantId; $data['reported_by'] = $userId; - return $workOrder->issues()->create($data); + $issue = $workOrder->issues()->create($data); + + // 이슈 추가 감사 로그 + $this->auditLogger->log( + $tenantId, + self::AUDIT_TARGET, + $workOrderId, + 'issue_added', + null, + ['issue_id' => $issue->id, 'title' => $issue->title, 'priority' => $issue->priority] + ); + + return $issue; } /** @@ -365,6 +552,16 @@ public function resolveIssue(int $workOrderId, int $issueId) $issue->resolve($userId); + // 이슈 해결 감사 로그 + $this->auditLogger->log( + $tenantId, + self::AUDIT_TARGET, + $workOrderId, + 'issue_resolved', + ['issue_id' => $issueId, 'status' => 'open'], + ['issue_id' => $issueId, 'status' => 'resolved', 'resolved_by' => $userId] + ); + return $issue; } diff --git a/database/migrations/2025_01_08_100000_add_tenant_id_to_work_order_sub_tables.php b/database/migrations/2025_01_08_100000_add_tenant_id_to_work_order_sub_tables.php new file mode 100644 index 0000000..89fbc55 --- /dev/null +++ b/database/migrations/2025_01_08_100000_add_tenant_id_to_work_order_sub_tables.php @@ -0,0 +1,88 @@ +unsignedBigInteger('tenant_id')->nullable()->after('id')->comment('테넌트ID'); + $table->index('tenant_id', 'idx_work_order_items_tenant'); + }); + + // 기존 데이터 업데이트 + DB::statement(' + UPDATE work_order_items wi + JOIN work_orders wo ON wi.work_order_id = wo.id + SET wi.tenant_id = wo.tenant_id + '); + + // nullable 제거 + Schema::table('work_order_items', function (Blueprint $table) { + $table->unsignedBigInteger('tenant_id')->nullable(false)->change(); + }); + + // 2. work_order_bending_details + Schema::table('work_order_bending_details', function (Blueprint $table) { + $table->unsignedBigInteger('tenant_id')->nullable()->after('id')->comment('테넌트ID'); + $table->index('tenant_id', 'idx_work_order_bending_details_tenant'); + }); + + DB::statement(' + UPDATE work_order_bending_details wbd + JOIN work_orders wo ON wbd.work_order_id = wo.id + SET wbd.tenant_id = wo.tenant_id + '); + + Schema::table('work_order_bending_details', function (Blueprint $table) { + $table->unsignedBigInteger('tenant_id')->nullable(false)->change(); + }); + + // 3. work_order_issues + Schema::table('work_order_issues', function (Blueprint $table) { + $table->unsignedBigInteger('tenant_id')->nullable()->after('id')->comment('테넌트ID'); + $table->index('tenant_id', 'idx_work_order_issues_tenant'); + }); + + DB::statement(' + UPDATE work_order_issues woi + JOIN work_orders wo ON woi.work_order_id = wo.id + SET woi.tenant_id = wo.tenant_id + '); + + Schema::table('work_order_issues', function (Blueprint $table) { + $table->unsignedBigInteger('tenant_id')->nullable(false)->change(); + }); + } + + public function down(): void + { + Schema::table('work_order_items', function (Blueprint $table) { + $table->dropIndex('idx_work_order_items_tenant'); + $table->dropColumn('tenant_id'); + }); + + Schema::table('work_order_bending_details', function (Blueprint $table) { + $table->dropIndex('idx_work_order_bending_details_tenant'); + $table->dropColumn('tenant_id'); + }); + + Schema::table('work_order_issues', function (Blueprint $table) { + $table->dropIndex('idx_work_order_issues_tenant'); + $table->dropColumn('tenant_id'); + }); + } +}; diff --git a/database/migrations/2025_01_09_100000_create_work_order_assignees_table.php b/database/migrations/2025_01_09_100000_create_work_order_assignees_table.php new file mode 100644 index 0000000..f176470 --- /dev/null +++ b/database/migrations/2025_01_09_100000_create_work_order_assignees_table.php @@ -0,0 +1,41 @@ +bigIncrements('id'); + $table->unsignedBigInteger('tenant_id')->comment('테넌트ID'); + $table->unsignedBigInteger('work_order_id')->comment('작업지시ID'); + $table->unsignedBigInteger('user_id')->comment('담당자ID'); + $table->boolean('is_primary')->default(false)->comment('주담당자 여부'); + $table->timestamps(); + + // Indexes + $table->unique(['work_order_id', 'user_id'], 'uq_work_order_assignees'); + $table->index(['tenant_id', 'work_order_id'], 'idx_wo_assignees_tenant_wo'); + $table->index(['tenant_id', 'user_id'], 'idx_wo_assignees_tenant_user'); + + // Foreign keys + $table->foreign('work_order_id') + ->references('id') + ->on('work_orders') + ->onDelete('cascade'); + }); + } + + public function down(): void + { + Schema::dropIfExists('work_order_assignees'); + } +};