cangku/backend/src/services/productMonitoringService.js

944 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
// 商品监控:全公司在售商品的基础档案 + 库存 + 标签分级 + 部门运营指标
//
// 数据来源(2026-09-11 实测确认):
// 主表 业务库 rpt.pm_stockMonitor —— 脑图「商品监控」的原始数据源
// (5.6 万行 / 日更 / 含 sevenAvgSale 日销、jsStock 库存、jsUseDays 可用天数、
// suggestStock 建议备货、purchase 采购负责人、supplier、warehouse、sku_idStatus)
// 补充 业务库 rpt.jst_sku —— 图片、条码(sku_code)、材质、颜色、尺寸
// 配置 系统库 product_scale_thresholds / product_profit_levels / product_sabc_mappings
// (公司 SABC,由「系统设置 → 商品标签配置」维护)
// 配置 系统库 product_tag_rules (部门标签 G-S/G-A/G-B/G-C)
// 配置 系统库 department_sku_owners (部门 SKU 负责人)
//
// 判定口径:
// - 公司标签 S/A/B/C/D ← productTagConfigService.resolveProductTags()(业绩 + 日销双命中)
// - 部门标签 G-S/G-A/G-B/G-C ← product_tag_rules(tag_scope='department'),左闭右开,升序命中即停
//
// ⚠️ 已知留空字段(等业务给源表后再填):
// - 在途库存 inTransitStock → null,前端显示 `--`
// - 部门未发货数量 departmentUnshipped → null,前端显示 `--`
// (pm_stockMonitor.outOfStock 语义未确认,本次不用)
const database = require('../config/database');
const cloudDatabase = require('../config/cloudDatabase');
const productTagConfigService = require('./productTagConfigService');
const REPORT_SOURCE_TABLE = 'dbo.pm_stockMonitor';
// 销售明细(最新,2026-09-09):按 skuoid 聚合可得业绩/销售额、部门、主渠道、主运营
const SALES_DETAIL_TABLE = 'dbo.dws_sn_sales_full_info_di';
const SALES_WINDOW_DAYS = 7;
// 公司标签 S/A/B/C 的「业绩」阈值(系统设置 → 商品标签配置)是 30 天量级
// (如「住宅家具 - 书架」爆款线 48000),因此标签判定用 30 天销售额,日销仍用宽表 sevenAvgSale
const SALES_PERFORMANCE_WINDOW_DAYS = 30;
const SALE_STATUS_OPTIONS = Object.freeze(['销售', '停产', '退市']);
const DEFAULT_PAGE_SIZE = 50;
const MAX_PAGE_SIZE = 200;
const DEPARTMENT_TAG_SCOPE = 'department';
const DEPARTMENT_TAG_LEVELS = Object.freeze(['G-S', 'G-A', 'G-B', 'G-C']);
const DEPARTMENT_METRIC_KEYS = Object.freeze(['sales_qty_7d', 'sales_amount_7d']);
// ==================== 表结构 ====================
async function ensureTable(tableName, createSql) {
await database.local.query(createSql);
const rows = await database.local.query(
`SELECT 1 FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = ?
LIMIT 1`,
[tableName]
);
if (rows.length === 0) {
throw new Error(`Table ${tableName} create failed`);
}
}
async function ensureProductMonitoringSchema() {
await ensureTable(
'product_tag_rules',
`CREATE TABLE IF NOT EXISTS product_tag_rules (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
tag_scope VARCHAR(16) NOT NULL COMMENT 'company | department',
tag_level VARCHAR(16) NOT NULL COMMENT '部门标签 G-S/G-A/G-B/G-C',
metric_key VARCHAR(64) NOT NULL COMMENT 'sales_qty_7d | sales_amount_7d',
threshold_min DECIMAL(18,4) NULL,
threshold_max DECIMAL(18,4) NULL,
priority INT NOT NULL DEFAULT 0,
enabled TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_scope_level_metric (tag_scope, tag_level, metric_key),
KEY idx_scope_enabled (tag_scope, enabled),
KEY idx_metric (metric_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品标签分级规则(公司SABC / 部门G系列)'`
);
await ensureTable(
'department_sku_owners',
`CREATE TABLE IF NOT EXISTS department_sku_owners (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
department_id BIGINT UNSIGNED NOT NULL,
owner_type VARCHAR(16) NOT NULL COMMENT 'sku_owner | purchaser',
user_id BIGINT UNSIGNED NOT NULL,
spu VARCHAR(64) NULL COMMENT '为空表示部门级默认负责人',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_dept_type_spu_user (department_id, owner_type, spu, user_id),
KEY idx_dept (department_id),
KEY idx_spu (spu),
KEY idx_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='部门SKU负责人配置'`
);
// 部门标签默认四档(不存在时播种,口径:近 7 天销售件数)
const existing = await database.local.query(
'SELECT COUNT(*) AS c FROM product_tag_rules WHERE tag_scope = ?',
[DEPARTMENT_TAG_SCOPE]
);
if (Number(existing[0]?.c || 0) === 0) {
const seeds = [
['G-S', 500, null, 1],
['G-A', 200, 500, 2],
['G-B', 50, 200, 3],
['G-C', 0, 50, 4],
];
for (const [level, min, max, priority] of seeds) {
await database.local.query(
`INSERT INTO product_tag_rules
(tag_scope, tag_level, metric_key, threshold_min, threshold_max, priority, enabled)
VALUES (?, ?, 'sales_qty_7d', ?, ?, ?, 1)
ON DUPLICATE KEY UPDATE tag_scope = tag_scope`,
[DEPARTMENT_TAG_SCOPE, level, min, max, priority]
);
}
}
}
// ==================== 配置读取 ====================
async function loadDepartmentTagRules() {
const rows = await database.local.query(
`SELECT id, tag_level, metric_key, threshold_min, threshold_max, priority, enabled
FROM product_tag_rules
WHERE tag_scope = ? AND enabled = 1
ORDER BY priority ASC, id ASC`,
[DEPARTMENT_TAG_SCOPE]
);
return rows.map((row) => ({
id: row.id,
tagLevel: row.tag_level,
metricKey: row.metric_key,
thresholdMin: row.threshold_min === null ? null : Number(row.threshold_min),
thresholdMax: row.threshold_max === null ? null : Number(row.threshold_max),
priority: Number(row.priority || 0),
}));
}
// 公司 SKU 负责人:取自 rpt.V_caterAndOper.oper,**SPU 粒度**(方案第 12 条已确认接受该粒度)
async function loadCompanyOwnerIndex() {
try {
const rows = await cloudDatabase.query(
`SELECT spu, oper FROM dbo.V_caterAndOper WHERE oper IS NOT NULL AND oper <> N''`
);
const index = new Map();
rows.forEach((row) => {
const spu = String(row.spu || '').trim();
const oper = String(row.oper || '').trim();
if (spu && oper && !index.has(spu)) index.set(spu, oper);
});
return index;
} catch (error) {
console.warn('[ProductMonitoring] 加载公司SKU负责人失败,该列将留空:', error.message);
return new Map();
}
}
async function loadDepartmentSkuOwners() {
const rows = await database.local.query(
`SELECT o.department_id, o.owner_type, o.user_id, o.spu, u.name AS user_name, d.name AS department_name
FROM department_sku_owners o
LEFT JOIN users u ON u.id = o.user_id
LEFT JOIN departments d ON d.id = o.department_id`
);
return rows.map((row) => ({
departmentId: row.department_id,
departmentName: row.department_name,
ownerType: row.owner_type,
userId: row.user_id,
ownerName: row.user_name,
spu: row.spu,
}));
}
// 部门标签判定:左闭右开 [min, max),按 priority 升序命中即停;未命中返回 null
function matchDepartmentTag(rules, salesQty7d) {
if (!rules.length) return null;
// ⚠️ 无销售记录(null)不等于「销量 0」:Number(null) 是 0,会把无数据行误判成最低档
if (salesQty7d === null || salesQty7d === undefined || salesQty7d === '') return null;
const value = Number(salesQty7d);
if (!Number.isFinite(value)) return null;
for (const rule of rules) {
const geMin = rule.thresholdMin === null || value >= rule.thresholdMin;
const ltMax = rule.thresholdMax === null || value < rule.thresholdMax;
if (geMin && ltMax) return rule.tagLevel;
}
return null;
}
// ==================== 业务库查询 ====================
// 可排序字段(排序在内存完成,直接用行字段名)
const DERIVED_SORT_FIELDS = new Set([
'skuId', 'spu', 'category', 'productName', 'supplier', 'warehouse', 'purchaser', 'saleStatus',
'companyOwner', 'companySales7d', 'availableStock', 'stockDays', 'suggestedReplenish',
'companyTag', 'department', 'departmentTag', 'departmentOwner',
'performance7d', 'performance30d', 'departmentSales7d', 'mainSalesChannel', 'mainSalesOperator',
]);
async function getLatestReportDate() {
const rows = await cloudDatabase.query(
`SELECT MAX(date) AS latestDate FROM ${REPORT_SOURCE_TABLE}`
);
const latest = rows[0]?.latestDate;
if (!latest) {
throw new Error('业务库 pm_stockMonitor 无数据,无法确定最新统计日期');
}
return latest;
}
// 全量基础行:pm_stockMonitor 主表 + jst_sku 补图片/条码/材质/颜色/尺寸
//
// ⚠️ 只按统计日过滤、不带任何业务条件——结果会被 buildDataset 缓存,
// 业务条件(状态/品类/SPU/SKU/供应商…)与标签过滤都在内存里做,见 applyBaseFilters。
async function queryAllReportRows(latestDate) {
const params = { latestDate };
const dataSql = `
SELECT
m.sku_id AS skuId,
m.sku AS skuCode,
m.spu AS spu,
m.skuName AS productName,
m.category AS category,
m.supplier AS supplier,
m.warehouse AS warehouse,
m.purchase AS purchaser,
m.area AS area,
m.cooperation AS cooperation,
m.brand AS brand,
m.sku_idStatus AS saleStatus,
m.jsStock AS availableStock,
m.jsUseDays AS stockDays,
m.sevenAvgSale AS companySales7d,
m.suggestStock AS suggestedReplenish,
m.produceCycle AS produceCycle,
m.safetyDays AS safetyDays,
s.sku_code AS barcode,
s.color AS colorRaw,
s.properties_value AS sizeRaw,
COALESCE(NULLIF(s.other_10, N''), NULLIF(s.other_2, N'')) AS material,
COALESCE(s.pic_big, s.pic) AS imageUrl
FROM ${REPORT_SOURCE_TABLE} m
LEFT JOIN dbo.jst_sku s ON s.sku_id = m.sku_id
WHERE m.date = @latestDate
`;
const rows = await cloudDatabase.query(dataSql, params, { requestTimeout: 180000 });
return {
rows: rows.map((row) => ({
skuId: row.skuId,
skuCode: row.skuCode || row.skuId,
spu: row.spu,
barcode: row.barcode || row.skuId,
imageUrl: row.imageUrl || null,
category: row.category,
productName: row.productName,
material: row.material || null,
color: row.colorRaw || null,
size: row.sizeRaw || null,
saleStatus: row.saleStatus,
supplier: row.supplier || null,
warehouse: row.warehouse || null,
purchaser: row.purchaser || null,
area: row.area || null,
cooperation: row.cooperation || null,
brand: row.brand || null,
companySales7d: toNumberOrNull(row.companySales7d),
availableStock: toNumberOrNull(row.availableStock),
inTransitStock: null, // 待业务提供源表
stockDays: toNumberOrNull(row.stockDays),
suggestedReplenish: toNumberOrNull(row.suggestedReplenish),
produceCycle: toNumberOrNull(row.produceCycle),
safetyDays: toNumberOrNull(row.safetyDays),
departmentUnshipped: null, // 待业务确认 outOfStock 语义
})),
latestDate,
};
}
function toNumberOrNull(value) {
if (value === null || value === undefined || value === '') return null;
const num = Number(value);
return Number.isFinite(num) ? num : null;
}
// ==================== 汇总统计 ====================
// 汇总基于「过滤后的最终结果」统计,保证与列表口径一致
function buildSummary(rows = []) {
let outOfStockCount = 0;
let lowStockDaysCount = 0;
let totalSuggestedReplenish = 0;
let needReplenishCount = 0;
rows.forEach((row) => {
const stock = row.availableStock === null ? 0 : Number(row.availableStock);
const days = row.stockDays === null ? 0 : Number(row.stockDays);
if (stock <= 0) outOfStockCount += 1;
else if (days < 7) lowStockDaysCount += 1;
// ⚠️ suggestStock 是有符号的净需求(库存过剩为负数),合计只累计正值,避免负负相加失真
const replenish = row.suggestedReplenish === null ? 0 : Number(row.suggestedReplenish);
if (replenish > 0) {
totalSuggestedReplenish += replenish;
needReplenishCount += 1;
}
});
return {
skuCount: rows.length,
outOfStockCount,
lowStockDaysCount,
totalSuggestedReplenish: Math.round(totalSuggestedReplenish),
needReplenishCount,
};
}
// ==================== 销售明细聚合(业绩 / 部门 / 主渠道 / 主运营) ====================
// 取销售明细表的最新统计日
async function getLatestSalesDate() {
const rows = await cloudDatabase.query(
`SELECT MAX(stat_date) AS latestDate FROM ${SALES_DETAIL_TABLE}`
);
return rows[0]?.latestDate || null;
}
// 按 skuoid 聚合近 7 天:销售额(业绩口径)、部门、主销售渠道、主销售运营
async function loadSalesAggregatesBySku() {
try {
const latestDate = await getLatestSalesDate();
if (!latestDate) return new Map();
const rows = await cloudDatabase.query(
`SELECT
d.skuoid AS skuoid,
SUM(d.sales_amount) AS salesAmount30d,
SUM(d.goods_count) AS salesQty30d,
SUM(CASE WHEN d.stat_date >= DATEADD(day, -@days, @latestDate) THEN d.sales_amount ELSE 0 END) AS salesAmount7d,
SUM(CASE WHEN d.stat_date >= DATEADD(day, -@days, @latestDate) THEN d.goods_count ELSE 0 END) AS salesQty7d,
MAX(d.profit_center) AS department,
MAX(d.shop_dept) AS shopDept
FROM ${SALES_DETAIL_TABLE} d
WHERE d.stat_date >= DATEADD(day, -@perfDays, @latestDate)
AND d.skuoid IS NOT NULL AND d.skuoid <> N''
GROUP BY d.skuoid`,
{ days: SALES_WINDOW_DAYS, perfDays: SALES_PERFORMANCE_WINDOW_DAYS, latestDate },
{ requestTimeout: 180000 }
);
const index = new Map();
rows.forEach((row) => {
const key = String(row.skuoid || '').trim();
if (!key) return;
index.set(key, {
salesAmount7d: toNumberOrNull(row.salesAmount7d),
salesQty7d: toNumberOrNull(row.salesQty7d),
salesAmount30d: toNumberOrNull(row.salesAmount30d),
salesQty30d: toNumberOrNull(row.salesQty30d),
department: row.department || row.shopDept || null,
});
});
return index;
} catch (error) {
console.warn('[ProductMonitoring] 加载销售明细聚合失败,业绩/部门列将留空:', error.message);
return new Map();
}
}
// 主销售渠道 / 主销售运营:近 7 天销售额最高的渠道与运营(脑图口径)
async function loadMainChannelAndOperator(skuoids) {
const result = new Map();
if (!Array.isArray(skuoids) || !skuoids.length) return result;
try {
const latestDate = await getLatestSalesDate();
if (!latestDate) return result;
// 仅对本页 SKU 查询,避免全表聚合
const list = [...new Set(skuoids.filter(Boolean))].slice(0, MAX_PAGE_SIZE);
const params = { days: SALES_WINDOW_DAYS, latestDate };
const placeholders = list.map((sku, i) => {
params[`sku${i}`] = sku;
return `@sku${i}`;
});
const rows = await cloudDatabase.query(
`SELECT d.skuoid AS skuoid, d.shop_name AS shopName, d.ider AS operator,
SUM(d.sales_amount) AS amt
FROM ${SALES_DETAIL_TABLE} d
WHERE d.stat_date >= DATEADD(day, -@days, @latestDate)
AND d.skuoid IN (${placeholders.join(',')})
GROUP BY d.skuoid, d.shop_name, d.ider`,
params,
{ requestTimeout: 120000 }
);
const byChannel = new Map();
const byOperator = new Map();
rows.forEach((row) => {
const sku = String(row.skuoid || '').trim();
const amt = toNumberOrNull(row.amt) || 0;
if (!sku) return;
if (row.shopName) {
const key = `${sku}||${row.shopName}`;
const prev = byChannel.get(key) || 0;
byChannel.set(key, prev + amt);
}
if (row.operator) {
const key = `${sku}||${row.operator}`;
const prev = byOperator.get(key) || 0;
byOperator.set(key, prev + amt);
}
});
const pickTop = (map, targetMap) => {
map.forEach((amt, key) => {
const [sku, name] = key.split('||');
const prev = targetMap.get(sku);
if (!prev || amt > prev.amt) targetMap.set(sku, { name, amt });
});
};
const topChannel = new Map();
const topOperator = new Map();
pickTop(byChannel, topChannel);
pickTop(byOperator, topOperator);
list.forEach((sku) => {
result.set(sku, {
mainSalesChannel: topChannel.get(sku)?.name || null,
mainSalesOperator: topOperator.get(sku)?.name || null,
});
});
return result;
} catch (error) {
console.warn('[ProductMonitoring] 加载主渠道/主运营失败,相关列将留空:', error.message);
return result;
}
}
// 行下钻:该 SKU 归属的运营人员列表 + 各自日销(近 7 天日均发货件数)
async function getProductOperators(skuoid) {
const sku = String(skuoid || '').trim();
if (!sku) return [];
const latestDate = await getLatestSalesDate();
if (!latestDate) return [];
const rows = await cloudDatabase.query(
`SELECT d.ider AS operator,
SUM(d.goods_count) AS qty7d,
SUM(d.sales_amount) AS amt7d,
MAX(d.shop_name) AS sampleShop
FROM ${SALES_DETAIL_TABLE} d
WHERE d.stat_date >= DATEADD(day, -@days, @latestDate)
AND d.skuoid = @sku
AND d.ider IS NOT NULL AND d.ider <> N''
GROUP BY d.ider
ORDER BY SUM(d.goods_count) DESC`,
{ days: SALES_WINDOW_DAYS, latestDate, sku },
{ requestTimeout: 120000 }
);
// 每位运营的主渠道
const channelRows = await cloudDatabase.query(
`SELECT d.ider AS operator, d.shop_name AS shopName, SUM(d.sales_amount) AS amt
FROM ${SALES_DETAIL_TABLE} d
WHERE d.stat_date >= DATEADD(day, -@days, @latestDate)
AND d.skuoid = @sku AND d.ider IS NOT NULL AND d.ider <> N''
GROUP BY d.ider, d.shop_name`,
{ days: SALES_WINDOW_DAYS, latestDate, sku },
{ requestTimeout: 120000 }
);
const topChannel = new Map();
channelRows.forEach((row) => {
const amt = toNumberOrNull(row.amt) || 0;
const prev = topChannel.get(row.operator);
if (!prev || amt > prev.amt) topChannel.set(row.operator, { name: row.shopName, amt });
});
return rows.map((row) => {
const qty7d = toNumberOrNull(row.qty7d) || 0;
return {
operator: row.operator,
dailySales: Number((qty7d / SALES_WINDOW_DAYS).toFixed(2)),
salesQty7d: qty7d,
salesAmount7d: toNumberOrNull(row.amt7d),
mainChannel: topChannel.get(row.operator)?.name || null,
};
});
}
// ==================== 筛选选项 ====================
async function queryFilterOptions(latestDate) {
const [dimensionRows, statusRows] = await Promise.all([
cloudDatabase.query(
`SELECT DISTINCT TOP 300 m.category AS category, m.supplier AS supplier, m.warehouse AS warehouse,
m.purchase AS purchaser
FROM ${REPORT_SOURCE_TABLE} m
WHERE m.date = @latestDate`,
{ latestDate }
),
cloudDatabase.query(
`SELECT m.sku_idStatus AS status, COUNT(*) AS c
FROM ${REPORT_SOURCE_TABLE} m
WHERE m.date = @latestDate
GROUP BY m.sku_idStatus`,
{ latestDate }
),
]);
const uniq = (key) => [...new Set(dimensionRows.map((r) => r[key]).filter(Boolean))].sort((a, b) => String(a).localeCompare(String(b), 'zh-CN'));
return {
categories: uniq('category'),
suppliers: uniq('supplier'),
warehouses: uniq('warehouse'),
purchasers: uniq('purchaser'),
saleStatuses: SALE_STATUS_OPTIONS.map((status) => ({
status,
count: Number(statusRows.find((r) => r.status === status)?.c || 0),
})),
latestDate,
};
}
// ==================== 数据集缓存 ====================
//
// 实测:mssql 驱动逐行开销约 0.4ms/行,取全量 5.6 万行要 20~30 秒,不能每次请求都跑。
// 而「公司标签 / 部门标签 / 部门负责人」依赖系统库配置,无法下推到 SQL,必须拿到全量行再算。
// 因此:按统计日缓存一份「已完成派生」的全量数据集,后续请求只在内存里过滤 + 排序 + 分页。
// 缓存失效时机:① 统计日变化(日更)② 保存部门标签/负责人配置 ③ 前端带 refresh=1。
const DATASET_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
let datasetCache = null;
let datasetBuilding = null;
const dateKeyOf = (value) => (value ? new Date(value).toISOString().slice(0, 10) : '');
async function buildDataset(latestDate) {
const key = dateKeyOf(latestDate);
const [baseResult, departmentRules, departmentOwners, companyTagConfig, companyOwnerIndex, salesAggIndex] = await Promise.all([
queryAllReportRows(latestDate),
loadDepartmentTagRules(),
loadDepartmentSkuOwners(),
loadCompanyTagResolver(),
loadCompanyOwnerIndex(),
loadSalesAggregatesBySku(),
]);
const rows = baseResult.rows.map((row) => {
const salesAgg = salesAggIndex.get(String(row.skuCode || row.skuId).trim()) || null;
// 业绩口径 = 近 30 天销售额(对齐 SABC 阈值量级),近 7 天销售额单独留一列展示
const performance30d = salesAgg?.salesAmount30d ?? null;
const performance7d = salesAgg?.salesAmount7d ?? null;
const departmentSalesQty7d = salesAgg?.salesQty7d ?? null;
const companyTag = resolveCompanyTag(companyTagConfig, row.category, performance30d, row.companySales7d);
const departmentTag = matchDepartmentTag(departmentRules, departmentSalesQty7d);
const department = salesAgg?.department || null;
const owner = resolveDepartmentOwner(departmentOwners, { ...row, department });
return {
...row,
companyOwner: companyOwnerIndex.get(String(row.spu || '').trim()) || null,
department,
// 业绩(销售额)——30 天用于 SABC 判定,7 天用于展示
performance7d,
performance30d,
// 部门近7日日销(件数 / 7)
departmentSales7d: departmentSalesQty7d === null
? null
: Number((departmentSalesQty7d / SALES_WINDOW_DAYS).toFixed(2)),
departmentSalesQty7d,
// 建议备货:宽表 suggestStock 为有符号净需求,负数代表库存过剩,展示时取 0
suggestedReplenish: row.suggestedReplenish === null ? null : Math.max(0, row.suggestedReplenish),
companyTag,
departmentTag,
departmentOwner: owner?.ownerName || null,
mainSalesChannel: null,
mainSalesOperator: null,
};
});
return { key, builtAt: Date.now(), rows };
}
async function getDataset(force = false) {
const latestDate = await getLatestReportDate();
const key = dateKeyOf(latestDate);
const fresh = datasetCache && datasetCache.key === key
&& (Date.now() - datasetCache.builtAt) < DATASET_CACHE_TTL_MS;
if (!force && fresh) return { dataset: datasetCache, latestDate };
// 并发去重:同一统计日的构建只跑一次
if (datasetBuilding && datasetBuilding.key === key) {
return { dataset: await datasetBuilding.promise, latestDate };
}
const promise = buildDataset(latestDate)
.then((dataset) => {
datasetCache = dataset;
console.log(`[ProductMonitoring] 数据集构建完成:${key},共 ${dataset.rows.length} 行`);
return dataset;
})
.finally(() => { datasetBuilding = null; });
datasetBuilding = { key, promise };
return { dataset: await promise, latestDate };
}
function invalidateDataset() {
datasetCache = null;
}
// 启动预热:不阻塞应用启动,构建失败只记日志(首次请求会重试)
async function warmProductMonitoringDataset() {
try {
await getDataset();
} catch (error) {
console.warn('[ProductMonitoring] 数据集预热失败,将在首次请求时重试:', error.message);
}
}
// 基础字段过滤(原 SQL WHERE 的等价物,改为内存匹配)
function applyBaseFilters(rows = [], query = {}) {
const like = (value, keyword) => String(value ?? '').toLowerCase()
.includes(String(keyword ?? '').trim().toLowerCase());
const keywordFields = [
['category', 'category'],
['spu', 'spu'],
['sku', 'skuId'],
['productName', 'productName'],
['supplier', 'supplier'],
['warehouse', 'warehouse'],
['purchaser', 'purchaser'],
].map(([param, field]) => [field, String(query[param] ?? '').trim()])
.filter(([, keyword]) => keyword);
const saleStatus = String(query.saleStatus ?? '').trim();
const includeAll = String(query.includeAll ?? '') === '1';
const status = saleStatus || (includeAll ? '' : '销售');
if (!keywordFields.length && !status) return rows;
return rows.filter((row) => {
if (status && String(row.saleStatus || '') !== status) return false;
return keywordFields.every(([field, keyword]) => like(row[field], keyword));
});
}
// ==================== 主入口 ====================
async function getMonitoringList(query = {}) {
const forceRefresh = String(query.refresh ?? '') === '1';
const { dataset, latestDate } = await getDataset(forceRefresh);
// 1) 基础字段过滤 → 2) 派生字段过滤 → 3) 排序 → 4) 分页
const filtered = applyDerivedFilters(applyBaseFilters(dataset.rows, query), query);
const sorted = sortRows(filtered, query);
const total = sorted.length;
const page = Math.max(1, Number(query.page) || 1);
const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, Number(query.pageSize) || DEFAULT_PAGE_SIZE));
const pageRows = sorted.slice((page - 1) * pageSize, page * pageSize);
// 主渠道 / 主运营只对本页 SKU 查询,控制代价
const mainChannelIndex = await loadMainChannelAndOperator(pageRows.map((row) => row.skuCode || row.skuId));
const rows = pageRows.map((row) => {
const main = mainChannelIndex.get(String(row.skuCode || row.skuId).trim()) || {};
return {
...row,
mainSalesChannel: main.mainSalesChannel || null,
mainSalesOperator: main.mainSalesOperator || null,
};
});
return {
rows,
total,
page,
pageSize,
latestDate,
cachedAt: dataset.builtAt,
summary: buildSummary(sorted),
};
}
// 派生字段过滤:多选用逗号分隔(companyTag=S,A),文本用包含匹配
function applyDerivedFilters(rows = [], query = {}) {
const parseList = (value) => String(value ?? '')
.split(',')
.map((item) => item.trim())
.filter(Boolean);
const companyTags = parseList(query.companyTag);
const departmentTags = parseList(query.departmentTag);
const departmentKeyword = String(query.department ?? '').trim();
const departmentOwnerKeyword = String(query.departmentOwner ?? '').trim().toLowerCase();
const companyOwnerKeyword = String(query.companyOwner ?? '').trim().toLowerCase();
if (!companyTags.length && !departmentTags.length && !departmentKeyword
&& !departmentOwnerKeyword && !companyOwnerKeyword) {
return rows;
}
return rows.filter((row) => {
if (companyTags.length && !companyTags.includes(String(row.companyTag || ''))) return false;
if (departmentTags.length && !departmentTags.includes(String(row.departmentTag || ''))) return false;
if (departmentKeyword && String(row.department || '') !== departmentKeyword) return false;
if (departmentOwnerKeyword && !String(row.departmentOwner || '').toLowerCase().includes(departmentOwnerKeyword)) return false;
if (companyOwnerKeyword && !String(row.companyOwner || '').toLowerCase().includes(companyOwnerKeyword)) return false;
return true;
});
}
function sortRows(rows = [], query = {}) {
const field = DERIVED_SORT_FIELDS.has(query.sortField) ? query.sortField : 'companySales7d';
const desc = String(query.sortOrder || '').toUpperCase() !== 'ASC';
const direction = desc ? -1 : 1;
return [...rows].sort((a, b) => {
const av = a[field];
const bv = b[field];
// 空值恒排末尾,避免 null 参与数值/字符串比较造成的抖动
if (av === null || av === undefined || av === '') {
return bv === null || bv === undefined || bv === '' ? String(a.skuId).localeCompare(String(b.skuId)) : 1;
}
if (bv === null || bv === undefined || bv === '') return -1;
const an = Number(av);
const bn = Number(bv);
const bothNumeric = Number.isFinite(an) && Number.isFinite(bn);
const delta = bothNumeric
? an - bn
: String(av).localeCompare(String(bv), 'zh-CN');
return (delta || String(a.skuId).localeCompare(String(b.skuId))) * direction;
});
}
// 公司 SABC:复用「商品标签配置」的阈值,按 (品类, 业绩, 日销) 判定规模标签
async function loadCompanyTagResolver() {
try {
const config = await productTagConfigService.getProductTagConfig();
const thresholds = config?.scaleThresholds || [];
const byCategory = new Map();
thresholds.forEach((t) => {
if (t.enabled === false) return;
const key = String(t.category || '');
if (!byCategory.has(key)) byCategory.set(key, []);
byCategory.get(key).push(t);
});
return { byCategory, mappings: config?.sabcMappings || [] };
} catch (error) {
console.warn('[ProductMonitoring] 加载公司标签配置失败,标签列将留空:', error.message);
return null;
}
}
// 判定:业绩与日销同时命中(左闭右开),按 priority 升序命中即停
function resolveCompanyTag(config, category, performance, sales) {
if (!config) return null;
const ofCategory = config.byCategory.get(String(category || ''));
if (!ofCategory || !ofCategory.length) return null;
// 业绩缺失时按 0 处理(区间在业务上必然从 0 起),避免整列留空
const perf = performance === null || performance === undefined ? 0 : Number(performance);
const sale = sales === null || sales === undefined ? 0 : Number(sales);
const scaleTag = productTagConfigService.matchScaleTagOfCategory(ofCategory, perf, sale);
if (!scaleTag || scaleTag === '滞销款') return 'D';
// 利润等级当前无上游数据源,同规模标签下取最优档作为展示值
const hit = config.mappings.find((m) => m.scaleTag === scaleTag && m.enabled !== false);
return hit?.sabcTag || null;
}
// 部门负责人:优先「部门 + SPU」精确配置 → 「部门 + SPU 为空」的部门级默认配置
function resolveDepartmentOwner(owners, row) {
if (!owners.length) return null;
const deptName = String(row.department || '').trim();
const spu = String(row.spu || '').trim();
if (!deptName) return null;
const ofDept = owners.filter((o) => o.ownerType === 'sku_owner' && o.departmentName === deptName);
if (!ofDept.length) return null;
const bySpu = spu ? ofDept.find((o) => String(o.spu || '').trim() === spu) : null;
if (bySpu) return bySpu;
return ofDept.find((o) => !o.spu) || null;
}
async function getMonitoringFilters() {
const latestDate = await getLatestReportDate();
const [options, departmentRules] = await Promise.all([
queryFilterOptions(latestDate),
loadDepartmentTagRules(),
]);
return {
...options,
companyTags: ['S', 'A', 'B', 'C', 'D'],
departmentTags: DEPARTMENT_TAG_LEVELS,
departmentTagRules: departmentRules,
};
}
// ==================== 部门标签配置(系统设置用) ====================
async function getDepartmentTagConfig() {
const [rules, owners, departments, users] = await Promise.all([
loadDepartmentTagRules(),
loadDepartmentSkuOwners(),
database.local.query('SELECT id, name, code, status FROM departments WHERE status = ? ORDER BY sort ASC, id ASC', ['active']),
database.local.query('SELECT id, name, username, role FROM users ORDER BY id ASC LIMIT 1000'),
]);
return {
rules: DEPARTMENT_TAG_LEVELS.map((level) => {
const hit = rules.find((r) => r.tagLevel === level);
return {
tagLevel: level,
metricKey: hit?.metricKey || 'sales_qty_7d',
thresholdMin: hit?.thresholdMin ?? null,
thresholdMax: hit?.thresholdMax ?? null,
priority: hit?.priority ?? (DEPARTMENT_TAG_LEVELS.indexOf(level) + 1),
enabled: Boolean(hit),
};
}),
metricKeys: [...DEPARTMENT_METRIC_KEYS],
owners,
departments: departments.map((d) => ({ id: d.id, name: d.name, code: d.code })),
users: users.map((u) => ({ id: u.id, name: u.name, username: u.username, role: u.role })),
};
}
async function saveDepartmentTagConfig(payload = {}) {
// 标签/负责人配置变了,已缓存的数据集里的派生字段立即失效
invalidateDataset();
const rules = Array.isArray(payload.rules) ? payload.rules : null;
const owners = Array.isArray(payload.owners) ? payload.owners : null;
if (rules) {
validateDepartmentRules(rules);
await database.local.query('DELETE FROM product_tag_rules WHERE tag_scope = ?', [DEPARTMENT_TAG_SCOPE]);
for (const rule of rules) {
if (rule.enabled === false) continue;
await database.local.query(
`INSERT INTO product_tag_rules
(tag_scope, tag_level, metric_key, threshold_min, threshold_max, priority, enabled)
VALUES (?, ?, ?, ?, ?, ?, 1)
ON DUPLICATE KEY UPDATE
metric_key = VALUES(metric_key),
threshold_min = VALUES(threshold_min),
threshold_max = VALUES(threshold_max),
priority = VALUES(priority),
enabled = 1`,
[
DEPARTMENT_TAG_SCOPE,
String(rule.tagLevel),
DEPARTMENT_METRIC_KEYS.includes(rule.metricKey) ? rule.metricKey : 'sales_qty_7d',
toNumberOrNull(rule.thresholdMin),
toNumberOrNull(rule.thresholdMax),
Number(rule.priority) || 0,
]
);
}
}
if (owners) {
validateDepartmentOwners(owners);
await database.local.query('DELETE FROM department_sku_owners');
for (const owner of owners) {
await database.local.query(
`INSERT INTO department_sku_owners (department_id, owner_type, user_id, spu)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE department_id = VALUES(department_id)`,
[
Number(owner.departmentId),
owner.ownerType === 'purchaser' ? 'purchaser' : 'sku_owner',
Number(owner.userId),
owner.spu ? String(owner.spu).trim() : null,
]
);
}
}
return getDepartmentTagConfig();
}
function validateDepartmentRules(rules) {
const seen = new Set();
rules.forEach((rule, index) => {
const line = index + 1;
if (!DEPARTMENT_TAG_LEVELS.includes(String(rule.tagLevel))) {
throw new Error(`第 ${line} 行:部门标签必须是 ${DEPARTMENT_TAG_LEVELS.join(' / ')}`);
}
const min = toNumberOrNull(rule.thresholdMin);
const max = toNumberOrNull(rule.thresholdMax);
if (min !== null && max !== null && min >= max) {
throw new Error(`第 ${line} 行:${rule.tagLevel} 的起点(${min}) 必须小于终点(${max})`);
}
if (seen.has(rule.tagLevel)) {
throw new Error(`第 ${line} 行:部门标签「${rule.tagLevel}」重复`);
}
seen.add(rule.tagLevel);
});
}
function validateDepartmentOwners(owners) {
const seen = new Set();
owners.forEach((owner, index) => {
const line = index + 1;
if (!owner.departmentId) throw new Error(`第 ${line} 行:必须选择部门`);
if (!owner.userId) throw new Error(`第 ${line} 行:必须选择负责人`);
const key = `${owner.departmentId}||${owner.ownerType}||${owner.spu || ''}||${owner.userId}`;
if (seen.has(key)) throw new Error(`第 ${line} 行:部门/负责人/SPU 组合重复`);
seen.add(key);
});
}
module.exports = {
ensureProductMonitoringSchema,
getMonitoringList,
getMonitoringFilters,
getDepartmentTagConfig,
saveDepartmentTagConfig,
warmProductMonitoringDataset,
invalidateDataset,
getProductOperators,
matchDepartmentTag,
DEPARTMENT_TAG_LEVELS,
DEPARTMENT_METRIC_KEYS,
SALE_STATUS_OPTIONS,
};