diff --git a/app/Http/Controllers/Api/V1/ProductionOrderController.php b/app/Http/Controllers/Api/V1/ProductionOrderController.php new file mode 100644 index 0000000..ea7f794 --- /dev/null +++ b/app/Http/Controllers/Api/V1/ProductionOrderController.php @@ -0,0 +1,50 @@ +service->index($request->validated()); + + return ApiResponse::success($result, __('message.fetched')); + } + + /** + * 생산지시 상태별 통계 + */ + public function stats(): JsonResponse + { + $stats = $this->service->stats(); + + return ApiResponse::success($stats, __('message.fetched')); + } + + /** + * 생산지시 상세 조회 + */ + public function show(int $orderId): JsonResponse + { + try { + $detail = $this->service->show($orderId); + + return ApiResponse::success($detail, __('message.fetched')); + } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + return ApiResponse::error(__('error.order.not_found'), 404); + } + } +} diff --git a/app/Http/Controllers/Api/V1/WorkOrderController.php b/app/Http/Controllers/Api/V1/WorkOrderController.php index a093065..c7fd77b 100644 --- a/app/Http/Controllers/Api/V1/WorkOrderController.php +++ b/app/Http/Controllers/Api/V1/WorkOrderController.php @@ -230,6 +230,16 @@ public function inspectionReport(int $id) }, __('message.work_order.fetched')); } + /** + * 작업지시 검사 설정 조회 (공정 자동 판별 + 구성품 목록) + */ + public function inspectionConfig(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->getInspectionConfig($id); + }, __('message.work_order.fetched')); + } + /** * 작업지시의 검사용 문서 템플릿 조회 */ @@ -310,7 +320,14 @@ public function materialsForItem(int $id, int $itemId) public function registerMaterialInputForItem(MaterialInputForItemRequest $request, int $id, int $itemId) { return ApiResponse::handle(function () use ($request, $id, $itemId) { - return $this->service->registerMaterialInputForItem($id, $itemId, $request->validated()['inputs']); + $validated = $request->validated(); + + return $this->service->registerMaterialInputForItem( + $id, + $itemId, + $validated['inputs'], + (bool) ($validated['replace'] ?? false) + ); }, __('message.work_order.material_input_registered')); } diff --git a/app/Http/Requests/ProductionOrder/ProductionOrderIndexRequest.php b/app/Http/Requests/ProductionOrder/ProductionOrderIndexRequest.php new file mode 100644 index 0000000..39a848c --- /dev/null +++ b/app/Http/Requests/ProductionOrder/ProductionOrderIndexRequest.php @@ -0,0 +1,25 @@ + 'nullable|string|max:100', + 'production_status' => 'nullable|in:waiting,in_production,completed', + 'sort_by' => 'nullable|in:created_at,delivery_date,order_no', + 'sort_dir' => 'nullable|in:asc,desc', + 'page' => 'nullable|integer|min:1', + 'per_page' => 'nullable|integer|min:1|max:100', + ]; + } +} diff --git a/app/Http/Requests/V1/ProcessStep/StoreProcessStepRequest.php b/app/Http/Requests/V1/ProcessStep/StoreProcessStepRequest.php index 0c29e8d..a5671df 100644 --- a/app/Http/Requests/V1/ProcessStep/StoreProcessStepRequest.php +++ b/app/Http/Requests/V1/ProcessStep/StoreProcessStepRequest.php @@ -22,6 +22,12 @@ public function rules(): array 'connection_type' => ['nullable', 'string', 'max:20'], 'connection_target' => ['nullable', 'string', 'max:255'], 'completion_type' => ['nullable', 'string', 'in:click_complete,selection_complete,inspection_complete'], + 'options' => ['nullable', 'array'], + 'options.inspection_setting' => ['nullable', 'array'], + 'options.inspection_scope' => ['nullable', 'array'], + 'options.inspection_scope.type' => ['nullable', 'string', 'in:all,sampling,group'], + 'options.inspection_scope.sample_size' => ['nullable', 'integer', 'min:1'], + 'options.inspection_scope.sample_base' => ['nullable', 'string', 'in:order,lot'], ]; } @@ -36,6 +42,12 @@ public function attributes(): array 'connection_type' => '연결유형', 'connection_target' => '연결대상', 'completion_type' => '완료유형', + 'options' => '옵션', + 'options.inspection_setting' => '검사설정', + 'options.inspection_scope' => '검사범위', + 'options.inspection_scope.type' => '검사범위 유형', + 'options.inspection_scope.sample_size' => '샘플 크기', + 'options.inspection_scope.sample_base' => '샘플 기준', ]; } } diff --git a/app/Http/Requests/V1/ProcessStep/UpdateProcessStepRequest.php b/app/Http/Requests/V1/ProcessStep/UpdateProcessStepRequest.php index ac81f87..1ff34e3 100644 --- a/app/Http/Requests/V1/ProcessStep/UpdateProcessStepRequest.php +++ b/app/Http/Requests/V1/ProcessStep/UpdateProcessStepRequest.php @@ -22,6 +22,12 @@ public function rules(): array 'connection_type' => ['nullable', 'string', 'max:20'], 'connection_target' => ['nullable', 'string', 'max:255'], 'completion_type' => ['nullable', 'string', 'in:click_complete,selection_complete,inspection_complete'], + 'options' => ['nullable', 'array'], + 'options.inspection_setting' => ['nullable', 'array'], + 'options.inspection_scope' => ['nullable', 'array'], + 'options.inspection_scope.type' => ['nullable', 'string', 'in:all,sampling,group'], + 'options.inspection_scope.sample_size' => ['nullable', 'integer', 'min:1'], + 'options.inspection_scope.sample_base' => ['nullable', 'string', 'in:order,lot'], ]; } @@ -36,6 +42,12 @@ public function attributes(): array 'connection_type' => '연결유형', 'connection_target' => '연결대상', 'completion_type' => '완료유형', + 'options' => '옵션', + 'options.inspection_setting' => '검사설정', + 'options.inspection_scope' => '검사범위', + 'options.inspection_scope.type' => '검사범위 유형', + 'options.inspection_scope.sample_size' => '샘플 크기', + 'options.inspection_scope.sample_base' => '샘플 기준', ]; } } diff --git a/app/Http/Requests/WorkOrder/MaterialInputForItemRequest.php b/app/Http/Requests/WorkOrder/MaterialInputForItemRequest.php index d1c7a5f..1af761e 100644 --- a/app/Http/Requests/WorkOrder/MaterialInputForItemRequest.php +++ b/app/Http/Requests/WorkOrder/MaterialInputForItemRequest.php @@ -17,6 +17,8 @@ public function rules(): array 'inputs' => 'required|array|min:1', 'inputs.*.stock_lot_id' => 'required|integer', 'inputs.*.qty' => 'required|numeric|gt:0', + 'inputs.*.bom_group_key' => 'sometimes|nullable|string|max:100', + 'replace' => 'sometimes|boolean', ]; } diff --git a/app/Models/ProcessStep.php b/app/Models/ProcessStep.php index 953fda9..ee1543f 100644 --- a/app/Models/ProcessStep.php +++ b/app/Models/ProcessStep.php @@ -22,6 +22,7 @@ class ProcessStep extends Model 'connection_type', 'connection_target', 'completion_type', + 'options', ]; protected $casts = [ @@ -30,6 +31,7 @@ class ProcessStep extends Model 'needs_inspection' => 'boolean', 'is_active' => 'boolean', 'sort_order' => 'integer', + 'options' => 'array', ]; /** diff --git a/app/Models/Production/WorkOrder.php b/app/Models/Production/WorkOrder.php index 4715845..0aff877 100644 --- a/app/Models/Production/WorkOrder.php +++ b/app/Models/Production/WorkOrder.php @@ -6,6 +6,7 @@ use App\Models\Members\User; use App\Models\Orders\Order; use App\Models\Process; +use App\Models\Qualitys\Inspection; use App\Models\Tenants\Department; use App\Models\Tenants\Shipment; use App\Traits\Auditable; @@ -234,6 +235,14 @@ public function shipments(): HasMany return $this->hasMany(Shipment::class); } + /** + * 품질검사 (IQC/PQC/FQC) + */ + public function inspections(): HasMany + { + return $this->hasMany(Inspection::class); + } + /** * 생성자 */ diff --git a/app/Models/Production/WorkOrderMaterialInput.php b/app/Models/Production/WorkOrderMaterialInput.php index c921d34..92fe21f 100644 --- a/app/Models/Production/WorkOrderMaterialInput.php +++ b/app/Models/Production/WorkOrderMaterialInput.php @@ -27,6 +27,7 @@ class WorkOrderMaterialInput extends Model 'work_order_item_id', 'stock_lot_id', 'item_id', + 'bom_group_key', 'qty', 'input_by', 'input_at', diff --git a/app/Services/Production/BendingInfoBuilder.php b/app/Services/Production/BendingInfoBuilder.php index cad62df..a35677c 100644 --- a/app/Services/Production/BendingInfoBuilder.php +++ b/app/Services/Production/BendingInfoBuilder.php @@ -218,17 +218,19 @@ public function buildDynamicBomForItem(array $context, int $width, int $height, // ─── 3. 셔터박스 세부품목 ─── if ($boxSize) { - $isStandard = $boxSize === '500*380'; $dist = $this->shutterBoxDistribution($width); - $shutterPartTypes = ['front', 'lintel', 'inspection', 'rear_corner', 'top_cover', 'fin_cover']; + // 상부덮개(top_cover), 마구리(fin_cover)는 1219mm 기준으로 별도 생성 (아래 256행~) + $shutterPartTypes = ['front', 'lintel', 'inspection', 'rear_corner']; - foreach ($dist as $length => $count) { - $totalCount = $count * $qty; - if ($totalCount <= 0) { - continue; - } - foreach ($shutterPartTypes as $partType) { - $prefix = $resolver->resolveShutterBoxPrefix($partType, $isStandard); + // 작업일지와 동일한 순서: 파트 → 길이 + foreach ($shutterPartTypes as $partType) { + foreach ($dist as $length => $count) { + $totalCount = $count * $qty; + if ($totalCount <= 0) { + continue; + } + + $prefix = $resolver->resolveShutterBoxPrefix($partType); $itemCode = $resolver->buildItemCode($prefix, $length); if (! $itemCode) { continue; @@ -256,7 +258,7 @@ public function buildDynamicBomForItem(array $context, int $width, int $height, // 상부덮개 수량: ceil(width / 1219) × qty (1219mm 단위) $coverQty = (int) ceil($width / 1219) * $qty; if ($coverQty > 0) { - $coverPrefix = $resolver->resolveShutterBoxPrefix('top_cover', $isStandard); + $coverPrefix = $resolver->resolveShutterBoxPrefix('top_cover'); $coverCode = $resolver->buildItemCode($coverPrefix, 1219); if ($coverCode) { $coverId = $resolver->resolveItemId($coverCode, $tenantId); @@ -278,7 +280,7 @@ public function buildDynamicBomForItem(array $context, int $width, int $height, // 마구리 수량: qty × 2 $finQty = $qty * 2; if ($finQty > 0) { - $finPrefix = $resolver->resolveShutterBoxPrefix('fin_cover', $isStandard); + $finPrefix = $resolver->resolveShutterBoxPrefix('fin_cover'); // 마구리는 박스 높이 기준이므로 셔터박스 최소 단위 사용 $finCode = $resolver->buildItemCode($finPrefix, 1219); if ($finCode) { diff --git a/app/Services/Production/PrefixResolver.php b/app/Services/Production/PrefixResolver.php index 772b995..75f915d 100644 --- a/app/Services/Production/PrefixResolver.php +++ b/app/Services/Production/PrefixResolver.php @@ -189,16 +189,14 @@ public function resolveBottomBarPrefix(string $partType, string $productCode, st /** * 셔터박스 세부품목의 prefix 결정 * + * CF/CL/CP/CB 품목은 모든 길이에 등록되어 있으므로 boxSize 무관하게 적용. + * top_cover, fin_cover는 전용 품목 없이 XX(하부BASE/상부/마구리) 공용. + * * @param string $partType 'front', 'lintel', 'inspection', 'rear_corner', 'top_cover', 'fin_cover' - * @param bool $isStandardSize 500*380인지 * @return string prefix */ - public function resolveShutterBoxPrefix(string $partType, bool $isStandardSize): string + public function resolveShutterBoxPrefix(string $partType): string { - if (! $isStandardSize) { - return 'XX'; - } - return self::SHUTTER_STANDARD[$partType] ?? 'XX'; } diff --git a/app/Services/ProductionOrderService.php b/app/Services/ProductionOrderService.php new file mode 100644 index 0000000..638ca00 --- /dev/null +++ b/app/Services/ProductionOrderService.php @@ -0,0 +1,285 @@ +tenantId(); + + $query = Order::query() + ->where('tenant_id', $tenantId) + ->whereIn('status_code', self::PRODUCTION_STATUSES) + ->with(['client', 'workOrders.process', 'workOrders.assignees.user']) + ->withCount([ + 'workOrders' => fn ($q) => $q->whereNotNull('process_id') + ->where(fn ($q2) => $q2->whereNull('options->is_auxiliary') + ->orWhere('options->is_auxiliary', false)), + 'nodes', + ]); + + // 검색어 필터 + if (! empty($params['search'])) { + $search = $params['search']; + $query->where(function ($q) use ($search) { + $q->where('order_no', 'like', "%{$search}%") + ->orWhere('client_name', 'like', "%{$search}%") + ->orWhere('site_name', 'like', "%{$search}%"); + }); + } + + // 생산 상태 필터 + if (! empty($params['production_status'])) { + switch ($params['production_status']) { + case 'waiting': + $query->where('status_code', Order::STATUS_IN_PROGRESS); + break; + case 'in_production': + $query->where('status_code', Order::STATUS_IN_PRODUCTION); + break; + case 'completed': + $query->whereIn('status_code', [ + Order::STATUS_PRODUCED, + Order::STATUS_SHIPPING, + Order::STATUS_SHIPPED, + ]); + break; + } + } + + // 정렬 + $sortBy = $params['sort_by'] ?? 'created_at'; + $sortDir = $params['sort_dir'] ?? 'desc'; + $query->orderBy($sortBy, $sortDir); + + // 페이지네이션 + $perPage = $params['per_page'] ?? 20; + $result = $query->paginate($perPage); + + // 가공 필드 추가 + $result->getCollection()->transform(function (Order $order) { + $minCreatedAt = $order->workOrders->min('created_at'); + $order->production_ordered_at = $minCreatedAt + ? $minCreatedAt->format('Y-m-d') + : null; + + // 개소수 (order_nodes 수) + $order->node_count = $order->nodes_count ?? 0; + + // 주요 생산 공정 WO만 (구매품 + 보조 공정 제외) + $productionWOs = $this->filterMainProductionWOs($order->workOrders); + $order->work_order_progress = [ + 'total' => $productionWOs->count(), + 'completed' => $productionWOs->where('status', 'completed')->count() + + $productionWOs->where('status', 'shipped')->count(), + 'in_progress' => $productionWOs->where('status', 'in_progress')->count(), + ]; + + // 프론트 탭용 production_status 매핑 + $order->production_status = $this->mapProductionStatus($order->status_code); + + return $order; + }); + + return $result; + } + + /** + * 상태별 통계 + */ + public function stats(): array + { + $tenantId = $this->tenantId(); + + $waiting = Order::where('tenant_id', $tenantId) + ->where('status_code', Order::STATUS_IN_PROGRESS) + ->count(); + + $inProduction = Order::where('tenant_id', $tenantId) + ->where('status_code', Order::STATUS_IN_PRODUCTION) + ->count(); + + $completed = Order::where('tenant_id', $tenantId) + ->whereIn('status_code', [ + Order::STATUS_PRODUCED, + Order::STATUS_SHIPPING, + Order::STATUS_SHIPPED, + ]) + ->count(); + + return [ + 'total' => $waiting + $inProduction + $completed, + 'waiting' => $waiting, + 'in_production' => $inProduction, + 'completed' => $completed, + ]; + } + + /** + * 생산지시 상세 조회 + */ + public function show(int $orderId): array + { + $tenantId = $this->tenantId(); + + $order = Order::query() + ->where('tenant_id', $tenantId) + ->whereIn('status_code', self::PRODUCTION_STATUSES) + ->with([ + 'client', + 'workOrders.process', + 'workOrders.items', + 'workOrders.assignees.user', + 'nodes', + ]) + ->withCount('nodes') + ->findOrFail($orderId); + + // 생산지시일 (날짜만) + $minCreatedAt = $order->workOrders->min('created_at'); + $order->production_ordered_at = $minCreatedAt + ? $minCreatedAt->format('Y-m-d') + : null; + $order->production_status = $this->mapProductionStatus($order->status_code); + + // 주요 생산 공정 WO만 필터 (구매품 + 보조 공정 제외) + $productionWorkOrders = $this->filterMainProductionWOs($order->workOrders); + + // WorkOrder 진행 현황 (생산 공정 기준) + $workOrderProgress = [ + 'total' => $productionWorkOrders->count(), + 'completed' => $productionWorkOrders->where('status', 'completed')->count() + + $productionWorkOrders->where('status', 'shipped')->count(), + 'in_progress' => $productionWorkOrders->where('status', 'in_progress')->count(), + ]; + + // WorkOrder 목록 가공 (생산 공정만) + $workOrders = $productionWorkOrders->values()->map(function ($wo) { + return [ + 'id' => $wo->id, + 'work_order_no' => $wo->work_order_no, + 'process_name' => $wo->process?->process_name ?? '', + 'quantity' => $wo->items->count(), + 'status' => $wo->status, + 'assignees' => $wo->assignees->map(fn ($a) => $a->user?->name ?? '')->filter()->values()->toArray(), + ]; + }); + + // BOM 데이터 (order_nodes에서 추출) + $bomProcessGroups = $this->extractBomProcessGroups($order->nodes); + + return [ + 'order' => $order->makeHidden(['workOrders', 'nodes']), + 'production_ordered_at' => $order->production_ordered_at, + 'production_status' => $order->production_status, + 'node_count' => $order->nodes_count ?? 0, + 'work_order_progress' => $workOrderProgress, + 'work_orders' => $workOrders, + 'bom_process_groups' => $bomProcessGroups, + ]; + } + + /** + * Order status_code → 프론트 production_status 매핑 + */ + private function mapProductionStatus(string $statusCode): string + { + return match ($statusCode) { + Order::STATUS_IN_PROGRESS => 'waiting', + Order::STATUS_IN_PRODUCTION => 'in_production', + default => 'completed', + }; + } + + /** + * order_nodes에서 BOM 공정 분류 추출 + * + * bom_result 구조: { items: [...], success, subtotals, ... } + * 각 item: { item_id, item_code, item_name, process_group, specification, quantity, unit, ... } + */ + private function extractBomProcessGroups($nodes): array + { + $groups = []; + + foreach ($nodes as $node) { + $bomResult = $node->options['bom_result'] ?? null; + if (! $bomResult || ! is_array($bomResult)) { + continue; + } + + // bom_result.items 배열에서 추출 + $items = $bomResult['items'] ?? []; + if (! is_array($items)) { + continue; + } + + $nodeName = $node->name ?? ''; + + foreach ($items as $item) { + if (! is_array($item)) { + continue; + } + + $processGroup = $item['process_group'] ?? $item['category_group'] ?? '기타'; + + if (! isset($groups[$processGroup])) { + $groups[$processGroup] = [ + 'process_name' => $processGroup, + 'items' => [], + ]; + } + + $groups[$processGroup]['items'][] = [ + 'id' => $item['item_id'] ?? null, + 'item_code' => $item['item_code'] ?? '', + 'item_name' => $item['item_name'] ?? '', + 'spec' => $item['specification'] ?? '', + 'unit' => $item['unit'] ?? '', + 'quantity' => $item['quantity'] ?? 0, + 'unit_price' => $item['unit_price'] ?? 0, + 'total_price' => $item['total_price'] ?? 0, + 'node_name' => $nodeName, + ]; + } + } + + return array_values($groups); + } + + /** + * 주요 생산 공정 WO만 필터 (구매품/서비스 + 보조 공정 제외) + * + * 제외 대상: + * - process_id가 null인 WO (구매품/서비스) + * - options.is_auxiliary가 true인 WO (재고생산 등 보조 공정) + */ + private function filterMainProductionWOs($workOrders): \Illuminate\Support\Collection + { + return $workOrders->filter(function ($wo) { + if (empty($wo->process_id)) { + return false; + } + $options = is_array($wo->options) ? $wo->options : (json_decode($wo->options, true) ?? []); + + return empty($options['is_auxiliary']); + }); + } +} diff --git a/app/Services/WorkOrderService.php b/app/Services/WorkOrderService.php index 3311d68..ba39c09 100644 --- a/app/Services/WorkOrderService.php +++ b/app/Services/WorkOrderService.php @@ -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(), diff --git a/app/Swagger/v1/ProductionOrderApi.php b/app/Swagger/v1/ProductionOrderApi.php new file mode 100644 index 0000000..bed878d --- /dev/null +++ b/app/Swagger/v1/ProductionOrderApi.php @@ -0,0 +1,188 @@ +string('bom_group_key')->nullable()->after('item_id') + ->comment('BOM 그룹키 (같은 item_id의 다른 용도 구분, ex: itemId_category_partType)'); + + $table->index(['work_order_item_id', 'bom_group_key'], 'idx_womi_item_bomgroup'); + }); + } + + public function down(): void + { + Schema::table('work_order_material_inputs', function (Blueprint $table) { + $table->dropIndex('idx_womi_item_bomgroup'); + $table->dropColumn('bom_group_key'); + }); + } +}; diff --git a/database/migrations/2026_03_04_100000_add_options_to_process_steps_table.php b/database/migrations/2026_03_04_100000_add_options_to_process_steps_table.php new file mode 100644 index 0000000..55ad247 --- /dev/null +++ b/database/migrations/2026_03_04_100000_add_options_to_process_steps_table.php @@ -0,0 +1,23 @@ +json('options')->nullable()->after('completion_type') + ->comment('검사설정, 검사범위 등 추가 옵션 JSON'); + }); + } + + public function down(): void + { + Schema::table('process_steps', function (Blueprint $table) { + $table->dropColumn('options'); + }); + } +}; diff --git a/routes/api/v1/production.php b/routes/api/v1/production.php index 0700bfa..17cb3f5 100644 --- a/routes/api/v1/production.php +++ b/routes/api/v1/production.php @@ -10,6 +10,7 @@ */ use App\Http\Controllers\Api\V1\InspectionController; +use App\Http\Controllers\Api\V1\ProductionOrderController; use App\Http\Controllers\Api\V1\WorkOrderController; use App\Http\Controllers\Api\V1\WorkResultController; use App\Http\Controllers\V1\ProcessController; @@ -82,6 +83,7 @@ // 중간검사 관리 Route::post('/{id}/items/{itemId}/inspection', [WorkOrderController::class, 'storeItemInspection'])->whereNumber('id')->whereNumber('itemId')->name('v1.work-orders.items.inspection'); // 품목 검사 저장 + Route::get('/{id}/inspection-config', [WorkOrderController::class, 'inspectionConfig'])->whereNumber('id')->name('v1.work-orders.inspection-config'); // 검사 설정 (공정 판별 + 구성품) Route::get('/{id}/inspection-data', [WorkOrderController::class, 'inspectionData'])->whereNumber('id')->name('v1.work-orders.inspection-data'); // 검사 데이터 조회 Route::get('/{id}/inspection-report', [WorkOrderController::class, 'inspectionReport'])->whereNumber('id')->name('v1.work-orders.inspection-report'); // 검사 성적서 조회 Route::get('/{id}/inspection-template', [WorkOrderController::class, 'inspectionTemplate'])->whereNumber('id')->name('v1.work-orders.inspection-template'); // 검사 문서 템플릿 조회 @@ -113,9 +115,17 @@ Route::prefix('inspections')->group(function () { Route::get('', [InspectionController::class, 'index'])->name('v1.inspections.index'); // 목록 Route::get('/stats', [InspectionController::class, 'stats'])->name('v1.inspections.stats'); // 통계 + Route::get('/calendar', [InspectionController::class, 'calendar'])->name('v1.inspections.calendar'); // 캘린더 Route::post('', [InspectionController::class, 'store'])->name('v1.inspections.store'); // 생성 Route::get('/{id}', [InspectionController::class, 'show'])->whereNumber('id')->name('v1.inspections.show'); // 상세 Route::put('/{id}', [InspectionController::class, 'update'])->whereNumber('id')->name('v1.inspections.update'); // 수정 Route::delete('/{id}', [InspectionController::class, 'destroy'])->whereNumber('id')->name('v1.inspections.destroy'); // 삭제 Route::patch('/{id}/complete', [InspectionController::class, 'complete'])->whereNumber('id')->name('v1.inspections.complete'); // 완료 처리 }); + +// Production Order API (생산지시 조회) +Route::prefix('production-orders')->group(function () { + Route::get('', [ProductionOrderController::class, 'index'])->name('v1.production-orders.index'); + Route::get('/stats', [ProductionOrderController::class, 'stats'])->name('v1.production-orders.stats'); + Route::get('/{orderId}', [ProductionOrderController::class, 'show'])->whereNumber('orderId')->name('v1.production-orders.show'); +});