Files
sam-api/app/Swagger/v1/PricingApi.php
hskwon 8d3ea4bb39 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 마이그레이션 및 데이터 이관
2025-12-08 19:03:50 +09:00

400 lines
19 KiB
PHP

<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(name="Pricing", description="단가 관리")
*
* ========= 스키마 정의 =========
*
* @OA\Schema(
* schema="Price",
* type="object",
* 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="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="PricePagination",
* type="object",
*
* @OA\Property(property="current_page", type="integer", example=1),
* @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),
* @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=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="PriceRevision",
* type="object",
* 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="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="PriceUpdateRequest",
* type="object",
*
* @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="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="PriceByItemsRequest",
* type="object",
* required={"items"},
*
* @OA\Property(
* property="items",
* type="array",
*
* @OA\Items(type="object",
*
* @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="date", type="string", format="date", nullable=true, example="2025-01-15")
* )
*
* @OA\Schema(
* 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="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
{
/**
* @OA\Get(
* path="/api/v1/pricing",
* tags={"Pricing"},
* 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="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/PricePagination"))
* })
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function index() {}
/**
* @OA\Get(
* path="/api/v1/pricing/{id}",
* tags={"Pricing"},
* summary="단가 상세 조회",
* 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=404, description="미존재", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function show() {}
/**
* @OA\Post(
* path="/api/v1/pricing",
* tags={"Pricing"},
* summary="단가 등록",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/PriceStoreRequest")),
*
* @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 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)",
* 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=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() {}
}