From 090c07605e089e057d556595dee5540d75e756b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Fri, 16 Jan 2026 21:58:57 +0900 Subject: [PATCH] =?UTF-8?q?fix(WEB):=20=EC=88=98=EC=A3=BC=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D/=EC=88=98=EC=A0=95=20=EC=98=B5=EC=85=98=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=A0=80=EC=9E=A5=20=EB=B0=8F=20=EB=8B=B4=EB=8B=B9?= =?UTF-8?q?=EC=9E=90=20=ED=91=9C=EC=8B=9C=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FormRequest에 options 필드 validation 추가 (StoreOrderRequest, UpdateOrderRequest) - shipping_cost_code, receiver, receiver_contact, shipping_address 등 - OrderService.show()에서 client 로드 시 manager_name 필드 추가 - 수주확정/생산지시 되돌리기 기능 추가 (revertOrderConfirmation, revertProductionOrder) - 견적 calculation_inputs 포함하여 로드 Co-Authored-By: Claude --- LOGICAL_RELATIONSHIPS.md | 2 +- .../Controllers/Api/V1/OrderController.php | 20 ++++ .../Controllers/Api/V1/QuoteController.php | 15 ++- app/Http/Requests/Order/StoreOrderRequest.php | 8 ++ .../Requests/Order/UpdateOrderRequest.php | 8 ++ app/Models/Orders/Order.php | 2 + app/Models/Orders/OrderItem.php | 9 +- app/Services/OrderService.php | 101 +++++++++++++++++- app/Services/Quote/QuoteService.php | 38 ++++++- ..._16_202809_add_options_to_orders_table.php | 29 +++++ ...r_and_symbol_code_to_order_items_table.php | 29 +++++ lang/ko/error.php | 2 + lang/ko/message.php | 2 + routes/api.php | 6 ++ 14 files changed, 262 insertions(+), 9 deletions(-) create mode 100644 database/migrations/2026_01_16_202809_add_options_to_orders_table.php create mode 100644 database/migrations/2026_01_16_204302_add_floor_and_symbol_code_to_order_items_table.php diff --git a/LOGICAL_RELATIONSHIPS.md b/LOGICAL_RELATIONSHIPS.md index f37f762..cfce5a1 100644 --- a/LOGICAL_RELATIONSHIPS.md +++ b/LOGICAL_RELATIONSHIPS.md @@ -1,6 +1,6 @@ # 논리적 데이터베이스 관계 문서 -> **자동 생성**: 2026-01-16 14:58:07 +> **자동 생성**: 2026-01-16 20:48:14 > **소스**: Eloquent 모델 관계 분석 ## 📊 모델별 관계 현황 diff --git a/app/Http/Controllers/Api/V1/OrderController.php b/app/Http/Controllers/Api/V1/OrderController.php index b8d8c1e..f6935bc 100644 --- a/app/Http/Controllers/Api/V1/OrderController.php +++ b/app/Http/Controllers/Api/V1/OrderController.php @@ -107,4 +107,24 @@ public function createProductionOrder(CreateProductionOrderRequest $request, int return $this->service->createProductionOrder($id, $request->validated()); }, __('message.order.production_order_created')); } + + /** + * 수주확정 되돌리기 (수주등록 상태로 변경) + */ + public function revertOrderConfirmation(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->revertOrderConfirmation($id); + }, __('message.order.order_confirmation_reverted')); + } + + /** + * 생산지시 되돌리기 (작업지시 및 관련 데이터 삭제) + */ + public function revertProductionOrder(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->revertProductionOrder($id); + }, __('message.order.production_order_reverted')); + } } diff --git a/app/Http/Controllers/Api/V1/QuoteController.php b/app/Http/Controllers/Api/V1/QuoteController.php index 469c7cd..cd76dd6 100644 --- a/app/Http/Controllers/Api/V1/QuoteController.php +++ b/app/Http/Controllers/Api/V1/QuoteController.php @@ -81,8 +81,19 @@ public function store(QuoteStoreRequest $request) */ public function update(QuoteUpdateRequest $request, int $id) { - return ApiResponse::handle(function () use ($request, $id) { - return $this->quoteService->update($id, $request->validated()); + $validated = $request->validated(); + + // 🔍 디버깅: 요청 데이터 확인 + \Log::info('🔍 [QuoteController::update] 요청 수신', [ + 'id' => $id, + 'raw_options_keys' => $request->input('options') ? array_keys($request->input('options')) : null, + 'raw_options_detail_items_count' => $request->input('options.detail_items') ? count($request->input('options.detail_items')) : 0, + 'validated_options_keys' => isset($validated['options']) ? array_keys($validated['options']) : null, + 'validated_options_detail_items_count' => isset($validated['options']['detail_items']) ? count($validated['options']['detail_items']) : 0, + ]); + + return ApiResponse::handle(function () use ($validated, $id) { + return $this->quoteService->update($id, $validated); }, __('message.quote.updated')); } diff --git a/app/Http/Requests/Order/StoreOrderRequest.php b/app/Http/Requests/Order/StoreOrderRequest.php index 471805d..32bb859 100644 --- a/app/Http/Requests/Order/StoreOrderRequest.php +++ b/app/Http/Requests/Order/StoreOrderRequest.php @@ -46,6 +46,14 @@ public function rules(): array 'remarks' => 'nullable|string', 'note' => 'nullable|string', + // 옵션 (운임비용, 수신자 정보 등) + 'options' => 'nullable|array', + 'options.shipping_cost_code' => 'nullable|string|max:50', + 'options.receiver' => 'nullable|string|max:100', + 'options.receiver_contact' => 'nullable|string|max:100', + 'options.shipping_address' => 'nullable|string|max:500', + 'options.shipping_address_detail' => 'nullable|string|max:500', + // 품목 배열 'items' => 'nullable|array', 'items.*.item_id' => 'nullable|integer|exists:items,id', diff --git a/app/Http/Requests/Order/UpdateOrderRequest.php b/app/Http/Requests/Order/UpdateOrderRequest.php index fd731b8..7f40a32 100644 --- a/app/Http/Requests/Order/UpdateOrderRequest.php +++ b/app/Http/Requests/Order/UpdateOrderRequest.php @@ -41,6 +41,14 @@ public function rules(): array 'remarks' => 'nullable|string', 'note' => 'nullable|string', + // 옵션 (운임비용, 수신자 정보 등) + 'options' => 'nullable|array', + 'options.shipping_cost_code' => 'nullable|string|max:50', + 'options.receiver' => 'nullable|string|max:100', + 'options.receiver_contact' => 'nullable|string|max:100', + 'options.shipping_address' => 'nullable|string|max:500', + 'options.shipping_address_detail' => 'nullable|string|max:500', + // 품목 배열 (전체 교체) 'items' => 'nullable|array', 'items.*.item_id' => 'nullable|integer|exists:items,id', diff --git a/app/Models/Orders/Order.php b/app/Models/Orders/Order.php index 205d288..0beafaf 100644 --- a/app/Models/Orders/Order.php +++ b/app/Models/Orders/Order.php @@ -65,6 +65,7 @@ class Order extends Model 'memo', 'remarks', 'note', + 'options', // 감사 'created_by', 'updated_by', @@ -80,6 +81,7 @@ class Order extends Model 'discount_amount' => 'decimal:2', 'received_at' => 'datetime', 'delivery_date' => 'date', + 'options' => 'array', 'created_at' => 'datetime', 'updated_at' => 'datetime', 'deleted_at' => 'datetime', diff --git a/app/Models/Orders/OrderItem.php b/app/Models/Orders/OrderItem.php index 3bd541f..3b72bfe 100644 --- a/app/Models/Orders/OrderItem.php +++ b/app/Models/Orders/OrderItem.php @@ -33,6 +33,9 @@ class OrderItem extends Model 'item_code', 'item_name', 'specification', + // 제품-부품 매핑용 코드 + 'floor_code', + 'symbol_code', 'unit', // 수량/금액 'quantity', @@ -153,8 +156,9 @@ public function recalculateAmounts(): self * 견적 품목에서 수주 품목 생성 * * @param int $serialIndex 품목 순번 (1부터 시작) + * @param array $productMapping 제품 매핑 정보 ['floor_code' => '10', 'symbol_code' => 'F1'] */ - public static function createFromQuoteItem(QuoteItem $quoteItem, int $orderId, int $serialIndex = 1): self + public static function createFromQuoteItem(QuoteItem $quoteItem, int $orderId, int $serialIndex = 1, array $productMapping = []): self { $qty = $quoteItem->calculated_quantity ?? 1; $supplyAmount = $quoteItem->unit_price * $qty; @@ -170,6 +174,9 @@ public static function createFromQuoteItem(QuoteItem $quoteItem, int $orderId, i 'item_code' => $quoteItem->item_code, 'item_name' => $quoteItem->item_name, 'specification' => $quoteItem->specification, + // 제품-부품 매핑 코드 + 'floor_code' => $productMapping['floor_code'] ?? null, + 'symbol_code' => $productMapping['symbol_code'] ?? null, 'unit' => $quoteItem->unit ?? 'EA', 'quantity' => $qty, 'unit_price' => $quoteItem->unit_price, diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php index b2cd981..ea68977 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -118,9 +118,9 @@ public function show(int $id) $order = Order::where('tenant_id', $tenantId) ->with([ - 'client:id,name,contact_person,phone,email', + 'client:id,name,contact_person,phone,email,manager_name', 'items' => fn ($q) => $q->orderBy('sort_order'), - 'quote:id,quote_number,site_name', + 'quote:id,quote_number,site_name,calculation_inputs', ]) ->find($id); @@ -518,4 +518,101 @@ private function generateWorkOrderNo(int $tenantId): string return sprintf('%s%s%04d', $prefix, $date, $seq); } + + /** + * 수주확정 되돌리기 (수주등록 상태로 변경) + */ + public function revertOrderConfirmation(int $orderId): array + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + // 수주 조회 + $order = Order::where('tenant_id', $tenantId) + ->find($orderId); + + if (! $order) { + throw new NotFoundHttpException(__('error.not_found')); + } + + // 수주확정 상태에서만 되돌리기 가능 + if ($order->status_code !== Order::STATUS_CONFIRMED) { + throw new BadRequestHttpException(__('error.order.cannot_revert_not_confirmed')); + } + + // 상태 변경 + $previousStatus = $order->status_code; + $order->status_code = Order::STATUS_DRAFT; + $order->updated_by = $userId; + $order->save(); + + return [ + 'order' => $order->load(['client:id,name', 'items']), + 'previous_status' => $previousStatus, + ]; + } + + /** + * 생산지시 되돌리기 (작업지시 및 관련 데이터 삭제) + */ + public function revertProductionOrder(int $orderId): array + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + // 수주 조회 + $order = Order::where('tenant_id', $tenantId) + ->find($orderId); + + if (! $order) { + throw new NotFoundHttpException(__('error.not_found')); + } + + // 완료된 수주는 되돌리기 불가 + if ($order->status_code === Order::STATUS_COMPLETED) { + throw new BadRequestHttpException(__('error.order.cannot_revert_completed')); + } + + return DB::transaction(function () use ($order, $tenantId, $userId) { + // 관련 작업지시 ID 조회 + $workOrderIds = WorkOrder::where('tenant_id', $tenantId) + ->where('sales_order_id', $order->id) + ->pluck('id') + ->toArray(); + + $deletedCounts = [ + 'work_results' => 0, + 'work_order_items' => 0, + 'work_orders' => 0, + ]; + + if (count($workOrderIds) > 0) { + // 1. 작업결과 삭제 + $deletedCounts['work_results'] = DB::table('work_results') + ->whereIn('work_order_id', $workOrderIds) + ->delete(); + + // 2. 작업지시 품목 삭제 + $deletedCounts['work_order_items'] = DB::table('work_order_items') + ->whereIn('work_order_id', $workOrderIds) + ->delete(); + + // 3. 작업지시 삭제 + $deletedCounts['work_orders'] = WorkOrder::whereIn('id', $workOrderIds) + ->delete(); + } + + // 4. 수주 상태를 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']), + 'deleted_counts' => $deletedCounts, + 'previous_status' => $previousStatus, + ]; + }); + } } diff --git a/app/Services/Quote/QuoteService.php b/app/Services/Quote/QuoteService.php index c7373e8..504a61b 100644 --- a/app/Services/Quote/QuoteService.php +++ b/app/Services/Quote/QuoteService.php @@ -359,9 +359,7 @@ public function update(int $id, array $data): Quote 'calculation_inputs' => $data['calculation_inputs'] ?? $quote->calculation_inputs, // 견적 옵션 (summary_items, expense_items, price_adjustments, detail_items, price_adjustment_data) // 기존 options와 새 options를 병합 (새 데이터가 기존 데이터를 덮어씀) - 'options' => isset($data['options']) - ? array_merge($quote->options ?? [], $data['options']) - : $quote->options, + 'options' => $this->mergeOptions($quote->options, $data['options'] ?? null), // 감사 'updated_by' => $userId, 'current_revision' => $quote->current_revision + 1, @@ -711,4 +709,38 @@ public function findBySiteBriefingId(int $siteBriefingId): ?Quote ->where('site_briefing_id', $siteBriefingId) ->first(); } + + /** + * 견적 options 병합 + * 기존 options와 새 options를 병합하여 반환 + */ + private function mergeOptions(?array $existingOptions, ?array $newOptions): ?array + { + \Log::info('🔍 [QuoteService::mergeOptions] 시작', [ + 'existingOptions_keys' => $existingOptions ? array_keys($existingOptions) : null, + 'newOptions_keys' => $newOptions ? array_keys($newOptions) : null, + 'newOptions_detail_items_count' => isset($newOptions['detail_items']) ? count($newOptions['detail_items']) : 0, + 'newOptions_price_adjustment_data' => isset($newOptions['price_adjustment_data']) ? 'exists' : 'null', + ]); + + if ($newOptions === null) { + return $existingOptions; + } + + if ($existingOptions === null) { + \Log::info('✅ [QuoteService::mergeOptions] 기존 없음, 새 options 반환', [ + 'result_keys' => array_keys($newOptions), + ]); + return $newOptions; + } + + $merged = array_merge($existingOptions, $newOptions); + + \Log::info('✅ [QuoteService::mergeOptions] 병합 완료', [ + 'merged_keys' => array_keys($merged), + 'merged_detail_items_count' => isset($merged['detail_items']) ? count($merged['detail_items']) : 0, + ]); + + return $merged; + } } diff --git a/database/migrations/2026_01_16_202809_add_options_to_orders_table.php b/database/migrations/2026_01_16_202809_add_options_to_orders_table.php new file mode 100644 index 0000000..596316f --- /dev/null +++ b/database/migrations/2026_01_16_202809_add_options_to_orders_table.php @@ -0,0 +1,29 @@ +json('options')->nullable()->after('note')->comment('추가 옵션 (운임비용, 수신자, 수신처 연락처, 주소 등)'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('orders', function (Blueprint $table) { + $table->dropColumn('options'); + }); + } +}; + diff --git a/database/migrations/2026_01_16_204302_add_floor_and_symbol_code_to_order_items_table.php b/database/migrations/2026_01_16_204302_add_floor_and_symbol_code_to_order_items_table.php new file mode 100644 index 0000000..a0655ae --- /dev/null +++ b/database/migrations/2026_01_16_204302_add_floor_and_symbol_code_to_order_items_table.php @@ -0,0 +1,29 @@ +string('floor_code', 50)->nullable()->after('specification')->comment('층 코드 (제품-부품 매핑용)'); + $table->string('symbol_code', 50)->nullable()->after('floor_code')->comment('부호 코드 (제품-부품 매핑용)'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('order_items', function (Blueprint $table) { + $table->dropColumn(['floor_code', 'symbol_code']); + }); + } +}; diff --git a/lang/ko/error.php b/lang/ko/error.php index c256f7c..fa8fe5e 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -366,6 +366,8 @@ 'already_created_from_quote' => '이미 해당 견적에서 수주가 생성되었습니다.', 'must_be_confirmed_for_production' => '확정 상태의 수주만 생산지시를 생성할 수 있습니다.', 'production_order_already_exists' => '이미 생산지시가 존재합니다.', + 'cannot_revert_completed' => '완료된 수주의 생산지시는 되돌릴 수 없습니다.', + 'cannot_revert_not_confirmed' => '수주확정 상태에서만 되돌리기가 가능합니다.', ], // 견적 관련 diff --git a/lang/ko/message.php b/lang/ko/message.php index fa5675a..329d939 100644 --- a/lang/ko/message.php +++ b/lang/ko/message.php @@ -456,5 +456,7 @@ 'status_updated' => '수주 상태가 변경되었습니다.', 'created_from_quote' => '견적에서 수주가 생성되었습니다.', 'production_order_created' => '생산지시가 생성되었습니다.', + 'production_order_reverted' => '생산지시가 되돌려졌습니다.', + 'order_confirmation_reverted' => '수주확정이 취소되었습니다.', ], ]; diff --git a/routes/api.php b/routes/api.php index 73cf538..5da9dd7 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1167,6 +1167,12 @@ // 생산지시 생성 Route::post('/{id}/production-order', [OrderController::class, 'createProductionOrder'])->whereNumber('id')->name('v1.orders.production-order'); + + // 수주확정 되돌리기 + Route::post('/{id}/revert-confirmation', [OrderController::class, 'revertOrderConfirmation'])->whereNumber('id')->name('v1.orders.revert-confirmation'); + + // 생산지시 되돌리기 + Route::post('/{id}/revert-production', [OrderController::class, 'revertProductionOrder'])->whereNumber('id')->name('v1.orders.revert-production'); }); // 작업지시 관리 API (Production)