Files
sam-react-prod/src/components/material/ReceivingManagement/actions.ts
유병철 ea6ca335f1 feat: CSP 다음/카카오 도메인 허용 + 입고 성적서 파일 백엔드 연동 + 팝업 이미지 중앙정렬
- middleware CSP: *.kakao.com, *.kakaocdn.net 추가 (다음 주소찾기 차단 해결)
- frame-src에 'self' 추가
- 공지 팝업 이미지 중앙정렬 ([&_img]:mx-auto)
- HR 사원관리, 결재, 품목, 생산 등 다수 개선
- API 에러 핸들링 및 JSON 파싱 안정화
2026-03-11 22:32:58 +09:00

1921 lines
62 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 입고 관리 서버 액션
*
* 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<string, ReceivingDetail> = {
'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<string, unknown>;
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<string, string> = {
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<ReceivingDetail>
): Record<string, unknown> {
const result: Record<string, unknown> = {};
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<string, unknown> {
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<ReceivingApiData, ReceivingItem>({
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<ReceivingDetail>
): 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<ReceivingDetail>
): 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<Record<string, string>> }
const result = await executeServerAction<ItemApiData, ItemOption[]>({
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<Record<string, string>> }
const result = await executeServerAction<SupplierApiData, SupplierOption[]>({
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<string, unknown>;
/** 레거시 필드 (하위 호환) */
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<DocumentResolveResponse>({
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<string, string> = { 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<string, string> = { 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<string, string>();
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<string, 'thickness' | 'width' | 'length'> = {
'두께': '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<number, GroupInfo>();
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<string, keyof typeof itemAttrs> = {
'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<string, string> = {
'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 };
}