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 재구현 — 좌측 리스트 필터)

View File

@@ -23,6 +23,9 @@ router.get("/process-materials", ctrl.getProcessMaterials);
// 외주발주 가능 작업지시 목록 (좌측 리스트 필터, TASK:ERP-019 재구현)
router.get("/outsourceable-work-orders", ctrl.listOutsourceableWorkOrders);
// 외주발주현황 통합 조회 (TASK:ERP-025)
router.get("/status", ctrl.listOrderStatus);
// 사급자재 출고요청
router.post("/release-request", ctrl.requestRelease);

View File

@@ -1188,3 +1188,181 @@ async function mapToOpoMaterials(
};
});
}
// ─────────────────────────────────────────────────────────────────────────────
// 외주발주현황 통합 조회 (TASK:ERP-025)
// 발주 × 공정 × 자재 × 사급출고 × 입고 단위로 펼친 행 반환
// ─────────────────────────────────────────────────────────────────────────────
export interface OrderStatusFilter {
keyword?: string;
source_type?: string;
order_status?: string;
release_status?: string; // '출고완료' | '입고완료'
date_from?: string;
date_to?: string;
}
export async function listOrderStatus(
companyCode: string,
opts: OrderStatusFilter,
) {
const pool = getPool();
const params: any[] = [companyCode];
let where = `o.company_code = $1`;
if (opts.source_type) {
params.push(opts.source_type);
where += ` AND o.source_type = $${params.length}`;
}
if (opts.order_status) {
params.push(opts.order_status);
where += ` AND o.status = $${params.length}`;
}
if (opts.date_from) {
params.push(opts.date_from);
where += ` AND o.order_date >= $${params.length}`;
}
if (opts.date_to) {
params.push(opts.date_to);
where += ` AND o.order_date <= $${params.length}`;
}
// 출고: outsource_purchase_order_material.outbound_id → outbound_mng.id 정참조
// 입고: inbound_mng.source_table='outbound_mng' AND source_id=outbound_mng.id 역참조 합산
const sql = `
SELECT
o.id AS order_id,
o.order_no,
COALESCE(o.source_type, '') AS source_type,
COALESCE(o.source_no, '') AS source_no,
COALESCE(o.item_code, '') AS item_code,
COALESCE(o.item_name, '') AS item_name,
COALESCE(o.spec, '') AS spec,
COALESCE(o.material, '') AS material,
COALESCE(NULLIF(o.quantity::text, '')::numeric, 0) AS quantity,
o.order_date,
o.due_date,
COALESCE(o.status, '') AS order_status,
COALESCE(o.manager, '') AS manager,
p.id AS process_id,
p.seq,
COALESCE(p.process_name, '') AS process_name,
COALESCE(p.vendor_code, '') AS vendor_code,
COALESCE(p.vendor_name, '') AS vendor_name,
COALESCE(p.material_needed, false) AS material_needed,
m.id AS material_id,
COALESCE(m.item_code, '') AS material_item_code,
COALESCE(m.item_name, '') AS material_item_name,
COALESCE(NULLIF(m.qty::text, '')::numeric, 0) AS material_qty,
COALESCE(m.unit, '') AS material_unit,
COALESCE(m.release_status, '') AS material_release_status,
om.id AS outbound_id,
COALESCE(om.outbound_number, '') AS outbound_number,
COALESCE(om.outbound_status, '') AS outbound_status,
COALESCE(NULLIF(om.outbound_qty, '')::numeric, 0) AS released_qty,
COALESCE(om.outbound_date, '') AS request_date,
COALESCE(in_sum.received_qty, 0)::numeric AS received_qty
FROM outsource_purchase_order o
LEFT JOIN outsource_purchase_order_process p
ON p.opo_id = o.id
LEFT JOIN outsource_purchase_order_material m
ON m.opo_process_id = p.id
LEFT JOIN outbound_mng om
ON om.id = m.outbound_id
AND om.company_code = o.company_code
LEFT JOIN (
SELECT company_code, source_id, SUM(inbound_qty)::numeric AS received_qty
FROM inbound_mng
WHERE source_table = 'outbound_mng'
GROUP BY company_code, source_id
) in_sum
ON in_sum.source_id = om.id
AND in_sum.company_code = o.company_code
WHERE ${where}
ORDER BY o.order_date DESC NULLS LAST, o.order_no DESC, p.seq NULLS LAST
`;
const r = await pool.query(sql, params);
// 행 단위 파생 필드 + 키워드/출고상태 필터
let rows = r.rows.map((row: any) => {
const releasedQty = Number(row.released_qty || 0);
const receivedQty = Number(row.received_qty || 0);
const remainQty = Math.max(0, releasedQty - receivedQty);
const ratePct =
releasedQty > 0 ? Math.round((receivedQty / releasedQty) * 100) : 0;
// 화면 표기용 통합 출고상태
let release_status = "";
if (releasedQty > 0) {
release_status = receivedQty >= releasedQty ? "입고완료" : "출고완료";
}
return {
order_id: row.order_id,
order_no: row.order_no,
source_type: row.source_type,
source_no: row.source_no,
item_code: row.item_code,
item_name: row.item_name,
spec: row.spec,
material: row.material,
quantity: Number(row.quantity || 0),
order_date: row.order_date || "",
due_date: row.due_date || "",
order_status: row.order_status,
manager: row.manager,
process_id: row.process_id || "",
seq: row.seq != null ? Number(row.seq) : null,
process_name: row.process_name,
vendor_code: row.vendor_code,
vendor_name: row.vendor_name,
material_needed: !!row.material_needed,
material_id: row.material_id || "",
material_item_code: row.material_item_code,
material_item_name: row.material_item_name,
material_qty: Number(row.material_qty || 0),
material_unit: row.material_unit,
material_release_status: row.material_release_status,
outbound_id: row.outbound_id || "",
outbound_number: row.outbound_number,
outbound_status: row.outbound_status,
released_qty: releasedQty,
request_date: row.request_date,
received_qty: receivedQty,
remain_qty: remainQty,
rate_pct: ratePct,
release_status,
};
});
if (opts.keyword) {
const kw = opts.keyword.toLowerCase();
rows = rows.filter((row: any) => {
const hay = [
row.order_no,
row.source_type,
row.source_no,
row.item_code,
row.item_name,
row.spec,
row.material,
row.process_name,
row.vendor_name,
row.material_item_code,
row.material_item_name,
row.manager,
]
.join(" ")
.toLowerCase();
return hay.includes(kw);
});
}
if (opts.release_status) {
rows = rows.filter((row: any) => row.release_status === opts.release_status);
}
return { rows, total: rows.length };
}

