feat: H-1 입고 관리 API 구현

- ReceivingController: CRUD 및 목록 조회 API
- ReceivingService: 입고 비즈니스 로직
- Receiving 모델: 다중 테넌트 지원
- FormRequest 검증 클래스
- Swagger 문서화
- receivings 테이블 마이그레이션

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-26 15:45:33 +09:00
parent 84cce6742e
commit 43ccd1e6e0
8 changed files with 1039 additions and 0 deletions

View File

@@ -0,0 +1,99 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\V1\Receiving\ProcessReceivingRequest;
use App\Http\Requests\V1\Receiving\StoreReceivingRequest;
use App\Http\Requests\V1\Receiving\UpdateReceivingRequest;
use App\Services\ReceivingService;
use Illuminate\Http\Request;
class ReceivingController extends Controller
{
public function __construct(
private readonly ReceivingService $service
) {}
/**
* 입고 목록
*/
public function index(Request $request)
{
$params = $request->only([
'search',
'status',
'start_date',
'end_date',
'sort_by',
'sort_dir',
'per_page',
'page',
]);
$receivings = $this->service->index($params);
return ApiResponse::success($receivings, __('message.fetched'));
}
/**
* 입고 통계
*/
public function stats()
{
$stats = $this->service->stats();
return ApiResponse::success($stats, __('message.fetched'));
}
/**
* 입고 등록
*/
public function store(StoreReceivingRequest $request)
{
$receiving = $this->service->store($request->validated());
return ApiResponse::success($receiving, __('message.created'), [], 201);
}
/**
* 입고 상세
*/
public function show(int $id)
{
$receiving = $this->service->show($id);
return ApiResponse::success($receiving, __('message.fetched'));
}
/**
* 입고 수정
*/
public function update(int $id, UpdateReceivingRequest $request)
{
$receiving = $this->service->update($id, $request->validated());
return ApiResponse::success($receiving, __('message.updated'));
}
/**
* 입고 삭제
*/
public function destroy(int $id)
{
$this->service->destroy($id);
return ApiResponse::success(null, __('message.deleted'));
}
/**
* 입고처리 (상태 변경 + 입고 정보 입력)
*/
public function process(int $id, ProcessReceivingRequest $request)
{
$receiving = $this->service->process($id, $request->validated());
return ApiResponse::success($receiving, __('message.receiving.processed'));
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Http\Requests\V1\Receiving;
use Illuminate\Foundation\Http\FormRequest;
class ProcessReceivingRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'receiving_qty' => ['required', 'numeric', 'min:0'],
'receiving_date' => ['nullable', 'date'],
'lot_no' => ['nullable', 'string', 'max:50'],
'supplier_lot' => ['nullable', 'string', 'max:50'],
'receiving_location' => ['required', 'string', 'max:100'],
'receiving_manager' => ['nullable', 'string', 'max:50'],
'remark' => ['nullable', 'string', 'max:1000'],
];
}
public function messages(): array
{
return [
'receiving_qty.required' => __('validation.required', ['attribute' => '입고수량']),
'receiving_qty.numeric' => __('validation.numeric', ['attribute' => '입고수량']),
'receiving_qty.min' => __('validation.min.numeric', ['attribute' => '입고수량', 'min' => 0]),
'receiving_location.required' => __('validation.required', ['attribute' => '입고위치']),
];
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Http\Requests\V1\Receiving;
use Illuminate\Foundation\Http\FormRequest;
class StoreReceivingRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'order_no' => ['nullable', 'string', 'max:30'],
'order_date' => ['nullable', 'date'],
'item_id' => ['nullable', 'integer'],
'item_code' => ['required', 'string', 'max:50'],
'item_name' => ['required', 'string', 'max:200'],
'specification' => ['nullable', 'string', 'max:200'],
'supplier' => ['required', 'string', 'max:100'],
'order_qty' => ['required', 'numeric', 'min:0'],
'order_unit' => ['nullable', 'string', 'max:20'],
'due_date' => ['nullable', 'date'],
'status' => ['nullable', 'string', 'in:order_completed,shipping,inspection_pending,receiving_pending'],
'remark' => ['nullable', 'string', 'max:1000'],
];
}
public function messages(): array
{
return [
'item_code.required' => __('validation.required', ['attribute' => '품목코드']),
'item_name.required' => __('validation.required', ['attribute' => '품목명']),
'supplier.required' => __('validation.required', ['attribute' => '공급업체']),
'order_qty.required' => __('validation.required', ['attribute' => '발주수량']),
'order_qty.numeric' => __('validation.numeric', ['attribute' => '발주수량']),
'order_qty.min' => __('validation.min.numeric', ['attribute' => '발주수량', 'min' => 0]),
];
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Requests\V1\Receiving;
use Illuminate\Foundation\Http\FormRequest;
class UpdateReceivingRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'order_no' => ['nullable', 'string', 'max:30'],
'order_date' => ['nullable', 'date'],
'item_code' => ['sometimes', 'string', 'max:50'],
'item_name' => ['sometimes', 'string', 'max:200'],
'specification' => ['nullable', 'string', 'max:200'],
'supplier' => ['sometimes', 'string', 'max:100'],
'order_qty' => ['sometimes', 'numeric', 'min:0'],
'order_unit' => ['nullable', 'string', 'max:20'],
'due_date' => ['nullable', 'date'],
'status' => ['sometimes', 'string', 'in:order_completed,shipping,inspection_pending,receiving_pending'],
'remark' => ['nullable', 'string', 'max:1000'],
];
}
public function messages(): array
{
return [
'order_qty.numeric' => __('validation.numeric', ['attribute' => '발주수량']),
'order_qty.min' => __('validation.min.numeric', ['attribute' => '발주수량', 'min' => 0]),
];
}
}

