feat: H-3 출하 관리 API 구현

- ShipmentController: 출하 CRUD 및 상태 관리 API
- ShipmentService: 출하 비즈니스 로직
- Shipment, ShipmentItem 모델
- FormRequest 검증 클래스
- Swagger 문서화
- shipments, shipment_items 테이블 마이그레이션

🤖 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:46:07 +09:00
parent 5ec201b985
commit aca0902c26
10 changed files with 1808 additions and 0 deletions

View File

@@ -0,0 +1,164 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Shipment\ShipmentStoreRequest;
use App\Http\Requests\Shipment\ShipmentUpdateRequest;
use App\Http\Requests\Shipment\ShipmentUpdateStatusRequest;
use App\Services\ShipmentService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ShipmentController extends Controller
{
public function __construct(
private readonly ShipmentService $service
) {}
/**
* 출하 목록 조회
*/
public function index(Request $request): JsonResponse
{
$params = $request->only([
'search',
'status',
'priority',
'delivery_method',
'scheduled_from',
'scheduled_to',
'can_ship',
'deposit_confirmed',
'sort_by',
'sort_dir',
'per_page',
'page',
]);
$shipments = $this->service->index($params);
return ApiResponse::success($shipments, __('message.fetched'));
}
/**
* 출하 통계 조회
*/
public function stats(): JsonResponse
{
$stats = $this->service->stats();
return ApiResponse::success($stats, __('message.fetched'));
}
/**
* 상태별 통계 조회 (탭용)
*/
public function statsByStatus(): JsonResponse
{
$stats = $this->service->statsByStatus();
return ApiResponse::success($stats, __('message.fetched'));
}
/**
* 출하 상세 조회
*/
public function show(int $id): JsonResponse
{
try {
$shipment = $this->service->show($id);
return ApiResponse::success($shipment, __('message.fetched'));
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
return ApiResponse::error(__('error.shipment.not_found'), 404);
}
}
/**
* 출하 생성
*/
public function store(ShipmentStoreRequest $request): JsonResponse
{
$shipment = $this->service->store($request->validated());
return ApiResponse::success($shipment, __('message.created'), 201);
}
/**
* 출하 수정
*/
public function update(ShipmentUpdateRequest $request, int $id): JsonResponse
{
try {
$shipment = $this->service->update($id, $request->validated());
return ApiResponse::success($shipment, __('message.updated'));
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
return ApiResponse::error(__('error.shipment.not_found'), 404);
}
}
/**
* 출하 상태 변경
*/
public function updateStatus(ShipmentUpdateStatusRequest $request, int $id): JsonResponse
{
try {
$shipment = $this->service->updateStatus(
$id,
$request->validated('status'),
$request->validated()
);
return ApiResponse::success($shipment, __('message.updated'));
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
return ApiResponse::error(__('error.shipment.not_found'), 404);
}
}
/**
* 출하 삭제
*/
public function destroy(int $id): JsonResponse
{
try {
$this->service->delete($id);
return ApiResponse::success(null, __('message.deleted'));
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
return ApiResponse::error(__('error.shipment.not_found'), 404);
}
}
/**
* LOT 옵션 조회
*/
public function lotOptions(): JsonResponse
{
$options = $this->service->getLotOptions();
return ApiResponse::success($options, __('message.fetched'));
}
/**
* 물류사 옵션 조회
*/
public function logisticsOptions(): JsonResponse
{
$options = $this->service->getLogisticsOptions();
return ApiResponse::success($options, __('message.fetched'));
}
/**
* 차량 톤수 옵션 조회
*/
public function vehicleTonnageOptions(): JsonResponse
{
$options = $this->service->getVehicleTonnageOptions();
return ApiResponse::success($options, __('message.fetched'));
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace App\Http\Requests\Shipment;
use Illuminate\Foundation\Http\FormRequest;
class ShipmentStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
// 기본 정보
'shipment_no' => 'nullable|string|max:50',
'lot_no' => 'nullable|string|max:50',
'order_id' => 'nullable|integer|exists:orders,id',
'scheduled_date' => 'required|date',
'status' => 'nullable|in:scheduled,ready,shipping,completed',
'priority' => 'nullable|in:urgent,normal,low',
'delivery_method' => 'nullable|in:pickup,direct,logistics',
// 발주처/배송 정보
'client_id' => 'nullable|integer|exists:clients,id',
'customer_name' => 'nullable|string|max:100',
'site_name' => 'nullable|string|max:100',
'delivery_address' => 'nullable|string|max:255',
'receiver' => 'nullable|string|max:50',
'receiver_contact' => 'nullable|string|max:50',
// 상태 플래그
'can_ship' => 'nullable|boolean',
'deposit_confirmed' => 'nullable|boolean',
'invoice_issued' => 'nullable|boolean',
'customer_grade' => 'nullable|string|max:20',
// 상차 정보
'loading_manager' => 'nullable|string|max:50',
'loading_time' => 'nullable|date',
// 물류/배차 정보
'logistics_company' => 'nullable|string|max:50',
'vehicle_tonnage' => 'nullable|string|max:20',
'shipping_cost' => 'nullable|numeric|min:0',
// 차량/운전자 정보
'vehicle_no' => 'nullable|string|max:20',
'driver_name' => 'nullable|string|max:50',
'driver_contact' => 'nullable|string|max:50',
'expected_arrival' => 'nullable|date',
// 기타
'remarks' => 'nullable|string',
// 출하 품목
'items' => 'nullable|array',
'items.*.seq' => 'nullable|integer|min:1',
'items.*.item_code' => 'nullable|string|max:50',
'items.*.item_name' => 'required|string|max:100',
'items.*.floor_unit' => 'nullable|string|max:50',
'items.*.specification' => 'nullable|string|max:100',
'items.*.quantity' => 'nullable|numeric|min:0',
'items.*.unit' => 'nullable|string|max:20',
'items.*.lot_no' => 'nullable|string|max:50',
'items.*.stock_lot_id' => 'nullable|integer|exists:stock_lots,id',
'items.*.remarks' => 'nullable|string',
];
}
public function messages(): array
{
return [
'scheduled_date.required' => __('validation.required', ['attribute' => '출고예정일']),
'items.*.item_name.required' => __('validation.required', ['attribute' => '품목명']),
];
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Http\Requests\Shipment;
use Illuminate\Foundation\Http\FormRequest;
class ShipmentUpdateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
// 기본 정보
'lot_no' => 'nullable|string|max:50',
'order_id' => 'nullable|integer|exists:orders,id',
'scheduled_date' => 'nullable|date',
'priority' => 'nullable|in:urgent,normal,low',
'delivery_method' => 'nullable|in:pickup,direct,logistics',
// 발주처/배송 정보
'client_id' => 'nullable|integer|exists:clients,id',
'customer_name' => 'nullable|string|max:100',
'site_name' => 'nullable|string|max:100',
'delivery_address' => 'nullable|string|max:255',
'receiver' => 'nullable|string|max:50',
'receiver_contact' => 'nullable|string|max:50',
// 상태 플래그
'can_ship' => 'nullable|boolean',
'deposit_confirmed' => 'nullable|boolean',
'invoice_issued' => 'nullable|boolean',
'customer_grade' => 'nullable|string|max:20',
// 상차 정보
'loading_manager' => 'nullable|string|max:50',
'loading_time' => 'nullable|date',
// 물류/배차 정보
'logistics_company' => 'nullable|string|max:50',
'vehicle_tonnage' => 'nullable|string|max:20',
'shipping_cost' => 'nullable|numeric|min:0',
// 차량/운전자 정보
'vehicle_no' => 'nullable|string|max:20',
'driver_name' => 'nullable|string|max:50',
'driver_contact' => 'nullable|string|max:50',
'expected_arrival' => 'nullable|date',
// 기타
'remarks' => 'nullable|string',
// 출하 품목
'items' => 'nullable|array',
'items.*.seq' => 'nullable|integer|min:1',
'items.*.item_code' => 'nullable|string|max:50',
'items.*.item_name' => 'required|string|max:100',
'items.*.floor_unit' => 'nullable|string|max:50',
'items.*.specification' => 'nullable|string|max:100',
'items.*.quantity' => 'nullable|numeric|min:0',
'items.*.unit' => 'nullable|string|max:20',
'items.*.lot_no' => 'nullable|string|max:50',
'items.*.stock_lot_id' => 'nullable|integer|exists:stock_lots,id',
'items.*.remarks' => 'nullable|string',
];
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Http\Requests\Shipment;
use Illuminate\Foundation\Http\FormRequest;
class ShipmentUpdateStatusRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'status' => 'required|in:scheduled,ready,shipping,completed',
// 상태별 추가 데이터
'loading_time' => 'nullable|date',
'loading_completed_at' => 'nullable|date',
'vehicle_no' => 'nullable|string|max:20',
'driver_name' => 'nullable|string|max:50',
'driver_contact' => 'nullable|string|max:50',
'confirmed_arrival' => 'nullable|date',
];
}
public function messages(): array
{
return [
'status.required' => __('validation.required', ['attribute' => '상태']),
'status.in' => __('validation.in', ['attribute' => '상태']),
];
}
}

