feat: 每日进销存支持导入聚水潭官方进销存校正数据
业务库只能还原 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>
This commit is contained in:
parent
2d8ef49c12
commit
0e77a70c33
@ -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": {
|
||||
|
||||
97
backend/scripts/importDailyInventoryOfficial.js
Normal file
97
backend/scripts/importDailyInventoryOfficial.js
Normal file
@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 导入聚水潭「进销存按仓导出」到每日进销存校正表
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/importDailyInventoryOfficial.js <业务日期> <xlsx 路径>
|
||||
* 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> <xlsx 路径>');
|
||||
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);
|
||||
});
|
||||
@ -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;
|
||||
|
||||
@ -101,6 +101,11 @@ const DAILY_INVENTORY_BASELINE = [
|
||||
"dbWarehouse": "简尚-横林二仓",
|
||||
"wmsCoId": 15033302
|
||||
},
|
||||
{
|
||||
"warehouse": "简尚-横林三仓主仓",
|
||||
"dbWarehouse": "简尚-横林三仓",
|
||||
"wmsCoId": 15043561
|
||||
},
|
||||
{
|
||||
"warehouse": "简尚-山东品牌仓主仓",
|
||||
"dbWarehouse": "简尚-山东品牌仓",
|
||||
|
||||
184
backend/src/config/dailyInventoryOfficialDb.js
Normal file
184
backend/src/config/dailyInventoryOfficialDb.js
Normal file
@ -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,
|
||||
};
|
||||
137
backend/src/services/dailyInventoryOfficial.js
Normal file
137
backend/src/services/dailyInventoryOfficial.js
Normal file
@ -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<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,
|
||||
};
|
||||
101
backend/test/dailyInventoryOfficial.test.js
Normal file
101
backend/test/dailyInventoryOfficial.test.js
Normal file
@ -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);
|
||||
});
|
||||
@ -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 } }
|
||||
);
|
||||
|
||||
@ -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。',
|
||||
'',
|
||||
|
||||
@ -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: <TipTitle tip={col.tip}>{col.label}</TipTitle>,
|
||||
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 <span className="daily-inventory-warehouse">{value}</span>;
|
||||
// 流水列暂无数据来源时,合计行同样显示「—」,避免误读为 0
|
||||
if (isUnavailable) return <span className="daily-inventory-cell--na">—</span>;
|
||||
// 该列整体无数据来源时,合计行同样显示「—」,避免误读为 0
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return <span className="daily-inventory-cell--na">—</span>;
|
||||
}
|
||||
if (col.key === 'turnoverDays') return percent(value);
|
||||
return int(value);
|
||||
}
|
||||
if (col.key === 'warehouse') return <span className="daily-inventory-warehouse">{value}</span>;
|
||||
if (col.key === 'closingQty') return int(value);
|
||||
if (col.key === 'warehouse') {
|
||||
return (
|
||||
<span className="daily-inventory-warehouse">
|
||||
{value}
|
||||
{record?.source?.hasOfficial ? (
|
||||
<Tooltip title="该仓已导入聚水潭官方进销存,期初/流水/期末均为官方值">
|
||||
<Tag color="blue" className="daily-inventory-tag">官方</Tag>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title="该仓未导入聚水潭官方进销存,数值为业务库推算(流水列可能缺 8 类单据)">
|
||||
<Tag className="daily-inventory-tag">推算</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return <span className="daily-inventory-cell--na">—</span>;
|
||||
}
|
||||
// 数据来源标记:官方值正常显示,业务库推算值标灰提示口径差异
|
||||
const cellSource = col.key === 'openingQty' ? record?.source?.opening
|
||||
: col.key === 'closingQty' ? record?.source?.closing
|
||||
: record?.source?.flows?.[col.key];
|
||||
const wrap = (node) => (cellSource === 'derived' ? (
|
||||
<Tooltip title="业务库推算值,口径与聚水潭进销存存在差异;导入该业务日的聚水潭导出后自动切换为官方值">
|
||||
<span className="daily-inventory-cell--derived">{node}</span>
|
||||
</Tooltip>
|
||||
) : 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 <span className="daily-inventory-cell--na">—</span>;
|
||||
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 = () => {
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<Space>
|
||||
<Space wrap>
|
||||
<Tooltip title="切换业务日:导入过聚水潭进销存导出的日期会以官方值显示">
|
||||
<Select
|
||||
style={{ width: 210 }}
|
||||
value={selectedDate || '__latest__'}
|
||||
onChange={(v) => setSelectedDate(v === '__latest__' ? '' : v)}
|
||||
options={dateOptions}
|
||||
/>
|
||||
</Tooltip>
|
||||
{isAdmin ? (
|
||||
<Tooltip title="上传聚水潭「进销存按仓导出」xlsx,按业务日校正期初 / 12 个流水列 / 期末">
|
||||
<Button icon={<UploadOutlined />} onClick={openImport}>导入聚水潭进销存</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{isAdmin ? (
|
||||
<Button icon={<BellOutlined />} onClick={() => setNotifyOpen(true)}>通知设置</Button>
|
||||
) : null}
|
||||
@ -282,16 +404,31 @@ const DailyInventory = () => {
|
||||
</div>
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
type={data?.official?.hasData ? 'success' : 'info'}
|
||||
showIcon
|
||||
message="全部数据来自数据库,流水列已接入 4/12"
|
||||
message={data?.official?.hasData
|
||||
? `已按聚水潭官方进销存校正(${data.latestBizDate}):12 个流水列全部为官方值`
|
||||
: '尚未导入聚水潭官方进销存:流水列为业务库推算值'}
|
||||
description={
|
||||
<span>
|
||||
期初数量取本地快照表中 <b>{data?.openingDate || '—'}</b> 的期末结存(每日 01:30 自动落库,逐日滚动;快照缺失时自动用「实时库存扣流水」回推补写);
|
||||
期末数量与右侧 5 列为运行时从业务库实时取数。
|
||||
流水列中采购进仓/采购退货/销售出仓/销售退货已从业务库单据表取数;
|
||||
其余 8 列(加工/其它/盘点/调拨)因业务库缺单据表仍显示「—」,已向 DBA 提交补表需求,因此「期初+流水」与「期末」暂不相等。
|
||||
{data?.noOpeningCount > 0 ? ` 其中 ${data.noOpeningCount} 个仓尚无 ${data.openingDate} 的期初快照(新纳入的仓库从次日起自动补齐)。` : ''}
|
||||
{data?.official?.hasData ? (
|
||||
<>
|
||||
本页 <b>{data.latestBizDate}</b> 的期初、12 个流水列、期末取自聚水潭「进销存按仓导出」,
|
||||
共 <b>{data.official.warehouseCount}</b> 个仓;
|
||||
{data.official.balanceMismatch?.length
|
||||
? `其中 ${data.official.balanceMismatch.length} 个仓「期初 + 流水 ≠ 期末」,请核对导出文件。`
|
||||
: '「期初 + 流水 = 期末」逐仓闭合。'}
|
||||
{' '}未纳入该导出的仓库仍用业务库推算(仓库列标记为「推算」)。
|
||||
可在右上角切换业务日或重新导入。
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
业务库只能还原 4/12 个流水列,且口径与聚水潭进销存不一致(采购按单据日期、销售按订单发货日期),
|
||||
调拨/盘点/加工/其它出入库没有对应单据表。
|
||||
请在右上角「导入聚水潭进销存」上传导出文件,导入后该业务日的期初与 12 个流水列即为官方值。
|
||||
</>
|
||||
)}
|
||||
{data?.staleMetrics ? ` 当前查看的是历史业务日 ${data.latestBizDate},右侧周转率与四级库存量为当前实时口径。` : ''}
|
||||
{noGradeCount > 0 ? ` 其中 ${noGradeCount} 个仓在聚水潭库存表中无有效库存,周转率与四级库存量显示「—」。` : ''}
|
||||
</span>
|
||||
}
|
||||
@ -471,6 +608,60 @@ const DailyInventory = () => {
|
||||
</Spin>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="导入聚水潭进销存(按仓)"
|
||||
open={importOpen}
|
||||
onCancel={() => setImportOpen(false)}
|
||||
width={620}
|
||||
destroyOnHidden
|
||||
footer={(
|
||||
<Space wrap>
|
||||
<Button type="primary" loading={importing} onClick={submitImport}>开始导入</Button>
|
||||
<Button onClick={() => setImportOpen(false)}>关闭</Button>
|
||||
</Space>
|
||||
)}
|
||||
>
|
||||
<div className="daily-inventory-import">
|
||||
<div className="daily-inventory-import-row">
|
||||
<span className="daily-inventory-import-label">业务日期</span>
|
||||
<DatePicker
|
||||
value={importDate}
|
||||
onChange={(v) => setImportDate(v)}
|
||||
allowClear={false}
|
||||
style={{ width: 180 }}
|
||||
/>
|
||||
<Text type="secondary">导出文件对应的进销存日期</Text>
|
||||
</div>
|
||||
<div className="daily-inventory-import-row">
|
||||
<span className="daily-inventory-import-label">导出文件</span>
|
||||
<Upload
|
||||
accept=".xlsx,.xls"
|
||||
maxCount={1}
|
||||
fileList={importFile ? [{ uid: 'import', name: importFile.name, status: 'done' }] : []}
|
||||
beforeUpload={(file) => { setImportFile(file); return false; }}
|
||||
onRemove={() => { setImportFile(null); return true; }}
|
||||
>
|
||||
<Button icon={<UploadOutlined />}>选择文件</Button>
|
||||
</Upload>
|
||||
</div>
|
||||
<Text type="secondary">
|
||||
需包含「仓库 / 期初数量 / 12 个流水列 / 期末数量」表头;同一业务日重复导入会整体覆盖。
|
||||
导入后会自动回写本地库存快照,使「今日期初 = 昨日结存」的推算也对齐聚水潭口径。
|
||||
</Text>
|
||||
{importResult ? (
|
||||
<Alert
|
||||
type={importResult.type}
|
||||
showIcon
|
||||
style={{ marginTop: 12 }}
|
||||
message={importResult.text}
|
||||
description={importResult.notes?.length
|
||||
? <span>{importResult.notes.join(';')}</span>
|
||||
: null}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="计算口径说明"
|
||||
open={helpOpen}
|
||||
|
||||
@ -78,6 +78,37 @@
|
||||
color: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* 业务库推算值:标灰 + 斜体,提示与聚水潭官方口径存在差异 */
|
||||
.daily-inventory-cell--derived {
|
||||
color: rgba(0, 0, 0, 0.42);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 仓库名后的来源标签 */
|
||||
.daily-inventory-tag {
|
||||
margin-left: 6px;
|
||||
transform: scale(0.85);
|
||||
transform-origin: left center;
|
||||
}
|
||||
|
||||
/* 导入聚水潭进销存弹窗 */
|
||||
.daily-inventory-import {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.daily-inventory-import-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.daily-inventory-import-label {
|
||||
width: 72px;
|
||||
color: rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
|
||||
.daily-inventory-row--total > td {
|
||||
font-weight: 600;
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user