Files
sam-api/app/Services/QmsLotAuditService.php
김보곤 d4b10452f5 feat: [수주서] 절곡품 이미지 연동 — bending_items files 기반
- groupBendingParts에서 item_code prefix → bending_items 이미지 매칭
- getBendingItemImages: BD prefix 기반 files 테이블 사전 조회
- 각 bending_parts item에 image_url 포함하여 응답
2026-03-22 15:21:01 +09:00

937 lines
36 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\Orders\Order;
use App\Models\Production\WorkOrder;
use App\Models\Production\WorkOrderMaterialInput;
use App\Models\Qualitys\Inspection;
use App\Models\Qualitys\QualityDocument;
use App\Models\Qualitys\QualityDocumentLocation;
use App\Models\Qualitys\QualityDocumentOrder;
use App\Models\Tenants\Shipment;
use App\Models\Tenants\StockLot;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class QmsLotAuditService extends Service
{
/**
* 품질관리서 목록 (로트 추적 심사용)
* completed 상태의 품질관리서를 PerformanceReport 기반으로 필터링
*/
public function index(array $params): array
{
$query = QualityDocument::with([
'documentOrders.order.item',
'documentOrders.locations',
'performanceReport',
])
->where('status', QualityDocument::STATUS_COMPLETED)
->whereHas('performanceReport', fn ($pr) => $pr->where('confirmation_status', '!=', 'unconfirmed'));
// 연도 필터
if (! empty($params['year'])) {
$year = (int) $params['year'];
$query->whereHas('performanceReport', fn ($pr) => $pr->where('year', $year));
}
// 분기 필터
if (! empty($params['quarter'])) {
$quarter = (int) $params['quarter'];
$query->whereHas('performanceReport', fn ($pr) => $pr->where('quarter', $quarter));
}
// 검색어 필터
if (! empty($params['q'])) {
$term = trim($params['q']);
$query->where(function ($q) use ($term) {
$q->where('quality_doc_number', 'like', "%{$term}%")
->orWhere('site_name', 'like', "%{$term}%");
});
}
$query->orderByDesc('id');
$perPage = (int) ($params['per_page'] ?? 20);
$paginated = $query->paginate($perPage);
$items = $paginated->getCollection()->map(fn ($doc) => $this->transformReportToFrontend($doc));
return [
'items' => $items,
'current_page' => $paginated->currentPage(),
'last_page' => $paginated->lastPage(),
'per_page' => $paginated->perPage(),
'total' => $paginated->total(),
];
}
/**
* 품질관리서 상세 — 수주/개소 목록 (RouteItem[])
*/
public function show(int $id): array
{
$doc = QualityDocument::with([
'documentOrders.order',
'documentOrders.locations.orderItem',
])->findOrFail($id);
return $doc->documentOrders->map(fn ($docOrder) => $this->transformRouteToFrontend($docOrder, $doc))->values()->all();
}
/**
* 수주 로트별 8종 서류 목록 (Document[])
*/
public function routeDocuments(int $qualityDocumentOrderId): array
{
$docOrder = QualityDocumentOrder::with([
'order.workOrders.process',
'locations.orderItem',
'locations.document',
'qualityDocument',
])->findOrFail($qualityDocumentOrderId);
$order = $docOrder->order;
$qualityDoc = $docOrder->qualityDocument;
$workOrders = $order->workOrders;
$documents = [];
// 1. 수입검사 성적서 (IQC): WorkOrderMaterialInput → StockLot → lot_no → Inspection(IQC)
$investedLotIds = WorkOrderMaterialInput::whereIn('work_order_id', $workOrders->pluck('id'))
->pluck('stock_lot_id')
->unique();
$investedLotNos = StockLot::whereIn('id', $investedLotIds)
->whereNotNull('lot_no')
->pluck('lot_no')
->unique();
$iqcInspections = Inspection::where('inspection_type', 'IQC')
->whereIn('lot_no', $investedLotNos)
->where('status', 'completed')
->get();
$documents[] = $this->formatDocument('import', '수입검사 성적서', $iqcInspections);
// 2. 수주서
$documents[] = $this->formatDocument('order', '수주서', collect([$order]));
// 3. 작업일지 & 4. 중간검사 성적서 (인식 가능한 공정만 — 공정별 1개씩)
$recognizedWorkOrders = $workOrders->filter(function ($wo) {
$subType = $this->mapProcessToSubType($wo->process?->process_name);
return $subType !== null;
})->groupBy('process_id')->map(fn ($group) => $group->first());
$documents[] = $this->formatDocumentWithSubType('log', '작업일지', $recognizedWorkOrders);
$documents[] = $this->formatDocumentWithSubType('report', '중간검사 성적서', $recognizedWorkOrders);
// 5. 납품확인서
$shipments = $order->shipments()->get();
$documents[] = $this->formatDocument('confirmation', '납품확인서', $shipments);
// 6. 출고증
$documents[] = $this->formatDocument('shipping', '출고증', $shipments);
// 7. 제품검사 성적서 (FQC 문서 또는 inspection_data 완료건)
$locationsWithInspection = $docOrder->locations->filter(
fn ($loc) => $loc->document_id || $loc->inspection_status === 'completed'
);
$documents[] = $this->formatDocument('product', '제품검사 성적서', $locationsWithInspection);
// 8. 품질관리서 (파일 정보 포함)
$qualityDoc->loadMissing('file');
$qualityDocFormatted = $this->formatDocument('quality', '품질관리서', collect([$qualityDoc]));
// 파일 정보 추가
if ($qualityDoc->file) {
$qualityDocFormatted['file_id'] = $qualityDoc->file->id;
$qualityDocFormatted['file_name'] = $qualityDoc->file->display_name ?? $qualityDoc->file->original_name;
$qualityDocFormatted['file_size'] = $qualityDoc->file->file_size;
}
$documents[] = $qualityDocFormatted;
return $documents;
}
/**
* 서류 상세 조회 (2단계 로딩 — 모달 렌더링용)
*/
public function documentDetail(string $type, int $id): array
{
return match ($type) {
'import' => $this->getInspectionDetail($id, 'IQC'),
'order' => $this->getOrderDetail($id),
'log' => $this->getWorkOrderLogDetail($id),
'report' => $this->getWorkOrderLogDetail($id),
'confirmation', 'shipping' => $this->getShipmentDetail($id),
'product' => $this->getLocationDetail($id),
'quality' => $this->getQualityDocDetail($id),
default => throw new NotFoundHttpException(__('error.not_found')),
};
}
/**
* 개소별 로트 심사 확인 토글
*/
public function confirm(int $locationId, array $data): array
{
$location = QualityDocumentLocation::findOrFail($locationId);
$confirmed = (bool) $data['confirmed'];
$userId = $this->apiUserId();
DB::transaction(function () use ($location, $confirmed, $userId) {
$location->lockForUpdate();
$options = $location->options ?? [];
$options['lot_audit_confirmed'] = $confirmed;
$options['lot_audit_confirmed_at'] = $confirmed ? now()->toIso8601String() : null;
$options['lot_audit_confirmed_by'] = $confirmed ? $userId : null;
$location->options = $options;
$location->save();
});
$location->refresh();
return [
'id' => (string) $location->id,
'name' => $this->buildLocationName($location),
'location' => $this->buildLocationCode($location),
'is_completed' => (bool) data_get($location->options, 'lot_audit_confirmed', false),
];
}
// ===== Private: Transform Methods =====
private function transformReportToFrontend(QualityDocument $doc): array
{
$performanceReport = $doc->performanceReport;
// 수주로트 건수 = documentOrders 수
$totalRoutes = $doc->documentOrders->count();
// 확인 완료 수주로트 = 해당 주문의 모든 개소가 확인된 건수
$confirmedRoutes = $doc->documentOrders->filter(function ($docOrder) {
$locations = $docOrder->locations;
if ($locations->isEmpty()) {
return false;
}
return $locations->every(fn ($loc) => data_get($loc->options, 'lot_audit_confirmed', false));
})->count();
return [
'id' => (string) $doc->id,
'code' => $doc->quality_doc_number,
'site_name' => $doc->site_name,
'item' => $this->getFgProductName($doc),
'route_count' => $confirmedRoutes,
'total_routes' => $totalRoutes,
'quarter' => $performanceReport
? $performanceReport->year.'년 '.$performanceReport->quarter.'분기'
: '',
'year' => $performanceReport?->year ?? now()->year,
'quarter_num' => $performanceReport?->quarter ?? 0,
];
}
/**
* 수주 대표 제품명 추출
* Order.item_id → Item.name
*/
private function getFgProductName(QualityDocument $doc): string
{
$order = $doc->documentOrders->first()?->order;
if (! $order) {
return '';
}
// 1차: Order.item 릴레이션
if ($order->item?->name) {
return $order->item->name;
}
// 2차: Order.items (order_items 테이블)의 첫 번째 품목명
return $order->items->first()?->item_name ?? '';
}
private function transformRouteToFrontend(QualityDocumentOrder $docOrder, QualityDocument $qualityDoc): array
{
return [
'id' => (string) $docOrder->id,
'code' => $docOrder->order->order_no,
'date' => $docOrder->order->received_at?->toDateString(),
'client' => $docOrder->order->client_name ?? '',
'site' => $docOrder->order->site_name ?? '',
'location_count' => $docOrder->locations->count(),
'sub_items' => $docOrder->locations->values()->map(fn ($loc, $idx) => [
'id' => (string) $loc->id,
'name' => $qualityDoc->quality_doc_number.'-'.str_pad($idx + 1, 2, '0', STR_PAD_LEFT),
'location' => $this->buildLocationCode($loc),
'is_completed' => (bool) data_get($loc->options, 'lot_audit_confirmed', false),
])->all(),
];
}
private function buildLocationName(QualityDocumentLocation $location): string
{
$qualityDoc = $location->qualityDocument;
if (! $qualityDoc) {
return '';
}
// location의 순번을 구하기 위해 같은 문서의 location 목록 조회
$locations = QualityDocumentLocation::where('quality_document_id', $qualityDoc->id)
->orderBy('id')
->pluck('id');
$index = $locations->search($location->id);
return $qualityDoc->quality_doc_number.'-'.str_pad(($index !== false ? $index + 1 : 1), 2, '0', STR_PAD_LEFT);
}
private function buildLocationCode(QualityDocumentLocation $location): string
{
$orderItem = $location->orderItem;
if (! $orderItem) {
return '';
}
return trim(($orderItem->floor_code ?? '').' '.($orderItem->symbol_code ?? ''));
}
// ===== Private: Document Format Helpers =====
private function formatDocument(string $type, string $title, $collection): array
{
return [
'id' => $type,
'type' => $type,
'title' => $title,
'count' => $collection->count(),
'items' => $collection->values()->map(fn ($item) => $this->formatDocumentItem($type, $item))->all(),
];
}
private function formatDocumentWithSubType(string $type, string $title, $collection, ?string $workOrderRelation = null): array
{
return [
'id' => $type,
'type' => $type,
'title' => $title,
'count' => $collection->count(),
'items' => $collection->values()->map(function ($item) use ($type, $workOrderRelation) {
$formatted = $this->formatDocumentItem($type, $item);
// subType: process.process_name 기반 + work_order_id 전달
$workOrder = $workOrderRelation ? $item->{$workOrderRelation} : $item;
if ($workOrder instanceof WorkOrder) {
$processName = $workOrder->process?->process_name;
$formatted['sub_type'] = $this->mapProcessToSubType($processName);
$formatted['work_order_id'] = $workOrder->id;
}
return $formatted;
})->all(),
];
}
private function formatDocumentItem(string $type, $item): array
{
return match ($type) {
'import' => [
'id' => (string) $item->id,
'title' => $item->inspection_no ?? '',
'date' => $item->inspection_date?->toDateString() ?? $item->request_date?->toDateString() ?? '',
'code' => $item->inspection_no ?? '',
],
'report' => [
'id' => (string) $item->id,
'title' => $item->process?->process_name ?? '중간검사 성적서',
'date' => $item->created_at?->toDateString() ?? '',
'code' => $item->work_order_no ?? '',
],
'order' => [
'id' => (string) $item->id,
'title' => $item->order_no,
'date' => $item->received_at?->toDateString() ?? '',
'code' => $item->order_no,
],
'log' => [
'id' => (string) $item->id,
'title' => $item->process?->process_name ?? '작업일지',
'date' => $item->created_at?->toDateString() ?? '',
'code' => $item->work_order_no ?? '',
],
'confirmation', 'shipping' => [
'id' => (string) $item->id,
'title' => $item->shipment_no ?? '',
'date' => $item->scheduled_date?->toDateString() ?? '',
'code' => $item->shipment_no ?? '',
],
'product' => [
'id' => (string) $item->id,
'title' => trim(($item->orderItem?->floor_code ?? '').' '.($item->orderItem?->symbol_code ?? '')) ?: '제품검사 성적서',
'date' => $item->updated_at?->toDateString() ?? '',
'code' => $item->document?->document_no ?? '',
],
'quality' => [
'id' => (string) $item->id,
'title' => $item->quality_doc_number ?? '',
'date' => $item->received_date?->toDateString() ?? '',
'code' => $item->quality_doc_number ?? '',
],
default => [
'id' => (string) $item->id,
'title' => '',
'date' => '',
],
};
}
/**
* process_name → subType 매핑
*/
private function mapProcessToSubType(?string $processName): ?string
{
if (! $processName) {
return null;
}
$name = mb_strtolower($processName);
return match (true) {
str_contains($name, 'screen') || str_contains($name, '스크린') => 'screen',
str_contains($name, 'bending') || str_contains($name, '절곡') => 'bending',
str_contains($name, 'slat') || str_contains($name, '슬랫') => 'slat',
str_contains($name, 'jointbar') || str_contains($name, '조인트바') || str_contains($name, 'joint') => 'jointbar',
default => mb_strtolower(trim($processName)),
};
}
// ===== Private: Document Detail Methods (2단계 로딩) =====
private function getInspectionDetail(int $id, string $type): array
{
$inspection = Inspection::where('inspection_type', $type)
->with(['item', 'workOrder.process'])
->findOrFail($id);
return [
'type' => $type === 'IQC' ? 'import' : 'report',
'data' => [
'id' => $inspection->id,
'inspection_no' => $inspection->inspection_no,
'inspection_type' => $inspection->inspection_type,
'status' => $inspection->status,
'result' => $inspection->result,
'request_date' => $inspection->request_date?->toDateString(),
'inspection_date' => $inspection->inspection_date?->toDateString(),
'lot_no' => $inspection->lot_no,
'item_name' => $inspection->item?->name,
'process_name' => $inspection->workOrder?->process?->process_name,
'meta' => $inspection->meta,
'items' => $inspection->items,
'attachments' => $inspection->attachments,
'extra' => $inspection->extra,
],
];
}
private function getOrderDetail(int $id): array
{
$order = Order::with([
'client',
'nodes' => fn ($q) => $q->whereNull('parent_id')->orderBy('id'),
])->findOrFail($id);
$rootNodes = $order->nodes;
$options = $order->options ?? [];
// 개소별 제품 정보
$products = $rootNodes->map(function ($node, $index) {
$opts = $node->options ?? [];
$vars = data_get($opts, 'bom_result.variables', []);
return [
'no' => $index + 1,
'floor' => $opts['floor'] ?? '-',
'symbol' => $opts['symbol'] ?? '-',
'product_name' => $opts['product_name'] ?? '-',
'product_code' => $opts['product_code'] ?? null,
'open_width' => $opts['open_width'] ?? null,
'open_height' => $opts['open_height'] ?? null,
'made_width' => $opts['width'] ?? null,
'made_height' => $opts['height'] ?? null,
'guide_rail' => $vars['installation_type'] ?? '-',
'shaft' => $vars['bracket_inch'] ?? '-',
'case_inch' => $vars['bracket_inch'] ?? '-',
'bracket' => $vars['BRACKET_SIZE'] ?? '-',
'capacity' => $vars['MOTOR_CAPACITY'] ?? '-',
'finish' => $vars['finishing_type'] ?? '-',
'product_type' => $vars['product_type'] ?? null,
'joint_bar' => null, // 철재 전용 — 아래에서 bom_items에서 보강
];
})->values()->toArray();
// BOM items 집계 (모든 노드에서)
$allBomItems = $rootNodes->flatMap(function ($node) {
return collect(data_get($node->options, 'bom_result.items', []));
});
// 철재 제품의 조인트바 수량 보강
foreach ($rootNodes->values() as $index => $node) {
$bomItems = collect(data_get($node->options, 'bom_result.items', []));
$jointBar = $bomItems->first(fn ($i) => str_contains($i['item_name'] ?? '', '조인트바'));
if ($jointBar) {
$products[$index]['joint_bar'] = $jointBar['quantity'] ?? null;
}
}
// 모터 정보 (category: motor, controller)
$motorItems = $allBomItems->filter(fn ($i) => in_array($i['item_category'] ?? '', ['motor', 'controller']));
$motorLeft = [];
$motorRight = [];
foreach ($motorItems->groupBy('item_name') as $name => $group) {
$item = $group->first();
$totalQty = (int) $group->sum('quantity');
$row = [
'item' => $item['item_name'],
'type' => $item['specification'] ?? '-',
'spec' => $item['item_code'] ?? '-',
'qty' => $totalQty,
];
// 모터/브라켓 → 좌, 제어기/전동개폐기 → 우
if (in_array($item['item_category'], ['controller'])) {
$motorRight[] = $row;
} else {
$motorLeft[] = $row;
}
}
// 절곡물 (category: steel)
$steelItems = $allBomItems->filter(fn ($i) => ($i['item_category'] ?? '') === 'steel');
$shutterCount = $rootNodes->count();
$bendingParts = $this->groupBendingParts($steelItems, $shutterCount);
// 부자재 (category: parts)
$partItems = $allBomItems->filter(fn ($i) => ($i['item_category'] ?? '') === 'parts');
$subsidiaryParts = [];
foreach ($partItems->groupBy('item_name') as $name => $group) {
$item = $group->first();
$subsidiaryParts[] = [
'name' => $item['item_name'],
'spec' => $item['specification'] ?? '-',
'qty' => (int) $group->sum('quantity'),
];
}
return [
'type' => 'order',
'data' => [
'id' => $order->id,
'order_no' => $order->order_no,
'status_code' => $order->status_code,
'category_code' => $order->category_code,
'received_at' => $order->received_at?->toDateString(),
'delivery_date' => $order->delivery_date?->toDateString(),
'delivery_method_code' => $order->delivery_method_code,
'site_name' => $order->site_name,
'client_name' => $order->client_name ?? $order->client?->name,
'client_contact' => $order->client_contact,
'manager_name' => $options['manager_name'] ?? null,
'receiver' => $options['receiver'] ?? null,
'receiver_contact' => $options['receiver_contact'] ?? null,
'shipping_address' => $options['shipping_address'] ?? null,
'shipping_address_detail' => $options['shipping_address_detail'] ?? null,
'shipping_cost_code' => $options['shipping_cost_code'] ?? null,
'quantity' => $order->quantity,
'supply_amount' => $order->supply_amount,
'tax_amount' => $order->tax_amount,
'total_amount' => $order->total_amount,
'remarks' => $order->remarks,
'nodes_count' => $rootNodes->count(),
'products' => $products,
'motors' => [
'left' => $motorLeft,
'right' => $motorRight,
],
'bending_parts' => $bendingParts,
'subsidiary_parts' => $subsidiaryParts,
],
];
}
/**
* 절곡물 BOM items를 그룹별로 분류
* 문서 표시용: 길이 기반 BOM 수량 → 개수(EA) 기반으로 변환
* - 가이드레일: 2 × 셔터수량 (좌/우)
* - 케이스: 셔터수량
* - 마구리(측면부): 2 × 셔터수량 (좌/우)
*/
private function groupBendingParts($steelItems, int $shutterCount = 0): array
{
$tenantId = $this->tenantId();
$groups = [
'가이드레일' => [],
'케이스' => [],
'하단마감' => [],
'연기차단재' => [],
'기타' => [],
];
// item_code별 bending_item 이미지 사전 조회
$itemCodes = $steelItems->pluck('item_code')->filter()->unique()->values()->toArray();
$imageMap = $this->getBendingItemImages($itemCodes, $tenantId);
foreach ($steelItems->groupBy('item_name') as $name => $group) {
$item = $group->first();
$totalQty = $group->sum('quantity');
// 개수(EA) 기반 수량 결정
$isMaguri = str_contains($name, '마구리') || str_contains($name, '측면');
$isGuideRail = str_contains($name, '가이드레일');
$isCase = str_contains($name, '케이스') && ! $isMaguri;
if ($shutterCount > 0 && ($isGuideRail || $isCase || $isMaguri)) {
$qty = ($isGuideRail || $isMaguri) ? 2 * $shutterCount : $shutterCount;
} else {
$qty = $totalQty;
}
// 이미지 URL: item_code(BD-XX-NN) 기반 → bending_item prefix 매칭
$itemCode = $item['item_code'] ?? null;
$imageUrl = $itemCode ? ($imageMap[$itemCode] ?? null) : null;
// item_code로 직접 매칭 안 되면 prefix(BD-XX)로 매칭 시도
if (! $imageUrl && $itemCode && preg_match('/^(BD-[A-Z]{2})/', $itemCode, $m)) {
$prefix = $m[1];
foreach ($imageMap as $code => $url) {
if (str_starts_with($code, $prefix)) {
$imageUrl = $url;
break;
}
}
}
$row = [
'name' => $item['item_name'],
'spec' => $item['specification'] ?? '-',
'qty' => (int) $qty,
'image_url' => $imageUrl,
];
if (str_contains($name, '연기차단재')) {
$groups['연기차단재'][] = $row;
} elseif ($isGuideRail) {
$groups['가이드레일'][] = $row;
} elseif ($isCase || $isMaguri) {
$groups['케이스'][] = $row;
} elseif (str_contains($name, '하장바') || str_contains($name, 'L-BAR') || str_contains($name, '보강평철')) {
$groups['하단마감'][] = $row;
} else {
$groups['기타'][] = $row;
}
}
$result = [];
foreach ($groups as $groupName => $items) {
if (! empty($items)) {
$result[] = [
'group' => $groupName,
'items' => $items,
];
}
}
return $result;
}
/**
* bending_items의 이미지 URL 사전 조회
*
* item_code(BD-XX-NN) → bending_items.code(BD-XX.NNN) prefix 매칭 → files 테이블에서 이미지 조회
*
* @return array<string, string> item_code → image_url 매핑
*/
private function getBendingItemImages(array $itemCodes, int $tenantId): array
{
if (empty($itemCodes)) {
return [];
}
// item_code에서 prefix 추출 (BD-RM-24 → BD-RM)
$prefixes = [];
foreach ($itemCodes as $code) {
if (preg_match('/^(BD-[A-Z]{2})/', $code, $m)) {
$prefixes[$m[1]] = true;
}
}
if (empty($prefixes)) {
return [];
}
// bending_items에서 해당 prefix 매칭 + files 조인
$bendingItems = \App\Models\BendingItem::where('tenant_id', $tenantId)
->where(function ($q) use ($prefixes) {
foreach (array_keys($prefixes) as $prefix) {
$q->orWhere('code', 'LIKE', "{$prefix}%");
}
})
->whereHas('files')
->with(['files' => fn ($q) => $q->orderBy('id', 'desc')->limit(1)])
->get();
// prefix → file_id 매핑 (첫 번째 매칭만)
$prefixImageMap = [];
foreach ($bendingItems as $bi) {
$file = $bi->files->first();
if (! $file) {
continue;
}
// BD-RM.001 → BD-RM prefix 추출
$biPrefix = substr($bi->code, 0, 5); // "BD-RM"
if (! isset($prefixImageMap[$biPrefix])) {
$prefixImageMap[$biPrefix] = url("/api/v1/files/{$file->id}/view");
}
}
// item_code → image_url 매핑
$result = [];
foreach ($itemCodes as $code) {
if (preg_match('/^(BD-[A-Z]{2})/', $code, $m)) {
$prefix = $m[1];
if (isset($prefixImageMap[$prefix])) {
$result[$code] = $prefixImageMap[$prefix];
}
}
}
return $result;
}
private function getWorkOrderLogDetail(int $id): array
{
$workOrder = WorkOrder::with('process')->findOrFail($id);
return [
'type' => 'log',
'data' => [
'id' => $workOrder->id,
'project_name' => $workOrder->project_name,
'status' => $workOrder->status,
'process_name' => $workOrder->process?->process_name,
'options' => $workOrder->options,
'created_at' => $workOrder->created_at?->toDateString(),
],
];
}
private function getShipmentDetail(int $id): array
{
$shipment = Shipment::with([
'vehicleDispatches',
'items',
'order.nodes' => fn ($q) => $q->whereNull('parent_id'),
])->findOrFail($id);
// 배차정보
$vehicleDispatches = $shipment->vehicleDispatches->map(fn ($d) => [
'logistics_company' => $d->logistics_company,
'arrival_datetime' => $d->arrival_datetime,
'tonnage' => $d->tonnage,
'vehicle_no' => $d->vehicle_no,
'driver_contact' => $d->driver_contact,
'remarks' => $d->remarks,
])->values()->toArray();
// 출하 품목 → 제품 그룹별 분류
$productGroups = [];
$otherParts = [];
foreach ($shipment->items as $item) {
$row = [
'item_name' => $item->item_name,
'specification' => $item->specification,
'quantity' => $item->quantity,
'unit' => $item->unit,
'lot_no' => $item->lot_no,
'floor_unit' => $item->floor_unit,
];
// floor_unit가 있으면 해당 제품 그룹에, 없으면 기타 부품
if ($item->floor_unit) {
$productGroups[$item->floor_unit][] = $row;
} else {
$otherParts[] = $row;
}
}
return [
'type' => 'shipping',
'data' => [
'id' => $shipment->id,
'order_id' => $shipment->order_id,
'shipment_no' => $shipment->shipment_no,
'lot_no' => $shipment->lot_no,
'status' => $shipment->status,
'scheduled_date' => $shipment->scheduled_date?->toDateString(),
'customer_name' => $shipment->customer_name,
'customer_grade' => $shipment->customer_grade,
'site_name' => $shipment->site_name,
'delivery_address' => $shipment->delivery_address,
'delivery_method' => $shipment->delivery_method,
'shipping_cost' => $shipment->shipping_cost,
'receiver' => $shipment->receiver,
'receiver_contact' => $shipment->receiver_contact,
'vehicle_no' => $shipment->vehicle_no,
'driver_name' => $shipment->driver_name,
'driver_contact' => $shipment->driver_contact,
'remarks' => $shipment->remarks,
'vehicle_dispatches' => $vehicleDispatches,
'product_groups' => $productGroups,
'other_parts' => $otherParts,
],
];
}
private function getLocationDetail(int $id): array
{
$location = QualityDocumentLocation::with([
'orderItem',
'document.template.sections.items',
'document.template.columns',
'document.template.approvalLines',
'document.template.basicFields',
'document.data',
])->findOrFail($id);
$result = [
'type' => 'product',
'data' => [
'id' => $location->id,
'inspection_status' => $location->inspection_status,
'inspection_data' => $location->inspection_data,
'post_width' => $location->post_width,
'post_height' => $location->post_height,
'floor_code' => $location->orderItem?->floor_code,
'symbol_code' => $location->orderItem?->symbol_code,
'document_id' => $location->document_id,
],
];
// FQC 문서가 있으면 template + data 포함
if ($location->document) {
$doc = $location->document;
$result['data']['fqc_document'] = [
'id' => $doc->id,
'template_id' => $doc->template_id,
'document_no' => $doc->document_no,
'title' => $doc->title,
'status' => $doc->status,
'created_at' => $doc->created_at?->toIso8601String(),
'template' => $this->formatFqcTemplate($doc->template),
'data' => $doc->data->map(fn ($d) => [
'section_id' => $d->section_id,
'column_id' => $d->column_id,
'row_index' => $d->row_index,
'field_key' => $d->field_key,
'field_value' => $d->field_value,
])->all(),
];
}
return $result;
}
private function formatFqcTemplate($template): ?array
{
if (! $template) {
return null;
}
return [
'id' => $template->id,
'name' => $template->name,
'category' => $template->category,
'title' => $template->title,
'approval_lines' => $template->approvalLines->map(fn ($a) => [
'id' => $a->id,
'name' => $a->name,
'department' => $a->department,
'sort_order' => $a->sort_order,
])->all(),
'basic_fields' => $template->basicFields->map(fn ($f) => [
'id' => $f->id,
'label' => $f->label,
'field_key' => $f->field_key,
'field_type' => $f->field_type,
'default_value' => $f->default_value,
'is_required' => $f->is_required,
'sort_order' => $f->sort_order,
])->all(),
'sections' => $template->sections->map(function ($s) {
$imageUrl = null;
if ($s->file_id) {
$file = \App\Models\Commons\File::withoutGlobalScopes()->find($s->file_id);
$imageUrl = $file?->presignedUrl();
} elseif ($s->image_path) {
$imageUrl = \Illuminate\Support\Facades\Storage::disk('r2')->temporaryUrl($s->image_path, now()->addMinutes(30));
}
return [
'id' => $s->id,
'name' => $s->name,
'title' => $s->title,
'description' => $s->description,
'image_path' => $s->image_path,
'file_id' => $s->file_id,
'image_url' => $imageUrl,
'sort_order' => $s->sort_order,
'items' => $s->items->map(fn ($i) => [
'id' => $i->id,
'section_id' => $i->section_id,
'item_name' => $i->item ?? '',
'standard' => $i->standard,
'tolerance' => $i->tolerance,
'measurement_type' => $i->measurement_type,
'frequency' => $i->frequency,
'sort_order' => $i->sort_order,
'category' => $i->category,
'method' => $i->method,
])->all(),
];
})->all(),
'columns' => $template->columns->map(fn ($c) => [
'id' => $c->id,
'label' => $c->label,
'column_type' => $c->column_type,
'width' => $c->width,
'group_name' => $c->group_name,
'sort_order' => $c->sort_order,
])->all(),
];
}
private function getQualityDocDetail(int $id): array
{
$doc = QualityDocument::with(['client', 'inspector:id,name'])->findOrFail($id);
return [
'type' => 'quality',
'data' => [
'id' => $doc->id,
'quality_doc_number' => $doc->quality_doc_number,
'site_name' => $doc->site_name,
'status' => $doc->status,
'received_date' => $doc->received_date?->toDateString(),
'client_name' => $doc->client?->name,
'inspector_name' => $doc->inspector?->name,
'options' => $doc->options,
],
];
}
}