业务库只能还原 4/12 个流水列且口径与聚水潭不一致(采购按单据日期、销售按订单发货日期, jst_salesOutboundInfo.qty 全为 0),调拨/盘点/加工/其它出入库无单据表,故以官方导出为准: - 新增本地表 daily_inventory_official(biz_date + warehouse 存期初/12 流水列/期末) - 新增解析与导入服务:xlsx 解析、仓库 ID 映射、「期初+流水=期末」恒等式校验 - GET /api/daily-inventory 支持 ?date= 指定业务日,官方值优先、缺失回退推算并返回来源标记 - 新增 POST /api/daily-inventory/official-import(xlsx 上传/JSON)与 GET /official 查询 - 导入后回写本地库存快照(官方期初→上一日期末、官方期末→当日期末),使推算也对齐 - 前端:业务日切换、官方/推算来源标记、导入弹窗、口径说明同步更新 - 仓库清单补入「简尚-横林三仓主仓」(聚水潭导出中存在) - 命令行导入:npm run inventory:import-official -- <日期> <xlsx>
564 lines
22 KiB
JavaScript
564 lines
22 KiB
JavaScript
const express = require('express');
|
||
const multer = require('multer');
|
||
const cloudDb = require('../config/cloudDatabase');
|
||
const snapshotDb = require('../config/dailyInventorySnapshotDb');
|
||
const officialDb = require('../config/dailyInventoryOfficialDb');
|
||
const {
|
||
DAILY_INVENTORY_BASELINE,
|
||
FLOW_COLUMNS,
|
||
} = require('../config/dailyInventoryBaseline');
|
||
const { fetchFlows, AVAILABLE_FLOW_FIELDS } = require('../services/dailyInventoryFlows');
|
||
const { deriveAndStoreOpeningSnapshot } = require('../services/dailyInventoryOpening');
|
||
const {
|
||
parseWorkbook,
|
||
normalizeRecords,
|
||
checkBalance,
|
||
importOfficial,
|
||
} = require('../services/dailyInventoryOfficial');
|
||
|
||
const router = express.Router();
|
||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 20 * 1024 * 1024 } });
|
||
|
||
/** 库存衡量基准时刻:快照日次日 01:00(报表库每日凌晨 1 点跑批,标注 T-1) */
|
||
const AS_OF_TIME = '01:00';
|
||
|
||
/**
|
||
* 「月度」发货窗口天数:滚动 30 天。
|
||
* 库龄阈值:3 个月 = 90 天、12 个月 = 365 天(按日历天折算)。
|
||
*/
|
||
const SHIP_WINDOW_DAYS = 30;
|
||
const AGE_3M_DAYS = 90;
|
||
const AGE_12M_DAYS = 365;
|
||
|
||
/** 结果缓存:单次查询要扫订单明细 + 库存明细两张大表,避免连续刷新重复打库 */
|
||
const RESULT_CACHE_TTL_MS = 5 * 60 * 1000;
|
||
let cachedResult = null;
|
||
let cachedResultAt = 0;
|
||
|
||
const toNumber = (v) => {
|
||
if (v == null) return null;
|
||
const n = Number(v);
|
||
return Number.isFinite(n) ? n : null;
|
||
};
|
||
|
||
/** 快照日 + 次日 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 - 30 天, asOf),滚动一个月 */
|
||
function shipWindow(asOf) {
|
||
const to = new Date(`${asOf.slice(0, 10)}T${AS_OF_TIME}:00Z`);
|
||
const from = new Date(to.getTime() - SHIP_WINDOW_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;
|
||
}
|
||
|
||
/** 上一自然日(日历天,用于取「期初 = 昨日结存」) */
|
||
function prevDay(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);
|
||
}
|
||
|
||
/** mssql 不支持数组参数,按位展开成 @p0,@p1... */
|
||
function buildInClause(values, prefix) {
|
||
const params = {};
|
||
const clause = values
|
||
.map((v, i) => {
|
||
const key = `${prefix}${i}`;
|
||
params[key] = v;
|
||
return `@${key}`;
|
||
})
|
||
.join(',');
|
||
return { clause, params };
|
||
}
|
||
|
||
/**
|
||
* 分级规则(库龄 + 周转率双维度),口径与业务给定一致:
|
||
* 库龄 < 3 个月 且 周转率 30%~199% → 滞销预警
|
||
* 库龄 < 3 个月 且 周转率 ≥ 200% → 商品慢流
|
||
* 库龄 > 3 个月 且 周转率 30%~99% → 商品慢流
|
||
* 库龄 3~12 个月 且 周转率 ≥ 100% → 商品呆滞
|
||
* 库龄 > 12 个月 且 周转率 100%~199% → 商品呆滞
|
||
* 库龄 > 12 个月 且 周转率 ≥ 200% → 商品僵尸
|
||
* 边界:库龄正好 90 天不计入「慢流」(业务描述为 > 3 个月),正好 365 天仍属「3~12 个月」。
|
||
*/
|
||
function buildLevelExpr() {
|
||
const t = 'b.turnoverPct';
|
||
return `
|
||
CASE
|
||
WHEN b.stockQty <= 0 OR ${t} IS NULL OR b.ageDays IS NULL THEN NULL
|
||
WHEN b.ageDays < ${AGE_3M_DAYS} AND ${t} >= 30 AND ${t} < 200 THEN 'warn'
|
||
WHEN b.ageDays < ${AGE_3M_DAYS} AND ${t} >= 200 THEN 'slow'
|
||
WHEN b.ageDays > ${AGE_3M_DAYS} AND ${t} >= 30 AND ${t} < 100 THEN 'slow'
|
||
WHEN b.ageDays >= ${AGE_3M_DAYS} AND b.ageDays <= ${AGE_12M_DAYS} AND ${t} >= 100 THEN 'stale'
|
||
WHEN b.ageDays > ${AGE_12M_DAYS} AND ${t} >= 100 AND ${t} < 200 THEN 'stale'
|
||
WHEN b.ageDays > ${AGE_12M_DAYS} AND ${t} >= 200 THEN 'zombie'
|
||
ELSE NULL
|
||
END`;
|
||
}
|
||
|
||
/**
|
||
* 每个仓库的周转率与四级库存量(一次查询覆盖全部仓库)。
|
||
*
|
||
* 口径:
|
||
* 库存周转天数(周转率)= 月度发货总件数 / 月度日均库存件数 × 100%
|
||
* · 月度发货总件数 = 该仓近 30 天已发货订单件数(jst_OrderItem × jst_Orders,按 send_date,排除 Delete/Cancelled)
|
||
* · 月度日均库存件数 = 该仓聚水潭库存 jst_inventoryInfo(无历史结存表,以当日库存近似月度日均)
|
||
* 库龄(天) = DATEDIFF(基准时刻, SKU 建档时间 created)
|
||
*
|
||
* SKU 关联链路:jst_inventoryInfo.sku_id = jst_OrderItem.sku_id = pm_stockMonitor.sku_id(实测崔桥 1852/1852 命中)
|
||
*/
|
||
async function fetchGradeMetrics(wmsCoIds, bizDate, asOf) {
|
||
if (!wmsCoIds.length || !bizDate || !asOf) return new Map();
|
||
const invIn = buildInClause(wmsCoIds, 'w');
|
||
const shipIn = buildInClause(wmsCoIds, 'v');
|
||
const { shipFrom, shipTo } = shipWindow(asOf);
|
||
const level = buildLevelExpr();
|
||
|
||
const sqlText = `
|
||
WITH inv AS (
|
||
SELECT i.wms_co_id AS wmsCoId,
|
||
i.sku_id AS skuId,
|
||
SUM(CAST(ISNULL(i.qty, 0) AS DECIMAL(18, 2))) AS stockQty
|
||
FROM rpt.jst_inventoryInfo i
|
||
WHERE i.wms_co_id IN (${invIn.clause})
|
||
GROUP BY i.wms_co_id, i.sku_id
|
||
),
|
||
created AS (
|
||
SELECT m.sku_id AS skuId, MIN(m.created) AS created
|
||
FROM rpt.pm_stockMonitor m
|
||
WHERE CONVERT(VARCHAR(10), m.date, 23) = @bizDate
|
||
GROUP BY m.sku_id
|
||
),
|
||
ship AS (
|
||
SELECT o.wms_co_id AS wmsCoId,
|
||
oi.sku_id AS skuId,
|
||
SUM(CAST(ISNULL(oi.qty, 0) AS DECIMAL(18, 2))) AS shipQty
|
||
FROM rpt.jst_OrderItem oi
|
||
JOIN rpt.jst_Orders o ON o.o_id = oi.o_id
|
||
WHERE o.wms_co_id IN (${shipIn.clause})
|
||
AND o.send_date >= @shipFrom
|
||
AND o.send_date < @shipTo
|
||
AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled')
|
||
GROUP BY o.wms_co_id, oi.sku_id
|
||
),
|
||
base AS (
|
||
SELECT v.wmsCoId,
|
||
v.skuId,
|
||
v.stockQty,
|
||
ISNULL(s.shipQty, 0) AS shipQty,
|
||
DATEDIFF(DAY, c.created, @asOf) AS ageDays,
|
||
CASE WHEN v.stockQty <= 0 THEN NULL
|
||
ELSE ISNULL(s.shipQty, 0) * 100.0 / v.stockQty END AS turnoverPct
|
||
FROM inv v
|
||
LEFT JOIN ship s ON s.wmsCoId = v.wmsCoId AND s.skuId = v.skuId
|
||
LEFT JOIN created c ON c.skuId = v.skuId
|
||
)
|
||
SELECT b.wmsCoId,
|
||
SUM(CASE WHEN ABS(b.stockQty) > 0.0001 THEN 1 ELSE 0 END) AS skuCount,
|
||
SUM(b.stockQty) AS totalStock,
|
||
SUM(b.shipQty) AS totalShip,
|
||
SUM(CASE WHEN b.ageDays IS NULL OR b.turnoverPct IS NULL OR b.stockQty <= 0 THEN 1 ELSE 0 END) AS ungradedSku,
|
||
CASE WHEN SUM(b.stockQty) > 0
|
||
THEN CAST(SUM(b.shipQty) * 100.0 / SUM(b.stockQty) AS DECIMAL(18, 2))
|
||
ELSE NULL END AS turnoverPct,
|
||
SUM(CASE WHEN ${level} = 'warn' THEN b.stockQty ELSE 0 END) AS warnLevel,
|
||
SUM(CASE WHEN ${level} = 'slow' THEN b.stockQty ELSE 0 END) AS slowLevel,
|
||
SUM(CASE WHEN ${level} = 'stale' THEN b.stockQty ELSE 0 END) AS staleLevel,
|
||
SUM(CASE WHEN ${level} = 'zombie' THEN b.stockQty ELSE 0 END) AS zombieLevel
|
||
FROM base b
|
||
GROUP BY b.wmsCoId`;
|
||
|
||
const rows = await cloudDb.query(sqlText, {
|
||
...invIn.params,
|
||
...shipIn.params,
|
||
bizDate,
|
||
asOf,
|
||
shipFrom,
|
||
shipTo,
|
||
}, { requestTimeout: 120000 });
|
||
|
||
const map = new Map();
|
||
rows.forEach((r) => {
|
||
map.set(String(r.wmsCoId), {
|
||
skuCount: toNumber(r.skuCount) || 0,
|
||
gradeStock: toNumber(r.totalStock) || 0,
|
||
shipQty: toNumber(r.totalShip) || 0,
|
||
turnoverDays: toNumber(r.turnoverPct),
|
||
warnLevel: toNumber(r.warnLevel) || 0,
|
||
slowLevel: toNumber(r.slowLevel) || 0,
|
||
staleLevel: toNumber(r.staleLevel) || 0,
|
||
zombieLevel: toNumber(r.zombieLevel) || 0,
|
||
ungradedSku: toNumber(r.ungradedSku) || 0,
|
||
});
|
||
});
|
||
return map;
|
||
}
|
||
|
||
/** 实时库存表的数据时效(一次取全局即可) */
|
||
async function fetchInventoryTakenAt() {
|
||
const rows = await cloudDb.query(
|
||
`SELECT MAX(modified) AS takenAt FROM rpt.jst_inventoryInfo`
|
||
);
|
||
return rows[0]?.takenAt || null;
|
||
}
|
||
|
||
/**
|
||
* GET /api/daily-inventory
|
||
* 每日进销存:26 个仓库 ×(期初 / 期末 / 周转率 / 四级库存量)
|
||
*
|
||
* 数据源分工(全部来自数据库,无硬编码业务数值):
|
||
* - 期初数量:本地快照表 daily_inventory_snapshot 中「上一业务日」的期末结存;
|
||
* 快照缺失时按 pm_stockMonitor(精确)/ 实时库存扣流水(近似)推导并补写
|
||
* - 期末数量:jst_inventoryInfo 实时库存(按 wms_co_id 单仓)
|
||
* - 库存周转天数 / 四级库存量:近 30 天发货 × 实时库存 × SKU 建档日期
|
||
* - 12 个流水列:4 列可从业务库单据表还原(采购进仓/采购退货/销售出仓/销售退货,
|
||
* 见 services/dailyInventoryFlows.js),其余 8 列业务库无单据表,返回 null(前端显示「—」)
|
||
*/
|
||
router.get('/', async (req, res) => {
|
||
try {
|
||
const force = String(req.query?.refresh || '') === '1';
|
||
const requestedDate = typeof req.query?.date === 'string'
|
||
&& /^\d{4}-\d{2}-\d{2}$/.test(req.query.date) ? req.query.date : null;
|
||
const systemBizDate = await fetchLatestBizDate().catch(() => null);
|
||
const latestBizDate = requestedDate || systemBizDate;
|
||
// 查看历史业务日时,业务库只有「当前」的实时库存与流水,不能当成该日的期末/发生额
|
||
const staleMetrics = Boolean(requestedDate && systemBizDate && requestedDate !== systemBizDate);
|
||
const cacheKey = `${latestBizDate}|${staleMetrics ? 'hist' : 'live'}`;
|
||
if (!force && cachedResult && cachedResult.latestBizDate === cacheKey
|
||
&& Date.now() - cachedResultAt < RESULT_CACHE_TTL_MS) {
|
||
return res.json({ success: true, data: cachedResult, cached: true });
|
||
}
|
||
|
||
const asOf = nextDayAsOf(latestBizDate);
|
||
const openingDate = prevDay(latestBizDate);
|
||
const wmsCoIds = DAILY_INVENTORY_BASELINE
|
||
.map((b) => b.wmsCoId)
|
||
.filter((v) => v != null);
|
||
|
||
const [gradeMap, takenAt, officialMap, officialDates] = await Promise.all([
|
||
fetchGradeMetrics(wmsCoIds, latestBizDate, asOf).catch((e) => {
|
||
console.error('[daily-inventory] 分级计算失败:', e.message);
|
||
return new Map();
|
||
}),
|
||
fetchInventoryTakenAt().catch(() => null),
|
||
officialDb.fetchOfficial(latestBizDate).catch(() => new Map()),
|
||
officialDb.listOfficialDates(30).catch(() => []),
|
||
]);
|
||
|
||
// 期初 = 上一业务日的期末快照;若定时任务漏跑,顺手把当前业务日的快照补齐
|
||
let openingMap = await snapshotDb.fetchSnapshot(openingDate).catch(() => new Map());
|
||
const todayMap = await snapshotDb.fetchSnapshot(latestBizDate).catch(() => new Map());
|
||
const missing = DAILY_INVENTORY_BASELINE
|
||
.filter((b) => b.wmsCoId != null && !todayMap.has(String(b.wmsCoId)))
|
||
.map((b) => {
|
||
const g = gradeMap.get(String(b.wmsCoId));
|
||
return {
|
||
wmsCoId: b.wmsCoId,
|
||
warehouse: b.warehouse,
|
||
dbWarehouse: b.dbWarehouse,
|
||
closingQty: Number(g?.gradeStock ?? 0),
|
||
skuCount: Number(g?.skuCount ?? 0),
|
||
takenAt: takenAt ? new Date(takenAt) : null,
|
||
};
|
||
});
|
||
if (missing.length) {
|
||
await snapshotDb.upsertSnapshot(latestBizDate, missing).catch((e) => {
|
||
console.error('[daily-inventory] 快照补写失败:', e.message);
|
||
});
|
||
}
|
||
|
||
// 期初快照也缺失时,用 pm_stockMonitor(精确)/ 实时库存扣流水(近似)推导并补写
|
||
if (openingDate && !openingMap.size) {
|
||
await deriveAndStoreOpeningSnapshot({
|
||
cloudDb,
|
||
snapshotDb,
|
||
baseline: DAILY_INVENTORY_BASELINE,
|
||
openingDate,
|
||
latestBizDate,
|
||
takenAt,
|
||
}).catch((e) => {
|
||
console.error('[daily-inventory] 期初快照推导失败:', e.message);
|
||
});
|
||
openingMap = await snapshotDb.fetchSnapshot(openingDate).catch(() => new Map());
|
||
}
|
||
|
||
// 流水列:自期初时刻(最新业务日 01:00)以来的单据合计(带符号,流出为负)
|
||
let flowMap = new Map();
|
||
if (openingDate) {
|
||
flowMap = await fetchFlows(cloudDb, wmsCoIds, `${latestBizDate} 01:00:00`).catch((e) => {
|
||
console.error('[daily-inventory] 流水取数失败:', e.message);
|
||
return new Map();
|
||
});
|
||
}
|
||
|
||
const items = DAILY_INVENTORY_BASELINE.map((base) => {
|
||
const official = officialMap.get(base.warehouse) || null;
|
||
const grade = base.wmsCoId != null ? gradeMap.get(String(base.wmsCoId)) : null;
|
||
const opening = base.wmsCoId != null ? openingMap.get(String(base.wmsCoId)) : null;
|
||
const itemFlows = base.wmsCoId != null ? flowMap.get(String(base.wmsCoId)) : null;
|
||
|
||
// 12 个流水列:聚水潭官方导出优先,缺失时回退业务库推算(历史业务日不做回退)
|
||
const flows = {};
|
||
const flowSources = {};
|
||
for (const col of FLOW_COLUMNS) {
|
||
const fromOfficial = official?.flows?.[col.field];
|
||
if (fromOfficial != null) {
|
||
flows[col.field] = Number(fromOfficial);
|
||
flowSources[col.field] = 'official';
|
||
continue;
|
||
}
|
||
const derived = itemFlows ? itemFlows[col.field] : null;
|
||
if (derived != null && !staleMetrics) {
|
||
flows[col.field] = Number(derived.toFixed(2));
|
||
flowSources[col.field] = 'derived';
|
||
continue;
|
||
}
|
||
flows[col.field] = null;
|
||
flowSources[col.field] = 'none';
|
||
}
|
||
|
||
// 期末:官方期末优先;无官方时,历史业务日不给实时库存(口径不对)
|
||
const gradeStock = grade?.gradeStock ?? null;
|
||
let closingQty = gradeStock;
|
||
let closingSource = 'derived';
|
||
if (official?.closingQty != null) {
|
||
closingQty = Number(official.closingQty);
|
||
closingSource = 'official';
|
||
} else if (staleMetrics) {
|
||
closingQty = null;
|
||
closingSource = 'none';
|
||
}
|
||
|
||
// 期初:官方期初优先,其次本地快照
|
||
let openingQty = opening ? opening.closingQty : null;
|
||
let openingSource = opening ? 'snapshot' : 'none';
|
||
if (official?.openingQty != null) {
|
||
openingQty = Number(official.openingQty);
|
||
openingSource = 'official';
|
||
} else if (staleMetrics) {
|
||
openingQty = null;
|
||
openingSource = 'none';
|
||
}
|
||
|
||
return {
|
||
warehouse: base.warehouse,
|
||
dbWarehouse: base.dbWarehouse,
|
||
openingQty,
|
||
openingQtyExtra: null,
|
||
...flows,
|
||
closingQty,
|
||
closingSkuCount: grade?.skuCount ?? null,
|
||
closingTakenAt: takenAt,
|
||
shipQty30d: grade?.shipQty ?? null,
|
||
turnoverDays: grade?.turnoverDays ?? null,
|
||
gradeStock,
|
||
warnLevel: grade?.warnLevel ?? null,
|
||
slowLevel: grade?.slowLevel ?? null,
|
||
staleLevel: grade?.staleLevel ?? null,
|
||
zombieLevel: grade?.zombieLevel ?? null,
|
||
ungradedSku: grade?.ungradedSku ?? null,
|
||
hasGrade: Boolean(grade),
|
||
source: {
|
||
opening: openingSource,
|
||
closing: closingSource,
|
||
flows: flowSources,
|
||
hasOfficial: Boolean(official),
|
||
},
|
||
};
|
||
});
|
||
|
||
// 合计行:便于与表格总额核对
|
||
const sum = (k) => items.reduce((acc, it) => acc + (Number(it[k]) || 0), 0);
|
||
const totals = { warehouse: '合计', dbWarehouse: '', isTotal: true };
|
||
for (const k of ['openingQty', 'openingQtyExtra', ...FLOW_COLUMNS.map((c) => c.field), 'closingQty', 'warnLevel', 'slowLevel', 'staleLevel', 'zombieLevel']) {
|
||
// 若所有仓库该列均无数据(null),合计同样置空,避免误读为 0
|
||
totals[k] = items.every((it) => it[k] == null) ? null : Number(sum(k).toFixed(2));
|
||
}
|
||
// 合计行周转率按「总发货 / 总库存」重算,避免简单累加失真
|
||
totals.shipQty30d = Number(sum('shipQty30d').toFixed(2));
|
||
const totalStock = sum('closingQty');
|
||
totals.turnoverDays = totalStock > 0
|
||
? Number((sum('shipQty30d') * 100 / totalStock).toFixed(2))
|
||
: null;
|
||
|
||
const noOpeningCount = items.filter((it) => it.openingQty == null).length;
|
||
const balanceMismatch = checkBalance([...officialMap.values()]);
|
||
const officialFlowFields = FLOW_COLUMNS
|
||
.filter((c) => items.some((it) => it.source.flows[c.field] === 'official'))
|
||
.map((c) => c.field);
|
||
const derivedFlowFields = FLOW_COLUMNS
|
||
.filter((c) => items.some((it) => it.source.flows[c.field] === 'derived'))
|
||
.map((c) => c.field);
|
||
|
||
const payload = {
|
||
openingDate,
|
||
latestBizDate,
|
||
systemBizDate,
|
||
staleMetrics,
|
||
asOf,
|
||
shipWindow: asOf ? { ...shipWindow(asOf), days: SHIP_WINDOW_DAYS } : null,
|
||
flowColumns: FLOW_COLUMNS,
|
||
flowColumnsAvailable: AVAILABLE_FLOW_FIELDS,
|
||
flowColumnsOfficial: officialFlowFields,
|
||
flowColumnsDerived: derivedFlowFields,
|
||
noOpeningCount,
|
||
official: {
|
||
hasData: officialMap.size > 0,
|
||
warehouseCount: officialMap.size,
|
||
availableDates: officialDates,
|
||
balanceMismatch,
|
||
},
|
||
items,
|
||
totals,
|
||
};
|
||
cachedResult = payload;
|
||
cachedResultAt = Date.now();
|
||
return res.json({ success: true, data: payload });
|
||
} catch (error) {
|
||
console.error('[daily-inventory] 查询失败:', error.message);
|
||
return res.status(500).json({ success: false, error: '每日进销存数据查询失败', detail: error.message });
|
||
}
|
||
});
|
||
|
||
/**
|
||
* 官方期初 = 上一业务日期末、官方期末 = 当日期末。
|
||
* 导入后回写本地快照,让「今日期初 = 昨日结存」的自动推算也对齐聚水潭口径。
|
||
*/
|
||
async function syncSnapshotFromOfficial(bizDate, rows) {
|
||
const byId = new Map(DAILY_INVENTORY_BASELINE.map((b) => [String(b.wmsCoId), b]));
|
||
const known = rows.filter((r) => r.wmsCoId != null);
|
||
if (!known.length) return { prev: 0, current: 0 };
|
||
const prevDate = prevDay(bizDate);
|
||
|
||
let prevCount = 0;
|
||
if (prevDate) {
|
||
const prev = await snapshotDb.fetchSnapshot(prevDate).catch(() => new Map());
|
||
prevCount = await snapshotDb.upsertSnapshot(prevDate, known
|
||
.filter((r) => r.openingQty != null)
|
||
.map((r) => ({
|
||
wmsCoId: r.wmsCoId,
|
||
warehouse: r.warehouse,
|
||
dbWarehouse: byId.get(String(r.wmsCoId))?.dbWarehouse || null,
|
||
closingQty: Number(r.openingQty),
|
||
skuCount: Number(prev.get(String(r.wmsCoId))?.skuCount || 0),
|
||
takenAt: null,
|
||
}))).catch((e) => {
|
||
console.error('[daily-inventory] 期初快照回写失败:', e.message);
|
||
return 0;
|
||
});
|
||
}
|
||
|
||
const current = await snapshotDb.fetchSnapshot(bizDate).catch(() => new Map());
|
||
const currentCount = await snapshotDb.upsertSnapshot(bizDate, known
|
||
.filter((r) => r.closingQty != null)
|
||
.map((r) => ({
|
||
wmsCoId: r.wmsCoId,
|
||
warehouse: r.warehouse,
|
||
dbWarehouse: byId.get(String(r.wmsCoId))?.dbWarehouse || null,
|
||
closingQty: Number(r.closingQty),
|
||
skuCount: Number(current.get(String(r.wmsCoId))?.skuCount || 0),
|
||
takenAt: null,
|
||
}))).catch((e) => {
|
||
console.error('[daily-inventory] 期末快照回写失败:', e.message);
|
||
return 0;
|
||
});
|
||
|
||
return { prev: prevCount, current: currentCount };
|
||
}
|
||
|
||
/**
|
||
* GET /api/daily-inventory/official
|
||
* 查询某业务日已导入的聚水潭官方进销存数据
|
||
*/
|
||
router.get('/official', async (req, res) => {
|
||
try {
|
||
const date = String(req.query?.date || '').trim();
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||
return res.status(400).json({ success: false, error: '请提供业务日期 date=YYYY-MM-DD' });
|
||
}
|
||
const map = await officialDb.fetchOfficial(date);
|
||
const dates = await officialDb.listOfficialDates(30);
|
||
return res.json({
|
||
success: true,
|
||
data: { date, rows: [...map.values()], availableDates: dates },
|
||
});
|
||
} catch (error) {
|
||
console.error('[daily-inventory] 官方数据查询失败:', error.message);
|
||
return res.status(500).json({ success: false, error: '官方数据查询失败', detail: error.message });
|
||
}
|
||
});
|
||
|
||
/**
|
||
* POST /api/daily-inventory/official-import
|
||
* 导入聚水潭进销存按仓导出(multipart: file=xlsx, date;或 JSON: {date, rows})
|
||
*/
|
||
router.post('/official-import', upload.single('file'), async (req, res) => {
|
||
try {
|
||
const date = String((req.body && req.body.date) || req.query?.date || '').trim();
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||
return res.status(400).json({ success: false, error: '请提供业务日期 date(YYYY-MM-DD)' });
|
||
}
|
||
|
||
let rows = [];
|
||
let warnings = [];
|
||
let sheetName = null;
|
||
if (req.file && req.file.buffer) {
|
||
const parsed = parseWorkbook(req.file.buffer);
|
||
rows = parsed.rows;
|
||
warnings = parsed.warnings;
|
||
sheetName = parsed.sheetName;
|
||
if (!rows.length) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
error: '未能从文件解析出仓库行',
|
||
detail: warnings.join(';') || '未找到含「仓库」列的工作表',
|
||
});
|
||
}
|
||
} else if (req.body && Array.isArray(req.body.rows)) {
|
||
const parsed = normalizeRecords(req.body.rows);
|
||
rows = parsed.rows;
|
||
warnings = parsed.warnings;
|
||
if (!rows.length) {
|
||
return res.status(400).json({ success: false, error: 'rows 中未解析到仓库行', detail: warnings.join(';') });
|
||
}
|
||
} else {
|
||
return res.status(400).json({ success: false, error: '请上传 xlsx 文件(字段名 file)或提供 rows 数组' });
|
||
}
|
||
|
||
const result = await importOfficial(date, rows, {
|
||
source: String((req.body && req.body.source) || 'jst-export'),
|
||
});
|
||
const synced = await syncSnapshotFromOfficial(date, rows);
|
||
cachedResult = null; // 导入后立即使查询缓存失效
|
||
|
||
return res.json({
|
||
success: true,
|
||
data: {
|
||
...result,
|
||
sheetName,
|
||
warnings,
|
||
snapshotSynced: synced,
|
||
},
|
||
});
|
||
} catch (error) {
|
||
console.error('[daily-inventory] 官方数据导入失败:', error.message);
|
||
return res.status(500).json({ success: false, error: '官方数据导入失败', detail: error.message });
|
||
}
|
||
});
|
||
|
||
module.exports = router;
|