feat: 견적확정 밸리데이션, 작업지시 통계 공정별 카운트, 입고/재고 개선

- 견적확정 시 업체명/현장명/담당자/연락처 필수 검증 추가 (QuoteService)
- 작업지시 stats API에 by_process 공정별 카운트 반환 추가
- 작업지시 목록/상세 쿼리에 수주 개소(rootNodes) 연관 로딩
- 작업지시 품목에 sourceOrderItem.node 관계 추가
- 입고관리 완료건 수정 허용 및 재고 차이 조정
- work_order_step_progress 테이블 마이그레이션
- receivings 테이블 options 컬럼 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 03:27:07 +09:00
parent 6b3e5c3e87
commit 487e651845
22 changed files with 1422 additions and 72 deletions

View File

@@ -489,21 +489,12 @@ public function resolve(array $params): array
];
$categoryName = $categoryMapping[$category] ?? $category;
$template = DocumentTemplate::query()
$baseQuery = 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);
});
->where(function ($q) use ($itemId) {
$q->whereJsonContains('linked_item_ids', (int) $itemId)
->orWhereJsonContains('linked_item_ids', (string) $itemId);
})
->with([
'approvalLines',
@@ -511,10 +502,22 @@ public function resolve(array $params): array
'sections.items',
'columns',
'sectionFields',
'links.linkValues',
])
]);
// 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'));
}
@@ -607,6 +610,19 @@ public function upsert(array $data): Document
*/
private 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,
@@ -620,6 +636,8 @@ private function formatTemplateForReact(DocumentTemplate $template): array
'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,
@@ -648,23 +666,29 @@ private function formatTemplateForReact(DocumentTemplate $template): array
'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(),
'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,
@@ -707,9 +731,11 @@ private function formatDocumentForReact(Document $document): array
'description' => $a->description,
'file' => $a->file ? [
'id' => $a->file->id,
'original_name' => $a->file->original_name,
'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) => [