功能: - 退货库存预警:横林二仓/苏北/睢宁/山东青岗集/河南洛宁 五张看板, 按各仓客退上架仓对应的自定义库存位取数(各仓位不同:横林/苏北/睢宁 c1,山东 c2) - 支持导入聚水潭「箱及仓位库存」导出文件覆盖创建时间、件数、供应商 (副本只有仓库级数量,没有箱级维度,真实上架时间只能靠导入) - 退货入库核销与每日库存监控:状态落本地系统库,含定时任务与差异清单 - 服务端导出(xlsx/csv),不受列表分页与展示上限约束 稳定性: - 前端幂等请求在后端重启窗口内自动退避重连,不再直接抛 500 - 后端补齐统一 JSON 错误兜底与 /api JSON 404,异常不再返回 HTML - 隧道 ssh 改为绝对路径解析并隐藏窗口,避免非交互式启动 ENOENT 与黑窗弹窗 - 业务库 DATETIME 按北京时间解析(useUTC: false),修复时间显示整体 +8 小时 合并:与上游「隧道断线自愈 + 配置负缓存」为同一问题的两份实现, 本分支采用上游方案(带 TTL 的负缓存 + 统一失效函数 + 单测)。
101 lines
3.7 KiB
JavaScript
101 lines
3.7 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* 通用表格导出
|
||
* --------------------------------------------------------------------------
|
||
* 把 [{ name, header: string[], rows: any[][] }] 渲染成:
|
||
* - xlsx:多工作表工作簿(基于 backend 已依赖的 xlsx 包)
|
||
* - csv :单工作表、带 UTF-8 BOM(Excel 直接打开中文不乱码)
|
||
* 语义与 frontend/src/utils/tableExport.js 的「表头 + 行」保持一致。
|
||
*/
|
||
|
||
const xlsx = require('xlsx');
|
||
|
||
const XLSX_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||
const CSV_CONTENT_TYPE = 'text/csv; charset=utf-8';
|
||
|
||
// Excel 工作表名限制:<=31 字符,且不能包含 : \ / ? * [ ]
|
||
const sanitizeSheetName = (name, fallback) => {
|
||
const cleaned = String(name || '').replace(/[:\\/?*[\]]/g, ' ').replace(/\s+/g, ' ').trim();
|
||
return (cleaned || fallback).slice(0, 31);
|
||
};
|
||
|
||
const toCellValue = (value) => (
|
||
value === null || value === undefined || value === '' ? '' : value
|
||
);
|
||
|
||
const sheetToAoa = (sheet) => ([
|
||
(sheet?.header || []).map(toCellValue),
|
||
...((sheet?.rows || []).map((row) => (row || []).map(toCellValue))),
|
||
]);
|
||
|
||
/** 多工作表 xlsx buffer;工作表名重复/非法时自动去重与截断 */
|
||
const buildXlsxBuffer = (sheets = []) => {
|
||
const workbook = xlsx.utils.book_new();
|
||
const usedNames = new Set();
|
||
|
||
sheets.forEach((sheet, index) => {
|
||
const base = sanitizeSheetName(sheet?.name, `工作表${index + 1}`);
|
||
let name = base;
|
||
let suffix = 2;
|
||
while (usedNames.has(name)) {
|
||
name = `${base.slice(0, 27)}(${suffix})`;
|
||
suffix += 1;
|
||
}
|
||
usedNames.add(name);
|
||
|
||
const worksheet = xlsx.utils.aoa_to_sheet(sheetToAoa(sheet));
|
||
worksheet['!cols'] = (sheet?.header || []).map((title) => ({
|
||
wch: Math.max(10, Math.min(32, String(title || '').length * 2 + 6)),
|
||
}));
|
||
xlsx.utils.book_append_sheet(workbook, worksheet, name);
|
||
});
|
||
|
||
return xlsx.write(workbook, { type: 'buffer', bookType: 'xlsx' });
|
||
};
|
||
|
||
const quoteCsvCell = (value) => {
|
||
const text = value === null || value === undefined ? '' : String(value);
|
||
return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
||
};
|
||
|
||
/** 单工作表 csv buffer(只取第一个工作表),前置 BOM */
|
||
const buildCsvBuffer = (sheets = []) => {
|
||
const sheet = sheets[0] || { header: [], rows: [] };
|
||
const lines = [(sheet.header || []).map(quoteCsvCell).join(',')];
|
||
(sheet.rows || []).forEach((row) => {
|
||
lines.push((row || []).map(quoteCsvCell).join(','));
|
||
});
|
||
return Buffer.from(`\ufeff${lines.join('\r\n')}`, 'utf8');
|
||
};
|
||
|
||
const sanitizeFileName = (fileName) => String(fileName || '导出数据').replace(/[\\/:*?"<>|]/g, '_').trim() || '导出数据';
|
||
|
||
/**
|
||
* 直接把导出结果写回响应。
|
||
* @param {import('express').Response} res
|
||
* @param {{ fileName?: string, format?: 'xlsx'|'csv', sheets?: Array }} payload
|
||
*/
|
||
const sendWorkbookDownload = (res, { fileName, format = 'xlsx', sheets = [] } = {}) => {
|
||
const isCsv = String(format).toLowerCase() === 'csv';
|
||
const exportSheets = isCsv ? sheets.slice(0, 1) : sheets;
|
||
const buffer = isCsv ? buildCsvBuffer(exportSheets) : buildXlsxBuffer(exportSheets);
|
||
const totalRows = exportSheets.reduce((sum, sheet) => sum + ((sheet?.rows || []).length), 0);
|
||
const fullName = `${sanitizeFileName(fileName)}${isCsv ? '.csv' : '.xlsx'}`;
|
||
|
||
res.setHeader('Content-Type', isCsv ? CSV_CONTENT_TYPE : XLSX_CONTENT_TYPE);
|
||
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(fullName)}`);
|
||
res.setHeader('X-Export-Count', String(totalRows));
|
||
res.send(buffer);
|
||
return { totalRows, fullName };
|
||
};
|
||
|
||
module.exports = {
|
||
XLSX_CONTENT_TYPE,
|
||
CSV_CONTENT_TYPE,
|
||
sanitizeSheetName,
|
||
buildXlsxBuffer,
|
||
buildCsvBuffer,
|
||
sendWorkbookDownload,
|
||
};
|