'use strict'; /** * 退货库存预警(客退数据监控)——通用服务,按仓库出数 * * 支撑 5 张看板:横林二仓 / 苏北 / 睢宁 / 山东 / 河南(见 returnStockWarehouseRegistry.js)。 * 页面结构、口径、字段完全一致,差异只有「取哪个仓」「客退临时上架仓占哪个自定义库存位」, * 由仓库注册表声明。所有对外函数都接受 options.warehouse(key 或 wms_co_id),不传则默认横林二仓。 * * 口径说明(2026-09-17 复核): * * - 数据范围:聚水潭「箱及仓位库存 · {仓库} · {该仓客退上架仓}」。 * 仓位名逐仓不同(横林=客退临时上架仓 / 苏北=新苏北客退上架仓 / 睢宁=睢宁客退上架仓 / * 山东=青岗集客退上架仓 / 河南=河南洛宁客退上架仓),见 returnStockWarehouseRegistry。 * 报表副本(JSReportReplica)确实没有箱/仓位级库存:库内 218 张表 / 5,496 个字段, * 列名含 bin / box / position / location / shelf / slot 的为 0 个; * jst_sku.bin、jst_sku.other_bin、jst_returnAndRefundItem.box_id、 * jst_packInventoryInfo.bin 四个疑似字段全部 100% 为空。 * 因此用仓库级库存表 jst_inventoryInfo(按 wms_co_id 过滤)里该仓位绑定的自定义库存位近似, * 粒度是「商品编码」而非「箱/仓位」。 * - 创建时间(口径可切,默认 stockCreate): * · stockCreate(默认):聚水潭库存记录创建时间 jst_inventoryInfo.createTime,即该编码**当前这条** * 库存记录的建仓时间。横林二仓一个编码只有 1 行记录(2026-09-16 全量核对), * SQL 里的 MIN 只是 GROUP BY 语法要求,取到的就是当前值。 * 2026-09-17 实测:横林二仓 1,365 个编码里约 27%(369 个)取到的是 2025-12-03 的批量 * 建账时间(一个时刻就有 268 个编码),不是真实上架时间;其余编码与聚水潭一致。 * · firstInbound:该编码的最早入库时间(ERP 售后单 ∪ 奇门退款单 并集后 MIN),旧口径,值通常更早。 * · 导入覆盖最优先:导入聚水潭「箱及仓位库存」导出文件后,创建时间直接取文件里的「创建日期」 * (同一编码多箱时取最早的箱创建时间),此时与聚水潭完全一致。导入按仓库隔离。 * * ⚠️ 已删除的错误尝试(2026-09-17):曾上线「按退货单件数反推上架时间」并设为默认, * 实测被证伪——反推假设「客退位在库 N 件 = 最近 N 件的退货入库」,但副本里退货表的留存 * 窗口与真实退厂节奏对不上,横林二仓 1,365 个编码的反推结果**全部**落在最近一周 * (2026-09-09~09-17),与 createTime 精确一致的数量为 0(上轮看到的「精确一致」是 * 反推失败后回退到 createTime 的假象)。故整段下线,不要再实现类似反推。 * * 根因:聚水潭「箱及仓位库存」的「创建日期」= 仓位库存记录的创建时间,属于**仓位维度**, * 副本里没有任何仓位级库存表(见上)。要拿到它只有两条路: * ① IT 补同步——jst_packInventoryInfo 的表结构里本来就有 bin / bin_group / wms_co_id * 三个字段,但 85,152 行**全部为空**(pack_type 全是 DefaultPack)。让同步程序按 * 「装箱/仓位库存」接口把这三个字段取回来,即可按仓位精确取数(含创建时间); * ② 导入聚水潭导出的 xlsx(页面已有按钮,按仓隔离,导入值优先级最高)。 * 注:业务库 DATETIME 存的是北京时间,连接池已设 useUTC:false,页面显示值 = 库内原值, * 不再出现历史上的 +8 小时偏差。 * - 件数:默认只取本仓「客退上架仓」绑定的自定义库存位(横林/苏北/睢宁 = customize_qty_1, * 山东 = customize_qty_2;各仓用的位不同,2026-09-16 全库扫描确认)——2026-09-17 用户明确 * 「每个看板取聚水潭对应客退上架仓中的数据」,故不再叠加退货仓 return_qty(销退仓是另一个仓位, * 页面/导出仍单列出来供核对)。可用 qtyBasis 切换比对,导入聚水潭文件后以导入件数为准。 * - 在库天数:今天 − 创建时间,这是判断退厂的直接依据——「当天提取的库存数据里还有这条编码」, * 就说明这批货还没退厂,所以在库天数就是它的滞留时长;哪天这条编码从库存里消失了, * 就说明已经完成退厂,自然也不会出现在报表里。因此不再需要「出库日期 / 出库件数」。 * - 出库日期 / 出库件数:已从页面与导出里去掉。副本里没有聚水潭「采购退货(退供出库)」数据 * (jst_purchase 只有采购入库;全库 jst_purchaseItem.return_qty>0 仅 26 行且无横林二仓), * 此前两列恒为「待同步」,对判断退厂没有信息量,保留只会误导。 * - 单价 / 总金额:单价取商品资料 jst_sku 的成本价 cost_price(缺失或为 0 时回退 purchase_price), * 总金额 = 单价 × 总库存。两个价格都没有的编码(多为虚拟编码)金额为空,页面显示「--」。 * - 单价预警:在库龄预警之外的另一维度——单价 > 300 元 → 重点关注,200 ~ 300 元 → 关注, * 阈值可在页面调;库龄看「压了多久」,单价看「每件压了多少钱」,两者叠加才看得出资金风险。 * - 退厂时限(重点关注商品的处置要求):单价 > 重点关注线(默认 300 元)的编码, * 要求入库后 N 天内完成库存退厂,默认 N = 7 天(页面可调)。 * 在库天数 > N → overdue(已超期,超 N 天);≤ N → pending(剩余 N − 在库天数 天); * done(已退厂,保留分支:退厂完成即从库存消失,一般不会出现在报表里);非重点关注 → none(不适用)。 */ const cloudDb = require('../config/cloudDatabase'); const database = require('../config/database'); const { WAREHOUSE_LIST, RETURN_SLOT_COLUMNS, DEFAULT_WAREHOUSE_KEY, resolveWarehouse, resolveWarehouseKey, getWarehouseStockLabel, } = require('./returnStockWarehouseRegistry'); // 默认仓库(横林二仓):所有对外函数在不传 warehouse 时都走它 const DEFAULT_WAREHOUSE = resolveWarehouse(DEFAULT_WAREHOUSE_KEY); // 兼容旧引用:默认仓的标签 / 销退仓名 / 仓编码 const WAREHOUSE_LABEL = getWarehouseStockLabel(DEFAULT_WAREHOUSE); const WAREHOUSE_DB = DEFAULT_WAREHOUSE.returnWarehouseDb; const HENLIN2_WMS_CO_ID = DEFAULT_WAREHOUSE.wmsCoId; // 件数口径(副本能给出的候选,用于与聚水潭比对时切换) // 默认 = 「仅该仓客退上架仓占用的那一位自定义库存」:2026-09-17 用户明确 // 「每个看板取聚水潭对应客退上架仓中的数据」,所以默认不再叠加退货仓 return_qty // (return_qty 是「销退仓」的库存,与客退上架仓是两个仓位,页面仍单独列出来供核对)。 // value 是稳定的键,label 按仓库动态生成——因为各仓用的自定义库存位不同 // (横林/苏北/睢宁 = customize_qty_1,山东 = customize_qty_2),写死列名会让山东的 // 选项文案与实际取的列对不上。 const QTY_BASIS_VALUES = ['c1', 'rt_c1', 'rt', 'all']; const DEFAULT_QTY_BASIS = 'c1'; const buildQtyBasis = (warehouse = DEFAULT_WAREHOUSE) => { const slot = warehouse.returnSlot; // customize_qty_1 / customize_qty_2 / ... const bin = warehouse.returnBinName || '客退上架仓'; return [ { value: 'c1', label: `仅客退上架仓(${slot})`, note: `默认。即聚水潭「${bin}」这一个仓位的库存,对应副本列 ${slot}`, }, { value: 'rt_c1', label: `客退上架仓 + 退货仓(${slot} + return_qty)`, note: '若聚水潭把销退仓的货也算进该仓位,用这个口径', }, { value: 'rt', label: '仅退货仓(return_qty)', note: '聚水潭「销售退仓 / 销退仓」口径' }, { value: 'all', label: '退货 + 客退上架 + 在途(+ in_qty)', note: '含在途,一般偏大' }, ]; }; // 默认仓的口径选项(对外导出保持兼容) const QTY_BASIS = buildQtyBasis(DEFAULT_WAREHOUSE); // 创建时间口径(页面可切换,便于与聚水潭「箱及仓位库存 - 创建日期」逐条比对) // // ⚠️ 2026-09-17 复盘:曾短暂上线过「按退货单反推上架时间」口径,已**下线删除**。 // 原因:该反推假设「当前客退位在库件数 = 最近 N 件的退货入库」,但副本里两张退货表 // (jst_returnAndRefundItem 售后单、jst_qmcust_refundlist_item 奇门退款单)的留存窗口 // 与实际退厂节奏都对不上,实测在横林二仓 1,365 个编码上: // · 与 createTime 精确一致(精确到秒)的:0 个(上一轮看到的「精确一致」其实是 // 反推失败后回退到 createTime 的假象) // · 反推结果全部落在最近 1 周内(2026-09-09 ~ 09-17),与真实上架时间无关 // 结论:副本不具备还原「仓位库存记录创建时间」的数据,不要再用反推。 const CREATED_AT_BASIS_STOCK_CREATE = 'stockCreate'; const CREATED_AT_BASIS_FIRST_INBOUND = 'firstInbound'; const CREATED_AT_BASIS = [ { value: CREATED_AT_BASIS_STOCK_CREATE, label: '聚水潭库存记录创建时间', note: 'jst_inventoryInfo.createTime。注意 2026-09-17 实测:约 27~33% 的编码(2025-12-03 批量建账的老库存)' + '取到的是建账时间,不是真实上架时间;其余编码与聚水潭一致', }, { value: CREATED_AT_BASIS_FIRST_INBOUND, label: '最早入库时间(旧口径)', note: 'ERP 售后单 ∪ 奇门退款单取 MIN,通常比聚水潭早很多', }, ]; const DEFAULT_CREATED_AT_BASIS = CREATED_AT_BASIS_STOCK_CREATE; // 「期初批量建账」识别:stockCreate 口径下取到这些时刻的编码,其创建时间**不是**真实上架时间, // 而是聚水潭期初建账时间(2026-09-17 实测横林二仓:2025-12-03 就一个时刻 268 个编码、约 27%)。 // 用一个独立 source 标出来(不放进可选项,只用于「数据来源」列与导出的标注)。 const CREATED_AT_BASIS_STOCK_CREATE_BATCH = 'stockCreateBatch'; const STOCK_CREATE_BATCH_DAYS = ['2025-12-03']; const isBatchStockCreate = (raw) => { const text = formatDateTime(raw); return !!text && STOCK_CREATE_BATCH_DAYS.some((day) => text.startsWith(day)); }; const CREATED_AT_SOURCE_LABELS = { imported: '聚水潭导入', [CREATED_AT_BASIS_STOCK_CREATE]: '聚水潭库存记录', [CREATED_AT_BASIS_STOCK_CREATE_BATCH]: '聚水潭库存记录(期初建账时间,非真实上架时间)', [CREATED_AT_BASIS_FIRST_INBOUND]: 'ERP最早入库', }; // 库龄预警阈值(在库天数,页面可自己调) // 在库天数 > alertDays → 红色预警(默认 > 15 天) // 在库天数 > focusDays → 橙色关注(默认 > 7 天) // 之所以按在库天数分级:业务库的 ope_clear_task.alert(清仓任务预警)覆盖率只有 18% // (1,788 个编码里仅 324 个有值),且语义是「清仓任务是否异常」,不适合做本页面的库存预警。 const DEFAULT_FOCUS_DAYS = 7; const DEFAULT_ALERT_DAYS = 15; const MAX_THRESHOLD_DAYS = 3650; // 单价预警阈值(商品资料单价,元;页面可自己调) // 单价 > priceHighMin → 重点关注(默认 > 300 元) // 单价 ≥ priceFocusMin 且 ≤ priceHighMin → 关注(默认 200 ~ 300 元) // 单价 < priceFocusMin → 正常 // 按单价单独预警的理由:单价高的客退库存单件占资金多,同样压 1 件, // 占用的资金可能是低单价编码的几十倍,只看件数会把资金风险看平。 const DEFAULT_PRICE_FOCUS_MIN = 200; const DEFAULT_PRICE_HIGH_MIN = 300; const MAX_PRICE_THRESHOLD = 1000000; // 退厂时限(重点关注商品的处置要求,页面可调) // 单价 > priceHighMin 的编码(重点关注)→ 要求入库后 N 天内完成库存退厂,默认 7 天 // 在库天数 > N → 已超期;≤ N → 剩余(N − 在库天数)天;已出库 → 已退厂 // 为什么要单独设这个时限:这批编码单件占用资金最多,压在仓里越久越容易变成呆滞; // 给它一个硬性退厂窗口,页面按「剩余 / 超期」倒计时,便于催办。 const DEFAULT_RETURN_LIMIT_DAYS = 7; const MAX_RETURN_LIMIT_DAYS = 3650; const WARNING_LEVEL_OPTIONS = [ { value: 'all', label: '全部等级' }, { value: 'alert', label: '红色预警' }, { value: 'attention', label: '关注及以上' }, { value: 'focus', label: '关注' }, { value: 'normal', label: '正常' }, ]; const WARNING_LABELS = { alert: '红色预警', focus: '关注', normal: '正常', unknown: '未知', }; // 单价预警分级标签(与前端 StockWarning.jsx / dashboard 保持一致) const PRICE_WARNING_LABELS = { high: '重点关注', focus: '关注', normal: '正常', unknown: '无单价', }; const PRICE_LEVEL_RANK = { high: 3, focus: 2, normal: 1, unknown: 0 }; // 退厂时限状态标签 / 排序权重(与前端 StockWarning.jsx 保持一致) const RETURN_DEADLINE_LABELS = { overdue: '已超期', pending: '剩余', done: '已退厂', none: '不适用', }; const RETURN_DEADLINE_RANK = { overdue: 3, pending: 2, done: 1, none: 0 }; // 单价预警筛选(导出时服务端按同一口径过滤;页面里的表格是前端即时过滤) const PRICE_LEVEL_OPTIONS = [ { value: 'all', label: '全部价格' }, { value: 'high', label: '重点关注(> 300 元)' }, { value: 'attention', label: '关注及以上' }, { value: 'focus', label: '关注(200 ~ 300 元)' }, { value: 'normal', label: '正常(< 200 元)' }, { value: 'unknown', label: '无单价' }, ]; /** * 口径说明(按仓库生成)——每个看板的「已知口径差异」文案 */ const buildKnownGaps = (warehouse = DEFAULT_WAREHOUSE) => { const slot = warehouse.returnSlot; const gaps = [ `「箱及仓位库存 · ${warehouse.warehouseName} · ${warehouse.returnBinName}」是箱/仓位级数据,报表副本里没有该维度(2026-09-17 复核:库内 218 张表 / 5,496 个字段中,列名含 bin / box / position / location / shelf / slot 的为 0 个;jst_sku.bin、jst_sku.other_bin、jst_returnAndRefundItem.box_id、jst_packInventoryInfo.bin 这四个疑似字段全部 100% 为空),因此当前用 jst_inventoryInfo 仓库级库存(wms_co_id=${warehouse.wmsCoId} ${warehouse.warehouseName})里该仓位绑定的自定义库存位 ${slot} 近似,粒度是「商品编码」而非「箱/仓位」。要逐箱还原(含真实箱上架时间)只能导入聚水潭导出的文件。`, '创建时间:取聚水潭库存记录创建时间(jst_inventoryInfo.createTime)。它与聚水潭页面的「创建日期」**只能部分一致**,原因是聚水潭的「创建日期」属于箱/仓位维度:一个编码有几箱就有几条记录、各有各的上架时间,而副本里没有仓位级库存表,本页只能按编码取一个值。实测横林二仓 1,365 个编码里约 27%(369 个)取到的是 2025-12-03 的**期初批量建账时间**(一个时刻就有 268 个编码),比真实上架时间早很多,这类编码的在库天数会偏大、偏保守;其余约 73% 的编码该值与聚水潭一致。(2026-09-17 曾上线「按退货单反推上架时间」口径试图修正这批值,经 1,365 个编码全量验证为**错误**——反推结果全部落在最近一周,已下线删除。)', '创建时间的完整解决方案(二选一):① 让 IT 补同步——副本表 jst_packInventoryInfo 的结构里本来就有 bin(仓位)、bin_group、wms_co_id 三个字段,但现有 85,152 行的这三个字段**全部为空**(pack_type 全是 DefaultPack),说明同步时没取「装箱/仓位库存」的仓位信息;补上之后本页就能直接按「仓位 = ' + warehouse.returnBinName + '」精确取数,创建时间即聚水潭页面的「创建日期」。② 用页面右上「导入」按钮上传聚水潭「箱及仓位库存」导出的 xlsx,创建时间直接取文件里的「创建日期」(同一编码多箱取最早箱时间、件数求和),导入值优先级最高、按仓隔离、不覆盖其他看板。', `件数:默认只取「客退上架仓」这一位——${slot}(各仓用的位不一样:横林二仓 / 新苏北仓 / 睢宁仓用 customize_qty_1,山东青岗集仓用 customize_qty_2),不再叠加退货仓 return_qty(那是「销退仓」的库存,页面/导出里仍单列出来供核对)。若与聚水潭「${warehouse.returnBinName}」对不上,可在「件数口径」里切「客退上架仓 + 退货仓」比对,或直接导入聚水潭导出文件为准。`, '在库天数与退厂判定:报表只展示「在库天数」(今天 − 创建时间)。判断退厂不依赖出库表——当天提取的库存数据里还有这条编码,就说明没退厂,在库天数就是它的滞留时长;哪天它从库存里消失,就说明已退厂,自然也不在报表里。「出库日期 / 出库件数」两列因此已从页面和导出中移除(副本里没有聚水潭「采购退货(退供出库)」数据,全库 207 张表中只有 jst_purchase / jst_purchaseItem 采购入库单,item_type 全为「成品」、无退货单据类型;采购明细里 return_qty>0 仅 26 行、902 件且不在本仓;ope_clear_pool / ope_clear_task 是清货任务,也不是退供出库)。', '单价 / 总金额:单价取商品资料 jst_sku 的成本价(cost_price),成本价缺失或为 0 时回退采购价(purchase_price);总金额 = 单价 × 总库存(即「客退临时上架仓」的库存金额)。商品资料里两个价格都没有的编码(多为虚拟编码,如 HBZBF)单价与总金额显示「--」,且不计入顶部「库存总金额」,页面会单独提示这类编码的数量。', '单价预警:按商品单价单独分级,默认「单价 > 300 元 → 重点关注」「200 ~ 300 元 → 关注」「< 200 元 → 正常」,阈值可在页面上直接调。它与库龄预警是两个互补维度——库龄看「货压了多久」,单价看「每件压了多少钱」,两者叠加才能看出真正占资金的那批编码。', '退厂时限:单价 > 重点关注线(默认 300 元)的编码,要求入库后 7 天内完成库存退厂(时限可在页面调)。页面按「在库天数」对比时限,给出「已超期 N 天 / 剩余 N 天」,并在顶部单独提示这批编码的处置要求。「已退厂」的判定是「该编码已从当天库存中消失」(不再依赖出库表),一旦退厂完成,这条编码自然就不会再出现在报表里。', ]; if (!warehouse.hasReturnStock) { gaps.unshift( `⚠ 本仓(${warehouse.warehouseName},wms_co_id=${warehouse.wmsCoId})当天在副本里没有客退在库数据:仓库存在、退货单也有(「${warehouse.returnWarehouseDb}」),但没有编码落在客退库存位上,所以表格是空的。等聚水潭侧有客退库存同步进来后,本页会自动出现内容。` ); } return gaps; }; // 默认仓的口径说明(对外导出保持兼容) const KNOWN_GAPS = buildKnownGaps(DEFAULT_WAREHOUSE); const formatLocalDate = (value = new Date()) => { const date = value instanceof Date ? value : new Date(value); const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; }; const formatDateTime = (value) => { if (value === null || value === undefined || value === '') return ''; const date = value instanceof Date ? value : new Date(value); if (Number.isNaN(date.getTime())) return ''; const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0'); const hh = String(date.getHours()).padStart(2, '0'); const mm = String(date.getMinutes()).padStart(2, '0'); const ss = String(date.getSeconds()).padStart(2, '0'); return `${y}-${m}-${d} ${hh}:${mm}:${ss}`; }; const diffDays = (fromValue, toValue) => { if (!fromValue) return null; const from = fromValue instanceof Date ? fromValue : new Date(fromValue); if (Number.isNaN(from.getTime())) return null; const to = toValue instanceof Date ? toValue : new Date(toValue); if (Number.isNaN(to.getTime())) return null; const fromDay = Date.UTC(from.getFullYear(), from.getMonth(), from.getDate()); const toDay = Date.UTC(to.getFullYear(), to.getMonth(), to.getDate()); return Math.round((toDay - fromDay) / 86400000); }; // 客退库存(按仓库):把各口径的件数列都取出来,口径选择在 JS 层做,便于即时切换比对 // 同时带上聚水潭库存记录创建时间 createTime(默认创建时间口径) // // 三个自定义库存位全部取出来(横林/苏北/睢宁用 customize_qty_1,山东用 customize_qty_2), // 具体用哪一位由仓库注册表的 returnSlot 决定,见 calcQty。 // // 关于 MIN(createTime):这里用 MIN 只是 GROUP BY 的语法要求,**不是「取最早」**。 // 2026-09-16 全量核对过:横林二仓(wms_co_id=15033302)在 jst_inventoryInfo 里 // 12,425 个编码**每个都只有 1 行**记录,MIN 与 MAX 的差异编码数 = 0, // 所以 MIN 取到的就是该编码当前那条库存记录本身的创建时间,与聚水潭一致。 // 若将来副本改成按箱/仓位同步(一个编码多行),这里要重新确认「取哪一箱」的口径。 const buildStockCte = (warehouse = DEFAULT_WAREHOUSE) => { const slotExprs = RETURN_SLOT_COLUMNS .map((col) => `SUM(ISNULL(i.${col}, 0)) AS ${col}`); const havingExpr = ['return_qty', warehouse.returnSlot] .filter((col, index, arr) => arr.indexOf(col) === index) .map((col) => { if (col === 'return_qty') return 'ISNULL(i.return_qty, 0)'; return `ISNULL(i.${col}, 0)`; }); return ` stock AS ( SELECT LTRIM(RTRIM(CONVERT(NVARCHAR(120), i.sku_id))) AS product_code, SUM(ISNULL(i.return_qty, 0)) AS return_qty, ${slotExprs.join(',\n ')}, SUM(ISNULL(i.in_qty, 0)) AS in_qty, MIN(i.createTime) AS stock_create_time FROM dbo.jst_inventoryInfo i WHERE i.wms_co_id = ${warehouse.wmsCoId} AND i.sku_id IS NOT NULL AND LTRIM(RTRIM(CONVERT(NVARCHAR(120), i.sku_id))) <> N'' GROUP BY LTRIM(RTRIM(CONVERT(NVARCHAR(120), i.sku_id))) HAVING SUM(${havingExpr.join(' + ')}) > 0 )`; }; // 该商品编码的最早入库时间(ERP 售后单 ∪ 奇门退款单) // 只针对 stock 里已有的编码求 MIN,避免对两张退货明细大表做全量 GROUP BY const buildFirstInboundCte = (warehouse = DEFAULT_WAREHOUSE) => ` first_in AS ( SELECT product_code, MIN(inbound_time) AS first_inbound_time FROM ( SELECT LTRIM(RTRIM(CONVERT(NVARCHAR(120), it.sku_id))) AS product_code, COALESCE(it.receive_date, i.as_date) AS inbound_time FROM dbo.jst_returnAndRefundInfo i JOIN dbo.jst_returnAndRefundItem it ON it.as_id = i.as_id WHERE i.warehouse = N'${warehouse.returnWarehouseDb}' AND it.sku_id IN (SELECT product_code FROM stock) UNION ALL SELECT LTRIM(RTRIM(CONVERT(NVARCHAR(120), qit.sku_id))) AS product_code, COALESCE(qit.receive_date, q.as_date) AS inbound_time FROM dbo.jst_qmcust_refundlist q JOIN dbo.jst_qmcust_refundlist_item qit ON CONVERT(NVARCHAR(50), q.as_id) = CONVERT(NVARCHAR(50), qit.as_id) WHERE q.warehouse = N'${warehouse.returnWarehouseDb}' AND qit.sku_id IN (SELECT product_code FROM stock) ) u WHERE u.inbound_time IS NOT NULL GROUP BY u.product_code )`; // first_in 只在选用「最早入库时间」口径时才拼进 SQL: // 默认口径(聚水潭库存记录创建时间)不需要扫两张退货明细大表,快很多 const buildFullSql = (createdAtBasis = DEFAULT_CREATED_AT_BASIS, warehouse = DEFAULT_WAREHOUSE) => { const useFirstInbound = createdAtBasis === CREATED_AT_BASIS_FIRST_INBOUND; const ctes = [buildStockCte(warehouse)]; if (useFirstInbound) ctes.push(buildFirstInboundCte(warehouse)); const selectColumns = [ 's.product_code AS [productCode]', 's.return_qty AS [returnQty]', 's.customize_qty_1 AS [customizeQty1]', 's.customize_qty_2 AS [customizeQty2]', 's.customize_qty_3 AS [customizeQty3]', 's.in_qty AS [inQty]', 's.stock_create_time AS [stockCreateTime]', ]; if (useFirstInbound) selectColumns.push('f.first_inbound_time AS [firstInboundTime]'); selectColumns.push("ISNULL(sk.supplier_name, N'') AS [supplier]"); // 单价取自商品资料 jst_sku:成本价优先,缺失/为 0 时回退采购价(见 pickUnitPrice) selectColumns.push('sk.cost_price AS [costPrice]'); selectColumns.push('sk.purchase_price AS [purchasePrice]'); const joins = ['LEFT JOIN dbo.jst_sku sk ON sk.sku_id = s.product_code']; if (useFirstInbound) joins.push('LEFT JOIN first_in f ON f.product_code = s.product_code'); return ` WITH ${ctes.join(',\n ')} SELECT ${selectColumns.join(',\n ')} FROM stock s ${joins.join('\n ')}`; }; // --------------------------------------------------------------------------- // 副本数据缓存:全量只有 1,788 行,但 first_in 要扫两张退货明细大表,单次查询 3~10s。 // 口径切换 / 排序 / 分页 / 关键字全部在内存里做,因此缓存原始行即可, // 只有显式 refresh 才回源,避免翻页或换口径时反复打库。 // --------------------------------------------------------------------------- // 库存表的实际拉取时间(副本同步进来的 updateTime),用于页面展示「库存更新时间」 const buildStockSyncedSql = (warehouse = DEFAULT_WAREHOUSE) => ` SELECT MAX(updateTime) AS [syncedAt] FROM dbo.jst_inventoryInfo WHERE wms_co_id = ${warehouse.wmsCoId}`; const CACHE_TTL_MS = 10 * 60 * 1000; // 缓存键 = 仓库 + 创建时间口径(两者都会改变 SQL / 取到的行) const buildCacheKey = (warehouse, basis) => `${warehouse.key}|${basis}`; const rowCache = new Map(); const inflightCache = new Map(); // 「库存更新时间」也要按仓库分,否则切仓会串号 const stockSyncedAtState = new Map(); async function loadReplicaRows({ refresh = false, createdAtBasis = DEFAULT_CREATED_AT_BASIS, warehouse = DEFAULT_WAREHOUSE, } = {}) { const basis = resolveCreatedAtBasis(createdAtBasis); const key = buildCacheKey(warehouse, basis); const cached = rowCache.get(key); if (!refresh && cached && cached.rows && Date.now() - cached.at < CACHE_TTL_MS) { return cached.rows; } const running = inflightCache.get(key); if (running) return running; const task = Promise.all([ cloudDb.query(buildFullSql(basis, warehouse), {}, { requestTimeout: 300000 }), cloudDb.query(buildStockSyncedSql(warehouse), {}, { requestTimeout: 120000 }), ]) .then(([rows, syncRows]) => { rowCache.set(key, { at: Date.now(), rows: rows || [] }); const syncedAt = formatDateTime(syncRows?.[0]?.syncedAt); if (syncedAt) stockSyncedAtState.set(warehouse.key, { at: Date.now(), value: syncedAt }); inflightCache.delete(key); return rowCache.get(key).rows; }) .catch((error) => { inflightCache.delete(key); throw error; }); inflightCache.set(key, task); return task; } /** 副本库存表最近一次同步时间(YYYY-MM-DD HH:mm:ss),页面「库存更新时间」用 */ async function loadStockSyncedAt({ refresh = false, warehouse = DEFAULT_WAREHOUSE } = {}) { const state = stockSyncedAtState.get(warehouse.key); if (!refresh && state?.value && Date.now() - state.at < CACHE_TTL_MS) { return state.value; } try { await loadReplicaRows({ refresh, warehouse }); } catch (error) { return state?.value || ''; } return stockSyncedAtState.get(warehouse.key)?.value || ''; } /** 启动时后台预热,避免用户第一次点开页面时等几十秒(五个仓库都预热,默认口径) */ async function warmReturnStockWarningCache() { // 串行而非并发:预热是后台任务,没必要在启动瞬间同时打五条库 for (const warehouse of WAREHOUSE_LIST) { try { // eslint-disable-next-line no-await-in-loop await loadReplicaRows({ refresh: true, warehouse }); } catch (error) { // 预热失败不影响启动,用户访问时会再试一次 } } } const resolveQtyBasis = (value) => { const key = String(value || '').trim(); return QTY_BASIS_VALUES.includes(key) ? key : DEFAULT_QTY_BASIS; }; const resolveCreatedAtBasis = (value) => { const key = String(value || '').trim(); return CREATED_AT_BASIS.some((item) => item.value === key) ? key : DEFAULT_CREATED_AT_BASIS; }; const resolveThreshold = (value, fallback) => { const parsed = Number.parseInt(value, 10); if (!Number.isFinite(parsed) || parsed < 0) return fallback; return Math.min(MAX_THRESHOLD_DAYS, parsed); }; /** * 预警阈值归一:关注线不能高于预警线,否则低阈值会把高阈值吃掉 */ const resolveThresholds = (options = {}) => { const focusRaw = resolveThreshold(options.focusDays, DEFAULT_FOCUS_DAYS); const alertRaw = resolveThreshold(options.alertDays, DEFAULT_ALERT_DAYS); const alertDays = Math.max(focusRaw, alertRaw); return { focusDays: Math.min(focusRaw, alertDays), alertDays }; }; // 「大于」判定:在库天数 > 预警天数 才算红色预警(默认 > 15 天) const resolveWarningLevel = (stockDays, focusDays, alertDays) => { if (stockDays === null || stockDays === undefined) return 'unknown'; if (stockDays > alertDays) return 'alert'; if (stockDays > focusDays) return 'focus'; return 'normal'; }; const matchesWarningLevel = (level, filter) => { if (!filter || filter === 'all') return true; if (filter === 'alert') return level === 'alert'; if (filter === 'focus') return level === 'focus'; if (filter === 'attention') return level === 'alert' || level === 'focus'; if (filter === 'normal') return level === 'normal'; return true; }; const resolveWarningLevelFilter = (value) => { const key = String(value || '').trim(); return WARNING_LEVEL_OPTIONS.some((item) => item.value === key) ? key : 'all'; }; const resolvePriceThreshold = (value, fallback) => { const parsed = Number.parseFloat(value); if (!Number.isFinite(parsed) || parsed < 0) return fallback; return Math.min(MAX_PRICE_THRESHOLD, Math.round(parsed * 100) / 100); }; /** 单价阈值归一:关注线不能高于重点关注线 */ const resolvePriceThresholds = (options = {}) => { const focusRaw = resolvePriceThreshold(options.priceFocusMin, DEFAULT_PRICE_FOCUS_MIN); const highRaw = resolvePriceThreshold(options.priceHighMin, DEFAULT_PRICE_HIGH_MIN); const priceHighMin = Math.max(focusRaw, highRaw); return { priceFocusMin: Math.min(focusRaw, priceHighMin), priceHighMin }; }; /** * 单价预警分级(「大于」判定,与页面文案一致): * 单价 > priceHighMin → high(重点关注,默认 > 300 元) * 单价 ≥ priceFocusMin → focus(关注,默认 200 ~ 300 元) * 其余 → normal * 拿不到单价(商品资料缺成本价/采购价)→ unknown */ const resolvePriceLevel = (unitPrice, priceFocusMin, priceHighMin) => { if (unitPrice === null || unitPrice === undefined) return 'unknown'; if (unitPrice > priceHighMin) return 'high'; if (unitPrice >= priceFocusMin) return 'focus'; return 'normal'; }; const matchesPriceLevel = (level, filter) => { if (!filter || filter === 'all') return true; if (filter === 'high') return level === 'high'; if (filter === 'focus') return level === 'focus'; if (filter === 'attention') return level === 'high' || level === 'focus'; if (filter === 'normal') return level === 'normal'; if (filter === 'unknown') return level === 'unknown'; return true; }; const resolvePriceLevelFilter = (value) => { const key = String(value || '').trim(); const allowed = PRICE_LEVEL_OPTIONS.some((item) => item.value === key) || key === 'unknown'; return allowed ? key : 'all'; }; const resolveReturnLimitDays = (value) => { const parsed = Number.parseInt(value, 10); if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_RETURN_LIMIT_DAYS; return Math.min(MAX_RETURN_LIMIT_DAYS, parsed); }; /** * 退厂时限判定(只对「重点关注」编码生效): * 非重点关注 → none(不适用) * 已出库 → done(已退厂,保留分支:退厂完成即从库存消失,一般不会出现在报表里) * 在库天数 > 退厂时限 → overdue,returnRemainDays = 超期天数 * 其余 → pending,returnRemainDays = 剩余天数(时限 − 在库天数) * 拿不到创建时间(算不出在库天数)时按 pending 处理,剩余天数留空,页面提示「待补创建时间」。 */ const resolveReturnDeadline = ({ unitPriceLevel, stockDays, isOutbound, returnLimitDays }) => { if (unitPriceLevel !== 'high') return { status: 'none', remainDays: null }; if (isOutbound) return { status: 'done', remainDays: null }; if (stockDays === null || stockDays === undefined) return { status: 'pending', remainDays: null }; if (stockDays > returnLimitDays) return { status: 'overdue', remainDays: stockDays - returnLimitDays }; return { status: 'pending', remainDays: returnLimitDays - stockDays }; }; /** 退厂时限的一句话文案(导出 Excel 用,与页面表格显示口径一致) */ const formatReturnDeadlineLabel = (status, remainDays) => { if (status === 'overdue') return remainDays === null ? '已超期' : `已超期 ${remainDays} 天`; if (status === 'pending') return remainDays === null ? '剩余天数未知' : `剩余 ${remainDays} 天`; if (status === 'done') return '已退厂'; return '—'; }; // 自定义库存位列名 → 行字段名(SQL 里 select 出来的别名) const RETURN_SLOT_FIELD_MAP = { customize_qty_1: 'customizeQty1', customize_qty_2: 'customizeQty2', customize_qty_3: 'customizeQty3', }; /** 本仓「客退临时上架仓」的件数(取该仓注册表里声明的那个自定义库存位) */ const pickReturnSlotQty = (row, warehouse = DEFAULT_WAREHOUSE) => { const field = RETURN_SLOT_FIELD_MAP[warehouse.returnSlot] || 'customizeQty1'; return Number(row[field] || 0); }; /** * 件数口径(各仓的「客退临时上架仓」用的库存位不同,因此 c1/c2 都是相对本仓而言): * rt_c1(默认)→ return_qty + 本仓客退位 * c1 → 仅本仓客退位 * rt → 仅退货仓 return_qty * all → return_qty + 三个自定义位 + 在途 in_qty */ const calcQty = (row, basis, warehouse = DEFAULT_WAREHOUSE) => { const rt = Number(row.returnQty || 0); const slotQty = pickReturnSlotQty(row, warehouse); const inQty = Number(row.inQty || 0); if (basis === 'c1') return slotQty; if (basis === 'rt') return rt; if (basis === 'all') { const allSlots = RETURN_SLOT_COLUMNS.reduce((sum, col) => sum + Number(row[RETURN_SLOT_FIELD_MAP[col]] || 0), 0); return rt + allSlots + inQty; } return rt + slotQty; }; // --------------------------------------------------------------------------- // 单价口径:商品资料 jst_sku 的成本价(cost_price)优先,缺失或为 0 时回退采购价(purchase_price)。 // 总金额 = 单价 × 总库存。两个价格都拿不到的编码(多为虚拟编码,如 HBZBF)金额为 null,页面显示「--」。 // --------------------------------------------------------------------------- const UNIT_PRICE_BASIS_LABEL = '成本价 cost_price(缺失时回退采购价 purchase_price)'; const toFiniteNumber = (value) => { if (value === null || value === undefined || value === '') return null; const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; }; /** 单价:成本价 > 采购价,都拿不到时返回 null */ const pickUnitPrice = (source = {}) => { const cost = toFiniteNumber(source.costPrice); const purchase = toFiniteNumber(source.purchasePrice); if (cost !== null && cost > 0) return Math.round(cost * 10000) / 10000; if (purchase !== null && purchase > 0) return Math.round(purchase * 10000) / 10000; if (cost !== null) return cost; if (purchase !== null) return purchase; return null; }; const roundAmount = (value) => (value === null || value === undefined ? null : Math.round(value * 100) / 100); /** 总金额:单价 × 总库存;单价缺失时为 null */ const calcAmount = (unitPrice, qty) => (unitPrice === null ? null : roundAmount(unitPrice * Number(qty || 0))); /** * 只为「导入里有、副本里没有」的编码补单价(副本里的行 SQL 已 JOIN 带出价格)。 * 一般只有零星几个,返回 Map。 */ async function loadPriceMap(codes = []) { const unique = Array.from(new Set(codes.map((code) => String(code || '').trim()).filter(Boolean))); const map = new Map(); if (!unique.length) return map; const CHUNK = 500; for (let start = 0; start < unique.length; start += CHUNK) { const chunk = unique.slice(start, start + CHUNK); const params = {}; const placeholders = chunk.map((code, index) => { params[`code${index}`] = code; return `@code${index}`; }); // eslint-disable-next-line no-await-in-loop const rows = await cloudDb.query( `SELECT LTRIM(RTRIM(CONVERT(NVARCHAR(120), sku_id))) AS [productCode], cost_price AS [costPrice], purchase_price AS [purchasePrice] FROM dbo.jst_sku WHERE sku_id IN (${placeholders.join(', ')})`, params, { requestTimeout: 120000 } ); (rows || []).forEach((row) => { const code = String(row.productCode || '').trim(); if (code) map.set(code, { costPrice: row.costPrice, purchasePrice: row.purchasePrice }); }); } return map; } /** 副本行在 SQL 里已 JOIN 出价格,只有「导入里有、副本里没有」的编码才需要额外查一次商品资料 */ async function loadPriceMapForOverrides(replicaRows = [], overrides = new Map()) { if (!overrides || overrides.size === 0) return new Map(); const replicaCodes = new Set((replicaRows || []).map((row) => String(row.productCode || '').trim())); const missing = Array.from(overrides.keys()).filter((code) => !replicaCodes.has(String(code || '').trim())); if (!missing.length) return new Map(); try { return await loadPriceMap(missing); } catch (error) { // 补单价失败不影响主流程,页面按「--」展示 return new Map(); } } const normalizeKeyword = (keyword) => { const value = String(keyword || '').trim(); return value ? value.slice(0, 100) : ''; }; const matchesKeyword = (row, keyword) => { if (!keyword) return true; const needle = keyword.toLowerCase(); return String(row.productCode || '').toLowerCase().includes(needle) || String(row.supplier || '').toLowerCase().includes(needle); }; const normalizeSupplier = (value) => { const text = String(value || '').trim(); return text ? text.slice(0, 200) : ''; }; /** 供应商筛选:精确匹配(页面下拉里的值来自数据本身),保证页面与导出口径一致 */ const matchesSupplier = (row, supplier) => (!supplier ? true : String(row.supplier || '').trim() === supplier); /** * 读取导入的覆盖值(以聚水潭导出为准) * 按仓库隔离:每个看板只吃自己那份聚水潭导出文件,互不覆盖 */ async function loadImportOverrides(warehouse = DEFAULT_WAREHOUSE) { try { const rows = await database.local.query( `SELECT product_code, created_at, inbound_qty, supplier FROM return_stock_warning_import WHERE warehouse_key = ?`, [warehouse.key] ); const map = new Map(); (rows || []).forEach((row) => { const code = String(row.product_code || '').trim(); if (!code) return; map.set(code, { createdAt: row.created_at ? new Date(row.created_at) : null, inboundQty: row.inbound_qty === null || row.inbound_qty === undefined ? null : Number(row.inbound_qty), supplier: row.supplier ? String(row.supplier) : '', }); }); return map; } catch (error) { // 表尚未建立时静默降级为「无导入」 return new Map(); } } async function loadLatestImportBatch(warehouse = DEFAULT_WAREHOUSE) { try { const rows = await database.local.query( `SELECT batch_id, file_name, total_rows, matched_rows, with_created_at, with_qty, skipped_rows, imported_by, imported_at FROM return_stock_warning_import_batch WHERE warehouse_key = ? ORDER BY imported_at DESC LIMIT 1`, [warehouse.key] ); const row = rows?.[0]; if (!row) return null; return { batchId: row.batch_id, fileName: row.file_name || '', totalRows: Number(row.total_rows || 0), matchedRows: Number(row.matched_rows || 0), withCreatedAt: Number(row.with_created_at || 0), withQty: Number(row.with_qty || 0), skippedRows: Number(row.skipped_rows || 0), importedBy: row.imported_by || '', importedAt: formatDateTime(row.imported_at), }; } catch (error) { return null; } } /** * 创建时间取值:聚水潭导入 > 选中的副本口径 */ function pickCreatedAt(row, override, createdAtBasis) { if (override && override.createdAt) { return { raw: override.createdAt, source: 'imported' }; } if (createdAtBasis === CREATED_AT_BASIS_FIRST_INBOUND) { return { raw: row.firstInboundTime || null, source: CREATED_AT_BASIS_FIRST_INBOUND }; } const raw = row.stockCreateTime || null; return { raw, source: isBatchStockCreate(raw) ? CREATED_AT_BASIS_STOCK_CREATE_BATCH : CREATED_AT_BASIS_STOCK_CREATE, }; } /** * 组装最终行:副本数据 + 导入覆盖 + 口径选择 */ function composeRows({ replicaRows, overrides, qtyBasis, createdAtBasis, today, scope, focusDays, alertDays, priceMap, priceFocusMin = DEFAULT_PRICE_FOCUS_MIN, priceHighMin = DEFAULT_PRICE_HIGH_MIN, returnLimitDays = DEFAULT_RETURN_LIMIT_DAYS, warehouse = DEFAULT_WAREHOUSE, }) { const hasImport = overrides.size > 0; const rows = []; const seen = new Set(); // 退厂判定:能出现在这份库存数据里,就说明「还没退厂」;哪天编码从库存消失即视为已退厂。 // 因此这里恒为 false(无需采购退货出库表),两列 outboundDate / outboundQty 也不再对外展示。 const isOutbound = false; replicaRows.forEach((row) => { const code = String(row.productCode || '').trim(); if (!code) return; seen.add(code); const override = overrides.get(code); const basisQty = calcQty(row, qtyBasis, warehouse); const qty = override && override.inboundQty !== null ? override.inboundQty : basisQty; // 创建时间优先级:聚水潭导入 > 选中的副本口径 const picked = pickCreatedAt(row, override, createdAtBasis); const createdAtRaw = picked.raw; const createdAt = formatDateTime(createdAtRaw); const stockDays = diffDays(createdAtRaw, today); const unitPrice = pickUnitPrice(row); const unitPriceLevel = resolvePriceLevel(unitPrice, priceFocusMin, priceHighMin); // 重点关注(单价 > priceHighMin)的编码:入库后必须在 returnLimitDays 天内完成退厂 const deadline = resolveReturnDeadline({ unitPriceLevel, stockDays, isOutbound, returnLimitDays, }); if (scope === 'imported' && !override) return; if (qty <= 0) return; rows.push({ productCode: code, createdAt, createdAtRaw: createdAtRaw || null, createdAtSource: picked.source, createdAtSourceLabel: CREATED_AT_SOURCE_LABELS[picked.source] || '', inboundQty: qty, unitPrice, amount: calcAmount(unitPrice, qty), unitPriceLevel, unitPriceLevelLabel: PRICE_WARNING_LABELS[unitPriceLevel] || '', returnDeadlineStatus: deadline.status, returnDeadlineStatusLabel: RETURN_DEADLINE_LABELS[deadline.status] || '', returnRemainDays: deadline.remainDays, supplier: (override && override.supplier) || row.supplier || '', returnQty: Number(row.returnQty || 0), customizeQty1: Number(row.customizeQty1 || 0), customizeQty2: Number(row.customizeQty2 || 0), customizeQty3: Number(row.customizeQty3 || 0), // 本仓「客退临时上架仓」对应位的件数,导出里单列出来便于与聚水潭比对 returnSlotQty: pickReturnSlotQty(row, warehouse), outboundDate: '', outboundQty: null, stockDays, warningLevel: resolveWarningLevel(stockDays, focusDays, alertDays), isOutbound, source: override ? 'jst' : 'replica', noInboundRecord: !createdAtRaw, }); }); // 导入里有、副本里没有的编码:以导入为准补进来 overrides.forEach((override, code) => { if (seen.has(code)) return; if (scope === 'replica') return; const qty = override.inboundQty === null ? 0 : override.inboundQty; if (qty <= 0) return; const stockDays = diffDays(override.createdAt, today); const source = override.createdAt ? 'imported' : createdAtBasis; // 副本里没有这行,单价单独查商品资料补上 const unitPrice = pickUnitPrice(priceMap.get(code) || {}); const unitPriceLevel = resolvePriceLevel(unitPrice, priceFocusMin, priceHighMin); const deadline = resolveReturnDeadline({ unitPriceLevel, stockDays, isOutbound, returnLimitDays, }); rows.push({ productCode: code, createdAt: formatDateTime(override.createdAt), createdAtRaw: override.createdAt || null, createdAtSource: source, createdAtSourceLabel: CREATED_AT_SOURCE_LABELS[source] || '', inboundQty: qty, unitPrice, amount: calcAmount(unitPrice, qty), unitPriceLevel, unitPriceLevelLabel: PRICE_WARNING_LABELS[unitPriceLevel] || '', returnDeadlineStatus: deadline.status, returnDeadlineStatusLabel: RETURN_DEADLINE_LABELS[deadline.status] || '', returnRemainDays: deadline.remainDays, supplier: override.supplier || '', returnQty: null, customizeQty1: null, customizeQty2: null, customizeQty3: null, returnSlotQty: null, outboundDate: '', outboundQty: null, stockDays, warningLevel: resolveWarningLevel(stockDays, focusDays, alertDays), isOutbound, source: 'jst', noInboundRecord: !override.createdAt, }); }); return { rows, hasImport }; } function sortRows(rows, sortBy, sortOrder) { const direction = String(sortOrder).toLowerCase() === 'asc' ? 1 : -1; const value = (row) => { if (sortBy === 'createdAt') return row.createdAtRaw ? row.createdAtRaw.getTime() : 0; if (sortBy === 'productCode') return row.productCode; if (sortBy === 'supplier') return row.supplier; if (sortBy === 'stockDays') return row.stockDays === null ? -1 : row.stockDays; if (sortBy === 'amount') return row.amount === null || row.amount === undefined ? -1 : row.amount; if (sortBy === 'unitPrice') return row.unitPrice === null || row.unitPrice === undefined ? -1 : row.unitPrice; if (sortBy === 'unitPriceLevel') return PRICE_LEVEL_RANK[row.unitPriceLevel] || 0; if (sortBy === 'returnDeadlineStatus') return RETURN_DEADLINE_RANK[row.returnDeadlineStatus] || 0; // 退厂剩余天数为 null(不适用 / 算不出)时排到最后 if (sortBy === 'returnRemainDays') { return row.returnRemainDays === null || row.returnRemainDays === undefined ? -1 : row.returnRemainDays; } return row.inboundQty; }; return rows.sort((a, b) => { const va = value(a); const vb = value(b); if (typeof va === 'string' || typeof vb === 'string') { return String(va).localeCompare(String(vb)) * direction; } if (va === vb) return String(a.productCode).localeCompare(String(b.productCode)); return (va - vb) * direction; }); } function buildSummary(rows) { const suppliers = new Set(); let totalQty = 0; let totalAmount = 0; let pricedCodes = 0; let missingPriceCodes = 0; let maxStockDays = null; let maxUnitPrice = null; let noInboundRecord = 0; let importedCodes = 0; const warning = { alertCodes: 0, alertQty: 0, focusCodes: 0, focusQty: 0, normalCodes: 0, normalQty: 0, unknownCodes: 0, unknownQty: 0, }; // 单价预警汇总:按价位的编码数 / 件数 / 库存金额(金额最能反映资金占用) const priceWarning = { highCodes: 0, highQty: 0, highAmount: 0, focusCodes: 0, focusQty: 0, focusAmount: 0, normalCodes: 0, normalQty: 0, normalAmount: 0, unknownCodes: 0, unknownQty: 0, unknownAmount: 0, }; // 退厂时限汇总:只统计「重点关注」(单价 > 重点关注线)的编码——只有这批有退厂时限要求 const returnDeadline = { highCodes: 0, highQty: 0, highAmount: 0, overdueCodes: 0, overdueQty: 0, overdueAmount: 0, pendingCodes: 0, pendingQty: 0, pendingAmount: 0, doneCodes: 0, }; rows.forEach((row) => { const qty = Number(row.inboundQty || 0); const amount = row.amount === null || row.amount === undefined ? 0 : Number(row.amount || 0); totalQty += qty; if (row.supplier) suppliers.add(row.supplier); // 只有在商品资料里取到单价的编码才计入总金额,其余单列提示,避免金额被低估却看不出来 if (row.amount === null || row.amount === undefined) { missingPriceCodes += 1; } else { totalAmount += amount; pricedCodes += 1; } if (row.stockDays !== null && (maxStockDays === null || row.stockDays > maxStockDays)) { maxStockDays = row.stockDays; } if (row.unitPrice !== null && row.unitPrice !== undefined && (maxUnitPrice === null || row.unitPrice > maxUnitPrice)) { maxUnitPrice = row.unitPrice; } if (row.noInboundRecord) noInboundRecord += 1; if (row.source === 'jst') importedCodes += 1; if (row.warningLevel === 'alert') { warning.alertCodes += 1; warning.alertQty += qty; } else if (row.warningLevel === 'focus') { warning.focusCodes += 1; warning.focusQty += qty; } else if (row.warningLevel === 'normal') { warning.normalCodes += 1; warning.normalQty += qty; } else { warning.unknownCodes += 1; warning.unknownQty += qty; } if (row.unitPriceLevel === 'high') { priceWarning.highCodes += 1; priceWarning.highQty += qty; priceWarning.highAmount += amount; } else if (row.unitPriceLevel === 'focus') { priceWarning.focusCodes += 1; priceWarning.focusQty += qty; priceWarning.focusAmount += amount; } else if (row.unitPriceLevel === 'normal') { priceWarning.normalCodes += 1; priceWarning.normalQty += qty; priceWarning.normalAmount += amount; } else { priceWarning.unknownCodes += 1; priceWarning.unknownQty += qty; priceWarning.unknownAmount += amount; } // 退厂时限:只对重点关注的编码统计「已超期 / 剩余 / 已退厂」 if (row.unitPriceLevel === 'high') { returnDeadline.highCodes += 1; returnDeadline.highQty += qty; returnDeadline.highAmount += amount; if (row.returnDeadlineStatus === 'overdue') { returnDeadline.overdueCodes += 1; returnDeadline.overdueQty += qty; returnDeadline.overdueAmount += amount; } else if (row.returnDeadlineStatus === 'done') { returnDeadline.doneCodes += 1; } else { returnDeadline.pendingCodes += 1; returnDeadline.pendingQty += qty; returnDeadline.pendingAmount += amount; } } }); priceWarning.highAmount = roundAmount(priceWarning.highAmount); priceWarning.focusAmount = roundAmount(priceWarning.focusAmount); priceWarning.normalAmount = roundAmount(priceWarning.normalAmount); priceWarning.unknownAmount = roundAmount(priceWarning.unknownAmount); returnDeadline.highAmount = roundAmount(returnDeadline.highAmount); returnDeadline.overdueAmount = roundAmount(returnDeadline.overdueAmount); returnDeadline.pendingAmount = roundAmount(returnDeadline.pendingAmount); return { codes: rows.length, totalQty, totalAmount: roundAmount(totalAmount), pricedCodes, missingPriceCodes, suppliers: suppliers.size, maxStockDays, maxUnitPrice, noInboundRecord, importedCodes, warning, priceWarning, returnDeadline, }; } /** * 退货库存预警列表 */ async function getReturnStockWarningList(options = {}) { const warehouse = resolveWarehouse(options.warehouse); const page = Math.max(1, Number.parseInt(options.page, 10) || 1); const pageSize = Math.min(5000, Math.max(1, Number.parseInt(options.pageSize, 10) || 100)); const keyword = normalizeKeyword(options.keyword); const supplier = normalizeSupplier(options.supplier); const qtyBasis = resolveQtyBasis(options.qtyBasis); const createdAtBasis = resolveCreatedAtBasis(options.createdAtBasis); const scope = ['imported', 'replica'].includes(String(options.scope)) ? String(options.scope) : 'all'; const { focusDays, alertDays } = resolveThresholds(options); const { priceFocusMin, priceHighMin } = resolvePriceThresholds(options); const returnLimitDays = resolveReturnLimitDays(options.returnLimitDays); const warningLevel = resolveWarningLevelFilter(options.warningLevel); const priceLevel = resolvePriceLevelFilter(options.priceLevel); const today = formatLocalDate(); const [replicaRows, overrides, latestImport] = await Promise.all([ loadReplicaRows({ refresh: String(options.refresh) === 'true', createdAtBasis, warehouse }), loadImportOverrides(warehouse), loadLatestImportBatch(warehouse), ]); const priceMap = await loadPriceMapForOverrides(replicaRows, overrides); const composed = composeRows({ replicaRows: replicaRows || [], overrides, qtyBasis, createdAtBasis, today, scope, focusDays, alertDays, priceMap, priceFocusMin, priceHighMin, returnLimitDays, warehouse, }); const filtered = composed.rows.filter((row) => matchesKeyword(row, keyword) && matchesSupplier(row, supplier)); const sorted = sortRows(filtered, options.sortBy, options.sortOrder); // 汇总基于「关键字/供应商过滤后、预警等级过滤前」的全集,这样切预警等级时汇总数字不会跟着缩水 const summary = buildSummary(sorted); const levelFiltered = sorted.filter((row) => matchesWarningLevel(row.warningLevel, warningLevel)); const priceFiltered = levelFiltered.filter((row) => matchesPriceLevel(row.unitPriceLevel, priceLevel)); const start = (page - 1) * pageSize; return { rows: priceFiltered.slice(start, start + pageSize).map((row) => ({ ...row, warningLabel: WARNING_LABELS[row.warningLevel] || WARNING_LABELS.normal, unitPriceLevelLabel: PRICE_WARNING_LABELS[row.unitPriceLevel] || '', createdAtRaw: undefined, })), total: priceFiltered.length, page, pageSize, // 当前仓库信息(页面标题 / 口径文案都按它渲染) warehouseKey: warehouse.key, warehouseName: warehouse.warehouseName, warehouseShortName: warehouse.shortName, warehouseTitle: warehouse.title, warehouseWmsCoId: warehouse.wmsCoId, warehouseHasReturnStock: warehouse.hasReturnStock, returnSlot: warehouse.returnSlot, warehouse: getWarehouseStockLabel(warehouse), statDate: today, stockSyncedAt: stockSyncedAtState.get(warehouse.key)?.value || '', qtyBasis, qtyBasisOptions: buildQtyBasis(warehouse), createdAtBasis, createdAtBasisOptions: CREATED_AT_BASIS, unitPriceBasisLabel: UNIT_PRICE_BASIS_LABEL, scope, supplier, focusDays, alertDays, priceFocusMin, priceHighMin, returnLimitDays, warningLevel, warningLevelOptions: WARNING_LEVEL_OPTIONS, priceLevel, priceLevelOptions: PRICE_LEVEL_OPTIONS, summary, import: latestImport, hasImport: composed.hasImport, gaps: buildKnownGaps(warehouse), }; } /** * 导出列(按仓库生成)——最后那列「客退临时上架库存」的列名要跟着仓库走, * 因为各仓用的自定义库存位不同(山东是 customize_qty_2),写死会误导看表的人。 */ const buildExportColumns = (warehouse = DEFAULT_WAREHOUSE) => [ { key: 'warningLabel', title: '预警' }, { key: 'supplier', title: '供应商' }, { key: 'productCode', title: '商品编码' }, { key: 'createdAt', title: '创建时间' }, { key: 'stockDays', title: '在库天数' }, { key: 'inboundQty', title: '总库存' }, { key: 'unitPrice', title: '单价(成本价)' }, { key: 'amount', title: '总金额' }, { key: 'unitPriceLevelLabel', title: '单价预警' }, { key: 'returnDeadlineLabel', title: '退厂时限' }, { key: 'isOutboundLabel', title: '是否出库' }, { key: 'returnQty', title: '退货仓库存(return_qty)' }, { key: 'returnSlotQty', title: `客退临时上架库存(${warehouse.returnSlot})` }, { key: 'sourceLabel', title: '数据来源' }, ]; // 默认仓的导出列(对外导出保持兼容) const EXPORT_COLUMNS = buildExportColumns(DEFAULT_WAREHOUSE); /** * 导出数据集(不分页,按当前关键字/口径全量) */ async function getReturnStockWarningExportRows(options = {}) { const warehouse = resolveWarehouse(options.warehouse); const keyword = normalizeKeyword(options.keyword); const supplier = normalizeSupplier(options.supplier); const qtyBasis = resolveQtyBasis(options.qtyBasis); const createdAtBasis = resolveCreatedAtBasis(options.createdAtBasis); const scope = ['imported', 'replica'].includes(String(options.scope)) ? String(options.scope) : 'all'; const { focusDays, alertDays } = resolveThresholds(options); const { priceFocusMin, priceHighMin } = resolvePriceThresholds(options); const returnLimitDays = resolveReturnLimitDays(options.returnLimitDays); const warningLevel = resolveWarningLevelFilter(options.warningLevel); const priceLevel = resolvePriceLevelFilter(options.priceLevel); const today = formatLocalDate(); const [replicaRows, overrides] = await Promise.all([ loadReplicaRows({ refresh: String(options.refresh) === 'true', createdAtBasis, warehouse }), loadImportOverrides(warehouse), ]); const priceMap = await loadPriceMapForOverrides(replicaRows, overrides); const composed = composeRows({ replicaRows: replicaRows || [], overrides, qtyBasis, createdAtBasis, today, scope, focusDays, alertDays, priceMap, priceFocusMin, priceHighMin, returnLimitDays, warehouse, }); const filtered = composed.rows.filter((row) => matchesKeyword(row, keyword) && matchesSupplier(row, supplier)); const levelFiltered = filtered.filter((row) => matchesWarningLevel(row.warningLevel, warningLevel)); const priceFiltered = levelFiltered.filter((row) => matchesPriceLevel(row.unitPriceLevel, priceLevel)); // 导出按「预警等级 → 在库天数」排序,红色预警排最前,便于直接拿去处理 const sorted = sortRows(priceFiltered, 'inboundQty', 'desc'); return sorted.map((row) => ({ ...row, warningLabel: WARNING_LABELS[row.warningLevel] || WARNING_LABELS.normal, unitPriceLevelLabel: PRICE_WARNING_LABELS[row.unitPriceLevel] || '', returnDeadlineLabel: formatReturnDeadlineLabel(row.returnDeadlineStatus, row.returnRemainDays), isOutboundLabel: row.isOutbound ? '是' : '否', sourceLabel: row.createdAtSourceLabel || '', createdAtRaw: undefined, })); } module.exports = { WAREHOUSE_LABEL, KNOWN_GAPS, QTY_BASIS, WAREHOUSE_LIST, resolveWarehouse, resolveWarehouseKey, getWarehouseStockLabel, buildQtyBasis, buildExportColumns, buildKnownGaps, CREATED_AT_BASIS, WARNING_LEVEL_OPTIONS, PRICE_LEVEL_OPTIONS, PRICE_WARNING_LABELS, DEFAULT_FOCUS_DAYS, DEFAULT_ALERT_DAYS, DEFAULT_CREATED_AT_BASIS, DEFAULT_PRICE_FOCUS_MIN, DEFAULT_PRICE_HIGH_MIN, DEFAULT_RETURN_LIMIT_DAYS, RETURN_DEADLINE_LABELS, UNIT_PRICE_BASIS_LABEL, EXPORT_COLUMNS, warmReturnStockWarningCache, loadStockSyncedAt, getReturnStockWarningList, getReturnStockWarningExportRows, };