2026-03-03 21:49:56 +09:00
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 : "인증 정보가 없습니다." } ) ;
}
2026-03-04 18:26:16 +09:00
const { status , target_table , target_record_id , requester_id , my_approvals , page = "1" , limit = "20" } = req . query ;
2026-03-03 21:49:56 +09:00
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 ) ;
}
2026-03-04 18:26:16 +09:00
if ( target_record_id ) {
conditions . push ( ` r.target_record_id = $ ${ idx ++ } ` ) ;
params . push ( target_record_id ) ;
}
2026-03-03 21:49:56 +09:00
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 }]
2026-03-04 18:26:16 +09:00
approval_mode , // "sequential" | "parallel"
2026-03-03 21:49:56 +09:00
} = req . body ;
2026-03-04 18:26:16 +09:00
if ( ! title || ! target_table ) {
return res . status ( 400 ) . json ( { success : false , message : "제목과 대상 테이블은 필수입니다." } ) ;
2026-03-03 21:49:56 +09:00
}
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 || "" ;
2026-03-04 18:26:16 +09:00
const isParallel = approval_mode === "parallel" ;
const totalSteps = approvers . length ;
// approval_mode를 target_record_data에 병합 저장
const mergedRecordData = {
. . . ( target_record_data || { } ) ,
approval_mode : approval_mode || "sequential" ,
} ;
2026-03-03 21:49:56 +09:00
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 * ` ,
[
2026-03-04 18:26:16 +09:00
title , description , definition_id , target_table , target_record_id || null ,
JSON . stringify ( mergedRecordData ) ,
totalSteps ,
2026-03-03 21:49:56 +09:00
userId , userName , deptName ,
screen_id , button_component_id , companyCode ,
]
) ;
result = reqRows [ 0 ] ;
2026-03-04 18:26:16 +09:00
// 결재 라인 생성
// 동시결재: 모든 결재자 pending (step_order는 고유값) / 다단결재: 첫 번째만 pending
2026-03-03 21:49:56 +09:00
for ( let i = 0 ; i < approvers . length ; i ++ ) {
const approver = approvers [ i ] ;
2026-03-04 18:26:16 +09:00
const lineStatus = isParallel ? "pending" : ( i === 0 ? "pending" : "waiting" ) ;
2026-03-03 21:49:56 +09:00
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 ,
2026-03-04 18:26:16 +09:00
approver . approver_label || ( isParallel ? "동시 결재" : ` ${ i + 1 } 차 결재 ` ) ,
lineStatus ,
2026-03-03 21:49:56 +09:00
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 ]
) ;
2026-03-04 18:26:16 +09:00
// 남은 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 ]
) ;
2026-03-03 21:49:56 +09:00
} else {
2026-03-04 18:26:16 +09:00
// 승인: 동시결재 vs 다단결재 분기
const recordData = request . target_record_data ;
const isParallelMode = recordData ? . approval_mode === "parallel" ;
if ( isParallelMode ) {
// 동시결재: 남은 pending 라인이 있는지 확인
const { rows : remainingLines } = await client . query (
` SELECT COUNT(*) as cnt FROM approval_lines
WHERE request_id = $1 AND status = 'pending' AND line_id != $2 AND company_code = $3 ` ,
[ line . request_id , lineId , companyCode ]
2026-03-03 21:49:56 +09:00
) ;
2026-03-04 18:26:16 +09:00
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 ]
) ;
}
// 아직 남은 결재자 있으면 대기 (상태 변경 없음)
2026-03-03 21:49:56 +09:00
} else {
2026-03-04 18:26:16 +09:00
// 다단결재: 다음 단계 활성화 또는 최종 완료
const nextStep = line . step_order + 1 ;
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 ]
) ;
}
2026-03-03 21:49:56 +09:00
}
}
} ) ;
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 : "알 수 없는 오류" ,
} ) ;
}
}
}