vault backup: 2026-05-25 23:02:51

This commit is contained in:
Willie
2026-05-25 23:02:51 +08:00
parent 754fcadaf4
commit b3a8c502e7
6 changed files with 917 additions and 16 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/scenarios/deposit/deposit-first-time-renovation.md",
"mode": "source", "mode": "source",
"source": false "source": false
}, },
"icon": "lucide-file", "icon": "lucide-file",
"title": "deposit-additional-topup" "title": "deposit-first-time-renovation"
} }
} }
] ]
@@ -94,7 +94,7 @@
"state": { "state": {
"type": "backlink", "type": "backlink",
"state": { "state": {
"file": "prop-acc/scenarios/deposit/deposit-additional-topup.md", "file": "prop-acc/scenarios/deposit/deposit-first-time-renovation.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 deposit-first-time-renovation"
} }
}, },
{ {
@@ -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/scenarios/deposit/deposit-first-time-renovation.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 deposit-first-time-renovation"
} }
}, },
{ {
@@ -156,13 +156,13 @@
"state": { "state": {
"type": "outline", "type": "outline",
"state": { "state": {
"file": "prop-acc/scenarios/deposit/deposit-additional-topup.md", "file": "prop-acc/scenarios/deposit/deposit-first-time-renovation.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 deposit-first-time-renovation"
} }
}, },
{ {
@@ -197,10 +197,16 @@
}, },
"active": "b06ed69835363258", "active": "b06ed69835363258",
"lastOpenFiles": [ "lastOpenFiles": [
"prop-acc/concepts/prepaid/prepaid-account-vs-transaction.md", "prop-acc/concepts/prepaid/auto-deduction-design.md",
"prop-acc/concepts/prepaid",
"prop-acc/maps/deposit-knowledge-map.md", "prop-acc/maps/deposit-knowledge-map.md",
"prop-acc/index.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/concepts/deposit/deposit-vs-adhoc-vs-prepaid.md",
"prop-acc/scenarios/deposit/audit-long-pending-accounts.md", "prop-acc/scenarios/deposit/audit-long-pending-accounts.md",
"prop-acc/scenarios/deposit/audit-monthly-deposit-balance.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-with-payment-channel-switch.md",
"prop-acc/scenarios/deposit/refund-partial-after-forfeit.md", "prop-acc/scenarios/deposit/refund-partial-after-forfeit.md",
"prop-acc/scenarios/deposit/refund-full-no-damage.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/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",

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]]