Files
sam-api/app/Services/QualityDocumentService.php
권혁성 3ac64d5b76 feat: [품질검사] 수주 선택 필터링 + 개소 상세 + 검사 상태 개선
- availableOrders: client_id/item_id 필터 파라미터 지원
- availableOrders: 응답에 client_id, client_name, item_id, item_name, locations(개소 상세) 추가
- show: 개소별 데이터에 거래처/모델 정보 포함
- DocumentService: fqcStatus rootNodes 기반으로 변경

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00

1168 lines
42 KiB
PHP

<?php
namespace App\Services;
use App\Models\Documents\Document;
use App\Models\Documents\DocumentData;
use App\Models\Documents\DocumentTemplate;
use App\Models\Orders\Order;
use App\Models\Orders\OrderItem;
use App\Models\Orders\OrderNode;
use App\Models\Qualitys\PerformanceReport;
use App\Models\Qualitys\QualityDocument;
use App\Models\Qualitys\QualityDocumentLocation;
use App\Models\Qualitys\QualityDocumentOrder;
use App\Services\Audit\AuditLogger;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class QualityDocumentService extends Service
{
private const AUDIT_TARGET = 'quality_document';
public function __construct(
private readonly AuditLogger $auditLogger
) {}
/**
* 목록 조회
*/
public function index(array $params)
{
$tenantId = $this->tenantId();
$perPage = (int) ($params['per_page'] ?? 20);
$q = trim((string) ($params['q'] ?? ''));
$status = $params['status'] ?? null;
$dateFrom = $params['date_from'] ?? null;
$dateTo = $params['date_to'] ?? null;
$query = QualityDocument::query()
->where('tenant_id', $tenantId)
->with(['client', 'inspector:id,name', 'creator:id,name', 'documentOrders.order', 'locations']);
if ($q !== '') {
$query->where(function ($qq) use ($q) {
$qq->where('quality_doc_number', 'like', "%{$q}%")
->orWhere('site_name', 'like', "%{$q}%");
});
}
if ($status !== null) {
$dbStatus = QualityDocument::mapStatusFromFrontend($status);
$query->where('status', $dbStatus);
}
if ($dateFrom !== null) {
$query->where('received_date', '>=', $dateFrom);
}
if ($dateTo !== null) {
$query->where('received_date', '<=', $dateTo);
}
$query->orderByDesc('id');
$paginated = $query->paginate($perPage);
$transformedData = $paginated->getCollection()->map(fn ($doc) => $this->transformToFrontend($doc));
return [
'items' => $transformedData,
'current_page' => $paginated->currentPage(),
'last_page' => $paginated->lastPage(),
'per_page' => $paginated->perPage(),
'total' => $paginated->total(),
];
}
/**
* 통계 조회
*/
public function stats(array $params = []): array
{
$tenantId = $this->tenantId();
$query = QualityDocument::where('tenant_id', $tenantId);
if (! empty($params['date_from'])) {
$query->where('received_date', '>=', $params['date_from']);
}
if (! empty($params['date_to'])) {
$query->where('received_date', '<=', $params['date_to']);
}
$counts = (clone $query)
->select('status', DB::raw('count(*) as count'))
->groupBy('status')
->pluck('count', 'status')
->toArray();
return [
'reception_count' => $counts[QualityDocument::STATUS_RECEIVED] ?? 0,
'in_progress_count' => $counts[QualityDocument::STATUS_IN_PROGRESS] ?? 0,
'completed_count' => $counts[QualityDocument::STATUS_COMPLETED] ?? 0,
];
}
/**
* 캘린더 스케줄 조회
*/
public function calendar(array $params): array
{
$tenantId = $this->tenantId();
$year = (int) ($params['year'] ?? now()->year);
$month = (int) ($params['month'] ?? now()->month);
$startDate = sprintf('%04d-%02d-01', $year, $month);
$endDate = date('Y-m-t', strtotime($startDate));
$query = QualityDocument::query()
->where('tenant_id', $tenantId)
->with(['inspector:id,name']);
// options JSON 내 inspection.start_date / inspection.end_date 기준 필터링
// received_date 기준으로 범위 필터 (options JSON은 직접 SQL 필터 어려우므로)
$query->where(function ($q) use ($startDate, $endDate) {
$q->whereBetween('received_date', [$startDate, $endDate]);
});
if (! empty($params['status'])) {
$dbStatus = QualityDocument::mapStatusFromFrontend($params['status']);
$query->where('status', $dbStatus);
}
return $query->orderBy('received_date')
->get()
->map(function (QualityDocument $doc) {
$options = $doc->options ?? [];
$inspection = $options['inspection'] ?? [];
return [
'id' => $doc->id,
'start_date' => $inspection['start_date'] ?? $doc->received_date?->format('Y-m-d'),
'end_date' => $inspection['end_date'] ?? $doc->received_date?->format('Y-m-d'),
'inspector' => $doc->inspector?->name ?? '',
'site_name' => $doc->site_name,
'status' => QualityDocument::mapStatusToFrontend($doc->status),
];
})
->values()
->toArray();
}
/**
* 단건 조회
*/
public function show(int $id)
{
$tenantId = $this->tenantId();
$doc = QualityDocument::where('tenant_id', $tenantId)
->with([
'client',
'inspector:id,name',
'creator:id,name',
'documentOrders.order',
'locations.orderItem.node',
])
->find($id);
if (! $doc) {
throw new NotFoundHttpException(__('error.not_found'));
}
return $this->transformToFrontend($doc, true);
}
/**
* 생성
*/
public function store(array $data)
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
return DB::transaction(function () use ($data, $tenantId, $userId) {
// order_ids는 별도 처리 후 $data에서 제거
$orderIds = $data['order_ids'] ?? null;
unset($data['order_ids']);
$data['tenant_id'] = $tenantId;
$data['quality_doc_number'] = QualityDocument::generateDocNumber($tenantId);
$data['status'] = QualityDocument::STATUS_RECEIVED;
$data['created_by'] = $userId;
$doc = QualityDocument::create($data);
// 수주 연결
if (! empty($orderIds)) {
$this->syncOrders($doc, $orderIds, $tenantId);
}
$this->auditLogger->log(
$tenantId,
self::AUDIT_TARGET,
$doc->id,
'created',
null,
$doc->toArray()
);
$doc->load(['client', 'inspector:id,name', 'creator:id,name', 'documentOrders.order', 'locations.orderItem.node']);
// 요청서 Document(EAV) 자동생성
$this->syncRequestDocument($doc);
return $this->transformToFrontend($doc);
});
}
/**
* 수정
*/
public function update(int $id, array $data)
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$doc = QualityDocument::where('tenant_id', $tenantId)->find($id);
if (! $doc) {
throw new NotFoundHttpException(__('error.not_found'));
}
$beforeData = $doc->toArray();
return DB::transaction(function () use ($doc, $data, $userId, $beforeData, $tenantId) {
// order_ids, locations는 별도 처리 후 $data에서 제거
$orderIds = $data['order_ids'] ?? null;
$locations = $data['locations'] ?? null;
unset($data['order_ids'], $data['locations']);
$data['updated_by'] = $userId;
// options는 기존 값과 병합
if (isset($data['options'])) {
$existingOptions = $doc->options ?? [];
$data['options'] = array_replace_recursive($existingOptions, $data['options']);
}
$doc->update($data);
// 수주 동기화 (order_ids가 전달된 경우만)
if ($orderIds !== null) {
$this->syncOrders($doc, $orderIds, $tenantId);
}
// 개소별 데이터 업데이트 (시공규격, 변경사유, 검사데이터)
if (! empty($locations)) {
$this->updateLocations($doc->id, $locations);
}
$this->auditLogger->log(
$doc->tenant_id,
self::AUDIT_TARGET,
$doc->id,
'updated',
$beforeData,
$doc->fresh()->toArray()
);
$doc->load(['client', 'inspector:id,name', 'creator:id,name', 'documentOrders.order', 'locations.orderItem.node']);
// 요청서 Document(EAV) 동기화
$this->syncRequestDocument($doc);
return $this->transformToFrontend($doc);
});
}
/**
* 삭제
*/
public function destroy(int $id)
{
$tenantId = $this->tenantId();
$doc = QualityDocument::where('tenant_id', $tenantId)->find($id);
if (! $doc) {
throw new NotFoundHttpException(__('error.not_found'));
}
if ($doc->isCompleted()) {
throw new BadRequestHttpException(__('error.quality.cannot_delete_completed'));
}
$beforeData = $doc->toArray();
$doc->deleted_by = $this->apiUserId();
$doc->save();
$doc->delete();
$this->auditLogger->log(
$tenantId,
self::AUDIT_TARGET,
$doc->id,
'deleted',
$beforeData,
null
);
return 'success';
}
/**
* 검사 완료 처리
*/
public function complete(int $id)
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$doc = QualityDocument::where('tenant_id', $tenantId)
->with(['locations'])
->find($id);
if (! $doc) {
throw new NotFoundHttpException(__('error.not_found'));
}
if ($doc->isCompleted()) {
throw new BadRequestHttpException(__('error.quality.already_completed'));
}
// 미완료 개소 확인
$pendingCount = $doc->locations->where('inspection_status', QualityDocumentLocation::STATUS_PENDING)->count();
if ($pendingCount > 0) {
throw new BadRequestHttpException(__('error.quality.pending_locations', ['count' => $pendingCount]));
}
$beforeData = $doc->toArray();
return DB::transaction(function () use ($doc, $userId, $tenantId, $beforeData) {
$doc->update([
'status' => QualityDocument::STATUS_COMPLETED,
'updated_by' => $userId,
]);
// 실적신고 자동 생성
$now = now();
PerformanceReport::firstOrCreate(
[
'tenant_id' => $tenantId,
'quality_document_id' => $doc->id,
],
[
'year' => $now->year,
'quarter' => (int) ceil($now->month / 3),
'confirmation_status' => PerformanceReport::STATUS_UNCONFIRMED,
'created_by' => $userId,
]
);
$this->auditLogger->log(
$tenantId,
self::AUDIT_TARGET,
$doc->id,
'completed',
$beforeData,
$doc->fresh()->toArray()
);
return $this->transformToFrontend($doc->load(['client', 'inspector:id,name', 'creator:id,name']));
});
}
/**
* 검사 미등록 수주 목록
*/
public function availableOrders(array $params): array
{
$tenantId = $this->tenantId();
$q = trim((string) ($params['q'] ?? ''));
$clientId = $params['client_id'] ?? null;
$itemId = $params['item_id'] ?? null;
// 이미 연결된 수주 ID 목록
$linkedOrderIds = QualityDocumentOrder::whereHas('qualityDocument', function ($query) use ($tenantId) {
$query->where('tenant_id', $tenantId);
})->pluck('order_id');
$query = Order::where('tenant_id', $tenantId)
->whereNotIn('id', $linkedOrderIds)
->with(['item:id,name', 'nodes' => function ($q) {
$q->whereNull('parent_id')->orderBy('sort_order')
->with(['items' => function ($q2) {
$q2->orderBy('sort_order')->limit(1);
}]);
}])
->withCount(['nodes as location_count' => function ($q) {
$q->whereNull('parent_id');
}]);
if ($q !== '') {
$query->where(function ($qq) use ($q) {
$qq->where('order_no', 'like', "%{$q}%")
->orWhere('site_name', 'like', "%{$q}%");
});
}
// 같은 거래처(발주처) 필터
if ($clientId) {
$query->where('client_id', $clientId);
}
// 같은 모델(품목) 필터
if ($itemId) {
$query->where('item_id', $itemId);
}
return $query->orderByDesc('id')
->limit(50)
->get()
->map(fn ($order) => [
'id' => $order->id,
'order_number' => $order->order_no,
'site_name' => $order->site_name ?? '',
'client_id' => $order->client_id,
'client_name' => $order->client_name ?? '',
'item_id' => $order->item_id,
'item_name' => $order->item?->name ?? '',
'delivery_date' => $order->delivery_date?->format('Y-m-d') ?? '',
'location_count' => $order->location_count,
'locations' => $order->nodes->where('parent_id', null)->map(function ($node) {
$item = $node->items->first();
$options = $node->options ?? [];
return [
'node_id' => $node->id,
'floor' => $item?->floor_code ?? $node->code ?? '',
'symbol' => $item?->symbol_code ?? '',
'order_width' => $options['width'] ?? 0,
'order_height' => $options['height'] ?? 0,
];
})->values()->toArray(),
])
->toArray();
}
/**
* 개소별 데이터 업데이트
*/
private function updateLocations(int $docId, array $locations): void
{
foreach ($locations as $locData) {
$locId = $locData['id'] ?? null;
if (! $locId) {
continue;
}
$location = QualityDocumentLocation::where('quality_document_id', $docId)->find($locId);
if (! $location) {
continue;
}
$updateData = [];
if (array_key_exists('post_width', $locData)) {
$updateData['post_width'] = $locData['post_width'];
}
if (array_key_exists('post_height', $locData)) {
$updateData['post_height'] = $locData['post_height'];
}
if (array_key_exists('change_reason', $locData)) {
$updateData['change_reason'] = $locData['change_reason'];
}
if (array_key_exists('inspection_data', $locData)) {
$updateData['inspection_data'] = $locData['inspection_data'];
}
if (! empty($updateData)) {
$location->update($updateData);
}
}
}
/**
* 수주 동기화 (update 시 사용)
*/
private function syncOrders(QualityDocument $doc, array $orderIds, int $tenantId): void
{
$existingOrderIds = QualityDocumentOrder::where('quality_document_id', $doc->id)
->pluck('order_id')
->toArray();
$toAttach = array_diff($orderIds, $existingOrderIds);
$toDetach = array_diff($existingOrderIds, $orderIds);
// 새로 연결
foreach ($toAttach as $orderId) {
$order = Order::where('tenant_id', $tenantId)->find($orderId);
if (! $order) {
continue;
}
$docOrder = QualityDocumentOrder::firstOrCreate([
'quality_document_id' => $doc->id,
'order_id' => $orderId,
]);
// 개소(root OrderNode) 기준으로 location 생성
$rootNodes = OrderNode::where('order_id', $orderId)
->whereNull('parent_id')
->orderBy('sort_order')
->get();
foreach ($rootNodes as $node) {
// 각 개소의 대표 OrderItem (해당 노드 하위 첫 번째 품목)
$representativeItem = OrderItem::where('order_node_id', $node->id)
->orderBy('sort_order')
->first();
if ($representativeItem) {
QualityDocumentLocation::firstOrCreate([
'quality_document_id' => $doc->id,
'quality_document_order_id' => $docOrder->id,
'order_item_id' => $representativeItem->id,
]);
}
}
}
// 연결 해제
foreach ($toDetach as $orderId) {
$docOrder = QualityDocumentOrder::where('quality_document_id', $doc->id)
->where('order_id', $orderId)
->first();
if ($docOrder) {
QualityDocumentLocation::where('quality_document_order_id', $docOrder->id)->delete();
$docOrder->delete();
}
}
}
/**
* 수주 연결
*/
public function attachOrders(int $docId, array $orderIds)
{
$tenantId = $this->tenantId();
$doc = QualityDocument::where('tenant_id', $tenantId)->find($docId);
if (! $doc) {
throw new NotFoundHttpException(__('error.not_found'));
}
return DB::transaction(function () use ($doc, $orderIds, $tenantId) {
foreach ($orderIds as $orderId) {
$order = Order::where('tenant_id', $tenantId)->find($orderId);
if (! $order) {
continue;
}
// 중복 체크
$docOrder = QualityDocumentOrder::firstOrCreate([
'quality_document_id' => $doc->id,
'order_id' => $orderId,
]);
// 수주 연결 시 개소(root OrderNode)를 locations에 자동 생성
$rootNodes = OrderNode::where('order_id', $orderId)
->whereNull('parent_id')
->orderBy('sort_order')
->get();
foreach ($rootNodes as $node) {
$representativeItem = OrderItem::where('order_node_id', $node->id)
->orderBy('sort_order')
->first();
if ($representativeItem) {
QualityDocumentLocation::firstOrCreate([
'quality_document_id' => $doc->id,
'quality_document_order_id' => $docOrder->id,
'order_item_id' => $representativeItem->id,
]);
}
}
}
// 상태를 진행중으로 변경 (접수 상태일 때)
if ($doc->isReceived()) {
$doc->update(['status' => QualityDocument::STATUS_IN_PROGRESS]);
}
$doc->load(['client', 'inspector:id,name', 'creator:id,name', 'documentOrders.order', 'locations.orderItem.node']);
// 요청서 Document(EAV) 동기화 (개소 추가됨)
$this->syncRequestDocument($doc);
return $this->transformToFrontend($doc);
});
}
/**
* 수주 연결 해제
*/
public function detachOrder(int $docId, int $orderId)
{
$tenantId = $this->tenantId();
$doc = QualityDocument::where('tenant_id', $tenantId)->find($docId);
if (! $doc) {
throw new NotFoundHttpException(__('error.not_found'));
}
if ($doc->isCompleted()) {
throw new BadRequestHttpException(__('error.quality.cannot_modify_completed'));
}
$docOrder = QualityDocumentOrder::where('quality_document_id', $docId)
->where('order_id', $orderId)
->first();
if ($docOrder) {
// 해당 수주의 locations 삭제
QualityDocumentLocation::where('quality_document_order_id', $docOrder->id)->delete();
$docOrder->delete();
}
return $this->transformToFrontend(
$doc->load(['client', 'inspector:id,name', 'creator:id,name', 'documentOrders.order', 'locations.orderItem.node'])
);
}
/**
* 필수정보 계산
*/
public function calculateRequiredInfo(QualityDocument $doc): string
{
$options = $doc->options ?? [];
$missing = 0;
$sections = [
'construction_site' => ['name', 'land_location', 'lot_number'],
'material_distributor' => ['company', 'address', 'ceo', 'phone'],
'contractor' => ['company', 'address', 'name', 'phone'],
'supervisor' => ['office', 'address', 'name', 'phone'],
];
foreach ($sections as $section => $fields) {
$data = $options[$section] ?? [];
foreach ($fields as $field) {
if (empty($data[$field])) {
$missing++;
break; // 섹션 단위
}
}
}
return $missing === 0 ? '완료' : "{$missing}건 누락";
}
/**
* DB → 프론트엔드 변환
*/
private function transformToFrontend(QualityDocument $doc, bool $detail = false): array
{
$options = $doc->options ?? [];
$result = [
'id' => $doc->id,
'quality_doc_number' => $doc->quality_doc_number,
'site_name' => $doc->site_name,
'client' => $doc->client?->name ?? '',
'location_count' => $doc->locations?->count() ?? 0,
'required_info' => $this->calculateRequiredInfo($doc),
'inspection_period' => $this->formatInspectionPeriod($options),
'inspector' => $doc->inspector?->name ?? '',
'status' => QualityDocument::mapStatusToFrontend($doc->status),
'author' => $doc->creator?->name ?? '',
'reception_date' => $doc->received_date?->format('Y-m-d'),
'manager' => $options['manager']['name'] ?? '',
'manager_contact' => $options['manager']['phone'] ?? '',
];
// 요청서 Document ID (EAV)
$requestDoc = Document::where('tenant_id', $doc->tenant_id)
->where('template_id', self::REQUEST_TEMPLATE_ID)
->where('linkable_type', QualityDocument::class)
->where('linkable_id', $doc->id)
->first();
$result['request_document_id'] = $requestDoc?->id;
if ($detail) {
$result['construction_site'] = [
'site_name' => $options['construction_site']['name'] ?? '',
'land_location' => $options['construction_site']['land_location'] ?? '',
'lot_number' => $options['construction_site']['lot_number'] ?? '',
];
$result['material_distributor'] = [
'company_name' => $options['material_distributor']['company'] ?? '',
'company_address' => $options['material_distributor']['address'] ?? '',
'representative_name' => $options['material_distributor']['ceo'] ?? '',
'phone' => $options['material_distributor']['phone'] ?? '',
];
$result['constructor_info'] = [
'company_name' => $options['contractor']['company'] ?? '',
'company_address' => $options['contractor']['address'] ?? '',
'name' => $options['contractor']['name'] ?? '',
'phone' => $options['contractor']['phone'] ?? '',
];
$result['supervisor'] = [
'office_name' => $options['supervisor']['office'] ?? '',
'office_address' => $options['supervisor']['address'] ?? '',
'name' => $options['supervisor']['name'] ?? '',
'phone' => $options['supervisor']['phone'] ?? '',
];
$result['schedule_info'] = [
'visit_request_date' => $options['inspection']['request_date'] ?? '',
'start_date' => $options['inspection']['start_date'] ?? '',
'end_date' => $options['inspection']['end_date'] ?? '',
'inspector' => $doc->inspector?->name ?? '',
'site_postal_code' => $options['site_address']['postal_code'] ?? '',
'site_address' => $options['site_address']['address'] ?? '',
'site_address_detail' => $options['site_address']['detail'] ?? '',
];
// 개소 목록 (각 location은 1개 root OrderNode = 1개 개소)
$result['order_items'] = $doc->locations->map(function ($loc) {
$orderItem = $loc->orderItem;
$node = $orderItem?->node;
$nodeOptions = $node?->options ?? [];
$order = $loc->qualityDocumentOrder?->order;
return [
'id' => (string) $loc->id,
'order_id' => $order?->id,
'order_number' => $order?->order_no ?? '',
'site_name' => $order?->site_name ?? '',
'client_id' => $order?->client_id,
'client_name' => $order?->client_name ?? '',
'item_id' => $order?->item_id,
'item_name' => $order?->item?->name ?? '',
'delivery_date' => $order?->delivery_date ? $order->delivery_date->format('Y-m-d') : '',
'floor' => $orderItem?->floor_code ?? '',
'symbol' => $orderItem?->symbol_code ?? '',
'order_width' => $nodeOptions['width'] ?? 0,
'order_height' => $nodeOptions['height'] ?? 0,
'construction_width' => $loc->post_width ?? 0,
'construction_height' => $loc->post_height ?? 0,
'change_reason' => $loc->change_reason ?? '',
'inspection_data' => $loc->inspection_data,
'document_id' => $loc->document_id,
];
})->toArray();
}
return $result;
}
/**
* 개소별 검사 저장 (시공후 규격 + 검사 성적서)
*/
public function inspectLocation(int $docId, int $locId, array $data)
{
$tenantId = $this->tenantId();
$doc = QualityDocument::where('tenant_id', $tenantId)->find($docId);
if (! $doc) {
throw new NotFoundHttpException(__('error.not_found'));
}
$location = QualityDocumentLocation::where('quality_document_id', $docId)->find($locId);
if (! $location) {
throw new NotFoundHttpException(__('error.not_found'));
}
return DB::transaction(function () use ($location, $data, $doc) {
$updateData = [];
if (isset($data['post_width'])) {
$updateData['post_width'] = $data['post_width'];
}
if (isset($data['post_height'])) {
$updateData['post_height'] = $data['post_height'];
}
if (isset($data['change_reason'])) {
$updateData['change_reason'] = $data['change_reason'];
}
if (isset($data['inspection_status'])) {
$updateData['inspection_status'] = $data['inspection_status'];
}
if (array_key_exists('inspection_data', $data)) {
$updateData['inspection_data'] = $data['inspection_data'];
}
if (! empty($updateData)) {
$location->update($updateData);
}
// 상태를 진행중으로 변경 (접수 상태일 때)
if ($doc->isReceived()) {
$doc->update(['status' => QualityDocument::STATUS_IN_PROGRESS]);
}
return $location->fresh()->toArray();
});
}
/**
* 검사제품요청서 데이터 (PDF/프린트용)
*/
public function requestDocument(int $id): array
{
$tenantId = $this->tenantId();
$doc = QualityDocument::where('tenant_id', $tenantId)
->with([
'client',
'inspector:id,name',
'documentOrders.order',
'locations.orderItem.node',
])
->find($id);
if (! $doc) {
throw new NotFoundHttpException(__('error.not_found'));
}
$options = $doc->options ?? [];
return [
'quality_doc_number' => $doc->quality_doc_number,
'site_name' => $doc->site_name,
'client' => $doc->client?->name ?? '',
'received_date' => $doc->received_date?->format('Y-m-d'),
'inspector' => $doc->inspector?->name ?? '',
'construction_site' => $options['construction_site'] ?? [],
'material_distributor' => $options['material_distributor'] ?? [],
'contractor' => $options['contractor'] ?? [],
'supervisor' => $options['supervisor'] ?? [],
'inspection' => $options['inspection'] ?? [],
'site_address' => $options['site_address'] ?? [],
'manager' => $options['manager'] ?? [],
'items' => $doc->locations->map(function ($loc) {
$orderItem = $loc->orderItem;
$node = $orderItem?->node;
$nodeOptions = $node?->options ?? [];
$order = $loc->qualityDocumentOrder?->order;
return [
'order_number' => $order?->order_no ?? '',
'floor' => $orderItem?->floor_code ?? '',
'symbol' => $orderItem?->symbol_code ?? '',
'item_name' => $orderItem?->item_name ?? '',
'specification' => $orderItem?->specification ?? '',
'order_width' => $nodeOptions['width'] ?? 0,
'order_height' => $nodeOptions['height'] ?? 0,
'quantity' => $orderItem?->quantity ?? 1,
];
})->toArray(),
];
}
/**
* 제품검사성적서 데이터 (documents EAV 연동)
*/
public function resultDocument(int $id): array
{
$tenantId = $this->tenantId();
$doc = QualityDocument::where('tenant_id', $tenantId)
->with([
'client',
'inspector:id,name',
'locations.orderItem.node',
'locations.document.data',
'locations.document.template',
])
->find($id);
if (! $doc) {
throw new NotFoundHttpException(__('error.not_found'));
}
$options = $doc->options ?? [];
return [
'quality_doc_number' => $doc->quality_doc_number,
'site_name' => $doc->site_name,
'client' => $doc->client?->name ?? '',
'inspector' => $doc->inspector?->name ?? '',
'status' => QualityDocument::mapStatusToFrontend($doc->status),
'locations' => $doc->locations->map(function ($loc) {
$orderItem = $loc->orderItem;
$node = $orderItem?->node;
$nodeOptions = $node?->options ?? [];
$document = $loc->document;
$result = [
'id' => $loc->id,
'floor' => $orderItem?->floor_code ?? '',
'symbol' => $orderItem?->symbol_code ?? '',
'order_width' => $nodeOptions['width'] ?? 0,
'order_height' => $nodeOptions['height'] ?? 0,
'post_width' => $loc->post_width,
'post_height' => $loc->post_height,
'change_reason' => $loc->change_reason,
'inspection_status' => $loc->inspection_status,
'document_id' => $loc->document_id,
];
// EAV 문서 데이터가 있으면 포함
if ($document) {
$result['document'] = [
'id' => $document->id,
'document_no' => $document->document_no,
'status' => $document->status,
'template_id' => $document->template_id,
'data' => $document->data?->map(fn ($d) => [
'field_key' => $d->field_key,
'field_value' => $d->field_value,
'section_id' => $d->section_id,
'column_id' => $d->column_id,
])->toArray() ?? [],
];
}
return $result;
})->toArray(),
];
}
// =========================================================================
// 제품검사 요청서 Document 자동생성/동기화
// =========================================================================
private const REQUEST_TEMPLATE_ID = 66;
/**
* 요청서 Document(EAV) 동기화
*
* quality_document 생성/수정 시 호출.
* - Document 없으면 생성 (template_id=66, linkable=QualityDocument)
* - 기본필드 + 섹션 데이터 + 사전고지 테이블을 EAV로 매핑
*/
private function syncRequestDocument(QualityDocument $doc): void
{
$tenantId = $doc->tenant_id;
// 템플릿 존재 확인
$template = DocumentTemplate::where('tenant_id', $tenantId)
->where('id', self::REQUEST_TEMPLATE_ID)
->with(['basicFields', 'sections.items', 'columns'])
->first();
if (! $template) {
return; // 템플릿 미등록 시 스킵
}
// 기존 Document 조회 또는 생성
$document = Document::where('tenant_id', $tenantId)
->where('template_id', self::REQUEST_TEMPLATE_ID)
->where('linkable_type', QualityDocument::class)
->where('linkable_id', $doc->id)
->first();
if (! $document) {
$documentNo = $this->generateRequestDocumentNo($tenantId);
$document = Document::create([
'tenant_id' => $tenantId,
'template_id' => self::REQUEST_TEMPLATE_ID,
'document_no' => $documentNo,
'title' => '제품검사 요청서 - '.($doc->site_name ?? $doc->quality_doc_number),
'status' => Document::STATUS_DRAFT,
'linkable_type' => QualityDocument::class,
'linkable_id' => $doc->id,
'created_by' => $doc->created_by,
'updated_by' => $doc->updated_by ?? $doc->created_by,
]);
} else {
// rendered_html 초기화 (데이터 변경 시 재캡처 필요)
$document->update([
'rendered_html' => null,
'updated_by' => $doc->updated_by ?? $doc->created_by,
]);
}
// 기존 EAV 데이터 삭제 후 재생성
DocumentData::where('document_id', $document->id)->delete();
$options = $doc->options ?? [];
$eavData = [];
// 1. 기본필드 매핑 (quality_document → basicFields)
$fieldMapping = $this->buildBasicFieldMapping($doc, $options);
foreach ($template->basicFields as $bf) {
$value = $fieldMapping[$bf->field_key] ?? '';
if ($value !== '') {
$eavData[] = [
'document_id' => $document->id,
'section_id' => null,
'column_id' => null,
'row_index' => 0,
'field_key' => $bf->field_key,
'field_value' => (string) $value,
];
}
}
// 2. 섹션 아이템 매핑 (options → section items)
$sectionMapping = $this->buildSectionMapping($options);
foreach ($template->sections as $section) {
if ($section->items->isEmpty()) {
continue; // 사전 고지 정보 섹션은 items가 없으므로 스킵
}
$sectionData = $sectionMapping[$section->title] ?? [];
foreach ($section->items as $item) {
$value = $sectionData[$item->item] ?? '';
if ($value !== '') {
$eavData[] = [
'document_id' => $document->id,
'section_id' => $section->id,
'column_id' => null,
'row_index' => 0,
'field_key' => $item->item, // item name as key
'field_value' => (string) $value,
];
}
}
}
// 3. 사전고지 테이블 매핑 (locations → columns)
$doc->loadMissing(['locations.orderItem.node', 'locations.qualityDocumentOrder.order']);
$columns = $template->columns->sortBy('sort_order');
foreach ($doc->locations as $rowIdx => $loc) {
$orderItem = $loc->orderItem;
$node = $orderItem?->node;
$nodeOptions = $node?->options ?? [];
$order = $loc->qualityDocumentOrder?->order;
$rowData = [
'No.' => (string) ($rowIdx + 1),
'층수' => $orderItem?->floor_code ?? '',
'부호' => $orderItem?->symbol_code ?? '',
'발주 가로' => (string) ($nodeOptions['width'] ?? ''),
'발주 세로' => (string) ($nodeOptions['height'] ?? ''),
'시공 가로' => (string) ($loc->post_width ?? ''),
'시공 세로' => (string) ($loc->post_height ?? ''),
'변경사유' => $loc->change_reason ?? '',
];
foreach ($columns as $col) {
$value = $rowData[$col->label] ?? '';
$eavData[] = [
'document_id' => $document->id,
'section_id' => null,
'column_id' => $col->id,
'row_index' => $rowIdx,
'field_key' => $col->label,
'field_value' => $value,
];
}
}
// EAV 일괄 삽입
if (! empty($eavData)) {
DocumentData::insert(array_map(function ($d) {
$d['created_at'] = now();
$d['updated_at'] = now();
return $d;
}, $eavData));
}
}
/**
* 기본필드 매핑 (quality_document → template basicFields)
*/
private function buildBasicFieldMapping(QualityDocument $doc, array $options): array
{
$manager = $options['manager'] ?? [];
$inspection = $options['inspection'] ?? [];
$siteAddress = $options['site_address'] ?? [];
$order = $doc->documentOrders?->first()?->order;
return [
'client' => $doc->client?->name ?? '',
'company_name' => $manager['company'] ?? '',
'manager' => $manager['name'] ?? '',
'order_number' => $order?->order_no ?? '',
'manager_contact' => $manager['phone'] ?? '',
'site_name' => $doc->site_name ?? '',
'delivery_date' => $order?->delivery_date?->format('Y-m-d') ?? '',
'site_address' => trim(($siteAddress['address'] ?? '').' '.($siteAddress['detail'] ?? '')),
'total_locations' => (string) ($doc->locations?->count() ?? 0),
'receipt_date' => $doc->received_date?->format('Y-m-d') ?? '',
'inspection_request_date' => $inspection['request_date'] ?? '',
];
}
/**
* 섹션 데이터 매핑 (options → section items by section title)
*/
private function buildSectionMapping(array $options): array
{
$cs = $options['construction_site'] ?? [];
$md = $options['material_distributor'] ?? [];
$ct = $options['contractor'] ?? [];
$sv = $options['supervisor'] ?? [];
return [
'건축공사장 정보' => [
'현장명' => $cs['name'] ?? '',
'대지위치' => $cs['land_location'] ?? '',
'지번' => $cs['lot_number'] ?? '',
],
'자재유통업자 정보' => [
'회사명' => $md['company'] ?? '',
'주소' => $md['address'] ?? '',
'대표자' => $md['ceo'] ?? '',
'전화번호' => $md['phone'] ?? '',
],
'공사시공자 정보' => [
'회사명' => $ct['company'] ?? '',
'주소' => $ct['address'] ?? '',
'성명' => $ct['name'] ?? '',
'전화번호' => $ct['phone'] ?? '',
],
'공사감리자 정보' => [
'사무소명' => $sv['office'] ?? '',
'주소' => $sv['address'] ?? '',
'성명' => $sv['name'] ?? '',
'전화번호' => $sv['phone'] ?? '',
],
];
}
/**
* 요청서 문서번호 생성
*/
private function generateRequestDocumentNo(int $tenantId): string
{
$prefix = 'REQ';
$date = now()->format('Ymd');
$lastNumber = Document::where('tenant_id', $tenantId)
->where('document_no', 'like', "{$prefix}-{$date}-%")
->orderByDesc('document_no')
->value('document_no');
$sequence = $lastNumber ? (int) substr($lastNumber, -4) + 1 : 1;
return sprintf('%s-%s-%04d', $prefix, $date, $sequence);
}
private function formatInspectionPeriod(array $options): string
{
$inspection = $options['inspection'] ?? [];
$start = $inspection['start_date'] ?? '';
$end = $inspection['end_date'] ?? '';
if ($start && $end) {
return "{$start}~{$end}";
}
return $start ?: $end ?: '';
}
}