View File

@@ -0,0 +1,107 @@
<?php
namespace App\Models\Tenants;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class Receiving extends Model
{
use BelongsToTenant, SoftDeletes;
protected $fillable = [
'tenant_id',
'receiving_number',
'order_no',
'order_date',
'item_id',
'item_code',
'item_name',
'specification',
'supplier',
'order_qty',
'order_unit',
'due_date',
'receiving_qty',
'receiving_date',
'lot_no',
'supplier_lot',
'receiving_location',
'receiving_manager',
'status',
'remark',
'created_by',
'updated_by',
'deleted_by',
];
protected $casts = [
'order_date' => 'date',
'due_date' => 'date',
'receiving_date' => 'date',
'order_qty' => 'decimal:2',
'receiving_qty' => 'decimal:2',
'item_id' => 'integer',
];
/**
* 상태 목록
*/
public const STATUSES = [
'order_completed' => '발주완료',
'shipping' => '배송중',
'inspection_pending' => '검사대기',
'receiving_pending' => '입고대기',
'completed' => '입고완료',
];
/**
* 품목 관계
*/
public function item(): BelongsTo
{
return $this->belongsTo(\App\Models\Items\Item::class);
}
/**
* 생성자 관계
*/
public function creator(): BelongsTo
{
return $this->belongsTo(\App\Models\Members\User::class, 'created_by');
}
/**
* 상태 라벨
*/
public function getStatusLabelAttribute(): string
{
return self::STATUSES[$this->status] ?? $this->status;
}
/**
* 수정 가능 여부
*/
public function canEdit(): bool
{
return $this->status !== 'completed';
}
/**
* 삭제 가능 여부
*/
public function canDelete(): bool
{
return $this->status !== 'completed';
}
/**
* 입고처리 가능 여부
*/
public function canProcess(): bool
{
return in_array($this->status, ['order_completed', 'shipping', 'inspection_pending', 'receiving_pending']);
}
}

View File

