[agent-pipeline] pipe-20260305133525-uca5 round-2

This commit is contained in:
DDD1542 2026-03-05 22:43:26 +09:00
parent 74d0e730cd
commit f93dc26338
3 changed files with 849 additions and 101 deletions

View File

@ -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<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]
);
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<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, 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<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, 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<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 query<any>(
`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<any>(
"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<any>(
`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<any>(
`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<any>(
"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<any>(
`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<any>(
"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<any>(
"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<any>(
`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 : "알 수 없는 오류",
});
}
}
}

View File

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

View File

@ -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;