- ProductionOrder 전용 엔드포인트 (목록/통계/상세) - 재고생산 보조공정 일반 워크플로우에서 분리 - 자재투입 replace 모드 + bom_group_key 개별 저장 - 공정단계 options 컬럼 추가 (검사 설정/범위) - 셔터박스 prefix isStandard 파라미터 제거 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
286 lines
9.8 KiB
PHP
286 lines
9.8 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Orders\Order;
|
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
|
|
|
class ProductionOrderService extends Service
|
|
{
|
|
/**
|
|
* 생산지시 대상 상태 코드
|
|
*/
|
|
private const PRODUCTION_STATUSES = [
|
|
Order::STATUS_IN_PROGRESS,
|
|
Order::STATUS_IN_PRODUCTION,
|
|
Order::STATUS_PRODUCED,
|
|
Order::STATUS_SHIPPING,
|
|
Order::STATUS_SHIPPED,
|
|
];
|
|
|
|
/**
|
|
* 생산지시 목록 조회
|
|
*/
|
|
public function index(array $params): LengthAwarePaginator
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$query = Order::query()
|
|
->where('tenant_id', $tenantId)
|
|
->whereIn('status_code', self::PRODUCTION_STATUSES)
|
|
->with(['client', 'workOrders.process', 'workOrders.assignees.user'])
|
|
->withCount([
|
|
'workOrders' => fn ($q) => $q->whereNotNull('process_id')
|
|
->where(fn ($q2) => $q2->whereNull('options->is_auxiliary')
|
|
->orWhere('options->is_auxiliary', false)),
|
|
'nodes',
|
|
]);
|
|
|
|
// 검색어 필터
|
|
if (! empty($params['search'])) {
|
|
$search = $params['search'];
|
|
$query->where(function ($q) use ($search) {
|
|
$q->where('order_no', 'like', "%{$search}%")
|
|
->orWhere('client_name', 'like', "%{$search}%")
|
|
->orWhere('site_name', 'like', "%{$search}%");
|
|
});
|
|
}
|
|
|
|
// 생산 상태 필터
|
|
if (! empty($params['production_status'])) {
|
|
switch ($params['production_status']) {
|
|
case 'waiting':
|
|
$query->where('status_code', Order::STATUS_IN_PROGRESS);
|
|
break;
|
|
case 'in_production':
|
|
$query->where('status_code', Order::STATUS_IN_PRODUCTION);
|
|
break;
|
|
case 'completed':
|
|
$query->whereIn('status_code', [
|
|
Order::STATUS_PRODUCED,
|
|
Order::STATUS_SHIPPING,
|
|
Order::STATUS_SHIPPED,
|
|
]);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// 정렬
|
|
$sortBy = $params['sort_by'] ?? 'created_at';
|
|
$sortDir = $params['sort_dir'] ?? 'desc';
|
|
$query->orderBy($sortBy, $sortDir);
|
|
|
|
// 페이지네이션
|
|
$perPage = $params['per_page'] ?? 20;
|
|
$result = $query->paginate($perPage);
|
|
|
|
// 가공 필드 추가
|
|
$result->getCollection()->transform(function (Order $order) {
|
|
$minCreatedAt = $order->workOrders->min('created_at');
|
|
$order->production_ordered_at = $minCreatedAt
|
|
? $minCreatedAt->format('Y-m-d')
|
|
: null;
|
|
|
|
// 개소수 (order_nodes 수)
|
|
$order->node_count = $order->nodes_count ?? 0;
|
|
|
|
// 주요 생산 공정 WO만 (구매품 + 보조 공정 제외)
|
|
$productionWOs = $this->filterMainProductionWOs($order->workOrders);
|
|
$order->work_order_progress = [
|
|
'total' => $productionWOs->count(),
|
|
'completed' => $productionWOs->where('status', 'completed')->count()
|
|
+ $productionWOs->where('status', 'shipped')->count(),
|
|
'in_progress' => $productionWOs->where('status', 'in_progress')->count(),
|
|
];
|
|
|
|
// 프론트 탭용 production_status 매핑
|
|
$order->production_status = $this->mapProductionStatus($order->status_code);
|
|
|
|
return $order;
|
|
});
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* 상태별 통계
|
|
*/
|
|
public function stats(): array
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$waiting = Order::where('tenant_id', $tenantId)
|
|
->where('status_code', Order::STATUS_IN_PROGRESS)
|
|
->count();
|
|
|
|
$inProduction = Order::where('tenant_id', $tenantId)
|
|
->where('status_code', Order::STATUS_IN_PRODUCTION)
|
|
->count();
|
|
|
|
$completed = Order::where('tenant_id', $tenantId)
|
|
->whereIn('status_code', [
|
|
Order::STATUS_PRODUCED,
|
|
Order::STATUS_SHIPPING,
|
|
Order::STATUS_SHIPPED,
|
|
])
|
|
->count();
|
|
|
|
return [
|
|
'total' => $waiting + $inProduction + $completed,
|
|
'waiting' => $waiting,
|
|
'in_production' => $inProduction,
|
|
'completed' => $completed,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 생산지시 상세 조회
|
|
*/
|
|
public function show(int $orderId): array
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$order = Order::query()
|
|
->where('tenant_id', $tenantId)
|
|
->whereIn('status_code', self::PRODUCTION_STATUSES)
|
|
->with([
|
|
'client',
|
|
'workOrders.process',
|
|
'workOrders.items',
|
|
'workOrders.assignees.user',
|
|
'nodes',
|
|
])
|
|
->withCount('nodes')
|
|
->findOrFail($orderId);
|
|
|
|
// 생산지시일 (날짜만)
|
|
$minCreatedAt = $order->workOrders->min('created_at');
|
|
$order->production_ordered_at = $minCreatedAt
|
|
? $minCreatedAt->format('Y-m-d')
|
|
: null;
|
|
$order->production_status = $this->mapProductionStatus($order->status_code);
|
|
|
|
// 주요 생산 공정 WO만 필터 (구매품 + 보조 공정 제외)
|
|
$productionWorkOrders = $this->filterMainProductionWOs($order->workOrders);
|
|
|
|
// WorkOrder 진행 현황 (생산 공정 기준)
|
|
$workOrderProgress = [
|
|
'total' => $productionWorkOrders->count(),
|
|
'completed' => $productionWorkOrders->where('status', 'completed')->count()
|
|
+ $productionWorkOrders->where('status', 'shipped')->count(),
|
|
'in_progress' => $productionWorkOrders->where('status', 'in_progress')->count(),
|
|
];
|
|
|
|
// WorkOrder 목록 가공 (생산 공정만)
|
|
$workOrders = $productionWorkOrders->values()->map(function ($wo) {
|
|
return [
|
|
'id' => $wo->id,
|
|
'work_order_no' => $wo->work_order_no,
|
|
'process_name' => $wo->process?->process_name ?? '',
|
|
'quantity' => $wo->items->count(),
|
|
'status' => $wo->status,
|
|
'assignees' => $wo->assignees->map(fn ($a) => $a->user?->name ?? '')->filter()->values()->toArray(),
|
|
];
|
|
});
|
|
|
|
// BOM 데이터 (order_nodes에서 추출)
|
|
$bomProcessGroups = $this->extractBomProcessGroups($order->nodes);
|
|
|
|
return [
|
|
'order' => $order->makeHidden(['workOrders', 'nodes']),
|
|
'production_ordered_at' => $order->production_ordered_at,
|
|
'production_status' => $order->production_status,
|
|
'node_count' => $order->nodes_count ?? 0,
|
|
'work_order_progress' => $workOrderProgress,
|
|
'work_orders' => $workOrders,
|
|
'bom_process_groups' => $bomProcessGroups,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Order status_code → 프론트 production_status 매핑
|
|
*/
|
|
private function mapProductionStatus(string $statusCode): string
|
|
{
|
|
return match ($statusCode) {
|
|
Order::STATUS_IN_PROGRESS => 'waiting',
|
|
Order::STATUS_IN_PRODUCTION => 'in_production',
|
|
default => 'completed',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* order_nodes에서 BOM 공정 분류 추출
|
|
*
|
|
* bom_result 구조: { items: [...], success, subtotals, ... }
|
|
* 각 item: { item_id, item_code, item_name, process_group, specification, quantity, unit, ... }
|
|
*/
|
|
private function extractBomProcessGroups($nodes): array
|
|
{
|
|
$groups = [];
|
|
|
|
foreach ($nodes as $node) {
|
|
$bomResult = $node->options['bom_result'] ?? null;
|
|
if (! $bomResult || ! is_array($bomResult)) {
|
|
continue;
|
|
}
|
|
|
|
// bom_result.items 배열에서 추출
|
|
$items = $bomResult['items'] ?? [];
|
|
if (! is_array($items)) {
|
|
continue;
|
|
}
|
|
|
|
$nodeName = $node->name ?? '';
|
|
|
|
foreach ($items as $item) {
|
|
if (! is_array($item)) {
|
|
continue;
|
|
}
|
|
|
|
$processGroup = $item['process_group'] ?? $item['category_group'] ?? '기타';
|
|
|
|
if (! isset($groups[$processGroup])) {
|
|
$groups[$processGroup] = [
|
|
'process_name' => $processGroup,
|
|
'items' => [],
|
|
];
|
|
}
|
|
|
|
$groups[$processGroup]['items'][] = [
|
|
'id' => $item['item_id'] ?? null,
|
|
'item_code' => $item['item_code'] ?? '',
|
|
'item_name' => $item['item_name'] ?? '',
|
|
'spec' => $item['specification'] ?? '',
|
|
'unit' => $item['unit'] ?? '',
|
|
'quantity' => $item['quantity'] ?? 0,
|
|
'unit_price' => $item['unit_price'] ?? 0,
|
|
'total_price' => $item['total_price'] ?? 0,
|
|
'node_name' => $nodeName,
|
|
];
|
|
}
|
|
}
|
|
|
|
return array_values($groups);
|
|
}
|
|
|
|
/**
|
|
* 주요 생산 공정 WO만 필터 (구매품/서비스 + 보조 공정 제외)
|
|
*
|
|
* 제외 대상:
|
|
* - process_id가 null인 WO (구매품/서비스)
|
|
* - options.is_auxiliary가 true인 WO (재고생산 등 보조 공정)
|
|
*/
|
|
private function filterMainProductionWOs($workOrders): \Illuminate\Support\Collection
|
|
{
|
|
return $workOrders->filter(function ($wo) {
|
|
if (empty($wo->process_id)) {
|
|
return false;
|
|
}
|
|
$options = is_array($wo->options) ? $wo->options : (json_decode($wo->options, true) ?? []);
|
|
|
|
return empty($options['is_auxiliary']);
|
|
});
|
|
}
|
|
}
|