diff --git a/backend/src/services/returnStockWarningService.js b/backend/src/services/returnStockWarningService.js index 5b8302d..9ad4438 100644 --- a/backend/src/services/returnStockWarningService.js +++ b/backend/src/services/returnStockWarningService.js @@ -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, })); diff --git a/backend/src/services/supplierExitDateProvider.js b/backend/src/services/supplierExitDateProvider.js new file mode 100644 index 0000000..e1ed94b --- /dev/null +++ b/backend/src/services/supplierExitDateProvider.js @@ -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 } + */ +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, +}; diff --git a/frontend/src/pages/ReturnsInventoryWarning/StockWarning.jsx b/frontend/src/pages/ReturnsInventoryWarning/StockWarning.jsx index b70077a..5b71cfe 100644 --- a/frontend/src/pages/ReturnsInventoryWarning/StockWarning.jsx +++ b/frontend/src/pages/ReturnsInventoryWarning/StockWarning.jsx @@ -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 ( + + {value} + + ); + } + // 已接入但该供应商没有退厂日期 → 显示破折号;整列未接入 → 显示「待接入」 + if (record?.latestExitDateAvailable) return —; + return ( + + 待接入 + + ); + }, + }, { 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 || '加载客退数据监控失败'); diff --git a/frontend/src/pages/ReturnsInventoryWarning/monitor.css b/frontend/src/pages/ReturnsInventoryWarning/monitor.css index b8a3ce5..b37d282 100644 --- a/frontend/src/pages/ReturnsInventoryWarning/monitor.css +++ b/frontend/src/pages/ReturnsInventoryWarning/monitor.css @@ -648,6 +648,11 @@ font-weight: 600; } +/* 表格「最晚退厂时间」列:数据源已接入但该供应商无日期时的占位符 */ +.rsw-exit-date-empty { + color: var(--sycm-text-quaternary, #bfbfbf); +} + /* ── 退货库存预警:图表区 ─────────────────────────────────── */ .rsw-charts { display: grid;