feature/v2-renewal #400

Merged
kjs merged 11 commits from feature/v2-renewal into main 2026-03-04 23:03:04 +09:00
4 changed files with 1336 additions and 0 deletions
Showing only changes of commit 0d71e79c54 - Show all commits

View File

@ -115,6 +115,7 @@ import entitySearchRoutes, { entityOptionsRouter } from "./routes/entitySearchRo
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
import approvalRoutes from "./routes/approvalRoutes"; // 결재 시스템
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
import cascadingRelationRoutes from "./routes/cascadingRelationRoutes"; // 연쇄 드롭다운 관계 관리
@ -310,6 +311,7 @@ app.use("/api/category-tree", categoryTreeRoutes); // 카테고리 트리 (테
app.use("/api/process-work-standard", processWorkStandardRoutes); // 공정 작업기준
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
app.use("/api/approval", approvalRoutes); // 결재 시스템
// app.use("/api/collections", collectionRoutes); // 임시 주석
// app.use("/api/batch", batchRoutes); // 임시 주석
// app.use('/api/users', userRoutes);

View File

@ -0,0 +1,846 @@
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { query, queryOne, transaction } from "../database/db";
// ============================================================
// 결재 정의 (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[] = ["company_code = $1"];
const params: any[] = [companyCode];
let idx = 2;
if (is_active) {
conditions.push(`is_active = $${idx}`);
params.push(is_active);
idx++;
}
if (search) {
conditions.push(`(definition_name ILIKE $${idx} OR definition_name_eng ILIKE $${idx})`);
params.push(`%${search}%`);
idx++;
}
const rows = await query<any>(
`SELECT * FROM approval_definitions WHERE ${conditions.join(" AND ")} ORDER BY 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;
const row = await queryOne<any>(
"SELECT * FROM approval_definitions WHERE definition_id = $1 AND company_code = $2",
[id, companyCode]
);
if (!row) {
return res.status(404).json({ success: false, message: "결재 유형을 찾을 수 없습니다." });
}
return res.json({ success: true, data: row });
} catch (error) {
console.error("결재 유형 상세 조회 오류:", error);
return res.status(500).json({
success: false,
message: "결재 유형 상세 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 결재 유형 생성
static async createDefinition(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const {
definition_name,
definition_name_eng,
description,
default_template_id,
max_steps = 5,
allow_self_approval = false,
allow_cancel = true,
is_active = "Y",
} = req.body;
if (!definition_name) {
return res.status(400).json({ success: false, message: "결재 유형명은 필수입니다." });
}
const userId = req.user?.userId || "system";
const [row] = await query<any>(
`INSERT INTO approval_definitions (
definition_name, definition_name_eng, description, default_template_id,
max_steps, allow_self_approval, allow_cancel, is_active,
company_code, created_by, updated_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $10)
RETURNING *`,
[
definition_name, definition_name_eng, description, default_template_id,
max_steps, allow_self_approval, allow_cancel, is_active,
companyCode, userId,
]
);
return res.status(201).json({ success: true, data: row, message: "결재 유형이 생성되었습니다." });
} catch (error) {
console.error("결재 유형 생성 오류:", error);
return res.status(500).json({
success: false,
message: "결재 유형 생성 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 결재 유형 수정
static async updateDefinition(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { id } = req.params;
const existing = await queryOne<any>(
"SELECT definition_id FROM approval_definitions WHERE definition_id = $1 AND company_code = $2",
[id, companyCode]
);
if (!existing) {
return res.status(404).json({ success: false, message: "결재 유형을 찾을 수 없습니다." });
}
const {
definition_name, definition_name_eng, description, default_template_id,
max_steps, allow_self_approval, allow_cancel, is_active,
} = req.body;
const fields: string[] = [];
const params: any[] = [];
let idx = 1;
if (definition_name !== undefined) { fields.push(`definition_name = $${idx++}`); params.push(definition_name); }
if (definition_name_eng !== undefined) { fields.push(`definition_name_eng = $${idx++}`); params.push(definition_name_eng); }
if (description !== undefined) { fields.push(`description = $${idx++}`); params.push(description); }
if (default_template_id !== undefined) { fields.push(`default_template_id = $${idx++}`); params.push(default_template_id); }
if (max_steps !== undefined) { fields.push(`max_steps = $${idx++}`); params.push(max_steps); }
if (allow_self_approval !== undefined) { fields.push(`allow_self_approval = $${idx++}`); params.push(allow_self_approval); }
if (allow_cancel !== undefined) { fields.push(`allow_cancel = $${idx++}`); params.push(allow_cancel); }
if (is_active !== undefined) { fields.push(`is_active = $${idx++}`); params.push(is_active); }
fields.push(`updated_by = $${idx++}`, `updated_at = NOW()`);
params.push(req.user?.userId || "system");
params.push(id, companyCode);
const [row] = await query<any>(
`UPDATE approval_definitions SET ${fields.join(", ")}
WHERE definition_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 deleteDefinition(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { id } = req.params;
const existing = await queryOne<any>(
"SELECT definition_id FROM approval_definitions WHERE definition_id = $1 AND company_code = $2",
[id, companyCode]
);
if (!existing) {
return res.status(404).json({ success: false, message: "결재 유형을 찾을 수 없습니다." });
}
await query<any>(
"DELETE FROM approval_definitions WHERE definition_id = $1 AND company_code = $2",
[id, companyCode]
);
return res.json({ success: true, message: "결재 유형이 삭제되었습니다." });
} catch (error) {
console.error("결재 유형 삭제 오류:", error);
return res.status(500).json({
success: false,
message: "결재 유형 삭제 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
}
// ============================================================
// 결재선 템플릿 (Approval Line Templates) CRUD
// ============================================================
export class ApprovalTemplateController {
// 템플릿 목록 조회
static async getTemplates(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { definition_id, is_active } = req.query;
const conditions: string[] = ["t.company_code = $1"];
const params: any[] = [companyCode];
let idx = 2;
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 rows = await query<any>(
`SELECT t.*, d.definition_name
FROM approval_line_templates t
LEFT JOIN approval_definitions d ON t.definition_id = d.definition_id AND t.company_code = d.company_code
WHERE ${conditions.join(" AND ")}
ORDER BY 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;
const template = await queryOne<any>(
`SELECT t.*, d.definition_name
FROM approval_line_templates t
LEFT JOIN approval_definitions d ON t.definition_id = d.definition_id AND t.company_code = d.company_code
WHERE t.template_id = $1 AND t.company_code = $2`,
[id, companyCode]
);
if (!template) {
return res.status(404).json({ success: false, message: "결재선 템플릿을 찾을 수 없습니다." });
}
const steps = await query<any>(
"SELECT * FROM approval_line_template_steps WHERE template_id = $1 AND company_code = $2 ORDER BY step_order ASC",
[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, 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, is_active, company_code, created_by, updated_by)
VALUES ($1, $2, $3, $4, $5, $6, $6) RETURNING *`,
[template_name, description, definition_id, is_active, companyCode, userId]
);
result = rows[0];
// 단계 일괄 삽입
if (Array.isArray(steps) && steps.length > 0) {
for (const step of steps) {
await client.query(
`INSERT INTO approval_line_template_steps
(template_id, step_order, approver_type, approver_user_id, approver_position, approver_dept_code, approver_label, company_code)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
result.template_id,
step.step_order,
step.approver_type || "user",
step.approver_user_id || null,
step.approver_position || null,
step.approver_dept_code || null,
step.approver_label || null,
companyCode,
]
);
}
}
});
return res.status(201).json({ success: true, data: result, message: "결재선 템플릿이 생성되었습니다." });
} catch (error) {
console.error("결재선 템플릿 생성 오류:", error);
return res.status(500).json({
success: false,
message: "결재선 템플릿 생성 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 템플릿 수정
static async updateTemplate(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { id } = req.params;
const existing = await queryOne<any>(
"SELECT template_id FROM approval_line_templates WHERE template_id = $1 AND company_code = $2",
[id, companyCode]
);
if (!existing) {
return res.status(404).json({ success: false, message: "결재선 템플릿을 찾을 수 없습니다." });
}
const { template_name, description, definition_id, 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 (is_active !== undefined) { fields.push(`is_active = $${idx++}`); params.push(is_active); }
fields.push(`updated_by = $${idx++}`, `updated_at = NOW()`);
params.push(userId, id, companyCode);
const { rows } = await client.query(
`UPDATE approval_line_templates SET ${fields.join(", ")}
WHERE template_id = $${idx++} AND company_code = $${idx++} RETURNING *`,
params
);
result = rows[0];
// 단계 재등록 (steps 배열이 주어진 경우 전체 교체)
if (Array.isArray(steps)) {
await client.query(
"DELETE FROM approval_line_template_steps WHERE template_id = $1 AND company_code = $2",
[id, companyCode]
);
for (const step of steps) {
await client.query(
`INSERT INTO approval_line_template_steps
(template_id, step_order, approver_type, approver_user_id, approver_position, approver_dept_code, approver_label, company_code)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[id, step.step_order, step.approver_type || "user", step.approver_user_id || null,
step.approver_position || null, step.approver_dept_code || null, step.approver_label || null, companyCode]
);
}
}
});
return res.json({ success: true, data: result, message: "결재선 템플릿이 수정되었습니다." });
} catch (error) {
console.error("결재선 템플릿 수정 오류:", error);
return res.status(500).json({
success: false,
message: "결재선 템플릿 수정 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 템플릿 삭제
static async deleteTemplate(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { id } = req.params;
const existing = await queryOne<any>(
"SELECT template_id FROM approval_line_templates WHERE template_id = $1 AND company_code = $2",
[id, companyCode]
);
if (!existing) {
return res.status(404).json({ success: false, message: "결재선 템플릿을 찾을 수 없습니다." });
}
await query<any>(
"DELETE FROM approval_line_templates WHERE template_id = $1 AND company_code = $2",
[id, companyCode]
);
return res.json({ success: true, message: "결재선 템플릿이 삭제되었습니다." });
} catch (error) {
console.error("결재선 템플릿 삭제 오류:", error);
return res.status(500).json({
success: false,
message: "결재선 템플릿 삭제 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
}
// ============================================================
// 결재 요청 (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, requester_id, my_approvals, page = "1", limit = "20" } = req.query;
const conditions: string[] = ["r.company_code = $1"];
const params: any[] = [companyCode];
let idx = 2;
if (status) {
conditions.push(`r.status = $${idx++}`);
params.push(status);
}
if (target_table) {
conditions.push(`r.target_table = $${idx++}`);
params.push(target_table);
}
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);
params.push(parseInt(limit as string), offset);
const rows = await query<any>(
`SELECT r.*, d.definition_name
FROM approval_requests r
LEFT JOIN approval_definitions d ON r.definition_id = d.definition_id AND r.company_code = d.company_code
WHERE ${conditions.join(" AND ")}
ORDER BY r.created_at DESC
LIMIT $${idx++} OFFSET $${idx++}`,
params
);
// 전체 건수 조회
const countParams = params.slice(0, params.length - 2);
const [countRow] = await query<any>(
`SELECT COUNT(*) as total FROM approval_requests r
WHERE ${conditions.join(" AND ")}`,
countParams
);
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;
const request = await queryOne<any>(
`SELECT r.*, d.definition_name
FROM approval_requests r
LEFT JOIN approval_definitions d ON r.definition_id = d.definition_id AND r.company_code = d.company_code
WHERE r.request_id = $1 AND r.company_code = $2`,
[id, companyCode]
);
if (!request) {
return res.status(404).json({ success: false, message: "결재 요청을 찾을 수 없습니다." });
}
const lines = await query<any>(
"SELECT * FROM approval_lines WHERE request_id = $1 AND company_code = $2 ORDER BY step_order ASC",
[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 : "알 수 없는 오류",
});
}
}
// 결재 요청 생성 (결재 라인 자동 생성)
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, target_table, target_record_id,
target_record_data, screen_id, button_component_id,
approvers, // [{ approver_id, approver_name, approver_position, approver_dept, approver_label }]
} = req.body;
if (!title || !target_table || !target_record_id) {
return res.status(400).json({ success: false, message: "제목, 대상 테이블, 대상 레코드 ID는 필수입니다." });
}
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 || "";
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,
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)
RETURNING *`,
[
title, description, definition_id, target_table, target_record_id,
target_record_data ? JSON.stringify(target_record_data) : null,
approvers.length,
userId, userName, deptName,
screen_id, button_component_id, companyCode,
]
);
result = reqRows[0];
// 결재 라인 생성 (첫 번째 단계는 pending, 나머지는 waiting)
for (let i = 0; i < approvers.length; i++) {
const approver = approvers[i];
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)`,
[
result.request_id,
i + 1,
approver.approver_id,
approver.approver_name || null,
approver.approver_position || null,
approver.approver_dept || null,
approver.approver_label || `${i + 1}차 결재`,
i === 0 ? "pending" : "waiting",
companyCode,
]
);
}
// 상태를 in_progress로 업데이트
await client.query(
"UPDATE approval_requests SET status = 'in_progress' WHERE request_id = $1",
[result.request_id]
);
result.status = "in_progress";
});
return res.status(201).json({ success: true, data: result, message: "결재 요청이 생성되었습니다." });
} catch (error) {
console.error("결재 요청 생성 오류:", error);
return res.status(500).json({
success: false,
message: "결재 요청 생성 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
// 결재 요청 회수 (cancel)
static async cancelRequest(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
const userId = req.user?.userId;
if (!companyCode || !userId) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const { id } = req.params;
const request = await queryOne<any>(
"SELECT * FROM approval_requests WHERE request_id = $1 AND company_code = $2",
[id, companyCode]
);
if (!request) {
return res.status(404).json({ success: false, message: "결재 요청을 찾을 수 없습니다." });
}
if (request.requester_id !== userId) {
return res.status(403).json({ success: false, message: "본인이 요청한 건만 회수할 수 있습니다." });
}
if (!["requested", "in_progress"].includes(request.status)) {
return res.status(400).json({ success: false, message: "이미 처리된 결재 요청은 회수할 수 없습니다." });
}
await query<any>(
"UPDATE approval_requests SET status = 'cancelled', updated_at = NOW() WHERE request_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 Lines - 승인/반려)
// ============================================================
export class ApprovalLineController {
// 결재 처리 (승인/반려)
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 } = req.body; // action: 'approved' | 'rejected'
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]
);
const { rows: reqRows } = await client.query(
"SELECT * FROM approval_requests WHERE request_id = $1 AND company_code = $2",
[line.request_id, companyCode]
);
const request = reqRows[0];
if (!request) return;
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`,
[userId, comment || null, line.request_id]
);
} else {
// 승인: 다음 단계 활성화 또는 최종 완료
const nextStep = line.step_order + 1;
if (nextStep <= request.total_steps) {
// 다음 결재자를 pending으로 변경
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]
);
}
}
});
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 : "알 수 없는 오류",
});
}
}
// 내 결재 대기 목록 조회
static async getMyPendingLines(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
const userId = req.user?.userId;
if (!companyCode || !userId) {
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
}
const rows = await query<any>(
`SELECT l.*, r.title, r.target_table, r.target_record_id, r.requester_name, r.requester_dept, r.created_at as request_created_at
FROM approval_lines l
JOIN approval_requests r ON l.request_id = r.request_id AND l.company_code = r.company_code
WHERE l.approver_id = $1 AND l.status = 'pending' AND l.company_code = $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 : "알 수 없는 오류",
});
}
}
}

