本次将此前被列入退役名单的运营分析相关模块恢复为可用,并新增「模块化库存监控」
一级菜单及其两个子页面。
主要变更:
1. 恢复启用:商品监控、激励看板、营销活动、推广模块、商品分析
- retiredFeatures 移出对应退役名单与清表语句,改为正常保留
- 补齐后端路由、前端页面与 API 封装、侧边栏配置、桌面客户端能力项
- 同步更新 retiredFeatures / retiredRoutes / retiredNavigation / desktopSessionService 测试
2. 新增「模块化库存监控」一级菜单
- 配件库存监控:仅取「简尚-崔桥工厂半成品包装项目仓」SKU 的实时库存、
当日入库/出库、近 7 日日均发货量、库存可用天数与预警(库存可用天数 ≤ 7 预警)
- MKH-BOM资料:按商品编码展示配件明细、数量、仓位、库存,支持 A4 纵向打印
3. 新增产品资料系统数据库(PostgreSQL)连接模块:读取系统库 database_configs 中
name = product_information_database 的配置,凭据只从配置读取,不落代码
4. 新增系统设置页配置项:商品标签配置、部门商品标签配置、部门SKU负责人配置
5. 旧 /api/dashboard 接口迁移至 /api/productAnalysis,仪表盘预览页改用新接口,
相应移除 dashboard 路由模块及其测试
6. 修复 retiredRoutes 测试在 Windows 下挂起超时的问题:该测试用
parent.filename.endsWith('/api/planningUpload.js') 判断是否注入 mock,
而 Windows 的 filename 为反斜杠路径,导致判断恒为 false、mock 全部失效、
真实模块去连中央系统库而挂起(Linux CI 正常)。改为平台无关的路径匹配。
7. .gitignore 忽略本地验证产物(outputs/、.git-status.txt)
验证:
- 前端:lint / test(16 项)/ build(含 planning entry 校验)全部通过
- 后端:语法检查 149 个文件通过;测试 38/39 通过
- 已知未通过项:resilientSecretStore.test.js 断言密钥备份文件权限为 0o600,
Windows 下 chmod 无法表达该权限(实际为 0o666),属既有的平台相关失败,
与本次改动无关,本次未修改该文件
- 说明:本机 frontend/ 下存在未纳入版本库的旧依赖备份目录 node_modules_old_*,
会污染 ESLint 扫描(十余万条第三方报错),故 lint 步骤以
--ignore-pattern node_modules_old_*/** 排除后执行,其余步骤与仓库 check 一致
未提交内容:本地验证截图 outputs/ 与 .git-status.txt 已被 .gitignore 排除。
514 lines
20 KiB
JavaScript
514 lines
20 KiB
JavaScript
'use strict';
|
||
|
||
// 模块化库存监控 —— 配件库存监控
|
||
//
|
||
// 需求来源:《模块化库存监控.xlsx》
|
||
// 一级功能:模块化库存监控 二级功能:配件库存监控
|
||
// 表头:图片 | 预警 | SKU编码 | 名称 | 当日入库库存 | 当日出库库存 | 实时库存 |
|
||
// 仓位 | 近7日日均发货量 | 库存可用天数 | 所在仓库
|
||
// 数据范围:仅取「简尚-崔桥工厂半成品包装项目仓」仓库下的 SKU 编码
|
||
//
|
||
// 数据来源(业务库 JSReportReplica / schema=rpt,2026-09-12 实测确认):
|
||
// 主表 rpt.jst_sku —— SKU 档案;other_3 = 所属仓库
|
||
// 目标仓库共 794 个 SKU
|
||
// 库存 rpt.jst_inventoryInfo —— 实时库存 qty;按 wms_co_id 区分仓库
|
||
// 目标仓库 wms_co_id = 15718196(rpt.jst_warehouseInfo 查得)
|
||
// 监控 rpt.pm_stockMonitor —— 日更快照,目标仓每天 794 行,覆盖全部 SKU:
|
||
// sevenAvgSale 近7日日均发货量
|
||
// jsUseDays 库存可用天数
|
||
// safetyDays 安全天数(默认 15)
|
||
// suggestStock 建议备货量
|
||
// jsStock 监控表口径库存(作为 inventoryInfo 的兜底)
|
||
// nearSecondSale 近一日发货量 → 当日出库
|
||
//
|
||
// ⚠️ 关联键坑(2026-09-12 修正):
|
||
// pm_stockMonitor 里同时有 sku 与 sku_id 两列:
|
||
// sku = 短款号,如 DNZ040967 —— 用它关联只能命中 34 / 794
|
||
// sku_id = 完整 SKU 编码 DNZ040967N1440V01 —— 必须用它关联,命中 794 / 794
|
||
// 当日入库:业务库无出入库流水表,用采购到货 rpt.jst_purchaseItem 按「监控日」汇总 inQty
|
||
// (全库 207 张视图均无库存流水表;dws_sn_sales_full_info_di 是成品销售明细,只命中 97 / 794)
|
||
//
|
||
// ⚠️ 业务库确实无数据源的字段:
|
||
// - 仓位 location:rpt.jst_sku.bin / other_bin 全库 557,475 行填充数为 0
|
||
|
||
const cloudDatabase = require('../config/cloudDatabase');
|
||
const productDb = require('../config/productInformationDatabase');
|
||
|
||
const WAREHOUSE_NAME = '简尚-崔桥工厂半成品包装项目仓';
|
||
// rpt.jst_warehouseInfo 中「简尚-崔桥工厂半成品包装项目仓」对应的 wms_co_id
|
||
const WAREHOUSE_WMS_CO_ID = 15718196;
|
||
|
||
const DEFAULT_PAGE_SIZE = 50;
|
||
const MAX_PAGE_SIZE = 200;
|
||
const DEFAULT_SAFETY_DAYS = 15;
|
||
// 预警阈值:库存可用天数 <= 7 天即预警
|
||
const LOW_STOCK_DAYS_THRESHOLD = 7;
|
||
|
||
const WARNING_LEVEL = Object.freeze({
|
||
OUT_OF_STOCK: '缺货',
|
||
WARNING: '预警',
|
||
NORMAL: '正常',
|
||
});
|
||
|
||
// 模块化配置状态:
|
||
// 「已配置MKH」= 该 SKU 已在「产品资料系统数据库」中建立商品档案
|
||
// (external.product_catalog_wide_base_v1 按 barcode 匹配)
|
||
// 「未配置MKH」= 产品资料系统数据库中查不到该 SKU 档案
|
||
//
|
||
// 口径变更说明(2026-09-15,按业务确认):
|
||
// 原先用业务库 jst_comboProductItem.src_sku_id 判定「是否被 MKH 组合 BOM 引用」,
|
||
// 实测目标仓 794 个 SKU 中只有 32 个命中——因为目标仓(半成品包装仓)与
|
||
// MKH 组合 BOM 的配件(159 个 SKU / 14 个款号)是两批基本不重叠的编码集合,
|
||
// 按款号(SPU)放宽也只有 3 个款号重叠。
|
||
// 业务确认改用「产品资料系统数据库是否有商品档案」作为判定口径,
|
||
// 实测目标仓 661/794 命中。
|
||
const MKH_STATUS = Object.freeze({
|
||
CONFIGURED: '已配置MKH',
|
||
UNCONFIGURED: '未配置MKH',
|
||
});
|
||
|
||
// 产品资料覆盖度缓存:避免每次列表请求都回查产品资料系统数据库
|
||
const PRODUCT_COVERAGE_TTL_MS = 10 * 60 * 1000;
|
||
let productCoverageCache = null;
|
||
|
||
const SORTABLE_FIELDS = Object.freeze({
|
||
skuId: 's.sku_id',
|
||
productName: 's.name',
|
||
category: 's.category',
|
||
realtimeStock: 'realtimeStock',
|
||
avgShip7d: 'm.sevenAvgSale',
|
||
stockDays: 'm.jsUseDays',
|
||
warningLevel: 'warningOrder',
|
||
mkhStatus: 'mkhStatus',
|
||
location: 'location',
|
||
warehouse: 's.other_3',
|
||
});
|
||
|
||
/**
|
||
* 「已配置MKH」判定表达式。
|
||
* available = true —— 用产品资料库覆盖清单(@productCoveredCsv,逗号分隔的 SKU 编码)
|
||
* available = false —— 产品资料库不可用时回退到业务库 MKH BOM 判定,保证页面可用
|
||
*/
|
||
const buildConfiguredExistsSql = (coverage = {}) => (
|
||
coverage.available
|
||
? `EXISTS (
|
||
SELECT 1 FROM STRING_SPLIT(@productCoveredCsv, N',') pc WHERE pc.value = s.sku_id
|
||
)`
|
||
: `EXISTS (
|
||
SELECT 1 FROM JSDataMiddlePlatform.dbo.jst_comboProductItem x WHERE x.src_sku_id = s.sku_id
|
||
)`
|
||
);
|
||
|
||
const buildBaseSql = (coverage = {}) => `
|
||
FROM dbo.jst_sku s
|
||
OUTER APPLY (
|
||
SELECT TOP 1 i.qty
|
||
FROM dbo.jst_inventoryInfo i
|
||
WHERE i.sku_id = s.sku_id AND i.wms_co_id = @wmsCoId
|
||
ORDER BY i.updateTime DESC
|
||
) inv
|
||
OUTER APPLY (
|
||
SELECT TOP 1 m2.sevenAvgSale, m2.jsUseDays, m2.safetyDays, m2.suggestStock,
|
||
m2.date, m2.jsStock, m2.nearSecondSale
|
||
FROM dbo.pm_stockMonitor m2
|
||
WHERE m2.sku_id = s.sku_id AND m2.warehouse = @warehouseName
|
||
ORDER BY m2.date DESC
|
||
) m
|
||
OUTER APPLY (
|
||
SELECT CAST(SUM(ISNULL(p.inQty, 0)) AS DECIMAL(18, 2)) AS inQty
|
||
FROM dbo.jst_purchaseItem p
|
||
WHERE p.sku_id = s.sku_id
|
||
AND p.delivery_date >= @monitorDate
|
||
AND p.delivery_date < DATEADD(DAY, 1, @monitorDate)
|
||
) pin
|
||
OUTER APPLY (
|
||
SELECT TOP 1 NULLIF(LTRIM(RTRIM(c.pictureSkucode)), N'') AS pictureSkucode
|
||
FROM dbo.fys_skuCostPriceUpdate c
|
||
WHERE UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), c.sku)))) = UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), s.sku_id))))
|
||
ORDER BY c.createTime DESC
|
||
) cost
|
||
-- 模块化配置状态:见文件头部「口径变更说明」
|
||
OUTER APPLY (
|
||
SELECT CAST(CASE WHEN ${buildConfiguredExistsSql(coverage)} THEN 1 ELSE 0 END AS BIT) AS configured
|
||
) cfg
|
||
WHERE s.other_3 = @warehouseName`;
|
||
|
||
const toNumber = (value) => {
|
||
if (value === null || value === undefined || value === '') return null;
|
||
const parsed = Number(value);
|
||
return Number.isFinite(parsed) ? parsed : null;
|
||
};
|
||
|
||
const toText = (value) => {
|
||
if (value === null || value === undefined) return null;
|
||
const text = String(value).trim();
|
||
return text === '' ? null : text;
|
||
};
|
||
|
||
const normalizeWarningLevel = (realtimeStock, stockDays) => {
|
||
const stock = Number(realtimeStock || 0);
|
||
if (stock <= 0) return WARNING_LEVEL.OUT_OF_STOCK;
|
||
|
||
const days = toNumber(stockDays);
|
||
if (days !== null && days <= LOW_STOCK_DAYS_THRESHOLD) return WARNING_LEVEL.WARNING;
|
||
|
||
return WARNING_LEVEL.NORMAL;
|
||
};
|
||
|
||
const WARNING_ORDER = {
|
||
[WARNING_LEVEL.OUT_OF_STOCK]: 0,
|
||
[WARNING_LEVEL.WARNING]: 1,
|
||
[WARNING_LEVEL.NORMAL]: 2,
|
||
};
|
||
|
||
/**
|
||
* 取目标仓最新监控日(pm_stockMonitor 快照日),作为「当日入库 / 当日出库」的统计基准日。
|
||
* 业务库没有出入库流水表,只能以监控日为基准做按日汇总。
|
||
*/
|
||
async function resolveMonitorDate() {
|
||
const rows = await cloudDatabase.query(
|
||
`SELECT CAST(MAX(date) AS DATE) AS monitorDate
|
||
FROM dbo.pm_stockMonitor
|
||
WHERE warehouse = @warehouseName`,
|
||
{ warehouseName: WAREHOUSE_NAME },
|
||
{ requestTimeout: 120000 }
|
||
);
|
||
|
||
const raw = rows?.[0]?.monitorDate;
|
||
if (!raw) return null;
|
||
const d = new Date(raw);
|
||
return Number.isNaN(d.getTime()) ? null : d.toISOString().slice(0, 10);
|
||
}
|
||
|
||
const buildFilters = (query = {}, monitorDate = null, coverage = {}) => {
|
||
const conditions = [];
|
||
const params = {
|
||
wmsCoId: WAREHOUSE_WMS_CO_ID,
|
||
warehouseName: WAREHOUSE_NAME,
|
||
monitorDate,
|
||
};
|
||
|
||
// 「已配置MKH」判定依赖产品资料库覆盖清单(逗号分隔的 SKU 编码)
|
||
if (coverage.available) {
|
||
params.productCoveredCsv = coverage.csv || '';
|
||
}
|
||
|
||
const keyword = toText(query.keyword);
|
||
if (keyword) {
|
||
params.keyword = `%${keyword}%`;
|
||
conditions.push('(s.sku_id LIKE @keyword OR s.name LIKE @keyword)');
|
||
}
|
||
|
||
const category = toText(query.category);
|
||
if (category) {
|
||
params.category = category;
|
||
conditions.push('s.category = @category');
|
||
}
|
||
|
||
const location = toText(query.location);
|
||
if (location) {
|
||
params.location = location;
|
||
conditions.push(`COALESCE(NULLIF(LTRIM(RTRIM(s.bin)), N''), NULLIF(LTRIM(RTRIM(s.other_bin)), N'')) = @location`);
|
||
}
|
||
|
||
const mkhStatus = toText(query.mkhStatus);
|
||
if (mkhStatus === MKH_STATUS.CONFIGURED || mkhStatus === MKH_STATUS.UNCONFIGURED) {
|
||
params.mkhStatus = mkhStatus;
|
||
conditions.push(`CASE WHEN cfg.configured = 1 THEN N'${MKH_STATUS.CONFIGURED}' ELSE N'${MKH_STATUS.UNCONFIGURED}' END = @mkhStatus`);
|
||
}
|
||
|
||
const warningLevel = toText(query.warningLevel);
|
||
if (warningLevel && Object.prototype.hasOwnProperty.call(WARNING_ORDER, warningLevel)) {
|
||
params.warningOrder = WARNING_ORDER[warningLevel];
|
||
conditions.push(`
|
||
CASE
|
||
WHEN COALESCE(inv.qty, m.jsStock, 0) <= 0 THEN 0
|
||
WHEN m.jsUseDays IS NOT NULL AND m.jsUseDays <= ${LOW_STOCK_DAYS_THRESHOLD} THEN 1
|
||
ELSE 2
|
||
END = @warningOrder`);
|
||
}
|
||
|
||
const onlyWarning = String(query.onlyWarning || '').toLowerCase();
|
||
if (['1', 'true', 'yes'].includes(onlyWarning)) {
|
||
conditions.push(`(
|
||
COALESCE(inv.qty, m.jsStock, 0) <= 0
|
||
OR (m.jsUseDays IS NOT NULL AND m.jsUseDays <= ${LOW_STOCK_DAYS_THRESHOLD})
|
||
)`);
|
||
}
|
||
|
||
return { conditions, params };
|
||
};
|
||
|
||
/**
|
||
* 用「产品资料系统数据库」补齐商品图片。
|
||
*
|
||
* ⚠️ 2026-09-15 实测:目标仓 794 个 SKU 在业务库里图片字段全部为空——
|
||
* rpt.jst_sku.pic / pic_big 794/794 为 NULL
|
||
* rpt.fys_skuCostPriceUpdate.pictureSkucode 794/794 为 NULL
|
||
* 且 rpt.jst_sku 中只有 pic / pic_big 两个图片列,无其他图片源。
|
||
* 因此图片统一改从产品资料系统数据库(PostgreSQL)取:
|
||
* external.product_catalog_wide_base_v1
|
||
* 按 barcode(完整 SKU 编码)匹配,image_url 优先、product_thumbnail_url 兜底
|
||
* 实测目标仓 661 条可匹配,其中 481 条带图。
|
||
* 查询失败时静默降级,不影响列表主流程。
|
||
*/
|
||
async function attachProductImages(rows = []) {
|
||
const codes = Array.from(new Set(rows.map((row) => toText(row.skuId)).filter(Boolean)));
|
||
if (codes.length === 0) {
|
||
return rows;
|
||
}
|
||
|
||
const imageMap = new Map();
|
||
try {
|
||
const imageRows = await productDb.query(
|
||
`SELECT barcode, image_url, product_thumbnail_url
|
||
FROM external.product_catalog_wide_base_v1
|
||
WHERE barcode = ANY($1)`,
|
||
[codes]
|
||
);
|
||
|
||
imageRows.forEach((row) => {
|
||
const code = toText(row.barcode);
|
||
if (!code || imageMap.has(code)) return;
|
||
const url = toText(row.image_url) || toText(row.product_thumbnail_url);
|
||
if (url) imageMap.set(code, url);
|
||
});
|
||
} catch (error) {
|
||
console.warn('[ModularStock] 读取产品资料库图片失败,降级为业务库图片:', error.message);
|
||
}
|
||
|
||
return rows.map((row) => ({
|
||
...row,
|
||
productImageUrl: imageMap.get(toText(row.skuId)) || null,
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* 解析目标仓 SKU 的「产品资料库覆盖清单」,用于「已配置MKH」判定。
|
||
* 返回 { available, csv, set, total, coveredCount },结果按 TTL 缓存。
|
||
* 产品资料库不可用时 available = false,SQL 侧自动回退到业务库 MKH BOM 判定。
|
||
*/
|
||
async function resolveProductCoverage() {
|
||
const now = Date.now();
|
||
if (
|
||
productCoverageCache
|
||
&& productCoverageCache.warehouse === WAREHOUSE_NAME
|
||
&& now - productCoverageCache.at < PRODUCT_COVERAGE_TTL_MS
|
||
) {
|
||
return productCoverageCache;
|
||
}
|
||
|
||
const skuRows = await cloudDatabase.query(
|
||
`SELECT s.sku_id AS skuId FROM dbo.jst_sku s WHERE s.other_3 = @warehouseName`,
|
||
{ warehouseName: WAREHOUSE_NAME },
|
||
{ requestTimeout: 180000 }
|
||
);
|
||
const codes = skuRows.map((row) => toText(row.skuId)).filter(Boolean);
|
||
|
||
let covered = [];
|
||
let available = true;
|
||
try {
|
||
const rows = await productDb.query(
|
||
`SELECT DISTINCT barcode
|
||
FROM external.product_catalog_wide_base_v1
|
||
WHERE barcode = ANY($1)`,
|
||
[codes]
|
||
);
|
||
covered = rows.map((row) => toText(row.barcode)).filter(Boolean);
|
||
} catch (error) {
|
||
available = false;
|
||
console.warn('[ModularStock] 产品资料库覆盖清单查询失败,回退业务库 MKH BOM 判定:', error.message);
|
||
}
|
||
|
||
productCoverageCache = {
|
||
warehouse: WAREHOUSE_NAME,
|
||
available,
|
||
csv: covered.join(','),
|
||
set: new Set(covered),
|
||
total: codes.length,
|
||
coveredCount: covered.length,
|
||
at: now,
|
||
};
|
||
return productCoverageCache;
|
||
}
|
||
|
||
const mapRow = (row = {}) => {
|
||
const realtimeStock = Number(row.realtimeStock || 0);
|
||
const stockDays = toNumber(row.stockDays);
|
||
const safetyDays = toNumber(row.safetyDays) ?? DEFAULT_SAFETY_DAYS;
|
||
const warningLevel = normalizeWarningLevel(realtimeStock, stockDays);
|
||
|
||
return {
|
||
skuId: toText(row.skuId),
|
||
productName: toText(row.productName),
|
||
category: toText(row.category),
|
||
// 图片优先取产品资料系统数据库(业务库 pic/pic_big 目标仓全为空)
|
||
imageUrl: toText(row.productImageUrl) || toText(row.imageUrl),
|
||
warningLevel,
|
||
warningOrder: WARNING_ORDER[warningLevel],
|
||
mkhStatus: toText(row.mkhStatus) || MKH_STATUS.UNCONFIGURED,
|
||
// 业务库无出入库流水表:入库取采购到货(监控日),出库取 pm_stockMonitor 近一日发货量
|
||
inQtyToday: toNumber(row.inQtyToday) ?? 0,
|
||
outQtyToday: toNumber(row.outQtyToday) ?? 0,
|
||
realtimeStock,
|
||
location: toText(row.location),
|
||
avgShip7d: toNumber(row.avgShip7d),
|
||
stockDays,
|
||
safetyDays,
|
||
suggestStock: toNumber(row.suggestStock),
|
||
warehouse: toText(row.warehouse) || WAREHOUSE_NAME,
|
||
};
|
||
};
|
||
|
||
const buildProjection = () => `
|
||
s.sku_id AS skuId,
|
||
s.name AS productName,
|
||
s.category AS category,
|
||
COALESCE(inv.qty, m.jsStock, 0) AS realtimeStock,
|
||
ISNULL(pin.inQty, 0) AS inQtyToday,
|
||
ISNULL(m.nearSecondSale, 0) AS outQtyToday,
|
||
COALESCE(NULLIF(LTRIM(RTRIM(s.bin)), N''), NULLIF(LTRIM(RTRIM(s.other_bin)), N'')) AS location,
|
||
m.sevenAvgSale AS avgShip7d,
|
||
m.jsUseDays AS stockDays,
|
||
m.safetyDays AS safetyDays,
|
||
m.suggestStock AS suggestStock,
|
||
s.other_3 AS warehouse,
|
||
COALESCE(
|
||
NULLIF(LTRIM(RTRIM(s.pic_big)), N''),
|
||
NULLIF(LTRIM(RTRIM(s.pic)), N''),
|
||
cost.pictureSkucode
|
||
) AS imageUrl,
|
||
CASE WHEN cfg.configured = 1 THEN N'已配置MKH' ELSE N'未配置MKH' END AS mkhStatus,
|
||
CASE
|
||
WHEN COALESCE(inv.qty, m.jsStock, 0) <= 0 THEN 0
|
||
WHEN m.jsUseDays IS NOT NULL AND m.jsUseDays <= ${LOW_STOCK_DAYS_THRESHOLD} THEN 1
|
||
ELSE 2
|
||
END AS warningOrder,
|
||
m.date AS monitorDate`;
|
||
|
||
async function getModularStockList(query = {}) {
|
||
const page = Math.max(1, Number.parseInt(query.page, 10) || 1);
|
||
const requestedSize = Number.parseInt(query.pageSize, 10) || DEFAULT_PAGE_SIZE;
|
||
const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, requestedSize));
|
||
const [monitorDate, coverage] = await Promise.all([
|
||
resolveMonitorDate(),
|
||
resolveProductCoverage(),
|
||
]);
|
||
const { conditions, params } = buildFilters(query, monitorDate, coverage);
|
||
const baseSql = buildBaseSql(coverage);
|
||
const whereSql = conditions.length ? `${baseSql} AND ${conditions.join(' AND ')}` : baseSql;
|
||
|
||
const sortField = SORTABLE_FIELDS[query.sortField] ? query.sortField : 'realtimeStock';
|
||
const sortOrder = String(query.sortOrder || 'DESC').toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
|
||
const orderSql = `${SORTABLE_FIELDS[sortField]} ${sortOrder}`;
|
||
|
||
const offset = (page - 1) * pageSize;
|
||
|
||
// 已按 s.sku_id 排序时不再追加 tie-breaker,否则 ORDER BY 出现重复列报错 169
|
||
const orderClause = SORTABLE_FIELDS[sortField] === 's.sku_id'
|
||
? orderSql
|
||
: `${orderSql}, s.sku_id ASC`;
|
||
|
||
const rows = await cloudDatabase.query(
|
||
`SELECT ${buildProjection()}
|
||
${whereSql}
|
||
ORDER BY ${orderClause}
|
||
OFFSET ${offset} ROWS FETCH NEXT ${pageSize} ROWS ONLY`,
|
||
params,
|
||
{ requestTimeout: 180000 }
|
||
);
|
||
|
||
const [totalRow] = await cloudDatabase.query(
|
||
`SELECT COUNT_BIG(*) AS total ${whereSql}`,
|
||
params,
|
||
{ requestTimeout: 180000 }
|
||
);
|
||
|
||
const [summaryRow] = await buildSummary(whereSql, params);
|
||
|
||
// 用产品资料系统数据库补齐图片(业务库目标仓图片字段全为空)
|
||
const enrichedRows = await attachProductImages(rows);
|
||
|
||
return {
|
||
rows: enrichedRows.map(mapRow),
|
||
total: Number(totalRow?.total || 0),
|
||
page,
|
||
pageSize,
|
||
warehouse: WAREHOUSE_NAME,
|
||
monitorDate: summaryRow?.monitorDate || monitorDate,
|
||
summary: {
|
||
skuCount: summaryRow?.skuCount || 0,
|
||
outOfStockCount: summaryRow?.outOfStockCount || 0,
|
||
lowStockDaysCount: summaryRow?.lowStockDaysCount || 0,
|
||
configuredCount: summaryRow?.configuredCount || 0,
|
||
unconfiguredCount: (summaryRow?.skuCount || 0) - (summaryRow?.configuredCount || 0),
|
||
totalStock: summaryRow?.totalStock || 0,
|
||
},
|
||
};
|
||
}
|
||
|
||
async function buildSummary(whereSql, params) {
|
||
const rows = await cloudDatabase.query(
|
||
`SELECT
|
||
COUNT_BIG(*) AS skuCount,
|
||
SUM(CASE WHEN COALESCE(inv.qty, m.jsStock, 0) <= 0 THEN 1 ELSE 0 END) AS outOfStockCount,
|
||
CAST(ISNULL(SUM(COALESCE(inv.qty, m.jsStock, 0)), 0) AS BIGINT) AS totalStock,
|
||
SUM(CASE WHEN m.jsUseDays IS NOT NULL AND m.jsUseDays <= ${LOW_STOCK_DAYS_THRESHOLD} THEN 1 ELSE 0 END) AS lowStockDaysCount,
|
||
SUM(CASE WHEN cfg.configured = 1 THEN 1 ELSE 0 END) AS configuredCount,
|
||
MAX(m.date) AS monitorDate
|
||
${whereSql}`,
|
||
params,
|
||
{ requestTimeout: 180000 }
|
||
);
|
||
|
||
const row = rows[0] || {};
|
||
|
||
return [{
|
||
skuCount: Number(row.skuCount || 0),
|
||
outOfStockCount: Number(row.outOfStockCount || 0),
|
||
totalStock: Number(row.totalStock || 0),
|
||
lowStockDaysCount: Number(row.lowStockDaysCount || 0),
|
||
configuredCount: Number(row.configuredCount || 0),
|
||
monitorDate: row.monitorDate ? new Date(row.monitorDate).toISOString().slice(0, 10) : null,
|
||
}];
|
||
}
|
||
|
||
async function getModularStockFilters() {
|
||
const [monitorDate, coverage] = await Promise.all([
|
||
resolveMonitorDate(),
|
||
resolveProductCoverage(),
|
||
]);
|
||
const params = {
|
||
wmsCoId: WAREHOUSE_WMS_CO_ID,
|
||
warehouseName: WAREHOUSE_NAME,
|
||
monitorDate,
|
||
};
|
||
if (coverage.available) {
|
||
params.productCoveredCsv = coverage.csv || '';
|
||
}
|
||
|
||
const rows = await cloudDatabase.query(
|
||
`SELECT DISTINCT s.category
|
||
${buildBaseSql(coverage)} AND s.category IS NOT NULL AND LTRIM(RTRIM(s.category)) <> N''
|
||
ORDER BY s.category`,
|
||
params,
|
||
{ requestTimeout: 180000 }
|
||
);
|
||
|
||
return {
|
||
categories: rows.map((row) => toText(row.category)).filter(Boolean),
|
||
warningLevels: [WARNING_LEVEL.OUT_OF_STOCK, WARNING_LEVEL.WARNING, WARNING_LEVEL.NORMAL],
|
||
mkhStatuses: [MKH_STATUS.CONFIGURED, MKH_STATUS.UNCONFIGURED],
|
||
warehouse: WAREHOUSE_NAME,
|
||
};
|
||
}
|
||
|
||
module.exports = {
|
||
WAREHOUSE_NAME,
|
||
WAREHOUSE_WMS_CO_ID,
|
||
WARNING_LEVEL,
|
||
DEFAULT_SAFETY_DAYS,
|
||
getModularStockList,
|
||
getModularStockFilters,
|
||
};
|