From 1410cf725a88ccb925c93ece7d6e490dfbcc9274 Mon Sep 17 00:00:00 2001 From: kent Date: Mon, 5 Jan 2026 15:56:46 +0900 Subject: [PATCH] =?UTF-8?q?feat(API):=20=EA=B2=AC=EC=A0=81-=EC=A3=BC?= =?UTF-8?q?=EB=AC=B8=20=EC=97=B0=EB=8F=99=20=ED=95=84=EB=93=9C=20=EB=B0=8F?= =?UTF-8?q?=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=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 - Order, OrderItem 모델에 견적 연동 필드 추가 - Quote 모델에 order_id 관계 추가 - QuoteService 개선 - 관련 마이그레이션 파일 추가 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CURRENT_WORKS.md | 21 +++ LOGICAL_RELATIONSHIPS.md | 5 +- app/Models/Orders/Order.php | 153 ++++++++++++++-- app/Models/Orders/OrderItem.php | 167 ++++++++++++++++-- app/Models/Quote/Quote.php | 10 ++ app/Services/Quote/QuoteService.php | 65 ++++++- ..._add_price_fields_to_order_items_table.php | 78 ++++++++ ...41500_add_quote_fields_to_orders_table.php | 66 +++++++ ...05_142000_add_order_id_to_quotes_table.php | 33 ++++ 9 files changed, 566 insertions(+), 32 deletions(-) create mode 100644 database/migrations/2026_01_05_140111_add_price_fields_to_order_items_table.php create mode 100644 database/migrations/2026_01_05_141500_add_quote_fields_to_orders_table.php create mode 100644 database/migrations/2026_01_05_142000_add_order_id_to_quotes_table.php diff --git a/CURRENT_WORKS.md b/CURRENT_WORKS.md index 3bad373..8ab210b 100644 --- a/CURRENT_WORKS.md +++ b/CURRENT_WORKS.md @@ -36,6 +36,27 @@ ### 남은 작업 --- +## 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 구현 ### 작업 목표 diff --git a/LOGICAL_RELATIONSHIPS.md b/LOGICAL_RELATIONSHIPS.md index 938716b..a71cb9e 100644 --- a/LOGICAL_RELATIONSHIPS.md +++ b/LOGICAL_RELATIONSHIPS.md @@ -1,6 +1,6 @@ # 논리적 데이터베이스 관계 문서 -> **자동 생성**: 2026-01-02 11:42:39 +> **자동 생성**: 2026-01-05 14:14:22 > **소스**: Eloquent 모델 관계 분석 ## 📊 모델별 관계 현황 @@ -297,6 +297,7 @@ ### client_groups ### orders **모델**: `App\Models\Orders\Order` +- **quote()**: belongsTo → `quotes` - **item()**: belongsTo → `items` - **items()**: hasMany → `order_items` - **histories()**: hasMany → `order_histories` @@ -312,6 +313,8 @@ ### order_items - **order()**: belongsTo → `orders` - **item()**: belongsTo → `items` +- **quote()**: belongsTo → `quotes` +- **quoteItem()**: belongsTo → `quote_items` - **components()**: hasMany → `order_item_components` ### order_item_components diff --git a/app/Models/Orders/Order.php b/app/Models/Orders/Order.php index 93f3bc8..5948233 100644 --- a/app/Models/Orders/Order.php +++ b/app/Models/Orders/Order.php @@ -2,47 +2,172 @@ namespace App\Models\Orders; +use App\Models\Clients\Client; use App\Models\Items\Item; +use App\Models\Quote\Quote; +use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; /** + * 수주 마스터 (Orders) + * * @mixin IdeHelperOrder */ class Order extends Model { - use SoftDeletes; + use BelongsToTenant, SoftDeletes; + + // 상태 코드 + public const STATUS_DRAFT = 'DRAFT'; + + public const STATUS_CONFIRMED = 'CONFIRMED'; + + public const STATUS_IN_PROGRESS = 'IN_PROGRESS'; + + public const STATUS_COMPLETED = 'COMPLETED'; + + public const STATUS_CANCELLED = 'CANCELLED'; + + // 주문 유형 + public const TYPE_ORDER = 'ORDER'; // 수주 + + public const TYPE_PURCHASE = 'PURCHASE'; // 발주 - // 주문(견적/수주/발주 메인) protected $table = 'orders'; protected $fillable = [ - 'tenant_id', 'order_no', 'order_type_code', 'status_code', 'category_code', 'item_id', - 'received_at', 'writer_id', 'client_id', 'client_contact', 'site_name', 'quantity', 'delivery_date', - 'delivery_method_code', 'memo', + 'tenant_id', + 'quote_id', + 'order_no', + 'order_type_code', + 'status_code', + 'category_code', + 'item_id', + 'received_at', + 'writer_id', + 'client_id', + 'client_name', + 'client_contact', + 'site_name', + 'quantity', + // 금액 정보 + 'supply_amount', + 'tax_amount', + 'total_amount', + 'discount_rate', + 'discount_amount', + // 기타 + 'delivery_date', + 'delivery_method_code', + 'memo', + 'remarks', + 'note', + // 감사 + 'created_by', + 'updated_by', + 'deleted_by', ]; - // 상세(라인) - public function items() + protected $casts = [ + 'quantity' => 'decimal:4', + 'supply_amount' => 'decimal:2', + 'tax_amount' => 'decimal:2', + 'total_amount' => 'decimal:2', + 'discount_rate' => 'decimal:2', + 'discount_amount' => 'decimal:2', + 'received_at' => 'datetime', + 'delivery_date' => 'date', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'deleted_at' => 'datetime', + ]; + + /** + * 수주 상세 품목 + */ + public function items(): HasMany { - return $this->hasMany(OrderItem::class); + return $this->hasMany(OrderItem::class)->orderBy('sort_order'); } - // 이력 - public function histories() + /** + * 수주 이력 + */ + public function histories(): HasMany { return $this->hasMany(OrderHistory::class); } - // 버전관리 - public function versions() + /** + * 수주 버전 + */ + public function versions(): HasMany { return $this->hasMany(OrderVersion::class); } - // 품목 (통합 items 테이블) - public function item() + /** + * 원본 견적 + */ + public function quote(): BelongsTo + { + return $this->belongsTo(Quote::class); + } + + /** + * 거래처 + */ + public function client(): BelongsTo + { + return $this->belongsTo(Client::class); + } + + /** + * 품목 (통합 items 테이블) + */ + public function item(): BelongsTo { return $this->belongsTo(Item::class, 'item_id'); } + + /** + * 품목들로부터 금액 합계 재계산 + */ + public function recalculateTotals(): self + { + $this->supply_amount = $this->items->sum('supply_amount'); + $this->tax_amount = $this->items->sum('tax_amount'); + $this->total_amount = $this->items->sum('total_amount'); + $this->quantity = $this->items->sum('quantity'); + + return $this; + } + + /** + * 견적에서 수주 생성 + */ + public static function createFromQuote(Quote $quote, string $orderNo): self + { + return new self([ + 'tenant_id' => $quote->tenant_id, + 'quote_id' => $quote->id, + 'order_no' => $orderNo, + 'order_type_code' => self::TYPE_ORDER, + 'status_code' => self::STATUS_DRAFT, + 'client_id' => $quote->client_id, + 'client_name' => $quote->client?->name, + 'client_contact' => $quote->contact_person, + 'site_name' => $quote->site_name, + 'quantity' => $quote->items->sum('calculated_quantity'), + 'supply_amount' => $quote->total_amount, + 'tax_amount' => round($quote->total_amount * 0.1, 2), + 'total_amount' => round($quote->total_amount * 1.1, 2), + 'delivery_date' => $quote->delivery_date, + 'memo' => $quote->remarks, + 'remarks' => $quote->internal_notes, + ]); + } } diff --git a/app/Models/Orders/OrderItem.php b/app/Models/Orders/OrderItem.php index f472a2e..3bd541f 100644 --- a/app/Models/Orders/OrderItem.php +++ b/app/Models/Orders/OrderItem.php @@ -3,38 +3,181 @@ namespace App\Models\Orders; use App\Models\Items\Item; +use App\Models\Quote\Quote; +use App\Models\Quote\QuoteItem; +use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; /** + * 수주 상세 (Order Items) + * * @mixin IdeHelperOrderItem */ class OrderItem extends Model { - use SoftDeletes; + use BelongsToTenant, SoftDeletes; - // 주문 상세 protected $table = 'order_items'; protected $fillable = [ - 'tenant_id', 'order_id', 'serial_no', 'item_id', 'quantity', - 'status_code', 'design_code', 'remarks', 'attributes', + 'tenant_id', + 'order_id', + 'quote_id', + 'quote_item_id', + 'serial_no', + // 품목 정보 + 'item_id', + 'item_code', + 'item_name', + 'specification', + 'unit', + // 수량/금액 + 'quantity', + 'unit_price', + 'supply_amount', + 'tax_amount', + 'total_amount', + // 할인 + 'discount_rate', + 'discount_amount', + // 기타 + 'status_code', + 'design_code', + 'remarks', + 'note', + 'sort_order', + 'attributes', + // 감사 + 'created_by', + 'updated_by', + 'deleted_by', ]; - // 투입 구성(자재/BOM 등) - public function components() - { - return $this->hasMany(OrderItemComponent::class); - } + protected $casts = [ + 'quantity' => 'decimal:4', + 'unit_price' => 'decimal:2', + 'supply_amount' => 'decimal:2', + 'tax_amount' => 'decimal:2', + 'total_amount' => 'decimal:2', + 'discount_rate' => 'decimal:2', + 'discount_amount' => 'decimal:2', + 'attributes' => 'array', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'deleted_at' => 'datetime', + ]; - public function order() + /** + * 수주 마스터 + */ + public function order(): BelongsTo { return $this->belongsTo(Order::class); } - // 품목 (통합 items 테이블) - public function item() + /** + * 품목 마스터 + */ + public function item(): BelongsTo { return $this->belongsTo(Item::class, 'item_id'); } + + /** + * 원본 견적 + */ + public function quote(): BelongsTo + { + return $this->belongsTo(Quote::class); + } + + /** + * 원본 견적 품목 + */ + public function quoteItem(): BelongsTo + { + return $this->belongsTo(QuoteItem::class); + } + + /** + * 투입 구성 (자재/BOM 등) + */ + public function components(): HasMany + { + return $this->hasMany(OrderItemComponent::class); + } + + /** + * 공급가액 계산 (수량 × 단가 - 할인) + */ + public function calculateSupplyAmount(): float + { + $gross = $this->quantity * $this->unit_price; + $discount = $this->discount_amount ?: ($gross * ($this->discount_rate / 100)); + + return round($gross - $discount, 2); + } + + /** + * 세액 계산 (공급가액 × 10%) + */ + public function calculateTaxAmount(): float + { + return round($this->supply_amount * 0.1, 2); + } + + /** + * 총액 계산 (공급가액 + 세액) + */ + public function calculateTotalAmount(): float + { + return round($this->supply_amount + $this->tax_amount, 2); + } + + /** + * 금액 재계산 및 저장 + */ + public function recalculateAmounts(): self + { + $this->supply_amount = $this->calculateSupplyAmount(); + $this->tax_amount = $this->calculateTaxAmount(); + $this->total_amount = $this->calculateTotalAmount(); + + return $this; + } + + /** + * 견적 품목에서 수주 품목 생성 + * + * @param int $serialIndex 품목 순번 (1부터 시작) + */ + public static function createFromQuoteItem(QuoteItem $quoteItem, int $orderId, int $serialIndex = 1): self + { + $qty = $quoteItem->calculated_quantity ?? 1; + $supplyAmount = $quoteItem->unit_price * $qty; + $taxAmount = round($supplyAmount * 0.1, 2); + + return new self([ + 'tenant_id' => $quoteItem->tenant_id, + 'order_id' => $orderId, + 'quote_id' => $quoteItem->quote_id, + 'quote_item_id' => $quoteItem->id, + 'serial_no' => str_pad($serialIndex, 3, '0', STR_PAD_LEFT), + 'item_id' => $quoteItem->item_id, + 'item_code' => $quoteItem->item_code, + 'item_name' => $quoteItem->item_name, + 'specification' => $quoteItem->specification, + 'unit' => $quoteItem->unit ?? 'EA', + 'quantity' => $qty, + 'unit_price' => $quoteItem->unit_price, + 'supply_amount' => $supplyAmount, + 'tax_amount' => $taxAmount, + 'total_amount' => $supplyAmount + $taxAmount, + 'note' => $quoteItem->note, + 'sort_order' => $quoteItem->sort_order ?? 0, + ]); + } } diff --git a/app/Models/Quote/Quote.php b/app/Models/Quote/Quote.php index 6c2f848..3632b5d 100644 --- a/app/Models/Quote/Quote.php +++ b/app/Models/Quote/Quote.php @@ -5,6 +5,7 @@ use App\Models\Items\Item; use App\Models\Members\User; use App\Models\Orders\Client; +use App\Models\Orders\Order; use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -18,6 +19,7 @@ class Quote extends Model protected $fillable = [ 'tenant_id', + 'order_id', 'quote_number', 'registration_date', 'receipt_date', @@ -142,6 +144,14 @@ public function item(): BelongsTo return $this->belongsTo(Item::class, 'item_id'); } + /** + * 전환된 수주 + */ + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + /** * 확정자 */ diff --git a/app/Services/Quote/QuoteService.php b/app/Services/Quote/QuoteService.php index c9af7e2..a317062 100644 --- a/app/Services/Quote/QuoteService.php +++ b/app/Services/Quote/QuoteService.php @@ -2,6 +2,8 @@ namespace App\Services\Quote; +use App\Models\Orders\Order; +use App\Models\Orders\OrderItem; use App\Models\Quote\Quote; use App\Models\Quote\QuoteItem; use App\Models\Quote\QuoteRevision; @@ -369,7 +371,10 @@ public function convertToOrder(int $id): Quote $tenantId = $this->tenantId(); $userId = $this->apiUserId(); - $quote = Quote::where('tenant_id', $tenantId)->find($id); + $quote = Quote::where('tenant_id', $tenantId) + ->with(['items', 'client']) + ->find($id); + if (! $quote) { throw new NotFoundHttpException(__('error.quote_not_found')); } @@ -378,19 +383,69 @@ public function convertToOrder(int $id): Quote throw new BadRequestHttpException(__('error.quote_not_convertible')); } - return DB::transaction(function () use ($quote, $userId) { - // TODO: 수주(Order) 생성 로직 구현 - // $order = $this->orderService->createFromQuote($quote); + return DB::transaction(function () use ($quote, $userId, $tenantId) { + // 수주번호 생성 + $orderNo = $this->generateOrderNumber($tenantId); + // 수주 마스터 생성 + $order = Order::createFromQuote($quote, $orderNo); + $order->created_by = $userId; + $order->save(); + + // 수주 상세 품목 생성 + $serialIndex = 1; + foreach ($quote->items as $quoteItem) { + $orderItem = OrderItem::createFromQuoteItem($quoteItem, $order->id, $serialIndex); + $orderItem->created_by = $userId; + $orderItem->save(); + $serialIndex++; + } + + // 수주 합계 재계산 + $order->load('items'); + $order->recalculateTotals(); + $order->save(); + + // 견적 상태 변경 $quote->update([ 'status' => Quote::STATUS_CONVERTED, + 'order_id' => $order->id, 'updated_by' => $userId, ]); - return $quote->refresh()->load(['items', 'client']); + return $quote->refresh()->load(['items', 'client', 'order']); }); } + /** + * 수주번호 생성 + * 형식: ORD-YYMMDD-NNN (예: ORD-260105-001) + */ + private function generateOrderNumber(int $tenantId): string + { + $dateStr = now()->format('ymd'); + $prefix = "ORD-{$dateStr}-"; + + $lastOrder = Order::withTrashed() + ->where('tenant_id', $tenantId) + ->where('order_no', 'like', $prefix.'%') + ->orderBy('order_no', 'desc') + ->first(); + + $sequence = 1; + if ($lastOrder) { + $parts = explode('-', $lastOrder->order_no); + if (count($parts) >= 3) { + $lastSeq = (int) end($parts); + $sequence = $lastSeq + 1; + } + } + + $seqStr = str_pad((string) $sequence, 3, '0', STR_PAD_LEFT); + + return "{$prefix}{$seqStr}"; + } + /** * 견적 품목 생성 */ diff --git a/database/migrations/2026_01_05_140111_add_price_fields_to_order_items_table.php b/database/migrations/2026_01_05_140111_add_price_fields_to_order_items_table.php new file mode 100644 index 0000000..c976070 --- /dev/null +++ b/database/migrations/2026_01_05_140111_add_price_fields_to_order_items_table.php @@ -0,0 +1,78 @@ +string('item_code', 50)->nullable()->after('item_id')->comment('품목코드'); + $table->string('item_name', 200)->nullable()->after('item_code')->comment('품명'); + $table->string('specification', 100)->nullable()->after('item_name')->comment('규격'); + $table->string('unit', 20)->default('EA')->after('specification')->comment('단위'); + + // 금액 정보 + $table->decimal('unit_price', 15, 2)->default(0)->after('quantity')->comment('단가'); + $table->decimal('supply_amount', 15, 2)->default(0)->after('unit_price')->comment('공급가액'); + $table->decimal('tax_amount', 15, 2)->default(0)->after('supply_amount')->comment('세액'); + $table->decimal('total_amount', 15, 2)->default(0)->after('tax_amount')->comment('금액 (공급가액 + 세액)'); + + // 할인 정보 + $table->decimal('discount_rate', 5, 2)->default(0)->after('total_amount')->comment('할인율 (%)'); + $table->decimal('discount_amount', 15, 2)->default(0)->after('discount_rate')->comment('할인금액'); + + // 추가 정보 + $table->text('note')->nullable()->after('remarks')->comment('비고'); + $table->unsignedInteger('sort_order')->default(0)->after('note')->comment('정렬순서'); + + // 견적 연결 (추적용) + $table->unsignedBigInteger('quote_id')->nullable()->after('order_id')->comment('원본 견적 ID'); + $table->unsignedBigInteger('quote_item_id')->nullable()->after('quote_id')->comment('원본 견적 품목 ID'); + + // 인덱스 + $table->index('item_code', 'idx_order_items_item_code'); + $table->index('quote_id', 'idx_order_items_quote_id'); + $table->index('sort_order', 'idx_order_items_sort_order'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('order_items', function (Blueprint $table) { + // 인덱스 삭제 + $table->dropIndex('idx_order_items_item_code'); + $table->dropIndex('idx_order_items_quote_id'); + $table->dropIndex('idx_order_items_sort_order'); + + // 컬럼 삭제 + $table->dropColumn([ + 'item_code', + 'item_name', + 'specification', + 'unit', + 'unit_price', + 'supply_amount', + 'tax_amount', + 'total_amount', + 'discount_rate', + 'discount_amount', + 'note', + 'sort_order', + 'quote_id', + 'quote_item_id', + ]); + }); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_01_05_141500_add_quote_fields_to_orders_table.php b/database/migrations/2026_01_05_141500_add_quote_fields_to_orders_table.php new file mode 100644 index 0000000..50c13c8 --- /dev/null +++ b/database/migrations/2026_01_05_141500_add_quote_fields_to_orders_table.php @@ -0,0 +1,66 @@ +unsignedBigInteger('quote_id')->nullable()->after('tenant_id')->comment('원본 견적 ID'); + + // 거래처 정보 (비정규화 - 조회 성능) + $table->string('client_name', 200)->nullable()->after('client_id')->comment('거래처명'); + + // 금액 정보 (합계) + $table->decimal('supply_amount', 15, 2)->default(0)->after('quantity')->comment('공급가액 합계'); + $table->decimal('tax_amount', 15, 2)->default(0)->after('supply_amount')->comment('세액 합계'); + $table->decimal('total_amount', 15, 2)->default(0)->after('tax_amount')->comment('총액 (공급가액 + 세액)'); + + // 할인 정보 + $table->decimal('discount_rate', 5, 2)->default(0)->after('total_amount')->comment('할인율 (%)'); + $table->decimal('discount_amount', 15, 2)->default(0)->after('discount_rate')->comment('할인금액'); + + // 추가 정보 + $table->text('remarks')->nullable()->after('memo')->comment('비고'); + $table->text('note')->nullable()->after('remarks')->comment('내부 메모'); + + // 인덱스 + $table->index('quote_id', 'idx_orders_quote_id'); + $table->index('client_name', 'idx_orders_client_name'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('orders', function (Blueprint $table) { + // 인덱스 삭제 + $table->dropIndex('idx_orders_quote_id'); + $table->dropIndex('idx_orders_client_name'); + + // 컬럼 삭제 + $table->dropColumn([ + 'quote_id', + 'client_name', + 'supply_amount', + 'tax_amount', + 'total_amount', + 'discount_rate', + 'discount_amount', + 'remarks', + 'note', + ]); + }); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_01_05_142000_add_order_id_to_quotes_table.php b/database/migrations/2026_01_05_142000_add_order_id_to_quotes_table.php new file mode 100644 index 0000000..8169061 --- /dev/null +++ b/database/migrations/2026_01_05_142000_add_order_id_to_quotes_table.php @@ -0,0 +1,33 @@ +unsignedBigInteger('order_id')->nullable()->after('tenant_id')->comment('전환된 수주 ID'); + + $table->index('order_id', 'idx_quotes_order_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('quotes', function (Blueprint $table) { + $table->dropIndex('idx_quotes_order_id'); + $table->dropColumn('order_id'); + }); + } +}; \ No newline at end of file