View File

@@ -0,0 +1,210 @@
<?php
namespace App\Models\Tenants;
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;
class Shipment extends Model
{
use BelongsToTenant, SoftDeletes;
protected $fillable = [
'tenant_id',
'shipment_no',
'lot_no',
'order_id',
'scheduled_date',
'status',
'priority',
'delivery_method',
// 발주처/배송 정보
'client_id',
'customer_name',
'site_name',
'delivery_address',
'receiver',
'receiver_contact',
// 상태 플래그
'can_ship',
'deposit_confirmed',
'invoice_issued',
'customer_grade',
// 상차 정보
'loading_manager',
'loading_completed_at',
'loading_time',
// 물류/배차 정보
'logistics_company',
'vehicle_tonnage',
'shipping_cost',
// 차량/운전자 정보
'vehicle_no',
'driver_name',
'driver_contact',
'expected_arrival',
'confirmed_arrival',
// 기타
'remarks',
'created_by',
'updated_by',
'deleted_by',
];
protected $casts = [
'scheduled_date' => 'date',
'can_ship' => 'boolean',
'deposit_confirmed' => 'boolean',
'invoice_issued' => 'boolean',
'loading_completed_at' => 'datetime',
'loading_time' => 'datetime',
'expected_arrival' => 'datetime',
'confirmed_arrival' => 'datetime',
'shipping_cost' => 'decimal:0',
'order_id' => 'integer',
'client_id' => 'integer',
];
/**
* 출하 상태 목록
*/
public const STATUSES = [
'scheduled' => '출고예정',
'ready' => '출하대기',
'shipping' => '배송중',
'completed' => '배송완료',
];
/**
* 우선순위 목록
*/
public const PRIORITIES = [
'urgent' => '긴급',
'normal' => '보통',
'low' => '낮음',
];
/**
* 배송방식 목록
*/
public const DELIVERY_METHODS = [
'pickup' => '상차',
'direct' => '직접배차',
'logistics' => '물류사',
];
/**
* 출하 품목 관계
*/
public function items(): HasMany
{
return $this->hasMany(ShipmentItem::class)->orderBy('seq');
}
/**
* 거래처 관계
*/
public function client(): BelongsTo
{
return $this->belongsTo(\App\Models\Clients\Client::class);
}
/**
* 생성자 관계
*/
public function creator(): BelongsTo
{
return $this->belongsTo(\App\Models\Members\User::class, 'created_by');
}
/**
* 수정자 관계
*/
public function updater(): BelongsTo
{
return $this->belongsTo(\App\Models\Members\User::class, 'updated_by');
}
/**
* 상태 라벨
*/
public function getStatusLabelAttribute(): string
{
return self::STATUSES[$this->status] ?? $this->status;
}
/**
* 우선순위 라벨
*/
public function getPriorityLabelAttribute(): string
{
return self::PRIORITIES[$this->priority] ?? $this->priority;
}
/**
* 배송방식 라벨
*/
public function getDeliveryMethodLabelAttribute(): string
{
return self::DELIVERY_METHODS[$this->delivery_method] ?? $this->delivery_method;
}
/**
* 총 품목 수량
*/
public function getTotalQuantityAttribute(): float
{
return $this->items->sum('quantity');
}
/**
* 품목 수
*/
public function getItemCountAttribute(): int
{
return $this->items->count();
}
/**
* 긴급 여부
*/
public function getIsUrgentAttribute(): bool
{
return $this->priority === 'urgent';
}
/**
* 출하 가능 여부 확인
*/
public function canProceedToShip(): bool
{
return $this->can_ship && $this->deposit_confirmed;
}
/**
* 새 출하번호 생성
*/
public static function generateShipmentNo(int $tenantId): string
{
$today = now()->format('Ymd');
$prefix = 'SHP-'.$today.'-';
$lastShipment = static::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->where('shipment_no', 'like', $prefix.'%')
->orderByDesc('shipment_no')
->first();
if ($lastShipment) {
$lastSeq = (int) substr($lastShipment->shipment_no, -4);
$newSeq = str_pad($lastSeq + 1, 4, '0', STR_PAD_LEFT);
} else {
$newSeq = '0001';
}
return $prefix.$newSeq;
}
}

