- middleware CSP: *.kakao.com, *.kakaocdn.net 추가 (다음 주소찾기 차단 해결) - frame-src에 'self' 추가 - 공지 팝업 이미지 중앙정렬 ([&_img]:mx-auto) - HR 사원관리, 결재, 품목, 생산 등 다수 개선 - API 에러 핸들링 및 JSON 파싱 안정화
1921 lines
62 KiB
TypeScript
1921 lines
62 KiB
TypeScript
/**
|
||
* 입고 관리 서버 액션
|
||
*
|
||
* 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 };
|
||
} |