View File

@@ -12,6 +12,17 @@ export interface AdjustInventoryParams {
validateStockEnough?: boolean;
}
/**
* inventory_stock 차감/증가 + inventory_history 기록.
*
* 매칭 우선순위:
* 1) 사용자 선택 창고+위치 정확히 일치 (strict)
* 2) 창고만 일치
* 3) 같은 품목의 첫 row (위치/창고 빈값 데이터 fallback)
*
* 즉, 사용자가 창고를 선택했지만 해당 row가 없고 빈 창고 row만 있는 경우라도
* 첫 매칭 row에서 차감되도록 한다. inventory_history에는 실제 차감된 row의 위치 기록.
*/
export async function adjustInventory(
client: PoolClient,
params: AdjustInventoryParams,
@@ -30,96 +41,96 @@ export async function adjustInventory(
if (!itemCode || delta === 0) return;
if (validateStockEnough && delta < 0) {
const stockRes = 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`,
[companyCode, itemCode, whCode || "", locCode || ""],
);
const cur = parseFloat(stockRes.rows[0]?.cur || "0");
if (cur + delta < 0) {
throw new Error(
`재고 부족: 품목 ${itemCode} (창고 ${whCode || "미지정"}) — 현재 재고 ${cur}, 차감 요청 ${-delta}`,
);
}
}
const existing = 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`,
// 1) 매칭 row 탐색 (우선순위 정렬 + LIMIT 1)
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 || ""],
);
if (existing.rows.length > 0) {
let stockId: string | null = matchRes.rows[0]?.id || null;
const matchedWh: string | null = matchRes.rows[0]?.warehouse_code ?? null;
const matchedLoc: string | null = matchRes.rows[0]?.location_code ?? null;
const cur = parseFloat(matchRes.rows[0]?.cur || "0");
// 2) 재고 부족 검증 (차감 케이스)
if (validateStockEnough && delta < 0 && cur + delta < 0) {
throw new Error(
`재고 부족: 품목 ${itemCode} (창고 ${whCode || "미지정"}) — 현재 재고 ${cur}, 차감 요청 ${-delta}`,
);
}
// 3) row가 있으면 UPDATE, 없으면 신규 INSERT (입고 시에만)
if (stockId) {
if (delta >= 0) {
await client.query(
`UPDATE inventory_stock
SET current_qty = CAST(COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) + $1 AS text),
last_in_date = NOW(),
updated_date = NOW()
WHERE id = $2`,
[delta, existing.rows[0].id],
SET current_qty = CAST(COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) + $1 AS text),
last_in_date = NOW(),
updated_date = NOW()
WHERE id = $2`,
[delta, stockId],
);
} else {
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`,
[delta, existing.rows[0].id],
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`,
[delta, stockId],
);
}
} else {
const initQty = Math.max(delta, 0);
await client.query(
const insRes = await client.query(
`INSERT INTO inventory_stock (
id, company_code, item_code, warehouse_code, location_code,
current_qty, safety_qty, last_in_date, last_out_date,
created_date, updated_date, writer
) VALUES (
gen_random_uuid()::text, $1, $2, $3, $4,
$5, '0',
${delta > 0 ? "NOW()" : "NULL"},
${delta < 0 ? "NOW()" : "NULL"},
NOW(), NOW(), $6
)`,
id, company_code, item_code, warehouse_code, location_code,
current_qty, safety_qty, last_in_date, last_out_date,
created_date, updated_date, writer
) VALUES (
gen_random_uuid()::text, $1, $2, $3, $4,
$5, '0',
${delta > 0 ? "NOW()" : "NULL"},
${delta < 0 ? "NOW()" : "NULL"},
NOW(), NOW(), $6
) RETURNING id`,
[companyCode, itemCode, whCode, locCode, String(initQty), userId],
);
stockId = insRes.rows[0].id;
}
// 4) 차감 후 잔량 (이력 기록용)
const afterRes = 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`,
[stockId],
);
const afterQty = afterRes.rows[0]?.current_qty || "0";
// 5) inventory_history 기록 — 실제 차감/증가된 row의 창고/위치를 기록
const historyWh = matchedWh ?? whCode;
const historyLoc = matchedLoc ?? locCode;
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,
$5, NOW(), $6, $7, $8,
$9, NOW()
)`,
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,
$5, NOW(), $6, $7, $8,
$9, NOW()
)`,
[
companyCode,
itemCode,
whCode,
locCode,
historyWh,
historyLoc,
transactionType,
(delta > 0 ? "+" : "") + String(delta),
afterQty,