/** * 입고 관리 서버 액션 * * API Endpoints: * - GET /api/v1/receivings - 목록 조회 * - GET /api/v1/receivings/stats - 통계 조회 * - GET /api/v1/receivings/{id} - 상세 조회 * - POST /api/v1/receivings - 등록 * - PUT /api/v1/receivings/{id} - 수정 * - DELETE /api/v1/receivings/{id} - 삭제 * - POST /api/v1/receivings/{id}/process - 입고처리 */ 'use server'; // ===== 목데이터 모드 플래그 ===== const USE_MOCK_DATA = false; import { serverFetch } from '@/lib/api/fetch-wrapper'; import { buildApiUrl } from '@/lib/api/query-params'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; import { executeServerAction } from '@/lib/api/execute-server-action'; import { executePaginatedAction } from '@/lib/api/execute-paginated-action'; import { getTodayString, formatDate } from '@/lib/utils/date'; import type { ReceivingItem, ReceivingDetail, ReceivingStats, ReceivingStatus, ReceivingProcessFormData, } from './types'; // ===== 목데이터 ===== const MOCK_RECEIVING_LIST: ReceivingItem[] = [ { id: '1', materialNo: 'MAT-001', lotNo: 'LOT-2026-001', itemId: 101, inspectionStatus: '적', inspectionResult: '합격', inspectionDate: '2026-01-25', supplier: '(주)대한철강', manufacturer: '포스코', itemCode: 'STEEL-001', itemType: '원자재', itemName: 'SUS304 스테인리스 판재', specification: '1000x2000x3T', unit: 'EA', receivingQty: 100, receivingDate: '2026-01-26', createdBy: '김철수', status: 'completed', }, { id: '2', materialNo: 'MAT-002', lotNo: 'LOT-2026-002', itemId: 102, inspectionStatus: '적', inspectionResult: '합격', inspectionDate: '2026-01-26', supplier: '삼성전자부품', manufacturer: '삼성전자', itemCode: 'ELEC-002', itemType: '부품', itemName: 'MCU 컨트롤러 IC', specification: 'STM32F103C8T6', unit: 'EA', receivingQty: 500, receivingDate: '2026-01-27', createdBy: '이영희', status: 'completed', }, { id: '3', materialNo: 'MAT-003', lotNo: 'LOT-2026-003', itemId: 103, inspectionStatus: '-', inspectionResult: undefined, inspectionDate: undefined, supplier: '한국플라스틱', manufacturer: '한국플라스틱', itemCode: 'PLAS-003', itemType: '부자재', itemName: 'ABS 사출 케이스', specification: '150x100x50', unit: 'SET', receivingQty: undefined, receivingDate: undefined, createdBy: '박민수', status: 'receiving_pending', }, { id: '4', materialNo: 'MAT-004', lotNo: 'LOT-2026-004', itemId: 104, inspectionStatus: '부적', inspectionResult: '불합격', inspectionDate: '2026-01-27', supplier: '(주)대한철강', manufacturer: '포스코', itemCode: 'STEEL-002', itemType: '원자재', itemName: '알루미늄 프로파일', specification: '40x40x2000L', unit: 'EA', receivingQty: 50, receivingDate: '2026-01-28', createdBy: '김철수', status: 'inspection_pending', }, { id: '5', materialNo: 'MAT-005', lotNo: 'LOT-2026-005', itemId: 105, inspectionStatus: '-', inspectionResult: undefined, inspectionDate: undefined, supplier: '글로벌전자', manufacturer: '글로벌전자', itemCode: 'ELEC-005', itemType: '부품', itemName: 'DC 모터 24V', specification: '24V 100RPM', unit: 'EA', receivingQty: undefined, receivingDate: undefined, createdBy: '최지훈', status: 'receiving_pending', }, { id: '6', materialNo: 'MAT-006', lotNo: 'LOT-2026-006', itemId: undefined, // 품목 미연결 → 수입검사 대상 아님 inspectionStatus: '-', inspectionResult: undefined, inspectionDate: undefined, supplier: '동양화학', manufacturer: '동양화학', itemCode: 'CHEM-001', itemType: '부자재', itemName: '에폭시 접착제', specification: '500ml', unit: 'EA', receivingQty: 200, receivingDate: '2026-01-25', createdBy: '이영희', status: 'completed', }, { id: '7', materialNo: 'MAT-007', lotNo: 'LOT-2026-007', itemId: 107, inspectionStatus: '적', inspectionResult: '합격', inspectionDate: '2026-01-28', supplier: '삼성전자부품', manufacturer: '삼성전자', itemCode: 'ELEC-007', itemType: '부품', itemName: '커패시터 100uF', specification: '100uF 50V', unit: 'EA', receivingQty: 1000, receivingDate: '2026-01-28', createdBy: '박민수', status: 'completed', }, { id: '8', materialNo: 'MAT-008', lotNo: 'LOT-2026-008', itemId: undefined, // 품목 미연결 → 수입검사 대상 아님 inspectionStatus: '-', inspectionResult: undefined, inspectionDate: undefined, supplier: '한국볼트', manufacturer: '한국볼트', itemCode: 'BOLT-001', itemType: '부품', itemName: 'SUS 볼트 M8x30', specification: 'M8x30 SUS304', unit: 'EA', receivingQty: undefined, receivingDate: undefined, createdBy: '김철수', status: 'receiving_pending', }, ]; const MOCK_RECEIVING_STATS: ReceivingStats = { receivingPendingCount: 3, receivingCompletedCount: 4, inspectionPendingCount: 1, inspectionCompletedCount: 5, }; // 기획서 2026-02-03 기준 상세 목데이터 const MOCK_RECEIVING_DETAIL: Record = { '1': { id: '1', materialNo: 'MAT-001', lotNo: 'LOT-2026-001', itemCode: 'STEEL-001', itemName: 'SUS304 스테인리스 판재', specification: '1000x2000x3T', unit: 'EA', supplier: '(주)대한철강', manufacturer: '포스코', receivingQty: 100, receivingDate: '2026-01-26', createdBy: '김철수', status: 'completed', remark: '', inspectionDate: '2026-01-25', inspectionResult: '합격', certificateFile: undefined, inventoryAdjustments: [ { id: 'adj-1', adjustmentDate: '2026-01-05', quantity: 10, inspector: '홍길동' }, { id: 'adj-2', adjustmentDate: '2026-01-05', quantity: 5, inspector: '홍길동' }, { id: 'adj-3', adjustmentDate: '2026-01-05', quantity: -15, inspector: '홍길동' }, { id: 'adj-4', adjustmentDate: '2026-01-05', quantity: 5, inspector: '홍길동' }, ], orderNo: 'PO-2026-001', orderUnit: 'EA', }, '2': { id: '2', materialNo: 'MAT-002', lotNo: 'LOT-2026-002', itemCode: 'ELEC-002', itemName: 'MCU 컨트롤러 IC', specification: 'STM32F103C8T6', unit: 'EA', supplier: '삼성전자부품', manufacturer: '삼성전자', receivingQty: 500, receivingDate: '2026-01-27', createdBy: '이영희', status: 'completed', remark: '긴급 입고', inspectionDate: '2026-01-26', inspectionResult: '합격', inventoryAdjustments: [], orderNo: 'PO-2026-002', orderUnit: 'EA', }, '3': { id: '3', materialNo: 'MAT-003', lotNo: 'LOT-2026-003', itemCode: 'PLAS-003', itemName: 'ABS 사출 케이스', specification: '150x100x50', unit: 'SET', supplier: '한국플라스틱', manufacturer: '한국플라스틱', receivingQty: undefined, receivingDate: undefined, createdBy: '박민수', status: 'receiving_pending', remark: '', inspectionDate: undefined, inspectionResult: undefined, inventoryAdjustments: [], orderNo: 'PO-2026-003', orderUnit: 'SET', }, '4': { id: '4', materialNo: 'MAT-004', lotNo: 'LOT-2026-004', itemCode: 'STEEL-002', itemName: '알루미늄 프로파일', specification: '40x40x2000L', unit: 'EA', supplier: '(주)대한철강', manufacturer: '포스코', receivingQty: 50, receivingDate: '2026-01-28', createdBy: '김철수', status: 'inspection_pending', remark: '검사 진행 중', inspectionDate: '2026-01-27', inspectionResult: '불합격', inventoryAdjustments: [], orderNo: 'PO-2026-004', orderUnit: 'EA', }, '5': { id: '5', materialNo: 'MAT-005', lotNo: 'LOT-2026-005', itemCode: 'ELEC-005', itemName: 'DC 모터 24V', specification: '24V 100RPM', unit: 'EA', supplier: '글로벌전자', manufacturer: '글로벌전자', receivingQty: undefined, receivingDate: undefined, createdBy: '최지훈', status: 'receiving_pending', remark: '', inspectionDate: undefined, inspectionResult: undefined, inventoryAdjustments: [], orderNo: 'PO-2026-005', orderUnit: 'EA', }, }; // ===== API 데이터 타입 ===== interface ReceivingApiData { id: number; receiving_number: string; order_no?: string; order_date?: string; item_id?: number; item_code: string; item_name: string; specification?: string; supplier: string; order_qty: string | number; order_unit: string; due_date?: string; receiving_qty?: string | number; receiving_date?: string; lot_no?: string; supplier_lot?: string; receiving_location?: string; receiving_manager?: string; status: ReceivingStatus; remark?: string; options?: Record; creator?: { id: number; name: string }; created_at?: string; updated_at?: string; // 품목 관계 (item relation이 로드된 경우) item?: { id: number; item_type?: string; code?: string; name?: string; }; // options에서 추출된 접근자 (API appends) manufacturer?: string; material_no?: string; inspection_status?: string; inspection_date?: string; inspection_result?: string; // 수입검사 템플릿 연결 여부 (서버에서 계산) has_inspection_template?: boolean; // 성적서 파일 certificate_file_id?: number; certificate_file?: { id: number; display_name?: string; file_path?: string }; } interface ReceivingApiStatsResponse { receiving_pending_count: number; shipping_count: number; inspection_pending_count: number; today_receiving_count: number; } // ===== 품목유형 코드 → 라벨 변환 ===== const ITEM_TYPE_LABELS: Record = { FG: '완제품', PT: '부품', SM: '부자재', RM: '원자재', CS: '소모품', }; // ===== API → Frontend 변환 (목록용) ===== function transformApiToListItem(data: ReceivingApiData): ReceivingItem { return { id: String(data.id), // 입고번호: receiving_number 매핑 materialNo: data.receiving_number, // 거래처 자재번호: options.material_no supplierMaterialNo: data.material_no, // 원자재로트 lotNo: data.lot_no, // 품목 ID itemId: data.item_id, // 수입검사 템플릿 연결 여부 hasInspectionTemplate: data.has_inspection_template ?? false, // 수입검사: options.inspection_status (적/부적/-) inspectionStatus: data.inspection_status, // 검사결과: options.inspection_result (합격/불합격) inspectionResult: data.inspection_result, // 검사일: options.inspection_date inspectionDate: data.inspection_date, // 발주처 supplier: data.supplier, // 제조사: options.manufacturer manufacturer: data.manufacturer, // 품목코드 itemCode: data.item_code, // 품목유형: item relation에서 가져옴 itemType: data.item?.item_type ? ITEM_TYPE_LABELS[data.item.item_type] || data.item.item_type : undefined, // 품목명 itemName: data.item_name, // 규격 specification: data.specification, // 단위 unit: data.order_unit || 'EA', // 수량 (입고수량) receivingQty: data.receiving_qty ? parseFloat(String(data.receiving_qty)) : undefined, // 입고변경일: updated_at 매핑 receivingDate: data.updated_at ? formatDate(data.updated_at) : data.receiving_date, // 작성자 createdBy: data.creator?.name, // 상태 status: data.status, // 기존 필드 (하위 호환) orderNo: data.order_no || data.receiving_number, orderQty: parseFloat(String(data.order_qty)) || 0, orderUnit: data.order_unit || 'EA', }; } // ===== API → Frontend 변환 (상세용) ===== function transformApiToDetail(data: ReceivingApiData): ReceivingDetail { return { id: String(data.id), materialNo: data.receiving_number, supplierMaterialNo: data.material_no, lotNo: data.lot_no, orderNo: data.order_no || data.receiving_number, orderDate: data.order_date, supplier: data.supplier, manufacturer: data.manufacturer, itemId: data.item_id, itemCode: data.item_code, itemName: data.item_name, specification: data.specification, orderQty: parseFloat(String(data.order_qty)) || 0, orderUnit: data.order_unit || 'EA', dueDate: data.due_date, status: data.status, remark: data.remark, receivingDate: data.receiving_date, receivingQty: data.receiving_qty ? parseFloat(String(data.receiving_qty)) : undefined, receivingLot: data.lot_no, supplierLot: data.supplier_lot, receivingLocation: data.receiving_location, receivingManager: data.receiving_manager, unit: data.order_unit || 'EA', createdBy: data.creator?.name, inspectionDate: data.inspection_date, inspectionResult: data.inspection_result, certificateFileId: data.certificate_file_id, certificateFileName: data.certificate_file?.display_name, }; } // ===== API → Frontend 변환 (통계용) ===== function transformApiToStats(data: ReceivingApiStatsResponse): ReceivingStats { return { receivingPendingCount: data.receiving_pending_count, receivingCompletedCount: data.shipping_count, inspectionPendingCount: data.inspection_pending_count, inspectionCompletedCount: data.today_receiving_count, shippingCount: data.shipping_count, todayReceivingCount: data.today_receiving_count, }; } // ===== Frontend → API 변환 (등록/수정용) ===== function transformFrontendToApi( data: Partial ): Record { const result: Record = {}; if (data.orderNo !== undefined) result.order_no = data.orderNo; if (data.orderDate !== undefined) result.order_date = data.orderDate; if (data.itemCode !== undefined) result.item_code = data.itemCode; if (data.itemName !== undefined) result.item_name = data.itemName; if (data.specification !== undefined) result.specification = data.specification; if (data.supplier !== undefined) result.supplier = data.supplier; // 발주수량: orderQty가 없으면 receivingQty를 사용 const orderQty = data.orderQty ?? data.receivingQty; if (orderQty !== undefined) result.order_qty = orderQty; if (data.orderUnit !== undefined) result.order_unit = data.orderUnit; if (data.dueDate !== undefined) result.due_date = data.dueDate; if (data.status !== undefined) result.status = data.status; if (data.remark !== undefined) result.remark = data.remark; if (data.receivingQty !== undefined) result.receiving_qty = data.receivingQty; if (data.receivingDate !== undefined) result.receiving_date = data.receivingDate; if (data.lotNo !== undefined) result.lot_no = data.lotNo; if (data.supplierMaterialNo !== undefined) result.material_no = data.supplierMaterialNo; if (data.manufacturer !== undefined) result.manufacturer = data.manufacturer; if (data.certificateFileId !== undefined) result.certificate_file_id = data.certificateFileId; return result; } // ===== Frontend → API 변환 (입고처리용) ===== function transformProcessDataToApi( data: ReceivingProcessFormData ): Record { return { receiving_qty: data.receivingQty, lot_no: data.receivingLot, supplier_lot: data.supplierLot, receiving_location: data.receivingLocation, remark: data.remark, }; } // ===== 입고 목록 조회 ===== export async function getReceivings(params?: { page?: number; perPage?: number; startDate?: string; endDate?: string; status?: string; search?: string; }) { // ===== 목데이터 모드 ===== if (USE_MOCK_DATA) { let filteredData = [...MOCK_RECEIVING_LIST]; // 상태 필터 if (params?.status && params.status !== 'all') { filteredData = filteredData.filter(item => item.status === params.status); } // 검색 필터 if (params?.search) { const search = params.search.toLowerCase(); filteredData = filteredData.filter( item => item.lotNo?.toLowerCase().includes(search) || item.itemCode.toLowerCase().includes(search) || item.itemName.toLowerCase().includes(search) || item.supplier.toLowerCase().includes(search) ); } const page = params?.page || 1; const perPage = params?.perPage || 20; const total = filteredData.length; const lastPage = Math.ceil(total / perPage); const startIndex = (page - 1) * perPage; const paginatedData = filteredData.slice(startIndex, startIndex + perPage); return { success: true as const, data: paginatedData, pagination: { currentPage: page, lastPage, perPage, total, }, }; } return executePaginatedAction({ url: buildApiUrl('/api/v1/receivings', { page: params?.page, per_page: params?.perPage, start_date: params?.startDate, end_date: params?.endDate, status: params?.status && params.status !== 'all' ? params.status : undefined, search: params?.search, }), transform: transformApiToListItem, errorMessage: '입고 목록 조회에 실패했습니다.', }); } // ===== 입고 통계 조회 ===== export async function getReceivingStats(): Promise<{ success: boolean; data?: ReceivingStats; error?: string; __authError?: boolean; }> { if (USE_MOCK_DATA) return { success: true, data: MOCK_RECEIVING_STATS }; const result = await executeServerAction({ url: buildApiUrl('/api/v1/receivings/stats'), transform: (data: ReceivingApiStatsResponse) => transformApiToStats(data), errorMessage: '입고 통계 조회에 실패했습니다.', }); if (result.__authError) return { success: false, __authError: true }; return { success: result.success, data: result.data, error: result.error }; } // ===== 입고 상세 조회 ===== export async function getReceivingById(id: string): Promise<{ success: boolean; data?: ReceivingDetail; error?: string; __authError?: boolean; }> { if (USE_MOCK_DATA) { const detail = MOCK_RECEIVING_DETAIL[id]; return detail ? { success: true, data: detail } : { success: false, error: '입고 정보를 찾을 수 없습니다.' }; } const result = await executeServerAction({ url: buildApiUrl(`/api/v1/receivings/${id}`), transform: (data: ReceivingApiData) => transformApiToDetail(data), errorMessage: '입고 조회에 실패했습니다.', }); if (result.__authError) return { success: false, __authError: true }; return { success: result.success, data: result.data, error: result.error }; } // ===== 입고 등록 ===== export async function createReceiving( data: Partial ): Promise<{ success: boolean; data?: ReceivingDetail; error?: string; __authError?: boolean }> { const apiData = transformFrontendToApi(data); const result = await executeServerAction({ url: buildApiUrl('/api/v1/receivings'), method: 'POST', body: apiData, transform: (d: ReceivingApiData) => transformApiToDetail(d), errorMessage: '입고 등록에 실패했습니다.', }); if (result.__authError) return { success: false, __authError: true }; return { success: result.success, data: result.data, error: result.error }; } // ===== 입고 수정 ===== export async function updateReceiving( id: string, data: Partial ): Promise<{ success: boolean; data?: ReceivingDetail; error?: string; __authError?: boolean }> { const apiData = transformFrontendToApi(data); const result = await executeServerAction({ url: buildApiUrl(`/api/v1/receivings/${id}`), method: 'PUT', body: apiData, transform: (d: ReceivingApiData) => transformApiToDetail(d), errorMessage: '입고 수정에 실패했습니다.', }); if (result.__authError) return { success: false, __authError: true }; return { success: result.success, data: result.data, error: result.error }; } // ===== 입고 삭제 ===== export async function deleteReceiving( id: string ): Promise<{ success: boolean; error?: string; __authError?: boolean }> { const result = await executeServerAction({ url: buildApiUrl(`/api/v1/receivings/${id}`), method: 'DELETE', errorMessage: '입고 삭제에 실패했습니다.', }); if (result.__authError) return { success: false, __authError: true }; return { success: result.success, error: result.error }; } // ===== 입고처리 ===== export async function processReceiving( id: string, data: ReceivingProcessFormData ): Promise<{ success: boolean; data?: ReceivingDetail; error?: string; __authError?: boolean }> { const apiData = transformProcessDataToApi(data); const result = await executeServerAction({ url: buildApiUrl(`/api/v1/receivings/${id}/process`), method: 'POST', body: apiData, transform: (d: ReceivingApiData) => transformApiToDetail(d), errorMessage: '입고처리에 실패했습니다.', }); if (result.__authError) return { success: false, __authError: true }; return { success: result.success, data: result.data, error: result.error }; } // ===== 품목 검색 (입고 등록용) ===== export interface ItemOption { value: string; // itemCode label: string; // itemCode 표시 description?: string; // 품목명 + 규격 itemName: string; specification: string; unit: string; } const MOCK_ITEMS: ItemOption[] = [ { value: 'STEEL-001', label: 'STEEL-001', description: 'SUS304 스테인리스 판재 (1000x2000x3T)', itemName: 'SUS304 스테인리스 판재', specification: '1000x2000x3T', unit: 'EA' }, { value: 'STEEL-002', label: 'STEEL-002', description: '알루미늄 프로파일 (40x40x2000L)', itemName: '알루미늄 프로파일', specification: '40x40x2000L', unit: 'EA' }, { value: 'ELEC-002', label: 'ELEC-002', description: 'MCU 컨트롤러 IC (STM32F103C8T6)', itemName: 'MCU 컨트롤러 IC', specification: 'STM32F103C8T6', unit: 'EA' }, { value: 'ELEC-005', label: 'ELEC-005', description: 'DC 모터 24V (24V 100RPM)', itemName: 'DC 모터 24V', specification: '24V 100RPM', unit: 'EA' }, { value: 'ELEC-007', label: 'ELEC-007', description: '커패시터 100uF (100uF 50V)', itemName: '커패시터 100uF', specification: '100uF 50V', unit: 'EA' }, { value: 'PLAS-003', label: 'PLAS-003', description: 'ABS 사출 케이스 (150x100x50)', itemName: 'ABS 사출 케이스', specification: '150x100x50', unit: 'SET' }, { value: 'CHEM-001', label: 'CHEM-001', description: '에폭시 접착제 (500ml)', itemName: '에폭시 접착제', specification: '500ml', unit: 'EA' }, { value: 'BOLT-001', label: 'BOLT-001', description: 'SUS 볼트 M8x30 (M8x30 SUS304)', itemName: 'SUS 볼트 M8x30', specification: 'M8x30 SUS304', unit: 'EA' }, ]; export async function searchItems(query?: string): Promise<{ success: boolean; data: ItemOption[]; }> { if (USE_MOCK_DATA) { if (!query) return { success: true, data: MOCK_ITEMS }; const q = query.toLowerCase(); const filtered = MOCK_ITEMS.filter( (item) => item.value.toLowerCase().includes(q) || item.itemName.toLowerCase().includes(q) ); return { success: true, data: filtered }; } interface ItemApiData { data: Array> } const result = await executeServerAction({ url: buildApiUrl('/api/v1/items', { search: query, per_page: 50 }), transform: (d) => (d.data || []).map((item) => ({ value: item.item_code, label: item.item_code, description: `${item.item_name} (${item.specification || '-'})`, itemName: item.item_name, specification: item.specification || '', unit: item.unit || 'EA', })), errorMessage: '품목 검색에 실패했습니다.', }); return { success: result.success, data: result.data || [] }; } // ===== 발주처 검색 (입고 등록용) ===== export interface SupplierOption { value: string; label: string; } const MOCK_SUPPLIERS: SupplierOption[] = [ { value: '(주)대한철강', label: '(주)대한철강' }, { value: '삼성전자부품', label: '삼성전자부품' }, { value: '한국플라스틱', label: '한국플라스틱' }, { value: '글로벌전자', label: '글로벌전자' }, { value: '동양화학', label: '동양화학' }, { value: '한국볼트', label: '한국볼트' }, { value: '지오TNS (KG스틸)', label: '지오TNS (KG스틸)' }, ]; export async function searchSuppliers(query?: string): Promise<{ success: boolean; data: SupplierOption[]; }> { if (USE_MOCK_DATA) { if (!query) return { success: true, data: MOCK_SUPPLIERS }; const q = query.toLowerCase(); const filtered = MOCK_SUPPLIERS.filter((s) => s.label.toLowerCase().includes(q)); return { success: true, data: filtered }; } interface SupplierApiData { data: Array> } const result = await executeServerAction({ url: buildApiUrl('/api/v1/suppliers', { search: query, per_page: 50 }), transform: (d) => (d.data || []).map((s) => ({ value: s.name, label: s.name, })), errorMessage: '발주처 검색에 실패했습니다.', }); return { success: result.success, data: result.data || [] }; } // ===== 수입검사 템플릿 타입 (ImportInspectionDocument와 동일) ===== export interface InspectionTemplateResponse { templateId: string; templateName: string; headerInfo: { productName: string; specification: string; materialNo: string; lotSize: number; supplier: string; lotNo: string; inspectionDate: string; inspector: string; reportDate: string; approvers: { writer?: string; reviewer?: string; approver?: string; }; // 결재선 원본 데이터 (동적 UI 표시용) approvalLines?: Array<{ id: number; name: string; dept: string; role: string; sortOrder: number; }>; }; inspectionItems: Array<{ id: string; no: number; name: string; subName?: string; parentId?: string; standard: { description?: string; value?: string | number; tolerance?: string; // 허용치 문자열 (MNG와 동일) options?: Array<{ id: string; label: string; tolerance: string; isSelected: boolean; }>; }; inspectionMethod: string; inspectionCycle: string; measurementType: 'okng' | 'numeric' | 'both' | 'single_value' | 'substitute'; measurementCount: number; // 1차 그룹 (category) rowspan - NO, 카테고리명 컬럼용 categoryRowSpan?: number; isFirstInCategory?: boolean; // 2차 그룹 (item) rowspan - 검사항목(세부), 검사방식, 검사주기, 측정치, 판정 컬럼용 itemRowSpan?: number; isFirstInItem?: boolean; // 기존 호환용 (deprecated) rowSpan?: number; isSubRow?: boolean; }>; notes?: string[]; } // ===== 수입검사 템플릿 Resolve API 응답 타입 ===== export interface DocumentResolveResponse { is_new: boolean; template: { id: number; name: string; category: string; title: string; company_name?: string; company_address?: string; company_contact?: string; footer_remark_label?: string; footer_judgement_label?: string; footer_judgement_options?: string[]; approval_lines: Array<{ id: number; name?: string; dept?: string; role: string; user_id?: number; sort_order: number; }>; basic_fields: Array<{ id: number; field_key: string; label: string; input_type: string; options?: unknown; default_value?: string; is_required: boolean; sort_order: number; }>; section_fields: Array<{ id: number; field_key: string; label: string; field_type: string; options?: unknown; width?: string; is_required: boolean; sort_order: number; }>; sections: Array<{ id: number; name: string; sort_order: number; items: Array<{ id: number; /** 새로운 API 형식: field_values에 모든 필드값 포함 */ field_values?: Record; /** 레거시 필드 (하위 호환) */ category?: string; item?: string; standard?: string; /** 범위 조건 객체: { min, min_op, max, max_op } */ standard_criteria?: { min?: number | null; min_op?: 'gt' | 'gte' | null; max?: number | null; max_op?: 'lt' | 'lte' | null; } | null; /** 공차 객체: { type, value, plus, minus, min, max, op } */ tolerance?: { type?: 'symmetric' | 'asymmetric' | 'range' | 'percentage' | 'limit'; value?: string | number; plus?: string | number; minus?: string | number; min?: string | number; max?: string | number; op?: 'lte' | 'lt' | 'gte' | 'gt'; // limit 타입용 } | string | null; method?: string; method_name?: string; // 검사방식 한글 이름 (API에서 common_codes join) measurement_type?: string; frequency?: string; frequency_n?: number; frequency_c?: number; regulation?: string; sort_order: number; }>; }>; columns: Array<{ id: number; label: string; input_type: string; options?: unknown; width?: string; is_required: boolean; sort_order: number; }>; }; document: { id: number; document_no: string; title: string; status: 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED' | 'CANCELLED'; linkable_type?: string; linkable_id?: number; submitted_at?: string; completed_at?: string; created_at?: string; data: Array<{ section_id?: number | null; column_id?: number | null; row_index: number; field_key: string; field_value?: string | null; }>; attachments: Array<{ id: number; file_id: number; attachment_type: string; description?: string; file?: { id: number; original_name: string; display_name?: string; file_path: string; file_size: number; mime_type?: string; }; }>; approvals: Array<{ id: number; user_id: number; user_name?: string; step: number; role: string; status: string; comment?: string; acted_at?: string; }>; } | null; item: { id: number; code: string; name: string; /** 품목 속성 (thickness, width, length 등) */ attributes?: { thickness?: number; width?: number; length?: number; [key: string]: unknown; } | null; }; } // ===== 수입검사 템플릿 존재 여부 확인 ===== export async function checkInspectionTemplate(itemId?: number): Promise<{ success: boolean; hasTemplate: boolean; attachments?: Array<{ id: number; file_id: number; attachment_type: string; description?: string; file?: { id: number; display_name?: string; original_name?: string; file_path: string; file_size: number; mime_type?: string; }; }>; error?: string; }> { if (!itemId) { return { success: true, hasTemplate: false }; } // 목데이터 모드 if (USE_MOCK_DATA) { return { success: true, hasTemplate: true }; } try { const url = buildApiUrl('/api/v1/documents/resolve', { category: 'incoming_inspection', item_id: itemId, }); const { response, error } = await serverFetch(url, { method: 'GET' }); if (error) { // 404는 템플릿 없음으로 처리 if (error.code === 'NOT_FOUND' || error.message?.includes('404')) { return { success: true, hasTemplate: false }; } return { success: false, hasTemplate: false, error: error.message || '템플릿 조회 실패' }; } if (!response) { return { success: false, hasTemplate: false, error: '템플릿 조회 실패' }; } // 404 응답도 템플릿 없음으로 처리 if (response.status === 404) { return { success: true, hasTemplate: false }; } const result = await response.json(); // template.id가 있으면 템플릿 존재 const hasTemplate = !!(result?.data?.template?.id); const attachments = result?.data?.document?.attachments || []; return { success: true, hasTemplate, attachments }; } catch (error) { if (isNextRedirectError(error)) throw error; console.error('checkInspectionTemplate error:', error); return { success: false, hasTemplate: false, error: '템플릿 조회 중 오류' }; } } // ===== 수입검사 템플릿 조회 (item_id 기반 - /v1/documents/resolve API 사용) ===== export async function getInspectionTemplate(params: { itemId?: number; itemName?: string; specification?: string; lotNo?: string; supplier?: string; inspector?: string; // 검사자 (현재 로그인 사용자) lotSize?: number; // 로트크기 (입고수량) materialNo?: string; // 자재번호 }): Promise<{ success: boolean; data?: InspectionTemplateResponse; resolveData?: DocumentResolveResponse; error?: string; __authError?: boolean; }> { // ===== 목데이터 모드 - EGI 강판 템플릿 반환 ===== if (USE_MOCK_DATA) { // 품목명/규격에 따라 다른 템플릿 반환 (추후 24종 확장) const inspectorName = params.inspector || '노원호'; const mockTemplate: InspectionTemplateResponse = { templateId: 'EGI-001', templateName: '전기 아연도금 강판', headerInfo: { productName: params.itemName || '전기 아연도금 강판 (KS D 3528, SECC) "EGI 평국판"', specification: params.specification || '1.55 * 1218 × 480', materialNo: params.materialNo || 'PE02RB', lotSize: params.lotSize || 200, supplier: params.supplier || '지오TNS (KG스틸)', lotNo: params.lotNo || '250715-02', inspectionDate: new Date().toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit' }).replace('. ', '/').replace('.', ''), inspector: inspectorName, reportDate: getTodayString(), approvers: { writer: inspectorName, reviewer: '', approver: '', }, // 결재선 데이터 approvalLines: [ { id: 1, name: '', dept: '', role: '작성', sortOrder: 1 }, { id: 2, name: '', dept: '', role: '검토', sortOrder: 2 }, { id: 3, name: '', dept: '', role: '승인', sortOrder: 3 }, { id: 4, name: '', dept: '', role: '승인', sortOrder: 4 }, ], }, inspectionItems: [ { id: 'appearance', no: 1, name: '겉모양', standard: { description: '사용상 해로운 결함이 없을 것' }, inspectionMethod: '육안검사', inspectionCycle: '', measurementType: 'okng', measurementCount: 3, }, { id: 'thickness', no: 2, name: '치수', subName: '두께', standard: { value: 1.55, options: [ { id: 't1', label: '0.8 이상 ~ 1.0 미만', tolerance: '± 0.07', isSelected: false }, { id: 't2', label: '1.0 이상 ~ 1.25 미만', tolerance: '± 0.08', isSelected: false }, { id: 't3', label: '1.25 이상 ~ 1.6 미만', tolerance: '± 0.10', isSelected: true }, { id: 't4', label: '1.6 이상 ~ 2.0 미만', tolerance: '± 0.12', isSelected: false }, ], }, inspectionMethod: 'n = 3\nc = 0', inspectionCycle: '체크검사', measurementType: 'numeric', measurementCount: 3, rowSpan: 3, }, { id: 'width', no: 2, name: '치수', subName: '너비', parentId: 'thickness', standard: { value: 1219, options: [{ id: 'w1', label: '1250 미만', tolerance: '+ 7\n- 0', isSelected: true }], }, inspectionMethod: '', inspectionCycle: '', measurementType: 'numeric', measurementCount: 3, isSubRow: true, }, { id: 'length', no: 2, name: '치수', subName: '길이', parentId: 'thickness', standard: { value: 480, options: [{ id: 'l1', label: '2000 이상 ~ 4000 미만', tolerance: '+ 15\n- 0', isSelected: true }], }, inspectionMethod: '', inspectionCycle: '', measurementType: 'numeric', measurementCount: 3, isSubRow: true, }, { id: 'tensileStrength', no: 3, name: '인장강도 (N/㎟)', standard: { description: '270 이상' }, inspectionMethod: '', inspectionCycle: '', measurementType: 'numeric', measurementCount: 1, }, { id: 'elongation', no: 4, name: '연신율 %', standard: { options: [ { id: 'e1', label: '두께 0.6 이상 ~ 1.0 미만', tolerance: '36 이상', isSelected: false }, { id: 'e2', label: '두께 1.0 이상 ~ 1.6 미만', tolerance: '37 이상', isSelected: true }, { id: 'e3', label: '두께 1.6 이상 ~ 2.3 미만', tolerance: '38 이상', isSelected: false }, ], }, inspectionMethod: '공급업체\n밀시트', inspectionCycle: '입고시', measurementType: 'numeric', measurementCount: 1, }, { id: 'zincCoating', no: 5, name: '아연의 최소 부착량 (g/㎡)', standard: { description: '편면 17 이상' }, inspectionMethod: '', inspectionCycle: '', measurementType: 'numeric', measurementCount: 2, }, ], notes: [ '※ 1.55mm의 경우 KS F 4510에 따른 MIN 1.5의 기준에 따름', '※ 두께의 경우 너비 1000 이상 ~ 1250 미만 기준에 따름', ], }; return { success: true, data: mockTemplate }; } // ===== 실제 API 호출 (/v1/documents/resolve) ===== // itemId가 있으면 실제 API로 템플릿 조회 if (!params.itemId) { return { success: false, error: '품목 ID가 필요합니다.' }; } const result = await executeServerAction({ url: buildApiUrl('/api/v1/documents/resolve', { category: 'incoming_inspection', item_id: params.itemId, }), errorMessage: '검사 템플릿 조회에 실패했습니다.', }); if (result.__authError) return { success: false, __authError: true }; if (!result.success || !result.data) return { success: false, error: result.error }; const resolveData = result.data; const template = transformResolveToTemplate(resolveData, params); return { success: true, data: template, resolveData }; } // ===== Tolerance 타입 정의 ===== interface ToleranceObject { type?: 'symmetric' | 'asymmetric' | 'range' | 'percentage' | 'limit'; value?: string | number; plus?: string | number; minus?: string | number; min?: string | number; max?: string | number; min_op?: string; max_op?: string; op?: 'lte' | 'lt' | 'gte' | 'gt'; // limit 타입용 } // ===== StandardCriteria 타입 정의 ===== interface StandardCriteriaObject { min?: number | null; min_op?: 'gt' | 'gte' | null; max?: number | null; max_op?: 'lt' | 'lte' | null; } // ===== tolerance 객체를 문자열로 변환하는 헬퍼 함수 ===== function _formatTolerance(tolerance: unknown): string { if (!tolerance) return ''; if (typeof tolerance === 'string') return tolerance; if (typeof tolerance !== 'object') return String(tolerance); const t = tolerance as ToleranceObject; // 새로운 API 형식: type 기반 처리 switch (t.type) { case 'symmetric': return t.value ? `± ${t.value}` : ''; case 'asymmetric': return `+ ${t.plus || 0}\n- ${t.minus || 0}`; case 'range': return `${t.min || ''} ~ ${t.max || ''}`; case 'percentage': return t.value ? `± ${t.value}%` : ''; } // 레거시 형식: min/max 직접 처리 (type 없는 경우) if (t.min !== undefined || t.max !== undefined) { return formatStandardCriteria(t as StandardCriteriaObject); } return ''; } // ===== standard_criteria 객체를 문자열로 변환 ===== function formatStandardCriteria(criteria: unknown): string { if (!criteria) return ''; if (typeof criteria === 'string') return criteria; if (typeof criteria !== 'object') return String(criteria); const c = criteria as StandardCriteriaObject; const parts: string[] = []; // min 조건 if (c.min !== undefined && c.min !== null) { const op = c.min_op === 'gte' ? '≥' : c.min_op === 'gt' ? '>' : '≥'; parts.push(`${op} ${c.min}`); } // max 조건 if (c.max !== undefined && c.max !== null) { const op = c.max_op === 'lte' ? '≤' : c.max_op === 'lt' ? '<' : '≤'; parts.push(`${op} ${c.max}`); } if (parts.length === 0) return ''; if (parts.length === 1) return parts[0]; // 두 조건 모두 있으면 "min 이상 ~ max 미만" 형태 return parts.join(', '); } // ===== 범위 라벨 생성 (예: "0.8 이상 ~ 1.0 미만") ===== function formatCriteriaLabel(criteria: unknown): string { if (!criteria) return ''; if (typeof criteria === 'string') return criteria; if (typeof criteria !== 'object') return ''; const c = criteria as StandardCriteriaObject; const parts: string[] = []; if (c.min !== undefined && c.min !== null) { const op = c.min_op === 'gte' ? '이상' : c.min_op === 'gt' ? '초과' : '이상'; parts.push(`${c.min} ${op}`); } if (c.max !== undefined && c.max !== null) { const op = c.max_op === 'lte' ? '이하' : c.max_op === 'lt' ? '미만' : '미만'; parts.push(`${c.max} ${op}`); } return parts.join(' ~ '); } // ===== MNG와 동일한 허용치 포맷팅 ===== function formatToleranceForDisplay(tolerance: unknown): string { if (!tolerance) return '-'; // 문자열인 경우 (레거시) if (typeof tolerance === 'string') return tolerance || '-'; if (typeof tolerance !== 'object') return String(tolerance); const t = tolerance as ToleranceObject; // type이 없으면 레거시 if (!t.type) return '-'; switch (t.type) { case 'symmetric': return t.value != null ? `±${t.value}` : '-'; case 'asymmetric': return (t.plus != null || t.minus != null) ? `+${t.plus ?? 0} / -${t.minus ?? 0}` : '-'; case 'range': return (t.min != null || t.max != null) ? `${t.min ?? ''} ~ ${t.max ?? ''}` : '-'; case 'limit': { const opSymbol: Record = { lte: '≤', lt: '<', gte: '≥', gt: '>' }; return t.value != null ? `${opSymbol[t.op as string] || '≤'}${t.value}` : '-'; } default: return '-'; } } // ===== MNG와 동일한 검사기준 포맷팅 ===== function formatStandardForDisplay(item: { standard?: string; standard_criteria?: unknown; tolerance?: unknown; }): string { const c = item.standard_criteria as StandardCriteriaObject | undefined; if (c && (c.min != null || c.max != null)) { const opLabel: Record = { gte: '이상', gt: '초과', lte: '이하', lt: '미만' }; const parts: string[] = []; if (c.min != null) parts.push(`${c.min} ${opLabel[c.min_op || 'gte']}`); if (c.max != null) parts.push(`${c.max} ${opLabel[c.max_op || 'lte']}`); return parts.join(' ~ '); } let std = item.standard || '-'; const tolStr = formatToleranceForDisplay(item.tolerance); if (tolStr !== '-') std += ` (${tolStr})`; return std; } // ===== MNG와 동일한 검사주기 포맷팅 ===== function formatFrequencyForDisplay(item: { frequency?: string; frequency_n?: number; frequency_c?: number; }): string { const parts: string[] = []; if (item.frequency_n != null && item.frequency_n !== 0) { let nc = `n=${item.frequency_n}`; // c=0도 표시 (null이 아니면 표시) if (item.frequency_c != null) nc += `, c=${item.frequency_c}`; parts.push(nc); } if (item.frequency) parts.push(item.frequency); return parts.length > 0 ? parts.join(' / ') : '-'; } // ===== DocumentResolve 응답을 InspectionTemplateResponse로 변환 ===== function transformResolveToTemplate( resolveData: DocumentResolveResponse, params: { itemName?: string; specification?: string; lotNo?: string; supplier?: string; inspector?: string; lotSize?: number; materialNo?: string } ): InspectionTemplateResponse { const { template, document, item } = resolveData; // 기존 문서 데이터를 맵으로 변환 (빠른 조회용) const savedDataMap = new Map(); if (document?.data) { document.data.forEach(d => { const key = `${d.section_id || 0}_${d.row_index}_${d.field_key}`; savedDataMap.set(key, d.field_value || ''); }); } // 품목 속성 추출 (자동 하이라이트용) const itemAttrs = item.attributes as { thickness?: number; width?: number; length?: number } | undefined; // 모든 섹션의 items를 하나로 합침 let allSectionItems: Array<{ sectionItem: typeof template.sections[0]['items'][0]; category: string; itemName: string; }> = []; for (const section of template.sections) { for (const sectionItem of section.items) { const fieldValues = sectionItem.field_values || {}; const category = safeString(sectionItem.category) || safeString(fieldValues.category); const itemName = safeString(sectionItem.item) || safeString(fieldValues.item); allSectionItems.push({ sectionItem, category, itemName }); } } // 품목에 해당 치수 속성이 없으면 검사항목에서 제거 (코일 등 너비/길이 없는 품목) if (itemAttrs) { const dimensionAttrMap: Record = { '두께': 'thickness', '너비': 'width', '길이': 'length', }; allSectionItems = allSectionItems.filter(({ itemName }) => { const attrKey = dimensionAttrMap[itemName]; if (!attrKey) return true; return itemAttrs[attrKey] != null; }); } // 그룹핑 로직: // - category가 있으면: category로 1차 그룹, item으로 2차 그룹 // - category가 없으면: 같은 item 이름끼리 1차 그룹 (2차 그룹 없음) interface GroupInfo { categoryRowSpan: number; isFirstInCategory: boolean; itemRowSpan: number; isFirstInItem: boolean; hasCategory: boolean; } const groupInfoMap = new Map(); for (let i = 0; i < allSectionItems.length; i++) { const { category, itemName } = allSectionItems[i]; const hasCategory = !!category; let categoryRowSpan = 1; let isFirstInCategory = true; let itemRowSpan = 1; let isFirstInItem = true; if (hasCategory) { // category가 있는 경우: category로 1차 그룹, item으로 2차 그룹 // 1차 그룹 (category) 계산 for (let j = 0; j < i; j++) { if (allSectionItems[j].category === category) { isFirstInCategory = false; break; } } if (isFirstInCategory) { for (let j = i + 1; j < allSectionItems.length; j++) { if (allSectionItems[j].category === category) { categoryRowSpan++; } else { break; } } } // 2차 그룹 (item) 계산 - category 내에서 같은 item 그룹핑 for (let j = 0; j < i; j++) { if (allSectionItems[j].category === category && allSectionItems[j].itemName === itemName) { isFirstInItem = false; break; } } if (isFirstInItem) { for (let j = i + 1; j < allSectionItems.length; j++) { if (allSectionItems[j].category === category && allSectionItems[j].itemName === itemName) { itemRowSpan++; } else if (allSectionItems[j].category !== category) { break; } } } } else { // category가 없는 경우: 같은 item 이름끼리 그룹핑 // 같은 item 이름이 이전에 있었는지 확인 for (let j = 0; j < i; j++) { if (!allSectionItems[j].category && allSectionItems[j].itemName === itemName) { isFirstInCategory = false; isFirstInItem = false; break; } } // 같은 item 이름이 몇 개 연속되는지 확인 (처음인 경우만) if (isFirstInCategory) { for (let j = i + 1; j < allSectionItems.length; j++) { if (!allSectionItems[j].category && allSectionItems[j].itemName === itemName) { categoryRowSpan++; itemRowSpan++; } else { break; } } } } groupInfoMap.set(i, { categoryRowSpan: isFirstInCategory ? categoryRowSpan : 0, isFirstInCategory, itemRowSpan: isFirstInItem ? itemRowSpan : 0, isFirstInItem, hasCategory, }); } // 섹션의 items를 검사항목으로 변환 const inspectionItems: InspectionTemplateResponse['inspectionItems'] = []; let noCounter = 0; let prevCategoryForNo = ''; for (let i = 0; i < allSectionItems.length; i++) { const { sectionItem, category, itemName } = allSectionItems[i]; const groupInfo = groupInfoMap.get(i)!; // NO 증가 (category가 바뀔 때만, 또는 category가 없을 때) if (category !== prevCategoryForNo || !category) { noCounter++; prevCategoryForNo = category; } const fieldValues = sectionItem.field_values || {}; const standardText = safeString(sectionItem.standard) || safeString(fieldValues.standard); const method = safeString(sectionItem.method) || safeString(fieldValues.method); const measurementType = safeString(sectionItem.measurement_type) || safeString(fieldValues.measurement_type); // MNG와 동일한 검사기준 포맷팅 const formattedStandard = formatStandardForDisplay({ standard: standardText, standard_criteria: sectionItem.standard_criteria, tolerance: sectionItem.tolerance, }); // MNG와 동일한 허용치 포맷팅 const formattedTolerance = formatToleranceForDisplay(sectionItem.tolerance); // MNG와 동일한 검사주기 포맷팅 const formattedFrequency = formatFrequencyForDisplay({ frequency: sectionItem.frequency, frequency_n: sectionItem.frequency_n, frequency_c: sectionItem.frequency_c, }); // 참조 속성 (연신율 등은 두께를 기준으로 검사기준 결정) const referenceAttribute = safeString(fieldValues.reference_attribute); // 자동 하이라이트 (품목 속성과 standard_criteria 매칭) const isHighlighted = shouldHighlightRow( itemName, sectionItem.standard_criteria, itemAttrs, referenceAttribute || undefined ); // standard_criteria가 있으면 옵션으로 변환 (하이라이트용) let toleranceOptions: Array<{ id: string; label: string; tolerance: string; isSelected: boolean }> | undefined; const criteriaLabel = formatCriteriaLabel(sectionItem.standard_criteria); if (sectionItem.standard_criteria && criteriaLabel) { toleranceOptions = [{ id: `criteria-${sectionItem.id}`, label: criteriaLabel, tolerance: formattedTolerance !== '-' ? formattedTolerance : '', isSelected: isHighlighted, }]; } // 측정 횟수: frequency_n 값 사용 (기본값 1) const measurementCount = sectionItem.frequency_n && sectionItem.frequency_n > 0 ? sectionItem.frequency_n : 1; inspectionItems.push({ id: String(sectionItem.id), no: noCounter, name: category || itemName, // 카테고리가 있으면 카테고리, 없으면 항목명 subName: category ? itemName : undefined, // 카테고리가 있을 때만 항목명이 subName standard: { description: formattedStandard, // MNG와 동일한 포맷 (검사기준 전체) value: undefined, tolerance: formattedTolerance !== '-' ? formattedTolerance : undefined, // 허용치 options: toleranceOptions, }, inspectionMethod: formatInspectionMethod(method, sectionItem), inspectionCycle: formattedFrequency, // MNG와 동일한 포맷 measurementType: mapMeasurementType(measurementType), measurementCount, // 3단계 그룹핑 정보 categoryRowSpan: groupInfo.categoryRowSpan, isFirstInCategory: groupInfo.isFirstInCategory, itemRowSpan: groupInfo.itemRowSpan, isFirstInItem: groupInfo.isFirstInItem, // 기존 호환용 rowSpan: groupInfo.categoryRowSpan, isSubRow: !groupInfo.isFirstInCategory, }); } // 품목 속성에서 규격 추출 (두께*너비*길이 형식) let specificationFromAttrs = ''; if (itemAttrs) { const parts: string[] = []; if (itemAttrs.thickness) parts.push(String(itemAttrs.thickness)); if (itemAttrs.width) parts.push(String(itemAttrs.width)); if (itemAttrs.length) parts.push(String(itemAttrs.length)); if (parts.length > 0) { specificationFromAttrs = parts.join(' × '); } } // 결재선 정보 (role → 표시명) const sortedApprovalLines = [...template.approval_lines].sort((a, b) => a.sort_order - b.sort_order); const writerLine = sortedApprovalLines.find(l => l.role === '작성'); const reviewerLine = sortedApprovalLines.find(l => l.role === '검토'); const approverLines = sortedApprovalLines.filter(l => l.role === '승인'); // notes에 비고 추가 const notes: string[] = []; if (template.footer_remark_label) { notes.push(template.footer_remark_label); } return { templateId: String(template.id), templateName: template.name, headerInfo: { productName: item.name || params.itemName || '', specification: params.specification || specificationFromAttrs || '', materialNo: params.materialNo || item.code || '', lotSize: params.lotSize || 0, supplier: params.supplier || '', lotNo: params.lotNo || '', inspectionDate: new Date().toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit' }).replace('. ', '/').replace('.', ''), inspector: params.inspector || '', reportDate: getTodayString(), approvers: { // 작성자는 현재 검사자 (로그인 사용자)로 설정 writer: params.inspector || writerLine?.role || '', reviewer: reviewerLine?.role || '', // 승인자들 (여러 명 가능) approver: approverLines.map(l => l.role).join(', ') || '', }, // 결재선 원본 데이터 (UI에서 동적으로 표시하기 위해) approvalLines: sortedApprovalLines.map(l => ({ id: l.id, name: l.name || l.role || '', dept: l.dept || '', role: l.role || l.name || '', sortOrder: l.sort_order, })), }, inspectionItems, notes, }; } // ===== 안전한 문자열 변환 ===== function safeString(value: unknown): string { if (value === null || value === undefined) return ''; if (typeof value === 'string') return value; if (typeof value === 'number') return String(value); if (typeof value === 'object') { // 객체인 경우 JSON.stringify 하지 않고 빈 문자열 반환 return ''; } return String(value); } // ===== 자동 하이라이트 판단 ===== function shouldHighlightRow( itemName: string, criteria: unknown, itemAttrs?: { thickness?: number; width?: number; length?: number }, referenceAttribute?: string // field_values.reference_attribute ): boolean { if (!criteria || !itemAttrs) return false; if (typeof criteria !== 'object') return false; const c = criteria as StandardCriteriaObject; const name = itemName.toLowerCase(); let targetValue: number | undefined; // 1. referenceAttribute가 명시되어 있으면 해당 속성 사용 (연신율 등) if (referenceAttribute) { const attrMap: Record = { 'thickness': 'thickness', 'width': 'width', 'length': 'length', }; const attrKey = attrMap[referenceAttribute]; if (attrKey && itemAttrs[attrKey] !== undefined) { targetValue = itemAttrs[attrKey]; } } // 2. referenceAttribute가 없으면 항목명에서 추론 (기존 로직) else { if (name.includes('두께') && itemAttrs.thickness !== undefined) { targetValue = itemAttrs.thickness; } else if (name.includes('너비') && itemAttrs.width !== undefined) { targetValue = itemAttrs.width; } else if (name.includes('길이') && itemAttrs.length !== undefined) { targetValue = itemAttrs.length; } } if (targetValue === undefined) return false; // 범위 체크 let match = true; if (c.min !== undefined && c.min !== null) { match = match && (c.min_op === 'gte' ? targetValue >= c.min : targetValue > c.min); } if (c.max !== undefined && c.max !== null) { match = match && (c.max_op === 'lte' ? targetValue <= c.max : targetValue < c.max); } return match; } // ===== 검사방식 포맷 ===== function formatInspectionMethod( method: string, sectionItem: { method_name?: string; frequency_n?: number; frequency_c?: number } ): string { // API에서 반환한 method_name이 있으면 우선 사용 (common_codes join 결과) if (sectionItem.method_name) { return sectionItem.method_name; } // method 코드가 있으면 fallback 매핑 사용 if (method) { const methodMap: Record = { 'visual': '육안검사', 'check': '체크검사', 'measure': '계측검사', 'mill_sheet': '공급업체 밀시트', 'millsheet': '밀시트', 'certified_agency': '공인시험기관', 'substitute_cert': '공급업체 성적서 대체', 'other': '기타', }; return methodMap[method] || method; } // frequency_n, frequency_c가 있으면 "n = X, c = Y" 형식 if (sectionItem.frequency_n !== undefined && sectionItem.frequency_n > 0) { const n = sectionItem.frequency_n; const c = sectionItem.frequency_c ?? 0; return `n = ${n}\nc = ${c}`; } return ''; } // ===== 측정유형 매핑 - MNG와 동일 ===== function mapMeasurementType(type: string): 'okng' | 'numeric' | 'both' | 'single_value' | 'substitute' { switch (type) { case 'numeric': return 'numeric'; case 'checkbox': case 'okng': return 'okng'; case 'both': return 'both'; case 'single_value': return 'single_value'; case 'substitute': return 'substitute'; default: return 'okng'; } } // ===== 수입검사 파일 업로드 (사진 첨부용) ===== export interface UploadedInspectionFile { id: number; name: string; url: string; size?: number; } export async function uploadInspectionFiles(files: File[]): Promise<{ success: boolean; data?: UploadedInspectionFile[]; error?: string; }> { if (files.length === 0) { return { success: true, data: [] }; } try { const { cookies } = await import('next/headers'); const cookieStore = await cookies(); const token = cookieStore.get('access_token')?.value; const uploadedFiles: UploadedInspectionFile[] = []; for (const file of files) { const formData = new FormData(); formData.append('file', file); const response = await fetch( buildApiUrl('/api/v1/files/upload'), { method: 'POST', headers: { 'Authorization': token ? `Bearer ${token}` : '', 'X-API-KEY': process.env.API_KEY || '', }, body: formData, } ); if (!response.ok) { console.error('[ReceivingActions] File upload error:', response.status); return { success: false, error: `파일 업로드 실패: ${file.name}` }; } const result = await response.json(); if (result.success && result.data) { uploadedFiles.push({ id: result.data.id, name: result.data.display_name || file.name, url: `/api/proxy/files/${result.data.id}/download`, size: result.data.file_size, }); } } return { success: true, data: uploadedFiles }; } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[ReceivingActions] uploadInspectionFiles error:', error); return { success: false, error: '파일 업로드 중 오류가 발생했습니다.' }; } } // ===== 수입검사 데이터 저장 (documents/upsert + receivings 상태 업데이트) ===== export async function saveInspectionData(params: { templateId: number; itemId: number; title?: string; data: Array<{ section_id?: number | null; column_id?: number | null; row_index: number; field_key: string; field_value: string | null }>; attachments?: Array<{ file_id: number; attachment_type: string; description?: string }>; receivingId: string; inspectionResult?: 'pass' | 'fail' | null; rendered_html?: string; }): Promise<{ success: boolean; error?: string; __authError?: boolean; }> { // Step 1: POST /v1/documents/upsert - 검사 데이터 저장 const docResult = await executeServerAction({ url: buildApiUrl('/api/v1/documents/upsert'), method: 'POST', body: { template_id: params.templateId, item_id: params.itemId, title: params.title || '수입검사 성적서', data: params.data, attachments: params.attachments || [], rendered_html: params.rendered_html, }, errorMessage: '검사 데이터 저장에 실패했습니다.', }); if (docResult.__authError) return { success: false, __authError: true }; if (!docResult.success) return { success: false, error: docResult.error }; // Step 2: PUT /v1/receivings/{id} - 검사 완료 후 입고대기로 상태 변경 (비필수) const today = getTodayString(); const inspectionStatus = params.inspectionResult === 'pass' ? '적' : params.inspectionResult === 'fail' ? '부적' : '-'; const inspectionResultLabel = params.inspectionResult === 'pass' ? '합격' : params.inspectionResult === 'fail' ? '불합격' : null; const recResult = await executeServerAction({ url: buildApiUrl(`/api/v1/receivings/${params.receivingId}`), method: 'PUT', body: { status: 'receiving_pending', inspection_status: inspectionStatus, inspection_date: today, inspection_result: inspectionResultLabel, }, errorMessage: '입고 상태 업데이트에 실패했습니다.', }); if (!recResult.success) { console.error('[ReceivingActions] 입고 상태 업데이트 실패 (검사 데이터는 저장됨):', recResult.error); } return { success: true }; }