View File

@@ -0,0 +1,61 @@
<?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 ShipmentItem extends Model
{
use BelongsToTenant, SoftDeletes;
protected $fillable = [
'tenant_id',
'shipment_id',
'seq',
'item_code',
'item_name',
'floor_unit',
'specification',
'quantity',
'unit',
'lot_no',
'stock_lot_id',
'remarks',
];
protected $casts = [
'seq' => 'integer',
'quantity' => 'decimal:2',
'shipment_id' => 'integer',
'stock_lot_id' => 'integer',
];
/**
* 출하 관계
*/
public function shipment(): BelongsTo
{
return $this->belongsTo(Shipment::class);
}
/**
* 재고 LOT 관계
*/
public function stockLot(): BelongsTo
{
return $this->belongsTo(StockLot::class);
}
/**
* 다음 순번 가져오기
*/
public static function getNextSeq(int $shipmentId): int
{
$maxSeq = static::where('shipment_id', $shipmentId)->max('seq');
return ($maxSeq ?? 0) + 1;
}
}

View File

@@ -0,0 +1,436 @@
<?php
namespace App\Services;
use App\Models\Tenants\Shipment;
use App\Models\Tenants\ShipmentItem;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
class ShipmentService extends Service
{
/**
* 출하 목록 조회
*/
public function index(array $params): LengthAwarePaginator
{
$tenantId = $this->tenantId();
$query = Shipment::query()
->where('tenant_id', $tenantId)
->with('items');
// 검색어 필터
if (! empty($params['search'])) {
$search = $params['search'];
$query->where(function ($q) use ($search) {
$q->where('shipment_no', 'like', "%{$search}%")
->orWhere('lot_no', 'like', "%{$search}%")
->orWhere('customer_name', 'like', "%{$search}%")
->orWhere('site_name', 'like', "%{$search}%");
});
}
// 상태 필터
if (! empty($params['status'])) {
$query->where('status', $params['status']);
}
// 우선순위 필터
if (! empty($params['priority'])) {
$query->where('priority', $params['priority']);
}
// 배송방식 필터
if (! empty($params['delivery_method'])) {
$query->where('delivery_method', $params['delivery_method']);
}
// 예정일 범위 필터
if (! empty($params['scheduled_from'])) {
$query->where('scheduled_date', '>=', $params['scheduled_from']);
}
if (! empty($params['scheduled_to'])) {
$query->where('scheduled_date', '<=', $params['scheduled_to']);
}
// 출하가능 필터
if (isset($params['can_ship'])) {
$query->where('can_ship', (bool) $params['can_ship']);
}
// 입금확인 필터
if (isset($params['deposit_confirmed'])) {
$query->where('deposit_confirmed', (bool) $params['deposit_confirmed']);
}
// 정렬
$sortBy = $params['sort_by'] ?? 'scheduled_date';
$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();
$total = Shipment::where('tenant_id', $tenantId)->count();
$scheduled = Shipment::where('tenant_id', $tenantId)
->where('status', 'scheduled')
->count();
$ready = Shipment::where('tenant_id', $tenantId)
->where('status', 'ready')
->count();
$shipping = Shipment::where('tenant_id', $tenantId)
->where('status', 'shipping')
->count();
$completed = Shipment::where('tenant_id', $tenantId)
->where('status', 'completed')
->count();
$urgent = Shipment::where('tenant_id', $tenantId)
->where('priority', 'urgent')
->whereIn('status', ['scheduled', 'ready'])
->count();
$todayScheduled = Shipment::where('tenant_id', $tenantId)
->whereDate('scheduled_date', now())
->count();
return [
'total' => $total,
'scheduled' => $scheduled,
'ready' => $ready,
'shipping' => $shipping,
'completed' => $completed,
'urgent' => $urgent,
'today_scheduled' => $todayScheduled,
];
}
/**
* 상태별 통계 (탭용)
*/
public function statsByStatus(): array
{
$tenantId = $this->tenantId();
$stats = Shipment::where('tenant_id', $tenantId)
->selectRaw('status, COUNT(*) as count')
->groupBy('status')
->get()
->keyBy('status');
$result = [];
foreach (Shipment::STATUSES as $key => $label) {
$data = $stats->get($key);
$result[$key] = [
'label' => $label,
'count' => $data?->count ?? 0,
];
}
return $result;
}
/**
* 출하 상세 조회
*/
public function show(int $id): Shipment
{
$tenantId = $this->tenantId();
return Shipment::query()
->where('tenant_id', $tenantId)
->with(['items' => function ($query) {
$query->orderBy('seq');
}, 'creator', 'updater'])
->findOrFail($id);
}
/**
* 출하 생성
*/
public function store(array $data): Shipment
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
return DB::transaction(function () use ($data, $tenantId, $userId) {
// 출하번호 자동 생성
$shipmentNo = $data['shipment_no'] ?? Shipment::generateShipmentNo($tenantId);
$shipment = Shipment::create([
'tenant_id' => $tenantId,
'shipment_no' => $shipmentNo,
'lot_no' => $data['lot_no'] ?? null,
'order_id' => $data['order_id'] ?? null,
'scheduled_date' => $data['scheduled_date'],
'status' => $data['status'] ?? 'scheduled',
'priority' => $data['priority'] ?? 'normal',
'delivery_method' => $data['delivery_method'] ?? 'pickup',
// 발주처/배송 정보
'client_id' => $data['client_id'] ?? null,
'customer_name' => $data['customer_name'] ?? null,
'site_name' => $data['site_name'] ?? null,
'delivery_address' => $data['delivery_address'] ?? null,
'receiver' => $data['receiver'] ?? null,
'receiver_contact' => $data['receiver_contact'] ?? null,
// 상태 플래그
'can_ship' => $data['can_ship'] ?? false,
'deposit_confirmed' => $data['deposit_confirmed'] ?? false,
'invoice_issued' => $data['invoice_issued'] ?? false,
'customer_grade' => $data['customer_grade'] ?? null,
// 상차 정보
'loading_manager' => $data['loading_manager'] ?? null,
'loading_time' => $data['loading_time'] ?? null,
// 물류/배차 정보
'logistics_company' => $data['logistics_company'] ?? null,
'vehicle_tonnage' => $data['vehicle_tonnage'] ?? null,
'shipping_cost' => $data['shipping_cost'] ?? null,
// 차량/운전자 정보
'vehicle_no' => $data['vehicle_no'] ?? null,
'driver_name' => $data['driver_name'] ?? null,
'driver_contact' => $data['driver_contact'] ?? null,
'expected_arrival' => $data['expected_arrival'] ?? null,
// 기타
'remarks' => $data['remarks'] ?? null,
'created_by' => $userId,
]);
// 출하 품목 추가
if (! empty($data['items'])) {
$this->syncItems($shipment, $data['items'], $tenantId);
}
return $shipment->load('items');
});
}
/**
* 출하 수정
*/
public function update(int $id, array $data): Shipment
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
return DB::transaction(function () use ($id, $data, $tenantId, $userId) {
$shipment = Shipment::where('tenant_id', $tenantId)->findOrFail($id);
$shipment->update([
'lot_no' => $data['lot_no'] ?? $shipment->lot_no,
'order_id' => $data['order_id'] ?? $shipment->order_id,
'scheduled_date' => $data['scheduled_date'] ?? $shipment->scheduled_date,
'priority' => $data['priority'] ?? $shipment->priority,
'delivery_method' => $data['delivery_method'] ?? $shipment->delivery_method,
// 발주처/배송 정보
'client_id' => $data['client_id'] ?? $shipment->client_id,
'customer_name' => $data['customer_name'] ?? $shipment->customer_name,
'site_name' => $data['site_name'] ?? $shipment->site_name,
'delivery_address' => $data['delivery_address'] ?? $shipment->delivery_address,
'receiver' => $data['receiver'] ?? $shipment->receiver,
'receiver_contact' => $data['receiver_contact'] ?? $shipment->receiver_contact,
// 상태 플래그
'can_ship' => $data['can_ship'] ?? $shipment->can_ship,
'deposit_confirmed' => $data['deposit_confirmed'] ?? $shipment->deposit_confirmed,
'invoice_issued' => $data['invoice_issued'] ?? $shipment->invoice_issued,
'customer_grade' => $data['customer_grade'] ?? $shipment->customer_grade,
// 상차 정보
'loading_manager' => $data['loading_manager'] ?? $shipment->loading_manager,
'loading_time' => $data['loading_time'] ?? $shipment->loading_time,
// 물류/배차 정보
'logistics_company' => $data['logistics_company'] ?? $shipment->logistics_company,
'vehicle_tonnage' => $data['vehicle_tonnage'] ?? $shipment->vehicle_tonnage,
'shipping_cost' => $data['shipping_cost'] ?? $shipment->shipping_cost,
// 차량/운전자 정보
'vehicle_no' => $data['vehicle_no'] ?? $shipment->vehicle_no,
'driver_name' => $data['driver_name'] ?? $shipment->driver_name,
'driver_contact' => $data['driver_contact'] ?? $shipment->driver_contact,
'expected_arrival' => $data['expected_arrival'] ?? $shipment->expected_arrival,
// 기타
'remarks' => $data['remarks'] ?? $shipment->remarks,
'updated_by' => $userId,
]);
// 출하 품목 동기화
if (isset($data['items'])) {
$this->syncItems($shipment, $data['items'], $tenantId);
}
return $shipment->load('items');
});
}
/**
* 출하 상태 변경
*/
public function updateStatus(int $id, string $status, ?array $additionalData = null): Shipment
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$shipment = Shipment::where('tenant_id', $tenantId)->findOrFail($id);
$updateData = [
'status' => $status,
'updated_by' => $userId,
];
// 상태별 추가 데이터
if ($status === 'ready' && isset($additionalData['loading_time'])) {
$updateData['loading_time'] = $additionalData['loading_time'];
}
if ($status === 'shipping') {
if (isset($additionalData['loading_completed_at'])) {
$updateData['loading_completed_at'] = $additionalData['loading_completed_at'];
} else {
$updateData['loading_completed_at'] = now();
}
if (isset($additionalData['vehicle_no'])) {
$updateData['vehicle_no'] = $additionalData['vehicle_no'];
}
if (isset($additionalData['driver_name'])) {
$updateData['driver_name'] = $additionalData['driver_name'];
}
if (isset($additionalData['driver_contact'])) {
$updateData['driver_contact'] = $additionalData['driver_contact'];
}
}
if ($status === 'completed' && isset($additionalData['confirmed_arrival'])) {
$updateData['confirmed_arrival'] = $additionalData['confirmed_arrival'];
}
$shipment->update($updateData);
return $shipment->load('items');
}
/**
* 출하 삭제
*/
public function delete(int $id): bool
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$shipment = Shipment::where('tenant_id', $tenantId)->findOrFail($id);
return DB::transaction(function () use ($shipment, $userId) {
// 품목 삭제
$shipment->items()->delete();
// 출하 삭제
$shipment->update(['deleted_by' => $userId]);
$shipment->delete();
return true;
});
}
/**
* 출하 품목 동기화
*/
protected function syncItems(Shipment $shipment, array $items, int $tenantId): void
{
// 기존 품목 삭제
$shipment->items()->forceDelete();
// 새 품목 생성
$seq = 1;
foreach ($items as $item) {
ShipmentItem::create([
'tenant_id' => $tenantId,
'shipment_id' => $shipment->id,
'seq' => $item['seq'] ?? $seq,
'item_code' => $item['item_code'] ?? null,
'item_name' => $item['item_name'],
'floor_unit' => $item['floor_unit'] ?? null,
'specification' => $item['specification'] ?? null,
'quantity' => $item['quantity'] ?? 0,
'unit' => $item['unit'] ?? null,
'lot_no' => $item['lot_no'] ?? null,
'stock_lot_id' => $item['stock_lot_id'] ?? null,
'remarks' => $item['remarks'] ?? null,
]);
$seq++;
}
}
/**
* LOT 옵션 조회 (출고 가능한 LOT 목록)
*/
public function getLotOptions(): array
{
$tenantId = $this->tenantId();
return \App\Models\Tenants\StockLot::query()
->where('tenant_id', $tenantId)
->whereIn('status', ['available', 'reserved'])
->where('qty', '>', 0)
->with('stock:id,item_code,item_name')
->orderBy('fifo_order')
->get()
->map(function ($lot) {
return [
'id' => $lot->id,
'lot_no' => $lot->lot_no,
'item_code' => $lot->stock?->item_code,
'item_name' => $lot->stock?->item_name,
'qty' => $lot->qty,
'available_qty' => $lot->available_qty,
'unit' => $lot->unit,
'location' => $lot->location,
'fifo_order' => $lot->fifo_order,
];
})
->toArray();
}
/**
* 물류사 옵션 조회
*/
public function getLogisticsOptions(): array
{
// TODO: 별도 물류사 테이블이 있다면 조회
// 현재는 기본값 반환
return [
['value' => '한진택배', 'label' => '한진택배'],
['value' => '롯데택배', 'label' => '롯데택배'],
['value' => 'CJ대한통운', 'label' => 'CJ대한통운'],
['value' => '우체국택배', 'label' => '우체국택배'],
['value' => '로젠택배', 'label' => '로젠택배'],
['value' => '경동택배', 'label' => '경동택배'],
['value' => '직접입력', 'label' => '직접입력'],
];
}
/**
* 차량 톤수 옵션 조회
*/
public function getVehicleTonnageOptions(): array
{
return [
['value' => '1톤', 'label' => '1톤'],
['value' => '2.5톤', 'label' => '2.5톤'],
['value' => '5톤', 'label' => '5톤'],
['value' => '11톤', 'label' => '11톤'],
['value' => '25톤', 'label' => '25톤'],
];
}
}

