业务库只能还原 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>
185 lines
7.3 KiB
JavaScript
185 lines
7.3 KiB
JavaScript
/**
|
||
* 每日进销存 —— 聚水潭「官方进销存导出」校正数据
|
||
*
|
||
* 背景:业务库(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,
|
||
};
|