feat: 기간별 단가 설정 기능 구현 - 자동 계산 시스템

- 선택항목 상세입력 컴포넌트 확장
  - 실시간 가격 계산 기능 추가 (할인율/할인금액, 반올림 방식)
  - 카테고리 값 기반 연산 매핑 시스템
  - 3단계 드릴다운 방식 설정 UI (메뉴 → 카테고리 → 값 매핑)

- 설정 가능한 계산 로직
  - autoCalculation 설정으로 계산 필드명 동적 지정
  - valueMapping으로 카테고리 코드와 연산 타입 매핑
  - 할인 방식: none/rate/amount
  - 반올림 방식: none/round/floor/ceil
  - 반올림 단위: 1/10/100/1000

- UI 개선
  - 입력 필드 가로 배치 (반응형 Grid)
  - 카테고리 타입 필드 옵션 로딩 개선
  - 계산 결과 필드 자동 표시 및 읽기 전용 처리
  - 날짜 입력 필드 네이티브 피커 지원

- API 연동
  - 2레벨 메뉴 목록 조회
  - 메뉴별 카테고리 컬럼 조회
  - 카테고리별 값 목록 조회

- 문서화
  - 기간별 단가 설정 가이드 작성
This commit is contained in:
kjs
2025-11-18 16:12:47 +09:00
parent 967b76591b
commit e1a5befdf7
10 changed files with 1966 additions and 186 deletions

View File

