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

This commit is contained in:
kjs
2026-03-04 21:16:48 +09:00
63 changed files with 10660 additions and 475 deletions

View File

@@ -90,6 +90,7 @@ import externalCallConfigRoutes from "./routes/externalCallConfigRoutes";
import dataflowExecutionRoutes from "./routes/dataflowExecutionRoutes";
import dashboardRoutes from "./routes/dashboardRoutes";
import reportRoutes from "./routes/reportRoutes";
import barcodeLabelRoutes from "./routes/barcodeLabelRoutes";
import openApiProxyRoutes from "./routes/openApiProxyRoutes"; // 날씨/환율 API
import deliveryRoutes from "./routes/deliveryRoutes"; // 배송/화물 관리
import riskAlertRoutes from "./routes/riskAlertRoutes"; // 리스크/알림 관리
@@ -114,6 +115,7 @@ import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙
import entitySearchRoutes, { entityOptionsRouter } from "./routes/entitySearchRoutes"; // 엔티티 검색 및 옵션
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리
import popActionRoutes from "./routes/popActionRoutes"; // POP 액션 실행
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
@@ -244,6 +246,7 @@ app.use("/api/table-management", tableManagementRoutes);
app.use("/api/table-management", entityJoinRoutes); // 🎯 Entity 조인 기능
app.use("/api/screen-management", screenManagementRoutes);
app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리
app.use("/api/pop", popActionRoutes); // POP 액션 실행
app.use("/api/common-codes", commonCodeRoutes);
app.use("/api/dynamic-form", dynamicFormRoutes);
app.use("/api/files", fileRoutes);
@@ -279,6 +282,7 @@ app.use("/api/external-call-configs", externalCallConfigRoutes);
app.use("/api/dataflow", dataflowExecutionRoutes);
app.use("/api/dashboards", dashboardRoutes);
app.use("/api/admin/reports", reportRoutes);
app.use("/api/admin/barcode-labels", barcodeLabelRoutes);
app.use("/api/open-api", openApiProxyRoutes); // 날씨/환율 외부 API
app.use("/api/delivery", deliveryRoutes); // 배송/화물 관리
app.use("/api/risk-alerts", riskAlertRoutes); // 리스크/알림 관리

View File

@@ -0,0 +1,218 @@
/**
* 바코드 라벨 관리 컨트롤러
* ZD421 등 바코드 프린터용 라벨 CRUD 및 레이아웃/템플릿
*/
import { Request, Response, NextFunction } from "express";
import barcodeLabelService from "../services/barcodeLabelService";
function getUserId(req: Request): string {
return (req as any).user?.userId || "SYSTEM";
}
export class BarcodeLabelController {
async getLabels(req: Request, res: Response, next: NextFunction) {
try {
const page = Math.max(1, parseInt((req.query.page as string) || "1", 10));
const limit = Math.min(100, Math.max(1, parseInt((req.query.limit as string) || "20", 10)));
const searchText = (req.query.searchText as string) || "";
const useYn = (req.query.useYn as string) || "Y";
const sortBy = (req.query.sortBy as string) || "created_at";
const sortOrder = (req.query.sortOrder as "ASC" | "DESC") || "DESC";
const data = await barcodeLabelService.getLabels({
page,
limit,
searchText,
useYn,
sortBy,
sortOrder,
});
return res.json({ success: true, data });
} catch (error) {
return next(error);
}
}
async getLabelById(req: Request, res: Response, next: NextFunction) {
try {
const { labelId } = req.params;
const label = await barcodeLabelService.getLabelById(labelId);
if (!label) {
return res.status(404).json({
success: false,
message: "바코드 라벨을 찾을 수 없습니다.",
});
}
return res.json({ success: true, data: label });
} catch (error) {
return next(error);
}
}
async getLayout(req: Request, res: Response, next: NextFunction) {
try {
const { labelId } = req.params;
const layout = await barcodeLabelService.getLayout(labelId);
if (!layout) {
return res.status(404).json({
success: false,
message: "레이아웃을 찾을 수 없습니다.",
});
}
return res.json({ success: true, data: layout });
} catch (error) {
return next(error);
}
}
async createLabel(req: Request, res: Response, next: NextFunction) {
try {
const body = req.body as {
labelNameKor?: string;
labelNameEng?: string;
description?: string;
templateId?: string;
};
if (!body?.labelNameKor?.trim()) {
return res.status(400).json({
success: false,
message: "라벨명(한글)은 필수입니다.",
});
}
const labelId = await barcodeLabelService.createLabel(
{
labelNameKor: body.labelNameKor.trim(),
labelNameEng: body.labelNameEng?.trim(),
description: body.description?.trim(),
templateId: body.templateId?.trim(),
},
getUserId(req)
);
return res.status(201).json({
success: true,
data: { labelId },
message: "바코드 라벨이 생성되었습니다.",
});
} catch (error) {
return next(error);
}
}
async updateLabel(req: Request, res: Response, next: NextFunction) {
try {
const { labelId } = req.params;
const body = req.body as {
labelNameKor?: string;
labelNameEng?: string;
description?: string;
useYn?: string;
};
const success = await barcodeLabelService.updateLabel(
labelId,
{
labelNameKor: body.labelNameKor?.trim(),
labelNameEng: body.labelNameEng?.trim(),
description: body.description !== undefined ? body.description : undefined,
useYn: body.useYn,
},
getUserId(req)
);
if (!success) {
return res.status(404).json({
success: false,
message: "바코드 라벨을 찾을 수 없습니다.",
});
}
return res.json({ success: true, message: "수정되었습니다." });
} catch (error) {
return next(error);
}
}
async saveLayout(req: Request, res: Response, next: NextFunction) {
try {
const { labelId } = req.params;
const layout = req.body as { width_mm: number; height_mm: number; components: any[] };
if (!layout || typeof layout.width_mm !== "number" || typeof layout.height_mm !== "number" || !Array.isArray(layout.components)) {
return res.status(400).json({
success: false,
message: "width_mm, height_mm, components 배열이 필요합니다.",
});
}
await barcodeLabelService.saveLayout(
labelId,
{ width_mm: layout.width_mm, height_mm: layout.height_mm, components: layout.components },
getUserId(req)
);
return res.json({ success: true, message: "레이아웃이 저장되었습니다." });
} catch (error) {
return next(error);
}
}
async deleteLabel(req: Request, res: Response, next: NextFunction) {
try {
const { labelId } = req.params;
const success = await barcodeLabelService.deleteLabel(labelId);
if (!success) {
return res.status(404).json({
success: false,
message: "바코드 라벨을 찾을 수 없습니다.",
});
}
return res.json({ success: true, message: "삭제되었습니다." });
} catch (error) {
return next(error);
}
}
async copyLabel(req: Request, res: Response, next: NextFunction) {
try {
const { labelId } = req.params;
const newId = await barcodeLabelService.copyLabel(labelId, getUserId(req));
if (!newId) {
return res.status(404).json({
success: false,
message: "바코드 라벨을 찾을 수 없습니다.",
});
}
return res.json({
success: true,
data: { labelId: newId },
message: "복사되었습니다.",
});
} catch (error) {
return next(error);
}
}
async getTemplates(req: Request, res: Response, next: NextFunction) {
try {
const templates = await barcodeLabelService.getTemplates();
return res.json({ success: true, data: templates });
} catch (error) {
return next(error);
}
}
async getTemplateById(req: Request, res: Response, next: NextFunction) {
try {
const { templateId } = req.params;
const template = await barcodeLabelService.getTemplateById(templateId);
if (!template) {
return res.status(404).json({
success: false,
message: "템플릿을 찾을 수 없습니다.",
});
}
const layout = JSON.parse(template.layout_json);
return res.json({ success: true, data: { ...template, layout } });
} catch (error) {
return next(error);
}
}
}
export default new BarcodeLabelController();

