Merge remote-tracking branch 'upstream/main'

This commit is contained in:
kjs 2026-01-06 15:34:23 +09:00
commit a94d3f78de
10 changed files with 222 additions and 68 deletions

View File

@ -901,13 +901,23 @@ export async function addTableData(
} }
// 데이터 추가 // 데이터 추가
await tableManagementService.addTableData(tableName, data); const result = await tableManagementService.addTableData(tableName, data);
logger.info(`테이블 데이터 추가 완료: ${tableName}`); logger.info(`테이블 데이터 추가 완료: ${tableName}`);
const response: ApiResponse<null> = { // 무시된 컬럼이 있으면 경고 정보 포함
const response: ApiResponse<{
skippedColumns?: string[];
savedColumns?: string[];
}> = {
success: true, success: true,
message: "테이블 데이터를 성공적으로 추가했습니다.", message: result.skippedColumns.length > 0
? `테이블 데이터를 추가했습니다. (무시된 컬럼 ${result.skippedColumns.length}개: ${result.skippedColumns.join(", ")})`
: "테이블 데이터를 성공적으로 추가했습니다.",
data: {
skippedColumns: result.skippedColumns.length > 0 ? result.skippedColumns : undefined,
savedColumns: result.savedColumns,
},
}; };
res.status(201).json(response); res.status(201).json(response);

View File

