Merge remote-tracking branch 'upstream/main'
Some checks failed
Build and Push Images / build-and-push (push) Failing after 51s
Some checks failed
Build and Push Images / build-and-push (push) Failing after 51s
This commit is contained in:
@@ -823,6 +823,76 @@ export class EntityJoinService {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 콤마 구분 다중값 해결 (겸직 부서 등)
|
||||
* entity join이 NULL인데 소스값에 콤마가 있으면 개별 코드를 각각 조회해서 라벨로 변환
|
||||
*/
|
||||
async resolveCommaValues(
|
||||
data: Record<string, any>[],
|
||||
joinConfigs: EntityJoinConfig[]
|
||||
): Promise<Record<string, any>[]> {
|
||||
if (!data.length || !joinConfigs.length) return data;
|
||||
|
||||
for (const config of joinConfigs) {
|
||||
const sourceCol = config.sourceColumn;
|
||||
const displayCol = config.displayColumns?.[0] || config.displayColumn;
|
||||
if (!displayCol || displayCol === "none") continue;
|
||||
|
||||
const aliasCol = config.aliasColumn || `${sourceCol}_${displayCol}`;
|
||||
const labelCol = `${sourceCol}_label`;
|
||||
|
||||
const codesSet = new Set<string>();
|
||||
const rowsToResolve: number[] = [];
|
||||
|
||||
data.forEach((row, idx) => {
|
||||
const srcVal = row[sourceCol];
|
||||
if (!srcVal || typeof srcVal !== "string" || !srcVal.includes(",")) return;
|
||||
|
||||
const joinedVal = row[aliasCol] || row[labelCol];
|
||||
if (joinedVal && joinedVal !== "") return;
|
||||
|
||||
rowsToResolve.push(idx);
|
||||
srcVal.split(",").map((v: string) => v.trim()).filter(Boolean).forEach((code: string) => codesSet.add(code));
|
||||
});
|
||||
|
||||
if (codesSet.size === 0) continue;
|
||||
|
||||
const codes = Array.from(codesSet);
|
||||
const refCol = config.referenceColumn || "id";
|
||||
const placeholders = codes.map((_, i) => `$${i + 1}`).join(",");
|
||||
try {
|
||||
const result = await query<Record<string, any>>(
|
||||
`SELECT "${refCol}"::TEXT as _key, "${displayCol}"::TEXT as _label
|
||||
FROM ${config.referenceTable}
|
||||
WHERE "${refCol}"::TEXT IN (${placeholders})`,
|
||||
codes
|
||||
);
|
||||
|
||||
const labelMap = new Map<string, string>();
|
||||
result.forEach((r) => labelMap.set(r._key, r._label));
|
||||
|
||||
for (const idx of rowsToResolve) {
|
||||
const srcVal = data[idx][sourceCol] as string;
|
||||
const resolvedLabels = srcVal
|
||||
.split(",")
|
||||
.map((v: string) => v.trim())
|
||||
.filter(Boolean)
|
||||
.map((code: string) => labelMap.get(code) || code)
|
||||
.join(", ");
|
||||
|
||||
data[idx][aliasCol] = resolvedLabels;
|
||||
data[idx][labelCol] = resolvedLabels;
|
||||
}
|
||||
|
||||
logger.info(`콤마 구분 entity 값 해결: ${sourceCol} → ${codesSet.size}개 코드, ${rowsToResolve.length}개 행`);
|
||||
} catch (err) {
|
||||
logger.warn(`콤마 구분 entity 값 해결 실패: ${sourceCol}`, err);
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export const entityJoinService = new EntityJoinService();
|
||||
|
||||
@@ -235,6 +235,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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 규칙 목록 조회 (전체)
|
||||
*/
|
||||
@@ -1101,6 +1407,7 @@ class NumberingRuleService {
|
||||
const hasManualPart = rule.parts.some((p: any) => p.generationMethod === "manual");
|
||||
const skipSequenceLookup = hasManualPart && !manualInputValue;
|
||||
|
||||
// prefix_key 기반 순번 조회 + 테이블 내 최대값과 비교
|
||||
const manualValues = manualInputValue ? [manualInputValue] : undefined;
|
||||
const prefixKey = await this.buildPrefixKey(rule, formData, manualValues);
|
||||
const pool = getPool();
|
||||
@@ -1108,8 +1415,36 @@ class NumberingRuleService {
|
||||
? 0
|
||||
: await this.getSequenceForPrefix(pool, ruleId, companyCode, prefixKey);
|
||||
|
||||
logger.info("미리보기: prefix_key 기반 순번 조회", {
|
||||
ruleId, prefixKey, currentSeq, skipSequenceLookup,
|
||||
// 대상 테이블에서 실제 최대 시퀀스 조회
|
||||
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, skipSequenceLookup,
|
||||
});
|
||||
|
||||
const parts = await Promise.all(rule.parts
|
||||
@@ -1125,7 +1460,7 @@ class NumberingRuleService {
|
||||
case "sequence": {
|
||||
const length = autoConfig.sequenceLength || 3;
|
||||
const startFrom = autoConfig.startFrom || 1;
|
||||
const nextSequence = currentSeq + startFrom;
|
||||
const nextSequence = baseSeq + startFrom;
|
||||
return String(nextSequence).padStart(length, "0");
|
||||
}
|
||||
|
||||
@@ -1239,20 +1574,15 @@ class NumberingRuleService {
|
||||
const prefixKey = await this.buildPrefixKey(rule, formData, extractedManualValues);
|
||||
const hasSequence = rule.parts.some((p: any) => p.partType === "sequence");
|
||||
|
||||
// 3단계: 순번이 있으면 prefix_key 기반으로 UPSERT하여 다음 순번 획득
|
||||
// 3단계: 순번이 있으면 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: prefix_key + 테이블 기반 순번 할당", {
|
||||
ruleId, prefixKey, allocatedSequence, extractedManualValues,
|
||||
});
|
||||
|
||||
|
||||
@@ -3588,12 +3588,15 @@ export class TableManagementService {
|
||||
`✅ [executeJoinQuery] 조회 완료: ${dataResult?.length}개 행`
|
||||
);
|
||||
|
||||
const data = Array.isArray(dataResult) ? dataResult : [];
|
||||
let data = Array.isArray(dataResult) ? dataResult : [];
|
||||
const total =
|
||||
Array.isArray(countResult) && countResult.length > 0
|
||||
? Number((countResult[0] as any).total)
|
||||
: 0;
|
||||
|
||||
// 콤마 구분 다중값 후처리 (겸직 부서 등)
|
||||
data = await entityJoinService.resolveCommaValues(data, joinConfigs);
|
||||
|
||||
const queryTime = Date.now() - startTime;
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user