fix(WEB): 견적 개소 입력 개선 및 BOM 변환 안정화
- 층/부호 필수 검증 제거, 빈값 시 "-" 대체 - DevFill 제품 1개 고정 + 수량 1 고정 (모델별 인증 반영) - note에서 "-" 값 필터링, formula_source 필드 추가 - FG 조회 시 has_bom 필터 제거
This commit is contained in:
@@ -43,13 +43,16 @@ const WRITERS = ['드미트리', '김철수', '이영희', '박지민', '최서
|
||||
export function generateQuoteFormItem(
|
||||
index: number,
|
||||
products?: Array<{ code: string; name: string; category?: string }>,
|
||||
category?: string
|
||||
category?: string,
|
||||
fixedProductCode?: string
|
||||
): QuoteFormItem {
|
||||
const selectedCategory = category || randomPick(PRODUCT_CATEGORIES);
|
||||
|
||||
// 카테고리에 맞는 제품 필터링
|
||||
let productCode = '';
|
||||
if (products && products.length > 0) {
|
||||
if (fixedProductCode) {
|
||||
productCode = fixedProductCode;
|
||||
} else if (products && products.length > 0) {
|
||||
const categoryProducts = products.filter(p =>
|
||||
p.category?.toUpperCase() === selectedCategory || !p.category
|
||||
);
|
||||
@@ -70,7 +73,7 @@ export function generateQuoteFormItem(
|
||||
guideRailType: randomPick(GUIDE_RAIL_TYPES),
|
||||
motorPower: randomPick(MOTOR_POWERS),
|
||||
controller: randomPick(CONTROLLERS),
|
||||
quantity: randomInt(1, 10),
|
||||
quantity: 1,
|
||||
wingSize: '50',
|
||||
inspectionFee: 50000,
|
||||
};
|
||||
@@ -104,11 +107,22 @@ export function generateQuoteData(options: GenerateQuoteDataOptions = {}): Quote
|
||||
// 품목 수 결정
|
||||
const count = itemCount ?? randomInt(1, 5);
|
||||
|
||||
// 품목 생성 (동일 카테고리 사용)
|
||||
// 제품 1개 고정 선택 (모델별 인증이라 섞을 수 없음)
|
||||
const selectedCategory = category || randomPick(PRODUCT_CATEGORIES);
|
||||
let fixedProductCode = '';
|
||||
if (products && products.length > 0) {
|
||||
const categoryProducts = products.filter(p =>
|
||||
p.category?.toUpperCase() === selectedCategory || !p.category
|
||||
);
|
||||
if (categoryProducts.length > 0) {
|
||||
fixedProductCode = randomPick(categoryProducts).code;
|
||||
}
|
||||
}
|
||||
|
||||
// 품목 생성 (동일 제품, 수량 1)
|
||||
const items: QuoteFormItem[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
items.push(generateQuoteFormItem(i, products, selectedCategory));
|
||||
items.push(generateQuoteFormItem(i, products, selectedCategory, fixedProductCode));
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -134,10 +134,6 @@ export function LocationListPanel({
|
||||
// 개소 추가 (BOM 계산 성공 시에만 폼 초기화)
|
||||
const handleAdd = useCallback(async () => {
|
||||
// 유효성 검사
|
||||
if (!formData.floor || !formData.code) {
|
||||
toast.error("층과 부호를 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
if (!formData.openWidth || !formData.openHeight) {
|
||||
toast.error("가로와 세로를 입력해주세요.");
|
||||
return;
|
||||
@@ -150,8 +146,8 @@ export function LocationListPanel({
|
||||
const product = finishedGoods.find((fg) => fg.item_code === formData.productCode);
|
||||
|
||||
const newLocation: Omit<LocationItem, "id"> = {
|
||||
floor: formData.floor,
|
||||
code: formData.code,
|
||||
floor: formData.floor || "-",
|
||||
code: formData.code || "-",
|
||||
openWidth: parseFloat(formData.openWidth) || 0,
|
||||
openHeight: parseFloat(formData.openHeight) || 0,
|
||||
productCode: formData.productCode,
|
||||
|
||||
@@ -221,47 +221,49 @@ export function QuoteRegistration({
|
||||
// DevFill (개발/테스트용 자동 채우기)
|
||||
// ---------------------------------------------------------------------------
|
||||
useDevFill("quoteV2", useCallback(() => {
|
||||
// BOM이 있는 제품만 필터링
|
||||
const productsWithBom = finishedGoods.filter((fg) => fg.has_bom === true || (fg.bom && Array.isArray(fg.bom) && fg.bom.length > 0));
|
||||
// 제품 1개 고정 선택 (모델별 인증이라 섞을 수 없음)
|
||||
const fixedProduct = finishedGoods.length > 0
|
||||
? finishedGoods[Math.floor(Math.random() * finishedGoods.length)]
|
||||
: null;
|
||||
|
||||
// 랜덤 개소 생성 함수
|
||||
const createRandomLocation = (index: number): LocationItem => {
|
||||
const floors = ["B2", "B1", "1F", "2F", "3F", "4F", "5F", "R"];
|
||||
const codePrefix = ["SD", "FSS", "FD", "SS", "DS"];
|
||||
const guideRailTypes = ["wall", "floor", "mixed"];
|
||||
const motorPowers = ["single", "three"];
|
||||
const controllers = ["basic", "smart", "premium"];
|
||||
// 층 순서 (정렬된 상태로 순차 할당)
|
||||
const sortedFloors = ["B2", "B1", "1F", "2F", "3F", "4F", "5F", "R"];
|
||||
// 부호 접두사 1개 고정
|
||||
const codePrefixes = ["SD", "FSS", "FD", "SS", "DS"];
|
||||
const fixedPrefix = codePrefixes[Math.floor(Math.random() * codePrefixes.length)];
|
||||
|
||||
const randomFloor = floors[Math.floor(Math.random() * floors.length)];
|
||||
const randomPrefix = codePrefix[Math.floor(Math.random() * codePrefix.length)];
|
||||
const randomWidth = (Math.floor(Math.random() * 40) + 20) * 100; // 2000~6000 (100단위)
|
||||
const randomHeight = (Math.floor(Math.random() * 30) + 20) * 100; // 2000~5000 (100단위)
|
||||
// BOM이 있는 제품 중에서 랜덤 선택 (없으면 전체에서 선택)
|
||||
const productPool = productsWithBom.length > 0 ? productsWithBom : finishedGoods;
|
||||
const randomProduct = productPool[Math.floor(Math.random() * productPool.length)];
|
||||
const guideRailTypes = ["wall", "floor", "mixed"];
|
||||
const motorPowers = ["single", "three"];
|
||||
const controllers = ["basic", "smart", "premium"];
|
||||
|
||||
return {
|
||||
id: `loc-${Date.now()}-${index}`,
|
||||
floor: randomFloor,
|
||||
code: `${randomPrefix}-${String(index + 1).padStart(2, "0")}`,
|
||||
// 1~5개 랜덤 개소 생성
|
||||
const locationCount = Math.floor(Math.random() * 5) + 1;
|
||||
|
||||
// 층을 순차 할당할 시작 인덱스 (랜덤 시작점, 순서대로 올라감)
|
||||
const maxStartIdx = Math.max(0, sortedFloors.length - locationCount);
|
||||
const floorStartIdx = Math.floor(Math.random() * (maxStartIdx + 1));
|
||||
|
||||
const testLocations: LocationItem[] = [];
|
||||
for (let i = 0; i < locationCount; i++) {
|
||||
const floorIdx = Math.min(floorStartIdx + i, sortedFloors.length - 1);
|
||||
const randomWidth = (Math.floor(Math.random() * 40) + 20) * 100;
|
||||
const randomHeight = (Math.floor(Math.random() * 30) + 20) * 100;
|
||||
|
||||
testLocations.push({
|
||||
id: `loc-${Date.now()}-${i}`,
|
||||
floor: sortedFloors[floorIdx],
|
||||
code: `${fixedPrefix}-${String(i + 1).padStart(2, "0")}`,
|
||||
openWidth: randomWidth,
|
||||
openHeight: randomHeight,
|
||||
productCode: randomProduct?.item_code || "FG-SCR-001",
|
||||
productName: randomProduct?.item_name || "방화 스크린 셔터 (소형)",
|
||||
quantity: Math.floor(Math.random() * 3) + 1, // 1~3
|
||||
productCode: fixedProduct?.item_code || "FG-SCR-001",
|
||||
productName: fixedProduct?.item_name || "방화 스크린 셔터 (소형)",
|
||||
quantity: 1,
|
||||
guideRailType: guideRailTypes[Math.floor(Math.random() * guideRailTypes.length)],
|
||||
motorPower: motorPowers[Math.floor(Math.random() * motorPowers.length)],
|
||||
controller: controllers[Math.floor(Math.random() * controllers.length)],
|
||||
wingSize: [50, 60, 70][Math.floor(Math.random() * 3)],
|
||||
inspectionFee: [50000, 60000, 70000][Math.floor(Math.random() * 3)],
|
||||
};
|
||||
};
|
||||
|
||||
// 1~5개 랜덤 개소 생성
|
||||
const locationCount = Math.floor(Math.random() * 5) + 1;
|
||||
const testLocations: LocationItem[] = [];
|
||||
for (let i = 0; i < locationCount; i++) {
|
||||
testLocations.push(createRandomLocation(i));
|
||||
});
|
||||
}
|
||||
|
||||
// 로그인 사용자 정보 가져오기
|
||||
@@ -511,29 +513,36 @@ export function QuoteRegistration({
|
||||
const source = formData.locations.find((loc) => loc.id === locationId);
|
||||
if (!source) return;
|
||||
|
||||
// 부호에서 접두어와 번호 분리 (예: "DS-01" → prefix="DS-", num=1)
|
||||
const codeMatch = source.code.match(/^(.*?)(\d+)$/);
|
||||
let newCode = source.code + "-copy";
|
||||
// 층/부호가 없거나 "-"이면 그대로 유지
|
||||
let newFloor = source.floor || "-";
|
||||
let newCode = source.code || "-";
|
||||
|
||||
if (codeMatch) {
|
||||
const prefix = codeMatch[1]; // "DS-"
|
||||
const numLength = codeMatch[2].length; // 2 (자릿수 보존)
|
||||
if (newCode !== "-") {
|
||||
// 부호에서 접두어와 번호 분리 (예: "DS-01" → prefix="DS-", num=1)
|
||||
const codeMatch = source.code.match(/^(.*?)(\d+)$/);
|
||||
if (codeMatch) {
|
||||
const prefix = codeMatch[1]; // "DS-"
|
||||
const numLength = codeMatch[2].length; // 2 (자릿수 보존)
|
||||
|
||||
// 같은 접두어를 가진 부호 중 최대 번호 찾기
|
||||
let maxNum = 0;
|
||||
formData.locations.forEach((loc) => {
|
||||
const m = loc.code.match(new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\d+)$`));
|
||||
if (m) {
|
||||
maxNum = Math.max(maxNum, parseInt(m[1], 10));
|
||||
}
|
||||
});
|
||||
// 같은 접두어를 가진 부호 중 최대 번호 찾기
|
||||
let maxNum = 0;
|
||||
formData.locations.forEach((loc) => {
|
||||
const m = loc.code.match(new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\d+)$`));
|
||||
if (m) {
|
||||
maxNum = Math.max(maxNum, parseInt(m[1], 10));
|
||||
}
|
||||
});
|
||||
|
||||
newCode = prefix + String(maxNum + 1).padStart(numLength, "0");
|
||||
newCode = prefix + String(maxNum + 1).padStart(numLength, "0");
|
||||
} else {
|
||||
newCode = source.code + "-copy";
|
||||
}
|
||||
}
|
||||
|
||||
const clonedLocation: LocationItem = {
|
||||
...source,
|
||||
id: `loc-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
floor: newFloor,
|
||||
code: newCode,
|
||||
};
|
||||
|
||||
|
||||
@@ -309,7 +309,6 @@ export async function getFinishedGoods(category?: string): Promise<{
|
||||
const result = await executeServerAction<FGApiResponse | Record<string, unknown>[]>({
|
||||
url: buildApiUrl('/api/v1/items', {
|
||||
item_type: 'FG',
|
||||
has_bom: '1',
|
||||
item_category: category,
|
||||
size: '5000',
|
||||
}),
|
||||
|
||||
@@ -766,6 +766,7 @@ export function transformV2ToApi(
|
||||
total_price: number;
|
||||
sort_order: number;
|
||||
note: string | null;
|
||||
formula_source?: string;
|
||||
item_index?: number;
|
||||
finished_goods_code?: string;
|
||||
formula_category?: string;
|
||||
@@ -796,7 +797,8 @@ export function transformV2ToApi(
|
||||
unit_price: bomItem.unit_price,
|
||||
total_price: bomItem.unit_price * calcQty,
|
||||
sort_order: sortOrder++,
|
||||
note: `${loc?.floor || ''} ${loc?.code || ''}`.trim() || null,
|
||||
note: [loc?.floor, loc?.code].filter(v => v && v !== '-').join(' ') || null,
|
||||
formula_source: `product_${locIndex}`,
|
||||
item_index: locIndex,
|
||||
finished_goods_code: bomResult.finished_goods.code,
|
||||
formula_category: bomItem.process_group || undefined,
|
||||
@@ -827,7 +829,8 @@ export function transformV2ToApi(
|
||||
unit_price: bomItem.unit_price,
|
||||
total_price: bomItem.unit_price * calcQty,
|
||||
sort_order: sortOrder++,
|
||||
note: `${loc.floor || ''} ${loc.code || ''}`.trim() || null,
|
||||
note: [loc.floor, loc.code].filter(v => v && v !== '-').join(' ') || null,
|
||||
formula_source: `product_${locIndex}`,
|
||||
item_index: locIndex,
|
||||
finished_goods_code: loc.bomResult!.finished_goods.code,
|
||||
formula_category: bomItem.process_group || undefined,
|
||||
@@ -850,7 +853,8 @@ export function transformV2ToApi(
|
||||
unit_price: loc.unitPrice || loc.inspectionFee || 0,
|
||||
total_price: loc.totalPrice || (loc.unitPrice || loc.inspectionFee || 0) * loc.quantity,
|
||||
sort_order: index + 1,
|
||||
note: `${loc.floor} ${loc.code}`.trim() || null,
|
||||
note: [loc.floor, loc.code].filter(v => v && v !== '-').join(' ') || null,
|
||||
formula_source: `product_${index}`,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1027,6 +1031,7 @@ export function transformFormDataToApi(formData: QuoteFormData): Record<string,
|
||||
total_price: number;
|
||||
sort_order: number;
|
||||
note: string | null;
|
||||
formula_source?: string;
|
||||
item_index?: number;
|
||||
finished_goods_code?: string;
|
||||
formula_category?: string;
|
||||
@@ -1058,7 +1063,8 @@ export function transformFormDataToApi(formData: QuoteFormData): Record<string,
|
||||
unit_price: bomItem.unit_price,
|
||||
total_price: totalPrice,
|
||||
sort_order: sortOrder++,
|
||||
note: `${formItem?.floor || ''} ${formItem?.code || ''}`.trim() || null,
|
||||
note: [formItem?.floor, formItem?.code].filter(v => v && v !== '-').join(' ') || null,
|
||||
formula_source: `product_${calcItem.index}`,
|
||||
item_index: calcItem.index,
|
||||
finished_goods_code: calcItem.result.finished_goods.code,
|
||||
formula_category: bomItem.process_group || undefined,
|
||||
@@ -1084,7 +1090,8 @@ export function transformFormDataToApi(formData: QuoteFormData): Record<string,
|
||||
unit_price: unitPrice,
|
||||
total_price: supplyAmount,
|
||||
sort_order: index + 1,
|
||||
note: `${item.floor || ''} ${item.code || ''}`.trim() || null,
|
||||
note: [item.floor, item.code].filter(v => v && v !== '-').join(' ') || null,
|
||||
formula_source: `product_${index}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user