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:
2025-12-08 19:03:50 +09:00
parent 56c707f033
commit 8d3ea4bb39
18 changed files with 1933 additions and 251 deletions

View File

@@ -47,7 +47,8 @@ public static function debugQueryLog(): array
public static function success(
$data = null,
string $message = '요청 성공',
array $debug = []
array $debug = [],
int $statusCode = 200
): JsonResponse {
$response = [
'success' => true,
@@ -58,7 +59,7 @@ public static function success(
$response['query'] = $debug;
}
return response()->json($response);
return response()->json($response, $statusCode);
}
public static function error(
@@ -149,19 +150,21 @@ public static function handle(
return self::error($message, $code, ['details' => $details]);
}
// 표준 박스( ['data'=>..., 'query'=>...] ) 하위호환
// 표준 박스( ['data'=>..., 'query'=>..., 'statusCode'=>...] ) 하위호환
if (is_array($result) && array_key_exists('data', $result)) {
$data = $result['data'];
$debug = $result['query'] ?? [];
$statusCode = $result['statusCode'] ?? 200;
} else {
// 그냥 도메인 결과만 반환한 경우
$data = $result;
$debug = (app()->environment('local') && request()->is('api/*'))
? self::debugQueryLog()
: [];
$statusCode = 200;
}
return self::success($data, $responseTitle, $debug);
return self::success($data, $responseTitle, $debug, $statusCode);
} catch (\Throwable $e) {

View File

@@ -4,93 +4,124 @@
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\Pricing\PricingService;
use Illuminate\Http\Request;
use App\Http\Requests\Pricing\PriceByItemsRequest;
use App\Http\Requests\Pricing\PriceCostRequest;
use App\Http\Requests\Pricing\PriceIndexRequest;
use App\Http\Requests\Pricing\PriceStoreRequest;
use App\Http\Requests\Pricing\PriceUpdateRequest;
use App\Services\PricingService;
class PricingController extends Controller
{
protected PricingService $service;
public function __construct(PricingService $service)
{
$this->service = $service;
}
public function __construct(
protected PricingService $service
) {}
/**
* 가격 이력 목록 조회
* 가 목록 조회
*/
public function index(Request $request)
public function index(PriceIndexRequest $request)
{
return ApiResponse::handle(function () use ($request) {
$filters = $request->only([
'item_type_code',
'item_id',
'price_type_code',
'client_group_id',
'date',
]);
$perPage = (int) ($request->input('size') ?? 15);
$data = $this->service->listPrices($filters, $perPage);
$data = $this->service->index($request->validated());
return ['data' => $data, 'message' => __('message.fetched')];
});
}
/**
* 단일 항목 가격 조회
* 단가 상세 조회
*/
public function show(Request $request)
public function show(int $id)
{
return ApiResponse::handle(function () use ($request) {
$itemType = $request->input('item_type'); // PRODUCT | MATERIAL
$itemId = (int) $request->input('item_id');
$clientId = $request->input('client_id') ? (int) $request->input('client_id') : null;
$date = $request->input('date') ?? null;
return ApiResponse::handle(function () use ($id) {
$data = $this->service->show($id);
$result = $this->service->getItemPrice($itemType, $itemId, $clientId, $date);
return ['data' => $result, 'message' => __('message.fetched')];
return ['data' => $data, 'message' => __('message.fetched')];
});
}
/**
* 여러 항목 일괄 가격 조회
* 단가 등록
*/
public function bulk(Request $request)
public function store(PriceStoreRequest $request)
{
return ApiResponse::handle(function () use ($request) {
$items = $request->input('items'); // [['item_type' => 'PRODUCT', 'item_id' => 1], ...]
$clientId = $request->input('client_id') ? (int) $request->input('client_id') : null;
$date = $request->input('date') ?? null;
$data = $this->service->store($request->validated());
$result = $this->service->getBulkItemPrices($items, $clientId, $date);
return ['data' => $result, 'message' => __('message.fetched')];
return ['data' => $data, 'message' => __('message.created'), 'statusCode' => 201];
});
}
/**
* 가격 등록/수정
* 단가 수정
*/
public function upsert(Request $request)
public function update(PriceUpdateRequest $request, int $id)
{
return ApiResponse::handle(function () use ($request) {
$data = $this->service->upsertPrice($request->all());
return ApiResponse::handle(function () use ($request, $id) {
$data = $this->service->update($id, $request->validated());
return ['data' => $data, 'message' => __('message.created')];
return ['data' => $data, 'message' => __('message.updated')];
});
}
/**
* 가 삭제
* 가 삭제
*/
public function destroy(int $id)
{
return ApiResponse::handle(function () use ($id) {
$this->service->deletePrice($id);
$this->service->destroy($id);
return ['data' => null, 'message' => __('message.deleted')];
});
}
/**
* 단가 확정
*/
public function finalize(int $id)
{
return ApiResponse::handle(function () use ($id) {
$data = $this->service->finalize($id);
return ['data' => $data, 'message' => __('message.pricing.finalized')];
});
}
/**
* 품목별 단가 현황 조회
*/
public function byItems(PriceByItemsRequest $request)
{
return ApiResponse::handle(function () use ($request) {
$data = $this->service->byItems($request->validated());
return ['data' => $data, 'message' => __('message.fetched')];
});
}
/**
* 변경 이력 조회
*/
public function revisions(int $id)
{
return ApiResponse::handle(function () use ($id) {
$data = $this->service->revisions($id);
return ['data' => $data, 'message' => __('message.fetched')];
});
}
/**
* 원가 조회 (수입검사 > 표준원가 fallback)
*/
public function cost(PriceCostRequest $request)
{
return ApiResponse::handle(function () use ($request) {
$data = $this->service->getCost($request->validated());
return ['data' => $data, 'message' => __('message.fetched')];
});
}
}

View 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',
];
}
}

View 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',
];
}
}

