fix(WEB): 견적 개소 입력 개선 및 BOM 변환 안정화

- 층/부호 필수 검증 제거, 빈값 시 "-" 대체
- DevFill 제품 1개 고정 + 수량 1 고정 (모델별 인증 반영)
- note에서 "-" 값 필터링, formula_source 필드 추가
- FG 조회 시 has_bom 필터 제거
This commit is contained in:
2026-02-21 01:06:48 +09:00
parent 463da04038
commit 0784b2a40e
5 changed files with 88 additions and 63 deletions

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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,
};

View File

@@ -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',
}),

View File

@@ -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}`,
};
});
}