Files
sam-docs/plans/archive/bending-lot-pipeline-dev-plan.md
권혁성 28b69e5449 docs: archive 37개 + COMPLETED 3개 복원 - 향후 docs/ 정식 문서화 시 참조용
- 완료 문서의 상세 내용은 추후 docs/ 구조화 시 정식 문서에 반영 예정
- HISTORY.md는 요약 인덱스로 유지, 개별 파일은 상세 참조용 보관

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 22:32:20 +09:00

42 KiB
Raw Blame History

절곡 자재투입 LOT 매핑 파이프라인 개발 계획

작성일: 2026-02-22 목적: 절곡 세부품목(BD-XX-NN)의 동적 BOM 생성 및 LOT 추적 파이프라인 구축 기준 문서: docs/plans/bending-material-input-mapping-plan.md 상태: 완료 (Serena ID: bending-lot-pipeline-state)


📍 현재 진행 상태

항목 내용
마지막 완료 작업 Phase 5.2 완료 — 전체 파이프라인 완성
다음 작업 없음 (전체 완료)
진행률 13/13 (100%)
마지막 업데이트 2026-02-22

1. 개요

1.1 배경

절곡 작업일지에는 4대 카테고리(가이드레일/하단마감재/셔터박스/연기차단재)의 세부품목이 표시되나, 현재 SAM에서 이 세부품목들이 items 테이블의 BOM과 연결되지 않아 자재투입 시 세부품목별 LOT 매핑이 불가능하다.

방안 B(동적 BOM 생성) 확정: 작업지시 생성 시 BendingInfoBuilder를 확장하여 work_order_items.options.dynamic_bom에 세부품목 정보를 저장하고, getMaterials() API가 이를 우선 참조하도록 수정한다.

1.2 기준 원칙

┌─────────────────────────────────────────────────────────────────┐
│  🎯 핵심 원칙                                                   │
├─────────────────────────────────────────────────────────────────┤
│  1. 견적 로직(QuoteCalculationService) 수정 없음               │
│  2. DB 스키마 변경 없음 — 기존 options JSON 컬럼 활용            │
│  3. 하위 호환성 — dynamic_bom 없는 기존 데이터도 정상 동작       │
│  4. bending_info와 dynamic_bom은 동일 Builder에서 동시 생성      │
└─────────────────────────────────────────────────────────────────┘

1.3 변경 승인 정책

분류 예시 승인
즉시 가능 JSON 필드 추가, 새 Service 클래스 생성, 유틸 함수, 테스트 불필요
⚠️ 컨펌 필요 getMaterials() 로직 변경, registerMaterialInput API 통일, 프론트 모달 동작 변경 필수
🔴 금지 items.bom 컬럼 직접 수정, 견적 로직 변경, work_order_material_inputs 스키마 변경 별도 협의

1.4 준수 규칙

  • docs/standards/api-rules.md — Service-First, FormRequest, ApiResponse
  • docs/standards/quality-checklist.md — 품질 체크리스트
  • docs/rules/item-policy.md — 품목 정책 (BD-* 명명 규칙)
  • api/CLAUDE.md — SAM API 개발 규칙

1.5 성공 기준

기준 측정 방법
작업지시 생성 시 dynamic_bom JSON 자동 생성 work_order_items.options에 dynamic_bom 존재 확인
getMaterials API가 세부품목(BD-RS-43 등) 반환 API 응답에 세부품목 리스트 포함 확인
세부품목별 LOT 선택 → 재고 차감 정상 stock_transactions + work_order_material_inputs 레코드 확인
자재투입 이력에 work_order_item_id 기록 WorkOrderMaterialInput 레코드의 work_order_item_id NOT NULL
레거시 5130과 동일한 LOT prefix 체계 prefix × lengthCode 전체 조합 매칭 검증

1.6 현재 구현 컨텍스트 (새 세션 필독)

이 섹션은 새 세션에서 별도 파일을 읽지 않고도 작업을 시작할 수 있도록 핵심 코드 구조를 인라인합니다.

1.6.1 전체 데이터 흐름

[견적/수주]
  QuoteCalculationService.calculateBom()
    → order_nodes.options.bom_result에 부모 품목 저장
    → 예: BD-가이드레일-KSS01-SUS-120*70, qty=8.5m
                    ↓
[작업지시 생성]
  WorkOrderService.store() (L266-316)
    → salesOrder.items 순회 → work_order_items에 복사
    → nodeOptions에서 bending_info 복사: work_order_items.options.bending_info
    → ⭐ [신규] dynamic_bom도 여기서 저장: work_order_items.options.dynamic_bom
                    ↓
  BendingInfoBuilder.build(Order, processId) (L29-69)
    → 절곡 공정 확인 → rootNodes 필터링 → productCode 파싱
    → getMaterialMapping() → aggregateNodes() → assembleBendingInfo()
    → ⭐ [신규] buildDynamicBom() → 길이 버킷팅 결과로 BD-XX-NN 세부품목 매핑
                    ↓
[자재투입 조회]
  getMaterials(workOrderId) (L1183-1317)
    → work_order_items 순회
    → ⭐ [신규] options.dynamic_bom 있으면 세부품목 사용 / 없으면 item.bom fallback
    → 세부품목별 Stock → StockLot (FIFO) 조회
                    ↓
[자재투입 등록]
  registerMaterialInputForItem(workOrderId, itemId, inputs) (L2821-2907)
    → StockService.decreaseFromLot() — 재고 차감
    → WorkOrderMaterialInput::create() — 투입 이력 기록
                    ↓
[생산완료]
  updateStatus(workOrderId, 'completed') (L520-602)
    → sales_order_id 있으면: createShipmentFromWorkOrder() (출하 직행)
    → sales_order_id 없으면: stockInFromProduction() → stock_lots 생성

