forked from lunanting/cangku
944 lines
36 KiB
JavaScript
944 lines
36 KiB
JavaScript
'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,
|
||
};
|