功能: - 退货库存预警:横林二仓/苏北/睢宁/山东青岗集/河南洛宁 五张看板, 按各仓客退上架仓对应的自定义库存位取数(各仓位不同:横林/苏北/睢宁 c1,山东 c2) - 支持导入聚水潭「箱及仓位库存」导出文件覆盖创建时间、件数、供应商 (副本只有仓库级数量,没有箱级维度,真实上架时间只能靠导入) - 退货入库核销与每日库存监控:状态落本地系统库,含定时任务与差异清单 - 服务端导出(xlsx/csv),不受列表分页与展示上限约束 稳定性: - 前端幂等请求在后端重启窗口内自动退避重连,不再直接抛 500 - 后端补齐统一 JSON 错误兜底与 /api JSON 404,异常不再返回 HTML - 隧道 ssh 改为绝对路径解析并隐藏窗口,避免非交互式启动 ENOENT 与黑窗弹窗 - 业务库 DATETIME 按北京时间解析(useUTC: false),修复时间显示整体 +8 小时 合并:与上游「隧道断线自愈 + 配置负缓存」为同一问题的两份实现, 本分支采用上游方案(带 TTL 的负缓存 + 统一失效函数 + 单测)。
1246 lines
60 KiB
JavaScript
1246 lines
60 KiB
JavaScript
'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<productCode, {costPrice, purchasePrice}>。
|
||
*/
|
||
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,
|
||
};
|