物业催缴系统为什么总在"冷却"这一步翻车:账期建模与规则命中的几个取舍

先说结论:这类系统出问题,几乎不写在代码语法上,而是写在两个语义没想清的地方------"欠费"到底是一个数还是一串账期,"不要重复打扰"到底是按户算还是按单子算。我把这两件事想清楚之后,规则引擎反倒只花了一百行。

写它之前,我先把收费员的动作拆成动词:算欠多少、判断该催不该催、发消息、记下发了什么。这四个动词里,只有"发消息"是技术活,另外三个都是语义问题。下面按这个顺序讲,代码放在最后作为验证手段。

一、"欠费"不是一个数,是一串账期

绝大多数物业台账的问题出在这一格:一列房号,一列欠费合计。合计这个数一旦落库,就丢掉了两样东西------欠的是哪几期、每期什么时候到期。丢掉之后,逾期天数没法算(只能从最后一次缴费日推),催缴话术没法分档(不知道是欠三个月还是欠三年),对账也没法解释(住户说交过了,你只能说"系统里还是欠")。

所以模型要拆成两张表:应收按账期一行,收款流水只增不改。欠费是两者相减的结果,永远不落成字段。

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 链接> 下一篇:《<下一篇标题>》

相关推荐
Web极客码1 小时前
决策模型 + LLM:用 Python 拆解浏览器自动化工作流
java·python·自动化
fanruitian1 小时前
python 装饰器 闭包
python
泡茶喝茶写代码1 小时前
A股量化策略回测实战:从行情数据到绩效评估(第 13 篇):放量突破策略
java·python·股票数据api·股票数据api接口·股票量化数据api·股票量化数据接口
cxyxixiy2 小时前
数智码力:数据核对不用人工比对,Python 快速完成多表格差异校验
开发语言·python·学习
LMT流氓兔2 小时前
第五单元——第11课:自动测试“拒绝后绝不能写入”
数据库·人工智能·python·langchain
ratel️2 小时前
小墨析 · Office Agent 日常办公不烧心
python·langchain·word·excel·fastapi·js·ppt
MrGet3 小时前
一步一步学Python(三):文件读写
python
是Yu欸3 小时前
先让 T1 Pro 传回一张好用的图:JPEG 抓拍、RTSP 视频与方向校正
python·机器人·音视频
伞伞悦读3 小时前
【第60期】特征值不是重要性:Python 特征分解与 PCA 的前置判断
python