feat: Enhance MasterDetailExcelService with table alias for JOIN operations

- Added a new property `tableAlias` to distinguish between master ("m") and detail ("d") tables during JOIN operations.
- Updated the SELECT clause to include the appropriate table alias for master and detail tables.
- Improved the entity join clause construction to utilize the new table alias, ensuring clarity in SQL queries.
This commit is contained in:
kjs 2026-02-10 11:38:02 +09:00
parent 30ee36f881
commit 219f7724e7
6 changed files with 226 additions and 42 deletions

View File

@ -310,6 +310,7 @@ class MasterDetailExcelService {
sourceColumn: string;
alias: string;
displayColumn: string;
tableAlias: string; // "m" (마스터) 또는 "d" (디테일) - JOIN 시 소스 테이블 구분
}> = [];
// SELECT 절 구성
@ -332,6 +333,7 @@ class MasterDetailExcelService {
sourceColumn: fkColumn.sourceColumn,
alias,
displayColumn,
tableAlias: "m", // 마스터 테이블에서 조인
});
selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`);
} else {
@ -360,6 +362,7 @@ class MasterDetailExcelService {
sourceColumn: fkColumn.sourceColumn,
alias,
displayColumn,
tableAlias: "d", // 디테일 테이블에서 조인
});
selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`);
} else {
@ -373,9 +376,9 @@ class MasterDetailExcelService {
const selectClause = selectParts.join(", ");
// 엔티티 조인 절 구성
// 엔티티 조인 절 구성 (마스터/디테일 테이블 alias 구분)
const entityJoinClauses = entityJoins.map(ej =>
`LEFT JOIN "${ej.refTable}" ${ej.alias} ON m."${ej.sourceColumn}" = ${ej.alias}."${ej.refColumn}"`
`LEFT JOIN "${ej.refTable}" ${ej.alias} ON ${ej.tableAlias}."${ej.sourceColumn}" = ${ej.alias}."${ej.refColumn}"`
).join("\n ");
// WHERE 절 구성

View File

@ -1371,40 +1371,67 @@ class TableCategoryValueService {
const pool = getPool();
// 동적으로 파라미터 플레이스홀더 생성
const placeholders = valueCodes.map((_, i) => `$${i + 1}`).join(", ");
const n = valueCodes.length;
// 첫 번째 쿼리용 플레이스홀더: $1 ~ $n
const placeholders1 = valueCodes.map((_, i) => `$${i + 1}`).join(", ");
let query: string;
let params: any[];
if (companyCode === "*") {
// 최고 관리자: 모든 카테고리 값 조회
// 최고 관리자: 두 테이블 모두에서 조회 (UNION으로 병합)
// 두 번째 쿼리용 플레이스홀더: $n+1 ~ $2n
const placeholders2 = valueCodes.map((_, i) => `$${n + i + 1}`).join(", ");
query = `
SELECT value_code, value_label FROM (
SELECT value_code, value_label
FROM table_column_category_values
WHERE value_code IN (${placeholders})
WHERE value_code IN (${placeholders1})
AND is_active = true
UNION ALL
SELECT value_code, value_label
FROM category_values
WHERE value_code IN (${placeholders2})
AND is_active = true
) combined
`;
params = valueCodes;
params = [...valueCodes, ...valueCodes];
} else {
// 일반 회사: 자신의 카테고리 값 + 공통 카테고리 값 조회
// 일반 회사: 두 테이블에서 자신의 카테고리 값 + 공통 카테고리 값 조회
// 첫 번째: $1~$n (valueCodes), $n+1 (companyCode)
// 두 번째: $n+2~$2n+1 (valueCodes), $2n+2 (companyCode)
const companyIdx1 = n + 1;
const placeholders2 = valueCodes.map((_, i) => `$${n + 1 + i + 1}`).join(", ");
const companyIdx2 = 2 * n + 2;
query = `
SELECT value_code, value_label FROM (
SELECT value_code, value_label
FROM table_column_category_values
WHERE value_code IN (${placeholders})
WHERE value_code IN (${placeholders1})
AND is_active = true
AND (company_code = $${valueCodes.length + 1} OR company_code = '*')
AND (company_code = $${companyIdx1} OR company_code = '*')
UNION ALL
SELECT value_code, value_label
FROM category_values
WHERE value_code IN (${placeholders2})
AND is_active = true
AND (company_code = $${companyIdx2} OR company_code = '*')
) combined
`;
params = [...valueCodes, companyCode];
params = [...valueCodes, companyCode, ...valueCodes, companyCode];
}
const result = await pool.query(query, params);
// { [code]: label } 형태로 변환
// { [code]: label } 형태로 변환 (중복 시 첫 번째 결과 우선)
const labels: Record<string, string> = {};
for (const row of result.rows) {
if (!labels[row.value_code]) {
labels[row.value_code] = row.value_label;
}
}
logger.info(`카테고리 라벨 ${Object.keys(labels).length}개 조회 완료`, { companyCode });

View File

@ -172,6 +172,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
selectedData: eventSelectedData,
selectedIds,
isCreateMode, // 🆕 복사 모드 플래그 (true면 editData가 있어도 originalData 설정 안 함)
fieldMappings, // 🆕 필드 매핑 정보 (명시적 매핑이 있으면 모든 매핑된 필드 전달)
} = event.detail;
// 🆕 모달 열린 시간 기록
@ -267,6 +268,17 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
parentData.company_code = rawParentData.company_code;
}
// 🆕 명시적 필드 매핑이 있으면 매핑된 타겟 필드를 모두 보존
// (버튼 설정에서 fieldMappings로 지정한 필드는 link 필드가 아니어도 전달)
const mappedTargetFields = new Set<string>();
if (fieldMappings && Array.isArray(fieldMappings)) {
for (const mapping of fieldMappings) {
if (mapping.targetField) {
mappedTargetFields.add(mapping.targetField);
}
}
}
// parentDataMapping에 정의된 필드만 전달
for (const mapping of parentDataMapping) {
const sourceValue = rawParentData[mapping.sourceColumn];
@ -275,8 +287,17 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
}
}
// parentDataMapping이 비어있으면 연결 필드 자동 감지 (equipment_code, xxx_code, xxx_id 패턴)
if (parentDataMapping.length === 0) {
// 🆕 명시적 필드 매핑이 있으면 해당 필드를 모두 전달
if (mappedTargetFields.size > 0) {
for (const [key, value] of Object.entries(rawParentData)) {
if (mappedTargetFields.has(key) && value !== undefined && value !== null) {
parentData[key] = value;
}
}
}
// parentDataMapping이 비어있고 명시적 필드 매핑도 없으면 연결 필드 자동 감지
if (parentDataMapping.length === 0 && mappedTargetFields.size === 0) {
const linkFieldPatterns = ["_code", "_id"];
const excludeFields = [
"id",
@ -293,6 +314,29 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
if (value === undefined || value === null) continue;
// 연결 필드 패턴 확인
const isLinkField = linkFieldPatterns.some((pattern) => key.endsWith(pattern));
if (isLinkField) {
parentData[key] = value;
}
}
} else if (parentDataMapping.length === 0 && mappedTargetFields.size > 0) {
// 🆕 명시적 매핑이 있어도 연결 필드(_code, _id)는 추가로 전달
const linkFieldPatterns = ["_code", "_id"];
const excludeFields = [
"id",
"company_code",
"created_date",
"updated_date",
"created_at",
"updated_at",
"writer",
];
for (const [key, value] of Object.entries(rawParentData)) {
if (excludeFields.includes(key)) continue;
if (parentData[key] !== undefined) continue; // 이미 매핑된 필드는 스킵
if (value === undefined || value === null) continue;
const isLinkField = linkFieldPatterns.some((pattern) => key.endsWith(pattern));
if (isLinkField) {
parentData[key] = value;

View File

@ -23,15 +23,26 @@ import { AutoGenerationConfig } from "@/types/screen";
import { previewNumberingCode } from "@/lib/api/numberingRule";
// 형식별 입력 마스크 및 검증 패턴
const FORMAT_PATTERNS: Record<V2InputFormat, { pattern: RegExp; placeholder: string }> = {
none: { pattern: /.*/, placeholder: "" },
email: { pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, placeholder: "example@email.com" },
tel: { pattern: /^\d{2,3}-\d{3,4}-\d{4}$/, placeholder: "010-1234-5678" },
url: { pattern: /^https?:\/\/.+/, placeholder: "https://example.com" },
currency: { pattern: /^[\d,]+$/, placeholder: "1,000,000" },
biz_no: { pattern: /^\d{3}-\d{2}-\d{5}$/, placeholder: "123-45-67890" },
const FORMAT_PATTERNS: Record<V2InputFormat, { pattern: RegExp; placeholder: string; errorMessage: string }> = {
none: { pattern: /.*/, placeholder: "", errorMessage: "" },
email: { pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, placeholder: "example@email.com", errorMessage: "올바른 이메일 형식이 아닙니다" },
tel: { pattern: /^\d{2,3}-\d{3,4}-\d{4}$/, placeholder: "010-1234-5678", errorMessage: "올바른 전화번호 형식이 아닙니다" },
url: { pattern: /^https?:\/\/.+/, placeholder: "https://example.com", errorMessage: "올바른 URL 형식이 아닙니다 (https://로 시작)" },
currency: { pattern: /^[\d,]+$/, placeholder: "1,000,000", errorMessage: "숫자만 입력 가능합니다" },
biz_no: { pattern: /^\d{3}-\d{2}-\d{5}$/, placeholder: "123-45-67890", errorMessage: "올바른 사업자번호 형식이 아닙니다" },
};
// 형식 검증 함수 (외부에서도 사용 가능)
export function validateInputFormat(value: string, format: V2InputFormat): { isValid: boolean; errorMessage: string } {
if (!value || value.trim() === "" || format === "none") {
return { isValid: true, errorMessage: "" };
}
const formatConfig = FORMAT_PATTERNS[format];
if (!formatConfig) return { isValid: true, errorMessage: "" };
const isValid = formatConfig.pattern.test(value);
return { isValid, errorMessage: isValid ? "" : formatConfig.errorMessage };
}
// 통화 형식 변환
function formatCurrency(value: string | number): string {
const num = typeof value === "string" ? parseFloat(value.replace(/,/g, "")) : value;
@ -70,8 +81,13 @@ const TextInput = forwardRef<
readonly?: boolean;
disabled?: boolean;
className?: string;
columnName?: string;
}
>(({ value, onChange, format = "none", placeholder, readonly, disabled, className }, ref) => {
>(({ value, onChange, format = "none", placeholder, readonly, disabled, className, columnName }, ref) => {
// 검증 상태
const [hasBlurred, setHasBlurred] = useState(false);
const [validationError, setValidationError] = useState<string>("");
// 형식에 따른 값 포맷팅
const formatValue = useCallback(
(val: string): string => {
@ -104,29 +120,101 @@ const TextInput = forwardRef<
newValue = formatTel(newValue);
}
// 입력 중 에러 표시 해제 (입력 중에는 관대하게)
if (hasBlurred && validationError) {
const { isValid } = validateInputFormat(newValue, format);
if (isValid) {
setValidationError("");
}
}
onChange?.(newValue);
},
[format, onChange],
[format, onChange, hasBlurred, validationError],
);
// blur 시 형식 검증
const handleBlur = useCallback(() => {
setHasBlurred(true);
const currentValue = value !== undefined && value !== null ? String(value) : "";
if (currentValue && format !== "none") {
const { isValid, errorMessage } = validateInputFormat(currentValue, format);
setValidationError(isValid ? "" : errorMessage);
} else {
setValidationError("");
}
}, [value, format]);
// 값 변경 시 검증 상태 업데이트
useEffect(() => {
if (hasBlurred) {
const currentValue = value !== undefined && value !== null ? String(value) : "";
if (currentValue && format !== "none") {
const { isValid, errorMessage } = validateInputFormat(currentValue, format);
setValidationError(isValid ? "" : errorMessage);
} else {
setValidationError("");
}
}
}, [value, format, hasBlurred]);
// 글로벌 폼 검증 이벤트 리스너 (저장 시 호출)
useEffect(() => {
if (format === "none" || !columnName) return;
const handleValidateForm = (event: CustomEvent) => {
const currentValue = value !== undefined && value !== null ? String(value) : "";
if (currentValue) {
const { isValid, errorMessage } = validateInputFormat(currentValue, format);
if (!isValid) {
setHasBlurred(true);
setValidationError(errorMessage);
// 검증 결과를 이벤트에 기록
if (event.detail?.errors) {
event.detail.errors.push({
columnName,
message: errorMessage,
});
}
}
}
};
window.addEventListener("validateFormInputs", handleValidateForm as EventListener);
return () => {
window.removeEventListener("validateFormInputs", handleValidateForm as EventListener);
};
}, [format, value, columnName]);
const displayValue = useMemo(() => {
if (value === undefined || value === null) return "";
return formatValue(String(value));
}, [value, formatValue]);
const inputPlaceholder = placeholder || FORMAT_PATTERNS[format].placeholder;
const hasError = hasBlurred && !!validationError;
return (
<div className="flex h-full w-full flex-col">
<Input
ref={ref}
type="text"
value={displayValue}
onChange={handleChange}
onBlur={handleBlur}
placeholder={inputPlaceholder}
readOnly={readonly}
disabled={disabled}
className={cn("h-full w-full", className)}
className={cn(
"h-full w-full",
hasError && "border-destructive focus-visible:ring-destructive",
className,
)}
/>
{hasError && (
<p className="text-destructive mt-1 text-[11px]">{validationError}</p>
)}
</div>
);
});
TextInput.displayName = "TextInput";
@ -678,6 +766,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
placeholder={config.placeholder}
readonly={readonly || (autoGeneration.enabled && hasGeneratedRef.current)}
disabled={disabled}
columnName={columnName}
/>
);
@ -835,9 +924,11 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
setAutoGeneratedValue(null);
onChange?.(v);
}}
format={config.format}
placeholder={config.placeholder}
readonly={readonly}
disabled={disabled}
columnName={columnName}
/>
);
}

View File

@ -121,8 +121,8 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
onChange={handleChange}
config={{
mode: config.mode || "dropdown",
// 🔧 카테고리 타입이면 source를 "category"로 설정
source: config.source || (isCategoryType ? "category" : "distinct"),
// 🔧 카테고리 타입이면 source를 무조건 "category"로 강제 (테이블 타입 관리 설정 우선)
source: isCategoryType ? "category" : (config.source || "distinct"),
multiple: config.multiple || false,
searchable: config.searchable ?? true,
placeholder: config.placeholder || "선택하세요",

View File

@ -541,6 +541,23 @@ export class ButtonActionExecutor {
return false;
}
// ✅ 입력 형식 검증 (이메일, 전화번호, URL 등)
const formatValidationDetail = { errors: [] as Array<{ columnName: string; message: string }> };
window.dispatchEvent(
new CustomEvent("validateFormInputs", {
detail: formatValidationDetail,
}),
);
// 약간의 대기 (이벤트 핸들러가 동기적으로 실행되지만 안전을 위해)
await new Promise((resolve) => setTimeout(resolve, 50));
if (formatValidationDetail.errors.length > 0) {
const errorMessages = formatValidationDetail.errors.map((e) => e.message);
console.log("❌ [handleSave] 입력 형식 검증 실패:", formatValidationDetail.errors);
toast.error(`입력 형식을 확인해주세요: ${errorMessages.join(", ")}`);
return false;
}
// 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용
if (onSave) {
try {
@ -3144,6 +3161,8 @@ export class ButtonActionExecutor {
editData: useAsEditData && isPassDataMode ? parentData : undefined,
splitPanelParentData: isPassDataMode ? parentData : undefined,
urlParams: dataSourceId ? { dataSourceId } : undefined,
// 🆕 필드 매핑 정보 전달 - ScreenModal에서 매핑된 필드를 필터링하지 않도록
fieldMappings: config.fieldMappings,
},
});