feat: 客退看板新增「最晚退厂时间」列(工作平台数据,先占位待接入)

- 新增 supplierExitDateProvider.js:按供应商名匹配「最晚退厂时间」,
  数据源为工作平台库 WorkbenchDataShared(同实例,另有 WorkbenchIdentity /
  DataPlatformControl)。当前副本账号 cangku 无该库访问权限、系统设置里没有它的
  连接配置、.env 的 JIAN_SUN_DB_* 与 WORK_ORDER_* 也为空,因此先返回不可用。
  接入方式:IT 开通后配 SUPPLIER_EXIT_DATE_ENABLED=true + SUPPLIER_EXIT_DATE_SQL
  (查询需返回 supplierName / exitDate 两列),或直接写死 CODE_* 常量,无需改其他文件。
- returnStockWarningService.js:composeRows 增加 latestExitDate /
  latestExitDateLabel / latestExitDateAvailable;列表响应新增 exitDateMeta;
  导出列在「供应商是否合作」后新增「最晚退厂时间」(未接入时导出显示「待接入」);
  口径说明补一条(含完整取证:工作平台库不可达、副本无任何退厂字段、
  fys_supplier.NoCoopTime 是不可用的停更快照)。
- StockWarning.jsx:表格新增「最晚退厂时间」列,有值显示日期、未接入显示「待接入」
  (Tooltip 说明数据来源与开通方式);monitor.css 补占位符样式。
This commit is contained in:
lunanting 2026-09-28 16:06:40 +08:00
parent 901212a32c
commit 30554720b2
4 changed files with 210 additions and 2 deletions

View File

