Files
sam-api/app/Services/OrderService.php

765 lines
26 KiB
PHP
Raw Normal View History

<?php
namespace App\Services;
use App\Models\Orders\Order;
use App\Models\Production\WorkOrder;
use App\Models\Quote\Quote;
use App\Models\Tenants\Sale;
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;
$forWorkOrder = filter_var($params['for_work_order'] ?? false, FILTER_VALIDATE_BOOLEAN);
$query = Order::query()
->where('tenant_id', $tenantId)
->with(['client:id,name,manager_name', 'items', 'quote:id,quote_number']);
// 작업지시 생성 가능한 수주만 필터링
if ($forWorkOrder) {
// 1. DRAFT(등록) 상태만 (생산지시 전)
$query->where('status_code', Order::STATUS_DRAFT);
// 2. 작업지시가 아직 없는 수주만
$query->whereDoesntHave('workOrders');
}
// 검색어 (수주번호, 현장명, 거래처명)
if ($q !== '') {
$query->where(function ($qq) use ($q) {
$qq->where('order_no', 'like', "%{$q}%")
->orWhere('site_name', 'like', "%{$q}%")
->orWhere('client_name', 'like', "%{$q}%");
});
}
// 상태 필터 (for_work_order와 함께 사용시 무시)
if ($status !== null && ! $forWorkOrder) {
$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,contact_person,phone,email,manager_name',
'items' => fn ($q) => $q->orderBy('sort_order'),
'quote:id,quote_number,site_name,calculation_inputs',
])
->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['tenant_id'] = $tenantId;
$item['serial_no'] = $index + 1; // 1부터 시작하는 순번
$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, $tenantId, $userId) {
$data['updated_by'] = $userId;
$items = $data['items'] ?? null;
unset($data['items'], $data['order_no']); // 번호 변경 불가
$order->update($data);
// 품목 교체 (있는 경우)
if ($items !== null) {
// 기존 품목의 floor_code/symbol_code 매핑 저장 (item_name + specification → floor_code/symbol_code)
$existingMappings = [];
foreach ($order->items as $existingItem) {
$key = ($existingItem->item_name ?? '').'|'.($existingItem->specification ?? '');
$existingMappings[$key] = [
'floor_code' => $existingItem->floor_code,
'symbol_code' => $existingItem->symbol_code,
];
}
$order->items()->delete();
foreach ($items as $index => $item) {
$item['tenant_id'] = $tenantId;
$item['serial_no'] = $index + 1; // 1부터 시작하는 순번
$item['sort_order'] = $index;
// floor_code/symbol_code 보존: 프론트엔드에서 전달되지 않으면 기존 값 사용
if (empty($item['floor_code']) || empty($item['symbol_code'])) {
$key = ($item['item_name'] ?? '').'|'.($item['specification'] ?? '');
if (isset($existingMappings[$key])) {
$item['floor_code'] = $item['floor_code'] ?? $existingMappings[$key]['floor_code'];
$item['symbol_code'] = $item['symbol_code'] ?? $existingMappings[$key]['symbol_code'];
}
}
$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);
return DB::transaction(function () use ($order, $status, $userId) {
$createdSale = null;
$previousStatus = $order->status_code;
// 수주확정 시 매출 자동 생성 (sales_recognition = on_order_confirm인 경우)
if ($status === Order::STATUS_CONFIRMED && $order->shouldCreateSaleOnConfirm()) {
$createdSale = $this->createSaleFromOrder($order, $userId);
$order->sale_id = $createdSale->id;
}
// 🆕 수주확정 시 재고 예약
if ($status === Order::STATUS_CONFIRMED && $previousStatus !== Order::STATUS_CONFIRMED) {
$order->load('items');
app(StockService::class)->reserveForOrder($order->items, $order->id);
}
// 🆕 수주취소 시 재고 예약 해제
if ($status === Order::STATUS_CANCELLED && $previousStatus === Order::STATUS_CONFIRMED) {
$order->load('items');
app(StockService::class)->releaseReservationForOrder($order->items, $order->id);
}
$order->status_code = $status;
$order->updated_by = $userId;
$order->save();
$result = $order->load(['client:id,name', 'items']);
// 매출이 생성된 경우 응답에 포함
if ($createdSale) {
$result->setAttribute('created_sale', $createdSale);
}
return $result;
});
}
/**
* 수주에서 매출 생성
*/
private function createSaleFromOrder(Order $order, int $userId): Sale
{
$saleNumber = $this->generateSaleNumber($order->tenant_id);
$sale = Sale::createFromOrder($order, $saleNumber);
$sale->created_by = $userId;
$sale->save();
return $sale;
}
/**
* 매출번호 자동 생성
*/
private function generateSaleNumber(int $tenantId): string
{
$prefix = 'SAL';
$yearMonth = now()->format('Ym');
// 해당 월 기준 마지막 번호 조회
$lastNo = Sale::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->where('sale_number', 'like', "{$prefix}-{$yearMonth}-%")
->orderByDesc('sale_number')
->value('sale_number');
if ($lastNo) {
$seq = (int) substr($lastNo, -4) + 1;
} else {
$seq = 1;
}
return sprintf('%s-%s-%04d', $prefix, $yearMonth, $seq);
}
/**
* 상태 전환 규칙 검증
*/
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);
}
/**
* 견적에서 수주 생성
*/
public function createFromQuote(int $quoteId, array $data = [])
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
// 견적 조회
$quote = Quote::where('tenant_id', $tenantId)
->with(['items', 'client'])
->find($quoteId);
if (! $quote) {
throw new NotFoundHttpException(__('error.quote.not_found'));
}
// 이미 수주가 생성된 견적인지 확인
$existingOrder = Order::where('tenant_id', $tenantId)
->where('quote_id', $quoteId)
->first();
if ($existingOrder) {
throw new BadRequestHttpException(__('error.order.already_created_from_quote'));
}
return DB::transaction(function () use ($quote, $data, $tenantId, $userId) {
// 수주번호 생성
$orderNo = $this->generateOrderNo($tenantId);
// Order 모델의 createFromQuote 사용
$order = Order::createFromQuote($quote, $orderNo);
$order->created_by = $userId;
$order->updated_by = $userId;
// 추가 데이터 병합 (납품일, 메모 등)
if (! empty($data['delivery_date'])) {
$order->delivery_date = $data['delivery_date'];
}
if (! empty($data['memo'])) {
$order->memo = $data['memo'];
}
$order->save();
// calculation_inputs에서 제품-부품 매핑 정보 추출
$calculationInputs = $quote->calculation_inputs ?? [];
$calcInputItems = $calculationInputs['items'] ?? [];
// 견적 품목을 수주 품목으로 변환
foreach ($quote->items as $index => $quoteItem) {
// calculation_inputs.items에서 해당 품목의 floor/code 정보 찾기
// 1. item_index로 매칭 시도
// 2. 없으면 배열 인덱스로 fallback
$floorCode = null;
$symbolCode = null;
$itemIndex = $quoteItem->item_index ?? null;
if ($itemIndex !== null) {
// item_index로 매칭
foreach ($calcInputItems as $calcItem) {
if (($calcItem['index'] ?? null) === $itemIndex) {
$floorCode = $calcItem['floor'] ?? null;
$symbolCode = $calcItem['code'] ?? null;
break;
}
}
}
// item_index로 못 찾으면 배열 인덱스로 fallback
if ($floorCode === null && $symbolCode === null && isset($calcInputItems[$index])) {
$floorCode = $calcInputItems[$index]['floor'] ?? null;
$symbolCode = $calcInputItems[$index]['code'] ?? null;
}
$order->items()->create([
'tenant_id' => $tenantId,
'serial_no' => $index + 1, // 1부터 시작하는 순번
'item_id' => $quoteItem->item_id,
'item_code' => $quoteItem->item_code,
'item_name' => $quoteItem->item_name,
'specification' => $quoteItem->specification,
'floor_code' => $floorCode,
'symbol_code' => $symbolCode,
'quantity' => $quoteItem->calculated_quantity,
'unit' => $quoteItem->unit,
'unit_price' => $quoteItem->unit_price,
'supply_amount' => $quoteItem->total_price,
'tax_amount' => round($quoteItem->total_price * 0.1, 2),
'total_amount' => round($quoteItem->total_price * 1.1, 2),
'sort_order' => $index,
]);
}
// 합계 재계산
$order->refresh();
$order->recalculateTotals()->save();
// 견적 상태를 '수주전환완료'로 변경
$quote->update([
'status' => Quote::STATUS_CONVERTED,
'order_id' => $order->id,
'updated_by' => $userId,
]);
return $order->load(['client:id,name', 'items', 'quote:id,quote_number']);
});
}
/**
* 생산지시 생성 (공정별 작업지시 다중 생성)
*/
public function createProductionOrder(int $orderId, array $data)
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
// 수주 조회
$order = Order::where('tenant_id', $tenantId)
->with('items')
->find($orderId);
if (! $order) {
throw new NotFoundHttpException(__('error.not_found'));
}
// 상태 확인 (CONFIRMED 상태에서만 생산지시 가능)
if ($order->status_code !== Order::STATUS_CONFIRMED) {
throw new BadRequestHttpException(__('error.order.must_be_confirmed_for_production'));
}
// 이미 생산지시가 존재하는지 확인
$existingWorkOrder = WorkOrder::where('tenant_id', $tenantId)
->where('sales_order_id', $orderId)
->first();
if ($existingWorkOrder) {
throw new BadRequestHttpException(__('error.order.production_order_already_exists'));
}
// process_ids 배열 또는 단일 process_id 처리
$processIds = $data['process_ids'] ?? [];
if (empty($processIds) && ! empty($data['process_id'])) {
$processIds = [$data['process_id']];
}
// 공정이 없으면 null로 하나만 생성
if (empty($processIds)) {
$processIds = [null];
}
return DB::transaction(function () use ($order, $data, $tenantId, $userId, $processIds) {
$workOrders = [];
foreach ($processIds as $processId) {
// 작업지시번호 생성
$workOrderNo = $this->generateWorkOrderNo($tenantId);
// 작업지시 생성
$workOrder = WorkOrder::create([
'tenant_id' => $tenantId,
'work_order_no' => $workOrderNo,
'sales_order_id' => $order->id,
'project_name' => $order->site_name ?? $order->client_name,
'process_id' => $processId,
'status' => WorkOrder::STATUS_PENDING,
'assignee_id' => $data['assignee_id'] ?? null,
'team_id' => $data['team_id'] ?? null,
'scheduled_date' => $data['scheduled_date'] ?? $order->delivery_date,
'memo' => $data['memo'] ?? null,
'is_active' => true,
'created_by' => $userId,
'updated_by' => $userId,
]);
$workOrders[] = $workOrder->load(['assignee:id,name', 'team:id,name', 'process:id,process_name,process_code']);
}
// 수주 상태를 IN_PROGRESS로 변경
$order->status_code = Order::STATUS_IN_PROGRESS;
$order->updated_by = $userId;
$order->save();
return [
'work_orders' => $workOrders,
'work_order' => $workOrders[0] ?? null, // 하위 호환성
'order' => $order->load(['client:id,name', 'items']),
];
});
}
/**
* 작업지시번호 자동 생성
*/
private function generateWorkOrderNo(int $tenantId): string
{
$prefix = 'WO';
$date = now()->format('Ymd');
$lastNo = WorkOrder::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->where('work_order_no', 'like', "{$prefix}{$date}%")
->orderByDesc('work_order_no')
->value('work_order_no');
if ($lastNo) {
$seq = (int) substr($lastNo, -4) + 1;
} else {
$seq = 1;
}
return sprintf('%s%s%04d', $prefix, $date, $seq);
}
/**
* 수주확정 되돌리기 (수주등록 상태로 변경)
*/
public function revertOrderConfirmation(int $orderId): array
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
// 수주 조회
$order = Order::where('tenant_id', $tenantId)
->find($orderId);
if (! $order) {
throw new NotFoundHttpException(__('error.not_found'));
}
// 수주확정 상태에서만 되돌리기 가능
if ($order->status_code !== Order::STATUS_CONFIRMED) {
throw new BadRequestHttpException(__('error.order.cannot_revert_not_confirmed'));
}
return DB::transaction(function () use ($order, $tenantId, $userId) {
$deletedSaleId = null;
// 수주확정 시 생성된 매출이 있으면 삭제
if ($order->sale_id) {
$sale = Sale::where('tenant_id', $tenantId)
->where('id', $order->sale_id)
->first();
if ($sale) {
// 수주확정 시 생성된 매출만 삭제 (draft 상태이고 order_confirm 타입)
if ($sale->source_type === Sale::SOURCE_ORDER_CONFIRM
&& $sale->status === 'draft') {
$deletedSaleId = $sale->id;
$sale->deleted_by = $userId;
$sale->save();
$sale->delete();
}
}
// 수주의 매출 연결 해지
$order->sale_id = null;
}
// 상태 변경
$previousStatus = $order->status_code;
$order->status_code = Order::STATUS_DRAFT;
$order->updated_by = $userId;
$order->save();
return [
'order' => $order->load(['client:id,name', 'items']),
'previous_status' => $previousStatus,
'deleted_sale_id' => $deletedSaleId,
];
});
}
/**
* 생산지시 되돌리기 (작업지시 관련 데이터 삭제)
*/
public function revertProductionOrder(int $orderId): array
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
// 수주 조회
$order = Order::where('tenant_id', $tenantId)
->find($orderId);
if (! $order) {
throw new NotFoundHttpException(__('error.not_found'));
}
// 완료된 수주는 되돌리기 불가
if ($order->status_code === Order::STATUS_COMPLETED) {
throw new BadRequestHttpException(__('error.order.cannot_revert_completed'));
}
return DB::transaction(function () use ($order, $tenantId, $userId) {
// 관련 작업지시 ID 조회
$workOrderIds = WorkOrder::where('tenant_id', $tenantId)
->where('sales_order_id', $order->id)
->pluck('id')
->toArray();
$deletedCounts = [
'work_results' => 0,
'work_order_items' => 0,
'work_orders' => 0,
];
if (count($workOrderIds) > 0) {
// 1. 작업결과 삭제
$deletedCounts['work_results'] = DB::table('work_results')
->whereIn('work_order_id', $workOrderIds)
->delete();
// 2. 작업지시 품목 삭제
$deletedCounts['work_order_items'] = DB::table('work_order_items')
->whereIn('work_order_id', $workOrderIds)
->delete();
// 3. 작업지시 삭제
$deletedCounts['work_orders'] = WorkOrder::whereIn('id', $workOrderIds)
->delete();
}
// 4. 수주 상태를 CONFIRMED로 되돌리기
$previousStatus = $order->status_code;
$order->status_code = Order::STATUS_CONFIRMED;
$order->updated_by = $userId;
$order->save();
return [
'order' => $order->load(['client:id,name', 'items']),
'deleted_counts' => $deletedCounts,
'previous_status' => $previousStatus,
];
});
}
}