From 8d3ea4bb39f23fffc1c32beea8d236f6c33306c8 Mon Sep 17 00:00:00 2001 From: hskwon Date: Mon, 8 Dec 2025 19:03:50 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=8B=A8=EA=B0=80=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=20API=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20Flow=20Tester=20?= =?UTF-8?q?=ED=98=B8=ED=99=98=EC=84=B1=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Price, PriceRevision 모델 추가 (PriceHistory 대체) - PricingService: CRUD, 원가 조회, 확정 기능 - PricingController: statusCode 파라미터로 201 반환 지원 - NotFoundHttpException(404) 적용 (존재하지 않는 리소스) - FormRequest 분리 (Store, Update, Index, Cost, ByItems) - Swagger 문서 업데이트 - ApiResponse::handle()에 statusCode 옵션 추가 - prices/price_revisions 마이그레이션 및 데이터 이관 --- app/Helpers/ApiResponse.php | 11 +- .../Controllers/Api/V1/PricingController.php | 121 ++-- .../Requests/Pricing/PriceByItemsRequest.php | 24 + .../Requests/Pricing/PriceCostRequest.php | 22 + .../Requests/Pricing/PriceIndexRequest.php | 27 + .../Requests/Pricing/PriceStoreRequest.php | 51 ++ .../Requests/Pricing/PriceUpdateRequest.php | 54 ++ app/Models/Products/Price.php | 258 +++++++++ app/Models/Products/PriceHistory.php | 85 --- app/Models/Products/PriceRevision.php | 92 +++ app/Services/PricingService.php | 533 ++++++++++++++++++ app/Swagger/v1/PricingApi.php | 383 +++++++++---- .../2025_12_08_154633_create_prices_table.php | 75 +++ ...08_154634_create_price_revisions_table.php | 48 ++ ...4635_migrate_price_histories_to_prices.php | 63 +++ ...2_08_154636_drop_price_histories_table.php | 44 ++ docs/api-flows/pricing-crud-flow.json | 277 +++++++++ routes/api.php | 16 +- 18 files changed, 1933 insertions(+), 251 deletions(-) create mode 100644 app/Http/Requests/Pricing/PriceByItemsRequest.php create mode 100644 app/Http/Requests/Pricing/PriceCostRequest.php create mode 100644 app/Http/Requests/Pricing/PriceIndexRequest.php create mode 100644 app/Http/Requests/Pricing/PriceStoreRequest.php create mode 100644 app/Http/Requests/Pricing/PriceUpdateRequest.php create mode 100644 app/Models/Products/Price.php delete mode 100644 app/Models/Products/PriceHistory.php create mode 100644 app/Models/Products/PriceRevision.php create mode 100644 app/Services/PricingService.php create mode 100644 database/migrations/2025_12_08_154633_create_prices_table.php create mode 100644 database/migrations/2025_12_08_154634_create_price_revisions_table.php create mode 100644 database/migrations/2025_12_08_154635_migrate_price_histories_to_prices.php create mode 100644 database/migrations/2025_12_08_154636_drop_price_histories_table.php create mode 100644 docs/api-flows/pricing-crud-flow.json diff --git a/app/Helpers/ApiResponse.php b/app/Helpers/ApiResponse.php index 20011d5..e627306 100644 --- a/app/Helpers/ApiResponse.php +++ b/app/Helpers/ApiResponse.php @@ -47,7 +47,8 @@ public static function debugQueryLog(): array public static function success( $data = null, string $message = '요청 성공', - array $debug = [] + array $debug = [], + int $statusCode = 200 ): JsonResponse { $response = [ 'success' => true, @@ -58,7 +59,7 @@ public static function success( $response['query'] = $debug; } - return response()->json($response); + return response()->json($response, $statusCode); } public static function error( @@ -149,19 +150,21 @@ public static function handle( return self::error($message, $code, ['details' => $details]); } - // 표준 박스( ['data'=>..., 'query'=>...] ) 하위호환 + // 표준 박스( ['data'=>..., 'query'=>..., 'statusCode'=>...] ) 하위호환 if (is_array($result) && array_key_exists('data', $result)) { $data = $result['data']; $debug = $result['query'] ?? []; + $statusCode = $result['statusCode'] ?? 200; } else { // 그냥 도메인 결과만 반환한 경우 $data = $result; $debug = (app()->environment('local') && request()->is('api/*')) ? self::debugQueryLog() : []; + $statusCode = 200; } - return self::success($data, $responseTitle, $debug); + return self::success($data, $responseTitle, $debug, $statusCode); } catch (\Throwable $e) { diff --git a/app/Http/Controllers/Api/V1/PricingController.php b/app/Http/Controllers/Api/V1/PricingController.php index 94791b8..99456f0 100644 --- a/app/Http/Controllers/Api/V1/PricingController.php +++ b/app/Http/Controllers/Api/V1/PricingController.php @@ -4,93 +4,124 @@ use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; -use App\Services\Pricing\PricingService; -use Illuminate\Http\Request; +use App\Http\Requests\Pricing\PriceByItemsRequest; +use App\Http\Requests\Pricing\PriceCostRequest; +use App\Http\Requests\Pricing\PriceIndexRequest; +use App\Http\Requests\Pricing\PriceStoreRequest; +use App\Http\Requests\Pricing\PriceUpdateRequest; +use App\Services\PricingService; class PricingController extends Controller { - protected PricingService $service; - - public function __construct(PricingService $service) - { - $this->service = $service; - } + public function __construct( + protected PricingService $service + ) {} /** - * 가격 이력 목록 조회 + * 단가 목록 조회 */ - public function index(Request $request) + public function index(PriceIndexRequest $request) { return ApiResponse::handle(function () use ($request) { - $filters = $request->only([ - 'item_type_code', - 'item_id', - 'price_type_code', - 'client_group_id', - 'date', - ]); - $perPage = (int) ($request->input('size') ?? 15); - - $data = $this->service->listPrices($filters, $perPage); + $data = $this->service->index($request->validated()); return ['data' => $data, 'message' => __('message.fetched')]; }); } /** - * 단일 항목 가격 조회 + * 단가 상세 조회 */ - public function show(Request $request) + public function show(int $id) { - return ApiResponse::handle(function () use ($request) { - $itemType = $request->input('item_type'); // PRODUCT | MATERIAL - $itemId = (int) $request->input('item_id'); - $clientId = $request->input('client_id') ? (int) $request->input('client_id') : null; - $date = $request->input('date') ?? null; + return ApiResponse::handle(function () use ($id) { + $data = $this->service->show($id); - $result = $this->service->getItemPrice($itemType, $itemId, $clientId, $date); - - return ['data' => $result, 'message' => __('message.fetched')]; + return ['data' => $data, 'message' => __('message.fetched')]; }); } /** - * 여러 항목 일괄 가격 조회 + * 단가 등록 */ - public function bulk(Request $request) + public function store(PriceStoreRequest $request) { return ApiResponse::handle(function () use ($request) { - $items = $request->input('items'); // [['item_type' => 'PRODUCT', 'item_id' => 1], ...] - $clientId = $request->input('client_id') ? (int) $request->input('client_id') : null; - $date = $request->input('date') ?? null; + $data = $this->service->store($request->validated()); - $result = $this->service->getBulkItemPrices($items, $clientId, $date); - - return ['data' => $result, 'message' => __('message.fetched')]; + return ['data' => $data, 'message' => __('message.created'), 'statusCode' => 201]; }); } /** - * 가격 등록/수정 + * 단가 수정 */ - public function upsert(Request $request) + public function update(PriceUpdateRequest $request, int $id) { - return ApiResponse::handle(function () use ($request) { - $data = $this->service->upsertPrice($request->all()); + return ApiResponse::handle(function () use ($request, $id) { + $data = $this->service->update($id, $request->validated()); - return ['data' => $data, 'message' => __('message.created')]; + return ['data' => $data, 'message' => __('message.updated')]; }); } /** - * 가격 삭제 + * 단가 삭제 */ public function destroy(int $id) { return ApiResponse::handle(function () use ($id) { - $this->service->deletePrice($id); + $this->service->destroy($id); return ['data' => null, 'message' => __('message.deleted')]; }); } + + /** + * 단가 확정 + */ + public function finalize(int $id) + { + return ApiResponse::handle(function () use ($id) { + $data = $this->service->finalize($id); + + return ['data' => $data, 'message' => __('message.pricing.finalized')]; + }); + } + + /** + * 품목별 단가 현황 조회 + */ + public function byItems(PriceByItemsRequest $request) + { + return ApiResponse::handle(function () use ($request) { + $data = $this->service->byItems($request->validated()); + + return ['data' => $data, 'message' => __('message.fetched')]; + }); + } + + /** + * 변경 이력 조회 + */ + public function revisions(int $id) + { + return ApiResponse::handle(function () use ($id) { + $data = $this->service->revisions($id); + + return ['data' => $data, 'message' => __('message.fetched')]; + }); + } + + /** + * 원가 조회 (수입검사 > 표준원가 fallback) + */ + public function cost(PriceCostRequest $request) + { + return ApiResponse::handle(function () use ($request) { + $data = $this->service->getCost($request->validated()); + + return ['data' => $data, 'message' => __('message.fetched')]; + }); + } } diff --git a/app/Http/Requests/Pricing/PriceByItemsRequest.php b/app/Http/Requests/Pricing/PriceByItemsRequest.php new file mode 100644 index 0000000..1d13230 --- /dev/null +++ b/app/Http/Requests/Pricing/PriceByItemsRequest.php @@ -0,0 +1,24 @@ + 'required|array|min:1|max:100', + 'items.*.item_type_code' => 'required|string|in:PRODUCT,MATERIAL', + 'items.*.item_id' => 'required|integer', + 'client_group_id' => 'nullable|integer', + 'date' => 'nullable|date', + ]; + } +} diff --git a/app/Http/Requests/Pricing/PriceCostRequest.php b/app/Http/Requests/Pricing/PriceCostRequest.php new file mode 100644 index 0000000..ed446f9 --- /dev/null +++ b/app/Http/Requests/Pricing/PriceCostRequest.php @@ -0,0 +1,22 @@ + 'required|string|in:PRODUCT,MATERIAL', + 'item_id' => 'required|integer', + 'date' => 'nullable|date', + ]; + } +} diff --git a/app/Http/Requests/Pricing/PriceIndexRequest.php b/app/Http/Requests/Pricing/PriceIndexRequest.php new file mode 100644 index 0000000..3b05a6a --- /dev/null +++ b/app/Http/Requests/Pricing/PriceIndexRequest.php @@ -0,0 +1,27 @@ + 'nullable|integer|min:1|max:100', + 'page' => 'nullable|integer|min:1', + 'q' => 'nullable|string|max:100', + 'item_type_code' => 'nullable|string|in:PRODUCT,MATERIAL', + 'item_id' => 'nullable|integer', + 'client_group_id' => 'nullable', + 'status' => 'nullable|string|in:draft,active,inactive,finalized', + 'valid_at' => 'nullable|date', + ]; + } +} diff --git a/app/Http/Requests/Pricing/PriceStoreRequest.php b/app/Http/Requests/Pricing/PriceStoreRequest.php new file mode 100644 index 0000000..588eb00 --- /dev/null +++ b/app/Http/Requests/Pricing/PriceStoreRequest.php @@ -0,0 +1,51 @@ + 'required|string|in:PRODUCT,MATERIAL', + 'item_id' => 'required|integer', + 'client_group_id' => 'nullable|integer', + + // 원가 정보 + 'purchase_price' => 'nullable|numeric|min:0', + 'processing_cost' => 'nullable|numeric|min:0', + 'loss_rate' => 'nullable|numeric|min:0|max:100', + + // 판매가 정보 + 'margin_rate' => 'nullable|numeric|min:0|max:100', + 'sales_price' => 'nullable|numeric|min:0', + 'rounding_rule' => ['nullable', Rule::in(['round', 'ceil', 'floor'])], + 'rounding_unit' => ['nullable', Rule::in([1, 10, 100, 1000])], + + // 메타 정보 + 'supplier' => 'nullable|string|max:255', + 'effective_from' => 'required|date', + 'effective_to' => 'nullable|date|after_or_equal:effective_from', + 'note' => 'nullable|string|max:1000', + + // 상태 + 'status' => ['nullable', Rule::in(['draft', 'active', 'inactive'])], + ]; + } + + public function messages(): array + { + return [ + 'effective_to.after_or_equal' => __('error.pricing.effective_to_must_be_after_from'), + ]; + } +} diff --git a/app/Http/Requests/Pricing/PriceUpdateRequest.php b/app/Http/Requests/Pricing/PriceUpdateRequest.php new file mode 100644 index 0000000..267928a --- /dev/null +++ b/app/Http/Requests/Pricing/PriceUpdateRequest.php @@ -0,0 +1,54 @@ + 'sometimes|string|in:PRODUCT,MATERIAL', + 'item_id' => 'sometimes|integer', + 'client_group_id' => 'nullable|integer', + + // 원가 정보 + 'purchase_price' => 'nullable|numeric|min:0', + 'processing_cost' => 'nullable|numeric|min:0', + 'loss_rate' => 'nullable|numeric|min:0|max:100', + + // 판매가 정보 + 'margin_rate' => 'nullable|numeric|min:0|max:100', + 'sales_price' => 'nullable|numeric|min:0', + 'rounding_rule' => ['nullable', Rule::in(['round', 'ceil', 'floor'])], + 'rounding_unit' => ['nullable', Rule::in([1, 10, 100, 1000])], + + // 메타 정보 + 'supplier' => 'nullable|string|max:255', + 'effective_from' => 'sometimes|date', + 'effective_to' => 'nullable|date|after_or_equal:effective_from', + 'note' => 'nullable|string|max:1000', + + // 상태 + 'status' => ['nullable', Rule::in(['draft', 'active', 'inactive'])], + + // 변경 사유 (리비전 기록용) + 'change_reason' => 'nullable|string|max:500', + ]; + } + + public function messages(): array + { + return [ + 'effective_to.after_or_equal' => __('error.pricing.effective_to_must_be_after_from'), + ]; + } +} diff --git a/app/Models/Products/Price.php b/app/Models/Products/Price.php new file mode 100644 index 0000000..9454a54 --- /dev/null +++ b/app/Models/Products/Price.php @@ -0,0 +1,258 @@ + 'decimal:4', + 'processing_cost' => 'decimal:4', + 'loss_rate' => 'decimal:2', + 'margin_rate' => 'decimal:2', + 'sales_price' => 'decimal:4', + 'rounding_unit' => 'integer', + 'effective_from' => 'date', + 'effective_to' => 'date', + 'is_final' => 'boolean', + 'finalized_at' => 'datetime', + ]; + + /** + * 고객 그룹 관계 + */ + public function clientGroup(): BelongsTo + { + return $this->belongsTo(ClientGroup::class, 'client_group_id'); + } + + /** + * 리비전 이력 관계 + */ + public function revisions(): HasMany + { + return $this->hasMany(PriceRevision::class, 'price_id')->orderBy('revision_number', 'desc'); + } + + /** + * 품목 관계 (Polymorphic - item_type_code에 따라) + */ + public function item() + { + if ($this->item_type_code === 'PRODUCT') { + return $this->belongsTo(Product::class, 'item_id'); + } elseif ($this->item_type_code === 'MATERIAL') { + return $this->belongsTo(Material::class, 'item_id'); + } + + return null; + } + + /** + * 제품 관계 (item_type_code = PRODUCT인 경우) + */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class, 'item_id'); + } + + /** + * 자재 관계 (item_type_code = MATERIAL인 경우) + */ + public function material(): BelongsTo + { + return $this->belongsTo(Material::class, 'item_id'); + } + + // ========== 스코프 ========== + + /** + * 특정 품목 필터 + */ + public function scopeForItem($query, string $itemType, int $itemId) + { + return $query->where('item_type_code', $itemType) + ->where('item_id', $itemId); + } + + /** + * 고객 그룹 필터 + */ + public function scopeForClientGroup($query, ?int $clientGroupId) + { + return $query->where('client_group_id', $clientGroupId); + } + + /** + * 특정 일자에 유효한 단가 + */ + public function scopeValidAt($query, $date) + { + return $query->where('effective_from', '<=', $date) + ->where(function ($q) use ($date) { + $q->whereNull('effective_to') + ->orWhere('effective_to', '>=', $date); + }); + } + + /** + * 상태 필터 + */ + public function scopeStatus($query, string $status) + { + return $query->where('status', $status); + } + + /** + * 활성 단가만 + */ + public function scopeActive($query) + { + return $query->where('status', 'active'); + } + + /** + * 확정된 단가만 + */ + public function scopeFinalized($query) + { + return $query->where('is_final', true); + } + + // ========== 계산 메서드 ========== + + /** + * 총원가 계산 + * 총원가 = (매입단가 + 가공비) × (1 + LOSS율/100) + */ + public function calculateTotalCost(): float + { + $baseCost = ($this->purchase_price ?? 0) + ($this->processing_cost ?? 0); + $lossMultiplier = 1 + (($this->loss_rate ?? 0) / 100); + + return $baseCost * $lossMultiplier; + } + + /** + * 판매단가 계산 (마진율 기반) + * 판매단가 = 반올림(총원가 × (1 + 마진율/100), 반올림단위, 반올림규칙) + */ + public function calculateSalesPrice(): float + { + $totalCost = $this->calculateTotalCost(); + $marginMultiplier = 1 + (($this->margin_rate ?? 0) / 100); + $rawPrice = $totalCost * $marginMultiplier; + + return $this->applyRounding($rawPrice); + } + + /** + * 반올림 적용 + */ + private function applyRounding(float $value): float + { + $unit = $this->rounding_unit ?: 1; + + return match ($this->rounding_rule) { + 'ceil' => ceil($value / $unit) * $unit, + 'floor' => floor($value / $unit) * $unit, + default => round($value / $unit) * $unit, // 'round' + }; + } + + /** + * 확정 가능 여부 + */ + public function canFinalize(): bool + { + return ! $this->is_final && in_array($this->status, ['draft', 'active']); + } + + /** + * 수정 가능 여부 + */ + public function canEdit(): bool + { + return ! $this->is_final; + } + + /** + * 스냅샷 생성 (리비전용) + */ + public function toSnapshot(): array + { + return [ + 'purchase_price' => $this->purchase_price, + 'processing_cost' => $this->processing_cost, + 'loss_rate' => $this->loss_rate, + 'margin_rate' => $this->margin_rate, + 'sales_price' => $this->sales_price, + 'rounding_rule' => $this->rounding_rule, + 'rounding_unit' => $this->rounding_unit, + 'supplier' => $this->supplier, + 'effective_from' => $this->effective_from?->format('Y-m-d'), + 'effective_to' => $this->effective_to?->format('Y-m-d'), + 'status' => $this->status, + 'is_final' => $this->is_final, + 'note' => $this->note, + ]; + } +} diff --git a/app/Models/Products/PriceHistory.php b/app/Models/Products/PriceHistory.php deleted file mode 100644 index f334ed8..0000000 --- a/app/Models/Products/PriceHistory.php +++ /dev/null @@ -1,85 +0,0 @@ - 'decimal:4', - 'started_at' => 'date', - 'ended_at' => 'date', - ]; - - // ClientGroup 관계 - public function clientGroup() - { - return $this->belongsTo(ClientGroup::class, 'client_group_id'); - } - - // Polymorphic 관계 (item_type_code에 따라 Product 또는 Material) - public function item() - { - if ($this->item_type_code === 'PRODUCT') { - return $this->belongsTo(Product::class, 'item_id'); - } elseif ($this->item_type_code === 'MATERIAL') { - return $this->belongsTo(\App\Models\Materials\Material::class, 'item_id'); - } - - return null; - } - - // 스코프 - public function scopeForItem($query, string $itemType, int $itemId) - { - return $query->where('item_type_code', $itemType) - ->where('item_id', $itemId); - } - - public function scopeForClientGroup($query, ?int $clientGroupId) - { - return $query->where('client_group_id', $clientGroupId); - } - - public function scopeValidAt($query, $date) - { - return $query->where('started_at', '<=', $date) - ->where(function ($q) use ($date) { - $q->whereNull('ended_at') - ->orWhere('ended_at', '>=', $date); - }); - } - - public function scopeSalePrice($query) - { - return $query->where('price_type_code', 'SALE'); - } - - public function scopePurchasePrice($query) - { - return $query->where('price_type_code', 'PURCHASE'); - } -} diff --git a/app/Models/Products/PriceRevision.php b/app/Models/Products/PriceRevision.php new file mode 100644 index 0000000..914e818 --- /dev/null +++ b/app/Models/Products/PriceRevision.php @@ -0,0 +1,92 @@ + 'integer', + 'changed_at' => 'datetime', + 'before_snapshot' => 'array', + 'after_snapshot' => 'array', + ]; + + /** + * 단가 관계 + */ + public function price(): BelongsTo + { + return $this->belongsTo(Price::class, 'price_id'); + } + + /** + * 변경자 관계 + */ + public function changedByUser(): BelongsTo + { + return $this->belongsTo(\App\Models\Members\User::class, 'changed_by'); + } + + /** + * 변경된 필드 목록 추출 + */ + public function getChangedFields(): array + { + if (! $this->before_snapshot) { + return array_keys($this->after_snapshot ?? []); + } + + $changed = []; + foreach ($this->after_snapshot as $key => $newValue) { + $oldValue = $this->before_snapshot[$key] ?? null; + if ($oldValue !== $newValue) { + $changed[] = $key; + } + } + + return $changed; + } + + /** + * 특정 필드의 이전/이후 값 + */ + public function getFieldChange(string $field): array + { + return [ + 'before' => $this->before_snapshot[$field] ?? null, + 'after' => $this->after_snapshot[$field] ?? null, + ]; + } +} diff --git a/app/Services/PricingService.php b/app/Services/PricingService.php new file mode 100644 index 0000000..3e062ee --- /dev/null +++ b/app/Services/PricingService.php @@ -0,0 +1,533 @@ +tenantId(); + + $size = (int) ($params['size'] ?? 20); + $q = trim((string) ($params['q'] ?? '')); + $itemType = $params['item_type_code'] ?? null; + $itemId = $params['item_id'] ?? null; + $clientGroupId = $params['client_group_id'] ?? null; + $status = $params['status'] ?? null; + $validAt = $params['valid_at'] ?? null; + + $query = Price::query() + ->with(['clientGroup:id,name']) + ->where('tenant_id', $tenantId); + + // 검색어 필터 + if ($q !== '') { + $query->where(function ($w) use ($q) { + $w->where('supplier', 'like', "%{$q}%") + ->orWhere('note', 'like', "%{$q}%"); + }); + } + + // 품목 유형 필터 + if ($itemType) { + $query->where('item_type_code', $itemType); + } + + // 품목 ID 필터 + if ($itemId) { + $query->where('item_id', (int) $itemId); + } + + // 고객그룹 필터 + if ($clientGroupId !== null) { + if ($clientGroupId === 'null' || $clientGroupId === '') { + $query->whereNull('client_group_id'); + } else { + $query->where('client_group_id', (int) $clientGroupId); + } + } + + // 상태 필터 + if ($status) { + $query->where('status', $status); + } + + // 특정 일자에 유효한 단가 필터 + if ($validAt) { + $query->validAt($validAt); + } + + return $query->orderByDesc('id')->paginate($size); + } + + /** + * 단가 상세 조회 + */ + public function show(int $id): Price + { + $tenantId = $this->tenantId(); + + $price = Price::query() + ->with(['clientGroup:id,name', 'revisions' => function ($q) { + $q->orderByDesc('revision_number')->limit(10); + }]) + ->where('tenant_id', $tenantId) + ->find($id); + + if (! $price) { + throw new NotFoundHttpException(__('error.not_found')); + } + + return $price; + } + + /** + * 단가 등록 + */ + public function store(array $data): Price + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($data, $tenantId, $userId) { + // 중복 체크 (동일 품목+고객그룹+시작일) + $this->checkDuplicate( + $tenantId, + $data['item_type_code'], + (int) $data['item_id'], + $data['client_group_id'] ?? null, + $data['effective_from'] + ); + + // 기존 무기한 단가의 종료일 자동 설정 + $this->autoCloseExistingPrice( + $tenantId, + $data['item_type_code'], + (int) $data['item_id'], + $data['client_group_id'] ?? null, + $data['effective_from'] + ); + + $payload = array_merge($data, [ + 'tenant_id' => $tenantId, + 'status' => $data['status'] ?? 'draft', + 'is_final' => false, + 'created_by' => $userId, + ]); + + // 판매단가 자동 계산 (sales_price가 없고 필요 데이터가 있을 때) + if (empty($payload['sales_price']) && isset($payload['purchase_price'])) { + $tempPrice = new Price($payload); + $payload['sales_price'] = $tempPrice->calculateSalesPrice(); + } + + $price = Price::create($payload); + + // 최초 리비전 생성 + $this->createRevision($price, null, $userId, __('message.pricing.created')); + + return $price; + }); + } + + /** + * 단가 수정 + */ + public function update(int $id, array $data): Price + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $data, $tenantId, $userId) { + $price = Price::query() + ->where('tenant_id', $tenantId) + ->find($id); + + if (! $price) { + throw new NotFoundHttpException(__('error.not_found')); + } + + // 확정된 단가는 수정 불가 + if (! $price->canEdit()) { + throw new BadRequestHttpException(__('error.pricing.finalized_cannot_edit')); + } + + // 변경 전 스냅샷 + $beforeSnapshot = $price->toSnapshot(); + + // 키 변경 시 중복 체크 + $itemType = $data['item_type_code'] ?? $price->item_type_code; + $itemId = $data['item_id'] ?? $price->item_id; + $clientGroupId = $data['client_group_id'] ?? $price->client_group_id; + $effectiveFrom = $data['effective_from'] ?? $price->effective_from; + + $keyChanged = ( + $itemType !== $price->item_type_code || + $itemId !== $price->item_id || + $clientGroupId !== $price->client_group_id || + $effectiveFrom != $price->effective_from + ); + + if ($keyChanged) { + $this->checkDuplicate($tenantId, $itemType, $itemId, $clientGroupId, $effectiveFrom, $id); + } + + $data['updated_by'] = $userId; + $price->update($data); + + // 판매단가 재계산 (관련 필드가 변경된 경우) + if ($this->shouldRecalculateSalesPrice($data)) { + $price->sales_price = $price->calculateSalesPrice(); + $price->save(); + } + + $price->refresh(); + + // 리비전 생성 + $changeReason = $data['change_reason'] ?? null; + $this->createRevision($price, $beforeSnapshot, $userId, $changeReason); + + return $price; + }); + } + + /** + * 단가 삭제 (soft delete) + */ + public function destroy(int $id): void + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + DB::transaction(function () use ($id, $tenantId, $userId) { + $price = Price::query() + ->where('tenant_id', $tenantId) + ->find($id); + + if (! $price) { + throw new NotFoundHttpException(__('error.not_found')); + } + + // 확정된 단가는 삭제 불가 + if ($price->is_final) { + throw new BadRequestHttpException(__('error.pricing.finalized_cannot_delete')); + } + + // 삭제 전 스냅샷 저장 + $beforeSnapshot = $price->toSnapshot(); + $this->createRevision($price, $beforeSnapshot, $userId, __('message.pricing.deleted')); + + $price->deleted_by = $userId; + $price->save(); + $price->delete(); + }); + } + + /** + * 단가 확정 + */ + public function finalize(int $id): Price + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $tenantId, $userId) { + $price = Price::query() + ->where('tenant_id', $tenantId) + ->find($id); + + if (! $price) { + throw new NotFoundHttpException(__('error.not_found')); + } + + if (! $price->canFinalize()) { + throw new BadRequestHttpException(__('error.pricing.cannot_finalize')); + } + + $beforeSnapshot = $price->toSnapshot(); + + $price->update([ + 'is_final' => true, + 'status' => 'finalized', + 'finalized_at' => now(), + 'finalized_by' => $userId, + 'updated_by' => $userId, + ]); + + $price->refresh(); + + // 확정 리비전 생성 + $this->createRevision($price, $beforeSnapshot, $userId, __('message.pricing.finalized')); + + return $price; + }); + } + + /** + * 품목별 단가 현황 조회 + * 여러 품목의 현재 유효한 단가를 한번에 조회 + */ + public function byItems(array $params): Collection + { + $tenantId = $this->tenantId(); + + $items = $params['items'] ?? []; // [{item_type_code, item_id}, ...] + $clientGroupId = $params['client_group_id'] ?? null; + $date = $params['date'] ?? now()->toDateString(); + + if (empty($items)) { + return collect(); + } + + $results = collect(); + + foreach ($items as $item) { + $itemType = $item['item_type_code'] ?? null; + $itemId = $item['item_id'] ?? null; + + if (! $itemType || ! $itemId) { + continue; + } + + // 고객그룹별 단가 우선 조회 + $price = null; + if ($clientGroupId) { + $price = Price::query() + ->where('tenant_id', $tenantId) + ->forItem($itemType, (int) $itemId) + ->forClientGroup((int) $clientGroupId) + ->validAt($date) + ->active() + ->orderByDesc('effective_from') + ->first(); + } + + // 기본 단가 fallback + if (! $price) { + $price = Price::query() + ->where('tenant_id', $tenantId) + ->forItem($itemType, (int) $itemId) + ->whereNull('client_group_id') + ->validAt($date) + ->active() + ->orderByDesc('effective_from') + ->first(); + } + + $results->push([ + 'item_type_code' => $itemType, + 'item_id' => $itemId, + 'price' => $price, + 'has_price' => $price !== null, + ]); + } + + return $results; + } + + /** + * 리비전 이력 조회 + */ + public function revisions(int $priceId, array $params = []): LengthAwarePaginator + { + $tenantId = $this->tenantId(); + + // 단가 존재 확인 + $price = Price::query() + ->where('tenant_id', $tenantId) + ->find($priceId); + + if (! $price) { + throw new NotFoundHttpException(__('error.not_found')); + } + + $size = (int) ($params['size'] ?? 20); + + return PriceRevision::query() + ->where('price_id', $priceId) + ->with('changedByUser:id,name') + ->orderByDesc('revision_number') + ->paginate($size); + } + + /** + * 원가 조회 (수입검사 > 표준원가 fallback) + */ + public function getCost(array $params): array + { + $tenantId = $this->tenantId(); + + $itemType = $params['item_type_code']; + $itemId = (int) $params['item_id']; + $date = $params['date'] ?? now()->toDateString(); + + $result = [ + 'item_type_code' => $itemType, + 'item_id' => $itemId, + 'date' => $date, + 'cost_source' => 'not_found', + 'purchase_price' => null, + 'receipt_id' => null, + 'receipt_date' => null, + 'price_id' => null, + ]; + + // 1순위: 자재인 경우 수입검사 입고단가 조회 + if ($itemType === 'MATERIAL') { + $receipt = MaterialReceipt::query() + ->where('material_id', $itemId) + ->where('receipt_date', '<=', $date) + ->whereNotNull('purchase_price_excl_vat') + ->orderByDesc('receipt_date') + ->orderByDesc('id') + ->first(); + + if ($receipt && $receipt->purchase_price_excl_vat > 0) { + $result['cost_source'] = 'receipt'; + $result['purchase_price'] = (float) $receipt->purchase_price_excl_vat; + $result['receipt_id'] = $receipt->id; + $result['receipt_date'] = $receipt->receipt_date; + + return $result; + } + } + + // 2순위: 표준원가 (prices 테이블) + $price = Price::query() + ->where('tenant_id', $tenantId) + ->forItem($itemType, $itemId) + ->whereNull('client_group_id') + ->validAt($date) + ->active() + ->orderByDesc('effective_from') + ->first(); + + if ($price && $price->purchase_price > 0) { + $result['cost_source'] = 'standard'; + $result['purchase_price'] = (float) $price->purchase_price; + $result['price_id'] = $price->id; + + return $result; + } + + // 3순위: 미등록 + return $result; + } + + /** + * 중복 체크 + */ + private function checkDuplicate( + int $tenantId, + string $itemType, + int $itemId, + ?int $clientGroupId, + string $effectiveFrom, + ?int $excludeId = null + ): void { + $query = Price::query() + ->where('tenant_id', $tenantId) + ->where('item_type_code', $itemType) + ->where('item_id', $itemId) + ->where('effective_from', $effectiveFrom); + + if ($clientGroupId) { + $query->where('client_group_id', $clientGroupId); + } else { + $query->whereNull('client_group_id'); + } + + if ($excludeId) { + $query->where('id', '!=', $excludeId); + } + + if ($query->exists()) { + throw new BadRequestHttpException(__('error.duplicate_key')); + } + } + + /** + * 기존 무기한 단가의 종료일 자동 설정 + */ + private function autoCloseExistingPrice( + int $tenantId, + string $itemType, + int $itemId, + ?int $clientGroupId, + string $newEffectiveFrom + ): void { + $query = Price::query() + ->where('tenant_id', $tenantId) + ->where('item_type_code', $itemType) + ->where('item_id', $itemId) + ->whereNull('effective_to') + ->where('effective_from', '<', $newEffectiveFrom); + + if ($clientGroupId) { + $query->where('client_group_id', $clientGroupId); + } else { + $query->whereNull('client_group_id'); + } + + $existingPrice = $query->first(); + + if ($existingPrice && ! $existingPrice->is_final) { + $newEndDate = Carbon::parse($newEffectiveFrom)->subDay()->toDateString(); + $existingPrice->update([ + 'effective_to' => $newEndDate, + 'updated_by' => $this->apiUserId(), + ]); + } + } + + /** + * 리비전 생성 + */ + private function createRevision(Price $price, ?array $beforeSnapshot, int $userId, ?string $reason = null): void + { + // 다음 리비전 번호 계산 + $nextRevision = PriceRevision::query() + ->where('price_id', $price->id) + ->max('revision_number') + 1; + + PriceRevision::create([ + 'tenant_id' => $price->tenant_id, + 'price_id' => $price->id, + 'revision_number' => $nextRevision, + 'changed_at' => now(), + 'changed_by' => $userId, + 'change_reason' => $reason, + 'before_snapshot' => $beforeSnapshot, + 'after_snapshot' => $price->toSnapshot(), + ]); + } + + /** + * 판매단가 재계산이 필요한지 확인 + */ + private function shouldRecalculateSalesPrice(array $data): bool + { + $recalcFields = ['purchase_price', 'processing_cost', 'loss_rate', 'margin_rate', 'rounding_rule', 'rounding_unit']; + + foreach ($recalcFields as $field) { + if (array_key_exists($field, $data)) { + return true; + } + } + + return false; + } +} diff --git a/app/Swagger/v1/PricingApi.php b/app/Swagger/v1/PricingApi.php index 6ba3279..1569ff3 100644 --- a/app/Swagger/v1/PricingApi.php +++ b/app/Swagger/v1/PricingApi.php @@ -3,87 +3,137 @@ namespace App\Swagger\v1; /** - * @OA\Tag(name="Pricing", description="가격 이력 관리") + * @OA\Tag(name="Pricing", description="단가 관리") + * + * ========= 스키마 정의 ========= * * @OA\Schema( - * schema="PriceHistory", + * schema="Price", * type="object", - * required={"id","item_type_code","item_id","price_type_code","price","started_at"}, + * description="단가 마스터", * * @OA\Property(property="id", type="integer", example=1), * @OA\Property(property="tenant_id", type="integer", example=1), - * @OA\Property(property="item_type_code", type="string", enum={"PRODUCT","MATERIAL"}, example="PRODUCT", description="항목 유형"), - * @OA\Property(property="item_id", type="integer", example=10, description="제품/자재 ID"), - * @OA\Property(property="price_type_code", type="string", enum={"SALE","PURCHASE"}, example="SALE", description="가격 유형"), - * @OA\Property(property="client_group_id", type="integer", nullable=true, example=1, description="고객 그룹 ID (NULL=기본 가격)"), - * @OA\Property(property="price", type="number", format="decimal", example=50000.00), - * @OA\Property(property="started_at", type="string", format="date", example="2025-01-01"), - * @OA\Property(property="ended_at", type="string", format="date", nullable=true, example="2025-12-31"), - * @OA\Property(property="created_at", type="string", example="2025-10-01 12:00:00"), - * @OA\Property(property="updated_at", type="string", example="2025-10-01 12:00:00") + * @OA\Property(property="item_type_code", type="string", enum={"PRODUCT","MATERIAL"}, example="PRODUCT", description="품목 유형"), + * @OA\Property(property="item_id", type="integer", example=10, description="품목 ID"), + * @OA\Property(property="client_group_id", type="integer", nullable=true, example=1, description="고객그룹 ID (NULL=기본가)"), + * @OA\Property(property="purchase_price", type="number", format="decimal", nullable=true, example=10000.00, description="매입단가 (표준원가)"), + * @OA\Property(property="processing_cost", type="number", format="decimal", nullable=true, example=2000.00, description="가공비"), + * @OA\Property(property="loss_rate", type="number", format="decimal", nullable=true, example=5.00, description="LOSS율 (%)"), + * @OA\Property(property="margin_rate", type="number", format="decimal", nullable=true, example=25.00, description="마진율 (%)"), + * @OA\Property(property="sales_price", type="number", format="decimal", nullable=true, example=15800.00, description="판매단가"), + * @OA\Property(property="rounding_rule", type="string", enum={"round","ceil","floor"}, example="round", description="반올림 규칙"), + * @OA\Property(property="rounding_unit", type="integer", example=100, description="반올림 단위 (1,10,100,1000)"), + * @OA\Property(property="supplier", type="string", nullable=true, example="ABC공급", description="공급업체"), + * @OA\Property(property="effective_from", type="string", format="date", example="2025-01-01", description="적용 시작일"), + * @OA\Property(property="effective_to", type="string", format="date", nullable=true, example="2025-12-31", description="적용 종료일"), + * @OA\Property(property="note", type="string", nullable=true, example="2025년 상반기 가격", description="비고"), + * @OA\Property(property="status", type="string", enum={"draft","active","inactive","finalized"}, example="active", description="상태"), + * @OA\Property(property="is_final", type="boolean", example=false, description="최종 확정 여부"), + * @OA\Property(property="finalized_at", type="string", format="datetime", nullable=true, example="2025-01-15 10:30:00", description="확정 일시"), + * @OA\Property(property="finalized_by", type="integer", nullable=true, example=1, description="확정자 ID"), + * @OA\Property(property="created_at", type="string", example="2025-01-01 12:00:00"), + * @OA\Property(property="updated_at", type="string", example="2025-01-01 12:00:00"), + * @OA\Property( + * property="client_group", + * type="object", + * nullable=true, + * @OA\Property(property="id", type="integer", example=1), + * @OA\Property(property="name", type="string", example="VIP 고객") + * ) * ) * * @OA\Schema( - * schema="PriceHistoryPagination", + * schema="PricePagination", * type="object", * * @OA\Property(property="current_page", type="integer", example=1), - * @OA\Property( - * property="data", - * type="array", - * - * @OA\Items(ref="#/components/schemas/PriceHistory") - * ), - * + * @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Price")), * @OA\Property(property="first_page_url", type="string", example="/api/v1/pricing?page=1"), * @OA\Property(property="from", type="integer", example=1), * @OA\Property(property="last_page", type="integer", example=3), * @OA\Property(property="last_page_url", type="string", example="/api/v1/pricing?page=3"), - * @OA\Property( - * property="links", - * type="array", - * - * @OA\Items(type="object", - * - * @OA\Property(property="url", type="string", nullable=true, example=null), - * @OA\Property(property="label", type="string", example="« Previous"), - * @OA\Property(property="active", type="boolean", example=false) - * ) - * ), - * @OA\Property(property="next_page_url", type="string", nullable=true, example="/api/v1/pricing?page=2"), + * @OA\Property(property="links", type="array", @OA\Items(type="object", + * @OA\Property(property="url", type="string", nullable=true), + * @OA\Property(property="label", type="string"), + * @OA\Property(property="active", type="boolean") + * )), + * @OA\Property(property="next_page_url", type="string", nullable=true), * @OA\Property(property="path", type="string", example="/api/v1/pricing"), - * @OA\Property(property="per_page", type="integer", example=15), - * @OA\Property(property="prev_page_url", type="string", nullable=true, example=null), - * @OA\Property(property="to", type="integer", example=15), + * @OA\Property(property="per_page", type="integer", example=20), + * @OA\Property(property="prev_page_url", type="string", nullable=true), + * @OA\Property(property="to", type="integer", example=20), * @OA\Property(property="total", type="integer", example=50) * ) * * @OA\Schema( - * schema="PriceUpsertRequest", + * schema="PriceRevision", * type="object", - * required={"item_type_code","item_id","price_type_code","price","started_at"}, + * description="단가 변경 이력", + * + * @OA\Property(property="id", type="integer", example=1), + * @OA\Property(property="price_id", type="integer", example=1), + * @OA\Property(property="revision_number", type="integer", example=1, description="리비전 번호"), + * @OA\Property(property="changed_at", type="string", format="datetime", example="2025-01-01 12:00:00", description="변경 일시"), + * @OA\Property(property="changed_by", type="integer", example=1, description="변경자 ID"), + * @OA\Property(property="change_reason", type="string", nullable=true, example="2025년 단가 인상", description="변경 사유"), + * @OA\Property(property="before_snapshot", type="object", nullable=true, description="변경 전 데이터"), + * @OA\Property(property="after_snapshot", type="object", description="변경 후 데이터"), + * @OA\Property( + * property="changed_by_user", + * type="object", + * nullable=true, + * @OA\Property(property="id", type="integer", example=1), + * @OA\Property(property="name", type="string", example="홍길동") + * ) + * ) + * + * @OA\Schema( + * schema="PriceStoreRequest", + * type="object", + * required={"item_type_code","item_id","effective_from"}, * * @OA\Property(property="item_type_code", type="string", enum={"PRODUCT","MATERIAL"}, example="PRODUCT"), * @OA\Property(property="item_id", type="integer", example=10), - * @OA\Property(property="price_type_code", type="string", enum={"SALE","PURCHASE"}, example="SALE"), - * @OA\Property(property="client_group_id", type="integer", nullable=true, example=1, description="NULL=기본 가격"), - * @OA\Property(property="price", type="number", format="decimal", example=50000.00), - * @OA\Property(property="started_at", type="string", format="date", example="2025-01-01"), - * @OA\Property(property="ended_at", type="string", format="date", nullable=true, example="2025-12-31") + * @OA\Property(property="client_group_id", type="integer", nullable=true, example=1), + * @OA\Property(property="purchase_price", type="number", nullable=true, example=10000.00), + * @OA\Property(property="processing_cost", type="number", nullable=true, example=2000.00), + * @OA\Property(property="loss_rate", type="number", nullable=true, example=5.00), + * @OA\Property(property="margin_rate", type="number", nullable=true, example=25.00), + * @OA\Property(property="sales_price", type="number", nullable=true, example=15800.00), + * @OA\Property(property="rounding_rule", type="string", enum={"round","ceil","floor"}, example="round"), + * @OA\Property(property="rounding_unit", type="integer", enum={1,10,100,1000}, example=100), + * @OA\Property(property="supplier", type="string", nullable=true, example="ABC공급"), + * @OA\Property(property="effective_from", type="string", format="date", example="2025-01-01"), + * @OA\Property(property="effective_to", type="string", format="date", nullable=true, example="2025-12-31"), + * @OA\Property(property="note", type="string", nullable=true, example="2025년 상반기 가격"), + * @OA\Property(property="status", type="string", enum={"draft","active","inactive"}, example="draft") * ) * * @OA\Schema( - * schema="PriceQueryResult", + * schema="PriceUpdateRequest", * type="object", * - * @OA\Property(property="price", type="number", format="decimal", nullable=true, example=50000.00), - * @OA\Property(property="price_history_id", type="integer", nullable=true, example=1), + * @OA\Property(property="item_type_code", type="string", enum={"PRODUCT","MATERIAL"}, example="PRODUCT"), + * @OA\Property(property="item_id", type="integer", example=10), * @OA\Property(property="client_group_id", type="integer", nullable=true, example=1), - * @OA\Property(property="warning", type="string", nullable=true, example="가격을 찾을 수 없습니다") + * @OA\Property(property="purchase_price", type="number", nullable=true, example=10000.00), + * @OA\Property(property="processing_cost", type="number", nullable=true, example=2000.00), + * @OA\Property(property="loss_rate", type="number", nullable=true, example=5.00), + * @OA\Property(property="margin_rate", type="number", nullable=true, example=25.00), + * @OA\Property(property="sales_price", type="number", nullable=true, example=15800.00), + * @OA\Property(property="rounding_rule", type="string", enum={"round","ceil","floor"}, example="round"), + * @OA\Property(property="rounding_unit", type="integer", enum={1,10,100,1000}, example=100), + * @OA\Property(property="supplier", type="string", nullable=true, example="ABC공급"), + * @OA\Property(property="effective_from", type="string", format="date", example="2025-01-01"), + * @OA\Property(property="effective_to", type="string", format="date", nullable=true, example="2025-12-31"), + * @OA\Property(property="note", type="string", nullable=true, example="2025년 상반기 가격"), + * @OA\Property(property="status", type="string", enum={"draft","active","inactive"}, example="active"), + * @OA\Property(property="change_reason", type="string", nullable=true, example="단가 인상", description="변경 사유 (리비전 기록용)") * ) * * @OA\Schema( - * schema="BulkPriceQueryRequest", + * schema="PriceByItemsRequest", * type="object", * required={"items"}, * @@ -93,32 +143,39 @@ * * @OA\Items(type="object", * - * @OA\Property(property="item_type", type="string", enum={"PRODUCT","MATERIAL"}, example="PRODUCT"), + * @OA\Property(property="item_type_code", type="string", enum={"PRODUCT","MATERIAL"}, example="PRODUCT"), * @OA\Property(property="item_id", type="integer", example=10) * ) * ), - * @OA\Property(property="client_id", type="integer", nullable=true, example=5), - * @OA\Property(property="date", type="string", format="date", nullable=true, example="2025-10-13") + * @OA\Property(property="client_group_id", type="integer", nullable=true, example=1), + * @OA\Property(property="date", type="string", format="date", nullable=true, example="2025-01-15") * ) * * @OA\Schema( - * schema="BulkPriceQueryResult", + * schema="PriceByItemsResult", + * type="array", + * + * @OA\Items(type="object", + * + * @OA\Property(property="item_type_code", type="string", example="PRODUCT"), + * @OA\Property(property="item_id", type="integer", example=10), + * @OA\Property(property="price", ref="#/components/schemas/Price", nullable=true), + * @OA\Property(property="has_price", type="boolean", example=true) + * ) + * ) + * + * @OA\Schema( + * schema="PriceCostResult", * type="object", * - * @OA\Property( - * property="prices", - * type="array", - * - * @OA\Items(type="object", - * - * @OA\Property(property="item_type", type="string", example="PRODUCT"), - * @OA\Property(property="item_id", type="integer", example=10), - * @OA\Property(property="price", type="number", nullable=true, example=50000.00), - * @OA\Property(property="price_history_id", type="integer", nullable=true, example=1), - * @OA\Property(property="client_group_id", type="integer", nullable=true, example=1) - * ) - * ), - * @OA\Property(property="warnings", type="array", @OA\Items(type="string")) + * @OA\Property(property="item_type_code", type="string", example="MATERIAL"), + * @OA\Property(property="item_id", type="integer", example=123), + * @OA\Property(property="date", type="string", format="date", example="2025-01-15"), + * @OA\Property(property="cost_source", type="string", enum={"receipt","standard","not_found"}, example="receipt", description="원가 출처"), + * @OA\Property(property="purchase_price", type="number", nullable=true, example=10500.00), + * @OA\Property(property="receipt_id", type="integer", nullable=true, example=456, description="수입검사 ID (cost_source=receipt일 때)"), + * @OA\Property(property="receipt_date", type="string", format="date", nullable=true, example="2025-01-10"), + * @OA\Property(property="price_id", type="integer", nullable=true, example=null, description="단가 ID (cost_source=standard일 때)") * ) */ class PricingApi @@ -127,22 +184,24 @@ class PricingApi * @OA\Get( * path="/api/v1/pricing", * tags={"Pricing"}, - * summary="가격 이력 목록", + * summary="단가 목록 조회", * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, * + * @OA\Parameter(name="q", in="query", description="검색어 (supplier, note)", @OA\Schema(type="string")), * @OA\Parameter(name="item_type_code", in="query", @OA\Schema(type="string", enum={"PRODUCT","MATERIAL"})), * @OA\Parameter(name="item_id", in="query", @OA\Schema(type="integer")), - * @OA\Parameter(name="price_type_code", in="query", @OA\Schema(type="string", enum={"SALE","PURCHASE"})), - * @OA\Parameter(name="client_group_id", in="query", @OA\Schema(type="integer")), - * @OA\Parameter(name="date", in="query", description="특정 날짜 기준 유효한 가격", @OA\Schema(type="string", format="date")), - * @OA\Parameter(name="size", in="query", @OA\Schema(type="integer", example=15)), + * @OA\Parameter(name="client_group_id", in="query", description="고객그룹 ID (빈값/null=기본가만)", @OA\Schema(type="string")), + * @OA\Parameter(name="status", in="query", @OA\Schema(type="string", enum={"draft","active","inactive","finalized"})), + * @OA\Parameter(name="valid_at", in="query", description="특정 날짜에 유효한 단가만", @OA\Schema(type="string", format="date")), + * @OA\Parameter(name="size", in="query", @OA\Schema(type="integer", example=20)), + * @OA\Parameter(name="page", in="query", @OA\Schema(type="integer", example=1)), * * @OA\Response(response=200, description="조회 성공", * * @OA\JsonContent(allOf={ * * @OA\Schema(ref="#/components/schemas/ApiResponse"), - * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/PriceHistoryPagination")) + * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/PricePagination")) * }) * ), * @@ -153,86 +212,188 @@ public function index() {} /** * @OA\Get( - * path="/api/v1/pricing/show", + * path="/api/v1/pricing/{id}", * tags={"Pricing"}, - * summary="단일 항목 가격 조회", - * description="특정 제품/자재의 현재 유효한 가격 조회", + * summary="단가 상세 조회", * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, * - * @OA\Parameter(name="item_type", in="query", required=true, @OA\Schema(type="string", enum={"PRODUCT","MATERIAL"})), - * @OA\Parameter(name="item_id", in="query", required=true, @OA\Schema(type="integer")), - * @OA\Parameter(name="client_id", in="query", @OA\Schema(type="integer"), description="고객 ID (고객 그룹별 가격 적용)"), - * @OA\Parameter(name="date", in="query", @OA\Schema(type="string", format="date"), description="기준일 (미지정시 오늘)"), + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), * * @OA\Response(response=200, description="조회 성공", * * @OA\JsonContent(allOf={ * * @OA\Schema(ref="#/components/schemas/ApiResponse"), - * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/PriceQueryResult")) + * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Price")) * }) - * ) + * ), + * + * @OA\Response(response=404, description="미존재", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) * ) */ public function show() {} /** * @OA\Post( - * path="/api/v1/pricing/bulk", + * path="/api/v1/pricing", * tags={"Pricing"}, - * summary="여러 항목 일괄 가격 조회", - * description="여러 제품/자재의 가격을 한 번에 조회", + * summary="단가 등록", * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, * - * @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/BulkPriceQueryRequest")), + * @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/PriceStoreRequest")), * - * @OA\Response(response=200, description="조회 성공", + * @OA\Response(response=200, description="등록 성공", * * @OA\JsonContent(allOf={ * * @OA\Schema(ref="#/components/schemas/ApiResponse"), - * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/BulkPriceQueryResult")) - * }) - * ) - * ) - */ - public function bulk() {} - - /** - * @OA\Post( - * path="/api/v1/pricing/upsert", - * tags={"Pricing"}, - * summary="가격 등록/수정", - * description="가격 이력 등록 (동일 조건 존재 시 업데이트)", - * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, - * - * @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/PriceUpsertRequest")), - * - * @OA\Response(response=200, description="저장 성공", - * - * @OA\JsonContent(allOf={ - * - * @OA\Schema(ref="#/components/schemas/ApiResponse"), - * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/PriceHistory")) + * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Price")) * }) * ), * * @OA\Response(response=400, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) * ) */ - public function upsert() {} + public function store() {} + + /** + * @OA\Put( + * path="/api/v1/pricing/{id}", + * tags={"Pricing"}, + * summary="단가 수정", + * description="확정(finalized) 상태의 단가는 수정 불가", + * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, + * + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * + * @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/PriceUpdateRequest")), + * + * @OA\Response(response=200, description="수정 성공", + * + * @OA\JsonContent(allOf={ + * + * @OA\Schema(ref="#/components/schemas/ApiResponse"), + * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Price")) + * }) + * ), + * + * @OA\Response(response=400, description="수정 불가 (확정된 단가)", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * ) + */ + public function update() {} /** * @OA\Delete( * path="/api/v1/pricing/{id}", * tags={"Pricing"}, - * summary="가격 이력 삭제(soft)", + * summary="단가 삭제 (soft)", + * description="확정(finalized) 상태의 단가는 삭제 불가", * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, * * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), * - * @OA\Response(response=200, description="삭제 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse")) + * @OA\Response(response=200, description="삭제 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse")), + * @OA\Response(response=400, description="삭제 불가", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) * ) */ public function destroy() {} + + /** + * @OA\Post( + * path="/api/v1/pricing/{id}/finalize", + * tags={"Pricing"}, + * summary="단가 확정", + * description="단가를 확정 상태로 변경 (확정 후 수정/삭제 불가)", + * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, + * + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * + * @OA\Response(response=200, description="확정 성공", + * + * @OA\JsonContent(allOf={ + * + * @OA\Schema(ref="#/components/schemas/ApiResponse"), + * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Price")) + * }) + * ), + * + * @OA\Response(response=400, description="확정 불가", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * ) + */ + public function finalize() {} + + /** + * @OA\Post( + * path="/api/v1/pricing/by-items", + * tags={"Pricing"}, + * summary="품목별 단가 현황 조회", + * description="여러 품목의 현재 유효한 단가를 한번에 조회", + * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, + * + * @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/PriceByItemsRequest")), + * + * @OA\Response(response=200, description="조회 성공", + * + * @OA\JsonContent(allOf={ + * + * @OA\Schema(ref="#/components/schemas/ApiResponse"), + * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/PriceByItemsResult")) + * }) + * ) + * ) + */ + public function byItems() {} + + /** + * @OA\Get( + * path="/api/v1/pricing/{id}/revisions", + * tags={"Pricing"}, + * summary="변경 이력 조회", + * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, + * + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * @OA\Parameter(name="size", in="query", @OA\Schema(type="integer", example=20)), + * + * @OA\Response(response=200, description="조회 성공", + * + * @OA\JsonContent(allOf={ + * + * @OA\Schema(ref="#/components/schemas/ApiResponse"), + * @OA\Schema(@OA\Property(property="data", type="object", + * + * @OA\Property(property="current_page", type="integer"), + * @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/PriceRevision")), + * @OA\Property(property="total", type="integer") + * )) + * }) + * ), + * + * @OA\Response(response=404, description="단가 미존재", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * ) + */ + public function revisions() {} + + /** + * @OA\Get( + * path="/api/v1/pricing/cost", + * tags={"Pricing"}, + * summary="원가 조회", + * description="품목의 원가를 조회. 자재는 수입검사 입고단가 우선, 없으면 표준원가 사용", + * security={{"ApiKeyAuth":{}},{"BearerAuth":{}}}, + * + * @OA\Parameter(name="item_type_code", in="query", required=true, @OA\Schema(type="string", enum={"PRODUCT","MATERIAL"})), + * @OA\Parameter(name="item_id", in="query", required=true, @OA\Schema(type="integer")), + * @OA\Parameter(name="date", in="query", description="기준일 (미지정시 오늘)", @OA\Schema(type="string", format="date")), + * + * @OA\Response(response=200, description="조회 성공", + * + * @OA\JsonContent(allOf={ + * + * @OA\Schema(ref="#/components/schemas/ApiResponse"), + * @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/PriceCostResult")) + * }) + * ) + * ) + */ + public function cost() {} } diff --git a/database/migrations/2025_12_08_154633_create_prices_table.php b/database/migrations/2025_12_08_154633_create_prices_table.php new file mode 100644 index 0000000..951293e --- /dev/null +++ b/database/migrations/2025_12_08_154633_create_prices_table.php @@ -0,0 +1,75 @@ +id()->comment('ID'); + $table->foreignId('tenant_id')->comment('테넌트 ID'); + + // 품목 연결 + $table->string('item_type_code', 20)->comment('품목유형 (PRODUCT/MATERIAL)'); + $table->unsignedBigInteger('item_id')->comment('품목 ID'); + $table->unsignedBigInteger('client_group_id')->nullable()->comment('고객그룹 ID (NULL=기본가)'); + + // 원가 정보 + $table->decimal('purchase_price', 15, 4)->nullable()->comment('매입단가 (표준원가)'); + $table->decimal('processing_cost', 15, 4)->nullable()->comment('가공비'); + $table->decimal('loss_rate', 5, 2)->nullable()->comment('LOSS율 (%)'); + + // 판매가 정보 + $table->decimal('margin_rate', 5, 2)->nullable()->comment('마진율 (%)'); + $table->decimal('sales_price', 15, 4)->nullable()->comment('판매단가'); + $table->enum('rounding_rule', ['round', 'ceil', 'floor'])->default('round')->comment('반올림 규칙'); + $table->integer('rounding_unit')->default(1)->comment('반올림 단위 (1,10,100,1000)'); + + // 메타 정보 + $table->string('supplier', 255)->nullable()->comment('공급업체'); + $table->date('effective_from')->comment('적용 시작일'); + $table->date('effective_to')->nullable()->comment('적용 종료일'); + $table->text('note')->nullable()->comment('비고'); + + // 상태 관리 + $table->enum('status', ['draft', 'active', 'inactive', 'finalized'])->default('draft')->comment('상태'); + $table->boolean('is_final')->default(false)->comment('최종 확정 여부'); + $table->dateTime('finalized_at')->nullable()->comment('확정 일시'); + $table->unsignedBigInteger('finalized_by')->nullable()->comment('확정자 ID'); + + // 감사 컬럼 + $table->foreignId('created_by')->nullable()->comment('생성자 ID'); + $table->foreignId('updated_by')->nullable()->comment('수정자 ID'); + $table->foreignId('deleted_by')->nullable()->comment('삭제자 ID'); + $table->timestamps(); + $table->softDeletes(); + + // 인덱스 + $table->index('tenant_id', 'idx_prices_tenant'); + $table->index(['tenant_id', 'item_type_code', 'item_id'], 'idx_prices_item'); + $table->index(['tenant_id', 'effective_from', 'effective_to'], 'idx_prices_effective'); + $table->index(['tenant_id', 'status'], 'idx_prices_status'); + $table->unique( + ['tenant_id', 'item_type_code', 'item_id', 'client_group_id', 'effective_from', 'deleted_at'], + 'idx_prices_unique' + ); + + // Foreign Key + $table->foreign('client_group_id')->references('id')->on('client_groups')->onDelete('set null'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('prices'); + } +}; diff --git a/database/migrations/2025_12_08_154634_create_price_revisions_table.php b/database/migrations/2025_12_08_154634_create_price_revisions_table.php new file mode 100644 index 0000000..c555c26 --- /dev/null +++ b/database/migrations/2025_12_08_154634_create_price_revisions_table.php @@ -0,0 +1,48 @@ +id()->comment('ID'); + $table->foreignId('tenant_id')->comment('테넌트 ID'); + $table->foreignId('price_id')->comment('단가 ID'); + + // 리비전 정보 + $table->integer('revision_number')->comment('리비전 번호'); + $table->dateTime('changed_at')->comment('변경 일시'); + $table->unsignedBigInteger('changed_by')->comment('변경자 ID'); + $table->string('change_reason', 500)->nullable()->comment('변경 사유'); + + // 변경 스냅샷 (JSON) + $table->json('before_snapshot')->nullable()->comment('변경 전 데이터'); + $table->json('after_snapshot')->comment('변경 후 데이터'); + + $table->timestamp('created_at')->useCurrent(); + + // 인덱스 + $table->index('price_id', 'idx_revisions_price'); + $table->index('tenant_id', 'idx_revisions_tenant'); + $table->unique(['price_id', 'revision_number'], 'idx_revisions_unique'); + + // Foreign Key + $table->foreign('price_id')->references('id')->on('prices')->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('price_revisions'); + } +}; diff --git a/database/migrations/2025_12_08_154635_migrate_price_histories_to_prices.php b/database/migrations/2025_12_08_154635_migrate_price_histories_to_prices.php new file mode 100644 index 0000000..2161419 --- /dev/null +++ b/database/migrations/2025_12_08_154635_migrate_price_histories_to_prices.php @@ -0,0 +1,63 @@ +truncate(); + } +}; diff --git a/database/migrations/2025_12_08_154636_drop_price_histories_table.php b/database/migrations/2025_12_08_154636_drop_price_histories_table.php new file mode 100644 index 0000000..59147b8 --- /dev/null +++ b/database/migrations/2025_12_08_154636_drop_price_histories_table.php @@ -0,0 +1,44 @@ +id()->comment('ID'); + $table->foreignId('tenant_id')->comment('테넌트 ID'); + $table->string('item_type_code', 20)->comment('품목유형 (PRODUCT/MATERIAL)'); + $table->unsignedBigInteger('item_id')->comment('품목 ID'); + $table->string('price_type_code', 20)->comment('가격유형 (SALE/PURCHASE)'); + $table->unsignedBigInteger('client_group_id')->nullable()->comment('고객 그룹 ID'); + $table->decimal('price', 15, 4)->comment('단가'); + $table->date('started_at')->comment('적용 시작일'); + $table->date('ended_at')->nullable()->comment('적용 종료일'); + $table->foreignId('created_by')->nullable()->comment('생성자 ID'); + $table->foreignId('updated_by')->nullable()->comment('수정자 ID'); + $table->foreignId('deleted_by')->nullable()->comment('삭제자 ID'); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['tenant_id', 'item_type_code', 'item_id', 'client_group_id', 'started_at'], 'idx_price_histories_main'); + $table->foreign('client_group_id')->references('id')->on('client_groups')->onDelete('cascade'); + }); + } +}; diff --git a/docs/api-flows/pricing-crud-flow.json b/docs/api-flows/pricing-crud-flow.json new file mode 100644 index 0000000..074543e --- /dev/null +++ b/docs/api-flows/pricing-crud-flow.json @@ -0,0 +1,277 @@ +{ + "name": "단가 관리 CRUD 테스트", + "description": "단가(Pricing) API의 생성, 조회, 수정, 확정, 삭제 전체 플로우 테스트", + "version": "1.0", + "config": { + "baseUrl": "", + "timeout": 30000, + "stopOnFailure": true + }, + "variables": { + "user_id": "{{$env.FLOW_TESTER_USER_ID}}", + "user_pwd": "{{$env.FLOW_TESTER_USER_PWD}}" + }, + "steps": [ + { + "id": "login", + "name": "로그인", + "method": "POST", + "endpoint": "/login", + "body": { + "user_id": "{{user_id}}", + "user_pwd": "{{user_pwd}}" + }, + "expect": { + "status": [200], + "jsonPath": { + "$.message": "로그인 성공", + "$.access_token": "@isString" + } + }, + "extract": { + "token": "$.access_token" + } + }, + { + "id": "list_prices", + "name": "단가 목록 조회", + "method": "GET", + "endpoint": "/pricing", + "headers": { + "Authorization": "Bearer {{login.token}}" + }, + "query": { + "per_page": 10, + "page": 1 + }, + "expect": { + "status": [200], + "jsonPath": { + "$.success": true, + "$.data.data": "@isArray" + } + } + }, + { + "id": "create_price", + "name": "단가 생성 (MATERIAL)", + "method": "POST", + "endpoint": "/pricing", + "headers": { + "Authorization": "Bearer {{login.token}}" + }, + "body": { + "item_type_code": "MATERIAL", + "item_id": 1, + "client_group_id": null, + "purchase_price": 10000, + "processing_cost": 500, + "loss_rate": 5, + "margin_rate": 20, + "sales_price": 12600, + "rounding_rule": "round", + "rounding_unit": 100, + "supplier": "테스트 공급업체", + "effective_from": "2025-01-01", + "effective_to": "2025-12-31", + "note": "API Flow 테스트용 단가", + "status": "draft" + }, + "expect": { + "status": [201], + "jsonPath": { + "$.success": true, + "$.data.id": "@isNumber", + "$.data.item_type_code": "MATERIAL", + "$.data.purchase_price": 10000, + "$.data.status": "draft" + } + }, + "extract": { + "price_id": "$.data.id" + } + }, + { + "id": "show_price", + "name": "생성된 단가 상세 조회", + "method": "GET", + "endpoint": "/pricing/{{create_price.price_id}}", + "headers": { + "Authorization": "Bearer {{login.token}}" + }, + "expect": { + "status": [200], + "jsonPath": { + "$.success": true, + "$.data.id": "{{create_price.price_id}}", + "$.data.item_type_code": "MATERIAL", + "$.data.supplier": "테스트 공급업체" + } + } + }, + { + "id": "update_price", + "name": "단가 수정 (가격 변경)", + "method": "PUT", + "endpoint": "/pricing/{{create_price.price_id}}", + "headers": { + "Authorization": "Bearer {{login.token}}" + }, + "body": { + "purchase_price": 11000, + "processing_cost": 600, + "margin_rate": 25, + "sales_price": 14500, + "note": "단가 수정 테스트", + "change_reason": "원가 인상으로 인한 가격 조정", + "status": "active" + }, + "expect": { + "status": [200], + "jsonPath": { + "$.success": true, + "$.data.purchase_price": 11000, + "$.data.processing_cost": 600, + "$.data.status": "active" + } + } + }, + { + "id": "get_revisions", + "name": "변경 이력 조회", + "method": "GET", + "endpoint": "/pricing/{{create_price.price_id}}/revisions", + "headers": { + "Authorization": "Bearer {{login.token}}" + }, + "expect": { + "status": [200], + "jsonPath": { + "$.success": true, + "$.data": "@isArray" + } + } + }, + { + "id": "get_cost", + "name": "원가 조회 (receipt > standard 폴백)", + "method": "GET", + "endpoint": "/pricing/cost", + "headers": { + "Authorization": "Bearer {{login.token}}" + }, + "query": { + "item_type_code": "MATERIAL", + "item_id": 1, + "date": "2025-06-15" + }, + "expect": { + "status": [200], + "jsonPath": { + "$.success": true, + "$.data.item_type_code": "MATERIAL", + "$.data.item_id": 1 + } + } + }, + { + "id": "by_items", + "name": "다중 품목 단가 조회", + "method": "POST", + "endpoint": "/pricing/by-items", + "headers": { + "Authorization": "Bearer {{login.token}}" + }, + "body": { + "items": [ + { + "item_type_code": "MATERIAL", + "item_id": 1 + } + ], + "date": "2025-06-15" + }, + "expect": { + "status": [200], + "jsonPath": { + "$.success": true, + "$.data": "@isArray" + } + } + }, + { + "id": "create_price_for_finalize", + "name": "확정 테스트용 단가 생성", + "method": "POST", + "endpoint": "/pricing", + "headers": { + "Authorization": "Bearer {{login.token}}" + }, + "body": { + "item_type_code": "PRODUCT", + "item_id": 1, + "purchase_price": 50000, + "sales_price": 70000, + "effective_from": "2025-01-01", + "status": "active" + }, + "expect": { + "status": [201], + "jsonPath": { + "$.success": true, + "$.data.id": "@isNumber" + } + }, + "extract": { + "finalize_price_id": "$.data.id" + } + }, + { + "id": "finalize_price", + "name": "가격 확정 (불변 처리)", + "method": "POST", + "endpoint": "/pricing/{{create_price_for_finalize.finalize_price_id}}/finalize", + "headers": { + "Authorization": "Bearer {{login.token}}" + }, + "expect": { + "status": [200], + "jsonPath": { + "$.success": true, + "$.data.status": "finalized", + "$.data.is_final": true + } + } + }, + { + "id": "delete_price", + "name": "단가 삭제 (soft delete)", + "method": "DELETE", + "endpoint": "/pricing/{{create_price.price_id}}", + "headers": { + "Authorization": "Bearer {{login.token}}" + }, + "expect": { + "status": [200], + "jsonPath": { + "$.success": true + } + } + }, + { + "id": "verify_deleted", + "name": "삭제된 단가 조회 시 404 확인", + "method": "GET", + "endpoint": "/pricing/{{create_price.price_id}}", + "headers": { + "Authorization": "Bearer {{login.token}}" + }, + "expect": { + "status": [404], + "jsonPath": { + "$.success": false + } + } + } + ] +} diff --git a/routes/api.php b/routes/api.php index c0751a9..bbb0dc1 100644 --- a/routes/api.php +++ b/routes/api.php @@ -365,13 +365,17 @@ Route::get('/{id}/send/history', [QuoteController::class, 'sendHistory'])->whereNumber('id')->name('v1.quotes.send-history'); // 발송 이력 }); - // Pricing (가격 이력 관리) + // Pricing (단가 관리) Route::prefix('pricing')->group(function () { - Route::get('', [PricingController::class, 'index'])->name('v1.pricing.index'); // 목록 - Route::get('/show', [PricingController::class, 'show'])->name('v1.pricing.show'); // 단일 항목 가격 조회 - Route::post('/bulk', [PricingController::class, 'bulk'])->name('v1.pricing.bulk'); // 여러 항목 일괄 조회 - Route::post('/upsert', [PricingController::class, 'upsert'])->name('v1.pricing.upsert'); // 가격 등록/수정 - Route::delete('/{id}', [PricingController::class, 'destroy'])->whereNumber('id')->name('v1.pricing.destroy'); // 삭제 + Route::get('', [PricingController::class, 'index'])->name('v1.pricing.index'); // 목록 + Route::get('/cost', [PricingController::class, 'cost'])->name('v1.pricing.cost'); // 원가 조회 + Route::post('/by-items', [PricingController::class, 'byItems'])->name('v1.pricing.by-items'); // 품목별 단가 현황 + Route::post('', [PricingController::class, 'store'])->name('v1.pricing.store'); // 등록 + Route::get('/{id}', [PricingController::class, 'show'])->whereNumber('id')->name('v1.pricing.show'); // 상세 + Route::put('/{id}', [PricingController::class, 'update'])->whereNumber('id')->name('v1.pricing.update'); // 수정 + Route::delete('/{id}', [PricingController::class, 'destroy'])->whereNumber('id')->name('v1.pricing.destroy'); // 삭제 + Route::post('/{id}/finalize', [PricingController::class, 'finalize'])->whereNumber('id')->name('v1.pricing.finalize'); // 확정 + Route::get('/{id}/revisions', [PricingController::class, 'revisions'])->whereNumber('id')->name('v1.pricing.revisions'); // 변경이력 }); // Products & Materials (제품/자재 통합 관리)