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:
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user