Files
sam-api/app/Services/OrderService.php
권혁성 afc31be642 fix: [order] 견적→수주 변환 개소별 분리 구현
- CreateFromQuoteRequest 검증 규칙 추가
- Order 모델 견적 연동 관계 보강
- OrderService 변환 시 개소별 분리 로직
2026-03-17 13:55:28 +09:00

2048 lines
82 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Services;
use App\Models\Documents\Document;
use App\Models\Documents\DocumentApproval;
use App\Models\Documents\DocumentData;
use App\Models\Items\Item;
use App\Models\Orders\Order;
use App\Models\Orders\OrderHistory;
use App\Models\Orders\OrderNode;
use App\Models\Production\WorkOrder;
use App\Models\Production\WorkOrderMaterialInput;
use App\Models\Quote\Quote;
use App\Models\Tenants\Sale;
use App\Services\Production\BendingInfoBuilder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class OrderService extends Service
{
public function __construct(
private NumberingService $numberingService
) {}
/**
* 상세 조회용 공통 relations (show와 동일한 구조 보장)
*/
private function loadDetailRelations(Order $order): Order
{
return $order->load([
'client:id,name,contact_person,phone,email,manager_name',
'items' => fn ($q) => $q->orderBy('sort_order'),
'rootNodes' => fn ($q) => $q->withRecursiveChildren(),
'quote:id,quote_number,site_name,calculation_inputs',
]);
}
/**
* 목록 조회 (검색/필터링/페이징)
*/
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', 'rootNodes:id,order_id,name,options'])
->withSum('rootNodes', 'quantity');
// 작업지시 생성 가능한 수주만 필터링
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(?string $orderType = null): array
{
$tenantId = $this->tenantId();
$baseQuery = Order::where('tenant_id', $tenantId);
if ($orderType !== null) {
$baseQuery->where('order_type_code', $orderType);
}
$counts = (clone $baseQuery)
->select('status_code', DB::raw('count(*) as count'))
->groupBy('status_code')
->pluck('count', 'status_code')
->toArray();
$amounts = (clone $baseQuery)
->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)->find($id);
if (! $order) {
throw new NotFoundHttpException(__('error.not_found'));
}
return $this->loadDetailRelations($order);
}
/**
* 생성
*/
public function store(array $data)
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
return DB::transaction(function () use ($data, $tenantId, $userId) {
// 수주번호 자동 생성 (재고생산은 STK 접두사)
$pairCode = $data['pair_code'] ?? null;
unset($data['pair_code']);
$isStock = ($data['order_type_code'] ?? null) === Order::TYPE_STOCK;
$data['order_no'] = $isStock
? $this->generateStockOrderNo($tenantId)
: $this->generateOrderNo($tenantId, $pairCode);
$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;
// 재고생산: 현장명 자동 설정
if ($isStock) {
$data['site_name'] = '재고생산';
}
$items = $data['items'] ?? [];
unset($data['items']);
$order = Order::create($data);
// quote_id가 있으면 OrderNode 생성 (개소별 사이즈 정보)
$nodeMap = [];
$productItems = [];
if ($order->quote_id) {
$quote = Quote::withoutGlobalScopes()->find($order->quote_id);
if ($quote) {
$ci = $quote->calculation_inputs ?? [];
$productItems = $ci['items'] ?? [];
$bomResults = $ci['bomResults'] ?? [];
foreach ($productItems as $idx => $locItem) {
$bomResult = $bomResults[$idx] ?? null;
$grandTotal = $bomResult['grand_total'] ?? 0;
$bomVars = $bomResult['variables'] ?? [];
$qty = (int) ($locItem['quantity'] ?? 1);
$floor = $locItem['floor'] ?? '';
$symbol = $locItem['code'] ?? '';
$nodeMap[$idx] = OrderNode::create([
'tenant_id' => $tenantId,
'order_id' => $order->id,
'parent_id' => null,
'node_type' => 'location',
'code' => trim("{$floor}-{$symbol}", '-') ?: "LOC-{$idx}",
'name' => trim("{$floor} {$symbol}") ?: '개소 '.($idx + 1),
'status_code' => OrderNode::STATUS_PENDING,
'quantity' => $qty,
'unit_price' => $grandTotal,
'total_price' => $grandTotal * $qty,
'options' => [
'floor' => $floor,
'symbol' => $symbol,
'product_code' => $locItem['productCode'] ?? null,
'product_name' => $locItem['productName'] ?? null,
'open_width' => $locItem['openWidth'] ?? null,
'open_height' => $locItem['openHeight'] ?? null,
'width' => $bomVars['W1'] ?? $locItem['openWidth'] ?? null,
'height' => $bomVars['H1'] ?? $locItem['openHeight'] ?? null,
'bom_result' => $bomResult,
],
'depth' => 0,
'sort_order' => $idx,
'created_by' => $userId,
]);
}
}
}
// 품목 저장
// sort_order 기반 분배 준비
$locationCount = count($productItems);
$itemsPerLocation = ($locationCount > 1)
? intdiv(count($items), $locationCount)
: 0;
// floor/code 조합이 개소별로 고유한지 확인 (모두 동일하면 매칭 무의미)
$uniqueLocations = collect($productItems)
->map(fn ($p) => ($p['floor'] ?? '').'-'.($p['code'] ?? ''))
->unique()
->count();
$canMatchByFloorCode = $uniqueLocations > 1;
foreach ($items as $index => $item) {
$item['tenant_id'] = $tenantId;
$item['serial_no'] = $index + 1; // 1부터 시작하는 순번
$item['sort_order'] = $index;
$this->calculateItemAmounts($item);
// item_id가 없고 item_code가 있으면 item_code로 조회하여 보완
if (empty($item['item_id']) && ! empty($item['item_code'])) {
$foundItem = Item::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->where('code', $item['item_code'])
->first();
if ($foundItem) {
$item['item_id'] = $foundItem->id;
}
}
// 노드 매칭 (개소 분배)
if (! empty($nodeMap) && ! empty($productItems)) {
$locIdx = 0;
$matched = false;
// 1순위: floor_code/symbol_code로 매칭 (개소별 고유값이 있는 경우만)
if ($canMatchByFloorCode) {
$floorCode = $item['floor_code'] ?? null;
$symbolCode = $item['symbol_code'] ?? null;
if ($floorCode && $symbolCode) {
foreach ($productItems as $pidx => $pItem) {
if (($pItem['floor'] ?? '') === $floorCode && ($pItem['code'] ?? '') === $symbolCode) {
$locIdx = $pidx;
$matched = true;
break;
}
}
}
}
// 2순위: sort_order 기반 균등 분배
if (! $matched && $itemsPerLocation > 0) {
$locIdx = min(intdiv($index, $itemsPerLocation), $locationCount - 1);
}
$item['order_node_id'] = $nodeMap[$locIdx]->id ?? null;
}
$order->items()->create($item);
}
// 합계 재계산
$order->refresh();
$order->recalculateTotals()->save();
// 견적 연결: Quote.order_id 동기화
if ($order->quote_id) {
Quote::withoutGlobalScopes()
->where('id', $order->quote_id)
->whereNull('order_id')
->update(['order_id' => $order->id]);
}
return $this->loadDetailRelations($order);
});
}
/**
* 수정
*/
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 $this->loadDetailRelations($order);
});
}
/**
* 삭제
*/
public function destroy(int $id)
{
$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_IN_PROGRESS,
Order::STATUS_IN_PRODUCTION,
Order::STATUS_PRODUCED,
Order::STATUS_SHIPPING,
Order::STATUS_SHIPPED,
Order::STATUS_COMPLETED,
])) {
throw new BadRequestHttpException(__('error.order.cannot_delete_in_progress'));
}
// 작업지시가 존재하면 삭제 불가
if ($order->workOrders()->exists()) {
throw new BadRequestHttpException(__('error.order.cannot_delete_has_work_orders'));
}
// 출하 정보가 존재하면 삭제 불가
if ($order->shipments()->exists()) {
throw new BadRequestHttpException(__('error.order.cannot_delete_has_shipments'));
}
return DB::transaction(function () use ($order, $userId) {
// 0. 연결된 견적의 수주 연결 해제 (order_id → null, status → finalized)
if ($order->quote_id) {
Quote::withoutGlobalScopes()
->where('id', $order->quote_id)
->where('order_id', $order->id)
->update([
'order_id' => null,
'status' => Quote::STATUS_FINALIZED,
]);
}
// 1. order_item_components soft delete
foreach ($order->items as $item) {
$item->components()->update(['deleted_by' => $userId]);
$item->components()->delete();
}
// 2. order_items soft delete
$order->items()->update(['deleted_by' => $userId]);
$order->items()->delete();
// 3. order_nodes soft delete
$order->nodes()->update(['deleted_by' => $userId]);
$order->nodes()->delete();
// 4. order 마스터 soft delete
$order->deleted_by = $userId;
$order->save();
$order->delete();
// order_histories, order_versions는 감사 기록이므로 보존
return 'success';
});
}
/**
* 일괄 삭제
*/
public function bulkDestroy(array $ids, bool $force = false): array
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
// force=true는 운영 환경에서 차단
if ($force && app()->environment('production')) {
throw new \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException(__('error.forbidden'));
}
$orders = Order::where('tenant_id', $tenantId)
->whereIn('id', $ids)
->get();
$deletedCount = 0;
$skippedIds = [];
return DB::transaction(function () use ($orders, $force, $userId, $tenantId, &$deletedCount, &$skippedIds) {
foreach ($orders as $order) {
if ($force) {
// force=true (개발환경 완전삭제): 모든 상태 허용, 연관 데이터 모두 삭제
$this->forceDeleteWorkOrders($order, $tenantId);
} else {
// 일반 삭제: 상태/작업지시/출하 검증
if (! in_array($order->status_code, [
Order::STATUS_DRAFT,
Order::STATUS_CONFIRMED,
Order::STATUS_CANCELLED,
])) {
$skippedIds[] = $order->id;
continue;
}
if ($order->workOrders()->exists()) {
$skippedIds[] = $order->id;
continue;
}
if ($order->shipments()->exists()) {
$skippedIds[] = $order->id;
continue;
}
}
// 견적 연결 해제
if ($order->quote_id) {
Quote::withoutGlobalScopes()
->where('id', $order->quote_id)
->where('order_id', $order->id)
->update([
'order_id' => null,
'status' => Quote::STATUS_FINALIZED,
]);
}
if ($force) {
// hard delete: 컴포넌트 → 품목 → 노드 → 마스터
foreach ($order->items as $item) {
$item->components()->forceDelete();
}
$order->items()->forceDelete();
$order->nodes()->forceDelete();
$order->forceDelete();
} else {
// soft delete: 기존 destroy() 로직과 동일
foreach ($order->items as $item) {
$item->components()->update(['deleted_by' => $userId]);
$item->components()->delete();
}
$order->items()->update(['deleted_by' => $userId]);
$order->items()->delete();
$order->nodes()->update(['deleted_by' => $userId]);
$order->nodes()->delete();
$order->deleted_by = $userId;
$order->save();
$order->delete();
}
$deletedCount++;
}
return [
'deleted_count' => $deletedCount,
'skipped_count' => count($skippedIds),
'skipped_ids' => $skippedIds,
];
});
}
/**
* 작업지시 및 연관 데이터 강제 삭제 (개발환경 완전삭제용)
*/
private function forceDeleteWorkOrders(Order $order, int $tenantId): void
{
$workOrderIds = WorkOrder::where('tenant_id', $tenantId)
->where('sales_order_id', $order->id)
->pluck('id')
->toArray();
if (empty($workOrderIds)) {
return;
}
// 1. 자재 투입 재고 복구 + 삭제
$materialInputs = WorkOrderMaterialInput::whereIn('work_order_id', $workOrderIds)->get();
if ($materialInputs->isNotEmpty()) {
$stockService = app(StockService::class);
foreach ($materialInputs as $input) {
try {
$stockService->increaseToLot(
stockLotId: $input->stock_lot_id,
qty: (float) $input->qty,
reason: 'work_order_input_cancel',
referenceId: $input->work_order_id
);
} catch (\Exception $e) {
Log::warning('완전삭제: 재고 복원 실패', [
'input_id' => $input->id,
'stock_lot_id' => $input->stock_lot_id,
'error' => $e->getMessage(),
]);
}
}
WorkOrderMaterialInput::whereIn('work_order_id', $workOrderIds)->delete();
}
// 2. 문서 삭제
$documentIds = Document::where('linkable_type', 'work_order')
->whereIn('linkable_id', $workOrderIds)
->pluck('id')
->toArray();
if (! empty($documentIds)) {
DocumentData::whereIn('document_id', $documentIds)->delete();
DocumentApproval::whereIn('document_id', $documentIds)->delete();
Document::whereIn('id', $documentIds)->forceDelete();
}
// 3. 출하 참조 해제
DB::table('shipments')
->whereIn('work_order_id', $workOrderIds)
->update(['work_order_id' => null]);
// 4. 부속 데이터 삭제
DB::table('work_order_step_progress')->whereIn('work_order_id', $workOrderIds)->delete();
DB::table('work_order_assignees')->whereIn('work_order_id', $workOrderIds)->delete();
DB::table('work_order_bending_details')->whereIn('work_order_id', $workOrderIds)->delete();
DB::table('work_order_issues')->whereIn('work_order_id', $workOrderIds)->delete();
DB::table('work_results')->whereIn('work_order_id', $workOrderIds)->delete();
// 5. 작업지시 품목 → 작업지시 삭제
DB::table('work_order_items')->whereIn('work_order_id', $workOrderIds)->delete();
WorkOrder::whereIn('id', $workOrderIds)->forceDelete();
}
/**
* 상태 변경
*/
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;
// 수주확정 시 매출 자동 생성 (재고생산은 매출 생성 불필요)
if ($status === Order::STATUS_CONFIRMED && $order->order_type_code !== Order::TYPE_STOCK && $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 = $this->loadDetailRelations($order);
// 매출이 생성된 경우 응답에 포함
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'];
}
/**
* 수주번호 자동 생성
*
* 채번규칙이 있으면 NumberingService 사용 (KD-{pairCode}-{YYMMDD}-{NN}),
* 없으면 레거시 로직 (ORD{YYYYMMDD}{NNNN})
*/
private function generateOrderNo(int $tenantId, ?string $pairCode = null): string
{
$this->numberingService->setContext($tenantId, $this->apiUserId());
$number = $this->numberingService->generate('order', [
'pair_code' => $pairCode ?? 'SS',
]);
if ($number !== null) {
return $number;
}
return $this->generateOrderNoLegacy($tenantId);
}
/**
* 레거시 수주번호 생성 (ORD{YYYYMMDD}{NNNN})
*/
private function generateOrderNoLegacy(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);
}
/**
* 재고생산 번호 생성 (STK{YYYYMMDD}{NNNN})
*/
private function generateStockOrderNo(int $tenantId): string
{
$prefix = 'STK';
$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) {
$pairCode = $data['pair_code'] ?? null;
// calculation_inputs에서 제품 정보 추출
$calculationInputs = $quote->calculation_inputs ?? [];
$productItems = $calculationInputs['items'] ?? [];
$bomResults = $calculationInputs['bomResults'] ?? [];
$locationCount = count($productItems);
// 품목→개소 매핑 사전 계산
$hasFormulaSource = $quote->items->contains(fn ($item) => ! empty($item->formula_source));
$itemsPerLocation = (! $hasFormulaSource && $locationCount > 1)
? intdiv($quote->items->count(), $locationCount)
: 0;
// 견적 품목을 개소별로 그룹핑
$itemsByLocation = [];
foreach ($quote->items as $index => $quoteItem) {
$locIdx = $this->resolveQuoteItemLocationIndex($quoteItem, $productItems, $itemsPerLocation, $index, $locationCount);
$itemsByLocation[$locIdx][] = $quoteItem;
}
// 개소 × 수량 → 노드 목록 확장 (qty=10 → 노드 10개, 각 qty=1)
$expandedNodes = [];
foreach ($productItems as $idx => $locItem) {
$qty = (int) ($locItem['quantity'] ?? 1);
for ($q = 0; $q < $qty; $q++) {
$expandedNodes[] = [
'locItem' => $locItem,
'bomResult' => $bomResults[$idx] ?? null,
'origIdx' => $idx,
'seqNo' => $q + 1,
];
}
}
// 수주 1건 생성
$orderNo = $this->generateOrderNo($tenantId, $pairCode);
$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'];
}
if (! empty($data['delivery_method_code'])) {
$order->delivery_method_code = $data['delivery_method_code'];
}
// options 병합 (수신자, 수신처, 운임 등)
if (! empty($data['options'])) {
$order->options = array_merge($order->options ?? [], $data['options']);
}
$order->save();
// 확장된 노드별로 OrderNode + OrderItem 생성
foreach ($expandedNodes as $nodeIdx => $expanded) {
$locItem = $expanded['locItem'];
$bomResult = $expanded['bomResult'];
$origIdx = $expanded['origIdx'];
$bomVars = $bomResult['variables'] ?? [];
$grandTotal = $bomResult['grand_total'] ?? 0;
$floor = $locItem['floor'] ?? '';
$symbol = $locItem['code'] ?? '';
// 노드명 = 제품명, 코드/부호에 수량 번호 부여
$productName = $locItem['productName'] ?? '';
$nodeCode = trim("{$floor}-{$symbol}", '-') ?: "LOC-{$nodeIdx}";
$nodeSymbol = $symbol;
$totalQty = (int) ($locItem['quantity'] ?? 1);
if ($totalQty > 1) {
$nodeCode .= '-'.$expanded['seqNo'];
$nodeSymbol .= ' #'.$expanded['seqNo'];
}
$nodeName = $productName ?: trim("{$floor} {$nodeSymbol}") ?: '개소 '.($nodeIdx + 1);
$node = OrderNode::create([
'tenant_id' => $tenantId,
'order_id' => $order->id,
'parent_id' => null,
'node_type' => 'location',
'code' => $nodeCode,
'name' => $nodeName,
'status_code' => OrderNode::STATUS_PENDING,
'quantity' => 1,
'unit_price' => $grandTotal,
'total_price' => $grandTotal,
'options' => [
'floor' => $floor,
'symbol' => $nodeSymbol,
'product_code' => $locItem['productCode'] ?? null,
'product_name' => $locItem['productName'] ?? null,
'open_width' => $locItem['openWidth'] ?? null,
'open_height' => $locItem['openHeight'] ?? null,
'width' => $bomVars['W1'] ?? $locItem['openWidth'] ?? null,
'height' => $bomVars['H1'] ?? $locItem['openHeight'] ?? null,
'guide_rail_type' => $locItem['guideRailType'] ?? null,
'motor_power' => $locItem['motorPower'] ?? null,
'controller' => $locItem['controller'] ?? null,
'wing_size' => $locItem['wingSize'] ?? null,
'inspection_fee' => $locItem['inspectionFee'] ?? null,
'bom_result' => $bomResult,
'slat_info' => $this->extractSlatInfoFromBom($bomResult, $locItem),
],
'depth' => 0,
'sort_order' => $nodeIdx,
'created_by' => $userId,
]);
// 해당 개소 소속 품목 → OrderItem 복제
foreach ($itemsByLocation[$origIdx] ?? [] as $serialIdx => $quoteItem) {
$floorCode = $locItem['floor'] ?? null;
$symbolCode = $locItem['code'] ?? null;
$order->items()->create([
'tenant_id' => $tenantId,
'order_node_id' => $node->id,
'serial_no' => $serialIdx + 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),
'note' => $quoteItem->formula_category,
'sort_order' => $serialIdx,
]);
}
}
// 합계 재계산
$order->refresh();
$order->recalculateTotals()->save();
// 견적에 수주 연결 (status는 accessor가 자동으로 'converted' 반환)
$quote->update([
'order_id' => $order->id,
'updated_by' => $userId,
]);
return $this->loadDetailRelations($order);
});
}
/**
* 견적 품목이 속하는 개소 인덱스 결정
*/
private function resolveQuoteItemLocationIndex(
$quoteItem,
array $productItems,
int $itemsPerLocation,
int $itemIndex,
int $locationCount
): int {
$locIdx = 0;
// 1순위: formula_source에서 인덱스 추출
$formulaSource = $quoteItem->formula_source ?? '';
if (preg_match('/product_(\d+)/', $formulaSource, $matches)) {
return (int) $matches[1];
}
// 2순위: sort_order 기반 분배
if ($itemsPerLocation > 0) {
return min(intdiv($itemIndex, $itemsPerLocation), $locationCount - 1);
}
// 3순위: note에서 floor/code 매칭
$note = trim($quoteItem->note ?? '');
if ($note !== '' && $note !== '-' && $note !== '- -') {
$parts = preg_split('/\s+/', $note, 2);
$floorCode = ($parts[0] ?? null) !== '-' ? ($parts[0] ?? null) : null;
$symbolCode = ($parts[1] ?? null) !== '-' ? ($parts[1] ?? null) : null;
if (! empty($floorCode) && ! empty($symbolCode)) {
foreach ($productItems as $pidx => $pItem) {
if (($pItem['floor'] ?? '') === $floorCode && ($pItem['code'] ?? '') === $symbolCode) {
return $pidx;
}
}
}
}
return $locIdx;
}
/**
* 견적 변경사항을 수주에 동기화
*
* 견적이 수정되면 연결된 수주도 함께 업데이트하고 히스토리를 생성합니다.
*
* @param Quote $quote 수정된 견적
* @param int $revision 견적 수정 차수
* @return Order|null 업데이트된 수주 또는 null (연결된 수주가 없는 경우)
*/
public function syncFromQuote(Quote $quote, int $revision): ?Order
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
// 연결된 수주 확인
$order = Order::where('tenant_id', $tenantId)
->where('quote_id', $quote->id)
->first();
if (! $order) {
return null;
}
// 생산 진행 이상의 상태면 동기화 불가 (DRAFT, CONFIRMED, IN_PROGRESS만 허용)
$allowedStatuses = [
Order::STATUS_DRAFT,
Order::STATUS_CONFIRMED,
Order::STATUS_IN_PROGRESS,
];
if (! in_array($order->status_code, $allowedStatuses)) {
throw new BadRequestHttpException(__('error.order.cannot_sync_after_production'));
}
return DB::transaction(function () use ($order, $quote, $tenantId, $userId, $revision) {
// 변경 전 데이터 스냅샷 (히스토리용)
$beforeData = [
'site_name' => $order->site_name,
'client_name' => $order->client_name,
'total_amount' => $order->total_amount,
'items_count' => $order->items()->count(),
];
// 수주 기본 정보 업데이트
$order->update([
'site_name' => $quote->site_name,
'client_id' => $quote->client_id,
'client_name' => $quote->client_name,
'discount_rate' => $quote->discount_rate ?? 0,
'discount_amount' => $quote->discount_amount ?? 0,
'total_amount' => $quote->total_amount,
'updated_by' => $userId,
]);
// 기존 품목 및 노드 삭제 후 새로 생성
$order->items()->delete();
$order->nodes()->delete();
// calculation_inputs에서 제품 정보 추출
$calculationInputs = $quote->calculation_inputs ?? [];
$productItems = $calculationInputs['items'] ?? [];
$bomResults = $calculationInputs['bomResults'] ?? [];
// OrderNode 생성 (개소별)
$nodeMap = [];
foreach ($productItems as $idx => $locItem) {
$bomResult = $bomResults[$idx] ?? null;
$bomVars = $bomResult['variables'] ?? [];
$grandTotal = $bomResult['grand_total'] ?? 0;
$qty = (int) ($locItem['quantity'] ?? 1);
$floor = $locItem['floor'] ?? '';
$symbol = $locItem['code'] ?? '';
$nodeMap[$idx] = OrderNode::create([
'tenant_id' => $tenantId,
'order_id' => $order->id,
'parent_id' => null,
'node_type' => 'location',
'code' => trim("{$floor}-{$symbol}", '-') ?: "LOC-{$idx}",
'name' => trim("{$floor} {$symbol}") ?: '개소 '.($idx + 1),
'status_code' => OrderNode::STATUS_PENDING,
'quantity' => $qty,
'unit_price' => $grandTotal,
'total_price' => $grandTotal * $qty,
'options' => [
'floor' => $floor,
'symbol' => $symbol,
'product_code' => $locItem['productCode'] ?? null,
'product_name' => $locItem['productName'] ?? null,
'open_width' => $locItem['openWidth'] ?? null,
'open_height' => $locItem['openHeight'] ?? null,
'width' => $bomVars['W1'] ?? $locItem['openWidth'] ?? null,
'height' => $bomVars['H1'] ?? $locItem['openHeight'] ?? null,
'guide_rail_type' => $locItem['guideRailType'] ?? null,
'motor_power' => $locItem['motorPower'] ?? null,
'controller' => $locItem['controller'] ?? null,
'wing_size' => $locItem['wingSize'] ?? null,
'inspection_fee' => $locItem['inspectionFee'] ?? null,
'bom_result' => $bomResult,
'slat_info' => $this->extractSlatInfoFromBom($bomResult, $locItem),
],
'depth' => 0,
'sort_order' => $idx,
'created_by' => $userId,
]);
}
// 견적 품목을 수주 품목으로 변환 (노드 연결 포함)
// formula_source 없는 레거시 데이터용: sort_order 기반 분배 준비
$locationCount = count($productItems);
$hasFormulaSource = $quote->items->contains(fn ($item) => ! empty($item->formula_source));
$itemsPerLocation = (! $hasFormulaSource && $locationCount > 1)
? intdiv($quote->items->count(), $locationCount)
: 0;
foreach ($quote->items as $index => $quoteItem) {
$floorCode = null;
$symbolCode = null;
$locIdx = 0;
// 1순위: formula_source에서 인덱스 추출
$formulaSource = $quoteItem->formula_source ?? '';
if (preg_match('/product_(\d+)/', $formulaSource, $matches)) {
$locIdx = (int) $matches[1];
}
// 2순위: sort_order 기반 분배 (formula_source 없는 레거시 데이터)
if ($locIdx === 0 && $itemsPerLocation > 0) {
$locIdx = min(intdiv($index, $itemsPerLocation), $locationCount - 1);
}
// note에서 floor/code 파싱
$note = trim($quoteItem->note ?? '');
if ($note !== '' && $note !== '-' && $note !== '- -') {
$parts = preg_split('/\s+/', $note, 2);
$floorCode = ($parts[0] ?? null) !== '-' ? ($parts[0] ?? null) : null;
$symbolCode = ($parts[1] ?? null) !== '-' ? ($parts[1] ?? null) : null;
}
// 3순위: note에서 파싱 실패 시 calculation_inputs에서 가져오기
if (empty($floorCode) && empty($symbolCode)) {
if (isset($productItems[$locIdx])) {
$floorCode = $productItems[$locIdx]['floor'] ?? null;
$symbolCode = $productItems[$locIdx]['code'] ?? null;
} elseif (count($productItems) === 1) {
$floorCode = $productItems[0]['floor'] ?? null;
$symbolCode = $productItems[0]['code'] ?? null;
}
}
// note 파싱으로 locIdx 결정 (formula_source 없는 경우)
if ($locIdx === 0 && ! $itemsPerLocation && ! empty($floorCode) && ! empty($symbolCode)) {
foreach ($productItems as $pidx => $pItem) {
if (($pItem['floor'] ?? '') === ($floorCode ?? '') && ($pItem['code'] ?? '') === ($symbolCode ?? '')) {
$locIdx = $pidx;
break;
}
}
}
$order->items()->create([
'tenant_id' => $tenantId,
'order_node_id' => $nodeMap[$locIdx]->id ?? null,
'serial_no' => $index + 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),
'note' => $quoteItem->formula_category,
'sort_order' => $index,
]);
}
// 합계 재계산
$order->refresh();
$order->recalculateTotals()->save();
// 변경 후 데이터 스냅샷
$afterData = [
'site_name' => $order->site_name,
'client_name' => $order->client_name,
'total_amount' => $order->total_amount,
'items_count' => $order->items()->count(),
];
// 히스토리 생성
OrderHistory::create([
'tenant_id' => $tenantId,
'order_id' => $order->id,
'history_type' => 'quote_updated',
'content' => json_encode([
'message' => "견적 {$revision}차 수정으로 수주 정보가 업데이트되었습니다.",
'quote_id' => $quote->id,
'quote_number' => $quote->quote_number,
'revision' => $revision,
'before' => $beforeData,
'after' => $afterData,
], JSON_UNESCAPED_UNICODE),
'created_by' => $userId,
]);
return $this->loadDetailRelations($order);
});
}
/**
* 생산지시 생성 (공정별 작업지시 다중 생성)
*/
public function createProductionOrder(int $orderId, array $data)
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
// 수주 + 노드 조회
$order = Order::where('tenant_id', $tenantId)
->with(['items', 'rootNodes'])
->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'));
}
// 재고생산(STOCK): 절곡 공정에 모든 품목 직접 배정 (BOM 매칭 스킵)
$isStock = $order->order_type_code === Order::TYPE_STOCK;
$nodesBomMap = [];
if ($isStock) {
$bendingProcess = \App\Models\Process::where('tenant_id', $tenantId)
->where('process_name', '절곡')
->where('is_active', true)
->first();
if (! $bendingProcess) {
throw new BadRequestHttpException(__('error.order.bending_process_not_found'));
}
$itemsByProcess = [
$bendingProcess->id => [
'process_id' => $bendingProcess->id,
'items' => $order->items->all(),
],
];
} else {
// 기존 로직: order_nodes의 BOM 결과를 기반으로 공정별 자동 분류
$bomItemIds = [];
foreach ($order->rootNodes as $node) {
$bomResult = $node->options['bom_result'] ?? [];
$bomItems = $bomResult['items'] ?? [];
foreach ($bomItems as $bomItem) {
if (! empty($bomItem['item_id'])) {
$bomItemIds[] = $bomItem['item_id'];
$nodesBomMap[$node->id][$bomItem['item_name']] = $bomItem;
}
}
}
$bomItemIds = array_unique($bomItemIds);
// process_items 테이블에서 item_id → process_id 매핑 조회
$itemProcessMap = [];
if (! empty($bomItemIds)) {
$processItems = DB::table('process_items as pi')
->join('processes as p', 'pi.process_id', '=', 'p.id')
->where('p.tenant_id', $tenantId)
->whereIn('pi.item_id', $bomItemIds)
->where('pi.is_active', true)
->select('pi.item_id', 'pi.process_id')
->get();
foreach ($processItems as $pi) {
$itemProcessMap[$pi->item_id] = $pi->process_id;
}
}
// item_code → item_id 매핑 구축 (fallback용 — N+1 방지를 위해 사전 일괄 조회)
$codeToIdMap = [];
if (! empty($bomItemIds)) {
$codeToIdRows = DB::table('items')
->where('tenant_id', $tenantId)
->whereIn('id', $bomItemIds)
->whereNull('deleted_at')
->select('id', 'code')
->get();
foreach ($codeToIdRows as $row) {
$codeToIdMap[$row->code] = $row->id;
}
}
// order_items의 item_code로 추가 매핑 사전 구축 (루프 내 DB 조회 방지)
$orderItemCodes = $order->items->pluck('item_code')->filter()->unique()->values()->all();
$unmappedCodes = array_diff($orderItemCodes, array_keys($codeToIdMap));
if (! empty($unmappedCodes)) {
$extraRows = DB::table('items')
->where('tenant_id', $tenantId)
->whereIn('code', $unmappedCodes)
->whereNull('deleted_at')
->select('id', 'code')
->get();
foreach ($extraRows as $row) {
$codeToIdMap[$row->code] = $row->id;
}
}
// 사전 매핑된 item_id에 대한 process_items도 일괄 조회
$allResolvedIds = array_values(array_unique(array_merge(
array_keys($itemProcessMap),
array_values($codeToIdMap)
)));
$unmappedProcessIds = array_diff($allResolvedIds, array_keys($itemProcessMap));
if (! empty($unmappedProcessIds)) {
$extraProcessItems = DB::table('process_items')
->whereIn('item_id', $unmappedProcessIds)
->where('is_active', true)
->select('item_id', 'process_id')
->get();
foreach ($extraProcessItems as $pi) {
$itemProcessMap[$pi->item_id] = $pi->process_id;
}
}
// order_items를 공정별로 그룹화 (BOM item_id → process 매핑 활용)
$itemsByProcess = [];
foreach ($order->items as $orderItem) {
$processId = null;
// 1. order_item의 item_id가 있으면 직접 매핑
if ($orderItem->item_id && isset($itemProcessMap[$orderItem->item_id])) {
$processId = $itemProcessMap[$orderItem->item_id];
}
// 2. item_id가 없으면 노드의 BOM에서 item_name으로 찾기
elseif ($orderItem->order_node_id && isset($nodesBomMap[$orderItem->order_node_id])) {
$nodeBom = $nodesBomMap[$orderItem->order_node_id];
$bomItem = $nodeBom[$orderItem->item_name] ?? null;
if ($bomItem && ! empty($bomItem['item_id']) && isset($itemProcessMap[$bomItem['item_id']])) {
$processId = $itemProcessMap[$bomItem['item_id']];
}
}
// 3. fallback: 사전 구축된 맵에서 item_code → process 매핑 (N+1 제거)
if ($processId === null && $orderItem->item_code) {
$resolvedId = $codeToIdMap[$orderItem->item_code] ?? null;
if ($resolvedId && isset($itemProcessMap[$resolvedId])) {
$processId = $itemProcessMap[$resolvedId];
}
}
$key = $processId ?? 'none';
if (! isset($itemsByProcess[$key])) {
$itemsByProcess[$key] = [
'process_id' => $processId,
'items' => [],
];
}
$itemsByProcess[$key]['items'][] = $orderItem;
}
}
return DB::transaction(function () use ($order, $data, $tenantId, $userId, $itemsByProcess, $nodesBomMap, $isStock) {
$workOrders = [];
// 담당자 ID 배열 처리 (assignee_ids 우선, fallback으로 assignee_id)
$assigneeIds = $data['assignee_ids'] ?? [];
if (empty($assigneeIds) && ! empty($data['assignee_id'])) {
$assigneeIds = [$data['assignee_id']];
}
$assigneeIds = array_unique(array_filter($assigneeIds));
$primaryAssigneeId = $assigneeIds[0] ?? null;
foreach ($itemsByProcess as $key => $group) {
$processId = $group['process_id'];
$items = $group['items'];
// 작업지시번호 생성
$workOrderNo = $this->generateWorkOrderNo($tenantId);
// 공정 옵션 초기화 (보조 공정 플래그 포함)
$workOrderOptions = null;
$process = null;
if ($processId) {
$process = \App\Models\Process::find($processId);
if ($process && ! empty($process->options['is_auxiliary'])) {
$workOrderOptions = ['is_auxiliary' => true];
}
// 이 작업지시에 포함되는 노드 ID만 추출
$nodeIds = collect($items)
->pluck('order_node_id')
->filter()
->unique()
->values()
->all();
$buildResult = app(BendingInfoBuilder::class)->build($order, $processId, $nodeIds ?: null);
if ($buildResult) {
$workOrderOptions = array_merge($workOrderOptions ?? [], ['bending_info' => $buildResult['bending_info']]);
}
}
// team_id 결정: 명시적 전달값 > 공정 담당부서 자동 매핑
$teamId = $data['team_id'] ?? null;
if (! $teamId && $process && $process->department) {
$teamId = DB::table('departments')
->where('tenant_id', $tenantId)
->where('name', $process->department)
->value('id');
}
// priority 결정: 문자열 → 숫자 변환 (urgent=1, high=4, normal=7)
$priorityMap = ['urgent' => 1, 'high' => 4, 'normal' => 7];
$priority = is_numeric($data['priority'] ?? null)
? (int) $data['priority']
: ($priorityMap[$data['priority'] ?? 'normal'] ?? 7);
// 작업지시 생성
$workOrder = WorkOrder::create([
'tenant_id' => $tenantId,
'work_order_no' => $workOrderNo,
'sales_order_id' => $order->id,
'project_name' => $isStock ? '재고생산' : ($order->site_name ?? $order->client_name),
'process_id' => $processId,
'status' => (! empty($assigneeIds) || $teamId) ? WorkOrder::STATUS_WAITING : WorkOrder::STATUS_UNASSIGNED,
'priority' => $priority,
'assignee_id' => $primaryAssigneeId,
'team_id' => $teamId,
'scheduled_date' => $data['scheduled_date'] ?? ($isStock ? now()->toDateString() : $order->delivery_date),
'memo' => $data['memo'] ?? null,
'options' => $workOrderOptions,
'is_active' => true,
'created_by' => $userId,
'updated_by' => $userId,
]);
// 다중 담당자 저장 (work_order_assignees)
foreach ($assigneeIds as $index => $assigneeId) {
$workOrder->assignees()->create([
'tenant_id' => $tenantId,
'user_id' => $assigneeId,
'is_primary' => $index === 0,
]);
}
// work_order_items에 아이템 추가
$sortOrder = 1;
foreach ($items as $orderItem) {
// item_id 결정: order_item에 있으면 사용, 없으면 item_code로 조회, 최후에 BOM에서 가져오기
$itemId = $orderItem->item_id;
if (! $itemId && $orderItem->item_code) {
$foundItem = Item::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->where('code', $orderItem->item_code)
->first();
$itemId = $foundItem?->id;
}
if (! $itemId && $orderItem->order_node_id && isset($nodesBomMap[$orderItem->order_node_id])) {
$bomItem = $nodesBomMap[$orderItem->order_node_id][$orderItem->item_name] ?? null;
$itemId = $bomItem['item_id'] ?? null;
}
// 수주 품목의 노드에서 options(사이즈 등) 조합
$nodeOptions = [];
if ($orderItem->order_node_id) {
$node = $order->rootNodes->firstWhere('id', $orderItem->order_node_id);
$nodeOptions = $node ? ($node->options ?? []) : [];
}
// slat_info: nodeOptions에 있으면 사용, 없으면 bom_result에서 추출 (하위 호환)
$slatInfo = $nodeOptions['slat_info'] ?? null;
if (! $slatInfo && isset($nodeOptions['bom_result'])) {
$slatInfo = $this->extractSlatInfoFromBom($nodeOptions['bom_result']);
}
// slat_info의 joint_bar가 0이면 레거시 공식으로 자동 계산
$woWidth = $nodeOptions['width'] ?? $nodeOptions['open_width'] ?? null;
if ($slatInfo && ($slatInfo['joint_bar'] ?? 0) <= 0 && $woWidth > 0) {
$qty = (int) $orderItem->quantity;
$slatInfo['joint_bar'] = (2 + (int) floor(((float) $woWidth - 500) / 1000)) * $qty;
}
$woHeight = $nodeOptions['height'] ?? $nodeOptions['open_height'] ?? null;
$woItemOptions = array_filter([
'floor' => $orderItem->floor_code,
'code' => $orderItem->symbol_code,
'product_code' => ! empty($nodeOptions['product_code']) ? $nodeOptions['product_code'] : null,
'product_name' => ! empty($nodeOptions['product_name']) ? $nodeOptions['product_name'] : null,
'width' => $woWidth,
'height' => $woHeight,
'cutting_info' => $nodeOptions['cutting_info'] ?? null,
'slat_info' => $slatInfo,
'bending_info' => $nodeOptions['bending_info'] ?? null,
'wip_info' => $nodeOptions['wip_info'] ?? null,
], fn ($v) => $v !== null);
// 절곡 공정: 개소별 dynamic_bom 생성
if (! empty($buildResult['context']) && $woWidth && $woHeight) {
$dynamicBom = app(BendingInfoBuilder::class)->buildDynamicBomForItem(
$buildResult['context'],
(int) $woWidth,
(int) $woHeight,
(int) ($orderItem->quantity ?? 1),
$tenantId,
);
if (! empty($dynamicBom)) {
$woItemOptions['dynamic_bom'] = $dynamicBom;
}
}
DB::table('work_order_items')->insert([
'tenant_id' => $tenantId,
'work_order_id' => $workOrder->id,
'source_order_item_id' => $orderItem->id,
'item_id' => $itemId,
'item_name' => $orderItem->item_name,
'specification' => $orderItem->specification,
'quantity' => (int) $orderItem->quantity,
'unit' => $orderItem->unit,
'sort_order' => $sortOrder++,
'status' => 'pending',
'options' => ! empty($woItemOptions) ? json_encode($woItemOptions) : null,
'created_at' => now(),
'updated_at' => now(),
]);
}
$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' => $this->loadDetailRelations($order),
];
});
}
/**
* BOM 결과에서 슬랫 공정 정보 추출
*
* 조인트바 수량: BOM items에서 '조인트바' 항목의 quantity, 없으면 inputVariables의 joint_bar_qty
* 방화유리 수량: 프론트 입력값(glass_qty) 우선, 없으면 QTY(개소당 수량) 사용
*/
private function extractSlatInfoFromBom(?array $bomResult, array $locItem = []): ?array
{
if (! $bomResult) {
return null;
}
$bomVars = $bomResult['variables'] ?? [];
$bomItems = $bomResult['items'] ?? [];
$productType = $bomVars['product_type'] ?? 'screen';
// 스크린 전용 제품은 슬랫 정보 불필요
if ($productType === 'screen') {
return null;
}
// 조인트바 수량: BOM items에서 추출, 없으면 입력 변수에서
$jointBarQty = (int) ($bomVars['joint_bar_qty'] ?? 0);
if ($jointBarQty === 0) {
foreach ($bomItems as $item) {
if (str_contains($item['item_name'] ?? '', '조인트바')) {
$jointBarQty = (int) ($item['quantity'] ?? 0);
break;
}
}
}
// 프론트 미전달 시 레거시 5130 자동 계산 (Slat_updateCo76)
// col76 = (2 + floor((제작가로 - 500) / 1000)) * 셔터수량
if ($jointBarQty <= 0) {
$width = (float) ($bomVars['W0'] ?? $locItem['width'] ?? 0);
$quantity = (int) ($bomVars['QTY'] ?? $locItem['quantity'] ?? 1);
if ($width > 0) {
$jointBarQty = (2 + (int) floor(($width - 500) / 1000)) * $quantity;
}
}
// 방화유리 수량: 투시창 선택 시에만 유효 (프론트 입력값 또는 견적 BOM 변수)
// 레거시: col4_quartz='투시창'일 때만 col14에 수량 표시
$glassQty = (int) ($locItem['glass_qty'] ?? $bomVars['glass_qty'] ?? 0);
return [
'joint_bar' => $jointBarQty,
'glass_qty' => $glassQty,
];
}
/**
* 작업지시번호 자동 생성
*/
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' => $this->loadDetailRelations($order),
'previous_status' => $previousStatus,
'deleted_sale_id' => $deletedSaleId,
];
});
}
/**
* 생산지시 되돌리기
*
* force=true: 개발 모드 - 모든 데이터 hard delete + 재고 복구 (운영환경 차단)
* force=false: 운영 모드 - 작업지시 취소 상태 변경 + 재고 역분개 (데이터 보존)
*/
public function revertProductionOrder(int $orderId, bool $force = false, ?string $reason = null): array
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
// force=true는 운영 환경에서 차단
if ($force && app()->environment('production')) {
throw new \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException(__('error.forbidden'));
}
// 운영 모드에서는 reason 필수
if (! $force && empty($reason)) {
throw new BadRequestHttpException(__('error.order.cancel_reason_required'));
}
// 수주 조회
$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'));
}
if ($force) {
return $this->revertProductionOrderForce($order, $tenantId, $userId);
}
return $this->revertProductionOrderCancel($order, $tenantId, $userId, $reason);
}
/**
* 생산지시 되돌리기 - 개발 모드 (hard delete)
*/
private function revertProductionOrderForce(Order $order, int $tenantId, int $userId): array
{
return DB::transaction(function () use ($order, $tenantId, $userId) {
$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,
'material_inputs' => 0,
'documents' => 0,
'step_progress' => 0,
'assignees' => 0,
'bending_details' => 0,
'issues' => 0,
];
if (count($workOrderIds) > 0) {
// 1. 자재 투입 재고 복구 + 삭제
$materialInputs = WorkOrderMaterialInput::whereIn('work_order_id', $workOrderIds)->get();
if ($materialInputs->isNotEmpty()) {
$stockService = app(StockService::class);
foreach ($materialInputs as $input) {
try {
$stockService->increaseToLot(
stockLotId: $input->stock_lot_id,
qty: (float) $input->qty,
reason: 'work_order_input_cancel',
referenceId: $input->work_order_id
);
} catch (\Exception $e) {
Log::warning('생산지시 되돌리기: 재고 복원 실패', [
'input_id' => $input->id,
'stock_lot_id' => $input->stock_lot_id,
'error' => $e->getMessage(),
]);
}
}
$deletedCounts['material_inputs'] = WorkOrderMaterialInput::whereIn('work_order_id', $workOrderIds)->delete();
}
// 2. 문서 삭제 (검사 성적서, 작업일지 등)
$documentIds = Document::where('linkable_type', 'work_order')
->whereIn('linkable_id', $workOrderIds)
->pluck('id')
->toArray();
if (count($documentIds) > 0) {
DocumentData::whereIn('document_id', $documentIds)->delete();
DocumentApproval::whereIn('document_id', $documentIds)->delete();
$deletedCounts['documents'] = Document::whereIn('id', $documentIds)->forceDelete();
}
// 3. 출하 정보에서 작업지시 참조 해제 (출하 자체는 보존)
DB::table('shipments')
->whereIn('work_order_id', $workOrderIds)
->update(['work_order_id' => null]);
// 4. 작업지시 부속 데이터 삭제
$deletedCounts['step_progress'] = DB::table('work_order_step_progress')
->whereIn('work_order_id', $workOrderIds)
->delete();
$deletedCounts['assignees'] = DB::table('work_order_assignees')
->whereIn('work_order_id', $workOrderIds)
->delete();
$deletedCounts['bending_details'] = DB::table('work_order_bending_details')
->whereIn('work_order_id', $workOrderIds)
->delete();
$deletedCounts['issues'] = DB::table('work_order_issues')
->whereIn('work_order_id', $workOrderIds)
->delete();
// 5. 작업결과 삭제
$deletedCounts['work_results'] = DB::table('work_results')
->whereIn('work_order_id', $workOrderIds)
->delete();
// 6. 작업지시 품목 삭제
$deletedCounts['work_order_items'] = DB::table('work_order_items')
->whereIn('work_order_id', $workOrderIds)
->delete();
// 7. 작업지시 삭제
$deletedCounts['work_orders'] = WorkOrder::whereIn('id', $workOrderIds)->delete();
}
// 8. 수주 상태를 CONFIRMED로 되돌리기
$previousStatus = $order->status_code;
$order->status_code = Order::STATUS_CONFIRMED;
$order->updated_by = $userId;
$order->save();
return [
'order' => $this->loadDetailRelations($order),
'deleted_counts' => $deletedCounts,
'previous_status' => $previousStatus,
];
});
}
/**
* 생산지시 되돌리기 - 운영 모드 (취소 상태 변경, 데이터 보존)
*/
private function revertProductionOrderCancel(Order $order, int $tenantId, int $userId, string $reason): array
{
return DB::transaction(function () use ($order, $tenantId, $userId, $reason) {
$workOrders = WorkOrder::where('tenant_id', $tenantId)
->where('sales_order_id', $order->id)
->get();
$cancelledCount = 0;
$skippedIds = [];
$stockService = app(StockService::class);
foreach ($workOrders as $workOrder) {
// completed/shipped 상태는 취소 거부
if (in_array($workOrder->status, [WorkOrder::STATUS_COMPLETED, WorkOrder::STATUS_SHIPPED])) {
$skippedIds[] = $workOrder->id;
continue;
}
// 작업지시 상태를 cancelled로 변경
$workOrder->status = WorkOrder::STATUS_CANCELLED;
$workOrder->updated_by = $userId;
// options에 취소 정보 기록
$options = $workOrder->options ?? [];
$options['cancelled_at'] = now()->toIso8601String();
$options['cancelled_by'] = $userId;
$options['cancel_reason'] = $reason;
$workOrder->options = $options;
$workOrder->save();
// 자재 투입분 재고 역분개
$materialInputs = WorkOrderMaterialInput::where('work_order_id', $workOrder->id)->get();
foreach ($materialInputs as $input) {
try {
$stockService->increaseToLot(
stockLotId: $input->stock_lot_id,
qty: (float) $input->qty,
reason: 'work_order_cancel',
referenceId: $workOrder->id
);
} catch (\Exception $e) {
Log::warning('생산지시 취소: 재고 복원 실패', [
'input_id' => $input->id,
'stock_lot_id' => $input->stock_lot_id,
'error' => $e->getMessage(),
]);
}
}
$cancelledCount++;
}
// 수주 상태를 CONFIRMED로 복원
$previousStatus = $order->status_code;
$order->status_code = Order::STATUS_CONFIRMED;
$order->updated_by = $userId;
$order->save();
return [
'order' => $this->loadDetailRelations($order),
'cancelled_count' => $cancelledCount,
'skipped_count' => count($skippedIds),
'skipped_ids' => $skippedIds,
'previous_status' => $previousStatus,
];
});
}
/**
* 수주의 절곡 BOM 품목별 재고 현황 조회
*
* order_items에서 item_category='BENDING'인 품목을 추출하고
* 각 품목의 재고 가용량/부족량을 반환합니다.
*/
public function checkBendingStockForOrder(int $orderId): array
{
$tenantId = $this->tenantId();
$order = Order::where('tenant_id', $tenantId)
->with(['items'])
->find($orderId);
if (! $order) {
throw new NotFoundHttpException(__('error.not_found'));
}
// order_items에서 item_id가 있는 품목의 ID 수집 + 수량 합산
$itemQtyMap = []; // item_id => total_qty
foreach ($order->items as $orderItem) {
$itemId = $orderItem->item_id;
if (! $itemId) {
continue;
}
$qty = (float) ($orderItem->quantity ?? 0);
if ($qty <= 0) {
continue;
}
$itemQtyMap[$itemId] = ($itemQtyMap[$itemId] ?? 0) + $qty;
}
if (empty($itemQtyMap)) {
return [];
}
// items 테이블에서 item_category = 'BENDING'인 것만 필터
$bendingItems = DB::table('items')
->where('tenant_id', $tenantId)
->whereIn('id', array_keys($itemQtyMap))
->where('item_category', 'BENDING')
->whereNull('deleted_at')
->select('id', 'code', 'name', 'unit')
->get();
if ($bendingItems->isEmpty()) {
return [];
}
// 배치 조회로 N+1 방지 (루프 내 개별 Stock 조회 제거)
$bendingItemIds = $bendingItems->pluck('id')->all();
$stocksMap = \App\Models\Tenants\Stock::where('tenant_id', $tenantId)
->whereIn('item_id', $bendingItemIds)
->get()
->keyBy('item_id');
$result = [];
foreach ($bendingItems as $item) {
$neededQty = $itemQtyMap[$item->id];
$stock = $stocksMap->get($item->id);
$availableQty = $stock ? (float) $stock->available_qty : 0;
$reservedQty = $stock ? (float) $stock->reserved_qty : 0;
$stockQty = $stock ? (float) $stock->stock_qty : 0;
$shortfallQty = max(0, $neededQty - $availableQty);
$result[] = [
'item_id' => $item->id,
'item_code' => $item->code,
'item_name' => $item->name,
'unit' => $item->unit,
'needed_qty' => $neededQty,
'stock_qty' => $stockQty,
'reserved_qty' => $reservedQty,
'available_qty' => $availableQty,
'shortfall_qty' => $shortfallQty,
'status' => $shortfallQty > 0 ? 'insufficient' : 'sufficient',
];
}
return $result;
}
}