diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 03e127a8..3928ffed 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -95,7 +95,7 @@ public function render($request, Throwable $exception) if ($exception instanceof BadRequestHttpException) { return response()->json([ 'success' => false, - 'message' => '잘못된 요청', + 'message' => $exception->getMessage() ?: '잘못된 요청', 'data' => null, ], 400); } diff --git a/app/Http/Controllers/Api/V1/OrderController.php b/app/Http/Controllers/Api/V1/OrderController.php index d7b4b2ca..acf7001f 100644 --- a/app/Http/Controllers/Api/V1/OrderController.php +++ b/app/Http/Controllers/Api/V1/OrderController.php @@ -30,10 +30,10 @@ public function index(Request $request) /** * 통계 조회 */ - public function stats() + public function stats(Request $request) { - return ApiResponse::handle(function () { - return $this->service->stats(); + return ApiResponse::handle(function () use ($request) { + return $this->service->stats($request->input('order_type')); }, __('message.order.fetched')); } diff --git a/app/Http/Requests/Order/StoreOrderRequest.php b/app/Http/Requests/Order/StoreOrderRequest.php index aed252d1..272f924c 100644 --- a/app/Http/Requests/Order/StoreOrderRequest.php +++ b/app/Http/Requests/Order/StoreOrderRequest.php @@ -18,7 +18,7 @@ public function rules(): array return [ // 기본 정보 'quote_id' => 'nullable|integer|exists:quotes,id', - 'order_type_code' => ['nullable', Rule::in([Order::TYPE_ORDER, Order::TYPE_PURCHASE])], + 'order_type_code' => ['nullable', Rule::in([Order::TYPE_ORDER, Order::TYPE_PURCHASE, Order::TYPE_STOCK])], 'status_code' => ['nullable', Rule::in([ Order::STATUS_DRAFT, Order::STATUS_CONFIRMED, @@ -55,6 +55,8 @@ public function rules(): array 'options.shipping_address' => 'nullable|string|max:500', 'options.shipping_address_detail' => 'nullable|string|max:500', 'options.manager_name' => 'nullable|string|max:100', + 'options.production_reason' => 'nullable|string|max:500', + 'options.target_stock_qty' => 'nullable|numeric|min:0', // 품목 배열 'items' => 'nullable|array', diff --git a/app/Http/Requests/Order/UpdateOrderRequest.php b/app/Http/Requests/Order/UpdateOrderRequest.php index 59a25181..53cd3168 100644 --- a/app/Http/Requests/Order/UpdateOrderRequest.php +++ b/app/Http/Requests/Order/UpdateOrderRequest.php @@ -17,7 +17,7 @@ public function rules(): array { return [ // 기본 정보 (order_no는 수정 불가) - 'order_type_code' => ['nullable', Rule::in([Order::TYPE_ORDER, Order::TYPE_PURCHASE])], + 'order_type_code' => ['nullable', Rule::in([Order::TYPE_ORDER, Order::TYPE_PURCHASE, Order::TYPE_STOCK])], 'category_code' => 'nullable|string|max:50', // 거래처 정보 @@ -49,6 +49,8 @@ public function rules(): array 'options.shipping_address' => 'nullable|string|max:500', 'options.shipping_address_detail' => 'nullable|string|max:500', 'options.manager_name' => 'nullable|string|max:100', + 'options.production_reason' => 'nullable|string|max:500', + 'options.target_stock_qty' => 'nullable|numeric|min:0', // 품목 배열 (전체 교체) 'items' => 'nullable|array', diff --git a/app/Models/Orders/Order.php b/app/Models/Orders/Order.php index 426aece9..792ab592 100644 --- a/app/Models/Orders/Order.php +++ b/app/Models/Orders/Order.php @@ -78,6 +78,8 @@ class Order extends Model public const TYPE_PURCHASE = 'PURCHASE'; // 발주 + public const TYPE_STOCK = 'STOCK'; // 재고생산 + // 매출 인식 시점 public const SALES_ON_ORDER_CONFIRM = 'on_order_confirm'; // 수주확정 시 diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php index f0636ca0..f66ac5ee 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -109,17 +109,22 @@ public function index(array $params) /** * 통계 조회 */ - public function stats(): array + public function stats(?string $orderType = null): array { $tenantId = $this->tenantId(); - $counts = Order::where('tenant_id', $tenantId) + $baseQuery = Order::where('tenant_id', $tenantId); + if ($orderType !== null) { + $baseQuery->where('order_type_code', $orderType); + } + + $counts = (clone $baseQuery) ->select('status_code', DB::raw('count(*) as count')) ->groupBy('status_code') ->pluck('count', 'status_code') ->toArray(); - $amounts = Order::where('tenant_id', $tenantId) + $amounts = (clone $baseQuery) ->select('status_code', DB::raw('sum(total_amount) as total')) ->groupBy('status_code') ->pluck('total', 'status_code') @@ -162,10 +167,13 @@ public function store(array $data) $userId = $this->apiUserId(); return DB::transaction(function () use ($data, $tenantId, $userId) { - // 수주번호 자동 생성 + // 수주번호 자동 생성 (재고생산은 STK 접두사) $pairCode = $data['pair_code'] ?? null; unset($data['pair_code']); - $data['order_no'] = $this->generateOrderNo($tenantId, $pairCode); + $isStock = ($data['order_type_code'] ?? null) === Order::TYPE_STOCK; + $data['order_no'] = $isStock + ? $this->generateStockOrderNo($tenantId) + : $this->generateOrderNo($tenantId, $pairCode); $data['tenant_id'] = $tenantId; $data['created_by'] = $userId; $data['updated_by'] = $userId; @@ -174,6 +182,11 @@ public function store(array $data) $data['status_code'] = $data['status_code'] ?? Order::STATUS_DRAFT; $data['order_type_code'] = $data['order_type_code'] ?? Order::TYPE_ORDER; + // 재고생산: 현장명 자동 설정 + if ($isStock) { + $data['site_name'] = '재고생산'; + } + $items = $data['items'] ?? []; unset($data['items']); @@ -629,8 +642,8 @@ public function updateStatus(int $id, string $status) $createdSale = null; $previousStatus = $order->status_code; - // 수주확정 시 매출 자동 생성 (sales_recognition = on_order_confirm인 경우) - if ($status === Order::STATUS_CONFIRMED && $order->shouldCreateSaleOnConfirm()) { + // 수주확정 시 매출 자동 생성 (재고생산은 매출 생성 불필요) + if ($status === Order::STATUS_CONFIRMED && $order->order_type_code !== Order::TYPE_STOCK && $order->shouldCreateSaleOnConfirm()) { $createdSale = $this->createSaleFromOrder($order, $userId); $order->sale_id = $createdSale->id; } @@ -776,6 +789,29 @@ private function generateOrderNoLegacy(int $tenantId): string return sprintf('%s%s%04d', $prefix, $date, $seq); } + /** + * 재고생산 번호 생성 (STK{YYYYMMDD}{NNNN}) + */ + private function generateStockOrderNo(int $tenantId): string + { + $prefix = 'STK'; + $date = now()->format('Ymd'); + + $lastNo = Order::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('order_no', 'like', "{$prefix}{$date}%") + ->orderByDesc('order_no') + ->value('order_no'); + + if ($lastNo) { + $seq = (int) substr($lastNo, -4) + 1; + } else { + $seq = 1; + } + + return sprintf('%s%s%04d', $prefix, $date, $seq); + } + /** * 견적에서 수주 생성 */ @@ -1202,124 +1238,145 @@ public function createProductionOrder(int $orderId, array $data) throw new BadRequestHttpException(__('error.order.production_order_already_exists')); } - // order_nodes의 BOM 결과를 기반으로 공정별 자동 분류 - $bomItemIds = []; - $nodesBomMap = []; // node_id => [item_name => bom_item] + // 재고생산(STOCK): 절곡 공정에 모든 품목 직접 배정 (BOM 매칭 스킵) + $isStock = $order->order_type_code === Order::TYPE_STOCK; + $nodesBomMap = []; - foreach ($order->rootNodes as $node) { - $bomResult = $node->options['bom_result'] ?? []; - $bomItems = $bomResult['items'] ?? []; - - foreach ($bomItems as $bomItem) { - if (! empty($bomItem['item_id'])) { - $bomItemIds[] = $bomItem['item_id']; - $nodesBomMap[$node->id][$bomItem['item_name']] = $bomItem; - } - } - } - - $bomItemIds = array_unique($bomItemIds); - - // process_items 테이블에서 item_id → process_id 매핑 조회 - $itemProcessMap = []; - if (! empty($bomItemIds)) { - $processItems = DB::table('process_items as pi') - ->join('processes as p', 'pi.process_id', '=', 'p.id') - ->where('p.tenant_id', $tenantId) - ->whereIn('pi.item_id', $bomItemIds) - ->where('pi.is_active', true) - ->select('pi.item_id', 'pi.process_id') - ->get(); - - foreach ($processItems as $pi) { - $itemProcessMap[$pi->item_id] = $pi->process_id; - } - } - - // item_code → item_id 매핑 구축 (fallback용 — N+1 방지를 위해 사전 일괄 조회) - $codeToIdMap = []; - if (! empty($bomItemIds)) { - $codeToIdRows = DB::table('items') - ->where('tenant_id', $tenantId) - ->whereIn('id', $bomItemIds) - ->whereNull('deleted_at') - ->select('id', 'code') - ->get(); - foreach ($codeToIdRows as $row) { - $codeToIdMap[$row->code] = $row->id; - } - } - - // order_items의 item_code로 추가 매핑 사전 구축 (루프 내 DB 조회 방지) - $orderItemCodes = $order->items->pluck('item_code')->filter()->unique()->values()->all(); - $unmappedCodes = array_diff($orderItemCodes, array_keys($codeToIdMap)); - if (! empty($unmappedCodes)) { - $extraRows = DB::table('items') - ->where('tenant_id', $tenantId) - ->whereIn('code', $unmappedCodes) - ->whereNull('deleted_at') - ->select('id', 'code') - ->get(); - foreach ($extraRows as $row) { - $codeToIdMap[$row->code] = $row->id; - } - } - - // 사전 매핑된 item_id에 대한 process_items도 일괄 조회 - $allResolvedIds = array_values(array_unique(array_merge( - array_keys($itemProcessMap), - array_values($codeToIdMap) - ))); - $unmappedProcessIds = array_diff($allResolvedIds, array_keys($itemProcessMap)); - if (! empty($unmappedProcessIds)) { - $extraProcessItems = DB::table('process_items') - ->whereIn('item_id', $unmappedProcessIds) + if ($isStock) { + $bendingProcess = \App\Models\Process::where('tenant_id', $tenantId) + ->where('process_name', '절곡') ->where('is_active', true) - ->select('item_id', 'process_id') - ->get(); - foreach ($extraProcessItems as $pi) { - $itemProcessMap[$pi->item_id] = $pi->process_id; - } - } + ->first(); - // order_items를 공정별로 그룹화 (BOM item_id → process 매핑 활용) - $itemsByProcess = []; - foreach ($order->items as $orderItem) { - $processId = null; - - // 1. order_item의 item_id가 있으면 직접 매핑 - if ($orderItem->item_id && isset($itemProcessMap[$orderItem->item_id])) { - $processId = $itemProcessMap[$orderItem->item_id]; + if (! $bendingProcess) { + throw new BadRequestHttpException(__('error.order.bending_process_not_found')); } - // 2. item_id가 없으면 노드의 BOM에서 item_name으로 찾기 - elseif ($orderItem->order_node_id && isset($nodesBomMap[$orderItem->order_node_id])) { - $nodeBom = $nodesBomMap[$orderItem->order_node_id]; - $bomItem = $nodeBom[$orderItem->item_name] ?? null; - if ($bomItem && ! empty($bomItem['item_id']) && isset($itemProcessMap[$bomItem['item_id']])) { - $processId = $itemProcessMap[$bomItem['item_id']]; + + $itemsByProcess = [ + $bendingProcess->id => [ + 'process_id' => $bendingProcess->id, + 'items' => $order->items->all(), + ], + ]; + } else { + // 기존 로직: order_nodes의 BOM 결과를 기반으로 공정별 자동 분류 + $bomItemIds = []; + + foreach ($order->rootNodes as $node) { + $bomResult = $node->options['bom_result'] ?? []; + $bomItems = $bomResult['items'] ?? []; + + foreach ($bomItems as $bomItem) { + if (! empty($bomItem['item_id'])) { + $bomItemIds[] = $bomItem['item_id']; + $nodesBomMap[$node->id][$bomItem['item_name']] = $bomItem; + } } } - // 3. fallback: 사전 구축된 맵에서 item_code → process 매핑 (N+1 제거) - if ($processId === null && $orderItem->item_code) { - $resolvedId = $codeToIdMap[$orderItem->item_code] ?? null; - if ($resolvedId && isset($itemProcessMap[$resolvedId])) { - $processId = $itemProcessMap[$resolvedId]; + $bomItemIds = array_unique($bomItemIds); + + // process_items 테이블에서 item_id → process_id 매핑 조회 + $itemProcessMap = []; + if (! empty($bomItemIds)) { + $processItems = DB::table('process_items as pi') + ->join('processes as p', 'pi.process_id', '=', 'p.id') + ->where('p.tenant_id', $tenantId) + ->whereIn('pi.item_id', $bomItemIds) + ->where('pi.is_active', true) + ->select('pi.item_id', 'pi.process_id') + ->get(); + + foreach ($processItems as $pi) { + $itemProcessMap[$pi->item_id] = $pi->process_id; } } - $key = $processId ?? 'none'; - - if (! isset($itemsByProcess[$key])) { - $itemsByProcess[$key] = [ - 'process_id' => $processId, - 'items' => [], - ]; + // item_code → item_id 매핑 구축 (fallback용 — N+1 방지를 위해 사전 일괄 조회) + $codeToIdMap = []; + if (! empty($bomItemIds)) { + $codeToIdRows = DB::table('items') + ->where('tenant_id', $tenantId) + ->whereIn('id', $bomItemIds) + ->whereNull('deleted_at') + ->select('id', 'code') + ->get(); + foreach ($codeToIdRows as $row) { + $codeToIdMap[$row->code] = $row->id; + } + } + + // order_items의 item_code로 추가 매핑 사전 구축 (루프 내 DB 조회 방지) + $orderItemCodes = $order->items->pluck('item_code')->filter()->unique()->values()->all(); + $unmappedCodes = array_diff($orderItemCodes, array_keys($codeToIdMap)); + if (! empty($unmappedCodes)) { + $extraRows = DB::table('items') + ->where('tenant_id', $tenantId) + ->whereIn('code', $unmappedCodes) + ->whereNull('deleted_at') + ->select('id', 'code') + ->get(); + foreach ($extraRows as $row) { + $codeToIdMap[$row->code] = $row->id; + } + } + + // 사전 매핑된 item_id에 대한 process_items도 일괄 조회 + $allResolvedIds = array_values(array_unique(array_merge( + array_keys($itemProcessMap), + array_values($codeToIdMap) + ))); + $unmappedProcessIds = array_diff($allResolvedIds, array_keys($itemProcessMap)); + if (! empty($unmappedProcessIds)) { + $extraProcessItems = DB::table('process_items') + ->whereIn('item_id', $unmappedProcessIds) + ->where('is_active', true) + ->select('item_id', 'process_id') + ->get(); + foreach ($extraProcessItems as $pi) { + $itemProcessMap[$pi->item_id] = $pi->process_id; + } + } + + // order_items를 공정별로 그룹화 (BOM item_id → process 매핑 활용) + $itemsByProcess = []; + foreach ($order->items as $orderItem) { + $processId = null; + + // 1. order_item의 item_id가 있으면 직접 매핑 + if ($orderItem->item_id && isset($itemProcessMap[$orderItem->item_id])) { + $processId = $itemProcessMap[$orderItem->item_id]; + } + // 2. item_id가 없으면 노드의 BOM에서 item_name으로 찾기 + elseif ($orderItem->order_node_id && isset($nodesBomMap[$orderItem->order_node_id])) { + $nodeBom = $nodesBomMap[$orderItem->order_node_id]; + $bomItem = $nodeBom[$orderItem->item_name] ?? null; + if ($bomItem && ! empty($bomItem['item_id']) && isset($itemProcessMap[$bomItem['item_id']])) { + $processId = $itemProcessMap[$bomItem['item_id']]; + } + } + + // 3. fallback: 사전 구축된 맵에서 item_code → process 매핑 (N+1 제거) + if ($processId === null && $orderItem->item_code) { + $resolvedId = $codeToIdMap[$orderItem->item_code] ?? null; + if ($resolvedId && isset($itemProcessMap[$resolvedId])) { + $processId = $itemProcessMap[$resolvedId]; + } + } + + $key = $processId ?? 'none'; + + if (! isset($itemsByProcess[$key])) { + $itemsByProcess[$key] = [ + 'process_id' => $processId, + 'items' => [], + ]; + } + $itemsByProcess[$key]['items'][] = $orderItem; } - $itemsByProcess[$key]['items'][] = $orderItem; } - return DB::transaction(function () use ($order, $data, $tenantId, $userId, $itemsByProcess, $nodesBomMap) { + return DB::transaction(function () use ($order, $data, $tenantId, $userId, $itemsByProcess, $nodesBomMap, $isStock) { $workOrders = []; // 담당자 ID 배열 처리 (assignee_ids 우선, fallback으로 assignee_id) @@ -1339,6 +1396,7 @@ public function createProductionOrder(int $orderId, array $data) // 공정 옵션 초기화 (보조 공정 플래그 포함) $workOrderOptions = null; + $process = null; if ($processId) { $process = \App\Models\Process::find($processId); if ($process && ! empty($process->options['is_auxiliary'])) { @@ -1378,13 +1436,13 @@ public function createProductionOrder(int $orderId, array $data) 'tenant_id' => $tenantId, 'work_order_no' => $workOrderNo, 'sales_order_id' => $order->id, - 'project_name' => $order->site_name ?? $order->client_name, + 'project_name' => $isStock ? '재고생산' : ($order->site_name ?? $order->client_name), 'process_id' => $processId, 'status' => (! empty($assigneeIds) || $teamId) ? WorkOrder::STATUS_WAITING : WorkOrder::STATUS_UNASSIGNED, 'priority' => $priority, 'assignee_id' => $primaryAssigneeId, 'team_id' => $teamId, - 'scheduled_date' => $data['scheduled_date'] ?? $order->delivery_date, + 'scheduled_date' => $data['scheduled_date'] ?? ($isStock ? now()->toDateString() : $order->delivery_date), 'memo' => $data['memo'] ?? null, 'options' => $workOrderOptions, 'is_active' => true, @@ -1473,7 +1531,7 @@ public function createProductionOrder(int $orderId, array $data) 'item_id' => $itemId, 'item_name' => $orderItem->item_name, 'specification' => $orderItem->specification, - 'quantity' => $orderItem->quantity, + 'quantity' => (int) $orderItem->quantity, 'unit' => $orderItem->unit, 'sort_order' => $sortOrder++, 'status' => 'pending', diff --git a/database/migrations/2026_03_17_105822_unify_account_codes_category_to_english.php b/database/migrations/2026_03_17_105822_unify_account_codes_category_to_english.php new file mode 100644 index 00000000..74e2ef91 --- /dev/null +++ b/database/migrations/2026_03_17_105822_unify_account_codes_category_to_english.php @@ -0,0 +1,52 @@ + 'asset', + '부채' => 'liability', + '자본' => 'capital', + '수익' => 'revenue', + '비용' => 'expense', + ]; + + foreach ($mapping as $korean => $english) { + DB::table('account_codes') + ->where('category', $korean) + ->update(['category' => $english]); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $mapping = [ + 'asset' => '자산', + 'liability' => '부채', + 'capital' => '자본', + 'revenue' => '수익', + 'expense' => '비용', + ]; + + // tenant_id=1만 원복 (다른 테넌트는 원래 영문) + foreach ($mapping as $english => $korean) { + DB::table('account_codes') + ->where('tenant_id', 1) + ->where('category', $english) + ->update(['category' => $korean]); + } + } +}; diff --git a/database/migrations/2026_03_17_111249_replace_tenant1_account_codes_with_kis_standard.php b/database/migrations/2026_03_17_111249_replace_tenant1_account_codes_with_kis_standard.php new file mode 100644 index 00000000..43bd5d63 --- /dev/null +++ b/database/migrations/2026_03_17_111249_replace_tenant1_account_codes_with_kis_standard.php @@ -0,0 +1,68 @@ +where('tenant_id', '!=', 1) + ->whereRaw('LENGTH(code) = 5') + ->value('tenant_id'); + + if (! $sourceTenantId) { + // 소스 테넌트가 없으면 스킵 + return; + } + + // 1. tenant_id=1 기존 데이터 전체 삭제 + DB::table('account_codes')->where('tenant_id', 1)->delete(); + + // 2. 소스 테넌트의 5자리 코드를 tenant_id=1로 복사 (활성 상태) + $sourceCodes = DB::table('account_codes') + ->where('tenant_id', $sourceTenantId) + ->whereRaw('LENGTH(code) = 5') + ->get(); + + $now = now(); + $inserts = $sourceCodes->map(fn ($row) => [ + 'tenant_id' => 1, + 'code' => $row->code, + 'name' => $row->name, + 'category' => $row->category, + 'sub_category' => $row->sub_category, + 'parent_code' => $row->parent_code, + 'depth' => $row->depth, + 'department_type' => $row->department_type, + 'description' => $row->description, + 'sort_order' => $row->sort_order, + 'is_active' => true, + 'created_at' => $now, + 'updated_at' => $now, + ])->toArray(); + + // chunk insert (한 번에 100건씩) + foreach (array_chunk($inserts, 100) as $chunk) { + DB::table('account_codes')->insert($chunk); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // 롤백 불가 (기존 3자리 데이터 복원 불가능) + // 필요 시 백업에서 복원 + } +}; diff --git a/lang/en/error.php b/lang/en/error.php index cea9ee2c..01aa5b42 100644 --- a/lang/en/error.php +++ b/lang/en/error.php @@ -122,6 +122,7 @@ 'not_found' => 'Work order not found.', 'cannot_delete_in_progress' => 'Cannot delete in progress or completed work order.', 'not_bending_process' => 'This is not a bending process.', + 'bending_process_not_found' => 'Bending process not found. Please check process settings.', 'invalid_transition' => "Cannot transition status from ':from' to ':to'. Allowed statuses: :allowed", ], diff --git a/lang/ko/error.php b/lang/ko/error.php index 29d03c1d..68027f33 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -428,6 +428,7 @@ 'already_created_from_quote' => '이미 해당 견적에서 수주가 생성되었습니다.', 'must_be_confirmed_for_production' => '확정 상태의 수주만 생산지시를 생성할 수 있습니다.', 'production_order_already_exists' => '이미 생산지시가 존재합니다.', + 'bending_process_not_found' => '절곡 공정을 찾을 수 없습니다. 공정 설정을 확인해주세요.', 'cannot_revert_completed' => '완료된 수주의 생산지시는 되돌릴 수 없습니다.', 'cannot_revert_not_confirmed' => '수주확정 상태에서만 되돌리기가 가능합니다.', 'cannot_revert_work_order_completed' => '완료 또는 출하된 작업지시는 취소할 수 없습니다.',