'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, };