@@ -0,0 +1,298 @@
<?php
namespace App\Services;
use App\Models\Tenants\Receiving;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
class ReceivingService extends Service
{
/**
* 입고 목록 조회
*/
public function index(array $params): LengthAwarePaginator
{
$tenantId = $this->tenantId();
$query = Receiving::query()
->where('tenant_id', $tenantId);
// 검색어 필터
if (! empty($params['search'])) {
$search = $params['search'];
$query->where(function ($q) use ($search) {
$q->where('order_no', 'like', "%{$search}%")
->orWhere('item_code', 'like', "%{$search}%")
->orWhere('item_name', 'like', "%{$search}%")
->orWhere('supplier', 'like', "%{$search}%");
});
}
// 상태 필터
if (! empty($params['status'])) {
if ($params['status'] === 'receiving_pending') {
// 입고대기: receiving_pending + inspection_pending
$query->whereIn('status', ['receiving_pending', 'inspection_pending']);
} elseif ($params['status'] === 'completed') {
$query->where('status', 'completed');
} else {
$query->where('status', $params['status']);
}
}
// 날짜 범위 필터
if (! empty($params['start_date'])) {
$query->where('receiving_date', '>=', $params['start_date']);
}
if (! empty($params['end_date'])) {
$query->where('receiving_date', '<=', $params['end_date']);
}
// 정렬
$sortBy = $params['sort_by'] ?? 'created_at';
$sortDir = $params['sort_dir'] ?? 'desc';
$query->orderBy($sortBy, $sortDir);
// 페이지네이션
$perPage = $params['per_page'] ?? 20;
return $query->paginate($perPage);
}
/**
* 입고 통계 조회
*/
public function stats(): array
{
$tenantId = $this->tenantId();
$today = now()->toDateString();
$receivingPendingCount = Receiving::where('tenant_id', $tenantId)
->where('status', 'receiving_pending')
->count();
$shippingCount = Receiving::where('tenant_id', $tenantId)
->where('status', 'shipping')
->count();
$inspectionPendingCount = Receiving::where('tenant_id', $tenantId)
->where('status', 'inspection_pending')
->count();
$todayReceivingCount = Receiving::where('tenant_id', $tenantId)
->where('status', 'completed')
->whereDate('receiving_date', $today)
->count();
return [
'receiving_pending_count' => $receivingPendingCount,
'shipping_count' => $shippingCount,
'inspection_pending_count' => $inspectionPendingCount,
'today_receiving_count' => $todayReceivingCount,
];
}
/**
* 입고 상세 조회
*/
public function show(int $id): Receiving
{
$tenantId = $this->tenantId();
return Receiving::query()
->where('tenant_id', $tenantId)
->with(['creator:id,name'])
->findOrFail($id);
}
/**
* 입고 등록
*/
public function store(array $data): Receiving
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
return DB::transaction(function () use ($data, $tenantId, $userId) {
// 입고번호 자동 생성
$receivingNumber = $this->generateReceivingNumber($tenantId);
$receiving = new Receiving;
$receiving->tenant_id = $tenantId;
$receiving->receiving_number = $receivingNumber;
$receiving->order_no = $data['order_no'] ?? null;
$receiving->order_date = $data['order_date'] ?? null;
$receiving->item_id = $data['item_id'] ?? null;
$receiving->item_code = $data['item_code'];
$receiving->item_name = $data['item_name'];
$receiving->specification = $data['specification'] ?? null;
$receiving->supplier = $data['supplier'];
$receiving->order_qty = $data['order_qty'];
$receiving->order_unit = $data['order_unit'] ?? 'EA';
$receiving->due_date = $data['due_date'] ?? null;
$receiving->status = $data['status'] ?? 'order_completed';
$receiving->remark = $data['remark'] ?? null;
$receiving->created_by = $userId;
$receiving->updated_by = $userId;
$receiving->save();
return $receiving;
});
}
/**
* 입고 수정
*/
public function update(int $id, array $data): Receiving
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
return DB::transaction(function () use ($id, $data, $tenantId, $userId) {
$receiving = Receiving::query()
->where('tenant_id', $tenantId)
->findOrFail($id);
if (! $receiving->canEdit()) {
throw new \Exception(__('error.receiving.cannot_edit'));
}
if (isset($data['order_no'])) {
$receiving->order_no = $data['order_no'];
}
if (isset($data['order_date'])) {
$receiving->order_date = $data['order_date'];
}
if (isset($data['item_code'])) {
$receiving->item_code = $data['item_code'];
}
if (isset($data['item_name'])) {
$receiving->item_name = $data['item_name'];
}
if (array_key_exists('specification', $data)) {
$receiving->specification = $data['specification'];
}
if (isset($data['supplier'])) {
$receiving->supplier = $data['supplier'];
}
if (isset($data['order_qty'])) {
$receiving->order_qty = $data['order_qty'];
}
if (isset($data['order_unit'])) {
$receiving->order_unit = $data['order_unit'];
}
if (array_key_exists('due_date', $data)) {
$receiving->due_date = $data['due_date'];
}
if (isset($data['status'])) {
$receiving->status = $data['status'];
}
if (array_key_exists('remark', $data)) {
$receiving->remark = $data['remark'];
}
$receiving->updated_by = $userId;
$receiving->save();
return $receiving->fresh();
});
}
/**
* 입고 삭제
*/
public function destroy(int $id): bool
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
return DB::transaction(function () use ($id, $tenantId, $userId) {
$receiving = Receiving::query()
->where('tenant_id', $tenantId)
->findOrFail($id);
if (! $receiving->canDelete()) {
throw new \Exception(__('error.receiving.cannot_delete'));
}
$receiving->deleted_by = $userId;
$receiving->save();
$receiving->delete();
return true;
});
}
/**
* 입고처리 (상태 변경 + 입고 정보 입력)
*/
public function process(int $id, array $data): Receiving
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
return DB::transaction(function () use ($id, $data, $tenantId, $userId) {
$receiving = Receiving::query()
->where('tenant_id', $tenantId)
->findOrFail($id);
if (! $receiving->canProcess()) {
throw new \Exception(__('error.receiving.cannot_process'));
}
// LOT번호 생성 (없으면 자동 생성)
$lotNo = $data['lot_no'] ?? $this->generateLotNo();
$receiving->receiving_qty = $data['receiving_qty'];
$receiving->receiving_date = $data['receiving_date'] ?? now()->toDateString();
$receiving->lot_no = $lotNo;
$receiving->supplier_lot = $data['supplier_lot'] ?? null;
$receiving->receiving_location = $data['receiving_location'];
$receiving->receiving_manager = $data['receiving_manager'] ?? null;
$receiving->status = 'completed';
$receiving->remark = $data['remark'] ?? $receiving->remark;
$receiving->updated_by = $userId;
$receiving->save();
return $receiving->fresh();
});
}
/**
* 입고번호 자동 생성
*/
private function generateReceivingNumber(int $tenantId): string
{
$prefix = 'RV'.date('Ymd');
$lastReceiving = Receiving::query()
->where('tenant_id', $tenantId)
->where('receiving_number', 'like', $prefix.'%')
->orderBy('receiving_number', 'desc')
->first();
if ($lastReceiving) {
$lastSeq = (int) substr($lastReceiving->receiving_number, -4);
$newSeq = $lastSeq + 1;
} else {
$newSeq = 1;
}
return $prefix.str_pad($newSeq, 4, '0', STR_PAD_LEFT);
}
/**
* LOT번호 자동 생성
*/
private function generateLotNo(): string
{
$now = now();
$year = $now->format('y');
$month = $now->format('m');
$day = $now->format('d');
$seq = str_pad(rand(1, 99), 2, '0', STR_PAD_LEFT);
return "{$year}{$month}{$day}-{$seq}";
}
}