1.6.2 BendingInfoBuilder 핵심 구조

파일: api/app/Services/Production/BendingInfoBuilder.php

// 진입점
public function build(Order $order, int $processId, ?array $nodeIds = null): ?array

// BOM 아이템 카테고리 분류 (L96-130)
private function categorizeBomItem(array $bomItem): ?string
// 반환: 'guideRail', 'shutterBox_case', 'shutterBox_finCover', 'bottomBar',
//       'smokeBarrier_rail', 'smokeBarrier_case', 'detail_lbar', 'detail_reinforce', 'motor'

// 노드 집계 (L135-175)
private function aggregateNodes(Collection $nodes): array
// 반환: { dimensionGroups: [{height, width, qty}], totalNodeQty, bomCategories: {category => bomItem} }

// 높이 기준 버킷팅 (L760-763) — 가이드레일용
private function heightLengthData(array $dimGroups): array
// 반환: [{ length: 2438, quantity: 5 }, { length: 3000, quantity: 3 }]
// 표준 길이: [2438, 3000, 3500, 4000, 4300]

// 하단마감재 배분 (L801-834)
private function bottomBarDistribution(int $openWidth): array
// 반환: [3000mm수량, 4000mm수량]
// 예: openWidth=7000 → [1, 1] (3000×1 + 4000×1)

// 셔터박스 배분 (L411-548)
private function shutterBoxDistribution(int $openWidth): array
// 반환: [1219 => qty, 2438 => qty, 3000 => qty, 3500 => qty, 4000 => qty, 4150 => qty]

// 가이드레일 섹션 (L251-299)
private function buildGuideRail(string $guideType, string $baseSize, array $materials, array $dimGroups, string $productCode): array
// guideType: '벽면형', '측면형', '혼합형'
// 반환: { wall: {baseSize, baseDimension, lengthData}, side: {...} | null }

// 표준 길이 버킷팅 (L856-865) — ⚠️ 초과 시 원본 반환
private function bucketToStandardLength(int $dimension, array $buckets): int

1.6.3 getMaterials() 현재 로직

파일: api/app/Services/WorkOrderService.php L1183-1317

Phase 1: 유니크 자재 수집
  for each workOrder.items:
      if item.bom 존재:   ← 절곡 부모 품목은 bom=null이므로 여기 안 탐
          BOM 자식 순회 → uniqueMaterials[childItemId] += qty
      else:               ← 현재 절곡은 여기로 빠짐 (부모 품목 자체가 자재로)
          uniqueMaterials[itemId] = qty

Phase 2: StockLot 조회
  for each uniqueMaterial:
      stock = Stock.find(itemId) → StockLot.where(available) → FIFO 정렬

⚠️ 문제: 절곡 부모 품목(BD-가이드레일-KSS01-SUS-120*70)의 bom이 null
         → 세부품목(BD-RS-43 등)이 자재 목록에 나오지 않음
         → dynamic_bom으로 해결

1.6.4 registerMaterialInput 두 메서드 차이

항목 registerMaterialInput (L1330) registerMaterialInputForItem (L2821)
파라미터 workOrderId, inputs workOrderId, itemId, inputs
재고 차감 decreaseFromLot decreaseFromLot
WorkOrderMaterialInput 미생성 생성 (work_order_item_id 포함)
용도 전체 작업지시 단위 개소(품목) 단위

1.6.5 프론트엔드 현재 구조

MaterialInputModal (react/src/components/production/WorkerScreen/MaterialInputModal.tsx)

// Props — workOrderItemId 유무로 API 경로 분기
interface MaterialInputModalProps {
  order: WorkOrder | null;
  workOrderItemId?: number;      // 있으면 개소별 API, 없으면 전체 API
  workOrderItemName?: string;
}

// 품목 그룹핑 (L102-119): itemId 기준 Map<number, MaterialForInput[]>
// FIFO 배분 (L121-138): selectedLotKeys → 가용량 순서로 자동 배분
// 등록 (L261-307):
//   workOrderItemId ? registerMaterialInputForItem() : registerMaterialInput()

API 엔드포인트 (react/src/components/production/WorkerScreen/actions.ts)

메서드 경로 함수명
GET /api/v1/work-orders/{id}/materials getMaterialsForWorkOrder
GET /api/v1/work-orders/{id}/items/{itemId}/materials getMaterialsForItem
POST /api/v1/work-orders/{id}/material-inputs registerMaterialInput
POST /api/v1/work-orders/{id}/items/{itemId}/material-inputs registerMaterialInputForItem
GET /api/v1/work-orders/{id}/items/{itemId}/material-inputs getMaterialInputsForItem
DELETE /api/v1/work-orders/{id}/material-inputs/{inputId} deleteMaterialInput
PATCH /api/v1/work-orders/{id}/material-inputs/{inputId} updateMaterialInput

절곡 유틸리티 (react/.../documents/bending/utils.ts)

  • getSLengthCode(length, category) — 길이→코드 변환
  • getMaterialMapping(productCode, finishMaterial) — 재질 매핑
  • buildWallGuideRailRows(), buildSideGuideRailRows(), buildBottomBarRows(), buildShutterBoxRows(), buildSmokeBarrierRows() — 각 섹션 파트 행 생성 (lotPrefix 포함)

1.6.6 LOT Prefix 전체 맵 (PrefixResolver 구현 기준)

가이드레일 벽면형 (Wall)

세부품목 KSS01(SUS) KSE01/KWE01(EGI마감) KSE01/KWE01(SUS마감) KTE01(철재)
마감재 RS RE RE RS
본체 RM RM RM RT
C형 RC RC RC RC
D형 RD RD RD RD
별도마감 - - YY -
하부BASE XX XX XX XX

