1127 lines
30 KiB
Markdown
1127 lines
30 KiB
Markdown
# 채번규칙 테이블 기반 필터링 구현 계획서
|
|
|
|
## 📋 프로젝트 개요
|
|
|
|
### 목적
|
|
|
|
현재 메뉴 기반 채번규칙 필터링 방식을 **테이블 기반 필터링**으로 전환하여 더 직관적이고 유지보수하기 쉬운 시스템 구축
|
|
|
|
### 현재 문제점
|
|
|
|
1. 화면관리에서 `menuObjid` 정보가 없어 `scope_type='menu'` 규칙을 볼 수 없음
|
|
2. 메뉴 구조 변경 시 채번규칙 재설정 필요
|
|
3. 같은 테이블을 사용하는 화면들에 동일한 규칙을 반복 설정해야 함
|
|
4. 메뉴 계층 구조를 이해해야 규칙 설정 가능 (복잡도 높음)
|
|
|
|
### 해결 방안
|
|
|
|
- **테이블명 기반 자동 매칭**: 화면의 테이블과 규칙의 테이블이 같으면 자동으로 표시
|
|
- **하이브리드 접근**: `scope_type`을 'global', 'table', 'menu' 세 가지로 확장
|
|
- **우선순위 시스템**: menu > table > global 순으로 구체적인 규칙 우선 적용
|
|
|
|
---
|
|
|
|
## 🎯 목표
|
|
|
|
### 기능 목표
|
|
|
|
- [x] 같은 테이블을 사용하는 화면에서 채번규칙 자동 표시
|
|
- [x] 세 가지 scope_type 지원 (global, table, menu)
|
|
- [x] 우선순위 기반 규칙 선택
|
|
- [x] 기존 규칙 자동 마이그레이션
|
|
|
|
### 비기능 목표
|
|
|
|
- [x] 기존 기능 100% 호환성 유지
|
|
- [x] 성능 저하 없음 (인덱스 최적화)
|
|
- [x] 멀티테넌시 보안 유지
|
|
- [x] 롤백 가능한 마이그레이션
|
|
|
|
---
|
|
|
|
## 📐 시스템 설계
|
|
|
|
### scope_type 정의
|
|
|
|
| scope_type | 설명 | 우선순위 | 사용 케이스 |
|
|
| ---------- | ---------------------- | -------- | ------------------------------- |
|
|
| `menu` | 특정 메뉴에서만 사용 | 1 (최고) | 메뉴별로 다른 채번 방식 필요 시 |
|
|
| `table` | 특정 테이블에서만 사용 | 2 (중간) | 테이블 기준 채번 (일반적) |
|
|
| `global` | 모든 곳에서 사용 가능 | 3 (최저) | 공통 채번 규칙 |
|
|
|
|
### 필터링 로직 (우선순위)
|
|
|
|
```sql
|
|
WHERE company_code = $1
|
|
AND (
|
|
-- 1순위: 메뉴별 규칙 (가장 구체적)
|
|
(scope_type = 'menu' AND menu_objid = $3)
|
|
|
|
-- 2순위: 테이블별 규칙 (일반적)
|
|
OR (scope_type = 'table' AND table_name = $2)
|
|
|
|
-- 3순위: 전역 규칙 (가장 일반적, table_name 제약 없음)
|
|
OR (scope_type = 'global' AND table_name IS NULL)
|
|
)
|
|
ORDER BY
|
|
CASE scope_type
|
|
WHEN 'menu' THEN 1
|
|
WHEN 'table' THEN 2
|
|
WHEN 'global' THEN 3
|
|
END,
|
|
created_at DESC
|
|
```
|
|
|
|
### 데이터베이스 스키마 변경
|
|
|
|
#### numbering_rules 테이블
|
|
|
|
**변경 전**:
|
|
|
|
```sql
|
|
scope_type VARCHAR(20) -- 값: 'global' 또는 'menu'
|
|
```
|
|
|
|
**변경 후**:
|
|
|
|
```sql
|
|
scope_type VARCHAR(20) -- 값: 'global', 'table', 'menu'
|
|
CHECK (scope_type IN ('global', 'table', 'menu'))
|
|
```
|
|
|
|
**추가 제약조건**:
|
|
|
|
```sql
|
|
-- table 타입은 반드시 table_name이 있어야 함
|
|
CHECK (
|
|
(scope_type = 'table' AND table_name IS NOT NULL)
|
|
OR scope_type != 'table'
|
|
)
|
|
|
|
-- global 타입은 table_name이 없어야 함
|
|
CHECK (
|
|
(scope_type = 'global' AND table_name IS NULL)
|
|
OR scope_type != 'global'
|
|
)
|
|
|
|
-- menu 타입은 반드시 menu_objid가 있어야 함
|
|
CHECK (
|
|
(scope_type = 'menu' AND menu_objid IS NOT NULL)
|
|
OR scope_type != 'menu'
|
|
)
|
|
```
|
|
|
|
---
|
|
|
|
## 🔧 구현 단계
|
|
|
|
### Phase 1: 데이터베이스 마이그레이션 (30분)
|
|
|
|
#### 1.1 마이그레이션 파일 생성
|
|
|
|
- 파일: `db/migrations/046_update_numbering_rules_scope_type.sql`
|
|
- 내용:
|
|
1. scope_type 제약조건 확장
|
|
2. 유효성 검증 제약조건 추가
|
|
3. 기존 데이터 마이그레이션 (global → table)
|
|
4. 인덱스 최적화
|
|
|
|
#### 1.2 데이터 마이그레이션 로직
|
|
|
|
```sql
|
|
-- 기존 규칙 중 table_name이 있는 것은 'table' 타입으로 변경
|
|
UPDATE numbering_rules
|
|
SET scope_type = 'table'
|
|
WHERE scope_type = 'global'
|
|
AND table_name IS NOT NULL;
|
|
|
|
-- 기존 규칙 중 table_name이 없는 것은 'global' 유지
|
|
-- (변경 불필요)
|
|
```
|
|
|
|
#### 1.3 롤백 계획
|
|
|
|
- 마이그레이션 실패 시 자동 롤백 (트랜잭션)
|
|
- 수동 롤백 스크립트 제공
|
|
|
|
---
|
|
|
|
### Phase 2: 백엔드 API 수정 (1시간)
|
|
|
|
#### 2.1 numberingRuleService.ts 수정
|
|
|
|
**변경할 함수**:
|
|
|
|
##### getAvailableRulesForScreen (신규 함수)
|
|
|
|
```typescript
|
|
async getAvailableRulesForScreen(
|
|
companyCode: string,
|
|
tableName: string,
|
|
menuObjid?: number
|
|
): Promise<NumberingRuleConfig[]> {
|
|
try {
|
|
logger.info("화면용 채번 규칙 조회", {
|
|
companyCode,
|
|
tableName,
|
|
menuObjid,
|
|
});
|
|
|
|
const pool = getPool();
|
|
|
|
// 멀티테넌시: 최고 관리자 vs 일반 회사
|
|
let query: string;
|
|
let params: any[];
|
|
|
|
if (companyCode === "*") {
|
|
// 최고 관리자: 모든 회사의 규칙 조회 가능
|
|
// 하지만 일반적으로는 일반 회사들의 규칙을 조회하므로
|
|
// company_code != '*' 조건 추가 (최고 관리자 전용 규칙 제외)
|
|
query = `
|
|
SELECT
|
|
rule_id AS "ruleId",
|
|
rule_name AS "ruleName",
|
|
description,
|
|
separator,
|
|
reset_period AS "resetPeriod",
|
|
current_sequence AS "currentSequence",
|
|
table_name AS "tableName",
|
|
column_name AS "columnName",
|
|
company_code AS "companyCode",
|
|
menu_objid AS "menuObjid",
|
|
scope_type AS "scopeType",
|
|
created_at AS "createdAt",
|
|
updated_at AS "updatedAt",
|
|
created_by AS "createdBy"
|
|
FROM numbering_rules
|
|
WHERE company_code != '*'
|
|
AND (
|
|
(scope_type = 'menu' AND menu_objid = $1)
|
|
OR (scope_type = 'table' AND table_name = $2)
|
|
OR (scope_type = 'global' AND table_name IS NULL)
|
|
)
|
|
ORDER BY
|
|
CASE scope_type
|
|
WHEN 'menu' THEN 1
|
|
WHEN 'table' THEN 2
|
|
WHEN 'global' THEN 3
|
|
END,
|
|
created_at DESC
|
|
`;
|
|
params = [menuObjid, tableName];
|
|
logger.info("최고 관리자: 일반 회사 채번 규칙 조회 (company_code != '*')");
|
|
} else {
|
|
// 일반 회사: 자신의 규칙만 조회
|
|
query = `
|
|
SELECT
|
|
rule_id AS "ruleId",
|
|
rule_name AS "ruleName",
|
|
description,
|
|
separator,
|
|
reset_period AS "resetPeriod",
|
|
current_sequence AS "currentSequence",
|
|
table_name AS "tableName",
|
|
column_name AS "columnName",
|
|
company_code AS "companyCode",
|
|
menu_objid AS "menuObjid",
|
|
scope_type AS "scopeType",
|
|
created_at AS "createdAt",
|
|
updated_at AS "updatedAt",
|
|
created_by AS "createdBy"
|
|
FROM numbering_rules
|
|
WHERE company_code = $1
|
|
AND (
|
|
(scope_type = 'menu' AND menu_objid = $2)
|
|
OR (scope_type = 'table' AND table_name = $3)
|
|
OR (scope_type = 'global' AND table_name IS NULL)
|
|
)
|
|
ORDER BY
|
|
CASE scope_type
|
|
WHEN 'menu' THEN 1
|
|
WHEN 'table' THEN 2
|
|
WHEN 'global' THEN 3
|
|
END,
|
|
created_at DESC
|
|
`;
|
|
params = [companyCode, menuObjid, tableName];
|
|
}
|
|
|
|
const result = await pool.query(query, params);
|
|
|
|
// 각 규칙의 파트 정보 로드
|
|
for (const rule of result.rows) {
|
|
const partsQuery = `
|
|
SELECT
|
|
id,
|
|
part_order AS "order",
|
|
part_type AS "partType",
|
|
generation_method AS "generationMethod",
|
|
auto_config AS "autoConfig",
|
|
manual_config AS "manualConfig"
|
|
FROM numbering_rule_parts
|
|
WHERE rule_id = $1
|
|
AND company_code = $2
|
|
ORDER BY part_order
|
|
`;
|
|
|
|
const partsResult = await pool.query(partsQuery, [
|
|
rule.ruleId,
|
|
companyCode === "*" ? rule.companyCode : companyCode,
|
|
]);
|
|
|
|
rule.parts = partsResult.rows;
|
|
}
|
|
|
|
logger.info(`화면용 채번 규칙 조회 완료: ${result.rows.length}개`, {
|
|
companyCode,
|
|
tableName,
|
|
});
|
|
|
|
return result.rows;
|
|
} catch (error: any) {
|
|
logger.error("화면용 채번 규칙 조회 실패", error);
|
|
throw error;
|
|
}
|
|
}
|
|
```
|
|
|
|
##### getAvailableRulesForMenu (기존 함수 유지)
|
|
|
|
- 채번규칙 관리 화면에서 사용
|
|
- 변경 없음 (하위 호환성)
|
|
|
|
#### 2.2 numberingRuleController.ts 수정
|
|
|
|
**신규 엔드포인트 추가**:
|
|
|
|
```typescript
|
|
// GET /api/numbering-rules/available-for-screen?tableName=xxx&menuObjid=xxx
|
|
router.get(
|
|
"/available-for-screen",
|
|
authMiddleware,
|
|
async (req: Request, res: Response) => {
|
|
try {
|
|
const companyCode = req.user!.companyCode;
|
|
const { tableName, menuObjid } = req.query;
|
|
|
|
if (!tableName) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: "tableName is required",
|
|
});
|
|
}
|
|
|
|
const rules = await numberingRuleService.getAvailableRulesForScreen(
|
|
companyCode,
|
|
tableName as string,
|
|
menuObjid ? parseInt(menuObjid as string) : undefined
|
|
);
|
|
|
|
return res.json({
|
|
success: true,
|
|
data: rules,
|
|
});
|
|
} catch (error: any) {
|
|
logger.error("화면용 채번 규칙 조회 실패", error);
|
|
return res.status(500).json({
|
|
success: false,
|
|
message: error.message,
|
|
});
|
|
}
|
|
}
|
|
);
|
|
```
|
|
|
|
---
|
|
|
|
### Phase 3: 프론트엔드 API 클라이언트 수정 (30분)
|
|
|
|
#### 3.1 lib/api/numberingRule.ts 수정
|
|
|
|
**신규 함수 추가**:
|
|
|
|
```typescript
|
|
/**
|
|
* 화면용 채번 규칙 조회 (테이블 기반)
|
|
* @param tableName 화면의 테이블명 (필수)
|
|
* @param menuObjid 현재 메뉴의 objid (선택)
|
|
* @returns 사용 가능한 채번 규칙 목록
|
|
*/
|
|
export async function getAvailableNumberingRulesForScreen(
|
|
tableName: string,
|
|
menuObjid?: number
|
|
): Promise<ApiResponse<NumberingRuleConfig[]>> {
|
|
try {
|
|
const params: any = { tableName };
|
|
if (menuObjid) {
|
|
params.menuObjid = menuObjid;
|
|
}
|
|
|
|
const response = await apiClient.get(
|
|
"/numbering-rules/available-for-screen",
|
|
{
|
|
params,
|
|
}
|
|
);
|
|
return response.data;
|
|
} catch (error: any) {
|
|
return {
|
|
success: false,
|
|
error: error.message || "화면용 규칙 조회 실패",
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
**기존 함수 유지**:
|
|
|
|
```typescript
|
|
// getAvailableNumberingRules (메뉴 기반) - 하위 호환성
|
|
// 채번규칙 관리 컴포넌트에서 계속 사용
|
|
```
|
|
|
|
---
|
|
|
|
### Phase 4: 화면관리 UI 수정 (30분)
|
|
|
|
#### 4.1 TextTypeConfigPanel.tsx 수정
|
|
|
|
**변경 전**:
|
|
|
|
```typescript
|
|
const response = await getAvailableNumberingRules();
|
|
```
|
|
|
|
**변경 후**:
|
|
|
|
```typescript
|
|
const loadRules = async () => {
|
|
setLoadingRules(true);
|
|
try {
|
|
// 화면의 테이블명 가져오기
|
|
const screenTableName = getScreenTableName(); // 구현 필요
|
|
|
|
if (!screenTableName) {
|
|
logger.warn("화면 테이블명을 찾을 수 없습니다");
|
|
setNumberingRules([]);
|
|
return;
|
|
}
|
|
|
|
// 테이블 기반 규칙 조회
|
|
const response = await getAvailableNumberingRulesForScreen(
|
|
screenTableName,
|
|
undefined // menuObjid (향후 확장 가능)
|
|
);
|
|
|
|
if (response.success && response.data) {
|
|
setNumberingRules(response.data);
|
|
logger.info(`채번 규칙 ${response.data.length}개 로드 완료`, {
|
|
tableName: screenTableName,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error("채번 규칙 목록 로드 실패:", error);
|
|
setNumberingRules([]);
|
|
} finally {
|
|
setLoadingRules(false);
|
|
}
|
|
};
|
|
```
|
|
|
|
**화면 테이블명 가져오기**:
|
|
|
|
```typescript
|
|
// ScreenDesigner에서 props로 전달받거나 Context 사용
|
|
const getScreenTableName = (): string | undefined => {
|
|
// 방법 1: Props로 전달받기 (권장)
|
|
return props.screenTableName;
|
|
|
|
// 방법 2: Context에서 가져오기
|
|
// const { selectedScreen } = useScreenContext();
|
|
// return selectedScreen?.tableName;
|
|
|
|
// 방법 3: 상위 컴포넌트에서 찾기
|
|
// return component.tableName || selectedScreen?.tableName;
|
|
};
|
|
```
|
|
|
|
#### 4.2 ScreenDesigner.tsx 수정
|
|
|
|
**화면 테이블명을 하위 컴포넌트에 전달**:
|
|
|
|
```typescript
|
|
// PropertiesPanel에 screenTableName prop 추가
|
|
<PropertiesPanel
|
|
selectedComponent={selectedComponent}
|
|
onUpdateProperty={handleUpdateProperty}
|
|
onUpdateComponent={handleUpdateComponent}
|
|
screenTableName={tables[0]?.tableName} // 추가
|
|
/>
|
|
|
|
// PropertiesPanel에서 TextTypeConfigPanel에 전달
|
|
<TextTypeConfigPanel
|
|
config={config}
|
|
onConfigChange={handleConfigChange}
|
|
screenTableName={screenTableName} // 추가
|
|
/>
|
|
```
|
|
|
|
---
|
|
|
|
### Phase 5: 채번규칙 관리 UI 수정 (30분)
|
|
|
|
#### 5.1 NumberingRuleDesigner.tsx 수정
|
|
|
|
**scope_type 선택 UI 추가**:
|
|
|
|
```typescript
|
|
<div className="space-y-2">
|
|
<Label className="text-sm font-medium">적용 범위</Label>
|
|
<Select
|
|
value={config.scopeType || "table"}
|
|
onValueChange={(value) => updateConfig("scopeType", value)}
|
|
>
|
|
<SelectTrigger className="h-9 text-sm">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="global" className="text-sm">
|
|
전역 (모든 화면)
|
|
</SelectItem>
|
|
<SelectItem value="table" className="text-sm">
|
|
테이블별 (같은 테이블 화면)
|
|
</SelectItem>
|
|
<SelectItem value="menu" className="text-sm">
|
|
메뉴별 (특정 메뉴만)
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-xs text-muted-foreground">
|
|
{config.scopeType === "global" && "모든 화면에서 사용 가능"}
|
|
{config.scopeType === "table" && "같은 테이블을 사용하는 화면에서만 표시"}
|
|
{config.scopeType === "menu" && "선택한 메뉴에서만 사용 가능"}
|
|
</p>
|
|
</div>
|
|
```
|
|
|
|
**조건부 필드 표시**:
|
|
|
|
```typescript
|
|
{
|
|
/* table 타입: 테이블명 필수 */
|
|
}
|
|
{
|
|
config.scopeType === "table" && (
|
|
<div className="space-y-2">
|
|
<Label className="text-sm font-medium">
|
|
테이블명 <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Input
|
|
value={config.tableName || ""}
|
|
onChange={(e) => updateConfig("tableName", e.target.value)}
|
|
placeholder="예: item_info"
|
|
className="h-9 text-sm"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
{
|
|
/* menu 타입: 메뉴 선택 필수 */
|
|
}
|
|
{
|
|
config.scopeType === "menu" && (
|
|
<div className="space-y-2">
|
|
<Label className="text-sm font-medium">
|
|
메뉴 <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Select
|
|
value={config.menuObjid?.toString() || ""}
|
|
onValueChange={(value) => updateConfig("menuObjid", parseInt(value))}
|
|
>
|
|
<SelectTrigger className="h-9 text-sm">
|
|
<SelectValue placeholder="메뉴 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{/* 메뉴 목록 로드 */}
|
|
{menus.map((menu) => (
|
|
<SelectItem key={menu.objid} value={menu.objid.toString()}>
|
|
{menu.menuName}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
{
|
|
/* global 타입: 추가 설정 불필요 */
|
|
}
|
|
{
|
|
config.scopeType === "global" && (
|
|
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3">
|
|
<p className="text-sm text-blue-800">
|
|
이 규칙은 모든 화면에서 사용할 수 있습니다.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
#### 5.2 유효성 검증 추가
|
|
|
|
```typescript
|
|
const validateRuleConfig = (config: NumberingRuleConfig): string | null => {
|
|
if (config.scopeType === "table" && !config.tableName) {
|
|
return "테이블 타입은 테이블명이 필수입니다.";
|
|
}
|
|
|
|
if (config.scopeType === "menu" && !config.menuObjid) {
|
|
return "메뉴 타입은 메뉴 선택이 필수입니다.";
|
|
}
|
|
|
|
if (config.scopeType === "global" && config.tableName) {
|
|
return "전역 타입은 테이블명을 지정할 수 없습니다.";
|
|
}
|
|
|
|
return null;
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## 📝 마이그레이션 파일 작성
|
|
|
|
### 파일: `db/migrations/046_update_numbering_rules_scope_type.sql`
|
|
|
|
```sql
|
|
-- =====================================================
|
|
-- 마이그레이션 046: 채번규칙 scope_type 확장
|
|
-- 목적: 메뉴 기반 → 테이블 기반 필터링 지원
|
|
-- 날짜: 2025-11-08
|
|
-- =====================================================
|
|
|
|
BEGIN;
|
|
|
|
-- 1. 기존 제약조건 제거
|
|
ALTER TABLE numbering_rules
|
|
DROP CONSTRAINT IF EXISTS check_scope_type;
|
|
|
|
-- 2. 새로운 scope_type 제약조건 추가 (global, table, menu)
|
|
ALTER TABLE numbering_rules
|
|
ADD CONSTRAINT check_scope_type
|
|
CHECK (scope_type IN ('global', 'table', 'menu'));
|
|
|
|
-- 3. table 타입 유효성 검증 제약조건
|
|
ALTER TABLE numbering_rules
|
|
ADD CONSTRAINT check_table_scope_requires_table_name
|
|
CHECK (
|
|
(scope_type = 'table' AND table_name IS NOT NULL)
|
|
OR scope_type != 'table'
|
|
);
|
|
|
|
-- 4. global 타입 유효성 검증 제약조건
|
|
ALTER TABLE numbering_rules
|
|
ADD CONSTRAINT check_global_scope_no_table_name
|
|
CHECK (
|
|
(scope_type = 'global' AND table_name IS NULL)
|
|
OR scope_type != 'global'
|
|
);
|
|
|
|
-- 5. menu 타입 유효성 검증 제약조건
|
|
ALTER TABLE numbering_rules
|
|
ADD CONSTRAINT check_menu_scope_requires_menu_objid
|
|
CHECK (
|
|
(scope_type = 'menu' AND menu_objid IS NOT NULL)
|
|
OR scope_type != 'menu'
|
|
);
|
|
|
|
-- 6. 기존 데이터 마이그레이션
|
|
-- global 규칙 중 table_name이 있는 것 → table 타입으로 변경
|
|
-- 멀티테넌시: 모든 회사의 데이터를 안전하게 변환
|
|
UPDATE numbering_rules
|
|
SET scope_type = 'table'
|
|
WHERE scope_type = 'global'
|
|
AND table_name IS NOT NULL;
|
|
-- 주의: company_code 필터 없음 (모든 회사 데이터 마이그레이션)
|
|
|
|
-- 7. 인덱스 최적화 (멀티테넌시 필수!)
|
|
-- 기존 인덱스 제거
|
|
DROP INDEX IF EXISTS idx_numbering_rules_table;
|
|
|
|
-- 새로운 복합 인덱스 생성 (테이블 기반 조회 최적화)
|
|
-- company_code 포함으로 회사별 격리 성능 향상
|
|
CREATE INDEX IF NOT EXISTS idx_numbering_rules_scope_table
|
|
ON numbering_rules(scope_type, table_name, company_code);
|
|
|
|
-- 메뉴 기반 조회 최적화
|
|
-- company_code 포함으로 회사별 격리 성능 향상
|
|
CREATE INDEX IF NOT EXISTS idx_numbering_rules_scope_menu
|
|
ON numbering_rules(scope_type, menu_objid, company_code);
|
|
|
|
-- 8. 통계 정보 업데이트
|
|
ANALYZE numbering_rules;
|
|
|
|
COMMIT;
|
|
|
|
-- =====================================================
|
|
-- 롤백 스크립트 (문제 발생 시 실행)
|
|
-- =====================================================
|
|
/*
|
|
BEGIN;
|
|
|
|
-- 제약조건 제거
|
|
ALTER TABLE numbering_rules DROP CONSTRAINT IF EXISTS check_scope_type;
|
|
ALTER TABLE numbering_rules DROP CONSTRAINT IF EXISTS check_table_scope_requires_table_name;
|
|
ALTER TABLE numbering_rules DROP CONSTRAINT IF EXISTS check_global_scope_no_table_name;
|
|
ALTER TABLE numbering_rules DROP CONSTRAINT IF EXISTS check_menu_scope_requires_menu_objid;
|
|
|
|
-- 인덱스 제거
|
|
DROP INDEX IF EXISTS idx_numbering_rules_scope_table;
|
|
DROP INDEX IF EXISTS idx_numbering_rules_scope_menu;
|
|
|
|
-- 데이터 롤백 (table → global)
|
|
UPDATE numbering_rules
|
|
SET scope_type = 'global'
|
|
WHERE scope_type = 'table';
|
|
|
|
-- 기존 제약조건 복원
|
|
ALTER TABLE numbering_rules
|
|
ADD CONSTRAINT check_scope_type
|
|
CHECK (scope_type IN ('global', 'menu'));
|
|
|
|
-- 기존 인덱스 복원
|
|
CREATE INDEX IF NOT EXISTS idx_numbering_rules_table
|
|
ON numbering_rules(table_name, column_name);
|
|
|
|
COMMIT;
|
|
*/
|
|
```
|
|
|
|
---
|
|
|
|
## ✅ 검증 계획
|
|
|
|
### 1. 데이터베이스 검증
|
|
|
|
#### 1.1 제약조건 확인
|
|
|
|
```sql
|
|
-- scope_type 제약조건 확인
|
|
SELECT conname, pg_get_constraintdef(oid)
|
|
FROM pg_constraint
|
|
WHERE conrelid = 'numbering_rules'::regclass
|
|
AND conname LIKE '%scope%';
|
|
|
|
-- 예상 결과:
|
|
-- check_scope_type | CHECK (scope_type IN ('global', 'table', 'menu'))
|
|
-- check_table_scope_requires_table_name
|
|
-- check_global_scope_no_table_name
|
|
-- check_menu_scope_requires_menu_objid
|
|
```
|
|
|
|
#### 1.2 인덱스 확인
|
|
|
|
```sql
|
|
-- 인덱스 목록 확인
|
|
SELECT indexname, indexdef
|
|
FROM pg_indexes
|
|
WHERE tablename = 'numbering_rules'
|
|
ORDER BY indexname;
|
|
|
|
-- 예상 결과:
|
|
-- idx_numbering_rules_scope_table
|
|
-- idx_numbering_rules_scope_menu
|
|
```
|
|
|
|
#### 1.3 데이터 마이그레이션 확인
|
|
|
|
```sql
|
|
-- scope_type별 개수
|
|
SELECT scope_type, COUNT(*) as count
|
|
FROM numbering_rules
|
|
GROUP BY scope_type;
|
|
|
|
-- 테이블명이 있는데 global인 규칙 (없어야 정상)
|
|
SELECT rule_id, rule_name, scope_type, table_name
|
|
FROM numbering_rules
|
|
WHERE scope_type = 'global' AND table_name IS NOT NULL;
|
|
```
|
|
|
|
### 2. API 검증
|
|
|
|
#### 2.1 테이블 기반 조회 테스트
|
|
|
|
```bash
|
|
# 특정 테이블의 규칙 조회
|
|
curl -X GET "http://localhost:8080/api/numbering-rules/available-for-screen?tableName=item_info" \
|
|
-H "Authorization: Bearer {token}"
|
|
|
|
# 예상 응답:
|
|
# - scope_type='table' && table_name='item_info'
|
|
# - scope_type='global' && table_name IS NULL
|
|
```
|
|
|
|
#### 2.2 우선순위 테스트
|
|
|
|
```sql
|
|
-- 테스트 데이터 삽입
|
|
INSERT INTO numbering_rules (rule_id, rule_name, scope_type, table_name, company_code)
|
|
VALUES
|
|
('RULE_GLOBAL', '전역규칙', 'global', NULL, 'TEST_CO'),
|
|
('RULE_TABLE', '테이블규칙', 'table', 'item_info', 'TEST_CO'),
|
|
('RULE_MENU', '메뉴규칙', 'menu', NULL, 'TEST_CO');
|
|
|
|
-- API 호출 시 순서 확인 (menu > table > global)
|
|
```
|
|
|
|
### 3. 멀티테넌시 검증 (필수!)
|
|
|
|
#### 3.1 회사별 데이터 격리 확인
|
|
|
|
```sql
|
|
-- 회사 A 규칙 생성
|
|
INSERT INTO numbering_rules (rule_id, rule_name, scope_type, table_name, company_code)
|
|
VALUES ('RULE_A', '회사A규칙', 'table', 'item_info', 'COMPANY_A');
|
|
|
|
-- 회사 B 규칙 생성
|
|
INSERT INTO numbering_rules (rule_id, rule_name, scope_type, table_name, company_code)
|
|
VALUES ('RULE_B', '회사B규칙', 'table', 'item_info', 'COMPANY_B');
|
|
|
|
-- 회사 A로 로그인 → API 호출
|
|
-- 예상: RULE_A만 조회, RULE_B는 보이지 않음 ✅
|
|
|
|
-- 회사 B로 로그인 → API 호출
|
|
-- 예상: RULE_B만 조회, RULE_A는 보이지 않음 ✅
|
|
```
|
|
|
|
#### 3.2 최고 관리자 가시성 제한 확인
|
|
|
|
```sql
|
|
-- 최고 관리자 전용 규칙 생성
|
|
INSERT INTO numbering_rules (rule_id, rule_name, scope_type, table_name, company_code)
|
|
VALUES ('RULE_SUPER', '최고관리자규칙', 'global', NULL, '*');
|
|
|
|
-- 일반 회사로 로그인 → API 호출
|
|
-- 예상: RULE_SUPER는 보이지 않음 ✅ (company_code='*' 제외)
|
|
|
|
-- 최고 관리자로 로그인 → API 호출
|
|
-- 예상: 일반 회사 규칙들만 조회 (RULE_SUPER 제외) ✅
|
|
```
|
|
|
|
#### 3.3 company_code 필터링 로그 확인
|
|
|
|
```typescript
|
|
// 백엔드 로그에서 확인
|
|
logger.info("화면용 채번 규칙 조회 완료", {
|
|
companyCode: "COMPANY_A", // ✅ 로그에 회사 코드 기록
|
|
tableName: "item_info",
|
|
rowCount: 5,
|
|
});
|
|
|
|
// 최고 관리자 로그
|
|
logger.info("최고 관리자: 일반 회사 채번 규칙 조회 (company_code != '*')");
|
|
```
|
|
|
|
### 4. UI 검증
|
|
|
|
#### 4.1 화면관리 테스트
|
|
|
|
1. 화면 생성 (테이블: `item_info`)
|
|
2. 텍스트 필드 추가
|
|
3. 자동 입력 > 채번규칙 선택
|
|
4. **확인사항**:
|
|
- `table_name='item_info'`인 규칙 표시 ✅
|
|
- `scope_type='global'`인 규칙 표시 ✅
|
|
- 다른 테이블 규칙은 미표시 ✅
|
|
- **다른 회사 규칙은 미표시** ✅ (멀티테넌시)
|
|
|
|
#### 4.2 채번규칙 관리 테스트
|
|
|
|
1. 새 규칙 생성
|
|
2. 적용 범위 선택: "테이블별"
|
|
3. 테이블명 입력: `item_info`
|
|
4. 저장 → 화면관리에서 바로 표시 확인 ✅
|
|
|
|
#### 4.3 우선순위 테스트
|
|
|
|
1. 같은 테이블에 대해 3가지 scope_type 규칙 생성
|
|
2. 화면관리에서 조회 시 menu가 최상단에 표시 확인 ✅
|
|
|
|
---
|
|
|
|
## 🚨 예외 처리 및 엣지 케이스
|
|
|
|
### 1. 테이블명이 없는 화면
|
|
|
|
```typescript
|
|
// TextTypeConfigPanel.tsx
|
|
if (!screenTableName) {
|
|
logger.warn("화면에 테이블이 지정되지 않았습니다");
|
|
|
|
// global 규칙만 조회
|
|
const response = await getAvailableNumberingRules();
|
|
setNumberingRules(response.data || []);
|
|
return;
|
|
}
|
|
```
|
|
|
|
### 2. 규칙이 하나도 없는 경우
|
|
|
|
```typescript
|
|
if (numberingRules.length === 0) {
|
|
return (
|
|
<div className="text-center text-sm text-muted-foreground py-4">
|
|
사용 가능한 채번규칙이 없습니다.
|
|
<br />
|
|
채번규칙 관리에서 규칙을 먼저 생성해주세요.
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
### 3. 동일 우선순위에 여러 규칙
|
|
|
|
```sql
|
|
-- created_at DESC로 정렬되므로 최신 규칙 우선
|
|
ORDER BY
|
|
CASE scope_type
|
|
WHEN 'menu' THEN 1
|
|
WHEN 'table' THEN 2
|
|
WHEN 'global' THEN 3
|
|
END,
|
|
created_at DESC -- 같은 scope_type이면 최신 규칙 우선
|
|
```
|
|
|
|
### 4. 최고 관리자 특별 처리
|
|
|
|
```typescript
|
|
// company_code="*"인 경우 모든 규칙 조회 가능
|
|
if (companyCode === "*") {
|
|
// 모든 회사의 규칙 표시 (멀티테넌시 예외)
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 📊 성능 최적화
|
|
|
|
### 1. 인덱스 전략
|
|
|
|
```sql
|
|
-- 복합 인덱스로 WHERE + ORDER BY 최적화
|
|
CREATE INDEX idx_numbering_rules_scope_table
|
|
ON numbering_rules(scope_type, table_name, company_code);
|
|
|
|
CREATE INDEX idx_numbering_rules_scope_menu
|
|
ON numbering_rules(scope_type, menu_objid, company_code);
|
|
```
|
|
|
|
### 2. 쿼리 플랜 확인
|
|
|
|
```sql
|
|
EXPLAIN ANALYZE
|
|
SELECT * FROM numbering_rules
|
|
WHERE company_code = 'TEST_CO'
|
|
AND (
|
|
(scope_type = 'table' AND table_name = 'item_info')
|
|
OR (scope_type = 'global' AND table_name IS NULL)
|
|
)
|
|
ORDER BY
|
|
CASE scope_type
|
|
WHEN 'menu' THEN 1
|
|
WHEN 'table' THEN 2
|
|
WHEN 'global' THEN 3
|
|
END;
|
|
|
|
-- Index Scan 확인 (Seq Scan이면 인덱스 추가 필요)
|
|
```
|
|
|
|
### 3. 캐싱 전략 (향후 고려)
|
|
|
|
```typescript
|
|
// 자주 조회되는 규칙은 메모리 캐싱
|
|
const ruleCache = new Map<string, NumberingRuleConfig[]>();
|
|
|
|
async function getAvailableRulesWithCache(
|
|
tableName: string
|
|
): Promise<NumberingRuleConfig[]> {
|
|
const cacheKey = `rules:${tableName}`;
|
|
|
|
if (ruleCache.has(cacheKey)) {
|
|
return ruleCache.get(cacheKey)!;
|
|
}
|
|
|
|
const rules = await getAvailableRulesForScreen(tableName);
|
|
ruleCache.set(cacheKey, rules);
|
|
|
|
return rules;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 📅 구현 일정
|
|
|
|
| Phase | 작업 내용 | 예상 시간 | 담당자 |
|
|
| -------- | --------------------- | -------------- | -------- |
|
|
| Phase 1 | DB 마이그레이션 | 30분 | Backend |
|
|
| Phase 2 | 백엔드 API 수정 | 1시간 | Backend |
|
|
| Phase 3 | 프론트 API 클라이언트 | 30분 | Frontend |
|
|
| Phase 4 | 화면관리 UI 수정 | 30분 | Frontend |
|
|
| Phase 5 | 채번규칙 UI 수정 | 30분 | Frontend |
|
|
| 검증 | 통합 테스트 | 1시간 | All |
|
|
| **총계** | | **4시간 30분** | |
|
|
|
|
---
|
|
|
|
## 🔄 하위 호환성
|
|
|
|
### 기존 기능 유지
|
|
|
|
1. ✅ `getAvailableNumberingRules()` 함수 유지 (메뉴 기반)
|
|
2. ✅ 기존 `scope_type='menu'` 규칙 정상 동작
|
|
3. ✅ 채번규칙 관리 화면 정상 동작
|
|
|
|
### 마이그레이션 영향
|
|
|
|
- ⚠️ `scope_type='global'` + `table_name` 있는 규칙 → `'table'`로 자동 변경
|
|
- ✅ 기존 동작 유지 (자동 마이그레이션)
|
|
- ✅ 사용자 재설정 불필요
|
|
|
|
---
|
|
|
|
## 📖 사용자 가이드
|
|
|
|
### 규칙 생성 시 권장사항
|
|
|
|
#### 언제 global을 사용하나요?
|
|
|
|
- 회사 전체에서 공통으로 사용하는 채번 규칙
|
|
- 예: "공지사항 번호", "공통 문서 번호"
|
|
|
|
#### 언제 table을 사용하나요? (권장)
|
|
|
|
- 특정 테이블의 데이터에 적용되는 규칙
|
|
- 예: `item_info` 테이블의 "품목 코드"
|
|
- **대부분의 경우 이 방식 사용**
|
|
|
|
#### 언제 menu를 사용하나요?
|
|
|
|
- 같은 테이블이라도 메뉴별로 다른 채번 방식
|
|
- 예: "영업팀 품목 코드" vs "구매팀 품목 코드"
|
|
|
|
---
|
|
|
|
## 🎉 기대 효과
|
|
|
|
### 1. 사용자 경험 개선
|
|
|
|
- ✅ 화면관리에서 채번규칙이 자동으로 표시
|
|
- ✅ 메뉴 구조를 몰라도 규칙 설정 가능
|
|
- ✅ 같은 테이블 화면에 규칙 재사용 자동
|
|
|
|
### 2. 유지보수성 향상
|
|
|
|
- ✅ 메뉴 구조 변경 시 규칙 재설정 불필요
|
|
- ✅ 테이블 중심 설계로 직관적
|
|
- ✅ 코드 복잡도 감소
|
|
|
|
### 3. 확장성 확보
|
|
|
|
- ✅ 향후 scope_type 추가 가능
|
|
- ✅ 다중 테이블 지원 가능
|
|
- ✅ 조건부 규칙 확장 가능
|
|
|
|
---
|
|
|
|
## 📞 연락처
|
|
|
|
- **작성자**: 개발팀
|
|
- **작성일**: 2025-11-08
|
|
- **버전**: 1.0.0
|
|
- **상태**: 계획 수립 완료 ✅
|
|
|
|
---
|
|
|
|
## 다음 단계
|
|
|
|
1. ✅ 계획서 검토 및 승인
|
|
2. ⬜ Phase 1 실행 (DB 마이그레이션)
|
|
3. ⬜ Phase 2 실행 (백엔드 수정)
|
|
4. ⬜ Phase 3-5 실행 (프론트엔드 수정)
|
|
5. ⬜ 통합 테스트
|
|
6. ⬜ 운영 배포
|
|
|
|
**시작 준비 완료!** 🚀
|
|
|
|
---
|
|
|
|
## 🔒 멀티테넌시 보안 최종 확인
|
|
|
|
### ✅ 완벽하게 적용됨
|
|
|
|
#### 1. **데이터베이스 레벨**
|
|
|
|
```sql
|
|
-- ✅ company_code 컬럼 필수 (NOT NULL)
|
|
-- ✅ 외래키 제약조건 (company_info 참조)
|
|
-- ✅ 복합 인덱스에 company_code 포함
|
|
CREATE INDEX idx_numbering_rules_scope_table
|
|
ON numbering_rules(scope_type, table_name, company_code);
|
|
```
|
|
|
|
#### 2. **API 레벨**
|
|
|
|
```typescript
|
|
// ✅ 일반 회사: WHERE company_code = $1
|
|
WHERE company_code = $1
|
|
AND (scope_type = 'table' AND table_name = $2)
|
|
|
|
// ✅ 최고 관리자: WHERE company_code != '*'
|
|
// (일반 회사 데이터만 조회, 최고 관리자 전용 데이터 제외)
|
|
WHERE company_code != '*'
|
|
AND (scope_type = 'table' AND table_name = $2)
|
|
|
|
// ✅ 파트 조회: WHERE company_code = $2
|
|
WHERE rule_id = $1 AND company_code = $2
|
|
```
|
|
|
|
#### 3. **로깅 레벨**
|
|
|
|
```typescript
|
|
// ✅ 모든 로그에 companyCode 포함 (감사 추적)
|
|
logger.info("화면용 채번 규칙 조회 완료", {
|
|
companyCode, // 필수!
|
|
tableName,
|
|
rowCount,
|
|
});
|
|
```
|
|
|
|
#### 4. **검증 레벨**
|
|
|
|
```sql
|
|
-- ✅ 회사 A 규칙은 회사 B에서 절대 안 보임
|
|
-- ✅ company_code='*' 규칙은 일반 회사에서 안 보임
|
|
-- ✅ 로그에 회사 코드 기록으로 추적 가능
|
|
```
|
|
|
|
### 🛡️ 보안 원칙 준수
|
|
|
|
1. **완전한 격리**: 회사별 데이터 100% 격리
|
|
2. **최고 관리자 예외**: `company_code='*'` 데이터는 최고 관리자 전용
|
|
3. **감사 추적**: 모든 조회에 companyCode 로깅
|
|
4. **성능 최적화**: 인덱스에 company_code 포함
|
|
5. **데이터 무결성**: 외래키 제약조건으로 보장
|
|
|
|
### ⚠️ 주의사항
|
|
|
|
- ❌ 절대 `company_code` 필터 누락 금지
|
|
- ❌ 클라이언트에서 `company_code` 전달 금지 (서버에서만 사용)
|
|
- ❌ SQL 인젝션 방지 (파라미터 바인딩 필수)
|
|
- ✅ 모든 쿼리에 `company_code` 조건 포함
|
|
- ✅ 로그에 `companyCode` 필수 기록
|
|
|
|
**멀티테넌시가 완벽하게 적용되었습니다!** 🔐
|