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:
@@ -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 (수) - 슬랫 조인트바 자동 계산 및 데이터 파이프라인 완성
|
||||
|
||||
### 작업 목표
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user