View File

@@ -0,0 +1,625 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(name="Shipments", description="출하 관리")
*
* @OA\Schema(
* schema="Shipment",
* 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="shipment_no", type="string", example="SHP-20251226-0001", description="출하번호"),
* @OA\Property(property="lot_no", type="string", example="LOT-001", nullable=true, description="LOT번호"),
* @OA\Property(property="order_id", type="integer", nullable=true, description="수주 ID"),
* @OA\Property(property="scheduled_date", type="string", format="date", example="2025-12-26", description="출고예정일"),
* @OA\Property(property="status", type="string", enum={"scheduled","ready","shipping","completed"}, example="scheduled", description="상태"),
* @OA\Property(property="status_label", type="string", example="출고예정", description="상태 라벨"),
* @OA\Property(property="priority", type="string", enum={"urgent","normal","low"}, example="normal", description="우선순위"),
* @OA\Property(property="priority_label", type="string", example="보통", description="우선순위 라벨"),
* @OA\Property(property="delivery_method", type="string", enum={"pickup","direct","logistics"}, example="pickup", description="배송방식"),
* @OA\Property(property="delivery_method_label", type="string", example="상차", description="배송방식 라벨"),
* @OA\Property(property="client_id", type="integer", nullable=true, description="거래처 ID"),
* @OA\Property(property="customer_name", type="string", example="(주)고객사", nullable=true, description="발주처명"),
* @OA\Property(property="site_name", type="string", example="서울현장", nullable=true, description="현장명"),
* @OA\Property(property="delivery_address", type="string", example="서울시 강남구", nullable=true, description="배송주소"),
* @OA\Property(property="receiver", type="string", example="홍길동", nullable=true, description="인수자"),
* @OA\Property(property="receiver_contact", type="string", example="010-1234-5678", nullable=true, description="인수자 연락처"),
* @OA\Property(property="can_ship", type="boolean", example=true, description="출하가능 여부"),
* @OA\Property(property="deposit_confirmed", type="boolean", example=true, description="입금확인 여부"),
* @OA\Property(property="invoice_issued", type="boolean", example=false, description="세금계산서 발행 여부"),
* @OA\Property(property="customer_grade", type="string", example="A", nullable=true, description="거래처 등급"),
* @OA\Property(property="loading_manager", type="string", example="김상차", nullable=true, description="상차담당자"),
* @OA\Property(property="loading_completed_at", type="string", format="date-time", nullable=true, description="상차완료 일시"),
* @OA\Property(property="loading_time", type="string", format="date-time", nullable=true, description="상차시간(입차예정)"),
* @OA\Property(property="logistics_company", type="string", example="CJ대한통운", nullable=true, description="물류사"),
* @OA\Property(property="vehicle_tonnage", type="string", example="5톤", nullable=true, description="차량 톤수"),
* @OA\Property(property="shipping_cost", type="number", example=150000, nullable=true, description="운송비"),
* @OA\Property(property="vehicle_no", type="string", example="12가1234", nullable=true, description="차량번호"),
* @OA\Property(property="driver_name", type="string", example="이운전", nullable=true, description="운전자명"),
* @OA\Property(property="driver_contact", type="string", example="010-9876-5432", nullable=true, description="운전자 연락처"),
* @OA\Property(property="expected_arrival", type="string", format="date-time", nullable=true, description="입차예정시간"),
* @OA\Property(property="confirmed_arrival", type="string", format="date-time", nullable=true, description="입차확정시간"),
* @OA\Property(property="remarks", type="string", nullable=true, description="비고"),
* @OA\Property(property="is_urgent", type="boolean", example=false, description="긴급 여부"),
* @OA\Property(property="item_count", type="integer", example=3, description="품목 수"),
* @OA\Property(property="total_quantity", type="number", format="float", example=150.5, description="총 수량"),
* @OA\Property(property="created_at", type="string", format="date-time"),
* @OA\Property(property="updated_at", type="string", format="date-time")
* )
*
* @OA\Schema(
* schema="ShipmentItem",
* type="object",
* description="출하 품목 정보",
*
* @OA\Property(property="id", type="integer", example=1, description="품목 ID"),
* @OA\Property(property="shipment_id", type="integer", example=1, description="출하 ID"),
* @OA\Property(property="seq", type="integer", example=1, description="순번"),
* @OA\Property(property="item_code", type="string", example="ITEM-001", nullable=true, description="품목코드"),
* @OA\Property(property="item_name", type="string", example="제품 A", description="품목명"),
* @OA\Property(property="floor_unit", type="string", example="1층/M01", nullable=true, description="층/M호"),
* @OA\Property(property="specification", type="string", example="100x200mm", nullable=true, description="규격"),
* @OA\Property(property="quantity", type="number", format="float", example=10.5, description="수량"),
* @OA\Property(property="unit", type="string", example="EA", nullable=true, description="단위"),
* @OA\Property(property="lot_no", type="string", example="LOT-001", nullable=true, description="LOT번호"),
* @OA\Property(property="stock_lot_id", type="integer", nullable=true, description="재고 LOT ID"),
* @OA\Property(property="remarks", type="string", nullable=true, description="비고"),
* @OA\Property(property="created_at", type="string", format="date-time"),
* @OA\Property(property="updated_at", type="string", format="date-time")
* )
*
* @OA\Schema(
* schema="ShipmentWithItems",
* type="object",
* description="출하 상세 정보 (품목 포함)",
* allOf={
*
* @OA\Schema(ref="#/components/schemas/Shipment"),
* @OA\Schema(
*
* @OA\Property(
* property="items",
* type="array",
* description="출하 품목 목록",
*
* @OA\Items(ref="#/components/schemas/ShipmentItem")
* )
* )
* }
* )
*
* @OA\Schema(
* schema="ShipmentStats",
* type="object",
* description="출하 통계",
*
* @OA\Property(property="total", type="integer", example=100, description="전체 건수"),
* @OA\Property(property="scheduled", type="integer", example=30, description="출고예정 건수"),
* @OA\Property(property="ready", type="integer", example=20, description="출하대기 건수"),
* @OA\Property(property="shipping", type="integer", example=15, description="배송중 건수"),
* @OA\Property(property="completed", type="integer", example=35, description="배송완료 건수"),
* @OA\Property(property="urgent", type="integer", example=5, description="긴급 건수"),
* @OA\Property(property="today_scheduled", type="integer", example=10, description="오늘 출고예정 건수")
* )
*
* @OA\Schema(
* schema="ShipmentStatsByStatus",
* type="object",
* description="상태별 출하 통계",
*
* @OA\Property(
* property="scheduled",
* type="object",
*
* @OA\Property(property="label", type="string", example="출고예정"),
* @OA\Property(property="count", type="integer", example=30)
* ),
*
* @OA\Property(
* property="ready",
* type="object",
*
* @OA\Property(property="label", type="string", example="출하대기"),
* @OA\Property(property="count", type="integer", example=20)
* ),
*
* @OA\Property(
* property="shipping",
* type="object",
*
* @OA\Property(property="label", type="string", example="배송중"),
* @OA\Property(property="count", type="integer", example=15)
* ),
*
* @OA\Property(
* property="completed",
* type="object",
*
* @OA\Property(property="label", type="string", example="배송완료"),
* @OA\Property(property="count", type="integer", example=35)
* )
* )
*
* @OA\Schema(
* schema="ShipmentStoreRequest",
* type="object",
* required={"scheduled_date"},
*
* @OA\Property(property="shipment_no", type="string", example="SHP-20251226-0001", description="출하번호 (자동 생성 가능)"),
* @OA\Property(property="lot_no", type="string", example="LOT-001", description="LOT번호"),
* @OA\Property(property="order_id", type="integer", example=1, description="수주 ID"),
* @OA\Property(property="scheduled_date", type="string", format="date", example="2025-12-26", description="출고예정일"),
* @OA\Property(property="status", type="string", enum={"scheduled","ready","shipping","completed"}, example="scheduled", description="상태"),
* @OA\Property(property="priority", type="string", enum={"urgent","normal","low"}, example="normal", description="우선순위"),
* @OA\Property(property="delivery_method", type="string", enum={"pickup","direct","logistics"}, example="pickup", description="배송방식"),
* @OA\Property(property="client_id", type="integer", description="거래처 ID"),
* @OA\Property(property="customer_name", type="string", example="(주)고객사", description="발주처명"),
* @OA\Property(property="site_name", type="string", example="서울현장", description="현장명"),
* @OA\Property(property="delivery_address", type="string", example="서울시 강남구", description="배송주소"),
* @OA\Property(property="receiver", type="string", example="홍길동", description="인수자"),
* @OA\Property(property="receiver_contact", type="string", example="010-1234-5678", description="인수자 연락처"),
* @OA\Property(property="can_ship", type="boolean", example=true, description="출하가능 여부"),
* @OA\Property(property="deposit_confirmed", type="boolean", example=true, description="입금확인 여부"),
* @OA\Property(property="invoice_issued", type="boolean", example=false, description="세금계산서 발행 여부"),
* @OA\Property(property="loading_manager", type="string", description="상차담당자"),
* @OA\Property(property="loading_time", type="string", format="date-time", description="상차시간"),
* @OA\Property(property="logistics_company", type="string", description="물류사"),
* @OA\Property(property="vehicle_tonnage", type="string", description="차량 톤수"),
* @OA\Property(property="shipping_cost", type="number", description="운송비"),
* @OA\Property(property="vehicle_no", type="string", description="차량번호"),
* @OA\Property(property="driver_name", type="string", description="운전자명"),
* @OA\Property(property="driver_contact", type="string", description="운전자 연락처"),
* @OA\Property(property="expected_arrival", type="string", format="date-time", description="입차예정시간"),
* @OA\Property(property="remarks", type="string", description="비고"),
* @OA\Property(
* property="items",
* type="array",
* description="출하 품목",
*
* @OA\Items(
* type="object",
*
* @OA\Property(property="seq", type="integer", example=1),
* @OA\Property(property="item_code", type="string", example="ITEM-001"),
* @OA\Property(property="item_name", type="string", example="제품 A"),
* @OA\Property(property="floor_unit", type="string", example="1층/M01"),
* @OA\Property(property="specification", type="string", example="100x200mm"),
* @OA\Property(property="quantity", type="number", example=10.5),
* @OA\Property(property="unit", type="string", example="EA"),
* @OA\Property(property="lot_no", type="string", example="LOT-001"),
* @OA\Property(property="stock_lot_id", type="integer"),
* @OA\Property(property="remarks", type="string")
* )
* )
* )
*
* @OA\Schema(
* schema="ShipmentUpdateStatusRequest",
* type="object",
* required={"status"},
*
* @OA\Property(property="status", type="string", enum={"scheduled","ready","shipping","completed"}, example="shipping", description="변경할 상태"),
* @OA\Property(property="loading_time", type="string", format="date-time", description="상차시간 (ready 상태 시)"),
* @OA\Property(property="loading_completed_at", type="string", format="date-time", description="상차완료 일시 (shipping 상태 시)"),
* @OA\Property(property="vehicle_no", type="string", description="차량번호 (shipping 상태 시)"),
* @OA\Property(property="driver_name", type="string", description="운전자명 (shipping 상태 시)"),
* @OA\Property(property="driver_contact", type="string", description="운전자 연락처 (shipping 상태 시)"),
* @OA\Property(property="confirmed_arrival", type="string", format="date-time", description="입차확정시간 (completed 상태 시)")
* )
*
* @OA\Schema(
* schema="LotOption",
* type="object",
* description="LOT 옵션",
*
* @OA\Property(property="id", type="integer", example=1, description="LOT ID"),
* @OA\Property(property="lot_no", type="string", example="251226-01", description="LOT번호"),
* @OA\Property(property="item_code", type="string", example="ITEM-001", description="품목코드"),
* @OA\Property(property="item_name", type="string", example="원재료 A", description="품목명"),
* @OA\Property(property="qty", type="number", format="float", example=100.0, description="수량"),
* @OA\Property(property="available_qty", type="number", format="float", example=80.0, description="가용수량"),
* @OA\Property(property="unit", type="string", example="EA", description="단위"),
* @OA\Property(property="location", type="string", example="A-01-01", description="위치"),
* @OA\Property(property="fifo_order", type="integer", example=1, description="FIFO 순서")
* )
*/
class ShipmentApi
{
/**
* @OA\Get(
* path="/api/v1/shipments",
* tags={"Shipments"},
* summary="출하 목록 조회",
* description="출하 목록을 조회합니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="search", in="query", description="검색어 (출하번호, LOT번호, 발주처명, 현장명)", @OA\Schema(type="string")),
* @OA\Parameter(name="status", in="query", description="상태", @OA\Schema(type="string", enum={"scheduled","ready","shipping","completed"})),
* @OA\Parameter(name="priority", in="query", description="우선순위", @OA\Schema(type="string", enum={"urgent","normal","low"})),
* @OA\Parameter(name="delivery_method", in="query", description="배송방식", @OA\Schema(type="string", enum={"pickup","direct","logistics"})),
* @OA\Parameter(name="scheduled_from", in="query", description="예정일 시작", @OA\Schema(type="string", format="date")),
* @OA\Parameter(name="scheduled_to", in="query", description="예정일 종료", @OA\Schema(type="string", format="date")),
* @OA\Parameter(name="can_ship", in="query", description="출하가능 여부", @OA\Schema(type="boolean")),
* @OA\Parameter(name="deposit_confirmed", in="query", description="입금확인 여부", @OA\Schema(type="boolean")),
* @OA\Parameter(name="sort_by", in="query", description="정렬 기준", @OA\Schema(type="string", enum={"scheduled_date","shipment_no","customer_name","priority"}, default="scheduled_date")),
* @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/ShipmentWithItems")),
* @OA\Property(property="per_page", type="integer", example=20),
* @OA\Property(property="total", type="integer", example=100)
* )
* )
* }
* )
* ),
*
* @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/shipments/stats",
* tags={"Shipments"},
* 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/ShipmentStats")
* )
* }
* )
* ),
*
* @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\Get(
* path="/api/v1/shipments/stats-by-status",
* tags={"Shipments"},
* 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/ShipmentStatsByStatus")
* )
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function statsByStatus() {}
/**
* @OA\Get(
* path="/api/v1/shipments/{id}",
* tags={"Shipments"},
* 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/ShipmentWithItems")
* )
* }
* )
* ),
*
* @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\Post(
* path="/api/v1/shipments",
* tags={"Shipments"},
* summary="출하 생성",
* description="새 출하를 생성합니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\RequestBody(
* required=true,
*
* @OA\JsonContent(ref="#/components/schemas/ShipmentStoreRequest")
* ),
*
* @OA\Response(
* response=201,
* description="생성 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(property="data", ref="#/components/schemas/ShipmentWithItems")
* )
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=422, description="유효성 검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function store() {}
/**
* @OA\Put(
* path="/api/v1/shipments/{id}",
* tags={"Shipments"},
* summary="출하 수정",
* description="출하 정보를 수정합니다.",
* 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/ShipmentStoreRequest")
* ),
*
* @OA\Response(
* response=200,
* description="수정 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(property="data", ref="#/components/schemas/ShipmentWithItems")
* )
* }
* )
* ),
*
* @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=422, description="유효성 검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function update() {}
/**
* @OA\Patch(
* path="/api/v1/shipments/{id}/status",
* tags={"Shipments"},
* summary="출하 상태 변경",
* description="출하 상태를 변경합니다.",
* 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/ShipmentUpdateStatusRequest")
* ),
*
* @OA\Response(
* response=200,
* description="상태 변경 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(property="data", ref="#/components/schemas/ShipmentWithItems")
* )
* }
* )
* ),
*
* @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=422, description="유효성 검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function updateStatus() {}
/**
* @OA\Delete(
* path="/api/v1/shipments/{id}",
* tags={"Shipments"},
* 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(ref="#/components/schemas/ApiResponse")
* ),
*
* @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\Get(
* path="/api/v1/shipments/options/lots",
* tags={"Shipments"},
* summary="LOT 옵션 조회",
* description="출고 가능한 LOT 목록을 조회합니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Response(
* response=200,
* description="조회 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/LotOption"))
* )
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function lotOptions() {}
/**
* @OA\Get(
* path="/api/v1/shipments/options/logistics",
* tags={"Shipments"},
* 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",
* type="array",
*
* @OA\Items(
* type="object",
*
* @OA\Property(property="value", type="string", example="CJ대한통운"),
* @OA\Property(property="label", type="string", example="CJ대한통운")
* )
* )
* )
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function logisticsOptions() {}
/**
* @OA\Get(
* path="/api/v1/shipments/options/vehicle-tonnage",
* tags={"Shipments"},
* 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",
* type="array",
*
* @OA\Items(
* type="object",
*
* @OA\Property(property="value", type="string", example="5톤"),
* @OA\Property(property="label", type="string", example="5톤")
* )
* )
* )
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function vehicleTonnageOptions() {}
}

View File

@@ -0,0 +1,79 @@
<?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('shipments', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->comment('테넌트 ID');
$table->string('shipment_no', 50)->comment('출고번호');
$table->string('lot_no', 50)->nullable()->comment('LOT번호');
$table->foreignId('order_id')->nullable()->comment('수주 ID');
$table->date('scheduled_date')->comment('출고예정일');
$table->enum('status', ['scheduled', 'ready', 'shipping', 'completed'])->default('scheduled')->comment('상태: scheduled=출고예정, ready=출하대기, shipping=배송중, completed=배송완료');
$table->enum('priority', ['urgent', 'normal', 'low'])->default('normal')->comment('우선순위');
$table->enum('delivery_method', ['pickup', 'direct', 'logistics'])->default('pickup')->comment('배송방식: pickup=상차, direct=직접배차, logistics=물류사');
// 발주처/배송 정보
$table->foreignId('client_id')->nullable()->comment('거래처 ID');
$table->string('customer_name', 100)->nullable()->comment('발주처명');
$table->string('site_name', 100)->nullable()->comment('현장명');
$table->string('delivery_address', 255)->nullable()->comment('배송주소');
$table->string('receiver', 50)->nullable()->comment('인수자');
$table->string('receiver_contact', 50)->nullable()->comment('인수자 연락처');
// 상태 플래그
$table->boolean('can_ship')->default(false)->comment('출하가능 여부');
$table->boolean('deposit_confirmed')->default(false)->comment('입금확인 여부');
$table->boolean('invoice_issued')->default(false)->comment('세금계산서 발행 여부');
$table->string('customer_grade', 20)->nullable()->comment('거래처 등급');
// 상차 정보
$table->string('loading_manager', 50)->nullable()->comment('상차담당자');
$table->datetime('loading_completed_at')->nullable()->comment('상차완료 일시');
$table->datetime('loading_time')->nullable()->comment('상차시간(입차예정)');
// 물류/배차 정보
$table->string('logistics_company', 50)->nullable()->comment('물류사');
$table->string('vehicle_tonnage', 20)->nullable()->comment('차량 톤수');
$table->decimal('shipping_cost', 12, 0)->nullable()->comment('운송비');
// 차량/운전자 정보
$table->string('vehicle_no', 20)->nullable()->comment('차량번호');
$table->string('driver_name', 50)->nullable()->comment('운전자명');
$table->string('driver_contact', 50)->nullable()->comment('운전자 연락처');
$table->datetime('expected_arrival')->nullable()->comment('입차예정시간');
$table->datetime('confirmed_arrival')->nullable()->comment('입차확정시간');
// 기타
$table->text('remarks')->nullable()->comment('비고');
$table->foreignId('created_by')->nullable()->comment('등록자');
$table->foreignId('updated_by')->nullable()->comment('수정자');
$table->foreignId('deleted_by')->nullable()->comment('삭제자');
$table->timestamps();
$table->softDeletes();
// 인덱스
$table->unique(['tenant_id', 'shipment_no']);
$table->index(['tenant_id', 'status']);
$table->index(['tenant_id', 'scheduled_date']);
$table->index(['tenant_id', 'lot_no']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('shipments');
}
};

View File

@@ -0,0 +1,47 @@
<?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('shipment_items', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->comment('테넌트 ID');
$table->foreignId('shipment_id')->comment('출하 ID');
$table->integer('seq')->default(1)->comment('순번');
$table->string('item_code', 50)->nullable()->comment('품목코드');
$table->string('item_name', 100)->comment('품목명');
$table->string('floor_unit', 50)->nullable()->comment('층/M호');
$table->string('specification', 100)->nullable()->comment('규격');
$table->decimal('quantity', 10, 2)->default(0)->comment('수량');
$table->string('unit', 20)->nullable()->comment('단위');
$table->string('lot_no', 50)->nullable()->comment('LOT번호');
$table->foreignId('stock_lot_id')->nullable()->comment('재고 LOT ID');
$table->text('remarks')->nullable()->comment('비고');
$table->timestamps();
$table->softDeletes();
// 인덱스
$table->index(['shipment_id', 'seq']);
$table->index(['tenant_id', 'item_code']);
// 외래키
$table->foreign('shipment_id')->references('id')->on('shipments')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('shipment_items');
}
};