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:
@@ -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'));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 결재 워크플로우
|
||||
// =========================================================================
|
||||
|
||||
30
app/Http/Requests/Document/ResolveRequest.php
Normal file
30
app/Http/Requests/Document/ResolveRequest.php
Normal 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']),
|
||||
];
|
||||
}
|
||||
}
|
||||
51
app/Http/Requests/Document/UpsertRequest.php
Normal file
51
app/Http/Requests/Document/UpsertRequest.php
Normal 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' => '파일']),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 헬퍼 메서드
|
||||
// =========================================================================
|
||||
|
||||
Reference in New Issue
Block a user