/** * Zod 검증 스키마 * * react-hook-form과 함께 사용하는 폼 검증 */ import { z } from 'zod'; import type { ItemType } from '@/types/item'; // ===== 공통 스키마 ===== /** * 품목 코드 검증 * 형식: {업체코드}-{품목유형}-{일련번호} * 예: KD-FG-001 * * 현재 사용하지 않음 (품목 코드 자동 생성) */ const _itemCodeSchema = z.string() .min(1, '품목 코드를 입력해주세요') .regex( /^[A-Z0-9]+-[A-Z]{2}-\d+$/, '품목 코드 형식이 올바르지 않습니다 (예: KD-FG-001)' ); /** * 품목명 검증 */ const itemNameSchema = z.preprocess( (val) => val === undefined || val === null ? "" : val, z.string().min(1, '품목명을 입력해주세요').max(200, '품목명은 200자 이내로 입력해주세요') ); /** * 품목 유형 검증 */ const itemTypeSchema = z.enum(['FG', 'PT', 'SM', 'RM', 'CS'], { message: '품목 유형을 선택해주세요', }); /** * 단위 검증 * * 현재 사용하지 않음 (materialUnitSchema로 대체) */ const _unitSchema = z.string() .min(1, '단위를 입력해주세요') .max(20, '단위는 20자 이내로 입력해주세요'); /** * 양수 검증 (가격, 수량 등) * undefined나 빈 문자열은 검증하지 않음 */ const positiveNumberSchema = z.union([ z.number().positive('0보다 큰 값을 입력해주세요'), z.string().transform((val) => parseFloat(val)).pipe(z.number().positive('0보다 큰 값을 입력해주세요')), z.undefined(), z.null(), z.literal('') ]).optional(); /** * 날짜 검증 (YYYY-MM-DD) * 빈 문자열이나 undefined는 검증하지 않음 */ const dateSchema = z.preprocess( (val) => { if (val === undefined || val === null || val === '') return undefined; return val; }, z.string() .regex(/^\d{4}-\d{2}-\d{2}$/, '날짜 형식이 올바르지 않습니다 (YYYY-MM-DD)') .optional() ); // ===== BOM 라인 스키마 ===== /** * 절곡품 전개도 상세 스키마 */ export const bendingDetailSchema = z.object({ id: z.string(), no: z.number().int().positive(), input: z.number(), elongation: z.number().default(-1), calculated: z.number(), sum: z.number(), shaded: z.boolean().default(false), aAngle: z.number().optional(), }); /** * BOM 라인 스키마 */ export const bomLineSchema = z.object({ id: z.string(), childItemCode: z.string().min(1, '하위 품목 코드를 입력해주세요'), childItemName: z.string().min(1, '하위 품목명을 입력해주세요'), quantity: z.number().positive('수량은 0보다 커야 합니다'), unit: z.string().min(1, '단위를 입력해주세요'), unitPrice: positiveNumberSchema, quantityFormula: z.string().optional(), note: z.string().max(500).optional(), // 절곡품 관련 isBending: z.boolean().optional(), bendingDiagram: z.string().url().optional(), bendingDetails: z.array(bendingDetailSchema).optional(), }); // ===== 품목 마스터 기본 스키마 ===== /** * 품목 마스터 공통 필드 */ const itemMasterBaseSchema = z.object({ // 공통 필수 필드 itemCode: z.string().optional(), // 자동생성되므로 선택 사항 itemName: itemNameSchema, itemType: itemTypeSchema, unit: z.string().max(20).optional(), // 선택 사항으로 변경 // 공통 선택 필드 specification: z.string().max(500).optional(), isActive: z.boolean().default(true), // 분류 category1: z.string().max(100).optional(), category2: z.string().max(100).optional(), category3: z.string().max(100).optional(), // 가격 정보 - 모두 선택 사항 purchasePrice: z.number().optional(), salesPrice: z.number().optional(), marginRate: z.number().min(0).max(100).optional(), processingCost: z.number().optional(), laborCost: z.number().optional(), installCost: z.number().optional(), // BOM bom: z.array(bomLineSchema).optional(), bomCategories: z.array(z.string()).optional(), // 메타데이터 safetyStock: z.number().int().nonnegative().optional(), leadTime: z.number().int().nonnegative().optional(), isVariableSize: z.boolean().optional(), // 버전 관리 currentRevision: z.number().int().nonnegative().default(0), isFinal: z.boolean().default(false), finalizedDate: dateSchema, finalizedBy: z.string().optional(), // 시스템 필드 createdAt: z.string().optional(), updatedAt: z.string().optional(), }); // ===== 제품(FG) 스키마 ===== /** * 제품 전용 필드 */ const productFieldsSchema = z.object({ productName: z.preprocess( (val) => val === undefined || val === null ? "" : val, z.string().min(1, '상품명을 입력해주세요').max(200, '상품명은 200자 이내로 입력해주세요') ), productCategory: z.enum(['SCREEN', 'STEEL']).optional(), lotAbbreviation: z.string().max(10).optional(), note: z.string().max(1000).optional(), // 인정 정보 certificationNumber: z.string().max(100).optional(), certificationStartDate: dateSchema, certificationEndDate: dateSchema, specificationFile: z.string().optional(), specificationFileName: z.string().optional(), certificationFile: z.string().optional(), certificationFileName: z.string().optional(), }); /** * 제품(FG) 전체 스키마 (refinement 없이) * 제품에는 가격 정보가 없으므로 제거 */ const productSchemaBase = itemMasterBaseSchema .omit({ purchasePrice: true, salesPrice: true, processingCost: true, laborCost: true, installCost: true, }) .merge(productFieldsSchema); /** * 제품(FG) 전체 스키마 (refinement 포함) */ export const productSchema = productSchemaBase.refine( (data) => { // 인정 정보 검증: 시작일과 종료일이 모두 있거나 모두 없어야 함 if (data.certificationStartDate && data.certificationEndDate) { return new Date(data.certificationStartDate) <= new Date(data.certificationEndDate); } return true; }, { message: '인정 유효기간 종료일은 시작일보다 이후여야 합니다', path: ['certificationEndDate'], } ); // ===== 부품(PT) 스키마 ===== /** * 부품 전용 필드 */ const partFieldsSchema = z.object({ partType: z.preprocess( (val) => val === undefined || val === null ? "" : val, z.string() .min(1, '부품 유형을 선택해주세요') .refine( (val) => ['ASSEMBLY', 'BENDING', 'PURCHASED'].includes(val), { message: '부품 유형을 선택해주세요' } ) ), partUsage: z.enum(['GUIDE_RAIL', 'BOTTOM_FINISH', 'CASE', 'DOOR', 'BRACKET', 'GENERAL']).optional(), // 조립 부품 installationType: z.string().max(50).optional(), assemblyType: z.string().max(50).optional(), sideSpecWidth: z.string().max(50).optional(), sideSpecHeight: z.string().max(50).optional(), assemblyLength: z.string().max(50).optional(), // 가이드레일 guideRailModelType: z.string().max(100).optional(), guideRailModel: z.string().max(100).optional(), // 절곡품 bendingDiagram: z.string().url().optional(), bendingDetails: z.array(bendingDetailSchema).optional(), material: z.string().max(100).optional(), length: z.string().max(50).optional(), bendingLength: z.string().max(50).optional(), // 구매 부품 electricOpenerPower: z.string().max(50).optional(), electricOpenerCapacity: z.string().max(50).optional(), motorVoltage: z.string().max(50).optional(), motorCapacity: z.string().max(50).optional(), chainSpec: z.string().max(100).optional(), // 인정 정보 (부품도 인정 가능) certificationNumber: z.string().max(100).optional(), certificationStartDate: dateSchema, certificationEndDate: dateSchema, specificationFile: z.string().optional(), specificationFileName: z.string().optional(), certificationFile: z.string().optional(), certificationFileName: z.string().optional(), }); /** * 부품(PT) 전체 스키마 (refinement 없이) * 부품은 itemName을 사용하지 않으므로 선택 사항으로 변경 */ const partSchemaBase = itemMasterBaseSchema .extend({ itemName: z.string().max(200).optional(), // 부품은 itemName 선택 사항 }) .merge(partFieldsSchema); /** * 부품(PT) 전체 스키마 (refinement 포함) */ export const partSchema = partSchemaBase .superRefine((data, ctx) => { // 1단계: 부품 유형 필수 if (!data.partType || data.partType === '') { ctx.addIssue({ code: z.ZodIssueCode.custom, message: '부품 유형을 선택해주세요', path: ['partType'], }); return; // 부품 유형이 없으면 더 이상 검증하지 않음 } // 2단계: 부품 유형이 있을 때만 품목명 필수 if (!data.category1 || data.category1 === '') { ctx.addIssue({ code: z.ZodIssueCode.custom, message: '품목명을 선택해주세요', path: ['category1'], }); } // 3단계: 조립 부품 전용 필드 if (data.partType === 'ASSEMBLY') { if (!data.installationType) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: '설치 유형을 선택해주세요', path: ['installationType'], }); } if (!data.sideSpecWidth) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: '측면 규격 (가로)를 입력해주세요', path: ['sideSpecWidth'], }); } if (!data.sideSpecHeight) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: '측면 규격 (세로)를 입력해주세요', path: ['sideSpecHeight'], }); } if (!data.assemblyLength) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: '길이를 선택해주세요', path: ['assemblyLength'], }); } } // 절곡품 전용 필드 if (data.partType === 'BENDING') { if (!data.material) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: '재질을 선택해주세요', path: ['material'], }); } if (!data.length) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: '폭 합계를 입력해주세요', path: ['length'], }); } if (!data.bendingLength) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: '모양&길이를 선택해주세요', path: ['bendingLength'], }); } } // 구매 부품 전용 필드 if (data.partType === 'PURCHASED') { if (data.category1 === 'electric_opener') { if (!data.electricOpenerPower) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: '전원을 선택해주세요', path: ['electricOpenerPower'], }); } if (!data.electricOpenerCapacity) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: '용량을 선택해주세요', path: ['electricOpenerCapacity'], }); } } if (data.category1 === 'motor' && !data.motorVoltage) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: '전압을 선택해주세요', path: ['motorVoltage'], }); } if (data.category1 === 'chain' && !data.chainSpec) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: '체인 규격을 선택해주세요', path: ['chainSpec'], }); } } }); // ===== 원자재/부자재(RM/SM) 스키마 ===== /** * 원자재/부자재 전용 필드 * * 현재 사용하지 않음 (materialSchemaBase에 직접 포함됨) */ const _materialFieldsSchema = z.object({ material: z.string().max(100).optional(), length: z.string().max(50).optional(), }); /** * 원자재/부자재 규격 필수 필드 스키마 */ const materialSpecificationSchema = z.preprocess( (val) => val === undefined || val === null ? "" : val, z.string().min(1, '규격을 입력해주세요').max(500, '최대 500자') ); /** * 원자재/부자재 단위 필수 필드 스키마 */ const materialUnitSchema = z.preprocess( (val) => val === undefined || val === null ? "" : val, z.string().min(1, '단위를 입력해주세요').max(20, '최대 20자') ); /** * 원자재/부자재 Base 스키마 (refinement 없음, 필드만 정의) * specification, unit을 필수로 정의 (z.object로 완전히 새로 정의) */ const materialSchemaBase = z.object({ // 공통 필수 필드 itemCode: z.string().optional(), itemName: itemNameSchema, itemType: itemTypeSchema, specification: materialSpecificationSchema, // 필수! unit: materialUnitSchema, // 필수! isActive: z.boolean().default(true), // 분류 category1: z.string().max(100).optional(), category2: z.string().max(100).optional(), category3: z.string().max(100).optional(), // 가격 정보 purchasePrice: z.number().optional(), salesPrice: z.number().optional(), marginRate: z.number().min(0).max(100).optional(), processingCost: z.number().optional(), laborCost: z.number().optional(), installCost: z.number().optional(), // BOM bom: z.array(bomLineSchema).optional(), bomCategories: z.array(z.string()).optional(), // 메타데이터 safetyStock: z.number().int().nonnegative().optional(), leadTime: z.number().int().nonnegative().optional(), isVariableSize: z.boolean().optional(), // 버전 관리 currentRevision: z.number().int().nonnegative().default(0), isFinal: z.boolean().default(false), finalizedDate: dateSchema, finalizedBy: z.string().optional(), // 시스템 필드 createdAt: z.string().optional(), updatedAt: z.string().optional(), // 원자재/부자재 전용 필드 material: z.string().max(100).optional(), length: z.string().max(50).optional(), }); /** * 원자재/부자재 전체 스키마 (export용) */ export const materialSchema = materialSchemaBase; // ===== 소모품(CS) 스키마 ===== /** * 소모품 Base 스키마 * specification, unit을 필수로 오버라이드 */ const consumableSchemaBase = itemMasterBaseSchema .extend({ specification: materialSpecificationSchema, // optional → 필수로 변경 unit: materialUnitSchema, // optional → 필수로 변경 }); /** * 소모품 전체 스키마 (export용) */ export const consumableSchema = consumableSchemaBase; // ===== 통합 품목 스키마 ===== /** * 품목 유형에 따른 동적 검증 * * Zod 4.x에서는 refinement가 있는 스키마를 extend할 수 없으므로, * refinement가 없는 base 스키마를 사용합니다. */ export const itemMasterSchema = z.discriminatedUnion('itemType', [ productSchemaBase.extend({ itemType: z.literal('FG') }), partSchemaBase.extend({ itemType: z.literal('PT') }), materialSchema.extend({ itemType: z.literal('SM') }), materialSchema.extend({ itemType: z.literal('RM') }), consumableSchema.extend({ itemType: z.literal('CS') }), ]); // ===== 폼 데이터 스키마 (생성/수정용) ===== /** * 품목 생성 폼 스키마 * (id, createdAt, updatedAt 제외) * * discriminatedUnion은 omit()을 지원하지 않으므로, * 각 스키마에 대해 개별적으로 omit을 적용합니다. */ // partSchemaBase를 omit한 후 itemType merge - refinement는 마지막에 적용 const partSchemaForForm = partSchemaBase .omit({ createdAt: true, updatedAt: true }) .merge(z.object({ itemType: z.literal('PT') })) .superRefine((data, ctx) => { // 1단계: 부품 유형 필수 if (!data.partType || data.partType === '') { ctx.addIssue({ code: z.ZodIssueCode.custom, message: '부품 유형을 선택해주세요', path: ['partType'], }); return; // 부품 유형이 없으면 더 이상 검증하지 않음 } // 2단계: 부품 유형이 있을 때만 품목명 필수 if (!data.category1 || data.category1 === '') { ctx.addIssue({ code: z.ZodIssueCode.custom, message: '품목명을 선택해주세요', path: ['category1'], }); return; // 품목명이 없으면 더 이상 검증하지 않음 (설치유형 등 체크 안 함) } // 3단계: 조립 부품 전용 필드 (partType과 category1이 모두 있을 때만 실행됨) if (data.partType === 'ASSEMBLY') { if (!data.installationType) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: '설치 유형을 선택해주세요', path: ['installationType'], }); } if (!data.sideSpecWidth) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: '측면 규격 (가로)를 입력해주세요', path: ['sideSpecWidth'], }); } if (!data.sideSpecHeight) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: '측면 규격 (세로)를 입력해주세요', path: ['sideSpecHeight'], }); } if (!data.assemblyLength) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: '길이를 선택해주세요', path: ['assemblyLength'], }); } } // 절곡품 전용 필드 (partType과 category1이 모두 있을 때만 실행됨) if (data.partType === 'BENDING') { // 단계별 검증: 종류(category2) → 재질(material) → 폭 합계 → 모양&길이 if (!data.category2 || data.category2 === '') { ctx.addIssue({ code: z.ZodIssueCode.custom, message: '종류를 선택해주세요', path: ['category2'], }); return; // 종류가 없으면 재질, 폭 합계, 모양&길이 체크 안 함 } if (!data.material) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: '재질을 선택해주세요', path: ['material'], }); return; // 재질이 없으면 폭 합계, 모양&길이 체크 안 함 } if (!data.length) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: '폭 합계를 입력해주세요', path: ['length'], }); return; // 폭 합계가 없으면 모양&길이 체크 안 함 } if (!data.bendingLength) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: '모양&길이를 선택해주세요', path: ['bendingLength'], }); } } // 구매 부품 전용 필드 (partType과 category1이 모두 있을 때만 실행됨) if (data.partType === 'PURCHASED') { if (data.category1 === 'electric_opener') { if (!data.electricOpenerPower) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: '전원을 선택해주세요', path: ['electricOpenerPower'], }); } if (!data.electricOpenerCapacity) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: '용량을 선택해주세요', path: ['electricOpenerCapacity'], }); } } if (data.category1 === 'motor' && !data.motorVoltage) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: '전압을 선택해주세요', path: ['motorVoltage'], }); } if (data.category1 === 'chain' && !data.chainSpec) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: '체인 규격을 선택해주세요', path: ['chainSpec'], }); } } }); export const createItemFormSchema = z.discriminatedUnion('itemType', [ productSchema.omit({ createdAt: true, updatedAt: true }).extend({ itemType: z.literal('FG') }), partSchemaForForm, // itemType이 이미 merge되어 있음 materialSchemaBase.omit({ createdAt: true, updatedAt: true }).extend({ itemType: z.literal('SM') }), materialSchemaBase.omit({ createdAt: true, updatedAt: true }).extend({ itemType: z.literal('RM') }), consumableSchemaBase.omit({ createdAt: true, updatedAt: true }).extend({ itemType: z.literal('CS') }), ]); /** * 품목 수정 폼 스키마 * (모든 필드 선택적) * * discriminatedUnion은 partial()도 지원하지 않으므로, * 각 스키마에 대해 개별적으로 처리합니다. */ export const updateItemFormSchema = z.discriminatedUnion('itemType', [ productSchemaBase.omit({ createdAt: true, updatedAt: true }).partial().extend({ itemType: z.literal('FG') }), partSchemaBase.omit({ createdAt: true, updatedAt: true }).partial().extend({ itemType: z.literal('PT') }), materialSchemaBase.omit({ createdAt: true, updatedAt: true }).partial().extend({ itemType: z.literal('SM') }), materialSchemaBase.omit({ createdAt: true, updatedAt: true }).partial().extend({ itemType: z.literal('RM') }), consumableSchemaBase.omit({ createdAt: true, updatedAt: true }).partial().extend({ itemType: z.literal('CS') }), ]); // ===== 필터 스키마 ===== /** * 품목 목록 필터 스키마 */ export const itemFilterSchema = z.object({ itemType: itemTypeSchema.optional(), search: z.string().optional(), category1: z.string().optional(), category2: z.string().optional(), category3: z.string().optional(), isActive: z.boolean().optional(), }); // ===== 타입 추출 ===== export type ItemMasterFormData = z.infer; export type CreateItemFormData = z.infer; export type UpdateItemFormData = z.infer; export type ItemFilterFormData = z.infer; export type BOMLineFormData = z.infer; export type BendingDetailFormData = z.infer; // ===== 유틸리티 함수 ===== /** * 품목 유형에 따른 스키마 선택 */ export function getSchemaByItemType(itemType: ItemType) { switch (itemType) { case 'FG': return productSchema; case 'PT': return partSchema; case 'SM': case 'RM': return materialSchema; case 'CS': return consumableSchema; default: return itemMasterBaseSchema; } } /** * 에러 메시지 한글화 */ export function formatZodError(error: z.ZodError): Record { const formatted: Record = {}; error.issues.forEach((err) => { const path = err.path.join('.'); formatted[path] = err.message; }); return formatted; }