Files
sam-manage/app/Http/Controllers/DocumentController.php
2026-02-25 11:45:01 +09:00

457 lines
16 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Models\Documents\Document;
use App\Models\Documents\DocumentData;
use App\Models\DocumentTemplate;
use App\Models\Items\Item;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class DocumentController extends Controller
{
/**
* 문서 목록 페이지
*/
public function index(Request $request): View|Response
{
if ($request->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) : [],
]);
}
/**
* 문서 수정 페이지
*/
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),
]);
}
/**
* 문서 인쇄용 화면 (성적서 양식)
*/
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,
]);
}
/**
* 문서 상세 페이지 (읽기 전용)
*/
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);
return view('documents.show', [
'document' => $document,
'workOrderItems' => $workOrderItems,
'workOrder' => $workOrder,
'salesOrder' => $salesOrder,
'materialInputLots' => $materialInputLots,
'itemLotMap' => $itemLotMap ?? collect(),
]);
}
/**
* 기본정보(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
->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 ?? '',
];
}
/**
* 템플릿에 연결된 품목들의 규격 정보 (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;
}
}