feat(API): 작업일지 생성/조회 API 추가

- WorkOrderService: getWorkLogTemplate, getWorkLog, createWorkLog 메서드 추가
- WorkOrderController: 작업일지 3개 엔드포인트 추가
- 라우트: GET work-log-template, GET/POST work-log
- WorkOrder 모델: documents() MorphMany 관계 추가
- i18n: work_log_saved, no_work_log_template 메시지 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-12 09:39:48 +09:00
parent 911c8a36ad
commit 45dd18dbab
6 changed files with 367 additions and 0 deletions

View File

@@ -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'));
}
}

View File

@@ -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');
}
/**
* 출하 목록
*/

View File

@@ -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;
}
}

View File

@@ -424,6 +424,7 @@
'invalid_transition' => "상태를 ':from'에서 ':to'(으)로 변경할 수 없습니다. 허용된 상태: :allowed",
'assignee_required_for_linked' => '수주 연동 등록 시 담당자를 선택해주세요.',
'no_inspection_template' => '검사용 문서 양식이 설정되지 않았습니다.',
'no_work_log_template' => '작업일지 양식이 설정되지 않았습니다.',
],
// 검사 관련

View File

@@ -440,6 +440,7 @@
'material_input_registered' => '자재 투입이 등록되었습니다.',
'inspection_saved' => '검사 데이터가 저장되었습니다.',
'inspection_document_created' => '검사 문서가 생성되었습니다.',
'work_log_saved' => '작업일지가 저장되었습니다.',
],
// 검사 관리

View File

@@ -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 (작업실적 관리)