fix(WEB): 방화유리 수량 폴백 제거 및 수주→작업지시 파이프라인 개선

- OrderService: glass_qty에서 quantity 폴백 제거 (투시창 선택 시에만 유효)
- OrderService: createProductionOrder()에서 절곡 공정 bending_info 자동 생성
- OrderService: formula_source 없는 레거시 데이터의 sort_order 기반 개소 분배
- OrderService: note 파싱에서 '-' 단독값 무시 처리
- FormulaEvaluatorService: 철재 W1 계산 (W0+160 → W0+110) 레거시 일치
- WorkOrderService: store()에서 order_node_id null 품목용 rootNodes fallback 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 01:02:23 +09:00
parent 3ab4f24bb4
commit 400adb7c58
4 changed files with 94 additions and 19 deletions

View File

@@ -1,3 +1,21 @@
## 2026-02-19 (수) - 작업지시 show() materialInputs eager loading 누락 수정
### 커밋 내역
- `23029b1` fix: 작업지시 단건조회(show)에 materialInputs eager loading 추가
### 수정된 파일
| 파일명 | 설명 |
|--------|------|
| `app/Services/WorkOrderService.php` | show()에 items.materialInputs + items.materialInputs.stockLot eager loading 추가 |
### 원인
- 목록조회(Line 59-64)에만 `items.materialInputs.stockLot` 있고, 단건조회 `show()`에는 누락
- 프론트 슬랫 작업일지에서 개소별 입고 LOT NO 표시 불가
### 상태: ✅ 완료
---
## 2026-02-19 (수) - 슬랫 조인트바 자동 계산 및 데이터 파이프라인 완성
### 작업 목표

View File

