迭代加氢订单、站点记录与台账链路,同步审批组件与导航注册,并下线旧工作台入口。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
王冕
2026-07-21 00:04:50 +08:00
parent 84a25c42df
commit e39df1c7c8
67 changed files with 6082 additions and 5933 deletions

View File

@@ -47,6 +47,7 @@ const MORE_ACTION_ICONS: Record<string, LucideIcon> = {
manage: Settings2,
preview: Eye,
download: Download,
failDetail: FileText,
};
function renderMenuItemLabel(item: OperationActionItem) {
@@ -95,7 +96,7 @@ function ViewIcon() {
}
function MoreIcon() {
return <MoreHorizontal className="vm-op-action__icon" aria-hidden />;
return <MoreHorizontal className="vm-op-more-btn__icon" aria-hidden strokeWidth={1.75} />;
}
const PrimaryActionButton = React.forwardRef<
@@ -121,18 +122,22 @@ const PrimaryActionButton = React.forwardRef<
},
ref,
) {
const isMore = icon === 'more';
return (
<button
ref={ref}
type="button"
className={['vm-op-action', className].filter(Boolean).join(' ')}
className={[
isMore ? 'vm-op-more-btn' : 'vm-op-action',
className,
].filter(Boolean).join(' ')}
aria-label={ariaLabel}
aria-haspopup={ariaHasPopup ? 'menu' : undefined}
disabled={disabled}
onClick={onClick}
>
{icon === 'view' ? <ViewIcon /> : <MoreIcon />}
<span className="vm-op-action__label">{label}</span>
{isMore ? <MoreIcon /> : <ViewIcon />}
{isMore ? null : <span className="vm-op-action__label">{label}</span>}
</button>
);
});
@@ -236,9 +241,9 @@ export function OperationActions({
<PrimaryActionButton
icon="more"
label={moreLabel}
ariaLabel={moreLabel + '操作'}
ariaLabel="更多操作"
ariaHasPopup
className={moreOpen ? 'vm-op-action--open' : undefined}
className={moreOpen ? 'vm-op-more-btn--open' : undefined}
/>
</Dropdown>
) : null}

View File

