功能: - 退货库存预警:横林二仓/苏北/睢宁/山东青岗集/河南洛宁 五张看板, 按各仓客退上架仓对应的自定义库存位取数(各仓位不同:横林/苏北/睢宁 c1,山东 c2) - 支持导入聚水潭「箱及仓位库存」导出文件覆盖创建时间、件数、供应商 (副本只有仓库级数量,没有箱级维度,真实上架时间只能靠导入) - 退货入库核销与每日库存监控:状态落本地系统库,含定时任务与差异清单 - 服务端导出(xlsx/csv),不受列表分页与展示上限约束 稳定性: - 前端幂等请求在后端重启窗口内自动退避重连,不再直接抛 500 - 后端补齐统一 JSON 错误兜底与 /api JSON 404,异常不再返回 HTML - 隧道 ssh 改为绝对路径解析并隐藏窗口,避免非交互式启动 ENOENT 与黑窗弹窗 - 业务库 DATETIME 按北京时间解析(useUTC: false),修复时间显示整体 +8 小时 合并:与上游「隧道断线自愈 + 配置负缓存」为同一问题的两份实现, 本分支采用上游方案(带 TTL 的负缓存 + 统一失效函数 + 单测)。
131 lines
6.2 KiB
JavaScript
131 lines
6.2 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* 退货库存预警(客退数据监控)本地表
|
||
* --------------------------------------------------------------------------
|
||
* 为什么需要本地表:
|
||
* 报表副本(JSReportReplica)只有仓库级库存表 jst_inventoryInfo,
|
||
* 没有聚水潭「箱及仓位库存」的箱/仓位维度,因此:
|
||
* - 「创建时间」= 聚水潭该箱/仓位记录的上架时间,副本里根本没有这个字段;
|
||
* - 「件数」也可能与聚水潭口径不同(副本只能按 return_qty + 客退自定义库存位近似)。
|
||
* 要跟聚水潭页面 100% 对齐,唯一办法是把聚水潭导出的「箱及仓位库存」导入进来,
|
||
* 按商品编码覆盖创建时间(可选覆盖件数),并持久化。
|
||
*
|
||
* 按仓库隔离(2026-09-16 新增):
|
||
* 现在有横林二仓 / 苏北 / 睢宁 / 山东 / 河南 五张看板,每张上传的是各自仓库的聚水潭导出文件,
|
||
* 同一份文件里同一个商品编码可能同时出现在多个仓;如果不按仓隔离,后导入的会把先导入的覆盖掉。
|
||
* 因此两张表都加了 warehouse_key,主键从 (product_code) 改成 (warehouse_key, product_code)。
|
||
*/
|
||
|
||
const database = require('../config/database');
|
||
|
||
const DEFAULT_WAREHOUSE_KEY = 'henlin2';
|
||
|
||
const SCHEMA_SQL = [
|
||
// 1. 按「仓库 + 商品编码」的导入覆盖值(以聚水潭导出为准)
|
||
`CREATE TABLE IF NOT EXISTS return_stock_warning_import (
|
||
warehouse_key VARCHAR(32) NOT NULL DEFAULT '${DEFAULT_WAREHOUSE_KEY}' COMMENT '仓库标识(henlin2/subei/suining/shandong/henan)',
|
||
product_code VARCHAR(120) NOT NULL COMMENT '商品编码',
|
||
created_at DATETIME NULL COMMENT '聚水潭创建时间(上架时间)',
|
||
inbound_qty INT NULL COMMENT '聚水潭件数(导入,可为空=不覆盖)',
|
||
supplier VARCHAR(255) NULL COMMENT '聚水潭供应商(导入,可为空)',
|
||
batch_id VARCHAR(64) NOT NULL COMMENT '导入批次',
|
||
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '导入时间',
|
||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||
PRIMARY KEY (warehouse_key, product_code),
|
||
KEY idx_rswi_batch (batch_id)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='退货库存预警-聚水潭导入覆盖值(按仓库+商品编码)'`,
|
||
|
||
// 2. 导入批次日志
|
||
`CREATE TABLE IF NOT EXISTS return_stock_warning_import_batch (
|
||
batch_id VARCHAR(64) NOT NULL COMMENT '导入批次',
|
||
warehouse_key VARCHAR(32) NOT NULL DEFAULT '${DEFAULT_WAREHOUSE_KEY}' COMMENT '仓库标识',
|
||
file_name VARCHAR(255) NULL COMMENT '文件名',
|
||
total_rows INT NOT NULL DEFAULT 0 COMMENT '解析出的行数',
|
||
matched_rows INT NOT NULL DEFAULT 0 COMMENT '成功写入的编码数',
|
||
with_created_at INT NOT NULL DEFAULT 0 COMMENT '含创建时间的行数',
|
||
with_qty INT NOT NULL DEFAULT 0 COMMENT '含件数的行数',
|
||
skipped_rows INT NOT NULL DEFAULT 0 COMMENT '无商品编码被跳过的行数',
|
||
imported_by VARCHAR(100) NULL COMMENT '导入人',
|
||
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '导入时间',
|
||
PRIMARY KEY (batch_id),
|
||
KEY idx_rswibatch_time (imported_at),
|
||
KEY idx_rswibatch_wh (warehouse_key, imported_at)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='退货库存预警-聚水潭导入批次日志'`,
|
||
];
|
||
|
||
/** 某张表上是否存在某列 */
|
||
async function hasColumn(table, column) {
|
||
const rows = await database.local.query(
|
||
`SELECT COUNT(*) AS cnt
|
||
FROM information_schema.COLUMNS
|
||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?`,
|
||
[table, column]
|
||
);
|
||
return Number(rows?.[0]?.cnt || 0) > 0;
|
||
}
|
||
|
||
/** 某张表的主键列(按顺序) */
|
||
async function getPrimaryKeyColumns(table) {
|
||
const rows = await database.local.query(
|
||
`SELECT COLUMN_NAME
|
||
FROM information_schema.KEY_COLUMN_USAGE
|
||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND CONSTRAINT_NAME = 'PRIMARY'
|
||
ORDER BY ORDINAL_POSITION`,
|
||
[table]
|
||
);
|
||
return (rows || []).map((row) => String(row.COLUMN_NAME || '').toUpperCase());
|
||
}
|
||
|
||
/**
|
||
* 老库升级:给已存在的两张表补 warehouse_key,并把导入表主键改成 (warehouse_key, product_code)。
|
||
* 幂等,每次启动都跑一遍,已经是新结构时不做任何事。
|
||
*/
|
||
async function migrateReturnStockWarningSchema() {
|
||
// 逐列补:两张表都要有 warehouse_key
|
||
const targets = ['return_stock_warning_import', 'return_stock_warning_import_batch'];
|
||
for (const table of targets) {
|
||
// eslint-disable-next-line no-await-in-loop
|
||
if (!(await hasColumn(table, 'warehouse_key'))) {
|
||
// 老数据全部归到默认仓(横林二仓)——它本来就是为横林二仓建的
|
||
// eslint-disable-next-line no-await-in-loop
|
||
await database.local.query(
|
||
`ALTER TABLE ${table}
|
||
ADD COLUMN warehouse_key VARCHAR(32) NOT NULL DEFAULT '${DEFAULT_WAREHOUSE_KEY}'
|
||
COMMENT '仓库标识' AFTER ${table === 'return_stock_warning_import' ? 'product_code' : 'batch_id'}`
|
||
);
|
||
}
|
||
}
|
||
|
||
// 主键改造:老结构是 (product_code),需要换成 (warehouse_key, product_code)
|
||
const pk = await getPrimaryKeyColumns('return_stock_warning_import');
|
||
if (pk.length === 1 && pk[0] === 'PRODUCT_CODE') {
|
||
await database.local.query(
|
||
'ALTER TABLE return_stock_warning_import DROP PRIMARY KEY, ADD PRIMARY KEY (warehouse_key, product_code)'
|
||
);
|
||
}
|
||
|
||
// 批次表的仓库索引
|
||
const idxRows = await database.local.query(
|
||
`SELECT COUNT(*) AS cnt
|
||
FROM information_schema.STATISTICS
|
||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'return_stock_warning_import_batch'
|
||
AND INDEX_NAME = 'idx_rswibatch_wh'`
|
||
);
|
||
if (Number(idxRows?.[0]?.cnt || 0) === 0) {
|
||
await database.local.query(
|
||
'ALTER TABLE return_stock_warning_import_batch ADD KEY idx_rswibatch_wh (warehouse_key, imported_at)'
|
||
);
|
||
}
|
||
}
|
||
|
||
async function ensureReturnStockWarningSchema() {
|
||
for (const sql of SCHEMA_SQL) {
|
||
// eslint-disable-next-line no-await-in-loop
|
||
await database.local.query(sql);
|
||
}
|
||
await migrateReturnStockWarningSchema();
|
||
}
|
||
|
||
module.exports = { ensureReturnStockWarningSchema, migrateReturnStockWarningSchema };
|