- 제품검사 문서 생성 시 bf_납품명 형식 → bf_{field->id} 형식으로 변경
- 템플릿 basicFields를 로드하여 field_key 기반 매핑
- mng show.blade.php와 키 형식 통일
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1022 lines
37 KiB
PHP
1022 lines
37 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Documents\Document;
|
|
use App\Models\Documents\DocumentApproval;
|
|
use App\Models\Documents\DocumentAttachment;
|
|
use App\Models\Documents\DocumentData;
|
|
use App\Models\Documents\DocumentTemplate;
|
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
|
|
class DocumentService extends Service
|
|
{
|
|
// =========================================================================
|
|
// 문서 목록/상세
|
|
// =========================================================================
|
|
|
|
/**
|
|
* 문서 목록 조회
|
|
*/
|
|
public function list(array $params): LengthAwarePaginator
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$query = Document::query()
|
|
->where('tenant_id', $tenantId)
|
|
->with([
|
|
'template:id,name,category',
|
|
'creator:id,name',
|
|
]);
|
|
|
|
// 상태 필터
|
|
if (! empty($params['status'])) {
|
|
$query->where('status', $params['status']);
|
|
}
|
|
|
|
// 템플릿 필터
|
|
if (! empty($params['template_id'])) {
|
|
$query->where('template_id', $params['template_id']);
|
|
}
|
|
|
|
// 검색 (문서번호, 제목)
|
|
if (! empty($params['search'])) {
|
|
$search = $params['search'];
|
|
$query->where(function ($q) use ($search) {
|
|
$q->where('document_no', 'like', "%{$search}%")
|
|
->orWhere('title', 'like', "%{$search}%");
|
|
});
|
|
}
|
|
|
|
// 날짜 범위 필터
|
|
if (! empty($params['from_date'])) {
|
|
$query->whereDate('created_at', '>=', $params['from_date']);
|
|
}
|
|
if (! empty($params['to_date'])) {
|
|
$query->whereDate('created_at', '<=', $params['to_date']);
|
|
}
|
|
|
|
// 정렬
|
|
$sortBy = $params['sort_by'] ?? 'created_at';
|
|
$sortDir = $params['sort_dir'] ?? 'desc';
|
|
$query->orderBy($sortBy, $sortDir);
|
|
|
|
$perPage = $params['per_page'] ?? 20;
|
|
|
|
return $query->paginate($perPage);
|
|
}
|
|
|
|
/**
|
|
* 문서 상세 조회
|
|
*/
|
|
public function show(int $id): Document
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
return Document::query()
|
|
->where('tenant_id', $tenantId)
|
|
->with([
|
|
'template:id,name,category,title',
|
|
'template.approvalLines',
|
|
'template.basicFields',
|
|
'template.sections.items',
|
|
'template.columns',
|
|
'approvals.user:id,name',
|
|
'data',
|
|
'attachments.file',
|
|
'creator:id,name',
|
|
'updater:id,name',
|
|
])
|
|
->findOrFail($id);
|
|
}
|
|
|
|
// =========================================================================
|
|
// 문서 생성/수정/삭제
|
|
// =========================================================================
|
|
|
|
/**
|
|
* 문서 생성
|
|
*/
|
|
public function create(array $data): Document
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
|
|
return DB::transaction(function () use ($data, $tenantId, $userId) {
|
|
// 문서번호 생성
|
|
$documentNo = $this->generateDocumentNo($tenantId, $data['template_id']);
|
|
|
|
// 문서 생성
|
|
$document = Document::create([
|
|
'tenant_id' => $tenantId,
|
|
'template_id' => $data['template_id'],
|
|
'document_no' => $documentNo,
|
|
'title' => $data['title'],
|
|
'status' => Document::STATUS_DRAFT,
|
|
'linkable_type' => $data['linkable_type'] ?? null,
|
|
'linkable_id' => $data['linkable_id'] ?? null,
|
|
'created_by' => $userId,
|
|
'updated_by' => $userId,
|
|
]);
|
|
|
|
// 결재선 생성
|
|
if (! empty($data['approvers'])) {
|
|
$this->createApprovals($document, $data['approvers'], $userId);
|
|
}
|
|
|
|
// 문서 데이터 저장
|
|
if (! empty($data['data'])) {
|
|
$this->saveDocumentData($document, $data['data']);
|
|
}
|
|
|
|
// 첨부파일 연결
|
|
if (! empty($data['attachments'])) {
|
|
$this->attachFiles($document, $data['attachments'], $userId);
|
|
}
|
|
|
|
return $document->fresh([
|
|
'template:id,name,category',
|
|
'approvals.user:id,name',
|
|
'data',
|
|
'attachments.file',
|
|
'creator:id,name',
|
|
]);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 문서 수정
|
|
*/
|
|
public function update(int $id, array $data): Document
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
|
|
return DB::transaction(function () use ($id, $data, $tenantId, $userId) {
|
|
$document = Document::query()
|
|
->where('tenant_id', $tenantId)
|
|
->findOrFail($id);
|
|
|
|
// 수정 가능 상태 확인
|
|
if (! $document->canEdit()) {
|
|
throw new BadRequestHttpException(__('error.document.not_editable'));
|
|
}
|
|
|
|
// 기본 정보 수정
|
|
$document->fill([
|
|
'title' => $data['title'] ?? $document->title,
|
|
'linkable_type' => $data['linkable_type'] ?? $document->linkable_type,
|
|
'linkable_id' => $data['linkable_id'] ?? $document->linkable_id,
|
|
'updated_by' => $userId,
|
|
]);
|
|
|
|
// 반려 상태에서 수정 시 DRAFT로 변경
|
|
if ($document->status === Document::STATUS_REJECTED) {
|
|
$document->status = Document::STATUS_DRAFT;
|
|
$document->submitted_at = null;
|
|
$document->completed_at = null;
|
|
}
|
|
|
|
$document->save();
|
|
|
|
// 결재선 수정
|
|
if (isset($data['approvers'])) {
|
|
$document->approvals()->delete();
|
|
if (! empty($data['approvers'])) {
|
|
$this->createApprovals($document, $data['approvers'], $userId);
|
|
}
|
|
}
|
|
|
|
// 문서 데이터 수정
|
|
if (isset($data['data'])) {
|
|
$document->data()->delete();
|
|
if (! empty($data['data'])) {
|
|
$this->saveDocumentData($document, $data['data']);
|
|
}
|
|
}
|
|
|
|
// 첨부파일 수정
|
|
if (isset($data['attachments'])) {
|
|
$document->attachments()->delete();
|
|
if (! empty($data['attachments'])) {
|
|
$this->attachFiles($document, $data['attachments'], $userId);
|
|
}
|
|
}
|
|
|
|
return $document->fresh([
|
|
'template:id,name,category',
|
|
'approvals.user:id,name',
|
|
'data',
|
|
'attachments.file',
|
|
'creator:id,name',
|
|
]);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 문서 삭제
|
|
*/
|
|
public function destroy(int $id): bool
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
|
|
return DB::transaction(function () use ($id, $tenantId, $userId) {
|
|
$document = Document::query()
|
|
->where('tenant_id', $tenantId)
|
|
->findOrFail($id);
|
|
|
|
// DRAFT 상태만 삭제 가능
|
|
if ($document->status !== Document::STATUS_DRAFT) {
|
|
throw new BadRequestHttpException(__('error.document.not_deletable'));
|
|
}
|
|
|
|
$document->deleted_by = $userId;
|
|
$document->save();
|
|
$document->delete();
|
|
|
|
return true;
|
|
});
|
|
}
|
|
|
|
// =========================================================================
|
|
// 결재 처리
|
|
// =========================================================================
|
|
|
|
/**
|
|
* 결재 요청 (DRAFT → PENDING)
|
|
*/
|
|
public function submit(int $id): Document
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
|
|
return DB::transaction(function () use ($id, $tenantId, $userId) {
|
|
$document = Document::query()
|
|
->where('tenant_id', $tenantId)
|
|
->findOrFail($id);
|
|
|
|
// 결재 요청 가능 상태 확인
|
|
if (! $document->canSubmit()) {
|
|
throw new BadRequestHttpException(__('error.document.not_submittable'));
|
|
}
|
|
|
|
// 결재선 존재 확인
|
|
$approvalCount = $document->approvals()->count();
|
|
if ($approvalCount === 0) {
|
|
throw new BadRequestHttpException(__('error.document.approvers_required'));
|
|
}
|
|
|
|
$document->status = Document::STATUS_PENDING;
|
|
$document->submitted_at = now();
|
|
$document->updated_by = $userId;
|
|
$document->save();
|
|
|
|
return $document->fresh([
|
|
'template:id,name,category',
|
|
'approvals.user:id,name',
|
|
'creator:id,name',
|
|
]);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 결재 승인
|
|
*/
|
|
public function approve(int $id, ?string $comment = null): Document
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
|
|
return DB::transaction(function () use ($id, $comment, $tenantId, $userId) {
|
|
$document = Document::query()
|
|
->where('tenant_id', $tenantId)
|
|
->findOrFail($id);
|
|
|
|
// 결재 가능 상태 확인
|
|
if (! $document->canApprove()) {
|
|
throw new BadRequestHttpException(__('error.document.not_approvable'));
|
|
}
|
|
|
|
// 현재 사용자의 대기 중인 결재 단계 찾기
|
|
$myApproval = $document->approvals()
|
|
->where('user_id', $userId)
|
|
->where('status', DocumentApproval::STATUS_PENDING)
|
|
->orderBy('step')
|
|
->first();
|
|
|
|
if (! $myApproval) {
|
|
throw new BadRequestHttpException(__('error.document.not_your_turn'));
|
|
}
|
|
|
|
// 순차 결재 확인 (이전 단계가 완료되었는지)
|
|
$pendingBefore = $document->approvals()
|
|
->where('step', '<', $myApproval->step)
|
|
->where('status', DocumentApproval::STATUS_PENDING)
|
|
->exists();
|
|
|
|
if ($pendingBefore) {
|
|
throw new BadRequestHttpException(__('error.document.not_your_turn'));
|
|
}
|
|
|
|
// 승인 처리
|
|
$myApproval->status = DocumentApproval::STATUS_APPROVED;
|
|
$myApproval->comment = $comment;
|
|
$myApproval->acted_at = now();
|
|
$myApproval->updated_by = $userId;
|
|
$myApproval->save();
|
|
|
|
// 모든 결재 완료 확인
|
|
$allApproved = ! $document->approvals()
|
|
->where('status', DocumentApproval::STATUS_PENDING)
|
|
->exists();
|
|
|
|
if ($allApproved) {
|
|
$document->status = Document::STATUS_APPROVED;
|
|
$document->completed_at = now();
|
|
}
|
|
|
|
$document->updated_by = $userId;
|
|
$document->save();
|
|
|
|
return $document->fresh([
|
|
'template:id,name,category',
|
|
'approvals.user:id,name',
|
|
'creator:id,name',
|
|
]);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 결재 반려
|
|
*/
|
|
public function reject(int $id, string $comment): Document
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
|
|
return DB::transaction(function () use ($id, $comment, $tenantId, $userId) {
|
|
$document = Document::query()
|
|
->where('tenant_id', $tenantId)
|
|
->findOrFail($id);
|
|
|
|
// 결재 가능 상태 확인
|
|
if (! $document->canApprove()) {
|
|
throw new BadRequestHttpException(__('error.document.not_approvable'));
|
|
}
|
|
|
|
// 현재 사용자의 대기 중인 결재 단계 찾기
|
|
$myApproval = $document->approvals()
|
|
->where('user_id', $userId)
|
|
->where('status', DocumentApproval::STATUS_PENDING)
|
|
->orderBy('step')
|
|
->first();
|
|
|
|
if (! $myApproval) {
|
|
throw new BadRequestHttpException(__('error.document.not_your_turn'));
|
|
}
|
|
|
|
// 순차 결재 확인
|
|
$pendingBefore = $document->approvals()
|
|
->where('step', '<', $myApproval->step)
|
|
->where('status', DocumentApproval::STATUS_PENDING)
|
|
->exists();
|
|
|
|
if ($pendingBefore) {
|
|
throw new BadRequestHttpException(__('error.document.not_your_turn'));
|
|
}
|
|
|
|
// 반려 처리
|
|
$myApproval->status = DocumentApproval::STATUS_REJECTED;
|
|
$myApproval->comment = $comment;
|
|
$myApproval->acted_at = now();
|
|
$myApproval->updated_by = $userId;
|
|
$myApproval->save();
|
|
|
|
// 문서 반려 상태로 변경
|
|
$document->status = Document::STATUS_REJECTED;
|
|
$document->completed_at = now();
|
|
$document->updated_by = $userId;
|
|
$document->save();
|
|
|
|
return $document->fresh([
|
|
'template:id,name,category',
|
|
'approvals.user:id,name',
|
|
'creator:id,name',
|
|
]);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 결재 취소/회수 (작성자만)
|
|
*/
|
|
public function cancel(int $id): Document
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
|
|
return DB::transaction(function () use ($id, $tenantId, $userId) {
|
|
$document = Document::query()
|
|
->where('tenant_id', $tenantId)
|
|
->findOrFail($id);
|
|
|
|
// 취소 가능 상태 확인
|
|
if (! $document->canCancel()) {
|
|
throw new BadRequestHttpException(__('error.document.not_cancellable'));
|
|
}
|
|
|
|
// 작성자만 취소 가능
|
|
if ($document->created_by !== $userId) {
|
|
throw new BadRequestHttpException(__('error.document.only_creator_can_cancel'));
|
|
}
|
|
|
|
$document->status = Document::STATUS_CANCELLED;
|
|
$document->completed_at = now();
|
|
$document->updated_by = $userId;
|
|
$document->save();
|
|
|
|
return $document->fresh([
|
|
'template:id,name,category',
|
|
'creator:id,name',
|
|
]);
|
|
});
|
|
}
|
|
|
|
// =========================================================================
|
|
// FQC 일괄생성 (제품검사)
|
|
// =========================================================================
|
|
|
|
/**
|
|
* 수주 개소별 제품검사 문서 일괄생성
|
|
*
|
|
* Order의 OrderItem 수만큼 Document를 DRAFT 상태로 생성.
|
|
* 기본필드(납품명, 제품명, 발주처, LOT NO, 로트크기) 자동매핑.
|
|
*/
|
|
public function bulkCreateFqc(array $data): array
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
$templateId = $data['template_id'];
|
|
$orderId = $data['order_id'];
|
|
|
|
// 템플릿 존재 확인
|
|
$template = DocumentTemplate::where('tenant_id', $tenantId)
|
|
->where('is_active', true)
|
|
->findOrFail($templateId);
|
|
|
|
// 수주 + 개소 조회
|
|
$order = \App\Models\Orders\Order::where('tenant_id', $tenantId)
|
|
->with('items')
|
|
->findOrFail($orderId);
|
|
|
|
if ($order->items->isEmpty()) {
|
|
throw new BadRequestHttpException(__('error.document.no_order_items'));
|
|
}
|
|
|
|
// 이미 생성된 문서 확인 (중복 방지)
|
|
$existingLinkableIds = Document::where('tenant_id', $tenantId)
|
|
->where('template_id', $templateId)
|
|
->where('linkable_type', \App\Models\Orders\OrderItem::class)
|
|
->whereIn('linkable_id', $order->items->pluck('id'))
|
|
->pluck('linkable_id')
|
|
->toArray();
|
|
|
|
$itemsToCreate = $order->items->reject(function ($item) use ($existingLinkableIds) {
|
|
return in_array($item->id, $existingLinkableIds);
|
|
});
|
|
|
|
if ($itemsToCreate->isEmpty()) {
|
|
throw new BadRequestHttpException(__('error.document.already_created'));
|
|
}
|
|
|
|
// 템플릿의 기본필드 로드 (bf_{id} 형식으로 저장하기 위해)
|
|
$template = DocumentTemplate::with('basicFields')->find($templateId);
|
|
|
|
return DB::transaction(function () use ($itemsToCreate, $order, $templateId, $tenantId, $userId, $template) {
|
|
$documents = [];
|
|
|
|
foreach ($itemsToCreate as $orderItem) {
|
|
// 문서번호 생성
|
|
$documentNo = $this->generateDocumentNo($tenantId, $templateId);
|
|
|
|
// 개소 식별 문자열
|
|
$locationLabel = trim("{$orderItem->floor_code}-{$orderItem->symbol_code}");
|
|
$specLabel = $orderItem->specification ?? '';
|
|
$titleSuffix = $specLabel ? "{$locationLabel} ({$specLabel})" : $locationLabel;
|
|
|
|
// Document 생성
|
|
$document = Document::create([
|
|
'tenant_id' => $tenantId,
|
|
'template_id' => $templateId,
|
|
'document_no' => $documentNo,
|
|
'title' => "제품검사 - {$titleSuffix}",
|
|
'status' => Document::STATUS_DRAFT,
|
|
'linkable_type' => \App\Models\Orders\OrderItem::class,
|
|
'linkable_id' => $orderItem->id,
|
|
'created_by' => $userId,
|
|
'updated_by' => $userId,
|
|
]);
|
|
|
|
// 기본필드 자동매핑 (bf_{id} 형식, mng show.blade.php 호환)
|
|
$resolveMap = [
|
|
'product_name' => $orderItem->item_name,
|
|
'client' => $order->client_name,
|
|
'lot_no' => $order->order_no,
|
|
'lot_size' => '1 EA',
|
|
'site_name' => $order->site_name ?? '',
|
|
];
|
|
|
|
if ($template && $template->basicFields) {
|
|
foreach ($template->basicFields as $field) {
|
|
$value = $resolveMap[$field->field_key] ?? '';
|
|
|
|
// field_key가 없는 필드는 라벨 매칭
|
|
if (! $value && ! $field->field_key) {
|
|
if (str_contains($field->label, '납품')) {
|
|
$value = $order->site_name ?? $order->order_no;
|
|
}
|
|
}
|
|
|
|
if ($value) {
|
|
DocumentData::create([
|
|
'document_id' => $document->id,
|
|
'section_id' => null,
|
|
'column_id' => null,
|
|
'row_index' => 0,
|
|
'field_key' => "bf_{$field->id}",
|
|
'field_value' => (string) $value,
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
$documents[] = $document;
|
|
}
|
|
|
|
return [
|
|
'created_count' => count($documents),
|
|
'skipped_count' => count($existingLinkableIds ?? []),
|
|
'total_items' => count($documents) + count($existingLinkableIds ?? []),
|
|
'documents' => collect($documents)->map(fn ($doc) => [
|
|
'id' => $doc->id,
|
|
'document_no' => $doc->document_no,
|
|
'title' => $doc->title,
|
|
'status' => $doc->status,
|
|
'linkable_id' => $doc->linkable_id,
|
|
])->toArray(),
|
|
];
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 수주 개소별 FQC 진행현황 조회
|
|
*/
|
|
public function fqcStatus(int $orderId, int $templateId): array
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$order = \App\Models\Orders\Order::where('tenant_id', $tenantId)
|
|
->with('items')
|
|
->findOrFail($orderId);
|
|
|
|
// 해당 수주의 FQC 문서 조회
|
|
$documents = Document::where('tenant_id', $tenantId)
|
|
->where('template_id', $templateId)
|
|
->where('linkable_type', \App\Models\Orders\OrderItem::class)
|
|
->whereIn('linkable_id', $order->items->pluck('id'))
|
|
->with('data')
|
|
->get()
|
|
->keyBy('linkable_id');
|
|
|
|
$items = $order->items->map(function ($orderItem) use ($documents) {
|
|
$doc = $documents->get($orderItem->id);
|
|
|
|
// 종합판정 값 추출
|
|
$judgement = null;
|
|
if ($doc) {
|
|
$judgementData = $doc->data->firstWhere('field_key', 'footer_judgement');
|
|
$judgement = $judgementData?->field_value;
|
|
}
|
|
|
|
return [
|
|
'order_item_id' => $orderItem->id,
|
|
'floor_code' => $orderItem->floor_code,
|
|
'symbol_code' => $orderItem->symbol_code,
|
|
'specification' => $orderItem->specification,
|
|
'item_name' => $orderItem->item_name,
|
|
'document_id' => $doc?->id,
|
|
'document_no' => $doc?->document_no,
|
|
'status' => $doc?->status ?? 'NONE',
|
|
'judgement' => $judgement,
|
|
];
|
|
});
|
|
|
|
// 통계
|
|
$total = $items->count();
|
|
$created = $items->where('status', '!=', 'NONE')->count();
|
|
$approved = $items->where('status', 'APPROVED')->count();
|
|
$passed = $items->where('judgement', '합격')->count();
|
|
$failed = $items->where('judgement', '불합격')->count();
|
|
|
|
return [
|
|
'order_id' => $orderId,
|
|
'order_no' => $order->order_no,
|
|
'total' => $total,
|
|
'created' => $created,
|
|
'approved' => $approved,
|
|
'passed' => $passed,
|
|
'failed' => $failed,
|
|
'pending' => $total - $created,
|
|
'items' => $items->toArray(),
|
|
];
|
|
}
|
|
|
|
// =========================================================================
|
|
// Resolve/Upsert (React 연동용)
|
|
// =========================================================================
|
|
|
|
/**
|
|
* 문서 Resolve (category + item_id로 템플릿 + 기존 문서 조회)
|
|
*
|
|
* React에서 문서 작성 시:
|
|
* 1. category + item_id로 해당 품목이 연결된 템플릿 조회
|
|
* 2. 기존 문서가 있으면 그 문서를, 없으면 빈 폼 + is_new=true 반환
|
|
*/
|
|
public function resolve(array $params): array
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$category = $params['category'];
|
|
$itemId = $params['item_id'];
|
|
|
|
// 1. common_codes에서 category 유효성 확인 (tenant 우선, 없으면 global)
|
|
$validCategory = DB::table('common_codes')
|
|
->where('code_group', 'document_category')
|
|
->where('code', $category)
|
|
->where('is_active', true)
|
|
->where(function ($q) use ($tenantId) {
|
|
$q->where('tenant_id', $tenantId)
|
|
->orWhereNull('tenant_id');
|
|
})
|
|
->orderByRaw('tenant_id IS NULL') // tenant 우선
|
|
->first();
|
|
|
|
if (! $validCategory) {
|
|
throw new BadRequestHttpException(__('error.document.invalid_category'));
|
|
}
|
|
|
|
// 2. category에 매칭되는 템플릿 + 해당 item_id가 연결된 것 조회
|
|
// category 필드 값은 기존 데이터가 "수입검사", "품질검사" 등 한글 또는
|
|
// common_code의 code와 매핑되어야 함
|
|
// 우선 code_name 매핑: incoming_inspection → 수입검사
|
|
$categoryMapping = [
|
|
'incoming_inspection' => '수입검사',
|
|
'quality_inspection' => '품질검사',
|
|
'outgoing_inspection' => '출하검사',
|
|
];
|
|
$categoryName = $categoryMapping[$category] ?? $category;
|
|
|
|
$baseQuery = DocumentTemplate::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('is_active', true)
|
|
->where(function ($q) use ($itemId) {
|
|
$q->whereJsonContains('linked_item_ids', (int) $itemId)
|
|
->orWhereJsonContains('linked_item_ids', (string) $itemId);
|
|
})
|
|
->with([
|
|
'approvalLines',
|
|
'basicFields',
|
|
'sections.items',
|
|
'columns',
|
|
'sectionFields',
|
|
]);
|
|
|
|
// 1차: category 매칭 + item_id
|
|
$template = (clone $baseQuery)
|
|
->where(function ($q) use ($category, $categoryName) {
|
|
$q->where('category', $category)
|
|
->orWhere('category', $categoryName)
|
|
->orWhere('category', 'LIKE', "%{$categoryName}%");
|
|
})
|
|
->first();
|
|
|
|
// 2차: category 무관, item_id 연결만으로 fallback
|
|
if (! $template) {
|
|
$template = $baseQuery->first();
|
|
}
|
|
|
|
if (! $template) {
|
|
throw new NotFoundHttpException(__('error.document.template_not_found'));
|
|
}
|
|
|
|
// 3. 기존 문서 조회 (template + item_id, 수정 가능한 상태만)
|
|
$existingDocument = Document::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('template_id', $template->id)
|
|
->where('linkable_type', 'item')
|
|
->where('linkable_id', $itemId)
|
|
->whereIn('status', [Document::STATUS_DRAFT, Document::STATUS_REJECTED])
|
|
->with(['data', 'attachments.file', 'approvals.user:id,name'])
|
|
->first();
|
|
|
|
// 4. 품목 정보 조회 (auto-highlight용 속성 포함)
|
|
$item = DB::table('items')
|
|
->where('id', $itemId)
|
|
->where('tenant_id', $tenantId)
|
|
->select('id', 'code', 'name', 'attributes')
|
|
->first();
|
|
|
|
if (! $item) {
|
|
throw new NotFoundHttpException(__('error.item.not_found'));
|
|
}
|
|
|
|
// 5. 응답 구성
|
|
return [
|
|
'is_new' => $existingDocument === null,
|
|
'template' => $this->formatTemplateForReact($template),
|
|
'document' => $existingDocument ? $this->formatDocumentForReact($existingDocument) : null,
|
|
'item' => [
|
|
'id' => $item->id,
|
|
'code' => $item->code,
|
|
'name' => $item->name,
|
|
'attributes' => $item->attributes ? json_decode($item->attributes, true) : null,
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 문서 Upsert (INSERT if new, UPDATE if exists)
|
|
*
|
|
* React에서 문서 저장 시:
|
|
* - 기존 문서가 있으면 update
|
|
* - 없으면 create
|
|
*/
|
|
public function upsert(array $data): Document
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
|
|
return DB::transaction(function () use ($data, $tenantId) {
|
|
$templateId = $data['template_id'];
|
|
$itemId = $data['item_id'];
|
|
|
|
// 기존 문서 조회 (수정 가능한 상태만)
|
|
$existingDocument = Document::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('template_id', $templateId)
|
|
->where('linkable_type', 'item')
|
|
->where('linkable_id', $itemId)
|
|
->whereIn('status', [Document::STATUS_DRAFT, Document::STATUS_REJECTED])
|
|
->first();
|
|
|
|
if ($existingDocument) {
|
|
// UPDATE: 기존 update 로직 재사용
|
|
return $this->update($existingDocument->id, [
|
|
'title' => $data['title'] ?? $existingDocument->title,
|
|
'linkable_type' => 'item',
|
|
'linkable_id' => $itemId,
|
|
'data' => $data['data'] ?? [],
|
|
'attachments' => $data['attachments'] ?? [],
|
|
]);
|
|
}
|
|
|
|
// CREATE: 기존 create 로직 재사용
|
|
return $this->create([
|
|
'template_id' => $templateId,
|
|
'title' => $data['title'] ?? '',
|
|
'linkable_type' => 'item',
|
|
'linkable_id' => $itemId,
|
|
'data' => $data['data'] ?? [],
|
|
'attachments' => $data['attachments'] ?? [],
|
|
]);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 템플릿을 React 응답용으로 포맷
|
|
*/
|
|
public function formatTemplateForReact(DocumentTemplate $template): array
|
|
{
|
|
// common_codes에서 inspection_method 코드 목록 조회 (code → name 매핑)
|
|
$tenantId = $this->tenantId();
|
|
$methodCodes = DB::table('common_codes')
|
|
->where('code_group', 'inspection_method')
|
|
->where('is_active', true)
|
|
->where(function ($q) use ($tenantId) {
|
|
$q->where('tenant_id', $tenantId)
|
|
->orWhereNull('tenant_id');
|
|
})
|
|
->orderByRaw('tenant_id IS NULL') // tenant 우선
|
|
->pluck('name', 'code')
|
|
->toArray();
|
|
|
|
return [
|
|
'id' => $template->id,
|
|
'name' => $template->name,
|
|
'category' => $template->category,
|
|
'title' => $template->title,
|
|
'company_name' => $template->company_name,
|
|
'company_address' => $template->company_address,
|
|
'company_contact' => $template->company_contact,
|
|
'footer_remark_label' => $template->footer_remark_label,
|
|
'footer_judgement_label' => $template->footer_judgement_label,
|
|
'footer_judgement_options' => $template->footer_judgement_options,
|
|
'approval_lines' => $template->approvalLines->map(fn ($line) => [
|
|
'id' => $line->id,
|
|
'name' => $line->name,
|
|
'dept' => $line->dept,
|
|
'role' => $line->role,
|
|
'user_id' => $line->user_id,
|
|
'sort_order' => $line->sort_order,
|
|
])->toArray(),
|
|
'basic_fields' => $template->basicFields->map(fn ($field) => [
|
|
'id' => $field->id,
|
|
'field_key' => $field->field_key,
|
|
'label' => $field->label,
|
|
'field_type' => $field->field_type,
|
|
'default_value' => $field->default_value,
|
|
'sort_order' => $field->sort_order,
|
|
])->toArray(),
|
|
'section_fields' => $template->sectionFields->map(fn ($field) => [
|
|
'id' => $field->id,
|
|
'field_key' => $field->field_key,
|
|
'label' => $field->label,
|
|
'field_type' => $field->field_type,
|
|
'options' => $field->options,
|
|
'width' => $field->width,
|
|
'is_required' => $field->is_required,
|
|
'sort_order' => $field->sort_order,
|
|
])->toArray(),
|
|
'sections' => $template->sections->map(fn ($section) => [
|
|
'id' => $section->id,
|
|
'name' => $section->title,
|
|
'title' => $section->title,
|
|
'image_path' => $section->image_path,
|
|
'sort_order' => $section->sort_order,
|
|
'items' => $section->items->map(function ($item) use ($methodCodes) {
|
|
// method 코드를 한글 이름으로 변환
|
|
$methodName = $item->method ? ($methodCodes[$item->method] ?? $item->method) : null;
|
|
|
|
return [
|
|
'id' => $item->id,
|
|
'field_values' => $item->field_values ?? [],
|
|
// 레거시 필드도 포함 (하위 호환)
|
|
'category' => $item->category,
|
|
'item' => $item->item,
|
|
'standard' => $item->standard,
|
|
'standard_criteria' => $item->standard_criteria,
|
|
'tolerance' => $item->tolerance,
|
|
'method' => $item->method,
|
|
'method_name' => $methodName, // 검사방식 한글 이름 추가
|
|
'measurement_type' => $item->measurement_type,
|
|
'frequency' => $item->frequency,
|
|
'frequency_n' => $item->frequency_n,
|
|
'frequency_c' => $item->frequency_c,
|
|
'regulation' => $item->regulation,
|
|
'sort_order' => $item->sort_order,
|
|
];
|
|
})->toArray(),
|
|
])->toArray(),
|
|
'columns' => $template->columns->map(fn ($col) => [
|
|
'id' => $col->id,
|
|
'label' => $col->label,
|
|
'column_type' => $col->column_type,
|
|
'sub_labels' => $col->sub_labels,
|
|
'group_name' => $col->group_name,
|
|
'width' => $col->width,
|
|
'sort_order' => $col->sort_order,
|
|
])->toArray(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 문서를 React 응답용으로 포맷
|
|
*/
|
|
private function formatDocumentForReact(Document $document): array
|
|
{
|
|
return [
|
|
'id' => $document->id,
|
|
'document_no' => $document->document_no,
|
|
'title' => $document->title,
|
|
'status' => $document->status,
|
|
'linkable_type' => $document->linkable_type,
|
|
'linkable_id' => $document->linkable_id,
|
|
'submitted_at' => $document->submitted_at?->toIso8601String(),
|
|
'completed_at' => $document->completed_at?->toIso8601String(),
|
|
'created_at' => $document->created_at?->toIso8601String(),
|
|
'data' => $document->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,
|
|
])->toArray(),
|
|
'attachments' => $document->attachments->map(fn ($a) => [
|
|
'id' => $a->id,
|
|
'file_id' => $a->file_id,
|
|
'attachment_type' => $a->attachment_type,
|
|
'description' => $a->description,
|
|
'file' => $a->file ? [
|
|
'id' => $a->file->id,
|
|
'original_name' => $a->file->original_name ?? $a->file->display_name ?? $a->file->stored_name,
|
|
'display_name' => $a->file->display_name,
|
|
'file_path' => $a->file->file_path,
|
|
'file_size' => $a->file->file_size,
|
|
'mime_type' => $a->file->mime_type,
|
|
] : null,
|
|
])->toArray(),
|
|
'approvals' => $document->approvals->map(fn ($ap) => [
|
|
'id' => $ap->id,
|
|
'user_id' => $ap->user_id,
|
|
'user_name' => $ap->user?->name,
|
|
'step' => $ap->step,
|
|
'role' => $ap->role,
|
|
'status' => $ap->status,
|
|
'comment' => $ap->comment,
|
|
'acted_at' => $ap->acted_at?->toIso8601String(),
|
|
])->toArray(),
|
|
];
|
|
}
|
|
|
|
// =========================================================================
|
|
// 헬퍼 메서드
|
|
// =========================================================================
|
|
|
|
/**
|
|
* 문서번호 생성
|
|
*/
|
|
private function generateDocumentNo(int $tenantId, int $templateId): string
|
|
{
|
|
$prefix = 'DOC';
|
|
$date = now()->format('Ymd');
|
|
|
|
// 오늘 생성된 문서 중 마지막 번호 조회
|
|
$lastNumber = Document::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('document_no', 'like', "{$prefix}-{$date}-%")
|
|
->orderByDesc('document_no')
|
|
->value('document_no');
|
|
|
|
if ($lastNumber) {
|
|
$sequence = (int) substr($lastNumber, -4) + 1;
|
|
} else {
|
|
$sequence = 1;
|
|
}
|
|
|
|
return sprintf('%s-%s-%04d', $prefix, $date, $sequence);
|
|
}
|
|
|
|
/**
|
|
* 결재선 생성
|
|
*/
|
|
private function createApprovals(Document $document, array $approvers, int $userId): void
|
|
{
|
|
foreach ($approvers as $index => $approver) {
|
|
DocumentApproval::create([
|
|
'document_id' => $document->id,
|
|
'user_id' => $approver['user_id'],
|
|
'step' => $index + 1,
|
|
'role' => $approver['role'] ?? '승인',
|
|
'status' => DocumentApproval::STATUS_PENDING,
|
|
'created_by' => $userId,
|
|
'updated_by' => $userId,
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 문서 데이터 저장
|
|
*/
|
|
private function saveDocumentData(Document $document, array $dataItems): void
|
|
{
|
|
foreach ($dataItems as $item) {
|
|
DocumentData::create([
|
|
'document_id' => $document->id,
|
|
'section_id' => $item['section_id'] ?? null,
|
|
'column_id' => $item['column_id'] ?? null,
|
|
'row_index' => $item['row_index'] ?? 0,
|
|
'field_key' => $item['field_key'],
|
|
'field_value' => $item['field_value'] ?? null,
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 첨부파일 연결
|
|
*/
|
|
private function attachFiles(Document $document, array $attachments, int $userId): void
|
|
{
|
|
foreach ($attachments as $attachment) {
|
|
DocumentAttachment::create([
|
|
'document_id' => $document->id,
|
|
'file_id' => $attachment['file_id'],
|
|
'attachment_type' => $attachment['attachment_type'] ?? DocumentAttachment::TYPE_GENERAL,
|
|
'description' => $attachment['description'] ?? null,
|
|
'created_by' => $userId,
|
|
]);
|
|
}
|
|
}
|
|
}
|