구매관리 입고관리 입고등록 + 입고일별 마감정보입력 + 매입마감

- backend purchaseInboundService 신설 — getInboundFormInit / saveInboundForm
  (arrival_plan UPSERT 트랜잭션) / saveDeadlineInfo (8필드 일괄 UPDATE) /
  closeArrival (이미 마감된 건 차단) + listWarehouseOptions / listAcctCodeOptions
- backend routes — GET /inbound-form/:pomObjid / POST /inbound-form/save /
  POST /arrival/deadline / POST /arrival/close + 옵션 2개
- InboundFormDialog 신설 — wace deliveryAcceptanceFormPopUp_new.jsp 1:1
  (좌 발주품목 read-only + 우 차수별 입고입력 + 미입고 일괄적용)
- DeadlineInfoDialog 신설 — wace swal 모달 1:1 (8필드 일괄, 단건 시 prefill)
- inbound 페이지 입고등록 / inbound-by-date 마감정보입력+매입마감 연결
- 입고등록 master SELECT 함정 수정 — RPS 에 POM.delivery_status 없어 reception_status fallback
- DataGrid 다중 frozen 누적 left 계산 인프라 추가 (frozenLeftPx props 보강)
  — shadcn Table 기반이라 진짜 column pinning 불가 (자연 위치 도달 후 sticky),
    입고 3페이지의 frozen 부여는 일단 제거. 진짜 pinning 은 별도 작업
