迭代加氢站 H5/Web:手工台账与预约列表、导入主键与实收规则,并同步站点信息拆分与导航。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,26 +5,32 @@
|
||||
|
||||
var H2_RECONCILE_RECONCILED = 'reconciled';
|
||||
var H2_RECONCILE_PENDING = 'pending';
|
||||
var H2_VERIFY_VERIFIED = 'verified';
|
||||
var H2_VERIFY_UNVERIFIED = 'unverified';
|
||||
|
||||
var H2_STATION_CODE_MAP = {
|
||||
'嘉兴加氢站(一期)': 'JX-H2-001',
|
||||
'中国石油中油高新能源牙谷加油加氢站': 'JX-H2-001',
|
||||
'杭州临平加氢站': 'HZ-H2-002',
|
||||
'上海宝山加氢站': 'SH-H2-003',
|
||||
'苏州工业园区备用站': 'SZ-H2-004'
|
||||
};
|
||||
|
||||
/** 与车辆氢费明细、加氢站模块对齐的 canonical seed(10 条) */
|
||||
/** 与车辆氢费明细、加氢站模块对齐的 canonical seed(含本站未核对 / 已核对未对账用例) */
|
||||
var H2_CANONICAL_LEDGER_SEED = [
|
||||
{ key: 'rf-1', id: 'rf-1', stationId: 'JX-H2-001', stationName: '嘉兴加氢站(一期)', hydrogenTime: '2026-05-28 10:21:08', plateNo: '浙A12345F', customerName: '嘉兴市鑫峤供应链科技有限公司', hydrogenKg: 12.5, costUnitPrice: 42.5, costTotal: 531.25, customerUnitPrice: 45, customerAmount: 562.5, settlementStatus: 'customer', mileageKm: 128560, creatorName: '张三', reconcileStatus: H2_RECONCILE_RECONCILED },
|
||||
{ key: 'rf-2', id: 'rf-2', stationId: 'JX-H2-001', stationName: '嘉兴加氢站(一期)', hydrogenTime: '2026-05-26 14:08:33', plateNo: '浙A67890F', customerName: '浙江绿运物流有限公司', hydrogenKg: 10.0, costUnitPrice: 42.5, costTotal: 425.0, customerUnitPrice: 45, customerAmount: 450.0, settlementStatus: 'internal', mileageKm: 95230, creatorName: '李四', reconcileStatus: H2_RECONCILE_RECONCILED },
|
||||
{ key: 'rf-3', id: 'rf-3', stationId: 'JX-H2-001', stationName: '嘉兴加氢站(一期)', hydrogenTime: '2026-05-22 09:15:00', plateNo: '浙A88888F', customerName: '嘉兴市鑫峤供应链科技有限公司', hydrogenKg: 18.3, costUnitPrice: 42.5, costTotal: 777.75, customerUnitPrice: 45, customerAmount: 823.5, settlementStatus: 'customer_self', mileageKm: 201880, creatorName: '王静', reconcileStatus: H2_RECONCILE_RECONCILED },
|
||||
{ key: 'rf-4', id: 'rf-4', stationId: 'JX-H2-001', stationName: '嘉兴加氢站(一期)', hydrogenTime: '2026-05-18 16:42:11', plateNo: '浙A03561F', customerName: '嘉兴港务氢能运输队', hydrogenKg: 15.6, costUnitPrice: 42.5, costTotal: 663.0, customerUnitPrice: 45, customerAmount: 702.0, settlementStatus: 'customer', mileageKm: 167420, creatorName: '张三', reconcileStatus: H2_RECONCILE_RECONCILED },
|
||||
{ key: 'rf-5', id: 'rf-5', stationId: 'HZ-H2-002', stationName: '杭州临平加氢站', hydrogenTime: '2026-05-30 09:30:22', plateNo: '浙B23456F', customerName: '杭州临平城配中心', hydrogenKg: 15.3, costUnitPrice: 43.0, costTotal: 657.9, customerUnitPrice: 46, customerAmount: 703.8, settlementStatus: 'internal', mileageKm: 143200, creatorName: '赵敏', reconcileStatus: H2_RECONCILE_RECONCILED },
|
||||
{ key: 'rf-6', id: 'rf-6', stationId: 'HZ-H2-002', stationName: '杭州临平加氢站', hydrogenTime: '2026-05-27 18:10:05', plateNo: '浙B99999F', customerName: '浙江氢运科技', hydrogenKg: 18.2, costUnitPrice: 43.0, costTotal: 782.6, customerUnitPrice: 46, customerAmount: 837.2, settlementStatus: 'customer_self', mileageKm: 189650, creatorName: '陈浩', reconcileStatus: H2_RECONCILE_RECONCILED },
|
||||
{ key: 'rf-7', id: 'rf-7', stationId: 'HZ-H2-002', stationName: '杭州临平加氢站', hydrogenTime: '2026-05-24 11:05:40', plateNo: '浙B58888F', customerName: '杭州临平城配中心', hydrogenKg: 11.8, costUnitPrice: 43.0, costTotal: 507.4, customerUnitPrice: 46, customerAmount: 542.8, settlementStatus: 'customer', mileageKm: 110340, creatorName: '李四', reconcileStatus: H2_RECONCILE_RECONCILED },
|
||||
{ key: 'rf-8', id: 'rf-8', stationId: 'SH-H2-003', stationName: '上海宝山加氢站', hydrogenTime: '2026-04-20 16:45:18', plateNo: '沪A88888F', customerName: '上海羚牛氢运', hydrogenKg: 8.0, costUnitPrice: 44.0, costTotal: 352.0, customerUnitPrice: 47, customerAmount: 376.0, settlementStatus: 'internal', mileageKm: 88420, creatorName: '王静', reconcileStatus: H2_RECONCILE_RECONCILED },
|
||||
{ key: 'rf-9', id: 'rf-9', stationId: 'SH-H2-003', stationName: '上海宝山加氢站', hydrogenTime: '2026-04-08 09:12:55', plateNo: '沪BDB9161F', customerName: '宝山园区试运车队', hydrogenKg: 9.5, costUnitPrice: 44.0, costTotal: 418.0, customerUnitPrice: 47, customerAmount: 446.5, settlementStatus: 'customer_self', mileageKm: 76500, creatorName: '张三', reconcileStatus: H2_RECONCILE_RECONCILED },
|
||||
{ key: 'rf-10', id: 'rf-10', stationId: 'SZ-H2-004', stationName: '苏州工业园区备用站', hydrogenTime: '2026-03-15 10:00:00', plateNo: '苏E33333F', customerName: '苏州试运客户', hydrogenKg: 6.2, costUnitPrice: 41.0, costTotal: 254.2, customerUnitPrice: 44, customerAmount: 272.8, settlementStatus: 'customer', mileageKm: 45210, creatorName: '赵敏', reconcileStatus: H2_RECONCILE_PENDING }
|
||||
/* 嘉兴本站 · 列表首页用例:未核对(无核对时间)→ 已核对未对账 → 已核对已对账 */
|
||||
{ key: 'rf-11', id: 'rf-11', stationId: 'JX-H2-001', stationName: '中国石油中油高新能源牙谷加油加氢站', hydrogenTime: '2026-07-16 09:20:18', plateNo: '浙A55666F', customerName: '中国石油中油高新能源牙谷加油加氢站·站端上报', hydrogenKg: 11.2, costUnitPrice: 42.5, costTotal: 476.0, customerUnitPrice: 42.5, customerAmount: 476.0, settlementStatus: 'customer', mileageKm: 132080, creatorName: '站端账号', verifyStatus: H2_VERIFY_UNVERIFIED, verifiedAt: null, reconcileStatus: H2_RECONCILE_PENDING },
|
||||
{ key: 'rf-12', id: 'rf-12', stationId: 'JX-H2-001', stationName: '中国石油中油高新能源牙谷加油加氢站', hydrogenTime: '2026-07-15 16:45:02', plateNo: '浙A77888F', customerName: '中国石油中油高新能源牙谷加油加氢站·站端上报', hydrogenKg: 9.8, costUnitPrice: 42.5, costTotal: 416.5, customerUnitPrice: 42.5, customerAmount: 416.5, settlementStatus: 'customer', mileageKm: null, creatorName: '站端账号', verifyStatus: H2_VERIFY_UNVERIFIED, verifiedAt: null, reconcileStatus: H2_RECONCILE_PENDING },
|
||||
{ key: 'rf-13', id: 'rf-13', stationId: 'JX-H2-001', stationName: '中国石油中油高新能源牙谷加油加氢站', hydrogenTime: '2026-07-14 11:08:40', plateNo: '浙A99001F', customerName: '嘉兴市鑫峤供应链科技有限公司', hydrogenKg: 14.0, costUnitPrice: 42.5, costTotal: 595.0, customerUnitPrice: 45, customerAmount: 630.0, settlementStatus: 'customer', mileageKm: 155200, creatorName: '站端账号', verifyStatus: H2_VERIFY_VERIFIED, verifiedAt: '2026-07-14 15:30:00', reconcileStatus: H2_RECONCILE_PENDING, reconcileDate: null },
|
||||
{ key: 'rf-1', id: 'rf-1', stationId: 'JX-H2-001', stationName: '中国石油中油高新能源牙谷加油加氢站', hydrogenTime: '2026-05-28 10:21:08', plateNo: '浙A12345F', customerName: '嘉兴市鑫峤供应链科技有限公司', hydrogenKg: 12.5, costUnitPrice: 42.5, costTotal: 531.25, customerUnitPrice: 45, customerAmount: 562.5, settlementStatus: 'customer', mileageKm: 128560, creatorName: '张三', verifyStatus: H2_VERIFY_VERIFIED, verifiedAt: '2026-05-29 10:00:00', reconcileStatus: H2_RECONCILE_RECONCILED, reconcileDate: '2026-05-29 10:00:00' },
|
||||
{ key: 'rf-2', id: 'rf-2', stationId: 'JX-H2-001', stationName: '中国石油中油高新能源牙谷加油加氢站', hydrogenTime: '2026-05-26 14:08:33', plateNo: '浙A67890F', customerName: '浙江绿运物流有限公司', hydrogenKg: 10.0, costUnitPrice: 42.5, costTotal: 425.0, customerUnitPrice: 45, customerAmount: 450.0, settlementStatus: 'internal', mileageKm: 95230, creatorName: '李四', verifyStatus: H2_VERIFY_VERIFIED, verifiedAt: '2026-05-27 10:00:00', reconcileStatus: H2_RECONCILE_RECONCILED, reconcileDate: '2026-05-27 10:00:00' },
|
||||
{ key: 'rf-3', id: 'rf-3', stationId: 'JX-H2-001', stationName: '中国石油中油高新能源牙谷加油加氢站', hydrogenTime: '2026-05-22 09:15:00', plateNo: '浙A88888F', customerName: '嘉兴市鑫峤供应链科技有限公司', hydrogenKg: 18.3, costUnitPrice: 42.5, costTotal: 777.75, customerUnitPrice: 45, customerAmount: 823.5, settlementStatus: 'customer_self', mileageKm: 201880, creatorName: '王静', verifyStatus: H2_VERIFY_VERIFIED, verifiedAt: '2026-05-23 10:00:00', reconcileStatus: H2_RECONCILE_RECONCILED, reconcileDate: '2026-05-23 10:00:00' },
|
||||
{ key: 'rf-4', id: 'rf-4', stationId: 'JX-H2-001', stationName: '中国石油中油高新能源牙谷加油加氢站', hydrogenTime: '2026-05-18 16:42:11', plateNo: '浙A03561F', customerName: '嘉兴港务氢能运输队', hydrogenKg: 15.6, costUnitPrice: 42.5, costTotal: 663.0, customerUnitPrice: 45, customerAmount: 702.0, settlementStatus: 'customer', mileageKm: 167420, creatorName: '张三', verifyStatus: H2_VERIFY_VERIFIED, verifiedAt: '2026-05-19 10:00:00', reconcileStatus: H2_RECONCILE_RECONCILED, reconcileDate: '2026-05-19 10:00:00' },
|
||||
{ key: 'rf-5', id: 'rf-5', stationId: 'HZ-H2-002', stationName: '杭州临平加氢站', hydrogenTime: '2026-05-30 09:30:22', plateNo: '浙B23456F', customerName: '杭州临平城配中心', hydrogenKg: 15.3, costUnitPrice: 43.0, costTotal: 657.9, customerUnitPrice: 46, customerAmount: 703.8, settlementStatus: 'internal', mileageKm: 143200, creatorName: '赵敏', verifyStatus: H2_VERIFY_VERIFIED, verifiedAt: '2026-05-31 10:00:00', reconcileStatus: H2_RECONCILE_RECONCILED, reconcileDate: '2026-05-31 10:00:00' },
|
||||
{ key: 'rf-6', id: 'rf-6', stationId: 'HZ-H2-002', stationName: '杭州临平加氢站', hydrogenTime: '2026-05-27 18:10:05', plateNo: '浙B99999F', customerName: '浙江氢运科技', hydrogenKg: 18.2, costUnitPrice: 43.0, costTotal: 782.6, customerUnitPrice: 46, customerAmount: 837.2, settlementStatus: 'customer_self', mileageKm: 189650, creatorName: '陈浩', verifyStatus: H2_VERIFY_VERIFIED, verifiedAt: '2026-05-28 10:00:00', reconcileStatus: H2_RECONCILE_RECONCILED, reconcileDate: '2026-05-28 10:00:00' },
|
||||
{ key: 'rf-7', id: 'rf-7', stationId: 'HZ-H2-002', stationName: '杭州临平加氢站', hydrogenTime: '2026-05-24 11:05:40', plateNo: '浙B58888F', customerName: '杭州临平城配中心', hydrogenKg: 11.8, costUnitPrice: 43.0, costTotal: 507.4, customerUnitPrice: 46, customerAmount: 542.8, settlementStatus: 'customer', mileageKm: 110340, creatorName: '李四', verifyStatus: H2_VERIFY_VERIFIED, verifiedAt: '2026-05-25 10:00:00', reconcileStatus: H2_RECONCILE_RECONCILED, reconcileDate: '2026-05-25 10:00:00' },
|
||||
{ key: 'rf-8', id: 'rf-8', stationId: 'SH-H2-003', stationName: '上海宝山加氢站', hydrogenTime: '2026-04-20 16:45:18', plateNo: '沪A88888F', customerName: '上海羚牛氢运', hydrogenKg: 8.0, costUnitPrice: 44.0, costTotal: 352.0, customerUnitPrice: 47, customerAmount: 376.0, settlementStatus: 'internal', mileageKm: 88420, creatorName: '王静', verifyStatus: H2_VERIFY_VERIFIED, verifiedAt: '2026-04-21 10:00:00', reconcileStatus: H2_RECONCILE_RECONCILED, reconcileDate: '2026-04-21 10:00:00' },
|
||||
{ key: 'rf-9', id: 'rf-9', stationId: 'SH-H2-003', stationName: '上海宝山加氢站', hydrogenTime: '2026-04-08 09:12:55', plateNo: '沪BDB9161F', customerName: '宝山园区试运车队', hydrogenKg: 9.5, costUnitPrice: 44.0, costTotal: 418.0, customerUnitPrice: 47, customerAmount: 446.5, settlementStatus: 'customer_self', mileageKm: 76500, creatorName: '张三', verifyStatus: H2_VERIFY_VERIFIED, verifiedAt: '2026-04-09 10:00:00', reconcileStatus: H2_RECONCILE_PENDING, reconcileDate: null },
|
||||
{ key: 'rf-10', id: 'rf-10', stationId: 'SZ-H2-004', stationName: '苏州工业园区备用站', hydrogenTime: '2026-03-15 10:00:00', plateNo: '苏E33333F', customerName: '苏州试运客户', hydrogenKg: 6.2, costUnitPrice: 41.0, costTotal: 254.2, customerUnitPrice: 44, customerAmount: 272.8, settlementStatus: 'customer', mileageKm: 45210, creatorName: '赵敏', verifyStatus: H2_VERIFY_UNVERIFIED, verifiedAt: null, reconcileStatus: H2_RECONCILE_PENDING }
|
||||
];
|
||||
|
||||
function h2BridgeCloneRows(rows) {
|
||||
@@ -38,6 +44,26 @@ function h2BridgeFormatDateTime(value) {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/** 已对账记录必须有对账时间;缺省时按加氢日次日 10:00:00 推导(原型演示) */
|
||||
function h2BridgeDeriveReconcileTime(hydrogenTime) {
|
||||
var s = h2BridgeFormatDateTime(hydrogenTime);
|
||||
var m = String(s).match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
if (!m) return '2026-01-01 10:00:00';
|
||||
var d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]) + 1, 10, 0, 0);
|
||||
var pad = function (n) { return n < 10 ? '0' + n : String(n); };
|
||||
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' 10:00:00';
|
||||
}
|
||||
|
||||
function h2BridgeResolveReconcileTime(row) {
|
||||
/* 对账时间仅来自对账单回写的 reconcileDate;勿用 reconciledAt(完成核对会写入) */
|
||||
var explicit = row.reconcileDate || '';
|
||||
if (explicit) return h2BridgeFormatDateTime(explicit);
|
||||
if (row.reconcileStatus === H2_RECONCILE_RECONCILED) {
|
||||
return h2BridgeDeriveReconcileTime(row.hydrogenTime);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function h2BridgeMatchStatementPatch(row, patch) {
|
||||
if (patch.ledgerId && row.ledgerSyncId && patch.ledgerId === row.ledgerSyncId) return true;
|
||||
var rowTime = h2BridgeFormatDateTime(row.hydrogenTime);
|
||||
@@ -66,7 +92,6 @@ function h2BridgeApplyStatementPatches(rows, patches) {
|
||||
receiptDate: patch.receiptDate,
|
||||
stationPaymentStatus: patch.paymentStatus || 'paid',
|
||||
paymentStatus: patch.paymentStatus || 'paid',
|
||||
reconciledAt: patch.reconcileDate,
|
||||
statementRecordId: patch.statementRecordId != null ? patch.statementRecordId : r.statementRecordId
|
||||
});
|
||||
});
|
||||
@@ -77,9 +102,71 @@ function h2BridgeBuildInitialRows() {
|
||||
return h2BridgeApplyStatementPatches(h2BridgeCloneRows(H2_CANONICAL_LEDGER_SEED), patches);
|
||||
}
|
||||
|
||||
function h2BridgePad2(n) {
|
||||
return n < 10 ? '0' + n : String(n);
|
||||
}
|
||||
|
||||
/** 自然日 YYYY-MM-DD(设备本地时区) */
|
||||
function h2BridgeTodayDateKey(d) {
|
||||
var x = d || new Date();
|
||||
return x.getFullYear() + '-' + h2BridgePad2(x.getMonth() + 1) + '-' + h2BridgePad2(x.getDate());
|
||||
}
|
||||
|
||||
function h2BridgeYesterdayDateKey() {
|
||||
var d = new Date();
|
||||
d.setDate(d.getDate() - 1);
|
||||
return h2BridgeTodayDateKey(d);
|
||||
}
|
||||
|
||||
function h2BridgeManualLedgerKey(stationId, ledgerDate) {
|
||||
return String(stationId || '').trim() + '|' + String(ledgerDate || '').trim();
|
||||
}
|
||||
|
||||
function h2BridgeResolveStationId(stationId, stationName) {
|
||||
var id = String(stationId || '').trim();
|
||||
if (id) return id;
|
||||
var name = String(stationName || '').trim();
|
||||
return H2_STATION_CODE_MAP[name] || '';
|
||||
}
|
||||
|
||||
/** 手工台账种子:昨日已上传;今日默认未上传,便于演示拦截新增 */
|
||||
function h2BridgeBuildInitialManualLedgers() {
|
||||
var yesterday = h2BridgeYesterdayDateKey();
|
||||
return [
|
||||
{
|
||||
id: 'ml-seed-yesterday',
|
||||
stationId: 'JX-H2-001',
|
||||
stationName: '中国石油中油高新能源牙谷加油加氢站',
|
||||
ledgerDate: yesterday,
|
||||
images: [
|
||||
{
|
||||
id: 'ml-img-seed-1',
|
||||
name: '昨日手工台账.jpg',
|
||||
dataUrl: '',
|
||||
placeholder: true,
|
||||
archived: true,
|
||||
uploadedAt: yesterday + ' 18:30:00'
|
||||
}
|
||||
],
|
||||
updatedAt: yesterday + ' 18:30:00'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function h2BridgeCloneManualLedgers(list) {
|
||||
return (list || []).map(function (item) {
|
||||
return Object.assign({}, item, {
|
||||
images: (item.images || []).map(function (img) { return Object.assign({}, img); })
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function h2BridgeCreateStore() {
|
||||
var listeners = [];
|
||||
var state = { rows: h2BridgeBuildInitialRows() };
|
||||
var state = {
|
||||
rows: h2BridgeBuildInitialRows(),
|
||||
manualLedgers: h2BridgeBuildInitialManualLedgers()
|
||||
};
|
||||
|
||||
function notify() {
|
||||
listeners.forEach(function (fn) {
|
||||
@@ -93,6 +180,7 @@ function h2BridgeCreateStore() {
|
||||
state.rows = h2BridgeCloneRows(rows);
|
||||
notify();
|
||||
},
|
||||
getManualLedgers: function () { return h2BridgeCloneManualLedgers(state.manualLedgers); },
|
||||
subscribe: function (fn) {
|
||||
listeners.push(fn);
|
||||
return function () {
|
||||
@@ -115,6 +203,7 @@ function h2BridgeCreateStore() {
|
||||
next.key = next.key || next.id;
|
||||
}
|
||||
if (!next.reconcileStatus) next.reconcileStatus = H2_RECONCILE_PENDING;
|
||||
if (!next.verifyStatus) next.verifyStatus = H2_VERIFY_UNVERIFIED;
|
||||
var found = false;
|
||||
state.rows = state.rows.map(function (r) {
|
||||
if ((r.id || r.key) === next.id || (r.id || r.key) === next.key) {
|
||||
@@ -137,6 +226,93 @@ function h2BridgeCreateStore() {
|
||||
var changed = state.rows.length !== before;
|
||||
if (changed) notify();
|
||||
return changed;
|
||||
},
|
||||
upsertManualLedger: function (input) {
|
||||
if (!input) return null;
|
||||
var stationId = h2BridgeResolveStationId(input.stationId, input.stationName);
|
||||
var stationName = String(input.stationName || '').trim();
|
||||
var ledgerDate = String(input.ledgerDate || h2BridgeTodayDateKey()).trim();
|
||||
if (!stationId || !ledgerDate) return null;
|
||||
var key = h2BridgeManualLedgerKey(stationId, ledgerDate);
|
||||
var existing = null;
|
||||
state.manualLedgers.forEach(function (item) {
|
||||
if (h2BridgeManualLedgerKey(item.stationId, item.ledgerDate) === key) existing = item;
|
||||
});
|
||||
function normalizeImage(img, i) {
|
||||
return {
|
||||
id: img.id || ('ml-img-' + Date.now() + '-' + i),
|
||||
name: String(img.name || ('台账照片' + (i + 1) + '.jpg')),
|
||||
dataUrl: String(img.dataUrl || ''),
|
||||
placeholder: Boolean(img.placeholder),
|
||||
archived: true,
|
||||
uploadedAt: img.uploadedAt || (ledgerDate + ' 12:00:00')
|
||||
};
|
||||
}
|
||||
/** 已归档图不可删:保留原有全部,仅追加新图 */
|
||||
var images = (existing && existing.images ? existing.images : []).map(function (img, i) {
|
||||
return normalizeImage(img, i);
|
||||
});
|
||||
var seen = {};
|
||||
images.forEach(function (img) { seen[img.id] = true; });
|
||||
(input.images || []).forEach(function (img, i) {
|
||||
var nextImg = normalizeImage(img, images.length + i);
|
||||
if (!nextImg.dataUrl && !nextImg.placeholder) return;
|
||||
if (seen[nextImg.id]) return;
|
||||
seen[nextImg.id] = true;
|
||||
images.push(nextImg);
|
||||
});
|
||||
if (!images.length) return null;
|
||||
var now = new Date();
|
||||
var nowStr = now.getFullYear() + '-' + h2BridgePad2(now.getMonth() + 1) + '-' + h2BridgePad2(now.getDate())
|
||||
+ ' ' + h2BridgePad2(now.getHours()) + ':' + h2BridgePad2(now.getMinutes()) + ':' + h2BridgePad2(now.getSeconds());
|
||||
var next = {
|
||||
id: 'ml-' + stationId + '-' + ledgerDate,
|
||||
stationId: stationId,
|
||||
stationName: stationName || stationId,
|
||||
ledgerDate: ledgerDate,
|
||||
images: images,
|
||||
updatedAt: nowStr
|
||||
};
|
||||
var found = false;
|
||||
state.manualLedgers = state.manualLedgers.map(function (item) {
|
||||
if (h2BridgeManualLedgerKey(item.stationId, item.ledgerDate) === key) {
|
||||
found = true;
|
||||
return next;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
if (!found) state.manualLedgers = [next].concat(state.manualLedgers);
|
||||
notify();
|
||||
return h2BridgeCloneManualLedgers([next])[0];
|
||||
},
|
||||
getManualLedger: function (stationIdOrName, ledgerDate) {
|
||||
var dateKey = String(ledgerDate || h2BridgeTodayDateKey()).trim();
|
||||
var sid = h2BridgeResolveStationId(stationIdOrName, stationIdOrName);
|
||||
if (!sid) sid = h2BridgeResolveStationId('', stationIdOrName);
|
||||
if (!sid) return null;
|
||||
var key = h2BridgeManualLedgerKey(sid, dateKey);
|
||||
var i;
|
||||
for (i = 0; i < state.manualLedgers.length; i++) {
|
||||
var item = state.manualLedgers[i];
|
||||
if (h2BridgeManualLedgerKey(item.stationId, item.ledgerDate) === key) {
|
||||
return h2BridgeCloneManualLedgers([item])[0];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
listManualLedgersByStation: function (stationIdOrName) {
|
||||
var sid = h2BridgeResolveStationId(stationIdOrName, stationIdOrName);
|
||||
if (!sid) sid = h2BridgeResolveStationId('', stationIdOrName);
|
||||
if (!sid) return [];
|
||||
return h2BridgeCloneManualLedgers(state.manualLedgers.filter(function (item) {
|
||||
return item.stationId === sid;
|
||||
})).sort(function (a, b) {
|
||||
return String(b.ledgerDate).localeCompare(String(a.ledgerDate));
|
||||
});
|
||||
},
|
||||
hasManualLedgerForDate: function (stationIdOrName, ledgerDate) {
|
||||
var entry = this.getManualLedger(stationIdOrName, ledgerDate);
|
||||
return Boolean(entry && entry.images && entry.images.length);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -193,8 +369,9 @@ function h2BridgeMapToHrRecord(row, index, fillerNames) {
|
||||
var costTotal = row.costTotal != null ? row.costTotal : row.costAmount;
|
||||
var unitPrice = row.costUnitPrice != null ? row.costUnitPrice : row.customerUnitPrice;
|
||||
var totalAmount = costTotal != null ? costTotal : row.customerAmount;
|
||||
var reconcileTime = row.reconcileDate || row.reconciledAt || '';
|
||||
var isReconciled = row.reconcileStatus === H2_RECONCILE_RECONCILED || Boolean(reconcileTime);
|
||||
var reconcileTime = h2BridgeResolveReconcileTime(row);
|
||||
var isReconciled = row.reconcileStatus === H2_RECONCILE_RECONCILED || Boolean(row.reconcileDate);
|
||||
var verifiedAt = row.verifiedAt || '';
|
||||
return {
|
||||
id: row.id || row.key || ('hr-' + (index + 1)),
|
||||
hydrogenTime: h2BridgeFormatDateTime(row.hydrogenTime),
|
||||
@@ -211,8 +388,10 @@ function h2BridgeMapToHrRecord(row, index, fillerNames) {
|
||||
settlementStatus: row.settlementStatus,
|
||||
stationName: row.stationName,
|
||||
stationCode: row.stationId || H2_STATION_CODE_MAP[row.stationName] || '',
|
||||
verifyStatus: row.verifyStatus === H2_VERIFY_VERIFIED ? H2_VERIFY_VERIFIED : H2_VERIFY_UNVERIFIED,
|
||||
verifiedAt: verifiedAt ? h2BridgeFormatDateTime(verifiedAt) : '',
|
||||
reconcileStatus: isReconciled ? H2_RECONCILE_RECONCILED : H2_RECONCILE_PENDING,
|
||||
reconcileTime: isReconciled ? h2BridgeFormatDateTime(reconcileTime) : ''
|
||||
reconcileTime: isReconciled ? reconcileTime : ''
|
||||
};
|
||||
}
|
||||
|
||||
@@ -299,6 +478,84 @@ function h2BridgeGetStationList() {
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 车牌写入口径:大写并补尾缀 F */
|
||||
function h2BridgeNormalizePlate(plateNo) {
|
||||
var plate = String(plateNo || '').trim().toUpperCase();
|
||||
if (plate && !/F$/u.test(plate)) plate += 'F';
|
||||
return plate;
|
||||
}
|
||||
|
||||
/** 时间写入口径:补全秒 */
|
||||
function h2BridgeNormalizeHydrogenTime(hydrogenTime) {
|
||||
var s = String(hydrogenTime || '').trim();
|
||||
if (s.length === 16) s += ':00';
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务主键:同站 + 同车牌 + 同加氢时间(精确到秒)视为重复。
|
||||
* @returns {object|null} 已存在行的浅拷贝,未命中返回 null
|
||||
*/
|
||||
function h2BridgeFindDuplicateRow(candidate, excludeId) {
|
||||
if (!candidate) return null;
|
||||
var store = h2BridgeGetStore();
|
||||
if (!store) return null;
|
||||
var station = String(candidate.stationName || '').trim();
|
||||
var plate = h2BridgeNormalizePlate(candidate.plateNo);
|
||||
var time = h2BridgeNormalizeHydrogenTime(candidate.hydrogenTime);
|
||||
if (!station || !plate || !time) return null;
|
||||
var exclude = excludeId != null ? String(excludeId) : '';
|
||||
var rows = store.getRows();
|
||||
var i;
|
||||
for (i = 0; i < rows.length; i++) {
|
||||
var row = rows[i];
|
||||
var id = String(row.id || row.key || '');
|
||||
if (exclude && id === exclude) continue;
|
||||
if (String(row.stationName || '').trim() !== station) continue;
|
||||
if (h2BridgeNormalizePlate(row.plateNo) !== plate) continue;
|
||||
if (h2BridgeNormalizeHydrogenTime(row.hydrogenTime) !== time) continue;
|
||||
return Object.assign({}, row);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 总额与「单价×量」偏差(元)。容差默认 0.05。
|
||||
* @returns {{ expected: number, actual: number, diff: number }|null}
|
||||
*/
|
||||
function h2BridgeTotalAmountMismatch(unitPrice, hydrogenKg, totalAmount, toleranceYuan) {
|
||||
var p = Number(unitPrice);
|
||||
var k = Number(hydrogenKg);
|
||||
var a = Number(totalAmount);
|
||||
if (!isFinite(p) || !isFinite(k) || !isFinite(a)) return null;
|
||||
var expected = Math.round(p * k * 100) / 100;
|
||||
var actual = Math.round(a * 100) / 100;
|
||||
var tol = toleranceYuan != null ? Number(toleranceYuan) : 0.05;
|
||||
if (!isFinite(tol)) tol = 0.05;
|
||||
var diff = Math.round(Math.abs(actual - expected) * 100) / 100;
|
||||
if (diff <= tol) return null;
|
||||
return { expected: expected, actual: actual, diff: diff };
|
||||
}
|
||||
|
||||
/** 取该站最近一条流水的站端口径单价,缺省 42.5 */
|
||||
function h2BridgeLookupStationUnitPrice(stationName) {
|
||||
var name = String(stationName || '').trim();
|
||||
if (!name) return 42.5;
|
||||
var store = h2BridgeGetStore();
|
||||
if (!store) return 42.5;
|
||||
var rows = store.getRows().slice().sort(function (a, b) {
|
||||
return String(b.hydrogenTime || '').localeCompare(String(a.hydrogenTime || ''));
|
||||
});
|
||||
var i;
|
||||
for (i = 0; i < rows.length; i++) {
|
||||
if (String(rows[i].stationName || '').trim() !== name) continue;
|
||||
var price = rows[i].costUnitPrice != null ? rows[i].costUnitPrice : rows[i].customerUnitPrice;
|
||||
var n = Number(price);
|
||||
if (isFinite(n) && n >= 0) return Math.round(n * 100) / 100;
|
||||
}
|
||||
return 42.5;
|
||||
}
|
||||
|
||||
function h2BridgeInit() {
|
||||
h2BridgeEnsureApi();
|
||||
var store = h2BridgeGetStore();
|
||||
@@ -311,6 +568,24 @@ function h2BridgeInit() {
|
||||
getHrRecords: h2BridgeGetHrRecords,
|
||||
getOrderRecords: h2BridgeGetOrderRecords,
|
||||
getStationList: h2BridgeGetStationList,
|
||||
normalizePlate: h2BridgeNormalizePlate,
|
||||
normalizeHydrogenTime: h2BridgeNormalizeHydrogenTime,
|
||||
findDuplicateRow: h2BridgeFindDuplicateRow,
|
||||
totalAmountMismatch: h2BridgeTotalAmountMismatch,
|
||||
lookupStationUnitPrice: h2BridgeLookupStationUnitPrice,
|
||||
todayDateKey: h2BridgeTodayDateKey,
|
||||
getManualLedger: function (stationIdOrName, ledgerDate) {
|
||||
return store.getManualLedger(stationIdOrName, ledgerDate);
|
||||
},
|
||||
listManualLedgersByStation: function (stationIdOrName) {
|
||||
return store.listManualLedgersByStation(stationIdOrName);
|
||||
},
|
||||
hasManualLedgerForDate: function (stationIdOrName, ledgerDate) {
|
||||
return store.hasManualLedgerForDate(stationIdOrName, ledgerDate);
|
||||
},
|
||||
upsertManualLedger: function (input) {
|
||||
return store.upsertManualLedger(input);
|
||||
},
|
||||
upsertRow: function (row) { return store.upsertRow(row); },
|
||||
removeRow: function (id) { return store.removeRow(id); },
|
||||
subscribe: function (fn) { return store.subscribe(fn); }
|
||||
@@ -326,6 +601,8 @@ export {
|
||||
H2_STATION_CODE_MAP,
|
||||
H2_RECONCILE_RECONCILED,
|
||||
H2_RECONCILE_PENDING,
|
||||
H2_VERIFY_VERIFIED,
|
||||
H2_VERIFY_UNVERIFIED,
|
||||
h2BridgeInit,
|
||||
h2BridgeGetStore,
|
||||
h2BridgeGetAllRefuelRecords,
|
||||
@@ -333,5 +610,10 @@ export {
|
||||
h2BridgeBuildStationLedgerStore,
|
||||
h2BridgeGetHrRecords,
|
||||
h2BridgeGetOrderRecords,
|
||||
h2BridgeGetStationList
|
||||
h2BridgeGetStationList,
|
||||
h2BridgeNormalizePlate,
|
||||
h2BridgeNormalizeHydrogenTime,
|
||||
h2BridgeFindDuplicateRow,
|
||||
h2BridgeTotalAmountMismatch,
|
||||
h2BridgeLookupStationUnitPrice
|
||||
};
|
||||
|
||||
45
src/prototypes/lease-business-detail/.spec/import-rules.md
Normal file
45
src/prototypes/lease-business-detail/.spec/import-rules.md
Normal 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` 中注释停用,函数保留签名便于对照。
|
||||
|
||||
---
|
||||
|
||||
## 与收款状态的关系
|
||||
|
||||
收款状态仍按实收与应收比较展示(未收款 / 部分收款 / 已结清);实收 ≥ 应收合计时为「已结清」,与是否允许多收不冲突。
|
||||
@@ -34,8 +34,8 @@
|
||||
|
||||
| 场景 | 现状 |
|
||||
|------|------|
|
||||
| 重复导入 | 跳过 / 覆盖 / 报错 + 摘要 Toast |
|
||||
| 实收超额 | 导入/编辑拦截;列表负未收警示 |
|
||||
| 重复导入 | 跳过 / 覆盖 / 报错;主键 year-month-plate-customer |
|
||||
| 实收超额 | **允许**;导入/编辑不拦截;列表负未收/超额样式 |
|
||||
| 行内操作 | 编辑、删除、变更日志 |
|
||||
|
||||
## 证据与评估说明
|
||||
@@ -49,6 +49,6 @@
|
||||
|---|---|---|
|
||||
| P1 | 缺少盈亏月度汇总 Tab | ➡️ 改由业务部台账;本页已移除 Tab |
|
||||
| P1 | 对账/锁定无入口 | ➡️ 产品范围调整:本页不做对账锁定 |
|
||||
| P2 | 导入重复键 | ✅ 三策略 + 主键 year-month-plate |
|
||||
| P2 | 实收超额 | ✅ 导入/编辑校验 + 负未收样式 |
|
||||
| P2 | 导入重复键 | ✅ 三策略 + 主键 year-month-plate-**customer** |
|
||||
| P2 | 实收超额 | ➡️ 产品调整:**允许**超额;拦截已停用 |
|
||||
| P3 | PRD/annotation 偏差 | ✅ 公式、导出 48 列、边界文案已更新 |
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
|
||||
### 导入重复策略
|
||||
|
||||
业务主键:`年份 + 月份 + 车牌号码`。
|
||||
业务主键:`年份 + 月份 + 车牌号码 + 客户名称`(完整规则见 **`.spec/import-rules.md`**)。
|
||||
|
||||
| 策略 | 行为 |
|
||||
|---|---|
|
||||
@@ -87,7 +87,9 @@
|
||||
| 覆盖重复 | 用导入行覆盖已有记录 |
|
||||
| 报错 | 重复行写入错误日志 |
|
||||
|
||||
导入与编辑均校验:**实收金额 ≤ 应收合计**。
|
||||
### 实收金额
|
||||
|
||||
**不限制**实收 ≤ 应收合计:导入与编辑均允许实收大于应收合计(未收可为负,列表超额样式提示)。旧规则已停用,见 `.spec/import-rules.md`。
|
||||
|
||||
---
|
||||
|
||||
@@ -179,9 +181,10 @@
|
||||
| 筛选 / KPI 汇总 | `utils/detail.ts` |
|
||||
| 收款状态 | `utils/payment-status.ts` |
|
||||
| 公式计算 | `utils/calc-detail.ts` |
|
||||
| 导入导出 | `utils/batch-io.ts`、`utils/import-merge.ts` |
|
||||
| 导入导出 | `utils/batch-io.ts`、`utils/import-merge.ts`、`utils/row-key.ts` |
|
||||
| 单元格校验 | `utils/field-checks.ts`、`utils/system-ref.ts`、`components/FieldCheckIcon.tsx` |
|
||||
| 保存校验 | `utils/validate-detail.ts` |
|
||||
| 保存校验 | `utils/validate-detail.ts`(实收超额拦截已停用) |
|
||||
| 导入规则说明 | `.spec/import-rules.md` |
|
||||
| 变更日志 | `utils/change-log.ts` |
|
||||
| 类型 | `types.ts` |
|
||||
| 组件 | `FilterPanel.tsx`、`DetailKpiRow.tsx`、`DetailTable.tsx`、`DetailImportModal.tsx`、`DetailEditModal.tsx`、`DetailChangeLogModal.tsx` |
|
||||
@@ -196,6 +199,8 @@
|
||||
4. 氢费非零可点击明细;编辑/删除/变更日志可用
|
||||
5. 未收 > 0 标红;盈亏正负着色
|
||||
6. 客户名称等校验列:通过显示绿勾,不一致显示黄警告并可查看说明 / 一键替换(规则见 `.spec/field-checks.md`)
|
||||
7. 导入主键含客户名称;同车牌同月不同客户可并存
|
||||
8. 实收可大于应收合计:导入/编辑不拦截;未收可为负并有超额样式
|
||||
|
||||
---
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -54,7 +54,7 @@ export function DetailEditModal({ open, row, onClose, onSave }: DetailEditModalP
|
||||
</button>
|
||||
</header>
|
||||
<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}
|
||||
<div className="lbd-edit-grid">
|
||||
{DETAIL_EDIT_FIELDS.map((field) => (
|
||||
|
||||
@@ -16,7 +16,7 @@ interface DetailImportModalProps {
|
||||
}
|
||||
|
||||
const DUPLICATE_OPTIONS: { value: DetailImportDuplicateStrategy; label: string; hint: string }[] = [
|
||||
{ value: 'skip', label: '跳过重复', hint: '同「年份+月份+车牌」已存在则跳过' },
|
||||
{ value: 'skip', label: '跳过重复', hint: '同「年份+月份+车牌+客户名称」已存在则跳过' },
|
||||
{ value: 'overwrite', label: '覆盖重复', hint: '用导入行覆盖已有记录' },
|
||||
{ value: 'error', label: '报错', hint: '重复行写入错误日志' },
|
||||
];
|
||||
@@ -66,7 +66,7 @@ export function DetailImportModal({
|
||||
<section className="vm-import-step">
|
||||
<div className="vm-import-step-head">
|
||||
<span className="vm-step-badge">2</span>
|
||||
<span>重复记录处理(主键:年份 + 月份 + 车牌)</span>
|
||||
<span>重复记录处理(主键:年份 + 月份 + 车牌 + 客户名称)</span>
|
||||
</div>
|
||||
<div className="lbd-import-dup-options" role="radiogroup" aria-label="重复记录处理">
|
||||
{DUPLICATE_OPTIONS.map((option) => (
|
||||
|
||||
@@ -10,10 +10,12 @@ const root = join(__dirname, '..');
|
||||
const jsonPath = join(root, 'annotation-source.json');
|
||||
const prdPath = join(root, '.spec/requirements-prd.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 prd = readFileSync(prdPath, 'utf8');
|
||||
const fieldChecksMd = readFileSync(fieldChecksPath, 'utf8');
|
||||
const importRulesMd = readFileSync(importRulesPath, 'utf8');
|
||||
|
||||
for (const node of doc.data.nodes) {
|
||||
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. 公式列(系统自动计算)
|
||||
|
||||
| 字段 | 计算方式 |
|
||||
@@ -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'],
|
||||
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',
|
||||
id: 'lbd-doc-source',
|
||||
@@ -233,10 +248,11 @@ doc.directory.nodes = [
|
||||
| 筛选 / KPI 汇总 | \`utils/detail.ts\` |
|
||||
| 收款状态 | \`utils/payment-status.ts\` |
|
||||
| 公式计算 | \`utils/calc-detail.ts\` |
|
||||
| 导入导出 | \`utils/batch-io.ts\`、\`utils/import-merge.ts\` |
|
||||
| 导入导出 | \`utils/batch-io.ts\`、\`utils/import-merge.ts\`、\`utils/row-key.ts\` |
|
||||
| 单元格校验 | \`utils/field-checks.ts\`、\`utils/system-ref.ts\`、\`components/FieldCheckIcon.tsx\` |
|
||||
| 保存校验 | \`utils/validate-detail.ts\` |
|
||||
| 保存校验 | \`utils/validate-detail.ts\`(实收超额拦截已停用) |
|
||||
| 校验说明 | \`.spec/field-checks.md\` |
|
||||
| 导入规则 | \`.spec/import-rules.md\` |
|
||||
| 变更日志 | \`utils/change-log.ts\` |
|
||||
| 类型 | \`types.ts\` |
|
||||
| 组件 | \`FilterPanel.tsx\`、\`DetailKpiRow.tsx\`、\`DetailTable.tsx\`、\`DetailImportModal.tsx\`、\`DetailEditModal.tsx\`、\`DetailChangeLogModal.tsx\` |
|
||||
|
||||
@@ -4,7 +4,6 @@ import { DETAIL_EXPORT_HEADERS, DETAIL_EXPORT_SPECS } from '../components/tableC
|
||||
import { applyDetailCalculations } from './calc-detail';
|
||||
import { getContractRef, getVehicleRef, isPlateInVehicleSystem } from './system-ref';
|
||||
import { resolveDetailPaymentStatus } from './payment-status';
|
||||
import { validateDetailRowAmounts } from './validate-detail';
|
||||
|
||||
/** 导入模板列(不含公式列;业务部门/业务员由系统合同反写) */
|
||||
export const DETAIL_IMPORT_TEMPLATE_HEADERS = [
|
||||
@@ -373,12 +372,7 @@ export async function parseDetailImportFile(file: File): Promise<DetailImportPar
|
||||
}
|
||||
|
||||
const calculated = applyDetailCalculations(draft);
|
||||
const amountReason = validateDetailRowAmounts(calculated);
|
||||
if (amountReason) {
|
||||
errors.push({ reason: amountReason, rawCells });
|
||||
return;
|
||||
}
|
||||
|
||||
// 实收 ≤ 应收合计:已停用,允许实收大于应收合计
|
||||
success.push(calculated);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { LeaseBusinessDetailRow } from '../types';
|
||||
import type { DetailImportErrorRow } from './batch-io';
|
||||
import { detailRowBusinessKey } from './row-key';
|
||||
import { validateDetailRowAmounts } from './validate-detail';
|
||||
import { detailRowBusinessKey, detailRowBusinessKeyLabel } from './row-key';
|
||||
|
||||
export type DetailImportDuplicateStrategy = 'skip' | 'overwrite' | 'error';
|
||||
|
||||
@@ -11,6 +10,7 @@ export interface DetailImportMergeResult {
|
||||
overwritten: number;
|
||||
overwrittenIds: string[];
|
||||
duplicateErrors: DetailImportErrorRow[];
|
||||
/** 历史字段:实收超额校验已停用,始终为空 */
|
||||
amountErrors: DetailImportErrorRow[];
|
||||
}
|
||||
|
||||
@@ -37,12 +37,7 @@ export function mergeDetailImportRows(
|
||||
const amountErrors: DetailImportErrorRow[] = [];
|
||||
|
||||
imported.forEach((row) => {
|
||||
const amountReason = validateDetailRowAmounts(row);
|
||||
if (amountReason) {
|
||||
amountErrors.push({ reason: amountReason, rawCells: emptyRawCells() });
|
||||
return;
|
||||
}
|
||||
|
||||
// 实收 ≤ 应收合计:已停用,允许实收大于应收合计
|
||||
const key = detailRowBusinessKey(row);
|
||||
let existingIndex = keyToIndex.get(key);
|
||||
|
||||
@@ -62,7 +57,7 @@ export function mergeDetailImportRows(
|
||||
}
|
||||
if (strategy === 'error') {
|
||||
duplicateErrors.push({
|
||||
reason: `重复记录:${row.year}年${row.month}月 · ${row.plateNo} 与本批导入重复`,
|
||||
reason: `重复记录:${detailRowBusinessKeyLabel(row)} 与本批导入重复`,
|
||||
rawCells: emptyRawCells(),
|
||||
});
|
||||
return;
|
||||
@@ -80,7 +75,7 @@ export function mergeDetailImportRows(
|
||||
|
||||
if (strategy === 'error') {
|
||||
duplicateErrors.push({
|
||||
reason: `重复记录:${row.year}年${row.month}月 · ${row.plateNo} 已存在`,
|
||||
reason: `重复记录:${detailRowBusinessKeyLabel(row)} 已存在`,
|
||||
rawCells: emptyRawCells(),
|
||||
});
|
||||
return;
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import type { LeaseBusinessDetailRow } from '../types';
|
||||
|
||||
/** 业务主键:年份 + 月份 + 车牌号码 */
|
||||
/** 业务主键:年份 + 月份 + 车牌号码 + 客户名称 */
|
||||
export function detailRowBusinessKey(
|
||||
row: Pick<LeaseBusinessDetailRow, 'year' | 'month' | 'plateNo'>,
|
||||
row: Pick<LeaseBusinessDetailRow, 'year' | 'month' | 'plateNo' | 'customerName'>,
|
||||
): 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() || '(无客户)'}`;
|
||||
}
|
||||
|
||||
@@ -3,14 +3,19 @@ import { applyDetailCalculations } from './calc-detail';
|
||||
|
||||
const MONEY_EPS = 0.005;
|
||||
|
||||
/** 实收不得超过应收合计(允许分位误差) */
|
||||
/**
|
||||
* 【已停用】原规则:实收金额不得超过应收合计。
|
||||
* 产品已确认:实收金额可以大于应收合计(多收/预收场景),导入与编辑均不再拦截。
|
||||
* 保留函数签名供对照历史逻辑;当前始终返回 null。
|
||||
*/
|
||||
export function validateReceivedAgainstReceivable(
|
||||
receivableTotal: number,
|
||||
receivedAmount: number,
|
||||
_receivableTotal: number,
|
||||
_receivedAmount: number,
|
||||
): string | null {
|
||||
if (receivedAmount > receivableTotal + MONEY_EPS) {
|
||||
return `实收金额(${receivedAmount.toFixed(2)})不能大于应收合计(${receivableTotal.toFixed(2)})`;
|
||||
}
|
||||
// if (receivedAmount > receivableTotal + MONEY_EPS) {
|
||||
// return `实收金额(${receivedAmount.toFixed(2)})不能大于应收合计(${receivableTotal.toFixed(2)})`;
|
||||
// }
|
||||
void MONEY_EPS;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -18,7 +23,7 @@ export function validateDetailRowAmounts(row: LeaseBusinessDetailRow): string |
|
||||
return validateReceivedAgainstReceivable(row.receivableTotal, row.receivedAmount);
|
||||
}
|
||||
|
||||
/** 编辑保存前:合并 patch 并重算后校验 */
|
||||
/** 编辑保存前:合并 patch 并重算后校验(当前无金额上限拦截) */
|
||||
export function validateDetailPatch(
|
||||
before: LeaseBusinessDetailRow,
|
||||
patch: Partial<LeaseBusinessDetailRow>,
|
||||
|
||||
46
src/prototypes/oneos-h5-h2-order/.spec/feature-create.md
Normal file
46
src/prototypes/oneos-h5-h2-order/.spec/feature-create.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# 功能:新增加氢记录
|
||||
|
||||
## 目标
|
||||
|
||||
站端通过手机浏览器上报一笔加氢记录:拍照识别 + 可校正,提交写入共享氢费 Store。
|
||||
|
||||
## 表单分区
|
||||
|
||||
### 1. 基础信息
|
||||
|
||||
| 字段 | 规则 |
|
||||
|------|------|
|
||||
| 加氢时间 * | 默认=点击「新增」时的当前时间;点击底部弹层选日期+时+分 |
|
||||
| 加氢站名称 | 只读,登录本站名称 |
|
||||
|
||||
### 2. 车牌识别
|
||||
|
||||
| 字段 | 规则 |
|
||||
|------|------|
|
||||
| 拍摄区 | 提示「请拍摄车牌」;相册/相机;OCR 模拟回填车牌 |
|
||||
| 车牌号 * | 占位「请拍摄或输入车牌号」;点击弹出车牌键盘 |
|
||||
| 车辆标签 | 有车牌后:系统车辆列表命中→「羚牛车辆」,否则→「非羚牛车辆」 |
|
||||
| 里程(km) | 选填 |
|
||||
|
||||
### 3. 加氢机面板
|
||||
|
||||
| 字段 | 规则 |
|
||||
|------|------|
|
||||
| 拍摄区 | 提示「请拍摄清晰的加氢机照片」;OCR 模拟回填单价、量、总额 |
|
||||
| 氢气单价 * | 可改;变更后重算总额 |
|
||||
| 加氢量 * | 可改;变更后重算总额 |
|
||||
| 加氢总额(元) | 只读;= 单价 × 加氢量(两位小数);界面不展示公式文案 |
|
||||
|
||||
## 提交与拦截
|
||||
|
||||
- 必填齐全方可提交
|
||||
- 同站 + 同车牌 + 同加氢时间 → 拦截重复
|
||||
- 有填写内容点返回 → 二次确认防丢失
|
||||
- 已核对或已对账记录不可从此入口编辑(列表进详情后按钮隐藏)
|
||||
|
||||
## 验收
|
||||
|
||||
1. 时间默认正确且可选到分
|
||||
2. 车牌键盘可用;标签随车牌变化
|
||||
3. 改单价或量后总额自动更新且不可手改
|
||||
4. 空表单可直接返回;有内容返回需确认
|
||||
35
src/prototypes/oneos-h5-h2-order/.spec/feature-detail.md
Normal file
35
src/prototypes/oneos-h5-h2-order/.spec/feature-detail.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# 功能:详情、编辑与删除
|
||||
|
||||
## 目标
|
||||
|
||||
查看单笔加氢记录完整字段;在「未核对且未对账」时可编辑或删除。
|
||||
|
||||
## 详情展示
|
||||
|
||||
- 顶部:车牌、加氢时间、核对标签、对账标签
|
||||
- 指标条:加氢总额、加氢量
|
||||
- 明细行:站点、车牌、里程、单价、核对/对账状态与时间
|
||||
|
||||
## 锁定规则
|
||||
|
||||
| 条件 | 结果 |
|
||||
|------|------|
|
||||
| 已对账 | 不可编辑/删除;提示已纳入站点对账单 |
|
||||
| 已核对(未对账) | 不可编辑/删除;提示能源部已核对 |
|
||||
| 未核对且未对账 | 可编辑、可删除 |
|
||||
|
||||
判定函数:`isOrderLocked`(核对或对账任一成立即锁)。
|
||||
|
||||
## 编辑
|
||||
|
||||
进入与「新增」同一套表单,预填当前值;提交走更新逻辑(仍校验重复键)。
|
||||
|
||||
## 删除
|
||||
|
||||
二次确认后从 Bridge 移除该行。
|
||||
|
||||
## 验收
|
||||
|
||||
1. 未核对未对账:底部有删除/编辑
|
||||
2. 已核对或已对账:无操作栏,有锁定说明
|
||||
3. 详情含对账信息;列表卡片不含对账
|
||||
40
src/prototypes/oneos-h5-h2-order/.spec/feature-list.md
Normal file
40
src/prototypes/oneos-h5-h2-order/.spec/feature-list.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# 功能:本站列表与 KPI
|
||||
|
||||
## 目标
|
||||
|
||||
站端人员打开「加氢订单」后,一眼看到本站汇总与近期加氢记录,点击卡片进入详情。
|
||||
|
||||
## 页面结构
|
||||
|
||||
1. **当前站点**(只读):登录账号对应加氢站名称,无切站入口
|
||||
2. **KPI 三卡**:订单数 / 加氢量(kg) / 加氢金额
|
||||
3. **加氢记录列表**:按加氢时间倒序
|
||||
4. **底栏「新增」**:固定不遮挡卡片滚动区
|
||||
|
||||
## 列表卡片字段
|
||||
|
||||
| 展示 | 说明 |
|
||||
|------|------|
|
||||
| 车牌号 | 去掉尾缀 F 展示 |
|
||||
| 核对标签 | 未核对(橙)/ 已核对(绿) |
|
||||
| 加氢时间 | 精确到分 |
|
||||
| 加氢量 / 加氢总额 | 等宽数字 |
|
||||
| 单价 | 页脚左侧 |
|
||||
| 核对时间 | 仅已核对时页脚右侧显示 |
|
||||
|
||||
**不展示**:里程、对账状态(对账在站点对账单侧完成)。
|
||||
|
||||
## 交互
|
||||
|
||||
- 点击卡片 → 详情页
|
||||
- 点击底栏「新增」→ 新增表单(时间默认=点击时刻)
|
||||
|
||||
## 数据范围
|
||||
|
||||
仅 `stationName === 当前登录站` 的 Bridge 台账行。原型默认站:中国石油中油高新能源牙谷加油加氢站。
|
||||
|
||||
## 验收
|
||||
|
||||
1. 无切站控件
|
||||
2. KPI 与列表条数一致
|
||||
3. 滚动列表时底栏「新增」仍固定且不压住末条卡片
|
||||
@@ -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,一端上传另一端可见(同会话)
|
||||
30
src/prototypes/oneos-h5-h2-order/.spec/fleet-plate-tag.md
Normal file
30
src/prototypes/oneos-h5-h2-order/.spec/fleet-plate-tag.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# 车牌「羚牛车辆 / 非羚牛车辆」标签
|
||||
|
||||
原型本地判定,未接真实车辆管理 API。
|
||||
|
||||
## 判定顺序
|
||||
|
||||
| 优先级 | 条件 | 结果 |
|
||||
|--------|------|------|
|
||||
| 1 | 车牌为空 | 不展示标签 |
|
||||
| 2 | 规范化后车牌 ∈ 本地系统车辆集合 | **羚牛车辆** |
|
||||
| 3 | 有车牌但不在集合中 | **非羚牛车辆** |
|
||||
|
||||
## 前置条件
|
||||
|
||||
- 识别(OCR)或键盘输入得到车牌后才展示。
|
||||
- 比较前去掉尾缀 `F`,统一大写。
|
||||
|
||||
## 数据源
|
||||
|
||||
- `utils/fleet-plates.ts` 内本地种子车牌集合(含加氢 Bridge 演示车牌与部分车辆管理样例)。
|
||||
- 正式环境应改为查询车辆管理 / 系统车辆列表接口。
|
||||
|
||||
## 用户可见结果
|
||||
|
||||
- 绿色标签「羚牛车辆」或橙色标签「非羚牛车辆」。
|
||||
- 不拦截提交(仅提示归属)。
|
||||
|
||||
## 代码路径
|
||||
|
||||
- `getVehicleTag` / `isLingniuVehicle` → `CreateWizard` 车牌行右侧标签。
|
||||
@@ -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**) |
|
||||
| 双端一致 | H5「加氢订单」与 Web「加氢记录」读写同一 Store、同一站端口径字段;Web 另有导出 / 预约加氢 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 判定优先级
|
||||
## 1. 两套状态(判定优先级)
|
||||
|
||||
| 优先级 | 条件 | 站端 H5 展示 |
|
||||
|---|---|---|
|
||||
| 1 | `reconcileStatus === 'reconciled'` 或已写 `reconcileDate`(对账单提交回写) | **已对账**;对账时间取 `reconcileDate` / `reconciledAt`,格式 `YYYY-MM-DD HH:mm` |
|
||||
| 2 | 否则 | **未对账**;对账时间显示「—」 |
|
||||
| 优先级 | 状态 | 字段 | 谁触发 | 用户可见 |
|
||||
|---|---|---|---|---|
|
||||
| 1 | **核对** | `verifyStatus` / `verifiedAt` | 车辆氢费明细 · **完成核对** | 未核对 / 已核对 + 核对时间 |
|
||||
| 2 | **对账** | `reconcileStatus` / `reconcileDate` | 站点信息 · **对账单提交** | 未对账 / 已对账 + 对账时间 |
|
||||
|
||||
## 2. 前置条件
|
||||
> `reconciledAt` 仅作完成核对时的兼容写入,**不得**再用于判定「已对账」。
|
||||
|
||||
| 场景 | 是否校验 / 联动 |
|
||||
|---|---|
|
||||
| 未打开站点信息、未提交对账单 | 种子里已对账记录仍显示已对账;H5 新增默认为未对账 |
|
||||
| 记录已绑定 `statementRecordId` | 不再进入下一张对账单 |
|
||||
| 切换演示站点 | 仅过滤 `stationName`,不对其他站回写 |
|
||||
|
||||
## 3. 数据流
|
||||
## 2. 数据流
|
||||
|
||||
```text
|
||||
站端 H5 加氢订单 / Web 加氢记录 新增/编辑
|
||||
→ Bridge.upsertRow(reconcileStatus=pending,成本侧单价/量/总额)
|
||||
→ 共享 Store 通知订阅方(双端列表同步)
|
||||
站端 H5 / Web 新增加氢记录
|
||||
→ Bridge.upsertRow(verify=unverified,reconcile=pending;成本单价/量/总额)
|
||||
→ 车辆氢费明细可见
|
||||
|
||||
站点信息 · 生成对账单
|
||||
→ 筛选:本站 ∧ 无 statementRecordId ∧ (pending|reconciled) ∧ 账期内
|
||||
→ 提交:写 statementRecordId、reconcileStatus=reconciled、reconcileDate
|
||||
→ applyPatches → H5 / Web 同步「已对账」+ 对账时间
|
||||
能源部 · 完成核对
|
||||
→ verifyStatus=verified + verifiedAt
|
||||
→ 站端同步「已核对」,禁改删
|
||||
|
||||
站点信息 · 对账单提交(仅已核对 ∧ 未对账)
|
||||
→ reconcileStatus=reconciled + reconcileDate + statementRecordId
|
||||
→ 站端 / 台账同步「已对账」
|
||||
```
|
||||
|
||||
## 4. 用户可见结果
|
||||
## 3. 用户可见结果(H5)
|
||||
|
||||
| 结果 | 文案 / 行为 |
|
||||
| 结果 | 行为 |
|
||||
|---|---|
|
||||
| 未对账 | 标签「未对账」;可编辑、删除 |
|
||||
| 已对账 | 标签「已对账」;详情只读,提示「已纳入站点对账单,不可修改」 |
|
||||
| 提交对账单成功后 | 同步出现对账时间(操作完成时间) |
|
||||
| 未核对 | 可编辑、删除 |
|
||||
| 已核对未对账 | 只读;提示能源部已核对 |
|
||||
| 已对账 | 只读;提示已纳入对账单 |
|
||||
|
||||
## 5. 边界
|
||||
## 4. 边界
|
||||
|
||||
- 站端口径单价/总额映射 `costUnitPrice` / `costTotal`(加氢机成本侧)
|
||||
- OCR 为模拟识别,不解析真实图片数字
|
||||
- 跨页联动依赖同浏览器会话内未刷新导致的 Store 重建场景有限:刷新后以种子 + `H2_STATION_STATEMENT_LEDGER_UPDATES` 队列重建
|
||||
- 站端上报默认未核对、未对账
|
||||
- 对账时间只认 `reconcileDate`(对账单回写)
|
||||
- 跨页联动依赖同浏览器会话共享 Store
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
# 加氢订单(H5)— 产品需求文档
|
||||
# 加氢订单(H5)— 产品需求文档(PRD)
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 文档版本 | v1.0 |
|
||||
| 文档版本 | v1.3 |
|
||||
| 模块名称 | 加氢站管理 → 加氢订单 |
|
||||
| 所属系统 | ONE-OS(加氢站手机浏览器) |
|
||||
| 交互原型 | `/prototypes/oneos-h5-h2-order` |
|
||||
| 设计基底 | 小羚羚 `xll-miniapp/DESIGN.md` |
|
||||
| 关联模块 | [站点信息](/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. 背景与目标
|
||||
|
||||
采购创建加氢站并绑定供应商、开放系统账号后,加氢站人员通过手机浏览器登录 OneOS,在「加氢订单」模块上报加氢记录。Web「加氢记录」为同一业务的 PC 入口(数据结构一致;PC 另有导出与预约加氢)。
|
||||
采购创建加氢站并绑定供应商、开放系统账号后,加氢站人员通过手机浏览器登录 OneOS,在「加氢订单」模块上报加氢记录。Web「加氢记录」为同一业务的 PC 入口(共享 Bridge 数据;PC 另有导出与预约加氢)。
|
||||
|
||||
每日须上传**手工台账**照片,作为电子记录的核对依据;未上传当日手工台账时不可新增加氢记录。手工台账页提供月历,标出已传/未传日期,支持补传过往缺口。
|
||||
|
||||
### 1.1 目标用户
|
||||
|
||||
@@ -23,16 +23,18 @@
|
||||
|
||||
### 1.2 功能清单
|
||||
|
||||
| # | 功能 | 说明 |
|
||||
| # | 功能 | 说明文档 |
|
||||
|---|---|---|
|
||||
| 1 | 本站列表 | 仅当前站;KPI:订单数 / 加氢量 / 加氢金额;字段见下表 |
|
||||
| 2 | OCR 新增 | 单表单:拍车牌 / 拍面板 OCR 可校正后提交;返回时非空二次确认 |
|
||||
| 3 | 未对账编辑/删除 | 已对账只读 |
|
||||
| 4 | 对账单联动 | 共享氢费 Store;对账后状态与时间回写 |
|
||||
| 1 | 本站列表与 KPI | [feature-list.md](./feature-list.md) |
|
||||
| 2 | 新增加氢记录 | [feature-create.md](./feature-create.md) |
|
||||
| 3 | 详情 / 编辑 / 删除 | [feature-detail.md](./feature-detail.md) |
|
||||
| 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 列表字段
|
||||
|
||||
加氢时间、加氢站名称、车牌号、氢气单价(元/kg)、加氢量、加氢总额、对账状态、对账时间(`YYYY-MM-DD HH:mm`)。
|
||||
加氢时间、车牌号、氢气单价、加氢量、加氢总额、核对状态;已核对时展示核对时间。里程在新增/详情展示(选填),列表不展示。列表不展示对账状态。
|
||||
|
||||
### 1.4 不做
|
||||
|
||||
@@ -43,10 +45,12 @@
|
||||
## 2. 验收项
|
||||
|
||||
1. 菜单位于 OneOS → 加氢站管理 → 加氢订单
|
||||
2. 默认已登录;可切换演示站点;列表仅本站
|
||||
3. FAB 进入单表单新增;OCR 可校正;时间默认当前;站名只读;有内容返回二次确认
|
||||
4. 已对账不可编辑/删除;未对账可
|
||||
5. 站点信息提交对账单后,本页对应记录显示「已对账」+ 对账时间(同会话 Store)
|
||||
2. 默认已登录本站;列表仅本站(无切站)
|
||||
3. 底栏 Tab:加氢订单 / 手工台账;月历可区分已传/未传并补传过往日;未上传今日手工台账不可新增;上传后可新增;时间默认点击新增时刻(到分)
|
||||
4. 已核对或已对账不可编辑/删除;未核对未对账可
|
||||
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 生成)。
|
||||
|
||||
@@ -2,89 +2,443 @@
|
||||
"documentVersion": 1,
|
||||
"format": "axhub-annotation-source",
|
||||
"data": {
|
||||
"version": 1,
|
||||
"version": 2,
|
||||
"prototypeName": "oneos-h5-h2-order",
|
||||
"pageId": "h5-h2-order",
|
||||
"updatedAt": 1784010000000,
|
||||
"pageId": "list",
|
||||
"updatedAt": 1784260628220,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "h5-station",
|
||||
"images": [],
|
||||
"controls": [],
|
||||
"createdAt": 1784260628220,
|
||||
"updatedAt": 1784260628220,
|
||||
"hasMarkdown": true,
|
||||
"annotationText": "",
|
||||
"id": "h5-hero",
|
||||
"index": 1,
|
||||
"title": "当前加氢站(演示)",
|
||||
"pageId": "h5-h2-order",
|
||||
"locator": {
|
||||
"selectors": ["#h5-station-switch", "[data-ax=\"h5-h2-order-root\"]"],
|
||||
"fingerprint": "station|h5-h2-order",
|
||||
"path": []
|
||||
},
|
||||
"annotationText": "已登录态演示:可切换示例站点;列表仅展示本站加氢记录。",
|
||||
"hasMarkdown": false,
|
||||
"title": "当前站点",
|
||||
"pageId": "list",
|
||||
"color": "#7AB929",
|
||||
"images": [],
|
||||
"createdAt": 1784010000000,
|
||||
"updatedAt": 1784010000000,
|
||||
"controls": []
|
||||
"locator": {
|
||||
"selectors": [
|
||||
"[data-annotation-id='h5-hero']",
|
||||
".h5-hero"
|
||||
],
|
||||
"fingerprint": "hero|list",
|
||||
"path": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "h5-fab",
|
||||
"images": [],
|
||||
"controls": [],
|
||||
"createdAt": 1784260628220,
|
||||
"updatedAt": 1784260628220,
|
||||
"hasMarkdown": true,
|
||||
"annotationText": "",
|
||||
"id": "h5-summary",
|
||||
"index": 2,
|
||||
"title": "新增加氢记录",
|
||||
"pageId": "h5-h2-order",
|
||||
"title": "本站 KPI",
|
||||
"pageId": "list",
|
||||
"color": "#0E8A7B",
|
||||
"locator": {
|
||||
"selectors": [".h5-fab"],
|
||||
"fingerprint": "fab|h5-h2-order",
|
||||
"selectors": [
|
||||
"[data-annotation-id='h5-summary']",
|
||||
".h5-summary"
|
||||
],
|
||||
"fingerprint": "summary|list",
|
||||
"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",
|
||||
"index": 3,
|
||||
"title": "订单卡片",
|
||||
"pageId": "h5-h2-order",
|
||||
"locator": {
|
||||
"selectors": [".h5-card"],
|
||||
"fingerprint": "card|h5-h2-order",
|
||||
"path": []
|
||||
},
|
||||
"annotationText": "展示时间、车牌、单价、量、总额、对账状态/时间;已对账只读,未对账可编辑删除。",
|
||||
"hasMarkdown": false,
|
||||
"pageId": "list",
|
||||
"color": "#FF7D00",
|
||||
"images": [],
|
||||
"createdAt": 1784010000000,
|
||||
"updatedAt": 1784010000000,
|
||||
"controls": []
|
||||
}
|
||||
],
|
||||
"directory": [
|
||||
"locator": {
|
||||
"selectors": [
|
||||
"[data-annotation-id='h5-card']",
|
||||
".h5-card"
|
||||
],
|
||||
"fingerprint": "card|list",
|
||||
"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",
|
||||
"id": "h5-doc-root",
|
||||
"title": "加氢订单(H5)说明",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"id": "h5-prd-main",
|
||||
"title": "加氢订单(H5)PRD",
|
||||
"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",
|
||||
"title": "对账联动规则",
|
||||
"type": "markdown",
|
||||
"markdownPath": "src/prototypes/oneos-h5-h2-order/.spec/reconcile-linkage.md"
|
||||
"type": "folder",
|
||||
"id": "h5-doc-features",
|
||||
"title": "功能说明",
|
||||
"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.upsertRow(verify=unverified,reconcile=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",
|
||||
"title": "需求与设计决策",
|
||||
"type": "markdown",
|
||||
"markdownPath": "src/prototypes/oneos-h5-h2-order/.spec/2026-07-14-requirements-design.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": "# 功能:本站列表与 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
import type { CreateDraft } from '../types';
|
||||
import { getVehicleTag } from '../utils/fleet-plates';
|
||||
import { delay, mockPanelOcr, mockPlateOcr, readFileAsDataUrl } from '../utils/ocr-mock';
|
||||
import { DateTimePickerSheet } from './DateTimePickerSheet';
|
||||
import {
|
||||
IconCamera,
|
||||
IconCheck,
|
||||
@@ -10,6 +12,7 @@ import {
|
||||
IconSpinner,
|
||||
} from './Icons';
|
||||
import { PhoneShell } from './PhoneShell';
|
||||
import { PlateKeyboardSheet } from './PlateKeyboardSheet';
|
||||
|
||||
type Props = {
|
||||
mode: 'create' | 'edit';
|
||||
@@ -26,6 +29,7 @@ function draftFingerprint(draft: CreateDraft): string {
|
||||
return [
|
||||
draft.hydrogenTime.trim(),
|
||||
draft.plateNo.trim(),
|
||||
draft.mileageKm.trim(),
|
||||
draft.unitPrice.trim(),
|
||||
draft.hydrogenKg.trim(),
|
||||
draft.totalAmount.trim(),
|
||||
@@ -44,6 +48,7 @@ export function isDraftDirty(
|
||||
}
|
||||
return Boolean(
|
||||
draft.plateNo.trim() ||
|
||||
draft.mileageKm.trim() ||
|
||||
draft.unitPrice.trim() ||
|
||||
draft.hydrogenKg.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({
|
||||
mode,
|
||||
draft,
|
||||
@@ -66,6 +88,19 @@ export function CreateWizard({
|
||||
const panelInputRef = useRef<HTMLInputElement>(null);
|
||||
const [recognizing, setRecognizing] = useState<'plate' | 'panel' | null>(null);
|
||||
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) {
|
||||
if (!file) return;
|
||||
@@ -153,28 +188,39 @@ export function CreateWizard({
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<section className="h5-panel">
|
||||
<section className="h5-panel" data-annotation-id="h5-create-basic">
|
||||
<div className="h5-panel-head">
|
||||
<div className="h5-panel-badge" aria-hidden>
|
||||
<IconClock size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h3>基础信息</h3>
|
||||
<p>加氢时间默认当前,站点由登录账号带入。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h5-field">
|
||||
<label htmlFor="h5-time">加氢时间</label>
|
||||
<input
|
||||
<label htmlFor="h5-time">
|
||||
<RequiredMark />
|
||||
加氢时间
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
id="h5-time"
|
||||
value={draft.hydrogenTime}
|
||||
onChange={(e) => onChangeDraft({ hydrogenTime: e.target.value })}
|
||||
placeholder="YYYY-MM-DD HH:mm"
|
||||
/>
|
||||
className="h5-field-trigger tabular-nums"
|
||||
data-annotation-id="h5-time"
|
||||
onClick={() => setTimeOpen(true)}
|
||||
>
|
||||
{draft.hydrogenTime || '请选择加氢时间'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="h5-field">
|
||||
<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>
|
||||
</section>
|
||||
|
||||
@@ -185,7 +231,7 @@ export function CreateWizard({
|
||||
</div>
|
||||
<div>
|
||||
<h3>车牌识别</h3>
|
||||
<p>拍照或相册选择,OCR 结果可校正。</p>
|
||||
<p>请拍摄车牌</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -220,12 +266,38 @@ export function CreateWizard({
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
id="h5-plate"
|
||||
value={draft.plateNo}
|
||||
onChange={(e) => onChangeDraft({ plateNo: e.target.value.toUpperCase() })}
|
||||
placeholder="如 浙A88H201"
|
||||
id="h5-mileage"
|
||||
inputMode="numeric"
|
||||
value={draft.mileageKm}
|
||||
onChange={(e) => onChangeDraft({ mileageKm: e.target.value })}
|
||||
placeholder="请输入里程"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
@@ -238,7 +310,7 @@ export function CreateWizard({
|
||||
</div>
|
||||
<div>
|
||||
<h3>加氢机面板识别</h3>
|
||||
<p>识别单价、加氢量与总额,结果可校正。</p>
|
||||
<p>请拍摄清晰的加氢机照片</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -273,30 +345,41 @@ export function CreateWizard({
|
||||
/>
|
||||
</div>
|
||||
<div className="h5-field" style={{ marginTop: 12 }}>
|
||||
<label htmlFor="h5-price">氢气单价(元/kg)</label>
|
||||
<label htmlFor="h5-price">
|
||||
<RequiredMark />
|
||||
氢气单价(元/kg)
|
||||
</label>
|
||||
<input
|
||||
id="h5-price"
|
||||
data-annotation-id="h5-price"
|
||||
inputMode="decimal"
|
||||
value={draft.unitPrice}
|
||||
onChange={(e) => onChangeDraft({ unitPrice: e.target.value })}
|
||||
onChange={(e) => patchDraft({ unitPrice: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="h5-field">
|
||||
<label htmlFor="h5-kg">加氢量(kg)</label>
|
||||
<label htmlFor="h5-kg">
|
||||
<RequiredMark />
|
||||
加氢量(kg)
|
||||
</label>
|
||||
<input
|
||||
id="h5-kg"
|
||||
data-annotation-id="h5-kg"
|
||||
inputMode="decimal"
|
||||
value={draft.hydrogenKg}
|
||||
onChange={(e) => onChangeDraft({ hydrogenKg: e.target.value })}
|
||||
onChange={(e) => patchDraft({ hydrogenKg: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="h5-field">
|
||||
<label htmlFor="h5-total">加氢总额(元)</label>
|
||||
<label htmlFor="h5-total" data-annotation-id="h5-total-label">
|
||||
加氢总额(元)
|
||||
</label>
|
||||
<input
|
||||
id="h5-total"
|
||||
inputMode="decimal"
|
||||
value={draft.totalAmount}
|
||||
onChange={(e) => onChangeDraft({ totalAmount: e.target.value })}
|
||||
readOnly
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
@@ -306,6 +389,25 @@ export function CreateWizard({
|
||||
{toast}
|
||||
</div>
|
||||
) : 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -10,38 +10,45 @@ type Props = {
|
||||
};
|
||||
|
||||
export function OrderCard({ order, onClick, index = 0 }: Props) {
|
||||
const done = order.reconcileStatus === 'reconciled';
|
||||
const verified = order.verifyStatus === 'verified';
|
||||
return (
|
||||
<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}
|
||||
style={{ animationDelay: `${Math.min(index, 8) * 40}ms` }}
|
||||
aria-label={`${formatPlateDisplay(order.plateNo)},${verified ? '已核对' : '未核对'}`}
|
||||
>
|
||||
<div className="h5-card-top">
|
||||
<div className="h5-plate">{formatPlateDisplay(order.plateNo)}</div>
|
||||
<span className={`h5-tag ${done ? 'h5-tag--done' : 'h5-tag--pending'}`}>
|
||||
{done ? <IconCheck size={12} /> : <IconClock size={12} />}
|
||||
{done ? '已对账' : '未对账'}
|
||||
<span className={`h5-tag ${verified ? 'h5-tag--done' : 'h5-tag--pending'}`}>
|
||||
{verified ? <IconCheck size={12} /> : <IconClock size={12} />}
|
||||
{verified ? '已核对' : '未核对'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h5-card-meta">
|
||||
<IconClock size={13} />
|
||||
{formatDateTimeMinute(order.hydrogenTime)}
|
||||
<span className="tabular-nums">{formatDateTimeMinute(order.hydrogenTime)}</span>
|
||||
</div>
|
||||
<div className="h5-card-metrics">
|
||||
<div className="h5-card-metrics" aria-label="加氢数据">
|
||||
<div>
|
||||
<strong>{formatKg(order.hydrogenKg)}</strong>
|
||||
<span>加氢量 kg</span>
|
||||
<span>加氢量</span>
|
||||
<strong>
|
||||
{formatKg(order.hydrogenKg)}
|
||||
<em> kg</em>
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<strong>¥{formatMoney(order.totalAmount)}</strong>
|
||||
<span>加氢总额</span>
|
||||
<strong className="h5-card-metrics__money">¥{formatMoney(order.totalAmount)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h5-card-foot">
|
||||
<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>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React from 'react';
|
||||
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 { PhoneShell } from './PhoneShell';
|
||||
import { isOrderLocked } from '../utils/orders';
|
||||
|
||||
type Props = {
|
||||
order: H5OrderRecord;
|
||||
@@ -12,7 +13,9 @@ type 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 (
|
||||
<PhoneShell
|
||||
@@ -22,9 +25,9 @@ export function OrderDetail({ order, onBack, onEdit, onDelete }: Props) {
|
||||
<IconChevronLeft size={20} />
|
||||
</button>
|
||||
}
|
||||
bodyClassName={done ? undefined : 'h5-body--wizard'}
|
||||
bodyClassName={locked ? undefined : 'h5-body--wizard'}
|
||||
footer={
|
||||
done ? null : (
|
||||
locked ? null : (
|
||||
<div className="h5-action-bar">
|
||||
<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-plate">{formatPlateDisplay(order.plateNo)}</div>
|
||||
<span className={`h5-tag ${done ? 'h5-tag--done' : 'h5-tag--pending'}`}>
|
||||
{done ? <IconCheck size={12} /> : <IconClock size={12} />}
|
||||
{done ? '已对账' : '未对账'}
|
||||
</span>
|
||||
<div>
|
||||
<div className="h5-plate">{formatPlateDisplay(order.plateNo)}</div>
|
||||
<div className="h5-detail-hero__time tabular-nums">
|
||||
{formatDateTimeMinute(order.hydrogenTime)}
|
||||
</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 className="h5-detail-row">
|
||||
<span>加氢时间</span>
|
||||
<strong>{formatDateTimeMinute(order.hydrogenTime)}</strong>
|
||||
<div className="h5-detail-metrics" aria-label="加氢金额与用量">
|
||||
<div>
|
||||
<span>加氢总额</span>
|
||||
<strong>¥{formatMoney(order.totalAmount)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>加氢量</span>
|
||||
<strong>
|
||||
{formatKg(order.hydrogenKg)}
|
||||
<em> kg</em>
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h5-detail-row">
|
||||
<span>加氢站名称</span>
|
||||
@@ -56,32 +79,41 @@ export function OrderDetail({ order, onBack, onEdit, onDelete }: Props) {
|
||||
<span>车牌号</span>
|
||||
<strong>{formatPlateDisplay(order.plateNo)}</strong>
|
||||
</div>
|
||||
<div className="h5-detail-row">
|
||||
<span>里程</span>
|
||||
<strong>{formatMileage(order.mileageKm)} km</strong>
|
||||
</div>
|
||||
<div className="h5-detail-row">
|
||||
<span>氢气单价</span>
|
||||
<strong>{formatMoney(order.unitPrice)} 元/kg</strong>
|
||||
</div>
|
||||
<div className="h5-detail-row">
|
||||
<span>加氢量</span>
|
||||
<strong>{formatKg(order.hydrogenKg)} kg</strong>
|
||||
<span>核对状态</span>
|
||||
<strong>{verified ? '已核对' : '未核对'}</strong>
|
||||
</div>
|
||||
<div className="h5-detail-row">
|
||||
<span>加氢总额</span>
|
||||
<strong>¥{formatMoney(order.totalAmount)}</strong>
|
||||
<span>核对时间</span>
|
||||
<strong>{verified ? formatDateTimeMinute(order.verifiedAt) : '—'}</strong>
|
||||
</div>
|
||||
<div className="h5-detail-row">
|
||||
<span>对账状态</span>
|
||||
<strong>{done ? '已对账' : '未对账'}</strong>
|
||||
<strong>{reconciled ? '已对账' : '未对账'}</strong>
|
||||
</div>
|
||||
<div className="h5-detail-row">
|
||||
<span>对账时间</span>
|
||||
<strong>{done ? formatDateTimeMinute(order.reconcileTime) : '—'}</strong>
|
||||
<strong>{reconciled ? formatDateTimeMinute(order.reconcileTime) : '—'}</strong>
|
||||
</div>
|
||||
</section>
|
||||
{done ? (
|
||||
{reconciled ? (
|
||||
<p className="h5-lock-note">
|
||||
<IconCheck size={16} />
|
||||
该记录已纳入站点对账单,不可修改
|
||||
</p>
|
||||
) : verified ? (
|
||||
<p className="h5-lock-note">
|
||||
<IconCheck size={16} />
|
||||
能源部已完成核对,不可修改
|
||||
</p>
|
||||
) : null}
|
||||
</PhoneShell>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { clearHostPrototypeRouteInfo } from '../../common/useHashPage';
|
||||
import annotationSourceDocument from './annotation-source.json';
|
||||
import { CreateWizard } from './components/CreateWizard';
|
||||
import {
|
||||
IconCamera,
|
||||
IconChevronLeft,
|
||||
IconDroplet,
|
||||
IconInbox,
|
||||
@@ -23,12 +24,17 @@ import {
|
||||
IconPlus,
|
||||
IconWallet,
|
||||
} from './components/Icons';
|
||||
import { ManualLedgerPanel } from './components/ManualLedgerPanel';
|
||||
import { OrderCard } from './components/OrderCard';
|
||||
import { OrderDetail } from './components/OrderDetail';
|
||||
import { PhoneShell } from './components/PhoneShell';
|
||||
import type { AppView, CreateDraft, StationOption } from './types';
|
||||
import { formatDateTimeMinute, formatKg, formatMoney, nowDateTimeMinute } from './utils/format';
|
||||
import { hasManualLedgerForDate } from './utils/manual-ledger';
|
||||
import {
|
||||
findDuplicateStationOrder,
|
||||
getTotalAmountMismatch,
|
||||
isOrderLocked,
|
||||
listOrdersForStation,
|
||||
listStations,
|
||||
removeStationOrder,
|
||||
@@ -41,6 +47,7 @@ function emptyDraft(partial?: Partial<CreateDraft>): CreateDraft {
|
||||
return {
|
||||
hydrogenTime: nowDateTimeMinute(),
|
||||
plateNo: '',
|
||||
mileageKm: '',
|
||||
unitPrice: '',
|
||||
hydrogenKg: '',
|
||||
totalAmount: '',
|
||||
@@ -56,11 +63,14 @@ export default function OneosH5H2Order() {
|
||||
}, []);
|
||||
|
||||
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 [view, setView] = useState<AppView>({ name: 'list' });
|
||||
const [mainTab, setMainTab] = useState<'orders' | 'manual'>('orders');
|
||||
const [toast, setToast] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -79,6 +89,12 @@ export default function OneosH5H2Order() {
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
if (!hasManualLedgerForDate(station.stationId || station.stationName)) {
|
||||
showToast('请先上传今日手工台账');
|
||||
setMainTab('manual');
|
||||
setView({ name: 'list' });
|
||||
return;
|
||||
}
|
||||
const draft = emptyDraft();
|
||||
setView({
|
||||
name: 'create',
|
||||
@@ -90,7 +106,7 @@ export default function OneosH5H2Order() {
|
||||
|
||||
function openEdit(id: string) {
|
||||
const order = orders.find((item) => item.id === id);
|
||||
if (!order || order.reconcileStatus === 'reconciled') return;
|
||||
if (!order || isOrderLocked(order)) return;
|
||||
const draft = emptyDraft({
|
||||
id: order.id,
|
||||
hydrogenTime:
|
||||
@@ -98,6 +114,7 @@ export default function OneosH5H2Order() {
|
||||
? nowDateTimeMinute()
|
||||
: order.hydrogenTime.slice(0, 16),
|
||||
plateNo: order.plateNo.replace(/F$/u, ''),
|
||||
mileageKm: order.mileageKm != null ? String(order.mileageKm) : '',
|
||||
unitPrice: String(order.unitPrice),
|
||||
hydrogenKg: String(order.hydrogenKg),
|
||||
totalAmount: String(order.totalAmount),
|
||||
@@ -120,12 +137,41 @@ export default function OneosH5H2Order() {
|
||||
showToast('请完善加氢信息');
|
||||
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({
|
||||
id: draft.id,
|
||||
stationId: station.stationId,
|
||||
stationName: station.stationName,
|
||||
hydrogenTime: draft.hydrogenTime,
|
||||
plateNo: draft.plateNo.trim(),
|
||||
mileageKm,
|
||||
unitPrice,
|
||||
hydrogenKg,
|
||||
totalAmount,
|
||||
@@ -141,7 +187,7 @@ export default function OneosH5H2Order() {
|
||||
|
||||
function handleDelete(id: string) {
|
||||
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;
|
||||
removeStationOrder(id);
|
||||
setTick((n) => n + 1);
|
||||
@@ -149,6 +195,9 @@ export default function OneosH5H2Order() {
|
||||
showToast('已删除');
|
||||
}
|
||||
|
||||
const annotationPageId =
|
||||
view.name === 'create' ? 'create' : view.name === 'detail' ? 'detail' : 'list';
|
||||
|
||||
const annotationOptions = useMemo<AnnotationViewerOptions>(
|
||||
() => ({
|
||||
showToolbar: true,
|
||||
@@ -156,9 +205,9 @@ export default function OneosH5H2Order() {
|
||||
showColorFilter: true,
|
||||
emptyWhenNoData: false,
|
||||
toolbarEdge: 'right',
|
||||
currentPageId: 'h5-h2-order',
|
||||
currentPageId: annotationPageId,
|
||||
}),
|
||||
[],
|
||||
[annotationPageId],
|
||||
);
|
||||
|
||||
let content: React.ReactNode = null;
|
||||
@@ -208,90 +257,129 @@ export default function OneosH5H2Order() {
|
||||
<IconInbox size={36} />
|
||||
</div>
|
||||
<strong>记录不存在</strong>
|
||||
可能已切换站点,请返回列表查看
|
||||
请返回列表查看
|
||||
</div>
|
||||
</PhoneShell>
|
||||
);
|
||||
} else {
|
||||
const todayUploaded = hasManualLedgerForDate(station.stationId || station.stationName);
|
||||
content = (
|
||||
<PhoneShell title="加氢订单">
|
||||
<div className="h5-hero">
|
||||
<div className="h5-hero-label">
|
||||
<IconMapPin size={14} />
|
||||
当前加氢站(演示可切换)
|
||||
</div>
|
||||
<label htmlFor="h5-station-switch" className="sr-only">
|
||||
当前加氢站
|
||||
</label>
|
||||
<select
|
||||
id="h5-station-switch"
|
||||
className="h5-station-select"
|
||||
value={station.stationId}
|
||||
onChange={(e) => {
|
||||
const next = stations.find((s) => s.stationId === e.target.value);
|
||||
if (next) setStation(next);
|
||||
}}
|
||||
>
|
||||
{stations.map((s) => (
|
||||
<option key={s.stationId} value={s.stationId}>
|
||||
{s.stationName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="h5-summary" aria-label="本站汇总">
|
||||
<div className="h5-summary-chip">
|
||||
<div className="h5-summary-chip-icon" aria-hidden>
|
||||
<IconList size={15} />
|
||||
</div>
|
||||
<strong>{summary.count}</strong>
|
||||
<span>订单数</span>
|
||||
</div>
|
||||
<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>
|
||||
<PhoneShell
|
||||
title={mainTab === 'manual' ? '手工台账' : '加氢订单'}
|
||||
bodyClassName="h5-body--list"
|
||||
footer={
|
||||
<>
|
||||
{mainTab === 'orders' ? (
|
||||
<div className="h5-fab-dock" data-annotation-id="h5-fab-dock">
|
||||
{!todayUploaded ? (
|
||||
<p className="h5-fab-dock__hint">今日未上传手工台账,无法新增电子记录</p>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="h5-fab"
|
||||
data-annotation-id="h5-fab"
|
||||
onClick={openCreate}
|
||||
aria-label="新增加氢记录"
|
||||
>
|
||||
<IconPlus size={22} />
|
||||
<span className="h5-fab-label">新增</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<nav className="h5-tabbar" aria-label="主导航">
|
||||
<button
|
||||
type="button"
|
||||
className={`h5-tabbar-item${mainTab === 'orders' ? ' is-active' : ''}`}
|
||||
onClick={() => setMainTab('orders')}
|
||||
>
|
||||
<IconList size={18} />
|
||||
加氢订单
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`h5-tabbar-item${mainTab === 'manual' ? ' is-active' : ''}`}
|
||||
onClick={() => setMainTab('manual')}
|
||||
>
|
||||
<IconCamera size={18} />
|
||||
手工台账
|
||||
{!todayUploaded ? <span className="h5-tabbar-dot" aria-label="今日未上传" /> : null}
|
||||
</button>
|
||||
</nav>
|
||||
{toast ? (
|
||||
<div className="h5-toast h5-toast--above-dock" role="status" aria-live="polite">
|
||||
{toast}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{mainTab === 'manual' ? (
|
||||
<ManualLedgerPanel
|
||||
station={station}
|
||||
tick={tick}
|
||||
showToast={showToast}
|
||||
onUploaded={() => setTick((n) => n + 1)}
|
||||
/>
|
||||
) : (
|
||||
orders.map((order, index) => (
|
||||
<OrderCard
|
||||
key={order.id}
|
||||
order={order}
|
||||
index={index}
|
||||
onClick={() => setView({ name: 'detail', id: order.id })}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
<>
|
||||
<div className="h5-hero" data-annotation-id="h5-hero" aria-label="当前站点">
|
||||
<div className="h5-hero-label">
|
||||
<IconMapPin size={13} />
|
||||
当前站点
|
||||
</div>
|
||||
<div className="h5-hero-station">{station.stationName}</div>
|
||||
</div>
|
||||
|
||||
<button type="button" className="h5-fab" onClick={openCreate} aria-label="新增加氢记录">
|
||||
<IconPlus size={22} />
|
||||
<span className="h5-fab-label">新增</span>
|
||||
</button>
|
||||
{toast ? (
|
||||
<div className="h5-toast" role="status" aria-live="polite">
|
||||
{toast}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="h5-summary" data-annotation-id="h5-summary" aria-label="本站汇总">
|
||||
<div className="h5-summary-chip">
|
||||
<div className="h5-summary-chip-icon" aria-hidden>
|
||||
<IconList size={15} />
|
||||
</div>
|
||||
<strong>{summary.count}</strong>
|
||||
<span>订单数</span>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -1,4 +1,5 @@
|
||||
export type ReconcileStatus = 'pending' | 'reconciled';
|
||||
export type VerifyStatus = 'unverified' | 'verified';
|
||||
|
||||
export type StationOption = {
|
||||
stationId: string;
|
||||
@@ -11,9 +12,13 @@ export type H5OrderRecord = {
|
||||
stationName: string;
|
||||
hydrogenTime: string;
|
||||
plateNo: string;
|
||||
/** 里程 km;未填为 null */
|
||||
mileageKm: number | null;
|
||||
unitPrice: number;
|
||||
hydrogenKg: number;
|
||||
totalAmount: number;
|
||||
verifyStatus: VerifyStatus;
|
||||
verifiedAt: string;
|
||||
reconcileStatus: ReconcileStatus;
|
||||
reconcileTime: string;
|
||||
};
|
||||
@@ -22,6 +27,7 @@ export type CreateDraft = {
|
||||
id?: string;
|
||||
hydrogenTime: string;
|
||||
plateNo: string;
|
||||
mileageKm: string;
|
||||
unitPrice: string;
|
||||
hydrogenKg: string;
|
||||
totalAmount: string;
|
||||
|
||||
47
src/prototypes/oneos-h5-h2-order/utils/fleet-plates.ts
Normal file
47
src/prototypes/oneos-h5-h2-order/utils/fleet-plates.ts
Normal 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';
|
||||
}
|
||||
@@ -30,3 +30,11 @@ export function formatKg(n: number): string {
|
||||
if (!Number.isFinite(n)) return '—';
|
||||
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');
|
||||
}
|
||||
|
||||
202
src/prototypes/oneos-h5-h2-order/utils/manual-ledger.ts
Normal file
202
src/prototypes/oneos-h5-h2-order/utils/manual-ledger.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,22 @@ declare global {
|
||||
upsertRow: (row: Record<string, unknown>) => Record<string, unknown> | null;
|
||||
removeRow: (id: string) => boolean;
|
||||
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 VERIFIED = 'verified';
|
||||
|
||||
function asNumber(value: unknown, fallback = 0): number {
|
||||
const n = Number(value);
|
||||
@@ -23,34 +34,63 @@ function asString(value: unknown): string {
|
||||
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 {
|
||||
const reconcileTime = asString(row.reconcileDate || row.reconciledAt);
|
||||
/* 对账时间仅认 reconcileDate,勿用 reconciledAt(完成核对会写) */
|
||||
let reconcileTime = asString(row.reconcileDate);
|
||||
const reconcileStatus =
|
||||
asString(row.reconcileStatus) === RECONCILED || Boolean(reconcileTime)
|
||||
? 'reconciled'
|
||||
: '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 hydrogenKg = asNumber(row.hydrogenKg);
|
||||
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 {
|
||||
id: asString(row.id || row.key),
|
||||
stationId: asString(row.stationId),
|
||||
stationName: asString(row.stationName),
|
||||
hydrogenTime: asString(row.hydrogenTime),
|
||||
plateNo: asString(row.plateNo),
|
||||
mileageKm,
|
||||
unitPrice,
|
||||
hydrogenKg,
|
||||
totalAmount,
|
||||
verifyStatus,
|
||||
verifiedAt,
|
||||
reconcileStatus,
|
||||
reconcileTime: reconcileStatus === 'reconciled' ? reconcileTime : '',
|
||||
};
|
||||
}
|
||||
|
||||
export function isOrderLocked(order: H5OrderRecord): boolean {
|
||||
return order.verifyStatus === 'verified' || order.reconcileStatus === 'reconciled';
|
||||
}
|
||||
|
||||
export function listStations(): StationOption[] {
|
||||
const bridge = typeof window !== 'undefined' ? window.H2VehicleLedgerBridge : undefined;
|
||||
if (!bridge) {
|
||||
return [
|
||||
{ stationId: 'JX-H2-001', stationName: '嘉兴加氢站(一期)' },
|
||||
{ stationId: 'JX-H2-001', stationName: '中国石油中油高新能源牙谷加油加氢站' },
|
||||
{ stationId: 'HZ-H2-002', stationName: '杭州临平加氢站' },
|
||||
{ stationId: 'SH-H2-003', stationName: '上海宝山加氢站' },
|
||||
{ stationId: 'SZ-H2-004', stationName: '苏州工业园区备用站' },
|
||||
@@ -83,6 +123,7 @@ export function upsertStationOrder(input: {
|
||||
stationName: string;
|
||||
hydrogenTime: string;
|
||||
plateNo: string;
|
||||
mileageKm?: number | null;
|
||||
unitPrice: number;
|
||||
hydrogenKg: number;
|
||||
totalAmount: number;
|
||||
@@ -90,12 +131,18 @@ export function upsertStationOrder(input: {
|
||||
const bridge = typeof window !== 'undefined' ? window.H2VehicleLedgerBridge : undefined;
|
||||
if (!bridge?.upsertRow) return null;
|
||||
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({
|
||||
id: input.id,
|
||||
key: input.id,
|
||||
stationId: input.stationId,
|
||||
stationName: input.stationName,
|
||||
hydrogenTime: input.hydrogenTime.length === 16 ? `${input.hydrogenTime}:00` : input.hydrogenTime,
|
||||
hydrogenTime,
|
||||
plateNo: plate,
|
||||
customerName: `${input.stationName}·站端上报`,
|
||||
hydrogenKg: input.hydrogenKg,
|
||||
@@ -104,13 +151,48 @@ export function upsertStationOrder(input: {
|
||||
customerUnitPrice: input.unitPrice,
|
||||
customerAmount: input.totalAmount,
|
||||
settlementStatus: 'customer',
|
||||
mileageKm: 0,
|
||||
mileageKm,
|
||||
creatorName: '站端账号',
|
||||
reconcileStatus: 'pending',
|
||||
verifyStatus: 'unverified',
|
||||
});
|
||||
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 {
|
||||
const bridge = typeof window !== 'undefined' ? window.H2VehicleLedgerBridge : undefined;
|
||||
if (!bridge?.removeRow) return false;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": 1,
|
||||
"updatedAt": "2026-07-16T01:04:29.807Z",
|
||||
"updatedAt": "2026-07-17T03:52:22.620Z",
|
||||
"title": "小羚羚",
|
||||
"sectionId": "folder-prototypes-xll-miniapp",
|
||||
"description": "氢能车辆运营移动端原型;菜单与小羚羚「小程序」项目目录同步。",
|
||||
|
||||
@@ -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 | 首版需求:口径、写回时机、不可删日志、时间上限、下钻锚点 |
|
||||
@@ -4,7 +4,7 @@
|
||||
- 用户资料/参考资料:
|
||||
- `src/prototypes/oneos-web-h2-station-site/.spec/requirements.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`
|
||||
- 生成时间:2026-07-01(复审)
|
||||
|
||||
|
||||
@@ -203,4 +203,4 @@ flowchart TD
|
||||
| 口径函数 | `h2IsBalanceAlertStation` / `h2IsArrearsStation` |
|
||||
| 打开弹窗 | `openAlertStationModal` |
|
||||
| 批量充值 | `handleBatchRechargeFromAlert` → `openRechargeModal` |
|
||||
| 页面文件 | `src/prototypes/oneos-web-h2-station/pages/03-站点信息.jsx` |
|
||||
| 页面文件 | `src/prototypes/oneos-web-h2-station-site/pages/03-站点信息.jsx` |
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
| 联系方式 | 联系人 + 电话 |
|
||||
| 操作 | 查看 + 更多菜单 |
|
||||
|
||||
**更多菜单**:编辑、删除、营业状态、价格配置、生成对账单、查看对账记录等(按状态与权限显隐)。
|
||||
**更多菜单**:编辑、删除、营业状态、价格配置、余额提醒、期末余额设置、生成对账单、查看对账记录等(按状态与权限显隐)。
|
||||
|
||||
**分页**:默认 10 条,可选 5/10/20/50。
|
||||
|
||||
|
||||
@@ -118,10 +118,21 @@
|
||||
3. **阶梯量重置日期**见价格配置表单内「阶梯量重置日期」;到达重置日后,累计加氢量**从零重新计数**,用于判断当前所处价格区间。
|
||||
4. **生效时间**自表单内「生效时间」起对新数据生效,**不追溯**此前已产生的数据;生效时间之前的历史记录,仍按当时已达的**最高阶梯价格**结算。
|
||||
|
||||
### 5.3 下钻分析
|
||||
### 5.3 期末余额设置
|
||||
|
||||
> 完整规则见 [period-end-balance.md](./period-end-balance.md)
|
||||
|
||||
与加氢站对账存在时间差时,登记**期末余额**与**期末时间**作为锚点:
|
||||
|
||||
- **入口**:更多 → 期末余额设置
|
||||
- **计算**:`当前预付余额 = 期末余额 − 期末后支出 + 期末后收入`(仅保存时重算一次)
|
||||
- **约束**:期末时间不可晚于今天;变更记录只追加、不可删除
|
||||
- **下钻**:余额流水从期末锚点行起重新累加
|
||||
|
||||
### 5.4 下钻分析
|
||||
|
||||
- **加氢量下钻**:统计卡 + 明细表 + 导出,字段对齐车辆氢费明细
|
||||
- **预付余额下钻**:充值/加氢流水 + 趋势图 + 导出
|
||||
- **预付余额下钻**:充值/加氢流水 + 趋势图 + 导出;若已配置期末余额,自期末时间锚点重算后续余额
|
||||
|
||||
---
|
||||
|
||||
@@ -143,7 +154,7 @@
|
||||
|---|---|
|
||||
| 氢费明细 | `src/common/h2VehicleLedgerBridge.js` |
|
||||
| 对账回写 | `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` |
|
||||
|
||||
---
|
||||
@@ -163,7 +174,13 @@
|
||||
- [ ] 两阶段交互与必填校验完整
|
||||
- [ ] 提交后回写车辆氢费明细可跨页验收
|
||||
|
||||
### 8.3 标注与文档
|
||||
### 8.3 期末余额设置
|
||||
|
||||
- [ ] 更多菜单可打开期末余额设置;期末时间不可晚于今天
|
||||
- [ ] 保存后写回预付余额,变更记录只追加不可删
|
||||
- [ ] 余额下钻出现期末锚点且后续余额连续推算
|
||||
|
||||
### 8.4 标注与文档
|
||||
|
||||
- [ ] `@axhub/annotation` 已接入,关键区域有 `data-annotation-id`
|
||||
- [ ] 右侧工具栏目录可阅 PRD 全文与页面标注
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -17,7 +17,7 @@ import {
|
||||
} from '@axhub/annotation';
|
||||
import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host';
|
||||
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';
|
||||
|
||||
/** 与租赁合同管理 / vehicle-management 主题一致 */
|
||||
|
||||
@@ -1137,6 +1137,28 @@ function h2FormatReceiptAmountInput(text) {
|
||||
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) {
|
||||
var defaultCfg = h2GetDefaultAllCustomerPriceConfig(record);
|
||||
if (defaultCfg) {
|
||||
@@ -2996,7 +3018,8 @@ function h2FormatYuanSymbol(v, options) {
|
||||
var H2_BALANCE_BIZ_TYPE_MAP = {
|
||||
recharge: { key: 'recharge', label: '充值', color: 'success' },
|
||||
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) {
|
||||
@@ -3142,6 +3165,115 @@ function h2BuildMockPrepaidBalanceRows(record) {
|
||||
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) {
|
||||
if (!changeTime) return '—';
|
||||
var text = String(changeTime).trim();
|
||||
@@ -3512,10 +3644,13 @@ function h2GetAvailableStatementLedgerRows(store, stationName, startDate, endDat
|
||||
if (!name || isNaN(startMs) || isNaN(endMs)) return [];
|
||||
var endInclusive = endMs + 86400000 - 1;
|
||||
return (store || []).filter(function (r) {
|
||||
/* 站端 H5 上报多为 pending;完成对账后亦可能尚待结算 — 均纳入对账单来源 */
|
||||
/* 对账单仅纳入:已核对 ∧ 未对账 ∧ 未绑定其他对账单 */
|
||||
if (r.statementRecordId) return false;
|
||||
var verify = r.verifyStatus || 'unverified';
|
||||
if (verify !== 'verified') return false;
|
||||
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;
|
||||
var t = h2ParseDateTimeMs(r.hydrogenTime);
|
||||
if (isNaN(t)) return false;
|
||||
@@ -4213,7 +4348,15 @@ flowchart LR
|
||||
4. 保存后:
|
||||
- 若已到生效时间 → 列表「当前成本价格」**立即更新**
|
||||
- 若未到生效时间 → 到点后自动更新
|
||||
4. 下方展示价格调整记录(操作人、调整前后价格、生效时间)。
|
||||
5. 下方展示价格调整记录(操作人、调整前后价格、生效时间)。
|
||||
|
||||
### 5.3 期末余额设置
|
||||
|
||||
**入口**:更多 → **期末余额设置**
|
||||
|
||||
1. 填写 **期末余额(元)** 与 **期末时间**(不可晚于今天)。
|
||||
2. 保存后按「期末余额 − 期末后支出 + 期末后收入」重算并写回「预付余额」(仅保存时计算一次)。
|
||||
3. 下方展示变更记录(期末余额、生效时间、操作人、操作时间),只追加不可删除,支持分页。
|
||||
|
||||
---
|
||||
|
||||
@@ -4237,6 +4380,7 @@ flowchart LR
|
||||
|
||||
- 当前余额(负数显示「已欠费」)
|
||||
- 充值 / 车辆加氢流水明细
|
||||
- 若已配置期末余额:流水中出现「期末余额」锚点行,其后收入/支出从该余额连续推算
|
||||
- **导出 CSV** 按钮
|
||||
|
||||
---
|
||||
@@ -4347,6 +4491,8 @@ sequenceDiagram
|
||||
| 编辑 | 整页编辑站点主数据 |
|
||||
| 营业状态 | 维护营业状态与营业时间 |
|
||||
| 价格配置 | 设置成本价与生效时间 |
|
||||
| 余额提醒设置 | 设置预付余额预警阈值 |
|
||||
| 期末余额设置 | 登记期末余额与期末时间,重算预付余额 |
|
||||
| 生成对账单 | 两阶段对账结算 |
|
||||
| 查看对账记录 | 历史对账单与明细 |
|
||||
| 删除 | 删除站点(不可恢复) |
|
||||
@@ -4537,7 +4683,15 @@ flowchart TB
|
||||
|
||||
**加氢量下钻**:统计卡(次数、加氢量、成本总价、加氢总价)+ 明细表 + 导出 CSV,字段对齐车辆氢费明细。
|
||||
|
||||
**预付余额下钻**:充值/车辆加氢流水 + 导出 CSV;余额为负显示已欠费。
|
||||
**预付余额下钻**:充值/车辆加氢流水 + 导出 CSV;余额为负显示已欠费;若已配置期末余额,自期末时间锚点重算后续余额。
|
||||
|
||||
### 5.4 期末余额设置
|
||||
|
||||
**入口**:更多 → **期末余额设置**
|
||||
|
||||
1. 填写 **期末余额(元)** 与 **期末时间**(不可晚于今天)。
|
||||
2. 保存后:「当前预付余额 = 期末余额 − 期末后支出 + 期末后收入」(仅保存时重算一次并写回列表)。
|
||||
3. 下方展示变更记录(期末余额、生效时间、操作人、操作时间),只追加不可删除,支持分页。
|
||||
|
||||
---
|
||||
|
||||
@@ -5772,6 +5926,17 @@ const Component = function () {
|
||||
var balanceAlertModal = balanceAlertModalState[0];
|
||||
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 globalBalanceAlertModal = globalBalanceAlertModalState[0];
|
||||
var setGlobalBalanceAlertModal = globalBalanceAlertModalState[1];
|
||||
@@ -6277,6 +6442,9 @@ const Component = function () {
|
||||
costUnitPrice: createStation.costUnitPrice ? parseFloat(createStation.costUnitPrice) : undefined,
|
||||
customerUnitPrice: createStation.customerUnitPrice ? parseFloat(createStation.customerUnitPrice) : undefined,
|
||||
prepaidBalance: 0,
|
||||
periodEndBalance: null,
|
||||
periodEndTime: null,
|
||||
periodEndBalanceLogs: [],
|
||||
businessStatusLogs: [],
|
||||
costPriceLogs: [],
|
||||
updateTime: now,
|
||||
@@ -6545,7 +6713,7 @@ const Component = function () {
|
||||
open: true,
|
||||
stationName: record.name || '',
|
||||
endingBalance: h2NumOrZero(record.prepaidBalance),
|
||||
rows: h2BuildMockPrepaidBalanceRows(record)
|
||||
rows: h2BuildPrepaidBalanceRowsForRecord(record)
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -7179,6 +7347,92 @@ const Component = function () {
|
||||
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 () {
|
||||
if (!balanceAlertModal.record) return;
|
||||
var thresholdText = h2FormatReceiptAmountInput(balanceAlertModal.threshold);
|
||||
@@ -7367,12 +7621,13 @@ const Component = function () {
|
||||
{ key: 'business', label: '营业状态', onClick: function () { openBusinessSetting(record); } },
|
||||
{ key: 'price', label: '价格配置', onClick: function () { openPriceConfig(record); } },
|
||||
{ key: 'balanceAlert', label: '余额提醒设置', onClick: function () { openBalanceAlertSetting(record); } },
|
||||
{ key: 'periodEndBalance', label: '期末余额设置', onClick: function () { openPeriodEndBalanceSetting(record); } },
|
||||
{ key: 'statement', label: '生成对账单', onClick: function () { openStatementModal(record); } },
|
||||
{ key: 'statementHistory', label: '查看对账记录', onClick: function () { openStatementHistoryModal(record); } },
|
||||
{ type: 'divider' },
|
||||
{ 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 () {
|
||||
setImportModalOpen(false);
|
||||
@@ -9873,6 +10128,170 @@ const Component = function () {
|
||||
: 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, {
|
||||
title: '全站余额提醒设置',
|
||||
open: globalBalanceAlertModal.open,
|
||||
@@ -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 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 periodEndPrd = fs.readFileSync(path.join(specRoot, 'period-end-balance.md'), 'utf8');
|
||||
const scopeMd = fs.readFileSync(path.join(specRoot, 'requirements.md'), 'utf8');
|
||||
|
||||
const NODE_DEFS = [
|
||||
@@ -97,6 +98,18 @@ const NODE_DEFS = [
|
||||
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 = {
|
||||
@@ -107,9 +120,21 @@ const NODE_PRD_MAP = {
|
||||
'h2-site-price-cost-input': { file: 'full', heading: '### 5.2 价格配置' },
|
||||
'h2-site-price-tiered': { file: 'full', 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) {
|
||||
const lines = md.split('\n');
|
||||
@@ -276,6 +301,13 @@ const source = {
|
||||
markdown: statementPrd,
|
||||
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',
|
||||
id: 'h2-site-doc-scope',
|
||||
|
||||
@@ -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 共用。
|
||||
@@ -4,6 +4,7 @@
|
||||
|---|---|
|
||||
| 代码路径 | UI:`pages/02-加氢记录.jsx`;Store:`src/common/h2VehicleLedgerBridge.js` |
|
||||
| 对齐模块 | H5「加氢订单」`oneos-h5-h2-order`(同一业务、同一 Store;PC 多导出 / 预约加氢) |
|
||||
| 对账入口 | 菜单原型 `oneos-web-h2-station-site`(实现:`oneos-web-h2-station-site/pages/03-站点信息.jsx`) |
|
||||
| 数据源 | 原型本地种子 + 内存共享 Store(**未接真实 API**) |
|
||||
|
||||
---
|
||||
@@ -17,48 +18,94 @@
|
||||
| 加氢时间 | `YYYY-MM-DD HH:mm:ss` |
|
||||
| 加氢站名称 | 站名;写入时同步 `stationId`/`stationCode` |
|
||||
| 车牌号 | 展示去尾缀 `F`;写入 Store 补 `F` |
|
||||
| 里程(km) | `mileageKm`;**非必填**;未填存 `null`,列表展示「—」 |
|
||||
| 氢气单价(元/kg) | 站端口径,映射 `costUnitPrice` / `customerUnitPrice` |
|
||||
| 加氢量(kg) | `hydrogenKg` |
|
||||
| 加氢总额(元) | 映射 `costTotal` / `customerAmount` |
|
||||
| 对账状态 | `pending` / `reconciled` |
|
||||
| 对账时间 | `reconcileDate` / `reconciledAt` 格式化展示 |
|
||||
| 核对状态 | `verifyStatus`:`unverified` / `verified`;能源部「完成核对」写入 |
|
||||
| 核对时间 | `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'` 或已有对账时间 | **已对账**;禁编辑、禁删除 |
|
||||
| 2 | 否则 | **未对账**;可编辑、删除 |
|
||||
| 1 | `reconcileStatus === 'reconciled'` 或有 `reconcileDate` | **已对账**;禁编辑、禁删除;对账时间必有值 |
|
||||
| 2 | `verifyStatus === 'verified'` | **已核对**(可仍未对账);禁编辑、禁删除;展示核对时间 |
|
||||
| 3 | 否则 | **未核对 + 未对账**;可编辑、删除 |
|
||||
|
||||
## 3. 前置条件
|
||||
|
||||
| 场景 | 行为 |
|
||||
|---|---|
|
||||
| 共享 Bridge 不可用 | 列表用本地 fallback;保存/删除提示失败 |
|
||||
| 已对账点编辑/删除 | 警告「已对账记录不可编辑/删除」 |
|
||||
| PC 独有 | **导出**当前筛选结果 CSV;**预约加氢**独立弹窗(不影响加氢记录数据结构) |
|
||||
| 已核对或已对账点编辑/删除 | 对应警告文案 |
|
||||
| PC 独有 | **导出**含核对/对账列;**预约加氢**接受后写入未核对流水 |
|
||||
|
||||
## 4. 数据流
|
||||
|
||||
```text
|
||||
Web 加氢记录 新增/编辑/批量
|
||||
→ hrUpsertBridgeRow → Bridge.upsertRow(站端字段映射)
|
||||
→ Store subscribe → 列表刷新
|
||||
Web / H5 新增加氢记录
|
||||
→ Bridge.upsertRow(unverified + pending)
|
||||
→ 车辆氢费明细可见
|
||||
|
||||
H5 加氢订单 同上 upsert/remove
|
||||
→ 与 Web 读写同一 Store
|
||||
车辆氢费明细 · 完成核对
|
||||
→ verifyStatus=verified + verifiedAt
|
||||
→ Web / H5 同步「已核对」
|
||||
|
||||
站点信息 · 对账单提交
|
||||
→ applyPatches(reconciled + 对账时间)
|
||||
→ Web / H5 同步展示
|
||||
站点信息 · 对账单提交(仅已核对 ∧ 未对账)
|
||||
→ reconcileStatus=reconciled + reconcileDate
|
||||
→ 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
|
||||
|
||||
@@ -15,29 +15,41 @@
|
||||
|
||||
与 H5「加氢订单」为同一业务:站端上报加氢流水。Web 供 PC 操作;H5 供移动端。数据结构、共享账本与对账规则一致。Web **额外**提供导出、预约加氢处理。
|
||||
|
||||
### 1.0 加氢站管理菜单分工
|
||||
|
||||
| 菜单 | 原型 | 职责 |
|
||||
|---|---|---|
|
||||
| 站点信息 | `oneos-web-h2-station-site` | 站点主数据、对账单生成与提交回写 |
|
||||
| 加氢订单 | `oneos-h5-h2-order` | 站端手机浏览器录入本站流水 |
|
||||
| 加氢记录 | **本原型** | PC 多站流水台账、导出、预约受理 |
|
||||
|
||||
### 1.1 功能清单
|
||||
|
||||
| # | 功能 | 说明 |
|
||||
|---|---|---|
|
||||
| 1 | 列表 / 筛选 | 字段与 H5 一致;可按时间、车牌、站名、对账状态筛选 |
|
||||
| 2 | 新增 / 批量 / 编辑 | 字段:时间、站、车牌、单价、量、总额;已对账只读 |
|
||||
| 3 | 删除 | 仅未对账;写共享 Store |
|
||||
| 4 | 导出 | 当前筛选结果 CSV(Web 独有) |
|
||||
| 5 | 预约加氢 | 弹窗处理预约(Web 独有,与记录数据结构分离) |
|
||||
| 2 | 手工台账 | 与 H5 同逻辑:月历标已传/未传、可补传过往日;未上传今日台账不可新增;见 [feature-manual-ledger](../oneos-h5-h2-order/.spec/feature-manual-ledger.md) |
|
||||
| 3 | 新增 / 批量 / 编辑 | 字段:时间、站、车牌、**里程(选填)**、单价、量、总额;已对账只读;同站同车牌同时间拦截;总额偏离单价×量超 0.05 元需确认 |
|
||||
| 4 | 删除 | 仅未对账;写共享 Store |
|
||||
| 5 | 导出 | 当前筛选结果 CSV(Web 独有) |
|
||||
| 6 | 预约加氢 | 列表工具栏左上角第三个 Tab;列表展示预约信息与处理情况;**接受后自动生成未对账加氢记录** |
|
||||
|
||||
### 1.2 列表字段
|
||||
|
||||
加氢时间、加氢站名称、车牌号、氢气单价(元/kg)、加氢量、加氢总额、对账状态、对账时间。
|
||||
加氢时间、加氢站名称、车牌号、里程(km)、氢气单价(元/kg)、加氢量、加氢总额、核对状态、核对时间、对账状态、对账时间。
|
||||
|
||||
---
|
||||
|
||||
## 2. 验收项
|
||||
|
||||
1. 列表/表单字段与 H5「加氢订单」一致(无公里数 / 充装员 / 承担方式录入)
|
||||
1. 列表/表单字段与 H5「加氢订单」一致(含选填里程;无充装员 / 承担方式录入)
|
||||
2. 与 H5 读写同一共享 Store;一端增删改后另一端可见(同会话)
|
||||
3. 已对账不可编辑、删除
|
||||
4. 导出列与列表字段一致
|
||||
5. 预约加氢入口与弹窗仍可用
|
||||
3. 已核对或已对账不可编辑、删除;**已对账记录对账时间不为空**(对账单回写);核对由车辆氢费明细「完成核对」触发
|
||||
4. 导出列与列表字段一致(含核对/对账)
|
||||
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) §5–6 |
|
||||
| 预约接受落账 | 接受 → 未对账草稿流水 | [record-data-model.md](./record-data-model.md) §7 |
|
||||
|
||||
81
src/prototypes/oneos-web-h2-station/.spec/ui-review.md
Normal file
81
src/prototypes/oneos-web-h2-station/.spec/ui-review.md
Normal 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-17(P0–P2 已按规范落地)
|
||||
|
||||
## 总体点评
|
||||
|
||||
加氢记录页是典型中后台列表:筛选、近 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'`、中后台列表形态一致)。
|
||||
@@ -2,10 +2,9 @@
|
||||
"documentVersion": 1,
|
||||
"format": "axhub-annotation-source",
|
||||
"data": {
|
||||
"version": 1,
|
||||
"version": 2,
|
||||
"prototypeName": "oneos-web-h2-station",
|
||||
"pageId": "h2-record",
|
||||
"updatedAt": 1784013600000,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "hr-filter",
|
||||
@@ -13,7 +12,9 @@
|
||||
"title": "筛选条件",
|
||||
"pageId": "h2-record",
|
||||
"locator": {
|
||||
"selectors": [".lc-filter-card"],
|
||||
"selectors": [
|
||||
".lc-filter-card"
|
||||
],
|
||||
"fingerprint": "filter|h2-record",
|
||||
"path": []
|
||||
},
|
||||
@@ -31,11 +32,13 @@
|
||||
"title": "列表工具栏",
|
||||
"pageId": "h2-record",
|
||||
"locator": {
|
||||
"selectors": [".lc-table-toolbar"],
|
||||
"selectors": [
|
||||
".lc-table-toolbar"
|
||||
],
|
||||
"fingerprint": "toolbar|h2-record",
|
||||
"path": []
|
||||
},
|
||||
"annotationText": "Web 独有:预约加氢、导出;新增/编辑写入与 H5 同一共享账本。已对账不可改删。",
|
||||
"annotationText": "有待处理预约时显示全站待处理条数。弹窗含加氢站名称(全站运营视角)。接受预约将自动生成一条未对账加氢记录(时间=预约时间、量=预约量);拒绝仅改预约状态。",
|
||||
"hasMarkdown": false,
|
||||
"color": "#059669",
|
||||
"images": [],
|
||||
@@ -49,39 +52,44 @@
|
||||
"title": "加氢记录列表",
|
||||
"pageId": "h2-record",
|
||||
"locator": {
|
||||
"selectors": [".lc-list-table"],
|
||||
"selectors": [
|
||||
".lc-list-table"
|
||||
],
|
||||
"fingerprint": "table|h2-record",
|
||||
"path": []
|
||||
},
|
||||
"annotationText": "列:加氢时间、站名、车牌、单价、量、总额、对账状态、对账时间(与 H5 一致)。",
|
||||
"annotationText": "列:加氢时间、站名、车牌、里程、单价、量、总额、核对状态/时间、对账状态/时间。已核对或已对账禁改删;对账仅对账单提交后出现。",
|
||||
"hasMarkdown": false,
|
||||
"color": "#FF7D00",
|
||||
"images": [],
|
||||
"createdAt": 1784013600000,
|
||||
"updatedAt": 1784013600000,
|
||||
"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": [
|
||||
{
|
||||
"id": "hr-prd-root",
|
||||
"title": "产品需求",
|
||||
"type": "folder",
|
||||
"children": [
|
||||
{
|
||||
"id": "hr-prd-main",
|
||||
"title": "加氢记录(Web)PRD",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
"updatedAt": 1784013600000
|
||||
},
|
||||
"markdownMap": {},
|
||||
"assetMap": {}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import React from 'react';
|
||||
import '../vehicle-management/style.css';
|
||||
import Page2 from './pages/02-加氢记录.jsx';
|
||||
import { OneosWebLegacyShell } from '../../common/oneosWebLegacy/OneosWebLegacyShell';
|
||||
import { AnnotationViewer, type AnnotationSourceDocument } from '@axhub/annotation';
|
||||
import annotationSourceDocument from './annotation-source.json';
|
||||
|
||||
const pages = [
|
||||
{ id: 'h2-record', title: '加氢记录', component: Page2 },
|
||||
@@ -15,10 +17,23 @@ const pages = [
|
||||
|
||||
export default function OneosWebH2Station() {
|
||||
return (
|
||||
<OneosWebLegacyShell
|
||||
moduleTitle="加氢记录"
|
||||
pages={pages}
|
||||
defaultPageId="h2-record"
|
||||
/>
|
||||
<>
|
||||
<OneosWebLegacyShell
|
||||
moduleTitle="加氢记录"
|
||||
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,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
15
src/prototypes/oneos-web-h2-station/pages/README.md
Normal file
15
src/prototypes/oneos-web-h2-station/pages/README.md
Normal 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` 已迁移至站点信息原型包。
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| 文档版本 | v2.3(业务版) |
|
||||
| 文档版本 | v2.4(业务版) |
|
||||
| 产品模块 | 台账数据 → 车辆氢费明细 |
|
||||
| 文档类型 | 产品需求说明 |
|
||||
| 适用读者 | 产品、业务、运营、测试、项目 |
|
||||
| 修订说明 | 同步成本单价校验图标与一键替换、固定/阶梯定价标签及与加氢站价格配置取价逻辑 |
|
||||
| 修订说明 | v2.4:核对 vs 对账双状态——「完成核对」≠ 对账结案;对账仅由加氢站对账单提交回写 |
|
||||
| 双状态规格 | [verify-reconcile.md](./verify-reconcile.md) |
|
||||
|
||||
---
|
||||
|
||||
@@ -15,7 +16,7 @@
|
||||
### 1.1 业务痛点
|
||||
|
||||
- 车辆加氢费用分散在业务同事手工记录中,缺少统一台账,难以按客户、站点、时间核对。
|
||||
- 录入后需区分「是否已正式提交」「是否已完成对账」,避免未核实数据进入对账与结算环节。
|
||||
- 录入后需区分「是否已正式提交」「是否已完成核对」「是否已完成对账」,避免未核实数据进入对账与结算环节。
|
||||
- 加氢量偏大、单价与公司在该站该时段的标准价不一致等情况,需要**提前暴露**,减少后续财务纠纷。
|
||||
|
||||
### 1.2 产品价值
|
||||
@@ -23,7 +24,7 @@
|
||||
| 价值点 | 说明 |
|
||||
|--------|------|
|
||||
| 统一台账 | 一条记录对应一次加氢业务,关联车辆、客户、站点与金额 |
|
||||
| 流程可控 | 待保存 → 未对账 → 已对账,责任边界清晰 |
|
||||
| 流程可控 | 待保存 → 保存(未核对+未对账)→ **完成核对** → 对账单提交 → **已对账** |
|
||||
| 效率提升 | 支持批量导入、批量改价、复制同类记录 |
|
||||
| 风险可见 | 异常数据标黄,可一键只看有问题记录 |
|
||||
| 可追溯 | 关键修改留痕,便于主管复核与客诉查证 |
|
||||
|
||||
@@ -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
|
||||
@@ -1,5 +1,5 @@
|
||||
// 【重要】必须使用 const Component 作为组件变量名
|
||||
// 台账数据 - 车辆氢费明细(业务人员手动录入;保存后为未对账,勾选后「完成对账」)
|
||||
// 台账数据 - 车辆氢费明细(业务人员手动录入;保存后未核对+未对账;勾选「完成核对」;对账由站点对账单提交)
|
||||
// 原型:联调后对接加氢站、车辆、客户、公司及登录账号接口
|
||||
|
||||
// 车辆氢费明细 PRD 正文(与 车辆氢费明细-需求文档.md 同步)
|
||||
@@ -871,6 +871,8 @@ const Component = function () {
|
||||
var RECONCILE_DRAFT = 'draft';
|
||||
var RECONCILE_PENDING = 'pending';
|
||||
var RECONCILE_RECONCILED = 'reconciled';
|
||||
var VERIFY_VERIFIED = 'verified';
|
||||
var VERIFY_UNVERIFIED = 'unverified';
|
||||
|
||||
var VEHICLE_INFO_TABLE = [
|
||||
'浙AD12345F',
|
||||
@@ -2298,7 +2300,7 @@ const Component = function () {
|
||||
}
|
||||
|
||||
function isCustomerNameWarn(row) {
|
||||
if (isReconciledRow(row)) return false;
|
||||
if (isDataLockedRow(row)) return false;
|
||||
return getCustomerNameWarnInfo(row).warn;
|
||||
}
|
||||
|
||||
@@ -2319,7 +2321,7 @@ const Component = function () {
|
||||
}
|
||||
|
||||
function canQuickReplaceCustomer(row, isAdminRole) {
|
||||
if (!row || isReconciledRow(row)) return false;
|
||||
if (!row || isDataLockedRow(row)) return false;
|
||||
if (isAdminRole) return true;
|
||||
return row.createdBy === CURRENT_USER.id;
|
||||
}
|
||||
@@ -2535,28 +2537,45 @@ const Component = function () {
|
||||
return row && row.reconcileStatus === RECONCILE_RECONCILED;
|
||||
}
|
||||
|
||||
function isVerifiedRow(row) {
|
||||
return row && (row.verifyStatus === VERIFY_VERIFIED || row.verifyStatus === 'verified');
|
||||
}
|
||||
|
||||
function isStatementLockedRow(row) {
|
||||
return !!(row && row.statementPatched);
|
||||
}
|
||||
|
||||
/** 已核对或已对账后业务员侧锁定 */
|
||||
function isDataLockedRow(row) {
|
||||
return isVerifiedRow(row) || isReconciledRow(row) || isStatementLockedRow(row);
|
||||
}
|
||||
|
||||
function rowDisplaySortTier(row) {
|
||||
if (isReconciledRow(row)) return 0;
|
||||
if (isPendingRow(row)) return 1;
|
||||
return 2;
|
||||
if (isVerifiedRow(row)) return 1;
|
||||
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) {
|
||||
if (isReconciledRow(row)) return '已对账';
|
||||
if (isPendingRow(row)) return '未对账';
|
||||
if (isPendingRow(row) || isVerifiedRow(row)) return '未对账';
|
||||
return '';
|
||||
}
|
||||
|
||||
function canShowPendingActions(row) {
|
||||
return isPendingRow(row);
|
||||
return isPendingRow(row) && !isVerifiedRow(row);
|
||||
}
|
||||
|
||||
function canCopyRow(row) {
|
||||
if (!row || isReconciledRow(row)) return false;
|
||||
if (!row || isDataLockedRow(row)) return false;
|
||||
if (isPendingRow(row)) return true;
|
||||
return isNewRow(row) && row.createdBy === CURRENT_USER.id;
|
||||
}
|
||||
@@ -2602,15 +2621,16 @@ const Component = function () {
|
||||
companyName: sourceRow.companyName || '',
|
||||
createdAt: now,
|
||||
reconcileStatus: null,
|
||||
verifyStatus: null,
|
||||
savedAt: null,
|
||||
verifiedAt: null,
|
||||
reconciledAt: null
|
||||
};
|
||||
return recalcAllRows([draft])[0];
|
||||
}
|
||||
|
||||
function isRowBatchEditable(row) {
|
||||
if (isStatementLockedRow(row)) return false;
|
||||
if (isReconciledRow(row)) return false;
|
||||
if (isDataLockedRow(row)) return false;
|
||||
if (isPendingRow(row)) return true;
|
||||
return isNewRow(row) && row.createdBy === CURRENT_USER.id;
|
||||
}
|
||||
@@ -2941,8 +2961,7 @@ const Component = function () {
|
||||
receiptDate: patch.receiptDate,
|
||||
receiptAmount: patch.receiptAmount != null ? patch.receiptAmount : r.receiptAmount,
|
||||
stationPaymentStatus: patch.paymentStatus || 'paid',
|
||||
paymentStatus: patch.paymentStatus || 'paid',
|
||||
reconciledAt: patch.reconcileDate
|
||||
paymentStatus: patch.paymentStatus || 'paid'
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -3081,7 +3100,9 @@ const Component = function () {
|
||||
remark: '',
|
||||
createdAt: day,
|
||||
reconcileStatus: reconcileStatus,
|
||||
verifyStatus: reconcileStatus === RECONCILE_RECONCILED ? VERIFY_VERIFIED : (reconcileStatus === RECONCILE_PENDING ? VERIFY_UNVERIFIED : null),
|
||||
savedAt: reconcileStatus !== RECONCILE_DRAFT ? day : null,
|
||||
verifiedAt: reconcileStatus === RECONCILE_RECONCILED ? day : null,
|
||||
reconciledAt: reconcileStatus === RECONCILE_RECONCILED ? day : null
|
||||
});
|
||||
}
|
||||
@@ -3105,7 +3126,9 @@ const Component = function () {
|
||||
remark: '客户名称校验演示',
|
||||
createdAt: demoDay,
|
||||
reconcileStatus: RECONCILE_PENDING,
|
||||
savedAt: demoDay
|
||||
verifyStatus: VERIFY_UNVERIFIED,
|
||||
savedAt: demoDay,
|
||||
verifiedAt: null
|
||||
},
|
||||
draft
|
||||
);
|
||||
@@ -3794,34 +3817,38 @@ const Component = function () {
|
||||
setEditingKeys([]);
|
||||
}, [allRows, appendChangeLog]);
|
||||
|
||||
var handleCompleteReconcile = useCallback(function () {
|
||||
var handleCompleteVerify = useCallback(function () {
|
||||
if (!selectedRowKeys.length) {
|
||||
message.warning('请先勾选需要对账的记录');
|
||||
message.warning('请先勾选需要核对的记录');
|
||||
return;
|
||||
}
|
||||
var keySet = {};
|
||||
selectedRowKeys.forEach(function (k) { keySet[k] = true; });
|
||||
var selected = allRows.filter(function (r) { return keySet[r.key]; });
|
||||
var notPending = selected.filter(function (r) { return !isPendingRow(r); });
|
||||
if (notPending.length) {
|
||||
message.warning('仅可对「未对账」状态的记录完成对账');
|
||||
var invalid = selected.filter(function (r) { return !isPendingRow(r) || isVerifiedRow(r); });
|
||||
if (invalid.length) {
|
||||
message.warning('仅可对「已保存且未核对」的记录完成核对');
|
||||
return;
|
||||
}
|
||||
var reconciledAt = nowDayjs();
|
||||
var verifiedAt = nowDayjs();
|
||||
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) {
|
||||
return prev.map(function (r) {
|
||||
if (keySet[r.key] && isPendingRow(r)) {
|
||||
return Object.assign({}, r, { reconcileStatus: RECONCILE_RECONCILED, reconciledAt: reconciledAt });
|
||||
if (keySet[r.key] && isPendingRow(r) && !isVerifiedRow(r)) {
|
||||
return Object.assign({}, r, {
|
||||
verifyStatus: VERIFY_VERIFIED,
|
||||
verifiedAt: verifiedAt,
|
||||
reconciledAt: verifiedAt
|
||||
});
|
||||
}
|
||||
return r;
|
||||
});
|
||||
});
|
||||
setSelectedRowKeys([]);
|
||||
setEditingKeys([]);
|
||||
message.success('已完成对账,共 ' + selected.length + ' 条');
|
||||
message.success('已完成核对,共 ' + selected.length + ' 条');
|
||||
}, [selectedRowKeys, allRows, appendChangeLog]);
|
||||
|
||||
var rowSelection = useMemo(
|
||||
@@ -3832,7 +3859,7 @@ const Component = function () {
|
||||
setSelectedRowKeys(keys);
|
||||
},
|
||||
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',
|
||||
width: 72,
|
||||
width: 88,
|
||||
align: 'center',
|
||||
render: function (_, record) {
|
||||
var label = rowStatusLabel(record);
|
||||
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);
|
||||
}
|
||||
},
|
||||
@@ -5475,12 +5514,12 @@ const Component = function () {
|
||||
React.createElement(
|
||||
Popconfirm,
|
||||
{
|
||||
title: '完成对账后数据将标记为已对账,只能联系主管进行修改,是否确认。',
|
||||
title: '完成核对后数据将标记为已核对,只能联系主管进行修改;对账须待加氢站对账单提交后才会变为已对账。是否确认?',
|
||||
okText: '确认',
|
||||
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' }, '完成核对')
|
||||
)
|
||||
)
|
||||
),
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"path": []
|
||||
},
|
||||
"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,
|
||||
"color": "#0f766e",
|
||||
"images": [],
|
||||
@@ -83,7 +83,7 @@
|
||||
"path": []
|
||||
},
|
||||
"aiPrompt": "保存、对账、导入导出与状态流转。",
|
||||
"annotationText": "# 工具栏与状态流转\n\n| 按钮 | 作用 |\n|---|---|\n| 批量导入 | 模板化上传多条记录 |\n| 批量导出 | 导出未对账+已对账,自选列 |\n| 保存 | 待保存 → 未对账 |\n| 完成对账 | 勾选未对账行结案为已对账 |\n\n**保存**与**完成对账**区别:保存是提交草稿;完成对账表示与客户/内部核对无误,业务员不可再改删。",
|
||||
"annotationText": "# 工具栏与状态流转\n\n| 按钮 | 作用 |\n|---|---|\n| 批量导入 | 模板化上传多条记录 |\n| 批量导出 | 导出未对账+已对账,自选列 |\n| 保存 | 待保存 → 未对账 |\n| 完成核对 | 勾选未核对行标记为已核对;对账须待站点对账单提交 |\n\n**保存**与**完成核对**区别:保存是提交草稿;完成核对表示能源部确认数据无误;对账结案由加氢站对账单提交触发。",
|
||||
"hasMarkdown": true,
|
||||
"color": "#334155",
|
||||
"images": [],
|
||||
@@ -146,7 +146,7 @@
|
||||
"path": []
|
||||
},
|
||||
"aiPrompt": "待保存与未对账状态转换。",
|
||||
"annotationText": "将待保存行提交为「未对账」正式数据,方可勾选完成对账。",
|
||||
"annotationText": "将待保存行提交为「未核对+未对账」正式数据,方可勾选完成核对。",
|
||||
"hasMarkdown": false,
|
||||
"color": "#2563eb",
|
||||
"images": [],
|
||||
@@ -157,7 +157,7 @@
|
||||
{
|
||||
"id": "vh2-btn-reconcile",
|
||||
"index": 8,
|
||||
"title": "完成对账",
|
||||
"title": "完成核对",
|
||||
"pageId": "list",
|
||||
"locator": {
|
||||
"selectors": [
|
||||
@@ -283,10 +283,10 @@
|
||||
]
|
||||
},
|
||||
"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-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-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表头支持批量改价(成本单价、加氢单价、承担方式)。"
|
||||
@@ -304,7 +304,7 @@
|
||||
"type": "markdown",
|
||||
"id": "vh2-doc-overview",
|
||||
"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",
|
||||
@@ -348,7 +348,7 @@
|
||||
"type": "markdown",
|
||||
"id": "vh2-doc-table",
|
||||
"title": "列表与合计",
|
||||
"markdown": "# 列表与合计\n\n顶部合计条实时汇总可见行的加氢量、成本总价、加氢总价。\n\n列表支持行内编辑、复制、批量改价、变更日志;仅未对账可勾选完成对账。\n\n成本单价列含固定/阶梯标签、标准价校验图标与一键替换;客户名称列含运营态校验、警示图标与一键替换(见异常数据说明)。"
|
||||
"markdown": "# 列表与合计\n\n顶部合计条实时汇总可见行的加氢量、成本总价、加氢总价。\n\n列表支持行内编辑、复制、批量改价、变更日志;仅未核对可勾选完成核对;已对账由对账单提交产生。\n\n成本单价列含固定/阶梯标签、标准价校验图标与一键替换;客户名称列含运营态校验、警示图标与一键替换(见异常数据说明)。"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -335,6 +335,6 @@ import { OperationActions } from '../../common/OperationActions';
|
||||
| 租赁业务台账 | `src/prototypes/lease-business-ledger/index.tsx` |
|
||||
| 客户管理 | `src/prototypes/customer-management/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/lease-contract-management/LeaseContractManagement.jsx` |
|
||||
|
||||
@@ -343,7 +343,7 @@ const vmTheme = {
|
||||
| 车辆资产 | `src/prototypes/vehicle-management/index.tsx` |
|
||||
| 租赁合同 | `src/prototypes/lease-contract-management/LeaseContractManagement.jsx` |
|
||||
| 租赁业务台账 | `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` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user