가이드레일 측면형 (Side)

세부품목 KSS01(SUS) KSE01/KWE01(EGI마감) KSE01/KWE01(SUS마감) KTE01(철재)
마감재 SS SE SE SS
본체 SM SM SM ST
C형 SC SC SC SC
D형 SD SD SD SD
별도마감 - - YY -
하부BASE XX XX XX XX

하단마감재

세부품목 EGI마감 SUS마감 철재
메인 BE BS TS
L-Bar LA LA LA
보강평철 HH HH HH
별도마감 - YY -

셔터박스 (표준 500*380 사이즈만 개별 prefix)

세부품목 표준 prefix 비표준 prefix
전면부 CF XX
린텔부 CL XX
점검구 CP XX
후면코너부 CB XX
상부덮개 XX XX
마구리 XX XX

연기차단재: W50, W80 모두 → GI

1.6.7 길이코드 매핑 (getSLengthCode)

길이(mm) 코드 비고
1219 12 셔터박스
2438 24 셔터박스
3000 30 공통
3500 35 공통
4000 40 공통
4150 41 셔터박스
4200 42 -
4300 43 가이드레일
3000 53 연기차단재50 전용
4000 54 연기차단재50 전용
3000 83 연기차단재80 전용
4000 84 연기차단재80 전용

코드 생성 규칙: BD-{prefix}-{lengthCode} → 예: BD-RS-43 = 가이드레일 벽면 SUS 마감재 4300mm

1.6.8 BD-* 마스터 현황 (items 테이블, 총 148개)

A. 제품 마스터형 (58개) — 부모 품목 (견적 BOM에 사용)

BD-가이드레일-KSS01-SUS-120*70 등 (20개: 제품코드별)
BD-하단마감재-KSE01-EGI-60*40 등 (10개)
BD-케이스-500*380 등 (10개), BD-마구리-505*355 등 (10개)
BD-L-BAR-*, BD-보강평철-*, BD-연기차단재 (8개)

B. LOT prefix형 (90개 등록, XX/YY/HH 미등록) — 세부품목 (자재투입 대상)

prefix 수량 prefix 수량 prefix 수량
BD-RS 5 BD-SS 4 BD-BE 2
BD-RM 6 BD-SM 5 BD-BS 5
BD-RC 6 BD-SC 5 BD-TS 1
BD-RD 6 BD-SD 5 BD-LA 2
BD-RT 2 BD-ST 1 BD-CF 6
BD-SU 4 BD-CL 6
BD-CP 6
BD-CB 6
BD-GI 7

미등록: BD-XX (하부BASE/셔터 상부/마구리), BD-YY (별도SUS마감), BD-HH (보강평철) → Phase 0.1에서 등록

1.6.9 dynamic_bom JSON 목표 구조

work_order_items.options.dynamic_bom 에 저장:

[
  {
    "child_item_id": 15812,
    "child_item_code": "BD-RS-43",
    "lot_prefix": "RS",
    "part_type": "마감재",
    "category": "guideRail",
    "material_type": "SUS",
    "length_mm": 4300,
    "qty": 1
  },
  {
    "child_item_id": 15809,
    "child_item_code": "BD-RS-40",
    "lot_prefix": "RS",
    "part_type": "마감재",
    "category": "guideRail",
    "material_type": "SUS",
    "length_mm": 4000,
    "qty": 1
  },
  {
    "child_item_id": 15826,
    "child_item_code": "BD-RM-43",
    "lot_prefix": "RM",
    "part_type": "본체",
    "category": "guideRail",
    "material_type": "EGI",
    "length_mm": 4300,
    "qty": 1
  }
]

필드 설명:

  • child_item_id: items 테이블 PK (getMaterials에서 Stock/StockLot 조회용)
  • child_item_code: items.code (표시용)
  • lot_prefix: LOT prefix (프론트 작업일지 매핑용)
  • part_type: 세부품명 한글 (마감재, 본체, C형 등)
  • category: 4대 카테고리 (guideRail, bottomBar, shutterBox, smokeBarrier)
  • material_type: 재질 (SUS, EGI 등)
  • length_mm: 표준 길이 (mm)
  • qty: 수량

2. 대상 범위

2.1 Phase 0: 선행 준비 (마스터 데이터)

# 작업 항목 상태 비고
0.1 XX/YY/HH 미등록 품목 items 등록 22건 등록 (13+9 추가 누락)
0.2 마스터 데이터 검증 스크립트 작성 101/101 전체 통과

2.2 Phase 1: GAP #1 해결 — API 통일

# 작업 항목 상태 비고
1.1 registerMaterialInput → registerMaterialInputForItem 통일 work_order_item_id 분기 + fallback + N+1 수정
1.2 프론트 workOrderItemId 전달 보장 actions.ts + MaterialInputModal work_order_item_id 전달

2.3 Phase 2: 방안 B 핵심 — dynamic_bom 생성

# 작업 항목 상태 비고
2.1 PrefixResolver 클래스 구현 app/Services/Production/PrefixResolver.php
2.2 BendingInfoBuilder 확장 — dynamic_bom 생성 build() 리턴 변경 + buildDynamicBomForItem() 추가, OrderService 연동
2.3 DynamicBomEntry DTO 구현 app/DTOs/Production/DynamicBomEntry.php

2.4 Phase 3: getMaterials 연동

# 작업 항목 상태 비고
3.1 getMaterials() dynamic_bom 우선 체크 dynamic_bom → BOM fallback, (item_id, woItem_id) 쌍 합산, 추가 필드 반환
3.2 N+1 쿼리 최적화 + uniqueMaterials 합산 단위 변경 3.1에서 함께 해결: Item/Stock/StockLot 모두 배치 조회

