cangku/backend/src/services/largeStockMonitorFlow.js
lunanting 2bc949099b feat: 新增大货库存监控(26 仓)
按「库存监控」表格在侧边栏增加一级/二级菜单与共用页面
(路由 /planning/large-stock-monitor/:warehouseSlug)。

后端:
- 新增 largeStockMonitorConfig.js:26 仓配置,含 wms_co_id / 快照仓名 / routeKey / slug 映射
- 新增 largeStockMonitor.js:config / overview / sku-list 三个接口
- 新增 largeStockMonitorFlow.js:采购进仓、采购退货、销售出仓、销售退货四类单据流水取数
- 新增 verify-large-stock-monitor.js:26 仓端到端校验脚本
- index.js:挂载 /api/large-stock-monitor 路由

口径说明:
- 库存周转天数 = 月度发货总件数 / 月度日均库存件数 × 100%,下沉到 SQL CASE WHEN 计算
- 四级分级(滞销预警/慢流/呆滞/僵尸)按库龄 + 周转率双维度判定
- 期末数量取聚水潭实际库存 jst_inventoryInfo(按 wms_co_id 单仓),
  不用 pm_stockMonitor.jsStock —— 后者 warehouse 是「归属仓」口径,
  会把已调拨到外仓的货仍计在本仓名下(崔桥工厂仓因此虚高约 2.2 万)
- 截止时刻统一为快照日次日 01:00(跑批时点),库龄/分级均以此为基准,不再用 GETDATE()

前端:
- 新增 LargeStockMonitor 页面、18 列定义、API 封装与样式
- 路由与侧边栏:26 条二级菜单 + 权限点

冲突处理:
- 主仓库已下线 /api/dashboard(backend/src/api/dashboard.js 被移除),
  相应删除 dashboardRoutes 的 require 与挂载,避免后端启动时 require 失败崩溃
- 保留主仓库新增的「模块化库存监控」菜单组及其子项
2026-09-23 13:42:58 +08:00

