2026-03-25 10:48:47 +09:00
/ * *
* 출 고 관 리 컨 트 롤 러
*
* 출 고 유 형 별 소 스 테 이 블 :
* - 판 매 출 고 → shipment_instruction + shipment_instruction_detail ( 출 하 지 시 )
* - 반 품 출 고 → purchase_order_mng ( 발 주 / 입 고 )
* - 기 타 출 고 → item_info ( 품 목 )
* /
import { Response } from "express" ;
import { AuthenticatedRequest } from "../types/auth" ;
import { getPool } from "../database/db" ;
import { logger } from "../utils/logger" ;
// 출고 목록 조회
export async function getList ( req : AuthenticatedRequest , res : Response ) {
try {
const companyCode = req . user ! . companyCode ;
const {
outbound_type ,
outbound_status ,
search_keyword ,
date_from ,
date_to ,
} = req . query ;
const conditions : string [ ] = [ ] ;
const params : any [ ] = [ ] ;
let paramIdx = 1 ;
if ( companyCode === "*" ) {
// 최고 관리자: 전체 조회
} else {
conditions . push ( ` om.company_code = $ ${ paramIdx } ` ) ;
params . push ( companyCode ) ;
paramIdx ++ ;
}
if ( outbound_type && outbound_type !== "all" ) {
conditions . push ( ` om.outbound_type = $ ${ paramIdx } ` ) ;
params . push ( outbound_type ) ;
paramIdx ++ ;
}
if ( outbound_status && outbound_status !== "all" ) {
conditions . push ( ` om.outbound_status = $ ${ paramIdx } ` ) ;
params . push ( outbound_status ) ;
paramIdx ++ ;
}
if ( search_keyword ) {
conditions . push (
` (om.outbound_number ILIKE $ ${ paramIdx } OR om.item_name ILIKE $ ${ paramIdx } OR om.item_code ILIKE $ ${ paramIdx } OR om.customer_name ILIKE $ ${ paramIdx } OR om.reference_number ILIKE $ ${ paramIdx } ) `
) ;
params . push ( ` % ${ search_keyword } % ` ) ;
paramIdx ++ ;
}
if ( date_from ) {
conditions . push ( ` om.outbound_date >= $ ${ paramIdx } ` ) ;
params . push ( date_from ) ;
paramIdx ++ ;
}
if ( date_to ) {
conditions . push ( ` om.outbound_date <= $ ${ paramIdx } ` ) ;
params . push ( date_to ) ;
paramIdx ++ ;
}
const whereClause =
conditions . length > 0 ? ` WHERE ${ conditions . join ( " AND " ) } ` : "" ;
const query = `
SELECT
om . * ,
wh . warehouse_name
FROM outbound_mng om
LEFT JOIN warehouse_info wh
ON om . warehouse_code = wh . warehouse_code
AND om . company_code = wh . company_code
$ { whereClause }
ORDER BY om . created_date DESC
` ;
const pool = getPool ( ) ;
const result = await pool . query ( query , params ) ;
logger . info ( "출고 목록 조회" , {
companyCode ,
rowCount : result.rowCount ,
} ) ;
return res . json ( { success : true , data : result.rows } ) ;
} catch ( error : any ) {
logger . error ( "출고 목록 조회 실패" , { error : error.message } ) ;
return res . status ( 500 ) . json ( { success : false , message : error.message } ) ;
}
}
// 출고 등록 (다건)
export async function create ( req : AuthenticatedRequest , res : Response ) {
const pool = getPool ( ) ;
const client = await pool . connect ( ) ;
try {
const companyCode = req . user ! . companyCode ;
const userId = req . user ! . userId ;
const { items , outbound_number , outbound_date , warehouse_code , location_code , manager_id , memo } = req . body ;
if ( ! items || ! Array . isArray ( items ) || items . length === 0 ) {
return res . status ( 400 ) . json ( { success : false , message : "출고 품목이 없습니다." } ) ;
}
await client . query ( "BEGIN" ) ;
const insertedRows : any [ ] = [ ] ;
for ( const item of items ) {
const result = await client . query (
` INSERT INTO outbound_mng (
2026-04-02 17:39:42 +09:00
id , company_code , outbound_number , outbound_type , outbound_date ,
2026-03-25 10:48:47 +09:00
reference_number , customer_code , customer_name ,
item_code , item_name , specification , material , unit ,
outbound_qty , unit_price , total_amount ,
lot_number , warehouse_code , location_code ,
outbound_status , manager_id , memo ,
source_type , sales_order_id , shipment_plan_id , item_info_id ,
destination_code , delivery_destination , delivery_address ,
created_date , created_by , writer , status
) VALUES (
2026-04-02 17:39:42 +09:00
gen_random_uuid ( ) : : text , $1 , $2 , $3 , $4 ,
2026-03-25 10:48:47 +09:00
$5 , $6 , $7 ,
$8 , $9 , $10 , $11 , $12 ,
$13 , $14 , $15 ,
$16 , $17 , $18 ,
$19 , $20 , $21 ,
$22 , $23 , $24 , $25 ,
$26 , $27 , $28 ,
NOW ( ) , $29 , $29 , '출고'
) RETURNING * ` ,
[
companyCode ,
outbound_number || item . outbound_number ,
item . outbound_type ,
outbound_date || item . outbound_date ,
item . reference_number || null ,
item . customer_code || null ,
item . customer_name || null ,
item . item_code || item . item_number || null ,
item . item_name || null ,
item . spec || item . specification || null ,
item . material || null ,
item . unit || "EA" ,
item . outbound_qty || 0 ,
item . unit_price || 0 ,
item . total_amount || 0 ,
item . lot_number || null ,
warehouse_code || item . warehouse_code || null ,
location_code || item . location_code || null ,
item . outbound_status || "대기" ,
manager_id || item . manager_id || null ,
memo || item . memo || null ,
item . source_type || null ,
item . sales_order_id || null ,
item . shipment_plan_id || null ,
item . item_info_id || null ,
item . destination_code || null ,
item . delivery_destination || null ,
item . delivery_address || null ,
userId ,
]
) ;
insertedRows . push ( result . rows [ 0 ] ) ;
// 재고 업데이트 (inventory_stock): 출고 수량 차감
const itemCode = item . item_code || item . item_number || null ;
const whCode = warehouse_code || item . warehouse_code || null ;
const locCode = location_code || item . location_code || null ;
const outQty = Number ( item . outbound_qty ) || 0 ;
if ( itemCode && outQty > 0 ) {
2026-04-09 14:28:57 +09:00
// 재고 사전 검증: 부족 시 즉시 에러 (트랜잭션 ROLLBACK)
const stockCheck = await client . query (
` SELECT COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) as cur
FROM inventory_stock
WHERE company_code = $1 AND item_code = $2
AND COALESCE ( warehouse_code , '' ) = COALESCE ( $3 , '' )
AND COALESCE ( location_code , '' ) = COALESCE ( $4 , '' )
LIMIT 1 ` ,
[ companyCode , itemCode , whCode || '' , locCode || '' ]
) ;
const currentStock = parseFloat ( stockCheck . rows [ 0 ] ? . cur || '0' ) ;
if ( currentStock < outQty ) {
throw new Error (
` 재고 부족: ${ item . item_name || itemCode } (창고 ${ whCode || '미지정' } ) — 현재 재고 ${ currentStock } , 요청 출고 ${ outQty } `
) ;
}
2026-03-25 10:48:47 +09:00
const existingStock = await client . query (
` SELECT id FROM inventory_stock
WHERE company_code = $1 AND item_code = $2
AND COALESCE ( warehouse_code , '' ) = COALESCE ( $3 , '' )
AND COALESCE ( location_code , '' ) = COALESCE ( $4 , '' )
LIMIT 1 ` ,
[ companyCode , itemCode , whCode || '' , locCode || '' ]
) ;
if ( existingStock . rows . length > 0 ) {
await client . query (
` UPDATE inventory_stock
SET current_qty = CAST ( GREATEST ( COALESCE ( CAST ( NULLIF ( current_qty , '' ) AS numeric ) , 0 ) - $1 , 0 ) AS text ) ,
last_out_date = NOW ( ) ,
updated_date = NOW ( )
WHERE id = $2 ` ,
[ outQty , existingStock . rows [ 0 ] . id ]
) ;
} else {
// 재고 레코드가 없으면 0으로 생성 (마이너스 방지)
await client . query (
` INSERT INTO inventory_stock (
2026-04-03 16:02:14 +09:00
id , company_code , item_code , warehouse_code , location_code ,
2026-03-25 10:48:47 +09:00
current_qty , safety_qty , last_out_date ,
created_date , updated_date , writer
2026-04-03 16:02:14 +09:00
) VALUES ( gen_random_uuid ( ) : : text , $1 , $2 , $3 , $4 , '0' , '0' , NOW ( ) , NOW ( ) , NOW ( ) , $5 ) ` ,
2026-03-25 10:48:47 +09:00
[ companyCode , itemCode , whCode , locCode , userId ]
) ;
}
2026-04-03 16:02:14 +09:00
// 재고 이력 기록 (inventory_history)
const afterStockRes = await client . query (
` SELECT current_qty FROM inventory_stock
WHERE company_code = $1 AND item_code = $2
AND COALESCE ( warehouse_code , '' ) = COALESCE ( $3 , '' )
AND COALESCE ( location_code , '' ) = COALESCE ( $4 , '' )
LIMIT 1 ` ,
[ companyCode , itemCode , whCode || '' , locCode || '' ]
) ;
const afterQty = afterStockRes . rows [ 0 ] ? . current_qty || '0' ;
await client . query (
` INSERT INTO inventory_history (
2026-04-03 17:38:14 +09:00
id , company_code , item_code , warehouse_code , location_code ,
transaction_type , transaction_date , quantity , balance_qty , remark ,
writer , created_date
) VALUES ( gen_random_uuid ( ) : : text , $1 , $2 , $3 , $4 , '출고' , NOW ( ) , $5 , $6 , $7 , $8 , NOW ( ) ) ` ,
2026-04-03 16:02:14 +09:00
[ companyCode , itemCode , whCode , locCode , String ( - outQty ) , afterQty , item . outbound_type || '출고' , userId ]
) ;
2026-03-25 10:48:47 +09:00
}
// 판매출고인 경우 출하지시의 ship_qty 업데이트
if ( item . outbound_type === "판매출고" && item . source_id && item . source_type === "shipment_instruction_detail" ) {
await client . query (
` UPDATE shipment_instruction_detail
SET ship_qty = COALESCE ( ship_qty , 0 ) + $1 ,
updated_date = NOW ( )
WHERE id = $2 AND company_code = $3 ` ,
[ item . outbound_qty || 0 , item . source_id , companyCode ]
) ;
}
}
await client . query ( "COMMIT" ) ;
logger . info ( "출고 등록 완료" , {
companyCode ,
userId ,
count : insertedRows.length ,
outbound_number ,
} ) ;
return res . json ( {
success : true ,
data : insertedRows ,
message : ` ${ insertedRows . length } 건 출고 등록 완료 ` ,
} ) ;
} catch ( error : any ) {
await client . query ( "ROLLBACK" ) ;
logger . error ( "출고 등록 실패" , { error : error.message } ) ;
return res . status ( 500 ) . json ( { success : false , message : error.message } ) ;
} finally {
client . release ( ) ;
}
}
// 출고 수정
export async function update ( req : AuthenticatedRequest , res : Response ) {
try {
const companyCode = req . user ! . companyCode ;
const userId = req . user ! . userId ;
const { id } = req . params ;
const {
outbound_date , outbound_qty , unit_price , total_amount ,
lot_number , warehouse_code , location_code ,
outbound_status , manager_id : mgr , memo ,
} = req . body ;
const pool = getPool ( ) ;
const result = await pool . query (
` UPDATE outbound_mng SET
outbound_date = COALESCE ( $1 , outbound_date ) ,
outbound_qty = COALESCE ( $2 , outbound_qty ) ,
unit_price = COALESCE ( $3 , unit_price ) ,
total_amount = COALESCE ( $4 , total_amount ) ,
lot_number = COALESCE ( $5 , lot_number ) ,
warehouse_code = COALESCE ( $6 , warehouse_code ) ,
location_code = COALESCE ( $7 , location_code ) ,
outbound_status = COALESCE ( $8 , outbound_status ) ,
manager_id = COALESCE ( $9 , manager_id ) ,
memo = COALESCE ( $10 , memo ) ,
updated_date = NOW ( ) ,
updated_by = $11
WHERE id = $12 AND company_code = $13
RETURNING * ` ,
[
outbound_date , outbound_qty , unit_price , total_amount ,
lot_number , warehouse_code , location_code ,
outbound_status , mgr , memo ,
userId , id , companyCode ,
]
) ;
if ( result . rowCount === 0 ) {
return res . status ( 404 ) . json ( { success : false , message : "출고 데이터를 찾을 수 없습니다." } ) ;
}
logger . info ( "출고 수정" , { companyCode , userId , id } ) ;
return res . json ( { success : true , data : result.rows [ 0 ] } ) ;
} catch ( error : any ) {
logger . error ( "출고 수정 실패" , { error : error.message } ) ;
return res . status ( 500 ) . json ( { success : false , message : error.message } ) ;
}
}
// 출고 삭제
export async function deleteOutbound ( req : AuthenticatedRequest , res : Response ) {
try {
const companyCode = req . user ! . companyCode ;
const { id } = req . params ;
const pool = getPool ( ) ;
const result = await pool . query (
` DELETE FROM outbound_mng WHERE id = $ 1 AND company_code = $ 2 RETURNING id ` ,
[ id , companyCode ]
) ;
if ( result . rowCount === 0 ) {
return res . status ( 404 ) . json ( { success : false , message : "데이터를 찾을 수 없습니다." } ) ;
}
logger . info ( "출고 삭제" , { companyCode , id } ) ;
return res . json ( { success : true , message : "삭제 완료" } ) ;
} catch ( error : any ) {
logger . error ( "출고 삭제 실패" , { error : error.message } ) ;
return res . status ( 500 ) . json ( { success : false , message : error.message } ) ;
}
}
// 판매출고용: 출하지시 데이터 조회
export async function getShipmentInstructions ( req : AuthenticatedRequest , res : Response ) {
try {
const companyCode = req . user ! . companyCode ;
const { keyword } = req . query ;
const conditions : string [ ] = [ "si.company_code = $1" ] ;
const params : any [ ] = [ companyCode ] ;
let paramIdx = 2 ;
if ( keyword ) {
conditions . push (
` (si.instruction_no ILIKE $ ${ paramIdx } OR sid.item_name ILIKE $ ${ paramIdx } OR sid.item_code ILIKE $ ${ paramIdx } ) `
) ;
params . push ( ` % ${ keyword } % ` ) ;
paramIdx ++ ;
}
const pool = getPool ( ) ;
const result = await pool . query (
` SELECT
sid . id AS detail_id ,
si . id AS instruction_id ,
si . instruction_no ,
si . instruction_date ,
si . partner_id ,
si . status AS instruction_status ,
sid . item_code ,
sid . item_name ,
sid . spec ,
sid . material ,
COALESCE ( sid . plan_qty , 0 ) AS plan_qty ,
COALESCE ( sid . ship_qty , 0 ) AS ship_qty ,
COALESCE ( sid . order_qty , 0 ) AS order_qty ,
GREATEST ( COALESCE ( sid . plan_qty , 0 ) - COALESCE ( sid . ship_qty , 0 ) , 0 ) AS remain_qty ,
sid . source_type
FROM shipment_instruction si
JOIN shipment_instruction_detail sid
ON si . id = sid . instruction_id
AND si . company_code = sid . company_code
WHERE $ { conditions . join ( " AND " ) }
AND COALESCE ( sid . plan_qty , 0 ) > COALESCE ( sid . ship_qty , 0 )
ORDER BY si . instruction_date DESC , si . instruction_no ` ,
params
) ;
return res . json ( { success : true , data : result.rows } ) ;
} catch ( error : any ) {
logger . error ( "출하지시 데이터 조회 실패" , { error : error.message } ) ;
return res . status ( 500 ) . json ( { success : false , message : error.message } ) ;
}
}
// 반품출고용: 발주(입고) 데이터 조회
export async function getPurchaseOrders ( req : AuthenticatedRequest , res : Response ) {
try {
const companyCode = req . user ! . companyCode ;
const { keyword } = req . query ;
const conditions : string [ ] = [ "company_code = $1" ] ;
const params : any [ ] = [ companyCode ] ;
let paramIdx = 2 ;
// 입고된 것만 (반품 대상)
conditions . push (
` COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) > 0 `
) ;
if ( keyword ) {
conditions . push (
` (purchase_no ILIKE $ ${ paramIdx } OR item_name ILIKE $ ${ paramIdx } OR item_code ILIKE $ ${ paramIdx } OR supplier_name ILIKE $ ${ paramIdx } ) `
) ;
params . push ( ` % ${ keyword } % ` ) ;
paramIdx ++ ;
}
const pool = getPool ( ) ;
const result = await pool . query (
` SELECT
id , purchase_no , order_date , supplier_code , supplier_name ,
item_code , item_name , spec , material ,
COALESCE ( CAST ( NULLIF ( order_qty , '' ) AS numeric ) , 0 ) AS order_qty ,
COALESCE ( CAST ( NULLIF ( received_qty , '' ) AS numeric ) , 0 ) AS received_qty ,
COALESCE ( CAST ( NULLIF ( unit_price , '' ) AS numeric ) , 0 ) AS unit_price ,
status , due_date
FROM purchase_order_mng
WHERE $ { conditions . join ( " AND " ) }
ORDER BY order_date DESC , purchase_no ` ,
params
) ;
return res . json ( { success : true , data : result.rows } ) ;
} catch ( error : any ) {
logger . error ( "발주 데이터 조회 실패" , { error : error.message } ) ;
return res . status ( 500 ) . json ( { success : false , message : error.message } ) ;
}
}
// 기타출고용: 품목 데이터 조회
export async function getItems ( req : AuthenticatedRequest , res : Response ) {
try {
const companyCode = req . user ! . companyCode ;
const { keyword } = req . query ;
const conditions : string [ ] = [ "company_code = $1" ] ;
const params : any [ ] = [ companyCode ] ;
let paramIdx = 2 ;
if ( keyword ) {
conditions . push (
` (item_number ILIKE $ ${ paramIdx } OR item_name ILIKE $ ${ paramIdx } ) `
) ;
params . push ( ` % ${ keyword } % ` ) ;
paramIdx ++ ;
}
const pool = getPool ( ) ;
const result = await pool . query (
` SELECT
id , item_number , item_name , size AS spec , material , unit ,
COALESCE ( CAST ( NULLIF ( standard_price , '' ) AS numeric ) , 0 ) AS standard_price
FROM item_info
WHERE $ { conditions . join ( " AND " ) }
ORDER BY item_name ` ,
params
) ;
return res . json ( { success : true , data : result.rows } ) ;
} catch ( error : any ) {
logger . error ( "품목 데이터 조회 실패" , { error : error.message } ) ;
return res . status ( 500 ) . json ( { success : false , message : error.message } ) ;
}
}
// 출고번호 자동생성
export async function generateNumber ( req : AuthenticatedRequest , res : Response ) {
try {
const companyCode = req . user ! . companyCode ;
2026-04-07 17:11:39 +09:00
const ruleId = ( req . query . ruleId as string ) || ( req . query . rule_id as string ) ;
// 1순위: POP 화면설정에서 선택한 채번규칙 사용
if ( ruleId && ruleId !== "__none__" ) {
try {
const { numberingRuleService } = await import ( "../services/numberingRuleService" ) ;
const newNumber = await numberingRuleService . allocateCode ( ruleId , companyCode ) ;
return res . json ( { success : true , data : newNumber } ) ;
} catch ( e : any ) {
logger . warn ( "선택한 채번규칙 사용 실패, 기본 채번으로 폴백" , { ruleId , error : e.message } ) ;
}
}
2026-03-25 10:48:47 +09:00
2026-04-07 17:11:39 +09:00
// 2순위: 기본 하드코딩 채번 (OUT-YYYY-XXXX)
const pool = getPool ( ) ;
2026-03-25 10:48:47 +09:00
const today = new Date ( ) ;
const yyyy = today . getFullYear ( ) ;
const prefix = ` OUT- ${ yyyy } - ` ;
const result = await pool . query (
` SELECT outbound_number FROM outbound_mng
WHERE company_code = $1 AND outbound_number LIKE $2
ORDER BY outbound_number DESC LIMIT 1 ` ,
[ companyCode , ` ${ prefix } % ` ]
) ;
let seq = 1 ;
if ( result . rows . length > 0 ) {
const lastNo = result . rows [ 0 ] . outbound_number ;
const lastSeq = parseInt ( lastNo . replace ( prefix , "" ) , 10 ) ;
if ( ! isNaN ( lastSeq ) ) seq = lastSeq + 1 ;
}
const newNumber = ` ${ prefix } ${ String ( seq ) . padStart ( 4 , "0" ) } ` ;
return res . json ( { success : true , data : newNumber } ) ;
} catch ( error : any ) {
logger . error ( "출고번호 생성 실패" , { error : error.message } ) ;
return res . status ( 500 ) . json ( { success : false , message : error.message } ) ;
}
}
// 창고 목록 조회
export async function getWarehouses ( req : AuthenticatedRequest , res : Response ) {
try {
const companyCode = req . user ! . companyCode ;
const pool = getPool ( ) ;
const result = await pool . query (
` SELECT warehouse_code, warehouse_name, warehouse_type
FROM warehouse_info
2026-04-03 17:38:14 +09:00
WHERE company_code = $1 AND COALESCE ( status , '' ) != '삭제'
2026-03-25 10:48:47 +09:00
ORDER BY warehouse_name ` ,
[ companyCode ]
) ;
return res . json ( { success : true , data : result.rows } ) ;
} catch ( error : any ) {
logger . error ( "창고 목록 조회 실패" , { error : error.message } ) ;
return res . status ( 500 ) . json ( { success : false , message : error.message } ) ;
}
}
2026-04-03 17:38:14 +09:00
// 창고별 위치 목록 조회
export async function getLocations ( req : AuthenticatedRequest , res : Response ) {
try {
const companyCode = req . user ! . companyCode ;
const warehouseCode = req . query . warehouse_code as string ;
const pool = getPool ( ) ;
const result = await pool . query (
` SELECT location_code, location_name, warehouse_code
FROM warehouse_location
WHERE company_code = $1 $ { warehouseCode ? "AND warehouse_code = $2" : "" }
ORDER BY location_code ` ,
warehouseCode ? [ companyCode , warehouseCode ] : [ companyCode ]
) ;
return res . json ( { success : true , data : result.rows } ) ;
} catch ( error : any ) {
logger . error ( "위치 목록 조회 실패" , { error : error.message } ) ;
return res . status ( 500 ) . json ( { success : false , message : error.message } ) ;
}
}