From 86634d1050e0c6824c945c7b488d36065ad0df9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=86=95?= Date: Tue, 23 Jun 2026 10:43:30 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=8A=A0=E6=B0=A2=E7=AB=99?= =?UTF-8?q?=E7=AB=99=E7=82=B9=E4=BF=A1=E6=81=AF=EF=BC=9A=E4=BB=B7=E6=A0=BC?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E3=80=81=E4=BD=99=E9=A2=9D=E6=8F=90=E9=86=92?= =?UTF-8?q?=E3=80=81=E5=9C=B0=E5=9B=BE=E5=AE=9A=E4=BD=8D=E4=B8=8E=20PRD?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增多规则价格配置、全站余额提醒、地图选点及 v2.0 产品需求说明文档。 Co-authored-by: Cursor --- web端/加氢站管理/站点信息-产品需求说明.md | 487 ++++++ web端/加氢站管理/站点信息.jsx | 1664 +++++++++++++++++++-- 2 files changed, 2043 insertions(+), 108 deletions(-) create mode 100644 web端/加氢站管理/站点信息-产品需求说明.md diff --git a/web端/加氢站管理/站点信息-产品需求说明.md b/web端/加氢站管理/站点信息-产品需求说明.md new file mode 100644 index 0000000..a8045af --- /dev/null +++ b/web端/加氢站管理/站点信息-产品需求说明.md @@ -0,0 +1,487 @@ +# 加氢站管理 · 站点信息 — 产品需求说明(PRD) + +| 项目 | 内容 | +|------|------| +| 文档版本 | v2.0 | +| 产品模块 | 加氢站管理 → 站点信息 | +| 文档类型 | 产品需求说明 | +| 适用读者 | 研发、测试、产品、项目 | +| 关联模块 | 台账数据 → 车辆氢费明细 | +| 原型文件 | `web端/加氢站管理/站点信息.jsx` | + +--- + +## 一、为什么做这件事 + +### 1.1 业务背景 + +加氢站是氢费成本结算的关键节点。运营侧需要维护站点主数据(签约、地址坐标、营业、成本价、预付余额),并在财务周期内按站点与加氢站完成对账、收票与付款闭环。 + +### 1.2 本期目标 + +建设 Web 端「站点信息」页面,支撑: + +- 站点台账的查询、新建、编辑、查看、删除与批量导入 +- 地图定位、营业状态、多规则成本价格、余额提醒等运营配置 +- 加氢量 / 预付余额下钻分析与充值单发起 +- **按站点生成氢费对账单 → 填写收票信息 → 提交结算**,并与「车辆氢费明细」联动回写 + +### 1.3 用户角色 + +| 角色 | 典型诉求 | +|------|----------| +| **加氢站运营** | 维护站点资料、营业与价格;查看加氢量与余额;设置余额提醒 | +| **财务/结算** | 按周期生成对账单、登记收票、发起充值、完成站点付款闭环 | +| **管理员** | 批量导入站点、全站余额提醒、删除无效站点、绑定系统账号 | + +--- + +## 二、页面整体结构 + +用户进入「加氢站管理 → 站点信息」后,页面自上而下为: + +1. **筛选区** — 名称、是否签约、地区、营业状态 +2. **KPI 分类** — 全部 / 预付余额预警 / 已欠费 / 无加氢;签约站点快捷筛选 +3. **工具栏** — 全站余额提醒设置、批量导入、发起充值单、新建站点 +4. **站点列表** — 主数据 + 运营指标 + 操作列 +5. **子页面 / 弹窗** — 新建/编辑整页、查看、营业状态、价格配置、余额提醒、对账单、对账记录、下钻分析等 + +```mermaid +flowchart TB + subgraph 列表页 + A[筛选 / KPI] --> B[站点列表] + B --> C[查看站点] + B --> D[编辑 / 删除] + B --> E[更多菜单] + B --> F[加氢量下钻] + B --> G[预付余额下钻] + end + E --> H[营业状态] + E --> I[价格配置] + E --> J[余额提醒设置] + E --> K[生成对账单] + E --> L[查看对账记录] + B --> M[工具栏-发起充值单] + B --> N[工具栏-全站余额提醒] +``` + +页面右上角提供 **「查看需求说明」**、**「查看使用说明书」** 入口。 + +--- + +## 三、列表与 KPI 规则 + +### 3.1 列表字段 + +| 字段 | 说明 | +|------|------| +| 加氢站名称 | 含签约标签(签约站点 / 签约站点-未上传合同);下一行展示完整地址 | +| 合作时间 | 未签约显示为空 | +| 营业状态 / 营业时间 | 列表只读;在「更多 → 营业状态」维护 | +| 当前成本价格 | 元/kg;由价格配置规则与生效时间驱动;多客户规则时展示主规则价格 | +| 加氢次数 / 加氢量 | 加氢量可点击下钻;列表头支持排序 | +| 预付余额 | 可点击下钻流水;负值标红并显示「已欠费」 | +| 联系方式 | 联系人 + 电话 | +| 操作 | 查看 + 更多(⋮) | + +### 3.2 KPI 分类逻辑 + +| 分类 | 规则 | +|------|------| +| 全部加氢站 | 台账内全部站点 | +| 预付余额预警站点 | 已设置提醒阈值,且预付余额 ≥ 0 且低于阈值(不含已欠费) | +| 已欠费站点 | 预付余额 < 0 | +| 无加氢站点 | 加氢次数 = 0 | + +**签约快捷筛选**(与 KPI 并列展示): + +| 分类 | 规则 | +|------|------| +| 签约站点 | `isSigned = true` | +| 普通站点 | `isSigned = false` | + +点击「预付余额预警站点」「已欠费站点」KPI 卡,打开对应站点列表弹窗,支持 **一键发起充值单**(预填站点与当前余额)。 + +筛选或 KPI 切换后列表回到第 1 页;默认分页 10 条,可选 5 / 10 / 20 / 50。 + +--- + +## 四、站点主数据(新建 / 编辑 / 查看) + +### 4.1 新建站点 + +**入口**:列表工具栏 → **新建站点** + +整页表单分三块卡片: + +#### (1)加氢站基本信息 + +| 字段 | 必填 | 说明 | +|------|------|------| +| 加氢站名称 | 是 | 不可与已有站点重名 | +| 省市、通讯地址 | 是 | 只读,由地址解析回填 | +| 地址解析 | 否 | 粘贴完整地址,失焦后自动解析省/市与详细地址 | +| 地图定位 | 否 | 见 [4.4 地图定位模块](#44-地图定位模块) | +| 联系人 | 是 | — | +| 手机号 / 固定电话 | 至少一项 | 格式校验 | +| 站点类型 | 否 | 签约站点 / 普通站点;签约须填合作时间与合同附件 | + +#### (2)系统账号绑定 + +- 选择 **绑定系统账号** 或 **暂不绑定** +- 绑定支持 **多选**;已被其他站点占用的账号不可选 +- 绑定后所选账号可登录并管理该站点 + +#### (3)供应商相关信息 + +Tab:**关联已有** / **新建供应商** / **不关联供应商** + +- 关联已有:搜索供应商档案绑定,可 OCR 营业执照 +- 新建供应商:主体、开票、银行、证照上传 +- 不关联:跳过供应商区块 + +**底部**:返回(未保存二次确认)、提交创建(进度达 100% 后可点) + +### 4.2 编辑 / 查看 + +| 模式 | 交互 | +|------|------| +| **编辑** | 整页(与新建同结构);**不含**供应商;营业状态不在此页;系统账号仅 admin 可改 | +| **查看** | 弹窗分块:加氢站信息、系统账号、供应商信息、付款信息 | + +编辑页同样包含 **地图定位模块**,回显已保存经纬度。 + +### 4.3 批量导入 + +**入口**:列表工具栏 → **批量导入**(位于「全站余额提醒设置」右侧) + +- 下载 CSV 模板 → 上传 `.csv` / `.xlsx` / `.xls` +- 预览可导入 / 不可导入条数及错误原因 +- 站点名称不可与现有台账重复 + +模板字段与新建表单一致(名称、省、市、详细地址、是否签约、签约起止、营业状态、营业时间、联系人、联系电话等)。 + +### 4.4 地图定位模块 + +**位置**:新建 / 编辑页 → 加氢站基本信息卡片 → 地址解析下方 + +**数据字段**: + +| 字段 | 说明 | +|------|------| +| `mapAddress` | 地图模块地址(可与通讯地址一致) | +| `latitude` | 纬度,保留 6 位小数 | +| `longitude` | 经度,保留 6 位小数 | + +**交互规则**: + +1. **地址解析联动**:上方「地址解析」识别成功后,自动将完整地址填入地图地址框并触发定位 +2. **输入定位**:地图地址框支持手动输入;失焦、回车或点击「定位」后,地图自动移动到对应位置 +3. **拖动微调**:地图上标记可拖动,拖动结束后更新经纬度并展示在地图下方 +4. **定位失败**:提示用户拖动标记手动选点 + +**实现说明(原型)**:使用 OpenStreetMap + Leaflet;优先按省市区 Mock 坐标定位,必要时调用 Nominatim 地理编码。 + +### 4.5 删除站点 + +**入口**:更多 → 删除 → 二次确认,不可撤销。 + +--- + +## 五、运营配置 + +### 5.1 营业状态 + +**入口**:更多 → **营业状态** + +- 顶部站点信息卡(名称、地址、当前状态) +- **营业状态**:营业中 / 暂停营业 / 停止营业(按钮组) +- **营业时间**:全天营业 / 自定义时段;非全天须填开始、结束时间(HH:mm) +- 下方展示 **营业状态变更记录** +- 保存后列表同步更新 + +### 5.2 价格配置 + +**入口**:更多 → **价格配置** + +#### 5.2.1 弹窗结构 + +``` +┌─────────────────────────────────────────────┐ +│ 顶部:加氢站名称 + 签约站点标签(无则省略) │ +├─────────────────────────────────────────────┤ +│ 当前已生效价格配置(表格) [+ 新增规则] │ +├─────────────────────────────────────────────┤ +│ (点击修改/新增后展开)编辑表单 │ +│ 定价方式 | 适用客户 │ +│ 固定价:成本价格 + 生效时间 │ +│ 阶梯价:阶梯条件 + 重置日/生效时间 │ +├─────────────────────────────────────────────┤ +│ (编辑态)当前规则调整记录 │ +└─────────────────────────────────────────────┘ +``` + +- 打开弹窗默认为 **查看态**,仅展示已生效配置表格 +- 点击行内 **「修改」** 或 **「+ 新增规则」** 才展开编辑卡片 +- **保存** 后回到查看态,**不关闭**弹窗 + +#### 5.2.2 已生效配置表格 + +| 列 | 说明 | +|----|------| +| 适用客户 | 全部客户 / 指定客户名称 | +| 定价方式 | 固定价格 / 阶梯价格(Tag) | +| 当前价格 | 元/kg;阶梯价展示当前匹配单价,可附「距下个阶梯还差 X kg」 | +| 生效时间 | 最近一条已生效日志的生效时间 | +| 阶梯重置 | 阶梯规则展示「每月 N 日」;固定价显示 — | +| 操作 | 修改 | + +#### 5.2.3 定价方式 + +**固定价格** + +- 成本价格(元/kg)+ 生效时间 +- 适用客户:与阶梯价相同的多选下拉 + - 未选客户 → 保存为「全部客户」规则 + - 已选客户 → 为每个客户各生成一条固定价规则 + - 已有单独规则的客户在下拉中禁用 + - 固定价提示:默认对全部客户生效;已为某客户单独配置的,将自动从本规则中排除 + +**阶梯价格** + +- 阶梯价格条件:加氢总量阈值(kg)+ 成本单价,可多条 +- 阶梯量重置日期(每月 1~28 日)+ 生效时间 +- 适用客户:多选,至少选一个;可为多个客户批量配置相同阶梯规则 + +#### 5.2.4 生效与列表联动 + +- 保存时写入规则及调整日志 +- 到达生效时间后,列表「当前成本价格」按适用客户范围更新(原型模拟) +- 同一客户仅允许一套生效规则;保存时校验冲突 + +### 5.3 余额提醒设置 + +#### 5.3.1 单站设置 + +**入口**:更多 → **余额提醒设置** + +- 展示站点名称、当前预付余额 +- 必填 **提醒金额(元)**,须 > 0 +- 保存后写入 `balanceAlertThreshold` +- 当预付余额低于阈值时,站点计入 KPI「预付余额预警站点」(已欠费站点不计入预警) + +#### 5.3.2 全站批量设置 + +**入口**:列表工具栏 → **全站余额提醒设置**(位于批量导入左侧) + +- 交互与单站余额提醒相同 +- 保存后将同一阈值批量写入 **全部加氢站** + +### 5.4 发起充值单 + +**入口**: + +- 列表工具栏 → **发起充值单**(空白行,自选站点) +- KPI 预警 / 欠费弹窗 → **一键发起充值单**(预填站点) + +**规则**: + +- 支持多行,每行选择站点、填写付款金额 +- 自动带出收款公司、银行账号、转账用途等(来自供应商付款信息) +- 提交后走付款流程(原型提示成功) + +### 5.5 加氢量 / 预付余额下钻 + +**加氢量下钻**(点击列表加氢量): + +- 统计卡:加氢次数、加氢总量、成本总价、加氢总价 +- 明细表字段对齐「车辆氢费明细」 +- 支持导出 CSV + +**预付余额下钻**(点击列表预付余额): + +- 当前余额(负值标「已欠费」) +- 充值 / 车辆加氢流水 +- 余额趋势图 + 导出 CSV + +--- + +## 六、核心流程:生成对账单 + +本模块与「车辆氢费明细」配合完成 **站点侧氢费结算闭环**。 + +### 6.1 数据来源 + +对账单明细取自 **车辆氢费明细** 中同时满足以下条件的记录: + +1. 对账状态 = **已对账** +2. **尚未**被本站点历史对账单结算过(未绑定 `statementRecordId`) +3. 加氢时间落在 `[账单开始日期, 账单结束日期]` 内 +4. 加氢站名称与当前操作站点一致 + +> 对账单明细 **仅展示成本字段**,不展示加氢单价、加氢总价。 + +### 6.2 两阶段交互 + +```mermaid +flowchart TD + A[打开「生成对账单」] --> B[选择账单开始/结束日期] + B --> C[展示上次对账单结束时间] + C --> D{点击「生成对账单」} + D -->|无符合条件记录| E[提示调整日期] + D -->|有记录| F[生成对账记录草稿] + F --> G[统计:加氢次数/总量/成本总金额] + G --> H[成本明细表] + H --> I[填写结算信息] + I --> J{点击「提交对账单」} + J -->|校验失败| K[提示必填项] + J -->|校验通过| L[完成闭环] +``` + +**阶段一 · 选期生成** + +- 展示 **上次对账单结束时间**(无则「暂无」) +- 默认开始日期 = 上次结束日 + 1 天 +- 生成后进入阶段二,日期锁定 + +**阶段二 · 结算提交** + +| 统计项 | 说明 | +|--------|------| +| 加氢次数 | 本账单笔数 | +| 加氢总量 | kg 合计 | +| 成本总金额 | 成本总价合计 | + +**必填结算项**: + +| 字段 | 规则 | +|------|------| +| 结算后加氢站预付款余额 | 只读,= 当前预付余额 − 成本总金额 | +| 收票日期 | 必填 | +| 收票金额 | 必填,默认成本总金额 | +| 发票附件 | 必填 | + +### 6.3 提交后的系统行为 + +提交成功后需原子性完成: + +1. 写入对账历史 +2. 扣减站点预付余额 +3. 标记台账记录已结算(`statementRecordId`) +4. 回写车辆氢费明细:对账日期、收票日期、加氢站付款状态 = 已付款 + +```mermaid +sequenceDiagram + participant 台账 as 车辆氢费明细 + participant 站点 as 站点信息 + participant 财务 as 结算操作人 + + 台账->>台账: 完成对账 → 已对账 + 财务->>站点: 更多 → 生成对账单 + 站点->>台账: 拉取已对账且未结算记录 + 财务->>站点: 生成对账记录 + 填收票信息 + 财务->>站点: 提交对账单 + 站点->>站点: 更新预付余额 / 写对账历史 + 站点->>台账: 回写对账日期、收票日期、已付款 +``` + +--- + +## 七、查看对账记录 + +**入口**:更多 → **查看对账记录** + +### 7.1 历史列表 + +| 列 | 说明 | +|----|------| +| 对账日期 | 提交操作时间 | +| 对账人 | 操作人 | +| 账单开始/结束日期 | 覆盖区间 | +| 加氢次数 / 加氢金额 | 本单汇总 | +| 对账后预付款余额 | 负值标「已欠费」 | +| 操作 | 查看明细 | + +### 7.2 查看明细 + +- 账单区间、统计三卡 +- 收票时间、收票金额、发票附件下载 +- 成本明细表 + +--- + +## 八、操作列「更多」菜单 + +| 菜单项 | 作用 | +|--------|------| +| 编辑 | 整页编辑站点主数据(含地图) | +| 营业状态 | 维护营业状态与营业时间 | +| 价格配置 | 固定价 / 阶梯价多规则配置 | +| 余额提醒设置 | 单站提醒阈值 | +| 生成对账单 | 两阶段对账结算 | +| 查看对账记录 | 历史对账单与明细 | +| 删除 | 删除站点(不可恢复) | + +--- + +## 九、校验与异常 + +| 场景 | 处理 | +|------|------| +| 新建/编辑:名称重复 | 阻止提交 | +| 新建/编辑:省市区或详细地址为空 | 阻止提交 | +| 新建/编辑:联系人或电话缺失 | 阻止提交 | +| 签约站点:合作时间或合同缺失 | 阻止提交 | +| 价格配置:未填价格/生效时间/阶梯条件 | 阻止保存 | +| 阶梯价:未选适用客户 | 阻止保存 | +| 价格配置:客户已有规则 | 提示编辑原规则 | +| 余额提醒:金额无效 | 阻止保存 | +| 对账单:日期无效 / 无已对账记录 | 阻止生成 | +| 对账单:收票信息不完整 | 阻止提交 | +| 预付余额结算后为负 | 允许提交,标「已欠费」 | +| 地图:地址无法定位 | 提示手动拖动选点 | + +--- + +## 十、研发实现要点 + +1. **价格配置模型**:`priceConfigs[]` 支持 `customerScope: all | customer`、固定价 / 阶梯价、生效日志 `logs[]`;列表价取当前时间已生效日志。 +2. **客户规则互斥**:`h2GetAssignedCustomerIds` 维护已占用客户;保存时 `h2ApplyCustomerExclusion` 处理规则覆盖。 +3. **余额预警**:`balanceAlertThreshold` + `prepaidBalance` 计算 KPI;欠费优先于预警。 +4. **地图字段**:`mapAddress`、`latitude`、`longitude` 随站点主数据持久化。 +5. **对账单幂等**:同一笔已对账记录只能绑定一次 `statementRecordId`。 +6. **跨模块同步**:生产对接统一台账服务;原型通过 `H2_STATION_STATEMENT_LEDGER_UPDATES` / `H2_VEHICLE_LEDGER_API` 模拟回写。 +7. **权限**(后续):对账提交、全站余额设置建议限制财务 / 管理员角色。 + +### 10.1 主要数据字段(站点) + +| 字段 | 类型 | 说明 | +|------|------|------| +| `name` | string | 加氢站名称 | +| `region` | string[] | 省、市 | +| `addressDetail` | string | 详细地址 | +| `fullAddress` | string | 完整地址展示 | +| `mapAddress` | string | 地图定位地址 | +| `latitude` / `longitude` | number | 经纬度 | +| `isSigned` | boolean | 是否签约 | +| `contractStart` / `contractEnd` | string | 合作时间 | +| `businessStatus` / `businessHours` | string | 营业信息 | +| `costUnitPrice` | number | 列表展示用当前成本价 | +| `priceConfigs` | array | 价格规则集合 | +| `balanceAlertThreshold` | number | 余额提醒阈值 | +| `prepaidBalance` | number | 预付余额 | +| `bindAccountIds` | string[] | 绑定系统账号 | + +--- + +## 十一、本期不做 + +- 对账单审批流、ERP 自动过账 +- 发票 OCR 识别与验真 +- 多币种 / 多税率 +- 高德 / 百度地图商用 Key 接入(原型使用 OSM) +- 按组织架构的复杂数据权限 + +--- + +**文档结束** diff --git a/web端/加氢站管理/站点信息.jsx b/web端/加氢站管理/站点信息.jsx index 4b8ab0a..f4fcaea 100644 --- a/web端/加氢站管理/站点信息.jsx +++ b/web端/加氢站管理/站点信息.jsx @@ -95,6 +95,10 @@ var H2_PAGE_STYLE = ONEOS_ANT_TABLE_GLOBAL_FIX.concat([ '.h2-station-page .h2-row-actions { display: inline-flex; align-items: center; gap: 4px; }', '.h2-station-page .lc-station-name { font-weight: 700; color: #0f172a; font-size: 13px; }', '.h2-station-page .lc-station-name-cell { min-width: 0; }', + '.h2-station-page .h2-current-cost-price-cell { display: inline-flex; align-items: center; justify-content: flex-end; gap: 6px; max-width: 100%; flex-wrap: nowrap; }', + '.h2-station-page .h2-price-type-tag.ant-tag { margin: 0; font-size: 11px; line-height: 18px; padding: 0 6px; flex-shrink: 0; }', + '.h2-station-page .h2-price-multi-warn { display: inline-flex; align-items: center; justify-content: center; color: #f59e0b; cursor: help; flex-shrink: 0; line-height: 0; }', + '.h2-station-page .h2-price-multi-warn:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; border-radius: 4px; }', '.h2-station-page .lc-station-name-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; min-width: 0; }', '.h2-station-page .lc-station-name-row .lc-station-name { min-width: 0; }', '.h2-station-page .lc-station-address-line { margin-top: 4px; font-size: 12px; color: #64748b; line-height: 1.4; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }', @@ -336,9 +340,10 @@ var H2_PAGE_STYLE = ONEOS_ANT_TABLE_GLOBAL_FIX.concat([ '.h2-price-config-modal .ant-modal-body { padding: 16px 24px 20px !important; background: #f8fafc; }', '.h2-price-config-modal .ant-modal-footer { padding: 12px 24px 18px !important; border-top: 1px solid #f1f5f9 !important; background: #fff; }', '.h2-price-config-modal .h2-price-config-panel { display: flex; flex-direction: column; gap: 14px; }', - '.h2-price-config-modal .h2-price-config-station-card { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; padding: 12px 16px; background: linear-gradient(135deg, #fff7ed 0%, #fffbeb 50%, #f8fafc 100%); border: 1px solid #fed7aa; border-radius: 12px; }', - '.h2-price-config-modal .h2-price-config-station-card__name { font-size: 15px; font-weight: 700; color: #0f172a; line-height: 1.35; }', - '.h2-price-config-modal .h2-price-config-station-card__meta { font-size: 12px; color: #64748b; font-variant-numeric: tabular-nums; white-space: nowrap; }', + '.h2-price-config-modal .h2-price-config-station-card { padding: 12px 16px; background: linear-gradient(135deg, #fff7ed 0%, #fffbeb 50%, #f8fafc 100%); border: 1px solid #fed7aa; border-radius: 12px; }', + '.h2-price-config-modal .h2-price-config-station-card__row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; min-width: 0; }', + '.h2-price-config-modal .h2-price-config-station-card__name { font-size: 15px; font-weight: 700; color: #0f172a; line-height: 1.35; min-width: 0; }', + '.h2-price-config-modal .h2-price-config-station-card .lc-station-signed-tag { margin: 0 !important; border-radius: 6px !important; font-weight: 600 !important; flex-shrink: 0; line-height: 20px !important; }', '.h2-price-config-modal .h2-price-config-stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }', '@media (max-width: 640px) { .h2-price-config-modal .h2-price-config-stats { grid-template-columns: 1fr; } }', '.h2-price-config-modal .h2-price-config-stat { display: flex; flex-direction: column; justify-content: center; min-height: 78px; padding: 12px 14px; border-radius: 12px; border: 1px solid #e2e8f0; background: #fff; box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06); min-width: 0; box-sizing: border-box; }', @@ -356,6 +361,8 @@ var H2_PAGE_STYLE = ONEOS_ANT_TABLE_GLOBAL_FIX.concat([ '.h2-price-config-modal .h2-price-config-form-wrap .ant-form-item-label > label { font-size: 13px; font-weight: 600; color: #334155; }', '.h2-price-config-modal .h2-price-config-form-wrap .ant-input, .h2-price-config-modal .h2-price-config-form-wrap .ant-picker { width: 100% !important; border-radius: 8px !important; }', '.h2-price-config-modal .h2-price-config-form-hint { margin-top: 10px; padding: 8px 12px; font-size: 12px; color: #64748b; line-height: 1.5; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; }', + '.h2-price-config-modal .h2-price-config-form-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; padding-bottom: 10px; border-bottom: 1px solid #f1f5f9; }', + '.h2-price-config-modal .h2-price-config-form-head__title { font-size: 14px; font-weight: 700; color: #0f172a; }', '.h2-price-config-modal .h2-price-config-table-wrap { background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04); }', '.h2-price-config-modal .h2-price-config-table-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 14px; border-bottom: 1px solid #f1f5f9; background: #fafbfc; }', '.h2-price-config-modal .h2-price-config-table-head__title { font-size: 13px; font-weight: 700; color: #334155; }', @@ -374,6 +381,57 @@ var H2_PAGE_STYLE = ONEOS_ANT_TABLE_GLOBAL_FIX.concat([ '.h2-price-config-modal .h2-price-config-record-table .h2-price-config-money { font-variant-numeric: tabular-nums; white-space: nowrap; font-weight: 600; }', '.h2-price-config-modal .h2-price-config-record-table .h2-price-config-money--before { color: #64748b; }', '.h2-price-config-modal .h2-price-config-record-table .h2-price-config-money--after { color: #ea580c; font-weight: 700; }', + '.h2-price-config-modal .h2-price-config-after-lines { display: flex; flex-direction: column; gap: 4px; min-width: 0; }', + '.h2-price-config-modal .h2-price-config-after-line { font-size: 12px; line-height: 1.45; color: #334155; font-variant-numeric: tabular-nums; white-space: nowrap; }', + '.h2-price-config-modal .h2-price-config-after-line .h2-price-config-money { font-size: 12px; }', + '.h2-price-config-modal .h2-price-tier-rules { display: flex; flex-direction: column; gap: 10px; margin-top: 4px; }', + '.h2-price-config-modal .h2-price-tier-rule-row { display: grid; grid-template-columns: 1fr 1fr auto; gap: 10px; align-items: end; padding: 10px 12px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; }', + '@media (max-width: 640px) { .h2-price-config-modal .h2-price-tier-rule-row { grid-template-columns: 1fr; } }', + '.h2-price-config-modal .h2-price-tier-rule-field label { display: block; font-size: 12px; font-weight: 600; color: #64748b; margin-bottom: 6px; }', + '.h2-price-config-modal .h2-price-config-effective-price { font-size: 12px; color: #334155; font-variant-numeric: tabular-nums; }', + '.h2-price-config-modal .h2-price-config-effective-table .ant-table-tbody > tr { cursor: default; }', + '.h2-price-config-modal .h2-price-config-rule-list--empty { font-size: 13px; color: #94a3b8; text-align: center; line-height: 1.5; }', + '.h2-price-config-modal .h2-price-config-rule-item { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; padding: 10px 12px; background: #fff; border: 1px solid #e2e8f0; border-radius: 10px; cursor: pointer; transition: border-color 0.2s ease, box-shadow 0.2s ease; }', + '.h2-price-config-modal .h2-price-config-rule-item:hover { border-color: #fdba74; }', + '.h2-price-config-modal .h2-price-config-rule-item.is-active { border-color: #f97316; box-shadow: 0 0 0 2px rgba(249, 115, 22, 0.12); background: #fff7ed; }', + '.h2-price-config-modal .h2-price-config-rule-item__main { min-width: 0; flex: 1; }', + '.h2-price-config-modal .h2-price-config-rule-item__title { font-size: 13px; font-weight: 700; color: #0f172a; margin-bottom: 6px; }', + '.h2-price-config-modal .h2-price-config-rule-item__tags { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; margin-bottom: 6px; }', + '.h2-price-config-modal .h2-price-config-rule-item__price-text { font-size: 12px; color: #334155; font-variant-numeric: tabular-nums; line-height: 1.5; }', + '.h2-price-config-modal .h2-price-config-rule-item__time { font-size: 12px; color: #64748b; line-height: 1.5; font-variant-numeric: tabular-nums; }', + '.h2-price-config-modal .h2-price-config-rule-item__time-sep { margin: 0 8px; color: #cbd5e1; }', + '.h2-price-config-modal .h2-price-config-rule-item__meta { font-size: 12px; color: #64748b; line-height: 1.5; }', + '.h2-price-config-modal .h2-price-config-rule-item__timing { display: flex; flex-wrap: wrap; gap: 8px 16px; margin-top: 4px; font-size: 12px; color: #64748b; }', + '.h2-price-config-modal .h2-price-config-rule-item__timing-item { white-space: nowrap; }', + '.h2-price-config-modal .h2-price-tier-timing-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }', + '@media (max-width: 640px) { .h2-price-config-modal .h2-price-tier-timing-row { grid-template-columns: 1fr; } }', + '.h2-price-config-modal .h2-price-config-type-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 16px; margin-bottom: 14px; }', + '.h2-price-config-modal .h2-price-config-type-row__col { min-width: 0; }', + '.h2-price-config-modal .h2-price-config-type-row .ant-form-item { margin-bottom: 0; }', + '@media (max-width: 768px) { .h2-price-config-modal .h2-price-config-type-row { grid-template-columns: 1fr; } }', + '.h2-price-config-modal .h2-tier-customer-picker { width: 100%; }', + '.h2-price-config-modal .h2-tier-customer-picker__trigger { width: 100%; display: flex; align-items: center; justify-content: space-between; min-height: 32px; padding: 4px 11px; border: 1px solid #d9d9d9; border-radius: 8px; background: #fff; cursor: pointer; transition: border-color 0.2s ease, box-shadow 0.2s ease; }', + '.h2-price-config-modal .h2-tier-customer-picker__trigger:hover { border-color: #f97316; }', + '.h2-price-config-modal .h2-tier-customer-picker__trigger.is-open { border-color: #f97316; box-shadow: 0 0 0 2px rgba(249, 115, 22, 0.12); }', + '.h2-price-config-modal .h2-tier-customer-picker__trigger-text { font-size: 13px; color: #334155; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; flex: 1; text-align: left; }', + '.h2-price-config-modal .h2-tier-customer-picker__trigger-text.is-placeholder { color: #94a3b8; }', + '.h2-price-config-modal .h2-tier-customer-dropdown { min-width: 300px; max-width: 360px; max-height: 300px; overflow-y: auto; padding: 6px 0; background: #fff; border-radius: 10px; box-shadow: 0 12px 32px rgba(15, 23, 42, 0.14); }', + '.h2-price-config-modal .h2-tier-customer-dropdown__item { display: flex; align-items: center; gap: 8px; padding: 8px 12px; font-size: 13px; color: #334155; cursor: pointer; transition: background 0.15s ease; }', + '.h2-price-config-modal .h2-tier-customer-dropdown__item:hover:not(.is-disabled) { background: #fff7ed; }', + '.h2-price-config-modal .h2-tier-customer-dropdown__item.is-disabled { color: #94a3b8; cursor: not-allowed; }', + '.h2-price-config-modal .h2-tier-customer-dropdown__item.is-all { border-bottom: 1px solid #f1f5f9; margin-bottom: 4px; font-weight: 600; }', + '.h2-price-config-modal .h2-tier-customer-dropdown__hint { padding: 0 12px 8px; font-size: 11px; color: #94a3b8; line-height: 1.45; }', + /* 阶梯客户下拉挂到 body,需全局样式(不可仅写在 .h2-price-config-modal 内) */ + '.h2-tier-customer-dropdown-overlay.ant-dropdown { background: transparent !important; box-shadow: none !important; padding: 0 !important; }', + '.h2-tier-customer-dropdown-overlay .ant-dropdown-menu { background: transparent !important; box-shadow: none !important; padding: 0 !important; }', + '.h2-tier-customer-dropdown { min-width: 300px; max-width: 360px; max-height: 300px; overflow-y: auto; padding: 6px 0; background: #fff !important; border: 1px solid #e2e8f0; border-radius: 10px; box-shadow: 0 12px 32px rgba(15, 23, 42, 0.14); }', + '.h2-tier-customer-dropdown__item { display: flex; align-items: center; gap: 8px; padding: 8px 12px; font-size: 13px; color: #334155; cursor: pointer; transition: background 0.15s ease; background: #fff; }', + '.h2-tier-customer-dropdown__item:hover:not(.is-disabled) { background: #fff7ed; }', + '.h2-tier-customer-dropdown__item.is-disabled { color: #94a3b8; cursor: not-allowed; background: #fff; }', + '.h2-tier-customer-dropdown__item.is-all { border-bottom: 1px solid #f1f5f9; margin-bottom: 4px; font-weight: 600; }', + '.h2-tier-customer-dropdown__hint { padding: 0 12px 8px; font-size: 11px; color: #94a3b8; line-height: 1.45; background: #fff; }', + '.h2-price-config-modal .h2-price-config-stat--tier { border-left: 4px solid #14b8a6; background: linear-gradient(180deg, #fff 0%, #f0fdfa 100%); }', + '.h2-price-config-modal .h2-price-config-stat__value--tier { color: #0f766e; }', '.h2-business-status-modal .ant-modal-content { border-radius: 16px !important; overflow: hidden; box-shadow: 0 24px 48px -12px rgba(15, 23, 42, 0.18) !important; }', '.h2-business-status-modal .ant-modal-header { padding: 18px 24px 14px !important; border-bottom: 1px solid #f1f5f9 !important; margin-bottom: 0 !important; }', '.h2-business-status-modal .ant-modal-title { font-size: 17px !important; font-weight: 700 !important; color: #0f172a !important; }', @@ -564,6 +622,16 @@ var H2_PAGE_STYLE = ONEOS_ANT_TABLE_GLOBAL_FIX.concat([ '.h2-station-page--create .h2-create-panel--address .h2-region-address--inline { gap: 12px; }', '.h2-station-page--create .h2-create-panel--address .h2-region-address--inline .h2-region-address-cascader { flex: 0 0 42%; max-width: 280px; }', '.h2-station-page--create .h2-create-panel--address .h2-address-paste { margin-top: 12px; padding-top: 12px; border-top: 1px dashed #e2e8f0; }', + '.h2-station-page--create .h2-station-map-picker { display: flex; flex-direction: column; gap: 12px; width: 100%; }', + '.h2-station-page--create .h2-station-map-picker__toolbar { display: flex; align-items: center; gap: 8px; width: 100%; }', + '.h2-station-page--create .h2-station-map-picker__toolbar .ant-input { flex: 1; min-width: 0; }', + '.h2-station-page--create .h2-station-map-picker__toolbar .ant-btn { flex-shrink: 0; min-height: 32px; border-radius: 8px; font-weight: 600; }', + '.h2-station-page--create .h2-station-map-picker__map { position: relative; width: 100%; height: 280px; border-radius: 10px; border: 1px solid #e2e8f0; overflow: hidden; background: #f1f5f9; }', + '.h2-station-page--create .h2-station-map-picker__map .leaflet-container { height: 100%; width: 100%; font-family: inherit; z-index: 0; }', + '.h2-station-page--create .h2-station-map-picker__coords { display: flex; flex-wrap: wrap; gap: 8px 20px; font-size: 12px; color: #64748b; line-height: 1.5; }', + '.h2-station-page--create .h2-station-map-picker__coords-item { display: inline-flex; align-items: center; gap: 6px; }', + '.h2-station-page--create .h2-station-map-picker__coords-item strong { color: #0f172a; font-weight: 700; font-variant-numeric: tabular-nums; }', + '.h2-station-page--create .h2-station-map-picker__hint { font-size: 12px; color: #94a3b8; line-height: 1.45; }', '.h2-station-page--create .h2-create-panel--contract .h2-create-radio-group { padding: 10px 14px; background: #fff; border-radius: 10px; border: 1px solid #e2e8f0; }', '.h2-station-page--create .h2-create-contract-fields { display: grid; grid-template-columns: 1fr; gap: 16px; margin-top: 14px; }', '@media (min-width: 640px) { .h2-station-page--create .h2-create-contract-fields { grid-template-columns: 1fr 1fr; } }', @@ -734,14 +802,321 @@ function h2CreateBusinessStatusLog(beforeStatus, afterStatus, operator) { }; } -function h2CreateCostPriceLog(beforePrice, afterPrice, effectiveTime, operator) { - return { +function h2CreateCostPriceLog(beforePrice, afterPrice, effectiveTime, operator, extra) { + return Object.assign({ id: 'cpl-' + Date.now() + '-' + Math.floor(Math.random() * 1000), operator: operator || H2_CURRENT_OPERATOR, operateTime: h2OperateTimestamp(), beforeCostPrice: beforePrice, afterCostPrice: afterPrice, effectiveTime: effectiveTime + }, extra || {}); +} + +function h2CreateEmptyTierRule() { + return { id: 'tr-' + Date.now() + '-' + Math.floor(Math.random() * 1000), thresholdKg: '', price: '' }; +} + +function h2CreatePriceConfigId() { + return 'pc-' + Date.now() + '-' + Math.floor(Math.random() * 1000); +} + +function h2CollectCustomerOptions(refuelRecords) { + var map = {}; + var i; + for (i = 0; i < (refuelRecords || []).length; i++) { + var r = refuelRecords[i]; + var name = (r.customerName || '').trim(); + if (!name) continue; + var id = 'cust-' + name; + map[id] = name; + } + return Object.keys(map).map(function (id) { + return { value: id, label: map[id] }; + }); +} + +function h2MigrateLegacyPriceConfig(record) { + return { + id: 'pc-default', + priceType: 'fixed', + customerScope: 'all', + customerId: null, + customerName: null, + fixedPrice: record && record.costUnitPrice != null ? record.costUnitPrice : null, + tierRules: [], + tierResetDay: 1, + tierPeriodVolumeKg: 0, + tierPeriodKey: '', + logs: (record && record.costPriceLogs) ? record.costPriceLogs.slice() : [] + }; +} + +function h2GetStationPriceConfigs(record) { + if (record && record.priceConfigs && record.priceConfigs.length) { + return record.priceConfigs.map(function (cfg) { + return Object.assign({}, cfg, { + logs: (cfg.logs || []).slice(), + tierRules: (cfg.tierRules || []).map(function (rule) { return Object.assign({}, rule); }) + }); + }); + } + return [h2MigrateLegacyPriceConfig(record)]; +} + +function h2SortTierRules(rules) { + return (rules || []).slice().sort(function (a, b) { + return (parseFloat(a.thresholdKg) || 0) - (parseFloat(b.thresholdKg) || 0); + }); +} + +function h2ResolveTierUnitPrice(volumeKg, tierRules) { + var rules = h2SortTierRules(tierRules); + if (!rules.length) return null; + var vol = parseFloat(volumeKg); + if (isNaN(vol)) vol = 0; + var matched = rules[0]; + var i; + for (i = 0; i < rules.length; i++) { + var threshold = parseFloat(rules[i].thresholdKg); + if (isNaN(threshold)) threshold = 0; + if (vol >= threshold) matched = rules[i]; + } + var price = parseFloat(matched.price); + return isNaN(price) ? null : price; +} + +function h2GetTierPeriodKey(resetDay, nowMs) { + var dayjs = window.dayjs; + if (!dayjs) return ''; + var d = dayjs(nowMs || Date.now()); + var day = parseInt(resetDay, 10) || 1; + if (d.date() < day) d = d.subtract(1, 'month'); + return d.format('YYYY-MM'); +} + +function h2GetTierPeriodVolume(config, nowMs) { + var now = nowMs || Date.now(); + var periodKey = h2GetTierPeriodKey(config.tierResetDay, now); + if (config.tierPeriodKey && config.tierPeriodKey !== periodKey) return 0; + return config.tierPeriodVolumeKg != null ? parseFloat(config.tierPeriodVolumeKg) || 0 : 0; +} + +function h2ComputeTierStats(config, nowMs) { + var volume = h2GetTierPeriodVolume(config, nowMs); + var unitPrice = h2ResolveTierUnitPrice(volume, config.tierRules); + var total = unitPrice != null ? volume * unitPrice : null; + return { volumeKg: volume, unitPrice: unitPrice, totalCost: total }; +} + +function h2ResolveFixedPriceFromConfig(config, nowMs) { + var logs = (config && config.logs) || []; + var now = nowMs || Date.now(); + var best = null; + var i; + for (i = 0; i < logs.length; i++) { + var log = logs[i]; + var t = h2ParseDateTimeMs(log.effectiveTime); + if (isNaN(t) || t > now) continue; + if (!best || t >= best.t) best = { t: t, price: log.afterCostPrice }; + } + if (best && best.price != null) return best.price; + if (config && config.fixedPrice != null) return config.fixedPrice; + return null; +} + +function h2ResolveConfigCurrentPrice(config, nowMs) { + if (!config) return null; + if (config.priceType === 'tiered') { + var stats = h2ComputeTierStats(config, nowMs); + return stats.unitPrice; + } + return h2ResolveFixedPriceFromConfig(config, nowMs); +} + +function h2GetDefaultAllCustomerPriceConfig(record) { + var configs = h2GetStationPriceConfigs(record); + var i; + for (i = 0; i < configs.length; i++) { + if (configs[i].customerScope === 'all') return configs[i]; + } + return configs[0] || null; +} + +function h2GetPrimaryPriceConfig(record) { + var configs = h2GetStationPriceConfigs(record); + return configs.length ? configs[0] : null; +} + +function h2HasMultiCustomerPriceSystems(record) { + return h2GetStationPriceConfigs(record).length > 1; +} + +function h2GetCustomerPriceConfig(record, customerId) { + var configs = h2GetStationPriceConfigs(record); + var i; + for (i = 0; i < configs.length; i++) { + if (configs[i].customerScope === 'customer' && configs[i].customerId === customerId) return configs[i]; + } + return h2GetDefaultAllCustomerPriceConfig(record); +} + +function h2GetAssignedCustomerIds(priceConfigs, exceptConfigId) { + var ids = {}; + var i; + for (i = 0; i < (priceConfigs || []).length; i++) { + var cfg = priceConfigs[i]; + if (exceptConfigId && cfg.id === exceptConfigId) continue; + if (cfg.customerScope === 'customer' && cfg.customerId) ids[cfg.customerId] = cfg.customerName || cfg.customerId; + } + return ids; +} + +function h2FindPriceConfigByCustomerId(priceConfigs, customerId, exceptConfigId) { + var i; + for (i = 0; i < (priceConfigs || []).length; i++) { + var cfg = priceConfigs[i]; + if (exceptConfigId && cfg.id === exceptConfigId) continue; + if (cfg.customerScope === 'customer' && cfg.customerId === customerId) return cfg; + } + return null; +} + +function h2GetConfigEffectiveTimeLabel(config) { + var logs = (config && config.logs) || []; + return logs.length && logs[0].effectiveTime ? logs[0].effectiveTime : '—'; +} + +function h2FormatConfigEffectiveDateTime(config) { + var raw = h2GetConfigEffectiveTimeLabel(config); + if (!raw || raw === '—') return '—'; + var dayjs = window.dayjs; + if (dayjs) { + var d = dayjs(raw.indexOf('T') >= 0 ? raw : raw.replace(' ', 'T')); + if (d.isValid && d.isValid()) return d.format('YYYY-MM-DD HH:mm'); + } + var s = String(raw).trim(); + if (/^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}/.test(s)) return s.slice(0, 16); + return s; +} + +function h2GetTierResetTimeLabel(config) { + var resetDay = parseInt(config && config.tierResetDay, 10) || 1; + return '每月' + resetDay + '日'; +} + +function h2GetTierGapToNextThreshold(config, nowMs) { + var rules = h2SortTierRules(config && config.tierRules); + if (!rules.length) return null; + var volume = h2GetTierPeriodVolume(config, nowMs); + var i; + for (i = 0; i < rules.length; i++) { + var threshold = parseFloat(rules[i].thresholdKg); + if (isNaN(threshold)) threshold = 0; + if (threshold > volume) return threshold - volume; + } + return null; +} + +function h2FormatPriceConfigScopeLabel(config) { + if (!config || config.customerScope !== 'customer') return '全部客户'; + return config.customerName || config.customerId || '指定客户'; +} + +function h2FormatPriceTypeLabel(priceType) { + return priceType === 'tiered' ? '阶梯价格' : '固定价格'; +} + +function h2FormatTierRulesSummary(tierRules) { + var rules = h2SortTierRules(tierRules); + if (!rules.length) return '—'; + return rules.map(function (rule) { + return h2FormatTierRuleLine(rule); + }).join(';'); +} + +function h2FormatTierRuleLine(rule) { + return '≥' + h2FormatKgNum(rule.thresholdKg) + 'kg → ' + h2FormatYuanNum(rule.price) + '元/kg'; +} + +function h2GetTierRuleDisplayLines(row) { + if (row && row.tierRules && row.tierRules.length) { + return h2SortTierRules(row.tierRules).map(function (rule) { + return h2FormatTierRuleLine(rule); + }); + } + var summary = row && row.tierRulesSummary; + if (!summary || summary === '—') return []; + return String(summary).split(';').map(function (line) { return line.trim(); }).filter(Boolean); +} + +function h2SyncRecordPriceFields(record) { + var configs = h2GetStationPriceConfigs(record); + var primaryCfg = h2GetPrimaryPriceConfig(record); + var resolved = primaryCfg ? h2ResolveConfigCurrentPrice(primaryCfg) : null; + var legacyLogs = primaryCfg && primaryCfg.logs && primaryCfg.logs.length + ? primaryCfg.logs.slice() + : (record.costPriceLogs || []); + return Object.assign({}, record, { + priceConfigs: configs, + costPriceLogs: legacyLogs, + costUnitPrice: resolved != null ? resolved : record.costUnitPrice + }); +} + +function h2ApplyCustomerExclusion(priceConfigs, savedConfig) { + var savedId = savedConfig.id; + var savedCustomerId = savedConfig.customerScope === 'customer' ? savedConfig.customerId : null; + return (priceConfigs || []).filter(function (cfg) { + if (cfg.id === savedId) return false; + if (savedCustomerId && cfg.customerScope === 'customer' && cfg.customerId === savedCustomerId) return false; + return true; + }); +} + +function h2EmptyPriceModalForm() { + var dayjs = window.dayjs; + return { + editingConfigId: null, + priceFormVisible: false, + priceType: 'fixed', + customerScope: 'all', + customerId: null, + customerName: null, + selectedCustomerIds: [], + tierCustomerPickerOpen: false, + costUnitPrice: '', + effectiveTime: window.dayjs ? window.dayjs().format('YYYY-MM-DD HH:mm') : h2OperateTimestamp(), + tierRules: [h2CreateEmptyTierRule()], + tierResetDay: 1 + }; +} + +function h2PriceModalFormFromConfig(config) { + if (!config) return h2EmptyPriceModalForm(); + var selectedCustomerIds = config.customerScope === 'customer' && config.customerId ? [config.customerId] : []; + return { + editingConfigId: config.id, + priceType: config.priceType || 'fixed', + customerScope: config.customerScope || 'all', + customerId: config.customerId || null, + customerName: config.customerName || null, + selectedCustomerIds: selectedCustomerIds, + tierCustomerPickerOpen: false, + costUnitPrice: config.priceType !== 'tiered' + ? String(h2ResolveFixedPriceFromConfig(config) != null ? h2ResolveFixedPriceFromConfig(config) : (config.fixedPrice != null ? config.fixedPrice : '')) + : '', + effectiveTime: window.dayjs ? window.dayjs().format('YYYY-MM-DD HH:mm') : h2OperateTimestamp(), + tierRules: (config.tierRules && config.tierRules.length) + ? config.tierRules.map(function (rule) { + return { + id: rule.id || h2CreateEmptyTierRule().id, + thresholdKg: rule.thresholdKg != null ? String(rule.thresholdKg) : '', + price: rule.price != null ? String(rule.price) : '' + }; + }) + : [h2CreateEmptyTierRule()], + tierResetDay: config.tierResetDay != null ? config.tierResetDay : 1 }; } @@ -789,6 +1164,11 @@ function h2FormatReceiptAmountInput(text) { } function h2ResolveCurrentCostPrice(record) { + var primaryCfg = h2GetPrimaryPriceConfig(record); + if (primaryCfg) { + var resolved = h2ResolveConfigCurrentPrice(primaryCfg); + if (resolved != null) return resolved; + } var logs = (record && record.costPriceLogs) || []; var now = Date.now(); var best = null; @@ -806,10 +1186,7 @@ function h2ResolveCurrentCostPrice(record) { } function h2ApplyDueCostPriceToRecord(record) { - var resolved = h2ResolveCurrentCostPrice(record); - if (resolved == null) return record; - if (record.costUnitPrice === resolved) return record; - return Object.assign({}, record, { costUnitPrice: resolved }); + return h2SyncRecordPriceFields(record); } function h2ApplyDueCostPricesToList(records) { @@ -1383,6 +1760,256 @@ function AddressPasteInput(props) { ); } +var H2_DEFAULT_MAP_CENTER = { lat: 30.274084, lng: 120.15507 }; +var H2_MAP_LEAFLET_READY = null; + +var H2_CITY_GEO_COORDS = { + '浙江省|嘉兴市': { lat: 30.746129, lng: 120.755486 }, + '浙江省|杭州市': { lat: 30.274084, lng: 120.15507 }, + '浙江省|宁波市': { lat: 29.868336, lng: 121.54399 }, + '上海市|上海市': { lat: 31.230416, lng: 121.473701 }, + '江苏省|南京市': { lat: 32.060255, lng: 118.796877 }, + '江苏省|苏州市': { lat: 31.298886, lng: 120.585316 }, + '广东省|广州市': { lat: 23.12911, lng: 113.264385 }, + '广东省|深圳市': { lat: 22.543099, lng: 114.057868 } +}; + +function h2EnsureLeafletLoaded() { + if (H2_MAP_LEAFLET_READY) return H2_MAP_LEAFLET_READY; + H2_MAP_LEAFLET_READY = new Promise(function (resolve, reject) { + if (window.L && window.L.map) { + resolve(window.L); + return; + } + if (!document.querySelector('link[data-h2-leaflet-css]')) { + var link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'; + link.setAttribute('data-h2-leaflet-css', '1'); + document.head.appendChild(link); + } + var existing = document.querySelector('script[data-h2-leaflet-js]'); + if (existing) { + existing.addEventListener('load', function () { resolve(window.L); }); + existing.addEventListener('error', reject); + return; + } + var script = document.createElement('script'); + script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js'; + script.setAttribute('data-h2-leaflet-js', '1'); + script.onload = function () { resolve(window.L); }; + script.onerror = reject; + document.head.appendChild(script); + }); + return H2_MAP_LEAFLET_READY; +} + +function h2FixLeafletDefaultIcon(L) { + if (!L || !L.Icon || !L.Icon.Default) return; + delete L.Icon.Default.prototype._getIconUrl; + L.Icon.Default.mergeOptions({ + iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png', + iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png', + shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png' + }); +} + +function h2ResolveGeoFromRegion(region) { + if (!region || region.length < 2) return null; + return H2_CITY_GEO_COORDS[region[0] + '|' + region[1]] || null; +} + +function h2GeocodeAddressPromise(address) { + var text = String(address || '').trim(); + if (!text) return Promise.resolve(null); + var parsed = h2ParseAddressText(text); + if (parsed.region && parsed.region.length >= 2) { + var city = h2ResolveGeoFromRegion(parsed.region); + if (city) return Promise.resolve({ lat: city.lat, lng: city.lng }); + } + if (!window.fetch) return Promise.resolve(null); + return fetch('https://nominatim.openstreetmap.org/search?format=json&limit=1&q=' + encodeURIComponent(text), { + headers: { 'Accept-Language': 'zh-CN' } + }) + .then(function (res) { return res.json(); }) + .then(function (data) { + if (!data || !data.length) return null; + var lat = parseFloat(data[0].lat); + var lng = parseFloat(data[0].lon); + if (isNaN(lat) || isNaN(lng)) return null; + return { lat: lat, lng: lng }; + }) + .catch(function () { return null; }); +} + +function h2FormatCoordNum(value) { + if (value == null || value === '') return '—'; + var n = parseFloat(value); + if (isNaN(n)) return '—'; + return n.toFixed(6); +} + +function h2NormalizeCoord(value) { + if (value == null || value === '') return null; + var n = parseFloat(value); + return isNaN(n) ? null : n; +} + +/** 地图定位:输入地址自动定位,拖动标记获取经纬度 */ +function StationMapPicker(props) { + var antd = window.antd; + var Input = antd.Input; + var Button = antd.Button; + var message = antd.message; + var mapElRef = React.useRef(null); + var mapRef = React.useRef(null); + var markerRef = React.useRef(null); + var geocodeReqRef = React.useRef(0); + var lastExternalAddressRef = React.useRef(''); + var locateByAddressRef = React.useRef(null); + + var _localAddr = React.useState(props.address || ''); + var localAddress = _localAddr[0]; + var setLocalAddress = _localAddr[1]; + + var _loading = React.useState(false); + var loading = _loading[0]; + var setLoading = _loading[1]; + + var _mapReady = React.useState(false); + var mapReady = _mapReady[0]; + var setMapReady = _mapReady[1]; + + var inputClassName = props.inputClassName || ''; + var disabled = !!props.disabled; + var latitude = h2NormalizeCoord(props.latitude); + var longitude = h2NormalizeCoord(props.longitude); + + var applyMarkerPosition = React.useCallback(function (lat, lng, moveView) { + if (lat == null || lng == null || isNaN(lat) || isNaN(lng)) return; + if (markerRef.current) markerRef.current.setLatLng([lat, lng]); + if (moveView !== false && mapRef.current) mapRef.current.setView([lat, lng], Math.max(mapRef.current.getZoom() || 15, 14)); + if (props.onPositionChange) props.onPositionChange(lat, lng); + }, [props.onPositionChange]); + + var locateByAddress = React.useCallback(function (addr, showWarn) { + var text = String(addr || '').trim(); + if (!text || disabled) return; + setLoading(true); + var reqId = geocodeReqRef.current + 1; + geocodeReqRef.current = reqId; + h2GeocodeAddressPromise(text).then(function (result) { + if (reqId !== geocodeReqRef.current) return; + setLoading(false); + if (!result) { + if (showWarn !== false) message.warning('未能定位该地址,请拖动地图标记手动选点'); + return; + } + applyMarkerPosition(result.lat, result.lng, true); + }); + }, [disabled, applyMarkerPosition]); + + locateByAddressRef.current = locateByAddress; + + React.useEffect(function () { + var ext = props.address || ''; + if (ext === lastExternalAddressRef.current) return; + lastExternalAddressRef.current = ext; + setLocalAddress(ext); + if (ext && locateByAddressRef.current) locateByAddressRef.current(ext, false); + }, [props.address]); + + React.useEffect(function () { + var cancelled = false; + h2EnsureLeafletLoaded().then(function (L) { + if (cancelled || !mapElRef.current || mapRef.current) return; + h2FixLeafletDefaultIcon(L); + var initLat = latitude != null ? latitude : H2_DEFAULT_MAP_CENTER.lat; + var initLng = longitude != null ? longitude : H2_DEFAULT_MAP_CENTER.lng; + var map = L.map(mapElRef.current, { + zoomControl: true, + attributionControl: true + }).setView([initLat, initLng], latitude != null && longitude != null ? 15 : 11); + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 19, + attribution: '© OpenStreetMap' + }).addTo(map); + var marker = L.marker([initLat, initLng], { draggable: !disabled }).addTo(map); + marker.on('dragend', function () { + if (disabled) return; + var ll = marker.getLatLng(); + if (props.onPositionChange) props.onPositionChange(ll.lat, ll.lng); + }); + mapRef.current = map; + markerRef.current = marker; + setMapReady(true); + setTimeout(function () { map.invalidateSize(); }, 80); + }).catch(function () { + if (!cancelled) message.error('地图加载失败,请检查网络后刷新重试'); + }); + return function () { + cancelled = true; + if (mapRef.current) { + mapRef.current.remove(); + mapRef.current = null; + markerRef.current = null; + } + }; + }, []); + + React.useEffect(function () { + if (!mapReady || latitude == null || longitude == null) return; + if (markerRef.current) { + var ll = markerRef.current.getLatLng(); + if (Math.abs(ll.lat - latitude) < 0.000001 && Math.abs(ll.lng - longitude) < 0.000001) return; + } + applyMarkerPosition(latitude, longitude, false); + }, [latitude, longitude, mapReady, applyMarkerPosition]); + + React.useEffect(function () { + if (markerRef.current) { + if (disabled) markerRef.current.dragging.disable(); + else markerRef.current.dragging.enable(); + } + }, [disabled, mapReady]); + + return React.createElement('div', { className: 'h2-station-map-picker' }, + React.createElement('div', { className: 'h2-station-map-picker__toolbar' }, + React.createElement(Input, { + className: inputClassName, + value: localAddress, + disabled: disabled, + placeholder: '输入地址后定位,或从上方地址解析自动填入', + onChange: function (e) { + var next = e.target.value; + setLocalAddress(next); + if (props.onAddressChange) props.onAddressChange(next); + }, + onBlur: function () { + if (disabled) return; + locateByAddress(localAddress, true); + }, + onPressEnter: function () { + if (disabled) return; + locateByAddress(localAddress, true); + } + }), + React.createElement(Button, { + type: 'default', + disabled: disabled || loading || !String(localAddress || '').trim(), + loading: loading, + onClick: function () { locateByAddress(localAddress, true); } + }, '定位') + ), + React.createElement('div', { className: 'h2-station-map-picker__map', ref: mapElRef, role: 'application', 'aria-label': '加氢站地图定位' }), + React.createElement('div', { className: 'h2-station-map-picker__coords' }, + React.createElement('span', { className: 'h2-station-map-picker__coords-item' }, '经度:', React.createElement('strong', null, h2FormatCoordNum(longitude))), + React.createElement('span', { className: 'h2-station-map-picker__coords-item' }, '纬度:', React.createElement('strong', null, h2FormatCoordNum(latitude))) + ), + React.createElement('div', { className: 'h2-station-map-picker__hint' }, '拖动地图标记可微调位置,经纬度将自动更新') + ); +} + var H2_BUSINESS_LICENSE_OCR_MOCKS = [ { test: function (name) { return /嘉兴|南湖|氢能供应/i.test(name || ''); }, @@ -1448,8 +2075,10 @@ function h2ApplyBusinessLicenseOcr(ctx, ocrData) { } ctx.updateSupplier(supplierPatch); if (ctx.updateStation && ctx.supplierMode === 'new' && region.length >= 2) { + var fullAddr = h2BuildFullAddress(region, detail); ctx.updateStation({ - address: { region: region.slice(), detail: detail } + address: { region: region.slice(), detail: detail }, + mapAddress: fullAddr }); } } @@ -2112,6 +2741,9 @@ var H2_MOCK_STATIONS = [ region: ['浙江省', '嘉兴市'], addressDetail: '南湖区科技大道88号', fullAddress: '浙江省-嘉兴市南湖区科技大道88号', + mapAddress: '浙江省嘉兴市南湖区科技大道88号', + latitude: 30.7512, + longitude: 120.7588, isSigned: true, contractStart: '2024-01-01', contractEnd: '2026-12-31', @@ -2121,8 +2753,45 @@ var H2_MOCK_STATIONS = [ costUnitPrice: 42.5, customerUnitPrice: 45, costPriceLogs: [ - { id: 'cpl-1-1', operator: '王静', operateTime: '2026-01-10 09:00', beforeCostPrice: null, afterCostPrice: 41.0, effectiveTime: '2026-01-15 00:00' }, - { id: 'cpl-1-2', operator: '系统管理员', operateTime: '2026-05-01 14:20', beforeCostPrice: 41.0, afterCostPrice: 42.5, effectiveTime: '2026-05-05 00:00' } + { id: 'cpl-1-1', operator: '王静', operateTime: '2026-01-10 09:00', beforeCostPrice: null, afterCostPrice: 41.0, effectiveTime: '2026-01-15 00:00', priceType: 'fixed', customerScope: 'all' }, + { id: 'cpl-1-2', operator: '系统管理员', operateTime: '2026-05-01 14:20', beforeCostPrice: 41.0, afterCostPrice: 42.5, effectiveTime: '2026-05-05 00:00', priceType: 'fixed', customerScope: 'all' } + ], + priceConfigs: [ + { + id: 'pc-default', + priceType: 'fixed', + customerScope: 'all', + customerId: null, + customerName: null, + fixedPrice: 42.5, + tierRules: [], + tierResetDay: 1, + tierPeriodVolumeKg: 0, + tierPeriodKey: '2026-06', + logs: [ + { id: 'cpl-1-1', operator: '王静', operateTime: '2026-01-10 09:00', beforeCostPrice: null, afterCostPrice: 41.0, effectiveTime: '2026-01-15 00:00', priceType: 'fixed', customerScope: 'all' }, + { id: 'cpl-1-2', operator: '系统管理员', operateTime: '2026-05-01 14:20', beforeCostPrice: 41.0, afterCostPrice: 42.5, effectiveTime: '2026-05-05 00:00', priceType: 'fixed', customerScope: 'all' } + ] + }, + { + id: 'pc-tier-xf', + priceType: 'tiered', + customerScope: 'customer', + customerId: 'cust-嘉兴市鑫峤供应链科技有限公司', + customerName: '嘉兴市鑫峤供应链科技有限公司', + fixedPrice: null, + tierRules: [ + { id: 'tr-xf-1', thresholdKg: 0, price: 43.0 }, + { id: 'tr-xf-2', thresholdKg: 300, price: 41.5 }, + { id: 'tr-xf-3', thresholdKg: 600, price: 40.0 } + ], + tierResetDay: 1, + tierPeriodVolumeKg: 312.5, + tierPeriodKey: '2026-06', + logs: [ + { id: 'cpl-xf-1', operator: '王静', operateTime: '2026-05-10 10:00', beforeCostPrice: null, afterCostPrice: 43.0, effectiveTime: '2026-05-10 10:00', priceType: 'tiered', customerScope: 'customer', customerName: '嘉兴市鑫峤供应链科技有限公司', tierRulesSummary: '≥0.00kg → 43.00元/kg;≥300.00kg → 41.50元/kg;≥600.00kg → 40.00元/kg' } + ] + } ], prepaidBalance: 85620.0, balanceAlertThreshold: 50000, @@ -3189,6 +3858,9 @@ function h2RecordToEditStationForm(record) { region: (record.region || []).slice(), detail: record.addressDetail || '' }, + mapAddress: record.mapAddress || record.fullAddress || h2BuildFullAddress(record.region, record.addressDetail), + latitude: h2NormalizeCoord(record.latitude), + longitude: h2NormalizeCoord(record.longitude), isSigned: record.isSigned !== false, contractStart: record.contractStart || '', contractEnd: record.contractEnd || '', @@ -3212,6 +3884,9 @@ function h2CreateEmptyStationForm() { return { name: '', address: h2EmptyAddressValue(), + mapAddress: '', + latitude: null, + longitude: null, isSigned: true, contractStart: '', contractEnd: '', @@ -4316,6 +4991,8 @@ function h2CreateFormDirty(station, supplier, supplierMode, linkedSupplierId) { if ((station.contact || '').trim()) return true; if ((station.mobilePhone || '').trim() || (station.landlinePhone || '').trim()) return true; if (station.address && ((station.address.region && station.address.region.length) || (station.address.detail || '').trim())) return true; + if ((station.mapAddress || '').trim()) return true; + if (station.latitude != null || station.longitude != null) return true; if (supplierMode !== 'none') { if ((supplier.signingCompany || '').trim()) return true; if ((supplier.name || '').trim()) return true; @@ -4329,7 +5006,7 @@ function h2CreateFormDirty(station, supplier, supplierMode, linkedSupplierId) { function h2EditFormDirty(station, originForm) { if (!originForm) return h2CreateFormDirty(station, h2CreateEmptySupplierForm(), 'new', undefined); - var keys = ['name', 'contact', 'mobilePhone', 'landlinePhone', 'businessHours', 'isSigned', 'contractStart', 'contractEnd', 'bindAccountMode']; + var keys = ['name', 'contact', 'mobilePhone', 'landlinePhone', 'businessHours', 'isSigned', 'contractStart', 'contractEnd', 'bindAccountMode', 'mapAddress', 'latitude', 'longitude']; var i; for (i = 0; i < keys.length; i++) { if (String(station[keys[i]] || '') !== String(originForm[keys[i]] || '')) return true; @@ -4860,7 +5537,11 @@ function h2BuildStationCreateView(ctx) { col24(formItem('地址解析', false, React.createElement(AddressPasteInput, { inputClassName: inputCls, onParsed: function (parsed) { - ctx.updateStation({ address: { region: parsed.region, detail: parsed.detail } }); + var fullAddr = h2BuildFullAddress(parsed.region, parsed.detail); + ctx.updateStation({ + address: { region: parsed.region, detail: parsed.detail }, + mapAddress: fullAddr + }); if (ctx.supplierMode === 'new' && parsed.region && parsed.region.length >= 2) { ctx.updateSupplier({ city: parsed.region.slice(), @@ -4870,6 +5551,18 @@ function h2BuildStationCreateView(ctx) { } }))) ), + formRow( + col24(formItem('地图定位', false, React.createElement(StationMapPicker, { + inputClassName: inputCls, + address: ctx.station.mapAddress || h2BuildFullAddress(stationAddr.region, stationAddr.detail) || '', + latitude: ctx.station.latitude, + longitude: ctx.station.longitude, + onAddressChange: function (addr) { ctx.updateStation({ mapAddress: addr }); }, + onPositionChange: function (lat, lng) { + ctx.updateStation({ latitude: lat, longitude: lng }); + } + }))) + ), formRow( col8(formItem('联系人', true, React.createElement(Input, { className: inputCls, @@ -5029,6 +5722,7 @@ const Component = function () { var Divider = antd.Divider; var Alert = antd.Alert; var Dropdown = antd.Dropdown; + var Checkbox = antd.Checkbox; var Descriptions = antd.Descriptions; var Cascader = antd.Cascader; var Radio = antd.Radio; @@ -5102,7 +5796,24 @@ const Component = function () { var businessModal = businessModalState[0]; var setBusinessModal = businessModalState[1]; - var priceModalState = useState({ open: false, record: null, costUnitPrice: '', effectiveTime: '', priceLogs: [] }); + var priceModalState = useState({ + open: false, + record: null, + priceConfigs: [], + priceLogs: [], + priceFormVisible: false, + editingConfigId: null, + priceType: 'fixed', + customerScope: 'all', + customerId: null, + customerName: null, + selectedCustomerIds: [], + tierCustomerPickerOpen: false, + costUnitPrice: '', + effectiveTime: '', + tierRules: [], + tierResetDay: 1 + }); var priceModal = priceModalState[0]; var setPriceModal = priceModalState[1]; @@ -5110,6 +5821,10 @@ const Component = function () { var balanceAlertModal = balanceAlertModalState[0]; var setBalanceAlertModal = balanceAlertModalState[1]; + var globalBalanceAlertModalState = useState({ open: false, threshold: '' }); + var globalBalanceAlertModal = globalBalanceAlertModalState[0]; + var setGlobalBalanceAlertModal = globalBalanceAlertModalState[1]; + var rechargeModalState = useState({ open: false, lines: [] }); var rechargeModal = rechargeModalState[0]; var setRechargeModal = rechargeModalState[1]; @@ -5588,6 +6303,9 @@ const Component = function () { region: createStation.address.region || [], addressDetail: (createStation.address.detail || '').trim(), fullAddress: h2BuildFullAddress(createStation.address.region, createStation.address.detail), + mapAddress: (createStation.mapAddress || '').trim() || h2BuildFullAddress(createStation.address.region, createStation.address.detail), + latitude: h2NormalizeCoord(createStation.latitude), + longitude: h2NormalizeCoord(createStation.longitude), isSigned: !!createStation.isSigned, contractStart: createStation.isSigned ? createStation.contractStart : '', contractEnd: createStation.isSigned ? createStation.contractEnd : '', @@ -5809,6 +6527,9 @@ const Component = function () { region: editStation.address.region || [], addressDetail: (editStation.address.detail || '').trim(), fullAddress: h2BuildFullAddress(editStation.address.region, editStation.address.detail), + mapAddress: (editStation.mapAddress || '').trim() || h2BuildFullAddress(editStation.address.region, editStation.address.detail), + latitude: h2NormalizeCoord(editStation.latitude), + longitude: h2NormalizeCoord(editStation.longitude), isSigned: !!editStation.isSigned, contractStart: editStation.isSigned ? editStation.contractStart : '', contractEnd: editStation.isSigned ? editStation.contractEnd : '', @@ -6164,54 +6885,318 @@ const Component = function () { }, [businessModal, closeBusinessModal]); var openPriceConfig = useCallback(function (record) { - setPriceModal({ + var configs = h2GetStationPriceConfigs(record); + setPriceModal(Object.assign({ open: true, record: record, - costUnitPrice: '', - effectiveTime: dayjs ? dayjs().format('YYYY-MM-DD HH:mm') : h2OperateTimestamp(), - priceLogs: (record.costPriceLogs || []).slice() - }); + priceConfigs: configs, + priceLogs: [], + priceFormVisible: false + }, h2EmptyPriceModalForm())); }, []); var closePriceModal = useCallback(function () { - setPriceModal({ open: false, record: null, costUnitPrice: '', effectiveTime: '', priceLogs: [] }); + setPriceModal(Object.assign({ open: false, record: null, priceConfigs: [], priceLogs: [], priceFormVisible: false }, h2EmptyPriceModalForm())); + }, []); + + var loadPriceModalFromConfig = useCallback(function (config) { + var form = h2PriceModalFormFromConfig(config); + setPriceModal(function (m) { + return Object.assign({}, m, form, { + priceLogs: (config && config.logs) ? config.logs.slice() : [], + priceFormVisible: true + }); + }); + }, []); + + var resetPriceModalToNew = useCallback(function () { + setPriceModal(function (m) { + var form = h2EmptyPriceModalForm(); + return Object.assign({}, m, form, { + priceLogs: [], + priceFormVisible: true + }); + }); + }, []); + + var cancelPriceFormEdit = useCallback(function () { + setPriceModal(function (m) { + return Object.assign({}, m, h2EmptyPriceModalForm(), { + priceLogs: [], + priceFormVisible: false + }); + }); + }, []); + + var updateTierRuleField = useCallback(function (ruleId, field, value) { + setPriceModal(function (m) { + return Object.assign({}, m, { + tierRules: (m.tierRules || []).map(function (rule) { + if (rule.id !== ruleId) return rule; + return Object.assign({}, rule, { [field]: value }); + }) + }); + }); + }, []); + + var addTierRuleRow = useCallback(function () { + setPriceModal(function (m) { + return Object.assign({}, m, { tierRules: (m.tierRules || []).concat([h2CreateEmptyTierRule()]) }); + }); + }, []); + + var removeTierRuleRow = useCallback(function (ruleId) { + setPriceModal(function (m) { + var next = (m.tierRules || []).filter(function (rule) { return rule.id !== ruleId; }); + if (!next.length) next = [h2CreateEmptyTierRule()]; + return Object.assign({}, m, { tierRules: next }); + }); }, []); var handleSavePriceConfig = useCallback(function () { if (!priceModal.record) return; - var costText = (priceModal.costUnitPrice || '').trim(); + var priceType = priceModal.priceType || 'fixed'; + var selectedCustomerIds = (priceModal.selectedCustomerIds || []).slice(); var effectiveText = (priceModal.effectiveTime || '').trim(); - if (!costText) { - message.warning('请填写成本价格'); - return; - } - var cost = parseFloat(costText); - if (isNaN(cost) || cost < 0) { - message.warning('请输入有效的成本价格(元/kg)'); - return; - } if (!effectiveText) { message.warning('请选择生效时间'); return; } - var beforePrice = h2ResolveCurrentCostPrice(priceModal.record); - var newLog = h2CreateCostPriceLog(beforePrice, cost, effectiveText, H2_CURRENT_OPERATOR); + if (priceType === 'tiered') { + if (!selectedCustomerIds.length) { + message.warning('请至少选择一个适用客户'); + return; + } + } + + var tierRules = []; + var fixedPrice = null; + var afterPrice = null; + var tierRulesSummary = ''; + + if (priceType === 'fixed') { + var costText = (priceModal.costUnitPrice || '').trim(); + if (!costText) { + message.warning('请填写成本价格'); + return; + } + fixedPrice = parseFloat(costText); + if (isNaN(fixedPrice) || fixedPrice < 0) { + message.warning('请输入有效的成本价格(元/kg)'); + return; + } + afterPrice = fixedPrice; + } else { + var i; + for (i = 0; i < (priceModal.tierRules || []).length; i++) { + var row = priceModal.tierRules[i]; + var thresholdText = String(row.thresholdKg || '').trim(); + var priceText = h2FormatReceiptAmountInput(row.price); + if (!thresholdText && !priceText) continue; + if (!thresholdText) { + message.warning('请填写第 ' + (i + 1) + ' 条阶梯条件的加氢总量值'); + return; + } + if (!priceText) { + message.warning('请填写第 ' + (i + 1) + ' 条阶梯条件的价格'); + return; + } + var thresholdKg = parseFloat(thresholdText); + var tierPrice = parseFloat(priceText); + if (isNaN(thresholdKg) || thresholdKg < 0) { + message.warning('第 ' + (i + 1) + ' 条加氢总量值无效'); + return; + } + if (isNaN(tierPrice) || tierPrice < 0) { + message.warning('第 ' + (i + 1) + ' 条价格无效'); + return; + } + tierRules.push({ id: row.id || h2CreateEmptyTierRule().id, thresholdKg: thresholdKg, price: tierPrice }); + } + if (!tierRules.length) { + message.warning('请至少添加一条阶梯价格条件'); + return; + } + tierRules = h2SortTierRules(tierRules); + tierRulesSummary = h2FormatTierRulesSummary(tierRules); + afterPrice = h2ResolveTierUnitPrice(0, tierRules); + } + var recordId = priceModal.record.id; + var editingId = priceModal.editingConfigId; + var existingConfigs = h2GetStationPriceConfigs(priceModal.record); + var existingCfg = null; + var j; + for (j = 0; j < existingConfigs.length; j++) { + if (existingConfigs[j].id === editingId) { + existingCfg = existingConfigs[j]; + break; + } + } + + var allCustomerOpts = h2CollectCustomerOptions(H2_MOCK_REFUEL_RECORDS); + var customerNameMap = {}; + allCustomerOpts.forEach(function (opt) { customerNameMap[opt.value] = opt.label; }); + + var assignedCustomers = h2GetAssignedCustomerIds(existingConfigs, editingId); + if (selectedCustomerIds.length) { + for (j = 0; j < selectedCustomerIds.length; j++) { + var sid = selectedCustomerIds[j]; + if (assignedCustomers[sid] && !(editingId && existingCfg && existingCfg.customerId === sid)) { + message.warning('客户「' + (customerNameMap[sid] || sid) + '」已有生效规则,请编辑原规则'); + return; + } + } + } + + var configsToSave = []; + if (priceType === 'tiered') { + selectedCustomerIds.forEach(function (cid) { + var isEditTarget = !!(editingId && existingCfg && existingCfg.customerId === cid); + var priorCfg = isEditTarget ? existingCfg : h2FindPriceConfigByCustomerId(existingConfigs, cid, null); + var configId = isEditTarget ? editingId : (priorCfg ? priorCfg.id : h2CreatePriceConfigId()); + var beforePrice = priorCfg ? h2ResolveConfigCurrentPrice(priorCfg) : null; + var cname = customerNameMap[cid] || (priorCfg && priorCfg.customerName) || cid; + var newLog = h2CreateCostPriceLog(beforePrice, afterPrice, effectiveText, H2_CURRENT_OPERATOR, { + priceType: 'tiered', + customerScope: 'customer', + customerName: cname, + tierRules: tierRules.map(function (rule) { return Object.assign({}, rule); }), + tierRulesSummary: tierRulesSummary + }); + configsToSave.push({ + id: configId, + priceType: 'tiered', + customerScope: 'customer', + customerId: cid, + customerName: cname, + fixedPrice: null, + tierRules: tierRules.map(function (rule) { return Object.assign({}, rule); }), + tierResetDay: parseInt(priceModal.tierResetDay, 10) || 1, + tierPeriodVolumeKg: priorCfg && priorCfg.tierPeriodVolumeKg != null ? priorCfg.tierPeriodVolumeKg : 0, + tierPeriodKey: priorCfg && priorCfg.tierPeriodKey ? priorCfg.tierPeriodKey : h2GetTierPeriodKey(priceModal.tierResetDay), + logs: [newLog].concat(priorCfg ? (priorCfg.logs || []) : []) + }); + }); + } else if (selectedCustomerIds.length) { + selectedCustomerIds.forEach(function (cid) { + var isEditTarget = !!(editingId && existingCfg && existingCfg.customerScope === 'customer' && existingCfg.customerId === cid); + var priorCfg = isEditTarget ? existingCfg : h2FindPriceConfigByCustomerId(existingConfigs, cid, null); + var configId = isEditTarget ? editingId : (priorCfg ? priorCfg.id : h2CreatePriceConfigId()); + var beforePriceFixed = priorCfg + ? h2ResolveConfigCurrentPrice(priorCfg) + : h2ResolveCurrentCostPrice(priceModal.record); + var cname = customerNameMap[cid] || (priorCfg && priorCfg.customerName) || cid; + var newLogFixed = h2CreateCostPriceLog(beforePriceFixed, afterPrice, effectiveText, H2_CURRENT_OPERATOR, { + priceType: 'fixed', + customerScope: 'customer', + customerName: cname + }); + configsToSave.push({ + id: configId, + priceType: 'fixed', + customerScope: 'customer', + customerId: cid, + customerName: cname, + fixedPrice: fixedPrice, + tierRules: tierRules, + tierResetDay: parseInt(priceModal.tierResetDay, 10) || 1, + tierPeriodVolumeKg: priorCfg && priorCfg.tierPeriodVolumeKg != null ? priorCfg.tierPeriodVolumeKg : 0, + tierPeriodKey: priorCfg && priorCfg.tierPeriodKey ? priorCfg.tierPeriodKey : h2GetTierPeriodKey(priceModal.tierResetDay), + logs: [newLogFixed].concat(priorCfg ? (priorCfg.logs || []) : []) + }); + }); + } else { + var isAllEdit = !!(editingId && existingCfg && existingCfg.customerScope === 'all'); + var beforePriceAll = existingCfg + ? h2ResolveConfigCurrentPrice(existingCfg) + : h2ResolveCurrentCostPrice(priceModal.record); + var newLogAll = h2CreateCostPriceLog(beforePriceAll, afterPrice, effectiveText, H2_CURRENT_OPERATOR, { + priceType: 'fixed', + customerScope: 'all', + customerName: null + }); + configsToSave.push({ + id: isAllEdit ? editingId : h2CreatePriceConfigId(), + priceType: 'fixed', + customerScope: 'all', + customerId: null, + customerName: null, + fixedPrice: fixedPrice, + tierRules: tierRules, + tierResetDay: parseInt(priceModal.tierResetDay, 10) || 1, + tierPeriodVolumeKg: existingCfg && existingCfg.tierPeriodVolumeKg != null ? existingCfg.tierPeriodVolumeKg : 0, + tierPeriodKey: existingCfg && existingCfg.tierPeriodKey ? existingCfg.tierPeriodKey : h2GetTierPeriodKey(priceModal.tierResetDay), + logs: [newLogAll].concat(existingCfg && existingCfg.customerScope === 'all' ? (existingCfg.logs || []) : []) + }); + } + + var dropEditingConfig = editingId && !configsToSave.some(function (c) { return c.id === editingId; }); + setListData(function (prev) { return prev.map(function (r) { if (r.id !== recordId) return r; - var logs = [newLog].concat(r.costPriceLogs || []); + var configs = h2GetStationPriceConfigs(r); + if (dropEditingConfig) { + configs = configs.filter(function (cfg) { return cfg.id !== editingId; }); + } + configsToSave.forEach(function (savedConfig) { + configs = h2ApplyCustomerExclusion(configs, savedConfig); + var replaced = false; + configs = configs.map(function (cfg) { + if (cfg.id === savedConfig.id) { + replaced = true; + return savedConfig; + } + return cfg; + }); + if (!replaced) configs = configs.concat([savedConfig]); + }); var updated = Object.assign({}, r, { - costPriceLogs: logs, + priceConfigs: configs, updateTime: h2OperateTimestamp() }); - return h2ApplyDueCostPriceToRecord(updated); + return h2SyncRecordPriceFields(h2ApplyDueCostPriceToRecord(updated)); }); }); + + var nextConfigs = h2GetStationPriceConfigs(priceModal.record); + if (dropEditingConfig) { + nextConfigs = nextConfigs.filter(function (cfg) { return cfg.id !== editingId; }); + } + configsToSave.forEach(function (savedConfig) { + nextConfigs = h2ApplyCustomerExclusion(nextConfigs, savedConfig); + var replaced = false; + nextConfigs = nextConfigs.map(function (cfg) { + if (cfg.id === savedConfig.id) { + replaced = true; + return savedConfig; + } + return cfg; + }); + if (!replaced) nextConfigs = nextConfigs.concat([savedConfig]); + }); + var updatedRecord = h2SyncRecordPriceFields(h2ApplyDueCostPriceToRecord(Object.assign({}, priceModal.record, { + priceConfigs: nextConfigs, + updateTime: h2OperateTimestamp() + }))); + setPriceModal(function (m) { + return Object.assign({}, m, h2EmptyPriceModalForm(), { + record: updatedRecord, + priceConfigs: nextConfigs, + priceLogs: [], + priceFormVisible: false + }); + }); + var appliedNow = h2ParseDateTimeMs(effectiveText) <= Date.now(); - message.success(appliedNow ? '价格配置已保存,当前成本价格已更新(原型)' : '价格配置已保存,将于生效时间自动更新(原型)'); - closePriceModal(); - }, [priceModal, closePriceModal]); + var scopeTip = selectedCustomerIds.length + ? '(' + selectedCustomerIds.length + ' 个客户)' + : '(全部客户)'; + message.success(appliedNow + ? '价格配置已保存' + scopeTip + ',当前规则已更新(原型)' + : '价格配置已保存' + scopeTip + ',将于生效时间自动更新(原型)'); + }, [priceModal]); var openBalanceAlertSetting = useCallback(function (record) { var threshold = h2ResolveBalanceAlertThreshold(record); @@ -6252,6 +7237,37 @@ const Component = function () { closeBalanceAlertModal(); }, [balanceAlertModal, closeBalanceAlertModal]); + var openGlobalBalanceAlertSetting = useCallback(function () { + setGlobalBalanceAlertModal({ open: true, threshold: '' }); + }, []); + + var closeGlobalBalanceAlertModal = useCallback(function () { + setGlobalBalanceAlertModal({ open: false, threshold: '' }); + }, []); + + var handleSaveGlobalBalanceAlert = useCallback(function () { + var thresholdText = h2FormatReceiptAmountInput(globalBalanceAlertModal.threshold); + if (!thresholdText) { + message.warning('请填写有效的提醒金额'); + return; + } + var thresholdNum = parseFloat(thresholdText); + if (isNaN(thresholdNum) || thresholdNum <= 0) { + message.warning('提醒金额须大于 0'); + return; + } + setListData(function (prev) { + return prev.map(function (r) { + return Object.assign({}, r, { + balanceAlertThreshold: thresholdNum, + updateTime: h2OperateTimestamp() + }); + }); + }); + message.success('已为全部加氢站保存余额提醒设置(原型)'); + closeGlobalBalanceAlertModal(); + }, [globalBalanceAlertModal, closeGlobalBalanceAlertModal]); + var openRechargeModal = useCallback(function (stations) { var lines = []; if (stations && stations.length) { @@ -7465,27 +8481,134 @@ const Component = function () { }; var costPriceLogColumns = [ - { title: '操作人', dataIndex: 'operator', key: 'operator', width: 100, ellipsis: true }, - { title: '操作时间', dataIndex: 'operateTime', key: 'operateTime', width: 150 }, + { title: '操作人', dataIndex: 'operator', key: 'operator', width: 92, ellipsis: true }, + { title: '操作时间', dataIndex: 'operateTime', key: 'operateTime', width: 138 }, { - title: '调整前成本价格', + title: '定价方式', + dataIndex: 'priceType', + key: 'priceType', + width: 88, + render: function (v) { return h2FormatPriceTypeLabel(v || 'fixed'); } + }, + { + title: '适用客户', + dataIndex: 'customerName', + key: 'customerName', + width: 120, + ellipsis: true, + render: function (v, row) { + return row.customerScope === 'customer' ? (v || '指定客户') : '全部客户'; + } + }, + { + title: '调整前', dataIndex: 'beforeCostPrice', key: 'beforeCostPrice', - width: 130, + width: 96, align: 'right', render: function (v) { return renderPriceConfigMoney(h2FormatCostPriceDisplay(v), 'before'); } }, { - title: '调整后成本价格', + title: '调整后', dataIndex: 'afterCostPrice', key: 'afterCostPrice', - width: 130, - align: 'right', - render: function (v) { return renderPriceConfigMoney(h2FormatCostPriceDisplay(v), 'after'); } + width: 148, + align: 'left', + render: function (v, row) { + if (row.priceType === 'tiered') { + var lines = h2GetTierRuleDisplayLines(row); + if (lines.length) { + return React.createElement('div', { className: 'h2-price-config-after-lines' }, + lines.map(function (line, idx) { + return React.createElement('div', { key: idx, className: 'h2-price-config-after-line' }, + renderPriceConfigMoney(line, 'after') + ); + }) + ); + } + } + return renderPriceConfigMoney(h2FormatCostPriceDisplay(v), 'after'); + } }, - { title: '生效时间', dataIndex: 'effectiveTime', key: 'effectiveTime', width: 150 } + { title: '生效时间', dataIndex: 'effectiveTime', key: 'effectiveTime', width: 138 } ]; + var effectivePriceConfigColumns = useMemo(function () { + return [ + { + title: '适用客户', + key: 'scope', + width: 120, + ellipsis: true, + render: function (_, cfg) { + return h2FormatPriceConfigScopeLabel(cfg); + } + }, + { + title: '定价方式', + key: 'priceType', + width: 96, + render: function (_, cfg) { + var isTiered = cfg.priceType === 'tiered'; + return React.createElement(Tag, { + color: isTiered ? 'purple' : 'processing', + bordered: false, + style: { margin: 0, borderRadius: 6, fontWeight: 600 } + }, h2FormatPriceTypeLabel(cfg.priceType)); + } + }, + { + title: '当前价格', + key: 'price', + width: 220, + render: function (_, cfg) { + var isTiered = cfg.priceType === 'tiered'; + var cfgPrice = h2ResolveConfigCurrentPrice(cfg); + var priceText = cfgPrice != null ? h2FormatYuanNum(cfgPrice) + '元/kg' : '—'; + if (isTiered) { + var gapKg = h2GetTierGapToNextThreshold(cfg); + if (gapKg != null) { + priceText += ',距下个阶梯还差' + h2FormatKgNum(gapKg) + ' kg'; + } + } + return React.createElement('span', { + className: 'h2-price-config-effective-price', + style: { whiteSpace: 'normal', lineHeight: 1.5 } + }, priceText); + } + }, + { + title: '生效时间', + key: 'effectiveTime', + width: 150, + render: function (_, cfg) { + return h2FormatConfigEffectiveDateTime(cfg); + } + }, + { + title: '阶梯重置', + key: 'tierReset', + width: 100, + render: function (_, cfg) { + return cfg.priceType === 'tiered' ? h2GetTierResetTimeLabel(cfg) : '—'; + } + }, + { + title: '操作', + key: 'action', + width: 72, + fixed: 'right', + render: function (_, cfg) { + return React.createElement(Button, { + type: 'link', + size: 'small', + onClick: function () { loadPriceModalFromConfig(cfg); } + }, '修改'); + } + } + ]; + }, [loadPriceModalFromConfig]); + var businessStatusLogColumns = [ { title: '操作时间', dataIndex: 'operateTime', key: 'operateTime', width: 150 }, { title: '操作人', dataIndex: 'operator', key: 'operator', width: 100, ellipsis: true }, @@ -7505,6 +8628,34 @@ const Component = function () { } ]; + var renderCurrentCostPriceCell = function (record) { + var primaryCfg = h2GetPrimaryPriceConfig(record); + var price = h2ResolveCurrentCostPrice(record); + var multiPrice = h2HasMultiCustomerPriceSystems(record); + var isTiered = primaryCfg && primaryCfg.priceType === 'tiered'; + var typeTag = React.createElement(Tag, { + className: 'h2-price-type-tag', + color: isTiered ? 'purple' : 'processing', + bordered: false + }, isTiered ? '阶梯' : '固定'); + var priceNode = price == null + ? React.createElement('span', { style: { color: '#94a3b8' } }, '—') + : React.createElement('span', { + style: { fontVariantNumeric: 'tabular-nums', fontWeight: 600, color: '#0f172a' } + }, '¥' + h2FormatYuanNum(price)); + var warnNode = multiPrice + ? React.createElement(Tooltip, { title: '多个客户存在不同价格体系' }, + React.createElement('span', { + className: 'h2-price-multi-warn', + role: 'img', + 'aria-label': '多个客户存在不同价格体系', + tabIndex: 0 + }, H2_ICONS.none) + ) + : null; + return React.createElement('div', { className: 'h2-current-cost-price-cell' }, typeTag, priceNode, warnNode); + }; + var columns = [ { title: '加氢站名称', @@ -7549,16 +8700,10 @@ const Component = function () { { title: '当前成本价格(元/kg)', key: 'currentCostPrice', - width: 128, + width: 168, align: 'right', render: function (_, r) { - var price = h2ResolveCurrentCostPrice(r); - if (price == null) { - return React.createElement('span', { style: { color: '#94a3b8' } }, '—'); - } - return React.createElement('span', { - style: { fontVariantNumeric: 'tabular-nums', fontWeight: 600, color: '#0f172a' } - }, '¥' + h2FormatYuanNum(price)); + return renderCurrentCostPriceCell(r); } }, { @@ -7750,14 +8895,60 @@ const Component = function () { var record = priceModal.record; var logs = priceModal.priceLogs || []; var stationName = (record && record.name) || '—'; - var currentPrice = record ? h2ResolveCurrentCostPrice(record) : null; - var pendingCount = h2CountPendingCostPriceLogs(logs); - var currentPriceText = currentPrice != null ? h2FormatYuanSymbol(currentPrice, { keepZero: true }) + '/kg' : '—'; - var stats = [ - { key: 'current', label: '当前成本价格', value: currentPriceText, mod: 'current', valueMod: 'current' }, - { key: 'history', label: '历史调整次数', value: String(logs.length), mod: 'history', valueMod: 'history' }, - { key: 'pending', label: '待生效配置', value: String(pendingCount), mod: 'pending', valueMod: 'pending' } - ]; + var priceConfigs = priceModal.priceConfigs || []; + var priceType = priceModal.priceType || 'fixed'; + var assignedCustomerIds = h2GetAssignedCustomerIds(priceConfigs, priceModal.editingConfigId); + var allCustomerOptions = h2CollectCustomerOptions(H2_MOCK_REFUEL_RECORDS); + var selectedCustomerIds = priceModal.selectedCustomerIds || []; + var enabledTierCustomerOptions = allCustomerOptions.filter(function (opt) { + return !assignedCustomerIds[opt.value]; + }); + var enabledSelectedCount = enabledTierCustomerOptions.filter(function (opt) { + return selectedCustomerIds.indexOf(opt.value) >= 0; + }).length; + var tierAllSelected = enabledTierCustomerOptions.length > 0 && enabledSelectedCount === enabledTierCustomerOptions.length; + var tierIndeterminate = enabledSelectedCount > 0 && !tierAllSelected; + + var toggleTierCustomer = function (customerId, checked) { + setPriceModal(function (m) { + var current = (m.selectedCustomerIds || []).slice(); + var next = checked + ? (current.indexOf(customerId) >= 0 ? current : current.concat([customerId])) + : current.filter(function (id) { return id !== customerId; }); + var names = allCustomerOptions.filter(function (opt) { return next.indexOf(opt.value) >= 0; }).map(function (opt) { return opt.label; }); + return Object.assign({}, m, { + selectedCustomerIds: next, + customerScope: next.length ? 'customer' : 'all', + customerId: next.length === 1 ? next[0] : null, + customerName: next.length === 1 ? (names[0] || null) : (names.length ? names.join('、') : null) + }); + }); + }; + + var toggleTierCustomerAll = function (checked) { + setPriceModal(function (m) { + var next = checked ? enabledTierCustomerOptions.map(function (opt) { return opt.value; }) : []; + var names = allCustomerOptions.filter(function (opt) { return next.indexOf(opt.value) >= 0; }).map(function (opt) { return opt.label; }); + return Object.assign({}, m, { + selectedCustomerIds: next, + customerScope: next.length ? 'customer' : 'all', + customerId: next.length === 1 ? next[0] : null, + customerName: next.length === 1 ? (names[0] || null) : (names.length ? names.join('、') : null) + }); + }); + }; + + var tierCustomerTriggerText = selectedCustomerIds.length + ? allCustomerOptions.filter(function (opt) { return selectedCustomerIds.indexOf(opt.value) >= 0; }).map(function (opt) { return opt.label; }).join('、') + : (priceType === 'fixed' && (priceModal.customerScope === 'all' || !priceModal.editingConfigId) ? '全部客户' : ''); + var customerPickerHint = priceType === 'fixed' + ? '默认对全部客户生效;已为某客户单独配置的,将自动从本规则中排除' + : undefined; + var resetDayOptions = []; + var di; + for (di = 1; di <= 28; di++) { + resetDayOptions.push({ value: di, label: '每月 ' + di + ' 日' }); + } var effectiveTimeValue = h2ToDateTimeDayjs(priceModal.effectiveTime); var effectivePicker = dayjs && DatePicker ? React.createElement(DatePicker, { @@ -7789,57 +8980,251 @@ const Component = function () { return React.createElement('div', { className: 'h2-price-config-panel' }, React.createElement('div', { className: 'h2-price-config-station-card' }, - React.createElement('div', { className: 'h2-price-config-station-card__name' }, stationName), - React.createElement('div', { className: 'h2-price-config-station-card__meta' }, '成本价格配置 · 共 ' + logs.length + ' 条记录') - ), - React.createElement('div', { className: 'h2-price-config-stats', role: 'group', 'aria-label': '价格统计' }, - stats.map(function (item) { - return React.createElement('div', { - key: item.key, - className: 'h2-price-config-stat h2-price-config-stat--' + item.mod, - role: 'article', - 'aria-label': item.label + ' ' + item.value - }, - React.createElement('div', { className: 'h2-price-config-stat__label' }, item.label), - React.createElement('div', { - className: 'h2-price-config-stat__value h2-price-config-stat__value--' + item.valueMod - }, item.value) - ); - }) - ), - React.createElement('div', { className: 'h2-price-config-form-wrap' }, - React.createElement(Form, { layout: 'vertical', requiredMark: false }, - formItem('成本价格(元/kg)', true, React.createElement(Input, { - value: priceModal.costUnitPrice, - placeholder: '请输入成本价格', - style: { borderRadius: 8 }, - onChange: function (e) { setPriceModal(function (m) { return Object.assign({}, m, { costUnitPrice: e.target.value }); }); } - })), - formItem('生效时间', true, effectivePicker) - ), - React.createElement('div', { className: 'h2-price-config-form-hint', role: 'note' }, - '到达生效时间后,列表「当前成本价格」将自动更新为调整后价格' + React.createElement('div', { className: 'h2-price-config-station-card__row' }, + React.createElement('div', { className: 'h2-price-config-station-card__name' }, stationName), + h2RenderStationSignedTag(record) ) ), React.createElement('div', { className: 'h2-price-config-table-wrap' }, React.createElement('div', { className: 'h2-price-config-table-head' }, - React.createElement('span', { className: 'h2-price-config-table-head__title' }, '价格调整记录'), - React.createElement('span', { className: 'h2-price-config-table-head__count' }, '共 ' + logs.length + ' 条') + React.createElement('span', { className: 'h2-price-config-table-head__title' }, '当前已生效价格配置'), + React.createElement(Button, { type: 'link', size: 'small', onClick: resetPriceModalToNew, style: { padding: 0, height: 'auto' } }, '+ 新增规则') ), React.createElement(Table, { - className: 'h2-price-config-record-table', + className: 'h2-price-config-record-table h2-price-config-effective-table', size: 'small', bordered: false, rowKey: 'id', - columns: costPriceLogColumns, - dataSource: logs, - pagination: logs.length > 5 ? { pageSize: 5, showSizeChanger: false, size: 'small', showTotal: function (t) { return '共 ' + t + ' 条'; } } : false, - locale: { emptyText: '暂无价格调整记录' }, - scroll: logs.length > 5 ? { x: 680, y: 240 } : { x: 680 } + columns: effectivePriceConfigColumns, + dataSource: priceConfigs, + pagination: priceConfigs.length > 5 ? { pageSize: 5, showSizeChanger: false, size: 'small', showTotal: function (t) { return '共 ' + t + ' 条'; } } : false, + locale: { emptyText: '暂无已生效价格配置,点击「+ 新增规则」创建' }, + scroll: { x: 860 } }) + ), + priceModal.priceFormVisible + ? React.createElement('div', { className: 'h2-price-config-form-wrap' }, + React.createElement('div', { className: 'h2-price-config-form-head' }, + React.createElement('span', { className: 'h2-price-config-form-head__title' }, priceModal.editingConfigId ? '修改价格配置' : '新增价格配置'), + React.createElement(Button, { type: 'link', size: 'small', onClick: cancelPriceFormEdit, style: { padding: 0, height: 'auto' } }, '收起') + ), + React.createElement(Form, { layout: 'vertical', requiredMark: false }, + React.createElement('div', { className: 'h2-price-config-type-row' }, + React.createElement('div', { className: 'h2-price-config-type-row__col' }, + formItem('定价方式', true, React.createElement(Radio.Group, { + value: priceType, + onChange: function (e) { + var nextType = e.target.value; + setPriceModal(function (m) { + var patch = { priceType: nextType, tierCustomerPickerOpen: false }; + if (!(m.selectedCustomerIds || []).length && m.customerScope === 'customer' && m.customerId) { + patch.selectedCustomerIds = [m.customerId]; + } + return Object.assign({}, m, patch); + }); + } + }, + React.createElement(Radio.Button, { value: 'fixed' }, '固定价格'), + React.createElement(Radio.Button, { value: 'tiered' }, '阶梯价格') + )) + ), + React.createElement('div', { className: 'h2-price-config-type-row__col' }, + formItem('适用客户', true, React.createElement(Dropdown, { + trigger: ['click'], + overlayClassName: 'h2-tier-customer-dropdown-overlay', + open: priceModal.tierCustomerPickerOpen, + onOpenChange: function (open) { + setPriceModal(function (m) { return Object.assign({}, m, { tierCustomerPickerOpen: open }); }); + }, + dropdownRender: function () { + return React.createElement('div', { className: 'h2-tier-customer-dropdown', onClick: function (e) { e.stopPropagation(); } }, + React.createElement('div', { + className: 'h2-tier-customer-dropdown__item is-all', + onClick: function () { toggleTierCustomerAll(!tierAllSelected); } + }, + React.createElement(Checkbox, { + checked: tierAllSelected, + indeterminate: tierIndeterminate, + onChange: function (e) { toggleTierCustomerAll(e.target.checked); } + }), + React.createElement('span', null, '全选') + ), + allCustomerOptions.length + ? allCustomerOptions.map(function (opt) { + var disabled = !!assignedCustomerIds[opt.value]; + var checked = selectedCustomerIds.indexOf(opt.value) >= 0; + return React.createElement('div', { + key: opt.value, + className: 'h2-tier-customer-dropdown__item' + (disabled ? ' is-disabled' : ''), + onClick: function () { + if (disabled) return; + toggleTierCustomer(opt.value, !checked); + } + }, + React.createElement(Checkbox, { + checked: checked, + disabled: disabled, + onChange: function (e) { + if (disabled) return; + toggleTierCustomer(opt.value, e.target.checked); + } + }), + React.createElement('span', { title: opt.label }, opt.label), + disabled ? React.createElement('span', { style: { marginLeft: 'auto', fontSize: 11, color: '#94a3b8' } }, '已有规则') : null + ); + }) + : React.createElement('div', { className: 'h2-tier-customer-dropdown__hint' }, '暂无可选客户'), + priceType === 'tiered' + ? React.createElement('div', { className: 'h2-tier-customer-dropdown__hint' }, '同一客户仅可配置一套生效规则;已有规则的客户不可重复选择') + : null + ); + } + }, + React.createElement('div', { + className: 'h2-tier-customer-picker__trigger' + (priceModal.tierCustomerPickerOpen ? ' is-open' : ''), + role: 'button', + tabIndex: 0, + 'aria-haspopup': 'listbox', + 'aria-expanded': !!priceModal.tierCustomerPickerOpen + }, + React.createElement('span', { + className: 'h2-tier-customer-picker__trigger-text' + (tierCustomerTriggerText ? '' : ' is-placeholder') + }, tierCustomerTriggerText || (priceType === 'fixed' ? '全部客户' : '请选择适用客户')), + React.createElement('span', { style: { color: '#94a3b8', fontSize: 12, flexShrink: 0 } }, selectedCustomerIds.length ? ('已选 ' + selectedCustomerIds.length) : '▼') + ) + ), customerPickerHint) + ) + ), + priceType === 'fixed' + ? React.createElement(Form.Item, { + label: React.createElement('span', null, + React.createElement('span', { style: { color: '#ef4444', marginRight: 4 } }, '*'), + '价格配置' + ) + }, + React.createElement('div', { className: 'h2-price-tier-timing-row' }, + React.createElement('div', null, + React.createElement('div', { className: 'h2-price-tier-rule-field' }, + React.createElement('label', null, '成本价格(元/kg)'), + React.createElement(Input, { + value: priceModal.costUnitPrice, + placeholder: '请输入成本价格,保留两位小数', + style: { borderRadius: 8 }, + onChange: function (e) { setPriceModal(function (m) { return Object.assign({}, m, { costUnitPrice: e.target.value }); }); }, + onBlur: function (e) { + var formatted = h2FormatReceiptAmountInput(e.target.value); + if (formatted) setPriceModal(function (m) { return Object.assign({}, m, { costUnitPrice: formatted }); }); + } + }) + ) + ), + React.createElement('div', null, + React.createElement('div', { className: 'h2-price-tier-rule-field' }, + React.createElement('label', null, '生效时间'), + effectivePicker + ) + ) + ) + ) + : React.createElement(Form.Item, { + label: React.createElement('span', null, React.createElement('span', { style: { color: '#ef4444', marginRight: 4 } }, '*'), '阶梯价格条件') + }, + React.createElement('div', { className: 'h2-price-tier-rules' }, + (priceModal.tierRules || []).map(function (rule, idx) { + return React.createElement('div', { key: rule.id, className: 'h2-price-tier-rule-row' }, + React.createElement('div', { className: 'h2-price-tier-rule-field' }, + React.createElement('label', null, '加氢总量 ≥(kg)'), + React.createElement(Input, { + value: rule.thresholdKg, + placeholder: idx === 0 ? '0' : '如 300', + style: { borderRadius: 8 }, + onChange: function (e) { updateTierRuleField(rule.id, 'thresholdKg', e.target.value); } + }) + ), + React.createElement('div', { className: 'h2-price-tier-rule-field' }, + React.createElement('label', null, '成本价格(元/kg)'), + React.createElement(Input, { + value: rule.price, + placeholder: '两位小数', + style: { borderRadius: 8 }, + onChange: function (e) { updateTierRuleField(rule.id, 'price', e.target.value); }, + onBlur: function (e) { + var formatted = h2FormatReceiptAmountInput(e.target.value); + if (formatted) updateTierRuleField(rule.id, 'price', formatted); + } + }) + ), + React.createElement(Button, { + type: 'link', + danger: true, + disabled: (priceModal.tierRules || []).length <= 1, + onClick: function () { removeTierRuleRow(rule.id); }, + style: { marginBottom: 2 } + }, '删除') + ); + }), + React.createElement(Button, { type: 'dashed', block: true, onClick: addTierRuleRow, style: { borderRadius: 8 } }, '+ 新增条件') + ) + ), + priceType === 'tiered' + ? React.createElement(Form.Item, { + label: React.createElement('span', null, + React.createElement('span', { style: { color: '#ef4444', marginRight: 4 } }, '*'), + '阶梯量重置日期 / 生效时间' + ) + }, + React.createElement('div', { className: 'h2-price-tier-timing-row' }, + React.createElement('div', null, + React.createElement('div', { className: 'h2-price-tier-rule-field' }, + React.createElement('label', null, '阶梯量重置日期'), + React.createElement(Select, { + style: { width: '100%' }, + value: priceModal.tierResetDay || 1, + options: resetDayOptions, + onChange: function (val) { + setPriceModal(function (m) { return Object.assign({}, m, { tierResetDay: val }); }); + } + }) + ) + ), + React.createElement('div', null, + React.createElement('div', { className: 'h2-price-tier-rule-field' }, + React.createElement('label', null, '生效时间'), + effectivePicker + ) + ) + ) + ) + : null + ), + priceType === 'tiered' + ? React.createElement('div', { className: 'h2-price-config-form-hint', role: 'note' }, + '保存后,所选客户在生效时间起按阶梯规则计价;每月重置日将清零周期加氢量并重新匹配阶梯单价' + ) + : null ) + : null, + priceModal.priceFormVisible + ? React.createElement('div', { className: 'h2-price-config-table-wrap' }, + React.createElement('div', { className: 'h2-price-config-table-head' }, + React.createElement('span', { className: 'h2-price-config-table-head__title' }, '当前规则调整记录'), + React.createElement('span', { className: 'h2-price-config-table-head__count' }, '共 ' + logs.length + ' 条') + ), + React.createElement(Table, { + className: 'h2-price-config-record-table', + size: 'small', + bordered: false, + rowKey: 'id', + columns: costPriceLogColumns, + dataSource: logs, + pagination: logs.length > 5 ? { pageSize: 5, showSizeChanger: false, size: 'small', showTotal: function (t) { return '共 ' + t + ' 条'; } } : false, + locale: { emptyText: '暂无价格调整记录' }, + scroll: logs.length > 5 ? { x: 920, y: 240 } : { x: 920 } + }) + ) + : null ); - }, [priceModal, costPriceLogColumns]); + }, [priceModal, costPriceLogColumns, effectivePriceConfigColumns, loadPriceModalFromConfig, resetPriceModalToNew, cancelPriceFormEdit, addTierRuleRow, removeTierRuleRow, updateTierRuleField]); var getRechargeStationOptions = useCallback(function (currentLineId) { var usedIds = {}; @@ -8187,6 +9572,13 @@ const Component = function () { React.createElement('div', { className: 'lc-table-section' }, React.createElement('div', { className: 'lc-table-toolbar' }, React.createElement('div', { className: 'lc-table-toolbar-actions' }, + React.createElement(Button, { + type: 'default', + icon: H2_ICONS.none, + style: { borderRadius: 8, fontWeight: 600, borderColor: '#f59e0b', color: '#d97706' }, + onClick: openGlobalBalanceAlertSetting, + 'aria-label': '全站余额提醒设置' + }, '全站余额提醒设置'), React.createElement(Button, { type: 'default', icon: H2_ICONS.upload, @@ -8477,12 +9869,14 @@ const Component = function () { title: '价格配置', open: priceModal.open, onCancel: closePriceModal, - footer: React.createElement(Space, null, - React.createElement(Button, { onClick: closePriceModal, style: { borderRadius: 8 } }, '取消'), - React.createElement(Button, { type: 'primary', onClick: handleSavePriceConfig, style: H2_PRIMARY_BTN_STYLE }, '保存') - ), + footer: priceModal.priceFormVisible + ? React.createElement(Space, null, + React.createElement(Button, { onClick: cancelPriceFormEdit, style: { borderRadius: 8 } }, '取消'), + React.createElement(Button, { type: 'primary', onClick: handleSavePriceConfig, style: H2_PRIMARY_BTN_STYLE }, '保存') + ) + : React.createElement(Button, { onClick: closePriceModal, style: H2_PRIMARY_BTN_STYLE }, '关闭'), centered: true, - width: 820, + width: 920, destroyOnClose: true, styles: { body: { maxHeight: '78vh', overflow: 'auto' } } }, @@ -8550,6 +9944,60 @@ const Component = function () { : null ), + React.createElement(Modal, { + title: '全站余额提醒设置', + open: globalBalanceAlertModal.open, + onCancel: closeGlobalBalanceAlertModal, + footer: React.createElement(Space, null, + React.createElement(Button, { onClick: closeGlobalBalanceAlertModal, style: { borderRadius: 8 } }, '取消'), + React.createElement(Button, { type: 'primary', onClick: handleSaveGlobalBalanceAlert, style: H2_PRIMARY_BTN_STYLE }, '保存') + ), + centered: true, + width: 480, + destroyOnClose: true + }, + React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 14 } }, + React.createElement('div', { + style: { + padding: '12px 14px', + borderRadius: 10, + background: '#f8fafc', + border: '1px solid #e2e8f0', + fontSize: 13, + color: '#475569', + lineHeight: 1.55 + } + }, + React.createElement('div', { style: { fontWeight: 700, color: '#0f172a', marginBottom: 4 } }, '全部加氢站'), + '将对全部 ', + React.createElement('strong', { style: { color: '#0f172a', fontVariantNumeric: 'tabular-nums' } }, listData.length), + ' 个加氢站统一设置余额提醒阈值。当某站点预付余额低于所设金额时,该站点将列入「预付余额预警站点」。' + ), + React.createElement(Form, { layout: 'vertical', requiredMark: false }, + React.createElement(Form.Item, { + label: React.createElement('span', null, React.createElement('span', { style: { color: '#ef4444', marginRight: 4 } }, '*'), '提醒金额(元)') + }, + React.createElement(Input, { + className: 'h2-statement-receipt-amount-input', + inputMode: 'decimal', + prefix: '¥', + style: { width: '100%', borderRadius: 8 }, + placeholder: '请输入提醒金额', + value: globalBalanceAlertModal.threshold || '', + onChange: function (e) { + var next = h2SanitizeReceiptAmountInput(e.target.value); + setGlobalBalanceAlertModal(function (m) { return Object.assign({}, m, { threshold: next }); }); + }, + onBlur: function (e) { + var formatted = h2FormatReceiptAmountInput(e.target.value); + setGlobalBalanceAlertModal(function (m) { return Object.assign({}, m, { threshold: formatted }); }); + } + }) + ) + ) + ) + ), + React.createElement(Modal, { className: 'h2-recharge-modal', title: '发起充值单',