From 5cc43828d36de157b27d8defec4dd7725aa26887 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Sat, 21 Feb 2026 02:03:35 +0900 Subject: [PATCH 01/26] =?UTF-8?q?fix(WEB):=20=EC=B2=A0=EC=9E=AC=20?= =?UTF-8?q?=EB=A9=B4=EC=A0=81=20=EA=B3=B5=EC=8B=9D=20=EB=A0=88=EA=B1=B0?= =?UTF-8?q?=EC=8B=9C=20=EC=9D=BC=EC=B9=98=20(W1=C3=97H1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FormulaEvaluatorService: steel 면적 W1×(H1+550) → W1×H1 Co-Authored-By: Claude Opus 4.6 --- app/Services/Quote/FormulaEvaluatorService.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/Services/Quote/FormulaEvaluatorService.php b/app/Services/Quote/FormulaEvaluatorService.php index 8cf6974..3720f3a 100644 --- a/app/Services/Quote/FormulaEvaluatorService.php +++ b/app/Services/Quote/FormulaEvaluatorService.php @@ -1662,11 +1662,11 @@ private function calculateTenantBom( $weightFormula = 'AREA × 25'; $weightCalc = "{$area} × 25"; } elseif ($productType === 'steel') { - // 철재: W1 × (H1 + 550) / 1M, 중량 = 면적 × 25 - $area = ($W1 * ($H1 + 550)) / 1000000; + // 철재: W1 × H1 / 1M, 중량 = 면적 × 25 (레거시 Slat_updateCol12and13 동일) + $area = ($W1 * $H1) / 1000000; $weight = $area * 25; - $areaFormula = '(W1 × (H1 + 550)) / 1,000,000'; - $areaCalc = "({$W1} × ({$H1} + 550)) / 1,000,000"; + $areaFormula = '(W1 × H1) / 1,000,000'; + $areaCalc = "({$W1} × {$H1}) / 1,000,000"; $weightFormula = 'AREA × 25'; $weightCalc = "{$area} × 25"; } else { From a2dbdae14b44d3dac0a736ccad9197342d401710 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Sat, 21 Feb 2026 07:22:35 +0900 Subject: [PATCH 02/26] =?UTF-8?q?fix(WEB):=20=EC=9E=85=EA=B3=A0=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=EA=B2=80=EC=A6=9D=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20-=20Store=20=EA=B7=9C=EC=B9=99=20=EB=B3=B4?= =?UTF-8?q?=EC=99=84=20=EB=B0=8F=20=EC=83=81=ED=83=9C=EA=B0=92=20=EC=A0=95?= =?UTF-8?q?=ED=95=A9=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StoreReceivingRequest에 receiving_qty, receiving_date, lot_no 규칙 추가 - UpdateReceivingRequest status에 inspection_completed 허용 추가 - ReceivingService store()에 receiving_qty/date/lot_no 저장 처리 - order_qty null 안전 처리, 기본 status를 receiving_pending으로 변경 Co-Authored-By: Claude Opus 4.6 --- app/Http/Requests/V1/Receiving/StoreReceivingRequest.php | 3 +++ app/Http/Requests/V1/Receiving/UpdateReceivingRequest.php | 2 +- app/Services/ReceivingService.php | 7 +++++-- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/app/Http/Requests/V1/Receiving/StoreReceivingRequest.php b/app/Http/Requests/V1/Receiving/StoreReceivingRequest.php index 4cc2380..56594ee 100644 --- a/app/Http/Requests/V1/Receiving/StoreReceivingRequest.php +++ b/app/Http/Requests/V1/Receiving/StoreReceivingRequest.php @@ -24,6 +24,9 @@ public function rules(): array 'order_qty' => ['nullable', 'numeric', 'min:0'], 'order_unit' => ['nullable', 'string', 'max:20'], 'due_date' => ['nullable', 'date'], + 'receiving_qty' => ['nullable', 'numeric', 'min:0'], + 'receiving_date' => ['nullable', 'date'], + 'lot_no' => ['nullable', 'string', 'max:50'], 'status' => ['nullable', 'string', 'in:order_completed,shipping,inspection_pending,receiving_pending'], 'remark' => ['nullable', 'string', 'max:1000'], ]; diff --git a/app/Http/Requests/V1/Receiving/UpdateReceivingRequest.php b/app/Http/Requests/V1/Receiving/UpdateReceivingRequest.php index 93a5789..61e2de9 100644 --- a/app/Http/Requests/V1/Receiving/UpdateReceivingRequest.php +++ b/app/Http/Requests/V1/Receiving/UpdateReceivingRequest.php @@ -23,7 +23,7 @@ public function rules(): array 'order_qty' => ['sometimes', 'numeric', 'min:0'], 'order_unit' => ['nullable', 'string', 'max:20'], 'due_date' => ['nullable', 'date'], - 'status' => ['sometimes', 'string', 'in:order_completed,shipping,inspection_pending,receiving_pending,completed'], + 'status' => ['sometimes', 'string', 'in:order_completed,shipping,inspection_pending,receiving_pending,completed,inspection_completed'], 'remark' => ['nullable', 'string', 'max:1000'], 'receiving_qty' => ['nullable', 'numeric', 'min:0'], 'receiving_date' => ['nullable', 'date'], diff --git a/app/Services/ReceivingService.php b/app/Services/ReceivingService.php index b2eeab6..ed005c6 100644 --- a/app/Services/ReceivingService.php +++ b/app/Services/ReceivingService.php @@ -194,10 +194,13 @@ public function store(array $data): Receiving $receiving->item_name = $data['item_name']; $receiving->specification = $data['specification'] ?? null; $receiving->supplier = $data['supplier']; - $receiving->order_qty = $data['order_qty']; + $receiving->order_qty = $data['order_qty'] ?? null; $receiving->order_unit = $data['order_unit'] ?? 'EA'; $receiving->due_date = $data['due_date'] ?? null; - $receiving->status = $data['status'] ?? 'order_completed'; + $receiving->receiving_qty = $data['receiving_qty'] ?? null; + $receiving->receiving_date = $data['receiving_date'] ?? null; + $receiving->lot_no = $data['lot_no'] ?? null; + $receiving->status = $data['status'] ?? 'receiving_pending'; $receiving->remark = $data['remark'] ?? null; // options 필드 처리 (제조사, 수입검사 등 확장 필드) From d7ca8cfa007df46a552312bbffe84e7db74b5afd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Sat, 21 Feb 2026 07:43:21 +0900 Subject: [PATCH 03/26] =?UTF-8?q?refactor:=EA=B2=AC=EC=A0=81=20converted?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=EB=A5=BC=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98(order=5Fid)=EC=9C=BC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Quote 모델에 getStatusAttribute() accessor 추가: order_id 존재 시 자동으로 'converted' 반환 - scopeConverted() → whereNotNull('order_id') 변경 - QuoteService/OrderService에서 status='converted' 직접 세팅 제거, order_id만 세팅 - 상태 필터 쿼리: converted는 order_id IS NOT NULL 기반 - 통계 쿼리: status='converted' → order_id IS NOT NULL - 수주 직접 등록 시에도 자동으로 수주전환 상태 반영 Co-Authored-By: Claude Opus 4.6 --- app/Models/Quote/Quote.php | 21 +++++++++++++++++++-- app/Services/OrderService.php | 3 +-- app/Services/Quote/QuoteService.php | 19 ++++++++++--------- app/Services/Stats/QuoteStatService.php | 2 +- 4 files changed, 31 insertions(+), 14 deletions(-) diff --git a/app/Models/Quote/Quote.php b/app/Models/Quote/Quote.php index c8bde74..2a6a900 100644 --- a/app/Models/Quote/Quote.php +++ b/app/Models/Quote/Quote.php @@ -254,7 +254,7 @@ public function scopeFinalized($query) public function scopeConverted($query) { - return $query->where('status', self::STATUS_CONVERTED); + return $query->whereNotNull('order_id'); } /** @@ -339,12 +339,29 @@ public function isEditable(): bool return true; } + /** + * 상태 접근자: order_id가 존재하면 자동으로 'converted' 반환 + * DB에 status='converted'를 저장하지 않고, 수주 존재 여부로 판별 + */ + public function getStatusAttribute($value): string + { + if ($this->order_id) { + return self::STATUS_CONVERTED; + } + + return $value; + } + /** * 삭제 가능 여부 확인 */ public function isDeletable(): bool { - return ! in_array($this->status, [self::STATUS_FINALIZED, self::STATUS_CONVERTED]); + if ($this->order_id) { + return false; + } + + return $this->getRawOriginal('status') !== self::STATUS_FINALIZED; } /** diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php index 8e8399b..59d32ed 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -755,9 +755,8 @@ public function createFromQuote(int $quoteId, array $data = []) $order->refresh(); $order->recalculateTotals()->save(); - // 견적 상태를 '수주전환완료'로 변경 + // 견적에 수주 연결 (status는 accessor가 자동으로 'converted' 반환) $quote->update([ - 'status' => Quote::STATUS_CONVERTED, 'order_id' => $order->id, 'updated_by' => $userId, ]); diff --git a/app/Services/Quote/QuoteService.php b/app/Services/Quote/QuoteService.php index 021b3f1..cc71695 100644 --- a/app/Services/Quote/QuoteService.php +++ b/app/Services/Quote/QuoteService.php @@ -73,9 +73,11 @@ public function index(array $params): LengthAwarePaginator $query->where('quote_type', $quoteType); } - // 상태 필터 - if ($status) { - $query->where('status', $status); + // 상태 필터 (converted는 order_id 기반으로 판별) + if ($status === Quote::STATUS_CONVERTED) { + $query->whereNotNull('order_id'); + } elseif ($status) { + $query->where('status', $status)->whereNull('order_id'); } // 제품 카테고리 필터 @@ -592,12 +594,12 @@ public function cancelFinalize(int $id): Quote throw new NotFoundHttpException(__('error.quote_not_found')); } - if ($quote->status !== Quote::STATUS_FINALIZED) { - throw new BadRequestHttpException(__('error.quote_not_finalized')); + if ($quote->order_id) { + throw new BadRequestHttpException(__('error.quote_already_converted')); } - if ($quote->status === Quote::STATUS_CONVERTED) { - throw new BadRequestHttpException(__('error.quote_already_converted')); + if ($quote->getRawOriginal('status') !== Quote::STATUS_FINALIZED) { + throw new BadRequestHttpException(__('error.quote_not_finalized')); } $quote->update([ @@ -705,9 +707,8 @@ public function convertToOrder(int $id): Quote $order->recalculateTotals(); $order->save(); - // 견적 상태 변경 + // 견적에 수주 연결 (status는 accessor가 자동으로 'converted' 반환) $quote->update([ - 'status' => Quote::STATUS_CONVERTED, 'order_id' => $order->id, 'updated_by' => $userId, ]); diff --git a/app/Services/Stats/QuoteStatService.php b/app/Services/Stats/QuoteStatService.php index 5a09be9..49c6694 100644 --- a/app/Services/Stats/QuoteStatService.php +++ b/app/Services/Stats/QuoteStatService.php @@ -23,7 +23,7 @@ public function aggregateDaily(int $tenantId, Carbon $date): int COALESCE(SUM(total_amount), 0) as total_amount, SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END) as approved_count, SUM(CASE WHEN status = 'rejected' THEN 1 ELSE 0 END) as rejected_count, - SUM(CASE WHEN status = 'converted' THEN 1 ELSE 0 END) as conversion_count + SUM(CASE WHEN order_id IS NOT NULL THEN 1 ELSE 0 END) as conversion_count ") ->first(); From 37424b9cef734dff00feeee716e81313713a79b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Sat, 21 Feb 2026 08:29:53 +0900 Subject: [PATCH 04/26] =?UTF-8?q?feat(WEB):=20=EC=88=98=EC=A3=BC=20Bulk=20?= =?UTF-8?q?Delete=20API=20+=20=EC=9E=91=EC=97=85=EC=A7=80=EC=8B=9C=20Rever?= =?UTF-8?q?t=20Force=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 수주 일괄 삭제 API 추가 (DELETE /orders/bulk) - OrderBulkDeleteRequest (ids, force 검증) - force=true: hard delete (운영환경 차단), force=false: soft delete - 삭제 불가 건(상태/작업지시/출하) skip 처리 + skipped_ids 반환 - 작업지시 되돌리기 force/운영 모드 분기 - force=true (개발): 기존 hard delete 로직 유지 - force=false (운영): 작업지시 cancelled 상태 변경, options에 취소정보 기록, 자재 투입분 재고 역분개, 데이터 보존 - reason 필수 (운영 모드) - WorkOrder 모델에 STATUS_CANCELLED 상수 추가 Co-Authored-By: Claude Opus 4.6 --- .../Controllers/Api/V1/OrderController.php | 25 ++- .../Requests/Order/OrderBulkDeleteRequest.php | 22 ++ app/Models/Production/WorkOrder.php | 3 + app/Services/OrderService.php | 195 +++++++++++++++++- lang/ko/error.php | 2 + lang/ko/message.php | 1 + routes/api/v1/sales.php | 1 + 7 files changed, 243 insertions(+), 6 deletions(-) create mode 100644 app/Http/Requests/Order/OrderBulkDeleteRequest.php diff --git a/app/Http/Controllers/Api/V1/OrderController.php b/app/Http/Controllers/Api/V1/OrderController.php index f6935bc..0138723 100644 --- a/app/Http/Controllers/Api/V1/OrderController.php +++ b/app/Http/Controllers/Api/V1/OrderController.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Controller; use App\Http\Requests\Order\CreateFromQuoteRequest; use App\Http\Requests\Order\CreateProductionOrderRequest; +use App\Http\Requests\Order\OrderBulkDeleteRequest; use App\Http\Requests\Order\StoreOrderRequest; use App\Http\Requests\Order\UpdateOrderRequest; use App\Http\Requests\Order\UpdateOrderStatusRequest; @@ -66,6 +67,21 @@ public function update(UpdateOrderRequest $request, int $id) }, __('message.order.updated')); } + /** + * 일괄 삭제 + */ + public function bulkDestroy(OrderBulkDeleteRequest $request) + { + return ApiResponse::handle(function () use ($request) { + $validated = $request->validated(); + + return $this->service->bulkDestroy( + $validated['ids'], + $validated['force'] ?? false + ); + }, __('message.order.bulk_deleted')); + } + /** * 삭제 */ @@ -121,10 +137,13 @@ public function revertOrderConfirmation(int $id) /** * 생산지시 되돌리기 (작업지시 및 관련 데이터 삭제) */ - public function revertProductionOrder(int $id) + public function revertProductionOrder(Request $request, int $id) { - return ApiResponse::handle(function () use ($id) { - return $this->service->revertProductionOrder($id); + $force = $request->boolean('force', false); + $reason = $request->input('reason'); + + return ApiResponse::handle(function () use ($id, $force, $reason) { + return $this->service->revertProductionOrder($id, $force, $reason); }, __('message.order.production_order_reverted')); } } diff --git a/app/Http/Requests/Order/OrderBulkDeleteRequest.php b/app/Http/Requests/Order/OrderBulkDeleteRequest.php new file mode 100644 index 0000000..7cdc85f --- /dev/null +++ b/app/Http/Requests/Order/OrderBulkDeleteRequest.php @@ -0,0 +1,22 @@ + 'required|array|min:1', + 'ids.*' => 'required|integer', + 'force' => 'sometimes|boolean', + ]; + } +} diff --git a/app/Models/Production/WorkOrder.php b/app/Models/Production/WorkOrder.php index 2ca6480..4715845 100644 --- a/app/Models/Production/WorkOrder.php +++ b/app/Models/Production/WorkOrder.php @@ -101,6 +101,8 @@ class WorkOrder extends Model public const STATUS_SHIPPED = 'shipped'; // 출하 + public const STATUS_CANCELLED = 'cancelled'; // 취소 + public const STATUSES = [ self::STATUS_UNASSIGNED, self::STATUS_PENDING, @@ -108,6 +110,7 @@ class WorkOrder extends Model self::STATUS_IN_PROGRESS, self::STATUS_COMPLETED, self::STATUS_SHIPPED, + self::STATUS_CANCELLED, ]; /** diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php index 59d32ed..2738d57 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -402,6 +402,98 @@ public function destroy(int $id) }); } + /** + * 일괄 삭제 + */ + public function bulkDestroy(array $ids, bool $force = false): array + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + // force=true는 운영 환경에서 차단 + if ($force && app()->environment('production')) { + throw new \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException(__('error.forbidden')); + } + + $orders = Order::where('tenant_id', $tenantId) + ->whereIn('id', $ids) + ->get(); + + $deletedCount = 0; + $skippedIds = []; + + return DB::transaction(function () use ($orders, $force, $userId, &$deletedCount, &$skippedIds) { + foreach ($orders as $order) { + // 상태 검증: DRAFT/CONFIRMED/CANCELLED만 삭제 가능 + if (! in_array($order->status_code, [ + Order::STATUS_DRAFT, + Order::STATUS_CONFIRMED, + Order::STATUS_CANCELLED, + ])) { + $skippedIds[] = $order->id; + + continue; + } + + // 작업지시 존재 시 skip + if ($order->workOrders()->exists()) { + $skippedIds[] = $order->id; + + continue; + } + + // 출하 존재 시 skip + if ($order->shipments()->exists()) { + $skippedIds[] = $order->id; + + continue; + } + + // 견적 연결 해제 + if ($order->quote_id) { + Quote::withoutGlobalScopes() + ->where('id', $order->quote_id) + ->where('order_id', $order->id) + ->update([ + 'order_id' => null, + 'status' => Quote::STATUS_FINALIZED, + ]); + } + + if ($force) { + // hard delete: 컴포넌트 → 품목 → 노드 → 마스터 + foreach ($order->items as $item) { + $item->components()->forceDelete(); + } + $order->items()->forceDelete(); + $order->nodes()->forceDelete(); + $order->forceDelete(); + } else { + // soft delete: 기존 destroy() 로직과 동일 + foreach ($order->items as $item) { + $item->components()->update(['deleted_by' => $userId]); + $item->components()->delete(); + } + $order->items()->update(['deleted_by' => $userId]); + $order->items()->delete(); + $order->nodes()->update(['deleted_by' => $userId]); + $order->nodes()->delete(); + $order->deleted_by = $userId; + $order->save(); + $order->delete(); + } + + $deletedCount++; + } + + return [ + 'deleted_count' => $deletedCount, + 'skipped_count' => count($skippedIds), + 'skipped_ids' => $skippedIds, + ]; + }); + } + /** * 상태 변경 */ @@ -1390,13 +1482,26 @@ public function revertOrderConfirmation(int $orderId): array } /** - * 생산지시 되돌리기 (작업지시 및 관련 데이터 삭제) + * 생산지시 되돌리기 + * + * force=true: 개발 모드 - 모든 데이터 hard delete + 재고 복구 (운영환경 차단) + * force=false: 운영 모드 - 작업지시 취소 상태 변경 + 재고 역분개 (데이터 보존) */ - public function revertProductionOrder(int $orderId): array + public function revertProductionOrder(int $orderId, bool $force = false, ?string $reason = null): array { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); + // force=true는 운영 환경에서 차단 + if ($force && app()->environment('production')) { + throw new \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException(__('error.forbidden')); + } + + // 운영 모드에서는 reason 필수 + if (! $force && empty($reason)) { + throw new BadRequestHttpException(__('error.order.cancel_reason_required')); + } + // 수주 조회 $order = Order::where('tenant_id', $tenantId) ->find($orderId); @@ -1410,8 +1515,19 @@ public function revertProductionOrder(int $orderId): array throw new BadRequestHttpException(__('error.order.cannot_revert_completed')); } + if ($force) { + return $this->revertProductionOrderForce($order, $tenantId, $userId); + } + + return $this->revertProductionOrderCancel($order, $tenantId, $userId, $reason); + } + + /** + * 생산지시 되돌리기 - 개발 모드 (hard delete) + */ + private function revertProductionOrderForce(Order $order, int $tenantId, int $userId): array + { return DB::transaction(function () use ($order, $tenantId, $userId) { - // 관련 작업지시 ID 조회 $workOrderIds = WorkOrder::where('tenant_id', $tenantId) ->where('sales_order_id', $order->id) ->pluck('id') @@ -1514,4 +1630,77 @@ public function revertProductionOrder(int $orderId): array ]; }); } + + /** + * 생산지시 되돌리기 - 운영 모드 (취소 상태 변경, 데이터 보존) + */ + private function revertProductionOrderCancel(Order $order, int $tenantId, int $userId, string $reason): array + { + return DB::transaction(function () use ($order, $tenantId, $userId, $reason) { + $workOrders = WorkOrder::where('tenant_id', $tenantId) + ->where('sales_order_id', $order->id) + ->get(); + + $cancelledCount = 0; + $skippedIds = []; + + $stockService = app(StockService::class); + + foreach ($workOrders as $workOrder) { + // completed/shipped 상태는 취소 거부 + if (in_array($workOrder->status, [WorkOrder::STATUS_COMPLETED, WorkOrder::STATUS_SHIPPED])) { + $skippedIds[] = $workOrder->id; + + continue; + } + + // 작업지시 상태를 cancelled로 변경 + $workOrder->status = WorkOrder::STATUS_CANCELLED; + $workOrder->updated_by = $userId; + + // options에 취소 정보 기록 + $options = $workOrder->options ?? []; + $options['cancelled_at'] = now()->toIso8601String(); + $options['cancelled_by'] = $userId; + $options['cancel_reason'] = $reason; + $workOrder->options = $options; + $workOrder->save(); + + // 자재 투입분 재고 역분개 + $materialInputs = WorkOrderMaterialInput::where('work_order_id', $workOrder->id)->get(); + foreach ($materialInputs as $input) { + try { + $stockService->increaseToLot( + stockLotId: $input->stock_lot_id, + qty: (float) $input->qty, + reason: 'work_order_cancel', + referenceId: $workOrder->id + ); + } catch (\Exception $e) { + Log::warning('생산지시 취소: 재고 복원 실패', [ + 'input_id' => $input->id, + 'stock_lot_id' => $input->stock_lot_id, + 'error' => $e->getMessage(), + ]); + } + } + + $cancelledCount++; + } + + // 수주 상태를 CONFIRMED로 복원 + $previousStatus = $order->status_code; + $order->status_code = Order::STATUS_CONFIRMED; + $order->updated_by = $userId; + $order->save(); + + return [ + 'order' => $order->load(['client:id,name', 'items']), + 'cancelled_count' => $cancelledCount, + 'skipped_count' => count($skippedIds), + 'skipped_ids' => $skippedIds, + 'previous_status' => $previousStatus, + ]; + }); + } } diff --git a/lang/ko/error.php b/lang/ko/error.php index 59f5b04..d48bf30 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -431,6 +431,8 @@ 'production_order_already_exists' => '이미 생산지시가 존재합니다.', 'cannot_revert_completed' => '완료된 수주의 생산지시는 되돌릴 수 없습니다.', 'cannot_revert_not_confirmed' => '수주확정 상태에서만 되돌리기가 가능합니다.', + 'cannot_revert_work_order_completed' => '완료 또는 출하된 작업지시는 취소할 수 없습니다.', + 'cancel_reason_required' => '취소 사유를 입력해주세요.', 'cannot_sync_after_production' => '생산지시 이후의 수주는 견적에서 자동 동기화할 수 없습니다.', ], diff --git a/lang/ko/message.php b/lang/ko/message.php index a907f89..c30fbb1 100644 --- a/lang/ko/message.php +++ b/lang/ko/message.php @@ -468,6 +468,7 @@ 'created' => '수주가 등록되었습니다.', 'updated' => '수주가 수정되었습니다.', 'deleted' => '수주가 삭제되었습니다.', + 'bulk_deleted' => '수주가 일괄 삭제되었습니다.', 'status_updated' => '수주 상태가 변경되었습니다.', 'created_from_quote' => '견적에서 수주가 생성되었습니다.', 'production_order_created' => '생산지시가 생성되었습니다.', diff --git a/routes/api/v1/sales.php b/routes/api/v1/sales.php index 3330cfb..1398d41 100644 --- a/routes/api/v1/sales.php +++ b/routes/api/v1/sales.php @@ -152,6 +152,7 @@ Route::get('', [OrderController::class, 'index'])->name('v1.orders.index'); // 목록 Route::get('/stats', [OrderController::class, 'stats'])->name('v1.orders.stats'); // 통계 Route::post('', [OrderController::class, 'store'])->name('v1.orders.store'); // 생성 + Route::delete('/bulk', [OrderController::class, 'bulkDestroy'])->name('v1.orders.bulk-destroy'); // 일괄 삭제 Route::get('/{id}', [OrderController::class, 'show'])->whereNumber('id')->name('v1.orders.show'); // 상세 Route::put('/{id}', [OrderController::class, 'update'])->whereNumber('id')->name('v1.orders.update'); // 수정 Route::delete('/{id}', [OrderController::class, 'destroy'])->whereNumber('id')->name('v1.orders.destroy'); // 삭제 From c637dd38ebd65cb2d80e98ab10bd2f4935282ac2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Sat, 21 Feb 2026 08:56:50 +0900 Subject: [PATCH 05/26] =?UTF-8?q?docs(WEB):=20=EC=88=98=EC=A3=BC=20Swagger?= =?UTF-8?q?=20=EB=AC=B8=EC=84=9C=20=EC=B6=94=EA=B0=80=20-=20bulkDestroy,?= =?UTF-8?q?=20revertProductionOrder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OrderBulkDeleteRequest 스키마 추가 (ids, force) - OrderRevertProductionRequest 스키마 추가 (force, reason) - DELETE /api/v1/orders/bulk 엔드포인트 문서 추가 - POST /api/v1/orders/{id}/revert-production 엔드포인트 문서 추가 Co-Authored-By: Claude Opus 4.6 --- app/Swagger/v1/OrderApi.php | 99 +++++++++++++ storage/api-docs/api-docs-v1.json | 224 ++++++++++++++++++++++++++++++ 2 files changed, 323 insertions(+) diff --git a/app/Swagger/v1/OrderApi.php b/app/Swagger/v1/OrderApi.php index 6f6a0de..38d26ff 100644 --- a/app/Swagger/v1/OrderApi.php +++ b/app/Swagger/v1/OrderApi.php @@ -153,6 +153,23 @@ * * @OA\Property(property="status", type="string", enum={"DRAFT", "CONFIRMED", "IN_PROGRESS", "COMPLETED", "CANCELLED"}, example="CONFIRMED") * ) + * + * @OA\Schema( + * schema="OrderBulkDeleteRequest", + * type="object", + * required={"ids"}, + * + * @OA\Property(property="ids", type="array", @OA\Items(type="integer"), example={1, 2, 3}), + * @OA\Property(property="force", type="boolean", description="강제 삭제 여부 (진행중 수주 포함)", example=false) + * ) + * + * @OA\Schema( + * schema="OrderRevertProductionRequest", + * type="object", + * + * @OA\Property(property="force", type="boolean", description="강제 되돌리기 (물리 삭제, 기본값 false)", example=false), + * @OA\Property(property="reason", type="string", description="되돌리기 사유 (운영 모드 시 필수)", example="고객 요청에 의한 생산지시 취소") + * ) */ class OrderApi { @@ -364,4 +381,86 @@ public function destroy() {} * ) */ public function updateStatus() {} + + /** + * @OA\Delete( + * path="/api/v1/orders/bulk", + * tags={"Order"}, + * summary="수주 일괄 삭제", + * description="여러 수주를 일괄 삭제합니다 (Soft Delete). 진행중/완료 수주는 건너뜁니다.", + * security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}}, + * + * @OA\RequestBody( + * required=true, + * + * @OA\JsonContent(ref="#/components/schemas/OrderBulkDeleteRequest") + * ), + * + * @OA\Response( + * response=200, + * description="성공", + * + * @OA\JsonContent( + * + * @OA\Property(property="success", type="boolean", example=true), + * @OA\Property(property="message", type="string"), + * @OA\Property(property="data", type="object", + * @OA\Property(property="deleted_count", type="integer", example=3), + * @OA\Property(property="skipped_count", type="integer", example=1), + * @OA\Property(property="skipped_ids", type="array", @OA\Items(type="integer"), example={5}) + * ) + * ) + * ), + * + * @OA\Response(response=422, description="유효성 검증 실패") + * ) + */ + public function bulkDestroy() {} + + /** + * @OA\Post( + * path="/api/v1/orders/{id}/revert-production", + * tags={"Order"}, + * summary="생산지시 되돌리기", + * description="생산지시를 되돌립니다. 기본 모드(force=false)에서는 작업지시를 취소 처리하며, 강제 모드(force=true)에서는 물리 삭제합니다.", + * security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}}, + * + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * + * @OA\RequestBody( + * required=false, + * + * @OA\JsonContent(ref="#/components/schemas/OrderRevertProductionRequest") + * ), + * + * @OA\Response( + * response=200, + * description="성공", + * + * @OA\JsonContent( + * + * @OA\Property(property="success", type="boolean", example=true), + * @OA\Property(property="message", type="string"), + * @OA\Property(property="data", type="object", + * @OA\Property(property="order", ref="#/components/schemas/Order"), + * @OA\Property(property="deleted_counts", type="object", + * @OA\Property(property="work_results", type="integer", example=0), + * @OA\Property(property="work_order_items", type="integer", example=5), + * @OA\Property(property="work_orders", type="integer", example=2) + * ), + * @OA\Property(property="cancelled_counts", type="object", nullable=true, + * @OA\Property(property="work_orders", type="integer", example=2), + * @OA\Property(property="work_order_items", type="integer", example=5) + * ), + * @OA\Property(property="previous_status", type="string", example="IN_PROGRESS") + * ) + * ) + * ), + * + * @OA\Response(response=400, description="되돌리기 불가 상태 (수주확정/수주등록 상태)"), + * @OA\Response(response=404, description="수주를 찾을 수 없음"), + * @OA\Response(response=422, description="운영 모드에서 사유 미입력") + * ) + */ + public function revertProductionOrder() {} } diff --git a/storage/api-docs/api-docs-v1.json b/storage/api-docs/api-docs-v1.json index 1cd2532..a12284d 100755 --- a/storage/api-docs/api-docs-v1.json +++ b/storage/api-docs/api-docs-v1.json @@ -35625,6 +35625,191 @@ ] } }, + "/api/v1/orders/bulk": { + "delete": { + "tags": [ + "Order" + ], + "summary": "수주 일괄 삭제", + "description": "여러 수주를 일괄 삭제합니다 (Soft Delete). 진행중/완료 수주는 건너뜁니다.", + "operationId": "84fb75f391c22af2356913507ddb98d1", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrderBulkDeleteRequest" + } + } + } + }, + "responses": { + "200": { + "description": "성공", + "content": { + "application/json": { + "schema": { + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string" + }, + "data": { + "properties": { + "deleted_count": { + "type": "integer", + "example": 3 + }, + "skipped_count": { + "type": "integer", + "example": 1 + }, + "skipped_ids": { + "type": "array", + "items": { + "type": "integer" + }, + "example": [ + 5 + ] + } + }, + "type": "object" + } + }, + "type": "object" + } + } + } + }, + "422": { + "description": "유효성 검증 실패" + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ] + } + }, + "/api/v1/orders/{id}/revert-production": { + "post": { + "tags": [ + "Order" + ], + "summary": "생산지시 되돌리기", + "description": "생산지시를 되돌립니다. 기본 모드(force=false)에서는 작업지시를 취소 처리하며, 강제 모드(force=true)에서는 물리 삭제합니다.", + "operationId": "73634a58fa6ef750ab26e38747d03f65", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrderRevertProductionRequest" + } + } + } + }, + "responses": { + "200": { + "description": "성공", + "content": { + "application/json": { + "schema": { + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string" + }, + "data": { + "properties": { + "order": { + "$ref": "#/components/schemas/Order" + }, + "deleted_counts": { + "properties": { + "work_results": { + "type": "integer", + "example": 0 + }, + "work_order_items": { + "type": "integer", + "example": 5 + }, + "work_orders": { + "type": "integer", + "example": 2 + } + }, + "type": "object" + }, + "cancelled_counts": { + "properties": { + "work_orders": { + "type": "integer", + "example": 2 + }, + "work_order_items": { + "type": "integer", + "example": 5 + } + }, + "type": "object", + "nullable": true + }, + "previous_status": { + "type": "string", + "example": "IN_PROGRESS" + } + }, + "type": "object" + } + }, + "type": "object" + } + } + } + }, + "400": { + "description": "되돌리기 불가 상태 (수주확정/수주등록 상태)" + }, + "404": { + "description": "수주를 찾을 수 없음" + }, + "422": { + "description": "운영 모드에서 사유 미입력" + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ] + } + }, "/api/v1/payments": { "get": { "tags": [ @@ -79288,6 +79473,45 @@ }, "type": "object" }, + "OrderBulkDeleteRequest": { + "required": [ + "ids" + ], + "properties": { + "ids": { + "type": "array", + "items": { + "type": "integer" + }, + "example": [ + 1, + 2, + 3 + ] + }, + "force": { + "description": "강제 삭제 여부 (진행중 수주 포함)", + "type": "boolean", + "example": false + } + }, + "type": "object" + }, + "OrderRevertProductionRequest": { + "properties": { + "force": { + "description": "강제 되돌리기 (물리 삭제, 기본값 false)", + "type": "boolean", + "example": false + }, + "reason": { + "description": "되돌리기 사유 (운영 모드 시 필수)", + "type": "string", + "example": "고객 요청에 의한 생산지시 취소" + } + }, + "type": "object" + }, "Payment": { "description": "결제 정보", "properties": { From f8858cf1b794cb202dd92f8193123990f0ef646e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Sat, 21 Feb 2026 09:04:36 +0900 Subject: [PATCH 06/26] =?UTF-8?q?fix(WEB):=20=EC=88=98=EC=A3=BC=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EB=B3=80=EA=B2=BD=20API=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=EC=97=90=20=EA=B3=B5=ED=86=B5=20relations=20=EB=A1=9C=EB=94=A9?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - loadDetailRelations() 공통 메서드 추가 (show()와 동일한 relations 보장) - store/update/updateStatus/createFromQuote/revert 등 11곳 일괄 적용 - 수주확정/되돌리기 시 제품내용이 기타부품으로 매핑되던 문제 해결 - 원인: updateStatus 등에서 quote relation 미로딩 → products 빈 배열 Co-Authored-By: Claude Opus 4.6 --- app/Services/OrderService.php | 42 ++++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php index 2738d57..a040d13 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -25,6 +25,19 @@ public function __construct( private NumberingService $numberingService ) {} + /** + * 상세 조회용 공통 relations (show와 동일한 구조 보장) + */ + private function loadDetailRelations(Order $order): Order + { + return $order->load([ + 'client:id,name,contact_person,phone,email,manager_name', + 'items' => fn ($q) => $q->orderBy('sort_order'), + 'rootNodes' => fn ($q) => $q->withRecursiveChildren(), + 'quote:id,quote_number,site_name,calculation_inputs', + ]); + } + /** * 목록 조회 (검색/필터링/페이징) */ @@ -131,20 +144,13 @@ public function show(int $id) { $tenantId = $this->tenantId(); - $order = Order::where('tenant_id', $tenantId) - ->with([ - 'client:id,name,contact_person,phone,email,manager_name', - 'items' => fn ($q) => $q->orderBy('sort_order'), - 'rootNodes' => fn ($q) => $q->withRecursiveChildren(), - 'quote:id,quote_number,site_name,calculation_inputs', - ]) - ->find($id); + $order = Order::where('tenant_id', $tenantId)->find($id); if (! $order) { throw new NotFoundHttpException(__('error.not_found')); } - return $order; + return $this->loadDetailRelations($order); } /** @@ -260,7 +266,7 @@ public function store(array $data) $order->refresh(); $order->recalculateTotals()->save(); - return $order->load(['client:id,name', 'items']); + return $this->loadDetailRelations($order); }); } @@ -326,7 +332,7 @@ public function update(int $id, array $data) $order->recalculateTotals()->save(); } - return $order->load(['client:id,name', 'items']); + return $this->loadDetailRelations($order); }); } @@ -549,7 +555,7 @@ public function updateStatus(int $id, string $status) $order->updated_by = $userId; $order->save(); - $result = $order->load(['client:id,name', 'items']); + $result = $this->loadDetailRelations($order); // 매출이 생성된 경우 응답에 포함 if ($createdSale) { @@ -853,7 +859,7 @@ public function createFromQuote(int $quoteId, array $data = []) 'updated_by' => $userId, ]); - return $order->load(['client:id,name', 'items', 'quote:id,quote_number']); + return $this->loadDetailRelations($order); }); } @@ -1065,7 +1071,7 @@ public function syncFromQuote(Quote $quote, int $revision): ?Order 'created_by' => $userId, ]); - return $order->load(['client:id,name', 'items', 'quote:id,quote_number']); + return $this->loadDetailRelations($order); }); } @@ -1342,7 +1348,7 @@ public function createProductionOrder(int $orderId, array $data) return [ 'work_orders' => $workOrders, 'work_order' => $workOrders[0] ?? null, // 하위 호환성 - 'order' => $order->load(['client:id,name', 'items']), + 'order' => $this->loadDetailRelations($order), ]; }); } @@ -1474,7 +1480,7 @@ public function revertOrderConfirmation(int $orderId): array $order->save(); return [ - 'order' => $order->load(['client:id,name', 'items']), + 'order' => $this->loadDetailRelations($order), 'previous_status' => $previousStatus, 'deleted_sale_id' => $deletedSaleId, ]; @@ -1624,7 +1630,7 @@ private function revertProductionOrderForce(Order $order, int $tenantId, int $us $order->save(); return [ - 'order' => $order->load(['client:id,name', 'items']), + 'order' => $this->loadDetailRelations($order), 'deleted_counts' => $deletedCounts, 'previous_status' => $previousStatus, ]; @@ -1695,7 +1701,7 @@ private function revertProductionOrderCancel(Order $order, int $tenantId, int $u $order->save(); return [ - 'order' => $order->load(['client:id,name', 'items']), + 'order' => $this->loadDetailRelations($order), 'cancelled_count' => $cancelledCount, 'skipped_count' => count($skippedIds), 'skipped_ids' => $skippedIds, From ba49313ffa98e0095cf5a46f17766515dee575e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Sat, 21 Feb 2026 09:49:00 +0900 Subject: [PATCH 07/26] =?UTF-8?q?fix(WEB):=20=EC=88=98=EC=A3=BC=20?= =?UTF-8?q?=EC=99=84=EC=A0=84=EC=82=AD=EC=A0=9C(force)=20=EC=8B=9C=20?= =?UTF-8?q?=EC=83=9D=EC=82=B0=EC=A7=80=EC=8B=9C=EC=99=84=EB=A3=8C=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=B2=98=EB=A6=AC=20=EB=B0=8F=20skip=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bulkDestroy force=true일 때 상태 체크 bypass, 연관 작업지시 데이터 모두 삭제 - forceDeleteWorkOrders() 헬퍼: 자재투입 재고복구, 문서, 부속데이터 정리 후 hard delete Co-Authored-By: Claude Opus 4.6 --- app/Services/OrderService.php | 123 ++++++++++++++++++++++++++++------ 1 file changed, 104 insertions(+), 19 deletions(-) diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php index a040d13..70fd65e 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -428,31 +428,34 @@ public function bulkDestroy(array $ids, bool $force = false): array $deletedCount = 0; $skippedIds = []; - return DB::transaction(function () use ($orders, $force, $userId, &$deletedCount, &$skippedIds) { + return DB::transaction(function () use ($orders, $force, $userId, $tenantId, &$deletedCount, &$skippedIds) { foreach ($orders as $order) { - // 상태 검증: DRAFT/CONFIRMED/CANCELLED만 삭제 가능 - if (! in_array($order->status_code, [ - Order::STATUS_DRAFT, - Order::STATUS_CONFIRMED, - Order::STATUS_CANCELLED, - ])) { - $skippedIds[] = $order->id; + if ($force) { + // force=true (개발환경 완전삭제): 모든 상태 허용, 연관 데이터 모두 삭제 + $this->forceDeleteWorkOrders($order, $tenantId); + } else { + // 일반 삭제: 상태/작업지시/출하 검증 + if (! in_array($order->status_code, [ + Order::STATUS_DRAFT, + Order::STATUS_CONFIRMED, + Order::STATUS_CANCELLED, + ])) { + $skippedIds[] = $order->id; - continue; - } + continue; + } - // 작업지시 존재 시 skip - if ($order->workOrders()->exists()) { - $skippedIds[] = $order->id; + if ($order->workOrders()->exists()) { + $skippedIds[] = $order->id; - continue; - } + continue; + } - // 출하 존재 시 skip - if ($order->shipments()->exists()) { - $skippedIds[] = $order->id; + if ($order->shipments()->exists()) { + $skippedIds[] = $order->id; - continue; + continue; + } } // 견적 연결 해제 @@ -500,6 +503,72 @@ public function bulkDestroy(array $ids, bool $force = false): array }); } + /** + * 작업지시 및 연관 데이터 강제 삭제 (개발환경 완전삭제용) + */ + private function forceDeleteWorkOrders(Order $order, int $tenantId): void + { + $workOrderIds = WorkOrder::where('tenant_id', $tenantId) + ->where('sales_order_id', $order->id) + ->pluck('id') + ->toArray(); + + if (empty($workOrderIds)) { + return; + } + + // 1. 자재 투입 재고 복구 + 삭제 + $materialInputs = WorkOrderMaterialInput::whereIn('work_order_id', $workOrderIds)->get(); + if ($materialInputs->isNotEmpty()) { + $stockService = app(StockService::class); + foreach ($materialInputs as $input) { + try { + $stockService->increaseToLot( + stockLotId: $input->stock_lot_id, + qty: (float) $input->qty, + reason: 'work_order_input_cancel', + referenceId: $input->work_order_id + ); + } catch (\Exception $e) { + Log::warning('완전삭제: 재고 복원 실패', [ + 'input_id' => $input->id, + 'stock_lot_id' => $input->stock_lot_id, + 'error' => $e->getMessage(), + ]); + } + } + WorkOrderMaterialInput::whereIn('work_order_id', $workOrderIds)->delete(); + } + + // 2. 문서 삭제 + $documentIds = Document::where('linkable_type', 'work_order') + ->whereIn('linkable_id', $workOrderIds) + ->pluck('id') + ->toArray(); + + if (! empty($documentIds)) { + DocumentData::whereIn('document_id', $documentIds)->delete(); + DocumentApproval::whereIn('document_id', $documentIds)->delete(); + Document::whereIn('id', $documentIds)->forceDelete(); + } + + // 3. 출하 참조 해제 + DB::table('shipments') + ->whereIn('work_order_id', $workOrderIds) + ->update(['work_order_id' => null]); + + // 4. 부속 데이터 삭제 + DB::table('work_order_step_progress')->whereIn('work_order_id', $workOrderIds)->delete(); + DB::table('work_order_assignees')->whereIn('work_order_id', $workOrderIds)->delete(); + DB::table('work_order_bending_details')->whereIn('work_order_id', $workOrderIds)->delete(); + DB::table('work_order_issues')->whereIn('work_order_id', $workOrderIds)->delete(); + DB::table('work_results')->whereIn('work_order_id', $workOrderIds)->delete(); + + // 5. 작업지시 품목 → 작업지시 삭제 + DB::table('work_order_items')->whereIn('work_order_id', $workOrderIds)->delete(); + WorkOrder::whereIn('id', $workOrderIds)->forceDelete(); + } + /** * 상태 변경 */ @@ -783,6 +852,17 @@ public function createFromQuote(int $quoteId, array $data = []) ? intdiv($quote->items->count(), $locationCount) : 0; + // DEBUG: 분배 로직 디버깅 (임시) + \Log::info('[createFromQuote] Distribution params', [ + 'quoteId' => $quote->id, + 'itemCount' => $quote->items->count(), + 'locationCount' => $locationCount, + 'hasFormulaSource' => $hasFormulaSource, + 'itemsPerLocation' => $itemsPerLocation, + 'collectionKeys_first5' => $quote->items->keys()->take(5)->all(), + 'nodeMapKeys' => array_keys($nodeMap), + ]); + foreach ($quote->items as $index => $quoteItem) { $floorCode = null; $symbolCode = null; @@ -799,6 +879,11 @@ public function createFromQuote(int $quoteId, array $data = []) $locIdx = min(intdiv($index, $itemsPerLocation), $locationCount - 1); } + // DEBUG: 처음 3개와 전환점(17-19) 로깅 (임시) + if ($index < 3 || ($index >= 17 && $index <= 19)) { + \Log::info("[createFromQuote] item idx={$index} locIdx={$locIdx} fs='{$formulaSource}'"); + } + // calculation_inputs에서 floor/code 가져오기 if (isset($productItems[$locIdx])) { $floorCode = $productItems[$locIdx]['floor'] ?? null; From 8be54c3b8bfaa45cd7d370f65f1f1bd27dc3fe88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Sat, 21 Feb 2026 15:32:24 +0900 Subject: [PATCH 08/26] =?UTF-8?q?feat(WEB):=20=EC=A0=88=EA=B3=A1=ED=92=88?= =?UTF-8?q?=20=EC=84=A0=EC=83=9D=EC=82=B0=E2=86=92=EC=9E=AC=EA=B3=A0?= =?UTF-8?q?=EC=A0=81=EC=9E=AC=20Phase=201=20-=20=EC=83=9D=EC=82=B0?= =?UTF-8?q?=EC=9E=85=EA=B3=A0=20=EA=B8=B0=EB=B0=98=20=EA=B5=AC=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StockTransaction: REASON_PRODUCTION_OUTPUT 상수 및 '생산입고' 라벨 추가 - StockLot: work_order_id FK 컬럼 마이그레이션 + 모델 fillable/casts/relation 추가 - StockService: increaseFromProduction() 메서드 구현 (increaseFromReceiving 기반) - WorkOrderService: 완료 시 sales_order_id 유무에 따라 출하/재고입고 분기 - stockInFromProduction(): 품목별 양품 재고 입고 처리 - shouldStockIn(): items.options 기반 입고 대상 판단 Co-Authored-By: Claude Opus 4.6 --- app/Models/Tenants/StockLot.php | 10 ++ app/Models/Tenants/StockTransaction.php | 5 +- app/Services/StockService.php | 95 ++++++++++++ app/Services/WorkOrderService.php | 136 +++++++++++++++--- ..._add_work_order_id_to_stock_lots_table.php | 36 +++++ 5 files changed, 259 insertions(+), 23 deletions(-) create mode 100644 database/migrations/2026_02_21_100000_add_work_order_id_to_stock_lots_table.php diff --git a/app/Models/Tenants/StockLot.php b/app/Models/Tenants/StockLot.php index 7bd28e9..4287b74 100644 --- a/app/Models/Tenants/StockLot.php +++ b/app/Models/Tenants/StockLot.php @@ -28,6 +28,7 @@ class StockLot extends Model 'location', 'status', 'receiving_id', + 'work_order_id', 'created_by', 'updated_by', 'deleted_by', @@ -41,6 +42,7 @@ class StockLot extends Model 'available_qty' => 'decimal:3', 'stock_id' => 'integer', 'receiving_id' => 'integer', + 'work_order_id' => 'integer', ]; /** @@ -68,6 +70,14 @@ public function receiving(): BelongsTo return $this->belongsTo(Receiving::class); } + /** + * 작업지시 관계 (생산입고) + */ + public function workOrder(): BelongsTo + { + return $this->belongsTo(\App\Models\Production\WorkOrder::class); + } + /** * 생성자 관계 */ diff --git a/app/Models/Tenants/StockTransaction.php b/app/Models/Tenants/StockTransaction.php index 2c5fad5..4c13c11 100644 --- a/app/Models/Tenants/StockTransaction.php +++ b/app/Models/Tenants/StockTransaction.php @@ -48,12 +48,15 @@ class StockTransaction extends Model public const REASON_ORDER_CANCEL = 'order_cancel'; + public const REASON_PRODUCTION_OUTPUT = 'production_output'; + public const REASONS = [ self::REASON_RECEIVING => '입고', self::REASON_WORK_ORDER_INPUT => '생산투입', self::REASON_SHIPMENT => '출하', self::REASON_ORDER_CONFIRM => '수주확정', self::REASON_ORDER_CANCEL => '수주취소', + self::REASON_PRODUCTION_OUTPUT => '생산입고', ]; protected $fillable = [ @@ -111,4 +114,4 @@ public function getReasonLabelAttribute(): string { return self::REASONS[$this->reason] ?? ($this->reason ?? '-'); } -} \ No newline at end of file +} diff --git a/app/Services/StockService.php b/app/Services/StockService.php index 0a51525..72f043d 100644 --- a/app/Services/StockService.php +++ b/app/Services/StockService.php @@ -3,6 +3,8 @@ namespace App\Services; use App\Models\Items\Item; +use App\Models\Production\WorkOrder; +use App\Models\Production\WorkOrderItem; use App\Models\Tenants\Receiving; use App\Models\Tenants\Stock; use App\Models\Tenants\StockLot; @@ -313,6 +315,99 @@ public function increaseFromReceiving(Receiving $receiving): StockLot }); } + /** + * 생산 완료 시 완성품 재고 입고 + * + * increaseFromReceiving()을 기반으로 구현. + * 선생산(수주 없는 작업지시) 완료 시 양품을 재고로 적재. + * + * @param WorkOrder $workOrder 선생산 작업지시 + * @param WorkOrderItem $woItem 작업지시 품목 + * @param float $goodQty 양품 수량 + * @param string $lotNo LOT 번호 + * @return StockLot 생성된 StockLot + */ + public function increaseFromProduction( + WorkOrder $workOrder, + WorkOrderItem $woItem, + float $goodQty, + string $lotNo + ): StockLot { + if (! $woItem->item_id) { + throw new \Exception(__('error.stock.item_id_required')); + } + + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($workOrder, $woItem, $goodQty, $lotNo, $tenantId, $userId) { + // 1. Stock 조회 또는 생성 + $stock = $this->getOrCreateStock($woItem->item_id); + + // 2. FIFO 순서 계산 + $fifoOrder = $this->getNextFifoOrder($stock->id); + + // 3. StockLot 생성 + $stockLot = new StockLot; + $stockLot->tenant_id = $tenantId; + $stockLot->stock_id = $stock->id; + $stockLot->lot_no = $lotNo; + $stockLot->fifo_order = $fifoOrder; + $stockLot->receipt_date = now()->toDateString(); + $stockLot->qty = $goodQty; + $stockLot->reserved_qty = 0; + $stockLot->available_qty = $goodQty; + $stockLot->unit = $woItem->unit ?? 'EA'; + $stockLot->supplier = null; + $stockLot->supplier_lot = null; + $stockLot->po_number = null; + $stockLot->location = null; + $stockLot->status = 'available'; + $stockLot->receiving_id = null; + $stockLot->work_order_id = $workOrder->id; + $stockLot->created_by = $userId; + $stockLot->updated_by = $userId; + $stockLot->save(); + + // 4. Stock 합계 갱신 + $stock->refreshFromLots(); + + // 5. 거래 이력 기록 + $this->recordTransaction( + stock: $stock, + type: StockTransaction::TYPE_IN, + qty: $goodQty, + reason: StockTransaction::REASON_PRODUCTION_OUTPUT, + referenceType: 'work_order', + referenceId: $workOrder->id, + lotNo: $lotNo, + stockLotId: $stockLot->id + ); + + // 6. 감사 로그 기록 + $this->logStockChange( + stock: $stock, + action: 'production_in', + reason: 'production_output', + referenceType: 'work_order', + referenceId: $workOrder->id, + qtyChange: $goodQty, + lotNo: $lotNo + ); + + Log::info('Stock increased from production', [ + 'work_order_id' => $workOrder->id, + 'item_id' => $woItem->item_id, + 'stock_id' => $stock->id, + 'stock_lot_id' => $stockLot->id, + 'qty' => $goodQty, + 'lot_no' => $lotNo, + ]); + + return $stockLot; + }); + } + /** * 입고 수정 시 재고 조정 (차이만큼 증감) * diff --git a/app/Services/WorkOrderService.php b/app/Services/WorkOrderService.php index 890fa51..2821606 100644 --- a/app/Services/WorkOrderService.php +++ b/app/Services/WorkOrderService.php @@ -24,7 +24,8 @@ class WorkOrderService extends Service private const AUDIT_TARGET = 'work_order'; public function __construct( - private readonly AuditLogger $auditLogger + private readonly AuditLogger $auditLogger, + private readonly StockService $stockService ) {} /** @@ -587,15 +588,62 @@ public function updateStatus(int $id, string $status, ?array $resultData = null) // 연결된 수주(Order) 상태 동기화 $this->syncOrderStatus($workOrder, $tenantId); - // 작업완료 시 자동 출하 생성 + // 작업완료 시: 수주 연동 → 출하 생성 / 선생산 → 재고 입고 if ($status === WorkOrder::STATUS_COMPLETED) { - $this->createShipmentFromWorkOrder($workOrder, $tenantId, $userId); + if ($workOrder->sales_order_id) { + $this->createShipmentFromWorkOrder($workOrder, $tenantId, $userId); + } else { + $this->stockInFromProduction($workOrder); + } } return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'process:id,process_name,process_code']); }); } + /** + * 선생산 작업지시 완료 시 완성품을 재고로 입고 + * + * 수주 없는 작업지시(sales_order_id = null)가 완료되면 + * 각 품목의 양품 수량을 재고 시스템에 입고 처리합니다. + */ + private function stockInFromProduction(WorkOrder $workOrder): void + { + $workOrder->loadMissing('items.item'); + + foreach ($workOrder->items as $woItem) { + if ($this->shouldStockIn($woItem)) { + $resultData = $woItem->options['result'] ?? []; + $goodQty = (float) ($resultData['good_qty'] ?? $woItem->quantity); + $lotNo = $resultData['lot_no'] ?? ''; + + if ($goodQty > 0 && $lotNo) { + $this->stockService->increaseFromProduction( + $workOrder, $woItem, $goodQty, $lotNo + ); + } + } + } + } + + /** + * 품목이 생산입고 대상인지 판단 + * + * items.options의 production_source와 lot_managed 속성으로 판단. + */ + private function shouldStockIn(WorkOrderItem $woItem): bool + { + $item = $woItem->item; + if (! $item) { + return false; + } + + $options = $item->options ?? []; + + return ($options['production_source'] ?? null) === 'self_produced' + && ($options['lot_managed'] ?? false) === true; + } + /** * 작업지시 완료 시 자동 출하 생성 * @@ -2193,8 +2241,8 @@ public function getInspectionReport(int $workOrderId): array $tenantId = $this->tenantId(); $workOrder = WorkOrder::where('tenant_id', $tenantId) - ->with(['order', 'items' => function ($q) { - $q->ordered(); + ->with(['salesOrder', 'items' => function ($q) { + $q->ordered()->with('sourceOrderItem'); }]) ->find($workOrderId); @@ -2202,18 +2250,61 @@ public function getInspectionReport(int $workOrderId): array throw new NotFoundHttpException(__('error.not_found')); } - $items = $workOrder->items->map(function ($item) { - return [ - 'id' => $item->id, - 'item_name' => $item->item_name, - 'specification' => $item->specification, - 'quantity' => $item->quantity, - 'sort_order' => $item->sort_order, - 'status' => $item->status, - 'options' => $item->options, - 'inspection_data' => $item->getInspectionData(), + // 개소(order_node_id)별 그룹핑 — WorkerScreen과 동일한 구조 + $grouped = $workOrder->items->groupBy( + fn ($item) => $item->sourceOrderItem?->order_node_id ?? 'unassigned' + ); + + $nodeIds = $grouped->keys()->filter(fn ($k) => $k !== 'unassigned')->values()->all(); + $nodes = ! empty($nodeIds) + ? \App\Models\Orders\OrderNode::whereIn('id', $nodeIds)->get()->keyBy('id') + : collect(); + + $nodeGroups = []; + foreach ($grouped as $nodeId => $groupItems) { + $node = $nodeId !== 'unassigned' ? $nodes->get($nodeId) : null; + $nodeOpts = $node?->options ?? []; + + $firstItem = $groupItems->first(); + $soi = $firstItem->sourceOrderItem; + $floorCode = $soi?->floor_code ?? '-'; + $symbolCode = $soi?->symbol_code ?? '-'; + $floorLabel = collect([$floorCode, $symbolCode]) + ->filter(fn ($v) => $v && $v !== '-')->join('/'); + + $nodeGroups[] = [ + 'node_id' => $nodeId !== 'unassigned' ? (int) $nodeId : null, + 'node_name' => $floorLabel ?: ($node?->name ?? '미지정'), + 'floor' => $nodeOpts['floor'] ?? $floorCode, + 'code' => $nodeOpts['symbol'] ?? $symbolCode, + 'width' => $nodeOpts['width'] ?? 0, + 'height' => $nodeOpts['height'] ?? 0, + 'total_quantity' => $groupItems->sum('quantity'), + 'options' => $nodeOpts, + 'items' => $groupItems->map(fn ($item) => [ + 'id' => $item->id, + 'item_name' => $item->item_name, + 'specification' => $item->specification, + 'quantity' => $item->quantity, + 'sort_order' => $item->sort_order, + 'status' => $item->status, + 'options' => $item->options, + 'inspection_data' => $item->getInspectionData(), + ])->values()->all(), ]; - }); + } + + // 플랫 아이템 목록 (summary 계산용) + $items = $workOrder->items->map(fn ($item) => [ + 'id' => $item->id, + 'item_name' => $item->item_name, + 'specification' => $item->specification, + 'quantity' => $item->quantity, + 'sort_order' => $item->sort_order, + 'status' => $item->status, + 'options' => $item->options, + 'inspection_data' => $item->getInspectionData(), + ]); return [ 'work_order' => [ @@ -2223,13 +2314,14 @@ public function getInspectionReport(int $workOrderId): array 'planned_date' => $workOrder->planned_date, 'due_date' => $workOrder->due_date, ], - 'order' => $workOrder->order ? [ - 'id' => $workOrder->order->id, - 'order_no' => $workOrder->order->order_no, - 'client_name' => $workOrder->order->client_name ?? null, - 'site_name' => $workOrder->order->site_name ?? null, - 'order_date' => $workOrder->order->order_date ?? null, + 'order' => $workOrder->salesOrder ? [ + 'id' => $workOrder->salesOrder->id, + 'order_no' => $workOrder->salesOrder->order_no, + 'client_name' => $workOrder->salesOrder->client_name ?? null, + 'site_name' => $workOrder->salesOrder->site_name ?? null, + 'order_date' => $workOrder->salesOrder->order_date ?? null, ] : null, + 'node_groups' => $nodeGroups, 'items' => $items, 'summary' => [ 'total_items' => $items->count(), diff --git a/database/migrations/2026_02_21_100000_add_work_order_id_to_stock_lots_table.php b/database/migrations/2026_02_21_100000_add_work_order_id_to_stock_lots_table.php new file mode 100644 index 0000000..c2232fc --- /dev/null +++ b/database/migrations/2026_02_21_100000_add_work_order_id_to_stock_lots_table.php @@ -0,0 +1,36 @@ +unsignedBigInteger('work_order_id') + ->nullable() + ->after('receiving_id') + ->comment('생산입고 시 작업지시 참조'); + + $table->foreign('work_order_id') + ->references('id') + ->on('work_orders') + ->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('stock_lots', function (Blueprint $table) { + $table->dropForeign(['work_order_id']); + $table->dropColumn('work_order_id'); + }); + } +}; \ No newline at end of file From 25e21ee6d71ba5c3ab55a5f41c613c4dc615f3a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Sat, 21 Feb 2026 15:46:53 +0900 Subject: [PATCH 09/26] =?UTF-8?q?feat(WEB):=20=EC=A0=88=EA=B3=A1=ED=92=88?= =?UTF-8?q?=20=EC=84=A0=EC=83=9D=EC=82=B0=E2=86=92=EC=9E=AC=EA=B3=A0?= =?UTF-8?q?=EC=A0=81=EC=9E=AC=20Phase=202=20-=20=ED=92=88=EB=AA=A9=20?= =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=ED=95=84=ED=84=B0=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StockController: item_category 파라미터 수용 - StockService: items.item_category 기반 필터링 로직 추가 Co-Authored-By: Claude Opus 4.6 --- app/Http/Controllers/Api/V1/StockController.php | 1 + app/Services/StockService.php | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/app/Http/Controllers/Api/V1/StockController.php b/app/Http/Controllers/Api/V1/StockController.php index f7cc586..fe9e03e 100644 --- a/app/Http/Controllers/Api/V1/StockController.php +++ b/app/Http/Controllers/Api/V1/StockController.php @@ -22,6 +22,7 @@ public function index(Request $request): JsonResponse $params = $request->only([ 'search', 'item_type', + 'item_category', 'status', 'location', 'sort_by', diff --git a/app/Services/StockService.php b/app/Services/StockService.php index 72f043d..e3569cf 100644 --- a/app/Services/StockService.php +++ b/app/Services/StockService.php @@ -69,6 +69,11 @@ public function index(array $params): LengthAwarePaginator $query->where('items.item_type', strtoupper($params['item_type'])); } + // 품목 카테고리 필터 (Item.item_category: BENDING, SCREEN, STEEL 등) + if (! empty($params['item_category'])) { + $query->where('items.item_category', strtoupper($params['item_category'])); + } + // 재고 상태 필터 (Stock.status) if (! empty($params['status'])) { $query->whereHas('stock', function ($q) use ($params) { From 4f777d8cf9abc3dcd78a0a30cb0c32b73cbefe45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Sat, 21 Feb 2026 16:26:09 +0900 Subject: [PATCH 10/26] =?UTF-8?q?feat(WEB):=20=EC=A0=88=EA=B3=A1=ED=92=88?= =?UTF-8?q?=20=EC=84=A0=EC=83=9D=EC=82=B0=E2=86=92=EC=9E=AC=EA=B3=A0?= =?UTF-8?q?=EC=A0=81=EC=9E=AC=20Phase=203=20-=20=EC=88=98=EC=A3=BC=20?= =?UTF-8?q?=EC=A0=88=EA=B3=A1=20=EC=9E=AC=EA=B3=A0=20=ED=99=95=EC=9D=B8=20?= =?UTF-8?q?API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OrderService: checkBendingStockForOrder() 메서드 추가 - order_items에서 item_category='BENDING'인 품목 추출 - 각 품목의 가용재고/부족수량 계산 후 반환 - OrderController: checkBendingStock() 엔드포인트 추가 - Route: GET /api/v1/orders/{id}/bending-stock Co-Authored-By: Claude Opus 4.6 --- .../Controllers/Api/V1/OrderController.php | 10 ++ app/Services/OrderService.php | 137 +++++++++++++++--- routes/api/v1/sales.php | 3 + 3 files changed, 126 insertions(+), 24 deletions(-) diff --git a/app/Http/Controllers/Api/V1/OrderController.php b/app/Http/Controllers/Api/V1/OrderController.php index 0138723..d7b4b2c 100644 --- a/app/Http/Controllers/Api/V1/OrderController.php +++ b/app/Http/Controllers/Api/V1/OrderController.php @@ -134,6 +134,16 @@ public function revertOrderConfirmation(int $id) }, __('message.order.order_confirmation_reverted')); } + /** + * 절곡 BOM 품목 재고 확인 + */ + public function checkBendingStock(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->checkBendingStockForOrder($id); + }, __('message.fetched')); + } + /** * 생산지시 되돌리기 (작업지시 및 관련 데이터 삭제) */ diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php index 70fd65e..de33722 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -228,6 +228,19 @@ public function store(array $data) } // 품목 저장 + // sort_order 기반 분배 준비 + $locationCount = count($productItems); + $itemsPerLocation = ($locationCount > 1) + ? intdiv(count($items), $locationCount) + : 0; + + // floor/code 조합이 개소별로 고유한지 확인 (모두 동일하면 매칭 무의미) + $uniqueLocations = collect($productItems) + ->map(fn ($p) => ($p['floor'] ?? '').'-'.($p['code'] ?? '')) + ->unique() + ->count(); + $canMatchByFloorCode = $uniqueLocations > 1; + foreach ($items as $index => $item) { $item['tenant_id'] = $tenantId; $item['serial_no'] = $index + 1; // 1부터 시작하는 순번 @@ -245,18 +258,32 @@ public function store(array $data) } } - // floor_code/symbol_code로 노드 매칭 + // 노드 매칭 (개소 분배) if (! empty($nodeMap) && ! empty($productItems)) { - $floorCode = $item['floor_code'] ?? null; - $symbolCode = $item['symbol_code'] ?? null; - if ($floorCode && $symbolCode) { - foreach ($productItems as $pidx => $pItem) { - if (($pItem['floor'] ?? '') === $floorCode && ($pItem['code'] ?? '') === $symbolCode) { - $item['order_node_id'] = $nodeMap[$pidx]->id ?? null; - break; + $locIdx = 0; + $matched = false; + + // 1순위: floor_code/symbol_code로 매칭 (개소별 고유값이 있는 경우만) + if ($canMatchByFloorCode) { + $floorCode = $item['floor_code'] ?? null; + $symbolCode = $item['symbol_code'] ?? null; + if ($floorCode && $symbolCode) { + foreach ($productItems as $pidx => $pItem) { + if (($pItem['floor'] ?? '') === $floorCode && ($pItem['code'] ?? '') === $symbolCode) { + $locIdx = $pidx; + $matched = true; + break; + } } } } + + // 2순위: sort_order 기반 균등 분배 + if (! $matched && $itemsPerLocation > 0) { + $locIdx = min(intdiv($index, $itemsPerLocation), $locationCount - 1); + } + + $item['order_node_id'] = $nodeMap[$locIdx]->id ?? null; } $order->items()->create($item); @@ -852,17 +879,6 @@ public function createFromQuote(int $quoteId, array $data = []) ? intdiv($quote->items->count(), $locationCount) : 0; - // DEBUG: 분배 로직 디버깅 (임시) - \Log::info('[createFromQuote] Distribution params', [ - 'quoteId' => $quote->id, - 'itemCount' => $quote->items->count(), - 'locationCount' => $locationCount, - 'hasFormulaSource' => $hasFormulaSource, - 'itemsPerLocation' => $itemsPerLocation, - 'collectionKeys_first5' => $quote->items->keys()->take(5)->all(), - 'nodeMapKeys' => array_keys($nodeMap), - ]); - foreach ($quote->items as $index => $quoteItem) { $floorCode = null; $symbolCode = null; @@ -879,11 +895,6 @@ public function createFromQuote(int $quoteId, array $data = []) $locIdx = min(intdiv($index, $itemsPerLocation), $locationCount - 1); } - // DEBUG: 처음 3개와 전환점(17-19) 로깅 (임시) - if ($index < 3 || ($index >= 17 && $index <= 19)) { - \Log::info("[createFromQuote] item idx={$index} locIdx={$locIdx} fs='{$formulaSource}'"); - } - // calculation_inputs에서 floor/code 가져오기 if (isset($productItems[$locIdx])) { $floorCode = $productItems[$locIdx]['floor'] ?? null; @@ -1794,4 +1805,82 @@ private function revertProductionOrderCancel(Order $order, int $tenantId, int $u ]; }); } + + /** + * 수주의 절곡 BOM 품목별 재고 현황 조회 + * + * order_items에서 item_category='BENDING'인 품목을 추출하고 + * 각 품목의 재고 가용량/부족량을 반환합니다. + */ + public function checkBendingStockForOrder(int $orderId): array + { + $tenantId = $this->tenantId(); + + $order = Order::where('tenant_id', $tenantId) + ->with(['items']) + ->find($orderId); + + if (! $order) { + throw new NotFoundHttpException(__('error.not_found')); + } + + // order_items에서 item_id가 있는 품목의 ID 수집 + 수량 합산 + $itemQtyMap = []; // item_id => total_qty + foreach ($order->items as $orderItem) { + $itemId = $orderItem->item_id; + if (! $itemId) { + continue; + } + $qty = (float) ($orderItem->quantity ?? 0); + if ($qty <= 0) { + continue; + } + $itemQtyMap[$itemId] = ($itemQtyMap[$itemId] ?? 0) + $qty; + } + + if (empty($itemQtyMap)) { + return []; + } + + // items 테이블에서 item_category = 'BENDING'인 것만 필터 + $bendingItems = DB::table('items') + ->where('tenant_id', $tenantId) + ->whereIn('id', array_keys($itemQtyMap)) + ->where('item_category', 'BENDING') + ->whereNull('deleted_at') + ->select('id', 'code', 'name', 'unit') + ->get(); + + if ($bendingItems->isEmpty()) { + return []; + } + + $stockService = app(StockService::class); + $result = []; + + foreach ($bendingItems as $item) { + $neededQty = $itemQtyMap[$item->id]; + $stockInfo = $stockService->getAvailableStock($item->id); + + $availableQty = $stockInfo ? (float) $stockInfo['available_qty'] : 0; + $reservedQty = $stockInfo ? (float) $stockInfo['reserved_qty'] : 0; + $stockQty = $stockInfo ? (float) $stockInfo['stock_qty'] : 0; + $shortfallQty = max(0, $neededQty - $availableQty); + + $result[] = [ + 'item_id' => $item->id, + 'item_code' => $item->code, + 'item_name' => $item->name, + 'unit' => $item->unit, + 'needed_qty' => $neededQty, + 'stock_qty' => $stockQty, + 'reserved_qty' => $reservedQty, + 'available_qty' => $availableQty, + 'shortfall_qty' => $shortfallQty, + 'status' => $shortfallQty > 0 ? 'insufficient' : 'sufficient', + ]; + } + + return $result; + } } diff --git a/routes/api/v1/sales.php b/routes/api/v1/sales.php index 1398d41..f588ea9 100644 --- a/routes/api/v1/sales.php +++ b/routes/api/v1/sales.php @@ -163,6 +163,9 @@ // 견적에서 수주 생성 Route::post('/from-quote/{quoteId}', [OrderController::class, 'createFromQuote'])->whereNumber('quoteId')->name('v1.orders.from-quote'); + // 절곡 재고 현황 확인 + Route::get('/{id}/bending-stock', [OrderController::class, 'checkBendingStock'])->whereNumber('id')->name('v1.orders.bending-stock'); + // 생산지시 생성 Route::post('/{id}/production-order', [OrderController::class, 'createProductionOrder'])->whereNumber('id')->name('v1.orders.production-order'); From 9c88138de8d6aa8f710bb851f5b983906ab361d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Sat, 21 Feb 2026 18:19:03 +0900 Subject: [PATCH 11/26] =?UTF-8?q?feat(WEB):=205130=20=EB=A0=88=EA=B1=B0?= =?UTF-8?q?=EC=8B=9C=20=EC=A0=88=EA=B3=A1=ED=92=88=20=EC=9E=AC=EA=B3=A0=20?= =?UTF-8?q?=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EC=BB=A4=EB=A7=A8=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - php artisan migrate:5130-bending-stock 커맨드 생성 - 5130 lot 테이블 → SAM stocks + stock_lots 마이그레이션 - 5130 bending_work_log → SAM stock_transactions(OUT) 마이그레이션 - prod+spec+slength 3코드 → BD-{PROD}{SPEC}-{SLENGTH} 아이템 코드 매핑 - --dry-run 시뮬레이션, --rollback 롤백 지원 - 기존 BD- 아이템 item_category='BENDING' 자동 업데이트 - FIFO 기반 LOT 수량 차감 및 Stock 집계 갱신 Co-Authored-By: Claude Opus 4.6 --- .../Commands/Migrate5130BendingStock.php | 773 ++++++++++++++++++ 1 file changed, 773 insertions(+) create mode 100644 app/Console/Commands/Migrate5130BendingStock.php diff --git a/app/Console/Commands/Migrate5130BendingStock.php b/app/Console/Commands/Migrate5130BendingStock.php new file mode 100644 index 0000000..9cd10ce --- /dev/null +++ b/app/Console/Commands/Migrate5130BendingStock.php @@ -0,0 +1,773 @@ + '가이드레일(벽면)', + 'S' => '가이드레일(측면)', + 'G' => '연기차단재', + 'B' => '하단마감재(스크린)', + 'T' => '하단마감재(철재)', + 'L' => 'L-Bar', + 'C' => '케이스', + ]; + + // 5130 spec 코드 → 한글명 + private array $specNames = [ + 'I' => '화이바원단', + 'S' => 'SUS', + 'U' => 'SUS2', + 'E' => 'EGI', + 'A' => '스크린용', + 'D' => 'D형', + 'C' => 'C형', + 'M' => '본체', + 'T' => '본체(철재)', + 'B' => '후면코너부', + 'L' => '린텔부', + 'P' => '점검구', + 'F' => '전면부', + ]; + + // 5130 slength 코드 → 한글명 + private array $slengthNames = [ + '53' => 'W50×3000', + '54' => 'W50×4000', + '83' => 'W80×3000', + '84' => 'W80×4000', + '12' => '1219mm', + '24' => '2438mm', + '30' => '3000mm', + '35' => '3500mm', + '40' => '4000mm', + '41' => '4150mm', + '42' => '4200mm', + '43' => '4300mm', + ]; + + // 통계 + private array $stats = [ + 'items_updated' => 0, + 'items_created' => 0, + 'stocks_created' => 0, + 'lots_created' => 0, + 'transactions_in' => 0, + 'transactions_out' => 0, + 'skipped_lots' => 0, + ]; + + public function handle(): int + { + $tenantId = (int) $this->option('tenant_id'); + $dryRun = $this->option('dry-run'); + $rollback = $this->option('rollback'); + + $this->info('=== 5130 → SAM 절곡품 재고 마이그레이션 ==='); + $this->info("Tenant ID: {$tenantId}"); + $this->info('Mode: '.($dryRun ? 'DRY-RUN (시뮬레이션)' : 'LIVE')); + $this->newLine(); + + if ($rollback) { + return $this->rollbackMigration($tenantId, $dryRun); + } + + // 1. 5130 데이터 읽기 + $this->info('📥 Step 1: 5130 레거시 데이터 읽기...'); + $lots = $this->readLegacyLots(); + $workLogs = $this->readLegacyWorkLogs(); + $this->info(" - lot 레코드: {$lots->count()}건"); + $this->info(" - bending_work_log 레코드: {$workLogs->count()}건"); + $this->newLine(); + + if ($lots->isEmpty()) { + $this->warn('5130 lot 데이터가 없습니다. 마이그레이션을 종료합니다.'); + + return self::SUCCESS; + } + + // 2. 재고 요약 계산 + $this->info('📊 Step 2: 재고 현황 계산...'); + $stockSummary = $this->calculateStockSummary($lots, $workLogs); + $this->showSummary($stockSummary); + $this->newLine(); + + // 3. 기존 BD- 아이템 item_category 업데이트 현황 + $this->info('🏷️ Step 3: 기존 BD- 아이템 카테고리 업데이트...'); + $this->updateExistingBdItems($tenantId, $dryRun); + $this->newLine(); + + if ($dryRun) { + $this->showStats(); + $this->info('🔍 DRY RUN 완료. 실제 실행은 --dry-run 플래그를 제거하세요.'); + + return self::SUCCESS; + } + + if (! $this->confirm('마이그레이션을 진행하시겠습니까?')) { + $this->info('취소되었습니다.'); + + return self::SUCCESS; + } + + // 4. 실행 + $this->info('🚀 Step 4: 마이그레이션 실행...'); + DB::connection($this->targetDb)->transaction(function () use ($tenantId, $lots, $workLogs, $stockSummary) { + $this->executeMigration($tenantId, $lots, $workLogs, $stockSummary); + }); + + $this->newLine(); + $this->showStats(); + $this->info('✅ 마이그레이션 완료!'); + + return self::SUCCESS; + } + + /** + * 5130 lot 테이블 데이터 읽기 + */ + private function readLegacyLots(): \Illuminate\Support\Collection + { + return DB::connection($this->sourceDb) + ->table('lot') + ->where('is_deleted', 0) + ->whereNotNull('prod') + ->where('prod', '!=', '') + ->whereNotNull('surang') + ->where('surang', '>', 0) + ->select('num', 'reg_date', 'lot_number', 'prod', 'spec', 'slength', 'surang', 'rawLot', 'author', 'remark') + ->orderBy('reg_date') + ->orderBy('num') + ->get(); + } + + /** + * 5130 bending_work_log 테이블 데이터 읽기 + */ + private function readLegacyWorkLogs(): \Illuminate\Support\Collection + { + return DB::connection($this->sourceDb) + ->table('bending_work_log') + ->where('is_deleted', 0) + ->whereNotNull('prod_code') + ->where('prod_code', '!=', '') + ->where('quantity', '>', 0) + ->select('id', 'work_date', 'work_order_no', 'prod_code', 'spec_code', 'slength_code', 'quantity', 'unit', 'work_type', 'worker', 'remark', 'created_at') + ->orderBy('work_date') + ->orderBy('id') + ->get(); + } + + /** + * prod+spec+slength 기준 재고 요약 계산 + */ + private function calculateStockSummary(\Illuminate\Support\Collection $lots, \Illuminate\Support\Collection $workLogs): array + { + $summary = []; + + // 입고(lot) 합산 + foreach ($lots as $lot) { + $key = $this->makeProductKey($lot->prod, $lot->spec, $lot->slength); + if (! isset($summary[$key])) { + $summary[$key] = [ + 'prod' => $lot->prod, + 'spec' => $lot->spec, + 'slength' => $lot->slength, + 'total_in' => 0, + 'total_out' => 0, + 'lot_count' => 0, + ]; + } + $summary[$key]['total_in'] += (float) $lot->surang; + $summary[$key]['lot_count']++; + } + + // 출고(bending_work_log) 합산 + foreach ($workLogs as $log) { + $key = $this->makeProductKey($log->prod_code, $log->spec_code, $log->slength_code); + if (! isset($summary[$key])) { + $summary[$key] = [ + 'prod' => $log->prod_code, + 'spec' => $log->spec_code, + 'slength' => $log->slength_code, + 'total_in' => 0, + 'total_out' => 0, + 'lot_count' => 0, + ]; + } + $summary[$key]['total_out'] += (float) $log->quantity; + } + + return $summary; + } + + /** + * 재고 요약 표시 + */ + private function showSummary(array $summary): void + { + $headers = ['코드', '품목명', '입고합계', '출고합계', '현재고', 'LOT수']; + $rows = []; + + foreach ($summary as $key => $item) { + $netStock = $item['total_in'] - $item['total_out']; + $rows[] = [ + $key, + $this->makeItemName($item['prod'], $item['spec'], $item['slength']), + number_format($item['total_in']), + number_format($item['total_out']), + number_format($netStock), + $item['lot_count'], + ]; + } + + $this->table($headers, $rows); + $this->info(' - 품목 종류: '.count($summary).'개'); + } + + /** + * 기존 BD- 아이템의 item_category를 BENDING으로 업데이트 + */ + private function updateExistingBdItems(int $tenantId, bool $dryRun): void + { + $count = DB::connection($this->targetDb) + ->table('items') + ->where('tenant_id', $tenantId) + ->where('code', 'like', 'BD-%') + ->whereNull('deleted_at') + ->where(function ($q) { + $q->whereNull('item_category') + ->orWhere('item_category', '!=', 'BENDING'); + }) + ->count(); + + $this->info(" - BD- 아이템 중 item_category 미설정: {$count}건"); + + if ($count > 0 && ! $dryRun) { + DB::connection($this->targetDb) + ->table('items') + ->where('tenant_id', $tenantId) + ->where('code', 'like', 'BD-%') + ->whereNull('deleted_at') + ->where(function ($q) { + $q->whereNull('item_category') + ->orWhere('item_category', '!=', 'BENDING'); + }) + ->update(['item_category' => 'BENDING', 'updated_at' => now()]); + + $this->info(" ✅ {$count}건 업데이트 완료"); + } + + $this->stats['items_updated'] = $count; + } + + /** + * 마이그레이션 실행 + */ + private function executeMigration(int $tenantId, \Illuminate\Support\Collection $lots, \Illuminate\Support\Collection $workLogs, array $stockSummary): void + { + $itemMap = []; // productKey => item_id + $stockMap = []; // productKey => stock_id + $lotMap = []; // productKey => [stock_lot_ids] + + // 1. 각 제품 조합별 아이템 & 스톡 생성 + $this->info(' 📦 아이템 & 재고 레코드 생성...'); + foreach ($stockSummary as $key => $data) { + $itemCode = $this->makeItemCode($data['prod'], $data['spec'], $data['slength']); + $itemName = $this->makeItemName($data['prod'], $data['spec'], $data['slength']); + + // 아이템 조회 또는 생성 + $item = DB::connection($this->targetDb) + ->table('items') + ->where('tenant_id', $tenantId) + ->where('code', $itemCode) + ->whereNull('deleted_at') + ->first(); + + if (! $item) { + $itemId = DB::connection($this->targetDb)->table('items')->insertGetId([ + 'tenant_id' => $tenantId, + 'code' => $itemCode, + 'name' => $itemName, + 'item_type' => 'PT', + 'item_category' => 'BENDING', + 'unit' => 'EA', + 'source' => '5130_migration', + 'options' => json_encode([ + 'lot_managed' => true, + 'consumption_method' => 'auto', + 'production_source' => 'self_produced', + 'input_tracking' => true, + 'legacy_prod' => $data['prod'], + 'legacy_spec' => $data['spec'], + 'legacy_slength' => $data['slength'], + ]), + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + $this->stats['items_created']++; + $this->line(" + 아이템 생성: {$itemCode} ({$itemName})"); + } else { + $itemId = $item->id; + $this->line(" ✓ 기존 아이템 사용: {$itemCode}"); + } + + $itemMap[$key] = $itemId; + + // Stock 레코드 조회 또는 생성 + $stock = DB::connection($this->targetDb) + ->table('stocks') + ->where('tenant_id', $tenantId) + ->where('item_id', $itemId) + ->whereNull('deleted_at') + ->first(); + + if (! $stock) { + $stockId = DB::connection($this->targetDb)->table('stocks')->insertGetId([ + 'tenant_id' => $tenantId, + 'item_id' => $itemId, + 'item_code' => $itemCode, + 'item_name' => $itemName, + 'item_type' => 'bent_part', + 'unit' => 'EA', + 'stock_qty' => 0, + 'safety_stock' => 0, + 'reserved_qty' => 0, + 'available_qty' => 0, + 'lot_count' => 0, + 'status' => 'out', + 'created_at' => now(), + 'updated_at' => now(), + ]); + $this->stats['stocks_created']++; + } else { + $stockId = $stock->id; + } + + $stockMap[$key] = $stockId; + } + + // 2. LOT 데이터 마이그레이션 (입고) + $this->newLine(); + $this->info(' 📋 LOT 데이터 마이그레이션 (입고)...'); + $fifoCounters = []; // stockId => current fifo_order + + foreach ($lots as $lot) { + $key = $this->makeProductKey($lot->prod, $lot->spec, $lot->slength); + + if (! isset($stockMap[$key])) { + $this->stats['skipped_lots']++; + + continue; + } + + $stockId = $stockMap[$key]; + $itemCode = $this->makeItemCode($lot->prod, $lot->spec, $lot->slength); + $itemName = $this->makeItemName($lot->prod, $lot->spec, $lot->slength); + + // FIFO 순서 계산 + if (! isset($fifoCounters[$stockId])) { + $existing = DB::connection($this->targetDb) + ->table('stock_lots') + ->where('stock_id', $stockId) + ->max('fifo_order'); + $fifoCounters[$stockId] = ($existing ?? 0); + } + $fifoCounters[$stockId]++; + + // LOT 번호 생성 (5130 lot_number 사용, 없으면 생성) + $lotNo = $lot->lot_number; + if (empty($lotNo)) { + $regDate = $lot->reg_date ? Carbon::parse($lot->reg_date)->format('ymd') : Carbon::now()->format('ymd'); + $lotNo = "5130-{$regDate}-{$lot->num}"; + } + + // 중복 체크 + $existingLot = DB::connection($this->targetDb) + ->table('stock_lots') + ->where('tenant_id', $tenantId) + ->where('stock_id', $stockId) + ->where('lot_no', $lotNo) + ->whereNull('deleted_at') + ->first(); + + if ($existingLot) { + $this->stats['skipped_lots']++; + + continue; + } + + $qty = (float) $lot->surang; + $receiptDate = $lot->reg_date ?? now()->toDateString(); + + $stockLotId = DB::connection($this->targetDb)->table('stock_lots')->insertGetId([ + 'tenant_id' => $tenantId, + 'stock_id' => $stockId, + 'lot_no' => $lotNo, + 'fifo_order' => $fifoCounters[$stockId], + 'receipt_date' => $receiptDate, + 'qty' => $qty, + 'reserved_qty' => 0, + 'available_qty' => $qty, + 'unit' => 'EA', + 'supplier' => null, + 'supplier_lot' => $lot->rawLot ?: null, + 'location' => null, + 'status' => 'available', + 'receiving_id' => null, + 'work_order_id' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + // 입고 트랜잭션 기록 + // balance_qty는 나중에 refreshFromLots 후 업데이트되므로 임시로 qty 사용 + DB::connection($this->targetDb)->table('stock_transactions')->insert([ + 'tenant_id' => $tenantId, + 'stock_id' => $stockId, + 'stock_lot_id' => $stockLotId, + 'type' => 'IN', + 'qty' => $qty, + 'balance_qty' => 0, // 나중에 갱신 + 'reference_type' => 'migration', + 'reference_id' => $lot->num, + 'lot_no' => $lotNo, + 'reason' => 'receiving', + 'remark' => '5130 레거시 마이그레이션 (lot.num='.$lot->num.')', + 'item_code' => $itemCode, + 'item_name' => $itemName, + 'created_at' => $receiptDate, + ]); + + $this->stats['lots_created']++; + $this->stats['transactions_in']++; + + if (! isset($lotMap[$key])) { + $lotMap[$key] = []; + } + $lotMap[$key][] = $stockLotId; + } + + $this->info(" ✅ LOT {$this->stats['lots_created']}건 생성"); + + // 3. 출고 로그 마이그레이션 (bending_work_log → stock_transactions OUT) + $this->newLine(); + $this->info(' 📋 출고 로그 마이그레이션...'); + + foreach ($workLogs as $log) { + $key = $this->makeProductKey($log->prod_code, $log->spec_code, $log->slength_code); + + if (! isset($stockMap[$key])) { + continue; + } + + $stockId = $stockMap[$key]; + $itemCode = $this->makeItemCode($log->prod_code, $log->spec_code, $log->slength_code); + $itemName = $this->makeItemName($log->prod_code, $log->spec_code, $log->slength_code); + $qty = (float) $log->quantity; + $workDate = $log->work_date ?? ($log->created_at ?? now()->toDateString()); + + DB::connection($this->targetDb)->table('stock_transactions')->insert([ + 'tenant_id' => $tenantId, + 'stock_id' => $stockId, + 'stock_lot_id' => null, // 레거시 데이터는 특정 LOT 추적 불가 + 'type' => 'OUT', + 'qty' => -$qty, + 'balance_qty' => 0, // 나중에 갱신 + 'reference_type' => 'migration', + 'reference_id' => $log->id, + 'lot_no' => null, + 'reason' => 'work_order_input', + 'remark' => '5130 레거시 마이그레이션 (bending_work_log.id='.$log->id.', worker='.$log->worker.')', + 'item_code' => $itemCode, + 'item_name' => $itemName, + 'created_at' => $workDate, + ]); + + $this->stats['transactions_out']++; + } + + $this->info(" ✅ 출고 트랜잭션 {$this->stats['transactions_out']}건 생성"); + + // 4. LOT 수량 조정 (출고분 차감) + $this->newLine(); + $this->info(' 🔄 LOT 수량 조정 (출고분 FIFO 차감)...'); + + foreach ($stockSummary as $key => $data) { + if (! isset($stockMap[$key])) { + continue; + } + + $stockId = $stockMap[$key]; + $totalOut = $data['total_out']; + + if ($totalOut <= 0) { + continue; + } + + // FIFO 순서로 LOT에서 차감 + $remainingOut = $totalOut; + $stockLots = DB::connection($this->targetDb) + ->table('stock_lots') + ->where('stock_id', $stockId) + ->where('tenant_id', $tenantId) + ->whereNull('deleted_at') + ->orderBy('fifo_order') + ->get(); + + foreach ($stockLots as $sl) { + if ($remainingOut <= 0) { + break; + } + + $lotQty = (float) $sl->qty; + $deduct = min($lotQty, $remainingOut); + $newQty = $lotQty - $deduct; + $remainingOut -= $deduct; + + $status = $newQty <= 0 ? 'used' : 'available'; + DB::connection($this->targetDb) + ->table('stock_lots') + ->where('id', $sl->id) + ->update([ + 'qty' => max(0, $newQty), + 'available_qty' => max(0, $newQty), + 'status' => $status, + 'updated_at' => now(), + ]); + } + + if ($remainingOut > 0) { + $this->warn(" ⚠️ {$key}: 출고량이 입고량보다 {$remainingOut}만큼 초과 (데이터 불일치)"); + } + } + + // 5. Stock 집계 갱신 + $this->newLine(); + $this->info(' 📊 Stock 집계 갱신...'); + + foreach ($stockMap as $key => $stockId) { + $this->refreshStockFromLots($stockId, $tenantId); + } + + // 6. balance_qty 갱신 (트랜잭션별 잔량) + $this->info(' 📊 트랜잭션 잔량(balance_qty) 갱신...'); + + foreach ($stockMap as $key => $stockId) { + $stock = DB::connection($this->targetDb) + ->table('stocks') + ->where('id', $stockId) + ->first(); + + if ($stock) { + DB::connection($this->targetDb) + ->table('stock_transactions') + ->where('stock_id', $stockId) + ->where('tenant_id', $tenantId) + ->update(['balance_qty' => $stock->stock_qty]); + } + } + + $this->info(' ✅ 집계 갱신 완료'); + } + + /** + * Stock 집계 갱신 (LOT 기반) + */ + private function refreshStockFromLots(int $stockId, int $tenantId): void + { + $lotStats = DB::connection($this->targetDb) + ->table('stock_lots') + ->where('stock_id', $stockId) + ->where('tenant_id', $tenantId) + ->whereNull('deleted_at') + ->selectRaw(' + COALESCE(SUM(qty), 0) as total_qty, + COALESCE(SUM(reserved_qty), 0) as total_reserved, + COALESCE(SUM(available_qty), 0) as total_available, + COUNT(*) as lot_count, + MIN(receipt_date) as oldest_lot_date, + MAX(receipt_date) as latest_receipt_date + ') + ->first(); + + $stockQty = (float) $lotStats->total_qty; + $reservedQty = (float) $lotStats->total_reserved; + $availableQty = (float) $lotStats->total_available; + + $status = 'normal'; + if ($stockQty <= 0) { + $status = 'out'; + } + + DB::connection($this->targetDb) + ->table('stocks') + ->where('id', $stockId) + ->update([ + 'stock_qty' => $stockQty, + 'reserved_qty' => $reservedQty, + 'available_qty' => $availableQty, + 'lot_count' => (int) $lotStats->lot_count, + 'oldest_lot_date' => $lotStats->oldest_lot_date, + 'last_receipt_date' => $lotStats->latest_receipt_date, + 'status' => $status, + 'updated_at' => now(), + ]); + } + + /** + * 롤백: migration 참조 데이터 삭제 + */ + private function rollbackMigration(int $tenantId, bool $dryRun): int + { + $this->warn('⚠️ 롤백: migration 소스 데이터를 삭제합니다.'); + + // 마이그레이션으로 생성된 트랜잭션 + $txCount = DB::connection($this->targetDb) + ->table('stock_transactions') + ->where('tenant_id', $tenantId) + ->where('reference_type', 'migration') + ->count(); + + // 마이그레이션으로 생성된 아이템 (source = '5130_migration') + $itemCount = DB::connection($this->targetDb) + ->table('items') + ->where('tenant_id', $tenantId) + ->where('source', '5130_migration') + ->whereNull('deleted_at') + ->count(); + + $this->info(' 삭제 대상:'); + $this->info(" - stock_transactions (reference_type=migration): {$txCount}건"); + $this->info(" - items (source=5130_migration): {$itemCount}건"); + + if ($dryRun) { + $this->info('DRY RUN - 실제 삭제 없음'); + + return self::SUCCESS; + } + + if (! $this->confirm('정말 롤백하시겠습니까? 되돌릴 수 없습니다.')) { + return self::SUCCESS; + } + + DB::connection($this->targetDb)->transaction(function () use ($tenantId) { + // 1. 트랜잭션 삭제 + DB::connection($this->targetDb) + ->table('stock_transactions') + ->where('tenant_id', $tenantId) + ->where('reference_type', 'migration') + ->delete(); + + // 2. migration으로 생성된 아이템의 stock_lots 삭제 + $migrationItemIds = DB::connection($this->targetDb) + ->table('items') + ->where('tenant_id', $tenantId) + ->where('source', '5130_migration') + ->whereNull('deleted_at') + ->pluck('id'); + + if ($migrationItemIds->isNotEmpty()) { + $stockIds = DB::connection($this->targetDb) + ->table('stocks') + ->where('tenant_id', $tenantId) + ->whereIn('item_id', $migrationItemIds) + ->pluck('id'); + + if ($stockIds->isNotEmpty()) { + DB::connection($this->targetDb) + ->table('stock_lots') + ->whereIn('stock_id', $stockIds) + ->delete(); + + DB::connection($this->targetDb) + ->table('stocks') + ->whereIn('id', $stockIds) + ->delete(); + } + + // 3. 아이템 삭제 + DB::connection($this->targetDb) + ->table('items') + ->whereIn('id', $migrationItemIds) + ->delete(); + } + }); + + $this->info('✅ 롤백 완료'); + + return self::SUCCESS; + } + + /** + * 5130 코드 → 제품 키 생성 + */ + private function makeProductKey(string $prod, ?string $spec, ?string $slength): string + { + return trim($prod).'-'.trim($spec ?? '').'-'.trim($slength ?? ''); + } + + /** + * 5130 코드 → SAM 아이템 코드 생성 + * 형식: BD-{PROD}{SPEC}-{SLENGTH} (예: BD-RS-40) + */ + private function makeItemCode(string $prod, ?string $spec, ?string $slength): string + { + $p = trim($prod); + $s = trim($spec ?? ''); + $l = trim($slength ?? ''); + + return "BD-{$p}{$s}-{$l}"; + } + + /** + * 5130 코드 → 사람이 읽을 수 있는 아이템명 생성 + */ + private function makeItemName(string $prod, ?string $spec, ?string $slength): string + { + $prodName = $this->prodNames[trim($prod)] ?? trim($prod); + $specName = $this->specNames[trim($spec ?? '')] ?? trim($spec ?? ''); + $slengthName = $this->slengthNames[trim($slength ?? '')] ?? trim($slength ?? ''); + + $parts = array_filter([$prodName, $specName, $slengthName]); + + return implode(' ', $parts); + } + + /** + * 통계 출력 + */ + private function showStats(): void + { + $this->newLine(); + $this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + $this->info('📊 마이그레이션 통계'); + $this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + $this->info(" 아이템 카테고리 업데이트: {$this->stats['items_updated']}건"); + $this->info(" 아이템 신규 생성: {$this->stats['items_created']}건"); + $this->info(" Stock 레코드 생성: {$this->stats['stocks_created']}건"); + $this->info(" StockLot 생성: {$this->stats['lots_created']}건"); + $this->info(" 입고 트랜잭션: {$this->stats['transactions_in']}건"); + $this->info(" 출고 트랜잭션: {$this->stats['transactions_out']}건"); + $this->info(" 스킵된 LOT: {$this->stats['skipped_lots']}건"); + $this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + } +} From 5a3d6c2243f904e14f885084ae3486c62bfcf77b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Sun, 22 Feb 2026 02:13:08 +0900 Subject: [PATCH 12/26] =?UTF-8?q?feat(WEB):=20=EC=A0=88=EA=B3=A1=20?= =?UTF-8?q?=EC=9E=90=EC=9E=AC=ED=88=AC=EC=9E=85=20LOT=20=EB=A7=A4=ED=95=91?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PrefixResolver: 제품코드×마감재질→LOT prefix 결정 + BD-XX-NN 코드 생성 - DynamicBomEntry DTO: dynamic_bom JSON 항목 타입 안전 관리 - BendingInfoBuilder 확장: build() 리턴 변경 + buildDynamicBomForItem() 추가 - OrderService: 작업지시 생성 시 per-item dynamic_bom 자동 저장 - WorkOrderService.getMaterials(): dynamic_bom 우선 체크 + N+1 배치 최적화 - WorkOrderService.registerMaterialInput(): work_order_item_id 분기 라우팅 통일 - 단위 테스트 58개 + 통합 테스트 6개 (64 tests / 293 assertions) Co-Authored-By: Claude Opus 4.6 --- app/DTOs/Production/DynamicBomEntry.php | 101 ++++++ app/Services/OrderService.php | 24 +- .../Production/BendingInfoBuilder.php | 307 +++++++++++++++++- app/Services/Production/PrefixResolver.php | 307 ++++++++++++++++++ app/Services/WorkOrderService.php | 205 ++++++++++-- .../Production/BendingLotPipelineTest.php | 278 ++++++++++++++++ tests/Unit/Production/DynamicBomEntryTest.php | 173 ++++++++++ tests/Unit/Production/PrefixResolverTest.php | 263 +++++++++++++++ 8 files changed, 1625 insertions(+), 33 deletions(-) create mode 100644 app/DTOs/Production/DynamicBomEntry.php create mode 100644 app/Services/Production/PrefixResolver.php create mode 100644 tests/Feature/Production/BendingLotPipelineTest.php create mode 100644 tests/Unit/Production/DynamicBomEntryTest.php create mode 100644 tests/Unit/Production/PrefixResolverTest.php diff --git a/app/DTOs/Production/DynamicBomEntry.php b/app/DTOs/Production/DynamicBomEntry.php new file mode 100644 index 0000000..982967c --- /dev/null +++ b/app/DTOs/Production/DynamicBomEntry.php @@ -0,0 +1,101 @@ + $this->child_item_id, + 'child_item_code' => $this->child_item_code, + 'lot_prefix' => $this->lot_prefix, + 'part_type' => $this->part_type, + 'category' => $this->category, + 'material_type' => $this->material_type, + 'length_mm' => $this->length_mm, + 'qty' => $this->qty, + ]; + } + + /** + * 필수 필드 검증 + * + * @throws InvalidArgumentException + */ + public static function validate(array $data): bool + { + $required = ['child_item_id', 'child_item_code', 'lot_prefix', 'part_type', 'category', 'material_type', 'length_mm', 'qty']; + + foreach ($required as $field) { + if (! array_key_exists($field, $data) || $data[$field] === null) { + throw new InvalidArgumentException("DynamicBomEntry: '{$field}' is required"); + } + } + + if ((int) $data['child_item_id'] <= 0) { + throw new InvalidArgumentException('DynamicBomEntry: child_item_id must be positive'); + } + + $validCategories = ['guideRail', 'bottomBar', 'shutterBox', 'smokeBarrier']; + if (! in_array($data['category'], $validCategories, true)) { + throw new InvalidArgumentException('DynamicBomEntry: category must be one of: '.implode(', ', $validCategories)); + } + + if ($data['qty'] <= 0) { + throw new InvalidArgumentException('DynamicBomEntry: qty must be positive'); + } + + return true; + } + + /** + * DynamicBomEntry 배열 → JSON 저장용 배열 변환 + * + * @param DynamicBomEntry[] $entries + */ + public static function toArrayList(array $entries): array + { + return array_map(fn (self $e) => $e->toArray(), $entries); + } +} diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php index de33722..98b35d1 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -1336,9 +1336,9 @@ public function createProductionOrder(int $orderId, array $data) ->values() ->all(); - $bendingInfo = app(BendingInfoBuilder::class)->build($order, $processId, $nodeIds ?: null); - if ($bendingInfo) { - $workOrderOptions = ['bending_info' => $bendingInfo]; + $buildResult = app(BendingInfoBuilder::class)->build($order, $processId, $nodeIds ?: null); + if ($buildResult) { + $workOrderOptions = ['bending_info' => $buildResult['bending_info']]; } } @@ -1405,17 +1405,33 @@ public function createProductionOrder(int $orderId, array $data) $slatInfo['joint_bar'] = (2 + (int) floor(((float) $woWidth - 500) / 1000)) * $qty; } + $woHeight = $nodeOptions['height'] ?? $nodeOptions['open_height'] ?? null; + $woItemOptions = array_filter([ 'floor' => $orderItem->floor_code, 'code' => $orderItem->symbol_code, 'width' => $woWidth, - 'height' => $nodeOptions['height'] ?? $nodeOptions['open_height'] ?? null, + 'height' => $woHeight, 'cutting_info' => $nodeOptions['cutting_info'] ?? null, 'slat_info' => $slatInfo, 'bending_info' => $nodeOptions['bending_info'] ?? null, 'wip_info' => $nodeOptions['wip_info'] ?? null, ], fn ($v) => $v !== null); + // 절곡 공정: 개소별 dynamic_bom 생성 + if (! empty($buildResult['context']) && $woWidth && $woHeight) { + $dynamicBom = app(BendingInfoBuilder::class)->buildDynamicBomForItem( + $buildResult['context'], + (int) $woWidth, + (int) $woHeight, + (int) ($orderItem->quantity ?? 1), + $tenantId, + ); + if (! empty($dynamicBom)) { + $woItemOptions['dynamic_bom'] = $dynamicBom; + } + } + DB::table('work_order_items')->insert([ 'tenant_id' => $tenantId, 'work_order_id' => $workOrder->id, diff --git a/app/Services/Production/BendingInfoBuilder.php b/app/Services/Production/BendingInfoBuilder.php index 799d75d..cad62df 100644 --- a/app/Services/Production/BendingInfoBuilder.php +++ b/app/Services/Production/BendingInfoBuilder.php @@ -2,15 +2,17 @@ namespace App\Services\Production; +use App\DTOs\Production\DynamicBomEntry; use App\Models\Orders\Order; use App\Models\Process; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Log; /** * 수주 → 생산지시 시 절곡 공정용 bending_info JSON 자동 생성 * * 입력: Order (rootNodes eager loaded) + processId - * 출력: BendingInfoExtended 구조의 array (work_orders.options.bending_info에 저장) + * 출력: ['bending_info' => array, 'context' => array] — bending_info + dynamic_bom 생성 컨텍스트 * * @see react/src/components/production/WorkOrders/documents/bending/types.ts */ @@ -18,6 +20,7 @@ class BendingInfoBuilder { // 표준 원자재 길이 버킷 (5130 레거시 write_form.php 기준) private const GUIDE_RAIL_LENGTHS = [2438, 3000, 3500, 4000, 4300]; + private const SHUTTER_BOX_LENGTHS = [1219, 2438, 3000, 3500, 4000, 4150]; /** @@ -65,7 +68,307 @@ public function build(Order $order, int $processId, ?array $nodeIds = null): ?ar $aggregated = $this->aggregateNodes($nodes); // 6. bending_info 조립 - return $this->assembleBendingInfo($productInfo, $materials, $aggregated); + $bendingInfo = $this->assembleBendingInfo($productInfo, $materials, $aggregated); + + // 7. 셔터박스 크기 추출 (dynamic_bom 컨텍스트용) + $caseBom = $aggregated['bomCategories']['shutterBox_case'] ?? null; + $motorBom = $aggregated['bomCategories']['motor'] ?? null; + $boxSize = null; + if ($caseBom) { + $motorCapacity = $this->extractMotorCapacity($motorBom); + $boxSize = $motorCapacity ? $this->getShutterBoxSize($motorCapacity) : null; + if (! $boxSize) { + $boxSize = str_replace('BD-케이스-', '', $caseBom['item_code'] ?? ''); + } + } + + return [ + 'bending_info' => $bendingInfo, + 'context' => [ + 'productCode' => $productInfo['productCode'], + 'guideType' => $productInfo['guideType'], + 'finishMaterial' => $productInfo['finishMaterial'], + 'materials' => $materials, + 'boxSize' => $boxSize, + 'hasSmokeRail' => isset($aggregated['bomCategories']['smokeBarrier_rail']), + 'hasSmokeCase' => isset($aggregated['bomCategories']['smokeBarrier_case']), + ], + ]; + } + + /** + * 개소(work_order_item) 단위 dynamic_bom 생성 + * + * @param array $context build() 반환값의 'context' + * @param int $width 개소의 오픈폭 (mm) + * @param int $height 개소의 오픈높이 (mm) + * @param int $qty 개소 수량 + * @param int $tenantId 테넌트 ID (item 조회용) + * @return array DynamicBomEntry::toArray() 배열 + */ + public function buildDynamicBomForItem(array $context, int $width, int $height, int $qty, int $tenantId = 287): array + { + $resolver = new PrefixResolver; + $entries = []; + + $productCode = $context['productCode']; + $guideType = $context['guideType']; + $finishMaterial = $context['finishMaterial']; + $materials = $context['materials']; + $boxSize = $context['boxSize']; + $hasExtraFinish = ! empty($materials['guideRailExtraFinish']); + + // ─── 1. 가이드레일 세부품목 ─── + $dimGroups = [['height' => $height, 'width' => $width, 'qty' => $qty]]; + $heightData = $this->heightLengthData($dimGroups); + + // 가이드레일은 개구부 양쪽 2개이므로 수량 ×2 + foreach ($heightData as &$entry) { + $entry['quantity'] *= 2; + } + unset($entry); + + $guideTypes = match ($guideType) { + '혼합형' => ['wall', 'side'], + '측면형' => ['side'], + default => ['wall'], + }; + + $guidePartTypes = ['finish', 'body', 'c_type', 'd_type']; + if ($hasExtraFinish) { + $guidePartTypes[] = 'extra_finish'; + } + $guidePartTypes[] = 'base'; + + foreach ($guideTypes as $gType) { + foreach ($heightData as $ld) { + foreach ($guidePartTypes as $partType) { + $prefix = $resolver->resolveGuideRailPrefix($partType, $gType, $productCode); + if (empty($prefix)) { + continue; + } + + $itemCode = $resolver->buildItemCode($prefix, $ld['length']); + if (! $itemCode) { + Log::warning('BendingInfoBuilder: lengthCode 변환 실패', ['prefix' => $prefix, 'length' => $ld['length']]); + + continue; + } + + $itemId = $resolver->resolveItemId($itemCode, $tenantId); + if (! $itemId) { + Log::warning('BendingInfoBuilder: 미등록 품목', ['code' => $itemCode]); + + continue; + } + + $entries[] = new DynamicBomEntry( + child_item_id: $itemId, + child_item_code: $itemCode, + lot_prefix: $prefix, + part_type: PrefixResolver::partTypeName($partType), + category: 'guideRail', + material_type: $this->resolvePartMaterial($partType, $gType, $materials), + length_mm: $ld['length'], + qty: $ld['quantity'], + ); + } + } + } + + // ─── 2. 하단마감재 세부품목 ─── + [$qty3000, $qty4000] = $this->bottomBarDistribution($width); + $bottomLengths = array_filter([3000 => $qty3000 * $qty, 4000 => $qty4000 * $qty]); + + $bottomPartTypes = ['main', 'lbar', 'reinforce']; + $hasBottomExtra = ! empty($materials['bottomBarExtraFinish']) && $materials['bottomBarExtraFinish'] !== '없음'; + if ($hasBottomExtra) { + $bottomPartTypes[] = 'extra'; + } + + foreach ($bottomLengths as $length => $lengthQty) { + if ($lengthQty <= 0) { + continue; + } + foreach ($bottomPartTypes as $partType) { + $prefix = $resolver->resolveBottomBarPrefix($partType, $productCode, $finishMaterial); + $itemCode = $resolver->buildItemCode($prefix, $length); + if (! $itemCode) { + continue; + } + $itemId = $resolver->resolveItemId($itemCode, $tenantId); + if (! $itemId) { + Log::warning('BendingInfoBuilder: 미등록 하단마감재', ['code' => $itemCode]); + + continue; + } + + $entries[] = new DynamicBomEntry( + child_item_id: $itemId, + child_item_code: $itemCode, + lot_prefix: $prefix, + part_type: PrefixResolver::partTypeName($partType), + category: 'bottomBar', + material_type: $materials['bottomBarFinish'], + length_mm: $length, + qty: $lengthQty, + ); + } + } + + // ─── 3. 셔터박스 세부품목 ─── + if ($boxSize) { + $isStandard = $boxSize === '500*380'; + $dist = $this->shutterBoxDistribution($width); + $shutterPartTypes = ['front', 'lintel', 'inspection', 'rear_corner', 'top_cover', 'fin_cover']; + + foreach ($dist as $length => $count) { + $totalCount = $count * $qty; + if ($totalCount <= 0) { + continue; + } + foreach ($shutterPartTypes as $partType) { + $prefix = $resolver->resolveShutterBoxPrefix($partType, $isStandard); + $itemCode = $resolver->buildItemCode($prefix, $length); + if (! $itemCode) { + continue; + } + $itemId = $resolver->resolveItemId($itemCode, $tenantId); + if (! $itemId) { + Log::warning('BendingInfoBuilder: 미등록 셔터박스', ['code' => $itemCode]); + + continue; + } + + $entries[] = new DynamicBomEntry( + child_item_id: $itemId, + child_item_code: $itemCode, + lot_prefix: $prefix, + part_type: PrefixResolver::partTypeName($partType), + category: 'shutterBox', + material_type: 'EGI', + length_mm: $length, + qty: $totalCount, + ); + } + } + + // 상부덮개 수량: ceil(width / 1219) × qty (1219mm 단위) + $coverQty = (int) ceil($width / 1219) * $qty; + if ($coverQty > 0) { + $coverPrefix = $resolver->resolveShutterBoxPrefix('top_cover', $isStandard); + $coverCode = $resolver->buildItemCode($coverPrefix, 1219); + if ($coverCode) { + $coverId = $resolver->resolveItemId($coverCode, $tenantId); + if ($coverId) { + $entries[] = new DynamicBomEntry( + child_item_id: $coverId, + child_item_code: $coverCode, + lot_prefix: $coverPrefix, + part_type: PrefixResolver::partTypeName('top_cover'), + category: 'shutterBox', + material_type: 'EGI', + length_mm: 1219, + qty: $coverQty, + ); + } + } + } + + // 마구리 수량: qty × 2 + $finQty = $qty * 2; + if ($finQty > 0) { + $finPrefix = $resolver->resolveShutterBoxPrefix('fin_cover', $isStandard); + // 마구리는 박스 높이 기준이므로 셔터박스 최소 단위 사용 + $finCode = $resolver->buildItemCode($finPrefix, 1219); + if ($finCode) { + $finId = $resolver->resolveItemId($finCode, $tenantId); + if ($finId) { + $entries[] = new DynamicBomEntry( + child_item_id: $finId, + child_item_code: $finCode, + lot_prefix: $finPrefix, + part_type: PrefixResolver::partTypeName('fin_cover'), + category: 'shutterBox', + material_type: 'EGI', + length_mm: 1219, + qty: $finQty, + ); + } + } + } + } + + // ─── 4. 연기차단재 세부품목 ─── + if ($context['hasSmokeRail'] || $context['hasSmokeCase']) { + $smokePrefix = $resolver->resolveSmokeBarrierPrefix(); + + // W50 (레일용): open_height + 250 → 표준 길이 + if ($context['hasSmokeRail']) { + $col24 = $height + 250; + $w50Length = $this->bucketToStandardLength($col24, [2438, 3000, 3500, 4000, 4300]); + if ($w50Length && $col24 <= 4300) { + $w50Code = $resolver->buildItemCode($smokePrefix, $w50Length, 'w50'); + if ($w50Code) { + $w50Id = $resolver->resolveItemId($w50Code, $tenantId); + if ($w50Id) { + $entries[] = new DynamicBomEntry( + child_item_id: $w50Id, + child_item_code: $w50Code, + lot_prefix: $smokePrefix, + part_type: '연기차단재(W50)', + category: 'smokeBarrier', + material_type: 'GI', + length_mm: $w50Length, + qty: 2 * $qty, + ); + } + } + } + } + + // W80 (케이스용): floor((width+240)*2/3000 + 1) × qty + if ($context['hasSmokeCase']) { + $col38 = $width + 240; + $w80PerNode = (int) floor(($col38 * 2 / 3000) + 1); + $w80Qty = $w80PerNode * $qty; + if ($w80Qty > 0) { + // W80은 3000mm 기본 (레거시 동일) + $w80Code = $resolver->buildItemCode($smokePrefix, 3000, 'w80'); + if ($w80Code) { + $w80Id = $resolver->resolveItemId($w80Code, $tenantId); + if ($w80Id) { + $entries[] = new DynamicBomEntry( + child_item_id: $w80Id, + child_item_code: $w80Code, + lot_prefix: $smokePrefix, + part_type: '연기차단재(W80)', + category: 'smokeBarrier', + material_type: 'GI', + length_mm: 3000, + qty: $w80Qty, + ); + } + } + } + } + } + + return DynamicBomEntry::toArrayList($entries); + } + + /** + * 파트타입 + 가이드타입 → 실제 재질 결정 + */ + private function resolvePartMaterial(string $partType, string $guideType, array $materials): string + { + return match ($partType) { + 'finish' => $materials['guideRailFinish'], + 'extra_finish' => $materials['guideRailExtraFinish'], + 'body', 'c_type', 'd_type' => $materials['bodyMaterial'], + 'base' => 'EGI', + default => '', + }; } /** diff --git a/app/Services/Production/PrefixResolver.php b/app/Services/Production/PrefixResolver.php new file mode 100644 index 0000000..772b995 --- /dev/null +++ b/app/Services/Production/PrefixResolver.php @@ -0,0 +1,307 @@ + ['KSS' => 'RS', 'KQTS' => 'RS', 'KSE' => 'RE', 'KWE' => 'RE', 'KTE' => 'RS'], + 'body' => 'RM', + 'c_type' => 'RC', + 'd_type' => 'RD', + 'extra_finish' => 'YY', + 'base' => 'XX', + ]; + + /** 측면형(Side) prefix */ + private const SIDE_PREFIXES = [ + 'finish' => ['KSS' => 'SS', 'KQTS' => 'SS', 'KSE' => 'SE', 'KWE' => 'SE', 'KTE' => 'SS'], + 'body' => 'SM', + 'c_type' => 'SC', + 'd_type' => 'SD', + 'extra_finish' => 'YY', + 'base' => 'XX', + ]; + + /** 철재(KTE01) body 오버라이드 */ + private const STEEL_BODY_OVERRIDES = [ + 'wall' => 'RT', + 'side' => 'ST', + ]; + + // ───────────────────────────────────────────────── + // 하단마감재 Prefix 맵 + // ───────────────────────────────────────────────── + + /** 하단마감재 main prefix: finishMaterial 기반 */ + private const BOTTOM_BAR_MAIN = [ + 'EGI' => 'BE', + 'SUS' => 'BS', + 'STEEL' => 'TS', + ]; + + // ───────────────────────────────────────────────── + // 셔터박스 Prefix 맵 + // ───────────────────────────────────────────────── + + /** 표준 사이즈(500*380) 셔터박스 prefix */ + private const SHUTTER_STANDARD = [ + 'front' => 'CF', + 'lintel' => 'CL', + 'inspection' => 'CP', + 'rear_corner' => 'CB', + 'top_cover' => 'XX', + 'fin_cover' => 'XX', + ]; + + // ───────────────────────────────────────────────── + // 길이코드 매핑 + // ───────────────────────────────────────────────── + + private const LENGTH_TO_CODE = [ + 1219 => '12', + 2438 => '24', + 3000 => '30', + 3500 => '35', + 4000 => '40', + 4150 => '41', + 4200 => '42', + 4300 => '43', + ]; + + /** 연기차단재 전용 길이코드 */ + private const SMOKE_LENGTH_TO_CODE = [ + 'w50' => [3000 => '53', 4000 => '54'], + 'w80' => [3000 => '83', 4000 => '84'], + ]; + + /** 파트타입 한글명 */ + private const PART_TYPE_NAMES = [ + 'finish' => '마감재', + 'body' => '본체', + 'c_type' => 'C형', + 'd_type' => 'D형', + 'extra_finish' => '별도마감', + 'base' => '하부BASE', + 'main' => '메인', + 'lbar' => 'L-Bar', + 'reinforce' => '보강평철', + 'extra' => '별도마감', + 'front' => '전면부', + 'lintel' => '린텔부', + 'inspection' => '점검구', + 'rear_corner' => '후면코너부', + 'top_cover' => '상부덮개', + 'fin_cover' => '마구리', + 'smoke' => '연기차단재', + ]; + + /** items.id 캐시: code → id */ + private array $itemIdCache = []; + + // ───────────────────────────────────────────────── + // 가이드레일 + // ───────────────────────────────────────────────── + + /** + * 가이드레일 세부품목의 prefix 결정 + * + * @param string $partType 'finish', 'body', 'c_type', 'd_type', 'extra_finish', 'base' + * @param string $guideType 'wall', 'side' + * @param string $productCode 'KSS01', 'KSE01', 'KWE01', 'KTE01', 'KQTS01' + * @return string prefix (빈 문자열이면 해당 파트 없음) + */ + public function resolveGuideRailPrefix(string $partType, string $guideType, string $productCode): string + { + $prefixMap = $guideType === 'wall' ? self::WALL_PREFIXES : self::SIDE_PREFIXES; + $codePrefix = $this->extractCodePrefix($productCode); + $isSteel = $codePrefix === 'KTE'; + + // body: 철재 오버라이드 + if ($partType === 'body' && $isSteel) { + return self::STEEL_BODY_OVERRIDES[$guideType] ?? ''; + } + + // finish: productCode별 분기 + if ($partType === 'finish') { + $finishMap = $prefixMap['finish'] ?? []; + + return $finishMap[$codePrefix] ?? ''; + } + + // extra_finish, base, c_type, d_type, body: 고정 prefix + return $prefixMap[$partType] ?? ''; + } + + // ───────────────────────────────────────────────── + // 하단마감재 + // ───────────────────────────────────────────────── + + /** + * 하단마감재 세부품목의 prefix 결정 + * + * @param string $partType 'main', 'lbar', 'reinforce', 'extra' + * @param string $productCode 'KSS01', 'KSE01', etc. + * @param string $finishMaterial 'EGI마감', 'SUS마감' + * @return string prefix + */ + public function resolveBottomBarPrefix(string $partType, string $productCode, string $finishMaterial): string + { + if ($partType === 'lbar') { + return 'LA'; + } + if ($partType === 'reinforce') { + return 'HH'; + } + if ($partType === 'extra') { + return 'YY'; + } + + // main: 재질 기반 + $codePrefix = $this->extractCodePrefix($productCode); + $isSteel = $codePrefix === 'KTE'; + + if ($isSteel) { + return 'TS'; + } + + $isSUS = in_array($codePrefix, ['KSS', 'KQTS']); + + return $isSUS ? 'BS' : 'BE'; + } + + // ───────────────────────────────────────────────── + // 셔터박스 + // ───────────────────────────────────────────────── + + /** + * 셔터박스 세부품목의 prefix 결정 + * + * @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 + { + if (! $isStandardSize) { + return 'XX'; + } + + return self::SHUTTER_STANDARD[$partType] ?? 'XX'; + } + + // ───────────────────────────────────────────────── + // 연기차단재 + // ───────────────────────────────────────────────── + + /** + * 연기차단재 세부품목의 prefix 결정 (항상 GI) + */ + public function resolveSmokeBarrierPrefix(): string + { + return 'GI'; + } + + // ───────────────────────────────────────────────── + // 코드 생성 및 조회 + // ───────────────────────────────────────────────── + + /** + * prefix + 길이(mm) → BD-XX-NN 코드 생성 + * + * @param string $prefix LOT prefix (RS, RM, etc.) + * @param int $lengthMm 길이 (mm) + * @param string|null $smokeCategory 연기차단재 카테고리 ('w50', 'w80') + * @return string|null BD 코드 (길이코드 변환 실패 시 null) + */ + public function buildItemCode(string $prefix, int $lengthMm, ?string $smokeCategory = null): ?string + { + $lengthCode = self::lengthToCode($lengthMm, $smokeCategory); + if ($lengthCode === null) { + return null; + } + + return "BD-{$prefix}-{$lengthCode}"; + } + + /** + * BD-XX-NN 코드 → items.id 조회 (캐시) + * + * @return int|null items.id (미등록 시 null) + */ + public function resolveItemId(string $itemCode, int $tenantId = 287): ?int + { + $cacheKey = "{$tenantId}:{$itemCode}"; + + if (isset($this->itemIdCache[$cacheKey])) { + return $this->itemIdCache[$cacheKey]; + } + + $id = DB::table('items') + ->where('tenant_id', $tenantId) + ->where('code', $itemCode) + ->whereNull('deleted_at') + ->value('id'); + + $this->itemIdCache[$cacheKey] = $id; + + return $id; + } + + /** + * 길이(mm) → 길이코드 변환 + * + * @param int $lengthMm 길이 (mm) + * @param string|null $smokeCategory 연기차단재 카테고리 ('w50', 'w80') + * @return string|null 길이코드 (변환 불가 시 null) + */ + public static function lengthToCode(int $lengthMm, ?string $smokeCategory = null): ?string + { + // 연기차단재 전용 코드 + if ($smokeCategory && isset(self::SMOKE_LENGTH_TO_CODE[$smokeCategory][$lengthMm])) { + return self::SMOKE_LENGTH_TO_CODE[$smokeCategory][$lengthMm]; + } + + return self::LENGTH_TO_CODE[$lengthMm] ?? null; + } + + /** + * 파트타입 한글명 반환 + */ + public static function partTypeName(string $partType): string + { + return self::PART_TYPE_NAMES[$partType] ?? $partType; + } + + /** + * 캐시 초기화 (테스트 용) + */ + public function clearCache(): void + { + $this->itemIdCache = []; + } + + // ───────────────────────────────────────────────── + // private + // ───────────────────────────────────────────────── + + /** + * 'KSS01' → 'KSS', 'KQTS01' → 'KQTS' 등 제품코드 prefix 추출 + */ + private function extractCodePrefix(string $productCode): string + { + return preg_replace('/\d+$/', '', $productCode); + } +} diff --git a/app/Services/WorkOrderService.php b/app/Services/WorkOrderService.php index 2821606..3d925ce 100644 --- a/app/Services/WorkOrderService.php +++ b/app/Services/WorkOrderService.php @@ -1192,13 +1192,67 @@ public function getMaterials(int $workOrderId): array throw new NotFoundHttpException(__('error.not_found')); } - // Phase 1: 작업지시 품목들에서 유니크 자재 목록 수집 (item_id 기준 합산) + // ── Step 1: dynamic_bom 대상 item_id 일괄 수집 (N+1 방지) ── + $allDynamicItemIds = []; + foreach ($workOrder->items as $woItem) { + $options = is_string($woItem->options) ? json_decode($woItem->options, true) : ($woItem->options ?? []); + $dynamicBom = $options['dynamic_bom'] ?? null; + if ($dynamicBom && is_array($dynamicBom)) { + $allDynamicItemIds = array_merge($allDynamicItemIds, array_column($dynamicBom, 'child_item_id')); + } + } + + // 배치 조회 (dynamic_bom 품목) + $dynamicItems = []; + if (! empty($allDynamicItemIds)) { + $dynamicItems = \App\Models\Items\Item::where('tenant_id', $tenantId) + ->whereIn('id', array_unique($allDynamicItemIds)) + ->get() + ->keyBy('id'); + } + + // ── Step 2: 유니크 자재 목록 수집 ── + // 키: dynamic_bom → "{item_id}_{woItem_id}", 기존 BOM → "{item_id}" $uniqueMaterials = []; foreach ($workOrder->items as $woItem) { + $options = is_string($woItem->options) ? json_decode($woItem->options, true) : ($woItem->options ?? []); + $dynamicBom = $options['dynamic_bom'] ?? null; + + // dynamic_bom 우선 — 있으면 BOM 무시 + if ($dynamicBom && is_array($dynamicBom)) { + foreach ($dynamicBom as $bomEntry) { + $childItemId = $bomEntry['child_item_id'] ?? null; + if (! $childItemId || ! isset($dynamicItems[$childItemId])) { + continue; + } + + // 합산 키: (item_id, work_order_item_id) 쌍 + $key = $childItemId.'_'.$woItem->id; + $bomQty = (float) ($bomEntry['qty'] ?? 1); + $requiredQty = $bomQty * ($woItem->quantity ?? 1); + + if (isset($uniqueMaterials[$key])) { + $uniqueMaterials[$key]['required_qty'] += $requiredQty; + } else { + $uniqueMaterials[$key] = [ + 'item' => $dynamicItems[$childItemId], + 'bom_qty' => $bomQty, + 'required_qty' => $requiredQty, + 'work_order_item_id' => $woItem->id, + 'lot_prefix' => $bomEntry['lot_prefix'] ?? null, + 'part_type' => $bomEntry['part_type'] ?? null, + 'category' => $bomEntry['category'] ?? null, + ]; + } + } + + continue; // dynamic_bom이 있으면 기존 BOM fallback 건너뜀 + } + + // 기존 BOM 로직 (하위 호환) $materialItems = []; - // BOM이 있으면 자식 품목들을 자재로 사용 if ($woItem->item_id) { $item = \App\Models\Items\Item::where('tenant_id', $tenantId) ->find($woItem->item_id); @@ -1237,7 +1291,7 @@ public function getMaterials(int $workOrderId): array ]; } - // 유니크 자재 수집 (같은 item_id면 required_qty 합산) + // 기존 방식: item_id 기준 합산 foreach ($materialItems as $matInfo) { $itemId = $matInfo['item']->id; if (isset($uniqueMaterials[$itemId])) { @@ -1248,30 +1302,67 @@ public function getMaterials(int $workOrderId): array } } - // Phase 2: 유니크 자재별로 StockLot 조회 + // ── Step 3: 유니크 자재별로 StockLot 조회 ── + // 배치 조회를 위해 전체 item_id 수집 + $allItemIds = []; + foreach ($uniqueMaterials as $matInfo) { + $allItemIds[] = $matInfo['item']->id; + } + $allItemIds = array_unique($allItemIds); + + // Stock 배치 조회 (N+1 방지) + $stockMap = []; + if (! empty($allItemIds)) { + $stocks = \App\Models\Tenants\Stock::where('tenant_id', $tenantId) + ->whereIn('item_id', $allItemIds) + ->get(); + + foreach ($stocks as $stock) { + $stockMap[$stock->item_id] = $stock; + } + } + + // StockLot 배치 조회 (N+1 방지) + $lotsByStockId = []; + $stockIds = array_map(fn ($s) => $s->id, $stockMap); + if (! empty($stockIds)) { + $allLots = \App\Models\Tenants\StockLot::where('tenant_id', $tenantId) + ->whereIn('stock_id', $stockIds) + ->where('status', 'available') + ->where('available_qty', '>', 0) + ->orderBy('fifo_order', 'asc') + ->get(); + + foreach ($allLots as $lot) { + $lotsByStockId[$lot->stock_id][] = $lot; + } + } + $materials = []; $rank = 1; foreach ($uniqueMaterials as $matInfo) { $materialItem = $matInfo['item']; - - $stock = \App\Models\Tenants\Stock::where('tenant_id', $tenantId) - ->where('item_id', $materialItem->id) - ->first(); - + $stock = $stockMap[$materialItem->id] ?? null; $lotsFound = false; + // 공통 필드 (dynamic_bom 추가 필드 포함) + $extraFields = []; + if (isset($matInfo['work_order_item_id'])) { + $extraFields = [ + 'work_order_item_id' => $matInfo['work_order_item_id'], + 'lot_prefix' => $matInfo['lot_prefix'], + 'part_type' => $matInfo['part_type'], + 'category' => $matInfo['category'], + ]; + } + if ($stock) { - $lots = \App\Models\Tenants\StockLot::where('tenant_id', $tenantId) - ->where('stock_id', $stock->id) - ->where('status', 'available') - ->where('available_qty', '>', 0) - ->orderBy('fifo_order', 'asc') - ->get(); + $lots = $lotsByStockId[$stock->id] ?? []; foreach ($lots as $lot) { $lotsFound = true; - $materials[] = [ + $materials[] = array_merge([ 'stock_lot_id' => $lot->id, 'item_id' => $materialItem->id, 'lot_no' => $lot->lot_no, @@ -1287,13 +1378,13 @@ public function getMaterials(int $workOrderId): array 'receipt_date' => $lot->receipt_date, 'supplier' => $lot->supplier, 'fifo_rank' => $rank++, - ]; + ], $extraFields); } } // 가용 로트가 없는 경우 자재 정보만 반환 (재고 없음 표시) if (! $lotsFound) { - $materials[] = [ + $materials[] = array_merge([ 'stock_lot_id' => null, 'item_id' => $materialItem->id, 'lot_no' => null, @@ -1309,7 +1400,7 @@ public function getMaterials(int $workOrderId): array 'receipt_date' => null, 'supplier' => null, 'fifo_rank' => $rank++, - ]; + ], $extraFields); } } @@ -1337,11 +1428,50 @@ public function registerMaterialInput(int $workOrderId, array $inputs): array throw new NotFoundHttpException(__('error.not_found')); } - return DB::transaction(function () use ($inputs, $tenantId, $userId, $workOrderId) { + // work_order_item_id가 있는 항목은 registerMaterialInputForItem()으로 위임 + $groupedByItem = []; + $noItemInputs = []; + + foreach ($inputs as $input) { + $woItemId = $input['work_order_item_id'] ?? null; + if ($woItemId) { + $groupedByItem[$woItemId][] = $input; + } else { + $noItemInputs[] = $input; + } + } + + // work_order_item_id가 있는 항목 → 개소별 투입으로 위임 + $delegatedResults = []; + foreach ($groupedByItem as $woItemId => $itemInputs) { + $delegatedResults[] = $this->registerMaterialInputForItem($workOrderId, $woItemId, $itemInputs); + } + + // work_order_item_id가 없는 항목 → 기존 방식 + WorkOrderMaterialInput 레코드 생성 + if (empty($noItemInputs)) { + // 전부 위임된 경우 + $totalCount = array_sum(array_column($delegatedResults, 'material_count')); + $allResults = array_merge(...array_map(fn ($r) => $r['input_results'], $delegatedResults)); + + return [ + 'work_order_id' => $workOrderId, + 'material_count' => $totalCount, + 'input_results' => $allResults, + 'input_at' => now()->toDateTimeString(), + ]; + } + + // fallback: 첫 번째 work_order_item_id로 매핑 + $fallbackWoItemId = WorkOrderItem::where('tenant_id', $tenantId) + ->where('work_order_id', $workOrderId) + ->orderBy('id') + ->value('id'); + + return DB::transaction(function () use ($noItemInputs, $tenantId, $userId, $workOrderId, $fallbackWoItemId, $delegatedResults) { $stockService = app(StockService::class); $inputResults = []; - foreach ($inputs as $input) { + foreach ($noItemInputs as $input) { $stockLotId = $input['stock_lot_id'] ?? null; $qty = (float) ($input['qty'] ?? 0); @@ -1357,6 +1487,21 @@ public function registerMaterialInput(int $workOrderId, array $inputs): array referenceId: $workOrderId ); + // WorkOrderMaterialInput 레코드 생성 (이력 통일) + $lot = \App\Models\Tenants\StockLot::with('stock')->find($stockLotId); + $lotItemId = $lot?->stock?->item_id; + + WorkOrderMaterialInput::create([ + 'tenant_id' => $tenantId, + 'work_order_id' => $workOrderId, + 'work_order_item_id' => $fallbackWoItemId, + 'stock_lot_id' => $stockLotId, + 'item_id' => $lotItemId ?? 0, + 'qty' => $qty, + 'input_by' => $userId, + 'input_at' => now(), + ]); + $inputResults[] = [ 'stock_lot_id' => $stockLotId, 'qty' => $qty, @@ -1373,17 +1518,23 @@ public function registerMaterialInput(int $workOrderId, array $inputs): array 'material_input', null, [ - 'inputs' => $inputs, + 'inputs' => $noItemInputs, 'input_results' => $inputResults, 'input_by' => $userId, 'input_at' => now()->toDateTimeString(), ] ); + // 위임된 결과와 합산 + $allResults = $inputResults; + foreach ($delegatedResults as $dr) { + $allResults = array_merge($allResults, $dr['input_results']); + } + return [ 'work_order_id' => $workOrderId, - 'material_count' => count($inputResults), - 'input_results' => $inputResults, + 'material_count' => count($allResults), + 'input_results' => $allResults, 'input_at' => now()->toDateTimeString(), ]; }); @@ -2856,9 +3007,9 @@ public function registerMaterialInputForItem(int $workOrderId, int $itemId, arra referenceId: $workOrderId ); - // 로트의 품목 ID 조회 - $lot = \App\Models\Tenants\StockLot::find($stockLotId); - $lotItemId = $lot ? ($lot->stock->item_id ?? null) : null; + // 로트의 품목 ID 조회 (Eager Loading으로 N+1 방지) + $lot = \App\Models\Tenants\StockLot::with('stock')->find($stockLotId); + $lotItemId = $lot?->stock?->item_id; // 개소별 매핑 레코드 생성 WorkOrderMaterialInput::create([ diff --git a/tests/Feature/Production/BendingLotPipelineTest.php b/tests/Feature/Production/BendingLotPipelineTest.php new file mode 100644 index 0000000..dbf0aac --- /dev/null +++ b/tests/Feature/Production/BendingLotPipelineTest.php @@ -0,0 +1,278 @@ +resolver = new PrefixResolver; + } + + // ───────────────────────────────────────────────── + // PrefixResolver → items.id 조회 통합 + // ───────────────────────────────────────────────── + + /** + * BD-* 품목이 items 테이블에 실제 존재하는지 확인 + */ + public function test_prefix_resolver_resolves_existing_bd_items(): void + { + $testCodes = [ + 'BD-RS-43', 'BD-RM-30', 'BD-RC-35', 'BD-RD-40', + 'BD-SS-43', 'BD-SM-30', 'BD-SC-35', 'BD-SD-40', + 'BD-BE-30', 'BD-BS-40', 'BD-LA-30', + 'BD-CF-30', 'BD-CL-24', 'BD-CP-30', 'BD-CB-30', + 'BD-GI-53', 'BD-GI-84', + 'BD-XX-30', 'BD-YY-43', 'BD-HH-30', + ]; + + $foundCount = 0; + $missingCodes = []; + + foreach ($testCodes as $code) { + $id = $this->resolver->resolveItemId($code, self::TENANT_ID); + if ($id !== null) { + $foundCount++; + $this->assertGreaterThan(0, $id, "Item ID for {$code} must be positive"); + } else { + $missingCodes[] = $code; + } + } + + // Phase 0에서 전부 등록했으므로 모두 존재해야 함 + $this->assertEmpty( + $missingCodes, + 'Missing BD items: '.implode(', ', $missingCodes) + ); + $this->assertCount(count($testCodes), array_diff($testCodes, $missingCodes)); + } + + /** + * resolveItemId 캐시 동작 확인 + */ + public function test_resolve_item_id_uses_cache(): void + { + $code = 'BD-RS-43'; + $id1 = $this->resolver->resolveItemId($code, self::TENANT_ID); + $id2 = $this->resolver->resolveItemId($code, self::TENANT_ID); + + $this->assertNotNull($id1); + $this->assertSame($id1, $id2, 'Cached result should be identical'); + } + + // ───────────────────────────────────────────────── + // dynamic_bom 생성 → JSON 구조 검증 + // ───────────────────────────────────────────────── + + /** + * DynamicBomEntry 배열이 올바른 JSON 구조로 변환되는지 확인 + */ + public function test_dynamic_bom_entries_produce_valid_json_structure(): void + { + $entries = []; + + // 가이드레일 벽면형 KSS01 (SUS) 4300mm + $testCombinations = [ + ['finish', 'wall', 'KSS01', 4300, 'guideRail', 'SUS'], + ['body', 'wall', 'KSS01', 4300, 'guideRail', 'EGI'], + ['c_type', 'wall', 'KSS01', 4300, 'guideRail', 'EGI'], + ['d_type', 'wall', 'KSS01', 4300, 'guideRail', 'EGI'], + ['base', 'wall', 'KSS01', 4300, 'guideRail', 'EGI'], + ]; + + foreach ($testCombinations as [$partType, $guideType, $productCode, $lengthMm, $category, $materialType]) { + $prefix = $this->resolver->resolveGuideRailPrefix($partType, $guideType, $productCode); + $itemCode = $this->resolver->buildItemCode($prefix, $lengthMm); + $this->assertNotNull($itemCode, "buildItemCode failed for {$prefix}/{$lengthMm}"); + + $itemId = $this->resolver->resolveItemId($itemCode, self::TENANT_ID); + if ($itemId === null) { + $this->markTestSkipped("Item {$itemCode} not found in DB — run Phase 0 first"); + } + + $entries[] = DynamicBomEntry::fromArray([ + 'child_item_id' => $itemId, + 'child_item_code' => $itemCode, + 'lot_prefix' => $prefix, + 'part_type' => PrefixResolver::partTypeName($partType), + 'category' => $category, + 'material_type' => $materialType, + 'length_mm' => $lengthMm, + 'qty' => 1, + ]); + } + + $json = DynamicBomEntry::toArrayList($entries); + + $this->assertCount(5, $json); + $this->assertEquals('BD-RS-43', $json[0]['child_item_code']); + $this->assertEquals('BD-RM-43', $json[1]['child_item_code']); + $this->assertEquals('BD-RC-43', $json[2]['child_item_code']); + $this->assertEquals('BD-RD-43', $json[3]['child_item_code']); + $this->assertEquals('BD-XX-43', $json[4]['child_item_code']); + + // JSON 인코딩/디코딩 정합성 + $encoded = json_encode($json, JSON_UNESCAPED_UNICODE); + $decoded = json_decode($encoded, true); + $this->assertEquals($json, $decoded, 'JSON round-trip should be identical'); + } + + // ───────────────────────────────────────────────── + // getMaterials dynamic_bom 우선 체크 + // ───────────────────────────────────────────────── + + /** + * work_order_items.options.dynamic_bom이 있는 경우 + * getMaterials가 세부품목을 반환하는지 확인 + */ + public function test_get_materials_returns_dynamic_bom_items(): void + { + // 절곡 작업지시 찾기 (dynamic_bom이 있는) + $woItem = DB::table('work_order_items') + ->where('tenant_id', self::TENANT_ID) + ->whereNotNull('options') + ->whereRaw("JSON_EXTRACT(options, '$.dynamic_bom') IS NOT NULL") + ->first(); + + if (! $woItem) { + $this->markTestSkipped('No work_order_items with dynamic_bom found — create a bending work order first'); + } + + $options = is_string($woItem->options) ? json_decode($woItem->options, true) : ($woItem->options ?? []); + $dynamicBom = $options['dynamic_bom'] ?? []; + + $this->assertNotEmpty($dynamicBom, 'dynamic_bom should not be empty'); + + // dynamic_bom 각 항목 구조 검증 + foreach ($dynamicBom as $entry) { + $this->assertArrayHasKey('child_item_id', $entry); + $this->assertArrayHasKey('child_item_code', $entry); + $this->assertArrayHasKey('lot_prefix', $entry); + $this->assertArrayHasKey('part_type', $entry); + $this->assertArrayHasKey('category', $entry); + $this->assertGreaterThan(0, $entry['child_item_id']); + $this->assertMatchesRegularExpression('/^BD-[A-Z]{2}-\d{2}$/', $entry['child_item_code']); + } + } + + /** + * getMaterials API 응답에 work_order_item_id 필드가 포함되는지 확인 + */ + public function test_get_materials_api_includes_work_order_item_id(): void + { + // 절곡 작업지시 찾기 + $wo = DB::table('work_orders') + ->where('tenant_id', self::TENANT_ID) + ->whereExists(function ($query) { + $query->select(DB::raw(1)) + ->from('work_order_items') + ->whereColumn('work_order_items.work_order_id', 'work_orders.id') + ->whereRaw("JSON_EXTRACT(options, '$.dynamic_bom') IS NOT NULL"); + }) + ->first(); + + if (! $wo) { + $this->markTestSkipped('No work order with dynamic_bom items found'); + } + + // WorkOrderService 직접 호출로 getMaterials 검증 + $service = app(WorkOrderService::class); + $service->setContext(self::TENANT_ID, 1); + + $materials = $service->getMaterials($wo->id); + + // dynamic_bom 품목에는 work_order_item_id가 포함되어야 함 + $dynamicBomMaterials = array_filter($materials, fn ($m) => isset($m['work_order_item_id'])); + + if (empty($dynamicBomMaterials)) { + $this->markTestSkipped('getMaterials returned no dynamic_bom materials'); + } + + foreach ($dynamicBomMaterials as $material) { + $this->assertArrayHasKey('work_order_item_id', $material); + $this->assertArrayHasKey('lot_prefix', $material); + $this->assertArrayHasKey('category', $material); + $this->assertGreaterThan(0, $material['work_order_item_id']); + } + } + + // ───────────────────────────────────────────────── + // 전체 prefix × lengthCode 마스터 검증 (Phase 0 검증 재확인) + // ───────────────────────────────────────────────── + + /** + * 19종 prefix × 해당 lengthCode 조합이 모두 items 테이블에 존재하는지 확인 + */ + public function test_all_prefix_length_combinations_exist_in_items(): void + { + $standardLengths = [30, 35, 40, 43]; + $boxLengths = [12, 24, 30, 35, 40, 41]; + + $prefixLengthMap = [ + // 가이드레일 벽면형 + 'RS' => $standardLengths, 'RM' => array_merge($standardLengths, [24, 35]), + 'RC' => array_merge($standardLengths, [24, 35]), 'RD' => array_merge($standardLengths, [24, 35]), + 'RT' => [30, 43], + // 가이드레일 측면형 + 'SS' => [30, 35, 40, 43], 'SM' => [30, 35, 40, 43, 24], + 'SC' => [30, 35, 40, 43, 24], 'SD' => [30, 35, 40, 43, 24], + 'ST' => [43], 'SU' => [30, 35, 40, 43], + // 하단마감재 + 'BE' => [30, 40], 'BS' => [30, 35, 40, 43, 24], + 'TS' => [40, 43], + 'LA' => [30, 40], + // 셔터박스 (표준 길이: 43 제외 — 4300mm는 가이드레일 전용) + 'CF' => $boxLengths, 'CL' => $boxLengths, + 'CP' => $boxLengths, 'CB' => $boxLengths, + // 연기차단재 + 'GI' => [53, 54, 83, 84, 30, 35, 40], + // 공통 + 'XX' => array_merge($boxLengths, [43]), 'YY' => $standardLengths, + 'HH' => [30, 40], + ]; + + $missing = []; + + foreach ($prefixLengthMap as $prefix => $codes) { + foreach ($codes as $code) { + $itemCode = "BD-{$prefix}-{$code}"; + $exists = DB::table('items') + ->where('tenant_id', self::TENANT_ID) + ->where('code', $itemCode) + ->whereNull('deleted_at') + ->exists(); + + if (! $exists) { + $missing[] = $itemCode; + } + } + } + + $this->assertEmpty( + $missing, + 'Missing BD items in items table: '.implode(', ', $missing) + ); + } +} diff --git a/tests/Unit/Production/DynamicBomEntryTest.php b/tests/Unit/Production/DynamicBomEntryTest.php new file mode 100644 index 0000000..e787d93 --- /dev/null +++ b/tests/Unit/Production/DynamicBomEntryTest.php @@ -0,0 +1,173 @@ + 15812, + 'child_item_code' => 'BD-RS-43', + 'lot_prefix' => 'RS', + 'part_type' => '마감재', + 'category' => 'guideRail', + 'material_type' => 'SUS', + 'length_mm' => 4300, + 'qty' => 2, + ]; + } + + // ───────────────────────────────────────────────── + // fromArray + toArray 라운드트립 + // ───────────────────────────────────────────────── + + public function test_from_array_creates_dto(): void + { + $entry = DynamicBomEntry::fromArray($this->validData()); + + $this->assertEquals(15812, $entry->child_item_id); + $this->assertEquals('BD-RS-43', $entry->child_item_code); + $this->assertEquals('RS', $entry->lot_prefix); + $this->assertEquals('마감재', $entry->part_type); + $this->assertEquals('guideRail', $entry->category); + $this->assertEquals('SUS', $entry->material_type); + $this->assertEquals(4300, $entry->length_mm); + $this->assertEquals(2, $entry->qty); + } + + public function test_to_array_round_trip(): void + { + $data = $this->validData(); + $entry = DynamicBomEntry::fromArray($data); + $this->assertEquals($data, $entry->toArray()); + } + + public function test_to_array_list(): void + { + $entries = [ + DynamicBomEntry::fromArray($this->validData()), + DynamicBomEntry::fromArray(array_merge($this->validData(), [ + 'child_item_id' => 15813, + 'child_item_code' => 'BD-RM-43', + 'lot_prefix' => 'RM', + 'part_type' => '본체', + ])), + ]; + + $list = DynamicBomEntry::toArrayList($entries); + $this->assertCount(2, $list); + $this->assertEquals('BD-RS-43', $list[0]['child_item_code']); + $this->assertEquals('BD-RM-43', $list[1]['child_item_code']); + } + + // ───────────────────────────────────────────────── + // 유효한 카테고리 + // ───────────────────────────────────────────────── + + /** + * @dataProvider validCategoryProvider + */ + public function test_valid_categories(string $category): void + { + $data = array_merge($this->validData(), ['category' => $category]); + $entry = DynamicBomEntry::fromArray($data); + $this->assertEquals($category, $entry->category); + } + + public static function validCategoryProvider(): array + { + return [ + 'guideRail' => ['guideRail'], + 'bottomBar' => ['bottomBar'], + 'shutterBox' => ['shutterBox'], + 'smokeBarrier' => ['smokeBarrier'], + ]; + } + + // ───────────────────────────────────────────────── + // 필수 필드 누락 검증 + // ───────────────────────────────────────────────── + + /** + * @dataProvider requiredFieldProvider + */ + public function test_missing_required_field_throws(string $field): void + { + $data = $this->validData(); + unset($data[$field]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("'{$field}' is required"); + DynamicBomEntry::fromArray($data); + } + + public static function requiredFieldProvider(): array + { + return [ + 'child_item_id' => ['child_item_id'], + 'child_item_code' => ['child_item_code'], + 'lot_prefix' => ['lot_prefix'], + 'part_type' => ['part_type'], + 'category' => ['category'], + 'material_type' => ['material_type'], + 'length_mm' => ['length_mm'], + 'qty' => ['qty'], + ]; + } + + // ───────────────────────────────────────────────── + // 값 제약 검증 + // ───────────────────────────────────────────────── + + public function test_invalid_child_item_id_throws(): void + { + $data = array_merge($this->validData(), ['child_item_id' => 0]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('child_item_id must be positive'); + DynamicBomEntry::fromArray($data); + } + + public function test_invalid_category_throws(): void + { + $data = array_merge($this->validData(), ['category' => 'invalidCategory']); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('category must be one of'); + DynamicBomEntry::fromArray($data); + } + + public function test_zero_qty_throws(): void + { + $data = array_merge($this->validData(), ['qty' => 0]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('qty must be positive'); + DynamicBomEntry::fromArray($data); + } + + public function test_negative_qty_throws(): void + { + $data = array_merge($this->validData(), ['qty' => -1]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('qty must be positive'); + DynamicBomEntry::fromArray($data); + } + + // ───────────────────────────────────────────────── + // float qty 허용 + // ───────────────────────────────────────────────── + + public function test_float_qty_allowed(): void + { + $data = array_merge($this->validData(), ['qty' => 1.5]); + $entry = DynamicBomEntry::fromArray($data); + $this->assertEquals(1.5, $entry->qty); + } +} diff --git a/tests/Unit/Production/PrefixResolverTest.php b/tests/Unit/Production/PrefixResolverTest.php new file mode 100644 index 0000000..5551730 --- /dev/null +++ b/tests/Unit/Production/PrefixResolverTest.php @@ -0,0 +1,263 @@ +resolver = new PrefixResolver; + } + + // ───────────────────────────────────────────────── + // 가이드레일 벽면형(Wall) Prefix + // ───────────────────────────────────────────────── + + /** + * @dataProvider wallFinishProvider + */ + public function test_wall_finish_prefix(string $productCode, string $expected): void + { + $this->assertEquals( + $expected, + $this->resolver->resolveGuideRailPrefix('finish', 'wall', $productCode) + ); + } + + public static function wallFinishProvider(): array + { + return [ + 'KSS01 → RS' => ['KSS01', 'RS'], + 'KQTS01 → RS' => ['KQTS01', 'RS'], + 'KSE01 → RE' => ['KSE01', 'RE'], + 'KWE01 → RE' => ['KWE01', 'RE'], + 'KTE01 → RS' => ['KTE01', 'RS'], + ]; + } + + public function test_wall_body_prefix(): void + { + $this->assertEquals('RM', $this->resolver->resolveGuideRailPrefix('body', 'wall', 'KSS01')); + $this->assertEquals('RM', $this->resolver->resolveGuideRailPrefix('body', 'wall', 'KSE01')); + $this->assertEquals('RM', $this->resolver->resolveGuideRailPrefix('body', 'wall', 'KWE01')); + } + + public function test_wall_body_steel_override(): void + { + $this->assertEquals('RT', $this->resolver->resolveGuideRailPrefix('body', 'wall', 'KTE01')); + } + + public function test_wall_fixed_prefixes(): void + { + foreach (['KSS01', 'KSE01', 'KWE01', 'KTE01', 'KQTS01'] as $code) { + $this->assertEquals('RC', $this->resolver->resolveGuideRailPrefix('c_type', 'wall', $code)); + $this->assertEquals('RD', $this->resolver->resolveGuideRailPrefix('d_type', 'wall', $code)); + $this->assertEquals('YY', $this->resolver->resolveGuideRailPrefix('extra_finish', 'wall', $code)); + $this->assertEquals('XX', $this->resolver->resolveGuideRailPrefix('base', 'wall', $code)); + } + } + + // ───────────────────────────────────────────────── + // 가이드레일 측면형(Side) Prefix + // ───────────────────────────────────────────────── + + /** + * @dataProvider sideFinishProvider + */ + public function test_side_finish_prefix(string $productCode, string $expected): void + { + $this->assertEquals( + $expected, + $this->resolver->resolveGuideRailPrefix('finish', 'side', $productCode) + ); + } + + public static function sideFinishProvider(): array + { + return [ + 'KSS01 → SS' => ['KSS01', 'SS'], + 'KQTS01 → SS' => ['KQTS01', 'SS'], + 'KSE01 → SE' => ['KSE01', 'SE'], + 'KWE01 → SE' => ['KWE01', 'SE'], + 'KTE01 → SS' => ['KTE01', 'SS'], + ]; + } + + public function test_side_body_prefix(): void + { + $this->assertEquals('SM', $this->resolver->resolveGuideRailPrefix('body', 'side', 'KSS01')); + $this->assertEquals('SM', $this->resolver->resolveGuideRailPrefix('body', 'side', 'KSE01')); + } + + public function test_side_body_steel_override(): void + { + $this->assertEquals('ST', $this->resolver->resolveGuideRailPrefix('body', 'side', 'KTE01')); + } + + public function test_side_fixed_prefixes(): void + { + foreach (['KSS01', 'KSE01', 'KWE01', 'KTE01', 'KQTS01'] as $code) { + $this->assertEquals('SC', $this->resolver->resolveGuideRailPrefix('c_type', 'side', $code)); + $this->assertEquals('SD', $this->resolver->resolveGuideRailPrefix('d_type', 'side', $code)); + $this->assertEquals('YY', $this->resolver->resolveGuideRailPrefix('extra_finish', 'side', $code)); + $this->assertEquals('XX', $this->resolver->resolveGuideRailPrefix('base', 'side', $code)); + } + } + + // ───────────────────────────────────────────────── + // 하단마감재 Prefix + // ───────────────────────────────────────────────── + + public function test_bottom_bar_main_prefix(): void + { + // EGI 제품 + $this->assertEquals('BE', $this->resolver->resolveBottomBarPrefix('main', 'KSE01', 'EGI마감')); + $this->assertEquals('BE', $this->resolver->resolveBottomBarPrefix('main', 'KWE01', 'EGI마감')); + + // SUS 제품 + $this->assertEquals('BS', $this->resolver->resolveBottomBarPrefix('main', 'KSS01', 'SUS마감')); + $this->assertEquals('BS', $this->resolver->resolveBottomBarPrefix('main', 'KQTS01', 'SUS마감')); + + // 철재 + $this->assertEquals('TS', $this->resolver->resolveBottomBarPrefix('main', 'KTE01', 'EGI마감')); + } + + public function test_bottom_bar_fixed_prefixes(): void + { + foreach (['KSS01', 'KSE01', 'KWE01', 'KTE01'] as $code) { + $this->assertEquals('LA', $this->resolver->resolveBottomBarPrefix('lbar', $code, 'EGI마감')); + $this->assertEquals('HH', $this->resolver->resolveBottomBarPrefix('reinforce', $code, 'EGI마감')); + $this->assertEquals('YY', $this->resolver->resolveBottomBarPrefix('extra', $code, 'SUS마감')); + } + } + + // ───────────────────────────────────────────────── + // 셔터박스 Prefix + // ───────────────────────────────────────────────── + + public function test_shutter_box_standard_prefixes(): void + { + $this->assertEquals('CF', $this->resolver->resolveShutterBoxPrefix('front', true)); + $this->assertEquals('CL', $this->resolver->resolveShutterBoxPrefix('lintel', true)); + $this->assertEquals('CP', $this->resolver->resolveShutterBoxPrefix('inspection', true)); + $this->assertEquals('CB', $this->resolver->resolveShutterBoxPrefix('rear_corner', true)); + $this->assertEquals('XX', $this->resolver->resolveShutterBoxPrefix('top_cover', true)); + $this->assertEquals('XX', $this->resolver->resolveShutterBoxPrefix('fin_cover', true)); + } + + public function test_shutter_box_nonstandard_all_xx(): void + { + foreach (['front', 'lintel', 'inspection', 'rear_corner', 'top_cover', 'fin_cover'] as $part) { + $this->assertEquals('XX', $this->resolver->resolveShutterBoxPrefix($part, false)); + } + } + + // ───────────────────────────────────────────────── + // 연기차단재 Prefix + // ───────────────────────────────────────────────── + + public function test_smoke_barrier_always_gi(): void + { + $this->assertEquals('GI', $this->resolver->resolveSmokeBarrierPrefix()); + } + + // ───────────────────────────────────────────────── + // lengthToCode 변환 + // ───────────────────────────────────────────────── + + /** + * @dataProvider lengthCodeProvider + */ + public function test_length_to_code(int $lengthMm, ?string $smokeCategory, ?string $expected): void + { + $this->assertSame($expected, PrefixResolver::lengthToCode($lengthMm, $smokeCategory)); + } + + public static function lengthCodeProvider(): array + { + return [ + '1219 → 12' => [1219, null, '12'], + '2438 → 24' => [2438, null, '24'], + '3000 → 30' => [3000, null, '30'], + '3500 → 35' => [3500, null, '35'], + '4000 → 40' => [4000, null, '40'], + '4150 → 41' => [4150, null, '41'], + '4200 → 42' => [4200, null, '42'], + '4300 → 43' => [4300, null, '43'], + 'smoke w50 3000 → 53' => [3000, 'w50', '53'], + 'smoke w50 4000 → 54' => [4000, 'w50', '54'], + 'smoke w80 3000 → 83' => [3000, 'w80', '83'], + 'smoke w80 4000 → 84' => [4000, 'w80', '84'], + 'unknown length → null' => [9999, null, null], + ]; + } + + // ───────────────────────────────────────────────── + // buildItemCode + // ───────────────────────────────────────────────── + + public function test_build_item_code(): void + { + $this->assertEquals('BD-RS-43', $this->resolver->buildItemCode('RS', 4300)); + $this->assertEquals('BD-RM-30', $this->resolver->buildItemCode('RM', 3000)); + $this->assertEquals('BD-GI-53', $this->resolver->buildItemCode('GI', 3000, 'w50')); + $this->assertEquals('BD-GI-84', $this->resolver->buildItemCode('GI', 4000, 'w80')); + } + + public function test_build_item_code_invalid_length_returns_null(): void + { + $this->assertNull($this->resolver->buildItemCode('RS', 9999)); + } + + // ───────────────────────────────────────────────── + // partTypeName + // ───────────────────────────────────────────────── + + public function test_part_type_name(): void + { + $this->assertEquals('마감재', PrefixResolver::partTypeName('finish')); + $this->assertEquals('본체', PrefixResolver::partTypeName('body')); + $this->assertEquals('C형', PrefixResolver::partTypeName('c_type')); + $this->assertEquals('D형', PrefixResolver::partTypeName('d_type')); + $this->assertEquals('별도마감', PrefixResolver::partTypeName('extra_finish')); + $this->assertEquals('하부BASE', PrefixResolver::partTypeName('base')); + $this->assertEquals('L-Bar', PrefixResolver::partTypeName('lbar')); + $this->assertEquals('보강평철', PrefixResolver::partTypeName('reinforce')); + $this->assertEquals('전면부', PrefixResolver::partTypeName('front')); + $this->assertEquals('unknown_type', PrefixResolver::partTypeName('unknown_type')); + } + + // ───────────────────────────────────────────────── + // 전체 조합 커버리지 (productCode × guideType × partType) + // ───────────────────────────────────────────────── + + public function test_all_product_code_guide_type_combinations_produce_non_empty_prefix(): void + { + $productCodes = ['KSS01', 'KSE01', 'KWE01', 'KTE01', 'KQTS01']; + $guideTypes = ['wall', 'side']; + $partTypes = ['finish', 'body', 'c_type', 'd_type', 'base']; + + foreach ($productCodes as $code) { + foreach ($guideTypes as $guide) { + foreach ($partTypes as $part) { + $prefix = $this->resolver->resolveGuideRailPrefix($part, $guide, $code); + $this->assertNotEmpty( + $prefix, + "Empty prefix for {$code}/{$guide}/{$part}" + ); + $this->assertMatchesRegularExpression( + '/^[A-Z]{2}$/', + $prefix, + "Invalid prefix '{$prefix}' for {$code}/{$guide}/{$part}" + ); + } + } + } + } +} From b00fa0502a06c577c9a85affedcd2ea5126eb5c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Sun, 22 Feb 2026 03:04:42 +0900 Subject: [PATCH 13/26] =?UTF-8?q?fix:=20=EA=B2=AC=EC=A0=81=E2=86=92?= =?UTF-8?q?=EC=88=98=EC=A3=BC=20=EB=B3=80=ED=99=98=20=EC=8B=9C=20=EB=A0=88?= =?UTF-8?q?=EA=B1=B0=EC=8B=9C=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EA=B0=9C?= =?UTF-8?q?=EC=86=8C=20=EB=B6=84=EB=B0=B0=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - formula_source 없는 레거시 견적에서 sort_order 기반 개소 분배 로직 추가 - resolveLocationMapping/resolveLocationIndex 실패 시 index÷itemsPerLocation 폴백 - 기존 formula_source 매칭 로직은 그대로 유지 Co-Authored-By: Claude Opus 4.6 --- app/Services/Quote/QuoteService.php | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/app/Services/Quote/QuoteService.php b/app/Services/Quote/QuoteService.php index cc71695..34e05e0 100644 --- a/app/Services/Quote/QuoteService.php +++ b/app/Services/Quote/QuoteService.php @@ -689,11 +689,23 @@ public function convertToOrder(int $id): Quote } // 수주 상세 품목 생성 (노드 연결 포함) + // 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; + $serialIndex = 1; - foreach ($quote->items as $quoteItem) { + foreach ($quote->items as $index => $quoteItem) { $productMapping = $this->resolveLocationMapping($quoteItem, $productItems); $locIdx = $this->resolveLocationIndex($quoteItem, $productItems); + // sort_order 기반 분배 (formula_source/note 매칭 모두 실패 시) + if ($locIdx === 0 && $itemsPerLocation > 0) { + $locIdx = min(intdiv($index, $itemsPerLocation), $locationCount - 1); + } + $productMapping['order_node_id'] = isset($nodeMap[$locIdx]) ? $nodeMap[$locIdx]->id : null; $orderItem = OrderItem::createFromQuoteItem($quoteItem, $order->id, $serialIndex, $productMapping); From 855e806e42354c244dde1f9af0fa42177cf5dbfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Sun, 22 Feb 2026 03:04:47 +0900 Subject: [PATCH 14/26] =?UTF-8?q?refactor:=20=EC=A0=88=EA=B3=A1=20?= =?UTF-8?q?=EC=9E=AC=EA=B3=A0=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EC=BB=A4=EB=A7=A8=EB=93=9C=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81=20=EB=B0=8F=20=EA=B2=80=EC=A6=9D/?= =?UTF-8?q?=EC=8B=9C=EB=8D=94=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migrate5130BendingStock: BD-* 품목 초기 재고 셋팅으로 목적 변경, --min-stock 옵션 추가 - ValidateBendingItems: BD-* 품목 존재 여부 검증 커맨드 신규 - BendingItemSeeder: 경동 절곡 품목 시딩 신규 Co-Authored-By: Claude Opus 4.6 --- .../Commands/Migrate5130BendingStock.php | 850 ++++++++---------- app/Console/Commands/ValidateBendingItems.php | 137 +++ .../seeders/Kyungdong/BendingItemSeeder.php | 151 ++++ 3 files changed, 642 insertions(+), 496 deletions(-) create mode 100644 app/Console/Commands/ValidateBendingItems.php create mode 100644 database/seeders/Kyungdong/BendingItemSeeder.php diff --git a/app/Console/Commands/Migrate5130BendingStock.php b/app/Console/Commands/Migrate5130BendingStock.php index 9cd10ce..280c889 100644 --- a/app/Console/Commands/Migrate5130BendingStock.php +++ b/app/Console/Commands/Migrate5130BendingStock.php @@ -4,16 +4,16 @@ use Illuminate\Console\Attributes\AsCommand; use Illuminate\Console\Command; -use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; -#[AsCommand(name: 'migrate:5130-bending-stock', description: '5130 레거시 절곡품 재고(lot, bending_work_log)를 SAM stocks/stock_lots/stock_transactions로 마이그레이션')] +#[AsCommand(name: 'migrate:5130-bending-stock', description: '5130 레거시 절곡품 코드 생성 + BD-* 전체 품목 초기 재고 셋팅')] class Migrate5130BendingStock extends Command { protected $signature = 'migrate:5130-bending-stock {--tenant_id=287 : Target tenant ID (default: 287 경동기업)} {--dry-run : 실제 저장 없이 시뮬레이션만 수행} - {--rollback : 마이그레이션 롤백 (migration 소스 데이터 삭제)}'; + {--min-stock=100 : 품목별 초기 재고 수량 (기본: 100)} + {--rollback : 초기 재고 셋팅 롤백 (init_stock 소스 데이터 삭제)}'; private string $sourceDb = 'chandj'; @@ -21,57 +21,35 @@ class Migrate5130BendingStock extends Command // 5130 prod 코드 → 한글명 private array $prodNames = [ - 'R' => '가이드레일(벽면)', - 'S' => '가이드레일(측면)', - 'G' => '연기차단재', - 'B' => '하단마감재(스크린)', - 'T' => '하단마감재(철재)', - 'L' => 'L-Bar', - 'C' => '케이스', + 'R' => '가이드레일(벽면)', 'S' => '가이드레일(측면)', + 'G' => '연기차단재', 'B' => '하단마감재(스크린)', + 'T' => '하단마감재(철재)', 'L' => 'L-Bar', 'C' => '케이스', ]; // 5130 spec 코드 → 한글명 private array $specNames = [ - 'I' => '화이바원단', - 'S' => 'SUS', - 'U' => 'SUS2', - 'E' => 'EGI', - 'A' => '스크린용', - 'D' => 'D형', - 'C' => 'C형', - 'M' => '본체', - 'T' => '본체(철재)', - 'B' => '후면코너부', - 'L' => '린텔부', - 'P' => '점검구', - 'F' => '전면부', + 'I' => '화이바원단', 'S' => 'SUS', 'U' => 'SUS2', 'E' => 'EGI', + 'A' => '스크린용', 'D' => 'D형', 'C' => 'C형', 'M' => '본체', + 'T' => '본체(철재)', 'B' => '후면코너부', 'L' => '린텔부', + 'P' => '점검구', 'F' => '전면부', ]; // 5130 slength 코드 → 한글명 private array $slengthNames = [ - '53' => 'W50×3000', - '54' => 'W50×4000', - '83' => 'W80×3000', - '84' => 'W80×4000', - '12' => '1219mm', - '24' => '2438mm', - '30' => '3000mm', - '35' => '3500mm', - '40' => '4000mm', - '41' => '4150mm', - '42' => '4200mm', - '43' => '4300mm', + '53' => 'W50×3000', '54' => 'W50×4000', '83' => 'W80×3000', + '84' => 'W80×4000', '12' => '1219mm', '24' => '2438mm', + '30' => '3000mm', '35' => '3500mm', '40' => '4000mm', + '41' => '4150mm', '42' => '4200mm', '43' => '4300mm', ]; - // 통계 private array $stats = [ - 'items_updated' => 0, - 'items_created' => 0, + 'items_found' => 0, + 'items_created_5130' => 0, + 'items_category_updated' => 0, 'stocks_created' => 0, + 'stocks_skipped' => 0, 'lots_created' => 0, - 'transactions_in' => 0, - 'transactions_out' => 0, - 'skipped_lots' => 0, + 'transactions_created' => 0, ]; public function handle(): int @@ -79,275 +57,214 @@ public function handle(): int $tenantId = (int) $this->option('tenant_id'); $dryRun = $this->option('dry-run'); $rollback = $this->option('rollback'); + $minStock = (int) $this->option('min-stock'); - $this->info('=== 5130 → SAM 절곡품 재고 마이그레이션 ==='); + $this->info('=== BD-* 절곡품 초기 재고 셋팅 ==='); $this->info("Tenant ID: {$tenantId}"); $this->info('Mode: '.($dryRun ? 'DRY-RUN (시뮬레이션)' : 'LIVE')); + $this->info("초기 재고: {$minStock}개/품목"); $this->newLine(); if ($rollback) { - return $this->rollbackMigration($tenantId, $dryRun); + return $this->rollbackInitStock($tenantId, $dryRun); } - // 1. 5130 데이터 읽기 - $this->info('📥 Step 1: 5130 레거시 데이터 읽기...'); - $lots = $this->readLegacyLots(); - $workLogs = $this->readLegacyWorkLogs(); - $this->info(" - lot 레코드: {$lots->count()}건"); - $this->info(" - bending_work_log 레코드: {$workLogs->count()}건"); + // 0. 5130 레거시 데이터에서 BD-{PROD}{SPEC}-{SLENGTH} 아이템 생성 + $this->info('📥 Step 0: 5130 레거시 코드 → BD 아이템 생성...'); + $this->createLegacyItems($tenantId, $dryRun); $this->newLine(); - if ($lots->isEmpty()) { - $this->warn('5130 lot 데이터가 없습니다. 마이그레이션을 종료합니다.'); + // 1. 전체 BD-* 아이템 조회 (기존 58개 + 5130 생성분) + $this->info('📥 Step 1: BD-* 절곡품 품목 조회...'); + $items = DB::connection($this->targetDb) + ->table('items') + ->where('tenant_id', $tenantId) + ->where('code', 'like', 'BD-%') + ->whereNull('deleted_at') + ->select('id', 'code', 'name', 'item_type', 'item_category', 'unit', 'options') + ->orderBy('code') + ->get(); + + $this->stats['items_found'] = $items->count(); + $this->info(" - BD-* 품목: {$items->count()}건"); + + if ($items->isEmpty()) { + $this->warn('BD-* 품목이 없습니다. 종료합니다.'); return self::SUCCESS; } - // 2. 재고 요약 계산 - $this->info('📊 Step 2: 재고 현황 계산...'); - $stockSummary = $this->calculateStockSummary($lots, $workLogs); - $this->showSummary($stockSummary); + // 2. item_category 미설정 품목 업데이트 $this->newLine(); + $this->info('🏷️ Step 2: item_category 업데이트...'); + $needsCategoryUpdate = $items->filter(fn ($item) => $item->item_category !== 'BENDING'); - // 3. 기존 BD- 아이템 item_category 업데이트 현황 - $this->info('🏷️ Step 3: 기존 BD- 아이템 카테고리 업데이트...'); - $this->updateExistingBdItems($tenantId, $dryRun); + if ($needsCategoryUpdate->isNotEmpty()) { + $this->info(" - item_category 미설정/불일치: {$needsCategoryUpdate->count()}건"); + if (! $dryRun) { + DB::connection($this->targetDb) + ->table('items') + ->where('tenant_id', $tenantId) + ->where('code', 'like', 'BD-%') + ->whereNull('deleted_at') + ->where(function ($q) { + $q->whereNull('item_category') + ->orWhere('item_category', '!=', 'BENDING'); + }) + ->update(['item_category' => 'BENDING', 'updated_at' => now()]); + } + $this->stats['items_category_updated'] = $needsCategoryUpdate->count(); + } else { + $this->info(' - 모든 품목 BENDING 카테고리 설정 완료'); + } + + // 3. 현재 재고 현황 표시 $this->newLine(); + $this->info('📊 Step 3: 현재 재고 현황...'); + $this->showCurrentStockStatus($tenantId, $items); + + // 4. 재고 셋팅 대상 확인 + $this->newLine(); + $this->info('📦 Step 4: 재고 셋팅 대상 확인...'); + $itemsNeedingStock = $this->getItemsNeedingStock($tenantId, $items, $minStock); + + if ($itemsNeedingStock->isEmpty()) { + $this->info(" - 모든 품목이 이미 {$minStock}개 이상 재고 보유. 추가 작업 불필요."); + $this->showStats(); + + return self::SUCCESS; + } + + $this->info(" - 재고 셋팅 필요: {$itemsNeedingStock->count()}건"); + $this->table( + ['코드', '품목명', '현재고', '목표', '추가수량'], + $itemsNeedingStock->map(fn ($item) => [ + $item->code, + mb_strlen($item->name) > 30 ? mb_substr($item->name, 0, 30).'...' : $item->name, + number_format($item->current_qty), + number_format($minStock), + number_format($item->supplement_qty), + ])->toArray() + ); if ($dryRun) { + $this->stats['stocks_created'] = $itemsNeedingStock->filter(fn ($i) => ! $i->has_stock)->count(); + $this->stats['lots_created'] = $itemsNeedingStock->count(); + $this->stats['transactions_created'] = $itemsNeedingStock->count(); $this->showStats(); $this->info('🔍 DRY RUN 완료. 실제 실행은 --dry-run 플래그를 제거하세요.'); return self::SUCCESS; } - if (! $this->confirm('마이그레이션을 진행하시겠습니까?')) { + if (! $this->confirm('초기 재고를 셋팅하시겠습니까?')) { $this->info('취소되었습니다.'); return self::SUCCESS; } - // 4. 실행 - $this->info('🚀 Step 4: 마이그레이션 실행...'); - DB::connection($this->targetDb)->transaction(function () use ($tenantId, $lots, $workLogs, $stockSummary) { - $this->executeMigration($tenantId, $lots, $workLogs, $stockSummary); + // 5. 실행 + $this->newLine(); + $this->info('🚀 Step 5: 초기 재고 셋팅 실행...'); + DB::connection($this->targetDb)->transaction(function () use ($tenantId, $itemsNeedingStock, $minStock) { + $this->executeStockSetup($tenantId, $itemsNeedingStock, $minStock); }); $this->newLine(); $this->showStats(); - $this->info('✅ 마이그레이션 완료!'); + $this->info('✅ 초기 재고 셋팅 완료!'); return self::SUCCESS; } /** - * 5130 lot 테이블 데이터 읽기 + * 현재 재고 현황 표시 */ - private function readLegacyLots(): \Illuminate\Support\Collection + private function showCurrentStockStatus(int $tenantId, \Illuminate\Support\Collection $items): void { - return DB::connection($this->sourceDb) - ->table('lot') - ->where('is_deleted', 0) - ->whereNotNull('prod') - ->where('prod', '!=', '') - ->whereNotNull('surang') - ->where('surang', '>', 0) - ->select('num', 'reg_date', 'lot_number', 'prod', 'spec', 'slength', 'surang', 'rawLot', 'author', 'remark') - ->orderBy('reg_date') - ->orderBy('num') - ->get(); - } + $itemIds = $items->pluck('id'); - /** - * 5130 bending_work_log 테이블 데이터 읽기 - */ - private function readLegacyWorkLogs(): \Illuminate\Support\Collection - { - return DB::connection($this->sourceDb) - ->table('bending_work_log') - ->where('is_deleted', 0) - ->whereNotNull('prod_code') - ->where('prod_code', '!=', '') - ->where('quantity', '>', 0) - ->select('id', 'work_date', 'work_order_no', 'prod_code', 'spec_code', 'slength_code', 'quantity', 'unit', 'work_type', 'worker', 'remark', 'created_at') - ->orderBy('work_date') - ->orderBy('id') - ->get(); - } - - /** - * prod+spec+slength 기준 재고 요약 계산 - */ - private function calculateStockSummary(\Illuminate\Support\Collection $lots, \Illuminate\Support\Collection $workLogs): array - { - $summary = []; - - // 입고(lot) 합산 - foreach ($lots as $lot) { - $key = $this->makeProductKey($lot->prod, $lot->spec, $lot->slength); - if (! isset($summary[$key])) { - $summary[$key] = [ - 'prod' => $lot->prod, - 'spec' => $lot->spec, - 'slength' => $lot->slength, - 'total_in' => 0, - 'total_out' => 0, - 'lot_count' => 0, - ]; - } - $summary[$key]['total_in'] += (float) $lot->surang; - $summary[$key]['lot_count']++; - } - - // 출고(bending_work_log) 합산 - foreach ($workLogs as $log) { - $key = $this->makeProductKey($log->prod_code, $log->spec_code, $log->slength_code); - if (! isset($summary[$key])) { - $summary[$key] = [ - 'prod' => $log->prod_code, - 'spec' => $log->spec_code, - 'slength' => $log->slength_code, - 'total_in' => 0, - 'total_out' => 0, - 'lot_count' => 0, - ]; - } - $summary[$key]['total_out'] += (float) $log->quantity; - } - - return $summary; - } - - /** - * 재고 요약 표시 - */ - private function showSummary(array $summary): void - { - $headers = ['코드', '품목명', '입고합계', '출고합계', '현재고', 'LOT수']; - $rows = []; - - foreach ($summary as $key => $item) { - $netStock = $item['total_in'] - $item['total_out']; - $rows[] = [ - $key, - $this->makeItemName($item['prod'], $item['spec'], $item['slength']), - number_format($item['total_in']), - number_format($item['total_out']), - number_format($netStock), - $item['lot_count'], - ]; - } - - $this->table($headers, $rows); - $this->info(' - 품목 종류: '.count($summary).'개'); - } - - /** - * 기존 BD- 아이템의 item_category를 BENDING으로 업데이트 - */ - private function updateExistingBdItems(int $tenantId, bool $dryRun): void - { - $count = DB::connection($this->targetDb) - ->table('items') + $stocks = DB::connection($this->targetDb) + ->table('stocks') ->where('tenant_id', $tenantId) - ->where('code', 'like', 'BD-%') + ->whereIn('item_id', $itemIds) ->whereNull('deleted_at') - ->where(function ($q) { - $q->whereNull('item_category') - ->orWhere('item_category', '!=', 'BENDING'); - }) - ->count(); + ->get() + ->keyBy('item_id'); - $this->info(" - BD- 아이템 중 item_category 미설정: {$count}건"); + $hasStock = 0; + $noStock = 0; - if ($count > 0 && ! $dryRun) { - DB::connection($this->targetDb) - ->table('items') - ->where('tenant_id', $tenantId) - ->where('code', 'like', 'BD-%') - ->whereNull('deleted_at') - ->where(function ($q) { - $q->whereNull('item_category') - ->orWhere('item_category', '!=', 'BENDING'); - }) - ->update(['item_category' => 'BENDING', 'updated_at' => now()]); - - $this->info(" ✅ {$count}건 업데이트 완료"); + foreach ($items as $item) { + $stock = $stocks->get($item->id); + if ($stock && (float) $stock->stock_qty > 0) { + $hasStock++; + } else { + $noStock++; + } } - $this->stats['items_updated'] = $count; + $this->info(" - 재고 있음: {$hasStock}건"); + $this->info(" - 재고 없음: {$noStock}건"); } /** - * 마이그레이션 실행 + * 재고 셋팅이 필요한 품목 목록 조회 */ - private function executeMigration(int $tenantId, \Illuminate\Support\Collection $lots, \Illuminate\Support\Collection $workLogs, array $stockSummary): void + private function getItemsNeedingStock(int $tenantId, \Illuminate\Support\Collection $items, int $minStock): \Illuminate\Support\Collection { - $itemMap = []; // productKey => item_id - $stockMap = []; // productKey => stock_id - $lotMap = []; // productKey => [stock_lot_ids] + $itemIds = $items->pluck('id'); - // 1. 각 제품 조합별 아이템 & 스톡 생성 - $this->info(' 📦 아이템 & 재고 레코드 생성...'); - foreach ($stockSummary as $key => $data) { - $itemCode = $this->makeItemCode($data['prod'], $data['spec'], $data['slength']); - $itemName = $this->makeItemName($data['prod'], $data['spec'], $data['slength']); + $stocks = DB::connection($this->targetDb) + ->table('stocks') + ->where('tenant_id', $tenantId) + ->whereIn('item_id', $itemIds) + ->whereNull('deleted_at') + ->get() + ->keyBy('item_id'); - // 아이템 조회 또는 생성 - $item = DB::connection($this->targetDb) - ->table('items') - ->where('tenant_id', $tenantId) - ->where('code', $itemCode) - ->whereNull('deleted_at') - ->first(); + $result = collect(); - if (! $item) { - $itemId = DB::connection($this->targetDb)->table('items')->insertGetId([ - 'tenant_id' => $tenantId, - 'code' => $itemCode, - 'name' => $itemName, - 'item_type' => 'PT', - 'item_category' => 'BENDING', - 'unit' => 'EA', - 'source' => '5130_migration', - 'options' => json_encode([ - 'lot_managed' => true, - 'consumption_method' => 'auto', - 'production_source' => 'self_produced', - 'input_tracking' => true, - 'legacy_prod' => $data['prod'], - 'legacy_spec' => $data['spec'], - 'legacy_slength' => $data['slength'], - ]), - 'is_active' => true, - 'created_at' => now(), - 'updated_at' => now(), - ]); - $this->stats['items_created']++; - $this->line(" + 아이템 생성: {$itemCode} ({$itemName})"); - } else { - $itemId = $item->id; - $this->line(" ✓ 기존 아이템 사용: {$itemCode}"); + foreach ($items as $item) { + $stock = $stocks->get($item->id); + $currentQty = $stock ? (float) $stock->stock_qty : 0; + + if ($currentQty >= $minStock) { + $this->stats['stocks_skipped']++; + + continue; } - $itemMap[$key] = $itemId; + $supplementQty = $minStock - $currentQty; - // Stock 레코드 조회 또는 생성 - $stock = DB::connection($this->targetDb) - ->table('stocks') - ->where('tenant_id', $tenantId) - ->where('item_id', $itemId) - ->whereNull('deleted_at') - ->first(); + $item->has_stock = (bool) $stock; + $item->stock_id = $stock?->id; + $item->current_qty = $currentQty; + $item->supplement_qty = $supplementQty; - if (! $stock) { + $result->push($item); + } + + return $result; + } + + /** + * 초기 재고 셋팅 실행 + */ + private function executeStockSetup(int $tenantId, \Illuminate\Support\Collection $items, int $minStock): void + { + foreach ($items as $item) { + $stockId = $item->stock_id; + + // Stock 레코드가 없으면 생성 + if (! $item->has_stock) { $stockId = DB::connection($this->targetDb)->table('stocks')->insertGetId([ 'tenant_id' => $tenantId, - 'item_id' => $itemId, - 'item_code' => $itemCode, - 'item_name' => $itemName, + 'item_id' => $item->id, + 'item_code' => $item->code, + 'item_name' => $item->name, 'item_type' => 'bent_part', - 'unit' => 'EA', + 'unit' => $item->unit ?? 'EA', 'stock_qty' => 0, 'safety_stock' => 0, 'reserved_qty' => 0, @@ -358,47 +275,18 @@ private function executeMigration(int $tenantId, \Illuminate\Support\Collection 'updated_at' => now(), ]); $this->stats['stocks_created']++; - } else { - $stockId = $stock->id; + $this->line(" + Stock 생성: {$item->code}"); } - $stockMap[$key] = $stockId; - } - - // 2. LOT 데이터 마이그레이션 (입고) - $this->newLine(); - $this->info(' 📋 LOT 데이터 마이그레이션 (입고)...'); - $fifoCounters = []; // stockId => current fifo_order - - foreach ($lots as $lot) { - $key = $this->makeProductKey($lot->prod, $lot->spec, $lot->slength); - - if (! isset($stockMap[$key])) { - $this->stats['skipped_lots']++; - - continue; - } - - $stockId = $stockMap[$key]; - $itemCode = $this->makeItemCode($lot->prod, $lot->spec, $lot->slength); - $itemName = $this->makeItemName($lot->prod, $lot->spec, $lot->slength); - // FIFO 순서 계산 - if (! isset($fifoCounters[$stockId])) { - $existing = DB::connection($this->targetDb) - ->table('stock_lots') - ->where('stock_id', $stockId) - ->max('fifo_order'); - $fifoCounters[$stockId] = ($existing ?? 0); - } - $fifoCounters[$stockId]++; + $maxFifo = DB::connection($this->targetDb) + ->table('stock_lots') + ->where('stock_id', $stockId) + ->max('fifo_order'); + $nextFifo = ($maxFifo ?? 0) + 1; - // LOT 번호 생성 (5130 lot_number 사용, 없으면 생성) - $lotNo = $lot->lot_number; - if (empty($lotNo)) { - $regDate = $lot->reg_date ? Carbon::parse($lot->reg_date)->format('ymd') : Carbon::now()->format('ymd'); - $lotNo = "5130-{$regDate}-{$lot->num}"; - } + // LOT 번호 생성 + $lotNo = 'INIT-'.now()->format('ymd').'-'.str_replace(['-', ' ', '*'], ['', '', 'x'], $item->code); // 중복 체크 $existingLot = DB::connection($this->targetDb) @@ -410,183 +298,54 @@ private function executeMigration(int $tenantId, \Illuminate\Support\Collection ->first(); if ($existingLot) { - $this->stats['skipped_lots']++; + $this->warn(" ⚠️ 이미 LOT 존재 (skip): {$lotNo}"); continue; } - $qty = (float) $lot->surang; - $receiptDate = $lot->reg_date ?? now()->toDateString(); + $supplementQty = $item->supplement_qty; + // StockLot 생성 $stockLotId = DB::connection($this->targetDb)->table('stock_lots')->insertGetId([ 'tenant_id' => $tenantId, 'stock_id' => $stockId, 'lot_no' => $lotNo, - 'fifo_order' => $fifoCounters[$stockId], - 'receipt_date' => $receiptDate, - 'qty' => $qty, + 'fifo_order' => $nextFifo, + 'receipt_date' => now()->toDateString(), + 'qty' => $supplementQty, 'reserved_qty' => 0, - 'available_qty' => $qty, - 'unit' => 'EA', - 'supplier' => null, - 'supplier_lot' => $lot->rawLot ?: null, - 'location' => null, + 'available_qty' => $supplementQty, + 'unit' => $item->unit ?? 'EA', 'status' => 'available', - 'receiving_id' => null, - 'work_order_id' => null, 'created_at' => now(), 'updated_at' => now(), ]); + $this->stats['lots_created']++; - // 입고 트랜잭션 기록 - // balance_qty는 나중에 refreshFromLots 후 업데이트되므로 임시로 qty 사용 + // StockTransaction 생성 DB::connection($this->targetDb)->table('stock_transactions')->insert([ 'tenant_id' => $tenantId, 'stock_id' => $stockId, 'stock_lot_id' => $stockLotId, 'type' => 'IN', - 'qty' => $qty, - 'balance_qty' => 0, // 나중에 갱신 - 'reference_type' => 'migration', - 'reference_id' => $lot->num, + 'qty' => $supplementQty, + 'balance_qty' => 0, + 'reference_type' => 'init_stock', + 'reference_id' => 0, 'lot_no' => $lotNo, 'reason' => 'receiving', - 'remark' => '5130 레거시 마이그레이션 (lot.num='.$lot->num.')', - 'item_code' => $itemCode, - 'item_name' => $itemName, - 'created_at' => $receiptDate, + 'remark' => "절곡품 초기 재고 셋팅 (min-stock={$minStock})", + 'item_code' => $item->code, + 'item_name' => $item->name, + 'created_at' => now(), ]); + $this->stats['transactions_created']++; - $this->stats['lots_created']++; - $this->stats['transactions_in']++; - - if (! isset($lotMap[$key])) { - $lotMap[$key] = []; - } - $lotMap[$key][] = $stockLotId; - } - - $this->info(" ✅ LOT {$this->stats['lots_created']}건 생성"); - - // 3. 출고 로그 마이그레이션 (bending_work_log → stock_transactions OUT) - $this->newLine(); - $this->info(' 📋 출고 로그 마이그레이션...'); - - foreach ($workLogs as $log) { - $key = $this->makeProductKey($log->prod_code, $log->spec_code, $log->slength_code); - - if (! isset($stockMap[$key])) { - continue; - } - - $stockId = $stockMap[$key]; - $itemCode = $this->makeItemCode($log->prod_code, $log->spec_code, $log->slength_code); - $itemName = $this->makeItemName($log->prod_code, $log->spec_code, $log->slength_code); - $qty = (float) $log->quantity; - $workDate = $log->work_date ?? ($log->created_at ?? now()->toDateString()); - - DB::connection($this->targetDb)->table('stock_transactions')->insert([ - 'tenant_id' => $tenantId, - 'stock_id' => $stockId, - 'stock_lot_id' => null, // 레거시 데이터는 특정 LOT 추적 불가 - 'type' => 'OUT', - 'qty' => -$qty, - 'balance_qty' => 0, // 나중에 갱신 - 'reference_type' => 'migration', - 'reference_id' => $log->id, - 'lot_no' => null, - 'reason' => 'work_order_input', - 'remark' => '5130 레거시 마이그레이션 (bending_work_log.id='.$log->id.', worker='.$log->worker.')', - 'item_code' => $itemCode, - 'item_name' => $itemName, - 'created_at' => $workDate, - ]); - - $this->stats['transactions_out']++; - } - - $this->info(" ✅ 출고 트랜잭션 {$this->stats['transactions_out']}건 생성"); - - // 4. LOT 수량 조정 (출고분 차감) - $this->newLine(); - $this->info(' 🔄 LOT 수량 조정 (출고분 FIFO 차감)...'); - - foreach ($stockSummary as $key => $data) { - if (! isset($stockMap[$key])) { - continue; - } - - $stockId = $stockMap[$key]; - $totalOut = $data['total_out']; - - if ($totalOut <= 0) { - continue; - } - - // FIFO 순서로 LOT에서 차감 - $remainingOut = $totalOut; - $stockLots = DB::connection($this->targetDb) - ->table('stock_lots') - ->where('stock_id', $stockId) - ->where('tenant_id', $tenantId) - ->whereNull('deleted_at') - ->orderBy('fifo_order') - ->get(); - - foreach ($stockLots as $sl) { - if ($remainingOut <= 0) { - break; - } - - $lotQty = (float) $sl->qty; - $deduct = min($lotQty, $remainingOut); - $newQty = $lotQty - $deduct; - $remainingOut -= $deduct; - - $status = $newQty <= 0 ? 'used' : 'available'; - DB::connection($this->targetDb) - ->table('stock_lots') - ->where('id', $sl->id) - ->update([ - 'qty' => max(0, $newQty), - 'available_qty' => max(0, $newQty), - 'status' => $status, - 'updated_at' => now(), - ]); - } - - if ($remainingOut > 0) { - $this->warn(" ⚠️ {$key}: 출고량이 입고량보다 {$remainingOut}만큼 초과 (데이터 불일치)"); - } - } - - // 5. Stock 집계 갱신 - $this->newLine(); - $this->info(' 📊 Stock 집계 갱신...'); - - foreach ($stockMap as $key => $stockId) { + // Stock 집계 갱신 $this->refreshStockFromLots($stockId, $tenantId); + + $this->line(" ✅ {$item->code}: 0 → {$supplementQty} (+{$supplementQty})"); } - - // 6. balance_qty 갱신 (트랜잭션별 잔량) - $this->info(' 📊 트랜잭션 잔량(balance_qty) 갱신...'); - - foreach ($stockMap as $key => $stockId) { - $stock = DB::connection($this->targetDb) - ->table('stocks') - ->where('id', $stockId) - ->first(); - - if ($stock) { - DB::connection($this->targetDb) - ->table('stock_transactions') - ->where('stock_id', $stockId) - ->where('tenant_id', $tenantId) - ->update(['balance_qty' => $stock->stock_qty]); - } - } - - $this->info(' ✅ 집계 갱신 완료'); } /** @@ -610,54 +369,57 @@ private function refreshStockFromLots(int $stockId, int $tenantId): void ->first(); $stockQty = (float) $lotStats->total_qty; - $reservedQty = (float) $lotStats->total_reserved; - $availableQty = (float) $lotStats->total_available; - - $status = 'normal'; - if ($stockQty <= 0) { - $status = 'out'; - } DB::connection($this->targetDb) ->table('stocks') ->where('id', $stockId) ->update([ 'stock_qty' => $stockQty, - 'reserved_qty' => $reservedQty, - 'available_qty' => $availableQty, + 'reserved_qty' => (float) $lotStats->total_reserved, + 'available_qty' => (float) $lotStats->total_available, 'lot_count' => (int) $lotStats->lot_count, 'oldest_lot_date' => $lotStats->oldest_lot_date, 'last_receipt_date' => $lotStats->latest_receipt_date, - 'status' => $status, + 'status' => $stockQty > 0 ? 'normal' : 'out', 'updated_at' => now(), ]); } /** - * 롤백: migration 참조 데이터 삭제 + * 롤백: init_stock 참조 데이터 삭제 */ - private function rollbackMigration(int $tenantId, bool $dryRun): int + private function rollbackInitStock(int $tenantId, bool $dryRun): int { - $this->warn('⚠️ 롤백: migration 소스 데이터를 삭제합니다.'); + $this->warn('⚠️ 롤백: 초기 재고 셋팅 데이터를 삭제합니다.'); - // 마이그레이션으로 생성된 트랜잭션 + // init_stock으로 생성된 트랜잭션 $txCount = DB::connection($this->targetDb) ->table('stock_transactions') ->where('tenant_id', $tenantId) - ->where('reference_type', 'migration') + ->where('reference_type', 'init_stock') ->count(); - // 마이그레이션으로 생성된 아이템 (source = '5130_migration') - $itemCount = DB::connection($this->targetDb) + // init_stock 트랜잭션에 연결된 LOT + $lotIds = DB::connection($this->targetDb) + ->table('stock_transactions') + ->where('tenant_id', $tenantId) + ->where('reference_type', 'init_stock') + ->whereNotNull('stock_lot_id') + ->pluck('stock_lot_id') + ->unique(); + + // 5130으로 생성된 아이템 + $legacyItemCount = DB::connection($this->targetDb) ->table('items') ->where('tenant_id', $tenantId) - ->where('source', '5130_migration') + ->where('options->source', '5130_migration') ->whereNull('deleted_at') ->count(); $this->info(' 삭제 대상:'); - $this->info(" - stock_transactions (reference_type=migration): {$txCount}건"); - $this->info(" - items (source=5130_migration): {$itemCount}건"); + $this->info(" - stock_transactions (reference_type=init_stock): {$txCount}건"); + $this->info(" - stock_lots (연결 LOT): {$lotIds->count()}건"); + $this->info(" - items (source=5130_migration): {$legacyItemCount}건"); if ($dryRun) { $this->info('DRY RUN - 실제 삭제 없음'); @@ -669,42 +431,62 @@ private function rollbackMigration(int $tenantId, bool $dryRun): int return self::SUCCESS; } - DB::connection($this->targetDb)->transaction(function () use ($tenantId) { + DB::connection($this->targetDb)->transaction(function () use ($tenantId, $lotIds) { // 1. 트랜잭션 삭제 DB::connection($this->targetDb) ->table('stock_transactions') ->where('tenant_id', $tenantId) - ->where('reference_type', 'migration') + ->where('reference_type', 'init_stock') ->delete(); - // 2. migration으로 생성된 아이템의 stock_lots 삭제 + // 2. LOT에서 stock_id 목록 수집 (집계 갱신용) + $affectedStockIds = collect(); + if ($lotIds->isNotEmpty()) { + $affectedStockIds = DB::connection($this->targetDb) + ->table('stock_lots') + ->whereIn('id', $lotIds) + ->pluck('stock_id') + ->unique(); + + // LOT 삭제 + DB::connection($this->targetDb) + ->table('stock_lots') + ->whereIn('id', $lotIds) + ->delete(); + } + + // 3. 영향받은 Stock 집계 갱신 + foreach ($affectedStockIds as $stockId) { + $this->refreshStockFromLots($stockId, $tenantId); + } + + // 4. 5130 migration으로 생성된 아이템 + 연결 stocks 삭제 $migrationItemIds = DB::connection($this->targetDb) ->table('items') ->where('tenant_id', $tenantId) - ->where('source', '5130_migration') + ->where('options->source', '5130_migration') ->whereNull('deleted_at') ->pluck('id'); if ($migrationItemIds->isNotEmpty()) { - $stockIds = DB::connection($this->targetDb) + $migrationStockIds = DB::connection($this->targetDb) ->table('stocks') ->where('tenant_id', $tenantId) ->whereIn('item_id', $migrationItemIds) ->pluck('id'); - if ($stockIds->isNotEmpty()) { + if ($migrationStockIds->isNotEmpty()) { DB::connection($this->targetDb) ->table('stock_lots') - ->whereIn('stock_id', $stockIds) + ->whereIn('stock_id', $migrationStockIds) ->delete(); DB::connection($this->targetDb) ->table('stocks') - ->whereIn('id', $stockIds) + ->whereIn('id', $migrationStockIds) ->delete(); } - // 3. 아이템 삭제 DB::connection($this->targetDb) ->table('items') ->whereIn('id', $migrationItemIds) @@ -718,38 +500,114 @@ private function rollbackMigration(int $tenantId, bool $dryRun): int } /** - * 5130 코드 → 제품 키 생성 + * 5130 레거시 데이터에서 BD-{PROD}{SPEC}-{SLENGTH} 아이템 생성 */ - private function makeProductKey(string $prod, ?string $spec, ?string $slength): string + private function createLegacyItems(int $tenantId, bool $dryRun): void { - return trim($prod).'-'.trim($spec ?? '').'-'.trim($slength ?? ''); - } + // 5130 lot 테이블에서 고유 prod+spec+slength 조합 추출 + $lots = DB::connection($this->sourceDb) + ->table('lot') + ->where(function ($q) { + $q->whereNull('is_deleted') + ->orWhere('is_deleted', 0); + }) + ->whereNotNull('prod') + ->where('prod', '!=', '') + ->whereNotNull('surang') + ->where('surang', '>', 0) + ->select('prod', 'spec', 'slength') + ->distinct() + ->get(); - /** - * 5130 코드 → SAM 아이템 코드 생성 - * 형식: BD-{PROD}{SPEC}-{SLENGTH} (예: BD-RS-40) - */ - private function makeItemCode(string $prod, ?string $spec, ?string $slength): string - { - $p = trim($prod); - $s = trim($spec ?? ''); - $l = trim($slength ?? ''); + // bending_work_log 테이블에서도 추출 (lot에 없는 조합 포함) + $workLogs = DB::connection($this->sourceDb) + ->table('bending_work_log') + ->where(function ($q) { + $q->whereNull('is_deleted') + ->orWhere('is_deleted', 0); + }) + ->whereNotNull('prod_code') + ->where('prod_code', '!=', '') + ->select('prod_code as prod', 'spec_code as spec', 'slength_code as slength') + ->distinct() + ->get(); - return "BD-{$p}{$s}-{$l}"; - } + $allRecords = $lots->merge($workLogs); - /** - * 5130 코드 → 사람이 읽을 수 있는 아이템명 생성 - */ - private function makeItemName(string $prod, ?string $spec, ?string $slength): string - { - $prodName = $this->prodNames[trim($prod)] ?? trim($prod); - $specName = $this->specNames[trim($spec ?? '')] ?? trim($spec ?? ''); - $slengthName = $this->slengthNames[trim($slength ?? '')] ?? trim($slength ?? ''); + if ($allRecords->isEmpty()) { + $this->info(' - 5130 데이터 없음'); - $parts = array_filter([$prodName, $specName, $slengthName]); + return; + } - return implode(' ', $parts); + // 고유 제품 조합 추출 + $uniqueProducts = []; + foreach ($allRecords as $row) { + $key = trim($row->prod).'-'.trim($row->spec ?? '').'-'.trim($row->slength ?? ''); + if (! isset($uniqueProducts[$key])) { + $uniqueProducts[$key] = [ + 'prod' => trim($row->prod), + 'spec' => trim($row->spec ?? ''), + 'slength' => trim($row->slength ?? ''), + ]; + } + } + + $this->info(" - 5130 고유 제품 조합: ".count($uniqueProducts).'개'); + + $created = 0; + $skipped = 0; + + foreach ($uniqueProducts as $data) { + $itemCode = "BD-{$data['prod']}{$data['spec']}-{$data['slength']}"; + $prodName = $this->prodNames[$data['prod']] ?? $data['prod']; + $specName = $this->specNames[$data['spec']] ?? $data['spec']; + $slengthName = $this->slengthNames[$data['slength']] ?? $data['slength']; + $itemName = implode(' ', array_filter([$prodName, $specName, $slengthName])); + + // 이미 존재하는지 확인 + $existing = DB::connection($this->targetDb) + ->table('items') + ->where('tenant_id', $tenantId) + ->where('code', $itemCode) + ->whereNull('deleted_at') + ->first(); + + if ($existing) { + $skipped++; + + continue; + } + + if (! $dryRun) { + DB::connection($this->targetDb)->table('items')->insert([ + 'tenant_id' => $tenantId, + 'code' => $itemCode, + 'name' => $itemName, + 'item_type' => 'PT', + 'item_category' => 'BENDING', + 'unit' => 'EA', + 'options' => json_encode([ + 'source' => '5130_migration', + 'lot_managed' => true, + 'consumption_method' => 'auto', + 'production_source' => 'self_produced', + 'input_tracking' => true, + 'legacy_prod' => $data['prod'], + 'legacy_spec' => $data['spec'], + 'legacy_slength' => $data['slength'], + ]), + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + $created++; + } + + $this->stats['items_created_5130'] = $created; + $this->info(" - 신규 생성: {$created}건, 기존 존재 (skip): {$skipped}건"); } /** @@ -759,15 +617,15 @@ private function showStats(): void { $this->newLine(); $this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - $this->info('📊 마이그레이션 통계'); + $this->info('📊 실행 통계'); $this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - $this->info(" 아이템 카테고리 업데이트: {$this->stats['items_updated']}건"); - $this->info(" 아이템 신규 생성: {$this->stats['items_created']}건"); + $this->info(" 5130 아이템 생성: {$this->stats['items_created_5130']}건"); + $this->info(" BD-* 품목 수 (전체): {$this->stats['items_found']}건"); + $this->info(" 카테고리 업데이트: {$this->stats['items_category_updated']}건"); $this->info(" Stock 레코드 생성: {$this->stats['stocks_created']}건"); + $this->info(" 기존 재고 충분 (skip): {$this->stats['stocks_skipped']}건"); $this->info(" StockLot 생성: {$this->stats['lots_created']}건"); - $this->info(" 입고 트랜잭션: {$this->stats['transactions_in']}건"); - $this->info(" 출고 트랜잭션: {$this->stats['transactions_out']}건"); - $this->info(" 스킵된 LOT: {$this->stats['skipped_lots']}건"); + $this->info(" 입고 트랜잭션: {$this->stats['transactions_created']}건"); $this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); } -} +} \ No newline at end of file diff --git a/app/Console/Commands/ValidateBendingItems.php b/app/Console/Commands/ValidateBendingItems.php new file mode 100644 index 0000000..3274c9f --- /dev/null +++ b/app/Console/Commands/ValidateBendingItems.php @@ -0,0 +1,137 @@ + $guideRailCodes, // 벽면 SUS 마감재 + 'RM' => ['24', '30', '35', '40', '42', '43'], // 벽면 본체 (EGI) + 'RC' => ['24', '30', '35', '40', '42', '43'], // 벽면 C형 + 'RD' => ['24', '30', '35', '40', '42', '43'], // 벽면 D형 + 'RT' => ['30', '43'], // 벽면 본체 (철재) + + // 가이드레일 측면형 + 'SS' => ['30', '35', '40'], // 측면 SUS 마감재 + 'SM' => ['24', '30', '35', '40', '43'], // 측면 본체 (EGI) + 'SC' => ['24', '30', '35', '40', '43'], // 측면 C형 + 'SD' => ['24', '30', '35', '40', '43'], // 측면 D형 + 'ST' => ['43'], // 측면 본체 (철재) + 'SU' => ['30', '35', '40', '43'], // 측면 SUS (SUS2) + + // 하단마감재 + 'BE' => $bottomBarCodes, // EGI 마감 + 'BS' => ['24', '30', '35', '40', '43'], // SUS 마감 + 'TS' => ['43'], // 철재 SUS + 'LA' => $bottomBarCodes, // L-Bar + + // 셔터박스 + 'CF' => $shutterBoxCodes, // 전면부 + 'CL' => $shutterBoxCodes, // 린텔부 + 'CP' => $shutterBoxCodes, // 점검구 + 'CB' => $shutterBoxCodes, // 후면코너부 + + // 연기차단재 + 'GI' => ['53', '54', '83', '84', '30', '35', '40'], // W50/W80 + 일반 + + // 공용/기타 + 'XX' => ['12', '24', '30', '35', '40', '41', '43'], // 하부BASE/셔터 상부/마구리 + 'YY' => ['30', '35', '40', '43'], // 별도 SUS 마감 + 'HH' => ['30', '40'], // 보강평철 + ]; + } + + public function handle(): int + { + $tenantId = (int) $this->option('tenant_id'); + + $this->info("=== BD-* 절곡 세부품목 마스터 검증 (tenant: {$tenantId}) ==="); + $this->newLine(); + + // DB에서 전체 BD-* 품목 조회 + $existingItems = DB::table('items') + ->where('tenant_id', $tenantId) + ->where('code', 'like', 'BD-%') + ->whereNull('deleted_at') + ->pluck('code') + ->toArray(); + + $existingSet = array_flip($existingItems); + + $this->info('현재 등록된 BD-* 품목: '.count($existingItems).'개'); + $this->newLine(); + + $prefixMap = $this->getPrefixLengthCodes(); + $totalExpected = 0; + $missing = []; + $found = 0; + + foreach ($prefixMap as $prefix => $codes) { + $prefixMissing = []; + foreach ($codes as $code) { + $itemCode = "BD-{$prefix}-{$code}"; + $totalExpected++; + + if (isset($existingSet[$itemCode])) { + $found++; + } else { + $prefixMissing[] = $itemCode; + $missing[] = $itemCode; + } + } + + $status = empty($prefixMissing) ? '✅' : '❌'; + $countStr = count($codes) - count($prefixMissing).'/'.count($codes); + $this->line(" {$status} BD-{$prefix}: {$countStr}"); + + if (! empty($prefixMissing)) { + foreach ($prefixMissing as $m) { + $this->line(" ⚠️ 누락: {$m}"); + } + } + } + + $this->newLine(); + $this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + $this->info("검증 결과: {$found}/{$totalExpected} 등록 완료"); + + if (empty($missing)) { + $this->info('✅ All items registered — 누락 0건'); + + return self::SUCCESS; + } + + $this->warn('❌ 누락 항목: '.count($missing).'건'); + $this->newLine(); + $this->table(['누락 품목코드'], array_map(fn ($m) => [$m], $missing)); + + return self::FAILURE; + } +} diff --git a/database/seeders/Kyungdong/BendingItemSeeder.php b/database/seeders/Kyungdong/BendingItemSeeder.php new file mode 100644 index 0000000..956738c --- /dev/null +++ b/database/seeders/Kyungdong/BendingItemSeeder.php @@ -0,0 +1,151 @@ + 1219, + '24' => 2438, + '30' => 3000, + '35' => 3500, + '40' => 4000, + '41' => 4150, + '42' => 4200, + '43' => 4300, + '53' => 3000, // 연기차단재50 전용 + '54' => 4000, // 연기차단재50 전용 + '83' => 3000, // 연기차단재80 전용 + '84' => 4000, // 연기차단재80 전용 + ]; + + /** + * 등록 대상 정의 + */ + private function getItemDefinitions(): array + { + return [ + // Phase 0.1 대상 + 'XX' => [ + 'name' => '하부BASE/셔터 상부/마구리', + 'lengthCodes' => ['12', '24', '30', '35', '40', '41', '43'], + ], + 'YY' => [ + 'name' => '별도SUS마감', + 'lengthCodes' => ['30', '35', '40', '43'], + ], + 'HH' => [ + 'name' => '보강평철', + 'lengthCodes' => ['30', '40'], + ], + // 추가 누락분 + 'RM' => [ + 'name' => '가이드레일(벽면) 본체', + 'lengthCodes' => ['42'], + ], + 'RC' => [ + 'name' => '가이드레일(벽면) C형', + 'lengthCodes' => ['42'], + ], + 'RD' => [ + 'name' => '가이드레일(벽면) D형', + 'lengthCodes' => ['42'], + ], + 'SM' => [ + 'name' => '가이드레일(측면) 본체', + 'lengthCodes' => ['24'], + ], + 'BS' => [ + 'name' => '하단마감재 SUS', + 'lengthCodes' => ['35', '43'], + ], + 'TS' => [ + 'name' => '하단마감재 철재SUS', + 'lengthCodes' => ['43'], + ], + 'GI' => [ + 'name' => '연기차단재', + 'lengthCodes' => ['54', '84'], + 'nameOverrides' => [ + '54' => '연기차단재 W50 4000mm', + '84' => '연기차단재 W80 4000mm', + ], + ], + ]; + } + + public function run(): void + { + $definitions = $this->getItemDefinitions(); + $created = 0; + $skipped = 0; + + foreach ($definitions as $prefix => $def) { + foreach ($def['lengthCodes'] as $lengthCode) { + $code = "BD-{$prefix}-{$lengthCode}"; + $lengthMm = self::LENGTH_MAP[$lengthCode]; + $name = ($def['nameOverrides'][$lengthCode] ?? null) + ?: "{$def['name']} {$lengthMm}mm"; + + $exists = DB::table('items') + ->where('tenant_id', $this->tenantId) + ->where('code', $code) + ->whereNull('deleted_at') + ->exists(); + + if ($exists) { + $this->command?->line(" ⏭️ skip (exists): {$code}"); + $skipped++; + + continue; + } + + DB::table('items')->insert([ + 'tenant_id' => $this->tenantId, + 'code' => $code, + 'name' => $name, + 'item_type' => 'PT', + 'item_category' => 'BENDING', + 'unit' => 'EA', + 'bom' => null, + 'attributes' => json_encode([]), + 'attributes_archive' => json_encode([]), + 'options' => json_encode([ + 'source' => 'bending_item_seeder', + 'lot_managed' => true, + 'consumption_method' => 'auto', + 'production_source' => 'self_produced', + 'input_tracking' => true, + 'prefix' => $prefix, + 'length_code' => $lengthCode, + 'length_mm' => $lengthMm, + ]), + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->command?->line(" ✅ created: {$code} ({$name})"); + $created++; + } + } + + $this->command?->info("BD-* 누락 품목 등록 완료: 생성 {$created}건, 스킵 {$skipped}건"); + } +} From 7c117bb29f9fe132ff4617c9dd4ed59248029182 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Sun, 22 Feb 2026 03:04:54 +0900 Subject: [PATCH 15/26] =?UTF-8?q?chore:=20=EC=9E=91=EC=97=85=ED=98=84?= =?UTF-8?q?=ED=99=A9=20=EC=A0=95=EB=A6=AC=20=EB=B0=8F=20=EA=B4=80=EA=B3=84?= =?UTF-8?q?=20=EB=AC=B8=EC=84=9C=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CURRENT_WORKS.md 이전 작업 이력 정리 - LOGICAL_RELATIONSHIPS.md stock_lots.workOrder 관계 추가 Co-Authored-By: Claude Opus 4.6 --- .serena/project.yml | 4 + CURRENT_WORKS.md | 2692 +------------------------------------- LOGICAL_RELATIONSHIPS.md | 3 +- 3 files changed, 37 insertions(+), 2662 deletions(-) diff --git a/.serena/project.yml b/.serena/project.yml index 7f909e2..ddf691b 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -103,3 +103,7 @@ default_modes: # fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. # This cannot be combined with non-empty excluded_tools or included_optional_tools. fixed_tools: [] + +# override of the corresponding setting in serena_config.yml, see the documentation there. +# If null or missing, the value from the global config is used. +symbol_info_budget: diff --git a/CURRENT_WORKS.md b/CURRENT_WORKS.md index 57272c5..9622b2c 100644 --- a/CURRENT_WORKS.md +++ b/CURRENT_WORKS.md @@ -1,2675 +1,45 @@ -## 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 (수) - 슬랫 조인트바 자동 계산 및 데이터 파이프라인 완성 - -### 작업 목표 -- 조인트바 수량이 BOM에 생성되지 않는 문제 수정 -- 견적→수주→작업지시 전체 데이터 파이프라인에 조인트바/방화유리 연동 - -### 근본 원인 -- `joint_bar_qty`가 프론트에서 전달되지 않아 항상 0 → 조인트바 BOM 미생성 -- 레거시 5130은 자동 계산: `(2 + floor((제작가로 - 500) / 1000)) × 셔터수량` - -### 수정된 파일 -| 파일명 | 설명 | -|--------|------| -| `app/Services/Quote/Handlers/KyungdongFormulaHandler.php` | joint_bar_qty 미전달 시 레거시 공식 자동 계산 추가 | -| `app/Services/OrderService.php` | extractSlatInfoFromBom() 및 createWorkOrders()에 동일 폴백 추가 | - -### 데이터 백필 -- 기존 72건 work_order_items의 slat_info 업데이트 (joint_bar: 0→4, glass_qty: 0→1) - -### 상태: ✅ 완료 -- ✅ KyungdongFormulaHandler 자동 계산 추가 -- ✅ OrderService 3단계 폴백 (nodeOptions → bom_result → width 기반 계산) -- ✅ 기존 데이터 백필 완료 -- ✅ 견적 화면에서 조인트바 BOM 항목 표시 확인 -- ⬜ 전체 파이프라인 E2E 검증 (견적→수주→작업지시→작업자화면) - ---- - -## 2026-02-19 (수) - 작업일지 담당자 정보 및 슬랫 데이터 파이프라인 구축 - -### 커밋 내역 -- `316d412` fix: 슬랫 작업일지 데이터 파이프라인 구축 -- `1ddef5a` fix: 경동 BOM 계산 수정 및 품목-공정 매핑 -- `0c58c52` fix: 견적→수주 변환 시 담당자 정보 누락 수정 - -### 수정된 파일 -| 파일명 | 설명 | -|--------|------| -| `app/Services/OrderService.php` | extractSlatInfoFromBom() 신규, createFromQuote/syncFromQuote에 slat_info 추가, createWorkOrders 폴백 | -| `app/Services/Quote/Handlers/KyungdongFormulaHandler.php` | 조인트바 조건: slat → slat+steel 확장 | - ---- - -## 2026-01-30 (목) - 5130↔SAM 견적 교차 검증 완료 + 마이그레이션 검증 - -### 작업 목표 -- SAM 견적 계산이 5130 레거시 시스템과 100% 일치하는지 교차 검증 -- FormulaEvaluatorService 슬랫/스틸 지원 완성 -- MigrateBDModelsPrices 커맨드 동작 검증 - -### 수정된 파일 -| 파일명 | 설명 | -|--------|------| -| `app/Services/Quote/FormulaEvaluatorService.php` | 제품타입별 면적/중량 공식 분기, 모터/브라켓 입력값 오버라이드, 디버그 포뮬러 동적 표시 | -| `app/Services/Quote/Handlers/KyungdongFormulaHandler.php` | 제품타입별 면적/중량 공식, normalizeGuideType() 추가, guide_rail_spec 파라미터 별칭 | - -### 핵심 수정 내용 - -#### 1. 제품타입별 면적/중량 공식 (FormulaEvaluatorService + Handler) -- **Screen**: area = (W1 × (H1+550)) / 1M, weight = area×2 + (W0/1000)×14.17 -- **Slat**: area = (W0 × (H0+50)) / 1M, weight = area×25 -- **Steel**: area = (W1 × (H1+550)) / 1M, weight = area×25 - -#### 2. 모터/브라켓 입력값 오버라이드 -- 기존: 항상 자동 계산 -- 수정: `MOTOR_CAPACITY`, `BRACKET_SIZE` 입력값이 있으면 우선 사용 - -#### 3. 가이드타입 정규화 -- `normalizeGuideType()` 메서드 추가 (벽면↔벽면형, 측면↔측면형, 혼합↔혼합형) -- `guide_rail_spec` 파라미터 별칭 지원 - -### 검증 결과 - -#### 전 모델 교차 검증 (Task #6) ✅ -``` -16/16 ALL PASS -- 10개 스크린 조합 (KSS01, KSS02, KSE01, KWE01, KTE01, KQTS01, KDSS01 × SUS/EGI) -- 6개 슬랫 조합 (KSS02, KSE01, KTE01 × SUS × 2사이즈) -- 조건: 6800×2700, QTY=1, 300K 모터, 5인치 브라켓 -``` - -#### 가이드타입 교차 검증 (Task #7) ✅ -``` -21/21 ALL PASS -- 벽면/측면/혼합 × 4모델(KSS02, KSE01, KTE01, KDSS01) × screen -- 벽면/측면/혼합 × 3모델(KSS02, KSE01, KTE01) × slat -- 혼합형: 5130은 col6에 "혼합 120*70/120*120" 두 규격 필요 -``` - -#### MigrateBDModelsPrices 커맨드 검증 (Task #4, #5) ✅ -``` -커맨드 정상 동작 확인 -- BD-* (절곡품): 58건 마이그레이션 완료 -- EST-* (모터/제어기/원자재 등): 71건 마이그레이션 완료 -- chandj 원본 가격 일치: 7/7 검증 통과 -- --dry-run, --fresh 옵션 정상 동작 -``` - -### Git 커밋 -- `f4a902f` - fix: FormulaEvaluatorService 슬랫/스틸 제품타입별 면적/중량/모터/가이드 수정 - ---- - -## 2026-01-29 (수) - 경동기업 견적 로직 Phase 4 완료 - -### 작업 목표 -- 경동기업(tenant_id=287) 전용 견적 계산 로직 구현 -- 5130 레거시 시스템의 BOM/견적 로직을 SAM에 이식 -- 동적 BOM 계산: 모터, 제어기, 절곡품(10종), 부자재(3종) - -### 생성된 파일 -| 파일명 | 설명 | -|--------|------| -| `app/Models/Kyungdong/KdPriceTable.php` | 경동기업 전용 단가 테이블 모델 | -| `app/Services/Quote/Handlers/KyungdongFormulaHandler.php` | 경동기업 견적 계산 핸들러 | -| `database/migrations/2026_01_29_004736_create_kd_price_tables_table.php` | kd_price_tables 마이그레이션 | -| `database/seeders/Kyungdong/KdPriceTableSeeder.php` | 단가 데이터 시더 (47건) | - -### 수정된 파일 -| 파일명 | 설명 | -|--------|------| -| `app/Services/Quote/FormulaEvaluatorService.php` | tenant_id=287 라우팅 추가 | - -### 구현된 기능 -| 기능 | 설명 | -|------|------| -| 모터 용량 계산 | 제품타입 × 인치 × 중량 3차원 조건 | -| 브라켓 크기 결정 | 중량 기반 530*320, 600*350, 690*390 | -| 주자재 계산 | W × (H + 550) / 1,000,000 × 단가 | -| 절곡품 계산 (10종) | 케이스, 마구리, 가이드레일, 하장바, L바, 평철, 환봉 등 | -| 부자재 계산 (3종) | 감기샤프트, 각파이프, 앵글 | - -### 테스트 결과 -``` -입력: W0=3000, H0=2500, 철재형, 5인치, KSS01 SUS -출력: 16개 항목, 합계 751,200원 ✅ -``` - -### 검증 완료 -- [x] Pint 코드 스타일 통과 -- [x] 마이그레이션 실행 완료 (kd_price_tables) -- [x] 시더 실행 완료 (47건 단가 데이터) -- [x] tinker 테스트 통과 (16개 항목 정상 계산) - -### 계획 문서 -- `docs/plans/kd-quote-logic-plan.md` - Phase 0~4 완료 (100%) - ---- - -## 2026-01-21 (화) - TodayIssue 헤더 알림 API (Phase 3 완료) - -### 작업 목표 -- TodayIssue + 알림 시스템 통합 Phase 3: 헤더 알림 API 구현 -- 읽지 않은 이슈 목록/개수 조회, 읽음 처리 API 구현 - -### 수정된 파일 -| 파일명 | 설명 | -|--------|------| -| `app/Services/TodayIssueService.php` | getUnreadList(), getUnreadCount(), markAllAsRead() 추가 | -| `app/Http/Controllers/Api/V1/TodayIssueController.php` | unread(), unreadCount(), markAsRead(), markAllAsRead() 추가 | -| `routes/api.php` | 4개 엔드포인트 추가 | -| `lang/ko/message.php` | today_issue.marked_as_read, all_marked_as_read 메시지 추가 | -| `app/Swagger/v1/TodayIssueApi.php` | 4개 엔드포인트 + 스키마 문서화 | -| `app/Swagger/v1/ComprehensiveAnalysisApi.php` | 스키마 이름 충돌 해결 (TodayIssueItem → ComprehensiveTodayIssueItem) | - -### API 엔드포인트 -| Method | Endpoint | 설명 | -|--------|----------|------| -| GET | `/api/v1/today-issues/unread` | 읽지 않은 이슈 목록 (헤더 알림 드롭다운용) | -| GET | `/api/v1/today-issues/unread/count` | 읽지 않은 이슈 개수 (헤더 뱃지용) | -| POST | `/api/v1/today-issues/{id}/read` | 단일 이슈 읽음 처리 | -| POST | `/api/v1/today-issues/read-all` | 모든 이슈 읽음 처리 | - -### 검증 완료 -- [x] Pint 코드 스타일 통과 -- [x] Swagger 문서 생성 완료 -- [x] PHP 문법 검증 통과 -- [x] Service-First 아키텍처 준수 -- [x] Multi-tenancy (tenant_id 필터링) 적용 -- [x] i18n 메시지 키 사용 - -### 계획 문서 -- `docs/plans/today-issue-notification-integration-plan.md` -- 백엔드 작업 완료 (Phase 1-3: 100%) -- Phase 4 (React 헤더 연동)는 프론트엔드 담당 - ---- - -## 2026-01-11 (토) - Labor(노임관리) API 구현 - -### 작업 목표 -- 시공관리 > 노임관리 API 백엔드 구현 -- Frontend actions.ts API 연동 - -### 생성된 파일 -| 파일명 | 설명 | -|--------|------| -| `app/Models/Labor.php` | 노임 모델 (BelongsToTenant, SoftDeletes) | -| `app/Http/Controllers/Api/V1/LaborController.php` | 노임 컨트롤러 (7개 메서드) | -| `app/Services/LaborService.php` | 노임 서비스 (비즈니스 로직) | -| `app/Http/Requests/Labor/LaborIndexRequest.php` | 목록 조회 검증 | -| `app/Http/Requests/Labor/LaborStoreRequest.php` | 등록 요청 검증 | -| `app/Http/Requests/Labor/LaborUpdateRequest.php` | 수정 요청 검증 | -| `app/Http/Requests/Labor/LaborBulkDeleteRequest.php` | 일괄 삭제 검증 | -| `database/migrations/2026_01_11_000000_create_labors_table.php` | 마이그레이션 | - -### 수정된 파일 -| 파일명 | 설명 | -|--------|------| -| `routes/api.php` | Labor 라우트 7개 추가 (line 1005-1014) | - -### API 엔드포인트 -| Method | Endpoint | 설명 | -|--------|----------|------| -| GET | `/api/v1/labor` | 목록 조회 | -| GET | `/api/v1/labor/stats` | 통계 조회 | -| POST | `/api/v1/labor` | 등록 | -| GET | `/api/v1/labor/{id}` | 상세 조회 | -| PUT | `/api/v1/labor/{id}` | 수정 | -| DELETE | `/api/v1/labor/{id}` | 삭제 | -| DELETE | `/api/v1/labor/bulk` | 일괄 삭제 | - -### 검증 완료 -- [x] 마이그레이션 실행 완료 -- [x] Pint 코드 스타일 통과 -- [x] Service-First 아키텍처 준수 -- [x] FormRequest 검증 사용 -- [x] Multi-tenancy (BelongsToTenant) 적용 - ---- - # SAM API 작업 현황 -## 2025-01-09 (목) - 작업지시 코드 리뷰 기반 전면 개선 - -### 작업 목표 -- 작업지시(Work Orders) 기능 코드 리뷰 결과 기반 전면 개선 -- Critical, High, Medium 우선순위 항목 전체 수정 - -### 수정된 파일 -| 파일명 | 설명 | -|--------|------| -| `app/Models/Production/WorkOrderItem.php` | BelongsToTenant 트레이트 적용 | -| `app/Models/Production/WorkOrderBendingDetail.php` | BelongsToTenant 트레이트 적용 | -| `app/Models/Production/WorkOrderIssue.php` | BelongsToTenant 트레이트 적용 | -| `app/Models/Production/WorkOrder.php` | 상태 전이 규칙 (STATUS_TRANSITIONS, canTransitionTo, transitionTo) | -| `app/Services/WorkOrderService.php` | 감사 로그, 다중 담당자, 부분 수정 지원 | - -### 생성된 파일 -| 파일명 | 설명 | -|--------|------| -| `app/Models/Production/WorkOrderAssignee.php` | 다중 담당자 피벗 모델 | -| `database/migrations/*_create_work_order_assignees_table.php` | 다중 담당자 테이블 마이그레이션 | - -### 주요 변경 내용 -1. **Multi-tenancy 적용**: 하위 모델 4개에 BelongsToTenant 트레이트 적용 -2. **감사 로그 적용**: 품목 삭제, 상태 변경, 이슈 등록/해결, 담당자 배정 시 기록 -3. **상태 전이 규칙**: STATUS_TRANSITIONS 상수 + canTransitionTo(), transitionTo() 메서드 -4. **다중 담당자 지원**: - - WorkOrderAssignee 피벗 모델 생성 - - assign() 메서드에서 배열 담당자 지원 - - is_primary 플래그로 주 담당자 구분 -5. **부분 수정 지원**: 품목 업데이트 시 ID 기반 upsert/delete (기존 삭제 후 재생성 → ID 기반 부분 수정) - -### 검증 완료 -- [x] Pint 코드 스타일 (3개 파일) -- [x] Service-First 아키텍처 준수 -- [x] Eager loading에 assignees.user:id,name 추가 - -### Git 커밋 -- `349917f refactor(work-orders): 코드 리뷰 기반 전면 개선` - -### 관련 문서 -- 계획: `~/.claude/plans/purring-sparking-pinwheel.md` - ---- - -## 2026-01-08 (수) - Order Management API Phase 1.1 구현 - -### 작업 목표 -- 수주관리(Order Management) API 기본 CRUD 및 상태 관리 기능 구현 -- WorkOrderService/Controller 패턴을 참고하여 SAM API 규칙 준수 - -### 생성된 파일 -| 파일명 | 설명 | -|--------|------| -| `app/Services/OrderService.php` | 수주 비즈니스 로직 서비스 | -| `app/Http/Controllers/Api/V1/OrderController.php` | 수주 API 컨트롤러 | -| `app/Http/Requests/Order/StoreOrderRequest.php` | 생성 요청 검증 | -| `app/Http/Requests/Order/UpdateOrderRequest.php` | 수정 요청 검증 | -| `app/Http/Requests/Order/UpdateOrderStatusRequest.php` | 상태 변경 요청 검증 | -| `app/Swagger/v1/OrderApi.php` | Swagger API 문서 | - -### 수정된 파일 -| 파일명 | 설명 | -|--------|------| -| `routes/api.php` | OrderController import 및 7개 라우트 추가 | -| `lang/ko/message.php` | 수주 관련 메시지 키 추가 | -| `lang/en/message.php` | 수주 관련 메시지 키 추가 | -| `lang/ko/error.php` | 수주 에러 메시지 키 추가 | -| `lang/en/error.php` | 수주 에러 메시지 키 추가 | - -### 주요 구현 내용 -1. **OrderService 메서드**: index, stats, show, store, update, destroy, updateStatus -2. **상태 전환 규칙**: DRAFT → CONFIRMED → IN_PROGRESS → COMPLETED/CANCELLED -3. **수주번호 자동생성**: ORD{YYYYMMDD}{0001} 형식 -4. **품목 금액 계산**: 공급가, 세액, 합계 자동 계산 -5. **Swagger 스키마**: Order, OrderItem, OrderPagination, OrderStats 등 - -### 검증 완료 -- [x] Pint 코드 스타일 (6개 파일 자동 수정) -- [x] Swagger 문서 생성 -- [x] Service-First 아키텍처 준수 -- [x] i18n 메시지 키 사용 - -### 관련 문서 -- 계획: `docs/plans/order-management-plan.md` -- 변경 요약: `docs/changes/20250108_order_management_phase1.md` - ---- - -## 2026-01-02 (목) - 채권현황 동적월 지원 및 버그 수정 - -### 작업 목표 -- "최근 1년" 필터 선택 시 동적 월 기간(최근 12개월) 지원 -- year=0 파라미터 처리 버그 수정 -- 거래처별 연체 상태 및 메모 관리 기능 추가 - -### 수정된 파일 -| 파일명 | 설명 | -|--------|------| -| `app/Http/Controllers/Api/V1/ReceivablesController.php` | boolean 유효성 검사 수정, 디버깅 로그 추가 | -| `app/Services/ReceivablesService.php` | 동적 월 기간 지원, 이월잔액 계산 추가 | -| `app/Models/Orders/Client.php` | is_overdue, memo 필드 추가 | -| `routes/api.php` | 채권현황 라우트 추가 | - -### 생성된 파일 -| 파일명 | 설명 | -|--------|------| -| `database/migrations/2026_01_02_113722_add_is_overdue_to_clients_table.php` | clients 테이블 is_overdue 컬럼 추가 | - -### 주요 변경 내용 -1. **Boolean 유효성 검사 수정**: `'nullable|boolean'` → `'nullable|string|in:true,false,1,0'` - - 쿼리 문자열의 `"true"` 문자열을 올바르게 처리 -2. **동적 월 기간 지원**: `recent_year=true` 시 최근 12개월 동적 계산 -3. **월별 레이블 동적 생성**: 예: `['25.02', '25.03', ...]` -4. **이월잔액(carry_forward_balance) 계산**: 선택 기간 이전의 누적 미수금 - -### Git 커밋 -- `4fa38e3` feat(API): 채권현황 동적월 지원 및 year=0 파라미터 버그 수정 - -### 남은 작업 -- [ ] 디버깅 로그 제거 (테스트 완료 후) -- [ ] 추가 UI 개선사항 확인 - ---- - -## 2026-01-02 (목) - 견적 BOM 산출 작업 현황 및 Item 모델 주석 추가 - -### 작업 목표 -- 견적 BOM 산출 관련 작업 진행 상황 문서화 -- Item 모델 필드 주석 추가 - -### 수정된 파일 -| 파일명 | 설명 | -|--------|------| -| `app/Models/Items/Item.php` | item_category 필드 주석 추가 | - -### 주요 변경 내용 -1. **Item 모델 필드 주석**: - - `item_category` 필드에 설명 주석 추가 - - React 프론트엔드에서 필드 매핑 시 참조용 - -### Git 커밋 -- `02e268e` docs(API): 견적 BOM 산출 작업 현황 및 Item 모델 주석 추가 - ---- - -## 2026-01-02 (목) - Phase 1.2 다건 BOM 기반 자동산출 API 구현 - -### 작업 목표 -- React 견적등록 화면에서 여러 품목의 자동산출을 일괄 요청할 수 있는 API 구현 -- React QuoteFormItem 인터페이스 필드명(camelCase)과 API 변수명(약어) 모두 지원 - -### 생성된 파일 -| 파일명 | 설명 | -|--------|------| -| `app/Http/Requests/Quote/QuoteBomBulkCalculateRequest.php` | 다건 BOM 산출 FormRequest (필드 변환 포함) | -| `docs/changes/20260102_1300_quote_bom_bulk_calculation.md` | 변경 내용 문서 | - -### 수정된 파일 -| 파일명 | 설명 | -|--------|------| -| `app/Services/Quote/QuoteCalculationService.php` | calculateBomBulk() 메서드 추가 | -| `app/Http/Controllers/Api/V1/QuoteController.php` | calculateBomBulk 액션 추가 | -| `routes/api.php` | /calculate/bom/bulk 라우트 추가 | -| `app/Swagger/v1/QuoteApi.php` | 스키마 3개 + 엔드포인트 추가 | - -### 주요 변경 내용 -1. **다건 BOM 기반 자동산출 API**: `POST /api/v1/quotes/calculate/bom/bulk` -2. **필드 매핑 지원**: React camelCase (openWidth, openHeight) ↔ API 약어 (W0, H0) -3. **일괄 처리**: 여러 품목 동시 계산, 성공/실패 요약 제공 -4. **Swagger 문서화**: QuoteBomBulkCalculateRequest, QuoteBomBulkItemInput, QuoteBomBulkCalculationResult - -### 필드 매핑 테이블 -| React 필드 | API 변수 | 설명 | -|-----------|---------|------| -| openWidth | W0 | 개구부 폭 | -| openHeight | H0 | 개구부 높이 | -| quantity | QTY | 수량 | -| productCategory | PC | 제품 카테고리 | -| guideRailType | GT | 가이드레일 타입 | -| motorPower | MP | 모터 출력 | -| controller | CT | 제어반 | -| wingSize | WS | 날개 크기 | -| inspectionFee | INSP | 검사비 | - -### Git 커밋 -- `4e59bbf` feat: Phase 1.2 - 다건 BOM 기반 자동산출 API 구현 - -### 관련 문서 -- 계획 문서: `docs/plans/quote-calculation-api-plan.md` -- Phase 1.1: `docs/changes/20260102_quote_bom_calculation_api.md` - ---- - -## 2026-01-02 (목) - Phase 1.1 견적 산출 API 엔드포인트 구현 - -### 작업 목표 -- React 프론트엔드에서 BOM 기반 견적 계산 API 호출 가능하도록 구현 -- MNG FormulaEvaluatorService.calculateBomWithDebug() 연결 - -### 생성된 파일 -| 파일명 | 설명 | -|--------|------| -| `app/Http/Requests/Quote/QuoteBomCalculateRequest.php` | BOM 계산용 FormRequest | -| `docs/changes/20260102_quote_bom_calculation_api.md` | 변경 내용 문서 | - -### 수정된 파일 -| 파일명 | 설명 | -|--------|------| -| `app/Services/Quote/QuoteCalculationService.php` | calculateBom 메서드 추가 | -| `app/Http/Controllers/Api/V1/QuoteController.php` | calculateBom 액션 추가 | -| `routes/api.php` | /calculate/bom 라우트 추가 | -| `app/Swagger/v1/QuoteApi.php` | 스키마 및 엔드포인트 문서 추가 | - -### 주요 변경 내용 -1. **BOM 기반 견적 계산 API**: `POST /api/v1/quotes/calculate/bom` -2. **입력 변수**: finished_goods_code, W0, H0, QTY, PC, GT, MP, CT, WS, INSP -3. **10단계 디버깅**: debug=true 옵션으로 계산 과정 확인 가능 -4. **Swagger 문서화**: QuoteBomCalculateRequest, QuoteBomCalculationResult 스키마 - -### 관련 문서 -- 계획 문서: `docs/plans/quote-calculation-api-plan.md` -- FormulaEvaluatorService: Phase 1.1에서 구현 완료 - ---- - -## 2025-12-30 (월) - Phase 1.1 견적 계산 MNG 로직 재구현 - -### 작업 목표 -- MNG FormulaEvaluatorService 10단계 BOM 계산 로직을 API로 이식 -- React 프론트엔드에서 MNG와 동일한 견적 계산 기능 사용 가능하도록 구현 - -### 생성된 파일 -| 파일명 | 설명 | -|--------|------| -| `app/Models/CategoryGroup.php` | 카테고리별 단가 계산 방식 모델 (신규) | -| `docs/changes/20251230_2339_quote_calculation_mng_logic.md` | 변경 내용 문서 | - -### 수정된 파일 -| 파일명 | 설명 | -|--------|------| -| `app/Services/Quote/FormulaEvaluatorService.php` | MNG 10단계 BOM 계산 로직 추가 (537줄→1176줄) | - -### 주요 변경 내용 -1. **CategoryGroup 모델**: 면적/중량/수량 기반 단가 계산 방식 관리 -2. **calculateBomWithDebug()**: 10단계 BOM 계산 (디버그 모드) -3. **calculateCategoryPrice()**: 카테고리 기반 단가 계산 -4. **groupItemsByProcess()**: 공정별 품목 그룹화 -5. **getItemDetails()**: 품목 상세 정보 및 BOM 트리 - -### 관련 문서 -- `docs/plans/quote-calculation-api-plan.md` - ---- - -## 2025-12-30 (월) - Phase L 설정 및 기준정보 API 개발 - -### 작업 목표 -- L-2 권한관리 API 개발 -- L-3 직급관리 + L-4 직책관리 API 개발 (통합 positions 테이블) - -### 생성된 파일 - -#### L-2 권한관리 -| 파일명 | 설명 | -|--------|------| -| `database/migrations/2025_12_30_160802_add_is_hidden_to_roles_table.php` | roles 테이블 is_hidden 컬럼 추가 | -| `app/Http/Controllers/Api/V1/RoleController.php` | Role CRUD API | -| `app/Http/Controllers/Api/V1/RolePermissionController.php` | 권한 매트릭스 API | -| `app/Services/RoleService.php` | Role 비즈니스 로직 | -| `app/Swagger/v1/RoleApi.php` | Swagger 문서 | -| `app/Swagger/v1/RolePermissionApi.php` | Swagger 문서 | - -#### L-3/L-4 직급/직책 관리 -| 파일명 | 설명 | -|--------|------| -| `database/migrations/2025_12_30_091821_create_positions_table.php` | positions 테이블 생성 | -| `database/migrations/2025_12_30_091822_add_position_type_to_common_codes.php` | position_type 코드 추가 | -| `app/Models/Tenants/Position.php` | Position 모델 | -| `app/Services/PositionService.php` | Position 비즈니스 로직 | -| `app/Http/Controllers/Api/V1/PositionController.php` | Position CRUD API | -| `app/Http/Requests/PositionRequest.php` | 생성/수정 요청 검증 | -| `app/Http/Requests/PositionReorderRequest.php` | 순서 변경 요청 검증 | -| `app/Swagger/v1/PositionApi.php` | Swagger 문서 | - -### API 엔드포인트 - -#### Role API (9개) -``` -GET /api/v1/roles # 역할 목록 -POST /api/v1/roles # 역할 생성 -GET /api/v1/roles/{id} # 역할 상세 -PATCH /api/v1/roles/{id} # 역할 수정 -DELETE /api/v1/roles/{id} # 역할 삭제 -GET /api/v1/roles/{id}/permissions # 역할 권한 조회 -POST /api/v1/roles/{id}/permissions # 권한 추가 -DELETE /api/v1/roles/{id}/permissions # 권한 제거 -PUT /api/v1/roles/{id}/permissions/sync # 권한 동기화 -``` - -#### Position API (6개) -``` -GET /api/v1/positions?type=rank # 직급 목록 -GET /api/v1/positions?type=title # 직책 목록 -POST /api/v1/positions # 생성 (type 필수) -PUT /api/v1/positions/{id} # 수정 -DELETE /api/v1/positions/{id} # 삭제 -POST /api/v1/positions/reorder # 순서 변경 (bulk) -``` - -### 설계 결정사항 -- **통합 테이블**: 직급(rank)과 직책(title)을 `positions` 테이블로 통합 -- **구분 컬럼**: `type` 컬럼으로 rank/title 구분 -- **정렬 지원**: `sort_order` 컬럼 + reorder API로 드래그 앤 드롭 지원 - -### 참고 -- 계획 문서: `docs/plans/l2-permission-management-plan.md` -- 세레나 메모리: `position-api-development`, `l2-permission-state` - ---- - -## 2025-12-28 (토) - 시스템 게시판 tenant_id 및 custom_fields 수정 - -### 작업 목표 -- POST `/api/v1/system-boards/qna/posts` 500 에러 해결 -- 시스템 게시판 tenant_id 처리 로직 개선 -- custom_fields field_key → field_id 매핑 지원 -- 댓글 생성 시 tenant_id 누락 수정 - -### 문제 원인 -1. `posts.tenant_id` NOT NULL 제약조건 위반 (시스템 게시판에서 null 설정 시도) -2. `saveCustomFields()`에서 field_key(string)를 field_id(integer)로 사용 -3. `createComment()`에서 tenant_id 미설정 - -### 수정된 파일 (1개) - -| 파일명 | 변경 내용 | -|--------|----------| -| `app/Services/Boards/PostService.php` | tenant_id 항상 설정, custom_fields 매핑 개선, 댓글 tenant_id 추가 | - -### 상세 변경사항 - -#### 1. HQ_TENANT_ID 상수 추가 -```php -private const HQ_TENANT_ID = 1; // 본사 테넌트 ID -``` - -#### 2. applySystemBoardScope() 헬퍼 메서드 추가 -```php -private function applySystemBoardScope($query): void -{ - $query->where(function ($q) { - $q->where('tenant_id', self::HQ_TENANT_ID) - ->orWhere('tenant_id', $this->tenantId()); - }); -} -``` - -#### 3. createPost() 수정 -- 변경 전: `$data['tenant_id'] = $isSystemBoard ? null : $this->tenantId();` -- 변경 후: `$data['tenant_id'] = $this->tenantId();` (항상 설정) - -#### 4. saveCustomFields() 개선 -- boardId 파라미터 추가 -- field_key → field_id 매핑 로직 추가 (BoardSetting 조회) -- 모든 호출부 업데이트 (createPost, updatePost 등) - -#### 5. createComment() 수정 -- `$data['tenant_id'] = $this->tenantId();` 추가 - -### 시스템 게시판 조회 조건 -``` -(tenant_id = 1) OR (tenant_id = 현재 테넌트) -``` -- 본사(tenant_id=1)의 글: 모든 테넌트에서 조회 가능 -- 각 테넌트 글: 해당 테넌트만 조회 가능 - -### 테스트 결과 -- ✅ 게시글 생성 (id=7, id=8 with custom_fields) -- ✅ custom_fields 저장 (inquiry_type → field_id=2 변환) -- ✅ 댓글 생성 (id=1, tenant_id=1) - -### Git 커밋 -``` -4a2c185 fix: 게시판 시스템 tenant_id 및 custom_fields 처리 개선 -``` - ---- - -## 2025-12-27 (금) - 결재 API 프론트엔드 호환성 개선 - -### 작업 목표 -- 프론트엔드에서 `form_code`, `step_type`, `approver_id` 필드명 사용 지원 -- 기존 `form_id`, `type`, `user_id` 필드명과 호환성 유지 - -### 수정된 파일 (4개) - -| 파일명 | 변경 내용 | -|--------|----------| -| `app/Http/Requests/Approval/StoreRequest.php` | form_code, step_type, approver_id 필드 추가 | -| `app/Http/Requests/Approval/UpdateRequest.php` | form_code, steps 필드 지원 추가 | -| `app/Services/ApprovalService.php` | store()/update() - form_code→form_id 변환, createApprovalSteps() - step_type/approver_id 지원 | -| `lang/ko/error.php` | `approval.form_required` 에러 메시지 추가 | - -### 필드 호환성 매핑 - -| 프론트엔드 | API (이전) | 설명 | -|-----------|-----------|------| -| `form_code` | `form_id` | 양식 코드로 form_id 자동 조회 | -| `step_type` | `type` | 결재/참조 구분 | -| `approver_id` | `user_id` | 결재자 사용자 ID | -| `step_order` | - | 결재 순서 (자동 증가) | - ---- - -## 2025-12-26 (목) - 휴가관리 휴직 직원 표시 수정 - -### 작업 목표 -- 휴가 사용현황에 휴직(leave) 상태 직원도 표시되도록 수정 - -### 수정된 파일 (1개) - -| 파일명 | 변경 내용 | -|--------|----------| -| `app/Services/LeaveService.php` | `getAllBalances()`에서 employee_status 필터를 `'active'`에서 `['active', 'leave']`로 변경 | - -### 영향받는 직원 -- 최준호(46), 한지민(50), 오태양(51) - 휴직 상태 - -### Git 커밋 -``` -defe971 fix(leave): 휴직 직원도 휴가 사용현황에 표시되도록 수정 -``` - ---- - -## 2025-12-24 (화) - 매입 세금계산서 수취 토글 기능 추가 - -### 작업 목표 -- 매입(Purchase) 테이블에 세금계산서 수취 여부(tax_invoice_received) 필드 추가 -- React 프론트엔드에서 토글 API 호출 가능하도록 지원 - -### 생성된 마이그레이션 (1개) - -| 파일명 | 설명 | -|--------|------| -| `2025_12_24_160000_add_tax_invoice_received_to_purchases_table.php` | purchases 테이블에 tax_invoice_received 컬럼 추가 | - -### 수정된 파일 (3개) - -| 파일명 | 변경 내용 | -|--------|----------| -| `app/Models/Tenants/Purchase.php` | fillable에 tax_invoice_received 추가, casts에 boolean 타입 추가 | -| `app/Services/PurchaseService.php` | toggleTaxInvoice() 메서드 추가 | -| `app/Http/Requests/V1/Purchase/UpdatePurchaseRequest.php` | tax_invoice_received 필드 검증 규칙 추가 | - -### 마이그레이션 실행 -```bash -php artisan migrate -# 2025_12_24_160000_add_tax_invoice_received_to_purchases_table ... DONE -``` - -### 테스트 결과 -- 세금계산서 수취 토글 API 정상 동작 -- React 매입 관리 페이지에서 토글 기능 정상 작동 - ---- - -## 2025-12-22 (일) - 견적수식 시더 업데이트 (5130 연동) - -### 작업 목표 -- 5130 레거시 데이터 기반 견적수식 시더 업데이트 -- 케이스(셔터박스) 3600mm, 6000mm 품목 및 범위 추가 - -### 수정된 파일 (2개) - -| 파일명 | 변경 내용 | -|--------|----------| -| `database/seeders/QuoteFormulaSeeder.php` | CASE_AUTO_SELECT 범위에 3600, 6000 구간 추가 | -| `database/seeders/QuoteFormulaItemSeeder.php` | PT-CASE-3600, PT-CASE-6000 품목 추가 | - -### 테스트 결과 -- W0=3000, H0=2500 입력 시: - - S=3270 → PT-CASE-3600 정상 선택 - - H1=2770 → PT-GR-3000 정상 선택 - - K=41.21kg → PT-MOTOR-150 정상 선택 - -### Git 커밋 -- `eeca8d3` feat: 견적수식 케이스 3600/6000 품목 및 범위 추가 - ---- - -## 2025-12-19 (목) - Phase 7.2 보완 - 나의 게시글 API 추가 - -### 작업 목표 -- Phase 7 게시판 연동 분석 결과, 7.1/7.2 대부분 구현 완료 확인 -- 누락된 `/posts/my` (나의 게시글) API 추가 - -### 수정된 파일 (4개) - -| 파일명 | 변경 내용 | -|--------|----------| -| `app/Services/Boards/PostService.php` | `getMyPosts()` 메서드 추가 | -| `app/Http/Controllers/Api/V1/PostController.php` | `myPosts()` 액션 추가 | -| `routes/api.php` | `GET /v1/posts/my` 라우트 추가 | -| `app/Swagger/v1/PostApi.php` | `/posts/my` Swagger 문서 추가 | - -### API 라우트 (1개) - -| Method | Endpoint | 설명 | -|--------|----------|------| -| GET | /v1/posts/my | 나의 게시글 목록 (게시판 코드/검색/상태 필터 지원) | - -### Git 커밋 -- `c15a245` feat: Phase 7.2 보완 - 나의 게시글 API 추가 - ---- - -## 2025-12-19 (목) - Phase 6.1 악성채권 추심관리 API 개발 - -### 작업 목표 -- `docs/plans/erp-api-development-plan-d1.0-changes.md` Phase 6.1 악성채권 추심관리 -- 악성채권 CRUD, 서류 첨부, 메모 관리 API 구현 - -### 생성된 마이그레이션 (3개) - -| 파일명 | 설명 | -|--------|------| -| `2025_12_19_160001_create_bad_debts_table.php` | 악성채권 테이블 | -| `2025_12_19_160002_create_bad_debt_documents_table.php` | 악성채권 서류 테이블 | -| `2025_12_19_160003_create_bad_debt_memos_table.php` | 악성채권 메모 테이블 | - -### 생성된 모델 (3개) - -**app/Models/BadDebts/BadDebt.php:** -- 악성채권 모델 (BelongsToTenant, SoftDeletes) -- 상태: collecting(추심중), legal_action(법적조치), recovered(회수완료), bad_debt(대손처리) -- Relations: client(), assignedUser(), creator(), documents(), memos() - -**app/Models/BadDebts/BadDebtDocument.php:** -- 서류 모델 (document_type: business_license, tax_invoice, additional) -- Relations: badDebt(), file() - -**app/Models/BadDebts/BadDebtMemo.php:** -- 메모 모델 -- Relations: badDebt(), creator() - -### 생성된 서비스 (1개) - -**app/Services/BadDebtService.php:** -- CRUD: index, show, store, update, destroy -- 토글: toggle (is_active) -- 요약: summary (상태별 통계) -- 서류: addDocument, removeDocument -- 메모: addMemo, removeMemo - -### 생성된 FormRequest (4개) - -| 파일명 | 설명 | -|--------|------| -| `StoreBadDebtRequest.php` | 등록 (client_id, amount, status, assigned_user_id 등) | -| `UpdateBadDebtRequest.php` | 수정 (선택적 필드) | -| `StoreBadDebtDocumentRequest.php` | 서류 첨부 (document_type, file_id) | -| `StoreBadDebtMemoRequest.php` | 메모 추가 (content) | - -### 생성된 컨트롤러 (1개) - -**app/Http/Controllers/Api/V1/BadDebtController.php:** -- index, summary, store, show, update, destroy, toggle -- addDocument, removeDocument, addMemo, removeMemo - -### API 라우트 (11개) - -| Method | Endpoint | 설명 | -|--------|----------|------| -| GET | /v1/bad-debts | 목록 | -| POST | /v1/bad-debts | 등록 | -| GET | /v1/bad-debts/summary | 요약 통계 | -| GET | /v1/bad-debts/{id} | 상세 | -| PUT | /v1/bad-debts/{id} | 수정 | -| DELETE | /v1/bad-debts/{id} | 삭제 | -| PATCH | /v1/bad-debts/{id}/toggle | 활성화 토글 | -| POST | /v1/bad-debts/{id}/documents | 서류 첨부 | -| DELETE | /v1/bad-debts/{id}/documents/{documentId} | 서류 삭제 | -| POST | /v1/bad-debts/{id}/memos | 메모 추가 | -| DELETE | /v1/bad-debts/{id}/memos/{memoId} | 메모 삭제 | - -### Swagger 문서 - -**app/Swagger/v1/BadDebtApi.php:** -- BadDebt, BadDebtDocument, BadDebtMemo 스키마 -- 모든 엔드포인트 문서화 완료 - -### Git 커밋 -- `c0af888` feat: Phase 6.1 악성채권 추심관리 API 구현 - ---- - -## 2025-12-18 (수) - 가지급금 관리 API 개발 - -### 작업 목표 -- `docs/plans/erp-api-development-plan.md` Phase 3의 3.5 가지급금 관리 -- 가지급금 CRUD, 정산 처리, 인정이자 계산/리포트 API 구현 - -### 생성된 마이그레이션 (1개) - -| 파일명 | 설명 | -|--------|------| -| `2025_12_18_120001_create_loans_table.php` | 가지급금 테이블 (tenant_id, user_id, loan_date, amount, purpose, settlement_date, settlement_amount, status, withdrawal_id) | - -### 생성된 모델 (1개) - -**app/Models/Tenants/Loan.php:** -- 가지급금 모델 (BelongsToTenant, SoftDeletes) -- 상태: outstanding(미정산), settled(정산완료), partial(부분정산) -- 인정이자율: 연 4.6% (2024/2025), DEFAULT_INTEREST_RATE -- 세금: 법인세 19%, 소득세 35%, 주민세 10% -- Relations: user(), withdrawal(), creator(), updater() -- Methods: calculateRecognizedInterest(), calculateTaxes(), isEditable(), isSettleable() - -### 생성된 서비스 (1개) - -**app/Services/LoanService.php:** -- CRUD: index, show, store, update, destroy -- 요약: summary (미정산 건수/금액, 정산완료 금액) -- 정산: settle (전액/부분 정산) -- 인정이자: calculateInterest, interestReport - -### 생성된 FormRequest (5개) - -| 파일명 | 설명 | -|--------|------| -| `LoanIndexRequest.php` | 목록 조회 (user_id, status, date_from, date_to, per_page) | -| `LoanStoreRequest.php` | 등록 (user_id, loan_date, amount, purpose, withdrawal_id) | -| `LoanUpdateRequest.php` | 수정 (선택적 필드) | -| `LoanSettleRequest.php` | 정산 (settlement_date, settlement_amount) | -| `LoanCalculateInterestRequest.php` | 인정이자 계산 (year, user_id) | - -### 생성된 컨트롤러 (1개) - -**app/Http/Controllers/Api/V1/LoanController.php:** -- index, summary, store, show, update, destroy, settle, calculateInterest, interestReport - -### API 라우트 (9개) - -| Method | Endpoint | 설명 | -|--------|----------|------| -| GET | /v1/loans | 목록 조회 | -| POST | /v1/loans | 등록 | -| GET | /v1/loans/summary | 요약 조회 | -| POST | /v1/loans/calculate-interest | 인정이자 계산 | -| GET | /v1/loans/interest-report/{year} | 인정이자 리포트 | -| GET | /v1/loans/{id} | 상세 조회 | -| PUT | /v1/loans/{id} | 수정 | -| DELETE | /v1/loans/{id} | 삭제 | -| POST | /v1/loans/{id}/settle | 정산 | - -### i18n 키 추가 - -**lang/ko/message.php:** -- loan.fetched, loan.created, loan.updated, loan.deleted -- loan.settled, loan.summary_fetched -- loan.interest_calculated, loan.interest_report_fetched - -**lang/ko/error.php:** -- loan.not_found, loan.not_editable, loan.not_deletable -- loan.not_settleable, loan.settlement_exceeds -- loan.invalid_withdrawal, loan.user_not_found - -**lang/ko/validation.php (attributes):** -- loan_date, amount, purpose, settlement_date, settlement_amount, year - -### Git 커밋 -- `af83319` - feat: 가지급금 관리 API 구현 - ---- - -## 2025-12-17 (화) - 전자결재 모듈 API 개발 - -### 작업 목표 -- `docs/plans/erp-api-development-plan.md` Phase 2의 3.1 전자결재 모듈 -- 결재 양식 (approval_forms), 결재선 템플릿 (approval_lines), 결재 문서 (approvals) CRUD API 구현 -- 기안함, 결재함, 참조함 조회 및 결재 액션 (상신, 승인, 반려, 회수) 기능 - -### 생성된 마이그레이션 (4개) - -| 파일명 | 설명 | -|--------|------| -| `2025_12_17_200001_create_approval_forms_table.php` | 결재 양식 테이블 (name, code, category, template JSON) | -| `2025_12_17_200002_create_approval_lines_table.php` | 결재선 템플릿 테이블 (name, steps JSON, is_default) | -| `2025_12_17_200003_create_approvals_table.php` | 결재 문서 테이블 (form_id, drafter_id, title, content JSON, status) | -| `2025_12_17_200004_create_approval_steps_table.php` | 결재 단계 테이블 (approval_id, step_order, type, user_id, status) | - -### 생성된 모델 (4개) - -**app/Models/Tenants/ApprovalForm.php:** -- 결재 양식 모델 (BelongsToTenant, SoftDeletes) -- Relations: creator(), approvals() -- Scopes: active() - -**app/Models/Tenants/ApprovalLine.php:** -- 결재선 템플릿 모델 (BelongsToTenant, SoftDeletes) -- Relations: creator() -- Methods: getStepCountAttribute() - -**app/Models/Tenants/Approval.php:** -- 결재 문서 모델 (BelongsToTenant, SoftDeletes) -- 상태: draft → pending → approved/rejected/cancelled -- Relations: form(), drafter(), steps(), currentStepApprover() -- Methods: canEdit(), canDelete(), canSubmit(), canAction(), canCancel() - -**app/Models/Tenants/ApprovalStep.php:** -- 결재 단계 모델 (SoftDeletes) -- 단계 유형: approval, agreement, reference -- Relations: approval(), user() -- Methods: isPending(), isApproved(), isRejected() - -### 생성된 서비스 (1개) - -**app/Services/ApprovalService.php:** -- Form CRUD: formIndex, formShow, formStore, formUpdate, formDestroy, formActive -- Line CRUD: lineIndex, lineShow, lineStore, lineUpdate, lineDestroy -- Approval CRUD: show, store, update, destroy -- 기안함: drafts, draftsSummary -- 결재함: inbox, inboxSummary -- 참조함: referenceList -- 액션: submit, approve, reject, cancel, markRead, markUnread - -### 생성된 FormRequest (13개) - -| 파일 | 설명 | -|------|------| -| `FormIndexRequest.php` | 양식 목록 조회 파라미터 | -| `FormStoreRequest.php` | 양식 생성 검증 (name, code, template) | -| `FormUpdateRequest.php` | 양식 수정 검증 | -| `LineIndexRequest.php` | 결재선 목록 조회 파라미터 | -| `LineStoreRequest.php` | 결재선 생성 검증 (name, steps) | -| `LineUpdateRequest.php` | 결재선 수정 검증 | -| `IndexRequest.php` | 기안함 조회 파라미터 | -| `InboxIndexRequest.php` | 결재함 조회 파라미터 | -| `ReferenceIndexRequest.php` | 참조함 조회 파라미터 | -| `StoreRequest.php` | 문서 생성 검증 (form_id, title) | -| `UpdateRequest.php` | 문서 수정 검증 | -| `SubmitRequest.php` | 상신 검증 (steps 필수) | -| `RejectRequest.php` | 반려 검증 (comment 필수) | - -### 생성된 컨트롤러 (3개) - -| 파일 | 엔드포인트 | -|------|-----------| -| `ApprovalFormController.php` | index, active, show, store, update, destroy | -| `ApprovalLineController.php` | index, show, store, update, destroy | -| `ApprovalController.php` | drafts, draftsSummary, inbox, inboxSummary, reference, show, store, update, destroy, submit, approve, reject, cancel, markRead, markUnread | - -### 수정된 파일 - -**routes/api.php:** -- Approval Forms 라우트 그룹 추가 (6개 라우트) -- Approval Lines 라우트 그룹 추가 (5개 라우트) -- Approvals 라우트 그룹 추가 (15개 라우트) - -**lang/ko/message.php:** -- approval 섹션 추가 (16개 키) - -**lang/ko/error.php:** -- approval 섹션 추가 (15개 키) - -### 생성된 Swagger 문서 (3개) - -| 파일 | 설명 | -|------|------| -| `app/Swagger/v1/ApprovalFormApi.php` | 결재 양식 API 문서 (6개 엔드포인트) | -| `app/Swagger/v1/ApprovalLineApi.php` | 결재선 API 문서 (5개 엔드포인트) | -| `app/Swagger/v1/ApprovalApi.php` | 전자결재 API 문서 (15개 엔드포인트) | - -### API 엔드포인트 - -**결재 양식 API (Approval Forms):** -- `GET /api/v1/approval-forms` - 목록 조회 -- `POST /api/v1/approval-forms` - 생성 -- `GET /api/v1/approval-forms/active` - 활성 양식 (셀렉트박스용) -- `GET /api/v1/approval-forms/{id}` - 상세 조회 -- `PATCH /api/v1/approval-forms/{id}` - 수정 -- `DELETE /api/v1/approval-forms/{id}` - 삭제 - -**결재선 API (Approval Lines):** -- `GET /api/v1/approval-lines` - 목록 조회 -- `POST /api/v1/approval-lines` - 생성 -- `GET /api/v1/approval-lines/{id}` - 상세 조회 -- `PATCH /api/v1/approval-lines/{id}` - 수정 -- `DELETE /api/v1/approval-lines/{id}` - 삭제 - -**전자결재 API (Approvals):** -- `GET /api/v1/approvals/drafts` - 기안함 -- `GET /api/v1/approvals/drafts/summary` - 기안함 현황 -- `GET /api/v1/approvals/inbox` - 결재함 -- `GET /api/v1/approvals/inbox/summary` - 결재함 현황 -- `GET /api/v1/approvals/reference` - 참조함 -- `POST /api/v1/approvals` - 문서 생성 -- `GET /api/v1/approvals/{id}` - 상세 조회 -- `PATCH /api/v1/approvals/{id}` - 수정 -- `DELETE /api/v1/approvals/{id}` - 삭제 -- `POST /api/v1/approvals/{id}/submit` - 상신 -- `POST /api/v1/approvals/{id}/approve` - 승인 -- `POST /api/v1/approvals/{id}/reject` - 반려 -- `POST /api/v1/approvals/{id}/cancel` - 회수 -- `POST /api/v1/approvals/{id}/read` - 열람 -- `POST /api/v1/approvals/{id}/unread` - 미열람 - -### 검증 완료 -- ✅ Pint 스타일 검사 통과 (19개 파일) -- ✅ 라우트 등록 확인 (26개) -- ✅ Swagger 문서 생성 완료 - ---- - -## 2025-12-17 (화) - 매출/매입 관리 API 개발 - -### 작업 목표 -- `docs/plans/erp-api-development-plan.md` Phase 1의 2.5 매출/매입 관리 -- 매출(Sale) 및 매입(Purchase) CRUD API 구현 -- 확정(confirm) 기능 및 요약(summary) 조회 기능 포함 - -### 생성된 마이그레이션 (2개) - -| 파일명 | 설명 | -|--------|------| -| `2025_12_17_100001_create_sales_table.php` | 매출 테이블 (sale_number, sale_date, client_id, 금액, 상태) | -| `2025_12_17_100002_create_purchases_table.php` | 매입 테이블 (purchase_number, purchase_date, client_id, 금액, 상태) | - -### 생성된 모델 (2개) - -**app/Models/Tenants/Sale.php:** -- 매출 모델 (BelongsToTenant, SoftDeletes) -- 상태: draft → confirmed → invoiced -- Relations: client(), deposit(), creator() -- Methods: canConfirm(), canEdit(), canDelete() - -**app/Models/Tenants/Purchase.php:** -- 매입 모델 (BelongsToTenant, SoftDeletes) -- 상태: draft → confirmed -- Relations: client(), withdrawal(), creator() -- Methods: canConfirm(), canEdit(), canDelete() - -### 생성된 서비스 (2개) - -**app/Services/SaleService.php:** -- CRUD, confirm(), summary() -- 문서번호 자동 생성: SL{YYYYMMDD}{0001} -- 상태 검증 (수정/삭제는 draft만 가능) - -**app/Services/PurchaseService.php:** -- CRUD, confirm(), summary() -- 문서번호 자동 생성: PU{YYYYMMDD}{0001} -- 상태 검증 (수정/삭제는 draft만 가능) - -### 생성된 FormRequest (4개) - -| 파일 | 설명 | -|------|------| -| `app/Http/Requests/V1/Sale/StoreSaleRequest.php` | 매출 등록 검증 | -| `app/Http/Requests/V1/Sale/UpdateSaleRequest.php` | 매출 수정 검증 | -| `app/Http/Requests/V1/Purchase/StorePurchaseRequest.php` | 매입 등록 검증 | -| `app/Http/Requests/V1/Purchase/UpdatePurchaseRequest.php` | 매입 수정 검증 | - -### 생성된 컨트롤러 (2개) - -| 파일 | 엔드포인트 | -|------|-----------| -| `SaleController.php` | index, store, show, update, destroy, confirm, summary | -| `PurchaseController.php` | index, store, show, update, destroy, confirm, summary | - -### 수정된 파일 - -**routes/api.php:** -- Sales 라우트 그룹 추가 (7개 라우트) -- Purchases 라우트 그룹 추가 (7개 라우트) - -### 생성된 Swagger 문서 (2개) - -| 파일 | 설명 | -|------|------| -| `app/Swagger/v1/SaleApi.php` | 매출 API 문서 (전체 엔드포인트) | -| `app/Swagger/v1/PurchaseApi.php` | 매입 API 문서 (전체 엔드포인트) | - -### API 엔드포인트 - -**매출 API (Sales):** -- `GET /api/v1/sales` - 목록 조회 -- `POST /api/v1/sales` - 등록 -- `GET /api/v1/sales/{id}` - 상세 조회 -- `PUT /api/v1/sales/{id}` - 수정 -- `DELETE /api/v1/sales/{id}` - 삭제 -- `POST /api/v1/sales/{id}/confirm` - 확정 -- `GET /api/v1/sales/summary` - 요약 - -**매입 API (Purchases):** -- `GET /api/v1/purchases` - 목록 조회 -- `POST /api/v1/purchases` - 등록 -- `GET /api/v1/purchases/{id}` - 상세 조회 -- `PUT /api/v1/purchases/{id}` - 수정 -- `DELETE /api/v1/purchases/{id}` - 삭제 -- `POST /api/v1/purchases/{id}/confirm` - 확정 -- `GET /api/v1/purchases/summary` - 요약 - -### 검증 완료 -- ✅ Pint 스타일 검사 통과 -- ✅ 라우트 등록 확인 (14개) -- ✅ 마이그레이션 실행 성공 -- ✅ Swagger 문서 생성 완료 - ---- - -## 2025-12-13 (금) - Items 테이블 통합 마이그레이션 작성 - -### 작업 목표 -- `docs/plans/items-table-unification-plan.md` 기반 작업 -- products + materials 테이블을 items 단일 테이블로 통합 -- BOM 관리 단순화 (child_item_type + child_item_id → child_item_id만) - -### 생성된 마이그레이션 파일 (6개) - -| 순서 | 파일명 | Phase | 설명 | -|------|--------|-------|------| -| 1 | `2025_12_13_152423_normalize_item_types_before_unification.php` | 0 | 비표준 item_type 삭제 (PRODUCT, SUBASSEMBLY, PART 등) | -| 2 | `2025_12_13_152507_create_items_table.php` | 1.1 | items 테이블 생성 | -| 3 | `2025_12_13_152553_create_item_details_table.php` | 1.2 | item_details 테이블 생성 (1:1 확장 필드) | -| 4 | `2025_12_13_152631_migrate_products_materials_to_items.php` | 1.3 | 데이터 이관 + item_id_mappings 매핑 테이블 | -| 5 | `2025_12_13_153116_update_item_pages_source_table_to_items.php` | 3 | item_pages.source_table 업데이트 | -| 6 | `2025_12_13_153544_update_reference_tables_to_items.php` | 5 | 참조 테이블 item_id 컬럼 추가 및 매핑 | - -### 생성된 모델 (2개) - -**app/Models/Items/Item.php:** -- 통합 품목 모델 (FG, PT, SM, RM, CS) -- BelongsToTenant, SoftDeletes -- 스코프: products(), materials(), type(), active() -- BOM 헬퍼: getBomChildIds(), loadBomChildren() - -**app/Models/Items/ItemDetail.php:** -- 품목 상세 정보 (1:1 관계) -- Products 전용: is_sellable, is_purchasable, is_producible, 파일 필드 -- Materials 전용: is_inspection, specification - -### 생성된 서비스 - -**app/Services/ItemService.php:** -- items 단일 테이블 CRUD -- 동적 필드 → options JSON 병합 -- 카테고리 트리 조회 -- 활성/비활성 토글 - -### 수정된 파일 - -**app/Models/ItemMaster/ItemPage.php:** -- `getTargetModelClass()`: items 테이블 지원 추가 -- `isItemPage()`, `isProductType()`, `isMaterialType()` 헬퍼 추가 - -### ID 매핑 전략 - -**item_id_mappings 테이블:** -- 기존 products/materials ID → 새 items ID 매핑 -- Phase 5 참조 테이블 마이그레이션에서 활용 - -### 참조 테이블 업데이트 대상 (Phase 5) - -| 테이블 | 기존 | 추가 컬럼 | -|--------|------|----------| -| product_components | ref_type + ref_id | item_id, parent_item_id | -| bom_template_items | ref_type + ref_id | item_id | -| orders | product_id | item_id | -| order_items | product_id | item_id | -| material_receipts | material_id | item_id | -| lots | material_id | item_id | -| price_histories | item_type + item_id | new_item_id | -| item_fields | source_table | → 'items' | - -### 실행 명령어 - -```bash -# 마이그레이션 실행 (순서대로) -php artisan migrate - -# 롤백 (전체) -php artisan migrate:rollback --step=6 -``` - -### Phase 6: 마이그레이션 실행 완료 ✅ - -**실행 결과:** -``` -items: 362개 (FG:4, PT:4, RM:133, SM:217, CS:4) -item_details: 362개 (1:1 관계) -item_id_mappings: 362개 (ID 매핑 완료) -item_pages: 47개 → source_table='items'로 통합 -product_components: 4개 중 2개 item_id 매핑 완료 -``` - -**수정 사항:** -- `migrate_products_materials_to_items.php`: null material_code 자동 생성 로직 추가 - -### 다음 작업 (Phase 7 이후) - -- [ ] ItemsController → ItemService 교체 -- [ ] CRUD 테스트 (전체 item_type) -- [ ] BOM 계산 테스트 -- [ ] Item-Master 연동 테스트 -- [ ] 기존 products/materials 테이블 삭제 (확인 후) - -### 참조 문서 -- `docs/plans/items-table-unification-plan.md` -- `docs/INDEX.md` - ---- - -## 2025-12-09 (월) - HR API 개발 완료 (Employee, Attendance, Department Tree) - -### 작업 목표 -- `docs/features/HR_API_ANALYSIS.md` 기반 HR API 구현 -- Employee 관리 API (tenant_user_profiles 활용) -- Attendance 근태 관리 API (attendances 테이블) -- Department 트리 조회 API - -### Phase 1: 마이그레이션 ✅ - -**추가된 마이그레이션:** -- `2025_12_09_084138_add_employee_status_to_tenant_user_profiles_table.php` - - `employee_status` ENUM('active', 'leave', 'resigned') DEFAULT 'active' - - `json_extra` JSON nullable (유연한 사원 정보) - -- `2025_12_09_084231_create_attendances_table.php` - - `user_id`, `base_date`, `status`, `json_details`, `remarks` - - `json_details`: check_in, check_out, gps_data, work_minutes 등 - -### Phase 2: Employee API ✅ - -**수정된 파일:** -- `app/Models/Tenants/TenantUserProfile.php` - employee_status, json_extra 헬퍼 추가 - -**추가된 파일:** -- `app/Services/EmployeeService.php` - 사원 CRUD, 통계, 계정 생성 -- `app/Http/Controllers/Api/V1/EmployeeController.php` -- `app/Http/Requests/Employee/` (5개 FormRequest) - - IndexRequest, StoreRequest, UpdateRequest, BulkDeleteRequest, CreateAccountRequest -- `app/Swagger/v1/EmployeeApi.php` - 8개 엔드포인트 문서 - -**API 엔드포인트 (8개):** -| Method | Endpoint | 설명 | -|--------|----------|------| -| GET | `/v1/employees` | 사원 목록 | -| POST | `/v1/employees` | 사원 등록 | -| GET | `/v1/employees/stats` | 사원 통계 | -| GET | `/v1/employees/{id}` | 사원 상세 | -| PATCH | `/v1/employees/{id}` | 사원 수정 | -| DELETE | `/v1/employees/{id}` | 사원 삭제 | -| POST | `/v1/employees/bulk-delete` | 일괄 삭제 | -| POST | `/v1/employees/{id}/create-account` | 계정 생성 | - -### Phase 3: Department Tree API ✅ - -**수정된 파일:** -- `app/Services/DepartmentService.php` - getTree(), buildTreeNode() 추가 -- `app/Http/Controllers/Api/V1/DepartmentController.php` - tree() 액션 추가 -- `routes/api.php` - `/v1/departments/tree` 라우트 추가 - -### Phase 4: Attendance API ✅ - -**추가된 파일:** -- `app/Models/Tenants/Attendance.php` - 근태 모델 - - BelongsToTenant, SoftDeletes - - json_details 헬퍼 (check_in, check_out, gps_data, work_minutes 등) - - 스코프: onDate, betweenDates, forUser, withStatus - -- `app/Services/AttendanceService.php` - 근태 CRUD, 출퇴근, 월간 통계 -- `app/Http/Controllers/Api/V1/AttendanceController.php` -- `app/Http/Requests/Attendance/` (6개 FormRequest) - - IndexRequest, StoreRequest, UpdateRequest, CheckInRequest, CheckOutRequest, MonthlyStatsRequest -- `app/Swagger/v1/AttendanceApi.php` - 9개 엔드포인트 문서 - -**API 엔드포인트 (9개):** -| Method | Endpoint | 설명 | -|--------|----------|------| -| GET | `/v1/attendances` | 근태 목록 | -| POST | `/v1/attendances` | 근태 등록 | -| GET | `/v1/attendances/monthly-stats` | 월간 통계 | -| POST | `/v1/attendances/check-in` | 출근 기록 | -| POST | `/v1/attendances/check-out` | 퇴근 기록 | -| GET | `/v1/attendances/{id}` | 근태 상세 | -| PATCH | `/v1/attendances/{id}` | 근태 수정 | -| DELETE | `/v1/attendances/{id}` | 근태 삭제 | -| POST | `/v1/attendances/bulk-delete` | 일괄 삭제 | - -### 검증 결과 -- ✅ Pint 코드 포맷팅 완료 (13개 파일 수정) -- ✅ 마이그레이션 실행 완료 (Batch 48) -- ✅ 라우트 등록 확인 (Employee 8개, Attendance 9개, Department Tree 1개) -- ✅ Swagger 문서 생성 완료 - -### 수정된 파일 목록 - -**routes/api.php:** -- EmployeeController, AttendanceController import 추가 -- Employee API 라우트 그룹 (8개) -- Attendance API 라우트 그룹 (9개) -- Department /tree 라우트 추가 - -**버그 수정:** -- `app/Models/Tenants/Attendance.php` - BelongsToTenant 경로 수정 - - `App\Models\Scopes\BelongsToTenant` → `App\Traits\BelongsToTenant` - -### 다음 작업 -- [ ] React 프론트엔드 연동 -- [ ] 휴가 관리 API 구현 (향후) - ---- - -## 2025-12-08 (일) - Flow Tester Error #61 해결 (GET query 파라미터 처리) - -### 문제 -- `GET /pricing/cost` 요청 시 422 에러 발생 -- 에러: `item_id`와 `item_type_code`가 필수 항목인데 누락 - -### 원인 -- Flow 정의에 `query` 필드로 파라미터 정의되어 있음 -- **FlowExecutor**가 `query` 필드를 처리하지 않고 `body`만 처리 -- GET 요청은 query string으로 파라미터를 전달해야 하는데 누락됨 - -### 해결 -**수정 파일**: `mng/app/Services/FlowTester/FlowExecutor.php` - -```php -// Line 226: query 변수 바인딩 추가 -$query = $this->binder->bind($step['query'] ?? []); - -// Line 230-234: HTTP 요청에 query 옵션 전달 -$response = $this->httpClient->request($method, $endpoint, [ - 'headers' => $headers, - 'body' => $body, - 'query' => $query, // 추가 -]); - -// 결과 로그에도 query 정보 포함 -'request' => [ - 'method' => $method, - 'endpoint' => $endpoint, - 'headers' => $headers, - 'body' => $body, - 'query' => $query, // 추가 -], -``` - -### 검증 -- PHP 문법 검사: ✅ 통과 - ---- - -## 2025-12-08 (일) - Flow Tester Error #60 해결 (중복 테스트 데이터 삭제) - -### 문제 -- Flow Tester 재실행 시 `error.duplicate_key` 오류 발생 -- 이전 테스트 실행(Error #59)이 중간에 실패하면서 테스트 데이터가 남아있음 -- 동적 날짜(`{{$date}}`)가 정상 작동 중이지만, 같은 날 재실행 시 중복 발생 - -### 원인 -- Error #59 실행 시 create_price 단계까지 진행 후 실패 -- cleanup 단계(delete_price)에 도달하지 못해 테스트 데이터 잔류 -- checkDuplicate() 메서드가 기존 데이터 발견 - -### 해결 -```bash -# 잔류 테스트 데이터 삭제 -docker exec -i sam-api-1 php artisan tinker --execute=" - use App\Models\Products\Price; - Price::where('effective_from', '2025-12-08')->delete(); -" -# 결과: 1건 삭제 완료 -``` - -### 추가 권장사항 -- Flow Tester 재실행 전 cleanup 또는 setup 스크립트 추가 고려 -- 또는 effective_from에 `{{$uuid}}`나 `{{$timestamp}}`를 조합하여 고유성 보장 - ---- - -## 2025-12-08 (일) - User 모델 경로 오류 수정 (500 에러 해결) - -### 문제 -- Flow Tester #59 실행 시 `GET /pricing/{id}/revisions` 에서 500 에러 -- 에러: `Class "App\Models\User" not found` - -### 원인 -- `PriceRevision.php`, `Board.php`에서 잘못된 User 모델 경로 참조 -- 실제 경로: `App\Models\Members\User` - -### 수정된 파일 -- `app/Models/Products/PriceRevision.php` - `\App\Models\User` → `\App\Models\Members\User` -- `app/Models/Boards/Board.php` - `use App\Models\User` → `use App\Models\Members\User` - ---- - -## 2025-12-08 (일) - Flow Tester 동적 날짜 변수 적용 (duplicate key 해결) - -### 문제 -- Flow Tester #58 실행 시 `error.duplicate_key` 에러 발생 -- 원인: 하드코딩된 `effective_from: "2025-01-01"`로 인해 이전 테스트 데이터와 중복 - -### 해결 -`{{$date}}` 동적 변수를 사용하여 매번 테스트 시 오늘 날짜 사용 - -### 수정 내용 (Flow ID: 8 - 단가 관리 CRUD 테스트) - -| 스텝 | 필드 | 변경 전 | 변경 후 | -|------|------|---------|---------| -| create_price | effective_from | "2025-01-01" | "{{$date}}" | -| create_price | effective_to | "2025-12-31" | "2099-12-31" | -| create_price_for_finalize | effective_from | "2025-01-01" | "{{$date}}" | -| get_cost | date | "2025-06-15" | "{{$date}}" | -| by_items | date | "2025-06-15" | "{{$date}}" | - -### VariableBinder 지원 변수 - -| 변수 | 설명 | 예시 | -|------|------|------| -| `{{$date}}` | 현재 날짜 (Y-m-d) | 2025-12-08 | -| `{{$datetime}}` | 현재 날짜시간 (Y-m-d H:i:s) | 2025-12-08 14:30:00 | -| `{{$timestamp}}` | Unix 타임스탬프 | 1733637000 | -| `{{$uuid}}` | 랜덤 UUID | 550e8400-e29b-41d4-a716-... | -| `{{$random:N}}` | N자리 랜덤 숫자 | 123456 | -| `{{$faker.xxx}}` | Faker 랜덤 데이터 | 회사명, 이름 등 | - -### 해결 원리 -- 매번 테스트 시 오늘 날짜가 사용되어 다른 날의 중복 데이터와 충돌 없음 -- 플로우 마지막에 `delete_price`와 `cleanup_finalized` 스텝이 테스트 데이터 정리 -- 별도의 cleanup 스텝 없이도 반복 실행 가능 - ---- - -## 2025-12-08 (일) - Flow Tester HTTP 상태 코드 수정 - -### 문제 -- Flow Tester `POST /pricing` 요청에서 예상 상태 코드 201, 실제 200 반환 -- HTTP 표준: POST 리소스 생성 시 201 Created 반환 필요 - -### 수정 내용 - -**ApiResponse 클래스 개선:** -- `ApiResponse::success()`: `$statusCode` 파라미터 추가 (기본값 200) -- `ApiResponse::handle()`: 콜백에서 `statusCode` 키로 상태 코드 지정 가능 - -**PricingController 수정:** -- `store()` 메서드: `'statusCode' => 201` 반환 - -### 수정된 파일 -- `app/Helpers/ApiResponse.php` - 상태 코드 파라미터 추가 -- `app/Http/Controllers/Api/V1/PricingController.php` - store 201 반환 - -### 사용 예시 -```php -// Controller에서 201 반환 -return ApiResponse::handle(function () use ($request) { - $data = $this->service->store($request->validated()); - return ['data' => $data, 'message' => __('message.created'), 'statusCode' => 201]; -}); -``` - -### 검증 필요 -- [x] Flow Tester 재실행하여 201 응답 확인 ✅ -- [x] duplicate key 에러 해결 (동적 날짜 변수 적용) ✅ - -### 남은 작업 (TODO) -다른 Controller store 메서드 일괄 수정 (27개): -- AdminController, BoardController, CategoryController, CategoryFieldController -- CategoryTemplateController, ClassificationController, ClientGroupController -- CommonController, DesignModelController, EstimateController, FolderController -- ItemsController, MaterialController, MenuController, ModelSetController -- PostController, QuoteController, RoleController, TenantController -- TenantOptionGroupController, TenantOptionValueController, TenantStatFieldController -- ItemMaster 하위: CustomTabController, ItemBomItemController, ItemFieldController -- ItemMaster 하위: ItemSectionController, UnitOptionController - ---- - -## 2025-12-08 (일) - 단가 관리 API 전면 재설계 (prices + price_revisions) - -### 작업 목표 -- `price_histories` → `prices` + `price_revisions` 구조로 전면 재설계 -- 원가 조회 시 수입검사 입고단가 우선, 표준원가 폴백 로직 구현 -- 가격 확정(finalize) 기능으로 불변성 보장 -- 리비전 관리로 변경 이력 추적 - -### 테이블 구조 변경 - -**prices (단가 마스터):** -| 컬럼 | 설명 | -|------|------| -| item_type_code | 품목유형 (PRODUCT/MATERIAL) | -| item_id | 품목 ID | -| client_group_id | 고객그룹 ID (NULL=기본가) | -| purchase_price | 매입단가 (표준원가) | -| processing_cost | 가공비 | -| loss_rate | LOSS율 (%) | -| margin_rate | 마진율 (%) | -| sales_price | 판매단가 | -| rounding_rule | 반올림 규칙 (round/ceil/floor) | -| rounding_unit | 반올림 단위 (1,10,100,1000) | -| effective_from/to | 적용 기간 | -| status | 상태 (draft/active/inactive/finalized) | -| is_final | 최종 확정 여부 | - -**price_revisions (변경 이력):** -| 컬럼 | 설명 | -|------|------| -| price_id | 단가 FK | -| revision_number | 리비전 번호 | -| changed_at | 변경 일시 | -| changed_by | 변경자 ID | -| change_reason | 변경 사유 | -| before_snapshot | 변경 전 JSON | -| after_snapshot | 변경 후 JSON | - -### 생성된 파일 - -**마이그레이션 (4개):** -- `2025_12_08_154633_create_prices_table.php` -- `2025_12_08_154634_create_price_revisions_table.php` -- `2025_12_08_154635_migrate_price_histories_to_prices.php` -- `2025_12_08_154636_drop_price_histories_table.php` - -**모델 (2개):** -- `app/Models/Price.php` - BelongsToTenant, SoftDeletes -- `app/Models/PriceRevision.php` - -**서비스 (1개):** -- `app/Services/PricingService.php` - - index, show, store, update, destroy (CRUD) - - byItems: 다중 품목 단가 조회 - - revisions: 변경 이력 조회 - - finalize: 가격 확정 (불변 처리) - - getCost: 원가 조회 (receipt > standard 폴백) - -**FormRequest (5개):** -- `app/Http/Requests/Pricing/PriceIndexRequest.php` -- `app/Http/Requests/Pricing/PriceStoreRequest.php` -- `app/Http/Requests/Pricing/PriceUpdateRequest.php` -- `app/Http/Requests/Pricing/PriceByItemsRequest.php` -- `app/Http/Requests/Pricing/PriceCostRequest.php` - -**Swagger (1개):** -- `app/Swagger/v1/PricingApi.php` - 전면 재작성 - -### 삭제된 파일 -- `app/Models/PriceHistory.php` - -### API 엔드포인트 (9개) - -| Method | Endpoint | 설명 | -|--------|----------|------| -| GET | `/api/v1/pricing` | 단가 목록 (페이지네이션) | -| POST | `/api/v1/pricing` | 단가 생성 | -| GET | `/api/v1/pricing/cost` | 원가 조회 (receipt > standard) | -| POST | `/api/v1/pricing/by-items` | 다중 품목 단가 조회 | -| GET | `/api/v1/pricing/{id}` | 단가 상세 | -| PUT | `/api/v1/pricing/{id}` | 단가 수정 | -| DELETE | `/api/v1/pricing/{id}` | 단가 삭제 | -| POST | `/api/v1/pricing/{id}/finalize` | 가격 확정 | -| GET | `/api/v1/pricing/{id}/revisions` | 변경 이력 조회 | - -### 원가 조회 로직 (getCost) - -``` -1순위: 자재인 경우 → material_receipts.purchase_price_excl_vat - (수입검사 완료된 최신 입고단가) - -2순위: prices.purchase_price (표준원가) - -총원가 계산: - total_cost = (purchase_price + processing_cost) × (1 + loss_rate/100) - -판매가 계산: - sales_price = round(total_cost × (1 + margin_rate/100), rounding_unit, rounding_rule) -``` - -### 검증 결과 -- PHP 문법 검사: ✅ 모든 파일 통과 -- Pint 코드 포맷팅: ✅ 14개 파일 수정 완료 -- Swagger 문서 생성: ✅ 완료 - -### 다음 작업 -- [ ] 마이그레이션 실행 (php artisan migrate) -- [ ] API 테스트 (Swagger UI) -- [ ] React 프론트엔드 연동 - -### 참조 문서 -- `docs/front/[API-2025-12-08] pricing-api-enhancement-request.md` -- `docs/rules/pricing-policy.md` - ---- - -## 2025-12-04 (수) - 견적 API Phase 3: Controller + FormRequest + Routes + Swagger 완료 - -### 작업 목표 -- 견적 API Phase 3 Controller Layer 구현 -- 16개 API 엔드포인트 구현 완료 - -### 생성된 파일 - -**Controller (1개):** -- `app/Http/Controllers/Api/V1/QuoteController.php` - - 16개 메서드: index, show, store, update, destroy, bulkDestroy, finalize, cancelFinalize, convertToOrder, previewNumber, calculate, calculationSchema, generatePdf, sendEmail, sendKakao, sendHistory - - 4개 Service DI: QuoteService, QuoteNumberService, QuoteCalculationService, QuoteDocumentService - - ApiResponse::handle() 패턴 적용 - -**FormRequest (7개):** - -| 파일 | 설명 | -|------|------| -| `QuoteIndexRequest.php` | 목록 조회 파라미터 검증 | -| `QuoteStoreRequest.php` | 견적 생성 검증 (items 배열 포함) | -| `QuoteUpdateRequest.php` | 견적 수정 검증 | -| `QuoteBulkDeleteRequest.php` | 일괄 삭제 IDs 검증 | -| `QuoteCalculateRequest.php` | 자동산출 입력값 검증 | -| `QuoteSendEmailRequest.php` | 이메일 발송 검증 | -| `QuoteSendKakaoRequest.php` | 카카오 발송 검증 | - -**Swagger (1개):** -- `app/Swagger/v1/QuoteApi.php` - - 12개 스키마: Quote, QuoteItem, QuotePagination, QuoteCreateRequest, QuoteUpdateRequest 등 - - 16개 엔드포인트 문서화 - -### API 엔드포인트 (16개) - -| Method | Endpoint | 설명 | -|--------|----------|------| -| GET | `/api/v1/quotes` | 견적 목록 (페이지네이션) | -| POST | `/api/v1/quotes` | 견적 생성 | -| GET | `/api/v1/quotes/number/preview` | 견적번호 미리보기 | -| POST | `/api/v1/quotes/calculate` | 자동산출 | -| GET | `/api/v1/quotes/calculate/schema` | 산출 스키마 조회 | -| DELETE | `/api/v1/quotes/bulk` | 일괄 삭제 | -| GET | `/api/v1/quotes/{id}` | 견적 상세 | -| PUT | `/api/v1/quotes/{id}` | 견적 수정 | -| DELETE | `/api/v1/quotes/{id}` | 견적 삭제 | -| POST | `/api/v1/quotes/{id}/finalize` | 확정 | -| POST | `/api/v1/quotes/{id}/cancel-finalize` | 확정 취소 | -| POST | `/api/v1/quotes/{id}/convert` | 주문 전환 | -| GET | `/api/v1/quotes/{id}/pdf` | PDF 생성 | -| POST | `/api/v1/quotes/{id}/send/email` | 이메일 발송 | -| POST | `/api/v1/quotes/{id}/send/kakao` | 카카오 발송 | -| GET | `/api/v1/quotes/{id}/send/history` | 발송 이력 | - -### 검증 결과 -- PHP 문법 검사: ✅ 9개 파일 통과 -- Pint 코드 포맷팅: ✅ 완료 -- Swagger 문서 생성: ✅ 완료 -- 라우트 등록: ✅ 16개 라우트 확인 - -### 다음 작업 (Phase 4) -- [ ] 단위 테스트 작성 -- [ ] 통합 테스트 작성 -- [ ] 마이그레이션 실행 및 실제 데이터 검증 - ---- - -## 2025-12-04 (수) - 견적 API Phase 2: Service Layer 구현 완료 - -### 작업 목표 -- 견적 API Phase 2 Service Layer 구현 -- 5개 Service 파일 생성 완료 - -### 생성된 파일 - -**Service Layer (5개):** - -| 파일 | 설명 | 주요 기능 | -|------|------|----------| -| `QuoteService.php` | 견적 CRUD + 상태관리 | index, show, store, update, destroy, bulkDestroy, finalize, cancelFinalize, convertToOrder | -| `QuoteNumberService.php` | 견적번호 채번 | generate, preview, validate, parse, isUnique | -| `FormulaEvaluatorService.php` | 수식 평가 엔진 | validateFormula, evaluate, evaluateMultiple, evaluateRange, evaluateMapping | -| `QuoteCalculationService.php` | 견적 자동산출 | calculate, preview, recalculate, getInputSchema | -| `QuoteDocumentService.php` | 문서 생성/발송 | generatePdf, sendEmail, sendKakao, getSendHistory | - -### 견적번호 형식 -``` -KD-{PREFIX}-{YYMMDD}-{SEQ} -예: KD-SC-251204-01 (스크린), KD-ST-251204-01 (철재) -``` - -### FormulaEvaluatorService 지원 함수 -- 수학: `SUM`, `ROUND`, `CEIL`, `FLOOR`, `ABS`, `MIN`, `MAX` -- 논리: `IF`, `AND`, `OR`, `NOT` - -### QuoteCalculationService 입력 스키마 - -**공통 입력:** -- `W0`: 개구부 폭 (mm) -- `H0`: 개구부 높이 (mm) -- `QTY`: 수량 - -**스크린 제품 추가:** -- `INSTALL_TYPE`: 설치 유형 (wall/ceiling/floor) -- `MOTOR_TYPE`: 모터 유형 (standard/heavy) -- `CONTROL_TYPE`: 제어 방식 (switch/remote/smart) -- `CHAIN_SIDE`: 체인 위치 (left/right) - -**철재 제품 추가:** -- `MATERIAL`: 재질 (ss304/ss316/galvanized) -- `THICKNESS`: 두께 (mm) -- `FINISH`: 표면처리 (hairline/mirror/matte) -- `WELDING`: 용접 방식 (tig/mig/spot) - -### i18n 키 추가 - -**에러 메시지 (error.php):** -- `quote_not_found`, `quote_not_editable`, `quote_not_deletable` -- `quote_not_finalizable`, `quote_not_finalized`, `quote_already_converted` -- `quote_not_convertible`, `quote_email_not_found`, `quote_phone_not_found` -- `formula_empty`, `formula_parentheses_mismatch`, `formula_unsupported_function`, `formula_calculation_error` - -**성공 메시지 (message.php):** -- `quote.fetched`, `quote.created`, `quote.updated`, `quote.deleted` -- `quote.bulk_deleted`, `quote.finalized`, `quote.finalize_cancelled` -- `quote.converted`, `quote.calculated`, `quote.pdf_generated` -- `quote_email_sent`, `quote_kakao_sent` - -### 검증 결과 -- PHP 문법 검사: ✅ 5개 파일 통과 -- Pint 코드 포맷팅: ✅ 완료 - -### 다음 작업 (Phase 3) -- [ ] QuoteController.php 생성 -- [ ] FormRequest 생성 (QuoteStoreRequest, QuoteUpdateRequest 등) -- [ ] Swagger 문서 작성 (QuoteApi.php) -- [ ] 라우트 등록 - ---- - -## 2025-12-04 (수) - 거래처 API 2차 필드 추가 및 견적 API 계획 업데이트 - -### 작업 목표 -- 거래처 API에 2차 필드 추가 (17개 신규 필드) -- 견적 API 변경사항 분석 및 계획 문서 업데이트 - -### 거래처 API 2차 필드 추가 - -**추가된 필드 (7개 섹션, 20개 필드):** - -| 섹션 | 필드 | 설명 | -|------|------|------| -| 거래처 유형 | `client_type` | 매입/매출/매입매출 | -| 연락처 | `mobile`, `fax` | 모바일, 팩스 | -| 담당자 | `manager_name`, `manager_tel`, `system_manager` | 담당자 정보 | -| 발주처 설정 | `account_id`, `account_password`, `purchase_payment_day`, `sales_payment_day` | 계정 및 결제일 | -| 약정 세금 | `tax_agreement`, `tax_amount`, `tax_start_date`, `tax_end_date` | 세금 약정 정보 | -| 악성채권 | `bad_debt`, `bad_debt_amount`, `bad_debt_receive_date`, `bad_debt_end_date`, `bad_debt_progress` | 채권 정보 | -| 기타 | `memo` | 메모 | - -**수정된 파일:** -- `database/migrations/2025_12_04_205603_add_extended_fields_to_clients_table.php` (NEW) -- `app/Models/Orders/Client.php` - fillable, casts, hidden 업데이트 -- `app/Http/Requests/Client/ClientStoreRequest.php` - 검증 규칙 추가 -- `app/Http/Requests/Client/ClientUpdateRequest.php` - 검증 규칙 추가 -- `app/Services/ClientService.php` - store/update 검증 추가 -- `app/Swagger/v1/ClientApi.php` - 3개 스키마 업데이트 - -### 견적 API 계획 업데이트 - -**신규 요청 - 문서 발송 API (Section 3.5):** - -| Method | Endpoint | 설명 | -|--------|----------|------| -| POST | `/api/v1/quotes/{id}/send/email` | 이메일 발송 | -| POST | `/api/v1/quotes/{id}/send/fax` | 팩스 발송 | -| POST | `/api/v1/quotes/{id}/send/kakao` | 카카오톡 발송 | - -**계획 문서 업데이트 내용:** -- Phase 2: `QuoteDocumentService` 추가 -- Phase 3: `QuoteSendEmailRequest`, `QuoteSendFaxRequest`, `QuoteSendKakaoRequest` 추가 -- Service 5개, FormRequest 8개로 조정 - -### Git 커밋 -``` -commit d164bb4 -feat: [client] 거래처 API 2차 필드 추가 및 견적 계획 업데이트 -``` - -### 다음 작업 -- 견적 API Phase 2: Service Layer 구현 - ---- - -## 2025-12-04 (수) - 견적수식 시드 데이터 구현 - -### 작업 목표 -- design/src/components/utils/formulaSampleData.ts의 데이터를 MNG에서 관리할 수 있도록 시드 데이터 구현 -- 26개 수식 규칙, 11개 카테고리를 DB에 입력 - -### 추가된 파일 - -**Seeder (2개):** -- `database/seeders/QuoteFormulaCategorySeeder.php` - - 11개 카테고리 시드 (OPEN_SIZE, MAKE_SIZE, AREA, WEIGHT, GUIDE_RAIL, CASE, MOTOR, CONTROLLER, EDGE_WING, INSPECTION, PRICE_FORMULA) - - updateOrInsert 패턴으로 멱등성 보장 - -- `database/seeders/QuoteFormulaSeeder.php` - - 29개 수식 시드 (input 2개, calculation 18개, range 3개, mapping 1개, 단가수식 8개) - - 8개 범위 데이터 (quote_formula_ranges) - - 카테고리 코드 → ID 매핑으로 FK 참조 - -### 시드 데이터 상세 - -| 카테고리 | 코드 | 수식 수 | 설명 | -|----------|------|---------|------| -| 오픈사이즈 | OPEN_SIZE | 2 | W0, H0 입력 | -| 제작사이즈 | MAKE_SIZE | 4 | W1/H1 (스크린/철재) | -| 면적 | AREA | 1 | W1 × H1 / 1000000 | -| 중량 | WEIGHT | 2 | 스크린/철재 중량 계산 | -| 가이드레일 | GUIDE_RAIL | 5 | 길이, 자동선택, 설치유형별 수량 | -| 케이스 | CASE | 3 | 사이즈, 자재 자동선택 | -| 모터 | MOTOR | 1 | 중량 기반 자동선택 | -| 제어기 | CONTROLLER | 1 | 유형별 자동선택 | -| 마구리 | EDGE_WING | 1 | 날개 수량 계산 | -| 검사 | INSPECTION | 1 | 검사비 고정 | -| 단가수식 | PRICE_FORMULA | 8 | 품목별 단가 계산 | - -### 실행 명령어 - -```bash -# 순서대로 실행 -php artisan db:seed --class=QuoteFormulaCategorySeeder -php artisan db:seed --class=QuoteFormulaSeeder -``` - -### 검증 결과 -- 카테고리: 11개 생성 완료 ✅ -- 수식: 29개 생성 완료 ✅ -- 범위 데이터: 8개 생성 완료 ✅ - -### 참조 문서 -- `mng/docs/QUOTE_FORMULA_SEED_PLAN.md` - 구현 계획서 -- `design/src/components/utils/formulaSampleData.ts` - 소스 데이터 - ---- - -## 2025-12-02 (월) - 메뉴 통합관리 시스템 구현 (Phase 1-2) - -### 작업 목표 -- 글로벌 메뉴-테넌트 메뉴 연결 시스템 구현 -- Phase 1: DB 스키마 변경 및 모델 수정 -- Phase 2: 서비스 및 API 엔드포인트 개발 - -### Phase 1 완료 (DB 스키마 및 모델) - -**추가된 파일:** -- `database/migrations/2025_12_02_100000_add_global_menu_link_columns_to_menus_table.php` - - `global_menu_id` 컬럼: 원본 글로벌 메뉴 ID - - `is_customized` 컬럼: 테넌트 커스터마이징 여부 - - 인덱스 추가 - -**수정된 파일:** -- `app/Models/Commons/Menu.php` - - fillable: `global_menu_id`, `is_customized` 추가 - - casts: `is_customized => boolean` 등 추가 - - 관계 메서드: `globalMenu()`, `tenantMenus()` - - 헬퍼 메서드: `isGlobal()`, `isClonedFromGlobal()`, `isCustomized()` - - 스코프: `scopeGlobal()`, `scopeActive()`, `scopeVisible()`, `scopeRoots()` - - `getSyncFields()`: 동기화 비교 대상 필드 목록 - -- `app/Services/MenuBootstrapService.php` - - `cloneGlobalMenusForTenant()`: global_menu_id 저장 추가 - - 활성 메뉴만 복제 (is_active=true) - -### Phase 2 완료 (서비스 및 API) - -**추가된 파일:** -- `app/Services/GlobalMenuService.php` (신규) - - 글로벌 메뉴 CRUD (tenant_id = NULL) - - `syncToAllTenants()`: 특정 메뉴를 모든 테넌트에 동기화 - - `stats()`: 글로벌 메뉴 통계 - -- `app/Services/MenuSyncService.php` (신규) - - 동기화 상태 상수: NEW, UP_TO_DATE, UPDATABLE, CUSTOMIZED, DELETED - - `getSyncStatus()`: 동기화 상태 목록 조회 - - `syncMenus()`: 선택 동기화 (신규/업데이트) - - `importNewMenus()`: 신규 글로벌 메뉴 일괄 가져오기 - - `syncUpdates()`: 변경된 메뉴 일괄 업데이트 (커스텀 제외) - - `getAvailableGlobalMenus()`: 복제 가능한 글로벌 메뉴 목록 - -- `app/Http/Controllers/Api/Admin/GlobalMenuController.php` (신규) - - 시스템 관리자용 글로벌 메뉴 관리 - - index, tree, show, store, update, destroy, reorder - - syncToTenants, stats - -**수정된 파일:** -- `app/Services/MenuService.php` - - `update()`: 글로벌 복제 메뉴 수정 시 is_customized=true 자동 설정 - - `restore()`: 삭제된 메뉴 복원 추가 - - `trashedList()`: 삭제된 메뉴 목록 조회 추가 - -- `app/Http/Controllers/Api/V1/MenuController.php` - - MenuSyncService DI 추가 - - restore, trashed, availableGlobal, syncStatus, sync, syncNew, syncUpdates 메서드 추가 - -- `routes/api.php` - - GlobalMenuController use 문 추가 - - 테넌트 메뉴 동기화 라우트 6개 추가 (trashed, available-global, sync-status, sync, sync-new, sync-updates, restore) - - 글로벌 메뉴 관리 라우트 9개 추가 (admin/global-menus/*) - -### API 엔드포인트 - -**테넌트 메뉴 동기화 (V1):** -| Method | Path | 설명 | -|--------|------|------| -| GET | /v1/menus/trashed | 삭제된 메뉴 목록 | -| GET | /v1/menus/available-global | 복제 가능한 글로벌 메뉴 | -| GET | /v1/menus/sync-status | 동기화 상태 조회 | -| POST | /v1/menus/sync | 선택 동기화 | -| POST | /v1/menus/sync-new | 신규 메뉴 일괄 가져오기 | -| POST | /v1/menus/sync-updates | 변경된 메뉴 일괄 업데이트 | -| POST | /v1/menus/{id}/restore | 삭제된 메뉴 복원 | - -**글로벌 메뉴 관리 (Admin):** -| Method | Path | 설명 | -|--------|------|------| -| GET | /v1/admin/global-menus | 글로벌 메뉴 목록 | -| POST | /v1/admin/global-menus | 글로벌 메뉴 생성 | -| GET | /v1/admin/global-menus/tree | 글로벌 메뉴 트리 | -| GET | /v1/admin/global-menus/stats | 통계 조회 | -| POST | /v1/admin/global-menus/reorder | 순서 변경 | -| GET | /v1/admin/global-menus/{id} | 단건 조회 | -| PUT | /v1/admin/global-menus/{id} | 수정 | -| DELETE | /v1/admin/global-menus/{id} | 삭제 | -| POST | /v1/admin/global-menus/{id}/sync-to-tenants | 모든 테넌트에 동기화 | - -### 검증 결과 -- PHP 문법 검사: ✅ 모든 파일 통과 -- 라우트 등록: ✅ 9개 글로벌 메뉴 + 7개 테넌트 동기화 라우트 확인 - -### 다음 작업 (Phase 3-4) -- [ ] Phase 3: MNG 글로벌 메뉴 관리 화면 -- [ ] Phase 3: MNG 동기화 센터 화면 -- [ ] Phase 4: 마이그레이션 실행 및 테스트 - ---- - -## 2025-12-01 (일) - 메뉴 통합관리 시스템 설계 - -### 작업 목표 -- PDF 기획서(SAM_ERP_인사관리전자결재_Storyboard)에서 메뉴 추출 -- 글로벌 메뉴와 테넌트 메뉴 간의 연결(링크) 시스템 설계 -- 메뉴 추가 SQL 쿼리 생성 - -### 추가된 파일 -- `claudedocs/MENU_INTEGRATION_SYSTEM_DESIGN.md` (신규) - - 글로벌-테넌트 메뉴 연결 시스템 설계서 - - global_menu_id, is_customized 컬럼 추가 계획 - - API 엔드포인트 설계 (글로벌/테넌트 메뉴 관리) - - MNG 화면 설계 (복제, 동기화 기능) - - 구현 Phase 1~4 계획 - -- `claudedocs/MENU_INSERT_QUERIES.sql` (신규) - - PDF 기획서 기반 신규 메뉴 23개 INSERT 쿼리 - - 인사관리 (근태/휴가/급여) - - 전자결재 (기안함/결재함/참조함) - - 게시판, 보고서, 계정정보, 회사정보, 구독관리, 결제내역, 고객센터 - - 기준정보 관리 하위 8개 메뉴 - -### 정책 결정 사항 -| 항목 | 결정 내용 | -|------|----------| -| 글로벌 메뉴 삭제 시 | 테넌트 메뉴 유지 (global_menu_id = NULL) | -| 활성 메뉴 (is_active=1) | 새 테넌트 생성 시 자동 복사 | -| 비활성 메뉴 (is_active=0) | 테넌트가 수동으로 복제 가능 | -| 숨김 메뉴 (hidden=1) | 복사되지만 테넌트에서 안 보임 | -| 기존 데이터 | 신규 테넌트부터 적용 | - -### 다음 작업 (Phase별) -- [ ] Phase 1: 마이그레이션 (global_menu_id, is_customized) -- [ ] Phase 1: Menu 모델 수정 -- [ ] Phase 1: MenuBootstrapService 수정 -- [ ] Phase 2: GlobalMenuService 생성 -- [ ] Phase 2: MenuService 메서드 추가 -- [ ] Phase 2: API 엔드포인트 추가 -- [ ] Phase 3: MNG 글로벌 메뉴 관리 화면 -- [ ] Phase 3: MNG 테넌트 메뉴 관리 화면 개선 -- [ ] Phase 4: 테스트 - -### Git 커밋 -``` -commit d7fdfa8 -docs: 메뉴 통합관리 시스템 설계서 및 SQL 쿼리 추가 -``` - -### 참고 문서 -- PDF: SAM_ERP_인사관리전자결재_Storyboard_D0.6_251201.pdf -- 설계서: claudedocs/MENU_INTEGRATION_SYSTEM_DESIGN.md - ---- - -## 2025-11-27 (수) - 시스템 게시판 기능 확장 - -### 작업 목표 -- 기존 boards 테이블에 시스템 게시판 지원 추가 -- mng에서 시스템 게시판 생성, sam에서 테넌트 게시판 + 시스템 게시판 조회 - -### 수정된 파일 - -**Migration**: -- `database/migrations/2025_11_27_205429_add_system_fields_to_boards_table.php` (NEW) - - `tenant_id` nullable 변경 - - `is_system` boolean 컬럼 추가 - - `board_type` VARCHAR(50) 컬럼 추가 - - `deleted_at`, `deleted_by` SoftDeletes 추가 - - 인덱스 추가 - -**Model**: -- `app/Models/Boards/Board.php` - - SoftDeletes 추가 - - fillable, casts 업데이트 - - `scopeAccessible(tenantId)`, `scopeSystemOnly()`, `scopeTenantOnly(tenantId)` 스코프 추가 - - 권한 헬퍼 메서드 (canRead, canWrite, canManage) - -- `app/Models/Boards/BoardSetting.php` - - casts, fillable 업데이트 - - getMeta() 헬퍼 추가 - -**Service**: -- `app/Services/Boards/BoardService.php` (NEW) - - 시스템/테넌트 게시판 CRUD - - 필드 관리 (add, update, delete, reorder) - - 유틸리티 메서드 - -### DB 스키마 변경 - -```sql --- boards 테이블 추가 컬럼 -is_system TINYINT(1) DEFAULT 0 COMMENT '시스템 게시판 여부' -board_type VARCHAR(50) NULL COMMENT '게시판 유형' -deleted_at TIMESTAMP NULL -deleted_by BIGINT UNSIGNED NULL -``` - -### 다음 작업 -- mng 게시판 관리 화면 개발 ✅ -- sam API 개발 (Swagger 포함) ✅ -- 검증 및 테스트 ✅ - ---- - -## 2025-11-27 (수) - sam API 게시판/게시글 API 개발 - -### 작업 목표 -- 테넌트용 게시판/게시글 V1 API 개발 -- Swagger 문서 작성 - -### 추가된 파일 - -**Service**: -- `app/Services/Boards/PostService.php` (NEW) - - 게시글 CRUD (boardCode 기반) - - 댓글 CRUD - - 커스텀 필드 관리 - - 조회수 증가 - -**Controller**: -- `app/Http/Controllers/Api/V1/BoardController.php` (NEW) - - index: 접근 가능한 게시판 목록 (시스템 + 테넌트) - - tenantBoards: 테넌트 게시판만 - - show: 게시판 상세 (코드 기반) - - store/update/destroy: 테넌트 게시판 CRUD - - fields: 게시판 필드 목록 - -- `app/Http/Controllers/Api/V1/PostController.php` (NEW) - - index/show/store/update/destroy: 게시글 CRUD - - comments/storeComment/updateComment/destroyComment: 댓글 CRUD - -**FormRequest**: -- `app/Http/Requests/Boards/BoardStoreRequest.php` (NEW) -- `app/Http/Requests/Boards/BoardUpdateRequest.php` (NEW) -- `app/Http/Requests/Boards/PostStoreRequest.php` (NEW) -- `app/Http/Requests/Boards/PostUpdateRequest.php` (NEW) -- `app/Http/Requests/Boards/CommentStoreRequest.php` (NEW) - -**Swagger**: -- `app/Swagger/v1/BoardApi.php` (NEW) - - Board, BoardField 스키마 - - BoardCreateRequest, BoardUpdateRequest 스키마 - - 7개 엔드포인트 문서화 - -- `app/Swagger/v1/PostApi.php` (NEW) - - Post, PostPagination, Comment 스키마 - - PostCreateRequest, PostUpdateRequest, CommentCreateRequest 스키마 - - 9개 엔드포인트 문서화 - -### 수정된 파일 -- `routes/api.php` - - BoardController, PostController import 추가 - - /v1/boards 프리픽스로 16개 라우트 등록 - -### API 엔드포인트 (16개) - -**게시판 관리**: -| Method | Path | 설명 | -|--------|------|------| -| GET | /v1/boards | 접근 가능한 게시판 목록 | -| GET | /v1/boards/tenant | 테넌트 게시판만 | -| POST | /v1/boards | 테넌트 게시판 생성 | -| GET | /v1/boards/{code} | 게시판 상세 (코드 기반) | -| PUT | /v1/boards/{id} | 테넌트 게시판 수정 | -| DELETE | /v1/boards/{id} | 테넌트 게시판 삭제 | -| GET | /v1/boards/{code}/fields | 게시판 필드 목록 | - -**게시글 관리**: -| Method | Path | 설명 | -|--------|------|------| -| GET | /v1/boards/{code}/posts | 게시글 목록 | -| POST | /v1/boards/{code}/posts | 게시글 작성 | -| GET | /v1/boards/{code}/posts/{id} | 게시글 상세 | -| PUT | /v1/boards/{code}/posts/{id} | 게시글 수정 | -| DELETE | /v1/boards/{code}/posts/{id} | 게시글 삭제 | - -**댓글 관리**: -| Method | Path | 설명 | -|--------|------|------| -| GET | /v1/boards/{code}/posts/{postId}/comments | 댓글 목록 | -| POST | /v1/boards/{code}/posts/{postId}/comments | 댓글 작성 | -| PUT | /v1/boards/{code}/posts/{postId}/comments/{commentId} | 댓글 수정 | -| DELETE | /v1/boards/{code}/posts/{postId}/comments/{commentId} | 댓글 삭제 | - -### 검증 결과 -- PHP 문법 검사: ✅ 통과 -- Pint 코드 포맷팅: ✅ 완료 -- Swagger 문서 생성: ✅ 완료 -- 라우트 등록: ✅ 16개 라우트 확인 - ---- - -## 2025-11-27 (수) - ItemMaster API group_id(계층번호) 추가 및 Swagger 보완 - -### 작업 목표 -- FormRequest에 누락된 `group_id` 필드 추가 -- Swagger 스키마에 `group_id` description="계층번호" 추가 -- API 문서 정확도 개선 +## TODO - 운영용 작업지시 취소 기능 (Production Work Order Cancellation) ### 배경 -- `POST /api/v1/item-master/pages/{pageId}/sections` Swagger 문서 점검 중 `group_id` 누락 발견 -- Service에서 `$data['group_id'] ?? 1`로 사용하지만 FormRequest와 Swagger에 미정의 -- `group_id`는 "계층번호"로 통일하여 주석 및 description 작성 +- 현재 `revertProductionOrder()`는 **개발/테스트용** (전체 초기화 - work_orders 및 하위 데이터 삭제) +- 운영 환경에서는 **비즈니스 취소** 개념이 필요 (이력 보존 + 역분개) +- 개발용과 운영용이 **별도 공존**해야 함 -### 수정된 파일 (4개) +### 요구사항 +1. **상태 변경**: `pending`/`in_progress` → `cancelled` (completed는 취소 거부) +2. **자재 역분개**: 삭제가 아닌 역분개 트랜잭션 생성 (이력 보존) +3. **사유 기록 필수**: `cancelled_at`, `cancelled_by`, `cancel_reason` 필드 추가 +4. **단건 처리**: 특정 작업지시 1건 단위로 취소 (전체 초기화와 구분) +5. **감사 추적**: 취소 이력이 감사 로그에 남아야 함 -**FormRequest (group_id 필드 추가)**: -- `app/Http/Requests/ItemMaster/ItemSectionStoreRequest.php` -- `app/Http/Requests/ItemMaster/ItemFieldStoreRequest.php` -- `app/Http/Requests/ItemMaster/ItemBomItemStoreRequest.php` +### 영향 범위 +- `WorkOrderService.php` - `cancelWorkOrder()` 메서드 신규 +- `work_orders` 테이블 - `cancelled_at`, `cancelled_by`, `cancel_reason` (options JSON 활용 가능) +- `stock_transactions` - 역분개 트랜잭션 생성 로직 +- 프론트엔드 - 취소 사유 입력 모달 + 취소 버튼 조건부 표시 -**Swagger**: -- `app/Swagger/v1/ItemMasterApi.php` - -### Swagger 스키마 업데이트 상세 - -**모델 스키마 (3개)** - `description="계층번호"` 추가: -- `ItemSection` -- `ItemField` -- `ItemBomItem` - -**Request 스키마 (6개)** - `group_id` 추가 또는 description 추가: -- `ItemSectionStoreRequest` - group_id 신규 추가 -- `ItemFieldStoreRequest` - group_id 신규 추가 -- `ItemBomItemStoreRequest` - group_id 신규 추가 -- `IndependentSectionStoreRequest` - description 추가 -- `IndependentFieldStoreRequest` - description 추가 -- `IndependentBomItemStoreRequest` - description 추가 - -### 12개 EntityRelationship 엔드포인트 - -`EntityRelationshipApi.php`에 이미 문서화 완료 확인: -- POST/DELETE `/pages/{pageId}/link-section`, `/unlink-section/{sectionId}` -- POST/DELETE `/pages/{pageId}/link-field`, `/unlink-field/{fieldId}` -- GET `/pages/{pageId}/relationships`, `/structure` -- POST/DELETE `/sections/{sectionId}/link-field`, `/unlink-field/{fieldId}` -- POST/DELETE `/sections/{sectionId}/link-bom`, `/unlink-bom/{bomId}` -- GET `/sections/{sectionId}/relationships` -- POST `/relationships/reorder` - -### 검증 결과 -- Pint 코드 포맷팅: ✅ 통과 -- Swagger 문서 생성: ✅ 완료 - -### Git 커밋 -``` -commit 4f78eed -feat: ItemMaster API group_id(계층번호) 추가 및 Swagger 보완 -``` +### 우선순위: 🟡 중간 (Phase 4 이후) --- -## 2025-11-26 (화) - AccessService permission_overrides 테이블 사용으로 수정 +## TODO - 건설관리 Backend API 미개발 모듈 -### 주요 작업 -- API AccessService가 존재하지 않는 테이블(user_permission_overrides, department_permissions)을 참조하던 문제 수정 -- 실제 DB의 `permission_overrides` 테이블을 사용하도록 수정 - -### 수정된 파일: -- `app/Services/Authz/AccessService.php` - - `hasUserOverride()`: `user_permission_overrides` → `permission_overrides` (model_type='App\Models\Members\User') - - `departmentAllows()`: `department_permissions` → `permission_overrides` (model_type='App\Models\Tenants\Department') - - 필드명 변경: `is_allowed` → `effect`, `user_id` → `model_id` - -### 기술 상세: -**permission_overrides 테이블 구조:** -- `model_type`: 폴리모픽 타입 (User, Department) -- `model_id`: 대상 ID -- `permission_id`: 권한 ID -- `effect`: 0=DENY, 1=ALLOW -- `effective_from`, `effective_to`: 유효 기간 - -### 코드 품질: -- ✅ PHP 문법 검사 통과 -- ✅ Pint 포맷팅 통과 +| 모듈 | 설명 | +|------|------| +| bidding | 입찰관리 | +| site-briefings | 현장설명회 | +| structure-review | 구조검토 | +| labor-management | 노무관리 (Labor API는 별도 완료) | --- -## 2025-11-26 (화) - Item Master 독립 엔티티 API 추가 ✅ 완료 - -### 작업 목표 -- 독립 엔티티(섹션, 필드, BOM) CRUD API 10개 추가 -- `SectionTemplate` 모델 삭제 → `ItemSection.is_template` 플래그로 통합 -- Swagger 문서 업데이트 - -### 변경 내용 - -**1. SectionTemplate → ItemSection 통합** -- `section_templates` 테이블 삭제 -- `item_sections` 테이블에 `is_template` 컬럼 추가 -- 기존 `/section-templates` API는 유지 (내부적으로 `is_template=true` 사용) - -**2. 10개 독립 API 추가** - -| API | 메서드 | 설명 | -|-----|--------|------| -| `/sections` | GET | 섹션 목록 (is_template 필터) | -| `/sections` | POST | 독립 섹션 생성 | -| `/sections/{id}/clone` | POST | 섹션 복제 | -| `/sections/{id}/usage` | GET | 섹션 사용처 조회 | -| `/fields` | GET | 필드 목록 | -| `/fields` | POST | 독립 필드 생성 | -| `/fields/{id}/clone` | POST | 필드 복제 | -| `/fields/{id}/usage` | GET | 필드 사용처 조회 | -| `/bom-items` | GET | BOM 항목 목록 | -| `/bom-items` | POST | 독립 BOM 생성 | - -### 추가된 파일 -- `app/Http/Requests/ItemMaster/IndependentSectionStoreRequest.php` -- `app/Http/Requests/ItemMaster/IndependentFieldStoreRequest.php` -- `app/Http/Requests/ItemMaster/IndependentBomItemStoreRequest.php` - -### 삭제된 파일 -- `app/Models/ItemMaster/SectionTemplate.php` - -### 수정된 파일 -- `app/Http/Controllers/Api/V1/ItemMaster/ItemSectionController.php` -- `app/Http/Controllers/Api/V1/ItemMaster/ItemFieldController.php` -- `app/Http/Controllers/Api/V1/ItemMaster/ItemBomItemController.php` -- `app/Services/ItemMaster/ItemSectionService.php` -- `app/Services/ItemMaster/ItemFieldService.php` -- `app/Services/ItemMaster/ItemBomItemService.php` -- `app/Models/ItemMaster/ItemSection.php` (is_template, scopeTemplates 추가) -- `routes/api.php` -- `app/Swagger/v1/ItemMasterApi.php` - -### 마이그레이션 -```bash -# 실행된 마이그레이션 -2025_11_26_120000_add_is_template_to_item_sections_and_drop_section_templates.php -``` - -### 검증 결과 -- PHP 문법 검사: ✅ 통과 -- Pint 코드 포맷팅: ✅ 통과 -- Swagger 문서 생성: ✅ 완료 - ---- - -## 2025-11-26 (화) - Item Master 하이브리드 구조 전환 (독립 엔티티 + 링크 테이블) ✅ 완료 - -### 작업 목표 -기존 CASCADE FK 기반 계층 구조를 **독립 엔티티 + 링크 테이블** 구조로 전환 - -### 배경 -- **문제점**: 현재 구조에서 섹션 삭제 시 항목(필드)도 함께 삭제됨 (CASCADE) -- **요구사항**: - - 페이지, 섹션, 항목은 독립적으로 존재 - - 관계는 링크 테이블로 관리 (Many-to-Many) - - 페이지에서 섹션/항목 모두 직접 연결 가능 - - 섹션에서 항목 연결 가능 - - 엔티티 삭제 시 링크만 제거, 다른 엔티티는 유지 - - `group_id`로 카테고리 격리 (품목관리=1, 향후 확장) - -### 변경 구조 - -**Before (CASCADE FK)**: -``` -item_pages - ↓ page_id FK (CASCADE) -item_sections - ↓ section_id FK (CASCADE) -item_fields / item_bom_items -``` - -**After (독립 + 링크)**: -``` -item_pages (독립) -item_sections (독립) -item_fields (독립) -item_bom_items (독립) - ⇄ entity_relationships (링크 테이블) -``` - -### Phase 계획 - -| Phase | 작업 내용 | 상태 | -|-------|----------|------| -| 1 | 마이그레이션: FK 제거 + group_id 추가 | ✅ 완료 | -| 2 | 마이그레이션: entity_relationships 테이블 생성 | ✅ 완료 | -| 3 | 마이그레이션: 기존 데이터 이관 | ✅ 완료 | -| 4 | 모델 및 Service 수정 | ✅ 완료 | -| 5 | 새로운 API 엔드포인트 추가 | ✅ 완료 | -| 6 | Swagger 문서 업데이트 | ✅ 완료 | -| 7 | 테스트 및 검증 | ✅ 완료 | - -### 추가된 파일 - -**마이그레이션** (Batch 26으로 실행): -- `database/migrations/2025_11_26_100001_convert_item_tables_to_independent_entities.php` -- `database/migrations/2025_11_26_100002_create_entity_relationships_table.php` -- `database/migrations/2025_11_26_100003_migrate_existing_relationships_to_entity_relationships.php` - -**모델**: -- `app/Models/ItemMaster/EntityRelationship.php` (신규) - -**서비스**: -- `app/Services/ItemMaster/EntityRelationshipService.php` (신규) - -**컨트롤러**: -- `app/Http/Controllers/Api/V1/ItemMaster/EntityRelationshipController.php` (신규) - -**Request**: -- `app/Http/Requests/ItemMaster/LinkEntityRequest.php` (신규) -- `app/Http/Requests/ItemMaster/ReorderRelationshipsRequest.php` (신규) - -**Swagger**: -- `app/Swagger/v1/EntityRelationshipApi.php` (신규) - -### 수정된 파일 - -**모델 (group_id 추가 + relationship 메서드)**: -- `app/Models/ItemMaster/ItemPage.php` -- `app/Models/ItemMaster/ItemSection.php` -- `app/Models/ItemMaster/ItemField.php` -- `app/Models/ItemMaster/ItemBomItem.php` -- `app/Models/ItemMaster/SectionTemplate.php` -- `app/Models/ItemMaster/ItemMasterField.php` - -**라우트**: -- `routes/api.php` (새로운 엔드포인트 추가) - -**언어 파일**: -- `lang/ko/message.php` (linked, unlinked 추가) -- `lang/ko/error.php` (page_not_found, section_not_found, field_not_found, bom_not_found 추가) - -### 새로운 API 엔드포인트 (14개) - -**페이지-섹션 연결**: -- `POST /api/v1/item-master/pages/{pageId}/link-section` -- `DELETE /api/v1/item-master/pages/{pageId}/unlink-section/{sectionId}` - -**페이지-필드 직접 연결**: -- `POST /api/v1/item-master/pages/{pageId}/link-field` -- `DELETE /api/v1/item-master/pages/{pageId}/unlink-field/{fieldId}` - -**페이지 관계 조회**: -- `GET /api/v1/item-master/pages/{pageId}/relationships` -- `GET /api/v1/item-master/pages/{pageId}/structure` - -**섹션-필드 연결**: -- `POST /api/v1/item-master/sections/{sectionId}/link-field` -- `DELETE /api/v1/item-master/sections/{sectionId}/unlink-field/{fieldId}` - -**섹션-BOM 연결**: -- `POST /api/v1/item-master/sections/{sectionId}/link-bom` -- `DELETE /api/v1/item-master/sections/{sectionId}/unlink-bom/{bomId}` - -**섹션 관계 조회**: -- `GET /api/v1/item-master/sections/{sectionId}/relationships` - -**관계 순서 변경**: -- `POST /api/v1/item-master/relationships/reorder` - -### 검증 결과 -- PHP 문법 검사: ✅ 통과 -- Pint 코드 포맷팅: ✅ 통과 (9개 신규 파일) -- Swagger 문서 생성: ✅ 완료 -- 라우트 등록: ✅ 44개 item-master 라우트 확인 - -### 롤백 방법 -```bash -php artisan migrate:rollback --step=3 -``` - -### 다음 작업 (옵션) -- 기존 API (POST /pages/{pageId}/sections 등) 내부적으로 entity_relationships 사용하도록 수정 -- 독립 엔티티 CRUD API 추가 (POST /sections, POST /fields 등) - ---- - -## 2025-11-25 (월) - API 인증 에러 처리 개선 및 요청 로그 강화 - -### 문제 상황 -- GET /item-master/init 요청 시 `Route [login] not defined` 에러 발생 -- user_id null (인증 실패 상태) -- API Request 로그에 헤더 정보 누락 - -### 근본 원인 -1. **Handler.php**: `expectsJson()` 체크만 있어서, Accept 헤더 없는 API 요청이 기본 동작(로그인 리다이렉트)으로 처리됨 -2. **ApiKeyMiddleware**: 요청 로그에 헤더 정보 (X-API-KEY, Authorization) 미포함 - -### 해결 방안 - -**Handler.php (app/Exceptions/Handler.php)** -- Line 60: API 라우트 체크 추가 (`str_starts_with($request->path(), 'api/')`) -- Line 62: `$request->expectsJson() || $isApiRoute` 조건으로 변경 -- 효과: Accept 헤더 없어도 API 라우트는 무조건 JSON 응답 반환 - -**ApiKeyMiddleware (app/Http/Middleware/ApiKeyMiddleware.php)** -- Line 52-57: headers 필드 추가 - - X-API-KEY: 마스킹 ('***') - - Authorization: Bearer 토큰 마스킹 ('Bearer ***') - - Accept, Content-Type: 원본 그대로 -- 효과: 인증 문제 디버깅 용이 - -### 수정된 파일 -- `app/Exceptions/Handler.php` -- `app/Http/Middleware/ApiKeyMiddleware.php` - -### 검증 결과 -- PHP 문법 체크: ✅ 통과 -- Pint 코드 포맷팅: ✅ 통과 - -### 기대 효과 -1. API 요청 시 Accept 헤더 없어도 정상 JSON 응답 -2. 인증 실패 시 로그인 리다이렉트 대신 401 JSON 응답 -3. 요청 로그에서 헤더 정보 확인 가능 (디버깅 개선) - ---- - -## 2025-11-24 (일) - 소프트삭제 및 타임스탬프 감사 컬럼 추가 - -### 작업 목표 -- deleted_at이 있는 테이블에 deleted_by 컬럼 추가 -- created_at, updated_at이 있는 테이블에 created_by, updated_by 컬럼 추가 - -### 작업 내용 - -**1. DB 스키마 분석 (INFORMATION_SCHEMA 쿼리)** -- deleted_at은 있지만 deleted_by가 없는 테이블: 30개 -- created_at은 있지만 created_by, updated_by가 없는 테이블: 45개 - -**2. 마이그레이션 생성** -- `2025_11_24_192518_add_deleted_by_to_soft_delete_tables.php` - - 30개 테이블에 deleted_by 추가 - - nullable, COMMENT('삭제자 사용자 ID') - - after('deleted_at') 배치 - -- `2025_11_24_192518_add_audit_columns_to_tables.php` - - 38개 비즈니스 테이블에 created_by, updated_by 추가 - - 시스템 테이블 제외 (jobs, job_batches, password_reset_tokens, personal_access_tokens, taggables, tags) - - nullable, COMMENT - - after('updated_at'), after('created_by') 배치 - -**3. 마이그레이션 실행 및 검증** -- 실행 시간: deleted_by (429.53ms), audit_columns (1초) -- 샘플 테이블 검증: users, products, models, bom_templates, department_user 모두 정상 - -### 추가된 파일 -- `database/migrations/2025_11_24_192518_add_deleted_by_to_soft_delete_tables.php` -- `database/migrations/2025_11_24_192518_add_audit_columns_to_tables.php` - -### 마이그레이션 상태 -- Batch 25로 실행 완료 -- 롤백 가능 (down 메서드 구현) - ---- - -## 2025-11-24 (일) - CORS Preflight 문제 해결 - -### 문제 상황 -- React 프론트엔드(http://192.0.0.2:3001)에서 API 호출 시 CORS 에러 -- 에러: `Request header field x-api-key is not allowed by Access-Control-Allow-Headers in preflight response` -- 서버 로그: OPTIONS 요청만 있고 Response 로그 없음 (401 차단) - -### 근본 원인 (root-cause-analyst 스킬 활용) -1. CorsMiddleware에서 `Access-Control-Allow-Headers`에 `X-API-KEY` 누락 -2. OPTIONS 요청(Preflight)이 ApiKeyMiddleware에서 401로 차단 -3. 브라우저는 커스텀 헤더 사용 시 Preflight 요청을 자동 전송 - -### 해결 방안 - -**CorsMiddleware 수정:** -- OPTIONS 요청을 미들웨어 체인 진입 전에 즉시 200 OK 처리 -- `Access-Control-Allow-Headers`에 `X-API-KEY` 추가 -- PATCH 메서드 추가, Max-Age 86400초 설정 - -**ApiKeyMiddleware 정리:** -- 불필요한 OPTIONS 체크 제거 - -**CORS 설정 업데이트:** -- `config/cors.php`: exposed_headers, max_age 설정 - -### 수정된 파일 -- `app/Http/Middleware/CorsMiddleware.php` -- `app/Http/Middleware/ApiKeyMiddleware.php` -- `config/cors.php` - -### Git 커밋 -- `2e96660` - CORS preflight 요청 처리 개선 및 X-API-KEY 헤더 허용 -- `8e8ab65` - CORS preflight 응답에 x-api-key 헤더 허용 추가 - -### 다음 작업 -- React에서 API 호출 테스트 -- 개발 서버 로그 확인 (Request/Response 쌍 기록 여부) - ---- - -## 2025-11-20 (수) - ItemMaster API 테스트 및 버그 수정 - -### 주요 작업 -- ItemMaster API 통합 테스트 작성 (12개 테스트, 82개 assertion) -- 누락된 마이그레이션 실행 (section_templates, tab_columns) -- API Key 미들웨어 수정 (로그인 엔드포인트 API Key 필수화) -- ReorderRequest validation 수정 (범용성 확보) -- 네임스페이스 오류 수정 (5개 Controller) -- Route 순서 수정 (specific route 우선) - -### 테스트 결과 -✅ 12/12 테스트 통과 (100%) - ---- - -## 2025-11-19 (화) - ItemMaster API Swagger 문서 작성 - -### 주요 작업 -- ItemMaster 전체 API (32개 엔드포인트) Swagger 문서화 완료 -- OpenAPI 3.0 표준 준수 -- Model Schemas 8개, Request Schemas 12개 작성 - ---- - -## 2025-11-18 (월) - Category API 테스트 및 개선 - -### 주요 작업 -- Category CRUD 테스트 작성 (9개 테스트, 98개 assertion) -- 계층 구조 및 필드 관리 테스트 -- Validation 로직 개선 - ---- -## 2025-12-19 (목) - Phase 6.2 팝업관리 API 구현 - -### 주요 작업 -- Phase 6.2 팝업관리 기능 구현 완료 -- PROJECT_DEVELOPMENT_POLICY.md 정책 준수 (string 타입, options JSON 가변 컬럼) - -### 추가된 파일 - -**마이그레이션:** -- `database/migrations/2025_12_19_170001_create_popups_table.php` - -**모델:** -- `app/Models/Popups/Popup.php` - - BelongsToTenant, SoftDeletes 적용 - - target_type: all(전사), department(부서) - - status: active(사용), inactive(사용안함) - - 스코프: active(), status(), targetType(), forUser() - - 관계: department(), creator(), updater() - -**서비스:** -- `app/Services/PopupService.php` - - index(): 관리자용 목록 (페이지네이션) - - getActivePopups(): 사용자용 활성 팝업 - - show(): 상세 조회 - - store(): 등록 - - update(): 수정 - - destroy(): 삭제 (Soft Delete) - -**FormRequest:** -- `app/Http/Requests/V1/Popup/StorePopupRequest.php` -- `app/Http/Requests/V1/Popup/UpdatePopupRequest.php` - -**컨트롤러:** -- `app/Http/Controllers/Api/V1/PopupController.php` - -**Swagger:** -- `app/Swagger/v1/PopupApi.php` - -### 수정된 파일 -- `routes/api.php`: Popup 라우트 추가 (6개 엔드포인트) - -### API 엔드포인트 (6개) -| Method | Path | Description | -|--------|------|-------------| -| GET | /api/v1/popups | 팝업 목록 (관리자용) | -| POST | /api/v1/popups | 팝업 등록 | -| GET | /api/v1/popups/active | 활성 팝업 (사용자용) | -| GET | /api/v1/popups/{id} | 팝업 상세 | -| PUT | /api/v1/popups/{id} | 팝업 수정 | -| DELETE | /api/v1/popups/{id} | 팝업 삭제 | - -### 정책 준수 사항 -- ✅ 기존 테이블 확인 후 신규 생성 -- ✅ string 타입 사용 (enum 대신) -- ✅ options JSON 가변 컬럼 -- ✅ BelongsToTenant, SoftDeletes 적용 -- ✅ Service-First 아키텍처 -- ✅ FormRequest 검증 - ---- +## 최근 커밋 이력 (참고용) + +| 날짜 | 커밋 | 내용 | +|------|------|------| +| 2026-02-21 | `5ecd966` | 견적 converted 상태 데이터 기반 변경 | +| 2026-02-21 | `b0547c4` | FG 수식 산출 입력폼/파싱/폴백 추가 | +| 2026-02-19 | `23029b1` | 작업지시 show() materialInputs eager loading 추가 | +| 2026-02-19 | `316d412` | 슬랫 작업일지 데이터 파이프라인 구축 | diff --git a/LOGICAL_RELATIONSHIPS.md b/LOGICAL_RELATIONSHIPS.md index 115db54..3f6f735 100644 --- a/LOGICAL_RELATIONSHIPS.md +++ b/LOGICAL_RELATIONSHIPS.md @@ -1,6 +1,6 @@ # 논리적 데이터베이스 관계 문서 -> **자동 생성**: 2026-02-19 16:26:58 +> **자동 생성**: 2026-02-21 16:28:35 > **소스**: Eloquent 모델 관계 분석 ## 📊 모델별 관계 현황 @@ -1079,6 +1079,7 @@ ### stock_lots - **stock()**: belongsTo → `stocks` - **receiving()**: belongsTo → `receivings` +- **workOrder()**: belongsTo → `work_orders` - **creator()**: belongsTo → `users` ### stock_transactions From 16b8dbcc6fdc1a9d32904969f1da654830847342 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Sun, 22 Feb 2026 03:50:18 +0900 Subject: [PATCH 16/26] =?UTF-8?q?fix:=20=EC=A0=88=EA=B3=A1=20=EC=9E=90?= =?UTF-8?q?=EC=9E=AC=ED=88=AC=EC=9E=85=20dynamic=5Fbom=20=EC=88=98?= =?UTF-8?q?=EB=9F=89=20=EB=B3=B4=EC=A0=95=20=EB=B0=8F=20=EA=B0=9C=EC=86=8C?= =?UTF-8?q?=EB=8B=B9=20=EC=88=98=EB=9F=89=20=EC=82=B0=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getMaterialsForItem(): dynamic_bom 우선 체크 추가 (정적 BOM만 확인하던 문제) - dynamic_bom.qty를 woItem.quantity로 나눠 개소당 수량 산출 (작업일지 bendingInfo와 일치) - getMaterials(): 동일하게 개소당 수량으로 변환 - 응답에 lot_prefix, part_type, category 필드 추가 Co-Authored-By: Claude Opus 4.6 --- app/Services/WorkOrderService.php | 56 ++++++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 5 deletions(-) diff --git a/app/Services/WorkOrderService.php b/app/Services/WorkOrderService.php index 3d925ce..3311d68 100644 --- a/app/Services/WorkOrderService.php +++ b/app/Services/WorkOrderService.php @@ -1229,16 +1229,18 @@ public function getMaterials(int $workOrderId): array // 합산 키: (item_id, work_order_item_id) 쌍 $key = $childItemId.'_'.$woItem->id; + // dynamic_bom.qty는 아이템 수량이 곱해져 있으므로 나눠서 개소당 수량 산출 $bomQty = (float) ($bomEntry['qty'] ?? 1); - $requiredQty = $bomQty * ($woItem->quantity ?? 1); + $woItemQty = max(1, (float) ($woItem->quantity ?? 1)); + $perNodeQty = $bomQty / $woItemQty; if (isset($uniqueMaterials[$key])) { - $uniqueMaterials[$key]['required_qty'] += $requiredQty; + $uniqueMaterials[$key]['required_qty'] += $perNodeQty; } else { $uniqueMaterials[$key] = [ 'item' => $dynamicItems[$childItemId], - 'bom_qty' => $bomQty, - 'required_qty' => $requiredQty, + 'bom_qty' => $perNodeQty, + 'required_qty' => $perNodeQty, 'work_order_item_id' => $woItem->id, 'lot_prefix' => $bomEntry['lot_prefix'] ?? null, 'part_type' => $bomEntry['part_type'] ?? null, @@ -2847,7 +2849,45 @@ public function getMaterialsForItem(int $workOrderId, int $itemId): array // 해당 개소의 BOM 기반 자재 추출 $materialItems = []; - if ($woItem->item_id) { + // ① dynamic_bom 우선 체크 (절곡 등 동적 BOM 사용 공정) + $options = is_string($woItem->options) ? json_decode($woItem->options, true) : ($woItem->options ?? []); + $dynamicBom = $options['dynamic_bom'] ?? null; + + if ($dynamicBom && is_array($dynamicBom)) { + // dynamic_bom child_item_id 배치 조회 (N+1 방지) + $childItemIds = array_filter(array_column($dynamicBom, 'child_item_id')); + $childItems = []; + if (! empty($childItemIds)) { + $childItems = \App\Models\Items\Item::where('tenant_id', $tenantId) + ->whereIn('id', array_unique($childItemIds)) + ->get() + ->keyBy('id'); + } + + foreach ($dynamicBom as $bomEntry) { + $childItemId = $bomEntry['child_item_id'] ?? null; + if (! $childItemId || ! isset($childItems[$childItemId])) { + continue; + } + + // dynamic_bom.qty는 아이템 수량이 곱해져 있으므로 나눠서 개소당 수량 산출 + // (작업일지 bendingInfo와 동일한 수량) + $bomQty = (float) ($bomEntry['qty'] ?? 1); + $woItemQty = max(1, (float) ($woItem->quantity ?? 1)); + $perNodeQty = $bomQty / $woItemQty; + $materialItems[] = [ + 'item' => $childItems[$childItemId], + 'bom_qty' => $perNodeQty, + 'required_qty' => $perNodeQty, + 'lot_prefix' => $bomEntry['lot_prefix'] ?? null, + 'part_type' => $bomEntry['part_type'] ?? null, + 'category' => $bomEntry['category'] ?? null, + ]; + } + } + + // ② dynamic_bom이 없으면 정적 BOM fallback + if (empty($materialItems) && $woItem->item_id) { $item = \App\Models\Items\Item::where('tenant_id', $tenantId) ->find($woItem->item_id); @@ -2936,6 +2976,9 @@ public function getMaterialsForItem(int $workOrderId, int $itemId): array 'receipt_date' => $lot->receipt_date, 'supplier' => $lot->supplier, 'fifo_rank' => $rank++, + 'lot_prefix' => $matInfo['lot_prefix'] ?? null, + 'part_type' => $matInfo['part_type'] ?? null, + 'category' => $matInfo['category'] ?? null, ]; } } @@ -2959,6 +3002,9 @@ public function getMaterialsForItem(int $workOrderId, int $itemId): array 'receipt_date' => null, 'supplier' => null, 'fifo_rank' => $rank++, + 'lot_prefix' => $matInfo['lot_prefix'] ?? null, + 'part_type' => $matInfo['part_type'] ?? null, + 'category' => $matInfo['category'] ?? null, ]; } } From 7eb5825d4108364382b5ee04bfbb19d86e50b044 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Sun, 22 Feb 2026 03:50:24 +0900 Subject: [PATCH 17/26] =?UTF-8?q?fix(WEB):=20=EC=9E=85=EA=B3=A0=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EC=A0=80=EC=9E=A5=20=EC=98=A4=EB=A5=98=203?= =?UTF-8?q?=EA=B1=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FormRequest에 manufacturer, material_no 규칙 추가 (validated()에서 누락 방지) - store() 시 lot_no 자동 생성 (generateLotNo() 폴백) - getOrCreateStock()에서 item_code 기반 2차 검색 추가 (unique key 충돌 방지) - 동일 item_code, 다른 item_id인 Stock 존재 시 item_id 업데이트 - SoftDeletes 복원 로직 포함 Co-Authored-By: Claude Opus 4.6 --- .../V1/Receiving/StoreReceivingRequest.php | 2 ++ .../V1/Receiving/UpdateReceivingRequest.php | 2 ++ app/Services/ReceivingService.php | 2 +- app/Services/StockService.php | 31 ++++++++++++++++--- 4 files changed, 32 insertions(+), 5 deletions(-) diff --git a/app/Http/Requests/V1/Receiving/StoreReceivingRequest.php b/app/Http/Requests/V1/Receiving/StoreReceivingRequest.php index 56594ee..56904de 100644 --- a/app/Http/Requests/V1/Receiving/StoreReceivingRequest.php +++ b/app/Http/Requests/V1/Receiving/StoreReceivingRequest.php @@ -29,6 +29,8 @@ public function rules(): array 'lot_no' => ['nullable', 'string', 'max:50'], 'status' => ['nullable', 'string', 'in:order_completed,shipping,inspection_pending,receiving_pending'], 'remark' => ['nullable', 'string', 'max:1000'], + 'manufacturer' => ['nullable', 'string', 'max:100'], + 'material_no' => ['nullable', 'string', 'max:50'], ]; } diff --git a/app/Http/Requests/V1/Receiving/UpdateReceivingRequest.php b/app/Http/Requests/V1/Receiving/UpdateReceivingRequest.php index 61e2de9..4273062 100644 --- a/app/Http/Requests/V1/Receiving/UpdateReceivingRequest.php +++ b/app/Http/Requests/V1/Receiving/UpdateReceivingRequest.php @@ -31,6 +31,8 @@ public function rules(): array 'inspection_status' => ['nullable', 'string', 'max:10'], 'inspection_date' => ['nullable', 'date'], 'inspection_result' => ['nullable', 'string', 'max:20'], + 'manufacturer' => ['nullable', 'string', 'max:100'], + 'material_no' => ['nullable', 'string', 'max:50'], ]; } diff --git a/app/Services/ReceivingService.php b/app/Services/ReceivingService.php index ed005c6..6ecf6ff 100644 --- a/app/Services/ReceivingService.php +++ b/app/Services/ReceivingService.php @@ -199,7 +199,7 @@ public function store(array $data): Receiving $receiving->due_date = $data['due_date'] ?? null; $receiving->receiving_qty = $data['receiving_qty'] ?? null; $receiving->receiving_date = $data['receiving_date'] ?? null; - $receiving->lot_no = $data['lot_no'] ?? null; + $receiving->lot_no = $data['lot_no'] ?? $this->generateLotNo(); $receiving->status = $data['status'] ?? 'receiving_pending'; $receiving->remark = $data['remark'] ?? null; diff --git a/app/Services/StockService.php b/app/Services/StockService.php index e3569cf..d980a30 100644 --- a/app/Services/StockService.php +++ b/app/Services/StockService.php @@ -525,18 +525,41 @@ public function getOrCreateStock(int $itemId, ?Receiving $receiving = null): Sto $tenantId = $this->tenantId(); $userId = $this->apiUserId(); - $stock = Stock::where('tenant_id', $tenantId) + $item = Item::where('tenant_id', $tenantId) + ->findOrFail($itemId); + + // 1차: item_id로 조회 (SoftDeletes 포함) + $stock = Stock::withTrashed() + ->where('tenant_id', $tenantId) ->where('item_id', $itemId) ->first(); + // 2차: item_code로 조회 (unique key 기준, item_id가 다를 수 있음) + if (! $stock) { + $stock = Stock::withTrashed() + ->where('tenant_id', $tenantId) + ->where('item_code', $item->code) + ->first(); + + // item_id가 변경된 경우 업데이트 + if ($stock && $stock->item_id !== $itemId) { + $stock->item_id = $itemId; + } + } + if ($stock) { + if ($stock->trashed()) { + $stock->restore(); + $stock->status = 'out'; + } + $stock->item_name = $item->name; + $stock->updated_by = $userId; + $stock->save(); + return $stock; } // Stock이 없으면 새로 생성 - $item = Item::where('tenant_id', $tenantId) - ->findOrFail($itemId); - $stock = new Stock; $stock->tenant_id = $tenantId; $stock->item_id = $itemId; From b49f66ed575cce1c5dd45af6a542716b8296a244 Mon Sep 17 00:00:00 2001 From: DEV-SERVER Date: Tue, 24 Feb 2026 08:15:05 +0900 Subject: [PATCH 18/26] ci: add Jenkinsfile for CI/CD pipeline (stage/main) --- Jenkinsfile | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 Jenkinsfile diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..4b9efc3 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,106 @@ +pipeline { + agent any + + environment { + DEPLOY_USER = 'hskwon' + RELEASE_ID = new Date().format('yyyyMMdd_HHmmss') + } + + stages { + stage('Checkout') { + steps { checkout scm } + } + + // ── stage → 운영서버 Stage 배포 ── + stage('Deploy Stage') { + when { branch 'stage' } + steps { + sshagent(credentials: ['deploy-ssh-key']) { + sh """ + rsync -az --delete \ + --exclude='.git' \ + --exclude='.env' \ + --exclude='storage/app' \ + --exclude='storage/logs' \ + --exclude='storage/framework/sessions' \ + --exclude='storage/framework/cache' \ + . ${DEPLOY_USER}@211.117.60.189:/home/webservice/api-stage/releases/${RELEASE_ID}/ + + ssh ${DEPLOY_USER}@211.117.60.189 ' + cd /home/webservice/api-stage/releases/${RELEASE_ID} && + ln -sfn /home/webservice/api-stage/shared/.env .env && + ln -sfn /home/webservice/api-stage/shared/storage/app storage/app && + composer install --no-dev --optimize-autoloader --no-interaction && + php artisan config:cache && + php artisan route:cache && + php artisan view:cache && + php artisan migrate --force && + ln -sfn /home/webservice/api-stage/releases/${RELEASE_ID} /home/webservice/api-stage/current && + sudo systemctl reload php8.4-fpm && + cd /home/webservice/api-stage/releases && ls -1dt */ | tail -n +4 | xargs rm -rf 2>/dev/null || true + ' + """ + } + } + } + + // ── main → 운영서버 Production 배포 ── + stage('Deploy Production') { + when { branch 'main' } + steps { + sshagent(credentials: ['deploy-ssh-key']) { + sh """ + rsync -az --delete \ + --exclude='.git' \ + --exclude='.env' \ + --exclude='storage/app' \ + --exclude='storage/logs' \ + --exclude='storage/framework/sessions' \ + --exclude='storage/framework/cache' \ + . ${DEPLOY_USER}@211.117.60.189:/home/webservice/api/releases/${RELEASE_ID}/ + + ssh ${DEPLOY_USER}@211.117.60.189 ' + cd /home/webservice/api/releases/${RELEASE_ID} && + ln -sfn /home/webservice/api/shared/.env .env && + ln -sfn /home/webservice/api/shared/storage/app storage/app && + composer install --no-dev --optimize-autoloader --no-interaction && + php artisan config:cache && + php artisan route:cache && + php artisan view:cache && + php artisan migrate --force && + ln -sfn /home/webservice/api/releases/${RELEASE_ID} /home/webservice/api/current && + sudo systemctl reload php8.4-fpm && + sudo supervisorctl restart sam-queue-worker:* && + cd /home/webservice/api/releases && ls -1dt */ | tail -n +6 | xargs rm -rf 2>/dev/null || true + ' + """ + } + } + } + + // develop → Jenkins 관여 안함 (기존 post-update hook 유지) + } + + post { + success { echo '✅ api 배포 완료 (' + env.BRANCH_NAME + ')' } + failure { + echo '❌ api 배포 실패 (' + env.BRANCH_NAME + ')' + script { + if (env.BRANCH_NAME in ['main', 'stage']) { + def baseDir = env.BRANCH_NAME == 'main' + ? '/home/webservice/api' + : '/home/webservice/api-stage' + sshagent(credentials: ['deploy-ssh-key']) { + sh """ + ssh ${DEPLOY_USER}@211.117.60.189 ' + PREV=\$(ls -1dt ${baseDir}/releases/*/ | sed -n "2p" | xargs basename) && + [ -n "\$PREV" ] && ln -sfn ${baseDir}/releases/\$PREV ${baseDir}/current && + sudo systemctl reload php8.4-fpm + ' + """ + } + } + } + } + } +} From 0ded7897a41c5bc15b7f13bc2420bc11ab0c7b38 Mon Sep 17 00:00:00 2001 From: DEV-SERVER Date: Tue, 24 Feb 2026 13:22:22 +0900 Subject: [PATCH 19/26] =?UTF-8?q?refactor:=20stage=20=EB=B8=8C=EB=9E=9C?= =?UTF-8?q?=EC=B9=98=20=EC=A0=9C=EA=B1=B0,=20main=EC=97=90=EC=84=9C=20Stag?= =?UTF-8?q?e=E2=86=92=EC=8A=B9=EC=9D=B8=E2=86=92Production=20=EB=B0=B0?= =?UTF-8?q?=ED=8F=AC=20=ED=9D=90=EB=A6=84=EC=9C=BC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - main: Stage 자동 배포 → Jenkins 승인(24h) → Production 배포 - stage 브랜치 더 이상 사용 안함 - Production 실패 시 자동 롤백 --- Jenkinsfile | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 4b9efc3..4022f60 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -11,12 +11,14 @@ pipeline { steps { checkout scm } } - // ── stage → 운영서버 Stage 배포 ── + // ── main → 운영서버 Stage 배포 ── stage('Deploy Stage') { - when { branch 'stage' } + when { branch 'main' } steps { sshagent(credentials: ['deploy-ssh-key']) { sh """ + ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/api-stage/releases/${RELEASE_ID}' + rsync -az --delete \ --exclude='.git' \ --exclude='.env' \ @@ -44,12 +46,25 @@ pipeline { } } + // ── 운영 배포 승인 ── + stage('Production Approval') { + when { branch 'main' } + steps { + timeout(time: 24, unit: 'HOURS') { + input message: 'Stage 확인 후 운영 배포를 진행하시겠습니까?\nStage API: https://stage-api.sam.it.kr', + ok: '운영 배포 진행' + } + } + } + // ── main → 운영서버 Production 배포 ── stage('Deploy Production') { when { branch 'main' } steps { sshagent(credentials: ['deploy-ssh-key']) { sh """ + ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/api/releases/${RELEASE_ID}' + rsync -az --delete \ --exclude='.git' \ --exclude='.env' \ @@ -82,21 +97,18 @@ pipeline { } post { - success { echo '✅ api 배포 완료 (' + env.BRANCH_NAME + ')' } + success { echo "✅ api 배포 완료 (${env.BRANCH_NAME})" } failure { - echo '❌ api 배포 실패 (' + env.BRANCH_NAME + ')' + echo "❌ api 배포 실패 (${env.BRANCH_NAME})" script { - if (env.BRANCH_NAME in ['main', 'stage']) { - def baseDir = env.BRANCH_NAME == 'main' - ? '/home/webservice/api' - : '/home/webservice/api-stage' + if (env.BRANCH_NAME == 'main') { sshagent(credentials: ['deploy-ssh-key']) { sh """ ssh ${DEPLOY_USER}@211.117.60.189 ' - PREV=\$(ls -1dt ${baseDir}/releases/*/ | sed -n "2p" | xargs basename) && - [ -n "\$PREV" ] && ln -sfn ${baseDir}/releases/\$PREV ${baseDir}/current && + PREV=\$(ls -1dt /home/webservice/api/releases/*/ | sed -n "2p" | xargs basename 2>/dev/null) && + [ -n "\$PREV" ] && ln -sfn /home/webservice/api/releases/\$PREV /home/webservice/api/current && sudo systemctl reload php8.4-fpm - ' + ' || true """ } } From cb9c905698086d4a3b3c30768f47246474b32de0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Tue, 24 Feb 2026 22:40:24 +0900 Subject: [PATCH 20/26] =?UTF-8?q?fix:=20AppServiceProvider=20CLI=20?= =?UTF-8?q?=EC=BB=A8=ED=85=8D=EC=8A=A4=ED=8A=B8=EC=97=90=EC=84=9C=20reques?= =?UTF-8?q?t()=20=ED=98=B8=EC=B6=9C=20=EC=97=90=EB=9F=AC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - runningInConsole() 체크 추가로 artisan 명령어 실행 시 request 바인딩 에러 방지 - composer install 후 package:discover 실패 해결 Co-Authored-By: Claude Opus 4.6 --- app/Providers/AppServiceProvider.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index ac3963a..96bc624 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -46,9 +46,8 @@ class AppServiceProvider extends ServiceProvider public function register(): void { // 개발환경 + API 라우트에서만 쿼리 로그 수집 - if (app()->environment('local')) { - // 콘솔/큐 등 non-HTTP 컨텍스트 보호 - if (function_exists('request') && request() && request()->is('api/*')) { + if (app()->environment('local') && !app()->runningInConsole()) { + if (request()->is('api/*')) { DB::enableQueryLog(); } } From 6a4485134a948266b299b852f9e33a515fc2b63d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Tue, 24 Feb 2026 22:53:38 +0900 Subject: [PATCH 21/26] =?UTF-8?q?fix:=20=EB=B0=B0=ED=8F=AC=20=EC=8B=9C=20b?= =?UTF-8?q?ootstrap/cache=20=EB=94=94=EB=A0=89=ED=84=B0=EB=A6=AC=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20+=20slackSend=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Stage/Production 배포에 mkdir -p bootstrap/cache 추가 - .gitignore로 누락되는 디렉터리 → composer install 시 package:discover 실패 해결 - rebase 중 사라진 slackSend 알림 복구 (Checkout, success, failure) Co-Authored-By: Claude Opus 4.6 --- Jenkinsfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Jenkinsfile b/Jenkinsfile index 4022f60..bbea368 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -30,6 +30,7 @@ pipeline { ssh ${DEPLOY_USER}@211.117.60.189 ' cd /home/webservice/api-stage/releases/${RELEASE_ID} && + mkdir -p bootstrap/cache && ln -sfn /home/webservice/api-stage/shared/.env .env && ln -sfn /home/webservice/api-stage/shared/storage/app storage/app && composer install --no-dev --optimize-autoloader --no-interaction && @@ -76,6 +77,7 @@ pipeline { ssh ${DEPLOY_USER}@211.117.60.189 ' cd /home/webservice/api/releases/${RELEASE_ID} && + mkdir -p bootstrap/cache && ln -sfn /home/webservice/api/shared/.env .env && ln -sfn /home/webservice/api/shared/storage/app storage/app && composer install --no-dev --optimize-autoloader --no-interaction && From 3f5a9429391c988e0ef3e7d2c300c40ebdf67aef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Tue, 24 Feb 2026 23:00:32 +0900 Subject: [PATCH 22/26] =?UTF-8?q?fix:=20=EB=B0=B0=ED=8F=AC=20=EC=8B=9C=20s?= =?UTF-8?q?torage/framework=20=EB=94=94=EB=A0=89=ED=84=B0=EB=A6=AC=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mkdir -p storage/framework/{views,cache/data,sessions} storage/logs 추가 - .gitignore로 누락되는 Laravel 필수 디렉터리 생성 - Blade 캐시 경로 에러 해결 Co-Authored-By: Claude Opus 4.6 --- Jenkinsfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index bbea368..1a00683 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -30,7 +30,7 @@ pipeline { ssh ${DEPLOY_USER}@211.117.60.189 ' cd /home/webservice/api-stage/releases/${RELEASE_ID} && - mkdir -p bootstrap/cache && + mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs && ln -sfn /home/webservice/api-stage/shared/.env .env && ln -sfn /home/webservice/api-stage/shared/storage/app storage/app && composer install --no-dev --optimize-autoloader --no-interaction && @@ -77,7 +77,7 @@ pipeline { ssh ${DEPLOY_USER}@211.117.60.189 ' cd /home/webservice/api/releases/${RELEASE_ID} && - mkdir -p bootstrap/cache && + mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs && ln -sfn /home/webservice/api/shared/.env .env && ln -sfn /home/webservice/api/shared/storage/app storage/app && composer install --no-dev --optimize-autoloader --no-interaction && From 97f61e24bcfab7b789a139ea17e212f5dc5d2b1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Tue, 24 Feb 2026 23:53:48 +0900 Subject: [PATCH 23/26] =?UTF-8?q?ci:=20Jenkinsfile=20slackSend=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Checkout: slackSend 빌드 시작 알림 추가 (tokenCredentialId) - Post success/failure: echo → slackSend 교체 (tokenCredentialId) - 이전 rebase 과정에서 소실된 slackSend 복구 Co-Authored-By: Claude Opus 4.6 --- Jenkinsfile | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 1a00683..243be46 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -8,7 +8,11 @@ pipeline { stages { stage('Checkout') { - steps { checkout scm } + steps { + slackSend channel: '#product_infra', color: '#439FE0', tokenCredentialId: 'slack-token', + message: "🚀 *api* 빌드 시작 (`${env.BRANCH_NAME}`)\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>" + checkout scm + } } // ── main → 운영서버 Stage 배포 ── @@ -99,9 +103,13 @@ pipeline { } post { - success { echo "✅ api 배포 완료 (${env.BRANCH_NAME})" } + success { + slackSend channel: '#product_infra', color: 'good', tokenCredentialId: 'slack-token', + message: "✅ *api* 배포 성공 (`${env.BRANCH_NAME}`)\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>" + } failure { - echo "❌ api 배포 실패 (${env.BRANCH_NAME})" + slackSend channel: '#product_infra', color: 'danger', tokenCredentialId: 'slack-token', + message: "❌ *api* 배포 실패 (`${env.BRANCH_NAME}`)\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>" script { if (env.BRANCH_NAME == 'main') { sshagent(credentials: ['deploy-ssh-key']) { From 0a461c9209008855d678e13e188e65948899ce46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Wed, 25 Feb 2026 11:24:39 +0900 Subject: [PATCH 24/26] =?UTF-8?q?ci:Jenkinsfile=20=EB=8F=99=EC=8B=9C=20?= =?UTF-8?q?=EB=B9=8C=EB=93=9C=20=EB=B0=A9=EC=A7=80=20=EC=98=B5=EC=85=98=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- Jenkinsfile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Jenkinsfile b/Jenkinsfile index 243be46..a33198a 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,6 +1,10 @@ pipeline { agent any + options { + disableConcurrentBuilds() + } + environment { DEPLOY_USER = 'hskwon' RELEASE_ID = new Date().format('yyyyMMdd_HHmmss') From c4bcab07c1f0645c3d81ae1e11f85d411710224f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Wed, 25 Feb 2026 11:30:49 +0900 Subject: [PATCH 25/26] =?UTF-8?q?ci:=EC=9A=B4=EC=98=81=20=EB=B0=B0?= =?UTF-8?q?=ED=8F=AC=20=EC=8A=B9=EC=9D=B8=20=EB=8C=80=EA=B8=B0=20Slack=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EC=B6=94=EA=B0=80=20(#product=5Fdeploy)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- Jenkinsfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Jenkinsfile b/Jenkinsfile index a33198a..d1d38dc 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -59,6 +59,8 @@ pipeline { stage('Production Approval') { when { branch 'main' } steps { + slackSend channel: '#product_deploy', color: '#FF9800', tokenCredentialId: 'slack-token', + message: "🔔 *api* 운영 배포 승인 대기 중\nStage API: https://stage-api.sam.it.kr\n<${env.BUILD_URL}input|승인하러 가기>" timeout(time: 24, unit: 'HOURS') { input message: 'Stage 확인 후 운영 배포를 진행하시겠습니까?\nStage API: https://stage-api.sam.it.kr', ok: '운영 배포 진행' From bb0615693e986af9336725d3ab9fd398938fd1f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Wed, 25 Feb 2026 12:52:17 +0900 Subject: [PATCH 26/26] =?UTF-8?q?chore:=20Slack=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=EC=97=90=20=EC=BB=A4=EB=B0=8B=20=EB=A9=94=EC=8B=9C=EC=A7=80=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Checkout 단계에서 GIT_COMMIT_MSG 캡처 (git log -1 --pretty=format:'%s') - checkout scm을 slackSend 이전으로 이동 (커밋 정보 먼저 획득) - 빌드 시작, 승인 대기, 성공, 실패 모든 Slack 메시지에 커밋 제목 포함 Co-Authored-By: Claude Opus 4.6 --- Jenkinsfile | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index d1d38dc..fe11928 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -13,9 +13,12 @@ pipeline { stages { stage('Checkout') { steps { - slackSend channel: '#product_infra', color: '#439FE0', tokenCredentialId: 'slack-token', - message: "🚀 *api* 빌드 시작 (`${env.BRANCH_NAME}`)\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>" checkout scm + script { + env.GIT_COMMIT_MSG = sh(script: "git log -1 --pretty=format:'%s'", returnStdout: true).trim() + } + slackSend channel: '#product_infra', color: '#439FE0', tokenCredentialId: 'slack-token', + message: "🚀 *api* 빌드 시작 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>" } } @@ -60,7 +63,7 @@ pipeline { when { branch 'main' } steps { slackSend channel: '#product_deploy', color: '#FF9800', tokenCredentialId: 'slack-token', - message: "🔔 *api* 운영 배포 승인 대기 중\nStage API: https://stage-api.sam.it.kr\n<${env.BUILD_URL}input|승인하러 가기>" + message: "🔔 *api* 운영 배포 승인 대기 중\n${env.GIT_COMMIT_MSG}\nStage API: https://stage-api.sam.it.kr\n<${env.BUILD_URL}input|승인하러 가기>" timeout(time: 24, unit: 'HOURS') { input message: 'Stage 확인 후 운영 배포를 진행하시겠습니까?\nStage API: https://stage-api.sam.it.kr', ok: '운영 배포 진행' @@ -111,11 +114,11 @@ pipeline { post { success { slackSend channel: '#product_infra', color: 'good', tokenCredentialId: 'slack-token', - message: "✅ *api* 배포 성공 (`${env.BRANCH_NAME}`)\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>" + message: "✅ *api* 배포 성공 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>" } failure { slackSend channel: '#product_infra', color: 'danger', tokenCredentialId: 'slack-token', - message: "❌ *api* 배포 실패 (`${env.BRANCH_NAME}`)\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>" + message: "❌ *api* 배포 실패 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>" script { if (env.BRANCH_NAME == 'main') { sshagent(credentials: ['deploy-ssh-key']) {