@@ -11,6 +11,41 @@ var H2_VERIFY_UNVERIFIED = 'unverified';
var H2_RECORD_SOURCE_STATION = 'station';
var H2_RECORD_SOURCE_LINGNIU = 'lingniu';
/**
* 原型本地「系统车辆列表」车牌(不含尾缀 F。命中 → 羚牛车辆,否则 → 非羚牛车辆。
* 正式环境应改为车辆管理接口;未接真实 API。
*/
var H2_FLEET_PLATE_KEYS = [
'浙A55666', '浙A77888', '浙A99001', '浙A12345', '浙A67890', '浙A88888', '浙A03561',
'浙B23456', '浙B99999', '浙B58888',
'沪A88888', '沪BDB9161', '沪ADB9161',
'苏E33333',
'浙A88H201', '浙F00688', '浙F07588', '浙F06618',
'粤AGR8556', '粤AGP5156',
/* 车辆氢费明细演示车牌 */
'浙AD12345', '浙AH55660', '浙BK33210', '粤BK33210', '沪AD12345', '苏EF99887', '京CN88771', '川AL55602'
];
var H2_FLEET_PLATE_SET = (function () {
var set = {};
H2_FLEET_PLATE_KEYS.forEach(function (p) {
set[String(p).toUpperCase()] = true;
});
return set;
})();
/** 车牌规范化键:去尾缀 F、大写、去空白 */
function h2BridgeNormalizePlateKey(plateNo) {
return String(plateNo || '').trim().toUpperCase().replace(/F$/u, '');
}
/** 是否羚牛车辆(系统车辆列表命中) */
function h2BridgeIsLingniuVehicle(plateNo) {
var key = h2BridgeNormalizePlateKey(plateNo);
if (!key) return false;
return Boolean(H2_FLEET_PLATE_SET[key]);
}
var H2_STATION_CODE_MAP = {
'中国石油中油高新能源牙谷加油加氢站': 'JX-H2-001',
'杭州临平加氢站': 'HZ-H2-002',
@@ -41,7 +76,9 @@ var H2_CANONICAL_LEDGER_SEED = [
{ 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 }
{ 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 },
/* 非羚牛车辆演示:车牌不在系统车辆列表 → 列表核对/对账字段应显示为空 */
{ key: 'rf-ext-1', id: 'rf-ext-1', stationId: 'JX-H2-001', stationName: '中国石油中油高新能源牙谷加油加氢站', hydrogenTime: '2026-07-13 14:22:00', plateNo: '浙C77801F', customerName: '中国石油中油高新能源牙谷加油加氢站·站端上报', hydrogenKg: 8.5, costUnitPrice: 42.5, costTotal: 361.25, customerUnitPrice: 42.5, customerAmount: 361.25, settlementStatus: 'customer', mileageKm: 88000, creatorName: '站端账号', verifyStatus: H2_VERIFY_UNVERIFIED, verifiedAt: null, reconcileStatus: H2_RECONCILE_PENDING }
];
function h2BridgeFormatDateTime(value) {
@@ -263,26 +300,28 @@ function h2BridgeResolveStationId(stationId, stationName) {
return a || '';
}
/** 手工台账种子:日已上传;日默认未上传,便于演示拦截新增 */
/** 手工台账种子:日已上传;日默认未上传,便于演示「前一日缺失则今日禁新增 */
function h2BridgeBuildInitialManualLedgers() {
var yesterday = h2BridgeYesterdayDateKey();
var d = new Date();
d.setDate(d.getDate() - 2);
var dayBeforeYesterday = h2BridgeTodayDateKey(d);
return [
{
id: 'ml-seed-yesterday',
id: 'ml-seed-day-before-yesterday',
stationId: 'JX-H2-001',
stationName: '中国石油中油高新能源牙谷加油加氢站',
ledgerDate: yesterday,
ledgerDate: dayBeforeYesterday,
images: [
{
id: 'ml-img-seed-1',
name: '日手工台账.jpg',
name: '日手工台账.jpg',
dataUrl: '',
placeholder: true,
archived: true,
uploadedAt: yesterday + ' 18:30:00'
uploadedAt: dayBeforeYesterday + ' 18:30:00'
}
],
updatedAt: yesterday + ' 18:30:00'
updatedAt: dayBeforeYesterday + ' 18:30:00'
}
];
}
@@ -507,6 +546,9 @@ function h2BridgeMapToStationLedgerRow(row, index) {
costTotal: costTotal,
settlementStatus: row.settlementStatus,
orderNo: h2BridgeBuildRefuelOrderNo(row, index),
/* 对账单筛选依赖:须透出核对状态,否则站点侧会把全部行当成未核对 */
verifyStatus: row.verifyStatus === H2_VERIFY_VERIFIED ? H2_VERIFY_VERIFIED : H2_VERIFY_UNVERIFIED,
verifiedAt: row.verifiedAt || null,
reconcileStatus: row.reconcileStatus || (isReconciled ? H2_RECONCILE_RECONCILED : H2_RECONCILE_PENDING),
statementRecordId: row.statementRecordId || null,
reconcileDate: row.reconcileDate || null,
@@ -732,6 +774,7 @@ function h2BridgeInit() {
totalAmountMismatch: h2BridgeTotalAmountMismatch,
lookupStationUnitPrice: h2BridgeLookupStationUnitPrice,
todayDateKey: h2BridgeTodayDateKey,
yesterdayDateKey: h2BridgeYesterdayDateKey,
getManualLedger: function (stationIdOrName, ledgerDate) {
return store.getManualLedger(stationIdOrName, ledgerDate);
},
@@ -744,9 +787,30 @@ function h2BridgeInit() {
upsertManualLedger: function (input) {
return store.upsertManualLedger(input);
},
/** 前一日台账未上传(且昨日≥本站首笔加氢日)→ 今日禁新增 */
isManualLedgerGateBlocked: function (stationIdOrName) {
var station = String(stationIdOrName || '').trim();
if (!station) return false;
var todayKey = h2BridgeTodayDateKey();
var yesterdayKey = h2BridgeYesterdayDateKey();
var rows = store.getRows() || [];
var earliest = null;
rows.forEach(function (row) {
var sid = String(row.stationId || '').trim();
var sn = String(row.stationName || '').trim();
if (sid !== station && sn !== station) return;
var m = String(row.hydrogenTime || '').match(/^(\d{4}-\d{2}-\d{2})/);
if (!m) return;
if (!earliest || m[1] < earliest) earliest = m[1];
});
if (!earliest || yesterdayKey > todayKey || yesterdayKey < earliest) return false;
return !store.hasManualLedgerForDate(station, yesterdayKey);
},
upsertRow: function (row) { return store.upsertRow(row); },
removeRow: function (id) { return store.removeRow(id); },
subscribe: function (fn) { return store.subscribe(fn); }
subscribe: function (fn) { return store.subscribe(fn); },
isLingniuVehicle: h2BridgeIsLingniuVehicle,
normalizePlateKey: h2BridgeNormalizePlateKey
};
}
return store;
@@ -776,5 +840,7 @@ export {
h2BridgeFindDuplicateRow,
h2BridgeTotalAmountMismatch,
h2BridgeLookupStationUnitPrice,
h2BridgeInferRecordSource
h2BridgeInferRecordSource,
h2BridgeIsLingniuVehicle,
h2BridgeNormalizePlateKey
};

View File

@@ -15,7 +15,7 @@ export interface ApprovalCardProps {
function statusClassName(status: ApprovalCardItem['status']): string {
if (status === 'approved') return 'ap-status ap-status--approved';
if (status === 'rejected') return 'ap-status ap-status--rejected';
if (status === 'rejected' || status === 'terminated') return 'ap-status ap-status--rejected';
return 'ap-status ap-status--pending';
}

View File

@@ -32,7 +32,9 @@ export interface ApprovalDetailPlaceholderProps {
function statusBadgeClass(status: ApprovalCardItem['status']): string {
if (status === 'approved') return 'ap-detail__badge ap-detail__badge--approved';
if (status === 'rejected') return 'ap-detail__badge ap-detail__badge--rejected';
if (status === 'rejected' || status === 'terminated') {
return 'ap-detail__badge ap-detail__badge--rejected';
}
return 'ap-detail__badge ap-detail__badge--pending';
}

View File

@@ -43,7 +43,8 @@ const STATUS_LABEL_MAP: Record<ApprovalStatus, string> = {
pending: '审批中',
processing: '审批中',
approved: '已通过',
rejected: '驳回',
rejected: '驳回',
terminated: '被终止',
};
export function getApprovalTypeLabel(type: ApprovalTypeKey): string {

View File

@@ -1,4 +1,4 @@
export type ApprovalStatus = 'pending' | 'processing' | 'approved' | 'rejected';
export type ApprovalStatus = 'pending' | 'processing' | 'approved' | 'rejected' | 'terminated';
export type ApprovalTabKey = 'todo' | 'done' | 'initiated' | 'cc';

View File

@@ -84,6 +84,12 @@
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ln-primary, #32a06e) 35%, transparent);
}
.vm-op-more-btn--open,
.vm-op-more-btn[aria-expanded="true"] {
color: var(--ln-ink, #0f172a);
background: var(--ln-canvas-soft, #f4f4f5);
}
.vm-op-more-btn__icon {
width: 18px;
height: 18px;

View File

@@ -43,7 +43,10 @@ const moment = dayjs;
const ANCHOR_TODAY = '2026-06-01';
const IPC_STORAGE_KEY = 'oneos_ipc_insurance_v1';
const IPC_COMPARE_SHEETS_KEY = 'oneos_ipc_compare_sheets_v1';
const IPC_POLICY_RECOGN_TASKS_KEY = 'oneos_ipc_policy_recogn_tasks_v2';
const IPC_POLICY_RECOGN_TASKS_KEY = 'oneos_ipc_policy_recogn_tasks_v3';
const POLICY_RECOGN_FAIL_REASON_BLUR = 'OCR 识别失败,请检查文件清晰度或重新上传';
const POLICY_RECOGN_FAIL_REASON_FORMAT = '文件格式或版式无法解析,请上传清晰 PDF / 图片保单';
const POLICY_RECOGN_FAIL_REASON_CORRUPT = '文件损坏或内容为空,请重新导出后上传';
const IPC_INSURANCE_HISTORY_EDITS_KEY = 'oneos_ipc_insurance_history_edits_v1';
const IPC_EDIT_PLATE_KEY = 'oneos_ipc_edit_plate';
const NO_PLATE_LABEL = '暂无车牌';
@@ -1084,17 +1087,35 @@ const buildMockOcrResults = (files, mode, insuranceTypeLabel, allInsurance) => (
mode
);
if (files.length >= 2 && idx === files.length - 1) {
return {
...result,
recognSuccess: false,
matched: false,
matchTip: 'OCR 识别失败,请检查文件清晰度或重新上传',
};
return markRecognResultFailed(result, POLICY_RECOGN_FAIL_REASON_BLUR);
}
return result;
})
);
/** 识别失败原因:优先 failReason回退 matchTip */
const getPolicyRecognFailReason = (result) => (
String(result?.failReason || result?.matchTip || '').trim() || '识别失败,原因未知'
);
const getPolicyRecognFailResults = (results) => (
(results || [])
.filter((r) => r.recognSuccess === false)
.map((r) => ({
id: r.id || r.fileUid || r.fileName,
fileName: r.fileName || '未命名文件',
failReason: getPolicyRecognFailReason(r),
}))
);
const markRecognResultFailed = (result, reason) => ({
...result,
recognSuccess: false,
matched: false,
matchTip: reason,
failReason: reason,
});
const recognResultToPolicyDetail = (result, taskMode = 'policy') => {
const mode = resolvePolicyRecognEffectiveMode(taskMode, result);
const pd = result.policyDetail || {};
@@ -2561,6 +2582,13 @@ const createMockPolicyRecognTasks = () => {
];
const resultsCompleted1 = buildMockOcrResults(filesCompleted1, 'policy', '交强险', insMap);
if (resultsCompleted1[0]) resultsCompleted1[0].confirmed = true;
// 末条失败清晰度问题buildMockOcrResults 已标记);再给另一张不同失败原因
if (resultsCompleted1[1]) {
resultsCompleted1[1] = markRecognResultFailed(
resultsCompleted1[1],
POLICY_RECOGN_FAIL_REASON_FORMAT,
);
}
const filesCompleted2 = [
{ uid: 'demo-c4', name: '粤B88888_复驶批单.pdf', status: 'done' },
@@ -2572,6 +2600,14 @@ const createMockPolicyRecognTasks = () => {
resultsCompleted2.forEach((r) => {
if (r.recognSuccess !== false) r.confirmed = true;
});
// 末条已是清晰度失败;确保 failReason 字段齐全
const lastResumeFail = resultsCompleted2[resultsCompleted2.length - 1];
if (lastResumeFail && lastResumeFail.recognSuccess === false) {
resultsCompleted2[resultsCompleted2.length - 1] = markRecognResultFailed(
lastResumeFail,
POLICY_RECOGN_FAIL_REASON_BLUR,
);
}
const filesSuspend = [
{ uid: 'demo-s1', name: '沪A06192F_商业险_停保批单.pdf', status: 'done' },
@@ -2579,6 +2615,18 @@ const createMockPolicyRecognTasks = () => {
];
const resultsSuspend = buildMockOcrResults(filesSuspend, 'suspend', '', insMap);
// 全部失败任务:便于演示「无成功仍可看失败明细」
const filesFailOnly = [
{ uid: 'demo-f1', name: '损坏文件_保单.pdf', status: 'done' },
{ uid: 'demo-f2', name: '非保单截图.jpg', status: 'done' },
];
const resultsFailOnly = buildMockOcrResults(filesFailOnly, 'policy', '商业险', insMap).map((r, idx) => (
markRecognResultFailed(
r,
idx === 0 ? POLICY_RECOGN_FAIL_REASON_CORRUPT : POLICY_RECOGN_FAIL_REASON_FORMAT,
)
));
return [
buildPolicyRecognTaskRecord({
id: 'TASK-83892906',
@@ -2616,6 +2664,18 @@ const createMockPolicyRecognTasks = () => {
totalFileCount: filesSuspend.length,
recognDoneCount: filesSuspend.length,
}),
buildPolicyRecognTaskRecord({
id: 'TASK-84450001',
entry: 'ocr',
mode: 'policy',
insuranceType: '商业险',
results: resultsFailOnly,
createdAt: '2026-06-03 11:05:00',
completedAt: '2026-06-03 11:06:20',
phase: 'results',
totalFileCount: filesFailOnly.length,
recognDoneCount: filesFailOnly.length,
}),
buildPolicyRecognTaskRecord({
id: 'TASK-84210588',
entry: 'ocr',
@@ -3317,6 +3377,8 @@ const Component = function () {
return stored && stored.length ? stored : createMockPolicyRecognTasks();
});
const [policyRecognTasksOpen, setPolicyRecognTasksOpen] = useState(false);
const [policyRecognFailDetailOpen, setPolicyRecognFailDetailOpen] = useState(false);
const [policyRecognFailDetailTask, setPolicyRecognFailDetailTask] = useState(null);
const [policyRecognTasksFilters, setPolicyRecognTasksFilters] = useState(() => ({ ...DEFAULT_POLICY_RECOGN_TASK_FILTERS }));
const [appliedPolicyRecognTasksFilters, setAppliedPolicyRecognTasksFilters] = useState(() => ({ ...DEFAULT_POLICY_RECOGN_TASK_FILTERS }));
const [policyAddOpen, setPolicyAddOpen] = useState(false);
@@ -4602,10 +4664,27 @@ const Component = function () {
if (preferred) {
setPolicyRecognActiveResultId(preferred.id);
setPolicyRecognConfirmDraft(buildPolicyRecognConfirmDraft(preferred, task.mode || 'policy'));
showPolicyRecognResultPreview(preferred);
} else {
setPolicyRecognActiveResultId('');
setPolicyRecognConfirmDraft({ ...EMPTY_POLICY_DETAIL });
}
};
const openPolicyRecognFailDetail = (task) => {
const fails = getPolicyRecognFailResults(task?.results);
if (!fails.length) {
message.info('该任务没有识别失败记录');
return;
}
setPolicyRecognFailDetailTask(task);
setPolicyRecognFailDetailOpen(true);
};
const policyRecognFailDetailRows = useMemo(
() => getPolicyRecognFailResults(policyRecognFailDetailTask?.results),
[policyRecognFailDetailTask],
);
const closePolicyRecogn = () => {
const syncedResults = policyRecognPhase === 'results'
? persistActiveRecognDraft()
@@ -8120,13 +8199,31 @@ const Component = function () {
{
title: '识别失败数',
dataIndex: 'recognFailCount',
width: 96,
width: 108,
align: 'center',
render: (val) => (
<span style={{ fontVariantNumeric: 'tabular-nums', color: val > 0 ? '#dc2626' : '#64748b', fontWeight: 600 }}>
{val ?? 0}
</span>
),
render: (val, record) => {
const count = val ?? 0;
if (count > 0) {
return (
<button
type="button"
className="lc-policy-recogn-fail-count-btn"
aria-label={`查看 ${count} 条识别失败明细`}
onClick={(e) => {
e.stopPropagation();
openPolicyRecognFailDetail(record);
}}
>
{count}
</button>
);
}
return (
<span style={{ fontVariantNumeric: 'tabular-nums', color: '#64748b', fontWeight: 600 }}>
0
</span>
);
},
},
{
title: '识别进度',
@@ -8171,31 +8268,112 @@ const Component = function () {
fixed: 'right',
render: (_, record) => {
const recognizing = isPolicyRecognTaskRecognizing(record);
const noSuccess = !(record.recognSuccessCount > 0);
const disabled = recognizing || noSuccess;
const action = (
<OperationActions
view={{
label: '确认识别结果',
disabled,
onClick: () => openPolicyRecognTaskRecord(record),
}}
/>
);
const hasSuccess = (record.recognSuccessCount || 0) > 0;
const hasFail = (record.recognFailCount || 0) > 0;
if (recognizing) {
return (
<Tooltip title="请等待识别完成后操作">
<span>{action}</span>
<span>
<OperationActions
view={{
label: '确认识别结果',
disabled: true,
onClick: () => {},
}}
/>
</span>
</Tooltip>
);
}
return action;
if (!hasSuccess && hasFail) {
return (
<OperationActions
view={{
label: '查看失败明细',
onClick: () => openPolicyRecognFailDetail(record),
}}
/>
);
}
return (
<OperationActions
view={{
label: '确认识别结果',
disabled: !hasSuccess,
onClick: () => openPolicyRecognTaskRecord(record),
}}
more={hasFail ? [{
key: 'failDetail',
label: '查看失败明细',
onClick: () => openPolicyRecognFailDetail(record),
}] : undefined}
/>
);
},
},
]}
/>
</Modal>
<Modal
className="lc-policy-recogn-fail-detail-modal"
open={policyRecognFailDetailOpen}
title={policyRecognFailDetailTask
? `识别失败明细(${policyRecognFailDetailRows.length}`
: '识别失败明细'}
width={720}
centered
footer={(
<Button
type="primary"
onClick={() => {
setPolicyRecognFailDetailOpen(false);
setPolicyRecognFailDetailTask(null);
}}
>
关闭
</Button>
)}
onCancel={() => {
setPolicyRecognFailDetailOpen(false);
setPolicyRecognFailDetailTask(null);
}}
>
<Alert
type="warning"
showIcon
style={{ marginBottom: 14, borderRadius: 8 }}
message="以下文件识别未成功,不会进入确认写入台账;可核对文件后重新发起识别。"
/>
<Table
size="middle"
rowKey="id"
dataSource={policyRecognFailDetailRows}
pagination={policyRecognFailDetailRows.length > 8
? { pageSize: 8, showSizeChanger: false }
: false}
locale={{ emptyText: '暂无失败记录' }}
columns={[
{
title: '文件/保单名',
dataIndex: 'fileName',
width: 280,
ellipsis: true,
render: (val) => (
<span style={{ fontWeight: 600, color: '#0f172a' }}>{val || '—'}</span>
),
},
{
title: '失败原因',
dataIndex: 'failReason',
render: (val) => (
<span style={{ color: '#b91c1c', lineHeight: 1.45 }}>{val || '—'}</span>
),
},
]}
/>
</Modal>
<Modal
className="lc-policy-recogn-modal"
title={

View File

@@ -460,6 +460,46 @@
margin-bottom: var(--oneos-space-3);
}
/* 识别任务:失败数可点击入口 */
.lc-policy-recogn-fail-count-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 44px;
min-height: 44px;
padding: 0 8px;
margin: 0;
border: none;
border-radius: 6px;
background: transparent;
color: #dc2626;
font-size: 13px;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1.2;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
transition: background-color 0.15s ease, color 0.15s ease;
}
.lc-policy-recogn-fail-count-btn:hover,
.lc-policy-recogn-fail-count-btn:focus-visible {
color: #b91c1c;
background: color-mix(in srgb, #ef4444 10%, transparent);
outline: none;
}
.lc-policy-recogn-fail-count-btn:focus-visible {
box-shadow: 0 0 0 2px color-mix(in srgb, #ef4444 35%, transparent);
}
@media (prefers-reduced-motion: reduce) {
.lc-policy-recogn-fail-count-btn {
transition: none;
}
}
@media (max-width: 1280px) {
.vm-page.ipc-page .lc-alert-stats-row {
grid-template-columns: repeat(2, minmax(0, 1fr));

View File

@@ -6,6 +6,7 @@ import {
BookOpen,
Building2,
Calendar,
CalendarClock,
Car,
CircleDollarSign,
ClipboardCheck,
@@ -772,34 +773,53 @@ export const SELF_OPERATED_MODULES: LineModule[] = [
id: 'self-contract',
step: 1,
title: '自营合同',
subtitle: '关键信息录入 · 上传合同 · 自动交车',
subtitle: '关键信息录入 · 业务确认生效 · 可派车',
shortLabel: '合同',
accent: 'blue',
icon: FileSignature,
roles: ['业务管理组', '业务服务组'],
start: '创建自营合同,记录客户方、车型、车辆数、交车时间与地点等关键信息,并上传合同附件。',
start: '创建自营运力服务合同,记录客户方、车型、车辆数、交车时间与地点等关键信息,并上传合同附件。',
process: [
'录入自营项目与客户方、车型、车辆数量等合同要素。',
'填写交车时间、交车地点,上传自营合同扫描件。',
'合同提交后自动生成交车任务,推送运维条线处理交付。',
'业务确认生效后可创建轻量调度任务,并提示运维交车交付。',
],
closure: '合同信息与交车任务联动,自营项目从签约起即可进入交付与运营环节。',
prototypeHref: '/prototypes/oneos-web-lease-contract',
prototypeLabel: '打开车辆租赁合同',
closure: '合同界定项目边界,成为调度派车与交车的上游凭证。',
prototypeHref: '/prototypes/self-operated-contract',
prototypeLabel: '打开自营合同',
},
{
id: 'self-dispatch',
step: 2,
title: '调度任务',
subtitle: '轻量派车 · 交还车校验 · 办结出车',
shortLabel: '调度',
accent: 'indigo',
icon: CalendarClock,
roles: ['业务服务组', '调度岗', '运维部'],
start: '在生效自营合同下创建出车任务,派车派司机;非 TMS不做配载与承运商调度。',
process: [
'选择合同、计划出车日、线路说明、是否多趟与计价口径。',
'派车派司机;车辆未交车时先完成运维交车(原型可模拟交车)。',
'办结确认实际出车后,自动生成物流业务明细一行。',
],
closure: '出车事实可追溯到合同与车牌,并驱动台账落账。',
prototypeHref: '/prototypes/self-operated-dispatch-task',
prototypeLabel: '打开调度任务',
},
{
id: 'self-ledger',
step: 2,
step: 3,
title: '自营台账',
subtitle: '导入台账 · 成本利润核算 · 项目盈亏',
subtitle: '任务自动生成 · 补录导入 · 盈亏核算',
shortLabel: '台账',
accent: 'emerald',
icon: BookOpen,
roles: ['业务服务组', '财务部'],
start: '根据导入的自营台账与账单信息,汇总项目侧收入与成本数据。',
start: '承接调度办结自动生成的出车明细,并以导入/行编辑补录异常与历史数据。',
process: [
'导入或维护自营运营台账、账单明细。',
'自动计算利润侧数据,以及司机成本、车辆成本侧数据。',
'调度办结后自动写入出车营收侧字段,并计算金额/总成本/盈亏。',
'氢费、ETC 等成本一期可手工补录;导入模板保留为补录通道。',
'汇集至项目盈亏表,支撑项目经营分析。',
'演进阶段关联财务「收款记录」,形成业财记录闭环。',
],
@@ -924,26 +944,26 @@ export const BUSINESS_LINES: BusinessLineConfig[] = [
shortLabel: '自营',
hero: {
title: '自营业务条线',
tagline: '自营物流条线合同->账单业财闭环',
tagline: '自营合同 → 轻量调度 → 交还车 → 台账自动生成',
summary:
'大模块串联自营合同与自营台账:合同驱动交车任务,台账导入后自动核算司机、车辆成本与项目盈亏,逐步对接财务收款形成业财闭环。',
'大模块串联自营合同、轻量调度任务与自营台账:合同界定项目,调度派车办结驱动明细自动生成,运维交还车保障运营态,逐步对接财务收款形成业财闭环。',
stats: [
{ value: '2', label: '业务模块' },
{ value: '3', label: '业务模块' },
{ value: '4+', label: '协同部门' },
{ value: '1', label: '业财闭环' },
{ value: '1', label: '经营闭环' },
],
},
modules: SELF_OPERATED_MODULES,
loopTitle: '合同到台账,自营经营可核算',
loopTitle: '合同到调度再到台账,自营经营可核算',
loopSummary:
'自营合同录入并生成交车任务 → 运营数据导入自营台账 → 自动汇总利润与司机、车辆成本 → 汇集项目盈亏表 → 关联收款记录完成业财闭环。',
'自营合同生效 → 轻量调度派车(交车后可办结)→ 办结自动生成物流业务明细 → 自动汇总盈亏 → 汇集项目盈亏表 → 演进关联收款记录完成业财闭环。',
evolution: {
currentLabel: '现阶段 · 人工核算台账',
currentLabel: '现阶段 · 合同 + 调度办结落账',
currentText:
'人工发起自营合同,人工计算车辆成本、司机成本与自营台账,项目盈亏依赖线下表格汇总与人工核对。',
'自营合同业务确认生效;轻量调度派车办结(须交车);台账以自动生成为主、导入为辅,盈亏在台账内可算。',
targetLabel: '演进方向 · 业财记录闭环',
targetText:
'仍人工发起合同;司机在线管理、司机成本自动计算;自营台账人工导入后自动汇总,关联财务「收款记录」模块,形成业财记录闭环。',
'成本侧氢费/ETC/日成本自动回填;司机在线管理;台账关联财务「收款记录」,形成业财记录闭环;不做完整 TMS。',
},
},
];

View File

@@ -1,11 +1,23 @@
# 加氢订单H5— 已确认需求与设计决策
> **状态:已收敛 / 部分作废2026-07-18**
> 现行权威规格以 [requirements-prd.md](./requirements-prd.md) 及同目录功能规格为准。
> 下文保留为 2026-07-14 对齐快照;下列条目**已被后续决策覆盖,勿再按本文件验收**
>
> | 快照原文 | 现行口径 |
> |---|---|
> | 顶栏演示可切换示例站 | **H5 无切站**,固定登录本站 |
> | 列表展示对账状态 / 对账时间 | 列表只展示**核对**;对账见详情 |
> | 已对账才只读 | **羚牛**已核对或已对账即锁定;非羚牛不锁 |
> | 无底 Tab | 已有底栏 Tab加氢订单 / 手工台账 |
> | 分步 OCR 向导 | 已改为单表单 + OCR 拍摄区 |
| 项 | 内容 |
|---|---|
| 日期 | 2026-07-14 |
| 日期 | 2026-07-14(快照);覆盖说明 2026-07-18 |
| 原型 ID | `oneos-h5-h2-order` |
| 菜单位置 | OneOS → 加氢站管理 → 加氢订单 |
| 文档性质 | 决策快照(对齐当时确认,不随实现逐行同步 |
| 文档性质 | 历史决策快照(**不随实现同步**;冲突处以 PRD 为准 |
---
@@ -14,8 +26,8 @@
| 问题 | 用户选择 |
|---|---|
| 列表数据范围 | **A. 仅本站**(当前演示站点绑定的加氢站) |
| 登录态 | **A. 已登录态**;顶栏展示站名演示可切换示例站 |
| 已对账记录 | **A. 不可改**(只读);未对账可编辑 / 删除 |
| 登录态 | **A. 已登录态**;顶栏展示站名 ~~演示可切换示例站~~**已收敛:无切站** |
| 已对账记录 | **A. 不可改**(只读);未对账可编辑 / 删除**已收敛:羚牛·已核对或已对账均锁** |
| OCR 演示 | **A. 拍照或选图 → 约 1s 模拟识别 → 可校正**(已改为单表单,非三步) |
### 1.1 目标与范围
@@ -61,8 +73,8 @@
| 预览壳 | 居中 390×844 手机框;真机浏览器可全宽 |
| 首屏 | 列表为主 + 悬浮「+」 |
| 卡片优先级 | 车牌、状态、时间、量与金额;单价 / 对账时间次级 |
| 无底 Tab | 本模块单入口,不套小羚羚全局 Tab |
| 演示切换 | 顶栏站点下拉,仅演示,不代表正式鉴权 |
| 无底 Tab | ~~本模块单入口~~**已收敛:底栏「加氢订单 / 手工台账」** |
| 演示切换 | ~~顶栏站点下拉~~**已收敛H5 固定本站、无切站** |
### 2.2 信息架构

View File

@@ -2,7 +2,7 @@
## 目标
查看单笔加氢记录完整字段;在「未核对且未对账」时可编辑或删除。
查看单笔加氢记录完整字段;在「未核对且未对账」时可编辑或删除**锁定仅适用于羚牛车辆**
## 详情展示
@@ -11,15 +11,18 @@
- **识别原图**:车牌照片、加氢机面板照片(均为拍摄时叠加时间/地点水印后的原图;种子数据无实拍时用演示水印图)
- 明细行:站点、车牌、里程、单价、核对/对账状态与时间
> 非羚牛车辆:核对/对账字段展示「—」,不展示核对待办语义(见 [fleet-plate-tag.md](./fleet-plate-tag.md))。
## 锁定规则
| 条件 | 结果 |
|------|------|
| 已对账 | 不可编辑/删除;提示已纳入站点对账单 |
| 已核对(未对账 | 不可编辑/删除;提示能源部已核对 |
| 核对未对账 | 可编辑、可删除 |
| 优先级 | 条件 | 结果 |
|--------|------|------|
| 1 | **非羚牛车辆** | **不锁定**(不以核对/对账状态禁改删) |
| 2 | 羚牛 ∧ 已对账 | 不可编辑/删除;提示已纳入站点对账单 |
| 3 | 羚牛 ∧ 已核对未对账 | 可编辑/删除;提示能源部已核对 |
| 4 | 羚牛 ∧ 未核对且未对账 | 可编辑、可删除 |
判定函数:`isOrderLocked`(核对或对账任一成立即锁)。
判定函数:`isOrderLocked`先判羚牛;再核「核对或对账任一成立即锁)。
## 编辑
@@ -27,11 +30,12 @@
## 删除
二次确认后从 Bridge 移除该行。
二次确认文案:「确认删除车牌 … 的未锁定记录?」;确认后从 Bridge 移除该行。
## 验收
1. 未核对未对账:底部有删除/编辑
2. 已核对或已对账:无操作栏,有锁定说明
3. 详情含对账信息;列表卡片不含对账
4. 详情展示车牌与面板带水印原图(新上报提交后为实拍;种子行为演示图)
1. 羚牛 ∧ 未核对未对账:底部有删除/编辑
2. 羚牛 ∧ 已核对或已对账:无操作栏,有锁定说明
3. 非羚牛:可改删(不因核对/对账锁定);核对/对账展示「—」
4. 详情含对账信息;列表卡片不含对账
5. 详情展示车牌与面板带水印原图(新上报提交后为实拍;种子行为演示图)

View File

@@ -9,14 +9,14 @@
| 项 | 规则 |
|---|---|
| 入口 | H5底部 Tab「手工台账」Web顶栏「手工台账」 |
| 站点范围 | 仅展示当前登录用户角色对应站点;单站人员仅 1 个、多站可切换、超级管理员可选全部(原型用标注状态切换演示,未接真实登录) |
| 站点范围 | **H5 固定本站**无切站Web 可按角色多站/超管切换(标注状态演示,未接真实登录) |
| 上传形态 | 仅图片(拍照/相册),可多张 |
| 维度 | **加氢站 + 自然日(本地日历日)** 一份 |
| 日历 | 月历自本站**首笔加氢记录日**起算:绿=已传、橙=未传;首笔之前无需补传;禁选未来日 |
| 补传 | 允许为过往未传日期补传;已传日期仅可**追加**新图 |
| 归档 | **确认上传后的图片进入已归档状态,不可删除**;未确认的草稿图仍可删除 |
| 二次确认 | Web 点「确认上传/补传」先弹窗:「提交后将无法修改,是否确认手工台账照片无误」;确认后才写入 |
| 拦截 | **未上传今日手工台账 → 禁止新增加氢记录**(编辑已有记录不拦;补传历史不替代日校验) |
| 二次确认 | H5 / Web 确认上传弹窗:「提交后将无法修改,是否确认手工台账照片无误」;确认后才写入 |
| 拦截 | **前一日手工台账未上传今日禁止新增加氢记录**(编辑已有记录不拦;补传更早历史不替代日校验) |
| 数据 | 共享 `H2VehicleLedgerBridge` 内存 Store未接真实 API站名/编码解析见 Bridge.`h2BridgeResolveStationId` |
## 判定顺序(新增拦截)
@@ -24,8 +24,9 @@
| 优先级 | 条件 | 结果 |
|--------|------|------|
| 1 | 操作=编辑已有记录 | 不校验手工台账 |
| 2 | 操作=新增,且目标站今日已有 ≥1 张台账 | 允许进入新增 |
| 3 | 操作=新增,今日未上传 | 提示并跳转手工台账 Tab |
| 2 | 操作=新增,且昨日早于本站首笔加氢日(或不要求昨日台账 | 允许进入新增 |
| 3 | 操作=新增,且目标站昨日已有 ≥1 张台账图 | 允许进入新增 |
| 4 | 操作=新增,昨日未上传 | 顶部/Toast 提示「手工台账有缺失,作为对账重要依据,请先上传手工台账」,并**直接跳转**手工台账页且选中昨日 |
## 日历与补传
@@ -36,26 +37,27 @@
| 未传计数 | 本月内 `max(月初, 首笔日)`min(月末, 今日) 中未上传的天数 |
| 点选 | 仅 `dateKey ≤ 今日`;选中后右侧/下方展示该日草稿图与上传按钮 |
| 归档不可删 | Store 已有图强制保留;`upsertManualLedger` 仅追加新图,忽略删除已有图的请求 |
| 与拦截关系 | 仅 **** 影响「禁止新增」;历史补传解决核对缺口,不放宽今日门禁 |
| 与拦截关系 | 仅 **** 影响「禁止新增」;历史补传解决核对缺口;今日台账仍可按日常上传,但不作为新增门禁 |
## 用户可见
- 今日状态双反馈Web 放在右侧「当日台账」卡片内):未上传「今日尚未上传手工台账YYYY-MM-DD」异常态已上传「今日已上传手工台账YYYY-MM-DD」正常态
- 今日未上传:橙/黄提示 + Tab 红点
- 今日状态双反馈(H5 台账页顶部卡片 / Web 右侧「当日台账」):未上传「今日尚未上传…」异常态;已上传「今日已上传…」正常态。**日常上传反馈 ≠ 新增门禁**H5 文案须写明「今日不作为新增门禁,仅要求前一日已上传」
- 昨日缺失H5**列表顶栏可点橙条** + 底栏 FAB 旁提示 + Tab 红点;台账页同样展示橙条并可选中昨日;文案固定为「手工台账有缺失,作为对账重要依据,请先上传手工台账」
- 日历:自首笔加氢日起绿/橙标记;首笔之前无橙点(无需补传);本月未传天数摘要
- Web 右侧面板标题「手工台账」:空态提示;有图可点进全屏翻页预览并角标「已归档/待提交」;预览区内上传;底部确认前二次弹窗;提交后立即展示已归档缩略图;左右卡片等高
## 代码路径
- 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 月历
- Bridge`src/common/h2VehicleLedgerBridge.js``hasManualLedgerForDate` / `isManualLedgerGateBlocked` / `upsertManualLedger` / `listManualLedgersByStation`
- H5`oneos-h5-h2-order` · `ManualLedgerPanel` + `utils/manual-ledger.ts``isManualLedgerGateBlocked` / `buildManualMonthCells` / `getFirstHydrogenDateKey`
- Web`oneos-web-h2-station/pages/02-加氢记录.jsx``hrIsManualLedgerGateBlocked` / 跳转选中昨日
## 验收
1. 日未上传时H5/Web 点「新增」被拦截并引导上传
2. 上传至少一张图并确认后,可新增电子记录
1. 日未上传时H5/Web 列表顶栏提示固定文案;点「新增」被拦截并直接进入手工台账(选中昨日)
2. 补传昨日台账并确认后,可新增加氢记录
3. 日历自首笔加氢日起区分已传/未传;首笔之前不标未传、不计入补传;可点选范围内未传日补传
4. 补传历史日后,该日日历变为已传;今日仍未传时新增仍被拦截
4. 补传更早历史日、昨日仍未传时新增仍被拦截
5. 确认上传后的图显示已归档且不可删除;仅可追加补传
6. H5 与 Web 共用同一 Store一端上传另一端可见同会话
7. Web 导出 CSV 列含「车辆来源」(羚牛车辆/非羚牛车辆)、「数据来源」(站端上传/羚牛上传),与列表标签口径一致

View File

@@ -2,6 +2,9 @@
原型本地判定,未接真实车辆管理 API。
全文(含核对/对账字段空展示规则,跨 Web / 台账 / H5
[../../oneos-web-h2-station/.spec/fleet-vehicle-verify.md](../../oneos-web-h2-station/.spec/fleet-vehicle-verify.md)
## 判定顺序
| 优先级 | 条件 | 结果 |
@@ -17,14 +20,14 @@
## 数据源
- `utils/fleet-plates.ts` 内本地种子车牌集合(含加氢 Bridge 演示车牌与部分车辆管理样例)。
- 正式环境应改为查询车辆管理 / 系统车辆列表接口。
- 优先 `H2VehicleLedgerBridge.isLingniuVehicle`;本地回退见 `utils/fleet-plates.ts`(与 Bridge `H2_FLEET_PLATE_KEYS` 同步)。
## 用户可见结果
- 绿色标签「羚牛车辆」或橙色标签「非羚牛车辆」。
- **非羚牛车辆**:列表/详情中核对状态、核对时间、对账状态、对账时间显示「—」,不展示核对待办语义。
- 不拦截提交(仅提示归属)。
## 代码路径
- `getVehicleTag` / `isLingniuVehicle``CreateWizard` 车牌行右侧标签。
- `getVehicleTag` / `isLingniuVehicle``CreateWizard``OrderCard``OrderDetail`

View File

@@ -4,6 +4,7 @@
|---|---|
| 代码路径 | 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) |
| 车辆标签 | [fleet-plate-tag.md](./fleet-plate-tag.md) |
| 数据源 | 原型本地种子 + 内存共享 Store**未接真实 API** |
---
@@ -17,6 +18,8 @@
> `reconciledAt` 仅作完成核对时的兼容写入,**不得**再用于判定「已对账」。
---
## 2. 数据流
```text
@@ -26,23 +29,29 @@
能源部 · 完成核对
→ verifyStatus=verified + verifiedAt
→ 站端同步「已核对」禁改删
→ 站端同步「已核对」;羚牛车辆禁改删
站点信息 · 对账单提交(仅已核对 ∧ 未对账)
→ reconcileStatus=reconciled + reconcileDate + statementRecordId
→ 站端 / 台账同步「已对账」
→ 站端 / 台账同步「已对账」;羚牛车辆禁改删
```
---
## 3. 用户可见结果H5
| 结果 | 行为 |
|---|---|
| 未核对 | 可编辑、删除 |
| 已核对未对账 | 只读;提示能源部已核对 |
| 对账 | 只读;提示已纳入对账单 |
| 非羚牛车辆 | 核对/对账展示「—」;**不锁定**改删 |
| 羚牛 · 未核对 | 可编辑、删除 |
| 羚牛 · 已核对未对账 | 只读;提示能源部已核对 |
| 羚牛 · 已对账 | 只读;提示已纳入对账单 |
---
## 4. 边界
- 站端上报默认未核对、未对账
- **站端改删锁定仅适用于羚牛车辆**(与 Web / 台账车辆标签口径一致)
- 对账时间只认 `reconcileDate`(对账单回写)
- 跨页联动依赖同浏览器会话共享 Store

View File

@@ -2,7 +2,7 @@
| 项 | 内容 |
|---|---|
| 文档版本 | v1.5 |
| 文档版本 | v1.7 |
| 模块名称 | 加氢站管理 → 加氢订单 |
| 所属系统 | ONE-OS加氢站手机浏览器 |
| 交互原型 | `/prototypes/oneos-h5-h2-order` |
@@ -15,7 +15,7 @@
采购创建加氢站并绑定供应商、开放系统账号后,加氢站人员通过手机浏览器登录 OneOS在「加氢订单」模块上报加氢记录。Web「加氢记录」为同一业务的 PC 入口(共享 Bridge 数据PC 另有导出与预约加氢)。
每日须上传**手工台账**照片,作为电子记录的核对依据;未上传当日手工台账时不可新增加氢记录。手工台账页提供月历,标出已传/未传日期,支持补传过往缺口。
每日须上传**手工台账**照片,作为电子记录的核对依据;**前一日**手工台账未上传时,今日不可新增加氢记录。手工台账页提供月历,标出已传/未传日期,支持补传过往缺口。
### 1.1 目标用户
@@ -46,14 +46,15 @@
1. 菜单位于 OneOS → 加氢站管理 → 加氢订单
2. 默认已登录本站;列表仅本站(无切站)
3. 底栏 Tab加氢订单 / 手工台账;月历可区分已传/未传并补传过往日;未上传今日手工台账不可新增;上传后可新增;时间默认点击新增时刻(到分)
4. 已核对或已对账不可编辑/删除;未核对未对账可
5. 能源部完成核对后同步「已核对」;站点对账单提交后同步「已对账」+ 对账时间
3. 底栏 Tab加氢订单 / 手工台账;月历可区分已传/未传并补传过往日;前一日未上传时提示「手工台账有缺失,作为对账重要依据,请先上传手工台账」且点新增直达台账页;补传昨日后可新增;时间默认点击新增时刻(到分)
4. **羚牛车辆**已核对或已对账不可编辑/删除;非羚牛不锁定;未核对未对账(羚牛)可改删
5. 能源部完成核对后同步「已核对」;站点对账单提交后同步「已对账」+ 对账时间(锁定仅羚牛)
6. 同站同车牌同加氢时间重复保存被拦截
7. Web「加氢记录」与 H5「加氢订单」手工台账逻辑一致同 Store
8. 车牌/面板拍摄预览含水印(时间、地点=本站名称里程可从车机获取Mock
9. 面板 OCR 可展示站点信息维护的加氢机品牌型号线索
10. 详情页展示车牌与面板带水印原图
11. 昨日台账缺失时:列表顶栏橙条 + 底栏提示 + Tab 红点;台账页不把「今日未传」写成新增门禁
---
@@ -61,11 +62,11 @@
| 主题 | 摘要 | 全文 |
|---|---|---|
| 对账 / 核对 | 核对≠对账;触发方不同 | [reconcile-linkage.md](./reconcile-linkage.md)、[车辆氢费 verify-reconcile](../vehicle-h2-fee-ledger/.spec/verify-reconcile.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) |
| 手工台账 | 按站+自然日上传图片;月历标已传/未传可补传;**昨日未传则今日禁新增** | [feature-manual-ledger.md](./feature-manual-ledger.md) |
---
@@ -77,4 +78,16 @@
| `create` | 时间选择、站名、车牌键盘、面板 OCR、金额字段 |
| `detail` | 详情字段、锁定说明、编辑/删除 |
右侧工具栏「原型目录」结构:
| 目录 | 内容 |
|---|---|
| PRD 全文 | requirements-prd.md |
| 需求角色 | roles.md |
| 用户故事 | user-stories.md |
| 关键逻辑 | key-logic + 台账门禁 / 对账 / 车辆标签 |
| 流程图 | flows.mdMermaid |
| 功能说明 | 列表 / 新增 / 台账 / 详情 / 车辆 / 对账 |
| 按页面 | list / create / detail 要点 |
标注数据:`annotation-source.json`(由 `scripts/build-annotation-source.mjs` 从本目录 Markdown 生成)。

View File

@@ -5,13 +5,13 @@
"version": 2,
"prototypeName": "oneos-h5-h2-order",
"pageId": "list",
"updatedAt": 1784280448632,
"updatedAt": 1784378211658,
"nodes": [
{
"images": [],
"controls": [],
"createdAt": 1784280448632,
"updatedAt": 1784280448632,
"createdAt": 1784378211658,
"updatedAt": 1784378211658,
"hasMarkdown": true,
"annotationText": "",
"id": "h5-hero",
@@ -31,8 +31,8 @@
{
"images": [],
"controls": [],
"createdAt": 1784280448632,
"updatedAt": 1784280448632,
"createdAt": 1784378211658,
"updatedAt": 1784378211658,
"hasMarkdown": true,
"annotationText": "",
"id": "h5-summary",
@@ -52,8 +52,8 @@
{
"images": [],
"controls": [],
"createdAt": 1784280448632,
"updatedAt": 1784280448632,
"createdAt": 1784378211658,
"updatedAt": 1784378211658,
"hasMarkdown": true,
"annotationText": "",
"id": "h5-card",
@@ -73,8 +73,8 @@
{
"images": [],
"controls": [],
"createdAt": 1784280448632,
"updatedAt": 1784280448632,
"createdAt": 1784378211658,
"updatedAt": 1784378211658,
"hasMarkdown": true,
"annotationText": "",
"id": "h5-fab",
@@ -94,8 +94,8 @@
{
"images": [],
"controls": [],
"createdAt": 1784280448632,
"updatedAt": 1784280448632,
"createdAt": 1784378211658,
"updatedAt": 1784378211658,
"hasMarkdown": true,
"annotationText": "",
"id": "h5-time",
@@ -115,8 +115,8 @@
{
"images": [],
"controls": [],
"createdAt": 1784280448632,
"updatedAt": 1784280448632,
"createdAt": 1784378211658,
"updatedAt": 1784378211658,
"hasMarkdown": true,
"annotationText": "",
"id": "h5-station",
@@ -136,8 +136,8 @@
{
"images": [],
"controls": [],
"createdAt": 1784280448632,
"updatedAt": 1784280448632,
"createdAt": 1784378211658,
"updatedAt": 1784378211658,
"hasMarkdown": true,
"annotationText": "",
"id": "h5-plate",
@@ -157,8 +157,8 @@
{
"images": [],
"controls": [],
"createdAt": 1784280448632,
"updatedAt": 1784280448632,
"createdAt": 1784378211658,
"updatedAt": 1784378211658,
"hasMarkdown": true,
"annotationText": "",
"id": "h5-mileage",
@@ -178,8 +178,8 @@
{
"images": [],
"controls": [],
"createdAt": 1784280448632,
"updatedAt": 1784280448632,
"createdAt": 1784378211658,
"updatedAt": 1784378211658,
"hasMarkdown": true,
"annotationText": "",
"id": "h5-dispenser-brands",
@@ -199,8 +199,8 @@
{
"images": [],
"controls": [],
"createdAt": 1784280448632,
"updatedAt": 1784280448632,
"createdAt": 1784378211658,
"updatedAt": 1784378211658,
"hasMarkdown": true,
"annotationText": "",
"id": "h5-price",
@@ -220,8 +220,8 @@
{
"images": [],
"controls": [],
"createdAt": 1784280448632,
"updatedAt": 1784280448632,
"createdAt": 1784378211658,
"updatedAt": 1784378211658,
"hasMarkdown": true,
"annotationText": "",
"id": "h5-kg",
@@ -241,8 +241,8 @@
{
"images": [],
"controls": [],
"createdAt": 1784280448632,
"updatedAt": 1784280448632,
"createdAt": 1784378211658,
"updatedAt": 1784378211658,
"hasMarkdown": true,
"annotationText": "",
"id": "h5-total-label",
@@ -262,8 +262,8 @@
{
"images": [],
"controls": [],
"createdAt": 1784280448632,
"updatedAt": 1784280448632,
"createdAt": 1784378211658,
"updatedAt": 1784378211658,
"hasMarkdown": true,
"annotationText": "",
"id": "h5-detail",
@@ -283,8 +283,8 @@
{
"images": [],
"controls": [],
"createdAt": 1784280448632,
"updatedAt": 1784280448632,
"createdAt": 1784378211658,
"updatedAt": 1784378211658,
"hasMarkdown": true,
"annotationText": "",
"id": "h5-detail-photos",
@@ -304,8 +304,8 @@
{
"images": [],
"controls": [],
"createdAt": 1784280448632,
"updatedAt": 1784280448632,
"createdAt": 1784378211658,
"updatedAt": 1784378211658,
"hasMarkdown": true,
"annotationText": "",
"id": "h5-manual",
@@ -325,8 +325,8 @@
{
"images": [],
"controls": [],
"createdAt": 1784280448632,
"updatedAt": 1784280448632,
"createdAt": 1784378211658,
"updatedAt": 1784378211658,
"hasMarkdown": true,
"annotationText": "",
"id": "h5-manual-calendar",
@@ -352,16 +352,16 @@
"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-plate": "# 车牌「羚牛车辆 / 非羚牛车辆」标签\n\n原型本地判定未接真实车辆管理 API。\n\n全文(含核对/对账字段空展示规则,跨 Web / 台账 / H5 \n[../../oneos-web-h2-station/.spec/fleet-vehicle-verify.md](../../oneos-web-h2-station/.spec/fleet-vehicle-verify.md)\n\n## 判定顺序\n\n| 优先级 | 条件 | 结果 |\n|--------|------|------|\n| 1 | 车牌为空 | 不展示标签 |\n| 2 | 规范化后车牌 ∈ 本地系统车辆集合 | **羚牛车辆** |\n| 3 | 有车牌但不在集合中 | **非羚牛车辆** |\n\n## 前置条件\n\n- 识别OCR或键盘输入得到车牌后才展示。\n- 比较前去掉尾缀 `F`,统一大写。\n\n## 数据源\n\n- 优先 `H2VehicleLedgerBridge.isLingniuVehicle`;本地回退见 `utils/fleet-plates.ts`(与 Bridge `H2_FLEET_PLATE_KEYS` 同步)。\n\n## 用户可见结果\n\n- 绿色标签「羚牛车辆」或橙色标签「非羚牛车辆」。\n- **非羚牛车辆**:列表/详情中核对状态、核对时间、对账状态、对账时间显示「—」,不展示核对待办语义。\n- 不拦截提交(仅提示归属)。\n\n## 代码路径\n\n- `getVehicleTag` / `isLingniuVehicle` → `CreateWizard`、`OrderCard`、`OrderDetail`\n",
"h5-mileage": "## 里程km\n\n选填。支持「从车机获取」原型 Mock识别车牌后可自动回填。正式环境对接车机 / T-Box。\n\n详见 [新增加氢记录](feature-create)。",
"h5-dispenser-brands": "## 加氢机品牌参考\n\n展示站点信息「加氢机品牌管理」维护的品牌·型号供面板 OCR 模型判断参考。数据源:`h2DispenserBrandStore`。",
"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\n判定函数`isOrderLocked`(核对或对账任一成立即锁)。\n\n## 编辑\n\n进入与「新增」同一套表单预填当前值含已存水印照片提交走更新逻辑仍校验重复键。\n\n## 删除\n\n二次确认后从 Bridge 移除该行。\n\n## 验收\n\n1. 未核对未对账:底部有删除/编辑 \n2. 已核对或已对账:无操作栏,有锁定说明 \n3. 详情含对账信息;列表卡片不含对账 \n4. 详情展示车牌与面板带水印原图(新上报提交后为实拍;种子行为演示图) \n",
"h5-detail": "# 功能:详情、编辑与删除\n\n## 目标\n\n查看单笔加氢记录完整字段在「未核对且未对账」时可编辑或删除**锁定仅适用于羚牛车辆**。\n\n## 详情展示\n\n- 顶部:车牌、加氢时间、核对标签、对账标签 \n- 指标条:加氢总额、加氢量 \n- **识别原图**:车牌照片、加氢机面板照片(均为拍摄时叠加时间/地点水印后的原图;种子数据无实拍时用演示水印图) \n- 明细行:站点、车牌、里程、单价、核对/对账状态与时间 \n\n> 非羚牛车辆:核对/对账字段展示「—」,不展示核对待办语义(见 [fleet-plate-tag.md](./fleet-plate-tag.md))。\n\n## 锁定规则\n\n| 优先级 | 条件 | 结果 |\n|--------|------|------|\n| 1 | **非羚牛车辆** | **不锁定**(不以核对/对账状态禁改删) |\n| 2 | 羚牛 ∧ 已对账 | 不可编辑/删除;提示已纳入站点对账单 |\n| 3 | 羚牛 ∧ 已核对(未对账) | 不可编辑/删除;提示能源部已核对 |\n| 4 | 羚牛 ∧ 未核对且未对账 | 可编辑、可删除 |\n\n判定函数`isOrderLocked`先判羚牛;再核「核对或对账任一成立即锁)。\n\n## 编辑\n\n进入与「新增」同一套表单预填当前值含已存水印照片提交走更新逻辑仍校验重复键。\n\n## 删除\n\n二次确认文案:「确认删除车牌 … 的未锁定记录?」;确认后从 Bridge 移除该行。\n\n## 验收\n\n1. 羚牛 ∧ 未核对未对账:底部有删除/编辑 \n2. 羚牛 ∧ 已核对或已对账:无操作栏,有锁定说明 \n3. 非羚牛:可改删(不因核对/对账锁定);核对/对账展示「—」 \n4. 详情含对账信息;列表卡片不含对账 \n5. 详情展示车牌与面板带水印原图(新上报提交后为实拍;种子行为演示图) \n",
"h5-detail-photos": "## 识别原图(含水印)\n\n详情展示车牌识别、加氢机面板的原始照片照片在拍摄/选图时已叠加时间与地点水印并随记录保存。种子数据无实拍时用演示水印图。\n\n详见 [详情编辑删除](feature-detail)。",
"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)。"
"h5-manual": "# 功能:手工台账上传\n\n## 目标\n\n站端每日上传纸质**手工台账照片**,作为系统电子加氢记录的核对依据。多日未操作时,可通过日历查看缺口并补传过往日期。\n\n## 产品口径\n\n| 项 | 规则 |\n|---|---|\n| 入口 | H5底部 Tab「手工台账」Web顶栏「手工台账」 |\n| 站点范围 | **H5 固定本站**无切站Web 可按角色多站/超管切换(标注状态演示,未接真实登录) |\n| 上传形态 | 仅图片(拍照/相册),可多张 |\n| 维度 | **加氢站 + 自然日(本地日历日)** 一份 |\n| 日历 | 月历自本站**首笔加氢记录日**起算:绿=已传、橙=未传;首笔之前无需补传;禁选未来日 |\n| 补传 | 允许为过往未传日期补传;已传日期仅可**追加**新图 |\n| 归档 | **确认上传后的图片进入已归档状态,不可删除**;未确认的草稿图仍可删除 |\n| 二次确认 | H5 / Web 确认上传前弹窗:「提交后将无法修改,是否确认手工台账照片无误」;确认后才写入 |\n| 拦截 | **前一日手工台账未上传今日禁止新增加氢记录**(编辑已有记录不拦;补传更早历史不替代日校验) |\n| 数据 | 共享 `H2VehicleLedgerBridge` 内存 Store未接真实 API;站名/编码解析见 Bridge.`h2BridgeResolveStationId` |\n\n## 判定顺序(新增拦截)\n\n| 优先级 | 条件 | 结果 |\n|--------|------|------|\n| 1 | 操作=编辑已有记录 | 不校验手工台账 |\n| 2 | 操作=新增,且昨日早于本站首笔加氢日(或不要求昨日台账) | 允许进入新增 |\n| 3 | 操作=新增,且目标站日已有 ≥1 张台账图 | 允许进入新增 |\n| 4 | 操作=新增,日未上传 | 顶部/Toast 提示「手工台账有缺失,作为对账重要依据,请先上传手工台账」,并**直接跳转**手工台账页且选中昨日 |\n\n## 日历与补传\n\n| 规则 | 说明 |\n|------|------|\n| 数据源 | `listManualLedgersByStation`;当日有 ≥1 张图视为已上传 |\n| 业务起点 | 取本站 Bridge 加氢记录中最早 `hydrogenTime` 自然日;无记录则历史日均不要求补传 |\n| 未传计数 | 本月内 `max(月初, 首笔日)`min(月末, 今日) 中未上传的天数 |\n| 点选 | 仅 `dateKey ≤ 今日`;选中后右侧/下方展示该日草稿图与上传按钮 |\n| 归档不可删 | Store 已有图强制保留;`upsertManualLedger` 仅追加新图,忽略删除已有图的请求 |\n| 与拦截关系 | 仅 **日** 影响「禁止新增」;历史补传解决核对缺口;今日台账仍可按日常上传,但不作为新增门禁 |\n\n## 用户可见\n\n- 今日状态双反馈H5 台账页顶部卡片 / Web 右侧「当日台账」):未上传「今日尚未上传…」异常态;已上传「今日已上传…」正常态。**日常上传反馈 ≠ 新增门禁**H5 文案须写明「今日不作为新增门禁,仅要求前一日已上传」 \n- 昨日缺失H5**列表顶栏可点橙条** + 底栏 FAB 旁提示 + Tab 红点;台账页同样展示橙条并可选中昨日;文案固定为「手工台账有缺失,作为对账重要依据,请先上传手工台账」 \n- 日历:自首笔加氢日起绿/橙标记;首笔之前无橙点(无需补传);本月未传天数摘要 \n- Web 右侧面板标题「手工台账」:空态提示;有图可点进全屏翻页预览并角标「已归档/待提交」;预览区内上传;底部确认前二次弹窗;提交后立即展示已归档缩略图;左右卡片等高\n\n## 代码路径\n\n- Bridge`src/common/h2VehicleLedgerBridge.js``hasManualLedgerForDate` / `isManualLedgerGateBlocked` / `upsertManualLedger` / `listManualLedgersByStation` \n- H5`oneos-h5-h2-order` · `ManualLedgerPanel` + `utils/manual-ledger.ts``isManualLedgerGateBlocked` / `buildManualMonthCells` / `getFirstHydrogenDateKey` \n- Web`oneos-web-h2-station/pages/02-加氢记录.jsx``hrIsManualLedgerGateBlocked` / 跳转选中昨日 \n\n## 验收\n\n1. 日未上传时H5/Web 列表顶栏提示固定文案;点「新增」被拦截并直接进入手工台账(选中昨日) \n2. 补传昨日台账并确认后,可新增加氢记录 \n3. 日历自首笔加氢日起区分已传/未传;首笔之前不标未传、不计入补传;可点选范围内未传日补传 \n4. 补传更早历史日、昨日仍未传时新增仍被拦截 \n5. 确认上传后的图显示已归档且不可删除;仅可追加补传 \n6. H5 与 Web 共用同一 Store一端上传另一端可见同会话 \n7. Web 导出 CSV 列含「车辆来源」(羚牛车辆/非羚牛车辆)、「数据来源」(站端上传/羚牛上传),与列表标签口径一致 \n",
"h5-manual-calendar": "## 台账日历\n\n自本站首笔加氢记录日起绿=已上传,橙=未上传;首笔之前无需补传。禁选未来日。点选范围内未传日可补传。**前一日**未上传时禁止新增加氢记录(今日台账状态不作为门禁)。\n\n详见 [手工台账上传](feature-manual-ledger)。"
},
"assetMap": {},
"directory": {
@@ -376,9 +376,90 @@
"type": "markdown",
"id": "h5-doc-prd",
"title": "PRD 全文",
"markdown": "# 加氢订单H5— 产品需求文档PRD\n\n| 项 | 内容 |\n|---|---|\n| 文档版本 | v1.5 |\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 \n8. 车牌/面板拍摄预览含水印(时间、地点=本站名称里程可从车机获取Mock \n9. 面板 OCR 可展示站点信息维护的加氢机品牌型号线索 \n10. 详情页展示车牌与面板带水印原图 \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",
"markdown": "# 加氢订单H5— 产品需求文档PRD\n\n| 项 | 内容 |\n|---|---|\n| 文档版本 | v1.7 |\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 \n8. 车牌/面板拍摄预览含水印(时间、地点=本站名称里程可从车机获取Mock \n9. 面板 OCR 可展示站点信息维护的加氢机品牌型号线索 \n10. 详情页展示车牌与面板带水印原图 \n11. 昨日台账缺失时:列表顶栏橙条 + 底栏提示 + Tab 红点;台账页不把「今日未传」写成新增门禁 \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右侧工具栏「原型目录」结构\n\n| 目录 | 内容 |\n|---|---|\n| PRD 全文 | requirements-prd.md |\n| 需求角色 | roles.md |\n| 用户故事 | user-stories.md |\n| 关键逻辑 | key-logic + 台账门禁 / 对账 / 车辆标签 |\n| 流程图 | flows.mdMermaid |\n| 功能说明 | 列表 / 新增 / 台账 / 详情 / 车辆 / 对账 |\n| 按页面 | list / create / detail 要点 |\n\n标注数据`annotation-source.json`(由 `scripts/build-annotation-source.mjs` 从本目录 Markdown 生成)。\n",
"markdownPath": ".spec/requirements-prd.md"
},
{
"type": "folder",
"id": "h5-doc-roles",
"title": "需求角色",
"defaultExpanded": true,
"children": [
{
"type": "markdown",
"id": "h5-doc-roles-md",
"title": "角色说明",
"markdown": "# 需求角色\n\n| 项 | 说明 |\n|---|---|\n| 适用原型 | 加氢订单H5`oneos-h5-h2-order` |\n| 关联 Web | 加氢记录 `oneos-web-h2-station`(同一业务、不同端) |\n| 数据 | 原型本地种子 + 共享 Bridge未接真实登录 / API |\n\n---\n\n## 1. 角色一览\n\n| 角色 | 端 | 核心诉求 | 本原型是否为主用户 |\n|---|---|---|---|\n| **加氢站操作人员** | H5手机浏览器 | 在站场快速上报本站加氢流水;补传手工台账照片 | **是** |\n| **加氢站站长 / 多站管理员** | Web 为主H5 可看本站 | 多站台账、导出、手工台账补传与督导 | 间接H5 固定单站演示) |\n| **能源部核对人员** | 车辆氢费明细 | 完成核对,回写「已核对」 | 否(触发方在台账) |\n| **站点对账人员** | 站点信息 · 对账单 | 提交对账单,回写「已对账」 | 否(触发方在站点信息) |\n| **超级管理员** | Web | 查看全部站点流水与台账 | 否Web 标注状态演示) |\n\n---\n\n## 2. 本原型主角色:加氢站操作人员\n\n| 项 | 说明 |\n|---|---|\n| 场景 | 户外 / 站场,手机竖屏 |\n| 权限边界 | 仅本站数据;不可切站(原型固定演示站) |\n| 日常动作 | 看列表 KPI → 上传昨日手工台账(若缺失)→ 新增加氢记录 → 查看/编辑未锁定记录 |\n| 约束 | 前一日手工台账未上传则今日不可新增;已核对或已对账不可改删 |\n\n---\n\n## 3. 角色边界(本期不做)\n\n- 真登录与组织权限中心对接 \n- 能源部 / 对账人员在 H5 内操作核对或对账 \n- 跨站汇总与导出(属 Web「加氢记录」 \n",
"markdownPath": ".spec/roles.md"
}
]
},
{
"type": "folder",
"id": "h5-doc-stories",
"title": "用户故事",
"defaultExpanded": true,
"children": [
{
"type": "markdown",
"id": "h5-doc-stories-md",
"title": "用户故事",
"markdown": "# 用户故事\n\n> 以加氢站操作人员为主;验收口径见 [requirements-prd.md](./requirements-prd.md)。\n\n---\n\n## US-01 查看本站加氢订单\n\n**作为** 加氢站操作人员, \n**我希望** 打开加氢订单即看到本站列表与 KPI \n**以便** 快速了解今日/近期上报情况。\n\n**验收要点**\n\n- 默认已登录本站;无切站 \n- KPI订单数 / 加氢量 / 加氢金额随列表汇总 \n- 卡片展示加氢时间、车牌、量、额、核对状态等 \n\n---\n\n## US-02 补传缺失的手工台账后再新增\n\n**作为** 加氢站操作人员, \n**我希望** 在前一日手工台账未上传时被明确拦截,并直达台账页补传, \n**以便** 保证纸质台账作为对账依据不缺失。\n\n**验收要点**\n\n- 顶栏橙条 + 底栏提示:「手工台账有缺失,作为对账重要依据,请先上传手工台账」 \n- 点「新增」或顶栏橙条跳转手工台账并选中昨日 \n- 补传昨日确认后可新增 \n\n---\n\n## US-03 新增加氢记录\n\n**作为** 加氢站操作人员, \n**我希望** 用车牌键盘 + 面板拍照/OCR 快速填单, \n**以便** 在站场少打字完成上报。\n\n**验收要点**\n\n- 加氢时间默认=点击新增时刻(到分) \n- 站名只读为本站 \n- 总额 = 单价 × 加氢量(界面不展示公式) \n- 同站同车牌同时间重复保存拦截 \n\n---\n\n## US-04 查看详情并编辑/删除未锁定记录\n\n**作为** 加氢站操作人员, \n**我希望** 打开详情查看字段与识别原图,并对未核对未对账记录改删, \n**以便** 纠正录入错误。\n\n**验收要点**\n\n- 羚牛车辆已核对或已对账:不可编辑/删除,有锁定说明;非羚牛不锁定 \n- 详情展示车牌/面板含水印原图 \n\n---\n\n## US-05 按日历管理手工台账\n\n**作为** 加氢站操作人员, \n**我希望** 在月历上看已传/未传并补传过往日, \n**以便** 多日未操作后仍能补齐缺口。\n\n**验收要点**\n\n- 自首笔加氢日起绿/橙标记;首笔前不要求 \n- 禁选未来日;已归档图不可删,仅可追加 \n\n---\n\n## US-06 识别羚牛车辆\n\n**作为** 加氢站操作人员, \n**我希望** 在列表/详情看到是否羚牛车辆, \n**以便** 理解后续核对对账是否适用该车。\n\n**验收要点**\n\n- 标签:羚牛车辆 / 非羚牛车辆(本地车辆集合判定) \n",
"markdownPath": ".spec/user-stories.md"
}
]
},
{
"type": "folder",
"id": "h5-doc-logic",
"title": "关键逻辑",
"defaultExpanded": true,
"children": [
{
"type": "markdown",
"id": "h5-doc-logic-index",
"title": "逻辑索引",
"markdown": "# 关键逻辑索引\n\n本页汇总加氢订单H5复杂判定入口细则见各规格全文。原型数据均为本地种子 / 内存 Store**未接真实 API**。\n\n---\n\n## 1. 判定主题一览\n\n| 主题 | 一句话 | 全文 |\n|---|---|---|\n| 手工台账门禁 | **昨日**未传 → 今日禁新增;固定提示文案并直达台账 | [feature-manual-ledger.md](./feature-manual-ledger.md) |\n| 核对 ≠ 对账 | 能源部核对 / 站点对账单提交,触发方不同 | [reconcile-linkage.md](./reconcile-linkage.md) |\n| 记录锁定 | **仅羚牛**:已核对或已对账 → 站端不可改删;非羚牛不锁 | [feature-detail.md](./feature-detail.md)、[reconcile-linkage.md](./reconcile-linkage.md)、[fleet-plate-tag.md](./fleet-plate-tag.md) |\n| 重复键 | 同站 + 同车牌 + 同加氢时间 | Bridge / [Web record-data-model](../oneos-web-h2-station/.spec/record-data-model.md) |\n| 总额计算 | 总额 = 单价 × 量(两位小数) | [feature-create.md](./feature-create.md) |\n| 羚牛车辆 | 本地系统车牌集合 | [fleet-plate-tag.md](./fleet-plate-tag.md) |\n\n---\n\n## 2. 手工台账门禁(摘要)\n\n| 优先级 | 条件 | 结果 |\n|--------|------|------|\n| 1 | 编辑已有记录 | 不校验 |\n| 2 | 昨日早于本站首笔加氢日 | 不要求昨日台账,可新增 |\n| 3 | 昨日已有 ≥1 张台账图 | 可新增 |\n| 4 | 昨日未上传 | 提示并跳转手工台账(选中昨日) |\n\n用户可见文案`手工台账有缺失,作为对账重要依据,请先上传手工台账`\n\n---\n\n## 3. 核对 / 对账(摘要)\n\n| 状态 | 谁触发 | 站端影响 |\n|---|---|---|\n| 已核对 | 车辆氢费明细「完成核对」 | **羚牛**不可改删 |\n| 已对账 | 站点信息对账单提交 | **羚牛**不可改删;回写对账时间 |\n| 非羚牛 | — | 不参与核对/对账展示与锁定 |\n\nH5 列表主要展示核对状态(仅羚牛);对账细节见联动规格。\n\n---\n\n## 4. 代码路径\n\n| 能力 | 路径 |\n|---|---|\n| Bridge | `src/common/h2VehicleLedgerBridge.js` |\n| 门禁工具 | `utils/manual-ledger.ts` → `isManualLedgerGateBlocked` |\n| 订单 CRUD | `utils/orders.ts` |\n| 页面 | `index.tsx` / `ManualLedgerPanel` / `CreateWizard` |\n",
"markdownPath": ".spec/key-logic.md"
},
{
"type": "markdown",
"id": "h5-doc-logic-manual",
"title": "手工台账门禁",
"markdown": "# 功能:手工台账上传\n\n## 目标\n\n站端每日上传纸质**手工台账照片**,作为系统电子加氢记录的核对依据。多日未操作时,可通过日历查看缺口并补传过往日期。\n\n## 产品口径\n\n| 项 | 规则 |\n|---|---|\n| 入口 | H5底部 Tab「手工台账」Web顶栏「手工台账」 |\n| 站点范围 | **H5 固定本站**无切站Web 可按角色多站/超管切换(标注状态演示,未接真实登录) |\n| 上传形态 | 仅图片(拍照/相册),可多张 |\n| 维度 | **加氢站 + 自然日(本地日历日)** 一份 |\n| 日历 | 月历自本站**首笔加氢记录日**起算:绿=已传、橙=未传;首笔之前无需补传;禁选未来日 |\n| 补传 | 允许为过往未传日期补传;已传日期仅可**追加**新图 |\n| 归档 | **确认上传后的图片进入已归档状态,不可删除**;未确认的草稿图仍可删除 |\n| 二次确认 | H5 / Web 确认上传前弹窗:「提交后将无法修改,是否确认手工台账照片无误」;确认后才写入 |\n| 拦截 | **前一日手工台账未上传 → 今日禁止新增加氢记录**(编辑已有记录不拦;补传更早历史日不替代昨日校验) |\n| 数据 | 共享 `H2VehicleLedgerBridge` 内存 Store未接真实 API站名/编码解析见 Bridge.`h2BridgeResolveStationId` |\n\n## 判定顺序(新增拦截)\n\n| 优先级 | 条件 | 结果 |\n|--------|------|------|\n| 1 | 操作=编辑已有记录 | 不校验手工台账 |\n| 2 | 操作=新增,且昨日早于本站首笔加氢日(或不要求昨日台账) | 允许进入新增 |\n| 3 | 操作=新增,且目标站昨日已有 ≥1 张台账图 | 允许进入新增 |\n| 4 | 操作=新增,昨日未上传 | 顶部/Toast 提示「手工台账有缺失,作为对账重要依据,请先上传手工台账」,并**直接跳转**手工台账页且选中昨日 |\n\n## 日历与补传\n\n| 规则 | 说明 |\n|------|------|\n| 数据源 | `listManualLedgersByStation`;当日有 ≥1 张图视为已上传 |\n| 业务起点 | 取本站 Bridge 加氢记录中最早 `hydrogenTime` 自然日;无记录则历史日均不要求补传 |\n| 未传计数 | 本月内 `max(月初, 首笔日)`min(月末, 今日) 中未上传的天数 |\n| 点选 | 仅 `dateKey ≤ 今日`;选中后右侧/下方展示该日草稿图与上传按钮 |\n| 归档不可删 | Store 已有图强制保留;`upsertManualLedger` 仅追加新图,忽略删除已有图的请求 |\n| 与拦截关系 | 仅 **昨日** 影响「禁止新增」;历史补传解决核对缺口;今日台账仍可按日常上传,但不作为新增门禁 |\n\n## 用户可见\n\n- 今日状态双反馈H5 台账页顶部卡片 / Web 右侧「当日台账」):未上传「今日尚未上传…」异常态;已上传「今日已上传…」正常态。**日常上传反馈 ≠ 新增门禁**H5 文案须写明「今日不作为新增门禁,仅要求前一日已上传」 \n- 昨日缺失H5**列表顶栏可点橙条** + 底栏 FAB 旁提示 + Tab 红点;台账页同样展示橙条并可选中昨日;文案固定为「手工台账有缺失,作为对账重要依据,请先上传手工台账」 \n- 日历:自首笔加氢日起绿/橙标记;首笔之前无橙点(无需补传);本月未传天数摘要 \n- Web 右侧面板标题「手工台账」:空态提示;有图可点进全屏翻页预览并角标「已归档/待提交」;预览区内上传;底部确认前二次弹窗;提交后立即展示已归档缩略图;左右卡片等高\n\n## 代码路径\n\n- Bridge`src/common/h2VehicleLedgerBridge.js``hasManualLedgerForDate` / `isManualLedgerGateBlocked` / `upsertManualLedger` / `listManualLedgersByStation` \n- H5`oneos-h5-h2-order` · `ManualLedgerPanel` + `utils/manual-ledger.ts``isManualLedgerGateBlocked` / `buildManualMonthCells` / `getFirstHydrogenDateKey` \n- Web`oneos-web-h2-station/pages/02-加氢记录.jsx``hrIsManualLedgerGateBlocked` / 跳转选中昨日) \n\n## 验收\n\n1. 昨日未上传时H5/Web 列表顶栏提示固定文案;点「新增」被拦截并直接进入手工台账(选中昨日) \n2. 补传昨日台账并确认后,可新增加氢记录 \n3. 日历自首笔加氢日起区分已传/未传;首笔之前不标未传、不计入补传;可点选范围内未传日补传 \n4. 仅补传更早历史日、昨日仍未传时,新增仍被拦截 \n5. 确认上传后的图显示已归档且不可删除;仅可追加补传 \n6. H5 与 Web 共用同一 Store一端上传另一端可见同会话 \n7. Web 导出 CSV 列含「车辆来源」(羚牛车辆/非羚牛车辆)、「数据来源」(站端上传/羚牛上传),与列表标签口径一致 \n",
"markdownPath": ".spec/feature-manual-ledger.md"
},
{
"type": "markdown",
"id": "h5-doc-logic-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| 车辆标签 | [fleet-plate-tag.md](./fleet-plate-tag.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---\n\n## 2. 数据流\n\n```text\n站端 H5 / Web 新增加氢记录\n → Bridge.upsertRowverify=unverifiedreconcile=pending成本单价/量/总额)\n → 车辆氢费明细可见\n\n能源部 · 完成核对\n → verifyStatus=verified + verifiedAt\n → 站端同步「已核对」;羚牛车辆禁改删\n\n站点信息 · 对账单提交(仅已核对 ∧ 未对账)\n → reconcileStatus=reconciled + reconcileDate + statementRecordId\n → 站端 / 台账同步「已对账」;羚牛车辆禁改删\n```\n\n---\n\n## 3. 用户可见结果H5\n\n| 结果 | 行为 |\n|---|---|\n| 非羚牛车辆 | 核对/对账展示「—」;**不锁定**改删 |\n| 羚牛 · 未核对 | 可编辑、删除 |\n| 羚牛 · 已核对未对账 | 只读;提示能源部已核对 |\n| 羚牛 · 已对账 | 只读;提示已纳入对账单 |\n\n---\n\n## 4. 边界\n\n- 站端上报默认未核对、未对账\n- **站端改删锁定仅适用于羚牛车辆**(与 Web / 台账车辆标签口径一致)\n- 对账时间只认 `reconcileDate`(对账单回写)\n- 跨页联动依赖同浏览器会话共享 Store\n",
"markdownPath": ".spec/reconcile-linkage.md"
},
{
"type": "markdown",
"id": "h5-doc-logic-fleet",
"title": "羚牛车辆标签",
"markdown": "# 车牌「羚牛车辆 / 非羚牛车辆」标签\n\n原型本地判定未接真实车辆管理 API。\n\n全文含核对/对账字段空展示规则,跨 Web / 台账 / H5 \n[../../oneos-web-h2-station/.spec/fleet-vehicle-verify.md](../../oneos-web-h2-station/.spec/fleet-vehicle-verify.md)\n\n## 判定顺序\n\n| 优先级 | 条件 | 结果 |\n|--------|------|------|\n| 1 | 车牌为空 | 不展示标签 |\n| 2 | 规范化后车牌 ∈ 本地系统车辆集合 | **羚牛车辆** |\n| 3 | 有车牌但不在集合中 | **非羚牛车辆** |\n\n## 前置条件\n\n- 识别OCR或键盘输入得到车牌后才展示。\n- 比较前去掉尾缀 `F`,统一大写。\n\n## 数据源\n\n- 优先 `H2VehicleLedgerBridge.isLingniuVehicle`;本地回退见 `utils/fleet-plates.ts`(与 Bridge `H2_FLEET_PLATE_KEYS` 同步)。\n\n## 用户可见结果\n\n- 绿色标签「羚牛车辆」或橙色标签「非羚牛车辆」。\n- **非羚牛车辆**:列表/详情中核对状态、核对时间、对账状态、对账时间显示「—」,不展示核对待办语义。\n- 不拦截提交(仅提示归属)。\n\n## 代码路径\n\n- `getVehicleTag` / `isLingniuVehicle` → `CreateWizard`、`OrderCard`、`OrderDetail`\n",
"markdownPath": ".spec/fleet-plate-tag.md"
}
]
},
{
"type": "folder",
"id": "h5-doc-flows",
"title": "流程图",
"defaultExpanded": true,
"children": [
{
"type": "markdown",
"id": "h5-doc-flows-md",
"title": "操作流程图",
"markdown": "# 流程图\n\n> 标注面板支持 Mermaid 渲染。下列流程与当前原型行为一致(本地 Store未接真实 API。\n\n---\n\n## 1. 主路径:从打开到上报\n\n```mermaid\nflowchart TD\n A[打开加氢订单 H5] --> B[查看本站列表与 KPI]\n B --> C{前一日手工台账已上传?}\n C -->|否| D[提示:手工台账有缺失…]\n D --> E[进入手工台账 · 选中昨日]\n E --> F[拍照/相册上传并确认]\n F --> C\n C -->|是| G[点新增]\n G --> H[填写时间/车牌/面板/量价]\n H --> I{同站同车牌同时间已存在?}\n I -->|是| J[拦截提示]\n J --> H\n I -->|否| K[保存写入 Bridge]\n K --> B\n```\n\n---\n\n## 2. 手工台账补传\n\n```mermaid\nflowchart LR\n A[手工台账 Tab] --> B[月历:绿已传 / 橙未传]\n B --> C[点选 ≤今日 且 ≥首笔日]\n C --> D[上传图片]\n D --> E[二次确认]\n E --> F[归档不可删 · 可追加]\n```\n\n---\n\n## 3. 编辑 / 删除与锁定\n\n```mermaid\nflowchart TD\n A[打开详情] --> L{羚牛车辆?}\n L -->|否| D[可编辑 / 可删除]\n L -->|是| B{已核对或已对账?}\n B -->|是| C[只读 · 禁改删]\n B -->|否| D\n D --> E[写回 Bridge]\n```\n\n---\n\n## 4. 与 Web / 台账联动(概念)\n\n```mermaid\nsequenceDiagram\n participant H5 as 加氢订单 H5\n participant BR as Bridge Store\n participant WEB as 加氢记录 Web\n participant LED as 车辆氢费明细\n participant SITE as 站点对账单\n H5->>BR: 新增/改删流水 · 上传台账\n WEB->>BR: 同 Store 读写 · 导出\n LED->>BR: 完成核对 → 已核对\n SITE->>BR: 提交对账单 → 已对账\n```\n",
"markdownPath": ".spec/flows.md"
}
]
},
{
"type": "folder",
"id": "h5-doc-features",
@@ -403,28 +484,28 @@
"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",
"markdown": "# 功能:手工台账上传\n\n## 目标\n\n站端每日上传纸质**手工台账照片**,作为系统电子加氢记录的核对依据。多日未操作时,可通过日历查看缺口并补传过往日期。\n\n## 产品口径\n\n| 项 | 规则 |\n|---|---|\n| 入口 | H5底部 Tab「手工台账」Web顶栏「手工台账」 |\n| 站点范围 | **H5 固定本站**无切站Web 可按角色多站/超管切换(标注状态演示,未接真实登录) |\n| 上传形态 | 仅图片(拍照/相册),可多张 |\n| 维度 | **加氢站 + 自然日(本地日历日)** 一份 |\n| 日历 | 月历自本站**首笔加氢记录日**起算:绿=已传、橙=未传;首笔之前无需补传;禁选未来日 |\n| 补传 | 允许为过往未传日期补传;已传日期仅可**追加**新图 |\n| 归档 | **确认上传后的图片进入已归档状态,不可删除**;未确认的草稿图仍可删除 |\n| 二次确认 | H5 / Web 确认上传前弹窗:「提交后将无法修改,是否确认手工台账照片无误」;确认后才写入 |\n| 拦截 | **前一日手工台账未上传今日禁止新增加氢记录**(编辑已有记录不拦;补传更早历史不替代日校验) |\n| 数据 | 共享 `H2VehicleLedgerBridge` 内存 Store未接真实 API;站名/编码解析见 Bridge.`h2BridgeResolveStationId` |\n\n## 判定顺序(新增拦截)\n\n| 优先级 | 条件 | 结果 |\n|--------|------|------|\n| 1 | 操作=编辑已有记录 | 不校验手工台账 |\n| 2 | 操作=新增,且昨日早于本站首笔加氢日(或不要求昨日台账) | 允许进入新增 |\n| 3 | 操作=新增,且目标站日已有 ≥1 张台账图 | 允许进入新增 |\n| 4 | 操作=新增,日未上传 | 顶部/Toast 提示「手工台账有缺失,作为对账重要依据,请先上传手工台账」,并**直接跳转**手工台账页且选中昨日 |\n\n## 日历与补传\n\n| 规则 | 说明 |\n|------|------|\n| 数据源 | `listManualLedgersByStation`;当日有 ≥1 张图视为已上传 |\n| 业务起点 | 取本站 Bridge 加氢记录中最早 `hydrogenTime` 自然日;无记录则历史日均不要求补传 |\n| 未传计数 | 本月内 `max(月初, 首笔日)`min(月末, 今日) 中未上传的天数 |\n| 点选 | 仅 `dateKey ≤ 今日`;选中后右侧/下方展示该日草稿图与上传按钮 |\n| 归档不可删 | Store 已有图强制保留;`upsertManualLedger` 仅追加新图,忽略删除已有图的请求 |\n| 与拦截关系 | 仅 **日** 影响「禁止新增」;历史补传解决核对缺口;今日台账仍可按日常上传,但不作为新增门禁 |\n\n## 用户可见\n\n- 今日状态双反馈H5 台账页顶部卡片 / Web 右侧「当日台账」):未上传「今日尚未上传…」异常态;已上传「今日已上传…」正常态。**日常上传反馈 ≠ 新增门禁**H5 文案须写明「今日不作为新增门禁,仅要求前一日已上传」 \n- 昨日缺失H5**列表顶栏可点橙条** + 底栏 FAB 旁提示 + Tab 红点;台账页同样展示橙条并可选中昨日;文案固定为「手工台账有缺失,作为对账重要依据,请先上传手工台账」 \n- 日历:自首笔加氢日起绿/橙标记;首笔之前无橙点(无需补传);本月未传天数摘要 \n- Web 右侧面板标题「手工台账」:空态提示;有图可点进全屏翻页预览并角标「已归档/待提交」;预览区内上传;底部确认前二次弹窗;提交后立即展示已归档缩略图;左右卡片等高\n\n## 代码路径\n\n- Bridge`src/common/h2VehicleLedgerBridge.js``hasManualLedgerForDate` / `isManualLedgerGateBlocked` / `upsertManualLedger` / `listManualLedgersByStation` \n- H5`oneos-h5-h2-order` · `ManualLedgerPanel` + `utils/manual-ledger.ts``isManualLedgerGateBlocked` / `buildManualMonthCells` / `getFirstHydrogenDateKey` \n- Web`oneos-web-h2-station/pages/02-加氢记录.jsx``hrIsManualLedgerGateBlocked` / 跳转选中昨日 \n\n## 验收\n\n1. 日未上传时H5/Web 列表顶栏提示固定文案;点「新增」被拦截并直接进入手工台账(选中昨日) \n2. 补传昨日台账并确认后,可新增加氢记录 \n3. 日历自首笔加氢日起区分已传/未传;首笔之前不标未传、不计入补传;可点选范围内未传日补传 \n4. 补传更早历史日、昨日仍未传时新增仍被拦截 \n5. 确认上传后的图显示已归档且不可删除;仅可追加补传 \n6. H5 与 Web 共用同一 Store一端上传另一端可见同会话 \n7. Web 导出 CSV 列含「车辆来源」(羚牛车辆/非羚牛车辆)、「数据来源」(站端上传/羚牛上传),与列表标签口径一致 \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\n判定函数`isOrderLocked`(核对或对账任一成立即锁)。\n\n## 编辑\n\n进入与「新增」同一套表单预填当前值含已存水印照片提交走更新逻辑仍校验重复键。\n\n## 删除\n\n二次确认后从 Bridge 移除该行。\n\n## 验收\n\n1. 未核对未对账:底部有删除/编辑 \n2. 已核对或已对账:无操作栏,有锁定说明 \n3. 详情含对账信息;列表卡片不含对账 \n4. 详情展示车牌与面板带水印原图(新上报提交后为实拍;种子行为演示图) \n",
"markdown": "# 功能:详情、编辑与删除\n\n## 目标\n\n查看单笔加氢记录完整字段在「未核对且未对账」时可编辑或删除**锁定仅适用于羚牛车辆**。\n\n## 详情展示\n\n- 顶部:车牌、加氢时间、核对标签、对账标签 \n- 指标条:加氢总额、加氢量 \n- **识别原图**:车牌照片、加氢机面板照片(均为拍摄时叠加时间/地点水印后的原图;种子数据无实拍时用演示水印图) \n- 明细行:站点、车牌、里程、单价、核对/对账状态与时间 \n\n> 非羚牛车辆:核对/对账字段展示「—」,不展示核对待办语义(见 [fleet-plate-tag.md](./fleet-plate-tag.md))。\n\n## 锁定规则\n\n| 优先级 | 条件 | 结果 |\n|--------|------|------|\n| 1 | **非羚牛车辆** | **不锁定**(不以核对/对账状态禁改删) |\n| 2 | 羚牛 ∧ 已对账 | 不可编辑/删除;提示已纳入站点对账单 |\n| 3 | 羚牛 ∧ 已核对(未对账) | 不可编辑/删除;提示能源部已核对 |\n| 4 | 羚牛 ∧ 未核对且未对账 | 可编辑、可删除 |\n\n判定函数`isOrderLocked`先判羚牛;再核「核对或对账任一成立即锁)。\n\n## 编辑\n\n进入与「新增」同一套表单预填当前值含已存水印照片提交走更新逻辑仍校验重复键。\n\n## 删除\n\n二次确认文案:「确认删除车牌 … 的未锁定记录?」;确认后从 Bridge 移除该行。\n\n## 验收\n\n1. 羚牛 ∧ 未核对未对账:底部有删除/编辑 \n2. 羚牛 ∧ 已核对或已对账:无操作栏,有锁定说明 \n3. 非羚牛:可改删(不因核对/对账锁定);核对/对账展示「—」 \n4. 详情含对账信息;列表卡片不含对账 \n5. 详情展示车牌与面板带水印原图(新上报提交后为实拍;种子行为演示图) \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",
"markdown": "# 车牌「羚牛车辆 / 非羚牛车辆」标签\n\n原型本地判定未接真实车辆管理 API。\n\n全文(含核对/对账字段空展示规则,跨 Web / 台账 / H5 \n[../../oneos-web-h2-station/.spec/fleet-vehicle-verify.md](../../oneos-web-h2-station/.spec/fleet-vehicle-verify.md)\n\n## 判定顺序\n\n| 优先级 | 条件 | 结果 |\n|--------|------|------|\n| 1 | 车牌为空 | 不展示标签 |\n| 2 | 规范化后车牌 ∈ 本地系统车辆集合 | **羚牛车辆** |\n| 3 | 有车牌但不在集合中 | **非羚牛车辆** |\n\n## 前置条件\n\n- 识别OCR或键盘输入得到车牌后才展示。\n- 比较前去掉尾缀 `F`,统一大写。\n\n## 数据源\n\n- 优先 `H2VehicleLedgerBridge.isLingniuVehicle`;本地回退见 `utils/fleet-plates.ts`(与 Bridge `H2_FLEET_PLATE_KEYS` 同步)。\n\n## 用户可见结果\n\n- 绿色标签「羚牛车辆」或橙色标签「非羚牛车辆」。\n- **非羚牛车辆**:列表/详情中核对状态、核对时间、对账状态、对账时间显示「—」,不展示核对待办语义。\n- 不拦截提交(仅提示归属)。\n\n## 代码路径\n\n- `getVehicleTag` / `isLingniuVehicle` → `CreateWizard`、`OrderCard`、`OrderDetail`\n",
"markdownPath": ".spec/fleet-plate-tag.md"
},
{
"type": "markdown",
"id": "h5-doc-reconcile",
"title": "核对与对账联动",
"markdown": "# 加氢订单H5· 对账 / 核对联动规则\n\n| 项 | 说明 |\n|---|---|\n| 代码路径 | Store`src/common/h2VehicleLedgerBridge.js`;对账单:`oneos-web-h2-station-site`PC 加氢记录:`oneos-web-h2-station`;车辆氢费明细:`vehicle-h2-fee-ledger` |\n| 规格全文 | [../vehicle-h2-fee-ledger/.spec/verify-reconcile.md](../vehicle-h2-fee-ledger/.spec/verify-reconcile.md) |\n| 数据源 | 原型本地种子 + 内存共享 Store**未接真实 API** |\n\n---\n\n## 1. 两套状态(判定优先级)\n\n| 优先级 | 状态 | 字段 | 谁触发 | 用户可见 |\n|---|---|---|---|---|\n| 1 | **核对** | `verifyStatus` / `verifiedAt` | 车辆氢费明细 · **完成核对** | 未核对 / 已核对 + 核对时间 |\n| 2 | **对账** | `reconcileStatus` / `reconcileDate` | 站点信息 · **对账单提交** | 未对账 / 已对账 + 对账时间 |\n\n> `reconciledAt` 仅作完成核对时的兼容写入,**不得**再用于判定「已对账」。\n\n## 2. 数据流\n\n```text\n站端 H5 / Web 新增加氢记录\n → Bridge.upsertRowverify=unverifiedreconcile=pending成本单价/量/总额)\n → 车辆氢费明细可见\n\n能源部 · 完成核对\n → verifyStatus=verified + verifiedAt\n → 站端同步「已核对」禁改删\n\n站点信息 · 对账单提交(仅已核对 ∧ 未对账)\n → reconcileStatus=reconciled + reconcileDate + statementRecordId\n → 站端 / 台账同步「已对账」\n```\n\n## 3. 用户可见结果H5\n\n| 结果 | 行为 |\n|---|---|\n| 未核对 | 可编辑、删除 |\n| 已核对未对账 | 只读;提示能源部已核对 |\n| 已对账 | 只读;提示已纳入对账单 |\n\n## 4. 边界\n\n- 站端上报默认未核对、未对账\n- 对账时间只认 `reconcileDate`(对账单回写)\n- 跨页联动依赖同浏览器会话共享 Store\n",
"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| 车辆标签 | [fleet-plate-tag.md](./fleet-plate-tag.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---\n\n## 2. 数据流\n\n```text\n站端 H5 / Web 新增加氢记录\n → Bridge.upsertRowverify=unverifiedreconcile=pending成本单价/量/总额)\n → 车辆氢费明细可见\n\n能源部 · 完成核对\n → verifyStatus=verified + verifiedAt\n → 站端同步「已核对」;羚牛车辆禁改删\n\n站点信息 · 对账单提交(仅已核对 ∧ 未对账)\n → reconcileStatus=reconciled + reconcileDate + statementRecordId\n → 站端 / 台账同步「已对账」;羚牛车辆禁改删\n```\n\n---\n\n## 3. 用户可见结果H5\n\n| 结果 | 行为 |\n|---|---|\n| 非羚牛车辆 | 核对/对账展示「—」;**不锁定**改删 |\n| 羚牛 · 未核对 | 可编辑、删除 |\n| 羚牛 · 已核对未对账 | 只读;提示能源部已核对 |\n| 羚牛 · 已对账 | 只读;提示已纳入对账单 |\n\n---\n\n## 4. 边界\n\n- 站端上报默认未核对、未对账\n- **站端改删锁定仅适用于羚牛车辆**(与 Web / 台账车辆标签口径一致)\n- 对账时间只认 `reconcileDate`(对账单回写)\n- 跨页联动依赖同浏览器会话共享 Store\n",
"markdownPath": ".spec/reconcile-linkage.md"
}
]
@@ -433,7 +514,7 @@
"type": "folder",
"id": "h5-doc-pages",
"title": "按页面",
"defaultExpanded": true,
"defaultExpanded": false,
"children": [
{
"type": "folder",
@@ -500,7 +581,7 @@
"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\n判定函数`isOrderLocked`(核对或对账任一成立即锁)。\n\n## 编辑\n\n进入与「新增」同一套表单预填当前值含已存水印照片提交走更新逻辑仍校验重复键。\n\n## 删除\n\n二次确认后从 Bridge 移除该行。\n\n## 验收\n\n1. 未核对未对账:底部有删除/编辑 \n2. 已核对或已对账:无操作栏,有锁定说明 \n3. 详情含对账信息;列表卡片不含对账 \n4. 详情展示车牌与面板带水印原图(新上报提交后为实拍;种子行为演示图) \n"
"markdown": "# 功能:详情、编辑与删除\n\n## 目标\n\n查看单笔加氢记录完整字段在「未核对且未对账」时可编辑或删除**锁定仅适用于羚牛车辆**。\n\n## 详情展示\n\n- 顶部:车牌、加氢时间、核对标签、对账标签 \n- 指标条:加氢总额、加氢量 \n- **识别原图**:车牌照片、加氢机面板照片(均为拍摄时叠加时间/地点水印后的原图;种子数据无实拍时用演示水印图) \n- 明细行:站点、车牌、里程、单价、核对/对账状态与时间 \n\n> 非羚牛车辆:核对/对账字段展示「—」,不展示核对待办语义(见 [fleet-plate-tag.md](./fleet-plate-tag.md))。\n\n## 锁定规则\n\n| 优先级 | 条件 | 结果 |\n|--------|------|------|\n| 1 | **非羚牛车辆** | **不锁定**(不以核对/对账状态禁改删) |\n| 2 | 羚牛 ∧ 已对账 | 不可编辑/删除;提示已纳入站点对账单 |\n| 3 | 羚牛 ∧ 已核对(未对账) | 不可编辑/删除;提示能源部已核对 |\n| 4 | 羚牛 ∧ 未核对且未对账 | 可编辑、可删除 |\n\n判定函数`isOrderLocked`先判羚牛;再核「核对或对账任一成立即锁)。\n\n## 编辑\n\n进入与「新增」同一套表单预填当前值含已存水印照片提交走更新逻辑仍校验重复键。\n\n## 删除\n\n二次确认文案:「确认删除车牌 … 的未锁定记录?」;确认后从 Bridge 移除该行。\n\n## 验收\n\n1. 羚牛 ∧ 未核对未对账:底部有删除/编辑 \n2. 羚牛 ∧ 已核对或已对账:无操作栏,有锁定说明 \n3. 非羚牛:可改删(不因核对/对账锁定);核对/对账展示「—」 \n4. 详情含对账信息;列表卡片不含对账 \n5. 详情展示车牌与面板带水印原图(新上报提交后为实拍;种子行为演示图) \n"
}
]
}

View File

@@ -7,9 +7,11 @@ import {
getFirstHydrogenDateKey,
getManualLedger,
isArchivedManualImage,
isManualLedgerGateBlocked,
listManualLedgersByStation,
todayDateKey,
upsertManualLedger,
yesterdayDateKey,
type ManualLedgerImage,
} from '../utils/manual-ledger';
import { IconCamera, IconCheck } from './Icons';
@@ -19,21 +21,31 @@ type Props = {
tick: number;
onUploaded: () => void;
showToast: (msg: string) => void;
/** 打开面板时优先选中该日(如昨日缺口) */
focusDate?: string;
};
const WEEK_LABELS = ['日', '一', '二', '三', '四', '五', '六'];
export function ManualLedgerPanel({ station, tick, onUploaded, showToast }: Props) {
export function ManualLedgerPanel({ station, tick, onUploaded, showToast, focusDate }: 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 initialDate = focusDate && focusDate <= today ? focusDate : today;
const [selectedDate, setSelectedDate] = useState(initialDate);
const [cursorYm, setCursorYm] = useState(() => {
const [y, m] = today.split('-').map(Number);
const [y, m] = initialDate.split('-').map(Number);
return { y, m };
});
useEffect(() => {
if (!focusDate || focusDate > today) return;
setSelectedDate(focusDate);
const [y, m] = focusDate.split('-').map(Number);
setCursorYm({ y, m });
}, [focusDate, today]);
const stationKey = station.stationId || station.stationName;
const allEntries = useMemo(
@@ -127,7 +139,7 @@ export function ManualLedgerPanel({ station, tick, onUploaded, showToast }: Prop
showToast('请至少上传一张手工台账照片');
return;
}
const ok = window.confirm('确认上传后该记录将归档无法修改,是否确认无误');
const ok = window.confirm('提交后将无法修改,是否确认手工台账照片无误');
if (!ok) return;
setSaving(true);
try {
@@ -174,8 +186,24 @@ export function ManualLedgerPanel({ station, tick, onUploaded, showToast }: Prop
setSelectedDate(dateKey);
}
const yesterday = yesterdayDateKey();
const gateBlocked = isManualLedgerGateBlocked(stationKey);
return (
<div className="h5-manual" data-annotation-id="h5-manual">
{gateBlocked ? (
<button
type="button"
className="h5-banner-warn"
onClick={() => {
setSelectedDate(yesterday);
const [y, m] = yesterday.split('-').map(Number);
setCursorYm({ y, m });
}}
>
{yesterday}
</button>
) : null}
<div
className={`h5-manual-status${
uploadedSet.has(today) ? ' h5-manual-status--ok' : ''
@@ -186,7 +214,9 @@ export function ManualLedgerPanel({ station, tick, onUploaded, showToast }: Prop
<strong>{uploadedSet.has(today) ? '今日已上传' : '今日尚未上传'}</strong>
<p>
{today} · {station.stationName}
{!uploadedSet.has(today) ? '。上传今日台账后才可新增加氢记录。' : ''}
{!uploadedSet.has(today)
? '。今日台账可按日常上传,不作为新增门禁;新增加氢记录仅要求前一日已上传。'
: ''}
</p>
</div>
</div>

View File

@@ -1,6 +1,7 @@
import React from 'react';
import type { H5OrderRecord } from '../types';
import { formatDateTimeMinute, formatKg, formatMoney, formatPlateDisplay } from '../utils/format';
import { getVehicleTag } from '../utils/fleet-plates';
import { IconCheck, IconClock } from './Icons';
type Props = {
@@ -10,22 +11,34 @@ type Props = {
};
export function OrderCard({ order, onClick, index = 0 }: Props) {
const verified = order.verifyStatus === 'verified';
const vehicleTag = getVehicleTag(order.plateNo);
const isLingniu = vehicleTag === 'lingniu';
const verified = isLingniu && order.verifyStatus === 'verified';
const verifyLabel = !isLingniu ? null : verified ? '已核对' : '未核对';
return (
<button
type="button"
className={`h5-card${verified ? ' h5-card--done' : ' h5-card--pending'}`}
className={`h5-card${isLingniu ? (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 ? '已核对' : '未核对'}`}
aria-label={`${formatPlateDisplay(order.plateNo)}${verifyLabel ? `${verifyLabel}` : ''}`}
>
<div className="h5-card-top">
<div className="h5-plate">{formatPlateDisplay(order.plateNo)}</div>
<span className={`h5-tag ${verified ? 'h5-tag--done' : 'h5-tag--pending'}`}>
{verified ? <IconCheck size={12} /> : <IconClock size={12} />}
{verified ? '已核对' : '未核对'}
</span>
<div className="h5-plate-wrap">
<div className="h5-plate">{formatPlateDisplay(order.plateNo)}</div>
{vehicleTag === 'lingniu' ? (
<span className="h5-vehicle-tag h5-vehicle-tag--in"></span>
) : vehicleTag === 'external' ? (
<span className="h5-vehicle-tag h5-vehicle-tag--out"></span>
) : null}
</div>
{verifyLabel ? (
<span className={`h5-tag ${verified ? 'h5-tag--done' : 'h5-tag--pending'}`}>
{verified ? <IconCheck size={12} /> : <IconClock size={12} />}
{verifyLabel}
</span>
) : null}
</div>
<div className="h5-card-meta">
<IconClock size={13} />
@@ -46,7 +59,7 @@ export function OrderCard({ order, onClick, index = 0 }: Props) {
</div>
<div className="h5-card-foot">
<span> {formatMoney(order.unitPrice)} /kg</span>
{verified ? (
{isLingniu && verified ? (
<span className="h5-card-foot__verify"> {formatDateTimeMinute(order.verifiedAt)}</span>
) : null}
</div>

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
import type { H5OrderRecord } from '../types';
import { buildDemoWatermarkedPhoto } from '../utils/demo-photo';
import { formatDateTimeMinute, formatKg, formatMileage, formatMoney, formatPlateDisplay } from '../utils/format';
import { getVehicleTag } from '../utils/fleet-plates';
import { IconCheck, IconChevronLeft, IconClock } from './Icons';
import { PhoneShell } from './PhoneShell';
import { isOrderLocked } from '../utils/orders';
@@ -14,8 +15,10 @@ type Props = {
};
export function OrderDetail({ order, onBack, onEdit, onDelete }: Props) {
const reconciled = order.reconcileStatus === 'reconciled';
const verified = order.verifyStatus === 'verified';
const vehicleTag = getVehicleTag(order.plateNo);
const isLingniu = vehicleTag === 'lingniu';
const reconciled = isLingniu && order.reconcileStatus === 'reconciled';
const verified = isLingniu && order.verifyStatus === 'verified';
const locked = isOrderLocked(order);
const [plateSrc, setPlateSrc] = useState(order.platePhotoUrl || '');
const [panelSrc, setPanelSrc] = useState(order.panelPhotoUrl || '');
@@ -87,21 +90,30 @@ export function OrderDetail({ order, onBack, onEdit, onDelete }: Props) {
<section className="h5-panel h5-panel--detail" data-annotation-id="h5-detail">
<div className="h5-detail-hero">
<div>
<div className="h5-plate">{formatPlateDisplay(order.plateNo)}</div>
<div className="h5-plate-wrap">
<div className="h5-plate">{formatPlateDisplay(order.plateNo)}</div>
{vehicleTag === 'lingniu' ? (
<span className="h5-vehicle-tag h5-vehicle-tag--in"></span>
) : vehicleTag === 'external' ? (
<span className="h5-vehicle-tag h5-vehicle-tag--out"></span>
) : null}
</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>
{isLingniu ? (
<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>
) : null}
</div>
<div className="h5-detail-metrics" aria-label="加氢金额与用量">
<div>
@@ -152,19 +164,19 @@ export function OrderDetail({ order, onBack, onEdit, onDelete }: Props) {
</div>
<div className="h5-detail-row">
<span></span>
<strong>{verified ? '已核对' : '未核对'}</strong>
<strong>{isLingniu ? (verified ? '已核对' : '未核对') : '—'}</strong>
</div>
<div className="h5-detail-row">
<span></span>
<strong>{verified ? formatDateTimeMinute(order.verifiedAt) : '—'}</strong>
<strong>{isLingniu && verified ? formatDateTimeMinute(order.verifiedAt) : '—'}</strong>
</div>
<div className="h5-detail-row">
<span></span>
<strong>{reconciled ? '已对账' : '未对账'}</strong>
<strong>{isLingniu ? (reconciled ? '已对账' : '未对账') : '—'}</strong>
</div>
<div className="h5-detail-row">
<span></span>
<strong>{reconciled ? formatDateTimeMinute(order.reconcileTime) : '—'}</strong>
<strong>{isLingniu && reconciled ? formatDateTimeMinute(order.reconcileTime) : '—'}</strong>
</div>
</section>
{reconciled ? (

View File

@@ -31,7 +31,7 @@ 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 { isManualLedgerGateBlocked, yesterdayDateKey } from './utils/manual-ledger';
import {
findDuplicateStationOrder,
getTotalAmountMismatch,
@@ -72,6 +72,7 @@ export default function OneosH5H2Order() {
const [tick, setTick] = useState(0);
const [view, setView] = useState<AppView>({ name: 'list' });
const [mainTab, setMainTab] = useState<'orders' | 'manual'>('orders');
const [manualFocusDate, setManualFocusDate] = useState<string | undefined>(undefined);
const [toast, setToast] = useState('');
useEffect(() => {
@@ -90,8 +91,10 @@ export default function OneosH5H2Order() {
}
function openCreate() {
if (!hasManualLedgerForDate(station.stationId || station.stationName)) {
showToast('请先上传今日手工台账');
const stationKey = station.stationId || station.stationName;
if (isManualLedgerGateBlocked(stationKey)) {
showToast('手工台账有缺失,作为对账重要依据,请先上传手工台账');
setManualFocusDate(yesterdayDateKey());
setMainTab('manual');
setView({ name: 'list' });
return;
@@ -193,7 +196,7 @@ export default function OneosH5H2Order() {
function handleDelete(id: string) {
const order = orders.find((item) => item.id === id);
if (!order || isOrderLocked(order)) return;
if (!window.confirm(`确认删除车牌 ${order.plateNo.replace(/F$/u, '')} `)) return;
if (!window.confirm(`确认删除车牌 ${order.plateNo.replace(/F$/u, '')} `)) return;
removeStationOrder(id);
setTick((n) => n + 1);
setView({ name: 'list' });
@@ -267,7 +270,7 @@ export default function OneosH5H2Order() {
</PhoneShell>
);
} else {
const todayUploaded = hasManualLedgerForDate(station.stationId || station.stationName);
const gateBlocked = isManualLedgerGateBlocked(station.stationId || station.stationName);
content = (
<PhoneShell
title={mainTab === 'manual' ? '手工台账' : '加氢订单'}
@@ -276,8 +279,8 @@ export default function OneosH5H2Order() {
<>
{mainTab === 'orders' ? (
<div className="h5-fab-dock" data-annotation-id="h5-fab-dock">
{!todayUploaded ? (
<p className="h5-fab-dock__hint">今日未上传手工台账,无法新增电子记录</p>
{gateBlocked ? (
<p className="h5-fab-dock__hint">手工台账有缺失,作为对账重要依据,请先上传手工台账</p>
) : null}
<button
type="button"
@@ -307,7 +310,7 @@ export default function OneosH5H2Order() {
>
<IconCamera size={18} />
手工台账
{!todayUploaded ? <span className="h5-tabbar-dot" aria-label="今日未上传" /> : null}
{gateBlocked ? <span className="h5-tabbar-dot" aria-label="前一日手工台账未上传" /> : null}
</button>
</nav>
{toast ? (
@@ -322,11 +325,29 @@ export default function OneosH5H2Order() {
<ManualLedgerPanel
station={station}
tick={tick}
focusDate={manualFocusDate}
showToast={showToast}
onUploaded={() => setTick((n) => n + 1)}
onUploaded={() => {
setTick((n) => n + 1);
setManualFocusDate(undefined);
}}
/>
) : (
<>
{gateBlocked ? (
<button
type="button"
className="h5-banner-warn"
data-annotation-id="h5-ledger-gate-banner"
onClick={() => {
setManualFocusDate(yesterdayDateKey());
setMainTab('manual');
}}
>
手工台账有缺失,作为对账重要依据,请先上传手工台账
</button>
) : null}
<div className="h5-hero" data-annotation-id="h5-hero" aria-label="当前站点">
<div className="h5-hero-label">
<IconMapPin size={13} />

View File

@@ -35,6 +35,10 @@ 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 mdRoles = readMd('roles.md');
const mdStories = readMd('user-stories.md');
const mdLogic = readMd('key-logic.md');
const mdFlows = readMd('flows.md');
const source = {
documentVersion: 1,
@@ -267,7 +271,7 @@ const source = {
'## 识别原图(含水印)\n\n详情展示车牌识别、加氢机面板的原始照片照片在拍摄/选图时已叠加时间与地点水印并随记录保存。种子数据无实拍时用演示水印图。\n\n详见 [详情编辑删除](feature-detail)。',
'h5-manual': mdManual,
'h5-manual-calendar':
'## 台账日历\n\n自本站首笔加氢记录日起绿=已上传,橙=未上传;首笔之前无需补传。禁选未来日。点选范围内未传日可补传。仅「今日」是否上传影响新增加氢记录。\n\n详见 [手工台账上传](feature-manual-ledger)。',
'## 台账日历\n\n自本站首笔加氢记录日起绿=已上传,橙=未上传;首笔之前无需补传。禁选未来日。点选范围内未传日可补传。**前一日**未上传时禁止新增加氢记录(今日台账状态不作为门禁)。\n\n详见 [手工台账上传](feature-manual-ledger)。',
},
assetMap: {},
directory: {
@@ -285,6 +289,87 @@ const source = {
markdown: mdPrd,
markdownPath: '.spec/requirements-prd.md',
},
{
type: 'folder',
id: 'h5-doc-roles',
title: '需求角色',
defaultExpanded: true,
children: [
{
type: 'markdown',
id: 'h5-doc-roles-md',
title: '角色说明',
markdown: mdRoles,
markdownPath: '.spec/roles.md',
},
],
},
{
type: 'folder',
id: 'h5-doc-stories',
title: '用户故事',
defaultExpanded: true,
children: [
{
type: 'markdown',
id: 'h5-doc-stories-md',
title: '用户故事',
markdown: mdStories,
markdownPath: '.spec/user-stories.md',
},
],
},
{
type: 'folder',
id: 'h5-doc-logic',
title: '关键逻辑',
defaultExpanded: true,
children: [
{
type: 'markdown',
id: 'h5-doc-logic-index',
title: '逻辑索引',
markdown: mdLogic,
markdownPath: '.spec/key-logic.md',
},
{
type: 'markdown',
id: 'h5-doc-logic-manual',
title: '手工台账门禁',
markdown: mdManual,
markdownPath: '.spec/feature-manual-ledger.md',
},
{
type: 'markdown',
id: 'h5-doc-logic-reconcile',
title: '核对与对账联动',
markdown: mdReconcile,
markdownPath: '.spec/reconcile-linkage.md',
},
{
type: 'markdown',
id: 'h5-doc-logic-fleet',
title: '羚牛车辆标签',
markdown: mdFleet,
markdownPath: '.spec/fleet-plate-tag.md',
},
],
},
{
type: 'folder',
id: 'h5-doc-flows',
title: '流程图',
defaultExpanded: true,
children: [
{
type: 'markdown',
id: 'h5-doc-flows-md',
title: '操作流程图',
markdown: mdFlows,
markdownPath: '.spec/flows.md',
},
],
},
{
type: 'folder',
id: 'h5-doc-features',
@@ -339,7 +424,7 @@ const source = {
type: 'folder',
id: 'h5-doc-pages',
title: '按页面',
defaultExpanded: true,
defaultExpanded: false,
children: [
{
type: 'folder',

View File

@@ -373,6 +373,13 @@
font-variant-numeric: tabular-nums;
}
.h5-plate-wrap {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.h5-tag {
font-size: 12px;
font-weight: 700;

View File

@@ -1,4 +1,6 @@
/** 原型本地「系统车辆列表」车牌(不含尾缀 F用于羚牛/非羚牛判定 */
/** 原型本地「系统车辆列表」车牌(不含尾缀 F用于羚牛/非羚牛判定
* 与 `src/common/h2VehicleLedgerBridge.js` 的 H2_FLEET_PLATE_KEYS 保持同步。
*/
const FLEET_PLATES = new Set(
[
'浙A55666',
@@ -21,10 +23,21 @@ const FLEET_PLATES = new Set(
'浙F06618',
'粤AGR8556',
'粤AGP5156',
'浙AD12345',
'浙AH55660',
'浙BK33210',
'粤BK33210',
'沪AD12345',
'苏EF99887',
'京CN88771',
'川AL55602',
].map((p) => p.toUpperCase()),
);
export function normalizePlateKey(plateNo: string): string {
if (typeof window !== 'undefined' && window.H2VehicleLedgerBridge?.normalizePlateKey) {
return window.H2VehicleLedgerBridge.normalizePlateKey(plateNo);
}
return String(plateNo || '')
.trim()
.toUpperCase()
@@ -33,6 +46,9 @@ export function normalizePlateKey(plateNo: string): string {
/** 车牌是否在系统车辆列表中 → 羚牛车辆 */
export function isLingniuVehicle(plateNo: string): boolean {
if (typeof window !== 'undefined' && window.H2VehicleLedgerBridge?.isLingniuVehicle) {
return window.H2VehicleLedgerBridge.isLingniuVehicle(plateNo);
}
const key = normalizePlateKey(plateNo);
if (!key) return false;
return FLEET_PLATES.has(key);

View File

@@ -70,6 +70,32 @@ export function todayDateKey(): string {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
}
export function yesterdayDateKey(): string {
const b = bridge();
if (b?.yesterdayDateKey) return b.yesterdayDateKey();
const d = new Date();
d.setDate(d.getDate() - 1);
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
}
/**
* 前一日手工台账未上传 → 今日不可新增加氢记录。
* 若昨日早于本站首笔加氢日(或无首笔),不要求昨日台账。
*/
export function isManualLedgerGateBlocked(stationIdOrName: string): boolean {
const b = bridge();
if (b?.isManualLedgerGateBlocked) {
return Boolean(b.isManualLedgerGateBlocked(stationIdOrName));
}
const station = String(stationIdOrName || '').trim();
if (!station) return false;
const todayKey = todayDateKey();
const yKey = yesterdayDateKey();
const firstKey = getFirstHydrogenDateKey(station);
if (!cellNeedsLedger(yKey, todayKey, firstKey)) return false;
return !hasManualLedgerForDate(station, yKey);
}
export function hasManualLedgerForDate(
stationIdOrName: string,
ledgerDate?: string,
@@ -191,6 +217,8 @@ declare global {
interface Window {
H2VehicleLedgerBridge?: {
todayDateKey?: () => string;
yesterdayDateKey?: () => string;
isManualLedgerGateBlocked?: (stationIdOrName: string) => boolean;
getRows?: () => Array<{ stationId?: string; stationName?: string; hydrogenTime?: string }>;
hasManualLedgerForDate?: (stationIdOrName: string, ledgerDate?: string) => boolean;
getManualLedger?: (stationIdOrName: string, ledgerDate?: string) => ManualLedgerEntry | null;

View File

@@ -18,6 +18,8 @@ declare global {
totalAmount: number,
toleranceYuan?: number,
) => { expected: number; actual: number; diff: number } | null;
isLingniuVehicle?: (plateNo: string) => boolean;
normalizePlateKey?: (plateNo: string) => string;
};
}
}
@@ -85,6 +87,12 @@ export function mapRowToOrder(row: Record<string, unknown>): H5OrderRecord {
}
export function isOrderLocked(order: H5OrderRecord): boolean {
/* 非羚牛车辆不参与核对/对账业务属性,不以核对对账状态锁定 */
const bridge = typeof window !== 'undefined' ? window.H2VehicleLedgerBridge : undefined;
const isLingniu = bridge?.isLingniuVehicle
? bridge.isLingniuVehicle(order.plateNo)
: true;
if (!isLingniu) return false;
return order.verifyStatus === 'verified' || order.reconcileStatus === 'reconciled';
}

View File

@@ -6,10 +6,10 @@
"title": "OneOS",
"children": [
{
"id": "item:prototypes:oneos-web-workbench",
"id": "item:prototypes:oneos-web-workbench-new",
"kind": "item",
"title": "工作台",
"itemKey": "prototypes/oneos-web-workbench"
"itemKey": "prototypes/oneos-web-workbench-new"
},
{
"id": "folder-oneos-approval",
@@ -78,6 +78,12 @@
"title": "租赁合同",
"itemKey": "prototypes/lease-contract-management"
},
{
"id": "item:prototypes:self-operated-contract",
"kind": "item",
"title": "自营合同",
"itemKey": "prototypes/self-operated-contract"
},
{
"id": "item:prototypes:insurance-procurement",
"kind": "item",
@@ -221,6 +227,12 @@
"kind": "item",
"title": "任务工单",
"itemKey": "prototypes/task-work-order"
},
{
"id": "item:prototypes:self-operated-dispatch-task",
"kind": "item",
"title": "调度任务",
"itemKey": "prototypes/self-operated-dispatch-task"
}
]
},
@@ -228,14 +240,7 @@
"id": "folder-1784341576489-spa7ms",
"kind": "folder",
"title": "审批中心",
"children": [
{
"id": "item:prototypes:oneos-web-approval",
"kind": "item",
"title": "审批中心",
"itemKey": "prototypes/oneos-web-approval"
}
]
"children": []
},
{
"id": "item:prototypes:lease-business-line-overview",

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +0,0 @@
/**
* @name 审批中心
*/
import { ApprovalApp } from '../../common/oneos-web-approval/ApprovalApp';
export default function OneosWebApproval() {
return <ApprovalApp pageTitle="审批中心" showTabs />;
}

View File

@@ -37,8 +37,8 @@
1. 选择**账单开始日期**与**账单结束日期**后,从**车辆氢费明细**获取该时间段内、对应当前站点、且为**已核对且尚未结算(未对账)**的加氢记录。
2. 点击「**生成对账单**」时弹出**二次确认**,提示:生成并提交后,车辆氢费明细中对应记录将标记为**已对账**且无法修改。
3. 确认后进入结算阶段,填写**收票日期**、**收票金额****发票附件**(对账日期在提交时由系统自动回写)。
4. 点击「**提交对账单**」后,本批明细在车辆氢费明细中锁定为**已对账**并绑定本对账单,不可再进入后续对账单。
3. 确认后进入结算阶段,填写**收票日期**、**收票金额****发票附件**及**对账单盖章附件**(对账日期在提交时由系统自动回写)。
4. 点击「**提交对账单**」并二次确认后,本批明细在**加氢记录Web、加氢订单H5、车辆氢费明细**三端同步锁定为**已对账**并绑定本对账单,不可再进入后续对账单。
**阶段一 · 选期生成**
@@ -65,6 +65,7 @@
| 收票日期 | 必填 |
| 收票金额 | 必填,默认填入成本总金额 |
| 发票附件 | 必填,支持上传 |
| 对账单盖章附件 | **必填**;支持盖章 PDF、图片`accept=.pdf,image/*` |
---
@@ -72,19 +73,18 @@
提交成功后需**原子性**完成:
1. 写入对账历史(供「查看对账记录」查询)
1. 写入对账历史(供「查看对账记录」查询;含发票附件与盖章附件
2. 扣减站点预付余额
3. 标记台账记录已结算(绑定 `statementRecordId`
4. **回写车辆氢费明细**
4. **经共享 Bridge 回写三端**(同浏览器会话)
| 车辆氢费明细字段 | 回写 |
| 模块 | 回写结果 |
|---|---|
| 对账状态 | **已对账**`reconcileStatus = reconciled` |
| 对账日期 | 操作完成时间 |
| 收票日期 | 操作完成日期 |
| 加氢站付款状态 | 已付款 |
| 车辆氢费明细 | 对账状态 **已对账**;对账日期 / 收票日期 / 付款状态 / 对账人 |
| 加氢记录Web | 对账状态 → **已对账**(订阅 Bridge |
| 加氢订单H5 | 对账状态 → **已对账**(订阅 Bridge |
原型通过 `H2_STATION_STATEMENT_LEDGER_UPDATES` / `h2VehicleLedgerBridge` 模拟跨页回写
原型通过 `H2_STATION_STATEMENT_LEDGER_UPDATES` / `h2VehicleLedgerBridge.applyPatches` 模拟跨页回写(**未接真实 API**
> 「完成核对」只改变核对状态,**不会**把记录写成已对账;已对账仅由本页对账单提交产生。
@@ -96,7 +96,7 @@
历史列表:对账日期、对账人、账单区间、加氢次数、加氢金额、结算后余额、查看明细。
查看明细:账单区间、统计三卡、收票信息、发票附件、成本明细表。
查看明细:账单区间、统计三卡、收票信息、发票附件、对账单盖章附件、成本明细表。
---
@@ -108,8 +108,10 @@
| 区间内无「已核对且未对账未结算」记录 | 阻止生成,提示调整日期(或先完成核对) |
| 区间内存在未核对记录 | **不纳入**对账单;不单独弹窗枚举(静默排除) |
| 点击「生成对账单」未确认 | 不进入结算阶段 |
| 收票日期/金额/附件未填 | 阻止提交 |
| 收票日期/金额/发票附件/盖章附件未填 | 阻止提交 |
| 盖章附件非 PDF/图片 | 拒绝该文件并提示 |
| 收票金额 ≤ 0 | 阻止提交 |
| 点击「提交对账单」未二次确认 | 不执行回写 |
| 预付余额结算后为负 | 允许提交,标「已欠费」 |
---

View File

@@ -145,8 +145,8 @@
| 步骤 | 说明 |
|---|---|
| 1. 拉取明细 | 按账期从车辆氢费明细取 **已核对****未对账** ∧ 未结算 ∧ 本站 的记录;**未核对不纳入** |
| 2. 两阶段 | 选期生成 → 填收票提交 |
| 3. 提交回写 | 更新预付余额、写对账历史;台账变为 **已对账**(对账日期、收票日期、已付款) |
| 2. 两阶段 | 选期生成 → 填收票 / 发票附件 / **对账单盖章附件** → 二次确认提交 |
| 3. 提交回写 | 更新预付余额、写对账历史;**加氢记录 Web / H5 / 车辆氢费明细**三端同步为 **已对账** |
> **核对 ≠ 对账**:核对由车辆氢费明细「完成核对」产生;对账仅由本页「提交对账单」回写。
@@ -175,8 +175,8 @@
### 8.2 对账流程
- [ ]**已核对** 且未对账、未结算的记录可进入对账单;未核对明细即使在账期内也不出现
- [ ] 两阶段交互与必填校验完整
- [ ] 提交后回写车辆氢费明细为 **已对账**,可跨页验收
- [ ] 两阶段交互与必填校验完整含对账单盖章附件PDF/图片)
- [ ] 提交二次确认后加氢记录Web、加氢订单H5车辆氢费明细三端同步**已对账**
### 8.3 期末余额设置

View File

@@ -71,6 +71,7 @@
| 规则 | 代码 |
|---|---|
| 筛选入口 | `h2GetAvailableStatementLedgerRows(store, stationName, startDate, endDate)` |
| Store 映射 | `h2BridgeMapToStationLedgerRow` **必须透出** `verifyStatus`(否则站点侧会全部当成未核对) |
| 已核对 | `verifyStatus === 'verified'`(缺省按 `unverified` |
| 未对账 | `reconcileStatus === 'pending'`(且 ≠ `reconciled` |
| 账期 | `h2ParseDateTimeMs(hydrogenTime)` 落在起止日闭区间 |

File diff suppressed because one or more lines are too long

View File

@@ -2329,16 +2329,27 @@ function h2ResolveUploadPreview(file, variant) {
};
}
/** 盖章附件:仅允许 PDF / 常见图片 */
function h2IsSealAttachmentFile(file) {
var name = String((file && (file.name || file.fileName)) || '').toLowerCase();
var type = String((file && file.type) || '').toLowerCase();
if (type.indexOf('pdf') >= 0 || type.indexOf('image/') === 0) return true;
return /\.(pdf|png|jpe?g|gif|webp|bmp)$/i.test(name);
}
/** 合同附件:按钮上传 + 预览/下载 */
function ContractFilesUpload(props) {
var antd = window.antd;
var Upload = antd.Upload;
var Button = antd.Button;
var messageApi = antd.message;
var fileList = props.fileList || [];
var disabled = props.disabled;
var showHint = props.showHint !== false;
var wrapClass = 'h2-contract-upload' + (props.wrapClassName ? ' ' + props.wrapClassName : '');
var uploadClass = props.uploadClassName || 'h2-create-upload-btn';
var accept = props.accept || undefined;
var validateFile = props.validateFile;
return React.createElement('div', { className: wrapClass },
React.createElement('div', { className: 'h2-contract-upload-actions' },
@@ -2347,8 +2358,24 @@ function ContractFilesUpload(props) {
multiple: true,
disabled: disabled,
fileList: fileList,
beforeUpload: function () { return false; },
onChange: props.onChange,
accept: accept,
beforeUpload: function (file) {
if (typeof validateFile === 'function' && !validateFile(file)) {
if (messageApi && messageApi.warning) {
messageApi.warning(props.rejectMessage || '文件格式不支持');
}
return Upload.LIST_IGNORE || false;
}
return false;
},
onChange: function (info) {
if (!props.onChange) return;
var nextList = info.fileList || [];
if (typeof validateFile === 'function') {
nextList = nextList.filter(function (f) { return validateFile(f); });
}
props.onChange({ fileList: nextList });
},
showUploadList: false
},
React.createElement(Button, {
@@ -2359,7 +2386,7 @@ function ContractFilesUpload(props) {
style: props.buttonStyle
}, props.uploadLabel || '上传附件')
),
showHint ? React.createElement('span', { style: { fontSize: 12, color: '#94a3b8' } }, '点击上传,可多选') : null
showHint ? React.createElement('span', { style: { fontSize: 12, color: '#94a3b8' } }, props.hintText || '点击上传,可多选') : null
),
fileList.length
? React.createElement('div', { className: 'h2-contract-file-list' },
@@ -5992,7 +6019,8 @@ const Component = function () {
balanceAfterSettlement: 0,
receiptDate: '',
receiptAmount: '',
invoiceFiles: []
invoiceFiles: [],
sealFiles: []
});
var statementModal = statementModalState[0];
var setStatementModal = statementModalState[1];
@@ -6753,13 +6781,14 @@ const Component = function () {
open: true,
record: record,
startDate: defaultStart,
endDate: '2026-05-31',
endDate: '2026-07-31',
phase: 'select',
draftRows: [],
balanceAfterSettlement: h2NumOrZero(record.prepaidBalance),
receiptDate: '',
receiptAmount: '',
invoiceFiles: []
invoiceFiles: [],
sealFiles: []
});
}, [statementRecords]);
@@ -6774,7 +6803,8 @@ const Component = function () {
balanceAfterSettlement: 0,
receiptDate: '',
receiptAmount: '',
invoiceFiles: []
invoiceFiles: [],
sealFiles: []
});
}, []);
@@ -6829,7 +6859,8 @@ const Component = function () {
balanceAfterSettlement: balanceAfter,
receiptAmount: summary.totalCost ? h2FormatYuanNum(summary.totalCost) : '',
receiptDate: '',
invoiceFiles: []
invoiceFiles: [],
sealFiles: []
});
});
message.success('已生成对账记录,共 ' + summary.count + ' 笔,请填写结算信息并提交');
@@ -6857,71 +6888,88 @@ const Component = function () {
message.warning('请上传发票附件');
return;
}
var summary = h2CalcStatementSummary(rows);
var operateTime = h2OperateTimestamp();
var operateDate = h2FormatOperateDateOnly();
var statementId = 'stmt-' + Date.now();
var recordIds = rows.map(function (r) { return r.id; });
var stationRecord = statementModal.record;
var balanceAfter = h2NumOrZero(statementModal.balanceAfterSettlement);
var newStatement = {
id: statementId,
stationId: stationRecord.id,
stationName: stationRecord.name,
reconcileDate: operateTime,
reconciler: H2_CURRENT_OPERATOR,
startDate: statementModal.startDate,
endDate: statementModal.endDate,
refuelCount: summary.count,
totalKg: summary.totalKg,
totalCost: summary.totalCost,
balanceAfterSettlement: balanceAfter,
receiptDate: statementModal.receiptDate,
receiptAmount: receiptAmountNum,
invoiceFiles: (statementModal.invoiceFiles || []).slice(),
recordIds: recordIds
};
var patches = [];
setLedgerStore(function (prev) {
return prev.map(function (r) {
if (recordIds.indexOf(r.id) < 0) return r;
patches.push({
ledgerId: r.id,
orderNo: r.orderNo,
stationName: r.stationName,
plateNo: r.plateNo,
hydrogenTime: r.hydrogenTime,
reconcileDate: operateTime,
receiptDate: operateDate,
paymentStatus: H2_PAYMENT_STATUS_PAID,
statementRecordId: statementId,
reconcileStatus: H2_RECONCILE_RECONCILED
});
return Object.assign({}, r, {
statementRecordId: statementId,
reconcileStatus: H2_RECONCILE_RECONCILED,
reconcileDate: operateTime,
receiptDate: operateDate,
paymentStatus: H2_PAYMENT_STATUS_PAID
});
});
});
setStatementRecords(function (prev) { return [newStatement].concat(prev); });
setListData(function (prev) {
return prev.map(function (item) {
if (item.id !== stationRecord.id) return item;
return Object.assign({}, item, {
prepaidBalance: balanceAfter,
updateTime: operateTime
});
});
});
h2PushStatementLedgerPatches(patches);
if (typeof window !== 'undefined' && window.H2_VEHICLE_LEDGER_API && typeof window.H2_VEHICLE_LEDGER_API.applyPatches === 'function') {
window.H2_VEHICLE_LEDGER_API.applyPatches(patches);
if (!statementModal.sealFiles || !statementModal.sealFiles.length) {
message.warning('请上传对账单盖章附件');
return;
}
message.success('对账单已提交,共结算 ' + summary.count + ' 笔加氢记录');
closeStatementModal();
var summary = h2CalcStatementSummary(rows);
Modal.confirm({
title: '确认提交对账单?',
content: '提交后,本批 ' + summary.count + ' 笔记录将在加氢记录、加氢订单H5、车辆氢费明细三端同步为「已对账」且不可再修改是否确认',
okText: '确认提交',
cancelText: '取消',
centered: true,
onOk: function () {
var operateTime = h2OperateTimestamp();
var operateDate = h2FormatOperateDateOnly();
var statementId = 'stmt-' + Date.now();
var recordIds = rows.map(function (r) { return r.id; });
var stationRecord = statementModal.record;
var balanceAfter = h2NumOrZero(statementModal.balanceAfterSettlement);
var newStatement = {
id: statementId,
stationId: stationRecord.id,
stationName: stationRecord.name,
reconcileDate: operateTime,
reconciler: H2_CURRENT_OPERATOR,
startDate: statementModal.startDate,
endDate: statementModal.endDate,
refuelCount: summary.count,
totalKg: summary.totalKg,
totalCost: summary.totalCost,
balanceAfterSettlement: balanceAfter,
receiptDate: statementModal.receiptDate,
receiptAmount: receiptAmountNum,
invoiceFiles: (statementModal.invoiceFiles || []).slice(),
sealFiles: (statementModal.sealFiles || []).slice(),
recordIds: recordIds
};
var patches = [];
setLedgerStore(function (prev) {
return prev.map(function (r) {
if (recordIds.indexOf(r.id) < 0) return r;
patches.push({
ledgerId: r.id,
orderNo: r.orderNo,
stationName: r.stationName,
plateNo: r.plateNo,
hydrogenTime: r.hydrogenTime,
reconcileDate: operateTime,
receiptDate: operateDate,
paymentStatus: H2_PAYMENT_STATUS_PAID,
statementRecordId: statementId,
reconcileStatus: H2_RECONCILE_RECONCILED,
reconciler: H2_CURRENT_OPERATOR,
reconciledByName: H2_CURRENT_OPERATOR
});
return Object.assign({}, r, {
statementRecordId: statementId,
reconcileStatus: H2_RECONCILE_RECONCILED,
reconcileDate: operateTime,
receiptDate: operateDate,
paymentStatus: H2_PAYMENT_STATUS_PAID,
reconciledByName: H2_CURRENT_OPERATOR
});
});
});
setStatementRecords(function (prev) { return [newStatement].concat(prev); });
setListData(function (prev) {
return prev.map(function (item) {
if (item.id !== stationRecord.id) return item;
return Object.assign({}, item, {
prepaidBalance: balanceAfter,
updateTime: operateTime
});
});
});
h2PushStatementLedgerPatches(patches);
if (typeof window !== 'undefined' && window.H2_VEHICLE_LEDGER_API && typeof window.H2_VEHICLE_LEDGER_API.applyPatches === 'function') {
window.H2_VEHICLE_LEDGER_API.applyPatches(patches);
}
message.success('对账单已提交,共结算 ' + summary.count + ' 笔;三端已同步为「已对账」');
closeStatementModal();
}
});
}, [statementModal, closeStatementModal]);
var openStatementHistoryModal = useCallback(function (record) {
@@ -8458,9 +8506,9 @@ const Component = function () {
React.createElement('strong', null, lastEndDate || '暂无')
),
!isDraft ? React.createElement('div', { className: 'h2-statement-form-hint' },
'数据来源:',
'数据来源:仅计入',
React.createElement('strong', null, '车辆氢费明细'),
' 中标记为「已核对」且尚未对账结算的加氢记录;未核对记录不会进入对账单明细仅展示成本价与成本总价。'
' 中所有「已核对」且尚未对账的加氢记录;未核对记录不会进入对账单。提交后加氢记录Web、加氢订单H5、车辆氢费明细三端同步为「已对账」。明细仅展示成本价与成本总价。'
) : null
),
isDraft ? React.createElement('div', { className: 'h2-statement-stats', role: 'group', 'aria-label': '对账统计' },
@@ -8556,6 +8604,21 @@ const Component = function () {
onChange: function (info) {
setStatementModal(function (m) { return Object.assign({}, m, { invoiceFiles: info.fileList || [] }); });
}
})),
formItem('对账单盖章附件', true, React.createElement(ContractFilesUpload, {
fileList: statementModal.sealFiles || [],
showHint: true,
hintText: '支持盖章 PDF、图片可多选',
accept: '.pdf,image/*',
validateFile: h2IsSealAttachmentFile,
rejectMessage: '盖章附件仅支持 PDF 或图片文件',
uploadLabel: '上传盖章附件',
wrapClassName: 'h2-statement-seal-upload',
uploadClassName: 'h2-statement-upload-btn-wrap',
buttonClassName: 'h2-statement-upload-btn',
onChange: function (info) {
setStatementModal(function (m) { return Object.assign({}, m, { sealFiles: info.fileList || [] }); });
}
}))
) : null
);
@@ -8635,6 +8698,25 @@ const Component = function () {
if (!stmt) return null;
var summary = h2CalcStatementSummary(rows);
var invoiceFiles = stmt.invoiceFiles || [];
var sealFiles = stmt.sealFiles || [];
var renderStmtFileList = function (files, emptyText) {
if (!files.length) {
return React.createElement('div', { style: { fontSize: 13, color: '#94a3b8', marginTop: 8 } }, emptyText);
}
return React.createElement('div', { className: 'h2-statement-detail-receipt-file-list' },
files.map(function (file) {
return React.createElement('div', { key: file.uid || file.name, className: 'h2-statement-detail-receipt-file-item' },
React.createElement('span', { className: 'h2-statement-detail-receipt-file-name', title: file.name }, file.name || '未命名附件'),
React.createElement(Button, {
type: 'link',
size: 'small',
icon: H2_ICONS.download,
onClick: function () { h2DownloadUploadFile(file); }
}, '下载')
);
})
);
};
return React.createElement('div', { className: 'h2-statement-panel' },
React.createElement('div', { className: 'h2-statement-station-card' },
React.createElement('div', { className: 'h2-statement-station-card__name' }, stmt.stationName || '—'),
@@ -8672,21 +8754,11 @@ const Component = function () {
),
React.createElement('div', { className: 'h2-statement-detail-receipt-files' },
React.createElement('div', { className: 'h2-statement-detail-receipt-item__label' }, '发票附件'),
invoiceFiles.length
? React.createElement('div', { className: 'h2-statement-detail-receipt-file-list' },
invoiceFiles.map(function (file) {
return React.createElement('div', { key: file.uid || file.name, className: 'h2-statement-detail-receipt-file-item' },
React.createElement('span', { className: 'h2-statement-detail-receipt-file-name', title: file.name }, file.name || '未命名附件'),
React.createElement(Button, {
type: 'link',
size: 'small',
icon: H2_ICONS.download,
onClick: function () { h2DownloadUploadFile(file); }
}, '下载')
);
})
)
: React.createElement('div', { style: { fontSize: 13, color: '#94a3b8', marginTop: 8 } }, '暂无发票附件')
renderStmtFileList(invoiceFiles, '暂无发票附件')
),
React.createElement('div', { className: 'h2-statement-detail-receipt-files', style: { marginTop: 12 } },
React.createElement('div', { className: 'h2-statement-detail-receipt-item__label' }, '对账单盖章附件'),
renderStmtFileList(sealFiles, '暂无盖章附件')
)
),
React.createElement('div', { className: 'h2-statement-table-wrap' },

View File

@@ -93,8 +93,8 @@ const NODE_DEFS = [
title: '生成对账单',
pageId: 'site-info',
color: '#7c3aed',
aiPrompt: '选期仅拉取已核对未对账氢费明细、二次确认与结算提交。',
annotationText: '账期内仅纳入车辆氢费明细「已核对」记录;未核对不进对账单;提交后标为已对账',
aiPrompt: '选期仅拉取已核对未对账氢费明细;结算须上传盖章附件;提交二次确认后三端同步已对账。',
annotationText: '仅计入车辆氢费明细全部「已核对」且未对账记录结算阶段必填对账单盖章附件PDF/图片);提交确认后 Web/H5/台账三端同步「已对账',
locator: {
selectors: ['[data-annotation-id="h2-site-statement-form"]', '.h2-statement-form-wrap'],
fingerprint: 'form|statement',

View File

@@ -65,7 +65,7 @@
|---|---|
| 共享 Bridge 不可用 | 列表用本地 fallback保存/删除提示失败 |
| 已核对或已对账点编辑/删除 | 对应警告文案 |
| PC 独有 | **导出**含核对/对账列;手工台账上传(站点按角色范围;今日双态提示;右侧预览区确认上传) |
| PC 独有 | **导出**含车辆来源/数据来源/核对/对账列;手工台账上传(站点按角色范围;**昨日门禁**禁新增;今日双态提示;右侧预览区确认上传) |
## 4. 数据流

View File

@@ -26,7 +26,7 @@
- **车辆氢费明细**:车牌号下方标签(站端=绿 / 羚牛=蓝)
- **加氢记录 Web**:车牌号下方同口径标签
- 导出 CSV 含「来源」列
- 导出 CSV 含「数据来源」列(与「车辆来源」分列)
---

View File

@@ -27,15 +27,17 @@
| # | 功能 | 说明 |
|---|---|---|
| 1 | 列表 / 筛选 | 字段与 H5 一致;默认一行(加氢时间/车牌/站名/对账状态);`splitFilterFields` + **更多筛选/收起**`vm-filter-expand` / `vm-filter-toggle`)对齐物流业务明细;占位符样式见全局设计规范 §4.1.1 |
| 2 | 手工台账 | 与 H5 同逻辑:站点按登录角色范围过滤;今日已传/未传双态提示;右侧「手工台账」预览(空态/全屏翻页/已归档角标)+ 确认前二次提示;月历补传与拦截见 [feature-manual-ledger](../oneos-h5-h2-order/.spec/feature-manual-ledger.md) |
| 3 | 新增 / 批量 / 编辑 | 字段:时间、站、车牌、**里程(选填)**、单价、量、总额;已对账只读;同站同车牌同时间拦截;总额偏离单价×量超 0.05 元需确认 |
| 1 | 列表 / 筛选 / 详情 | 字段与 H5 一致;默认一行(加氢时间/车牌/站名/对账状态);`splitFilterFields` + **更多筛选/收起**;操作列 **详情**(记录字段 + 车牌/面板原图)在「更多」之前,见 [feature-list.md](./feature-list.md) |
| 2 | 手工台账 | 与 H5 同逻辑:站点按登录角色范围过滤;今日已传/未传双态提示;**前一日缺失则今日禁新增**(顶栏固定文案,点新增直达台账并选中昨日);右侧「手工台账」预览 + 确认前二次提示;月历补传见 [feature-manual-ledger](../oneos-h5-h2-order/.spec/feature-manual-ledger.md) |
| 3 | 新增 / 批量 / 编辑 | 时间精确至秒;单站默认/多站请选择;车牌旁羚牛/外部车辆标签;总额自动只读;支持批量导入(模板+结果页);顶栏无「批量新增加氢记录」标题;详见 [feature-create.md](./feature-create.md) |
| 4 | 删除 | 仅未对账;写共享 Store |
| 5 | 导出 | 当前筛选结果 CSVWeb 独有 |
| 5 | 导出 | 当前筛选结果 CSV;列含**车辆来源**、**数据来源**(与列表标签一致 |
### 1.2 列表字段
加氢时间、加氢站名称、车牌号(附来源标签、里程km、氢气单价元/kg、加氢量、加氢总额、核对状态、核对时间、核对人、对账状态、对账时间、对账人;操作含 **变更日志**
加氢时间、加氢站名称、车牌号(附**车辆来源**:羚牛车辆/非羚牛车辆 + **数据来源**:站端上传/羚牛上传、里程km、氢气单价元/kg、加氢量、加氢总额、核对状态、核对时间、核对人、对账状态、对账时间、对账人。
操作列(`OperationActions`):外显 **详情**(弹窗含字段摘要 + 车牌识别原图 / 加氢机面板原图);**更多** 含编辑、变更日志、删除。
非羚牛车辆的核对/对账相关列显示为空,详见 [fleet-vehicle-verify.md](./fleet-vehicle-verify.md)。
### 1.3 工具栏 Tab
@@ -54,11 +56,14 @@
2. 与 H5 读写同一共享 Store一端增删改后另一端可见同会话
3. 筛选默认一行;更多筛选/收起对齐物流业务明细;支持核对状态、核对时间、对账状态、对账时间;占位符样式与全局规范一致
4. 已核对或已对账不可编辑、删除;**已对账记录对账时间不为空**(对账单回写);核对由车辆氢费明细「完成核对」触发
5. 导出列与列表字段一致(含来源、核对人、对账人)
5. 导出列与列表字段一致(含车辆来源、数据来源、核对人、对账人)
6. 列表左上角两个 Tab加氢记录 / 手工台账;悬浮与选中样式符合分段 Tab 规范;无预约加氢入口
7. 同站同车牌同加氢时间重复保存被拦截;总额与单价×量偏差 > 0.05 元时出现确认
8. 来源标签:站端上报→「站端上传」,台账手增→「羚牛上传」;变更日志含时间/操作人/修改项/原值/新值;核对时间后有核对人、对账时间后有对账人
8. 数据来源标签:站端上报→「站端上传」,台账手增→「羚牛上传」;变更日志含时间/操作人/修改项/原值/新值;核对时间后有核对人、对账时间后有对账人
9. 每日统计默认收起,第一屏以列表为主
10. 车牌附「羚牛车辆 / 非羚牛车辆」;非羚牛车辆核对/对账字段显示为空
11. 前一日手工台账缺失时,顶栏提示「手工台账有缺失,作为对账重要依据,请先上传手工台账」;点新增直达手工台账并选中昨日;补传后可新增
12. 操作列「详情」在「更多」之前;弹窗可查看加氢记录字段及车牌号、加氢机面板照片(无实拍时用演示占位图)
---
@@ -69,3 +74,22 @@
| 数据结构与对账 | 站端口径字段;**核对 vs 对账**双状态PC 多导出 | [record-data-model.md](./record-data-model.md)、[../vehicle-h2-fee-ledger/.spec/verify-reconcile.md](../vehicle-h2-fee-ledger/.spec/verify-reconcile.md) |
| 重复键 / 总额偏差 | 业务主键拦截;总额偏差二次确认 | [record-data-model.md](./record-data-model.md) §56 |
| 来源 / 变更日志 / 操作人 | `recordSource`、行级 `changeLogs`、核对人/对账人 | [record-source-changelog.md](./record-source-changelog.md) |
| 羚牛车辆与核对对账可见性 | 系统车辆列表判定;非羚牛字段空展示 | [fleet-vehicle-verify.md](./fleet-vehicle-verify.md) |
| 手工台账门禁 | 昨日未传 → 今日禁新增;文案与跳转见规格 | [../oneos-h5-h2-order/.spec/feature-manual-ledger.md](../oneos-h5-h2-order/.spec/feature-manual-ledger.md) |
---
## 4. 右侧标注目录
打开原型右侧「原型标注 / 原型目录」,可阅读:
| 目录 | 内容 |
|---|---|
| PRD 全文 | [requirements-prd.md](./requirements-prd.md) |
| 需求角色 | [roles.md](./roles.md) |
| 用户故事 | [user-stories.md](./user-stories.md) |
| 关键逻辑 | [key-logic.md](./key-logic.md) 及数据模型、车辆、来源、台账、对账规格 |
| 流程图 | [flows.md](./flows.md)Mermaid |
| 功能说明 | 列表、新增、台账、导出、每日统计等 |
`scripts/build-annotation-source.mjs``.spec/*.md` 生成 `annotation-source.json`

File diff suppressed because one or more lines are too long

View File

@@ -4,11 +4,15 @@
*/
import '../../common/oneosWebLegacy/legacyGlobals';
import '../../common/h2VehicleLedgerBridge.js';
import React from 'react';
import React, { useMemo } 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 {
type AnnotationSourceDocument,
type AnnotationViewerOptions,
} from '@axhub/annotation';
import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host';
import annotationSourceDocument from './annotation-source.json';
const pages = [
@@ -16,23 +20,28 @@ const pages = [
];
export default function OneosWebH2Station() {
const annotationOptions = useMemo<AnnotationViewerOptions>(
() => ({
currentPageId: 'h2-record',
toolbarEdge: 'right',
showToolbar: true,
showThemeToggle: true,
showColorFilter: true,
emptyWhenNoData: false,
}),
[],
);
return (
<>
<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,
}}
moduleTitle="加氢记录"
pages={pages}
defaultPageId="h2-record"
/>
<PrototypeAnnotationHost
source={annotationSourceDocument as AnnotationSourceDocument}
options={annotationOptions}
/>
</>
);

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +0,0 @@
/**
* @name 工作台
* 自 ONE-OS web端 原稿复刻1 个页面)
*/
import '../../common/oneosWebLegacy/legacyGlobals';
import React from 'react';
import Page1 from './pages/01-工作台.jsx';
import { OneosWebLegacyShell } from '../../common/oneosWebLegacy/OneosWebLegacyShell';
const pages = [
{ id: 'u5de5u4f5cu53f0', title: '工作台', component: Page1 },
];
export default function OneosWebWorkbench() {
return (
<OneosWebLegacyShell
moduleTitle="工作台"
pages={pages}
defaultPageId="u5de5u4f5cu53f0"
/>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -39,8 +39,8 @@
"fingerprint": "pr-list",
"path": []
},
"aiPrompt": "新增、批量导入、导出与收款列表。",
"annotationText": "列表含收款时间、汇款账户名、公司主体、银行、金额、备注、关联账单、未认领金额、操作人、操作时间;操作列支持关联账单。",
"aiPrompt": "新增、批量导入、导出与收款列表;导入成功演示入账短信通知。",
"annotationText": "列表含收款时间、汇款账户名、公司主体、银行、金额、备注、关联账单、未关联金额、操作人、操作时间;操作列支持关联账单。新增/批量导入成功后按账户名通知客户负责人与合同创建人(原型演示)。",
"hasMarkdown": false,
"color": "#0f766e",
"images": [],
@@ -104,6 +104,18 @@
"id": "pr-doc-overview",
"title": "模块总览",
"markdown": "# 收款记录\n\n维护客户到账收款支持关联租赁账单、自营账单、氢费账单。KPI 按收款记录统计:到账总金额 = 收款金额合计;认领总金额 = 已认领金额合计;未关联总金额 = 到账金额 已认领金额。"
},
{
"type": "markdown",
"id": "pr-doc-prd",
"title": "产品需求摘要",
"markdownPath": ".spec/requirements-prd.md"
},
{
"type": "markdown",
"id": "pr-doc-notify",
"title": "入账短信通知",
"markdownPath": ".spec/payment-notify.md"
}
]
}

View File

@@ -35,6 +35,7 @@ import {
sumUnclaimedAmount,
type PaymentKpiKey,
} from './utils/payment';
import { notifyPaymentEntrySms, notifyPaymentImportSms } from './utils/notify-recipients';
import annotationSourceDocument from './annotation-source.json';
const PAGE_SIZE = 10;
@@ -187,6 +188,7 @@ function PaymentRecordsContent() {
setRecords((prev) => [row, ...prev]);
setAddOpen(false);
message.success('收款记录已新增(原型演示)');
notifyPaymentEntrySms(row.customerName, message, { source: 'add' });
};
const handleLinkBills = (recordId: string, linkedBills: LinkedBill[]) => {
@@ -249,7 +251,13 @@ function PaymentRecordsContent() {
type="button"
className="vm-btn vm-btn-ghost ldb-toolbar-btn"
data-vm-icon="import"
onClick={() => message.info('批量导入:请下载模板后上传 Excel原型演示')}
onClick={() => {
const demoCustomers = filtered.slice(0, 3).map((row) => row.customerName);
const names = demoCustomers.length > 0
? demoCustomers
: ['嘉兴港区韵达快递有限公司', '未知客户(演示未匹配)'];
notifyPaymentImportSms(names, message);
}}
>
</button>

View File

@@ -1,7 +1,9 @@
# 物流业务明细 · 产品需求说明PRD
> 参照《2026年业务二部运营台账总表》物流业务明细子表行内编辑与导入为原型 Toast 演示。
> **界面设计规范**与租赁业务明细、租赁合同列表统一,见 `src/prototypes/ledger-shared/DESIGN.md``vm-page ldb-page`)。
> **界面设计规范**与租赁业务明细、租赁合同列表统一,见 `src/prototypes/ledger-shared/DESIGN.md``vm-page ldb-page`)。
> **主路径升级**:调度任务办结自动生成明细行;导入降为补录。规则全文见 [ledger-auto-gen.md](./ledger-auto-gen.md)。
> 假设:[assumptions.md](../../../resources/self-operated-logistics/assumptions.md)。
---
@@ -11,8 +13,9 @@
|---|---|
| 模块名称 | 台账数据 — 物流业务明细 |
| 目标用户 | 业务服务组操作人、主管 |
| 核心任务 | 记录出车营收与成本、自动汇总盈亏、导入导出与删改 |
| 核心任务 | 承接调度自动出车明细、补录导入、自动汇总盈亏 |
| 页面结构 | 筛选区 + KPI 统计 + 工具栏 + 宽表 + 分页 |
| 上游 | 自营合同 → 轻量调度任务办结 |
---
@@ -73,3 +76,22 @@
**总成本** = 氢费 + 人工报销费用 + ETC费用 + 电费 + 薪资 + 车辆费用。
**盈亏** = 金额 总成本;参照 A 股配色:**正数红色、负数绿色**。
---
## 6. 调度办结自动生成(摘要)
| 项 | 说明 |
|---|---|
| 业务目的 | 办结出车任务后自动落明细,减少手工导入 |
| 触发 | `self-operated-dispatch-task` 办结成功(须车辆已交车) |
| 粒度 | 一任务一行;备注前缀 `[调度自动·任务号]` |
| 成本一期 | 营收侧自动;氢费/ETC 等默认 0可补录 |
| 数据源 | Bridge `ledger-auto` + 种子/导入行合并展示 |
| 规格全文 | [ledger-auto-gen.md](./ledger-auto-gen.md) |
### 验收重点(自动生成)
- [ ] 调度办结后本页刷新可见新行(同浏览器 localStorage
- [ ] 自动行备注含任务号;金额/盈亏公式正确
- [ ] 导入仍可用,不覆盖不同 id 的自动行

View File

@@ -5,7 +5,7 @@
"version": 2,
"prototypeName": "self-operated-business-ledger",
"pageId": "list",
"updatedAt": 1783910257820,
"updatedAt": 1784350346385,
"nodes": [
{
"id": "sob-filter",
@@ -25,7 +25,7 @@
"color": "#2563eb",
"images": [],
"createdAt": 1740787200000,
"updatedAt": 1783910257820
"updatedAt": 1784350346385
},
{
"id": "sob-kpi",
@@ -45,7 +45,7 @@
"color": "#64748b",
"images": [],
"createdAt": 1740787200000,
"updatedAt": 1783910257820
"updatedAt": 1784350346385
},
{
"id": "sob-toolbar",
@@ -65,7 +65,7 @@
"color": "#0f766e",
"images": [],
"createdAt": 1740787200000,
"updatedAt": 1783910257820
"updatedAt": 1784350346385
},
{
"id": "sob-table",
@@ -85,7 +85,7 @@
"color": "#2563eb",
"images": [],
"createdAt": 1740787200000,
"updatedAt": 1783910257820
"updatedAt": 1784350346385
}
]
},
@@ -93,7 +93,7 @@
"sob-filter": "## 2. 筛选区\n\n默认展示 4 项,点击「更多筛选」展开。\n\n| 筛选项 | 说明 |\n|---|---|\n| 月份 | YYYY-MM |\n| 出车日期 | 日期区间 |\n| 业务名称 | 精确匹配 |\n| 车牌号码 | 多选 |\n| 车辆承接方 | 我司 / 第三方车队 |\n| 品牌 / 型号 | 级联 |\n| 维护人 | 仅主管可见 |\n\n---",
"sob-kpi": "## 3. KPI 统计卡片6 张)\n\n明细条数、金额合计、总成本合计、氢费总计、ETC费用总计、盈亏合计。\n\n卡片样式对齐租赁业务明细左侧色条、图标、悬浮说明、盈亏正负着色。\n\n---",
"sob-toolbar": "## 4. 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 批量导入 | 下载模板23 列手工录入)→ 上传 Excel → 系统车型/金额/总成本/盈亏自动计算 |\n| 导出 | 导出当前筛选结果(原型演示) |\n| 添加行 | 表格底栏新增行(原型演示) |\n\n不支持批量删除与行多选。\n\n### 导入模板列23 列)\n\n年份、月份、出车日期、业务名称、司机、电话、车牌号码、单价、数量、氢费、ETC费用、薪资、电费、人工报销费用、日社保服务费、日挂车服务费、日挂车费用、日停车费用、日轮胎费用、车辆费用、是否多趟、线路计价、备注。\n\n**必填**:年份、月份、出车日期、业务名称、司机、电话、车牌号码、单价、数量、薪资、是否多趟、线路计价。\n\n**不含**(导入后自动计算/反写):系统车型、金额、总成本、盈亏。\n\n---",
"sob-table": "## 5. 列表字段\n\n月份、出车日期、业务名称、司机、电话、车牌号码、系统车型、单价、数量、金额、氢费、ETC费用、薪资、电费、人工报销费用、日社保服务费、日挂车费用、日停车费用、日轮胎费用、车辆费用、总成本、盈亏、是否多趟、线路计价、备注、操作。\n\n**月份展示**:列表合并显示为 `YYYY-MM`;导入模板仍为「年份」「月份」两列,导入后自动合并展示。\n\n**系统车型**:按车牌从车辆主数据反写,展示为「品牌·型号」。\n\n**金额** = 单价 × 数量。\n\n**总成本** = 氢费 + 人工报销费用 + ETC费用 + 电费 + 薪资 + 车辆费用。\n\n**盈亏** = 金额 总成本;参照 A 股配色:**正数红色、负数绿色**。"
"sob-table": "## 5. 列表字段\n\n月份、出车日期、业务名称、司机、电话、车牌号码、系统车型、单价、数量、金额、氢费、ETC费用、薪资、电费、人工报销费用、日社保服务费、日挂车费用、日停车费用、日轮胎费用、车辆费用、总成本、盈亏、是否多趟、线路计价、备注、操作。\n\n**月份展示**:列表合并显示为 `YYYY-MM`;导入模板仍为「年份」「月份」两列,导入后自动合并展示。\n\n**系统车型**:按车牌从车辆主数据反写,展示为「品牌·型号」。\n\n**金额** = 单价 × 数量。\n\n**总成本** = 氢费 + 人工报销费用 + ETC费用 + 电费 + 薪资 + 车辆费用。\n\n**盈亏** = 金额 总成本;参照 A 股配色:**正数红色、负数绿色**。\n\n---"
},
"assetMap": {},
"directory": {
@@ -108,7 +108,7 @@
"type": "markdown",
"id": "sob-doc-overview",
"title": "模块总览",
"markdown": "## 1. 模块定位\n\n| 项 | 说明 |\n|---|---|\n| 模块名称 | 台账数据 — 物流业务明细 |\n| 目标用户 | 业务服务组操作人、主管 |\n| 核心任务 | 记录出车营收与成本、自动汇总盈亏、导入导出与删改 |\n| 页面结构 | 筛选区 + KPI 统计 + 工具栏 + 宽表 + 分页 |\n\n---"
"markdown": "## 1. 模块定位\n\n| 项 | 说明 |\n|---|---|\n| 模块名称 | 台账数据 — 物流业务明细 |\n| 目标用户 | 业务服务组操作人、主管 |\n| 核心任务 | 承接调度自动出车明细、补录导入、自动汇总盈亏 |\n| 页面结构 | 筛选区 + KPI 统计 + 工具栏 + 宽表 + 分页 |\n| 上游 | 自营合同 → 轻量调度任务办结 |\n\n---"
},
{
"type": "link",
@@ -121,7 +121,13 @@
"type": "markdown",
"id": "sob-doc-prd",
"title": "PRD 全文",
"markdown": "# 物流业务明细 · 产品需求说明PRD\n\n> 参照《2026年业务二部运营台账总表》物流业务明细子表行内编辑与导入为原型 Toast 演示。 \n> **界面设计规范**与租赁业务明细、租赁合同列表统一,见 `src/prototypes/ledger-shared/DESIGN.md``vm-page ldb-page`)。\n\n---\n\n## 1. 模块定位\n\n| 项 | 说明 |\n|---|---|\n| 模块名称 | 台账数据 — 物流业务明细 |\n| 目标用户 | 业务服务组操作人、主管 |\n| 核心任务 | 记录出车营收与成本、自动汇总盈亏、导入导出与删改 |\n| 页面结构 | 筛选区 + KPI 统计 + 工具栏 + 宽表 + 分页 |\n\n---\n\n## 2. 筛选区\n\n默认展示 4 项,点击「更多筛选」展开。\n\n| 筛选项 | 说明 |\n|---|---|\n| 月份 | YYYY-MM |\n| 出车日期 | 日期区间 |\n| 业务名称 | 精确匹配 |\n| 车牌号码 | 多选 |\n| 车辆承接方 | 我司 / 第三方车队 |\n| 品牌 / 型号 | 级联 |\n| 维护人 | 仅主管可见 |\n\n---\n\n## 3. KPI 统计卡片6 张)\n\n明细条数、金额合计、总成本合计、氢费总计、ETC费用总计、盈亏合计。\n\n卡片样式对齐租赁业务明细左侧色条、图标、悬浮说明、盈亏正负着色。\n\n---\n\n## 4. 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 批量导入 | 下载模板23 列手工录入)→ 上传 Excel → 系统车型/金额/总成本/盈亏自动计算 |\n| 导出 | 导出当前筛选结果(原型演示) |\n| 添加行 | 表格底栏新增行(原型演示) |\n\n不支持批量删除与行多选。\n\n### 导入模板列23 列)\n\n年份、月份、出车日期、业务名称、司机、电话、车牌号码、单价、数量、氢费、ETC费用、薪资、电费、人工报销费用、日社保服务费、日挂车服务费、日挂车费用、日停车费用、日轮胎费用、车辆费用、是否多趟、线路计价、备注。\n\n**必填**:年份、月份、出车日期、业务名称、司机、电话、车牌号码、单价、数量、薪资、是否多趟、线路计价。\n\n**不含**(导入后自动计算/反写):系统车型、金额、总成本、盈亏。\n\n---\n\n## 5. 列表字段\n\n月份、出车日期、业务名称、司机、电话、车牌号码、系统车型、单价、数量、金额、氢费、ETC费用、薪资、电费、人工报销费用、日社保服务费、日挂车费用、日停车费用、日轮胎费用、车辆费用、总成本、盈亏、是否多趟、线路计价、备注、操作。\n\n**月份展示**:列表合并显示为 `YYYY-MM`;导入模板仍为「年份」「月份」两列,导入后自动合并展示。\n\n**系统车型**:按车牌从车辆主数据反写,展示为「品牌·型号」。\n\n**金额** = 单价 × 数量。\n\n**总成本** = 氢费 + 人工报销费用 + ETC费用 + 电费 + 薪资 + 车辆费用。\n\n**盈亏** = 金额 总成本;参照 A 股配色:**正数红色、负数绿色**。"
"markdown": "# 物流业务明细 · 产品需求说明PRD\n\n> 参照《2026年业务二部运营台账总表》物流业务明细子表行内编辑与导入为原型 Toast 演示。 \n> **界面设计规范**与租赁业务明细、租赁合同列表统一,见 `src/prototypes/ledger-shared/DESIGN.md``vm-page ldb-page`)。 \n> **主路径升级**:调度任务办结自动生成明细行;导入降为补录。规则全文见 [ledger-auto-gen.md](./ledger-auto-gen.md)。 \n> 假设:[assumptions.md](../../../resources/self-operated-logistics/assumptions.md)。\n\n---\n\n## 1. 模块定位\n\n| 项 | 说明 |\n|---|---|\n| 模块名称 | 台账数据 — 物流业务明细 |\n| 目标用户 | 业务服务组操作人、主管 |\n| 核心任务 | 承接调度自动出车明细、补录导入、自动汇总盈亏 |\n| 页面结构 | 筛选区 + KPI 统计 + 工具栏 + 宽表 + 分页 |\n| 上游 | 自营合同 → 轻量调度任务办结 |\n\n---\n\n## 2. 筛选区\n\n默认展示 4 项,点击「更多筛选」展开。\n\n| 筛选项 | 说明 |\n|---|---|\n| 月份 | YYYY-MM |\n| 出车日期 | 日期区间 |\n| 业务名称 | 精确匹配 |\n| 车牌号码 | 多选 |\n| 车辆承接方 | 我司 / 第三方车队 |\n| 品牌 / 型号 | 级联 |\n| 维护人 | 仅主管可见 |\n\n---\n\n## 3. KPI 统计卡片6 张)\n\n明细条数、金额合计、总成本合计、氢费总计、ETC费用总计、盈亏合计。\n\n卡片样式对齐租赁业务明细左侧色条、图标、悬浮说明、盈亏正负着色。\n\n---\n\n## 4. 列表工具栏\n\n| 按钮 | 行为 |\n|---|---|\n| 批量导入 | 下载模板23 列手工录入)→ 上传 Excel → 系统车型/金额/总成本/盈亏自动计算 |\n| 导出 | 导出当前筛选结果(原型演示) |\n| 添加行 | 表格底栏新增行(原型演示) |\n\n不支持批量删除与行多选。\n\n### 导入模板列23 列)\n\n年份、月份、出车日期、业务名称、司机、电话、车牌号码、单价、数量、氢费、ETC费用、薪资、电费、人工报销费用、日社保服务费、日挂车服务费、日挂车费用、日停车费用、日轮胎费用、车辆费用、是否多趟、线路计价、备注。\n\n**必填**:年份、月份、出车日期、业务名称、司机、电话、车牌号码、单价、数量、薪资、是否多趟、线路计价。\n\n**不含**(导入后自动计算/反写):系统车型、金额、总成本、盈亏。\n\n---\n\n## 5. 列表字段\n\n月份、出车日期、业务名称、司机、电话、车牌号码、系统车型、单价、数量、金额、氢费、ETC费用、薪资、电费、人工报销费用、日社保服务费、日挂车费用、日停车费用、日轮胎费用、车辆费用、总成本、盈亏、是否多趟、线路计价、备注、操作。\n\n**月份展示**:列表合并显示为 `YYYY-MM`;导入模板仍为「年份」「月份」两列,导入后自动合并展示。\n\n**系统车型**:按车牌从车辆主数据反写,展示为「品牌·型号」。\n\n**金额** = 单价 × 数量。\n\n**总成本** = 氢费 + 人工报销费用 + ETC费用 + 电费 + 薪资 + 车辆费用。\n\n**盈亏** = 金额 总成本;参照 A 股配色:**正数红色、负数绿色**。\n\n---\n\n## 6. 调度办结自动生成(摘要)\n\n| 项 | 说明 |\n|---|---|\n| 业务目的 | 办结出车任务后自动落明细,减少手工导入 |\n| 触发 | `self-operated-dispatch-task` 办结成功(须车辆已交车) |\n| 粒度 | 一任务一行;备注前缀 `[调度自动·任务号]` |\n| 成本一期 | 营收侧自动;氢费/ETC 等默认 0可补录 |\n| 数据源 | Bridge `ledger-auto` + 种子/导入行合并展示 |\n| 规格全文 | [ledger-auto-gen.md](./ledger-auto-gen.md) |\n\n### 验收重点(自动生成)\n\n- [ ] 调度办结后本页刷新可见新行(同浏览器 localStorage\n- [ ] 自动行备注含任务号;金额/盈亏公式正确\n- [ ] 导入仍可用,不覆盖不同 id 的自动行"
},
{
"type": "markdown",
"id": "sob-doc-auto-gen",
"title": "调度自动生成",
"markdown": "# 物流业务明细 · 调度办结自动生成\n\n> 实现:`src/common/selfOperatedLogisticsBridge.js` → `completeDispatchTask` / `mergeLedgerWithAutoRows` \n> UI`self-operated-business-ledger/index.tsx` 合并自动行;触发页在 `self-operated-dispatch-task`\n\n## 业务目的\n\n调度任务办结后自动生成 1 行出车明细,减少人工导入;导入/行编辑保留为补录与纠错。\n\n## 触发条件\n\n| 顺序 | 条件 | 说明 |\n|---|---|---|\n| 1 | 调度任务办结成功 | 见 `dispatch-rules.md` 办结判定 |\n| 2 | 车辆已交车 | 运营态 = self_operated |\n| 3 | 幂等 | 同一 `dispatchTaskId` 覆盖旧自动行 |\n\n## 数据源\n\n| 项 | 说明 |\n|---|---|\n| 自动行存储 | localStorage `oneos.self-operated.ledger-auto.v1` |\n| 列表合并 | `mergeLedgerWithAutoRows(seedOrLocalRows)`:自动行置顶,同 id 去重 |\n| 种子 JSON | `data/rows.json` 仍作历史/演示导入数据 |\n\n原型本地种子 vs **未接真实 API**。\n\n## 用户可见结果\n\n| 结果 | 表现 |\n|---|---|\n| 生成成功 | 调度页 Toast台账备注前缀 `[调度自动·DD-xxxx]` |\n| 未交车 | 调度页拦截,台账无新行 |\n| 成本一期 | 营收侧(单价/数量/金额/薪资)带出;氢费/ETC 等为 0可手工补 |\n\n## 与导入关系\n\n| 路径 | 优先级 |\n|---|---|\n| 调度自动生成 | 主路径 |\n| Excel 批量导入 | 补录/历史 |\n| 添加行 | 补录演示 |\n\n取消已办结任务**禁止**(避免台账孤儿行)。若未来支持作废,须同步删除或标记自动行(本期不做)。\n\n## 公式(与现网一致)\n\n- 金额 = 单价 × 数量 \n- 总成本 = 氢费 + 人工报销 + ETC + 电费 + 薪资 + 车辆费用 \n- 盈亏 = 金额 总成本"
},
{
"type": "markdown",
@@ -145,7 +151,7 @@
"type": "markdown",
"id": "sob-doc-sob-table",
"title": "物流业务明细宽表",
"markdown": "## 5. 列表字段\n\n月份、出车日期、业务名称、司机、电话、车牌号码、系统车型、单价、数量、金额、氢费、ETC费用、薪资、电费、人工报销费用、日社保服务费、日挂车费用、日停车费用、日轮胎费用、车辆费用、总成本、盈亏、是否多趟、线路计价、备注、操作。\n\n**月份展示**:列表合并显示为 `YYYY-MM`;导入模板仍为「年份」「月份」两列,导入后自动合并展示。\n\n**系统车型**:按车牌从车辆主数据反写,展示为「品牌·型号」。\n\n**金额** = 单价 × 数量。\n\n**总成本** = 氢费 + 人工报销费用 + ETC费用 + 电费 + 薪资 + 车辆费用。\n\n**盈亏** = 金额 总成本;参照 A 股配色:**正数红色、负数绿色**。"
"markdown": "## 5. 列表字段\n\n月份、出车日期、业务名称、司机、电话、车牌号码、系统车型、单价、数量、金额、氢费、ETC费用、薪资、电费、人工报销费用、日社保服务费、日挂车费用、日停车费用、日轮胎费用、车辆费用、总成本、盈亏、是否多趟、线路计价、备注、操作。\n\n**月份展示**:列表合并显示为 `YYYY-MM`;导入模板仍为「年份」「月份」两列,导入后自动合并展示。\n\n**系统车型**:按车牌从车辆主数据反写,展示为「品牌·型号」。\n\n**金额** = 单价 × 数量。\n\n**总成本** = 氢费 + 人工报销费用 + ETC费用 + 电费 + 薪资 + 车辆费用。\n\n**盈亏** = 金额 总成本;参照 A 股配色:**正数红色、负数绿色**。\n\n---"
}
]
}

View File

@@ -5,14 +5,18 @@
import '../vehicle-management/style.css';
import '../lease-business-ledger/styles/index.css';
import './styles/index.css';
import React, { useCallback, useMemo, useState } from 'react';
import { Download, FileUp, Plus } from 'lucide-react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
type AnnotationDirectoryRouteNode,
type AnnotationSourceDocument,
type AnnotationViewerOptions
} from '@axhub/annotation';
import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host';
import {
mergeLedgerWithAutoRows,
removeAutoLedgerRow,
subscribeSelfOperatedBridge,
} from '../../common/selfOperatedLogisticsBridge.js';
import seedRows from './data/rows.json';
import { FilterPanel } from './components/FilterPanel';
import { LedgerKpiRow } from './components/LedgerKpiRow';
@@ -36,8 +40,21 @@ import annotationSourceDocument from './annotation-source.json';
const PAGE_SIZE = 10;
function loadMergedRecords(): SelfOperatedLedgerRow[] {
return mergeLedgerWithAutoRows(seedRows as SelfOperatedLedgerRow[]) as SelfOperatedLedgerRow[];
}
export default function SelfOperatedBusinessLedgerApp() {
const [records, setRecords] = useState<SelfOperatedLedgerRow[]>(seedRows as SelfOperatedLedgerRow[]);
const [baseRecords, setBaseRecords] = useState<SelfOperatedLedgerRow[]>(
() => seedRows as SelfOperatedLedgerRow[],
);
const [records, setRecords] = useState<SelfOperatedLedgerRow[]>(() => loadMergedRecords());
useEffect(() => {
const sync = () => setRecords(mergeLedgerWithAutoRows(baseRecords) as SelfOperatedLedgerRow[]);
sync();
return subscribeSelfOperatedBridge(sync);
}, [baseRecords]);
const [pendingFilters, setPendingFilters] = useState<SelfOperatedFilters>(EMPTY_SELF_FILTERS);
const [appliedFilters, setAppliedFilters] = useState<SelfOperatedFilters>(EMPTY_SELF_FILTERS);
const [page, setPage] = useState(1);
@@ -89,7 +106,7 @@ export default function SelfOperatedBusinessLedgerApp() {
}
if (result.success.length > 0) {
setRecords((prev) => [...result.success, ...prev]);
setBaseRecords((prev) => [...result.success, ...prev]);
setPage(1);
}
@@ -132,6 +149,22 @@ export default function SelfOperatedBusinessLedgerApp() {
<LedgerKpiRow summary={kpi} />
<p
data-annotation-id="sob-autogen-hint"
style={{
margin: '0 0 10px',
padding: '10px 12px',
borderRadius: 8,
background: '#f0fdf4',
border: '1px solid #bbf7d0',
color: '#166534',
fontSize: 13,
lineHeight: 1.5,
}}
>
DD-2026-0001
</p>
<section className="vm-table-section">
<div className="vm-table-toolbar ldb-table-toolbar" data-annotation-id="sob-toolbar">
<div className="vm-table-actions">
@@ -192,7 +225,11 @@ export default function SelfOperatedBusinessLedgerApp() {
onCancel={() => setDeleteTarget(null)}
onConfirm={() => {
if (!deleteTarget) return;
setRecords((prev) => prev.filter((row) => row.id !== deleteTarget.id));
if (deleteTarget.rowSource === 'auto' || String(deleteTarget.id).startsWith('auto-')) {
removeAutoLedgerRow(deleteTarget.id);
} else {
setBaseRecords((prev) => prev.filter((row) => row.id !== deleteTarget.id));
}
setDeleteTarget(null);
showToast(`已删除 ${deleteTarget.plateNo}(原型)`);
}}

View File

@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const protoRoot = path.resolve(__dirname, '..');
const prd = fs.readFileSync(path.join(protoRoot, '.spec/requirements-prd.md'), 'utf8');
const autoGen = fs.readFileSync(path.join(protoRoot, '.spec/ledger-auto-gen.md'), 'utf8');
const NODE_DEFS = [
{
@@ -112,6 +113,7 @@ const source = {
target: 'self',
},
{ type: 'markdown', id: 'sob-doc-prd', title: 'PRD 全文', markdown: prd.trim() },
{ type: 'markdown', id: 'sob-doc-auto-gen', title: '调度自动生成', markdown: autoGen.trim() },
...nodes.map((node) => ({
type: 'markdown',
id: `sob-doc-${node.id}`,

View File

@@ -29,6 +29,11 @@ export interface SelfOperatedLedgerRow {
multiTrip: string;
routePricing: string;
remark: string;
/** seed | import | auto调度办结 */
rowSource?: 'seed' | 'import' | 'auto';
dispatchTaskId?: string;
dispatchTaskCode?: string;
contractCode?: string;
}
export interface SelfOperatedFilters {

View File

@@ -6,7 +6,7 @@
| 产品模块 | 台账数据 → 车辆氢费明细 |
| 文档类型 | 产品需求说明 |
| 适用读者 | 产品、业务、运营、测试、项目 |
| 修订说明 | v2.5记录来源标签站端上传/羚牛上传);核对/对账人;变更日志挂行级字段 |
| 修订说明 | v2.6车牌「羚牛车辆/非羚牛车辆」;非羚牛车辆核对/对账字段空展示 |
| 双状态规格 | [verify-reconcile.md](./verify-reconcile.md) |
---
@@ -162,7 +162,9 @@ flowchart LR
- **说明:** 合计条含「待保存 / 未对账 / 已对账」等当前列表中的全部可见记录,与导出范围(仅未对账+已对账)不同。
**列表列顺序(左→右,业务视角):**
序号 → 加氢日期 → 加氢时间 → 加氢站名称 → 客户名称 → 车牌号(下来源标签:站端上传 / 羚牛上传)→ 加氢量 → 成本单价 → 成本总价 → 加氢单价 → 加氢总价 → 行驶里程 → 备注 → 业务员 → 承担方式 → 对账日期 → **对账人****收票日期****加氢站付款状态****开票日期****客户收款状态****开票公司** → 核对状态 → **核对时间****核对人** → 对账状态 → 订单编号 → 操作(操作列固定右侧)。
序号 → 加氢日期 → 加氢时间 → 加氢站名称 → 客户名称 → 车牌号(附**羚牛车辆/非羚牛车辆** + 来源:站端上传/羚牛上传)→ 加氢量 → 成本单价 → 成本总价 → 加氢单价 → 加氢总价 → 行驶里程 → 备注 → 业务员 → 承担方式 → 对账日期 → **对账人****收票日期****加氢站付款状态****开票日期****客户收款状态****开票公司** → 核对状态 → **核对时间****核对人** → 对账状态 → 订单编号 → 操作(操作列固定右侧)。
> 非羚牛车辆:核对状态/时间/人、对账状态/日期/人显示为空。判定见 [../oneos-web-h2-station/.spec/fleet-vehicle-verify.md](../oneos-web-h2-station/.spec/fleet-vehicle-verify.md)。
**系统带出字段(不可手工维护):** 收票日期、加氢站付款状态、开票日期、客户收款状态、开票公司;选择/变更客户等信息后由系统自动刷新,单元格为浅灰底只读样式。

View File

@@ -27,7 +27,7 @@
|---|---|---|
| 1 | 加氢站H5/Web新增加氢记录 | 写入共享 Store`verifyStatus=unverified``reconcileStatus=pending`;按站点成本价补 `costUnitPrice``costTotal≈单价×量`;同步出现在车辆氢费明细 |
| 2 | 能源部在车辆氢费明细确认无误 → **完成核对** | `verifyStatus=verified`,写 `verifiedAt`;站端列表同步「已核对」;**业务员侧不可再改删**(原型规则) |
| 3 | 站点信息生成并对账单 **提交** | 仅纳入:**已核对** ∧ **未对账** ∧ 无 `statementRecordId` ∧ 本站 ∧ 账期;提交后 `reconcileStatus=reconciled`,写 `reconcileDate``statementRecordId` 等 |
| 3 | 站点信息生成并对账单 **提交**(须填收票信息 + **对账单盖章附件**,二次确认) | 仅纳入:**已核对** ∧ **未对账** ∧ 无 `statementRecordId` ∧ 本站 ∧ 账期;提交后三端(台账 / 加氢记录 Web / H5`reconcileStatus=reconciled`,写 `reconcileDate``statementRecordId` 等 |
---

View File

@@ -882,7 +882,9 @@ const Component = function () {
'沪AD12345F',
'苏EF99887F',
'京CN88771F',
'川AL55602F'
'川AL55602F',
/* 非羚牛车辆演示(不在系统车辆列表 / Bridge 车队集合) */
'粤Z99999F'
];
/** 原型:按车牌+加氢日期匹配车辆运营态(交还车/库存/异动),联调后接车辆管理 */
@@ -2985,6 +2987,28 @@ const Component = function () {
return '羚牛上传';
}
function isLingniuVehiclePlate(plateNo) {
if (typeof window !== 'undefined' && window.H2VehicleLedgerBridge && typeof window.H2VehicleLedgerBridge.isLingniuVehicle === 'function') {
return Boolean(window.H2VehicleLedgerBridge.isLingniuVehicle(plateNo));
}
var key = String(plateNo || '').trim().toUpperCase().replace(/F$/u, '');
if (!key) return false;
/* 明确非羚牛演示车牌(未进 Bridge 车队集合) */
if (key === '粤Z99999' || key === '浙C77801') return false;
return VEHICLE_INFO_TABLE.some(function (p) {
return String(p).toUpperCase().replace(/F$/u, '') === key;
});
}
function vehicleKindLabel(plateNo) {
if (!String(plateNo || '').trim()) return '';
return isLingniuVehiclePlate(plateNo) ? '羚牛车辆' : '非羚牛车辆';
}
function showVerifyReconcileFields(row) {
return isLingniuVehiclePlate(row && row.plateNo);
}
function recalcRowFields(row) {
var mileage = normalizeMileageKm(row.mileageKm);
var kg = isEmptyNum(row.hydrogenKg) ? null : roundMoney(row.hydrogenKg);
@@ -3211,31 +3235,40 @@ const Component = function () {
costTotal: 3168,
remark: '成本单价与站点标准价不一致演示'
});
pushDemoRow('mock-h2-external-plate', {
plateNo: '粤Z99999F',
hydrogenTime: demoDay && demoDay.subtract ? demoDay.subtract(3, 'day') : demoDay,
customerId: '00007',
customerName: '上海迅杰物流有限公司',
salesperson: '金可鹏',
recordSource: 'station',
remark: '非羚牛车辆演示:核对/对账字段应显示为空'
});
return recalcAllRows(rows);
}
var layoutStyle = {
padding: '16px 24px 24px',
padding: '16px 24px 48px',
minHeight: '100vh',
background: 'linear-gradient(165deg, #eef4ff 0%, #f5f7fa 42%, #f0f2f5 100%)'
background: 'var(--ln-canvas-parchment, #f5f6f6)'
};
var filterLabelStyle = { marginBottom: 6, fontSize: 13, color: 'rgba(0,0,0,0.55)', fontWeight: 500 };
var filterLabelStyle = { marginBottom: 6, fontSize: 13, color: 'var(--ln-muted, #71717a)', fontWeight: 500 };
var filterItemStyle = { marginBottom: 12 };
var filterControlStyle = { width: '100%' };
var filterCardStyle = {
marginBottom: 20,
borderRadius: 16,
boxShadow: '0 4px 20px -4px rgba(16,24,40,0.03), 0 0 0 1px rgba(16,24,40,0.06)',
border: 'none',
background: '#ffffff'
marginBottom: 16,
borderRadius: 'var(--ln-radius-card, 12px)',
boxShadow: '0 1px 2px rgba(15,23,42,0.04), 0 4px 12px rgba(15,23,42,0.03)',
border: '1px solid var(--ln-hairline, #e5e7eb)',
background: 'var(--ln-surface-card, #ffffff)'
};
var tableCardStyle = {
borderRadius: 16,
boxShadow: '0 10px 32px -4px rgba(16,24,40,0.06), 0 0 0 1px rgba(16,24,40,0.04)',
border: 'none',
background: '#ffffff',
borderRadius: 'var(--ln-radius-card, 12px)',
boxShadow: '0 1px 2px rgba(15,23,42,0.04), 0 4px 12px rgba(15,23,42,0.03)',
border: '1px solid var(--ln-hairline, #e5e7eb)',
background: 'var(--ln-surface-card, #ffffff)',
overflow: 'hidden'
};
@@ -3261,22 +3294,24 @@ const Component = function () {
// 异常行水印用 background-image避免 td::after + position 破坏固定列行高同步
var ledgerTableStyle =
'.h2-ledger-table-wrap{border-radius:12px;overflow:hidden;box-shadow:0 4px 24px -6px rgba(15,23,42,0.05),0 0 0 1px rgba(22,119,255,0.1)}' +
'.h2-ledger-table .ant-table-thead>tr>th,.h2-ledger-table .ant-table-thead .ant-table-cell{white-space:nowrap;color:#0f172a!important;font-weight:600!important;font-size:12px!important;' +
'background:#e8f4fc!important;border-bottom:1px solid #bae6fd!important;border-inline-end:1px solid #dbeafe!important;padding:0 6px!important;height:36px!important;text-align:center!important}' +
'.h2-ledger-table .ant-table-tbody>tr:not(.ant-table-measure-row)>td{padding:4px 6px!important;vertical-align:middle!important;font-size:12px}' +
'.h2-seq-cell{line-height:1.35;white-space:nowrap}' +
'.h2-ledger-table .ant-table-tbody>tr.h2-row-data:hover>td{background:#f0f9ff!important}' +
'.h2-ledger-totals-bar{display:flex;align-items:stretch;gap:0;margin-bottom:10px;border:1px solid #bae6fd;border-radius:10px;overflow:hidden;background:#f8fafc;box-shadow:0 1px 0 rgba(15,23,42,0.04)}' +
'.h2-ledger-totals-bar__title{display:flex;align-items:center;justify-content:center;min-width:72px;padding:10px 14px;font-size:14px;font-weight:700;color:#0f172a;background:#e8f4fc;border-right:1px solid #bae6fd}' +
'.vh2-page{font-family:Inter,system-ui,-apple-system,sans-serif}' +
'.vh2-plate-cell .ant-select{min-width:120px}' +
'.h2-ledger-table-wrap{border-radius:var(--ln-radius-card,12px);overflow:hidden;box-shadow:none;border:1px solid var(--ln-hairline,#e5e7eb)}' +
'.h2-ledger-table .ant-table-thead>tr>th,.h2-ledger-table .ant-table-thead .ant-table-cell{white-space:nowrap;color:var(--ln-muted,#71717a)!important;font-weight:500!important;font-size:12px!important;' +
'background:var(--ln-canvas-soft,#f4f4f5)!important;border-bottom:1px solid var(--ln-hairline,#e5e7eb)!important;border-inline-end:none!important;padding:10px 8px!important;height:auto!important;text-align:center!important}' +
'.h2-ledger-table .ant-table-tbody>tr:not(.ant-table-measure-row)>td{padding:8px 8px!important;vertical-align:middle!important;font-size:13px;color:var(--ln-ink,#18181b)}' +
'.h2-seq-cell{line-height:1.35;white-space:nowrap;font-variant-numeric:tabular-nums}' +
'.h2-ledger-table .ant-table-tbody>tr.h2-row-data:hover>td{background:color-mix(in srgb,var(--ln-primary,#32a06e) 6%,#fff)!important}' +
'.h2-ledger-totals-bar{display:flex;align-items:stretch;gap:0;margin-bottom:12px;border:1px solid var(--ln-hairline,#e5e7eb);border-radius:var(--ln-radius-card,12px);overflow:hidden;background:var(--ln-surface-card,#fff);box-shadow:0 1px 2px rgba(15,23,42,0.04)}' +
'.h2-ledger-totals-bar__title{display:flex;align-items:center;justify-content:center;min-width:72px;padding:10px 14px;font-size:14px;font-weight:600;color:var(--ln-ink,#18181b);background:var(--ln-canvas-soft,#f4f4f5);border-right:1px solid var(--ln-hairline,#e5e7eb)}' +
'.h2-ledger-totals-bar__items{display:flex;flex:1;flex-wrap:wrap}' +
'.h2-ledger-totals-bar__item{flex:1;min-width:160px;padding:8px 20px;border-right:1px solid #e2e8f0;display:flex;flex-direction:column;justify-content:center;gap:4px}' +
'.h2-ledger-totals-bar__item{flex:1;min-width:160px;padding:8px 20px;border-right:1px solid var(--ln-hairline,#e5e7eb);display:flex;flex-direction:column;justify-content:center;gap:4px}' +
'.h2-ledger-totals-bar__item:last-child{border-right:none}' +
'.h2-ledger-totals-bar__label{font-size:12px;color:rgba(15,23,42,0.55);font-weight:500;line-height:1.2}' +
'.h2-ledger-totals-bar__value{font-size:16px;font-weight:700;color:#0f172a;font-variant-numeric:tabular-nums;line-height:1.3}' +
'.h2-cell-readonly{color:#64748b;background:#f8fafc;padding:4px 6px}' +
'.h2-cell-auto{color:#475569;background:#f1f5f9;padding:4px 6px}' +
'.h2-cell-warn{color:#874d00;background:#fffbe6!important;border:1px solid #ffe58f}' +
'.h2-ledger-totals-bar__label{font-size:12px;color:var(--ln-muted,#71717a);font-weight:500;line-height:1.2}' +
'.h2-ledger-totals-bar__value{font-size:16px;font-weight:700;color:var(--ln-ink,#18181b);font-variant-numeric:tabular-nums;line-height:1.3}' +
'.h2-cell-readonly{color:var(--ln-muted,#71717a);background:var(--ln-canvas-soft,#f4f4f5);padding:4px 6px;border-radius:6px}' +
'.h2-cell-auto{color:var(--ln-body,#52525b);background:var(--ln-surface-strong,#f4f4f5);padding:4px 6px;border-radius:6px}' +
'.h2-cell-warn{color:#874d00;background:#fffbe6!important;border:1px solid #ffe58f;border-radius:6px}' +
'.h2-customer-warn-cell,.h2-cost-warn-cell{display:inline-flex;align-items:center;justify-content:center;gap:4px;max-width:100%}' +
'.h2-cost-warn-cell{justify-content:flex-end;gap:4px}' +
'.h2-cost-price-type-tag{flex-shrink:0}' +
@@ -3322,11 +3357,11 @@ const Component = function () {
'.h2-warn-cost-pop .ant-popover-inner-content{padding:10px 12px}' +
'.h2-warn-input-wrap .ant-input-number,.h2-warn-input-wrap .ant-input-number-input{background:#fffbe6!important}' +
'.h2-ledger-table .ant-picker.h2-datetime-picker{min-width:178px}' +
'.h2-row-tier-0>td{background:#f8fafc!important}' +
'.h2-row-tier-0>td{background:var(--ln-canvas-soft,#f8fafc)!important}' +
'.h2-row-tier-2>td{background:#fffbeb!important}' +
'.h2-row-tier-boundary>td{border-top:2px solid #7dd3fc!important}' +
'.h2-row-tier-boundary>td{border-top:2px solid color-mix(in srgb,var(--ln-primary,#32a06e) 35%,#fff)!important}' +
'.h2-action-icon-btn{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:4px;cursor:pointer;color:rgba(15,23,42,0.55);transition:background .15s,color .15s}' +
'.h2-action-icon-btn:hover{background:#f0f9ff;color:#1677ff}' +
'.h2-action-icon-btn:hover{background:color-mix(in srgb,var(--ln-primary,#32a06e) 10%,#fff);color:var(--ln-primary,#32a06e)}' +
'.h2-action-icon-btn.h2-action-icon-danger:hover{background:#fff1f0;color:#ff4d4f}' +
'.h2-action-icon-btn.is-disabled{color:rgba(15,23,42,0.25);cursor:not-allowed;pointer-events:none}' +
'.h2-row-more-btn.h2-action-icon-btn:hover{background:#f5f5f5;color:rgba(15,23,42,0.75)}' +
@@ -3864,9 +3899,11 @@ const Component = function () {
var keySet = {};
selectedRowKeys.forEach(function (k) { keySet[k] = true; });
var selected = allRows.filter(function (r) { return keySet[r.key]; });
var invalid = selected.filter(function (r) { return !isPendingRow(r) || isVerifiedRow(r); });
var invalid = selected.filter(function (r) {
return !isPendingRow(r) || isVerifiedRow(r) || !showVerifyReconcileFields(r);
});
if (invalid.length) {
message.warning('仅可对「已保存且未核对」的记录完成核对');
message.warning('仅可对「已保存且未核对」的羚牛车辆记录完成核对');
return;
}
var verifiedAt = nowDayjs();
@@ -4619,14 +4656,21 @@ const Component = function () {
title: reqTitle('车牌号'),
dataIndex: 'plateNo',
key: 'plateNo',
width: 132,
width: 220,
align: 'center',
render: function (v, record) {
var source = recordSourceLabel(record);
var tag = React.createElement(Tag, {
var kind = vehicleKindLabel(v);
var sourceTag = React.createElement(Tag, {
color: source === '站端上传' ? 'green' : 'blue',
style: { margin: 0, borderRadius: 4, fontSize: 11, lineHeight: '18px', fontWeight: 600 }
}, source);
var kindTag = kind
? React.createElement(Tag, {
color: isLingniuVehiclePlate(v) ? 'green' : 'orange',
style: { margin: 0, borderRadius: 4, fontSize: 11, lineHeight: '18px', fontWeight: 600 }
}, kind)
: null;
var plateNode = !canEditRow(record)
? readOnlyCell(v)
: React.createElement(Select, {
@@ -4641,9 +4685,24 @@ const Component = function () {
notFoundContent: '车辆不存在',
onChange: function (val) { patch(record.key, 'plateNo', val); }
});
return React.createElement('div', {
style: { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }
}, plateNode, tag);
return React.createElement(
'div',
{
className: 'vh2-plate-cell',
style: {
display: 'inline-flex',
flexDirection: 'row',
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
maxWidth: '100%'
}
},
plateNode,
kindTag,
sourceTag
);
}
},
{
@@ -4827,7 +4886,8 @@ const Component = function () {
onHeaderCell: function () {
return { 'data-annotation-id': 'vh2-col-reconcile-date' };
},
render: function (v) {
render: function (v, record) {
if (!showVerifyReconcileFields(record)) return readOnlyCell('—');
if (!v) return readOnlyCell('-');
var text = String(v);
if (text.indexOf(':') >= 0) return readOnlyCell(text.length > 16 ? text.slice(0, 16) : text);
@@ -4840,7 +4900,8 @@ const Component = function () {
key: 'reconciledByName',
width: 88,
align: 'center',
render: function (v) {
render: function (v, record) {
if (!showVerifyReconcileFields(record)) return readOnlyCell('—');
return readOnlyCell(v || '—');
}
},
@@ -4901,6 +4962,9 @@ const Component = function () {
width: 88,
align: 'center',
render: function (_, record) {
if (!showVerifyReconcileFields(record)) {
return React.createElement('span', { style: { color: '#cbd5e1' } }, '—');
}
var label = rowVerifyLabel(record);
if (!label) return React.createElement('span', { style: { color: '#cbd5e1' } }, '—');
var color = label === '已核对' ? 'green' : 'orange';
@@ -4913,7 +4977,8 @@ const Component = function () {
key: 'verifiedAt',
width: 148,
align: 'center',
render: function (v) {
render: function (v, record) {
if (!showVerifyReconcileFields(record)) return readOnlyCell('—');
if (!v) return readOnlyCell('—');
return readOnlyCell(formatDateTime(v) || '—');
}
@@ -4924,7 +4989,8 @@ const Component = function () {
key: 'verifiedByName',
width: 88,
align: 'center',
render: function (v) {
render: function (v, record) {
if (!showVerifyReconcileFields(record)) return readOnlyCell('—');
return readOnlyCell(v || '—');
}
},
@@ -4934,6 +5000,9 @@ const Component = function () {
width: 88,
align: 'center',
render: function (_, record) {
if (!showVerifyReconcileFields(record)) {
return React.createElement('span', { style: { color: '#cbd5e1' } }, '—');
}
var label = rowStatusLabel(record);
if (!label) return React.createElement('span', { style: { color: '#cbd5e1' } }, '—');
var color = label === '已对账' ? 'blue' : 'default';
@@ -5340,15 +5409,16 @@ const Component = function () {
React.createElement('style', null, ledgerTableStyle),
React.createElement(
'div',
{ style: layoutStyle },
{ className: 'vm-page ldb-page vh2-page', style: layoutStyle, 'data-annotation-id': 'vh2-page' },
React.createElement(
'div',
{
'data-annotation-id': 'vh2-header',
className: 'ldb-page-header',
style: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
justifyContent: 'flex-end',
marginBottom: 12,
gap: 16
}

View File

@@ -209,12 +209,12 @@
"path": []
},
"aiPrompt": "列表字段、行内编辑、客户校验与操作列。",
"annotationText": "# 列表字段与操作\n\n**核心可编辑字段**(未对账/待保存):加氢时间、站点、车牌、加氢量、成本单价、加氢单价、承担方式等。\n\n**来源标签**(车牌下方):站端主动上报→「站端上传」;本页人工新增→「羚牛上传」。\n\n**系统带出只读**:业务员、开票/付款相关字段;核对时间/核对人(完成核对写入);对账日期/对账人(对账单提交写入)。\n\n## 成本单价列\n\n- 单价前展示 **固定 / 阶梯** 标签(与加氢站价格配置一致)\n- 阶梯价悬浮:当前阶梯量、对应单价\n- 校验图标:正确绿色 ✓ / 异常红色 ⚠\n- **一键替换为标准成本单价**(未对账;创建人或主管),写入变更日志\n\n## 客户名称列\n\n- 与系统登记不一致时整行标红,单元格水印「客户疑似有误」\n- 客户名称后 **警示图标**:悬浮显示异常说明(系统客户、交车时间等)\n- **一键替换为系统客户**(仅「与系统登记客户不一致」场景,未对账数据可操作)\n- 替换写入变更日志\n\n**行操作**(更多菜单):编辑、删除、复制多行、变更日志(时间/操作人/修改项/原值/新值)。\n\n**列表勾选**:仅未对账记录可勾选,用于批量完成核对。\n\n表头支持批量改价成本单价、加氢单价、承担方式。",
"annotationText": "# 列表字段与操作\n\n**核心可编辑字段**(未对账/待保存):加氢时间、站点、车牌、加氢量、成本单价、加氢单价、承担方式等。\n\n**车辆类型**(车牌下方):系统车辆→「羚牛车辆」,否则→「非羚牛车辆」。\n\n**来源标签**:站端主动上报→「站端上传」;本页人工新增→「羚牛上传」。\n\n**非羚牛车辆**:核对状态/时间/人、对账状态/日期/人显示为空;不可完成核对。\n\n**系统带出只读**:业务员、开票/付款相关字段;核对时间/核对人(完成核对写入);对账日期/对账人(对账单提交写入)。\n\n## 成本单价列\n\n- 单价前展示 **固定 / 阶梯** 标签(与加氢站价格配置一致)\n- 阶梯价悬浮:当前阶梯量、对应单价\n- 校验图标:正确绿色 ✓ / 异常红色 ⚠\n- **一键替换为标准成本单价**(未对账;创建人或主管),写入变更日志\n\n## 客户名称列\n\n- 与系统登记不一致时整行标红,单元格水印「客户疑似有误」\n- 客户名称后 **警示图标**:悬浮显示异常说明(系统客户、交车时间等)\n- **一键替换为系统客户**(仅「与系统登记客户不一致」场景,未对账数据可操作)\n- 替换写入变更日志\n\n**行操作**(更多菜单):编辑、删除、复制多行、变更日志(时间/操作人/修改项/原值/新值)。\n\n**列表勾选**:仅未对账记录可勾选,用于批量完成核对。\n\n表头支持批量改价成本单价、加氢单价、承担方式。",
"hasMarkdown": true,
"color": "#334155",
"images": [],
"createdAt": 1782806400000,
"updatedAt": 1784520000000,
"updatedAt": 1784523600000,
"controls": []
},
{

View File

@@ -1,5 +1,6 @@
/**
* @name 车辆氢费明细
* 设计规范ledger-shared/DESIGN.md · vm-page + ldb-page与维修明细 / 租赁台账对齐)
*/
import React, { useMemo } from 'react';
@@ -8,6 +9,8 @@ import {
type AnnotationViewerOptions
} from '@axhub/annotation';
import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host';
import '../vehicle-management/style.css';
import './styles/index.css';
import './globals';
import 'antd/dist/reset.css';
import H2LedgerPage from './H2LedgerPage.jsx';

View File

@@ -59,14 +59,10 @@ function FleetModelCard({
);
}
function FleetOverviewModal({
open,
onClose,
function FleetShareBody({
segmentCounts,
onOpenDetail,
}: {
open: boolean;
onClose: () => void;
segmentCounts: Record<OnLeaseFleetSegment, number>;
onOpenDetail: (item: OnLeaseFleetBucket) => void;
}) {
@@ -87,6 +83,58 @@ function FleetOverviewModal({
? Math.max(...summary.map((item) => item.count))
: 1;
return (
<div className="vm-fleet-overview-modal__body">
<div className="vm-fleet-overview-modal__brands" role="tablist" aria-label="业务线筛选">
{ON_LEASE_FLEET_SEGMENTS.map((segment) => (
<button
key={segment}
type="button"
role="tab"
className={`vm-fleet-brand-chip${segmentFilter === segment ? ' is-active' : ''}`}
onClick={() => setSegmentFilter(segment)}
aria-selected={segmentFilter === segment}
>
{segment}
<span className="vm-fleet-brand-chip__count tabular-nums">{segmentCounts[segment]}</span>
</button>
))}
</div>
{summary.length ? (
<div className="vm-fleet-overview-modal__grid" role="list">
{summary.map((item) => (
<FleetModelCard
key={item.key}
item={item}
totalOnLease={totalOnLease}
maxCount={maxCount}
onOpenDetail={onOpenDetail}
/>
))}
</div>
) : (
<div className="vm-fleet-overview-modal__empty">
<p>线</p>
<button type="button" className="vm-btn vm-btn-ghost" data-vm-icon="eye" onClick={() => setSegmentFilter('全部')}>
</button>
</div>
)}
</div>
);
}
function FleetOverviewModal({
open,
onClose,
segmentCounts,
onOpenDetail,
}: {
open: boolean;
onClose: () => void;
segmentCounts: Record<OnLeaseFleetSegment, number>;
onOpenDetail: (item: OnLeaseFleetBucket) => void;
}) {
if (!open) return null;
return (
@@ -102,51 +150,13 @@ function FleetOverviewModal({
<header className="vm-modal-header">
<div>
<h2 id="vm-fleet-overview-title"></h2>
<p>
<span className="tabular-nums">{totalOnLease}</span>
</p>
<p>线</p>
</div>
<button type="button" className="vm-modal-close" onClick={onClose} aria-label="关闭">
<X size={18} aria-hidden />
</button>
</header>
<div className="vm-fleet-overview-modal__body">
<div className="vm-fleet-overview-modal__brands" role="tablist" aria-label="业务线筛选">
{ON_LEASE_FLEET_SEGMENTS.map((segment) => (
<button
key={segment}
type="button"
role="tab"
className={`vm-fleet-brand-chip${segmentFilter === segment ? ' is-active' : ''}`}
onClick={() => setSegmentFilter(segment)}
aria-selected={segmentFilter === segment}
>
{segment}
<span className="vm-fleet-brand-chip__count tabular-nums">{segmentCounts[segment]}</span>
</button>
))}
</div>
{summary.length ? (
<div className="vm-fleet-overview-modal__grid" role="list">
{summary.map((item) => (
<FleetModelCard
key={item.key}
item={item}
totalOnLease={totalOnLease}
maxCount={maxCount}
onOpenDetail={onOpenDetail}
/>
))}
</div>
) : (
<div className="vm-fleet-overview-modal__empty">
<p>线</p>
<button type="button" className="vm-btn vm-btn-ghost" data-vm-icon="eye" onClick={() => setSegmentFilter('全部')}>
</button>
</div>
)}
</div>
<FleetShareBody segmentCounts={segmentCounts} onOpenDetail={onOpenDetail} />
<footer className="vm-modal-footer">
<button type="button" className="vm-btn vm-btn-primary" onClick={onClose} data-vm-icon="x"></button>
</footer>
@@ -155,6 +165,114 @@ function FleetOverviewModal({
);
}
/** 工作台等场景:直接内联展示在租车型占比仪表(无需点击 KPI 卡打开弹层) */
export function OnLeaseFleetSharePanel({ emptyText }: { emptyText?: string }) {
const fleetSourceList = useMemo(() => buildOnLeaseFleetSourceList(), []);
const segmentCounts = useMemo(
() => countOnLeaseFleetBySegment(fleetSourceList),
[fleetSourceList],
);
const [detailItem, setDetailItem] = useState<OnLeaseFleetBucket | null>(null);
const [segmentFilter, setSegmentFilter] = useState<OnLeaseFleetSegment>('全部');
const summary = useMemo(
() => buildOnLeaseFleetSummary(fleetSourceList, segmentFilter),
[fleetSourceList, segmentFilter],
);
const totalOnLease = useMemo(
() => summary.reduce((sum, item) => sum + item.count, 0),
[summary],
);
if (segmentCounts. <= 0) {
return (
<p className="wb-insights-fleet-share-empty">
{emptyText || '暂无在租车辆,无法查看车型占比'}
</p>
);
}
return (
<>
<div className="vm-fleet-share-panel" data-annotation-id="vm-fleet-share-panel">
<div className="vm-fleet-share-panel__head">
<h4 className="vm-fleet-share-panel__title"></h4>
<p className="vm-fleet-share-panel__sub">
<span className="tabular-nums">{totalOnLease}</span>
</p>
</div>
<div className="vm-fleet-overview-modal__brands" role="tablist" aria-label="业务线筛选">
{ON_LEASE_FLEET_SEGMENTS.map((segment) => (
<button
key={segment}
type="button"
role="tab"
className={`vm-fleet-brand-chip${segmentFilter === segment ? ' is-active' : ''}`}
onClick={() => setSegmentFilter(segment)}
aria-selected={segmentFilter === segment}
>
{segment}
<span className="vm-fleet-brand-chip__count tabular-nums">{segmentCounts[segment]}</span>
</button>
))}
</div>
{summary.length ? (
<div className="vm-fleet-share-gauges" role="list" aria-label="在租车型占比仪表">
{summary.map((item) => {
const share = totalOnLease ? Math.round((item.count / totalOnLease) * 100) : 0;
const r = 34;
const c = 2 * Math.PI * r;
const dash = (Math.max(4, Math.min(100, share)) / 100) * c;
return (
<button
key={item.key}
type="button"
className="vm-fleet-share-gauge"
role="listitem"
onClick={() => setDetailItem(item)}
aria-label={`${item.brand} ${item.model}${item.count} 辆,占 ${share}%`}
>
<span className="vm-fleet-share-gauge__ring" aria-hidden>
<svg viewBox="0 0 96 96" className="vm-fleet-share-gauge__svg">
<circle className="vm-fleet-share-gauge__track" cx="48" cy="48" r={r} />
<circle
className="vm-fleet-share-gauge__arc"
cx="48"
cy="48"
r={r}
strokeDasharray={`${dash} ${c - dash}`}
transform="rotate(-90 48 48)"
/>
</svg>
<span className="vm-fleet-share-gauge__center">
<strong className="tabular-nums">{share}%</strong>
<span className="tabular-nums">{item.count} </span>
</span>
</span>
<span className="vm-fleet-share-gauge__brand">{item.brand}</span>
<span className="vm-fleet-share-gauge__model" title={item.model}>{item.model}</span>
</button>
);
})}
</div>
) : (
<div className="vm-fleet-overview-modal__empty">
<p>线</p>
<button type="button" className="vm-btn vm-btn-ghost" data-vm-icon="eye" onClick={() => setSegmentFilter('全部')}>
</button>
</div>
)}
</div>
<FleetDetailModal
open={!!detailItem}
item={detailItem}
onClose={() => setDetailItem(null)}
/>
</>
);
}
function FleetDetailModal({
open,
item,

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,11 @@
# 车辆氢费明细 — 产品需求说明PRD
> **与站端加氢订单 / Bridge 口径冲突时的权威顺序2026-07-18**
> 本文为历史业务版说明;其中「保存→未对账→完成对账」等命名与现行「**核对**(能源部)≠ **对账**(站点对账单)」二分不完全同词。
> **站端 H5 / Web 加氢记录 / 车辆氢费现行联动**以以下文档为准,勿用本文覆盖:
> - [`vehicle-h2-fee-ledger/.spec/verify-reconcile.md`](../../../prototypes/vehicle-h2-fee-ledger/.spec/verify-reconcile.md)
> - [`oneos-h5-h2-order/.spec/reconcile-linkage.md`](../../../prototypes/oneos-h5-h2-order/.spec/reconcile-linkage.md)
| 项目 | 内容 |
|------|------|
| 文档版本 | v2.1(业务版) |