header('HX-Request')) { return response('', 200)->header('HX-Redirect', route('documents.index')); } $tenantId = session('selected_tenant_id'); // 템플릿 목록 (필터용) $templates = $tenantId ? DocumentTemplate::where(function ($q) use ($tenantId) { $q->whereNull('tenant_id')->orWhere('tenant_id', $tenantId); })->where('is_active', true)->orderBy('name')->get() : collect(); // 양식분류 목록 (필터용) $categories = $tenantId ? DocumentTemplate::where(function ($q) use ($tenantId) { $q->whereNull('tenant_id')->orWhere('tenant_id', $tenantId); })->where('is_active', true) ->whereNotNull('category') ->distinct() ->pluck('category') ->sort() ->values() : collect(); return view('documents.index', [ 'templates' => $templates, 'categories' => $categories, 'statuses' => Document::STATUS_LABELS, ]); } /** * 문서 생성 페이지 */ public function create(Request $request): View|Response { if ($request->header('HX-Request')) { return response('', 200)->header('HX-Redirect', route('documents.create', $request->query())); } $tenantId = session('selected_tenant_id'); $templateId = $request->query('template_id'); // 템플릿 목록 $templates = $tenantId ? DocumentTemplate::where(function ($q) use ($tenantId) { $q->whereNull('tenant_id')->orWhere('tenant_id', $tenantId); })->where('is_active', true)->orderBy('name')->get() : collect(); // 선택된 템플릿 $template = $templateId ? DocumentTemplate::with(['approvalLines', 'basicFields', 'sections.items', 'columns', 'sectionFields', 'links.linkValues'])->find($templateId) : null; return view('documents.edit', [ 'document' => null, 'template' => $template, 'templates' => $templates, 'isCreate' => true, 'linkedItemSpecs' => $template ? $this->getLinkedItemSpecs($template) : [], 'blockHtml' => $template ? $this->renderBlockHtml($template, null) : '', ]); } /** * 문서 수정 페이지 */ public function edit(int $id): View|Response { if (request()->header('HX-Request')) { return response('', 200)->header('HX-Redirect', route('documents.edit', $id)); } $tenantId = session('selected_tenant_id'); $document = Document::with([ 'template.approvalLines', 'template.basicFields', 'template.sections.items', 'template.columns', 'template.sectionFields', 'template.links.linkValues', 'approvals.user', 'data', 'attachments.file', 'creator', ])->where('tenant_id', $tenantId)->findOrFail($id); // 템플릿 목록 (변경용) $templates = $tenantId ? DocumentTemplate::where(function ($q) use ($tenantId) { $q->whereNull('tenant_id')->orWhere('tenant_id', $tenantId); })->where('is_active', true)->orderBy('name')->get() : collect(); // 기본정보 bf_ 자동 backfill (show 안 거치고 바로 edit 진입 대비) $this->resolveAndBackfillBasicFields($document); return view('documents.edit', [ 'document' => $document, 'template' => $document->template, 'templates' => $templates, 'isCreate' => false, 'linkedItemSpecs' => $this->getLinkedItemSpecs($document->template), 'blockHtml' => $this->renderBlockHtml($document->template, $document), ]); } /** * 문서 인쇄용 화면 (성적서 양식) */ public function print(int $id): View { $tenantId = session('selected_tenant_id'); $document = Document::with([ 'template.approvalLines', 'template.basicFields', 'template.sections.items', 'template.columns', 'template.sectionFields', 'template.links.linkValues', 'approvals.user', 'data', 'creator', ])->where('tenant_id', $tenantId)->findOrFail($id); // 연결된 작업지시서의 work_order_items 로드 $workOrderItems = collect(); if ($document->linkable_type === 'work_order' && $document->linkable_id) { $workOrderItems = DB::table('work_order_items') ->where('work_order_id', $document->linkable_id) ->orderBy('sort_order') ->get() ->map(function ($item) { $item->options = json_decode($item->options, true) ?? []; return $item; }); } // 기본정보 bf_ 자동 backfill $this->resolveAndBackfillBasicFields($document); return view('documents.print', [ 'document' => $document, 'workOrderItems' => $workOrderItems, 'blockHtml' => $this->renderBlockHtml($document->template, $document, 'print'), ]); } /** * 문서 상세 페이지 (읽기 전용) */ public function show(int $id): View { $tenantId = session('selected_tenant_id'); $document = Document::with([ 'template.approvalLines', 'template.basicFields', 'template.sections.items', 'template.columns', 'template.sectionFields', 'template.links.linkValues', 'approvals.user', 'data', 'attachments.file', 'creator', 'updater', ])->where('tenant_id', $tenantId)->findOrFail($id); // 연결된 작업지시서의 work_order_items 로드 $workOrderItems = collect(); $workOrder = null; $salesOrder = null; $materialInputLots = collect(); if ($document->linkable_type === 'work_order' && $document->linkable_id) { $workOrderItems = DB::table('work_order_items') ->where('work_order_id', $document->linkable_id) ->orderBy('sort_order') ->get() ->map(function ($item) { $item->options = json_decode($item->options, true) ?? []; return $item; }); // 작업일지용: 작업지시 + 수주 + 자재투입LOT 로드 $workOrder = DB::table('work_orders') ->where('id', $document->linkable_id) ->first(); if ($workOrder?->sales_order_id) { $salesOrder = DB::table('orders') ->where('id', $workOrder->sales_order_id) ->first(); } // 자재 투입 LOT (stock_transactions 기반, 취소 트랜잭션 상쇄 포함) $transactions = DB::table('stock_transactions') ->where('tenant_id', $tenantId) ->whereIn('reference_type', ['work_order_input', 'work_order_input_cancel']) ->where('reference_id', $document->linkable_id) ->orderBy('created_at') ->get(['lot_no', 'item_code', 'item_name', 'qty', 'reference_type', 'created_at']); $lotMap = []; foreach ($transactions as $tx) { $lotNo = $tx->lot_no; if (! isset($lotMap[$lotNo])) { $lotMap[$lotNo] = (object) [ 'lot_no' => $lotNo, 'item_code' => $tx->item_code, 'item_name' => $tx->item_name, 'total_qty' => 0, 'input_count' => 0, 'first_input_at' => $tx->created_at, ]; } // OUT(투입)은 음수, IN(취소)은 양수 → 합산하면 순수 투입량 $lotMap[$lotNo]->total_qty += (float) $tx->qty; if ($tx->reference_type === 'work_order_input') { $lotMap[$lotNo]->input_count++; } } // 순수 투입량이 0 이하인 LOT 제외, qty를 절대값으로 변환 $materialInputLots = collect(array_values($lotMap)) ->filter(fn ($lot) => $lot->total_qty < 0) ->map(function ($lot) { $lot->total_qty = abs($lot->total_qty); return $lot; }) ->values(); // 개소별 투입자재 LOT (work_order_material_inputs 기반, work_order_item_id → lot_no) $itemLotMap = DB::table('work_order_material_inputs as mi') ->join('stock_lots as sl', 'sl.id', '=', 'mi.stock_lot_id') ->where('mi.work_order_id', $document->linkable_id) ->select('mi.work_order_item_id', 'sl.lot_no', 'mi.qty') ->orderBy('mi.work_order_item_id') ->get() ->groupBy('work_order_item_id') ->map(fn ($rows) => $rows->pluck('lot_no')->unique()->join(', ')); } // 기본정보 bf_ 자동 backfill $this->resolveAndBackfillBasicFields($document); // 절곡 작업일지용: bending_info 추출 $bendingInfo = null; if ($workOrder) { $woOptions = json_decode($workOrder->options ?? '{}', true); $bendingInfo = $woOptions['bending_info'] ?? null; } // 절곡 중간검사용: inspection_data 스냅샷 추출 (work_order_items.options.inspection_data) $inspectionData = null; foreach ($workOrderItems as $item) { if (! empty($item->options['inspection_data'])) { $inspectionData = $item->options['inspection_data']; break; } } return view('documents.show', [ 'document' => $document, 'workOrderItems' => $workOrderItems, 'workOrder' => $workOrder, 'salesOrder' => $salesOrder, 'materialInputLots' => $materialInputLots, 'itemLotMap' => $itemLotMap ?? collect(), 'blockHtml' => $this->renderBlockHtml($document->template, $document, 'view'), 'bendingInfo' => $bendingInfo, 'inspectionData' => $inspectionData, ]); } /** * 기본정보(bf_) 레코드가 없으면 원본 데이터에서 resolve → document_data에 저장 * 검사 문서: field_key 기반 매핑 * 작업일지: label 기반 매핑 (DB에서 직접 JOIN) */ private function resolveAndBackfillBasicFields(Document $document): void { $basicFields = $document->template?->basicFields; if (! $basicFields || $basicFields->isEmpty()) { return; } // bf_ 레코드가 하나라도 있으면 이미 저장된 것 → skip $existingBfCount = ($document->data ?? collect()) ->filter(fn ($d) => str_starts_with($d->field_key, 'bf_')) ->count(); if ($existingBfCount > 0) { return; } // 원본 데이터 로드: work_order + order if ($document->linkable_type !== 'work_order' || ! $document->linkable_id) { return; } $workOrder = DB::table('work_orders')->find($document->linkable_id); if (! $workOrder) { return; } $workOrderItems = DB::table('work_order_items') ->where('work_order_id', $workOrder->id) ->orderBy('sort_order') ->get() ->map(function ($item) { $item->options = json_decode($item->options, true) ?? []; return $item; }); $order = $workOrder->sales_order_id ? DB::table('orders')->find($workOrder->sales_order_id) : null; // 작업일지 vs 검사 문서 판별: 섹션이 없으면 작업일지 $isWorkLog = ! $document->template->sections || $document->template->sections->isEmpty(); if ($isWorkLog) { $resolveMap = $this->buildWorkLogResolveMap($workOrder, $order); } else { $resolveMap = $this->buildInspectionResolveMap($workOrder, $workOrderItems, $order); } // document_data에 bf_ 레코드 저장 $records = []; foreach ($basicFields as $field) { if ($isWorkLog) { // 작업일지: label 기반 매핑 $value = $resolveMap[$field->label] ?? $field->default_value ?? ''; } else { // 검사 문서: field_key 기반 매핑 $value = $resolveMap[$field->field_key] ?? $field->default_value ?? ''; } if ($value === '') { continue; } $records[] = [ 'document_id' => $document->id, 'section_id' => null, 'column_id' => null, 'row_index' => 0, 'field_key' => 'bf_'.$field->id, 'field_value' => (string) $value, 'created_at' => now(), 'updated_at' => now(), ]; } if (! empty($records)) { DocumentData::insert($records); // 메모리의 data relation도 갱신 $document->load('data'); } } /** * 작업일지용 기본필드 resolve 맵 (label → 값) * API WorkOrderService::buildWorkLogAutoValues 와 동일 매핑 */ private function buildWorkLogResolveMap(object $workOrder, ?object $salesOrder): array { $receivedAt = $salesOrder?->received_at ?? $salesOrder?->created_at ?? null; $orderDate = $receivedAt ? substr((string) $receivedAt, 0, 10) : ''; $deliveryDate = $salesOrder?->delivery_date ?? null; $deliveryStr = $deliveryDate ? substr((string) $deliveryDate, 0, 10) : ''; $orderNo = $salesOrder?->order_no ?? ''; return [ '발주처' => $salesOrder?->client_name ?? '', '현장명' => $salesOrder?->site_name ?? $workOrder->project_name ?? '', '작업일자' => now()->format('Y-m-d'), 'LOT NO' => $orderNo, '납기일' => $deliveryStr, '작업지시번호' => $workOrder->work_order_no ?? '', '수주일' => $orderDate, '수주처' => $salesOrder?->client_name ?? '', '담당자' => '', '연락처' => '', '제품 LOT NO' => $orderNo, '생산담당자' => '', '출고예정일' => $deliveryStr, ]; } /** * 검사 문서용 기본필드 resolve 맵 (field_key → 값) */ private function buildInspectionResolveMap(object $workOrder, $workOrderItems, ?object $order): array { $firstItem = $workOrderItems->first(); $inspectionData = $firstItem?->options['inspection_data'] ?? []; $inspectedBy = $inspectionData['inspected_by'] ?? null; $inspectedAt = $inspectionData['inspected_at'] ?? null; $inspectorName = $inspectedBy ? DB::table('users')->where('id', $inspectedBy)->value('name') : null; return [ 'product_name' => $firstItem?->item_name ?? '', 'specification' => $firstItem?->specification ?? '', 'lot_no' => $order?->order_no ?? '', 'lot_size' => $workOrderItems->count().' 개소', 'client' => $order?->client_name ?? '', 'site_name' => $workOrder->project_name ?? '', 'inspection_date' => $inspectedAt ? substr($inspectedAt, 0, 10) : now()->format('Y-m-d'), 'inspector' => $inspectorName ?? '', ]; } /** * 블록 빌더 서식의 HTML 렌더링 */ private function renderBlockHtml(DocumentTemplate $template, ?Document $document, string $mode = 'edit'): string { if (! $template->isBlockBuilder() || empty($template->schema)) { return ''; } $schema = $template->schema; // document_data에서 field_key => field_value 맵 생성 $data = []; if ($document && $document->data) { foreach ($document->data as $d) { $data[$d->field_key] = $d->field_value; } } $renderer = new BlockRendererService; return $renderer->render($schema, $mode, $data); } /** * 템플릿에 연결된 품목들의 규격 정보 (thickness, width, length) 조회 */ private function getLinkedItemSpecs(DocumentTemplate $template): array { $specs = []; if (! $template->relationLoaded('links')) { $template->load('links.linkValues'); } foreach ($template->links as $link) { if ($link->source_table !== 'items') { continue; } foreach ($link->linkValues as $lv) { $item = Item::find($lv->linkable_id); if (! $item) { continue; } $attrs = $item->attributes ?? []; if (isset($attrs['thickness']) || isset($attrs['width']) || isset($attrs['length'])) { $specs[] = [ 'id' => $item->id, 'name' => $item->name, 'thickness' => $attrs['thickness'] ?? null, 'width' => $attrs['width'] ?? null, 'length' => $attrs['length'] ?? null, ]; } } } return $specs; } }