View 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',
];
}
}

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

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

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

View File

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

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

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

View File

@@ -3,87 +3,137 @@
namespace App\Swagger\v1;
/**
* @OA\Tag(name="Pricing", description="가격 이력 관리")
* @OA\Tag(name="Pricing", description="가 관리")
*
* ========= 스키마 정의 =========
*
* @OA\Schema(
* schema="PriceHistory",
* schema="Price",
* type="object",
* required={"id","item_type_code","item_id","price_type_code","price","started_at"},
* description="단가 마스터",
*
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="tenant_id", type="integer", example=1),
* @OA\Property(property="item_type_code", type="string", enum={"PRODUCT","MATERIAL"}, example="PRODUCT", description="목 유형"),
* @OA\Property(property="item_id", type="integer", example=10, description="제품/자재 ID"),
* @OA\Property(property="price_type_code", type="string", enum={"SALE","PURCHASE"}, example="SALE", description="가격 유형"),
* @OA\Property(property="client_group_id", type="integer", nullable=true, example=1, description="고객 그룹 ID (NULL=기본 가격)"),
* @OA\Property(property="price", type="number", format="decimal", example=50000.00),
* @OA\Property(property="started_at", type="string", format="date", example="2025-01-01"),
* @OA\Property(property="ended_at", type="string", format="date", nullable=true, example="2025-12-31"),
* @OA\Property(property="created_at", type="string", example="2025-10-01 12:00:00"),
* @OA\Property(property="updated_at", type="string", example="2025-10-01 12:00:00")
* @OA\Property(property="item_type_code", type="string", enum={"PRODUCT","MATERIAL"}, example="PRODUCT", description="목 유형"),
* @OA\Property(property="item_id", type="integer", example=10, description="품목 ID"),
* @OA\Property(property="client_group_id", type="integer", nullable=true, example=1, description="고객그룹 ID (NULL=기본가)"),
* @OA\Property(property="purchase_price", type="number", format="decimal", nullable=true, example=10000.00, description="매입단가 (표준원가)"),
* @OA\Property(property="processing_cost", type="number", format="decimal", nullable=true, example=2000.00, description="가공비"),
* @OA\Property(property="loss_rate", type="number", format="decimal", nullable=true, example=5.00, description="LOSS율 (%)"),
* @OA\Property(property="margin_rate", type="number", format="decimal", nullable=true, example=25.00, description="마진율 (%)"),
* @OA\Property(property="sales_price", type="number", format="decimal", nullable=true, example=15800.00, description="판매단가"),
* @OA\Property(property="rounding_rule", type="string", enum={"round","ceil","floor"}, example="round", description="반올림 규칙"),
* @OA\Property(property="rounding_unit", type="integer", example=100, description="반올림 단위 (1,10,100,1000)"),
* @OA\Property(property="supplier", type="string", nullable=true, example="ABC공급", description="공급업체"),
* @OA\Property(property="effective_from", type="string", format="date", example="2025-01-01", description="적용 시작일"),
* @OA\Property(property="effective_to", type="string", format="date", nullable=true, example="2025-12-31", description="적용 종료일"),
* @OA\Property(property="note", type="string", nullable=true, example="2025년 상반기 가격", description="비고"),
* @OA\Property(property="status", type="string", enum={"draft","active","inactive","finalized"}, example="active", description="상태"),
* @OA\Property(property="is_final", type="boolean", example=false, description="최종 확정 여부"),
* @OA\Property(property="finalized_at", type="string", format="datetime", nullable=true, example="2025-01-15 10:30:00", description="확정 일시"),
* @OA\Property(property="finalized_by", type="integer", nullable=true, example=1, description="확정자 ID"),
* @OA\Property(property="created_at", type="string", example="2025-01-01 12:00:00"),
* @OA\Property(property="updated_at", type="string", example="2025-01-01 12:00:00"),
* @OA\Property(
* property="client_group",
* type="object",
* nullable=true,
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="name", type="string", example="VIP 고객")
* )
* )
*
* @OA\Schema(
* schema="PriceHistoryPagination",
* schema="PricePagination",
* type="object",
*
* @OA\Property(property="current_page", type="integer", example=1),
* @OA\Property(
* property="data",
* type="array",
*
* @OA\Items(ref="#/components/schemas/PriceHistory")
* ),
*
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Price")),
* @OA\Property(property="first_page_url", type="string", example="/api/v1/pricing?page=1"),
* @OA\Property(property="from", type="integer", example=1),
* @OA\Property(property="last_page", type="integer", example=3),
* @OA\Property(property="last_page_url", type="string", example="/api/v1/pricing?page=3"),
* @OA\Property(
* property="links",
* type="array",
*
* @OA\Items(type="object",
*
* @OA\Property(property="url", type="string", nullable=true, example=null),
* @OA\Property(property="label", type="string", example="&laquo; Previous"),
* @OA\Property(property="active", type="boolean", example=false)
* )
* ),
* @OA\Property(property="next_page_url", type="string", nullable=true, example="/api/v1/pricing?page=2"),
* @OA\Property(property="links", type="array", @OA\Items(type="object",
* @OA\Property(property="url", type="string", nullable=true),
* @OA\Property(property="label", type="string"),
* @OA\Property(property="active", type="boolean")
* )),
* @OA\Property(property="next_page_url", type="string", nullable=true),
* @OA\Property(property="path", type="string", example="/api/v1/pricing"),
* @OA\Property(property="per_page", type="integer", example=15),
* @OA\Property(property="prev_page_url", type="string", nullable=true, example=null),
* @OA\Property(property="to", type="integer", example=15),
* @OA\Property(property="per_page", type="integer", example=20),
* @OA\Property(property="prev_page_url", type="string", nullable=true),
* @OA\Property(property="to", type="integer", example=20),
* @OA\Property(property="total", type="integer", example=50)
* )
*
* @OA\Schema(
* schema="PriceUpsertRequest",
* schema="PriceRevision",
* type="object",
* required={"item_type_code","item_id","price_type_code","price","started_at"},
* description="단가 변경 이력",
*
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="price_id", type="integer", example=1),
* @OA\Property(property="revision_number", type="integer", example=1, description="리비전 번호"),
* @OA\Property(property="changed_at", type="string", format="datetime", example="2025-01-01 12:00:00", description="변경 일시"),
* @OA\Property(property="changed_by", type="integer", example=1, description="변경자 ID"),
* @OA\Property(property="change_reason", type="string", nullable=true, example="2025년 단가 인상", description="변경 사유"),
* @OA\Property(property="before_snapshot", type="object", nullable=true, description="변경 전 데이터"),
* @OA\Property(property="after_snapshot", type="object", description="변경 후 데이터"),
* @OA\Property(
* property="changed_by_user",
* type="object",
* nullable=true,
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="name", type="string", example="홍길동")
* )
* )
*
* @OA\Schema(
* schema="PriceStoreRequest",
* type="object",
* required={"item_type_code","item_id","effective_from"},
*
* @OA\Property(property="item_type_code", type="string", enum={"PRODUCT","MATERIAL"}, example="PRODUCT"),
* @OA\Property(property="item_id", type="integer", example=10),
* @OA\Property(property="price_type_code", type="string", enum={"SALE","PURCHASE"}, example="SALE"),
* @OA\Property(property="client_group_id", type="integer", nullable=true, example=1, description="NULL=기본 가격"),
* @OA\Property(property="price", type="number", format="decimal", example=50000.00),
* @OA\Property(property="started_at", type="string", format="date", example="2025-01-01"),
* @OA\Property(property="ended_at", type="string", format="date", nullable=true, example="2025-12-31")
* @OA\Property(property="client_group_id", type="integer", nullable=true, example=1),
* @OA\Property(property="purchase_price", type="number", nullable=true, example=10000.00),
* @OA\Property(property="processing_cost", type="number", nullable=true, example=2000.00),
* @OA\Property(property="loss_rate", type="number", nullable=true, example=5.00),
* @OA\Property(property="margin_rate", type="number", nullable=true, example=25.00),
* @OA\Property(property="sales_price", type="number", nullable=true, example=15800.00),
* @OA\Property(property="rounding_rule", type="string", enum={"round","ceil","floor"}, example="round"),
* @OA\Property(property="rounding_unit", type="integer", enum={1,10,100,1000}, example=100),
* @OA\Property(property="supplier", type="string", nullable=true, example="ABC공급"),
* @OA\Property(property="effective_from", type="string", format="date", example="2025-01-01"),
* @OA\Property(property="effective_to", type="string", format="date", nullable=true, example="2025-12-31"),
* @OA\Property(property="note", type="string", nullable=true, example="2025년 상반기 가격"),
* @OA\Property(property="status", type="string", enum={"draft","active","inactive"}, example="draft")
* )
*
* @OA\Schema(
* schema="PriceQueryResult",
* schema="PriceUpdateRequest",
* type="object",
*
* @OA\Property(property="price", type="number", format="decimal", nullable=true, example=50000.00),
* @OA\Property(property="price_history_id", type="integer", nullable=true, example=1),
* @OA\Property(property="item_type_code", type="string", enum={"PRODUCT","MATERIAL"}, example="PRODUCT"),
* @OA\Property(property="item_id", type="integer", example=10),
* @OA\Property(property="client_group_id", type="integer", nullable=true, example=1),
* @OA\Property(property="warning", type="string", nullable=true, example="가격을 찾을 수 없습니다")
* @OA\Property(property="purchase_price", type="number", nullable=true, example=10000.00),
* @OA\Property(property="processing_cost", type="number", nullable=true, example=2000.00),
* @OA\Property(property="loss_rate", type="number", nullable=true, example=5.00),
* @OA\Property(property="margin_rate", type="number", nullable=true, example=25.00),
* @OA\Property(property="sales_price", type="number", nullable=true, example=15800.00),
* @OA\Property(property="rounding_rule", type="string", enum={"round","ceil","floor"}, example="round"),
* @OA\Property(property="rounding_unit", type="integer", enum={1,10,100,1000}, example=100),
* @OA\Property(property="supplier", type="string", nullable=true, example="ABC공급"),
* @OA\Property(property="effective_from", type="string", format="date", example="2025-01-01"),
* @OA\Property(property="effective_to", type="string", format="date", nullable=true, example="2025-12-31"),
* @OA\Property(property="note", type="string", nullable=true, example="2025년 상반기 가격"),
* @OA\Property(property="status", type="string", enum={"draft","active","inactive"}, example="active"),
* @OA\Property(property="change_reason", type="string", nullable=true, example="단가 인상", description="변경 사유 (리비전 기록용)")
* )
*
* @OA\Schema(
* schema="BulkPriceQueryRequest",
* schema="PriceByItemsRequest",
* type="object",
* required={"items"},
*
@@ -93,32 +143,39 @@
*
* @OA\Items(type="object",
*
* @OA\Property(property="item_type", type="string", enum={"PRODUCT","MATERIAL"}, example="PRODUCT"),
* @OA\Property(property="item_type_code", type="string", enum={"PRODUCT","MATERIAL"}, example="PRODUCT"),
* @OA\Property(property="item_id", type="integer", example=10)
* )
* ),
* @OA\Property(property="client_id", type="integer", nullable=true, example=5),
* @OA\Property(property="date", type="string", format="date", nullable=true, example="2025-10-13")
* @OA\Property(property="client_group_id", type="integer", nullable=true, example=1),
* @OA\Property(property="date", type="string", format="date", nullable=true, example="2025-01-15")
* )
*
* @OA\Schema(
* schema="BulkPriceQueryResult",
* schema="PriceByItemsResult",
* type="array",
*
* @OA\Items(type="object",
*
* @OA\Property(property="item_type_code", type="string", example="PRODUCT"),
* @OA\Property(property="item_id", type="integer", example=10),
* @OA\Property(property="price", ref="#/components/schemas/Price", nullable=true),
* @OA\Property(property="has_price", type="boolean", example=true)
* )
* )
*
* @OA\Schema(
* schema="PriceCostResult",
* type="object",
*
* @OA\Property(
* property="prices",
* type="array",
*
* @OA\Items(type="object",
*
* @OA\Property(property="item_type", type="string", example="PRODUCT"),
* @OA\Property(property="item_id", type="integer", example=10),
* @OA\Property(property="price", type="number", nullable=true, example=50000.00),
* @OA\Property(property="price_history_id", type="integer", nullable=true, example=1),
* @OA\Property(property="client_group_id", type="integer", nullable=true, example=1)
* )
* ),
* @OA\Property(property="warnings", type="array", @OA\Items(type="string"))
* @OA\Property(property="item_type_code", type="string", example="MATERIAL"),
* @OA\Property(property="item_id", type="integer", example=123),
* @OA\Property(property="date", type="string", format="date", example="2025-01-15"),
* @OA\Property(property="cost_source", type="string", enum={"receipt","standard","not_found"}, example="receipt", description="원가 출처"),
* @OA\Property(property="purchase_price", type="number", nullable=true, example=10500.00),
* @OA\Property(property="receipt_id", type="integer", nullable=true, example=456, description="수입검사 ID (cost_source=receipt일 때)"),
* @OA\Property(property="receipt_date", type="string", format="date", nullable=true, example="2025-01-10"),
* @OA\Property(property="price_id", type="integer", nullable=true, example=null, description="단가 ID (cost_source=standard일 때)")
* )
*/
class PricingApi
@@ -127,22 +184,24 @@ class PricingApi
* @OA\Get(
* path="/api/v1/pricing",
* tags={"Pricing"},
* summary="가격 이력 목록",
* summary="단가 목록 조회",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="q", in="query", description="검색어 (supplier, note)", @OA\Schema(type="string")),
* @OA\Parameter(name="item_type_code", in="query", @OA\Schema(type="string", enum={"PRODUCT","MATERIAL"})),
* @OA\Parameter(name="item_id", in="query", @OA\Schema(type="integer")),
* @OA\Parameter(name="price_type_code", in="query", @OA\Schema(type="string", enum={"SALE","PURCHASE"})),
* @OA\Parameter(name="client_group_id", in="query", @OA\Schema(type="integer")),
* @OA\Parameter(name="date", in="query", description="특정 날짜 기준 유효한 가격", @OA\Schema(type="string", format="date")),
* @OA\Parameter(name="size", in="query", @OA\Schema(type="integer", example=15)),
* @OA\Parameter(name="client_group_id", in="query", description="고객그룹 ID (빈값/null=기본가만)", @OA\Schema(type="string")),
* @OA\Parameter(name="status", in="query", @OA\Schema(type="string", enum={"draft","active","inactive","finalized"})),
* @OA\Parameter(name="valid_at", in="query", description="특정 날짜 유효한 단가만", @OA\Schema(type="string", format="date")),
* @OA\Parameter(name="size", in="query", @OA\Schema(type="integer", example=20)),
* @OA\Parameter(name="page", in="query", @OA\Schema(type="integer", example=1)),
*
* @OA\Response(response=200, description="조회 성공",
*
* @OA\JsonContent(allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/PriceHistoryPagination"))
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/PricePagination"))
* })
* ),
*
@@ -153,86 +212,188 @@ public function index() {}
/**
* @OA\Get(
* path="/api/v1/pricing/show",
* path="/api/v1/pricing/{id}",
* tags={"Pricing"},
* summary="단일 항목 가격 조회",
* description="특정 제품/자재의 현재 유효한 가격 조회",
* summary="단가 상세 조회",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="item_type", in="query", required=true, @OA\Schema(type="string", enum={"PRODUCT","MATERIAL"})),
* @OA\Parameter(name="item_id", in="query", required=true, @OA\Schema(type="integer")),
* @OA\Parameter(name="client_id", in="query", @OA\Schema(type="integer"), description="고객 ID (고객 그룹별 가격 적용)"),
* @OA\Parameter(name="date", in="query", @OA\Schema(type="string", format="date"), description="기준일 (미지정시 오늘)"),
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
*
* @OA\Response(response=200, description="조회 성공",
*
* @OA\JsonContent(allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/PriceQueryResult"))
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Price"))
* })
* )
* ),
*
* @OA\Response(response=404, description="미존재", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function show() {}
/**
* @OA\Post(
* path="/api/v1/pricing/bulk",
* path="/api/v1/pricing",
* tags={"Pricing"},
* summary="여러 항목 일괄 가격 조회",
* description="여러 제품/자재의 가격을 한 번에 조회",
* summary="단가 등록",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/BulkPriceQueryRequest")),
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/PriceStoreRequest")),
*
* @OA\Response(response=200, description="조회 성공",
* @OA\Response(response=200, description="등록 성공",
*
* @OA\JsonContent(allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/BulkPriceQueryResult"))
* })
* )
* )
*/
public function bulk() {}
/**
* @OA\Post(
* path="/api/v1/pricing/upsert",
* tags={"Pricing"},
* summary="가격 등록/수정",
* description="가격 이력 등록 (동일 조건 존재 시 업데이트)",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/PriceUpsertRequest")),
*
* @OA\Response(response=200, description="저장 성공",
*
* @OA\JsonContent(allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/PriceHistory"))
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Price"))
* })
* ),
*
* @OA\Response(response=400, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function upsert() {}
public function store() {}
/**
* @OA\Put(
* path="/api/v1/pricing/{id}",
* tags={"Pricing"},
* summary="단가 수정",
* description="확정(finalized) 상태의 단가는 수정 불가",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
*
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/PriceUpdateRequest")),
*
* @OA\Response(response=200, description="수정 성공",
*
* @OA\JsonContent(allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Price"))
* })
* ),
*
* @OA\Response(response=400, description="수정 불가 (확정된 단가)", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function update() {}
/**
* @OA\Delete(
* path="/api/v1/pricing/{id}",
* tags={"Pricing"},
* summary="가격 이력 삭제(soft)",
* summary="가 삭제 (soft)",
* description="확정(finalized) 상태의 단가는 삭제 불가",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
*
* @OA\Response(response=200, description="삭제 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse"))
* @OA\Response(response=200, description="삭제 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse")),
* @OA\Response(response=400, description="삭제 불가", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function destroy() {}
/**
* @OA\Post(
* path="/api/v1/pricing/{id}/finalize",
* tags={"Pricing"},
* summary="단가 확정",
* description="단가를 확정 상태로 변경 (확정 후 수정/삭제 불가)",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
*
* @OA\Response(response=200, description="확정 성공",
*
* @OA\JsonContent(allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Price"))
* })
* ),
*
* @OA\Response(response=400, description="확정 불가", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function finalize() {}
/**
* @OA\Post(
* path="/api/v1/pricing/by-items",
* tags={"Pricing"},
* summary="품목별 단가 현황 조회",
* description="여러 품목의 현재 유효한 단가를 한번에 조회",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/PriceByItemsRequest")),
*
* @OA\Response(response=200, description="조회 성공",
*
* @OA\JsonContent(allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/PriceByItemsResult"))
* })
* )
* )
*/
public function byItems() {}
/**
* @OA\Get(
* path="/api/v1/pricing/{id}/revisions",
* tags={"Pricing"},
* summary="변경 이력 조회",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
* @OA\Parameter(name="size", in="query", @OA\Schema(type="integer", example=20)),
*
* @OA\Response(response=200, description="조회 성공",
*
* @OA\JsonContent(allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", type="object",
*
* @OA\Property(property="current_page", type="integer"),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/PriceRevision")),
* @OA\Property(property="total", type="integer")
* ))
* })
* ),
*
* @OA\Response(response=404, description="단가 미존재", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function revisions() {}
/**
* @OA\Get(
* path="/api/v1/pricing/cost",
* tags={"Pricing"},
* summary="원가 조회",
* description="품목의 원가를 조회. 자재는 수입검사 입고단가 우선, 없으면 표준원가 사용",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="item_type_code", in="query", required=true, @OA\Schema(type="string", enum={"PRODUCT","MATERIAL"})),
* @OA\Parameter(name="item_id", in="query", required=true, @OA\Schema(type="integer")),
* @OA\Parameter(name="date", in="query", description="기준일 (미지정시 오늘)", @OA\Schema(type="string", format="date")),
*
* @OA\Response(response=200, description="조회 성공",
*
* @OA\JsonContent(allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/PriceCostResult"))
* })
* )
* )
*/
public function cost() {}
}

