仓位用于定位滞销库存的实际存放位置,便于盘点与挪位处理。 数据源为 rpt.jst_packInventoryInfo.bin(jst_sku.bin 全部为空、库存表无仓位字段, 这是业务库唯一可得的系统仓位): - 按 (wms_co_id, sku_id) 聚合,LTRIM 去空格后 DISTINCT,STRING_AGG 按名称排序用「、」连接 - 排除无仓位(bin 为空)与零库存(qty=0)的行,避免无效仓位干扰 - LEFT JOIN 且按「仓库+SKU」双条件匹配,避免跨仓错配 - 导出列顺序:仓库 | SKU编码 | SKU名称 | 系统仓位 | 库存数量 | 销量 | 统计天数 | 统计基准日 - 前端按钮提示同步说明多仓位拼接规则与留空含义 验证:后端 syntax 通过、单测 9/9(全量 389/390,唯一失败为既有 Windows 权限环境项); 前端 eslint/build/测试均通过; 本地实测导出 3650 行,系统仓位列就位,3381 行有仓位(92.6%,未覆盖的 7.4% 为 仓位表仅含 13 个仓库所致),销量列全为 0,最长仓位串 600 字符(Excel 上限内); 浏览器实测点击下载,文件名与列结构正确
282 lines
10 KiB
JavaScript
282 lines
10 KiB
JavaScript
const express = require('express');
|
||
const cloudDb = require('../config/cloudDatabase');
|
||
const { DAILY_INVENTORY_BASELINE } = require('../config/dailyInventoryBaseline');
|
||
const {
|
||
SALES_GRADE_RULES,
|
||
SALES_TOTAL_LABEL,
|
||
STOCK_SALES_WINDOW_OPTIONS,
|
||
STOCK_SALES_DEFAULT_DAYS,
|
||
} = require('../config/stockSalesAnalysis');
|
||
const { fetchGradeRows, fetchZeroSaleSkuRows } = require('../services/stockSalesAnalysis');
|
||
const { sendWorkbookDownload } = require('../services/exportWorkbook');
|
||
|
||
const router = express.Router();
|
||
|
||
/** 库存衡量基准时刻:快照日次日 01:00(报表库每日凌晨跑批,标注 T-1) */
|
||
const AS_OF_TIME = '01:00';
|
||
|
||
/** 结果缓存:一次要扫订单明细 + 库存明细两张大表,避免连续刷新重复打库 */
|
||
const RESULT_CACHE_TTL_MS = 5 * 60 * 1000;
|
||
const cacheByDays = new Map();
|
||
|
||
const round2 = (v) => Math.round((Number(v) || 0) * 100) / 100;
|
||
|
||
/** 快照日 + 次日 01:00 作为统一的统计基准时刻 */
|
||
function nextDayAsOf(bizDate) {
|
||
if (!bizDate || !/^\d{4}-\d{2}-\d{2}$/.test(bizDate)) return null;
|
||
const d = new Date(`${bizDate}T00:00:00Z`);
|
||
d.setUTCDate(d.getUTCDate() + 1);
|
||
return `${d.toISOString().slice(0, 10)} ${AS_OF_TIME}`;
|
||
}
|
||
|
||
/** 发货窗口:[asOf - N 天, asOf) */
|
||
function buildShipWindow(asOf, days) {
|
||
const to = new Date(`${asOf.slice(0, 10)}T${AS_OF_TIME}:00Z`);
|
||
const from = new Date(to.getTime() - days * 86400000);
|
||
const fmt = (dt) => dt.toISOString().slice(0, 19).replace('T', ' ');
|
||
return { shipFrom: fmt(from), shipTo: fmt(to) };
|
||
}
|
||
|
||
async function fetchLatestBizDate() {
|
||
const rows = await cloudDb.query(
|
||
`SELECT MAX(CONVERT(VARCHAR(10), date, 23)) AS d FROM rpt.pm_stockMonitor`
|
||
);
|
||
return rows[0]?.d || null;
|
||
}
|
||
|
||
async function fetchInventoryTakenAt() {
|
||
const rows = await cloudDb.query(
|
||
`SELECT MAX(modified) AS takenAt FROM rpt.jst_inventoryInfo`
|
||
);
|
||
return rows[0]?.takenAt || null;
|
||
}
|
||
|
||
/** 解析并收敛统计窗口天数,只允许白名单内的取值 */
|
||
function resolveDays(raw) {
|
||
const n = Number.parseInt(String(raw ?? ''), 10);
|
||
return STOCK_SALES_WINDOW_OPTIONS.includes(n) ? n : STOCK_SALES_DEFAULT_DAYS;
|
||
}
|
||
|
||
/**
|
||
* GET /api/stock-sales-analysis
|
||
* 仓库库存动销分析:仓库 × 动销等级 → SKU数量 / SKU占比 / 库存数量 / 库存占比 / 销量
|
||
*/
|
||
router.get('/', async (req, res) => {
|
||
try {
|
||
const force = String(req.query?.refresh || '') === '1';
|
||
const days = resolveDays(req.query?.days);
|
||
const latestBizDate = await fetchLatestBizDate().catch(() => null);
|
||
|
||
const cached = cacheByDays.get(days);
|
||
if (!force && cached && cached.latestBizDate === latestBizDate
|
||
&& Date.now() - cached.at < RESULT_CACHE_TTL_MS) {
|
||
return res.json({ success: true, data: cached.payload, cached: true });
|
||
}
|
||
|
||
const asOf = nextDayAsOf(latestBizDate);
|
||
if (!asOf) {
|
||
return res.status(503).json({
|
||
success: false,
|
||
error: '业务库暂无库存监控数据,无法定位统计基准日',
|
||
});
|
||
}
|
||
|
||
const { shipFrom, shipTo } = buildShipWindow(asOf, days);
|
||
const warehouses = DAILY_INVENTORY_BASELINE.filter((b) => b.wmsCoId != null);
|
||
const wmsCoIds = warehouses.map((b) => b.wmsCoId);
|
||
|
||
const [rows, takenAt] = await Promise.all([
|
||
fetchGradeRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days }),
|
||
fetchInventoryTakenAt().catch(() => null),
|
||
]);
|
||
|
||
// (仓库, 等级) → 指标
|
||
const matrix = new Map();
|
||
rows.forEach((r) => {
|
||
matrix.set(`${r.wmsCoId}::${r.gradeKey}`, r);
|
||
});
|
||
|
||
const buildBucket = (grade, cell) => ({
|
||
key: grade.key,
|
||
label: grade.label,
|
||
tip: grade.tip,
|
||
skuCount: cell?.skuCount ?? 0,
|
||
stockQty: round2(cell?.stockQty ?? 0),
|
||
salesQty: round2(cell?.salesQty ?? 0),
|
||
dailySales: Number((cell?.dailySales ?? 0).toFixed(2)),
|
||
});
|
||
|
||
const items = warehouses.map((base) => {
|
||
const id = String(base.wmsCoId);
|
||
const buckets = SALES_GRADE_RULES.map((grade) => (
|
||
buildBucket(grade, matrix.get(`${id}::${grade.key}`))
|
||
));
|
||
|
||
const totalSku = buckets.reduce((acc, b) => acc + b.skuCount, 0);
|
||
const totalStock = buckets.reduce((acc, b) => acc + b.stockQty, 0);
|
||
const totalSales = round2(buckets.reduce((acc, b) => acc + b.salesQty, 0));
|
||
|
||
return {
|
||
warehouse: base.warehouse,
|
||
dbWarehouse: base.dbWarehouse,
|
||
wmsCoId: base.wmsCoId,
|
||
hasData: totalSku > 0 || totalStock !== 0 || totalSales !== 0,
|
||
buckets: buckets.map((b) => ({
|
||
...b,
|
||
skuRatio: totalSku > 0 ? b.skuCount / totalSku : null,
|
||
stockRatio: totalStock !== 0 ? b.stockQty / totalStock : null,
|
||
})),
|
||
total: {
|
||
key: '__total__',
|
||
label: SALES_TOTAL_LABEL,
|
||
skuCount: totalSku,
|
||
skuRatio: totalSku > 0 ? 1 : null,
|
||
stockQty: totalStock,
|
||
stockRatio: totalStock !== 0 ? 1 : null,
|
||
salesQty: totalSales,
|
||
dailySales: Number((totalSales / days).toFixed(2)),
|
||
},
|
||
};
|
||
});
|
||
|
||
// 全仓合计:各等级跨仓累加 + 汇总
|
||
const grandBuckets = SALES_GRADE_RULES.map((grade) => {
|
||
const skuCount = items.reduce((acc, it) => acc + (it.buckets.find((b) => b.key === grade.key)?.skuCount || 0), 0);
|
||
const stockQty = round2(items.reduce((acc, it) => acc + (it.buckets.find((b) => b.key === grade.key)?.stockQty || 0), 0));
|
||
const salesQty = round2(items.reduce((acc, it) => acc + (it.buckets.find((b) => b.key === grade.key)?.salesQty || 0), 0));
|
||
return {
|
||
key: grade.key,
|
||
label: grade.label,
|
||
tip: grade.tip,
|
||
skuCount,
|
||
stockQty,
|
||
salesQty,
|
||
dailySales: Number((salesQty / days).toFixed(2)),
|
||
};
|
||
});
|
||
const grandSku = grandBuckets.reduce((acc, b) => acc + b.skuCount, 0);
|
||
const grandStock = round2(grandBuckets.reduce((acc, b) => acc + b.stockQty, 0));
|
||
const grandSales = round2(grandBuckets.reduce((acc, b) => acc + b.salesQty, 0));
|
||
const grandTotal = {
|
||
key: '__total__',
|
||
label: SALES_TOTAL_LABEL,
|
||
skuCount: grandSku,
|
||
skuRatio: grandSku > 0 ? 1 : null,
|
||
stockQty: grandStock,
|
||
stockRatio: grandStock !== 0 ? 1 : null,
|
||
salesQty: grandSales,
|
||
dailySales: Number((grandSales / days).toFixed(2)),
|
||
};
|
||
|
||
const payload = {
|
||
days,
|
||
windowOptions: STOCK_SALES_WINDOW_OPTIONS,
|
||
latestBizDate,
|
||
asOf,
|
||
shipWindow: { shipFrom, shipTo, days },
|
||
takenAt,
|
||
grades: SALES_GRADE_RULES.map((g) => ({ key: g.key, label: g.label, tip: g.tip })),
|
||
totalLabel: SALES_TOTAL_LABEL,
|
||
items,
|
||
grand: {
|
||
warehouse: '全部仓库合计',
|
||
hasData: grandSku > 0,
|
||
buckets: grandBuckets.map((b) => ({
|
||
...b,
|
||
skuRatio: grandSku > 0 ? b.skuCount / grandSku : null,
|
||
stockRatio: grandStock !== 0 ? b.stockQty / grandStock : null,
|
||
})),
|
||
total: grandTotal,
|
||
},
|
||
emptyWarehouseCount: items.filter((it) => !it.hasData).length,
|
||
};
|
||
|
||
cacheByDays.set(days, { latestBizDate, at: Date.now(), payload });
|
||
return res.json({ success: true, data: payload });
|
||
} catch (error) {
|
||
console.error('[stock-sales-analysis] 查询失败:', error.message);
|
||
return res.status(500).json({
|
||
success: false,
|
||
error: '仓库库存动销分析数据查询失败',
|
||
detail: error.message,
|
||
});
|
||
}
|
||
});
|
||
|
||
/**
|
||
* GET /api/stock-sales-analysis/zero-sales-export
|
||
* 导出「销售=0」档的 SKU 级明细。
|
||
* query: days(统计窗口)、wmsCoId(可选,逗号分隔,不传=全部仓库)、format(xlsx|csv,默认 xlsx)
|
||
*/
|
||
router.get('/zero-sales-export', async (req, res) => {
|
||
try {
|
||
const days = resolveDays(req.query?.days);
|
||
const format = String(req.query?.format || 'xlsx').toLowerCase() === 'csv' ? 'csv' : 'xlsx';
|
||
const latestBizDate = await fetchLatestBizDate().catch(() => null);
|
||
|
||
const asOf = nextDayAsOf(latestBizDate);
|
||
if (!asOf) {
|
||
return res.status(503).json({
|
||
success: false,
|
||
error: '业务库暂无库存监控数据,无法定位统计基准日',
|
||
});
|
||
}
|
||
|
||
const { shipFrom, shipTo } = buildShipWindow(asOf, days);
|
||
|
||
// 仓库过滤:只允许出现在仓库清单里的 wmsCoId,防止任意拼参
|
||
const known = new Map(
|
||
DAILY_INVENTORY_BASELINE.filter((b) => b.wmsCoId != null)
|
||
.map((b) => [String(b.wmsCoId), b])
|
||
);
|
||
const requested = String(req.query?.wmsCoId || '')
|
||
.split(',')
|
||
.map((v) => v.trim())
|
||
.filter((v) => known.has(v));
|
||
const wmsCoIds = requested.length > 0 ? requested : [...known.keys()];
|
||
|
||
const rows = await fetchZeroSaleSkuRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days });
|
||
|
||
const sheetRows = rows.map((r) => {
|
||
const base = known.get(r.wmsCoId);
|
||
return [
|
||
base?.warehouse || r.wmsCoId,
|
||
r.skuId,
|
||
r.skuName,
|
||
r.binName,
|
||
r.stockQty,
|
||
r.salesQty,
|
||
days,
|
||
latestBizDate || '',
|
||
];
|
||
});
|
||
|
||
const scopeLabel = requested.length === 1 ? `-${known.get(requested[0]).warehouse}` : '';
|
||
const fileName = `库存动销分析-销售0明细${scopeLabel}-${(latestBizDate || '').replace(/-/g, '') || '未知日期'}`;
|
||
const { totalRows } = sendWorkbookDownload(res, {
|
||
fileName,
|
||
format,
|
||
sheets: [{
|
||
name: '销售=0明细',
|
||
header: ['仓库', 'SKU编码', 'SKU名称', '系统仓位', '库存数量', `销量(近${days}天)`, '统计天数', '统计基准日'],
|
||
rows: sheetRows,
|
||
}],
|
||
});
|
||
console.log(`[stock-sales-analysis] 销售=0明细导出: ${totalRows} 行, 仓库 ${wmsCoIds.length} 个, 窗口 ${days} 天`);
|
||
return null;
|
||
} catch (error) {
|
||
console.error('[stock-sales-analysis] 销售=0明细导出失败:', error.message);
|
||
// 响应可能已部分写出(sendWorkbookDownload 之后一般不会抛),仅在未发送时回 JSON
|
||
if (!res.headersSent) {
|
||
return res.status(500).json({
|
||
success: false,
|
||
error: '销售=0明细导出失败',
|
||
detail: error.message,
|
||
});
|
||
}
|
||
return null;
|
||
}
|
||
});
|
||
|
||
module.exports = router;
|