2.5 Phase 4: 프론트엔드 연동

# 작업 항목 상태 비고
4.1 자재투입 모달 세부품목 단위 표시 MaterialInputModal groupKey + category badge + actions.ts 필드 추가
4.2 작업일지 LOT NO 표시 연동 4개 섹션 lotNoMap prop + WorkLogModal lotNoMap 빌드

2.6 Phase 5: 테스트 및 검증

# 작업 항목 상태 비고
5.1 PrefixResolver + dynamic_bom 단위 테스트 58 tests / 256 assertions 통과
5.2 getMaterials → 자재투입 통합 테스트 6 tests (4 pass + 2 skip — dynamic_bom 작업지시 미생성), 마스터 품목 전체 검증

2.7 별도 과제 (이 계획 범위 밖)

# 항목 시점
X.1 GAP #4: 수주 연결 생산완료 → stock_lots 입고 통일 출하 시스템 설계 시
X.2 GAP #3: lot_genealogy (투입↔산출 LOT 직접 연결) 향후 고도화

3. 작업 절차

3.1 단계별 절차

Phase 0: 선행 준비
├── 0.1 XX/YY/HH 품목 등록 (items 테이블 INSERT)
└── 0.2 검증 스크립트 (Artisan Command)
    └── 19종 prefix × 7-12 lengthCode 조합 → items 존재 확인

