先说结论:这类系统出问题,几乎不写在代码语法上,而是写在两个语义没想清的地方------"欠费"到底是一个数还是一串账期,"不要重复打扰"到底是按户算还是按单子算。我把这两件事想清楚之后,规则引擎反倒只花了一百行。
写它之前,我先把收费员的动作拆成动词:算欠多少、判断该催不该催、发消息、记下发了什么。这四个动词里,只有"发消息"是技术活,另外三个都是语义问题。下面按这个顺序讲,代码放在最后作为验证手段。
一、"欠费"不是一个数,是一串账期
绝大多数物业台账的问题出在这一格:一列房号,一列欠费合计。合计这个数一旦落库,就丢掉了两样东西------欠的是哪几期、每期什么时候到期。丢掉之后,逾期天数没法算(只能从最后一次缴费日推),催缴话术没法分档(不知道是欠三个月还是欠三年),对账也没法解释(住户说交过了,你只能说"系统里还是欠")。
所以模型要拆成两张表:应收按账期一行,收款流水只增不改。欠费是两者相减的结果,永远不落成字段。
sql
CREATE TABLE fee_bill ( -- 应收:一户一账期一条
id INTEGER PRIMARY KEY,
room_id INTEGER NOT NULL,
period TEXT NOT NULL, -- 账期,如 '2026-08'
amount_due INTEGER NOT NULL, -- 应收,单位分
due_date TEXT NOT NULL, -- 应缴截止日
UNIQUE (room_id, period)
);
CREATE TABLE pay_record ( -- 收款流水:只增不改
id INTEGER PRIMARY KEY,
room_id INTEGER NOT NULL,
amount INTEGER NOT NULL, -- 实收,单位分
paid_at TEXT NOT NULL
);
CREATE VIEW v_arrears AS
SELECT b.room_id, b.period, b.due_date,
b.amount_due - COALESCE(SUM(p.amount), 0) AS owe_fen,
CAST(julianday('now') - julianday(b.due_date) AS INTEGER) AS days_over
FROM fee_bill b LEFT JOIN pay_record p
ON p.room_id = b.room_id
AND p.paid_at >= b.period || '-01'
AND p.paid_at < date(b.period || '-01', '+1 month')
GROUP BY b.id HAVING owe_fen > 0;
两个取舍值得单独说。
金额存整数分,不存小数。欠费要参与求和、冲抵、对账三轮计算,浮点在每轮都会留一点尾巴,几百户攒下来台账和财务永远差几毛。这不是精度洁癖,是减少解释成本。
视图里把冲抵口径写死:某期的收款只冲该期。真实场景里住户一次交三个月很常见,严格按归属冲抵会让"提前交的"那部分钱挂在未来账期上,欠费反而显示不出来。所以生产环境要换成"按账期先后依次冲抵"的存储过程。取舍在于:口径可以在一个视图里改,散在业务代码里就改不动了。
二、规则表天生会多重命中,这是最容易写错的地方
规则写成 if-else 链的代价不是难读,是每次调档都要发版。做成数据表,运维能改数字,代码不动。
python
from dataclasses import dataclass
from datetime import date
@dataclass(frozen=True)
class Rule:
name: str; min_days: int; max_days: int
min_fen: int; cooldown_days: int; channel: str
RULES = (
Rule("到期前提醒", -7, -1, 0, 30, "sms"),
Rule("轻度", 1, 15, 5000, 7, "sms"),
Rule("中度", 16, 45, 20000, 15, "sms_call"),
Rule("重度", 46, 9999, 50000, 30, "letter"),
)
def pick_rule(days_over, owe_fen):
hit = [r for r in RULES
if r.min_days <= days_over <= r.max_days and owe_fen >= r.min_fen]
return max(hit, key=lambda r: r.min_days) if hit else None
关键在最后一行。规则表是区间叠加的结构,欠 60 天、欠 800 元的户会同时踩中三条。若按顺序取上一条命中的规则,返回的是"到期前提醒"------越欠越久的户收到越客气的消息。必须取区间最严的一档,max(..., key=min_days) 表达的就是这件事。
同一段逻辑还有个隐蔽的取舍:金额门槛按户汇总还是按单期判定。按单期判定,一户欠 6 期、每期 3000 元,永远够不上 50000 分的门槛,永远进不了重度档,系统看起来正常但这条规则是死的。按户汇总才是业务想要的语义:
sql
SELECT room_id, SUM(owe_fen) AS owe_total, MAX(days_over) AS days_over
FROM v_arrears GROUP BY room_id;
三、幂等只能靠约束,不能靠"先查一下"
定时任务最怕重跑。运维手动补一次、调度器抖一次、两个人同时点一次,消息就重复了。代码里写"先 SELECT 看有没有记录,没有就 INSERT",中间那段空隙足够两次执行互相看不见。
交给数据库:同一户、同一账期、同一档位,只允许一条记录。撞了约束就跳过,不需要 catch 之外任何逻辑。
sql
CREATE TABLE dunning_log (
id INTEGER PRIMARY KEY, room_id INTEGER NOT NULL,
period TEXT NOT NULL, rule_name TEXT NOT NULL,
channel TEXT NOT NULL, sent_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'queued' -- queued/sent/failed
);
CREATE UNIQUE INDEX ux_dunning_dedup
ON dunning_log (room_id, period, rule_name);
这张表顺带解决另一个问题:换人。收费员离职最难交接的不是客户名单,是"这户催过几次、上次谁催的"。记录在库里,接手的人查一眼就接上了。
四、冷却的主语是人,不是账期
这是我认为整篇里最反直觉的一条。去重索引解决的是"同一档别发两次",但一个人欠六个账期,会产生六条不同的待发记录,去重索引一条都拦不住。所以还需要一层按户的冷却:
python
def gate_cooldown(conn, room_id, rule, today):
last = conn.execute(
"SELECT MAX(sent_at) FROM dunning_log "
"WHERE room_id=? AND status='sent'", (room_id,)).fetchone()[0]
if last and (today - date.fromisoformat(last[:10])).days < rule.cooldown_days:
return False, "冷却期内"
return True, ""
注意这条 SQL 里没有 period。加上它看着更"精确",实际会让六期欠费各自通过冷却检查,同一天发出六条。住户的感受是"你们是不是有病",物业的感受是短信费白烧。冷却语义的主体是这户人家最近有没有被打扰,账期只是打扰的理由,不是打扰的对象。
再叠一层月度上限(单户自然月不超过 3 次)和一条人工兜底:重度档不进自动队列,写进待办表。系统知道欠了 90 天,不知道这户是独居老人还是恶意拖欠,最后一步交给人。
串起来就是一个循环,短到可以贴给非技术同事看:
python
def run_daily(conn, today):
for room_id, owe, days in conn.execute(
"SELECT room_id, SUM(owe_fen), MAX(days_over) "
"FROM v_arrears GROUP BY room_id"):
rule = pick_rule(days, owe)
if not rule or not gate_month_cap(conn, room_id, today):
continue
if gate_cooldown(conn, room_id, rule, today)[0]:
yield room_id, rule # letter 档改投人工待办表
被跳过的户不要静默丢弃,把跳过原因落到当日运行明细里。收费员问"这户为什么没催",答案就在那一列。
自测结果与对照
代码在本地 SQLite 上直接建库可跑,插入假数据即可验证。下表只列结构差异和验证方式,不写无法复现的业务数字。
| 环节 | 翻表人工做法 | 这套设计 | 怎么自己验证 |
|---|---|---|---|
| 欠费口径 | 一列合计,账期混在一起 | 视图按账期冲抵算出 | 插 3 期数据,跑 v_arrears 看分期 |
| 该催谁 | 人眼看颜色、凭记忆 | 规则表匹配,取最严档 | 造欠 60 天、欠 800 元的户,看命中项 |
| 重复发送 | 靠收费员记性 | 去重索引 + 户级冷却 | 同批数据连跑两次,第二次结果应为空 |
| 交接 | 靠口头 | dunning_log 全留痕 |
查 status='sent',含档位与时间 |
| 越权发送 | 无约束 | 重度档转人工 | 造 90 天欠费,看它进 manual 不进 auto |
省下的不是打电话的时间,是"这户到底催过没有"的扯皮时间。
这套系统有在线演示(假数据,可点):<演示站链接> 完整版支持私有化部署与贴牌,说明见项目 README:<Gitee 链接> 下一篇:《<下一篇标题>》