diff --git a/LOGICAL_RELATIONSHIPS.md b/LOGICAL_RELATIONSHIPS.md index 40cfd1e0..34aca152 100644 --- a/LOGICAL_RELATIONSHIPS.md +++ b/LOGICAL_RELATIONSHIPS.md @@ -1,6 +1,6 @@ # 논리적 데이터베이스 관계 문서 -> **자동 생성**: 2026-03-20 11:23:51 +> **자동 생성**: 2026-03-20 16:30:28 > **소스**: Eloquent 모델 관계 분석 ## 📊 모델별 관계 현황 diff --git a/app/Services/BendingCodeService.php b/app/Services/BendingCodeService.php index 4ae7e406..74d667a9 100644 --- a/app/Services/BendingCodeService.php +++ b/app/Services/BendingCodeService.php @@ -2,8 +2,7 @@ namespace App\Services; -use App\Models\BendingItem; -use App\Models\Orders\Order; +use App\Models\Items\Item; class BendingCodeService extends Service { @@ -128,28 +127,19 @@ public function getCodeMap(): array } /** - * 드롭다운 선택 조합 → bending_items 품목 매핑 조회 + * 드롭다운 선택 조합 → 품목(items) 매핑 조회 * - * legacy_code 패턴: BD-{prod}{spec}-{length} (예: BD-CP-30) + * 품목코드 패턴: BD-{prod}{spec}-{length} (예: BD-RC-24) */ public function resolveItem(string $prodCode, string $specCode, string $lengthCode): ?array { - // 1차: code + length_code로 조회 (신규 LOT 체계) - $item = BendingItem::where('tenant_id', $this->tenantId()) - ->where('code', 'like', "{$prodCode}{$specCode}%") - ->where('length_code', $lengthCode) + $itemCode = "BD-{$prodCode}{$specCode}-{$lengthCode}"; + + $item = Item::where('tenant_id', $this->tenantId()) + ->where('code', $itemCode) ->where('is_active', true) ->first(); - // 2차: legacy_code 폴백 - if (! $item) { - $legacyCode = "BD-{$prodCode}{$specCode}-{$lengthCode}"; - $item = BendingItem::where('tenant_id', $this->tenantId()) - ->where('legacy_code', $legacyCode) - ->where('is_active', true) - ->first(); - } - if (! $item) { return null; } @@ -157,9 +147,9 @@ public function resolveItem(string $prodCode, string $specCode, string $lengthCo return [ 'item_id' => $item->id, 'item_code' => $item->code, - 'item_name' => $item->item_name, - 'specification' => $item->item_spec, - 'unit' => 'EA', + 'item_name' => $item->name, + 'specification' => $item->getOption('item_spec'), + 'unit' => $item->unit ?? 'EA', ]; } @@ -202,4 +192,87 @@ public static function getMaterial(string $prodCode, string $specCode): ?string { return self::MATERIAL_MAP["{$prodCode}:{$specCode}"] ?? null; } + + /** + * 품목 코드(BD-XX-YY) → 매칭되는 bending_item의 전개 폭(width_sum) 반환 + * + * 매칭 로직: + * BD-{prod}{spec}-{length} 파싱 + * → PRODUCTS/SPECS에서 item_bending, item_sep, 키워드 추출 + * → bending_items 검색 → bending_data 마지막 sum = 전개 폭 + */ + public function getBendingWidthByItemCode(string $itemCode): ?float + { + if (! preg_match('/^BD-([A-Z])([A-Z])-(\d+)$/', $itemCode, $m)) { + return null; + } + $prodCode = $m[1]; + $specCode = $m[2]; + + // 제품명 → item_bending 추출 (가이드레일(벽면형) → 가이드레일) + $productName = null; + foreach (self::PRODUCTS as $p) { + if ($p['code'] === $prodCode) { + $productName = $p['name']; + break; + } + } + if (! $productName) { + return null; + } + + // 종류명 추출 + $specName = null; + foreach (self::SPECS as $s) { + if ($s['code'] === $specCode && in_array($prodCode, $s['products'])) { + $specName = $s['name']; + break; + } + } + if (! $specName) { + return null; + } + + // item_bending: 괄호 제거 (가이드레일(벽면형) → 가이드레일) + $itemBending = preg_replace('/\(.*\)/', '', $productName); + + // item_sep 판단: 종류명 또는 제품명에 '철재' → 철재, 아니면 스크린 + $itemSep = (str_contains($specName, '철재') || str_contains($productName, '철재')) + ? '철재' : '스크린'; + + // bending_items 검색 + $query = \App\Models\BendingItem::query() + ->where('tenant_id', $this->tenantId()) + ->where('item_bending', $itemBending) + ->where('item_sep', $itemSep) + ->whereNotNull('bending_data'); + + // 가이드레일: 벽면형/측면형 구분 (item_name 키워드 매칭) + if (str_contains($productName, '벽면형')) { + $query->where('item_name', 'LIKE', '%벽면형%'); + } elseif (str_contains($productName, '측면형')) { + $query->where('item_name', 'LIKE', '%측면형%'); + } + + // 종류 키워드 매칭 (본체, C형, D형, 전면, 점검구, 린텔 등) + $specKeyword = preg_replace('/\(.*\)/', '', $specName); // 본체(철재) → 본체 + $query->where('item_name', 'LIKE', "%{$specKeyword}%"); + + // 최신 코드 우선 + $bendingItem = $query->orderByDesc('code')->first(); + + if (! $bendingItem) { + return null; + } + + // bending_data 마지막 항목의 sum = 전개 폭 + $data = $bendingItem->bending_data; + if (empty($data)) { + return null; + } + + $last = end($data); + + return isset($last['sum']) ? (float) $last['sum'] : null; + } } diff --git a/app/Services/WorkOrderService.php b/app/Services/WorkOrderService.php index 421b789e..99d3b65d 100644 --- a/app/Services/WorkOrderService.php +++ b/app/Services/WorkOrderService.php @@ -3,7 +3,6 @@ namespace App\Services; use App\Models\Documents\Document; -use Illuminate\Support\Facades\Storage; use App\Models\Documents\DocumentTemplate; use App\Models\Orders\Order; use App\Models\Process; @@ -18,6 +17,7 @@ use App\Models\Tenants\StockTransaction; use App\Services\Audit\AuditLogger; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Storage; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -62,6 +62,7 @@ public function index(array $params) 'salesOrder.client:id,name', 'process:id,process_name,process_code,department,options', 'items:id,work_order_id,item_id,item_name,specification,quantity,unit,status,options,sort_order,source_order_item_id', + 'items.item:id,code', 'items.sourceOrderItem:id,order_node_id,floor_code,symbol_code', 'items.sourceOrderItem.node:id,name,code', 'items.materialInputs:id,work_order_id,work_order_item_id,stock_lot_id,item_id,qty,input_by,input_at', @@ -123,14 +124,21 @@ public function index(array $params) ->orWhereHas('assignees', fn ($aq) => $aq->where('user_id', $userId)); }); } else { - // 2차: 사용자 소속 부서의 작업지시 필터 + // 2차: 사용자 소속 부서 + 상위 부서의 작업지시 필터 $departmentIds = DB::table('department_user') ->where('user_id', $userId) ->where('tenant_id', $tenantId) ->pluck('department_id'); if ($departmentIds->isNotEmpty()) { - $query->whereIn('team_id', $departmentIds); + // 소속 부서의 상위 부서도 포함 (부서 계층 지원) + $parentIds = DB::table('departments') + ->whereIn('id', $departmentIds) + ->whereNotNull('parent_id') + ->pluck('parent_id'); + + $allDeptIds = $departmentIds->merge($parentIds)->unique(); + $query->whereIn('team_id', $allDeptIds); } // 3차: 부서도 없으면 필터 없이 전체 노출 } @@ -151,7 +159,37 @@ public function index(array $params) $query->orderByDesc('created_at'); - return $query->paginate($size, ['*'], 'page', $page); + $result = $query->paginate($size, ['*'], 'page', $page); + + // 작업자 화면: BENDING 카테고리 품목에 전개도 폭(bending_width) 추가 + if ($workerScreen) { + $this->appendBendingWidths($result); + } + + return $result; + } + + /** + * BENDING 카테고리 품목에 전개도 폭 추가 + */ + private function appendBendingWidths($paginator): void + { + $bendingService = app(BendingCodeService::class); + + foreach ($paginator->items() as $workOrder) { + foreach ($workOrder->items as $item) { + $itemCode = $item->item?->code; + if (! $itemCode || ! str_starts_with($itemCode, 'BD-')) { + continue; + } + $width = $bendingService->getBendingWidthByItemCode($itemCode); + if ($width !== null) { + $options = $item->options ?? []; + $options['bending_width'] = $width; + $item->setAttribute('options', $options); + } + } + } } /** @@ -215,6 +253,7 @@ public function show(int $id) 'salesOrder.writer:id,name', 'process:id,process_name,process_code,work_steps,department,options', 'process.steps' => fn ($q) => $q->where('is_active', true)->orderBy('sort_order'), + 'items.item:id,code', 'items.sourceOrderItem:id,order_node_id,floor_code,symbol_code', 'items.sourceOrderItem.node:id,name,code', 'items.materialInputs:id,work_order_id,work_order_item_id,stock_lot_id,item_id,qty,input_by,input_at', @@ -3806,13 +3845,59 @@ public function getMaterialsForItem(int $workOrderId, int $itemId): array } } - // BOM이 없으면 품목 자체를 자재로 사용 + // BOM이 없으면 BD 품목의 재질 정보로 원자재 자동 매칭 if (empty($materialItems) && $woItem->item_id && $woItem->item) { - $materialItems[] = [ - 'item' => $woItem->item, - 'bom_qty' => 1, - 'required_qty' => $woItem->quantity ?? 1, - ]; + $itemOptions = $woItem->item->options ?? []; + $material = $itemOptions['material'] ?? null; + + $matchedRawItems = []; + if ($material && preg_match('/^(\w+)\s*(\d+\.?\d*)/i', $material, $matMatch)) { + $matType = $matMatch[1]; + $matThickness = (float) $matMatch[2]; + + // 품목명에서 제품 길이 추출 (예: 1750mm) + $productLength = 0; + if (preg_match('/(\d{3,5})mm/', $woItem->item->name, $lenMatch)) { + $productLength = (int) $lenMatch[1]; + } + + // 원자재 검색: material_type + thickness 매칭, length >= 제품길이 + $rawItems = \App\Models\Items\Item::where('tenant_id', $tenantId) + ->where('item_type', 'RM') + ->whereRaw("JSON_UNQUOTE(JSON_EXTRACT(options, '$.attributes.material_type')) = ?", [$matType]) + ->whereRaw('CAST(JSON_EXTRACT(options, \'$.attributes.thickness\') AS DECIMAL(10,2)) = ?', [$matThickness]) + ->get(); + + foreach ($rawItems as $rawItem) { + $rawAttrs = $rawItem->options['attributes'] ?? []; + $rawLength = $rawAttrs['length'] ?? null; + + // 길이 조건: 원자재 길이 >= 제품 길이 (길이 미정이면 통과) + if ($rawLength !== null && $productLength > 0 && $rawLength < $productLength) { + continue; + } + + $matchedRawItems[] = $rawItem; + } + } + + if (! empty($matchedRawItems)) { + // 매칭된 원자재를 자재 목록으로 추가 + foreach ($matchedRawItems as $rawItem) { + $materialItems[] = [ + 'item' => $rawItem, + 'bom_qty' => 1, + 'required_qty' => $woItem->quantity ?? 1, + ]; + } + } else { + // 매칭 실패 시 기존 동작 유지 (품목 자체를 자재로 표시) + $materialItems[] = [ + 'item' => $woItem->item, + 'bom_qty' => 1, + 'required_qty' => $woItem->quantity ?? 1, + ]; + } } // 이미 투입된 수량 조회 (item_id별 SUM)