# 부적합관리 API 명세 > **작성일**: 2026-03-19 > **상태**: Phase 1+2 API 완료 (결재 연동 포함) > **대상**: React 프론트엔드 개발자 > **라우트**: `/material/nonconforming-management` --- ## 1. 개요 ### 1.1 목적 제조/자재 입고 과정에서 발생하는 품질 부적합(불량)을 등록하고, 원인 분석 → 시정 조치 → 비용 산정까지 관리하는 기능이다. ### 1.2 화면 구성 수주서(`OrderRegistration`)와 동일한 `IntegratedDetailTemplate` + `FormSection` 패턴을 따른다. - **목록**: `/material/nonconforming-management` - **등록**: `/material/nonconforming-management?mode=new` - **상세**: `/material/nonconforming-management/{id}` - **수정**: `/material/nonconforming-management/{id}?mode=edit` ### 1.3 핵심 3가지 1. **불량 내역** — 무엇이, 언제, 어디서 발생했는지 2. **처리 방법** — 원인 분석 + 시정/개선 조치 3. **자재 내역과 비용** — 자재 상세 테이블 + 비용 요약 (4항목 + 합계) --- ## 2. 코드 상수 ### 2.1 부적합 유형 (`nc_type`) | 값 | 라벨 | |------|------| | `material` | 자재불량 | | `process` | 공정불량 | | `construction` | 시공불량 | | `other` | 기타 | ### 2.2 상태 (`status`) | 값 | 라벨 | Badge 색상 (권장) | |------|------|------| | `RECEIVED` | 접수 | default (회색) | | `ANALYZING` | 분석중 | warning (노랑) | | `RESOLVED` | 조치완료 | info (파랑) | | `CLOSED` | 종결 | success (녹색) | ### 2.3 상태 전이 규칙 ``` RECEIVED → ANALYZING (조건 없음) ANALYZING → RESOLVED (cause_analysis + corrective_action 필수) RESOLVED → (결재상신) → 결재 승인 시 자동 CLOSED 결재 반려/회수 시 → RESOLVED 유지 (재상신 가능) ``` > `CLOSED` 상태에서는 수정/삭제 불가 (API가 403 반환) --- ## 3. API 엔드포인트 > **기본 URL**: `/api/v1/material/nonconforming-reports` > **인증**: `Bearer Token` + `X-API-KEY` + `X-TENANT-ID` --- ### 3.1 목록 조회 ``` GET /api/v1/material/nonconforming-reports ``` | 파라미터 | 타입 | 필수 | 설명 | |----------|------|:----:|------| | `page` | int | - | 페이지 (기본: 1) | | `per_page` | int | - | 페이지 크기 (기본: 20) | | `search` | string | - | 검색어 (부적합번호, 현장명, 품목명) | | `status` | string | - | 상태 필터 (`RECEIVED`, `ANALYZING`, `RESOLVED`, `CLOSED`) | | `nc_type` | string | - | 유형 필터 (`material`, `process`, `construction`, `other`) | | `from_date` | date | - | 발생일 시작 (`YYYY-MM-DD`) | | `to_date` | date | - | 발생일 종료 (`YYYY-MM-DD`) | **응답**: ```json { "success": true, "message": "데이터를 조회했습니다.", "data": { "data": [ { "id": 1, "nc_number": "NC-20260319-001", "status": "RECEIVED", "nc_type": "material", "occurred_at": "2026-03-19", "confirmed_at": "2026-03-19", "site_name": "강남 현장", "total_cost": 180000, "creator": { "id": 5, "name": "김보곤" }, "item": { "id": 45, "name": "알루미늄 프레임 50T" }, "order": { "id": 152, "order_number": "ORD-20260315-001" } } ], "current_page": 1, "last_page": 1, "per_page": 20, "total": 1 } } ``` --- ### 3.2 상태별 통계 ``` GET /api/v1/material/nonconforming-reports/stats ``` **응답**: ```json { "success": true, "data": { "by_status": { "RECEIVED": 3, "ANALYZING": 2, "RESOLVED": 5, "CLOSED": 10 }, "total_count": 20, "total_cost": 2500000 } } ``` --- ### 3.3 단건 조회 ``` GET /api/v1/material/nonconforming-reports/{id} ``` **응답**: ```json { "success": true, "data": { "id": 1, "nc_number": "NC-20260319-001", "status": "RECEIVED", "nc_type": "material", "occurred_at": "2026-03-19", "confirmed_at": "2026-03-19", "site_name": "강남 현장", "department": { "id": 3, "name": "제조파트" }, "order": { "id": 152, "order_number": "ORD-20260315-001", "site_name": "강남 현장" }, "item": { "id": 45, "name": "알루미늄 프레임 50T" }, "defect_quantity": 10.00, "unit": "EA", "defect_description": "V-CUT 불량 - 절단면 틀어짐", "cause_analysis": "절단기 날 마모로 인한 정밀도 저하", "corrective_action": "절단기 날 교체 및 정밀도 재검증", "action_completed_at": null, "action_manager": { "id": 8, "name": "이철수" }, "related_employee": { "id": 12, "name": "박영희" }, "material_cost": 150000, "shipping_cost": 30000, "construction_cost": 0, "other_cost": 0, "total_cost": 180000, "remarks": "", "drawing_location": "nas2dual/도면/2026/03", "items": [ { "id": 1, "item_id": 45, "item_name": "알루미늄 프레임 50T", "specification": "50 x 3000mm", "quantity": 10.00, "unit_price": 15000, "amount": 150000, "sort_order": 0, "remarks": "전량 폐기" } ], "files": [], "approval": { "id": 10, "document_number": "AP-20260319-0001", "status": "pending", "completed_at": null, "steps": [ { "id": 1, "step_order": 1, "step_type": "approval", "approver_id": 3, "status": "pending", "comment": null, "acted_at": null, "approver": { "id": 3, "name": "홍길동" } } ] }, "creator": { "id": 5, "name": "김보곤" }, "created_at": "2026-03-19", "updated_at": "2026-03-19" } } ``` > `approval`은 결재상신 전에는 `null`. 결재 진행 중이면 `status: "pending"`, 승인 완료 시 `"approved"`. ``` --- ### 3.4 등록 ``` POST /api/v1/material/nonconforming-reports ``` **요청 Body**: ```json { "nc_type": "material", "occurred_at": "2026-03-19", "confirmed_at": "2026-03-19", "site_name": "강남 현장", "department_id": 3, "order_id": 152, "item_id": 45, "defect_quantity": 10, "unit": "EA", "defect_description": "V-CUT 불량 - 절단면 틀어짐", "cause_analysis": "", "corrective_action": "", "action_manager_id": 8, "related_employee_id": 12, "shipping_cost": 30000, "construction_cost": 0, "other_cost": 0, "drawing_location": "nas2dual/도면/2026/03", "remarks": "", "items": [ { "item_id": 45, "item_name": "알루미늄 프레임 50T", "specification": "50 x 3000mm", "quantity": 10, "unit_price": 15000, "remarks": "전량 폐기" } ] } ``` | 필드 | 타입 | 필수 | 설명 | |------|------|:----:|------| | `nc_type` | string | ✅ | 부적합유형 (2.1 참고) | | `occurred_at` | date | ✅ | 발생일 | | `confirmed_at` | date | - | 불량확인일 | | `site_name` | string | - | 현장명 | | `department_id` | int | - | 부서 ID | | `order_id` | int | - | 관련 수주 ID | | `item_id` | int | - | 관련 품목 ID | | `defect_quantity` | number | - | 불량 수량 | | `unit` | string | - | 단위 (EA, M, KG 등) | | `defect_description` | string | - | 불량 상세 설명 | | `cause_analysis` | string | - | 원인 분석 | | `corrective_action` | string | - | 처리 방안 | | `action_manager_id` | int | - | 조치 담당자 ID | | `related_employee_id` | int | - | 관련 직원 ID | | `shipping_cost` | int | - | 운송 비용 | | `construction_cost` | int | - | 시공 비용 | | `other_cost` | int | - | 기타 비용 | | `drawing_location` | string | - | 도면 저장 위치 | | `remarks` | string | - | 비고 | | `items` | array | - | 자재 상세 내역 (아래 참고) | **items 배열 항목**: | 필드 | 타입 | 필수 | 설명 | |------|------|:----:|------| | `item_id` | int | - | 품목 마스터 ID (선택적) | | `item_name` | string | ✅ | 품목명 | | `specification` | string | - | 규격/사양 | | `quantity` | number | - | 수량 | | `unit_price` | int | - | 단가 | | `remarks` | string | - | 비고 | > `material_cost`는 전송하지 않아도 됨 — items의 `quantity × unit_price` 합계로 자동 계산 > `total_cost`도 자동 계산: `material_cost + shipping_cost + construction_cost + other_cost` **응답**: 단건 조회와 동일한 구조 --- ### 3.5 수정 ``` PUT /api/v1/material/nonconforming-reports/{id} ``` 등록과 동일한 Body. 부분 수정 지원 (변경된 필드만 전송 가능). > `items` 배열이 포함되면 기존 items를 **전체 교체** (삭제 후 재생성) > `items`를 빈 배열 `[]`로 보내면 기존 자재 내역 전부 삭제 --- ### 3.6 삭제 ``` DELETE /api/v1/material/nonconforming-reports/{id} ``` > `CLOSED` 상태에서는 삭제 불가 (403) --- ### 3.7 상태 변경 ``` PATCH /api/v1/material/nonconforming-reports/{id}/status ``` **요청 Body**: ```json { "status": "ANALYZING" } ``` > 전이 규칙 위반 시 422 에러 반환 (섹션 2.3 참고) --- ### 3.8 결재상신 ``` POST /api/v1/material/nonconforming-reports/{id}/submit-approval ``` > `RESOLVED` 상태에서만 가능. 이미 결재가 연결된 경우 422 에러. **요청 Body**: ```json { "title": "부적합 처리 결재 - NC-20260319-001", "form_id": 5, "steps": [ { "approver_id": 3, "step_type": "approval" }, { "approver_id": 7, "step_type": "approval" } ] } ``` | 필드 | 타입 | 필수 | 설명 | |------|------|:----:|------| | `title` | string | - | 결재 제목 (미입력 시 자동 생성) | | `form_id` | int | - | 결재 양식 ID | | `steps` | array | ✅ | 결재선 (최소 1명) | | `steps[].approver_id` | int | ✅ | 결재자 사용자 ID | | `steps[].step_type` | string | - | `approval`(결재), `agreement`(합의), `reference`(참조) — 기본: `approval` | **결재 결과에 따른 부적합 상태 변화**: | 결재 결과 | 부적합 상태 | 설명 | |----------|-----------|------| | 승인 (`approved`) | → `CLOSED` | 종결 (수정 불가) | | 반려 (`rejected`) | `RESOLVED` 유지 | `approval_id` 해제, 재상신 가능 | | 회수 (`cancelled`) | `RESOLVED` 유지 | `approval_id` 해제, 재상신 가능 | **응답**: 단건 조회와 동일 (approval 정보 포함) --- ## 4. 화면별 구현 가이드 ### 4.1 목록 페이지 ``` 파일: react/src/components/material/nonconforming/NonconformingList.tsx ``` | 영역 | 내용 | |------|------| | **상단 통계 카드** | stats API로 상태별 건수 표시 (4개 카드) | | **필터** | 기간(DateRangePicker), 상태(Select), 유형(Select), 검색(Input) | | **테이블 컬럼** | No, 부적합번호, 유형, 현장명, 품목명, 발생일, 비용합계, 상태(Badge), 등록자 | ### 4.2 등록/수정 폼 — 5개 FormSection ``` 파일: react/src/components/material/nonconforming/NonconformingForm.tsx ``` | 섹션 | 필드 | |------|------| | **1. 기본 정보** | 부적합번호(자동/읽기전용), 상태(Badge), 발생일, 확인일, 부적합유형(Select), 현장명, 부서, 관련 수주(검색), 관련 직원 | | **2. 불량 내역** | 관련 품목(검색), 불량 수량, 단위, 불량 상세 설명(Textarea), 첨부파일 | | **3. 원인 분석 및 처리 방안** | 원인 분석(Textarea), 처리 방안(Textarea), 조치 완료일, 조치 담당자 | | **4. 자재 내역 및 비용** | 자재 상세 테이블(행 추가/삭제) + 비용 요약(4항목+합계) | | **5. 비고** | 비고(Textarea), 도면 저장 위치(Input) | ### 4.3 자재 상세 테이블 ``` 파일: react/src/components/material/nonconforming/NonconformingItemTable.tsx ``` | 컬럼 | 타입 | 설명 | |------|------|------| | No | 자동 | 1부터 순번 | | 품목명 | Input (필수) | `item_name` | | 규격 | Input | `specification` | | 수량 | NumberInput | `quantity` | | 단가 | NumberInput | `unit_price` | | 금액 | 읽기전용 | `quantity × unit_price` (자동 계산) | | 비고 | Input | `remarks` | | 작업 | Button | 삭제 버튼 | - 하단에 "행 추가" 버튼 - 금액 합계 = `material_cost` (비용 요약에 자동 반영) ### 4.4 비용 요약 ``` 파일: react/src/components/material/nonconforming/CostSummary.tsx ``` | 항목 | 입력 방식 | 설명 | |------|----------|------| | 자재 비용 | **읽기 전용** | 자재 상세 테이블 금액 합계 | | 운송 비용 | NumberInput | 수동 입력 | | 시공 비용 | NumberInput | 수동 입력 | | 기타 비용 | NumberInput | 수동 입력 | | **비용 합계** | **읽기 전용** | 4개 항목 합계 | ### 4.5 상태별 UI 제어 | 상태 | 수정 가능 | 액션 버튼 | |------|:---------:|----------| | `RECEIVED` | ✅ | "분석 시작" → `ANALYZING` | | `ANALYZING` | ✅ | "조치 완료" → `RESOLVED` (원인분석+처리방안 필수) | | `RESOLVED` | ✅ | "결재상신" → submit-approval API 호출 | | `RESOLVED` + 결재중 | ❌ | 결재 진행 상태 표시 (기존 결재 시스템 UI) | | `CLOSED` | ❌ 읽기전용 | 없음 (결재 승인으로 자동 전환됨) | --- ## 5. React 파일 구조 ``` react/src/ ├── app/[locale]/(protected)/material/ │ └── nonconforming-management/ │ ├── page.tsx ← mode 분기 (목록/등록) │ └── [id]/page.tsx ← 상세/수정 ├── components/material/nonconforming/ │ ├── NonconformingList.tsx ← 목록 + 통계 카드 │ ├── NonconformingForm.tsx ← 등록/수정 (5개 FormSection) │ ├── NonconformingDetail.tsx ← 상세 뷰 (읽기전용) │ ├── NonconformingItemTable.tsx ← 자재 상세 테이블 │ ├── CostSummary.tsx ← 비용 요약 (4항목+합계) │ └── actions.ts ← Server Actions ``` --- ## 6. Server Actions 예시 ```typescript // actions.ts 'use server'; import { buildApiUrl } from '@/lib/api/query-params'; import { executeServerAction, executePaginatedAction } from '@/lib/api/server-action'; // 목록 export async function getNonconformingReports(params: Record) { return executePaginatedAction({ url: buildApiUrl('/api/v1/material/nonconforming-reports', params), method: 'GET', }); } // 통계 export async function getNonconformingStats() { return executeServerAction({ url: buildApiUrl('/api/v1/material/nonconforming-reports/stats'), method: 'GET', }); } // 단건 export async function getNonconformingReport(id: number) { return executeServerAction({ url: buildApiUrl(`/api/v1/material/nonconforming-reports/${id}`), method: 'GET', }); } // 등록 export async function createNonconformingReport(data: Record) { return executeServerAction({ url: buildApiUrl('/api/v1/material/nonconforming-reports'), method: 'POST', body: data, }); } // 수정 export async function updateNonconformingReport(id: number, data: Record) { return executeServerAction({ url: buildApiUrl(`/api/v1/material/nonconforming-reports/${id}`), method: 'PUT', body: data, }); } // 삭제 export async function deleteNonconformingReport(id: number) { return executeServerAction({ url: buildApiUrl(`/api/v1/material/nonconforming-reports/${id}`), method: 'DELETE', }); } // 상태 변경 export async function changeNonconformingStatus(id: number, status: string) { return executeServerAction({ url: buildApiUrl(`/api/v1/material/nonconforming-reports/${id}/status`), method: 'PATCH', body: { status }, }); } // 결재상신 export async function submitNonconformingApproval( id: number, data: { title?: string; form_id?: number; steps: Array<{ approver_id: number; step_type?: string }> } ) { return executeServerAction({ url: buildApiUrl(`/api/v1/material/nonconforming-reports/${id}/submit-approval`), method: 'POST', body: data, }); } ``` --- ## 관련 문서 - `dev/dev_plans/nonconforming-management-plan.md` — 기획서 (전체 Phase 계획) - `rules/item-policy.md` — 품목 정책 - `features/quotes/README.md` — 수주 시스템 (폼 구조 참고) --- **최종 업데이트**: 2026-03-19