From 38d56aa564a5e36325571b82d23abbc943309133 Mon Sep 17 00:00:00 2001 From: kent Date: Tue, 13 Jan 2026 16:00:47 +0900 Subject: [PATCH] =?UTF-8?q?feat(work-order):=20=ED=92=88=EB=AA=A9=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20=EC=9E=91?= =?UTF-8?q?=EC=97=85=EC=A7=80=EC=8B=9C=20=EC=83=81=ED=83=9C=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WorkOrderItem 모델에 status 컬럼 및 상수 추가 (waiting/in_progress/completed) - 품목 상태 변경 API 엔드포인트 추가 (PATCH /work-orders/{id}/items/{itemId}/status) - syncWorkOrderStatusFromItems() 메서드로 품목→작업지시 상태 자동 동기화 - 품목 중 하나라도 in_progress → 작업지시 in_progress - 모든 품목 completed → 작업지시 completed - 모든 품목 waiting → 작업지시 waiting - 감사 로그: item_status_changed, status_synced_from_items 액션 추가 Co-Authored-By: Claude --- .../Api/V1/WorkOrderController.php | 10 ++ app/Models/Production/WorkOrderItem.php | 14 ++ app/Services/WorkOrderService.php | 169 ++++++++++++++++-- ...3_add_status_to_work_order_items_table.php | 29 +++ routes/api.php | 20 ++- 5 files changed, 224 insertions(+), 18 deletions(-) create mode 100644 database/migrations/2026_01_13_095513_add_status_to_work_order_items_table.php diff --git a/app/Http/Controllers/Api/V1/WorkOrderController.php b/app/Http/Controllers/Api/V1/WorkOrderController.php index 9138ff4..ed982ad 100644 --- a/app/Http/Controllers/Api/V1/WorkOrderController.php +++ b/app/Http/Controllers/Api/V1/WorkOrderController.php @@ -127,4 +127,14 @@ public function resolveIssue(int $workOrderId, int $issueId) return $this->service->resolveIssue($workOrderId, $issueId); }, __('message.work_order.issue_resolved')); } + + /** + * 품목 상태 변경 + */ + public function updateItemStatus(Request $request, int $workOrderId, int $itemId) + { + return ApiResponse::handle(function () use ($request, $workOrderId, $itemId) { + return $this->service->updateItemStatus($workOrderId, $itemId, $request->input('status')); + }, __('message.work_order.item_status_updated')); + } } diff --git a/app/Models/Production/WorkOrderItem.php b/app/Models/Production/WorkOrderItem.php index 9667b46..626c5b4 100644 --- a/app/Models/Production/WorkOrderItem.php +++ b/app/Models/Production/WorkOrderItem.php @@ -25,6 +25,20 @@ class WorkOrderItem extends Model 'quantity', 'unit', 'sort_order', + 'status', + ]; + + /** + * 품목 상태 상수 + */ + public const STATUS_WAITING = 'waiting'; + public const STATUS_IN_PROGRESS = 'in_progress'; + public const STATUS_COMPLETED = 'completed'; + + public const STATUSES = [ + self::STATUS_WAITING, + self::STATUS_IN_PROGRESS, + self::STATUS_COMPLETED, ]; protected $casts = [ diff --git a/app/Services/WorkOrderService.php b/app/Services/WorkOrderService.php index b5e95ed..85e9ed9 100644 --- a/app/Services/WorkOrderService.php +++ b/app/Services/WorkOrderService.php @@ -5,6 +5,7 @@ use App\Models\Production\WorkOrder; use App\Models\Production\WorkOrderAssignee; use App\Models\Production\WorkOrderBendingDetail; +use App\Models\Production\WorkOrderItem; use App\Services\Audit\AuditLogger; use Illuminate\Support\Facades\DB; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; @@ -29,7 +30,7 @@ public function index(array $params) $size = (int) ($params['size'] ?? 20); $q = trim((string) ($params['q'] ?? '')); $status = $params['status'] ?? null; - $processType = $params['process_type'] ?? null; + $processId = $params['process_id'] ?? null; $assigneeId = $params['assignee_id'] ?? null; $teamId = $params['team_id'] ?? null; $scheduledFrom = $params['scheduled_from'] ?? null; @@ -37,7 +38,7 @@ public function index(array $params) $query = WorkOrder::query() ->where('tenant_id', $tenantId) - ->with(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'salesOrder:id,order_no']); + ->with(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'salesOrder:id,order_no', 'process:id,process_name,process_code']); // 검색어 if ($q !== '') { @@ -52,9 +53,9 @@ public function index(array $params) $query->where('status', $status); } - // 공정유형 필터 - if ($processType !== null) { - $query->where('process_type', $processType); + // 공정 필터 (process_id) + if ($processId !== null) { + $query->where('process_id', $processId); } // 담당자 필터 @@ -116,7 +117,9 @@ public function show(int $id) 'assignee:id,name', 'assignees.user:id,name', 'team:id,name', - 'salesOrder:id,order_no,project_name', + 'salesOrder:id,order_no,site_name', + 'salesOrder.client:id,name', + 'process:id,process_name,process_code,work_steps', 'items', 'bendingDetail', 'issues' => fn ($q) => $q->orderByDesc('created_at'), @@ -156,6 +159,9 @@ public function store(array $data) $workOrder = WorkOrder::create($data); + // process 관계 로드 (isBending 체크용) + $workOrder->load('process:id,process_name,process_code'); + // 품목 저장 foreach ($items as $index => $item) { $item['tenant_id'] = $tenantId; @@ -164,7 +170,7 @@ public function store(array $data) } // 벤딩 상세 저장 (벤딩 공정인 경우) - if ($data['process_type'] === WorkOrder::PROCESS_BENDING && $bendingDetail) { + if ($workOrder->isBending() && $bendingDetail) { $bendingDetail['tenant_id'] = $tenantId; $workOrder->bendingDetail()->create($bendingDetail); } @@ -179,7 +185,7 @@ public function store(array $data) $workOrder->toArray() ); - return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'items', 'bendingDetail']); + return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'process:id,process_name,process_code', 'items', 'bendingDetail']); }); } @@ -191,7 +197,9 @@ public function update(int $id, array $data) $tenantId = $this->tenantId(); $userId = $this->apiUserId(); - $workOrder = WorkOrder::where('tenant_id', $tenantId)->find($id); + $workOrder = WorkOrder::where('tenant_id', $tenantId) + ->with('process:id,process_name,process_code') + ->find($id); if (! $workOrder) { throw new NotFoundHttpException(__('error.not_found')); } @@ -259,8 +267,8 @@ public function update(int $id, array $data) ); } - // 벤딩 상세 업데이트 - if ($bendingDetail !== null && $workOrder->process_type === WorkOrder::PROCESS_BENDING) { + // 벤딩 상세 업데이트 (벤딩 공정인 경우에만) + if ($bendingDetail !== null && $workOrder->isBending()) { $bendingDetail['tenant_id'] = $workOrder->tenant_id; $workOrder->bendingDetail()->updateOrCreate( ['work_order_id' => $workOrder->id], @@ -278,7 +286,7 @@ public function update(int $id, array $data) $workOrder->fresh()->toArray() ); - return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'items', 'bendingDetail']); + return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'process:id,process_name,process_code', 'items', 'bendingDetail']); }); } @@ -378,7 +386,7 @@ public function updateStatus(int $id, string $status) ['status' => $status] ); - return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name']); + return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'process:id,process_name,process_code']); } /** @@ -454,7 +462,7 @@ public function assign(int $id, array $data) ] ); - return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name']); + return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'process:id,process_name,process_code']); }); } @@ -465,12 +473,14 @@ public function toggleBendingField(int $id, string $field) { $tenantId = $this->tenantId(); - $workOrder = WorkOrder::where('tenant_id', $tenantId)->find($id); + $workOrder = WorkOrder::where('tenant_id', $tenantId) + ->with('process:id,process_name,process_code') + ->find($id); if (! $workOrder) { throw new NotFoundHttpException(__('error.not_found')); } - if ($workOrder->process_type !== WorkOrder::PROCESS_BENDING) { + if (! $workOrder->isBending()) { throw new BadRequestHttpException(__('error.work_order.not_bending_process')); } @@ -588,4 +598,131 @@ private function generateWorkOrderNo(int $tenantId): string return sprintf('%s%s%04d', $prefix, $date, $seq); } + + /** + * 품목 상태 변경 + */ + public function updateItemStatus(int $workOrderId, int $itemId, string $status) + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $workOrder = WorkOrder::where('tenant_id', $tenantId)->find($workOrderId); + if (! $workOrder) { + throw new NotFoundHttpException(__('error.not_found')); + } + + $item = $workOrder->items()->find($itemId); + if (! $item) { + throw new NotFoundHttpException(__('error.not_found')); + } + + // 상태 유효성 검증 + if (! in_array($status, WorkOrderItem::STATUSES)) { + throw new BadRequestHttpException(__('error.invalid_status')); + } + + $beforeStatus = $item->status; + $item->status = $status; + $item->save(); + + // 감사 로그 + $this->auditLogger->log( + $tenantId, + self::AUDIT_TARGET, + $workOrderId, + 'item_status_changed', + ['item_id' => $itemId, 'status' => $beforeStatus], + ['item_id' => $itemId, 'status' => $status] + ); + + // 작업지시 상태 자동 연동 + $workOrderStatusChanged = $this->syncWorkOrderStatusFromItems($workOrder); + + // 품목과 함께 작업지시 상태도 반환 + return [ + 'item' => $item, + 'work_order_status' => $workOrder->fresh()->status, + 'work_order_status_changed' => $workOrderStatusChanged, + ]; + } + + /** + * 품목 상태 기반으로 작업지시 상태 자동 동기화 + * + * 규칙: + * - 품목 중 하나라도 in_progress → 작업지시 in_progress + * - 모든 품목이 completed → 작업지시 completed + * - 모든 품목이 waiting → 작업지시 waiting (단, waiting 이상인 경우만) + * + * @return bool 상태 변경 여부 + */ + private function syncWorkOrderStatusFromItems(WorkOrder $workOrder): bool + { + // 품목이 없으면 동기화하지 않음 + $items = $workOrder->items()->get(); + if ($items->isEmpty()) { + return false; + } + + // 작업 가능 상태가 아니면 동기화하지 않음 (unassigned, pending은 제외) + $syncableStatuses = [ + WorkOrder::STATUS_WAITING, + WorkOrder::STATUS_IN_PROGRESS, + WorkOrder::STATUS_COMPLETED, + ]; + if (! in_array($workOrder->status, $syncableStatuses)) { + return false; + } + + // 품목 상태 집계 + $statusCounts = $items->groupBy('status')->map->count(); + $totalItems = $items->count(); + + $waitingCount = $statusCounts->get(WorkOrderItem::STATUS_WAITING, 0); + $inProgressCount = $statusCounts->get(WorkOrderItem::STATUS_IN_PROGRESS, 0); + $completedCount = $statusCounts->get(WorkOrderItem::STATUS_COMPLETED, 0); + + // 새 상태 결정 + $newStatus = null; + if ($inProgressCount > 0) { + // 하나라도 진행중이면 작업지시도 진행중 + $newStatus = WorkOrder::STATUS_IN_PROGRESS; + } elseif ($completedCount === $totalItems) { + // 모두 완료면 작업지시도 완료 + $newStatus = WorkOrder::STATUS_COMPLETED; + } elseif ($waitingCount === $totalItems) { + // 모두 대기면 작업지시도 대기 + $newStatus = WorkOrder::STATUS_WAITING; + } + + // 상태가 변경되어야 하고, 현재와 다른 경우에만 업데이트 + if ($newStatus && $newStatus !== $workOrder->status) { + $oldStatus = $workOrder->status; + $workOrder->status = $newStatus; + + // 상태에 따른 타임스탬프 업데이트 + if ($newStatus === WorkOrder::STATUS_IN_PROGRESS && ! $workOrder->started_at) { + $workOrder->started_at = now(); + } elseif ($newStatus === WorkOrder::STATUS_COMPLETED) { + $workOrder->completed_at = now(); + } + + $workOrder->save(); + + // 상태 변경 감사 로그 + $this->auditLogger->log( + $workOrder->tenant_id, + self::AUDIT_TARGET, + $workOrder->id, + 'status_synced_from_items', + ['status' => $oldStatus], + ['status' => $newStatus] + ); + + return true; + } + + return false; + } } diff --git a/database/migrations/2026_01_13_095513_add_status_to_work_order_items_table.php b/database/migrations/2026_01_13_095513_add_status_to_work_order_items_table.php new file mode 100644 index 0000000..1f04d0e --- /dev/null +++ b/database/migrations/2026_01_13_095513_add_status_to_work_order_items_table.php @@ -0,0 +1,29 @@ +string('status', 20)->default('waiting')->after('sort_order') + ->comment('품목 상태: waiting=대기, in_progress=작업중, completed=완료'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('work_order_items', function (Blueprint $table) { + $table->dropColumn('status'); + }); + } +}; diff --git a/routes/api.php b/routes/api.php index 9ee02e0..73d01a2 100644 --- a/routes/api.php +++ b/routes/api.php @@ -58,6 +58,7 @@ // use App\Http\Controllers\Api\V1\MaterialController; // REMOVED: materials 테이블 삭제됨 use App\Http\Controllers\Api\V1\ItemsController; use App\Http\Controllers\Api\V1\ItemsFileController; +use App\Http\Controllers\Api\V1\LaborController; use App\Http\Controllers\Api\V1\LeaveController; use App\Http\Controllers\Api\V1\LeavePolicyController; use App\Http\Controllers\Api\V1\LoanController; @@ -69,10 +70,9 @@ use App\Http\Controllers\Api\V1\PayrollController; use App\Http\Controllers\Api\V1\PermissionController; use App\Http\Controllers\Api\V1\PlanController; -use App\Http\Controllers\Api\V1\PopupController; // use App\Http\Controllers\Api\V1\ProductBomItemController; // REMOVED: products 테이블 삭제됨 // use App\Http\Controllers\Api\V1\ProductController; // REMOVED: products 테이블 삭제됨 -use App\Http\Controllers\Api\V1\LaborController; +use App\Http\Controllers\Api\V1\PopupController; use App\Http\Controllers\Api\V1\PositionController; use App\Http\Controllers\Api\V1\PostController; use App\Http\Controllers\Api\V1\PricingController; @@ -89,6 +89,7 @@ use App\Http\Controllers\Api\V1\SalaryController; use App\Http\Controllers\Api\V1\SaleController; use App\Http\Controllers\Api\V1\ShipmentController; +use App\Http\Controllers\Api\V1\SiteBriefingController; use App\Http\Controllers\Api\V1\SiteController; use App\Http\Controllers\Api\V1\StockController; use App\Http\Controllers\Api\V1\SubscriptionController; @@ -425,6 +426,17 @@ Route::delete('/{id}', [SiteController::class, 'destroy'])->whereNumber('id')->name('v1.sites.destroy'); }); + // Site Briefing API (현장설명회 관리) + Route::prefix('site-briefings')->group(function () { + Route::get('', [SiteBriefingController::class, 'index'])->name('v1.site-briefings.index'); + Route::post('', [SiteBriefingController::class, 'store'])->name('v1.site-briefings.store'); + Route::get('/stats', [SiteBriefingController::class, 'stats'])->name('v1.site-briefings.stats'); + Route::delete('/bulk', [SiteBriefingController::class, 'bulkDestroy'])->name('v1.site-briefings.bulk-destroy'); + Route::get('/{id}', [SiteBriefingController::class, 'show'])->whereNumber('id')->name('v1.site-briefings.show'); + Route::put('/{id}', [SiteBriefingController::class, 'update'])->whereNumber('id')->name('v1.site-briefings.update'); + Route::delete('/{id}', [SiteBriefingController::class, 'destroy'])->whereNumber('id')->name('v1.site-briefings.destroy'); + }); + // Construction API (시공관리) Route::prefix('construction')->group(function () { // Contract API (계약관리) @@ -1105,6 +1117,7 @@ // 견적 관리 API Route::prefix('estimates')->group(function () { Route::get('/', [EstimateController::class, 'index'])->name('v1.estimates.index'); // 견적 목록 + Route::get('/stats', [EstimateController::class, 'stats'])->name('v1.estimates.stats'); // 견적 통계 Route::post('/', [EstimateController::class, 'store'])->name('v1.estimates.store'); // 견적 생성 Route::get('/{id}', [EstimateController::class, 'show'])->name('v1.estimates.show'); // 견적 상세 Route::put('/{id}', [EstimateController::class, 'update'])->name('v1.estimates.update'); // 견적 수정 @@ -1170,6 +1183,9 @@ // 이슈 관리 Route::post('/{id}/issues', [WorkOrderController::class, 'addIssue'])->whereNumber('id')->name('v1.work-orders.issues.store'); // 이슈 등록 Route::patch('/{id}/issues/{issueId}/resolve', [WorkOrderController::class, 'resolveIssue'])->whereNumber('id')->name('v1.work-orders.issues.resolve'); // 이슈 해결 + + // 품목 상태 변경 + Route::patch('/{id}/items/{itemId}/status', [WorkOrderController::class, 'updateItemStatus'])->whereNumber('id')->whereNumber('itemId')->name('v1.work-orders.items.status'); }); // 작업실적 관리 API (Production)