@@ -1604,10 +1604,14 @@ export async function toggleLogTable(
}
/**
* 메뉴의 형제 메뉴들이 사용하는 모든 테이블의 카테고리 타입 컬럼 조회
* 메뉴의 상위 메뉴들이 설정한 모든 카테고리 타입 컬럼 조회 (계층 구조 상속)
*
* @route GET /api/table-management/menu/:menuObjid/category-columns
* @description 형제 메뉴들의 화면에서 사용하는 테이블의 input_type='category' 컬럼 조회
* @description 현재 메뉴와 상위 메뉴들에서 설정한 category_column_mapping의 모든 카테고리 컬럼 조회
*
* 예시:
* - 2레벨 메뉴 "고객사관리"에서 discount_type, rounding_type 설정
* - 3레벨 메뉴 "고객등록", "고객조회" 등에서도 동일하게 보임 (상속)
*/
export async function getCategoryColumnsByMenu(
req: AuthenticatedRequest,
@@ -1627,40 +1631,10 @@ export async function getCategoryColumnsByMenu(
return;
}
// 1. 형제 메뉴 조회
const { getSiblingMenuObjids } = await import("../services/menuService");
const siblingObjids = await getSiblingMenuObjids(parseInt(menuObjid));
logger.info("✅ 형제 메뉴 조회 완료", { siblingObjids });
// 2. 형제 메뉴들이 사용하는 테이블 조회
const { getPool } = await import("../database/db");
const pool = getPool();
const tablesQuery = `
SELECT DISTINCT sd.table_name
FROM screen_menu_assignments sma
INNER JOIN screen_definitions sd ON sma.screen_id = sd.screen_id
WHERE sma.menu_objid = ANY($1)
AND sma.company_code = $2
AND sd.table_name IS NOT NULL
`;
const tablesResult = await pool.query(tablesQuery, [siblingObjids, companyCode]);
const tableNames = tablesResult.rows.map((row: any) => row.table_name);
logger.info("✅ 형제 메뉴 테이블 조회 완료", { tableNames, count: tableNames.length });
if (tableNames.length === 0) {
res.json({
success: true,
data: [],
message: "형제 메뉴에 연결된 테이블이 없습니다.",
});
return;
}
// 3. category_column_mapping 테이블 존재 여부 확인
// 1. category_column_mapping 테이블 존재 여부 확인
const tableExistsResult = await pool.query(`
SELECT EXISTS (
SELECT FROM information_schema.tables
@@ -1672,33 +1646,42 @@ export async function getCategoryColumnsByMenu(
let columnsResult;
if (mappingTableExists) {
// 🆕 category_column_mapping을 사용한 필터링
logger.info("🔍 category_column_mapping 기반 카테고리 컬럼 조회", { menuObjid, companyCode });
// 🆕 category_column_mapping을 사용한 계층 구조 기반 조회
logger.info("🔍 category_column_mapping 기반 카테고리 컬럼 조회 (계층 구조 상속)", { menuObjid, companyCode });
// 현재 메뉴와 모든 상위 메뉴의 objid 조회 (재귀)
const ancestorMenuQuery = `
WITH RECURSIVE menu_hierarchy AS (
-- 현재 메뉴
SELECT objid, parent_obj_id, menu_type
SELECT objid, parent_obj_id, menu_type, menu_name_kor
FROM menu_info
WHERE objid = $1
UNION ALL
-- 부모 메뉴 재귀 조회
SELECT m.objid, m.parent_obj_id, m.menu_type
SELECT m.objid, m.parent_obj_id, m.menu_type, m.menu_name_kor
FROM menu_info m
INNER JOIN menu_hierarchy mh ON m.objid = mh.parent_obj_id
WHERE m.parent_obj_id != 0 -- 최상위 메뉴(parent_obj_id=0) 제외
)
SELECT ARRAY_AGG(objid) as menu_objids
SELECT
ARRAY_AGG(objid) as menu_objids,
ARRAY_AGG(menu_name_kor) as menu_names
FROM menu_hierarchy
`;
const ancestorMenuResult = await pool.query(ancestorMenuQuery, [parseInt(menuObjid)]);
const ancestorMenuObjids = ancestorMenuResult.rows[0]?.menu_objids || [parseInt(menuObjid)];
const ancestorMenuNames = ancestorMenuResult.rows[0]?.menu_names || [];
logger.info("✅ 상위 메뉴 계층 조회 완료", {
ancestorMenuObjids,
ancestorMenuNames,
hierarchyDepth: ancestorMenuObjids.length
});
// 상위 메뉴들에 설정된 모든 카테고리 컬럼 조회 (테이블 필터링 제거)
const columnsQuery = `
SELECT DISTINCT
ttc.table_name AS "tableName",
@@ -1711,7 +1694,8 @@ export async function getCategoryColumnsByMenu(
cl.column_label,
initcap(replace(ccm.logical_column_name, '_', ' '))
) AS "columnLabel",
ttc.input_type AS "inputType"
ttc.input_type AS "inputType",
ccm.menu_objid AS "definedAtMenuObjid"
FROM category_column_mapping ccm
INNER JOIN table_type_columns ttc
ON ccm.table_name = ttc.table_name
@@ -1721,18 +1705,48 @@ export async function getCategoryColumnsByMenu(
AND ttc.column_name = cl.column_name
LEFT JOIN table_labels tl
ON ttc.table_name = tl.table_name
WHERE ccm.table_name = ANY($1)
AND ccm.company_code = $2
AND ccm.menu_objid = ANY($3)
WHERE ccm.company_code = $1
AND ccm.menu_objid = ANY($2)
AND ttc.input_type = 'category'
ORDER BY ttc.table_name, ccm.logical_column_name
`;
columnsResult = await pool.query(columnsQuery, [tableNames, companyCode, ancestorMenuObjids]);
logger.info("✅ category_column_mapping 기반 조회 완료", { rowCount: columnsResult.rows.length });
columnsResult = await pool.query(columnsQuery, [companyCode, ancestorMenuObjids]);
logger.info("✅ category_column_mapping 기반 조회 완료 (계층 구조 상속)", {
rowCount: columnsResult.rows.length,
columns: columnsResult.rows.map((r: any) => `${r.tableName}.${r.columnName}`)
});
} else {
// 🔄 기존 방식: table_type_columns에서 모든 카테고리 컬럼 조회
logger.info("🔍 레거시 방식: table_type_columns 기반 카테고리 컬럼 조회", { tableNames, companyCode });
// 🔄 레거시 방식: 형제 메뉴들의 테이블에서 모든 카테고리 컬럼 조회
logger.info("🔍 레거시 방식: 형제 메뉴 테이블 기반 카테고리 컬럼 조회", { menuObjid, companyCode });
// 형제 메뉴 조회
const { getSiblingMenuObjids } = await import("../services/menuService");
const siblingObjids = await getSiblingMenuObjids(parseInt(menuObjid));
// 형제 메뉴들이 사용하는 테이블 조회
const tablesQuery = `
SELECT DISTINCT sd.table_name
FROM screen_menu_assignments sma
INNER JOIN screen_definitions sd ON sma.screen_id = sd.screen_id
WHERE sma.menu_objid = ANY($1)
AND sma.company_code = $2
AND sd.table_name IS NOT NULL
`;
const tablesResult = await pool.query(tablesQuery, [siblingObjids, companyCode]);
const tableNames = tablesResult.rows.map((row: any) => row.table_name);
logger.info("✅ 형제 메뉴 테이블 조회 완료", { tableNames, count: tableNames.length });
if (tableNames.length === 0) {
res.json({
success: true,
data: [],
message: "형제 메뉴에 연결된 테이블이 없습니다.",
});
return;
}
const columnsQuery = `
SELECT