/** * 每日进销存 —— 聚水潭官方进销存导出解析 / 导入 * * 输入为聚水潭进销存报表按仓导出(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} 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, };