Files
sam-docs/dev/dev_plans/qms-api-integration-plan.md
권혁성 47cef8be96 docs: [QMS] API 연동 계획 문서 보강 — 아키텍처 결정 사항 및 실제 코드 기반 상세화
- IQC 추적 경로: StockLot 직접 → WorkOrderMaterialInput 경유로 수정 (생산입고 vs 투입 관계)
- actions.ts: executeServerAction + buildApiUrl + ActionResult<T> 프로젝트 표준 적용
- snake→camelCase 변환 레이어 및 API 원본 타입 추가
- 필드명 수정: order_code→order_no, order_date→received_at, orderNodes()→nodes()
- 상태 관리 커스텀 훅 설계 (useDay1Audit, useDay2LotAudit) 및 로딩 세분화
- confirm 토글 원자성 보강, null 방어, FormRequest 추가
- Phase 3 일정 재산정 (2.5일→4.5일, 총 9일→11.5일)

- 아키텍처 결정 사항 7건 추가 (2단계 로딩, FG 제품명, 채번 형식, StockLot 기반 IQC, 비관적 업데이트, subType, PR 없는 문서 처리)
- 프론트엔드 상세 구조 추가 (types.ts 전체, page.tsx 상태/핸들러, mockData 계층, 컴포넌트 목록)
- 백엔드 기존 코드 참조 추가 (모델/서비스/컨트롤러 경로, DB 스키마 4개 테이블, 모델 관계 맵)
- 구현 패턴 가이드 추가 (Controller/Service/FormRequest/Model/API 응답/라우트 코드 예시)
- 8종 서류 조합 의사 코드 및 API 응답 매핑 코드 추가
- Phase별 체크리스트 보강

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 17:53:23 +09:00

60 KiB
Raw Blame History

품질인정심사(QMS) API 연동 계획

작성일: 2026-03-09 최종 업데이트: 2026-03-10 (아키텍처 결정 + 3-페르소나 심층 분석 반영) 상태: 계획 수립 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.locationOrderItem.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 전체)

// ===== 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 핵심)

// ===== 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 패턴

// 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 패턴

// 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 패턴

// 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 패턴 (신규 모델 생성 시)

// 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 응답 형식

// 성공 (목록)
{
  "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 라우트 등록

// 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 해당 품질관리서 원본

서류 조합 의사 코드:

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 활용:

{
  "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

// 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 응답 변환:

// 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} 응답 변환:

// 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개)

-- 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} 예상 응답:

{
  "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> 패턴 준수

// 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 상태 폭발 방지):

// 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 변경:

// 제거
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에서 동적 로드 onSelectgetReportRoutes() 호출
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 상수 패턴 적용 (기존 프로젝트 표준):
// 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.tsexecuteServerAction + 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일차)

관련 문서


최종 업데이트: 2026-03-10 (3-페르소나 심층 분석 반영)