迭代加氢站 H5/Web:手工台账与预约列表、导入主键与实收规则,并同步站点信息拆分与导航。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
王冕
2026-07-17 16:10:34 +08:00
parent d2a7cc188d
commit 305199c508
62 changed files with 8071 additions and 2063 deletions

View File

@@ -1,7 +1,13 @@
{ {
"version": 1, "version": 1,
"updatedAt": "2026-07-16T01:03:00.000Z", "updatedAt": "2026-07-16T01:04:51.317Z",
"prototypes": [ "prototypes": [
{
"id": "item-prototypes-oneos-web-approval",
"kind": "item",
"title": "oneos web approval",
"itemKey": "prototypes/oneos-web-approval"
},
{ {
"id": "folder-1783875936186-27raza", "id": "folder-1783875936186-27raza",
"kind": "folder", "kind": "folder",

View File

@@ -395,7 +395,7 @@ const vmTheme = {
| 合同 Ant 对齐 | `src/prototypes/lease-contract-management/styles/lease-contract.css` | | 合同 Ant 对齐 | `src/prototypes/lease-contract-management/styles/lease-contract.css` |
| 参考:车辆管理 | `src/prototypes/vehicle-management/index.tsx` | | 参考:车辆管理 | `src/prototypes/vehicle-management/index.tsx` |
| 参考:租赁合同 | `src/prototypes/lease-contract-management/LeaseContractManagement.jsx` | | 参考:租赁合同 | `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 | | 主题 token 来源 | `src/themes/linear/DESIGN.md`inverse 浅色用于 vm |
--- ---

View File

@@ -5,26 +5,32 @@
var H2_RECONCILE_RECONCILED = 'reconciled'; var H2_RECONCILE_RECONCILED = 'reconciled';
var H2_RECONCILE_PENDING = 'pending'; var H2_RECONCILE_PENDING = 'pending';
var H2_VERIFY_VERIFIED = 'verified';
var H2_VERIFY_UNVERIFIED = 'unverified';
var H2_STATION_CODE_MAP = { var H2_STATION_CODE_MAP = {
'嘉兴加氢站(一期)': 'JX-H2-001', '中国石油中油高新能源牙谷加油加氢站': 'JX-H2-001',
'杭州临平加氢站': 'HZ-H2-002', '杭州临平加氢站': 'HZ-H2-002',
'上海宝山加氢站': 'SH-H2-003', '上海宝山加氢站': 'SH-H2-003',
'苏州工业园区备用站': 'SZ-H2-004' '苏州工业园区备用站': 'SZ-H2-004'
}; };
/** 与车辆氢费明细、加氢站模块对齐的 canonical seed10 条 */ /** 与车辆氢费明细、加氢站模块对齐的 canonical seed含本站未核对 / 已核对未对账用例 */
var H2_CANONICAL_LEDGER_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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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) { function h2BridgeCloneRows(rows) {
@@ -38,6 +44,26 @@ function h2BridgeFormatDateTime(value) {
return String(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) { function h2BridgeMatchStatementPatch(row, patch) {
if (patch.ledgerId && row.ledgerSyncId && patch.ledgerId === row.ledgerSyncId) return true; if (patch.ledgerId && row.ledgerSyncId && patch.ledgerId === row.ledgerSyncId) return true;
var rowTime = h2BridgeFormatDateTime(row.hydrogenTime); var rowTime = h2BridgeFormatDateTime(row.hydrogenTime);
@@ -66,7 +92,6 @@ function h2BridgeApplyStatementPatches(rows, patches) {
receiptDate: patch.receiptDate, receiptDate: patch.receiptDate,
stationPaymentStatus: patch.paymentStatus || 'paid', stationPaymentStatus: patch.paymentStatus || 'paid',
paymentStatus: patch.paymentStatus || 'paid', paymentStatus: patch.paymentStatus || 'paid',
reconciledAt: patch.reconcileDate,
statementRecordId: patch.statementRecordId != null ? patch.statementRecordId : r.statementRecordId statementRecordId: patch.statementRecordId != null ? patch.statementRecordId : r.statementRecordId
}); });
}); });
@@ -77,9 +102,71 @@ function h2BridgeBuildInitialRows() {
return h2BridgeApplyStatementPatches(h2BridgeCloneRows(H2_CANONICAL_LEDGER_SEED), patches); 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() { function h2BridgeCreateStore() {
var listeners = []; var listeners = [];
var state = { rows: h2BridgeBuildInitialRows() }; var state = {
rows: h2BridgeBuildInitialRows(),
manualLedgers: h2BridgeBuildInitialManualLedgers()
};
function notify() { function notify() {
listeners.forEach(function (fn) { listeners.forEach(function (fn) {
@@ -93,6 +180,7 @@ function h2BridgeCreateStore() {
state.rows = h2BridgeCloneRows(rows); state.rows = h2BridgeCloneRows(rows);
notify(); notify();
}, },
getManualLedgers: function () { return h2BridgeCloneManualLedgers(state.manualLedgers); },
subscribe: function (fn) { subscribe: function (fn) {
listeners.push(fn); listeners.push(fn);
return function () { return function () {
@@ -115,6 +203,7 @@ function h2BridgeCreateStore() {
next.key = next.key || next.id; next.key = next.key || next.id;
} }
if (!next.reconcileStatus) next.reconcileStatus = H2_RECONCILE_PENDING; if (!next.reconcileStatus) next.reconcileStatus = H2_RECONCILE_PENDING;
if (!next.verifyStatus) next.verifyStatus = H2_VERIFY_UNVERIFIED;
var found = false; var found = false;
state.rows = state.rows.map(function (r) { state.rows = state.rows.map(function (r) {
if ((r.id || r.key) === next.id || (r.id || r.key) === next.key) { 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; var changed = state.rows.length !== before;
if (changed) notify(); if (changed) notify();
return changed; 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 costTotal = row.costTotal != null ? row.costTotal : row.costAmount;
var unitPrice = row.costUnitPrice != null ? row.costUnitPrice : row.customerUnitPrice; var unitPrice = row.costUnitPrice != null ? row.costUnitPrice : row.customerUnitPrice;
var totalAmount = costTotal != null ? costTotal : row.customerAmount; var totalAmount = costTotal != null ? costTotal : row.customerAmount;
var reconcileTime = row.reconcileDate || row.reconciledAt || ''; var reconcileTime = h2BridgeResolveReconcileTime(row);
var isReconciled = row.reconcileStatus === H2_RECONCILE_RECONCILED || Boolean(reconcileTime); var isReconciled = row.reconcileStatus === H2_RECONCILE_RECONCILED || Boolean(row.reconcileDate);
var verifiedAt = row.verifiedAt || '';
return { return {
id: row.id || row.key || ('hr-' + (index + 1)), id: row.id || row.key || ('hr-' + (index + 1)),
hydrogenTime: h2BridgeFormatDateTime(row.hydrogenTime), hydrogenTime: h2BridgeFormatDateTime(row.hydrogenTime),
@@ -211,8 +388,10 @@ function h2BridgeMapToHrRecord(row, index, fillerNames) {
settlementStatus: row.settlementStatus, settlementStatus: row.settlementStatus,
stationName: row.stationName, stationName: row.stationName,
stationCode: row.stationId || H2_STATION_CODE_MAP[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, reconcileStatus: isReconciled ? H2_RECONCILE_RECONCILED : H2_RECONCILE_PENDING,
reconcileTime: isReconciled ? h2BridgeFormatDateTime(reconcileTime) : '' reconcileTime: isReconciled ? reconcileTime : ''
}; };
} }
@@ -299,6 +478,84 @@ function h2BridgeGetStationList() {
return list; 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() { function h2BridgeInit() {
h2BridgeEnsureApi(); h2BridgeEnsureApi();
var store = h2BridgeGetStore(); var store = h2BridgeGetStore();
@@ -311,6 +568,24 @@ function h2BridgeInit() {
getHrRecords: h2BridgeGetHrRecords, getHrRecords: h2BridgeGetHrRecords,
getOrderRecords: h2BridgeGetOrderRecords, getOrderRecords: h2BridgeGetOrderRecords,
getStationList: h2BridgeGetStationList, 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); }, upsertRow: function (row) { return store.upsertRow(row); },
removeRow: function (id) { return store.removeRow(id); }, removeRow: function (id) { return store.removeRow(id); },
subscribe: function (fn) { return store.subscribe(fn); } subscribe: function (fn) { return store.subscribe(fn); }
@@ -326,6 +601,8 @@ export {
H2_STATION_CODE_MAP, H2_STATION_CODE_MAP,
H2_RECONCILE_RECONCILED, H2_RECONCILE_RECONCILED,
H2_RECONCILE_PENDING, H2_RECONCILE_PENDING,
H2_VERIFY_VERIFIED,
H2_VERIFY_UNVERIFIED,
h2BridgeInit, h2BridgeInit,
h2BridgeGetStore, h2BridgeGetStore,
h2BridgeGetAllRefuelRecords, h2BridgeGetAllRefuelRecords,
@@ -333,5 +610,10 @@ export {
h2BridgeBuildStationLedgerStore, h2BridgeBuildStationLedgerStore,
h2BridgeGetHrRecords, h2BridgeGetHrRecords,
h2BridgeGetOrderRecords, h2BridgeGetOrderRecords,
h2BridgeGetStationList h2BridgeGetStationList,
h2BridgeNormalizePlate,
h2BridgeNormalizeHydrogenTime,
h2BridgeFindDuplicateRow,
h2BridgeTotalAmountMismatch,
h2BridgeLookupStationUnitPrice
}; };

View File

@@ -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` 中注释停用,函数保留签名便于对照。
---
## 与收款状态的关系
收款状态仍按实收与应收比较展示(未收款 / 部分收款 / 已结清);实收 ≥ 应收合计时为「已结清」,与是否允许多收不冲突。

View File

@@ -34,8 +34,8 @@
| 场景 | 现状 | | 场景 | 现状 |
|------|------| |------|------|
| 重复导入 | 跳过 / 覆盖 / 报错 + 摘要 Toast | | 重复导入 | 跳过 / 覆盖 / 报错;主键 year-month-plate-customer |
| 实收超额 | 导入/编辑拦截;列表负未收警示 | | 实收超额 | **允许**导入/编辑拦截;列表负未收/超额样式 |
| 行内操作 | 编辑、删除、变更日志 | | 行内操作 | 编辑、删除、变更日志 |
## 证据与评估说明 ## 证据与评估说明
@@ -49,6 +49,6 @@
|---|---|---| |---|---|---|
| P1 | 缺少盈亏月度汇总 Tab | ➡️ 改由业务部台账;本页已移除 Tab | | P1 | 缺少盈亏月度汇总 Tab | ➡️ 改由业务部台账;本页已移除 Tab |
| P1 | 对账/锁定无入口 | ➡️ 产品范围调整:本页不做对账锁定 | | P1 | 对账/锁定无入口 | ➡️ 产品范围调整:本页不做对账锁定 |
| P2 | 导入重复键 | ✅ 三策略 + 主键 year-month-plate | | P2 | 导入重复键 | ✅ 三策略 + 主键 year-month-plate-**customer** |
| P2 | 实收超额 | ✅ 导入/编辑校验 + 负未收样式 | | P2 | 实收超额 | ➡️ 产品调整:**允许**超额;拦截已停用 |
| P3 | PRD/annotation 偏差 | ✅ 公式、导出 48 列、边界文案已更新 | | P3 | PRD/annotation 偏差 | ✅ 公式、导出 48 列、边界文案已更新 |

View File

@@ -79,7 +79,7 @@
### 导入重复策略 ### 导入重复策略
业务主键:`年份 + 月份 + 车牌号码` 业务主键:`年份 + 月份 + 车牌号码 + 客户名称`(完整规则见 **`.spec/import-rules.md`**
| 策略 | 行为 | | 策略 | 行为 |
|---|---| |---|---|
@@ -87,7 +87,9 @@
| 覆盖重复 | 用导入行覆盖已有记录 | | 覆盖重复 | 用导入行覆盖已有记录 |
| 报错 | 重复行写入错误日志 | | 报错 | 重复行写入错误日志 |
导入与编辑均校验:**实收金额 ≤ 应收合计**。 ### 实收金额
**不限制**实收 ≤ 应收合计:导入与编辑均允许实收大于应收合计(未收可为负,列表超额样式提示)。旧规则已停用,见 `.spec/import-rules.md`
--- ---
@@ -179,9 +181,10 @@
| 筛选 / KPI 汇总 | `utils/detail.ts` | | 筛选 / KPI 汇总 | `utils/detail.ts` |
| 收款状态 | `utils/payment-status.ts` | | 收款状态 | `utils/payment-status.ts` |
| 公式计算 | `utils/calc-detail.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/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` | | 变更日志 | `utils/change-log.ts` |
| 类型 | `types.ts` | | 类型 | `types.ts` |
| 组件 | `FilterPanel.tsx``DetailKpiRow.tsx``DetailTable.tsx``DetailImportModal.tsx``DetailEditModal.tsx``DetailChangeLogModal.tsx` | | 组件 | `FilterPanel.tsx``DetailKpiRow.tsx``DetailTable.tsx``DetailImportModal.tsx``DetailEditModal.tsx``DetailChangeLogModal.tsx` |
@@ -196,6 +199,8 @@
4. 氢费非零可点击明细;编辑/删除/变更日志可用 4. 氢费非零可点击明细;编辑/删除/变更日志可用
5. 未收 > 0 标红;盈亏正负着色 5. 未收 > 0 标红;盈亏正负着色
6. 客户名称等校验列:通过显示绿勾,不一致显示黄警告并可查看说明 / 一键替换(规则见 `.spec/field-checks.md` 6. 客户名称等校验列:通过显示绿勾,不一致显示黄警告并可查看说明 / 一键替换(规则见 `.spec/field-checks.md`
7. 导入主键含客户名称;同车牌同月不同客户可并存
8. 实收可大于应收合计:导入/编辑不拦截;未收可为负并有超额样式
--- ---

File diff suppressed because one or more lines are too long

View File

@@ -54,7 +54,7 @@ export function DetailEditModal({ open, row, onClose, onSave }: DetailEditModalP
</button> </button>
</header> </header>
<form id="lbd-edit-form" className="vm-modal-body lbd-edit-form" onSubmit={handleSubmit}> <form id="lbd-edit-form" className="vm-modal-body lbd-edit-form" onSubmit={handleSubmit}>
<p className="lbd-edit-hint"></p> <p className="lbd-edit-hint"></p>
{error ? <p className="lbd-edit-error" role="alert">{error}</p> : null} {error ? <p className="lbd-edit-error" role="alert">{error}</p> : null}
<div className="lbd-edit-grid"> <div className="lbd-edit-grid">
{DETAIL_EDIT_FIELDS.map((field) => ( {DETAIL_EDIT_FIELDS.map((field) => (

View File

@@ -16,7 +16,7 @@ interface DetailImportModalProps {
} }
const DUPLICATE_OPTIONS: { value: DetailImportDuplicateStrategy; label: string; hint: string }[] = [ const DUPLICATE_OPTIONS: { value: DetailImportDuplicateStrategy; label: string; hint: string }[] = [
{ value: 'skip', label: '跳过重复', hint: '同「年份+月份+车牌」已存在则跳过' }, { value: 'skip', label: '跳过重复', hint: '同「年份+月份+车牌+客户名称」已存在则跳过' },
{ value: 'overwrite', label: '覆盖重复', hint: '用导入行覆盖已有记录' }, { value: 'overwrite', label: '覆盖重复', hint: '用导入行覆盖已有记录' },
{ value: 'error', label: '报错', hint: '重复行写入错误日志' }, { value: 'error', label: '报错', hint: '重复行写入错误日志' },
]; ];
@@ -66,7 +66,7 @@ export function DetailImportModal({
<section className="vm-import-step"> <section className="vm-import-step">
<div className="vm-import-step-head"> <div className="vm-import-step-head">
<span className="vm-step-badge">2</span> <span className="vm-step-badge">2</span>
<span> + + </span> <span> + + + </span>
</div> </div>
<div className="lbd-import-dup-options" role="radiogroup" aria-label="重复记录处理"> <div className="lbd-import-dup-options" role="radiogroup" aria-label="重复记录处理">
{DUPLICATE_OPTIONS.map((option) => ( {DUPLICATE_OPTIONS.map((option) => (

View File

@@ -10,10 +10,12 @@ const root = join(__dirname, '..');
const jsonPath = join(root, 'annotation-source.json'); const jsonPath = join(root, 'annotation-source.json');
const prdPath = join(root, '.spec/requirements-prd.md'); const prdPath = join(root, '.spec/requirements-prd.md');
const fieldChecksPath = join(root, '.spec/field-checks.md'); const fieldChecksPath = join(root, '.spec/field-checks.md');
const importRulesPath = join(root, '.spec/import-rules.md');
const doc = JSON.parse(readFileSync(jsonPath, 'utf8')); const doc = JSON.parse(readFileSync(jsonPath, 'utf8'));
const prd = readFileSync(prdPath, 'utf8'); const prd = readFileSync(prdPath, 'utf8');
const fieldChecksMd = readFileSync(fieldChecksPath, 'utf8'); const fieldChecksMd = readFileSync(fieldChecksPath, 'utf8');
const importRulesMd = readFileSync(importRulesPath, 'utf8');
for (const node of doc.data.nodes) { for (const node of doc.data.nodes) {
if (node.annotationText) { if (node.annotationText) {
@@ -64,10 +66,16 @@ doc.markdownMap['lbd-toolbar'] = `## 4. 列表工具栏
### 导入重复策略 ### 导入重复策略
主键:年份 + 月份 + 车牌。支持跳过 / 覆盖 / 报错;实收 ≤ 应收合计 主键:年份 + 月份 + 车牌 + **客户名称**。支持跳过 / 覆盖 / 报错。
### 实收金额
允许实收大于应收合计(导入/编辑不拦截)。详见 \`.spec/import-rules.md\`
---`; ---`;
doc.markdownMap['lbd-doc-import-rules'] = importRulesMd;
doc.markdownMap['lbd-doc-formulas'] = `## 6. 公式列(系统自动计算) doc.markdownMap['lbd-doc-formulas'] = `## 6. 公式列(系统自动计算)
| 字段 | 计算方式 | | 字段 | 计算方式 |
@@ -85,7 +93,7 @@ doc.markdownMap['lbd-doc-boundary'] = `## 模块边界
| 模块 | 定位 | | 模块 | 定位 |
|---|---| |---|---|
| **租赁业务明细** | Excel 宽表维护48 列;收款状态;导入去重与实收校验 | | **租赁业务明细** | Excel 宽表维护48 列;收款状态;导入去重(含客户名称) |
| **租赁业务台账** | 账单维度、收款关联、归档与数据范围 | | **租赁业务台账** | 账单维度、收款关联、归档与数据范围 |
| **业务部台账** | 租赁业绩与盈亏月度汇总(数据来源含本模块明细) | | **业务部台账** | 租赁业绩与盈亏月度汇总(数据来源含本模块明细) |
@@ -219,6 +227,13 @@ doc.directory.nodes = [
markdown: mm['lbd-doc-field-checks'], markdown: mm['lbd-doc-field-checks'],
markdownPath: 'src/prototypes/lease-business-detail/.spec/field-checks.md', markdownPath: 'src/prototypes/lease-business-detail/.spec/field-checks.md',
}, },
{
type: 'markdown',
id: 'lbd-doc-import-rules',
title: '导入与金额规则',
markdown: mm['lbd-doc-import-rules'],
markdownPath: 'src/prototypes/lease-business-detail/.spec/import-rules.md',
},
{ {
type: 'markdown', type: 'markdown',
id: 'lbd-doc-source', id: 'lbd-doc-source',
@@ -233,10 +248,11 @@ doc.directory.nodes = [
| 筛选 / KPI 汇总 | \`utils/detail.ts\` | | 筛选 / KPI 汇总 | \`utils/detail.ts\` |
| 收款状态 | \`utils/payment-status.ts\` | | 收款状态 | \`utils/payment-status.ts\` |
| 公式计算 | \`utils/calc-detail.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/field-checks.ts\`\`utils/system-ref.ts\`\`components/FieldCheckIcon.tsx\` |
| 保存校验 | \`utils/validate-detail.ts\` | | 保存校验 | \`utils/validate-detail.ts\`(实收超额拦截已停用) |
| 校验说明 | \`.spec/field-checks.md\` | | 校验说明 | \`.spec/field-checks.md\` |
| 导入规则 | \`.spec/import-rules.md\` |
| 变更日志 | \`utils/change-log.ts\` | | 变更日志 | \`utils/change-log.ts\` |
| 类型 | \`types.ts\` | | 类型 | \`types.ts\` |
| 组件 | \`FilterPanel.tsx\`\`DetailKpiRow.tsx\`\`DetailTable.tsx\`\`DetailImportModal.tsx\`\`DetailEditModal.tsx\`\`DetailChangeLogModal.tsx\` | | 组件 | \`FilterPanel.tsx\`\`DetailKpiRow.tsx\`\`DetailTable.tsx\`\`DetailImportModal.tsx\`\`DetailEditModal.tsx\`\`DetailChangeLogModal.tsx\` |

View File

@@ -4,7 +4,6 @@ import { DETAIL_EXPORT_HEADERS, DETAIL_EXPORT_SPECS } from '../components/tableC
import { applyDetailCalculations } from './calc-detail'; import { applyDetailCalculations } from './calc-detail';
import { getContractRef, getVehicleRef, isPlateInVehicleSystem } from './system-ref'; import { getContractRef, getVehicleRef, isPlateInVehicleSystem } from './system-ref';
import { resolveDetailPaymentStatus } from './payment-status'; import { resolveDetailPaymentStatus } from './payment-status';
import { validateDetailRowAmounts } from './validate-detail';
/** 导入模板列(不含公式列;业务部门/业务员由系统合同反写) */ /** 导入模板列(不含公式列;业务部门/业务员由系统合同反写) */
export const DETAIL_IMPORT_TEMPLATE_HEADERS = [ export const DETAIL_IMPORT_TEMPLATE_HEADERS = [
@@ -373,12 +372,7 @@ export async function parseDetailImportFile(file: File): Promise<DetailImportPar
} }
const calculated = applyDetailCalculations(draft); const calculated = applyDetailCalculations(draft);
const amountReason = validateDetailRowAmounts(calculated); // 实收 ≤ 应收合计:已停用,允许实收大于应收合计
if (amountReason) {
errors.push({ reason: amountReason, rawCells });
return;
}
success.push(calculated); success.push(calculated);
}); });

View File

@@ -1,7 +1,6 @@
import type { LeaseBusinessDetailRow } from '../types'; import type { LeaseBusinessDetailRow } from '../types';
import type { DetailImportErrorRow } from './batch-io'; import type { DetailImportErrorRow } from './batch-io';
import { detailRowBusinessKey } from './row-key'; import { detailRowBusinessKey, detailRowBusinessKeyLabel } from './row-key';
import { validateDetailRowAmounts } from './validate-detail';
export type DetailImportDuplicateStrategy = 'skip' | 'overwrite' | 'error'; export type DetailImportDuplicateStrategy = 'skip' | 'overwrite' | 'error';
@@ -11,6 +10,7 @@ export interface DetailImportMergeResult {
overwritten: number; overwritten: number;
overwrittenIds: string[]; overwrittenIds: string[];
duplicateErrors: DetailImportErrorRow[]; duplicateErrors: DetailImportErrorRow[];
/** 历史字段:实收超额校验已停用,始终为空 */
amountErrors: DetailImportErrorRow[]; amountErrors: DetailImportErrorRow[];
} }
@@ -37,12 +37,7 @@ export function mergeDetailImportRows(
const amountErrors: DetailImportErrorRow[] = []; const amountErrors: DetailImportErrorRow[] = [];
imported.forEach((row) => { imported.forEach((row) => {
const amountReason = validateDetailRowAmounts(row); // 实收 ≤ 应收合计:已停用,允许实收大于应收合计
if (amountReason) {
amountErrors.push({ reason: amountReason, rawCells: emptyRawCells() });
return;
}
const key = detailRowBusinessKey(row); const key = detailRowBusinessKey(row);
let existingIndex = keyToIndex.get(key); let existingIndex = keyToIndex.get(key);
@@ -62,7 +57,7 @@ export function mergeDetailImportRows(
} }
if (strategy === 'error') { if (strategy === 'error') {
duplicateErrors.push({ duplicateErrors.push({
reason: `重复记录:${row.year}${row.month}月 · ${row.plateNo} 与本批导入重复`, reason: `重复记录:${detailRowBusinessKeyLabel(row)} 与本批导入重复`,
rawCells: emptyRawCells(), rawCells: emptyRawCells(),
}); });
return; return;
@@ -80,7 +75,7 @@ export function mergeDetailImportRows(
if (strategy === 'error') { if (strategy === 'error') {
duplicateErrors.push({ duplicateErrors.push({
reason: `重复记录:${row.year}${row.month}月 · ${row.plateNo} 已存在`, reason: `重复记录:${detailRowBusinessKeyLabel(row)} 已存在`,
rawCells: emptyRawCells(), rawCells: emptyRawCells(),
}); });
return; return;

View File

@@ -1,8 +1,15 @@
import type { LeaseBusinessDetailRow } from '../types'; import type { LeaseBusinessDetailRow } from '../types';
/** 业务主键:年份 + 月份 + 车牌号码 */ /** 业务主键:年份 + 月份 + 车牌号码 + 客户名称 */
export function detailRowBusinessKey( export function detailRowBusinessKey(
row: Pick<LeaseBusinessDetailRow, 'year' | 'month' | 'plateNo'>, row: Pick<LeaseBusinessDetailRow, 'year' | 'month' | 'plateNo' | 'customerName'>,
): string { ): string {
return `${row.year}-${String(row.month).padStart(2, '0')}-${row.plateNo.trim()}`; return `${row.year}-${String(row.month).padStart(2, '0')}-${row.plateNo.trim()}::${row.customerName.trim()}`;
}
/** 重复提示用的可读标签 */
export function detailRowBusinessKeyLabel(
row: Pick<LeaseBusinessDetailRow, 'year' | 'month' | 'plateNo' | 'customerName'>,
): string {
return `${row.year}${row.month}月 · ${row.plateNo} · ${row.customerName.trim() || '(无客户)'}`;
} }

View File

@@ -3,14 +3,19 @@ import { applyDetailCalculations } from './calc-detail';
const MONEY_EPS = 0.005; const MONEY_EPS = 0.005;
/** 实收不得超过应收合计(允许分位误差) */ /**
* 【已停用】原规则:实收金额不得超过应收合计。
* 产品已确认:实收金额可以大于应收合计(多收/预收场景),导入与编辑均不再拦截。
* 保留函数签名供对照历史逻辑;当前始终返回 null。
*/
export function validateReceivedAgainstReceivable( export function validateReceivedAgainstReceivable(
receivableTotal: number, _receivableTotal: number,
receivedAmount: number, _receivedAmount: number,
): string | null { ): string | null {
if (receivedAmount > receivableTotal + MONEY_EPS) { // if (receivedAmount > receivableTotal + MONEY_EPS) {
return `实收金额(${receivedAmount.toFixed(2)})不能大于应收合计(${receivableTotal.toFixed(2)}`; // return `实收金额(${receivedAmount.toFixed(2)})不能大于应收合计(${receivableTotal.toFixed(2)}`;
} // }
void MONEY_EPS;
return null; return null;
} }
@@ -18,7 +23,7 @@ export function validateDetailRowAmounts(row: LeaseBusinessDetailRow): string |
return validateReceivedAgainstReceivable(row.receivableTotal, row.receivedAmount); return validateReceivedAgainstReceivable(row.receivableTotal, row.receivedAmount);
} }
/** 编辑保存前:合并 patch 并重算后校验 */ /** 编辑保存前:合并 patch 并重算后校验(当前无金额上限拦截) */
export function validateDetailPatch( export function validateDetailPatch(
before: LeaseBusinessDetailRow, before: LeaseBusinessDetailRow,
patch: Partial<LeaseBusinessDetailRow>, patch: Partial<LeaseBusinessDetailRow>,

View File

@@ -0,0 +1,46 @@
# 功能:新增加氢记录
## 目标
站端通过手机浏览器上报一笔加氢记录:拍照识别 + 可校正,提交写入共享氢费 Store。
## 表单分区
### 1. 基础信息
| 字段 | 规则 |
|------|------|
| 加氢时间 * | 默认=点击「新增」时的当前时间;点击底部弹层选日期+时+分 |
| 加氢站名称 | 只读,登录本站名称 |
### 2. 车牌识别
| 字段 | 规则 |
|------|------|
| 拍摄区 | 提示「请拍摄车牌」;相册/相机OCR 模拟回填车牌 |
| 车牌号 * | 占位「请拍摄或输入车牌号」;点击弹出车牌键盘 |
| 车辆标签 | 有车牌后:系统车辆列表命中→「羚牛车辆」,否则→「非羚牛车辆」 |
| 里程km | 选填 |
### 3. 加氢机面板
| 字段 | 规则 |
|------|------|
| 拍摄区 | 提示「请拍摄清晰的加氢机照片」OCR 模拟回填单价、量、总额 |
| 氢气单价 * | 可改;变更后重算总额 |
| 加氢量 * | 可改;变更后重算总额 |
| 加氢总额(元) | 只读;= 单价 × 加氢量(两位小数);界面不展示公式文案 |
## 提交与拦截
- 必填齐全方可提交
- 同站 + 同车牌 + 同加氢时间 → 拦截重复
- 有填写内容点返回 → 二次确认防丢失
- 已核对或已对账记录不可从此入口编辑(列表进详情后按钮隐藏)
## 验收
1. 时间默认正确且可选到分
2. 车牌键盘可用;标签随车牌变化
3. 改单价或量后总额自动更新且不可手改
4. 空表单可直接返回;有内容返回需确认

View File

@@ -0,0 +1,35 @@
# 功能:详情、编辑与删除
## 目标
查看单笔加氢记录完整字段;在「未核对且未对账」时可编辑或删除。
## 详情展示
- 顶部:车牌、加氢时间、核对标签、对账标签
- 指标条:加氢总额、加氢量
- 明细行:站点、车牌、里程、单价、核对/对账状态与时间
## 锁定规则
| 条件 | 结果 |
|------|------|
| 已对账 | 不可编辑/删除;提示已纳入站点对账单 |
| 已核对(未对账) | 不可编辑/删除;提示能源部已核对 |
| 未核对且未对账 | 可编辑、可删除 |
判定函数:`isOrderLocked`(核对或对账任一成立即锁)。
## 编辑
进入与「新增」同一套表单,预填当前值;提交走更新逻辑(仍校验重复键)。
## 删除
二次确认后从 Bridge 移除该行。
## 验收
1. 未核对未对账:底部有删除/编辑
2. 已核对或已对账:无操作栏,有锁定说明
3. 详情含对账信息;列表卡片不含对账

View File

@@ -0,0 +1,40 @@
# 功能:本站列表与 KPI
## 目标
站端人员打开「加氢订单」后,一眼看到本站汇总与近期加氢记录,点击卡片进入详情。
## 页面结构
1. **当前站点**(只读):登录账号对应加氢站名称,无切站入口
2. **KPI 三卡**:订单数 / 加氢量(kg) / 加氢金额
3. **加氢记录列表**:按加氢时间倒序
4. **底栏「新增」**:固定不遮挡卡片滚动区
## 列表卡片字段
| 展示 | 说明 |
|------|------|
| 车牌号 | 去掉尾缀 F 展示 |
| 核对标签 | 未核对(橙)/ 已核对(绿) |
| 加氢时间 | 精确到分 |
| 加氢量 / 加氢总额 | 等宽数字 |
| 单价 | 页脚左侧 |
| 核对时间 | 仅已核对时页脚右侧显示 |
**不展示**:里程、对账状态(对账在站点对账单侧完成)。
## 交互
- 点击卡片 → 详情页
- 点击底栏「新增」→ 新增表单(时间默认=点击时刻)
## 数据范围
`stationName === 当前登录站` 的 Bridge 台账行。原型默认站:中国石油中油高新能源牙谷加油加氢站。
## 验收
1. 无切站控件
2. KPI 与列表条数一致
3. 滚动列表时底栏「新增」仍固定且不压住末条卡片

View File

@@ -0,0 +1,58 @@
# 功能:手工台账上传
## 目标
站端每日上传纸质**手工台账照片**,作为系统电子加氢记录的核对依据。多日未操作时,可通过日历查看缺口并补传过往日期。
## 产品口径
| 项 | 规则 |
|---|---|
| 入口 | H5底部 Tab「手工台账」Web顶栏「手工台账」 |
| 上传形态 | 仅图片(拍照/相册),可多张 |
| 维度 | **加氢站 + 自然日(本地日历日)** 一份 |
| 日历 | 月历自本站**首笔加氢记录日**起算:绿=已传、橙=未传;首笔之前无需补传;禁选未来日 |
| 补传 | 允许为过往未传日期补传;已传日期仅可**追加**新图 |
| 归档 | **确认上传后的图片进入已归档状态,不可删除**;未确认的草稿图仍可删除 |
| 拦截 | **未上传今日手工台账 → 禁止新增加氢记录**(编辑已有记录不拦;补传历史不替代今日校验) |
| 数据 | 共享 `H2VehicleLedgerBridge` 内存 Store未接真实 API |
## 判定顺序(新增拦截)
| 优先级 | 条件 | 结果 |
|--------|------|------|
| 1 | 操作=编辑已有记录 | 不校验手工台账 |
| 2 | 操作=新增,且目标站今日已有 ≥1 张台账图 | 允许进入新增 |
| 3 | 操作=新增,今日未上传 | 提示并跳转手工台账 Tab |
## 日历与补传
| 规则 | 说明 |
|------|------|
| 数据源 | `listManualLedgersByStation`;当日有 ≥1 张图视为已上传 |
| 业务起点 | 取本站 Bridge 加氢记录中最早 `hydrogenTime` 自然日;无记录则历史日均不要求补传 |
| 未传计数 | 本月内 `max(月初, 首笔日)`min(月末, 今日) 中未上传的天数 |
| 点选 | 仅 `dateKey ≤ 今日`;选中后右侧/下方展示该日草稿图与上传按钮 |
| 归档不可删 | Store 已有图强制保留;`upsertManualLedger` 仅追加新图,忽略删除已有图的请求 |
| 与拦截关系 | 仅 **今日** 影响「禁止新增」;历史补传解决核对缺口,不放宽今日门禁 |
## 用户可见
- 今日未上传:橙/黄提示条 + Tab 红点
- 日历:自首笔加氢日起绿/橙标记;首笔之前无橙点(无需补传);本月未传天数摘要
- 选中日:上传区标题为该日期;已归档图显示「已归档」无删除;仅未确认草稿可删;已有台账时主按钮为「确认补传」
## 代码路径
- Bridge`src/common/h2VehicleLedgerBridge.js``hasManualLedgerForDate` / `upsertManualLedger` / `listManualLedgersByStation`
- H5`oneos-h5-h2-order` · `ManualLedgerPanel` + `utils/manual-ledger.ts``buildManualMonthCells` / `getFirstHydrogenDateKey`
- Web`oneos-web-h2-station/pages/02-加氢记录.jsx`(手工台账 Tab 月历)
## 验收
1. 今日未上传时H5/Web 点「新增」均被拦截并引导上传
2. 上传至少一张图并确认后,可新增电子记录
3. 日历自首笔加氢日起区分已传/未传;首笔之前不标未传、不计入补传;可点选范围内未传日补传
4. 补传历史日后,该日日历变为已传;今日仍未传时新增仍被拦截
5. 确认上传后的图显示已归档且不可删除;仅可追加补传
6. H5 与 Web 共用同一 Store一端上传另一端可见同会话

View File

@@ -0,0 +1,30 @@
# 车牌「羚牛车辆 / 非羚牛车辆」标签
原型本地判定,未接真实车辆管理 API。
## 判定顺序
| 优先级 | 条件 | 结果 |
|--------|------|------|
| 1 | 车牌为空 | 不展示标签 |
| 2 | 规范化后车牌 ∈ 本地系统车辆集合 | **羚牛车辆** |
| 3 | 有车牌但不在集合中 | **非羚牛车辆** |
## 前置条件
- 识别OCR或键盘输入得到车牌后才展示。
- 比较前去掉尾缀 `F`,统一大写。
## 数据源
- `utils/fleet-plates.ts` 内本地种子车牌集合(含加氢 Bridge 演示车牌与部分车辆管理样例)。
- 正式环境应改为查询车辆管理 / 系统车辆列表接口。
## 用户可见结果
- 绿色标签「羚牛车辆」或橙色标签「非羚牛车辆」。
- 不拦截提交(仅提示归属)。
## 代码路径
- `getVehicleTag` / `isLingniuVehicle``CreateWizard` 车牌行右侧标签。

View File

@@ -1,51 +1,48 @@
# 加氢订单H5· 对账联动规则 # 加氢订单H5· 对账 / 核对联动规则
| 项 | 说明 | | 项 | 说明 |
|---|---| |---|---|
| 代码路径 | `src/common/h2VehicleLedgerBridge.js`;对账单 UI`oneos-web-h2-station/pages/03-站点信息.jsx`PC 加氢记录:`oneos-web-h2-station/pages/02-加氢记录.jsx` | | 代码路径 | Store`src/common/h2VehicleLedgerBridge.js`;对账单:`oneos-web-h2-station-site`PC 加氢记录:`oneos-web-h2-station`;车辆氢费明细:`vehicle-h2-fee-ledger` |
| 规格全文 | [../vehicle-h2-fee-ledger/.spec/verify-reconcile.md](../vehicle-h2-fee-ledger/.spec/verify-reconcile.md) |
| 数据源 | 原型本地种子 + 内存共享 Store**未接真实 API** | | 数据源 | 原型本地种子 + 内存共享 Store**未接真实 API** |
| 双端一致 | H5「加氢订单」与 Web「加氢记录」读写同一 Store、同一站端口径字段Web 另有导出 / 预约加氢 |
--- ---
## 1. 判定优先级 ## 1. 两套状态(判定优先级
| 优先级 | 条件 | 站端 H5 展示 | | 优先级 | 状态 | 字段 | 谁触发 | 用户可见 |
|---|---|---| |---|---|---|---|---|
| 1 | `reconcileStatus === 'reconciled'` 或已写 `reconcileDate`(对账单提交回写) | **已对账**;对账时间取 `reconcileDate` / `reconciledAt`,格式 `YYYY-MM-DD HH:mm` | | 1 | **核对** | `verifyStatus` / `verifiedAt` | 车辆氢费明细 · **完成核对** | 未核对 / 已核对 + 核对时间 |
| 2 | 否则 | **对账**;对账时间显示「—」 | | 2 | **对账** | `reconcileStatus` / `reconcileDate` | 站点信息 · **对账单提交** | 未对账 / 已对账 + 对账时间 |
## 2. 前置条件 > `reconciledAt` 仅作完成核对时的兼容写入,**不得**再用于判定「已对账」。
| 场景 | 是否校验 / 联动 | ## 2. 数据流
|---|---|
| 未打开站点信息、未提交对账单 | 种子里已对账记录仍显示已对账H5 新增默认为未对账 |
| 记录已绑定 `statementRecordId` | 不再进入下一张对账单 |
| 切换演示站点 | 仅过滤 `stationName`,不对其他站回写 |
## 3. 数据流
```text ```text
站端 H5 加氢订单 / Web 加氢记录 新增/编辑 站端 H5 / Web 新增加氢记录
→ Bridge.upsertRowreconcileStatus=pending成本单价/量/总额) → Bridge.upsertRowverify=unverifiedreconcile=pending成本单价/量/总额)
共享 Store 通知订阅方(双端列表同步) 车辆氢费明细可见
站点信息 · 生成对账单 能源部 · 完成核对
筛选:本站 ∧ 无 statementRecordId ∧ (pending|reconciled) ∧ 账期内 verifyStatus=verified + verifiedAt
提交:写 statementRecordId、reconcileStatus=reconciled、reconcileDate 站端同步「已核对」,禁改删
→ applyPatches → H5 / Web 同步「已对账」+ 对账时间
站点信息 · 对账单提交(仅已核对 ∧ 未对账)
→ reconcileStatus=reconciled + reconcileDate + statementRecordId
→ 站端 / 台账同步「已对账」
``` ```
## 4. 用户可见结果 ## 3. 用户可见结果H5
| 结果 | 文案 / 行为 | | 结果 | 行为 |
|---|---| |---|---|
| 未对 | 标签「未对账」;可编辑、删除 | | 未对 | 可编辑、删除 |
| 已对账 | 标签「已对账」;详情只读提示「已纳入站点对账单,不可修改」 | | 已核对未对账 | 只读提示能源部已核对 |
| 提交对账单成功后 | 同步出现对账时间(操作完成时间) | | 已对账 | 只读;提示已纳入对账单 |
## 5. 边界 ## 4. 边界
- 站端口径单价/总额映射 `costUnitPrice` / `costTotal`(加氢机成本侧) - 站端上报默认未核对、未对账
- OCR 为模拟识别,不解析真实图片数字 - 对账时间只认 `reconcileDate`(对账单回写)
- 跨页联动依赖同浏览器会话内未刷新导致的 Store 重建场景有限:刷新后以种子 + `H2_STATION_STATEMENT_LEDGER_UPDATES` 队列重建 - 跨页联动依赖同浏览器会话共享 Store

View File

@@ -1,21 +1,21 @@
# 加氢订单H5— 产品需求文档 # 加氢订单H5— 产品需求文档PRD
| 项 | 内容 | | 项 | 内容 |
|---|---| |---|---|
| 文档版本 | v1.0 | | 文档版本 | v1.3 |
| 模块名称 | 加氢站管理 → 加氢订单 | | 模块名称 | 加氢站管理 → 加氢订单 |
| 所属系统 | ONE-OS加氢站手机浏览器 | | 所属系统 | ONE-OS加氢站手机浏览器 |
| 交互原型 | `/prototypes/oneos-h5-h2-order` | | 交互原型 | `/prototypes/oneos-h5-h2-order` |
| 设计基底 | 小羚羚 `xll-miniapp/DESIGN.md` | | 设计基底 | 小羚羚 `xll-miniapp/DESIGN.md` |
| 关联模块 | [站点信息](/prototypes/oneos-web-h2-station-site)、[加氢记录 Web](/prototypes/oneos-web-h2-station)、[车辆氢费明细](/prototypes/vehicle-h2-fee-ledger) | | 关联模块 | [站点信息](/prototypes/oneos-web-h2-station-site)、[加氢记录 Web](/prototypes/oneos-web-h2-station)、[车辆氢费明细](/prototypes/vehicle-h2-fee-ledger) |
| 决策快照 | [2026-07-14-requirements-design.md](./2026-07-14-requirements-design.md) |
| 对账规则全文 | [reconcile-linkage.md](./reconcile-linkage.md) |
--- ---
## 1. 背景与目标 ## 1. 背景与目标
采购创建加氢站并绑定供应商、开放系统账号后,加氢站人员通过手机浏览器登录 OneOS在「加氢订单」模块上报加氢记录。Web「加氢记录」为同一业务的 PC 入口(数据结构一致PC 另有导出与预约加氢)。 采购创建加氢站并绑定供应商、开放系统账号后,加氢站人员通过手机浏览器登录 OneOS在「加氢订单」模块上报加氢记录。Web「加氢记录」为同一业务的 PC 入口(共享 Bridge 数据PC 另有导出与预约加氢)。
每日须上传**手工台账**照片,作为电子记录的核对依据;未上传当日手工台账时不可新增加氢记录。手工台账页提供月历,标出已传/未传日期,支持补传过往缺口。
### 1.1 目标用户 ### 1.1 目标用户
@@ -23,16 +23,18 @@
### 1.2 功能清单 ### 1.2 功能清单
| # | 功能 | 说明 | | # | 功能 | 说明文档 |
|---|---|---| |---|---|---|
| 1 | 本站列表 | 仅当前站KPI订单数 / 加氢量 / 加氢金额;字段见下表 | | 1 | 本站列表与 KPI | [feature-list.md](./feature-list.md) |
| 2 | OCR 新增 | 单表单:拍车牌 / 拍面板 OCR 可校正后提交;返回时非空二次确认 | | 2 | 新增加氢记录 | [feature-create.md](./feature-create.md) |
| 3 | 未对账编辑/删除 | 已对账只读 | | 3 | 详情 / 编辑 / 删除 | [feature-detail.md](./feature-detail.md) |
| 4 | 对账单联动 | 共享氢费 Store对账后状态与时间回写 | | 4 | **手工台账上传** | [feature-manual-ledger.md](./feature-manual-ledger.md) |
| 5 | 羚牛车辆标签 | [fleet-plate-tag.md](./fleet-plate-tag.md) |
| 6 | 核对与对账联动 | [reconcile-linkage.md](./reconcile-linkage.md) |
### 1.3 列表字段 ### 1.3 列表字段
加氢时间、加氢站名称、车牌号、氢气单价(元/kg、加氢量、加氢总额、对账状态、对账时间(`YYYY-MM-DD HH:mm` 加氢时间、车牌号、氢气单价、加氢量、加氢总额、核对状态;已核对时展示核对时间。里程在新增/详情展示(选填),列表不展示。列表不展示对账状态
### 1.4 不做 ### 1.4 不做
@@ -43,10 +45,12 @@
## 2. 验收项 ## 2. 验收项
1. 菜单位于 OneOS → 加氢站管理 → 加氢订单 1. 菜单位于 OneOS → 加氢站管理 → 加氢订单
2. 默认已登录;可切换演示站点;列表仅本站 2. 默认已登录本站;列表仅本站(无切站)
3. FAB 进入单表单新增OCR 可校正;时间默认当前;站名只读;有内容返回二次确认 3. 底栏 Tab加氢订单 / 手工台账;月历可区分已传/未传并补传过往日;未上传今日手工台账不可新增;上传后可新增;时间默认点击新增时刻(到分)
4. 已对账不可编辑/删除;未对账可 4.核对或已对账不可编辑/删除;未核对未对账可
5. 站点信息提交对账单后,本页对应记录显示「已对账」+ 对账时间(同会话 Store 5. 能源部完成核对后同步「已核对」;站点对账单提交后同步「已对账」+ 对账时间
6. 同站同车牌同加氢时间重复保存被拦截
7. Web「加氢记录」与 H5「加氢订单」手工台账逻辑一致同 Store
--- ---
@@ -54,4 +58,20 @@
| 主题 | 摘要 | 全文 | | 主题 | 摘要 | 全文 |
|---|---|---| |---|---|---|
| 对账联动 | 站端上报写入共享 Store与 Web 加氢记录同源);对账单纳入 pending/已对账未结算记录;提交后标记已对账并写对账时间 | [reconcile-linkage.md](./reconcile-linkage.md) | | 对账 / 核对 | 核对≠对账;触发方不同 | [reconcile-linkage.md](./reconcile-linkage.md)、[车辆氢费 verify-reconcile](../vehicle-h2-fee-ledger/.spec/verify-reconcile.md) |
| 重复键 | 与 Web 共用 Bridge | [Web 数据模型](../oneos-web-h2-station/.spec/record-data-model.md) |
| 羚牛车辆标签 | 本地系统车辆集合判定 | [fleet-plate-tag.md](./fleet-plate-tag.md) |
| 总额 | 单价×加氢量自动算,界面不展示公式 | [feature-create.md](./feature-create.md) |
| 手工台账 | 按站+自然日上传图片;月历标已传/未传可补传;未上传今日禁新增 | [feature-manual-ledger.md](./feature-manual-ledger.md) |
---
## 4. 页面与标注对照
| 页面 pageId | 主要能力 |
|---|---|
| `list` | 站点头、KPI、订单卡片、底栏新增 |
| `create` | 时间选择、站名、车牌键盘、面板 OCR、金额字段 |
| `detail` | 详情字段、锁定说明、编辑/删除 |
标注数据:`annotation-source.json`(由 `scripts/build-annotation-source.mjs` 从本目录 Markdown 生成)。

View File

@@ -2,89 +2,443 @@
"documentVersion": 1, "documentVersion": 1,
"format": "axhub-annotation-source", "format": "axhub-annotation-source",
"data": { "data": {
"version": 1, "version": 2,
"prototypeName": "oneos-h5-h2-order", "prototypeName": "oneos-h5-h2-order",
"pageId": "h5-h2-order", "pageId": "list",
"updatedAt": 1784010000000, "updatedAt": 1784260628220,
"nodes": [ "nodes": [
{ {
"id": "h5-station", "images": [],
"controls": [],
"createdAt": 1784260628220,
"updatedAt": 1784260628220,
"hasMarkdown": true,
"annotationText": "",
"id": "h5-hero",
"index": 1, "index": 1,
"title": "当前加氢站(演示)", "title": "当前站点",
"pageId": "h5-h2-order", "pageId": "list",
"locator": {
"selectors": ["#h5-station-switch", "[data-ax=\"h5-h2-order-root\"]"],
"fingerprint": "station|h5-h2-order",
"path": []
},
"annotationText": "已登录态演示:可切换示例站点;列表仅展示本站加氢记录。",
"hasMarkdown": false,
"color": "#7AB929", "color": "#7AB929",
"images": [], "locator": {
"createdAt": 1784010000000, "selectors": [
"updatedAt": 1784010000000, "[data-annotation-id='h5-hero']",
"controls": [] ".h5-hero"
],
"fingerprint": "hero|list",
"path": []
}
}, },
{ {
"id": "h5-fab", "images": [],
"controls": [],
"createdAt": 1784260628220,
"updatedAt": 1784260628220,
"hasMarkdown": true,
"annotationText": "",
"id": "h5-summary",
"index": 2, "index": 2,
"title": "新增加氢记录", "title": "本站 KPI",
"pageId": "h5-h2-order", "pageId": "list",
"color": "#0E8A7B",
"locator": { "locator": {
"selectors": [".h5-fab"], "selectors": [
"fingerprint": "fab|h5-h2-order", "[data-annotation-id='h5-summary']",
".h5-summary"
],
"fingerprint": "summary|list",
"path": [] "path": []
}, }
"annotationText": "单表单新增:加氢时间默认当前;拍车牌/面板 OCR 可校正;有内容点返回会二次确认防丢失。",
"hasMarkdown": false,
"color": "#7AB929",
"images": [],
"createdAt": 1784010000000,
"updatedAt": 1784010000000,
"controls": []
}, },
{ {
"images": [],
"controls": [],
"createdAt": 1784260628220,
"updatedAt": 1784260628220,
"hasMarkdown": true,
"annotationText": "",
"id": "h5-card", "id": "h5-card",
"index": 3, "index": 3,
"title": "订单卡片", "title": "订单卡片",
"pageId": "h5-h2-order", "pageId": "list",
"locator": {
"selectors": [".h5-card"],
"fingerprint": "card|h5-h2-order",
"path": []
},
"annotationText": "展示时间、车牌、单价、量、总额、对账状态/时间;已对账只读,未对账可编辑删除。",
"hasMarkdown": false,
"color": "#FF7D00", "color": "#FF7D00",
"images": [], "locator": {
"createdAt": 1784010000000, "selectors": [
"updatedAt": 1784010000000, "[data-annotation-id='h5-card']",
"controls": [] ".h5-card"
} ],
], "fingerprint": "card|list",
"directory": [ "path": []
}
},
{
"images": [],
"controls": [],
"createdAt": 1784260628220,
"updatedAt": 1784260628220,
"hasMarkdown": true,
"annotationText": "",
"id": "h5-fab",
"index": 4,
"title": "底栏新增",
"pageId": "list",
"color": "#7AB929",
"locator": {
"selectors": [
"[data-annotation-id='h5-fab']",
".h5-fab"
],
"fingerprint": "fab|list",
"path": []
}
},
{
"images": [],
"controls": [],
"createdAt": 1784260628220,
"updatedAt": 1784260628220,
"hasMarkdown": true,
"annotationText": "",
"id": "h5-time",
"index": 5,
"title": "加氢时间",
"pageId": "create",
"color": "#7AB929",
"locator": {
"selectors": [
"[data-annotation-id='h5-time']",
"#h5-time"
],
"fingerprint": "field|h5-time",
"path": []
}
},
{
"images": [],
"controls": [],
"createdAt": 1784260628220,
"updatedAt": 1784260628220,
"hasMarkdown": true,
"annotationText": "",
"id": "h5-station",
"index": 6,
"title": "加氢站名称",
"pageId": "create",
"color": "#86909C",
"locator": {
"selectors": [
"[data-annotation-id='h5-station']",
"#h5-station"
],
"fingerprint": "field|h5-station",
"path": []
}
},
{
"images": [],
"controls": [],
"createdAt": 1784260628220,
"updatedAt": 1784260628220,
"hasMarkdown": true,
"annotationText": "",
"id": "h5-plate",
"index": 7,
"title": "车牌号",
"pageId": "create",
"color": "#FF7D00",
"locator": {
"selectors": [
"[data-annotation-id='h5-plate']",
"#h5-plate"
],
"fingerprint": "field|h5-plate",
"path": []
}
},
{
"images": [],
"controls": [],
"createdAt": 1784260628220,
"updatedAt": 1784260628220,
"hasMarkdown": true,
"annotationText": "",
"id": "h5-price",
"index": 8,
"title": "氢气单价",
"pageId": "create",
"color": "#7AB929",
"locator": {
"selectors": [
"[data-annotation-id='h5-price']",
"#h5-price"
],
"fingerprint": "field|h5-price",
"path": []
}
},
{
"images": [],
"controls": [],
"createdAt": 1784260628220,
"updatedAt": 1784260628220,
"hasMarkdown": true,
"annotationText": "",
"id": "h5-kg",
"index": 9,
"title": "加氢量",
"pageId": "create",
"color": "#7AB929",
"locator": {
"selectors": [
"[data-annotation-id='h5-kg']",
"#h5-kg"
],
"fingerprint": "field|h5-kg",
"path": []
}
},
{
"images": [],
"controls": [],
"createdAt": 1784260628220,
"updatedAt": 1784260628220,
"hasMarkdown": true,
"annotationText": "",
"id": "h5-total-label",
"index": 10,
"title": "加氢总额",
"pageId": "create",
"color": "#0E8A7B",
"locator": {
"selectors": [
"[data-annotation-id='h5-total-label']",
"#h5-total"
],
"fingerprint": "field|h5-total",
"path": []
}
},
{
"images": [],
"controls": [],
"createdAt": 1784260628220,
"updatedAt": 1784260628220,
"hasMarkdown": true,
"annotationText": "",
"id": "h5-detail",
"index": 11,
"title": "记录详情",
"pageId": "detail",
"color": "#FF7D00",
"locator": {
"selectors": [
"[data-annotation-id='h5-detail']",
".h5-panel--detail"
],
"fingerprint": "detail|panel",
"path": []
}
},
{
"images": [],
"controls": [],
"createdAt": 1784260628220,
"updatedAt": 1784260628220,
"hasMarkdown": true,
"annotationText": "",
"id": "h5-manual",
"index": 12,
"title": "手工台账",
"pageId": "manual",
"color": "#7AB929",
"locator": {
"selectors": [
"[data-annotation-id='h5-manual']",
".h5-manual"
],
"fingerprint": "manual|panel",
"path": []
}
},
{
"images": [],
"controls": [],
"createdAt": 1784260628220,
"updatedAt": 1784260628220,
"hasMarkdown": true,
"annotationText": "",
"id": "h5-manual-calendar",
"index": 13,
"title": "台账日历",
"pageId": "manual",
"color": "#0E8A7B",
"locator": {
"selectors": [
"[data-annotation-id='h5-manual-calendar']",
".h5-cal-grid"
],
"fingerprint": "manual|calendar",
"path": []
}
}
]
},
"markdownMap": {
"h5-hero": "## 当前站点\n\n只读展示登录账号对应加氢站列表页不提供切站。\n\n详见 [本站列表](feature-list)。",
"h5-summary": "## 本站 KPI\n\n订单数 / 加氢量(kg) / 加氢金额,随本站列表实时汇总。\n\n详见功能说明「本站列表与 KPI」。",
"h5-card": "# 功能:本站列表与 KPI\n\n## 目标\n\n站端人员打开「加氢订单」后一眼看到本站汇总与近期加氢记录点击卡片进入详情。\n\n## 页面结构\n\n1. **当前站点**(只读):登录账号对应加氢站名称,无切站入口 \n2. **KPI 三卡**:订单数 / 加氢量(kg) / 加氢金额 \n3. **加氢记录列表**:按加氢时间倒序 \n4. **底栏「新增」**:固定不遮挡卡片滚动区 \n\n## 列表卡片字段\n\n| 展示 | 说明 |\n|------|------|\n| 车牌号 | 去掉尾缀 F 展示 |\n| 核对标签 | 未核对(橙)/ 已核对(绿) |\n| 加氢时间 | 精确到分 |\n| 加氢量 / 加氢总额 | 等宽数字 |\n| 单价 | 页脚左侧 |\n| 核对时间 | 仅已核对时页脚右侧显示 |\n\n**不展示**:里程、对账状态(对账在站点对账单侧完成)。\n\n## 交互\n\n- 点击卡片 → 详情页 \n- 点击底栏「新增」→ 新增表单(时间默认=点击时刻) \n\n## 数据范围\n\n仅 `stationName === 当前登录站` 的 Bridge 台账行。原型默认站:中国石油中油高新能源牙谷加油加氢站。\n\n## 验收\n\n1. 无切站控件 \n2. KPI 与列表条数一致 \n3. 滚动列表时底栏「新增」仍固定且不压住末条卡片 \n",
"h5-fab": "## 底栏新增\n\n固定在列表底部不遮挡卡片滚动区。点击进入新增表单加氢时间默认=点击时刻。\n\n详见功能说明「新增加氢记录」。",
"h5-time": "## 加氢时间(必填)\n\n默认=点击「新增」时的当前时间;点击后底部弹层选择日期 + 时 + 分。",
"h5-station": "## 加氢站名称\n\n只读。默认显示当前登录账号对应加氢站名称不可切换。",
"h5-plate": "# 车牌「羚牛车辆 / 非羚牛车辆」标签\n\n原型本地判定未接真实车辆管理 API。\n\n## 判定顺序\n\n| 优先级 | 条件 | 结果 |\n|--------|------|------|\n| 1 | 车牌为空 | 不展示标签 |\n| 2 | 规范化后车牌 ∈ 本地系统车辆集合 | **羚牛车辆** |\n| 3 | 有车牌但不在集合中 | **非羚牛车辆** |\n\n## 前置条件\n\n- 识别OCR或键盘输入得到车牌后才展示。\n- 比较前去掉尾缀 `F`,统一大写。\n\n## 数据源\n\n- `utils/fleet-plates.ts` 内本地种子车牌集合(含加氢 Bridge 演示车牌与部分车辆管理样例)。\n- 正式环境应改为查询车辆管理 / 系统车辆列表接口。\n\n## 用户可见结果\n\n- 绿色标签「羚牛车辆」或橙色标签「非羚牛车辆」。\n- 不拦截提交(仅提示归属)。\n\n## 代码路径\n\n- `getVehicleTag` / `isLingniuVehicle` → `CreateWizard` 车牌行右侧标签。\n",
"h5-price": "## 氢气单价(必填)\n\n可 OCR 识别后校正;变更后自动重算加氢总额(单价×加氢量)。",
"h5-kg": "## 加氢量(必填)\n\n可 OCR 识别后校正;变更后自动重算加氢总额。",
"h5-total-label": "## 加氢总额(元)\n\n只读展示由单价×加氢量自动计算两位小数。界面标签不展示公式文案。",
"h5-detail": "# 功能:详情、编辑与删除\n\n## 目标\n\n查看单笔加氢记录完整字段在「未核对且未对账」时可编辑或删除。\n\n## 详情展示\n\n- 顶部:车牌、加氢时间、核对标签、对账标签 \n- 指标条:加氢总额、加氢量 \n- 明细行:站点、车牌、里程、单价、核对/对账状态与时间 \n\n## 锁定规则\n\n| 条件 | 结果 |\n|------|------|\n| 已对账 | 不可编辑/删除;提示已纳入站点对账单 |\n| 已核对(未对账) | 不可编辑/删除;提示能源部已核对 |\n| 未核对且未对账 | 可编辑、可删除 |\n\n判定函数`isOrderLocked`(核对或对账任一成立即锁)。\n\n## 编辑\n\n进入与「新增」同一套表单预填当前值提交走更新逻辑仍校验重复键。\n\n## 删除\n\n二次确认后从 Bridge 移除该行。\n\n## 验收\n\n1. 未核对未对账:底部有删除/编辑 \n2. 已核对或已对账:无操作栏,有锁定说明 \n3. 详情含对账信息;列表卡片不含对账 \n",
"h5-manual": "# 功能:手工台账上传\n\n## 目标\n\n站端每日上传纸质**手工台账照片**,作为系统电子加氢记录的核对依据。多日未操作时,可通过日历查看缺口并补传过往日期。\n\n## 产品口径\n\n| 项 | 规则 |\n|---|---|\n| 入口 | H5底部 Tab「手工台账」Web顶栏「手工台账」 |\n| 上传形态 | 仅图片(拍照/相册),可多张 |\n| 维度 | **加氢站 + 自然日(本地日历日)** 一份 |\n| 日历 | 月历自本站**首笔加氢记录日**起算:绿=已传、橙=未传;首笔之前无需补传;禁选未来日 |\n| 补传 | 允许为过往未传日期补传;已传日期仅可**追加**新图 |\n| 归档 | **确认上传后的图片进入已归档状态,不可删除**;未确认的草稿图仍可删除 |\n| 拦截 | **未上传今日手工台账 → 禁止新增加氢记录**(编辑已有记录不拦;补传历史不替代今日校验) |\n| 数据 | 共享 `H2VehicleLedgerBridge` 内存 Store未接真实 API |\n\n## 判定顺序(新增拦截)\n\n| 优先级 | 条件 | 结果 |\n|--------|------|------|\n| 1 | 操作=编辑已有记录 | 不校验手工台账 |\n| 2 | 操作=新增,且目标站今日已有 ≥1 张台账图 | 允许进入新增 |\n| 3 | 操作=新增,今日未上传 | 提示并跳转手工台账 Tab |\n\n## 日历与补传\n\n| 规则 | 说明 |\n|------|------|\n| 数据源 | `listManualLedgersByStation`;当日有 ≥1 张图视为已上传 |\n| 业务起点 | 取本站 Bridge 加氢记录中最早 `hydrogenTime` 自然日;无记录则历史日均不要求补传 |\n| 未传计数 | 本月内 `max(月初, 首笔日)`min(月末, 今日) 中未上传的天数 |\n| 点选 | 仅 `dateKey ≤ 今日`;选中后右侧/下方展示该日草稿图与上传按钮 |\n| 归档不可删 | Store 已有图强制保留;`upsertManualLedger` 仅追加新图,忽略删除已有图的请求 |\n| 与拦截关系 | 仅 **今日** 影响「禁止新增」;历史补传解决核对缺口,不放宽今日门禁 |\n\n## 用户可见\n\n- 今日未上传:橙/黄提示条 + Tab 红点 \n- 日历:自首笔加氢日起绿/橙标记;首笔之前无橙点(无需补传);本月未传天数摘要 \n- 选中日:上传区标题为该日期;已归档图显示「已归档」无删除;仅未确认草稿可删;已有台账时主按钮为「确认补传」\n\n## 代码路径\n\n- Bridge`src/common/h2VehicleLedgerBridge.js``hasManualLedgerForDate` / `upsertManualLedger` / `listManualLedgersByStation` \n- H5`oneos-h5-h2-order` · `ManualLedgerPanel` + `utils/manual-ledger.ts``buildManualMonthCells` / `getFirstHydrogenDateKey` \n- Web`oneos-web-h2-station/pages/02-加氢记录.jsx`(手工台账 Tab 月历) \n\n## 验收\n\n1. 今日未上传时H5/Web 点「新增」均被拦截并引导上传 \n2. 上传至少一张图并确认后,可新增电子记录 \n3. 日历自首笔加氢日起区分已传/未传;首笔之前不标未传、不计入补传;可点选范围内未传日补传 \n4. 补传历史日后,该日日历变为已传;今日仍未传时新增仍被拦截 \n5. 确认上传后的图显示已归档且不可删除;仅可追加补传 \n6. H5 与 Web 共用同一 Store一端上传另一端可见同会话 \n",
"h5-manual-calendar": "## 台账日历\n\n自本站首笔加氢记录日起绿=已上传,橙=未上传;首笔之前无需补传。禁选未来日。点选范围内未传日可补传。仅「今日」是否上传影响新增加氢记录。\n\n详见 [手工台账上传](feature-manual-ledger)。"
},
"assetMap": {},
"directory": {
"nodes": [
{ {
"id": "h5-prd-root",
"title": "产品需求",
"type": "folder", "type": "folder",
"id": "h5-doc-root",
"title": "加氢订单H5说明",
"defaultExpanded": true,
"children": [ "children": [
{ {
"id": "h5-prd-main",
"title": "加氢订单H5PRD",
"type": "markdown", "type": "markdown",
"markdownPath": "src/prototypes/oneos-h5-h2-order/.spec/requirements-prd.md" "id": "h5-doc-prd",
"title": "PRD 全文",
"markdown": "# 加氢订单H5— 产品需求文档PRD\n\n| 项 | 内容 |\n|---|---|\n| 文档版本 | v1.3 |\n| 模块名称 | 加氢站管理 → 加氢订单 |\n| 所属系统 | ONE-OS加氢站手机浏览器 |\n| 交互原型 | `/prototypes/oneos-h5-h2-order` |\n| 设计基底 | 小羚羚 `xll-miniapp/DESIGN.md` |\n| 关联模块 | [站点信息](/prototypes/oneos-web-h2-station-site)、[加氢记录 Web](/prototypes/oneos-web-h2-station)、[车辆氢费明细](/prototypes/vehicle-h2-fee-ledger) |\n\n---\n\n## 1. 背景与目标\n\n采购创建加氢站并绑定供应商、开放系统账号后加氢站人员通过手机浏览器登录 OneOS在「加氢订单」模块上报加氢记录。Web「加氢记录」为同一业务的 PC 入口(共享 Bridge 数据PC 另有导出与预约加氢)。\n\n每日须上传**手工台账**照片,作为电子记录的核对依据;未上传当日手工台账时不可新增加氢记录。手工台账页提供月历,标出已传/未传日期,支持补传过往缺口。\n\n### 1.1 目标用户\n\n加氢站操作人员户外 / 站场手机使用)。\n\n### 1.2 功能清单\n\n| # | 功能 | 说明文档 |\n|---|---|---|\n| 1 | 本站列表与 KPI | [feature-list.md](./feature-list.md) |\n| 2 | 新增加氢记录 | [feature-create.md](./feature-create.md) |\n| 3 | 详情 / 编辑 / 删除 | [feature-detail.md](./feature-detail.md) |\n| 4 | **手工台账上传** | [feature-manual-ledger.md](./feature-manual-ledger.md) |\n| 5 | 羚牛车辆标签 | [fleet-plate-tag.md](./fleet-plate-tag.md) |\n| 6 | 核对与对账联动 | [reconcile-linkage.md](./reconcile-linkage.md) |\n\n### 1.3 列表字段\n\n加氢时间、车牌号、氢气单价、加氢量、加氢总额、核对状态已核对时展示核对时间。里程在新增/详情展示(选填),列表不展示。列表不展示对账状态。\n\n### 1.4 不做\n\n真登录、真 OCR SDK、PLC、原生 App、后端联调。\n\n---\n\n## 2. 验收项\n\n1. 菜单位于 OneOS → 加氢站管理 → 加氢订单 \n2. 默认已登录本站;列表仅本站(无切站) \n3. 底栏 Tab加氢订单 / 手工台账;月历可区分已传/未传并补传过往日;未上传今日手工台账不可新增;上传后可新增;时间默认点击新增时刻(到分) \n4. 已核对或已对账不可编辑/删除;未核对未对账可 \n5. 能源部完成核对后同步「已核对」;站点对账单提交后同步「已对账」+ 对账时间 \n6. 同站同车牌同加氢时间重复保存被拦截 \n7. Web「加氢记录」与 H5「加氢订单」手工台账逻辑一致同 Store \n\n---\n\n## 3. 复杂逻辑摘要\n\n| 主题 | 摘要 | 全文 |\n|---|---|---|\n| 对账 / 核对 | 核对≠对账;触发方不同 | [reconcile-linkage.md](./reconcile-linkage.md)、[车辆氢费 verify-reconcile](../vehicle-h2-fee-ledger/.spec/verify-reconcile.md) |\n| 重复键 | 与 Web 共用 Bridge | [Web 数据模型](../oneos-web-h2-station/.spec/record-data-model.md) |\n| 羚牛车辆标签 | 本地系统车辆集合判定 | [fleet-plate-tag.md](./fleet-plate-tag.md) |\n| 总额 | 单价×加氢量自动算,界面不展示公式 | [feature-create.md](./feature-create.md) |\n| 手工台账 | 按站+自然日上传图片;月历标已传/未传可补传;未上传今日禁新增 | [feature-manual-ledger.md](./feature-manual-ledger.md) |\n\n---\n\n## 4. 页面与标注对照\n\n| 页面 pageId | 主要能力 |\n|---|---|\n| `list` | 站点头、KPI、订单卡片、底栏新增 |\n| `create` | 时间选择、站名、车牌键盘、面板 OCR、金额字段 |\n| `detail` | 详情字段、锁定说明、编辑/删除 |\n\n标注数据`annotation-source.json`(由 `scripts/build-annotation-source.mjs` 从本目录 Markdown 生成)。\n",
"markdownPath": ".spec/requirements-prd.md"
}, },
{ {
"id": "h5-prd-reconcile", "type": "folder",
"title": "对账联动规则", "id": "h5-doc-features",
"type": "markdown", "title": "功能说明",
"markdownPath": "src/prototypes/oneos-h5-h2-order/.spec/reconcile-linkage.md" "defaultExpanded": true,
"children": [
{
"type": "markdown",
"id": "h5-doc-list",
"title": "本站列表与 KPI",
"markdown": "# 功能:本站列表与 KPI\n\n## 目标\n\n站端人员打开「加氢订单」后一眼看到本站汇总与近期加氢记录点击卡片进入详情。\n\n## 页面结构\n\n1. **当前站点**(只读):登录账号对应加氢站名称,无切站入口 \n2. **KPI 三卡**:订单数 / 加氢量(kg) / 加氢金额 \n3. **加氢记录列表**:按加氢时间倒序 \n4. **底栏「新增」**:固定不遮挡卡片滚动区 \n\n## 列表卡片字段\n\n| 展示 | 说明 |\n|------|------|\n| 车牌号 | 去掉尾缀 F 展示 |\n| 核对标签 | 未核对(橙)/ 已核对(绿) |\n| 加氢时间 | 精确到分 |\n| 加氢量 / 加氢总额 | 等宽数字 |\n| 单价 | 页脚左侧 |\n| 核对时间 | 仅已核对时页脚右侧显示 |\n\n**不展示**:里程、对账状态(对账在站点对账单侧完成)。\n\n## 交互\n\n- 点击卡片 → 详情页 \n- 点击底栏「新增」→ 新增表单(时间默认=点击时刻) \n\n## 数据范围\n\n仅 `stationName === 当前登录站` 的 Bridge 台账行。原型默认站:中国石油中油高新能源牙谷加油加氢站。\n\n## 验收\n\n1. 无切站控件 \n2. KPI 与列表条数一致 \n3. 滚动列表时底栏「新增」仍固定且不压住末条卡片 \n",
"markdownPath": ".spec/feature-list.md"
},
{
"type": "markdown",
"id": "h5-doc-create",
"title": "新增加氢记录",
"markdown": "# 功能:新增加氢记录\n\n## 目标\n\n站端通过手机浏览器上报一笔加氢记录拍照识别 + 可校正,提交写入共享氢费 Store。\n\n## 表单分区\n\n### 1. 基础信息\n\n| 字段 | 规则 |\n|------|------|\n| 加氢时间 * | 默认=点击「新增」时的当前时间;点击底部弹层选日期+时+分 |\n| 加氢站名称 | 只读,登录本站名称 |\n\n### 2. 车牌识别\n\n| 字段 | 规则 |\n|------|------|\n| 拍摄区 | 提示「请拍摄车牌」;相册/相机OCR 模拟回填车牌 |\n| 车牌号 * | 占位「请拍摄或输入车牌号」;点击弹出车牌键盘 |\n| 车辆标签 | 有车牌后:系统车辆列表命中→「羚牛车辆」,否则→「非羚牛车辆」 |\n| 里程km | 选填 |\n\n### 3. 加氢机面板\n\n| 字段 | 规则 |\n|------|------|\n| 拍摄区 | 提示「请拍摄清晰的加氢机照片」OCR 模拟回填单价、量、总额 |\n| 氢气单价 * | 可改;变更后重算总额 |\n| 加氢量 * | 可改;变更后重算总额 |\n| 加氢总额(元) | 只读;= 单价 × 加氢量(两位小数);界面不展示公式文案 |\n\n## 提交与拦截\n\n- 必填齐全方可提交 \n- 同站 + 同车牌 + 同加氢时间 → 拦截重复 \n- 有填写内容点返回 → 二次确认防丢失 \n- 已核对或已对账记录不可从此入口编辑(列表进详情后按钮隐藏) \n\n## 验收\n\n1. 时间默认正确且可选到分 \n2. 车牌键盘可用;标签随车牌变化 \n3. 改单价或量后总额自动更新且不可手改 \n4. 空表单可直接返回;有内容返回需确认 \n",
"markdownPath": ".spec/feature-create.md"
},
{
"type": "markdown",
"id": "h5-doc-manual",
"title": "手工台账上传",
"markdown": "# 功能:手工台账上传\n\n## 目标\n\n站端每日上传纸质**手工台账照片**,作为系统电子加氢记录的核对依据。多日未操作时,可通过日历查看缺口并补传过往日期。\n\n## 产品口径\n\n| 项 | 规则 |\n|---|---|\n| 入口 | H5底部 Tab「手工台账」Web顶栏「手工台账」 |\n| 上传形态 | 仅图片(拍照/相册),可多张 |\n| 维度 | **加氢站 + 自然日(本地日历日)** 一份 |\n| 日历 | 月历自本站**首笔加氢记录日**起算:绿=已传、橙=未传;首笔之前无需补传;禁选未来日 |\n| 补传 | 允许为过往未传日期补传;已传日期仅可**追加**新图 |\n| 归档 | **确认上传后的图片进入已归档状态,不可删除**;未确认的草稿图仍可删除 |\n| 拦截 | **未上传今日手工台账 → 禁止新增加氢记录**(编辑已有记录不拦;补传历史不替代今日校验) |\n| 数据 | 共享 `H2VehicleLedgerBridge` 内存 Store未接真实 API |\n\n## 判定顺序(新增拦截)\n\n| 优先级 | 条件 | 结果 |\n|--------|------|------|\n| 1 | 操作=编辑已有记录 | 不校验手工台账 |\n| 2 | 操作=新增,且目标站今日已有 ≥1 张台账图 | 允许进入新增 |\n| 3 | 操作=新增,今日未上传 | 提示并跳转手工台账 Tab |\n\n## 日历与补传\n\n| 规则 | 说明 |\n|------|------|\n| 数据源 | `listManualLedgersByStation`;当日有 ≥1 张图视为已上传 |\n| 业务起点 | 取本站 Bridge 加氢记录中最早 `hydrogenTime` 自然日;无记录则历史日均不要求补传 |\n| 未传计数 | 本月内 `max(月初, 首笔日)`min(月末, 今日) 中未上传的天数 |\n| 点选 | 仅 `dateKey ≤ 今日`;选中后右侧/下方展示该日草稿图与上传按钮 |\n| 归档不可删 | Store 已有图强制保留;`upsertManualLedger` 仅追加新图,忽略删除已有图的请求 |\n| 与拦截关系 | 仅 **今日** 影响「禁止新增」;历史补传解决核对缺口,不放宽今日门禁 |\n\n## 用户可见\n\n- 今日未上传:橙/黄提示条 + Tab 红点 \n- 日历:自首笔加氢日起绿/橙标记;首笔之前无橙点(无需补传);本月未传天数摘要 \n- 选中日:上传区标题为该日期;已归档图显示「已归档」无删除;仅未确认草稿可删;已有台账时主按钮为「确认补传」\n\n## 代码路径\n\n- Bridge`src/common/h2VehicleLedgerBridge.js``hasManualLedgerForDate` / `upsertManualLedger` / `listManualLedgersByStation` \n- H5`oneos-h5-h2-order` · `ManualLedgerPanel` + `utils/manual-ledger.ts``buildManualMonthCells` / `getFirstHydrogenDateKey` \n- Web`oneos-web-h2-station/pages/02-加氢记录.jsx`(手工台账 Tab 月历) \n\n## 验收\n\n1. 今日未上传时H5/Web 点「新增」均被拦截并引导上传 \n2. 上传至少一张图并确认后,可新增电子记录 \n3. 日历自首笔加氢日起区分已传/未传;首笔之前不标未传、不计入补传;可点选范围内未传日补传 \n4. 补传历史日后,该日日历变为已传;今日仍未传时新增仍被拦截 \n5. 确认上传后的图显示已归档且不可删除;仅可追加补传 \n6. H5 与 Web 共用同一 Store一端上传另一端可见同会话 \n",
"markdownPath": ".spec/feature-manual-ledger.md"
},
{
"type": "markdown",
"id": "h5-doc-detail",
"title": "详情编辑删除",
"markdown": "# 功能:详情、编辑与删除\n\n## 目标\n\n查看单笔加氢记录完整字段在「未核对且未对账」时可编辑或删除。\n\n## 详情展示\n\n- 顶部:车牌、加氢时间、核对标签、对账标签 \n- 指标条:加氢总额、加氢量 \n- 明细行:站点、车牌、里程、单价、核对/对账状态与时间 \n\n## 锁定规则\n\n| 条件 | 结果 |\n|------|------|\n| 已对账 | 不可编辑/删除;提示已纳入站点对账单 |\n| 已核对(未对账) | 不可编辑/删除;提示能源部已核对 |\n| 未核对且未对账 | 可编辑、可删除 |\n\n判定函数`isOrderLocked`(核对或对账任一成立即锁)。\n\n## 编辑\n\n进入与「新增」同一套表单预填当前值提交走更新逻辑仍校验重复键。\n\n## 删除\n\n二次确认后从 Bridge 移除该行。\n\n## 验收\n\n1. 未核对未对账:底部有删除/编辑 \n2. 已核对或已对账:无操作栏,有锁定说明 \n3. 详情含对账信息;列表卡片不含对账 \n",
"markdownPath": ".spec/feature-detail.md"
},
{
"type": "markdown",
"id": "h5-doc-fleet",
"title": "羚牛车辆标签",
"markdown": "# 车牌「羚牛车辆 / 非羚牛车辆」标签\n\n原型本地判定未接真实车辆管理 API。\n\n## 判定顺序\n\n| 优先级 | 条件 | 结果 |\n|--------|------|------|\n| 1 | 车牌为空 | 不展示标签 |\n| 2 | 规范化后车牌 ∈ 本地系统车辆集合 | **羚牛车辆** |\n| 3 | 有车牌但不在集合中 | **非羚牛车辆** |\n\n## 前置条件\n\n- 识别OCR或键盘输入得到车牌后才展示。\n- 比较前去掉尾缀 `F`,统一大写。\n\n## 数据源\n\n- `utils/fleet-plates.ts` 内本地种子车牌集合(含加氢 Bridge 演示车牌与部分车辆管理样例)。\n- 正式环境应改为查询车辆管理 / 系统车辆列表接口。\n\n## 用户可见结果\n\n- 绿色标签「羚牛车辆」或橙色标签「非羚牛车辆」。\n- 不拦截提交(仅提示归属)。\n\n## 代码路径\n\n- `getVehicleTag` / `isLingniuVehicle` → `CreateWizard` 车牌行右侧标签。\n",
"markdownPath": ".spec/fleet-plate-tag.md"
},
{
"type": "markdown",
"id": "h5-doc-reconcile",
"title": "核对与对账联动",
"markdown": "# 加氢订单H5· 对账 / 核对联动规则\n\n| 项 | 说明 |\n|---|---|\n| 代码路径 | Store`src/common/h2VehicleLedgerBridge.js`;对账单:`oneos-web-h2-station-site`PC 加氢记录:`oneos-web-h2-station`;车辆氢费明细:`vehicle-h2-fee-ledger` |\n| 规格全文 | [../vehicle-h2-fee-ledger/.spec/verify-reconcile.md](../vehicle-h2-fee-ledger/.spec/verify-reconcile.md) |\n| 数据源 | 原型本地种子 + 内存共享 Store**未接真实 API** |\n\n---\n\n## 1. 两套状态(判定优先级)\n\n| 优先级 | 状态 | 字段 | 谁触发 | 用户可见 |\n|---|---|---|---|---|\n| 1 | **核对** | `verifyStatus` / `verifiedAt` | 车辆氢费明细 · **完成核对** | 未核对 / 已核对 + 核对时间 |\n| 2 | **对账** | `reconcileStatus` / `reconcileDate` | 站点信息 · **对账单提交** | 未对账 / 已对账 + 对账时间 |\n\n> `reconciledAt` 仅作完成核对时的兼容写入,**不得**再用于判定「已对账」。\n\n## 2. 数据流\n\n```text\n站端 H5 / Web 新增加氢记录\n → Bridge.upsertRowverify=unverifiedreconcile=pending成本单价/量/总额)\n → 车辆氢费明细可见\n\n能源部 · 完成核对\n → verifyStatus=verified + verifiedAt\n → 站端同步「已核对」,禁改删\n\n站点信息 · 对账单提交(仅已核对 ∧ 未对账)\n → reconcileStatus=reconciled + reconcileDate + statementRecordId\n → 站端 / 台账同步「已对账」\n```\n\n## 3. 用户可见结果H5\n\n| 结果 | 行为 |\n|---|---|\n| 未核对 | 可编辑、删除 |\n| 已核对未对账 | 只读;提示能源部已核对 |\n| 已对账 | 只读;提示已纳入对账单 |\n\n## 4. 边界\n\n- 站端上报默认未核对、未对账\n- 对账时间只认 `reconcileDate`(对账单回写)\n- 跨页联动依赖同浏览器会话共享 Store\n",
"markdownPath": ".spec/reconcile-linkage.md"
}
]
}, },
{ {
"id": "h5-prd-decision", "type": "folder",
"title": "需求与设计决策", "id": "h5-doc-pages",
"type": "markdown", "title": "按页面",
"markdownPath": "src/prototypes/oneos-h5-h2-order/.spec/2026-07-14-requirements-design.md" "defaultExpanded": true,
"children": [
{
"type": "folder",
"id": "h5-page-list",
"title": "列表页",
"defaultExpanded": true,
"children": [
{
"type": "route",
"id": "h5-route-list",
"title": "list",
"route": "list",
"payload": {
"pageId": "list"
}
},
{
"type": "markdown",
"id": "h5-page-list-md",
"title": "列表页要点",
"markdown": "# 功能:本站列表与 KPI\n\n## 目标\n\n站端人员打开「加氢订单」后一眼看到本站汇总与近期加氢记录点击卡片进入详情。\n\n## 页面结构\n\n1. **当前站点**(只读):登录账号对应加氢站名称,无切站入口 \n2. **KPI 三卡**:订单数 / 加氢量(kg) / 加氢金额 \n3. **加氢记录列表**:按加氢时间倒序 \n4. **底栏「新增」**:固定不遮挡卡片滚动区 \n\n## 列表卡片字段\n\n| 展示 | 说明 |\n|------|------|\n| 车牌号 | 去掉尾缀 F 展示 |\n| 核对标签 | 未核对(橙)/ 已核对(绿) |\n| 加氢时间 | 精确到分 |\n| 加氢量 / 加氢总额 | 等宽数字 |\n| 单价 | 页脚左侧 |\n| 核对时间 | 仅已核对时页脚右侧显示 |\n\n**不展示**:里程、对账状态(对账在站点对账单侧完成)。\n\n## 交互\n\n- 点击卡片 → 详情页 \n- 点击底栏「新增」→ 新增表单(时间默认=点击时刻) \n\n## 数据范围\n\n仅 `stationName === 当前登录站` 的 Bridge 台账行。原型默认站:中国石油中油高新能源牙谷加油加氢站。\n\n## 验收\n\n1. 无切站控件 \n2. KPI 与列表条数一致 \n3. 滚动列表时底栏「新增」仍固定且不压住末条卡片 \n"
}
]
},
{
"type": "folder",
"id": "h5-page-create",
"title": "新增页",
"defaultExpanded": true,
"children": [
{
"type": "route",
"id": "h5-route-create",
"title": "create",
"route": "create",
"payload": {
"pageId": "create"
}
},
{
"type": "markdown",
"id": "h5-page-create-md",
"title": "新增页要点",
"markdown": "# 功能:新增加氢记录\n\n## 目标\n\n站端通过手机浏览器上报一笔加氢记录拍照识别 + 可校正,提交写入共享氢费 Store。\n\n## 表单分区\n\n### 1. 基础信息\n\n| 字段 | 规则 |\n|------|------|\n| 加氢时间 * | 默认=点击「新增」时的当前时间;点击底部弹层选日期+时+分 |\n| 加氢站名称 | 只读,登录本站名称 |\n\n### 2. 车牌识别\n\n| 字段 | 规则 |\n|------|------|\n| 拍摄区 | 提示「请拍摄车牌」;相册/相机OCR 模拟回填车牌 |\n| 车牌号 * | 占位「请拍摄或输入车牌号」;点击弹出车牌键盘 |\n| 车辆标签 | 有车牌后:系统车辆列表命中→「羚牛车辆」,否则→「非羚牛车辆」 |\n| 里程km | 选填 |\n\n### 3. 加氢机面板\n\n| 字段 | 规则 |\n|------|------|\n| 拍摄区 | 提示「请拍摄清晰的加氢机照片」OCR 模拟回填单价、量、总额 |\n| 氢气单价 * | 可改;变更后重算总额 |\n| 加氢量 * | 可改;变更后重算总额 |\n| 加氢总额(元) | 只读;= 单价 × 加氢量(两位小数);界面不展示公式文案 |\n\n## 提交与拦截\n\n- 必填齐全方可提交 \n- 同站 + 同车牌 + 同加氢时间 → 拦截重复 \n- 有填写内容点返回 → 二次确认防丢失 \n- 已核对或已对账记录不可从此入口编辑(列表进详情后按钮隐藏) \n\n## 验收\n\n1. 时间默认正确且可选到分 \n2. 车牌键盘可用;标签随车牌变化 \n3. 改单价或量后总额自动更新且不可手改 \n4. 空表单可直接返回;有内容返回需确认 \n"
}
]
},
{
"type": "folder",
"id": "h5-page-detail",
"title": "详情页",
"defaultExpanded": true,
"children": [
{
"type": "route",
"id": "h5-route-detail",
"title": "detail",
"route": "detail",
"payload": {
"pageId": "detail"
}
},
{
"type": "markdown",
"id": "h5-page-detail-md",
"title": "详情页要点",
"markdown": "# 功能:详情、编辑与删除\n\n## 目标\n\n查看单笔加氢记录完整字段在「未核对且未对账」时可编辑或删除。\n\n## 详情展示\n\n- 顶部:车牌、加氢时间、核对标签、对账标签 \n- 指标条:加氢总额、加氢量 \n- 明细行:站点、车牌、里程、单价、核对/对账状态与时间 \n\n## 锁定规则\n\n| 条件 | 结果 |\n|------|------|\n| 已对账 | 不可编辑/删除;提示已纳入站点对账单 |\n| 已核对(未对账) | 不可编辑/删除;提示能源部已核对 |\n| 未核对且未对账 | 可编辑、可删除 |\n\n判定函数`isOrderLocked`(核对或对账任一成立即锁)。\n\n## 编辑\n\n进入与「新增」同一套表单预填当前值提交走更新逻辑仍校验重复键。\n\n## 删除\n\n二次确认后从 Bridge 移除该行。\n\n## 验收\n\n1. 未核对未对账:底部有删除/编辑 \n2. 已核对或已对账:无操作栏,有锁定说明 \n3. 详情含对账信息;列表卡片不含对账 \n"
}
]
}
]
} }
] ]
} }

View File

@@ -1,6 +1,8 @@
import React, { useRef, useState } from 'react'; import React, { useMemo, useRef, useState } from 'react';
import type { CreateDraft } from '../types'; import type { CreateDraft } from '../types';
import { getVehicleTag } from '../utils/fleet-plates';
import { delay, mockPanelOcr, mockPlateOcr, readFileAsDataUrl } from '../utils/ocr-mock'; import { delay, mockPanelOcr, mockPlateOcr, readFileAsDataUrl } from '../utils/ocr-mock';
import { DateTimePickerSheet } from './DateTimePickerSheet';
import { import {
IconCamera, IconCamera,
IconCheck, IconCheck,
@@ -10,6 +12,7 @@ import {
IconSpinner, IconSpinner,
} from './Icons'; } from './Icons';
import { PhoneShell } from './PhoneShell'; import { PhoneShell } from './PhoneShell';
import { PlateKeyboardSheet } from './PlateKeyboardSheet';
type Props = { type Props = {
mode: 'create' | 'edit'; mode: 'create' | 'edit';
@@ -26,6 +29,7 @@ function draftFingerprint(draft: CreateDraft): string {
return [ return [
draft.hydrogenTime.trim(), draft.hydrogenTime.trim(),
draft.plateNo.trim(), draft.plateNo.trim(),
draft.mileageKm.trim(),
draft.unitPrice.trim(), draft.unitPrice.trim(),
draft.hydrogenKg.trim(), draft.hydrogenKg.trim(),
draft.totalAmount.trim(), draft.totalAmount.trim(),
@@ -44,6 +48,7 @@ export function isDraftDirty(
} }
return Boolean( return Boolean(
draft.plateNo.trim() || draft.plateNo.trim() ||
draft.mileageKm.trim() ||
draft.unitPrice.trim() || draft.unitPrice.trim() ||
draft.hydrogenKg.trim() || draft.hydrogenKg.trim() ||
draft.totalAmount.trim() || draft.totalAmount.trim() ||
@@ -52,6 +57,23 @@ export function isDraftDirty(
); );
} }
function calcTotalAmount(unitPrice: string, hydrogenKg: string): string {
const price = Number(unitPrice);
const kg = Number(hydrogenKg);
if (!Number.isFinite(price) || !Number.isFinite(kg) || unitPrice === '' || hydrogenKg === '') {
return '';
}
return String(Math.round(price * kg * 100) / 100);
}
function RequiredMark() {
return (
<span className="h5-req" aria-hidden>
*
</span>
);
}
export function CreateWizard({ export function CreateWizard({
mode, mode,
draft, draft,
@@ -66,6 +88,19 @@ export function CreateWizard({
const panelInputRef = useRef<HTMLInputElement>(null); const panelInputRef = useRef<HTMLInputElement>(null);
const [recognizing, setRecognizing] = useState<'plate' | 'panel' | null>(null); const [recognizing, setRecognizing] = useState<'plate' | 'panel' | null>(null);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [timeOpen, setTimeOpen] = useState(false);
const [plateOpen, setPlateOpen] = useState(false);
const vehicleTag = useMemo(() => getVehicleTag(draft.plateNo), [draft.plateNo]);
function patchDraft(patch: Partial<CreateDraft>) {
const nextPrice = patch.unitPrice !== undefined ? patch.unitPrice : draft.unitPrice;
const nextKg = patch.hydrogenKg !== undefined ? patch.hydrogenKg : draft.hydrogenKg;
if (patch.unitPrice !== undefined || patch.hydrogenKg !== undefined) {
patch = { ...patch, totalAmount: calcTotalAmount(nextPrice, nextKg) };
}
onChangeDraft(patch);
}
async function handlePlateFile(file: File | null) { async function handlePlateFile(file: File | null) {
if (!file) return; if (!file) return;
@@ -153,28 +188,39 @@ export function CreateWizard({
</div> </div>
} }
> >
<section className="h5-panel"> <section className="h5-panel" data-annotation-id="h5-create-basic">
<div className="h5-panel-head"> <div className="h5-panel-head">
<div className="h5-panel-badge" aria-hidden> <div className="h5-panel-badge" aria-hidden>
<IconClock size={18} /> <IconClock size={18} />
</div> </div>
<div> <div>
<h3></h3> <h3></h3>
<p></p>
</div> </div>
</div> </div>
<div className="h5-field"> <div className="h5-field">
<label htmlFor="h5-time"></label> <label htmlFor="h5-time">
<input <RequiredMark />
</label>
<button
type="button"
id="h5-time" id="h5-time"
value={draft.hydrogenTime} className="h5-field-trigger tabular-nums"
onChange={(e) => onChangeDraft({ hydrogenTime: e.target.value })} data-annotation-id="h5-time"
placeholder="YYYY-MM-DD HH:mm" onClick={() => setTimeOpen(true)}
/> >
{draft.hydrogenTime || '请选择加氢时间'}
</button>
</div> </div>
<div className="h5-field"> <div className="h5-field">
<label htmlFor="h5-station"></label> <label htmlFor="h5-station"></label>
<input id="h5-station" value={stationName} disabled /> <input
id="h5-station"
data-annotation-id="h5-station"
value={stationName}
disabled
title="取当前登录账号对应加氢站"
/>
</div> </div>
</section> </section>
@@ -185,7 +231,7 @@ export function CreateWizard({
</div> </div>
<div> <div>
<h3></h3> <h3></h3>
<p>OCR </p> <p></p>
</div> </div>
</div> </div>
<div <div
@@ -220,12 +266,38 @@ export function CreateWizard({
/> />
</div> </div>
<div className="h5-field" style={{ marginTop: 12 }}> <div className="h5-field" style={{ marginTop: 12 }}>
<label htmlFor="h5-plate"></label> <label htmlFor="h5-plate">
<RequiredMark />
</label>
<div className="h5-plate-row">
<button
type="button"
id="h5-plate"
className="h5-field-trigger"
data-annotation-id="h5-plate"
onClick={() => setPlateOpen(true)}
>
{draft.plateNo || (
<span className="h5-field-trigger__ph"></span>
)}
</button>
{vehicleTag === 'lingniu' ? (
<span className="h5-vehicle-tag h5-vehicle-tag--in"></span>
) : null}
{vehicleTag === 'external' ? (
<span className="h5-vehicle-tag h5-vehicle-tag--out"></span>
) : null}
</div>
</div>
<div className="h5-field">
<label htmlFor="h5-mileage">km</label>
<input <input
id="h5-plate" id="h5-mileage"
value={draft.plateNo} inputMode="numeric"
onChange={(e) => onChangeDraft({ plateNo: e.target.value.toUpperCase() })} value={draft.mileageKm}
placeholder="如 浙A88H201" onChange={(e) => onChangeDraft({ mileageKm: e.target.value })}
placeholder="请输入里程"
autoComplete="off" autoComplete="off"
/> />
</div> </div>
@@ -238,7 +310,7 @@ export function CreateWizard({
</div> </div>
<div> <div>
<h3></h3> <h3></h3>
<p></p> <p></p>
</div> </div>
</div> </div>
<div <div
@@ -273,30 +345,41 @@ export function CreateWizard({
/> />
</div> </div>
<div className="h5-field" style={{ marginTop: 12 }}> <div className="h5-field" style={{ marginTop: 12 }}>
<label htmlFor="h5-price">/kg</label> <label htmlFor="h5-price">
<RequiredMark />
/kg
</label>
<input <input
id="h5-price" id="h5-price"
data-annotation-id="h5-price"
inputMode="decimal" inputMode="decimal"
value={draft.unitPrice} value={draft.unitPrice}
onChange={(e) => onChangeDraft({ unitPrice: e.target.value })} onChange={(e) => patchDraft({ unitPrice: e.target.value })}
/> />
</div> </div>
<div className="h5-field"> <div className="h5-field">
<label htmlFor="h5-kg">kg</label> <label htmlFor="h5-kg">
<RequiredMark />
kg
</label>
<input <input
id="h5-kg" id="h5-kg"
data-annotation-id="h5-kg"
inputMode="decimal" inputMode="decimal"
value={draft.hydrogenKg} value={draft.hydrogenKg}
onChange={(e) => onChangeDraft({ hydrogenKg: e.target.value })} onChange={(e) => patchDraft({ hydrogenKg: e.target.value })}
/> />
</div> </div>
<div className="h5-field"> <div className="h5-field">
<label htmlFor="h5-total"></label> <label htmlFor="h5-total" data-annotation-id="h5-total-label">
</label>
<input <input
id="h5-total" id="h5-total"
inputMode="decimal" inputMode="decimal"
value={draft.totalAmount} value={draft.totalAmount}
onChange={(e) => onChangeDraft({ totalAmount: e.target.value })} readOnly
disabled
/> />
</div> </div>
</section> </section>
@@ -306,6 +389,25 @@ export function CreateWizard({
{toast} {toast}
</div> </div>
) : null} ) : null}
<DateTimePickerSheet
open={timeOpen}
value={draft.hydrogenTime}
onClose={() => setTimeOpen(false)}
onConfirm={(next) => {
onChangeDraft({ hydrogenTime: next });
setTimeOpen(false);
}}
/>
<PlateKeyboardSheet
open={plateOpen}
value={draft.plateNo}
onClose={() => setPlateOpen(false)}
onConfirm={(next) => {
onChangeDraft({ plateNo: next });
setPlateOpen(false);
}}
/>
</PhoneShell> </PhoneShell>
); );
} }

View File

@@ -0,0 +1,103 @@
import React, { useEffect, useMemo, useState } from 'react';
import { pad2 } from '../utils/format';
type Props = {
open: boolean;
value: string;
onClose: () => void;
onConfirm: (next: string) => void;
};
function parseValue(value: string): { date: string; hour: number; minute: number } {
const raw = String(value || '').trim().replace('T', ' ');
const date = raw.slice(0, 10);
const hour = Number(raw.slice(11, 13));
const minute = Number(raw.slice(14, 16));
const now = new Date();
return {
date: /^\d{4}-\d{2}-\d{2}$/.test(date)
? date
: `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())}`,
hour: Number.isFinite(hour) ? hour : now.getHours(),
minute: Number.isFinite(minute) ? minute : now.getMinutes(),
};
}
export function DateTimePickerSheet({ open, value, onClose, onConfirm }: Props) {
const initial = useMemo(() => parseValue(value), [value, open]);
const [date, setDate] = useState(initial.date);
const [hour, setHour] = useState(initial.hour);
const [minute, setMinute] = useState(initial.minute);
useEffect(() => {
if (!open) return;
const next = parseValue(value);
setDate(next.date);
setHour(next.hour);
setMinute(next.minute);
}, [open, value]);
if (!open) return null;
const preview = `${date} ${pad2(hour)}:${pad2(minute)}`;
return (
<div className="h5-sheet-root" role="dialog" aria-modal="true" aria-label="选择加氢时间">
<button type="button" className="h5-sheet-mask" aria-label="关闭" onClick={onClose} />
<div className="h5-sheet">
<div className="h5-sheet-bar">
<button type="button" className="h5-sheet-bar-btn" onClick={onClose}>
</button>
<strong></strong>
<button
type="button"
className="h5-sheet-bar-btn h5-sheet-bar-btn--ok"
onClick={() => onConfirm(preview)}
>
</button>
</div>
<div className="h5-sheet-preview tabular-nums">{preview}</div>
<div className="h5-dt-grid">
<label className="h5-dt-field">
<span></span>
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
/>
</label>
<label className="h5-dt-field">
<span></span>
<select
value={hour}
onChange={(e) => setHour(Number(e.target.value))}
aria-label="小时"
>
{Array.from({ length: 24 }, (_, i) => (
<option key={i} value={i}>
{pad2(i)}
</option>
))}
</select>
</label>
<label className="h5-dt-field">
<span></span>
<select
value={minute}
onChange={(e) => setMinute(Number(e.target.value))}
aria-label="分钟"
>
{Array.from({ length: 60 }, (_, i) => (
<option key={i} value={i}>
{pad2(i)}
</option>
))}
</select>
</label>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,347 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import type { StationOption } from '../types';
import { readFileAsDataUrl } from '../utils/ocr-mock';
import {
buildManualMonthCells,
countMissingInMonth,
getFirstHydrogenDateKey,
getManualLedger,
isArchivedManualImage,
listManualLedgersByStation,
todayDateKey,
upsertManualLedger,
type ManualLedgerImage,
} from '../utils/manual-ledger';
import { IconCamera, IconCheck } from './Icons';
type Props = {
station: StationOption;
tick: number;
onUploaded: () => void;
showToast: (msg: string) => void;
};
const WEEK_LABELS = ['日', '一', '二', '三', '四', '五', '六'];
export function ManualLedgerPanel({ station, tick, onUploaded, showToast }: Props) {
const today = todayDateKey();
const fileRef = useRef<HTMLInputElement>(null);
const [draftImages, setDraftImages] = useState<ManualLedgerImage[]>([]);
const [saving, setSaving] = useState(false);
const [selectedDate, setSelectedDate] = useState(today);
const [cursorYm, setCursorYm] = useState(() => {
const [y, m] = today.split('-').map(Number);
return { y, m };
});
const stationKey = station.stationId || station.stationName;
const allEntries = useMemo(
() => listManualLedgersByStation(stationKey),
[stationKey, tick],
);
const uploadedSet = useMemo(() => {
const set = new Set<string>();
allEntries.forEach((item) => {
if (item.images?.length) set.add(item.ledgerDate);
});
return set;
}, [allEntries]);
const firstHydrogenDate = useMemo(
() => getFirstHydrogenDateKey(stationKey) || getFirstHydrogenDateKey(station.stationName),
[stationKey, station.stationName, tick],
);
const selectedEntry = useMemo(
() => getManualLedger(stationKey, selectedDate),
[stationKey, selectedDate, tick],
);
const cells = useMemo(
() => buildManualMonthCells(cursorYm.y, cursorYm.m, uploadedSet, today, firstHydrogenDate),
[cursorYm.y, cursorYm.m, uploadedSet, today, firstHydrogenDate],
);
const missingInMonth = useMemo(
() => countMissingInMonth(cursorYm.y, cursorYm.m, uploadedSet, today, firstHydrogenDate),
[cursorYm.y, cursorYm.m, uploadedSet, today, firstHydrogenDate],
);
useEffect(() => {
if (selectedEntry?.images?.length) {
setDraftImages(
selectedEntry.images.map((img) => ({
...img,
archived: true,
})),
);
} else {
setDraftImages([]);
}
}, [selectedEntry, selectedDate, tick]);
const selectedUploaded = Boolean(selectedEntry?.images?.length);
const isTodaySelected = selectedDate === today;
const canEditSelected = selectedDate <= today;
const pendingNewCount = draftImages.filter((img) => !isArchivedManualImage(img)).length;
const canSubmit = canEditSelected && (selectedUploaded ? pendingNewCount > 0 : draftImages.length > 0);
async function handleFiles(files: FileList | null) {
if (!files?.length || !canEditSelected) return;
const next: ManualLedgerImage[] = [...draftImages];
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (!file.type.startsWith('image/')) continue;
next.push({
id: `ml-img-${Date.now()}-${i}`,
name: file.name || `台账照片${next.length + 1}.jpg`,
dataUrl: await readFileAsDataUrl(file),
archived: false,
uploadedAt: new Date().toISOString().slice(0, 19).replace('T', ' '),
});
}
setDraftImages(next);
}
function removeImage(id: string) {
setDraftImages((prev) =>
prev.filter((img) => {
if (img.id !== id) return true;
return !isArchivedManualImage(img);
}),
);
}
function handleSave() {
if (!canEditSelected) {
showToast('不能上传未来日期');
return;
}
if (selectedUploaded && pendingNewCount === 0) {
showToast('请先添加补传照片');
return;
}
if (!draftImages.length) {
showToast('请至少上传一张手工台账照片');
return;
}
const ok = window.confirm('确认上传后该记录将归档无法修改,是否确认无误');
if (!ok) return;
setSaving(true);
try {
const saved = upsertManualLedger({
stationId: station.stationId,
stationName: station.stationName,
ledgerDate: selectedDate,
images: draftImages,
});
if (!saved) {
showToast('上传失败,请重试');
return;
}
showToast(
selectedUploaded
? `${selectedDate} 已补传`
: isTodaySelected
? '今日手工台账已上传'
: `${selectedDate} 手工台账已上传`,
);
onUploaded();
} finally {
setSaving(false);
}
}
function shiftMonth(delta: number) {
setCursorYm((prev) => {
let m = prev.m + delta;
let y = prev.y;
if (m < 1) {
m = 12;
y -= 1;
} else if (m > 12) {
m = 1;
y += 1;
}
return { y, m };
});
}
function selectDay(dateKey: string, isFuture: boolean) {
if (isFuture) return;
setSelectedDate(dateKey);
}
return (
<div className="h5-manual" data-annotation-id="h5-manual">
<div
className={`h5-manual-status${
uploadedSet.has(today) ? ' h5-manual-status--ok' : ''
}`}
>
{uploadedSet.has(today) ? <IconCheck size={16} /> : null}
<div>
<strong>{uploadedSet.has(today) ? '今日已上传' : '今日尚未上传'}</strong>
<p>
{today} · {station.stationName}
{!uploadedSet.has(today) ? '。上传今日台账后才可新增加氢记录。' : ''}
</p>
</div>
</div>
<section className="h5-panel" data-annotation-id="h5-manual-calendar">
<div className="h5-cal-head">
<button type="button" className="h5-cal-nav" onClick={() => shiftMonth(-1)} aria-label="上一月">
</button>
<strong className="tabular-nums">
{cursorYm.y}{cursorYm.m}
</strong>
<button type="button" className="h5-cal-nav" onClick={() => shiftMonth(1)} aria-label="下一月">
</button>
</div>
<p className="h5-cal-summary">
{firstHydrogenDate
? missingInMonth > 0
? `本月尚有 ${missingInMonth} 天未上传(自 ${firstHydrogenDate} 起),点选日期可补传`
: `本月自 ${firstHydrogenDate} 起截至今日均已上传`
: '本站尚无加氢记录,历史日期无需补传'}
</p>
<div className="h5-cal-week">
{WEEK_LABELS.map((w) => (
<span key={w}>{w}</span>
))}
</div>
<div className="h5-cal-grid" role="grid" aria-label="手工台账日历">
{cells.map((cell) => {
const cls = [
'h5-cal-day',
cell.inMonth ? '' : 'is-out',
cell.isFuture ? 'is-future' : '',
cell.isToday ? 'is-today' : '',
!cell.needsLedger && cell.inMonth && !cell.isFuture ? 'is-before-start' : '',
cell.needsLedger && cell.uploaded ? 'is-uploaded' : '',
cell.needsLedger && !cell.uploaded ? 'is-missing' : '',
selectedDate === cell.dateKey ? 'is-selected' : '',
]
.filter(Boolean)
.join(' ');
const statusLabel = cell.isFuture
? '未来'
: !cell.needsLedger
? '无需补传'
: cell.uploaded
? '已上传'
: '未上传';
return (
<button
key={cell.dateKey + (cell.inMonth ? '' : '-o')}
type="button"
className={cls}
disabled={cell.isFuture}
onClick={() => selectDay(cell.dateKey, cell.isFuture)}
aria-label={`${cell.dateKey}${statusLabel}`}
>
<span className="tabular-nums">{cell.day}</span>
{cell.inMonth && cell.needsLedger ? (
<i className={`h5-cal-dot${cell.uploaded ? ' is-on' : ''}`} aria-hidden />
) : null}
</button>
);
})}
</div>
<div className="h5-cal-legend">
<span>
<i className="h5-cal-dot is-on" />
</span>
<span>
<i className="h5-cal-dot" />
</span>
</div>
</section>
<section className="h5-panel">
<div className="h5-panel-head">
<div className="h5-panel-badge" aria-hidden>
<IconCamera size={18} />
</div>
<div>
<h3>
{selectedDate}
{isTodaySelected ? '(今日)' : ''}
</h3>
<p>
{selectedUploaded
? '该日台账已归档,仅可补传新图,不可删除'
: '请拍摄清晰的纸质台账照片,可多张'}
</p>
</div>
</div>
<div className="h5-manual-grid">
{draftImages.map((img) => {
const archived = isArchivedManualImage(img);
return (
<div
key={img.id}
className={`h5-manual-thumb${archived ? ' is-archived' : ''}`}
>
{img.dataUrl ? (
<img src={img.dataUrl} alt={img.name} />
) : (
<div className="h5-manual-thumb__ph"></div>
)}
{archived ? (
<span className="h5-manual-thumb__badge"></span>
) : (
<button
type="button"
className="h5-manual-thumb__del"
onClick={() => removeImage(img.id)}
>
</button>
)}
</div>
);
})}
<button
type="button"
className="h5-manual-add"
onClick={() => fileRef.current?.click()}
disabled={!canEditSelected}
aria-label="添加台账照片"
>
<IconCamera size={22} />
<span>/</span>
</button>
</div>
<input
ref={fileRef}
type="file"
accept="image/*"
multiple
capture="environment"
className="sr-only"
onChange={(e) => {
void handleFiles(e.target.files);
e.target.value = '';
}}
/>
<button
type="button"
className="h5-btn h5-btn--primary"
style={{ width: '100%', marginTop: 14 }}
disabled={saving || !canSubmit}
onClick={handleSave}
>
{selectedUploaded ? `确认补传 ${selectedDate}` : `确认上传 ${selectedDate}`}
</button>
</section>
</div>
);
}

View File

@@ -10,38 +10,45 @@ type Props = {
}; };
export function OrderCard({ order, onClick, index = 0 }: Props) { export function OrderCard({ order, onClick, index = 0 }: Props) {
const done = order.reconcileStatus === 'reconciled'; const verified = order.verifyStatus === 'verified';
return ( return (
<button <button
type="button" type="button"
className={`h5-card${done ? ' h5-card--done' : ''}`} className={`h5-card${verified ? ' h5-card--done' : ' h5-card--pending'}`}
data-annotation-id="h5-card"
onClick={onClick} onClick={onClick}
style={{ animationDelay: `${Math.min(index, 8) * 40}ms` }} style={{ animationDelay: `${Math.min(index, 8) * 40}ms` }}
aria-label={`${formatPlateDisplay(order.plateNo)}${verified ? '已核对' : '未核对'}`}
> >
<div className="h5-card-top"> <div className="h5-card-top">
<div className="h5-plate">{formatPlateDisplay(order.plateNo)}</div> <div className="h5-plate">{formatPlateDisplay(order.plateNo)}</div>
<span className={`h5-tag ${done ? 'h5-tag--done' : 'h5-tag--pending'}`}> <span className={`h5-tag ${verified ? 'h5-tag--done' : 'h5-tag--pending'}`}>
{done ? <IconCheck size={12} /> : <IconClock size={12} />} {verified ? <IconCheck size={12} /> : <IconClock size={12} />}
{done ? '已对' : '未对'} {verified ? '已对' : '未对'}
</span> </span>
</div> </div>
<div className="h5-card-meta"> <div className="h5-card-meta">
<IconClock size={13} /> <IconClock size={13} />
{formatDateTimeMinute(order.hydrogenTime)} <span className="tabular-nums">{formatDateTimeMinute(order.hydrogenTime)}</span>
</div> </div>
<div className="h5-card-metrics"> <div className="h5-card-metrics" aria-label="加氢数据">
<div> <div>
<strong>{formatKg(order.hydrogenKg)}</strong> <span></span>
<span> kg</span> <strong>
{formatKg(order.hydrogenKg)}
<em> kg</em>
</strong>
</div> </div>
<div> <div>
<strong>¥{formatMoney(order.totalAmount)}</strong>
<span></span> <span></span>
<strong className="h5-card-metrics__money">¥{formatMoney(order.totalAmount)}</strong>
</div> </div>
</div> </div>
<div className="h5-card-foot"> <div className="h5-card-foot">
<span> {formatMoney(order.unitPrice)} /kg</span> <span> {formatMoney(order.unitPrice)} /kg</span>
<span>{done ? `对账 ${formatDateTimeMinute(order.reconcileTime)}` : order.stationName}</span> {verified ? (
<span className="h5-card-foot__verify"> {formatDateTimeMinute(order.verifiedAt)}</span>
) : null}
</div> </div>
</button> </button>
); );

View File

@@ -1,8 +1,9 @@
import React from 'react'; import React from 'react';
import type { H5OrderRecord } from '../types'; import type { H5OrderRecord } from '../types';
import { formatDateTimeMinute, formatKg, formatMoney, formatPlateDisplay } from '../utils/format'; import { formatDateTimeMinute, formatKg, formatMileage, formatMoney, formatPlateDisplay } from '../utils/format';
import { IconCheck, IconChevronLeft, IconClock } from './Icons'; import { IconCheck, IconChevronLeft, IconClock } from './Icons';
import { PhoneShell } from './PhoneShell'; import { PhoneShell } from './PhoneShell';
import { isOrderLocked } from '../utils/orders';
type Props = { type Props = {
order: H5OrderRecord; order: H5OrderRecord;
@@ -12,7 +13,9 @@ type Props = {
}; };
export function OrderDetail({ order, onBack, onEdit, onDelete }: Props) { export function OrderDetail({ order, onBack, onEdit, onDelete }: Props) {
const done = order.reconcileStatus === 'reconciled'; const reconciled = order.reconcileStatus === 'reconciled';
const verified = order.verifyStatus === 'verified';
const locked = isOrderLocked(order);
return ( return (
<PhoneShell <PhoneShell
@@ -22,9 +25,9 @@ export function OrderDetail({ order, onBack, onEdit, onDelete }: Props) {
<IconChevronLeft size={20} /> <IconChevronLeft size={20} />
</button> </button>
} }
bodyClassName={done ? undefined : 'h5-body--wizard'} bodyClassName={locked ? undefined : 'h5-body--wizard'}
footer={ footer={
done ? null : ( locked ? null : (
<div className="h5-action-bar"> <div className="h5-action-bar">
<button type="button" className="h5-btn h5-btn--danger" onClick={onDelete}> <button type="button" className="h5-btn h5-btn--danger" onClick={onDelete}>
@@ -36,17 +39,37 @@ export function OrderDetail({ order, onBack, onEdit, onDelete }: Props) {
) )
} }
> >
<section className="h5-panel"> <section className="h5-panel h5-panel--detail" data-annotation-id="h5-detail">
<div className="h5-detail-hero"> <div className="h5-detail-hero">
<div className="h5-plate">{formatPlateDisplay(order.plateNo)}</div> <div>
<span className={`h5-tag ${done ? 'h5-tag--done' : 'h5-tag--pending'}`}> <div className="h5-plate">{formatPlateDisplay(order.plateNo)}</div>
{done ? <IconCheck size={12} /> : <IconClock size={12} />} <div className="h5-detail-hero__time tabular-nums">
{done ? '已对账' : '未对账'} {formatDateTimeMinute(order.hydrogenTime)}
</span> </div>
</div>
<div className="h5-tag-row">
<span className={`h5-tag ${verified ? 'h5-tag--done' : 'h5-tag--pending'}`}>
{verified ? <IconCheck size={12} /> : <IconClock size={12} />}
{verified ? '已核对' : '未核对'}
</span>
<span className={`h5-tag ${reconciled ? 'h5-tag--done' : 'h5-tag--muted'}`}>
{reconciled ? <IconCheck size={12} /> : <IconClock size={12} />}
{reconciled ? '已对账' : '未对账'}
</span>
</div>
</div> </div>
<div className="h5-detail-row"> <div className="h5-detail-metrics" aria-label="加氢金额与用量">
<span></span> <div>
<strong>{formatDateTimeMinute(order.hydrogenTime)}</strong> <span></span>
<strong>¥{formatMoney(order.totalAmount)}</strong>
</div>
<div>
<span></span>
<strong>
{formatKg(order.hydrogenKg)}
<em> kg</em>
</strong>
</div>
</div> </div>
<div className="h5-detail-row"> <div className="h5-detail-row">
<span></span> <span></span>
@@ -56,32 +79,41 @@ export function OrderDetail({ order, onBack, onEdit, onDelete }: Props) {
<span></span> <span></span>
<strong>{formatPlateDisplay(order.plateNo)}</strong> <strong>{formatPlateDisplay(order.plateNo)}</strong>
</div> </div>
<div className="h5-detail-row">
<span></span>
<strong>{formatMileage(order.mileageKm)} km</strong>
</div>
<div className="h5-detail-row"> <div className="h5-detail-row">
<span></span> <span></span>
<strong>{formatMoney(order.unitPrice)} /kg</strong> <strong>{formatMoney(order.unitPrice)} /kg</strong>
</div> </div>
<div className="h5-detail-row"> <div className="h5-detail-row">
<span></span> <span></span>
<strong>{formatKg(order.hydrogenKg)} kg</strong> <strong>{verified ? '已核对' : '未核对'}</strong>
</div> </div>
<div className="h5-detail-row"> <div className="h5-detail-row">
<span></span> <span></span>
<strong>¥{formatMoney(order.totalAmount)}</strong> <strong>{verified ? formatDateTimeMinute(order.verifiedAt) : '—'}</strong>
</div> </div>
<div className="h5-detail-row"> <div className="h5-detail-row">
<span></span> <span></span>
<strong>{done ? '已对账' : '未对账'}</strong> <strong>{reconciled ? '已对账' : '未对账'}</strong>
</div> </div>
<div className="h5-detail-row"> <div className="h5-detail-row">
<span></span> <span></span>
<strong>{done ? formatDateTimeMinute(order.reconcileTime) : '—'}</strong> <strong>{reconciled ? formatDateTimeMinute(order.reconcileTime) : '—'}</strong>
</div> </div>
</section> </section>
{done ? ( {reconciled ? (
<p className="h5-lock-note"> <p className="h5-lock-note">
<IconCheck size={16} /> <IconCheck size={16} />
</p> </p>
) : verified ? (
<p className="h5-lock-note">
<IconCheck size={16} />
</p>
) : null} ) : null}
</PhoneShell> </PhoneShell>
); );

View File

@@ -0,0 +1,107 @@
import React, { useEffect, useState } from 'react';
type Props = {
open: boolean;
value: string;
onClose: () => void;
onConfirm: (next: string) => void;
};
const PROVINCES = [
'京', '津', '沪', '渝', '冀', '豫', '云', '辽', '黑', '湘',
'皖', '鲁', '新', '苏', '浙', '赣', '鄂', '桂', '甘', '晋',
'蒙', '陕', '吉', '闽', '贵', '粤', '青', '藏', '川', '宁', '琼',
];
const LETTERS = 'ABCDEFGHJKLMNPQRSTUVWXYZ'.split('');
const DIGITS = '0123456789'.split('');
const SPECIAL = ['学', '挂', '警', '港', '澳'];
const MAX_LEN = 8;
export function PlateKeyboardSheet({ open, value, onClose, onConfirm }: Props) {
const [draft, setDraft] = useState(value);
useEffect(() => {
if (open) setDraft(String(value || '').toUpperCase().replace(/F$/u, ''));
}, [open, value]);
if (!open) return null;
const needProvince = draft.length === 0;
function append(ch: string) {
setDraft((prev) => {
if (prev.length >= MAX_LEN) return prev;
return `${prev}${ch}`.slice(0, MAX_LEN);
});
}
function backspace() {
setDraft((prev) => prev.slice(0, -1));
}
return (
<div className="h5-sheet-root" role="dialog" aria-modal="true" aria-label="输入车牌号">
<button type="button" className="h5-sheet-mask" aria-label="关闭" onClick={onClose} />
<div className="h5-sheet h5-sheet--plate">
<div className="h5-sheet-bar">
<button type="button" className="h5-sheet-bar-btn" onClick={onClose}>
</button>
<strong></strong>
<button
type="button"
className="h5-sheet-bar-btn h5-sheet-bar-btn--ok"
disabled={draft.length < 7}
onClick={() => onConfirm(draft)}
>
</button>
</div>
<div className="h5-plate-draft" aria-live="polite">
{draft || <span className="h5-plate-draft__ph"></span>}
</div>
<div className="h5-plate-keys">
{needProvince
? PROVINCES.map((p) => (
<button key={p} type="button" className="h5-plate-key" onClick={() => append(p)}>
{p}
</button>
))
: (
<>
{LETTERS.map((ch) => (
<button key={ch} type="button" className="h5-plate-key" onClick={() => append(ch)}>
{ch}
</button>
))}
{DIGITS.map((ch) => (
<button key={ch} type="button" className="h5-plate-key" onClick={() => append(ch)}>
{ch}
</button>
))}
{SPECIAL.map((ch) => (
<button key={ch} type="button" className="h5-plate-key h5-plate-key--soft" onClick={() => append(ch)}>
{ch}
</button>
))}
</>
)}
<button type="button" className="h5-plate-key h5-plate-key--wide" onClick={backspace}>
</button>
{!needProvince ? (
<button
type="button"
className="h5-plate-key h5-plate-key--wide"
onClick={() => setDraft('')}
>
</button>
) : null}
</div>
</div>
</div>
);
}

View File

@@ -15,6 +15,7 @@ import { clearHostPrototypeRouteInfo } from '../../common/useHashPage';
import annotationSourceDocument from './annotation-source.json'; import annotationSourceDocument from './annotation-source.json';
import { CreateWizard } from './components/CreateWizard'; import { CreateWizard } from './components/CreateWizard';
import { import {
IconCamera,
IconChevronLeft, IconChevronLeft,
IconDroplet, IconDroplet,
IconInbox, IconInbox,
@@ -23,12 +24,17 @@ import {
IconPlus, IconPlus,
IconWallet, IconWallet,
} from './components/Icons'; } from './components/Icons';
import { ManualLedgerPanel } from './components/ManualLedgerPanel';
import { OrderCard } from './components/OrderCard'; import { OrderCard } from './components/OrderCard';
import { OrderDetail } from './components/OrderDetail'; import { OrderDetail } from './components/OrderDetail';
import { PhoneShell } from './components/PhoneShell'; import { PhoneShell } from './components/PhoneShell';
import type { AppView, CreateDraft, StationOption } from './types'; import type { AppView, CreateDraft, StationOption } from './types';
import { formatDateTimeMinute, formatKg, formatMoney, nowDateTimeMinute } from './utils/format'; import { formatDateTimeMinute, formatKg, formatMoney, nowDateTimeMinute } from './utils/format';
import { hasManualLedgerForDate } from './utils/manual-ledger';
import { import {
findDuplicateStationOrder,
getTotalAmountMismatch,
isOrderLocked,
listOrdersForStation, listOrdersForStation,
listStations, listStations,
removeStationOrder, removeStationOrder,
@@ -41,6 +47,7 @@ function emptyDraft(partial?: Partial<CreateDraft>): CreateDraft {
return { return {
hydrogenTime: nowDateTimeMinute(), hydrogenTime: nowDateTimeMinute(),
plateNo: '', plateNo: '',
mileageKm: '',
unitPrice: '', unitPrice: '',
hydrogenKg: '', hydrogenKg: '',
totalAmount: '', totalAmount: '',
@@ -56,11 +63,14 @@ export default function OneosH5H2Order() {
}, []); }, []);
const stations = useMemo(() => listStations(), []); const stations = useMemo(() => listStations(), []);
const [station, setStation] = useState<StationOption>( /** 已登录本站(原型固定演示站,不提供切换) */
() => stations[0] || { stationId: 'JX-H2-001', stationName: '嘉兴加氢站(一期)' }, const station: StationOption = useMemo(
() => stations[0] || { stationId: 'JX-H2-001', stationName: '中国石油中油高新能源牙谷加油加氢站' },
[stations],
); );
const [tick, setTick] = useState(0); const [tick, setTick] = useState(0);
const [view, setView] = useState<AppView>({ name: 'list' }); const [view, setView] = useState<AppView>({ name: 'list' });
const [mainTab, setMainTab] = useState<'orders' | 'manual'>('orders');
const [toast, setToast] = useState(''); const [toast, setToast] = useState('');
useEffect(() => { useEffect(() => {
@@ -79,6 +89,12 @@ export default function OneosH5H2Order() {
} }
function openCreate() { function openCreate() {
if (!hasManualLedgerForDate(station.stationId || station.stationName)) {
showToast('请先上传今日手工台账');
setMainTab('manual');
setView({ name: 'list' });
return;
}
const draft = emptyDraft(); const draft = emptyDraft();
setView({ setView({
name: 'create', name: 'create',
@@ -90,7 +106,7 @@ export default function OneosH5H2Order() {
function openEdit(id: string) { function openEdit(id: string) {
const order = orders.find((item) => item.id === id); const order = orders.find((item) => item.id === id);
if (!order || order.reconcileStatus === 'reconciled') return; if (!order || isOrderLocked(order)) return;
const draft = emptyDraft({ const draft = emptyDraft({
id: order.id, id: order.id,
hydrogenTime: hydrogenTime:
@@ -98,6 +114,7 @@ export default function OneosH5H2Order() {
? nowDateTimeMinute() ? nowDateTimeMinute()
: order.hydrogenTime.slice(0, 16), : order.hydrogenTime.slice(0, 16),
plateNo: order.plateNo.replace(/F$/u, ''), plateNo: order.plateNo.replace(/F$/u, ''),
mileageKm: order.mileageKm != null ? String(order.mileageKm) : '',
unitPrice: String(order.unitPrice), unitPrice: String(order.unitPrice),
hydrogenKg: String(order.hydrogenKg), hydrogenKg: String(order.hydrogenKg),
totalAmount: String(order.totalAmount), totalAmount: String(order.totalAmount),
@@ -120,12 +137,41 @@ export default function OneosH5H2Order() {
showToast('请完善加氢信息'); showToast('请完善加氢信息');
return; return;
} }
let mileageKm: number | null = null;
const mileageRaw = draft.mileageKm.trim();
if (mileageRaw) {
const n = Number(mileageRaw);
if (!Number.isFinite(n) || n < 0) {
showToast('里程须为不小于 0 的数字');
return;
}
mileageKm = Math.round(n);
}
if (
findDuplicateStationOrder({
id: draft.id,
stationName: station.stationName,
plateNo: draft.plateNo.trim(),
hydrogenTime: draft.hydrogenTime,
})
) {
showToast('已存在相同加氢站、车牌号与加氢时间的记录');
return;
}
const mismatch = getTotalAmountMismatch(unitPrice, hydrogenKg, totalAmount);
if (mismatch) {
const ok = window.confirm(
`加氢总额与单价×量不一致:应为 ${mismatch.expected.toFixed(2)} 元,当前 ${mismatch.actual.toFixed(2)} 元。仍要保存吗?`,
);
if (!ok) return;
}
const saved = upsertStationOrder({ const saved = upsertStationOrder({
id: draft.id, id: draft.id,
stationId: station.stationId, stationId: station.stationId,
stationName: station.stationName, stationName: station.stationName,
hydrogenTime: draft.hydrogenTime, hydrogenTime: draft.hydrogenTime,
plateNo: draft.plateNo.trim(), plateNo: draft.plateNo.trim(),
mileageKm,
unitPrice, unitPrice,
hydrogenKg, hydrogenKg,
totalAmount, totalAmount,
@@ -141,7 +187,7 @@ export default function OneosH5H2Order() {
function handleDelete(id: string) { function handleDelete(id: string) {
const order = orders.find((item) => item.id === id); const order = orders.find((item) => item.id === id);
if (!order || order.reconcileStatus === 'reconciled') return; if (!order || isOrderLocked(order)) return;
if (!window.confirm(`确认删除车牌 ${order.plateNo.replace(/F$/u, '')} `)) return; if (!window.confirm(`确认删除车牌 ${order.plateNo.replace(/F$/u, '')} `)) return;
removeStationOrder(id); removeStationOrder(id);
setTick((n) => n + 1); setTick((n) => n + 1);
@@ -149,6 +195,9 @@ export default function OneosH5H2Order() {
showToast('已删除'); showToast('已删除');
} }
const annotationPageId =
view.name === 'create' ? 'create' : view.name === 'detail' ? 'detail' : 'list';
const annotationOptions = useMemo<AnnotationViewerOptions>( const annotationOptions = useMemo<AnnotationViewerOptions>(
() => ({ () => ({
showToolbar: true, showToolbar: true,
@@ -156,9 +205,9 @@ export default function OneosH5H2Order() {
showColorFilter: true, showColorFilter: true,
emptyWhenNoData: false, emptyWhenNoData: false,
toolbarEdge: 'right', toolbarEdge: 'right',
currentPageId: 'h5-h2-order', currentPageId: annotationPageId,
}), }),
[], [annotationPageId],
); );
let content: React.ReactNode = null; let content: React.ReactNode = null;
@@ -208,90 +257,129 @@ export default function OneosH5H2Order() {
<IconInbox size={36} /> <IconInbox size={36} />
</div> </div>
<strong>记录不存在</strong> <strong>记录不存在</strong>
可能已切换站点,请返回列表查看 请返回列表查看
</div> </div>
</PhoneShell> </PhoneShell>
); );
} else { } else {
const todayUploaded = hasManualLedgerForDate(station.stationId || station.stationName);
content = ( content = (
<PhoneShell title="加氢订单"> <PhoneShell
<div className="h5-hero"> title={mainTab === 'manual' ? '手工台账' : '加氢订单'}
<div className="h5-hero-label"> bodyClassName="h5-body--list"
<IconMapPin size={14} /> footer={
当前加氢站(演示可切换) <>
</div> {mainTab === 'orders' ? (
<label htmlFor="h5-station-switch" className="sr-only"> <div className="h5-fab-dock" data-annotation-id="h5-fab-dock">
当前加氢站 {!todayUploaded ? (
</label> <p className="h5-fab-dock__hint">今日未上传手工台账,无法新增电子记录</p>
<select ) : null}
id="h5-station-switch" <button
className="h5-station-select" type="button"
value={station.stationId} className="h5-fab"
onChange={(e) => { data-annotation-id="h5-fab"
const next = stations.find((s) => s.stationId === e.target.value); onClick={openCreate}
if (next) setStation(next); aria-label="新增加氢记录"
}} >
> <IconPlus size={22} />
{stations.map((s) => ( <span className="h5-fab-label">新增</span>
<option key={s.stationId} value={s.stationId}> </button>
{s.stationName} </div>
</option> ) : null}
))} <nav className="h5-tabbar" aria-label="主导航">
</select> <button
</div> type="button"
className={`h5-tabbar-item${mainTab === 'orders' ? ' is-active' : ''}`}
<div className="h5-summary" aria-label="本站汇总"> onClick={() => setMainTab('orders')}
<div className="h5-summary-chip"> >
<div className="h5-summary-chip-icon" aria-hidden> <IconList size={18} />
<IconList size={15} /> 加氢订单
</div> </button>
<strong>{summary.count}</strong> <button
<span>订单数</span> type="button"
</div> className={`h5-tabbar-item${mainTab === 'manual' ? ' is-active' : ''}`}
<div className="h5-summary-chip"> onClick={() => setMainTab('manual')}
<div className="h5-summary-chip-icon" aria-hidden> >
<IconDroplet size={15} /> <IconCamera size={18} />
</div> 手工台账
<strong>{formatKg(summary.totalKg)}</strong> {!todayUploaded ? <span className="h5-tabbar-dot" aria-label="今日未上传" /> : null}
<span>加氢量 kg</span> </button>
</div> </nav>
<div className="h5-summary-chip"> {toast ? (
<div className="h5-summary-chip-icon" aria-hidden> <div className="h5-toast h5-toast--above-dock" role="status" aria-live="polite">
<IconWallet size={15} /> {toast}
</div> </div>
<strong>{formatMoney(summary.totalAmount)}</strong> ) : null}
<span>加氢金额</span> </>
</div> }
</div> >
{mainTab === 'manual' ? (
{orders.length === 0 ? ( <ManualLedgerPanel
<div className="h5-empty"> station={station}
<div className="h5-empty-icon" aria-hidden> tick={tick}
<IconInbox size={36} /> showToast={showToast}
</div> onUploaded={() => setTick((n) => n + 1)}
<strong>本站暂无加氢记录</strong> />
点击下方「新增」开始上报第一笔订单
</div>
) : ( ) : (
orders.map((order, index) => ( <>
<OrderCard <div className="h5-hero" data-annotation-id="h5-hero" aria-label="当前站点">
key={order.id} <div className="h5-hero-label">
order={order} <IconMapPin size={13} />
index={index} 当前站点
onClick={() => setView({ name: 'detail', id: order.id })} </div>
/> <div className="h5-hero-station">{station.stationName}</div>
)) </div>
)}
<button type="button" className="h5-fab" onClick={openCreate} aria-label="新增加氢记录"> <div className="h5-summary" data-annotation-id="h5-summary" aria-label="本站汇总">
<IconPlus size={22} /> <div className="h5-summary-chip">
<span className="h5-fab-label">新增</span> <div className="h5-summary-chip-icon" aria-hidden>
</button> <IconList size={15} />
{toast ? ( </div>
<div className="h5-toast" role="status" aria-live="polite"> <strong>{summary.count}</strong>
{toast} <span>订单数</span>
</div> </div>
) : null} <div className="h5-summary-chip">
<div className="h5-summary-chip-icon" aria-hidden>
<IconDroplet size={15} />
</div>
<strong>{formatKg(summary.totalKg)}</strong>
<span>加氢量 kg</span>
</div>
<div className="h5-summary-chip">
<div className="h5-summary-chip-icon" aria-hidden>
<IconWallet size={15} />
</div>
<strong>{formatMoney(summary.totalAmount)}</strong>
<span>加氢金额</span>
</div>
</div>
{orders.length === 0 ? (
<div className="h5-empty">
<div className="h5-empty-icon" aria-hidden>
<IconInbox size={36} />
</div>
<strong>本站暂无加氢记录</strong>
点击下方「新增」开始上报第一笔订单
</div>
) : (
<section className="h5-list-section" aria-label="加氢记录列表">
<div className="h5-list-head">
<h2 className="h5-list-head__title">加氢记录</h2>
<span className="h5-list-head__count tabular-nums">共 {orders.length} 条</span>
</div>
{orders.map((order, index) => (
<OrderCard
key={order.id}
order={order}
index={index}
onClick={() => setView({ name: 'detail', id: order.id })}
/>
))}
</section>
)}
</>
)}
</PhoneShell> </PhoneShell>
); );
} }

View File

@@ -0,0 +1,374 @@
/**
* 从 .spec/*.md 生成 annotation-source.json
* 用法node src/prototypes/oneos-h5-h2-order/scripts/build-annotation-source.mjs
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '..');
const specDir = path.join(root, '.spec');
const outFile = path.join(root, 'annotation-source.json');
const now = Date.now();
function readMd(name) {
return fs.readFileSync(path.join(specDir, name), 'utf8');
}
function node(partial) {
return {
images: [],
controls: [],
createdAt: now,
updatedAt: now,
hasMarkdown: true,
annotationText: '',
...partial,
};
}
const mdPrd = readMd('requirements-prd.md');
const mdList = readMd('feature-list.md');
const mdCreate = readMd('feature-create.md');
const mdDetail = readMd('feature-detail.md');
const mdManual = readMd('feature-manual-ledger.md');
const mdFleet = readMd('fleet-plate-tag.md');
const mdReconcile = readMd('reconcile-linkage.md');
const source = {
documentVersion: 1,
format: 'axhub-annotation-source',
data: {
version: 2,
prototypeName: 'oneos-h5-h2-order',
pageId: 'list',
updatedAt: now,
nodes: [
node({
id: 'h5-hero',
index: 1,
title: '当前站点',
pageId: 'list',
color: '#7AB929',
locator: {
selectors: ["[data-annotation-id='h5-hero']", '.h5-hero'],
fingerprint: 'hero|list',
path: [],
},
}),
node({
id: 'h5-summary',
index: 2,
title: '本站 KPI',
pageId: 'list',
color: '#0E8A7B',
locator: {
selectors: ["[data-annotation-id='h5-summary']", '.h5-summary'],
fingerprint: 'summary|list',
path: [],
},
}),
node({
id: 'h5-card',
index: 3,
title: '订单卡片',
pageId: 'list',
color: '#FF7D00',
locator: {
selectors: ["[data-annotation-id='h5-card']", '.h5-card'],
fingerprint: 'card|list',
path: [],
},
}),
node({
id: 'h5-fab',
index: 4,
title: '底栏新增',
pageId: 'list',
color: '#7AB929',
locator: {
selectors: ["[data-annotation-id='h5-fab']", '.h5-fab'],
fingerprint: 'fab|list',
path: [],
},
}),
node({
id: 'h5-time',
index: 5,
title: '加氢时间',
pageId: 'create',
color: '#7AB929',
locator: {
selectors: ["[data-annotation-id='h5-time']", '#h5-time'],
fingerprint: 'field|h5-time',
path: [],
},
}),
node({
id: 'h5-station',
index: 6,
title: '加氢站名称',
pageId: 'create',
color: '#86909C',
locator: {
selectors: ["[data-annotation-id='h5-station']", '#h5-station'],
fingerprint: 'field|h5-station',
path: [],
},
}),
node({
id: 'h5-plate',
index: 7,
title: '车牌号',
pageId: 'create',
color: '#FF7D00',
locator: {
selectors: ["[data-annotation-id='h5-plate']", '#h5-plate'],
fingerprint: 'field|h5-plate',
path: [],
},
}),
node({
id: 'h5-price',
index: 8,
title: '氢气单价',
pageId: 'create',
color: '#7AB929',
locator: {
selectors: ["[data-annotation-id='h5-price']", '#h5-price'],
fingerprint: 'field|h5-price',
path: [],
},
}),
node({
id: 'h5-kg',
index: 9,
title: '加氢量',
pageId: 'create',
color: '#7AB929',
locator: {
selectors: ["[data-annotation-id='h5-kg']", '#h5-kg'],
fingerprint: 'field|h5-kg',
path: [],
},
}),
node({
id: 'h5-total-label',
index: 10,
title: '加氢总额',
pageId: 'create',
color: '#0E8A7B',
locator: {
selectors: ["[data-annotation-id='h5-total-label']", '#h5-total'],
fingerprint: 'field|h5-total',
path: [],
},
}),
node({
id: 'h5-detail',
index: 11,
title: '记录详情',
pageId: 'detail',
color: '#FF7D00',
locator: {
selectors: ["[data-annotation-id='h5-detail']", '.h5-panel--detail'],
fingerprint: 'detail|panel',
path: [],
},
}),
node({
id: 'h5-manual',
index: 12,
title: '手工台账',
pageId: 'manual',
color: '#7AB929',
locator: {
selectors: ["[data-annotation-id='h5-manual']", '.h5-manual'],
fingerprint: 'manual|panel',
path: [],
},
}),
node({
id: 'h5-manual-calendar',
index: 13,
title: '台账日历',
pageId: 'manual',
color: '#0E8A7B',
locator: {
selectors: ["[data-annotation-id='h5-manual-calendar']", '.h5-cal-grid'],
fingerprint: 'manual|calendar',
path: [],
},
}),
],
},
markdownMap: {
'h5-hero':
'## 当前站点\n\n只读展示登录账号对应加氢站列表页不提供切站。\n\n详见 [本站列表](feature-list)。',
'h5-summary':
'## 本站 KPI\n\n订单数 / 加氢量(kg) / 加氢金额,随本站列表实时汇总。\n\n详见功能说明「本站列表与 KPI」。',
'h5-card': mdList,
'h5-fab':
'## 底栏新增\n\n固定在列表底部不遮挡卡片滚动区。点击进入新增表单加氢时间默认=点击时刻。\n\n详见功能说明「新增加氢记录」。',
'h5-time':
'## 加氢时间(必填)\n\n默认=点击「新增」时的当前时间;点击后底部弹层选择日期 + 时 + 分。',
'h5-station':
'## 加氢站名称\n\n只读。默认显示当前登录账号对应加氢站名称不可切换。',
'h5-plate': mdFleet,
'h5-price':
'## 氢气单价(必填)\n\n可 OCR 识别后校正;变更后自动重算加氢总额(单价×加氢量)。',
'h5-kg':
'## 加氢量(必填)\n\n可 OCR 识别后校正;变更后自动重算加氢总额。',
'h5-total-label':
'## 加氢总额(元)\n\n只读展示由单价×加氢量自动计算两位小数。界面标签不展示公式文案。',
'h5-detail': mdDetail,
'h5-manual': mdManual,
'h5-manual-calendar':
'## 台账日历\n\n自本站首笔加氢记录日起绿=已上传,橙=未上传;首笔之前无需补传。禁选未来日。点选范围内未传日可补传。仅「今日」是否上传影响新增加氢记录。\n\n详见 [手工台账上传](feature-manual-ledger)。',
},
assetMap: {},
directory: {
nodes: [
{
type: 'folder',
id: 'h5-doc-root',
title: '加氢订单H5说明',
defaultExpanded: true,
children: [
{
type: 'markdown',
id: 'h5-doc-prd',
title: 'PRD 全文',
markdown: mdPrd,
markdownPath: '.spec/requirements-prd.md',
},
{
type: 'folder',
id: 'h5-doc-features',
title: '功能说明',
defaultExpanded: true,
children: [
{
type: 'markdown',
id: 'h5-doc-list',
title: '本站列表与 KPI',
markdown: mdList,
markdownPath: '.spec/feature-list.md',
},
{
type: 'markdown',
id: 'h5-doc-create',
title: '新增加氢记录',
markdown: mdCreate,
markdownPath: '.spec/feature-create.md',
},
{
type: 'markdown',
id: 'h5-doc-manual',
title: '手工台账上传',
markdown: mdManual,
markdownPath: '.spec/feature-manual-ledger.md',
},
{
type: 'markdown',
id: 'h5-doc-detail',
title: '详情编辑删除',
markdown: mdDetail,
markdownPath: '.spec/feature-detail.md',
},
{
type: 'markdown',
id: 'h5-doc-fleet',
title: '羚牛车辆标签',
markdown: mdFleet,
markdownPath: '.spec/fleet-plate-tag.md',
},
{
type: 'markdown',
id: 'h5-doc-reconcile',
title: '核对与对账联动',
markdown: mdReconcile,
markdownPath: '.spec/reconcile-linkage.md',
},
],
},
{
type: 'folder',
id: 'h5-doc-pages',
title: '按页面',
defaultExpanded: true,
children: [
{
type: 'folder',
id: 'h5-page-list',
title: '列表页',
defaultExpanded: true,
children: [
{
type: 'route',
id: 'h5-route-list',
title: 'list',
route: 'list',
payload: { pageId: 'list' },
},
{
type: 'markdown',
id: 'h5-page-list-md',
title: '列表页要点',
markdown: mdList,
},
],
},
{
type: 'folder',
id: 'h5-page-create',
title: '新增页',
defaultExpanded: true,
children: [
{
type: 'route',
id: 'h5-route-create',
title: 'create',
route: 'create',
payload: { pageId: 'create' },
},
{
type: 'markdown',
id: 'h5-page-create-md',
title: '新增页要点',
markdown: mdCreate,
},
],
},
{
type: 'folder',
id: 'h5-page-detail',
title: '详情页',
defaultExpanded: true,
children: [
{
type: 'route',
id: 'h5-route-detail',
title: 'detail',
route: 'detail',
payload: { pageId: 'detail' },
},
{
type: 'markdown',
id: 'h5-page-detail-md',
title: '详情页要点',
markdown: mdDetail,
},
],
},
],
},
],
},
],
},
};
fs.writeFileSync(outFile, `${JSON.stringify(source, null, 2)}\n`, 'utf8');
console.log(`Wrote ${path.relative(process.cwd(), outFile)} (${source.data.nodes.length} markers)`);

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
export type ReconcileStatus = 'pending' | 'reconciled'; export type ReconcileStatus = 'pending' | 'reconciled';
export type VerifyStatus = 'unverified' | 'verified';
export type StationOption = { export type StationOption = {
stationId: string; stationId: string;
@@ -11,9 +12,13 @@ export type H5OrderRecord = {
stationName: string; stationName: string;
hydrogenTime: string; hydrogenTime: string;
plateNo: string; plateNo: string;
/** 里程 km未填为 null */
mileageKm: number | null;
unitPrice: number; unitPrice: number;
hydrogenKg: number; hydrogenKg: number;
totalAmount: number; totalAmount: number;
verifyStatus: VerifyStatus;
verifiedAt: string;
reconcileStatus: ReconcileStatus; reconcileStatus: ReconcileStatus;
reconcileTime: string; reconcileTime: string;
}; };
@@ -22,6 +27,7 @@ export type CreateDraft = {
id?: string; id?: string;
hydrogenTime: string; hydrogenTime: string;
plateNo: string; plateNo: string;
mileageKm: string;
unitPrice: string; unitPrice: string;
hydrogenKg: string; hydrogenKg: string;
totalAmount: string; totalAmount: string;

View File

@@ -0,0 +1,47 @@
/** 原型本地「系统车辆列表」车牌(不含尾缀 F用于羚牛/非羚牛判定 */
const FLEET_PLATES = new Set(
[
'浙A55666',
'浙A77888',
'浙A99001',
'浙A12345',
'浙A67890',
'浙A88888',
'浙A03561',
'浙B23456',
'浙B99999',
'浙B58888',
'沪A88888',
'沪BDB9161',
'沪ADB9161',
'苏E33333',
'浙A88H201',
'浙F00688',
'浙F07588',
'浙F06618',
'粤AGR8556',
'粤AGP5156',
].map((p) => p.toUpperCase()),
);
export function normalizePlateKey(plateNo: string): string {
return String(plateNo || '')
.trim()
.toUpperCase()
.replace(/F$/u, '');
}
/** 车牌是否在系统车辆列表中 → 羚牛车辆 */
export function isLingniuVehicle(plateNo: string): boolean {
const key = normalizePlateKey(plateNo);
if (!key) return false;
return FLEET_PLATES.has(key);
}
export type VehicleTagKind = 'lingniu' | 'external' | null;
export function getVehicleTag(plateNo: string): VehicleTagKind {
const key = normalizePlateKey(plateNo);
if (!key) return null;
return isLingniuVehicle(key) ? 'lingniu' : 'external';
}

View File

@@ -30,3 +30,11 @@ export function formatKg(n: number): string {
if (!Number.isFinite(n)) return '—'; if (!Number.isFinite(n)) return '—';
return n.toLocaleString('zh-CN', { minimumFractionDigits: 1, maximumFractionDigits: 2 }); return n.toLocaleString('zh-CN', { minimumFractionDigits: 1, maximumFractionDigits: 2 });
} }
/** 里程展示;未填显示 — */
export function formatMileage(n: number | null | undefined): string {
if (n == null) return '—';
const v = Number(n);
if (!Number.isFinite(v)) return '—';
return Math.round(v).toLocaleString('zh-CN');
}

View File

@@ -0,0 +1,202 @@
export type ManualLedgerImage = {
id: string;
name: string;
dataUrl: string;
/** 已确认入库:界面不可删除,仅可补传新图 */
archived?: boolean;
placeholder?: boolean;
uploadedAt: string;
};
export function isArchivedManualImage(img: ManualLedgerImage): boolean {
return Boolean(img.archived || img.placeholder);
}
export type ManualLedgerEntry = {
id: string;
stationId: string;
stationName: string;
ledgerDate: string;
images: ManualLedgerImage[];
updatedAt: string;
};
export type ManualCalendarCell = {
dateKey: string;
day: number;
inMonth: boolean;
isToday: boolean;
isFuture: boolean;
/** 是否纳入手工台账补传范围(≥本站首笔加氢日 且 ≤今日) */
needsLedger: boolean;
uploaded: boolean;
};
function bridge() {
return typeof window !== 'undefined' ? window.H2VehicleLedgerBridge : undefined;
}
function pad2(n: number): string {
return String(n).padStart(2, '0');
}
function dateKeyFromHydrogenTime(hydrogenTime: unknown): string {
const m = String(hydrogenTime || '').match(/^(\d{4}-\d{2}-\d{2})/);
return m ? m[1] : '';
}
/** 本站首笔加氢记录的自然日;无记录时返回 null日历不要求历史补传 */
export function getFirstHydrogenDateKey(stationIdOrName: string): string | null {
const b = bridge();
const rows = b?.getRows?.() || [];
const key = String(stationIdOrName || '').trim();
if (!key) return null;
let earliest: string | null = null;
rows.forEach((row) => {
const sid = String(row.stationId || '').trim();
const sn = String(row.stationName || '').trim();
if (sid !== key && sn !== key) return;
const dk = dateKeyFromHydrogenTime(row.hydrogenTime);
if (!dk) return;
if (!earliest || dk < earliest) earliest = dk;
});
return earliest;
}
export function todayDateKey(): string {
const b = bridge();
if (b?.todayDateKey) return b.todayDateKey();
const d = new Date();
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
}
export function hasManualLedgerForDate(
stationIdOrName: string,
ledgerDate?: string,
): boolean {
const b = bridge();
if (!b?.hasManualLedgerForDate) return false;
return Boolean(b.hasManualLedgerForDate(stationIdOrName, ledgerDate || todayDateKey()));
}
export function getManualLedger(
stationIdOrName: string,
ledgerDate?: string,
): ManualLedgerEntry | null {
const b = bridge();
if (!b?.getManualLedger) return null;
return b.getManualLedger(stationIdOrName, ledgerDate || todayDateKey()) || null;
}
export function listManualLedgersByStation(stationIdOrName: string): ManualLedgerEntry[] {
const b = bridge();
if (!b?.listManualLedgersByStation) return [];
return b.listManualLedgersByStation(stationIdOrName) || [];
}
export function upsertManualLedger(input: {
stationId: string;
stationName: string;
ledgerDate: string;
images: Array<{ id?: string; name: string; dataUrl: string; placeholder?: boolean }>;
}): ManualLedgerEntry | null {
const b = bridge();
if (!b?.upsertManualLedger) return null;
return b.upsertManualLedger(input) || null;
}
function cellNeedsLedger(
dateKey: string,
todayKey: string,
firstHydrogenDateKey: string | null,
): boolean {
if (dateKey > todayKey) return false;
if (!firstHydrogenDateKey) return false;
return dateKey >= firstHydrogenDateKey;
}
/** 构建某月日历格(含上月末 / 本月 / 下月头,共 6×7 */
export function buildManualMonthCells(
year: number,
month1to12: number,
uploadedDateSet: Set<string>,
todayKey: string = todayDateKey(),
firstHydrogenDateKey: string | null = null,
): ManualCalendarCell[] {
const first = new Date(year, month1to12 - 1, 1);
const startWeekday = first.getDay();
const daysInMonth = new Date(year, month1to12, 0).getDate();
const prevDays = new Date(year, month1to12 - 1, 0).getDate();
const cells: ManualCalendarCell[] = [];
for (let i = 0; i < 42; i++) {
let y = year;
let m = month1to12;
let d: number;
let inMonth = true;
if (i < startWeekday) {
d = prevDays - startWeekday + i + 1;
m = month1to12 - 1;
if (m < 1) {
m = 12;
y -= 1;
}
inMonth = false;
} else if (i >= startWeekday + daysInMonth) {
d = i - startWeekday - daysInMonth + 1;
m = month1to12 + 1;
if (m > 12) {
m = 1;
y += 1;
}
inMonth = false;
} else {
d = i - startWeekday + 1;
}
const dateKey = `${y}-${pad2(m)}-${pad2(d)}`;
const needsLedger = cellNeedsLedger(dateKey, todayKey, firstHydrogenDateKey);
cells.push({
dateKey,
day: d,
inMonth,
isToday: dateKey === todayKey,
isFuture: dateKey > todayKey,
needsLedger,
uploaded: uploadedDateSet.has(dateKey),
});
}
return cells;
}
/** 本月内需补传且未上传的天数(从首笔加氢日起算,不含未来) */
export function countMissingInMonth(
year: number,
month1to12: number,
uploadedDateSet: Set<string>,
todayKey: string = todayDateKey(),
firstHydrogenDateKey: string | null = null,
): number {
const daysInMonth = new Date(year, month1to12, 0).getDate();
let n = 0;
for (let d = 1; d <= daysInMonth; d++) {
const key = `${year}-${pad2(month1to12)}-${pad2(d)}`;
if (key > todayKey) break;
if (!cellNeedsLedger(key, todayKey, firstHydrogenDateKey)) continue;
if (!uploadedDateSet.has(key)) n += 1;
}
return n;
}
declare global {
interface Window {
H2VehicleLedgerBridge?: {
todayDateKey?: () => string;
getRows?: () => Array<{ stationId?: string; stationName?: string; hydrogenTime?: string }>;
hasManualLedgerForDate?: (stationIdOrName: string, ledgerDate?: string) => boolean;
getManualLedger?: (stationIdOrName: string, ledgerDate?: string) => ManualLedgerEntry | null;
listManualLedgersByStation?: (stationIdOrName: string) => ManualLedgerEntry[];
upsertManualLedger?: (input: unknown) => ManualLedgerEntry | null;
subscribe?: (fn: () => void) => () => void;
};
}
}

View File

@@ -8,11 +8,22 @@ declare global {
upsertRow: (row: Record<string, unknown>) => Record<string, unknown> | null; upsertRow: (row: Record<string, unknown>) => Record<string, unknown> | null;
removeRow: (id: string) => boolean; removeRow: (id: string) => boolean;
subscribe: (fn: (rows: Array<Record<string, unknown>>) => void) => () => void; subscribe: (fn: (rows: Array<Record<string, unknown>>) => void) => () => void;
findDuplicateRow?: (
candidate: { stationName?: string; plateNo?: string; hydrogenTime?: string },
excludeId?: string,
) => Record<string, unknown> | null;
totalAmountMismatch?: (
unitPrice: number,
hydrogenKg: number,
totalAmount: number,
toleranceYuan?: number,
) => { expected: number; actual: number; diff: number } | null;
}; };
} }
} }
const RECONCILED = 'reconciled'; const RECONCILED = 'reconciled';
const VERIFIED = 'verified';
function asNumber(value: unknown, fallback = 0): number { function asNumber(value: unknown, fallback = 0): number {
const n = Number(value); const n = Number(value);
@@ -23,34 +34,63 @@ function asString(value: unknown): string {
return value == null ? '' : String(value); return value == null ? '' : String(value);
} }
/** 已对账必须有对账时间;缺省时按加氢日次日 10:00:00 推导(原型演示) */
function deriveReconcileTime(hydrogenTime: string): string {
const m = String(hydrogenTime || '').match(/^(\d{4})-(\d{2})-(\d{2})/);
if (!m) return '2026-01-01 10:00:00';
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]) + 1, 10, 0, 0);
const pad = (n: number) => (n < 10 ? `0${n}` : String(n));
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} 10:00:00`;
}
export function mapRowToOrder(row: Record<string, unknown>): H5OrderRecord { export function mapRowToOrder(row: Record<string, unknown>): H5OrderRecord {
const reconcileTime = asString(row.reconcileDate || row.reconciledAt); /* 对账时间仅认 reconcileDate勿用 reconciledAt(完成核对会写) */
let reconcileTime = asString(row.reconcileDate);
const reconcileStatus = const reconcileStatus =
asString(row.reconcileStatus) === RECONCILED || Boolean(reconcileTime) asString(row.reconcileStatus) === RECONCILED || Boolean(reconcileTime)
? 'reconciled' ? 'reconciled'
: 'pending'; : 'pending';
if (reconcileStatus === 'reconciled' && !reconcileTime) {
reconcileTime = deriveReconcileTime(asString(row.hydrogenTime));
}
const verifyStatus =
asString(row.verifyStatus) === VERIFIED || Boolean(row.verifiedAt) ? 'verified' : 'unverified';
const verifiedAt = verifyStatus === 'verified' ? asString(row.verifiedAt) : '';
const unitPrice = asNumber(row.costUnitPrice, asNumber(row.customerUnitPrice)); const unitPrice = asNumber(row.costUnitPrice, asNumber(row.customerUnitPrice));
const hydrogenKg = asNumber(row.hydrogenKg); const hydrogenKg = asNumber(row.hydrogenKg);
const totalAmount = asNumber(row.costTotal, asNumber(row.costAmount, asNumber(row.customerAmount))); const totalAmount = asNumber(row.costTotal, asNumber(row.costAmount, asNumber(row.customerAmount)));
const mileageRaw = row.mileageKm;
let mileageKm: number | null = null;
if (mileageRaw != null && mileageRaw !== '') {
const n = Number(mileageRaw);
if (Number.isFinite(n) && n >= 0) mileageKm = Math.round(n);
}
return { return {
id: asString(row.id || row.key), id: asString(row.id || row.key),
stationId: asString(row.stationId), stationId: asString(row.stationId),
stationName: asString(row.stationName), stationName: asString(row.stationName),
hydrogenTime: asString(row.hydrogenTime), hydrogenTime: asString(row.hydrogenTime),
plateNo: asString(row.plateNo), plateNo: asString(row.plateNo),
mileageKm,
unitPrice, unitPrice,
hydrogenKg, hydrogenKg,
totalAmount, totalAmount,
verifyStatus,
verifiedAt,
reconcileStatus, reconcileStatus,
reconcileTime: reconcileStatus === 'reconciled' ? reconcileTime : '', reconcileTime: reconcileStatus === 'reconciled' ? reconcileTime : '',
}; };
} }
export function isOrderLocked(order: H5OrderRecord): boolean {
return order.verifyStatus === 'verified' || order.reconcileStatus === 'reconciled';
}
export function listStations(): StationOption[] { export function listStations(): StationOption[] {
const bridge = typeof window !== 'undefined' ? window.H2VehicleLedgerBridge : undefined; const bridge = typeof window !== 'undefined' ? window.H2VehicleLedgerBridge : undefined;
if (!bridge) { if (!bridge) {
return [ return [
{ stationId: 'JX-H2-001', stationName: '嘉兴加氢站(一期)' }, { stationId: 'JX-H2-001', stationName: '中国石油中油高新能源牙谷加油加氢站' },
{ stationId: 'HZ-H2-002', stationName: '杭州临平加氢站' }, { stationId: 'HZ-H2-002', stationName: '杭州临平加氢站' },
{ stationId: 'SH-H2-003', stationName: '上海宝山加氢站' }, { stationId: 'SH-H2-003', stationName: '上海宝山加氢站' },
{ stationId: 'SZ-H2-004', stationName: '苏州工业园区备用站' }, { stationId: 'SZ-H2-004', stationName: '苏州工业园区备用站' },
@@ -83,6 +123,7 @@ export function upsertStationOrder(input: {
stationName: string; stationName: string;
hydrogenTime: string; hydrogenTime: string;
plateNo: string; plateNo: string;
mileageKm?: number | null;
unitPrice: number; unitPrice: number;
hydrogenKg: number; hydrogenKg: number;
totalAmount: number; totalAmount: number;
@@ -90,12 +131,18 @@ export function upsertStationOrder(input: {
const bridge = typeof window !== 'undefined' ? window.H2VehicleLedgerBridge : undefined; const bridge = typeof window !== 'undefined' ? window.H2VehicleLedgerBridge : undefined;
if (!bridge?.upsertRow) return null; if (!bridge?.upsertRow) return null;
const plate = input.plateNo.endsWith('F') ? input.plateNo : `${input.plateNo}F`; const plate = input.plateNo.endsWith('F') ? input.plateNo : `${input.plateNo}F`;
const hydrogenTime =
input.hydrogenTime.length === 16 ? `${input.hydrogenTime}:00` : input.hydrogenTime;
const mileageKm =
input.mileageKm != null && Number.isFinite(input.mileageKm) && input.mileageKm >= 0
? Math.round(input.mileageKm)
: null;
const saved = bridge.upsertRow({ const saved = bridge.upsertRow({
id: input.id, id: input.id,
key: input.id, key: input.id,
stationId: input.stationId, stationId: input.stationId,
stationName: input.stationName, stationName: input.stationName,
hydrogenTime: input.hydrogenTime.length === 16 ? `${input.hydrogenTime}:00` : input.hydrogenTime, hydrogenTime,
plateNo: plate, plateNo: plate,
customerName: `${input.stationName}·站端上报`, customerName: `${input.stationName}·站端上报`,
hydrogenKg: input.hydrogenKg, hydrogenKg: input.hydrogenKg,
@@ -104,13 +151,48 @@ export function upsertStationOrder(input: {
customerUnitPrice: input.unitPrice, customerUnitPrice: input.unitPrice,
customerAmount: input.totalAmount, customerAmount: input.totalAmount,
settlementStatus: 'customer', settlementStatus: 'customer',
mileageKm: 0, mileageKm,
creatorName: '站端账号', creatorName: '站端账号',
reconcileStatus: 'pending', reconcileStatus: 'pending',
verifyStatus: 'unverified',
}); });
return saved ? mapRowToOrder(saved) : null; return saved ? mapRowToOrder(saved) : null;
} }
export function findDuplicateStationOrder(input: {
id?: string;
stationName: string;
plateNo: string;
hydrogenTime: string;
}): boolean {
const bridge = typeof window !== 'undefined' ? window.H2VehicleLedgerBridge : undefined;
if (!bridge?.findDuplicateRow) return false;
const plate = input.plateNo.endsWith('F') ? input.plateNo : `${input.plateNo}F`;
const hydrogenTime =
input.hydrogenTime.length === 16 ? `${input.hydrogenTime}:00` : input.hydrogenTime;
return Boolean(
bridge.findDuplicateRow(
{ stationName: input.stationName, plateNo: plate, hydrogenTime },
input.id,
),
);
}
export function getTotalAmountMismatch(
unitPrice: number,
hydrogenKg: number,
totalAmount: number,
): { expected: number; actual: number; diff: number } | null {
const bridge = typeof window !== 'undefined' ? window.H2VehicleLedgerBridge : undefined;
if (bridge?.totalAmountMismatch) {
return bridge.totalAmountMismatch(unitPrice, hydrogenKg, totalAmount, 0.05);
}
const expected = Math.round(unitPrice * hydrogenKg * 100) / 100;
const actual = Math.round(totalAmount * 100) / 100;
const diff = Math.round(Math.abs(actual - expected) * 100) / 100;
return diff > 0.05 ? { expected, actual, diff } : null;
}
export function removeStationOrder(id: string): boolean { export function removeStationOrder(id: string): boolean {
const bridge = typeof window !== 'undefined' ? window.H2VehicleLedgerBridge : undefined; const bridge = typeof window !== 'undefined' ? window.H2VehicleLedgerBridge : undefined;
if (!bridge?.removeRow) return false; if (!bridge?.removeRow) return false;

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"version": 1, "version": 1,
"updatedAt": "2026-07-16T01:04:29.807Z", "updatedAt": "2026-07-17T03:52:22.620Z",
"title": "小羚羚", "title": "小羚羚",
"sectionId": "folder-prototypes-xll-miniapp", "sectionId": "folder-prototypes-xll-miniapp",
"description": "氢能车辆运营移动端原型;菜单与小羚羚「小程序」项目目录同步。", "description": "氢能车辆运营移动端原型;菜单与小羚羚「小程序」项目目录同步。",

View File

@@ -0,0 +1,171 @@
# 期末余额设置 · 需求说明
| 项 | 内容 |
|---|---|
| 模块 | 加氢站管理 → 站点信息 |
| 功能名称 | 期末余额设置 |
| 交互原型 | `/prototypes/oneos-web-h2-station-site` |
| 关联功能 | 预付余额列表展示、预付余额下钻、车辆氢费明细、生成对账单 |
| 文档状态 | 已对齐原型 |
---
## 1. 需求背景
运营与加氢站(如嘉兴站)对账后,账面「期末余额」与系统内车辆氢费明细之间常有**时间差**:对账时点之后,站点仍可能继续产生加氢支出或充值收入,导致列表「预付余额」无法直接等于对账结果。
需要提供能力:在站点侧登记一次对账时的**期末余额**与**期末时间**,并以此为锚点推算当前可用余额。
---
## 2. 需求目标
1. 支持按站点登记期末余额、期末时间。
2. 保存时按统一公式重算「当前预付余额」并写回列表。
3. 预付余额下钻流水以期末时间为锚点,重新展示余额滚动。
4. 保留完整变更记录,便于追溯。
---
## 3. 用户与入口
| 角色 | 诉求 |
|---|---|
| 财务 / 结算 | 对账后登记期末余额,得到可解释的当前预付余额 |
| 加氢站运营 | 查看当前余额及下钻流水是否与期末锚点一致 |
**入口**:站点信息列表 → 操作列 **更多(⋮)****期末余额设置**
(菜单位置:余额提醒设置 与 生成对账单 之间)
---
## 4. 功能描述
### 4.1 弹窗内容
| 区域 | 内容 | 说明 |
|---|---|---|
| 站点摘要 | 站点名称、当前预付余额 | 只读 |
| 表单 | 期末余额(元)*、期末时间 * | 必填 |
| 操作 | 取消 / 保存 | 保存触发校验与重算 |
| 变更记录 | 期末余额、生效时间、操作人、操作时间 | 只追加;支持分页 |
### 4.2 主流程
```text
打开期末余额设置
→ 填写期末余额、期末时间
→ 点击保存
→ 校验通过
→ 追加变更记录
→ 按规则重算当前预付余额并写回列表
→ 提示保存成功
```
打开该站点「预付余额」下钻时,若已存在生效期末配置,流水按锚点规则重算展示(见 §6
---
## 5. 业务规则(核心)
### 5.1 字段规则
| 规则 ID | 规则 | 说明 |
|---|---|---|
| R1 | 期末余额必填 | 须为有效数字;**允许负数**(欠费场景) |
| R2 | 期末时间必填 | 精确到分钟(`YYYY-MM-DD HH:mm` |
| R3 | 期末时间不可晚于当前时刻 | 日期不可选今天之后;保存时若时刻晚于现在,阻止保存 |
| R4 | 以最近一次保存为生效锚点 | 站点仅保留一套「当前生效」的期末余额 / 期末时间;历史见变更记录 |
### 5.2 当前预付余额计算公式
**仅在点击「保存」时计算一次**(不监听之后新产生的流水自动刷新)。
```text
当前预付余额 = 期末余额 期末后支出合计 + 期末后收入合计
```
| 规则 ID | 规则 | 说明 |
|---|---|---|
| R5 | 支出范围 | 该站点、业务时间 **严格晚于** 期末时间 `T` 的车辆氢费明细,按**成本总价**合计 |
| R6 | 收入范围 | 该站点、变更时间 **严格晚于** `T` 的充值类收入合计 |
| R7 | 取整 | 结果保留 **2 位小数** 后写回「预付余额(元)」 |
| R8 | 时间边界 | 等于期末时间 `T` 当刻的流水 **不计入** 期末后收支(只统计 `> T` |
### 5.3 变更记录规则
| 规则 ID | 规则 | 说明 |
|---|---|---|
| R9 | 只追加 | 每次保存成功新增一条记录 |
| R10 | 不可删除 | 界面不提供删除 / 清空 |
| R11 | 不可编辑 | 历史行不可改 |
| R12 | 记录字段 | 期末余额、生效时间(= 本次期末时间)、操作人、操作时间(保存时刻) |
| R13 | 分页 | 变更记录支持分页紧凑页码5 / 10 / 20 / 50 |
### 5.4 与其他功能的关系
| 规则 ID | 规则 | 说明 |
|---|---|---|
| R14 | KPI / 余额提醒 | 仍读取「预付余额」;期末保存写回后,预警 / 欠费 KPI 随之变化 |
| R15 | 生成对账单 | 对账单提交仍可扣减 / 写回预付余额;与期末重算冲突时,**以后发生的写操作为准** |
| R16 | 未配置期末 | 未保存过期末余额时,预付余额逻辑保持原状(对账扣减、手工/其他入口) |
---
## 6. 预付余额下钻规则
当站点已配置生效的期末余额、期末时间时:
| 规则 ID | 规则 | 说明 |
|---|---|---|
| R17 | 插入锚点行 | 在流水中增加类型为「期末余额」的行:时间为期末时间,余额列 = 期末余额 |
| R18 | 锚点前流水 | 时间 ≤ 期末时间的历史流水可继续展示 |
| R19 | 锚点后流水 | 时间 > 期末时间的收入 / 支出,从期末余额起按时间顺序累加,重算每行余额 |
| R20 | 下钻不写回 | 打开下钻只重算展示,**不再次写回**列表预付余额(列表值仍以最近一次保存结果为准) |
**锚点后单行余额推算:**
```text
行余额 = 上一行余额 + 本行收入 本行支出
(起点上一行余额 = 期末余额)
```
---
## 7. 校验与异常
| 场景 | 系统行为 |
|---|---|
| 期末余额为空 / 非数字 | 阻止保存,提示填写有效期末余额 |
| 期末时间为空 | 阻止保存,提示选择期末时间 |
| 期末时间晚于当前时刻 | 阻止保存,提示「期末时间不能晚于今天」 |
| 期末后无收支流水 | 允许保存;当前预付余额 = 期末余额 |
| 变更记录为空 | 展示「暂无变更记录」 |
---
## 8. 验收标准
- [ ] 更多菜单可见「期末余额设置」
- [ ] 必填与「期末时间不可晚于今天」校验生效
- [ ] 保存后列表预付余额 = 公式计算结果
- [ ] 变更记录追加成功,无删除入口,分页可用
- [ ] 余额下钻出现「期末余额」锚点,其后余额连续正确
- [ ] 未配置期末时,原有预付余额 / 下钻行为不受影响
---
## 9. 非目标(本期不做)
- 变更记录编辑、删除、回滚到某一历史锚点
- 保存后对新流水的自动实时重算
- 多套期末锚点同时生效
- 真实充值流水 API、审批流原型可用 Mock
---
## 10. 修订记录
| 版本 | 日期 | 说明 |
|---|---|---|
| v1.0 | 2026-07-17 | 首版需求:口径、写回时机、不可删日志、时间上限、下钻锚点 |

View File

@@ -4,7 +4,7 @@
- 用户资料/参考资料: - 用户资料/参考资料:
- `src/prototypes/oneos-web-h2-station-site/.spec/requirements.md` - `src/prototypes/oneos-web-h2-station-site/.spec/requirements.md`
- `src/resources/oneos-web-legacy/加氢站管理/站点管理使用说明书.md` - `src/resources/oneos-web-legacy/加氢站管理/站点管理使用说明书.md`
- 页面实现:`src/prototypes/oneos-web-h2-station/pages/03-站点信息.jsx` - 页面实现:`src/prototypes/oneos-web-h2-station-site/pages/03-站点信息.jsx`
- 氢费共享 Store`src/common/h2VehicleLedgerBridge.js` - 氢费共享 Store`src/common/h2VehicleLedgerBridge.js`
- 生成时间2026-07-01复审 - 生成时间2026-07-01复审

View File

@@ -203,4 +203,4 @@ flowchart TD
| 口径函数 | `h2IsBalanceAlertStation` / `h2IsArrearsStation` | | 口径函数 | `h2IsBalanceAlertStation` / `h2IsArrearsStation` |
| 打开弹窗 | `openAlertStationModal` | | 打开弹窗 | `openAlertStationModal` |
| 批量充值 | `handleBatchRechargeFromAlert``openRechargeModal` | | 批量充值 | `handleBatchRechargeFromAlert``openRechargeModal` |
| 页面文件 | `src/prototypes/oneos-web-h2-station/pages/03-站点信息.jsx` | | 页面文件 | `src/prototypes/oneos-web-h2-station-site/pages/03-站点信息.jsx` |

View File

@@ -100,7 +100,7 @@
| 联系方式 | 联系人 + 电话 | | 联系方式 | 联系人 + 电话 |
| 操作 | 查看 + 更多菜单 | | 操作 | 查看 + 更多菜单 |
**更多菜单**:编辑、删除、营业状态、价格配置、生成对账单、查看对账记录等(按状态与权限显隐)。 **更多菜单**:编辑、删除、营业状态、价格配置、余额提醒、期末余额设置、生成对账单、查看对账记录等(按状态与权限显隐)。
**分页**:默认 10 条,可选 5/10/20/50。 **分页**:默认 10 条,可选 5/10/20/50。

View File

@@ -118,10 +118,21 @@
3. **阶梯量重置日期**见价格配置表单内「阶梯量重置日期」;到达重置日后,累计加氢量**从零重新计数**,用于判断当前所处价格区间。 3. **阶梯量重置日期**见价格配置表单内「阶梯量重置日期」;到达重置日后,累计加氢量**从零重新计数**,用于判断当前所处价格区间。
4. **生效时间**自表单内「生效时间」起对新数据生效,**不追溯**此前已产生的数据;生效时间之前的历史记录,仍按当时已达的**最高阶梯价格**结算。 4. **生效时间**自表单内「生效时间」起对新数据生效,**不追溯**此前已产生的数据;生效时间之前的历史记录,仍按当时已达的**最高阶梯价格**结算。
### 5.3 下钻分析 ### 5.3 期末余额设置
> 完整规则见 [period-end-balance.md](./period-end-balance.md)
与加氢站对账存在时间差时,登记**期末余额**与**期末时间**作为锚点:
- **入口**:更多 → 期末余额设置
- **计算**`当前预付余额 = 期末余额 期末后支出 + 期末后收入`(仅保存时重算一次)
- **约束**:期末时间不可晚于今天;变更记录只追加、不可删除
- **下钻**:余额流水从期末锚点行起重新累加
### 5.4 下钻分析
- **加氢量下钻**:统计卡 + 明细表 + 导出,字段对齐车辆氢费明细 - **加氢量下钻**:统计卡 + 明细表 + 导出,字段对齐车辆氢费明细
- **预付余额下钻**:充值/加氢流水 + 趋势图 + 导出 - **预付余额下钻**:充值/加氢流水 + 趋势图 + 导出;若已配置期末余额,自期末时间锚点重算后续余额
--- ---
@@ -143,7 +154,7 @@
|---|---| |---|---|
| 氢费明细 | `src/common/h2VehicleLedgerBridge.js` | | 氢费明细 | `src/common/h2VehicleLedgerBridge.js` |
| 对账回写 | `H2_STATION_STATEMENT_LEDGER_UPDATES` | | 对账回写 | `H2_STATION_STATEMENT_LEDGER_UPDATES` |
| 页面实现 | `src/prototypes/oneos-web-h2-station/pages/03-站点信息.jsx` | | 页面实现 | `src/prototypes/oneos-web-h2-station-site/pages/03-站点信息.jsx` |
| 独立入口 | `src/prototypes/oneos-web-h2-station-site/index.tsx` | | 独立入口 | `src/prototypes/oneos-web-h2-station-site/index.tsx` |
--- ---
@@ -163,7 +174,13 @@
- [ ] 两阶段交互与必填校验完整 - [ ] 两阶段交互与必填校验完整
- [ ] 提交后回写车辆氢费明细可跨页验收 - [ ] 提交后回写车辆氢费明细可跨页验收
### 8.3 标注与文档 ### 8.3 期末余额设置
- [ ] 更多菜单可打开期末余额设置;期末时间不可晚于今天
- [ ] 保存后写回预付余额,变更记录只追加不可删
- [ ] 余额下钻出现期末锚点且后续余额连续推算
### 8.4 标注与文档
- [ ] `@axhub/annotation` 已接入,关键区域有 `data-annotation-id` - [ ] `@axhub/annotation` 已接入,关键区域有 `data-annotation-id`
- [ ] 右侧工具栏目录可阅 PRD 全文与页面标注 - [ ] 右侧工具栏目录可阅 PRD 全文与页面标注

File diff suppressed because one or more lines are too long

View File

@@ -17,7 +17,7 @@ import {
} from '@axhub/annotation'; } from '@axhub/annotation';
import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host'; import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host';
import { clearHostPrototypeRouteInfo } from '../../common/useHashPage'; import { clearHostPrototypeRouteInfo } from '../../common/useHashPage';
import SiteInfoPage from '../oneos-web-h2-station/pages/03-站点信息.jsx'; import SiteInfoPage from './pages/03-站点信息.jsx';
import annotationSourceDocument from './annotation-source.json'; import annotationSourceDocument from './annotation-source.json';
/** 与租赁合同管理 / vehicle-management 主题一致 */ /** 与租赁合同管理 / vehicle-management 主题一致 */

View File

@@ -1137,6 +1137,28 @@ function h2FormatReceiptAmountInput(text) {
return n.toFixed(2); return n.toFixed(2);
} }
/** 期末余额等可允许负数的金额输入 */
function h2SanitizeSignedAmountInput(text) {
var raw = String(text || '');
var negative = raw.trim().charAt(0) === '-';
var s = raw.replace(/[^\d.]/g, '');
var parts = s.split('.');
if (parts.length > 2) s = parts[0] + '.' + parts.slice(1).join('');
var dot = s.indexOf('.');
if (dot >= 0 && s.length - dot - 1 > 2) s = s.slice(0, dot + 3);
if (negative && s) s = '-' + s;
else if (negative && !s) s = '-';
return s;
}
function h2FormatSignedAmountInput(text) {
var s = String(text || '').trim();
if (!s || s === '-') return '';
var n = parseFloat(s);
if (isNaN(n)) return '';
return n.toFixed(2);
}
function h2ResolveCurrentCostPrice(record) { function h2ResolveCurrentCostPrice(record) {
var defaultCfg = h2GetDefaultAllCustomerPriceConfig(record); var defaultCfg = h2GetDefaultAllCustomerPriceConfig(record);
if (defaultCfg) { if (defaultCfg) {
@@ -2996,7 +3018,8 @@ function h2FormatYuanSymbol(v, options) {
var H2_BALANCE_BIZ_TYPE_MAP = { var H2_BALANCE_BIZ_TYPE_MAP = {
recharge: { key: 'recharge', label: '充值', color: 'success' }, recharge: { key: 'recharge', label: '充值', color: 'success' },
refuel: { key: 'refuel', label: '车辆加氢', color: 'warning' }, refuel: { key: 'refuel', label: '车辆加氢', color: 'warning' },
ending: { key: 'ending', label: '期末', color: 'default' } ending: { key: 'ending', label: '期末', color: 'default' },
period_end: { key: 'period_end', label: '期末余额', color: 'processing' }
}; };
function h2Pad2(n) { function h2Pad2(n) {
@@ -3142,6 +3165,115 @@ function h2BuildMockPrepaidBalanceRows(record) {
return rows; return rows;
} }
/** 期末余额变更日志(只追加,不可删除) */
function h2CreatePeriodEndBalanceLog(periodEndBalance, effectiveTime, operator) {
return {
id: 'peb-' + Date.now() + '-' + Math.floor(Math.random() * 10000),
periodEndBalance: periodEndBalance,
effectiveTime: effectiveTime,
operator: operator || H2_CURRENT_OPERATOR,
operateTime: h2OperateTimestamp()
};
}
/** 期末时间之后该站氢费明细成本总价合计(支出) */
function h2SumStationLedgerExpenseAfter(store, stationName, periodEndTime) {
var t0 = h2ParseDateTimeMs(periodEndTime);
if (isNaN(t0)) return 0;
var name = String(stationName || '').trim();
var sum = 0;
var i;
for (i = 0; i < (store || []).length; i++) {
var r = store[i];
if ((r.stationName || '') !== name) continue;
var t = h2ParseDateTimeMs(r.hydrogenTime);
if (isNaN(t) || t <= t0) continue;
sum += h2NumOrZero(r.costTotal);
}
return Math.round(sum * 100) / 100;
}
/** 期末时间之后余额流水中的充值收入合计 */
function h2SumBalanceIncomeAfter(rows, periodEndTime) {
var t0 = h2ParseDateTimeMs(periodEndTime);
if (isNaN(t0)) return 0;
var sum = 0;
var i;
for (i = 0; i < (rows || []).length; i++) {
var r = rows[i];
if (r.bizType === 'period_end' || r.bizType === 'ending') continue;
var t = h2ParseChangeTimeMs(r.changeTime);
if (!t || t <= t0) continue;
if (r.incomeAmount != null && r.incomeAmount !== '') sum += h2NumOrZero(r.incomeAmount);
}
return Math.round(sum * 100) / 100;
}
/**
* 保存期末余额时重算预付余额
* 当前余额 = 期末余额 期末后支出 + 期末后收入
*/
function h2RecalcPrepaidFromPeriodEnd(record, ledgerStore, periodEndBalance, periodEndTime) {
var rawRows = h2BuildMockPrepaidBalanceRows(Object.assign({}, record, {
periodEndBalance: null,
periodEndTime: null
}));
var expenseSum = h2SumStationLedgerExpenseAfter(ledgerStore, record && record.name, periodEndTime);
var incomeSum = h2SumBalanceIncomeAfter(rawRows, periodEndTime);
var prepaidBalance = Math.round((h2NumOrZero(periodEndBalance) - expenseSum + incomeSum) * 100) / 100;
return { prepaidBalance: prepaidBalance, expenseSum: expenseSum, incomeSum: incomeSum };
}
/** 余额下钻:从期末锚点起重算后续行余额 */
function h2ApplyPeriodEndAnchorToBalanceRows(record, rows) {
var periodEndTime = record && record.periodEndTime;
var periodEndBalance = record && record.periodEndBalance;
if (periodEndTime == null || periodEndTime === '' || periodEndBalance == null || periodEndBalance === '') {
return (rows || []).slice();
}
var t0 = h2ParseDateTimeMs(periodEndTime);
if (isNaN(t0)) return (rows || []).slice();
var E = h2NumOrZero(periodEndBalance);
var before = [];
var after = [];
var i;
for (i = 0; i < (rows || []).length; i++) {
var r = rows[i];
if (r.bizType === 'period_end') continue;
var t = h2ParseChangeTimeMs(r.changeTime);
if (t && t > t0) after.push(r);
else before.push(r);
}
after.sort(function (a, b) {
return h2ParseChangeTimeMs(a.changeTime) - h2ParseChangeTimeMs(b.changeTime);
});
var timeText = String(periodEndTime).trim();
if (timeText.length === 16) timeText += ':00';
var anchor = {
key: String(record.id != null ? record.id : 'st') + '-period-end',
stationName: (record && record.name) || '',
changeTime: timeText,
bizType: 'period_end',
incomeAmount: null,
expenseAmount: null,
balance: E,
orderNo: 'PE' + String(record.id != null ? record.id : '')
};
var bal = E;
var rebuiltAfter = after.map(function (row) {
var income = row.incomeAmount != null && row.incomeAmount !== '' ? h2NumOrZero(row.incomeAmount) : 0;
var expense = row.expenseAmount != null && row.expenseAmount !== '' ? h2NumOrZero(row.expenseAmount) : 0;
bal = Math.round((bal + income - expense) * 100) / 100;
return Object.assign({}, row, { balance: bal });
});
return before.concat([anchor]).concat(rebuiltAfter);
}
function h2BuildPrepaidBalanceRowsForRecord(record) {
var base = h2BuildMockPrepaidBalanceRows(record);
return h2ApplyPeriodEndAnchorToBalanceRows(record, base);
}
function h2FormatBalanceTrendLabel(changeTime) { function h2FormatBalanceTrendLabel(changeTime) {
if (!changeTime) return '—'; if (!changeTime) return '—';
var text = String(changeTime).trim(); var text = String(changeTime).trim();
@@ -3512,10 +3644,13 @@ function h2GetAvailableStatementLedgerRows(store, stationName, startDate, endDat
if (!name || isNaN(startMs) || isNaN(endMs)) return []; if (!name || isNaN(startMs) || isNaN(endMs)) return [];
var endInclusive = endMs + 86400000 - 1; var endInclusive = endMs + 86400000 - 1;
return (store || []).filter(function (r) { return (store || []).filter(function (r) {
/* 站端 H5 上报多为 pending完成对账后亦可能尚待结算 — 均纳入对账单来源 */ /* 对账单仅纳入:已核对 ∧ 未对账 ∧ 未绑定其他对账单 */
if (r.statementRecordId) return false; if (r.statementRecordId) return false;
var verify = r.verifyStatus || 'unverified';
if (verify !== 'verified') return false;
var st = r.reconcileStatus || H2_RECONCILE_PENDING; var st = r.reconcileStatus || H2_RECONCILE_PENDING;
if (st !== H2_RECONCILE_RECONCILED && st !== H2_RECONCILE_PENDING) return false; if (st === H2_RECONCILE_RECONCILED) return false;
if (st !== H2_RECONCILE_PENDING) return false;
if ((r.stationName || '') !== name) return false; if ((r.stationName || '') !== name) return false;
var t = h2ParseDateTimeMs(r.hydrogenTime); var t = h2ParseDateTimeMs(r.hydrogenTime);
if (isNaN(t)) return false; if (isNaN(t)) return false;
@@ -4213,7 +4348,15 @@ flowchart LR
4. 保存后 4. 保存后
- 若已到生效时间 列表当前成本价格**立即更新** - 若已到生效时间 列表当前成本价格**立即更新**
- 若未到生效时间 到点后自动更新 - 若未到生效时间 到点后自动更新
4. 下方展示价格调整记录操作人调整前后价格生效时间 5. 下方展示价格调整记录操作人调整前后价格生效时间
### 5.3 期末余额设置
**入口**更多 **期末余额设置**
1. 填写 **期末余额** **期末时间**不可晚于今天
2. 保存后按期末余额 期末后支出 + 期末后收入重算并写回预付余额仅保存时计算一次
3. 下方展示变更记录期末余额生效时间操作人操作时间只追加不可删除支持分页
--- ---
@@ -4237,6 +4380,7 @@ flowchart LR
- 当前余额负数显示已欠费 - 当前余额负数显示已欠费
- 充值 / 车辆加氢流水明细 - 充值 / 车辆加氢流水明细
- 若已配置期末余额流水中出现期末余额锚点行其后收入/支出从该余额连续推算
- **导出 CSV** 按钮 - **导出 CSV** 按钮
--- ---
@@ -4347,6 +4491,8 @@ sequenceDiagram
| 编辑 | 整页编辑站点主数据 | | 编辑 | 整页编辑站点主数据 |
| 营业状态 | 维护营业状态与营业时间 | | 营业状态 | 维护营业状态与营业时间 |
| 价格配置 | 设置成本价与生效时间 | | 价格配置 | 设置成本价与生效时间 |
| 余额提醒设置 | 设置预付余额预警阈值 |
| 期末余额设置 | 登记期末余额与期末时间重算预付余额 |
| 生成对账单 | 两阶段对账结算 | | 生成对账单 | 两阶段对账结算 |
| 查看对账记录 | 历史对账单与明细 | | 查看对账记录 | 历史对账单与明细 |
| 删除 | 删除站点不可恢复 | | 删除 | 删除站点不可恢复 |
@@ -4537,7 +4683,15 @@ flowchart TB
**加氢量下钻**统计卡次数加氢量成本总价加氢总价+ 明细表 + 导出 CSV字段对齐车辆氢费明细 **加氢量下钻**统计卡次数加氢量成本总价加氢总价+ 明细表 + 导出 CSV字段对齐车辆氢费明细
**预付余额下钻**充值/车辆加氢流水 + 导出 CSV余额为负显示已欠费 **预付余额下钻**充值/车辆加氢流水 + 导出 CSV余额为负显示已欠费若已配置期末余额自期末时间锚点重算后续余额
### 5.4 期末余额设置
**入口**更多 **期末余额设置**
1. 填写 **期末余额** **期末时间**不可晚于今天
2. 保存后当前预付余额 = 期末余额 期末后支出 + 期末后收入仅保存时重算一次并写回列表
3. 下方展示变更记录期末余额生效时间操作人操作时间只追加不可删除支持分页
--- ---
@@ -5772,6 +5926,17 @@ const Component = function () {
var balanceAlertModal = balanceAlertModalState[0]; var balanceAlertModal = balanceAlertModalState[0];
var setBalanceAlertModal = balanceAlertModalState[1]; var setBalanceAlertModal = balanceAlertModalState[1];
var periodEndBalanceModalState = useState({
open: false,
record: null,
periodEndBalance: '',
periodEndTime: '',
logPage: 1,
logPageSize: 5
});
var periodEndBalanceModal = periodEndBalanceModalState[0];
var setPeriodEndBalanceModal = periodEndBalanceModalState[1];
var globalBalanceAlertModalState = useState({ open: false, threshold: '' }); var globalBalanceAlertModalState = useState({ open: false, threshold: '' });
var globalBalanceAlertModal = globalBalanceAlertModalState[0]; var globalBalanceAlertModal = globalBalanceAlertModalState[0];
var setGlobalBalanceAlertModal = globalBalanceAlertModalState[1]; var setGlobalBalanceAlertModal = globalBalanceAlertModalState[1];
@@ -6277,6 +6442,9 @@ const Component = function () {
costUnitPrice: createStation.costUnitPrice ? parseFloat(createStation.costUnitPrice) : undefined, costUnitPrice: createStation.costUnitPrice ? parseFloat(createStation.costUnitPrice) : undefined,
customerUnitPrice: createStation.customerUnitPrice ? parseFloat(createStation.customerUnitPrice) : undefined, customerUnitPrice: createStation.customerUnitPrice ? parseFloat(createStation.customerUnitPrice) : undefined,
prepaidBalance: 0, prepaidBalance: 0,
periodEndBalance: null,
periodEndTime: null,
periodEndBalanceLogs: [],
businessStatusLogs: [], businessStatusLogs: [],
costPriceLogs: [], costPriceLogs: [],
updateTime: now, updateTime: now,
@@ -6545,7 +6713,7 @@ const Component = function () {
open: true, open: true,
stationName: record.name || '', stationName: record.name || '',
endingBalance: h2NumOrZero(record.prepaidBalance), endingBalance: h2NumOrZero(record.prepaidBalance),
rows: h2BuildMockPrepaidBalanceRows(record) rows: h2BuildPrepaidBalanceRowsForRecord(record)
}); });
}, []); }, []);
@@ -7179,6 +7347,92 @@ const Component = function () {
setBalanceAlertModal({ open: false, record: null, threshold: '' }); setBalanceAlertModal({ open: false, record: null, threshold: '' });
}, []); }, []);
var openPeriodEndBalanceSetting = useCallback(function (record) {
var balanceText = record.periodEndBalance != null && record.periodEndBalance !== ''
? h2FormatYuanNum(record.periodEndBalance)
: '';
setPeriodEndBalanceModal({
open: true,
record: record,
periodEndBalance: balanceText,
periodEndTime: record.periodEndTime || '',
logPage: 1,
logPageSize: 5
});
}, []);
var closePeriodEndBalanceModal = useCallback(function () {
setPeriodEndBalanceModal({
open: false,
record: null,
periodEndBalance: '',
periodEndTime: '',
logPage: 1,
logPageSize: 5
});
}, []);
var handleSavePeriodEndBalance = useCallback(function () {
if (!periodEndBalanceModal.record) return;
var balanceText = h2FormatSignedAmountInput(periodEndBalanceModal.periodEndBalance);
if (balanceText === '' || balanceText == null) {
message.warning('请填写有效的期末余额');
return;
}
var balanceNum = parseFloat(balanceText);
if (isNaN(balanceNum)) {
message.warning('请填写有效的期末余额');
return;
}
var timeText = String(periodEndBalanceModal.periodEndTime || '').trim();
if (!timeText) {
message.warning('请选择期末时间');
return;
}
var timeMs = h2ParseDateTimeMs(timeText);
if (isNaN(timeMs)) {
message.warning('期末时间格式不正确');
return;
}
if (timeMs > Date.now()) {
message.warning('期末时间不能晚于今天');
return;
}
var recordId = periodEndBalanceModal.record.id;
var stationSnapshot = periodEndBalanceModal.record;
var recalc = h2RecalcPrepaidFromPeriodEnd(stationSnapshot, ledgerStore, balanceNum, timeText);
var newLog = h2CreatePeriodEndBalanceLog(balanceNum, timeText, H2_CURRENT_OPERATOR);
setListData(function (prev) {
return prev.map(function (r) {
if (r.id !== recordId) return r;
var logs = [newLog].concat(r.periodEndBalanceLogs || []);
return Object.assign({}, r, {
periodEndBalance: balanceNum,
periodEndTime: timeText,
periodEndBalanceLogs: logs,
prepaidBalance: recalc.prepaidBalance,
updateTime: h2OperateTimestamp()
});
});
});
setPeriodEndBalanceModal(function (m) {
if (!m.record || m.record.id !== recordId) return m;
var nextRecord = Object.assign({}, m.record, {
periodEndBalance: balanceNum,
periodEndTime: timeText,
periodEndBalanceLogs: [newLog].concat(m.record.periodEndBalanceLogs || []),
prepaidBalance: recalc.prepaidBalance
});
return Object.assign({}, m, {
record: nextRecord,
periodEndBalance: h2FormatYuanNum(balanceNum),
periodEndTime: timeText,
logPage: 1
});
});
message.success('期末余额已保存,预付余额已按流水重算为 ¥' + h2FormatYuanNum(recalc.prepaidBalance) + '(原型)');
}, [periodEndBalanceModal, ledgerStore]);
var handleSaveBalanceAlert = useCallback(function () { var handleSaveBalanceAlert = useCallback(function () {
if (!balanceAlertModal.record) return; if (!balanceAlertModal.record) return;
var thresholdText = h2FormatReceiptAmountInput(balanceAlertModal.threshold); var thresholdText = h2FormatReceiptAmountInput(balanceAlertModal.threshold);
@@ -7367,12 +7621,13 @@ const Component = function () {
{ key: 'business', label: '营业状态', onClick: function () { openBusinessSetting(record); } }, { key: 'business', label: '营业状态', onClick: function () { openBusinessSetting(record); } },
{ key: 'price', label: '价格配置', onClick: function () { openPriceConfig(record); } }, { key: 'price', label: '价格配置', onClick: function () { openPriceConfig(record); } },
{ key: 'balanceAlert', label: '余额提醒设置', onClick: function () { openBalanceAlertSetting(record); } }, { key: 'balanceAlert', label: '余额提醒设置', onClick: function () { openBalanceAlertSetting(record); } },
{ key: 'periodEndBalance', label: '期末余额设置', onClick: function () { openPeriodEndBalanceSetting(record); } },
{ key: 'statement', label: '生成对账单', onClick: function () { openStatementModal(record); } }, { key: 'statement', label: '生成对账单', onClick: function () { openStatementModal(record); } },
{ key: 'statementHistory', label: '查看对账记录', onClick: function () { openStatementHistoryModal(record); } }, { key: 'statementHistory', label: '查看对账记录', onClick: function () { openStatementHistoryModal(record); } },
{ type: 'divider' }, { type: 'divider' },
{ key: 'delete', label: '删除', danger: true, onClick: function () { setDeleteModal({ open: true, record: record }); } } { key: 'delete', label: '删除', danger: true, onClick: function () { setDeleteModal({ open: true, record: record }); } }
]; ];
}, [openBusinessSetting, openPriceConfig, openBalanceAlertSetting, openStatementModal, openStatementHistoryModal]); }, [openBusinessSetting, openPriceConfig, openBalanceAlertSetting, openPeriodEndBalanceSetting, openStatementModal, openStatementHistoryModal]);
var closeImportModal = useCallback(function () { var closeImportModal = useCallback(function () {
setImportModalOpen(false); setImportModalOpen(false);
@@ -9873,6 +10128,170 @@ const Component = function () {
: null : null
), ),
React.createElement(Modal, {
title: '期末余额设置',
open: periodEndBalanceModal.open,
onCancel: closePeriodEndBalanceModal,
footer: React.createElement(Space, null,
renderH2GhostButton('取消', closePeriodEndBalanceModal),
React.createElement(Button, { type: 'primary', onClick: handleSavePeriodEndBalance, style: H2_PRIMARY_BTN_STYLE }, '保存')
),
centered: true,
width: 640,
destroyOnClose: true,
styles: { body: { maxHeight: '78vh', overflow: 'auto' } }
},
(function () {
if (!periodEndBalanceModal.record) return null;
var logs = periodEndBalanceModal.record.periodEndBalanceLogs || [];
var logPage = periodEndBalanceModal.logPage || 1;
var logPageSize = periodEndBalanceModal.logPageSize || 5;
var logTotalPages = Math.max(1, Math.ceil(logs.length / logPageSize));
var safePage = Math.min(logPage, logTotalPages);
var pagedLogs = logs.slice((safePage - 1) * logPageSize, safePage * logPageSize);
var periodEndTimeValue = h2ToDateTimeDayjs(periodEndBalanceModal.periodEndTime);
var periodEndPicker = dayjs && DatePicker
? React.createElement(DatePicker, {
showTime: { format: 'HH:mm' },
needConfirm: false,
style: { width: '100%', borderRadius: 8 },
format: 'YYYY-MM-DD HH:mm',
placeholder: '请选择期末时间',
allowClear: true,
value: periodEndTimeValue,
disabledDate: function (current) {
if (!current || !dayjs) return false;
return current.isAfter(dayjs().endOf('day'));
},
onChange: function (d) {
var text = d && d.isValid && d.isValid() ? d.format('YYYY-MM-DD HH:mm') : '';
setPeriodEndBalanceModal(function (m) { return Object.assign({}, m, { periodEndTime: text }); });
}
})
: React.createElement(Input, {
style: { width: '100%', borderRadius: 8 },
placeholder: 'YYYY-MM-DD HH:mm',
value: periodEndBalanceModal.periodEndTime || '',
onChange: function (e) {
setPeriodEndBalanceModal(function (m) { return Object.assign({}, m, { periodEndTime: e.target.value }); });
}
});
return React.createElement('div', {
'data-annotation-id': 'h2-site-period-end-balance',
style: { display: 'flex', flexDirection: 'column', gap: 14 }
},
React.createElement('div', {
style: {
padding: '12px 14px',
borderRadius: 10,
background: '#f8fafc',
border: '1px solid #e2e8f0',
fontSize: 13,
color: '#475569',
lineHeight: 1.55
}
},
React.createElement('div', { style: { fontWeight: 700, color: '#0f172a', marginBottom: 4 } }, periodEndBalanceModal.record.name || '—'),
'登记与加氢站对账时的期末余额与期末时间。保存后按「期末余额 期末后支出 + 期末后收入」重算并写回预付余额。当前预付余额:',
React.createElement('strong', {
style: {
color: h2NumOrZero(periodEndBalanceModal.record.prepaidBalance) < 0 ? '#dc2626' : '#059669',
fontVariantNumeric: 'tabular-nums'
}
}, h2FormatYuanNum(periodEndBalanceModal.record.prepaidBalance)),
' 元'
),
React.createElement(Form, { layout: 'vertical', requiredMark: false },
React.createElement(Form.Item, {
label: React.createElement('span', null, React.createElement('span', { style: { color: '#ef4444', marginRight: 4 } }, '*'), '期末余额(元)')
},
React.createElement(Input, {
className: 'h2-statement-receipt-amount-input',
inputMode: 'decimal',
prefix: '¥',
style: { width: '100%', borderRadius: 8 },
placeholder: '请输入期末余额',
value: periodEndBalanceModal.periodEndBalance || '',
onChange: function (e) {
var next = h2SanitizeSignedAmountInput(e.target.value);
setPeriodEndBalanceModal(function (m) { return Object.assign({}, m, { periodEndBalance: next }); });
},
onBlur: function (e) {
var formatted = h2FormatSignedAmountInput(e.target.value);
setPeriodEndBalanceModal(function (m) { return Object.assign({}, m, { periodEndBalance: formatted }); });
}
})
),
React.createElement(Form.Item, {
label: React.createElement('span', null, React.createElement('span', { style: { color: '#ef4444', marginRight: 4 } }, '*'), '期末时间'),
extra: '不可晚于今天;保存后作为余额计算与下钻流水的锚点'
}, periodEndPicker)
),
React.createElement('div', null,
React.createElement('div', {
style: { fontSize: 13, fontWeight: 600, color: '#0f172a', marginBottom: 8 }
}, '变更记录'),
React.createElement(Table, {
className: 'h2-status-log-table',
size: 'small',
rowKey: 'id',
pagination: false,
locale: { emptyText: '暂无变更记录' },
dataSource: pagedLogs,
columns: [
{
title: '期末余额(元)',
dataIndex: 'periodEndBalance',
key: 'periodEndBalance',
width: 120,
render: function (v) {
return React.createElement('span', { style: { fontVariantNumeric: 'tabular-nums', fontWeight: 600 } }, h2FormatYuanNum(v));
}
},
{
title: '生效时间',
dataIndex: 'effectiveTime',
key: 'effectiveTime',
width: 148,
render: function (v) { return v || '—'; }
},
{
title: '操作人',
dataIndex: 'operator',
key: 'operator',
width: 100,
render: function (v) { return v || '—'; }
},
{
title: '操作时间',
dataIndex: 'operateTime',
key: 'operateTime',
width: 148,
render: function (v) { return v || '—'; }
}
]
}),
React.createElement('div', { className: 'vm-table-footer vm-table-footer--compact', style: { marginTop: 8, padding: '8px 0', borderTop: 'none' } },
React.createElement(TablePagination, {
page: safePage,
pageSize: logPageSize,
total: logs.length,
pageSizeOptions: COMPACT_PAGE_SIZE_OPTIONS,
onPageChange: function (p) {
setPeriodEndBalanceModal(function (m) { return Object.assign({}, m, { logPage: p }); });
},
onPageSizeChange: function (size) {
setPeriodEndBalanceModal(function (m) {
return Object.assign({}, m, { logPageSize: size, logPage: 1 });
});
}
})
)
)
);
})()
),
React.createElement(Modal, { React.createElement(Modal, {
title: '全站余额提醒设置', title: '全站余额提醒设置',
open: globalBalanceAlertModal.open, open: globalBalanceAlertModal.open,

View File

@@ -10,6 +10,7 @@ const fullPrd = fs.readFileSync(path.join(specRoot, 'requirements-prd.md'), 'utf
const listPrd = fs.readFileSync(path.join(specRoot, 'requirements-prd-list.md'), 'utf8'); const listPrd = fs.readFileSync(path.join(specRoot, 'requirements-prd-list.md'), 'utf8');
const statementPrd = fs.readFileSync(path.join(specRoot, 'requirements-prd-statement.md'), 'utf8'); const statementPrd = fs.readFileSync(path.join(specRoot, 'requirements-prd-statement.md'), 'utf8');
const kpiBalanceAlertPrd = fs.readFileSync(path.join(specRoot, 'requirements-prd-kpi-balance-alert.md'), 'utf8'); const kpiBalanceAlertPrd = fs.readFileSync(path.join(specRoot, 'requirements-prd-kpi-balance-alert.md'), 'utf8');
const periodEndPrd = fs.readFileSync(path.join(specRoot, 'period-end-balance.md'), 'utf8');
const scopeMd = fs.readFileSync(path.join(specRoot, 'requirements.md'), 'utf8'); const scopeMd = fs.readFileSync(path.join(specRoot, 'requirements.md'), 'utf8');
const NODE_DEFS = [ const NODE_DEFS = [
@@ -97,6 +98,18 @@ const NODE_DEFS = [
fingerprint: 'form|statement', fingerprint: 'form|statement',
}, },
}, },
{
id: 'h2-site-period-end-balance',
title: '期末余额设置',
pageId: 'site-info',
color: '#0891b2',
aiPrompt: '期末余额锚点、重算预付余额与变更记录。',
annotationText: '保存时按期末余额−支出+收入重算预付余额;变更记录只追加;期末时间不可晚于今天',
locator: {
selectors: ['[data-annotation-id="h2-site-period-end-balance"]'],
fingerprint: 'modal|period-end-balance',
},
},
]; ];
const NODE_PRD_MAP = { const NODE_PRD_MAP = {
@@ -107,9 +120,21 @@ const NODE_PRD_MAP = {
'h2-site-price-cost-input': { file: 'full', heading: '### 5.2 价格配置' }, 'h2-site-price-cost-input': { file: 'full', heading: '### 5.2 价格配置' },
'h2-site-price-tiered': { file: 'full', heading: '#### 阶梯价格 · 生成逻辑' }, 'h2-site-price-tiered': { file: 'full', heading: '#### 阶梯价格 · 生成逻辑' },
'h2-site-statement-form': { file: 'statement', heading: '#### 生成对账单 · 业务逻辑' }, 'h2-site-statement-form': { file: 'statement', heading: '#### 生成对账单 · 业务逻辑' },
'h2-site-period-end-balance': {
file: 'periodEnd',
heading: '## 1. 背景与目标',
combine: [
'## 1. 背景与目标',
'## 2. 已确认决策',
'## 3. 入口与交互',
'## 5. 重算逻辑(保存时)',
'## 6. 余额下钻联动',
'## 10. 验收清单',
],
},
}; };
const prdByKey = { list: listPrd, statement: statementPrd, full: fullPrd }; const prdByKey = { list: listPrd, statement: statementPrd, full: fullPrd, periodEnd: periodEndPrd };
function extractSection(md, startHeading) { function extractSection(md, startHeading) {
const lines = md.split('\n'); const lines = md.split('\n');
@@ -276,6 +301,13 @@ const source = {
markdown: statementPrd, markdown: statementPrd,
markdownPath: 'src/prototypes/oneos-web-h2-station-site/.spec/requirements-prd-statement.md', markdownPath: 'src/prototypes/oneos-web-h2-station-site/.spec/requirements-prd-statement.md',
}, },
{
type: 'markdown',
id: 'h2-site-doc-prd-period-end',
title: '期末余额设置',
markdown: periodEndPrd,
markdownPath: 'src/prototypes/oneos-web-h2-station-site/.spec/period-end-balance.md',
},
{ {
type: 'markdown', type: 'markdown',
id: 'h2-site-doc-scope', id: 'h2-site-doc-scope',

View File

@@ -0,0 +1,51 @@
# Prototype Review
- 审查目标:`src/prototypes/oneos-web-h2-station`(加氢站管理 → **加氢记录** Web入口 `index.tsx``pages/02-加氢记录.jsx`
- 用户资料/参考资料:
- `.spec/requirements-prd.md``.spec/record-data-model.md`
- 关联:`oneos-h5-h2-order/.spec/requirements-prd.md``reconcile-linkage.md`
- 源码:`index.tsx``pages/02-加氢记录.jsx``src/common/h2VehicleLedgerBridge.js``annotation-source.json`
- 菜单:`.axhub/make/sidebar-tree.json`(加氢站管理分组)
- 生成时间2026-07-16 09:56
- 修复闭环2026-07-16按 Review 建议处理 P1/P2/P3
- 资料冲突/待确认:**部分仍有**(对账权威入口叙事,见下)
## 总体点评
主路径(筛选 → 近 30 日统计 → 列表增删改/导出 → 已对账只读 → 与 H5 共用 Bridge完整可演示。按评审建议已补齐**预约带站名(全站运营)**、**接受预约落账**、**重复键拦截**、**总额偏差确认**、**孤儿页清理与对账入口文档对齐**。剩余待确认项主要为「车辆氢费明细点完成」与「站点对账单提交」两条对账叙事并存,不阻塞本页演示。
## P0-P3 优先级问题
### P1 - 预约加氢未绑定站点 — **已修复**
- 处理:预约种子补 `stationName`/`stationCode`;弹窗明细含站名;规格定为 **全站运营视角**
### P1 - 接受预约不产生加氢记录 — **已修复**
- 处理:接受后调用 `hrUpsertBridgeRow` 生成未对账流水(时间=预约时间,量=预约量,单价=该站近期单价);写 `linkedRecordId`;重复键时拒绝落账。
### P2 - 包内孤儿页与对账路径文档漂移 — **已修复**
- 处理:删除未引用的 `01-加氢订单.jsx``03-站点信息.jsx` 迁至 `oneos-web-h2-station-site/pages/``reconcile-linkage.md` / PRD / DESIGN 引用统一为站点信息原型入口。
### P2 - 缺少同站同车牌同加氢时间重复录入防护 — **已修复**
- 处理Bridge.`findDuplicateRow`Web / H5 保存前拦截;批量行内互查。规格见 `record-data-model.md` §5。
### P3 - 单价×量与总额可任意偏离无提示 — **已修复**
- 处理:偏差 > 0.05 元时二次确认,仍可按手改总额保存。规格见 `record-data-model.md` §6。
## 完整性与项目对齐
| 维度 | 结论 |
|------|------|
| 核心任务 | 已覆盖筛选、统计、CRUD、导出、预约受理并落账、重复键/总额校验 |
| 菜单对齐 | 站点信息 / 加氢订单(H5) / 加氢记录 / 数量统计 — 与 sidebar 一致;本包仅承载加氢记录 |
| 资料冲突/待确认 | 对账权威入口已拍板:见 [../vehicle-h2-fee-ledger/.spec/verify-reconcile.md](../vehicle-h2-fee-ledger/.spec/verify-reconcile.md)(核对 ≠ 对账) |
## 业务逻辑连贯性
- **流水生命周期**:新增 pending →站点对账提交reconciled → 禁编删。
- **预约状态机**pending → accepted+ 未对账流水)/ rejected徽标随全站 pending 递减。
- **双端同步**:同会话 Bridge重复键与总额规则 Web/H5 共用。

View File

@@ -4,6 +4,7 @@
|---|---| |---|---|
| 代码路径 | UI`pages/02-加氢记录.jsx`Store`src/common/h2VehicleLedgerBridge.js` | | 代码路径 | UI`pages/02-加氢记录.jsx`Store`src/common/h2VehicleLedgerBridge.js` |
| 对齐模块 | H5「加氢订单」`oneos-h5-h2-order`(同一业务、同一 StorePC 多导出 / 预约加氢) | | 对齐模块 | H5「加氢订单」`oneos-h5-h2-order`(同一业务、同一 StorePC 多导出 / 预约加氢) |
| 对账入口 | 菜单原型 `oneos-web-h2-station-site`(实现:`oneos-web-h2-station-site/pages/03-站点信息.jsx` |
| 数据源 | 原型本地种子 + 内存共享 Store**未接真实 API** | | 数据源 | 原型本地种子 + 内存共享 Store**未接真实 API** |
--- ---
@@ -17,48 +18,94 @@
| 加氢时间 | `YYYY-MM-DD HH:mm:ss` | | 加氢时间 | `YYYY-MM-DD HH:mm:ss` |
| 加氢站名称 | 站名;写入时同步 `stationId`/`stationCode` | | 加氢站名称 | 站名;写入时同步 `stationId`/`stationCode` |
| 车牌号 | 展示去尾缀 `F`;写入 Store 补 `F` | | 车牌号 | 展示去尾缀 `F`;写入 Store 补 `F` |
| 里程km | `mileageKm`**非必填**;未填存 `null`,列表展示「—」 |
| 氢气单价(元/kg | 站端口径,映射 `costUnitPrice` / `customerUnitPrice` | | 氢气单价(元/kg | 站端口径,映射 `costUnitPrice` / `customerUnitPrice` |
| 加氢量kg | `hydrogenKg` | | 加氢量kg | `hydrogenKg` |
| 加氢总额(元) | 映射 `costTotal` / `customerAmount` | | 加氢总额(元) | 映射 `costTotal` / `customerAmount` |
| 对状态 | `pending` / `reconciled` | | 对状态 | `verifyStatus``unverified` / `verified`;能源部「完成核对」写入 |
| 对时间 | `reconcileDate` / `reconciledAt` 格式化展示 | | 对时间 | `verifiedAt`;未核对应「—」 |
| 对账状态 | `pending` / `reconciled`**仅**站点对账单提交后变为已对账 |
| 对账时间 | **仅** `reconcileDate`(对账单回写);勿用 `reconciledAt` 判定对账 |
写入 Bridge 时默认:`settlementStatus=customer``mileageKm=0``creatorName=站端账号`(与 H5 `upsertStationOrder` 相同),不在 Web 表单暴露 写入 Bridge 时默认:`settlementStatus=customer``creatorName=站端账号``verifyStatus=unverified``reconcileStatus=pending`(与 H5 相同)。`mileageKm` 由表单选填写入,未填为 `null`
> 双状态全文:[../vehicle-h2-fee-ledger/.spec/verify-reconcile.md](../vehicle-h2-fee-ledger/.spec/verify-reconcile.md)
--- ---
## 2. 对账判定优先级 ## 2. 核对 / 对账判定与锁定
| 优先级 | 条件 | 用户可见 | | 优先级 | 条件 | 用户可见 |
|---|---|---| |---|---|---|
| 1 | `reconcileStatus === 'reconciled'`已有对账时间 | **已对账**;禁编辑、禁删除 | | 1 | `reconcileStatus === 'reconciled'``reconcileDate` | **已对账**;禁编辑、禁删除;对账时间必有值 |
| 2 | 否则 | **未对账**;可编辑、删除 | | 2 | `verifyStatus === 'verified'` | **已核对**(可仍未对账);禁编辑、删除;展示核对时间 |
| 3 | 否则 | **未核对 + 未对账**;可编辑、删除 |
## 3. 前置条件 ## 3. 前置条件
| 场景 | 行为 | | 场景 | 行为 |
|---|---| |---|---|
| 共享 Bridge 不可用 | 列表用本地 fallback保存/删除提示失败 | | 共享 Bridge 不可用 | 列表用本地 fallback保存/删除提示失败 |
| 已对账点编辑/删除 | 警告「已对账记录不可编辑/删除」 | | 已核对或已对账点编辑/删除 | 对应警告文案 |
| PC 独有 | **导出**当前筛选结果 CSV**预约加氢**独立弹窗(不影响加氢记录数据结构) | | PC 独有 | **导出**含核对/对账列;**预约加氢**接受后写入未核对流水 |
## 4. 数据流 ## 4. 数据流
```text ```text
Web 加氢记录 新增/编辑/批量 Web / H5 新增加氢记录
hrUpsertBridgeRow → Bridge.upsertRow站端字段映射 Bridge.upsertRowunverified + pending
Store subscribe → 列表刷新 车辆氢费明细可见
H5 加氢订单 同上 upsert/remove 车辆氢费明细 · 完成核对
与 Web 读写同一 Store verifyStatus=verified + verifiedAt
→ Web / H5 同步「已核对」
站点信息 · 对账单提交 站点信息 · 对账单提交(仅已核对 ∧ 未对账)
applyPatchesreconciled + 对账时间) reconcileStatus=reconciled + reconcileDate
→ Web / H5 同步展示 → Web / H5 / 台账同步「已对账」
``` ```
## 5. 边界 ## 5. 重复录入防护(业务主键)
- Web 不做 OCR录入单价×量可自动带出总额总额仍可手改 | 项 | 规则 |
- 预约加氢为 PC 侧能力,数据与加氢记录 Store 分离 |---|---|
| 业务键 | **加氢站名称 + 车牌号(含 F+ 加氢时间(精确到秒)** |
| 判定顺序 | 1规范化车牌/时间 → 2在共享 Store 查重(编辑时排除自身 id→ 3批量内行间互查 |
| 前置条件 | 站名、车牌、时间均非空才校验;已对账记录仍占键,不可再录入同键 |
| 用户可见 | **拦截保存**,提示「已存在相同加氢站、车牌号与加氢时间的记录」 |
| 代码 | Bridge.`findDuplicateRow`Web / H5 共用 |
## 6. 总额与单价×量偏差
| 项 | 规则 |
|---|---|
| 期望值 | `round(单价 × 加氢量, 2)` |
| 容差 | 绝对值 ≤ **0.05 元** 视为一致 |
| 超过容差 | 二次确认「仍要保存」;确认后按用户填写的总额写入(不强制改回) |
| 代码 | Bridge.`totalAmountMismatch`Web Modal / H5 `confirm` |
## 7. 预约加氢PC 独有 · 全站运营视角)
| 字段 | 说明 |
|---|---|
| 加氢站名称 | `stationName`(必有);列表与接受/拒绝确认文案展示 |
| 站点编码 | `stationCode` |
| 车牌 / 预约时间 / 量 / 司机 / 联系方式 | 司机预约信息 |
| 状态 | `pending` / `accepted` / `rejected` |
| 关联流水 | 接受成功后写 `linkedRecordId`(对应加氢记录 id |
| 规则 | 说明 |
|---|---|
| 运营视角 | **全站运营**顶栏「预约加氢」Tab 列出全部站点预约;靠「加氢站名称」列区分归属;列表含处理情况 |
| 徽标 | 统计全部 `pending` 条数,不随主列表站点筛选收缩 |
| 接受落账 | 生成 **未对账** 流水:`hydrogenTime=预约时间``hydrogenKg=预约量``unitPrice=该站近期单价``lookupStationUnitPrice`,缺省 42.5),总额=单价×量;`creatorName=预约受理` |
| 拒绝 | 仅改预约状态,不写流水 |
| 重复键 | 若同站同车牌同预约时间已有流水,**拒绝落账**并提示 |
代码:`HR_INITIAL_APPOINTMENTS``handleAcceptAppointment``pages/02-加氢记录.jsx`)。
## 8. 边界
- Web 不做 OCR录入单价×量可自动带出总额总额仍可手改超容差需确认
- 预约列表数据本地维护;落账写入共享 Store 后与 H5 / 对账单可见
- 跨页联动依赖同浏览器会话内共享 Store - 跨页联动依赖同浏览器会话内共享 Store

View File

@@ -15,29 +15,41 @@
与 H5「加氢订单」为同一业务站端上报加氢流水。Web 供 PC 操作H5 供移动端。数据结构、共享账本与对账规则一致。Web **额外**提供导出、预约加氢处理。 与 H5「加氢订单」为同一业务站端上报加氢流水。Web 供 PC 操作H5 供移动端。数据结构、共享账本与对账规则一致。Web **额外**提供导出、预约加氢处理。
### 1.0 加氢站管理菜单分工
| 菜单 | 原型 | 职责 |
|---|---|---|
| 站点信息 | `oneos-web-h2-station-site` | 站点主数据、对账单生成与提交回写 |
| 加氢订单 | `oneos-h5-h2-order` | 站端手机浏览器录入本站流水 |
| 加氢记录 | **本原型** | PC 多站流水台账、导出、预约受理 |
### 1.1 功能清单 ### 1.1 功能清单
| # | 功能 | 说明 | | # | 功能 | 说明 |
|---|---|---| |---|---|---|
| 1 | 列表 / 筛选 | 字段与 H5 一致;可按时间、车牌、站名、对账状态筛选 | | 1 | 列表 / 筛选 | 字段与 H5 一致;可按时间、车牌、站名、对账状态筛选 |
| 2 | 新增 / 批量 / 编辑 | 字段:时间、站、车牌、单价、量、总额;已对账只读 | | 2 | 手工台账 | 与 H5 同逻辑:月历标已传/未传、可补传过往日;未上传今日台账不可新增;见 [feature-manual-ledger](../oneos-h5-h2-order/.spec/feature-manual-ledger.md) |
| 3 | 删除 | 仅未对账;写共享 Store | | 3 | 新增 / 批量 / 编辑 | 字段:时间、站、车牌、**里程(选填)**、单价、量、总额;已对账只读;同站同车牌同时间拦截;总额偏离单价×量超 0.05 元需确认 |
| 4 | 导出 | 当前筛选结果 CSVWeb 独有) | | 4 | 删除 | 仅未对账;写共享 Store |
| 5 | 预约加氢 | 弹窗处理预约Web 独有,与记录数据结构分离 | | 5 | 导出 | 当前筛选结果 CSVWeb 独有 |
| 6 | 预约加氢 | 列表工具栏左上角第三个 Tab列表展示预约信息与处理情况**接受后自动生成未对账加氢记录** |
### 1.2 列表字段 ### 1.2 列表字段
加氢时间、加氢站名称、车牌号、氢气单价(元/kg、加氢量、加氢总额、对账状态、对账时间。 加氢时间、加氢站名称、车牌号、里程km氢气单价(元/kg、加氢量、加氢总额、核对状态、核对时间、对账状态、对账时间。
--- ---
## 2. 验收项 ## 2. 验收项
1. 列表/表单字段与 H5「加氢订单」一致无公里数 / 充装员 / 承担方式录入) 1. 列表/表单字段与 H5「加氢订单」一致含选填里程;无充装员 / 承担方式录入)
2. 与 H5 读写同一共享 Store一端增删改后另一端可见同会话 2. 与 H5 读写同一共享 Store一端增删改后另一端可见同会话
3. 已对账不可编辑、删除 3.核对或已对账不可编辑、删除**已对账记录对账时间不为空**(对账单回写);核对由车辆氢费明细「完成核对」触发
4. 导出列与列表字段一致 4. 导出列与列表字段一致(含核对/对账)
5. 预约加氢入口与弹窗仍可用 5. 列表左上角三个 Tab加氢记录 / 手工台账 / 预约加氢;预约 Tab 列表展示预约信息与处理情况
6. 预约徽标为全站待处理条数(全站运营视角,不随列表站点筛选收缩)
7. 接受预约后列表出现对应未对账流水(时间/站/车牌/量与预约一致)
8. 同站同车牌同加氢时间重复保存被拦截;总额与单价×量偏差 > 0.05 元时出现确认
--- ---
@@ -45,4 +57,6 @@
| 主题 | 摘要 | 全文 | | 主题 | 摘要 | 全文 |
|---|---|---| |---|---|---|
| 数据结构与对账 | 站端口径字段映射 Bridge对账后禁改PC 多导出/预约 | [record-data-model.md](./record-data-model.md) | | 数据结构与对账 | 站端口径字段**核对 vs 对账**双状态PC 多导出/预约落账 | [record-data-model.md](./record-data-model.md)、[../vehicle-h2-fee-ledger/.spec/verify-reconcile.md](../vehicle-h2-fee-ledger/.spec/verify-reconcile.md) |
| 重复键 / 总额偏差 | 业务主键拦截;总额偏差二次确认 | [record-data-model.md](./record-data-model.md) §56 |
| 预约接受落账 | 接受 → 未对账草稿流水 | [record-data-model.md](./record-data-model.md) §7 |

View File

@@ -0,0 +1,81 @@
# UI Review
- 审查目标:`src/prototypes/oneos-web-h2-station`(加氢站管理 · 加氢记录)
- 使用设计依据:`src/prototypes/vm-shared/DESIGN.md`
- 生成时间2026-07-17 12:55
- 修复回写2026-07-17P0P2 已按规范落地)
## 总体点评
加氢记录页是典型中后台列表:筛选、近 30 日 KPI 轮播、三 Tab加氢记录 / 手工台账 / 预约加氢)与明细表同屏协作,任务路径清晰。视觉上延续 emerald 主色、白卡片与 tabular-nums 数字排版,和站点信息等 OneOS Web 列表气质一致;列表工具栏左上角放 Tab、右侧放导出/新增,信息架构合理。
`vm-shared/DESIGN.md` 对照时,偏离曾集中在三条**强制规范**:底部分页、筛选日期区间、操作列。已改为 `TablePagination` / `DateRangeFilterField` / `OperationActions`,数字列去掉右对齐,预约统计卡去掉左侧色条。
亮点:每日统计卡片带 `aria-label`、预约待处理徽标、手工台账拦截提示可点击跳转;新增/编辑页底栏固定、主按钮约 44px。预约 Tab 列表化后比弹窗更易扫读处理情况。
## P0-P3 优先级问题
### P0 - 列表分页未使用 `TablePagination` — ✅ 已修复
- 证据(修复前):主表 `pagination: { current, pageSize, showTotal: '共 N 条', ... }`;预览为 Ant Design 分页。
- 修复:主表与预约表均 `pagination: false` + `vm-table-footer``TablePagination``COMPACT_PAGE_SIZE_OPTIONS`);数据为筛选后切片 `pagedList` / `pagedAppointments`
### P0 - 筛选「加氢时间」使用 Ant Design `RangePicker` — ✅ 已修复
- 证据(修复前):筛选区 `RangePicker`,文案「开始时间 / 结束时间」。
- 修复:改为 `DateRangeFilterField``startDate` / `endDate`),按自然日过滤;展示「至」连接起止。
### P0 - 操作列未使用 `OperationActions` — ✅ 已修复
- 证据(修复前):主表 / 预约表自建 `Button type="link"` 横排。
- 修复:主表 `OperationActions``edit` + `more` 含危险删除);预约待处理行 `more: [接受, 拒绝]`;无操作时组件渲染 `-`
### P1 - 金额 / 数量列右对齐 — ✅ 已修复
- 证据(修复前):里程 / 单价 / 加氢量 / 加氢总额 / 预约加氢量 `align: 'right'`
- 修复:去掉右对齐,保留 `tabular-nums` / `hr-refuel-*` 等宽数字类名。
### P2 - 预约统计卡左侧色条过重 — ✅ 已修复
- 证据(修复前):`.h2-refuel-drill-stat--*` `border-left: 4px solid …`
- 修复:去掉单侧色条,保留全边框 + 浅底kg 卡保留淡绿渐变底)。
## 核心元件
### 列表工具栏 / 三 Tab
- 保留Tab 落在列表工具栏左侧,导出/新增右对齐;预约待处理角标提升发现性。
### 筛选区
- 已对齐:`DateRangeFilterField`;四列网格 + 重置/查询底栏。
### 每日统计 KPI
- 保留:近 30 天轮播、今日强调、每卡 `article` + 完整 `aria-label`;数字等宽。
### 数据表格
- 已对齐:`TablePagination``OperationActions`、数字列左对齐。
### 预约加氢列表
- 已对齐:统计卡无侧色条;操作列 `OperationActions`;底部分页同主表。
### 新增 / 编辑壳层
- 保留:顶栏返回 + 居中标题;底栏固定、主按钮 `min-height: 44px`
## 响应式与可访问性
- 桌面:筛选 4→2→1 列断点存在;表格横向 `scroll.x` 可横向拖读。
- 移动端:未做完整窄屏操作验收;长表格 + 多 Tab 在手机上会偏挤,建议后续专项压测。
- 键盘 / 语义:筛选与按钮可聚焦;每日统计与预约统计有 `role`/`aria-label`;分页已用中文「共 N 条 / 每页」。
- 对比度:正文 `#0f172a` / `#475569` 在白底上大致满足 AA。
- 触控:底栏主按钮 ≥44px操作列改 `OperationActions`44×44 触控约定)。
## 证据与评估说明
- 浏览器/截图:已打开 `http://localhost:51720/prototypes/oneos-web-h2-station` 并截取列表首屏(审查当时为 Ant 分页)。
- Scanner审查时 `detect.mjs` 命中 `side-tab`;修复后已去掉对应 `border-left: 4px`
- 设计依据说明:原型未自带 `DESIGN.md`;按项目列表页强制规范选用 `src/prototypes/vm-shared/DESIGN.md`(与入口 `import '../vehicle-management/style.css'`、中后台列表形态一致)。

View File

@@ -2,10 +2,9 @@
"documentVersion": 1, "documentVersion": 1,
"format": "axhub-annotation-source", "format": "axhub-annotation-source",
"data": { "data": {
"version": 1, "version": 2,
"prototypeName": "oneos-web-h2-station", "prototypeName": "oneos-web-h2-station",
"pageId": "h2-record", "pageId": "h2-record",
"updatedAt": 1784013600000,
"nodes": [ "nodes": [
{ {
"id": "hr-filter", "id": "hr-filter",
@@ -13,7 +12,9 @@
"title": "筛选条件", "title": "筛选条件",
"pageId": "h2-record", "pageId": "h2-record",
"locator": { "locator": {
"selectors": [".lc-filter-card"], "selectors": [
".lc-filter-card"
],
"fingerprint": "filter|h2-record", "fingerprint": "filter|h2-record",
"path": [] "path": []
}, },
@@ -31,11 +32,13 @@
"title": "列表工具栏", "title": "列表工具栏",
"pageId": "h2-record", "pageId": "h2-record",
"locator": { "locator": {
"selectors": [".lc-table-toolbar"], "selectors": [
".lc-table-toolbar"
],
"fingerprint": "toolbar|h2-record", "fingerprint": "toolbar|h2-record",
"path": [] "path": []
}, },
"annotationText": "Web 独有:预约加氢、导出;新增/编辑写入与 H5 同一共享账本。已对账不可改删。", "annotationText": "有待处理预约时显示全站待处理条数。弹窗含加氢站名称(全站运营视角)。接受预约将自动生成一条未对账加氢记录(时间=预约时间、量=预约量);拒绝仅改预约状态。",
"hasMarkdown": false, "hasMarkdown": false,
"color": "#059669", "color": "#059669",
"images": [], "images": [],
@@ -49,39 +52,44 @@
"title": "加氢记录列表", "title": "加氢记录列表",
"pageId": "h2-record", "pageId": "h2-record",
"locator": { "locator": {
"selectors": [".lc-list-table"], "selectors": [
".lc-list-table"
],
"fingerprint": "table|h2-record", "fingerprint": "table|h2-record",
"path": [] "path": []
}, },
"annotationText": "列:加氢时间、站名、车牌、单价、量、总额、对账状态、对账时间(与 H5 一致)。", "annotationText": "列:加氢时间、站名、车牌、里程、单价、量、总额、核对状态/时间、对账状态/时间。已核对或已对账禁改删;对账仅对账单提交后出现。",
"hasMarkdown": false, "hasMarkdown": false,
"color": "#FF7D00", "color": "#FF7D00",
"images": [], "images": [],
"createdAt": 1784013600000, "createdAt": 1784013600000,
"updatedAt": 1784013600000, "updatedAt": 1784013600000,
"controls": [] "controls": []
},
{
"id": "hr-daily-stats",
"index": 4,
"title": "每日统计",
"pageId": "h2-record",
"locator": {
"selectors": [
".hr-daily-stats-head__meta",
".hr-daily-stats-section"
],
"fingerprint": "daily-stats|h2-record",
"path": []
},
"annotationText": "近30天含今天自今日前推展示起止日期与加氢总量每日卡片为 2×2加氢数据条数 / 已对账条数 / 加氢量 / 加氢总额,随筛选结果实时汇总。",
"hasMarkdown": false,
"color": "#7C3AED",
"images": [],
"createdAt": 1784013600000,
"updatedAt": 1784165900000,
"controls": []
} }
], ],
"directory": [ "updatedAt": 1784013600000
{ },
"id": "hr-prd-root", "markdownMap": {},
"title": "产品需求", "assetMap": {}
"type": "folder",
"children": [
{
"id": "hr-prd-main",
"title": "加氢记录WebPRD",
"type": "markdown",
"markdownPath": "src/prototypes/oneos-web-h2-station/.spec/requirements-prd.md"
},
{
"id": "hr-prd-model",
"title": "数据结构与对账",
"type": "markdown",
"markdownPath": "src/prototypes/oneos-web-h2-station/.spec/record-data-model.md"
}
]
}
]
}
} }

View File

@@ -8,6 +8,8 @@ import React from 'react';
import '../vehicle-management/style.css'; import '../vehicle-management/style.css';
import Page2 from './pages/02-加氢记录.jsx'; import Page2 from './pages/02-加氢记录.jsx';
import { OneosWebLegacyShell } from '../../common/oneosWebLegacy/OneosWebLegacyShell'; import { OneosWebLegacyShell } from '../../common/oneosWebLegacy/OneosWebLegacyShell';
import { AnnotationViewer, type AnnotationSourceDocument } from '@axhub/annotation';
import annotationSourceDocument from './annotation-source.json';
const pages = [ const pages = [
{ id: 'h2-record', title: '加氢记录', component: Page2 }, { id: 'h2-record', title: '加氢记录', component: Page2 },
@@ -15,10 +17,23 @@ const pages = [
export default function OneosWebH2Station() { export default function OneosWebH2Station() {
return ( return (
<OneosWebLegacyShell <>
moduleTitle="加氢记录" <OneosWebLegacyShell
pages={pages} moduleTitle="加氢记录"
defaultPageId="h2-record" pages={pages}
/> defaultPageId="h2-record"
/>
<AnnotationViewer
source={annotationSourceDocument as unknown as AnnotationSourceDocument}
options={{
currentPageId: "h2-record",
toolbarEdge: 'right',
showToolbar: true,
showThemeToggle: true,
showColorFilter: true,
emptyWhenNoData: true,
}}
/>
</>
); );
} }

View File

@@ -1,194 +0,0 @@
// 【重要】必须使用 const Component 作为组件变量名
// 加氢站管理 - 加氢订单(列表 + 筛选2026年3月版
const Component = function () {
var useState = React.useState;
var useMemo = React.useMemo;
var antd = window.antd;
var Card = antd.Card;
var Table = antd.Table;
var Button = antd.Button;
var Select = antd.Select;
var Input = antd.Input;
var DatePicker = antd.DatePicker;
var Space = antd.Space;
var Modal = antd.Modal;
var message = antd.message;
var RangePicker = DatePicker.RangePicker || DatePicker.RangePicker;
// 筛选状态
var stationState = useState(undefined);
var timeRangeState = useState([]);
var plateNoState = useState('');
var station = stationState[0];
var setStation = stationState[1];
var timeRange = timeRangeState[0];
var setTimeRange = timeRangeState[1];
var plateNo = plateNoState[0];
var setPlateNo = plateNoState[1];
var requirementModalVisible = useState(false);
var orderDataRevisionState = useState(0);
var orderDataRevision = orderDataRevisionState[0];
var setOrderDataRevision = orderDataRevisionState[1];
React.useEffect(function () {
var bridge = typeof window !== 'undefined' ? window.H2VehicleLedgerBridge : null;
if (!bridge || typeof bridge.subscribe !== 'function') return undefined;
return bridge.subscribe(function () {
setOrderDataRevision(function (v) { return v + 1; });
});
}, []);
// 模拟:加氢站与加氢记录数据(氢费明细来自车辆氢费明细共享 Store
var stationList = useMemo(function () {
var bridge = typeof window !== 'undefined' ? window.H2VehicleLedgerBridge : null;
if (bridge && typeof bridge.getStationList === 'function') {
return bridge.getStationList();
}
return [
{ value: 'JX-H2-001', label: '嘉兴加氢站(一期)' },
{ value: 'HZ-H2-002', label: '杭州临平加氢站' },
{ value: 'SH-H2-003', label: '上海宝山加氢站' }
];
}, [orderDataRevision]);
var rawData = useMemo(function () {
var bridge = typeof window !== 'undefined' ? window.H2VehicleLedgerBridge : null;
if (bridge && typeof bridge.getOrderRecords === 'function') {
return bridge.getOrderRecords();
}
return [
{ id: 1, stationCode: 'JX-H2-001', stationName: '嘉兴加氢站(一期)', time: '2026-03-01 10:21', plateNo: '浙A12345', amountKg: 12.5, amountYuan: 625.00 },
{ id: 2, stationCode: 'JX-H2-001', stationName: '嘉兴加氢站(一期)', time: '2026-03-01 14:08', plateNo: '浙A67890', amountKg: 10.0, amountYuan: 500.00 },
{ id: 3, stationCode: 'HZ-H2-002', stationName: '杭州临平加氢站', time: '2026-03-02 09:30', plateNo: '浙B23456', amountKg: 15.3, amountYuan: 765.00 },
{ id: 4, stationCode: 'SH-H2-003', stationName: '上海宝山加氢站', time: '2026-03-03 16:45', plateNo: '沪A88888', amountKg: 8.0, amountYuan: 400.00 },
{ id: 5, stationCode: 'HZ-H2-002', stationName: '杭州临平加氢站', time: '2026-03-03 18:10', plateNo: '浙B99999', amountKg: 18.2, amountYuan: 910.00 }
];
}, [orderDataRevision]);
// 简单字符串时间过滤(示例用,实际可改为 dayjs 比较)
function inRange(timeStr, range) {
if (!range || !range.length || !range[0] || !range[1]) return true;
// timeStr: 'YYYY-MM-DD HH:mm',只做字符串比较以示意
var t = timeStr.replace(/[-:\\s]/g, '');
var s = range[0].format ? range[0].format('YYYYMMDDHHmm') : '';
var e = range[1].format ? range[1].format('YYYYMMDDHHmm') : '';
if (!s || !e) return true;
return t >= s && t <= e;
}
var filteredData = useMemo(function () {
var list = rawData.slice();
if (station) {
list = list.filter(function (r) { return r.stationCode === station; });
}
if (plateNo && plateNo.trim()) {
var kw = plateNo.trim().toLowerCase();
list = list.filter(function (r) { return (r.plateNo || '').toLowerCase().indexOf(kw) !== -1; });
}
if (timeRange && timeRange.length === 2) {
list = list.filter(function (r) { return inRange(r.time, timeRange); });
}
return list.map(function (r, idx) {
return Object.assign({}, r, { seq: idx + 1 });
});
}, [rawData, station, plateNo, timeRange]);
var columns = [
{ title: '序号', dataIndex: 'seq', key: 'seq', width: 70, align: 'center' },
{ title: '加氢站', dataIndex: 'stationName', key: 'stationName', width: 200 },
{ title: '加氢时间', dataIndex: 'time', key: 'time', width: 180 },
{ title: '车牌号', dataIndex: 'plateNo', key: 'plateNo', width: 120 },
{ title: '加氢量kg', dataIndex: 'amountKg', key: 'amountKg', width: 120, align: 'right', render: function (v) { return (v != null ? v.toFixed(2) : '0.00'); } },
{ title: '金额(元)', dataIndex: 'amountYuan', key: 'amountYuan', width: 120, align: 'right', render: function (v) { var n = typeof v === 'number' ? v : parseFloat(v); return (isNaN(n) ? '0.00' : n.toFixed(2)); } }
];
var layoutStyle = { padding: '16px 24px', background: '#f5f5f5', minHeight: '100vh' };
var cardStyle = { marginBottom: 16 };
var filterLabelStyle = { marginBottom: 6, fontSize: 14, color: 'rgba(0,0,0,0.65)' };
var filterItemStyle = { marginBottom: 12 };
function handleReset() {
setStation(undefined);
setTimeRange([]);
setPlateNo('');
}
function handleExport() {
message.info('导出当前筛选条件下的加氢订单(示例)');
}
var requirementContent = '加氢订单一个「数字化资产ONEOS运管平台」中的「加氢站管理-加氢订单」模块。\n\n#1.面包屑:加氢站管理-加氢订单\n\n#2.筛选:支持加氢站、加氢时间、车牌号等筛选;\n2.1.加氢站:选择器,选择加氢站;\n2.2.加氢时间:日期时间范围选择器,支持开始时间-结束时间;\n2.3.车牌号:输入框,支持模糊匹配;\n右侧为查询、重置按钮。\n\n#3.列表加氢订单列表展示序号、加氢站、加氢时间、车牌号、加氢量kg、金额支持分页与导出。';
return React.createElement('div', { style: layoutStyle },
React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'flex-end', marginBottom: 16 } }, React.createElement(Button, { type: 'link', style: { padding: 0 }, onClick: function () { requirementModalVisible[1](true); } }, '查看需求说明')
),
React.createElement(Card, { title: '筛选', style: cardStyle },
React.createElement('div', {
style: { display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '16px 24px', alignItems: 'flex-end' }
},
React.createElement('div', { style: filterItemStyle },
React.createElement('div', { style: filterLabelStyle }, '加氢站'),
React.createElement(Select, {
placeholder: '请选择加氢站',
allowClear: true,
style: { width: '100%' },
value: station,
onChange: function (v) { setStation(v); },
options: stationList
})
),
React.createElement('div', { style: filterItemStyle },
React.createElement('div', { style: filterLabelStyle }, '加氢时间'),
React.createElement(RangePicker, {
style: { width: '100%' },
showTime: true,
placeholder: ['开始时间', '结束时间'],
value: timeRange,
onChange: function (v) { setTimeRange(v || []); }
})
),
React.createElement('div', { style: filterItemStyle },
React.createElement('div', { style: filterLabelStyle }, '车牌号'),
React.createElement(Input, {
placeholder: '请输入车牌号',
value: plateNo,
onChange: function (e) { setPlateNo(e.target.value); }
})
)
),
React.createElement('div', { style: { display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 16 } },
React.createElement(Button, { onClick: handleReset }, '重置'),
React.createElement(Button, { type: 'primary' }, '查询')
)
),
React.createElement(Card, { title: '加氢订单列表', style: cardStyle },
React.createElement('div', { style: { display: 'flex', justifyContent: 'flex-end', marginBottom: 16 } },
React.createElement(Button, { onClick: handleExport }, '导出')
),
React.createElement(Table, {
rowKey: 'id',
columns: columns,
dataSource: filteredData,
pagination: { pageSize: 10, showSizeChanger: true, showTotal: function (t) { return '共 ' + t + ' 条'; } },
bordered: true,
size: 'middle',
scroll: { x: 700 }
})
),
React.createElement(Modal, {
title: '需求说明',
open: requirementModalVisible[0],
onCancel: function () { requirementModalVisible[1](false); },
width: 560,
footer: React.createElement(Button, { onClick: function () { requirementModalVisible[1](false); } }, '关闭'),
bodyStyle: { maxHeight: '70vh', overflow: 'auto' }
}, React.createElement('div', { style: { padding: '8px 0', whiteSpace: 'pre-wrap', fontSize: 13, lineHeight: 1.6 } }, requirementContent))
);
};
export default Component;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
# 加氢记录Web页面说明
| 文件 | 说明 |
|---|---|
| `02-加氢记录.jsx` | 本原型唯一页面PC 加氢流水台账(含导出、预约加氢) |
## 菜单分工(加氢站管理)
| 菜单 | 原型 ID | 页面实现 |
|---|---|---|
| 站点信息 | `oneos-web-h2-station-site` | `src/prototypes/oneos-web-h2-station-site/pages/03-站点信息.jsx` |
| 加氢订单 | `oneos-h5-h2-order` | H5 包内 |
| 加氢记录 | `oneos-web-h2-station` | 本目录 `02-加氢记录.jsx` |
历史合包中的 `01-加氢订单.jsx` 已删除;`03-站点信息.jsx` 已迁移至站点信息原型包。

View File

@@ -2,11 +2,12 @@
| 项目 | 内容 | | 项目 | 内容 |
|------|------| |------|------|
| 文档版本 | v2.3(业务版) | | 文档版本 | v2.4(业务版) |
| 产品模块 | 台账数据 → 车辆氢费明细 | | 产品模块 | 台账数据 → 车辆氢费明细 |
| 文档类型 | 产品需求说明 | | 文档类型 | 产品需求说明 |
| 适用读者 | 产品、业务、运营、测试、项目 | | 适用读者 | 产品、业务、运营、测试、项目 |
| 修订说明 | 同步成本单价校验图标与一键替换、固定/阶梯定价标签及与加氢站价格配置取价逻辑 | | 修订说明 | v2.4:核对 vs 对账双状态——「完成核对」≠ 对账结案;对账仅由加氢站对账单提交回写 |
| 双状态规格 | [verify-reconcile.md](./verify-reconcile.md) |
--- ---
@@ -15,7 +16,7 @@
### 1.1 业务痛点 ### 1.1 业务痛点
- 车辆加氢费用分散在业务同事手工记录中,缺少统一台账,难以按客户、站点、时间核对。 - 车辆加氢费用分散在业务同事手工记录中,缺少统一台账,难以按客户、站点、时间核对。
- 录入后需区分「是否已正式提交」「是否已完成对账」,避免未核实数据进入对账与结算环节。 - 录入后需区分「是否已正式提交」「是否已完成核对」「是否已完成对账」,避免未核实数据进入对账与结算环节。
- 加氢量偏大、单价与公司在该站该时段的标准价不一致等情况,需要**提前暴露**,减少后续财务纠纷。 - 加氢量偏大、单价与公司在该站该时段的标准价不一致等情况,需要**提前暴露**,减少后续财务纠纷。
### 1.2 产品价值 ### 1.2 产品价值
@@ -23,7 +24,7 @@
| 价值点 | 说明 | | 价值点 | 说明 |
|--------|------| |--------|------|
| 统一台账 | 一条记录对应一次加氢业务,关联车辆、客户、站点与金额 | | 统一台账 | 一条记录对应一次加氢业务,关联车辆、客户、站点与金额 |
| 流程可控 | 待保存 → 未对账 → 已对账,责任边界清晰 | | 流程可控 | 待保存 → 保存(未核对+未对账)→ **完成核对** → 对账单提交 → **已对账** |
| 效率提升 | 支持批量导入、批量改价、复制同类记录 | | 效率提升 | 支持批量导入、批量改价、复制同类记录 |
| 风险可见 | 异常数据标黄,可一键只看有问题记录 | | 风险可见 | 异常数据标黄,可一键只看有问题记录 |
| 可追溯 | 关键修改留痕,便于主管复核与客诉查证 | | 可追溯 | 关键修改留痕,便于主管复核与客诉查证 |

View File

@@ -0,0 +1,73 @@
# 核对 vs 对账(双状态)
| 项 | 说明 |
|---|---|
| 适用范围 | 车辆氢费明细、加氢记录 Web、加氢订单 H5、站点信息·对账单 |
| 共享 Store | `src/common/h2VehicleLedgerBridge.js` |
| 数据源 | 原型本地种子 + 内存 Store**未接真实 API** |
---
## 1. 两套状态(不得混用)
| 状态 | 字段 | 取值 | 谁触发 | 用户可见 |
|---|---|---|---|---|
| **核对** | `verifyStatus` | `unverified` / `verified` | 车辆氢费明细 · **完成核对** | 未核对 / 已核对 |
| **核对时间** | `verifiedAt` | 日期时间 | 同上 | 列表可展示 |
| **对账** | `reconcileStatus` | `pending` / `reconciled`(台账另有 `draft` 待保存) | 站点信息 · **对账单提交** | 未对账 / 已对账 |
| **对账时间** | `reconcileDate` | 日期时间 | 对账单提交回写 | 对账日期列 |
> 历史字段 `reconciledAt`:完成核对时与 `verifiedAt` 同写(兼容旧展示);**不得**再由「完成核对」把 `reconcileStatus` 写成 `reconciled`。
---
## 2. 端到端数据流(判定顺序)
| 步骤 | 动作 | 结果 |
|---|---|---|
| 1 | 加氢站H5/Web新增加氢记录 | 写入共享 Store`verifyStatus=unverified``reconcileStatus=pending`;按站点成本价补 `costUnitPrice``costTotal≈单价×量`;同步出现在车辆氢费明细 |
| 2 | 能源部在车辆氢费明细确认无误 → **完成核对** | `verifyStatus=verified`,写 `verifiedAt`;站端列表同步「已核对」;**业务员侧不可再改删**(原型规则) |
| 3 | 站点信息生成并对账单 **提交** | 仅纳入:**已核对** ∧ **未对账** ∧ 无 `statementRecordId` ∧ 本站 ∧ 账期;提交后 `reconcileStatus=reconciled`,写 `reconcileDate``statementRecordId` 等 |
---
## 3. 前置条件
| 场景 | 行为 |
|---|---|
| 未保存草稿(台账 `draft` | 不可完成核对;不可进对账单 |
| 未核对 | 可编辑(未对账前提下);不可进对账单 |
| 已核对未对账 | 不可业务员改删;可进对账单 |
| 已对账 | 展示「已对账」+ 对账时间;绑定对账单后不再进入下一张 |
---
## 4. 车辆氢费明细 UI 口径
| 控件 | 规则 |
|---|---|
| 按钮文案 | **完成核对**(原「完成对账」) |
| 勾选 | 仅「已保存且未核对」行 |
| 核对状态列 | 未核对 / 已核对 |
| 对账状态列 | 未对账 / 已对账(由对账单决定,非完成核对) |
| 对账日期 | 对账单回写后才有值 |
---
## 5. 站端(加氢记录 / 加氢订单)展示
| 列/标签 | 数据 |
|---|---|
| 核对状态 | `verifyStatus` |
| 核对时间 | `verifiedAt`(未核对应「—」) |
| 对账状态 | `reconcileStatus === reconciled` → 已对账,否则未对账 |
| 对账时间 | `reconcileDate`(未对账「—」) |
| 只读 | 已核对 **或** 已对账 → 禁编辑/删除 |
---
## 6. 边界
- 站端上报默认未核对、未对账
- 原型演示:嘉兴本站首页种子含 **未核对**(无核对时间,`rf-11`/`rf-12`)、**已核对未对账**`rf-13`)、**已核对已对账**`rf-1``rf-4`);他站另有样例
- 跨页依赖同浏览器会话共享 Store

View File

@@ -1,5 +1,5 @@
// 【重要】必须使用 const Component 作为组件变量名 // 【重要】必须使用 const Component 作为组件变量名
// 台账数据 - 车辆氢费明细(业务人员手动录入;保存后未对账勾选「完成对账」 // 台账数据 - 车辆氢费明细(业务人员手动录入;保存后未核对+未对账勾选「完成核对」;对账由站点对账单提交
// 原型:联调后对接加氢站、车辆、客户、公司及登录账号接口 // 原型:联调后对接加氢站、车辆、客户、公司及登录账号接口
// 车辆氢费明细 PRD 正文(与 车辆氢费明细-需求文档.md 同步) // 车辆氢费明细 PRD 正文(与 车辆氢费明细-需求文档.md 同步)
@@ -871,6 +871,8 @@ const Component = function () {
var RECONCILE_DRAFT = 'draft'; var RECONCILE_DRAFT = 'draft';
var RECONCILE_PENDING = 'pending'; var RECONCILE_PENDING = 'pending';
var RECONCILE_RECONCILED = 'reconciled'; var RECONCILE_RECONCILED = 'reconciled';
var VERIFY_VERIFIED = 'verified';
var VERIFY_UNVERIFIED = 'unverified';
var VEHICLE_INFO_TABLE = [ var VEHICLE_INFO_TABLE = [
'浙AD12345F', '浙AD12345F',
@@ -2298,7 +2300,7 @@ const Component = function () {
} }
function isCustomerNameWarn(row) { function isCustomerNameWarn(row) {
if (isReconciledRow(row)) return false; if (isDataLockedRow(row)) return false;
return getCustomerNameWarnInfo(row).warn; return getCustomerNameWarnInfo(row).warn;
} }
@@ -2319,7 +2321,7 @@ const Component = function () {
} }
function canQuickReplaceCustomer(row, isAdminRole) { function canQuickReplaceCustomer(row, isAdminRole) {
if (!row || isReconciledRow(row)) return false; if (!row || isDataLockedRow(row)) return false;
if (isAdminRole) return true; if (isAdminRole) return true;
return row.createdBy === CURRENT_USER.id; return row.createdBy === CURRENT_USER.id;
} }
@@ -2535,28 +2537,45 @@ const Component = function () {
return row && row.reconcileStatus === RECONCILE_RECONCILED; return row && row.reconcileStatus === RECONCILE_RECONCILED;
} }
function isVerifiedRow(row) {
return row && (row.verifyStatus === VERIFY_VERIFIED || row.verifyStatus === 'verified');
}
function isStatementLockedRow(row) { function isStatementLockedRow(row) {
return !!(row && row.statementPatched); return !!(row && row.statementPatched);
} }
/** 已核对或已对账后业务员侧锁定 */
function isDataLockedRow(row) {
return isVerifiedRow(row) || isReconciledRow(row) || isStatementLockedRow(row);
}
function rowDisplaySortTier(row) { function rowDisplaySortTier(row) {
if (isReconciledRow(row)) return 0; if (isReconciledRow(row)) return 0;
if (isPendingRow(row)) return 1; if (isVerifiedRow(row)) return 1;
return 2; if (isPendingRow(row)) return 2;
return 3;
}
function rowVerifyLabel(row) {
if (isNewRow(row)) return '';
if (isVerifiedRow(row)) return '已核对';
if (isPendingRow(row) || isReconciledRow(row)) return '未核对';
return '';
} }
function rowStatusLabel(row) { function rowStatusLabel(row) {
if (isReconciledRow(row)) return '已对账'; if (isReconciledRow(row)) return '已对账';
if (isPendingRow(row)) return '未对账'; if (isPendingRow(row) || isVerifiedRow(row)) return '未对账';
return ''; return '';
} }
function canShowPendingActions(row) { function canShowPendingActions(row) {
return isPendingRow(row); return isPendingRow(row) && !isVerifiedRow(row);
} }
function canCopyRow(row) { function canCopyRow(row) {
if (!row || isReconciledRow(row)) return false; if (!row || isDataLockedRow(row)) return false;
if (isPendingRow(row)) return true; if (isPendingRow(row)) return true;
return isNewRow(row) && row.createdBy === CURRENT_USER.id; return isNewRow(row) && row.createdBy === CURRENT_USER.id;
} }
@@ -2602,15 +2621,16 @@ const Component = function () {
companyName: sourceRow.companyName || '', companyName: sourceRow.companyName || '',
createdAt: now, createdAt: now,
reconcileStatus: null, reconcileStatus: null,
verifyStatus: null,
savedAt: null, savedAt: null,
verifiedAt: null,
reconciledAt: null reconciledAt: null
}; };
return recalcAllRows([draft])[0]; return recalcAllRows([draft])[0];
} }
function isRowBatchEditable(row) { function isRowBatchEditable(row) {
if (isStatementLockedRow(row)) return false; if (isDataLockedRow(row)) return false;
if (isReconciledRow(row)) return false;
if (isPendingRow(row)) return true; if (isPendingRow(row)) return true;
return isNewRow(row) && row.createdBy === CURRENT_USER.id; return isNewRow(row) && row.createdBy === CURRENT_USER.id;
} }
@@ -2941,8 +2961,7 @@ const Component = function () {
receiptDate: patch.receiptDate, receiptDate: patch.receiptDate,
receiptAmount: patch.receiptAmount != null ? patch.receiptAmount : r.receiptAmount, receiptAmount: patch.receiptAmount != null ? patch.receiptAmount : r.receiptAmount,
stationPaymentStatus: patch.paymentStatus || 'paid', stationPaymentStatus: patch.paymentStatus || 'paid',
paymentStatus: patch.paymentStatus || 'paid', paymentStatus: patch.paymentStatus || 'paid'
reconciledAt: patch.reconcileDate
}); });
}); });
} }
@@ -3081,7 +3100,9 @@ const Component = function () {
remark: '', remark: '',
createdAt: day, createdAt: day,
reconcileStatus: reconcileStatus, reconcileStatus: reconcileStatus,
verifyStatus: reconcileStatus === RECONCILE_RECONCILED ? VERIFY_VERIFIED : (reconcileStatus === RECONCILE_PENDING ? VERIFY_UNVERIFIED : null),
savedAt: reconcileStatus !== RECONCILE_DRAFT ? day : null, savedAt: reconcileStatus !== RECONCILE_DRAFT ? day : null,
verifiedAt: reconcileStatus === RECONCILE_RECONCILED ? day : null,
reconciledAt: reconcileStatus === RECONCILE_RECONCILED ? day : null reconciledAt: reconcileStatus === RECONCILE_RECONCILED ? day : null
}); });
} }
@@ -3105,7 +3126,9 @@ const Component = function () {
remark: '客户名称校验演示', remark: '客户名称校验演示',
createdAt: demoDay, createdAt: demoDay,
reconcileStatus: RECONCILE_PENDING, reconcileStatus: RECONCILE_PENDING,
savedAt: demoDay verifyStatus: VERIFY_UNVERIFIED,
savedAt: demoDay,
verifiedAt: null
}, },
draft draft
); );
@@ -3794,34 +3817,38 @@ const Component = function () {
setEditingKeys([]); setEditingKeys([]);
}, [allRows, appendChangeLog]); }, [allRows, appendChangeLog]);
var handleCompleteReconcile = useCallback(function () { var handleCompleteVerify = useCallback(function () {
if (!selectedRowKeys.length) { if (!selectedRowKeys.length) {
message.warning('请先勾选需要对的记录'); message.warning('请先勾选需要对的记录');
return; return;
} }
var keySet = {}; var keySet = {};
selectedRowKeys.forEach(function (k) { keySet[k] = true; }); selectedRowKeys.forEach(function (k) { keySet[k] = true; });
var selected = allRows.filter(function (r) { return keySet[r.key]; }); var selected = allRows.filter(function (r) { return keySet[r.key]; });
var notPending = selected.filter(function (r) { return !isPendingRow(r); }); var invalid = selected.filter(function (r) { return !isPendingRow(r) || isVerifiedRow(r); });
if (notPending.length) { if (invalid.length) {
message.warning('仅可对「未对账」状态的记录完成对'); message.warning('仅可对「已保存且未核对」的记录完成对');
return; return;
} }
var reconciledAt = nowDayjs(); var verifiedAt = nowDayjs();
selected.forEach(function (r) { selected.forEach(function (r) {
appendChangeLog(r.key, 'reconcileStatus', RECONCILE_PENDING, RECONCILE_RECONCILED, r, { force: true }); appendChangeLog(r.key, 'verifyStatus', VERIFY_UNVERIFIED, VERIFY_VERIFIED, r, { force: true });
}); });
setAllRows(function (prev) { setAllRows(function (prev) {
return prev.map(function (r) { return prev.map(function (r) {
if (keySet[r.key] && isPendingRow(r)) { if (keySet[r.key] && isPendingRow(r) && !isVerifiedRow(r)) {
return Object.assign({}, r, { reconcileStatus: RECONCILE_RECONCILED, reconciledAt: reconciledAt }); return Object.assign({}, r, {
verifyStatus: VERIFY_VERIFIED,
verifiedAt: verifiedAt,
reconciledAt: verifiedAt
});
} }
return r; return r;
}); });
}); });
setSelectedRowKeys([]); setSelectedRowKeys([]);
setEditingKeys([]); setEditingKeys([]);
message.success('已完成对,共 ' + selected.length + ' 条'); message.success('已完成对,共 ' + selected.length + ' 条');
}, [selectedRowKeys, allRows, appendChangeLog]); }, [selectedRowKeys, allRows, appendChangeLog]);
var rowSelection = useMemo( var rowSelection = useMemo(
@@ -3832,7 +3859,7 @@ const Component = function () {
setSelectedRowKeys(keys); setSelectedRowKeys(keys);
}, },
getCheckboxProps: function (record) { getCheckboxProps: function (record) {
return { disabled: !isPendingRow(record) }; return { disabled: !(isPendingRow(record) && !isVerifiedRow(record)) };
} }
}; };
}, },
@@ -4810,14 +4837,26 @@ const Component = function () {
} }
}, },
{ {
title: '状态', title: '核对状态',
key: 'verifyStatus',
width: 88,
align: 'center',
render: function (_, record) {
var label = rowVerifyLabel(record);
if (!label) return React.createElement('span', { style: { color: '#cbd5e1' } }, '—');
var color = label === '已核对' ? 'green' : 'orange';
return React.createElement(Tag, { color: color, style: { margin: 0 } }, label);
}
},
{
title: '对账状态',
key: 'recordStatus', key: 'recordStatus',
width: 72, width: 88,
align: 'center', align: 'center',
render: function (_, record) { render: function (_, record) {
var label = rowStatusLabel(record); var label = rowStatusLabel(record);
if (!label) return React.createElement('span', { style: { color: '#cbd5e1' } }, '—'); if (!label) return React.createElement('span', { style: { color: '#cbd5e1' } }, '—');
var color = label === '已对账' ? 'blue' : 'orange'; var color = label === '已对账' ? 'blue' : 'default';
return React.createElement(Tag, { color: color, style: { margin: 0 } }, label); return React.createElement(Tag, { color: color, style: { margin: 0 } }, label);
} }
}, },
@@ -5475,12 +5514,12 @@ const Component = function () {
React.createElement( React.createElement(
Popconfirm, Popconfirm,
{ {
title: '完成对后数据将标记为已对,只能联系主管进行修改是否确认', title: '完成对后数据将标记为已对,只能联系主管进行修改;对账须待加氢站对账单提交后才会变为已对账。是否确认',
okText: '确认', okText: '确认',
cancelText: '取消', cancelText: '取消',
onConfirm: handleCompleteReconcile onConfirm: handleCompleteVerify
}, },
React.createElement(Button, { type: 'primary', 'data-annotation-id': 'vh2-btn-reconcile' }, '完成对') React.createElement(Button, { type: 'primary', 'data-annotation-id': 'vh2-btn-reconcile' }, '完成对')
) )
) )
), ),

View File

@@ -20,7 +20,7 @@
"path": [] "path": []
}, },
"aiPrompt": "说明台账数据模块下车辆氢费明细的定位。", "aiPrompt": "说明台账数据模块下车辆氢费明细的定位。",
"annotationText": "# 模块定位\n\n| 项 | 说明 |\n|---|---|\n| 模块 | 台账数据 → 车辆氢费明细 |\n| 用户 | 业务员(录入/对账)、主管(复核已对账) |\n| 核心流程 | 待保存 → 保存未对账 → 完成对账 → 已对账 |\n| 关联 | 租赁业务台账「氢费」列取自本页汇总 |\n\n右上角「查看需求明细」可打开完整 PRD 弹窗。", "annotationText": "# 模块定位\n\n| 项 | 说明 |\n|---|---|\n| 模块 | 台账数据 → 车辆氢费明细 |\n| 用户 | 业务员(录入/对账)、主管(复核已对账) |\n| 核心流程 | 待保存 → 保存(未核对+未对账→ 完成核对 → 对账单提交 → 已对账 |\n| 关联 | 租赁业务台账「氢费」列取自本页汇总 |\n\n右上角「查看需求明细」可打开完整 PRD 弹窗。",
"hasMarkdown": true, "hasMarkdown": true,
"color": "#0f766e", "color": "#0f766e",
"images": [], "images": [],
@@ -83,7 +83,7 @@
"path": [] "path": []
}, },
"aiPrompt": "保存、对账、导入导出与状态流转。", "aiPrompt": "保存、对账、导入导出与状态流转。",
"annotationText": "# 工具栏与状态流转\n\n| 按钮 | 作用 |\n|---|---|\n| 批量导入 | 模板化上传多条记录 |\n| 批量导出 | 导出未对账+已对账,自选列 |\n| 保存 | 待保存 → 未对账 |\n| 完成对 | 勾选未对账行结案为已对账 |\n\n**保存**与**完成对**区别:保存是提交草稿;完成对账表示与客户/内部核对无误,业务员不可再改删。", "annotationText": "# 工具栏与状态流转\n\n| 按钮 | 作用 |\n|---|---|\n| 批量导入 | 模板化上传多条记录 |\n| 批量导出 | 导出未对账+已对账,自选列 |\n| 保存 | 待保存 → 未对账 |\n| 完成对 | 勾选未核对行标记为已核对;对账须待站点对账单提交 |\n\n**保存**与**完成对**区别:保存是提交草稿;完成核对表示能源部确认数据无误;对账结案由加氢站对账单提交触发。",
"hasMarkdown": true, "hasMarkdown": true,
"color": "#334155", "color": "#334155",
"images": [], "images": [],
@@ -146,7 +146,7 @@
"path": [] "path": []
}, },
"aiPrompt": "待保存与未对账状态转换。", "aiPrompt": "待保存与未对账状态转换。",
"annotationText": "将待保存行提交为「未对账」正式数据,方可勾选完成对。", "annotationText": "将待保存行提交为「未核对+未对账」正式数据,方可勾选完成对。",
"hasMarkdown": false, "hasMarkdown": false,
"color": "#2563eb", "color": "#2563eb",
"images": [], "images": [],
@@ -157,7 +157,7 @@
{ {
"id": "vh2-btn-reconcile", "id": "vh2-btn-reconcile",
"index": 8, "index": 8,
"title": "完成对", "title": "完成对",
"pageId": "list", "pageId": "list",
"locator": { "locator": {
"selectors": [ "selectors": [
@@ -283,10 +283,10 @@
] ]
}, },
"markdownMap": { "markdownMap": {
"vh2-header": "# 模块定位\n\n| 项 | 说明 |\n|---|---|\n| 模块 | 台账数据 → 车辆氢费明细 |\n| 用户 | 业务员(录入/对账)、主管(复核已对账) |\n| 核心流程 | 待保存 → 保存未对账 → 完成对账 → 已对账 |\n| 关联 | 租赁业务台账「氢费」列取自本页汇总 |\n\n右上角「查看需求明细」可打开完整 PRD 弹窗。", "vh2-header": "# 模块定位\n\n| 项 | 说明 |\n|---|---|\n| 模块 | 台账数据 → 车辆氢费明细 |\n| 用户 | 业务员(录入/对账)、主管(复核已对账) |\n| 核心流程 | 待保存 → 保存(未核对+未对账→ 完成核对 → 对账单提交 → 已对账 |\n| 关联 | 租赁业务台账「氢费」列取自本页汇总 |\n\n右上角「查看需求明细」可打开完整 PRD 弹窗。",
"vh2-filter": "# 筛选条件\n\n默认展示加氢时间、订单编号、车牌号码点击「展开」显示客户、加氢站、业务员、承担方式、客户收款状态、开票公司等。\n\n| 操作 | 行为 |\n|---|---|\n| 查询 | 应用当前筛选,并重算顶部合计 |\n| 重置 | 清空筛选条件 |\n| 展开/收起 | 切换更多筛选项 |\n\n筛选后列表按状态分层排序待保存 → 未对账 → 已对账。", "vh2-filter": "# 筛选条件\n\n默认展示加氢时间、订单编号、车牌号码点击「展开」显示客户、加氢站、业务员、承担方式、客户收款状态、开票公司等。\n\n| 操作 | 行为 |\n|---|---|\n| 查询 | 应用当前筛选,并重算顶部合计 |\n| 重置 | 清空筛选条件 |\n| 展开/收起 | 切换更多筛选项 |\n\n筛选后列表按状态分层排序待保存 → 未对账 → 已对账。",
"vh2-only-warn": "# 异常数据规则\n\n以下情况计入异常可勾选「仅显示异常数据」筛出。\n\n## 单元格标黄\n\n1. **加氢量疑似有误**:加氢量 60kg\n2. **成本单价异常**:与站点该时段标准成本价不一致(固定价或阶梯价口径)\n3. **加氢单价异常**:与站点该时段标准客户价不一致\n\n## 成本单价列\n\n- 单价前 **固定 / 阶梯** 标签(阶梯价悬浮可见当前阶梯量与对应单价)\n- 与标准价一致:绿色 ✓;不一致:红色 ⚠ + 单元格标黄\n- **一键替换为标准成本单价**(未对账;创建人或主管),写入变更日志\n\n## 整行标红(客户名称异常)\n\n按 **车牌 + 加氢日期** 比对车辆运营态:\n\n| 场景 | 判定 |\n|------|------|\n| 租赁/自营已交车未还车 | 录入客户与系统登记客户不一致 |\n| 库存车 | 不应填写客户名称 |\n| 异动期内 | 客户名称应为「车辆异动」 |\n\n整行淡红底各单元格斜向水印「客户疑似有误」客户名称列有警示图标悬浮可看系统客户、交车时间等。\n\n**与系统登记客户不一致**时,提示框提供 **一键替换为系统客户**,替换后写入变更日志(客户名称(一键替换))。\n\n开启后只展示异常记录**本人待保存的新增行仍显示**。", "vh2-only-warn": "# 异常数据规则\n\n以下情况计入异常可勾选「仅显示异常数据」筛出。\n\n## 单元格标黄\n\n1. **加氢量疑似有误**:加氢量 60kg\n2. **成本单价异常**:与站点该时段标准成本价不一致(固定价或阶梯价口径)\n3. **加氢单价异常**:与站点该时段标准客户价不一致\n\n## 成本单价列\n\n- 单价前 **固定 / 阶梯** 标签(阶梯价悬浮可见当前阶梯量与对应单价)\n- 与标准价一致:绿色 ✓;不一致:红色 ⚠ + 单元格标黄\n- **一键替换为标准成本单价**(未对账;创建人或主管),写入变更日志\n\n## 整行标红(客户名称异常)\n\n按 **车牌 + 加氢日期** 比对车辆运营态:\n\n| 场景 | 判定 |\n|------|------|\n| 租赁/自营已交车未还车 | 录入客户与系统登记客户不一致 |\n| 库存车 | 不应填写客户名称 |\n| 异动期内 | 客户名称应为「车辆异动」 |\n\n整行淡红底各单元格斜向水印「客户疑似有误」客户名称列有警示图标悬浮可看系统客户、交车时间等。\n\n**与系统登记客户不一致**时,提示框提供 **一键替换为系统客户**,替换后写入变更日志(客户名称(一键替换))。\n\n开启后只展示异常记录**本人待保存的新增行仍显示**。",
"vh2-toolbar": "# 工具栏与状态流转\n\n| 按钮 | 作用 |\n|---|---|\n| 批量导入 | 模板化上传多条记录 |\n| 批量导出 | 导出未对账+已对账,自选列 |\n| 保存 | 待保存 → 未对账 |\n| 完成对 | 勾选未对账行结案为已对账 |\n\n**保存**与**完成对**区别:保存是提交草稿;完成对账表示与客户/内部核对无误,业务员不可再改删。", "vh2-toolbar": "# 工具栏与状态流转\n\n| 按钮 | 作用 |\n|---|---|\n| 批量导入 | 模板化上传多条记录 |\n| 批量导出 | 导出未对账+已对账,自选列 |\n| 保存 | 待保存 → 未对账 |\n| 完成对 | 勾选未核对行标记为已核对;对账须待站点对账单提交 |\n\n**保存**与**完成对**区别:保存是提交草稿;完成核对表示能源部确认数据无误;对账结案由加氢站对账单提交触发。",
"vh2-btn-reconcile": "# 完成对账\n\n- 仅 **未对账** 行可勾选\n- 点击前二次确认:「完成对账后只能联系主管修改」\n- 成功后状态变为 **已对账**,对账日期写入\n- 业务员侧编辑/删除不可用;主管可纠错并留变更日志", "vh2-btn-reconcile": "# 完成对账\n\n- 仅 **未对账** 行可勾选\n- 点击前二次确认:「完成对账后只能联系主管修改」\n- 成功后状态变为 **已对账**,对账日期写入\n- 业务员侧编辑/删除不可用;主管可纠错并留变更日志",
"vh2-totals": "# 顶部合计条\n\n展示当前列表可见行的三项合计\n\n- 加氢量(kg)\n- 成本总价(元)\n- 加氢总价(元)\n\n**随查询重算**:点击「查询」后按新结果汇总。\n\n**随异常开关变化**:开启「仅显示异常数据」时,仅对可见异常行(及本人待保存行)合计。\n\n> 合计含列表中全部可见状态;导出范围仅为未对账+已对账,不含待保存草稿。", "vh2-totals": "# 顶部合计条\n\n展示当前列表可见行的三项合计\n\n- 加氢量(kg)\n- 成本总价(元)\n- 加氢总价(元)\n\n**随查询重算**:点击「查询」后按新结果汇总。\n\n**随异常开关变化**:开启「仅显示异常数据」时,仅对可见异常行(及本人待保存行)合计。\n\n> 合计含列表中全部可见状态;导出范围仅为未对账+已对账,不含待保存草稿。",
"vh2-table": "# 列表字段与操作\n\n**核心可编辑字段**(未对账/待保存):加氢时间、站点、车牌、加氢量、成本单价、加氢单价、承担方式等。\n\n**系统带出只读**:业务员、开票/付款相关字段等;客户名称依业务可选填,但会与车辆运营态校验。\n\n## 成本单价列\n\n- 单价前展示 **固定 / 阶梯** 标签(与加氢站价格配置一致)\n- 阶梯价悬浮:当前阶梯量、对应单价\n- 校验图标:正确绿色 ✓ / 异常红色 ⚠\n- **一键替换为标准成本单价**(未对账;创建人或主管),写入变更日志\n\n## 客户名称列\n\n- 与系统登记不一致时整行标红,单元格水印「客户疑似有误」\n- 客户名称后 **警示图标**:悬浮显示异常说明(系统客户、交车时间等)\n- **一键替换为系统客户**(仅「与系统登记客户不一致」场景,未对账数据可操作)\n- 替换写入变更日志\n\n**行操作**(更多菜单):编辑、删除、复制多行、变更日志。\n\n**列表勾选**:仅未对账记录可勾选,用于批量完成对账。\n\n表头支持批量改价成本单价、加氢单价、承担方式。" "vh2-table": "# 列表字段与操作\n\n**核心可编辑字段**(未对账/待保存):加氢时间、站点、车牌、加氢量、成本单价、加氢单价、承担方式等。\n\n**系统带出只读**:业务员、开票/付款相关字段等;客户名称依业务可选填,但会与车辆运营态校验。\n\n## 成本单价列\n\n- 单价前展示 **固定 / 阶梯** 标签(与加氢站价格配置一致)\n- 阶梯价悬浮:当前阶梯量、对应单价\n- 校验图标:正确绿色 ✓ / 异常红色 ⚠\n- **一键替换为标准成本单价**(未对账;创建人或主管),写入变更日志\n\n## 客户名称列\n\n- 与系统登记不一致时整行标红,单元格水印「客户疑似有误」\n- 客户名称后 **警示图标**:悬浮显示异常说明(系统客户、交车时间等)\n- **一键替换为系统客户**(仅「与系统登记客户不一致」场景,未对账数据可操作)\n- 替换写入变更日志\n\n**行操作**(更多菜单):编辑、删除、复制多行、变更日志。\n\n**列表勾选**:仅未对账记录可勾选,用于批量完成对账。\n\n表头支持批量改价成本单价、加氢单价、承担方式。"
@@ -304,7 +304,7 @@
"type": "markdown", "type": "markdown",
"id": "vh2-doc-overview", "id": "vh2-doc-overview",
"title": "模块总览", "title": "模块总览",
"markdown": "## 1. 模块定位\n\n| 项 | 说明 |\n|---|---|\n| 模块名称 | 台账数据 — 车辆氢费明细 |\n| 目标用户 | 业务员、主管 |\n| 核心任务 | 录入加氢费用、保存、对账、导入导出 |\n| 页面结构 | 筛选区 + 工具栏 + 合计条 + 宽表 + 底部新增 |\n\n## 2. 状态流转\n\n待保存 → **保存** → 未对 → **完成对账** → 已对账\n\n## 3. 验收重点\n\n1. 筛选与合计联动\n2. 异常标黄与「仅显示异常」\n3. 成本单价固定/阶梯标签、校验图标与一键替换\n4. 保存/对账权限区分业务员与主管\n5. 变更日志可追溯\n6. 导出不含待保存草稿" "markdown": "## 1. 模块定位\n\n| 项 | 说明 |\n|---|---|\n| 模块名称 | 台账数据 — 车辆氢费明细 |\n| 目标用户 | 业务员、主管 |\n| 核心任务 | 录入加氢费用、保存、对账、导入导出 |\n| 页面结构 | 筛选区 + 工具栏 + 合计条 + 宽表 + 底部新增 |\n\n## 2. 状态流转\n\n待保存 → **保存** → 未对 → **完成核对** → 对账单提交 → 已对账\n\n## 3. 验收重点\n\n1. 筛选与合计联动\n2. 异常标黄与「仅显示异常」\n3. 成本单价固定/阶梯标签、校验图标与一键替换\n4. 保存/对账权限区分业务员与主管\n5. 变更日志可追溯\n6. 导出不含待保存草稿"
}, },
{ {
"type": "link", "type": "link",
@@ -348,7 +348,7 @@
"type": "markdown", "type": "markdown",
"id": "vh2-doc-table", "id": "vh2-doc-table",
"title": "列表与合计", "title": "列表与合计",
"markdown": "# 列表与合计\n\n顶部合计条实时汇总可见行的加氢量、成本总价、加氢总价。\n\n列表支持行内编辑、复制、批量改价、变更日志仅未对可勾选完成对账。\n\n成本单价列含固定/阶梯标签、标准价校验图标与一键替换;客户名称列含运营态校验、警示图标与一键替换(见异常数据说明)。" "markdown": "# 列表与合计\n\n顶部合计条实时汇总可见行的加氢量、成本总价、加氢总价。\n\n列表支持行内编辑、复制、批量改价、变更日志仅未对可勾选完成核对;已对账由对账单提交产生。\n\n成本单价列含固定/阶梯标签、标准价校验图标与一键替换;客户名称列含运营态校验、警示图标与一键替换(见异常数据说明)。"
} }
] ]
} }

View File

@@ -335,6 +335,6 @@ import { OperationActions } from '../../common/OperationActions';
| 租赁业务台账 | `src/prototypes/lease-business-ledger/index.tsx` | | 租赁业务台账 | `src/prototypes/lease-business-ledger/index.tsx` |
| 客户管理 | `src/prototypes/customer-management/index.tsx` | | 客户管理 | `src/prototypes/customer-management/index.tsx` |
| 收款记录 | `src/prototypes/payment-records/index.tsx` | | 收款记录 | `src/prototypes/payment-records/index.tsx` |
| 加氢站站点信息 | `src/prototypes/oneos-web-h2-station/pages/03-站点信息.jsx` | | 加氢站站点信息 | `src/prototypes/oneos-web-h2-station-site/pages/03-站点信息.jsx` |
| 车辆维修明细 | `src/prototypes/vehicle-maintenance-ledger/MaintenanceLedgerPage.jsx` | | 车辆维修明细 | `src/prototypes/vehicle-maintenance-ledger/MaintenanceLedgerPage.jsx` |
| 租赁合同管理 | `src/prototypes/lease-contract-management/LeaseContractManagement.jsx` | | 租赁合同管理 | `src/prototypes/lease-contract-management/LeaseContractManagement.jsx` |

View File

@@ -343,7 +343,7 @@ const vmTheme = {
| 车辆资产 | `src/prototypes/vehicle-management/index.tsx` | | 车辆资产 | `src/prototypes/vehicle-management/index.tsx` |
| 租赁合同 | `src/prototypes/lease-contract-management/LeaseContractManagement.jsx` | | 租赁合同 | `src/prototypes/lease-contract-management/LeaseContractManagement.jsx` |
| 租赁业务台账 | `src/prototypes/lease-business-ledger/index.tsx` | | 租赁业务台账 | `src/prototypes/lease-business-ledger/index.tsx` |
| 加氢站站点 | `src/prototypes/oneos-web-h2-station/pages/03-站点信息.jsx` | | 加氢站站点 | `src/prototypes/oneos-web-h2-station-site/pages/03-站点信息.jsx` |
--- ---