View File

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

View File

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

View File

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

View File

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

View 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
}
}
}
]
}

View File

@@ -365,13 +365,17 @@
Route::get('/{id}/send/history', [QuoteController::class, 'sendHistory'])->whereNumber('id')->name('v1.quotes.send-history'); // 발송 이력
});
// Pricing (가격 이력 관리)
// Pricing (가 관리)
Route::prefix('pricing')->group(function () {
Route::get('', [PricingController::class, 'index'])->name('v1.pricing.index'); // 목록
Route::get('/show', [PricingController::class, 'show'])->name('v1.pricing.show'); // 단일 항목 가격 조회
Route::post('/bulk', [PricingController::class, 'bulk'])->name('v1.pricing.bulk'); // 여러 항목 일괄 조회
Route::post('/upsert', [PricingController::class, 'upsert'])->name('v1.pricing.upsert'); // 가격 등록/수정
Route::delete('/{id}', [PricingController::class, 'destroy'])->whereNumber('id')->name('v1.pricing.destroy'); // 삭제
Route::get('', [PricingController::class, 'index'])->name('v1.pricing.index'); // 목록
Route::get('/cost', [PricingController::class, 'cost'])->name('v1.pricing.cost'); // 원가 조회
Route::post('/by-items', [PricingController::class, 'byItems'])->name('v1.pricing.by-items'); // 품목별 단가 현황
Route::post('', [PricingController::class, 'store'])->name('v1.pricing.store'); // 등록
Route::get('/{id}', [PricingController::class, 'show'])->whereNumber('id')->name('v1.pricing.show'); // 상세
Route::put('/{id}', [PricingController::class, 'update'])->whereNumber('id')->name('v1.pricing.update'); // 수정
Route::delete('/{id}', [PricingController::class, 'destroy'])->whereNumber('id')->name('v1.pricing.destroy'); // 삭제
Route::post('/{id}/finalize', [PricingController::class, 'finalize'])->whereNumber('id')->name('v1.pricing.finalize'); // 확정
Route::get('/{id}/revisions', [PricingController::class, 'revisions'])->whereNumber('id')->name('v1.pricing.revisions'); // 변경이력
});
// Products & Materials (제품/자재 통합 관리)