- Added `end_date` field to user management for better tracking of user status. - Updated SQL queries in `adminController` to include `end_date` during user save operations. - Improved purchase report data handling by refining the logic for received quantities. - Enhanced file preview functionality to streamline file path handling. - Updated outbound and receiving controllers to ensure accurate updates to shipment and purchase order details. These changes aim to improve the overall functionality and user experience in managing user data and reporting processes.
582 lines
20 KiB
TypeScript
582 lines
20 KiB
TypeScript
/**
|
|
* 출고관리 컨트롤러
|
|
*
|
|
* 출고유형별 소스 테이블:
|
|
* - 판매출고 → 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 (
|
|
id, company_code, outbound_number, outbound_type, outbound_date,
|
|
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 (
|
|
gen_random_uuid()::text, $1, $2, $3, $4,
|
|
$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) {
|
|
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 (
|
|
id, company_code, item_code, warehouse_code, location_code,
|
|
current_qty, safety_qty, last_out_date,
|
|
created_date, updated_date, writer
|
|
) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '0', '0', NOW(), NOW(), NOW(), $5)`,
|
|
[companyCode, itemCode, whCode, locCode, userId]
|
|
);
|
|
}
|
|
|
|
// 재고 이력 기록 (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 (
|
|
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())`,
|
|
[companyCode, itemCode, whCode, locCode, String(-outQty), afterQty, item.outbound_type || '출고', userId]
|
|
);
|
|
}
|
|
|
|
// 판매출고인 경우 출하지시의 ship_qty 업데이트 + 수주상세 ship_qty 반영
|
|
if (item.outbound_type === "판매출고" && item.source_id && item.source_type === "shipment_instruction_detail") {
|
|
const outQtyNum = Number(item.outbound_qty) || 0;
|
|
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`,
|
|
[outQtyNum, item.source_id, companyCode]
|
|
);
|
|
|
|
// 출하지시 상세의 detail_id로 수주상세(sales_order_detail) ship_qty도 업데이트
|
|
const sidRes = await client.query(
|
|
`SELECT detail_id FROM shipment_instruction_detail WHERE id = $1 AND company_code = $2`,
|
|
[item.source_id, companyCode]
|
|
);
|
|
const detailId = sidRes.rows[0]?.detail_id;
|
|
if (detailId) {
|
|
await client.query(
|
|
`UPDATE sales_order_detail
|
|
SET ship_qty = (COALESCE(NULLIF(ship_qty,'')::numeric, 0) + $1)::text,
|
|
balance_qty = (COALESCE(NULLIF(qty,'')::numeric, 0) - COALESCE(NULLIF(ship_qty,'')::numeric, 0) - $1)::text,
|
|
updated_date = NOW()
|
|
WHERE id = $2 AND company_code = $3`,
|
|
[outQtyNum, detailId, 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;
|
|
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 });
|
|
}
|
|
}
|
|
|
|
// 2순위: 기본 하드코딩 채번 (OUT-YYYY-XXXX)
|
|
const pool = getPool();
|
|
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
|
|
WHERE company_code = $1 AND COALESCE(status, '') != '삭제'
|
|
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 });
|
|
}
|
|
}
|
|
|
|
// 창고별 위치 목록 조회
|
|
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 });
|
|
}
|
|
}
|