cangku/backend/src/services/modularStockMonitorService.js

636 lines
27 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.

'use strict';
// 模块化库存监控 —— 配件库存监控
//
// 需求来源:《模块化库存监控.xlsx》
// 一级功能:模块化库存监控 二级功能:配件库存监控
// 表头:图片 | 预警 | 商品编码 | 名称 | 当日入库库存 | 当日出库库存 | 实时库存 |
// 仓位 | 近15天日均发货量 | 库存可用天数 | 所在仓库
// 数据范围:仅取「简尚-崔桥工厂半成品包装项目仓」仓库下的商品编码
//
// 数据来源(业务库 JSReportReplica / schema=rpt,2026-09-12 实测确认):
// 主表 rpt.jst_sku —— SKU 档案;other_3 = 所属仓库
// 目标仓库共 794 个 SKU
// 库存 rpt.jst_inventoryInfo —— 实时库存 qty;按 wms_co_id 区分仓库
// 目标仓库 wms_co_id = 15718196(rpt.jst_warehouseInfo 查得)
// 该表为 0/缺行而箱及仓位库存已有正库存时,用同仓 rpt.jst_packInventoryInfo 合计补齐;
// 只补 0,不覆盖 inventoryInfo 中的正库存,避免箱表未覆盖散货时低估库存。
// 监控 rpt.pm_stockMonitor —— 日更快照,目标仓每天 794 行,覆盖全部 SKU:
// fifteenAvgSale 近15天日均发货量(2026-09-17 由 7 天改 15 天,见下)
// jsUseDays 库存可用天数
// safetyDays 安全天数(默认 15)
// suggestStock 建议备货量
// jsStock 监控表口径库存(作为 inventoryInfo 的兜底)
// nearSecondSale 近一日发货量 → 当日出库
//
// ⚠️ 关联键坑(2026-09-12 修正):
// pm_stockMonitor 里同时有 sku 与 sku_id 两列:
// sku = 短款号,如 DNZ040967 —— 用它关联只能命中 34 / 794
// sku_id = 完整 SKU 编码 DNZ040967N1440V01 —— 必须用它关联,命中 794 / 794
// 当日入库:业务库无出入库流水表,用采购到货 rpt.jst_purchaseItem 按「监控日」汇总 inQty
// (全库 207 张视图均无库存流水表;dws_sn_sales_full_info_di 是成品销售明细,只命中 97 / 794)
//
// ✅ 仓位 location(2026-09-18 改源,旧的「无数据源」结论作废):
// - 旧源 rpt.jst_sku.bin / other_bin 在目标仓 794 行**恒为空**,所以页面长期显示「未维护」
// - 新源 rpt.jst_packInventoryInfo.bin(真货位):该表 2026-09-18 10:48 被上游刷新后
// 目标仓 794 个 SKU **命中 727 个**(如 ZT-8 / BCP01-01-1)
// - 详见 buildLocationSql() 注释
const cloudDatabase = require('../config/cloudDatabase');
const productDb = require('../config/productInformationDatabase');
const WAREHOUSE_NAME = '简尚-崔桥工厂半成品包装项目仓';
// rpt.jst_warehouseInfo 中「简尚-崔桥工厂半成品包装项目仓」对应的 wms_co_id
const WAREHOUSE_WMS_CO_ID = 15718196;
const DEFAULT_PAGE_SIZE = 50;
const MAX_PAGE_SIZE = 200;
const DEFAULT_SAFETY_DAYS = 15;
// 预警阈值:库存可用天数 <= 7 天即预警(缺货 = 实时库存 <= 0)
// ⚠️ 2026-09-17 业务确认:未配置MKH 的配件不进任何预警(见 buildWarningOrderSql)
const LOW_STOCK_DAYS_THRESHOLD = 7;
const WARNING_LEVEL = Object.freeze({
OUT_OF_STOCK: '缺货',
WARNING: '预警',
NORMAL: '正常',
});
// 模块化配置状态(2026-09-17 业务确认口径,2026-09-17 晚修正):
// 「已配置MKH」= 该 SKU 作为「配件商品编码」出现在聚水潭 SKU 级 BOM 维护中,
// 被某个 MKH 模块化成品引用:
// 业务库 rpt.jst_skuBomItem 中 map_outer_sku_id = 该 SKU
// 且父项 sku_id 以 'MKH' 开头(即 MKH 成品档案,如 MKHSJZ0033260600001)。
// 「未配置MKH」= 未出现在任何 MKH 成品的 BOM 配件清单中。
// 实测目标仓 794 个 SKU 中 **699** 个命中(按 map_outer_sku_id 匹配、父项 MKH 过滤)。
// ⚠️ 历史口径均已废弃:
// · 早期「产品资料系统数据库商品档案」口径(661/794)—— 覆盖面过宽、与 BOM 无关;
// · 上一版「jst_comboProductItem 组合装引用」口径(29/794)—— 仅命中 MKH- 组合装,
// 漏掉了真正以 MKH 成品为父项的 BOM 配件,数量明显偏低。
// ✅ 当前采用 rpt.jst_skuBomItem(聚水潭 SKU BOM 明细,含配件商品编码 map_outer_sku_id)。
const MKH_STATUS = Object.freeze({
CONFIGURED: '已配置MKH',
UNCONFIGURED: '未配置MKH',
});
const SORTABLE_FIELDS = Object.freeze({
skuId: 's.sku_id',
productName: 's.name',
category: 's.category',
realtimeStock: 'realtimeStock',
avgShip15d: 'm.fifteenAvgSale',
stockDays: 'stockDays',
warningLevel: 'warningOrder',
mkhStatus: 'mkhStatus',
location: 'location',
warehouse: 's.other_3',
});
// 实时库存优先使用库存表;库存表没有正库存时,用同仓「箱及仓位库存」补齐。
// 聚水潭上游可能已经生成箱/仓位库存,但 jst_inventoryInfo 仍停留在旧值。
// 只补 0/缺行,避免箱表只记录部分装箱库存时压低库存表中的有效库存。
const buildRealtimeStockSql = () => (
`CASE
WHEN inv.qty > 0 THEN inv.qty
WHEN ISNULL(ploc.packQty, 0) > 0 THEN ploc.packQty
WHEN inv.qty IS NOT NULL THEN inv.qty
ELSE ISNULL(m.jsStock, 0)
END`
);
// 箱表兜底生效时,监控快照的 jsUseDays 仍基于旧库存,需要同步重算。
// 无日销时返回 NULL,不能因为旧快照为 0 天而把已有库存误报为低库存。
const buildStockDaysSql = () => (
`CASE
WHEN ISNULL(inv.qty, 0) <= 0 AND ISNULL(ploc.packQty, 0) > 0 THEN
CASE
WHEN ISNULL(m.fifteenAvgSale, 0) > 0
THEN CAST(ploc.packQty / NULLIF(m.fifteenAvgSale, 0) AS DECIMAL(18, 2))
ELSE NULL
END
ELSE m.jsUseDays
END`
);
/**
* 「已配置MKH」判定表达式(2026-09-17 晚修正口径):
* 该 SKU 作为「配件商品编码」(map_outer_sku_id)出现在聚水潭 SKU 级 BOM 维护
* rpt.jst_skuBomItem 中,且父项 sku_id 以 'MKH' 开头(即被某个 MKH 模块化成品引用)。
*/
const buildConfiguredExistsSql = () => (
`EXISTS (
SELECT 1 FROM rpt.jst_skuBomItem x
WHERE x.map_outer_sku_id = s.sku_id
AND x.sku_id LIKE N'MKH%'
)`
);
/**
* 统一的「预警等级(数值)」表达式(2026-09-17 业务确认:未配置MKH 不进任何预警)。
* 0 = 缺货 / 1 = 预警 / 2 = 正常
* 未配置MKH(cfg.configured = 0)一律判为「正常」,不报缺货也不报预警。
* 注意 cfg / inv / m 三个别名都来自 buildBaseSql 的 OUTER APPLY,WHERE / SELECT 中均可引用。
* 列表映射、汇总计数、筛选、排序四处共用此表达式,避免口径不一致。
*/
const buildWarningOrderSql = () => (
`CASE
WHEN cfg.configured = 0 THEN 2
WHEN ${buildRealtimeStockSql()} <= 0 THEN 0
WHEN ${buildStockDaysSql()} IS NOT NULL
AND ${buildStockDaysSql()} <= ${LOW_STOCK_DAYS_THRESHOLD} THEN 1
ELSE 2
END`
);
/**
* 统一的「仓位」表达式(2026-09-18 改源)。
* 旧源 `jst_sku.bin / other_bin` 在目标仓 794 行**恒为空**(页面因此长期显示「未维护」);
* 真货位在 `rpt.jst_packInventoryInfo.bin` —— 该表 2026-09-18 10:48 被上游刷新后,
* 目标仓 794 个 SKU **命中 727 个**(09-19 实测;该表小时级增量刷新,命中数会变)。
* ⚠️ 只取 bin,**不 join 仓名**,所以不受该表 wh_id / wms_co_id 关联列问题影响(见
* mkhBomService 文件头 ⑤;那个坑只影响 MKH-BOM 页配件仓位列的仓名文本)。
* · 只取 `qty > 0` 的箱,同 SKU 多箱时取库存最大的那个(列表页只展示主货位)
* · 取不到才回退商品档案的 bin / other_bin(目前仍全空,保留兜底不影响结果)
* 与 MKH-BOM 页 `mkhBomService.loadComponentLocations()` 同源,区别只在本页取单个主货位。
* ⚠️ 列表映射、仓位筛选两处共用此表达式,改口径时只改这里。
*/
const buildLocationSql = () => (
`COALESCE(
ploc.bin,
NULLIF(LTRIM(RTRIM(s.bin)), N''),
NULLIF(LTRIM(RTRIM(s.other_bin)), N'')
)`
);
const buildBaseSql = () => `
FROM dbo.jst_sku s
OUTER APPLY (
SELECT TOP 1 i.qty
FROM dbo.jst_inventoryInfo i
WHERE i.sku_id = s.sku_id AND i.wms_co_id = @wmsCoId
ORDER BY i.updateTime DESC
) inv
OUTER APPLY (
SELECT TOP 1 m2.fifteenAvgSale, m2.jsUseDays, m2.safetyDays, m2.suggestStock,
m2.date, m2.jsStock, m2.nearSecondSale
FROM dbo.pm_stockMonitor m2
WHERE m2.sku_id = s.sku_id AND m2.warehouse = @warehouseName
ORDER BY m2.date DESC
) m
OUTER APPLY (
SELECT CAST(SUM(ISNULL(p.inQty, 0)) AS DECIMAL(18, 2)) AS inQty
FROM dbo.jst_purchaseItem p
WHERE p.sku_id = s.sku_id
AND p.delivery_date >= @monitorDate
AND p.delivery_date < DATEADD(DAY, 1, @monitorDate)
) pin
-- 采购在途:该 SKU 全部采购单中「已下单 − 已入库」的未到货部分合计
-- jst_purchaseItem.qty = 采购数量,inQty = 已入库数量,plan_arrive_qty 全库恒为 0(不可用)、
-- is_delivery 全库恒为 0(不可信),故在途口径 = SUM(MAX(0, qty − inQty))。
-- ⚠️ 此列只参与「取数/展示」,绝不参与 ORDER BY / 聚合(参考本文件头 MSSQL 坑说明,
-- 对 OUTER APPLY 列做排序/聚合会触发灾难性执行计划)。
OUTER APPLY (
SELECT CAST(SUM(CASE WHEN p.qty > ISNULL(p.inQty, 0) THEN p.qty - ISNULL(p.inQty, 0) ELSE 0 END) AS DECIMAL(18, 2)) AS transit
FROM dbo.jst_purchaseItem p
WHERE p.sku_id = s.sku_id
) ptr
OUTER APPLY (
SELECT TOP 1 NULLIF(LTRIM(RTRIM(c.pictureSkucode)), N'') AS pictureSkucode
FROM dbo.fys_skuCostPriceUpdate c
WHERE UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), c.sku)))) = UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), s.sku_id))))
ORDER BY c.createTime DESC
) cost
-- 仓位及箱库存:先按 SKU/仓位一次性汇总,再关联目标仓,避免对 794 个 SKU
-- 分别扫描无独立索引的上游视图。rn=1 是库存最大的主货位,packQty 是该 SKU 全仓合计。
LEFT JOIN (
SELECT ranked.sku_id, ranked.wms_co_id, ranked.bin, ranked.packQty
FROM (
SELECT
pk.sku_id,
pk.wms_co_id,
NULLIF(LTRIM(RTRIM(pk.bin)), N'') AS bin,
SUM(ISNULL(pk.qty, 0)) AS binQty,
SUM(SUM(ISNULL(pk.qty, 0))) OVER (PARTITION BY pk.sku_id, pk.wms_co_id) AS packQty,
ROW_NUMBER() OVER (
PARTITION BY pk.sku_id, pk.wms_co_id
ORDER BY SUM(ISNULL(pk.qty, 0)) DESC, NULLIF(LTRIM(RTRIM(pk.bin)), N'') ASC
) AS rn
FROM dbo.jst_packInventoryInfo pk WITH (NOLOCK)
WHERE pk.wms_co_id = @wmsCoId
AND NULLIF(LTRIM(RTRIM(pk.bin)), N'') IS NOT NULL
GROUP BY pk.sku_id, pk.wms_co_id, NULLIF(LTRIM(RTRIM(pk.bin)), N'')
HAVING SUM(ISNULL(pk.qty, 0)) > 0
) ranked
WHERE ranked.rn = 1
) ploc ON ploc.sku_id = s.sku_id AND ploc.wms_co_id = @wmsCoId
-- 模块化配置状态:见文件头部「已配置MKH」口径说明
OUTER APPLY (
SELECT CAST(CASE WHEN ${buildConfiguredExistsSql()} THEN 1 ELSE 0 END AS BIT) AS configured
) cfg
WHERE s.other_3 = @warehouseName`;
const toNumber = (value) => {
if (value === null || value === undefined || value === '') return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
};
const toText = (value) => {
if (value === null || value === undefined) return null;
const text = String(value).trim();
return text === '' ? null : text;
};
const WARNING_ORDER = {
[WARNING_LEVEL.OUT_OF_STOCK]: 0,
[WARNING_LEVEL.WARNING]: 1,
[WARNING_LEVEL.NORMAL]: 2,
};
/**
* 取目标仓最新监控日(pm_stockMonitor 快照日),作为「当日入库 / 当日出库」的统计基准日。
* 业务库没有出入库流水表,只能以监控日为基准做按日汇总。
*/
async function resolveMonitorDate() {
const rows = await cloudDatabase.query(
`SELECT CAST(MAX(date) AS DATE) AS monitorDate
FROM dbo.pm_stockMonitor
WHERE warehouse = @warehouseName`,
{ warehouseName: WAREHOUSE_NAME },
{ requestTimeout: 120000 }
);
const raw = rows?.[0]?.monitorDate;
if (!raw) return null;
const d = new Date(raw);
return Number.isNaN(d.getTime()) ? null : d.toISOString().slice(0, 10);
}
const buildFilters = (query = {}, monitorDate = null) => {
const conditions = [];
const params = {
wmsCoId: WAREHOUSE_WMS_CO_ID,
warehouseName: WAREHOUSE_NAME,
monitorDate,
};
const keyword = toText(query.keyword);
if (keyword) {
params.keyword = `%${keyword}%`;
conditions.push('(s.sku_id LIKE @keyword OR s.name LIKE @keyword)');
}
const category = toText(query.category);
if (category) {
params.category = category;
conditions.push('s.category = @category');
}
const location = toText(query.location);
if (location) {
params.location = location;
conditions.push(`${buildLocationSql()} = @location`);
}
const mkhStatus = toText(query.mkhStatus);
if (mkhStatus === MKH_STATUS.CONFIGURED || mkhStatus === MKH_STATUS.UNCONFIGURED) {
params.mkhStatus = mkhStatus;
conditions.push(`CASE WHEN cfg.configured = 1 THEN N'${MKH_STATUS.CONFIGURED}' ELSE N'${MKH_STATUS.UNCONFIGURED}' END = @mkhStatus`);
}
const warningLevel = toText(query.warningLevel);
if (warningLevel && Object.prototype.hasOwnProperty.call(WARNING_ORDER, warningLevel)) {
params.warningOrder = WARNING_ORDER[warningLevel];
// 用统一表达式,未配置MKH 不参与预警判定(筛「缺货 / 预警」时不会漏出未配置MKH)
conditions.push(`${buildWarningOrderSql()} = @warningOrder`);
}
const onlyWarning = String(query.onlyWarning || '').toLowerCase();
if (['1', 'true', 'yes'].includes(onlyWarning)) {
// 仅看缺货 / 预警:统一表达式 <= 1(未配置MKH 一律为 2,自动排除)
conditions.push(`${buildWarningOrderSql()} <= 1`);
}
return { conditions, params };
};
/**
* 用「产品资料系统数据库」补齐商品图片。
*
* ⚠️ 2026-09-15 实测:目标仓 794 个 SKU 在业务库里图片字段全部为空——
* rpt.jst_sku.pic / pic_big 794/794 为 NULL
* rpt.fys_skuCostPriceUpdate.pictureSkucode 794/794 为 NULL
* 且 rpt.jst_sku 中只有 pic / pic_big 两个图片列,无其他图片源。
* 因此图片统一改从产品资料系统数据库(PostgreSQL)取:
* external.product_catalog_wide_base_v1
* 按 barcode(完整 SKU 编码)匹配,image_url 优先、product_thumbnail_url 兜底
* 实测目标仓 661 条可匹配,其中 481 条带图。
* 查询失败时静默降级,不影响列表主流程。
*/
async function attachProductImages(rows = []) {
const codes = Array.from(new Set(rows.map((row) => toText(row.skuId)).filter(Boolean)));
if (codes.length === 0) {
return rows;
}
const imageMap = new Map();
try {
const imageRows = await productDb.query(
`SELECT barcode, image_url, product_thumbnail_url
FROM external.product_catalog_wide_base_v1
WHERE barcode = ANY($1)`,
[codes]
);
imageRows.forEach((row) => {
const code = toText(row.barcode);
if (!code || imageMap.has(code)) return;
const url = toText(row.image_url) || toText(row.product_thumbnail_url);
if (url) imageMap.set(code, url);
});
} catch (error) {
console.warn('[ModularStock] 读取产品资料库图片失败,降级为业务库图片:', error.message);
}
return rows.map((row) => ({
...row,
productImageUrl: imageMap.get(toText(row.skuId)) || null,
}));
}
/**
* 「已配置MKH」判定说明(2026-09-17 晚修正):
* 直接由 buildBaseSql 的 cfg 子查询判定,数据源为聚水潭 SKU 级 BOM 维护
* rpt.jst_skuBomItem(map_outer_sku_id = 该 SKU 且父项 sku_id 以 'MKH' 开头),
* 不再依赖产品资料系统数据库。产品资料库仅用于补齐商品图片(见 attachProductImages)。
*/
const mapRow = (row = {}) => {
const realtimeStock = Number(row.realtimeStock || 0);
const stockDays = toNumber(row.stockDays);
const safetyDays = toNumber(row.safetyDays) ?? DEFAULT_SAFETY_DAYS;
// 预警等级以 SQL 端 buildWarningOrderSql() 的判定为准(已含「未配置MKH 不进预警」规则),
// 前端显示与排序口径保持一致,避免前后端各算一份。
const warningOrder = Number(row.warningOrder ?? WARNING_ORDER[WARNING_LEVEL.NORMAL]);
const warningLevel = warningOrder === WARNING_ORDER[WARNING_LEVEL.OUT_OF_STOCK]
? WARNING_LEVEL.OUT_OF_STOCK
: warningOrder === WARNING_ORDER[WARNING_LEVEL.WARNING]
? WARNING_LEVEL.WARNING
: WARNING_LEVEL.NORMAL;
return {
skuId: toText(row.skuId),
productName: toText(row.productName),
category: toText(row.category),
// 图片优先取产品资料系统数据库(业务库 pic/pic_big 目标仓全为空)
imageUrl: toText(row.productImageUrl) || toText(row.imageUrl),
warningLevel,
warningOrder,
mkhStatus: toText(row.mkhStatus) || MKH_STATUS.UNCONFIGURED,
// 业务库无出入库流水表:入库取采购到货(监控日),出库取 pm_stockMonitor 近一日发货量
inQtyToday: toNumber(row.inQtyToday) ?? 0,
outQtyToday: toNumber(row.outQtyToday) ?? 0,
realtimeStock,
purchaseTransit: toNumber(row.purchaseTransit) ?? 0,
location: toText(row.location),
avgShip15d: toNumber(row.avgShip15d),
stockDays,
safetyDays,
suggestStock: toNumber(row.suggestStock),
warehouse: toText(row.warehouse) || WAREHOUSE_NAME,
};
};
const buildProjection = () => `
s.sku_id AS skuId,
s.name AS productName,
s.category AS category,
${buildRealtimeStockSql()} AS realtimeStock,
ISNULL(ptr.transit, 0) AS purchaseTransit,
ISNULL(pin.inQty, 0) AS inQtyToday,
ISNULL(m.nearSecondSale, 0) AS outQtyToday,
${buildLocationSql()} AS location,
m.fifteenAvgSale AS avgShip15d,
${buildStockDaysSql()} AS stockDays,
m.safetyDays AS safetyDays,
m.suggestStock AS suggestStock,
s.other_3 AS warehouse,
COALESCE(
NULLIF(LTRIM(RTRIM(s.pic_big)), N''),
NULLIF(LTRIM(RTRIM(s.pic)), N''),
cost.pictureSkucode
) AS imageUrl,
CASE WHEN cfg.configured = 1 THEN N'已配置MKH' ELSE N'未配置MKH' END AS mkhStatus,
${buildWarningOrderSql()} AS warningOrder,
m.date AS monitorDate`;
async function getModularStockList(query = {}) {
const page = Math.max(1, Number.parseInt(query.page, 10) || 1);
const requestedSize = Number.parseInt(query.pageSize, 10) || DEFAULT_PAGE_SIZE;
const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, requestedSize));
const monitorDate = await resolveMonitorDate();
const { conditions, params } = buildFilters(query, monitorDate);
const baseSql = buildBaseSql();
const whereSql = conditions.length ? `${baseSql} AND ${conditions.join(' AND ')}` : baseSql;
const sortField = SORTABLE_FIELDS[query.sortField] ? query.sortField : 'realtimeStock';
const sortOrder = String(query.sortOrder || 'DESC').toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
const orderSql = `${SORTABLE_FIELDS[sortField]} ${sortOrder}`;
const offset = (page - 1) * pageSize;
// 已按 s.sku_id 排序时不再追加 tie-breaker,否则 ORDER BY 出现重复列报错 169
const orderClause = SORTABLE_FIELDS[sortField] === 's.sku_id'
? orderSql
: `${orderSql}, s.sku_id ASC`;
const rows = await cloudDatabase.query(
`SELECT ${buildProjection()}
${whereSql}
ORDER BY ${orderClause}
OFFSET ${offset} ROWS FETCH NEXT ${pageSize} ROWS ONLY`,
params,
{ requestTimeout: 180000 }
);
const [totalRow] = await cloudDatabase.query(
`SELECT COUNT_BIG(*) AS total ${whereSql}`,
params,
{ requestTimeout: 180000 }
);
const [summaryRow] = await buildSummary(whereSql, params);
// 用产品资料系统数据库补齐图片(业务库目标仓图片字段全为空)
const enrichedRows = await attachProductImages(rows);
return {
rows: enrichedRows.map(mapRow),
total: Number(totalRow?.total || 0),
page,
pageSize,
warehouse: WAREHOUSE_NAME,
monitorDate: summaryRow?.monitorDate || monitorDate,
summary: {
skuCount: summaryRow?.skuCount || 0,
outOfStockCount: summaryRow?.outOfStockCount || 0,
lowStockDaysCount: summaryRow?.lowStockDaysCount || 0,
configuredCount: summaryRow?.configuredCount || 0,
unconfiguredCount: (summaryRow?.skuCount || 0) - (summaryRow?.configuredCount || 0),
totalStock: summaryRow?.totalStock || 0,
totalPurchaseTransit: summaryRow?.totalPurchaseTransit || 0,
},
};
}
async function buildSummary(whereSql, params) {
const rows = await cloudDatabase.query(
`SELECT
COUNT_BIG(*) AS skuCount,
SUM(CASE WHEN ${buildWarningOrderSql()} = 0 THEN 1 ELSE 0 END) AS outOfStockCount,
CAST(ISNULL(SUM(${buildRealtimeStockSql()}), 0) AS BIGINT) AS totalStock,
CAST(ISNULL(SUM(ISNULL(ptr.transit, 0)), 0) AS BIGINT) AS totalPurchaseTransit,
SUM(CASE WHEN ${buildWarningOrderSql()} = 1 THEN 1 ELSE 0 END) AS lowStockDaysCount,
SUM(CASE WHEN cfg.configured = 1 THEN 1 ELSE 0 END) AS configuredCount,
MAX(m.date) AS monitorDate
${whereSql}`,
params,
{ requestTimeout: 180000 }
);
const row = rows[0] || {};
return [{
skuCount: Number(row.skuCount || 0),
outOfStockCount: Number(row.outOfStockCount || 0),
totalStock: Number(row.totalStock || 0),
totalPurchaseTransit: Number(row.totalPurchaseTransit || 0),
lowStockDaysCount: Number(row.lowStockDaysCount || 0),
configuredCount: Number(row.configuredCount || 0),
monitorDate: row.monitorDate ? new Date(row.monitorDate).toISOString().slice(0, 10) : null,
}];
}
async function getModularStockFilters() {
// 2026-09-18 修正:品类下拉不需要 JOIN 库存/采购/监控等重表,只用 SKU 主表即可,
// 避免 buildBaseSql 里新增采购在途 OUTER APPLY 后把 filters 接口拖慢。
const rows = await cloudDatabase.query(
`SELECT DISTINCT s.category
FROM dbo.jst_sku s
WHERE s.other_3 = @warehouseName
AND s.category IS NOT NULL
AND LTRIM(RTRIM(s.category)) <> N''
ORDER BY s.category`,
{ warehouseName: WAREHOUSE_NAME },
{ requestTimeout: 60000 }
);
return {
categories: rows.map((row) => toText(row.category)).filter(Boolean),
warningLevels: [WARNING_LEVEL.OUT_OF_STOCK, WARNING_LEVEL.WARNING, WARNING_LEVEL.NORMAL],
mkhStatuses: [MKH_STATUS.CONFIGURED, MKH_STATUS.UNCONFIGURED],
warehouse: WAREHOUSE_NAME,
};
}
/**
* 采购在途明细列表:目标仓全部「采购数量 > 已入库数量」的采购单明细。
* 口径:在途数量 = qty − inQty(已下单但尚未入库的部分)。
* 支持 keyword 搜索商品编码 / 名称,支持按 transitQty(默认)/ skuId / purchaseQty / deliveryDate 排序。
*/
async function getPurchaseInTransitList(query = {}) {
const page = Math.max(1, Number.parseInt(query.page, 10) || 1);
const requestedSize = Number.parseInt(query.pageSize, 10) || DEFAULT_PAGE_SIZE;
const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, requestedSize));
const keyword = toText(query.keyword);
const params = { warehouseName: WAREHOUSE_NAME };
let whereSql = `s.other_3 = @warehouseName AND p.qty > ISNULL(p.inQty, 0)`;
if (keyword) {
params.keyword = `%${keyword}%`;
whereSql += ` AND (p.sku_id LIKE @keyword OR p.name LIKE @keyword)`;
}
const sortField = String(query.sortField || '').trim();
const sortOrder = String(query.sortOrder || 'DESC').toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
const orderMap = {
skuId: 'p.sku_id',
deliveryDate: 'p.delivery_date',
transitQty: 'transitQty',
purchaseQty: 'p.qty',
};
const orderCol = orderMap[sortField] || 'transitQty';
const orderClause = sortField === 'skuId'
? `${orderCol} ${sortOrder}`
: `${orderCol} ${sortOrder}, p.sku_id ASC`;
const offset = (page - 1) * pageSize;
const [rows, totalRow] = await Promise.all([
cloudDatabase.query(
`SELECT
p.sku_id AS skuId,
p.name AS productName,
CAST(p.qty AS BIGINT) AS purchaseQty,
CAST(ISNULL(p.inQty, 0) AS BIGINT) AS inQty,
CAST(p.qty - ISNULL(p.inQty, 0) AS BIGINT) AS transitQty,
p.delivery_date AS deliveryDate,
CAST(p.po_id AS NVARCHAR(50)) AS poId,
CAST(p.poi_id AS NVARCHAR(50)) AS poiId
FROM rpt.jst_purchaseItem p
INNER JOIN dbo.jst_sku s ON s.sku_id = p.sku_id
WHERE ${whereSql}
ORDER BY ${orderClause}
OFFSET ${offset} ROWS FETCH NEXT ${pageSize} ROWS ONLY`,
params,
{ requestTimeout: 180000 }
),
cloudDatabase.query(
`SELECT COUNT_BIG(*) AS total
FROM rpt.jst_purchaseItem p
INNER JOIN dbo.jst_sku s ON s.sku_id = p.sku_id
WHERE ${whereSql}`,
params,
{ requestTimeout: 180000 }
),
]);
return {
rows: rows.map((row) => ({
skuId: toText(row.skuId),
productName: toText(row.productName),
purchaseQty: Number(row.purchaseQty || 0),
inQty: Number(row.inQty || 0),
transitQty: Number(row.transitQty || 0),
deliveryDate: row.deliveryDate ? new Date(row.deliveryDate).toISOString().slice(0, 10) : null,
poId: toText(row.poId),
poiId: toText(row.poiId),
})),
total: Number(totalRow?.[0]?.total || 0),
page,
pageSize,
warehouse: WAREHOUSE_NAME,
};
}
// 手动同步:本页无进程内缓存(每次请求直连副本库),同步动作 = 重新解析监控日并返回,
// 前端据此刷新列表到最新快照。
// 注意:副本库由聚水潭侧同步,应用层无法触发上游拉取,这里只保证取到最新监控日。
async function syncModularStock() {
const monitorDate = await resolveMonitorDate();
return {
syncedAt: new Date().toISOString(),
monitorDate,
};
}
module.exports = {
WAREHOUSE_NAME,
WAREHOUSE_WMS_CO_ID,
WARNING_LEVEL,
DEFAULT_SAFETY_DAYS,
getModularStockList,
getModularStockFilters,
getPurchaseInTransitList,
syncModularStock,
};