diff --git a/.axhub/make/sidebar-tree.json b/.axhub/make/sidebar-tree.json index 5fe0748..63505ab 100644 --- a/.axhub/make/sidebar-tree.json +++ b/.axhub/make/sidebar-tree.json @@ -1,7 +1,13 @@ { "version": 1, - "updatedAt": "2026-07-16T01:03:00.000Z", + "updatedAt": "2026-07-16T01:04:51.317Z", "prototypes": [ + { + "id": "item-prototypes-oneos-web-approval", + "kind": "item", + "title": "oneos web approval", + "itemKey": "prototypes/oneos-web-approval" + }, { "id": "folder-1783875936186-27raza", "kind": "folder", diff --git a/rules/global-design-spec.md b/rules/global-design-spec.md index 923424d..e04b554 100644 --- a/rules/global-design-spec.md +++ b/rules/global-design-spec.md @@ -395,7 +395,7 @@ const vmTheme = { | 合同 Ant 对齐 | `src/prototypes/lease-contract-management/styles/lease-contract.css` | | 参考:车辆管理 | `src/prototypes/vehicle-management/index.tsx` | | 参考:租赁合同 | `src/prototypes/lease-contract-management/LeaseContractManagement.jsx` | -| 参考:加氢站站点 | `src/prototypes/oneos-web-h2-station/pages/03-站点信息.jsx` | +| 参考:加氢站站点 | `src/prototypes/oneos-web-h2-station-site/pages/03-站点信息.jsx` | | 主题 token 来源 | `src/themes/linear/DESIGN.md`(inverse 浅色用于 vm) | --- diff --git a/src/common/h2VehicleLedgerBridge.js b/src/common/h2VehicleLedgerBridge.js index 07eecda..7d66bef 100644 --- a/src/common/h2VehicleLedgerBridge.js +++ b/src/common/h2VehicleLedgerBridge.js @@ -5,26 +5,32 @@ var H2_RECONCILE_RECONCILED = 'reconciled'; var H2_RECONCILE_PENDING = 'pending'; +var H2_VERIFY_VERIFIED = 'verified'; +var H2_VERIFY_UNVERIFIED = 'unverified'; var H2_STATION_CODE_MAP = { - '嘉兴加氢站(一期)': 'JX-H2-001', + '中国石油中油高新能源牙谷加油加氢站': 'JX-H2-001', '杭州临平加氢站': 'HZ-H2-002', '上海宝山加氢站': 'SH-H2-003', '苏州工业园区备用站': 'SZ-H2-004' }; -/** 与车辆氢费明细、加氢站模块对齐的 canonical seed(10 条) */ +/** 与车辆氢费明细、加氢站模块对齐的 canonical seed(含本站未核对 / 已核对未对账用例) */ var H2_CANONICAL_LEDGER_SEED = [ - { key: 'rf-1', id: 'rf-1', stationId: 'JX-H2-001', stationName: '嘉兴加氢站(一期)', hydrogenTime: '2026-05-28 10:21:08', plateNo: '浙A12345F', customerName: '嘉兴市鑫峤供应链科技有限公司', hydrogenKg: 12.5, costUnitPrice: 42.5, costTotal: 531.25, customerUnitPrice: 45, customerAmount: 562.5, settlementStatus: 'customer', mileageKm: 128560, creatorName: '张三', reconcileStatus: H2_RECONCILE_RECONCILED }, - { key: 'rf-2', id: 'rf-2', stationId: 'JX-H2-001', stationName: '嘉兴加氢站(一期)', hydrogenTime: '2026-05-26 14:08:33', plateNo: '浙A67890F', customerName: '浙江绿运物流有限公司', hydrogenKg: 10.0, costUnitPrice: 42.5, costTotal: 425.0, customerUnitPrice: 45, customerAmount: 450.0, settlementStatus: 'internal', mileageKm: 95230, creatorName: '李四', reconcileStatus: H2_RECONCILE_RECONCILED }, - { key: 'rf-3', id: 'rf-3', stationId: 'JX-H2-001', stationName: '嘉兴加氢站(一期)', hydrogenTime: '2026-05-22 09:15:00', plateNo: '浙A88888F', customerName: '嘉兴市鑫峤供应链科技有限公司', hydrogenKg: 18.3, costUnitPrice: 42.5, costTotal: 777.75, customerUnitPrice: 45, customerAmount: 823.5, settlementStatus: 'customer_self', mileageKm: 201880, creatorName: '王静', reconcileStatus: H2_RECONCILE_RECONCILED }, - { key: 'rf-4', id: 'rf-4', stationId: 'JX-H2-001', stationName: '嘉兴加氢站(一期)', hydrogenTime: '2026-05-18 16:42:11', plateNo: '浙A03561F', customerName: '嘉兴港务氢能运输队', hydrogenKg: 15.6, costUnitPrice: 42.5, costTotal: 663.0, customerUnitPrice: 45, customerAmount: 702.0, settlementStatus: 'customer', mileageKm: 167420, creatorName: '张三', reconcileStatus: H2_RECONCILE_RECONCILED }, - { key: 'rf-5', id: 'rf-5', stationId: 'HZ-H2-002', stationName: '杭州临平加氢站', hydrogenTime: '2026-05-30 09:30:22', plateNo: '浙B23456F', customerName: '杭州临平城配中心', hydrogenKg: 15.3, costUnitPrice: 43.0, costTotal: 657.9, customerUnitPrice: 46, customerAmount: 703.8, settlementStatus: 'internal', mileageKm: 143200, creatorName: '赵敏', reconcileStatus: H2_RECONCILE_RECONCILED }, - { key: 'rf-6', id: 'rf-6', stationId: 'HZ-H2-002', stationName: '杭州临平加氢站', hydrogenTime: '2026-05-27 18:10:05', plateNo: '浙B99999F', customerName: '浙江氢运科技', hydrogenKg: 18.2, costUnitPrice: 43.0, costTotal: 782.6, customerUnitPrice: 46, customerAmount: 837.2, settlementStatus: 'customer_self', mileageKm: 189650, creatorName: '陈浩', reconcileStatus: H2_RECONCILE_RECONCILED }, - { key: 'rf-7', id: 'rf-7', stationId: 'HZ-H2-002', stationName: '杭州临平加氢站', hydrogenTime: '2026-05-24 11:05:40', plateNo: '浙B58888F', customerName: '杭州临平城配中心', hydrogenKg: 11.8, costUnitPrice: 43.0, costTotal: 507.4, customerUnitPrice: 46, customerAmount: 542.8, settlementStatus: 'customer', mileageKm: 110340, creatorName: '李四', reconcileStatus: H2_RECONCILE_RECONCILED }, - { key: 'rf-8', id: 'rf-8', stationId: 'SH-H2-003', stationName: '上海宝山加氢站', hydrogenTime: '2026-04-20 16:45:18', plateNo: '沪A88888F', customerName: '上海羚牛氢运', hydrogenKg: 8.0, costUnitPrice: 44.0, costTotal: 352.0, customerUnitPrice: 47, customerAmount: 376.0, settlementStatus: 'internal', mileageKm: 88420, creatorName: '王静', reconcileStatus: H2_RECONCILE_RECONCILED }, - { key: 'rf-9', id: 'rf-9', stationId: 'SH-H2-003', stationName: '上海宝山加氢站', hydrogenTime: '2026-04-08 09:12:55', plateNo: '沪BDB9161F', customerName: '宝山园区试运车队', hydrogenKg: 9.5, costUnitPrice: 44.0, costTotal: 418.0, customerUnitPrice: 47, customerAmount: 446.5, settlementStatus: 'customer_self', mileageKm: 76500, creatorName: '张三', reconcileStatus: H2_RECONCILE_RECONCILED }, - { key: 'rf-10', id: 'rf-10', stationId: 'SZ-H2-004', stationName: '苏州工业园区备用站', hydrogenTime: '2026-03-15 10:00:00', plateNo: '苏E33333F', customerName: '苏州试运客户', hydrogenKg: 6.2, costUnitPrice: 41.0, costTotal: 254.2, customerUnitPrice: 44, customerAmount: 272.8, settlementStatus: 'customer', mileageKm: 45210, creatorName: '赵敏', reconcileStatus: H2_RECONCILE_PENDING } + /* 嘉兴本站 · 列表首页用例:未核对(无核对时间)→ 已核对未对账 → 已核对已对账 */ + { key: 'rf-11', id: 'rf-11', stationId: 'JX-H2-001', stationName: '中国石油中油高新能源牙谷加油加氢站', hydrogenTime: '2026-07-16 09:20:18', plateNo: '浙A55666F', customerName: '中国石油中油高新能源牙谷加油加氢站·站端上报', hydrogenKg: 11.2, costUnitPrice: 42.5, costTotal: 476.0, customerUnitPrice: 42.5, customerAmount: 476.0, settlementStatus: 'customer', mileageKm: 132080, creatorName: '站端账号', verifyStatus: H2_VERIFY_UNVERIFIED, verifiedAt: null, reconcileStatus: H2_RECONCILE_PENDING }, + { key: 'rf-12', id: 'rf-12', stationId: 'JX-H2-001', stationName: '中国石油中油高新能源牙谷加油加氢站', hydrogenTime: '2026-07-15 16:45:02', plateNo: '浙A77888F', customerName: '中国石油中油高新能源牙谷加油加氢站·站端上报', hydrogenKg: 9.8, costUnitPrice: 42.5, costTotal: 416.5, customerUnitPrice: 42.5, customerAmount: 416.5, settlementStatus: 'customer', mileageKm: null, creatorName: '站端账号', verifyStatus: H2_VERIFY_UNVERIFIED, verifiedAt: null, reconcileStatus: H2_RECONCILE_PENDING }, + { key: 'rf-13', id: 'rf-13', stationId: 'JX-H2-001', stationName: '中国石油中油高新能源牙谷加油加氢站', hydrogenTime: '2026-07-14 11:08:40', plateNo: '浙A99001F', customerName: '嘉兴市鑫峤供应链科技有限公司', hydrogenKg: 14.0, costUnitPrice: 42.5, costTotal: 595.0, customerUnitPrice: 45, customerAmount: 630.0, settlementStatus: 'customer', mileageKm: 155200, creatorName: '站端账号', verifyStatus: H2_VERIFY_VERIFIED, verifiedAt: '2026-07-14 15:30:00', reconcileStatus: H2_RECONCILE_PENDING, reconcileDate: null }, + { key: 'rf-1', id: 'rf-1', stationId: 'JX-H2-001', stationName: '中国石油中油高新能源牙谷加油加氢站', hydrogenTime: '2026-05-28 10:21:08', plateNo: '浙A12345F', customerName: '嘉兴市鑫峤供应链科技有限公司', hydrogenKg: 12.5, costUnitPrice: 42.5, costTotal: 531.25, customerUnitPrice: 45, customerAmount: 562.5, settlementStatus: 'customer', mileageKm: 128560, creatorName: '张三', verifyStatus: H2_VERIFY_VERIFIED, verifiedAt: '2026-05-29 10:00:00', reconcileStatus: H2_RECONCILE_RECONCILED, reconcileDate: '2026-05-29 10:00:00' }, + { key: 'rf-2', id: 'rf-2', stationId: 'JX-H2-001', stationName: '中国石油中油高新能源牙谷加油加氢站', hydrogenTime: '2026-05-26 14:08:33', plateNo: '浙A67890F', customerName: '浙江绿运物流有限公司', hydrogenKg: 10.0, costUnitPrice: 42.5, costTotal: 425.0, customerUnitPrice: 45, customerAmount: 450.0, settlementStatus: 'internal', mileageKm: 95230, creatorName: '李四', verifyStatus: H2_VERIFY_VERIFIED, verifiedAt: '2026-05-27 10:00:00', reconcileStatus: H2_RECONCILE_RECONCILED, reconcileDate: '2026-05-27 10:00:00' }, + { key: 'rf-3', id: 'rf-3', stationId: 'JX-H2-001', stationName: '中国石油中油高新能源牙谷加油加氢站', hydrogenTime: '2026-05-22 09:15:00', plateNo: '浙A88888F', customerName: '嘉兴市鑫峤供应链科技有限公司', hydrogenKg: 18.3, costUnitPrice: 42.5, costTotal: 777.75, customerUnitPrice: 45, customerAmount: 823.5, settlementStatus: 'customer_self', mileageKm: 201880, creatorName: '王静', verifyStatus: H2_VERIFY_VERIFIED, verifiedAt: '2026-05-23 10:00:00', reconcileStatus: H2_RECONCILE_RECONCILED, reconcileDate: '2026-05-23 10:00:00' }, + { key: 'rf-4', id: 'rf-4', stationId: 'JX-H2-001', stationName: '中国石油中油高新能源牙谷加油加氢站', hydrogenTime: '2026-05-18 16:42:11', plateNo: '浙A03561F', customerName: '嘉兴港务氢能运输队', hydrogenKg: 15.6, costUnitPrice: 42.5, costTotal: 663.0, customerUnitPrice: 45, customerAmount: 702.0, settlementStatus: 'customer', mileageKm: 167420, creatorName: '张三', verifyStatus: H2_VERIFY_VERIFIED, verifiedAt: '2026-05-19 10:00:00', reconcileStatus: H2_RECONCILE_RECONCILED, reconcileDate: '2026-05-19 10:00:00' }, + { key: 'rf-5', id: 'rf-5', stationId: 'HZ-H2-002', stationName: '杭州临平加氢站', hydrogenTime: '2026-05-30 09:30:22', plateNo: '浙B23456F', customerName: '杭州临平城配中心', hydrogenKg: 15.3, costUnitPrice: 43.0, costTotal: 657.9, customerUnitPrice: 46, customerAmount: 703.8, settlementStatus: 'internal', mileageKm: 143200, creatorName: '赵敏', verifyStatus: H2_VERIFY_VERIFIED, verifiedAt: '2026-05-31 10:00:00', reconcileStatus: H2_RECONCILE_RECONCILED, reconcileDate: '2026-05-31 10:00:00' }, + { key: 'rf-6', id: 'rf-6', stationId: 'HZ-H2-002', stationName: '杭州临平加氢站', hydrogenTime: '2026-05-27 18:10:05', plateNo: '浙B99999F', customerName: '浙江氢运科技', hydrogenKg: 18.2, costUnitPrice: 43.0, costTotal: 782.6, customerUnitPrice: 46, customerAmount: 837.2, settlementStatus: 'customer_self', mileageKm: 189650, creatorName: '陈浩', verifyStatus: H2_VERIFY_VERIFIED, verifiedAt: '2026-05-28 10:00:00', reconcileStatus: H2_RECONCILE_RECONCILED, reconcileDate: '2026-05-28 10:00:00' }, + { key: 'rf-7', id: 'rf-7', stationId: 'HZ-H2-002', stationName: '杭州临平加氢站', hydrogenTime: '2026-05-24 11:05:40', plateNo: '浙B58888F', customerName: '杭州临平城配中心', hydrogenKg: 11.8, costUnitPrice: 43.0, costTotal: 507.4, customerUnitPrice: 46, customerAmount: 542.8, settlementStatus: 'customer', mileageKm: 110340, creatorName: '李四', verifyStatus: H2_VERIFY_VERIFIED, verifiedAt: '2026-05-25 10:00:00', reconcileStatus: H2_RECONCILE_RECONCILED, reconcileDate: '2026-05-25 10:00:00' }, + { key: 'rf-8', id: 'rf-8', stationId: 'SH-H2-003', stationName: '上海宝山加氢站', hydrogenTime: '2026-04-20 16:45:18', plateNo: '沪A88888F', customerName: '上海羚牛氢运', hydrogenKg: 8.0, costUnitPrice: 44.0, costTotal: 352.0, customerUnitPrice: 47, customerAmount: 376.0, settlementStatus: 'internal', mileageKm: 88420, creatorName: '王静', verifyStatus: H2_VERIFY_VERIFIED, verifiedAt: '2026-04-21 10:00:00', reconcileStatus: H2_RECONCILE_RECONCILED, reconcileDate: '2026-04-21 10:00:00' }, + { key: 'rf-9', id: 'rf-9', stationId: 'SH-H2-003', stationName: '上海宝山加氢站', hydrogenTime: '2026-04-08 09:12:55', plateNo: '沪BDB9161F', customerName: '宝山园区试运车队', hydrogenKg: 9.5, costUnitPrice: 44.0, costTotal: 418.0, customerUnitPrice: 47, customerAmount: 446.5, settlementStatus: 'customer_self', mileageKm: 76500, creatorName: '张三', verifyStatus: H2_VERIFY_VERIFIED, verifiedAt: '2026-04-09 10:00:00', reconcileStatus: H2_RECONCILE_PENDING, reconcileDate: null }, + { key: 'rf-10', id: 'rf-10', stationId: 'SZ-H2-004', stationName: '苏州工业园区备用站', hydrogenTime: '2026-03-15 10:00:00', plateNo: '苏E33333F', customerName: '苏州试运客户', hydrogenKg: 6.2, costUnitPrice: 41.0, costTotal: 254.2, customerUnitPrice: 44, customerAmount: 272.8, settlementStatus: 'customer', mileageKm: 45210, creatorName: '赵敏', verifyStatus: H2_VERIFY_UNVERIFIED, verifiedAt: null, reconcileStatus: H2_RECONCILE_PENDING } ]; function h2BridgeCloneRows(rows) { @@ -38,6 +44,26 @@ function h2BridgeFormatDateTime(value) { return String(value); } +/** 已对账记录必须有对账时间;缺省时按加氢日次日 10:00:00 推导(原型演示) */ +function h2BridgeDeriveReconcileTime(hydrogenTime) { + var s = h2BridgeFormatDateTime(hydrogenTime); + var m = String(s).match(/^(\d{4})-(\d{2})-(\d{2})/); + if (!m) return '2026-01-01 10:00:00'; + var d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]) + 1, 10, 0, 0); + var pad = function (n) { return n < 10 ? '0' + n : String(n); }; + return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' 10:00:00'; +} + +function h2BridgeResolveReconcileTime(row) { + /* 对账时间仅来自对账单回写的 reconcileDate;勿用 reconciledAt(完成核对会写入) */ + var explicit = row.reconcileDate || ''; + if (explicit) return h2BridgeFormatDateTime(explicit); + if (row.reconcileStatus === H2_RECONCILE_RECONCILED) { + return h2BridgeDeriveReconcileTime(row.hydrogenTime); + } + return ''; +} + function h2BridgeMatchStatementPatch(row, patch) { if (patch.ledgerId && row.ledgerSyncId && patch.ledgerId === row.ledgerSyncId) return true; var rowTime = h2BridgeFormatDateTime(row.hydrogenTime); @@ -66,7 +92,6 @@ function h2BridgeApplyStatementPatches(rows, patches) { receiptDate: patch.receiptDate, stationPaymentStatus: patch.paymentStatus || 'paid', paymentStatus: patch.paymentStatus || 'paid', - reconciledAt: patch.reconcileDate, statementRecordId: patch.statementRecordId != null ? patch.statementRecordId : r.statementRecordId }); }); @@ -77,9 +102,71 @@ function h2BridgeBuildInitialRows() { return h2BridgeApplyStatementPatches(h2BridgeCloneRows(H2_CANONICAL_LEDGER_SEED), patches); } +function h2BridgePad2(n) { + return n < 10 ? '0' + n : String(n); +} + +/** 自然日 YYYY-MM-DD(设备本地时区) */ +function h2BridgeTodayDateKey(d) { + var x = d || new Date(); + return x.getFullYear() + '-' + h2BridgePad2(x.getMonth() + 1) + '-' + h2BridgePad2(x.getDate()); +} + +function h2BridgeYesterdayDateKey() { + var d = new Date(); + d.setDate(d.getDate() - 1); + return h2BridgeTodayDateKey(d); +} + +function h2BridgeManualLedgerKey(stationId, ledgerDate) { + return String(stationId || '').trim() + '|' + String(ledgerDate || '').trim(); +} + +function h2BridgeResolveStationId(stationId, stationName) { + var id = String(stationId || '').trim(); + if (id) return id; + var name = String(stationName || '').trim(); + return H2_STATION_CODE_MAP[name] || ''; +} + +/** 手工台账种子:昨日已上传;今日默认未上传,便于演示拦截新增 */ +function h2BridgeBuildInitialManualLedgers() { + var yesterday = h2BridgeYesterdayDateKey(); + return [ + { + id: 'ml-seed-yesterday', + stationId: 'JX-H2-001', + stationName: '中国石油中油高新能源牙谷加油加氢站', + ledgerDate: yesterday, + images: [ + { + id: 'ml-img-seed-1', + name: '昨日手工台账.jpg', + dataUrl: '', + placeholder: true, + archived: true, + uploadedAt: yesterday + ' 18:30:00' + } + ], + updatedAt: yesterday + ' 18:30:00' + } + ]; +} + +function h2BridgeCloneManualLedgers(list) { + return (list || []).map(function (item) { + return Object.assign({}, item, { + images: (item.images || []).map(function (img) { return Object.assign({}, img); }) + }); + }); +} + function h2BridgeCreateStore() { var listeners = []; - var state = { rows: h2BridgeBuildInitialRows() }; + var state = { + rows: h2BridgeBuildInitialRows(), + manualLedgers: h2BridgeBuildInitialManualLedgers() + }; function notify() { listeners.forEach(function (fn) { @@ -93,6 +180,7 @@ function h2BridgeCreateStore() { state.rows = h2BridgeCloneRows(rows); notify(); }, + getManualLedgers: function () { return h2BridgeCloneManualLedgers(state.manualLedgers); }, subscribe: function (fn) { listeners.push(fn); return function () { @@ -115,6 +203,7 @@ function h2BridgeCreateStore() { next.key = next.key || next.id; } if (!next.reconcileStatus) next.reconcileStatus = H2_RECONCILE_PENDING; + if (!next.verifyStatus) next.verifyStatus = H2_VERIFY_UNVERIFIED; var found = false; state.rows = state.rows.map(function (r) { if ((r.id || r.key) === next.id || (r.id || r.key) === next.key) { @@ -137,6 +226,93 @@ function h2BridgeCreateStore() { var changed = state.rows.length !== before; if (changed) notify(); return changed; + }, + upsertManualLedger: function (input) { + if (!input) return null; + var stationId = h2BridgeResolveStationId(input.stationId, input.stationName); + var stationName = String(input.stationName || '').trim(); + var ledgerDate = String(input.ledgerDate || h2BridgeTodayDateKey()).trim(); + if (!stationId || !ledgerDate) return null; + var key = h2BridgeManualLedgerKey(stationId, ledgerDate); + var existing = null; + state.manualLedgers.forEach(function (item) { + if (h2BridgeManualLedgerKey(item.stationId, item.ledgerDate) === key) existing = item; + }); + function normalizeImage(img, i) { + return { + id: img.id || ('ml-img-' + Date.now() + '-' + i), + name: String(img.name || ('台账照片' + (i + 1) + '.jpg')), + dataUrl: String(img.dataUrl || ''), + placeholder: Boolean(img.placeholder), + archived: true, + uploadedAt: img.uploadedAt || (ledgerDate + ' 12:00:00') + }; + } + /** 已归档图不可删:保留原有全部,仅追加新图 */ + var images = (existing && existing.images ? existing.images : []).map(function (img, i) { + return normalizeImage(img, i); + }); + var seen = {}; + images.forEach(function (img) { seen[img.id] = true; }); + (input.images || []).forEach(function (img, i) { + var nextImg = normalizeImage(img, images.length + i); + if (!nextImg.dataUrl && !nextImg.placeholder) return; + if (seen[nextImg.id]) return; + seen[nextImg.id] = true; + images.push(nextImg); + }); + if (!images.length) return null; + var now = new Date(); + var nowStr = now.getFullYear() + '-' + h2BridgePad2(now.getMonth() + 1) + '-' + h2BridgePad2(now.getDate()) + + ' ' + h2BridgePad2(now.getHours()) + ':' + h2BridgePad2(now.getMinutes()) + ':' + h2BridgePad2(now.getSeconds()); + var next = { + id: 'ml-' + stationId + '-' + ledgerDate, + stationId: stationId, + stationName: stationName || stationId, + ledgerDate: ledgerDate, + images: images, + updatedAt: nowStr + }; + var found = false; + state.manualLedgers = state.manualLedgers.map(function (item) { + if (h2BridgeManualLedgerKey(item.stationId, item.ledgerDate) === key) { + found = true; + return next; + } + return item; + }); + if (!found) state.manualLedgers = [next].concat(state.manualLedgers); + notify(); + return h2BridgeCloneManualLedgers([next])[0]; + }, + getManualLedger: function (stationIdOrName, ledgerDate) { + var dateKey = String(ledgerDate || h2BridgeTodayDateKey()).trim(); + var sid = h2BridgeResolveStationId(stationIdOrName, stationIdOrName); + if (!sid) sid = h2BridgeResolveStationId('', stationIdOrName); + if (!sid) return null; + var key = h2BridgeManualLedgerKey(sid, dateKey); + var i; + for (i = 0; i < state.manualLedgers.length; i++) { + var item = state.manualLedgers[i]; + if (h2BridgeManualLedgerKey(item.stationId, item.ledgerDate) === key) { + return h2BridgeCloneManualLedgers([item])[0]; + } + } + return null; + }, + listManualLedgersByStation: function (stationIdOrName) { + var sid = h2BridgeResolveStationId(stationIdOrName, stationIdOrName); + if (!sid) sid = h2BridgeResolveStationId('', stationIdOrName); + if (!sid) return []; + return h2BridgeCloneManualLedgers(state.manualLedgers.filter(function (item) { + return item.stationId === sid; + })).sort(function (a, b) { + return String(b.ledgerDate).localeCompare(String(a.ledgerDate)); + }); + }, + hasManualLedgerForDate: function (stationIdOrName, ledgerDate) { + var entry = this.getManualLedger(stationIdOrName, ledgerDate); + return Boolean(entry && entry.images && entry.images.length); } }; } @@ -193,8 +369,9 @@ function h2BridgeMapToHrRecord(row, index, fillerNames) { var costTotal = row.costTotal != null ? row.costTotal : row.costAmount; var unitPrice = row.costUnitPrice != null ? row.costUnitPrice : row.customerUnitPrice; var totalAmount = costTotal != null ? costTotal : row.customerAmount; - var reconcileTime = row.reconcileDate || row.reconciledAt || ''; - var isReconciled = row.reconcileStatus === H2_RECONCILE_RECONCILED || Boolean(reconcileTime); + var reconcileTime = h2BridgeResolveReconcileTime(row); + var isReconciled = row.reconcileStatus === H2_RECONCILE_RECONCILED || Boolean(row.reconcileDate); + var verifiedAt = row.verifiedAt || ''; return { id: row.id || row.key || ('hr-' + (index + 1)), hydrogenTime: h2BridgeFormatDateTime(row.hydrogenTime), @@ -211,8 +388,10 @@ function h2BridgeMapToHrRecord(row, index, fillerNames) { settlementStatus: row.settlementStatus, stationName: row.stationName, stationCode: row.stationId || H2_STATION_CODE_MAP[row.stationName] || '', + verifyStatus: row.verifyStatus === H2_VERIFY_VERIFIED ? H2_VERIFY_VERIFIED : H2_VERIFY_UNVERIFIED, + verifiedAt: verifiedAt ? h2BridgeFormatDateTime(verifiedAt) : '', reconcileStatus: isReconciled ? H2_RECONCILE_RECONCILED : H2_RECONCILE_PENDING, - reconcileTime: isReconciled ? h2BridgeFormatDateTime(reconcileTime) : '' + reconcileTime: isReconciled ? reconcileTime : '' }; } @@ -299,6 +478,84 @@ function h2BridgeGetStationList() { return list; } +/** 车牌写入口径:大写并补尾缀 F */ +function h2BridgeNormalizePlate(plateNo) { + var plate = String(plateNo || '').trim().toUpperCase(); + if (plate && !/F$/u.test(plate)) plate += 'F'; + return plate; +} + +/** 时间写入口径:补全秒 */ +function h2BridgeNormalizeHydrogenTime(hydrogenTime) { + var s = String(hydrogenTime || '').trim(); + if (s.length === 16) s += ':00'; + return s; +} + +/** + * 业务主键:同站 + 同车牌 + 同加氢时间(精确到秒)视为重复。 + * @returns {object|null} 已存在行的浅拷贝,未命中返回 null + */ +function h2BridgeFindDuplicateRow(candidate, excludeId) { + if (!candidate) return null; + var store = h2BridgeGetStore(); + if (!store) return null; + var station = String(candidate.stationName || '').trim(); + var plate = h2BridgeNormalizePlate(candidate.plateNo); + var time = h2BridgeNormalizeHydrogenTime(candidate.hydrogenTime); + if (!station || !plate || !time) return null; + var exclude = excludeId != null ? String(excludeId) : ''; + var rows = store.getRows(); + var i; + for (i = 0; i < rows.length; i++) { + var row = rows[i]; + var id = String(row.id || row.key || ''); + if (exclude && id === exclude) continue; + if (String(row.stationName || '').trim() !== station) continue; + if (h2BridgeNormalizePlate(row.plateNo) !== plate) continue; + if (h2BridgeNormalizeHydrogenTime(row.hydrogenTime) !== time) continue; + return Object.assign({}, row); + } + return null; +} + +/** + * 总额与「单价×量」偏差(元)。容差默认 0.05。 + * @returns {{ expected: number, actual: number, diff: number }|null} + */ +function h2BridgeTotalAmountMismatch(unitPrice, hydrogenKg, totalAmount, toleranceYuan) { + var p = Number(unitPrice); + var k = Number(hydrogenKg); + var a = Number(totalAmount); + if (!isFinite(p) || !isFinite(k) || !isFinite(a)) return null; + var expected = Math.round(p * k * 100) / 100; + var actual = Math.round(a * 100) / 100; + var tol = toleranceYuan != null ? Number(toleranceYuan) : 0.05; + if (!isFinite(tol)) tol = 0.05; + var diff = Math.round(Math.abs(actual - expected) * 100) / 100; + if (diff <= tol) return null; + return { expected: expected, actual: actual, diff: diff }; +} + +/** 取该站最近一条流水的站端口径单价,缺省 42.5 */ +function h2BridgeLookupStationUnitPrice(stationName) { + var name = String(stationName || '').trim(); + if (!name) return 42.5; + var store = h2BridgeGetStore(); + if (!store) return 42.5; + var rows = store.getRows().slice().sort(function (a, b) { + return String(b.hydrogenTime || '').localeCompare(String(a.hydrogenTime || '')); + }); + var i; + for (i = 0; i < rows.length; i++) { + if (String(rows[i].stationName || '').trim() !== name) continue; + var price = rows[i].costUnitPrice != null ? rows[i].costUnitPrice : rows[i].customerUnitPrice; + var n = Number(price); + if (isFinite(n) && n >= 0) return Math.round(n * 100) / 100; + } + return 42.5; +} + function h2BridgeInit() { h2BridgeEnsureApi(); var store = h2BridgeGetStore(); @@ -311,6 +568,24 @@ function h2BridgeInit() { getHrRecords: h2BridgeGetHrRecords, getOrderRecords: h2BridgeGetOrderRecords, getStationList: h2BridgeGetStationList, + normalizePlate: h2BridgeNormalizePlate, + normalizeHydrogenTime: h2BridgeNormalizeHydrogenTime, + findDuplicateRow: h2BridgeFindDuplicateRow, + totalAmountMismatch: h2BridgeTotalAmountMismatch, + lookupStationUnitPrice: h2BridgeLookupStationUnitPrice, + todayDateKey: h2BridgeTodayDateKey, + getManualLedger: function (stationIdOrName, ledgerDate) { + return store.getManualLedger(stationIdOrName, ledgerDate); + }, + listManualLedgersByStation: function (stationIdOrName) { + return store.listManualLedgersByStation(stationIdOrName); + }, + hasManualLedgerForDate: function (stationIdOrName, ledgerDate) { + return store.hasManualLedgerForDate(stationIdOrName, ledgerDate); + }, + upsertManualLedger: function (input) { + return store.upsertManualLedger(input); + }, upsertRow: function (row) { return store.upsertRow(row); }, removeRow: function (id) { return store.removeRow(id); }, subscribe: function (fn) { return store.subscribe(fn); } @@ -326,6 +601,8 @@ export { H2_STATION_CODE_MAP, H2_RECONCILE_RECONCILED, H2_RECONCILE_PENDING, + H2_VERIFY_VERIFIED, + H2_VERIFY_UNVERIFIED, h2BridgeInit, h2BridgeGetStore, h2BridgeGetAllRefuelRecords, @@ -333,5 +610,10 @@ export { h2BridgeBuildStationLedgerStore, h2BridgeGetHrRecords, h2BridgeGetOrderRecords, - h2BridgeGetStationList + h2BridgeGetStationList, + h2BridgeNormalizePlate, + h2BridgeNormalizeHydrogenTime, + h2BridgeFindDuplicateRow, + h2BridgeTotalAmountMismatch, + h2BridgeLookupStationUnitPrice }; diff --git a/src/prototypes/lease-business-detail/.spec/import-rules.md b/src/prototypes/lease-business-detail/.spec/import-rules.md new file mode 100644 index 0000000..042a3c4 --- /dev/null +++ b/src/prototypes/lease-business-detail/.spec/import-rules.md @@ -0,0 +1,45 @@ +# 租赁业务明细 · 导入与金额规则 + +> 实现:`utils/row-key.ts`、`utils/import-merge.ts`、`utils/batch-io.ts`、`utils/validate-detail.ts` +> UI:`components/DetailImportModal.tsx`、`components/DetailEditModal.tsx` + +## 导入业务主键(去重) + +| 组成字段 | 说明 | +|---|---| +| 年份 | `year` | +| 月份 | `month` | +| 车牌号码 | `plateNo`(trim) | +| **客户名称** | `customerName`(trim) | + +键格式:`{year}-{MM}-{plateNo}::{customerName}`(见 `detailRowBusinessKey`)。 + +同一主键下支持三种策略: + +| 策略 | 行为 | +|---|---| +| 跳过重复(默认) | 已存在同键记录则跳过导入行 | +| 覆盖重复 | 用导入行覆盖已有记录(保留原 `id`) | +| 报错 | 重复行写入错误日志,不入库 | + +**边界**:同一车牌、同一月份、**不同客户**视为两条不同记录,不互相覆盖。 + +数据源:原型本地导入解析 + 内存中的已有明细;**未接真实后端 API**。 + +--- + +## 实收金额 vs 应收合计 + +| 项 | 规则 | +|---|---| +| 导入 | **不拦截**实收大于应收合计 | +| 编辑保存 | **不拦截**实收大于应收合计 | +| 未收计算 | 仍为「应收合计 − 实收金额」;实收超额时未收为负,列表用超额样式提示 | + +历史规则「实收金额 ≤ 应收合计」已在 `validate-detail.ts` 中注释停用,函数保留签名便于对照。 + +--- + +## 与收款状态的关系 + +收款状态仍按实收与应收比较展示(未收款 / 部分收款 / 已结清);实收 ≥ 应收合计时为「已结清」,与是否允许多收不冲突。 diff --git a/src/prototypes/lease-business-detail/.spec/prototype-review.md b/src/prototypes/lease-business-detail/.spec/prototype-review.md index b15204f..23f91f7 100644 --- a/src/prototypes/lease-business-detail/.spec/prototype-review.md +++ b/src/prototypes/lease-business-detail/.spec/prototype-review.md @@ -34,8 +34,8 @@ | 场景 | 现状 | |------|------| -| 重复导入 | 跳过 / 覆盖 / 报错 + 摘要 Toast | -| 实收超额 | 导入/编辑拦截;列表负未收警示 | +| 重复导入 | 跳过 / 覆盖 / 报错;主键 year-month-plate-customer | +| 实收超额 | **允许**;导入/编辑不拦截;列表负未收/超额样式 | | 行内操作 | 编辑、删除、变更日志 | ## 证据与评估说明 @@ -49,6 +49,6 @@ |---|---|---| | P1 | 缺少盈亏月度汇总 Tab | ➡️ 改由业务部台账;本页已移除 Tab | | P1 | 对账/锁定无入口 | ➡️ 产品范围调整:本页不做对账锁定 | -| P2 | 导入重复键 | ✅ 三策略 + 主键 year-month-plate | -| P2 | 实收超额 | ✅ 导入/编辑校验 + 负未收样式 | +| P2 | 导入重复键 | ✅ 三策略 + 主键 year-month-plate-**customer** | +| P2 | 实收超额 | ➡️ 产品调整:**允许**超额;拦截已停用 | | P3 | PRD/annotation 偏差 | ✅ 公式、导出 48 列、边界文案已更新 | diff --git a/src/prototypes/lease-business-detail/.spec/requirements-prd.md b/src/prototypes/lease-business-detail/.spec/requirements-prd.md index 32e7800..21a9f38 100644 --- a/src/prototypes/lease-business-detail/.spec/requirements-prd.md +++ b/src/prototypes/lease-business-detail/.spec/requirements-prd.md @@ -79,7 +79,7 @@ ### 导入重复策略 -业务主键:`年份 + 月份 + 车牌号码`。 +业务主键:`年份 + 月份 + 车牌号码 + 客户名称`(完整规则见 **`.spec/import-rules.md`**)。 | 策略 | 行为 | |---|---| @@ -87,7 +87,9 @@ | 覆盖重复 | 用导入行覆盖已有记录 | | 报错 | 重复行写入错误日志 | -导入与编辑均校验:**实收金额 ≤ 应收合计**。 +### 实收金额 + +**不限制**实收 ≤ 应收合计:导入与编辑均允许实收大于应收合计(未收可为负,列表超额样式提示)。旧规则已停用,见 `.spec/import-rules.md`。 --- @@ -179,9 +181,10 @@ | 筛选 / KPI 汇总 | `utils/detail.ts` | | 收款状态 | `utils/payment-status.ts` | | 公式计算 | `utils/calc-detail.ts` | -| 导入导出 | `utils/batch-io.ts`、`utils/import-merge.ts` | +| 导入导出 | `utils/batch-io.ts`、`utils/import-merge.ts`、`utils/row-key.ts` | | 单元格校验 | `utils/field-checks.ts`、`utils/system-ref.ts`、`components/FieldCheckIcon.tsx` | -| 保存校验 | `utils/validate-detail.ts` | +| 保存校验 | `utils/validate-detail.ts`(实收超额拦截已停用) | +| 导入规则说明 | `.spec/import-rules.md` | | 变更日志 | `utils/change-log.ts` | | 类型 | `types.ts` | | 组件 | `FilterPanel.tsx`、`DetailKpiRow.tsx`、`DetailTable.tsx`、`DetailImportModal.tsx`、`DetailEditModal.tsx`、`DetailChangeLogModal.tsx` | @@ -196,6 +199,8 @@ 4. 氢费非零可点击明细;编辑/删除/变更日志可用 5. 未收 > 0 标红;盈亏正负着色 6. 客户名称等校验列:通过显示绿勾,不一致显示黄警告并可查看说明 / 一键替换(规则见 `.spec/field-checks.md`) +7. 导入主键含客户名称;同车牌同月不同客户可并存 +8. 实收可大于应收合计:导入/编辑不拦截;未收可为负并有超额样式 --- diff --git a/src/prototypes/lease-business-detail/annotation-source.json b/src/prototypes/lease-business-detail/annotation-source.json index 2cca98d..6cc8d21 100644 --- a/src/prototypes/lease-business-detail/annotation-source.json +++ b/src/prototypes/lease-business-detail/annotation-source.json @@ -5,7 +5,7 @@ "version": 2, "prototypeName": "lease-business-detail", "pageId": "list", - "updatedAt": 1783932410001, + "updatedAt": 1784265940579, "nodes": [ { "id": "lbd-filter", @@ -62,7 +62,7 @@ "path": [] }, "aiPrompt": "导入模板与导出列范围。", - "annotationText": "## 4. 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 批量导入 | 下载模板(36 列)→ 选择重复策略 → 上传 Excel |\n| 批量导出 | 导出当前筛选结果(状态 + 47 列业务字段,共 48 列) |\n\n### 行内操作\n\n编辑、删除、变更日志。\n\n### 导入重复策略\n\n主键:年份 + 月份 + 车牌。支持跳过 / 覆盖 / 报错;实收 ≤ 应收合计。\n\n---", + "annotationText": "## 4. 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 批量导入 | 下载模板(36 列)→ 选择重复策略 → 上传 Excel |\n| 批量导出 | 导出当前筛选结果(状态 + 47 列业务字段,共 48 列) |\n\n### 行内操作\n\n编辑、删除、变更日志。\n\n### 导入重复策略\n\n主键:年份 + 月份 + 车牌 + **客户名称**。支持跳过 / 覆盖 / 报错。\n\n### 实收金额\n\n允许实收大于应收合计(导入/编辑不拦截)。详见 `.spec/import-rules.md`。\n\n---", "hasMarkdown": true, "color": "#7c3aed", "images": [], @@ -280,10 +280,10 @@ "markdownMap": { "lbd-filter": "## 2. 筛选区\n\n默认展示 **首行 4 项**(统计月份、业务部门、业务员、客户名称),「更多筛选」展开**状态、车牌号码**(与全局 `vm-filter-panel.ts` 4 列栅格规则一致,避免末行仅 1 项)。\n\n| 筛选项 | 说明 |\n|---|---|\n| 统计月份 | `YYYY-MM` 格式,精确匹配年份+月份 |\n| 业务部门 | 精确匹配 |\n| 业务员 | 精确匹配 |\n| 客户名称 | 模糊包含 |\n| 状态 | 已结清 / 部分收款 / 未收款(按实收与应收合计计算;更多筛选) |\n| 车牌号码 | 多选精确匹配(更多筛选) |\n\n查询按钮触发筛选;KPI 与表尾汇总基于**全量筛选结果**(非当前页)。\n\n---", "lbd-kpi": "## 3. KPI 统计卡片(7 张)\n\n顶部展示关键指标;完整 23 项金额合计见列表底部汇总行。\n\n| KPI | 汇总字段 |\n|---|---|\n| 应收总计 | receivableTotal |\n| 押金总计 | deposit(仅数值型) |\n| 实收总计 | receivedAmount |\n| 里程减免总计 | mileageDeduction |\n| 其他减免总计 | otherDeduction |\n| 未收总计 | outstanding(>0 标红) |\n| 盈亏总计 | profitLoss(正负着色) |\n\n---", - "lbd-toolbar": "## 4. 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 批量导入 | 下载模板(36 列)→ 选择重复策略 → 上传 Excel |\n| 批量导出 | 导出当前筛选结果(状态 + 47 列业务字段,共 48 列) |\n\n### 行内操作\n\n编辑、删除、变更日志。\n\n### 导入重复策略\n\n主键:年份 + 月份 + 车牌。支持跳过 / 覆盖 / 报错;实收 ≤ 应收合计。\n\n---", + "lbd-toolbar": "## 4. 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 批量导入 | 下载模板(36 列)→ 选择重复策略 → 上传 Excel |\n| 批量导出 | 导出当前筛选结果(状态 + 47 列业务字段,共 48 列) |\n\n### 行内操作\n\n编辑、删除、变更日志。\n\n### 导入重复策略\n\n主键:年份 + 月份 + 车牌 + **客户名称**。支持跳过 / 覆盖 / 报错。\n\n### 实收金额\n\n允许实收大于应收合计(导入/编辑不拦截)。详见 `.spec/import-rules.md`。\n\n---", "lbd-table": "## 5. 明细列表(48 列)\n\n表头顺序对齐 Excel「2.租赁车辆收入明细表」,并在月份列左侧增加「状态」列。\n\n### 交互\n\n- 横向滚动;左冻结:**状态 + 月份 + 车牌号码**\n- 仅**日期类列**表头支持正序/倒序排序\n- 公式列(`calculated`)浅灰底色,导入模板不含\n- 表尾**汇总行**:23 个金额列 SUBTOTAL,月份列显示「汇总」\n- 未收 > 0 单元格标红;盈亏正负着色\n- 底部分页:10 / 20 / 50 / 100\n\n### 收款状态(状态列)\n\n| 状态 | 判定规则 |\n|---|---|\n| 未收款 | 实收金额 = 0 或未录入 |\n| 部分收款 | 实收金额 > 0 且 < 应收合计 |\n| 已结清 | 实收金额 ≥ 应收合计 |\n\n### 表尾汇总(23 项)\n\n押金、合同标的租金、应收合计、应收租金、月度收入、月度租金、维保包干收入、保险上浮费、运维费(收入)、其他收入、里程减免金额、其他减免金额、实收金额、未收、车辆标准成本、车辆实际成本、保险费、氢费、运维费(成本)、居间费、其他、总成本、盈亏。\n\n### 单元格系统校验\n\n部分列在单元格右侧展示校验图标(绿色 ✓ / 黄色 ⚠),点击可查看说明;警告项在支持时提供**一键替换**。完整规则见 **`.spec/field-checks.md`**。\n\n**对照数据源(原型本地种子)**:客户管理、车辆管理、明细种子合同(`system-ref.ts` 聚合);当前未接真实后端。\n\n| 字段 | 校验要点 |\n|---|---|\n| 车牌号码 | 是否在车辆管理中登记 |\n| **客户名称** | 见下表 |\n| 提车 / 退车日期 | 与交车管理 / 还车记录一致 |\n| 押金、合同标的租金 | 与租赁合同一致 |\n| 应收租金 | = 合同标的租金 × 结算周期月数 |\n| 里程 / 其他减免 | 应以负数录入 |\n| 资产归属 | 与车辆登记所有权一致 |\n\n#### 客户名称判定顺序\n\n1. **通过(强)**:按「车牌 + 提车日期 + 退车日期」匹配合同,期望客户与当前名**完全一致** →「与系统登记一致」。\n2. **通过(强)**:「车牌 + 客户名」能命中租赁合同且名称一致 →「与租赁合同一致」。\n3. **警告**:能推算期望客户但与当前名不一致 → 提示建议客户,**可一键替换**。\n4. **通过(弱)**:客户名在客户管理中已登记(全等或双向包含简称/全称)→「已在客户管理中登记」。\n5. **警告**:以上均不满足 →「与系统不对应,请核对提车/退车日期」。\n\n车牌或客户名为空时不显示校验图标。\n\n---", "lbd-doc-formulas": "## 6. 公式列(系统自动计算)\n\n| 字段 | 计算方式 |\n|---|---|\n| **应收合计** | 应收租金 + 维保包干收入 + 保险上浮费 + 运维费(收入)+ 其他收入 + 里程减免 + 其他减免 |\n| **月度收入** | 月度租金 + 维保包干收入 + 保险上浮费 + 运维费(收入)+ 其他收入 + 里程减免 + 其他减免 |\n| **未收** | 应收合计 − 实收金额 |\n| **车辆实际成本** | 车辆标准成本 × 平均天数 |\n| **总成本** | 车辆实际成本 + 保险费 + 氢费 + 运维费(成本)+ 居间费 + 其他 |\n| **盈亏** | 月度收入 − 总成本 |\n\n---", - "lbd-doc-boundary": "## 模块边界\n\n| 模块 | 定位 |\n|---|---|\n| **租赁业务明细** | Excel 宽表维护;48 列;收款状态;导入去重与实收校验 |\n| **租赁业务台账** | 账单维度、收款关联、归档与数据范围 |\n| **业务部台账** | 租赁业绩与盈亏月度汇总(数据来源含本模块明细) |\n\n### 不做范围\n\n- 本页不做盈亏月度汇总(见业务部台账)\n- 服务端持久化、员工数据范围隔离\n- 收款记录自动回写实收\n\n---", + "lbd-doc-boundary": "## 模块边界\n\n| 模块 | 定位 |\n|---|---|\n| **租赁业务明细** | Excel 宽表维护;48 列;收款状态;导入去重(含客户名称) |\n| **租赁业务台账** | 账单维度、收款关联、归档与数据范围 |\n| **业务部台账** | 租赁业绩与盈亏月度汇总(数据来源含本模块明细) |\n\n### 不做范围\n\n- 本页不做盈亏月度汇总(见业务部台账)\n- 服务端持久化、员工数据范围隔离\n- 收款记录自动回写实收\n\n---", "lbd-doc-overview": "## 1. 模块定位\n\n| 项 | 说明 |\n|---|---|\n| 模块名称 | 台账数据 — 租赁业务明细 |\n| 目标用户 | 业务二部业管 / 运营人员 |\n| 核心任务 | 按 Excel 样表维护租赁车辆收入明细、查询汇总、批量导入导出 |\n| 页面结构 | 筛选区 + 7 项 KPI + 工具栏 + 48 列宽表 + 分页 |\n| 设计基底 | vm-page + ldb-page + lc-page |\n\n对齐 Excel《2026年业务二部运营台账总表》「2.租赁车辆收入明细表」。\n\n---", "lbd-col-receivable-total": "## 应收合计\n\n**计算方式**:维保包干收入 + 保险上浮费 + 运维费(收入)+ 其他收入 + 里程减免金额(负数)+ 其他减免金额(负数)。\n**录入方式**:系统自动计算,只读。\n\n---", "lbd-col-outstanding": "## 未收\n\n**计算方式**:应收合计 − 实收金额。\n**录入方式**:系统自动计算,只读。\n\n---", @@ -295,7 +295,8 @@ "lbd-changelog-modal": "## 变更日志\n\n弹窗标题仅显示「变更日志」;表格上方展示记录条数,列表字段为操作时间、操作人、字段、修改前、修改后。\n\n---", "lbd-doc-monthly-pl": "## 租赁业务盈亏月度汇总\n\n按年份聚合明细数据为 1–12 月行:押金、应收、实收、未收、自然月收入(月度收入)、成本(车辆实际成本)、居间费、氢费、总成本、盈亏。\n\n- 筛选:选择年份 + 查询\n- 导出:Excel\n- 氢费预充值:明细表暂无字段,汇总列占位为 0\n\n---", "lbd-doc-field-checks": "# 租赁业务明细 · 单元格系统校验\n\n> 实现:`utils/field-checks.ts`、`utils/system-ref.ts`;UI:`components/FieldCheckIcon.tsx`\n\n列表部分列在单元格右侧展示**校验图标**(绿色 ✓ / 黄色 ⚠)。点击可查看说明;警告项在支持时提供**一键替换**为系统建议值。\n\n## 对照数据源(原型本地种子)\n\n| 来源 | 路径 | 用途 |\n|---|---|---|\n| 客户管理 | `customer-management/data/customers.json` | 客户名称是否已登记 |\n| 车辆管理 | `vehicle-management/data/vehicles.json` | 车牌是否存在、交车/还车日期、登记所有权 |\n| 明细种子合同 | `lease-business-detail/data/rows.json` | 车牌 + 客户 → 合同(押金、租金、提车/退车日期等) |\n\n启动时由 `system-ref.ts` 聚合为内存索引;**当前原型未接真实后端 API**。\n\n## 交互与状态\n\n| 状态 | 图标 | 点击行为 |\n|---|---|---|\n| `ok` | 绿色 ✓ | 展示通过说明(tooltip) |\n| `warn` | 黄色 ⚠ | 展示差异说明;部分字段可「一键替换」 |\n| `none` | 不显示 | 无校验条件或无需提示 |\n\n匹配规则:字符串比较前 **trim 首尾空格**;金额比较容差 **0.005 元**。\n\n---\n\n## 客户名称(`customerName`)\n\n**前置**:车牌、客户名称均非空,否则不校验。\n\n**期望客户 `expected`**:`resolveExpectedCustomer(车牌, 提车日期, 退车日期)`\n\n- 在同车牌的所有合同记录中筛选;\n- 若明细行有提车日期且合同有提车日期 → 必须一致;\n- 若明细行有退车日期且合同有退车日期 → 必须一致;\n- 取匹配合同的客户名称(多条时以后遍历结果为准)。\n\n**判定顺序**(命中即返回,不再往下):\n\n| 优先级 | 条件 | 结果 | 提示文案 |\n|---|---|---|---|\n| 1 | `expected` 非空且与当前客户名完全一致 | ✅ 通过 | 客户名称与系统登记一致 |\n| 2 | `getContractRef(车牌, 客户名)` 命中且名称一致 | ✅ 通过 | 客户名称与租赁合同一致 |\n| 3 | `expected` 非空但与当前客户名不一致 | ⚠️ 警告 | 客户名称与系统「客户管理」不对应;**建议客户:{expected}**;可一键替换 |\n| 4 | `isKnownCustomerName(客户名)` 为真 | ✅ 通过(弱校验) | 客户名称已在客户管理中登记 |\n| 5 | 以上均否 | ⚠️ 警告 | 客户名称与系统「客户管理」不对应;请核对提车日期与退车日期 |\n\n**`isKnownCustomerName` 弱校验**:\n\n- 与客户管理名单**完全一致**;或\n- **双向包含**(任一方名称包含另一方,如简称/全称)。\n\n---\n\n## 车牌号码(`plateNo`)\n\n| 条件 | 结果 | 说明 |\n|---|---|---|\n| 车牌为空 | 不校验 | — |\n| 车牌在车辆管理中 | ✅ 通过 | 车牌已在车辆管理中登记 |\n| 否则 | ⚠️ 警告 | 车牌不存在;该车牌未在系统「车辆管理」中登记,无法导入 |\n\n---\n\n## 提车日期(`pickupDate`)\n\n对照车辆管理 `lastDeliveryTime`(交车管理)。\n\n| 条件 | 结果 |\n|---|---|\n| 无车辆档案或无提车日期 | 不校验 |\n| 与系统交车日期一致(YYYY-MM-DD) | ✅ 提车日期与交车管理一致 |\n| 不一致 | ⚠️ 警告;展示系统日期;可一键替换 |\n\n---\n\n## 退车日期(`returnDate`)\n\n对照车辆管理 `lastReturnTime`(还车记录)。\n\n| 条件 | 结果 |\n|---|---|\n| 无车辆档案或无退车日期 | 不校验 |\n| 与系统还车日期一致 | ✅ 退车日期与还车记录一致 |\n| 不一致 | ⚠️ 警告;展示系统日期;可一键替换 |\n\n---\n\n## 押金(`deposit`)\n\n对照租赁合同保证金(`车牌 + 客户名` 合同)。\n\n| 条件 | 结果 |\n|---|---|\n| 无合同或押金非数值 | 不校验 |\n| 与合同保证金一致 | ✅ 押金与合同保证金一致 |\n| 不一致 | ⚠️ 警告;可一键替换为合同值 |\n\n---\n\n## 合同标的租金(`contractRent`)\n\n对照租赁合同租金。\n\n| 条件 | 结果 |\n|---|---|\n| 无合同或无租金 | 不校验 |\n| 与合同租金一致 | ✅ 租金与合同一致 |\n| 不一致 | ⚠️ 警告;可一键替换 |\n\n---\n\n## 应收租金(`receivableRent`)\n\n**期望**:`合同标的租金 × 结算周期月数`(月数来自合同 `paymentMethod` 解析:月度 1 / 季度 3 / 半年 6 / 年度 12)。\n\n| 条件 | 结果 |\n|---|---|\n| 期望与当前均为 0 | 不校验 |\n| 与期望一致 | ✅ 应收租金 = 合同标的租金 × N 个月 |\n| 不一致 | ⚠️ 警告;展示正确金额;可一键替换 |\n\n---\n\n## 里程减免 / 其他减免(`mileageDeduction`、`otherDeduction`)\n\n| 条件 | 结果 |\n|---|---|\n| 值为 0 | 不校验 |\n| 值为负数 | ✅ 以负数显示(符合 Excel 口径) |\n| 值为正数 | ⚠️ 警告:应录入负值 |\n\n---\n\n## 资产归属(`assetOwner`)\n\n对照车辆管理「登记所有权」。\n\n| 条件 | 结果 |\n|---|---|\n| 无车辆档案或无资产归属 | 不校验 |\n| 与登记所有权一致 | ✅ 与车辆登记所有权一致 |\n| 不一致 | ⚠️ 警告;可一键替换 |\n\n---\n\n## 启用校验的列\n\n在 `components/tableColumns.ts` 中通过 `checkField` 挂载,由 `DetailTable` 调用 `getFieldCheck()`:\n\n`plateNo`、`customerName`、`pickupDate`、`returnDate`、`deposit`、`contractRent`、`receivableRent`、`mileageDeduction`、`otherDeduction`、`assetOwner`。\n", - "lbd-col-customer": "## 客户名称 · 系统校验\n\n导入后按**车牌 + 提车/退车日期**与合同推算期望客户,并与客户管理、租赁合同比对。\n\n### 判定顺序\n\n1. 期望客户与当前名完全一致 → ✅ 与系统登记一致\n2. 车牌+客户名命中租赁合同 → ✅ 与租赁合同一致\n3. 有期望客户但不一致 → ⚠️ 提示建议客户,可**一键替换**\n4. 客户管理已登记(全等或简称包含)→ ✅ 弱校验通过\n5. 否则 → ⚠️ 请核对提车/退车日期\n\n完整规则见目录「单元格系统校验」或 `.spec/field-checks.md`。\n\n---" + "lbd-col-customer": "## 客户名称 · 系统校验\n\n导入后按**车牌 + 提车/退车日期**与合同推算期望客户,并与客户管理、租赁合同比对。\n\n### 判定顺序\n\n1. 期望客户与当前名完全一致 → ✅ 与系统登记一致\n2. 车牌+客户名命中租赁合同 → ✅ 与租赁合同一致\n3. 有期望客户但不一致 → ⚠️ 提示建议客户,可**一键替换**\n4. 客户管理已登记(全等或简称包含)→ ✅ 弱校验通过\n5. 否则 → ⚠️ 请核对提车/退车日期\n\n完整规则见目录「单元格系统校验」或 `.spec/field-checks.md`。\n\n---", + "lbd-doc-import-rules": "# 租赁业务明细 · 导入与金额规则\n\n> 实现:`utils/row-key.ts`、`utils/import-merge.ts`、`utils/batch-io.ts`、`utils/validate-detail.ts` \n> UI:`components/DetailImportModal.tsx`、`components/DetailEditModal.tsx`\n\n## 导入业务主键(去重)\n\n| 组成字段 | 说明 |\n|---|---|\n| 年份 | `year` |\n| 月份 | `month` |\n| 车牌号码 | `plateNo`(trim) |\n| **客户名称** | `customerName`(trim) |\n\n键格式:`{year}-{MM}-{plateNo}::{customerName}`(见 `detailRowBusinessKey`)。\n\n同一主键下支持三种策略:\n\n| 策略 | 行为 |\n|---|---|\n| 跳过重复(默认) | 已存在同键记录则跳过导入行 |\n| 覆盖重复 | 用导入行覆盖已有记录(保留原 `id`) |\n| 报错 | 重复行写入错误日志,不入库 |\n\n**边界**:同一车牌、同一月份、**不同客户**视为两条不同记录,不互相覆盖。\n\n数据源:原型本地导入解析 + 内存中的已有明细;**未接真实后端 API**。\n\n---\n\n## 实收金额 vs 应收合计\n\n| 项 | 规则 |\n|---|---|\n| 导入 | **不拦截**实收大于应收合计 |\n| 编辑保存 | **不拦截**实收大于应收合计 |\n| 未收计算 | 仍为「应收合计 − 实收金额」;实收超额时未收为负,列表用超额样式提示 |\n\n历史规则「实收金额 ≤ 应收合计」已在 `validate-detail.ts` 中注释停用,函数保留签名便于对照。\n\n---\n\n## 与收款状态的关系\n\n收款状态仍按实收与应收比较展示(未收款 / 部分收款 / 已结清);实收 ≥ 应收合计时为「已结清」,与是否允许多收不冲突。\n" }, "assetMap": {}, "directory": { @@ -323,7 +324,7 @@ "type": "markdown", "id": "lbd-doc-prd", "title": "PRD 全文", - "markdown": "# 租赁业务明细 · 产品需求说明(PRD)\n\n> 对齐 Excel《2026年业务二部运营台账总表》「2.租赁车辆收入明细表」;业管人员按月份维护运营明细,公式列系统自动计算。\n\n---\n\n## 1. 模块定位\n\n| 项 | 说明 |\n|---|---|\n| 模块名称 | 台账数据 — 租赁业务明细 |\n| 原型路径 | `src/prototypes/lease-business-detail` |\n| 预览地址 | `/prototypes/lease-business-detail` |\n| 目标用户 | 业务二部业管 / 运营人员 |\n| 核心任务 | 按 Excel 样表维护租赁车辆收入明细、查询汇总、批量导入导出 |\n| 页面结构 | 筛选区 + 7 项 KPI + 工具栏 + 48 列宽表(表尾 23 项汇总)+ 分页 |\n| 设计基底 | `vm-page` + `ldb-page` + `lc-page`(见 `ledger-shared/DESIGN.md`) |\n\n### 与「租赁业务台账」的分工\n\n| 模块 | 定位 |\n|---|---|\n| **租赁业务明细**(本模块) | 对齐业管 Excel 运营台账宽表;手工列导入维护;6 个公式列自动计算;收款状态按实收/应收展示 |\n| **租赁业务台账** | 账单维度维护、关联收款记录、收款状态(未收/部分/已收)、归档与角色数据范围 |\n\n下游 **业务部台账 / 业务汇总分析** 中「租赁业绩」数据来源为本模块明细(见 `oneos-web-data-analysis/05-业务部台账`)。\n\n### 不做范围(当前原型)\n\n- 租赁业务盈亏月度汇总(改由**业务部台账**汇总展示)\n- 服务端持久化、员工/主管数据权限隔离(主管预览:`?role=supervisor`)\n- 关联收款记录自动回写实收\n\n---\n\n## 2. 筛选区\n\n默认展示 **首行 4 项**(统计月份、业务部门、业务员、客户名称),「更多筛选」展开**状态、车牌号码**(与全局 `vm-filter-panel.ts` 4 列栅格规则一致,避免末行仅 1 项)。\n\n| 筛选项 | 说明 |\n|---|---|\n| 统计月份 | `YYYY-MM` 格式,精确匹配年份+月份 |\n| 业务部门 | 精确匹配 |\n| 业务员 | 精确匹配 |\n| 客户名称 | 模糊包含 |\n| 状态 | 已结清 / 部分收款 / 未收款(按实收与应收合计计算;更多筛选) |\n| 车牌号码 | 多选精确匹配(更多筛选) |\n\n查询按钮触发筛选;KPI 与表尾汇总基于**全量筛选结果**(非当前页)。\n\n---\n\n## 3. KPI 统计卡片(7 张)\n\n顶部展示关键指标;完整 23 项金额合计见列表底部汇总行。\n\n| KPI | 汇总字段 |\n|---|---|\n| 应收总计 | `receivableTotal` |\n| 押金总计 | `deposit`(仅数值型) |\n| 实收总计 | `receivedAmount` |\n| 里程减免总计 | `mileageDeduction` |\n| 其他减免总计 | `otherDeduction` |\n| 未收总计 | `outstanding`(>0 标红) |\n| 盈亏总计 | `profitLoss`(正负着色) |\n\n---\n\n## 4. 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 批量导入 | 下载模板(36 列手工录入)→ 选择重复策略 → 上传 Excel → 公式列自动计算 |\n| 批量导出 | 导出当前筛选结果(**状态** + 47 列业务字段,共 48 列) |\n\n### 行内操作\n\n编辑、删除、变更日志。\n\n### 导入重复策略\n\n业务主键:`年份 + 月份 + 车牌号码`。\n\n| 策略 | 行为 |\n|---|---|\n| 跳过重复(默认) | 已存在同键记录则跳过 |\n| 覆盖重复 | 用导入行覆盖已有记录 |\n| 报错 | 重复行写入错误日志 |\n\n导入与编辑均校验:**实收金额 ≤ 应收合计**。\n\n---\n\n## 5. 明细列表(48 列)\n\n表头顺序对齐 Excel「2.租赁车辆收入明细表」,并在月份列左侧增加「状态」列。\n\n### 交互\n\n- 横向滚动;左冻结:**状态 + 月份 + 车牌号码**\n- 仅**日期类列**表头支持正序/倒序排序\n- 公式列(`calculated`)浅灰底色,导入模板不含\n- 表尾**汇总行**:23 个金额列 SUBTOTAL,月份列显示「汇总」\n- 未收 > 0 单元格标红;盈亏正负着色\n- 底部分页:10 / 20 / 50 / 100\n\n### 收款状态(状态列)\n\n| 状态 | 判定规则 |\n|---|---|\n| 未收款 | 实收金额 = 0 或未录入 |\n| 部分收款 | 实收金额 > 0 且 < 应收合计 |\n| 已结清 | 实收金额 ≥ 应收合计 |\n\n### 表尾汇总(23 项)\n\n押金、合同标的租金、应收合计、应收租金、月度收入、月度租金、维保包干收入、保险上浮费、运维费(收入)、其他收入、里程减免金额、其他减免金额、实收金额、未收、车辆标准成本、车辆实际成本、保险费、氢费、运维费(成本)、居间费、其他、总成本、盈亏。\n\n### 单元格系统校验\n\n部分列在单元格右侧展示校验图标(绿色 ✓ / 黄色 ⚠),点击可查看说明;警告项在支持时提供**一键替换**。完整规则见 **`.spec/field-checks.md`**。\n\n**对照数据源(原型本地种子)**:客户管理、车辆管理、明细种子合同(`system-ref.ts` 聚合);当前未接真实后端。\n\n| 字段 | 校验要点 |\n|---|---|\n| 车牌号码 | 是否在车辆管理中登记 |\n| **客户名称** | 见下表 |\n| 提车 / 退车日期 | 与交车管理 / 还车记录一致 |\n| 押金、合同标的租金 | 与租赁合同一致 |\n| 应收租金 | = 合同标的租金 × 结算周期月数 |\n| 里程 / 其他减免 | 应以负数录入 |\n| 资产归属 | 与车辆登记所有权一致 |\n\n#### 客户名称判定顺序\n\n1. **通过(强)**:按「车牌 + 提车日期 + 退车日期」匹配合同,期望客户与当前名**完全一致** →「与系统登记一致」。\n2. **通过(强)**:「车牌 + 客户名」能命中租赁合同且名称一致 →「与租赁合同一致」。\n3. **警告**:能推算期望客户但与当前名不一致 → 提示建议客户,**可一键替换**。\n4. **通过(弱)**:客户名在客户管理中已登记(全等或双向包含简称/全称)→「已在客户管理中登记」。\n5. **警告**:以上均不满足 →「与系统不对应,请核对提车/退车日期」。\n\n车牌或客户名为空时不显示校验图标。\n\n---\n\n## 6. 公式列(系统自动计算)\n\n对齐 Excel 表内公式;实现见 `utils/calc-detail.ts`。\n\n| 字段 | 计算方式 |\n|---|---|\n| **应收合计** | 应收租金 + 维保包干收入 + 保险上浮费 + 运维费(收入)+ 其他收入 + 里程减免 + 其他减免 |\n| **月度收入** | 月度租金 + 维保包干收入 + 保险上浮费 + 运维费(收入)+ 其他收入 + 里程减免 + 其他减免 |\n| **未收** | 应收合计 − 实收金额 |\n| **车辆实际成本** | 车辆标准成本 × 平均天数 |\n| **总成本** | 车辆实际成本 + 保险费 + 氢费 + 运维费(成本)+ 居间费 + 其他 |\n| **盈亏** | 月度收入 − 总成本 |\n\n减免金额在 Excel 中以负数录入;导入时填负数即可。\n\n---\n\n## 7. 样例数据\n\n- 文件:`data/rows.json`\n- 来源:Excel《2026年业务二部运营台账总表6.17 - 新版》2026 年 1–6 月明细\n- 条数:553 条;汇总与 Excel SUBTOTAL 行一致\n\n---\n\n## 8. 源码入口\n\n| 类型 | 路径 |\n|---|---|\n| 主入口 | `index.tsx` |\n| 标注源 | `annotation-source.json` |\n| 列定义 | `components/tableColumns.ts` |\n| 筛选 / KPI 汇总 | `utils/detail.ts` |\n| 收款状态 | `utils/payment-status.ts` |\n| 公式计算 | `utils/calc-detail.ts` |\n| 导入导出 | `utils/batch-io.ts`、`utils/import-merge.ts` |\n| 单元格校验 | `utils/field-checks.ts`、`utils/system-ref.ts`、`components/FieldCheckIcon.tsx` |\n| 保存校验 | `utils/validate-detail.ts` |\n| 变更日志 | `utils/change-log.ts` |\n| 类型 | `types.ts` |\n| 组件 | `FilterPanel.tsx`、`DetailKpiRow.tsx`、`DetailTable.tsx`、`DetailImportModal.tsx`、`DetailEditModal.tsx`、`DetailChangeLogModal.tsx` |\n\n---\n\n## 9. 验收重点\n\n1. 筛选区含「状态」;状态列展示未收款 / 部分收款 / 已结清\n2. 筛选后 KPI、表尾汇总与明细行合计一致\n3. 左冻结:状态 + 月份 + 车牌号码\n4. 氢费非零可点击明细;编辑/删除/变更日志可用\n5. 未收 > 0 标红;盈亏正负着色\n6. 客户名称等校验列:通过显示绿勾,不一致显示黄警告并可查看说明 / 一键替换(规则见 `.spec/field-checks.md`)\n\n---\n\n## 10. 待规划项(评审记录)\n\n详见 `.spec/prototype-review.md`:\n\n- P2:与租赁业务台账 / 收款模块集成口径(实收自动回写)\n- P3:氢费预充值字段(业务部台账月度汇总占位,明细表暂无对应列)\n", + "markdown": "# 租赁业务明细 · 产品需求说明(PRD)\n\n> 对齐 Excel《2026年业务二部运营台账总表》「2.租赁车辆收入明细表」;业管人员按月份维护运营明细,公式列系统自动计算。\n\n---\n\n## 1. 模块定位\n\n| 项 | 说明 |\n|---|---|\n| 模块名称 | 台账数据 — 租赁业务明细 |\n| 原型路径 | `src/prototypes/lease-business-detail` |\n| 预览地址 | `/prototypes/lease-business-detail` |\n| 目标用户 | 业务二部业管 / 运营人员 |\n| 核心任务 | 按 Excel 样表维护租赁车辆收入明细、查询汇总、批量导入导出 |\n| 页面结构 | 筛选区 + 7 项 KPI + 工具栏 + 48 列宽表(表尾 23 项汇总)+ 分页 |\n| 设计基底 | `vm-page` + `ldb-page` + `lc-page`(见 `ledger-shared/DESIGN.md`) |\n\n### 与「租赁业务台账」的分工\n\n| 模块 | 定位 |\n|---|---|\n| **租赁业务明细**(本模块) | 对齐业管 Excel 运营台账宽表;手工列导入维护;6 个公式列自动计算;收款状态按实收/应收展示 |\n| **租赁业务台账** | 账单维度维护、关联收款记录、收款状态(未收/部分/已收)、归档与角色数据范围 |\n\n下游 **业务部台账 / 业务汇总分析** 中「租赁业绩」数据来源为本模块明细(见 `oneos-web-data-analysis/05-业务部台账`)。\n\n### 不做范围(当前原型)\n\n- 租赁业务盈亏月度汇总(改由**业务部台账**汇总展示)\n- 服务端持久化、员工/主管数据权限隔离(主管预览:`?role=supervisor`)\n- 关联收款记录自动回写实收\n\n---\n\n## 2. 筛选区\n\n默认展示 **首行 4 项**(统计月份、业务部门、业务员、客户名称),「更多筛选」展开**状态、车牌号码**(与全局 `vm-filter-panel.ts` 4 列栅格规则一致,避免末行仅 1 项)。\n\n| 筛选项 | 说明 |\n|---|---|\n| 统计月份 | `YYYY-MM` 格式,精确匹配年份+月份 |\n| 业务部门 | 精确匹配 |\n| 业务员 | 精确匹配 |\n| 客户名称 | 模糊包含 |\n| 状态 | 已结清 / 部分收款 / 未收款(按实收与应收合计计算;更多筛选) |\n| 车牌号码 | 多选精确匹配(更多筛选) |\n\n查询按钮触发筛选;KPI 与表尾汇总基于**全量筛选结果**(非当前页)。\n\n---\n\n## 3. KPI 统计卡片(7 张)\n\n顶部展示关键指标;完整 23 项金额合计见列表底部汇总行。\n\n| KPI | 汇总字段 |\n|---|---|\n| 应收总计 | `receivableTotal` |\n| 押金总计 | `deposit`(仅数值型) |\n| 实收总计 | `receivedAmount` |\n| 里程减免总计 | `mileageDeduction` |\n| 其他减免总计 | `otherDeduction` |\n| 未收总计 | `outstanding`(>0 标红) |\n| 盈亏总计 | `profitLoss`(正负着色) |\n\n---\n\n## 4. 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 批量导入 | 下载模板(36 列手工录入)→ 选择重复策略 → 上传 Excel → 公式列自动计算 |\n| 批量导出 | 导出当前筛选结果(**状态** + 47 列业务字段,共 48 列) |\n\n### 行内操作\n\n编辑、删除、变更日志。\n\n### 导入重复策略\n\n业务主键:`年份 + 月份 + 车牌号码 + 客户名称`(完整规则见 **`.spec/import-rules.md`**)。\n\n| 策略 | 行为 |\n|---|---|\n| 跳过重复(默认) | 已存在同键记录则跳过 |\n| 覆盖重复 | 用导入行覆盖已有记录 |\n| 报错 | 重复行写入错误日志 |\n\n### 实收金额\n\n**不限制**实收 ≤ 应收合计:导入与编辑均允许实收大于应收合计(未收可为负,列表超额样式提示)。旧规则已停用,见 `.spec/import-rules.md`。\n\n---\n\n## 5. 明细列表(48 列)\n\n表头顺序对齐 Excel「2.租赁车辆收入明细表」,并在月份列左侧增加「状态」列。\n\n### 交互\n\n- 横向滚动;左冻结:**状态 + 月份 + 车牌号码**\n- 仅**日期类列**表头支持正序/倒序排序\n- 公式列(`calculated`)浅灰底色,导入模板不含\n- 表尾**汇总行**:23 个金额列 SUBTOTAL,月份列显示「汇总」\n- 未收 > 0 单元格标红;盈亏正负着色\n- 底部分页:10 / 20 / 50 / 100\n\n### 收款状态(状态列)\n\n| 状态 | 判定规则 |\n|---|---|\n| 未收款 | 实收金额 = 0 或未录入 |\n| 部分收款 | 实收金额 > 0 且 < 应收合计 |\n| 已结清 | 实收金额 ≥ 应收合计 |\n\n### 表尾汇总(23 项)\n\n押金、合同标的租金、应收合计、应收租金、月度收入、月度租金、维保包干收入、保险上浮费、运维费(收入)、其他收入、里程减免金额、其他减免金额、实收金额、未收、车辆标准成本、车辆实际成本、保险费、氢费、运维费(成本)、居间费、其他、总成本、盈亏。\n\n### 单元格系统校验\n\n部分列在单元格右侧展示校验图标(绿色 ✓ / 黄色 ⚠),点击可查看说明;警告项在支持时提供**一键替换**。完整规则见 **`.spec/field-checks.md`**。\n\n**对照数据源(原型本地种子)**:客户管理、车辆管理、明细种子合同(`system-ref.ts` 聚合);当前未接真实后端。\n\n| 字段 | 校验要点 |\n|---|---|\n| 车牌号码 | 是否在车辆管理中登记 |\n| **客户名称** | 见下表 |\n| 提车 / 退车日期 | 与交车管理 / 还车记录一致 |\n| 押金、合同标的租金 | 与租赁合同一致 |\n| 应收租金 | = 合同标的租金 × 结算周期月数 |\n| 里程 / 其他减免 | 应以负数录入 |\n| 资产归属 | 与车辆登记所有权一致 |\n\n#### 客户名称判定顺序\n\n1. **通过(强)**:按「车牌 + 提车日期 + 退车日期」匹配合同,期望客户与当前名**完全一致** →「与系统登记一致」。\n2. **通过(强)**:「车牌 + 客户名」能命中租赁合同且名称一致 →「与租赁合同一致」。\n3. **警告**:能推算期望客户但与当前名不一致 → 提示建议客户,**可一键替换**。\n4. **通过(弱)**:客户名在客户管理中已登记(全等或双向包含简称/全称)→「已在客户管理中登记」。\n5. **警告**:以上均不满足 →「与系统不对应,请核对提车/退车日期」。\n\n车牌或客户名为空时不显示校验图标。\n\n---\n\n## 6. 公式列(系统自动计算)\n\n对齐 Excel 表内公式;实现见 `utils/calc-detail.ts`。\n\n| 字段 | 计算方式 |\n|---|---|\n| **应收合计** | 应收租金 + 维保包干收入 + 保险上浮费 + 运维费(收入)+ 其他收入 + 里程减免 + 其他减免 |\n| **月度收入** | 月度租金 + 维保包干收入 + 保险上浮费 + 运维费(收入)+ 其他收入 + 里程减免 + 其他减免 |\n| **未收** | 应收合计 − 实收金额 |\n| **车辆实际成本** | 车辆标准成本 × 平均天数 |\n| **总成本** | 车辆实际成本 + 保险费 + 氢费 + 运维费(成本)+ 居间费 + 其他 |\n| **盈亏** | 月度收入 − 总成本 |\n\n减免金额在 Excel 中以负数录入;导入时填负数即可。\n\n---\n\n## 7. 样例数据\n\n- 文件:`data/rows.json`\n- 来源:Excel《2026年业务二部运营台账总表6.17 - 新版》2026 年 1–6 月明细\n- 条数:553 条;汇总与 Excel SUBTOTAL 行一致\n\n---\n\n## 8. 源码入口\n\n| 类型 | 路径 |\n|---|---|\n| 主入口 | `index.tsx` |\n| 标注源 | `annotation-source.json` |\n| 列定义 | `components/tableColumns.ts` |\n| 筛选 / KPI 汇总 | `utils/detail.ts` |\n| 收款状态 | `utils/payment-status.ts` |\n| 公式计算 | `utils/calc-detail.ts` |\n| 导入导出 | `utils/batch-io.ts`、`utils/import-merge.ts`、`utils/row-key.ts` |\n| 单元格校验 | `utils/field-checks.ts`、`utils/system-ref.ts`、`components/FieldCheckIcon.tsx` |\n| 保存校验 | `utils/validate-detail.ts`(实收超额拦截已停用) |\n| 导入规则说明 | `.spec/import-rules.md` |\n| 变更日志 | `utils/change-log.ts` |\n| 类型 | `types.ts` |\n| 组件 | `FilterPanel.tsx`、`DetailKpiRow.tsx`、`DetailTable.tsx`、`DetailImportModal.tsx`、`DetailEditModal.tsx`、`DetailChangeLogModal.tsx` |\n\n---\n\n## 9. 验收重点\n\n1. 筛选区含「状态」;状态列展示未收款 / 部分收款 / 已结清\n2. 筛选后 KPI、表尾汇总与明细行合计一致\n3. 左冻结:状态 + 月份 + 车牌号码\n4. 氢费非零可点击明细;编辑/删除/变更日志可用\n5. 未收 > 0 标红;盈亏正负着色\n6. 客户名称等校验列:通过显示绿勾,不一致显示黄警告并可查看说明 / 一键替换(规则见 `.spec/field-checks.md`)\n7. 导入主键含客户名称;同车牌同月不同客户可并存\n8. 实收可大于应收合计:导入/编辑不拦截;未收可为负并有超额样式\n\n---\n\n## 10. 待规划项(评审记录)\n\n详见 `.spec/prototype-review.md`:\n\n- P2:与租赁业务台账 / 收款模块集成口径(实收自动回写)\n- P3:氢费预充值字段(业务部台账月度汇总占位,明细表暂无对应列)\n", "markdownPath": "src/prototypes/lease-business-detail/.spec/requirements-prd.md" }, { @@ -348,7 +349,7 @@ "type": "markdown", "id": "lbd-doc-lbd-toolbar", "title": "批量导入导出", - "markdown": "## 4. 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 批量导入 | 下载模板(36 列)→ 选择重复策略 → 上传 Excel |\n| 批量导出 | 导出当前筛选结果(状态 + 47 列业务字段,共 48 列) |\n\n### 行内操作\n\n编辑、删除、变更日志。\n\n### 导入重复策略\n\n主键:年份 + 月份 + 车牌。支持跳过 / 覆盖 / 报错;实收 ≤ 应收合计。\n\n---" + "markdown": "## 4. 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 批量导入 | 下载模板(36 列)→ 选择重复策略 → 上传 Excel |\n| 批量导出 | 导出当前筛选结果(状态 + 47 列业务字段,共 48 列) |\n\n### 行内操作\n\n编辑、删除、变更日志。\n\n### 导入重复策略\n\n主键:年份 + 月份 + 车牌 + **客户名称**。支持跳过 / 覆盖 / 报错。\n\n### 实收金额\n\n允许实收大于应收合计(导入/编辑不拦截)。详见 `.spec/import-rules.md`。\n\n---" }, { "type": "markdown", @@ -376,6 +377,12 @@ "title": "状态", "markdown": "## 状态\n\n按**实收金额**与**应收合计**自动计算,只读展示。\n\n| 状态 | 判定规则 |\n|---|---|\n| **未收款** | 实收金额 = 0 或未录入 |\n| **部分收款** | 实收金额 > 0 且 < 应收合计 |\n| **已结清** | 实收金额 ≥ 应收合计 |\n\n列位置:月份列左侧;左冻结。\n\n---" }, + { + "type": "markdown", + "id": "lbd-doc-lbd-col-customer", + "title": "客户名称", + "markdown": "## 客户名称 · 系统校验\n\n导入后按**车牌 + 提车/退车日期**与合同推算期望客户,并与客户管理、租赁合同比对。\n\n### 判定顺序\n\n1. 期望客户与当前名完全一致 → ✅ 与系统登记一致\n2. 车牌+客户名命中租赁合同 → ✅ 与租赁合同一致\n3. 有期望客户但不一致 → ⚠️ 提示建议客户,可**一键替换**\n4. 客户管理已登记(全等或简称包含)→ ✅ 弱校验通过\n5. 否则 → ⚠️ 请核对提车/退车日期\n\n完整规则见目录「单元格系统校验」或 `.spec/field-checks.md`。\n\n---" + }, { "type": "markdown", "id": "lbd-doc-lbd-col-receivable-total", @@ -430,13 +437,27 @@ "type": "markdown", "id": "lbd-doc-boundary", "title": "模块边界", - "markdown": "## 模块边界\n\n| 模块 | 定位 |\n|---|---|\n| **租赁业务明细** | Excel 宽表维护;48 列;收款状态;导入去重与实收校验 |\n| **租赁业务台账** | 账单维度、收款关联、归档与数据范围 |\n| **业务部台账** | 租赁业绩与盈亏月度汇总(数据来源含本模块明细) |\n\n### 不做范围\n\n- 本页不做盈亏月度汇总(见业务部台账)\n- 服务端持久化、员工数据范围隔离\n- 收款记录自动回写实收\n\n---" + "markdown": "## 模块边界\n\n| 模块 | 定位 |\n|---|---|\n| **租赁业务明细** | Excel 宽表维护;48 列;收款状态;导入去重(含客户名称) |\n| **租赁业务台账** | 账单维度、收款关联、归档与数据范围 |\n| **业务部台账** | 租赁业绩与盈亏月度汇总(数据来源含本模块明细) |\n\n### 不做范围\n\n- 本页不做盈亏月度汇总(见业务部台账)\n- 服务端持久化、员工数据范围隔离\n- 收款记录自动回写实收\n\n---" + }, + { + "type": "markdown", + "id": "lbd-doc-field-checks", + "title": "单元格系统校验", + "markdown": "# 租赁业务明细 · 单元格系统校验\n\n> 实现:`utils/field-checks.ts`、`utils/system-ref.ts`;UI:`components/FieldCheckIcon.tsx`\n\n列表部分列在单元格右侧展示**校验图标**(绿色 ✓ / 黄色 ⚠)。点击可查看说明;警告项在支持时提供**一键替换**为系统建议值。\n\n## 对照数据源(原型本地种子)\n\n| 来源 | 路径 | 用途 |\n|---|---|---|\n| 客户管理 | `customer-management/data/customers.json` | 客户名称是否已登记 |\n| 车辆管理 | `vehicle-management/data/vehicles.json` | 车牌是否存在、交车/还车日期、登记所有权 |\n| 明细种子合同 | `lease-business-detail/data/rows.json` | 车牌 + 客户 → 合同(押金、租金、提车/退车日期等) |\n\n启动时由 `system-ref.ts` 聚合为内存索引;**当前原型未接真实后端 API**。\n\n## 交互与状态\n\n| 状态 | 图标 | 点击行为 |\n|---|---|---|\n| `ok` | 绿色 ✓ | 展示通过说明(tooltip) |\n| `warn` | 黄色 ⚠ | 展示差异说明;部分字段可「一键替换」 |\n| `none` | 不显示 | 无校验条件或无需提示 |\n\n匹配规则:字符串比较前 **trim 首尾空格**;金额比较容差 **0.005 元**。\n\n---\n\n## 客户名称(`customerName`)\n\n**前置**:车牌、客户名称均非空,否则不校验。\n\n**期望客户 `expected`**:`resolveExpectedCustomer(车牌, 提车日期, 退车日期)`\n\n- 在同车牌的所有合同记录中筛选;\n- 若明细行有提车日期且合同有提车日期 → 必须一致;\n- 若明细行有退车日期且合同有退车日期 → 必须一致;\n- 取匹配合同的客户名称(多条时以后遍历结果为准)。\n\n**判定顺序**(命中即返回,不再往下):\n\n| 优先级 | 条件 | 结果 | 提示文案 |\n|---|---|---|---|\n| 1 | `expected` 非空且与当前客户名完全一致 | ✅ 通过 | 客户名称与系统登记一致 |\n| 2 | `getContractRef(车牌, 客户名)` 命中且名称一致 | ✅ 通过 | 客户名称与租赁合同一致 |\n| 3 | `expected` 非空但与当前客户名不一致 | ⚠️ 警告 | 客户名称与系统「客户管理」不对应;**建议客户:{expected}**;可一键替换 |\n| 4 | `isKnownCustomerName(客户名)` 为真 | ✅ 通过(弱校验) | 客户名称已在客户管理中登记 |\n| 5 | 以上均否 | ⚠️ 警告 | 客户名称与系统「客户管理」不对应;请核对提车日期与退车日期 |\n\n**`isKnownCustomerName` 弱校验**:\n\n- 与客户管理名单**完全一致**;或\n- **双向包含**(任一方名称包含另一方,如简称/全称)。\n\n---\n\n## 车牌号码(`plateNo`)\n\n| 条件 | 结果 | 说明 |\n|---|---|---|\n| 车牌为空 | 不校验 | — |\n| 车牌在车辆管理中 | ✅ 通过 | 车牌已在车辆管理中登记 |\n| 否则 | ⚠️ 警告 | 车牌不存在;该车牌未在系统「车辆管理」中登记,无法导入 |\n\n---\n\n## 提车日期(`pickupDate`)\n\n对照车辆管理 `lastDeliveryTime`(交车管理)。\n\n| 条件 | 结果 |\n|---|---|\n| 无车辆档案或无提车日期 | 不校验 |\n| 与系统交车日期一致(YYYY-MM-DD) | ✅ 提车日期与交车管理一致 |\n| 不一致 | ⚠️ 警告;展示系统日期;可一键替换 |\n\n---\n\n## 退车日期(`returnDate`)\n\n对照车辆管理 `lastReturnTime`(还车记录)。\n\n| 条件 | 结果 |\n|---|---|\n| 无车辆档案或无退车日期 | 不校验 |\n| 与系统还车日期一致 | ✅ 退车日期与还车记录一致 |\n| 不一致 | ⚠️ 警告;展示系统日期;可一键替换 |\n\n---\n\n## 押金(`deposit`)\n\n对照租赁合同保证金(`车牌 + 客户名` 合同)。\n\n| 条件 | 结果 |\n|---|---|\n| 无合同或押金非数值 | 不校验 |\n| 与合同保证金一致 | ✅ 押金与合同保证金一致 |\n| 不一致 | ⚠️ 警告;可一键替换为合同值 |\n\n---\n\n## 合同标的租金(`contractRent`)\n\n对照租赁合同租金。\n\n| 条件 | 结果 |\n|---|---|\n| 无合同或无租金 | 不校验 |\n| 与合同租金一致 | ✅ 租金与合同一致 |\n| 不一致 | ⚠️ 警告;可一键替换 |\n\n---\n\n## 应收租金(`receivableRent`)\n\n**期望**:`合同标的租金 × 结算周期月数`(月数来自合同 `paymentMethod` 解析:月度 1 / 季度 3 / 半年 6 / 年度 12)。\n\n| 条件 | 结果 |\n|---|---|\n| 期望与当前均为 0 | 不校验 |\n| 与期望一致 | ✅ 应收租金 = 合同标的租金 × N 个月 |\n| 不一致 | ⚠️ 警告;展示正确金额;可一键替换 |\n\n---\n\n## 里程减免 / 其他减免(`mileageDeduction`、`otherDeduction`)\n\n| 条件 | 结果 |\n|---|---|\n| 值为 0 | 不校验 |\n| 值为负数 | ✅ 以负数显示(符合 Excel 口径) |\n| 值为正数 | ⚠️ 警告:应录入负值 |\n\n---\n\n## 资产归属(`assetOwner`)\n\n对照车辆管理「登记所有权」。\n\n| 条件 | 结果 |\n|---|---|\n| 无车辆档案或无资产归属 | 不校验 |\n| 与登记所有权一致 | ✅ 与车辆登记所有权一致 |\n| 不一致 | ⚠️ 警告;可一键替换 |\n\n---\n\n## 启用校验的列\n\n在 `components/tableColumns.ts` 中通过 `checkField` 挂载,由 `DetailTable` 调用 `getFieldCheck()`:\n\n`plateNo`、`customerName`、`pickupDate`、`returnDate`、`deposit`、`contractRent`、`receivableRent`、`mileageDeduction`、`otherDeduction`、`assetOwner`。\n", + "markdownPath": "src/prototypes/lease-business-detail/.spec/field-checks.md" + }, + { + "type": "markdown", + "id": "lbd-doc-import-rules", + "title": "导入与金额规则", + "markdown": "# 租赁业务明细 · 导入与金额规则\n\n> 实现:`utils/row-key.ts`、`utils/import-merge.ts`、`utils/batch-io.ts`、`utils/validate-detail.ts` \n> UI:`components/DetailImportModal.tsx`、`components/DetailEditModal.tsx`\n\n## 导入业务主键(去重)\n\n| 组成字段 | 说明 |\n|---|---|\n| 年份 | `year` |\n| 月份 | `month` |\n| 车牌号码 | `plateNo`(trim) |\n| **客户名称** | `customerName`(trim) |\n\n键格式:`{year}-{MM}-{plateNo}::{customerName}`(见 `detailRowBusinessKey`)。\n\n同一主键下支持三种策略:\n\n| 策略 | 行为 |\n|---|---|\n| 跳过重复(默认) | 已存在同键记录则跳过导入行 |\n| 覆盖重复 | 用导入行覆盖已有记录(保留原 `id`) |\n| 报错 | 重复行写入错误日志,不入库 |\n\n**边界**:同一车牌、同一月份、**不同客户**视为两条不同记录,不互相覆盖。\n\n数据源:原型本地导入解析 + 内存中的已有明细;**未接真实后端 API**。\n\n---\n\n## 实收金额 vs 应收合计\n\n| 项 | 规则 |\n|---|---|\n| 导入 | **不拦截**实收大于应收合计 |\n| 编辑保存 | **不拦截**实收大于应收合计 |\n| 未收计算 | 仍为「应收合计 − 实收金额」;实收超额时未收为负,列表用超额样式提示 |\n\n历史规则「实收金额 ≤ 应收合计」已在 `validate-detail.ts` 中注释停用,函数保留签名便于对照。\n\n---\n\n## 与收款状态的关系\n\n收款状态仍按实收与应收比较展示(未收款 / 部分收款 / 已结清);实收 ≥ 应收合计时为「已结清」,与是否允许多收不冲突。\n", + "markdownPath": "src/prototypes/lease-business-detail/.spec/import-rules.md" }, { "type": "markdown", "id": "lbd-doc-source", "title": "源码入口", - "markdown": "## 8. 源码入口\n\n| 类型 | 路径 |\n|---|---|\n| 主入口 | `index.tsx` |\n| 标注源 | `annotation-source.json` |\n| 列定义 | `components/tableColumns.ts` |\n| 筛选 / KPI 汇总 | `utils/detail.ts` |\n| 收款状态 | `utils/payment-status.ts` |\n| 公式计算 | `utils/calc-detail.ts` |\n| 导入导出 | `utils/batch-io.ts`、`utils/import-merge.ts` |\n| 校验 | `utils/validate-detail.ts` |\n| 变更日志 | `utils/change-log.ts` |\n| 类型 | `types.ts` |\n| 组件 | `FilterPanel.tsx`、`DetailKpiRow.tsx`、`DetailTable.tsx`、`DetailImportModal.tsx`、`DetailEditModal.tsx`、`DetailChangeLogModal.tsx` |\n\n---" + "markdown": "## 8. 源码入口\n\n| 类型 | 路径 |\n|---|---|\n| 主入口 | `index.tsx` |\n| 标注源 | `annotation-source.json` |\n| 列定义 | `components/tableColumns.ts` |\n| 筛选 / KPI 汇总 | `utils/detail.ts` |\n| 收款状态 | `utils/payment-status.ts` |\n| 公式计算 | `utils/calc-detail.ts` |\n| 导入导出 | `utils/batch-io.ts`、`utils/import-merge.ts`、`utils/row-key.ts` |\n| 单元格校验 | `utils/field-checks.ts`、`utils/system-ref.ts`、`components/FieldCheckIcon.tsx` |\n| 保存校验 | `utils/validate-detail.ts`(实收超额拦截已停用) |\n| 校验说明 | `.spec/field-checks.md` |\n| 导入规则 | `.spec/import-rules.md` |\n| 变更日志 | `utils/change-log.ts` |\n| 类型 | `types.ts` |\n| 组件 | `FilterPanel.tsx`、`DetailKpiRow.tsx`、`DetailTable.tsx`、`DetailImportModal.tsx`、`DetailEditModal.tsx`、`DetailChangeLogModal.tsx` |\n\n---" } ] } diff --git a/src/prototypes/lease-business-detail/components/DetailEditModal.tsx b/src/prototypes/lease-business-detail/components/DetailEditModal.tsx index 180d619..74dc1d0 100644 --- a/src/prototypes/lease-business-detail/components/DetailEditModal.tsx +++ b/src/prototypes/lease-business-detail/components/DetailEditModal.tsx @@ -54,7 +54,7 @@ export function DetailEditModal({ open, row, onClose, onSave }: DetailEditModalP