Files
uniprop-manual/prop-acc/concepts/prepaid/one-account-per-resident.md
2026-05-25 23:02:51 +08:00

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 · 一户一账约束
一户一账
预存款一户一账
one-account-per-resident
跨社区防御
概念
prop-acc
预存款
数据约束
业户
业务人员
架构师
已发布 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

开户时的约束行为

业务人员尝试为某社区某业户再次开预存款账户时,系统会:

  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)

消费时:

  • 系统找业户在本社区的预存款账户(唯一)
  • 余额够 → 抵扣
  • 余额不够 → 提示业户先充值

系统视角

抵扣账单的查找逻辑:

// 伪代码 — 找业户的预存款账户
$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() 之类容错)。

相关文档