feat: [inspection] Phase 3 절곡 검사 동적 구현 — inspection-config API + 트랜잭션 보강

- inspection-config API 신규: GET /work-orders/{id}/inspection-config
  - 공정 자동 판별 (resolveInspectionProcessType)
  - bending_info 기반 구성품 목록 + gap_points 반환
  - BENDING_GAP_PROFILES 상수 (6개 구성품 간격 기준치)
- createInspectionDocument 트랜잭션 보강
  - DB::transaction() + lockForUpdate() 적용
  - 동시 생성 race condition 방지

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 16:36:32 +09:00
parent e6c02292d2
commit f970b6bf4b
3 changed files with 263 additions and 66 deletions

View File

@@ -5,6 +5,7 @@
use App\Models\Documents\Document;
use App\Models\Documents\DocumentTemplate;
use App\Models\Orders\Order;
use App\Models\Process;
use App\Models\Production\WorkOrder;
use App\Models\Production\WorkOrderAssignee;
use App\Models\Production\WorkOrderBendingDetail;
@@ -1949,6 +1950,186 @@ public function getInspectionData(int $workOrderId, array $params = []): array
];
}
// ──────────────────────────────────────────────────────────────
// 검사 설정 (inspection-config)
// ──────────────────────────────────────────────────────────────
/**
* 절곡 검사 기준 간격 프로파일 (단면 치수 공학 사양)
* 향후 DB 테이블 또는 테넌트 설정으로 이관 가능
*/
private const BENDING_GAP_PROFILES = [
'guide_rail_wall' => [
'name' => '가이드레일 벽면',
'gap_points' => [
['point' => '(1)', 'design_value' => '30'],
['point' => '(2)', 'design_value' => '78'],
['point' => '(3)', 'design_value' => '25'],
['point' => '(4)', 'design_value' => '45'],
],
],
'guide_rail_side' => [
'name' => '가이드레일 측면',
'gap_points' => [
['point' => '(1)', 'design_value' => '28'],
['point' => '(2)', 'design_value' => '75'],
['point' => '(3)', 'design_value' => '42'],
['point' => '(4)', 'design_value' => '38'],
['point' => '(5)', 'design_value' => '32'],
],
],
'bottom_bar' => [
'name' => '하단마감재',
'gap_points' => [
['point' => '(1)', 'design_value' => '60'],
],
],
'case_box' => [
'name' => '케이스',
'gap_points' => [
['point' => '(1)', 'design_value' => '550'],
['point' => '(2)', 'design_value' => '50'],
['point' => '(3)', 'design_value' => '385'],
['point' => '(4)', 'design_value' => '50'],
['point' => '(5)', 'design_value' => '410'],
],
],
'smoke_w50' => [
'name' => '연기차단재 W50',
'gap_points' => [
['point' => '(1)', 'design_value' => '50'],
['point' => '(2)', 'design_value' => '12'],
],
],
'smoke_w80' => [
'name' => '연기차단재 W80',
'gap_points' => [
['point' => '(1)', 'design_value' => '80'],
['point' => '(2)', 'design_value' => '12'],
],
],
];
/**
* 작업지시의 검사 설정 조회 (공정 자동 판별 + 구성품 목록)
*
* 절곡 공정: bending_info 기반으로 검사 대상 구성품 + 간격 기준치 반환
* 기타 공정: items 빈 배열 (스크린/슬랫은 별도 구성품 없음)
*/
public function getInspectionConfig(int $workOrderId): array
{
$workOrder = WorkOrder::where('tenant_id', $this->tenantId())
->with(['process', 'items' => fn ($q) => $q->orderBy('sort_order')])
->findOrFail($workOrderId);
$process = $workOrder->process;
$processType = $this->resolveInspectionProcessType($process);
$firstItem = $workOrder->items->first();
$productCode = $firstItem?->options['product_code'] ?? null;
$templateId = $process?->document_template_id;
$items = [];
if ($processType === 'bending') {
$items = $this->buildBendingInspectionItems($firstItem);
}
return [
'work_order_id' => $workOrder->id,
'process_type' => $processType,
'product_code' => $productCode,
'template_id' => $templateId,
'items' => $items,
];
}
/**
* 공정명 → 검사 공정 타입 변환
*/
private function resolveInspectionProcessType(?Process $process): string
{
if (! $process) {
return 'unknown';
}
return match ($process->process_name) {
'스크린' => 'screen',
'슬랫' => 'slat',
'절곡' => 'bending',
default => strtolower($process->process_code ?? 'unknown'),
};
}
/**
* 절곡 bending_info에서 검사 대상 구성품 + 간격 기준치 빌드
*/
private function buildBendingInspectionItems(?WorkOrderItem $firstItem): array
{
if (! $firstItem) {
return [];
}
$bendingInfo = $firstItem->options['bending_info'] ?? null;
$items = [];
// 가이드레일 벽면 (벽면형 또는 혼합형)
$guideRail = $bendingInfo['guideRail'] ?? null;
$hasWall = ! $bendingInfo || ($guideRail && ($guideRail['wall'] ?? false));
$hasSide = $guideRail && ($guideRail['side'] ?? false);
if ($hasWall) {
$profile = self::BENDING_GAP_PROFILES['guide_rail_wall'];
$items[] = [
'id' => 'guide_rail_wall',
'name' => $profile['name'],
'gap_points' => $profile['gap_points'],
];
}
if ($hasSide) {
$profile = self::BENDING_GAP_PROFILES['guide_rail_side'];
$items[] = [
'id' => 'guide_rail_side',
'name' => $profile['name'],
'gap_points' => $profile['gap_points'],
];
}
// 하단마감재 (항상 포함)
$profile = self::BENDING_GAP_PROFILES['bottom_bar'];
$items[] = [
'id' => 'bottom_bar',
'name' => $profile['name'],
'gap_points' => $profile['gap_points'],
];
// 케이스 (항상 포함)
$profile = self::BENDING_GAP_PROFILES['case_box'];
$items[] = [
'id' => 'case_box',
'name' => $profile['name'],
'gap_points' => $profile['gap_points'],
];
// 연기차단재 W50 (항상 포함)
$profile = self::BENDING_GAP_PROFILES['smoke_w50'];
$items[] = [
'id' => 'smoke_w50',
'name' => $profile['name'],
'gap_points' => $profile['gap_points'],
];
// 연기차단재 W80 (항상 포함)
$profile = self::BENDING_GAP_PROFILES['smoke_w80'];
$items[] = [
'id' => 'smoke_w80',
'name' => $profile['name'],
'gap_points' => $profile['gap_points'],
];
return $items;
}
// ──────────────────────────────────────────────────────────────
// 검사 문서 템플릿 연동
// ──────────────────────────────────────────────────────────────
@@ -2096,80 +2277,85 @@ public function createInspectionDocument(int $workOrderId, array $inspectionData
throw new BadRequestHttpException(__('error.work_order.no_inspection_template'));
}
$documentService = app(DocumentService::class);
return DB::transaction(function () use ($workOrder, $workOrderId, $tenantId, $templateId, $inspectionData) {
// 동시 생성 방지: 동일 작업지시에 대한 락
$workOrder->lockForUpdate();
// 기존 DRAFT/REJECTED 문서가 있으면 update
$existingDocument = Document::query()
->where('tenant_id', $tenantId)
->where('template_id', $templateId)
->where('linkable_type', 'work_order')
->where('linkable_id', $workOrderId)
->whereIn('status', [Document::STATUS_DRAFT, Document::STATUS_REJECTED])
->latest()
->first();
$documentService = app(DocumentService::class);
// ★ 원본 기반 동기화: work_order_items.options.inspection_data에서 전체 수집
$rawItems = [];
foreach ($workOrder->items as $item) {
$inspData = $item->getInspectionData();
if ($inspData) {
$rawItems[] = $inspData;
// 기존 DRAFT/REJECTED 문서가 있으면 update
$existingDocument = Document::query()
->where('tenant_id', $tenantId)
->where('template_id', $templateId)
->where('linkable_type', 'work_order')
->where('linkable_id', $workOrderId)
->whereIn('status', [Document::STATUS_DRAFT, Document::STATUS_REJECTED])
->latest()
->first();
// ★ 원본 기반 동기화: work_order_items.options.inspection_data에서 전체 수집
$rawItems = [];
foreach ($workOrder->items as $item) {
$inspData = $item->getInspectionData();
if ($inspData) {
$rawItems[] = $inspData;
}
}
}
$documentDataRecords = $this->transformInspectionDataToDocumentRecords($rawItems, $templateId);
$documentDataRecords = $this->transformInspectionDataToDocumentRecords($rawItems, $templateId);
// 기존 문서의 기본필드(bf_*) 보존
if ($existingDocument) {
$existingBasicFields = $existingDocument->data()
->whereNull('section_id')
->where('field_key', 'LIKE', 'bf_%')
->get()
->map(fn ($d) => [
'section_id' => null,
'column_id' => null,
'row_index' => $d->row_index,
'field_key' => $d->field_key,
'field_value' => $d->field_value,
])
->toArray();
// 기존 문서의 기본필드(bf_*) 보존
if ($existingDocument) {
$existingBasicFields = $existingDocument->data()
->whereNull('section_id')
->where('field_key', 'LIKE', 'bf_%')
->get()
->map(fn ($d) => [
'section_id' => null,
'column_id' => null,
'row_index' => $d->row_index,
'field_key' => $d->field_key,
'field_value' => $d->field_value,
])
->toArray();
$document = $documentService->update($existingDocument->id, [
'title' => $inspectionData['title'] ?? $existingDocument->title,
'data' => array_merge($existingBasicFields, $documentDataRecords),
]);
$document = $documentService->update($existingDocument->id, [
'title' => $inspectionData['title'] ?? $existingDocument->title,
'data' => array_merge($existingBasicFields, $documentDataRecords),
]);
$action = 'inspection_document_updated';
} else {
$documentData = [
'template_id' => $templateId,
'title' => $inspectionData['title'] ?? "중간검사성적서 - {$workOrder->work_order_no}",
'linkable_type' => 'work_order',
'linkable_id' => $workOrderId,
'data' => $documentDataRecords,
'approvers' => $inspectionData['approvers'] ?? [],
$action = 'inspection_document_updated';
} else {
$documentData = [
'template_id' => $templateId,
'title' => $inspectionData['title'] ?? "중간검사성적서 - {$workOrder->work_order_no}",
'linkable_type' => 'work_order',
'linkable_id' => $workOrderId,
'data' => $documentDataRecords,
'approvers' => $inspectionData['approvers'] ?? [],
];
$document = $documentService->create($documentData);
$action = 'inspection_document_created';
}
// 감사 로그
$this->auditLogger->log(
$tenantId,
self::AUDIT_TARGET,
$workOrderId,
$action,
null,
['document_id' => $document->id, 'document_no' => $document->document_no]
);
return [
'document_id' => $document->id,
'document_no' => $document->document_no,
'status' => $document->status,
'is_new' => $action === 'inspection_document_created',
];
$document = $documentService->create($documentData);
$action = 'inspection_document_created';
}
// 감사 로그
$this->auditLogger->log(
$tenantId,
self::AUDIT_TARGET,
$workOrderId,
$action,
null,
['document_id' => $document->id, 'document_no' => $document->document_no]
);
return [
'document_id' => $document->id,
'document_no' => $document->document_no,
'status' => $document->status,
'is_new' => $action === 'inspection_document_created',
];
});
}
/**