业务库只能还原 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>
138 lines
5.1 KiB
JavaScript
138 lines
5.1 KiB
JavaScript
/**
|
||
* 每日进销存 —— 聚水潭官方进销存导出解析 / 导入
|
||
*
|
||
* 输入为聚水潭进销存报表按仓导出(xlsx),列包含:
|
||
* 仓库、期初数量、[12 个流水列]、期末数量
|
||
* 流水列符号沿用导出原值(出仓为负、入仓为正),前端不做二次转换。
|
||
*/
|
||
const XLSX = require('xlsx');
|
||
const {
|
||
FLOW_COLUMNS,
|
||
DAILY_INVENTORY_BASELINE,
|
||
} = require('../config/dailyInventoryBaseline');
|
||
const officialDb = require('../config/dailyInventoryOfficialDb');
|
||
|
||
const FLOW_FIELDS = FLOW_COLUMNS.map((c) => c.field);
|
||
|
||
/** 流水列中文表头 → field */
|
||
const LABEL_TO_FIELD = new Map(FLOW_COLUMNS.map((c) => [c.label, c.field]));
|
||
|
||
/** 仓库名 → wms_co_id(来自项目仓库清单) */
|
||
const WAREHOUSE_TO_ID = new Map(
|
||
DAILY_INVENTORY_BASELINE.map((b) => [b.warehouse, b.wmsCoId])
|
||
);
|
||
|
||
const num = (v) => {
|
||
if (v == null || v === '' || v === '-') return null;
|
||
const n = Number(String(v).replace(/,/g, '').trim());
|
||
return Number.isFinite(n) ? n : null;
|
||
};
|
||
|
||
/**
|
||
* 把「表头 → 值」的记录数组归一化为官方行
|
||
* @param {Array<Object>} records
|
||
* @returns {{rows: Array, warnings: string[]}}
|
||
*/
|
||
function normalizeRecords(records) {
|
||
const rows = [];
|
||
const warnings = [];
|
||
if (!Array.isArray(records)) return { rows, warnings: ['导入内容不是数组'] };
|
||
|
||
records.forEach((rec, idx) => {
|
||
const keys = Object.keys(rec);
|
||
const warehouseKey = keys.find((k) => String(k).trim() === '仓库');
|
||
if (!warehouseKey) return;
|
||
const warehouse = String(rec[warehouseKey] ?? '').trim();
|
||
if (!warehouse) return;
|
||
|
||
const openingKey = keys.find((k) => String(k).trim() === '期初数量');
|
||
const closingKey = keys.find((k) => String(k).trim() === '期末数量');
|
||
if (!openingKey && !closingKey) {
|
||
warnings.push(`第 ${idx + 2} 行缺少「期初数量 / 期末数量」列,已跳过`);
|
||
return;
|
||
}
|
||
|
||
// 12 个流水列先置 null,导出行里没出现的列保持 null(区别于显式为 0)
|
||
const flows = {};
|
||
for (const field of FLOW_FIELDS) flows[field] = null;
|
||
for (const k of keys) {
|
||
const label = String(k).trim();
|
||
const field = LABEL_TO_FIELD.get(label);
|
||
if (!field) continue;
|
||
flows[field] = num(rec[k]);
|
||
}
|
||
|
||
rows.push({
|
||
warehouse,
|
||
wmsCoId: WAREHOUSE_TO_ID.get(warehouse) ?? null,
|
||
openingQty: openingKey ? num(rec[openingKey]) : null,
|
||
closingQty: closingKey ? num(rec[closingKey]) : null,
|
||
flows,
|
||
});
|
||
});
|
||
|
||
if (!rows.length) warnings.push('未解析到任何仓库行,请确认包含「仓库」列');
|
||
return { rows, warnings };
|
||
}
|
||
|
||
/**
|
||
* 解析 xlsx 缓冲区(自动定位含「仓库」表头的工作表与表头行)
|
||
* @param {Buffer} buffer
|
||
*/
|
||
function parseWorkbook(buffer) {
|
||
const wb = XLSX.read(buffer, { type: 'buffer', cellDates: true });
|
||
const warnings = [];
|
||
for (const sheetName of wb.SheetNames) {
|
||
const ws = wb.Sheets[sheetName];
|
||
const grid = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '', blankrows: false });
|
||
const headerIdx = grid.findIndex((r) => r.some((c) => String(c).trim() === '仓库'));
|
||
if (headerIdx < 0) continue;
|
||
const header = grid[headerIdx].map((c) => String(c).trim());
|
||
const body = grid.slice(headerIdx + 1)
|
||
.filter((r) => r.some((c) => String(c).trim() !== ''))
|
||
.map((r) => Object.fromEntries(header.map((h, i) => [h, r[i]])));
|
||
const { rows, warnings: w } = normalizeRecords(body);
|
||
if (rows.length) return { sheetName, rows, warnings: warnings.concat(w) };
|
||
warnings.push(...w);
|
||
}
|
||
return { sheetName: null, rows: [], warnings: warnings.length ? warnings : ['未找到含「仓库」列的工作表'] };
|
||
}
|
||
|
||
/** 恒等式校验:期初 + Σ流水 = 期末 */
|
||
function checkBalance(rows, tolerance = 1) {
|
||
return rows
|
||
.map((r) => {
|
||
const flowSum = FLOW_FIELDS.reduce((acc, f) => acc + (Number(r.flows?.[f]) || 0), 0);
|
||
const opening = Number(r.openingQty) || 0;
|
||
const closing = Number(r.closingQty) || 0;
|
||
const diff = opening + flowSum - closing;
|
||
return { warehouse: r.warehouse, opening, flowSum, closing, diff: Number(diff.toFixed(2)) };
|
||
})
|
||
.filter((r) => Math.abs(r.diff) > tolerance);
|
||
}
|
||
|
||
/**
|
||
* 导入某业务日的官方进销存(先清后写)
|
||
* @returns {Promise<{bizDate:string, imported:number, warnings:string[], mismatches:Array, unknownWarehouses:string[], missingWarehouses:string[]}>}
|
||
*/
|
||
async function importOfficial(bizDate, rows, { source = 'jst-export' } = {}) {
|
||
await officialDb.deleteOfficial(bizDate);
|
||
const imported = await officialDb.upsertOfficial(bizDate, rows, source);
|
||
const mismatches = checkBalance(rows);
|
||
const unknownWarehouses = rows.filter((r) => r.wmsCoId == null).map((r) => r.warehouse);
|
||
const importedNames = new Set(rows.map((r) => r.warehouse));
|
||
const missingWarehouses = DAILY_INVENTORY_BASELINE
|
||
.filter((b) => !importedNames.has(b.warehouse))
|
||
.map((b) => b.warehouse);
|
||
return { bizDate, imported, warnings: [], mismatches, unknownWarehouses, missingWarehouses };
|
||
}
|
||
|
||
module.exports = {
|
||
FLOW_FIELDS,
|
||
LABEL_TO_FIELD,
|
||
normalizeRecords,
|
||
parseWorkbook,
|
||
checkBalance,
|
||
importOfficial,
|
||
};
|