Files
vexplor_dev/backend-node/src/controllers/approvalController.ts

1430 lines
57 KiB
TypeScript

import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { query, queryOne, transaction } from "../database/db";
import { PoolClient } from "pg";
import { NodeFlowExecutionService } from "../services/nodeFlowExecutionService";
// 트랜잭션 내부에서 throw하고 외부에서 instanceof로 구분하기 위한 커스텀 에러
class ValidationError extends Error {
constructor(public statusCode: number, message: string) {
super(message);
this.name = "ValidationError";
}
}
// ============================================================
// 결재 정의 (Approval Definitions) CRUD
// ============================================================
export class ApprovalDefinitionController {
// 결재 유형 목록 조회
static async getDefinitions(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { is_active, search } = req.query;
const conditions: string[] = [];
const params: any[] = [];
let idx = 1;
// SUPER_ADMIN은 전체 조회, 일반 회사는 자사 데이터만
if (companyCode === "*") {
// 전체 조회 (company_code 필터 없음)
} else {
conditions.push(`company_code = $${idx++}`);
params.push(companyCode);
}
if (is_active) {
conditions.push(`is_active = $${idx++}`);
params.push(is_active);
}
if (search) {
// ILIKE에서 같은 파라미터를 두 조건에서 참조 (파라미터는 1개만 push)
conditions.push(`(definition_name ILIKE $${idx} OR definition_name_eng ILIKE $${idx})`);
params.push(`%${search}%`);
idx++;
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const rows = await query<any>(
`SELECT * FROM approval_definitions ${whereClause} ORDER BY company_code, definition_id ASC`,
params
);
return res.json({ success: true, data: rows });
} catch (error) {
console.error("결재 유형 목록 조회 오류:", error);
return res.status(500).json({
success: false,
message: "결재 유형 목록 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 결재 유형 상세 조회
static async getDefinition(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { id } = req.params;
// SUPER_ADMIN은 company_code 필터 없이 조회 가능
const row = await queryOne<any>(
companyCode === "*"
? "SELECT * FROM approval_definitions WHERE definition_id = $1"
: "SELECT * FROM approval_definitions WHERE definition_id = $1 AND company_code = $2",
companyCode === "*" ? [id] : [id, companyCode]
);
if (!row) {
return res.status(404).json({ success: false, message: "결재 유형을 찾을 수 없습니다." });
}
return res.json({ success: true, data: row });
} catch (error) {
console.error("결재 유형 상세 조회 오류:", error);
return res.status(500).json({
success: false,
message: "결재 유형 상세 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 결재 유형 생성
static async createDefinition(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const {
definition_name,
definition_name_eng,
description,
default_template_id,
max_steps = 5,
allow_self_approval = false,
allow_cancel = true,
is_active = "Y",
} = req.body;
if (!definition_name) {
return res.status(400).json({ success: false, message: "결재 유형명은 필수입니다." });
}
const userId = req.user?.userId || "system";
const [row] = await query<any>(
`INSERT INTO approval_definitions (
definition_name, definition_name_eng, description, default_template_id,
max_steps, allow_self_approval, allow_cancel, is_active,
company_code, created_by, updated_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $10)
RETURNING *`,
[
definition_name, definition_name_eng, description, default_template_id,
max_steps, allow_self_approval, allow_cancel, is_active,
companyCode, userId,
]
);
return res.status(201).json({ success: true, data: row, message: "결재 유형이 생성되었습니다." });
} catch (error) {
console.error("결재 유형 생성 오류:", error);
return res.status(500).json({
success: false,
message: "결재 유형 생성 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 결재 유형 수정
static async updateDefinition(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { id } = req.params;
const existing = await queryOne<any>(
"SELECT definition_id FROM approval_definitions WHERE definition_id = $1 AND company_code = $2",
[id, companyCode]
);
if (!existing) {
return res.status(404).json({ success: false, message: "결재 유형을 찾을 수 없습니다." });
}
const {
definition_name, definition_name_eng, description, default_template_id,
max_steps, allow_self_approval, allow_cancel, is_active,
} = req.body;
const fields: string[] = [];
const params: any[] = [];
let idx = 1;
if (definition_name !== undefined) { fields.push(`definition_name = $${idx++}`); params.push(definition_name); }
if (definition_name_eng !== undefined) { fields.push(`definition_name_eng = $${idx++}`); params.push(definition_name_eng); }
if (description !== undefined) { fields.push(`description = $${idx++}`); params.push(description); }
if (default_template_id !== undefined) { fields.push(`default_template_id = $${idx++}`); params.push(default_template_id); }
if (max_steps !== undefined) { fields.push(`max_steps = $${idx++}`); params.push(max_steps); }
if (allow_self_approval !== undefined) { fields.push(`allow_self_approval = $${idx++}`); params.push(allow_self_approval); }
if (allow_cancel !== undefined) { fields.push(`allow_cancel = $${idx++}`); params.push(allow_cancel); }
if (is_active !== undefined) { fields.push(`is_active = $${idx++}`); params.push(is_active); }
fields.push(`updated_by = $${idx++}`, `updated_at = NOW()`);
params.push(req.user?.userId || "system");
// WHERE 절 파라미터 인덱스를 미리 계산 (쿼리 문자열 내 idx++ 호출 순서 보장)
const idIdx = idx++;
const ccIdx = idx++;
params.push(id, companyCode);
const [row] = await query<any>(
`UPDATE approval_definitions SET ${fields.join(", ")}
WHERE definition_id = $${idIdx} AND company_code = $${ccIdx} RETURNING *`,
params
);
return res.json({ success: true, data: row, message: "결재 유형이 수정되었습니다." });
} catch (error) {
console.error("결재 유형 수정 오류:", error);
return res.status(500).json({
success: false,
message: "결재 유형 수정 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 결재 유형 삭제
static async deleteDefinition(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { id } = req.params;
const existing = await queryOne<any>(
"SELECT definition_id FROM approval_definitions WHERE definition_id = $1 AND company_code = $2",
[id, companyCode]
);
if (!existing) {
return res.status(404).json({ success: false, message: "결재 유형을 찾을 수 없습니다." });
}
await query<any>(
"DELETE FROM approval_definitions WHERE definition_id = $1 AND company_code = $2",
[id, companyCode]
);
return res.json({ success: true, message: "결재 유형이 삭제되었습니다." });
} catch (error) {
console.error("결재 유형 삭제 오류:", error);
return res.status(500).json({
success: false,
message: "결재 유형 삭제 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
}
// ============================================================
// 결재선 템플릿 (Approval Line Templates) CRUD
// ============================================================
export class ApprovalTemplateController {
// 템플릿 목록 조회
static async getTemplates(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { definition_id, is_active } = req.query;
const conditions: string[] = [];
const params: any[] = [];
let idx = 1;
// SUPER_ADMIN은 전체 조회, 일반 회사는 자사 데이터만
if (companyCode !== "*") {
conditions.push(`t.company_code = $${idx++}`);
params.push(companyCode);
}
if (definition_id) {
conditions.push(`t.definition_id = $${idx++}`);
params.push(definition_id);
}
if (is_active) {
conditions.push(`t.is_active = $${idx++}`);
params.push(is_active);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const rows = await query<any>(
`SELECT t.*, d.definition_name
FROM approval_line_templates t
LEFT JOIN approval_definitions d ON t.definition_id = d.definition_id AND t.company_code = d.company_code
${whereClause}
ORDER BY t.company_code, t.template_id ASC`,
params
);
return res.json({ success: true, data: rows });
} catch (error) {
console.error("결재선 템플릿 목록 조회 오류:", error);
return res.status(500).json({
success: false,
message: "결재선 템플릿 목록 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 템플릿 상세 조회 (단계 포함)
static async getTemplate(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { id } = req.params;
// SUPER_ADMIN은 company_code 필터 없이 조회 가능
const template = await queryOne<any>(
companyCode === "*"
? `SELECT t.*, d.definition_name
FROM approval_line_templates t
LEFT JOIN approval_definitions d ON t.definition_id = d.definition_id AND t.company_code = d.company_code
WHERE t.template_id = $1`
: `SELECT t.*, d.definition_name
FROM approval_line_templates t
LEFT JOIN approval_definitions d ON t.definition_id = d.definition_id AND t.company_code = d.company_code
WHERE t.template_id = $1 AND t.company_code = $2`,
companyCode === "*" ? [id] : [id, companyCode]
);
if (!template) {
return res.status(404).json({ success: false, message: "결재선 템플릿을 찾을 수 없습니다." });
}
const steps = await query<any>(
companyCode === "*"
? "SELECT * FROM approval_line_template_steps WHERE template_id = $1 ORDER BY step_order ASC"
: "SELECT * FROM approval_line_template_steps WHERE template_id = $1 AND company_code = $2 ORDER BY step_order ASC",
companyCode === "*" ? [id] : [id, companyCode]
);
return res.json({ success: true, data: { ...template, steps } });
} catch (error) {
console.error("결재선 템플릿 상세 조회 오류:", error);
return res.status(500).json({
success: false,
message: "결재선 템플릿 상세 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 템플릿 생성 (단계 포함 트랜잭션)
static async createTemplate(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { template_name, description, definition_id, after_approval_flow_id, is_active = "Y", steps = [] } = req.body;
if (!template_name) {
return res.status(400).json({ success: false, message: "템플릿명은 필수입니다." });
}
const userId = req.user?.userId || "system";
let result: any;
await transaction(async (client) => {
const { rows } = await client.query(
`INSERT INTO approval_line_templates (template_name, description, definition_id, after_approval_flow_id, is_active, company_code, created_by, updated_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $7) RETURNING *`,
[template_name, description, definition_id, after_approval_flow_id || null, is_active, companyCode, userId]
);
result = rows[0];
// 단계 일괄 삽입
if (Array.isArray(steps) && steps.length > 0) {
for (const step of steps) {
await client.query(
`INSERT INTO approval_line_template_steps
(template_id, step_order, approver_type, approver_user_id, approver_position, approver_dept_code, approver_label, company_code)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
result.template_id,
step.step_order,
step.approver_type || "user",
step.approver_user_id || null,
step.approver_position || null,
step.approver_dept_code || null,
step.approver_label || null,
companyCode,
]
);
}
}
});
return res.status(201).json({ success: true, data: result, message: "결재선 템플릿이 생성되었습니다." });
} catch (error) {
console.error("결재선 템플릿 생성 오류:", error);
return res.status(500).json({
success: false,
message: "결재선 템플릿 생성 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 템플릿 수정
static async updateTemplate(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { id } = req.params;
const existing = await queryOne<any>(
"SELECT template_id FROM approval_line_templates WHERE template_id = $1 AND company_code = $2",
[id, companyCode]
);
if (!existing) {
return res.status(404).json({ success: false, message: "결재선 템플릿을 찾을 수 없습니다." });
}
const { template_name, description, definition_id, after_approval_flow_id, is_active, steps } = req.body;
const userId = req.user?.userId || "system";
let result: any;
await transaction(async (client) => {
const fields: string[] = [];
const params: any[] = [];
let idx = 1;
if (template_name !== undefined) { fields.push(`template_name = $${idx++}`); params.push(template_name); }
if (description !== undefined) { fields.push(`description = $${idx++}`); params.push(description); }
if (definition_id !== undefined) { fields.push(`definition_id = $${idx++}`); params.push(definition_id); }
if (after_approval_flow_id !== undefined) { fields.push(`after_approval_flow_id = $${idx++}`); params.push(after_approval_flow_id); }
if (is_active !== undefined) { fields.push(`is_active = $${idx++}`); params.push(is_active); }
fields.push(`updated_by = $${idx++}`, `updated_at = NOW()`);
params.push(userId);
// WHERE 절 파라미터 인덱스를 미리 계산
const tmplIdx = idx++;
const ccIdx = idx++;
params.push(id, companyCode);
const { rows } = await client.query(
`UPDATE approval_line_templates SET ${fields.join(", ")}
WHERE template_id = $${tmplIdx} AND company_code = $${ccIdx} RETURNING *`,
params
);
result = rows[0];
// 단계 재등록 (steps 배열이 주어진 경우 전체 교체)
if (Array.isArray(steps)) {
await client.query(
"DELETE FROM approval_line_template_steps WHERE template_id = $1 AND company_code = $2",
[id, companyCode]
);
for (const step of steps) {
await client.query(
`INSERT INTO approval_line_template_steps
(template_id, step_order, approver_type, approver_user_id, approver_position, approver_dept_code, approver_label, company_code)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[id, step.step_order, step.approver_type || "user", step.approver_user_id || null,
step.approver_position || null, step.approver_dept_code || null, step.approver_label || null, companyCode]
);
}
}
});
return res.json({ success: true, data: result, message: "결재선 템플릿이 수정되었습니다." });
} catch (error) {
console.error("결재선 템플릿 수정 오류:", error);
return res.status(500).json({
success: false,
message: "결재선 템플릿 수정 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 템플릿 삭제
static async deleteTemplate(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { id } = req.params;
const existing = await queryOne<any>(
"SELECT template_id FROM approval_line_templates WHERE template_id = $1 AND company_code = $2",
[id, companyCode]
);
if (!existing) {
return res.status(404).json({ success: false, message: "결재선 템플릿을 찾을 수 없습니다." });
}
await query<any>(
"DELETE FROM approval_line_templates WHERE template_id = $1 AND company_code = $2",
[id, companyCode]
);
return res.json({ success: true, message: "결재선 템플릿이 삭제되었습니다." });
} catch (error) {
console.error("결재선 템플릿 삭제 오류:", error);
return res.status(500).json({
success: false,
message: "결재선 템플릿 삭제 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
}
// ============================================================
// 다음 step 활성화 헬퍼 (혼합형 결재선 대응)
// notification step은 자동 통과 후 재귀적으로 다음 step 진행
// ============================================================
// 결재 상태 변경 시 원본 테이블(target_table)의 approval_status를 동기화하는 범용 hook
async function syncApprovalStatusToTarget(
client: PoolClient,
requestId: number,
newStatus: string,
companyCode: string,
): Promise<void> {
try {
const { rows: [req] } = await client.query(
`SELECT target_table, target_record_id FROM approval_requests WHERE request_id = $1 AND company_code = $2`,
[requestId, companyCode],
);
if (!req?.target_table || !req?.target_record_id || req.target_record_id === "0") return;
const { rows: cols } = await client.query(
`SELECT 1 FROM information_schema.columns WHERE table_name = $1 AND column_name = 'approval_status'`,
[req.target_table],
);
if (cols.length === 0) return;
const statusMap: Record<string, string> = {
in_progress: "결재중",
approved: "결재완료",
rejected: "반려",
cancelled: "작성중",
draft: "작성중",
post_pending: "후결대기",
};
const businessStatus = statusMap[newStatus] || newStatus;
const safeTable = req.target_table.replace(/[^a-zA-Z0-9_]/g, "");
// super admin(company_code='*')은 다른 회사 레코드도 업데이트 가능
if (companyCode === "*") {
await client.query(
`UPDATE "${safeTable}" SET approval_status = $1 WHERE id = $2`,
[businessStatus, req.target_record_id],
);
} else {
await client.query(
`UPDATE "${safeTable}" SET approval_status = $1 WHERE id = $2 AND company_code = $3`,
[businessStatus, req.target_record_id, companyCode],
);
}
// 결재 완료(approved) 시 제어관리(노드 플로우) 자동 실행
if (newStatus === "approved") {
await executeAfterApprovalFlow(client, requestId, companyCode, req);
}
} catch (err) {
console.error("[syncApprovalStatusToTarget] 원본 테이블 상태 동기화 실패:", err);
}
}
// 결재 완료 후 제어관리(노드 플로우) 실행 hook
// 우선순위: 템플릿(template) > 정의(definition) > 요청(request) 직접 지정
async function executeAfterApprovalFlow(
client: PoolClient,
requestId: number,
companyCode: string,
approvalReq: { target_table: string; target_record_id: string },
): Promise<void> {
try {
const { rows: [reqData] } = await client.query(
`SELECT r.after_approval_flow_id, r.definition_id, r.template_id, r.title, r.requester_id
FROM approval_requests r WHERE r.request_id = $1`,
[requestId],
);
let flowId: number | null = null;
// 1순위: 템플릿에 연결된 제어관리 플로우
if (reqData?.template_id) {
const { rows: [tmpl] } = await client.query(
`SELECT after_approval_flow_id FROM approval_line_templates WHERE template_id = $1`,
[reqData.template_id],
);
flowId = tmpl?.after_approval_flow_id || null;
}
// 2순위: 정의(definition)에 연결된 제어관리 플로우 (fallback)
if (!flowId && reqData?.definition_id) {
const { rows: [def] } = await client.query(
`SELECT after_approval_flow_id FROM approval_definitions WHERE definition_id = $1`,
[reqData.definition_id],
);
flowId = def?.after_approval_flow_id || null;
}
// 3순위: 요청 자체에 직접 지정된 플로우
if (!flowId) {
flowId = reqData?.after_approval_flow_id || null;
}
if (!flowId) return;
// 3. 원본 레코드 데이터 조회
const safeTable = approvalReq.target_table.replace(/[^a-zA-Z0-9_]/g, "");
const { rows: [targetRecord] } = await client.query(
`SELECT * FROM "${safeTable}" WHERE id = $1`,
[approvalReq.target_record_id],
);
// 4. 노드 플로우 실행
console.log(`[제어관리] 결재 완료 후 플로우 #${flowId} 실행 (request_id=${requestId})`);
const result = await NodeFlowExecutionService.executeFlow(flowId, {
formData: targetRecord || {},
approvalInfo: {
requestId,
title: reqData.title,
requesterId: reqData.requester_id,
targetTable: approvalReq.target_table,
targetRecordId: approvalReq.target_record_id,
},
companyCode,
selectedRows: targetRecord ? [targetRecord] : [],
});
console.log(`[제어관리] 플로우 #${flowId} 실행 결과: ${result.success ? "성공" : "실패"} (${result.executionTime}ms)`);
} catch (err) {
// 제어관리 실패는 결재 승인 자체에 영향 주지 않음
console.error("[executeAfterApprovalFlow] 제어관리 실행 실패:", err);
}
}
async function activateNextStep(
client: PoolClient,
requestId: number,
currentStep: number,
totalSteps: number,
companyCode: string,
userId: string,
comment: string | null,
): Promise<void> {
const nextStep = currentStep + 1;
if (nextStep > totalSteps) {
// 최종 승인 처리
await client.query(
`UPDATE approval_requests
SET status = CASE WHEN approval_type = 'post' THEN 'approved' ELSE 'approved' END,
is_post_approved = CASE WHEN approval_type = 'post' THEN true ELSE is_post_approved END,
post_approved_at = CASE WHEN approval_type = 'post' THEN NOW() ELSE post_approved_at END,
final_approver_id = $1, final_comment = $2, completed_at = NOW(), updated_at = NOW()
WHERE request_id = $3 AND company_code = $4`,
[userId, comment, requestId, companyCode]
);
await syncApprovalStatusToTarget(client, requestId, "approved", companyCode);
return;
}
// 다음 step의 결재 라인 조회 (FOR UPDATE로 동시성 방어)
const { rows: nextLines } = await client.query(
`SELECT * FROM approval_lines
WHERE request_id = $1 AND step_order = $2 AND company_code = $3
FOR UPDATE`,
[requestId, nextStep, companyCode]
);
if (nextLines.length === 0) {
// 다음 step이 비어있으면 최종 승인 처리
await client.query(
`UPDATE approval_requests
SET status = 'approved', final_approver_id = $1, final_comment = $2,
completed_at = NOW(), updated_at = NOW()
WHERE request_id = $3 AND company_code = $4`,
[userId, comment, requestId, companyCode]
);
await syncApprovalStatusToTarget(client, requestId, "approved", companyCode);
return;
}
const nextStepType = nextLines[0].step_type || "approval";
if (nextStepType === "notification") {
// 통보 단계: 자동 approved 처리 후 다음 step으로 재귀
for (const nl of nextLines) {
await client.query(
`UPDATE approval_lines SET status = 'approved', comment = '자동 통보 처리', processed_at = NOW()
WHERE line_id = $1 AND company_code = $2`,
[nl.line_id, companyCode]
);
}
await client.query(
`UPDATE approval_requests SET current_step = $1, updated_at = NOW()
WHERE request_id = $2 AND company_code = $3`,
[nextStep, requestId, companyCode]
);
// 재귀: 통보 다음 step 활성화
await activateNextStep(client, requestId, nextStep, totalSteps, companyCode, userId, comment);
} else {
// approval 또는 consensus: pending으로 전환
await client.query(
`UPDATE approval_lines SET status = 'pending'
WHERE request_id = $1 AND step_order = $2 AND company_code = $3`,
[requestId, nextStep, companyCode]
);
await client.query(
`UPDATE approval_requests SET current_step = $1, updated_at = NOW()
WHERE request_id = $2 AND company_code = $3`,
[nextStep, requestId, companyCode]
);
}
}
// ============================================================
// 결재 요청 (Approval Requests) CRUD
// ============================================================
export class ApprovalRequestController {
// 결재 요청 목록 조회
static async getRequests(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
const userId = req.user?.userId;
if (!companyCode || !userId) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { status, target_table, target_record_id, requester_id, my_approvals, page = "1", limit = "20" } = req.query;
const conditions: string[] = [];
const params: any[] = [];
let idx = 1;
// SUPER_ADMIN은 전체 조회, 일반 회사는 자사 데이터만
if (companyCode !== "*") {
conditions.push(`r.company_code = $${idx++}`);
params.push(companyCode);
}
if (status) {
conditions.push(`r.status = $${idx++}`);
params.push(status);
}
if (target_table) {
conditions.push(`r.target_table = $${idx++}`);
params.push(target_table);
}
if (target_record_id) {
conditions.push(`r.target_record_id = $${idx++}`);
params.push(target_record_id);
}
if (requester_id) {
conditions.push(`r.requester_id = $${idx++}`);
params.push(requester_id);
}
// 내 결재 대기 목록: 현재 사용자가 결재자인 라인만 조회
if (my_approvals === "true") {
conditions.push(
`EXISTS (SELECT 1 FROM approval_lines l WHERE l.request_id = r.request_id AND l.approver_id = $${idx++} AND l.status = 'pending' AND l.company_code = r.company_code)`
);
params.push(userId);
}
const offset = (parseInt(page as string) - 1) * parseInt(limit as string);
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
// countParams는 WHERE 조건 파라미터만 포함 (LIMIT/OFFSET 제외)
// my_approvals 파라미터도 포함된 후 복사해야 함
const countParams = [...params];
const [countRow] = await query<any>(
`SELECT COUNT(*) as total FROM approval_requests r ${whereClause}`,
countParams
);
// LIMIT/OFFSET 파라미터 인덱스를 미리 계산 (countParams 복사 후에 idx 증가)
const limitIdx = idx++;
const offsetIdx = idx++;
params.push(parseInt(limit as string), offset);
const rows = await query<any>(
`SELECT r.*, d.definition_name
FROM approval_requests r
LEFT JOIN approval_definitions d ON r.definition_id = d.definition_id AND r.company_code = d.company_code
${whereClause}
ORDER BY r.created_at DESC
LIMIT $${limitIdx} OFFSET $${offsetIdx}`,
params
);
return res.json({
success: true,
data: rows,
total: parseInt(countRow?.total || "0"),
page: parseInt(page as string),
limit: parseInt(limit as string),
});
} catch (error) {
console.error("결재 요청 목록 조회 오류:", error);
return res.status(500).json({
success: false,
message: "결재 요청 목록 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 결재 요청 상세 조회 (라인 포함)
static async getRequest(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { id } = req.params;
// SUPER_ADMIN은 company_code 필터 없이 모든 요청 조회 가능
const request = await queryOne<any>(
companyCode === "*"
? `SELECT r.*, d.definition_name
FROM approval_requests r
LEFT JOIN approval_definitions d ON r.definition_id = d.definition_id AND r.company_code = d.company_code
WHERE r.request_id = $1`
: `SELECT r.*, d.definition_name
FROM approval_requests r
LEFT JOIN approval_definitions d ON r.definition_id = d.definition_id AND r.company_code = d.company_code
WHERE r.request_id = $1 AND r.company_code = $2`,
companyCode === "*" ? [id] : [id, companyCode]
);
if (!request) {
return res.status(404).json({ success: false, message: "결재 요청을 찾을 수 없습니다." });
}
const lines = await query<any>(
companyCode === "*"
? "SELECT * FROM approval_lines WHERE request_id = $1 ORDER BY step_order ASC"
: "SELECT * FROM approval_lines WHERE request_id = $1 AND company_code = $2 ORDER BY step_order ASC",
companyCode === "*" ? [id] : [id, companyCode]
);
return res.json({ success: true, data: { ...request, lines } });
} catch (error) {
console.error("결재 요청 상세 조회 오류:", error);
return res.status(500).json({
success: false,
message: "결재 요청 상세 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 결재 요청 생성 (혼합형 결재선 지원 - self/escalation/consensus/post)
static async createRequest(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const {
title, description, definition_id, template_id, target_table, target_record_id,
target_record_data, screen_id, button_component_id,
approvers,
approval_mode,
approval_type = "escalation",
} = req.body;
if (!title || !target_table) {
return res.status(400).json({ success: false, message: "제목과 대상 테이블은 필수입니다." });
}
// target_record_id는 NOT NULL 컬럼이므로 빈 값은 기본값으로 대체
const safeTargetRecordId = target_record_id || "0";
const userId = req.user?.userId || "system";
const userName = req.user?.userName || "";
const deptName = req.user?.deptName || "";
// approval_mode를 target_record_data에 병합 저장 (하위호환)
const mergedRecordData = {
...(target_record_data || {}),
approval_mode: approval_mode || "sequential",
};
// ========== 자기결재(전결) ==========
if (approval_type === "self") {
// definition_id가 있으면 allow_self_approval 체크
if (definition_id) {
const def = await queryOne<any>(
"SELECT allow_self_approval FROM approval_definitions WHERE definition_id = $1 AND company_code = $2",
[definition_id, companyCode]
);
if (def && !def.allow_self_approval) {
return res.status(400).json({ success: false, message: "해당 결재 유형은 자기결재(전결)를 허용하지 않습니다." });
}
}
let result: any;
await transaction(async (client) => {
const { rows: reqRows } = await client.query(
`INSERT INTO approval_requests (
title, description, definition_id, template_id, target_table, target_record_id,
target_record_data, status, current_step, total_steps, approval_type,
requester_id, requester_name, requester_dept,
screen_id, button_component_id, company_code,
final_approver_id, completed_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, 'approved', 1, 1, 'self',
$8, $9, $10, $11, $12, $13, $8, NOW())
RETURNING *`,
[
title, description, definition_id, template_id || null, target_table, safeTargetRecordId,
JSON.stringify(mergedRecordData),
userId, userName, deptName,
screen_id, button_component_id, companyCode,
]
);
result = reqRows[0];
// 본인을 결재자로 INSERT (이미 approved)
await client.query(
`INSERT INTO approval_lines (
request_id, step_order, approver_id, approver_name, approver_position,
approver_dept, approver_label, status, step_type, processed_at, company_code
) VALUES ($1, 1, $2, $3, $4, $5, '자기결재', 'approved', 'approval', NOW(), $6)`,
[result.request_id, userId, userName, req.user?.positionName || null, deptName, companyCode]
);
await syncApprovalStatusToTarget(client, result.request_id, "approved", companyCode);
});
return res.status(201).json({ success: true, data: result, message: "자기결재(전결) 처리되었습니다." });
}
// ========== 그 외 유형: approvers 필수 검증 ==========
if (!Array.isArray(approvers) || approvers.length === 0) {
return res.status(400).json({ success: false, message: "결재자를 1명 이상 지정해야 합니다." });
}
// 각 approver에 step_type/step_order 할당 (혼합형 지원)
const hasExplicitStepType = approvers.some((a: any) => a.step_type);
interface NormalizedApprover {
approver_id: string;
approver_name: string | null;
approver_position: string | null;
approver_dept: string | null;
approver_label: string | null;
step_order: number;
step_type: string;
}
let normalizedApprovers: NormalizedApprover[];
if (approval_type === "consensus" && !hasExplicitStepType) {
// 단순 합의결재: 전원 step_order=1, step_type='consensus'
normalizedApprovers = approvers.map((a: any) => ({
approver_id: a.approver_id,
approver_name: a.approver_name || null,
approver_position: a.approver_position || null,
approver_dept: a.approver_dept || null,
approver_label: a.approver_label || "합의 결재",
step_order: 1,
step_type: "consensus",
}));
} else if (hasExplicitStepType) {
// 혼합형: 각 approver에 명시된 step_type/step_order 사용
normalizedApprovers = approvers.map((a: any, i: number) => ({
approver_id: a.approver_id,
approver_name: a.approver_name || null,
approver_position: a.approver_position || null,
approver_dept: a.approver_dept || null,
approver_label: a.approver_label || null,
step_order: a.step_order ?? (i + 1),
step_type: a.step_type || "approval",
}));
} else {
// escalation / post: 기본 sequential
normalizedApprovers = approvers.map((a: any, i: number) => ({
approver_id: a.approver_id,
approver_name: a.approver_name || null,
approver_position: a.approver_position || null,
approver_dept: a.approver_dept || null,
approver_label: a.approver_label || `${i + 1}차 결재`,
step_order: a.step_order ?? (i + 1),
step_type: "approval",
}));
}
// escalation 타입에서 같은 step_order에 2명 이상이면서 step_type이 approval인 경우 에러
const stepOrderGroups = new Map<number, NormalizedApprover[]>();
for (const a of normalizedApprovers) {
const group = stepOrderGroups.get(a.step_order) || [];
group.push(a);
stepOrderGroups.set(a.step_order, group);
}
for (const [stepOrder, group] of stepOrderGroups) {
if (group.length > 1) {
const allApproval = group.every(g => g.step_type === "approval");
if (allApproval) {
return res.status(400).json({
success: false,
message: `step_order ${stepOrder}에 approval 타입 결재자가 2명 이상입니다. consensus로 지정해주세요.`,
});
}
}
}
// total_steps = 고유한 step_order의 최대값
const uniqueStepOrders = [...new Set(normalizedApprovers.map(a => a.step_order))].sort((a, b) => a - b);
const totalSteps = Math.max(...uniqueStepOrders);
// 저장할 approval_type 결정 (혼합형은 escalation으로 저장)
const storedApprovalType = hasExplicitStepType ? "escalation" : approval_type;
const initialStatus = approval_type === "post" ? "post_pending" : "requested";
let result: any;
await transaction(async (client) => {
const { rows: reqRows } = await client.query(
`INSERT INTO approval_requests (
title, description, definition_id, template_id, target_table, target_record_id,
target_record_data, status, current_step, total_steps, approval_type,
requester_id, requester_name, requester_dept,
screen_id, button_component_id, company_code
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 1, $9, $10, $11, $12, $13, $14, $15, $16)
RETURNING *`,
[
title, description, definition_id, template_id || null, target_table, safeTargetRecordId,
JSON.stringify(mergedRecordData), initialStatus, totalSteps, storedApprovalType,
userId, userName, deptName,
screen_id, button_component_id, companyCode,
]
);
result = reqRows[0];
const firstStep = uniqueStepOrders[0];
for (const approver of normalizedApprovers) {
// 첫 번째 step의 결재자만 pending, 나머지는 waiting
let lineStatus: string;
if (approver.step_order === firstStep) {
lineStatus = "pending";
} else {
lineStatus = "waiting";
}
await client.query(
`INSERT INTO approval_lines (
request_id, step_order, approver_id, approver_name, approver_position,
approver_dept, approver_label, status, step_type, company_code
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
[
result.request_id,
approver.step_order,
approver.approver_id,
approver.approver_name,
approver.approver_position,
approver.approver_dept,
approver.approver_label,
lineStatus,
approver.step_type,
companyCode,
]
);
}
// 첫 번째 step이 notification이면 자동 통과 처리
const firstStepLines = normalizedApprovers.filter(a => a.step_order === firstStep);
const firstStepType = firstStepLines[0]?.step_type;
if (firstStepType === "notification") {
// notification은 자동 처리 → activateNextStep으로 재귀
for (const nl of firstStepLines) {
await client.query(
`UPDATE approval_lines SET status = 'approved', comment = '자동 통보 처리', processed_at = NOW()
WHERE request_id = $1 AND step_order = $2 AND approver_id = $3 AND company_code = $4`,
[result.request_id, nl.step_order, nl.approver_id, companyCode]
);
}
await activateNextStep(client, result.request_id, firstStep, totalSteps, companyCode, userId, null);
}
// status를 in_progress로 업데이트 (post_pending 제외)
if (approval_type !== "post") {
await client.query(
`UPDATE approval_requests SET status = 'in_progress' WHERE request_id = $1 AND company_code = $2`,
[result.request_id, companyCode]
);
result.status = "in_progress";
await syncApprovalStatusToTarget(client, result.request_id, "in_progress", companyCode);
} else {
await syncApprovalStatusToTarget(client, result.request_id, "post_pending", companyCode);
}
});
return res.status(201).json({ success: true, data: result, message: "결재 요청이 생성되었습니다." });
} catch (error) {
console.error("결재 요청 생성 오류:", error);
return res.status(500).json({
success: false,
message: "결재 요청 생성 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 결재 요청 회수 (cancel)
static async cancelRequest(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
const userId = req.user?.userId;
if (!companyCode || !userId) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { id } = req.params;
const request = await queryOne<any>(
"SELECT * FROM approval_requests WHERE request_id = $1 AND company_code = $2",
[id, companyCode]
);
if (!request) {
return res.status(404).json({ success: false, message: "결재 요청을 찾을 수 없습니다." });
}
if (request.requester_id !== userId) {
return res.status(403).json({ success: false, message: "본인이 요청한 건만 회수할 수 있습니다." });
}
if (!["requested", "in_progress", "post_pending"].includes(request.status)) {
return res.status(400).json({ success: false, message: "이미 처리된 결재 요청은 회수할 수 없습니다." });
}
await transaction(async (client) => {
await client.query(
"UPDATE approval_requests SET status = 'cancelled', updated_at = NOW() WHERE request_id = $1 AND company_code = $2",
[id, companyCode]
);
await syncApprovalStatusToTarget(client, Number(id), "cancelled", companyCode);
});
return res.json({ success: true, message: "결재 요청이 회수되었습니다." });
} catch (error) {
console.error("결재 요청 회수 오류:", error);
return res.status(500).json({
success: false,
message: "결재 요청 회수 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 후결 처리 엔드포인트
static async postApprove(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
const userId = req.user?.userId;
if (!companyCode || !userId) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { id } = req.params;
const { comment } = req.body;
const request = await queryOne<any>(
"SELECT * FROM approval_requests WHERE request_id = $1 AND company_code = $2",
[id, companyCode]
);
if (!request) {
return res.status(404).json({ success: false, message: "결재 요청을 찾을 수 없습니다." });
}
if (request.approval_type !== "post") {
return res.status(400).json({ success: false, message: "후결 유형의 결재 요청만 후결 처리할 수 있습니다." });
}
if (request.is_post_approved) {
return res.status(400).json({ success: false, message: "이미 후결 처리된 요청입니다." });
}
// 결재선 전원 approved 확인
const [pendingCount] = await query<any>(
`SELECT COUNT(*) as cnt FROM approval_lines
WHERE request_id = $1 AND status NOT IN ('approved', 'skipped') AND company_code = $2`,
[id, companyCode]
);
if (parseInt(pendingCount?.cnt || "0") > 0) {
return res.status(400).json({ success: false, message: "모든 결재자의 승인이 완료되지 않았습니다." });
}
await transaction(async (client) => {
await client.query(
`UPDATE approval_requests
SET status = 'approved', is_post_approved = true, post_approved_at = NOW(),
final_approver_id = $1, final_comment = $2, completed_at = NOW(), updated_at = NOW()
WHERE request_id = $3 AND company_code = $4`,
[userId, comment || null, id, companyCode]
);
await syncApprovalStatusToTarget(client, Number(id), "approved", companyCode);
});
return res.json({ success: true, message: "후결 처리가 완료되었습니다." });
} catch (error) {
console.error("후결 처리 오류:", error);
return res.status(500).json({
success: false,
message: "후결 처리 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
}
// ============================================================
// 결재 라인 처리 (Approval Lines - 승인/반려)
// ============================================================
export class ApprovalLineController {
// 결재 처리 (승인/반려) - FOR UPDATE 동시성 방어 + 대결 + step_type 분기
static async processApproval(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
const userId = req.user?.userId;
if (!companyCode || !userId) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { lineId } = req.params;
const { action, comment, proxy_reason } = req.body;
if (!["approved", "rejected"].includes(action)) {
return res.status(400).json({ success: false, message: "액션은 approved 또는 rejected여야 합니다." });
}
await transaction(async (client) => {
// FOR UPDATE로 결재 라인 잠금
// super admin(*)은 모든 회사의 라인을 처리할 수 있음
const lineQuery = companyCode === "*"
? `SELECT * FROM approval_lines WHERE line_id = $1 FOR UPDATE`
: `SELECT * FROM approval_lines WHERE line_id = $1 AND company_code IN ($2, '*') FOR UPDATE`;
const lineParams = companyCode === "*" ? [lineId] : [lineId, companyCode];
const { rows: [line] } = await client.query(lineQuery, lineParams);
if (!line) {
throw new ValidationError(404, "결재 라인을 찾을 수 없습니다.");
}
if (line.status !== "pending") {
throw new ValidationError(400, "대기 중인 결재만 처리할 수 있습니다.");
}
// 대결(proxy) 인증 로직
let proxyFor: string | null = null;
let proxyReasonVal: string | null = null;
if (line.approver_id !== userId) {
// super admin(company_code='*')은 모든 결재를 대리 처리 가능
if (companyCode === "*") {
proxyFor = line.approver_id;
proxyReasonVal = proxy_reason || "최고관리자 대리 처리";
} else {
const { rows: proxyRows } = await client.query(
`SELECT * FROM approval_proxy_settings
WHERE original_user_id = $1 AND proxy_user_id = $2
AND is_active = 'Y' AND start_date <= CURRENT_DATE AND end_date >= CURRENT_DATE
AND company_code = $3`,
[line.approver_id, userId, companyCode]
);
if (proxyRows.length === 0) {
throw new ValidationError(403, "본인이 결재자로 지정된 건만 처리할 수 있습니다.");
}
proxyFor = line.approver_id;
proxyReasonVal = proxy_reason || proxyRows[0].reason || "대결 처리";
}
}
// 현재 라인 처리 (proxy_for, proxy_reason 포함) - 라인의 company_code 기준
await client.query(
`UPDATE approval_lines
SET status = $1, comment = $2, processed_at = NOW(),
proxy_for = $3, proxy_reason = $4
WHERE line_id = $5 AND company_code = $6`,
[action, comment || null, proxyFor, proxyReasonVal, lineId, line.company_code]
);
// 결재 요청 조회 (FOR UPDATE) - 라인의 company_code 기준
const { rows: [request] } = await client.query(
`SELECT * FROM approval_requests WHERE request_id = $1 AND company_code = $2 FOR UPDATE`,
[line.request_id, line.company_code]
);
if (!request) return;
const lineCC = line.company_code;
if (action === "rejected") {
// 반려: 전체 요청 반려 처리
await client.query(
`UPDATE approval_requests SET status = 'rejected', final_approver_id = $1, final_comment = $2,
completed_at = NOW(), updated_at = NOW()
WHERE request_id = $3 AND company_code = $4`,
[userId, comment || null, line.request_id, lineCC]
);
// 남은 pending/waiting 라인도 skipped 처리
await client.query(
`UPDATE approval_lines SET status = 'skipped'
WHERE request_id = $1 AND status IN ('pending', 'waiting') AND line_id != $2 AND company_code = $3`,
[line.request_id, lineId, lineCC]
);
await syncApprovalStatusToTarget(client, line.request_id, "rejected", lineCC);
} else {
// 승인 처리: step_type 기반 분기
const currentStepType = line.step_type || "approval";
// 기존 isParallelMode 하위호환 (step_type이 없는 기존 데이터)
const recordData = request.target_record_data;
const isLegacyParallel = recordData?.approval_mode === "parallel" && !line.step_type;
if (isLegacyParallel) {
// 레거시 동시결재 (하위호환)
const { rows: remainingLines } = await client.query(
`SELECT COUNT(*) as cnt FROM approval_lines
WHERE request_id = $1 AND status = 'pending' AND line_id != $2 AND company_code = $3`,
[line.request_id, lineId, lineCC]
);
const remaining = parseInt(remainingLines[0]?.cnt || "0");
if (remaining === 0) {
await client.query(
`UPDATE approval_requests SET status = 'approved', final_approver_id = $1, final_comment = $2,
completed_at = NOW(), updated_at = NOW()
WHERE request_id = $3 AND company_code = $4`,
[userId, comment || null, line.request_id, lineCC]
);
await syncApprovalStatusToTarget(client, line.request_id, "approved", lineCC);
}
} else if (currentStepType === "consensus") {
// 합의결재: 같은 step의 모든 결재자 승인 확인
const { rows: remaining } = await client.query(
`SELECT COUNT(*) as cnt FROM approval_lines
WHERE request_id = $1 AND step_order = $2
AND status NOT IN ('approved', 'skipped')
AND line_id != $3 AND company_code = $4`,
[line.request_id, line.step_order, lineId, lineCC]
);
if (parseInt(remaining[0].cnt) === 0) {
// 합의 완료 → 다음 step 활성화
await activateNextStep(
client, line.request_id, line.step_order, request.total_steps,
lineCC, userId, comment || null,
);
}
} else {
// approval (기존 sequential 로직): 다음 step 활성화
await activateNextStep(
client, line.request_id, line.step_order, request.total_steps,
lineCC, userId, comment || null,
);
}
}
});
return res.json({ success: true, message: action === "approved" ? "승인 처리되었습니다." : "반려 처리되었습니다." });
} catch (error) {
// ValidationError는 트랜잭션이 rollback된 후 적절한 HTTP 상태코드로 응답
if (error instanceof Error && error.name === "ValidationError") {
const validationErr = error as any;
return res.status(validationErr.statusCode).json({
success: false,
message: validationErr.message,
});
}
console.error("결재 처리 오류:", error);
return res.status(500).json({
success: false,
message: "결재 처리 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 내 결재 대기 목록 조회
static async getMyPendingLines(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
const userId = req.user?.userId;
if (!companyCode || !userId) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const rows = await query<any>(
`SELECT l.*, r.title, r.target_table, r.target_record_id, r.requester_name, r.requester_dept, r.created_at as request_created_at
FROM approval_lines l
JOIN approval_requests r ON l.request_id = r.request_id AND l.company_code = r.company_code
WHERE l.approver_id = $1 AND l.status = 'pending' AND l.company_code IN ($2, '*')
ORDER BY r.created_at ASC`,
[userId, companyCode]
);
return res.json({ success: true, data: rows });
} catch (error) {
console.error("내 결재 대기 목록 조회 오류:", error);
return res.status(500).json({
success: false,
message: "내 결재 대기 목록 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
}