diff --git a/backend/package.json b/backend/package.json index a649155..14e0356 100644 --- a/backend/package.json +++ b/backend/package.json @@ -10,6 +10,7 @@ "test": "node --test test/*.test.js", "check": "npm run syntax && npm test", "alert:check": "node src/services/alertChecker.js", + "inventory:import-official": "node scripts/importDailyInventoryOfficial.js", "product-analysis:sync-replica": "node scripts/sync_jian_sun_product_analysis_tables.js" }, "dependencies": { diff --git a/backend/scripts/importDailyInventoryOfficial.js b/backend/scripts/importDailyInventoryOfficial.js new file mode 100644 index 0000000..58fad71 --- /dev/null +++ b/backend/scripts/importDailyInventoryOfficial.js @@ -0,0 +1,97 @@ +#!/usr/bin/env node +/** + * 导入聚水潭「进销存按仓导出」到每日进销存校正表 + * + * 用法: + * node scripts/importDailyInventoryOfficial.js <业务日期> + * node scripts/importDailyInventoryOfficial.js 2026-09-26 ./进销存_按仓_20260926.xlsx + * + * 作用: + * 1) 解析导出文件,校验「期初 + Σ流水 = 期末」逐仓闭合 + * 2) 写入本地表 daily_inventory_official(同一业务日整体覆盖) + * 3) 回写本地快照:官方期初 → 上一业务日期末、官方期末 → 该业务日期末 + * + * 页面右上角「导入聚水潭进销存」走的是同一个后端接口(POST /api/daily-inventory/official-import)。 + */ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { parseWorkbook, importOfficial, checkBalance } = require('../src/services/dailyInventoryOfficial'); +const snapshotDb = require('../src/config/dailyInventorySnapshotDb'); +const { DAILY_INVENTORY_BASELINE } = require('../src/config/dailyInventoryBaseline'); + +function prevDay(bizDate) { + const d = new Date(`${bizDate}T00:00:00Z`); + d.setUTCDate(d.getUTCDate() - 1); + return d.toISOString().slice(0, 10); +} + +async function main() { + const [bizDate, file] = process.argv.slice(2); + if (!bizDate || !/^\d{4}-\d{2}-\d{2}$/.test(bizDate)) { + console.error('用法: node scripts/importDailyInventoryOfficial.js <业务日期 YYYY-MM-DD> '); + process.exit(1); + } + if (!file || !fs.existsSync(file)) { + console.error(`文件不存在: ${file}`); + process.exit(1); + } + + const { rows, warnings, sheetName } = parseWorkbook(fs.readFileSync(file)); + console.log(`解析 ${path.basename(file)} [${sheetName}]:${rows.length} 行,提示:${warnings.join(';') || '无'}`); + if (!rows.length) { + console.error('未解析到仓库行,已中止'); + process.exit(1); + } + + const mismatches = checkBalance(rows); + if (mismatches.length) { + console.warn(`⚠ 以下 ${mismatches.length} 个仓「期初 + 流水 ≠ 期末」:`); + mismatches.forEach((m) => console.warn(` ${m.warehouse}: 差 ${m.diff}`)); + } else { + console.log('恒等式校验:期初 + 流水 = 期末,逐仓闭合 ✓'); + } + + const result = await importOfficial(bizDate, rows); + console.log(`写入 official:${result.imported} 行(${bizDate})`); + if (result.unknownWarehouses.length) { + console.warn(`⚠ 未匹配到仓库 ID(仅按仓库名存档):${result.unknownWarehouses.join('、')}`); + } + if (result.missingWarehouses.length) { + console.warn(`⚠ 清单中未出现在导出里的仓库:${result.missingWarehouses.join('、')}`); + } + + const byId = new Map(DAILY_INVENTORY_BASELINE.map((b) => [String(b.wmsCoId), b])); + const known = rows.filter((r) => r.wmsCoId != null); + const prevDate = prevDay(bizDate); + const prev = await snapshotDb.fetchSnapshot(prevDate); + const 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, + }))); + const current = await snapshotDb.fetchSnapshot(bizDate); + 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, + }))); + console.log(`快照回写:${prevDate}(官方期初 → 期末)${prevCount} 行,${bizDate}(官方期末)${currentCount} 行`); + process.exit(0); +} + +main().catch((e) => { + console.error('导入失败:', e.message); + process.exit(1); +}); diff --git a/backend/src/api/dailyInventory.js b/backend/src/api/dailyInventory.js index aec520f..239a6c1 100644 --- a/backend/src/api/dailyInventory.js +++ b/backend/src/api/dailyInventory.js @@ -1,14 +1,23 @@ 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'; @@ -222,8 +231,13 @@ async function fetchInventoryTakenAt() { router.get('/', async (req, res) => { try { const force = String(req.query?.refresh || '') === '1'; - const latestBizDate = await fetchLatestBizDate().catch(() => null); - const cacheKey = latestBizDate || 'none'; + 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 }); @@ -235,12 +249,14 @@ router.get('/', async (req, res) => { .map((b) => b.wmsCoId) .filter((v) => v != null); - const [gradeMap, takenAt] = await Promise.all([ + 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(() => []), ]); // 期初 = 上一业务日的期末快照;若定时任务漏跑,顺手把当前业务日的快照补齐 @@ -290,37 +306,78 @@ router.get('/', async (req, res) => { } const items = DAILY_INVENTORY_BASELINE.map((base) => { - // 12 个流水列:4 列从业务库单据表取数,其余 8 列无单据来源保持 null(前端渲染为「—」) - const flows = {}; - for (const col of FLOW_COLUMNS) flows[col.field] = null; - const itemFlows = base.wmsCoId != null ? flowMap.get(String(base.wmsCoId)) : null; - if (itemFlows) { - for (const field of AVAILABLE_FLOW_FIELDS) { - if (itemFlows[field] != null) flows[field] = Number(itemFlows[field].toFixed(2)); - } - } - + 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: opening ? opening.closingQty : null, + openingQty, openingQtyExtra: null, ...flows, - closingQty: grade?.gradeStock ?? null, + closingQty, closingSkuCount: grade?.skuCount ?? null, closingTakenAt: takenAt, shipQty30d: grade?.shipQty ?? null, turnoverDays: grade?.turnoverDays ?? null, - gradeStock: grade?.gradeStock ?? 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), + }, }; }); @@ -339,15 +396,32 @@ router.get('/', async (req, res) => { : 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, }; @@ -360,4 +434,130 @@ router.get('/', async (req, res) => { } }); +/** + * 官方期初 = 上一业务日期末、官方期末 = 当日期末。 + * 导入后回写本地快照,让「今日期初 = 昨日结存」的自动推算也对齐聚水潭口径。 + */ +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; diff --git a/backend/src/config/dailyInventoryBaseline.js b/backend/src/config/dailyInventoryBaseline.js index e534c55..fe83a89 100644 --- a/backend/src/config/dailyInventoryBaseline.js +++ b/backend/src/config/dailyInventoryBaseline.js @@ -101,6 +101,11 @@ const DAILY_INVENTORY_BASELINE = [ "dbWarehouse": "简尚-横林二仓", "wmsCoId": 15033302 }, + { + "warehouse": "简尚-横林三仓主仓", + "dbWarehouse": "简尚-横林三仓", + "wmsCoId": 15043561 + }, { "warehouse": "简尚-山东品牌仓主仓", "dbWarehouse": "简尚-山东品牌仓", diff --git a/backend/src/config/dailyInventoryOfficialDb.js b/backend/src/config/dailyInventoryOfficialDb.js new file mode 100644 index 0000000..f423fbd --- /dev/null +++ b/backend/src/config/dailyInventoryOfficialDb.js @@ -0,0 +1,184 @@ +/** + * 每日进销存 —— 聚水潭「官方进销存导出」校正数据 + * + * 背景:业务库(MSSQL / JSReportReplica)只能还原 4/12 个流水列,且口径与 + * 聚水潭进销存报表不一致(采购按单据日期 vs 实际入库、销售出仓按订单发货 vs 出库单等), + * 其余 8 个流水列(调拨/盘点/加工/其它出入库)业务库根本没有对应单据表。 + * 因此「每日进销存」以聚水潭官方导出的进销存报表为准: + * 官方有值 → 直接采用(source = official) + * 官方无值 → 回退业务库自动推算(source = derived) + * + * 存储:本地 MySQL 表 daily_inventory_official,主键 (biz_date, warehouse)。 + */ +const { getPool } = require('./dailyInventorySnapshotDb'); + +const TABLE = 'daily_inventory_official'; + +/** 流水列 field(与 dailyInventoryBaseline.FLOW_COLUMNS 一致)→ 数据库列名 */ +const FLOW_DB_COLUMNS = { + processIn: 'process_in', + purchaseReturn: 'purchase_return', + purchaseIn: 'purchase_in', + otherIn: 'other_in', + otherReturn: 'other_return', + salesOut: 'sales_out', + stocktaking: 'stocktaking', + processOut: 'process_out', + transferOut: 'transfer_out', + salesReturn: 'sales_return', + transferIn: 'transfer_in', + otherOut: 'other_out', +}; + +const CREATE_SQL = ` +CREATE TABLE IF NOT EXISTS ${TABLE} ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + biz_date DATE NOT NULL COMMENT '业务日期', + wms_co_id BIGINT NULL COMMENT '聚水潭仓库 ID(未知仓为空)', + warehouse VARCHAR(128) NOT NULL COMMENT '仓库名(聚水潭主仓)', + opening_qty DECIMAL(18,2) NULL COMMENT '期初数量', + closing_qty DECIMAL(18,2) NULL COMMENT '期末数量', + process_in DECIMAL(18,2) NULL COMMENT '加工进仓', + purchase_return DECIMAL(18,2) NULL COMMENT '采购退货', + purchase_in DECIMAL(18,2) NULL COMMENT '采购进仓', + other_in DECIMAL(18,2) NULL COMMENT '其它进仓', + other_return DECIMAL(18,2) NULL COMMENT '其它退货', + sales_out DECIMAL(18,2) NULL COMMENT '销售出仓', + stocktaking DECIMAL(18,2) NULL COMMENT '盘点', + process_out DECIMAL(18,2) NULL COMMENT '加工出仓', + transfer_out DECIMAL(18,2) NULL COMMENT '调拨出', + sales_return DECIMAL(18,2) NULL COMMENT '销售退货', + transfer_in DECIMAL(18,2) NULL COMMENT '调拨入', + other_out DECIMAL(18,2) NULL COMMENT '其它出库', + source VARCHAR(32) NOT NULL DEFAULT 'jst-export' COMMENT '数据来源', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_date_wh (biz_date, warehouse), + KEY idx_date (biz_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='每日进销存-聚水潭官方进销存校正数据';`; + +async function ensureTable() { + await getPool().query(CREATE_SQL); +} + +const num = (v) => { + if (v == null || v === '') return null; + const n = Number(v); + return Number.isFinite(n) ? n : null; +}; + +/** + * 写入某业务日的官方进销存(幂等,重复导入覆盖) + * @param {string} bizDate + * @param {Array<{warehouse:string, wmsCoId?:number|null, openingQty?:number|null, closingQty?:number|null, flows?:Object}>} rows + * @param {string} source + */ +async function upsertOfficial(bizDate, rows, source = 'jst-export') { + if (!bizDate || !rows.length) return 0; + await ensureTable(); + const conn = await getPool().getConnection(); + try { + for (const r of rows) { + const flows = r.flows || {}; + await conn.query( + `INSERT INTO ${TABLE} + (biz_date, wms_co_id, warehouse, opening_qty, closing_qty, + process_in, purchase_return, purchase_in, other_in, other_return, + sales_out, stocktaking, process_out, transfer_out, sales_return, transfer_in, other_out, source) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON DUPLICATE KEY UPDATE + wms_co_id = VALUES(wms_co_id), + opening_qty = VALUES(opening_qty), closing_qty = VALUES(closing_qty), + process_in = VALUES(process_in), purchase_return = VALUES(purchase_return), + purchase_in = VALUES(purchase_in), other_in = VALUES(other_in), + other_return = VALUES(other_return), sales_out = VALUES(sales_out), + stocktaking = VALUES(stocktaking), process_out = VALUES(process_out), + transfer_out = VALUES(transfer_out), sales_return = VALUES(sales_return), + transfer_in = VALUES(transfer_in), other_out = VALUES(other_out), + source = VALUES(source)`, + [ + bizDate, + r.wmsCoId ?? null, + r.warehouse, + num(r.openingQty), + num(r.closingQty), + num(flows.processIn), num(flows.purchaseReturn), num(flows.purchaseIn), + num(flows.otherIn), num(flows.otherReturn), num(flows.salesOut), + num(flows.stocktaking), num(flows.processOut), num(flows.transferOut), + num(flows.salesReturn), num(flows.transferIn), num(flows.otherOut), + source, + ] + ); + } + return rows.length; + } finally { + conn.release(); + } +} + +/** 删除某业务日的官方数据(重新导入前清理) */ +async function deleteOfficial(bizDate) { + if (!bizDate) return 0; + await ensureTable(); + const [res] = await getPool().query(`DELETE FROM ${TABLE} WHERE biz_date = ?`, [bizDate]); + return res.affectedRows || 0; +} + +/** 读取某业务日的官方进销存:key = 仓库名 */ +async function fetchOfficial(bizDate) { + const map = new Map(); + if (!bizDate) return map; + try { + await ensureTable(); + const cols = Object.values(FLOW_DB_COLUMNS).map((c) => `o.${c} AS ${c}`).join(', '); + const [rows] = await getPool().query( + `SELECT o.wms_co_id AS wmsCoId, o.warehouse AS warehouse, + o.opening_qty AS openingQty, o.closing_qty AS closingQty, o.source AS source, + ${cols} + FROM ${TABLE} o WHERE o.biz_date = ?`, + [bizDate] + ); + const dbToField = Object.fromEntries(Object.entries(FLOW_DB_COLUMNS).map(([f, c]) => [c, f])); + rows.forEach((r) => { + const flows = {}; + for (const [col, field] of Object.entries(dbToField)) flows[field] = num(r[col]); + map.set(String(r.warehouse), { + warehouse: r.warehouse, + wmsCoId: r.wmsCoId == null ? null : Number(r.wmsCoId), + openingQty: num(r.openingQty), + closingQty: num(r.closingQty), + source: r.source, + flows, + }); + }); + } catch (error) { + console.error('[daily-inventory-official] 读取官方数据失败:', error.message); + } + return map; +} + +/** 已有官方数据的业务日(倒序) */ +async function listOfficialDates(limit = 30) { + try { + await ensureTable(); + const [rows] = await getPool().query( + `SELECT DISTINCT biz_date AS d FROM ${TABLE} ORDER BY biz_date DESC LIMIT ?`, + [Number(limit) || 30] + ); + return rows.map((r) => (typeof r.d === 'string' ? r.d.slice(0, 10) : String(r.d).slice(0, 10))); + } catch (error) { + console.error('[daily-inventory-official] 读取官方日期失败:', error.message); + return []; + } +} + +module.exports = { + TABLE, + CREATE_SQL, + FLOW_DB_COLUMNS, + ensureTable, + upsertOfficial, + deleteOfficial, + fetchOfficial, + listOfficialDates, +}; diff --git a/backend/src/services/dailyInventoryOfficial.js b/backend/src/services/dailyInventoryOfficial.js new file mode 100644 index 0000000..01dfc6e --- /dev/null +++ b/backend/src/services/dailyInventoryOfficial.js @@ -0,0 +1,137 @@ +/** + * 每日进销存 —— 聚水潭官方进销存导出解析 / 导入 + * + * 输入为聚水潭进销存报表按仓导出(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, +}; diff --git a/backend/test/dailyInventoryOfficial.test.js b/backend/test/dailyInventoryOfficial.test.js new file mode 100644 index 0000000..25774a6 --- /dev/null +++ b/backend/test/dailyInventoryOfficial.test.js @@ -0,0 +1,101 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const XLSX = require('xlsx'); + +const { + normalizeRecords, + parseWorkbook, + checkBalance, +} = require('../src/services/dailyInventoryOfficial'); + +const RECORDS = [ + { + 一级功能按键: '大货库存监控', + 二级功能按键: '每日进销存', + 仓库: '简尚-崔桥工厂仓主仓', + 期初数量: 90811, + 加工进仓: 0, + 采购退货: 0, + 采购进仓: 0, + 其它进仓: 0, + 其它退货: 0, + 销售出仓: -3252, + 盘点: 0, + 加工出仓: 0, + 调拨出: -881, + 销售退货: 0, + 调拨入: 4144, + 期初: 0, + 其它出库: 0, + 期末数量: 90822, + }, + { + 仓库: '简尚-新外贸仓主仓', + 期初数量: 5165, + 销售出仓: -1743, + 期末数量: 3422, + }, +]; + +test('normalizeRecords 解析官方导出并映射仓库 ID', () => { + const { rows, warnings } = normalizeRecords(RECORDS); + assert.equal(rows.length, 2); + assert.deepEqual(warnings, []); + + const [cuiqiao] = rows; + assert.equal(cuiqiao.warehouse, '简尚-崔桥工厂仓主仓'); + assert.equal(cuiqiao.wmsCoId, 15038426); // 来自项目仓库清单 + assert.equal(cuiqiao.openingQty, 90811); + assert.equal(cuiqiao.closingQty, 90822); + assert.equal(cuiqiao.flows.salesOut, -3252); + assert.equal(cuiqiao.flows.transferIn, 4144); + assert.equal(cuiqiao.flows.otherIn, 0); // 导出里显式为 0 的列保留 0 + // 导出里没有出现的流水列保持 null,不能被当成 0 + assert.equal(rows[1].flows.transferOut, null); + assert.equal(rows[1].flows.stocktaking, null); + // 导出里的第二个「期初」列不参与映射 + assert.equal(Object.prototype.hasOwnProperty.call(cuiqiao.flows, 'openingQty'), false); +}); + +test('normalizeRecords 跳过无仓库名的行,未匹配仓库的 wmsCoId 为 null', () => { + const { rows } = normalizeRecords([ + { 仓库: '', 期初数量: 1, 期末数量: 1 }, + { 仓库: '某某新仓主仓', 期初数量: 10, 期末数量: 10 }, + ]); + assert.equal(rows.length, 1); + assert.equal(rows[0].warehouse, '某某新仓主仓'); + assert.equal(rows[0].wmsCoId, null); +}); + +test('checkBalance 校验「期初 + 流水 = 期末」,不闭合的仓库会被列出', () => { + const { rows } = normalizeRecords(RECORDS); + assert.deepEqual(checkBalance(rows), []); + + const broken = checkBalance([{ + warehouse: '差异仓', openingQty: 100, closingQty: 90, flows: { salesOut: -5 }, + }]); + assert.equal(broken.length, 1); + assert.equal(broken[0].diff, 5); +}); + +test('parseWorkbook 能从 xlsx 缓冲区还原仓库行(表头不在首行也能定位)', () => { + const grid = [ + ['说明', '这一行是导出时附加的标题'], + ['一级功能按键', '二级功能按键', '仓库', '期初数量', '销售出仓', '调拨入', '期末数量'], + ['大货库存监控', '每日进销存', '简尚-崔桥工厂仓主仓', 90811, -3252, 4144, 90822], + ['', '', '简尚-新外贸仓主仓', 5165, -1743, 0, 3422], + ]; + const ws = XLSX.utils.aoa_to_sheet(grid); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, 'Sheet2'); + const buffer = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }); + + const { rows, sheetName } = parseWorkbook(buffer); + assert.equal(sheetName, 'Sheet2'); + assert.equal(rows.length, 2); + assert.equal(rows[0].warehouse, '简尚-崔桥工厂仓主仓'); + assert.equal(rows[0].flows.transferIn, 4144); + assert.equal(rows[1].closingQty, 3422); +}); diff --git a/frontend/src/api/dailyInventory.js b/frontend/src/api/dailyInventory.js index ddc1259..7ab8d90 100644 --- a/frontend/src/api/dailyInventory.js +++ b/frontend/src/api/dailyInventory.js @@ -1,10 +1,29 @@ import request from './request'; const LIST_TIMEOUT_MS = 5 * 60 * 1000; +const IMPORT_TIMEOUT_MS = 2 * 60 * 1000; -// 每日进销存:25 仓 × 进销存流水 + 实时期末 + 分级库存量 +// 每日进销存:进销存流水 + 实时期末 + 分级库存量 // refresh=1 跳过后端 5 分钟缓存,强制重算(一次要扫订单明细,约 15~20s) +// date=YYYY-MM-DD 查看指定业务日(默认取业务库最新业务日) export const getDailyInventory = (params = {}) => request.get( '/api/daily-inventory', { params, timeout: LIST_TIMEOUT_MS } ); + +// 导入聚水潭「进销存按仓导出」xlsx:file 为 File 对象,date 为业务日 YYYY-MM-DD +export const importDailyInventoryOfficial = ({ file, date }) => { + const formData = new FormData(); + formData.append('file', file); + formData.append('date', date); + return request.post('/api/daily-inventory/official-import', formData, { + timeout: IMPORT_TIMEOUT_MS, + headers: { 'Content-Type': 'multipart/form-data' }, + }); +}; + +// 查询某业务日已导入的官方进销存数据 +export const getDailyInventoryOfficial = (date) => request.get( + '/api/daily-inventory/official', + { params: { date } } +); diff --git a/frontend/src/constants/dailyInventory.js b/frontend/src/constants/dailyInventory.js index 33f64ee..d595174 100644 --- a/frontend/src/constants/dailyInventory.js +++ b/frontend/src/constants/dailyInventory.js @@ -14,27 +14,28 @@ export const COLUMN_SOURCE = { unavailable: 'unavailable', }; -const NO_SOURCE_TIP = '业务库 JSReportReplica 缺少该单据表,无法还原,待 DBA 补充后自动接入(详见 docs/库存流水单据表需求.md)'; -const FLOW_CALIBER_NOTE = '口径说明:与聚水潭进销存导出可能存在少量偏差(业务库缺 8 类单据表,导出口径含调拨/盘点等,本页只还原数据库可得的 4 类单据)'; +const NO_SOURCE_TIP = '业务库 JSReportReplica 缺少该单据表(调拨 / 盘点 / 加工 / 其它出入库),以聚水潭「进销存按仓导出」为准,导入后本列自动出数'; +const FLOW_CALIBER_NOTE = '口径说明:业务库只能还原该列的近似值(采购按单据日期、销售按订单发货日期),与聚水潭进销存口径存在差异,已导入官方数据时以官方为准'; +const OFFICIAL_TIP = '同一业务日导入过聚水潭进销存导出时,本列直接采用官方值(单元格为常规色);未导入时回退业务库推算(单元格标灰)'; /** 表格存在两个名为「期初」的列,第二个加后缀区分 */ export const DAILY_INVENTORY_COLUMNS = [ { key: 'warehouse', label: '仓库', source: 'snapshot', tip: '取自主数据表 rpt.jst_warehouseInfo 的仓库名,表格显示为「XX主仓」' }, - { key: 'openingQty', label: '期初数量', source: 'snapshot', tip: '上一业务日的期末结存,取自本地快照表 daily_inventory_snapshot(每日 01:30 自动落库);快照缺失时自动用「实时库存扣流水」回推补写(对照聚水潭导出基准偏差 0.3%~2.7%,来自业务库缺失的 8 类单据)' }, - { key: 'processIn', label: '加工进仓', source: 'unavailable', tip: `加工进仓。${NO_SOURCE_TIP}` }, - { key: 'purchaseReturn', label: '采购退货', source: 'runtime', tip: `采购退货(流出,负数)。rpt.jst_purchaseout × jst_purchaseoutItem,按 io_date 统计。${FLOW_CALIBER_NOTE}` }, - { key: 'purchaseIn', label: '采购进仓', source: 'runtime', tip: `采购进仓(流入)。rpt.jst_purchase × jst_purchaseItem,按 po_date 统计,仅计 Confirmed/Finished 状态。${FLOW_CALIBER_NOTE}` }, - { key: 'otherIn', label: '其它进仓', source: 'unavailable', tip: `其它进仓。${NO_SOURCE_TIP}` }, - { key: 'otherReturn', label: '其它退货', source: 'unavailable', tip: `其它退货。${NO_SOURCE_TIP}` }, - { key: 'salesOut', label: '销售出仓', source: 'runtime', tip: `销售出仓(流出,负数)。rpt.jst_Orders × jst_OrderItem,按 send_date 统计,排除 Delete/Cancelled,与「库存周转天数」的发货口径一致。${FLOW_CALIBER_NOTE}` }, - { key: 'stocktaking', label: '盘点', source: 'unavailable', tip: `盘点。${NO_SOURCE_TIP}` }, - { key: 'processOut', label: '加工出仓', source: 'unavailable', tip: `加工出仓。${NO_SOURCE_TIP}` }, - { key: 'transferOut', label: '调拨出', source: 'unavailable', tip: `调拨出。${NO_SOURCE_TIP}` }, - { key: 'salesReturn', label: '销售退货', source: 'runtime', tip: `销售退货(流入)。rpt.jst_returnAndRefundInfo × jst_returnAndRefundItem,按 as_date 统计,类型限定普通退货/拒收退货/换货/维修,取明细实收数量 r_qty。${FLOW_CALIBER_NOTE}` }, - { key: 'transferIn', label: '调拨入', source: 'unavailable', tip: `调拨入。${NO_SOURCE_TIP}` }, + { key: 'openingQty', label: '期初数量', source: 'snapshot', tip: `优先取聚水潭「进销存按仓导出」的官方期初;未导入时取本地快照表 daily_inventory_snapshot 中上一业务日的期末结存(每日 01:30 自动落库),快照缺失则自动用「实时库存扣流水」回推补写(回推值受业务库缺失的 8 类单据影响,与官方存在偏差)` }, + { key: 'processIn', label: '加工进仓', source: 'unavailable', tip: `加工进仓。${NO_SOURCE_TIP} ${OFFICIAL_TIP}` }, + { key: 'purchaseReturn', label: '采购退货', source: 'runtime', tip: `采购退货(流出,负数)。rpt.jst_purchaseout × jst_purchaseoutItem,按 io_date 统计。${FLOW_CALIBER_NOTE} ${OFFICIAL_TIP}` }, + { key: 'purchaseIn', label: '采购进仓', source: 'runtime', tip: `采购进仓(流入)。rpt.jst_purchase × jst_purchaseItem,按 po_date 统计,仅计 Confirmed/Finished 状态。${FLOW_CALIBER_NOTE} ${OFFICIAL_TIP}` }, + { key: 'otherIn', label: '其它进仓', source: 'unavailable', tip: `其它进仓。${NO_SOURCE_TIP} ${OFFICIAL_TIP}` }, + { key: 'otherReturn', label: '其它退货', source: 'unavailable', tip: `其它退货。${NO_SOURCE_TIP} ${OFFICIAL_TIP}` }, + { key: 'salesOut', label: '销售出仓', source: 'runtime', tip: `销售出仓(流出,负数)。rpt.jst_Orders × jst_OrderItem,按 send_date 统计,排除 Delete/Cancelled,与「库存周转天数」的发货口径一致。${FLOW_CALIBER_NOTE} ${OFFICIAL_TIP}` }, + { key: 'stocktaking', label: '盘点', source: 'unavailable', tip: `盘点。${NO_SOURCE_TIP} ${OFFICIAL_TIP}` }, + { key: 'processOut', label: '加工出仓', source: 'unavailable', tip: `加工出仓。${NO_SOURCE_TIP} ${OFFICIAL_TIP}` }, + { key: 'transferOut', label: '调拨出', source: 'unavailable', tip: `调拨出。${NO_SOURCE_TIP} ${OFFICIAL_TIP}` }, + { key: 'salesReturn', label: '销售退货', source: 'runtime', tip: `销售退货(流入)。rpt.jst_returnAndRefundInfo × jst_returnAndRefundItem,按 as_date 统计,类型限定普通退货/拒收退货/换货/维修,取明细实收数量 r_qty。${FLOW_CALIBER_NOTE} ${OFFICIAL_TIP}` }, + { key: 'transferIn', label: '调拨入', source: 'unavailable', tip: `调拨入。${NO_SOURCE_TIP} ${OFFICIAL_TIP}` }, { key: 'openingQtyExtra', label: '期初(2)', source: 'unavailable', tip: '聚水潭导出表中的第二个「期初」列,业务库无对应来源' }, - { key: 'otherOut', label: '其它出库', source: 'unavailable', tip: `其它出库。${NO_SOURCE_TIP}` }, - { key: 'closingQty', label: '期末数量', source: 'runtime', tip: '聚水潭实时库存 rpt.jst_inventoryInfo,按仓库 wms_co_id 单仓统计(存货数量不为 0 的 SKU 汇总)' }, + { key: 'otherOut', label: '其它出库', source: 'unavailable', tip: `其它出库。${NO_SOURCE_TIP} ${OFFICIAL_TIP}` }, + { key: 'closingQty', label: '期末数量', source: 'runtime', tip: `优先取聚水潭「进销存按仓导出」的官方期末;未导入时取实时库存 rpt.jst_inventoryInfo,按仓库 wms_co_id 单仓统计(存货数量不为 0 的 SKU 汇总)` }, { key: 'turnoverDays', label: '库存周转天数', source: 'runtime', tip: '月度发货总件数 / 月度日均库存件数 × 100%。月度发货总件数=近 30 天已发货订单件数(jst_OrderItem×jst_Orders,按 send_date,排除 Delete/Cancelled);月度日均库存件数=该仓实时库存(无历史结存表,以当日库存近似)' }, { key: 'warnLevel', label: '滞销预警级库存量', source: 'runtime', tip: 'SKU 级:库龄 < 3 个月 且 周转率 30%~199% 的库存合计' }, { key: 'slowLevel', label: '商品慢流级库存量', source: 'runtime', tip: 'SKU 级:库龄 < 3 个月且周转率 ≥ 200%,或库龄 > 3 个月且周转率 30%~99% 的库存合计' }, @@ -44,15 +45,19 @@ export const DAILY_INVENTORY_COLUMNS = [ /** 口径说明弹框文案 */ export const FORMULA_DESCRIPTION = [ - '数据来源说明(全部来自数据库,无人工填数):', - '· 期初数量:项目本地快照表 daily_inventory_snapshot 中「上一业务日」的期末结存。', - ' 业务库 JSReportReplica 没有按天的历史库存结存表,因此由本系统每天 01:30 把 26 仓实时库存落一份快照,', - ' 今日期初 = 昨日快照,逐日自动滚动。', - '· 流水列(12 列):其中 4 列已从业务库单据表还原 ——', + '数据来源说明(全部来自数据库或聚水潭官方导出,无人工填数):', + '· 聚水潭官方进销存(优先):把聚水潭「进销存按仓导出」的 xlsx 在页面右上角导入后,', + ' 该业务日的期初、12 个流水列、期末直接采用官方值,恒等式「期初 + 流水 = 期末」逐仓闭合。', + ' 业务库 JSReportReplica 只能还原 4/12 个流水列,且口径与聚水潭不一致(采购按单据日期 vs 实际入库、', + ' 销售按订单发货日期 vs 出库单、jst_salesOutboundInfo.qty 全为 0),调拨 / 盘点 / 加工 / 其它出入库更没有对应单据表,', + ' 因此未导入官方数据时这些列显示「—」或为业务库近似值(单元格标灰)。', + '· 期初数量:官方期初优先;未导入时取本地快照表 daily_inventory_snapshot 中「上一业务日」的期末结存。', + ' 业务库 JSReportReplica 没有按天的历史库存结存表,因此由本系统每天 01:30 把各仓实时库存落一份快照,', + ' 今日期初 = 昨日快照,逐日自动滚动;导入官方数据时会同步回写快照,让后续推算也对齐聚水潭口径。', + '· 流水列(12 列):官方数据优先(导入后 12 列全部出数);未导入时业务库可还原 4 列 ——', ' 采购进仓(jst_purchase×Item,po_date,Confirmed/Finished)、采购退货(jst_purchaseout×Item,io_date)、', ' 销售出仓(jst_Orders×OrderItem,send_date,与周转率发货口径一致)、销售退货(jst_returnAndRefund×Item,as_date,实收 r_qty)。', - ' 其余 8 列(加工进仓 / 其它进仓 / 其它退货 / 盘点 / 加工出仓 / 调拨出 / 调拨入 / 其它出库)业务库无单据表,显示「—」,', - ' 已整理单据表需求给 DBA,补齐后自动接入。因此「期初 + 流水」与「期末」暂不相等,差值即缺失单据的影响。', + ' 其余 8 列(加工进仓 / 其它进仓 / 其它退货 / 盘点 / 加工出仓 / 调拨出 / 调拨入 / 其它出库)业务库无单据表,显示「—」。', '· 期末数量:运行时取聚水潭实时库存 rpt.jst_inventoryInfo(按 wms_co_id 单仓),随刷新滚动。', '· 库存周转天数 / 四级库存量:运行时计算,库龄基准时刻为快照日次日 01:00。', '', diff --git a/frontend/src/pages/DailyInventory/index.jsx b/frontend/src/pages/DailyInventory/index.jsx index 7dbe5c2..3330391 100644 --- a/frontend/src/pages/DailyInventory/index.jsx +++ b/frontend/src/pages/DailyInventory/index.jsx @@ -1,11 +1,14 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { - Alert, Button, Input, Modal, Space, Spin, Switch, Table, Tag, TimePicker, Tooltip, Typography, + Alert, Button, DatePicker, Input, Modal, Select, Space, Spin, Switch, Table, Tag, TimePicker, + Tooltip, Typography, Upload, } from 'antd'; -import { BellOutlined, InfoCircleOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'; +import { + BellOutlined, InfoCircleOutlined, PlusOutlined, ReloadOutlined, UploadOutlined, +} from '@ant-design/icons'; import dayjs from 'dayjs'; import { SycmPage } from '../../components/Common'; -import { getDailyInventory } from '../../api/dailyInventory'; +import { getDailyInventory, importDailyInventoryOfficial } from '../../api/dailyInventory'; import { getDailyInventoryNotificationConfig, runDailyInventoryNotificationTest, @@ -92,11 +95,22 @@ const DailyInventory = () => { const [recipientDraft, setRecipientDraft] = useState(''); const [notifyForm, setNotifyForm] = useState({ enabled: false, notifyTime: '01:00', recipients: [] }); + // ── 业务日切换 + 聚水潭官方进销存导入 ── + const [selectedDate, setSelectedDate] = useState(''); // 空 = 业务库最新业务日 + const [importOpen, setImportOpen] = useState(false); + const [importDate, setImportDate] = useState(null); + const [importFile, setImportFile] = useState(null); + const [importing, setImporting] = useState(false); + const [importResult, setImportResult] = useState(null); + const load = useCallback(async (force = false) => { setLoading(true); setError(''); try { - const res = await getDailyInventory(force ? { refresh: '1' } : {}); + const params = {}; + if (selectedDate) params.date = selectedDate; + if (force) params.refresh = '1'; + const res = await getDailyInventory(params); setData(res?.data ?? null); } catch (err) { setError(err?.response?.data?.error || err?.message || '每日进销存数据加载失败'); @@ -104,42 +118,77 @@ const DailyInventory = () => { } finally { setLoading(false); } - }, []); + }, [selectedDate]); useEffect(() => { load(); }, [load]); const items = useMemo(() => data?.items ?? [], [data]); + const officialFlowKeys = useMemo(() => new Set(data?.flowColumnsOfficial || []), [data]); + const columns = useMemo(() => DAILY_INVENTORY_COLUMNS.map((col) => { const isRuntime = col.source === COLUMN_SOURCE.runtime; const isUnavailable = col.source === COLUMN_SOURCE.unavailable; + // 该列已导入聚水潭官方数据时,不再按「无数据来源」置灰 + const noSource = isUnavailable && !officialFlowKeys.has(col.key); return { title: {col.label}, dataIndex: col.key, key: col.key, align: col.key === 'warehouse' ? 'left' : 'right', - width: col.key === 'warehouse' ? 240 : 130, + width: col.key === 'warehouse' ? 260 : 130, fixed: col.key === 'warehouse' ? 'left' : undefined, className: isRuntime ? 'daily-inventory-col--runtime' - : (isUnavailable ? 'daily-inventory-col--unavailable' : undefined), + : (noSource ? 'daily-inventory-col--unavailable' : undefined), render: (value, record) => { if (record?.isTotal) { // 合计行:四级库存量累加,周转率按「总发货 / 总库存」重算(后端已给出) if (col.key === 'warehouse') return {value}; - // 流水列暂无数据来源时,合计行同样显示「—」,避免误读为 0 - if (isUnavailable) return —; + // 该列整体无数据来源时,合计行同样显示「—」,避免误读为 0 + if (value === null || value === undefined || value === '') { + return —; + } if (col.key === 'turnoverDays') return percent(value); return int(value); } - if (col.key === 'warehouse') return {value}; - if (col.key === 'closingQty') return int(value); + if (col.key === 'warehouse') { + return ( + + {value} + {record?.source?.hasOfficial ? ( + + 官方 + + ) : ( + + 推算 + + )} + + ); + } + if (value === null || value === undefined || value === '') { + return —; + } + // 数据来源标记:官方值正常显示,业务库推算值标灰提示口径差异 + const cellSource = col.key === 'openingQty' ? record?.source?.opening + : col.key === 'closingQty' ? record?.source?.closing + : record?.source?.flows?.[col.key]; + const wrap = (node) => (cellSource === 'derived' ? ( + + {node} + + ) : node); + + if (col.key === 'openingQty' || col.key === 'openingQtyExtra' || col.key === 'closingQty') { + return wrap(int(value)); + } if (col.key === 'turnoverDays') return percent(value); - if (isUnavailable) return —; - return renderSigned(value); + return wrap(renderSigned(value)); }, }; - }), []); + }), [officialFlowKeys]); const tableRows = useMemo(() => { if (!data?.totals) return items; @@ -148,6 +197,66 @@ const DailyInventory = () => { const noGradeCount = useMemo(() => items.filter((it) => !it.hasGrade).length, [items]); + /** 业务日下拉:最新业务日 + 已导入聚水潭官方数据的日期 */ + const dateOptions = useMemo(() => { + const opts = [{ + value: '__latest__', + label: `最新业务日${data?.systemBizDate ? `(${data.systemBizDate})` : ''}`, + }]; + (data?.official?.availableDates || []) + .filter((d) => d !== data?.systemBizDate) + .forEach((d) => opts.push({ value: d, label: `${d}(聚水潭官方)` })); + return opts; + }, [data]); + + const openImport = useCallback(() => { + setImportResult(null); + setImportFile(null); + setImportDate(data?.latestBizDate ? dayjs(data.latestBizDate) : dayjs()); + setImportOpen(true); + }, [data]); + + const submitImport = useCallback(async () => { + if (!importFile) { + setImportResult({ type: 'error', text: '请先选择聚水潭导出的 xlsx 文件' }); + return; + } + if (!importDate) { + setImportResult({ type: 'error', text: '请选择业务日期' }); + return; + } + setImporting(true); + setImportResult(null); + try { + const res = await importDailyInventoryOfficial({ + file: importFile, + date: importDate.format('YYYY-MM-DD'), + }); + const result = res?.data ?? {}; + const bizDate = result.bizDate || importDate.format('YYYY-MM-DD'); + const notes = [ + ...(result.warnings || []), + ...(result.unknownWarehouses || []).map((w) => `未匹配到仓库 ID,已按仓库名存档:${w}`), + ...(result.mismatches || []).map((m) => `${m.warehouse}:期初 + 流水 − 期末 = ${m.diff}`), + ]; + setImportResult({ + type: 'success', + text: `${bizDate} 已导入 ${result.imported || 0} 个仓库${result.mismatches?.length ? ',存在恒等式不闭合的仓库' : ',恒等式逐仓闭合'}`, + notes, + }); + if (selectedDate === bizDate) await load(true); + else setSelectedDate(bizDate); + } catch (err) { + setImportResult({ + type: 'error', + text: err?.response?.data?.error || err?.message || '导入失败', + notes: err?.response?.data?.detail ? [err.response.data.detail] : [], + }); + } finally { + setImporting(false); + } + }, [importFile, importDate, selectedDate, load]); + // ── 通知设置:读取 / 保存 / 立即测试 ── const applyNotifyConfig = useCallback((config = {}) => { setNotifyConfig(config); @@ -270,7 +379,20 @@ const DailyInventory = () => { ) : null} - + + +