This commit is contained in:
hjjeong
2026-05-20 10:04:39 +09:00
parent 17b08c7a09
commit e51f5f7b69
10 changed files with 1228 additions and 16 deletions
@@ -7,6 +7,7 @@ import { AuthenticatedRequest } from "../types/auth";
import * as svc from "../services/purchaseService";
import * as formSvc from "../services/purchaseOrderFormService";
import * as mailSvc from "../services/purchaseOrderMailService";
import * as inboundSvc from "../services/purchaseInboundService";
import { logger } from "../utils/logger";
function parseFilter(q: Record<string, any>): svc.PurchaseListFilter {
@@ -156,6 +157,86 @@ export async function sendPurchaseOrderMail(req: AuthenticatedRequest, res: Resp
}
}
// ─── 입고관리 (입고등록 / 마감정보 / 매입마감) ─────────────────
/** GET /api/purchase/inbound-form/:pomObjid */
export async function getInboundFormInit(req: AuthenticatedRequest, res: Response) {
try {
const objid = String(req.params.pomObjid ?? "").trim();
if (!objid) return res.status(400).json({ success: false, message: "pomObjid required" });
const data = await inboundSvc.getInboundFormInit(objid);
if (!data) return res.status(404).json({ success: false, message: "발주서를 찾을 수 없어요" });
return res.json({ success: true, data });
} catch (e: any) {
logger.error("입고등록 폼 조회 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
/** POST /api/purchase/inbound-form/save */
export async function saveInboundForm(req: AuthenticatedRequest, res: Response) {
try {
const { pomObjid, rows } = req.body as { pomObjid: string; rows: inboundSvc.InboundSaveRow[] };
if (!pomObjid) return res.status(400).json({ success: false, message: "pomObjid required" });
const writer = String(req.user?.userId ?? "");
const result = await inboundSvc.saveInboundForm(pomObjid, rows ?? [], writer);
return res.json({ success: true, data: result });
} catch (e: any) {
logger.error("입고등록 저장 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
/** POST /api/purchase/arrival/deadline */
export async function saveArrivalDeadline(req: AuthenticatedRequest, res: Response) {
try {
const body = req.body as inboundSvc.DeadlineInfoBody;
if (!body || !Array.isArray(body.objIds) || body.objIds.length === 0) {
return res.status(400).json({ success: false, message: "objIds required" });
}
const result = await inboundSvc.saveDeadlineInfo(body);
return res.json({ success: true, data: result });
} catch (e: any) {
logger.error("마감정보 저장 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
/** GET /api/purchase/options/warehouses */
export async function getWarehouses(_req: AuthenticatedRequest, res: Response) {
try {
const data = await inboundSvc.listWarehouseOptions();
return res.json({ success: true, data });
} catch (e: any) {
return res.status(500).json({ success: false, message: e.message });
}
}
/** GET /api/purchase/options/acct-codes */
export async function getAcctCodes(_req: AuthenticatedRequest, res: Response) {
try {
const data = await inboundSvc.listAcctCodeOptions();
return res.json({ success: true, data });
} catch (e: any) {
return res.status(500).json({ success: false, message: e.message });
}
}
/** POST /api/purchase/arrival/close */
export async function closeArrival(req: AuthenticatedRequest, res: Response) {
try {
const { objIds } = req.body as { objIds: string[] };
if (!Array.isArray(objIds) || objIds.length === 0) {
return res.status(400).json({ success: false, message: "objIds required" });
}
const result = await inboundSvc.closeArrival(objIds);
return res.json({ success: true, data: result });
} catch (e: any) {
logger.error("매입마감 처리 실패", { error: e.message });
return res.status(400).json({ success: false, message: e.message });
}
}
export async function getSuppliers(_req: AuthenticatedRequest, res: Response) {
try {
const data = await svc.listSupplierOptions();
@@ -28,11 +28,19 @@ router.get ("/order-form/mail-info/:objid", ctrl.getPurchaseOrderMailInfo);
router.get ("/order-form/:objid", ctrl.getPurchaseOrderForm); // 수정/조회
router.delete("/order-form/:objid", ctrl.deletePurchaseOrderForm); // 삭제 cascade
// 입고관리 (wace deliveryAcceptanceFormPopUp_new 1:1)
router.get ("/inbound-form/:pomObjid", ctrl.getInboundFormInit); // 입고등록 팝업 자동채움
router.post("/inbound-form/save", ctrl.saveInboundForm); // arrival_plan 다수 UPSERT
router.post("/arrival/deadline", ctrl.saveArrivalDeadline); // 마감정보 일괄 UPDATE
router.post("/arrival/close", ctrl.closeArrival); // 매입마감 일괄
// 공통 옵션
router.get("/options/suppliers", ctrl.getSuppliers);
router.get("/options/vendors", ctrl.getVendors); // wace client_mng 기반
router.get("/options/users", ctrl.getUsers);
router.get("/options/projects", ctrl.getProjects);
router.get("/options/partner-managers/:partnerObjid", ctrl.getPartnerManagers); // 발주서 메일 담당자
router.get("/options/warehouses", ctrl.getWarehouses); // 입고창고 (warehouse_info)
router.get("/options/acct-codes", ctrl.getAcctCodes); // 계정과목 (account_code_info)
export default router;
@@ -0,0 +1,372 @@
// ============================================================
// 구매관리 > 입고관리 — 입고등록 / 마감정보입력 / 매입마감
//
// wace_plm 1:1 이식:
// - 입고등록 팝업: purchaseOrder/deliveryAcceptanceFormPopUp_new.do
// - 입고 저장: purchaseOrder/saveDeliveryInfo.do
// → supplyChainMgmt.saveDeliveryInfo (ARRIVAL_PLAN UPSERT)
// - 마감정보: purchaseOrder/saveArrivalPlanDeadlineInfo.do
// → purchaseOrder.saveArrivalPlanDeadlineInfo (8필드 조건부 UPDATE)
// - 매입마감: purchaseOrder/purchaseCloseByArrival.do
// → purchaseOrder.updateArrivalPlanCloseDate (PURCHASE_CLOSE_DATE)
//
// RPS 단순화:
// - 동시발주(MULTI_YN), inventory_mgmt 동기, ERROR_QTY/ERROR_REASON 흐름은 추후
// - 입고등록은 ARRIVAL_PLAN UPSERT 만 처리 (자재 신규/입고 이력은 차후 도메인)
// ============================================================
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
import { createObjId } from "../utils/objidUtil";
export interface InboundFormInitResult {
master: {
pom_objid: string;
purchase_order_no: string;
project_no: string;
contract_mgmt_objid: string;
partner_objid: string;
partner_name: string;
delivery_status: string;
};
/** 발주 품목 — 입고 등록 팝업 좌측 그리드 */
parts: {
order_part_objid: string;
part_objid: string;
part_no: string;
part_name: string;
spec: string;
maker: string;
unit_title: string;
order_qty: number;
arrival_qty: number; // 이미 입고된 수량
non_arrival_qty: number; // 미입고 수량 (order_qty - arrival_qty)
delivery_request_date: string;
}[];
/** 기존 입고 차수 — 팝업 우측 그리드 */
arrivals: {
objid: string;
order_part_objid: string;
part_objid: string;
group_seq: string;
seq: string;
receipt_date: string;
location: string;
sub_location: string;
receipt_qty: number;
arrival_qty: number;
inventory_status: string;
}[];
}
export interface InboundSaveRow {
objid?: string;
parent_objid: string; // PURCHASE_ORDER_MASTER objid
order_part_objid: string;
part_objid: string; // bigint as string OK
group_seq: string;
seq: string;
receipt_date: string;
location: string;
sub_location: string;
receipt_qty: number;
arrival_qty: number;
arrival_plan_date?: string;
}
export interface DeadlineInfoBody {
objIds: string[];
taxType: string;
taxInvoiceDate?: string;
exportDeclNo?: string;
loadingDate?: string;
foreignType?: string;
duty?: string;
importVat?: string;
exchangeRate?: string;
}
/** GET /api/purchase/inbound-form/:pomObjid — 입고등록 팝업 자동채움 */
export async function getInboundFormInit(pomObjid: string): Promise<InboundFormInitResult | null> {
const pool = getPool();
try {
const m = await pool.query(
`SELECT POM.OBJID AS pom_objid,
POM.PURCHASE_ORDER_NO AS purchase_order_no,
POM.CONTRACT_MGMT_OBJID AS contract_mgmt_objid,
POM.PARTNER_OBJID AS partner_objid,
CM.PROJECT_NO AS project_no,
C.CLIENT_NM AS partner_name,
COALESCE(POM.RECEPTION_STATUS, '') AS delivery_status
FROM PURCHASE_ORDER_MASTER POM
LEFT JOIN PROJECT_MGMT CM ON POM.CONTRACT_MGMT_OBJID = CM.OBJID
LEFT JOIN CLIENT_MNG C ON C.OBJID = POM.PARTNER_OBJID
WHERE POM.OBJID = $1
LIMIT 1`,
[pomObjid],
);
if (m.rows.length === 0) return null;
const master = m.rows[0];
const p = await pool.query(
`SELECT POP.OBJID AS order_part_objid,
POP.PART_OBJID AS part_objid,
POP.PART_NO AS part_no,
POP.PART_NAME AS part_name,
POP.SPEC AS spec,
'' AS maker,
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = POP.UNIT) AS unit_title,
COALESCE(NULLIF(POP.ORDER_QTY,'')::numeric, 0) AS order_qty,
COALESCE(POP.DELIVERY_REQUEST_DATE, '') AS delivery_request_date,
COALESCE((SELECT SUM(COALESCE(NULLIF(AP.RECEIPT_QTY,'')::numeric, 0))
FROM ARRIVAL_PLAN AP
WHERE AP.ORDER_PART_OBJID = POP.OBJID), 0) AS arrival_qty
FROM PURCHASE_ORDER_PART POP
WHERE POP.PURCHASE_ORDER_MASTER_OBJID = $1
ORDER BY POP.REGDATE`,
[pomObjid],
);
const parts = p.rows.map((r) => {
const orderQty = Number(r.order_qty || 0);
const arrivalQty = Number(r.arrival_qty || 0);
return {
order_part_objid: String(r.order_part_objid ?? ""),
part_objid: String(r.part_objid ?? ""),
part_no: String(r.part_no ?? ""),
part_name: String(r.part_name ?? ""),
spec: String(r.spec ?? ""),
maker: String(r.maker ?? ""),
unit_title: String(r.unit_title ?? ""),
order_qty: orderQty,
arrival_qty: arrivalQty,
non_arrival_qty: Math.max(orderQty - arrivalQty, 0),
delivery_request_date: String(r.delivery_request_date ?? ""),
};
});
const a = await pool.query(
`SELECT OBJID, ORDER_PART_OBJID, PART_OBJID::VARCHAR, GROUP_SEQ, SEQ,
RECEIPT_DATE, LOCATION, SUB_LOCATION,
COALESCE(NULLIF(RECEIPT_QTY,'')::numeric, 0) AS receipt_qty,
COALESCE(NULLIF(ARRIVAL_QTY,'')::numeric, 0) AS arrival_qty,
COALESCE(INVENTORY_STATUS, '') AS inventory_status
FROM ARRIVAL_PLAN
WHERE PARENT_OBJID = $1
ORDER BY GROUP_SEQ, SEQ`,
[pomObjid],
);
const arrivals = a.rows.map((r) => ({
objid: String(r.objid ?? ""),
order_part_objid: String(r.order_part_objid ?? ""),
part_objid: String(r.part_objid ?? ""),
group_seq: String(r.group_seq ?? "1"),
seq: String(r.seq ?? ""),
receipt_date: String(r.receipt_date ?? ""),
location: String(r.location ?? ""),
sub_location: String(r.sub_location ?? ""),
receipt_qty: Number(r.receipt_qty || 0),
arrival_qty: Number(r.arrival_qty || 0),
inventory_status: String(r.inventory_status ?? ""),
}));
return {
master: {
pom_objid: String(master.pom_objid ?? ""),
purchase_order_no: String(master.purchase_order_no ?? ""),
project_no: String(master.project_no ?? ""),
contract_mgmt_objid: String(master.contract_mgmt_objid ?? ""),
partner_objid: String(master.partner_objid ?? ""),
partner_name: String(master.partner_name ?? ""),
delivery_status: String(master.delivery_status ?? ""),
},
parts,
arrivals,
};
} catch (e: any) {
logger.error("getInboundFormInit 실패", { error: e.message, pomObjid });
throw e;
}
}
/** POST /api/purchase/inbound-form/save — arrival_plan 다수 UPSERT 트랜잭션 */
export async function saveInboundForm(
pomObjid: string,
rows: InboundSaveRow[],
writer: string,
): Promise<{ saved: number }> {
if (!pomObjid) throw new Error("pomObjid is required");
if (!Array.isArray(rows)) throw new Error("rows must be array");
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
let saved = 0;
for (const row of rows) {
const qty = Number(row.receipt_qty || 0);
// 입고수량 0 인 행은 저장 skip
if (qty <= 0) continue;
const objid = row.objid || createObjId();
// ARRIVAL_PLAN UPSERT (wace supplyChainMgmt.saveDeliveryInfo 1:1)
await client.query(
`INSERT INTO ARRIVAL_PLAN
(OBJID, PARENT_OBJID, ORDER_PART_OBJID, PART_OBJID,
RECEIPT_QTY, RECEIPT_DATE, LOCATION, SUB_LOCATION,
WRITER, RECEIVER_ID, GROUP_SEQ, SEQ, ARRIVAL_QTY, ARRIVAL_PLAN_DATE)
VALUES ($1, $2, $3, $4::bigint,
$5, $6, $7, $8,
$9, $9, $10, $11, $12, $13)
ON CONFLICT (OBJID) DO UPDATE SET
RECEIPT_QTY = EXCLUDED.RECEIPT_QTY,
RECEIPT_DATE = EXCLUDED.RECEIPT_DATE,
LOCATION = EXCLUDED.LOCATION,
SUB_LOCATION = EXCLUDED.SUB_LOCATION,
ARRIVAL_QTY = EXCLUDED.ARRIVAL_QTY,
ARRIVAL_PLAN_DATE = EXCLUDED.ARRIVAL_PLAN_DATE,
RECEIVER_ID = EXCLUDED.RECEIVER_ID`,
[
objid,
pomObjid,
row.order_part_objid,
row.part_objid || null,
String(qty),
row.receipt_date || "",
row.location || "",
row.sub_location || "",
writer || "",
row.group_seq || "1",
row.seq || "",
String(row.arrival_qty || qty),
row.arrival_plan_date || "",
],
);
saved++;
}
await client.query("COMMIT");
return { saved };
} catch (e: any) {
await client.query("ROLLBACK");
logger.error("saveInboundForm 실패", { error: e.message, pomObjid });
throw e;
} finally {
client.release();
}
}
/** POST /api/purchase/arrival/deadline — 마감정보 일괄 UPDATE */
export async function saveDeadlineInfo(body: DeadlineInfoBody): Promise<{ updated: number }> {
if (!Array.isArray(body.objIds) || body.objIds.length === 0) {
throw new Error("objIds required");
}
const pool = getPool();
// wace 패턴: tax_type 은 항상 SET, 나머지는 비어있지 않을 때만
const sets: string[] = [`tax_type = $1`];
const vals: any[] = [body.taxType ?? ""];
let i = 2;
const addIf = (col: string, v?: string) => {
if (v != null && v !== "") {
sets.push(`${col} = $${i++}`);
vals.push(v);
}
};
const addNumIf = (col: string, v?: string) => {
if (v != null && v !== "") {
sets.push(`${col} = $${i++}::numeric`);
vals.push(v);
}
};
addIf("tax_invoice_date", body.taxInvoiceDate);
if (body.exportDeclNo != null) { sets.push(`export_decl_no = $${i++}`); vals.push(body.exportDeclNo); }
addIf("loading_date", body.loadingDate);
addIf("foreign_type", body.foreignType);
addNumIf("duty", body.duty);
addNumIf("exchange_rate", body.exchangeRate);
addNumIf("import_vat", body.importVat);
const idIdx = i;
vals.push(body.objIds);
const sql = `UPDATE arrival_plan
SET ${sets.join(", ")}
WHERE OBJID = ANY($${idIdx}::text[])`;
try {
const r = await pool.query(sql, vals);
return { updated: r.rowCount ?? 0 };
} catch (e: any) {
logger.error("saveDeadlineInfo 실패", { error: e.message });
throw e;
}
}
/** 입고창고 옵션 — RPS warehouse_info 기반 (wace 는 WAREHOUSE_LOCATION) */
export async function listWarehouseOptions(): Promise<{ code: string; label: string }[]> {
const pool = getPool();
try {
const r = await pool.query(
`SELECT WAREHOUSE_CODE AS code,
WAREHOUSE_CODE || ' ' || COALESCE(WAREHOUSE_NAME, '') AS label
FROM WAREHOUSE_INFO
WHERE COALESCE(STATUS, 'active') NOT IN ('inactive', 'delete', 'D', 'N')
AND WAREHOUSE_CODE IS NOT NULL AND WAREHOUSE_CODE <> ''
ORDER BY WAREHOUSE_CODE`,
);
return r.rows;
} catch (e: any) {
logger.error("listWarehouseOptions 실패", { error: e.message });
return [];
}
}
/** 계정과목 옵션 — RPS account_code_info 기반 (wace 는 ERP_ACCT_CODE) */
export async function listAcctCodeOptions(): Promise<{ code: string; label: string }[]> {
const pool = getPool();
try {
const r = await pool.query(
`SELECT ACCOUNT_CODE AS code,
ACCOUNT_CODE || ' ' || COALESCE(ACCOUNT_NAME, '') AS label
FROM ACCOUNT_CODE_INFO
WHERE COALESCE(USE_YN, 'Y') IN ('Y', 'y', '1')
AND ACCOUNT_CODE IS NOT NULL AND ACCOUNT_CODE <> ''
ORDER BY ACCOUNT_CODE`,
);
return r.rows;
} catch (e: any) {
logger.error("listAcctCodeOptions 실패", { error: e.message });
return [];
}
}
/** POST /api/purchase/arrival/close — 매입마감 일괄 (PURCHASE_CLOSE_DATE = 오늘) */
export async function closeArrival(objIds: string[]): Promise<{ updated: number }> {
if (!Array.isArray(objIds) || objIds.length === 0) {
throw new Error("objIds required");
}
const pool = getPool();
try {
// 이미 마감된 건 차단 (wace fn_purchaseClose 와 동일)
const check = await pool.query(
`SELECT OBJID FROM ARRIVAL_PLAN
WHERE OBJID = ANY($1::text[])
AND COALESCE(PURCHASE_CLOSE_DATE, '') <> ''`,
[objIds],
);
if (check.rows.length > 0) {
const dup = check.rows.map((r: any) => r.objid).join(", ");
throw new Error(`이미 매입마감된 건이 포함돼 있어요 (${dup})`);
}
const r = await pool.query(
`UPDATE ARRIVAL_PLAN
SET PURCHASE_CLOSE_DATE = TO_CHAR(NOW(), 'YYYY-MM-DD')
WHERE OBJID = ANY($1::text[])`,
[objIds],
);
return { updated: r.rowCount ?? 0 };
} catch (e: any) {
logger.error("closeArrival 실패", { error: e.message });
throw e;
}
}