@ -2261,11 +2261,12 @@ export class TableManagementService {
/** /**
* *
* @returns ()
*/ */
async addTableData( async addTableData(
tableName: string, tableName: string,
data: Record<string, any> data: Record<string, any>
): Promise<void> { ): Promise<{ skippedColumns: string[]; savedColumns: string[] }> {
try { try {
logger.info(`=== 테이블 데이터 추가 시작: ${tableName} ===`); logger.info(`=== 테이블 데이터 추가 시작: ${tableName} ===`);
logger.info(`추가할 데이터:`, data); logger.info(`추가할 데이터:`, data);
@ -2296,10 +2297,41 @@ export class TableManagementService {
logger.info(`created_date 자동 추가: ${data.created_date}`); logger.info(`created_date 자동 추가: ${data.created_date}`);
} }
// 컬럼명과 값을 분리하고 타입에 맞게 변환 // 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼은 무시)
const columns = Object.keys(data); const skippedColumns: string[] = [];
const values = Object.values(data).map((value, index) => { const existingColumns = Object.keys(data).filter((col) => {
const columnName = columns[index]; const exists = columnTypeMap.has(col);
if (!exists) {
skippedColumns.push(col);
}
return exists;
});
// 무시된 컬럼이 있으면 경고 로그 출력
if (skippedColumns.length > 0) {
logger.warn(
`⚠️ [${tableName}] 테이블에 존재하지 않는 컬럼 ${skippedColumns.length}개 무시됨: ${skippedColumns.join(", ")}`
);
logger.warn(
`⚠️ [${tableName}] 무시된 컬럼 상세:`,
skippedColumns.map((col) => ({ column: col, value: data[col] }))
);
}
if (existingColumns.length === 0) {
throw new Error(
`저장할 유효한 컬럼이 없습니다. 테이블: ${tableName}, 전달된 컬럼: ${Object.keys(data).join(", ")}`
);
}
logger.info(
`✅ [${tableName}] 저장될 컬럼 ${existingColumns.length}개: ${existingColumns.join(", ")}`
);
// 컬럼명과 값을 분리하고 타입에 맞게 변환 (존재하는 컬럼만)
const columns = existingColumns;
const values = columns.map((columnName) => {
const value = data[columnName];
const dataType = columnTypeMap.get(columnName) || "text"; const dataType = columnTypeMap.get(columnName) || "text";
const convertedValue = this.convertValueForPostgreSQL(value, dataType); const convertedValue = this.convertValueForPostgreSQL(value, dataType);
logger.info( logger.info(
@ -2355,6 +2387,12 @@ export class TableManagementService {
await query(insertQuery, values); await query(insertQuery, values);
logger.info(`테이블 데이터 추가 완료: ${tableName}`); logger.info(`테이블 데이터 추가 완료: ${tableName}`);
// 무시된 컬럼과 저장된 컬럼 정보 반환
return {
skippedColumns,
savedColumns: existingColumns,
};
} catch (error) { } catch (error) {
logger.error(`테이블 데이터 추가 오류: ${tableName}`, error); logger.error(`테이블 데이터 추가 오류: ${tableName}`, error);
throw error; throw error;

View File

@ -107,10 +107,10 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
const dbTableName = config.dbTableName || "vehicles"; const dbTableName = config.dbTableName || "vehicles";
const dbKeyField = config.dbKeyField || "user_id"; const dbKeyField = config.dbKeyField || "user_id";
// 기본 옵션 (포항/광양) // 기본 옵션 (포항/광양) - 한글로 저장
const DEFAULT_OPTIONS: LocationOption[] = [ const DEFAULT_OPTIONS: LocationOption[] = [
{ value: "pohang", label: "포항" }, { value: "포항", label: "포항" },
{ value: "gwangyang", label: "광양" }, { value: "광양", label: "광양" },
]; ];
// 상태 // 상태

View File

@ -26,9 +26,9 @@ export const LocationSwapSelectorDefinition = createComponentDefinition({
labelField: "location_name", // 표시 필드 labelField: "location_name", // 표시 필드
codeCategory: "", // 코드 관리 카테고리 (type이 "code"일 때) codeCategory: "", // 코드 관리 카테고리 (type이 "code"일 때)
staticOptions: [ staticOptions: [
{ value: "pohang", label: "포항" }, { value: "포항", label: "포항" },
{ value: "gwangyang", label: "광양" }, { value: "광양", label: "광양" },
], // 정적 옵션 (type이 "static"일 때) ], // 정적 옵션 (type이 "static"일 때) - 한글로 저장
}, },
// 필드 매핑 // 필드 매핑
departureField: "departure", // 출발지 저장 필드 departureField: "departure", // 출발지 저장 필드

View File

@ -605,7 +605,7 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
location_type: context?.locationType || "선반", location_type: context?.locationType || "선반",
status: context?.status || "사용", status: context?.status || "사용",
// 추가 필드 (테이블 컬럼명과 동일) // 추가 필드 (테이블 컬럼명과 동일)
warehouse_id: context?.warehouseCode, warehouse_code: context?.warehouseCode,
warehouse_name: context?.warehouseName, warehouse_name: context?.warehouseName,
floor: context?.floor, floor: context?.floor,
zone: context?.zone, zone: context?.zone,
@ -623,6 +623,18 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
setPreviewData(locations); setPreviewData(locations);
setIsPreviewGenerated(true); setIsPreviewGenerated(true);
console.log("🏗️ [RackStructure] 생성된 위치 데이터:", {
locationsCount: locations.length,
firstLocation: locations[0],
context: {
warehouseCode: context?.warehouseCode,
warehouseName: context?.warehouseName,
floor: context?.floor,
zone: context?.zone,
},
});
onChange?.(locations); onChange?.(locations);
}, [conditions, context, generateLocationCode, onChange, missingFields, hasRowOverlap, duplicateErrors, existingLocations, rowOverlapErrors]); }, [conditions, context, generateLocationCode, onChange, missingFields, hasRowOverlap, duplicateErrors, existingLocations, rowOverlapErrors]);

View File

@ -27,7 +27,7 @@ export interface GeneratedLocation {
location_type?: string; // 위치 유형 location_type?: string; // 위치 유형
status?: string; // 사용 여부 status?: string; // 사용 여부
// 추가 필드 (상위 폼에서 매핑된 값) // 추가 필드 (상위 폼에서 매핑된 값)
warehouse_id?: string; // 창고 ID/코드 warehouse_code?: string; // 창고 코드 (DB 컬럼명과 동일)
warehouse_name?: string; // 창고명 warehouse_name?: string; // 창고명
floor?: string; // 층 floor?: string; // 층
zone?: string; // 구역 zone?: string; // 구역

View File

@ -432,10 +432,25 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
const groups = componentConfig.fieldGroups || []; const groups = componentConfig.fieldGroups || [];
const additionalFields = componentConfig.additionalFields || []; const additionalFields = componentConfig.additionalFields || [];
itemsList.forEach((item) => { itemsList.forEach((item, itemIndex) => {
// 각 그룹의 엔트리 배열들을 준비 // 각 그룹의 엔트리 배열들을 준비
const groupEntriesArrays: GroupEntry[][] = groups.map((group) => item.fieldGroups[group.id] || []); const groupEntriesArrays: GroupEntry[][] = groups.map((group) => item.fieldGroups[group.id] || []);
// 🆕 모든 그룹이 비어있는지 확인
const allGroupsEmpty = groupEntriesArrays.every((arr) => arr.length === 0);
if (allGroupsEmpty) {
// 🆕 모든 그룹이 비어있으면 품목 기본 정보만으로 레코드 생성
// (거래처 품번/품명, 기간별 단가 없이도 저장 가능)
console.log("📝 [generateCartesianProduct] 모든 그룹이 비어있음 - 품목 기본 레코드 생성", {
itemIndex,
itemId: item.id,
});
// 빈 객체를 추가하면 parentKeys와 합쳐져서 기본 레코드가 됨
allRecords.push({});
return;
}
// Cartesian Product 재귀 함수 // Cartesian Product 재귀 함수
const cartesian = (arrays: GroupEntry[][], currentIndex: number, currentCombination: Record<string, any>) => { const cartesian = (arrays: GroupEntry[][], currentIndex: number, currentCombination: Record<string, any>) => {
if (currentIndex === arrays.length) { if (currentIndex === arrays.length) {
@ -446,7 +461,8 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
const currentGroupEntries = arrays[currentIndex]; const currentGroupEntries = arrays[currentIndex];
if (currentGroupEntries.length === 0) { if (currentGroupEntries.length === 0) {
// 현재 그룹에 데이터가 없으면 빈 조합으로 다음 그룹 진행 // 🆕 현재 그룹에 데이터가 없으면 빈 조합으로 다음 그룹 진행
// (그룹이 비어있어도 다른 그룹의 데이터로 레코드 생성)
cartesian(arrays, currentIndex + 1, currentCombination); cartesian(arrays, currentIndex + 1, currentCombination);
return; return;
} }

View File

@ -2248,9 +2248,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 🆕 편집 모드 진입 placeholder (실제 구현은 visibleColumns 정의 후) // 🆕 편집 모드 진입 placeholder (실제 구현은 visibleColumns 정의 후)
const startEditingRef = useRef<() => void>(() => {}); const startEditingRef = useRef<() => void>(() => {});
// 🆕 각 컬럼의 고유값 목록 계산 // 🆕 각 컬럼의 고유값 목록 계산 (라벨 포함)
const columnUniqueValues = useMemo(() => { const columnUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {}; const result: Record<string, Array<{ value: string; label: string }>> = {};
if (data.length === 0) return result; if (data.length === 0) return result;
@ -2258,16 +2258,34 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (column.columnName === "__checkbox__") return; if (column.columnName === "__checkbox__") return;
const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName;
const values = new Set<string>(); // 라벨 컬럼 후보들 (백엔드에서 _name, _label, _value_label 등으로 반환할 수 있음)
const labelColumnCandidates = [
`${column.columnName}_name`, // 예: division_name
`${column.columnName}_label`, // 예: division_label
`${column.columnName}_value_label`, // 예: division_value_label
];
const valuesMap = new Map<string, string>(); // value -> label
data.forEach((row) => { data.forEach((row) => {
const val = row[mappedColumnName]; const val = row[mappedColumnName];
if (val !== null && val !== undefined && val !== "") { if (val !== null && val !== undefined && val !== "") {
values.add(String(val)); const valueStr = String(val);
// 라벨 컬럼 후보들 중 값이 있는 것 사용, 없으면 원본 값 사용
let label = valueStr;
for (const labelCol of labelColumnCandidates) {
if (row[labelCol] && row[labelCol] !== "") {
label = String(row[labelCol]);
break;
}
}
valuesMap.set(valueStr, label);
} }
}); });
result[column.columnName] = Array.from(values).sort(); // value-label 쌍으로 저장하고 라벨 기준 정렬
result[column.columnName] = Array.from(valuesMap.entries())
.map(([value, label]) => ({ value, label }))
.sort((a, b) => a.label.localeCompare(b.label));
}); });
return result; return result;
@ -5756,16 +5774,16 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
)} )}
</div> </div>
<div className="max-h-48 space-y-1 overflow-y-auto"> <div className="max-h-48 space-y-1 overflow-y-auto">
{columnUniqueValues[column.columnName]?.slice(0, 50).map((val) => { {columnUniqueValues[column.columnName]?.slice(0, 50).map((item) => {
const isSelected = headerFilters[column.columnName]?.has(val); const isSelected = headerFilters[column.columnName]?.has(item.value);
return ( return (
<div <div
key={val} key={item.value}
className={cn( className={cn(
"hover:bg-muted flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-xs", "hover:bg-muted flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-xs",
isSelected && "bg-primary/10", isSelected && "bg-primary/10",
)} )}
onClick={() => toggleHeaderFilter(column.columnName, val)} onClick={() => toggleHeaderFilter(column.columnName, item.value)}
> >
<div <div
className={cn( className={cn(
@ -5775,7 +5793,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
> >
{isSelected && <Check className="text-primary-foreground h-3 w-3" />} {isSelected && <Check className="text-primary-foreground h-3 w-3" />}
</div> </div>
<span className="truncate">{val || "(빈 값)"}</span> <span className="truncate">{item.label || "(빈 값)"}</span>
</div> </div>
); );
})} })}

View File

@ -385,6 +385,9 @@ export function TableSectionRenderer({
// 소스 테이블의 카테고리 타입 컬럼 목록 // 소스 테이블의 카테고리 타입 컬럼 목록
const [sourceCategoryColumns, setSourceCategoryColumns] = useState<string[]>([]); const [sourceCategoryColumns, setSourceCategoryColumns] = useState<string[]>([]);
// 소스 테이블의 컬럼 라벨 (API에서 동적 로드)
const [sourceColumnLabels, setSourceColumnLabels] = useState<Record<string, string>>({});
// 소스 테이블의 카테고리 타입 컬럼 목록 로드 // 소스 테이블의 카테고리 타입 컬럼 목록 로드
useEffect(() => { useEffect(() => {
const loadCategoryColumns = async () => { const loadCategoryColumns = async () => {
@ -410,6 +413,44 @@ export function TableSectionRenderer({
loadCategoryColumns(); loadCategoryColumns();
}, [tableConfig.source.tableName]); }, [tableConfig.source.tableName]);
// 소스 테이블의 컬럼 라벨 로드 (source.columnLabels가 비어있을 때만)
useEffect(() => {
const loadColumnLabels = async () => {
const sourceTableName = tableConfig.source.tableName;
if (!sourceTableName) return;
// 이미 source.columnLabels가 설정되어 있으면 스킵
if (tableConfig.source.columnLabels && Object.keys(tableConfig.source.columnLabels).length > 0) {
return;
}
try {
const response = await apiClient.get(`/table-management/tables/${sourceTableName}/columns`);
if (response.data?.success && response.data.data) {
const columnsData = response.data.data.columns || response.data.data || [];
const labels: Record<string, string> = {};
for (const col of columnsData) {
const colName = col.column_name || col.columnName;
// displayName: API에서 반환하는 라벨 (COALESCE(cl.column_label, c.column_name))
const colLabel = col.displayName || col.column_label || col.columnLabel || col.comment;
// 라벨이 컬럼명과 다를 때만 저장 (의미있는 라벨인 경우)
if (colName && colLabel && colLabel !== colName) {
labels[colName] = colLabel;
}
}
setSourceColumnLabels(labels);
}
} catch (error) {
console.error("소스 테이블 컬럼 라벨 조회 실패:", error);
}
};
loadColumnLabels();
}, [tableConfig.source.tableName, tableConfig.source.columnLabels]);
// 조건부 테이블: 동적 옵션 로드 (optionSource 설정이 있는 경우) // 조건부 테이블: 동적 옵션 로드 (optionSource 설정이 있는 경우)
useEffect(() => { useEffect(() => {
if (!isConditionalMode) return; if (!isConditionalMode) return;
@ -1305,7 +1346,12 @@ export function TableSectionRenderer({
const sourceTable = source.tableName; const sourceTable = source.tableName;
const sourceColumns = source.displayColumns; const sourceColumns = source.displayColumns;
const sourceSearchFields = source.searchColumns; const sourceSearchFields = source.searchColumns;
const columnLabels = source.columnLabels || {}; // 컬럼 라벨: source.columnLabels가 있으면 우선 사용, 없으면 동적 로드된 라벨 사용
const columnLabels = useMemo(() => {
const configLabels = source.columnLabels || {};
// 설정된 라벨이 있으면 설정 우선, 없으면 API에서 로드한 라벨 사용
return { ...sourceColumnLabels, ...configLabels };
}, [source.columnLabels, sourceColumnLabels]);
const modalTitle = uiConfig?.modalTitle || "항목 검색 및 선택"; const modalTitle = uiConfig?.modalTitle || "항목 검색 및 선택";
const multiSelect = uiConfig?.multiSelect ?? true; const multiSelect = uiConfig?.multiSelect ?? true;

View File

@ -996,38 +996,27 @@ export class ButtonActionExecutor {
} }
// 🆕 루트 레벨 formData에서 RepeaterFieldGroup에 전달할 공통 필드 추출 // 🆕 루트 레벨 formData에서 RepeaterFieldGroup에 전달할 공통 필드 추출
// 주문번호, 발주번호 등 마스터-디테일 관계에서 필요한 필드만 명시적으로 지정 // 규칙 기반 필터링: 하드코딩 대신 패턴으로 제외할 필드를 정의
const masterDetailFields = [ for (const [fieldName, value] of Object.entries(context.formData)) {
// 번호 필드 // 제외 규칙 1: comp_로 시작하는 필드 (하위 항목 배열)
"order_no", // 발주번호 if (fieldName.startsWith("comp_")) continue;
"sales_order_no", // 수주번호 // 제외 규칙 2: _numberingRuleId로 끝나는 필드 (채번 규칙 메타 정보)
"shipment_no", // 출하번호 if (fieldName.endsWith("_numberingRuleId")) continue;
"receipt_no", // 입고번호 // 제외 규칙 3: _로 시작하는 필드 (내부 메타 필드)
"work_order_no", // 작업지시번호 if (fieldName.startsWith("_")) continue;
// 거래처 필드 // 제외 규칙 4: 배열 타입 (하위 항목 데이터)
"supplier_code", // 공급처 코드 if (Array.isArray(value)) continue;
"supplier_name", // 공급처 이름 // 제외 규칙 5: 객체 타입 (복잡한 구조 데이터) - null 제외
"customer_code", // 고객 코드 if (value !== null && typeof value === "object") continue;
"customer_name", // 고객 이름 // 제외 규칙 6: 빈 값
// 날짜 필드 if (value === undefined || value === "" || value === null) continue;
"order_date", // 발주일 // 제외 규칙 7: 이미 commonFields에 있는 필드 (범용 폼 모달에서 가져온 필드)
"sales_date", // 수주일 if (fieldName in commonFields) continue;
"shipment_date", // 출하일
"receipt_date", // 입고일
"due_date", // 납기일
// 담당자/메모 필드
"manager", // 담당자
"memo", // 메모
"remark", // 비고
];
for (const fieldName of masterDetailFields) { // 위 규칙에 해당하지 않는 단순 값(문자열, 숫자, 날짜 등)은 공통 필드로 전달
const value = context.formData[fieldName]; commonFields[fieldName] = value;
if (value !== undefined && value !== "" && value !== null && !(fieldName in commonFields)) {
commonFields[fieldName] = value;
}
} }
console.log("📋 [handleSave] 최종 공통 필드 (마스터-디테일 필드 포함):", commonFields); console.log("📋 [handleSave] 최종 공통 필드 (규칙 기반 자동 추출):", commonFields);
for (const item of parsedData) { for (const item of parsedData) {
// 메타 필드 제거 (eslint 경고 무시 - 의도적으로 분리) // 메타 필드 제거 (eslint 경고 무시 - 의도적으로 분리)
@ -1074,6 +1063,13 @@ export class ButtonActionExecutor {
dataWithMeta, dataWithMeta,
); );
console.log("✅ [handleSave] RepeaterFieldGroup INSERT 완료:", insertResult.data); console.log("✅ [handleSave] RepeaterFieldGroup INSERT 완료:", insertResult.data);
// 무시된 컬럼이 있으면 경고 출력
if (insertResult.data?.data?.skippedColumns?.length > 0) {
console.warn(
`⚠️ [${repeaterTargetTable}] 테이블에 존재하지 않는 컬럼이 무시됨:`,
insertResult.data.data.skippedColumns,
);
}
} else if (item.id) { } else if (item.id) {
// UPDATE (기존 항목) // UPDATE (기존 항목)
const originalData = { id: item.id }; const originalData = { id: item.id };
@ -1089,10 +1085,15 @@ export class ButtonActionExecutor {
console.log("✅ [handleSave] RepeaterFieldGroup UPDATE 완료:", updateResult.data); console.log("✅ [handleSave] RepeaterFieldGroup UPDATE 완료:", updateResult.data);
} }
} catch (err) { } catch (err) {
const error = err as { response?: { data?: unknown }; message?: string }; const error = err as { response?: { data?: unknown; status?: number }; message?: string };
console.error( console.error(
`❌ [handleSave] RepeaterFieldGroup 저장 실패 (${repeaterTargetTable}):`, `❌ [handleSave] RepeaterFieldGroup 저장 실패 (${repeaterTargetTable}):`,
error.response?.data || error.message, {
status: error.response?.status,
data: error.response?.data,
message: error.message,
fullError: JSON.stringify(error.response?.data, null, 2),
},
); );
} }
} }
@ -1374,17 +1375,17 @@ export class ButtonActionExecutor {
// 저장 전 중복 체크 // 저장 전 중복 체크
const firstLocation = locations[0]; const firstLocation = locations[0];
const warehouseId = firstLocation.warehouse_id || firstLocation.warehouseCode; const warehouseCode = firstLocation.warehouse_code || firstLocation.warehouse_id || firstLocation.warehouseCode;
const floor = firstLocation.floor; const floor = firstLocation.floor;
const zone = firstLocation.zone; const zone = firstLocation.zone;
if (warehouseId && floor && zone) { if (warehouseCode && floor && zone) {
console.log("🔍 [handleRackStructureBatchSave] 기존 데이터 중복 체크:", { warehouseId, floor, zone }); console.log("🔍 [handleRackStructureBatchSave] 기존 데이터 중복 체크:", { warehouseCode, floor, zone });
try { try {
const existingResponse = await DynamicFormApi.getTableData(tableName, { const existingResponse = await DynamicFormApi.getTableData(tableName, {
filters: { filters: {
warehouse_id: warehouseId, warehouse_code: warehouseCode,
floor: floor, floor: floor,
zone: zone, zone: zone,
}, },
@ -1434,8 +1435,8 @@ export class ButtonActionExecutor {
location_name: loc.location_name || loc.locationName, location_name: loc.location_name || loc.locationName,
row_num: loc.row_num || String(loc.rowNum), row_num: loc.row_num || String(loc.rowNum),
level_num: loc.level_num || String(loc.levelNum), level_num: loc.level_num || String(loc.levelNum),
// 창고 정보 (렉 구조 컴포넌트에서 전달) // 창고 정보 (렉 구조 컴포넌트에서 전달) - DB 컬럼명은 warehouse_code
warehouse_id: loc.warehouse_id || loc.warehouseCode, warehouse_code: loc.warehouse_code || loc.warehouse_id || loc.warehouseCode,
warehouse_name: loc.warehouse_name || loc.warehouseName, warehouse_name: loc.warehouse_name || loc.warehouseName,
// 위치 정보 (렉 구조 컴포넌트에서 전달) // 위치 정보 (렉 구조 컴포넌트에서 전달)
floor: loc.floor, floor: loc.floor,
@ -2112,7 +2113,20 @@ export class ButtonActionExecutor {
// 모든 그룹의 카티션 곱 생성 // 모든 그룹의 카티션 곱 생성
const entryArrays = groupArrays.map((g) => g.entries); const entryArrays = groupArrays.map((g) => g.entries);
const combinations = cartesianProduct(entryArrays);
// 🆕 모든 그룹이 비어있는지 확인
const allGroupsEmpty = entryArrays.every((arr) => arr.length === 0);
let combinations: any[][];
if (allGroupsEmpty) {
// 🆕 모든 그룹이 비어있으면 빈 조합 하나 생성 (품목 기본 정보만으로 저장)
console.log("📝 [handleBatchSave] 모든 그룹이 비어있음 - 기본 레코드 생성");
combinations = [[]];
} else {
// 빈 그룹을 필터링하여 카티션 곱 계산 (빈 그룹은 무시)
const nonEmptyArrays = entryArrays.filter((arr) => arr.length > 0);
combinations = nonEmptyArrays.length > 0 ? cartesianProduct(nonEmptyArrays) : [[]];
}
// 각 조합을 개별 레코드로 저장 // 각 조합을 개별 레코드로 저장
for (let i = 0; i < combinations.length; i++) { for (let i = 0; i < combinations.length; i++) {