Compare commits

...

29 Commits

Author SHA1 Message Date
Willie
7d07ffa758 vault backup: 2026-05-26 01:23:19 2026-05-26 01:23:19 +08:00
Willie
42c135a5cf billing 子模块 · 轮 2:16 场景 + 知识地图收尾
写 16 个场景到 prop-acc/scenarios/billing/,覆盖 5 类业务:

📝 账单创建(3):
- create-periodic-property-fee(月度物业费 300 户批量,SkipExisting 默认策略)
- create-meter-bill-auto(抄表自动生成计量账单,与 meter pipeline 衔接)
- create-single-bill-manual(电梯维修分摊 / 罚款 / 跨期补开 三典型情境)

💰 收款(3):
- collect-payment-single(单张收款,Modal + 数据示例 + 部分付场景)
- collect-payment-batch(同业户多账单一次收款,1 个 CO + N 个 COBill)
- collect-via-prepaid-auto(billing × prepaid 跨模块联动,fund_source=prepaid)

✂️ 账单调整(3):
- split-bill(房东/租户分摊场景,SplitBillAction 全转/部分拆模式)
- suspend-bill(纠纷/失联 SuspendBillAction,与 Unpaid/Partial 状态守护)
- resume-bill(智能恢复 Unpaid / Partial,suspend_history 数组设计)

🗑️ 删除 / 作废(3):
- delete-bill-unpaid(物理删 canBeDeleted=Unpaid+无付款,activitylog 留 bill_no)
- void-paid-bill(Partial 作废可用 + Paid 作废需手工/tinker 流程的局限)
- bulk-delete-batch-mistake(智能批删 Modal 三档分类 + 完整 activitylog 实战)

🛡️ 异常 / 审计(4):
- exception-partial-payment(Partial 状态完整生命周期 + 多次补付流水)
- exception-overdue-bills(OverdueBillsListWidget + 分级催收 +
  滞纳金合规 + 服务限制合规边界)
- audit-monthly-billing-vs-collection(收款率核心指标 + SQL 报表 +
  与会计科目映射)
- audit-activitylog-trace(spatie activitylog 实战查询 +
  与 meta JSON 对比 + 跨模块审计 + 法务用途)

每篇结构:典型情境 → 业户/业务/财务/审计视角 → 系统流程(mermaid) →
SQL 报表 / 数据示例 → 常见问题 → 异常分支 → 相关文档(WikiLinks)。

billing 独特设计在场景中持续强调:
- 6 状态机的状态流转(Unpaid → Partial → Paid;Suspended ↔ Unpaid;Void 终态)
- 多对多关联(CollectionOrderBill 中间表 + allocated_amount 语义)
- 删 vs 作废双轨制(双层守护 + 业务场景区分)
- 智能批删 Modal(预检查三档分类 + 必填原因 + 单条 activitylog 含 affected_bill_nos)
- spatie activitylog 审计(properties JSON 跨模块查询)
- 跨子模块联动(meter → billing,prepaid → billing,billing → CollectionOrder)

收尾:
- prop-acc/maps/billing-knowledge-map.md:16 场景全部 
- prop-acc/maps/knowledge-map.md:billing 行状态改 " 23 篇"
- prop-acc/index.md:同步

billing 子模块完整覆盖:6 概念 + 16 场景 + 1 知识地图 = 23 篇。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 01:21:32 +08:00
Willie
7cdf0ec9a4 vault backup: 2026-05-26 01:18:18 2026-05-26 01:18:18 +08:00
Willie
669d3b4400 vault backup: 2026-05-26 01:13:17 2026-05-26 01:13:17 +08:00
Willie
1bc1884255 vault backup: 2026-05-26 01:08:16 2026-05-26 01:08:16 +08:00
Willie
d3a786ba76 vault backup: 2026-05-26 01:03:15 2026-05-26 01:03:15 +08:00
Willie
82de3396bb vault backup: 2026-05-26 00:58:14 2026-05-26 00:58:14 +08:00
Willie
085de29cc8 billing 子模块 · 轮 1:6 概念 + 知识地图 + 导航更新
写 6 个核心概念到 prop-acc/concepts/billing/:
- bill-six-state-machine:6 状态(Unpaid/Partial/Paid/Suspended/Processing/Void),
  prop-acc 最复杂状态机;canBePaid/canBeDeleted/canBeVoided 守护方法详解
- bill-types-and-sources:周期/计量/临时 三类账单 + sourceable polymorphism
  (MeterReading / 周期任务 / null 三种 source 类型)
- bill-vs-collection-order:Bill(应收)vs CollectionOrder(已收),
  CollectionOrderBill 多对多关联 + allocated_amount 字段语义;
  与 adhoc 的 1:1 关系对比
- periodic-bill-generation:GeneratePeriodicBillsAction 完整流程 +
  BillingMergeStrategy 三种策略(SkipExisting / Merge / Replace)
- delete-vs-void-dual-track:双轨制设计哲学,物理删(Unpaid 无付款)vs
  作废(留状态留审计);issue.md Q6 第一轮修复历史
- smart-bulk-delete-design:智能批量删除 Modal 设计(预检查三档分类 +
  必填原因 + 单条 activitylog 含 affected_bill_nos 数组);
  bill.bulkDelete 独立高敏权限

新建子模块知识地图:
- prop-acc/maps/billing-knowledge-map.md:6 概念入口 + 16 场景预占 +
  跨子模块对比(billing 与 deposit/prepaid/meter 的 6 维度差异)+ 代码索引

更新导航:
- prop-acc/maps/knowledge-map.md:域总图 billing 行链 billing 知识地图,状态 🟡
- prop-acc/index.md:同步

billing 是 prop-acc 的"收款中枢" -- 上游对接 meter/周期任务/手工,
下游对接 CollectionOrder/Receipt,侧链对接 prepaid 抵扣。

下一轮:16 个场景文档(billing/scenarios/),按本知识地图骨架填充。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 00:48:58 +08:00
Willie
5934191115 vault backup: 2026-05-26 00:48:12 2026-05-26 00:48:12 +08:00
Willie
81c09219ea vault backup: 2026-05-26 00:43:11 2026-05-26 00:43:11 +08:00
Willie
7b0e92f5b9 vault backup: 2026-05-26 00:33:10 2026-05-26 00:33:10 +08:00
Willie
92f3c6698e meter 子模块 · 轮 2:14 场景 + 知识地图收尾
写 14 个场景到 prop-acc/scenarios/meter/,覆盖 4 类业务:

📦 表管理(4):
- init-new-community-batch(新社区批量建表 + 初始读数 Excel 导入,
  走 MeterInitializationImporter + BaseImporter chunk rollback)
- register-single-meter(单独新增一张表,陈先生厨房分户表)
- replace-broken-meter(换表场景,旧表 5000 → 新表 -R1 后缀 + initial 5000 继承,
  ReplaceMeterAction 完整流程)
- decommission-without-replacement(退役不换表,3 种典型情境:
  房屋拆除 / 商铺撤店 / 法定年限到)

📊 抄表(4):
- read-single-meter-manual(后台单录,李师傅集抄掉线补抄)
- read-batch-via-excel-import(MeterReadingsImporter + 模板下载流程 +
  双义列名 silent corruption 已知风险)
- read-via-iot-remote-source(集抄系统对接,API + 防重放 + 与 deposit/prepaid 集成)
- read-with-photo-proof(物理表头照片,业户争议时关键凭证)

💰 账单生成(3):
- generate-bill-tiered-pricing(progressive 累进算法完整算例 35 吨水的三段计算,
  对比 full-tier 简陋实现)
- generate-bill-with-multiplier(工业表 multiplier=10 算例 + 抄表员录入注意事项)
- generate-bill-min-max-cap(漏水 max 封顶 + 零用量 min 兜底 + 正常范围三情境)

🛡️ 异常/审计(3):
- exception-high-consumption(HighConsumptionReadingsListWidget 预警 +
  分级处置 + 完整排查流程)
- exception-readings-locked-after-bill(双锁机制下的修正流程,当前手工 +
  未来 VoidBillAction 设计目标态,issue.md Q5 待补)
- audit-meters-needing-reading(MetersNeedingReadingListWidget +
  月度完成率 99% 目标 + 月度报告模板)

每篇结构:典型情境 → 业户/抄表员/业务人员视角 → 系统流程(mermaid)→
对比表 / 算例 → 常见问题 → 异常分支 → 相关文档(WikiLinks)。

meter 模块特性在场景中持续强调:
- 物理硬件维度(非抽象账户)
- 不直接产 Receipt(走 Bill 中转)
- 三层业务分层(Calculator + Service + Action)
- 双锁机制(创建即不可改 + 有 Bill 更严)
- 抄表来源 + 拍照存证 + 集抄对接
- progressive 累进 vs full-tier 简陋实现的设计正确性
- 倍率 + 阶梯 + min/max 三层叠加算法

收尾:
- prop-acc/maps/meter-knowledge-map.md:14 场景全部 ,加完成 callout
- prop-acc/maps/knowledge-map.md:meter 行状态改 " 21 篇"
- prop-acc/index.md:同步

meter 子模块完整覆盖:6 概念 + 14 场景 + 1 知识地图 = 21 篇。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 00:31:08 +08:00
Willie
6bdaa31017 vault backup: 2026-05-26 00:28:09 2026-05-26 00:28:09 +08:00
Willie
495caa2780 vault backup: 2026-05-26 00:23:07 2026-05-26 00:23:07 +08:00
Willie
896265cad6 vault backup: 2026-05-26 00:18:06 2026-05-26 00:18:06 +08:00
Willie
7987adb131 vault backup: 2026-05-26 00:13:05 2026-05-26 00:13:05 +08:00
Willie
293a2d2685 vault backup: 2026-05-25 23:58:02 2026-05-25 23:58:02 +08:00
Willie
898d3a93a7 meter 子模块 · 轮 1:6 概念 + 知识地图 + 导航更新
写 6 个核心概念到 prop-acc/concepts/meter/:
- meter-vs-meter-reading:物理表配置 + 不可变读数流水双对象;与"账户+流水"模式
  对比(主对象有 balance vs 物理硬件配置;直接产 Receipt vs 通过 Bill 中转)
- replacement-chain:replaced_meter_id + 自动 -R1 后缀 + 初始读数继承;
  nextReplacementCode() 算法 + 整链追溯 + ReplaceMeterAction 流程
- multiplier-and-tiered-pricing:倍率(decimal(10,4),工业表 10x/100x)+
  阶梯计价(progressive 累进算法,非 full-tier 简陋实现)+ min/max 封顶
- bill-generation-pipeline:三层分层 Calculator(纯算)→ Service(查费率+找业主+建账)
  → Action(入口);多调用方共用业务层;prop-acc 后续模块的样板
- reading-source-and-photo-proof:MeterReadingSource 2 种(manual/remote)+
  photo_url 拍照存证;业户对账单争议时的凭证依据
- decommission-and-locking:MeterDecommissionReason 5 种 + Reading 双锁机制
  (创建即不可改;有 bill_id 更不可改/删);issue.md Q5 第二轮修复历史

新建子模块知识地图:
- prop-acc/maps/meter-knowledge-map.md:6 概念入口 + 14 场景预占清单 +
  跨子模块对比(meter 与 deposit/prepaid 的核心差异)+ 代码索引

更新导航:
- prop-acc/maps/knowledge-map.md:域总图 meter 行链 meter 知识地图,状态 🟡
- prop-acc/index.md:同步

下一轮:14 个场景文档(meter/scenarios/),按本知识地图骨架填充。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 23:56:41 +08:00
Willie
a7ac2d50b9 vault backup: 2026-05-25 23:53:01 2026-05-25 23:53:01 +08:00
Willie
d67494595e vault backup: 2026-05-25 23:48:00 2026-05-25 23:48:00 +08:00
Willie
28ac7656a5 prepaid 子模块 · 轮 2:16 场景 + 知识地图收尾
写 16 个场景到 prop-acc/scenarios/prepaid/,覆盖 6 类业务:

📥 充值(3):
- deposit-first-time(张阿姨首次充 5000)
- deposit-additional-topup(已有账户追加充值)
- deposit-via-miniapp-pending(小程序在线充值设计意图,待补)

🧹 消费 Consume(4,最核心):
- consume-monthly-property-bill(手动抵扣月物业费)
- consume-multiple-bills-priority(多账单按 due_at 优先级抵扣)
- consume-meter-bill(抵扣计量账单 - 水电费)
- consume-batch-auto-monthly(月初批量自动抵扣 job 设计 + 业务流程,待补)

💰 退款(2):
- refund-full-resident-moveout(业户搬走全额退余,**不自动关账**)
- refund-partial-after-consume(部分退余,余额非零保持 Active)

🧊 冻结/解冻(2):
- freeze-suspected-fraud(疑似欺诈 / 风控冻结)
- unfreeze-after-verification(核实后解冻 = ReactivateAccountAction)

🔒 结清(2):
- close-resident-moveout(业户搬走主动关账,**需手动**与 deposit 不同)
- close-with-zero-balance-decision(余额清零不自动关,业户决定)

🛡️ 异常/审计(3):
- exception-cross-community-consume(跨社区消费三层防御,模型层抛 InvalidArgumentException)
- exception-refund-on-frozen(冻结状态退款三层守护,模型层最严 canOperate)
- audit-low-balance-and-overdue(低余额业户预警 + 逾期账单排查,
  关联 LowBalancePrepaidListWidget + DepositPrepaidDashboard)

每篇结构:典型情境 → 业户视角 → 业务人员视角 → 系统流程(mermaid)→
常见问题 → 异常分支 → 相关文档(WikiLinks)。

prepaid 与 deposit 的核心差异在场景中持续强调:
- 一户一账约束(deposit 不允许跨账户操作的设计)
- 零余额不自动关账(consume / refund 后状态保持 Active)
- 消费走 CollectionType=Bill(账单视角,fund_source=prepaid)
- 没有 ForceClose(纠纷罕见,简化设计)
- 缴款人只能是业户本人(deposit 支持装修公司代缴)

收尾:
- prop-acc/maps/prepaid-knowledge-map.md:16 场景全部 ,加完成 callout
- prop-acc/maps/knowledge-map.md:prepaid 行状态改 " 23 篇"
- prop-acc/index.md:同步

prepaid 子模块完整覆盖:6 概念 + 16 场景 + 1 知识地图 = 23 篇。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 23:39:05 +08:00
Willie
e759ec39ae vault backup: 2026-05-25 23:37:58 2026-05-25 23:37:58 +08:00
Willie
344bd552d1 vault backup: 2026-05-25 23:32:57 2026-05-25 23:32:57 +08:00
Willie
ae134c321d vault backup: 2026-05-25 23:27:56 2026-05-25 23:27:56 +08:00
Willie
f407569771 vault backup: 2026-05-25 23:22:55 2026-05-25 23:22:55 +08:00
Willie
ee7abbfc45 vault backup: 2026-05-25 23:17:54 2026-05-25 23:17:54 +08:00
Willie
867fd3dd24 vault backup: 2026-05-25 23:07:52 2026-05-25 23:07:53 +08:00
Willie
c9b92c6c1b prepaid 子模块 · 轮 1:6 概念 + 知识地图 + 导航更新
写 6 个核心概念到 prop-acc/concepts/prepaid/:
- prepaid-account-vs-transaction:账户+流水双对象,与 deposit 同构但多两条独有约束(一户一账、零余额不自动关账)
- account-state-machine:Active / Frozen / Closed,canOperate 统一守护,与 deposit 差异(零余额行为、无 ForceClose)
- one-account-per-resident:unique(community_id, profile_id) + 跨社区防御(prepaid 独有)
- transaction-types:4 种流水(deposit / consume / refund / adjustment),consume 是最高频
- consume-via-bill-collection-type:Consume 走 CollectionType=Bill 而非 Prepaid 的独特设计,资金来源标 meta.fund_source
- auto-deduction-design:月初批量自动抵扣 scheduled job 的设计意图(代码待补,issue.md Q4 已记录)

新建子模块知识地图:
- prop-acc/maps/prepaid-knowledge-map.md:6 概念入口 + 16 场景预占清单 + 跨域引用 + 代码索引

更新导航:
- prop-acc/maps/knowledge-map.md:域总图 prepaid 行链 prepaid 知识地图,状态 🟡
- prop-acc/index.md:同步

下一轮:16 个场景文档(prepaid/scenarios/),按本知识地图骨架填充。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 23:04:35 +08:00
Willie
b3a8c502e7 vault backup: 2026-05-25 23:02:51 2026-05-25 23:02:51 +08:00
69 changed files with 14655 additions and 46 deletions

View File

@@ -13,12 +13,12 @@
"state": { "state": {
"type": "markdown", "type": "markdown",
"state": { "state": {
"file": "prop-acc/scenarios/deposit/deposit-additional-topup.md", "file": "prop-acc/maps/billing-knowledge-map.md",
"mode": "source", "mode": "source",
"source": false "source": false
}, },
"icon": "lucide-file", "icon": "lucide-file",
"title": "deposit-additional-topup" "title": "billing-knowledge-map"
} }
} }
] ]
@@ -94,7 +94,7 @@
"state": { "state": {
"type": "backlink", "type": "backlink",
"state": { "state": {
"file": "prop-acc/scenarios/deposit/deposit-additional-topup.md", "file": "prop-acc/maps/billing-knowledge-map.md",
"collapseAll": false, "collapseAll": false,
"extraContext": false, "extraContext": false,
"sortOrder": "alphabetical", "sortOrder": "alphabetical",
@@ -104,7 +104,7 @@
"unlinkedCollapsed": true "unlinkedCollapsed": true
}, },
"icon": "links-coming-in", "icon": "links-coming-in",
"title": "Backlinks for deposit-additional-topup" "title": "Backlinks for billing-knowledge-map"
} }
}, },
{ {
@@ -113,12 +113,12 @@
"state": { "state": {
"type": "outgoing-link", "type": "outgoing-link",
"state": { "state": {
"file": "prop-acc/scenarios/deposit/deposit-additional-topup.md", "file": "prop-acc/maps/billing-knowledge-map.md",
"linksCollapsed": false, "linksCollapsed": false,
"unlinkedCollapsed": true "unlinkedCollapsed": true
}, },
"icon": "links-going-out", "icon": "links-going-out",
"title": "Outgoing links from deposit-additional-topup" "title": "Outgoing links from billing-knowledge-map"
} }
}, },
{ {
@@ -156,13 +156,13 @@
"state": { "state": {
"type": "outline", "type": "outline",
"state": { "state": {
"file": "prop-acc/scenarios/deposit/deposit-additional-topup.md", "file": "prop-acc/maps/billing-knowledge-map.md",
"followCursor": false, "followCursor": false,
"showSearch": false, "showSearch": false,
"searchQuery": "" "searchQuery": ""
}, },
"icon": "lucide-list", "icon": "lucide-list",
"title": "Outline of deposit-additional-topup" "title": "Outline of billing-knowledge-map"
} }
}, },
{ {
@@ -197,41 +197,41 @@
}, },
"active": "b06ed69835363258", "active": "b06ed69835363258",
"lastOpenFiles": [ "lastOpenFiles": [
"prop-acc/concepts/prepaid/prepaid-account-vs-transaction.md",
"prop-acc/concepts/prepaid",
"prop-acc/maps/deposit-knowledge-map.md",
"prop-acc/index.md", "prop-acc/index.md",
"prop-acc/concepts/deposit/deposit-vs-adhoc-vs-prepaid.md", "prop-acc/scenarios/prepaid/deposit-first-time.md",
"prop-acc/scenarios/deposit/audit-long-pending-accounts.md", "prop-acc/scenarios/billing/audit-activitylog-trace.md",
"prop-acc/scenarios/deposit/audit-monthly-deposit-balance.md", "prop-acc/scenarios/billing/audit-monthly-billing-vs-collection.md",
"prop-acc/scenarios/deposit/exception-deposit-on-frozen.md", "prop-acc/scenarios/billing/exception-overdue-bills.md",
"prop-acc/scenarios/deposit/force-close-retain.md", "prop-acc/scenarios/billing/exception-partial-payment.md",
"prop-acc/scenarios/deposit/force-close-forfeit.md", "prop-acc/scenarios/billing/bulk-delete-batch-mistake.md",
"prop-acc/scenarios/deposit/force-close-refund.md", "prop-acc/scenarios/billing/void-paid-bill.md",
"prop-acc/scenarios/deposit/close-manual-with-zero-balance.md", "prop-acc/scenarios/billing/delete-bill-unpaid.md",
"prop-acc/scenarios/deposit/close-after-zero-balance.md", "prop-acc/scenarios/billing/resume-bill.md",
"prop-acc/scenarios/deposit/unfreeze-after-mediation.md", "prop-acc/scenarios/billing/suspend-bill.md",
"prop-acc/scenarios/deposit/freeze-during-dispute.md", "prop-acc/scenarios/billing/split-bill.md",
"prop-acc/scenarios/deposit/forfeit-violation-no-permit.md", "prop-acc/scenarios/billing/collect-via-prepaid-auto.md",
"prop-acc/scenarios/deposit/forfeit-damage-public-area.md", "prop-acc/scenarios/billing/collect-payment-batch.md",
"prop-acc/scenarios/deposit/deposit-first-time-renovation.md", "prop-acc/scenarios/billing/collect-payment-single.md",
"prop-acc/scenarios/deposit/refund-with-payment-channel-switch.md", "prop-acc/scenarios/billing/create-single-bill-manual.md",
"prop-acc/scenarios/deposit/refund-partial-after-forfeit.md", "prop-acc/scenarios/billing/create-meter-bill-auto.md",
"prop-acc/scenarios/deposit/refund-full-no-damage.md", "prop-acc/scenarios/billing/create-periodic-property-fee.md",
"prop-acc/scenarios/deposit/deposit-additional-topup.md", "prop-acc/scenarios/billing",
"prop-acc/concepts/adhoc/collection-order-and-receipt.md", "prop-acc/maps/billing-knowledge-map.md",
"prop-acc/scenarios/deposit/deposit-on-behalf-by-company.md", "prop-acc/concepts/billing/smart-bulk-delete-design.md",
"prop-acc/concepts/billing/delete-vs-void-dual-track.md",
"prop-acc/concepts/billing/periodic-bill-generation.md",
"prop-acc/concepts/billing/bill-vs-collection-order.md",
"prop-acc/concepts/billing/bill-types-and-sources.md",
"prop-acc/concepts/billing/bill-six-state-machine.md",
"prop-acc/concepts/billing",
"prop-acc/scenarios/meter/audit-meters-needing-reading.md",
"prop-acc/scenarios/meter",
"prop-acc/concepts/meter",
"prop-acc/scenarios/prepaid",
"prop-acc/concepts/prepaid",
"prop-acc/scenarios/deposit", "prop-acc/scenarios/deposit",
"prop-acc/concepts/deposit/red-receipt-design.md",
"prop-acc/concepts/deposit/transaction-types.md",
"prop-acc/concepts/deposit/payer-types.md",
"prop-acc/concepts/deposit", "prop-acc/concepts/deposit",
"prop-acc/scenarios/adhoc", "prop-acc/scenarios/adhoc",
"prop-acc/concepts/adhoc", "prop-acc/concepts/adhoc"
"resident-portal/scenarios",
"resident-portal/reference",
"resident-portal/procedures",
"resident-portal/maps",
"resident-portal/glossary"
] ]
} }

View File

@@ -0,0 +1,222 @@
---
title: prop-acc · billing · 账单六状态机
aliases:
- 账单六状态机
- Bill 状态机
- BillStatus
- 6 个账单状态
tags:
- 概念
- prop-acc
- 账单
- 状态机
- 核心概念
audience:
- 业务人员
- 财务
- 架构师
status: 已发布
sub_feature: billing
last_review: 2026-05-26
code_version: 2026-05-22
---
# 账单六状态机
`Bill` 是 prop-acc 状态机**最复杂**的子模块,有 **6 个状态**:**Unpaid / Partial / Paid / Suspended / Processing / Void**。比 deposit / prepaid / meter 的 3-4 态都细,反映账单从生成到关闭的全生命周期 + 异常处置路径。
## 6 状态速查
| 状态 | 中文 | 何时进入 | 能做什么 |
|---|---|---|---|
| `Unpaid` | 未付 | 账单刚创建 | 收款 / 拆单 / 挂起 / 删 / 作废 |
| `Partial` | 部分付 | 收到一部分钱(小于账单金额) | 继续收款 / 作废(已收的需退) |
| `Paid` | 已付 | 收齐全部金额 | 作废(走退款) |
| `Suspended` | 挂起 | 业务人员主动暂停(纠纷 / 失联) | 恢复 / 作废 |
| `Processing` | 处理中 | 收款流程中(罕见,如银行扣款排队)| 等回调 |
| `Void` | 作废 | 主动 void | 只读,审计可查 |
## 状态机图
```mermaid
stateDiagram-v2
[*] --> Unpaid : 创建
Unpaid --> Partial : 部分收款
Unpaid --> Paid : 全额收款
Unpaid --> Suspended : suspend(纠纷)
Unpaid --> Void : void
Unpaid --> [*] : 物理删除(无付款关联)
Partial --> Partial : 继续收款(仍未付清)
Partial --> Paid : 收齐
Partial --> Suspended : suspend
Partial --> Void : void(已收部分需退)
Paid --> Void : void(已收全额需退)
Suspended --> Unpaid : resume(纠纷解决)
Suspended --> Partial : resume(回到部分付状态,若之前有付款)
Suspended --> Void : void
Processing --> Paid : 收款回调成功
Processing --> Unpaid : 收款回调失败
Void --> [*]
```
## 守护方法(模型层)
`Bill` 模型上的辅助方法:
| 方法 | 返回 true 的状态 | 用途 |
|---|---|---|
| `canBePaid()` | Unpaid / Partial | CollectPaymentAction 准入 |
| `canBeIssued()` | (用于"发出账单"流程,看实现) | — |
| `isVoid()` | Void | UI 灰化判断 |
| `isPaid()` | Paid | 报表 / 收款率统计 |
| `hasAnyPayment()` | 有任何 CollectionOrderBill / prepaid_transaction 关联 | 决定能否物理删 |
| `canBeDeleted()` | Unpaid + 无任何付款关联 | DeleteAction 守护 |
| `canBeVoided()` | 非 Paid 非 Void | VoidBillAction 守护 |
> [!warning] `canBeVoided` 的微妙之处
> Paid 状态**也可以走 void**(已付全额作废 → 退款),但 `canBeVoided()` 返回 false。原因:
> - 已付账单的作废需要**走退款流程**,不是单纯改状态
> - 简单的 `VoidBillAction` 只翻状态 + 留 meta,**不处理钱**
> - 已付的作废应该走专门的"作废 + 退款"组合流程(类似 [[../meter/exception-readings-locked-after-bill|计量账单修正]])
>
> 即:`canBeVoided()` 检查的是"能否走简单作废",不是"能否所有方式作废"。
## 状态转换详解
### Unpaid → Partial(部分收款)
业户付了 ¥300 给 ¥800 的账单:
```
- Bill.status: Unpaid → Partial
- 建 CollectionOrderBill(allocated_amount=300)
- 关联 CollectionOrder(actual_amount=+300)
- Bill.paid_amount(若有此字段) = 300
- 余 500 待付
```
### Partial → Paid(收齐)
业户再付 ¥500:
```
- Bill.status: Partial → Paid
- 建第 2 条 CollectionOrderBill(allocated_amount=500)
- Bill.paid_amount = 800(=账单金额)
- 收齐
```
### Unpaid → Suspended(挂起)
业户与物业纠纷 / 业户失联:
```
- Bill.status: Unpaid → Suspended
- 业务人员走 SuspendBillAction
- 记 suspend_reason + suspended_at(meta)
- 后续 CollectPaymentAction 守护拒绝(canBePaid=false)
```
### Suspended → Unpaid(恢复)
纠纷解决:
```
- Bill.status: Suspended → Unpaid(或回到 Partial,若之前有付款)
- 业务人员走 ResumeBillAction
- 记 resume_reason + resumed_at
- 后续 CollectPaymentAction 重新可用
```
### Unpaid → Void(作废)
误开的账单,业户没付:
```
- Bill.status: Unpaid → Void
- VoidBillAction 守护通过(canBeVoided=true)
- 记 voided_reason + voided_at + voided_by
- 业户无影响(没付钱)
```
### Paid → Void(已付作废)
走专门的"作废 + 退款"流程(未来扩展):
```
- 当前 VoidBillAction **不允许**(canBeVoided=false for Paid)
- 需要走类似 meter 的修正流程
- 后续若实施 RefundBillAction:作废 + 退款一次性
```
### Unpaid → 物理删除(误建立刻删)
```
- 状态层面消失(从数据库删除)
- 走 DeleteAction
- 守护:canBeDeleted = Unpaid + 无任何付款关联
- 留 activitylog
```
详见 [[delete-vs-void-dual-track]]。
## 业务人员视角
后台账户列表的状态列对应这 6 个值:
- 🟢 **Unpaid**:绿色,可操作(收款 / 拆 / 挂起 / 删 / 作废)
- 🟡 **Partial**:黄色,部分付,可继续收款
-**Paid**:绿色对号,已完成,无操作
- 🧊 **Suspended**:灰色雪花,挂起,只能恢复 / 作废
-**Processing**:旋转图标,处理中(等回调)
-**Void**:红色叉,已作废,只读
## 业户视角
业户感受到的:
| 状态 | 业户感知 |
|---|---|
| Unpaid | 收到"账单 ¥800 待付"通知 |
| Partial | 看到"已付 ¥300,还差 ¥500"|
| Paid | 收到"已付清"通知 + 收据 |
| Suspended | (通常通知)"账单挂起,事由 XXX,请联系物业" |
| Processing | 几乎不感知(过渡态)|
| Void | 收到"账单已作废,理由 XXX" |
## 与其他模块状态机的对比
| 模块 | 状态数 | 主要状态 |
|---|---|---|
| deposit | 3 | Active / Frozen / Closed |
| prepaid | 3 | Active / Frozen / Closed |
| meter(`is_active`)| 2 | active / inactive |
| **bill** | **6** | **Unpaid / Partial / Paid / Suspended / Processing / Void** |
bill 的复杂度反映**收款全流程**:从应收到已收,中间有挂起、部分付、回调中等多种异常路径。
## 历史:Policy 修复
> [!info] issue.md Q6 第一轮发现的漏洞
> 原 `BillPolicy` 几乎是空壳(只有 `getPermissionPrefix`),所有授权全靠 `AbstractPolicy` 默认(只查权限,**不查状态**)。
>
> 修复后补 7 个方法:`update` / `delete` / `deleteAny` / `void` / `collect` / `suspend` / `resume`。每个方法都加了**状态守护**(canBeDeleted / canBeVoided 等)。
>
> 详见 [[delete-vs-void-dual-track]] 和 [[smart-bulk-delete-design]]。
## 相关文档
- [[bill-types-and-sources]]
- [[bill-vs-collection-order]]
- [[delete-vs-void-dual-track]]
- [[smart-bulk-delete-design]]
- [[collect-payment-single]]
- [[suspend-bill]]
- [[void-paid-bill]]
- [[../meter/decommission-and-locking]](类似的"状态机+守护"对比)

View File

@@ -0,0 +1,220 @@
---
title: prop-acc · billing · 账单类型与来源
aliases:
- 账单类型
- 账单来源
- BillType
- sourceable polymorphism
- 周期账单 vs 计量账单
tags:
- 概念
- prop-acc
- 账单
- 数据模型
audience:
- 业务人员
- 财务
- 架构师
status: 已发布
sub_feature: billing
last_review: 2026-05-26
code_version: 2026-05-22
---
# 账单类型与来源
`Bill` 可以由**多种业务源**产生:周期任务(月度物业费)、抄表事件(水电费)、手工建单(临时收费)。系统通过 **`sourceable` 多态关联**统一存储,通过 **`BillType` 枚举**区分类型。
## 三种主要账单类型
### 1. 周期账单(Periodic)
**业务**:按固定周期(月 / 季 / 年)自动生成的账单。最典型:物业费、停车费、有线电视费。
**生成方式**:`GeneratePeriodicBillsAction`(批量,详见 [[periodic-bill-generation]])。
**源**:无直接 sourceable(由周期任务批量生成,不指向具体 reading / event)。
**特点**:
- 金额固定(由 RatePlan + 房屋面积 / 停车位等参数算)
- 期次明确(`billing_period_start / end`)
- 批量生成(一户一张,或合并)
### 2. 计量账单(Meter-based)
**业务**:抄表产生的账单。最典型:水费、电费、燃气费。
**生成方式**:`GenerateBillsFromMeterReadingsAction`([[../meter/bill-generation-pipeline]])。
**源**:`sourceable_type = MeterReading`,`sourceable_id = reading.id`
**特点**:
- 金额浮动(按用量算)
- 一抄表 → 一 Bill 行
- 由 [[../meter/multiplier-and-tiered-pricing|阶梯+倍率+min/max]] 三层算法决定金额
### 3. 临时账单(Manual / Ad-hoc)
**业务**:不定期的临时收费,业务人员手工建单。例如:维修费分摊、特别活动费、单次罚款。
**生成方式**:`CreateBill` Filament 页面手工建。
**源**:无 sourceable(或自定义 sourceable_type)。
**特点**:
- 金额手填
- 单次建单
- 适合"系统不知道怎么算"的特殊场景
## `BillType` 枚举
> [!info] 实际 BillType 枚举值看代码
> 当前 `packages/prop-acc/src/Enums/BillType.php` 的具体枚举值需查代码。常见可能:
>
> - `Periodic`(周期)
> - `Meter`(计量)
> - `OneTime`(一次性 / 临时)
> - `Adjustment`(调整,罕见)
>
> 类型字段用于:报表分类、UI 过滤、业务逻辑分支(例如周期账单的合并策略只对 Periodic 类型生效)。
## Sourceable Polymorphism
Bill 表用 Laravel 多态关联存"源":
```
Bill
├── sourceable_type: enum 'App\Models\MeterReading' / 'App\Jobs\GeneratePeriodicBills' / null
├── sourceable_id: 关联记录的 ID
└── (other fields)
```
### 类型对照
| Bill.sourceable_type | Bill.sourceable_id | 业务来源 |
|---|---|---|
| `MeterReading` | reading.id | 计量账单 |
| `PeriodicBillingTask`(假设) | task.id | 周期账单(若设计为指向任务记录)|
| `null` | null | 手工建单 / 周期任务但不挂任务表 |
| (其他业务可扩展) | (其他)| 未来新业务可加 |
### 多态的好处
| 好处 | 说明 |
|---|---|
| **可追溯** | Bill 上可以查到"这条 Bill 是哪条 reading 生成的" |
| **可反查** | reading 上有 `bill_id`,Bill 上有 `sourceable_*`,双向 |
| **统一接口** | 各业务源都用同样的 Bill 表,不用为每种源单独建表 |
| **扩展友好** | 未来新业务(如"维修工单产生账单")只需:写新 Action + 设 `sourceable_type='WorkOrder'`,不改 Bill 表结构 |
### 多态的代价
- ORM 查询稍复杂(需 morphTo 关系)
- 关联完整性靠应用层(数据库无强制外键到具体表)
- 调试时要看 sourceable_type 才知道是哪种业务
## 账单核心字段(简表)
| 字段 | 含义 |
|---|---|
| `id` | 账单 ID |
| `bill_no` | 账单编号(对外显示,如 `B-202605-501-001`)|
| `community_id` | 所属物业项目 |
| `asset_id` | 关联房屋 |
| `resident_id` | 账单收方业户 |
| `fee_type_id` | 费用类型(物业费 / 水费 / 电费 / ...)|
| **`bill_type`** | **BillType 枚举(Periodic / Meter / ...)** |
| `amount` | 账单金额 |
| `paid_amount` | 已付金额(可能字段名不同)|
| **`status`** | **BillStatus(6 种,见 [[bill-six-state-machine]])** |
| `billing_period_start` / `billing_period_end` | 期次(周期账单用)|
| `due_at` | 到期日 |
| **`sourceable_type`** / **`sourceable_id`** | **多态源关联** |
| `meta` | JSON 扩展(suspend_reason / voided_reason 等审计字段)|
## 业务人员视角
后台账单列表(`BillsTable`)列:
| 列 | 内容 |
|---|---|
| 账单号 | bill_no |
| 业户 | resident.name |
| 房号 | asset.code |
| 费用类型 | fee_type.name(物业费 / 水费 / ...)|
| **类型(BillType)** | 周期 / 计量 / 临时 |
| 期次 | period_start ~ period_end |
| 金额 | amount |
| 状态 | status(6 状态图标)|
| 操作 | 收款 / 拆 / 挂起 / 作废 / 删 |
可按 BillType 过滤:看本月所有"周期账单"或所有"计量账单"。
## 业户视角
业户**通常不关心账单类型**,只看到:
```
我的账单
5月 物业费 ¥800 ✅ 已付
5月 水费 ¥54 待付
5月 电费 ¥168 待付
5月 燃气 ¥30 待付
```
每条对应一个 Bill,业户不关心是周期还是计量,只关心"该付多少 / 付了没"。
## 与 CollectionOrder 的关系
Bill 是**应收应付**(应该收的钱),CollectionOrder 是**已收**(实际收到的钱)。两者多对多关联(CollectionOrderBill 中间表)。
详见 [[bill-vs-collection-order]]。
## 与其他子模块的协作
| 业务模块 | 与 billing 的关系 |
|---|---|
| **meter** | 抄表 → 生成计量 Bill(sourceable=MeterReading)|
| **prepaid** | 业户预存款抵 Bill(consume,Bill 状态 Unpaid → Paid)|
| **deposit** | 通常不抵 Bill(押金是代管资金,不主动抵)|
| **adhoc** | AdHocEvent 与 Bill 是兄弟概念(都产 CollectionOrder),但不互相关联 |
billing 是 prop-acc 的**收款中枢** —— 各种"该收的钱"都先变成 Bill,然后通过 CollectionOrder 完成收款。
## 常见问题
> [!question] 周期账单和计量账单的 BillType 一定区分吗?
> 业务上**应该区分**。理由:
>
> - 报表分类(物业费收入 vs 水电费收入)
> - 业务逻辑(周期账单可合并,计量账单不合并)
> - 业户感知(账单上区分类型,业户更明白)
> [!question] sourceable 为 null 的 Bill 怎么追溯来源?
> 看 `created_by`(操作员)+ `created_at`(时间)+ `meta`(备注)。手工建单可在 memo 写清缘由。
> [!question] 同一抄表 reading 多次生成 Bill 会怎样?
> 不会发生(理论上):
> - Reading 上有 `bill_id` 字段,有值表示已关联 Bill
> - `GenerateBillsFromMeterReadingsAction` 校验 `bill_id === null`(已有 Bill 跳过)
>
> 但若数据手工乱改 → 可能 Bill 重复 → reading 的 bill_id 会被新建的覆盖,留下"孤儿 Bill"。需运维清理。
> [!question] 一个 Bill 关联多个 reading 可以吗?
> 当前 `sourceable_*` 是 1:1(一个 Bill 对应一个 source)。如果业务上一户家有 2 张水表合并出账(罕见):需要扩展(新建中间表 BillReading,或者改 Bill schema 加 child reading 字段)。issue.md 未明确,可作为未来扩展点。
> [!question] 临时账单的 sourceable_type 应该是什么?
> `null` 或自定义类型。当前实现倾向 `null`(简单)。如果未来要"临时账单"也有追溯链(例如关联到具体维修工单),可以扩展为 `sourceable_type='WorkOrder'`。
## 相关文档
- [[bill-six-state-machine]]
- [[bill-vs-collection-order]]
- [[periodic-bill-generation]]
- [[delete-vs-void-dual-track]]
- [[create-periodic-property-fee]]
- [[create-meter-bill-auto]]
- [[create-single-bill-manual]]
- [[../meter/bill-generation-pipeline]]
- [[../prepaid/consume-via-bill-collection-type]]

View File

@@ -0,0 +1,265 @@
---
title: prop-acc · billing · Bill 与 CollectionOrder 的关系
aliases:
- Bill vs CollectionOrder
- CollectionOrderBill
- 应收 vs 已收
- 账单与收款单
tags:
- 概念
- prop-acc
- 账单
- 核心概念
audience:
- 业务人员
- 财务
- 架构师
status: 已发布
sub_feature: billing
last_review: 2026-05-26
code_version: 2026-05-22
---
# Bill 与 CollectionOrder 的关系
`Bill`(应收 / 应付)和 `CollectionOrder`(已收)是 prop-acc 的**两个核心对象**:
- **Bill** = "这是业户该付的钱"(应收账款)
- **CollectionOrder** = "业户实际付了多少 / 怎么付的"(已收记录)
两者通过 **`CollectionOrderBill` 中间表多对多关联** —— 一张 Bill 可能由多笔 CollectionOrder 凑齐(部分付);一笔 CollectionOrder 可能付多张 Bill(批量收款)。
## 一对多 vs 多对多
### 错例:一对一
```
Bill ─── CollectionOrder
```
业务上**不够用**:
- 业户付一半 → 怎么记?
- 业户一次付 3 张账单 → 怎么记?
- 一张大账单分两次付 → 怎么记?
### 正例:多对多(本设计)
```
Bill ─── CollectionOrderBill ─── CollectionOrder
M ───────── N ───────── M ───────── N
```
- 1 个 Bill 可对应多个 CollectionOrderBill(一张账单分两次付)
- 1 个 CollectionOrder 可对应多个 CollectionOrderBill(一次收款付多张账单)
- `CollectionOrderBill` 中间表存**分配金额**(`allocated_amount`)
## `CollectionOrderBill` 中间表
| 字段 | 含义 |
|---|---|
| `id` | 主键 |
| `collection_order_id` | 关联收款单 |
| `bill_id` | 关联账单 |
| **`allocated_amount`** | **本次分配给该账单的金额**(可以小于账单金额 = 部分付)|
| `created_at` | 关联时间 |
> [!info] allocated_amount 是关键
> 这个字段决定"这笔收款付了这张账单多少",而不是"账单全付了"。例如:
>
> - 业户付 ¥1,000 想分摊到 3 张账单(¥300 + ¥400 + ¥300)
> - 建 1 个 CollectionOrder(¥1,000)
> - 建 3 个 CollectionOrderBill(分别 allocated_amount 300/400/300,关联各自的 Bill)
> - 3 张 Bill 的 paid_amount 累加各自的 allocated_amount
## 完整模型关系图
```mermaid
erDiagram
Bill ||--o{ CollectionOrderBill : "1:N"
CollectionOrderBill }o--|| CollectionOrder : "N:1"
Bill {
id PK
bill_no
amount
paid_amount
status
}
CollectionOrderBill {
id PK
bill_id FK
collection_order_id FK
allocated_amount
}
CollectionOrder {
id PK
collection_type
actual_amount
payment_channel_id
status
}
```
## 业务场景对照
### 场景 1:简单单笔收款
业户付 ¥800 物业费,一笔现金:
```
Bill #B-001 (amount=800, status=Unpaid)
└── CollectionOrderBill #1 (allocated_amount=800)
└── CollectionOrder #CO-001 (type=Bill, actual_amount=+800, payment=现金)
└── Receipt #R-001 ("物业费 ¥800")
```
Bill 状态翻 Paid,完成。
### 场景 2:同业户批量收款(本月所有账单一起付)
业户付 ¥1,082 = 物业费 800 + 水费 54 + 电费 168 + 燃气 60:
```
Bill #B-001 物业费 (amount=800) ──┐
Bill #B-002 水费 (amount=54) ──┤
Bill #B-003 电费 (amount=168) ──┤
Bill #B-004 燃气 (amount=60) ──┤
4 个 CollectionOrderBill ──┘
└── 1 个 CollectionOrder (actual_amount=+1082, payment=微信)
└── 1 个 Receipt(可能列出 4 个明细)
```
`BatchCollectPaymentAction`,详见 [[collect-payment-batch]]。
### 场景 3:部分付(Partial)
业户付 ¥300 给 ¥800 物业费(欠 ¥500):
```
Bill #B-001 物业费 (amount=800, status=Partial)
└── CollectionOrderBill #1 (allocated_amount=300)
└── CollectionOrder #CO-001 (actual_amount=+300, payment=现金)
```
Bill 状态:**Partial**(详见 [[bill-six-state-machine]])。后续业户补付 ¥500:
```
Bill #B-001 (amount=800, status=Paid) ← 收齐变 Paid
├── CollectionOrderBill #1 (allocated_amount=300, 关联 CO-001)
└── CollectionOrderBill #2 (allocated_amount=500, 关联 CO-002)
└── CollectionOrder #CO-002 (+500)
```
### 场景 4:预存款抵扣
业户预存款余额 ¥5,000,自动抵 ¥800 物业费:
```
Bill #B-001 (amount=800, status=Unpaid → Paid)
└── CollectionOrderBill #1 (allocated_amount=800)
└── CollectionOrder #CO-001 (type=Bill, actual_amount=+800,
meta.fund_source=prepaid)
└── PrepaidTransaction (type=consume, amount=800)
```
详见 [[../prepaid/consume-via-bill-collection-type]]。**关键**:CollectionOrder.type 仍是 `Bill`(账单视角),fund_source 标 prepaid。
## CollectionOrderBill 是只读管理
`CollectionAllocationsRelationManager`(Bill 详情页的子表)**完全只读**(无任何 Action,不可改 / 不可删 / 不可加)。理由:
- 分配是收款 Action 内事务一次性写入,业务上不需要"事后调整"
- 改了会导致 Bill.paid_amount 与 allocations 累加值不一致 → 数据脱节
- 误删等于"业户付的钱凭空消失" → 灾难
issue.md Q6 明确:**"`CollectionAllocationsRelationManager` 完美保持不动:作为只读管理器的最佳示范"**。
## 业务人员视角
后台 Bill 详情(`ViewBill`)的子表"收款分配":
| 列 | 内容 |
|---|---|
| 收款单号 | CO-XXX |
| 分配金额 | allocated_amount |
| 付款方式 | CO 的 payment_channel |
| 付款时间 | CO 的 created_at |
业务人员看明白:"这张账单的 ¥800 是 5/15 微信付的"。
## 业户视角
业户**通常感受不到**这两个对象的复杂关系。看到的是:
- 账单状态(Unpaid / Partial / Paid / ...)
- 已付金额 / 未付金额
- 收款明细(几次付、什么时候付、用什么方式付)
整体感知:**"我付了 ¥800 物业费"**,而不是"我建了一个 CollectionOrder 关联到 Bill 通过 CollectionOrderBill"。
## 与 adhoc 模块的对比
[[../adhoc/collection-order-and-receipt|adhoc 的 CollectionOrder 与 Receipt]] 概念:
| 维度 | adhoc(一次性收费)| **billing(账单)** |
|---|---|---|
| 主对象 | `AdHocEvent` | `Bill` |
| 与 CollectionOrder 关系 | 1:1(一次买卖一个 CO)| **多对多(中间表)** |
| 是否支持部分付 | ❌(一次性付清)| **✅ Partial 状态** |
| 是否支持批量付 | ❌(每单独立)| **✅ BatchCollectPayment** |
| 收款类型 | `CollectionType=AdHoc` | `CollectionType=Bill` |
bill 的多对多设计让**批量收款 + 部分付**成为可能,这是周期账单业务的核心需求。
## 常见问题
> [!question] 为什么不直接在 Bill 上记 paid_amount,不要中间表?
> 因为要记**每笔付款的细节**(什么时候付、什么方式、多少金额)。中间表才能存这些。
>
> 单纯 `paid_amount` 字段只能表达"总付了多少",无法回答"业户上次付的时候用的什么方式"。
> [!question] 一个 CollectionOrder 关联到一个 Bill 但 allocated_amount < CO.actual_amount 怎么办?
> 业务上不应该发生(每笔 CO 应该被完全分配)。如果发生,差额部分是"未分配收款" → 需要后续补建 CollectionOrderBill 关联其他 Bill(或留作业户预付,看业务设计)。
> [!question] Bill.paid_amount 字段从哪算?
> 系统应自动维护:`paid_amount = SUM(collectionOrderBills.allocated_amount)`。每次新建 CollectionOrderBill 时回填 Bill.paid_amount。
>
> 若数据库直接改 collectionOrderBills 没回填(理论上不应该,但若有 bug)→ Bill.paid_amount 与实际不符 → 需修复 + 审计。
> [!question] 已付账单作废后,关联的 CollectionOrderBill 怎么办?
> 看实现:
> - **不动**(保留历史关联)+ 走"作废 + 退款"组合(建红字 CO 退给业户)
> - **解除关联**(allocated_amount 退还,但这种做法破坏审计)
>
> 推荐**前者**(保留历史 + 走退款)。详见 [[void-paid-bill]]。
> [!question] CollectionOrderBill 的允许的 allocated_amount 大于 Bill.amount 吗?
> 业务上不应该(超额付了无意义)。如果业户付多了:
> - 多余部分**应该退**(或转入预存款)
> - 不应该让 CollectionOrderBill.allocated_amount > Bill.amount(等于"账单凭空多了钱")
## 相关文档
- [[bill-six-state-machine]]
- [[bill-types-and-sources]]
- [[collect-payment-single]]
- [[collect-payment-batch]]
- [[collect-via-prepaid-auto]]
- [[exception-partial-payment]]
- [[../adhoc/collection-order-and-receipt]]
- [[../prepaid/consume-via-bill-collection-type]]

View File

@@ -0,0 +1,320 @@
---
title: prop-acc · billing · 删除 vs 作废双轨制
aliases:
- 删除 vs 作废
- 双轨制
- delete-vs-void-dual-track
- VoidBillAction
- canBeDeleted
- canBeVoided
tags:
- 概念
- prop-acc
- 账单
- 数据治理
audience:
- 业务人员
- 财务
- 架构师
status: 已发布
sub_feature: billing
last_review: 2026-05-26
code_version: 2026-05-22
---
# 删除 vs 作废双轨制
billing 模块对"账单消失"提供**两条路径**:**物理删除**(Delete,Unpaid + 无付款关联)和 **作废**(Void,留状态 + 留审计)。这是 prop-acc 其他子模块**都没有**的设计:deposit/prepaid/meter 都只支持 Close / Decommission 类的"终态保留"。
## 为什么要双轨制
业务方提了"想要批量删除功能 + 单张删除功能"。深入讨论后形成双轨:
| 场景 | 推荐路径 | 理由 |
|---|---|---|
| **误开账单立刻发现**(还没付款)| **物理删除** | 干净,没钱被卷入,删了不留痕(activitylog 仍有) |
| **已生成 + 已收一部分钱**(Partial) | **作废** | 钱已经卷入,删了等于消灭凭证;作废留状态 + 退款 |
| **已生成 + 已付清**(Paid)| **作废(需走退款)** | 同上 |
| **业户失联 / 长期不付** | **挂起(Suspend)** | 暂停状态,不是终态;后续可恢复或作废 |
## 删除路径
### 守护
`Bill::canBeDeleted()`:
```php
public function canBeDeleted(): bool
{
return $this->status === BillStatus::Unpaid
&& ! $this->hasAnyPayment();
}
```
`Bill::hasAnyPayment()` 检查:
- `CollectionOrderBill`(走收款的关联)
- `PrepaidTransaction`(走预存款抵的关联)
**Unpaid + 无任何付款关联** = 业户没付过任何钱 + 账单状态干净 → 安全删。
### 触发入口
| 入口 | UI |
|---|---|
| 单删 | `EditBill` 页面的 `DeleteAction`(`->visible(canBeDeleted)` + `->authorize('delete')`)|
| 批删 | `BulkDeleteBillsAction`(智能 Modal,详见 [[smart-bulk-delete-design]])|
### Policy 守护
```php
// BillPolicy
public function delete(AuthUser $user, Model $record): bool
{
return $user->can('delete bills') && $record->canBeDeleted();
}
public function deleteAny(AuthUser $user): bool
{
return $user->can('bulkDelete bills'); // 独立高敏权限
}
```
`deleteAny` 是**独立权限**(`bill.bulkDelete`),比单删更敏感(批删一次可能 100+ 张),需单独授权。
### 删除后留什么
| 留下 | 不留 |
|---|---|
| **activitylog** 记录(谁删了 / 什么时候 / 哪些 bill_no)| **Bill 数据库记录** |
| 业户 / asset 等关联实体不动 | **关联的 CollectionOrderBill**(应该 0 个,因守护保证)|
activitylog 详见 [[smart-bulk-delete-design]]"activitylog 审计"段。
## 作废路径
### 守护
`Bill::canBeVoided()`:
```php
public function canBeVoided(): bool
{
return $this->status !== BillStatus::Paid
&& $this->status !== BillStatus::Void;
}
```
**非 Paid 非 Void** = 可作废。包括 Unpaid / Partial / Suspended / Processing。
> [!warning] Paid 的作废需走专门流程
> `canBeVoided()` 对 Paid 返 false,因为单纯翻状态会让业户已付的钱"凭空消失"。Paid 账单作废需要**配套退款流程**(未来扩展,目前手工 / tinker 操作)。
>
> issue.md Q6 未具体规划"Paid 作废"业务流程,留作未来扩展。
### 触发入口
`VoidBillAction`(Filament UI)挂在 `EditBill` / `ViewBill` / Table 行。Modal 必填**作废原因**(`reason`):
```
请填写作废原因(必填,审计留痕):
[多行输入框]
[取消] [确认作废]
```
### 业务 Action 实现
`src/Actions/Bills/VoidBillAction.php`(业务层,与 Filament UI 分离):
```php
class VoidBillAction
{
public function handle(Bill $bill, string $reason, User $user): void
{
if (! $bill->canBeVoided()) {
throw new RuntimeException("账单状态 {$bill->status->value} 不可作废");
}
$bill->update([
'status' => BillStatus::Void,
'meta' => array_merge($bill->meta ?? [], [
'voided_reason' => $reason,
'voided_at' => now(),
'voided_by' => $user->id,
]),
]);
activity()
->performedOn($bill)
->causedBy($user)
->withProperties([
'reason' => $reason,
'from_status' => $bill->getOriginal('status'),
'to_status' => BillStatus::Void->value,
'bill_no' => $bill->bill_no,
'amount' => $bill->amount,
])
->event('voided')
->log('账单已作废');
}
}
```
### Policy 守护
```php
public function void(AuthUser $user, Bill $bill): bool
{
return $user->can('void bills') && $bill->canBeVoided();
}
```
### 作废后留什么
| 留下 | 改变 |
|---|---|
| **Bill 数据库记录**(只读)| `status = Void` |
| **关联的 CollectionOrderBill**(若有)| 不动 |
| **meta**:voided_reason / voided_at / voided_by | 新增 |
| **activitylog**:event=voided | 新增 |
| **Receipt 等下游凭证** | 不动(已经发出去的不撤销)|
业户可以看到账单"已作废",物业有完整审计链。
## 双轨决策树
```mermaid
flowchart TD
A[要让账单"消失"] --> B{账单状态?}
B -->|Unpaid| C{有付款关联吗?}
B -->|Partial / Suspended / Processing| D[走作废 Void]
B -->|Paid| E[当前 canBeVoided=false<br/>需走专门退款流程<br/>未来扩展]
B -->|Void| F[已经是 Void,无需重复]
C -->|无| G[物理删除 Delete]
C -->|有| D
```
## 业务人员视角
### 决策时
| 误开发现时间 | 推荐 |
|---|---|
| 几秒内(业户还在面前)| 物理删除(干净)|
| 几小时(可能业户已付)| 先查是否已付:已付 → 作废 + 退;未付 → 删 |
| 几天(已发账单)| 通常作废(留状态让业户知道) |
| 几月(已查到)| 作废 + 走退款流程(若已付) |
### Modal 操作
**单删 Modal**(`EditBill` → DeleteAction):
```
确认删除账单 #B-202605-501-001?
⚠️ 此账单状态为 Unpaid 且无付款关联,可物理删除。
删除后无法恢复(但 activitylog 会保留记录)。
[取消] [确认删除]
```
**作废 Modal**(`VoidBillAction`):
```
作废账单 #B-202605-501-001
账单状态:Unpaid
账单金额:¥800
请填写作废原因(必填):
[多行输入]
[取消] [确认作废]
```
## 业户视角
| 操作 | 业户感知 |
|---|---|
| 物理删除 | **无感**(账单从未真正"存在过",未通知业户) |
| 作废 | 收到通知"您的账单 #XXX 已作废,理由 YYY" + 账单状态显示 Void |
| Paid 作废(未来) | 收到通知 + 退款到账 + 红字凭证 |
## 与 prop-acc 其他模块的对比
| 模块 | 删 / 作废 / 退役 / 关账 |
|---|---|
| **deposit** | 无 delete UI(Policy 严格)+ ForceClose(终态)|
| **prepaid** | 无 delete UI + CloseAction(终态)|
| **meter** | 无 delete UI(仅退役 + 严格条件下删空表)+ Decommission |
| **bill(本模块)** | **delete + void 双轨** + Suspend / Resume |
bill 的双轨是 prop-acc **最完整的"账单消失"设计**。其他模块"没有删,只有 终态"是因为它们的对象天然不该消失(账户、押金、表都是长期实体)。bill 的"账单"本质上是**可消失的事务记录**,所以双轨合理。
## 历史:issue.md Q6 的修复
> [!info] 修复前
> `BillPolicy` 几乎是空壳(只有 `getPermissionPrefix`),`DeleteAction` 暴露在 EditBill 页 + `DeleteBulkAction` 暴露在 Table toolbar(`visible(false)` 假关闭),Unpaid + Paid + Void 全状态都能删 → 删 Paid Bill = 业户付的钱凭空消失 + 完全无审计痕迹。
>
> **风险等级**:严重(类似 deposit / prepaid 第二轮修复的"消灭法律证据"级别)。
> [!info] 修复后(issue.md Q6 第一轮)
>
> 1. Bill 模型加 3 辅助方法(`hasAnyPayment` / `canBeDeleted` / `canBeVoided`)
> 2. BillPolicy 补 7 方法(`update / delete / deleteAny / void / collect / suspend / resume`)
> 3. 新增 `VoidBillAction` 业务 Action + Filament UI
> 4. 新增 `BulkDeleteBillsAction` 智能批删(详见 [[smart-bulk-delete-design]])
> 5. 删除 `DeleteBulkAction` 的 `visible(false)` 反模式 → 替换为真正的智能批删
> 6. EditAction / DeleteAction 三处加 visible 守护
> 7. CollectPaymentAction authorize 改为 `->authorize('collect')`(取代跨模型 authorize)
> 8. Plugin.php 扩权限位:`bill.bulkDelete` 独立高敏权限
>
> 全栈守护(UI / Policy / Action / Model)+ activitylog 审计。
## 常见问题
> [!question] 为什么不全部走作废,简化设计?
> **物理删的"干净"是有价值的**:
>
> - 误开的账单不留痕,业务人员不慌
> - 业户看不到"已作废"的诡异状态(从未存在过最自然)
> - 数据库无 Void 状态垃圾堆积
>
> 但**只对没动钱的账单适用**。一旦动了钱,必须作废留痕,不能"假装从未发生"。
> [!question] 作废后能撤销吗?
> 不能(Void 是终态,无 reactivate 路径)。如需"恢复":新建一张同信息的 Bill。
> [!question] hasAnyPayment 检查的"付款关联"具体是?
> 看 `Bill::hasAnyPayment()` 实现。通常:
>
> - `collectionOrderBills()->exists()`(有任何分配记录)
> - `prepaidTransactions()->where('related_bill_id', this->id)->exists()`(被 prepaid consume 过)
>
> 任意一个为真 → 有付款关联 → 不可物理删。
> [!question] 批量删的智能 Modal 怎么工作?
> 详见专门概念 [[smart-bulk-delete-design]]。
> [!question] activitylog 怎么查?
>
> ```sql
> -- 某员工某月的全部批量删除
> SELECT * FROM activity_log
> WHERE causer_id = ?
> AND event IN ('voided', 'bulk_deleted')
> AND created_at BETWEEN '2026-05-01' AND '2026-05-31';
> ```
## 相关文档
- [[bill-six-state-machine]]
- [[smart-bulk-delete-design]]
- [[delete-bill-unpaid]]
- [[void-paid-bill]]
- [[bulk-delete-batch-mistake]]
- [[suspend-bill]]
- [[../meter/decommission-and-locking]](类似"保留 vs 删除"对比)

View File

@@ -0,0 +1,254 @@
---
title: prop-acc · billing · 周期账单生成机制
aliases:
- 周期账单生成
- 月度物业费生成
- GeneratePeriodicBillsAction
- BillingMergeStrategy
- 合单策略
tags:
- 概念
- prop-acc
- 账单
- 业务流程
audience:
- 业务人员
- 财务
- 架构师
status: 已发布
sub_feature: billing
last_review: 2026-05-26
code_version: 2026-05-22
---
# 周期账单生成机制
物业费、停车费、电视费等**按周期(月 / 季 / 年)固定收**的账单,通过 **`GeneratePeriodicBillsAction`** 批量生成。配合 **`BillingMergeStrategy` 枚举**决定"同业户多个费用类型是否合并到一张账单"。
## 业务场景
每月 1 日,物业为本社区 **300 户业主**生成本月物业费账单(每户 ¥800 起步,按房屋面积浮动)。系统应:
- 自动算每户的金额(按 RatePlan + 房屋面积)
- 批量建 Bill(300 张)
- 各张 Bill 期次清晰(billing_period 5/1 - 5/31)
- 不重复生成(本月已生成的不再生成)
- 业务人员收到结果报告
## 批量生成流程
```mermaid
flowchart TD
A[业务人员触发 GeneratePeriodicBillsAction] --> B[Modal 输入参数]
B --> C[参数:期次 / 费用类型 / 社区范围 / 合并策略]
C --> D[扫描候选业户清单]
D --> E[每户:计算应付金额]
E --> F{已有本期 Bill?}
F -->|否| G[建新 Bill]
F -->|是,根据策略| H{合并策略}
H -->|MERGE 合单| I[追加到既有 Bill]
H -->|SKIP 跳过| J[不动]
H -->|REPLACE 替换| K[作废旧 Bill + 建新]
G --> L[报告完成]
I --> L
J --> L
K --> L
```
## `GeneratePeriodicBillsAction` 参数
业务人员触发(`Bills` List 页 → 顶部 "批量生成周期账单" 按钮):
| 参数 | 说明 |
|---|---|
| **期次(billing_period)** | 如 "2026 年 5 月",决定 start / end |
| **费用类型(fee_type_id)** | 物业费 / 停车费 / 有线电视费 / ... |
| **社区范围(community_id)** | 单社区 / 全平台 |
| **业户范围** | 单个业户 / 全社区业户 / 自定义清单 |
| **合并策略(merge_strategy)** | MERGE / SKIP / REPLACE |
| 备注 | 选填 |
提交后系统扫描候选业户清单,逐户计算 + 建账。
## `BillingMergeStrategy` 枚举
> [!info] 实际枚举值看 `packages/prop-acc/src/Enums/BillingMergeStrategy.php`
> 可能值(推测):
>
> | 值 | 含义 |
> |---|---|
> | `SkipExisting` | 同业户同期次已有 Bill → 跳过(不重复生成)|
> | `Merge` | 追加到既有 Bill(同业户同期次的不同费用类型合一张)|
> | `Replace` | 作废旧 Bill + 建新(罕见,数据修复用)|
>
> 默认策略通常是 **`SkipExisting`**(最安全)。
## 三种策略对照
### 策略 1:SkipExisting(默认,推荐)
业务人员 5 月 1 日点击"生成 5 月物业费"。系统:
- 张阿姨已经有 5 月物业费 Bill → **跳过**(避免重复)
- 陈先生没有 5 月物业费 Bill → 新建
**适用**:正常月度操作,业务人员不确定是否之前已经生成过,跳过最安全。
### 策略 2:Merge(合单)
业务人员同时为业户生成"5 月物业费 + 5 月电视费"。系统:
- 找到张阿姨已有 5 月物业费 Bill(¥800)
- **追加电视费 ¥40 到同一张 Bill**(amount: 800 + 40 = 840)
- 业户收到**一张账单 ¥840**(明细两项)
**适用**:多种费用类型一起发(减少业户收到的账单数,体验好)。
> [!warning] Merge 的局限
> 合并后**单一 Bill 的金额 = 各费用类型总和**,但**关联的 sourceable** 怎么处理?Bill 表 sourceable 字段是 1:1。**当前实现可能**:
> - 不挂 sourceable(`sourceable_type=null`)
> - 或挂主费用类型的 source
>
> 具体看代码。Merge 策略实施细节复杂,生产环境**谨慎用**。
### 策略 3:Replace(替换)
业务人员发现 5 月物业费金额算错了(RatePlan 改过),要全部重算:
- 找到张阿姨已有的 5 月物业费 Bill
- **作废**(VoidBillAction,状态翻 Void)
- **重新生成**新 Bill(按新 RatePlan)
**适用**:数据修复场景(罕见,需高权限 + 必填原因)。
> [!warning] Replace 的风险
> 已付的 Bill 作废后,业户已经付的钱**怎么办**?
>
> - Bill 状态翻 Void
> - 已付的 CollectionOrderBill 关联保留(审计需要)
> - 但业户的钱 = 物业账面多收了
> - **必须走退款**(建红字 CollectionOrder)
>
> Replace 策略**只对 Unpaid Bill 使用相对安全**;Paid Bill 慎用,需配套退款流程。
## 金额计算
周期账单金额怎么算?取决于 RatePlan 配置:
| 算法 | 说明 |
|---|---|
| **固定金额** | 每户每月固定 ¥800(简单)|
| **按面积** | 房屋面积 × 单价(例如物业费 ¥3 / m² / 月)|
| **按车位** | 每个停车位固定金额(停车费)|
| **按设备** | 每台空调 ¥X / 月(中央空调费)|
| **阶梯** | 类似计量账单的阶梯(罕见)|
具体看 `RatePlan` 的配置 + 业务计算逻辑。可能在 `PeriodicBillGenerationService`(若有)实现,或在 Action 内联。
## 生成的 Bill 字段
每张生成的 Bill:
| 字段 | 值 |
|---|---|
| `bill_no` | 自动编号(`B-202605-501-001` 或类似)|
| `community_id` | 所属社区 |
| `asset_id` | 业户房屋 |
| `resident_id` | 业户 |
| `fee_type_id` | 费用类型 |
| `bill_type` | `Periodic`(枚举) |
| `amount` | 算出的金额 |
| `paid_amount` | 0(刚生成,未付)|
| `status` | `Unpaid` |
| `billing_period_start` | 期次开始(2026-05-01)|
| `billing_period_end` | 期次结束(2026-05-31)|
| `due_at` | 到期日(通常本期末 + 宽限期,如 2026-06-15)|
| `sourceable_*` | null(周期账单通常无 source)|
## 业户视角
业户每月初(或月底,看物业策略)收到推送:
> 张阿姨您好,您的 2026 年 5 月物业费 ¥800 已生成。请于 6 月 15 日前付清。
业户可选:
- 现金 / 微信付 → 走 [[collect-payment-single]]
- 预存款抵 → 走 [[collect-via-prepaid-auto]](自动)
- 与其他账单一起付 → 走 [[collect-payment-batch]]
- 暂时不付 → 到期日后变逾期,走 [[exception-overdue-bills]] 催收
## 业务人员视角
### 月初执行
每月 1-3 日,业务人员王主管:
1. 打开 BillsListPage → 顶部 "生成周期账单"
2. 选费用类型(物业费 / 停车费 / 等)+ 期次 + 策略 + 备注
3. 提交 → 系统扫描 + 生成 → 报告"已生成 N 张账单,跳过 M 张"
4. 抽样核对几张 Bill 的金额 + 业户
### 异常处置
| 异常 | 处置 |
|---|---|
| 某户 RatePlan 配置缺失 | 跳过该户 + 在报告里标记 → 单独建 RatePlan + 单独生成 |
| 某户已有同期 Bill(被跳过)| 看是否需要 Merge / Replace |
| 全部失败(系统错)| 联系运维查日志 |
## 自动化的可能
issue.md Q6 未列入"待补",但业务上可能需要:
- **Scheduled job 月初自动跑** — 不需要业务人员手动触发
- **跟 prepaid 自动抵扣 job 串联** — 账单生成 → 立即触发 [[../prepaid/auto-deduction-design|预存款自动抵]] → 业户感受"无感扣账单"
当前**仍是手动触发**。
## 常见问题
> [!question] 同业户同期次同费用类型能有两张 Bill 吗?
> 业务上**不应该**(违反业务规则:一户一月一物业费)。系统层面看是否有 unique 约束:
>
> ```sql
> UNIQUE INDEX (community_id, resident_id, fee_type_id, billing_period_start)
> ```
>
> 若有 → 数据库层挡住重复;若无 → 全靠 Action 的 SkipExisting 策略判断,理论上可能因并发产生重复。
> [!question] 业户搬走中途,本月物业费要不要全收?
> 看物业策略 + 合同约定:
>
> - **全月收**(简单,业户合同到月底)
> - **按天分摊**(按搬走前的天数算)
> - **不收**(罕见)
>
> 系统**当前简版**通常是全月收。按天分摊需要业务层算法支持。
> [!question] 周期账单生成后才发现 RatePlan 配错怎么办?
> 走 Replace 策略**只对 Unpaid 安全**。如果已付:
>
> - 看错的金额方向:
> - 收多了 → 退差额(走 [[void-paid-bill]] 类似流程)
> - 收少了 → 补开账单(新建一张 ¥差额 的临时账单)
> [!question] 批量生成多久?300 户 5 秒?
> 单户 ~50-100ms(查 RatePlan + 查业户 + 建 Bill + 日志)。300 户**顺序执行** ~15-30 秒。可优化为并发(若业务量大)。
> [!question] 周期账单和计量账单可以一起生成吗?
> 不在同一个 Action(周期是 `GeneratePeriodicBillsAction`,计量是 [[../meter/bill-generation-pipeline|GenerateBillsFromMeterReadingsAction]])。**业务流程上**业务人员通常先做完抄表 + 计量账单生成 → 再触发周期账单生成。
## 相关文档
- [[bill-six-state-machine]]
- [[bill-types-and-sources]]
- [[bill-vs-collection-order]]
- [[create-periodic-property-fee]]
- [[create-meter-bill-auto]]
- [[../meter/bill-generation-pipeline]](类似的 Calculator + Service + Action 模式)
- [[../prepaid/auto-deduction-design]](账单生成后的下游消费)

View File

@@ -0,0 +1,359 @@
---
title: prop-acc · billing · 智能批量删除设计
aliases:
- 智能批量删除
- BulkDeleteBillsAction
- 三档分类
- activitylog 审计
- smart-bulk-delete-design
tags:
- 概念
- prop-acc
- 账单
- 数据治理
- 审计
audience:
- 业务人员
- 财务
- 架构师
status: 已发布
sub_feature: billing
last_review: 2026-05-26
code_version: 2026-05-22
---
# 智能批量删除设计
`BulkDeleteBillsAction` 是 prop-acc **最精巧的批量操作设计**:选中 N 张 Bill 后,系统**预检查 + 分类三档**(可删 / 可作废 / 跳过),Modal 显示统计 + 选模式 + 必填原因,最后**一条 activitylog** 记录全过程,`affected_ids` 串联具体哪些账单。
## 为什么要"智能"
业务人员选了 100 张账单点"批量删除",其中可能:
- 92 张是 Unpaid + 无付款 → 可物理删
- 5 张是 Partial / Paid → 不能删(动了钱),但可作废
- 3 张是 Suspended / 已 Void → 跳过
**简单批量删**(一刀切删全部)的灾难:
- 5 张 Partial / Paid 的被删 → 业户付的钱凭空消失 + 审计断链
- 业务人员可能根本不知道里面有 Partial / Paid(没逐张点开看)
**智能批删**(本设计):
1. **预检查** 每张 Bill 分类
2. **Modal 显示**:`✅ 可删 92 ⚠️ 需作废 5 ❌ 跳过 3`
3. **业务人员选模式**:`仅删可删的``删可删 + 作废需作废的`
4. **必填原因**(审计要求)
5. **执行 + 一条 activitylog**:`affected_ids` 串联每张账单的处理结果
## 三档分类逻辑
```php
// 伪代码,来自 BulkDeleteBillsAction
foreach ($selectedBills as $bill) {
if ($bill->canBeDeleted()) {
$deletable[] = $bill;
} elseif ($bill->canBeVoided()) {
$voidable[] = $bill;
} else {
$blocked[] = $bill; // Paid / Void 已经 / 异常状态
}
}
```
| 档 | 条件 | 处置 |
|---|---|---|
| **可删**(Deletable) | `canBeDeleted()` = Unpaid + 无付款 | 物理 delete |
| **可作废**(Voidable) | `canBeVoided()` = 非 Paid 非 Void | 翻状态 Void + 留 meta |
| **跳过**(Blocked) | Paid / Void / 其他异常 | 不动 |
## Modal 设计
业务人员在 `BillsTable` 选 N 张 → 点 "批量删除" → 弹出:
```
批量删除账单 (选中 100 张)
预检查统计:
✅ 可删: 92 张
⚠️ 需作废: 5 张
❌ 跳过: 3 张(已付清 / 已作废 / 其他)
请选择处理模式:
⚪ 仅删除可删的(92 张物理删,5 张需作废 / 3 张跳过)
⚫ 删可删 + 作废需作废的(92 删 + 5 作废,3 跳过)
批量操作原因(必填,审计留痕):
[多行输入框]
⚠️ 注意:
- 物理删除不可恢复(但 activitylog 保留 bill_no)
- 作废不可撤销
- 跳过的账单不动
[取消] [确认执行]
```
## 业务 Action 实现(`src/Actions/Bills/BulkDeleteBillsAction`)
```php
class BulkDeleteBillsAction
{
public function handle(
iterable $bills,
BulkDeleteMode $mode, // OnlyDeletable / DeleteAndVoid
string $reason,
User $user,
): BulkDeleteResult {
$deletable = [];
$voidable = [];
$blocked = [];
// 第 1 步:分类
foreach ($bills as $bill) {
if ($bill->canBeDeleted()) {
$deletable[] = $bill;
} elseif ($bill->canBeVoided()) {
$voidable[] = $bill;
} else {
$blocked[] = $bill;
}
}
$deletedCount = 0;
$voidedCount = 0;
$affectedBillNos = [];
// 第 2 步:执行
DB::transaction(function () use (...) {
foreach ($deletable as $bill) {
$affectedBillNos[] = $bill->bill_no . ' [DELETED]';
$bill->delete();
$deletedCount++;
}
if ($mode === BulkDeleteMode::DeleteAndVoid) {
foreach ($voidable as $bill) {
$affectedBillNos[] = $bill->bill_no . ' [VOIDED]';
app(VoidBillAction::class)->handle($bill, $reason, $user);
$voidedCount++;
}
}
foreach ($blocked as $bill) {
$affectedBillNos[] = $bill->bill_no . ' [SKIPPED]';
}
// 第 3 步:一条总体 activitylog
activity()
->causedBy($user)
->withProperties([
'mode' => $mode->value,
'reason' => $reason,
'total_selected' => count($bills),
'deleted_count' => $deletedCount,
'voided_count' => $voidedCount,
'blocked_count' => count($blocked),
'affected_bill_nos' => $affectedBillNos,
])
->event('bulk_deleted')
->log('批量删除账单');
});
return new BulkDeleteResult(
deletedCount: $deletedCount,
voidedCount: $voidedCount,
blockedCount: count($blocked),
affectedBillNos: $affectedBillNos,
);
}
}
```
## activitylog 设计
利用 `spatie/laravel-activitylog`(项目已装但本次首次启用)。
### 单条作废日志
`VoidBillAction` 触发,subject 是被作废的 Bill:
| 字段 | 值 |
|---|---|
| `log_name` | `default` |
| `subject_type` | `Bill` |
| `subject_id` | 被作废 bill 的 ID |
| `event` | `voided` |
| `causer_id` | 操作员 ID |
| `properties` | `{reason, from_status, to_status, bill_no, amount}` |
### 批量删除日志(本场景)
`BulkDeleteBillsAction` 触发,**无 subject**(批量操作不绑单个对象):
| 字段 | 值 |
|---|---|
| `log_name` | `default` |
| `subject_type` | null |
| `subject_id` | null |
| `event` | `bulk_deleted` |
| `causer_id` | 操作员 ID |
| `properties` | `{mode, reason, total_selected, deleted_count, voided_count, blocked_count, affected_bill_nos[]}` |
`properties.affected_bill_nos` 是一个数组,每条形如 `"B-202605-501-001 [DELETED]"` / `"B-202605-502-003 [VOIDED]"`,审计可完整还原。
## 审计追溯 SQL
```sql
-- 某员工某月的全部批量删除
SELECT
id,
event,
causer_id,
properties->>'$.mode' AS mode,
properties->>'$.reason' AS reason,
properties->>'$.deleted_count' AS deleted,
properties->>'$.voided_count' AS voided,
properties->>'$.affected_bill_nos' AS affected,
created_at
FROM activity_log
WHERE causer_id = ?
AND event = 'bulk_deleted'
AND created_at BETWEEN '2026-05-01' AND '2026-05-31'
ORDER BY created_at DESC;
```
返回示例:
| created_at | mode | reason | deleted | voided | affected (部分) |
|---|---|---|---|---|---|
| 2026-05-15 14:32 | DeleteAndVoid | "5 月物业费 RatePlan 配错,清理重生成" | 92 | 5 | ["B-202605-501-001 [DELETED]", ...] |
## 完整业务流程
```mermaid
sequenceDiagram
participant 业务[业务人员]
participant Filament
participant BulkDelete[BulkDeleteBillsAction Filament UI]
participant Action[BulkDeleteBillsAction 业务层]
participant Bill[Bill 模型]
participant Activity[activitylog]
participant DB
业务->>Filament: 选 100 张 → 点"批量删除"
Filament->>BulkDelete: 显示 Modal
BulkDelete->>BulkDelete: 预检查每张 Bill 分类
BulkDelete->>Filament: Modal 显示 ✅92 ⚠5 ❌3
业务->>Filament: 选"删可删 + 作废需作废" + 填原因 + 提交
Filament->>Action: handle(100 bills, mode=DeleteAndVoid, reason, user)
Action->>Bill: 第 1 步:再次分类(防 UI 缓存过期)
Bill-->>Action: 92 deletable / 5 voidable / 3 blocked
Action->>DB: 开启事务
loop 92 deletable
Action->>Bill: delete()
Bill->>DB: 物理删
end
loop 5 voidable
Action->>Action: 调 VoidBillAction.handle(bill, reason, user)
Action->>Bill: 翻状态 Void + meta
Action->>Activity: log voided(单条)
end
Action->>Activity: log bulk_deleted(总体 + affected_bill_nos)
Action->>DB: 提交事务
Action-->>Filament: 结果(92 删 / 5 作废 / 3 跳过)
Filament-->>业务: 通知"批量操作完成"
```
## 业务人员视角
完整流程:
1. **筛选**:在 `BillsTable` 用过滤(按期次 / 状态 / 费用类型)选出要清理的批
2. **选中**:Table 的勾选框
3. **触发**:顶部 "批量删除" 按钮
4. **看预检查**:Modal 显示 ✅ / ⚠️ / ❌ 三档统计
5. **决策**:选"仅删可删的"或"删可删 + 作废需作废的"
6. **填原因**(必填):"5 月物业费配错,清理重生成"
7. **提交**:系统执行 + 通知
8. **审计**:事后可查 activitylog 追溯
## 业户视角
业户感知:
| 业户类型 | 感知 |
|---|---|
| 被物理删账单的业户(未付款)| **无感**(账单从未发出 / 未通知)|
| 被作废账单的业户(已付款)| 收到通知 + 看到 Void 状态 + 可能涉及退款 |
| 不在批量内的业户 | 无感 |
## 权限设计
| 权限 | 谁该有 |
|---|---|
| `bill.delete` | 普通业务人员(单删)|
| `bill.bulkDelete` | 主管 / 财务总监(批删独立权限)|
| `bill.void` | 普通业务人员(作废)|
`bulkDelete` 是**独立高敏权限**,因为:
- 一次可能影响 100+ 张账单
- 影响面大 → 限定高权限人员
- 责任明确 → 出问题能追溯到这个人
## 常见问题
> [!question] 为什么不直接走"全部作废"?
> 物理删的"干净"价值见 [[delete-vs-void-dual-track]]。批量场景里,**绝大多数账单是 Unpaid 误建**,物理删一次清理干净;少数已动钱的走作废留痕,两全其美。
> [!question] Modal 预检查后实际执行时状态变了怎么办?
> Action **再次分类**(防 UI 缓存),实际执行用最新状态。例如:
>
> - Modal 显示 Bill #X 可删(Unpaid 无付款)
> - 业务人员看 Modal 时,另一人付了款 → Bill #X 变 Partial
> - 业务人员点提交 → Action 重新分类 → Bill #X 进 voidable 档
> - 按当前模式处理(若选 "DeleteAndVoid",作废;若选 "OnlyDeletable",跳过)
> [!question] activitylog 表会无限膨胀吗?
> 是的(每条单作废 + 每次批量都加一条)。需要**归档策略**:
>
> - 超 X 年的 activitylog 归档到冷存储
> - 或拆表(按月分区)
>
> 当前未配置归档,需运维介入(若数据量大)。
> [!question] 业务人员误操作批删大量账单怎么办?
> activitylog 留有 `affected_bill_nos`,可以**反向恢复**(理论上):
>
> - 物理删的 Bill:无法恢复(数据真的没了),只能重新生成(走 [[create-periodic-property-fee]] 或 [[create-single-bill-manual]])
> - 作废的 Bill:状态翻回 Unpaid 是不允许的(Void 是终态)→ 重新创建一张同信息的 Bill
>
> **预防**:必填原因 + 高权限 + Modal 显示统计 + 强调"不可恢复"。
> [!question] BulkDelete vs 直接逐条 Delete 100 次有什么区别?
> | 维度 | BulkDelete | 逐条 100 次 |
> |---|---|---|
> | activitylog | 1 条(汇总)| 100 条(详细)|
> | 业务人员效率 | 高(一次操作)| 低(100 次点击)|
> | 模式灵活性 | 选 DeleteAndVoid 一次处理混合状态 | 逐条决策 |
> | 审计可读性 | 中(看汇总 + affected)| 高(每条独立)|
>
> 当前设计**汇总一条 log + affected 数组** = 兼顾效率与可审计。
## 相关文档
- [[bill-six-state-machine]]
- [[delete-vs-void-dual-track]]
- [[delete-bill-unpaid]]
- [[void-paid-bill]]
- [[bulk-delete-batch-mistake]]
- [[audit-activitylog-trace]]

View File

@@ -0,0 +1,294 @@
---
title: prop-acc · meter · 账单生成的三层分层
aliases:
- 账单生成 pipeline
- Calculator Service Action 三层
- MeterBillCalculator
- MeterBillGenerationService
- GenerateBillsFromMeterReadingsAction
tags:
- 概念
- prop-acc
- 计量表
- 架构
- 计费
audience:
- 架构师
- 业务人员
status: 已发布
sub_feature: meter
last_review: 2026-05-25
code_version: 2026-05-22
---
# 账单生成的三层分层
从"一条 MeterReading"到"一张 Bill",中间经过三层代码,每层职责清晰、可独立测试:
1. **`MeterBillCalculator`** — 纯算函数(无 DB IO),拿用量 + RatePlan,算出金额
2. **`MeterBillGenerationService`** — 查费率 + 找业主 + 建 Bill 数据库记录
3. **`GenerateBillsFromMeterReadingsAction`** — 业务入口,接收一组 reading,触发整个流程
> [!info] 为什么这种分层是 prop-acc 的"样板"
> issue.md Q5 提到 meter 是 prop-acc 里**最成熟**的模块,后续 deposit / prepaid / adhoc 学习了它的"业务分层方法"。这条 Calculator → Service → Action 的链路设计,**值得作为新模块的参考蓝本**。
## 三层职责清单
| 层 | 类名 | 职责 | 依赖 |
|---|---|---|---|
| **Calculator** | `MeterBillCalculator` | 纯算:用量 → 金额(阶梯 + 倍率 + min/max)| 无(纯函数)|
| **Service** | `MeterBillGenerationService` | 业务编排:从 Reading 查 fee_type → 查 RatePlan → 找 asset 业主 → 调 Calculator → 建 Bill | DB |
| **Action** | `GenerateBillsFromMeterReadingsAction` | 业务入口:接收一组 Reading → 校验 → 逐条调 Service | DB + 事件 |
## 调用栈
```mermaid
sequenceDiagram
participant 业务[业务人员/定时任务]
participant Filament
participant Action[GenerateBillsFromMeterReadingsAction]
participant Service[MeterBillGenerationService]
participant Calc[MeterBillCalculator]
participant DB
业务->>Filament: 触发"生成本月账单"按钮
Filament->>Action: handle([reading1, reading2, ..., readingN])
loop 每个 reading
Action->>Action: 校验:reading.bill_id == null
Action->>Service: generateBillForReading(reading)
Service->>DB: 查 Meter.fee_type → 查 RatePlan + RateTier
Service->>DB: 查 asset_id 关联的业户(community_asset_users)
Service->>Calc: calculate(consumption, ratePlan, min, max)
Calc-->>Service: amount = 148
Service->>DB: 建 Bill(amount, asset, resident, sourceable=reading)
Service->>DB: 更新 reading.bill_id = bill.id
Service-->>Action: ok
end
Action-->>Filament: 完成 + 报告(已生成 N 张 Bill)
```
## 每层细节
### Layer 1:`MeterBillCalculator`(纯算)
```php
// 伪代码示意
class MeterBillCalculator
{
public function calculate(
float $consumption,
RatePlan $ratePlan,
?float $minAmount,
?float $maxAmount,
): float {
$amount = $this->calculateTiered($consumption, $ratePlan->tiers);
if ($maxAmount !== null && $amount > $maxAmount) {
$amount = $maxAmount;
}
if ($minAmount !== null && $amount < $minAmount) {
$amount = $minAmount;
}
return $amount;
}
public function calculateTiered(float $consumption, Collection $tiers): float
{
// progressive 累进算法,见 multiplier-and-tiered-pricing
}
}
```
**特点**:
- 无 DB 查询
- 无业务对象关联(只接受参数)
- 完全可测试(给定输入 → 期望输出)
- 单元测试覆盖率 100% 容易达到
### Layer 2:`MeterBillGenerationService`(业务编排)
```php
// 伪代码示意
class MeterBillGenerationService
{
public function __construct(
private MeterBillCalculator $calculator,
) {}
public function generateBillForReading(MeterReading $reading): Bill
{
$meter = $reading->meter;
$feeType = $meter->feeType;
$ratePlan = $feeType->currentRatePlan;
$asset = $meter->asset;
$resident = $this->findCurrentResident($asset); // 关键业务逻辑
$amount = $this->calculator->calculate(
$reading->consumption,
$ratePlan,
$ratePlan->min_amount,
$ratePlan->max_amount,
);
return DB::transaction(function () use ($reading, $amount, /* ... */) {
$bill = Bill::create([
'community_id' => $meter->community_id,
'asset_id' => $meter->asset_id,
'resident_id' => $resident?->id,
'fee_type_id' => $meter->fee_type_id,
'amount' => $amount,
'sourceable_type' => MeterReading::class,
'sourceable_id' => $reading->id,
'status' => BillStatus::Unpaid,
'due_at' => /* 计算到期日 */,
]);
$reading->update(['bill_id' => $bill->id]);
return $bill;
});
}
}
```
**特点**:
- 查 DB(RatePlan / asset / resident)
- 业务规则(找当前业主、计算到期日)
- 事务边界(确保 Bill 建 + Reading 回写同时成功)
- 不直接处理 UI / 用户输入
### Layer 3:`GenerateBillsFromMeterReadingsAction`(入口)
```php
// 伪代码示意
class GenerateBillsFromMeterReadingsAction
{
public function __construct(
private MeterBillGenerationService $service,
) {}
public function handle(Collection $readings): array
{
$generated = [];
$skipped = [];
foreach ($readings as $reading) {
if ($reading->bill_id !== null) {
$skipped[] = ['reading' => $reading, 'reason' => 'already_billed'];
continue;
}
try {
$bill = $this->service->generateBillForReading($reading);
$generated[] = $bill;
} catch (\Exception $e) {
$skipped[] = ['reading' => $reading, 'reason' => $e->getMessage()];
}
}
return [
'generated' => $generated,
'skipped' => $skipped,
];
}
}
```
**特点**:
- 业务入口(给 Filament / Console / 定时任务调用)
- 处理批量
- 容错(单条失败不影响其他)
- 返回结构化结果供调用方报告 / 推送
## 调用方
`GenerateBillsFromMeterReadingsAction` 的调用方:
| 调用方 | 场景 |
|---|---|
| **Filament Action**(`ViewMeter` 上的"生成账单"按钮)| 业务人员手动触发(单表 / 多表)|
| **`MeterReadingsImporter`**(批量导入完成后)| 导入抄表数据后自动触发账单生成 |
| **`MeterReadingsRelationManager`** 单录后(可选)| 抄表后即生成账单(配置项)|
| **定时任务**(月初自动)| 待补(类似 prepaid 的 [[../prepaid/auto-deduction-design]]) |
**关键**:所有调用方**共用同一份 Action**,业务逻辑只写一遍。
## 为什么分层
| 不分层(全部塞 Filament Action)| **三层分层(本设计)** |
|---|---|
| 一个 Filament Action 500+ 行 | Calculator 100 行 + Service 200 行 + Action 100 行 |
| 测试要 mock UI 才能跑 | Calculator 直接单元测试 |
| 复用困难(其他调用方要复制粘贴)| 多调用方共用 Action / Service |
| 业务逻辑藏在 UI 层(违反层次原则)| 业务逻辑在 Action 层,UI 只是入口 |
| 重构成本极高 | 各层独立演化 |
issue.md 多处提到"清理内联业务逻辑"的迁移(deposit / prepaid / adhoc 都做过),meter 模块**一开始就是这样设计的**,所以没经历这种痛。
## 测试金字塔
```mermaid
flowchart TD
A[Action 层<br/>少数 Feature 测试] --> B[Service 层<br/>中等数 Feature 测试]
B --> C[Calculator 层<br/>大量 Unit 测试]
```
- Calculator:大量边界情况单元测试(0 用量、负用量、阶梯边界、min max 触发等)
- Service:Feature 测试覆盖业务规则(找业户失败、RatePlan 不存在等)
- Action:Feature 测试覆盖批量逻辑(部分失败、空集合、重复 reading 等)
## 性能与并发
- 单 reading 生成 Bill: ~50ms(查 RatePlan + 业户 + 建 Bill + 更新 reading)
- 批量 100 reading: ~5 秒(顺序执行)
- 大规模批量(>1000):分 chunk 处理(Action 内置 / 调用方分批)
- 并发安全:每次 `generateBillForReading` 在事务内,reading.bill_id 是数据库约束(unique 也好,not null check 也好),不会重复建 Bill
## 异常处理
| 异常 | Service 行为 | Action 报告 |
|---|---|---|
| RatePlan 不存在 | 抛 `RatePlanNotFoundException` | skipped[reason=rate_plan_not_found] |
| asset 没绑业户 | 视设计 — 抛 / 建匿名 Bill / 不建 | skipped[reason=no_resident] |
| Bill 建表 DB 错误 | 抛(回滚事务)| skipped[reason=db_error] |
| reading 已有 bill_id | Service 不接 / Action 跳过 | skipped[reason=already_billed] |
| consumption 是负数(读数倒走)| 视设计 — 抛 / 容错 | skipped[reason=negative_consumption] |
## 业务人员视角
在 Filament 后台**几乎感受不到**这三层:
- 看到的就是 `ViewMeter``ListMeters` 上的"生成账单"按钮
- 点击 → 弹出选择 reading 的 Modal → 提交
- 系统报告"X 张账单已生成"
- 失败的 reading 列出原因
底层的 Calculator / Service / Action 对业务人员透明。
## 架构师视角
这套分层是**评审新功能**的参考:
| 新需求 | 该改哪一层 |
|---|---|
| 加新计价算法(累退 / 二次方等)| Calculator 层(纯算)|
| 加业户关联规则(房屋多业主时按比例分摊)| Service 层(业务编排)|
| 加新触发场景(微信小程序业户主动结账)| Action 层(入口)+ 新 controller |
| 改账单字段(加新字段、改 due_at 计算)| Service 层 |
| 加批量优化(并行 / 队列)| Action 层 |
每层都是**单一职责**,改动局部、不会污染其他层。
## 相关文档
- [[meter-vs-meter-reading]]
- [[multiplier-and-tiered-pricing]]
- [[decommission-and-locking]]
- [[generate-bill-tiered-pricing]]
- [[generate-bill-with-multiplier]]
- [[generate-bill-min-max-cap]]
- [[../prepaid/auto-deduction-design]](类似的"Service + Action"分层借鉴)

View File

@@ -0,0 +1,252 @@
---
title: prop-acc · meter · 表退役与读数锁定
aliases:
- 表退役
- decommission
- 读数锁定
- reading lock
- MeterDecommissionReason
tags:
- 概念
- prop-acc
- 计量表
- 数据完整性
audience:
- 业务人员
- 架构师
status: 已发布
sub_feature: meter
last_review: 2026-05-25
code_version: 2026-05-22
---
# 表退役与读数锁定
Meter / MeterReading 模块有**两套不可变保护机制**保证数据完整性:
1. **表退役 (Decommission)** — 表停用后只读,不能改 / 不能删 / 不能继续抄
2. **读数锁定 (Reading lock)** — Reading 一经创建不可改;**一旦生成 Bill 更不可改 / 不可删**
两套机制保证**审计可追溯**,防止"事后改数据"导致账单与历史不符。
## 第 1 套:表退役机制
### 5 种退役原因
`MeterDecommissionReason` 枚举:
| 枚举 | 中文 | 业务场景 |
|---|---|---|
| `Damaged` | 损坏 | 表内电路烧 / 表头读不出 / 物理损坏 |
| `Replaced` | 更换 | 校验未通过 / 老化主动换,新表带 `-R1` 后缀(详见 [[replacement-chain]]) |
| `Removed` | 拆除 | 房屋拆迁 / 业主搬走永久弃用 / 重装时拆掉 |
| `Expired` | 到期 | 表的法定使用年限到(法律规定,需校验后定换或保留)|
| `Calibration` | 校验 | 送检校验中暂停使用(校验后可重启 / 退役)|
### 退役后的行为
```mermaid
stateDiagram-v2
[*] --> InUse : 装机
InUse --> InUse : 抄表 / 编辑配置
InUse --> Decommissioned : decommission()
Decommissioned --> [*]
Decommissioned --> Decommissioned : 只读,不可改 / 不可删 / 不能抄
```
退役表(`is_active=false` + `decommissioned_at` 已填)的能力对照:
| 操作 | InUse(`is_active=true`)| **Decommissioned(`is_active=false`)** |
|---|---|---|
| `EditAction`(改 code / multiplier / fee_type) | ✅ | **❌**(UI 灰化 + Policy 拦截)|
| `ReplaceMeterAction`(换表)| ✅ | **❌**(已退役无可换)|
| 录新 reading | ✅ | **❌**(业务上无表可读)|
| 看历史 reading | ✅ | ✅(只读)|
| 看历史 Bill | ✅ | ✅(只读)|
| `DeleteAction`(物理删除)| **❌**(UI 移除 + Policy 拦截)| **仅允许"已退役 + 无任何读数"**(罕见)|
> [!warning] 为什么退役表不能改 code / multiplier
> 退役表是**历史档案**。改 code / fee_type / multiplier 不会回填到历史 reading / Bill,会让"历史档案"和"当时实际计费配置"对不上号:
>
> | 反例 | 后果 |
> |---|---|
> | 已退役表把 multiplier 从 1 改成 10 | 业户翻历史账单,看到 reading consumption 显示翻 10 倍,但当时账单金额没翻 → 业户困惑、质疑系统数据准确性 |
> | 已退役表把 code 改个名 | 历史抄表照片上的物理表号对不上系统 code → 审计追溯断链 |
>
> `EditAction` 在 `is_active=false` 时**三处**(Table 行 / ViewMeter / EditMeter)隐藏,`MeterPolicy::update()` 服务端兜底。
### 退役表的物理删除
`MeterPolicy::delete()` 默认拦截 —— 退役表是**历史档案**,正常情况下不该删。**唯一允许**:
```php
// MeterPolicy::delete()
public function delete(AuthUser $user, Meter $meter): bool
{
return $user->can('delete meters')
&& ! $meter->is_active // 必须先退役
&& ! $meter->hasReadings(); // 且无任何读数(罕见,只在"误建表后未抄过"清理)
}
```
**业务场景**:某物业误建了张表(填错 asset),还没抄表就发现错了 → 退役 + 删除。
**反例**:已抄过表的表**绝不能删**,删了会级联抹掉历史 reading + Bill 关联,等于消灭审计证据。
> [!info] 历史:issue.md Q5 第二轮的修复
> 原本 `MeterPolicy` 是**空壳**(从父类继承默认 allow),Table 上的 `DeleteAction` / `DeleteBulkAction` / `EditMeter` 上的 `DeleteAction` 全暴露 → 业务人员一键级联删历史。第二轮修复:
>
> - 删 Table 行 `DeleteAction`
> - 删 Table toolbar `DeleteBulkAction`
> - 删 EditMeter 页 `DeleteAction`
> - `MeterPolicy::delete()` 服务端兜底
>
> 三处 UI 入口移除 + 一层 Policy 防御 = 双保险。
## 第 2 套:Reading 锁定机制
### Reading 创建后不可改
`MeterReading` 一经创建**只读**(模型设计,无 Update 入口):
| 字段 | 创建后可改吗 |
|---|---|
| `current_reading` | ❌ |
| `consumption` | ❌(算出来的)|
| `read_at` | ❌ |
| `source` | ❌ |
| `operated_by` | ❌ |
| `photo_url` | ❌ |
| `memo` | ❌(严格)|
| `bill_id` | ❌(由系统填写,业务人员不动)|
**业务上"修正错误读数"** 走专门流程([[exception-readings-locked-after-bill]]):
1. **如果还没生成 Bill**:理论上可以删 reading + 重建(看 Policy 允许)
2. **如果已生成 Bill**:必须先**作废 Bill** → 改 reading(实际是建新 reading + 标旧 reading 作废)→ 重生成 Bill
### 已生成 Bill 的 Reading 更严
如果 `MeterReading.bill_id != null`,有**双锁**:
```mermaid
flowchart TD
A[Reading 创建] --> B[Reading 只读<br/>第 1 锁]
B --> C{有 bill_id 吗?}
C -->|有| D[更不可改<br/>更不可删<br/>第 2 锁]
C -->|无| E[暂时可删<br/>看 Policy]
```
`MeterReadingPolicy`:
```php
// MeterReadingPolicy.php(伪代码)
public function update(AuthUser $user, MeterReading $reading): bool
{
// 几乎永远 false(Reading 不可改)
return false;
}
public function delete(AuthUser $user, MeterReading $reading): bool
{
return $user->can('delete meter readings')
&& $reading->bill_id === null; // 已生成 Bill 不可删
}
```
UI 同步:
- `MeterReadingsRelationManager``EditAction->visible(fn ($r) => $r->bill_id === null)`
- 已生成 Bill 的 reading 在 UI 上 Edit / Delete 按钮**自动灰化**
> [!info] 历史:issue.md Q5 第二轮的修复
> 原本 `MeterReadingsRelationManager` 行级 `DeleteAction` 完全暴露 → 已落账的 reading 可被删除 → Bill 数据脱节、审计断链。第二轮修复:
>
> - 删 `MeterReading.DeleteAction`
> - `EditAction` 加 `->visible(bill === null)`
> - `MeterReadingPolicy::update()` / `delete()` 服务端兜底要求 `bill === null`
## 两套机制的协同
```mermaid
flowchart LR
A[业务人员发现某 reading 数据错] --> B{Reading 有 bill 吗?}
B -->|没有| C[尝试删 reading<br/>看 Policy 是否允许]
C --> D[重建 reading]
D --> E[重生成 Bill]
B -->|有| F[先作废 Bill<br/>本身是复杂流程]
F --> G[Reading 上 bill_id 解除<br/>视设计]
G --> H[新建一条修正 reading<br/>+ 标旧 reading 作废]
H --> I[重生成 Bill]
```
第二种情况(已生成 Bill)是 issue.md Q5 "待补 / 已知问题"段中提到的:
> **"作废已生成 Bill 的 MeterReading"组合流程**:当前 Reading 一旦生成 Bill 即锁定。要修正错误读数需要先**作废 Bill** 再改 Reading 再重新生成。这个组合流程类似 AdHocEvent 的 `VoidAction`(级联废 + 留 voided 审计),需要单独设计。
**当前实施**:不支持自动化,运维 / 高权限人员手工通过 tinker 处理。常见场景见 [[exception-readings-locked-after-bill]]。
## 业务人员视角
### 退役表
通常场景:
- 检定到期 → `Calibration` → 送校验 → 校验后视情况
- 校验未过 → `Replaced` → 走 [[replacement-chain|换表]]
- 物理损坏 → `Damaged` → 走 [[replacement-chain|换表]] 或 `Removed` 不换
- 业户搬走永久弃用 → `Removed`
- 法定使用年限到 → `Expired` → 视情况换 / 退役
### 已锁定的 Reading
业务上常见的"修正错误读数"诉求:
- 抄表员手抖录错(2800 录成 2080)→ 已生成 Bill → 走作废 Bill 流程
- 集抄数据错 → 同上
- 业户拍照说"实际不是这个数字"→ 拿物理表当前读数对照 → 决定修正与否
修正不是常规操作,**预防胜于补救** —— 录入时多审核 + 拍照存证([[reading-source-and-photo-proof]])。
## 架构师视角
退役 + 锁定两套机制是**严肃的数据治理**:
- 保证账单与抄表历史**一致性**(不能修改产生过账单的数据)
- 保证物业有**审计抗辩能力**(被业户质疑时拿出原始记录)
- 保证**长期数据可信**(5 年、10 年后的查询仍准确)
替代方案(允许改)的代价:
- 业户质疑账单 → 物业拿不出真实凭证
- 内审查不出账面历史 vs 当时实际配置不一致
- 法律纠纷物业举证不利
## 常见问题
> [!question] 已退役表的 Reading 还能新建吗?
> 不能。物理表不存在了,业务上无可读。系统层面 `MeterReadingsRelationManager` 在 `is_active=false` 时隐藏 Create 按钮(应该实现,若没有需补)。
> [!question] 错误退役(其实表还好)能撤销吗?
> Policy 设计上**不允许**(`is_active` 从 false 改 true 没 UI 入口)。如果真需要撤销:
>
> - tinker 直接改字段(运维操作,留备注)
> - 或退役表保留 + 建一张新表(`is_active=true`,但失去更换链关联)
>
> **预防胜于补救**:退役前确认。
> [!question] Reading 和 Bill 哪个先?
> 时间上:Reading 先(抄表),Bill 后(生成账单)。数据上:Reading 创建即写入,Bill 是 reading 的派生。一旦 Bill 创建,会回写 `Reading.bill_id`。
> [!question] 删 Bill 会自动解锁 Reading 吗?
> 当前**不会自动**(Reading.bill_id 不会因为 Bill 删除而自动 nullify,看具体 cascade 配置)。Bill 作废后,需要业务流程明确"是否要重新生成"——如果重新生成,新 Bill 创建时回写 `Reading.bill_id`(覆盖旧的)。
## 相关文档
- [[meter-vs-meter-reading]]
- [[replacement-chain]]
- [[bill-generation-pipeline]]
- [[exception-readings-locked-after-bill]]
- [[decommission-without-replacement]]
- [[replace-broken-meter]]

View File

@@ -0,0 +1,143 @@
---
title: prop-acc · meter · 计量表与抄表流水
aliases:
- 计量表与抄表流水
- Meter 与 MeterReading
- 计量表的双对象模式
tags:
- 概念
- prop-acc
- 计量表
- 核心概念
audience:
- 业务人员
- 抄表员
- 财务
status: 已发布
sub_feature: meter
last_review: 2026-05-25
code_version: 2026-05-22
---
# 计量表与抄表流水
计量表模块底层是**两个对象**配合:**Meter**(物理表的配置)+ **MeterReading**(每次抄表的不可变流水)。模式上与 [[../deposit/deposit-account-vs-transaction|押金账户+流水]]同构,但**对象的物理性**让它有几条独特特征:
- **每张表是真实硬件**(电表 / 水表 / 燃气表),挂在具体房屋(`asset_id`)上
- **不直接产收据** —— 抄表产 `Bill`,后续业户付账单再走 [[../adhoc/collection-order-and-receipt|CollectionOrder + Receipt]]
- **没有"账户余额"概念**(没有 balance 字段),业户余额追踪在 [[../prepaid/prepaid-account-vs-transaction|预存款]]
## 为什么用双对象
> [!info] 类比:实体水表
> - **Meter** = 表面板,上面写编号、安装时间、倍率
> - **MeterReading** = 每次抄表的本子记录:几号几月几日读了多少、谁抄的、有没有拍照
如果只有 Meter 没有 Reading → 不知道每月用了多少,无法算账单。
如果只有 Reading 没有 Meter → 不知道表的物理属性(倍率、装在哪、什么费用),Reading 是孤立数字。
所以两个都要。
## 字段速查
### Meter(计量表)
| 字段 | 含义 |
|---|---|
| `id` | 表 ID |
| `community_id` | 所属物业项目 |
| **`asset_id`** | **绑定的房屋资产**(必填,通过 asset 找业户)|
| **`fee_type_id`** | **费用类型**(水费 / 电费 / 燃气费,决定单价)|
| `replaced_meter_id` | 上一代表(`null` = 原生新表,见 [[replacement-chain]]) |
| `code` | 表编号(物理表牌号,通常物业自编)|
| **`multiplier`** | **倍率**(decimal(10,4),工业表 10/100/1000)|
| `initial_reading` | 初始读数(decimal(12,2)) |
| `is_active` | 是否在役 |
| `installed_at` | 安装日期 |
| `decommissioned_at` | 退役日期(null = 在役)|
| `decommission_reason` | 退役原因(5 种枚举,详见 [[decommission-and-locking]])|
| `final_reading` | 退役时最终读数 |
### MeterReading(抄表流水)
| 字段 | 含义 |
|---|---|
| `id` | 流水 ID |
| `meter_id` | 归属表 |
| `read_at` | 抄表日期 |
| `current_reading` | 本次读数(物理表头数字)|
| `previous_reading` | 上次读数(自动从最近一条 reading 取)|
| **`consumption`** | **用量**(=(current - previous) × multiplier,自动算)|
| `source` | 来源:`manual`(手抄) / `remote`(集抄,详见 [[reading-source-and-photo-proof]])|
| `photo_url` | 拍照存证 URL(可选 / 集抄无)|
| `operated_by` | 抄表员 ID(manual 类型必填) |
| `bill_id` | 关联生成的账单(若已生成 Bill,null = 未生成)|
| `memo` | 备注 |
| 创建后**不可变** | 一旦生成只读;若 `bill_id != null` 更不可改(双锁)|
## 两者的契约
- **每张 Meter 可有多条 MeterReading**(理想情况是每月一条)
- **MeterReading 必属于一张 Meter**(`meter_id` NOT NULL)
- **最新 reading 的 current_reading = 该 meter 的"当前累计读数"**(Meter 自身不存 current,从 reading 推出)
- **consumption = (current - previous) × multiplier**:倍率乘进去就是真实用量(度 / 吨 / 立方米)
## 与"账户+流水"型模块的本质差异
| 维度 | Account + Transaction(deposit / prepaid)| **Meter + MeterReading** |
|---|---|---|
| 主对象表达什么 | 账户余额 | **物理表的配置** |
| 流水表达什么 | 资金变动(deposit / refund / consume) | **抄表读数(用量计算源)** |
| 主对象有 balance 吗 | ✅ 有 | **❌ 无**(余额是用量,在 Reading 里) |
| 流水方向 | + / -(余额加减)| **无方向**(只有"本月读了多少") |
| 自动关账 | 看模块(deposit 自动,prepaid 不) | 走"退役 (decommission)" 不是"关账" |
| 写入操作种类 | 多种(deposit / consume / refund / forfeit) | **单一**(抄表 record_reading)+ 换表 / 退役 |
| 直接产 Receipt | 是 | **否,通过 Bill 中转** |
## 业户视角
业户**通常不直接接触 Meter / MeterReading 概念**。看到的是:
- 月底物业 App 推送账单"5 月电费 ¥168,用电 280 度"
- 收据"水费 ¥54"
- 偶尔小程序"我的计量"页可查看本月用量趋势(若开启)
底下的 Meter 和 MeterReading 是后台运营的事。**唯一**会接触的:**业户对账单金额有异议**时,可申请看历次抄表记录(系统应能展示该业户表的全部 reading 历史)。
## 抄表员视角
李师傅是物业聘的抄表员,每月固定时间挨家挨户(或集中点位)读表头数字:
- 用物业 App / 抄表机 录入 → 系统建 MeterReading(`source=manual`,`operated_by=李师傅`)
- 拍照可选(详见 [[reading-source-and-photo-proof]])
- 如果该社区接入集抄系统(IoT 表),系统自动生成 reading(`source=remote`),李师傅不用跑
## 业务人员视角
物业财务的工作:
-`MeterDashboard`:本月待抄表清单、高用量异常、按费用类型用量走势
- 录入 / 校验 reading(若有异议)
- 触发 `GenerateBillsFromMeterReadingsAction` → 自动生成所有未结账的 reading 对应的 Bill
- 月底对账:每张 reading 是否都有对应 Bill,有 Bill 是否已 Paid
## 系统视角:Bill 生成是单独一步
```mermaid
flowchart LR
A[抄表员录入 MeterReading] -.第一步.-> B[MeterReading<br/>bill_id=null]
B -->|GenerateBillsFromMeterReadingsAction| C[Bill 生成<br/>+ MeterReading.bill_id 回写]
C -.业户付账单.-> D[CollectionOrder + Receipt<br/>付款方式可选现金/微信/预存款]
```
**关键**:MeterReading **不直接**产 CollectionOrder + Receipt。要先经过 **Bill 生成步骤**(可能是月底批量,可能是抄表后立即),然后业户**付账单**才走收款流程。计量表是**计费源**,不是收款源。
## 相关文档
- [[replacement-chain]]
- [[multiplier-and-tiered-pricing]]
- [[bill-generation-pipeline]]
- [[reading-source-and-photo-proof]]
- [[decommission-and-locking]]
- [[../prepaid/consume-via-bill-collection-type]](账单视角的收款,与本模块呼应)
- [[../deposit/deposit-account-vs-transaction]](账户+流水模式对比)

View File

@@ -0,0 +1,244 @@
---
title: prop-acc · meter · 倍率与阶梯计价
aliases:
- 倍率
- 阶梯计价
- multiplier
- tiered pricing
- min max 封顶
tags:
- 概念
- prop-acc
- 计量表
- 计费
audience:
- 业务人员
- 财务
- 架构师
status: 已发布
sub_feature: meter
last_review: 2026-05-25
code_version: 2026-05-22
---
# 倍率与阶梯计价
抄表生成账单的金额由三层叠加算出:**倍率(multiplier)** × **阶梯计价(tiered)** + **min/max 封顶**。这三层都在 `MeterBillCalculator` 内实现(纯算,无 DB IO),是 prop-acc 模块中**最严谨的算法之一**。
## 一句话总览
```
本月用量 = (current_reading - previous_reading) × multiplier
本月金额 = 阶梯计价(本月用量, RatePlan)
最终金额 = clamp(本月金额, min, max)
```
详见下文每层展开。
## 第 1 层:倍率(multiplier)
### 用途
工业 / 集团表的**实际计量值远大于表头数字**:
| 表型 | 表头读数 | multiplier | 实际用量 |
|---|---|---|---|
| 家用单相表 | 280 度 | 1 | 280 度 |
| 三相工业表 | 28 度 | **10** | 280 度 |
| 大型集团表(高压侧)| 28 度 | **100** | 2,800 度 |
物理原理:工业表为了在小表头显示大用量,内部有变压 / 分流比。multiplier 就是这个比值。
### 字段精度
```php
// migration
$table->decimal('multiplier', 10, 4);
// model casts
'multiplier' => 'decimal:4',
```
decimal(10,4) 精度足够工业场景(常见 1.0 / 10.0 / 100.0 / 1000.0,极少数 0.5 / 1.25 等特殊变比)。
### 用量计算
```
consumption = (current_reading - previous_reading) × multiplier
```
例:三相表上月 280,本月 308,multiplier=10:
```
consumption = (308 - 280) × 10 = 280 度
```
业户账单按"280 度"算,不是"28 度"。
## 第 2 层:阶梯计价(Tiered Pricing)
### 用途
水电气**阶梯递增**:用得越多,单价越高。鼓励节约,符合国家政策。
例:某市水阶梯计价:
| 阶梯 | 月用水(吨)| 单价 |
|---|---|---|
| 第一阶梯 | 0-20 | 3.0 元/吨 |
| 第二阶梯 | 21-30 | 4.5 元/吨 |
| 第三阶梯 | 30 以上 | 6.0 元/吨 |
业户本月用 35 吨:
| 段 | 用量 | 单价 | 段金额 |
|---|---|---|---|
| 0-20 吨(第一阶梯) | 20 | 3.0 | 60 |
| 21-30 吨(第二阶梯) | 10 | 4.5 | 45 |
| 31-35 吨(第三阶梯) | 5 | 6.0 | 30 |
| **合计** | **35** | | **135** |
### Progressive 累进 vs Full-tier 简陋实现
> [!info] 本系统采用 **progressive 累进**(正确算法),不是 full-tier 简陋实现。
| 算法 | 35 吨水 |
|---|---|
| **Progressive 累进**(本系统)| 20 × 3 + 10 × 4.5 + 5 × 6 = 135 元 ✅ |
| Full-tier 简陋(错)| 35 × 6 = 210 元 ❌(整月用量按最高阶梯计)|
| Mixed(更错)| 35 × 4.5 = 157.5 元 ❌ |
progressive 是国家政策标准做法,简陋实现是市场上劣质系统的常见 bug。`MeterBillCalculator::calculateTiered()` 实现得对,所以我们的账单准确。
### RatePlan + RateTier 模型
阶梯定义在 `RatePlan` + `RateTier` 模型里(不属于本子模块,在更通用的费率模块):
```
RatePlan ─── RateTier (1..n)
├── tier=1, lower=0, upper=20, unit_price=3.0
├── tier=2, lower=20, upper=30, unit_price=4.5
└── tier=3, lower=30, upper=null, unit_price=6.0
```
`fee_type_id`(在 Meter 上)指向 RatePlan,Calculator 拿到 RatePlan → 按 tier 累加段金额。
## 第 3 层:min / max 封顶
### 用途
防止**极端用量**导致离谱账单:
| 场景 | 问题 | 封顶方案 |
|---|---|---|
| 业户家漏水,1 月用 1000 吨 | 按阶梯算 ~ 6000+ 元 | `max=2000` 封顶,差额走维修保险 |
| 表故障读 0 度 | 业户不付钱了 | `min=20` 兜底(至少收基础费)|
| 业户家整月没人(零用量)| 看物业政策 | `min=20` 仍要收(物业服务费性质)|
### 实现
`RatePlan` 上有 `min_amount` / `max_amount` 字段:
```
final_amount = max(min_amount, min(calculated_amount, max_amount))
```
例:计算出 ¥6,000 但 `max_amount=2000` → 实际收 ¥2,000,差额 ¥4,000 由物业 / 维修保险承担(账面体现为"封顶减免")。
### 业务上的取舍
> [!warning] 封顶不是万能的
> max 封顶让业户感激,但物业要承担差额。建议:
>
> - 设合理的 max(覆盖正常波动范围)
> - 异常用量先排查([[exception-high-consumption]])再决定是否减免
> - 封顶降低收入,需评估对物业财务可持续性影响
min 类似:
> [!info] min 的政策意义
> min 通常对应"管网维护费 / 基础服务费",即使零用量也要分摊管网成本。但在国家政策严格的地区,要明确告知业户"min 是什么"。
## 完整算法流程
```mermaid
flowchart TD
A[抄表 current_reading] --> B[查询上次 reading]
B --> C[计算 consumption =<br/>(current - previous) × multiplier]
C --> D[加载 RatePlan + RateTier]
D --> E[Calculator.calculateTiered<br/>按阶梯累加段金额]
E --> F{有 min/max?}
F -->|有 max & 金额超| G[封顶到 max]
F -->|有 min & 金额低| H[补到 min]
F -->|正常范围| I[原值]
G --> J[最终账单金额]
H --> J
I --> J
```
## 完整算例
电费阶梯(假设):
| 阶梯 | 用电度数 | 单价 |
|---|---|---|
| 1 | 0-200 | 0.5 |
| 2 | 200-400 | 0.6 |
| 3 | 400+ | 0.8 |
`min_amount=10`,`max_amount=500`
工业表(三相,multiplier=10),5 月抄表 308,上月 280:
```
1. consumption = (308 - 280) × 10 = 280 度
2. 阶梯:200 × 0.5 + 80 × 0.6 = 100 + 48 = 148 元
3. 封顶:10 ≤ 148 ≤ 500 → 不动
4. 最终账单:148 元
```
家用表(multiplier=1),5 月抄表 1100,上月 800:
```
1. consumption = (1100 - 800) × 1 = 300 度
2. 阶梯:200 × 0.5 + 100 × 0.6 = 100 + 60 = 160 元
3. 封顶:10 ≤ 160 ≤ 500 → 不动
4. 最终账单:160 元
```
漏水 case(家用表,水阶梯,multiplier=1),本月用 1000 吨:
```
1. consumption = 1000 吨
2. 阶梯:20 × 3 + 10 × 4.5 + 970 × 6 = 60 + 45 + 5820 = 5925 元
3. 封顶:5925 > max_amount(假设 2000)→ 封到 2000
4. 最终账单:2000 元(差额 3925 由物业 / 维修保险承担)
```
## 业务人员视角
- **配置阶梯**:在 RatePlan + RateTier 模型里设置(不在 meter 子模块,通常运营 / 财务总监来设)
- **配置倍率**:建表时 / 后续编辑表时填(`MeterForm`,默认 1)
- **核对账单**:看到异常金额时,顺着 Calculator 算法手工验算
## 财务视角
- 阶梯计价 = 政策合规
- 倍率确保工业表账单正确
- min/max 封顶 = 风险控制 + 业户友好(max)
## 业户视角
业户**不需要懂这套算法**,看到的是账单金额。但**对账时**要能理解:
- 上月读数 → 本月读数 → 用量(度 / 吨 / 立方米)
- 用量 → 阶梯单价 → 金额
- 如有封顶 → 凭证显示
## 相关文档
- [[meter-vs-meter-reading]]
- [[bill-generation-pipeline]]
- [[generate-bill-tiered-pricing]]
- [[generate-bill-with-multiplier]]
- [[generate-bill-min-max-cap]]
- [[exception-high-consumption]]

View File

@@ -0,0 +1,206 @@
---
title: prop-acc · meter · 抄表来源与拍照存证
aliases:
- 抄表来源
- 抄表拍照存证
- manual vs remote
- MeterReadingSource
tags:
- 概念
- prop-acc
- 计量表
- 数据字典
audience:
- 业务人员
- 抄表员
- 财务
status: 已发布
sub_feature: meter
last_review: 2026-05-25
code_version: 2026-05-22
---
# 抄表来源与拍照存证
每条 `MeterReading`**`source` 字段**标记**抄表数据来源**(`manual` 手抄 vs `remote` 集抄),配合 **`photo_url`** 拍照存证,保证抄表数据**可追溯、可核对、有凭据**。
## `MeterReadingSource` 枚举
```php
enum MeterReadingSource: string
{
case Manual = 'manual'; // 抄表员手动录入
case Remote = 'remote'; // 集抄系统(IoT)自动上报
}
```
只有两种,简洁明了。
## 两种来源对照
| 维度 | `manual`(手抄)| `remote`(集抄 / IoT)|
|---|---|---|
| 触发 | 抄表员到现场读表 + 录入 | 物联网集抄系统定时推送 |
| 数据流 | 抄表员手机 / 抄表机 → App / 后台 | IoT 网关 → 后端 API → 系统 |
| 速度 | 慢(几天到几周完成全社区)| 快(几小时全社区)|
| 准确性 | 中(可能手抖 / 看错)| 高(设备直接传)|
| 拍照存证 | **推荐**(`photo_url` 必填或强烈推荐)| 自动 |
| 操作员(`operated_by`)| 必填(抄表员 ID)| null 或系统账号 |
| 业务上常见 | 中小社区 / 老旧设备 | 新建社区 / 升级改造后 |
| 故障率 | 抄表员漏抄、误抄、不录入 | IoT 设备掉线、数据丢失 |
## 拍照存证(`photo_url`)
### 为什么要拍照
抄表是物业**收业户钱**的依据。万一业户质疑账单金额,物业要拿出证据"5 月 X 日,您家表头读数确实是 280 度,这是抄表当天的照片"。
> [!info] 真实情境
> 张阿姨 5 月电费账单 ¥168(280 度),她声称"我家不可能用这么多",要看证据。
>
> 物业打开后台 → 找 5 月那条 reading → 看 `photo_url` → 给业户看现场拍的表头照片("280" 清晰可见,旁边日期戳)→ **业户无话可说**。
### 实施细节
| 字段 | 实施 |
|---|---|
| `photo_url` | 存储到对象存储(S3 / 阿里云 OSS / 本地)的 URL |
| 上传时机 | 抄表 App 录入读数同时上传(强制)|
| 上传时机(批量导入)| Excel 批量导入时**没有拍照** → 业务上要求抄表员当场拍 + 单独留存,导入时只录数字 |
| 上传时机(remote)| 集抄无拍照(IoT 设备本身就是凭证)|
| 数据保留 | 法律 / 业务规定保留期(通常 3-5 年),与账单同周期 |
### 业务流程上的强制度
| 物业政策 | 实施 |
|---|---|
| **必须拍照**(严)| `MeterReadingsRelationManager` Form 上 `photo` 字段标 `->required()`,无照片不能提交 |
| **建议拍照**(中)| Form 上 `photo` 可空,UI 上有"建议拍照"提示 |
| **不强制**(松)| Form 上 `photo` 可空,无任何提示 |
当前实现**看具体配置**(应该是建议拍照,可空)。生产环境**强烈推荐"必须拍照"**,避免后续举证困难。
## 集抄(remote)的对接
### 数据流
```mermaid
sequenceDiagram
participant Meter[物理表]
participant Gateway[IoT 网关]
participant Backend[第三方集抄平台]
participant API[本系统 API]
participant DB
Note over Meter: 物理表通过 RS485 / NB-IoT 等连网关
loop 定时(每天/每月)
Meter->>Gateway: 读数推送
Gateway->>Backend: 上传(GSM/4G/有线)
Backend->>API: HTTP POST(批量推 reading)
API->>DB: 建 MeterReading(source=remote)
end
```
### 触发账单生成
集抄推数后**通常立即触发账单生成**(详见 [[bill-generation-pipeline]]):
- 集抄 API 接收 → 写 MeterReading → 同事务内或立即触发 `GenerateBillsFromMeterReadingsAction`
- 或者每月固定时间批量(避免账单生成时机不一致)
### 集抄掉线的兜底
| 集抄掉线 | 处置 |
|---|---|
| 个别表掉线(少数)| 抄表员补抄(`source=manual` + 备注"集抄掉线")|
| 大面积掉线(网关故障)| 集抄运维介入 + 物业短期手抄兜底 + 排查恢复 |
| 长期掉线(>2 周)| 重新评估集抄设备的可靠性 |
## 手动抄表的实施
### 工具
- 抄表员手机 App(物业自研 / 第三方)
- 老式:纸质本子 + 后台输入(误差大,逐步淘汰)
### 流程
```mermaid
flowchart TD
A[抄表员到现场] --> B[读表头]
B --> C[拍照]
C --> D[App 上录入<br/>读数 + 上传照片]
D --> E[同步到后台]
E --> F[业务人员审核 / 直接生成账单]
```
### 操作员追踪
每条 `MeterReading``operated_by` 字段(抄表员 ID):
- 业户对账单有疑问 → 后台查 → 看是谁抄的
- 抄表员考核:本月抄了多少表、有无遗漏
- 异常追责:某抄表员的读数频繁错 → 培训 / 换人
## 业务人员视角
后台 `ViewMeter` → 看 Reading 列表(`MeterReadingsRelationManager`),每条 reading 显示:
| 列 | 内容 |
|---|---|
| 抄表日期 | `read_at` |
| 读数 | `current_reading` |
| 用量 | `consumption` |
| 来源 | `manual` / `remote`(图标区分)|
| 抄表员 | `operated_by`(`manual` 时显示)|
| 拍照 | `photo_url`(有图标,点开看照片)|
| 备注 | `memo` |
## 抄表员视角
抄表员李师傅每月 25-30 号集中抄表:
- 拿手机 App 出门
- 按片区(楼栋)走,App 自动按楼层 / 房号给清单
- 每家:开门(若有人)→ 找表 → 读数 → 拍照 → 录入
- 数据**实时上传**(网络可用时)或**离线缓存**(无网时回去再传)
- 当月所有表抄完 → App 显示"完成度 100%" → 提交
每天 KPI:60-100 户(看小区密度)。
## 业户视角
业户**通常感知不到抄表细节**,只看月底账单。对账单有异议时:
- 联系物业询问
- 物业出示 `photo_url` 证据
- 如果证据充分 → 业户认可
- 如果证据不足(无照片 + 抄表员说不清)→ 物业可能减免 / 让业户支付平均月用量
## 系统视角:不可变 + 双锁
详见 [[meter-vs-meter-reading]] "两者的契约" + [[decommission-and-locking]] "已生成 Bill 的 Reading 锁定" 段。
- MeterReading 一经创建**不可改**
- 一旦生成 Bill 后**更不可改**(Policy 双锁)
- 任何错误走"作废 Bill → 改 Reading → 重生成 Bill"的组合流程(详见 [[exception-readings-locked-after-bill]])
## 待补 / 已知问题
| 项 | 状态 |
|---|---|
| 集抄回调签名校验 / 防重放 | 未文档化(可能已实现,看具体集成 |
| 抄表照片的对象存储清理(留存期满)| 未实现,需求看物业法务定 |
| 抄表员位置 / 时间戳防作弊(GPS + 时间)| 未实现,部分物业有此需求 |
| 拍照强制 + AI 识别照片内读数比对录入 | 未实现,高大上但实施成本高 |
## 相关文档
- [[meter-vs-meter-reading]]
- [[bill-generation-pipeline]]
- [[decommission-and-locking]]
- [[read-single-meter-manual]]
- [[read-batch-via-excel-import]]
- [[read-via-iot-remote-source]]
- [[read-with-photo-proof]]

View File

@@ -0,0 +1,205 @@
---
title: prop-acc · meter · 表更换链
aliases:
- 表更换链
- Meter Replacement Chain
- replaced_meter_id
- R1 R2 后缀
tags:
- 概念
- prop-acc
- 计量表
- 数据模型
audience:
- 业务人员
- 抄表员
- 架构师
status: 已发布
sub_feature: meter
last_review: 2026-05-25
code_version: 2026-05-22
---
# 表更换链
物理表会**老化、损坏、定期校验**。旧表换新表后,系统通过 **`replaced_meter_id`** 字段把新表指回旧表,**初始读数继承**(避免业户被白嫖一段用量)。新表编号自动加 **`-R1` / `-R2` / ...** 后缀,**整条更换链**(代代相承)在数据库里可追溯。
## 为什么要更换链
> [!info] 真实情境
> 张阿姨家电表(编号 E-501)用了 8 年,2026 年 5 月 物业例行校验发现读数跳变(怀疑表内电路老化),要换新表。
>
> 换表那天:
> - 旧表 E-501 最后读数 = 5,000 度
> - 新表 E-501-R1 出厂 = 0 度,但**初始读数继承 5,000 度**
> - 否则:新表从 0 起 → 业户下个月看着账单(假设用了 50 度)→ 但系统算的 "5050 - 0 = 5050 度"全要业户付 → 灾难
更换链保证**用量计算连续**,业户感知无差异。
## 数据模型
### 字段关系
```mermaid
flowchart LR
A[Meter 旧表<br/>E-501<br/>final_reading=5000<br/>is_active=false<br/>decommissioned_at=2026-05-15<br/>decommission_reason=Replaced] -->|被 replaced_meter_id 指回| B[Meter 新表<br/>E-501-R1<br/>initial_reading=5000<br/>is_active=true<br/>installed_at=2026-05-15<br/>replaced_meter_id=旧表 ID]
```
### 字段语义
| Meter 字段 | 旧表(被换)| 新表(替代)|
|---|---|---|
| `code` | E-501 | **E-501-R1**(`nextReplacementCode()` 自动算)|
| `is_active` | **false** | true |
| `installed_at` | 8 年前 | 2026-05-15(换表当天)|
| `decommissioned_at` | **2026-05-15** | null |
| `decommission_reason` | `Replaced`(枚举,见 [[decommission-and-locking]])| null |
| `final_reading` | **5000**(换表前最后读数)| null |
| `initial_reading` | (历史值,不动)| **5000**(继承自旧表)|
| `replaced_meter_id` | null(无上一代)| **旧表 ID** |
## 换表后的抄表数据
```mermaid
flowchart TD
A[E-501 最后 reading<br/>2026-04-30 → 5000 度] --> B[换表 2026-05-15]
B --> C[E-501-R1 第一次抄表<br/>2026-05-31 → ?]
C --> D[计算 5月用量]
D --> E{用量公式}
E -->|新表上累计读数| F[假设新表读到 50 度<br/>但 initial_reading=5000<br/>所以 current = 5050]
F --> G[consumption = (5050 - 5000) * multiplier = 50 度]
```
**关键**:新表 `initial_reading=5000` **不是**抄表的起点,而是用于计算用量的**基准**。下次抄表时:
```
current_reading新表表头读数 + initial_reading= 50 + 5000 = 5050
previous_reading = 5000继承自旧表最后读数
consumption = (current - previous) × multiplier = (5050 - 5000) × 1 = 50
```
业户付的就是 5 月实际用的 50 度,不是 5050。
> [!warning] 抄表员要注意
> 抄表系统**显示给抄表员的数字是物理表头的数字**(50),系统**内部存的是叠加值**(5050)。如果系统设计不一致(让抄表员录 5050),会让人困惑。当前实现需查 `MeterReadingsRelationManager` / `MeterReadingsImporter` 看具体如何处理。
## 整条链的追溯
一张表可能多次换:
```
E-501 (原生) → E-501-R1 (第一次换) → E-501-R2 (第二次换) → E-501-R3 (第三次换)
```
每张表 `replaced_meter_id` 指上一代。后台 / API 可以:
```php
// 找当前在役表
$current = Meter::where('asset_id', $assetId)
->where('fee_type_id', $feeType)
->where('is_active', true)
->first();
// 顺着链向上追溯
$predecessors = [];
$cursor = $current;
while ($cursor->replaced_meter_id) {
$cursor = Meter::find($cursor->replaced_meter_id);
$predecessors[] = $cursor;
}
// $predecessors 现在是 [R2, R1, 原生] 的逆序数组
```
业务上可用于:
- 业户对历史用量有异议:看哪张表抄出来的
- 表更换历史报表
- 长期累计用量
## `nextReplacementCode()` 实现
代码 `Meter::nextReplacementCode($oldMeterCode)` 算法:
```php
// 伪代码
function nextReplacementCode(string $oldCode): string
{
// E-501 → E-501-R1
// E-501-R1 → E-501-R2
// E-501-R2 → E-501-R3
if (preg_match('/^(.*)-R(\d+)$/', $oldCode, $matches)) {
return $matches[1] . '-R' . ($matches[2] + 1);
}
return $oldCode . '-R1';
}
```
业务人员**不需要手动想**新表编号,系统自动算。
## 操作:`ReplaceMeterAction`
后台 → 计量表 → 找旧表 → 进 `ViewMeter` → 点 `ReplaceMeterAction`
Modal 表单:
| 字段 | 填什么 |
|---|---|
| **旧表最后读数(`final_reading`)** | 现场拍照确认,如 `5000` |
| **退役原因(`decommission_reason`)** | 选 `Replaced`(其他选项见 [[decommission-and-locking]])|
| **退役日期** | 默认今天 |
| **新表编号** | 自动 `E-501-R1`(可改但不推荐)|
| **新表 multiplier** | 默认继承旧表(可改)|
| **新表安装日期** | 默认今天 |
| 备注 | "校验未通过,换新表" |
提交后系统在一个事务内:
1. 旧表 `is_active=false`, `decommissioned_at=今天`, `decommission_reason=Replaced`, `final_reading=5000`
2. 建新表 `is_active=true`, `installed_at=今天`, `replaced_meter_id=旧表.id`, `initial_reading=5000`, `multiplier=继承`
## 常见问题
> [!question] 旧表读数比新表初始低,会发生吗?
> 不会。新表的 `initial_reading` 就是旧表的 `final_reading`,逻辑上必然相等。
> [!question] 换表时业户家正在用电怎么处理?
> 实际换表过程要断电断水短暂时间,业户可感知。系统层面:
>
> - 旧表 `decommissioned_at` 和新表 `installed_at` 都填换表那天
> - 中间用电量(几分钟到几小时)的微小差异通常忽略
> - 严格的物业可在换表说明里告知业户"换表过程几分钟用电不计费"
> [!question] 换表后旧表的历史 MeterReading 还能查吗?
> 能。每条 reading 都关联 `meter_id`(旧表 ID),不会因换表丢失。审计可完整追溯。
> [!question] 误换表(其实不该换)能撤销吗?
> 不能直接撤销(MeterReading 不可变,Meter 状态也不轻易回滚)。要修复:
>
> - 物理上把新表退役(`is_active=false`, `decommission_reason=Removed`)
> - 把旧表重启(`is_active=true`, `decommissioned_at=null`)→ 但这种"复活"操作在 Policy 层可能被守护拒绝([[decommission-and-locking]])
>
> **预防胜于补救**:换表前确认。
> [!question] 同一张表换好多次,链很长怎么办?
> 链长本身不是问题,系统正常处理。如果链过长(>10 代),通常说明该表频繁出问题,业务上应:
>
> - 排查表的型号 / 安装环境
> - 考虑改型号 / 换品牌
> - 长链不影响数据查询性能(关联查询逐级递归,但物业表数量通常不大)
> [!question] 新表 `multiplier` 与旧表不同可以吗?
> 可以,但**强烈不推荐**。如果新换的表倍率不同(例如旧 1x → 新 10x),用量计算公式就变,容易让业户困惑。除非业务上有明确升级原因(从普通家用表换成工业表),否则**继承旧表 multiplier**。
## 异常分支
- 表损坏(非校验)→ 走 [[replace-broken-meter]] 场景(meter_decommission_reason=Damaged)
- 不换表只退役 → [[decommission-without-replacement]]
## 相关文档
- [[meter-vs-meter-reading]]
- [[decommission-and-locking]]
- [[multiplier-and-tiered-pricing]]
- [[replace-broken-meter]]
- [[decommission-without-replacement]]

View File

@@ -0,0 +1,155 @@
---
title: prop-acc · prepaid · 预存款账户状态机
aliases:
- 预存款账户状态机
- PrepaidAccount 状态机
- canOperate
tags:
- 概念
- prop-acc
- 预存款
- 状态机
audience:
- 业户
- 业务人员
status: 已发布
sub_feature: prepaid
last_review: 2026-05-25
code_version: 2026-05-22
---
# 预存款账户状态机
预存款账户三种状态:**Active(可用)** / **Frozen(冻结)** / **Closed(已关闭)**。状态机骨架与 [[../deposit/account-state-machine|押金账户]]相同,但有两条**重要差异**:
1. **零余额不自动关账** —— 业户可以继续充值复用账户
2. **没有 ForceClose** —— 一户一账 + 预存款纠纷罕见,不需要 deposit 那种 Frozen + 有余额的解困出口
## 三状态速查
| 状态 | 中文 | 何时进入 | 能做什么 |
|---|---|---|---|
| `Active` | 可用 | 新账户首次充值后 | 充值 / 消费 / 退款 / 冻结 / 关账(主动)|
| `Frozen` | 冻结 | 风控、欺诈嫌疑、内审等 | 看流水(只读)/ 解冻 |
| `Closed` | 已关闭 | 业户搬走 / 主动关账 / 长期失联归档 | 看流水(只读)|
## 状态机图
```mermaid
stateDiagram-v2
[*] --> Active : 开户 + 首次充值
Active --> Active : 充值 / 消费 / 退款(任意次数,余额可清零回升)
Active --> Frozen : freeze() 风控
Frozen --> Active : reactivate() 等同解冻
Active --> Closed : close() 业户搬走等
Closed --> [*]
note right of Active
余额 0 时不自动关账
业户可继续充值复用
end note
note right of Frozen
冻结期间禁止任何资金动作
canOperate = false
end note
note left of Closed
永久终态
新业务请开新账户
(开新账户会撞一户一账约束 —— 业务上需要重新审视)
end note
```
## 守护方法
| 方法 | 返回 true 的状态 | 用途 |
|---|---|---|
| `canOperate()` | Active **only** | 统一守护:deposit / consume / refund 都调 |
| `hasBalance()` | balance > 0 | 配合判断"账户有钱"语义 |
| `isClosed()` | status == Closed | 给 UI / Policy 判断用 |
> [!info] canOperate 是模型层的最严防御
> 与 deposit 的 `canDeposit` / `canWithdraw` 二分不同,prepaid 用**统一** `canOperate()` —— 因为预存款的所有资金动作(充值 / 消费 / 退款)在 Frozen 状态下都要拒绝,没有"只能加不能减"的中间逻辑。
>
> 这条规则的实施有**三层兜底**(同 deposit):
> - UI 层:Filament Action 的 `visible` 用 `canOperate()`
> - Policy 层:`PrepaidAccountPolicy::deposit / consume / refund` 各自检查状态
> - **模型层(最严)**:`PrepaidAccount::deposit() / consume() / refund()` 内置 `canOperate()` 检查
>
> 即使 Filament / Policy 全部绕过(tinker、artisan、第三方调用),模型方法仍会抛错。详见 [[exception-refund-on-frozen]] "三层守护" 段。
## 与 deposit 状态机的关键差异
### 差异 1:零余额不自动关账
deposit:
```
balance > 0 ─ refund/forfeit ──→ balance == 0 ──自动──→ Closed
```
prepaid:
```
balance > 0 ─ consume/refund ──→ balance == 0 ──不自动关──→ Active(可继续充值)
```
为什么 prepaid 不自动关?
| 理由 | 解释 |
|---|---|
| 一户一账 | 关了再开会撞 unique 约束;关掉是浪费 |
| 业户长期使用 | 预存款是"我以后用"的账户,余额清零只意味着这一轮用完,可以继续充 |
| 业务高频 | 业户可能下周又充值,频繁开关账户毫无意义 |
| Bill 关联 | 关账后再有未付账单,业户充值无处去 |
deposit 自动关是因为:
| 理由 | 解释 |
|---|---|
| 业务完结 | 押金交完 → 装修完 → 退或扣 → 业务结束 |
| 多账户合理 | 业户可以同时有装修押金、入驻押金、设备押金多个账户 |
### 差异 2:没有 ForceClose
[[../deposit/account-state-machine|押金账户]]有 ForceClose 解决"Frozen + 有余额 + 想关账"的困境。预存款**没有**这个机制,因为:
- 一户一账,纠纷场景罕见(业户与自己的钱一般不打架)
- 真要关 Frozen 账户:先 [[unfreeze-after-verification|解冻]] → [[refund-full-resident-moveout|退余]] → 关账
- 业务方实际遇到再实现(issue.md Q4 "待补"段已记录)
## ReactivateAccountAction = 解冻
历史代码里有个 `ReactivateAccountAction`,字面意思"重新激活"。**实际行为只允许 Frozen → Active**(等同解冻),不允许 Closed → Active(那条永久禁止,同 deposit)。
UI 文案已统一为"解冻"(图标 `lock-open`),与 deposit 模块对齐。
> [!warning] 不要被名字误导
> "Reactivate" 字面给人"撤销关账"的暗示,实际**做不到**。Policy 守护 + UI visible 都只在 `status === Frozen` 时显示。
## 业务人员视角
后台账户列表的"状态"列对应三个值:
- 看到 `Active`:绿色,所有操作可用
- 看到 `Frozen`:橙色,所有写入按钮变灰,只剩 `ReactivateAccountAction`
- 看到 `Closed`:灰色,完全只读
## 业户视角
业户感受不到状态机内部,只感受到:
- 余额能正常用 → Active
- 充值 / 抵扣失败,提示"账户冻结中" → Frozen
- 账户已关 → 无法再充值 / 抵扣,看流水可
## 相关文档
- [[prepaid-account-vs-transaction]]
- [[transaction-types]]
- [[freeze-suspected-fraud]]
- [[unfreeze-after-verification]]
- [[close-with-zero-balance-decision]]
- [[exception-refund-on-frozen]]
- [[../deposit/account-state-machine]]

View File

@@ -0,0 +1,198 @@
---
title: prop-acc · prepaid · 月初批量自动抵扣设计
aliases:
- 自动抵扣设计
- 月初批量抵账单
- auto-deduction-design
- 预存款自动消费 job
tags:
- 概念
- prop-acc
- 预存款
- 架构设计
- 待补
audience:
- 业务人员
- 架构师
status: 草稿
sub_feature: prepaid
last_review: 2026-05-25
code_version: 2026-05-22
---
# 月初批量自动抵扣设计(待补)
> [!warning] 本功能**代码未实现**
> 当前所有 consume 操作都是**业务人员手动触发**(在后台 `ConsumeAction`)。月初批量自动抵扣**是预存款产品的核心卖点之一**,但代码层暂未实现。本文档描述设计意图,等业务方明确需求后落地。
>
> issue.md Q4 "待补" 段记录:
> > 月初批量自动抵扣 scheduled job:扫所有 status=Active AND balance>0 的预存款账户,找各自社区/业户下未付账单,自动调用 ConsumeFromPrepaidAccountAction 抵扣(优先抵 due_at 最早的账单)。
## 为什么这个 job 重要
预存款的**产品价值主张**是:
> "业户预存一次,以后账单自动扣,不用月月手动操作"。
如果没有自动抵扣 job:
- 业户预存了 ¥5000,以为以后账单会自动扣
- 月底账单出来,**不会自动扣**,需要业务人员手动到每个账户上点 ConsumeAction
- 业户次月发现还欠物业费,余额还有 → 投诉
- 业务人员手动抵扣 100+ 户 → 工作量大,容易遗漏
**没有 job = 产品价值缺失 50%**
## 设计意图(待实现)
### 1. 触发时机
- **月初**(每月 1 日 00:30,避开 0 点高峰)
- 或**账单生成后立即触发**(若账单生成本身也在月初,可串行)
### 2. 扫描范围
```sql
-- 候选预存款账户
SELECT id, community_id, community_user_profile_id, balance
FROM acc_prepaid_accounts
WHERE status = 'active'
AND balance > 0;
```
### 3. 对每个候选账户,找未付账单
```sql
-- 该业户未付账单(按到期日升序,先抵最早的)
SELECT id, amount, due_at, bill_type
FROM acc_bills
WHERE community_id = ? -- 与账户同社区(跨社区不抵)
AND resident_id = ? -- 业户档案
AND status = 'unpaid'
ORDER BY due_at ASC;
```
### 4. 按账户余额贪心抵扣
```python
# 伪代码
balance = account.balance
for bill in unpaid_bills:
if balance <= 0:
break
if balance >= bill.amount:
consume(account, bill, bill.amount) # 全额抵
balance -= bill.amount
else:
consume(account, bill, balance) # 部分抵(若 Bill 支持)
balance = 0
```
> [!info] 是否支持部分抵扣?
> 当前 `ConsumeFromPrepaidAccountAction` 看实现是按完整账单金额走的。**部分抵扣**(余额不够全付时抵一部分)需要 `Bill.recordPayment()` 支持部分支付才能实现。若不支持,余额不够时**跳过该账单**,等下月或业户补充其他方式付。
### 5. 复用既有 Action
```php
// 伪代码 — Job 内调用既有 Action 类
app(ConsumeFromPrepaidAccountAction::class)->handle(
account: $account,
bill: $bill,
amount: $consumeAmount,
);
```
**关键**:Job 不重新实现 consume 逻辑,直接调 `ConsumeFromPrepaidAccountAction` —— 与 Filament 手动触发**走同一份代码**。守护、事务、Receipt 生成全都自动复用。
### 6. 失败容忍
- 单笔抵扣失败(余额不够 / 账户 Frozen / 数据异常) → 跳过,继续下一个
- 整个 Job 失败 → 记录到 `failed_jobs` 表,可重跑
- 不允许"全部成功才提交" —— 部分账户的抵扣成功不应因其他账户失败回滚
### 7. 通知策略(设计待定)
| 业户场景 | 通知 |
|---|---|
| 余额充足,账单全抵 | 推送"5 月账单已自动抵扣,余额还有 ¥X" |
| 余额不够,部分抵 | 推送"已抵 ¥X,还差 ¥Y 请补缴" |
| 账户冻结无法抵 | 不主动通知(等业户自己看到欠费) |
## 与手动 ConsumeAction 的关系
| 维度 | 手动 ConsumeAction(已实现)| 自动 Job(待实现)|
|---|---|---|
| 触发 | 业务人员后台点击 | Scheduled job(crontab / Laravel Scheduler)|
| 单次范围 | 单个账户 + 单张账单 | 全社区所有账户 + 所有未付账单 |
| 业务上 | 个别情况(业户主动来抵)| 月度默认行为(产品价值核心)|
| 代码 | `Filament/Resources/.../Actions/ConsumeAction.php` + `Actions/Prepaids/ConsumeFromPrepaidAccountAction` | 待添加 `Console/Commands/PrepaidAutoDeductionCommand.php`(或类似) |
| 复用业务 Action | ✅ 直接调 ConsumeFromPrepaidAccountAction | ✅ 同上 |
二者**共用业务层 Action**,只是触发方式不同。这是为什么 issue.md Q4 强调"未来批量自动抵扣 job 可直接复用此 Action"。
## 数据流(自动抵扣场景)
```mermaid
sequenceDiagram
participant Scheduler
participant Job
participant Account[PrepaidAccount]
participant Bill[Bill]
participant Consume[ConsumeFromPrepaidAccountAction]
participant 数据库
Note over Scheduler: 每月 1 日 00:30
Scheduler->>Job: dispatch PrepaidAutoDeductionJob
Job->>数据库: SELECT 全部 Active 余额>0 的预存款账户
数据库-->>Job: [account1, account2, ..., accountN]
loop 对每个 account
Job->>数据库: SELECT 该业户未付账单 ORDER BY due_at
数据库-->>Job: [bill1, bill2, ...]
loop 对每张账单
alt 余额够付
Job->>Consume: handle(account, bill, full_amount)
Consume->>数据库: 建 CO + PrepaidTransaction + 更新 Bill
else 余额不够
Job->>Job: 跳过(或部分抵,看 Bill 支持)
end
end
end
Job->>Scheduler: 完成 + 报告(抵扣总额、失败数)
```
## 待讨论 / 决策
业务方拍板前,以下问题需明确:
| 问题 | 选项 |
|---|---|
| **触发频率** | 每月 1 次 / 每周 / 每天扫(更及时但更频繁)|
| **触发时点** | 月初固定时间 / 账单生成事件触发 / 业户手动充值后立即触发本户 |
| **优先级排序** | due_at 升序(最早的先) / amount 升序(小额先抵清) / 账单类型(物业费先 → 水电费后)|
| **部分抵扣** | 支持 / 不支持 / 取决于账单类型 |
| **失败通知** | 业户立即通知 / 月底汇总通知 / 仅后台告警 |
| **跨社区策略** | 跨社区一律不抵(已确认) / 跨社区可抵(需重新设计) |
| **运维监控** | 抵扣金额 / 失败率 / 跳过原因分布 |
| **回滚机制** | 抵错怎么办?(理论上事务保证,但业务上若抵了不该抵的账单需手工补正) |
## 关联场景
待 job 实现后,场景文档 [[consume-batch-auto-monthly]] 会描述:
- Job 执行的完整时序
- 业户/业务人员在何处感知结果
- 失败排查
- 业务人员的运维介入入口
## 相关文档
- [[transaction-types]]
- [[consume-via-bill-collection-type]]
- [[consume-monthly-property-bill]]
- [[consume-multiple-bills-priority]]
- [[consume-batch-auto-monthly]]
- [[one-account-per-resident]]

View File

@@ -0,0 +1,245 @@
---
title: prop-acc · prepaid · Consume 走 CollectionType=Bill 的设计
aliases:
- Consume 走 Bill
- CollectionType Bill vs Prepaid
- 预存款消费的资金流设计
- consume-via-bill-collection-type
tags:
- 概念
- prop-acc
- 预存款
- 架构决策
audience:
- 业务人员
- 架构师
- 财务
status: 已发布
sub_feature: prepaid
last_review: 2026-05-25
code_version: 2026-05-22
---
# Consume 走 CollectionType=Bill 的设计
预存款抵扣账单(consume)产生的 `CollectionOrder`,**`collection_type` 字段用 `Bill` 而非 `Prepaid`** —— 这是 prepaid 模块**最独特**的设计决策。本文说明为什么这么设计、对业户感知 / 账单状态 / 报表的影响。
## 核心结论(一句话)
> [!tip] Consume 产生的 CollectionOrder 是「账单视角」的收款单,不是「预存款视角」的支出单。
> - `collection_type = Bill`(账单已收款)
> - `actual_amount = +N`(正数,不是红字)
> - `meta.fund_source = prepaid`(标资金来源)
> - 触发 Receipt 与现金 / 微信付账单一样,文案"物业费 ¥N"
## 设计对比表
| 视角 | 选项 A:Consume 走 type=Prepaid | 选项 B(已采用):Consume 走 type=Bill |
|---|---|---|
| **CollectionOrder.collection_type** | Prepaid | **Bill** |
| **CollectionOrder.actual_amount** | -800(红字,预存款减少) | **+800**(正数,账单收款)|
| **Bill 状态** | 需另查找 / 另机制更新 | **自然走 Bill 收款流,翻 Paid** |
| **Receipt 文案** | "预付款消费 ¥-800" | **"物业费 ¥800"** |
| **业户感知** | 知道"用预存款付了" | **不感知差异,跟现金付一样** |
| **报表** | 分两个 type 求和 | **Bill 收款 SUM 直接拿到所有账单收入** |
| **资金来源追溯** | 关联流水可查 | **meta.fund_source = 'prepaid'** 直接标 |
## 业务模型图
```mermaid
flowchart TD
A[业户充值 5000] --> B[PrepaidAccount.balance=5000]
C[月底 物业费账单 800] --> D{业户付款方式}
D -->|现金| E[CollectionOrder<br/>type=Bill<br/>actual=+800<br/>payment=现金] --> F[Bill 翻 Paid<br/>+ Receipt 物业费 ¥800]
D -->|微信| G[CollectionOrder<br/>type=Bill<br/>actual=+800<br/>payment=微信] --> F
D -->|**预存款抵扣**| H[CollectionOrder<br/>**type=Bill**<br/>actual=+800<br/>meta.fund_source=prepaid] --> F
H --> I[同时 PrepaidTransaction<br/>type=consume<br/>amount=800<br/>balance 5000→4200]
```
无论怎么付,**Bill 视角看到的都是一笔 Bill 收款** —— 状态翻 Paid,收据"物业费 ¥800"。资金来源(现金 / 微信 / 预存款)只是不同的"付款渠道",最终都是账单已付。
## 为什么这么设计
### 1. 业户感知一致
业户收到的物业费收据**长一样**:"物业费 ¥800"。不会因为付款方式不同,收据就变成"预付款消费 ¥-800" + "物业费 ¥800" 两张分裂凭证。
### 2. 账单状态机自然
`Bill` 模型有自己的状态机(Unpaid → Paid → Settled)。Bill 翻 Paid 的逻辑是"收到对应金额的 CollectionOrder(type=Bill, Completed)"。
如果 prepaid consume 走 type=Prepaid,Bill 状态机需要**单独识别"预存款抵扣"这种例外情况**,代码复杂度激增。走 type=Bill 让"账单已收款"这条路径**统一**,无论付款方式如何。
### 3. 报表友好
物业财务的"账单收入"报表:
```sql
-- 所有账单收入(无论付款方式)
SELECT SUM(actual_amount)
FROM acc_collection_orders
WHERE collection_type = 'Bill'
AND status = 'completed';
```
走 type=Bill 让所有账单收入**自动归类**。
如果走 type=Prepaid,得做更复杂的 JOIN(找出 Prepaid 类型的红字 CO,关联流水的 related_bill_id,反推回账单收入),容易漏算 / 双算。
### 4. 与其他模块对齐
将来 deposit 的"业务结算"如果也想抵账单(罕见但可能),走同样模式 —— `CollectionOrder.type=Bill + meta.fund_source=deposit`。这种**资金来源标记**模式可推广。
## 资金来源标记(`meta.fund_source`)
`CollectionOrder.meta` 是 JSON,加 `fund_source` 字段标记钱的来源:
```json
{
"fund_source": "prepaid",
"prepaid_account_id": 123,
"prepaid_transaction_id": 456
}
```
对账时可查:
```sql
-- 上月通过预存款抵扣的账单总额
SELECT SUM(actual_amount)
FROM acc_collection_orders
WHERE collection_type = 'Bill'
AND JSON_EXTRACT(meta, '$.fund_source') = 'prepaid'
AND completed_at BETWEEN '2026-04-01' AND '2026-04-30 23:59:59';
```
可能的 `fund_source` 值(枚举 `FundSource`):
| 值 | 含义 |
|---|---|
| `external` | 外部支付(默认,现金 / 微信 / POS / 银行转账)|
| `prepaid` | 预付账户余额抵扣 |
| `deposit` | 保证金抵扣(罕见,未启用)|
| `overpayment` | 多付款项转入(罕见)|
| `credit` | 信用额度(预留)|
`FundSource::Prepaid``consume` 唯一会用到的值。
## 流水台账(完整对照)
业户陈先生充 ¥5,000 → 抵 ¥800 物业费 → 抵 ¥1,200 水电费 → 退余 ¥3,000 的完整记录:
### CollectionOrder(收款单)
| CO | type | actual_amount | status | meta.fund_source | 关联 |
|---|---|---|---|---|---|
| 1 | Prepaid | +5,000 | Completed | external | — |
| 2 | **Bill** | **+800** | Completed | **prepaid** | Bill #物业费 5月 |
| 3 | **Bill** | **+1,200** | Completed | **prepaid** | Bill #水电费 5月 |
| 4 | Prepaid | **-3,000** | Completed | external(退到银行) | — |
### PrepaidTransaction(流水)
| TX | type | amount | balance_before | balance_after | 关联 CO | related_bill_id |
|---|---|---|---|---|---|---|
| 1 | deposit | 5000 | 0 | 5000 | CO #1 | — |
| 2 | consume | 800 | 5000 | 4200 | CO #2 | Bill #物业费 |
| 3 | consume | 1200 | 4200 | 3000 | CO #3 | Bill #水电费 |
| 4 | refund | 3000 | 3000 | 0 | CO #4 | — |
### Receipt(凭证)
| Receipt | amount | 文案 |
|---|---|---|
| 1 | +5,000 | "预付款充值 ¥5,000" |
| 2 | **+800** | **"物业费 ¥800(5月)"** |
| 3 | **+1,200** | **"水电费 ¥1,200(5月)"** |
| 4 | -3,000 | "预付款退款 ¥-3,000"(红字)|
业户视角看 Receipt #2 #3 = 普通账单收据(跟现金 / 微信付一样),感知不到是预存款抵的。
## Listener `generatePrepaidReceiptItems`
收据 line items 生成由 Listener 处理。逻辑:
```php
// 伪代码
when (PrepaidTransaction::created)
switch ($transaction->type) {
case 'deposit':
Receipt::createItem("预付款充值", $transaction->amount);
break;
case 'consume':
// 走 Bill 渠道,Receipt 由 Bill 那边的 Listener 生成
// 这里不处理(避免重复)
break;
case 'refund':
Receipt::createItem("预付款退款", -$transaction->amount); // 取负号
break;
case 'adjustment':
Receipt::createItem("预付款调整", $direction * $transaction->amount);
break;
}
```
Consume 走 Bill 渠道,Receipt items 由 Bill 那边的 Listener 生成(关联的账单类型决定文案"物业费" / "水电费" 等)。
## 业务人员视角
后台:
- 预存款账户详情看 PrepaidTransaction 流水(每笔有 consume / deposit / refund / adjustment 标记)
- 关联的 CollectionOrder 列表(consume 的 CO 在"收款单"列表里显示 type=Bill,与现金付的账单看起来一样)
- 业户在小程序看到的是"账单已付,付款方式:预存款抵扣"
## 财务视角
月度报表查询:
```sql
-- 本月物业费收入(所有付款方式合计)
SELECT SUM(actual_amount) FROM acc_collection_orders
WHERE collection_type = 'Bill'
AND status = 'completed'
AND completed_at BETWEEN '2026-05-01' AND '2026-05-31 23:59:59';
-- 这条 SQL 自然涵盖现金 / 微信 / POS / 预存款抵扣
-- 本月通过预存款付的账单
SELECT SUM(actual_amount) FROM acc_collection_orders
WHERE collection_type = 'Bill'
AND status = 'completed'
AND JSON_EXTRACT(meta, '$.fund_source') = 'prepaid'
AND completed_at BETWEEN '2026-05-01' AND '2026-05-31 23:59:59';
-- 这条找出"用预存款付的部分"
```
两者差值就是"现金 / 微信 / POS / 转账" 等其他渠道的账单收入。
## 常见问题
> [!question] 业户能查到这张账单"是用预存款付的"吗?
> 后台可以(看 CO 的 `meta.fund_source`)。小程序业户侧默认显示"付款方式:预存款抵扣"(从 meta 读)。
> [!question] 这种设计未来扩展性怎样?
> 极好。比如将来:
> - 业户用"积分"抵账单 → CollectionOrder(type=Bill, meta.fund_source=points)
> - 业户用"信用额度" → meta.fund_source=credit
> - 业户用"保证金抵扣"(若允许) → meta.fund_source=deposit
>
> 所有都遵循同样模式:**Bill 视角统一,资金来源标在 meta**。
> [!question] consume 同时建 CO 和 PrepaidTransaction,如果中间出错怎么办?
> 整个 `ConsumeFromPrepaidAccountAction` 在**一个事务**里:CO + PrepaidTransaction + Bill 状态更新 + Receipt 生成都在同一事务,任何一步失败回滚。
## 相关文档
- [[transaction-types]]
- [[prepaid-account-vs-transaction]]
- [[consume-monthly-property-bill]]
- [[consume-multiple-bills-priority]]
- [[auto-deduction-design]]
- [[../adhoc/collection-order-and-receipt|CollectionOrder 与 Receipt]]

View File

@@ -0,0 +1,168 @@
---
title: prop-acc · prepaid · 一户一账约束
aliases:
- 一户一账
- 预存款一户一账
- one-account-per-resident
- 跨社区防御
tags:
- 概念
- prop-acc
- 预存款
- 数据约束
audience:
- 业户
- 业务人员
- 架构师
status: 已发布
sub_feature: prepaid
last_review: 2026-05-25
code_version: 2026-05-22
---
# 一户一账约束
预存款账户**每个社区每业户最多一个**。数据库唯一约束 + 业务层跨社区防御**双层保证**。这是 prepaid 子模块**最重要的独有约束**,影响开户、消费、跨社区调度的所有流程。
## 约束的形式化
```sql
UNIQUE INDEX (community_id, community_user_profile_id)
ON acc_prepaid_accounts;
```
- 同业户在同社区 → 只能有 1 个预存款账户
- 同业户在不同社区 → 可以各自有 1 个预存款账户(多社区独立)
## 为什么要一户一账
> [!info] 类比
> 业户在同一家超市办储值卡,通常**只有一张**。两张卡难管理(余额分散、要充两次、消费时选哪张),业务上无价值。
具体好处:
| 好处 | 解释 |
|---|---|
| **抵扣账单清晰** | 业户某月物业费 ¥800,系统找该业户的预存款账户(唯一),余额够就扣,不够就提示充值 |
| **业户体验简单** | 业户在小程序只看到 1 个账户,1 个余额,不用选 |
| **报表对账简单** | "本社区所有预存款余额" = sum(prepaid_accounts.balance where community_id=X),无重复计算 |
| **避免资金分散** | 业户分散在两个账户各 ¥500,某月账单 ¥800,系统抵不动(单账户余额不够);合并 ¥1000 就能抵 |
## 与 deposit 的对比
| 维度 | DepositAccount | PrepaidAccount |
|---|---|---|
| 每业户账户数 | **多个**(按 fee_type、按 asset) | **1 个**(每社区) |
| 唯一约束 | 无(可重复)| `(community_id, community_user_profile_id)` |
| 业务理由 | 不同押金种类性质不同(装修押金 vs 入驻押金 vs 设备押金) | 预存款只是"钱包",细分无意义 |
deposit 的多账户合理:同业户可以有"装修保证金 ¥5000" + "入驻押金 ¥2000" + "设备押金 ¥1000",各自独立结算。prepaid 的单账户合理:同业户只有一个"预存款钱包",钱都在一起。
## 跨社区防御
业户可能**同时入住多个社区**(例如自住 + 投资房在另一社区)。两个社区各有自己的预存款账户(`community_id` 不同,各自唯一)。这是**正常情况**,系统允许。
但**禁止跨社区消费** —— A 社区的预存款不能抵 B 社区的账单。这条由 `PrepaidAccount::consume()` 方法**内置守护**:
```php
public function consume(Bill $bill, ...): PrepaidTransaction
{
// 跨社区防御
if ($bill->community_id !== $this->community_id) {
throw new InvalidArgumentException(
'预存款账户与账单不在同一社区,无法抵扣'
);
}
// ...
}
```
> [!warning] 即使账户表面可"逻辑抵扣"也禁止
> 假设业户在 A 社区有 ¥5000 预存,B 社区有 ¥800 物业费账单。逻辑上"业户的钱够付"。但**仍禁止跨社区**抵扣,理由:
>
> - 每个社区财务独立核算,跨社区抵扣等于在两个社区之间转账(账面对不上)
> - 业户与 A 社区物业的合同关系,与 B 社区无关
> - 物业管理通常按社区独立公司化运营
>
> 业户要付 B 社区账单 → 在 B 社区充值新预存款,或直接现金 / 微信付。
详见 [[exception-cross-community-consume]]。
## 开户时的约束行为
业务人员尝试为某社区某业户**再次**开预存款账户时,系统会:
1. UI 层:`PrepaidAccountForm` 在提交前 SQL 查询是否已存在(可前置友好提示)
2. 数据库层:即使 UI 绕过,unique 约束抛 SQLSTATE 23000(duplicate entry)
**业务上**:同业户同社区只允许一次开户。如果业户旧账户已 Closed(搬走过又回来),通常做法是:
| 旧账户状态 | 业务处理 |
|---|---|
| Active | 不开新账户,继续用 |
| Frozen | 先解冻,继续用 |
| Closed + balance=0 | **目前阻塞**:开新会撞 unique。需要业务层加 `where status != 'closed'` 软约束,或允许"重启" Closed 账户(目前不支持)|
| Closed + balance>0 | 罕见情况(类似 retain),业务层应避免 |
> [!warning] 已知设计 gap
> 当前 unique 约束**没有过滤 Closed 状态**,等于"账户一旦关了就不能再开"。如果有"业户搬走又回来"的真实场景,需要后续讨论:
> - 改约束为 `WHERE status != 'closed'`(部分数据库支持)
> - 或允许 reopen Closed 账户(目前 deposit/prepaid 都禁)
> - 或运维 / 业务流程上把 closed 账户改名(改业户 id?)
>
> 暂时按"业户搬走绝大多数不会回来"的假设运行。issue.md Q4 未列入待补,业务方未提出。
## 业户视角
业户基本感受不到这条约束 —— 您本来就只期望"一个钱包":
- 同社区:看到一个余额、一份流水
- 跨社区(若有):分别看,不混淆
唯一可见的影响:跨社区购物 / 缴费时,不能用 A 社区的钱付 B 社区的账。
## 业务人员视角
开户时:
- 输入业户档案 → 系统检查该业户在本社区是否已有账户
- 已有 Active / Frozen → 提示"已有账户,请直接充值",跳到 `ViewPrepaidAccount`
- 已有 Closed → 当前阻塞,需特殊处理(联系运维)
- 无 → 走正常开户流程([[deposit-first-time]])
消费时:
- 系统找业户在本社区的预存款账户(唯一)
- 余额够 → 抵扣
- 余额不够 → 提示业户先充值
## 系统视角
抵扣账单的查找逻辑:
```php
// 伪代码 — 找业户的预存款账户
$account = PrepaidAccount::query()
->where('community_id', $bill->community_id)
->where('community_user_profile_id', $bill->resident_id)
->where('status', PrepaidAccountStatus::Active)
->first(); // ← 因为 unique,first() 直接拿到唯一一个(或 null)
if (! $account || $account->balance < $bill->amount) {
// 余额不够 / 没账户
return BillPaymentResult::insufficient();
}
$account->consume($bill, $bill->amount);
```
唯一约束让 `first()` 100% 准确(理论上不需要 `firstOrFail()` 之类容错)。
## 相关文档
- [[prepaid-account-vs-transaction]]
- [[account-state-machine]]
- [[consume-via-bill-collection-type]]
- [[exception-cross-community-consume]]
- [[deposit-first-time]]
- [[../cross/concepts/resident|业户]]

View File

@@ -0,0 +1,135 @@
---
title: prop-acc · prepaid · 预存款流水类型
aliases:
- 预存款流水类型
- PrepaidTransactionType
- 四种流水
tags:
- 概念
- prop-acc
- 预存款
- 业务字典
audience:
- 业户
- 业务人员
status: 已发布
sub_feature: prepaid
last_review: 2026-05-25
code_version: 2026-05-22
---
# 预存款流水类型(PrepaidTransactionType)
预存款账户的资金变动有 **4 种类型**:**deposit(充值)** / **consume(消费抵扣)** / **refund(退款)** / **adjustment(调整)**。其中 **consume 是最高频操作**(每月业户账单都触发),与 [[../deposit/transaction-types|押金的 3 种流水]]在数量和语义上都有差异。
## 4 种流水速查
| 类型 | 中文 | 余额方向 | 触发 Action | 关联对象 | Receipt 类型 |
|---|---|---|---|---|---|
| `Deposit` | 充值 | + 增加 | [[deposit-first-time]] / [[deposit-additional-topup]] | CollectionOrder(type=Prepaid, +N)| 正数,"预付款充值" |
| `Consume` | 消费抵扣 | 减少 | [[consume-monthly-property-bill]] / [[consume-batch-auto-monthly]] | CollectionOrder(**type=Bill**, +N) + Bill | 正数(走账单方向),"物业费 ¥800" |
| `Refund` | 退款 | 减少 | [[refund-full-resident-moveout]] / [[refund-partial-after-consume]] | CollectionOrder(type=Prepaid, **N 红字**) | 负数(红字),"预付款退款 ¥-X" |
| `Adjustment` | 调整 | ± | (无 UI 入口,运维场景)| 无强制关联 | 视方向选词 |
## amount 一律为正数
与 deposit 同样规则:`PrepaidTransaction.amount` **永远是正数**,方向由 `type` 表达。负号只出现在关联的 `CollectionOrder.actual_amount`(refund 时)和 Receipt amount(refund 时)。
举例(业户充 ¥5000 → 抵扣 ¥800 物业费 → 抵扣 ¥1200 水电费 → 退余 ¥3000):
| 流水 | type | amount | balance_before | balance_after |
|---|---|---|---|---|
| 1 | Deposit | 5000 | 0 | 5000 |
| 2 | Consume | 800 | 5000 | 4200 |
| 3 | Consume | 1200 | 4200 | 3000 |
| 4 | Refund | 3000 | 3000 | 0 |
最后余额 0,**账户保持 Active**(不像 deposit 自动 Closed)。详见 [[account-state-machine]] "零余额不自动关账" 段。
## Consume 的特殊性
`consume` 是预存款**独有**且**高频**的操作类型。三个关键特性:
### 1. 关联具体账单(`related_bill_id`)
每笔 consume 必须关联一张被抵扣的 [[../../cross/concepts/work-order|账单(Bill)]],流水的 `related_bill_id` 字段记录这个关联。审计时可追溯"这笔消费是抵的哪张账单"。
### 2. CollectionOrder 用 type=Bill 而非 Prepaid
这是**最关键**的设计 —— 详见 [[consume-via-bill-collection-type]]。简而言之:从账单视角,consume 是"账单收款完成";从预存款视角,consume 是"余额扣减"。CollectionOrder 用 `type=Bill` 让账单收款流统一,资金来源标在 `meta.fund_source=prepaid`
### 3. Receipt 走账单方向(正数)
普通 deposit 模块的退款 / 扣罚出红字 Receipt(`amount=-N`)。但 prepaid 的 consume **出正数 Receipt**,文案是"物业费 ¥800",对业户而言这就是一张**普通的账单收款收据** —— 跟现金 / 微信付物业费拿到的收据完全一样。
这种设计的好处:**业户感知一致** —— 不管钱怎么付(现金 / 微信 / 预存款抵扣),收据都长一样,业务上更直观。
## Refund 的设计
predpaid 的 refund 与 deposit 的 refund **几乎一样**:
- 建红字 CollectionOrder(`actual_amount=-N`)
-`PrepaidTransaction(type=refund, amount=正数)`
- 触发红字 Receipt"预付款退款 ¥-X"
**唯一差异**:不自动关账(deposit 余额清零自动 Closed,prepaid 不自动)。详见 [[refund-partial-after-consume]]。
## Adjustment 的设计取舍
> [!warning] Adjustment 没有 UI 入口
> 与 deposit 模块的 adjustment 一样,**保留 enum case 但不提供前台 UI**。理由(同 deposit issue.md Q3):
>
> - 余额修正给前台开后门,审计大忌
> - 任何错误应通过 deposit / consume / refund 组合补正
> - 留 enum 给运维场景:tinker / artisan / 一次性数据迁移脚本
具体修正套路(以"业户充值时多录了 ¥1,000"为例):
| 错误做法 | 正确做法 |
|---|---|
| 直接改余额 -1000 | 建一笔 `Refund` ¥1,000,备注"录错金额修正",业户拿到红字"预付款退款 ¥-1,000" |
修正的完整凭证链路完整保留,审计可追责。
## Receipt 文案(Listener `generatePrepaidReceiptItems`)
`PrepaidTransaction.type` 选词:
| type | Receipt 文案 |
|---|---|
| `deposit` | "预付款充值 ¥N" |
| `consume` | 走 Bill 渠道,文案按账单类型("物业费"、"水电费" 等) |
| `refund` | "预付款退款 ¥-N"(负数,红字) |
| `adjustment` | "预付款调整 ¥±N"(罕见) |
## 业户视角
业户在小程序"我的预存款"流水里看到:
```
2026-05-20 -1,200.00 抵扣 水电费(5月) consume
2026-05-15 -800.00 抵扣 物业费(5月) consume
2026-05-01 +5,000.00 预付款充值 deposit
2026-04-30 -3,000.00 预付款退款(余额提取) refund
2026-04-20 -500.00 抵扣 物业费(4月,部分) consume
```
每笔有时间、金额方向、说明、类型。点击可看关联的账单 / 收据。
## 业务人员视角
后台 → 预存款 → 账户详情 → 流水标签。
- 上方按钮:充值 / 消费 / 退款 / 冻结 / 解冻 / 关账
- 按钮可见性由 [[account-state-machine|canOperate()]] 守护:Frozen / Closed 状态下全部灰化
- 没有"修改 / 删除流水"按钮 —— 不可变设计
## 相关文档
- [[prepaid-account-vs-transaction]]
- [[account-state-machine]]
- [[consume-via-bill-collection-type]]
- [[auto-deduction-design]]
- [[consume-monthly-property-bill]]
- [[refund-full-resident-moveout]]
- [[../deposit/transaction-types]]

View File

@@ -25,9 +25,9 @@ last_review: 2026-05-25
| --------- | ---------------- | --------------------------------------------- | ------ | | --------- | ---------------- | --------------------------------------------- | ------ |
| **一次性收费** | IC 卡、装修证、泳票等单次购买 | [adhoc 知识地图](maps/adhoc-knowledge-map.md) | ✅ 28 篇 | | **一次性收费** | IC 卡、装修证、泳票等单次购买 | [adhoc 知识地图](maps/adhoc-knowledge-map.md) | ✅ 28 篇 |
| **保证金** | 装修押金等代管资金,完工后退还 | [deposit 知识地图](maps/deposit-knowledge-map.md) | ✅ 25 篇 | | **保证金** | 装修押金等代管资金,完工后退还 | [deposit 知识地图](maps/deposit-knowledge-map.md) | ✅ 25 篇 |
| **预存款** | 业户预存,自动抵扣月度账单 | _待补_ | 🚧 | | **预存款** | 业户预存,自动抵扣月度账单 | [prepaid 知识地图](maps/prepaid-knowledge-map.md) | ✅ 23 篇 |
| **计量表** | 水表/电表/燃气表,抄表生成账单 | _待补_ | 🚧 | | **计量表** | 水表/电表/燃气表,抄表生成账单 | [meter 知识地图](maps/meter-knowledge-map.md) | ✅ 21 篇 |
| **账单** | 周期性账单 + 计量账单 | _待补_ | 🚧 | | **账单** | 周期性账单 + 计量账单 | [billing 知识地图](maps/billing-knowledge-map.md) | ✅ 23 篇 |
| **收款订单** | 一次收款的支付方式、银行账户记录 | _待补_ | 🚧 | | **收款订单** | 一次收款的支付方式、银行账户记录 | _待补_ | 🚧 |
| **收据** | 成功收款后生成的凭证 | _待补_ | 🚧 | | **收据** | 成功收款后生成的凭证 | _待补_ | 🚧 |

View File

@@ -0,0 +1,145 @@
---
title: prop-acc · billing · 知识地图
aliases:
- billing 知识地图
- 账单知识地图
tags:
- 规范
- prop-acc
- 知识地图
- 账单
sub_feature: billing
audience:
- 业务人员
- 财务
- 抄表员
status: 已发布
last_review: 2026-05-26
code_version: 2026-05-22
---
# 账单(billing)知识地图
> 本子模块 = Bill + CollectionOrderBill(中间表)。覆盖物业收款的**应收应付侧**:账单生成、状态管理、收款关联、删除 / 作废 / 挂起 / 拆单全套。
>
> billing 是 prop-acc **最复杂的子模块**,也是**收款流程的中枢**(各业务源 → Bill → CollectionOrder)。
## 这是什么?
物业管理软件最核心的对象之一 —— **应收账款的记录**。从抄表 / 周期任务 / 手工建单产生,经历状态变化(Unpaid → Partial → Paid),最终通过 [CollectionOrder](../concepts/adhoc/collection-order-and-receipt.md) 完成收款。
## 与其他子模块的关系
| 关系 | 说明 |
|---|---|
| **上游**:meter → bill | 抄表 → 生成计量账单 |
| **上游**:周期任务 → bill | 月度物业费等批量生成 |
| **上游**:手工 → bill | 临时收费 |
| **下游**:bill → CollectionOrder | 收款时建 CO + Receipt |
| **侧链**:bill ← prepaid | 业户预存款抵账单(走 Bill consume,见 [prepaid 模块](prepaid-knowledge-map.md))|
## 与其他子模块的核心差异
| 维度 | bill | 其他子模块 |
|---|---|---|
| 状态数 | **6**(最复杂)| deposit/prepaid 3,meter 2 |
| 删 / 作废 | **双轨制** | 只有 Close / Decommission |
| Policy 方法数 | **7** | deposit 12 / prepaid 9 / meter 5 |
| 审计 | **activitylog + meta** | meta JSON only |
| 批删 | **智能 Modal(3 档分类)** | 无 |
| 与 CollectionOrder 关系 | **多对多**(中间表)| 1:1(adhoc / deposit / prepaid) |
## 核心概念(6 篇)
| 文档 | 一句话 |
|---|---|
| [账单六状态机](../concepts/billing/bill-six-state-machine.md) | 6 状态(Unpaid / Partial / Paid / Suspended / Processing / Void),prop-acc 最复杂 |
| [账单类型与来源](../concepts/billing/bill-types-and-sources.md) | 周期 / 计量 / 临时 三类 + sourceable polymorphism |
| [Bill 与 CollectionOrder 关系](../concepts/billing/bill-vs-collection-order.md) | 应收 vs 已收,CollectionOrderBill 多对多 |
| [周期账单生成机制](../concepts/billing/periodic-bill-generation.md) | `GeneratePeriodicBillsAction` + `BillingMergeStrategy` 三种合并策略 |
| [删除 vs 作废双轨制](../concepts/billing/delete-vs-void-dual-track.md) | 物理删(Unpaid 无付款)vs 作废(留状态留审计)的设计哲学 |
| [智能批量删除设计](../concepts/billing/smart-bulk-delete-design.md) | 预检查三档分类 + 必填原因 + activitylog 完整审计 |
## 场景手册(16 篇,**全部完成 ✅**)
### 📝 账单创建(3 篇)
- ✅ [月度物业费批量生成(`GeneratePeriodicBillsAction`)](../scenarios/billing/create-periodic-property-fee.md)
- ✅ [抄表自动生成计量账单(走 meter pipeline)](../scenarios/billing/create-meter-bill-auto.md)
- ✅ [手动建单(临时收费 / 调整账单)](../scenarios/billing/create-single-bill-manual.md)
### 💰 收款(3 篇)
- ✅ [单张账单收款(`CollectPaymentAction`)](../scenarios/billing/collect-payment-single.md)
- ✅ [同业户多账单批量收款(`BatchCollectPaymentAction`)](../scenarios/billing/collect-payment-batch.md)
- ✅ [预存款抵扣自动收款(关联 prepaid)](../scenarios/billing/collect-via-prepaid-auto.md)
### ✂️ 账单调整(3 篇)
- ✅ [拆账单(`SplitBillAction`,租户与房东分摊)](../scenarios/billing/split-bill.md)
- ✅ [挂起账单(业户失联 / 纠纷)](../scenarios/billing/suspend-bill.md)
- ✅ [恢复挂起的账单](../scenarios/billing/resume-bill.md)
### 🗑️ 删除 / 作废(3 篇)
- ✅ [物理删除未付账单(误建立刻删)](../scenarios/billing/delete-bill-unpaid.md)
- ✅ [作废已付账单(走作废 + 退款)](../scenarios/billing/void-paid-bill.md)
- ✅ [批量误建,智能 Modal 三档清理](../scenarios/billing/bulk-delete-batch-mistake.md)
### 🛡️ 异常 / 审计(4 篇)
- ✅ [部分付状态处理(Partial)](../scenarios/billing/exception-partial-payment.md)
- ✅ [逾期账单清单 + 催收(`OverdueBillsListWidget`)](../scenarios/billing/exception-overdue-bills.md)
- ✅ [月度账单生成 vs 收款对比(`MonthlyBillingVsCollectionChart`)](../scenarios/billing/audit-monthly-billing-vs-collection.md)
- ✅ [activitylog 审计追溯](../scenarios/billing/audit-activitylog-trace.md)
## 跨域引用
本子模块引用以下跨域共享概念:
- [业户](../../cross/concepts/resident.md) — 账单收方
- [房屋单元](../../cross/concepts/housing-unit.md) — `asset_id`,账单关联房屋
- [组织结构](../../cross/concepts/org-hierarchy.md) — `community_id`,物业项目归属
## 跨子模块引用
- [adhoc · CollectionOrder 与 Receipt](../concepts/adhoc/collection-order-and-receipt.md) — 收款侧的核心对象
- [meter · 账单生成的三层分层](../concepts/meter/bill-generation-pipeline.md) — 计量账单的生成器
- [prepaid · Consume 走 CollectionType=Bill](../concepts/prepaid/consume-via-bill-collection-type.md) — 预存款抵账单的资金流
- [meter · 表退役与读数锁定](../concepts/meter/decommission-and-locking.md) — 类似的"状态机+守护"对比
- [deposit · 账户与流水](../concepts/deposit/deposit-account-vs-transaction.md) — 双对象模式对比
## 相关代码
- 模型:[`Bill.php`](../../../packages/prop-acc/src/Models/Bill.php)、[`CollectionOrderBill.php`](../../../packages/prop-acc/src/Models/CollectionOrderBill.php)
- 枚举:`BillStatus`(6 种)、`BillType``BillingMergeStrategy``FeeTypeBillType`
- Policy:`BillPolicy`(7 个方法:update / delete / deleteAny / void / collect / suspend / resume)
- 业务 Actions(src/Actions/Bills/):
- [`VoidBillAction`](../../../packages/prop-acc/src/Actions/Bills/VoidBillAction.php)
- [`SplitBillAction`](../../../packages/prop-acc/src/Actions/Bills/SplitBillAction.php)
- [`SuspendBillAction`](../../../packages/prop-acc/src/Actions/Bills/SuspendBillAction.php)
- [`ResumeBillAction`](../../../packages/prop-acc/src/Actions/Bills/ResumeBillAction.php)
- `BulkDeleteBillsAction`(智能批删)
- Filament Resource:[`packages/prop-acc/src/Filament/Resources/Bills/`](../../../packages/prop-acc/src/Filament/Resources/Bills/)
- Filament Actions(UI 入口):8 个(CollectPayment / BatchCollectPayment / GeneratePeriodicBills / Split / Suspend / Resume / Void / BulkDelete)
- Widgets:`BillingStatsOverviewWidget``MonthlyBillingVsCollectionChart``FeeTypeRevenueDistributionChart``OverdueBillsListWidget``MonthlyRevenueTrendChart`
- 业务设计决策:`packages/prop-acc/issue.md` 的 Q6 段(最详细的 issue 之一)
## 相关文档
- [prop-acc 域知识地图](knowledge-map.md)
- [prop-acc 域首页](../index.md)
- [adhoc 子模块知识地图](adhoc-knowledge-map.md)
- [deposit 子模块知识地图](deposit-knowledge-map.md)
- [prepaid 子模块知识地图](prepaid-knowledge-map.md)
- [meter 子模块知识地图](meter-knowledge-map.md)
- [跨域协作地图](../../cross/maps/cross-domain-map.md)
---
> [!success] billing 子模块:6 概念 + 16 场景 + 1 知识地图 = **23 篇完成**
>
> 写作日期:2026-05-26
> 对应代码版本:2026-05-22(详见 `packages/prop-acc/issue.md` Q6 段,最详细 issue 之一)
>
> 如果发现遗漏的场景或需要补充的细节,告诉我,可以单独补充新文档。

View File

@@ -20,10 +20,10 @@ last_review: 2026-05-25
| 子模块 | 中文 | 一句话 | 深度地图 | 状态 | | 子模块 | 中文 | 一句话 | 深度地图 | 状态 |
|---|---|---|---|---| |---|---|---|---|---|
| adhoc | 一次性收费 | IC 卡、装修证、泳票等单次购买 | [adhoc 知识地图](adhoc-knowledge-map.md) | ✅ 25 场景 + 3 概念 | | adhoc | 一次性收费 | IC 卡、装修证、泳票等单次购买 | [adhoc 知识地图](adhoc-knowledge-map.md) | ✅ 25 场景 + 3 概念 |
| prepaid | 预存款 | 业户预存,自动抵扣月度账单 | _待补_ | 🚧 | | prepaid | 预存款 | 业户预存,自动抵扣月度账单 | [prepaid 知识地图](prepaid-knowledge-map.md) | ✅ 16 场景 + 6 概念 + 1 地图 = 23 篇 |
| deposit | 保证金 | 装修押金等代管资金,完工后退还 | [deposit 知识地图](deposit-knowledge-map.md) | ✅ 18 场景 + 6 概念 + 1 地图 = 25 篇 | | deposit | 保证金 | 装修押金等代管资金,完工后退还 | [deposit 知识地图](deposit-knowledge-map.md) | ✅ 18 场景 + 6 概念 + 1 地图 = 25 篇 |
| meter | 计量表 | 水表/电表/燃气表,抄表生成账单 | _待补_ | 🚧 | | meter | 计量表 | 水表/电表/燃气表,抄表生成账单 | [meter 知识地图](meter-knowledge-map.md) | ✅ 14 场景 + 6 概念 + 1 地图 = 21 篇 |
| billing | 账单 | 周期性账单 + 计量账单 | _待补_ | 🚧 | | billing | 账单 | 周期性账单 + 计量账单 | [billing 知识地图](billing-knowledge-map.md) | ✅ 16 场景 + 6 概念 + 1 地图 = 23 篇 |
| payment-order | 收款订单 | 一次收款的支付方式、银行账户记录 | _待补_ | 🚧 | | payment-order | 收款订单 | 一次收款的支付方式、银行账户记录 | _待补_ | 🚧 |
| receipt | 收据 | 成功收款后生成的凭证 | _待补_ | 🚧 | | receipt | 收据 | 成功收款后生成的凭证 | _待补_ | 🚧 |

View File

@@ -0,0 +1,135 @@
---
title: prop-acc · meter · 知识地图
aliases:
- meter 知识地图
- 计量表知识地图
tags:
- 规范
- prop-acc
- 知识地图
- 计量表
sub_feature: meter
audience:
- 业务人员
- 抄表员
- 财务
status: 已发布
last_review: 2026-05-25
code_version: 2026-05-22
---
# 计量表(meter)知识地图
> 本子模块 = Meter(物理表配置)+ MeterReading(不可变抄表流水)。覆盖物业计量收费(水电气)的全生命周期 —— 建表、抄表、生成账单、表更换、表退役、异常处理。
## 这是什么?
物业管理水表 / 电表 / 燃气表的**计量计费基础设施**。从"业户家里有一张物理表"到"每月物业费账单里有一行水电费",中间走的就是本模块。
> [!info] meter 是 prop-acc 里**最成熟**的子模块
> issue.md Q5 评估:数据模型对齐市场标准 ~90%,业务分层清楚(`Calculator → Service → Action`),完整的换表链、倍率支持、阶梯计价、min/max 封顶、抄表来源跟踪、拍照存证、初始化批量导入。**后续 deposit / prepaid / adhoc 模块的分层方法是从 meter 学的**。
## 与其他子模块的关系
| 关系 | 说明 |
|---|---|
| **下游产 Bill,不直接产 Receipt** | 抄表 → 生成 Bill → 业户付账单时走 [adhoc 的 CollectionOrder + Receipt 体系](../concepts/adhoc/collection-order-and-receipt.md) |
| **业户付账单的资金可来自预存款** | 走 [prepaid 的 consume](../concepts/prepaid/consume-via-bill-collection-type.md) 模式 —— Bill.amount 自动从预存款余额扣 |
| **本模块不涉及押金** | 计量表是日常计费工具,无押金概念 |
## 核心特性(与其他模块对比)
| 维度 | meter | deposit / prepaid |
|---|---|---|
| 主对象类型 | **物理硬件**(表)| 抽象账户 |
| 主对象有 balance | ❌ | ✅ |
| 流水方向 | 单向(只录读数,无 +/-) | 双向(deposit / refund / forfeit / consume) |
| 直接产 Receipt | ❌(走 Bill 中转) | ✅ |
| 表更换 / 退役机制 | ✅(`replaced_meter_id` 链 + 5 种退役原因) | N/A |
| 来源标记(manual/remote) | ✅ | ❌ |
| 拍照存证 | ✅ | ❌ |
## 核心概念(6 篇)
| 文档 | 一句话 |
|---|---|
| [计量表与抄表流水](../concepts/meter/meter-vs-meter-reading.md) | 双对象(物理表配置 + 不可变读数流水),与"账户+流水"模式的差异 |
| [表更换链](../concepts/meter/replacement-chain.md) | `replaced_meter_id` + 自动 `-R1` 后缀 + 初始读数继承,保证用量计算连续 |
| [倍率与阶梯计价](../concepts/meter/multiplier-and-tiered-pricing.md) | 倍率(工业表 10x/100x) + 阶梯计价(progressive 累进) + min/max 封顶 |
| [账单生成的三层分层](../concepts/meter/bill-generation-pipeline.md) | Calculator(纯算)→ Service(查费率 + 找业主)→ Action(入口),prop-acc 的样板 |
| [抄表来源与拍照存证](../concepts/meter/reading-source-and-photo-proof.md) | `manual` 手抄 vs `remote` 集抄 + `photo_url` 凭证,业户争议时的证据 |
| [表退役与读数锁定](../concepts/meter/decommission-and-locking.md) | 5 种退役原因 + Reading 双锁机制(创建即不可改,有 Bill 更不可改) |
## 场景手册(14 篇,**全部完成 ✅**)
### 📦 表管理(4 篇)
- ✅ [新社区批量建表 + 初始读数 Excel 导入](../scenarios/meter/init-new-community-batch.md)
- ✅ [单独新增一张表(后台单录)](../scenarios/meter/register-single-meter.md)
- ✅ [换表:旧表故障/退役,新表带 -R1 后缀,初始读数继承](../scenarios/meter/replace-broken-meter.md)
- ✅ [退役不换表(房屋拆除 / 业户永久弃用)](../scenarios/meter/decommission-without-replacement.md)
### 📊 抄表(4 篇)
- ✅ [单张表后台手动录入](../scenarios/meter/read-single-meter-manual.md)
- ✅ [一次导入整月所有读数(Excel 批量)](../scenarios/meter/read-batch-via-excel-import.md)
- ✅ [集抄系统自动推送(`source=remote`)](../scenarios/meter/read-via-iot-remote-source.md)
- ✅ [抄表拍照存证(物理表头照片)](../scenarios/meter/read-with-photo-proof.md)
### 💰 账单生成(3 篇)
- ✅ [阶梯水电价生成账单(progressive 累进算例)](../scenarios/meter/generate-bill-tiered-pricing.md)
- ✅ [工业表 10x 倍率生成账单](../scenarios/meter/generate-bill-with-multiplier.md)
- ✅ [单笔账单上下限封顶(防异常用量爆账)](../scenarios/meter/generate-bill-min-max-cap.md)
### 🛡️ 异常 / 审计(3 篇)
- ✅ [高用量异常(漏水 / 电器故障),`HighConsumptionReadingsListWidget` 预警](../scenarios/meter/exception-high-consumption.md)
- ✅ [已生成 Bill 的 Reading 锁定,要修正需作废 Bill](../scenarios/meter/exception-readings-locked-after-bill.md)
- ✅ [待抄表清单 + 月度抄表完成率(`MetersNeedingReadingListWidget`)](../scenarios/meter/audit-meters-needing-reading.md)
## 跨域引用
本子模块引用以下跨域共享概念:
- [业户](../../cross/concepts/resident.md) — 账单关联业户
- [房屋单元](../../cross/concepts/housing-unit.md) — `asset_id`,表绑定房屋
- [组织结构](../../cross/concepts/org-hierarchy.md) — `community_id`,物业项目归属
## 跨子模块引用
- [adhoc · CollectionOrder 与 Receipt](../concepts/adhoc/collection-order-and-receipt.md) — 计量账单付款时走的凭证体系
- [prepaid · Consume 走 CollectionType=Bill 的设计](../concepts/prepaid/consume-via-bill-collection-type.md) — 计量账单可由预存款抵扣
- [deposit · 账户与流水](../concepts/deposit/deposit-account-vs-transaction.md) — 账户+流水模式对比
## 相关代码
- 模型:[`Meter.php`](../../../packages/prop-acc/src/Models/Meter.php)、[`MeterReading.php`](../../../packages/prop-acc/src/Models/MeterReading.php)
- 枚举:`MeterReadingSource`(2 种)、`MeterDecommissionReason`(5 种)
- Policy:`MeterPolicy`(3 个方法)、`MeterReadingPolicy`(2 个方法)
- 业务层:
- [`MeterBillCalculator`](../../../packages/prop-acc/src/Services/MeterBillCalculator.php)(纯算)
- [`MeterBillGenerationService`](../../../packages/prop-acc/src/Services/MeterBillGenerationService.php)(业务编排)
- [`GenerateBillsFromMeterReadingsAction`](../../../packages/prop-acc/src/Actions/Meters/GenerateBillsFromMeterReadingsAction.php)(入口)
- Filament Resource:[`packages/prop-acc/src/Filament/Resources/Meters/`](../../../packages/prop-acc/src/Filament/Resources/Meters/)
- Importers:`MeterReadingsImporter``MeterInitializationImporter`(继承 `BaseImporter`)
- Dashboard / Widgets:`MeterDashboard``MetersNeedingReadingListWidget``HighConsumptionReadingsListWidget``MonthlyConsumptionByFeeTypeChart``MeterStatsOverviewWidget`
- 业务设计决策:`packages/prop-acc/issue.md` 的 Q5 段
## 相关文档
- [prop-acc 域知识地图](knowledge-map.md)
- [prop-acc 域首页](../index.md)
- [adhoc 子模块知识地图](adhoc-knowledge-map.md)
- [deposit 子模块知识地图](deposit-knowledge-map.md)
- [prepaid 子模块知识地图](prepaid-knowledge-map.md)
- [跨域协作地图](../../cross/maps/cross-domain-map.md)
---
> [!success] meter 子模块:6 概念 + 14 场景 + 1 知识地图 = **21 篇完成**
>
> 写作日期:2026-05-26
> 对应代码版本:2026-05-22(详见 `packages/prop-acc/issue.md` Q5 段)
>
> 如果发现遗漏的场景或需要补充的细节,告诉我,可以单独补充新文档。

View File

@@ -0,0 +1,131 @@
---
title: prop-acc · prepaid · 知识地图
aliases:
- prepaid 知识地图
- 预存款知识地图
tags:
- 规范
- prop-acc
- 知识地图
- 预存款
sub_feature: prepaid
audience:
- 业户
- 业务人员
- 财务
status: 已发布
last_review: 2026-05-25
code_version: 2026-05-22
---
# 预存款(prepaid)知识地图
> 本子模块 = PrepaidAccount + PrepaidTransaction。覆盖业户**预存金额抵扣账单**的全生命周期 —— 充值、消费(月度账单自动抵)、退款、冻结、关账。
## 这是什么?
业户**提前充值**一笔钱到自己的预存款账户,后续物业费 / 水电费等账单**自动从这里扣**,免得月月手动缴。是物业财务系统的"业户钱包"。
> [!warning] 预存款不是收入,也不是押金 — 是预收账款
> 业户充进来的钱在抵扣账单**之前**,会计科目记**预收账款(负债)**,不进收入。每次抵扣账单时,**对应部分**才转入收入。
>
> 三种模块的会计性质对比详见 [adhoc 的 deposit-vs-adhoc-vs-prepaid 概念](../concepts/deposit/deposit-vs-adhoc-vs-prepaid.md)。
## 核心特性(与 deposit 的关键差异)
| 维度 | prepaid 预存款 | deposit 保证金 |
|---|---|---|
| 每业户账户数 | **1 个**(一户一账) | 多个 |
| 核心高频操作 | **consume**(自动抵账单) | refund / forfeiture |
| Bill 关联 | ✅(consume 关联具体账单) | ❌ |
| 零余额行为 | **保持 Active**(可继续充值) | 自动 Closed |
| ForceClose | ❌(纠纷罕见) | ✅ |
| 缴款人 | 只能是业户本人 | 多种(业主/租户/装修公司/...) |
## 核心概念(6 篇)
| 文档 | 一句话 |
|---|---|
| [预存款账户与流水](../concepts/prepaid/prepaid-account-vs-transaction.md) | 双对象模式,与 deposit 同构但多两条独有约束 |
| [账户状态机](../concepts/prepaid/account-state-machine.md) | Active / Frozen / Closed,canOperate 统一守护,零余额不自动关账 |
| [一户一账约束](../concepts/prepaid/one-account-per-resident.md) | unique(community_id, profile_id) + 跨社区防御 |
| [流水类型](../concepts/prepaid/transaction-types.md) | 4 种(deposit / consume / refund / adjustment),consume 是高频 |
| [Consume 走 CollectionType=Bill 的设计](../concepts/prepaid/consume-via-bill-collection-type.md) | 独特设计:抵账单用 Bill 视角,资金来源标 meta.fund_source=prepaid |
| [月初批量自动抵扣设计](../concepts/prepaid/auto-deduction-design.md) | 待补 scheduled job,产品核心卖点 |
## 场景手册(16 篇,**全部完成 ✅**)
### 📥 充值(Deposit)— 3 篇
- ✅ [首次开户充值 5000](../scenarios/prepaid/deposit-first-time.md)
- ✅ [已有账户追加充值](../scenarios/prepaid/deposit-additional-topup.md)
- ✅ [小程序在线充值(待补设计意图)](../scenarios/prepaid/deposit-via-miniapp-pending.md)
### 🧹 消费 Consume — 4 篇(最核心)
- ✅ [手动抵扣月度物业费](../scenarios/prepaid/consume-monthly-property-bill.md)
- ✅ [多个未付账单按 due_at 优先级抵扣](../scenarios/prepaid/consume-multiple-bills-priority.md)
- ✅ [抵扣计量账单(水电费)](../scenarios/prepaid/consume-meter-bill.md)
- ✅ [月初批量自动抵扣 job(设计意图 + 业务流程)](../scenarios/prepaid/consume-batch-auto-monthly.md)
### 💰 退款(Refund)— 2 篇
- ✅ [业户搬走全额退余](../scenarios/prepaid/refund-full-resident-moveout.md)
- ✅ [部分消费后退余(不自动关账)](../scenarios/prepaid/refund-partial-after-consume.md)
### 🧊 冻结 / 解冻(Freeze / Unfreeze)— 2 篇
- ✅ [疑似欺诈 / 风控冻结](../scenarios/prepaid/freeze-suspected-fraud.md)
- ✅ [核实后解冻](../scenarios/prepaid/unfreeze-after-verification.md)
### 🔒 结清(Close)— 2 篇
- ✅ [业户搬走主动关账](../scenarios/prepaid/close-resident-moveout.md)
- ✅ [余额清零后不自动关,业户决定](../scenarios/prepaid/close-with-zero-balance-decision.md)
### 🛡️ 异常 / 审计(3 篇)
- ✅ [跨社区消费防御](../scenarios/prepaid/exception-cross-community-consume.md)
- ✅ [冻结状态退款被三层守护拦截](../scenarios/prepaid/exception-refund-on-frozen.md)
- ✅ [低余额业户预警 + 逾期账单排查](../scenarios/prepaid/audit-low-balance-and-overdue.md)
## 跨域引用
本子模块引用以下跨域共享概念:
- [业户](../../cross/concepts/resident.md) — 缴款人(只能是业户本人)
- [组织结构](../../cross/concepts/org-hierarchy.md) — community_id 归属
## 跨子模块引用
- [adhoc · CollectionOrder 与 Receipt](../concepts/adhoc/collection-order-and-receipt.md) — prepaid 同样产出 CO + Receipt,但 consume 的 CO 用 type=Bill(详见本子模块独特设计)
- [deposit · 账户与流水](../concepts/deposit/deposit-account-vs-transaction.md) — 双对象模式同构,可对比理解
- [deposit · 状态机](../concepts/deposit/account-state-machine.md) — 三状态相同,关账行为不同
- [deposit · 押金 vs 一次性收费 vs 预存款](../concepts/deposit/deposit-vs-adhoc-vs-prepaid.md) — 三模块对比
## 相关代码
- 模型:[`PrepaidAccount.php`](../../../packages/prop-acc/src/Models/PrepaidAccount.php)、[`PrepaidTransaction.php`](../../../packages/prop-acc/src/Models/PrepaidTransaction.php)
- 枚举:`PrepaidAccountStatus``PrepaidTransactionType``FundSource``CollectionType`
- Policy:`PrepaidAccountPolicy`(9 个方法)、`PrepaidTransactionPolicy`
- Actions(业务层):`packages/prop-acc/src/Actions/Prepaids/`(`ConsumeFromPrepaidAccountAction` / `RefundFromPrepaidAccountAction`,**自动抵扣 job 待补**)
- Filament Resource:[`packages/prop-acc/src/Filament/Resources/PrepaidAccounts/`](../../../packages/prop-acc/src/Filament/Resources/PrepaidAccounts/)
- Dashboard / Widgets:`DepositPrepaidDashboard``MonthlyPrepaidFlowChart``LowBalancePrepaidListWidget`
- 业务设计决策:`packages/prop-acc/issue.md` 的 Q4 段
## 相关文档
- [prop-acc 域知识地图](knowledge-map.md)
- [prop-acc 域首页](../index.md)
- [adhoc 子模块知识地图](adhoc-knowledge-map.md)
- [deposit 子模块知识地图](deposit-knowledge-map.md)
- [跨域协作地图](../../cross/maps/cross-domain-map.md)
---
> [!success] prepaid 子模块:6 概念 + 16 场景 + 1 知识地图 = **23 篇完成**
>
> 写作日期:2026-05-25
> 对应代码版本:2026-05-22(详见 `packages/prop-acc/issue.md` Q4 段)
>
> 如果发现遗漏的场景或需要补充的细节,告诉我,可以单独补充新文档。

View File

@@ -0,0 +1,311 @@
---
title: prop-acc · billing · 场景 - activitylog 审计追溯
aliases:
- activitylog 审计
- 操作日志查询
- audit-activitylog-trace
- 场景-activitylog 追溯
tags:
- 场景
- prop-acc
- 账单
- 审计
- 合规
audience:
- 业务人员
- 财务
- 审计师
- 法务
status: 已发布
sub_feature: billing
last_review: 2026-05-26
code_version: 2026-05-22
---
# 场景:activitylog 审计追溯
billing 模块**首次启用** `spatie/laravel-activitylog`(prop-acc 其他模块仅用 meta JSON)。所有关键操作(作废 / 批删 / 挂起 / 恢复 / 收款 / 创建)记 activitylog,审计 / 内审 / 法务可**精准追溯**:谁 / 什么时候 / 在哪 / 改了什么。
## 典型情境
> [!example] 真实情境
> 5 月 20 日,内审师审计 5 月嘉禾花园账单数据,发现:
>
> - 5 月 15 日有一次**批量删除 92 张账单**(`bulk_deleted`)
> - 同日有 5 条 **bill voided**(账单作废)
> - 还有几张账单的 status 从 Suspended → Unpaid(恢复)
>
> 审计师要查清:
> - 谁操作的?
> - 操作的原因?
> - 影响了哪些具体账单?
> - 是否合规?
## 业务人员 / 审计师视角
### 第 1 步:确定查询维度
- **谁操作**:`causer_id`(操作员)
- **什么时候**:`created_at` 范围
- **操作类型**:`event`(created / voided / bulk_deleted / suspended / resumed / collected / split)
- **针对哪个对象**:`subject_type` + `subject_id`(单条操作)/ properties.affected_bill_nos(批量)
- **操作详情**:`properties` JSON 字段
### 第 2 步:运行 SQL 查询
#### 查询 1:某员工某月所有操作
```sql
SELECT
id,
event,
subject_type,
subject_id,
properties,
created_at
FROM activity_log
WHERE causer_id = ? -- 王主管 ID
AND created_at BETWEEN '2026-05-01' AND '2026-05-31 23:59:59'
ORDER BY created_at DESC;
```
返回:
| id | event | subject | properties (节选) | created_at |
|---|---|---|---|---|
| 1023 | bulk_deleted | (null) | mode=DeleteAndVoid, reason="...", deleted=92, voided=5 | 2026-05-15 14:32 |
| 1022 | voided | Bill #500 | reason="业务调整", from_status=Partial | 2026-05-15 14:31 |
| 1021 | collected | Bill #321 | amount=800, channel=微信 | 2026-05-10 11:23 |
| ... | ... | ... | ... | ... |
#### 查询 2:某 Bill 的所有操作历史
```sql
SELECT
event,
causer_id,
properties,
created_at
FROM activity_log
WHERE subject_type = 'App\\Models\\Bill'
AND subject_id = ? -- 具体 Bill ID
ORDER BY created_at ASC;
```
返回该 Bill 的**全生命周期**:created → collected → voided 等。
#### 查询 3:批量删除的详情
```sql
SELECT
causer_id,
properties->>'$.mode' AS mode,
properties->>'$.reason' AS reason,
properties->>'$.total_selected' AS selected,
properties->>'$.deleted_count' AS deleted,
properties->>'$.voided_count' AS voided,
properties->>'$.blocked_count' AS blocked,
JSON_LENGTH(properties->'$.affected_bill_nos') AS affected_count,
created_at
FROM activity_log
WHERE event = 'bulk_deleted'
AND created_at BETWEEN ? AND ?
ORDER BY created_at DESC;
```
可看出**每次批删**的统计 + 原因。
#### 查询 4:某 bill_no 是否被批删 / 作废过
```sql
SELECT *
FROM activity_log
WHERE event = 'bulk_deleted'
AND JSON_CONTAINS(
properties->'$.affected_bill_nos',
JSON_QUOTE('B-202605-501-001 [DELETED]')
);
```
或:
```sql
-- 灵活模糊匹配
SELECT *
FROM activity_log
WHERE event = 'bulk_deleted'
AND properties->'$.affected_bill_nos' LIKE '%B-202605-501-001%';
```
可追溯到**已物理删的 Bill** 是何时被谁删的。
### 第 3 步:解读 properties
不同 event 的 properties 结构不同:
| event | properties 主要字段 |
|---|---|
| `created`(账单创建)| `bill_no, amount, fee_type_id, resident_id` |
| `collected`(收款)| `amount, channel, receipt_id` |
| `voided`(单作废)| `reason, from_status, to_status, bill_no, amount, paid_amount` |
| `suspended`(挂起)| `reason, from_status, to_status, bill_no` |
| `resumed`(恢复)| `reason, from_status, to_status, bill_no` |
| `split`(拆账单)| `target_resident, amount_split, original_bill_id, new_bill_id` |
| `bulk_deleted`(批删)| `mode, reason, total_selected, deleted_count, voided_count, blocked_count, affected_bill_nos[]` |
详见 [[smart-bulk-delete-design]]"activitylog 设计"段。
### 第 4 步:出审计报告
```markdown
# 2026 年 5 月 嘉禾花园账单操作审计报告
## 审计范围
- 时段:2026-05-01 至 2026-05-31
- 模块:billing
- 关注操作:bulk_deleted, voided
## 高敏操作统计
- 批量删除(bulk_deleted):2 次
- 5/15 王主管:92 删 + 5 作废,原因"5 月 1 日 Replace 策略误用清理"
- 5/28 李经理:30 删,原因"测试数据清理"
- 单条作废(voided):8 次
- 大多与上述批删事件关联
- 1 次独立:5/22 王主管作废 Bill #321(原因:陈先生纠纷调解结果)
## 异常发现
- 无未授权操作(所有 bulk_deleted 操作员均有 bill.bulkDelete 权限)
- 无超规模操作(单次最多 92 张,合规)
- 所有操作都填了 reason(合规)
## 合规结论
- ✅ 所有高敏操作均有 audit trail
- ✅ 操作员权限符合岗位
- ✅ 原因填写规范
## 建议
- 长期保留 activitylog(至少 7 年,与会计档案同周期)
- 季度审计抽查
```
## 业户视角
业户**通常不直接接触** activitylog。但**法律纠纷时**:
- 业户对某账单的操作有疑问 → 业户可申请查看 activitylog
- 物业有义务**留存 + 展示**操作历史(透明化、可追溯)
- 业户/法院通过 activitylog 评估物业操作合规性
## 法务 / 监管视角
| 用途 | 怎么用 |
|---|---|
| **司法纠纷举证** | 业户起诉物业不当操作 → 物业拿 activitylog 证明操作合规 |
| **政府监管检查** | 检查批量删除 / 作废操作是否合理 |
| **行业自律审计** | 行业协会定期抽查 |
| **内部审计** | 财务总监 / 审计部门定期审 |
## 与 prop-acc 其他模块的对比
| 模块 | 审计方案 |
|---|---|
| **billing(本)** | **activitylog + meta** |
| deposit | meta JSON(`force_closed_*` / `freeze_reason` 等)|
| prepaid | meta JSON(`freeze_reason` / `unfreeze_reason`)|
| meter | meta JSON(`decommission_reason`)|
| adhoc | meta JSON 或 `voided_at` 字段 |
billing 的 activitylog 是 prop-acc **首个启用 spatie 审计日志的模块**(issue.md Q6 标志性改进)。其他模块**可以借鉴**(未来若启用,有 billing 实施经验)。
## activitylog 表结构(spatie 标准)
```sql
CREATE TABLE activity_log (
id BIGINT PRIMARY KEY,
log_name VARCHAR(255),
description TEXT, -- 简单描述
subject_type VARCHAR(255), -- 对象类(Bill)
subject_id BIGINT, -- 对象 ID
causer_type VARCHAR(255), -- 操作员类(User)
causer_id BIGINT, -- 操作员 ID
properties JSON, -- 详情(reason / amount / etc.)
event VARCHAR(255), -- 自定义事件名
batch_uuid VARCHAR(36), -- 可关联多条 log
created_at TIMESTAMP,
updated_at TIMESTAMP,
INDEX idx_subject(subject_type, subject_id),
INDEX idx_causer(causer_type, causer_id),
INDEX idx_event(event),
INDEX idx_created_at(created_at)
);
```
## 数据保留与归档
> [!warning] activitylog 表会快速增长
> 每次高敏操作 1 条 + 收款 / 创建 / 编辑等可能也加 → 数据增长快。
>
> 建议:
>
> - **生产环境**:保留 1-3 年热数据(查询性能优)
> - **冷数据归档**:超 1 年的迁到 archive 表 / 对象存储
> - **永久保留**:某些事件(bulk_deleted / voided)**法定 7+ 年**
>
> 当前未实施归档,数据量大后需要运维介入。
## 常见问题
> [!question] activitylog 与 meta JSON 的关系?
> 互补:
>
> | 方面 | activitylog | meta JSON |
> |---|---|---|
> | 保留多久 | 全平台共表(易归档)| 与对象同存(无法独立归档)|
> | 查询性能 | 按时间 / 类型查快 | 在对象上 random access 快 |
> | 关联多个对象 | ✅(properties 数组) | ❌(只在自己 meta)|
> | 跨模块查询 | ✅ | ❌(各模块字段不同)|
> | 操作上下文 | ✅(causer_id 直接)| ❌(若没存)|
>
> 推荐:meta 存"对象当前状态的辅助"(如 voided_at);activitylog 存"事件链"(谁干了什么)。两者不冲突。
> [!question] 业务人员如何看 activitylog?
> 当前**没有 UI**(spatie 包提供数据存储,UI 需自建)。审计师 / 业务方查询:
>
> - 直接 SQL(本场景示范)
> - 让运维查 / 出导出
> - 未来加 `ActivityLogResource` Filament UI(优先级看需求)
> [!question] activitylog 能改 / 删吗?
> 系统层面**理论上可改**(就是普通表)。**合规上不应改**(篡改审计 = 大罪)。
>
> 高级实施:用**append-only**表(数据库层面 disallow UPDATE/DELETE)+ 定期写校验和(若数据被改可发现)。当前未实施。
> [!question] 跨模块审计能合并查吗?
> 可以(activitylog 是全平台共表)。但其他模块当前**没启用 activitylog**(用 meta),所以跨模块审计**目前只看 bill 模块**。未来若其他模块启用,可统一查。
> [!question] activitylog 与系统日志(Laravel log)的差异?
> | 维度 | activitylog | Laravel log |
> |---|---|---|
> | 内容 | 业务事件(带 subject / causer / properties) | 技术日志(error / debug / info)|
> | 持久化 | 数据库 | 文件 / 集中日志服务 |
> | 业务查询 | ✅ 结构化,SQL 查 | ❌ 全文搜索 |
> | 合规价值 | **高** | 低(辅助排错)|
>
> 两者并存,各管各的。activitylog 关注**业务操作**,Laravel log 关注**系统行为**。
## 异常分支
- 发现疑似篡改 → 物业内部审计 + 法务介入
- 数据量太大查询慢 → 归档老数据
- 业户申请查 activitylog → 出报告(由审计师 / 法务出)
- 长期审计需求 → 加 `ActivityLogResource` UI(未来扩展)
## 相关文档
- [[smart-bulk-delete-design]](核心 activitylog 设计)
- [[delete-vs-void-dual-track]]
- [[bulk-delete-batch-mistake]]
- [[void-paid-bill]]
- [[suspend-bill]]
- [[resume-bill]]
- [[audit-monthly-billing-vs-collection]]

View File

@@ -0,0 +1,294 @@
---
title: prop-acc · billing · 场景 - 月度账单生成 vs 收款对比
aliases:
- 账单 vs 收款对比
- MonthlyBillingVsCollectionChart
- 收款率
- audit-monthly-billing-vs-collection
- 场景-月度账单收款对比
tags:
- 场景
- prop-acc
- 账单
- 审计
audience:
- 业务人员
- 财务
- 管理层
status: 已发布
sub_feature: billing
last_review: 2026-05-26
code_version: 2026-05-22
---
# 场景:月度账单生成 vs 收款对比
物业**月度报表**核心指标 —— **本月生成多少账单 vs 本月收到多少款**`MonthlyBillingVsCollectionChart` Widget 直观对比。是评估**收款率 / 应收账款健康度**的标准动作。
## 典型情境
> [!example] 真实情境
> 5 月底,王主管打开 dashboard 看 `MonthlyBillingVsCollectionChart`:
>
> - 本月生成账单:¥120,000(300 户 × 平均 ¥400)
> - 本月已收款:¥95,000
> - **收款率:79.2%**
> - **应收账款增长**:¥25,000(120 - 95)
>
> 与 4 月对比:
> - 4 月生成:¥118,000,4 月收:¥110,000(93.2%)
> - **5 月收款率下降 14%**,需排查原因
## Widget 显示
`MonthlyBillingVsCollectionChart`(在 BillingDashboard 或主 Dashboard):
```
2026 年月度账单 vs 收款对比图
| 生成 | 收款 | 收款率
2026-01 | 120k | 115k | 95.8%
2026-02 | 119k | 117k | 98.3%
2026-03 | 121k | 113k | 93.4%
2026-04 | 118k | 110k | 93.2%
2026-05 | 120k | 95k | 79.2% ⚠️ 异常
(柱状图)
```
可下钻看:
- 按费用类型分布(物业费 vs 水电气 vs 临时)
- 按业户类别(住宅 vs 商铺)
- 按收款方式(现金 / 微信 / 预存款抵 / 其他)
- 逾期账单累计金额
## 业务人员视角
### 第 1 步:看 Widget
每月初(5/1-5/3)看上月数据。
### 第 2 步:对比历史
| 月份 | 收款率 | 趋势 |
|---|---|---|
| 4 月 | 93.2% | 平稳 |
| 3 月 | 93.4% | 平稳 |
| 2 月 | 98.3% | 高 |
| 1 月 | 95.8% | 高 |
| **5 月** | **79.2%** | **断崖** |
5 月异常,需要排查。
### 第 3 步:排查原因
可能原因:
| 原因 | 排查方式 |
|---|---|
| **逾期账单激增** | 看 `OverdueBillsListWidget`,看 5 月逾期累计 |
| **某费用类型收款率低** | 按费用类型下钻 |
| **某社区收款率低** | 多社区时按社区下钻 |
| **某收款渠道异常** | 看渠道分布,例如微信掉单 |
| **季节性**(例如春节后回流缓)| 与去年同月对比 |
| **集抄数据异常**(导致账单虚高)| 看 [[../meter/exception-high-consumption]] |
| **业务方调整 RatePlan**(账单变高,业户抗拒)| 看 RatePlan 变更日志 |
### 第 4 步:出月度报告
给物业总经理 / 财务总监:
```markdown
# 2026 年 5 月 嘉禾花园收款月报
## 总览
- 应收账款生成:¥120,000(300 户)
- 实际收款:¥95,000
- 收款率:79.2%(同比 -14% / 环比 -14%)
## 异常分析
- 主要原因:5 月物业费 RatePlan 上调(¥3 → ¥3.5),业户抗拒
- 60 户拒绝按新价付,只付旧价部分 → Partial 状态激增
- 业主大会沟通中,预计 6 月底有结果
## 已采取措施
- 暂停涨价部分的催收(挂起 60 户的差额账单)
- 6 月初业主大会重新讨论
## 预测
- 6 月若按新价确认,收款率回升 90%+
- 6 月若需妥协 → 走批量作废涨价部分 + 重建按旧价
## 资金影响
- 应收账款余额从 4 月底 ¥35,000 升至 5 月底 ¥60,000
- 风险:占用物业现金流 + 6 月人员工资 / 维修 可能紧张
```
## SQL 报表查询
### 本月生成账单总额
```sql
SELECT SUM(amount) AS billed_total
FROM acc_bills
WHERE community_id = ?
AND billing_period_start BETWEEN '2026-05-01' AND '2026-05-31'
AND status != 'void';
```
### 本月收款总额
```sql
SELECT SUM(actual_amount) AS collected_total
FROM acc_collection_orders
WHERE community_id = ?
AND collection_type = 'Bill'
AND status = 'completed'
AND completed_at BETWEEN '2026-05-01' AND '2026-05-31 23:59:59';
```
### 收款率
```sql
-- 本月生成的账单的本月收款率(注意:可能本月生成的下月才付,本月也可能付上月生成的)
-- 标准公式:本月收款总额 / 本月生成总额
```
更精准的"本月生成 → 本月收"对比需要 join,看具体业务定义。
### 按费用类型分布
```sql
SELECT
ft.name AS fee_type,
SUM(b.amount) AS billed,
SUM(b.paid_amount) AS paid,
SUM(b.amount - b.paid_amount) AS outstanding,
ROUND(SUM(b.paid_amount) * 100.0 / NULLIF(SUM(b.amount), 0), 2) AS rate_pct
FROM acc_bills b
JOIN fee_types ft ON b.fee_type_id = ft.id
WHERE b.community_id = ?
AND b.billing_period_start BETWEEN '2026-05-01' AND '2026-05-31'
AND b.status != 'void'
GROUP BY ft.name
ORDER BY billed DESC;
```
返回:
| fee_type | billed | paid | outstanding | rate_pct |
|---|---|---|---|---|
| 物业费 | 100,000 | 75,000 | 25,000 | 75.0% |
| 水费 | 8,000 | 7,500 | 500 | 93.8% |
| 电费 | 10,000 | 9,500 | 500 | 95.0% |
| 燃气 | 2,000 | 1,950 | 50 | 97.5% |
| 杂费 | 0 | 0 | 0 | N/A |
立刻能看出**物业费是问题**(其他费用收款率 90%+)。
## 关联 Widgets
| Widget | 用途 |
|---|---|
| **`MonthlyBillingVsCollectionChart`**(本场景)| 整体趋势 |
| `BillingStatsOverviewWidget` | 总数 + 总额 + 收款率快照 |
| `FeeTypeRevenueDistributionChart` | 按费用类型分布(饼图)|
| `OverdueBillsListWidget` | 逾期清单 |
| `MonthlyRevenueTrendChart` | 月度收入趋势(长期看 12 月)|
业务人员**月初看一圈**:整体 → 类别 → 逾期 → 长期趋势。
## 业户视角
业户**不直接看这种报表**。但物业**可能公布**(透明化):
- 业主大会汇报"上月物业费收款率 X%"
- 在小区公告栏公示
- 在业主群发月度总结
收款率高 → 业户感觉"物业团结" 反之有疑虑。
## 财务视角
### 财务关心的核心指标
| 指标 | 目标 | 含义 |
|---|---|---|
| **收款率** | > 90%(目标 95%+)| 月度收款 / 月度账单 |
| **应收账款余额** | < 2 个月账单合计 | 累计欠款,反映现金流压力 |
| **逾期账单占比** | < 10% | 长期不付的比例 |
| **平均逾期天数** | < 30 | 收款时效 |
### 与会计核算的衔接
billing 报表与会计科目映射:
| 报表项 | 会计科目 |
|---|---|
| 本月生成账单总额 | "应收账款"(借方)+ "营业收入"(贷方,权责发生制) |
| 本月收款总额 | "现金 / 银行存款"(借方)+ "应收账款"(贷方) |
| 应收账款余额 | "应收账款"账户余额 |
报表为财务月度结账提供数据。
## 趋势分析
```mermaid
xychart-beta
title "嘉禾花园 6 个月账单 vs 收款"
x-axis [Jan, Feb, Mar, Apr, May]
y-axis "金额(千元)" 0 --> 150
bar [120, 119, 121, 118, 120]
line [115, 117, 113, 110, 95]
```
异常点(5 月)立刻可见。趋势线告诉管理层何时介入。
## 常见问题
> [!question] 收款率多低算异常?
> 看历史基线 + 行业标准:
> - 健康物业:90%+
> - 一般物业:80-90%
> - 问题物业:< 80%
>
> 单月波动 5-10% 正常(春节后 / 大额账单后);连续 2-3 月低于基线 = 严重问题。
> [!question] 本月生成的账单本月没付完算异常吗?
> 不一定。账单 due_at 通常是月底 + 宽限期(到下月中旬)。**本月内付清率 < 100% 是常态**。重要的是**到期前付清率**。
> [!question] 报表数据与银行账户对账不一致怎么办?
> 排查:
> - 是否有 CO 状态 = Completed 但银行未到账(在途资金)
> - 是否有 CO 创建错(状态 Failed 但 amount 异常)
> - 是否有 fund_source=prepaid 的(账面收款但银行没动)
> - 是否有手工调账 / tinker 操作
> [!question] Widget 数据**慢**怎么办?
> 大数据量(100k+ 账单)时 SQL 可能慢。优化:
> - 加索引(`community_id`, `billing_period_start`, `status`)
> - 用物化视图(定时刷新)
> - 引入 OLAP 工具(BI dashboard 专门处理)
>
> 当前数据量不大,Widget 直接 SQL 应能秒级出。
> [!question] 多社区合并看怎么办?
> Widget 通常按当前 Panel 的 community_id 过滤。**多社区合并**需要管理 Panel 角色(无社区限制)+ Widget 显示总览(可下钻到具体社区)。
## 异常分支
- 收款率低 + 逾期多 → 走 [[exception-overdue-bills|催收]]
- 单业户 Partial 多 → [[exception-partial-payment|跟进部分付]]
- 收款率异常(数据 bug)→ [[audit-activitylog-trace|查 activitylog]] 排查异常操作
- 长期低收款率 → 业务方反思 RatePlan / 服务质量
## 相关文档
- [[exception-overdue-bills]]
- [[exception-partial-payment]]
- [[audit-activitylog-trace]]
- [[bill-six-state-machine]]
- [[bill-vs-collection-order]]
- [[../prepaid/audit-low-balance-and-overdue]]
- [[../deposit/audit-monthly-deposit-balance]]

View File

@@ -0,0 +1,267 @@
---
title: prop-acc · billing · 场景 - 批量误建,智能 Modal 三档分类清理
aliases:
- 批量删除账单
- BulkDeleteBillsAction 实战
- 智能批删
- bulk-delete-batch-mistake
- 场景-批量删除账单
tags:
- 场景
- prop-acc
- 账单
- 删除
- 批量
audience:
- 业务人员
- 财务
status: 已发布
sub_feature: billing
last_review: 2026-05-26
code_version: 2026-05-22
---
# 场景:批量误建,智能 Modal 三档分类清理
业务人员**误触发批量生成**(例如点了一次"生成 5 月物业费"后忘了又点了一次,导致每户有两张同样账单),需要清理。走 `BulkDeleteBillsAction` 的智能 Modal,自动按状态分三档处理。
## 典型情境
> [!example] 真实情境
> 王主管 5 月 1 日上午点了"生成 5 月物业费"批量(已生成 300 张)。下午接电话被打断,**忘了之前已经生成**,又点了一次 → 系统按 `SkipExisting` 策略**全部跳过**(因为已存在)。
>
> **但**:王主管 Modal 没看清,选了 `Replace` 策略 → 系统 **作废原 300 张 + 重新生成 300 张** → 同业户有重复账单(状态 Void 的旧账单 + Unpaid 的新账单)。
>
> 业务人员发现后需清理 300 张 Void 状态的旧账单。
或者:
> [!example] 真实情境(更典型)
> 业务人员手工建了 100 张维修分摊账单,分摊金额算错,需要全部清理重建。
## 业务人员视角
### 第 1 步:筛选要清理的账单
后台 → 账单 → 列表 → 用过滤(按期次 / 状态 / 费用类型 / 生成时间)选出要清理的批。
例:5 月物业费 + Void 状态 + 创建时间 today → 300 张 Void 旧账单。
### 第 2 步:选中
Table 上**勾选**这 300 张(全选 / 多选)。
### 第 3 步:触发批量删除
顶部 **"批量删除"** 按钮(`BulkDeleteBillsAction`)。
> [!warning] 权限守护
> 需要 `bill.bulkDelete` **独立高敏权限**(`Policy::deleteAny`)。普通业务人员可能没此权限,需主管 / 财务总监操作。
>
> 详见 [[smart-bulk-delete-design]]"权限设计"段。
### 第 4 步:智能 Modal(关键)
Modal 自动**预检查**所选账单 + 分三档:
```
批量删除账单 (选中 300 张)
预检查统计:
✅ 可删: 200 张 (Unpaid + 无付款关联)
⚠️ 需作废: 50 张 (Void 已经 / Partial 已经 = 状态)
❌ 跳过: 50 张 (Paid 已付 / 其他异常)
请选择处理模式:
⚪ 仅删除可删的(200 张物理删,100 张跳过)
⚫ 删可删 + 作废需作废的(200 删 + 50 作废,50 跳过)
批量操作原因(必填,审计留痕):
[多行输入框:
5 月 1 日批量生成时误选 Replace 策略,
导致 300 张旧账单 Void。现批量清理。
]
⚠️ 注意:
- 物理删除不可恢复(但 activitylog 保留 bill_no)
- 作废不可撤销
- 跳过的账单不动
[取消] [确认执行]
```
### 第 5 步:选模式 + 提交
业务人员看到统计,做决定:
| 选 | 业务效果 |
|---|---|
| 仅删可删的 | 200 物理删,100 暂留(包括 50 已作废的 + 50 跳过的)|
| 删可删 + 作废需作废 | 200 物理删,50 翻 Void(变作废),50 跳过 |
填原因 → 提交。
### 第 6 步:看执行结果
系统返回:
```
批量操作完成:
- 物理删除:200 张
- 作废:50 张(若选 DeleteAndVoid)
- 跳过:50 张
详细 bill_no 见 activitylog(本次操作)。
```
## 系统流程
```mermaid
sequenceDiagram
participant 业务
participant Filament
participant Modal
participant Action[BulkDeleteBillsAction 业务层]
participant Activity
participant DB
业务->>Filament: BillsList → 选 300 张 → 批量删除
Filament->>Modal: 预检查(分类三档)
Modal->>Filament: 显示 ✅200 ⚠50 ❌50
业务->>Modal: 选模式 + 填原因 + 提交
Modal->>Action: handle(bills, mode, reason, user)
Action->>Action: 再次分类(防 UI 缓存过期)
Action->>DB: 开启事务
loop 200 可删
Action->>DB: bill.delete()
end
alt mode = DeleteAndVoid
loop 50 可作废
Action->>Action: VoidBillAction.handle(bill, reason, user)
Action->>Activity: log voided(单条)
end
end
Action->>Activity: log bulk_deleted(总体 + affected_bill_nos 数组)
Action->>DB: 提交
Action-->>Filament: 结果 + 通知
Filament-->>业务: 显示"200 删 / 50 作废 / 50 跳过"
```
## activitylog 设计
详见 [[smart-bulk-delete-design]]"activitylog 设计"段。
**单条 bulk_deleted log**(批量操作只一条):
```json
{
"log_name": "default",
"subject_type": null,
"subject_id": null,
"event": "bulk_deleted",
"causer_id": 42, // 王主管 ID
"properties": {
"mode": "DeleteAndVoid",
"reason": "5 月 1 日批量生成时误选 Replace,300 张 Void 清理",
"total_selected": 300,
"deleted_count": 200,
"voided_count": 50,
"blocked_count": 50,
"affected_bill_nos": [
"B-202605-501-001 [DELETED]",
"B-202605-502-001 [DELETED]",
...
"B-202605-501-002 [VOIDED]",
...
"B-202605-501-003 [SKIPPED]",
...
]
}
}
```
`affected_bill_nos` 是数组,每条标记处理结果。事后可完整还原。
**同时,每张被作废的 Bill 还各有一条 voided log**(VoidBillAction 内部触发,subject=bill)。
## 业户视角
业户**通常不感知**:
- 被物理删的 200 张:业户未付款,通常未通知 → 无感
- 被作废的 50 张:业户**可能**收到通知(看物业策略),或无感
- 跳过的 50 张:不动,不影响
**理想**:批量误建及时清理 → 业户全程无感 → 物业体面化解错误。
**不理想**:清理前业户已经收到推送 + 已付款 → 跳过(Paid 不可删 / 不可作废)→ 需要走单独的"已付作废 + 退款"流程([[void-paid-bill]])。
## 与单删 / 单作废的对比
| 维度 | [[delete-bill-unpaid|单删]] | [[void-paid-bill|单作废]] | **智能批删(本)** |
|---|---|---|---|
| 一次操作多少张 | 1 | 1 | **多张(N)** |
| 权限 | `bill.delete` | `bill.void` | **`bill.bulkDelete`(独立高敏)** |
| 处理混合状态 | 无(只删 1 张)| 无 | **✅ 三档分类自动** |
| activitylog | 1 条单条 | 1 条单条 | **1 条总体 + N 条单条** |
| 业务人员效率 | 低(逐张)| 低 | **高(一次)** |
| 业务人员风险 | 低 | 低 | **中(影响面大)** |
## 常见问题
> [!question] 批删的"跳过"档具体包括哪些?
> `canBeDeleted=false && canBeVoided=false`:
>
> - **Paid**(已付清,无法走任何"消除"路径,需走专门退款流程)
> - **Void**(已经作废,无需重复)
> - **Processing**(罕见,处理中,等回调)
>
> 跳过的账单不动,业务人员需单独处理。
> [!question] 实际执行时与 Modal 预检查不一致(其他人在中间改了)?
> Action 内部**再次分类**(防 UI 缓存过期),实际按最新状态分。可能与 Modal 显示统计**略有差异**(但极少)。
> [!question] 批删后想撤销?
> - **物理删的**:不可恢复(数据没了)
> - **作废的**:不可撤销(Void 终态)
> - 唯一办法:重新创建(走 [[create-periodic-property-fee]] 或 [[create-single-bill-manual]])
>
> 信息可从 activitylog 还原(bill_no / amount 等)。
> [!question] 批删的影响面太大会有审批吗?
> 系统层面**无审批流**。靠**权限控制**(只主管 / 财务总监能批删)+ **必填原因**(留审计)+ **Modal 预检查统计**(让操作者看到影响面后决策)。
>
> 业务上若需"超过 100 张需双签",需扩展加审批 workflow。
> [!question] 批删失败怎么办?
> 事务内执行,某张失败 → 整批回滚。事务边界是**全成功或全失败**。
>
> 例外:`Action` 实现可能优化为"个别失败不影响其他",但破坏原子性 → 通常不推荐。
> [!question] activitylog 表太多 bulk_deleted 怎么管?
> 每次批删一条 log(总体 + affected_bill_nos)。即使 100 次批删 = 100 条 log。**不会爆表**。
>
> 但 voided 的子 log(每张作废一条) = 大量。批删 50 张作废 = 50 条 voided log。归档策略详见 [[smart-bulk-delete-design]]。
## 异常分支
- 单张删 → [[delete-bill-unpaid]]
- 单张作废 → [[void-paid-bill]]
- 跳过的 Paid 账单要消除 → [[void-paid-bill|Paid 作废 + 退款]](当前手工)
- 批删后想审计 → [[audit-activitylog-trace]]
## 相关文档
- [[smart-bulk-delete-design]](核心概念)
- [[delete-vs-void-dual-track]]
- [[delete-bill-unpaid]]
- [[void-paid-bill]]
- [[bill-six-state-machine]]
- [[audit-activitylog-trace]]

View File

@@ -0,0 +1,257 @@
---
title: prop-acc · billing · 场景 - 同业户多账单批量收款
aliases:
- 批量收款
- BatchCollectPaymentAction
- 一次付多张
- collect-payment-batch
- 场景-同业户批量收款
tags:
- 场景
- prop-acc
- 账单
- 收款
audience:
- 业户
- 业务人员
status: 已发布
sub_feature: billing
last_review: 2026-05-26
code_version: 2026-05-22
---
# 场景:同业户多账单批量收款
业户**本月有多张账单**(物业费 + 水电气费 + 其他),想**一次性付清**。业务人员走 `BatchCollectPaymentAction`,**一笔 CollectionOrder 关联多张 Bill**(走 CollectionOrderBill 多对多)。
## 典型情境
> [!example] 真实情境
> 张阿姨本月有 4 张账单:
>
> | 账单 | 金额 |
> |---|---|
> | 5 月物业费 | ¥800 |
> | 5 月水费 | ¥54 |
> | 5 月电费 | ¥168 |
> | 5 月燃气 | ¥30 |
> | **合计** | **¥1,052** |
>
> 张阿姨到前台:"4 张账单我一次性付清,微信扫码"。业务人员**1 笔操作**完成 4 张账单收款。
## 业户视角
### 第 1 步:告诉业务人员要付哪些
> "我把本月 4 张账单都付了,微信扫码"
### 第 2 步:确认金额
业务人员说:"4 张账单合计 ¥1,052,微信扫这个码"。
### 第 3 步:微信付
业户扫码 → 输密码 / 指纹 → 付 ¥1,052。
### 第 4 步:拿收据
可能是:
- 一张 Receipt 含 4 行明细(物业费 ¥800 / 水费 ¥54 / ...)
- 或 4 张独立 Receipt(每张账单一张)
具体看实现。**业户体验上前者更好**(一张收据看全部)。
## 业务人员视角
### 第 1 步:找业户
后台 → 业户 → 找到张阿姨 → "她的账单"标签 → 看到 4 张 Unpaid。
或:后台 → 账单 → 列表 → 过滤业户=张阿姨 + 状态=Unpaid。
### 第 2 步:选中多张账单
Table 上勾选 4 张账单 → 顶部 **"批量收款"** 按钮(`BatchCollectPaymentAction`)。
或:后台 → 业户视图 → "批量收款"(若 UI 支持单业户聚合)。
### 第 3 步:Modal 表单
| 字段 | 填什么 |
|---|---|
| **选中账单数** | 4 张(显示)|
| **合计金额** | ¥1,052(自动算)|
| **支付方式** | 微信 |
| **收款银行账户** | 物业微信账户 |
| **备注** | 选填,如 "业户本月全套缴" |
### 第 4 步:提交
系统在**一个事务**内:
1. 校验每张 Bill 可付(`canBePaid()`)
2.**1 个 CollectionOrder**(`type=Bill`,`actual_amount=+1052`,`status=Completed`)
3.**4 个 CollectionOrderBill**(每张账单一个,各自 `allocated_amount` = 该账单金额)
4. 4 张 Bill 各自 `paid_amount = amount`,`status = Paid`
5. 触发 `CollectionOrderCompleted` 事件
6. Listener 建 Receipt(可能含 4 个 line_items)
### 第 5 步:给收据
后台找到 Receipt → 打印 / 微信发。
## 系统流程
```mermaid
sequenceDiagram
participant 业户
participant 业务
participant Filament
participant Action[BatchCollectPaymentAction]
participant DB
业务->>Filament: BillsList → 选 4 张 → 批量收款
Filament->>Action: handle(bills, channel, bank)
Action->>Action: 每张 Bill 校验 canBePaid
Action->>DB: 开启事务
Action->>DB: 1. 建 CollectionOrder(+1052, Completed)
loop 每张 Bill
Action->>DB: 2. 建 CollectionOrderBill(allocated=bill.amount)
Action->>DB: 3. Bill.paid_amount = amount, status=Paid
end
Action->>Listener: 4. 触发 CollectionOrderCompleted
Listener->>DB: 5. 建 Receipt(line_items × 4)
Action->>DB: 提交事务
Filament-->>业务: 成功通知
业务-->>业户: 收据(含 4 项明细)
```
## 数据示例
收款后:
### CollectionOrder(1 条)
```
id: 67890
collection_type: Bill
actual_amount: +1052
payment_channel: 微信
status: Completed
meta.fund_source: external
```
### CollectionOrderBill(4 条)
| collection_order_id | bill_id | allocated_amount |
|---|---|---|
| 67890 | 物业费 Bill | 800 |
| 67890 | 水费 Bill | 54 |
| 67890 | 电费 Bill | 168 |
| 67890 | 燃气 Bill | 30 |
### Bill(4 条更新)
每张 paid_amount = amount,status = Paid。
### Receipt(1 条,4 行明细)
```
collection_order_id: 67890
amount: +1052
line_items: [
{ 物业费(5月), 800 },
{ 水费(5月), 54 },
{ 电费(5月), 168 },
{ 燃气(5月), 30 },
]
```
## 与单张收款的对比
| 维度 | [[collect-payment-single|单张]] | **批量(本场景)** |
|---|---|---|
| Modal 选账单 | 1 张(从 ViewBill 进入)| **多张**(Table 勾选)|
| CollectionOrder | 1 个 | 1 个(共用)|
| CollectionOrderBill | 1 个 | **N 个**(每张一个)|
| 业务人员操作 | 单笔 | 一笔 |
| 业户体验 | 一张一张付(慢)| 一次付清(快)|
**批量收款的好处**:业户体验更好(一次付),业务人员工作量更小。**唯一前提**:业户愿意一次付清。
## 不同支付方式的分摊
业户支付的钱**自动按账单原金额比例分摊** 到各 Bill。不需要业务人员手动分配。
例:¥1,052 微信付 → 4 个 CollectionOrderBill 各自 allocated_amount = 该账单 amount(全额分配)。
### 如果业户付不够(部分批量付)
业户只想付 ¥600(不够 ¥1,052)→ 业务人员有几种选择:
| 策略 | 操作 |
|---|---|
| **优先付物业费**(默认?)| ¥600 全部分配给物业费 Bill(部分付)|
| **按比例分摊** | 600 × 800/1052 = 456 给物业费,600 × 54/1052 = 31 给水费, ... |
| **业务人员手动决定** | 给业务人员选哪张账单付多少 |
当前实现的具体策略看代码。**业务上建议优先付到期早的**(避免逾期)。
> [!info] 批量收款的"部分付"复杂度
> 上述场景比单张部分付更复杂。当前 `BatchCollectPaymentAction` 可能**只支持全额批量**(若金额够付所有选中账单 → 全付;不够 → 走单张部分付逐张操作)。看实现。
## 业务人员视角:Modal 的预检查
类似 [[smart-bulk-delete-design|智能批删]] 的预检查思路:
- 选中 4 张账单 → Modal 显示"总计 ¥1,052"
- 选中包含已 Paid 的账单 → Modal 显示"4 选 + 1 已付跳过"
- 选中包含 Suspended → Modal 提示 "该账单挂起,无法收款"
> [!info] 当前实现的成熟度
> `BatchCollectPaymentAction` 的智能 Modal 程度看实现。可能比 `BulkDeleteBillsAction` 简单(批删有 issue.md Q6 详细设计,批收款未单独描述)。
## 常见问题
> [!question] 选中跨业户的多张账单能批量收款吗?
> 业务上**不应该**(不同业户付的钱不能混)。系统层面应**校验同业户**,否则拒绝。
>
> 例外:**家属代付**场景(儿子来付父母账单)→ 业务上算同业户的钱。
> [!question] 批量收款失败一半怎么办?
> 事务内**全成功或全失败**。任一 Bill 校验失败(例如某张已 Paid)→ 整笔回滚 → 业户的钱不被收。
>
> 实施上可能优化为"部分成功"(只失败的跳过),但破坏事务原子性,通常不推荐。
> [!question] 业户支付的钱比账单合计**多**?
> Modal 守护应限制金额 ≤ 合计。如果业务上业户故意多给:
>
> - 找零给业户(系统层面只收账单的金额)
> - **或转入业户预存款账户**(若有此自动逻辑)
> [!question] 不同期次的账单能一起付吗?
> 可以(账单状态都是 Unpaid 即可)。例如付 4 月物业费 + 5 月物业费 + 5 月水电气。
> [!question] 批量收款的 activitylog 怎么记?
> 一条 CollectionOrder 的 activitylog(event=created)+ 每张 Bill 状态变化的 log。可在 SQL 反查 affected_bill_ids。
## 异常分支
- 单张付 → [[collect-payment-single]]
- 部分付 → [[exception-partial-payment]]
- 预存款抵 → [[collect-via-prepaid-auto]]
- 业户全付不起,挑某张付 → 走 [[collect-payment-single]] 逐张
- 收错了想撤 → [[void-paid-bill]](已付作废 + 退款)
## 相关文档
- [[bill-vs-collection-order]]
- [[collect-payment-single]]
- [[collect-via-prepaid-auto]]
- [[exception-partial-payment]]
- [[../prepaid/auto-deduction-design]]

View File

@@ -0,0 +1,258 @@
---
title: prop-acc · billing · 场景 - 单张账单收款
aliases:
- 单张收款
- 收款
- CollectPaymentAction
- collect-payment-single
- 场景-单张账单收款
tags:
- 场景
- prop-acc
- 账单
- 收款
audience:
- 业户
- 业务人员
status: 已发布
sub_feature: billing
last_review: 2026-05-26
code_version: 2026-05-22
---
# 场景:单张账单收款
业户**单张账单付款**(物业费 / 水费 / 电费的某一张),业务人员后台触发 `CollectPaymentAction`。最基础高频的收款场景。
## 典型情境
> [!example] 真实情境
> 张阿姨 5 月物业费 ¥800 账单已生成,她下午到物业前台:
>
> - "我交 5 月物业费"
> - 业务人员小李打开张阿姨账户 → 找到 5 月物业费账单 → 收款
## 业户视角
### 第 1 步:到前台 / 小程序
带:
- 钱(现金 / 微信 / POS 卡)
- 房号 / 姓名(身份证)
### 第 2 步:告诉业务人员要付哪张账单
> "我交 5 月物业费"
业务人员从系统找到对应账单。
### 第 3 步:确认金额 + 付款方式
业务人员告诉张阿姨:
> "您 5 月物业费 ¥800,请选付款方式"
| 付款方式 | 操作 |
|---|---|
| 现金 | 给钱 → 找零 |
| 微信扫码 | 业务人员出示物业收款码 → 业户扫 |
| POS 刷卡 | 业户给银行卡 → POS 机刷 |
| 银行转账 | 业户给凭证(线下转账已到账)|
### 第 4 步:拿收据
- 纸质:**当场打印**
- 电子:发到业户微信 / 邮箱
> [!success] 完成
> 账单状态从 Unpaid → Paid。业户带收据离开,5 分钟内搞定。
## 业务人员视角
### 第 1 步:找账单
后台 → 账单 → 列表 → 按业户姓名 / 房号 / 期次过滤 → 找到张阿姨的 5 月物业费(Unpaid,¥800)→ 进 `ViewBill`
或者:
- 后台 → 业户 → 找到张阿姨 → "她的账单"标签 → 看 Unpaid 列表 → 选
### 第 2 步:点击 `CollectPaymentAction`(标签"收款")
右上角状态管理组。
> [!warning] 按钮可见性
> `CollectPaymentAction` 守护:`canBePaid()`(Unpaid / Partial)+ `->authorize('collect')`。Paid / Void / Suspended / Processing 灰化。
Modal 表单:
| 字段 | 填什么 |
|---|---|
| **收款金额** | ¥800(默认全额,可改为部分)|
| **支付方式(`payment_channel_id`)** | 现金 / 微信 / POS / 银行转账 |
| **收款银行账户(`bank_account_id`)** | 微信/POS/转账选对应银行账户;现金可空 |
| **收款备注** | 选填,如"业户现场付款" |
### 第 3 步:提交
系统在**一个事务**内:
1. 校验 Bill 可付(`canBePaid` = Unpaid / Partial)
2. 校验金额 ≤ Bill 剩余应付(`amount - paid_amount`)
3.`CollectionOrder`(`type=Bill`,`actual_amount=+800`,`status=Completed`,`payment_channel`,`meta.fund_source=external`)
4.`CollectionOrderBill`(`bill_id`,`collection_order_id`,`allocated_amount=800`)
5. 更新 `Bill.paid_amount += 800`
6. 更新 `Bill.status`:若 paid_amount = amount → `Paid`;若 < amount → `Partial`
7. 触发 `CollectionOrderCompleted` 事件
8. Listener 自动建 `Receipt` + `ReceiptItem`(文案"物业费 ¥800")
9. 写 activitylog(可选,具体看实现)
### 第 4 步:给业户收据
后台找到新生成 Receipt → 打印 / 微信发。
## 系统流程
```mermaid
sequenceDiagram
participant 业户
participant 业务[业务人员]
participant Filament
participant Action[CollectPaymentAction]
participant DB
participant Listener
业户->>业务: 付物业费 800
业务->>Filament: ViewBill → CollectPaymentAction(modal)
Filament->>Action: handle(bill, 800, channel, bank)
Action->>Action: canBePaid()? Unpaid=true
Action->>Action: 800 ≤ remaining(800)? yes
Action->>DB: 开启事务
Action->>DB: 1. 建 CollectionOrder(type=Bill, +800, Completed)
Action->>DB: 2. 建 CollectionOrderBill(allocated=800)
Action->>DB: 3. Bill.paid_amount=800
Action->>DB: 4. Bill.status=Paid(800=800)
Action->>Listener: 5. 触发 CollectionOrderCompleted
Listener->>DB: 6. 建 Receipt("物业费 ¥800")
Action->>DB: 提交事务
Filament-->>业务: 成功通知
业务-->>业户: 收据
```
## 部分付场景(business 上常见)
业户只想付 ¥300(全额 ¥800):
```
Modal 表单:
- 收款金额:300(手动改)
- 支付方式:现金
- 备注:"暂时只能付一部分"
```
提交后:
- Bill.paid_amount = 300
- Bill.status: Unpaid → **Partial**
- 建 CollectionOrder(+300)+ CollectionOrderBill(allocated=300)
业户后续补付 ¥500 → 同样走 `CollectPaymentAction` → 第 2 笔 CollectionOrderBill → Bill 收齐 → Paid。
详见 [[exception-partial-payment]]。
## 数据示例(完整流水)
业户付 ¥800 后:
### Bill 表
| 字段 | 值 |
|---|---|
| `id` | 12345 |
| `amount` | 800 |
| `paid_amount` | 800 |
| `status` | Paid |
### CollectionOrderBill 表
| 字段 | 值 |
|---|---|
| `bill_id` | 12345 |
| `collection_order_id` | 67890 |
| `allocated_amount` | 800 |
### CollectionOrder 表
| 字段 | 值 |
|---|---|
| `id` | 67890 |
| `collection_type` | Bill |
| `actual_amount` | +800 |
| `payment_channel_id` | 微信 |
| `status` | Completed |
| `meta.fund_source` | external |
### Receipt 表
| 字段 | 值 |
|---|---|
| `collection_order_id` | 67890 |
| `amount` | +800 |
| line_items | [{ 物业费(5月), 800 }] |
## 常见问题
> [!question] 业户付的钱与账单金额不一致(多付 / 少付)?
> | 情况 | 处置 |
> |---|---|
> | 少付 | 走部分付 Partial 状态;后续补付 |
> | **多付** | **不应该**(Modal 守护 `amount ≤ remaining`)。如果业务上业户给的钱多,**找零给业户**(系统层面只收账单的金额) |
> [!question] 业户付错账单(本想付物业费,付到电费)?
> 业务人员可:
> - 立即作废这笔 CollectionOrder(走 [[void-paid-bill]] 类似流程)+ 重新对正确账单收款
> - 或者**留这笔不动**(资金确实到账)+ 业务人员手工调整 CollectionOrderBill 的 allocated_amount(危险,会破坏审计)
>
> **推荐第一种**(走作废 + 重收)。
> [!question] Frozen Bill 能收款吗?
> 不能。`canBePaid()` 只允许 Unpaid / Partial。Suspended 状态需先 [[resume-bill|恢复]]。
> [!question] 已付的 Bill 能再收款吗?
> 不能。`canBePaid()` Paid 时返 false。理论上 Bill 已经付清。
> [!question] 业户预存款够付,业务人员怎么操作?
> 看自动 / 手动:
> - 自动(待补的 [[../prepaid/auto-deduction-design|预存款自动抵扣 job]]):业务人员不操作,系统自动
> - 手动:业务人员在 `ViewBill` → 走 `CollectPaymentAction` 选"预存款抵扣"(或专用 Action)→ 详见 [[collect-via-prepaid-auto]]
> [!question] 收款时 PaymentChannel 写错(选了微信实际是现金)?
> CollectionOrder 一经创建**通常不可改**字段。错了:
> - 走作废(详见 [[void-paid-bill]])+ 重新建
> - 或 tinker 改字段(运维 + 留审计)
>
> 预防:Modal 提交前再三确认。
> [!question] activitylog 记什么?
> 详见 [[smart-bulk-delete-design]] activitylog 设计。CollectPayment 通常也记一条 activitylog:event=collected,properties 含 amount / payment_channel / receipt_id。
## 异常分支
- 部分付场景 → [[exception-partial-payment]]
- 批量付(多张账单一起付)→ [[collect-payment-batch]]
- 预存款抵 → [[collect-via-prepaid-auto]]
- 收款错了想撤 → [[void-paid-bill]]
- Bill 挂起中无法收 → [[resume-bill]]
- 逾期账单催收 → [[exception-overdue-bills]]
## 相关文档
- [[bill-six-state-machine]]
- [[bill-vs-collection-order]]
- [[exception-partial-payment]]
- [[collect-payment-batch]]
- [[collect-via-prepaid-auto]]
- [[../adhoc/collection-order-and-receipt]]

View File

@@ -0,0 +1,267 @@
---
title: prop-acc · billing · 场景 - 预存款抵扣自动收款
aliases:
- 预存款抵账单
- 自动抵扣收款
- collect-via-prepaid-auto
- 场景-预存款自动抵扣
tags:
- 场景
- prop-acc
- 账单
- 收款
- 跨子模块
audience:
- 业户
- 业务人员
- 财务
status: 已发布
sub_feature: billing
last_review: 2026-05-26
code_version: 2026-05-22
---
# 场景:预存款抵扣自动收款
业户**预存款余额够付账单**,系统(理想是自动 job,当前是业务人员手动触发)走 `ConsumeFromPrepaidAccountAction` 抵扣账单。这是 billing × prepaid **两子模块联动**的核心场景。
> [!info] 本场景跨两个模块
> - **billing 视角**:Bill 从 Unpaid → Paid,看似与普通收款一样
> - **prepaid 视角**:PrepaidAccount.balance 减,走 consume 流水
>
> 详见 [[../prepaid/consume-monthly-property-bill]] 完整流程。本场景从 billing 角度补充。
## 典型情境
> [!example] 真实情境
> 张阿姨预存款账户余额 ¥3,400,5 月物业费 ¥800。
>
> 手动模式:王主管在张阿姨预存款账户上点 `ConsumeAction`,选 5 月物业费 → 抵扣完成 → 账单 Paid。
>
> 自动模式(待补 [[../prepaid/auto-deduction-design]]):月初 1 日凌晨 job 自动跑 → 张阿姨账单当月自动扣 → 早晨业户收到推送"5 月物业费 ¥800 已抵扣,余额 ¥2,600"。
## billing 视角
### Bill 的变化
| 字段 | 变化前 | 变化后 |
|---|---|---|
| `status` | Unpaid | Paid |
| `paid_amount` | 0 | 800 |
| 关联的 CollectionOrderBill | 0 个 | 1 个(allocated=800)|
| 关联的 CollectionOrder | 0 个 | 1 个(type=Bill, meta.fund_source=prepaid)|
| 关联的 Receipt | 0 个 | 1 个("物业费 ¥800") |
**与普通收款的唯一差异**:CollectionOrder 的 `meta.fund_source = 'prepaid'`(而非默认 `external`)。
### CollectionOrder 的特殊性
```yaml
id: 67890
collection_type: Bill # 仍是 Bill 视角(不是 Prepaid)
actual_amount: +800 # 正数
payment_channel: null # 不走外部支付渠道
status: Completed
meta:
fund_source: prepaid # 标资金来源
prepaid_account_id: 123
prepaid_transaction_id: 456
```
详见 [[../prepaid/consume-via-bill-collection-type]]"CollectionOrder.type=Bill 设计"段。
### Receipt 文案
与现金 / 微信付的收据**长一样**:
```
物业费(5月)¥800
```
业户**感知不到**资金来源差异。这是有意设计 —— "业户付清账单"的统一感受。
## prepaid 视角(简述)
详见 [[../prepaid/consume-monthly-property-bill]] 完整流程。
- 校验账户 `canOperate()`(Active)
- 校验余额够付(`balance >= 800`)
- 校验跨社区(Bill 与 Account 同 community)
-`PrepaidTransaction(type=consume, amount=800, related_bill_id, balance 3400→2600)`
- 更新 `PrepaidAccount.balance = 2600`
- 同步 Bill 状态翻 Paid(通过 CollectionOrder + CollectionOrderBill)
## 业户视角
### 您会感受到什么
| 模式 | 感知 |
|---|---|
| **自动(待补)** | 月初推送"5 月物业费 ¥800 已自动从预存款扣,余额 ¥2,600" |
| **手动** | 同上,只是触发时间不固定(看业务人员何时操作)|
### 与现金付的差异
| 维度 | 现金付 | **预存款抵** |
|---|---|---|
| 业户操作 | 到前台 + 给钱 | **无操作**(自动) |
| 业户感知 | "我付了" | **"已抵扣"**(被动) |
| Receipt | "物业费 ¥800" | **"物业费 ¥800"**(相同) |
| 业务人员介入 | 多(收钱 + 录入) | **无**(自动 job)/ 中(手动) |
| 时长 | 业户上门 + 5 分钟办理 | 0 秒(自动)/ 5 分钟(手动) |
## 业务人员视角
### 手动模式
业务人员在 `ViewPrepaidAccount`(预存款账户详情页):
1. 看到张阿姨账户余额 ¥3,400
2. 点击 `ConsumeAction`(预存款上的)
3. Modal 选 Bill = "5 月物业费 ¥800"
4. 提交 → 系统自动跑完整链路
> [!info] 这个 Action 不在 billing 模块
> `ConsumeAction` 是 prepaid 模块的 Filament Action,在 `PrepaidAccounts/Actions/`。billing 模块的 `CollectPaymentAction` 是普通收款用的(走外部 PaymentChannel)。两者**协作完成**预存款抵扣场景。
>
> 详见 [[../prepaid/consume-monthly-property-bill]]"业务人员视角"。
### 自动模式(待补)
业务人员**几乎不操作**(产品价值的最大体现)。月初看 dashboard 报告:
```
2026 年 6 月 1 日 PrepaidAutoDeductionJob 报告
- 候选账户:500
- 全抵成功:380(76%)
- 部分抵 / 跳过:80(16%)
- 账户冻结跳过:8(2%)
- 失败:0
```
逐个跟进失败 / 跳过的(走 [[../prepaid/audit-low-balance-and-overdue|低余额预警]] 等)。
## 系统流程(手动)
```mermaid
sequenceDiagram
participant 业务
participant Filament
participant ConsumeAction[Prepaid 的 ConsumeAction]
participant ConsumeFromPrepaid[ConsumeFromPrepaidAccountAction]
participant Bill
participant PrepaidAccount
participant DB
participant Listener
业务->>Filament: ViewPrepaidAccount → ConsumeAction(选 Bill, 800)
Filament->>ConsumeFromPrepaid: handle(account, bill, 800)
ConsumeFromPrepaid->>PrepaidAccount: canOperate() ? Active=true
ConsumeFromPrepaid->>PrepaidAccount: community_id match? yes
ConsumeFromPrepaid->>PrepaidAccount: balance >= 800? yes
ConsumeFromPrepaid->>DB: 开启事务
ConsumeFromPrepaid->>DB: 1. 建 CollectionOrder(type=Bill, +800, meta.fund_source=prepaid)
ConsumeFromPrepaid->>DB: 2. 建 CollectionOrderBill(allocated=800)
ConsumeFromPrepaid->>PrepaidAccount: 3. consume(bill, 800)
PrepaidAccount->>DB: 建 PrepaidTransaction(consume, 3400→2600, related_bill_id)
PrepaidAccount->>DB: 更新 balance=2600
ConsumeFromPrepaid->>Bill: 4. recordPayment(800)
Bill->>DB: paid_amount=800, status=Paid
ConsumeFromPrepaid->>Listener: 5. 触发 CollectionOrderCompleted
Listener->>DB: 6. 建 Receipt("物业费 ¥800")
ConsumeFromPrepaid->>DB: 提交事务
Filament-->>业务: 成功
```
## 自动 job 流程(待实现)
```mermaid
sequenceDiagram
participant Scheduler
participant Job[PrepaidAutoDeductionJob]
participant Bills
participant Action[ConsumeFromPrepaidAccountAction]
Note over Scheduler: 2026-06-01 00:30
Scheduler->>Job: dispatch
Job->>Bills: SELECT 未付账单(community_id, resident_id 匹配预存款)
loop 每户
Job->>Bills: 按 due_at 升序查未付账单
loop 每张账单
alt 余额够
Job->>Action: handle(account, bill, bill.amount)
Note over Action: 同手动模式的链路
else 余额不够
Job->>Job: 跳过
end
end
end
Job-->>Scheduler: 完成 + 报告
```
详见 [[../prepaid/auto-deduction-design]] + [[../prepaid/consume-batch-auto-monthly]]。
## 与单张 / 批量收款的对比
| 维度 | [[collect-payment-single|单张]] | [[collect-payment-batch|批量]] | **预存款抵(本)** |
|---|---|---|---|
| 业务人员操作 | 单张点 CollectPayment | 多选 + BatchCollect | 手动 ConsumeAction / 自动 job |
| 触发位置 | ViewBill | BillsList | **ViewPrepaidAccount** / 定时任务 |
| 资金来源 | 现金 / 微信 / POS / 转账 | 同 | **预存款余额** |
| CollectionOrder.meta.fund_source | external | external | **prepaid** |
| 业户感知 | 主动付 | 主动付 | **被动收到通知** |
| 频率 | 高 | 中 | **未来最高(自动 job)** |
## 常见问题
> [!question] 业户预存款不够付,但快有了(等到本月底)怎么办?
> 业务上不应等。可:
>
> - 提示业户立即充值预存款([[../prepaid/deposit-additional-topup]])
> - 业户用其他方式付(现金 / 微信)
> - 部分抵(如果 Bill 支持部分付,见 [[exception-partial-payment]])
> [!question] 业户希望某些账单不要从预存款扣(例如不接受被自动扣电费)?
> 当前自动 job 待实现,实施时**可加白名单 / 黑名单机制**:
> - 业户可设置"只允许物业费自动扣"
> - 其他费用(水电气)留给业户主动付
>
> issue.md 未明确需求,看业务方反馈。
> [!question] 预存款抵扣的 Bill 后续要作废怎么办?
> 走 [[void-paid-bill|作废已付账单]] 流程,但**退款方向不同**:
> - 不退现金 / 微信
> - **退回预存款**(走 PrepaidAccount::deposit 反向充值)
> - 详见 [[void-paid-bill]]"已付作废 + 预存款退还"段
> [!question] 业户跨社区,A 社区有预存款 ¥5000,B 社区欠物业费 ¥800,能跨社区抵吗?
> **不能**。详见 [[../prepaid/exception-cross-community-consume]]"跨社区消费防御"段。
> [!question] 自动 job 跑的时候,业户同时去前台付现金,会重复收款吗?
> 看时序 + 锁机制:
> - 若 job 已锁 Bill(乐观锁)→ 前台收款失败(Bill 状态可能已变 Paid)
> - 若 job 没锁 → 可能并发问题(罕见,需排查具体实现)
>
> **预防**:业务人员收款前看 Bill 当前状态,Paid 就不收。
## 异常分支
- 业户预存款不够 → 业务推 [[../prepaid/deposit-additional-topup]] 或走现金
- 业户预存款冻结 → [[../prepaid/exception-refund-on-frozen|冻结无法抵]]
- 跨社区抵企图 → [[../prepaid/exception-cross-community-consume|系统拦截]]
- 抵后想撤 → [[void-paid-bill]] + 预存款回填
## 相关文档
- [[bill-vs-collection-order]]
- [[../prepaid/consume-via-bill-collection-type]]
- [[../prepaid/consume-monthly-property-bill]]
- [[../prepaid/auto-deduction-design]]
- [[../prepaid/consume-batch-auto-monthly]]
- [[collect-payment-single]]
- [[void-paid-bill]]

View File

@@ -0,0 +1,253 @@
---
title: prop-acc · billing · 场景 - 抄表自动生成计量账单
aliases:
- 计量账单生成
- 抄表后建账单
- create-meter-bill-auto
- 场景-计量账单自动生成
tags:
- 场景
- prop-acc
- 账单
- 创建
- 计量账单
audience:
- 业务人员
- 财务
- 抄表员
status: 已发布
sub_feature: billing
last_review: 2026-05-26
code_version: 2026-05-22
---
# 场景:抄表自动生成计量账单
抄表数据进入系统后(`MeterReading`),触发 `GenerateBillsFromMeterReadingsAction` 自动建账单。**核心机制在 meter 模块的[bill-generation-pipeline](../concepts/meter/bill-generation-pipeline.md)**,本场景描述 billing 模块的**对接视角**。
## 典型情境
> [!example] 真实情境
> 嘉禾花园 5 月底:
>
> - 1,200 张表的本月抄表全部完成
> - 1,160 张走集抄([[../meter/read-via-iot-remote-source]])
> - 40 张走手抄([[../meter/read-single-meter-manual]] + [[../meter/read-batch-via-excel-import]])
> - 系统在抄表完成后**自动触发**:`GenerateBillsFromMeterReadingsAction`(批量,1,200 张 reading → 1,200 张 Bill)
> - 王主管看到的:**1,200 张计量账单已就绪**,无需手工建
## 业务人员视角
### 自动模式(默认)
业务人员**几乎不操作**:
1. 抄表完成 → 抄表 Action / Importer 内部触发 `GenerateBillsFromMeterReadingsAction`
2. 系统逐张 reading 算金额(走 [[../meter/multiplier-and-tiered-pricing|倍率+阶梯+min/max]] 三层算法)
3. 建 Bill,sourceable=MeterReading,bill_type=Meter
4. reading.bill_id 回写
5. 报告"已生成 1,200 张计量账单"
### 手动模式(罕见)
某些情况下业务人员需要**手动触发**:
- 抄表数据补录后(集抄掉线 + 后续补抄)
- 部分 reading 之前生成 Bill 失败后重试
- 测试 / 验证
后台 → 账单 → 列表 → 顶部 "从 reading 生成账单" 按钮(若有 UI)→ 选 reading 范围 → 提交。
### 第 1 步:核对 reading 完成
抄表完成后(月底):
-`MetersNeedingReadingListWidget`:本月待抄数 = 0
-`MeterReadingsList`:本月 reading 数 = 1,200
### 第 2 步:确认账单生成
抄表 Action 自动触发后:
-`BillsList`,过滤 `bill_type=Meter` + 本月期次 → 应有 1,200 张
- 看每张账单的 `sourceable_id` 指向对应 reading
### 第 3 步:异常处理
| 异常 | 处置 |
|---|---|
| 某 reading 没生成 Bill(状态:`reading.bill_id=null`)| 排查原因(RatePlan 缺失?业户没绑定?)→ 修复 → 重新触发 |
| 某 Bill 金额异常(高出预期 10 倍)| 排查 multiplier / 阶梯配置,可能要 [[exception-readings-locked-after-bill|修正流程]] |
| 大面积失败(>10%)| 系统级问题,联系运维 |
## 业户视角
业户**月底/月初**收到水电气账单:
> 陈先生您好,您的 2026 年 5 月费用账单已生成:
>
> - 水费:¥54(用水 12 吨)
> - 电费:¥168(用电 280 度)
> - 燃气费:¥35(用气 15 立方)
>
> 合计 ¥257,请于 6 月 15 日前付清。
业户**不知道**这些账单是抄表自动生成的(后端透明)。他看到的就是"几张需要付的账单"。
## 系统流程(完整链路)
```mermaid
sequenceDiagram
participant 集抄/抄表员
participant MeterAction[抄表 Action / Importer]
participant GenBills[GenerateBillsFromMeterReadingsAction]
participant Calc[MeterBillCalculator]
participant Service[MeterBillGenerationService]
participant DB
Note over 集抄/抄表员: 本月抄表完成
集抄/抄表员->>MeterAction: 推送 / 录入 reading 数据
MeterAction->>DB: 建 MeterReading(bill_id=null)
MeterAction->>GenBills: 触发(刚建的 reading 列表)
loop 每个 reading
GenBills->>Service: generateBillForReading(reading)
Service->>Calc: calculate(consumption, ratePlan)
Calc-->>Service: amount
Service->>DB: 建 Bill(bill_type=Meter, sourceable=reading, status=Unpaid)
Service->>DB: 更新 reading.bill_id = bill.id
end
GenBills-->>MeterAction: 报告(N 张生成,M 张失败)
```
详细分层见 [[../meter/bill-generation-pipeline]]"Calculator + Service + Action"段。
## 与 meter 模块的关系
| 维度 | meter 模块 | **billing 模块(本场景)** |
|---|---|---|
| 主对象 | Meter + MeterReading | **Bill** |
| 责任 | 抄表 + 算用量 | **建账单 + 后续收款** |
| 触发 | 抄表 Action / Importer | 由 meter 模块触发 |
| 共享 | sourceable_type='MeterReading' | sourceable_id 指向 reading |
billing 模块对计量账单**没有**自己的创建 Action UI(由 meter 模块的 `GenerateBillsFromMeterReadingsAction` 完全负责)。billing 模块只**接收** sourceable 标记 + 提供后续 [[collect-payment-single|收款]] / [[exception-overdue-bills|催收]] 流程。
## 计量账单的特殊处理
### 1. sourceable 关联
```php
// Bill 字段
sourceable_type = 'App\Models\MeterReading'
sourceable_id = 12345
```
可双向反查:
```php
// 从 Bill 看 reading
$bill->sourceable; // → MeterReading 实例
// 从 reading 看 Bill
$reading->bill; // → Bill 实例(通过 reading.bill_id)
```
### 2. bill_type=Meter
| 字段 | 值 |
|---|---|
| `bill_type` | `Meter`(BillType 枚举) |
| 业务分类 | 水费 / 电费 / 燃气费(由 fee_type_id 决定具体)|
| 报表分类 | 进"水电气收入"科目 |
### 3. 期次
计量账单的 `billing_period_start / end` 通常对应**抄表期次**(例如:本期抄表是 5/26 - 上期 4/28 = 期次 4/29 - 5/26)。具体看实现。
## 异常分支
### 异常 1:reading 已生成 Bill,重复触发
```php
// GenerateBillsFromMeterReadingsAction
foreach ($readings as $reading) {
if ($reading->bill_id !== null) {
$skipped[] = ['reading' => $reading, 'reason' => 'already_billed'];
continue;
}
// ...
}
```
已有 Bill 的 reading 直接 skip。不会重复建。
### 异常 2:RatePlan 不存在
某 fee_type 没配 RatePlan:
```php
$ratePlan = $feeType->currentRatePlan;
if (! $ratePlan) {
throw new RatePlanNotFoundException();
}
```
Service 抛错,Action 捕获后 skip 该 reading + 报告。业务人员需先配 RatePlan 再重试。
### 异常 3:asset 没绑业户(`community_asset_users` 缺失)
```php
$resident = $this->findCurrentResident($asset);
if (! $resident) {
// 视设计:抛 / 建匿名 Bill(无 resident_id)/ 跳过
}
```
通常**跳过**(看具体实现),业务人员先绑业户再重试。
### 异常 4:已落账的 reading 数据错
走 [[../meter/exception-readings-locked-after-bill|修正流程]]:作废 Bill → 改 reading → 重生成 Bill。复杂,需运维介入。
## 常见问题
> [!question] 触发时机:抄表 Action 内部 / 单独定时?
> 当前实现:**抄表 Action 完成后立即触发**(同事务或紧接事务)。
>
> 优点:数据立即一致(reading + Bill 同步出现)。
> 缺点:抄表 Action 性能 = 抄表 + 建账单两段时间。
>
> 替代方案:定时任务(每月固定时间扫所有未生成账单的 reading)。当前未采用。
> [!question] 业户对计量账单金额有疑问怎么办?
> 业务人员可:
>
> - 给业户看抄表照片(`reading.photo_url`)
> - 给业户看 RatePlan(单价配置)
> - 重算给业户看(用 Calculator 算法)
>
> 若证实数据错 → 走 [[../meter/exception-readings-locked-after-bill|修正流程]]。
> [!question] 与周期账单(物业费)冲突吗?
> 不冲突。两者 BillType 不同(`Periodic` vs `Meter`),fee_type 不同(物业费 vs 水电气),sourceable 不同(null vs MeterReading)。各走各的生成路径,各自独立账单。
> [!question] 业户预存款抵这种账单的优先级?
> 看 [[../prepaid/consume-batch-auto-monthly|预存款自动抵扣 job]] 的策略:
>
> - 按 `due_at` 升序(早到期的先抵)
> - 计量账单与物业费的 due_at 通常相近(都是月底 + 宽限期)
> - 哪个早抵哪个
## 相关文档
- [[bill-types-and-sources]]
- [[bill-vs-collection-order]]
- [[../meter/bill-generation-pipeline]]
- [[../meter/multiplier-and-tiered-pricing]]
- [[../meter/exception-readings-locked-after-bill]]
- [[create-periodic-property-fee]]
- [[create-single-bill-manual]]
- [[collect-payment-batch]](业户可能水电气一起付)

View File

@@ -0,0 +1,230 @@
---
title: prop-acc · billing · 场景 - 月度物业费 300 户批量生成
aliases:
- 批量生成物业费
- 月度物业费生成
- create-periodic-property-fee
- GeneratePeriodicBillsAction 实战
- 场景-月度物业费生成
tags:
- 场景
- prop-acc
- 账单
- 创建
- 周期账单
audience:
- 业务人员
- 财务
status: 已发布
sub_feature: billing
last_review: 2026-05-26
code_version: 2026-05-22
---
# 场景:月度物业费 300 户批量生成
每月 1 日,物业为社区**所有业户**批量生成本月物业费账单。走 `GeneratePeriodicBillsAction`,默认 `SkipExisting` 策略。
## 典型情境
> [!example] 真实情境
> 5 月 1 日上午 9 点,嘉禾花园物业财务王主管打开后台:
>
> - 300 户业户(住宅 + 商铺)
> - 物业费 RatePlan:住宅 ¥3 / m²(平均面积 100 m² → ¥300),商铺 ¥8 / m²
> - 王主管:**1 次点击 + 1 个 Modal**,300 张账单全部建好
## 业务人员视角
### 第 1 步:打开 BillsList
后台 → 账单 → 列表 → 顶部 **"批量生成周期账单"** 按钮(`GeneratePeriodicBillsAction`)。
### 第 2 步:Modal 填参数
| 参数 | 填什么 |
|---|---|
| **期次(billing_period)** | 2026 年 5 月(start: 5/1, end: 5/31)|
| **费用类型(fee_type_id)** | 物业费(下拉选)|
| **社区范围(community_id)** | 嘉禾花园 |
| **业户范围** | 全社区业户(默认)|
| **合并策略(merge_strategy)** | `SkipExisting`(默认,详见 [[periodic-bill-generation]])|
| 备注 | 选填,如 "5 月例行物业费生成" |
| **到期日(due_at)** | 6 月 15 日(本月 + 15 天宽限期)|
### 第 3 步:提交
> [!warning] Policy 守护
> 按钮 `->authorize('create', Bill::class)`,需要 `bill.create` 权限。
系统执行(后台逻辑):
```mermaid
flowchart TD
A[扫描候选业户清单<br/>SELECT * FROM community_user_profiles<br/>WHERE community_id=? AND status=active] --> B[300 业户]
B --> C[每户:]
C --> D{该户该期次<br/>已有 Bill?}
D -->|是| E[Skip(默认策略)]
D -->|否| F[查 RatePlan 算金额<br/>3 * 100 = 300]
F --> G[建 Bill]
G --> H[活动日志]
E --> I[报告]
H --> I
```
### 第 4 步:看结果报告
```
2026 年 5 月物业费生成完成
✅ 已生成:298 张
⏭️ 已跳过:2 张(本月已存在,SkipExisting 策略)
❌ 失败:0 张
总金额:¥120,400
平均每户:¥401
```
### 第 5 步:抽样核对
业务人员抽 2-3 张账单看金额是否对(尤其 RatePlan 改过后)。
### 第 6 步:推送给业户
视物业策略:
- 自动推送(若集成微信公众号 / 小程序)
- 或单独触发"通知本月账单"动作
## 系统流程
```mermaid
sequenceDiagram
participant 王主管
participant Filament
participant Action[GeneratePeriodicBillsAction]
participant DB
participant Notify[通知服务]
王主管->>Filament: 批量生成周期账单 → Modal 填参数 → 提交
Filament->>Action: handle(community, feeType, period, strategy)
Action->>DB: 扫描候选业户清单
loop 每户
Action->>DB: 查该户该期次是否已有 Bill
alt 已有
Action->>Action: 按策略处理(默认 Skip)
else 无
Action->>DB: 查 RatePlan + 算金额
Action->>DB: 建 Bill (status=Unpaid, period, due_at)
end
end
Action->>Notify: 触发"批量账单已生成"通知(可选)
Action-->>Filament: 报告(生成 / 跳过 / 失败统计)
Filament-->>王主管: Modal 显示统计
```
## 业户视角
业户 5 月 1 日 / 2 日陆续收到推送:
> 张阿姨您好,您的 2026 年 5 月物业费 ¥300 已生成,请于 6 月 15 日前付清。
>
> 可选付款方式:
> - 微信小程序付
> - 到前台付现金 / 微信 / POS
> - 您预存款余额 ¥1,200 足够,可自动抵扣(若开通自动抵扣)
业户可选:
- 立即付([[collect-payment-single]] / [[collect-payment-batch]])
- 等到期日前付
- 不付 → 到期日后变逾期([[exception-overdue-bills]])
## 异常处理
### 部分业户失败(RatePlan 缺失)
| 异常 | 处置 |
|---|---|
| 某户没分配 RatePlan(新业户)| Action 跳过该户 + 报告标记 → 业务人员手动配 RatePlan 后单独生成 |
| 某户面积字段缺失 | 同上 |
| 系统级故障(DB / 内存)| Action 抛错 + 部分生成的 Bill 在事务内回滚 |
### Merge 策略案例(进阶)
如果业务人员想"同业户的物业费 + 电视费 + 网络费**合并到一张账单**":
- Modal 选 `merge_strategy = Merge`
- 系统找到已生成的物业费 Bill,把电视费 / 网络费**追加进同一张 Bill**(amount 累加)
- 业户看到的是一张合并账单 ¥XX(明细几项)
详见 [[periodic-bill-generation]]"策略 2:Merge"段。
### Replace 策略案例(罕见)
业务人员发现 5 月物业费 RatePlan 配错了 → 选 `merge_strategy = Replace`:
- 找到已有 Bill → **作废**(VoidBillAction)
- 按新 RatePlan 重新生成
> [!warning] Replace 风险
> Replace 对**已付的 Bill** 慎用,会让业户已付的钱"卡在 Void 状态",需配套退款流程。
## 常见问题
> [!question] 同一户能否生成两次同期次同费用类型?
> 业务上不应(违反一户一月一物业费规则)。系统层面通常有 unique 索引拦截。SkipExisting 策略也避免重复。
> [!question] 一次生成失败一半怎么办?
> Action 内部对单户失败容错(跳过失败户,继续其他),不会因一户失败回滚全部。报告会列出失败户,业务人员单独处理。
> [!question] 周期账单生成后想推迟到期日?
> Bill 创建后 `due_at` 字段**通常可编辑**(若 status=Unpaid)。批量改可能需要专门的"批量推迟到期日" Action(当前没,可手工逐张改或运维 tinker)。
> [!question] 业务人员忘了月初生成怎么办?
> 月中或月底再触发,系统正常生成(due_at 仍按原计划,可能立即逾期)。业户体验不好(账单晚到),需提前通知。
> [!question] 自动化(Scheduled Job)有吗?
> issue.md Q6 未明确(meter 和 prepaid 也都有"自动化待补"的待办)。当前**手动触发**。未来可加:
>
> - 月初 1 日 00:30 自动触发(类似 prepaid 自动抵扣)
> - 触发后立即触发预存款抵扣(无缝)
> - 业户次日早收到推送
> [!question] 业户拒绝接收推送怎么办?
> 系统层面不影响(账单仍在,可在小程序 / 后台查)。推送只是触达手段。业户拒收推送 → 物业改用电话 / 短信 / 上门通知。
> [!question] 不同费用类型(物业费 + 电视费)能一次性都生成吗?
> 当前 `GeneratePeriodicBillsAction` 一次只生成**一个费用类型**。批量需触发多次(物业费 1 次 + 电视费 1 次 + ...)。或者业务方反馈后增加"多费用类型批量"功能。
## 与计量账单生成的对比
| 维度 | 本场景(周期账单)| [[create-meter-bill-auto|计量账单]] |
|---|---|---|
| 触发 | 业务人员手动 | 抄表完成后自动 / 批量 |
| 金额 | 固定(RatePlan)| 浮动(用量计算)|
| 数量 | 每户每期 1 张 | 每抄表 1 张 |
| sourceable | null(或周期任务 ID)| MeterReading |
| 频率 | 月度 1 次 | 看抄表频率 |
## 异常分支
- 计量账单生成 → [[create-meter-bill-auto]]
- 临时手动建单 → [[create-single-bill-manual]]
- 业务人员要批量删错的 → [[bulk-delete-batch-mistake]]
- 业户收到账单要付款 → [[collect-payment-single]]
## 相关文档
- [[periodic-bill-generation]]
- [[bill-six-state-machine]]
- [[bill-types-and-sources]]
- [[create-meter-bill-auto]]
- [[collect-payment-single]]
- [[bulk-delete-batch-mistake]]
- [[../meter/bill-generation-pipeline]]
- [[../prepaid/auto-deduction-design]](账单生成下游消费)

View File

@@ -0,0 +1,214 @@
---
title: prop-acc · billing · 场景 - 手动建单(临时收费/调整账单)
aliases:
- 手动建账单
- 临时账单
- create-single-bill-manual
- 场景-手动建账单
tags:
- 场景
- prop-acc
- 账单
- 创建
audience:
- 业务人员
- 财务
status: 已发布
sub_feature: billing
last_review: 2026-05-26
code_version: 2026-05-22
---
# 场景:手动建单(临时收费/调整账单)
业务上**不属于周期任务、不属于抄表**的临时收费,通过 `CreateBill` 后台**手工建一张账单**。例如:维修费分摊、特别活动费、单次罚款、跨期补开账单。
## 典型情境
> [!example] 真实情境(一):公共维修费分摊
> 嘉禾花园 3 单元电梯坏了维修,总成本 ¥15,000。物业公司决定**全单元 30 户业户分摊**(每户 ¥500)。这种"临时性的、不属周期 / 抄表"的费用,业务人员逐户手工建账单(或选定业户清单后批量手工)。
> [!example] 真实情境(二):个别业户的特别罚款
> 张阿姨违反小区车位管理规定(占用应急车位 1 小时),物业罚款 ¥100。业务人员单独给张阿姨建一张账单。
> [!example] 真实情境(三):跨期补开账单
> 业务人员发现陈先生 4 月物业费没生成(系统月初批量时陈先生数据有问题),5 月才发现。需补开 4 月账单。
## 业务人员视角
### 第 1 步:打开 CreateBill
后台 → 账单 → 列表 → 顶部 **"新建"** 按钮 → 进 `CreateBill` 页面。
### 第 2 步:填表单(`BillForm`)
| 字段 | 填什么 |
|---|---|
| **社区(community_id)** | 嘉禾花园(若多社区可选)|
| **业户(resident_id)** | 张阿姨(下拉选)|
| **房屋(asset_id)** | 12-3-501(自动带入或手选)|
| **费用类型(fee_type_id)** | 选合适的 FeeType(物业费 / 杂费 / 罚款 / ...) |
| **账单类型(bill_type)** | 通常选 `OneTime``Adjustment`(看 BillType 枚举)|
| **金额(amount)** | ¥500(电梯维修分摊)|
| **期次** | 选填(临时账单可不填,或填发生月)|
| **到期日(due_at)** | 6 月 15 日 |
| **备注(memo)** | **强烈推荐填**,说明缘由,如 "3 单元电梯维修分摊(2026-05),总成本 ¥15,000 ÷ 30 户" |
> [!warning] Policy 守护
> `CreateBill` 需要 `bill.create` 权限。
### 第 3 步:提交
系统:
1. 校验字段(business / asset / fee_type 存在)
2. 建 Bill(`status=Unpaid`,`sourceable_type=null` / 或自定义)
3. 写 activitylog(单条 Bill 创建,subject=Bill)
4. 跳到 `ViewBill` 页面
### 第 4 步:通知业户
通常需要业务人员**单独通知**(临时账单不在月度自动推送范围内):
- 微信 / 短信:"张阿姨,您本月有一笔电梯维修分摊费 ¥500,请于 6 月 15 日前付清"
- 附备注说明
### 第 5 步:批量手工建(电梯维修分摊场景)
如果是分摊给 30 户业户,逐户建效率低。当前没有"批量手动建" UI,只能:
- 逐户走 `CreateBill`(30 次)
- 或运维 tinker 脚本(SQL 批量 INSERT)
- 或要求业务方加"批量手工建"功能
未来可加:`BulkCreateBillsAction`(给定业户清单 + 模板 → 批量建)。
## 系统流程
```mermaid
sequenceDiagram
participant 业务[业务人员]
participant Filament
participant CreateBill
participant Activity[activitylog]
participant DB
业务->>Filament: ListBills → 新建 → CreateBill
Filament->>CreateBill: 渲染 form
业务->>CreateBill: 填业户 / 金额 / 备注 / 提交
CreateBill->>DB: 校验 fields
CreateBill->>DB: 建 Bill(status=Unpaid, bill_type=OneTime, sourceable=null)
CreateBill->>Activity: log(event=created, subject=Bill)
CreateBill-->>Filament: 跳转 ViewBill
Filament-->>业务: 显示新账单
```
## 业户视角
业户收到通知:
> 张阿姨您好,您本月有一笔账单:
> - 电梯维修分摊费:¥500
> - 备注:3 单元电梯维修分摊
> - 到期日:6 月 15 日
>
> 请通过微信小程序 / 前台 / 预存款 任选方式付款。
业户:
- 看明白 → 付([[collect-payment-single]])
- 不接受 → 投诉 / 走纠纷流程(可能 [[suspend-bill]] 挂起)
## 三种典型情境的不同处理
### 情境 1:电梯维修分摊
| 字段 | 值 |
|---|---|
| `bill_type` | `OneTime` |
| `fee_type_id` | "维修分摊费"(若有此 FeeType,否则用通用"杂费")|
| `amount` | ¥500 |
| 备注 | 详细说明(分摊依据 + 总成本)|
业务上还要**备齐资料**:维修发票、户主同意决议(若有业主大会决议)、分摊依据。这些不在系统(物业备查)。
### 情境 2:违规罚款
| 字段 | 值 |
|---|---|
| `bill_type` | `OneTime` |
| `fee_type_id` | "罚款"(若有此 FeeType)|
| `amount` | ¥100 |
| 备注 | 违规事由 + 处罚依据(管理规定第 X 条) |
业务上要 **业户书面确认**(违规事实),否则业户可能投诉 / 拒付。
### 情境 3:跨期补开
| 字段 | 值 |
|---|---|
| `bill_type` | `Periodic`(若是月物业费的补开)|
| `period` | **填实际期次**(4 月)|
| `amount` | 同正常月物业费 |
| 备注 | "4 月物业费补开,原因 XXX" |
业务人员**需要**:
- 确认陈先生 4 月确实欠物业费(没有遗漏)
- 与陈先生沟通(突然补开他可能困惑)
## 与周期账单 / 计量账单的对比
| 维度 | 周期账单([[create-periodic-property-fee]]) | 计量账单([[create-meter-bill-auto]]) | **手动账单(本场景)** |
|---|---|---|---|
| 触发 | 业务人员批量生成 | 抄表完成自动 | **业务人员单笔手工** |
| 数量 | 一次 100-1000 张 | 一次 N 张(对应 reading 数)| **一次 1 张** |
| 金额来源 | RatePlan + 房屋参数 | RatePlan + 用量计算 | **业务人员手填** |
| 业务场景 | 月度固定收费 | 抄表后变动收费 | **临时收费 / 调整** |
| sourceable | null / 周期任务 | MeterReading | **null** |
| 频率 | 每月 1 次 | 每月 1 次 | **不定时** |
## 常见问题
> [!question] 手动建单和周期账单的 fee_type 重复怎么办?
> 例如本月已经有"5 月物业费"账单,业务人员又手工建一张同 fee_type 同期次的 → 数据库 unique 约束可能拦截(若有),否则会有两张并存。
>
> **预防**:手动建单前确认是否已有同类账单。
> [!question] 手动建单金额没限制吗?
> 系统层面无限制。**业务上**应有审核流程(高金额 > X 元 应主管审批,但当前系统不强制此审批流)。
> [!question] 跨期补开的账单影响月度报表吗?
> 看报表的查询:
>
> - 按 `created_at`(账单创建时间)→ 影响 5 月报表(实际是 5 月创建)
> - 按 `billing_period_start`(账单期次)→ 影响 4 月报表
>
> 不同报表用不同维度。月度对账通常按期次。
> [!question] 手动建单后业户拒付?
> 走标准流程:
>
> - 沟通 → 协商
> - 不接受 → [[suspend-bill|挂起]] 等争议解决
> - 调解失败 → 物业法务介入(走线下,系统不参与司法)
> [!question] 业务人员误建一张账单怎么撤?
> 若立即发现:[[delete-bill-unpaid|物理删除]](Unpaid 无付款)。
> 若已发出:[[void-paid-bill|作废]](留状态留审计)。
## 异常分支
- 误建 → 立即删除 [[delete-bill-unpaid]]
- 业户拒付 → [[suspend-bill]]
- 已发现要修正金额 → 作废 + 新建 [[void-paid-bill]]
- 批量手工建 → 待业务方提需求 + 加 BulkCreateBillsAction
## 相关文档
- [[bill-types-and-sources]]
- [[bill-six-state-machine]]
- [[create-periodic-property-fee]]
- [[create-meter-bill-auto]]
- [[delete-bill-unpaid]]
- [[suspend-bill]]

View File

@@ -0,0 +1,223 @@
---
title: prop-acc · billing · 场景 - 物理删除未付账单(误建立刻删)
aliases:
- 物理删除账单
- 删除未付账单
- delete-bill-unpaid
- 场景-删除未付账单
tags:
- 场景
- prop-acc
- 账单
- 删除
audience:
- 业务人员
status: 已发布
sub_feature: billing
last_review: 2026-05-26
code_version: 2026-05-22
---
# 场景:物理删除未付账单(误建立刻删)
业务人员**误建**了账单(选错业户 / 写错金额 / 误触发批量生成),**立即发现**且**业户还没付**任何钱 → 走**物理删除**。`canBeDeleted()` 守护:Unpaid + 无付款关联。
## 典型情境
> [!example] 真实情境
> 王主管月初手工建账单"5 月物业费 ¥800",**填错业户 ID**(应该是张阿姨,选成了陈先生)。提交后立刻发现错误。
>
> - 账单状态:Unpaid
> - 业户没付过任何钱
> - **可走物理删除**(走 [[delete-vs-void-dual-track|双轨制]] 的 delete 路径)
> - 再重新建一张正确的(选张阿姨)
## 业务人员视角
### 第 1 步:发现错误
| 发现时机 | 处置 |
|---|---|
| **立即**(几秒钟内)| 物理删(本场景)|
| **几小时**(可能业户已收到推送)| 仍可删(若 Unpaid + 无付款)|
| **几天**(可能业户已付)| **不可删**,走 [[void-paid-bill|作废]] |
### 第 2 步:打开账单
后台 → 账单 → 找到误建账单 → 进 `EditBill`(或 `ViewBill`)。
### 第 3 步:点击 `DeleteAction`(标签"删除")
> [!warning] 按钮可见性
> 守护:`canBeDeleted()` = Unpaid + 无付款 + `->authorize('delete')`(`bill.delete` 权限)。
>
> 任何其他状态(Partial / Paid / Suspended / Void)或有任何付款关联 → 按钮**灰化**。
### 第 4 步:Modal 确认
```
确认删除账单 #B-202605-501-001?
⚠️ 此账单状态为 Unpaid 且无付款关联,可物理删除。
删除后无法恢复(但 activitylog 会保留记录)。
[取消] [确认删除]
```
### 第 5 步:提交
系统:
1. 再次校验 `canBeDeleted()`(防 UI 缓存过期)
2. 物理删 Bill(从数据库消失)
3. 写 activitylog(event=deleted,记 bill_no / amount / 操作员 / 时间)
> [!info] activitylog 留什么
> 即使 Bill 物理删了,activitylog 表还有完整记录:
>
> ```sql
> SELECT * FROM activity_log
> WHERE event = 'deleted'
> AND properties->>'$.bill_no' = 'B-202605-501-001';
> ```
>
> 可看到"谁在什么时候删了什么账单"。详见 [[smart-bulk-delete-design]]"activitylog 设计"。
### 第 6 步:重新建正确账单(若需要)
走 [[create-single-bill-manual|手动建单]] 流程,选正确业户。
## 系统流程
```mermaid
sequenceDiagram
participant 业务
participant Filament
participant Action[DeleteAction]
participant Bill
participant Activity
participant DB
业务->>Filament: EditBill → DeleteAction
Filament->>Action: handle(bill)
Action->>Bill: canBeDeleted()? Unpaid + 无付款 = true
Action->>Activity: log(event=deleted, properties={bill_no, amount, ...})
Action->>DB: 物理删 Bill
Action-->>Filament: 跳回 ListBills
```
## 业户视角
业户**无感**:
- 误建的账单(尤其几秒内删的)业户**根本不知道**它存在过
- 即使业户已经收到推送,删除后再次刷新看不到了
- 物业可选择给业户发"前述账单作废,系统误建"(可选,看业务策略)
**最理想场景**:业务人员发现 → 立即删 → 业户从未感知。
## `canBeDeleted` 详解
`Bill::canBeDeleted()`:
```php
public function canBeDeleted(): bool
{
return $this->status === BillStatus::Unpaid
&& ! $this->hasAnyPayment();
}
```
`hasAnyPayment()`:
```php
public function hasAnyPayment(): bool
{
return $this->collectionOrderBills()->exists()
|| $this->prepaidTransactionsRelatedToBill()->exists();
}
```
两条都为真(Unpaid + 无付款关联) → 可删。
> [!info] 为什么 Partial 不能删?
> Partial 意味着业户已经付了一部分 → 有 CollectionOrderBill 关联 → `hasAnyPayment=true` → 拒绝物理删。
>
> 业户付的钱不能"凭空消失"。Partial 状态要么继续收款 → Paid;要么走 [[void-paid-bill|作废 + 退款]] 流程。
## 与作废的对比
| 维度 | **删除(本场景)** | [[void-paid-bill|作废]] |
|---|---|---|
| 适用 | Unpaid + 无付款 | 非 Paid 非 Void(基本是 Partial / Suspended) |
| 数据库 | **从数据库消失** | 留状态 = Void |
| activitylog | 留(event=deleted)| 留(event=voided)|
| 业户感知 | 通常无感(未付过)| 有(看到状态 Void) |
| 可恢复 | ❌ 不可(数据真没了)| ❌ 不可(Void 终态)|
| 适用场景 | 误建立刻删 | 任何已付 / 有付款 / 业户已知的 |
详见 [[delete-vs-void-dual-track]] 双轨制设计哲学。
## 批量删除(误建一批)
如果不是单张误建,而是**误触发批量生成**(例如点了一次"生成 5 月物业费"后忘了又点了一次):
→ 走 [[bulk-delete-batch-mistake|智能批量删除]],走 `BulkDeleteBillsAction` 的预检查 + 三档分类。
## 常见问题
> [!question] 删了后悔了能恢复吗?
> 不能(数据真的没了)。**只能重新建**(走 [[create-single-bill-manual]] 或 [[create-periodic-property-fee]])。
>
> 但**所有信息可从 activitylog 还原**(原 bill_no / amount / 业户 等),重建相对简单。
> [!question] 业务人员误删大量账单怎么办?
> 单删一次一张,影响有限。**批删**才容易误删大量(详见 [[smart-bulk-delete-design]] 的预检查防护)。
>
> 单张误删的预防:
> - Modal 确认(默认有)
> - 思考"是否真要删"再点
> [!question] 删除有审批吗?
> 当前**无审批流**(单签批操作)。但**权限层有控制**:`bill.delete` 是普通业务人员权限;`bill.bulkDelete` 是高敏独立权限(详见 [[smart-bulk-delete-design]])。
> [!question] activitylog 表会爆掉吗?
> 长期看会(每次单删 + 批删 + 作废 + 收款都加几条)。需归档策略(详见 [[smart-bulk-delete-design]]"常见问题"段)。
> [!question] 删除影响 reading.bill_id 吗(若是计量账单)?
> 看 cascade 设计:
> - 若 reading 的 `bill_id` 是 FK + cascade SET NULL → 自动 nullify
> - 若没 cascade → reading.bill_id 仍指向已删 Bill(孤儿 FK,需修复)
>
> 当前实施看代码。**预防**:计量账单不轻易物理删,走作废更稳妥。
> [!question] 已删账单的 activitylog 怎么用?
> ```sql
> -- 某员工某天的全部 delete
> SELECT
> id, causer_id,
> properties->>'$.bill_no' AS bill_no,
> properties->>'$.amount' AS amount,
> created_at
> FROM activity_log
> WHERE event = 'deleted'
> AND causer_id = ?
> AND created_at BETWEEN ? AND ?;
> ```
## 异常分支
- 误删后想重建 → [[create-single-bill-manual]]
- 误删一批 → 走批删的反向(无,只能 1 张张重建)
- 不能删(有付款)→ [[void-paid-bill]]
- 批量误建 → [[bulk-delete-batch-mistake]]
## 相关文档
- [[delete-vs-void-dual-track]]
- [[smart-bulk-delete-design]]
- [[bill-six-state-machine]]
- [[void-paid-bill]]
- [[bulk-delete-batch-mistake]]
- [[audit-activitylog-trace]]
- [[create-single-bill-manual]]

View File

@@ -0,0 +1,242 @@
---
title: prop-acc · billing · 场景 - 逾期账单清单 + 催收流程
aliases:
- 逾期账单
- 催收
- OverdueBillsListWidget
- exception-overdue-bills
- 场景-逾期账单催收
tags:
- 场景
- prop-acc
- 账单
- 催收
audience:
- 业务人员
- 财务
- 业户
status: 已发布
sub_feature: billing
last_review: 2026-05-26
code_version: 2026-05-22
---
# 场景:逾期账单清单 + 催收流程
业户**到期未付**的账单进入逾期清单(`OverdueBillsListWidget`),业务人员**分级催收**:温和提醒 → 严肃催告 → 法律手段。是物业**应收账款管理**的核心。
## 典型情境
> [!example] 真实情境
> 5 月 16 日(物业费 due_at = 5/15 后第 1 天),王主管打开 dashboard:
>
> - `OverdueBillsListWidget` 显示**当前逾期 25 户**(本月物业费 + 部分上月遗留)
> - 合计欠款 ¥18,500
> - 平均逾期天数 1-30 天不等
## 业务人员视角
### 第 1 步:打开 Dashboard / Widget
后台 → Dashboard → `OverdueBillsListWidget`(可能在主 Dashboard 或财务 Dashboard)。
Widget 显示:
| 列 | 内容 |
|---|---|
| 业户 / 房号 | 12-3-501 张阿姨 |
| 账单号 | B-202605-501-001 |
| 费用类型 | 物业费 / 水费 / ... |
| 账单金额 | ¥800 |
| 已付金额 | ¥0(Unpaid)/ ¥300(Partial)|
| 剩余应付 | ¥800 / ¥500 |
| 到期日 | 5/15 |
| **逾期天数** | 1 天 / 7 天 / 30 天 / ... |
| 状态 | Unpaid / Partial |
排序通常**按逾期天数降序**(最严重的先)。
### 第 2 步:分级催收
```mermaid
flowchart TD
A[逾期账单清单] --> B{逾期天数}
B -->|1-7 天<br/>🟢 温和| C[小程序 / 微信 / 短信<br/>友好提醒]
B -->|8-30 天<br/>🟡 严肃| D[电话联系 + 上门拜访<br/>面谈了解原因]
B -->|31-90 天<br/>🔴 严重| E[正式催告函<br/>+ 加收滞纳金<br/>+ 部分服务受限]
B -->|>90 天<br/>⚫ 法律| F[律师函 / 司法起诉<br/>+ 业户失信记录]
C --> G{业户响应}
D --> G
E --> G
F --> G
G -->|付款| H[走 collect-payment-single]
G -->|协商| I[Suspend Bill + 等协议]
G -->|无响应| J[升级催收]
G -->|拒付不可调和| K[法律 + 长期 Suspend]
```
### 第 3 步:具体催收动作
#### 🟢 温和(1-7 天)
- 自动 推送 / 短信(由系统定时任务,若实现)
- "张阿姨您好,您的 5 月物业费 ¥800 已逾期,请尽快付清"
#### 🟡 严肃(8-30 天)
- 物业管家电话联系
- 上门拜访(若联系不上)
- 了解逾期原因 + 协商付款时间表
- 若业户有困难 → [[suspend-bill|挂起]] + 协议分期
#### 🔴 严重(31-90 天)
- 物业法务部门介入
- 出具正式催告函(纸质 + 电子)
- 可加滞纳金(看物业合同 / 业主大会决议)
- 部分服务限制(如停水电、限制电梯使用,具体看物业政策 + 法律允许度)
#### ⚫ 法律(>90 天)
- 委托律师事务所
- 律师函
- 司法起诉(物业 vs 业户)
- 法院判决 → 强制执行
### 第 4 步:更新跟进记录
业务人员每次催收**在系统记录**(若有催收日志功能):
- 催收时间 / 方式 / 业户反馈
- 下次跟进时间
(当前实施可能在 Bill.memo 或单独表,看代码。)
## 业户视角
### 您可能收到的
#### 温和提醒
> 张阿姨您好,您的 2026 年 5 月物业费 ¥800 已于 5/15 到期,请尽快通过以下方式付清:
> - 微信小程序
> - 到前台
> - 预存款充值后自动扣
>
> 您预存款余额仅 ¥200,不够付。
#### 严肃催告
> 张阿姨,您 5 月物业费 ¥800 已逾期 15 天,请于本周内付清。如有困难请联系物业 XXX 协商。
#### 严重催告
> [正式催告函]
> 您 2026 年 5 月物业费 ¥800 已严重逾期 60 天,根据物业管理合同第 X 条,我司将:
> 1. 加收滞纳金 ¥XX
> 2. 限制您的部分物业服务
> 3. 若 X 月 X 日前仍未付清,我司将启动法律程序追讨
>
> 请尽快处理。
### 您要做什么
- 立即付款(若能力允许)
- 与物业协商(若有困难)
- **不要** 不闻不问(代价升级)
## 滞纳金 / 罚息
> [!info] 滞纳金的合规边界
> 物业能否收滞纳金看:
> - 物业管理合同条款(常见日利率 0.05% 或类似)
> - 业主大会决议
> - 国家 / 地方法规(不能高于法定上限)
>
> 系统层面**可能不直接管滞纳金**(看实现)。若收滞纳金 → 通常**另开账单**(走 [[create-single-bill-manual]])"滞纳金:¥X(5 月物业费逾期 X 天)"。
## 部分服务限制的合规
物业**限制服务**(停水电 / 限电梯)需谨慎:
| 限制 | 合规性 |
|---|---|
| 限制小程序业户自助功能(查询 / 报修) | 合规(物业自主决定)|
| 拒绝业户业务申请(开停车证等)| 合规 |
| 停水(若物业有控制权)| 多数地区**不合规**(基本生活用水有法律保护)|
| 停电 | 同上,且通常电网在国家电网,物业无权停 |
| 限制电梯使用 | 部分地区合规,部分不合规 |
| 公布逾期业户名单 | 部分合规(看公开范围 + 业主大会决议)|
**严格合规咨询当地法律**。系统**不强制**这些限制,由物业流程决定。
## 长期逾期的处理
| 时长 | 处置 |
|---|---|
| 0-30 天 | 常规催收 |
| 30-90 天 | 升级催收 + 必要时 [[suspend-bill|挂起]] |
| 90+ 天 | 法律程序 + 长期挂起 |
| > 2 年 | 评估**作废 / 走司法判决执行** |
| > 5 年(诉讼时效)| 法律时效问题,通常作废 |
## 与 prepaid 模块的关系
如果业户**预存款够付** 但因故没自动抵扣(job 没跑 / 业户冻结 / 跨社区) → 账单逾期。**典型案例**:[[../prepaid/audit-low-balance-and-overdue]] 场景中提到。
业务人员看到逾期账单时,应先查业户预存款余额:
- 足够付:**手动触发** [[collect-via-prepaid-auto]] 抵扣(快速解决)
- 不够付:走标准催收
## 自动催收 job(待补)
> [!info] 自动化机会
> 当前催收**靠人工**(看 widget + 一一联系)。可加自动化:
>
> - **定时任务**:每天扫逾期账单 → 按逾期天数分级 → 自动推送 / 短信
> - **滞纳金自动计算**:每日跑 → 给逾期账单加滞纳金
> - **批量催告函生成**:选中 N 个业户 → 一次生成所有催告函(PDF / 邮件)
>
> 当前 issue.md 未明确实施。
## 常见问题
> [!question] Widget 上的逾期天数怎么算?
> `(NOW() - bill.due_at).days`(SQL 层算)。若 due_at 还没到 → 不在 widget。
> [!question] Suspended 状态的账单算逾期吗?
> **不算**。Widget 通常过滤 `status IN (Unpaid, Partial)`,Suspended 不在。
> [!question] 业户付了一部分但仍逾期算不算?
> Partial 状态 + 仍欠款 + 过 due_at = 算逾期。Widget 显示"剩余应付"。
> [!question] 滞纳金怎么记账?
> 单独建账单(`bill_type=OneTime`,`fee_type=滞纳金`)。详见 [[create-single-bill-manual]] "情境 2:违规罚款" 模式(滞纳金类似)。
> [!question] 业户长期失联,催收记录怎么留?
> 物业内部催收日志(纸质 / Excel)。系统层面无强制要求(若 issue.md 未实现催收日志功能)。法律纠纷时这些日志是关键证据。
> [!question] 业主大会决议某些业户免缴怎么办?
> 业务上:走 [[void-paid-bill|作废]] 该账单(附决议号)。系统层面不区分"免缴"vs"其他作废"(都是 Void 状态)。
## 异常分支
- 业户响应付款 → [[collect-payment-single]]
- 业户协商分期 → [[exception-partial-payment]]
- 业户失联 → [[suspend-bill]]
- 法律手段 → 走线下,系统记录 + [[void-paid-bill]]
- 业户预存款够付 → [[collect-via-prepaid-auto]] 手动触发
## 相关文档
- [[bill-six-state-machine]]
- [[exception-partial-payment]]
- [[suspend-bill]]
- [[void-paid-bill]]
- [[collect-payment-single]]
- [[collect-via-prepaid-auto]]
- [[../prepaid/audit-low-balance-and-overdue]](类似的预警审计场景)

View File

@@ -0,0 +1,279 @@
---
title: prop-acc · billing · 场景 - 部分付状态处理(Partial)
aliases:
- 部分付
- Partial 状态
- exception-partial-payment
- 场景-部分付账单
tags:
- 场景
- prop-acc
- 账单
- 部分付
audience:
- 业务人员
- 财务
status: 已发布
sub_feature: billing
last_review: 2026-05-26
code_version: 2026-05-22
---
# 场景:部分付状态处理(Partial)
业户**只付了一部分**账单金额,系统状态从 Unpaid 进入 **Partial**(部分付)。后续业务人员需要跟进:**继续催收剩余款** / 提醒业户 / 或视情况调整账单。
## 典型情境
> [!example] 真实情境
> 王先生(15-7-203)5 月物业费 ¥800,他到前台说"现在只能付 ¥300,剩下 ¥500 月底再补"。业务人员小李录入收款 ¥300:
>
> - Bill.paid_amount = 300
> - Bill.status:Unpaid → **Partial**
> - 建 CollectionOrderBill(allocated=300)+ CollectionOrder(+300)+ Receipt(¥300)
>
> 后续:王先生 5 月底再付 ¥500 → Bill.paid_amount = 800 → Bill.status: Partial → Paid。
## 业户视角
### 部分付场景
| 业户原因 | 频率 |
|---|---|
| 现金不够 | 中 |
| 短期资金紧张 | 中 |
| 对账单部分有异议(剩余部分协商中)| 低 |
| 拆分多次付方便记账(罕见)| 低 |
| 业户搬走前部分清账 | 低 |
### 您会感受到什么
- 收据上显示**实付金额**(¥300,不是账单总额 ¥800)
- 推送 / 小程序:"已付 ¥300,剩余 ¥500 待付"
- 余额显示 Partial 状态(账单未完全清)
- 月底前 reminder(若有催收机制)
### 您要做什么
- 在到期日前补齐剩余款
- 走 [[collect-payment-single|继续收款]](第 2 笔 ¥500)
- 若有困难 → 与物业沟通(协商分期 / 减免 / 挂起)
## 业务人员视角
### 部分付的触发
走 [[collect-payment-single|单张收款]] Modal,**手动改"收款金额"小于全额**:
```
收款金额:300(改成 300,不是默认的 800)
支付方式:现金
```
提交后:
- Bill.paid_amount: 0 → 300
- Bill.status: Unpaid → **Partial**
### Partial 状态的能力
| 操作 | Partial 状态 |
|---|---|
| `CollectPaymentAction`(继续收款)| ✅(canBePaid=true)|
| `SuspendBillAction`(挂起)| ✅ |
| `VoidBillAction`(作废)| ✅(canBeVoided=true,但需配套退款已付部分)|
| `DeleteAction`(物理删)| ❌(canBeDeleted 要求 Unpaid + 无付款)|
| `SplitBillAction`(拆账单)| 可能不允许(已付款拆分复杂,见 [[split-bill]])|
### 第 2 笔收款
业户来补付时:
1. 找到 Partial 账单
2.`CollectPaymentAction`(状态守护 canBePaid=true,Partial 也允许)
3. 收款金额 = ¥500(剩余应付,Modal 应默认带入 remaining)
4. 提交 → Bill.paid_amount = 800,status: Partial → Paid
### 监控 Partial 账单
业务人员**定期查看** Partial 状态账单:
```sql
SELECT bill_no, resident_id, amount, paid_amount, (amount - paid_amount) AS remaining
FROM acc_bills
WHERE status = 'partial'
AND community_id = ?
ORDER BY due_at ASC;
```
或后台 → 账单 → 过滤"状态=Partial"列表。
业务人员对 Partial 业户:
- 临近 due_at:发提醒
- 已逾期:走 [[exception-overdue-bills|催收]]
- 业户失联:走 [[suspend-bill|挂起]]
## 系统流程
```mermaid
sequenceDiagram
participant 业户
participant 业务
participant Filament
participant Action[CollectPaymentAction]
participant DB
业户->>业务: 我只付 300(账单 800)
业务->>Filament: ViewBill → CollectPayment(modal,改金额=300)
Filament->>Action: handle(bill, 300, channel)
Action->>DB: 1. 建 CO(+300)+ COBill(allocated=300)
Action->>DB: 2. Bill.paid_amount = 300
Action->>DB: 3. Bill.status: Unpaid → Partial(因 300 < 800)
Note over Filament: 几周后业户补付
业户->>业务: 现在付剩下 500
业务->>Filament: ViewBill(Partial)→ CollectPayment(默认带 500)
Filament->>Action: handle(bill, 500, channel)
Action->>DB: 4. 建 CO(+500)+ COBill(allocated=500)
Action->>DB: 5. Bill.paid_amount = 800
Action->>DB: 6. Bill.status: Partial → Paid
```
## 数据示例(完整流水)
业户付 ¥300 后:
### Bill 表
```
id: 12345
amount: 800
paid_amount: 300
status: Partial
```
### CollectionOrderBill(1 条)
```
bill_id: 12345
collection_order_id: 67890
allocated_amount: 300
```
### CollectionOrder(1 条)
```
id: 67890
actual_amount: +300
payment_channel: 现金
status: Completed
```
### Receipt(1 条)
```
amount: +300
line_items: [{ 物业费(5月)分付, 300 }]
```
业户再付 ¥500 后:
### Bill 表(更新)
```
paid_amount: 800
status: Paid ← 收齐
```
### CollectionOrderBill(2 条总)
```
1: bill_id=12345, collection_order_id=67890, allocated=300
2: bill_id=12345, collection_order_id=67891, allocated=500
```
业户两次付,流水**完整保留**。
## 部分付的几种异常
### 异常 1:业户长期不补付
业户付了 ¥300 后**消失**,剩余 ¥500 长期不付:
| 处置 | 路径 |
|---|---|
| 临时不催(可能有困难)| 不动,等业户主动 |
| 走逾期催收 | [[exception-overdue-bills]] |
| 业户失联 | [[suspend-bill|挂起]] |
| 协议放弃剩余 | [[void-paid-bill|作废]](但已付 ¥300 不退,业务上协商决定)|
### 异常 2:业户付错金额
业户原本想付 ¥300 给物业费,误付到了水费:
- 物业费 Bill 仍 Unpaid
- 水费 Bill 多付了(超过 ¥54)
处置:看实施细节,可能需手工调整 CollectionOrderBill(危险,破坏审计)。**预防**:Modal 提交前确认 Bill ID。
### 异常 3:业务人员录错 paid_amount
业务人员收 ¥500 现金,误录入 ¥300:
- Bill.paid_amount = 300(应该 500)
- 物业账面少记 ¥200 → 账上 vs 银行不一致
处置:走 [[void-paid-bill|作废]] 原 CollectionOrder + 重录,或运维 tinker 修字段。
## Partial 的边界
> [!info] 严格部分付 vs 宽松部分付
>
> 严格实现(本系统倾向):
> - Modal `amount` 校验 `<= remaining`
> - 不允许超付(避免凭空多记 paid_amount)
>
> 宽松实现:
> - 允许超付 → 多余部分转入业户预存款 / 留作"未分配收款"
>
> 当前实现看 `CollectPaymentAction` 代码。
## 常见问题
> [!question] 多次部分付的 Receipt 是合并还是分开?
> **分开**(每次收款一张 Receipt)。业户拿到的是两张:
>
> - Receipt 1:¥300(5/15 现金付)
> - Receipt 2:¥500(5/30 现金付)
>
> 不合并(合并破坏每次收款的独立凭证)。
> [!question] Partial 账单挂起后能恢复继续收吗?
> 可以。走 [[suspend-bill|挂起]] → [[resume-bill|恢复]] → 状态智能判定为 Partial(因有付款)→ 继续收款。
> [!question] 部分付能预存款抵吗?
> 看实施。理论上业户预存款余额 ¥200 + 账单剩余 ¥500 = 预存款抵 ¥200 → 仍 Partial(还差 ¥300)。需要部分抵扣功能(见 [[../prepaid/consume-multiple-bills-priority]] 部分抵讨论)。当前不一定支持。
> [!question] 业户付的钱比账单总额还多(超付)?
> 见上方"严格 vs 宽松"段。严格实现拒绝;宽松实现允许多付转入预存款。
> [!question] activitylog 怎么追踪 Partial 状态历史?
> 每次 CollectPayment 都有 log(event=collected),可看到状态从 Unpaid → Partial 的转变。
## 异常分支
- 单张全付 → [[collect-payment-single]](标准路径)
- 批量付(多张一次)→ [[collect-payment-batch]]
- 长期 Partial 无解 → [[suspend-bill]] / [[void-paid-bill]]
- 部分付逾期催收 → [[exception-overdue-bills]]
## 相关文档
- [[bill-six-state-machine]]
- [[bill-vs-collection-order]]
- [[collect-payment-single]]
- [[suspend-bill]]
- [[exception-overdue-bills]]
- [[void-paid-bill]]

View File

@@ -0,0 +1,253 @@
---
title: prop-acc · billing · 场景 - 恢复挂起的账单
aliases:
- 恢复账单
- ResumeBillAction
- 解除挂起
- resume-bill
- 场景-恢复挂起账单
tags:
- 场景
- prop-acc
- 账单
- 调整
audience:
- 业务人员
- 财务
status: 已发布
sub_feature: billing
last_review: 2026-05-26
code_version: 2026-05-22
---
# 场景:恢复挂起的账单
[[suspend-bill|挂起]] 状态的账单,在**纠纷解决 / 业户回来**后恢复到 Unpaid / Partial,后续可正常收款。`ResumeBillAction` 对称于 SuspendBillAction。
## 典型情境
> [!example] 真实情境(一):业户回来了
> 王先生(15-7-203)出国 3 个月后回来,到物业前台:"我去美国出差 3 个月,没顾上缴物业费,现在补"。
>
> - 王主管查看王先生账户 → 3 张挂起的物业费(Suspended)
> - 走 `ResumeBillAction` 逐张恢复 → 状态 Suspended → Unpaid
> - 然后走 [[collect-payment-batch|批量收款]] 一次性 ¥2,400 付清
> [!example] 真实情境(二):纠纷解决
> 陈先生与物业 5 月物业费纠纷调解结果:物业有部分过错,**协议金额 ¥600**(而不是原 ¥800)。
>
> - 物业要做:
> - 走 ResumeBill(挂起 → Unpaid)
> - 改账单金额(Edit Bill,若 Policy 允许 update 字段)/ 或作废原账单 + 重建 ¥600 账单
> - 业户付 ¥600 → 走 [[collect-payment-single]]
## 业务人员视角
### 第 1 步:确认恢复场景
| 场景 | 后续 |
|---|---|
| 业户失联回来要付 | 恢复 → 收款 |
| 纠纷解决(物业胜)| 恢复 → 收原金额 |
| 纠纷解决(妥协)| 恢复 → 改金额(或作废+重建) |
| 误挂起 | 恢复(reason = "误操作解除") |
### 第 2 步:打开账单
后台 → 账单 → 过滤"状态=Suspended" → 找到目标账单 → 进 `ViewBill`
状态显示 "🧊 Suspended",右上角只有 `ResumeBillAction``VoidBillAction` 可点。
### 第 3 步:点击 `ResumeBillAction`(标签"恢复")
> [!warning] 按钮可见性
> 守护:`bill.status === Suspended` + Policy `->authorize('resume')`。
Modal 表单:
| 字段 | 填什么 |
|---|---|
| **恢复原因(reason)** | 必填,如 "业户出差回来,主动付清"|
### 第 4 步:提交
`ResumeBillAction` 业务层逻辑:
```php
class ResumeBillAction
{
public function handle(Bill $bill, string $reason, User $user): void
{
if ($bill->status !== BillStatus::Suspended) {
throw new RuntimeException("账单非 Suspended 状态,不可恢复");
}
// 智能恢复:有部分付 → Partial;无付款 → Unpaid
$newStatus = $bill->paid_amount > 0
? BillStatus::Partial
: BillStatus::Unpaid;
$bill->update([
'status' => $newStatus,
'meta' => array_merge($bill->meta ?? [], [
'resume_reason' => $reason,
'resumed_at' => now(),
'resumed_by' => $user->id,
// 可选:把这次"挂起-恢复"完整记录追加到 suspend_history 数组
]),
]);
activity()
->performedOn($bill)
->causedBy($user)
->withProperties([
'reason' => $reason,
'from_status' => BillStatus::Suspended->value,
'to_status' => $newStatus->value,
'bill_no' => $bill->bill_no,
])
->event('resumed')
->log('账单已恢复');
}
}
```
### 第 5 步:通知业户(可选)
恢复后立即提示业户付款:
> 王先生,您的 3 张挂起账单已恢复(合计 ¥2,400),现在可以付清。
### 第 6 步:走收款
恢复后走 [[collect-payment-single]] 或 [[collect-payment-batch]]。
## 系统流程
```mermaid
sequenceDiagram
participant 业户
participant 业务
participant Filament
participant Action[ResumeBillAction]
participant DB
业户->>业务: 我要付挂起的账单
业务->>Filament: ViewBill(Suspended)→ ResumeBillAction(modal, reason)
Filament->>Action: handle(bill, reason, user)
Action->>Action: 校验 status === Suspended
Action->>Action: 判定恢复后状态(Unpaid 或 Partial)
Action->>DB: 1. bill.status = Unpaid / Partial
Action->>DB: 2. bill.meta.resume_reason / resumed_at / resumed_by
Action->>Activity: 3. log(event=resumed)
Filament-->>业务: 成功
业务->>Filament: 继续走收款流程
```
## 智能恢复:Partial vs Unpaid
`ResumeBillAction` 判断恢复到哪个状态:
| 挂起前的状态 | 恢复后的状态 |
|---|---|
| Unpaid(无付款)→ Suspended | **Unpaid** |
| Partial(部分付)→ Suspended | **Partial** |
代码层用 `paid_amount > 0` 判断。这样恢复后业户的"已付部分"还在账户上。
## 多次"挂起-恢复"的历史记录
如果同一账单被多次挂起 / 恢复:
```json
// Bill.meta 推荐结构(看实现是否如此)
{
"suspend_reason": "最近一次挂起原因",
"suspended_at": "最近一次挂起时间",
"resume_reason": "最近一次恢复原因",
"resumed_at": "最近一次恢复时间",
"suspend_history": [
{
"suspended_at": "...",
"suspended_by": "...",
"suspend_reason": "...",
"resumed_at": "...",
"resumed_by": "...",
"resume_reason": "..."
},
{ ...... },
...
]
}
```
第一次"挂-恢复"完整存进 `suspend_history` 数组,新一次的"挂"覆盖 `suspend_reason`/`suspended_at`。完整审计可追溯。
> [!info] 实施细节
> 当前 `SuspendBillAction` / `ResumeBillAction` 是否实现 `suspend_history` 数组看代码。简版实现可能只覆盖最近一次(无 history)。
## 业户视角
### 您会感受到什么
- 收到通知"您的账单已恢复,请尽快付款"
- 小程序"我的账单"看到状态:Suspended → Unpaid
- 后续付款流程同正常
### 业户配合
业户应:
- 立即付款(避免再次进入逾期)
- 若有付款困难,提前告诉物业(可能再次挂起 + 协商)
## 与其他模块的对比
| 模块 | 类似 Suspend / Resume |
|---|---|
| **billing(本)** | SuspendBillAction / ResumeBillAction |
| deposit | freeze / unfreeze(账户级,详见 [[../deposit/unfreeze-after-mediation]]) |
| prepaid | FreezeAccountAction / ReactivateAccountAction([[../prepaid/unfreeze-after-verification]]) |
| meter | 无(meter 用 decommission,不可恢复)|
**billing / deposit / prepaid 都有"挂起 / 恢复"的对偶设计** —— 这是金融类业务的通用模式。
## 常见问题
> [!question] 恢复后业户又找不到了怎么办?
> 走 [[exception-overdue-bills|逾期催收]] → 多次催不到 → 再 [[suspend-bill|挂起]] 一次(reason 改"再次失联")。多次"挂-恢复"循环说明业户有问题,需法律 / 走绕监管路径。
> [!question] 恢复时账单期次已经过去很久(例如 5 月账单 11 月才恢复),还按原 due_at 算逾期吗?
> 看业务策略:
>
> - 严格:仍按原 due_at(5 月底 + 宽限期)→ 一恢复就是逾期(可能加滞纳金)
> - 宽松:挂起期间不算逾期 → 恢复时给新的宽限期(例如恢复后 + 7 天)
>
> 当前实施看 `OverdueBillsListWidget` 的判断逻辑。
> [!question] 误挂起立即恢复,activitylog 显示两条(suspended + resumed)对吗?
> 对。每次状态变化各一条 log。可在 reason 备注"误操作 + 立即恢复"。
> [!question] 恢复后能直接改金额吗?
> 看 Policy / EditBill。Unpaid 状态可能允许 Edit(改 amount)。Partial 状态(已付款部分)改金额复杂(原 paid_amount 怎么算)→ 不推荐改,改用"作废 + 重建"。
> [!question] 恢复操作需要审批吗?
> 当前**无审批流**(单签批操作)。业务上若需要审批(例如金额大),靠人员制度保障(高权限人员才操作)。
## 异常分支
- 误恢复(应作废)→ [[void-paid-bill]] 走作废路径
- 恢复后改金额 → 复杂,走作废 + 重建([[create-single-bill-manual]])
- 长期挂起最终决定作废 → [[void-paid-bill]]
## 相关文档
- [[suspend-bill]]
- [[bill-six-state-machine]]
- [[void-paid-bill]]
- [[collect-payment-single]]
- [[exception-overdue-bills]]
- [[audit-activitylog-trace]]
- [[../deposit/unfreeze-after-mediation]](deposit 同类对比)
- [[../prepaid/unfreeze-after-verification]](prepaid 同类对比)

View File

@@ -0,0 +1,220 @@
---
title: prop-acc · billing · 场景 - 拆账单(SplitBillAction)
aliases:
- 拆账单
- 账单分摊
- SplitBillAction
- split-bill
- 场景-拆账单
tags:
- 场景
- prop-acc
- 账单
- 调整
audience:
- 业务人员
- 财务
status: 已发布
sub_feature: billing
last_review: 2026-05-26
code_version: 2026-05-22
---
# 场景:拆账单(SplitBillAction)
业户**多人共住一户**(房东 + 租户 / 合租),物业费 / 水电气**按比例分摊**到各自账户,业务人员走 `SplitBillAction` 把一张账单**拆成多张**。
## 典型情境
> [!example] 真实情境
> 12-3-501 房屋:
>
> - 房东陈先生(产权人)
> - 租户李先生(2026 年 1 月起租)
>
> 合同约定:**物业费房东付,水电气租户付**。但系统月初按"业户=陈先生"批量生成的:
>
> - 5 月物业费 ¥800(应给陈先生)
> - 5 月水费 ¥54(实际应租户付)
> - 5 月电费 ¥168(实际应租户付)
> - 5 月燃气 ¥30(实际应租户付)
>
> 业务人员王主管要把后 3 张账单**重新分给租户李先生**(从陈先生的账户拆出去)。
## 业务人员视角
### 第 1 步:确认拆单需求
业户来电话 / 合同约定:
- 房东本人来报备"水电气以后租户付"
- 看合同 / 租赁备案
- 确认租户身份(可能已在 `community_user_profiles` 注册)
### 第 2 步:逐张拆单
**(本场景默认逐张拆,不是一次 SplitAll)**
后台 → 账单 → 找到 5 月水费 Bill → 进 `ViewBill` → 点 `SplitBillAction`(标签"拆账单")。
> [!warning] 按钮可见性
> 看 `SplitBillAction` 守护(应该是 Unpaid + 业务权限)。Paid / Void 状态可能不允许拆(已付的钱难撤)。
### 第 3 步:Modal 填参数
| 字段 | 填什么 |
|---|---|
| **原账单** | 5 月水费 ¥54(陈先生)|
| **拆分方式** | 按比例 / 按金额 / 按目标业户 |
| **新业户** | 李先生(下拉选)|
| **拆给新业户的金额** | ¥54(全部 → 全转给李先生 = 业户更换;或部分 → 拆成两张)|
| **拆分原因** | 必填,如 "房东与租户合同约定:水费由租户付"|
### 第 4 步:提交
`SplitBillAction` 业务逻辑(看实现):
**模式 1:全转给新业户**(本场景常见)
```
- 原 Bill #X(陈先生):状态翻 Void(原账单作废)+ 标 split_to=新 Bill ID
- 新建 Bill #Y(李先生):amount=54, status=Unpaid
- 关联(meta 记拆分来源)
```
**模式 2:部分拆分**(罕见)
```
- 原 Bill #X(陈先生):amount 改为 30(原 54 - 拆出 24)
- 新建 Bill #Y(李先生):amount=24
- 两张同存
```
> [!info] 实施细节看代码
> `SplitBillAction` 的具体行为(全转 vs 部分拆 vs 按比例)看 `packages/prop-acc/src/Actions/Bills/SplitBillAction.php`。本文按业务场景描述。
### 第 5 步:通知双方
- 陈先生:"5 月水费已转给租户李先生付,您不必付了"
- 李先生:"您 5 月水费 ¥54 新账单已生成,请于 6 月 15 日前付清"
### 第 6 步:重复对电费 / 燃气
逐张拆。3 张全拆完后:
- 陈先生只欠 5 月物业费 ¥800
- 李先生欠 5 月水费 + 电费 + 燃气 ¥252
## 系统流程(全转模式)
```mermaid
sequenceDiagram
participant 业务
participant Filament
participant Action[SplitBillAction]
participant DB
participant Activity[activitylog]
业务->>Filament: ViewBill(陈先生水费)→ SplitBillAction
Filament->>Action: handle(bill=陈先生水费, mode=Full, target=李先生, reason)
Action->>Action: 校验 bill.status (Unpaid)
Action->>Action: 校验 target_resident 在同 community
Action->>DB: 开启事务
Action->>DB: 1. 原 Bill 翻 Void + meta.split_to=新 ID
Action->>DB: 2. 新建 Bill(target=李先生, amount=54, status=Unpaid, meta.split_from=原 ID)
Action->>Activity: log(event=split, properties)
Action->>DB: 提交
Filament-->>业务: 跳转新 Bill
```
## 业户视角
### 陈先生(原账单业户)
收到通知:
> 陈先生您好,您的 5 月水费 ¥54 已根据合同拆给租户李先生付。您原账单已作废,无需付款。
### 李先生(新账单业户)
收到通知:
> 李先生您好,您 5 月水费 ¥54 账单已生成(由 12-3-501 房屋的合同拆分而来),请于 6 月 15 日前付清。
## 拆单的几种业务场景
| 场景 | 频率 | 拆法 |
|---|---|---|
| **房东 / 租户合同分摊** | 中(本场景)| 按费用类型分,水电气给租户 |
| **多业主分摊**(共有产权)| 罕见 | 按比例 |
| **公摊费用追溯** | 中(本月才发现某费用应分摊)| 按户数 |
| **企业内部子公司分摊**(商铺)| 罕见 | 按合同 |
## 与"作废 + 重建"的对比
| 维度 | 拆账单(SplitBillAction)| 作废 + 手动重建 |
|---|---|---|
| 单一操作 | ✅(一个 Action 完成)| ❌(走 2-3 个 Action)|
| 关联追溯 | ✅(meta 标 split_from/to)| ❌(无系统关联)|
| 审计 | activitylog event=split | 多条 activitylog(void + create)|
| 灵活性 | 受 SplitBillAction 限制 | 更灵活(可改任意字段)|
| 推荐场景 | 简单拆分(全转 / 部分按金额)| 复杂拆分 / 拆完还要改其他字段 |
## 拆单后已付款的复杂情况
> [!warning] 已付款 Bill 拆单的复杂度
>
> 如果原账单**已付一部分**(Partial 状态):
>
> - 原账单作废 → 已付的部分怎么办?
> - 退还给原业户 → 走作废 + 退款([[void-paid-bill]] 类似)
> - 新账单上记 paid_amount? 不行(原业户付的钱不该算到新业户)
>
> 实施上 `SplitBillAction` 可能**不允许 Partial 状态拆**(只允许 Unpaid)。看具体实现。
>
> **推荐**:拆单前先处理已付款(作废 + 退款 / 退现金后)再拆。
## 常见问题
> [!question] 拆给的业户不存在(系统里没注册)怎么办?
> 先建业户档案(community 模块的 `community_user_profiles`)→ 然后拆。
> [!question] 拆错了(应该拆给李先生,选成王先生)?
> 看实施:
> - 若新 Bill 仍 Unpaid → 走 [[delete-bill-unpaid|删除]] + 重拆
> - 若已 Paid → 麻烦,需走作废 + 退款 + 重拆
> [!question] 按比例自动拆所有账单(房东 50% / 租户 50% 物业费)?
> 当前 `SplitBillAction` 应是**逐张操作**。按比例批量拆需要业务方提需求 + 加 `BulkSplitBillsAction`(待实现)。
> [!question] 拆单的合规性?
> 物业要确保:
> - 业户合同明确分摊条款
> - 双方书面同意拆分
> - 物业内部审批留底
>
> 系统层面只管账单数据,合规由物业流程保障。
> [!question] 一张账单能拆成 3+ 份吗(多人合租 3 人均摊)?
> 看 SplitBillAction 设计。简单实现是**一次拆 2 份**(原账单 + 新账单)。要拆 3 份需 2 次操作:
>
> 1. 第 1 次:原账单(¥54)→ 拆出 ¥18(给租户 A),原账单剩 ¥36
> 2. 第 2 次:原账单(¥36)→ 拆出 ¥18(给租户 B),原账单剩 ¥18(给房东自己)
>
> 累加得到三份 ¥18 各自归一人。
## 异常分支
- 拆错了 → 删除 / 作废新 Bill,原 Bill 状态可能要恢复(看实施)
- 已付的拆 → 走 [[void-paid-bill]] 流程,复杂
- 简单"换业户"(不拆,只改账单的 resident_id)→ 可能用 Edit Bill 直接改(若 Policy 允许),但**强烈不推荐**(破坏追溯)→ 用拆单更标准
## 相关文档
- [[bill-six-state-machine]]
- [[delete-bill-unpaid]]
- [[void-paid-bill]]
- [[suspend-bill]]
- [[bill-vs-collection-order]]

View File

@@ -0,0 +1,241 @@
---
title: prop-acc · billing · 场景 - 挂起账单(业户失联/纠纷)
aliases:
- 挂起账单
- SuspendBillAction
- 暂停收款
- suspend-bill
- 场景-挂起账单
tags:
- 场景
- prop-acc
- 账单
- 调整
audience:
- 业务人员
- 财务
status: 已发布
sub_feature: billing
last_review: 2026-05-26
code_version: 2026-05-22
---
# 场景:挂起账单(业户失联 / 纠纷)
业户**与物业有纠纷**或**长期失联**,该账单暂时**不应被收款 / 不应被催收**,但又不能直接作废(纠纷可能解决,业户可能出现)。走 `SuspendBillAction` 把账单挂起,状态 Unpaid → Suspended,后续可 [[resume-bill|恢复]]。
## 典型情境
> [!example] 真实情境(一):业户失联
> 王先生(15-7-203)三个月没缴物业费(累计 3 张账单 ¥2,400 Unpaid),物业多次联系电话不通、上门无人。物业认为业户可能搬走 / 失联 / 出国,**先把这 3 张账单挂起**,避免:
>
> - 月度报表"应收账款"虚高(挂着收不回来)
> - 催收资源浪费(联系不上的还反复发短信)
> - 业户突然回来时账单仍在(不会变作废)
> [!example] 真实情境(二):纠纷期间
> 陈先生认为 5 月物业费 ¥800 不合理(物业服务质量纠纷),拒绝付。物业 / 业主委员会调解中,**挂起该账单**,等调解结果:
> - 调解物业胜诉 → [[resume-bill|恢复]] → 业户付
> - 调解业户胜诉 → [[void-paid-bill|作废]] → 不收
> - 调解妥协 → 走拆账单 / 重新算金额
## 业务人员视角
### 第 1 步:确认挂起场景
| 场景 | 是否走挂起 |
|---|---|
| 业户失联 1-3 个月 | ✅ 挂起(等业户出现) |
| 业户失联 >6 个月 | 可考虑作废(看物业政策)|
| 业户纠纷中 | ✅ 挂起 |
| 业户拒不付 | 不挂起,走逾期催收([[exception-overdue-bills]]) |
| 业户真的搬走永久不再来 | 走作废 / 法律手段 |
### 第 2 步:打开账单
后台 → 账单 → 找到 Unpaid Bill → 进 `ViewBill`
### 第 3 步:点击 `SuspendBillAction`(标签"挂起")
> [!warning] 按钮可见性
> 守护:`bill.status === Unpaid || Partial` + Policy `->authorize('suspend')`。Paid / Void / Suspended 状态灰化。
Modal 表单:
| 字段 | 填什么 |
|---|---|
| **挂起原因(reason)** | **必填且详细**,如 "业户失联 3 个月,电话不通 + 上门无人 + 微信无回应" |
### 第 4 步:提交
`SuspendBillAction` 业务层逻辑:
```php
class SuspendBillAction
{
public function handle(Bill $bill, string $reason, User $user): void
{
if (! in_array($bill->status, [BillStatus::Unpaid, BillStatus::Partial])) {
throw new RuntimeException("账单状态不可挂起");
}
$bill->update([
'status' => BillStatus::Suspended,
'meta' => array_merge($bill->meta ?? [], [
'suspend_reason' => $reason,
'suspended_at' => now(),
'suspended_by' => $user->id,
]),
]);
activity()
->performedOn($bill)
->causedBy($user)
->withProperties([
'reason' => $reason,
'from_status' => $bill->getOriginal('status'),
'to_status' => BillStatus::Suspended->value,
'bill_no' => $bill->bill_no,
])
->event('suspended')
->log('账单已挂起');
}
}
```
### 第 5 步:通知业户(可选)
- 失联场景:不通知(联系不上,无意义)
- 纠纷场景:通知"您的账单已挂起,等调解结果"
## 系统流程
```mermaid
sequenceDiagram
participant 业务
participant Filament
participant Action[SuspendBillAction]
participant DB
participant Activity
业务->>Filament: ViewBill → SuspendBillAction(modal)
Filament->>Action: handle(bill, reason, user)
Action->>Action: 校验 status Unpaid/Partial
Action->>DB: 开启事务
Action->>DB: 1. bill.status=Suspended
Action->>DB: 2. bill.meta.suspend_reason / suspended_at / suspended_by
Action->>Activity: 3. log(event=suspended)
Action->>DB: 提交
Filament-->>业务: 成功
```
## 挂起后的能力对照
| 操作 | Unpaid / Partial | **Suspended** |
|---|---|---|
| `CollectPaymentAction`(收款)| ✅ | ❌(`canBePaid=false`)|
| `CollectPaymentAction`(预存款抵)| ✅ | ❌ |
| `SuspendBillAction`(再挂起)| ✅ | ❌(已是 Suspended)|
| `ResumeBillAction`(恢复)| ❌ | ✅ |
| `VoidBillAction`(作废)| ✅(canBeVoided=true)| ✅ |
| 看账单 / 看历史 | ✅ | ✅(只读)|
| 出现在 `OverdueBillsListWidget` | ✅(若到期)| ❌(挂起不算逾期)|
| 出现在月度账单清单 | ✅ | ✅(标 Suspended)|
> [!info] Suspended 与 Overdue 的关系
> 挂起的账单**不算逾期**(catch up 不会出现在催收清单)。但**期次仍在历史**(归属本月报表)。
>
> 这是**有意设计**:挂起的目的是停掉催收 + 暂时退出收款流程,但不让账单凭空消失。
## 业户视角
### 失联场景(无感)
业户失联本来就联系不上,业务方决定挂起 → 业户**完全不知道**。等业户出现时:
- 业户来电话 / 上门
- 业务人员说"您有挂起的账单 ¥XXX,我现在帮您恢复 + 收款"
- 走 [[resume-bill|恢复]] → 收款
### 纠纷场景
业户**可能收到通知**(看物业政策):
> 陈先生您好,您的 5 月物业费账单已挂起(暂停收款),等纠纷调解结果。预计 X 月 Y 日前出结果。
业户:
- 知情 + 等待
- 期间不会被催收
- 调解后走 [[resume-bill|恢复]] 或 [[void-paid-bill|作废]]
## 与"作废"的对比
| 维度 | **挂起(Suspended,本场景)** | [[void-paid-bill|作废 Void]] |
|---|---|---|
| 是否可恢复 | ✅ 走 [[resume-bill]]| ❌ 终态 |
| 业务场景 | 临时暂停(纠纷 / 失联)| 永久消除 |
| 报表归属 | 仍在本期(标 Suspended)| 标 Void(可过滤) |
| 后续是否能收款 | 恢复后能 | 不能(已 Void)|
**简单判断**:**不确定后续怎么办 → 挂起**;**确定不再收 → 作废**。
## 长期 Suspended 的处理
挂起后**长期没恢复 / 没作废**的账单,业务上需要定期 review:
| 挂起时长 | 推荐处置 |
|---|---|
| < 1 月 | 正常等待 |
| 1-3 月 | 评估业户情况(再次联系)|
| 3-6 月 | 决定:恢复(若有进展)/ 作废(若无希望)|
| > 6 月 | 通常作废(或走法律手段)|
可加 audit 场景:**"长期挂起账单清单"**(类似 [[../deposit/audit-long-pending-accounts]])。当前 issue.md 未明确实施,可作为未来扩展。
## 常见问题
> [!question] 挂起原因填得详细到什么程度?
> **越详细越好**。审计要求 + 业户事后查询 + 业务复盘都需要。推荐结构:
>
> - 触发事件(纠纷 / 失联 / 其他)
> - 已采取的联系措施(电话 / 上门 / 微信)
> - 业务上的预期(等调解 / 等业户回来 / 等司法)
> [!question] 挂起的账单已经有部分付(Partial)?
> 看实施。SuspendBillAction 可能允许(从 Partial → Suspended)。已付的部分**不退**(只是暂停后续收款)。
> [!question] 同一业户多张挂起,能批量挂吗?
> 当前**无批量挂起 UI**。逐张走 SuspendBillAction,效率低但可控。
>
> 可加 `BulkSuspendBillsAction`(类似批删的设计),业务方提需求时实施。
> [!question] 挂起影响 prepaid 自动抵扣 job?
> 是。job 应**跳过 Suspended 状态**的账单。设计上看 [[../prepaid/auto-deduction-design]]。
> [!question] activitylog 怎么查挂起记录?
> ```sql
> SELECT * FROM activity_log
> WHERE event = 'suspended'
> AND created_at BETWEEN ? AND ?
> ORDER BY created_at DESC;
> ```
>
> 详见 [[audit-activitylog-trace]]。
## 异常分支
- 业户出现/纠纷解决 → [[resume-bill]]
- 确定不再收 → [[void-paid-bill]]
- 业务上误挂起 → [[resume-bill]] 撤回(reason 改"误操作")
- 长期挂起无解 → 作废 / 法律手段(待业务方明确)
## 相关文档
- [[bill-six-state-machine]]
- [[resume-bill]]
- [[void-paid-bill]]
- [[exception-overdue-bills]]
- [[audit-activitylog-trace]]
- [[../deposit/freeze-during-dispute]](类似的"暂停"模式对比)

View File

@@ -0,0 +1,269 @@
---
title: prop-acc · billing · 场景 - 作废已付账单(走作废 + 退款)
aliases:
- 作废账单
- VoidBillAction
- void-paid-bill
- 作废加退款
- 场景-作废已付账单
tags:
- 场景
- prop-acc
- 账单
- 作废
audience:
- 业务人员
- 财务
status: 已发布
sub_feature: billing
last_review: 2026-05-26
code_version: 2026-05-22
---
# 场景:作废已付账单(走作废 + 退款)
业户**已付**的账单需要消除(误开账单业户付了 / 业户事后投诉成功 / 调解结果物业认错)。走**作废 + 退款**组合,留状态留审计 + 退还业户。
> [!warning] 当前实施状态
> `VoidBillAction` 本身**只翻状态 + 留 meta + 写 activitylog**(`canBeVoided=true` for **非 Paid**)。
>
> **Paid 状态 `canBeVoided=false`**,意味着 `VoidBillAction` 不直接处理 Paid 账单的作废。需要走**类似 meter 的修正流程**:
>
> - 当前手工 / tinker(运维操作)
> - 未来扩展 `VoidPaidBillAction` 自动化(包含退款 + 红字 CO 等)
>
> 详见 [[delete-vs-void-dual-track]]"`canBeVoided` 的微妙之处"段。
## 典型情境
### 情境 1:Partial 状态作废(系统支持)
> [!example] 真实情境
> 陈先生 5 月物业费 ¥800,部分付了 ¥300(状态 Partial)。后续物业承认服务有问题,**双方协议作废账单**,退 ¥300 给业户。
>
> 业务流程:
> 1. 走 `VoidBillAction`(Partial → Void)
> 2. **手工配套退款**(给陈先生退 ¥300 现金 / 微信)
> 3. (理想)系统自动建红字 CollectionOrder(待扩展)
### 情境 2:Paid 状态作废(当前需手工)
> [!example] 真实情境
> 张阿姨 5 月物业费 ¥800 已付清(Paid)。物业发现金额算错了(应该 ¥600),**多收 ¥200**。要全额作废 + 退还 ¥800 + 重新建一张 ¥600 账单。
>
> 当前流程(因 canBeVoided=false for Paid,VoidBillAction 不允许):
> 1. **运维 tinker 操作**:把 Bill 状态强制改为 Void + 记 meta
> 2. **手工建红字 CollectionOrder**(¥-800)+ Receipt(红字)
> 3. **物业线下退款** ¥800 给业户
> 4. **手工建新 Bill** ¥600
> 5. 业户付 ¥600
>
> 整个流程**复杂、易出错、无 UI**。issue.md Q6 未明确实施时间,标"待业务方明确"。
## 业务人员视角(Partial 作废)
### 第 1 步:确认作废
- 协议作废(业户与物业书面达成)
- 调解 / 司法判决物业方有责
- 业务上的特殊情况
### 第 2 步:打开账单
后台 → 账单 → 找到 Partial Bill → 进 `ViewBill`
### 第 3 步:点击 `VoidBillAction`(标签"作废")
> [!warning] 按钮可见性
> 守护:`canBeVoided()` = 非 Paid 非 Void + `->authorize('void')`(`bill.void` 权限)。
Modal 表单:
| 字段 | 填什么 |
|---|---|
| **作废原因(reason)** | **必填**,如 "调解结果:物业服务质量有问题,账单作废 + 退还已付款" |
### 第 4 步:提交
`VoidBillAction` 业务逻辑(详见 [[delete-vs-void-dual-track]]):
```php
$bill->update([
'status' => BillStatus::Void,
'meta' => array_merge($bill->meta ?? [], [
'voided_reason' => $reason,
'voided_at' => now(),
'voided_by' => $user->id,
]),
]);
activity()->performedOn($bill)->causedBy($user)
->withProperties([
'reason' => $reason,
'from_status' => $bill->getOriginal('status'),
'to_status' => 'Void',
'bill_no' => $bill->bill_no,
'amount' => $bill->amount,
'paid_amount' => $bill->paid_amount, // 关键:有付款需要后续退
])
->event('voided')
->log('账单已作废');
```
### 第 5 步:手工退款(若 paid_amount > 0)
VoidBillAction **本身不退钱**。业务人员后续:
1. 看 activitylog 的 `paid_amount` 字段 = ¥300
2. 联系业户确认退款方式(微信 / 现金 / 银行转账)
3. 走线下退款(物业财务实际打钱)
4. **理想**:建红字 CollectionOrder(`actual_amount=-300`,`type=Bill`,关联到该 Bill)+ Receipt 红字(待扩展自动化)
> [!info] 当前简化做法
> Bill 作废后:
> - Bill 状态 = Void
> - CollectionOrderBill 关联**不动**(审计需要)
> - 业务人员**线下手工退款**给业户
> - 系统中没有红字 CO / 红字 Receipt(待 `VoidPaidBillAction` 自动化)
>
> 财务账面**临时不一致**(Bill 是 Void 但 CO 仍是 Completed),需事后人工对账修复。
>
> **业务方提需求时,优先级会上来。**
### 第 6 步:通知业户
> 陈先生您好,您的 5 月物业费账单已作废,已付的 ¥300 我们将退还您。请确认收款方式。
## 业务人员视角(Paid 作废,当前 tinker 流程)
详见上方"情境 2"。当前**没自动化**:
1. 评估业务必要性(确认无误后操作)
2. 联系运维 tinker:
```php
$bill->update([
'status' => BillStatus::Void,
'meta' => array_merge($bill->meta ?? [], [
'voided_reason' => $reason,
'voided_at' => now(),
'voided_by' => $user->id,
'manual_void' => true, // 标记是 tinker 手工作废
]),
]);
```
3. 走完整退款流程(线下退 + 系统记一笔红字 CO/Receipt 若可能)
4. 重建账单(走 [[create-single-bill-manual]])
5. 业户付新账单
## 系统流程
```mermaid
sequenceDiagram
participant 业务
participant Filament
participant Action[VoidBillAction]
participant DB
participant Activity
业务->>Filament: ViewBill(Partial)→ VoidBillAction
Filament->>Action: handle(bill, reason, user)
Action->>Action: 校验 canBeVoided(非 Paid 非 Void)
Action->>DB: 1. bill.status = Void + meta
Action->>Activity: 2. log(event=voided, paid_amount=300)
Filament-->>业务: 成功
业务->>业务: 看 activitylog paid_amount=300
业务->>业户: 线下退款 + 通知
Note over Action: 注:不自动建红字 CO/Receipt
Note over Action: 待 VoidPaidBillAction 实现
```
## 业户视角
### 您会感受到什么
- 收到通知"您的账单 #XXX 已作废,理由 XXX"
- 已付的钱**未来几天**收到退款
- 收到收据备注(若系统支持)
- 小程序账单状态:Partial → Void
### 您要做什么
- 确认收款方式
- 接收退款(银行 / 微信 / 现金)
- 如有疑问联系物业
## 与 prop-acc 其他作废的对比
| 模块 | 作废 / void 机制 |
|---|---|
| **billing(本)** | `VoidBillAction`(非 Paid 非 Void)+ 手工退款配套(Paid 待扩展) |
| deposit | 无单独 void;用 [[../deposit/force-close-refund|ForceClose refund]] 等机制 |
| prepaid | 无 void;走 [[../prepaid/refund-partial-after-consume|refund]] |
| meter | 无 void(reading 不可改,见 [[../meter/exception-readings-locked-after-bill]])|
| adhoc | 走 [[../adhoc/cancel-amount-error-redo|VoidAction]](类似 billing 设计)|
bill 的 void 是**最完整的"账单作废"设计**,但 Paid 的作废**仍待补**(整个 prop-acc 通用问题:已收款的"反向"流程都不够成熟)。
## 已知限制(issue.md Q6 待补)
- **VoidBillAction 不处理 Paid 状态**:`canBeVoided=false`,需走专门流程(未实现)
- **作废后红字 CO + Receipt 自动生成**:未实现,需手工
- **退款金额自动算 / 自动建红字凭证**:未实现
- **BulkRefundBillsAction**(批量退款):未实现,issue.md 标优先级低
## 常见问题
> [!question] 作废后业户已付款怎么记账?
> 当前(简版):
> - Bill 状态 = Void
> - CollectionOrderBill 关联保留(`allocated_amount=300`)
> - 物业账面"已收 ¥300"(从 CO 角度)= 实际"应退给业户"
>
> 财务月度对账时需**人工识别这种情况**(账上有钱但 Bill 已 void = 待退款)。
>
> 未来自动化后:作废 + 同时建红字 CO(¥-300)抵消原 CO,账面归零。
> [!question] 作废能撤销吗?
> 不能(Void 是终态,详见 [[bill-six-state-machine]])。如需"恢复":新建一张同信息 Bill。
> [!question] 已付 + 已被预存款抵的 Bill 作废,怎么退到预存款?
> 自动化未实现。手工流程:
>
> 1. tinker 作废 Bill
> 2. 给业户预存款手工 deposit(走 [[../prepaid/deposit-additional-topup]] 把 ¥800 充回预存款)
> 3. 备注"账单作废退还"
>
> 系统层面:理想是自动反向(`PrepaidAccount::reverseConsume` 之类),未实现。
> [!question] activitylog 怎么查作废历史?
> ```sql
> SELECT * FROM activity_log
> WHERE event = 'voided'
> AND properties->>'$.bill_no' = ?
> ORDER BY created_at DESC;
> ```
> [!question] 业户对作废结果不满意?
> 已作废不可逆。业户:
> - 走司法 / 仲裁
> - 业务方重新协商(可能再建新账单)
## 异常分支
- Unpaid 无付款 → [[delete-bill-unpaid|物理删]] 更干净
- 批量作废 → [[bulk-delete-batch-mistake]] 选 DeleteAndVoid 模式
- 业户拒绝接受作废 → 协商 / 司法
- Paid 作废自动化 → 待业务方明确 + 实施 VoidPaidBillAction
## 相关文档
- [[delete-vs-void-dual-track]]
- [[smart-bulk-delete-design]]
- [[bill-six-state-machine]]
- [[delete-bill-unpaid]]
- [[bulk-delete-batch-mistake]]
- [[audit-activitylog-trace]]
- [[../meter/exception-readings-locked-after-bill]](类似 已落账修正)
- [[../adhoc/cancel-amount-error-redo]](adhoc 同类对比)

View File

@@ -0,0 +1,261 @@
---
title: prop-acc · meter · 场景 - 待抄表清单 + 月度抄表完成率
aliases:
- 待抄表清单
- 抄表完成率
- audit-meters-needing-reading
- MetersNeedingReadingListWidget
- 场景-待抄表审计
tags:
- 场景
- prop-acc
- 计量表
- 审计
audience:
- 业务人员
- 财务
- 抄表员
status: 已发布
sub_feature: meter
last_review: 2026-05-26
code_version: 2026-05-22
---
# 场景:待抄表清单 + 月度抄表完成率
物业**月底**核对:本月**应抄**多少张表(在役表数)vs **已抄**多少张(本月有 reading 的表)。差额 = **待抄表清单**(可能是抄表员漏抄 / 集抄掉线)。`MetersNeedingReadingListWidget``MeterDashboard` 实时显示。
## 典型情境
> [!example] 真实情境
> 5 月 28 日王主管打开 `MeterDashboard`,看 `MetersNeedingReadingListWidget`:
>
> - 嘉禾花园在役表 1,200 张
> - 本月已抄 1,150 张
> - **待抄 50 张**
>
> 分析:
> - 30 张:集抄掉线(`source=remote` 应自动推但没推)
> - 15 张:抄表员遗漏(手抄区域漏了)
> - 5 张:业户家无人无法入户读表
>
> 5/30 截止前必须补齐(否则当月账单缺漏)。
## 业务人员视角
### Widget 显示
`MetersNeedingReadingListWidget`(MeterDashboard):
| 列 | 内容 |
|---|---|
| 业户 / 资产 | 房号 + 业户姓名 |
| 表编号 | meter code |
| 费用类型 | 水/电/燃气 |
| 上次抄表日期 | 上月 read_at |
| 抄表来源 | manual / remote(看历史)|
| 状态 | 未抄 / 部分缺漏 |
| 操作 | 链接到该 meter 的录入入口 |
排序:**按上次抄表日期升序**(最久没抄的优先,可能问题最大)。
### 完成率计算
```
本月完成率 = 本月已抄表数 / 在役表总数
= 1150 / 1200
= 95.8%
```
业务上通常**目标 > 99%**(漏抄太多说明流程有问题)。
### 分级处置
| 待抄类型 | 数量 | 处置 |
|---|---|---|
| 集抄掉线 | 30 | 联系集抄运营商 + 抄表员现场补抄 |
| 抄表员漏抄 | 15 | 抄表员立即补抄 |
| 业户无人 | 5 | 多次上门 / 与业户约时间 / 估算用量(罕见) |
### 处理流程
```mermaid
flowchart TD
A[Widget 显示 50 张待抄] --> B{分类}
B -->|集抄掉线 30 张| C[联系集抄运营商]
C --> D{原因}
D -->|网关故障| E[运营商修复 → 重推数据]
D -->|个别表故障| F[抄表员现场补抄 + 走 replace-broken-meter]
B -->|抄表员漏抄 15 张| G[立即补抄]
B -->|业户无人 5 张| H[多次上门 / 约时间]
H --> I{约不上}
I -->|是| J[估算用量 / 用 min_amount 兜底 / 延后处理]
I -->|否| G
E --> K[完成率回升]
F --> K
G --> K
```
### 月度执行清单
每月最后一周(25-30 号),业务人员清单:
- [ ] 25 号:看 Widget,记录待抄数 + 分类
- [ ] 26-28 号:跟进集抄运营商 + 抄表员补抄
- [ ] 29 号:再看 Widget,核对剩余待抄
- [ ] 30 号:截止前完成 99%+,剩余的特殊处理
- [ ] 月初(1 号):看完成率报告 + 触发账单生成
## 抄表员视角(李师傅)
### 自己的清单
抄表员手机 App 上显示**本月分配的清单**(类似 widget 但只显示自己负责的区域)。
每天进度:
- 上午:看清单 + 规划路线
- 现场抄表 + 拍照 + 录入
- 下午回办公室:补录 / 同步
完成度自我管理。月底向王主管报告:"本月我负责的 X 张表已完成 Y 张,剩 Z 张是 [原因] 需 [协助]"。
## `MetersNeedingReadingListWidget` 实现
> [!info] 当前实现的逻辑
>
> SQL 大概:
>
> ```sql
> -- 在役表里,本月没有 reading 的
> SELECT m.*, MAX(r.read_at) AS last_read_at
> FROM acc_meters m
> LEFT JOIN acc_meter_readings r ON r.meter_id = m.id
> WHERE m.is_active = true
> AND m.community_id = ?
> -- 本月起没有 reading
> AND m.id NOT IN (
> SELECT meter_id FROM acc_meter_readings
> WHERE read_at BETWEEN '2026-05-01' AND '2026-05-31 23:59:59'
> )
> GROUP BY m.id
> ORDER BY last_read_at ASC;
> ```
>
> 关键:
> - 只看在役表(退役表 `is_active=false` 不算)
> - "本月没 reading" = 待抄
> - "上次抄表日期久远" = 优先级高
## 报表 / 周报
王主管月初出报告(给物业总经理 / 财务总监):
```markdown
# 2026 年 5 月 嘉禾花园抄表完成率报告
## 总览
- 在役表:1,200 张
- 本月已抄:1,200 张(目标 99%,实际 100%)
- 完成率:100% ✅
## 抄表来源分布
- 集抄(remote):1,160 张(96.7%)
- 手抄(manual):40 张(3.3%)
- 集抄掉线补抄:30 张
- 业户无人后多次上门:10 张
## 异常事件
- 集抄掉线高峰:5/25 一天 30 张(网关故障 4 小时,运营商已修)
- 单户长期无人:王先生(15-7-203),已联系成功 + 补抄
## 趋势
- 4 月完成率 99.8%(漏 2 张次月补)
- 5 月完成率 100%(全部当月完成)
- 集抄稳定性提升(运营商升级了网关)
## 建议
- 与集抄运营商签更严的 SLA(网关掉线 < 1 小时)
- 给抄表员增加"无人户备份方案"(预约 + 业户授权代抄)
```
## 业户视角
业户**通常不感知**这个审计动作。极少数情况:
| 业户场景 | 业户感知 |
|---|---|
| 业户无人导致漏抄,下月补 | 收到的下月账单可能比往常高(两个月用量)|
| 集抄掉线导致补抄 | 物业可能上门 / 微信问"麻烦看下您家水表读数发我下" |
| 长期补不上 | 物业按"平均月用量"估算账单(业户可能不爽)|
## 与其他审计的对比
| 审计场景 | 关注什么 | 频率 |
|---|---|---|
| **本场景(待抄表)** | 月度抄表完成率 | 月度 |
| [[exception-high-consumption|高用量预警]] | 异常用量预防 | 月度(抄表完成后)|
| [[../deposit/audit-monthly-deposit-balance|押金月度对账]] | 账面 vs 银行平衡 | 月度 |
| [[../prepaid/audit-low-balance-and-overdue|预存款低余额]] | 业户预存款健康度 | 每周 / 月度 |
各模块审计**频率与关注点不同**,本场景是 meter 模块的**最重要月度审计**。
## 常见问题
> [!question] 完成率 < 95% 的常见原因?
> - 集抄设备大面积故障
> - 抄表员人手不足
> - 业户长期无人 / 拒绝抄表
> - 系统 bug(meter 状态错乱)
>
> 长期完成率低 → 升级集抄 / 改流程 / 加人手。
> [!question] 漏抄的表下月一起算可以吗?
> 可以,但**业户体验差**(账单突然翻倍)。推荐:
> - 当月尽量补齐
> - 实在补不齐 → 让业户**自报**读数(业务上接受,需核对)
> - 估算用量(罕见,看物业政策)
> [!question] 业户长期无人怎么办?
> - 多次上门(2-3 次)
> - 业户授权代抄(物业拿钥匙 / 业户邻居代抄)
> - 估算用量(按往月平均算)
> - 长期(> 6 个月)无人 → 标记为"特殊处理户",月度估算 / 等业户回来再补
> [!question] 集抄运营商频繁掉线,物业如何制约?
> 合同 SLA:
> - 集抄运营商承诺 99% 在线率
> - 违约罚款(掉线超 X 小时罚 Y 元)
> - 严重违约 → 解约换供应商
> [!question] 漏抄但已经生成账单(只是少了 N 户)能补充生成吗?
> 可以。补抄 → 录 reading → 触发 [[bill-generation-pipeline|GenerateBills]] 对新 reading 生成 Bill。已有 Bill 不受影响。
## 升级机会(待补)
`MetersNeedingReadingListWidget` 当前简版,可升级为:
- **更精准的"待抄"判定**(按抄表周期,不一定按月)
- **完成率趋势图**(月度趋势线)
- **抄表员效率排名**(谁抄得快 / 准)
- **预警机制**(月底前 7 天若完成率 < 90% 自动告警)
## 异常分支
- 高用量预警(完成抄表后)→ [[exception-high-consumption]]
- 已生成 Bill 的 reading 错(本场景兜底)→ [[exception-readings-locked-after-bill]]
- 集抄系统 → [[read-via-iot-remote-source]]
- 单录补抄 → [[read-single-meter-manual]]
## 相关文档
- [[bill-generation-pipeline]]
- [[exception-high-consumption]]
- [[exception-readings-locked-after-bill]]
- [[read-via-iot-remote-source]]
- [[read-single-meter-manual]]
- [[../deposit/audit-monthly-deposit-balance]](类似月度对账模式)

View File

@@ -0,0 +1,191 @@
---
title: prop-acc · meter · 场景 - 退役不换表(房屋拆除/业户永久弃用)
aliases:
- 退役不换表
- 永久弃用
- decommission-without-replacement
- 场景-计量表退役不换
tags:
- 场景
- prop-acc
- 计量表
- 表管理
audience:
- 业务人员
status: 已发布
sub_feature: meter
last_review: 2026-05-26
code_version: 2026-05-22
---
# 场景:退役不换表(房屋拆除/业户永久弃用)
物理表**不再需要**(房屋拆除 / 业户永久搬走 / 商铺撤店 / 法定使用年限到不续装),系统**只退役不建新表**。`decommission_reason``Removed` / `Expired` 等,不走 `ReplaceMeterAction`
## 典型情境
> [!example] 真实情境(一):房屋拆除
> 嘉禾花园三期某栋老楼要拆迁重建,3 单元 1-6 层 30 户业主已全部搬走,准备拆楼。该单元所有水电气表(共 90 张)需要**永久退役**(房子拆了表也没了)。
> [!example] 真实情境(二):商铺撤店
> 一楼商铺(原餐饮店)经营 5 年后结业,装修拆除,新承租人方向未定。原餐饮店专用商业电表 + 燃气表暂不需要,**退役归档**(等新承租人入驻再视情况建新表)。
> [!example] 真实情境(三):法定使用年限到
> 一批 2010 年装的电表到 2026 年达到法定 15 年使用年限。物业评估:
> - 部分表性能仍好 + 业户无投诉 → 送校验,合格继续用([[replace-broken-meter|换表]] 的 `Calibration` reason)
> - 部分表频繁出问题 + 业户投诉 → 退役换新表(`Replaced`)
> - 个别表所在房屋已无人居住 → **退役不换表**(`Expired`)
## 业务人员视角(王主管)
### 第 1 步:确认场景与原因
选择正确的 `decommission_reason`:
| 场景 | 推荐 reason |
|---|---|
| 房屋拆除 | `Removed` |
| 业户永久搬走且无新业户 | `Removed` |
| 法定年限到不续装 | `Expired` |
| 校验送检中暂停(后续可能恢复) | `Calibration`(暂停态,不算永久退役)|
| 损坏不修(整体弃用)| `Damaged` |
| (换新表替代) | `Replaced`**走 [[replace-broken-meter]] 不是本场景** |
### 第 2 步:打开表
后台 → 计量表 → 按 asset 查找 → 进 `ViewMeter`(`is_active=true`)。
### 第 3 步:`EditMeter` 改字段(或专用退役 Action,看 UI 设计)
> [!info] UI 实现注意
> 当前实现可能**没有专门的"退役不换表"按钮**,而是通过:
> - `EditMeter` 页直接改 `is_active=false` + 填 `decommissioned_at` + `decommission_reason` + `final_reading`
> - 或自定义"退役"Action(若实现了)
>
> 看具体 `MeterForm` 的字段开关。若没专用按钮,走 EditMeter。
填字段:
| 字段 | 填什么 |
|---|---|
| `is_active` | **false**(取消勾选)|
| `decommissioned_at` | 2026-05-26 |
| `decommission_reason` | `Removed`(本场景一) / `Expired` / `Damaged` |
| `final_reading` | 退役那天的物理读数(若可读)/ 0(若表已被拆除无法读)|
| 备注 | 关键说明,如 "三期 3-1-101 拆迁,房屋已拆,表无法回读" |
### 第 4 步:提交
系统:
1. 校验旧表 `is_active=true`(`EditMeter` 守护 `->visible(is_active)`)
2. update Meter:`is_active=false`, `decommissioned_at`, `decommission_reason`, `final_reading`
3. **不建新表**(与 [[replace-broken-meter|换表]] 的关键区别)
### 第 5 步:后续不抄表
`MetersNeedingReadingListWidget` 自动**不再显示**此表(`is_active=false` 过滤)。
历史 reading 保留,可查。
## 批量退役
如果是单元拆除(90 张表一起退役),逐张走太慢。可能的批量方案:
| 方案 | 实现 |
|---|---|
| **List 页批量 Edit**(若有 BulkAction) | 选中多张 → 批量改 `is_active=false` |
| **Excel 导入"退役清单"** | 类似 `MeterInitializationImporter`,但当前没此功能(可补)|
| **tinker 脚本**(运维)| `Meter::whereIn('id', [...])->update([...])`,**留 audit log** |
当前推荐:**业务量小的话逐张 EditMeter**;**业务量大的话联系运维**走 tinker(批量改字段 + 留事由记录)。
## 系统流程
```mermaid
sequenceDiagram
participant 王主管
participant Filament
participant EditMeter
participant 数据库
王主管->>Filament: 找到要退役的表 → ViewMeter → 编辑
Filament->>EditMeter: 渲染 form
王主管->>EditMeter: is_active=false + 填 decommissioned_at + reason + final_reading + 备注
EditMeter->>EditMeter: 校验 is_active=true(原状态)+ Policy update 权限
EditMeter->>数据库: update Meter 字段
数据库-->>Filament: ok
Filament-->>王主管: 跳回 ViewMeter,状态显示"已退役 - Removed - 2026-05-26"
```
## 退役后的状态
| 字段 | 退役后值 |
|---|---|
| `is_active` | false |
| `decommissioned_at` | 2026-05-26 |
| `decommission_reason` | `Removed` |
| `final_reading` | 退役当天读数(或 0)|
| `replaced_meter_id` | null(没有继任者)|
| 后续是否能改字段 | **几乎不能**(`EditMeter` `visible(is_active)` 守护 + Policy 拦截)|
| 后续是否能删 | 仅当**无任何 reading** 时(罕见)|
详见 [[decommission-and-locking]]"退役后的行为"段。
## 与"换表"的关键差异
| 维度 | [[replace-broken-meter|换表(Replaced)]] | **退役不换表(本场景)** |
|---|---|---|
| `decommission_reason` | `Replaced` | `Removed` / `Expired` / `Damaged` |
| 是否建新表 | ✅ 是,带 `-R1` 后缀 | ❌ 否 |
| 业户后续是否要付水电费 | 是(用新表继续计费)| 否(无表无计费)|
| 房屋状态 | 仍有人住 | 通常拆 / 撤 / 弃用 |
| 触发 UI | `ReplaceMeterAction`(专用)| `EditMeter`(改字段)|
## 业户视角
业户**通常感知不到**这条系统操作,因为业户本人也搬走 / 房屋已拆。
如果是商铺撤店 / 房屋暂时无人(后续可能有新业户),系统层面表已退役,**未来如有新业户入住要重新装表 → 建新表(走 [[register-single-meter]]),不是"复活"旧表**。
## 常见问题
> [!question] 退役表能"复活"吗(`is_active=false → true`)?
> Policy 设计上**不允许**(`EditMeter` 在 `is_active=false` 时隐藏所有编辑)。如果真需要复活:
>
> - tinker 改字段(运维,留事由)
> - 推荐做法:**建新表**替代,旧表保持退役状态(历史档案)
> [!question] 退役表如何处理未付的历史 Bill?
> 表退役**不影响**已生成的 Bill(Bill 关联 reading 关联表,数据完整)。业户该付的还是要付。
>
> 若业户也搬走联系不上:走逾期催收流程(不在本场景)。
> [!question] 退役不换表后,房屋还在但暂无业户用电怎么办?
> 房屋空置但有可能后续入驻 → **保留表 active 状态**,只是没有抄表数据(零用量)。每月账单可能仍生成(看 RatePlan 是否有 `min_amount`,详见 [[multiplier-and-tiered-pricing|min/max 封顶]])。
>
> 真的永久不需要 → 退役。
> [!question] `decommission_reason` 选错了能改吗?
> 退役后修改字段被 Policy 拦截。若改错只能 tinker 修(运维 + 留事由)。
>
> **预防**:退役前确认场景,选对 reason。
> [!question] 退役了但表上物理装着没拆走怎么办?
> 系统层面无区别(系统不管物理状态,只管"系统层面已退役")。物业人员需要**实际去现场拆表**(若房屋拆迁要拆楼)或**留存**(若只是商铺暂关)。系统不管理物理库存。
> [!question] 表的物理库存追踪?
> 当前系统**不涉及**。物业的"实物计量表"库存(回收、报废、复用)在 ERP / 资产管理系统里处理,不在本子模块。
## 异常分支
- 换表(有新表替代)→ [[replace-broken-meter]]
- 误退役想撤销 → 困难,见上方"复活"问题
- 退役后无历史读数想删表 → 见 [[decommission-and-locking]]"退役表的物理删除"段
## 相关文档
- [[decommission-and-locking]]
- [[replace-broken-meter]]
- [[meter-vs-meter-reading]]
- [[multiplier-and-tiered-pricing]]

View File

@@ -0,0 +1,239 @@
---
title: prop-acc · meter · 场景 - 高用量异常(漏水/电器故障)
aliases:
- 高用量预警
- 漏水告警
- exception-high-consumption
- HighConsumptionReadingsListWidget
- 场景-高用量异常
tags:
- 场景
- prop-acc
- 计量表
- 异常
audience:
- 业务人员
- 业户
- 抄表员
status: 已发布
sub_feature: meter
last_review: 2026-05-26
code_version: 2026-05-22
---
# 场景:高用量异常(漏水/电器故障)
业户**用量异常**(漏水 / 电器故障 / 偷电),系统通过 `HighConsumptionReadingsListWidget` 在月度抄表数据出来后**主动预警**,业务人员介入排查,避免账单生成后业户暴击 / 投诉。
## 典型情境
> [!example] 真实情境
> 5 月底集抄 / 批量抄表完成,王主管打开 `MeterDashboard`:
>
> - `HighConsumptionReadingsListWidget` 显示 **本月用量 top 20** 的 reading
> - 其中前 3 名:
>
> | 业户 | 表 | 上月 | 本月 | 用量 | 异常? |
> |---|---|---|---|---|---|
> | 张阿姨(12-3-501) | 水表 | 12 吨 | **800 吨** | 800 | 🔴 极度异常(漏水) |
> | 陈先生(12-3-502) | 电表 | 200 度 | **2,800 度** | 2,800 | 🔴 异常高(空调故障 / 偷电) |
> | 商铺一楼餐厅 | 电表 | 1,500 度 | **3,200 度** | 3,200 | 🟡 警告(正常波动?旺季?)|
>
> 王主管立即处理。
## 业务人员视角
### Widget 显示
`HighConsumptionReadingsListWidget`(后台 → MeterDashboard):
| 列 | 内容 |
|---|---|
| 业户 / 资产 | 房号 + 业户姓名 |
| 表编号 | meter code |
| 上月用量 | previous reading 推出 |
| 本月用量 | consumption |
| 倍数 | 本月 / 上月 |
| 抄表日期 | read_at |
| 操作 | 链接到 reading 详情 / 查看 / 联系业户 |
排序:**按 consumption 降序 top 20**(简版实现,详见下方"待补")。
### 分级处置
| 级别 | 触发 | 处置 |
|---|---|---|
| 🔴 **极度异常**(>10× 历史平均) | 漏水 / 大型故障 | 立即联系业户 + 派人现场检查 |
| 🔴 **高异常**(>3× 历史平均) | 设备故障 / 习惯改变 | 联系业户确认 |
| 🟡 **警告**(>1.5× 历史平均) | 正常波动 / 季节性 | 推送提醒 / 标记观察 |
| 🟢 **正常** | < 1.5× | 不处理 |
### 处理流程(漏水案例)
```mermaid
flowchart TD
A[Widget 显示张阿姨水 800 吨] --> B[王主管联系张阿姨]
B --> C{业户回应}
C -->|"我不知道,你来看看"| D[派维修队上门]
C -->|"啊?我不可能用这么多"| D
C -->|"我装修了用水多"| E[业户接受账单]
D --> F{查到原因?}
F -->|墙内暗水管漏水| G[维修 + 重算账单]
F -->|无可见漏水点| H[换表测试是否表故障]
H --> I{换表后情况}
I -->|新表用量正常| J[确认旧表故障 + 走修正流程]
I -->|新表用量仍异常| K[排查家电 / 业户习惯]
G --> L[与业户协商:走 max 封顶/部分减免/重算]
J --> L
```
### 修正账单(若证实表故障)
如果证实是表故障 / 抄表错:
1. 走 [[replace-broken-meter|换表]]([[decommission-and-locking|退役旧表]])
2. 旧 reading 已生成 Bill → 走 [[exception-readings-locked-after-bill|作废 Bill]] 流程
3. 重新算正确用量 → 重生成 Bill
4. 业户付正确金额
## `HighConsumptionReadingsListWidget` 实现现状
> [!info] 当前实现简版,issue.md Q5 已标待升级
> 当前:**按 consumption 降序 top 20**,不是统计学意义的异常检测。
>
> issue.md Q5"待补":
>
> - **3σ 异常**(对比历史 3 个月平均的标准差)
> - **倒走告警**(current < previous)
> - **0 读数告警**(可能表故障)
>
> 当前简版的缺陷:
> - 排名靠前的可能是商铺 / 工业表(正常用量大),不是真异常
> - 漏掉"中等用量但异常波动"的住户(从 12 吨涨到 50 吨,绝对数小但相对倍数高)
> - 没区分"业户家用电习惯变了"vs"漏水 / 故障"
业务人员**需自行判断**(看 Widget 内容 + 历史用量对照 + 联系业户)。
## 业户视角
### 您可能收到的联系
物业打电话 / 微信 / 上门:
> 张阿姨您好,您家本月水量异常高(800 吨,平时 12 吨)。请问您家最近是否有装修 / 漏水 / 大量用水活动?如有疑问,我们可派维修队上门检查。
### 您要做的
| 情况 | 您要做 |
|---|---|
| 确实在装修 / 大量用水 | 接受账单 + 看是否触发 [[generate-bill-min-max-cap|max 封顶]] 减免 |
| 不知道原因 / 怀疑漏水 | 同意物业派人上门检查 |
| 怀疑表 / 系统错 | 要求看 [[read-with-photo-proof|抄表照片]] + 派人现场再读一次 |
### 检测到漏水后的减免
物业**通常会减免**(看政策):
- 部分减免(承担一半 / 30%)
- 全免(罕见,看物业宽厚)
- 按"平时月用量"算账(常见)
- max 封顶后业户支付封顶值,差额物业承担
具体看物业与业户的协商 + 物业的"漏水维修保险"理赔。
## 系统流程
```mermaid
sequenceDiagram
participant 集抄/抄表员
participant 系统
participant Widget[HighConsumptionReadingsListWidget]
participant 王主管
participant 业户
集抄/抄表员->>系统: 推 / 录入本月 reading
系统->>系统: 建 MeterReading + 算 consumption
Note over 系统: 月度数据完成
王主管->>Widget: 打开 MeterDashboard
Widget->>系统: SELECT TOP 20 reading ORDER BY consumption DESC
系统-->>Widget: 显示 top 20 异常清单
王主管->>王主管: 看清单 → 分级处置
loop 每个 🔴 异常
王主管->>业户: 联系 + 排查
alt 漏水 / 故障
王主管->>系统: 走修正流程(换表 / 作废 Bill 重算)
else 业户认账
王主管->>系统: 接受 + 触发 max 封顶减免(若适用)
end
end
```
## 高用量的常见原因清单
| 原因 | 业户感知 | 处置 |
|---|---|---|
| **水管漏水**(墙内 / 管井) | 业户不知道,直到账单异常 | 派维修队 + 修管子 + 减免 |
| **马桶漏水**(节流阀坏) | 业户偶尔听到流水声 | 业户自修 / 物业协助 |
| **空调 24h 不关** | 业户习惯 | 业户调整 / 接受账单 |
| **旧冰箱故障**(压缩机一直跑) | 业户不知道 | 业户换冰箱 |
| **电热水器**(储热式漏电 / 一直加热) | 业户不知道 | 业户检查 |
| **业户偷电 / 绕表** | 物业 / 国家电网监管 | 法律责任 |
| **抄表错** | (系统层面)| 走 [[exception-readings-locked-after-bill|修正]] |
| **集抄数据错** | (系统层面)| 同上 |
| **表故障**(乱跳)| 业户长期感觉 | [[replace-broken-meter|换表]] |
| **大型装修 / 大量用水活动** | 业户自知 | 业户接受账单 |
## 常见问题
> [!question] Widget 显示的"top 20"是当月吗?
> 看 Widget 实现。可能是:
>
> - 本月(`read_at` 在当月)
> - 最近 30 天
> - 所有未结账 reading
>
> 业务上推荐"本月",每月初看一遍 + 月底再看一遍。
> [!question] 排查发现是抄表错 + 已经生成 Bill 了怎么办?
> 走 [[exception-readings-locked-after-bill]] 流程:作废 Bill → 修正 reading → 重生成 Bill。复杂,需运维 / 高权限介入。
> [!question] 业户漏水但拒不修怎么办?
> 物业**强烈建议** + **法律手段**(漏水可能影响楼下邻居,涉及侵权)。系统层面无法干预。
> [!question] 商铺 / 工业用户的"高用量"和住宅"高用量"判断标准应该一样吗?
> **不一样**。商铺正常用量本来就大。Widget 当前简版**无区分**,需业务人员自己判断。
>
> 升级建议:Widget 按 `asset_type`(住宅 / 商铺 / 工业)分别统计 + 各自的"异常阈值"。
> [!question] 排查后没找到原因(业户也说没漏水也没新增电器)?
> 几个可能:
> - 业户家有人偷接电(罕见)
> - 表故障(漂移)→ 换表观察一个月
> - 集抄 / 抄表系统 bug(同时多户异常?排查系统)
> [!question] 高用量预警之外,有"用量异常低"(可能业户搬走 / 表故障 0 读数)预警吗?
> 当前**无**(只有高用量 widget)。需求详见 issue.md Q5"待补"。
## 异常分支
- 排查确认是抄表错 → [[exception-readings-locked-after-bill]] 修正
- 排查确认是表故障 → [[replace-broken-meter|换表]]
- 业户接受账单 → 走 [[generate-bill-min-max-cap|max 封顶]](若适用)
- 待抄表清单(对偶场景)→ [[audit-meters-needing-reading]]
## 相关文档
- [[multiplier-and-tiered-pricing]]
- [[generate-bill-min-max-cap]]
- [[exception-readings-locked-after-bill]]
- [[replace-broken-meter]]
- [[audit-meters-needing-reading]]
- [[reading-source-and-photo-proof]]

View File

@@ -0,0 +1,249 @@
---
title: prop-acc · meter · 场景 - 已生成 Bill 的 Reading 锁定,要修正需作废 Bill
aliases:
- Reading 锁定
- 已落账 reading 修正
- exception-readings-locked-after-bill
- 作废 Bill 重算
- 场景-已落账读数修正
tags:
- 场景
- prop-acc
- 计量表
- 异常
- 数据完整性
audience:
- 业务人员
- 架构师
status: 已发布
sub_feature: meter
last_review: 2026-05-26
code_version: 2026-05-22
---
# 场景:已生成 Bill 的 Reading 锁定,要修正需作废 Bill
`MeterReading` 一经创建**不可改**;**一旦生成 Bill 更不可改 / 不可删**(双锁,见 [[decommission-and-locking]])。如果发现已落账 reading 数据错(抄表录错 / 集抄错传 / 业户质疑成功),需走**复杂修正流程**:**先作废 Bill → 改 reading(实际是建新 reading + 标旧 reading 作废)→ 重生成 Bill**。
## 典型情境
> [!example] 真实情境
> 5 月底抄表 + 自动生成账单。陈先生 5 月电费账单 ¥1,200(2,800 度,异常高,触发 [[exception-high-consumption|高用量预警]])。
>
> 物业派人排查,发现:
> - **抄表员李师傅手抖**:把 1,500 录成 2,500
> - 实际本月用量 ~1,500 度,账单应 ¥600 左右(不是 1,200)
>
> 陈先生质疑成功,物业要把账单从 1,200 改成 ~600。但:
> - **Reading 不可改**(系统层面双锁)
> - **Bill 已经生成 + 关联 reading**
>
> 需要走**作废 + 重算**的组合流程。
## 当前实施状态
> [!warning] **当前系统不支持自动化此流程**
>
> issue.md Q5 "待补"段明确记录:
>
> > **"作废已生成 Bill 的 MeterReading"组合流程**:当前 Reading 一旦生成 Bill 即锁定。要修正错误读数需要先**作废 Bill** 再改 Reading 再重新生成。这个组合流程类似 AdHocEvent 的 `VoidAction`(级联废 + 留 voided 审计),需要单独设计。等业务方明确"已收款的 Bill 应该怎么撤销"再做(可能涉及红字 Bill / 退款)。
>
> **当前替代**:运维 / 高权限人员通过 tinker 手工处理。本场景描述**业务流程层**和**未来的目标态**。
## 业务人员视角(当前手工处理)
### 第 1 步:确认要修正
- 业户质疑账单 + 提供合理证据(自家拍照 / 历史数据对照)
- 物业核对:抄表照片 / 集抄数据 / 物理表
- 内部决定:确实要修正
### 第 2 步:看账单是否已付
| 账单状态 | 处理路径 |
|---|---|
| **未付**(Unpaid)| 简单:作废 Bill → 重算 |
| **已付**(Paid,业户付现金/微信)| 复杂:作废 Bill → 业户**退款** → 重新出账单 → 业户**重付** |
| **已付**(用 [[../prepaid/consume-monthly-property-bill|预存款抵扣]])| 复杂:作废 Bill → 预存款**反向充值** → 重新出账单 → 重新抵扣 |
### 第 3 步:走"作废 Bill"流程
当前**没专用 UI** —— 联系运维 / 高权限人员:
```php
// tinker 操作示意(运维)
DB::transaction(function () use ($readingId) {
$reading = MeterReading::find($readingId);
$oldBill = Bill::find($reading->bill_id);
// 1. 作废 Bill
$oldBill->update([
'status' => BillStatus::Voided,
'voided_at' => now(),
'voided_reason' => '抄表录错,需重算',
]);
// 2. 解锁 reading(把 bill_id 设 null,让它可被处理)
$reading->update(['bill_id' => null]);
// 或者:标 reading 作废,新建一条修正 reading
$reading->update(['voided_at' => now(), 'voided_reason' => '录错']);
});
```
### 第 4 步:建修正 reading
如果走"建新 reading"路径:
```php
$correctReading = MeterReading::create([
'meter_id' => $oldReading->meter_id,
'read_at' => $oldReading->read_at, // 同抄表日期
'current_reading' => 1500, // 改成正确值
'source' => MeterReadingSource::Manual,
'operated_by' => $currentAdmin->id,
'memo' => "修正:原 reading #{$oldReading->id} 数值录错(2500 → 1500)",
]);
```
### 第 5 步:重生成 Bill
走 [[bill-generation-pipeline]]:对新 reading 调 `GenerateBillsFromMeterReadingsAction`
### 第 6 步:处理已付款的差额
如果旧账单已被业户付了:
- **业户付现金**:物业现场退差额(¥1,200 - ¥600 = ¥600)
- **微信付**:物业微信退款
- **预存款抵扣**:走"反向 consume"(技术上是 `PrepaidAccount::deposit` 把钱充回 → 然后从新账单扣)
### 第 7 步:通知业户
完整说明:
- 旧账单 ¥1,200 已作废(原因:抄表录错)
- 新账单 ¥600
- 差额已退还(或预存款已回填)
## 未来目标态(待开发)
类似 AdHocEvent 的 `VoidAction`(级联废 + 留 voided 审计):
```mermaid
sequenceDiagram
participant 业务
participant Filament
participant VoidBillAction[待开发]
participant 数据库
业务->>Filament: 找到要作废的 Bill → VoidBillAction
Filament->>VoidBillAction: handle(bill, reason)
VoidBillAction->>数据库: Bill.status=Voided + voided_reason + voided_at
VoidBillAction->>数据库: Reading.bill_id=null(解锁)
VoidBillAction->>数据库: 若已付:建红字 Receipt + 退款 / 预存款回填
Filament-->>业务: 完成
业务->>Filament: 建修正 MeterReading(同 read_at)
Filament->>数据库: 新建 reading
Filament->>VoidBillAction[GenerateBills]: handle(new reading)
数据库->>数据库: 建新 Bill(amount=正确值)
```
## 系统视角:双锁的设计意义
为什么 Reading 创建后不可改(第 1 锁)+ 有 Bill 更不可改(第 2 锁)?
| 反例(若允许改)| 后果 |
|---|---|
| 业务人员直接改 Reading 数据 | 历史 Bill 仍是旧金额,Reading 是新数据 → 不一致 |
| 改 Reading 同时改 Bill | 已付的钱怎么办?业户付了 1200 你改成 600 → 600 凭空消失 |
| 业户已付 + 物业改 Reading 改小金额 | 物业账面收入虚高(实际收 1200,账面记 600,差额 600 不知去向)|
**双锁强制**业务人员走"作废 + 重生成"流程,**留下完整审计痕迹**(旧 Bill voided + 新 Bill 创建 + 退款 / 预存款回填记录)。
## 业户视角
业户**不直接接触系统层**,只感受:
- 提出异议
- 物业核实
- 收到说明:"经核实,5 月账单 ¥1,200 是抄表录错,实际应付 ¥600。已作废原账单,新账单已发,差额 ¥600 已退到您微信"
- 收到**红字凭证**(若已付,作废 + 退款)
- 收到**新账单**
整个流程业户感知:"物业认错 + 退钱 + 重发账单",是**正面体验**(物业承认错误并改正)。
## 流水台账(完整修正过程)
| 时间 | 动作 | Reading | Bill |
|---|---|---|---|
| 5/26 | 抄表录错 | #1(current=2500, bill_id=Bill#A) | Bill#A(amount=1200, Unpaid) |
| 5/27 | 业户质疑 + 核实 | (不变) | (不变) |
| 5/30 | 作废 Bill | #1.voided=true, bill_id=null | Bill#A.status=Voided, voided_reason="抄表录错" |
| 5/30 | 建修正 reading | #2(current=1500, bill_id=null) | (无) |
| 5/30 | 重生成 Bill | #2.bill_id=Bill#B | Bill#B(amount=600, Unpaid) |
| 5/31 | 业户付款 | (不变) | Bill#B.status=Paid |
整条修正记录可审计。
## 常见问题
> [!question] 如果原账单未付,作废就行,不用退款?
> 是的。未付 → 作废 → 不开账单 → 业户没付任何钱。重生成新账单业户直接付新账单即可。
> [!question] 业户已经付了,作废 Bill 之后如何退款?
> 看付款方式:
> - **现金 / 微信 / POS**:物业按渠道退
> - **预存款抵扣**:`PrepaidAccount::deposit` 反向充值(技术上是建一笔 deposit 流水把钱"还"回预存款余额)→ 然后业户继续用预存款付新账单
>
> 当前**没自动化**,需运维 / 业务流程操作。
> [!question] 已经走过红字流程的 deposit/prepaid 模块,meter 为什么没有?
> meter 模块**直接产 Bill 不直接产 Receipt**,与 deposit/prepaid 直接产 Receipt 的模式不同。"红字 Bill" 的设计需要 Bill 模型支持(增加 `voided_at` / `voided_reason` 字段 + 业务流程),issue.md Q5 标记为"待补"。
> [!question] 抄表员经常录错怎么办?
> 系统层面:
> - 强制拍照([[reading-source-and-photo-proof]] + [[read-with-photo-proof]])
> - Form 上显示 previous 读数 + 异常告警(本月差与上月差比超过 X 倍 → 提示)
>
> 业务层面:
> - 抄表员培训
> - 关键抄表数据二次审核(双签)
> - 升级集抄([[read-via-iot-remote-source]])减少人工录入
> [!question] "修正 reading" 是建新 reading 还是改旧?
> 看实现:
> - **改旧**(直接 update)→ 简单但丢失历史(原数据没了)
> - **建新 + 标旧 voided**(推荐)→ 复杂但完整保留审计
>
> 当前 issue.md 倾向"建新 + 标旧 voided"模式(类似 AdHocEvent VoidAction)。需要给 Reading 表加 `voided_at` / `voided_reason` 字段。
> [!question] 长期不修复这个 gap 有什么风险?
> - 业务人员**每次修正都要联系运维**(慢、不可扩展)
> - 修正没有标准化流程 → 容易出错(漏退款 / 漏作废)
> - 审计困难(运维 tinker 操作的痕迹靠日志,不像 UI 操作那么清晰)
> - 业户体验差(响应慢)
>
> 业务方提需求时优先级会上来。
## 相关 issue.md 待补
```
- "作废已生成 Bill 的 MeterReading"组合流程:
类似 AdHocEvent 的 VoidAction(级联废 + 留 voided 审计),需要单独设计。
等业务方明确"已收款的 Bill 应该怎么撤销"再做(可能涉及红字 Bill / 退款)
```
## 异常分支
- 抄表员录错预防 → [[read-with-photo-proof|拍照存证]] + Form 守护
- 高用量触发预警 → [[exception-high-consumption]]
- 表故障导致错读 → [[replace-broken-meter|换表]] + 走本场景修正
## 相关文档
- [[decommission-and-locking]]
- [[bill-generation-pipeline]]
- [[exception-high-consumption]]
- [[replace-broken-meter]]
- [[../adhoc/cancel-amount-error-redo]](adhoc 模块的类似 void 流程参考)

View File

@@ -0,0 +1,273 @@
---
title: prop-acc · meter · 场景 - 单笔账单上下限封顶(防异常用量爆账)
aliases:
- min max 封顶
- 账单封顶
- generate-bill-min-max-cap
- 场景-账单上下限封顶
tags:
- 场景
- prop-acc
- 计量表
- 账单生成
- 异常防御
audience:
- 业务人员
- 财务
- 业户
status: 已发布
sub_feature: meter
last_review: 2026-05-26
code_version: 2026-05-22
---
# 场景:单笔账单上下限封顶(防异常用量爆账)
`RatePlan` 上的 `min_amount` / `max_amount` 字段为单笔账单设置**上下限**:
- **`max_amount`** 防止极端用量(漏水 / 设备故障)导致离谱账单 → 业户友好,但物业承担差额
- **`min_amount`** 防止零用量 / 极低用量逃避基础服务费 → 物业兜底,但业户可能不爽
本场景演示三种触发情境。
## 典型情境
### 情境 1:漏水触发 max 封顶
> [!example] 真实情境
> 张阿姨家**水管漏水**(藏在墙里没发现),5 月用水 **800 吨**(平时 12 吨)。按阶梯计价:
>
> ```
> 0-20 吨段:20 × 3.0 = 60
> 21-30 吨段:10 × 4.5 = 45
> 31-800 吨段:770 × 6.0 = 4,620
> 算出金额:4,725 元
> ```
>
> RatePlan 配置 `max_amount = 1,500`,触发封顶:
>
> ```
> final_amount = min(4725, 1500) = 1500
> ```
>
> 账单 ¥1,500,差额 ¥3,225 物业承担(走维修保险 / 业务减免)。
### 情境 2:零用量触发 min 兜底
> [!example] 真实情境
> 王先生整月**出差不在家**,水表读数无变化(consumption=0)。按阶梯算 = 0 元。但物业配置 `min_amount = 20`,兜底:
>
> ```
> final_amount = max(0, 20) = 20
> ```
>
> 账单 ¥20,理由"基础服务费 / 管网维护费"。王先生抱怨"我没用水为什么收钱?",物业解释:小区水管 / 表的维护是公共成本,按户分摊。
### 情境 3:正常范围,无封顶
> [!example] 真实情境
> 陈先生 5 月用水 35 吨,按阶梯算 135 元。RatePlan 配置 `min_amount=20`, `max_amount=1500`:
>
> ```
> final_amount = max(20, min(135, 1500)) = 135
> ```
>
> 账单 ¥135,封顶规则**不触发**(在合理范围内)。
## min / max 算法
```
if (max_amount !== null && calculated > max_amount) calculated = max_amount;
if (min_amount !== null && calculated < min_amount) calculated = min_amount;
final_amount = calculated;
```
或等价:`final_amount = max(min_amount ?? 0, min(calculated, max_amount ?? INF))`
详见 [[multiplier-and-tiered-pricing|倍率与阶梯计价]]"第 3 层 min/max 封顶"段。
## 系统流程
```mermaid
sequenceDiagram
participant Calc[MeterBillCalculator]
participant Service[MeterBillGenerationService]
participant DB
Calc->>Calc: 阶梯算法 → calculated_amount
Note over Calc: 假设漏水算出 4725
Calc->>Calc: max_amount 检查
alt calculated > max_amount(4725 > 1500)
Calc->>Calc: final = max_amount = 1500
else 在范围内
Calc->>Calc: 通过
end
Calc->>Calc: min_amount 检查
alt final < min_amount
Calc->>Calc: final = min_amount
else 在范围内
Calc->>Calc: 通过
end
Calc-->>Service: 1500
Service->>DB: 建 Bill(amount=1500, sourceable=reading)
Note over DB: 业务上是否记录"封顶减免"?目前 Bill 表无此字段,留作业务备注
```
## 业户视角
### Max 封顶(漏水)
业户收到的账单:
```
2026 年 5 月水费账单
用水量:800 吨 ⚠️ 用量异常高
按阶梯算应付:¥4,725.00
封顶后实付:¥1,500.00
差额减免:¥3,225.00(由物业 / 维修保险承担)
应付:¥1,500.00
```
> [!info] 账单展示封顶信息
> **强烈推荐**账单展示"按阶梯算 X,封顶 Y,差额 Z 由物业承担"。让业户明白封顶的存在 + 物业的友好。
业户会**感激物业封顶**,但更会**自查漏水 / 设备故障**。
### Min 兜底(零用量)
业户收到的账单:
```
2026 年 5 月水费账单
用水量:0 吨
按阶梯算应付:¥0.00
基础费(min):¥20.00
应付:¥20.00
```
业户可能**不接受**:"我没用水为什么收钱?"。物业要解释:
- 水管 / 公共部位维护成本
- 物业服务费的"基础保障"性质(签合同时已告知)
- 法律 / 政策依据(若有)
## 业务人员视角
### 配置 min / max
后台 → 费率管理 → RatePlan → 编辑 → 填字段:
| 字段 | 推荐值(参考)|
|---|---|
| `min_amount` | 10-30 元(看物业 + 费用类型)|
| `max_amount` | 1000-5000 元(看业户类型,商铺可以更高)|
> [!warning] 配置要谨慎
>
> **max 配低**:正常用量也被封顶 → 物业损失收入。例如 max=300,商铺正常月费 ¥500 → 物业每月被减免 ¥200。**严重 bug**。
>
> **max 配高**(或不配):无封顶 → 极端用量爆账户,业户投诉 + 法律风险。
>
> **min 配高**:业户不满 + 投诉。
>
> **配置后用极端值算例验证**(0 度 / 极少 / 正常 / 极高 各算一遍看是否合理)。
### 触发封顶后的业务流程
| 触发 | 业务人员动作 |
|---|---|
| max 触发(异常高用量)| 联系业户排查([[exception-high-consumption]]) → 减免数额可能要审批 |
| min 触发(零 / 极低用量)| 通常无需介入,业户接受 min 即可 |
| 频繁 max 触发 | 评估是否表 / 设备有问题(漏水 / 故障)|
### 封顶减免的会计处理
封顶差额(`max_amount` 触发时,实际应付 vs 物业承担)的会计处理:
| 选项 | 实现 |
|---|---|
| **物业直接承担**(本系统当前简化)| Bill.amount 直接是封顶后金额。账面收入 ¥1,500(实际应是 ¥4,725)→ 物业少收 ¥3,225 |
| **走维修保险**(高大上)| 物业向保险公司报销 ¥3,225,账面通过应收 / 已收 走完整流程 |
| **业户与物业分摊**(罕见)| 部分协议:超过封顶部分 50% 业户 50% 物业 |
当前**最简单实现**:物业直接承担。其他方案需要业务方提需求。
## 财务视角
### 月度报表统计封顶情况
```sql
-- 本月触发 max 封顶的 reading(假设 Bill 不存"封顶前 amount",我们用 reading 算)
SELECT
r.id AS reading_id,
r.consumption,
-- 重算应付(简化,实际要走 Calculator 逻辑)
-- calculate(consumption, ratePlan) AS expected_amount
b.amount AS billed_amount,
-- (expected - billed) AS reduction
rp.max_amount
FROM acc_meter_readings r
JOIN acc_meters m ON r.meter_id = m.id
JOIN fee_types ft ON m.fee_type_id = ft.id
JOIN rate_plans rp ON ft.current_rate_plan_id = rp.id
JOIN acc_bills b ON r.bill_id = b.id
WHERE b.amount = rp.max_amount -- 简化判断:Bill 金额刚好等于封顶值 = 大概率封顶触发了
AND b.created_at BETWEEN '2026-05-01' AND '2026-05-31';
```
业务用途:看月度物业因封顶减免多少收入。若太多 → 调整 max 或排查根因(频繁漏水 / 设备故障)。
## 常见问题
> [!question] min_amount 是 0 / null 时不兜底?
> 是的。若 `min_amount=null`,系统不兜底,零用量账单 = 0 元(可能不开账单)。物业政策决定是否兜底。
> [!question] max 触发后业户反悔说"我自查没漏水,你怎么算出我用 800 吨的?"
> 业务人员排查:
> - 看 reading 数据(读数对吗?抄表照片有吗?)
> - 派人现场核对物理表
> - 找漏水点(物业派维修人员)
> - 若证实表故障 → 走 [[replace-broken-meter|换表]],并重算账单(走 [[exception-readings-locked-after-bill|修正流程]])
>
> 若所有证据都指向"确实用了 800 吨"(且业户家有漏水迹象)→ 业户认账,封顶后金额是优惠了。
> [!question] min 触发后业户拒付怎么办?
> 物业说服 + 法律协议层面要求(物业合同里通常有"基础服务费"条款)。坚决拒付 → 进入逾期催收。
> [!question] 不同业户(住宅 vs 商铺)封顶不同可以吗?
> 看 RatePlan 设计。当前可能"每个 FeeType 一份 RatePlan",所以同 FeeType 共用 min/max。如果要区分,可:
>
> - 给商铺单独建 FeeType + RatePlan
> - 或扩展 RatePlan 支持多档 min/max(改 schema)
> [!question] 跨月用量(忘了抄一个月,两个月用量算一笔)会触发 max 吗?
> 可能会(双月用量翻倍)。**预防**:不要漏抄(走 [[audit-meters-needing-reading|审计]] 监控)。漏抄了发现:
> - 把这笔大账单**人工拆**成两个月(系统不直接支持,业务流程做)
> - 或当作正常账单收 + 与业户沟通
> [!question] 封顶后差额怎么入账?
> 当前最简单实现:Bill.amount 直接是封顶后金额,差额不入账(物业默默承担)。
>
> 严格会计:差额应记入"管理费用 / 维修保险报销 / 服务减免"科目。需扩展 schema 才能精确处理。
## 异常分支
- 阶梯计价(本场景叠加)→ [[generate-bill-tiered-pricing]]
- 工业表倍率叠加 → [[generate-bill-with-multiplier]]
- 异常高用量(可能触发 max)→ [[exception-high-consumption]]
- 读数错误导致离谱算账 → [[exception-readings-locked-after-bill]] 修正
## 相关文档
- [[multiplier-and-tiered-pricing]]
- [[bill-generation-pipeline]]
- [[generate-bill-tiered-pricing]]
- [[generate-bill-with-multiplier]]
- [[exception-high-consumption]]

View File

@@ -0,0 +1,246 @@
---
title: prop-acc · meter · 场景 - 阶梯水电价生成账单(progressive 累进)
aliases:
- 阶梯计价账单
- tiered pricing 算例
- generate-bill-tiered-pricing
- 场景-阶梯计价生成账单
tags:
- 场景
- prop-acc
- 计量表
- 账单生成
audience:
- 业务人员
- 财务
- 业户
status: 已发布
sub_feature: meter
last_review: 2026-05-26
code_version: 2026-05-22
---
# 场景:阶梯水电价生成账单(progressive 累进)
业户**用量超过低阶梯**进入更高阶梯时,系统按 **progressive 累进** 算账单(每段用量按各自单价,**不是**整月按最高阶梯)。本场景完整演示算例。
## 典型情境
> [!example] 真实情境
> 张阿姨 5 月用水 35 吨(平时 12-15 吨,本月浇花 + 装修),嘉禾花园的水费阶梯如下:
>
> | 阶梯 | 月用水(吨)| 单价(元/吨)|
> |---|---|---|
> | 第一阶梯 | 0-20 | 3.0 |
> | 第二阶梯 | 21-30 | 4.5 |
> | 第三阶梯 | 30+ | 6.0 |
>
> 张阿姨 5 月用 35 吨,本月水费应是多少?
**Progressive 累进算法**(本系统采用):
```
段 1(0-20 吨):20 × 3.0 = 60 元
段 2(21-30 吨):10 × 4.5 = 45 元
段 3(31-35 吨): 5 × 6.0 = 30 元
合计: 135 元 ✅
```
**Full-tier 简陋实现**(错!本系统未采用):
```
35 吨 × 6.0(整月按最高阶梯) = 210 元 ❌
```
差额 ¥75。简陋实现对业户极不公平,**是市场上劣质系统的常见 bug**。本系统 `MeterBillCalculator::calculateTiered()` 实现的是 progressive,业户收到的账单准确。
## 系统流程
```mermaid
sequenceDiagram
participant 抄表完成
participant Action[GenerateBillsFromMeterReadingsAction]
participant Service[MeterBillGenerationService]
participant Calc[MeterBillCalculator]
participant 数据库
抄表完成->>Action: handle([5月 reading,consumption=35])
Action->>Service: generateBillForReading(reading)
Service->>数据库: 查 meter.fee_type=水费 → 查 RatePlan + RateTier(3 个 tier)
Service->>Calc: calculate(consumption=35, ratePlan, min, max)
Calc->>Calc: calculateTiered(35, [tier1, tier2, tier3])
Note over Calc: 段 1:min(35,20)-0=20 → 20*3=60
Note over Calc: 段 2:min(35,30)-20=10 → 10*4.5=45
Note over Calc: 段 3:35-30=5 → 5*6=30
Note over Calc: 总和 135
Calc-->>Service: 135
Service->>Service: clamp(135, min, max) = 135
Service->>数据库: 建 Bill(amount=135, sourceable=reading)
Service->>数据库: 更新 reading.bill_id
Service-->>Action: ok
```
## `calculateTiered()` 算法(伪代码)
```php
function calculateTiered(float $consumption, Collection $tiers): float
{
$amount = 0.0;
$remaining = $consumption;
foreach ($tiers as $tier) {
if ($remaining <= 0) break;
$tierRange = $tier->upper ? $tier->upper - $tier->lower : INF;
$consumedInTier = min($remaining, $tierRange);
$amount += $consumedInTier * $tier->unit_price;
$remaining -= $consumedInTier;
}
return $amount;
}
```
详见 [[multiplier-and-tiered-pricing|倍率与阶梯计价]] 概念。
## 业户视角
### 您收到的账单(简化版)
```
5 月水费账单
用水量:35 吨
明细:
0-20 吨段:20 × 3.0 = 60 元
21-30 吨段:10 × 4.5 = 45 元
31+ 吨段: 5 × 6.0 = 30 元
合计:135 元
应付:¥135.00
```
> [!info] 账单明细的展示
> 当前 Bill / Receipt 是否展示阶梯明细取决于模板设计。**强烈推荐展示**:
>
> - 业户能看清"为什么收 135 不是 105"
> - 政策合规(国家阶梯水电价要求公开透明)
> - 减少业户疑问
### 用得越多越贵的教育意义
阶梯计价**鼓励节约**:
| 用量 | 总水费 | 平均单价 |
|---|---|---|
| 15 吨(低用)| 45 | 3.0 |
| 35 吨(本场景)| 135 | 3.86 |
| 60 吨(浪费)| 270 | 4.5 |
业户**用越多平均单价越高**,符合"超额消费多付费"的政策导向。
## 业务人员视角
### 配置阶梯
阶梯定义在 `RatePlan` + `RateTier`(不在 meter 子模块,通常运营 / 财务总监配):
- 后台 → 费率管理 → 选水费 → 编辑 RatePlan
- 加 RateTier:`tier=1, lower=0, upper=20, unit_price=3.0`
- 加 RateTier:`tier=2, lower=20, upper=30, unit_price=4.5`
- 加 RateTier:`tier=3, lower=30, upper=null, unit_price=6.0`(upper=null 表示无上限)
> [!warning] 阶梯配置要严谨
> 配置错的常见症状:
> - 段不连续(`tier1 upper=20`, `tier2 lower=22`)→ 21 吨用量无法分配
> - 段重叠(`tier1 upper=20`, `tier2 lower=18`)→ 18-20 吨段算两次
> - 缺最高段(没有 `tier_max`)→ 超过最高阶梯的用量无单价
>
> 业务人员配置完后**用极端值算例**验证(0 / 1 / 20 / 21 / 30 / 31 / 100 / 1000 吨各算一遍看是否合理)。
### 月度账单生成
抄表完成 → 业务人员触发 `GenerateBillsFromMeterReadingsAction`(或自动)→ 系统调 `MeterBillCalculator` → 每张表算金额 → 建 Bill。
### 异常处理
阶梯计价的常见异常:
| 异常 | 处置 |
|---|---|
| 业户用量极高(> 100 吨) | [[exception-high-consumption|高用量预警]] → 排查是否漏水 / 设备故障 |
| 业户用量极低(0 吨)| `min_amount` 兜底,详见 [[generate-bill-min-max-cap]] |
| 业户用量倒走(reading 错)| 走 [[exception-readings-locked-after-bill|修正流程]] |
## 财务视角
### 账面会计
阶梯账单的**总金额**仍归"水费收入"科目。无需按阶梯拆分入账。
阶梯只影响**业户感知**(单价不同)和**业户行为引导**(鼓励节约),不影响会计核算。
### 报表统计
业务可能想看"本月各阶梯段用量分布"(政策报告 / 节约成效),需要单独的报表 SQL:
```sql
-- 本月各阶梯段用量分布(简化版,真实算法更复杂)
SELECT
SUM(LEAST(consumption, 20)) AS tier1_volume,
SUM(GREATEST(LEAST(consumption, 30) - 20, 0)) AS tier2_volume,
SUM(GREATEST(consumption - 30, 0)) AS tier3_volume
FROM acc_meter_readings
WHERE meter_id IN (SELECT id FROM acc_meters WHERE community_id=? AND fee_type_id=)
AND read_at BETWEEN '2026-05-01' AND '2026-05-31';
```
## 常见问题
> [!question] 阶梯按月 / 按年?
> 看物业政策:
> - **按月**(常见):每月 reset,从段 1 开始算
> - **按年**(部分地区):全年累计,跨段更慢
>
> 当前系统**按月**(单次抄表 = 单段计价)。按年的话需要不同算法(累加去年 12 月以来的用量,再分阶梯)。
> [!question] 不同物业 / 不同社区可以有不同阶梯吗?
> 可以。`RatePlan` 按 `community_id` + `fee_type_id` 隔离。每个社区独立配置。
> [!question] 阶梯改了,历史 Bill 怎么办?
> 历史 Bill 不变(`Bill.amount` 是当时算出的,不动态查 RatePlan)。新 Bill 按新阶梯算。
>
> 这是正确做法(已发账单不应因配置变化追溯改金额)。
> [!question] 业户对算法有疑问怎么解释?
> 给业户看明细(段 1 + 段 2 + 段 3)+ 阶梯单价表。绝大多数业户看懂后接受。
> [!question] progressive 算法的边界用量(如 20 吨整)算哪段?
> 看实现细节:
> - `consumption=20`:段 1 全部(20 × 3 = 60),段 2 / 3 不进入
> - `consumption=20.01`:段 1(20 × 3 = 60)+ 段 2(0.01 × 4.5 ≈ 0.045)
> - 边界是 inclusive 还是 exclusive 看 RateTier 配置(`lower`/`upper` 字段语义)
> [!question] 阶梯计价对工业表(multiplier > 1)的影响?
> 倍率 + 阶梯叠加:`consumption = (current - previous) × multiplier`,然后这个 consumption 走阶梯。例如三相工业表 multiplier=10:
>
> - 物理表头读数差 28 度 → consumption = 280 度
> - 280 度走阶梯计算
>
> 详见 [[generate-bill-with-multiplier]]。
## 异常分支
- 工业表倍率参与 → [[generate-bill-with-multiplier]]
- 异常用量触发 min/max → [[generate-bill-min-max-cap]]
- 用量异常高(漏水)→ [[exception-high-consumption]]
## 相关文档
- [[multiplier-and-tiered-pricing]]
- [[bill-generation-pipeline]]
- [[meter-vs-meter-reading]]
- [[generate-bill-with-multiplier]]
- [[generate-bill-min-max-cap]]

View File

@@ -0,0 +1,234 @@
---
title: prop-acc · meter · 场景 - 工业表 10x 倍率生成账单
aliases:
- 工业表账单
- multiplier 计算
- generate-bill-with-multiplier
- 场景-倍率表生成账单
tags:
- 场景
- prop-acc
- 计量表
- 账单生成
- 倍率
audience:
- 业务人员
- 财务
- 抄表员
status: 已发布
sub_feature: meter
last_review: 2026-05-26
code_version: 2026-05-22
---
# 场景:工业表 10x 倍率生成账单
商铺 / 集团表 / 三相工业表的物理表头**只显示用量的 1/10 或 1/100**,需要乘以倍率(multiplier)才是实际用量。系统在 `consumption = (current - previous) × multiplier` 公式中自动处理。
## 典型情境
> [!example] 真实情境
> 嘉禾花园 1 楼商铺(中式餐厅)装的是**三相工业电表**(multiplier=10)。本月抄表:
>
> - 上月 reading.current_reading = 280
> - 本月 reading.current_reading = 308
> - 物理表头读数差 = 28
>
> **实际用电量** = 28 × **10** = **280 度**(不是 28 度!)
>
> 按电费阶梯(0-200 度 0.8 元,200+ 度 1.0 元)算:
>
> ```
> 段 1:200 × 0.8 = 160 元
> 段 2:80 × 1.0 = 80 元
> 合计: 240 元
> ```
>
> 商铺老板看到账单 ¥240 / 280 度。
如果**没有 multiplier**:
```
consumption = (308 - 280) × 1 = 28 度
amount = 28 × 0.8 = 22.4 元 ❌
```
少收 ¥217.6,商铺白用 252 度电。**物业必须用倍率算账,这是工业表的天然属性**。
## 系统流程
```mermaid
sequenceDiagram
participant 抄表员
participant Filament
participant Calc[MeterBillCalculator]
participant 数据库
抄表员->>Filament: 录入 reading current=308(物理表头)
Filament->>Filament: 查 meter.multiplier=10
Filament->>Filament: 查 previous_reading=280(上月)
Filament->>Filament: consumption = (308 - 280) × 10 = 280 度
Filament->>数据库: 建 MeterReading(consumption=280)
Note over 数据库: 后续 GenerateBills
Filament->>Calc: calculate(consumption=280, ratePlan)
Calc->>Calc: 阶梯算法 200*0.8 + 80*1 = 240
Calc-->>Filament: 240
Filament->>数据库: 建 Bill(amount=240)
```
## 抄表员视角(李师傅)
### 抄表录入
抄表员看到物理表头是 **308**(不是 3080),录入时填 **308**
> [!warning] 抄表员不应自己乘倍率
>
> **错误做法**:抄表员看到 308 + 知道倍率 10 → 录入 3080
>
> **后果**:系统再乘一次 10 → consumption = (3080 - 2800) × 10 = 2800 度 → 收业户 2800 度电费 → 业户疯狂投诉
>
> **正确做法**:**严格录入物理表头数字**(308),倍率由系统自动处理。
Form 上应显示 meter 的 multiplier 提醒抄表员:
```
表编号:E-COMMERCIAL-1
倍率:10x(工业表)
上次读数:280
本次读数:[___] (请录入物理表头数字)
```
### 与家用表的对比
| 维度 | 家用表(multiplier=1)| 工业表(multiplier=10/100/...)|
|---|---|---|
| 抄表员录入 | 表头数字 = 实际用量 | 表头数字 ≠ 实际用量 |
| 是否需要换算 | 不需要 | 需要(系统自动)|
| 抄表员错误风险 | 低 | **中**(可能录乘 / 不乘 10) |
| 业务培训 | 简单 | **需培训**(说清楚"录原始数字")|
## 业务人员视角
### 配置倍率
建表时 `MeterForm.multiplier` 字段填:
| 表类型 | 推荐 multiplier |
|---|---|
| 家用单相电表 | 1 |
| 家用水表 | 1 |
| 三相工业电表 | 10(常见)|
| 大型工业电表(高压侧) | 100 / 1000 |
| 工业大流量水表 | 10 / 100 |
具体看表的物理铭牌 + 设计图纸。
### 倍率改了如何处理
> [!warning] 倍率改动影响极大
> `multiplier` 改了不会重算历史 reading 的 consumption(那些数据已经存了)。但**会影响后续抄表**。
>
> **何时可改 multiplier**:
> - 表是 `is_active=true`(退役表不可改,见 [[decommission-and-locking]])
> - 表**没有任何已生成 Bill 的 reading**(若有,改 multiplier 让历史 vs 现在的算法不一致,审计困难)
>
> **推荐做法**:倍率配错 → 退役旧表 → 建新表用正确 multiplier(不走更换链,因为不是物理换表)。详见 [[decommission-without-replacement]]。
## 业户视角(商铺老板)
### 您看到的账单
```
2026 年 5 月电费账单
用电量:280 度
(本月表头读数 308,上月 280,差 28 × 倍率 10)
明细:
0-200 度段:200 × 0.8 = 160 元
201+ 度段: 80 × 1.0 = 80 元
合计:240 元
```
> [!info] 账单展示倍率信息
> 强烈推荐账单 / Receipt 展示"原始读数 + 倍率 + 计算公式",让业户看明白:
>
> - 业户看到差 28 度,但收 240 元,会疑惑(单价怎么算?)
> - 展示"28 × 倍率 10 = 280 度,按 280 算"业户秒懂
### 商铺老板的特殊关注
| 业户疑问 | 应答 |
|---|---|
| "为什么我家是工业表不是家用表?" | 商铺用电量大,法规要求工业表 |
| "倍率是物业定的吗?" | 不是,是物理表的属性,出厂时定 |
| "可以换成家用表吗?" | 不能,商铺合规上必须用工业表 |
## 倍率与阶梯的叠加
倍率**先算**,得到 consumption;然后 consumption 走阶梯。两者完全独立、按顺序应用。
完整公式:
```
consumption = (current - previous) × multiplier # 第 1 层
amount = sum(段 i 用量 × 段 i 单价) # 第 2 层(阶梯)
amount = clamp(amount, min, max) # 第 3 层(min/max 封顶)
```
详见 [[multiplier-and-tiered-pricing]] 完整说明。
## 不同 multiplier 表的算例
| 表类型 | multiplier | 表头读数差 | consumption | 单价 0.8 | 账单 |
|---|---|---|---|---|---|
| 家用 | 1 | 280 | 280 | 224 | 224 |
| 三相工业 | 10 | 28 | 280 | 224 | 224 |
| 大工业(高压侧) | 100 | 2.8 | 280 | 224 | 224 |
**同样 280 度,账单相同**,只是物理表头数字大小不同。系统的 multiplier 字段把这层差异隐藏在算法里。
## 常见问题
> [!question] multiplier 必须是整数吗?
> 不必须。decimal(10,4) 精度,支持 0.5 / 1.25 等小数。但 1.0 / 10.0 / 100.0 是市场常见的"整数倍率"。
> [!question] 同一表的 multiplier 会随时间变吗?
> 物理上**不会**。表的物理参数(变压比)是出厂时确定的,使用过程中不变。
>
> 系统层面**可改字段**(若 `is_active=true` 且无 Bill,Policy 允许),但**不推荐**改 —— 历史与现在的算法不一致,审计困难。
> [!question] 抄表员录入时如何区分"录物理读数"vs"录乘倍率后读数"?
> Form 上**明确显示倍率**+ 标注"请录入物理表头数字"。培训抄表员**严格遵守**。系统统一存物理表头数字。
> [!question] 集抄系统推过来的数据是物理读数还是乘了倍率?
> 看集抄运营商。**通常是物理读数**(IoT 设备读表头,不知道倍率)。本系统接收时按 `meter.multiplier` 算 consumption。
>
> 如果集抄推已乘倍率的数据 → 集抄端**降级处理**(本系统 multiplier 应该设为 1,避免重复乘)。需对接时讲清楚。
> [!question] 配置错倍率(应该 10 配成 1)会怎样?
> 系统按 multiplier=1 算 → consumption 缩小 10 倍 → 业户账单缩小 10 倍 → 物业损失收入(直到发现)。
>
> 发现后修复:
> - 改 multiplier=10(若 Policy 允许)
> - 修复历史已欠收(走"补开账单"业务流程,系统不直接支持)
> - 通知业户 + 退还 / 补收差额
## 异常分支
- 阶梯计价(本场景叠加)→ [[generate-bill-tiered-pricing]]
- min/max 封顶 → [[generate-bill-min-max-cap]]
- 倍率配错想改 → 复杂,通常退役旧表新建([[decommission-and-locking]])
- 抄表员录错乘倍率 → [[exception-readings-locked-after-bill]] 修正
## 相关文档
- [[multiplier-and-tiered-pricing]]
- [[bill-generation-pipeline]]
- [[meter-vs-meter-reading]]
- [[generate-bill-tiered-pricing]]
- [[generate-bill-min-max-cap]]

View File

@@ -0,0 +1,185 @@
---
title: prop-acc · meter · 场景 - 新社区批量建表 + 初始读数 Excel 导入
aliases:
- 批量建表
- 新社区计量表初始化
- init-new-community-batch
- 场景-新社区批量建表
tags:
- 场景
- prop-acc
- 计量表
- 表管理
- 批量导入
audience:
- 业务人员
- 抄表员
status: 已发布
sub_feature: meter
last_review: 2026-05-26
code_version: 2026-05-22
---
# 场景:新社区批量建表 + 初始读数 Excel 导入
物业**新接管社区**(或老社区从 0 接入本系统),需要**批量建表 + 录初始读数**。通过 `MeterInitializationImporter` + `ImportActionWithExcel` 一次性导入。
## 典型情境
> [!example] 真实情境
> 平台新签了"嘉禾花园"社区,本月底接管。该社区有 300 户业主 + 公共部位 + 商铺,合计约 1,200 张表(每户水电气 3 张 + 公共部位 + 商铺各类)。
>
> 物业财务王主管不可能手工建 1,200 张表,**走批量导入**:
>
> 1. 抄表员李师傅 + 物业前任团队 出 Excel 表(包含每张表的房号、表号、初始读数)
> 2. 王主管在系统下载"建表初始化模板"
> 3. 把数据填入模板 → 上传 → 系统批量建表
## 业务人员视角
### 第 1 步:下载初始化模板
后台 → 计量表 → 列表 → 顶部 **"下载初始化模板"** 按钮(`ExportMeterInitializationTemplateAction`)。
下载到的 Excel 包含:
| 列 | 说明 | 示例 |
|---|---|---|
| 房号 / 资产编号 | 关联 asset(必填,系统按房号查 asset_id) | 12-3-501 |
| 费用类型 | 水费 / 电费 / 燃气费(必填) | 电费 |
| 表编号 | 物理表牌号(必填) | E-501 |
| 倍率 | multiplier(可选,默认 1)| 1 |
| 初始读数 | initial_reading(必填,首次接管时表上的读数)| 0 / 8523 / etc. |
| 安装日期 | installed_at(可选,默认导入日)| 2026-05-26 |
| 备注 | (可选) | "新装" |
模板列名清晰,业务人员 / 抄表员看得懂。
> [!warning] 模板列含义"双义"问题(已知 issue)
> 当前 `MeterInitializationImporter` 用**一个 Importer 处理两种 Excel layout**(住宅单元 vs 商铺/公共)。某些列 label 是"双义"形式,导入选项里选错 `asset_type` 不报错,只是数据写到错误字段(silent corruption)。issue.md Q5 已记录,待拆成两个独立 Importer。**当前预防**:务必仔细选 asset_type。
### 第 2 步:填写数据
物业 / 抄表员把 1,200 张表的信息填入模板。
关键字段对齐:
- 房号:对应系统里 asset 表已存在的编号(若不存在,先到 community 模块建 asset)
- 费用类型:对应系统配置的 FeeType(水/电/燃气,各社区独立配置)
- 表编号:物业自编(常见 `<费用类型简写>-<房号>` 模式)
- 初始读数:**接管当天**的物理表读数(关键!首次接管之前的用量物业不管)
### 第 3 步:上传 + 导入
后台 → 计量表 → 列表 → 顶部 **"导入初始化"** 按钮(`ImportActionWithExcel` + `MeterInitializationImporter`)→ 选 asset_type(住宅 / 商铺)→ 上传 Excel → 提交。
系统:
1. **解析 Excel**(走 `BaseImporter` + `ImportActionWithExcel`,支持 .xlsx / .xls / .csv)
2. **每行校验**(房号 asset 存在?费用类型存在?表编号是否在该社区重复?)
3. **批量建 Meter**(每行一张 Meter 记录)
4. **可选:同时建第一条 MeterReading**(若模板含"初始读数",建初始 reading 来锁定 `previous_reading` 起点)
5. **报告**:成功 N 张,失败 M 张 + 每条失败的原因
> [!info] BaseImporter + chunk rollback
> 走 `App\Filament\Importers\BaseImporter`(host 基类,详见 saas-baseline 规范)+ `TransactionalImportCsv` job。一批 100 行任意一行失败 → 该批全回滚。
>
> 这避免"部分建好部分没建"的脏中间态。失败的批可下载"失败行" Excel,修复后再导入。
### 第 4 步:核对
导入后:
- 后台 → 计量表 → 按社区过滤 → 看是否 1,200 张表都在
- 抽样核对:打开几张表看初始读数对不对
### 第 5 步:启动月度抄表
接管下一个月 → 抄表员去现场抄读数(走 [[read-batch-via-excel-import]] 或 [[read-single-meter-manual]])→ 系统按 `current - initial × multiplier` 算用量 → 生成第一份账单(走 [[bill-generation-pipeline]])。
## 系统流程
```mermaid
sequenceDiagram
participant 王主管
participant Filament
participant ImportActionWithExcel
participant MeterInitializationImporter
participant 数据库
Note over 王主管: 已填好 1200 行 Excel
王主管->>Filament: ListMeters → 导入初始化 → 选 asset_type + 上传
Filament->>ImportActionWithExcel: parse .xlsx
ImportActionWithExcel->>MeterInitializationImporter: 按 chunk 处理(100 行/批)
loop 每批
MeterInitializationImporter->>数据库: 开启事务
loop 每行
MeterInitializationImporter->>数据库: 校验 asset / fee_type / code 唯一
alt 校验通过
MeterInitializationImporter->>数据库: 建 Meter + 可选 initial MeterReading
else 校验失败
MeterInitializationImporter->>MeterInitializationImporter: 收集失败行
end
end
alt 全成功
MeterInitializationImporter->>数据库: 提交
else 任一失败
MeterInitializationImporter->>数据库: 回滚整批
end
end
MeterInitializationImporter-->>Filament: 报告 "成功 1198,失败 2"
Filament-->>王主管: 通知 + 下载失败行 Excel
```
## 业户视角
业户**不感知**这一步。新接管社区会发个公告"本月起本物业系统升级,各位业户的水电气计量将从 X 月 X 日起按本系统记账"。具体业户看到的:
- 接管前最后一份账单(由前任物业 / 自建系统出)
- 接管后第一份账单(由本系统出,用量从接管那天起算)
中间**绝不能有"重复账单"或"漏账"** —— 接管时的 `initial_reading` 必须准确反映物理表当时读数。
## 常见问题
> [!question] 为什么需要"初始读数"?
> 系统计算用量公式是 `(current - previous) × multiplier`。新表的"上一次读数"在系统里没有,所以接管时存的 `initial_reading` 就是"`previous_reading` 的起点"。后续每月抄表 → 当前 - 上次 = 用量。
>
> 如果不填初始读数 → 第一次抄表算用量会爆炸(`current - 0 = 所有历史用量`),业户被收一笔巨账,投诉。
> [!question] 导入失败的常见原因?
> - **房号(asset)不存在**:在 community 模块的 asset 还没建好。先建 asset 再导入表
> - **费用类型不存在**:RatePlan 没配置。先到 FeeType 配置
> - **表编号在该社区重复**(社区内 code 应唯一,虽然 issue.md Q5 提到目前是 nullable + 非 unique 的"待治理"状态)
> - **倍率格式错**(非数字 / 负数)
> - **初始读数格式错**(非数字 / 负数)
> [!question] 失败的行可以单独处理吗?
> 可以。导入完成后系统提供"下载失败行"Excel(走 host 的 `TransactionalImportCsv` 机制),业务人员修复后单独导入失败行。已成功的不影响。
> [!question] 同一社区多次导入会重复建表吗?
> 视 Importer 实现。若有 unique 校验(asset_id + fee_type_id 不可重复)→ 重复行会失败,需手工合并。若无校验 → 重复建,**灾难**。
> [!question] 老社区已经有一年的抄表历史,接管时怎么办?
> 简化做法:**只导入接管那天的状态**(initial_reading = 接管那天的物理读数),历史数据**不进系统**(在 Excel 备查)。
>
> 复杂做法:**导入历史 reading 数据**,让本系统有完整历史。需要业务方决定(用户对账复杂度 vs 系统数据完整度的权衡)。
> [!question] 商铺表 / 公共部位表怎么导入?
> 同样走 `MeterInitializationImporter`,但**选不同 asset_type**(public / shop)。导入时系统按 asset_type 决定列含义。详见 issue.md Q5"双义列名"问题。
## 异常分支
- 单张新表(后续装机 / 个别加表)→ [[register-single-meter]]
- 老表换新表 → [[replace-broken-meter]]
- 抄表(初始化后日常)→ [[read-batch-via-excel-import]] / [[read-single-meter-manual]]
## 相关文档
- [[meter-vs-meter-reading]]
- [[register-single-meter]]
- [[read-batch-via-excel-import]]
- [[bill-generation-pipeline]]

View File

@@ -0,0 +1,228 @@
---
title: prop-acc · meter · 场景 - 一次导入整月所有读数(Excel 批量)
aliases:
- 批量抄表
- Excel 导入抄表
- read-batch-via-excel-import
- MeterReadingsImporter
- 场景-Excel 批量抄表
tags:
- 场景
- prop-acc
- 计量表
- 抄表
- 批量导入
audience:
- 业务人员
- 抄表员
status: 已发布
sub_feature: meter
last_review: 2026-05-26
code_version: 2026-05-22
---
# 场景:一次导入整月所有读数(Excel 批量)
中型物业(几百到上千张表)、未上集抄系统的,抄表员**月底一次性**把全社区抄表数据填入 Excel,业务人员**上传导入**。
## 典型情境
> [!example] 真实情境
> 嘉禾花园 300 户 + 公共部位 + 商铺,合计 1,200 张表(水电气混)。未上集抄。抄表员李师傅 + 团队**每月最后一周**集中抄表 5-7 天,完成后:
>
> 1. 整理 Excel(1,200 行,每行一张表的本月读数)
> 2. 王主管下载"抄表读数模板"
> 3. 抄表数据填入模板对应列
> 4. 上传导入
## 业务人员视角
### 第 1 步:下载抄表模板
后台 → 计量表 → 列表 → 顶部 **"下载抄表模板"** 按钮(`ExportMeterReadingsAction`,**注意**:命名误导,实际是下载模板)。
> [!info] 命名问题(issue.md Q5)
> `ExportMeterReadingsAction` 的名字让人以为是"导出读数"(把数据从系统导出来),实际是**"下载抄表模板"**(给抄表员填的空模板,预填上次读数)。issue.md Q5 待补:重命名为 `DownloadMeterReadingsTemplateAction`。
下载的 Excel 包含:
| 列 | 说明 | 预填 |
|---|---|---|
| 房号 | asset 编号 | ✅ 系统填(对应每张已建表) |
| 表编号 | meter code | ✅ 系统填 |
| 费用类型 | 水/电/燃气 | ✅ 系统填 |
| 上次读数 | 上月该表 reading | ✅ 系统填(供抄表员对比)|
| **本次读数** | 本月该表 reading | **❌ 空,抄表员填**|
| 抄表日期 | 本次 read_at | **❌ 空,抄表员填**(或默认月底)|
| 备注 | 选填 | ❌ |
预填上次读数让抄表员**对比时有参考**(本次 > 上次 才合理),也防止漏填行(看清楚每行该填什么)。
### 第 2 步:抄表员填本月数据
抄表员李师傅团队**整月**(或月末几天)做这事:
- 现场抄表 + 拍照(用 App / 纸质本)
- 数据填入模板
- 完成后交回王主管
### 第 3 步:上传导入
后台 → 计量表 → 列表 → 顶部 **"导入抄表数据"** 按钮(`ImportActionWithExcel` + `MeterReadingsImporter`)→ 选 asset_type → 上传 → 提交。
系统:
1. 解析 Excel(走 `BaseImporter`)
2. 每行校验(meter 存在?asset 匹配?读数合法?)
3. 批量建 MeterReading(每行一条,`source=manual`,`operated_by=导入操作员`,无 photo_url —— 走批量没拍照)
4. 算 consumption
5. 报告:成功 N 条,失败 M 条
> [!warning] 批量导入无 photo_url
> Excel 模板没法批量上传照片。批量导入的 reading **photo_url 为空**。
>
> 业务上推荐:**抄表员独立留照片**(手机相册按月归档),业户事后争议时翻照片(虽然不在系统里)。或者:
> - 高争议表(住宅)走 [[read-single-meter-manual|单录 + 拍照]]
> - 低争议表(商铺 / 公共)走批量导入(节省时间)
### 第 4 步:核对
导入后:
- `MetersNeedingReadingListWidget` 显示"本月未抄表"清单 → 此时应 0 条(或个别遗漏)
- 抽样验证几张表的 reading 数据正确
- `HighConsumptionReadingsListWidget` 看是否有异常用量
### 第 5 步:触发账单生成
导入完成后:
- **自动触发**(`MeterReadingsImporter` 完成后默认调 `GenerateBillsFromMeterReadingsAction`)
- 或**手动触发**(`ListMeters` 上的"生成账单"按钮,选刚导入的 readings)
详见 [[bill-generation-pipeline]]。
## 系统流程
```mermaid
sequenceDiagram
participant 李师傅
participant 王主管
participant Filament
participant MeterReadingsImporter
participant GenerateBills[GenerateBillsFromMeterReadingsAction]
participant 数据库
王主管->>Filament: 下载抄表模板(预填上次读数)
Filament-->>王主管: 模板.xlsx
王主管->>李师傅: 转发模板
李师傅->>李师傅: 现场抄表 + 填模板
李师傅->>王主管: 已填模板.xlsx
王主管->>Filament: 导入抄表数据 → 选 asset_type + 上传
Filament->>MeterReadingsImporter: parse + chunk 处理(100/批)
loop 每批
MeterReadingsImporter->>数据库: 开启事务
loop 每行
MeterReadingsImporter->>数据库: 校验 meter + asset
alt 通过
MeterReadingsImporter->>数据库: 建 MeterReading
else 失败
MeterReadingsImporter->>MeterReadingsImporter: 记失败行
end
end
alt 全成功
MeterReadingsImporter->>数据库: 提交
else 任一失败
MeterReadingsImporter->>数据库: 回滚整批
end
end
MeterReadingsImporter->>GenerateBills: handle(刚建的 readings)
GenerateBills->>数据库: 批量建 Bill + 回写 reading.bill_id
MeterReadingsImporter-->>Filament: 报告
Filament-->>王主管: 通知 + 失败行下载
```
## 抄表员视角(李师傅)
整月抄表流程:
1. **第 1-5 周(月初)**:正常工作 + 部分非紧急表抄(若有时间)
2. **第 25-30 日(月末)**:集中抄表
- 按楼栋 + 单元顺序(避免漏)
- 每户:开门(若有人)/ 看公共表(若装在外)
- 读表 → 拍照 → 填模板
3. **30 日 / 月底**:整理完整 Excel → 交回王主管
工作量:1,200 张表 / 月 / 1 抄表员 → 约 40 张 / 天 / 5 天工作。比单录(后台一张张点)快 3-5 倍。
## 业户视角
业户**无感知** —— 抄表员上门时业户可能在家也可能不在(公共表 / 燃气表通常装在楼道,不用进户)。
## `MeterReadingsImporter` 双义列名问题(issue.md Q5)
> [!warning] 已知 silent corruption 风险
> 当前 `MeterReadingsImporter` 用**一个 Importer 处理两种 Excel layout**(住宅单元 vs 商铺/公共),列 label 是"双义"形式(如 `'层编号/费用类型'`),靠 `$this->options['asset_type']` 决定列含义。
>
> **风险**:导入时用户选错 `asset_type`:
> - 系统不报错(列存在,数据有值,看着像合法)
> - 但数据写到**错误字段**(silent corruption)
> - 业务人员事后核对才发现数据错位
>
> **修复**(issue.md Q5 待补):拆成 `MeterReadingsImporterForUnit` + `MeterReadingsImporterForShop`,每个列含义固定。
>
> **当前预防**:导入时**务必仔细确认** asset_type 选项 + 抽样核对前几行数据。
## 常见问题
> [!question] 导入失败的常见原因?
> - meter 不存在(asset 或 code 找不到对应表)
> - meter 已退役(`is_active=false`)
> - 读数倒走(本次 < 上次)
> - 读数格式错(非数字 / 含中文)
> - asset_type 选错(silent corruption,不报错但数据错)
> [!question] 已导入想撤销?
> Reading 不可改 / 不可删(若已生成 Bill 更严)。撤销 = 走 [[exception-readings-locked-after-bill]] 流程,复杂。
>
> **预防**:导入前抽样核对 Excel + 选对 asset_type + 小批量先试。
> [!question] 同一张表本月被重复导入(填了两次 Excel)?
> 看 `MeterReadingsImporter` 是否有"同 meter + 同 read_at 不允许"的守护。如果没有 → 系统建两条 reading → 两条都算用量 → 业户被算两遍。**严重 bug**,需要业务人员核对避免重复导入。
> [!question] 部分表本月没抄怎么办?
> `MetersNeedingReadingListWidget` 会显示"本月未抄"清单。业务人员可:
> - 让抄表员补抄(走 [[read-single-meter-manual|单录]])
> - 让业户自报读数(部分物业接受,但需核对)
> - 跳过本月(下月一起算,业户账单可能突然变高)
> [!question] 商铺 / 公共表与住宅表能合并导入吗?
> 看 Excel 模板设计。当前**asset_type 是必选项**,即一次导入只能处理一种类型。要分两次导入(住宅一次,商铺一次,公共一次)。
> [!question] 导入完成后立即生成账单还是等月底?
> 取决于业务流程:
> - **导入即生成**:`MeterReadingsImporter` 完成后自动调 GenerateBills(默认推荐)
> - **手动触发**:业务人员后续审核 reading 后再触发生成
>
> 当前实现应是"自动生成"模式(看 Importer 配置)。
## 异常分支
- 单张表录入 → [[read-single-meter-manual]]
- 集抄自动 → [[read-via-iot-remote-source]]
- 已导入发现错 → [[exception-readings-locked-after-bill]]
- 高用量异常 → [[exception-high-consumption]]
- 抄表完成率审计 → [[audit-meters-needing-reading]]
## 相关文档
- [[meter-vs-meter-reading]]
- [[reading-source-and-photo-proof]]
- [[bill-generation-pipeline]]
- [[read-single-meter-manual]]
- [[read-via-iot-remote-source]]
- [[init-new-community-batch]]

View File

@@ -0,0 +1,170 @@
---
title: prop-acc · meter · 场景 - 单张表后台手动录入
aliases:
- 手动抄表单录
- 单张表录入
- read-single-meter-manual
- 场景-单张表手动抄表
tags:
- 场景
- prop-acc
- 计量表
- 抄表
audience:
- 业务人员
- 抄表员
status: 已发布
sub_feature: meter
last_review: 2026-05-26
code_version: 2026-05-22
---
# 场景:单张表后台手动录入
抄表员**单张表**录入读数。最基础的抄表方式 —— 适合小规模物业 / 个别补抄 / 集抄掉线的兜底。
## 典型情境
> [!example] 真实情境
> 嘉禾花园抄表员李师傅本月集抄系统**有 5 户掉线**(IoT 表故障 / 信号不好)。这 5 户他亲自上门读表,然后回办公室后台单条录入。
>
> 本场景:其中一户(张阿姨 12-3-501 的电表 E-501,本月读数 5,280)。
## 抄表员视角
### 第 1 步:现场读表
到张阿姨家:
1. 找到电表(通常装在玄关 / 门外配电箱)
2. **看清表头数字**:5280
3. **拍照存证**(`photo_url`,见 [[reading-source-and-photo-proof]] + [[read-with-photo-proof]])
4. 记录(写在抄表本子上或手机备忘)
### 第 2 步:回办公室录入
打开手机 App / 浏览器后台 → 计量表 → 找 E-501 → 进 `ViewMeter` → 滚动到下方"抄表读数"(`MeterReadingsRelationManager`)→ 点 **"新增"** 按钮。
> 替代路径:抄表员手机 App 直接现场录入,无需回办公室。当前若有此 App 走集成接口,无此 App 走后台。
### 第 3 步:填表单
| 字段 | 填什么 |
|---|---|
| **抄表日期(`read_at`)** | 2026-05-26(默认今天)|
| **当前读数(`current_reading`)** | 5280(物理表头数字)|
| **来源(`source`)** | `manual`(默认)|
| **拍照(`photo_url`)** | 上传现场照片(若有要求)|
| **操作员(`operated_by`)** | 自动填李师傅(当前登录用户)|
| **备注(`memo`)** | 选填,如 "集抄掉线手动补抄" |
> [!info] previous_reading 不用填
> 系统自动从该 meter 最近一条 reading 取(或从 `Meter.initial_reading` 取)作为 previous,自动算 `consumption = (current - previous) × multiplier`。
>
> 例:上次抄表 5050 → previous=5050 → consumption = (5280 - 5050) × 1 = 230 度
### 第 4 步:提交
系统:
1. 校验 `meter.is_active=true`(退役表不能抄,详见 [[decommission-and-locking]])
2. 校验 `current_reading >= previous_reading`(若有此守护;否则即"读数倒走"异常,见 [[exception-high-consumption]] 相关)
3. 算 consumption
4. 建 MeterReading(`bill_id=null`,即未生成账单)
5. (可选)若配置了"抄表即生成账单",自动调 `GenerateBillsFromMeterReadingsAction`(详见 [[bill-generation-pipeline]])
提交后跳回 `ViewMeter`,新 reading 显示在列表第一条。
## 业务人员视角
业务人员**通常不直接抄表**,但会做:
- **审核**抄表员录入的数据(看高用量异常,见 [[exception-high-consumption]])
- **生成账单**(批量,月底统一,走 `GenerateBillsFromMeterReadingsAction`)
- **核对**抄表完成率(`MetersNeedingReadingListWidget`)
## 系统流程
```mermaid
sequenceDiagram
participant 李师傅[抄表员]
participant Filament
participant MeterReadingsRelationManager
participant 数据库
Note over 李师傅: 现场读表 5280
李师傅->>Filament: ViewMeter(E-501) → 新增 reading
Filament->>MeterReadingsRelationManager: 渲染 form
李师傅->>MeterReadingsRelationManager: 填 current=5280 + 上传照片 + 提交
MeterReadingsRelationManager->>数据库: 校验 meter.is_active=true
MeterReadingsRelationManager->>数据库: 查 previous_reading(上次 5050)
MeterReadingsRelationManager->>数据库: 算 consumption=(5280-5050)*1=230
MeterReadingsRelationManager->>数据库: 建 MeterReading(source=manual, operated_by=李师傅, photo_url, bill_id=null)
Filament-->>李师傅: 显示新 reading 230 度
```
## 业户视角
业户**无感知** —— 张阿姨可能根本不知道李师傅上门读了表。
下个月物业账单出来,显示"5 月电费:用电 230 度,¥XXX",业户看明白即可。如果有异议 → 物业拿出 `photo_url` 照片证明。
## 何时用本场景
| 场景 | 用本场景? |
|---|---|
| 集抄系统正常运行 | ❌ 走 [[read-via-iot-remote-source]] |
| 集抄系统某户掉线 | ✅ 个别补抄 |
| 小规模物业未上集抄(全靠人工)| ✅ 但建议升级到 [[read-batch-via-excel-import]](效率高)|
| 抄表数据需要事后修正 | ❌ Reading 不可改,见 [[exception-readings-locked-after-bill]] |
| 业户家暂时无人,无法抄 | 抄表员标"未抄",月底再补 |
## 常见问题
> [!question] 读数倒走(current < previous)怎么办?
> 看 Form 是否有守护。若**允许提交**:系统建 reading,consumption 是负数 → 后续生成 Bill 时 Calculator 可能抛错 / 给 0 / 给 min_amount。
>
> **业务上**读数倒走通常意味着:
> - 表故障(乱跳)→ 走 [[replace-broken-meter|换表]]
> - 抄表录错(可能是本月录上月数 / 笔误)→ 立即改(若没生成 Bill 可改;若有 Bill 见 [[exception-readings-locked-after-bill]])
> - 业户偷电 / 物理改表 → 严重事件,法务介入
> [!question] 读完一户表才发现没拍照怎么办?
> 看物业政策严格度:
> - **必须拍照**:回去补拍 → 上传(理论上事后照片也是凭据,但不如当场)
> - **建议拍照**:本次录入时备注"未拍照" → 业户事后无异议则 OK
>
> 长期不拍照 = 业户争议时无凭证 = 物业被动。
> [!question] 抄表日期填错了能改吗?
> Reading 不可改([[decommission-and-locking]] 第 2 锁)。若 `read_at` 填错且**没生成 Bill** → 见 Policy 是否允许删 + 重建。若**已生成 Bill** → 复杂,走作废 Bill 流程([[exception-readings-locked-after-bill]])。
>
> **预防**:Form 默认带入今天日期,改之前确认。
> [!question] 一次录多张表(几十户)用单录效率太低?
> 是的。**走 [[read-batch-via-excel-import]] 批量导入**或 [[read-via-iot-remote-source]] IoT 自动。本场景适合 1-10 张表的个别情况。
> [!question] 录入后立即生成 Bill 吗?
> 看配置:
> - **抄表即生成 Bill**:Form 提交后自动调 `GenerateBillsFromMeterReadingsAction`(每条 reading 立即变 Bill)
> - **月底批量生成 Bill**:Form 提交后只建 reading,月底业务人员统一触发批量生成
>
> 当前实现看具体 Filament 配置,两种都支持。
## 异常分支
- 批量录入 → [[read-batch-via-excel-import]]
- 集抄自动 → [[read-via-iot-remote-source]]
- 拍照存证 → [[read-with-photo-proof]]
- 读完发现是错的 → 立即改(无 Bill)/ [[exception-readings-locked-after-bill]](有 Bill)
- 异常用量 → [[exception-high-consumption]]
## 相关文档
- [[meter-vs-meter-reading]]
- [[reading-source-and-photo-proof]]
- [[bill-generation-pipeline]]
- [[read-batch-via-excel-import]]
- [[read-via-iot-remote-source]]
- [[exception-high-consumption]]

View File

@@ -0,0 +1,246 @@
---
title: prop-acc · meter · 场景 - 集抄系统自动推送(source=remote)
aliases:
- 集抄系统
- IoT 抄表
- read-via-iot-remote-source
- 场景-集抄自动抄表
tags:
- 场景
- prop-acc
- 计量表
- 抄表
- IoT
audience:
- 业务人员
- 架构师
- 抄表员
status: 已发布
sub_feature: meter
last_review: 2026-05-26
code_version: 2026-05-22
---
# 场景:集抄系统自动推送(source=remote)
集抄系统(IoT)通过 RS485 / NB-IoT / LoRa 等技术**远程读取**物理表读数,定时(每天 / 每小时 / 每月)推送给本系统。本系统收到后建 `MeterReading(source=remote)`,**完全自动,无人工介入**。是现代化物业的标配。
## 典型情境
> [!example] 真实情境
> 嘉禾花园 2 年前升级了集抄系统,1,200 张水电气表全部接入 IoT 网关。集抄运营商每月 1 日凌晨**统一上传上月用量数据**到本系统。
>
> 物业财务王主管早上来上班,集抄数据已经全部到位 + 账单已经自动生成。她只需要:
> - 看 dashboard 异常告警(高用量 / 集抄掉线表)
> - 手工处理少数异常情况
## 系统流程
```mermaid
sequenceDiagram
participant Meter[物理表]
participant Gateway[IoT 网关]
participant Vendor[集抄运营商平台]
participant API[本系统集抄 API]
participant 数据库
participant GenerateBills
Note over Meter: 物理表每月 1 日凌晨自报
Meter->>Gateway: 读数推送(RS485 / NB-IoT)
Gateway->>Vendor: 上传(GSM / 4G / 有线)
Vendor->>Vendor: 整合 + 校验 + 归档
Note over Vendor: 月度批量推送(可能是每天 / 每月)
Vendor->>API: HTTP POST(批量推 readings)
API->>API: 校验签名 + 防重放
API->>API: 校验 meter 是否存在 + 是否 active
loop 每条 reading
API->>数据库: 建 MeterReading(source=remote, operated_by=null, photo_url=null)
end
API->>GenerateBills: handle(刚建的 readings)
GenerateBills->>数据库: 批量建 Bill + 回写 reading.bill_id
API-->>Vendor: 响应(成功 N,失败 M)
```
## 业务人员视角
### 日常(集抄正常运行)
业务人员**几乎不操作**:
-`MeterDashboard`(自动跳出异常告警)
- 月度对账(`audit-meters-needing-reading.md` 类似审计)
- 不需要手动抄表 / 录入
### 月初(集抄推数完成后)
通常上午到岗时:
- 所有 reading 已经在 `MeterReading` 表里
- 所有 Bill 已经生成
- Dashboard 显示:本月已抄 N 张 / 应抄 M 张
-`HighConsumptionReadingsListWidget` 异常告警 → 处理([[exception-high-consumption]])
-`MetersNeedingReadingListWidget` 显示掉线 / 漏抄表(本场景的兜底:走 [[read-single-meter-manual]] 个别补抄)
### 异常(集抄掉线)
| 掉线规模 | 处置 |
|---|---|
| 个别表(< 5%)| 抄表员补抄([[read-single-meter-manual]])|
| 大面积(网关故障)| 联系集抄运营商修复 + 物业短期手抄兜底 |
| 长期掉线(> 2 周)| 重新评估集抄设备的可靠性 |
## 集抄 API 设计(待文档化 / 实现细节)
> [!info] 当前实施状态
> 集抄 API 的**具体实现细节**(endpoint、签名、数据格式、防重放)需要看代码或与集抄运营商对接文档,本场景描述**业务流程层**。
### 接口要求
| 要求 | 说明 |
|---|---|
| 接收端 | 本系统提供 HTTP POST endpoint |
| 数据格式 | JSON 数组,每条 reading 字段:meter_code / read_at / current_reading / community_id / vendor_signature |
| 签名校验 | 防伪造,通常 HMAC-SHA256 或非对称签名 |
| 防重放 | 同 meter + 同 read_at 不重复建(unique index)|
| 错误响应 | 详细告知哪条失败、原因 |
| 失败重试 | 集抄运营商按响应决定重试逻辑 |
### MeterReading 字段对照
集抄推送的 reading,数据库存的 MeterReading:
| 字段 | 集抄数据 | 系统存 |
|---|---|---|
| `meter_id` | 集抄推 meter_code → 系统查 | meter ID |
| `read_at` | 集抄推时间(通常表自身上报) | datetime |
| `current_reading` | 物理表读数 | decimal(12,2) |
| `previous_reading` | (系统自动取上次)| decimal |
| `consumption` | (系统自动算)| decimal |
| **`source`** | (系统设)| **`remote`** |
| `operated_by` | null(集抄无人)| null |
| `photo_url` | null(集抄无照片)| null |
| `bill_id` | null(初始)→ 后续 GenerateBills 回写 | 可空 |
| `memo` | 选填(集抄可能写型号 / 网关 ID)| text |
## 集抄 vs 手抄 数据差异
| 维度 | manual(手抄) | **remote(集抄)** |
|---|---|---|
| 录入速度 | 慢(几天到几周)| **几小时全社区** |
| 准确性 | 中(手抖 / 看错)| **高**(机器直读)|
| 拍照存证 | 强烈推荐 | 无(IoT 设备自身就是凭证)|
| operated_by | 抄表员 ID | null |
| 业户感知 | 抄表员上门 | **无感**(无人上门)|
| 异常处理 | 抄表员现场判断 | 系统后台告警 → 人工介入 |
| 成本 | 抄表员工资 | IoT 设备 + 网关 + 月度服务费 |
| 业户家在不在影响 | 影响(开门才能抄) | 无影响 |
## 业户视角
业户**完全无感** —— 集抄系统的存在业户可能都不知道(除非物业告知)。
唯一感知:
- 账单出来更准时(每月固定日期到账)
- 没人上门抄表(隐私感更好)
- 用量明细可能更详细(集抄能支持小时 / 天级别 granularity,虽然账单仍是月度)
## 集抄设备与本系统的关系
```mermaid
flowchart TB
subgraph "物理层"
M1[家用水表]
M2[家用电表]
M3[工业表]
end
subgraph "网关层"
G1[楼栋网关<br/>RS485 → 4G]
end
subgraph "运营商层(第三方)"
V[集抄运营商平台<br/>设备管理 / 数据归档 / API 输出]
end
subgraph "本系统(prop-acc)"
API[集抄 API endpoint]
DB[(MeterReading)]
GenerateBills[GenerateBillsFromMeterReadingsAction]
Bill[(Bill)]
end
M1 --> G1
M2 --> G1
M3 --> G1
G1 --> V
V -->|每月推送| API
API --> DB
DB --> GenerateBills
GenerateBills --> Bill
```
**本系统只**管接收 + 存 + 生成账单。**不管**物理设备、网关、信号质量。这些都是集抄运营商的事。
## 常见问题
> [!question] 集抄数据准确吗?会不会比手抄更容易出错?
> 集抄数据**理论上更准**(机器直读,没手抖)。但:
>
> - IoT 设备本身可能故障(读数漂移、传输错)
> - 信号不好可能漏传(掉线)
> - 集抄运营商平台可能 bug(数据传错)
>
> 准确性强烈依赖**集抄运营商的可靠性**。选大牌运营商 + 完善 SLA。
> [!question] 集抄推过来的数据能改吗?
> 不能(MeterReading 不可变,见 [[decommission-and-locking]])。如果集抄推错:
>
> - 在系统侧建错误 reading
> - 走作废 Bill → 删 reading(若 Policy 允许)→ 集抄重推
> - 或运维 tinker 介入
> [!question] 集抄运营商收费贵不贵?
> 看市场,通常:
> - 设备 + 安装一次性几十到几百每点
> - 月度服务费 几元 / 表 / 月
>
> 中型社区(1,000 表)月度成本可能几千到几万。物业自行评估"省抄表员工资 vs 集抄费用" ROI。
> [!question] 集抄系统对接需要多久?
> 看运营商:
> - 大牌(华为 / 海康)平台标准 API → 1-2 周(主要是数据格式映射 + 测试)
> - 小厂自研协议 → 1-3 个月(需自定义对接代码)
> [!question] 集抄推数据时本系统有问题(数据库挂 / 网络断)?
> 集抄运营商应有重试机制(看 SLA)。本系统应:
> - API 幂等(同 meter + 同 read_at 不重复建)
> - 失败响应详细(让运营商知道哪条没收到)
> - 重要数据有补传机制(运营商手动重推)
> [!question] 集抄数据与业户预存款自动扣的关系?
> 集抄推数 → 生成 Bill → 触发 [[../prepaid/auto-deduction-design|预存款自动抵扣 job]](待实现)→ 业户预存款余额自动扣 + Bill 翻 Paid。
>
> 整条链**完全无人介入**,业户次日推送:"5 月电费 ¥168 已自动扣,余额 ¥X"。
## 异常分支
- 集抄掉线个别表 → [[read-single-meter-manual]] 兜底
- 集抄数据异常用量 → [[exception-high-consumption]]
- 集抄推错数据需修正 → [[exception-readings-locked-after-bill]]
- 集抄 API 故障 → 运维 / 集抄运营商支持
## 相关文档
- [[meter-vs-meter-reading]]
- [[reading-source-and-photo-proof]]
- [[bill-generation-pipeline]]
- [[read-single-meter-manual]]
- [[read-batch-via-excel-import]]
- [[exception-high-consumption]]

View File

@@ -0,0 +1,229 @@
---
title: prop-acc · meter · 场景 - 抄表拍照存证(物理表头照片)
aliases:
- 抄表拍照
- 照片存证
- read-with-photo-proof
- 场景-抄表拍照存证
tags:
- 场景
- prop-acc
- 计量表
- 抄表
- 存证
audience:
- 业户
- 业务人员
- 抄表员
status: 已发布
sub_feature: meter
last_review: 2026-05-26
code_version: 2026-05-22
---
# 场景:抄表拍照存证(物理表头照片)
抄表员**录入读数同时上传现场拍的物理表头照片**(`photo_url`),作为**业户事后质疑账单时的关键凭证**。配合 [[read-single-meter-manual|单录]] 流程使用。
## 典型情境
> [!example] 真实情境
> 张阿姨 6 月初收到 5 月电费账单 ¥168(280 度),她声称"我家平时就 100 多度,不可能这么多":
>
> - 王主管打开后台 → 找 E-501 的 5 月 reading → 看 `photo_url`
> - 照片清晰显示:**表头数字 5,280**(日期戳 2026-05-26,环境信息可识别为张阿姨家配电箱)
> - 拿照片给张阿姨看 → **业户无话可说**(确实读数是 5,280)
> - 王主管协助分析:可能近期家里某电器异常耗电(空调 24 小时开?旧冰箱故障?)
> - 业户回去自查 → 发现儿子用了电烤箱 + 空气炸锅一个月 → 接受账单
>
> 拍照存证**避免了一次纠纷**。
## 抄表员视角(李师傅)
### 拍照要点
每张表抄表时,**拍 1-2 张照片**:
| 角度 | 内容 |
|---|---|
| **正面特写** | 表头数字清晰可读(关键!)|
| **环境照** | 表 + 周围环境(配电箱 / 房号牌),证明是哪家的表 |
照片要素:
- **清晰**(对焦,光线足够)
- **包含表头读数全部位数**(不要遮挡)
- **时间戳**(手机 EXIF 元数据自动带)
- **GPS**(若手机 App 支持,更好)
### 录入
后台 / 抄表 App 上的 reading 录入 form 含 `photo_url` 字段:
| 字段 | 填什么 |
|---|---|
| 当前读数 | 5280 |
| 拍照 | 上传刚才的照片(或 App 直接拍照上传)|
| 备注 | 选填,如"电表正常,环境无异常" |
上传后照片存储到对象存储(S3 / OSS),`photo_url` 字段存对外可访问 URL。
> [!info] 强制度看物业政策
> - **严格物业**:Form 上 `photo` 字段 `->required()`,无照片不能提交
> - **建议物业**:Form 可空,UI 上有"建议拍照"提示
> - **宽松物业**:Form 可空,无提示
>
> 当前实现看 `MeterReadingsRelationManager` 的 form 配置。**生产环境强烈推荐严格模式**。
## 业务人员视角
### 查照片
后台 → 计量表 → ViewMeter → Reading 列表(`MeterReadingsRelationManager`):
- 每条 reading 显示"照片"图标(若 photo_url 不为空)
- 点击图标 → 弹出照片预览
- 可下载原图
### 业户争议处理
业户来电话 / 上门质疑账单:
1. 王主管打开对应 reading
2. 调出 photo_url 照片
3. 与业户当面 / 视频核对
4. 业户接受 → 案件了结
5. 业户仍质疑 →
- 物业派人**现场再读一次**(若表还在)
- 若现场读数与照片一致 → 业户更难反驳
- 若现场读数与照片不一致 → 表可能故障 / 数据有问题,走 [[replace-broken-meter|换表]] / [[exception-readings-locked-after-bill|修正流程]]
## 拍照流程图
```mermaid
sequenceDiagram
participant 李师傅
participant 物理表
participant 手机App
participant ObjectStorage[对象存储 S3/OSS]
participant 数据库
李师傅->>物理表: 现场观察读数
李师傅->>物理表: 拍照
李师傅->>手机App: 录入读数 + 选刚拍的照片
手机App->>ObjectStorage: 上传照片
ObjectStorage-->>手机App: 返回 URL
手机App->>数据库: 建 MeterReading + photo_url
```
## 业户视角
### 您会感受到什么
通常**不感知** —— 抄表员拍照是物业内部流程。
唯一影响:
- 抄表员上门时可能要求"打开配电箱看表"(配合)
- 极端情况:业户怀疑抄表员拍假照(看清照片是否真的是自己家表 = 看背景环境)
### 您的权利
对账单有异议时**有权要求**:
- 看抄表照片
- 派人现场再读一次
- 提交业户自己拍的反证照片(若业户当时也拍了)
## 拍照的额外用途
除业户争议外,照片还有其他价值:
| 用途 | 怎么用 |
|---|---|
| **审计追溯**(年审 / 监管)| 抽查某月某些 reading 的 photo_url,验证抄表真实性 |
| **抄表员考核** | 检查抄表员是否真去现场(对比 GPS / 时间 / 环境)|
| **培训新人** | 给新抄表员看老抄表员的照片范例 |
| **争议预防** | 业户知道有照片在,主动质疑减少 |
## 拍照的成本
| 成本 | 说明 |
|---|---|
| 抄表员时间 | 每张表 +5-10 秒 → 1,000 张表 +1-2 小时 |
| 手机存储 | 每张照片 1-5 MB → 1,000 张 = 几 GB / 月 |
| 对象存储 | 几 GB / 月 ≈ 几元 / 月(阿里云 OSS / S3) |
| 长期累积 | 5 年 = TB 级,需归档策略 |
| 培训 | 抄表员要学会拍合格照片(对焦、清晰、含表头全部数字)|
整体**成本可控**,但需要持续投入。
## 拍照的法律意义
> [!info] 业户法律纠纷时
>
> 在物业 vs 业户法律纠纷中,拍照照片是**关键物证**:
>
> - 照片有**时间戳 + GPS + 环境元数据**(EXIF) → 可证明真实性
> - 照片清晰显示读数 + 表号 → 可证明读数正确
> - 法院通常**采信** 这类专业现场记录
>
> 物业**完全没有拍照** → 只有业户口供 vs 物业账面数据 → 物业举证困难。
## 集抄(remote)与拍照的关系
集抄 reading(`source=remote`)**通常无拍照**:
- IoT 设备直接传数据,无相机
- IoT 设备本身就是凭证(机器读数 → 通常比人工准)
- 集抄掉线的兜底 [[read-single-meter-manual]] 仍要拍照
详见 [[reading-source-and-photo-proof]]"拍照存证"段。
## 常见问题
> [!question] 业户拒绝抄表员进屋拍照怎么办?
> 公共部位的表(楼道电表、燃气表)通常装在外面,不用进屋。
>
> 入户表(水表常在厨房 / 卫生间)若业户拒绝:
> - 协商上门时间(业户在家时)
> - 业户**自己拍照**发给物业(部分物业接受)
> - 长期拒绝 → 物业政策决定(警告 / 法律手段 / 估算用量)
> [!question] 照片上传失败怎么办?
> 网络问题 / 对象存储故障:
> - App 应有**本地缓存**机制(离线时存手机,联网时上传)
> - 上传失败的 reading 应**仍能提交**(photo_url 为空但有备注"上传失败")
> - 后续重传(若设计支持)
> [!question] 照片存多久?
> 法律 / 业务规定:
> - 中国通常 3-5 年(看物业法规)
> - 与账单同周期(账单存多久,照片存多久)
> - 长期归档(冷存储,如阿里云 OSS Archive)成本极低
> [!question] 一张照片能证明哪张表是谁家的?
> 照片本身不能(看不出房号)。需配合:
> - **环境识别**(房号牌、门口装饰、邻居环境)
> - **抄表员手写标注**(若 App 支持)
> - **GPS 坐标**(若 App 支持)
> - **数据库 meter_id 关联**(reading 关联 meter,meter 关联 asset)
> [!question] 业户对照片质疑"那不是我家的表"怎么办?
> 调取**原始 EXIF 数据**(时间戳、GPS、设备型号)→ 证明照片真实性。物业可派人**现场对照**确认表号物理一致。
## 异常分支
- 单录(本场景前置)→ [[read-single-meter-manual]]
- 批量导入(无拍照)→ [[read-batch-via-excel-import]]
- 集抄(无拍照)→ [[read-via-iot-remote-source]]
- 拍照后业户仍质疑高用量 → [[exception-high-consumption]]
## 相关文档
- [[reading-source-and-photo-proof]]
- [[read-single-meter-manual]]
- [[read-batch-via-excel-import]]
- [[read-via-iot-remote-source]]
- [[exception-high-consumption]]
- [[decommission-and-locking]]

View File

@@ -0,0 +1,153 @@
---
title: prop-acc · meter · 场景 - 单独新增一张表
aliases:
- 新增计量表
- 单录建表
- register-single-meter
- 场景-新增计量表
tags:
- 场景
- prop-acc
- 计量表
- 表管理
audience:
- 业务人员
status: 已发布
sub_feature: meter
last_review: 2026-05-26
code_version: 2026-05-22
---
# 场景:单独新增一张表
社区**已经初始化完成**(走过 [[init-new-community-batch]] 或老社区已有数据),后续个别新装一张表(新业户入住装表 / 旧业户加装电表分户 / 商铺新进驻装表),走**后台单录**而非批量。
## 典型情境
> [!example] 真实情境
> 嘉禾花园 12-3-501 业户陈先生最近**装修后想加装一个独立电表**(主表外的厨房专用电表,方便核算厨房电费)。物业财务王主管要在系统建这张新表。
## 业务人员视角
### 第 1 步:确认装表信息
向陈先生 / 抄表员李师傅核实:
- 房号 / 资产编号:12-3-501(对应 asset)
- 费用类型:电费(对应 FeeType)
- 物理表编号:E-501-K(物业自编,K 表示厨房)
- 倍率:1(普通家用单相表)
- 安装日期:今天(2026-05-26)
- **初始读数**:抄表员现场看物理表读数,假设 0(全新表)
### 第 2 步:打开后台
后台 → 计量表 → 列表 → 右上角 **"新建"** 按钮 → 进 `CreateMeter` 页面。
### 第 3 步:填表单(`MeterForm`)
| 字段 | 填什么 |
|---|---|
| **社区(community_id)** | 嘉禾花园 |
| **绑定房屋(asset_id)** | 12-3-501(下拉选)|
| **费用类型(fee_type_id)** | 电费(下拉选)|
| **表编号(code)** | `E-501-K` |
| **倍率(multiplier)** | 1.0 |
| **初始读数(initial_reading)** | 0.0 |
| **安装日期(installed_at)** | 2026-05-26 |
| **是否在役(is_active)** | ✅ 是(默认)|
| **替换上一代(replaced_meter_id)** | 留空(全新表,不是换表)|
| 备注 | "陈先生厨房分户表" |
### 第 4 步:提交
系统:
1. 校验 asset / fee_type 存在
2. 校验 code 在该社区不重复(若有 unique 约束)
3. 建 Meter 记录(`is_active=true`, `replaced_meter_id=null`)
4. **可选**:是否同时建一条 `initial_reading` 的 MeterReading?看 `CreateMeter` 实现 —— 若 form 有"初始读数"字段(目前应该有),`installed_at` 当天会建一条 `MeterReading(current_reading=0)` 作为起点,这样下次抄表算用量有 previous 可对照
### 第 5 步:启用 + 抄表
新表建好后,下次抄表周期就纳入正常流程(`MetersNeedingReadingListWidget` 会显示)。
## 系统流程
```mermaid
sequenceDiagram
participant 王主管
participant Filament
participant CreateMeter
participant 数据库
王主管->>Filament: ListMeters → 新建按钮
Filament->>CreateMeter: 渲染 form
王主管->>CreateMeter: 填字段 + 提交
CreateMeter->>数据库: 校验 asset / fee_type / code
CreateMeter->>数据库: 建 Meter(is_active=true)
alt form 含 initial_reading
CreateMeter->>数据库: 建 MeterReading(current=initial, source=manual, operated_by=王主管)
end
CreateMeter-->>Filament: 跳转 ViewMeter
Filament-->>王主管: 显示新表详情
```
## 与批量导入的对比
| 维度 | [[init-new-community-batch|批量导入]] | **单录(本场景)** |
|---|---|---|
| 触发场景 | 新社区接管 / 一次性大批量 | 个别新装 / 后续补建 |
| 数量级 | 100+ | 1 |
| UI | Excel 导入 | Filament `CreateMeter` 表单 |
| 时长 | 几分钟 / 小时 | 1-2 分钟 |
| 出错容忍 | 单行失败 / 部分行可独立处理 | 单条提交,错就改了再交 |
| 业务人员熟练度 | 需熟悉 Excel 模板 | 任何人填表都行 |
## 常见问题
> [!question] 业主已经有主电表,加装分表合规吗?
> **业务问题**,看物业政策 / 法律法规:
>
> - 国家电网通常**禁止**业主自己装"二次表"用于电费分摊
> - 但**物业内部**核算可以(例如商铺租户共用一个主表,物业按业主装的分表算各自费用)
>
> 系统层面**只管记录**,不判断合规性。
> [!question] 同一房屋有主表也有分表怎么办?
> 系统允许同一 `asset_id` + 同一 `fee_type_id` 下有多张表(不像 prepaid 的"一户一账"约束)。每张表独立抄表 + 独立账单。
>
> 但**业务上要清楚谁付谁的钱**:
> - 主表账单给业主
> - 分表账单给租户 / 厨房承包人(看场景)
>
> 这要业务方明确**账单收方**,系统按 `community_asset_users` 关系找业户。
> [!question] 单录时填错 code 怎么办?
> 表创建后可走 `EditMeter`(`is_active=true` 时允许)修改。但若**已抄过表 / 生成 Bill**,改 code 会让"历史照片上的表号"与"系统 code"对不上 → 强烈不推荐改。详见 [[decommission-and-locking]]"为什么退役表不能改"段。
> [!question] 单录后没抄表就发现错了能删吗?
> 看 `MeterPolicy::delete()`:**仅允许"已退役 + 无任何读数"**的表被删。
>
> 处理流程:
> 1. 先退役表(`is_active=false`, `decommission_reason=Removed`)
> 2. 走删除(若 Policy 允许 + 没读数)
> 3. 重新建正确的表
>
> 详见 [[decommission-and-locking]]。
> [!question] 单录的表如何同步给抄表员?
> `MetersNeedingReadingListWidget` 会自动显示新表(下个抄表周期)。无需手工通知。
## 异常分支
- 大批量 → [[init-new-community-batch]]
- 换表(旧表退役 + 新表建)→ [[replace-broken-meter]]
- 错了删表 → [[decommission-without-replacement]]
## 相关文档
- [[meter-vs-meter-reading]]
- [[init-new-community-batch]]
- [[replace-broken-meter]]
- [[decommission-and-locking]]

View File

@@ -0,0 +1,234 @@
---
title: prop-acc · meter · 场景 - 换表:旧表故障/退役,新表带 -R1 后缀
aliases:
- 换表
- 表更换
- replace-broken-meter
- ReplaceMeterAction
- 场景-换计量表
tags:
- 场景
- prop-acc
- 计量表
- 表管理
audience:
- 业务人员
- 抄表员
status: 已发布
sub_feature: meter
last_review: 2026-05-26
code_version: 2026-05-22
---
# 场景:换表,旧表故障/退役,新表带 -R1 后缀
物理表**老化 / 损坏 / 校验未过**,需要换新表。系统通过 `ReplaceMeterAction` **一步完成**:旧表退役 + 新表建立 + `replaced_meter_id` 关联 + 初始读数继承。新表自动编号 `<旧编号>-R1`
## 典型情境
> [!example] 真实情境
> 张阿姨家电表(编号 E-501)用了 8 年,2026 年 5 月例行校验未通过(读数漂移),物业要换新表。
>
> 换表当天:
> - 抄表员李师傅现场读旧表:**5000 度**
> - 卸下旧表 + 装上新表
> - 新表出厂读数:**0**(物理上)
> - 系统操作:`ReplaceMeterAction`
>
> 系统结果:
> - 旧表 E-501:`is_active=false`, `decommissioned_at=今天`, `decommission_reason=Replaced`, `final_reading=5000`
> - 新表 **E-501-R1**:`is_active=true`, `installed_at=今天`, `replaced_meter_id=旧表 ID`, **`initial_reading=5000`(继承)**, multiplier 继承
## 抄表员视角(李师傅)
### 第 1 步:现场操作
到张阿姨家:
1. 检查旧表状态(确认换表必要性)
2. **拍照存证**旧表当前读数(关键!后续争议时凭证)
3. 物理换表(断电 → 换表 → 通电)
4. 拍照新表初始状态(出厂 0)
5. 把信息回传业务人员(微信 / App / 当面)
> [!warning] 拍照不能省
> 旧表读数没拍照 = 系统里填的"5000" 没物理证据 = 业户事后质疑"我家明明只用了 4500" 时物业百口莫辩。
### 第 2 步:报回业务
抄表员把信息汇总给王主管(业务人员):
- 旧表编号:E-501
- 旧表最后读数:5000
- 换表日期:2026-05-26
- 退役原因:校验未过(`Replaced`)
- 新表型号:同型号(multiplier=1)
## 业务人员视角
### 第 1 步:打开旧表
后台 → 计量表 → 找 E-501 → 进 `ViewMeter`
### 第 2 步:点 `ReplaceMeterAction`
右上角"换表"按钮(标签可能是"更换")。
> [!warning] 按钮可见性
> 守护:`is_active=true` + Policy `->authorize('replace')`。已退役表此按钮灰化。
Modal 表单:
| 字段 | 填什么 |
|---|---|
| **旧表最后读数(`final_reading`)** | 5000(抄表员现场读)|
| **退役原因(`decommission_reason`)** | `Replaced`(其他 4 种见 [[decommission-and-locking]]) |
| **退役日期** | 2026-05-26(默认今天)|
| **新表编号** | E-501-R1(系统自动生成,`nextReplacementCode()`,可改但不推荐)|
| **新表 multiplier** | 1.0(默认继承旧表)|
| **新表安装日期** | 2026-05-26(默认今天)|
| 备注 | "校验未通过,换新表" |
### 第 3 步:提交
系统在**一个事务**内:
1. 校验旧表 `is_active=true`(否则按钮就不该出现)
2. 旧表 update:
- `is_active = false`
- `decommissioned_at = 2026-05-26`
- `decommission_reason = Replaced`
- `final_reading = 5000`
3. 建新表:
- `code = E-501-R1`(`nextReplacementCode($oldCode)`)
- `is_active = true`
- `installed_at = 2026-05-26`
- `replaced_meter_id = 旧表 ID`
- **`initial_reading = 5000`**(继承自旧表 final_reading)
- `multiplier = 1.0`(继承)
- `community_id` / `asset_id` / `fee_type_id` 继承
### 第 4 步:验证 + 通知
后台 → 计量表 → 看新旧两张表 → 确认数据正确。
业户可不通知(业户对系统层无感)。
## 系统流程
```mermaid
sequenceDiagram
participant 抄表员
participant 王主管
participant Filament
participant ReplaceMeterAction
participant 数据库
抄表员->>抄表员: 现场拍照 + 换表(物理)
抄表员->>王主管: 旧表读数 5000,换表完成
王主管->>Filament: ViewMeter(旧表) → ReplaceMeterAction
Filament->>ReplaceMeterAction: handle(oldMeter, finalReading=5000, reason=Replaced)
ReplaceMeterAction->>数据库: 开启事务
ReplaceMeterAction->>数据库: 1. 旧表 update:is_active=false, decommissioned_at, decommission_reason=Replaced, final_reading=5000
ReplaceMeterAction->>数据库: 2. 建新表:code=E-501-R1, is_active=true, replaced_meter_id=旧表id, initial_reading=5000, multiplier=1
ReplaceMeterAction->>数据库: 提交事务
Filament-->>王主管: 跳转新表 ViewMeter
```
## 旧表 / 新表数据对照
| 字段 | 旧表 E-501 | **新表 E-501-R1** |
|---|---|---|
| `code` | E-501 | **E-501-R1** |
| `is_active` | **false** | true |
| `installed_at` | 2018-XX-XX(原值) | 2026-05-26 |
| `decommissioned_at` | **2026-05-26** | null |
| `decommission_reason` | **Replaced** | null |
| `final_reading` | **5000** | null |
| `initial_reading` | (历史值不动)| **5000**(继承)|
| `multiplier` | 1 | 1(继承) |
| `replaced_meter_id` | null | **旧表 id** |
| `community_id`, `asset_id`, `fee_type_id` | 不动 | 继承 |
## 5 月份的抄表 + 账单
换表那个月的账单:
```
本月用量 = current(新表第一次抄)+ initial(=5000) - previous(=5000)
= (50 + 5000) - 5000
= 50 度
```
新表 5 月底第一次抄读到 50(物理表头),系统存 `current_reading = 50 + 5000 = 5050`,`previous_reading = 5000`(继承),`consumption = 50`。账单按 50 度算,业户感觉不到换表。
> [!info] 抄表员录入逻辑
> 抄表员现场看到新表是 50,**系统应自动加上 5000 存为 5050**(避免抄表员手动算)。或者抄表员录 50,系统在保存时自动加 5000。具体实现看 `MeterReadingsRelationManager` 的 form。
## 业户视角
业户**几乎感受不到** —— 只看到下月账单仍是正常用量。
唯一感知:换表当天可能短暂断电断水(物理操作)。物业应**提前通知**业户。
## 整链追溯
如果以后这张 E-501-R1 又出问题再换 → 新表 E-501-R2,`replaced_meter_id` 指 R1。如此累加:
```
E-501 (原生) → E-501-R1 (第 1 次换) → E-501-R2 (第 2 次换) → E-501-R3 ...
```
详见 [[replacement-chain]]"整条链的追溯"段。
## 常见问题
> [!question] 旧表 `final_reading` 填错了能改吗?
> 旧表的 `final_reading` 严格上属于"已退役表的字段",`MeterPolicy::update()` 在 `is_active=false` 时拒绝改([[decommission-and-locking]] 守护)。
>
> 改错的话:
> - 通过 tinker 修(运维操作,留备注)
> - 或者把 `decommissioned_at = null` 让表"复活"(Policy 可能不允许),再走完整换表流程
>
> **预防**:换表 Modal 提交前与抄表员书面确认 final_reading。
> [!question] 新表 multiplier 与旧表不同可以吗?
> 可以(form 上可改),但**强烈不推荐**。理由见 [[replacement-chain]]"常见问题"段:不同 multiplier 让用量计算公式变,业户对账困难。
> [!question] 新表编号 -R1 不喜欢能改成别的吗?
> Modal 表单允许改 `code`,但**强烈不推荐**:
> - `-R1` 是标准化命名,审计 / 报表 / 后续换表的 `-R2` 都基于这个 pattern
> - 改成自定义 code(如 "E-501-NEW")会破坏 `nextReplacementCode()` 算法,下次换表生成 `E-501-NEW-R1` 看着别扭
> [!question] 换表后业户对历史账单有异议怎么办?
> 历史 reading 都关联到旧表(`meter_id=旧表 id`),不会因换表丢失。审计可:
>
> - 后台找旧表 → 看历次 reading(只读)
> - 拿物理表照片(若有)
> - 拿换表前的累计读数(旧表 final_reading)对照
> [!question] 业户搬走永久弃用表,这种"换表"怎么处理?
> 那不是换表,是 [[decommission-without-replacement|退役不换表]]。`decommission_reason=Removed` 或 `Expired`,不建新表。
> [!question] 旧表是 active 但有未结账 reading,能换表吗?
> 系统**不阻止**(Action 不查未结账 reading)。但业务上:
>
> - 应先生成未结账 reading 的 Bill(走 [[bill-generation-pipeline]])
> - 否则换表后那些 reading 永远不会被处理(它们关联旧表)
>
> 推荐流程:**换表前先把旧表当月抄表录入 + 生成 Bill** → 然后再换表。
## 异常分支
- 不换表只退役 → [[decommission-without-replacement]]
- 误换表想撤销 → 困难,见 [[replacement-chain]]"常见问题"段
- 单纯换 multiplier 不换表 → 不推荐(应换表保留历史)
## 相关文档
- [[meter-vs-meter-reading]]
- [[replacement-chain]]
- [[decommission-and-locking]]
- [[decommission-without-replacement]]
- [[register-single-meter]]

View File

@@ -0,0 +1,247 @@
---
title: prop-acc · prepaid · 场景 - 低余额业户预警 + 逾期账单排查
aliases:
- 低余额预警
- 预存款余额告警
- audit-low-balance-and-overdue
- 场景-预存款低余额预警
tags:
- 场景
- prop-acc
- 预存款
- 审计
audience:
- 业务人员
- 财务
- 产品
status: 已发布
sub_feature: prepaid
last_review: 2026-05-25
code_version: 2026-05-22
---
# 场景:低余额业户预警 + 逾期账单排查
物业业务人员**每周** / 每月扫描:
1. **低余额预存款业户**(下月预计账单 > 当前余额) → 主动提醒充值
2. **预存款余额不够付未付账单的业户** → 跨核对
`LowBalancePrepaidListWidget` 是后台 Dashboard 上专门的 Widget,展示低余额账户清单。
## 典型情境
> [!example] 真实情境
> 物业财务王主管每周一上午看 `DepositPrepaidDashboard`:
>
> - 低余额业户清单 widget 显示 **45 户**业户的预存款余额 < 下月预计账单
> - 其中 **12 户**已有当月未付账单
> - 主管按清单逐户联系:
> - 12 户已欠 → 督促立即充值
> - 33 户预警(还没欠)→ 友好提醒"下月账单 ¥X,余额 ¥Y 不够,建议充值"
## 业务人员视角
### Dashboard 查看
后台 → 仪表盘 → `DepositPrepaidDashboard` 页面 → 看 `LowBalancePrepaidListWidget`
Widget 显示:
| 业户 | 当前余额 | 下月预计账单 | 差额 | 当前未付账单 |
|---|---|---|---|---|
| 张阿姨(12-3-501)| ¥200 | ¥800 | -¥600 | 0 |
| 陈先生(12-3-502)| ¥1,500 | ¥2,200 | -¥700 | 1(水电费 ¥220 已逾期 3 天)|
| 刘先生(12-3-503)| ¥0 | ¥800 | -¥800 | 1(物业费 ¥800 未付)|
| (省略)| | | | |
### 处置策略(分级)
| 紧急度 | 业户特征 | 处置 |
|---|---|---|
| **🔴 紧急** | 已有逾期账单 + 余额不够 | 立即联系 + 督促充值 + 必要时手动催收 |
| **🟡 警告** | 当前余额低于下月预计账单 | 提前 3-7 天友好提醒 |
| **🟢 关注** | 余额低于 2 个月账单合计 | 月度提醒一次,无需紧急动作 |
### 第 1 步:扫描清单
打开 `DepositPrepaidDashboard` → 看清单 widget。也可手动 SQL 查:
```sql
-- 低余额预存款业户(余额 < 下月预计账单)
SELECT
p.id AS account_id,
p.community_user_profile_id,
cup.name AS resident_name,
p.balance AS current_balance,
estimated_next_bill, -- 子查询算下月预计
(estimated_next_bill - p.balance) AS shortage,
COUNT(b.id) AS overdue_bills_count
FROM acc_prepaid_accounts p
JOIN community_user_profiles cup ON p.community_user_profile_id = cup.id
LEFT JOIN acc_bills b ON b.resident_id = cup.id
AND b.community_id = p.community_id
AND b.status = 'unpaid'
AND b.due_at < NOW()
WHERE p.status = 'active'
GROUP BY p.id, ...
HAVING current_balance < estimated_next_bill
ORDER BY shortage DESC;
```
### 第 2 步:分级处置
**🔴 紧急(已欠款)**:
- 短信 / 微信 / 电话联系业户
- 告知"您欠 X 月物业费 ¥800,请立即处理(充值预存款 / 现金 / 微信付)"
- 业户回应 → 处理(走 [[deposit-additional-topup]] 或其他渠道收款)
- 业户不回应 → 走逾期催收流程(本文不展开)
**🟡 警告(还没欠)**:
- 微信 / App 推送"友好提醒"
- "您的预存款余额 ¥X,下月账单约 ¥Y,建议提前充值"
- 不强制
**🟢 关注**:
- 月度汇总报告(给业户的"预存款健康度月报")
- 不打扰
### 第 3 步:出周报
```markdown
# 2026 年 5 月 第 4 周 预存款健康度周报
## 低余额业户清单(共 45 户)
- 🔴 紧急(已欠款):12 户,合计欠款 ¥9,860
- 🟡 警告:24 户
- 🟢 关注:9 户
## 已处置
- 紧急 12 户:已联系
- 5 户已充值 / 付清
- 4 户承诺本周内处理
- 3 户失联(进入催收)
- 警告 24 户:已推送提醒
## 趋势
- 比上周(38 户低余额)增加 7 户 → 趋势变差
- 可能原因:5 月账单出账,部分业户余额不够付
## 建议
- 加强自动抵扣 job 落地的紧迫性(目前业户充值后还得业务人员手动抵)
- 主动给余额接近 0 的业户 push 充值提醒(改 widget 配置)
```
## 业户视角
### 您可能收到的通知
#### 🟡 警告(友好提醒)
> 张阿姨您好,您的预存款余额 ¥200,下月物业费约 ¥800,**预计余额不够付**。建议提前充值,避免账单逾期产生提醒费用。
业户可选择:
- 立即充值
- 现金 / 微信付下月账单
- 不管(后果自负)
#### 🔴 紧急(欠款提醒)
> 张阿姨您好,您 5 月物业费 ¥800 **已逾期 3 天**未付。请尽快通过以下方式付清:
> 1. 微信小程序"我的预存款"充值后系统自动抵
> 2. 到前台现金 / POS 付
> 3. 微信扫码付
## 系统流程
```mermaid
flowchart TD
A[每周/月触发扫描] --> B[SQL 查低余额账户 + 未付账单]
B --> C{分级}
C -->|🔴 紧急已欠| D[强提醒 + 业务人员介入催收]
C -->|🟡 警告未欠| E[友好推送提醒]
C -->|🟢 关注| F[月度汇总报告]
D --> G{业户响应?}
G -->|充值| H[走 deposit-additional-topup]
G -->|其他渠道付| I[正常收款流程]
G -->|无响应| J[逾期催收流程<br/>本文外]
E --> K{业户行动?}
K -->|充值| H
K -->|不充| L[转入🔴紧急]
H --> M[业务人员手动 ConsumeAction 抵账单]
Note over M: 月初自动 job 落地后此步自动
```
## 关联工具
- **`DepositPrepaidDashboard`**:后台 Dashboard 页面,统一展示押金 + 预存款的健康指标
- **`LowBalancePrepaidListWidget`**:本场景核心 Widget,实时列出低余额账户
- **`MonthlyPrepaidFlowChart`**:展示预存款流入 / 流出趋势(充值 vs 消费 vs 退款),业务总监层面看
- **`DepositPrepaidStatsOverviewWidget`**:总览数字(总账户数、总余额、本月流水量)
## 常见问题
> [!question] "下月预计账单" 怎么算?
> 不在系统内的硬规则,看 Widget 实现:
>
> - 取业户近 3 个月平均月账单
> - 或取上月账单
> - 或固定基数(物业费固定 ¥800)
>
> 实际取哪种,看 `LowBalancePrepaidListWidget` 内置逻辑。可调整。
> [!question] 业户被提醒"低余额"但其实人家就喜欢月月手动付,不想预存,怎么办?
> 这种业户应**关账户**(走 [[close-resident-moveout|主动关账]]),避免后续骚扰。或者 Widget 上加"忽略"按钮,业户的"预存款关账"决策可以让业务人员跟进。
> [!question] 低余额预警有没有自动化(短信)?
> 看产品决策。**强烈推荐**:
>
> - 🟢 关注:不推送(避免骚扰)
> - 🟡 警告:每月 1 次推送(可控)
> - 🔴 紧急:立即推送(必要)
>
> 实施需要短信 / 微信模板消息接入。
> [!question] 月初批量自动抵扣 job 落地后,本场景作用还大吗?
> **仍重要**。job 跑完后,跳过的余额不足业户**仍需要业务人员介入**:
>
> - 通知充值
> - 跟踪是否充值
> - 充值后 / 业户其他渠道付后,手动 ConsumeAction 补抵
>
> 详见 [[consume-batch-auto-monthly]] "业务人员视角 - 异常介入" 段。
> [!question] 多社区的业户低余额清单怎么展示?
> Widget 按当前 panel 的 community 过滤(若是社区级 Filament Panel)。或者展示"按社区分组"。如果业务人员管多个社区,可在 Dashboard 选社区切换。
## 与 deposit 长期未关账户排查的对比
| 维度 | deposit audit-long-pending-accounts | prepaid 本场景 |
|---|---|---|
| 关注什么 | 长期 Active 未关账户(>2 年)| 低余额账户(下月可能欠)|
| 业务侧重 | 清理代管资金边界 | 预防业户欠费 |
| 频率 | 季度 / 半年 | 每周 / 每月 |
| 处置 | 关账 / retain / 联系业户 | 通知充值 / 催收 |
两者**都是审计扫描场景**,但关注点和频率不同。
## 异常分支
- 业户充值后忘了抵 → 业务人员手动 ConsumeAction
- 业户长期不响应低余额预警 → 进入逾期催收流程(本文外)
- 业户决定不再用预存款 → [[close-with-zero-balance-decision]] / [[close-resident-moveout]]
## 相关文档
- [[consume-batch-auto-monthly]]
- [[auto-deduction-design]]
- [[deposit-additional-topup]]
- [[close-resident-moveout]]
- [[../deposit/audit-long-pending-accounts]](deposit 对应审计场景对比)

View File

@@ -0,0 +1,163 @@
---
title: prop-acc · prepaid · 场景 - 业户搬走主动关账
aliases:
- 关预存款账户
- 业户搬走关账
- close-resident-moveout
- 场景-预存款搬走关账
tags:
- 场景
- prop-acc
- 预存款
- 结清
audience:
- 业务人员
status: 已发布
sub_feature: prepaid
last_review: 2026-05-25
code_version: 2026-05-22
---
# 场景:业户搬走主动关账
业户搬走、走完 [[refund-full-resident-moveout|全额退余]] 后,**业务人员手动**关账户。**与 deposit 不同,prepaid 不会自动关账**(零余额仍 Active),所以这步必须手动做。
## 典型情境
> [!example] 真实情境
> 刘先生卖了房子,昨天走 [[refund-full-resident-moveout|全额退余]] 流程退完了 ¥3,200。账户余额 0,状态仍 Active(因为 prepaid 设计如此)。物业财务今天清理时主动关掉这个账户。
## 业务人员视角(本场景**业户无感**)
> [!info] 业户视角
> 业户已经搬走,通常**不感知**关账动作。小程序登录(若仍登)会看到账户从 ✅ Active 变 🔒 Closed,但流水照常可看。
### 第 1 步:确认前提
- 业户已搬走(房屋已过户 / 退租)
- 余额已退完(balance = 0)
- 无未付账单
### 第 2 步:打开账户
后台 → 预存款 → 找到刘先生账户(Active,balance=0)→ 进 `ViewPrepaidAccount`
### 第 3 步:点击 `CloseAccountAction`(标签"关账")
> [!warning] 按钮可见性
> 守护:`canOperate()`(Active only)+ `balance == 0` + Policy `->authorize('close')`。Frozen / 有余额账户灰化。
Modal 表单:
| 字段 | 填什么 |
|---|---|
| **关账事由(memo)** | 必填,如 "业户搬走,12-3-501 已过户,余额已退完" |
### 第 4 步:提交
系统调 `PrepaidAccount::close($memo)`:
1. 校验 `canOperate() && balance == 0`
2. 更新 `status=Closed`
3.`meta.close_memo` 记关账事由
4.`meta.closed_at` 记关账时间
**不产生** PrepaidTransaction / CollectionOrder / Receipt(状态变更)。
### 第 5 步:无需通知业户
业户已搬走,通知意义不大。后台档案有完整记录(开户 → 历次充值 / 消费 → 退款 → 关账)。
## 系统流程
```mermaid
sequenceDiagram
participant 财务
participant Filament
participant PrepaidAccount
participant 数据库
Note over 财务: 业户搬走,退余已完成,balance=0
财务->>Filament: ViewPrepaidAccount → CloseAccountAction(memo)
Filament->>PrepaidAccount: close(memo)
PrepaidAccount->>PrepaidAccount: canOperate() && balance==0? yes
PrepaidAccount->>数据库: 更新 status=Closed, meta.close_memo, meta.closed_at
数据库-->>Filament: ok
Filament-->>财务: 成功
Note over 数据库: 无 Transaction / CO / Receipt
```
## 完整流程(退余 + 关账)
业户搬走的**完整两步**:
```mermaid
sequenceDiagram
participant 业户
participant 财务
participant Filament
participant Account
业户->>财务: 我要搬走,退预存款
财务->>Filament: RefundAction (全额)
Filament->>Account: refund() → balance 0, status 仍 Active
Note over Filament: prepaid 不自动关账,需手动
财务->>Filament: CloseAccountAction
Filament->>Account: close() → status=Closed
财务-->>业户: 退款 + 关账完成
```
## 与 deposit 关账的差异
| 维度 | deposit close-after-zero-balance(自动)| prepaid close(本场景,手动)|
|---|---|---|
| 触发 | 最后一笔 refund/forfeit 使 balance=0 时自动 | 业务人员手动点 CloseAccountAction |
| 是否需 CloseAction | 不需要(自动)| **需要** |
| 业务背景 | 押金业务完结 | 业户搬走、长期不用 |
| 业务人员介入度 | 0 | 1 次操作 |
## 常见问题
> [!question] 业户没搬走但想关账户?
> 看具体情况:
> - **业户主动要求关**:不推荐(以后想用还得开新户,**一户一账约束阻塞**)。建议劝业户留 Active 账户,余额 0 不影响什么
> - **业户彻底不想用预存款**:走 [[refund-full-resident-moveout|全额退]] → 本场景关账
>
> 关闭账户是**业务终态**,反悔代价大。
> [!question] 没退完余额能关账吗?
> **不能**。`CloseAccountAction` 守护 `balance == 0`。要关必须先退完。这与 deposit 一致。
> [!question] 关账后能反悔重开吗?
> 不能(`canBeReopened` 永远 false)。新业务**开新账户**,但**一户一账阻塞**(详见 [[one-account-per-resident]] "已知设计 gap")。
> [!question] 业务上批量关账(例如批量清理多年未用的零余额账户)有功能吗?
> 当前没有。如果要清理 100+ 账户,需要 List 页加批量操作,或运维 tinker。**优先级不高** —— 零余额 Active 账户对业务影响小,不主动清理也可。
> [!question] 关账后流水台账还能看吗?
> 能。Closed 账户**只读模式**保留全部历史,流水台账 / Receipt 都可查询。
> [!question] 关账时业户还有未付账单怎么办?
> 不会触发系统校验(系统不主动联动 Bill 模块),但**业务上是大问题**:
> - 业户搬走 + 余额已退 + 关账户 → 未付账单挂业户身上
> - 业户搬走后催收困难
>
> **预防** = 关账前业务人员**手动核对** 该业户是否有未付账单,有 → 先 [[consume-monthly-property-bill|抵清]] 再关账。
## 异常分支
- 余额非 0 想关 → 先退完([[refund-full-resident-moveout]] / [[refund-partial-after-consume]])
- Frozen 账户想关 → 先 [[unfreeze-after-verification|解冻]] 再退再关
- 余额 0 但不想关(业户可能还用)→ [[close-with-zero-balance-decision]]
## 相关文档
- [[refund-full-resident-moveout]]
- [[close-with-zero-balance-decision]]
- [[account-state-machine]]
- [[one-account-per-resident]]
- [[../deposit/close-after-zero-balance]](deposit 自动关账对比)

View File

@@ -0,0 +1,199 @@
---
title: prop-acc · prepaid · 场景 - 余额清零后不自动关,业户决定
aliases:
- 零余额不自动关账
- 余额 0 决策
- close-with-zero-balance-decision
- 场景-预存款零余额决策
tags:
- 场景
- prop-acc
- 预存款
- 结清
audience:
- 业户
- 业务人员
status: 已发布
sub_feature: prepaid
last_review: 2026-05-25
code_version: 2026-05-22
---
# 场景:余额清零后不自动关,业户决定
业户预存款账户**余额自然变为 0**(消费抵扣完 / 退款完),账户**保持 Active**,等业户决定继续充值复用,还是主动 [[close-resident-moveout|关账]]。突出 prepaid 与 deposit 在零余额行为上的关键差异。
## 典型情境
> [!example] 真实情境
> 张阿姨预存款账户余额 ¥800,5 月物业费账单 ¥800,业务人员抵扣后**余额 = 0**。
>
> 账户**仍 Active** —— 系统没自动关。张阿姨有 3 个选择:
>
> 1. **继续用**:下月再充值,账户复用,啥都不操作
> 2. **主动关账**:不想用预存款了,联系物业关账
> 3. **不管**:留 Active 零余额账户,以后想用再充
## 业户视角
### 您会感受到什么
- 推送通知:"5 月物业费 ¥800 已抵扣,**余额 ¥0**"
- 小程序"我的预存款"显示 "✅ Active,余额 ¥0"
- **账户没关**,仍可用(若有钱)
### 您要做什么(三选一)
#### 选项 1:继续用(默认,推荐)
什么都不用做。下次想用预存款付账单,先充值:
- 走 [[deposit-additional-topup|追加充值]]
- 充值后余额非 0,继续抵账单
适合:**长期居住业户**,预存款是日常工具。
#### 选项 2:主动关账
如果决定**不再使用预存款**(例如转用现金 / 微信付每月账单):
- 联系物业(电话 / 微信 / 前台)
- 业务人员走 [[close-resident-moveout|关账]] 流程
适合:**业户偏好不变**(决定不再用预存款服务)、**搬走**等长期事件。
> [!warning] 关账后想反悔?
> 关账永久不可逆。如果以后又想用,**理论上**重开,但**一户一账约束阻塞**(详见 [[one-account-per-resident]] "已知设计 gap")。保险起见:不确定就**不要关**。
#### 选项 3:留 Active 不管
什么都不做。账户保持 Active + 余额 0:
- 不影响业户
- 占用一条数据库记录(微不足道)
- 后续可能在 [[audit-low-balance-and-overdue|审计]] 里被标记"长期零余额",业务人员可能主动联系您确认
适合:**犹豫**(可能以后会用)、**短期没决定**。
## 业务人员视角
### 通常无需操作
零余额 Active 账户**默认保留**,不主动清理。理由:
| 理由 | 说明 |
|---|---|
| 业户随时可能继续充值 | 关了再开成本大(一户一账约束)|
| 业务上无伤害 | 账户余额 0,不挂账、不欠款、不占资金 |
| 清理意义低 | 数据量不大,清理工时 > 收益 |
| 自动关风险大 | "自动关账后业户充值要重新开,体验差" |
### 何时主动关
只在以下情况业务人员主动关:
| 情况 | 关账理由 |
|---|---|
| 业户搬走 | 业务终结,清爽 |
| 业户明确说"不再用预存款" | 用户决定 |
| 账户长期闲置(>2 年)且业户长期失联 | 清账类似 [[audit-low-balance-and-overdue]] 处理 |
### 操作
走 [[close-resident-moveout|主动关账]] 流程,Modal 表单 memo 填具体原因。
## 与 deposit 的关键差异(再次强调)
| 维度 | deposit 零余额 | **prepaid 零余额(本场景)** |
|---|---|---|
| 自动关账 | ✅ 是,最后一笔 refund/forfeit 触发 | ❌ **保持 Active** |
| 业户感知 | 收到最后一张红字收据 + 自动关账通知 | **无感**(余额 0 但账户 Active)|
| 业务人员介入 | 不需要 | 视需求决定 |
| 设计哲学 | 押金 = 业务节点性,完结即关 | 预存款 = 长期工具,清零不等于终结 |
## 系统流程(消费导致清零)
```mermaid
sequenceDiagram
participant 业户
participant 业务
participant Filament
participant Account
participant 数据库
Note over Account: balance=800,有 800 物业费账单
业务->>Filament: ConsumeAction(800)
Filament->>Account: consume(bill, 800)
Account->>数据库: 建 CO(type=Bill, +800) + PrepaidTransaction(consume, 800→0)
Account->>数据库: **balance=0, status=Active(不变)**
Account->>监听器: 触发 CollectionOrderCompleted
监听器->>数据库: 建 Receipt("物业费 ¥800")
Account->>数据库: 提交
Note over Account: balance=0 但 Active
Filament-->>业务: 完成
业务-->>业户: 推送"5 月物业费已抵扣,余额 ¥0"
Note over 业户: 业户选择:继续用 / 关账 / 不管
```
## 流水台账(本场景)
| 流水 | type | amount | balance_before | balance_after | 备注 |
|---|---|---|---|---|---|
| ... | (前面省略)| | | | |
| N | consume | 800 | 800 | 0 | 5 月物业费抵扣 |
账户 `status` 保持 Active,无关账动作。
## 常见问题
> [!question] 为什么 prepaid 设计成不自动关账?
> 详见 [[account-state-machine]] "零余额不自动关账" 段。简言之:
>
> - 一户一账,关了重开成本大(unique 约束)
> - 业户长期可能复用
> - 业务高频,频繁开关无意义
> [!question] 系统层面有"零余额超 N 个月自动关账" job 吗?
> 没有,也**不推荐加**。零余额 Active 账户无害,自动关账反而引发业户"为什么我账户被关了"的客服压力。
> [!question] 业户登录小程序看到余额 0,会困惑吗?
> 不会(理论上)。小程序界面应清楚显示:
> - 余额:¥0
> - 状态:Active
> - 行动按钮:"立即充值"(显眼)
> - 流水:可看历史
>
> 业户清楚看到"我可以充值继续用"。
> [!question] 业户问"我账户还在用吗?"
> 看状态:
> - Active + 余额 > 0:正常用
> - Active + 余额 = 0:**仍在用,但需要充值才能抵账单**
> - Frozen:暂停中,联系物业了解
> - Closed:已关闭,不再使用
> [!question] 退到 0 的退款流程跟消费到 0 的流程一样吗?
> 状态机层面**完全一样** —— 都保持 Active。不同点:
> - 消费到 0:走 [[consume-monthly-property-bill]] 等抵扣场景
> - 退款到 0:走 [[refund-full-resident-moveout]] 或 [[refund-partial-after-consume]] 之类的退款场景
>
> 两种动作都**不触发**自动关账。
## 异常分支
- 业户决定关账 → [[close-resident-moveout]]
- 业户决定继续用 → [[deposit-additional-topup|追加充值]]
- 长期零余额累积成审计问题 → [[audit-low-balance-and-overdue]]
## 相关文档
- [[account-state-machine]]
- [[close-resident-moveout]]
- [[deposit-additional-topup]]
- [[refund-full-resident-moveout]]
- [[../deposit/close-after-zero-balance]](deposit 自动关账对比)
- [[../deposit/close-manual-with-zero-balance]](deposit 主动关空账户对比)

View File

@@ -0,0 +1,224 @@
---
title: prop-acc · prepaid · 场景 - 月初批量自动抵扣 job(待补)
aliases:
- 月初自动抵扣
- 批量预存款抵账单
- consume-batch-auto-monthly
- 场景-月初自动抵扣
tags:
- 场景
- prop-acc
- 预存款
- 消费
- 待补
audience:
- 业务人员
- 财务
- 产品
status: 草稿
sub_feature: prepaid
last_review: 2026-05-25
code_version: 2026-05-22
---
# 场景:月初批量自动抵扣 job(待补)
> [!warning] 本场景**代码未实现**
> 当前所有 consume 操作都需要业务人员**手动后台触发**。本文档描述自动 job 落地后的目标态。设计意图详见 [[auto-deduction-design]]。
预存款的**产品价值核心**。月初 1 日凌晨,Scheduled job 扫所有 Active + 余额>0 的预存款账户,对每个账户找未付账单,按优先级 ([[consume-multiple-bills-priority]]) 自动抵扣。
## 典型情境(目标态)
> [!example] 真实情境(目标态)
> 2026 年 6 月 1 日 00:30,系统自动跑 `PrepaidAutoDeductionJob`:
>
> - 扫描全平台 5 个社区,共 500 个 Active 预存款账户(余额 > 0)
> - 对每个账户找该业户的未付账单(物业费 + 水电费 + ...)
> - 按 due_at 升序抵扣
> - 全部完成耗时 ~5 分钟
>
> 早上 8 点,500 个业户陆续收到推送:
>
> | 业户场景 | 推送内容 |
> |---|---|
> | 余额充足,全抵 | "5 月账单已自动抵扣 ¥1,000,余额 ¥4,000" |
> | 余额部分够 | "已抵 ¥800,水电费 ¥200 余额不足,请充值" |
> | 账户冻结 | (不推送)|
## 业户视角(目标态)
### 您会感受到什么
- 月初某个早晨突然收到推送
- 小程序"我的账单"批量翻为 ✅ Paid
- 小程序"我的预存款"流水里多几笔 consume
- 收到对应数量的 Receipt(每张账单一张)
- **完全无感**(理想状态),不需要任何操作
### 您要做什么
只在以下情况要做:
| 推送内容 | 您要做 |
|---|---|
| "余额不足,X 账单未付" | 充值 → 业务人员手动补抵 / 等下月自动 job |
| "账户冻结无法抵" | 联系物业了解冻结原因 |
| "所有账单已抵,余额还剩 ¥X" | 不用做 |
## 业务人员视角
### Job 执行前
业务人员**不需要任何操作**。Scheduled 配置在系统层,运行无需介入。
### Job 执行过程
后台监控面板(待开发):
- 实时进度:已处理 N / 总共 M 账户
- 实时统计:已抵账单数、抵扣总额、失败数、跳过数
- 失败告警:任何异常立即推送给 ops
### Job 执行后
业务人员看汇总报告(后台 / 邮件):
```markdown
# 2026 年 6 月 1 日 PrepaidAutoDeductionJob 报告
## 统计
- 候选账户:500
- 全抵成功:380(76%)
- 部分抵 / 跳过:80(16%)
- 跨社区拦截:0
- 账户冻结跳过:8(2%)
- 失败(异常):0(0%)
- 其余(余额=0、无未付账单):32(6%)
## 资金动作
- 抵扣总额:¥412,300
- 涉及账单数:835(平均每户 1.6 张)
- 平均抵扣金额:¥513
- 最大单户抵扣:¥3,200(陈先生家,水电+物业+电梯)
## 失败明细
(无)
## 跳过明细(需关注)
- 80 户余额不足,合计欠款 ¥45,000
- 已发推送
- 业务人员可后续手动追缴
## 冻结跳过
- 8 户冻结中
- 需业务人员核实是否解冻
```
### 异常介入
| 场景 | 业务人员动作 |
|---|---|
| 跳过的余额不足业户 | 联系业户充值 + 后续手动 ConsumeAction |
| 冻结跳过业户 | 核实纠纷 / 风控状态 → [[unfreeze-after-verification|解冻]] |
| Job 失败(系统异常) | 立即联系运维查日志 |
| 某账户重复抵扣(理论上不应该) | 查 transactions 表是否有同一 Bill 被抵两次 → 立即停 job 排查 |
## 系统流程(目标态)
```mermaid
sequenceDiagram
participant Scheduler
participant Job[PrepaidAutoDeductionJob]
participant Account[PrepaidAccount]
participant Bill
participant Consume[ConsumeFromPrepaidAccountAction]
participant 监听器
participant 数据库
participant 业户
Note over Scheduler: 2026-06-01 00:30 触发
Scheduler->>Job: dispatch
Job->>数据库: SELECT prepaid_accounts WHERE status=Active AND balance>0
loop 每个 account
Job->>数据库: SELECT bills WHERE community_id=? AND resident_id=? AND status='unpaid' ORDER BY due_at, amount
loop 每个 bill
alt balance >= bill.amount
Job->>Consume: handle(account, bill, bill.amount)
Consume->>Account: consume()
Consume->>Bill: recordPayment() → Paid
Consume->>监听器: 触发 CollectionOrderCompleted
监听器->>数据库: 建 Receipt
Consume->>数据库: 提交事务
else 余额不够
Job->>Job: 跳过,记日志
end
end
end
Job->>数据库: 写汇总报告
Job-->>Scheduler: 完成
Job->>业户: 批量推送通知
```
## Job 的安全设计(目标态)
| 风险 | 防御 |
|---|---|
| 同一账户被抵两次(job 重跑) | 每笔 consume 关联 Bill,Bill.status=Paid 后跳过 |
| 跨社区误抵 | `ConsumeFromPrepaidAccountAction` 内置守护(consume 模型方法层) |
| Frozen 账户被抵 | `canOperate()` 守护(模型层) |
| 余额为负 | 事务回滚 + amount ≤ balance 守护 |
| Job 长时间运行影响业务 | 分批 chunk(100 / 批);限制最大并发 |
| Job 半夜失败无人发现 | 失败告警(Slack / 钉钉 / 短信)|
| 业户充值后想立即抵(月中)| 业务人员手动 ConsumeAction(不等下月 job)|
## 与手动 ConsumeAction 的关系
| 维度 | 手动 ConsumeAction | 自动 Job |
|---|---|---|
| 触发 | 业务人员后台点击 | Scheduled(月初)|
| 单次范围 | 1 账户 × 1 账单 | 全平台 × 全部账户 × 全部账单 |
| 业务场景 | 个案、运维、补抵 | 月度默认流程 |
| 通知 | 单笔 Receipt | 批量 Receipt + 汇总推送 |
| 复用代码 | `ConsumeFromPrepaidAccountAction` | **同上**(复用,不重写)|
## 实施前已记录的待讨论项
详见 [[auto-deduction-design]] "待讨论 / 决策" 段。简略列表:
- 触发频率(月度 / 周度 / 实时)
- 触发时点(月初固定 / 账单生成事件触发)
- 优先级排序(due_at / amount / bill_type 组合)
- 部分抵扣支持
- 失败通知策略
- 监控指标
## 未来扩展
job 落地后,后续可演化:
| 演化方向 | 价值 |
|---|---|
| **小程序"我的账单"显示"将于 X 月 1 日自动扣"** | 业户预期管理,避免临时余额不足惊讶 |
| **预扣预警**:月底前 7 天扫描"下月账单 > 当前余额"的业户 → 主动提醒充值 | 减少跳过率,提升用户体验 |
| **零余额自动通知**:月初 job 后,余额 = 0 的账户主动推送"请充值" | 提升复购率 |
| **跨账户均衡**(若同业户多社区):未来若放开跨社区抵扣 | 提升资金利用率 |
## 当前替代(job 实现前)
- 业务人员**月初批量手动**逐户处理(见 [[consume-monthly-property-bill]] + [[consume-multiple-bills-priority]])
- 工作量大,容易遗漏 / 顺序错乱
- **这就是 job 紧迫性的来源**
## 相关文档
- [[auto-deduction-design]]
- [[consume-monthly-property-bill]]
- [[consume-multiple-bills-priority]]
- [[consume-meter-bill]]
- [[consume-via-bill-collection-type]]
- [[audit-low-balance-and-overdue]]

View File

@@ -0,0 +1,179 @@
---
title: prop-acc · prepaid · 场景 - 抵扣计量账单(水电费)
aliases:
- 抵水电费
- 计量账单抵扣
- consume-meter-bill
- 场景-预存款抵计量账单
tags:
- 场景
- prop-acc
- 预存款
- 消费
- 计量
audience:
- 业户
- 业务人员
status: 已发布
sub_feature: prepaid
last_review: 2026-05-25
code_version: 2026-05-22
---
# 场景:抵扣计量账单(水电费)
业户的水表 / 电表 / 燃气表抄表后生成账单(由 Meter 模块出账,详见未来的 `meter/` 子模块文档),业务人员从业户的预存款余额抵扣。流程与 [[consume-monthly-property-bill|物业费抵扣]]**几乎相同**,差异在**账单的 bill_type** 和**金额浮动性**。
## 典型情境
> [!example] 真实情境
> 张阿姨家 5 月水表抄表:本月用水 12 吨,@ 4.5 元/吨 = ¥54;电表抄表:用电 280 度,@ 0.6 元/度 = ¥168。两张计量账单合计 ¥222,从张阿姨预存款余额 ¥3,400 抵扣。
## 业户视角
### 您会感受到什么
- 抄表数据通过物业 App / 集抄系统进入系统
- 几天内出账单(可能合并为"水电费"一张,也可能水、电分开两张)
- 业务人员手动 / 自动从预存款抵扣
- 收到收据:"水费 ¥54" + "电费 ¥168"(或合并"水电费 ¥222")
- 推送:"5 月水电费 ¥222 已抵扣,余额 ¥3,178"
### 您要做什么
什么都不用。看明白即可。**关键差异**:计量账单**金额每月浮动**(取决于用量),业户应:
- 用量大时确保余额充足(预存款充值要考虑这部分)
- 异常用量(突然翻倍)应自查(可能漏水 / 老人忘关电器)
- 对账单金额有异议 → 走 [[../adhoc/cancel-amount-error-redo|纠错流程]](见 adhoc 模块,通用)
## 业务人员视角
> [!info] 流程基本同物业费抵扣
> 看 [[consume-monthly-property-bill]] 完整流程。本场景只补充**计量账单特有**的注意点。
### 关键差异:bill_type
| 字段 | 物业费抵扣 | 计量账单抵扣 |
|---|---|---|
| `Bill.bill_type` | `property_fee` | `meter`(或 `water` / `electricity` / `gas`,看 Bill 模块设计)|
| `CollectionOrder.collection_type` | `Bill` | `Bill`(都是 Bill 视角)|
| `meta.fund_source` | `prepaid` | `prepaid` |
| Receipt 文案 | "物业费 ¥800" | "水费 ¥54" / "电费 ¥168"(看 Bill 的 line items)|
> 注:具体 bill_type 枚举值看 Bill 模块定义。本文按"计量类"统称。
### 关键差异:金额来源
物业费金额是**固定的**(合同约定,每月不变)。计量账单金额是**计算出来的**:
```
本月用量 = 本月抄表 - 上月抄表
本月金额 = 用量 × 单价(RatePlan)
```
数据流:Meter 抄表 → MeterReading 记录 → Bill 生成(按 RatePlan 计算金额) → 业务人员抵扣。
详见 Meter 模块文档(待补)。
### 关键差异:可能分单或合单
各物业财务习惯不同:
| 方式 | 优 | 缺 |
|---|---|---|
| **分单**(水、电、燃气各一张 Bill)| 业户能看清单项 | 业务人员要抵多张 |
| **合单**(一张"5月水电费 ¥222")| 操作快 | 业户看不清各项 |
系统两种都支持,看 Meter / Bill 模块的出账配置。
## 系统流程
```mermaid
sequenceDiagram
participant 集抄系统
participant Meter
participant Bill
participant 财务
participant Filament
participant Account[PrepaidAccount]
participant 数据库
集抄系统->>Meter: 推送本月抄表数据
Meter->>数据库: 写 MeterReading + 计算用量
Meter->>Bill: 生成 Bill(bill_type=meter, amount=222)
Bill->>数据库: status=Unpaid
Note over 财务: 几天后业务人员处理
财务->>Filament: ConsumeAction(选水电费 Bill)
Filament->>Account: consume(Bill, 222)
Account->>数据库: 建 CO(type=Bill, meta.fund_source=prepaid)
Account->>数据库: 建 PrepaidTransaction(consume, 3400→3178)
Account->>Bill: recordPayment(222) → Paid
Account->>监听器: 触发 CollectionOrderCompleted
监听器->>数据库: 建 Receipt("水电费 ¥222")
财务-->>业户: 推送 + 收据
```
## 流水台账(累计)
| 流水 | type | amount | balance_before | balance_after | related_bill_id | 备注 |
|---|---|---|---|---|---|---|
| ... | (前面省略)| | | | | |
| N | consume | 800 | 4200 | 3400 | Bill #5月物业费 | 5 月物业费抵扣 |
| **N+1** | **consume** | **222** | **3400** | **3178** | **Bill #5月水电费** | **本场景** |
## 用量异常的处理
> [!warning] 用量翻倍 / 异常巨高如何处理
>
> **场景**:张阿姨家平时月用水 12 吨,5 月用了 80 吨(翻 7 倍)。原因可能是:
>
> | 原因 | 处理 |
> |---|---|
> | 水管漏水 | 业户自查,联系物业维修;账单按事实承担(可能可申请减免)|
> | 抄表录错 | 走 [[../adhoc/cancel-amount-error-redo]] 流程,反向调整账单 |
> | 集抄系统 bug | 运维介入,重新抄表 / 校准 |
> | 业户家用水设备故障 | 业户自担,可向物业申请协助维修 |
>
> 异常账单**不要直接抵扣** —— 先核实再处理,避免业户余额被错误清空。系统不主动识别"用量异常",由业务人员判断。
## 常见问题
> [!question] 水电费 Bill 是 Meter 模块生成的,跟其他账单有什么区别?
> 唯一差别在 `bill_type` 字段和金额来源(计算 vs 固定)。对预存款 consume 流程**完全透明** —— `ConsumeFromPrepaidAccountAction` 不区分 bill_type,所有 Bill 一视同仁。
> [!question] 业户预存款余额不够付水电费怎么办?
> 同 [[consume-monthly-property-bill]] 处理:
> - 跳过该账单 → 推送业户充值
> - 业户充值后再抵
> - 不部分抵(避免半付状态)
> [!question] 水、电、燃气分开还是合并出账?
> 看 Meter 模块配置 + 物业财务习惯。预存款抵扣端**支持两种**。
> [!question] 月底 100+ 户的水电费账单要挨个抵,跟物业费一起 100+ 户,业务人员吃得消吗?
> 同样痛点 —— 等 [[auto-deduction-design|自动 job]] 落地一起解决。job 实现后,**月初一次 job 同时抵扣物业费 + 水电费 + 其他账单**。
> [!question] 业户对水电费金额有异议(认为抄表错了)?
> 走 Meter / Bill 模块的纠错流程:
> 1. 业户提报
> 2. 物业核查抄表数据(物理表 vs 录入数据)
> 3. 错了 → 走 Bill 的 reverse + reissue 流程(详见 Meter / Bill 模块文档)
> 4. 没错 → 沟通业户接受(或走司法纠纷)
## 异常分支
- 物业费抵扣 → [[consume-monthly-property-bill]]
- 多账单一起抵 → [[consume-multiple-bills-priority]]
- 异常用量需 Bill 模块介入 → 见 Meter / Bill 模块(待补)
- 月初批量(未来)→ [[consume-batch-auto-monthly]]
## 相关文档
- [[consume-monthly-property-bill]]
- [[consume-multiple-bills-priority]]
- [[consume-via-bill-collection-type]]
- [[transaction-types]]
- [[auto-deduction-design]]

View File

@@ -0,0 +1,195 @@
---
title: prop-acc · prepaid · 场景 - 手动抵扣月度物业费
aliases:
- 抵扣物业费
- 预存款抵账单
- consume-monthly-property-bill
- 场景-预存款抵月物业费
tags:
- 场景
- prop-acc
- 预存款
- 消费
audience:
- 业户
- 业务人员
status: 已发布
sub_feature: prepaid
last_review: 2026-05-25
code_version: 2026-05-22
---
# 场景:手动抵扣月度物业费
预存款最高频操作 —— 月底物业费账单出来后,业务人员后台**手动触发** `ConsumeAction`,从业户的预存款余额扣对应金额、Bill 状态翻 Paid。**未来批量自动 job 落地后,这条路径变成"运维个例兜底"**(详见 [[auto-deduction-design]])。
## 典型情境
> [!example] 真实情境
> 张阿姨的 12-3-501 房 5 月物业费账单 ¥800 已出账。张阿姨预存款账户余额 ¥4,200。物业财务王主管月初批量为 100+ 户业户做物业费抵扣,张阿姨是其中一户。
## 业户视角
### 您会感受到什么
- 5 月底账单出来后,几天内收到推送:
> "您的 5 月物业费 ¥800 已自动从预存款扣减,余额 ¥3,400"
- 收到收据:"物业费 ¥800(5 月)"
- 小程序"我的预存款"显示新流水:`-800.00 抵扣 物业费(5月)`
- 小程序"我的账单"显示该账单 ✅ 已付
### 您要做什么
什么都不用做。看看就行 ——
- 如果余额够,账单自动归零,无感
- 如果余额不够,会收到"余额不足"提示,需要您手动充值或现金/微信付
> [!info] 与现金付的差异
> 业户拿到的收据**长一样**(都是"物业费 ¥800"),只是结算来源不同。详见 [[consume-via-bill-collection-type]]。
## 业务人员视角
### 第 1 步:确认账单已生成
后台 → 账单(Bill)模块 → 5 月物业费账单批量 → 状态 Unpaid → 列表里有张阿姨的账单。
### 第 2 步:打开张阿姨的预存款账户
后台 → 预存款 → 账户列表 → 按业户姓名搜 → 找到 Active 账户(balance=4200)→ 进 `ViewPrepaidAccount`
### 第 3 步:点击 `ConsumeAction`(标签"消费抵扣")
> [!warning] 按钮可见性
> `ConsumeAction` 守护:`canOperate()`(Active only)+ `balance > 0` + Policy `->authorize('consume')`。Frozen / Closed / 零余额账户灰化。
Modal 表单:
| 字段 | 填什么 |
|---|---|
| **关联账单(Bill)** | 选业户的未付账单(下拉显示该业户 community 内 status=unpaid 的账单)|
| **抵扣金额** | 自动带入账单金额(可改,部分抵扣场景)|
| **备注** | 选填,如 "5 月物业费手动抵扣" |
### 第 4 步:提交
系统调 `ConsumeFromPrepaidAccountAction`,事务内:
1. 校验 `canOperate()`(Active only)
2. 校验跨社区(Bill 与 Account 必须同 community)
3. 校验余额(≥ 抵扣金额)
4.`CollectionOrder`(`type=Bill`,`actual=+800`,`meta.fund_source=prepaid`,`Completed`)
5.`CollectionOrderBill` 关联 CO 与 Bill
6.`PrepaidAccount::consume($bill, $amount)`:
-`PrepaidTransaction`(`type=consume`,`amount=800`,`balance_before=4200`,`balance_after=3400`,`related_bill_id=...`,关联 CO)
- 更新 `balance=3400`
7.`Bill::recordPayment($amount)`:
- 更新 `Bill.status=Paid`
8. 触发 `CollectionOrderCompleted` → Listener 建 Receipt(走 Bill 渠道,文案"物业费 ¥800")
### 第 5 步:给收据 / 通知
后台找到新建 Receipt → 发业户(微信 / 邮件)。
## 系统流程
```mermaid
sequenceDiagram
participant 业户
participant 财务
participant Filament
participant ConsumeAction
participant PrepaidAccount
participant Bill
participant 数据库
participant 监听器
Note over 业户,财务: 5 月物业费账单已出,张阿姨 balance=4200
财务->>Filament: ViewPrepaidAccount → ConsumeAction(选 Bill, 800)
Filament->>ConsumeAction: handle(account, bill, 800)
ConsumeAction->>PrepaidAccount: canOperate() ? Active=true
ConsumeAction->>PrepaidAccount: community_id match Bill? yes
ConsumeAction->>PrepaidAccount: balance >= 800? 4200≥800 yes
ConsumeAction->>数据库: 开启事务
ConsumeAction->>数据库: 1. 建 CO(type=Bill, +800, meta.fund_source=prepaid)
ConsumeAction->>数据库: 2. 建 CollectionOrderBill 关联
ConsumeAction->>PrepaidAccount: 3. consume(bill, 800)
PrepaidAccount->>数据库: 建 PrepaidTransaction(consume, 4200→3400, related_bill_id)
PrepaidAccount->>数据库: 更新 balance=3400
ConsumeAction->>Bill: 4. recordPayment(800)
Bill->>数据库: status=Paid
ConsumeAction->>监听器: 5. 触发 CollectionOrderCompleted
监听器->>数据库: 建 Receipt("物业费 ¥800")
ConsumeAction->>数据库: 提交事务
Filament-->>财务: 成功
财务-->>业户: 推送 + 收据
```
## 流水台账(本场景在累计流水中)
| 流水 | type | amount | balance_before | balance_after | related_bill_id | 备注 |
|---|---|---|---|---|---|---|
| 1 | deposit | 5000 | 0 | 5000 | — | 首次充值 |
| **2** | **consume** | **800** | **5000** | **4200** | **Bill #5月物业费** | **本场景** |
| (后续 4 月 5 月各 800 ...) |
5 月账单进入 Paid 状态,张阿姨账户余额变 ¥3,400。
## 部分抵扣的特殊情况
若业户余额**不够全付**(例如余额 ¥500,账单 ¥800):
| 选项 | 当前实现 |
|---|---|
| 抵扣 ¥500,账单剩 ¥300 待付 | 看 `Bill::recordPayment()` 是否支持部分支付 |
| 全部跳过(不抵)| 等业户充值或其他方式付 |
**当前推荐**:跳过,告知业户"余额不足,请充值或选其他方式付"。部分抵扣需 Bill 模块配合。
## 常见问题
> [!question] Modal 表单里"关联账单"下拉如何过滤?
> 系统只显示:
> - 与本账户同社区(community_id 一致)
> - 业户本人(resident_id 一致)
> - 状态 Unpaid
> - 按 due_at 升序(最早到期的先,引导业务人员优先抵)
>
> 多个账单 → [[consume-multiple-bills-priority|按优先级抵扣]]
> [!question] 抵扣后业户问"我用预存款付的为啥收据写'物业费'?"
> 这是**有意设计**(详见 [[consume-via-bill-collection-type]]):业户感知一致,不管怎么付,收据都长一样。如果业户想知道"是用预存款付的",可在小程序"我的账单"看到付款方式 = "预存款抵扣"。
> [!question] 抵扣失败如何排查?
> 看后台 / 日志的错误信息:
> - "账户冻结" → 解冻
> - "跨社区不允许" → 业务人员选错账单 / 账户
> - "余额不足" → 业户先充值
> - "账单已 Paid" → 不要重复抵扣
> [!question] 月底 100+ 户挨个 Modal 抵扣太慢了吧?
> 是的,这就是**月初批量自动抵扣 job** 的存在意义(详见 [[auto-deduction-design]])。**job 实现前**业务人员必须挨个手动。
> [!question] 已抵扣的账单想撤回怎么办?
> 不可变流水设计。如果抵错(例如抵了别人的账单):
> - 走退款 [[refund-partial-after-consume]] —— 但这退的是预存款余额,不是"撤销抵扣"
> - 撤销账单需 Bill 模块支持 reverse,不在本场景
> - 实际:**预防胜于补救**,Modal 表单提交前再三确认 Bill ID
## 异常分支
- 余额不够 → 业户先充 [[deposit-additional-topup]] 再来抵
- 账户 Frozen → 先 [[unfreeze-after-verification]]
- 多张账单一起抵 → [[consume-multiple-bills-priority]]
- 计量类账单 → [[consume-meter-bill]]
- 月初批量(未来)→ [[consume-batch-auto-monthly]]
## 相关文档
- [[transaction-types]]
- [[consume-via-bill-collection-type]]
- [[account-state-machine]]
- [[consume-multiple-bills-priority]]
- [[consume-batch-auto-monthly]]
- [[auto-deduction-design]]

View File

@@ -0,0 +1,196 @@
---
title: prop-acc · prepaid · 场景 - 多个未付账单按 due_at 优先级抵扣
aliases:
- 多账单抵扣优先级
- 优先抵最早到期账单
- consume-multiple-bills-priority
- 场景-多账单优先级抵扣
tags:
- 场景
- prop-acc
- 预存款
- 消费
audience:
- 业户
- 业务人员
status: 已发布
sub_feature: prepaid
last_review: 2026-05-25
code_version: 2026-05-22
---
# 场景:多个未付账单按 due_at 优先级抵扣
业户某月有**多张未付账单**(物业费、水电费、电梯维护费等),余额需抵多张。**优先抵最早到期的账单**(避免逾期罚款)。本场景描述业务人员的批量抵扣逻辑,也是 [[auto-deduction-design|自动 job]] 的核心算法。
## 典型情境
> [!example] 真实情境
> 张阿姨家 5 月有 3 张未付账单:
>
> | 账单 | 金额 | due_at |
> |---|---|---|
> | 5 月物业费 | ¥800 | 5 月 15 日 |
> | 5 月水电费 | ¥1,200 | 5 月 20 日 |
> | Q2 电梯维护费 | ¥300 | 5 月 31 日 |
>
> 合计 ¥2,300,张阿姨账户余额 ¥2,500。**全部能抵**,但顺序很重要:
>
> 1. 物业费(5 月 15 日)— 最早到期,先抵
> 2. 水电费(5 月 20 日)
> 3. 电梯维护费(5 月 31 日)
>
> 抵完余 ¥200。
## 业户视角
### 您会感受到什么
- 5 月底收到 3 张收据(各账单一张),金额对应原账单
- 小程序"我的预存款"流水按抵扣时间倒序显示 3 笔 consume
- 小程序"我的账单"3 张全部 ✅ Paid
- 推送通知"5 月 3 张账单已抵扣,余额 ¥200"
### 您要做什么
什么都不用。看明白即可。
> [!info] 余额够不够全付决定行为
> - **够全付**:全部抵,余额剩下的留账户
> - **不够全付**:按优先级先抵最早到期的,后面的留 Unpaid 状态 → 推送"余额不足,请充值"
## 业务人员视角
### 第 1 步:打开账户
后台 → 预存款 → 找到张阿姨账户 → 进 `ViewPrepaidAccount`(balance=2500)。
### 第 2 步:逐张抵扣(手动模式)
> [!warning] 当前没有"一键全抵"按钮
> 业务人员需要**对每张账单各点一次** `ConsumeAction`。这是 [[auto-deduction-design|自动 job]] 要解决的痛点。
**正确顺序**(按 due_at 升序):
| 步骤 | 选 Bill | 抵 amount | 之后余额 |
|---|---|---|---|
| 1 | 5 月物业费(due 5/15) | 800 | 2500 → 1700 |
| 2 | 5 月水电费(due 5/20) | 1200 | 1700 → 500 |
| 3 | Q2 电梯维护费(due 5/31) | 300 | 500 → 200 |
每张账单走完整 [[consume-monthly-property-bill]] 流程(Modal → 提交 → 触发监听器 → Receipt)。
### 第 3 步:核对结果
- 3 张账单全 ✅ Paid
- 账户余额 ¥200
- 3 张 Receipt 已生成(分别 ¥800 ¥1,200 ¥300)
## 优先级排序逻辑(自动 job 用)
未来自动 job 实现后,**按以下顺序排序未付账单**:
| 排序键 | 升序/降序 | 业务理由 |
|---|---|---|
| 1. `due_at` | 升序 | **最早到期的先抵**(避免逾期产生滞纳金 / 影响信用) |
| 2. `bill_type` | 自定义("物业费" → "水电费" → "其他") | 物业费是核心服务费,优先 |
| 3. `amount` | 升序 | 同优先级下,**小额先抵清**(避免余额不够时多张大账单都半抵)|
| 4. `created_at` | 升序 | 兜底:早建的先 |
伪代码:
```python
unpaid_bills = sorted(
bills,
key=lambda b: (b.due_at, BILL_TYPE_ORDER[b.bill_type], b.amount, b.created_at)
)
balance = account.balance
for bill in unpaid_bills:
if balance <= 0:
break
if balance >= bill.amount:
consume(account, bill, bill.amount)
balance -= bill.amount
else:
# 余额不够全付该账单 → 跳过,等业户充值
notify(account.resident, "余额不足,无法抵 ¥{bill.amount}{bill.bill_type}")
# 或部分抵(看 Bill 是否支持):consume(account, bill, balance); balance = 0; break
```
## 余额不够全付的策略对比
假设张阿姨账户余额 ¥1,500,3 张账单合计 ¥2,300:
| 策略 | 行为 | 结果 |
|---|---|---|
| **全部跳过** | 余额不够任何一笔不动 | 3 张全 Unpaid,余额 ¥1,500 闲置 |
| **按优先级抵到不够为止**(推荐) | 物业费 800 → 余 700;水电费 1200 不够跳过;电梯费 300 余 700 ≥ 300 → 抵 → 余 400 | 物业费 + 电梯费 Paid,水电费 Unpaid,余额 ¥400 |
| **按优先级抵 + 部分抵末张** | 物业费 800 → 余 700;水电费 1200 > 700 → 抵 700 部分 → 水电费余 500;电梯费 300 余 0 跳过 | 物业费 Paid + 水电费部分付 + 电梯费 Unpaid + 余额 0 |
**推荐第二种**(按优先级抵到不够为止,不做部分抵)。理由:
- 简单,Bill 模块不用支持部分付
- 业户看到余额还有钱但有账单未付,会主动充值
- 避免"抵了一半"的复杂状态
**第三种**等 Bill 模块支持部分支付后再考虑。
## 系统流程(手动模式 3 笔操作)
```mermaid
sequenceDiagram
participant 财务
participant Filament
participant Account
participant 数据库
Note over 财务: 余额 2500,3 张未付账单
财务->>Filament: ConsumeAction(物业费 800)
Filament->>Account: consume(物业费, 800)
Account->>数据库: balance 2500→1700, Bill Paid
财务->>Filament: ConsumeAction(水电费 1200)
Filament->>Account: consume(水电费, 1200)
Account->>数据库: balance 1700→500, Bill Paid
财务->>Filament: ConsumeAction(电梯费 300)
Filament->>Account: consume(电梯费, 300)
Account->>数据库: balance 500→200, Bill Paid
Note over 数据库: 3 张账单 Paid + 3 张 Receipt + 余额 200
```
## 常见问题
> [!question] 业务人员漏抵某张账单怎么办?
> 单纯漏抵 → 后续发现再抵一次。如果业户因此被收滞纳金,物业可走 [[../adhoc/cancel-amount-error-redo]] 之类的补救路径。
> [!question] 业务人员抵错优先级(先抵晚到期的)?
> 不影响资金正确性(账户余额扣对了,账单状态更新对了)。**业务上**可能让早到期的账单进入逾期。**预防** = 培训业务人员看 due_at,**根治** = 上自动 job。
> [!question] 跨多个业户批量抵扣可以吗?
> 当前不行,只能一个账户一个账户操作。**批量是自动 job 的核心需求**。
> [!question] 业务人员选错账单(选了别人的)?
> Modal 的账单下拉**已经过滤同业户 + 同社区**,理论上选不到别人的。除非 UI / 数据有 bug,否则不会发生。
> [!question] 部分抵扣场景频繁吗?
> 业务上罕见 —— 业户通常一次充够覆盖几个月。如果某业户经常余额不够,业务人员应主动提醒"建议充够 3 个月"。
## 异常分支
- 单笔抵扣 → [[consume-monthly-property-bill]]
- 计量类账单(水电费)→ [[consume-meter-bill]]
- 余额不够 → 告知业户充值
- 月初批量(未来)→ [[consume-batch-auto-monthly]]
- 账户冻结 → [[unfreeze-after-verification]]
## 相关文档
- [[consume-monthly-property-bill]]
- [[consume-meter-bill]]
- [[consume-batch-auto-monthly]]
- [[auto-deduction-design]]
- [[transaction-types]]

View File

@@ -0,0 +1,163 @@
---
title: prop-acc · prepaid · 场景 - 已有账户追加充值
aliases:
- 追加充值预存款
- 预存款续充
- deposit-additional-topup
- 场景-预存款追加充值
tags:
- 场景
- prop-acc
- 预存款
- 充值
audience:
- 业户
- 业务人员
status: 已发布
sub_feature: prepaid
last_review: 2026-05-25
code_version: 2026-05-22
---
# 场景:已有账户追加充值
业户**已有 Active 预存款账户**,余额不够 / 想多存,继续充值。比首次开户简单 —— 不建账户,只加流水。
## 典型情境
> [!example] 真实情境
> 张阿姨 3 个月前充了 ¥5,000 预存款,期间扣了 ¥2,400(物业费 800 × 3),余额 ¥2,600。下个月还要扣 ¥800 + ¥600 水电费,觉得余额勉强够,**再充 ¥3,000** 凑个整。
## 业户视角
### 第 1 步:到前台 / 小程序
跟物业管家说"我预存账户加 ¥3,000"。
### 第 2 步:付款
支付方式同首次充值。
### 第 3 步:拿收据
"预付款充值 ¥3,000"。
### 第 4 步:余额查看
后台 / 小程序看到:
- 上次余额 ¥2,600 + 本次 ¥3,000 = **当前余额 ¥5,600**
- 后续账单自动从这扣
## 业务人员视角
> [!info] 与首次充值的差异
> **不开新账户**,在既有账户上 `DepositAction` 加流水。
### 第 1 步:找到既有账户
后台 → 预存款 → 账户列表 → 按业户姓名 / 房号搜索 → 找到 Active 账户。
### 第 2 步:进 `ViewPrepaidAccount`
详情页右上角点 **`DepositAction`**(标签"充值")。
> [!warning] 按钮可见性
> `DepositAction` 守护:`canOperate()`(Active only)+ Policy `->authorize('deposit')`。Frozen / Closed 灰化。
### 第 3 步:Modal 表单
| 字段 | 填什么 |
|---|---|
| **充值金额** | ¥3,000 |
| **支付方式** | 现金 / 微信 / POS / 银行转账 |
| **收款银行账户** | 微信/POS/转账选对应银行 |
| **备注** | 选填 |
### 第 4 步:提交
系统调 `PrepaidAccount::deposit($amount, ...)`,事务内:
1. 模型层校验 `canOperate()`(Active only)
2.`CollectionOrder`(`type=Prepaid`,`actual=+3000`,`Completed`)
3.`PrepaidTransaction`(`type=deposit`,`amount=3000`,`balance_before=2600`,`balance_after=5600`,关联 CO)
4. 更新 `PrepaidAccount.balance=5600`
5. 触发 `CollectionOrderCompleted` → Listener 建 Receipt"预付款充值 ¥3,000"
### 第 5 步:给收据
打印 / 发微信。
## 系统流程
```mermaid
sequenceDiagram
participant 业户
participant 前台
participant Filament
participant PrepaidAccount
participant 数据库
业户->>前台: 给预存账户加 3000
前台->>Filament: ViewPrepaidAccount → DepositAction(modal)
Filament->>PrepaidAccount: deposit(3000, ...)
PrepaidAccount->>PrepaidAccount: canOperate()? Active=true
PrepaidAccount->>数据库: 开启事务
PrepaidAccount->>数据库: 1. 建 CO (Prepaid, +3000, Completed)
PrepaidAccount->>数据库: 2. 建 PrepaidTransaction (deposit, 2600→5600)
PrepaidAccount->>数据库: 3. 更新 balance=5600
PrepaidAccount->>监听器: 触发 CollectionOrderCompleted
监听器->>数据库: 建 Receipt (预付款充值 ¥3,000)
PrepaidAccount->>数据库: 提交
Filament-->>前台: 成功
前台->>业户: 收据
```
## 流水台账(本场景在累计流水中的位置)
| 流水 | type | amount | balance_before | balance_after | 备注 |
|---|---|---|---|---|---|
| 1 | deposit | 5000 | 0 | 5000 | 3 个月前首次充值 |
| 2 | consume | 800 | 5000 | 4200 | 第 1 月物业费 |
| 3 | consume | 800 | 4200 | 3400 | 第 2 月物业费 |
| 4 | consume | 800 | 3400 | 2600 | 第 3 月物业费 |
| **5** | **deposit** | **3000** | **2600** | **5600** | **本次追加** |
## 常见问题
> [!question] Frozen 账户能追加充值吗?
> **不能**。`canOperate()` 只允许 Active。详见 [[exception-refund-on-frozen|三层守护]](deposit / consume / refund 都一样)。
>
> 如果业户硬要充:
> - 系统层无法绕过(模型层兜底)
> - **业务层** 需先 [[unfreeze-after-verification|解冻]] → 再充
> - 不可"暂存钱等解冻后录入" —— 不合规
> [!question] Closed 账户能追加充值吗?
> **不能**(同上)。需要开新账户,但**一户一账约束阻塞**(详见 [[one-account-per-resident]] "已知设计 gap")。
> [!question] 同时多笔追加(一天充两次)可以吗?
> 可以。每次独立 Action,各自一笔 Transaction + CO + Receipt。账户 balance 累加。
> [!question] 充值过多担心退不出来?
> 任何时候可走 [[refund-partial-after-consume]] 或 [[refund-full-resident-moveout]] 退余。预存款不像押金有"装修结束才能退"的业务节点,**随时可退**。
> [!question] 业户问"我能用别人的微信付吗?"
> 系统不限制实际支付来源(微信扫码用谁付都行)。**业务上**:
> - 账面缴款人是业户本人(`PrepaidAccount.community_user_profile_id` 不变)
> - 实际付钱的是谁是业户自己的事
> - 退款时**只退给账面缴款人**(业户本人),不是实际付钱的微信号
## 异常分支
- 业户从未充过 → 走 [[deposit-first-time]] 开户
- 充错金额 → [[refund-partial-after-consume]]
- 账户冻结 → 先 [[unfreeze-after-verification]] 解冻
## 相关文档
- [[deposit-first-time]]
- [[account-state-machine]]
- [[consume-monthly-property-bill]]
- [[refund-partial-after-consume]]
- [[exception-refund-on-frozen]]

View File

@@ -0,0 +1,187 @@
---
title: prop-acc · prepaid · 场景 - 首次开户充值 5000
aliases:
- 首次充值预存款
- 开预存款账户
- deposit-first-time
- 场景-首次充值预存款
tags:
- 场景
- prop-acc
- 预存款
- 充值
audience:
- 业户
- 业务人员
status: 已发布
sub_feature: prepaid
last_review: 2026-05-25
code_version: 2026-05-22
---
# 场景:首次开户充值 5000
业户**第一次**开预存款账户并充值。一户一账约束:同业户在同社区只能开一个,系统在提交时校验。
## 典型情境
> [!example] 真实情境
> 张阿姨(12-3-501)每月物业费 ¥800,觉得月月去前台缴麻烦,跟物业管家说:"我一次充半年,以后从这里自动扣行不行?"
>
> 物业管家:"行,我帮您开个预存款账户,您充 ¥5,000 进去,以后账单出来自动从这里扣。"
## 业户视角
### 第 1 步:跟物业说要充值
- 到前台 / 物业管家微信 / 小程序(若开通)
- 表达"我想预存,以后自动扣账单"
### 第 2 步:确认充值金额
通常建议:**3-6 个月账单的金额**。少了频繁充值,多了占用资金。
- 月物业费 ¥800 → 充 ¥3,000-¥5,000(够 3-6 个月)
- 加水电费一起 → 充 ¥5,000-¥10,000
### 第 3 步:付款
支付方式:
- **现金**
- **微信扫码**
- **POS 刷卡**
- **银行转账**
### 第 4 步:拿收据
"预付款充值 ¥5,000"。
> [!info] 这张收据是普通正数收据
> 跟付物业费的收据长一样,只是文案不同。详见 [[transaction-types]]。
### 第 5 步:后续
- 物业费账单出来 → 业务人员手动 / (未来)自动抵扣 → 您收到"物业费 ¥800" 收据
- 余额 ¥4,200 留账户里,下月继续扣
## 业务人员视角
### 第 1 步:核实业户身份
- 业户档案存在(否则要先建)
- 业户当前社区(决定 community_id)
### 第 2 步:打开后台
后台 → 预存款 → **新建账户**(`ListPrepaidAccounts` 的 Create 按钮)。
### 第 3 步:填表单
| 字段 | 填什么 |
|---|---|
| **业户档案(`community_user_profile_id`)** | 通过房号 / 手机号 / 姓名找到张阿姨 |
| **社区(`community_id`)** | 自动带入业户所在社区(或手动选) |
| **首次充值金额** | ¥5,000 |
| **支付方式** | 现金 / 微信 / POS / 银行转账 |
| **收款银行账户** | 微信/POS/转账选对应银行;现金可空 |
| **备注** | 选填,如 "业户要求月度自动扣账" |
> [!warning] 一户一账校验
> 系统提交时检查 `(community_id, community_user_profile_id)` 是否已存在:
> - 已有 Active → 提示"该业户在本社区已有预存款账户,请直接充值" → 引导到 [[deposit-additional-topup]]
> - 已有 Frozen → 提示"账户冻结中,请先解冻"
> - 已有 Closed → 当前阻塞(见 [[one-account-per-resident]] "已知设计 gap" 段)
> - 无 → 正常建账
### 第 4 步:提交
系统在事务内:
1.`PrepaidAccount`(`status=Active`,`balance=5000`)
2.`CollectionOrder`(`collection_type=Prepaid`,`actual_amount=+5000`,`status=Completed`)
3.`PrepaidTransaction`(`type=deposit`,`amount=5000`,`balance_before=0`,`balance_after=5000`,关联 CO)
4. 触发 `CollectionOrderCompleted` 事件
5. Listener `generatePrepaidReceiptItems` 建 Receipt + ReceiptItem"预付款充值 ¥5,000"
### 第 5 步:打印 / 发收据
后台收据列表找到新生成 Receipt → 打印 / 微信发业户。
## 系统流程
```mermaid
sequenceDiagram
participant 业户
participant 前台
participant Filament
participant 数据库
participant 监听器
业户->>前台: 充 5000 预存款
前台->>Filament: ListPrepaidAccounts → Create
Filament->>数据库: 校验 unique(community_id, profile_id) → 通过
Filament->>数据库: 开启事务
Filament->>数据库: 1. 建 PrepaidAccount (Active, balance=5000)
Filament->>数据库: 2. 建 CollectionOrder (type=Prepaid, +5000, Completed)
Filament->>数据库: 3. 建 PrepaidTransaction (deposit, 0→5000, 关联 CO)
Filament->>监听器: 4. 触发 CollectionOrderCompleted
监听器->>数据库: 5. 建 Receipt ("预付款充值 ¥5,000")
Filament->>数据库: 提交事务
Filament-->>前台: 成功 + 显示新账户
前台->>业户: 给收据
```
## 与 deposit 首次缴款的对比
| 维度 | 押金首次缴款 | **预存款首次充值** |
|---|---|---|
| 表单字段 | payer_type / fee_type / asset 等多个 | **只需 community_user_profile** |
| 业户/缴款人差异 | 缴款人可与业户不同(装修公司代缴)| **缴款人必须是业户本人** |
| CollectionType | Deposit | **Prepaid** |
| 同业户多账户 | ✅ 多种费类多账户 | ❌ **一户一账** |
| 关账机制 | 退完自动 Closed | 退完仍 Active(可继续充) |
## 常见问题
> [!question] 业户已有 Closed 账户,如何开新?
> 当前系统阻塞(unique 约束)。可选:
> - 联系运维 tinker 改账户名(罕见)
> - 业务上说服业户用现金 / 微信付,不开新预存账户
> - 系统层加 `WHERE status != 'closed'` 软约束(待业务方拍板)
>
> 详见 [[one-account-per-resident]] "已知设计 gap" 段。
> [!question] 跨社区业户(同时住两个小区)怎么开?
> 各社区独立账户(各自 unique)。在 A 社区开一个、B 社区开一个,各自独立余额。
> [!question] 充值金额有上限吗?
> 系统层面无硬性上限。业务上建议:
> - 不超过 12 个月账单合计(避免资金被冻在物业账上太久)
> - 单笔大额(>10000)走银行转账,留银行流水
> - 单笔超 50000 需财务上报(防风险)
> [!question] 充错金额(把 5000 录成 50000)怎么办?
> 不要改流水。建一笔 `Refund` ¥45,000(走 [[refund-partial-after-consume]] 流程),业户拿到红字"预付款退款 ¥-45,000",事后审计完整可追。
> [!question] 业户不知道这账户怎么用,需要培训吗?
> 关键点:
> - 余额能抵物业费 / 水电费 / 其他账单
> - 余额随时可查(小程序 / 微信对账单)
> - 余额随时可退(业务人员后台操作)
> - 余额不够时账单不会自动扣 → 业户仍需补缴
## 异常分支
- 业户已有账户 → [[deposit-additional-topup]]
- 业户充错金额想撤 → [[refund-partial-after-consume]](走部分退款)
- 业户后悔不想用预存了 → [[refund-full-resident-moveout]] + [[close-resident-moveout]]
## 相关文档
- [[prepaid-account-vs-transaction]]
- [[account-state-machine]]
- [[one-account-per-resident]]
- [[transaction-types]]
- [[deposit-additional-topup]]
- [[consume-monthly-property-bill]]

View File

@@ -0,0 +1,180 @@
---
title: prop-acc · prepaid · 场景 - 小程序在线充值(待补)
aliases:
- 小程序充值预存款
- 线上充值预存款
- deposit-via-miniapp-pending
- 场景-小程序充值预存款
tags:
- 场景
- prop-acc
- 预存款
- 充值
- 待补
audience:
- 业户
- 产品
- 架构师
status: 草稿
sub_feature: prepaid
last_review: 2026-05-25
code_version: 2026-05-22
---
# 场景:小程序在线充值(待补)
> [!warning] 本场景**代码未实现**
> 当前所有充值都是**业务人员后台手动触发**。业户**没有自助充值入口**(微信小程序 / 公众号 / H5 都没接入)。本文档描述设计意图,等支付网关对接时一起落地。
>
> issue.md Q4 "待补" 段记录:
> > 小程序在线充值 + 退款 webhook:同保证金模块,等支付网关对接时一起做
## 为什么这个场景重要
预存款的**真正价值**是业户**自助**预存 + 系统自动抵账单。如果业户每次充值都要"跑前台 / 找物业管家",体验跟去前台月月缴费没区别,预存款产品价值大打折扣。
**自助充值是产品落地的必备入口**
## 业务场景(目标态)
### 业户视角
> [!example] 真实情境(目标态)
> 陈先生(年轻业主)某晚 11 点查看本月物业费账单,看到余额不够,马上在小程序"我的预存款"页面充值:
>
> 1. 点"立即充值"
> 2. 选金额(500 / 1000 / 3000 / 5000 / 自定义)
> 3. 弹出微信支付确认
> 4. 输入密码 / 指纹 → 付款成功
> 5. 小程序 3 秒后提示"充值成功,余额已到账"
>
> 第二天物业费账单自动从余额扣,陈先生收到推送"已抵扣物业费 ¥800"。
### 业务人员视角
业务人员**几乎不感知**:
- 不需要在后台手动建账户 / 录充值
- 月底对账时,看 CollectionOrder 列表多了一批 `payment_channel=微信``fund_source=external` 的预存款充值单
- 异常(支付掉单 / 超时未付)由系统自动处理
## 关键技术挑战
### 1. 支付网关对接
| 选项 | 优 | 缺 |
|---|---|---|
| 微信支付商户号 | 业户熟悉,转化高 | 资质要求高,手续费 0.6% |
| 支付宝 | 大额支付习惯 | 同上 |
| 银联 / 网银 | 大额转账 | 体验差 |
**推荐**:微信支付 + 支付宝双通道,与一次性收费 B 流复用([[../adhoc/flow-b-miniapp-wechat-pay|adhoc B 流]])。
### 2. CollectionOrder 状态机
复用与 adhoc B 流相同的 Pending → Completed 流程([[../adhoc/flow-a-vs-flow-b|A 流与 B 流]]):
```mermaid
sequenceDiagram
participant 业户
participant 小程序
participant 系统
participant 支付网关
业户->>小程序: 选 5000 充值
小程序->>系统: 建 CollectionOrder(type=Prepaid, +5000, **status=Pending**)
系统-->>小程序: 返回订单号 + 锁定金额
小程序->>支付网关: 调起微信支付
业户->>支付网关: 输入密码 / 指纹付款
支付网关->>系统: 支付回调 webhook
系统->>系统: 校验签名 + 金额
系统->>系统: CO.status = Completed
系统->>系统: 调 PrepaidAccount::deposit(5000)
系统->>系统: 触发 CollectionOrderCompleted → Receipt
系统-->>业户: 推送"充值成功"
```
### 3. 自动开户
如果业户在小程序充值时**还没有预存款账户**:
| 方案 A:必须先开户 | 方案 B:充值时自动开户 |
|---|---|
| 业户去前台开户 → 小程序充值 | 小程序充值流程内自动建账户 |
| 体验割裂(为啥要去前台?) | 体验顺畅 |
| 业务人员必须介入 | 全程自动 |
**推荐方案 B**:充值时若 `PrepaidAccount` 不存在,自动建 Active 账户(`opened_at` = 充值时间)。
### 4. 失败处理
| 失败场景 | 处理 |
|---|---|
| 业户付了款,回调延迟 | CO 仍 Pending → 业户重复点充值 → 防重(检查 24h 内同业户同金额 Pending CO)|
| 业户付了款,回调丢失 | 定时任务扫描 Pending > 30 min 的 CO,主动查询支付网关 |
| 业户取消支付 | CO 翻 Failed,余额不变 |
| 业户付款金额与订单不符(异常)| 拒绝,告警,人工介入 |
### 5. 退款 webhook
业户在小程序自助申请退款:
```mermaid
sequenceDiagram
业户->>小程序: 申请退余 3000
小程序->>系统: 建退款申请单(待业务审批)
Note over 系统: 业务审批通过后:
系统->>支付网关: 调退款 API
支付网关->>系统: 退款回调 webhook
系统->>系统: 建红字 CO + PrepaidTransaction(refund)
系统-->>业户: 推送"退款成功"
```
**关键**:不像后台手动退款是"业务人员决定退多少",小程序自助退款必须**先建审批工单**,业务方审核(防止业户恶意大额退款),通过后才走支付网关退款。
## 待讨论 / 决策
| 问题 | 选项 |
|---|---|
| **支付通道** | 微信支付 / 支付宝 / 银联 / 全部都开 |
| **充值起步** | 最低 100 / 500 / 1000 |
| **充值上限** | 单笔 5000 / 10000 / 不限 |
| **自动开户** | 充值时自动建 Active 账户 vs 业户必须先去前台 |
| **退款审批** | 业户提交即退 vs 业务审批后退 / 大额(>5000)需审批 |
| **超时未付** | 30 分钟自动取消 / 24 小时 / 不取消(避免业户晚付被取消)|
业务方拍板前,以上问题需明确。
## 当前替代方案(等代码就位前)
业户想自助充值的,目前只能:
| 方法 | 体验 |
|---|---|
| 联系物业管家微信 → 转账给物业财务 → 财务后台录入 | 还行,但有时延 |
| 到前台缴 → 现金 / POS | 慢、要跑 |
| 银行直接对公转账 → 备注业户姓名房号 → 财务认领 | 慢 + 复杂 |
**所有路径都需要业务人员介入** —— 体验远不如小程序自助。这就是为什么这个场景"产品价值高、紧迫性强"。
## 关联场景
实现后,以下场景的设计会发生关联变化:
- [[deposit-first-time]] / [[deposit-additional-topup]]:多一条"业户自助充值"路径,业务人员手动充值场景变少(但仍需保留,给老业主用)
- [[consume-batch-auto-monthly]]:小程序充值后自动到账,月初批量自动抵扣 job 可立即用上新余额
- [[refund-full-resident-moveout]] / [[refund-partial-after-consume]]:多一条"业户小程序自助申请"路径,需要审批流
## 异常分支(未来落地后)
- 支付网关掉单 / 超时 → 系统重试 / 业务介入
- 业户充错金额 → 走退款流程
- 业户重复提交 → 防重检测
## 相关文档
- [[auto-deduction-design]]
- [[deposit-first-time]]
- [[deposit-additional-topup]]
- [[../adhoc/flow-a-vs-flow-b|A 流与 B 流]](adhoc 的小程序在线模式,参考实现)
- [[../adhoc/flow-b-miniapp-wechat-pay|adhoc 小程序微信付]]

View File

@@ -0,0 +1,197 @@
---
title: prop-acc · prepaid · 场景 - 跨社区消费防御
aliases:
- 跨社区消费拦截
- 跨小区抵账单防御
- exception-cross-community-consume
- 场景-预存款跨社区消费防御
tags:
- 场景
- prop-acc
- 预存款
- 异常
audience:
- 业务人员
- 架构师
status: 已发布
sub_feature: prepaid
last_review: 2026-05-25
code_version: 2026-05-22
---
# 场景:跨社区消费防御
业务人员**误选**了"A 社区业户预存款账户"去抵"B 社区账单",系统**直接拦截**,不允许。`PrepaidAccount::consume()` 模型方法内置 community 校验,任何调用方都跑不掉。
## 典型情境
> [!example] 真实情境
> 业户陈先生在 A 社区(自住)和 B 社区(投资房出租)各有预存款账户:
>
> - A 社区账户余额 ¥5,000
> - B 社区账户余额 ¥200
>
> B 社区刚出账单"出租房物业费 ¥800",**B 社区余额 ¥200 不够付**。业务人员小李心想"陈先生 A 社区还有 ¥5,000,先抵 B 社区账单凑合下,以后再给陈先生说"。他打开陈先生 A 社区账户,选 B 社区账单,点抵扣 —— **系统直接拦截**,提示"预存款账户与账单不在同一社区,无法抵扣"。
## 业务人员视角
### 您看到什么
| 时刻 | 看到 |
|---|---|
| 进入 A 社区账户的 ViewPrepaidAccount | 状态 Active,balance 5000 |
| `ConsumeAction` Modal 表单 → 账单下拉 | **下拉只显示 A 社区账单**(B 社区账单不在下拉里,UI 层已过滤) |
| 如果硬调 API / tinker | 抛 InvalidArgumentException:"预存款账户与账单不在同一社区,无法抵扣" |
### 三道防御
防御层级与 [[exception-refund-on-frozen]] 类似:
1. **UI 层**:Modal 的账单下拉**只显示当前账户所在 community 的账单**
2. **Action 层**:`ConsumeFromPrepaidAccountAction` 入口校验 community 匹配
3. **模型层**(最严):`PrepaidAccount::consume()` 内置 community 校验,抛 InvalidArgumentException
```php
// PrepaidAccount.php
public function consume(Bill $bill, ...): PrepaidTransaction
{
if ($bill->community_id !== $this->community_id) {
throw new InvalidArgumentException(
'预存款账户与账单不在同一社区,无法抵扣'
);
}
// ... 其余逻辑
}
```
任何对 `PrepaidAccount::consume()` 的修改都会触发测试,确保守护不被无意中放宽。
### 正确路径
不同社区**独立处理**:
| 业户场景 | 正确处置 |
|---|---|
| A 社区想抵 A 社区账单 | 用 A 社区账户(本场景正常情况)|
| B 社区想抵 B 社区账单 | 用 B 社区账户 |
| **A 社区有钱 + B 社区缺钱** | **业户先在 B 社区充值**(走 [[deposit-additional-topup]]),再用 B 社区账户抵 B 社区账单 |
> [!info] 业务上能"A 社区退款 → 业户拿钱 → B 社区充值"吗?
> 完全可以,但**是业户自己的操作**:
>
> 1. 业务人员从 A 社区账户退 ¥800 给业户
> 2. 业户拿到 ¥800
> 3. 业户在 B 社区充 ¥800
> 4. B 社区账户抵账单
>
> 三步业务流程,**资金不直接跨社区流动** —— 各社区财务独立核算。
## 为什么这条守护这么严
> [!warning] 跨社区抵扣的灾难性后果
>
> 假设系统允许跨社区抵扣:
>
> | 反例 | 后果 |
> |---|---|
> | A 社区物业的钱**流出**到 B 社区物业 | 账面对不上,银行流水追溯困难 |
> | A 社区财务报表显示"代收 B 社区物业费" | 越权,A 社区无权管 B 社区收款 |
> | 业户提现:"我在 A 社区有 ¥1,000,在 B 社区抵 ¥1,000,然后从 A 社区退 ¥1,000" | A 社区净流出 ¥2,000(实际只该 ¥1,000)|
> | 各社区物业可能独立公司化,跨社区抵扣 = 关联交易 | 法务 / 税务问题 |
>
> 每个物业项目独立财务核算是行业基本要求。**跨社区抵扣 = 财务边界破坏**。
## 业户视角
业户在小程序"我的预存款"看到自己有 A、B 两个独立账户。**互不联通**,各自余额、各自流水、各自抵扣范围。
如果想跨社区"调资金",**只能业户自己做**:A 社区退款 → 自己拿钱 → B 社区充值。
## 系统流程
```mermaid
sequenceDiagram
participant 业务
participant Filament
participant ConsumeFromPrepaidAccountAction
participant PrepaidAccount[A 社区账户]
participant Bill[B 社区账单]
Note over 业务: 业务人员误想抵跨社区
业务->>Filament: 直接调 API 或 tinker:account_A.consume(bill_B, 800)
Filament->>ConsumeFromPrepaidAccountAction: handle(account_A, bill_B, 800)
ConsumeFromPrepaidAccountAction->>PrepaidAccount: consume(bill_B, 800)
PrepaidAccount->>PrepaidAccount: bill_B.community_id != self.community_id?
PrepaidAccount-->>ConsumeFromPrepaidAccountAction: throw InvalidArgumentException
ConsumeFromPrepaidAccountAction-->>Filament: 拦截 + 日志
Filament-->>业务: 报错:"预存款账户与账单不在同一社区,无法抵扣"
Note over PrepaidAccount,Bill: 无任何资金动作 / 流水产生
```
## 测试断言
代码层有专门测试覆盖此异常路径:
```php
test('cannot consume cross-community bill', function () {
$accountA = PrepaidAccount::factory()->for($communityA)->create(['balance' => 5000]);
$billB = Bill::factory()->for($communityB)->create(['amount' => 800]);
expect(fn () => $accountA->consume($billB, 800))
->toThrow(InvalidArgumentException::class, '预存款账户与账单不在同一社区');
expect($accountA->fresh()->balance)->toBe(5000.0); // 余额未变
expect(PrepaidTransaction::count())->toBe(0); // 流水未建
});
```
## 常见问题
> [!question] 同一物业公司管理多个社区,能不能允许跨社区抵?
> **业务层面**也不行。即使物业公司一家,每个社区**独立财务核算**(看营业执照、税务登记)。除非:
>
> - 多社区合并财务(罕见,需法务批准)
> - 业务方明确要求(走架构师评估)
>
> 当前设计假设"社区独立",未来若改变,需重新评估守护逻辑。
> [!question] 业户在小程序操作时能看到跨社区账户吗?
> 设计上**应该分开显示**。例如:
>
> ```
> 我的预存款
> ├── A 社区(自住):¥5,000
> └── B 社区(投资):¥200
> ```
>
> 不要混合显示总余额(避免业户误以为"跨社区可用")。
> [!question] 业户跨社区调资金的体验差,有什么改进?
> 长期可考虑:
>
> - **跨社区互转**:业户在小程序点"从 A 社区转 ¥1000 到 B 社区" → 系统两步操作(A 退 + B 充)+ 一张统一凭证
> - 但**资金仍走业户**(银行 / 微信回退再充值),系统不直接跨社区流动
> - 当前没有,需业务方推动
> [!question] 业户失联,A 社区有钱,B 社区欠费严重,能挪吗?
> **不能挪**。业户失联是业户的事,各社区独立催收。B 社区欠费走法务流程。
## 与 deposit 的对比
deposit 也有类似多账户(同业户可以有多种押金类型账户),但**没有跨社区消费场景**(押金不抵账单,不存在跨账户消费需求)。所以这条守护是 prepaid 独有。
## 异常分支
- 业务人员真的需要跨社区操作 → 业户自己走"A 退 + B 充"两步
- 多社区合并财务的特殊业务 → 架构师评估后改设计(目前无)
- 业务方提需求要跨社区抵扣 → 走架构评审,理由要充分
## 相关文档
- [[one-account-per-resident]]
- [[consume-monthly-property-bill]]
- [[consume-via-bill-collection-type]]
- [[exception-refund-on-frozen]]
- [[../cross/concepts/org-hierarchy|组织结构]]

View File

@@ -0,0 +1,233 @@
---
title: prop-acc · prepaid · 场景 - 冻结状态退款被三层守护拦截
aliases:
- 冻结状态退款被拒
- exception-refund-on-frozen
- 场景-冻结状态退款拦截
tags:
- 场景
- prop-acc
- 预存款
- 异常
audience:
- 业务人员
- 架构师
status: 已发布
sub_feature: prepaid
last_review: 2026-05-25
code_version: 2026-05-22
---
# 场景:冻结状态退款被三层守护拦截
业务人员对 Frozen 账户**误**点退款 / 充值 / 消费 等按钮,系统**三层**(UI / Policy / 模型)守护拦截。**最严**在模型层 —— 即使绕过 UI 和 Policy,模型方法的 `canOperate()` 检查兜底,任何调用方都跑不掉。
> [!info] 历史教训
> 这是 issue.md Q4 第二轮明确修复的**严重漏洞**(项目第 9 项):原 `PrepaidAccount::refund()` 方法**完全不查状态**(只查金额),Frozen / Closed 账户都能被退款。模型层是最底层防御,Action 类绕过 = 没人挡住。修复后所有写入模型方法(`deposit / consume / refund`)统一调 `canOperate()`,严格只允许 Active。
## 典型情境
> [!example] 真实情境
> 王女士预存款账户因风控异常被冻结(详见 [[freeze-suspected-fraud]]),余额 ¥50,000。王女士的"亲戚"找到物业说:"她身体不好,委托我领回余额。" 出示了模糊的身份证复印件(无授权书)。
>
> 物业职员小李没核实关系真实性,**直接打开账户点 RefundAction**。系统拦截:
>
> | 拦截层 | 触发 |
> |---|---|
> | UI 层(Filament Action visible)| `canOperate()` 返 false → 按钮**灰化**(理论上点不到)|
> | Policy 层(`RefundAction->authorize('refund')`)| 即使绕 UI 直接调,Policy 拦 → 抛 AuthorizationException |
> | 模型层(`PrepaidAccount::refund()`)| 即使绕 Policy 调模型方法,`canOperate()` 内置检查 → 抛 RuntimeException |
>
> 任何一层挡住即拦截。**模型层是最后兜底**,即使 tinker / artisan / 第三方包都跑不掉。
## 业务人员视角
### 您看到什么
| 时刻 | 看到 |
|---|---|
| 进入 Frozen 账户的 ViewPrepaidAccount | 状态显示 🧊 Frozen |
| 状态管理组按钮 | "充值 / 消费 / 退款 / 冻结 / 关账" 全部**灰化**,只剩 "解冻" 可点 |
| 如果硬调 API | 抛错(看不同层抛哪个)|
### 三道防御详解
```mermaid
flowchart TD
A[业务人员点 RefundAction] --> B[UI 层:button.visible<br/>canOperate]
B -->|false| C[按钮灰化,点不到]
B -->|绕过 UI<br/>直接调表单| D[Policy 层:->authorize 'refund'<br/>PrepaidAccountPolicy::refund]
D -->|拒绝| E[抛 AuthorizationException]
D -->|绕过 Policy<br/>直接调模型| F[模型层:account->refund<br/>canOperate 检查]
F -->|拒绝| G[抛 RuntimeException<br/>账户处于非可操作状态]
```
**三道独立** —— 任意一道挡住即拦截。
### 业务上正确做法
不要硬绕过。**沟通业户 + 走调解 / 法务流程**:
| 业户/请求方反应 | 处理 |
|---|---|
| "我急用钱" | 解释:账户冻结调查中,需先完成风控核实 → [[unfreeze-after-verification]] |
| "我证明身份了你为什么不退" | 核实材料是否充分(身份证复印件不够,需原件 + 当面确认 + 业户授权书)|
| "我亲戚委托我" | 委托关系需公证书,否则**绝不退**(常见欺诈套路)|
| 真的本人核实通过 | 走 [[unfreeze-after-verification|解冻]] → 解冻后可正常退款 |
## 三层守护的代码层面
### UI 层(Filament Action)
```php
RefundAction::make()
->visible(fn (PrepaidAccount $record) =>
$record->canOperate() && $record->balance > 0
)
```
Frozen → `canOperate()=false` → 按钮 hidden。
### Policy 层
```php
// PrepaidAccountPolicy.php
public function refund(AuthUser $user, PrepaidAccount $record): bool
{
return $user->can('update prepaid accounts')
&& $record->canOperate()
&& $record->hasBalance();
}
```
`RefundAction::make()->authorize('refund')` 触发此方法,Frozen → 返 false → 抛 AuthorizationException。
### 模型层(最严)
```php
// PrepaidAccount.php
public function refund(float $amount, ...): PrepaidTransaction
{
if (! $this->canOperate()) {
throw new RuntimeException(
"账户处于 {$this->status->value} 状态,无法操作"
);
}
// ... 其余逻辑
}
```
任何调用方(Filament Action / Action 类 / tinker / artisan / 测试)调 `$account->refund()`,模型层 `canOperate()` 检查兜底。
## 系统流程(API 被绕过的极端情况)
```mermaid
sequenceDiagram
participant 调用方[非 Filament 调用方]
participant Action[RefundFromPrepaidAccountAction]
participant Model[PrepaidAccount]
participant DB
Note over 调用方: 例如 tinker:RefundFromPrepaidAccountAction::handle(frozen_account, 5000)
调用方->>Action: handle(frozen_account, 5000, channel)
Action->>Model: refund(5000)
Model->>Model: canOperate()? Frozen → false
Model-->>Action: throw RuntimeException
Action-->>调用方: 抛出 + 日志记录
Note over DB: 无任何写入,事务自动回滚
```
## 测试断言
```php
test('cannot refund on frozen account', function () {
$account = PrepaidAccount::factory()->frozen()->create(['balance' => 5000]);
// 模型层
expect(fn () => $account->refund(2000))
->toThrow(RuntimeException::class, '无法操作');
// Action 层
expect(fn () => app(RefundFromPrepaidAccountAction::class)
->handle($account, 2000, $channel))
->toThrow(RuntimeException::class);
expect($account->fresh()->balance)->toBe(5000.0); // 余额未变
expect(PrepaidTransaction::count())->toBe(0); // 流水未建
});
test('cannot consume on frozen account', function () {
// 同样的三层守护
});
test('cannot deposit on frozen account', function () {
// 同样的三层守护
});
```
**3 个测试覆盖三种写入操作的 Frozen 状态拒绝**,确保守护不被无意中放宽。
## 与 deposit 的对比
deposit 模块同样三层守护,但**守护方法粒度不同**:
| 模块 | 模型层守护方法 |
|---|---|
| deposit | `canDeposit()` / `canWithdraw()`(分二种)|
| prepaid | `canOperate()`(统一)|
理由:deposit 业务上有"只能加不能减"的中间状态(理论上 Frozen 时**加**没问题?现已收紧为都不允许)。prepaid 设计简单,统一拒绝所有写入。
详见 [[account-state-machine]] "canOperate 是模型层的最严防御" 段。
## 常见问题
> [!question] 业户特别紧急要钱(如医疗),冻结状态能绕过吗?
> **绝对不能从系统层绕**。业务流程上:
>
> 1. 业务人员加急核实业户身份 + 紧急情况
> 2. 走 [[unfreeze-after-verification|解冻]] 流程
> 3. 解冻后立即 RefundAction
>
> 整个流程**1-2 小时内可完成**,比"擅自绕守护退款"的合规风险小得多。
> [!question] 三层守护是不是过度设计了?一层就够吧?
> **不是过度**。每一层都有可能被绕:
>
> - UI 灰化 → 用户可能开浏览器开发者工具篡改 DOM
> - Policy → API 调用者可能直接调 Action 类(绕过 Filament Action)
> - **模型层** → tinker / artisan / 测试 / 第三方包都直接操作模型
>
> 多层独立 = 任意一层挡住即安全。代码层面成本极低(每层一两行)。
> [!question] 为什么 prepaid 的修复是"第二轮"才做?
> issue.md Q4 提到的"第一轮"是 prepaid 模块刚做时,只有 `voidReverse` 一个 Policy 方法(其他都没)。第二轮全面审查发现这个**严重漏洞**(模型层不查状态),补齐了 9 个 Policy 方法 + 模型方法守护 + 测试。
> [!question] 已发现的所有漏洞都补齐了吗?
> 详见 issue.md Q4 "第二轮已落地 (2026-05-22)" 段,8 项修复:
>
> 7. 删 DeleteAction / DeleteBulkAction
> 8. Policy 从 1 个补到 9 个
> 9. **`PrepaidAccount.refund()` 加 canOperate 守护(最严重)**
> 10. RefundAction UI 守护改为 canOperate && balance>0
> 11. 6 个 Filament Action 加显式 authorize 调用
> 12. EditAction 加 visible 守护
> 13. PrepaidAccount.hasBalance() 辅助方法
>
> 当前未发现其他漏洞,但任何重要修改都应增量做安全审计。
## 异常分支
- 业务上需要退冻结账户 → 先 [[unfreeze-after-verification]]
- 业户失联无法核实 → 留 Frozen,等业户出现
- 冻结期间充值被拦 → 同样三层守护,处理一致
## 相关文档
- [[freeze-suspected-fraud]]
- [[unfreeze-after-verification]]
- [[account-state-machine]]
- [[exception-cross-community-consume]]
- [[../deposit/account-state-machine]](deposit 状态机对比)

View File

@@ -0,0 +1,196 @@
---
title: prop-acc · prepaid · 场景 - 疑似欺诈风控冻结
aliases:
- 冻结预存款账户
- 风控冻结
- freeze-suspected-fraud
- 场景-预存款风控冻结
tags:
- 场景
- prop-acc
- 预存款
- 冻结
audience:
- 业务人员
- 风控
status: 已发布
sub_feature: prepaid
last_review: 2026-05-25
code_version: 2026-05-22
---
# 场景:疑似欺诈风控冻结
物业财务 / 风控发现某预存款账户**有可疑迹象**(短时间大额充值、与其他账户关联异常、业户身份疑问等),先冻结账户,禁止任何资金动作,核实后再决定解冻或关账。
## 典型情境
> [!example] 真实情境
> 平台风控系统发现:
>
> - **王女士**(15-7-203)预存款账户**昨天充了 ¥50,000**(往常月充值 < ¥3,000)
> - 同时,该业户绑定的微信号**昨天给 3 个不同预存款账户**各转了 ¥10,000-¥20,000(不像本人正常操作)
> - 业户报备的手机号**昨天突然变更**
>
> 风控团队判断:**疑似账户被盗 / 洗钱嫌疑**。先**冻结**所有相关账户,联系业户核实。
## 业务人员视角(风控 + 财务)
### 第 1 步:风控触发
风控团队识别异常 → 通知物业财务 → 财务在系统层立即冻结。
### 第 2 步:打开账户
后台 → 预存款 → 找到王女士账户(Active,balance=50000+原余额)→ 进 `ViewPrepaidAccount`
### 第 3 步:点击 `FreezeAccountAction`(标签"冻结")
> [!warning] 按钮可见性
> 守护:`canBeFreezed()`(等价 Active)+ Policy `->authorize('freeze')`。Frozen / Closed 灰化。
Modal 表单:
| 字段 | 填什么 |
|---|---|
| **冻结事由(reason)** | **必填且详细**,如 "风控:24h 内大额异常充值 + 微信关联多账户 + 手机号变更,疑似账户被盗" |
### 第 4 步:提交
系统调 `PrepaidAccount::freeze($reason)`:
1. 校验 `canBeFreezed()`(Active only)
2. 更新 `status=Frozen`
3.`meta.freeze_reason` 记冻结事由
4.`meta.frozen_at` 记冻结时间
**不产生** `PrepaidTransaction`、**不产生** `CollectionOrder`、**不产生** `Receipt`(纯状态变更)。
### 第 5 步:通知 + 调查
- 通知业户:"您的预存款账户已冻结,事由 XXX,请联系物业核实身份"
- 联系业户本人(已知手机号 + 业户备用联系方式),核实近期操作是否本人
- 如果是本人 → 解释 + 解冻;如果不是本人 → 走风控 / 法务流程
## 业户视角
### 您会感受到什么
- 收到通知:"您的预存款账户已冻结,事由:风控异常,请联系物业核实"
- 小程序"我的预存款"显示 "🧊 冻结"
- 想充值 → 失败,提示"账户冻结"
- 想抵账单 → 失败,提示"账户冻结"
- 想退款 → 失败,提示"账户冻结"
- 余额仍可见(只读)
### 您要做什么
立即联系物业(电话 / 微信 / 上门),核实:
- 近期充值 / 退款是不是您本人操作
- 手机号变更是不是您本人申请
- 提供身份证 / 房产证等核实身份
| 核实结果 | 后续 |
|---|---|
| 是本人,正常操作 | 物业解冻,详见 [[unfreeze-after-verification]] |
| 不是本人(账户被盗 / 微信号被盗)| 走风控流程,可能 retain 账户等司法处理 |
| 不是本人(他人冒充)| 走法务流程,资金可能扣留待裁决 |
## 系统流程
```mermaid
sequenceDiagram
participant 风控
participant 财务
participant Filament
participant PrepaidAccount
participant 数据库
participant 业户
Note over 风控: 检测异常充值 + 关联微信号
风控->>财务: 告警,要求冻结王女士账户
财务->>Filament: ViewPrepaidAccount → FreezeAccountAction(reason)
Filament->>PrepaidAccount: freeze(reason)
PrepaidAccount->>PrepaidAccount: canBeFreezed()? Active=true
PrepaidAccount->>数据库: 更新 status=Frozen, meta.freeze_reason, meta.frozen_at
数据库-->>财务: ok
Filament-->>财务: 成功
Note over 数据库: 冻结期间所有 deposit/consume/refund 调用都拦截
财务->>业户: 通知冻结 + 要求核实
```
## 冻结期间的能力对照
| 操作 | Active | Frozen |
|---|---|---|
| `DepositAction`(充值)| ✅ | ❌(`canOperate=false`)|
| `ConsumeAction`(消费抵扣)| ✅ | ❌ |
| `RefundAction`(退款)| ✅ | ❌ |
| `FreezeAccountAction`(冻结)| ✅ | ❌(已是 Frozen)|
| `ReactivateAccountAction`(解冻)| ❌(已是 Active)| ✅ |
| `CloseAccountAction`(关账)| ✅(balance=0)| ❌(必须先解冻)|
| 看账户 / 看流水 | ✅ | ✅(只读)|
> [!info] 与 deposit 关键差异:**没有 ForceClose**
> deposit 在 Frozen + 有余额困境时可以走 ForceClose(refund/forfeit/retain 三种 disposition)直接关账。**prepaid 没有这条路径** —— 一户一账 + 业户基本是本人,纠纷场景罕见,设计上简化。
>
> 真要"关 Frozen 账户":
> 1. 先 [[unfreeze-after-verification|解冻]] 回 Active
> 2. 再退余 + 关账
> 3. 如果完全不能解冻(业户被司法冻结之类),账户**一直留 Frozen**,运维介入
## 真实情境(二):月初批量自动抵扣误冻
> [!example] 反例:误冻结
> 风控系统某次误报,把正常业户王女士的账户冻结了。月初自动抵扣 job 跑到她账户时,因 Frozen 跳过,她的物业费没扣。
>
> 业户次月发现欠费,投诉。物业核实是误冻,立即 [[unfreeze-after-verification|解冻]],手动 ConsumeAction 补抵账单。
误冻的代价比 deposit 大,因为 prepaid 是**业户日常用的钱包**,误冻一天就让业户感觉服务出问题。**冻结前务必充分判断**。
## 常见问题
> [!question] 冻结期间业户能查询余额吗?
> 能。只读。可以看到余额、流水、状态(Frozen)、冻结事由。
> [!question] 冻结后业务人员可以做什么?
> - 看账户和流水(只读)
> - 走 `ReactivateAccountAction` 解冻
> - 不能充值 / 消费 / 退款(全部按钮灰)
> - 不能关账(必须先解冻)
> - **不能 ForceClose**(prepaid 没这功能)
> [!question] 风控应该多严?误报代价大不大?
> 误报代价:
> - 业户感知 = 服务异常 = 投诉
> - 业务人员介入解释 + 解冻 = 工作量
> - 严重影响信任
>
> 漏报代价:
> - 真欺诈未拦截 = 资金损失 / 法律风险
>
> **建议**:风控规则宽严平衡,人工审核 + 紧急冻结。冻结前优先**主动联系业户核实**,确认异常再冻。
> [!question] 业户失联或不配合核实怎么办?
> 长期 Frozen 状态保留。资金留在账户(`balance`),业户出现可解冻。**prepaid 没有 retain 机制**,长期失联走业务流程(类似 deposit retain,运维 / 法务介入)。
> [!question] 冻结期间业户能在小程序看到原因吗?
> 看 UI 设计。**推荐** 在小程序的"账户状态"页显示 `meta.freeze_reason` 的对外友好版本(去掉技术细节,如"账户冻结中,请联系物业了解详情")。
## 异常分支
- 误冻 → 立即 [[unfreeze-after-verification|解冻]]
- 核实后正常 → [[unfreeze-after-verification|解冻]] → 继续用
- 核实后确认被盗 / 欺诈 → 走法务流程,资金可能 retain 等司法
- 业户已失联 → 留 Frozen,等业户出现
## 相关文档
- [[account-state-machine]]
- [[unfreeze-after-verification]]
- [[exception-refund-on-frozen]]
- [[../deposit/freeze-during-dispute]](deposit 冻结场景对比)

View File

@@ -0,0 +1,190 @@
---
title: prop-acc · prepaid · 场景 - 业户搬走全额退余
aliases:
- 业户搬走退预存款
- 全额退预存款
- refund-full-resident-moveout
- 场景-业户搬走退预存款
tags:
- 场景
- prop-acc
- 预存款
- 退款
audience:
- 业户
- 业务人员
status: 已发布
sub_feature: prepaid
last_review: 2026-05-25
code_version: 2026-05-22
---
# 场景:业户搬走全额退余
业户**搬离社区**(卖房 / 退租 / 不再使用本物业),要把预存款账户余额全额退回。退款后**不自动关账**(prepaid 特性),业务人员**主动**走 [[close-resident-moveout|关账]] 流程。
## 典型情境
> [!example] 真实情境
> 刘先生把 12-3-501 房子卖了,下周搬走。他在预存款账户里还有 ¥3,200(本月物业费扣完之后的余),要全额退回。
## 业户视角
### 第 1 步:告知物业搬走
- 跟物业管家说"我下周搬走,把预存款里的钱退给我"
- 提供退款渠道(银行卡 / 微信 / 支付宝)
### 第 2 步:等退款
- 业务人员核对账户余额
- 操作退款(走线下 + 系统)
### 第 3 步:收到红字收据 + 退款到账
- 红字收据"预付款退款 ¥-3,200"
- 银行 / 微信收到 ¥3,200
### 第 4 步:账户被关
- 后续物业再发账单(若有)→ 不会自动从这个账户扣(已 Closed)
- 业户在小程序"我的预存款" 显示 "🔒 已关闭"
## 业务人员视角
### 第 1 步:核实业户搬走情况
- 房屋已过户(看 community_user_profile 状态)
- 业户已结清其他费用(无未付账单)
- 业户提供退款渠道
> [!warning] 注意未付账单
> 如果业户还有未付账单(物业费 / 水电费等),**先抵扣再退余**:
> - 业户余额 ¥3,200,有未付账单 ¥800 → 先 [[consume-monthly-property-bill|抵 800]],余 ¥2,400 → 再退 ¥2,400
> - 不要直接退全部 → 否则未付账单仍挂业户身上,变成"搬走后还欠物业钱",催收困难
### 第 2 步:打开账户做退款
后台 → 预存款 → 找到刘先生账户(Active,balance=3200)→ 进 `ViewPrepaidAccount` → 点 `RefundAction`(标签"退款")。
> [!warning] 按钮可见性
> `RefundAction` 守护:`canOperate() && balance > 0` + Policy `->authorize('refund')`。Frozen / Closed / 零余额账户灰化。
Modal 表单:
| 字段 | 填什么 |
|---|---|
| **退款金额** | ¥3,200(默认带入当前余额)|
| **退款渠道(PaymentChannel)** | 选业户指定回款方式 |
| **退款备注** | 必填,如 "业户搬离,12-3-501 已过户,退预存款全额" |
### 第 3 步:提交
系统调 `RefundFromPrepaidAccountAction`,事务内:
1. 校验 `canOperate()`(Active only)
2. 校验金额 ≤ 当前余额
3.`CollectionOrder`(`type=Prepaid`,`actual_amount=-3200` 红字,`Completed`)
4.`PrepaidAccount::refund(3200, ...)`:
- 模型层再校验 `canOperate()`(三层防御之模型层兜底)
-`PrepaidTransaction`(`type=refund`,`amount=3200`,`balance_before=3200`,`balance_after=0`,关联红字 CO)
- 更新 `balance=0`
5. **不自动关账**(prepaid 特性,与 deposit 不同 —— 见 [[account-state-machine]] "零余额不自动关账" 段)
6. 触发 `CollectionOrderCompleted` → Listener 建红字 Receipt"预付款退款 ¥-3,200"
### 第 4 步:走线下退款
- 银行转账:导出回款指令 → 银行办理
- 微信:在物业微信号上做退款
- 支付宝:同上
### 第 5 步:主动关账([[close-resident-moveout]])
退完余额后**账户仍是 Active 状态**,需手动走 `CloseAccountAction` 关掉。这是 prepaid 与 deposit 的关键差异。
详见 [[close-resident-moveout]]。
### 第 6 步:把红字收据给业户
后台找 Receipt → 微信 / 邮件发刘先生。
## 系统流程
```mermaid
sequenceDiagram
participant 业户
participant 财务
participant Filament
participant RefundFromPrepaidAccountAction
participant 数据库
participant 监听器
Note over 业户,财务: 业户搬走,余额 3200
财务->>财务: 核实无未付账单(若有,先 consume)
财务->>Filament: ViewPrepaidAccount → RefundAction(3200)
Filament->>RefundFromPrepaidAccountAction: handle(account, 3200, channel)
RefundFromPrepaidAccountAction->>RefundFromPrepaidAccountAction: canOperate()? Active=true
RefundFromPrepaidAccountAction->>RefundFromPrepaidAccountAction: 3200 ≤ 3200 yes
RefundFromPrepaidAccountAction->>数据库: 开启事务
RefundFromPrepaidAccountAction->>数据库: 1. 建 CO(Prepaid, -3200 红字, Completed)
RefundFromPrepaidAccountAction->>数据库: 2. account.refund(3200) → balance 3200→0
RefundFromPrepaidAccountAction->>数据库: 3. balance=0, **status 仍 Active**
RefundFromPrepaidAccountAction->>监听器: 4. 触发 CollectionOrderCompleted
监听器->>数据库: 5. 建 Receipt("预付款退款 ¥-3,200")
RefundFromPrepaidAccountAction->>数据库: 提交事务
Filament-->>财务: 成功(注:account 仍 Active,需手动关账)
Note over 财务: 接着走关账
财务->>Filament: CloseAccountAction → status=Closed
财务-->>业户: 银行/微信退 3200 + 红字收据
```
## 与 deposit 退款的关键差异
| 维度 | deposit 退款(refund-full-no-damage) | prepaid 退款(本场景) |
|---|---|---|
| 余额清零后状态 | **自动 Closed** | **仍 Active**(可继续充值)|
| 关账操作 | 不需要 | **需手动 CloseAccountAction** |
| 业务背景 | 装修结束等业务节点 | 业户搬走等长期事件 |
| 是否常见 | 高频(每户装修都做) | 低频(业户搬走才做)|
## 常见问题
> [!question] 为什么 prepaid 不自动关账?
> 详见 [[account-state-machine]] "零余额不自动关账" 段。简言之:预存款账户**一户一账**,频繁开关无意义,业户随时可能继续充值。
> [!question] 退完不关账户有什么风险?
> 几乎无风险:
> - 业户搬走后再无消费/充值动作 → 账户保持 0 余额 Active
> - 但**长期闲置 Active 账户**会出现在审计扫描里([[audit-low-balance-and-overdue]] 类似),业务上不专业
> - 推荐**退完立即关**(走 [[close-resident-moveout]]),清爽
> [!question] 业户搬走后又租回来或买回来,关了账户怎么办?
> 当前**一户一账约束阻塞**(unique 不允许重开)。详见 [[one-account-per-resident]] "已知设计 gap"。业务上目前用:
> - 业户重新现金 / 微信付账单(不用预存款)
> - 联系运维特殊处理(罕见)
> [!question] 退款渠道与充值渠道不同可以吗?
> 可以,看 [[../deposit/refund-with-payment-channel-switch]] 介绍的换渠道逻辑(deposit 模块,逻辑相同)。
> [!question] 业户失联但要退预存款怎么办?
> 几个选项:
> - **暂留 Active**:不操作,等业户出现(余额对业户仍可用)
> - **freeze 账户**:走 [[freeze-suspected-fraud|风控冻结]] 流程(reason 改"业户失联待联系")
> - **不可走 ForceClose retain**(prepaid 没有 ForceClose,与 deposit 不同)
> - 长期失联(>2 年)走业务流程(类似 deposit 的 retain,但 prepaid 当前没有内建机制,需运维介入)
## 异常分支
- 退一部分余下继续用 → [[refund-partial-after-consume]]
- 账户 Frozen 想退 → 先 [[unfreeze-after-verification|解冻]] 再退(prepaid 没有 ForceClose)
- 关账步骤 → [[close-resident-moveout]]
## 相关文档
- [[refund-partial-after-consume]]
- [[close-resident-moveout]]
- [[account-state-machine]]
- [[transaction-types]]
- [[consume-monthly-property-bill]]

View File

@@ -0,0 +1,168 @@
---
title: prop-acc · prepaid · 场景 - 部分消费后退余(不自动关账)
aliases:
- 部分退预存款
- 退一部分留账户
- refund-partial-after-consume
- 场景-预存款部分退余
tags:
- 场景
- prop-acc
- 预存款
- 退款
audience:
- 业户
- 业务人员
status: 已发布
sub_feature: prepaid
last_review: 2026-05-25
code_version: 2026-05-22
---
# 场景:部分消费后退余(不自动关账)
业户**部分使用**预存款后,想**退余下一部分**(不全退,继续保留账户)。退完仍 Active,业户可后续继续充值复用。本场景突出 prepaid 与 deposit 的**关键差异**:零余额不自动关账,部分余额更不会。
## 典型情境
> [!example] 真实情境
> 陈先生 3 个月前充了 ¥5,000 预存款,期间扣了 ¥2,400(3 个月物业费),余额 ¥2,600。他最近现金流紧张,想**先退 ¥1,500 应急**,留 ¥1,100 在账户继续扣物业费。
## 业户视角
### 第 1 步:跟物业说要退一部分
- "我想从预存款退 ¥1,500,留点继续用"
- 提供退款渠道
### 第 2 步:等退款
- 业务人员核实余额、操作
### 第 3 步:收到红字收据 + 退款
- 红字收据"预付款退款 ¥-1,500"
- 银行 / 微信收到 ¥1,500
### 第 4 步:账户保持 Active
- 小程序"我的预存款"显示余额 ¥1,100
- 仍可继续抵账单
- 后续可继续充值
> [!info] 与 deposit 的核心差异
> deposit 退完余额到 0 会自动 Closed。prepaid **退完无论余额多少都不自动关**,业户随时可继续用。
## 业务人员视角
### 第 1 步:打开账户
后台 → 预存款 → 陈先生账户(Active,balance=2600)→ 进 `ViewPrepaidAccount`
### 第 2 步:`RefundAction` Modal
| 字段 | 填什么 |
|---|---|
| **退款金额** | **¥1,500**(不是全额,**手动改**)|
| 退款渠道 | 微信 / 银行 |
| 备注 | 选填,如 "业户申请部分退款" |
> [!warning] 易错点
> Modal 默认带入**当前余额全额**(¥2,600)。**必须手动改为 ¥1,500**,否则就成了全退。
### 第 3 步:提交
系统调 `RefundFromPrepaidAccountAction`,事务内:
1. 校验 `canOperate()`
2. 校验金额 ≤ 余额(1500 ≤ 2600 ✓)
3.`CollectionOrder`(`type=Prepaid`,`actual=-1500` 红字,`Completed`)
4.`account.refund(1500)`:
-`PrepaidTransaction`(type=refund, 2600→1100,关联 CO)
- 更新 balance=1100
5. **不关账**(余额非 0)
6. 触发监听器 → Receipt"预付款退款 ¥-1,500"
### 第 4 步:走线下退款 + 给收据
银行 / 微信退 ¥1,500;红字收据交业户。**账户保持 Active,余额 ¥1,100**。
### 第 5 步:告知业户
"已退 ¥1,500,账户还有 ¥1,100 可继续抵账单"。
## 系统流程
```mermaid
sequenceDiagram
participant 业户
participant 财务
participant Filament
participant 数据库
Note over 业户: 余额 2600,要退 1500
业户->>财务: 退 1500
财务->>Filament: ViewPrepaidAccount → RefundAction(modal, **改成 1500**)
Filament->>数据库: RefundFromPrepaidAccountAction
数据库->>数据库: 建 CO(-1500 红字) + PrepaidTransaction(refund, 2600→1100)
数据库->>数据库: balance=1100(**不关账**)
数据库->>监听器: 触发监听器 → Receipt("预付款退款 ¥-1,500")
财务-->>业户: 微信退 1500 + 红字收据 + 告知余额 1100
```
## 流水台账(累计)
| 流水 | type | amount | balance_before | balance_after | 备注 |
|---|---|---|---|---|---|
| 1 | deposit | 5000 | 0 | 5000 | 3 个月前首次充值 |
| 2 | consume | 800 | 5000 | 4200 | 第 1 月物业费 |
| 3 | consume | 800 | 4200 | 3400 | 第 2 月物业费 |
| 4 | consume | 800 | 3400 | 2600 | 第 3 月物业费 |
| **5** | **refund** | **1500** | **2600** | **1100** | **本场景** |
账户余额 ¥1,100,**仍 Active**,后续可继续抵账单或充值。
## 与 deposit 退款的差异
| 维度 | deposit 部分退款(refund-partial-after-forfeit) | prepaid 部分退款(本场景) |
|---|---|---|
| 退款产生的 CO 类型 | type=Deposit, -N 红字 | type=Prepaid, -N 红字 |
| 退完余额 0 | **自动 Closed** | **仍 Active** |
| 退完余额非 0 | 仍 Active(deposit 也允许部分退后继续动)| 仍 Active |
| 业务背景 | 押金扣罚 + 退余 | 业户应急需现金,部分提取 |
## 常见问题
> [!question] 退完余额变 0,会自动关账吗?
> **不会**。即使全退到 0,prepaid 仍保持 Active。详见 [[account-state-machine]]"零余额不自动关账"段 + [[close-with-zero-balance-decision]]。
> [!question] 业务人员退多了(改余额时手抖)?
> 系统会校验 `amount ≤ balance` 守护拦截。例如余额 2600,改成 3000 提交 → 抛错 "amount exceeds balance"。**预防**:Modal 提交前再三确认数字。
> [!question] 业户想退完了又改主意,要充回去?
> 当然可以。直接走 [[deposit-additional-topup|追加充值]] 把钱充回账户即可。账户一直 Active 没动过。
> [!question] 业户拿到红字收据后困惑"我没买东西啊为什么收据是负数"?
> 解释:
> - 红字 = 钱从物业流出 / 回到您手里
> - 这不是消费收据,是退款收据
> - 详见 [[../deposit/red-receipt-design]](deposit 模块的概念,prepaid 复用相同设计)
> [!question] 业户问"我现在余额 1100,还能扣账单吗?"
> 当然能。账户 Active,余额 > 0,正常用。物业费下月扣完后余额会变 ¥300(假设 ¥800 月费)。
## 异常分支
- 全额退 → [[refund-full-resident-moveout]]
- Frozen 状态退 → 先 [[unfreeze-after-verification]] 再退
- 退完想关账 → 走 [[close-with-zero-balance-decision]]
- 业户搬走全退 + 关账 → [[refund-full-resident-moveout]] + [[close-resident-moveout]]
## 相关文档
- [[refund-full-resident-moveout]]
- [[account-state-machine]]
- [[close-with-zero-balance-decision]]
- [[transaction-types]]
- [[../deposit/red-receipt-design]]

View File

@@ -0,0 +1,180 @@
---
title: prop-acc · prepaid · 场景 - 核实后解冻
aliases:
- 解冻预存款账户
- 风控核实后解冻
- unfreeze-after-verification
- 场景-预存款解冻
tags:
- 场景
- prop-acc
- 预存款
- 冻结
audience:
- 业务人员
- 风控
status: 已发布
sub_feature: prepaid
last_review: 2026-05-25
code_version: 2026-05-22
---
# 场景:核实后解冻
[[freeze-suspected-fraud|冻结]] 后,物业核实业户身份和操作合法性,**解冻账户**回到 Active,业户继续正常使用。是冻结的对称操作。
> [!info] Action 名称的历史
> 解冻的 Action 在代码里叫 **`ReactivateAccountAction`**(字面"重新激活"),但**实际行为只允许 Frozen → Active**(等价解冻)。UI 文案已统一为"解冻",图标 `lock-open`,与 deposit 模块对齐。详见 [[account-state-machine]]"ReactivateAccountAction = 解冻"段。
## 典型情境
> [!example] 真实情境
> 王女士的预存款账户因风控异常被冻结(详见 [[freeze-suspected-fraud]])。物业联系她核实:
>
> - 确认昨天大额充值是**本人操作**(她准备一次性存够全年物业费)
> - 微信号给其他账户转钱是给亲戚朋友转账,与预存款无关(只是该微信刚好绑了多个预存款账户在风控规则下触发了关联)
> - 手机号变更是因为旧号停用,她已到运营商办手续
>
> 物业核实后:王女士身份属实、所有操作合法。**立即解冻**。
## 业务人员视角
### 第 1 步:核实业户身份与操作
- 业户当面 / 视频 / 公证 提供身份证 + 房产证 / 租赁合同
- 核对近期操作是否本人(看充值时间、IP、设备)
- 核对手机号变更证明(运营商凭证)
- 核对资金来源说明(若大额异常)
> [!warning] 核实必须留书面凭证
> - 业户签字声明
> - 微信 / 邮件确认截图
> - 任何后续争议时的依据
### 第 2 步:打开账户
后台 → 预存款 → 找到王女士账户(Frozen)→ 进 `ViewPrepaidAccount`
状态显示 "🧊 Frozen",右上角只有 `ReactivateAccountAction`(标签"解冻")可点,其他写入按钮全灰。
### 第 3 步:点击解冻
> [!warning] 按钮可见性
> 守护:`status === Frozen` + Policy `->authorize('unfreeze')`。
>
> **修过的语义**:历史代码允许 `!= Active` 都可见(等于"既能撤销 Frozen 也能撤销 Closed"),issue.md Q4 改为**只允许 Frozen → Active**,等价解冻,**禁止从 Closed 撤销关账**。
Modal 表单:
| 字段 | 填什么 |
|---|---|
| **解冻事由(reason)** | 必填,如 "风控核实:大额充值与微信转账均为本人操作,手机号变更已凭运营商证明确认" |
### 第 4 步:提交
系统调 `PrepaidAccount::unfreeze($reason)`(或同名方法):
1. 校验 status === Frozen
2. 更新 `status=Active`
3.`meta.unfreeze_reason` 记解冻事由
4.`meta.unfrozen_at` 记解冻时间
5. (可选)`meta.freeze_history[]` 追加这次冻结-解冻的完整记录
**不产生** PrepaidTransaction(状态变更,无资金动作)。
### 第 5 步:通知业户
- "您的预存款账户已解冻,现可正常使用"
- 业务人员 / 运维监督看后续是否有异常
## 业户视角
### 您会感受到什么
- 收到通知:"您的预存款账户已解冻,事由:经核实身份与操作合法"
- 小程序"我的预存款"显示 "✅ Active"
- 充值 / 消费 / 退款 重新可用
- 余额未变(冻结期间不动)
### 您要做什么
继续正常用账户。建议:
- 留意自己账户的异常操作
- 重要变更(手机号、绑定微信)及时告知物业
- 大额充值(>10000)建议提前告知物业,避免风控误报
## 系统流程
```mermaid
sequenceDiagram
participant 业户
participant 物业
participant Filament
participant PrepaidAccount
participant 数据库
Note over 业户,物业: 核实业户身份和操作合法
业户->>物业: 提供身份证 / 房产证 / 操作说明
物业->>物业: 核实通过
物业->>Filament: ViewPrepaidAccount → ReactivateAccountAction(reason)
Filament->>PrepaidAccount: unfreeze(reason)
PrepaidAccount->>PrepaidAccount: status === Frozen? yes
PrepaidAccount->>数据库: 更新 status=Active, meta.unfreeze_reason
数据库-->>Filament: ok
Filament-->>物业: 成功
物业->>业户: 通知解冻
Note over 业户: 后续正常充值 / 消费 / 退款
```
## 流水台账(本场景不动)
| 流水 | 说明 |
|---|---|
| (无)| 解冻是状态变更,无资金动作 |
只有 `PrepaidAccount.status` 字段从 Frozen → Active,`meta` 多几个审计字段。
## 与 deposit 解冻的差异
| 维度 | deposit unfreeze-after-mediation | prepaid 解冻(本场景) |
|---|---|---|
| 业务上下文 | 押金纠纷调解 | 风控核实 / 误冻撤销 |
| Action 名 | `UnfreezeAction` | `ReactivateAccountAction`(字面历史包袱) |
| 后续操作 | 调解结果决定 refund / forfeit | 直接恢复使用 |
| 通常频率 | 中(押金纠纷有时间区) | 罕见(风控误报)|
## 常见问题
> [!question] 误冻立即解冻可以吗?
> 可以,且**推荐立即**。误冻每多挂一分钟,业户体验越差。
> [!question] 解冻后业户能立即充值 / 消费吗?
> 能。解冻是同步事务,提交后立即生效。
> [!question] 多次冻结-解冻同一账户会有问题吗?
> 不会。账户可以在 `Active ↔ Frozen` 之间多次切换。如果业务上常见,`meta.freeze_history[]` 数组(若已实现)记历次完整记录。
> [!question] 解冻后业户再次触发风控怎么办?
> 重复 [[freeze-suspected-fraud|冻结]] 流程 → 这次更严格核实。多次触发风控的业户可能是真的高风险,需法务介入。
> [!question] 解冻必须要书面凭证吗?
> 系统层面不强制(`reason` 字段非空即可)。**业务层面强烈推荐**,留书面凭证防纠纷。
> [!question] 解冻能从 Closed 状态做吗?
> **不能**。`ReactivateAccountAction` 只允许 Frozen → Active。Closed 永久(`canBeReopened` 永远 false)。这是 issue.md Q4 第二轮明确修的语义。
## 异常分支
- 核实不通过(确认欺诈)→ 留 Frozen,法务介入
- 业户长期不出现 → 留 Frozen,等业户出现或法律时效
- 解冻后再次异常 → 重新 [[freeze-suspected-fraud|冻结]]
## 相关文档
- [[freeze-suspected-fraud]]
- [[account-state-machine]]
- [[exception-refund-on-frozen]]
- [[../deposit/unfreeze-after-mediation]](deposit 解冻场景对比)