Files
sam-api/app/Services/OrderService.php
kent de19ac97aa feat: 수주관리(Order Management) API Phase 1.1 구현
- OrderService 구현 (index, stats, show, store, update, destroy, updateStatus)
- OrderController 구현 (7개 API 엔드포인트)
- FormRequest 클래스 3개 생성 (Store, Update, UpdateStatus)
- 상태 전환 규칙 검증 (DRAFT → CONFIRMED → IN_PROGRESS → COMPLETED/CANCELLED)
- 수주번호 자동 생성 (ORD{YYYYMMDD}{0001} 형식)
- Swagger API 문서 작성 (OrderApi.php)
- i18n 메시지 키 추가 (ko/en)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 11:11:54 +09:00

323 lines
9.8 KiB
PHP

<?php
namespace App\Services;
use App\Models\Orders\Order;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class OrderService extends Service
{
/**
* 목록 조회 (검색/필터링/페이징)
*/
public function index(array $params)
{
$tenantId = $this->tenantId();
$page = (int) ($params['page'] ?? 1);
$size = (int) ($params['size'] ?? 20);
$q = trim((string) ($params['q'] ?? ''));
$status = $params['status'] ?? null;
$orderType = $params['order_type'] ?? null;
$clientId = $params['client_id'] ?? null;
$dateFrom = $params['date_from'] ?? null;
$dateTo = $params['date_to'] ?? null;
$query = Order::query()
->where('tenant_id', $tenantId)
->with(['client:id,name', 'items']);
// 검색어 (수주번호, 현장명, 거래처명)
if ($q !== '') {
$query->where(function ($qq) use ($q) {
$qq->where('order_no', 'like', "%{$q}%")
->orWhere('site_name', 'like', "%{$q}%")
->orWhere('client_name', 'like', "%{$q}%");
});
}
// 상태 필터
if ($status !== null) {
$query->where('status_code', $status);
}
// 주문유형 필터 (ORDER/PURCHASE)
if ($orderType !== null) {
$query->where('order_type_code', $orderType);
}
// 거래처 필터
if ($clientId !== null) {
$query->where('client_id', $clientId);
}
// 날짜 범위 (수주일 기준)
if ($dateFrom !== null) {
$query->where('received_at', '>=', $dateFrom);
}
if ($dateTo !== null) {
$query->where('received_at', '<=', $dateTo);
}
$query->orderByDesc('created_at');
return $query->paginate($size, ['*'], 'page', $page);
}
/**
* 통계 조회
*/
public function stats(): array
{
$tenantId = $this->tenantId();
$counts = Order::where('tenant_id', $tenantId)
->select('status_code', DB::raw('count(*) as count'))
->groupBy('status_code')
->pluck('count', 'status_code')
->toArray();
$amounts = Order::where('tenant_id', $tenantId)
->select('status_code', DB::raw('sum(total_amount) as total'))
->groupBy('status_code')
->pluck('total', 'status_code')
->toArray();
return [
'total' => array_sum($counts),
'draft' => $counts[Order::STATUS_DRAFT] ?? 0,
'confirmed' => $counts[Order::STATUS_CONFIRMED] ?? 0,
'in_progress' => $counts[Order::STATUS_IN_PROGRESS] ?? 0,
'completed' => $counts[Order::STATUS_COMPLETED] ?? 0,
'cancelled' => $counts[Order::STATUS_CANCELLED] ?? 0,
'total_amount' => array_sum($amounts),
'confirmed_amount' => ($amounts[Order::STATUS_CONFIRMED] ?? 0) + ($amounts[Order::STATUS_IN_PROGRESS] ?? 0),
];
}
/**
* 단건 조회
*/
public function show(int $id)
{
$tenantId = $this->tenantId();
$order = Order::where('tenant_id', $tenantId)
->with([
'client:id,name,business_no,representative,phone,email',
'items' => fn ($q) => $q->orderBy('sort_order'),
'quote:id,quote_no,site_name',
])
->find($id);
if (! $order) {
throw new NotFoundHttpException(__('error.not_found'));
}
return $order;
}
/**
* 생성
*/
public function store(array $data)
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
return DB::transaction(function () use ($data, $tenantId, $userId) {
// 수주번호 자동 생성
$data['order_no'] = $this->generateOrderNo($tenantId);
$data['tenant_id'] = $tenantId;
$data['created_by'] = $userId;
$data['updated_by'] = $userId;
// 기본 상태 설정
$data['status_code'] = $data['status_code'] ?? Order::STATUS_DRAFT;
$data['order_type_code'] = $data['order_type_code'] ?? Order::TYPE_ORDER;
$items = $data['items'] ?? [];
unset($data['items']);
$order = Order::create($data);
// 품목 저장
foreach ($items as $index => $item) {
$item['sort_order'] = $index;
$this->calculateItemAmounts($item);
$order->items()->create($item);
}
// 합계 재계산
$order->refresh();
$order->recalculateTotals()->save();
return $order->load(['client:id,name', 'items']);
});
}
/**
* 수정
*/
public function update(int $id, array $data)
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$order = Order::where('tenant_id', $tenantId)->find($id);
if (! $order) {
throw new NotFoundHttpException(__('error.not_found'));
}
// 완료/취소 상태에서는 수정 불가
if (in_array($order->status_code, [Order::STATUS_COMPLETED, Order::STATUS_CANCELLED])) {
throw new BadRequestHttpException(__('error.order.cannot_update_completed'));
}
return DB::transaction(function () use ($order, $data, $userId) {
$data['updated_by'] = $userId;
$items = $data['items'] ?? null;
unset($data['items'], $data['order_no']); // 번호 변경 불가
$order->update($data);
// 품목 교체 (있는 경우)
if ($items !== null) {
$order->items()->delete();
foreach ($items as $index => $item) {
$item['sort_order'] = $index;
$this->calculateItemAmounts($item);
$order->items()->create($item);
}
// 합계 재계산
$order->refresh();
$order->recalculateTotals()->save();
}
return $order->load(['client:id,name', 'items']);
});
}
/**
* 삭제
*/
public function destroy(int $id)
{
$tenantId = $this->tenantId();
$order = Order::where('tenant_id', $tenantId)->find($id);
if (! $order) {
throw new NotFoundHttpException(__('error.not_found'));
}
// 진행 중이거나 완료된 수주는 삭제 불가
if (in_array($order->status_code, [
Order::STATUS_IN_PROGRESS,
Order::STATUS_COMPLETED,
])) {
throw new BadRequestHttpException(__('error.order.cannot_delete_in_progress'));
}
$order->deleted_by = $this->apiUserId();
$order->save();
$order->delete();
return 'success';
}
/**
* 상태 변경
*/
public function updateStatus(int $id, string $status)
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$order = Order::where('tenant_id', $tenantId)->find($id);
if (! $order) {
throw new NotFoundHttpException(__('error.not_found'));
}
// 상태 유효성 검증
$validStatuses = [
Order::STATUS_DRAFT,
Order::STATUS_CONFIRMED,
Order::STATUS_IN_PROGRESS,
Order::STATUS_COMPLETED,
Order::STATUS_CANCELLED,
];
if (! in_array($status, $validStatuses)) {
throw new BadRequestHttpException(__('error.invalid_status'));
}
// 상태 전환 규칙 검증
$this->validateStatusTransition($order->status_code, $status);
$order->status_code = $status;
$order->updated_by = $userId;
$order->save();
return $order->load(['client:id,name', 'items']);
}
/**
* 상태 전환 규칙 검증
*/
private function validateStatusTransition(string $from, string $to): void
{
$allowedTransitions = [
Order::STATUS_DRAFT => [Order::STATUS_CONFIRMED, Order::STATUS_CANCELLED],
Order::STATUS_CONFIRMED => [Order::STATUS_IN_PROGRESS, Order::STATUS_CANCELLED],
Order::STATUS_IN_PROGRESS => [Order::STATUS_COMPLETED, Order::STATUS_CANCELLED],
Order::STATUS_COMPLETED => [], // 완료 상태에서는 변경 불가
Order::STATUS_CANCELLED => [Order::STATUS_DRAFT], // 취소에서 임시저장으로만 복구 가능
];
if (! in_array($to, $allowedTransitions[$from] ?? [])) {
throw new BadRequestHttpException(__('error.order.invalid_status_transition'));
}
}
/**
* 품목 금액 계산
*/
private function calculateItemAmounts(array &$item): void
{
$quantity = (float) ($item['quantity'] ?? 0);
$unitPrice = (float) ($item['unit_price'] ?? 0);
$item['supply_amount'] = $quantity * $unitPrice;
$item['tax_amount'] = round($item['supply_amount'] * 0.1, 2);
$item['total_amount'] = $item['supply_amount'] + $item['tax_amount'];
}
/**
* 수주번호 자동 생성
*/
private function generateOrderNo(int $tenantId): string
{
$prefix = 'ORD';
$date = now()->format('Ymd');
// 오늘 날짜 기준 마지막 번호 조회
$lastNo = Order::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->where('order_no', 'like', "{$prefix}{$date}%")
->orderByDesc('order_no')
->value('order_no');
if ($lastNo) {
$seq = (int) substr($lastNo, -4) + 1;
} else {
$seq = 1;
}
return sprintf('%s%s%04d', $prefix, $date, $seq);
}
}