View File

@@ -0,0 +1,360 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(name="Receivings", description="입고 관리")
*
* @OA\Schema(
* schema="Receiving",
* type="object",
* description="입고 정보",
*
* @OA\Property(property="id", type="integer", example=1, description="입고 ID"),
* @OA\Property(property="tenant_id", type="integer", example=1, description="테넌트 ID"),
* @OA\Property(property="receiving_number", type="string", example="RV202512260001", description="입고번호"),
* @OA\Property(property="order_no", type="string", example="PO-2025-001", nullable=true, description="발주번호"),
* @OA\Property(property="order_date", type="string", format="date", example="2025-12-20", nullable=true, description="발주일자"),
* @OA\Property(property="item_id", type="integer", example=1, nullable=true, description="품목 ID"),
* @OA\Property(property="item_code", type="string", example="ITEM-001", description="품목코드"),
* @OA\Property(property="item_name", type="string", example="원재료 A", description="품목명"),
* @OA\Property(property="specification", type="string", example="100x100", nullable=true, description="규격"),
* @OA\Property(property="supplier", type="string", example="(주)공급사", description="공급업체"),
* @OA\Property(property="order_qty", type="number", format="float", example=100.00, description="발주수량"),
* @OA\Property(property="order_unit", type="string", example="EA", description="발주단위"),
* @OA\Property(property="due_date", type="string", format="date", example="2025-12-26", nullable=true, description="납기일"),
* @OA\Property(property="receiving_qty", type="number", format="float", example=100.00, nullable=true, description="입고수량"),
* @OA\Property(property="receiving_date", type="string", format="date", example="2025-12-26", nullable=true, description="입고일자"),
* @OA\Property(property="lot_no", type="string", example="251226-01", nullable=true, description="입고 LOT번호"),
* @OA\Property(property="supplier_lot", type="string", example="SUP-LOT-001", nullable=true, description="공급업체 LOT"),
* @OA\Property(property="receiving_location", type="string", example="A-01-01", nullable=true, description="입고위치"),
* @OA\Property(property="receiving_manager", type="string", example="홍길동", nullable=true, description="입고담당"),
* @OA\Property(property="status", type="string", enum={"order_completed","shipping","inspection_pending","receiving_pending","completed"}, example="receiving_pending", description="상태"),
* @OA\Property(property="status_label", type="string", example="입고대기", description="상태 라벨"),
* @OA\Property(property="remark", type="string", example="비고 내용", nullable=true, description="비고"),
* @OA\Property(property="creator", type="object", nullable=true,
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="name", type="string", example="관리자"),
* description="생성자 정보"
* ),
* @OA\Property(property="created_at", type="string", format="date-time"),
* @OA\Property(property="updated_at", type="string", format="date-time")
* )
*
* @OA\Schema(
* schema="ReceivingStats",
* type="object",
* description="입고 통계",
*
* @OA\Property(property="receiving_pending_count", type="integer", example=5, description="입고대기 건수"),
* @OA\Property(property="shipping_count", type="integer", example=3, description="배송중 건수"),
* @OA\Property(property="inspection_pending_count", type="integer", example=2, description="검사대기 건수"),
* @OA\Property(property="today_receiving_count", type="integer", example=10, description="금일 입고 건수")
* )
*
* @OA\Schema(
* schema="ReceivingCreateRequest",
* type="object",
* required={"item_code","item_name","supplier","order_qty"},
* description="입고 등록 요청",
*
* @OA\Property(property="order_no", type="string", example="PO-2025-001", maxLength=30, nullable=true, description="발주번호"),
* @OA\Property(property="order_date", type="string", format="date", example="2025-12-20", nullable=true, description="발주일자"),
* @OA\Property(property="item_id", type="integer", example=1, nullable=true, description="품목 ID"),
* @OA\Property(property="item_code", type="string", example="ITEM-001", maxLength=50, description="품목코드"),
* @OA\Property(property="item_name", type="string", example="원재료 A", maxLength=200, description="품목명"),
* @OA\Property(property="specification", type="string", example="100x100", maxLength=200, nullable=true, description="규격"),
* @OA\Property(property="supplier", type="string", example="(주)공급사", maxLength=100, description="공급업체"),
* @OA\Property(property="order_qty", type="number", format="float", example=100.00, description="발주수량"),
* @OA\Property(property="order_unit", type="string", example="EA", maxLength=20, description="발주단위"),
* @OA\Property(property="due_date", type="string", format="date", example="2025-12-26", nullable=true, description="납기일"),
* @OA\Property(property="status", type="string", enum={"order_completed","shipping","inspection_pending","receiving_pending"}, example="order_completed", description="상태"),
* @OA\Property(property="remark", type="string", example="비고 내용", maxLength=1000, nullable=true, description="비고")
* )
*
* @OA\Schema(
* schema="ReceivingUpdateRequest",
* type="object",
* description="입고 수정 요청",
*
* @OA\Property(property="order_no", type="string", example="PO-2025-001", maxLength=30, nullable=true, description="발주번호"),
* @OA\Property(property="order_date", type="string", format="date", example="2025-12-20", nullable=true, description="발주일자"),
* @OA\Property(property="item_code", type="string", example="ITEM-001", maxLength=50, description="품목코드"),
* @OA\Property(property="item_name", type="string", example="원재료 A", maxLength=200, description="품목명"),
* @OA\Property(property="specification", type="string", example="100x100", maxLength=200, nullable=true, description="규격"),
* @OA\Property(property="supplier", type="string", example="(주)공급사", maxLength=100, description="공급업체"),
* @OA\Property(property="order_qty", type="number", format="float", example=100.00, description="발주수량"),
* @OA\Property(property="order_unit", type="string", example="EA", maxLength=20, description="발주단위"),
* @OA\Property(property="due_date", type="string", format="date", example="2025-12-26", nullable=true, description="납기일"),
* @OA\Property(property="status", type="string", enum={"order_completed","shipping","inspection_pending","receiving_pending"}, example="shipping", description="상태"),
* @OA\Property(property="remark", type="string", example="비고 내용", maxLength=1000, nullable=true, description="비고")
* )
*
* @OA\Schema(
* schema="ReceivingProcessRequest",
* type="object",
* required={"receiving_qty","receiving_location"},
* description="입고처리 요청",
*
* @OA\Property(property="receiving_qty", type="number", format="float", example=100.00, description="입고수량"),
* @OA\Property(property="receiving_date", type="string", format="date", example="2025-12-26", nullable=true, description="입고일자 (미입력시 오늘)"),
* @OA\Property(property="lot_no", type="string", example="251226-01", maxLength=50, nullable=true, description="LOT번호 (미입력시 자동생성)"),
* @OA\Property(property="supplier_lot", type="string", example="SUP-LOT-001", maxLength=50, nullable=true, description="공급업체 LOT"),
* @OA\Property(property="receiving_location", type="string", example="A-01-01", maxLength=100, description="입고위치"),
* @OA\Property(property="receiving_manager", type="string", example="홍길동", maxLength=50, nullable=true, description="입고담당"),
* @OA\Property(property="remark", type="string", example="비고 내용", maxLength=1000, nullable=true, description="비고")
* )
*/
class ReceivingApi
{
/**
* @OA\Get(
* path="/api/v1/receivings",
* tags={"Receivings"},
* summary="입고 목록 조회",
* description="입고 목록을 조회합니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="search", in="query", description="검색어 (발주번호, 품목코드, 품목명, 공급업체)", @OA\Schema(type="string")),
* @OA\Parameter(name="status", in="query", description="상태 (receiving_pending 선택시 inspection_pending도 포함)", @OA\Schema(type="string", enum={"order_completed","shipping","inspection_pending","receiving_pending","completed"})),
* @OA\Parameter(name="start_date", in="query", description="입고일 시작일", @OA\Schema(type="string", format="date")),
* @OA\Parameter(name="end_date", in="query", description="입고일 종료일", @OA\Schema(type="string", format="date")),
* @OA\Parameter(name="sort_by", in="query", description="정렬 기준", @OA\Schema(type="string", enum={"receiving_date","due_date","created_at"}, default="created_at")),
* @OA\Parameter(name="sort_dir", in="query", description="정렬 방향", @OA\Schema(type="string", enum={"asc","desc"}, default="desc")),
* @OA\Parameter(ref="#/components/parameters/Page"),
* @OA\Parameter(ref="#/components/parameters/Size"),
*
* @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", example=1),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Receiving")),
* @OA\Property(property="per_page", type="integer", example=20),
* @OA\Property(property="total", type="integer", example=50)
* )
* )
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function index() {}
/**
* @OA\Get(
* path="/api/v1/receivings/stats",
* tags={"Receivings"},
* summary="입고 통계 조회",
* description="입고 현황 통계를 조회합니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Response(
* response=200,
* description="조회 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(property="data", ref="#/components/schemas/ReceivingStats")
* )
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function stats() {}
/**
* @OA\Post(
* path="/api/v1/receivings",
* tags={"Receivings"},
* summary="입고 등록",
* description="새로운 입고를 등록합니다. 입고번호는 자동 생성됩니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\RequestBody(
* required=true,
*
* @OA\JsonContent(ref="#/components/schemas/ReceivingCreateRequest")
* ),
*
* @OA\Response(
* response=201,
* description="등록 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(property="data", ref="#/components/schemas/Receiving")
* )
* }
* )
* ),
*
* @OA\Response(response=400, description="잘못된 요청", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function store() {}
/**
* @OA\Get(
* path="/api/v1/receivings/{id}",
* tags={"Receivings"},
* summary="입고 상세 조회",
* description="입고 상세 정보를 조회합니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="id", in="path", required=true, description="입고 ID", @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/Receiving")
* )
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=404, description="입고 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function show() {}
/**
* @OA\Put(
* path="/api/v1/receivings/{id}",
* tags={"Receivings"},
* summary="입고 수정",
* description="입고 정보를 수정합니다. 입고완료(completed) 상태에서는 수정 불가합니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="id", in="path", required=true, description="입고 ID", @OA\Schema(type="integer")),
*
* @OA\RequestBody(
* required=true,
*
* @OA\JsonContent(ref="#/components/schemas/ReceivingUpdateRequest")
* ),
*
* @OA\Response(
* response=200,
* description="수정 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(property="data", ref="#/components/schemas/Receiving")
* )
* }
* )
* ),
*
* @OA\Response(response=400, description="잘못된 요청 또는 수정 불가", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=404, description="입고 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function update() {}
/**
* @OA\Delete(
* path="/api/v1/receivings/{id}",
* tags={"Receivings"},
* summary="입고 삭제",
* description="입고를 삭제합니다. 입고완료(completed) 상태에서는 삭제 불가합니다. (Soft Delete)",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="id", in="path", required=true, description="입고 ID", @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")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=404, description="입고 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function destroy() {}
/**
* @OA\Post(
* path="/api/v1/receivings/{id}/process",
* tags={"Receivings"},
* summary="입고처리",
* description="입고를 처리합니다. 입고수량, 입고위치, LOT번호 등을 입력하고 상태를 '입고완료'로 변경합니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="id", in="path", required=true, description="입고 ID", @OA\Schema(type="integer")),
*
* @OA\RequestBody(
* required=true,
*
* @OA\JsonContent(ref="#/components/schemas/ReceivingProcessRequest")
* ),
*
* @OA\Response(
* response=200,
* description="입고처리 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(property="data", ref="#/components/schemas/Receiving")
* )
* }
* )
* ),
*
* @OA\Response(response=400, description="입고처리 불가", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=404, description="입고 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function process() {}
}

View File

@@ -0,0 +1,58 @@
<?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('receivings', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
$table->string('receiving_number', 30)->comment('입고번호');
$table->string('order_no', 30)->nullable()->comment('발주번호');
$table->date('order_date')->nullable()->comment('발주일자');
$table->unsignedBigInteger('item_id')->nullable()->comment('품목 ID');
$table->string('item_code', 50)->comment('품목코드');
$table->string('item_name', 200)->comment('품목명');
$table->string('specification', 200)->nullable()->comment('규격');
$table->string('supplier', 100)->comment('공급업체');
$table->decimal('order_qty', 15, 2)->comment('발주수량');
$table->string('order_unit', 20)->default('EA')->comment('발주단위');
$table->date('due_date')->nullable()->comment('납기일');
$table->decimal('receiving_qty', 15, 2)->nullable()->comment('입고수량');
$table->date('receiving_date')->nullable()->comment('입고일자');
$table->string('lot_no', 50)->nullable()->comment('입고 LOT번호');
$table->string('supplier_lot', 50)->nullable()->comment('공급업체 LOT');
$table->string('receiving_location', 100)->nullable()->comment('입고위치');
$table->string('receiving_manager', 50)->nullable()->comment('입고담당');
$table->string('status', 30)->default('order_completed')->comment('상태: order_completed/shipping/inspection_pending/receiving_pending/completed');
$table->text('remark')->nullable()->comment('비고');
$table->unsignedBigInteger('created_by')->nullable()->comment('생성자');
$table->unsignedBigInteger('updated_by')->nullable()->comment('수정자');
$table->unsignedBigInteger('deleted_by')->nullable()->comment('삭제자');
$table->softDeletes();
$table->timestamps();
// 인덱스
$table->unique(['tenant_id', 'receiving_number'], 'uk_tenant_receiving_number');
$table->index(['tenant_id', 'status'], 'idx_tenant_status');
$table->index(['tenant_id', 'receiving_date'], 'idx_tenant_receiving_date');
$table->index('item_id', 'idx_item');
$table->index('order_no', 'idx_order_no');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('receivings');
}
};