Implement Order Status Integration for Outsourcing Purchase Management

- Added a new endpoint `listOrderStatus` in the `outsourcePurchaseController` to retrieve integrated order status information, including filtering options for source type, order status, and date range.
- Updated the `outsourcePurchaseService` to handle the new order status retrieval logic, ensuring proper filtering and data aggregation.
- Introduced a new route for accessing the order status information in `outsourcePurchaseRoutes`.
- Created a detailed modal for viewing outsourcing purchase order details, enhancing the user interface for better data presentation.
- Developed a registration modal for creating and editing outsourcing purchase orders, featuring a tabbed interface for improved user experience.

(TASK: ERP-025, ERP-019)
This commit is contained in:
kjs
2026-05-07 18:33:38 +09:00
parent ec1c95b8c5
commit 2e686f059d
28 changed files with 13714 additions and 168 deletions

View File

@@ -199,63 +199,45 @@ export async function create(req: AuthenticatedRequest, res: Response) {
const locCode = location_code || item.location_code || null;
const outQty = Number(item.outbound_qty) || 0;
if (itemCode && outQty > 0) {
// 재고 사전 검증: 부족 시 즉시 에러 (트랜잭션 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 ($4 IS NULL OR $4 = '' OR COALESCE(location_code, '') = $4)
LIMIT 1`,
// 매칭 row 탐색 — 사용자 선택 창고/위치 우선, fallback 같은 품목의 첫 row
// (사용자가 창고를 선택했지만 inventory_stock에 빈 창고로 입고된 데이터가 있는 경우 대응)
const matchRes = await client.query(
`SELECT id, warehouse_code, location_code,
COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) AS cur
FROM inventory_stock
WHERE company_code = $1 AND item_code = $2
ORDER BY
CASE WHEN COALESCE(warehouse_code, '') = COALESCE($3, '') THEN 0 ELSE 1 END,
CASE WHEN COALESCE(location_code, '') = COALESCE($4, '') THEN 0 ELSE 1 END
LIMIT 1`,
[companyCode, itemCode, whCode || "", locCode || ""],
);
const currentStock = parseFloat(stockCheck.rows[0]?.cur || "0");
if (currentStock < outQty) {
const stockRow = matchRes.rows[0];
const currentStock = parseFloat(stockRow?.cur || "0");
if (!stockRow || currentStock < outQty) {
throw new Error(
`재고 부족: ${item.item_name || itemCode} (창고 ${whCode || "미지정"}) — 현재 재고 ${currentStock}, 요청 출고 ${outQty}`,
);
}
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 ($4 IS NULL OR $4 = '' OR COALESCE(location_code, '') = $4)
LIMIT 1`,
[companyCode, itemCode, whCode || "", locCode || ""],
// id 기반 차감
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, stockRow.id],
);
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)
// 차감 후 잔량 조회 (id 기반)
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 ($4 IS NULL OR $4 = '' OR COALESCE(location_code, '') = $4)
LIMIT 1`,
[companyCode, itemCode, whCode || "", locCode || ""],
`SELECT current_qty FROM inventory_stock WHERE id = $1`,
[stockRow.id],
);
const afterQty = afterStockRes.rows[0]?.current_qty || "0";
// 이력은 실제 차감된 row의 창고/위치를 기록
await client.query(
`INSERT INTO inventory_history (
id, company_code, item_code, warehouse_code, location_code,
@@ -265,8 +247,8 @@ export async function create(req: AuthenticatedRequest, res: Response) {
[
companyCode,
itemCode,
whCode,
locCode,
stockRow.warehouse_code ?? whCode,
stockRow.location_code ?? locCode,
String(-outQty),
afterQty,
resolvedItemOutboundType || "출고",
@@ -679,18 +661,20 @@ export async function getPurchaseOrders(
const companyCode = req.user!.companyCode;
const { keyword } = req.query;
const conditions: string[] = ["company_code = $1"];
// 발주 마스터(purchase_order_mng)는 헤더만 보유, 품목/수량은 detail(purchase_detail)에 있음
// 반품출고는 detail 단위로 처리되어야 하므로 detail row를 펼쳐서 반환
const conditions: string[] = ["pom.company_code = $1"];
const params: any[] = [companyCode];
let paramIdx = 2;
// 입고된 만 (반품 대상)
// 입고된 detail만 (반품 대상)
conditions.push(
`COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) > 0`,
`COALESCE(CAST(NULLIF(pd.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})`,
`(pom.purchase_no ILIKE $${paramIdx} OR pd.item_name ILIKE $${paramIdx} OR pd.item_code ILIKE $${paramIdx} OR pom.supplier_name ILIKE $${paramIdx})`,
);
params.push(`%${keyword}%`);
paramIdx++;
@@ -699,15 +683,25 @@ export async function getPurchaseOrders(
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
pd.id,
pom.purchase_no,
pom.order_date,
pom.supplier_code,
pom.supplier_name,
pd.item_code,
pd.item_name,
pd.spec,
pd.material,
COALESCE(CAST(NULLIF(pd.order_qty, '') AS numeric), 0) AS order_qty,
COALESCE(CAST(NULLIF(pd.received_qty, '') AS numeric), 0) AS received_qty,
COALESCE(CAST(NULLIF(pd.unit_price, '') AS numeric), 0) AS unit_price,
pom.status, pom.due_date
FROM purchase_order_mng pom
JOIN purchase_detail pd
ON pd.purchase_no = pom.purchase_no
AND pd.company_code = pom.company_code
WHERE ${conditions.join(" AND ")}
ORDER BY order_date DESC, purchase_no`,
ORDER BY pom.order_date DESC, pom.purchase_no, pd.item_code`,
params,
);

View File

@@ -224,6 +224,30 @@ export async function getProcessMaterials(
}
}
// ─────────────────────────────────────────────────────────────────────────────
// 외주발주현황 통합 조회 (TASK:ERP-025)
// ─────────────────────────────────────────────────────────────────────────────
export async function listOrderStatus(
req: AuthenticatedRequest,
res: Response,
) {
try {
const companyCode = req.user!.companyCode;
const opts: svc.OrderStatusFilter = {
keyword: req.query.keyword as string | undefined,
source_type: req.query.source_type as string | undefined,
order_status: req.query.order_status as string | undefined,
release_status: req.query.release_status as string | undefined,
date_from: req.query.date_from as string | undefined,
date_to: req.query.date_to as string | undefined,
};
const data = await svc.listOrderStatus(companyCode, opts);
return ok(res, data);
} catch (e: any) {
return fail(res, 500, e?.message || "외주발주현황 조회 실패", e);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// 외주발주 가능 작업지시 목록
// (TASK:ERP-019 재구현 — 좌측 리스트 필터)