Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node

This commit is contained in:
kjs
2026-03-12 14:23:34 +09:00
99 changed files with 14205 additions and 1442 deletions

View File

@@ -108,6 +108,46 @@ export async function getUserMenus(
}
}
/**
* POP 메뉴 목록 조회
* [POP] 태그가 있는 L1 메뉴의 하위 active 메뉴를 반환
*/
export async function getPopMenus(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const userCompanyCode = req.user?.companyCode || "ILSHIN";
const userType = req.user?.userType;
const result = await AdminService.getPopMenuList({
userCompanyCode,
userType,
});
const response: ApiResponse<any> = {
success: true,
message: "POP 메뉴 목록 조회 성공",
data: result,
};
res.status(200).json(response);
} catch (error) {
logger.error("POP 메뉴 목록 조회 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "POP 메뉴 목록 조회 중 오류가 발생했습니다.",
error: {
code: "POP_MENU_LIST_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
* 메뉴 정보 조회
*/

View File

@@ -51,29 +51,24 @@ export class AuthController {
logger.debug(`로그인 사용자 정보: ${userInfo.userId} (${userInfo.companyCode})`);
// 메뉴 조회를 위한 공통 파라미터
const { AdminService } = await import("../services/adminService");
const paramMap = {
userId: loginResult.userInfo.userId,
userCompanyCode: loginResult.userInfo.companyCode || "ILSHIN",
userType: loginResult.userInfo.userType,
userLang: "ko",
};
// 사용자의 첫 번째 접근 가능한 메뉴 조회
let firstMenuPath: string | null = null;
try {
const { AdminService } = await import("../services/adminService");
const paramMap = {
userId: loginResult.userInfo.userId,
userCompanyCode: loginResult.userInfo.companyCode || "ILSHIN",
userType: loginResult.userInfo.userType,
userLang: "ko",
};
const menuList = await AdminService.getUserMenuList(paramMap);
logger.debug(`로그인 후 메뉴 조회: 총 ${menuList.length}개 메뉴`);
// 접근 가능한 첫 번째 메뉴 찾기
// 조건:
// 1. LEV (레벨)이 2 이상 (최상위 폴더 제외)
// 2. MENU_URL이 있고 비어있지 않음
// 3. 이미 PATH, SEQ로 정렬되어 있으므로 첫 번째로 찾은 것이 첫 번째 메뉴
const firstMenu = menuList.find((menu: any) => {
const level = menu.lev || menu.level;
const url = menu.menu_url || menu.url;
return level >= 2 && url && url.trim() !== "" && url !== "#";
});
@@ -94,6 +89,22 @@ export class AuthController {
useType: "접속",
}).catch(() => {});
// POP 랜딩 경로 조회
let popLandingPath: string | null = null;
try {
const popResult = await AdminService.getPopMenuList(paramMap);
if (popResult.landingMenu?.menu_url) {
popLandingPath = popResult.landingMenu.menu_url;
} else if (popResult.childMenus.length === 1) {
popLandingPath = popResult.childMenus[0].menu_url;
} else if (popResult.childMenus.length > 1) {
popLandingPath = "/pop";
}
logger.debug(`POP 랜딩 경로: ${popLandingPath}`);
} catch (popError) {
logger.warn("POP 메뉴 조회 중 오류 (무시):", popError);
}
res.status(200).json({
success: true,
message: "로그인 성공",
@@ -101,6 +112,7 @@ export class AuthController {
userInfo,
token: loginResult.token,
firstMenuPath,
popLandingPath,
},
});
} else {

View File

@@ -2,6 +2,7 @@ import { Router } from "express";
import {
getAdminMenus,
getUserMenus,
getPopMenus,
getMenuInfo,
saveMenu, // 메뉴 추가
updateMenu, // 메뉴 수정
@@ -40,6 +41,7 @@ router.use(authenticateToken);
// 메뉴 관련 API
router.get("/menus", getAdminMenus);
router.get("/user-menus", getUserMenus);
router.get("/pop-menus", getPopMenus);
router.get("/menus/:menuId", getMenuInfo);
router.post("/menus", saveMenu); // 메뉴 추가
router.post("/menus/:menuObjid/copy", copyMenu); // 메뉴 복사 (NEW!)

View File

@@ -17,6 +17,7 @@ interface AutoGenMappingInfo {
numberingRuleId: string;
targetColumn: string;
showResultModal?: boolean;
shareAcrossItems?: boolean;
}
interface HiddenMappingInfo {
@@ -182,6 +183,31 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
throw new Error(`유효하지 않은 테이블명: ${cardMapping.targetTable}`);
}
const allAutoGen = [
...(fieldMapping?.autoGenMappings ?? []),
...(cardMapping?.autoGenMappings ?? []),
];
// 일괄 채번: shareAcrossItems=true인 매핑은 루프 전 1회만 채번
const sharedCodes: Record<string, string> = {};
for (const ag of allAutoGen) {
if (!ag.shareAcrossItems) continue;
if (!ag.numberingRuleId || !ag.targetColumn) continue;
if (!isSafeIdentifier(ag.targetColumn)) continue;
try {
const code = await numberingRuleService.allocateCode(
ag.numberingRuleId, companyCode, { ...fieldValues, ...(items[0] ?? {}) },
);
sharedCodes[ag.targetColumn] = code;
generatedCodes.push({ targetColumn: ag.targetColumn, code, showResultModal: ag.showResultModal ?? false });
logger.info("[pop/execute-action] 일괄 채번 완료", {
ruleId: ag.numberingRuleId, targetColumn: ag.targetColumn, code,
});
} catch (err: any) {
logger.error("[pop/execute-action] 일괄 채번 실패", { ruleId: ag.numberingRuleId, error: err.message });
}
}
for (const item of items) {
const columns: string[] = ["company_code"];
const values: unknown[] = [companyCode];
@@ -225,26 +251,41 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
values.push(value);
}
const allAutoGen = [
...(fieldMapping?.autoGenMappings ?? []),
...(cardMapping?.autoGenMappings ?? []),
];
for (const ag of allAutoGen) {
if (!ag.numberingRuleId || !ag.targetColumn) continue;
if (!isSafeIdentifier(ag.targetColumn)) continue;
if (columns.includes(`"${ag.targetColumn}"`)) continue;
try {
const generatedCode = await numberingRuleService.allocateCode(
ag.numberingRuleId, companyCode, { ...fieldValues, ...item },
);
if (ag.shareAcrossItems && sharedCodes[ag.targetColumn]) {
columns.push(`"${ag.targetColumn}"`);
values.push(generatedCode);
generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false });
} catch (err: any) {
logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message });
values.push(sharedCodes[ag.targetColumn]);
} else if (!ag.shareAcrossItems) {
try {
const generatedCode = await numberingRuleService.allocateCode(
ag.numberingRuleId, companyCode, { ...fieldValues, ...item },
);
columns.push(`"${ag.targetColumn}"`);
values.push(generatedCode);
generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false });
} catch (err: any) {
logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message });
}
}
}
if (!columns.includes('"created_date"')) {
columns.push('"created_date"');
values.push(new Date().toISOString());
}
if (!columns.includes('"updated_date"')) {
columns.push('"updated_date"');
values.push(new Date().toISOString());
}
if (!columns.includes('"writer"') && userId) {
columns.push('"writer"');
values.push(userId);
}
if (columns.length > 1) {
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
await client.query(
@@ -292,8 +333,9 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
for (let i = 0; i < lookupValues.length; i++) {
const item = items[i] ?? {};
const resolved = resolveStatusValue("conditional", task.fixedValue ?? "", task.conditionalValue, item);
const autoUpdated = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : "";
await client.query(
`UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1 WHERE company_code = $2 AND "${pkColumn}" = $3`,
`UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1${autoUpdated} WHERE company_code = $2 AND "${pkColumn}" = $3`,
[resolved, companyCode, lookupValues[i]],
);
processedCount++;
@@ -311,9 +353,10 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
const caseSql = `CASE WHEN COALESCE("${task.compareColumn}"::numeric, 0) ${op} COALESCE("${task.compareWith}"::numeric, 0) THEN $1 ELSE $2 END`;
const autoUpdatedDb = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : "";
const placeholders = lookupValues.map((_, i) => `$${i + 4}`).join(", ");
await client.query(
`UPDATE "${task.targetTable}" SET "${task.targetColumn}" = ${caseSql} WHERE company_code = $3 AND "${pkColumn}" IN (${placeholders})`,
`UPDATE "${task.targetTable}" SET "${task.targetColumn}" = ${caseSql}${autoUpdatedDb} WHERE company_code = $3 AND "${pkColumn}" IN (${placeholders})`,
[thenVal, elseVal, companyCode, ...lookupValues],
);
processedCount += lookupValues.length;
@@ -325,7 +368,14 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
if (valSource === "linked") {
value = item[task.sourceField ?? ""] ?? null;
} else {
value = task.fixedValue ?? "";
const raw = task.fixedValue ?? "";
if (raw === "__CURRENT_USER__") {
value = userId;
} else if (raw === "__CURRENT_TIME__") {
value = new Date().toISOString();
} else {
value = raw;
}
}
let setSql: string;
@@ -341,8 +391,9 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
setSql = `"${task.targetColumn}" = $1`;
}
const autoUpdatedDate = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : "";
await client.query(
`UPDATE "${task.targetTable}" SET ${setSql} WHERE company_code = $2 AND "${pkColumn}" = $3`,
`UPDATE "${task.targetTable}" SET ${setSql}${autoUpdatedDate} WHERE company_code = $2 AND "${pkColumn}" = $3`,
[value, companyCode, lookupValues[i]],
);
processedCount++;
@@ -448,6 +499,31 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
throw new Error(`유효하지 않은 테이블명: ${cardMapping.targetTable}`);
}
const allAutoGen = [
...(fieldMapping?.autoGenMappings ?? []),
...(cardMapping?.autoGenMappings ?? []),
];
// 일괄 채번: shareAcrossItems=true인 매핑은 루프 전 1회만 채번
const sharedCodes: Record<string, string> = {};
for (const ag of allAutoGen) {
if (!ag.shareAcrossItems) continue;
if (!ag.numberingRuleId || !ag.targetColumn) continue;
if (!isSafeIdentifier(ag.targetColumn)) continue;
try {
const code = await numberingRuleService.allocateCode(
ag.numberingRuleId, companyCode, { ...fieldValues, ...(items[0] ?? {}) },
);
sharedCodes[ag.targetColumn] = code;
generatedCodes.push({ targetColumn: ag.targetColumn, code, showResultModal: ag.showResultModal ?? false });
logger.info("[pop/execute-action] 일괄 채번 완료", {
ruleId: ag.numberingRuleId, targetColumn: ag.targetColumn, code,
});
} catch (err: any) {
logger.error("[pop/execute-action] 일괄 채번 실패", { ruleId: ag.numberingRuleId, error: err.message });
}
}
for (const item of items) {
const columns: string[] = ["company_code"];
const values: unknown[] = [companyCode];
@@ -467,7 +543,6 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
}
}
// 숨은 필드 매핑 처리 (고정값 / JSON추출 / DB컬럼)
const allHidden = [
...(fieldMapping?.hiddenMappings ?? []),
...(cardMapping?.hiddenMappings ?? []),
@@ -494,37 +569,44 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
values.push(value);
}
// 채번 규칙 실행: field + cardList의 autoGenMappings에서 코드 발급
const allAutoGen = [
...(fieldMapping?.autoGenMappings ?? []),
...(cardMapping?.autoGenMappings ?? []),
];
for (const ag of allAutoGen) {
if (!ag.numberingRuleId || !ag.targetColumn) continue;
if (!isSafeIdentifier(ag.targetColumn)) continue;
if (columns.includes(`"${ag.targetColumn}"`)) continue;
try {
const generatedCode = await numberingRuleService.allocateCode(
ag.numberingRuleId,
companyCode,
{ ...fieldValues, ...item },
);
if (ag.shareAcrossItems && sharedCodes[ag.targetColumn]) {
columns.push(`"${ag.targetColumn}"`);
values.push(generatedCode);
generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false });
logger.info("[pop/execute-action] 채번 완료", {
ruleId: ag.numberingRuleId,
targetColumn: ag.targetColumn,
generatedCode,
});
} catch (err: any) {
logger.error("[pop/execute-action] 채번 실패", {
ruleId: ag.numberingRuleId,
error: err.message,
});
values.push(sharedCodes[ag.targetColumn]);
} else if (!ag.shareAcrossItems) {
try {
const generatedCode = await numberingRuleService.allocateCode(
ag.numberingRuleId, companyCode, { ...fieldValues, ...item },
);
columns.push(`"${ag.targetColumn}"`);
values.push(generatedCode);
generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false });
logger.info("[pop/execute-action] 채번 완료", {
ruleId: ag.numberingRuleId, targetColumn: ag.targetColumn, generatedCode,
});
} catch (err: any) {
logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message });
}
}
}
if (!columns.includes('"created_date"')) {
columns.push('"created_date"');
values.push(new Date().toISOString());
}
if (!columns.includes('"updated_date"')) {
columns.push('"updated_date"');
values.push(new Date().toISOString());
}
if (!columns.includes('"writer"') && userId) {
columns.push('"writer"');
values.push(userId);
}
if (columns.length > 1) {
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
const sql = `INSERT INTO "${cardMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`;
@@ -558,6 +640,19 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
values.push(fieldValues[sourceField] ?? null);
}
if (!columns.includes('"created_date"')) {
columns.push('"created_date"');
values.push(new Date().toISOString());
}
if (!columns.includes('"updated_date"')) {
columns.push('"updated_date"');
values.push(new Date().toISOString());
}
if (!columns.includes('"writer"') && userId) {
columns.push('"writer"');
values.push(userId);
}
if (columns.length > 1) {
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
const sql = `INSERT INTO "${fieldMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`;
@@ -609,16 +704,18 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
}
if (valueType === "fixed") {
const autoUpd = rule.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : "";
const placeholders = lookupValues.map((_, i) => `$${i + 3}`).join(", ");
const sql = `UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1 WHERE company_code = $2 AND "${pkColumn}" IN (${placeholders})`;
const sql = `UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1${autoUpd} WHERE company_code = $2 AND "${pkColumn}" IN (${placeholders})`;
await client.query(sql, [fixedValue, companyCode, ...lookupValues]);
processedCount += lookupValues.length;
} else {
for (let i = 0; i < lookupValues.length; i++) {
const item = items[i] ?? {};
const resolvedValue = resolveStatusValue(valueType, fixedValue, rule.conditionalValue, item);
const autoUpd2 = rule.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : "";
await client.query(
`UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1 WHERE company_code = $2 AND "${pkColumn}" = $3`,
`UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1${autoUpd2} WHERE company_code = $2 AND "${pkColumn}" = $3`,
[resolvedValue, companyCode, lookupValues[i]]
);
processedCount++;

View File

@@ -621,6 +621,74 @@ export class AdminService {
}
}
/**
* POP 메뉴 목록 조회
* menu_name_kor에 'POP'이 포함되거나 menu_desc에 [POP] 태그가 있는 L1 메뉴의 하위 active 메뉴를 반환
* [POP_LANDING] 태그가 있는 하위 메뉴를 landingMenu로 별도 반환
*/
static async getPopMenuList(paramMap: any): Promise<{ parentMenu: any | null; childMenus: any[]; landingMenu: any | null }> {
try {
const { userCompanyCode, userType } = paramMap;
logger.info("AdminService.getPopMenuList 시작", { userCompanyCode, userType });
let queryParams: any[] = [];
let paramIndex = 1;
let companyFilter = "";
if (userType === "SUPER_ADMIN" && userCompanyCode === "*") {
companyFilter = `AND COMPANY_CODE = '*'`;
} else {
companyFilter = `AND COMPANY_CODE = $${paramIndex}`;
queryParams.push(userCompanyCode);
paramIndex++;
}
// POP L1 메뉴 조회
const parentMenus = await query<any>(
`SELECT OBJID, MENU_NAME_KOR, MENU_URL, MENU_DESC, SEQ, COMPANY_CODE, STATUS
FROM MENU_INFO
WHERE PARENT_OBJ_ID = 0
AND MENU_TYPE = 1
AND (
MENU_DESC LIKE '%[POP]%'
OR UPPER(MENU_NAME_KOR) LIKE '%POP%'
)
${companyFilter}
ORDER BY SEQ
LIMIT 1`,
queryParams
);
if (parentMenus.length === 0) {
logger.info("POP 메뉴 없음 (L1 POP 메뉴 미발견)");
return { parentMenu: null, childMenus: [], landingMenu: null };
}
const parentMenu = parentMenus[0];
// 하위 active 메뉴 조회 (부모와 같은 company_code로 필터링)
const childMenus = await query<any>(
`SELECT OBJID, MENU_NAME_KOR, MENU_URL, MENU_DESC, SEQ, COMPANY_CODE, STATUS
FROM MENU_INFO
WHERE PARENT_OBJ_ID = $1
AND STATUS = 'active'
AND COMPANY_CODE = $2
ORDER BY SEQ`,
[parentMenu.objid, parentMenu.company_code]
);
// [POP_LANDING] 태그가 있는 메뉴를 랜딩 화면으로 지정
const landingMenu = childMenus.find((m: any) => m.menu_desc?.includes("[POP_LANDING]")) || null;
logger.info(`POP 메뉴 조회 완료: 부모=${parentMenu.menu_name_kor}, 하위=${childMenus.length}개, 랜딩=${landingMenu?.menu_name_kor || '없음'}`);
return { parentMenu, childMenus, landingMenu };
} catch (error) {
logger.error("AdminService.getPopMenuList 오류:", error);
throw error;
}
}
/**
* 메뉴 정보 조회
*/

View File

@@ -3367,22 +3367,26 @@ export class TableManagementService {
`${safeColumn} != '${String(value).replace(/'/g, "''")}'`
);
break;
case "in":
if (Array.isArray(value) && value.length > 0) {
const values = value
case "in": {
const inArr = Array.isArray(value) ? value : value != null && value !== "" ? [String(value)] : [];
if (inArr.length > 0) {
const values = inArr
.map((v) => `'${String(v).replace(/'/g, "''")}'`)
.join(", ");
filterConditions.push(`${safeColumn} IN (${values})`);
}
break;
case "not_in":
if (Array.isArray(value) && value.length > 0) {
const values = value
}
case "not_in": {
const notInArr = Array.isArray(value) ? value : value != null && value !== "" ? [String(value)] : [];
if (notInArr.length > 0) {
const values = notInArr
.map((v) => `'${String(v).replace(/'/g, "''")}'`)
.join(", ");
filterConditions.push(`${safeColumn} NOT IN (${values})`);
}
break;
}
case "contains":
filterConditions.push(
`${safeColumn} LIKE '%${String(value).replace(/'/g, "''")}%'`
@@ -4500,26 +4504,30 @@ export class TableManagementService {
const rawColumns = await query<any>(
`SELECT
column_name as "columnName",
column_name as "displayName",
data_type as "dataType",
udt_name as "dbType",
is_nullable as "isNullable",
column_default as "defaultValue",
character_maximum_length as "maxLength",
numeric_precision as "numericPrecision",
numeric_scale as "numericScale",
c.column_name as "columnName",
c.column_name as "displayName",
c.data_type as "dataType",
c.udt_name as "dbType",
c.is_nullable as "isNullable",
c.column_default as "defaultValue",
c.character_maximum_length as "maxLength",
c.numeric_precision as "numericPrecision",
c.numeric_scale as "numericScale",
CASE
WHEN column_name IN (
SELECT column_name FROM information_schema.key_column_usage
WHERE table_name = $1 AND constraint_name LIKE '%_pkey'
WHEN c.column_name IN (
SELECT kcu.column_name FROM information_schema.key_column_usage kcu
WHERE kcu.table_name = $1 AND kcu.constraint_name LIKE '%_pkey'
) THEN true
ELSE false
END as "isPrimaryKey"
FROM information_schema.columns
WHERE table_name = $1
AND table_schema = 'public'
ORDER BY ordinal_position`,
END as "isPrimaryKey",
col_description(
(SELECT oid FROM pg_class WHERE relname = $1 AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public')),
c.ordinal_position
) as "columnComment"
FROM information_schema.columns c
WHERE c.table_name = $1
AND c.table_schema = 'public'
ORDER BY c.ordinal_position`,
[tableName]
);
@@ -4529,10 +4537,10 @@ export class TableManagementService {
displayName: col.displayName,
dataType: col.dataType,
dbType: col.dbType,
webType: "text", // 기본값
webType: "text",
inputType: "direct",
detailSettings: "{}",
description: "", // 필수 필드 추가
description: col.columnComment || "",
isNullable: col.isNullable,
isPrimaryKey: col.isPrimaryKey,
defaultValue: col.defaultValue,
@@ -4543,6 +4551,7 @@ export class TableManagementService {
numericScale: col.numericScale ? Number(col.numericScale) : undefined,
displayOrder: 0,
isVisible: true,
columnComment: col.columnComment || "",
}));
logger.info(

View File

@@ -98,23 +98,27 @@ export function buildDataFilterWhereClause(
paramIndex++;
break;
case "in":
if (Array.isArray(value) && value.length > 0) {
const placeholders = value.map((_, idx) => `$${paramIndex + idx}`).join(", ");
case "in": {
const inArr = Array.isArray(value) ? value : value != null && value !== "" ? [String(value)] : [];
if (inArr.length > 0) {
const placeholders = inArr.map((_, idx) => `$${paramIndex + idx}`).join(", ");
conditions.push(`${columnRef} IN (${placeholders})`);
params.push(...value);
paramIndex += value.length;
params.push(...inArr);
paramIndex += inArr.length;
}
break;
}
case "not_in":
if (Array.isArray(value) && value.length > 0) {
const placeholders = value.map((_, idx) => `$${paramIndex + idx}`).join(", ");
case "not_in": {
const notInArr = Array.isArray(value) ? value : value != null && value !== "" ? [String(value)] : [];
if (notInArr.length > 0) {
const placeholders = notInArr.map((_, idx) => `$${paramIndex + idx}`).join(", ");
conditions.push(`${columnRef} NOT IN (${placeholders})`);
params.push(...value);
paramIndex += value.length;
params.push(...notInArr);
paramIndex += notInArr.length;
}
break;
}
case "contains":
conditions.push(`${columnRef} LIKE $${paramIndex}`);