6.3 KiB
title, aliases, tags, audience, status, sub_feature, last_review, code_version
| title | aliases | tags | audience | status | sub_feature | last_review | code_version | |||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| prop-acc · prepaid · 一户一账约束 |
|
|
|
已发布 | prepaid | 2026-05-25 | 2026-05-22 |
一户一账约束
预存款账户每个社区每业户最多一个。数据库唯一约束 + 业务层跨社区防御双层保证。这是 prepaid 子模块最重要的独有约束,影响开户、消费、跨社区调度的所有流程。
约束的形式化
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() 方法内置守护:
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。
开户时的约束行为
业务人员尝试为某社区某业户再次开预存款账户时,系统会:
- UI 层:
PrepaidAccountForm在提交前 SQL 查询是否已存在(可前置友好提示) - 数据库层:即使 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)
消费时:
- 系统找业户在本社区的预存款账户(唯一)
- 余额够 → 抵扣
- 余额不够 → 提示业户先充值
系统视角
抵扣账单的查找逻辑:
// 伪代码 — 找业户的预存款账户
$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() 之类容错)。