View File

@@ -20,7 +20,7 @@ const pool = getPool();
export const getScreenGroups = async (req: AuthenticatedRequest, res: Response) => {
try {
const companyCode = req.user?.companyCode || "*";
const { page = 1, size = 20, searchTerm } = req.query;
const { page = 1, size = 20, searchTerm, excludePop } = req.query;
const offset = (parseInt(page as string) - 1) * parseInt(size as string);
let whereClause = "WHERE 1=1";
@@ -34,6 +34,11 @@ export const getScreenGroups = async (req: AuthenticatedRequest, res: Response)
paramIndex++;
}
// POP 그룹 제외 (PC 화면관리용)
if (excludePop === "true") {
whereClause += ` AND (hierarchy_path IS NULL OR (hierarchy_path NOT LIKE 'POP/%' AND hierarchy_path != 'POP'))`;
}
// 검색어 필터링
if (searchTerm) {
whereClause += ` AND (group_name ILIKE $${paramIndex} OR group_code ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`;
@@ -2573,11 +2578,11 @@ export const getPopScreenGroups = async (req: AuthenticatedRequest, res: Respons
const companyCode = req.user?.companyCode || "*";
const { searchTerm } = req.query;
let whereClause = "WHERE hierarchy_path LIKE 'POP/%' OR hierarchy_path = 'POP'";
let whereClause = "WHERE (hierarchy_path LIKE 'POP/%' OR hierarchy_path = 'POP')";
const params: any[] = [];
let paramIndex = 1;
// 회사 코드 필터링 (멀티테넌시)
// 회사 코드 필터링 (멀티테넌시) - 일반 회사는 자기 회사 데이터만
if (companyCode !== "*") {
whereClause += ` AND company_code = $${paramIndex}`;
params.push(companyCode);
@@ -2591,11 +2596,13 @@ export const getPopScreenGroups = async (req: AuthenticatedRequest, res: Respons
paramIndex++;
}
// POP 그룹 조회 (계층 구조를 위해 전체 조회)
// POP 그룹 조회 (POP 레이아웃이 있는 화면만 카운트/포함)
const dataQuery = `
SELECT
sg.*,
(SELECT COUNT(*) FROM screen_group_screens sgs WHERE sgs.group_id = sg.id) as screen_count,
(SELECT COUNT(*) FROM screen_group_screens sgs
INNER JOIN screen_layouts_pop slp ON sgs.screen_id = slp.screen_id AND sgs.company_code = slp.company_code
WHERE sgs.group_id = sg.id AND sgs.company_code = sg.company_code) as screen_count,
(SELECT json_agg(
json_build_object(
'id', sgs.id,
@@ -2608,7 +2615,8 @@ export const getPopScreenGroups = async (req: AuthenticatedRequest, res: Respons
) ORDER BY sgs.display_order
) FROM screen_group_screens sgs
LEFT JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id
WHERE sgs.group_id = sg.id
INNER JOIN screen_layouts_pop slp ON sgs.screen_id = slp.screen_id AND sgs.company_code = slp.company_code
WHERE sgs.group_id = sg.id AND sgs.company_code = sg.company_code
) as screens
FROM screen_groups sg
${whereClause}
@@ -2767,6 +2775,14 @@ export const deletePopScreenGroup = async (req: AuthenticatedRequest, res: Respo
const existing = await pool.query(checkQuery, checkParams);
if (existing.rows.length === 0) {
// 그룹이 다른 회사 소속인지 확인하여 구체적 메시지 제공
const anyGroup = await pool.query(`SELECT company_code FROM screen_groups WHERE id = $1`, [id]);
if (anyGroup.rows.length > 0) {
return res.status(403).json({
success: false,
message: `이 그룹은 ${anyGroup.rows[0].company_code === "*" ? "최고관리자" : anyGroup.rows[0].company_code} 소속이라 삭제할 수 없습니다.`
});
}
return res.status(404).json({ success: false, message: "그룹을 찾을 수 없습니다." });
}
@@ -2781,7 +2797,10 @@ export const deletePopScreenGroup = async (req: AuthenticatedRequest, res: Respo
[id]
);
if (parseInt(childCheck.rows[0].count) > 0) {
return res.status(400).json({ success: false, message: "하위 그룹이 있어 삭제할 수 없습니다." });
return res.status(400).json({
success: false,
message: `하위 그룹이 ${childCheck.rows[0].count}개 있어 삭제할 수 없습니다. 하위 그룹을 먼저 삭제해주세요.`
});
}
// 연결된 화면 확인
@@ -2790,7 +2809,10 @@ export const deletePopScreenGroup = async (req: AuthenticatedRequest, res: Respo
[id]
);
if (parseInt(screenCheck.rows[0].count) > 0) {
return res.status(400).json({ success: false, message: "그룹에 연결된 화면이 있어 삭제할 수 없습니다." });
return res.status(400).json({
success: false,
message: `그룹에 연결된 화면이 ${screenCheck.rows[0].count}개 있어 삭제할 수 없습니다. 화면을 먼저 제거해주세요.`
});
}
// 삭제
@@ -2805,33 +2827,44 @@ export const deletePopScreenGroup = async (req: AuthenticatedRequest, res: Respo
}
};
// POP 루트 그룹 확보 (없으면 자동 생성)
// POP 루트 그룹 확보 (최고관리자 전용 - 일반 회사는 복사 기능으로 배포)
export const ensurePopRootGroup = async (req: AuthenticatedRequest, res: Response) => {
try {
const companyCode = req.user?.companyCode || "*";
// POP 루트 그룹 확인
const checkQuery = `
SELECT * FROM screen_groups
WHERE hierarchy_path = 'POP' AND company_code = $1
`;
const existing = await pool.query(checkQuery, [companyCode]);
if (existing.rows.length > 0) {
return res.json({ success: true, data: existing.rows[0], message: "POP 루트 그룹이 이미 존재합니다." });
// 최고관리자만 자동 생성
if (companyCode !== "*") {
const existing = await pool.query(
`SELECT * FROM screen_groups WHERE hierarchy_path = 'POP' AND company_code = $1`,
[companyCode]
);
if (existing.rows.length > 0) {
return res.json({ success: true, data: existing.rows[0] });
}
return res.json({ success: true, data: null, message: "POP 루트 그룹이 없습니다." });
}
// 최고관리자(*): 루트 그룹 확인 후 없으면 생성
const checkQuery = `
SELECT * FROM screen_groups
WHERE hierarchy_path = 'POP' AND company_code = '*'
`;
const existing = await pool.query(checkQuery, []);
if (existing.rows.length > 0) {
return res.json({ success: true, data: existing.rows[0] });
}
// 없으면 생성 (writer 컬럼 사용, is_active는 'Y' - 기존 스키마에 맞춤)
const insertQuery = `
INSERT INTO screen_groups (
group_name, group_code, hierarchy_path, company_code,
description, display_order, is_active, writer
) VALUES ('POP 화면', 'POP', 'POP', $1, 'POP 화면 관리 루트', 0, 'Y', $2)
) VALUES ('POP 화면', 'POP', 'POP', '*', 'POP 화면 관리 루트', 0, 'Y', $1)
RETURNING *
`;
const result = await pool.query(insertQuery, [companyCode, req.user?.userId || ""]);
const result = await pool.query(insertQuery, [req.user?.userId || ""]);
logger.info("POP 루트 그룹 생성", { groupId: result.rows[0].id, companyCode });
logger.info("POP 루트 그룹 생성", { groupId: result.rows[0].id });
res.json({ success: true, data: result.rows[0], message: "POP 루트 그룹이 생성되었습니다." });
} catch (error: any) {

View File

@@ -7,7 +7,7 @@ import { auditLogService, getClientIp } from "../services/auditLogService";
export const getScreens = async (req: AuthenticatedRequest, res: Response) => {
try {
const userCompanyCode = (req.user as any).companyCode;
const { page = 1, size = 20, searchTerm, companyCode } = req.query;
const { page = 1, size = 20, searchTerm, companyCode, excludePop } = req.query;
// 쿼리 파라미터로 companyCode가 전달되면 해당 회사의 화면 조회 (최고 관리자 전용)
// 아니면 현재 사용자의 companyCode 사용
@@ -25,7 +25,8 @@ export const getScreens = async (req: AuthenticatedRequest, res: Response) => {
targetCompanyCode,
parseInt(page as string),
parseInt(size as string),
searchTerm as string // 검색어 전달
searchTerm as string,
{ excludePop: excludePop === "true" },
);
res.json({
@@ -1537,3 +1538,82 @@ export const copyCascadingRelation = async (
});
}
};
// POP 화면 연결 분석
export const analyzePopScreenLinks = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { screenId } = req.params;
const { companyCode } = req.user as any;
const result = await screenManagementService.analyzePopScreenLinks(
parseInt(screenId),
companyCode,
);
res.json({ success: true, data: result });
} catch (error: any) {
console.error("POP 화면 연결 분석 실패:", error);
res.status(500).json({
success: false,
message: error.message || "POP 화면 연결 분석에 실패했습니다.",
});
}
};
// POP 화면 배포 (다른 회사로 복사)
export const deployPopScreens = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { screens, targetCompanyCode, groupStructure } = req.body;
const { companyCode, userId } = req.user as any;
if (!screens || !Array.isArray(screens) || screens.length === 0) {
res.status(400).json({
success: false,
message: "배포할 화면 목록이 필요합니다.",
});
return;
}
if (!targetCompanyCode) {
res.status(400).json({
success: false,
message: "대상 회사 코드가 필요합니다.",
});
return;
}
if (companyCode !== "*") {
res.status(403).json({
success: false,
message: "최고 관리자만 POP 화면을 배포할 수 있습니다.",
});
return;
}
const result = await screenManagementService.deployPopScreens({
screens,
groupStructure: groupStructure || undefined,
targetCompanyCode,
companyCode,
userId,
});
res.json({
success: true,
data: result,
message: `POP 화면 ${result.deployedScreens.length}개가 ${targetCompanyCode}에 배포되었습니다.`,
});
} catch (error: any) {
console.error("POP 화면 배포 실패:", error);
res.status(500).json({
success: false,
message: error.message || "POP 화면 배포에 실패했습니다.",
});
}
};

View File

@@ -0,0 +1,41 @@
import { Router } from "express";
import barcodeLabelController from "../controllers/barcodeLabelController";
import { authenticateToken } from "../middleware/authMiddleware";
const router = Router();
router.use(authenticateToken);
router.get("/", (req, res, next) =>
barcodeLabelController.getLabels(req, res, next)
);
router.get("/templates", (req, res, next) =>
barcodeLabelController.getTemplates(req, res, next)
);
router.get("/templates/:templateId", (req, res, next) =>
barcodeLabelController.getTemplateById(req, res, next)
);
router.post("/", (req, res, next) =>
barcodeLabelController.createLabel(req, res, next)
);
router.get("/:labelId", (req, res, next) =>
barcodeLabelController.getLabelById(req, res, next)
);
router.get("/:labelId/layout", (req, res, next) =>
barcodeLabelController.getLayout(req, res, next)
);
router.put("/:labelId", (req, res, next) =>
barcodeLabelController.updateLabel(req, res, next)
);
router.put("/:labelId/layout", (req, res, next) =>
barcodeLabelController.saveLayout(req, res, next)
);
router.delete("/:labelId", (req, res, next) =>
barcodeLabelController.deleteLabel(req, res, next)
);
router.post("/:labelId/copy", (req, res, next) =>
barcodeLabelController.copyLabel(req, res, next)
);
export default router;

View File

@@ -0,0 +1,280 @@
import { Router, Request, Response } from "express";
import { getPool } from "../database/db";
import logger from "../utils/logger";
import { authenticateToken } from "../middleware/authMiddleware";
const router = Router();
// SQL 인젝션 방지: 테이블명/컬럼명 패턴 검증
const SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
function isSafeIdentifier(name: string): boolean {
return SAFE_IDENTIFIER.test(name);
}
interface MappingInfo {
targetTable: string;
columnMapping: Record<string, string>;
}
interface StatusConditionRule {
whenColumn: string;
operator: string;
whenValue: string;
thenValue: string;
}
interface ConditionalValueRule {
conditions: StatusConditionRule[];
defaultValue?: string;
}
interface StatusChangeRuleBody {
targetTable: string;
targetColumn: string;
lookupMode?: "auto" | "manual";
manualItemField?: string;
manualPkColumn?: string;
valueType: "fixed" | "conditional";
fixedValue?: string;
conditionalValue?: ConditionalValueRule;
// 하위호환: 기존 형식
value?: string;
condition?: string;
}
interface ExecuteActionBody {
action: string;
data: {
items?: Record<string, unknown>[];
fieldValues?: Record<string, unknown>;
};
mappings?: {
cardList?: MappingInfo | null;
field?: MappingInfo | null;
};
statusChanges?: StatusChangeRuleBody[];
}
function resolveStatusValue(
valueType: string,
fixedValue: string,
conditionalValue: ConditionalValueRule | undefined,
item: Record<string, unknown>
): string {
if (valueType !== "conditional" || !conditionalValue) return fixedValue;
for (const cond of conditionalValue.conditions) {
const actual = String(item[cond.whenColumn] ?? "");
const expected = cond.whenValue;
let match = false;
switch (cond.operator) {
case "=": match = actual === expected; break;
case "!=": match = actual !== expected; break;
case ">": match = parseFloat(actual) > parseFloat(expected); break;
case "<": match = parseFloat(actual) < parseFloat(expected); break;
case ">=": match = parseFloat(actual) >= parseFloat(expected); break;
case "<=": match = parseFloat(actual) <= parseFloat(expected); break;
default: match = actual === expected;
}
if (match) return cond.thenValue;
}
return conditionalValue.defaultValue ?? fixedValue;
}
router.post("/execute-action", authenticateToken, async (req: Request, res: Response) => {
const pool = getPool();
const client = await pool.connect();
try {
const companyCode = (req as any).user?.companyCode;
const userId = (req as any).user?.userId;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { action, data, mappings, statusChanges } = req.body as ExecuteActionBody;
const items = data?.items ?? [];
const fieldValues = data?.fieldValues ?? {};
logger.info("[pop/execute-action] 요청", {
action,
companyCode,
userId,
itemCount: items.length,
hasFieldValues: Object.keys(fieldValues).length > 0,
hasMappings: !!mappings,
statusChangeCount: statusChanges?.length ?? 0,
});
await client.query("BEGIN");
let processedCount = 0;
let insertedCount = 0;
if (action === "inbound-confirm") {
// 1. 매핑 기반 INSERT (장바구니 데이터 -> 대상 테이블)
const cardMapping = mappings?.cardList;
const fieldMapping = mappings?.field;
if (cardMapping?.targetTable && Object.keys(cardMapping.columnMapping).length > 0) {
if (!isSafeIdentifier(cardMapping.targetTable)) {
throw new Error(`유효하지 않은 테이블명: ${cardMapping.targetTable}`);
}
for (const item of items) {
const columns: string[] = ["company_code"];
const values: unknown[] = [companyCode];
for (const [sourceField, targetColumn] of Object.entries(cardMapping.columnMapping)) {
if (!isSafeIdentifier(targetColumn)) continue;
columns.push(`"${targetColumn}"`);
values.push(item[sourceField] ?? null);
}
if (fieldMapping?.targetTable === cardMapping.targetTable) {
for (const [sourceField, targetColumn] of Object.entries(fieldMapping.columnMapping)) {
if (!isSafeIdentifier(targetColumn)) continue;
if (columns.includes(`"${targetColumn}"`)) continue;
columns.push(`"${targetColumn}"`);
values.push(fieldValues[sourceField] ?? null);
}
}
if (columns.length > 1) {
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
const sql = `INSERT INTO "${cardMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`;
logger.info("[pop/execute-action] INSERT 실행", {
table: cardMapping.targetTable,
columnCount: columns.length,
});
await client.query(sql, values);
insertedCount++;
}
}
}
if (
fieldMapping?.targetTable &&
Object.keys(fieldMapping.columnMapping).length > 0 &&
fieldMapping.targetTable !== cardMapping?.targetTable
) {
if (!isSafeIdentifier(fieldMapping.targetTable)) {
throw new Error(`유효하지 않은 테이블명: ${fieldMapping.targetTable}`);
}
const columns: string[] = ["company_code"];
const values: unknown[] = [companyCode];
for (const [sourceField, targetColumn] of Object.entries(fieldMapping.columnMapping)) {
if (!isSafeIdentifier(targetColumn)) continue;
columns.push(`"${targetColumn}"`);
values.push(fieldValues[sourceField] ?? null);
}
if (columns.length > 1) {
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
const sql = `INSERT INTO "${fieldMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`;
await client.query(sql, values);
}
}
// 2. 상태 변경 규칙 실행 (설정 기반)
if (statusChanges && statusChanges.length > 0) {
for (const rule of statusChanges) {
if (!rule.targetTable || !rule.targetColumn) continue;
if (!isSafeIdentifier(rule.targetTable) || !isSafeIdentifier(rule.targetColumn)) {
logger.warn("[pop/execute-action] 유효하지 않은 식별자, 건너뜀", { table: rule.targetTable, column: rule.targetColumn });
continue;
}
const valueType = rule.valueType ?? "fixed";
const fixedValue = rule.fixedValue ?? rule.value ?? "";
const lookupMode = rule.lookupMode ?? "auto";
// 조회 키 결정: 아이템 필드(itemField) -> 대상 테이블 PK 컬럼(pkColumn)
let itemField: string;
let pkColumn: string;
if (lookupMode === "manual" && rule.manualItemField && rule.manualPkColumn) {
if (!isSafeIdentifier(rule.manualPkColumn)) {
logger.warn("[pop/execute-action] 수동 PK 컬럼 유효하지 않음", { manualPkColumn: rule.manualPkColumn });
continue;
}
itemField = rule.manualItemField;
pkColumn = rule.manualPkColumn;
logger.info("[pop/execute-action] 수동 조회 키", { itemField, pkColumn, table: rule.targetTable });
} else if (rule.targetTable === "cart_items") {
itemField = "__cart_id";
pkColumn = "id";
} else {
itemField = "__cart_row_key";
const pkResult = await client.query(
`SELECT a.attname FROM pg_index i JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) WHERE i.indrelid = $1::regclass AND i.indisprimary`,
[rule.targetTable]
);
pkColumn = pkResult.rows[0]?.attname || "id";
}
const lookupValues = items.map((item) => item[itemField] ?? item[itemField.replace(/^__cart_/, "")]).filter(Boolean);
if (lookupValues.length === 0) {
logger.warn("[pop/execute-action] 조회 키 값 없음, 건너뜀", { table: rule.targetTable, itemField });
continue;
}
if (valueType === "fixed") {
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})`;
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);
await client.query(
`UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1 WHERE company_code = $2 AND "${pkColumn}" = $3`,
[resolvedValue, companyCode, lookupValues[i]]
);
processedCount++;
}
}
logger.info("[pop/execute-action] 상태 변경 실행", {
table: rule.targetTable, column: rule.targetColumn, lookupMode, itemField, pkColumn, count: lookupValues.length,
});
}
}
}
await client.query("COMMIT");
logger.info("[pop/execute-action] 완료", {
action,
companyCode,
processedCount,
insertedCount,
});
return res.json({
success: true,
message: `${processedCount}건 처리 완료${insertedCount > 0 ? `, ${insertedCount}건 생성` : ""}`,
data: { processedCount, insertedCount },
});
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("[pop/execute-action] 오류:", error);
return res.status(500).json({
success: false,
message: error.message || "처리 중 오류가 발생했습니다.",
});
} finally {
client.release();
}
});
export default router;

View File

@@ -52,6 +52,8 @@ import {
updateZone,
deleteZone,
addLayerToZone,
analyzePopScreenLinks,
deployPopScreens,
} from "../controllers/screenManagementController";
const router = express.Router();
@@ -147,4 +149,8 @@ router.post("/copy-table-type-columns", copyTableTypeColumns);
// 연쇄관계 설정 복제
router.post("/copy-cascading-relation", copyCascadingRelation);
// POP 화면 배포 (다른 회사로 복사)
router.get("/screens/:screenId/pop-links", analyzePopScreenLinks);
router.post("/deploy-pop-screens", deployPopScreens);
export default router;

View File

@@ -0,0 +1,247 @@
/**
* 바코드 라벨 관리 서비스
* ZD421 등 라벨 디자인 CRUD 및 기본 템플릿 제공
*/
import { v4 as uuidv4 } from "uuid";
import { query, queryOne, transaction } from "../database/db";
import { BarcodeLabelLayout } from "../types/barcode";
export interface BarcodeLabelMaster {
label_id: string;
label_name_kor: string;
label_name_eng: string | null;
description: string | null;
width_mm: number;
height_mm: number;
layout_json: string | null;
use_yn: string;
created_at: string;
created_by: string | null;
updated_at: string | null;
updated_by: string | null;
}
export interface BarcodeLabelTemplate {
template_id: string;
template_name_kor: string;
template_name_eng: string | null;
width_mm: number;
height_mm: number;
layout_json: string;
sort_order: number;
}
export interface GetBarcodeLabelsParams {
page?: number;
limit?: number;
searchText?: string;
useYn?: string;
sortBy?: string;
sortOrder?: "ASC" | "DESC";
}
export interface GetBarcodeLabelsResult {
items: BarcodeLabelMaster[];
total: number;
page: number;
limit: number;
}
export class BarcodeLabelService {
async getLabels(params: GetBarcodeLabelsParams): Promise<GetBarcodeLabelsResult> {
const {
page = 1,
limit = 20,
searchText = "",
useYn = "Y",
sortBy = "created_at",
sortOrder = "DESC",
} = params;
const offset = (page - 1) * limit;
const conditions: string[] = [];
const values: any[] = [];
let idx = 1;
if (useYn) {
conditions.push(`use_yn = $${idx++}`);
values.push(useYn);
}
if (searchText) {
conditions.push(`(label_name_kor LIKE $${idx} OR label_name_eng LIKE $${idx})`);
values.push(`%${searchText}%`);
idx++;
}
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
const countSql = `SELECT COUNT(*) as total FROM barcode_labels ${where}`;
const countRow = await queryOne<{ total: string }>(countSql, values);
const total = parseInt(countRow?.total || "0", 10);
const listSql = `
SELECT label_id, label_name_kor, label_name_eng, description, width_mm, height_mm,
layout_json, use_yn, created_at, created_by, updated_at, updated_by
FROM barcode_labels ${where}
ORDER BY ${sortBy} ${sortOrder}
LIMIT $${idx++} OFFSET $${idx}
`;
const items = await query<BarcodeLabelMaster>(listSql, [...values, limit, offset]);
return { items, total, page, limit };
}
async getLabelById(labelId: string): Promise<BarcodeLabelMaster | null> {
const sql = `
SELECT label_id, label_name_kor, label_name_eng, description, width_mm, height_mm,
layout_json, use_yn, created_at, created_by, updated_at, updated_by
FROM barcode_labels WHERE label_id = $1
`;
return queryOne<BarcodeLabelMaster>(sql, [labelId]);
}
async getLayout(labelId: string): Promise<BarcodeLabelLayout | null> {
const row = await this.getLabelById(labelId);
if (!row?.layout_json) return null;
try {
return JSON.parse(row.layout_json) as BarcodeLabelLayout;
} catch {
return null;
}
}
async createLabel(
data: { labelNameKor: string; labelNameEng?: string; description?: string; templateId?: string },
userId: string
): Promise<string> {
const labelId = `LBL_${uuidv4().replace(/-/g, "").substring(0, 20)}`;
let widthMm = 50;
let heightMm = 30;
let layoutJson: string | null = null;
if (data.templateId) {
const t = await this.getTemplateById(data.templateId);
if (t) {
widthMm = t.width_mm;
heightMm = t.height_mm;
layoutJson = t.layout_json;
}
}
if (!layoutJson) {
const defaultLayout: BarcodeLabelLayout = {
width_mm: widthMm,
height_mm: heightMm,
components: [],
};
layoutJson = JSON.stringify(defaultLayout);
}
await query(
`INSERT INTO barcode_labels (label_id, label_name_kor, label_name_eng, description, width_mm, height_mm, layout_json, use_yn, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'Y', $8)`,
[
labelId,
data.labelNameKor,
data.labelNameEng || null,
data.description || null,
widthMm,
heightMm,
layoutJson,
userId,
]
);
return labelId;
}
async updateLabel(
labelId: string,
data: { labelNameKor?: string; labelNameEng?: string; description?: string; useYn?: string },
userId: string
): Promise<boolean> {
const setClauses: string[] = [];
const values: any[] = [];
let idx = 1;
if (data.labelNameKor !== undefined) {
setClauses.push(`label_name_kor = $${idx++}`);
values.push(data.labelNameKor);
}
if (data.labelNameEng !== undefined) {
setClauses.push(`label_name_eng = $${idx++}`);
values.push(data.labelNameEng);
}
if (data.description !== undefined) {
setClauses.push(`description = $${idx++}`);
values.push(data.description);
}
if (data.useYn !== undefined) {
setClauses.push(`use_yn = $${idx++}`);
values.push(data.useYn);
}
if (setClauses.length === 0) return false;
setClauses.push(`updated_at = CURRENT_TIMESTAMP`);
setClauses.push(`updated_by = $${idx++}`);
values.push(userId);
values.push(labelId);
const updated = await query<{ label_id: string }>(
`UPDATE barcode_labels SET ${setClauses.join(", ")} WHERE label_id = $${idx} RETURNING label_id`,
values
);
return updated.length > 0;
}
async saveLayout(labelId: string, layout: BarcodeLabelLayout, userId: string): Promise<boolean> {
const layoutJson = JSON.stringify(layout);
await query(
`UPDATE barcode_labels SET width_mm = $1, height_mm = $2, layout_json = $3, updated_at = CURRENT_TIMESTAMP, updated_by = $4 WHERE label_id = $5`,
[layout.width_mm, layout.height_mm, layoutJson, userId, labelId]
);
return true;
}
async deleteLabel(labelId: string): Promise<boolean> {
const deleted = await query<{ label_id: string }>(
`DELETE FROM barcode_labels WHERE label_id = $1 RETURNING label_id`,
[labelId]
);
return deleted.length > 0;
}
async copyLabel(labelId: string, userId: string): Promise<string | null> {
const row = await this.getLabelById(labelId);
if (!row) return null;
const newId = `LBL_${uuidv4().replace(/-/g, "").substring(0, 20)}`;
await query(
`INSERT INTO barcode_labels (label_id, label_name_kor, label_name_eng, description, width_mm, height_mm, layout_json, use_yn, created_by)
VALUES ($1, $2 || ' (복사)', $3, $4, $5, $6, $7, 'Y', $8)`,
[
newId,
row.label_name_kor,
row.label_name_eng,
row.description,
row.width_mm,
row.height_mm,
row.layout_json,
userId,
]
);
return newId;
}
async getTemplates(): Promise<BarcodeLabelTemplate[]> {
const sql = `
SELECT template_id, template_name_kor, template_name_eng, width_mm, height_mm, layout_json, sort_order
FROM barcode_label_templates ORDER BY sort_order, template_id
`;
const rows = await query<BarcodeLabelTemplate>(sql);
return rows || [];
}
async getTemplateById(templateId: string): Promise<BarcodeLabelTemplate | null> {
const sql = `SELECT template_id, template_name_kor, template_name_eng, width_mm, height_mm, layout_json, sort_order
FROM barcode_label_templates WHERE template_id = $1`;
return queryOne<BarcodeLabelTemplate>(sql, [templateId]);
}
}
export default new BarcodeLabelService();

View File

@@ -108,42 +108,49 @@ export class ScreenManagementService {
companyCode: string,
page: number = 1,
size: number = 20,
searchTerm?: string, // 검색어 추가
searchTerm?: string,
options?: { excludePop?: boolean },
): Promise<PaginatedResponse<ScreenDefinition>> {
const offset = (page - 1) * size;
// WHERE 절 동적 생성
const whereConditions: string[] = ["is_active != 'D'"];
const whereConditions: string[] = ["sd.is_active != 'D'"];
const params: any[] = [];
if (companyCode !== "*") {
whereConditions.push(`company_code = $${params.length + 1}`);
whereConditions.push(`sd.company_code = $${params.length + 1}`);
params.push(companyCode);
}
// 검색어 필터링 추가 (화면명, 화면 코드, 테이블명 검색)
if (searchTerm && searchTerm.trim() !== "") {
whereConditions.push(`(
screen_name ILIKE $${params.length + 1} OR
screen_code ILIKE $${params.length + 1} OR
table_name ILIKE $${params.length + 1}
sd.screen_name ILIKE $${params.length + 1} OR
sd.screen_code ILIKE $${params.length + 1} OR
sd.table_name ILIKE $${params.length + 1}
)`);
params.push(`%${searchTerm.trim()}%`);
}
// POP 화면 제외 필터: screen_layouts_pop에 레이아웃이 있는 화면 제외
if (options?.excludePop) {
whereConditions.push(
`NOT EXISTS (SELECT 1 FROM screen_layouts_pop slp WHERE slp.screen_id = sd.screen_id)`
);
}
const whereSQL = whereConditions.join(" AND ");
// 페이징 쿼리 (Raw Query)
const [screens, totalResult] = await Promise.all([
query<any>(
`SELECT * FROM screen_definitions
`SELECT sd.* FROM screen_definitions sd
WHERE ${whereSQL}
ORDER BY created_date DESC
ORDER BY sd.created_date DESC
LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
[...params, size, offset],
),
query<{ count: string }>(
`SELECT COUNT(*)::text as count FROM screen_definitions
`SELECT COUNT(*)::text as count FROM screen_definitions sd
WHERE ${whereSQL}`,
params,
),
@@ -5846,28 +5853,24 @@ export class ScreenManagementService {
async getScreenIdsWithPopLayout(
companyCode: string,
): Promise<number[]> {
console.log(`=== POP 레이아웃 존재 화면 ID 조회 ===`);
console.log(`회사 코드: ${companyCode}`);
let result: { screen_id: number }[];
if (companyCode === "*") {
// 최고 관리자: 모든 POP 레이아웃 조회
result = await query<{ screen_id: number }>(
`SELECT DISTINCT screen_id FROM screen_layouts_pop`,
[],
);
} else {
// 일반 회사: 해당 회사 또는 공통(*) 레이아웃 조회
// 일반 회사: 해당 회사 레이아웃 조회 (company_code='*'는 최고관리자 전용)
result = await query<{ screen_id: number }>(
`SELECT DISTINCT screen_id FROM screen_layouts_pop
WHERE company_code = $1 OR company_code = '*'`,
WHERE company_code = $1`,
[companyCode],
);
}
const screenIds = result.map((r) => r.screen_id);
console.log(`POP 레이아웃 존재 화면 수: ${screenIds.length}`);
logger.info("POP 레이아웃 존재 화면 ID 조회", { companyCode, count: screenIds.length });
return screenIds;
}
@@ -5905,6 +5908,512 @@ export class ScreenManagementService {
console.log(`POP 레이아웃 삭제 완료`);
return true;
}
// ============================================================
// POP 화면 배포 (다른 회사로 복사)
// ============================================================
/**
* POP layout_data 내 다른 화면 참조를 스캔하여 연결 관계 분석
*/
async analyzePopScreenLinks(
screenId: number,
companyCode: string,
): Promise<{
linkedScreenIds: number[];
references: Array<{
componentId: string;
referenceType: string;
targetScreenId: number;
}>;
}> {
const layoutResult = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_pop
WHERE screen_id = $1 AND company_code = $2`,
[screenId, companyCode],
);
if (!layoutResult?.layout_data) {
return { linkedScreenIds: [], references: [] };
}
const layoutData = layoutResult.layout_data;
const references: Array<{
componentId: string;
referenceType: string;
targetScreenId: number;
}> = [];
const scanComponents = (components: Record<string, any>) => {
for (const [compId, comp] of Object.entries(components)) {
const config = (comp as any).config || {};
if (config.cart?.cartScreenId) {
const sid = parseInt(config.cart.cartScreenId);
if (!isNaN(sid) && sid !== screenId) {
references.push({
componentId: compId,
referenceType: "cartScreenId",
targetScreenId: sid,
});
}
}
if (config.cartListMode?.sourceScreenId) {
const sid =
typeof config.cartListMode.sourceScreenId === "number"
? config.cartListMode.sourceScreenId
: parseInt(config.cartListMode.sourceScreenId);
if (!isNaN(sid) && sid !== screenId) {
references.push({
componentId: compId,
referenceType: "sourceScreenId",
targetScreenId: sid,
});
}
}
if (Array.isArray(config.followUpActions)) {
for (const action of config.followUpActions) {
if (action.targetScreenId) {
const sid = parseInt(action.targetScreenId);
if (!isNaN(sid) && sid !== screenId) {
references.push({
componentId: compId,
referenceType: "targetScreenId",
targetScreenId: sid,
});
}
}
}
}
if (config.action?.modalScreenId) {
const sid = parseInt(config.action.modalScreenId);
if (!isNaN(sid) && sid !== screenId) {
references.push({
componentId: compId,
referenceType: "modalScreenId",
targetScreenId: sid,
});
}
}
}
};
if (layoutData.components) {
scanComponents(layoutData.components);
}
if (Array.isArray(layoutData.modals)) {
for (const modal of layoutData.modals) {
if (modal.components) {
scanComponents(modal.components);
}
}
}
const linkedScreenIds = [
...new Set(references.map((r) => r.targetScreenId)),
];
return { linkedScreenIds, references };
}
/**
* POP 화면 배포 (최고관리자 화면을 특정 회사로 복사)
* - screen_definitions + screen_layouts_pop 복사
* - 화면 간 참조(cartScreenId, sourceScreenId 등) 자동 치환
* - numberingRuleId 초기화
*/
async deployPopScreens(data: {
screens: Array<{
sourceScreenId: number;
screenName: string;
screenCode: string;
}>;
groupStructure?: {
sourceGroupId: number;
groupName: string;
groupCode: string;
children?: Array<{
sourceGroupId: number;
groupName: string;
groupCode: string;
screenIds: number[];
}>;
screenIds: number[];
};
targetCompanyCode: string;
companyCode: string;
userId: string;
}): Promise<{
deployedScreens: Array<{
sourceScreenId: number;
newScreenId: number;
screenName: string;
screenCode: string;
}>;
createdGroups?: number;
}> {
if (data.companyCode !== "*") {
throw new Error("최고 관리자만 POP 화면을 배포할 수 있습니다.");
}
return await transaction(async (client) => {
const screenIdMap = new Map<number, number>();
const deployedScreens: Array<{
sourceScreenId: number;
newScreenId: number;
screenName: string;
screenCode: string;
}> = [];
// 1단계: screen_definitions 복사
for (const screen of data.screens) {
const sourceResult = await client.query<any>(
`SELECT * FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
[screen.sourceScreenId],
);
if (sourceResult.rows.length === 0) {
throw new Error(
`원본 화면(ID: ${screen.sourceScreenId})을 찾을 수 없습니다.`,
);
}
const sourceScreen = sourceResult.rows[0];
const existingResult = await client.query<any>(
`SELECT screen_id FROM screen_definitions
WHERE screen_code = $1 AND company_code = $2 AND deleted_date IS NULL
LIMIT 1`,
[screen.screenCode, data.targetCompanyCode],
);
if (existingResult.rows.length > 0) {
throw new Error(
`화면 코드 "${screen.screenCode}"가 대상 회사에 이미 존재합니다.`,
);
}
const newScreenResult = await client.query<any>(
`INSERT INTO screen_definitions (
screen_code, screen_name, description, company_code, table_name,
is_active, created_by, created_date, updated_by, updated_date
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $7, NOW())
RETURNING *`,
[
screen.screenCode,
screen.screenName,
sourceScreen.description,
data.targetCompanyCode,
sourceScreen.table_name,
"Y",
data.userId,
],
);
const newScreen = newScreenResult.rows[0];
screenIdMap.set(screen.sourceScreenId, newScreen.screen_id);
deployedScreens.push({
sourceScreenId: screen.sourceScreenId,
newScreenId: newScreen.screen_id,
screenName: screen.screenName,
screenCode: screen.screenCode,
});
logger.info("POP 화면 배포 - screen_definitions 생성", {
sourceScreenId: screen.sourceScreenId,
newScreenId: newScreen.screen_id,
targetCompanyCode: data.targetCompanyCode,
});
}
// 2단계: screen_layouts_pop 복사 + 참조 치환
for (const screen of data.screens) {
const newScreenId = screenIdMap.get(screen.sourceScreenId);
if (!newScreenId) continue;
// 원본 POP 레이아웃 조회 (company_code = '*' 우선, fallback)
let layoutResult = await client.query<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_pop
WHERE screen_id = $1 AND company_code = '*'`,
[screen.sourceScreenId],
);
let layoutData = layoutResult.rows[0]?.layout_data;
if (!layoutData) {
const fallbackResult = await client.query<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_pop
WHERE screen_id = $1 LIMIT 1`,
[screen.sourceScreenId],
);
layoutData = fallbackResult.rows[0]?.layout_data;
}
if (!layoutData) {
logger.warn("POP 레이아웃 없음, 건너뜀", {
sourceScreenId: screen.sourceScreenId,
});
continue;
}
const updatedLayoutData = this.updatePopLayoutScreenReferences(
JSON.parse(JSON.stringify(layoutData)),
screenIdMap,
);
await client.query(
`INSERT INTO screen_layouts_pop (screen_id, company_code, layout_data, created_at, updated_at, created_by, updated_by)
VALUES ($1, $2, $3, NOW(), NOW(), $4, $4)
ON CONFLICT (screen_id, company_code)
DO UPDATE SET layout_data = $3, updated_at = NOW(), updated_by = $4`,
[
newScreenId,
data.targetCompanyCode,
JSON.stringify(updatedLayoutData),
data.userId,
],
);
logger.info("POP 레이아웃 복사 완료", {
sourceScreenId: screen.sourceScreenId,
newScreenId,
componentCount: Object.keys(updatedLayoutData.components || {})
.length,
});
}
// 3단계: 그룹 구조 복사 (groupStructure가 있는 경우)
let createdGroups = 0;
if (data.groupStructure) {
const gs = data.groupStructure;
// 대상 회사의 POP 루트 그룹 찾기/생성
let popRootResult = await client.query<any>(
`SELECT id FROM screen_groups
WHERE hierarchy_path = 'POP' AND company_code = $1 LIMIT 1`,
[data.targetCompanyCode],
);
let popRootId: number;
if (popRootResult.rows.length > 0) {
popRootId = popRootResult.rows[0].id;
} else {
const createRootResult = await client.query<any>(
`INSERT INTO screen_groups (group_name, group_code, hierarchy_path, company_code, writer, is_active, display_order)
VALUES ('POP 화면', 'POP_ROOT', 'POP', $1, $2, 'Y', 0) RETURNING id`,
[data.targetCompanyCode, data.userId],
);
popRootId = createRootResult.rows[0].id;
}
// 메인 그룹 생성 (중복 코드 방지: _COPY 접미사 추가)
const mainGroupCode = gs.groupCode + "_COPY";
const dupCheck = await client.query<any>(
`SELECT id FROM screen_groups WHERE group_code = $1 AND company_code = $2`,
[mainGroupCode, data.targetCompanyCode],
);
let mainGroupId: number;
if (dupCheck.rows.length > 0) {
mainGroupId = dupCheck.rows[0].id;
} else {
const mainGroupResult = await client.query<any>(
`INSERT INTO screen_groups (group_name, group_code, hierarchy_path, company_code, parent_group_id, writer, is_active, display_order)
VALUES ($1, $2, $3, $4, $5, $6, 'Y', 0) RETURNING id`,
[
gs.groupName,
mainGroupCode,
`POP/${mainGroupCode}`,
data.targetCompanyCode,
popRootId,
data.userId,
],
);
mainGroupId = mainGroupResult.rows[0].id;
createdGroups++;
}
// 메인 그룹에 화면 연결
for (const oldScreenId of gs.screenIds) {
const newScreenId = screenIdMap.get(oldScreenId);
if (!newScreenId) continue;
await client.query(
`INSERT INTO screen_group_screens (group_id, screen_id, screen_role, display_order, is_default, company_code)
VALUES ($1, $2, 'main', 0, 'N', $3)
ON CONFLICT DO NOTHING`,
[mainGroupId, newScreenId, data.targetCompanyCode],
);
}
// 하위 그룹 생성 + 화면 연결
if (gs.children) {
for (let i = 0; i < gs.children.length; i++) {
const child = gs.children[i];
const childGroupCode = child.groupCode + "_COPY";
const childDupCheck = await client.query<any>(
`SELECT id FROM screen_groups WHERE group_code = $1 AND company_code = $2`,
[childGroupCode, data.targetCompanyCode],
);
let childGroupId: number;
if (childDupCheck.rows.length > 0) {
childGroupId = childDupCheck.rows[0].id;
} else {
const childResult = await client.query<any>(
`INSERT INTO screen_groups (group_name, group_code, hierarchy_path, company_code, parent_group_id, writer, is_active, display_order)
VALUES ($1, $2, $3, $4, $5, $6, 'Y', $7) RETURNING id`,
[
child.groupName,
childGroupCode,
`POP/${mainGroupCode}/${childGroupCode}`,
data.targetCompanyCode,
mainGroupId,
data.userId,
i,
],
);
childGroupId = childResult.rows[0].id;
createdGroups++;
}
for (const oldScreenId of child.screenIds) {
const newScreenId = screenIdMap.get(oldScreenId);
if (!newScreenId) continue;
await client.query(
`INSERT INTO screen_group_screens (group_id, screen_id, screen_role, display_order, is_default, company_code)
VALUES ($1, $2, 'main', 0, 'N', $3)
ON CONFLICT DO NOTHING`,
[childGroupId, newScreenId, data.targetCompanyCode],
);
}
}
}
logger.info("POP 그룹 구조 복사 완료", {
targetCompanyCode: data.targetCompanyCode,
createdGroups,
mainGroupName: gs.groupName,
});
}
return { deployedScreens, createdGroups };
});
}
/**
* POP layout_data 내 screen_id 참조 치환
* componentId, connectionId는 레이아웃 내부 식별자이므로 변경 불필요
*/
private updatePopLayoutScreenReferences(
layoutData: any,
screenIdMap: Map<number, number>,
): any {
if (!layoutData?.components) return layoutData;
const updateComponents = (
components: Record<string, any>,
): Record<string, any> => {
const updated: Record<string, any> = {};
for (const [compId, comp] of Object.entries(components)) {
const updatedComp = JSON.parse(JSON.stringify(comp));
const config = updatedComp.config || {};
// cart.cartScreenId (string)
if (config.cart?.cartScreenId) {
const oldId = parseInt(config.cart.cartScreenId);
const newId = screenIdMap.get(oldId);
if (newId) {
config.cart.cartScreenId = String(newId);
logger.info(`POP 참조 치환: cartScreenId ${oldId} -> ${newId}`);
}
}
// cartListMode.sourceScreenId (number)
if (config.cartListMode?.sourceScreenId) {
const oldId =
typeof config.cartListMode.sourceScreenId === "number"
? config.cartListMode.sourceScreenId
: parseInt(config.cartListMode.sourceScreenId);
const newId = screenIdMap.get(oldId);
if (newId) {
config.cartListMode.sourceScreenId = newId;
logger.info(
`POP 참조 치환: sourceScreenId ${oldId} -> ${newId}`,
);
}
}
// followUpActions[].targetScreenId (string)
if (Array.isArray(config.followUpActions)) {
for (const action of config.followUpActions) {
if (action.targetScreenId) {
const oldId = parseInt(action.targetScreenId);
const newId = screenIdMap.get(oldId);
if (newId) {
action.targetScreenId = String(newId);
logger.info(
`POP 참조 치환: targetScreenId ${oldId} -> ${newId}`,
);
}
}
}
}
// action.modalScreenId (숫자형이면 화면 참조로 간주)
if (config.action?.modalScreenId) {
const oldId = parseInt(config.action.modalScreenId);
if (!isNaN(oldId)) {
const newId = screenIdMap.get(oldId);
if (newId) {
config.action.modalScreenId = String(newId);
logger.info(
`POP 참조 치환: modalScreenId ${oldId} -> ${newId}`,
);
}
}
}
// numberingRuleId 초기화 (배포 후 대상 회사에서 재설정 필요)
if (config.numberingRuleId) {
logger.info(`POP 채번규칙 초기화: ${config.numberingRuleId}`);
config.numberingRuleId = "";
}
if (config.autoGenMappings) {
for (const mapping of Object.values(config.autoGenMappings) as any[]) {
if (mapping?.numberingRuleId) {
logger.info(
`POP 채번규칙 초기화: ${mapping.numberingRuleId}`,
);
mapping.numberingRuleId = "";
}
}
}
updatedComp.config = config;
updated[compId] = updatedComp;
}
return updated;
};
layoutData.components = updateComponents(layoutData.components);
if (Array.isArray(layoutData.modals)) {
for (const modal of layoutData.modals) {
if (modal.components) {
modal.components = updateComponents(modal.components);
}
}
}
return layoutData;
}
}
// 서비스 인스턴스 export

View File

@@ -0,0 +1,61 @@
/**
* 바코드 라벨 백엔드 타입
*/
export interface BarcodeLabelComponent {
id: string;
type: "text" | "barcode" | "image" | "line" | "rectangle";
x: number;
y: number;
width: number;
height: number;
zIndex: number;
// text
content?: string;
fontSize?: number;
fontColor?: string;
fontWeight?: string;
// barcode
barcodeType?: string;
barcodeValue?: string;
showBarcodeText?: boolean;
// image
imageUrl?: string;
objectFit?: string;
// line/rectangle
lineColor?: string;
lineWidth?: number;
backgroundColor?: string;
}
export interface BarcodeLabelLayout {
width_mm: number;
height_mm: number;
components: BarcodeLabelComponent[];
}
export interface BarcodeLabelRow {
label_id: string;
label_name_kor: string;
label_name_eng: string | null;
description: string | null;
width_mm: number;
height_mm: number;
layout_json: string | null;
use_yn: string;
created_at: string;
created_by: string | null;
updated_at: string | null;
updated_by: string | null;
}
export interface BarcodeLabelTemplateRow {
template_id: string;
template_name_kor: string;
template_name_eng: string | null;
width_mm: number;
height_mm: number;
layout_json: string;
sort_order: number;
created_at: string;
}