feat: [생산지시] 전용 API + 자재투입/공정 개선

- ProductionOrder 전용 엔드포인트 (목록/통계/상세)
- 재고생산 보조공정 일반 워크플로우에서 분리
- 자재투입 replace 모드 + bom_group_key 개별 저장
- 공정단계 options 컬럼 추가 (검사 설정/범위)
- 셔터박스 prefix isStandard 파라미터 제거

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 02:57:59 +09:00
parent f9cd219f67
commit 4dd38ab14d
17 changed files with 1335 additions and 101 deletions

View File

@@ -5,6 +5,7 @@
use App\Models\Documents\Document;
use App\Models\Documents\DocumentTemplate;
use App\Models\Orders\Order;
use App\Models\Process;
use App\Models\Production\WorkOrder;
use App\Models\Production\WorkOrderAssignee;
use App\Models\Production\WorkOrderBendingDetail;
@@ -258,6 +259,17 @@ public function store(array $data)
$salesOrderId = $data['sales_order_id'] ?? null;
unset($data['items'], $data['bending_detail']);
// 공정의 is_auxiliary 플래그를 WO options에 복사
if (! empty($data['process_id'])) {
$process = \App\Models\Process::find($data['process_id']);
if ($process && ! empty($process->options['is_auxiliary'])) {
$opts = $data['options'] ?? [];
$opts = is_array($opts) ? $opts : (json_decode($opts, true) ?? []);
$opts['is_auxiliary'] = true;
$data['options'] = $opts;
}
}
$workOrder = WorkOrder::create($data);
// process 관계 로드 (isBending 체크용)
@@ -285,6 +297,8 @@ public function store(array $data)
$options = array_filter([
'floor' => $orderItem->floor_code,
'code' => $orderItem->symbol_code,
'product_code' => ! empty($nodeOptions['product_code']) ? $nodeOptions['product_code'] : null,
'product_name' => ! empty($nodeOptions['product_name']) ? $nodeOptions['product_name'] : null,
'width' => $nodeOptions['width'] ?? $nodeOptions['open_width'] ?? null,
'height' => $nodeOptions['height'] ?? $nodeOptions['open_height'] ?? null,
'cutting_info' => $nodeOptions['cutting_info'] ?? null,
@@ -812,6 +826,11 @@ private function syncOrderStatus(WorkOrder $workOrder, int $tenantId): void
return;
}
// 보조 공정(재고생산 등)은 수주 상태에 영향 주지 않음
if ($this->isAuxiliaryWorkOrder($workOrder)) {
return;
}
$order = Order::where('tenant_id', $tenantId)->find($workOrder->sales_order_id);
if (! $order) {
return;
@@ -847,6 +866,47 @@ private function syncOrderStatus(WorkOrder $workOrder, int $tenantId): void
);
}
/**
* 자재 투입 시 작업지시가 대기 상태이면 자동으로 진행중으로 전환
*
* pending/waiting 상태에서 첫 자재 투입이 발생하면
* 작업지시 → in_progress, 수주 → IN_PRODUCTION 으로 자동 전환
*/
private function autoStartWorkOrderOnMaterialInput(WorkOrder $workOrder, int $tenantId): void
{
// 보조 공정(재고생산 등)은 WO 자체는 진행중으로 전환하되, 수주 상태는 변경하지 않음
$isAuxiliary = $this->isAuxiliaryWorkOrder($workOrder);
// 아직 진행 전인 상태에서만 자동 전환 (자재투입 = 실질적 작업 시작)
if (! in_array($workOrder->status, [
WorkOrder::STATUS_UNASSIGNED,
WorkOrder::STATUS_PENDING,
WorkOrder::STATUS_WAITING,
])) {
return;
}
$oldStatus = $workOrder->status;
$workOrder->status = WorkOrder::STATUS_IN_PROGRESS;
$workOrder->updated_by = $this->apiUserId();
$workOrder->save();
// 감사 로그
$this->auditLogger->log(
$tenantId,
self::AUDIT_TARGET,
$workOrder->id,
'status_auto_changed_on_material_input',
['status' => $oldStatus],
['status' => WorkOrder::STATUS_IN_PROGRESS]
);
// 보조 공정이 아닌 경우만 수주 상태 동기화
if (! $isAuxiliary) {
$this->syncOrderStatus($workOrder, $tenantId);
}
}
/**
* 작업지시 품목에 결과 데이터 저장
*/
@@ -887,6 +947,16 @@ private function saveItemResults(WorkOrder $workOrder, ?array $resultData, int $
}
}
/**
* 보조 공정(재고생산 등) 여부 판단
*/
private function isAuxiliaryWorkOrder(WorkOrder $workOrder): bool
{
$options = is_array($workOrder->options) ? $workOrder->options : (json_decode($workOrder->options, true) ?? []);
return ! empty($options['is_auxiliary']);
}
/**
* LOT 번호 생성
*/
@@ -1455,6 +1525,9 @@ public function registerMaterialInput(int $workOrderId, array $inputs): array
$totalCount = array_sum(array_column($delegatedResults, 'material_count'));
$allResults = array_merge(...array_map(fn ($r) => $r['input_results'], $delegatedResults));
// 자재 투입 시 작업지시가 대기 상태면 자동으로 진행중으로 전환
$this->autoStartWorkOrderOnMaterialInput($workOrder, $tenantId);
return [
'work_order_id' => $workOrderId,
'material_count' => $totalCount,
@@ -1533,6 +1606,9 @@ public function registerMaterialInput(int $workOrderId, array $inputs): array
$allResults = array_merge($allResults, $dr['input_results']);
}
// 자재 투입 시 작업지시가 대기 상태면 자동으로 진행중으로 전환
$this->autoStartWorkOrderOnMaterialInput($workOrder, $tenantId);
return [
'work_order_id' => $workOrderId,
'material_count' => count($allResults),
@@ -1834,25 +1910,25 @@ public function getMaterialInputLots(int $workOrderId): array
->orderBy('created_at')
->get(['id', 'lot_no', 'item_code', 'item_name', 'qty', 'stock_lot_id', 'created_at']);
// LOT 번호별 그룹핑 (동일 LOT에서 여러번 투입 가능)
$lotMap = [];
// 품목코드별 그룹핑 (작업일지에서 item_code → lot_no 매핑에 사용)
$itemMap = [];
foreach ($transactions as $tx) {
$lotNo = $tx->lot_no;
if (! isset($lotMap[$lotNo])) {
$lotMap[$lotNo] = [
'lot_no' => $lotNo,
'item_code' => $tx->item_code,
$itemCode = $tx->item_code;
if (! isset($itemMap[$itemCode])) {
$itemMap[$itemCode] = [
'item_code' => $itemCode,
'lot_no' => $tx->lot_no,
'item_name' => $tx->item_name,
'total_qty' => 0,
'input_count' => 0,
'first_input_at' => $tx->created_at,
];
}
$lotMap[$lotNo]['total_qty'] += abs((float) $tx->qty);
$lotMap[$lotNo]['input_count']++;
$itemMap[$itemCode]['total_qty'] += abs((float) $tx->qty);
$itemMap[$itemCode]['input_count']++;
}
return array_values($lotMap);
return array_values($itemMap);
}
// ──────────────────────────────────────────────────────────────
@@ -1887,6 +1963,16 @@ public function storeItemInspection(int $workOrderId, int $itemId, array $data):
$item->setInspectionData($inspectionData);
$item->save();
// 절곡 공정: 수주 단위 검사 → 동일 작업지시의 모든 item에 검사 데이터 복제
$processType = $data['process_type'] ?? '';
if (in_array($processType, ['bending', 'bending_wip'])) {
$otherItems = $workOrder->items()->where('id', '!=', $itemId)->get();
foreach ($otherItems as $otherItem) {
$otherItem->setInspectionData($inspectionData);
$otherItem->save();
}
}
// 감사 로그
$this->auditLogger->log(
$tenantId,
@@ -1947,6 +2033,293 @@ public function getInspectionData(int $workOrderId, array $params = []): array
];
}
// ──────────────────────────────────────────────────────────────
// 검사 설정 (inspection-config)
// ──────────────────────────────────────────────────────────────
/**
* 절곡 검사 기준 간격 프로파일 (5130 레거시 기준 S1/S2/S3 마감유형별)
*
* S1: KSS01 계열 (KQTS01 포함)
* S2: KSS02 계열 (EGI 마감 포함)
* S3: KWE01/KSE01 + SUS 별도마감
*
* 향후 DB 테이블 또는 테넌트 설정으로 이관 가능
*/
private const BENDING_GAP_PROFILES = [
'S1' => [
'guide_rail_wall' => [
'name' => '가이드레일(벽면형)',
'gap_points' => [
['point' => '(1)', 'design_value' => '30'],
['point' => '(2)', 'design_value' => '80'],
['point' => '(3)', 'design_value' => '45'],
['point' => '(4)', 'design_value' => '40'],
],
],
'guide_rail_side' => [
'name' => '가이드레일(측면형)',
'gap_points' => [
['point' => '(1)', 'design_value' => '30'],
['point' => '(2)', 'design_value' => '70'],
['point' => '(3)', 'design_value' => '45'],
['point' => '(4)', 'design_value' => '35'],
['point' => '(5)', 'design_value' => '95'],
['point' => '(6)', 'design_value' => '90'],
],
],
'bottom_bar' => [
'name' => '하단마감재',
'gap_points' => [
['point' => '(1)', 'design_value' => '60'],
],
],
],
'S2' => [
'guide_rail_wall' => [
'name' => '가이드레일(벽면형)',
'gap_points' => [
['point' => '(1)', 'design_value' => '30'],
['point' => '(2)', 'design_value' => '80'],
['point' => '(3)', 'design_value' => '45'],
],
],
'guide_rail_side' => [
'name' => '가이드레일(측면형)',
'gap_points' => [
['point' => '(1)', 'design_value' => '30'],
['point' => '(2)', 'design_value' => '70'],
['point' => '(3)', 'design_value' => '45'],
['point' => '(4)', 'design_value' => '35'],
['point' => '(5)', 'design_value' => '95'],
],
],
'bottom_bar' => [
'name' => '하단마감재',
'gap_points' => [
['point' => '(1)', 'design_value' => '60'],
],
],
],
'S3' => [
'guide_rail_wall' => [
'name' => '가이드레일(벽면형·별도마감)',
'gap_points' => [
['point' => '(1)', 'design_value' => '30'],
['point' => '(2)', 'design_value' => '80'],
['point' => '(3)', 'design_value' => '45'],
['point' => '(4)', 'design_value' => '40'],
['point' => '(5)', 'design_value' => '34'],
],
],
'guide_rail_side' => [
'name' => '가이드레일(측면형·별도마감)',
'gap_points' => [
['point' => '(1)', 'design_value' => '30'],
['point' => '(2)', 'design_value' => '70'],
['point' => '(3)', 'design_value' => '80'],
['point' => '(4)', 'design_value' => '45'],
['point' => '(5)', 'design_value' => '40'],
['point' => '(6)', 'design_value' => '34'],
['point' => '(7)', 'design_value' => '74'],
],
],
'bottom_bar' => [
'name' => '하단마감재(별도마감)',
'gap_points' => [
['point' => '(1)', 'design_value' => '60'],
['point' => '(2)', 'design_value' => '64'],
],
],
],
'common' => [
'case_box' => [
'name' => '케이스',
'gap_points' => [
['point' => '(1)', 'design_value' => '550'],
['point' => '(2)', 'design_value' => '50'],
['point' => '(3)', 'design_value' => '385'],
['point' => '(4)', 'design_value' => '50'],
['point' => '(5)', 'design_value' => '410'],
],
],
'smoke_w50' => [
'name' => '연기차단재 W50',
'gap_points' => [
['point' => '(1)', 'design_value' => '50'],
['point' => '(2)', 'design_value' => '12'],
],
],
'smoke_w80' => [
'name' => '연기차단재 W80',
'gap_points' => [
['point' => '(1)', 'design_value' => '80'],
['point' => '(2)', 'design_value' => '12'],
],
],
],
];
/**
* 작업지시의 검사 설정 조회 (공정 자동 판별 + 구성품 목록)
*
* 절곡 공정: bending_info 기반으로 검사 대상 구성품 + 간격 기준치 반환
* 기타 공정: items 빈 배열 (스크린/슬랫은 별도 구성품 없음)
*/
public function getInspectionConfig(int $workOrderId): array
{
$workOrder = WorkOrder::where('tenant_id', $this->tenantId())
->with(['process', 'items' => fn ($q) => $q->orderBy('sort_order')])
->findOrFail($workOrderId);
$process = $workOrder->process;
$processType = $this->resolveInspectionProcessType($process);
$firstItem = $workOrder->items->first();
$productCode = $firstItem?->options['product_code'] ?? null;
$templateId = $process?->document_template_id;
$items = [];
$finishingType = null;
if ($processType === 'bending') {
$finishingType = $this->resolveFinishingType($productCode);
$items = $this->buildBendingInspectionItems($firstItem);
}
return [
'work_order_id' => $workOrder->id,
'process_type' => $processType,
'product_code' => $productCode,
'finishing_type' => $finishingType,
'template_id' => $templateId,
'items' => $items,
];
}
/**
* 공정명 → 검사 공정 타입 변환
*/
private function resolveInspectionProcessType(?Process $process): string
{
if (! $process) {
return 'unknown';
}
return match ($process->process_name) {
'스크린' => 'screen',
'슬랫' => 'slat',
'절곡' => 'bending',
default => strtolower($process->process_code ?? 'unknown'),
};
}
/**
* 제품코드에서 마감유형(S1/S2/S3) 결정 (5130 레거시 기준)
*
* KSS01, KQTS01 → S1
* KSS02 (및 EGI 마감) → S2
* KWE01/KSE01 + SUS → S3
*/
private function resolveFinishingType(?string $productCode): string
{
if (! $productCode) {
return 'S1';
}
// FG-{model}-{type}-{material} 형식에서 모델코드와 재질 추출
$parts = explode('-', $productCode);
$modelCode = $parts[1] ?? '';
$material = $parts[3] ?? '';
// SUS 재질 + KWE/KSE 모델 → S3 (별도마감)
if (stripos($material, 'SUS') !== false && (str_starts_with($modelCode, 'KWE') || str_starts_with($modelCode, 'KSE'))) {
return 'S3';
}
return match (true) {
str_starts_with($modelCode, 'KSS01'), str_starts_with($modelCode, 'KQTS') => 'S1',
str_starts_with($modelCode, 'KSS02') => 'S2',
str_starts_with($modelCode, 'KWE'), str_starts_with($modelCode, 'KSE') => 'S2', // EGI마감 = S2
default => 'S2', // 기본값: S2 (5130 기준 EGI와 동일)
};
}
/**
* 절곡 bending_info에서 검사 대상 구성품 + 간격 기준치 빌드
* 마감유형(S1/S2/S3)에 따라 gap_points가 달라짐
*/
private function buildBendingInspectionItems(?WorkOrderItem $firstItem): array
{
if (! $firstItem) {
return [];
}
$productCode = $firstItem->options['product_code'] ?? null;
$finishingType = $this->resolveFinishingType($productCode);
$typeProfiles = self::BENDING_GAP_PROFILES[$finishingType] ?? self::BENDING_GAP_PROFILES['S1'];
$commonProfiles = self::BENDING_GAP_PROFILES['common'];
$bendingInfo = $firstItem->options['bending_info'] ?? null;
$items = [];
// 가이드레일 벽면 (벽면형 또는 혼합형)
$guideRail = $bendingInfo['guideRail'] ?? null;
$hasWall = ! $bendingInfo || ($guideRail && ($guideRail['wall'] ?? false));
$hasSide = $guideRail && ($guideRail['side'] ?? false);
if ($hasWall) {
$profile = $typeProfiles['guide_rail_wall'];
$items[] = [
'id' => 'guide_rail_wall',
'name' => $profile['name'],
'gap_points' => $profile['gap_points'],
];
}
if ($hasSide) {
$profile = $typeProfiles['guide_rail_side'];
$items[] = [
'id' => 'guide_rail_side',
'name' => $profile['name'],
'gap_points' => $profile['gap_points'],
];
}
// 하단마감재 (항상 포함, 마감유형별 gap_points 다름)
$profile = $typeProfiles['bottom_bar'];
$items[] = [
'id' => 'bottom_bar',
'name' => $profile['name'],
'gap_points' => $profile['gap_points'],
];
// 케이스 (항상 포함, 공통)
$profile = $commonProfiles['case_box'];
$items[] = [
'id' => 'case_box',
'name' => $profile['name'],
'gap_points' => $profile['gap_points'],
];
// 연기차단재 W50 (항상 포함, 공통)
$profile = $commonProfiles['smoke_w50'];
$items[] = [
'id' => 'smoke_w50',
'name' => $profile['name'],
'gap_points' => $profile['gap_points'],
];
// 연기차단재 W80 (항상 포함, 공통)
$profile = $commonProfiles['smoke_w80'];
$items[] = [
'id' => 'smoke_w80',
'name' => $profile['name'],
'gap_points' => $profile['gap_points'],
];
return $items;
}
// ──────────────────────────────────────────────────────────────
// 검사 문서 템플릿 연동
// ──────────────────────────────────────────────────────────────
@@ -2058,11 +2431,26 @@ public function resolveInspectionDocument(int $workOrderId, array $params = []):
->latest()
->first();
// Lazy Snapshot 대상: rendered_html이 없는 문서 (상태 무관)
$snapshotDocumentId = null;
$snapshotCandidate = Document::query()
->where('tenant_id', $tenantId)
->where('template_id', $templateId)
->where('linkable_type', 'work_order')
->where('linkable_id', $workOrderId)
->whereNull('rendered_html')
->latest()
->value('id');
if ($snapshotCandidate) {
$snapshotDocumentId = $snapshotCandidate;
}
return [
'work_order_id' => $workOrderId,
'template_id' => $templateId,
'template' => $formattedTemplate,
'existing_document' => $existingDocument,
'snapshot_document_id' => $snapshotDocumentId,
'work_order_info' => $this->buildWorkOrderInfo($workOrder),
];
}
@@ -2094,80 +2482,92 @@ public function createInspectionDocument(int $workOrderId, array $inspectionData
throw new BadRequestHttpException(__('error.work_order.no_inspection_template'));
}
$documentService = app(DocumentService::class);
return DB::transaction(function () use ($workOrder, $workOrderId, $tenantId, $templateId, $inspectionData) {
// 동시 생성 방지: 동일 작업지시에 대한 락
$workOrder->lockForUpdate();
// 기존 DRAFT/REJECTED 문서가 있으면 update
$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();
$documentService = app(DocumentService::class);
// ★ 원본 기반 동기화: work_order_items.options.inspection_data에서 전체 수집
$rawItems = [];
foreach ($workOrder->items as $item) {
$inspData = $item->getInspectionData();
if ($inspData) {
$rawItems[] = $inspData;
// 기존 DRAFT/REJECTED 문서가 있으면 update
$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();
// ★ 원본 기반 동기화: work_order_items.options.inspection_data에서 전체 수집
$rawItems = [];
foreach ($workOrder->items as $item) {
$inspData = $item->getInspectionData();
if ($inspData) {
$rawItems[] = $inspData;
}
}
}
$documentDataRecords = $this->transformInspectionDataToDocumentRecords($rawItems, $templateId);
$documentDataRecords = $this->transformInspectionDataToDocumentRecords($rawItems, $templateId);
// 기존 문서의 기본필드(bf_*) 보존
if ($existingDocument) {
$existingBasicFields = $existingDocument->data()
->whereNull('section_id')
->where('field_key', 'LIKE', 'bf_%')
->get()
->map(fn ($d) => [
'section_id' => null,
'column_id' => null,
'row_index' => $d->row_index,
'field_key' => $d->field_key,
'field_value' => $d->field_value,
])
->toArray();
// 기존 문서의 기본필드(bf_*) 보존
if ($existingDocument) {
$existingBasicFields = $existingDocument->data()
->whereNull('section_id')
->where('field_key', 'LIKE', 'bf_%')
->get()
->map(fn ($d) => [
'section_id' => null,
'column_id' => null,
'row_index' => $d->row_index,
'field_key' => $d->field_key,
'field_value' => $d->field_value,
])
->toArray();
$document = $documentService->update($existingDocument->id, [
'title' => $inspectionData['title'] ?? $existingDocument->title,
'data' => array_merge($existingBasicFields, $documentDataRecords),
]);
$updateData = [
'title' => $inspectionData['title'] ?? $existingDocument->title,
'data' => array_merge($existingBasicFields, $documentDataRecords),
];
if (isset($inspectionData['rendered_html'])) {
$updateData['rendered_html'] = $inspectionData['rendered_html'];
}
$document = $documentService->update($existingDocument->id, $updateData);
$action = 'inspection_document_updated';
} else {
$documentData = [
'template_id' => $templateId,
'title' => $inspectionData['title'] ?? "중간검사성적서 - {$workOrder->work_order_no}",
'linkable_type' => 'work_order',
'linkable_id' => $workOrderId,
'data' => $documentDataRecords,
'approvers' => $inspectionData['approvers'] ?? [],
$action = 'inspection_document_updated';
} else {
$documentData = [
'template_id' => $templateId,
'title' => $inspectionData['title'] ?? "중간검사성적서 - {$workOrder->work_order_no}",
'linkable_type' => 'work_order',
'linkable_id' => $workOrderId,
'data' => $documentDataRecords,
'approvers' => $inspectionData['approvers'] ?? [],
];
if (isset($inspectionData['rendered_html'])) {
$documentData['rendered_html'] = $inspectionData['rendered_html'];
}
$document = $documentService->create($documentData);
$action = 'inspection_document_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 === 'inspection_document_created',
];
$document = $documentService->create($documentData);
$action = 'inspection_document_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 === 'inspection_document_created',
];
});
}
/**
@@ -2197,10 +2597,107 @@ private function transformInspectionDataToDocumentRecords(array $rawItems, int $
], $rawItems);
}
// 절곡 products 배열 감지 → bending 전용 EAV 레코드 생성
$productsItem = collect($rawItems)->first(fn ($item) => isset($item['products']) && is_array($item['products']));
if ($productsItem) {
return $this->transformBendingProductsToRecords($productsItem, $templateId);
}
// 레거시 형식: templateValues/values 기반 → 정규화 변환
return $this->normalizeOldFormatRecords($rawItems, $templateId);
}
/**
* 절곡 products 배열 → bending 전용 EAV 레코드 변환
*
* InspectionInputModal이 저장하는 products 형식:
* [{ id, bendingStatus: '양호'|'불량', lengthMeasured, widthMeasured, gapPoints: [{point, designValue, measured}] }]
*
* 프론트엔드 TemplateInspectionContent가 기대하는 EAV field_key 형식:
* b{productIdx}_ok / b{productIdx}_ng, b{productIdx}_n1, b{productIdx}_p{pointIdx}_n1
*/
private function transformBendingProductsToRecords(array $item, int $templateId): array
{
$template = DocumentTemplate::with(['columns'])->find($templateId);
if (! $template) {
return [];
}
// 컬럼 식별 (column_type + sort_order 기반)
$checkCol = $template->columns->firstWhere('column_type', 'check');
$complexCols = $template->columns->where('column_type', 'complex')->sortBy('sort_order')->values();
// complex 컬럼 순서: 길이(0), 너비(1), 간격(2)
$lengthCol = $complexCols->get(0);
$widthCol = $complexCols->get(1);
$gapCol = $complexCols->get(2);
$records = [];
$products = $item['products'];
foreach ($products as $productIdx => $product) {
// 절곡상태 → check column
if ($checkCol) {
if (($product['bendingStatus'] ?? null) === '양호') {
$records[] = [
'section_id' => null, 'column_id' => $checkCol->id,
'row_index' => $productIdx, 'field_key' => "b{$productIdx}_ok", 'field_value' => 'OK',
];
} elseif (($product['bendingStatus'] ?? null) === '불량') {
$records[] = [
'section_id' => null, 'column_id' => $checkCol->id,
'row_index' => $productIdx, 'field_key' => "b{$productIdx}_ng", 'field_value' => 'NG',
];
}
}
// 길이 → first complex column
if ($lengthCol && ! empty($product['lengthMeasured'])) {
$records[] = [
'section_id' => null, 'column_id' => $lengthCol->id,
'row_index' => $productIdx, 'field_key' => "b{$productIdx}_n1", 'field_value' => (string) $product['lengthMeasured'],
];
}
// 너비 → second complex column
if ($widthCol && ! empty($product['widthMeasured'])) {
$records[] = [
'section_id' => null, 'column_id' => $widthCol->id,
'row_index' => $productIdx, 'field_key' => "b{$productIdx}_n1", 'field_value' => (string) $product['widthMeasured'],
];
}
// 간격 포인트 → third complex column (gap)
if ($gapCol && ! empty($product['gapPoints'])) {
foreach ($product['gapPoints'] as $pointIdx => $gp) {
if (! empty($gp['measured'])) {
$records[] = [
'section_id' => null, 'column_id' => $gapCol->id,
'row_index' => $productIdx, 'field_key' => "b{$productIdx}_p{$pointIdx}_n1", 'field_value' => (string) $gp['measured'],
];
}
}
}
}
// 전체 판정
if (isset($item['judgment'])) {
$records[] = [
'section_id' => null, 'column_id' => null,
'row_index' => 0, 'field_key' => 'overall_result', 'field_value' => (string) $item['judgment'],
];
}
// 부적합 내용
if (! empty($item['nonConformingContent'])) {
$records[] = [
'section_id' => null, 'column_id' => null,
'row_index' => 0, 'field_key' => 'remark', 'field_value' => (string) $item['nonConformingContent'],
];
}
return $records;
}
/**
* 레거시 형식(section_X_item_Y 키)을 정규화 레코드로 변환
*/
@@ -2665,20 +3162,28 @@ public function createWorkLog(int $workOrderId, array $workLogData): array
$documentDataRecords = $this->transformWorkLogDataToRecords($workLogData, $workOrder, $template);
if ($existingDocument) {
$document = $documentService->update($existingDocument->id, [
$updateData = [
'title' => $workLogData['title'] ?? $existingDocument->title,
'data' => $documentDataRecords,
]);
];
if (isset($workLogData['rendered_html'])) {
$updateData['rendered_html'] = $workLogData['rendered_html'];
}
$document = $documentService->update($existingDocument->id, $updateData);
$action = 'work_log_updated';
} else {
$document = $documentService->create([
$createData = [
'template_id' => $templateId,
'title' => $workLogData['title'] ?? "작업일지 - {$workOrder->work_order_no}",
'linkable_type' => 'work_order',
'linkable_id' => $workOrderId,
'data' => $documentDataRecords,
'approvers' => $workLogData['approvers'] ?? [],
]);
];
if (isset($workLogData['rendered_html'])) {
$createData['rendered_html'] = $workLogData['rendered_html'];
}
$document = $documentService->create($createData);
$action = 'work_log_created';
}
@@ -2870,6 +3375,12 @@ public function getMaterialsForItem(int $workOrderId, int $itemId): array
continue;
}
// LOT 관리 대상이 아닌 품목은 자재투입 목록에서 제외
$childOptions = $childItems[$childItemId]->options ?? [];
if (isset($childOptions['lot_managed']) && $childOptions['lot_managed'] === false) {
continue;
}
// dynamic_bom.qty는 아이템 수량이 곱해져 있으므로 나눠서 개소당 수량 산출
// (작업일지 bendingInfo와 동일한 수량)
$bomQty = (float) ($bomEntry['qty'] ?? 1);
@@ -2907,6 +3418,12 @@ public function getMaterialsForItem(int $workOrderId, int $itemId): array
continue;
}
// LOT 관리 대상이 아닌 품목은 자재투입 목록에서 제외
$childOptions = $childItem->options ?? [];
if (isset($childOptions['lot_managed']) && $childOptions['lot_managed'] === false) {
continue;
}
$materialItems[] = [
'item' => $childItem,
'bom_qty' => $bomQty,
@@ -2933,15 +3450,44 @@ public function getMaterialsForItem(int $workOrderId, int $itemId): array
->groupBy('item_id')
->pluck('total_qty', 'item_id');
// LOT별 기투입 수량 조회 (stock_lot_id + bom_group_key별 SUM)
$lotInputtedRaw = WorkOrderMaterialInput::where('tenant_id', $tenantId)
->where('work_order_id', $workOrderId)
->where('work_order_item_id', $itemId)
->whereNotNull('stock_lot_id')
->selectRaw('stock_lot_id, bom_group_key, SUM(qty) as total_qty')
->groupBy('stock_lot_id', 'bom_group_key')
->get();
// bom_group_key 포함 복합키 매핑 + stock_lot_id 단순 매핑 (하위호환)
$lotInputtedByGroup = [];
$lotInputtedByLot = [];
foreach ($lotInputtedRaw as $row) {
$lotId = $row->stock_lot_id;
$groupKey = $row->bom_group_key;
$qty = (float) $row->total_qty;
if ($groupKey) {
$compositeKey = $lotId.'_'.$groupKey;
$lotInputtedByGroup[$compositeKey] = ($lotInputtedByGroup[$compositeKey] ?? 0) + $qty;
}
$lotInputtedByLot[$lotId] = ($lotInputtedByLot[$lotId] ?? 0) + $qty;
}
// 자재별 LOT 조회
$materials = [];
$rank = 1;
foreach ($materialItems as $matInfo) {
foreach ($materialItems as $bomIdx => $matInfo) {
$materialItem = $matInfo['item'];
$alreadyInputted = (float) ($inputtedQties[$materialItem->id] ?? 0);
$remainingRequired = max(0, $matInfo['required_qty'] - $alreadyInputted);
// BOM 엔트리별 고유 그룹키 (같은 item_id라도 category+partType이 다르면 별도 그룹)
$bomGroupKey = $materialItem->id
.'_'.($matInfo['category'] ?? '')
.'_'.($matInfo['part_type'] ?? '');
$stock = \App\Models\Tenants\Stock::where('tenant_id', $tenantId)
->where('item_id', $materialItem->id)
->first();
@@ -2961,6 +3507,7 @@ public function getMaterialsForItem(int $workOrderId, int $itemId): array
$materials[] = [
'stock_lot_id' => $lot->id,
'item_id' => $materialItem->id,
'bom_group_key' => $bomGroupKey,
'lot_no' => $lot->lot_no,
'material_code' => $materialItem->code,
'material_name' => $materialItem->name,
@@ -2970,6 +3517,7 @@ public function getMaterialsForItem(int $workOrderId, int $itemId): array
'required_qty' => $matInfo['required_qty'],
'already_inputted' => $alreadyInputted,
'remaining_required_qty' => $remainingRequired,
'lot_inputted_qty' => (float) ($lotInputtedByGroup[$lot->id.'_'.$bomGroupKey] ?? $lotInputtedByLot[$lot->id] ?? 0),
'lot_qty' => (float) $lot->qty,
'lot_available_qty' => (float) $lot->available_qty,
'lot_reserved_qty' => (float) $lot->reserved_qty,
@@ -2987,6 +3535,7 @@ public function getMaterialsForItem(int $workOrderId, int $itemId): array
$materials[] = [
'stock_lot_id' => null,
'item_id' => $materialItem->id,
'bom_group_key' => $bomGroupKey,
'lot_no' => null,
'material_code' => $materialItem->code,
'material_name' => $materialItem->name,
@@ -2996,6 +3545,7 @@ public function getMaterialsForItem(int $workOrderId, int $itemId): array
'required_qty' => $matInfo['required_qty'],
'already_inputted' => $alreadyInputted,
'remaining_required_qty' => $remainingRequired,
'lot_inputted_qty' => 0,
'lot_qty' => 0,
'lot_available_qty' => 0,
'lot_reserved_qty' => 0,
@@ -3014,8 +3564,10 @@ public function getMaterialsForItem(int $workOrderId, int $itemId): array
/**
* 개소별 자재 투입 등록
*
* @param bool $replace true면 기존 투입 이력을 삭제(재고 복원) 후 새로 등록
*/
public function registerMaterialInputForItem(int $workOrderId, int $itemId, array $inputs): array
public function registerMaterialInputForItem(int $workOrderId, int $itemId, array $inputs, bool $replace = false): array
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
@@ -3033,13 +3585,32 @@ public function registerMaterialInputForItem(int $workOrderId, int $itemId, arra
throw new NotFoundHttpException(__('error.not_found'));
}
return DB::transaction(function () use ($inputs, $tenantId, $userId, $workOrderId, $itemId) {
return DB::transaction(function () use ($inputs, $tenantId, $userId, $workOrderId, $itemId, $replace) {
$stockService = app(StockService::class);
$inputResults = [];
// replace 모드: 기존 투입 이력 삭제 + 재고 복원
if ($replace) {
$existingInputs = WorkOrderMaterialInput::where('tenant_id', $tenantId)
->where('work_order_id', $workOrderId)
->where('work_order_item_id', $itemId)
->get();
foreach ($existingInputs as $existing) {
$stockService->increaseToLot(
stockLotId: $existing->stock_lot_id,
qty: (float) $existing->qty,
reason: 'work_order_input_replace',
referenceId: $workOrderId
);
$existing->delete();
}
}
foreach ($inputs as $input) {
$stockLotId = $input['stock_lot_id'] ?? null;
$qty = (float) ($input['qty'] ?? 0);
$bomGroupKey = $input['bom_group_key'] ?? null;
if (! $stockLotId || $qty <= 0) {
continue;
@@ -3064,6 +3635,7 @@ public function registerMaterialInputForItem(int $workOrderId, int $itemId, arra
'work_order_item_id' => $itemId,
'stock_lot_id' => $stockLotId,
'item_id' => $lotItemId ?? 0,
'bom_group_key' => $bomGroupKey,
'qty' => $qty,
'input_by' => $userId,
'input_at' => now(),