133 lines
5.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 大货库存监控 - 出入库流水取数(本月 1 日 ~ 快照日)
//
// 业务库 JSReportReplica 里与「库存流水」相关的单据表:
// 采购进仓 rpt.jst_purchase (wms_co_id, po_date, status) + rpt.jst_purchaseItem (po_id, inQty)
// 采购退货 rpt.jst_purchaseout (wms_co_id, io_date, status) + rpt.jst_purchaseoutItem (io_id, qty)
// 销售出仓 rpt.jst_salesOutboundInfo(wms_co_id, io_date, status) + rpt.jst_OrderItem (o_id, qty)
// 销售退货 rpt.jst_returnAndRefundInfo(warehouse 名称) + rpt.jst_returnAndRefundItem(as_id, r_qty)
//
// 有效单据状态:只统计 Confirmed / Finished,排除 Cancelled / Delete / WaitConfirm。
// 业务库中不存在加工、盘点、调拨、其它出库任何单据表,这 6 列无法取数。
const cloudDb = require('../config/cloudDatabase');
// 单据有效状态(已确认/已完成),避免把取消单算进流水
const PURCHASE_STATUS = "'Confirmed','Finished'";
const PURCHASEOUT_STATUS = "'Confirmed'";
const SALESOUT_STATUS = "'Confirmed'";
const REFUND_STATUS = "'Confirmed'";
function toNumber(value) {
if (value == null) return null;
const n = Number(value);
return Number.isFinite(n) ? n : null;
}
/**
* 采购进仓:采购单已入库数量(inQty)
*/
async function fetchPurchaseIn(wmsCoId, periodStart, periodEnd) {
const rows = await cloudDb.query(
`SELECT ISNULL(SUM(CAST(pi.inQty AS DECIMAL(18, 2))), 0) AS v
FROM rpt.jst_purchase p
JOIN rpt.jst_purchaseItem pi ON pi.po_id = p.po_id
WHERE p.wms_co_id = @wmsCoId
AND p.po_date >= @periodStart AND p.po_date < @periodEnd
AND p.status IN (${PURCHASE_STATUS})`,
{ wmsCoId, periodStart, periodEnd }
);
return toNumber(rows[0]?.v);
}
/**
* 采购退货:采购退货出库单数量
*/
async function fetchPurchaseReturn(wmsCoId, periodStart, periodEnd) {
const rows = await cloudDb.query(
`SELECT ISNULL(SUM(CAST(oi.qty AS DECIMAL(18, 2))), 0) AS v
FROM rpt.jst_purchaseout o
JOIN rpt.jst_purchaseoutItem oi ON oi.io_id = o.io_id
WHERE o.wms_co_id = @wmsCoId
AND o.io_date >= @periodStart AND o.io_date < @periodEnd
AND o.status IN (${PURCHASEOUT_STATUS})`,
{ wmsCoId, periodStart, periodEnd }
);
return toNumber(rows[0]?.v);
}
/**
* 销售出仓:销售出库单关联的订单明细数量
* 注意:jst_salesOutboundInfo.qty 是表头字段且恒为 0,真实件数在 jst_OrderItem.qty
*/
async function fetchSalesOut(wmsCoId, periodStart, periodEnd) {
const rows = await cloudDb.query(
`SELECT ISNULL(SUM(CAST(oi.qty AS DECIMAL(18, 2))), 0) AS v
FROM rpt.jst_salesOutboundInfo s
JOIN rpt.jst_OrderItem oi ON oi.o_id = s.o_id
WHERE s.wms_co_id = @wmsCoId
AND s.io_date >= @periodStart AND s.io_date < @periodEnd
AND s.status IN (${SALESOUT_STATUS})`,
{ wmsCoId, periodStart, periodEnd }
);
return toNumber(rows[0]?.v);
}
/**
* 销售退货:退货退款单明细的实退数量(r_qty)
* 该表 wms_co_id 大量为空(9 月 92821 单中 75094 单为空),改用 warehouse 名称匹配:
* 名称形如「简尚-崔桥工厂仓销退仓」/「简尚-分销部睢宁专用仓主仓」,统一去掉「销退仓」「主仓」后比对。
*/
async function fetchSalesReturn(dbWarehouse, periodStart, periodEnd) {
if (!dbWarehouse) return null;
const rows = await cloudDb.query(
`SELECT ISNULL(SUM(CAST(ri.r_qty AS DECIMAL(18, 2))), 0) AS v
FROM rpt.jst_returnAndRefundInfo r
JOIN rpt.jst_returnAndRefundItem ri ON ri.as_id = r.as_id
WHERE REPLACE(REPLACE(ISNULL(r.warehouse, ''), N'销退仓', N''), N'主仓', N'') = @whName
AND r.created >= @periodStart AND r.created < @periodEnd
AND r.status IN (${REFUND_STATUS})`,
{ whName: dbWarehouse, periodStart, periodEnd }
);
return toNumber(rows[0]?.v);
}
/**
* 仓库实物库存:聚水潭实时库存表 rpt.jst_inventoryInfo(按 wms_co_id 单仓统计)
* 这是报表「期末数量」的取数口径 —— pm_stockMonitor 的 warehouse 仅是归属仓,
* 其 jsStock 含已调拨到外仓的货,不能代表本仓实物库存。
*/
async function fetchInventoryQty(wmsCoId) {
if (!wmsCoId) return null;
const rows = await cloudDb.query(
`SELECT ISNULL(SUM(CAST(qty AS DECIMAL(18, 2))), 0) AS v,
COUNT(CASE WHEN ISNULL(qty, 0) <> 0 THEN 1 END) AS skuCount,
MAX(modified) AS takenAt
FROM rpt.jst_inventoryInfo
WHERE wms_co_id = @wmsCoId`,
{ wmsCoId }
);
return {
qty: toNumber(rows[0]?.v),
skuCount: toNumber(rows[0]?.skuCount) || 0,
takenAt: rows[0]?.takenAt ? new Date(rows[0].takenAt).toISOString() : null,
};
}
/**
* 一次性取全部流水;任一子查询失败不影响其它字段(返回 null 由调用方降级为「—」)
*/
async function fetchFlowSummary({ wmsCoId, dbWarehouse, periodStart, periodEnd }) {
const [purchaseIn, purchaseReturn, salesOut, salesReturn] = await Promise.all([
wmsCoId ? fetchPurchaseIn(wmsCoId, periodStart, periodEnd).catch(() => null) : null,
wmsCoId ? fetchPurchaseReturn(wmsCoId, periodStart, periodEnd).catch(() => null) : null,
wmsCoId ? fetchSalesOut(wmsCoId, periodStart, periodEnd).catch(() => null) : null,
fetchSalesReturn(dbWarehouse, periodStart, periodEnd).catch(() => null),
]);
return { purchaseIn, purchaseReturn, salesOut, salesReturn };
}
module.exports = {
fetchFlowSummary,
fetchInventoryQty,
};