Files
sam-docs/dev/dev_plans/qms-api-integration-plan.md

1511 lines
60 KiB
Markdown
Raw Normal View History

# 품질인정심사(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<AuditDisplaySettings>(DEFAULT_SETTINGS);
// AuditDisplaySettings: { showProgressBar, showDocumentViewer, showDocumentSection, showCompletedItems, expandAllCategories }
// Day 1
const [day1Categories, setDay1Categories] = useState<ChecklistCategory[]>(MOCK_DAY1_CATEGORIES);
const [selectedSubItemId, setSelectedSubItemId] = useState<string | null>(null);
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
const [selectedStandardDocId, setSelectedStandardDocId] = useState<string | null>(null);
// Day 2
const [selectedYear, setSelectedYear] = useState(2025);
const [selectedQuarter, setSelectedQuarter] = useState<'Q1'|'Q2'|'Q3'|'Q4'|'전체'>('전체');
const [searchTerm, setSearchTerm] = useState('');
const [selectedReport, setSelectedReport] = useState<InspectionReport | null>(null);
const [selectedRoute, setSelectedRoute] = useState<RouteItem | null>(null);
const [routesData, setRoutesData] = useState<Record<string, RouteItem[]>>(MOCK_ROUTES_INITIAL);
// 모달
const [modalOpen, setModalOpen] = useState(false);
const [selectedDoc, setSelectedDoc] = useState<Document | null>(null);
const [selectedDocItem, setSelectedDocItem] = useState<DocumentItem | null>(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
```
**렌더링 구조**:
```
<Header> + <SettingsButton>
<DayTabs> (진행률 표시)
<Filters> (연도/분기/검색)
activeDay === 1:
grid-cols-12: <Day1ChecklistPanel> | <Day1DocumentSection> | <Day1DocumentViewer>
activeDay === 2:
grid-cols-12: <ReportList> | <RouteList> | <DocumentList>
<AuditSettingsPanel> (모달)
<InspectionModal> (서류 뷰어 모달)
```
### 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<T>` 패턴.
> 레퍼런스: `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<ActionResult<InspectionReport[]>> {
if (USE_MOCK_FALLBACK) {
const { MOCK_REPORTS } = await import('./mockData');
return { success: true, data: MOCK_REPORTS.filter(r => r.year === params.year) };
}
return executeServerAction<InspectionReport[]>({
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<ActionResult<RouteItem[]>> {
return executeServerAction<RouteItem[]>({
url: buildApiUrl(`/api/v1/qms/lot-audit/reports/${reportId}`),
transform: (data) => data.map(transformRouteApi),
errorMessage: '수주/개소 목록 조회에 실패했습니다.',
});
}
export async function getRouteDocuments(routeId: string): Promise<ActionResult<Document[]>> {
return executeServerAction<Document[]>({
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<ActionResult<UnitInspection>> {
return executeServerAction<UnitInspection>({
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<ActionResult<DocumentDetailApi>> {
return executeServerAction<DocumentDetailApi>({
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<T>` 패턴 준수
```typescript
// actions.ts에 추가 — 1일차: 기준/매뉴얼 심사
export async function getChecklists(params: {
year: number;
quarter?: number;
}): Promise<ActionResult<{ id: string; year: number; quarter: number; status: string; progress: Day1Progress }[]>> {
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<ActionResult<{
categories: ChecklistCategory[];
progress: Day1Progress;
}>> {
return executeServerAction({
url: buildApiUrl(`/api/v1/qms/checklists/${id}`),
transform: (data) => transformChecklistResponse(data),
errorMessage: '점검표 상세 조회에 실패했습니다.',
});
}
// ⚠️ 1일차 토글도 비관적 업데이트 적용 (공식 기록이므로 2일차와 동일)
export async function toggleChecklistItem(itemId: string): Promise<ActionResult<ChecklistSubItem>> {
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<ActionResult<StandardDocument[]>> {
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<ActionResult<StandardDocument>> {
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<ActionResult<void>> {
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<InspectionReport[]>([]);
const [selectedReport, setSelectedReport] = useState<InspectionReport | null>(null);
const [routes, setRoutes] = useState<RouteItem[]>([]);
const [selectedRoute, setSelectedRoute] = useState<RouteItem | null>(null);
const [documents, setDocuments] = useState<Document[]>([]);
// ⚠️ 로딩 상태 세분화 — 하나의 loading으로 통합하면 안 됨
const [loadingReports, setLoadingReports] = useState(false);
const [loadingRoutes, setLoadingRoutes] = useState(false);
const [loadingDocuments, setLoadingDocuments] = useState(false);
const [pendingConfirmIds, setPendingConfirmIds] = useState<Set<string>>(new Set()); // 토글 중복 클릭 방지
// handlers...
return { reports, selectedReport, routes, selectedRoute, documents,
loadingReports, loadingRoutes, loadingDocuments, pendingConfirmIds,
/* handlers */ };
}
// hooks/useDay1Audit.ts — 1일차 전용 상태 + 핸들러 캡슐화
function useDay1Audit() {
const [categories, setCategories] = useState<ChecklistCategory[]>([]);
const [loadingChecklist, setLoadingChecklist] = useState(false);
const [pendingToggleIds, setPendingToggleIds] = useState<Set<string>>(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<T> 패턴에 맞춰 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<T>` 패턴
- [ ] 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-페르소나 심층 분석 반영)