# 품질인정심사(QMS) API 연동 계획 > **작성일**: 2026-03-09 > **최종 업데이트**: 2026-03-10 (아키텍처 결정 + 3-페르소나 심층 분석 반영) > **상태**: Phase 1~3 구현 완료 (API 연동 준비 완료, USE_MOCK=true) > **URL**: `/quality/qms` > **스토리보드**: 슬라이드 19~20 > **관련 문서**: `docs/features/quality-management/quality-certification-audit.md` --- ## 0. 아키텍처 결정 사항 > 2026-03-10 비즈니스 로직 분석 결과 확정된 사항. 모든 개발은 아래 결정을 따를 것. | # | 항목 | 결정 | 근거 | |---|------|------|------| | 1 | **서류 모달 데이터** | **2단계 로딩** | 목록은 간략히 → 모달 열 때 `GET /qms/lot-audit/documents/{type}/{id}`로 상세 조회 | | 2 | **InspectionReport.item** | **BOM 최상위(FG) 제품명** 자동 추출 | `Order → OrderNode → OrderItem → Item(FG)` 경로로 대표 품목명 조회. options.product_type 아님 | | 3 | **InspectionReport.code** | **품질관리서 번호** (KD-QD-...) | 채번관리 시스템과 연동. 목업의 KD-SS 형식은 수주 코드이므로 불일치 → 프론트 수정 필요 | | 4 | **IQC 추적 경로** | **WorkOrderMaterialInput → StockLot 기반** 실제 투입 LOT 추적 | `WorkOrder → WorkOrderMaterialInput → StockLot → lot_no → Inspection(IQC)`. StockLot.work_order_id는 **생산입고** 관계이므로 직접 사용 불가 | | 5 | **토글 동기화** | **비관적 업데이트** | 서버 PATCH 성공 후 UI 반영. QMS 심사는 공식 기록이므로 정합성 우선 | | 6 | **subType 결정** | **WorkOrder.options**에서 추출 | `WorkOrder.options.sub_type` 또는 공정 정보 기반. Process.name 문자열 매칭 아님 | | 7 | **PR 없는 completed 문서** | **목록에 표시** | 분기 필터 "전체"에서 노출, 특정 분기 필터 시 제외 | ### 개소(Location) 생성 원리 (필수 이해) > `QualityDocumentService::attachOrders()`의 실제 로직: ``` Order → OrderNode(parent_id=null, root node = 개소) → 하위 첫 번째 OrderItem (대표 item) → QualityDocumentLocation.order_item_id에 저장 ``` - **개소 = root OrderNode** (parent_id가 null인 노드) - 각 개소의 "대표 item" = 해당 node 하위 첫 번째 OrderItem - `UnitInspection.name` ← OrderNode 기반 코드 조합 - `UnitInspection.location` ← `OrderItem.floor_code + " " + OrderItem.symbol_code` --- ## 1. 현황 분석 ### 1.1 프론트엔드 현황 **경로**: `react/src/app/[locale]/(protected)/quality/qms/` ``` qms/ ├── types.ts # 95줄, 타입 정의 ├── mockData.ts # 543줄, 목업 데이터 ├── page.tsx # 14KB, 전체 페이지 ├── actions.ts # ❌ 없음 (API 연동 0%) └── components/ ├── Header.tsx # 825B - 페이지 헤더 + 설정 버튼 ├── Filters.tsx # 1.9KB - 연도/분기/검색 필터 ├── DayTabs.tsx # 5.8KB - 1일차/2일차 탭 + 진행률 ├── AuditProgressBar.tsx # 3.4KB - 진행률 시각화 ├── AuditSettingsPanel.tsx # 6.7KB - 표시 설정 모달 ├── ReportList.tsx # 2.7KB - 품질관리서 목록 (2일차) ├── RouteList.tsx # 6.1KB - 수주/개소 목록 + 토글 (2일차) ├── DocumentList.tsx # 5.3KB - 서류 카테고리 목록 (2일차) ├── InspectionModal.tsx # 19KB - 서류 뷰어 모달 ├── Day1ChecklistPanel.tsx # 8.3KB - 점검표 + 토글 (1일차) ├── Day1DocumentSection.tsx # 4.9KB - 기준 문서 패널 (1일차) ├── Day1DocumentViewer.tsx # 7.3KB - 문서 미리보기 (1일차) └── documents/ # 검사 문서 템플릿 ├── index.ts # 배럴 export ├── ImportInspectionDocument.tsx # 38KB - 수입검사 성적서 ├── ProductInspectionDocument.tsx # 9.5KB - 제품검사 성적서 ├── ScreenInspectionDocument.tsx # 14KB - 스크린 중간검사 ├── BendingInspectionDocument.tsx # 16KB - 절곡 중간검사 ├── SlatInspectionDocument.tsx # 13KB - 슬랫 중간검사 ├── JointbarInspectionDocument.tsx # 17KB - 조인트바 중간검사 └── QualityDocumentUploader.tsx # 9.6KB - QMS PDF 업로더 ``` ### 1.2 프론트엔드 타입 정의 (`types.ts` 전체) ```typescript // ===== Day 2: 로트 추적 심사 ===== export interface InspectionReport { id: string; code: string; // "KD-QD-202603-0001" (품질관리서 번호, 채번관리 연동) siteName: string; // "강남 아파트 단지" item: string; // "실리카 스크린" (BOM 최상위 FG 제품명 자동 추출) routeCount: number; totalRoutes: number; quarter: string; // "2025년 3분기" year: number; quarterNum: number; // 1, 2, 3, 4 } export interface RouteItem { id: string; code: string; // "KD-SS-240924-19" date: string; // "2024-09-24" site: string; // "강남 아파트 A동" locationCount: number; subItems: UnitInspection[]; } export interface UnitInspection { id: string; name: string; // "KD-SS-240924-19-01" location: string; // "101동 501호" isCompleted: boolean; } export interface Document { id: string; type: 'import' | 'order' | 'log' | 'report' | 'confirmation' | 'shipping' | 'product' | 'quality'; title: string; // "수입검사 성적서" date?: string; count: number; items?: DocumentItem[]; } export interface DocumentItem { id: string; title: string; date: string; code?: string; subType?: 'screen' | 'bending' | 'slat' | 'jointbar'; } // ===== Day 1: 기준/매뉴얼 심사 ===== export interface ChecklistSubItem { id: string; name: string; isCompleted: boolean; } export interface ChecklistCategory { id: string; title: string; // "원재료 품질관리 기준" subItems: ChecklistSubItem[]; } export interface StandardDocument { id: string; title: string; version: string; date: string; fileName?: string; fileUrl?: string; } export interface Day1CheckItem { id: string; categoryId: string; subItemId: string; title: string; description: string; buttonLabel: string; // "기준/매뉴얼 확인" standardDocuments: StandardDocument[]; } export interface Day1Progress { completed: number; total: number; } export interface Day2Progress { completed: number; total: number; } ``` ### 1.3 프론트엔드 상태 관리 (`page.tsx` 핵심) ```typescript // ===== State ===== // 탭/설정 const [activeDay, setActiveDay] = useState<1 | 2>(1); const [settingsOpen, setSettingsOpen] = useState(false); const [displaySettings, setDisplaySettings] = useState(DEFAULT_SETTINGS); // AuditDisplaySettings: { showProgressBar, showDocumentViewer, showDocumentSection, showCompletedItems, expandAllCategories } // Day 1 const [day1Categories, setDay1Categories] = useState(MOCK_DAY1_CATEGORIES); const [selectedSubItemId, setSelectedSubItemId] = useState(null); const [selectedCategoryId, setSelectedCategoryId] = useState(null); const [selectedStandardDocId, setSelectedStandardDocId] = useState(null); // Day 2 const [selectedYear, setSelectedYear] = useState(2025); const [selectedQuarter, setSelectedQuarter] = useState<'Q1'|'Q2'|'Q3'|'Q4'|'전체'>('전체'); const [searchTerm, setSearchTerm] = useState(''); const [selectedReport, setSelectedReport] = useState(null); const [selectedRoute, setSelectedRoute] = useState(null); const [routesData, setRoutesData] = useState>(MOCK_ROUTES_INITIAL); // 모달 const [modalOpen, setModalOpen] = useState(false); const [selectedDoc, setSelectedDoc] = useState(null); const [selectedDocItem, setSelectedDocItem] = useState(null); // ===== 계산값 (useMemo) ===== // day1Progress: 카테고리별 subItems.isCompleted 합산 // day2Progress: routesData 전체 subItems.isCompleted 합산 // filteredReports: year + quarter + searchTerm 필터링 (MOCK_REPORTS 기준) // currentRoutes: selectedReport → routesData[selectedReport.id] // currentDocuments: selectedRoute → MOCK_DOCUMENTS[selectedRoute.id] // selectedCheckItem: selectedSubItemId → MOCK_DAY1_CHECK_ITEMS 매칭 // selectedStandardDoc: selectedStandardDocId → MOCK_DAY1_STANDARD_DOCUMENTS 매칭 // ===== 핸들러 ===== // Day 1: handleSubItemSelect, handleSubItemToggle, handleConfirmComplete // Day 2: handleReportSelect, handleRouteSelect, handleViewDocument // handleYearChange, handleQuarterChange, handleSearchChange, handleToggleItem ``` **렌더링 구조**: ```
+ (진행률 표시) (연도/분기/검색) activeDay === 1: grid-cols-12: | | activeDay === 2: grid-cols-12: | | (모달) (서류 뷰어 모달) ``` ### 1.4 mockData 데이터 계층 ``` MOCK_REPORTS (3건) — InspectionReport[] └─ MOCK_ROUTES_INITIAL[reportId] — RouteItem[] └─ subItems — UnitInspection[] (개소별 isCompleted 토글) └─ MOCK_DOCUMENTS[routeId] — Document[] (8종 서류) └─ items — DocumentItem[] MOCK_DAY1_CATEGORIES (6개 카테고리) — ChecklistCategory[] └─ subItems — ChecklistSubItem[] (2~3개씩) └─ MOCK_DAY1_CHECK_ITEMS — Day1CheckItem[] (16개 항목) └─ MOCK_DAY1_STANDARD_DOCUMENTS[subItemId] — StandardDocument[] ``` ### 1.5 백엔드 현황 | 영역 | 기존 API | 신규 필요 | |------|----------|-----------| | **1일차 (기준/매뉴얼 심사)** | ❌ 없음 | 모델, 마이그레이션, 서비스, 컨트롤러 전체 | | **2일차 (로트 추적 심사)** | ⚠️ 부분 존재 | 기존 API 래핑 + 서류 조합 API 신규 | **기존 활용 가능 API** (라우트: `api/routes/api/v1/quality.php`): | 엔드포인트 | 컨트롤러 | 서비스 메서드 | 용도 | |-----------|---------|-------------|------| | `GET /quality/documents` | QualityDocumentController::index | QualityDocumentService::index() | 품질관리서 목록 (2일차 1단계) | | `GET /quality/documents/{id}` | QualityDocumentController::show | QualityDocumentService::show() | 상세 + 수주/개소 (2일차 2단계) | | `GET /quality/performance-reports` | PerformanceReportController::index | PerformanceReportService::index() | 실적신고 (분기 필터) | | `GET /inspections` | InspectionController::index | InspectionService::index() | 수입검사/중간검사 성적서 | ### 1.6 기존 모델/서비스 위치 | 구분 | 파일 경로 | 핵심 | |------|----------|------| | **모델** | | | | QualityDocument | `api/app/Models/Qualitys/QualityDocument.php` | 상태: received→in_progress→completed | | QualityDocumentOrder | `api/app/Models/Qualitys/QualityDocumentOrder.php` | FK: quality_document_id, order_id | | QualityDocumentLocation | `api/app/Models/Qualitys/QualityDocumentLocation.php` | 상태: pending→in_progress→completed | | PerformanceReport | `api/app/Models/Qualitys/PerformanceReport.php` | 상태: unconfirmed→confirmed→reported | | Inspection | `api/app/Models/Qualitys/Inspection.php` | 타입: IQC/PQC/FQC, 상태: waiting→in_progress→completed | | **서비스** | | | | QualityDocumentService | `api/app/Services/QualityDocumentService.php` | 52KB, 18개 메서드 | | InspectionService | `api/app/Services/InspectionService.php` | index, stats, show, store, update, complete | | PerformanceReportService | `api/app/Services/PerformanceReportService.php` | index, stats, confirm, missing | | **컨트롤러** | | | | QualityDocumentController | `api/app/Http/Controllers/Api/V1/QualityDocumentController.php` | 14개 메서드 | | InspectionController | `api/app/Http/Controllers/Api/V1/InspectionController.php` | 8개 메서드 | | PerformanceReportController | `api/app/Http/Controllers/Api/V1/PerformanceReportController.php` | 6개 메서드 | ### 1.7 기존 DB 테이블 구조 (QMS 관련) #### `quality_documents` | 컬럼 | 타입 | 설명 | |------|------|------| | id | bigint PK | | | tenant_id | bigint FK | multi-tenancy | | quality_doc_number | varchar(30) | 채번: KD-QD-YYYYMM-0001 | | site_name | varchar(255) | 현장명 | | status | varchar(20) | received/in_progress/completed | | client_id | bigint FK | 수주처 | | inspector_id | bigint FK | 검사자 | | received_date | date | 접수일 | | **options** | **json** | {construction_site, material_distributor, contractor, supervisor} | | created_by, updated_by, deleted_by | bigint | 감사 | | timestamps, softDeletes | | | **인덱스**: `(tenant_id, quality_doc_number)` UNIQUE, `(tenant_id, status)`, `(tenant_id, client_id)`, `(tenant_id, received_date)` #### `quality_document_orders` | 컬럼 | 타입 | 설명 | |------|------|------| | id | bigint PK | | | quality_document_id | bigint FK | CASCADE DELETE | | order_id | bigint FK | | | timestamps | | | **인덱스**: `(quality_document_id, order_id)` UNIQUE #### `quality_document_locations` | 컬럼 | 타입 | 설명 | |------|------|------| | id | bigint PK | | | quality_document_id | bigint FK | CASCADE DELETE | | quality_document_order_id | bigint FK | CASCADE DELETE | | order_item_id | bigint FK | 대표 품목 | | post_width, post_height | int | 시공후 규격 | | change_reason | varchar(255) | 규격 변경사유 | | **inspection_data** | **json** | 검사 데이터 (15개 항목 + 사진) | | document_id | bigint FK | 검사성적서 | | inspection_status | varchar(20) | pending/in_progress/completed | | timestamps | | | **인덱스**: `(quality_document_id, inspection_status)` > **주의**: `quality_document_locations`에는 현재 `options` 컬럼이 **없음**. QMS 로트 심사 확인 데이터 저장을 위해 `options` JSON 컬럼 추가 필요. #### `performance_reports` | 컬럼 | 타입 | 설명 | |------|------|------| | id | bigint PK | | | tenant_id | bigint FK | | | quality_document_id | bigint FK | 1:1 관계 | | year | smallint | 연도 | | quarter | tinyint | 분기 (1-4) | | confirmation_status | varchar(20) | unconfirmed/confirmed/reported | | confirmed_date | date | 확정일 | | confirmed_by | bigint FK | 확정자 | | memo | text | 특이사항 | | created_by, updated_by, deleted_by | bigint | | | timestamps, softDeletes | | | **인덱스**: `(tenant_id, quality_document_id)` UNIQUE, `(tenant_id, year, quarter)`, `(tenant_id, confirmation_status)` #### `inspections` | 컬럼 | 타입 | 설명 | |------|------|------| | id | bigint PK | | | tenant_id | bigint FK | | | inspection_no | varchar(30) | 채번: IQC-YYYYMMDD-0001 | | inspection_type | enum | IQC/PQC/FQC | | status | enum | waiting/in_progress/completed | | result | enum | pass/fail (nullable) | | request_date | date | 요청일 | | inspection_date | date | 검사일 (nullable) | | item_id | bigint FK | 품목 | | lot_no | varchar(50) | LOT 번호 | | work_order_id | bigint FK | 작업지시 (PQC/FQC용) | | inspector_id | bigint FK | 검사자 | | **meta** | **json** | {process_name, quantity, unit} | | **items** | **json** | 검사항목 배열 [{id, name, type, spec, result, judgment}] | | **attachments** | **json** | 첨부파일 | | **extra** | **json** | {remarks, opinion} | | created_by, updated_by | bigint | | | timestamps, softDeletes | | | **인덱스**: `(tenant_id, inspection_no)` UNIQUE, `(tenant_id, inspection_type, status)` 복합 ### 1.8 모델 관계 맵 ``` QualityDocument (1) ├── hasMany → QualityDocumentOrder (M) │ ├── belongsTo → Order │ │ ├── hasMany → OrderNode (root: parent_id=null = 개소) │ │ │ └── hasMany → OrderItem (대표 item = 첫 번째) │ │ ├── hasMany → WorkOrder (via sales_order_id) │ │ │ ├── hasMany → Inspection (PQC/FQC via work_order_id) │ │ │ ├── hasMany → WorkOrderMaterialInput (자재 투입) │ │ │ │ └── belongsTo → StockLot (투입 LOT) │ │ │ │ └── lot_no → Inspection (IQC 연결 키) │ │ │ └── hasMany → StockLot (⚠️ 생산입고 관계 — IQC 추적에 사용 금지) │ │ └── hasMany → Shipment (via order_id) │ └── hasMany → QualityDocumentLocation (M) │ ├── belongsTo → OrderItem (대표 item, floor_code/symbol_code 접근) │ └── belongsTo → Document (검사성적서) ├── hasMany → QualityDocumentLocation (M) ├── belongsTo → Client ├── belongsTo → User (inspector) └── hasOne → PerformanceReport └── belongsTo → User (confirmer) Inspection (독립) ├── belongsTo → WorkOrder ├── belongsTo → Item └── belongsTo → User (inspector) ``` > **IQC 추적 경로 (WorkOrderMaterialInput → StockLot 기반)**: > `Order → WorkOrder → WorkOrderMaterialInput → StockLot(실제 투입 LOT) → lot_no → Inspection(IQC)` > ⚠️ `StockLot.work_order_id`는 **생산입고** 관계(이 LOT이 이 작업지시에서 생산됨)이므로 직접 사용 불가 --- ## 2. 구현 패턴 가이드 > 새 코드 작성 시 반드시 아래 패턴을 따를 것. ### 2.1 Controller 패턴 ```php // api/app/Http/Controllers/Api/V1/QmsLotAuditController.php use App\Helpers\ApiResponse; class QmsLotAuditController extends Controller { public function __construct(private QmsLotAuditService $service) {} public function index(Request $request) { return ApiResponse::handle(function () use ($request) { return $this->service->index($request->all()); }, __('message.fetched')); } public function confirm(QmsLotAuditConfirmRequest $request, int $id) { return ApiResponse::handle(function () use ($request, $id) { return $this->service->confirm($id, $request->validated()); }, __('message.updated')); } } ``` ### 2.2 Service 패턴 ```php // api/app/Services/QmsLotAuditService.php use App\Services\Service; class QmsLotAuditService extends Service { public function index(array $params): array { $tenantId = $this->tenantId(); // BelongsToTenant 스코프 자동 적용됨 — where tenant_id 하드코딩 금지 // N+1 방지: getFgProductName()에서 사용하는 관계까지 eager load $query = QualityDocument::with([ 'documentOrders.order.nodes' => fn($q) => $q->whereNull('parent_id'), 'documentOrders.order.nodes.items.item', 'locations', 'performanceReport', ]) ->where('status', QualityDocument::STATUS_COMPLETED); // 필터링 if (!empty($params['year'])) { ... } if (!empty($params['quarter'])) { ... } if (!empty($params['q'])) { ... } $items = $query->paginate($params['limit'] ?? 20); return $this->transformPaginated($items); } } ``` ### 2.3 FormRequest 패턴 ```php // api/app/Http/Requests/Qms/QmsLotAuditConfirmRequest.php class QmsLotAuditConfirmRequest extends FormRequest { public function authorize(): bool { return true; } public function rules(): array { return [ 'confirmed' => 'required|boolean', ]; } public function messages(): array { return [ 'confirmed.required' => __('validation.required', ['attribute' => '확인 상태']), ]; } } ``` ### 2.4 Model 패턴 (신규 모델 생성 시) ```php // api/app/Models/Qualitys/AuditChecklist.php class AuditChecklist extends Model { use Auditable, BelongsToTenant, SoftDeletes; const STATUS_DRAFT = 'draft'; const STATUS_IN_PROGRESS = 'in_progress'; const STATUS_COMPLETED = 'completed'; protected $fillable = ['tenant_id', 'year', 'quarter', 'type', 'status', 'options', 'created_by', 'updated_by', 'deleted_by']; protected $casts = ['options' => 'array']; public function categories() { return $this->hasMany(AuditChecklistCategory::class, 'checklist_id'); } public function isDraft(): bool { return $this->status === self::STATUS_DRAFT; } } ``` ### 2.5 API 응답 형식 ```json // 성공 (목록) { "success": true, "message": "조회되었습니다", "data": { "items": [...], "pagination": { "current_page": 1, "last_page": 5, "per_page": 20, "total": 100 } } } // 성공 (단건) { "success": true, "message": "조회되었습니다", "data": { ... } } // 에러 { "success": false, "message": "확정 실패: 필수정보 미완료", "data": null } ``` ### 2.6 라우트 등록 ```php // api/routes/api/v1/quality.php 에 추가 Route::prefix('qms')->group(function () { // 2일차: 로트 추적 심사 Route::prefix('lot-audit')->group(function () { Route::get('reports', [QmsLotAuditController::class, 'index']); Route::get('reports/{id}', [QmsLotAuditController::class, 'show']); Route::get('routes/{id}/documents', [QmsLotAuditController::class, 'routeDocuments']); Route::get('documents/{type}/{id}', [QmsLotAuditController::class, 'documentDetail']); // 2단계 로딩 Route::patch('units/{id}/confirm', [QmsLotAuditController::class, 'confirm']); }); // 1일차: 기준/매뉴얼 심사 Route::resource('checklists', AuditChecklistController::class)->except(['edit', 'create']); Route::patch('checklists/{id}/complete', [AuditChecklistController::class, 'complete']); Route::patch('checklist-items/{id}/toggle', [AuditChecklistController::class, 'toggleItem']); Route::get('checklist-items/{id}/documents', [AuditChecklistController::class, 'itemDocuments']); Route::post('checklist-items/{id}/documents', [AuditChecklistController::class, 'attachDocument']); Route::delete('checklist-items/{id}/documents/{docId}', [AuditChecklistController::class, 'detachDocument']); }); ``` --- ## 3. 작업 범위 ### Phase 1: 2일차 (로트 추적 심사) API 연동 > **우선순위 높음** — 기존 API 활용 가능하여 빠르게 연동 가능 #### 3.1 Backend — 신규 파일 | 파일 | 역할 | |------|------| | `api/app/Services/QmsLotAuditService.php` | 로트 심사 전용 서비스 (index, show, routeDocuments, documentDetail, confirm, getFgProductName) | | `api/app/Http/Controllers/Api/V1/QmsLotAuditController.php` | 컨트롤러 (6개 메서드) | | `api/app/Http/Requests/Qms/QmsLotAuditConfirmRequest.php` | 확인 완료 검증 | | `api/app/Http/Requests/Qms/QmsLotAuditIndexRequest.php` | 목록 조회 파라미터 검증 (year: integer, quarter: 1-4, q: string) | | `api/app/Http/Requests/Qms/QmsLotAuditDocumentDetailRequest.php` | 서류 상세 조회 type enum 검증 | | `api/database/migrations/XXXX_add_options_to_quality_document_locations.php` | options 컬럼 추가 + Model casts 수정 | #### 3.2 Backend — API 엔드포인트 ``` GET /api/v1/qms/lot-audit/reports params: year, quarter, q(검색어) response: InspectionReport[] 형태로 변환 내부: QualityDocument(completed) + PerformanceReport(year/quarter) 조합 참고: PR 없는 completed 문서도 포함 (분기="전체"에서 노출, 특정 분기 필터 시 제외) GET /api/v1/qms/lot-audit/reports/{id} response: RouteItem[] 형태 (수주코드 + 개소 + 완료상태) 내부: QualityDocumentOrder → Order + QualityDocumentLocation 조합 참고: 개소 = root OrderNode 기반, 대표 OrderItem에서 floor_code/symbol_code 접근 GET /api/v1/qms/lot-audit/routes/{id}/documents response: Document[] 형태 (8종 서류 간략 목록만 — count + items 기본 정보) 내부: 아래 8종 서류 조합 로직 GET /api/v1/qms/lot-audit/documents/{type}/{id} ← 🆕 2단계 로딩용 params: type = import|order|log|report|confirmation|shipping|product|quality response: 타입별 상세 데이터 (모달 렌더링용) 내부: 타입에 따라 Inspection/WorkOrder/Shipment/QualityDocument 상세 조회 PATCH /api/v1/qms/lot-audit/units/{id}/confirm body: { confirmed: boolean } response: 업데이트된 UnitInspection 내부: quality_document_locations.options.lot_audit_confirmed 업데이트 참고: 비관적 업데이트 — 서버 성공 후 프론트 UI 반영 ⚠️ 원자성: DB::transaction + lockForUpdate() 또는 DB::raw("JSON_SET(options, '$.lot_audit_confirmed', ...)") 사용하여 동시 수정 시 JSON 병합 충돌 방지 ``` #### 3.3 8종 서류 조합 조회 로직 (핵심 복잡도) `QmsLotAuditService::getRouteDocuments(int $qualityDocumentOrderId)` 메서드 구현: | # | 서류 타입 | Document.type | 데이터 소스 | 조회 경로 | |---|-----------|---------------|-------------|-----------| | 1 | 수입검사 성적서 | `import` | `inspections` (type=IQC) | Order → WorkOrder → WorkOrderMaterialInput → StockLot(투입 LOT) → lot_no → Inspection(IQC) | | 2 | 수주서 | `order` | `orders` | QualityDocumentOrder.order_id → orders | | 3 | 작업일지 | `log` | `work_orders` | 수주 → work_orders → 작업일지 데이터, subType은 WorkOrder.options에서 추출 | | 4 | 중간검사 성적서 | `report` | `inspections` (type=PQC) | work_orders → inspections(PQC, work_order_id), subType은 WorkOrder.options에서 추출 | | 5 | 납품확인서 | `confirmation` | `shipments` | 수주 → 출하 → 납품확인서 | | 6 | 출고증 | `shipping` | `shipments` | 수주 → 출하 → 출고증 | | 7 | 제품검사 성적서 | `product` | `quality_document_locations` | 개소 → document_id (검사성적서) | | 8 | 품질관리서 | `quality` | `quality_documents` | 해당 품질관리서 원본 | **서류 조합 의사 코드**: ```php public function getRouteDocuments(int $orderId): array { $docOrder = QualityDocumentOrder::with(['order.workOrders', 'locations', 'qualityDocument'])->findOrFail($orderId); $order = $docOrder->order; $qualityDoc = $docOrder->qualityDocument; $documents = []; $workOrders = $order->workOrders; // 1. 수입검사 성적서: WorkOrderMaterialInput → StockLot 기반 실제 투입 LOT → IQC 추적 // ⚠️ StockLot.work_order_id는 "생산입고" 관계이므로 직접 사용 불가 // 올바른 경로: WorkOrder → WorkOrderMaterialInput → StockLot → lot_no → Inspection(IQC) $investedLotIds = WorkOrderMaterialInput::whereIn('work_order_id', $workOrders->pluck('id')) ->pluck('stock_lot_id') ->unique(); $investedLotNos = StockLot::whereIn('id', $investedLotIds) ->whereNotNull('lot_no') // null lot_no 방어 ->pluck('lot_no') ->unique(); $iqcInspections = Inspection::where('inspection_type', 'IQC') ->whereIn('lot_no', $investedLotNos) ->where('status', 'completed') ->get(); $documents[] = $this->formatDocument('import', '수입검사 성적서', $iqcInspections); // 2. 수주서 $documents[] = $this->formatDocument('order', '수주서', collect([$order])); // 3. 작업일지 (subType: WorkOrder.options에서 추출) $documents[] = $this->formatDocumentWithSubType('log', '작업일지', $workOrders); // 4. 중간검사 성적서 (PQC, subType: WorkOrder.options에서 추출) $pqcInspections = Inspection::where('inspection_type', 'PQC') ->whereIn('work_order_id', $workOrders->pluck('id')) ->get(); $documents[] = $this->formatDocumentWithSubType('report', '중간검사 성적서', $pqcInspections); // 5. 납품확인서 // Shipment 조회: Order → hasMany → Shipment (FK: order_id) $shipments = $order->shipments()->get(); $documents[] = $this->formatDocument('confirmation', '납품확인서', $shipments); // 6. 출고증 $documents[] = $this->formatDocument('shipping', '출고증', $shipments); // 7. 제품검사 성적서 $locations = $docOrder->locations->filter(fn($loc) => $loc->document_id); $documents[] = $this->formatDocument('product', '제품검사 성적서', $locations); // 8. 품질관리서 $documents[] = $this->formatDocument('quality', '품질관리서', collect([$qualityDoc])); return $documents; } ``` #### 3.4 DB 변경 | 변경 | 테이블 | 설명 | |------|--------|------| | 컬럼 추가 | `quality_document_locations` | `options` JSON 컬럼 추가 | `options` JSON 활용: ```json { "lot_audit_confirmed": true, "lot_audit_confirmed_at": "2026-03-10T09:00:00", "lot_audit_confirmed_by": 15 } ``` #### 3.5 Frontend — `actions.ts` 생성 > ⚠️ **프로젝트 표준 준수 필수**: `executeServerAction` + `buildApiUrl` + `ActionResult` 패턴. > 레퍼런스: `react/src/app/[locale]/(protected)/quality/inspection-management/actions.ts` ```typescript // react/src/app/[locale]/(protected)/quality/qms/actions.ts 'use server'; import { executeServerAction } from '@/lib/api/execute-server-action'; import { buildApiUrl } from '@/lib/api/query-params'; import type { ActionResult } from '@/lib/api/types'; import type { InspectionReport, RouteItem, Document, UnitInspection } from './types'; // ===== API 원본 타입 (snake_case) ===== // ⚠️ 'use server' 파일에서 타입 re-export 금지 (Turbopack 제한) // API 타입은 이 파일 내부에서만 사용 interface QualityReportApi { id: number; code: string; // quality_doc_number site_name: string; item: string; // FG 제품명 (서버에서 변환 완료) route_count: number; total_routes: number; quarter: string; year: number; quarter_num: number; } interface RouteItemApi { id: number; code: string; // order_no date: string; // received_at site: string; location_count: number; sub_items: { id: number; name: string; location: string; is_completed: boolean; }[]; } interface DocumentApi { id: number; type: string; title: string; date?: string; count: number; items?: { id: number; title: string; date: string; code?: string; sub_type?: string; }[]; } // ===== 8종 서류 상세 Discriminated Union ===== type DocumentDetailApi = | { type: 'import'; data: ImportInspectionDetailApi } | { type: 'order'; data: OrderDetailApi } | { type: 'log'; data: WorkOrderLogDetailApi } | { type: 'report'; data: PqcInspectionDetailApi } | { type: 'confirmation'; data: ShipmentDetailApi } | { type: 'shipping'; data: ShipmentDetailApi } | { type: 'product'; data: ProductInspectionDetailApi } | { type: 'quality'; data: QualityDocDetailApi }; // TODO: 각 DetailApi 인터페이스는 백엔드 구현 시 확정 후 정의 // ===== Transform 함수 (snake_case → camelCase) ===== function transformReportApi(api: QualityReportApi): InspectionReport { return { id: String(api.id), code: api.code, siteName: api.site_name, item: api.item, routeCount: api.route_count, totalRoutes: api.total_routes, quarter: api.quarter, year: api.year, quarterNum: api.quarter_num, }; } function transformRouteApi(api: RouteItemApi): RouteItem { return { id: String(api.id), code: api.code, date: api.date, site: api.site, locationCount: api.location_count, subItems: api.sub_items.map(s => ({ id: String(s.id), name: s.name, location: s.location, isCompleted: s.is_completed, })), }; } function transformDocumentApi(api: DocumentApi): Document { return { id: String(api.id), type: api.type as Document['type'], title: api.title, date: api.date, count: api.count, items: api.items?.map(i => ({ id: String(i.id), title: i.title, date: i.date, code: i.code, subType: i.sub_type as 'screen' | 'bending' | 'slat' | 'jointbar' | undefined, })), }; } // ===== USE_MOCK 패턴 (기존 프로젝트 패턴 준수) ===== const USE_MOCK_FALLBACK = false; // 프로젝트 표준: process.env가 아닌 상수 플래그 // ===== 2일차: 로트 추적 심사 ===== export async function getQualityReports(params: { year: number; quarter?: number; q?: string; }): Promise> { if (USE_MOCK_FALLBACK) { const { MOCK_REPORTS } = await import('./mockData'); return { success: true, data: MOCK_REPORTS.filter(r => r.year === params.year) }; } return executeServerAction({ url: buildApiUrl('/api/v1/qms/lot-audit/reports', { year: params.year, quarter: params.quarter, q: params.q, }), transform: (data) => data.items.map(transformReportApi), errorMessage: '품질관리서 목록 조회에 실패했습니다.', }); } export async function getReportRoutes(reportId: string): Promise> { return executeServerAction({ url: buildApiUrl(`/api/v1/qms/lot-audit/reports/${reportId}`), transform: (data) => data.map(transformRouteApi), errorMessage: '수주/개소 목록 조회에 실패했습니다.', }); } export async function getRouteDocuments(routeId: string): Promise> { return executeServerAction({ url: buildApiUrl(`/api/v1/qms/lot-audit/routes/${routeId}/documents`), transform: (data) => data.map(transformDocumentApi), errorMessage: '서류 목록 조회에 실패했습니다.', }); } export async function confirmUnitInspection( unitId: string, confirmed: boolean ): Promise> { return executeServerAction({ url: buildApiUrl(`/api/v1/qms/lot-audit/units/${unitId}/confirm`), method: 'PATCH', body: { confirmed }, transform: (data) => ({ id: String(data.id), name: data.name, location: data.location, isCompleted: data.is_completed, }), errorMessage: '확인 상태 변경에 실패했습니다.', }); } // 2단계 로딩: 서류 모달에서 상세 데이터 조회 export async function getDocumentDetail( type: Document['type'], id: string ): Promise> { return executeServerAction({ url: buildApiUrl(`/api/v1/qms/lot-audit/documents/${type}/${id}`), // transform은 type별로 다르므로 호출 측에서 처리 errorMessage: '서류 상세 조회에 실패했습니다.', }); } ``` #### 3.6 API 응답 매핑 (Backend → Frontend) **`GET /qms/lot-audit/reports` 응답 변환**: ```php // QmsLotAuditService::transformReportToFrontend() private function transformReportToFrontend(QualityDocument $doc): array { $performanceReport = $doc->performanceReport; $confirmedCount = $doc->locations->filter(function ($loc) { return data_get($loc->options, 'lot_audit_confirmed', false); })->count(); return [ 'id' => (string) $doc->id, 'code' => $doc->quality_doc_number, // → 품질관리서 번호 (KD-QD-YYYYMM-NNNN) 'siteName' => $doc->site_name, 'item' => $this->getFgProductName($doc), // → BOM 최상위(FG) 제품명 자동 추출 'routeCount' => $confirmedCount, // → 확인 완료된 개소 수 'totalRoutes' => $doc->locations->count(), 'quarter' => $performanceReport ? $performanceReport->year . '년 ' . $performanceReport->quarter . '분기' : '', // → PR 없으면 빈 문자열 'year' => $performanceReport?->year ?? now()->year, 'quarterNum' => $performanceReport?->quarter ?? 0, // → 0이면 분기 미지정 (PR 없음) ]; } /** * BOM 최상위(FG) 제품명 추출 * Order → root OrderNode → 대표 OrderItem → Item(FG).name */ private function getFgProductName(QualityDocument $doc): string { $firstDocOrder = $doc->documentOrders->first(); if (!$firstDocOrder) return ''; $order = $firstDocOrder->order; // ⚠️ Order 모델의 실제 관계명은 nodes() (orderNodes 아님) // eager load 시 whereNull('parent_id') 조건 포함하므로 추가 쿼리 없음 $rootNode = $order->nodes->first(); // eager loaded with parent_id=null filter if (!$rootNode) return ''; // eager loaded: nodes.items.item $representativeItem = $rootNode->items->first(); return $representativeItem?->item?->name ?? ''; } ``` **`GET /qms/lot-audit/reports/{id}` 응답 변환**: ```php // QmsLotAuditService::transformRouteToFrontend() private function transformRouteToFrontend(QualityDocumentOrder $docOrder): array { $qualityDoc = $docOrder->qualityDocument; // eager loaded — $doc 변수 아님에 주의 return [ 'id' => (string) $docOrder->id, 'code' => $docOrder->order->order_no, // ⚠️ 실제 필드명: order_no (order_code 아님) 'date' => $docOrder->order->received_at, // ⚠️ 실제 필드명: received_at (order_date 아님) 'site' => $docOrder->order->site_name ?? '', // → RouteItem.site 'locationCount' => $docOrder->locations->count(), // → RouteItem.locationCount 'subItems' => $docOrder->locations->map(fn($loc, $idx) => [ 'id' => (string) $loc->id, 'name' => $qualityDoc->quality_doc_number . '-' . str_pad($idx + 1, 2, '0', STR_PAD_LEFT), // KD-QD-202603-0001-01 'location' => trim(($loc->orderItem?->floor_code ?? '') . ' ' . ($loc->orderItem?->symbol_code ?? '')), // "1F A-1" 'isCompleted' => (bool) data_get($loc->options, 'lot_audit_confirmed', false), ])->values()->all(), ]; } ``` --- ### Phase 2: 1일차 (기준/매뉴얼 심사) 백엔드 구축 > **작업량 많음** — 완전 신규 백엔드 구축 필요 #### 4.1 DB 설계 (신규 테이블 4개) ```sql -- 1) audit_checklists (심사 점검표 마스터) CREATE TABLE audit_checklists ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, tenant_id BIGINT UNSIGNED NOT NULL, year SMALLINT UNSIGNED NOT NULL, quarter TINYINT UNSIGNED NOT NULL, -- 1~4 type VARCHAR(30) NOT NULL DEFAULT 'standard_manual', -- 1일차 status VARCHAR(20) NOT NULL DEFAULT 'draft', -- draft/in_progress/completed options JSON NULL, created_by BIGINT UNSIGNED NULL, updated_by BIGINT UNSIGNED NULL, deleted_by BIGINT UNSIGNED NULL, created_at TIMESTAMP NULL, updated_at TIMESTAMP NULL, deleted_at TIMESTAMP NULL, UNIQUE INDEX (tenant_id, year, quarter, type), INDEX (tenant_id, status), FOREIGN KEY (tenant_id) REFERENCES tenants(id) ); -- 2) audit_checklist_categories (점검표 카테고리) CREATE TABLE audit_checklist_categories ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, tenant_id BIGINT UNSIGNED NOT NULL, checklist_id BIGINT UNSIGNED NOT NULL, title VARCHAR(200) NOT NULL, -- "원재료 품질관리 기준" sort_order INT UNSIGNED NOT NULL DEFAULT 0, options JSON NULL, created_at TIMESTAMP NULL, updated_at TIMESTAMP NULL, INDEX (checklist_id, sort_order), FOREIGN KEY (checklist_id) REFERENCES audit_checklists(id) ON DELETE CASCADE ); -- 3) audit_checklist_items (점검표 세부 항목) CREATE TABLE audit_checklist_items ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, tenant_id BIGINT UNSIGNED NOT NULL, category_id BIGINT UNSIGNED NOT NULL, name VARCHAR(200) NOT NULL, -- "수입검사 기준 확인" description TEXT NULL, is_completed BOOLEAN NOT NULL DEFAULT FALSE, completed_at TIMESTAMP NULL, completed_by BIGINT UNSIGNED NULL, sort_order INT UNSIGNED NOT NULL DEFAULT 0, options JSON NULL, created_at TIMESTAMP NULL, updated_at TIMESTAMP NULL, INDEX (category_id, sort_order), INDEX (category_id, is_completed), FOREIGN KEY (category_id) REFERENCES audit_checklist_categories(id) ON DELETE CASCADE, FOREIGN KEY (completed_by) REFERENCES users(id) ON DELETE SET NULL ); -- 4) audit_standard_documents (기준 문서 연결) CREATE TABLE audit_standard_documents ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, tenant_id BIGINT UNSIGNED NOT NULL, checklist_item_id BIGINT UNSIGNED NOT NULL, title VARCHAR(200) NOT NULL, -- "수입검사기준서" version VARCHAR(20) NULL, -- "REV12" date DATE NULL, document_id BIGINT UNSIGNED NULL, -- FK → documents (EAV 파일) options JSON NULL, created_at TIMESTAMP NULL, updated_at TIMESTAMP NULL, INDEX (checklist_item_id), FOREIGN KEY (checklist_item_id) REFERENCES audit_checklist_items(id) ON DELETE CASCADE, FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE SET NULL ); ``` #### 4.2 Backend 신규 파일 | 파일 | 역할 | |------|------| | `api/app/Models/Qualitys/AuditChecklist.php` | 심사 점검표 모델 | | `api/app/Models/Qualitys/AuditChecklistCategory.php` | 카테고리 모델 | | `api/app/Models/Qualitys/AuditChecklistItem.php` | 세부 항목 모델 | | `api/app/Models/Qualitys/AuditStandardDocument.php` | 기준 문서 모델 | | `api/app/Services/AuditChecklistService.php` | 서비스 | | `api/app/Http/Controllers/Api/V1/AuditChecklistController.php` | 컨트롤러 | | `api/app/Http/Requests/Qms/AuditChecklistStoreRequest.php` | 생성 검증 | | `api/app/Http/Requests/Qms/AuditChecklistUpdateRequest.php` | 수정 검증 | | `api/database/migrations/XXXX_create_audit_checklists_tables.php` | 마이그레이션 (4테이블) | | `api/database/seeders/AuditChecklistSeeder.php` | 초기 점검표 데이터 | #### 4.3 API 엔드포인트 ``` GET /api/v1/qms/checklists — 점검표 목록 (year, quarter 필터) POST /api/v1/qms/checklists — 점검표 생성 (카테고리+항목 일괄) GET /api/v1/qms/checklists/{id} — 점검표 상세 (카테고리→항목→문서 중첩) PUT /api/v1/qms/checklists/{id} — 점검표 수정 PATCH /api/v1/qms/checklists/{id}/complete — 점검표 완료 처리 PATCH /api/v1/qms/checklist-items/{id}/toggle — 항목 완료/미완료 토글 GET /api/v1/qms/checklist-items/{id}/documents — 항목별 기준 문서 조회 POST /api/v1/qms/checklist-items/{id}/documents — 기준 문서 연결 DELETE /api/v1/qms/checklist-items/{id}/documents/{docId} — 기준 문서 연결 해제 ``` **`GET /qms/checklists/{id}` 예상 응답**: ```json { "success": true, "message": "조회되었습니다", "data": { "id": 1, "year": 2025, "quarter": 3, "type": "standard_manual", "status": "in_progress", "progress": { "completed": 8, "total": 12 }, "categories": [ { "id": "cat-1", "title": "원재료 품질관리 기준", "sortOrder": 1, "subItems": [ { "id": "cat-1-1", "name": "수입검사 기준 확인", "isCompleted": true, "completedAt": "2026-03-09T10:00:00", "standardDocuments": [ { "id": "std-1", "title": "수입검사기준서", "version": "REV12", "date": "2024-10-20", "fileName": "수입검사기준서_REV12.pdf", "fileUrl": "/api/v1/documents/123/download" } ] }, { "id": "cat-1-2", "name": "불합격품 처리 기준 확인", "isCompleted": false, "standardDocuments": [] } ] } ] } } ``` #### 4.4 Frontend — actions.ts 확장 > 동일하게 `executeServerAction` + `buildApiUrl` + `ActionResult` 패턴 준수 ```typescript // actions.ts에 추가 — 1일차: 기준/매뉴얼 심사 export async function getChecklists(params: { year: number; quarter?: number; }): Promise> { return executeServerAction({ url: buildApiUrl('/api/v1/qms/checklists', { year: params.year, quarter: params.quarter }), transform: (data) => data.items.map((c: any) => ({ id: String(c.id), year: c.year, quarter: c.quarter, status: c.status, progress: c.progress, })), errorMessage: '점검표 목록 조회에 실패했습니다.', }); } export async function getChecklistDetail(id: string): Promise> { return executeServerAction({ url: buildApiUrl(`/api/v1/qms/checklists/${id}`), transform: (data) => transformChecklistResponse(data), errorMessage: '점검표 상세 조회에 실패했습니다.', }); } // ⚠️ 1일차 토글도 비관적 업데이트 적용 (공식 기록이므로 2일차와 동일) export async function toggleChecklistItem(itemId: string): Promise> { return executeServerAction({ url: buildApiUrl(`/api/v1/qms/checklist-items/${itemId}/toggle`), method: 'PATCH', transform: (data) => ({ id: String(data.id), name: data.name, isCompleted: data.is_completed, }), errorMessage: '항목 상태 변경에 실패했습니다.', }); } export async function getCheckItemDocuments(itemId: string): Promise> { return executeServerAction({ url: buildApiUrl(`/api/v1/qms/checklist-items/${itemId}/documents`), transform: (data) => data.map((d: any) => ({ id: String(d.id), title: d.title, version: d.version, date: d.date, fileName: d.file_name, fileUrl: d.file_url, })), errorMessage: '기준 문서 조회에 실패했습니다.', }); } export async function attachStandardDocument(itemId: string, data: { title: string; version?: string; date?: string; documentId?: number; }): Promise> { return executeServerAction({ url: buildApiUrl(`/api/v1/qms/checklist-items/${itemId}/documents`), method: 'POST', body: { title: data.title, version: data.version, date: data.date, document_id: data.documentId }, transform: (d) => ({ id: String(d.id), title: d.title, version: d.version, date: d.date, fileName: d.file_name, fileUrl: d.file_url }), errorMessage: '기준 문서 연결에 실패했습니다.', }); } export async function detachStandardDocument(itemId: string, docId: string): Promise> { return executeServerAction({ url: buildApiUrl(`/api/v1/qms/checklist-items/${itemId}/documents/${docId}`), method: 'DELETE', errorMessage: '기준 문서 연결 해제에 실패했습니다.', }); } ``` --- ### Phase 3: 프론트엔드 목업 → API 전환 #### 5.1 page.tsx 수정 포인트 > ⚠️ **상태 관리 리팩토링 필수**: 현재 15+ useState → 커스텀 훅 분리 권장 **커스텀 훅 설계** (page.tsx 상태 폭발 방지): ```typescript // hooks/useDay2LotAudit.ts — 2일차 전용 상태 + 핸들러 캡슐화 function useDay2LotAudit() { const [reports, setReports] = useState([]); const [selectedReport, setSelectedReport] = useState(null); const [routes, setRoutes] = useState([]); const [selectedRoute, setSelectedRoute] = useState(null); const [documents, setDocuments] = useState([]); // ⚠️ 로딩 상태 세분화 — 하나의 loading으로 통합하면 안 됨 const [loadingReports, setLoadingReports] = useState(false); const [loadingRoutes, setLoadingRoutes] = useState(false); const [loadingDocuments, setLoadingDocuments] = useState(false); const [pendingConfirmIds, setPendingConfirmIds] = useState>(new Set()); // 토글 중복 클릭 방지 // handlers... return { reports, selectedReport, routes, selectedRoute, documents, loadingReports, loadingRoutes, loadingDocuments, pendingConfirmIds, /* handlers */ }; } // hooks/useDay1Audit.ts — 1일차 전용 상태 + 핸들러 캡슐화 function useDay1Audit() { const [categories, setCategories] = useState([]); const [loadingChecklist, setLoadingChecklist] = useState(false); const [pendingToggleIds, setPendingToggleIds] = useState>(new Set()); // ... } ``` **page.tsx 변경**: ```typescript // 제거 import { MOCK_REPORTS, MOCK_ROUTES_INITIAL, MOCK_DOCUMENTS, ... } from './mockData'; // 추가 import { getQualityReports, getReportRoutes, getRouteDocuments, ... } from './actions'; // 초기 상태 변경 — 커스텀 훅 사용 const day2 = useDay2LotAudit(); const day1 = useDay1Audit(); // useEffect — ActionResult 패턴에 맞춰 result.success 체크 useEffect(() => { async function fetchReports() { day2.setLoadingReports(true); const result = await getQualityReports({ year: selectedYear, quarter: selectedQuarter === '전체' ? undefined : parseInt(selectedQuarter.replace('Q','')), q: debouncedSearchTerm, // ⚠️ 검색어 디바운스 필수 (300ms 권장) }); if (result.success) { day2.setReports(result.data); } else { toast.error(result.error); // 프로젝트 표준: sonner toast 사용 } day2.setLoadingReports(false); } fetchReports(); }, [selectedYear, selectedQuarter, debouncedSearchTerm]); // ⚠️ 1일차 데이터는 탭 전환 시 lazy loading (페이지 마운트 시 로드하지 않음) useEffect(() => { if (activeDay === 1 && day1.categories.length === 0) { day1.fetchChecklist(selectedYear, selectedQuarter); } }, [activeDay]); // filteredReports useMemo → reports 직접 사용 (서버사이드 필터링) ``` #### 5.2 컴포넌트별 수정 사항 | 컴포넌트 | 현재 (목업) | 변경 후 (API) | 핵심 변경 | |----------|------------|-------------|----------| | `ReportList.tsx` | props로 MOCK_REPORTS | props로 API 데이터 | 변경 없음 (props 동일) | | `RouteList.tsx` | MOCK_ROUTES_INITIAL | API에서 동적 로드 | `onSelect` 시 `getReportRoutes()` 호출 | | `DocumentList.tsx` | MOCK_DOCUMENTS | API에서 동적 로드 | route 선택 시 `getRouteDocuments()` 호출 | | `InspectionModal.tsx` | 내장 MOCK 데이터 | **2단계 로딩** | 모달 열릴 때 `getDocumentDetail(type, id)` 호출 → 상세 데이터 렌더링 | | `Day1ChecklistPanel.tsx` | MOCK_DAY1_CATEGORIES | API에서 로드 | `getChecklistDetail()` 결과 바인딩 | | `Day1DocumentSection.tsx` | MOCK_DAY1_STANDARD_DOCUMENTS | API에서 로드 | `getCheckItemDocuments()` 호출 | | `Day1DocumentViewer.tsx` | 정적 미리보기 | 실제 파일 URL | `fileUrl` → 다운로드 API 연결 | | `AuditProgressBar.tsx` | 계산값 | 서버 progress | 변경 최소 (props 동일) | | `Filters.tsx` | 로컬 필터링 | 서버 필터링 | year/quarter 변경 → API 재호출 | #### 5.3 mockData.ts 처리 - Phase 3 완료 후 `mockData.ts` 삭제 - 또는 `USE_MOCK_FALLBACK` 상수 패턴 적용 (기존 프로젝트 표준): ```typescript // actions.ts 상단 — 이미 3.5절에 포함됨 const USE_MOCK_FALLBACK = false; // 개발 시 true로 변경, 커밋 시 false 유지 // ⚠️ process.env.NEXT_PUBLIC_USE_MOCK 대신 상수 사용 (기존 InspectionManagement 패턴 준수) ``` --- ## 4. 데이터 매핑 (전체) ### 4.1 InspectionReport ↔ QualityDocument + PerformanceReport | 프론트 (InspectionReport) | 백엔드 소스 | 변환 | |---------------------------|------------|------| | `id` | `quality_documents.id` | string 캐스팅 | | `code` | `quality_documents.quality_doc_number` | 직접 매핑 (KD-QD-YYYYMM-NNNN) | | `siteName` | `quality_documents.site_name` | 직접 매핑 | | `item` | `Order → root OrderNode → OrderItem → Item.name` | BOM 최상위(FG) 제품명 자동 추출 | | `routeCount` | `quality_document_locations` WHERE options→lot_audit_confirmed=true | COUNT | | `totalRoutes` | `quality_document_locations` | COUNT | | `quarter` | `performance_reports.year` + `quarter` | 포맷: "2025년 3분기" | | `year` | `performance_reports.year` | 직접 매핑 | | `quarterNum` | `performance_reports.quarter` | 직접 매핑 | ### 4.2 RouteItem ↔ QualityDocumentOrder + Order | 프론트 (RouteItem) | 백엔드 소스 | 변환 | |--------------------|------------|------| | `id` | `quality_document_orders.id` | string 캐스팅 | | `code` | `orders.order_no` | 관계 접근 (⚠️ order_code 아님) | | `date` | `orders.received_at` | 관계 접근 (⚠️ order_date 아님) | | `site` | `orders.site_name` | 관계 접근 | | `locationCount` | `quality_document_locations` | COUNT | | `subItems` | `quality_document_locations` → UnitInspection[] | 아래 참조 | ### 4.3 UnitInspection ↔ QualityDocumentLocation | 프론트 (UnitInspection) | 백엔드 소스 | 변환 | |------------------------|------------|------| | `id` | `quality_document_locations.id` | string 캐스팅 | | `name` | `quality_doc_number + "-" + 순번` | 조합: "KD-QD-202603-0001-01" | | `location` | `OrderItem.floor_code + " " + OrderItem.symbol_code` | 대표 item에서 (root OrderNode 기반) | | `isCompleted` | `quality_document_locations.options.lot_audit_confirmed` | JSON boolean | ### 4.4 ChecklistCategory ↔ AuditChecklistCategory | 프론트 (ChecklistCategory) | 백엔드 소스 | 변환 | |---------------------------|------------|------| | `id` | `audit_checklist_categories.id` | string 캐스팅 | | `title` | `audit_checklist_categories.title` | 직접 매핑 | | `subItems` | `audit_checklist_items` 관계 | ChecklistSubItem[] 변환 | ### 4.5 ChecklistSubItem ↔ AuditChecklistItem | 프론트 (ChecklistSubItem) | 백엔드 소스 | 변환 | |--------------------------|------------|------| | `id` | `audit_checklist_items.id` | string 캐스팅 | | `name` | `audit_checklist_items.name` | 직접 매핑 | | `isCompleted` | `audit_checklist_items.is_completed` | boolean | ### 4.6 StandardDocument ↔ AuditStandardDocument | 프론트 (StandardDocument) | 백엔드 소스 | 변환 | |--------------------------|------------|------| | `id` | `audit_standard_documents.id` | string 캐스팅 | | `title` | `audit_standard_documents.title` | 직접 매핑 | | `version` | `audit_standard_documents.version` | 직접 매핑 | | `date` | `audit_standard_documents.date` | 직접 매핑 | | `fileName` | `documents.original_name` | 관계 접근 | | `fileUrl` | `/api/v1/documents/{document_id}/download` | URL 생성 | --- ## 5. 일정 산정 | Phase | 작업 내용 | 예상 소요 | |-------|----------|-----------| | **Phase 1** | 2일차 API 연동 (기존 API 활용) | | | ├ 1-1 | Backend: QmsLotAuditService + Controller + Route + FormRequest | 1일 | | ├ 1-2 | Backend: 8종 서류 조합 조회 로직 (getRouteDocuments) | 1일 | | ├ 1-3 | Backend: DB 마이그레이션 (options 컬럼 추가) | 0.5일 | | ├ 1-4 | Frontend: actions.ts 2일차 함수 + page.tsx useEffect | 1일 | | └ 1-5 | 테스트 및 디버깅 | 0.5일 | | **Phase 2** | 1일차 백엔드 구축 (완전 신규) | | | ├ 2-1 | DB 마이그레이션 (4테이블) | 0.5일 | | ├ 2-2 | 모델 4개 + 관계 설정 | 0.5일 | | ├ 2-3 | AuditChecklistService + Controller + FormRequest | 1일 | | └ 2-4 | 초기 데이터 시딩 (6개 카테고리 × 12개 항목) | 0.5일 | | **Phase 3** | 프론트엔드 전환 | | | ├ 3-1 | 상태 관리 리팩토링 (커스텀 훅 분리: useDay1Audit, useDay2LotAudit) | 1일 | | ├ 3-2 | 2일차 컴포넌트 API 바인딩 (ReportList, RouteList, DocumentList, InspectionModal 2단계 로딩) | 1.5일 | | ├ 3-3 | 1일차 컴포넌트 API 바인딩 (Day1ChecklistPanel, Day1DocumentSection, Day1DocumentViewer) | 1일 | | └ 3-4 | 통합 테스트 + mockData 정리 + 로딩/에러 상태 세분화 + 디바운스 | 1일 | **총 예상: ~11.5일** (Phase 3 증가: 2.5일 → 4.5일) --- ## 6. 의존성 및 리스크 ### 6.1 의존성 | 항목 | 의존 대상 | 상태 | 비고 | |------|-----------|------|------| | 품질관리서 데이터 | `quality_documents` 실 데이터 | ✅ 운영 중 | QualityDocumentService 활용 | | 실적신고 데이터 | `performance_reports` 실 데이터 | ✅ 운영 중 | 분기 필터용 | | 수입검사 성적서 | `inspections` (IQC) | ✅ 운영 중 | InspectionService 활용 | | 중간검사 성적서 | `inspections` (PQC) | ⚠️ 구현 중 | work_order_id 연결 필요 | | 작업일지 | `work_orders` 연결 | ✅ 운영 중 | 수주→작업지시 경로 | | 출하/납품 | `shipments` | ✅ 운영 중 | 납품확인서/출고증 | | 기준 문서 파일 | EAV Document 시스템 | ✅ 운영 중 | 파일 업로드/다운로드 | ### 6.2 리스크 | 리스크 | 영향 | 완화 방안 | |--------|------|-----------| | 8종 서류 추적 로직 복잡 | Phase 1 지연 | 서류별 독립 조회 → 서비스 메서드 분리, 빈 결과 허용 | | 1일차 점검표 초기 데이터 부재 | Phase 2 테스트 어려움 | AuditChecklistSeeder로 기본 6카테고리×12항목 생성 | | 중간검사(PQC) 미완성 | 2일차 중간검사 서류 누락 | 빈 배열로 표시, count: 0, 추후 연동 | | quality_document_locations.options 없음 | 로트 확인 상태 저장 불가 | 마이그레이션으로 options 컬럼 추가 필수 | | StockLot 데이터 충분성 | IQC 추적 누락 가능 | WorkOrderMaterialInput 미등록 시 빈 배열 반환, 데이터 적재 후 자연 해소 | | confirm 토글 동시 수정 | JSON 병합 충돌 가능 | DB::raw JSON_SET 또는 lockForUpdate() 적용 | | WorkOrder ↔ StockLot 관계 미정의 | eager loading 불가 | WorkOrder 모델에 `materialInputs()` hasMany는 있으나 `stockLots()` 없음. materialInputs 경유 쿼리 사용 | | Order.nodes() 관계명 | 계획서 orderNodes()와 불일치 | 실제 관계명 `nodes()` 사용 또는 alias 추가 | --- ## 7. 권장 진행 순서 ``` Phase 1 (2일차 API 연동) — 4일 ↓ Phase 2 (1일차 백엔드 구축) — 2.5일 ↓ Phase 3 (프론트엔드 전환) — 4.5일 ``` **Phase 1을 먼저 하는 이유:** - 기존 API 활용으로 빠르게 실 데이터 확인 가능 - 로트 추적은 실적신고와 직접 연결되어 비즈니스 우선순위 높음 - Phase 2(1일차)는 독립적인 신규 개발이므로 나중에 진행 가능 --- ## 8. 체크리스트 (작업 완료 검증) ### Phase 1 완료 기준 - [ ] `QmsLotAuditService` 구현 (index, show, routeDocuments, documentDetail, confirm) - [ ] `QmsLotAuditController` + FormRequest 3개 (Confirm, Index, DocumentDetail) - [ ] 라우트 등록 (`api/routes/api/v1/quality.php`) - [ ] `quality_document_locations.options` 마이그레이션 + Model $fillable + $casts 수정 - [ ] 8종 서류 조합 조회 테스트 (각 타입별 빈 결과 포함) - [ ] IQC 추적: **WorkOrderMaterialInput → StockLot** 기반 LOT 추적 구현 + 테스트 - [ ] IQC 추적: `whereNotNull('lot_no')` null 방어 적용 - [ ] FG 제품명 추출 로직 (`getFgProductName`) + eager loading 확인 (N+1 없음) - [ ] 서류 상세 2단계 로딩 API (`documents/{type}/{id}`) 구현 - [ ] subType 결정: WorkOrder.options 기반 매핑 구현 - [ ] confirm 토글: 원자적 JSON 업데이트 (lockForUpdate 또는 JSON_SET) - [ ] Frontend `actions.ts` — `executeServerAction` + `buildApiUrl` + `ActionResult` 패턴 - [ ] Frontend `actions.ts` — snake→camelCase transform 함수 구현 - [ ] Swagger 문서 작성 ### Phase 2 완료 기준 - [ ] 4개 테이블 마이그레이션 실행 - [ ] 4개 모델 (관계, 캐스팅, 상수, 헬퍼) - [ ] `AuditChecklistService` 구현 - [ ] `AuditChecklistController` + FormRequest 2개 - [ ] 라우트 등록 - [ ] 시더 실행 (6카테고리 × 12항목) - [ ] Swagger 문서 작성 ### Phase 3 완료 기준 - [ ] 상태 관리 리팩토링: `useDay1Audit()`, `useDay2LotAudit()` 커스텀 훅 분리 - [ ] 로딩 상태 세분화 (reports/routes/documents/modal 각각 독립) - [ ] 검색 디바운스 (300ms) 적용 - [ ] `page.tsx`에서 mockData import 제거 - [ ] 모든 컴포넌트 API 데이터 바인딩 (ActionResult.success 체크 포함) - [ ] 에러 처리: sonner toast 사용 (setError 문자열 아님) - [ ] 토글 비관적 업데이트: `pendingConfirmIds` 중복 클릭 방지 + 서버 성공 후 UI 반영 - [ ] 1일차 토글도 비관적 업데이트 적용 - [ ] InspectionModal 2단계 로딩 구현 (모달 열 때 getDocumentDetail 호출) - [ ] 1일차 데이터 lazy loading (탭 전환 시 로드) - [ ] mockData.ts 삭제 또는 `USE_MOCK_FALLBACK` 상수 패턴 적용 - [ ] 전체 플로우 수동 테스트 (1일차 + 2일차) --- ## 관련 문서 - [품질인정심사 기능 문서](../../features/quality-management/quality-certification-audit.md) - [제품검사 관리](../../features/quality-management/inspection-management.md) - [생산실적신고](../../features/quality-management/performance-reports.md) - [통합 개선 마스터 플랜](./integrated-master-plan.md) - [API 개발 규칙](../standards/api-rules.md) - [DB 스키마 (생산/품질)](../../system/database/production.md) - [JSON options 컬럼 정책](../standards/options-column-policy.md) --- **최종 업데이트**: 2026-03-10 (3-페르소나 심층 분석 반영)