Files
sam-react-prod/src/lib/utils/validation/item-schemas.ts
유병철 b8dfa3d887 feat(WEB): CEO 대시보드 캘린더 강화 및 validation 모듈 분리
- CalendarSection 일정 CRUD 기능 확장 (상세 모달 연동)
- ScheduleDetailModal 개선
- CEO 대시보드 섹션별 API 키 통일
- validation.ts → validation/ 모듈 분리 (item-schemas, utils)
- formatters.ts 확장
- date.ts 유틸 추가
- SignupPage/EmployeeForm/AddCompanyDialog 등 소규모 개선
- PaymentHistory/PopupManagement utils 정리

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 13:01:41 +09:00

414 lines
12 KiB
TypeScript

/**
* 품목유형별 Zod 검증 스키마
*
* FG(제품), PT(부품), SM/RM(원자재/부자재), CS(소모품) 스키마 정의
*/
import { z } from 'zod';
import {
itemNameSchema,
itemTypeSchema,
dateSchema,
positiveNumberSchema,
bomLineSchema,
bendingDetailSchema,
} from './common';
// ===== 품목 마스터 기본 스키마 =====
/**
* 품목 마스터 공통 필드
*/
export 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 없이)
* 제품에는 가격 정보가 없으므로 제거
*/
export 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을 사용하지 않으므로 선택 사항으로 변경
*/
export 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로 완전히 새로 정의)
*/
export 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을 필수로 오버라이드
*/
export 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') }),
]);