View File

@ -0,0 +1,38 @@
import express from "express";
import {
ApprovalDefinitionController,
ApprovalTemplateController,
ApprovalRequestController,
ApprovalLineController,
} from "../controllers/approvalController";
import { authenticateToken } from "../middleware/authMiddleware";
const router = express.Router();
router.use(authenticateToken);
// ==================== 결재 유형 (Definitions) ====================
router.get("/definitions", ApprovalDefinitionController.getDefinitions);
router.get("/definitions/:id", ApprovalDefinitionController.getDefinition);
router.post("/definitions", ApprovalDefinitionController.createDefinition);
router.put("/definitions/:id", ApprovalDefinitionController.updateDefinition);
router.delete("/definitions/:id", ApprovalDefinitionController.deleteDefinition);
// ==================== 결재선 템플릿 (Templates) ====================
router.get("/templates", ApprovalTemplateController.getTemplates);
router.get("/templates/:id", ApprovalTemplateController.getTemplate);
router.post("/templates", ApprovalTemplateController.createTemplate);
router.put("/templates/:id", ApprovalTemplateController.updateTemplate);
router.delete("/templates/:id", ApprovalTemplateController.deleteTemplate);
// ==================== 결재 요청 (Requests) ====================
router.get("/requests", ApprovalRequestController.getRequests);
router.get("/requests/:id", ApprovalRequestController.getRequest);
router.post("/requests", ApprovalRequestController.createRequest);
router.post("/requests/:id/cancel", ApprovalRequestController.cancelRequest);
// ==================== 결재 라인 처리 (Lines) ====================
router.get("/my-pending", ApprovalLineController.getMyPendingLines);
router.post("/lines/:lineId/process", ApprovalLineController.processApproval);
export default router;

