# 품질인정심사(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-페르소나 심층 분석 반영)