Phase 1: API 통일 (GAP #1) — Phase 0 완료 후
├── 1.1 registerMaterialInput() 내부에서 registerMaterialInputForItem() 호출하도록 통일
│   ├── WorkOrderService.php L1330-1388 수정
│   └── 기존 프론트 호출 호환성 유지
└── 1.2 프론트 workOrderItemId 전달
    └── WorkerScreen/index.tsx → MaterialInputModal Props

Phase 2: dynamic_bom 생성 — Phase 0 완료 후 (Phase 1과 병행 가능)
├── 2.1 PrefixResolver 클래스
│   ├── productCode + finishMaterial + guideType → prefix 결정
│   ├── prefix + lengthMm → BD-XX-NN 코드 생성
│   └── BD-XX-NN → items.id 조회 (캐시)
├── 2.2 BendingInfoBuilder 확장
│   ├── build() 반환값에 dynamic_bom 추가
│   ├── bending_info와 동시 생성 (정합성 보장)
│   └── work_order_items.options.dynamic_bom에 저장
└── 2.3 DynamicBomValidator
    └── dynamic_bom JSON 구조 검증 (child_item_id 필수 등)

Phase 3: getMaterials 수정 — Phase 2 완료 후
├── 3.1 dynamic_bom 우선 체크
│   ├── WorkOrderService.php getMaterials() L1198 이후
│   ├── options.dynamic_bom 있으면 → 세부품목 리스트 사용
│   └── 없으면 → 기존 item.bom fallback (하위 호환)
└── 3.2 N+1 최적화
    ├── Item::whereIn() 배치 조회
    └── uniqueMaterials 합산 단위: (item_id, work_order_item_id) 쌍

Phase 4: 프론트엔드 — Phase 3 완료 후
├── 4.1 자재투입 모달 수정
│   ├── materialGroups가 세부품목 단위로 표시 (이미 itemId 기준 그룹핑)
│   └── 그룹 헤더에 세부품목명(BD-RS-43) 표시
└── 4.2 작업일지 LOT NO 표시
    ├── dynamic_bom에서 lotPrefix + lengthCode 조합
    └── 투입 이력(getMaterialInputsForItem)에서 실제 LOT NO 반영

Phase 5: 테스트 — Phase 3 완료 후 (Phase 4와 병행 가능)
├── 5.1 단위 테스트
│   ├── PrefixResolver: 7종 productCode × 3종 finishMaterial × 3종 guideType
│   ├── dynamic_bom 생성: 실제 bom_result 데이터 기반
│   └── DynamicBomValidator: 필수/선택 필드 검증
└── 5.2 통합 테스트
    ├── 작업지시 생성 → dynamic_bom 저장 확인
    ├── getMaterials → 세부품목 반환 확인
    └── 자재투입 → stock_transactions + work_order_material_inputs 확인

3.2 의존성 맵

Phase 0 ──→ Phase 1 (독립 진행 가능)
   │
   └──→ Phase 2 ──→ Phase 3 ──→ Phase 4
                        │
                        └──→ Phase 5

4. 상세 작업 내용

4.1 Phase 0: 선행 준비

0.1 XX/YY/HH 미등록 품목 등록

현재 상태: BD-* 품목 148개 중 XX(하부BASE), YY(별도SUS마감), HH(보강평철) 미등록

목표 상태: BD-XX-NN, BD-YY-NN, BD-HH-NN 패턴으로 items 테이블에 등록

등록 대상:

prefix 설명 등록할 길이코드 예상 수량
BD-XX 하부BASE, 셔터박스 상부덮개/마구리 12, 24, 30, 35, 40, 41, 43 7개
BD-YY 별도 SUS 마감 (SUS마감 시만) 30, 35, 40, 43 4개
BD-HH 보강평철 30, 40 2개

수정 파일: 없음 (DB INSERT — Seeder 또는 Artisan Command)

생성 파일:

  • api/database/seeders/BendingItemSeeder.php — BD-XX/YY/HH 품목 등록

검증: items 테이블에서 code LIKE 'BD-XX-%' 조회로 13개 확인


0.2 마스터 데이터 검증 스크립트

목적: 19종 prefix × 가능 lengthCode 전체 조합이 items에 존재하는지 확인

생성 파일:

  • api/app/Console/Commands/ValidateBendingItems.php

로직:

전체 prefix 목록 정의 (RS, RM, RC, RD, RT, SS, SM, SC, SD, ST, SU, BE, BS, TS, LA, CF, CL, CP, CB, GI, XX, YY, HH)
각 prefix별 유효 lengthCode 정의
조합별 items.code = "BD-{prefix}-{code}" 존재 확인
누락 항목 리스트 출력

실행: php artisan bending:validate-items

검증: 출력이 "All items registered" (누락 0건)


4.2 Phase 1: GAP #1 해결 — API 통일

1.1 registerMaterialInput → registerMaterialInputForItem 통일

현재 상태:

  • registerMaterialInput() (L1330): 재고 차감만, WorkOrderMaterialInput 레코드 미생성
  • registerMaterialInputForItem() (L2821): 재고 차감 + WorkOrderMaterialInput 레코드 생성

목표 상태: 모든 자재투입이 work_order_material_inputs에 기록

수정 파일:

  • api/app/Services/WorkOrderService.php

수정 내용:

registerMaterialInput(int $workOrderId, array $inputs) 수정:
  ├── $inputs 배열에 work_order_item_id 필드 추가 지원
  │   { stock_lot_id: N, qty: N, work_order_item_id?: N }
  ├── work_order_item_id가 있으면 → registerMaterialInputForItem() 위임
  └── work_order_item_id가 없으면 → 기존 동작 + WorkOrderMaterialInput 레코드 생성 추가
      (work_order_item_id = 첫 번째 work_order_item의 id로 fallback)

N+1 개선: registerMaterialInputForItem() L2860-2861의 StockLot::find()$lot->stock->item_id 호출을 StockLot::with('stock')->find() Eager Loading으로 변경

검증:

  • POST /work-orders/{id}/material-inputs 호출 후 work_order_material_inputs 테이블에 레코드 존재 확인
  • 기존 호출 형식(work_order_item_id 미포함)도 정상 동작 확인

1.2 프론트 workOrderItemId 전달 보장

현재 상태: WorkerScreen/index.tsx에서 MaterialInputModalworkOrderItemId Props를 전달하지만, 완료 플로우에서는 미지정 가능

수정 파일:

  • react/src/components/production/WorkerScreen/index.tsx

수정 내용:

  • 자재투입 모달 호출 시 workOrderItemId가 항상 전달되도록 보장
  • 완료 플로우에서도 selectedItemId 설정

검증: MaterialInputModal이 항상 registerMaterialInputForItem() 경로로 호출되는지 확인


4.3 Phase 2: 방안 B 핵심 — dynamic_bom 생성

2.1 PrefixResolver 클래스 구현

목적: 제품코드 + 마감재질 + 가이드타입 → LOT prefix 결정 로직을 단일 클래스로 집중

생성 파일:

  • api/app/Services/Production/PrefixResolver.php

클래스 구조:

class PrefixResolver
{
    // 벽면형 prefix 맵
    private const WALL_PREFIXES = [
        'finish'      => ['KSS' => 'RS', 'KSE' => 'RE', 'KWE' => 'RE'],
        'body'        => 'RM',
        'c_type'      => 'RC',
        'd_type'      => 'RD',
        'extra_finish' => 'YY',  // SUS 마감 시만
        'base'        => 'XX',
    ];

    // 측면형 prefix 맵
    private const SIDE_PREFIXES = [
        'finish'      => ['KSS' => 'SS', 'KSE' => 'SE', 'KWE' => 'SE'],
        'body'        => 'SM',
        'c_type'      => 'SC',
        'd_type'      => 'SD',
        'extra_finish' => 'YY',
        'base'        => 'XX',
    ];

    // 철재형 override
    private const STEEL_OVERRIDES = [
        'wall_body' => 'RT',
        'side_body' => 'ST',
    ];

    // 하단마감재 prefix 맵
    private const BOTTOM_BAR_PREFIXES = [
        'EGI' => 'BE',
        'SUS' => 'BS',
        'STEEL_SUS' => 'TS',
    ];

    // 셔터박스 prefix 맵 (표준 사이즈만)
    private const SHUTTER_BOX_PREFIXES = [
        'front'       => 'CF',
        'lintel'      => 'CL',
        'inspection'  => 'CP',
        'rear_corner' => 'CB',
        'top_cover'   => 'XX',
        'fin_cover'   => 'XX',
    ];

    // 연기차단재
    private const SMOKE_PREFIXES = [
        'w50' => 'GI',
        'w80' => 'GI',
    ];

    /**
     * 가이드레일 세부품목의 prefix 결정
     */
    public function resolveGuideRailPrefix(
        string $partType,       // 'finish', 'body', 'c_type', 'd_type', 'extra_finish', 'base'
        string $guideType,      // 'wall', 'side'
        string $productCode,    // 'KSS01', 'KSE01', ...
    ): string

    /**
     * 하단마감재 세부품목의 prefix 결정
     */
    public function resolveBottomBarPrefix(
        string $partType,       // 'main', 'lbar', 'reinforce', 'extra'
        string $finishMaterial, // 'EGI 1.55T', 'SUS 1.2T'
        string $productCode,
    ): string

    /**
     * 셔터박스 세부품목의 prefix 결정
     */
    public function resolveShutterBoxPrefix(
        string $partType,       // 'front', 'lintel', 'inspection', 'rear_corner', 'top_cover', 'fin_cover'
        bool $isStandardSize,   // 500*380인지
    ): string

    /**
     * 연기차단재 세부품목의 prefix 결정
     */
    public function resolveSmokeBarrierPrefix(string $partType): string

    /**
     * prefix + 길이(mm) → BD-XX-NN 코드 생성
     */
    public function buildItemCode(string $prefix, int $lengthMm, ?string $smokeCategory = null): string

    /**
     * BD-XX-NN 코드 → items.id 조회 (캐시 사용)
     */
    public function resolveItemId(string $itemCode): ?int

    /**
     * 길이(mm) → 길이코드 변환 (getSLengthCode 동일)
     */
    public static function lengthToCode(int $lengthMm, ?string $smokeCategory = null): ?string
}

의존성: App\Models\Items\Item (코드→ID 조회용)

검증: 단위 테스트에서 productCode × guideType × partType 전 조합 테스트


2.2 BendingInfoBuilder 확장 — dynamic_bom 생성

수정 파일:

  • api/app/Services/Production/BendingInfoBuilder.php

수정 범위:

  1. build() 메서드 (L29-69): 반환값에 dynamic_bom 배열 추가

    현재: return assembleBendingInfo(...)  // bending_info만
    변경: return [
        'bending_info' => assembleBendingInfo(...),
        'dynamic_bom' => buildDynamicBom(...)   // 신규
    ]
    
  2. buildDynamicBom() 신규 메서드: bending_info 생성과 동일한 길이 버킷팅 결과를 사용

    private function buildDynamicBom(
        array $aggregated,          // aggregateNodes() 결과
        string $productCode,
        array $materials,           // getMaterialMapping() 결과
        PrefixResolver $resolver,
    ): array
    

    로직:

    dynamic_bom = []
    
    // 1. 가이드레일 세부품목
    for each guideType (wall, side):
        lengthData = heightLengthData(dimGroups)  // 기존 버킷팅 재사용
        for each (length, qty) in lengthData:
            for each partType in [finish, body, c_type, d_type, extra_finish, base]:
                prefix = resolver.resolveGuideRailPrefix(partType, guideType, productCode)
                if prefix is empty: skip
                itemCode = resolver.buildItemCode(prefix, length)
                itemId = resolver.resolveItemId(itemCode)
                dynamic_bom[] = {
                    child_item_id: itemId,
                    child_item_code: itemCode,
                    lot_prefix: prefix,
                    part_type: partType의 한글명,
                    category: 'guideRail',
                    material_type: materials[partType],
                    length_mm: length,
                    qty: qty
                }
    
    // 2. 하단마감재 세부품목
    for each dimGroup:
        [qty3000, qty4000] = bottomBarDistribution(openWidth)
        for each (length, qty) in [(3000, qty3000), (4000, qty4000)]:
            if qty == 0: skip
            for each partType in [main, lbar, reinforce, extra]:
                prefix = resolver.resolveBottomBarPrefix(partType, finishMaterial, productCode)
                ... dynamic_bom 추가 ...
    
    // 3. 셔터박스 세부품목
    for each dimGroup:
        distribution = shutterBoxDistribution(openWidth)
        for each (length, qty) in distribution:
            if qty == 0: skip
            isStandard = (boxSize == '500*380')
            for each partType in [front, lintel, inspection, rear_corner, top_cover, fin_cover]:
                prefix = resolver.resolveShutterBoxPrefix(partType, isStandard)
                ... dynamic_bom 추가 ...
    
    // 4. 연기차단재 세부품목
    for each smokeType (w50, w80):
        for each (length, qty) in smokeLengthData:
            prefix = resolver.resolveSmokeBarrierPrefix(smokeType)
            smokeCategory = smokeType == 'w50' ? '연기차단재50' : '연기차단재80'
            itemCode = resolver.buildItemCode(prefix, length, smokeCategory)
            ... dynamic_bom 추가 ...
    
    return dynamic_bom
    
  3. work_order_items.options 저장 위치 수정:

    • WorkOrderService.php L275-306 (작업지시 품목 복사 로직)에서 build() 반환값의 dynamic_bomoptions.dynamic_bom에 저장

주의사항:

  • aggregateNodes() L164의 !isset 체크: 첫 노드에서만 BOM 메타 추출 → 노드별 BOM이 다를 수 있으므로 주의
  • bucketToStandardLength() L862-864: 표준 길이 초과 시 원본 반환 → PrefixResolver.resolveItemId()에서 null 반환 시 경고 로그 + fallback
  • 혼합형 가이드레일: wall + side 각각 독립 dynamic_bom 생성

검증:

  • 작업지시 생성 API 호출 후 work_order_items.options JSON에 dynamic_bom 배열 존재 확인
  • dynamic_bom의 각 항목에 child_item_id가 NOT NULL인지 확인
  • bending_info의 lengthData와 dynamic_bom의 length_mm/qty가 일치하는지 확인

2.3 DynamicBomValidator DTO 구현

생성 파일:

  • api/app/DTOs/Production/DynamicBomEntry.php

구조:

class DynamicBomEntry
{
    public function __construct(
        public readonly int $child_item_id,
        public readonly string $child_item_code,
        public readonly string $lot_prefix,
        public readonly string $part_type,
        public readonly string $category,       // guideRail, bottomBar, shutterBox, smokeBarrier
        public readonly string $material_type,
        public readonly int $length_mm,
        public readonly int|float $qty,
    ) {}

    public static function fromArray(array $data): self
    public function toArray(): array
    public static function validate(array $data): bool  // child_item_id 필수 등
}

검증: 단위 테스트에서 필수 필드 누락 시 예외 발생 확인


4.4 Phase 3: getMaterials 연동

3.1 getMaterials() dynamic_bom 우선 체크

수정 파일:

  • api/app/Services/WorkOrderService.php

수정 위치: getMaterials() L1198 이후

수정 내용:

현재 (L1198-1238):
  foreach (workOrderItems as woItem):
      item = woItem.item
      if (item.bom):
          ... BOM 순회 ...
      else:
          ... item 자체를 자재로 ...

변경:
  // Phase 1: dynamic_bom 대상 item_id 일괄 수집
  allDynamicItemIds = []
  foreach (workOrderItems as woItem):
      dynamicBom = woItem.options['dynamic_bom'] ?? null
      if (dynamicBom):
          allDynamicItemIds += array_column(dynamicBom, 'child_item_id')

  // Phase 2: 배치 조회 (N+1 방지)
  dynamicItems = Item::whereIn('id', array_unique(allDynamicItemIds))
                      ->get()->keyBy('id')

  // Phase 3: 유니크 자재 수집
  foreach (workOrderItems as woItem):
      dynamicBom = woItem.options['dynamic_bom'] ?? null
      if (dynamicBom):
          foreach (dynamicBom as bomEntry):
              childItem = dynamicItems[bomEntry['child_item_id']]
              // 합산 키: (item_id, work_order_item_id) 쌍
              key = bomEntry['child_item_id'] . '_' . woItem.id
              uniqueMaterials[key] = {
                  item_id: bomEntry['child_item_id'],
                  work_order_item_id: woItem.id,
                  bom_qty: bomEntry['qty'],
                  item: childItem,
                  ...
              }
      elseif (item.bom):
          ... 기존 BOM 로직 (하위 호환) ...
      else:
          ... 기존 fallback ...

반환 형식 변경:

기존: { stock_lot_id, item_id, lot_no, bom_qty, required_qty, ... }
추가: { ..., work_order_item_id, lot_prefix, part_type, category }

검증:

  • dynamic_bom 있는 work_order → 세부품목(BD-RS-43 등) 반환 확인
  • dynamic_bom 없는 work_order → 기존 동작 그대로 (하위 호환)
  • 동일 item_id가 다른 work_order_item에 속한 경우 별도 행으로 반환

3.2 N+1 쿼리 최적화 + uniqueMaterials 합산 단위 변경

수정 파일: api/app/Services/WorkOrderService.php

수정 내용:

  1. Item::find() 개별 호출 → Item::whereIn() 배치 조회
  2. uniqueMaterials 합산 키를 item_id(item_id, work_order_item_id) 쌍으로 변경
  3. StockLot 조회도 Stock::whereIn() 배치 처리

기대 효과: 쿼리 수 30-50회 → 3-5회로 감소

검증: Laravel Debugbar 또는 DB 쿼리 로그로 쿼리 수 확인


4.5 Phase 4: 프론트엔드 연동

4.1 자재투입 모달 세부품목 단위 표시

수정 파일:

  • react/src/components/production/WorkerScreen/MaterialInputModal.tsx

현재 상태: materialGroupsitemId 기준 그룹핑 (L102-119). getMaterials 응답이 세부품목을 반환하면 자동으로 세부품목 단위 그룹핑됨.

수정 내용:

  • 그룹 헤더에 세부품목명(BD-RS-43 등) + part_type(마감재 등) + category(가이드레일 등) 표시
  • 기존 materialCode/materialName 필드로 충분하나, 카테고리별 시각적 구분 추가

수정 규모: 소규모 — 그룹 헤더 렌더링 수정

검증: 자재투입 모달에서 세부품목별 그룹이 표시되고, 각 그룹 내 LOT 선택이 정상 동작


4.2 작업일지 LOT NO 표시 연동

수정 파일:

  • react/src/components/production/WorkOrders/documents/bending/GuideRailSection.tsx
  • 해당 폴더의 다른 Section 컴포넌트 (BottomBarSection, ShutterBoxSection 등)

현재 상태: LOT NO 컬럼이 "-"로 하드코딩

수정 내용:

  • getMaterialInputsForItem() API로 투입 이력 조회
  • lotPrefix + lengthCode 매칭으로 실제 LOT NO 표시
  • 투입 전이면 "-", 투입 후이면 실제 LOT 번호

수정 규모: 중규모 — 각 Section 컴포넌트에 LOT 조회 로직 추가

검증: 자재투입 완료 후 작업일지에 실제 LOT NO 표시


4.6 Phase 5: 테스트 및 검증

5.1 단위 테스트

생성 파일:

  • api/tests/Unit/Services/Production/PrefixResolverTest.php
  • api/tests/Unit/Services/Production/BendingInfoBuilderDynamicBomTest.php

테스트 케이스:

테스트 입력 기대 결과
KSS01 벽면형 마감재 4300mm ('finish', 'wall', 'KSS01') prefix='RS', code='BD-RS-43'
KSE01 측면형 본체 3000mm ('body', 'side', 'KSE01') prefix='SM', code='BD-SM-30'
KTE01 벽면형 본체 (철재) ('body', 'wall', 'KTE01') prefix='RT'
하단마감재 EGI ('main', 'EGI 1.55T', 'KSE01') prefix='BE'
셔터박스 비표준 사이즈 ('front', false) prefix='XX'
연기차단재 W50 3000mm resolveSmokeBarrierPrefix('w50') prefix='GI', code='BD-GI-53'
표준 길이 초과 (4500mm) buildItemCode('RS', 4500) 경고 로그 + null 반환

5.2 통합 테스트

생성 파일:

  • api/tests/Feature/Production/BendingMaterialInputFlowTest.php

테스트 시나리오:

1. 작업지시 생성 → dynamic_bom 저장 확인
   - Order (KSS01, SUS마감, 오픈높이=4300, 오픈폭=3000)
   - 작업지시 생성 → work_order_items.options.dynamic_bom 확인
   - dynamic_bom에 RS-43, RM-43, RC-43, RD-43 세부품목 존재

2. getMaterials → 세부품목 반환 확인
   - getMaterials(workOrderId) 호출
   - 응답에 BD-RS-43, BD-RM-43 등 세부품목 반환
   - 각 세부품목의 StockLot 정보 포함

3. 자재투입 → 이력 기록 확인
   - registerMaterialInputForItem() 호출
   - stock_transactions에 OUT 기록
   - work_order_material_inputs에 레코드 생성
   - stock_lots.available_qty 감소

5. 컨펌 대기 목록

# 항목 변경 내용 영향 범위 상태
1 registerMaterialInput API 통일 기존 API에 WorkOrderMaterialInput 레코드 생성 추가 프론트 호출 호환 유지
2 BendingInfoBuilder.build() 반환값 변경 기존 array → { bending_info, dynamic_bom } WorkOrderService 호출처 수정 필요
3 getMaterials() 로직 변경 dynamic_bom 우선 체크 + 합산 단위 변경 MaterialInputModal 응답 형식 변경

6. 변경 이력

날짜 변경 내용
2026-02-22 문서 초안 작성
2026-02-22 Phase 0 완료: BD-* 22건 등록 + 검증 101/101 통과
2026-02-22 Phase 2 완료: PrefixResolver, BendingInfoBuilder 확장(build→context+bending_info, buildDynamicBomForItem), DynamicBomEntry DTO, OrderService 연동
2026-02-22 Phase 1.1 + 3.1/3.2 완료: registerMaterialInput 통일 (work_order_item_id 분기+fallback+WorkOrderMaterialInput 레코드 생성), getMaterials dynamic_bom 우선체크 + N+1 배치최적화

7. 참고 문서

문서 경로
분석 기준 문서 docs/plans/bending-material-input-mapping-plan.md
선생산 재고 계획 docs/plans/bending-preproduction-stock-plan.md
BendingInfoBuilder api/app/Services/Production/BendingInfoBuilder.php
WorkOrderService api/app/Services/WorkOrderService.php
StockService api/app/Services/StockService.php
WorkOrderMaterialInput 모델 api/app/Models/Production/WorkOrderMaterialInput.php
MaterialInputModal react/src/components/production/WorkerScreen/MaterialInputModal.tsx
WorkerScreen actions react/src/components/production/WorkerScreen/actions.ts
Bending types/utils react/src/components/production/WorkOrders/documents/bending/
API 개발 규칙 docs/standards/api-rules.md
품질 체크리스트 docs/standards/quality-checklist.md

8. 세션 및 메모리 관리 정책 (Serena Optimized)

8.1 세션 시작 시 (Load Strategy)

read_memory("bending-lot-pipeline-state")           // 1. 상태 파악
read_memory("bending-lot-pipeline-snapshot")         // 2. 사고 흐름 복구
read_memory("bending-lot-pipeline-active-symbols")   // 3. 작업 대상 파악

8.2 작업 중 관리 (Context Defense)

컨텍스트 잔량 Action 내용
30% 이하 Snapshot write_memory("bending-lot-pipeline-snapshot", "코드변경+논의요약")
20% 이하 Context Purge write_memory("bending-lot-pipeline-active-symbols", "주요 수정 파일/함수")
10% 이하 Stop & Save 최종 상태 저장 후 세션 교체 권고

8.3 Serena 메모리 구조

  • bending-lot-pipeline-state: { phase, progress, next_step, last_decision }
  • bending-lot-pipeline-snapshot: 현재까지의 논의 및 코드 변경점 요약
  • bending-lot-pipeline-rules: 해당 작업에서 결정된 불변의 규칙들
  • bending-lot-pipeline-active-symbols: 현재 수정 중인 파일/심볼 리스트

9. 검증 결과

작업 완료 후 이 섹션에 검증 결과 추가

9.1 테스트 케이스

입력값 예상 결과 실제 결과 상태
KSS01 + SUS + 벽면형 + 4300mm BD-RS-43 (item_id 존재)
getMaterials (dynamic_bom 있는 WO) 세부품목 리스트 반환
자재투입 등록 work_order_material_inputs 레코드 생성
getMaterials (dynamic_bom 없는 WO) 기존 동작 (하위 호환)

9.2 성공 기준 달성 현황

기준 달성 비고
dynamic_bom 자동 생성 Phase 2 완료 후
getMaterials 세부품목 반환 Phase 3 완료 후
세부품목별 LOT 입력 가능 Phase 4 완료 후
자재투입 이력 100% 기록 Phase 1 완료 후
LOT prefix 체계 일치 Phase 0.2 검증 후

10. 자기완결성 점검 결과

10.1 체크리스트 검증

# 검증 항목 상태 비고
1 작업 목적이 명확한가? 1.1 배경
2 성공 기준이 정의되어 있는가? 1.5 성공 기준
3 작업 범위가 구체적인가? 섹션 2 대상 범위 (13개 태스크)
4 의존성이 명시되어 있는가? 3.2 의존성 맵
5 참고 파일 경로가 정확한가? 코드 분석 기반 확인
6 단계별 절차가 실행 가능한가? 섹션 4 상세 작업 내용
7 검증 방법이 명시되어 있는가? 각 태스크별 검증 항목
8 모호한 표현이 없는가? 라인 번호, 메서드명, 파일 경로 명시

10.2 새 세션 시뮬레이션 테스트

질문 답변 가능 참조 섹션
Q1. 이 작업의 목적은 무엇인가? 1.1 배경
Q2. 어디서부터 시작해야 하는가? 2.1 Phase 0 + 📍 현재 진행 상태
Q3. 어떤 파일을 수정해야 하는가? 섹션 4 (각 태스크별 수정/생성 파일 명시)
Q4. 작업 완료 확인 방법은? 9. 검증 결과 + 각 태스크별 검증 항목
Q5. 막혔을 때 참고 문서는? 7. 참고 문서

결과: 5/5 통과 → 자기완결성 확보


이 문서는 /sc:plan 스킬로 생성되었습니다.