@ -64,6 +64,10 @@
const cloudDb = require('../config/cloudDatabase');
const database = require('../config/database');
const {
loadSupplierExitDates,
EMPTY_EXIT_DATES,
} = require('./supplierExitDateProvider');
const {
WAREHOUSE_LIST,
RETURN_SLOT_COLUMNS,
@ -242,6 +246,7 @@ const buildKnownGaps = (warehouse = DEFAULT_WAREHOUSE) => {
'单价 / 总金额:单价取商品资料 jst_sku 的成本价(cost_price),成本价缺失或为 0 时回退采购价(purchase_price);总金额 = 单价 × 总库存(即「客退临时上架仓」的库存金额)。商品资料里两个价格都没有的编码(多为虚拟编码,如 HBZBF)单价与总金额显示「--」,且不计入顶部「库存总金额」,页面会单独提示这类编码的数量。',
'单价预警:按商品单价单独分级,默认「单价 > 300 元 → 重点关注」「200 ~ 300 元 → 关注」「< 200 元 → 正常」,阈值可在页面上直接调。它与库龄预警是两个互补维度——库龄看「货压了多久」,单价看「每件压了多少钱」,两者叠加才能看出真正占资金的那批编码。',
'退厂时限:单价 > 重点关注线(默认 300 元)的编码,要求入库后 7 天内完成库存退厂(时限可在页面调)。页面按「在库天数」对比时限,给出「已超期 N 天 / 剩余 N 天」,并在顶部单独提示这批编码的处置要求。「已退厂」的判定是「该编码已从当天库存中消失」(不再依赖出库表),一旦退厂完成,这条编码自然就不会再出现在报表里。',
'最晚退厂时间(当前「待接入」):业务上由工作平台对「不再合作」的供应商给出最晚退厂日期,看板按供应商名匹配后与「供应商是否合作」列并排展示。它不在报表副本里——数据属于工作平台库 WorkbenchDataShared(同实例另有 WorkbenchIdentity、DataPlatformControl),而 2026-09-28 取证时副本账号 cangku 访问该库被拒(The server principal "cangku" is not able to access the database "WorkbenchDataShared" under the current security context),系统设置里也没有它的连接配置,.env 的 JIAN_SUN_DB_* / WORK_ORDER_* 全为空;副本库内 5,496 个字段中没有任何「退厂」字段,最接近的 fys_supplier.NoCoopTime 是不可用的停更快照(崔桥仓 8 个不合作供应商里只有 1 个有值,且那 1 条自身还标着「合作」)。所以该列先以「待接入」占位,不影响其他列。要出数只需二选一:① 让 IT 给副本账号开通 WorkbenchDataShared 只读;② 让 IT 把工作平台「供应商不合作」表同步进 JSReportReplica。之后在 backend/src/services/supplierExitDateProvider.js 里填上查询语句即可自动出数,页面与导出都无需再改。',
];
if (!warehouse.hasReturnStock) {
gaps.unshift(
@ -825,6 +830,8 @@ function composeRows({
replicaRows, overrides, qtyBasis, createdAtBasis, today, scope, focusDays, alertDays, priceMap,
priceFocusMin = DEFAULT_PRICE_FOCUS_MIN, priceHighMin = DEFAULT_PRICE_HIGH_MIN,
returnLimitDays = DEFAULT_RETURN_LIMIT_DAYS, warehouse = DEFAULT_WAREHOUSE,
// 供应商「最晚退厂时间」(工作平台数据,见 supplierExitDateProvider;未接入时是空 Map)
exitDates = EMPTY_EXIT_DATES,
}) {
const hasImport = overrides.size > 0;
const rows = [];
@ -853,6 +860,9 @@ function composeRows({
});
// 供应商是否合作(js_supplier.coopersgn)
const supplierCoop = resolveSupplierCoop(row.supplierCoopSign);
// 最晚退厂时间(工作平台「供应商不合作」,按供应商名匹配;未接入时为空)
const supplierName = String(row.supplier || '').trim();
const latestExitDate = exitDates.bySupplier.get(supplierName) || '';
if (scope === 'imported' && !override) return;
if (qty <= 0) return;
@ -885,6 +895,9 @@ function composeRows({
isOutbound,
supplierCoop,
supplierCoopLabel: SUPPLIER_COOP_LABELS[supplierCoop] || '',
latestExitDate,
latestExitDateLabel: latestExitDate,
latestExitDateAvailable: exitDates.available,
source: override ? 'jst' : 'replica',
noInboundRecord: !createdAtRaw,
});
@ -906,6 +919,7 @@ function composeRows({
});
// 这条是「导入里有、副本里没有」补进来的编码,没走副本 join,拿不到 coopersgn → 标「未知」
const supplierCoop = 'unknown';
const supplierExitDate = exitDates.bySupplier.get(String(override.supplier || '').trim()) || '';
rows.push({
productCode: code,
createdAt: formatDateTime(override.createdAt),
@ -933,6 +947,9 @@ function composeRows({
isOutbound,
supplierCoop,
supplierCoopLabel: SUPPLIER_COOP_LABELS[supplierCoop] || '',
latestExitDate: supplierExitDate,
latestExitDateLabel: supplierExitDate,
latestExitDateAvailable: exitDates.available,
source: 'jst',
noInboundRecord: !override.createdAt,
});
@ -1095,10 +1112,11 @@ async function getReturnStockWarningList(options = {}) {
const priceLevel = resolvePriceLevelFilter(options.priceLevel);
const today = formatLocalDate();
const [replicaRows, overrides, latestImport] = await Promise.all([
const [replicaRows, overrides, latestImport, exitDates] = await Promise.all([
loadReplicaRows({ refresh: String(options.refresh) === 'true', createdAtBasis, warehouse }),
loadImportOverrides(warehouse),
loadLatestImportBatch(warehouse),
loadSupplierExitDates(),
]);
const priceMap = await loadPriceMapForOverrides(replicaRows, overrides);
@ -1117,6 +1135,7 @@ async function getReturnStockWarningList(options = {}) {
priceHighMin,
returnLimitDays,
warehouse,
exitDates,
});
const filtered = composed.rows.filter((row) => matchesKeyword(row, keyword) && matchesSupplier(row, supplier));
@ -1167,6 +1186,12 @@ async function getReturnStockWarningList(options = {}) {
summary,
import: latestImport,
hasImport: composed.hasImport,
// 「最晚退厂时间」数据源状态(页面用它渲染「待接入」提示与列 Tooltip)
exitDateMeta: {
available: exitDates.available,
sourceLabel: exitDates.sourceLabel,
message: exitDates.message,
},
gaps: buildKnownGaps(warehouse),
};
}
@ -1187,6 +1212,7 @@ const buildExportColumns = (warehouse = DEFAULT_WAREHOUSE) => [
{ key: 'unitPriceLevelLabel', title: '单价预警' },
{ key: 'returnDeadlineLabel', title: '退厂时限' },
{ key: 'supplierCoopLabel', title: '供应商是否合作' },
{ key: 'latestExitDateLabel', title: '最晚退厂时间' },
{ key: 'returnQty', title: '退货仓库存(return_qty)' },
{ key: 'returnSlotQty', title: `客退临时上架库存(${warehouse.returnSlot})` },
{ key: 'sourceLabel', title: '数据来源' },
@ -1212,9 +1238,10 @@ async function getReturnStockWarningExportRows(options = {}) {
const priceLevel = resolvePriceLevelFilter(options.priceLevel);
const today = formatLocalDate();
const [replicaRows, overrides] = await Promise.all([
const [replicaRows, overrides, exitDates] = await Promise.all([
loadReplicaRows({ refresh: String(options.refresh) === 'true', createdAtBasis, warehouse }),
loadImportOverrides(warehouse),
loadSupplierExitDates(),
]);
const priceMap = await loadPriceMapForOverrides(replicaRows, overrides);
@ -1233,6 +1260,7 @@ async function getReturnStockWarningExportRows(options = {}) {
priceHighMin,
returnLimitDays,
warehouse,
exitDates,
});
const filtered = composed.rows.filter((row) => matchesKeyword(row, keyword) && matchesSupplier(row, supplier));
@ -1247,6 +1275,9 @@ async function getReturnStockWarningExportRows(options = {}) {
unitPriceLevelLabel: PRICE_WARNING_LABELS[row.unitPriceLevel] || '',
returnDeadlineLabel: formatReturnDeadlineLabel(row.returnDeadlineStatus, row.returnRemainDays),
supplierCoopLabel: row.supplierCoopLabel || SUPPLIER_COOP_LABELS.unknown,
// 工作平台未接入时导出显示「待接入」,避免空列被误读成「没有退厂时间」
latestExitDateLabel: row.latestExitDateLabel
|| (row.latestExitDateAvailable ? '—' : '待接入'),
sourceLabel: row.createdAtSourceLabel || '',
createdAtRaw: undefined,
}));

View File

@ -0,0 +1,140 @@
'use strict';
/**
* 「最晚退厂时间」取数(2026-09-28 新增)
*
* 业务口径:供应商不再合作时,工作平台会给该供应商一个「最晚退厂时间」,
* 即它放在仓里的货最后必须拉走的日期,看板上跟「供应商是否合作」列并排展示。
*
* ── 数据在哪 ──────────────────────────────────────────────────────────────
* 工作平台库。副本实例(218.93.9.21:1433)上共有 9 个库:
* master / model / msdb / tempdb / SSISDB / JSReportReplica(副本,我们只读它)/
* DataPlatformControl / WorkbenchDataShared / WorkbenchIdentity。
* 「工作平台」= WorkbenchDataShared(配套身份库 WorkbenchIdentity,控制库 DataPlatformControl)。
*
* ── 当前为什么是空的(2026-09-28 已逐条取证,别再重复排查)───────────────
* ① 副本账号 cangku 访问 WorkbenchDataShared / WorkbenchIdentity / DataPlatformControl
* **一律被拒**:The server principal "cangku" is not able to access the database
* "WorkbenchDataShared" under the current security context.
* ② 系统设置(database_configs)里只有 cloud_database_public(JSReportReplica)与
* product_information_database(PostgreSQL),没有工作平台库。
* ③ 本机 .env 与生产 /opt/cangku/shared/backend.env 里的 JIAN_SUN_DB_HOST/USER/PASSWORD、
* WORK_ORDER_* 全部为空(只留了端口/库名占位)。
* ④ 副本库内部没有任何「退厂」字段:5,496 个字段全量搜过,COLUMN_NAME 含「退厂」的为 0;
* 最接近的 fys_supplier.NoCoopTime(不合作时间)**不能拿来顶替** —— 它是停更快照
* (DownloadTime 多在 2023~2024),崔桥仓 8 个不合作供应商里只有 1 个有值,
* 而且那 1 条自身还标着 IsCoop='合作',自相矛盾。
*
* ── IT 开通后怎么接(只需改本文件)────────────────────────────────────────
* 二选一,改完即生效,页面与导出都会自动出数:
* A. 给副本账号授只读:让 IT 执行 `USE WorkbenchDataShared; GRANT SELECT TO [cangku];`
* (或建同义词/视图),然后把下面 EXIT_DATE_QUERY 填成跨库查询,例如:
* SELECT SupplierName AS supplierName, CONVERT(varchar(10), LatestExitDate, 120) AS exitDate
* FROM WorkbenchDataShared.dbo.<供应商不合作表>
* B. 让 IT 把「供应商不合作」表同步进 JSReportReplica(像 js_supplier / fys_supplier 那样),
* 再把 EXIT_DATE_QUERY 指向副本里的表名。
* 查询只需要返回两列:supplierName(供应商名,口径同 js_supplier.scmname / jst_sku.supplier_name,
* 即简称)与 exitDate(最晚退厂日期,字符串 'YYYY-MM-DD' 或 datetime 均可)。
*
* 未接入时本模块恒返回 available=false,页面该列显示「待接入」,不会报错、不影响其他列。
*/
const cloudDb = require('../config/cloudDatabase');
const SOURCE_LABEL = '工作平台(WorkbenchDataShared)· 供应商不合作';
const UNAVAILABLE_MESSAGE = '工作平台库尚未授权,待 IT 开通后自动出数';
// ---- 接入点(IT 开通后配这两个环境变量即可,不必改代码)--------------------
// SUPPLIER_EXIT_DATE_ENABLED=true
// SUPPLIER_EXIT_DATE_SQL=<查询语句,需返回 supplierName / exitDate 两列>
// 也可以在下面 CODE_* 常量里直接写死(env 优先)。
const CODE_EXIT_DATE_ENABLED = false;
const CODE_EXIT_DATE_QUERY = '';
const ENABLED = process.env.SUPPLIER_EXIT_DATE_ENABLED === 'true' || CODE_EXIT_DATE_ENABLED;
const EXIT_DATE_QUERY = process.env.SUPPLIER_EXIT_DATE_SQL || CODE_EXIT_DATE_QUERY;
// ---------------------------------------------------------------------------
const CACHE_TTL_MS = Number.parseInt(process.env.SUPPLIER_EXIT_DATE_CACHE_TTL_MS, 10) >= 0
? Number.parseInt(process.env.SUPPLIER_EXIT_DATE_CACHE_TTL_MS, 10)
: 30 * 60 * 1000;
let cache = null; // { at, payload }
const formatExitDate = (value) => {
if (value === null || value === undefined || value === '') return '';
if (value instanceof Date) {
if (Number.isNaN(value.getTime())) return '';
const y = value.getFullYear();
const m = String(value.getMonth() + 1).padStart(2, '0');
const d = String(value.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
const text = String(value).trim();
if (!text) return '';
const match = text.match(/^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})/);
if (!match) return text;
return `${match[1]}-${match[2].padStart(2, '0')}-${match[3].padStart(2, '0')}`;
};
/**
* 取「供应商 → 最晚退厂时间」映射。
* 返回 { available, sourceLabel, message, bySupplier: Map<string, string> }
*/
async function loadSupplierExitDates() {
const describe = () => ({
available: false,
sourceLabel: SOURCE_LABEL,
message: UNAVAILABLE_MESSAGE,
bySupplier: new Map(),
});
if (!ENABLED || !EXIT_DATE_QUERY) return describe();
if (cache && Date.now() - cache.at < CACHE_TTL_MS) return cache.payload;
try {
const rows = await cloudDb.query(EXIT_DATE_QUERY, {}, { requestTimeout: 120000 });
const bySupplier = new Map();
(rows || []).forEach((row) => {
const name = String(row.supplierName || '').trim();
const date = formatExitDate(row.exitDate);
if (!name || !date) return;
const prev = bySupplier.get(name);
// 同一供应商多条时取最晚的日期
if (!prev || date > prev) bySupplier.set(name, date);
});
const payload = {
available: true,
sourceLabel: SOURCE_LABEL,
message: '',
bySupplier,
};
cache = { at: Date.now(), payload };
return payload;
} catch (error) {
// 取不到不算致命:该列留空、页面提示待接入,其余列照常
console.warn('[SupplierExitDate] 最晚退厂时间取数失败:', error.message);
const payload = {
available: false,
sourceLabel: SOURCE_LABEL,
message: `工作平台库读取失败(${error.message}),该列暂空`,
bySupplier: new Map(),
};
cache = { at: Date.now(), payload };
return payload;
}
}
const EMPTY_EXIT_DATES = Object.freeze({
available: false,
sourceLabel: SOURCE_LABEL,
message: UNAVAILABLE_MESSAGE,
bySupplier: new Map(),
});
module.exports = {
loadSupplierExitDates,
formatExitDate,
EMPTY_EXIT_DATES,
EXIT_DATE_SOURCE_LABEL: SOURCE_LABEL,
EXIT_DATE_UNAVAILABLE_MESSAGE: UNAVAILABLE_MESSAGE,
};

View File

@ -84,6 +84,12 @@ const SUPPLIER_COOP_TIPS = {
unknown: '供应商名在简尚供应商主数据(js_supplier)里没有匹配记录,或该编码没有供应商,无法判断合作状态',
};
// 最晚退厂时间(工作平台「供应商不合作」)—— 2026-09-28 先上线占位:
// 数据属于工作平台库 WorkbenchDataShared,副本账号目前没有该库访问权限(后端
// supplierExitDateProvider.js 有完整取证),IT 开通后自动出数,页面无需再改。
const EXIT_DATE_PENDING_TIP = '数据来源:工作平台(WorkbenchDataShared)· 供应商不合作。该库当前未授权给副本账号,待 IT 开通只读权限(或把该表同步进报表副本)后自动显示最晚退厂日期。';
const EXIT_DATE_VALUE_TIP = '工作平台「供应商不合作」给出的最晚退厂日期:该供应商放在仓里的货最后必须拉走的日期';
// 预警等级过滤:主视图改成一排状态页签(全部 / 红色预警 / 关注 / 正常 / 未知),
// 因此不再需要下拉选项;导出时仍把 warningLevel 原样传给后端(服务端 filter 同口径)。
@ -375,6 +381,29 @@ const COLUMNS = [
);
},
},
{
title: '最晚退厂时间',
dataIndex: 'latestExitDateLabel',
width: 132,
align: 'center',
sorter: (a, b) => String(a.latestExitDateLabel || '').localeCompare(String(b.latestExitDateLabel || '')),
render: (value, record) => {
if (value) {
return (
<Tooltip title={EXIT_DATE_VALUE_TIP}>
<Tag color="orange">{value}</Tag>
</Tooltip>
);
}
// 已接入但该供应商没有退厂日期 → 显示破折号;整列未接入 → 显示「待接入」
if (record?.latestExitDateAvailable) return <span className="rsw-exit-date-empty">—</span>;
return (
<Tooltip title={EXIT_DATE_PENDING_TIP}>
<Tag color="default">待接入</Tag>
</Tooltip>
);
},
},
{
title: '创建时间来源',
dataIndex: 'createdAtSourceLabel',
@ -426,6 +455,8 @@ const StockWarning = ({ warehouse }) => {
rows: [], total: 0, summary: {}, warehouse: '', warehouseName: '', returnSlot: '',
warehouseHasReturnStock: true, statDate: '', stockSyncedAt: '', gaps: [], import: null,
hasImport: false,
// 最晚退厂时间数据源状态(工作平台库未接入时为 available:false + message)
exitDateMeta: { available: false, sourceLabel: '', message: '' },
});
const [loading, setLoading] = useState(false);
const [exporting, setExporting] = useState(false);
@ -464,6 +495,7 @@ const StockWarning = ({ warehouse }) => {
gaps: payload.gaps || [],
import: payload.import || null,
hasImport: Boolean(payload.hasImport),
exitDateMeta: payload.exitDateMeta || { available: false, sourceLabel: '', message: '' },
});
} catch (requestError) {
setError(requestError?.response?.data?.error || requestError?.message || '加载客退数据监控失败');

View File

@ -648,6 +648,11 @@
font-weight: 600;
}
/* 表格「最晚退厂时间」列:数据源已接入但该供应商无日期时的占位符 */
.rsw-exit-date-empty {
color: var(--sycm-text-quaternary, #bfbfbf);
}
/* ── 退货库存预警:图表区 ─────────────────────────────────── */
.rsw-charts {
display: grid;