diff --git a/app/Http/Controllers/Api/V1/WorkOrderController.php b/app/Http/Controllers/Api/V1/WorkOrderController.php index 2e6d5a9..05f7d79 100644 --- a/app/Http/Controllers/Api/V1/WorkOrderController.php +++ b/app/Http/Controllers/Api/V1/WorkOrderController.php @@ -258,4 +258,34 @@ public function createInspectionDocument(Request $request, int $id) return $this->service->createInspectionDocument($id, $request->all()); }, __('message.work_order.inspection_document_created')); } + + /** + * 작업일지 양식 템플릿 조회 + */ + public function workLogTemplate(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->getWorkLogTemplate($id); + }, __('message.work_order.fetched')); + } + + /** + * 작업일지 조회 (기존 문서 + 템플릿 + 통계) + */ + public function workLog(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->getWorkLog($id); + }, __('message.work_order.fetched')); + } + + /** + * 작업일지 생성/수정 + */ + public function createWorkLog(Request $request, int $id) + { + return ApiResponse::handle(function () use ($request, $id) { + return $this->service->createWorkLog($id, $request->all()); + }, __('message.work_order.work_log_saved')); + } } diff --git a/app/Models/Production/WorkOrder.php b/app/Models/Production/WorkOrder.php index 3cc2ced..3d7480e 100644 --- a/app/Models/Production/WorkOrder.php +++ b/app/Models/Production/WorkOrder.php @@ -2,6 +2,7 @@ namespace App\Models\Production; +use App\Models\Documents\Document; use App\Models\Members\User; use App\Models\Orders\Order; use App\Models\Process; @@ -14,6 +15,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; +use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\SoftDeletes; /** @@ -203,6 +205,14 @@ public function stepProgress(): HasMany return $this->hasMany(WorkOrderStepProgress::class); } + /** + * 문서 (중간검사, 작업일지 등) + */ + public function documents(): MorphMany + { + return $this->morphMany(Document::class, 'linkable'); + } + /** * 출하 목록 */ diff --git a/app/Services/WorkOrderService.php b/app/Services/WorkOrderService.php index 813964a..f98f5af 100644 --- a/app/Services/WorkOrderService.php +++ b/app/Services/WorkOrderService.php @@ -2155,4 +2155,324 @@ public function getInspectionReport(int $workOrderId): array ], ]; } + + // ────────────────────────────────────────────────────────────── + // 작업일지 (Work Log) + // ────────────────────────────────────────────────────────────── + + /** + * 작업일지 양식 템플릿 조회 + * + * 공정(Process)의 work_log_template_id 기반으로 작업일지 양식을 조회하고 + * 기본필드에 작업지시 정보를 자동 매핑하여 반환 + */ + public function getWorkLogTemplate(int $workOrderId): array + { + $tenantId = $this->tenantId(); + + $workOrder = WorkOrder::where('tenant_id', $tenantId) + ->with([ + 'process.workLogTemplateRelation' => fn ($q) => $q->with([ + 'approvalLines', + 'basicFields', + 'columns', + ]), + 'salesOrder:id,order_no,client_name,site_name,delivery_date', + 'items:id,work_order_id,item_name,specification,quantity,unit,sort_order,status', + ]) + ->find($workOrderId); + + if (! $workOrder) { + throw new NotFoundHttpException(__('error.not_found')); + } + + $process = $workOrder->process; + $docTemplate = $process?->workLogTemplateRelation; + + if (! $docTemplate) { + return [ + 'work_order_id' => $workOrderId, + 'has_template' => false, + 'template' => null, + 'work_order_info' => $this->buildWorkOrderInfo($workOrder), + ]; + } + + $documentService = app(DocumentService::class); + $formattedTemplate = $documentService->formatTemplateForReact($docTemplate); + + // 기본필드 자동 매핑 (발주처, 현장명, LOT NO 등) + $autoValues = $this->buildWorkLogAutoValues($workOrder); + + return [ + 'work_order_id' => $workOrderId, + 'has_template' => true, + 'template' => $formattedTemplate, + 'auto_values' => $autoValues, + 'work_order_info' => $this->buildWorkOrderInfo($workOrder), + 'work_stats' => $this->calculateWorkStats($workOrder), + ]; + } + + /** + * 작업일지 조회 (기존 문서가 있으면 데이터 포함) + */ + public function getWorkLog(int $workOrderId): array + { + $tenantId = $this->tenantId(); + + $workOrder = WorkOrder::where('tenant_id', $tenantId) + ->with([ + 'process.workLogTemplateRelation' => fn ($q) => $q->with([ + 'approvalLines', + 'basicFields', + 'columns', + ]), + 'salesOrder:id,order_no,client_name,site_name,delivery_date', + 'items:id,work_order_id,item_name,specification,quantity,unit,sort_order,status', + ]) + ->find($workOrderId); + + if (! $workOrder) { + throw new NotFoundHttpException(__('error.not_found')); + } + + $process = $workOrder->process; + $templateId = $process?->work_log_template_id; + + // 기존 작업일지 문서 조회 + $document = null; + if ($templateId) { + $document = Document::query() + ->where('tenant_id', $tenantId) + ->where('template_id', $templateId) + ->where('linkable_type', 'work_order') + ->where('linkable_id', $workOrderId) + ->with(['approvals.user:id,name', 'data']) + ->latest() + ->first(); + } + + $docTemplate = $process?->workLogTemplateRelation; + $formattedTemplate = null; + if ($docTemplate) { + $documentService = app(DocumentService::class); + $formattedTemplate = $documentService->formatTemplateForReact($docTemplate); + } + + return [ + 'work_order_id' => $workOrderId, + 'has_template' => $docTemplate !== null, + 'template' => $formattedTemplate, + 'document' => $document ? [ + 'id' => $document->id, + 'document_no' => $document->document_no, + 'status' => $document->status, + 'submitted_at' => $document->submitted_at, + 'completed_at' => $document->completed_at, + 'approvals' => $document->approvals->map(fn ($a) => [ + 'id' => $a->id, + 'step' => $a->step, + 'role' => $a->role, + 'status' => $a->status, + 'user' => $a->user ? ['id' => $a->user->id, 'name' => $a->user->name] : null, + 'comment' => $a->comment, + 'acted_at' => $a->acted_at, + ])->toArray(), + 'data' => $document->data->map(fn ($d) => [ + 'field_key' => $d->field_key, + 'field_value' => $d->field_value, + 'section_id' => $d->section_id, + 'column_id' => $d->column_id, + 'row_index' => $d->row_index, + ])->toArray(), + ] : null, + 'auto_values' => $this->buildWorkLogAutoValues($workOrder), + 'work_order_info' => $this->buildWorkOrderInfo($workOrder), + 'work_stats' => $this->calculateWorkStats($workOrder), + ]; + } + + /** + * 작업일지 생성/수정 (Document 기반) + * + * 기존 DRAFT/REJECTED 문서가 있으면 update, 없으면 create + */ + public function createWorkLog(int $workOrderId, array $workLogData): array + { + $tenantId = $this->tenantId(); + + $workOrder = WorkOrder::where('tenant_id', $tenantId) + ->with(['process', 'salesOrder:id,order_no,client_name,site_name']) + ->find($workOrderId); + + if (! $workOrder) { + throw new NotFoundHttpException(__('error.not_found')); + } + + $process = $workOrder->process; + $templateId = $process?->work_log_template_id; + + if (! $templateId) { + throw new BadRequestHttpException(__('error.work_order.no_work_log_template')); + } + + $documentService = app(DocumentService::class); + + // 기존 DRAFT/REJECTED 문서 확인 + $existingDocument = Document::query() + ->where('tenant_id', $tenantId) + ->where('template_id', $templateId) + ->where('linkable_type', 'work_order') + ->where('linkable_id', $workOrderId) + ->whereIn('status', [Document::STATUS_DRAFT, Document::STATUS_REJECTED]) + ->latest() + ->first(); + + // 작업일지 데이터를 document_data 레코드로 변환 + $documentDataRecords = $this->transformWorkLogDataToRecords($workLogData, $workOrder); + + if ($existingDocument) { + $document = $documentService->update($existingDocument->id, [ + 'title' => $workLogData['title'] ?? $existingDocument->title, + 'data' => $documentDataRecords, + ]); + $action = 'work_log_updated'; + } else { + $document = $documentService->create([ + 'template_id' => $templateId, + 'title' => $workLogData['title'] ?? "작업일지 - {$workOrder->work_order_no}", + 'linkable_type' => 'work_order', + 'linkable_id' => $workOrderId, + 'data' => $documentDataRecords, + 'approvers' => $workLogData['approvers'] ?? [], + ]); + $action = 'work_log_created'; + } + + // 감사 로그 + $this->auditLogger->log( + $tenantId, + self::AUDIT_TARGET, + $workOrderId, + $action, + null, + ['document_id' => $document->id, 'document_no' => $document->document_no] + ); + + return [ + 'document_id' => $document->id, + 'document_no' => $document->document_no, + 'status' => $document->status, + 'is_new' => $action === 'work_log_created', + ]; + } + + /** + * 작업일지 기본필드 자동 매핑값 생성 + */ + private function buildWorkLogAutoValues(WorkOrder $workOrder): array + { + $salesOrder = $workOrder->salesOrder; + + return [ + '발주처' => $salesOrder?->client_name ?? '', + '현장명' => $salesOrder?->site_name ?? '', + '작업일자' => now()->format('Y-m-d'), + 'LOT NO' => '', + '납기일' => $salesOrder?->delivery_date?->format('Y-m-d') ?? '', + '작업지시번호' => $workOrder->work_order_no ?? '', + '수주일' => $salesOrder?->order_date ?? '', + '수주처' => $salesOrder?->client_name ?? '', + '담당자' => '', + '연락처' => '', + '제품 LOT NO' => '', + '생산담당자' => '', + '출고예정일' => $salesOrder?->delivery_date?->format('Y-m-d') ?? '', + ]; + } + + /** + * 작업 통계 계산 + */ + private function calculateWorkStats(WorkOrder $workOrder): array + { + $items = $workOrder->items; + + if (! $items || $items->isEmpty()) { + return [ + 'order_qty' => 0, + 'completed_qty' => 0, + 'in_progress_qty' => 0, + 'waiting_qty' => 0, + 'progress' => 0, + ]; + } + + $total = $items->count(); + $completed = $items->where('status', 'completed')->count(); + $inProgress = $items->where('status', 'in_progress')->count(); + $waiting = $total - $completed - $inProgress; + + return [ + 'order_qty' => $total, + 'completed_qty' => $completed, + 'in_progress_qty' => $inProgress, + 'waiting_qty' => $waiting, + 'progress' => $total > 0 ? round(($completed / $total) * 100, 1) : 0, + ]; + } + + /** + * 작업일지 데이터를 document_data 레코드로 변환 + * + * 입력 형식: + * basic_data: { '발주처': '...', '현장명': '...' } + * table_data: [{ item_name, floor_code, specification, quantity, status }] + * remarks: "특이사항" + */ + private function transformWorkLogDataToRecords(array $workLogData, WorkOrder $workOrder): array + { + $records = []; + + // 1. 기본필드 저장 + $basicData = $workLogData['basic_data'] ?? []; + foreach ($basicData as $key => $value) { + $records[] = [ + 'field_key' => "basic_{$key}", + 'field_value' => $value, + ]; + } + + // 2. 품목 테이블 데이터 (WorkOrderItem 기반) + $tableData = $workLogData['table_data'] ?? []; + foreach ($tableData as $index => $row) { + foreach ($row as $fieldKey => $fieldValue) { + $records[] = [ + 'row_index' => $index, + 'field_key' => "item_{$fieldKey}", + 'field_value' => is_array($fieldValue) ? json_encode($fieldValue) : (string) $fieldValue, + ]; + } + } + + // 3. 작업 통계 (자동 계산) + $stats = $this->calculateWorkStats($workOrder); + foreach ($stats as $key => $value) { + $records[] = [ + 'field_key' => "stats_{$key}", + 'field_value' => (string) $value, + ]; + } + + // 4. 특이사항 + if (isset($workLogData['remarks'])) { + $records[] = [ + 'field_key' => 'remarks', + 'field_value' => $workLogData['remarks'], + ]; + } + + return $records; + } } diff --git a/lang/ko/error.php b/lang/ko/error.php index 5bee37e..13e783e 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -424,6 +424,7 @@ 'invalid_transition' => "상태를 ':from'에서 ':to'(으)로 변경할 수 없습니다. 허용된 상태: :allowed", 'assignee_required_for_linked' => '수주 연동 등록 시 담당자를 선택해주세요.', 'no_inspection_template' => '검사용 문서 양식이 설정되지 않았습니다.', + 'no_work_log_template' => '작업일지 양식이 설정되지 않았습니다.', ], // 검사 관련 diff --git a/lang/ko/message.php b/lang/ko/message.php index 95db603..d26021d 100644 --- a/lang/ko/message.php +++ b/lang/ko/message.php @@ -440,6 +440,7 @@ 'material_input_registered' => '자재 투입이 등록되었습니다.', 'inspection_saved' => '검사 데이터가 저장되었습니다.', 'inspection_document_created' => '검사 문서가 생성되었습니다.', + 'work_log_saved' => '작업일지가 저장되었습니다.', ], // 검사 관리 diff --git a/routes/api/v1/production.php b/routes/api/v1/production.php index 218f0c8..2dc47d0 100644 --- a/routes/api/v1/production.php +++ b/routes/api/v1/production.php @@ -80,6 +80,11 @@ Route::get('/{id}/inspection-template', [WorkOrderController::class, 'inspectionTemplate'])->whereNumber('id')->name('v1.work-orders.inspection-template'); // 검사 문서 템플릿 조회 Route::get('/{id}/inspection-resolve', [WorkOrderController::class, 'resolveInspectionDocument'])->whereNumber('id')->name('v1.work-orders.inspection-resolve'); // 검사 문서 resolve (기존 문서/템플릿) Route::post('/{id}/inspection-document', [WorkOrderController::class, 'createInspectionDocument'])->whereNumber('id')->name('v1.work-orders.inspection-document'); // 검사 문서 생성/수정 + + // 작업일지 관리 + Route::get('/{id}/work-log-template', [WorkOrderController::class, 'workLogTemplate'])->whereNumber('id')->name('v1.work-orders.work-log-template'); // 작업일지 양식 조회 + Route::get('/{id}/work-log', [WorkOrderController::class, 'workLog'])->whereNumber('id')->name('v1.work-orders.work-log'); // 작업일지 조회 + Route::post('/{id}/work-log', [WorkOrderController::class, 'createWorkLog'])->whereNumber('id')->name('v1.work-orders.work-log.store'); // 작업일지 생성/수정 }); // Work Result API (작업실적 관리)