- OrderService: glass_qty에서 quantity 폴백 제거 (투시창 선택 시에만 유효) - OrderService: createProductionOrder()에서 절곡 공정 bending_info 자동 생성 - OrderService: formula_source 없는 레거시 데이터의 sort_order 기반 개소 분배 - OrderService: note 파싱에서 '-' 단독값 무시 처리 - FormulaEvaluatorService: 철재 W1 계산 (W0+160 → W0+110) 레거시 일치 - WorkOrderService: store()에서 order_node_id null 품목용 rootNodes fallback 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1519 lines
61 KiB
PHP
1519 lines
61 KiB
PHP
<?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
|
|
) {}
|
|
|
|
/**
|
|
* 목록 조회 (검색/필터링/페이징)
|
|
*/
|
|
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(): 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'),
|
|
'rootNodes' => fn ($q) => $q->withRecursiveChildren(),
|
|
'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) {
|
|
// 수주번호 자동 생성
|
|
$pairCode = $data['pair_code'] ?? null;
|
|
unset($data['pair_code']);
|
|
$data['order_no'] = $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;
|
|
|
|
$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,
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 품목 저장
|
|
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;
|
|
}
|
|
}
|
|
|
|
// floor_code/symbol_code로 노드 매칭
|
|
if (! empty($nodeMap) && ! empty($productItems)) {
|
|
$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) {
|
|
$item['order_node_id'] = $nodeMap[$pidx]->id ?? null;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$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();
|
|
$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 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'];
|
|
}
|
|
|
|
/**
|
|
* 수주번호 자동 생성
|
|
*
|
|
* 채번규칙이 있으면 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);
|
|
}
|
|
|
|
/**
|
|
* 견적에서 수주 생성
|
|
*/
|
|
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;
|
|
$orderNo = $this->generateOrderNo($tenantId, $pairCode);
|
|
|
|
// 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 ?? [];
|
|
$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);
|
|
}
|
|
|
|
// calculation_inputs에서 floor/code 가져오기
|
|
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;
|
|
}
|
|
|
|
// calculation_inputs에서 못 찾은 경우 note에서 파싱 시도
|
|
if (empty($floorCode) && empty($symbolCode)) {
|
|
$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;
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
|
|
// 견적 상태를 '수주전환완료'로 변경
|
|
$quote->update([
|
|
'status' => Quote::STATUS_CONVERTED,
|
|
'order_id' => $order->id,
|
|
'updated_by' => $userId,
|
|
]);
|
|
|
|
return $order->load(['client:id,name', 'items', 'quote:id,quote_number']);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 견적 변경사항을 수주에 동기화
|
|
*
|
|
* 견적이 수정되면 연결된 수주도 함께 업데이트하고 히스토리를 생성합니다.
|
|
*
|
|
* @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 $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', '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'));
|
|
}
|
|
|
|
// order_nodes의 BOM 결과를 기반으로 공정별 자동 분류
|
|
$bomItemIds = [];
|
|
$nodesBomMap = []; // node_id => [item_name => bom_item]
|
|
|
|
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용)
|
|
$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를 공정별로 그룹화 (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로 items 마스터 조회 → process_items 매핑
|
|
if ($processId === null && $orderItem->item_code) {
|
|
$resolvedId = $codeToIdMap[$orderItem->item_code] ?? null;
|
|
if (! $resolvedId) {
|
|
$resolvedId = DB::table('items')
|
|
->where('tenant_id', $tenantId)
|
|
->where('code', $orderItem->item_code)
|
|
->whereNull('deleted_at')
|
|
->value('id');
|
|
if ($resolvedId) {
|
|
$codeToIdMap[$orderItem->item_code] = $resolvedId;
|
|
}
|
|
}
|
|
if ($resolvedId && isset($itemProcessMap[$resolvedId])) {
|
|
$processId = $itemProcessMap[$resolvedId];
|
|
} elseif ($resolvedId) {
|
|
// process_items에서도 조회
|
|
$pi = DB::table('process_items')
|
|
->where('item_id', $resolvedId)
|
|
->where('is_active', true)
|
|
->value('process_id');
|
|
if ($pi) {
|
|
$processId = $pi;
|
|
$itemProcessMap[$resolvedId] = $pi;
|
|
}
|
|
}
|
|
}
|
|
|
|
$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) {
|
|
$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);
|
|
|
|
// 절곡 공정이면 bending_info 자동 생성
|
|
$workOrderOptions = null;
|
|
if ($processId) {
|
|
// 이 작업지시에 포함되는 노드 ID만 추출
|
|
$nodeIds = collect($items)
|
|
->pluck('order_node_id')
|
|
->filter()
|
|
->unique()
|
|
->values()
|
|
->all();
|
|
|
|
$bendingInfo = app(BendingInfoBuilder::class)->build($order, $processId, $nodeIds ?: null);
|
|
if ($bendingInfo) {
|
|
$workOrderOptions = ['bending_info' => $bendingInfo];
|
|
}
|
|
}
|
|
|
|
// 작업지시 생성
|
|
$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' => ! empty($assigneeIds) ? WorkOrder::STATUS_PENDING : WorkOrder::STATUS_UNASSIGNED,
|
|
'assignee_id' => $primaryAssigneeId,
|
|
'team_id' => $data['team_id'] ?? null,
|
|
'scheduled_date' => $data['scheduled_date'] ?? $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;
|
|
}
|
|
|
|
$woItemOptions = array_filter([
|
|
'floor' => $orderItem->floor_code,
|
|
'code' => $orderItem->symbol_code,
|
|
'width' => $woWidth,
|
|
'height' => $nodeOptions['height'] ?? $nodeOptions['open_height'] ?? null,
|
|
'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);
|
|
|
|
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' => $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' => $order->load(['client:id,name', 'items']),
|
|
];
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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' => $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,
|
|
'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' => $order->load(['client:id,name', 'items']),
|
|
'deleted_counts' => $deletedCounts,
|
|
'previous_status' => $previousStatus,
|
|
];
|
|
});
|
|
}
|
|
}
|