feat: 단가 관리 API 구현 및 Flow Tester 호환성 개선
- Price, PriceRevision 모델 추가 (PriceHistory 대체) - PricingService: CRUD, 원가 조회, 확정 기능 - PricingController: statusCode 파라미터로 201 반환 지원 - NotFoundHttpException(404) 적용 (존재하지 않는 리소스) - FormRequest 분리 (Store, Update, Index, Cost, ByItems) - Swagger 문서 업데이트 - ApiResponse::handle()에 statusCode 옵션 추가 - prices/price_revisions 마이그레이션 및 데이터 이관
This commit is contained in:
@@ -47,7 +47,8 @@ public static function debugQueryLog(): array
|
|||||||
public static function success(
|
public static function success(
|
||||||
$data = null,
|
$data = null,
|
||||||
string $message = '요청 성공',
|
string $message = '요청 성공',
|
||||||
array $debug = []
|
array $debug = [],
|
||||||
|
int $statusCode = 200
|
||||||
): JsonResponse {
|
): JsonResponse {
|
||||||
$response = [
|
$response = [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
@@ -58,7 +59,7 @@ public static function success(
|
|||||||
$response['query'] = $debug;
|
$response['query'] = $debug;
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json($response);
|
return response()->json($response, $statusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function error(
|
public static function error(
|
||||||
@@ -149,19 +150,21 @@ public static function handle(
|
|||||||
return self::error($message, $code, ['details' => $details]);
|
return self::error($message, $code, ['details' => $details]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 표준 박스( ['data'=>..., 'query'=>...] ) 하위호환
|
// 표준 박스( ['data'=>..., 'query'=>..., 'statusCode'=>...] ) 하위호환
|
||||||
if (is_array($result) && array_key_exists('data', $result)) {
|
if (is_array($result) && array_key_exists('data', $result)) {
|
||||||
$data = $result['data'];
|
$data = $result['data'];
|
||||||
$debug = $result['query'] ?? [];
|
$debug = $result['query'] ?? [];
|
||||||
|
$statusCode = $result['statusCode'] ?? 200;
|
||||||
} else {
|
} else {
|
||||||
// 그냥 도메인 결과만 반환한 경우
|
// 그냥 도메인 결과만 반환한 경우
|
||||||
$data = $result;
|
$data = $result;
|
||||||
$debug = (app()->environment('local') && request()->is('api/*'))
|
$debug = (app()->environment('local') && request()->is('api/*'))
|
||||||
? self::debugQueryLog()
|
? self::debugQueryLog()
|
||||||
: [];
|
: [];
|
||||||
|
$statusCode = 200;
|
||||||
}
|
}
|
||||||
|
|
||||||
return self::success($data, $responseTitle, $debug);
|
return self::success($data, $responseTitle, $debug, $statusCode);
|
||||||
|
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
|
||||||
|
|||||||
@@ -4,93 +4,124 @@
|
|||||||
|
|
||||||
use App\Helpers\ApiResponse;
|
use App\Helpers\ApiResponse;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Services\Pricing\PricingService;
|
use App\Http\Requests\Pricing\PriceByItemsRequest;
|
||||||
use Illuminate\Http\Request;
|
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
|
class PricingController extends Controller
|
||||||
{
|
{
|
||||||
protected PricingService $service;
|
public function __construct(
|
||||||
|
protected PricingService $service
|
||||||
public function __construct(PricingService $service)
|
) {}
|
||||||
{
|
|
||||||
$this->service = $service;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 가격 이력 목록 조회
|
* 단가 목록 조회
|
||||||
*/
|
*/
|
||||||
public function index(Request $request)
|
public function index(PriceIndexRequest $request)
|
||||||
{
|
{
|
||||||
return ApiResponse::handle(function () use ($request) {
|
return ApiResponse::handle(function () use ($request) {
|
||||||
$filters = $request->only([
|
$data = $this->service->index($request->validated());
|
||||||
'item_type_code',
|
|
||||||
'item_id',
|
|
||||||
'price_type_code',
|
|
||||||
'client_group_id',
|
|
||||||
'date',
|
|
||||||
]);
|
|
||||||
$perPage = (int) ($request->input('size') ?? 15);
|
|
||||||
|
|
||||||
$data = $this->service->listPrices($filters, $perPage);
|
|
||||||
|
|
||||||
return ['data' => $data, 'message' => __('message.fetched')];
|
return ['data' => $data, 'message' => __('message.fetched')];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 단일 항목 가격 조회
|
* 단가 상세 조회
|
||||||
*/
|
*/
|
||||||
public function show(Request $request)
|
public function show(int $id)
|
||||||
{
|
{
|
||||||
return ApiResponse::handle(function () use ($request) {
|
return ApiResponse::handle(function () use ($id) {
|
||||||
$itemType = $request->input('item_type'); // PRODUCT | MATERIAL
|
$data = $this->service->show($id);
|
||||||
$itemId = (int) $request->input('item_id');
|
|
||||||
$clientId = $request->input('client_id') ? (int) $request->input('client_id') : null;
|
|
||||||
$date = $request->input('date') ?? null;
|
|
||||||
|
|
||||||
$result = $this->service->getItemPrice($itemType, $itemId, $clientId, $date);
|
return ['data' => $data, 'message' => __('message.fetched')];
|
||||||
|
|
||||||
return ['data' => $result, 'message' => __('message.fetched')];
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 여러 항목 일괄 가격 조회
|
* 단가 등록
|
||||||
*/
|
*/
|
||||||
public function bulk(Request $request)
|
public function store(PriceStoreRequest $request)
|
||||||
{
|
{
|
||||||
return ApiResponse::handle(function () use ($request) {
|
return ApiResponse::handle(function () use ($request) {
|
||||||
$items = $request->input('items'); // [['item_type' => 'PRODUCT', 'item_id' => 1], ...]
|
$data = $this->service->store($request->validated());
|
||||||
$clientId = $request->input('client_id') ? (int) $request->input('client_id') : null;
|
|
||||||
$date = $request->input('date') ?? null;
|
|
||||||
|
|
||||||
$result = $this->service->getBulkItemPrices($items, $clientId, $date);
|
return ['data' => $data, 'message' => __('message.created'), 'statusCode' => 201];
|
||||||
|
|
||||||
return ['data' => $result, 'message' => __('message.fetched')];
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 가격 등록/수정
|
* 단가 수정
|
||||||
*/
|
*/
|
||||||
public function upsert(Request $request)
|
public function update(PriceUpdateRequest $request, int $id)
|
||||||
{
|
{
|
||||||
return ApiResponse::handle(function () use ($request) {
|
return ApiResponse::handle(function () use ($request, $id) {
|
||||||
$data = $this->service->upsertPrice($request->all());
|
$data = $this->service->update($id, $request->validated());
|
||||||
|
|
||||||
return ['data' => $data, 'message' => __('message.created')];
|
return ['data' => $data, 'message' => __('message.updated')];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 가격 삭제
|
* 단가 삭제
|
||||||
*/
|
*/
|
||||||
public function destroy(int $id)
|
public function destroy(int $id)
|
||||||
{
|
{
|
||||||
return ApiResponse::handle(function () use ($id) {
|
return ApiResponse::handle(function () use ($id) {
|
||||||
$this->service->deletePrice($id);
|
$this->service->destroy($id);
|
||||||
|
|
||||||
return ['data' => null, 'message' => __('message.deleted')];
|
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')];
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
24
app/Http/Requests/Pricing/PriceByItemsRequest.php
Normal file
24
app/Http/Requests/Pricing/PriceByItemsRequest.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Pricing;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class PriceByItemsRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'items' => '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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/Http/Requests/Pricing/PriceCostRequest.php
Normal file
22
app/Http/Requests/Pricing/PriceCostRequest.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Pricing;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class PriceCostRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'item_type_code' => 'required|string|in:PRODUCT,MATERIAL',
|
||||||
|
'item_id' => 'required|integer',
|
||||||
|
'date' => 'nullable|date',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/Http/Requests/Pricing/PriceIndexRequest.php
Normal file
27
app/Http/Requests/Pricing/PriceIndexRequest.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Pricing;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class PriceIndexRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'size' => '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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/Http/Requests/Pricing/PriceStoreRequest.php
Normal file
51
app/Http/Requests/Pricing/PriceStoreRequest.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Pricing;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class PriceStoreRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
// 품목 연결 (필수)
|
||||||
|
'item_type_code' => '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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/Http/Requests/Pricing/PriceUpdateRequest.php
Normal file
54
app/Http/Requests/Pricing/PriceUpdateRequest.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Pricing;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class PriceUpdateRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
// 품목 연결 (선택)
|
||||||
|
'item_type_code' => '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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
258
app/Models/Products/Price.php
Normal file
258
app/Models/Products/Price.php
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\Products;
|
||||||
|
|
||||||
|
use App\Models\Materials\Material;
|
||||||
|
use App\Models\Orders\ClientGroup;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단가 마스터 모델
|
||||||
|
*
|
||||||
|
* @property int $id
|
||||||
|
* @property int $tenant_id
|
||||||
|
* @property string $item_type_code
|
||||||
|
* @property int $item_id
|
||||||
|
* @property int|null $client_group_id
|
||||||
|
* @property float|null $purchase_price
|
||||||
|
* @property float|null $processing_cost
|
||||||
|
* @property float|null $loss_rate
|
||||||
|
* @property float|null $margin_rate
|
||||||
|
* @property float|null $sales_price
|
||||||
|
* @property string $rounding_rule
|
||||||
|
* @property int $rounding_unit
|
||||||
|
* @property string|null $supplier
|
||||||
|
* @property \Carbon\Carbon $effective_from
|
||||||
|
* @property \Carbon\Carbon|null $effective_to
|
||||||
|
* @property string|null $note
|
||||||
|
* @property string $status
|
||||||
|
* @property bool $is_final
|
||||||
|
* @property \Carbon\Carbon|null $finalized_at
|
||||||
|
* @property int|null $finalized_by
|
||||||
|
*/
|
||||||
|
class Price extends Model
|
||||||
|
{
|
||||||
|
use BelongsToTenant, SoftDeletes;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'tenant_id',
|
||||||
|
'item_type_code',
|
||||||
|
'item_id',
|
||||||
|
'client_group_id',
|
||||||
|
'purchase_price',
|
||||||
|
'processing_cost',
|
||||||
|
'loss_rate',
|
||||||
|
'margin_rate',
|
||||||
|
'sales_price',
|
||||||
|
'rounding_rule',
|
||||||
|
'rounding_unit',
|
||||||
|
'supplier',
|
||||||
|
'effective_from',
|
||||||
|
'effective_to',
|
||||||
|
'note',
|
||||||
|
'status',
|
||||||
|
'is_final',
|
||||||
|
'finalized_at',
|
||||||
|
'finalized_by',
|
||||||
|
'created_by',
|
||||||
|
'updated_by',
|
||||||
|
'deleted_by',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'purchase_price' => '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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models\Products;
|
|
||||||
|
|
||||||
use App\Models\Orders\ClientGroup;
|
|
||||||
use App\Traits\BelongsToTenant;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @mixin IdeHelperPriceHistory
|
|
||||||
*/
|
|
||||||
class PriceHistory extends Model
|
|
||||||
{
|
|
||||||
use BelongsToTenant, SoftDeletes;
|
|
||||||
|
|
||||||
protected $fillable = [
|
|
||||||
'tenant_id',
|
|
||||||
'item_type_code',
|
|
||||||
'item_id',
|
|
||||||
'price_type_code',
|
|
||||||
'client_group_id',
|
|
||||||
'price',
|
|
||||||
'started_at',
|
|
||||||
'ended_at',
|
|
||||||
'created_by',
|
|
||||||
'updated_by',
|
|
||||||
'deleted_by',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'price' => '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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
92
app/Models/Products/PriceRevision.php
Normal file
92
app/Models/Products/PriceRevision.php
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\Products;
|
||||||
|
|
||||||
|
use App\Traits\BelongsToTenant;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단가 변경 이력 모델
|
||||||
|
*
|
||||||
|
* @property int $id
|
||||||
|
* @property int $tenant_id
|
||||||
|
* @property int $price_id
|
||||||
|
* @property int $revision_number
|
||||||
|
* @property \Carbon\Carbon $changed_at
|
||||||
|
* @property int $changed_by
|
||||||
|
* @property string|null $change_reason
|
||||||
|
* @property array|null $before_snapshot
|
||||||
|
* @property array $after_snapshot
|
||||||
|
*/
|
||||||
|
class PriceRevision extends Model
|
||||||
|
{
|
||||||
|
use BelongsToTenant;
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'tenant_id',
|
||||||
|
'price_id',
|
||||||
|
'revision_number',
|
||||||
|
'changed_at',
|
||||||
|
'changed_by',
|
||||||
|
'change_reason',
|
||||||
|
'before_snapshot',
|
||||||
|
'after_snapshot',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'revision_number' => '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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
533
app/Services/PricingService.php
Normal file
533
app/Services/PricingService.php
Normal file
@@ -0,0 +1,533 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Materials\MaterialReceipt;
|
||||||
|
use App\Models\Products\Price;
|
||||||
|
use App\Models\Products\PriceRevision;
|
||||||
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
class PricingService extends Service
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 단가 목록 조회
|
||||||
|
*/
|
||||||
|
public function index(array $params): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
$tenantId = $this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,87 +3,137 @@
|
|||||||
namespace App\Swagger\v1;
|
namespace App\Swagger\v1;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @OA\Tag(name="Pricing", description="가격 이력 관리")
|
* @OA\Tag(name="Pricing", description="단가 관리")
|
||||||
|
*
|
||||||
|
* ========= 스키마 정의 =========
|
||||||
*
|
*
|
||||||
* @OA\Schema(
|
* @OA\Schema(
|
||||||
* schema="PriceHistory",
|
* schema="Price",
|
||||||
* type="object",
|
* 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="id", type="integer", example=1),
|
||||||
* @OA\Property(property="tenant_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_type_code", type="string", enum={"PRODUCT","MATERIAL"}, example="PRODUCT", description="품목 유형"),
|
||||||
* @OA\Property(property="item_id", type="integer", example=10, description="제품/자재 ID"),
|
* @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="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="price", type="number", format="decimal", example=50000.00),
|
* @OA\Property(property="processing_cost", type="number", format="decimal", nullable=true, example=2000.00, description="가공비"),
|
||||||
* @OA\Property(property="started_at", type="string", format="date", example="2025-01-01"),
|
* @OA\Property(property="loss_rate", type="number", format="decimal", nullable=true, example=5.00, description="LOSS율 (%)"),
|
||||||
* @OA\Property(property="ended_at", type="string", format="date", nullable=true, example="2025-12-31"),
|
* @OA\Property(property="margin_rate", type="number", format="decimal", nullable=true, example=25.00, description="마진율 (%)"),
|
||||||
* @OA\Property(property="created_at", type="string", example="2025-10-01 12:00:00"),
|
* @OA\Property(property="sales_price", type="number", format="decimal", nullable=true, example=15800.00, description="판매단가"),
|
||||||
* @OA\Property(property="updated_at", type="string", example="2025-10-01 12:00:00")
|
* @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(
|
* @OA\Schema(
|
||||||
* schema="PriceHistoryPagination",
|
* schema="PricePagination",
|
||||||
* type="object",
|
* type="object",
|
||||||
*
|
*
|
||||||
* @OA\Property(property="current_page", type="integer", example=1),
|
* @OA\Property(property="current_page", type="integer", example=1),
|
||||||
* @OA\Property(
|
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Price")),
|
||||||
* property="data",
|
|
||||||
* type="array",
|
|
||||||
*
|
|
||||||
* @OA\Items(ref="#/components/schemas/PriceHistory")
|
|
||||||
* ),
|
|
||||||
*
|
|
||||||
* @OA\Property(property="first_page_url", type="string", example="/api/v1/pricing?page=1"),
|
* @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="from", type="integer", example=1),
|
||||||
* @OA\Property(property="last_page", type="integer", example=3),
|
* @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="last_page_url", type="string", example="/api/v1/pricing?page=3"),
|
||||||
* @OA\Property(
|
* @OA\Property(property="links", type="array", @OA\Items(type="object",
|
||||||
* property="links",
|
* @OA\Property(property="url", type="string", nullable=true),
|
||||||
* type="array",
|
* @OA\Property(property="label", type="string"),
|
||||||
*
|
* @OA\Property(property="active", type="boolean")
|
||||||
* @OA\Items(type="object",
|
* )),
|
||||||
*
|
* @OA\Property(property="next_page_url", type="string", nullable=true),
|
||||||
* @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="path", type="string", example="/api/v1/pricing"),
|
* @OA\Property(property="path", type="string", example="/api/v1/pricing"),
|
||||||
* @OA\Property(property="per_page", type="integer", example=15),
|
* @OA\Property(property="per_page", type="integer", example=20),
|
||||||
* @OA\Property(property="prev_page_url", type="string", nullable=true, example=null),
|
* @OA\Property(property="prev_page_url", type="string", nullable=true),
|
||||||
* @OA\Property(property="to", type="integer", example=15),
|
* @OA\Property(property="to", type="integer", example=20),
|
||||||
* @OA\Property(property="total", type="integer", example=50)
|
* @OA\Property(property="total", type="integer", example=50)
|
||||||
* )
|
* )
|
||||||
*
|
*
|
||||||
* @OA\Schema(
|
* @OA\Schema(
|
||||||
* schema="PriceUpsertRequest",
|
* schema="PriceRevision",
|
||||||
* type="object",
|
* 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_type_code", type="string", enum={"PRODUCT","MATERIAL"}, example="PRODUCT"),
|
||||||
* @OA\Property(property="item_id", type="integer", example=10),
|
* @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),
|
||||||
* @OA\Property(property="client_group_id", type="integer", nullable=true, example=1, description="NULL=기본 가격"),
|
* @OA\Property(property="purchase_price", type="number", nullable=true, example=10000.00),
|
||||||
* @OA\Property(property="price", type="number", format="decimal", example=50000.00),
|
* @OA\Property(property="processing_cost", type="number", nullable=true, example=2000.00),
|
||||||
* @OA\Property(property="started_at", type="string", format="date", example="2025-01-01"),
|
* @OA\Property(property="loss_rate", type="number", nullable=true, example=5.00),
|
||||||
* @OA\Property(property="ended_at", type="string", format="date", nullable=true, example="2025-12-31")
|
* @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(
|
* @OA\Schema(
|
||||||
* schema="PriceQueryResult",
|
* schema="PriceUpdateRequest",
|
||||||
* type="object",
|
* type="object",
|
||||||
*
|
*
|
||||||
* @OA\Property(property="price", type="number", format="decimal", nullable=true, example=50000.00),
|
* @OA\Property(property="item_type_code", type="string", enum={"PRODUCT","MATERIAL"}, example="PRODUCT"),
|
||||||
* @OA\Property(property="price_history_id", type="integer", nullable=true, example=1),
|
* @OA\Property(property="item_id", type="integer", example=10),
|
||||||
* @OA\Property(property="client_group_id", type="integer", nullable=true, example=1),
|
* @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(
|
* @OA\Schema(
|
||||||
* schema="BulkPriceQueryRequest",
|
* schema="PriceByItemsRequest",
|
||||||
* type="object",
|
* type="object",
|
||||||
* required={"items"},
|
* required={"items"},
|
||||||
*
|
*
|
||||||
@@ -93,32 +143,39 @@
|
|||||||
*
|
*
|
||||||
* @OA\Items(type="object",
|
* @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="item_id", type="integer", example=10)
|
||||||
* )
|
* )
|
||||||
* ),
|
* ),
|
||||||
* @OA\Property(property="client_id", type="integer", nullable=true, example=5),
|
* @OA\Property(property="client_group_id", type="integer", nullable=true, example=1),
|
||||||
* @OA\Property(property="date", type="string", format="date", nullable=true, example="2025-10-13")
|
* @OA\Property(property="date", type="string", format="date", nullable=true, example="2025-01-15")
|
||||||
* )
|
* )
|
||||||
*
|
*
|
||||||
* @OA\Schema(
|
* @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",
|
* type="object",
|
||||||
*
|
*
|
||||||
* @OA\Property(
|
* @OA\Property(property="item_type_code", type="string", example="MATERIAL"),
|
||||||
* property="prices",
|
* @OA\Property(property="item_id", type="integer", example=123),
|
||||||
* type="array",
|
* @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\Items(type="object",
|
* @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="item_type", type="string", example="PRODUCT"),
|
* @OA\Property(property="receipt_date", type="string", format="date", nullable=true, example="2025-01-10"),
|
||||||
* @OA\Property(property="item_id", type="integer", example=10),
|
* @OA\Property(property="price_id", type="integer", nullable=true, example=null, description="단가 ID (cost_source=standard일 때)")
|
||||||
* @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"))
|
|
||||||
* )
|
* )
|
||||||
*/
|
*/
|
||||||
class PricingApi
|
class PricingApi
|
||||||
@@ -127,22 +184,24 @@ class PricingApi
|
|||||||
* @OA\Get(
|
* @OA\Get(
|
||||||
* path="/api/v1/pricing",
|
* path="/api/v1/pricing",
|
||||||
* tags={"Pricing"},
|
* tags={"Pricing"},
|
||||||
* summary="가격 이력 목록",
|
* summary="단가 목록 조회",
|
||||||
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
* 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_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="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", description="고객그룹 ID (빈값/null=기본가만)", @OA\Schema(type="string")),
|
||||||
* @OA\Parameter(name="client_group_id", in="query", @OA\Schema(type="integer")),
|
* @OA\Parameter(name="status", in="query", @OA\Schema(type="string", enum={"draft","active","inactive","finalized"})),
|
||||||
* @OA\Parameter(name="date", in="query", description="특정 날짜 기준 유효한 가격", @OA\Schema(type="string", format="date")),
|
* @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=15)),
|
* @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\Response(response=200, description="조회 성공",
|
||||||
*
|
*
|
||||||
* @OA\JsonContent(allOf={
|
* @OA\JsonContent(allOf={
|
||||||
*
|
*
|
||||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
* @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(
|
* @OA\Get(
|
||||||
* path="/api/v1/pricing/show",
|
* path="/api/v1/pricing/{id}",
|
||||||
* tags={"Pricing"},
|
* tags={"Pricing"},
|
||||||
* summary="단일 항목 가격 조회",
|
* summary="단가 상세 조회",
|
||||||
* description="특정 제품/자재의 현재 유효한 가격 조회",
|
|
||||||
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||||
*
|
*
|
||||||
* @OA\Parameter(name="item_type", in="query", required=true, @OA\Schema(type="string", enum={"PRODUCT","MATERIAL"})),
|
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
|
||||||
* @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\Response(response=200, description="조회 성공",
|
* @OA\Response(response=200, description="조회 성공",
|
||||||
*
|
*
|
||||||
* @OA\JsonContent(allOf={
|
* @OA\JsonContent(allOf={
|
||||||
*
|
*
|
||||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
* @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() {}
|
public function show() {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @OA\Post(
|
* @OA\Post(
|
||||||
* path="/api/v1/pricing/bulk",
|
* path="/api/v1/pricing",
|
||||||
* tags={"Pricing"},
|
* tags={"Pricing"},
|
||||||
* summary="여러 항목 일괄 가격 조회",
|
* summary="단가 등록",
|
||||||
* description="여러 제품/자재의 가격을 한 번에 조회",
|
|
||||||
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
* 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\JsonContent(allOf={
|
||||||
*
|
*
|
||||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||||
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/BulkPriceQueryResult"))
|
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Price"))
|
||||||
* })
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
*/
|
|
||||||
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\Response(response=400, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
* @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(
|
* @OA\Delete(
|
||||||
* path="/api/v1/pricing/{id}",
|
* path="/api/v1/pricing/{id}",
|
||||||
* tags={"Pricing"},
|
* tags={"Pricing"},
|
||||||
* summary="가격 이력 삭제(soft)",
|
* summary="단가 삭제 (soft)",
|
||||||
|
* description="확정(finalized) 상태의 단가는 삭제 불가",
|
||||||
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
|
||||||
*
|
*
|
||||||
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
|
* @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() {}
|
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() {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<?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::create('prices', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?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::create('price_revisions', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
* price_histories 데이터를 prices 테이블로 이관
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
// 기존 price_histories 데이터를 prices로 이관
|
||||||
|
DB::statement("
|
||||||
|
INSERT INTO prices (
|
||||||
|
tenant_id,
|
||||||
|
item_type_code,
|
||||||
|
item_id,
|
||||||
|
client_group_id,
|
||||||
|
purchase_price,
|
||||||
|
sales_price,
|
||||||
|
effective_from,
|
||||||
|
effective_to,
|
||||||
|
status,
|
||||||
|
created_by,
|
||||||
|
updated_by,
|
||||||
|
deleted_by,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
deleted_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
tenant_id,
|
||||||
|
item_type_code,
|
||||||
|
item_id,
|
||||||
|
client_group_id,
|
||||||
|
CASE WHEN price_type_code = 'PURCHASE' THEN price ELSE NULL END as purchase_price,
|
||||||
|
CASE WHEN price_type_code = 'SALE' THEN price ELSE NULL END as sales_price,
|
||||||
|
started_at as effective_from,
|
||||||
|
ended_at as effective_to,
|
||||||
|
'active' as status,
|
||||||
|
created_by,
|
||||||
|
updated_by,
|
||||||
|
deleted_by,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
deleted_at
|
||||||
|
FROM price_histories
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
// prices 테이블에서 이관된 데이터 삭제
|
||||||
|
// (원본 price_histories는 아직 존재하므로 prices만 비움)
|
||||||
|
DB::table('prices')->truncate();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
* price_histories 테이블 삭제 (prices로 완전 이관 완료 후)
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('price_histories');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
* 롤백 시 price_histories 테이블 재생성
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::create('price_histories', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
277
docs/api-flows/pricing-crud-flow.json
Normal file
277
docs/api-flows/pricing-crud-flow.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -365,13 +365,17 @@
|
|||||||
Route::get('/{id}/send/history', [QuoteController::class, 'sendHistory'])->whereNumber('id')->name('v1.quotes.send-history'); // 발송 이력
|
Route::get('/{id}/send/history', [QuoteController::class, 'sendHistory'])->whereNumber('id')->name('v1.quotes.send-history'); // 발송 이력
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pricing (가격 이력 관리)
|
// Pricing (단가 관리)
|
||||||
Route::prefix('pricing')->group(function () {
|
Route::prefix('pricing')->group(function () {
|
||||||
Route::get('', [PricingController::class, 'index'])->name('v1.pricing.index'); // 목록
|
Route::get('', [PricingController::class, 'index'])->name('v1.pricing.index'); // 목록
|
||||||
Route::get('/show', [PricingController::class, 'show'])->name('v1.pricing.show'); // 단일 항목 가격 조회
|
Route::get('/cost', [PricingController::class, 'cost'])->name('v1.pricing.cost'); // 원가 조회
|
||||||
Route::post('/bulk', [PricingController::class, 'bulk'])->name('v1.pricing.bulk'); // 여러 항목 일괄 조회
|
Route::post('/by-items', [PricingController::class, 'byItems'])->name('v1.pricing.by-items'); // 품목별 단가 현황
|
||||||
Route::post('/upsert', [PricingController::class, 'upsert'])->name('v1.pricing.upsert'); // 가격 등록/수정
|
Route::post('', [PricingController::class, 'store'])->name('v1.pricing.store'); // 등록
|
||||||
Route::delete('/{id}', [PricingController::class, 'destroy'])->whereNumber('id')->name('v1.pricing.destroy'); // 삭제
|
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 (제품/자재 통합 관리)
|
// Products & Materials (제품/자재 통합 관리)
|
||||||
|
|||||||
Reference in New Issue
Block a user