2026-03-03 21:49:56 +09:00
import { Response } from "express" ;
import { AuthenticatedRequest } from "../types/auth" ;
import { query , queryOne , transaction } from "../database/db" ;
2026-03-05 22:43:26 +09:00
import { PoolClient } from "pg" ;
2026-03-03 21:49:56 +09:00
2026-03-06 02:51:51 +09:00
// 트랜잭션 내부에서 throw하고 외부에서 instanceof로 구분하기 위한 커스텀 에러
class ValidationError extends Error {
constructor ( public statusCode : number , message : string ) {
super ( message ) ;
this . name = "ValidationError" ;
}
}
2026-03-03 21:49:56 +09:00
// ============================================================
// 결재 정의 (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 ;
2026-03-06 02:51:51 +09:00
const conditions : string [ ] = [ ] ;
const params : any [ ] = [ ] ;
let idx = 1 ;
// SUPER_ADMIN은 전체 조회, 일반 회사는 자사 데이터만
if ( companyCode === "*" ) {
// 전체 조회 (company_code 필터 없음)
} else {
conditions . push ( ` company_code = $ ${ idx ++ } ` ) ;
params . push ( companyCode ) ;
}
2026-03-03 21:49:56 +09:00
if ( is_active ) {
2026-03-06 02:51:51 +09:00
conditions . push ( ` is_active = $ ${ idx ++ } ` ) ;
2026-03-03 21:49:56 +09:00
params . push ( is_active ) ;
}
if ( search ) {
2026-03-06 02:51:51 +09:00
// ILIKE에서 같은 파라미터를 두 조건에서 참조 (파라미터는 1개만 push)
2026-03-03 21:49:56 +09:00
conditions . push ( ` (definition_name ILIKE $ ${ idx } OR definition_name_eng ILIKE $ ${ idx } ) ` ) ;
params . push ( ` % ${ search } % ` ) ;
idx ++ ;
}
2026-03-06 02:51:51 +09:00
const whereClause = conditions . length > 0 ? ` WHERE ${ conditions . join ( " AND " ) } ` : "" ;
2026-03-03 21:49:56 +09:00
const rows = await query < any > (
2026-03-06 02:51:51 +09:00
` SELECT * FROM approval_definitions ${ whereClause } ORDER BY company_code, definition_id ASC ` ,
2026-03-03 21:49:56 +09:00
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 ;
2026-03-06 03:21:42 +09:00
// SUPER_ADMIN은 company_code 필터 없이 조회 가능
2026-03-03 21:49:56 +09:00
const row = await queryOne < any > (
2026-03-06 03:21:42 +09:00
companyCode === "*"
? "SELECT * FROM approval_definitions WHERE definition_id = $1"
: "SELECT * FROM approval_definitions WHERE definition_id = $1 AND company_code = $2" ,
companyCode === "*" ? [ id ] : [ id , companyCode ]
2026-03-03 21:49:56 +09:00
) ;
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" ) ;
2026-03-06 02:27:10 +09:00
// WHERE 절 파라미터 인덱스를 미리 계산 (쿼리 문자열 내 idx++ 호출 순서 보장)
const idIdx = idx ++ ;
const ccIdx = idx ++ ;
2026-03-03 21:49:56 +09:00
params . push ( id , companyCode ) ;
const [ row ] = await query < any > (
` UPDATE approval_definitions SET ${ fields . join ( ", " ) }
2026-03-06 02:27:10 +09:00
WHERE definition_id = $ $ { idIdx } AND company_code = $ $ { ccIdx } RETURNING * ` ,
2026-03-03 21:49:56 +09:00
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 ;
2026-03-06 02:51:51 +09:00
const conditions : string [ ] = [ ] ;
const params : any [ ] = [ ] ;
let idx = 1 ;
// SUPER_ADMIN은 전체 조회, 일반 회사는 자사 데이터만
if ( companyCode !== "*" ) {
conditions . push ( ` t.company_code = $ ${ idx ++ } ` ) ;
params . push ( companyCode ) ;
}
2026-03-03 21:49:56 +09:00
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 ) ;
}
2026-03-06 02:51:51 +09:00
const whereClause = conditions . length > 0 ? ` WHERE ${ conditions . join ( " AND " ) } ` : "" ;
2026-03-03 21:49:56 +09:00
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
2026-03-06 02:51:51 +09:00
$ { whereClause }
ORDER BY t . company_code , t . template_id ASC ` ,
2026-03-03 21:49:56 +09:00
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 ;
2026-03-06 03:21:42 +09:00
// SUPER_ADMIN은 company_code 필터 없이 조회 가능
2026-03-03 21:49:56 +09:00
const template = await queryOne < any > (
2026-03-06 03:21:42 +09:00
companyCode === "*"
? ` SELECT t.*, d.definition_name
FROM approval_line_templates t
LEFT JOIN approval_definitions d ON t . definition_id = d . definition_id AND t . company_code = d . company_code
WHERE t . template_id = $1 `
: ` SELECT t.*, d.definition_name
FROM approval_line_templates t
LEFT JOIN approval_definitions d ON t . definition_id = d . definition_id AND t . company_code = d . company_code
WHERE t . template_id = $1 AND t . company_code = $2 ` ,
companyCode === "*" ? [ id ] : [ id , companyCode ]
2026-03-03 21:49:56 +09:00
) ;
if ( ! template ) {
return res . status ( 404 ) . json ( { success : false , message : "결재선 템플릿을 찾을 수 없습니다." } ) ;
}
const steps = await query < any > (
2026-03-06 03:21:42 +09:00
companyCode === "*"
? "SELECT * FROM approval_line_template_steps WHERE template_id = $1 ORDER BY step_order ASC"
: "SELECT * FROM approval_line_template_steps WHERE template_id = $1 AND company_code = $2 ORDER BY step_order ASC" ,
companyCode === "*" ? [ id ] : [ id , companyCode ]
2026-03-03 21:49:56 +09:00
) ;
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() ` ) ;
2026-03-06 02:27:10 +09:00
params . push ( userId ) ;
// WHERE 절 파라미터 인덱스를 미리 계산
const tmplIdx = idx ++ ;
const ccIdx = idx ++ ;
params . push ( id , companyCode ) ;
2026-03-03 21:49:56 +09:00
const { rows } = await client . query (
` UPDATE approval_line_templates SET ${ fields . join ( ", " ) }
2026-03-06 02:27:10 +09:00
WHERE template_id = $ $ { tmplIdx } AND company_code = $ $ { ccIdx } RETURNING * ` ,
2026-03-03 21:49:56 +09:00
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 : "알 수 없는 오류" ,
} ) ;
}
}
}
2026-03-05 22:43:26 +09:00
// ============================================================
// 다음 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 ]
) ;
}
}
2026-03-03 21:49:56 +09:00
// ============================================================
// 결재 요청 (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
2026-03-06 02:51:51 +09:00
const conditions : string [ ] = [ ] ;
const params : any [ ] = [ ] ;
let idx = 1 ;
// SUPER_ADMIN은 전체 조회, 일반 회사는 자사 데이터만
if ( companyCode !== "*" ) {
conditions . push ( ` r.company_code = $ ${ idx ++ } ` ) ;
params . push ( companyCode ) ;
}
2026-03-03 21:49:56 +09:00
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 ) ;
2026-03-06 02:51:51 +09:00
const whereClause = conditions . length > 0 ? ` WHERE ${ conditions . join ( " AND " ) } ` : "" ;
2026-03-06 02:27:10 +09:00
2026-03-06 03:21:42 +09:00
// countParams는 WHERE 조건 파라미터만 포함 (LIMIT/OFFSET 제외)
// my_approvals 파라미터도 포함된 후 복사해야 함
2026-03-06 02:27:10 +09:00
const countParams = [ . . . params ] ;
const [ countRow ] = await query < any > (
2026-03-06 02:51:51 +09:00
` SELECT COUNT(*) as total FROM approval_requests r ${ whereClause } ` ,
2026-03-06 02:27:10 +09:00
countParams
) ;
2026-03-06 03:21:42 +09:00
// LIMIT/OFFSET 파라미터 인덱스를 미리 계산 (countParams 복사 후에 idx 증가)
2026-03-06 02:27:10 +09:00
const limitIdx = idx ++ ;
const offsetIdx = idx ++ ;
2026-03-03 21:49:56 +09:00
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
2026-03-06 02:51:51 +09:00
$ { whereClause }
2026-03-03 21:49:56 +09:00
ORDER BY r . created_at DESC
2026-03-06 02:27:10 +09:00
LIMIT $ $ { limitIdx } OFFSET $ $ { offsetIdx } ` ,
2026-03-03 21:49:56 +09:00
params
) ;
return res . json ( {
success : true ,
data : rows ,
total : parseInt ( countRow ? . total || "0" ) ,
page : parseInt ( page as string ) ,
limit : parseInt ( limit as string ) ,
} ) ;
} catch ( error ) {
console . error ( "결재 요청 목록 조회 오류:" , error ) ;
return res . status ( 500 ) . json ( {
success : false ,
message : "결재 요청 목록 조회 중 오류가 발생했습니다." ,
error : error instanceof Error ? error . message : "알 수 없는 오류" ,
} ) ;
}
}
// 결재 요청 상세 조회 (라인 포함)
static async getRequest ( req : AuthenticatedRequest , res : Response ) {
try {
const companyCode = req . user ? . companyCode ;
if ( ! companyCode ) {
return res . status ( 401 ) . json ( { success : false , message : "인증 정보가 없습니다." } ) ;
}
const { id } = req . params ;
2026-03-06 03:21:42 +09:00
// SUPER_ADMIN은 company_code 필터 없이 모든 요청 조회 가능
2026-03-03 21:49:56 +09:00
const request = await queryOne < any > (
2026-03-06 03:21:42 +09:00
companyCode === "*"
? ` SELECT r.*, d.definition_name
FROM approval_requests r
LEFT JOIN approval_definitions d ON r . definition_id = d . definition_id AND r . company_code = d . company_code
WHERE r . request_id = $1 `
: ` SELECT r.*, d.definition_name
FROM approval_requests r
LEFT JOIN approval_definitions d ON r . definition_id = d . definition_id AND r . company_code = d . company_code
WHERE r . request_id = $1 AND r . company_code = $2 ` ,
companyCode === "*" ? [ id ] : [ id , companyCode ]
2026-03-03 21:49:56 +09:00
) ;
if ( ! request ) {
return res . status ( 404 ) . json ( { success : false , message : "결재 요청을 찾을 수 없습니다." } ) ;
}
const lines = await query < any > (
2026-03-06 03:21:42 +09:00
companyCode === "*"
? "SELECT * FROM approval_lines WHERE request_id = $1 ORDER BY step_order ASC"
: "SELECT * FROM approval_lines WHERE request_id = $1 AND company_code = $2 ORDER BY step_order ASC" ,
companyCode === "*" ? [ id ] : [ id , companyCode ]
2026-03-03 21:49:56 +09:00
) ;
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 : "알 수 없는 오류" ,
} ) ;
}
}
2026-03-05 22:43:26 +09:00
// 결재 요청 생성 (혼합형 결재선 지원 - self/escalation/consensus/post)
2026-03-03 21:49:56 +09:00
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 ,
2026-03-05 22:43:26 +09:00
approvers ,
approval_mode ,
approval_type = "escalation" ,
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
}
2026-03-06 02:27:10 +09:00
// target_record_id는 NOT NULL 컬럼이므로 빈 값은 기본값으로 대체
const safeTargetRecordId = target_record_id || "0" ;
2026-03-03 21:49:56 +09:00
const userId = req . user ? . userId || "system" ;
const userName = req . user ? . userName || "" ;
const deptName = req . user ? . deptName || "" ;
2026-03-05 22:43:26 +09:00
// approval_mode를 target_record_data에 병합 저장 (하위호환)
2026-03-04 18:26:16 +09:00
const mergedRecordData = {
. . . ( target_record_data || { } ) ,
approval_mode : approval_mode || "sequential" ,
} ;
2026-03-05 22:43:26 +09:00
// ========== 자기결재(전결) ==========
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 * ` ,
[
2026-03-06 02:27:10 +09:00
title , description , definition_id , target_table , safeTargetRecordId ,
2026-03-05 22:43:26 +09:00
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" ;
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 ,
2026-03-05 22:43:26 +09:00
target_record_data , status , current_step , total_steps , approval_type ,
2026-03-03 21:49:56 +09:00
requester_id , requester_name , requester_dept ,
screen_id , button_component_id , company_code
2026-03-05 22:43:26 +09:00
) VALUES ( $1 , $2 , $3 , $4 , $5 , $6 , $7 , 1 , $8 , $9 , $10 , $11 , $12 , $13 , $14 , $15 )
2026-03-03 21:49:56 +09:00
RETURNING * ` ,
[
2026-03-06 02:27:10 +09:00
title , description , definition_id , target_table , safeTargetRecordId ,
2026-03-05 22:43:26 +09:00
JSON . stringify ( mergedRecordData ) , initialStatus , totalSteps , storedApprovalType ,
2026-03-03 21:49:56 +09:00
userId , userName , deptName ,
screen_id , button_component_id , companyCode ,
]
) ;
result = reqRows [ 0 ] ;
2026-03-05 22:43:26 +09:00
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" ;
}
2026-03-04 18:26:16 +09:00
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 ,
2026-03-05 22:43:26 +09:00
approver_dept , approver_label , status , step_type , company_code
) VALUES ( $1 , $2 , $3 , $4 , $5 , $6 , $7 , $8 , $9 , $10 ) ` ,
2026-03-03 21:49:56 +09:00
[
result . request_id ,
2026-03-05 22:43:26 +09:00
approver . step_order ,
2026-03-03 21:49:56 +09:00
approver . approver_id ,
2026-03-05 22:43:26 +09:00
approver . approver_name ,
approver . approver_position ,
approver . approver_dept ,
approver . approver_label ,
2026-03-04 18:26:16 +09:00
lineStatus ,
2026-03-05 22:43:26 +09:00
approver . step_type ,
2026-03-03 21:49:56 +09:00
companyCode ,
]
) ;
}
2026-03-05 22:43:26 +09:00
// 첫 번째 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" ;
}
2026-03-03 21:49:56 +09:00
} ) ;
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 : "본인이 요청한 건만 회수할 수 있습니다." } ) ;
}
2026-03-05 22:43:26 +09:00
if ( ! [ "requested" , "in_progress" , "post_pending" ] . includes ( request . status ) ) {
2026-03-03 21:49:56 +09:00
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 : "알 수 없는 오류" ,
} ) ;
}
}
2026-03-05 22:43:26 +09:00
// 후결 처리 엔드포인트
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 : "알 수 없는 오류" ,
} ) ;
}
}
2026-03-03 21:49:56 +09:00
}
// ============================================================
// 결재 라인 처리 (Approval Lines - 승인/반려)
// ============================================================
export class ApprovalLineController {
2026-03-05 22:43:26 +09:00
// 결재 처리 (승인/반려) - FOR UPDATE 동시성 방어 + 대결 + step_type 분기
2026-03-03 21:49:56 +09:00
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 ;
2026-03-05 22:43:26 +09:00
const { action , comment , proxy_reason } = req . body ;
2026-03-03 21:49:56 +09:00
if ( ! [ "approved" , "rejected" ] . includes ( action ) ) {
return res . status ( 400 ) . json ( { success : false , message : "액션은 approved 또는 rejected여야 합니다." } ) ;
}
2026-03-05 22:43:26 +09:00
await transaction ( async ( client ) = > {
// FOR UPDATE로 결재 라인 잠금 (동시성 방어)
const { rows : [ line ] } = await client . query (
` SELECT * FROM approval_lines WHERE line_id = $ 1 AND company_code = $ 2 FOR UPDATE ` ,
[ lineId , companyCode ]
) ;
2026-03-03 21:49:56 +09:00
2026-03-05 22:43:26 +09:00
if ( ! line ) {
2026-03-06 01:24:50 +09:00
throw new ValidationError ( 404 , "결재 라인을 찾을 수 없습니다." ) ;
2026-03-05 22:43:26 +09:00
}
2026-03-03 21:49:56 +09:00
2026-03-05 22:43:26 +09:00
if ( line . status !== "pending" ) {
2026-03-06 01:24:50 +09:00
throw new ValidationError ( 400 , "대기 중인 결재만 처리할 수 있습니다." ) ;
2026-03-05 22:43:26 +09:00
}
2026-03-03 21:49:56 +09:00
2026-03-05 22:43:26 +09:00
// 대결(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 ) {
2026-03-06 01:24:50 +09:00
throw new ValidationError ( 403 , "본인이 결재자로 지정된 건만 처리할 수 있습니다." ) ;
2026-03-05 22:43:26 +09:00
}
proxyFor = line . approver_id ;
proxyReasonVal = proxy_reason || proxyRows [ 0 ] . reason || "대결 처리" ;
}
2026-03-03 21:49:56 +09:00
2026-03-05 22:43:26 +09:00
// 현재 라인 처리 (proxy_for, proxy_reason 포함)
2026-03-03 21:49:56 +09:00
await client . query (
2026-03-05 22:43:26 +09:00
` 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 ]
2026-03-03 21:49:56 +09:00
) ;
2026-03-05 22:43:26 +09:00
// 결재 요청 조회 (FOR UPDATE)
const { rows : [ request ] } = await client . query (
` SELECT * FROM approval_requests WHERE request_id = $ 1 AND company_code = $ 2 FOR UPDATE ` ,
2026-03-03 21:49:56 +09:00
[ line . request_id , companyCode ]
) ;
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 ( )
2026-03-05 22:43:26 +09:00
WHERE request_id = $3 AND company_code = $4 ` ,
[ userId , comment || null , line . request_id , companyCode ]
2026-03-03 21:49:56 +09:00
) ;
2026-03-04 18:26:16 +09:00
// 남은 pending/waiting 라인도 skipped 처리
await client . query (
` UPDATE approval_lines SET status = 'skipped'
2026-03-05 22:43:26 +09:00
WHERE request_id = $1 AND status IN ( 'pending' , 'waiting' ) AND line_id != $2 AND company_code = $3 ` ,
[ line . request_id , lineId , companyCode ]
2026-03-04 18:26:16 +09:00
) ;
2026-03-03 21:49:56 +09:00
} else {
2026-03-05 22:43:26 +09:00
// 승인 처리: step_type 기반 분기
const currentStepType = line . step_type || "approval" ;
// 기존 isParallelMode 하위호환 (step_type이 없는 기존 데이터)
2026-03-04 18:26:16 +09:00
const recordData = request . target_record_data ;
2026-03-05 22:43:26 +09:00
const isLegacyParallel = recordData ? . approval_mode === "parallel" && ! line . step_type ;
2026-03-04 18:26:16 +09:00
2026-03-05 22:43:26 +09:00
if ( isLegacyParallel ) {
// 레거시 동시결재 (하위호환)
2026-03-04 18:26:16 +09:00
const { rows : remainingLines } = await client . query (
` SELECT COUNT(*) as cnt FROM approval_lines
2026-03-05 22:43:26 +09:00
WHERE request_id = $1 AND status = 'pending' AND line_id != $2 AND company_code = $3
FOR UPDATE ` ,
2026-03-04 18:26:16 +09:00
[ 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 ( )
2026-03-05 22:43:26 +09:00
WHERE request_id = $3 AND company_code = $4 ` ,
[ userId , comment || null , line . request_id , companyCode ]
2026-03-04 18:26:16 +09:00
) ;
}
2026-03-05 22:43:26 +09:00
} 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 ]
) ;
2026-03-04 18:26:16 +09:00
2026-03-05 22:43:26 +09:00
if ( parseInt ( remaining [ 0 ] . cnt ) === 0 ) {
// 합의 완료 → 다음 step 활성화
await activateNextStep (
client , line . request_id , line . step_order , request . total_steps ,
companyCode , userId , comment || null ,
2026-03-04 18:26:16 +09:00
) ;
}
2026-03-05 22:43:26 +09:00
} else {
// approval (기존 sequential 로직): 다음 step 활성화
await activateNextStep (
client , line . request_id , line . step_order , request . total_steps ,
companyCode , userId , comment || null ,
) ;
2026-03-03 21:49:56 +09:00
}
}
} ) ;
2026-03-06 01:24:50 +09:00
return res . json ( { success : true , message : action === "approved" ? "승인 처리되었습니다." : "반려 처리되었습니다." } ) ;
2026-03-03 21:49:56 +09:00
} catch ( error ) {
2026-03-06 01:24:50 +09:00
// ValidationError는 트랜잭션이 rollback된 후 적절한 HTTP 상태코드로 응답
if ( error instanceof Error && error . name === "ValidationError" ) {
const validationErr = error as any ;
return res . status ( validationErr . statusCode ) . json ( {
2026-03-05 22:43:26 +09:00
success : false ,
2026-03-06 01:24:50 +09:00
message : validationErr.message ,
2026-03-05 22:43:26 +09:00
} ) ;
}
2026-03-06 01:24:50 +09:00
console . error ( "결재 처리 오류:" , error ) ;
return res . status ( 500 ) . json ( {
success : false ,
message : "결재 처리 중 오류가 발생했습니다." ,
error : error instanceof Error ? error . message : "알 수 없는 오류" ,
} ) ;
2026-03-03 21:49:56 +09:00
}
}
// 내 결재 대기 목록 조회
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 : "알 수 없는 오류" ,
} ) ;
}
}
}
2026-03-05 22:43:26 +09:00