This commit is contained in:
parent
e305e78155
commit
dfd26e1933
|
|
@ -227,6 +227,312 @@ class NumberingRuleService {
|
|||
);
|
||||
return result.rows[0].current_sequence;
|
||||
}
|
||||
|
||||
/**
|
||||
* 카운터를 특정 값 이상으로 동기화 (GREATEST 사용)
|
||||
* 테이블 내 실제 최대값이 카운터보다 높을 때 카운터를 맞춰줌
|
||||
*/
|
||||
private async setSequenceForPrefix(
|
||||
client: any,
|
||||
ruleId: string,
|
||||
companyCode: string,
|
||||
prefixKey: string,
|
||||
targetSequence: number
|
||||
): Promise<number> {
|
||||
const result = await client.query(
|
||||
`INSERT INTO numbering_rule_sequences (rule_id, company_code, prefix_key, current_sequence, last_allocated_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
ON CONFLICT (rule_id, company_code, prefix_key)
|
||||
DO UPDATE SET current_sequence = GREATEST(numbering_rule_sequences.current_sequence, $4),
|
||||
last_allocated_at = NOW()
|
||||
RETURNING current_sequence`,
|
||||
[ruleId, companyCode, prefixKey, targetSequence]
|
||||
);
|
||||
return result.rows[0].current_sequence;
|
||||
}
|
||||
|
||||
/**
|
||||
* 대상 테이블에서 해당 회사의 최대 시퀀스 번호를 조회
|
||||
* 코드의 prefix/suffix 패턴을 기반으로 sequence 부분만 추출하여 MAX 계산
|
||||
*/
|
||||
private async getMaxSequenceFromTable(
|
||||
client: any,
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
codePrefix: string,
|
||||
codeSuffix: string,
|
||||
seqLength: number,
|
||||
companyCode: string
|
||||
): Promise<number> {
|
||||
try {
|
||||
// 테이블에 company_code 컬럼이 있는지 확인
|
||||
const colCheck = await client.query(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||
[tableName]
|
||||
);
|
||||
const hasCompanyCode = colCheck.rows.length > 0;
|
||||
|
||||
// 대상 컬럼 존재 여부 확인
|
||||
const targetColCheck = await client.query(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = $1 AND column_name = $2`,
|
||||
[tableName, columnName]
|
||||
);
|
||||
if (targetColCheck.rows.length === 0) {
|
||||
logger.warn(`getMaxSequenceFromTable: 컬럼 없음 ${tableName}.${columnName}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// prefix와 suffix 사이의 sequence 부분을 추출하기 위한 위치 계산
|
||||
const prefixLen = codePrefix.length;
|
||||
const seqStart = prefixLen + 1; // SQL SUBSTRING은 1-based
|
||||
|
||||
// LIKE 패턴: prefix + N자리 숫자 + suffix
|
||||
const likePattern = codePrefix + "%" + codeSuffix;
|
||||
|
||||
let sql: string;
|
||||
let params: any[];
|
||||
|
||||
if (hasCompanyCode && companyCode !== "*") {
|
||||
sql = `
|
||||
SELECT MAX(
|
||||
CAST(SUBSTRING("${columnName}" FROM $1 FOR $2) AS INTEGER)
|
||||
) as max_seq
|
||||
FROM "${tableName}"
|
||||
WHERE "${columnName}" LIKE $3
|
||||
AND company_code = $4
|
||||
AND LENGTH("${columnName}") = $5
|
||||
AND SUBSTRING("${columnName}" FROM $1 FOR $2) ~ '^[0-9]+$'
|
||||
`;
|
||||
params = [seqStart, seqLength, likePattern, companyCode, prefixLen + seqLength + codeSuffix.length];
|
||||
} else {
|
||||
sql = `
|
||||
SELECT MAX(
|
||||
CAST(SUBSTRING("${columnName}" FROM $1 FOR $2) AS INTEGER)
|
||||
) as max_seq
|
||||
FROM "${tableName}"
|
||||
WHERE "${columnName}" LIKE $3
|
||||
AND LENGTH("${columnName}") = $4
|
||||
AND SUBSTRING("${columnName}" FROM $1 FOR $2) ~ '^[0-9]+$'
|
||||
`;
|
||||
params = [seqStart, seqLength, likePattern, prefixLen + seqLength + codeSuffix.length];
|
||||
}
|
||||
|
||||
const result = await client.query(sql, params);
|
||||
const maxSeq = result.rows[0]?.max_seq ?? 0;
|
||||
|
||||
logger.info("getMaxSequenceFromTable 결과", {
|
||||
tableName, columnName, codePrefix, codeSuffix,
|
||||
seqLength, companyCode, maxSeq,
|
||||
});
|
||||
|
||||
return maxSeq;
|
||||
} catch (error: any) {
|
||||
logger.warn("getMaxSequenceFromTable 실패 (카운터 폴백)", {
|
||||
tableName, columnName, error: error.message,
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 규칙의 파트 구성에서 sequence 파트 전후의 prefix/suffix를 계산
|
||||
* allocateCode/previewCode에서 비-sequence 파트 값이 이미 계산된 후 호출
|
||||
*/
|
||||
private buildCodePrefixSuffix(
|
||||
partValues: string[],
|
||||
sortedParts: any[],
|
||||
globalSeparator: string
|
||||
): { prefix: string; suffix: string; seqIndex: number; seqLength: number } | null {
|
||||
const seqIndex = sortedParts.findIndex((p: any) => p.partType === "sequence");
|
||||
if (seqIndex === -1) return null;
|
||||
|
||||
const seqLength = sortedParts[seqIndex].autoConfig?.sequenceLength || 3;
|
||||
|
||||
// prefix: sequence 파트 이전의 모든 파트값 + 구분자
|
||||
let prefix = "";
|
||||
for (let i = 0; i < seqIndex; i++) {
|
||||
prefix += partValues[i];
|
||||
const sep = sortedParts[i].separatorAfter ?? sortedParts[i].autoConfig?.separatorAfter ?? globalSeparator;
|
||||
prefix += sep;
|
||||
}
|
||||
|
||||
// suffix: sequence 파트 이후의 모든 파트값 + 구분자
|
||||
let suffix = "";
|
||||
for (let i = seqIndex + 1; i < partValues.length; i++) {
|
||||
const sep = sortedParts[i - 1].separatorAfter ?? sortedParts[i - 1].autoConfig?.separatorAfter ?? globalSeparator;
|
||||
if (i === seqIndex + 1) {
|
||||
// sequence 파트 바로 뒤 구분자
|
||||
const seqSep = sortedParts[seqIndex].separatorAfter ?? sortedParts[seqIndex].autoConfig?.separatorAfter ?? globalSeparator;
|
||||
suffix += seqSep;
|
||||
}
|
||||
suffix += partValues[i];
|
||||
if (i < partValues.length - 1) {
|
||||
const nextSep = sortedParts[i].separatorAfter ?? sortedParts[i].autoConfig?.separatorAfter ?? globalSeparator;
|
||||
suffix += nextSep;
|
||||
}
|
||||
}
|
||||
|
||||
return { prefix, suffix, seqIndex, seqLength };
|
||||
}
|
||||
|
||||
/**
|
||||
* 비-sequence 파트의 값을 계산하여 prefix/suffix 패턴 구축에 사용
|
||||
* sequence 파트는 빈 문자열로 반환 (이후 buildCodePrefixSuffix에서 처리)
|
||||
*/
|
||||
private async computeNonSequenceValues(
|
||||
rule: NumberingRuleConfig,
|
||||
formData?: Record<string, any>
|
||||
): Promise<string[]> {
|
||||
const sortedParts = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
|
||||
return Promise.all(sortedParts.map(async (part: any) => {
|
||||
if (part.partType === "sequence") return "";
|
||||
if (part.generationMethod === "manual") return "";
|
||||
|
||||
const autoConfig = part.autoConfig || {};
|
||||
|
||||
switch (part.partType) {
|
||||
case "text":
|
||||
return autoConfig.textValue || "TEXT";
|
||||
|
||||
case "number": {
|
||||
const length = autoConfig.numberLength || 3;
|
||||
const value = autoConfig.numberValue || 1;
|
||||
return String(value).padStart(length, "0");
|
||||
}
|
||||
|
||||
case "date": {
|
||||
const dateFormat = autoConfig.dateFormat || "YYYYMMDD";
|
||||
if (autoConfig.useColumnValue && autoConfig.sourceColumnName && formData) {
|
||||
const columnValue = formData[autoConfig.sourceColumnName];
|
||||
if (columnValue) {
|
||||
const dateValue = columnValue instanceof Date ? columnValue : new Date(columnValue);
|
||||
if (!isNaN(dateValue.getTime())) {
|
||||
return this.formatDate(dateValue, dateFormat);
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.formatDate(new Date(), dateFormat);
|
||||
}
|
||||
|
||||
case "category": {
|
||||
const categoryKey = autoConfig.categoryKey;
|
||||
const categoryMappings = autoConfig.categoryMappings || [];
|
||||
if (!categoryKey || !formData) return "";
|
||||
|
||||
const colName = categoryKey.includes(".") ? categoryKey.split(".")[1] : categoryKey;
|
||||
const selectedValue = formData[colName];
|
||||
if (!selectedValue) return "";
|
||||
|
||||
const selectedValueStr = String(selectedValue);
|
||||
let mapping = categoryMappings.find((m: any) => {
|
||||
if (m.categoryValueId?.toString() === selectedValueStr) return true;
|
||||
if (m.categoryValueCode && m.categoryValueCode === selectedValueStr) return true;
|
||||
if (m.categoryValueLabel === selectedValueStr) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!mapping) {
|
||||
try {
|
||||
const pool = getPool();
|
||||
const [ct, cc] = categoryKey.includes(".") ? categoryKey.split(".") : [categoryKey, categoryKey];
|
||||
const cvResult = await pool.query(
|
||||
`SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
|
||||
[ct, cc, selectedValueStr]
|
||||
);
|
||||
if (cvResult.rows.length > 0) {
|
||||
mapping = categoryMappings.find((m: any) => {
|
||||
if (m.categoryValueId?.toString() === String(cvResult.rows[0].value_id)) return true;
|
||||
if (m.categoryValueLabel === cvResult.rows[0].value_label) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
return mapping?.format || "";
|
||||
}
|
||||
|
||||
case "reference": {
|
||||
const refColumn = autoConfig.referenceColumnName;
|
||||
if (refColumn && formData && formData[refColumn]) {
|
||||
return String(formData[refColumn]);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 대상 테이블 기반으로 실제 최대 시퀀스를 확인하고,
|
||||
* 카운터와 비교하여 더 높은 값 + 1을 반환
|
||||
*/
|
||||
private async resolveNextSequence(
|
||||
client: any,
|
||||
rule: NumberingRuleConfig,
|
||||
companyCode: string,
|
||||
ruleId: string,
|
||||
prefixKey: string,
|
||||
formData?: Record<string, any>
|
||||
): Promise<number> {
|
||||
// 1. 현재 저장된 카운터 조회
|
||||
const currentCounter = await this.getSequenceForPrefix(
|
||||
client, ruleId, companyCode, prefixKey
|
||||
);
|
||||
|
||||
let baseSequence = currentCounter;
|
||||
|
||||
// 2. 규칙에 tableName/columnName이 설정되어 있으면 대상 테이블에서 MAX 조회
|
||||
if (rule.tableName && rule.columnName) {
|
||||
try {
|
||||
const sortedParts = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
|
||||
const patternValues = await this.computeNonSequenceValues(rule, formData);
|
||||
const psInfo = this.buildCodePrefixSuffix(patternValues, sortedParts, rule.separator || "");
|
||||
|
||||
if (psInfo) {
|
||||
const maxFromTable = await this.getMaxSequenceFromTable(
|
||||
client, rule.tableName, rule.columnName,
|
||||
psInfo.prefix, psInfo.suffix, psInfo.seqLength, companyCode
|
||||
);
|
||||
|
||||
if (maxFromTable > baseSequence) {
|
||||
logger.info("테이블 내 최대값이 카운터보다 높음 → 동기화", {
|
||||
ruleId, companyCode, currentCounter, maxFromTable,
|
||||
});
|
||||
baseSequence = maxFromTable;
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.warn("테이블 기반 MAX 조회 실패, 카운터 기반 폴백", {
|
||||
ruleId, error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 다음 시퀀스 = base + 1
|
||||
const nextSequence = baseSequence + 1;
|
||||
|
||||
// 4. 카운터를 동기화 (GREATEST 사용)
|
||||
await this.setSequenceForPrefix(client, ruleId, companyCode, prefixKey, nextSequence);
|
||||
|
||||
// 5. 호환성을 위해 numbering_rules.current_sequence도 업데이트
|
||||
await client.query(
|
||||
"UPDATE numbering_rules SET current_sequence = GREATEST(COALESCE(current_sequence, 0), $3) WHERE rule_id = $1 AND company_code = $2",
|
||||
[ruleId, companyCode, nextSequence]
|
||||
);
|
||||
|
||||
logger.info("resolveNextSequence 완료", {
|
||||
ruleId, companyCode, prefixKey, currentCounter, baseSequence, nextSequence,
|
||||
});
|
||||
|
||||
return nextSequence;
|
||||
}
|
||||
|
||||
/**
|
||||
* 규칙 목록 조회 (전체)
|
||||
*/
|
||||
|
|
@ -1087,13 +1393,41 @@ class NumberingRuleService {
|
|||
const rule = await this.getRuleById(ruleId, companyCode);
|
||||
if (!rule) throw new Error("규칙을 찾을 수 없습니다");
|
||||
|
||||
// prefix_key 기반 순번 조회
|
||||
// prefix_key 기반 순번 조회 + 테이블 내 최대값과 비교
|
||||
const prefixKey = await this.buildPrefixKey(rule, formData);
|
||||
const pool = getPool();
|
||||
const currentSeq = await this.getSequenceForPrefix(pool, ruleId, companyCode, prefixKey);
|
||||
|
||||
logger.info("미리보기: prefix_key 기반 순번 조회", {
|
||||
ruleId, prefixKey, currentSeq,
|
||||
// 대상 테이블에서 실제 최대 시퀀스 조회
|
||||
let baseSeq = currentSeq;
|
||||
if (rule.tableName && rule.columnName) {
|
||||
try {
|
||||
const sortedPartsForPattern = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
|
||||
const patternValues = await this.computeNonSequenceValues(rule, formData);
|
||||
const psInfo = this.buildCodePrefixSuffix(patternValues, sortedPartsForPattern, rule.separator || "");
|
||||
|
||||
if (psInfo) {
|
||||
const maxFromTable = await this.getMaxSequenceFromTable(
|
||||
pool, rule.tableName, rule.columnName,
|
||||
psInfo.prefix, psInfo.suffix, psInfo.seqLength, companyCode
|
||||
);
|
||||
|
||||
if (maxFromTable > baseSeq) {
|
||||
logger.info("미리보기: 테이블 내 최대값이 카운터보다 높음", {
|
||||
ruleId, companyCode, currentSeq, maxFromTable,
|
||||
});
|
||||
baseSeq = maxFromTable;
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.warn("미리보기: 테이블 기반 MAX 조회 실패, 카운터 기반 폴백", {
|
||||
ruleId, error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("미리보기: 순번 조회 완료", {
|
||||
ruleId, prefixKey, currentSeq, baseSeq,
|
||||
});
|
||||
|
||||
const parts = await Promise.all(rule.parts
|
||||
|
|
@ -1108,7 +1442,7 @@ class NumberingRuleService {
|
|||
switch (part.partType) {
|
||||
case "sequence": {
|
||||
const length = autoConfig.sequenceLength || 3;
|
||||
const nextSequence = currentSeq + 1;
|
||||
const nextSequence = baseSeq + 1;
|
||||
return String(nextSequence).padStart(length, "0");
|
||||
}
|
||||
|
||||
|
|
@ -1306,20 +1640,15 @@ class NumberingRuleService {
|
|||
const prefixKey = await this.buildPrefixKey(rule, formData);
|
||||
const hasSequence = rule.parts.some((p: any) => p.partType === "sequence");
|
||||
|
||||
// 순번이 있으면 prefix_key 기반으로 UPSERT하여 다음 순번 획득
|
||||
// 순번이 있으면 테이블 내 최대값과 카운터를 비교하여 다음 순번 결정
|
||||
let allocatedSequence = 0;
|
||||
if (hasSequence) {
|
||||
allocatedSequence = await this.incrementSequenceForPrefix(
|
||||
client, ruleId, companyCode, prefixKey
|
||||
);
|
||||
// 호환성을 위해 기존 current_sequence도 업데이트
|
||||
await client.query(
|
||||
"UPDATE numbering_rules SET current_sequence = current_sequence + 1 WHERE rule_id = $1 AND company_code = $2",
|
||||
[ruleId, companyCode]
|
||||
allocatedSequence = await this.resolveNextSequence(
|
||||
client, rule, companyCode, ruleId, prefixKey, formData
|
||||
);
|
||||
}
|
||||
|
||||
logger.info("allocateCode: prefix_key 기반 순번 할당", {
|
||||
logger.info("allocateCode: 테이블 기반 순번 할당", {
|
||||
ruleId, prefixKey, allocatedSequence,
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue