From b3a8c502e7ec9db7355468481506bba176fbd4b0 Mon Sep 17 00:00:00 2001 From: Willie Date: Mon, 25 May 2026 23:02:51 +0800 Subject: [PATCH] vault backup: 2026-05-25 23:02:51 --- .obsidian/workspace.json | 32 +-- .../concepts/prepaid/account-state-machine.md | 155 +++++++++++ .../concepts/prepaid/auto-deduction-design.md | 198 ++++++++++++++ .../consume-via-bill-collection-type.md | 245 ++++++++++++++++++ .../prepaid/one-account-per-resident.md | 168 ++++++++++++ .../concepts/prepaid/transaction-types.md | 135 ++++++++++ 6 files changed, 917 insertions(+), 16 deletions(-) create mode 100644 prop-acc/concepts/prepaid/account-state-machine.md create mode 100644 prop-acc/concepts/prepaid/auto-deduction-design.md create mode 100644 prop-acc/concepts/prepaid/consume-via-bill-collection-type.md create mode 100644 prop-acc/concepts/prepaid/one-account-per-resident.md create mode 100644 prop-acc/concepts/prepaid/transaction-types.md diff --git a/.obsidian/workspace.json b/.obsidian/workspace.json index 5fe0bad..3d6e23a 100644 --- a/.obsidian/workspace.json +++ b/.obsidian/workspace.json @@ -13,12 +13,12 @@ "state": { "type": "markdown", "state": { - "file": "prop-acc/scenarios/deposit/deposit-additional-topup.md", + "file": "prop-acc/scenarios/deposit/deposit-first-time-renovation.md", "mode": "source", "source": false }, "icon": "lucide-file", - "title": "deposit-additional-topup" + "title": "deposit-first-time-renovation" } } ] @@ -94,7 +94,7 @@ "state": { "type": "backlink", "state": { - "file": "prop-acc/scenarios/deposit/deposit-additional-topup.md", + "file": "prop-acc/scenarios/deposit/deposit-first-time-renovation.md", "collapseAll": false, "extraContext": false, "sortOrder": "alphabetical", @@ -104,7 +104,7 @@ "unlinkedCollapsed": true }, "icon": "links-coming-in", - "title": "Backlinks for deposit-additional-topup" + "title": "Backlinks for deposit-first-time-renovation" } }, { @@ -113,12 +113,12 @@ "state": { "type": "outgoing-link", "state": { - "file": "prop-acc/scenarios/deposit/deposit-additional-topup.md", + "file": "prop-acc/scenarios/deposit/deposit-first-time-renovation.md", "linksCollapsed": false, "unlinkedCollapsed": true }, "icon": "links-going-out", - "title": "Outgoing links from deposit-additional-topup" + "title": "Outgoing links from deposit-first-time-renovation" } }, { @@ -156,13 +156,13 @@ "state": { "type": "outline", "state": { - "file": "prop-acc/scenarios/deposit/deposit-additional-topup.md", + "file": "prop-acc/scenarios/deposit/deposit-first-time-renovation.md", "followCursor": false, "showSearch": false, "searchQuery": "" }, "icon": "lucide-list", - "title": "Outline of deposit-additional-topup" + "title": "Outline of deposit-first-time-renovation" } }, { @@ -197,10 +197,16 @@ }, "active": "b06ed69835363258", "lastOpenFiles": [ - "prop-acc/concepts/prepaid/prepaid-account-vs-transaction.md", - "prop-acc/concepts/prepaid", + "prop-acc/concepts/prepaid/auto-deduction-design.md", "prop-acc/maps/deposit-knowledge-map.md", "prop-acc/index.md", + "prop-acc/concepts/prepaid/consume-via-bill-collection-type.md", + "prop-acc/concepts/prepaid/transaction-types.md", + "prop-acc/concepts/prepaid/one-account-per-resident.md", + "prop-acc/scenarios/deposit/deposit-additional-topup.md", + "prop-acc/concepts/prepaid/account-state-machine.md", + "prop-acc/concepts/prepaid/prepaid-account-vs-transaction.md", + "prop-acc/concepts/prepaid", "prop-acc/concepts/deposit/deposit-vs-adhoc-vs-prepaid.md", "prop-acc/scenarios/deposit/audit-long-pending-accounts.md", "prop-acc/scenarios/deposit/audit-monthly-deposit-balance.md", @@ -218,13 +224,7 @@ "prop-acc/scenarios/deposit/refund-with-payment-channel-switch.md", "prop-acc/scenarios/deposit/refund-partial-after-forfeit.md", "prop-acc/scenarios/deposit/refund-full-no-damage.md", - "prop-acc/scenarios/deposit/deposit-additional-topup.md", - "prop-acc/concepts/adhoc/collection-order-and-receipt.md", - "prop-acc/scenarios/deposit/deposit-on-behalf-by-company.md", "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/scenarios/adhoc", "prop-acc/concepts/adhoc", diff --git a/prop-acc/concepts/prepaid/account-state-machine.md b/prop-acc/concepts/prepaid/account-state-machine.md new file mode 100644 index 0000000..66880cf --- /dev/null +++ b/prop-acc/concepts/prepaid/account-state-machine.md @@ -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]] diff --git a/prop-acc/concepts/prepaid/auto-deduction-design.md b/prop-acc/concepts/prepaid/auto-deduction-design.md new file mode 100644 index 0000000..f380985 --- /dev/null +++ b/prop-acc/concepts/prepaid/auto-deduction-design.md @@ -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]] diff --git a/prop-acc/concepts/prepaid/consume-via-bill-collection-type.md b/prop-acc/concepts/prepaid/consume-via-bill-collection-type.md new file mode 100644 index 0000000..59ab376 --- /dev/null +++ b/prop-acc/concepts/prepaid/consume-via-bill-collection-type.md @@ -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
type=Bill
actual=+800
payment=现金] --> F[Bill 翻 Paid
+ Receipt 物业费 ¥800] + + D -->|微信| G[CollectionOrder
type=Bill
actual=+800
payment=微信] --> F + + D -->|**预存款抵扣**| H[CollectionOrder
**type=Bill**
actual=+800
meta.fund_source=prepaid] --> F + H --> I[同时 PrepaidTransaction
type=consume
amount=800
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]] diff --git a/prop-acc/concepts/prepaid/one-account-per-resident.md b/prop-acc/concepts/prepaid/one-account-per-resident.md new file mode 100644 index 0000000..d3d03e5 --- /dev/null +++ b/prop-acc/concepts/prepaid/one-account-per-resident.md @@ -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|业户]] diff --git a/prop-acc/concepts/prepaid/transaction-types.md b/prop-acc/concepts/prepaid/transaction-types.md new file mode 100644 index 0000000..b1be586 --- /dev/null +++ b/prop-acc/concepts/prepaid/transaction-types.md @@ -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]]