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( `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( 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( `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( "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( `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( "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( "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( `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( 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( 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( "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( "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( "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 { 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 = { 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 { 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 { 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( `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( `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( 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( 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( "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(); 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( "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( "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( `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( `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 : "알 수 없는 오류", }); } } }