fix(WEB): 수주 등록/수정 옵션 필드 저장 및 담당자 표시 문제 해결

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-01-16 21:58:57 +09:00
parent b86397cbee
commit 090c07605e
14 changed files with 262 additions and 9 deletions

View File

@@ -1,6 +1,6 @@
# 논리적 데이터베이스 관계 문서
> **자동 생성**: 2026-01-16 14:58:07
> **자동 생성**: 2026-01-16 20:48:14
> **소스**: Eloquent 모델 관계 분석
## 📊 모델별 관계 현황

View File

@@ -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'));
}
}

View File

@@ -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'));
}

View File

@@ -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',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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,

View File

@@ -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,
];
});
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->json('options')->nullable()->after('note')->comment('추가 옵션 (운임비용, 수신자, 수신처 연락처, 주소 등)');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->dropColumn('options');
});
}
};

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('order_items', function (Blueprint $table) {
$table->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']);
});
}
};

View File

@@ -366,6 +366,8 @@
'already_created_from_quote' => '이미 해당 견적에서 수주가 생성되었습니다.',
'must_be_confirmed_for_production' => '확정 상태의 수주만 생산지시를 생성할 수 있습니다.',
'production_order_already_exists' => '이미 생산지시가 존재합니다.',
'cannot_revert_completed' => '완료된 수주의 생산지시는 되돌릴 수 없습니다.',
'cannot_revert_not_confirmed' => '수주확정 상태에서만 되돌리기가 가능합니다.',
],
// 견적 관련

View File

@@ -456,5 +456,7 @@
'status_updated' => '수주 상태가 변경되었습니다.',
'created_from_quote' => '견적에서 수주가 생성되었습니다.',
'production_order_created' => '생산지시가 생성되었습니다.',
'production_order_reverted' => '생산지시가 되돌려졌습니다.',
'order_confirmation_reverted' => '수주확정이 취소되었습니다.',
],
];

View File

@@ -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)