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:
@@ -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',
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user