diff --git a/backend-node/src/controllers/approvalController.ts b/backend-node/src/controllers/approvalController.ts index 84231245..8c179a7e 100644 --- a/backend-node/src/controllers/approvalController.ts +++ b/backend-node/src/controllers/approvalController.ts @@ -1,6 +1,7 @@ import { Response } from "express"; import { AuthenticatedRequest } from "../types/auth"; import { query, queryOne, transaction } from "../database/db"; +import { PoolClient } from "pg"; // ============================================================ // 결재 정의 (Approval Definitions) CRUD @@ -467,6 +468,89 @@ export class ApprovalTemplateController { } } +// ============================================================ +// 다음 step 활성화 헬퍼 (혼합형 결재선 대응) +// notification step은 자동 통과 후 재귀적으로 다음 step 진행 +// ============================================================ + +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] + ); + 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] + ); + 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 // ============================================================ @@ -587,7 +671,7 @@ export class ApprovalRequestController { } } - // 결재 요청 생성 (결재 라인 자동 생성) + // 결재 요청 생성 (혼합형 결재선 지원 - self/escalation/consensus/post) static async createRequest(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user?.companyCode; @@ -598,83 +682,228 @@ export class ApprovalRequestController { const { title, description, definition_id, target_table, target_record_id, target_record_data, screen_id, button_component_id, - approvers, // [{ approver_id, approver_name, approver_position, approver_dept, approver_label }] - approval_mode, // "sequential" | "parallel" + approvers, + approval_mode, + approval_type = "escalation", } = req.body; if (!title || !target_table) { return res.status(400).json({ success: false, message: "제목과 대상 테이블은 필수입니다." }); } - if (!Array.isArray(approvers) || approvers.length === 0) { - return res.status(400).json({ success: false, message: "결재자를 1명 이상 지정해야 합니다." }); - } - const userId = req.user?.userId || "system"; const userName = req.user?.userName || ""; const deptName = req.user?.deptName || ""; - const isParallel = approval_mode === "parallel"; - const totalSteps = approvers.length; - - // approval_mode를 target_record_data에 병합 저장 + // 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, 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, 'approved', 1, 1, 'self', + $7, $8, $9, $10, $11, $12, $7, NOW()) + RETURNING *`, + [ + title, description, definition_id, target_table, target_record_id || null, + 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] + ); + }); + + 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, target_table, target_record_id, - target_record_data, status, current_step, total_steps, + 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, 'requested', 1, $7, $8, $9, $10, $11, $12, $13) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, 1, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING *`, [ title, description, definition_id, target_table, target_record_id || null, - JSON.stringify(mergedRecordData), - totalSteps, + JSON.stringify(mergedRecordData), initialStatus, totalSteps, storedApprovalType, userId, userName, deptName, screen_id, button_component_id, companyCode, ] ); result = reqRows[0]; - // 결재 라인 생성 - // 동시결재: 모든 결재자 pending (step_order는 고유값) / 다단결재: 첫 번째만 pending - for (let i = 0; i < approvers.length; i++) { - const approver = approvers[i]; - const lineStatus = isParallel ? "pending" : (i === 0 ? "pending" : "waiting"); + 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, company_code - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + approver_dept, approver_label, status, step_type, company_code + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, [ result.request_id, - i + 1, + approver.step_order, approver.approver_id, - approver.approver_name || null, - approver.approver_position || null, - approver.approver_dept || null, - approver.approver_label || (isParallel ? "동시 결재" : `${i + 1}차 결재`), + approver.approver_name, + approver.approver_position, + approver.approver_dept, + approver.approver_label, lineStatus, + approver.step_type, companyCode, ] ); } - // 상태를 in_progress로 업데이트 - await client.query( - "UPDATE approval_requests SET status = 'in_progress' WHERE request_id = $1", - [result.request_id] - ); - result.status = "in_progress"; + // 첫 번째 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"; + } }); return res.status(201).json({ success: true, data: result, message: "결재 요청이 생성되었습니다." }); @@ -711,7 +940,7 @@ export class ApprovalRequestController { return res.status(403).json({ success: false, message: "본인이 요청한 건만 회수할 수 있습니다." }); } - if (!["requested", "in_progress"].includes(request.status)) { + if (!["requested", "in_progress", "post_pending"].includes(request.status)) { return res.status(400).json({ success: false, message: "이미 처리된 결재 요청은 회수할 수 없습니다." }); } @@ -730,6 +959,65 @@ export class ApprovalRequestController { }); } } + + // 후결 처리 엔드포인트 + 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 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] + ); + + 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 : "알 수 없는 오류", + }); + } + } } // ============================================================ @@ -737,7 +1025,7 @@ export class ApprovalRequestController { // ============================================================ export class ApprovalLineController { - // 결재 처리 (승인/반려) + // 결재 처리 (승인/반려) - FOR UPDATE 동시성 방어 + 대결 + step_type 분기 static async processApproval(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user?.companyCode; @@ -747,42 +1035,63 @@ export class ApprovalLineController { } const { lineId } = req.params; - const { action, comment } = req.body; // action: 'approved' | 'rejected' + const { action, comment, proxy_reason } = req.body; if (!["approved", "rejected"].includes(action)) { return res.status(400).json({ success: false, message: "액션은 approved 또는 rejected여야 합니다." }); } - const line = await queryOne( - "SELECT * FROM approval_lines WHERE line_id = $1 AND company_code = $2", - [lineId, companyCode] - ); - - if (!line) { - return res.status(404).json({ success: false, message: "결재 라인을 찾을 수 없습니다." }); - } - - if (line.approver_id !== userId) { - return res.status(403).json({ success: false, message: "본인이 결재자로 지정된 건만 처리할 수 있습니다." }); - } - - if (line.status !== "pending") { - return res.status(400).json({ success: false, message: "대기 중인 결재만 처리할 수 있습니다." }); - } - await transaction(async (client) => { - // 현재 라인 처리 - await client.query( - `UPDATE approval_lines SET status = $1, comment = $2, processed_at = NOW() - WHERE line_id = $3`, - [action, comment || null, lineId] + // FOR UPDATE로 결재 라인 잠금 (동시성 방어) + const { rows: [line] } = await client.query( + `SELECT * FROM approval_lines WHERE line_id = $1 AND company_code = $2 FOR UPDATE`, + [lineId, companyCode] ); - const { rows: reqRows } = await client.query( - "SELECT * FROM approval_requests WHERE request_id = $1 AND company_code = $2", + if (!line) { + res.status(404).json({ success: false, message: "결재 라인을 찾을 수 없습니다." }); + return; + } + + if (line.status !== "pending") { + res.status(400).json({ success: false, message: "대기 중인 결재만 처리할 수 있습니다." }); + return; + } + + // 대결(proxy) 인증 로직 + let proxyFor: string | null = null; + let proxyReasonVal: string | null = null; + + if (line.approver_id !== userId) { + 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) { + res.status(403).json({ success: false, message: "본인이 결재자로 지정된 건만 처리할 수 있습니다." }); + return; + } + proxyFor = line.approver_id; + proxyReasonVal = proxy_reason || proxyRows[0].reason || "대결 처리"; + } + + // 현재 라인 처리 (proxy_for, proxy_reason 포함) + 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, companyCode] + ); + + // 결재 요청 조회 (FOR UPDATE) + const { rows: [request] } = await client.query( + `SELECT * FROM approval_requests WHERE request_id = $1 AND company_code = $2 FOR UPDATE`, [line.request_id, companyCode] ); - const request = reqRows[0]; if (!request) return; @@ -791,73 +1100,82 @@ export class ApprovalLineController { 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`, - [userId, comment || null, line.request_id] + WHERE request_id = $3 AND company_code = $4`, + [userId, comment || null, line.request_id, companyCode] ); // 남은 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`, - [line.request_id, lineId] + WHERE request_id = $1 AND status IN ('pending', 'waiting') AND line_id != $2 AND company_code = $3`, + [line.request_id, lineId, companyCode] ); } else { - // 승인: 동시결재 vs 다단결재 분기 - const recordData = request.target_record_data; - const isParallelMode = recordData?.approval_mode === "parallel"; + // 승인 처리: step_type 기반 분기 + const currentStepType = line.step_type || "approval"; - if (isParallelMode) { - // 동시결재: 남은 pending 라인이 있는지 확인 + // 기존 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`, + WHERE request_id = $1 AND status = 'pending' AND line_id != $2 AND company_code = $3 + FOR UPDATE`, [line.request_id, lineId, companyCode] ); 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`, - [userId, comment || null, line.request_id] + WHERE request_id = $3 AND company_code = $4`, + [userId, comment || null, line.request_id, companyCode] ); } - // 아직 남은 결재자 있으면 대기 (상태 변경 없음) - } else { - // 다단결재: 다음 단계 활성화 또는 최종 완료 - const nextStep = line.step_order + 1; + } 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 + FOR UPDATE`, + [line.request_id, line.step_order, lineId, companyCode] + ); - if (nextStep <= request.total_steps) { - await client.query( - `UPDATE approval_lines SET status = 'pending' - WHERE request_id = $1 AND step_order = $2 AND company_code = $3`, - [line.request_id, nextStep, companyCode] - ); - await client.query( - `UPDATE approval_requests SET current_step = $1, updated_at = NOW() WHERE request_id = $2`, - [nextStep, line.request_id] - ); - } else { - 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`, - [userId, comment || null, line.request_id] + if (parseInt(remaining[0].cnt) === 0) { + // 합의 완료 → 다음 step 활성화 + await activateNextStep( + client, line.request_id, line.step_order, request.total_steps, + companyCode, userId, comment || null, ); } + } else { + // approval (기존 sequential 로직): 다음 step 활성화 + await activateNextStep( + client, line.request_id, line.step_order, request.total_steps, + companyCode, userId, comment || null, + ); } } }); - return res.json({ success: true, message: action === "approved" ? "승인 처리되었습니다." : "반려 처리되었습니다." }); + // 트랜잭션이 res에 응답을 보내지 않은 경우 (정상 처리) + if (!res.headersSent) { + return res.json({ success: true, message: action === "approved" ? "승인 처리되었습니다." : "반려 처리되었습니다." }); + } } catch (error) { console.error("결재 처리 오류:", error); - return res.status(500).json({ - success: false, - message: "결재 처리 중 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "알 수 없는 오류", - }); + if (!res.headersSent) { + return res.status(500).json({ + success: false, + message: "결재 처리 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } } } @@ -890,3 +1208,212 @@ export class ApprovalLineController { } } } + +// ============================================================ +// 대결 위임 설정 (Proxy Settings) CRUD +// ============================================================ + +export class ApprovalProxyController { + // 대결 위임 목록 조회 + static async getProxySettings(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); + } + + const { original_user_id, proxy_user_id, is_active } = req.query; + + const conditions: string[] = ["company_code = $1"]; + const params: any[] = [companyCode]; + let idx = 2; + + if (original_user_id) { + conditions.push(`original_user_id = $${idx++}`); + params.push(original_user_id); + } + if (proxy_user_id) { + conditions.push(`proxy_user_id = $${idx++}`); + params.push(proxy_user_id); + } + if (is_active) { + conditions.push(`is_active = $${idx++}`); + params.push(is_active); + } + + const rows = await query( + `SELECT * FROM approval_proxy_settings + WHERE ${conditions.join(" AND ")} + ORDER BY created_at DESC`, + 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 createProxySetting(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); + } + + const { original_user_id, proxy_user_id, start_date, end_date, reason, is_active = "Y" } = req.body; + + if (!original_user_id || !proxy_user_id) { + return res.status(400).json({ success: false, message: "위임자와 대결자는 필수입니다." }); + } + if (!start_date || !end_date) { + return res.status(400).json({ success: false, message: "시작일과 종료일은 필수입니다." }); + } + if (original_user_id === proxy_user_id) { + return res.status(400).json({ success: false, message: "위임자와 대결자가 동일할 수 없습니다." }); + } + + const [row] = await query( + `INSERT INTO approval_proxy_settings + (original_user_id, proxy_user_id, start_date, end_date, reason, is_active, company_code) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING *`, + [original_user_id, proxy_user_id, start_date, end_date, reason || null, is_active, companyCode] + ); + + 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 updateProxySetting(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 id FROM approval_proxy_settings WHERE id = $1 AND company_code = $2", + [id, companyCode] + ); + + if (!existing) { + return res.status(404).json({ success: false, message: "대결 위임 설정을 찾을 수 없습니다." }); + } + + const { original_user_id, proxy_user_id, start_date, end_date, reason, is_active } = req.body; + + const fields: string[] = []; + const params: any[] = []; + let idx = 1; + + if (original_user_id !== undefined) { fields.push(`original_user_id = $${idx++}`); params.push(original_user_id); } + if (proxy_user_id !== undefined) { fields.push(`proxy_user_id = $${idx++}`); params.push(proxy_user_id); } + if (start_date !== undefined) { fields.push(`start_date = $${idx++}`); params.push(start_date); } + if (end_date !== undefined) { fields.push(`end_date = $${idx++}`); params.push(end_date); } + if (reason !== undefined) { fields.push(`reason = $${idx++}`); params.push(reason); } + if (is_active !== undefined) { fields.push(`is_active = $${idx++}`); params.push(is_active); } + + fields.push(`updated_at = NOW()`); + params.push(id, companyCode); + + const [row] = await query( + `UPDATE approval_proxy_settings SET ${fields.join(", ")} + WHERE id = $${idx++} AND company_code = $${idx++} + 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 deleteProxySetting(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 id FROM approval_proxy_settings WHERE id = $1 AND company_code = $2", + [id, companyCode] + ); + + if (!existing) { + return res.status(404).json({ success: false, message: "대결 위임 설정을 찾을 수 없습니다." }); + } + + await query( + "DELETE FROM approval_proxy_settings WHERE 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 : "알 수 없는 오류", + }); + } + } + + // 특정 사용자의 현재 활성 대결자 조회 + static async checkActiveProxy(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); + } + + const { userId: targetUserId } = req.query; + + if (!targetUserId) { + return res.status(400).json({ success: false, message: "userId 파라미터는 필수입니다." }); + } + + const rows = await query( + `SELECT * FROM approval_proxy_settings + WHERE original_user_id = $1 AND is_active = 'Y' + AND start_date <= CURRENT_DATE AND end_date >= CURRENT_DATE + AND company_code = $2`, + [targetUserId, 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 : "알 수 없는 오류", + }); + } + } +} diff --git a/backend-node/src/controllers/approvalProxyController.ts b/backend-node/src/controllers/approvalProxyController.ts new file mode 100644 index 00000000..5788c7bf --- /dev/null +++ b/backend-node/src/controllers/approvalProxyController.ts @@ -0,0 +1,212 @@ +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { query, queryOne } from "../database/db"; + +// ============================================================ +// 대결 위임 설정 (Approval Proxy Settings) CRUD +// ============================================================ + +export class ApprovalProxyController { + // 대결 위임 목록 조회 (user_info JOIN으로 이름/부서 포함) + static async getProxySettings(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); + } + + const rows = await query( + `SELECT ps.*, + u1.user_name AS original_user_name, u1.dept_name AS original_dept_name, + u2.user_name AS proxy_user_name, u2.dept_name AS proxy_dept_name + FROM approval_proxy_settings ps + LEFT JOIN user_info u1 ON ps.original_user_id = u1.user_id AND ps.company_code = u1.company_code + LEFT JOIN user_info u2 ON ps.proxy_user_id = u2.user_id AND ps.company_code = u2.company_code + WHERE ps.company_code = $1 + ORDER BY ps.created_at DESC`, + [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 : "알 수 없는 오류", + }); + } + } + + // 대결 위임 생성 (기간 중복 체크 포함) + static async createProxySetting(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); + } + + const { original_user_id, proxy_user_id, start_date, end_date, reason, is_active = "Y" } = req.body; + + if (!original_user_id || !proxy_user_id) { + return res.status(400).json({ success: false, message: "위임자와 대결자는 필수입니다." }); + } + if (!start_date || !end_date) { + return res.status(400).json({ success: false, message: "시작일과 종료일은 필수입니다." }); + } + if (original_user_id === proxy_user_id) { + return res.status(400).json({ success: false, message: "위임자와 대결자가 동일할 수 없습니다." }); + } + + // 같은 기간 중복 체크 (daterange 오버랩) + const overlap = await queryOne( + `SELECT COUNT(*) AS cnt FROM approval_proxy_settings + WHERE original_user_id = $1 AND is_active = 'Y' + AND daterange(start_date, end_date, '[]') && daterange($2::date, $3::date, '[]') + AND company_code = $4`, + [original_user_id, start_date, end_date, companyCode] + ); + + if (overlap && parseInt(overlap.cnt) > 0) { + return res.status(400).json({ success: false, message: "해당 기간에 이미 대결 설정이 존재합니다." }); + } + + const [row] = await query( + `INSERT INTO approval_proxy_settings + (original_user_id, proxy_user_id, start_date, end_date, reason, is_active, company_code) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING *`, + [original_user_id, proxy_user_id, start_date, end_date, reason || null, is_active, companyCode] + ); + + 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 updateProxySetting(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 id FROM approval_proxy_settings WHERE id = $1 AND company_code = $2", + [id, companyCode] + ); + + if (!existing) { + return res.status(404).json({ success: false, message: "대결 위임 설정을 찾을 수 없습니다." }); + } + + const { proxy_user_id, start_date, end_date, reason, is_active } = req.body; + + const fields: string[] = []; + const params: any[] = []; + let idx = 1; + + if (proxy_user_id !== undefined) { fields.push(`proxy_user_id = $${idx++}`); params.push(proxy_user_id); } + if (start_date !== undefined) { fields.push(`start_date = $${idx++}`); params.push(start_date); } + if (end_date !== undefined) { fields.push(`end_date = $${idx++}`); params.push(end_date); } + if (reason !== undefined) { fields.push(`reason = $${idx++}`); params.push(reason); } + if (is_active !== undefined) { fields.push(`is_active = $${idx++}`); params.push(is_active); } + + if (fields.length === 0) { + return res.status(400).json({ success: false, message: "수정할 필드가 없습니다." }); + } + + fields.push(`updated_at = NOW()`); + params.push(id, companyCode); + + const [row] = await query( + `UPDATE approval_proxy_settings SET ${fields.join(", ")} + WHERE id = $${idx++} AND company_code = $${idx++} + 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 deleteProxySetting(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 result = await query( + "DELETE FROM approval_proxy_settings WHERE id = $1 AND company_code = $2 RETURNING id", + [id, companyCode] + ); + + if (result.length === 0) { + return res.status(404).json({ success: false, message: "대결 위임 설정을 찾을 수 없습니다." }); + } + + 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 checkActiveProxy(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); + } + + const { userId } = req.params; + + if (!userId) { + return res.status(400).json({ success: false, message: "userId 파라미터는 필수입니다." }); + } + + const rows = await query( + `SELECT ps.*, u.user_name AS proxy_user_name + FROM approval_proxy_settings ps + LEFT JOIN user_info u ON ps.proxy_user_id = u.user_id AND ps.company_code = u.company_code + WHERE ps.original_user_id = $1 AND ps.is_active = 'Y' + AND ps.start_date <= CURRENT_DATE AND ps.end_date >= CURRENT_DATE + AND ps.company_code = $2`, + [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 : "알 수 없는 오류", + }); + } + } +} diff --git a/backend-node/src/routes/approvalRoutes.ts b/backend-node/src/routes/approvalRoutes.ts index 3f2cd2f2..d312b344 100644 --- a/backend-node/src/routes/approvalRoutes.ts +++ b/backend-node/src/routes/approvalRoutes.ts @@ -5,6 +5,7 @@ import { ApprovalRequestController, ApprovalLineController, } from "../controllers/approvalController"; +import { ApprovalProxyController } from "../controllers/approvalProxyController"; import { authenticateToken } from "../middleware/authMiddleware"; const router = express.Router(); @@ -30,9 +31,17 @@ router.get("/requests", ApprovalRequestController.getRequests); router.get("/requests/:id", ApprovalRequestController.getRequest); router.post("/requests", ApprovalRequestController.createRequest); router.post("/requests/:id/cancel", ApprovalRequestController.cancelRequest); +router.post("/requests/:id/post-approve", ApprovalRequestController.postApprove); // ==================== 결재 라인 처리 (Lines) ==================== router.get("/my-pending", ApprovalLineController.getMyPendingLines); router.post("/lines/:lineId/process", ApprovalLineController.processApproval); +// ==================== 대결 위임 설정 (Proxy Settings) ==================== +router.get("/proxy-settings", ApprovalProxyController.getProxySettings); +router.post("/proxy-settings", ApprovalProxyController.createProxySetting); +router.put("/proxy-settings/:id", ApprovalProxyController.updateProxySetting); +router.delete("/proxy-settings/:id", ApprovalProxyController.deleteProxySetting); +router.get("/proxy-settings/check/:userId", ApprovalProxyController.checkActiveProxy); + export default router;