View File

@ -0,0 +1,450 @@
/**
* API
* : /api/approval/*
*/
// API URL 동적 설정
const getApiBaseUrl = (): string => {
if (process.env.NEXT_PUBLIC_API_URL) {
return process.env.NEXT_PUBLIC_API_URL;
}
if (typeof window !== "undefined") {
const currentHost = window.location.hostname;
if (currentHost === "v1.vexplor.com") {
return "https://api.vexplor.com/api";
}
if (currentHost === "localhost" || currentHost === "127.0.0.1") {
return "http://localhost:8080/api";
}
}
return "/api";
};
const API_BASE = getApiBaseUrl();
function getAuthToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem("authToken") || sessionStorage.getItem("authToken");
}
function getAuthHeaders(): HeadersInit {
const token = getAuthToken();
const headers: HeadersInit = { "Content-Type": "application/json" };
if (token) {
(headers as Record<string, string>)["Authorization"] = `Bearer ${token}`;
}
return headers;
}
// ============================================================
// 공통 타입 정의
// ============================================================
export interface ApiResponse<T = any> {
success: boolean;
data?: T;
message?: string;
error?: string;
total?: number;
page?: number;
limit?: number;
}
export interface ApprovalDefinition {
definition_id: number;
definition_name: string;
definition_name_eng?: string;
description?: string;
default_template_id?: number;
max_steps: number;
allow_self_approval: boolean;
allow_cancel: boolean;
is_active: string;
company_code: string;
created_by?: string;
created_at: string;
updated_by?: string;
updated_at: string;
}
export interface ApprovalLineTemplate {
template_id: number;
template_name: string;
description?: string;
definition_id?: number;
definition_name?: string;
is_active: string;
company_code: string;
created_by?: string;
created_at: string;
updated_by?: string;
updated_at: string;
steps?: ApprovalLineTemplateStep[];
}
export interface ApprovalLineTemplateStep {
step_id: number;
template_id: number;
step_order: number;
approver_type: "user" | "position" | "dept";
approver_user_id?: string;
approver_position?: string;
approver_dept_code?: string;
approver_label?: string;
company_code: string;
}
export interface ApprovalRequest {
request_id: number;
title: string;
description?: string;
definition_id?: number;
definition_name?: string;
target_table: string;
target_record_id: string;
target_record_data?: Record<string, any>;
status: "requested" | "in_progress" | "approved" | "rejected" | "cancelled";
current_step: number;
total_steps: number;
requester_id: string;
requester_name?: string;
requester_dept?: string;
completed_at?: string;
final_approver_id?: string;
final_comment?: string;
screen_id?: number;
button_component_id?: string;
company_code: string;
created_at: string;
updated_at: string;
lines?: ApprovalLine[];
}
export interface ApprovalLine {
line_id: number;
request_id: number;
step_order: number;
approver_id: string;
approver_name?: string;
approver_position?: string;
approver_dept?: string;
approver_label?: string;
status: "waiting" | "pending" | "approved" | "rejected" | "skipped";
comment?: string;
processed_at?: string;
company_code: string;
created_at: string;
// 요청 정보 (my-pending 조회 시 포함)
title?: string;
target_table?: string;
target_record_id?: string;
requester_name?: string;
requester_dept?: string;
request_created_at?: string;
}
export interface CreateApprovalRequestInput {
title: string;
description?: string;
definition_id?: number;
target_table: string;
target_record_id: string;
target_record_data?: Record<string, any>;
screen_id?: number;
button_component_id?: string;
approvers: {
approver_id: string;
approver_name?: string;
approver_position?: string;
approver_dept?: string;
approver_label?: string;
}[];
}
// ============================================================
// 결재 유형 (Definitions) API
// ============================================================
export async function getApprovalDefinitions(params?: {
is_active?: string;
search?: string;
}): Promise<ApiResponse<ApprovalDefinition[]>> {
try {
const qs = new URLSearchParams();
if (params?.is_active) qs.append("is_active", params.is_active);
if (params?.search) qs.append("search", params.search);
const response = await fetch(
`${API_BASE}/approval/definitions${qs.toString() ? `?${qs}` : ""}`,
{ headers: getAuthHeaders(), credentials: "include" }
);
return await response.json();
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function getApprovalDefinition(id: number): Promise<ApiResponse<ApprovalDefinition>> {
try {
const response = await fetch(`${API_BASE}/approval/definitions/${id}`, {
headers: getAuthHeaders(),
credentials: "include",
});
return await response.json();
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function createApprovalDefinition(data: {
definition_name: string;
definition_name_eng?: string;
description?: string;
default_template_id?: number;
max_steps?: number;
allow_self_approval?: boolean;
allow_cancel?: boolean;
is_active?: string;
}): Promise<ApiResponse<ApprovalDefinition>> {
try {
const response = await fetch(`${API_BASE}/approval/definitions`, {
method: "POST",
headers: getAuthHeaders(),
credentials: "include",
body: JSON.stringify(data),
});
return await response.json();
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function updateApprovalDefinition(
id: number,
data: Partial<ApprovalDefinition>
): Promise<ApiResponse<ApprovalDefinition>> {
try {
const response = await fetch(`${API_BASE}/approval/definitions/${id}`, {
method: "PUT",
headers: getAuthHeaders(),
credentials: "include",
body: JSON.stringify(data),
});
return await response.json();
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function deleteApprovalDefinition(id: number): Promise<ApiResponse<void>> {
try {
const response = await fetch(`${API_BASE}/approval/definitions/${id}`, {
method: "DELETE",
headers: getAuthHeaders(),
credentials: "include",
});
return await response.json();
} catch (error: any) {
return { success: false, error: error.message };
}
}
// ============================================================
// 결재선 템플릿 (Templates) API
// ============================================================
export async function getApprovalTemplates(params?: {
definition_id?: number;
is_active?: string;
}): Promise<ApiResponse<ApprovalLineTemplate[]>> {
try {
const qs = new URLSearchParams();
if (params?.definition_id) qs.append("definition_id", String(params.definition_id));
if (params?.is_active) qs.append("is_active", params.is_active);
const response = await fetch(
`${API_BASE}/approval/templates${qs.toString() ? `?${qs}` : ""}`,
{ headers: getAuthHeaders(), credentials: "include" }
);
return await response.json();
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function getApprovalTemplate(id: number): Promise<ApiResponse<ApprovalLineTemplate>> {
try {
const response = await fetch(`${API_BASE}/approval/templates/${id}`, {
headers: getAuthHeaders(),
credentials: "include",
});
return await response.json();
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function createApprovalTemplate(data: {
template_name: string;
description?: string;
definition_id?: number;
is_active?: string;
steps?: Omit<ApprovalLineTemplateStep, "step_id" | "template_id" | "company_code">[];
}): Promise<ApiResponse<ApprovalLineTemplate>> {
try {
const response = await fetch(`${API_BASE}/approval/templates`, {
method: "POST",
headers: getAuthHeaders(),
credentials: "include",
body: JSON.stringify(data),
});
return await response.json();
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function updateApprovalTemplate(
id: number,
data: {
template_name?: string;
description?: string;
definition_id?: number;
is_active?: string;
steps?: Omit<ApprovalLineTemplateStep, "step_id" | "template_id" | "company_code">[];
}
): Promise<ApiResponse<ApprovalLineTemplate>> {
try {
const response = await fetch(`${API_BASE}/approval/templates/${id}`, {
method: "PUT",
headers: getAuthHeaders(),
credentials: "include",
body: JSON.stringify(data),
});
return await response.json();
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function deleteApprovalTemplate(id: number): Promise<ApiResponse<void>> {
try {
const response = await fetch(`${API_BASE}/approval/templates/${id}`, {
method: "DELETE",
headers: getAuthHeaders(),
credentials: "include",
});
return await response.json();
} catch (error: any) {
return { success: false, error: error.message };
}
}
// ============================================================
// 결재 요청 (Requests) API
// ============================================================
export async function getApprovalRequests(params?: {
status?: string;
target_table?: string;
requester_id?: string;
my_approvals?: boolean;
page?: number;
limit?: number;
}): Promise<ApiResponse<ApprovalRequest[]>> {
try {
const qs = new URLSearchParams();
if (params?.status) qs.append("status", params.status);
if (params?.target_table) qs.append("target_table", params.target_table);
if (params?.requester_id) qs.append("requester_id", params.requester_id);
if (params?.my_approvals !== undefined) qs.append("my_approvals", String(params.my_approvals));
if (params?.page) qs.append("page", String(params.page));
if (params?.limit) qs.append("limit", String(params.limit));
const response = await fetch(
`${API_BASE}/approval/requests${qs.toString() ? `?${qs}` : ""}`,
{ headers: getAuthHeaders(), credentials: "include" }
);
return await response.json();
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function getApprovalRequest(id: number): Promise<ApiResponse<ApprovalRequest>> {
try {
const response = await fetch(`${API_BASE}/approval/requests/${id}`, {
headers: getAuthHeaders(),
credentials: "include",
});
return await response.json();
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function createApprovalRequest(
data: CreateApprovalRequestInput
): Promise<ApiResponse<ApprovalRequest>> {
try {
const response = await fetch(`${API_BASE}/approval/requests`, {
method: "POST",
headers: getAuthHeaders(),
credentials: "include",
body: JSON.stringify(data),
});
return await response.json();
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function cancelApprovalRequest(id: number): Promise<ApiResponse<void>> {
try {
const response = await fetch(`${API_BASE}/approval/requests/${id}/cancel`, {
method: "POST",
headers: getAuthHeaders(),
credentials: "include",
});
return await response.json();
} catch (error: any) {
return { success: false, error: error.message };
}
}
// ============================================================
// 결재 라인 처리 (Lines) API
// ============================================================
export async function getMyPendingApprovals(): Promise<ApiResponse<ApprovalLine[]>> {
try {
const response = await fetch(`${API_BASE}/approval/my-pending`, {
headers: getAuthHeaders(),
credentials: "include",
});
return await response.json();
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function processApprovalLine(
lineId: number,
data: { action: "approved" | "rejected"; comment?: string }
): Promise<ApiResponse<void>> {
try {
const response = await fetch(`${API_BASE}/approval/lines/${lineId}/process`, {
method: "POST",
headers: getAuthHeaders(),
credentials: "include",
body: JSON.stringify(data),
});
return await response.json();
} catch (error: any) {
return { success: false, error: error.message };
}
}