@@ -13,6 +13,7 @@
use App\Models\Production\WorkOrderMaterialInput;
use App\Models\Quote\Quote;
use App\Models\Tenants\Sale;
use App\Services\Production\BendingInfoBuilder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
@@ -43,7 +44,7 @@ public function index(array $params)
$query = Order::query()
->where('tenant_id', $tenantId)
->with(['client:id,name,manager_name', 'items', 'quote:id,quote_number'])
->with(['client:id,name,manager_name', 'items', 'quote:id,quote_number', 'rootNodes:id,order_id,name,options'])
->withSum('rootNodes', 'quantity');
// 작업지시 생성 가능한 수주만 필터링
@@ -677,6 +678,13 @@ public function createFromQuote(int $quoteId, array $data = [])
}
// 견적 품목을 수주 품목으로 변환 (노드 연결 포함)
// formula_source 없는 레거시 데이터용: sort_order 기반 분배 준비
$locationCount = count($productItems);
$hasFormulaSource = $quote->items->contains(fn ($item) => ! empty($item->formula_source));
$itemsPerLocation = (! $hasFormulaSource && $locationCount > 1)
? intdiv($quote->items->count(), $locationCount)
: 0;
foreach ($quote->items as $index => $quoteItem) {
$floorCode = null;
$symbolCode = null;
@@ -688,6 +696,11 @@ public function createFromQuote(int $quoteId, array $data = [])
$locIdx = (int) $matches[1];
}
// 2순위: sort_order 기반 분배 (formula_source 없는 레거시 데이터)
if ($locIdx === 0 && $itemsPerLocation > 0) {
$locIdx = min(intdiv($index, $itemsPerLocation), $locationCount - 1);
}
// calculation_inputs에서 floor/code 가져오기
if (isset($productItems[$locIdx])) {
$floorCode = $productItems[$locIdx]['floor'] ?? null;
@@ -700,15 +713,15 @@ public function createFromQuote(int $quoteId, array $data = [])
// calculation_inputs에서 못 찾은 경우 note에서 파싱 시도
if (empty($floorCode) && empty($symbolCode)) {
$note = trim($quoteItem->note ?? '');
if ($note !== '') {
if ($note !== '' && $note !== '-' && $note !== '- -') {
$parts = preg_split('/\s+/', $note, 2);
$floorCode = $parts[0] ?? null;
$symbolCode = $parts[1] ?? null;
$floorCode = ($parts[0] ?? null) !== '-' ? ($parts[0] ?? null) : null;
$symbolCode = ($parts[1] ?? null) !== '-' ? ($parts[1] ?? null) : null;
}
}
// note 파싱으로 locIdx 결정 (formula_source 없는 경우)
if ($locIdx === 0 && ! empty($floorCode) && ! empty($symbolCode)) {
if ($locIdx === 0 && ! $itemsPerLocation && ! empty($floorCode) && ! empty($symbolCode)) {
foreach ($productItems as $pidx => $pItem) {
if (($pItem['floor'] ?? '') === $floorCode && ($pItem['code'] ?? '') === $symbolCode) {
$locIdx = $pidx;
@@ -860,6 +873,13 @@ public function syncFromQuote(Quote $quote, int $revision): ?Order
}
// 견적 품목을 수주 품목으로 변환 (노드 연결 포함)
// formula_source 없는 레거시 데이터용: sort_order 기반 분배 준비
$locationCount = count($productItems);
$hasFormulaSource = $quote->items->contains(fn ($item) => ! empty($item->formula_source));
$itemsPerLocation = (! $hasFormulaSource && $locationCount > 1)
? intdiv($quote->items->count(), $locationCount)
: 0;
foreach ($quote->items as $index => $quoteItem) {
$floorCode = null;
$symbolCode = null;
@@ -871,15 +891,20 @@ public function syncFromQuote(Quote $quote, int $revision): ?Order
$locIdx = (int) $matches[1];
}
// note에서 floor/code 파싱
$note = trim($quoteItem->note ?? '');
if ($note !== '') {
$parts = preg_split('/\s+/', $note, 2);
$floorCode = $parts[0] ?? null;
$symbolCode = $parts[1] ?? null;
// 2순위: sort_order 기반 분배 (formula_source 없는 레거시 데이터)
if ($locIdx === 0 && $itemsPerLocation > 0) {
$locIdx = min(intdiv($index, $itemsPerLocation), $locationCount - 1);
}
// 2순위: note에서 파싱 실패 시 calculation_inputs에서 가져오기
// note에서 floor/code 파싱
$note = trim($quoteItem->note ?? '');
if ($note !== '' && $note !== '-' && $note !== '- -') {
$parts = preg_split('/\s+/', $note, 2);
$floorCode = ($parts[0] ?? null) !== '-' ? ($parts[0] ?? null) : null;
$symbolCode = ($parts[1] ?? null) !== '-' ? ($parts[1] ?? null) : null;
}
// 3순위: note에서 파싱 실패 시 calculation_inputs에서 가져오기
if (empty($floorCode) && empty($symbolCode)) {
if (isset($productItems[$locIdx])) {
$floorCode = $productItems[$locIdx]['floor'] ?? null;
@@ -891,7 +916,7 @@ public function syncFromQuote(Quote $quote, int $revision): ?Order
}
// note 파싱으로 locIdx 결정 (formula_source 없는 경우)
if ($locIdx === 0 && $note !== '') {
if ($locIdx === 0 && ! $itemsPerLocation && ! empty($floorCode) && ! empty($symbolCode)) {
foreach ($productItems as $pidx => $pItem) {
if (($pItem['floor'] ?? '') === ($floorCode ?? '') && ($pItem['code'] ?? '') === ($symbolCode ?? '')) {
$locIdx = $pidx;
@@ -1107,6 +1132,23 @@ public function createProductionOrder(int $orderId, array $data)
// 작업지시번호 생성
$workOrderNo = $this->generateWorkOrderNo($tenantId);
// 절곡 공정이면 bending_info 자동 생성
$workOrderOptions = null;
if ($processId) {
// 이 작업지시에 포함되는 노드 ID만 추출
$nodeIds = collect($items)
->pluck('order_node_id')
->filter()
->unique()
->values()
->all();
$bendingInfo = app(BendingInfoBuilder::class)->build($order, $processId, $nodeIds ?: null);
if ($bendingInfo) {
$workOrderOptions = ['bending_info' => $bendingInfo];
}
}
// 작업지시 생성
$workOrder = WorkOrder::create([
'tenant_id' => $tenantId,
@@ -1119,6 +1161,7 @@ public function createProductionOrder(int $orderId, array $data)
'team_id' => $data['team_id'] ?? null,
'scheduled_date' => $data['scheduled_date'] ?? $order->delivery_date,
'memo' => $data['memo'] ?? null,
'options' => $workOrderOptions,
'is_active' => true,
'created_by' => $userId,
'updated_by' => $userId,
@@ -1255,8 +1298,9 @@ private function extractSlatInfoFromBom(?array $bomResult, array $locItem = []):
}
}
// 방화유리 수량: 프론트 입력값 우선, 없으면 개소 수량(QTY)
$glassQty = (int) ($locItem['glass_qty'] ?? $bomVars['glass_qty'] ?? $locItem['quantity'] ?? 0);
// 방화유리 수량: 투시창 선택 시에만 유효 (프론트 입력값 또는 견적 BOM 변수)
// 레거시: col4_quartz='투시창'일 때만 col14에 수량 표시
$glassQty = (int) ($locItem['glass_qty'] ?? $bomVars['glass_qty'] ?? 0);
return [
'joint_bar' => $jointBarQty,

View File

@@ -1649,7 +1649,8 @@ private function calculateTenantBom(
// 핸들러는 파라미터로 이미 전달됨 (FormulaHandlerFactory가 생성)
// Step 3: 경동 전용 변수 계산 (제품타입별 면적/중량 공식)
$W1 = $W0 + 160;
// 스크린: +160, 철재: +110 (5130 레거시 기준)
$W1 = $W0 + ($productType === 'steel' ? 110 : 160);
$H1 = $H0 + 350;
if ($productType === 'slat') {
@@ -1730,8 +1731,8 @@ private function calculateTenantBom(
[
'var' => 'W1',
'desc' => '제작 폭',
'formula' => 'W0 + 160',
'calculation' => "{$W0} + 160",
'formula' => $productType === 'steel' ? 'W0 + 110' : 'W0 + 160',
'calculation' => $productType === 'steel' ? "{$W0} + 110" : "{$W0} + 160",
'result' => $W1,
'unit' => 'mm',
],

View File

@@ -264,11 +264,23 @@ public function store(array $data)
// 품목 저장: 직접 전달된 품목이 없고 수주 ID가 있으면 수주에서 복사
if (empty($items) && $salesOrderId) {
$salesOrder = \App\Models\Orders\Order::with('items.node')->find($salesOrderId);
$salesOrder = \App\Models\Orders\Order::with(['items.node', 'rootNodes'])->find($salesOrderId);
if ($salesOrder && $salesOrder->items->isNotEmpty()) {
// order_node_id가 null인 품목용 fallback: 수주의 root node를 인덱스로 매핑
$rootNodes = $salesOrder->rootNodes;
$rootNodeCount = $rootNodes->count();
foreach ($salesOrder->items as $index => $orderItem) {
// 수주 품목 + 노드에서 options 조합
// 1순위: 품목에 직접 연결된 node, 2순위: root node fallback
$nodeOptions = $orderItem->node?->options ?? [];
if (empty($nodeOptions) && $rootNodeCount > 0) {
// root node가 1개면 모든 품목이 해당 node, 여러 개면 인덱스 기반 분배
$fallbackNode = $rootNodeCount === 1
? $rootNodes->first()
: $rootNodes->values()->get($index % $rootNodeCount);
$nodeOptions = $fallbackNode?->options ?? [];
}
$options = array_filter([
'floor' => $orderItem->floor_code,
'code' => $orderItem->symbol_code,