feat:문서 resolve/upsert API 추가- React 연동용 resolve API (GET /documents/resolve)

- Upsert API (POST /documents/upsert)
- ResolveRequest, UpsertRequest FormRequest 생성
- DocumentService에 resolve/upsert 로직 추가
- document_category common_codes 마이그레이션
- 에러/성공 메시지 i18n 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-05 14:45:53 +09:00
parent 83d12a8ca2
commit 229ebc7483
8 changed files with 453 additions and 1 deletions

View File

@@ -7,8 +7,10 @@
use App\Http\Requests\Document\ApproveRequest;
use App\Http\Requests\Document\IndexRequest;
use App\Http\Requests\Document\RejectRequest;
use App\Http\Requests\Document\ResolveRequest;
use App\Http\Requests\Document\StoreRequest;
use App\Http\Requests\Document\UpdateRequest;
use App\Http\Requests\Document\UpsertRequest;
use App\Services\DocumentService;
use Illuminate\Http\JsonResponse;
@@ -71,6 +73,32 @@ public function destroy(int $id): JsonResponse
}, __('message.deleted'));
}
// =========================================================================
// Resolve/Upsert (React 연동용)
// =========================================================================
/**
* 문서 Resolve
* GET /v1/documents/resolve?category=incoming_inspection&item_id=12596
*/
public function resolve(ResolveRequest $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
return $this->service->resolve($request->validated());
}, __('message.fetched'));
}
/**
* 문서 Upsert
* POST /v1/documents/upsert
*/
public function upsert(UpsertRequest $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
return $this->service->upsert($request->validated());
}, __('message.saved'));
}
// =========================================================================
// 결재 워크플로우
// =========================================================================

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests\Document;
use Illuminate\Foundation\Http\FormRequest;
class ResolveRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'category' => 'required|string|max:50',
'item_id' => 'required|integer',
];
}
public function messages(): array
{
return [
'category.required' => __('validation.required', ['attribute' => '문서 분류']),
'item_id.required' => __('validation.required', ['attribute' => '품목 ID']),
'item_id.integer' => __('validation.integer', ['attribute' => '품목 ID']),
];
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Http\Requests\Document;
use App\Models\Documents\DocumentAttachment;
use Illuminate\Foundation\Http\FormRequest;
class UpsertRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$attachmentTypes = implode(',', DocumentAttachment::TYPES);
return [
// 필수: 템플릿 + 품목
'template_id' => 'required|integer|exists:document_templates,id',
'item_id' => 'required|integer',
'title' => 'nullable|string|max:255',
// 문서 데이터 (EAV)
'data' => 'nullable|array',
'data.*.section_id' => 'nullable|integer',
'data.*.column_id' => 'nullable|integer',
'data.*.row_index' => 'nullable|integer|min:0',
'data.*.field_key' => 'required_with:data|string|max:100',
'data.*.field_value' => 'nullable|string',
// 첨부파일
'attachments' => 'nullable|array',
'attachments.*.file_id' => 'required_with:attachments|integer|exists:files,id',
'attachments.*.attachment_type' => "nullable|string|in:{$attachmentTypes}",
'attachments.*.description' => 'nullable|string|max:255',
];
}
public function messages(): array
{
return [
'template_id.required' => __('validation.required', ['attribute' => '템플릿']),
'template_id.exists' => __('validation.exists', ['attribute' => '템플릿']),
'item_id.required' => __('validation.required', ['attribute' => '품목 ID']),
'data.*.field_key.required_with' => __('validation.required', ['attribute' => '필드 키']),
'attachments.*.file_id.exists' => __('validation.exists', ['attribute' => '파일']),
];
}
}

View File

@@ -6,9 +6,11 @@
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
{
@@ -443,6 +445,286 @@ public function cancel(int $id): Document
});
}
// =========================================================================
// 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;
$template = DocumentTemplate::query()
->where('tenant_id', $tenantId)
->where('is_active', true)
->where(function ($q) use ($category, $categoryName) {
// category 필드가 code 또는 name과 매칭
$q->where('category', $category)
->orWhere('category', $categoryName)
->orWhere('category', 'LIKE', "%{$categoryName}%");
})
->whereHas('links', function ($q) use ($itemId) {
// 해당 item_id가 연결된 템플릿만
$q->where('source_table', 'items')
->whereHas('linkValues', function ($q2) use ($itemId) {
$q2->where('linkable_id', $itemId);
});
})
->with([
'approvalLines',
'basicFields',
'sections.items',
'columns',
'sectionFields',
'links.linkValues',
])
->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 응답용으로 포맷
*/
private function formatTemplateForReact(DocumentTemplate $template): array
{
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,
'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,
'input_type' => $field->input_type,
'options' => $field->options,
'default_value' => $field->default_value,
'is_required' => $field->is_required,
'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->name,
'sort_order' => $section->sort_order,
'items' => $section->items->map(fn ($item) => [
'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,
'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,
'input_type' => $col->input_type,
'options' => $col->options,
'width' => $col->width,
'is_required' => $col->is_required,
'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,
'file_path' => $a->file->file_path,
'file_size' => $a->file->file_size,
] : 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(),
];
}
// =========================================================================
// 헬퍼 메서드
// =========================================================================