做批发、餐饮供货、生鲜配送这类 B2B 生意,"先拿货、后付款"是常态。口头约定一句"月底一起结",看似简单,落到系统里却涉及合约管理、账单生成、结算核销、逾期提醒一整套链路。最近在团队里做了一版"合约客户"结算模块,从建模到上线踩了不少坑,整理出来供有类似需求的同学参考。
一、业务背景:账期管理的三个难题
- 记账难:赊销订单散落在微信、电话、纸质单据里,靠本子记、靠脑子想;
- 对账难:结了几笔、欠了几笔,月底翻聊天记录对半天;
- 催收难:口头约定没有抓手,催急了伤客情,不催现金流紧张。
系统要解决的,就是把"口头约定"变成数据约束。
二、核心模型:四张主表撑起账期结算
抽象下来是四个实体:
实体 | 职责 | 关键字段 |
|---|---|---|
合约 Contract | 约定结算周期:按周 / 按月 / 自定义天数;记录起止时间、状态 |
|
订单 Order | 挂在合约下的记账入口,每笔赊销自动关联 |
|
账单 Statement | 按账期聚合生成,回答"这一期该结多少" |
|
流水 Repayment | 还款记录,支持部分还款、多笔核销 |
|
关系很清晰:客户 1—n 合约 1—n 账单 1—n 流水,订单按合约维度聚合进账单。
订单一旦产生就属于某个账期,聚合时用bill_period_no这个"账期编号"做关联键,比按时间范围BETWEEN查询要稳得多——后者在账期规则调整时会直接算错。
CREATE TABLE contract ( id BIGINT PRIMARY KEY, customer_id BIGINT NOT NULL, cycle_type TINYINT NOT NULL COMMENT '1周结 2月结 3自定义天数', cycle_days INT COMMENT '自定义账期天数', settle_day TINYINT COMMENT '月结时的结算日,如每月5号', grace_days INT DEFAULT 0 COMMENT '宽限期', start_date DATE NOT NULL, end_date DATE, status TINYINT NOT NULL COMMENT '1生效 2已到期 3已终止', version INT DEFAULT 0 ); CREATE TABLE statement ( id BIGINT PRIMARY KEY, contract_id BIGINT NOT NULL, period_no VARCHAR(32) NOT NULL COMMENT '账期编号,如 2025-05-W2 / 2025-05', total_amount BIGINT NOT NULL COMMENT '单位:分', paid_amount BIGINT NOT NULL DEFAULT 0, status TINYINT NOT NULL COMMENT '1待结算 2部分结算 3已结清 4逾期 5已作废', due_date DATE NOT NULL, version INT DEFAULT 0, UNIQUE KEY uk_contract_period (contract_id, period_no) );那个UNIQUE KEY uk_contract_period是整套设计的地基,后面所有幂等都依赖它。
三、关键流程与状态机
签约生效 → 订单自动挂约 → 账期结束生成账单 → 结算 → 核销 → 完结 ↘ 逾期未结 → 自动提醒 → 结清账单状态机建议保持简单,状态一多,边界情况就指数级增长:
1 待结算→ 收到部分还款 →2 部分结算2 部分结算→ 还清 →3 已结清1 / 2→ 超过due_date→4 逾期(逾期不是终态,还款后按部分 / 全额回流到2 / 3)
逾期不要做成独立状态位,而是由due_date与当前日期实时推导。否则宽限期一改,历史数据的逾期标记就全乱了。
四、实现要点
4.1 账单生成:幂等要写在数据库层
账期结束生成账单,第一版我是在代码里先查一次"有没有生成过",结果压测时并发跑批直接造出重复账单。正确做法是依赖唯一索引 + 异常吞掉:
try { statementMapper.insert(statement); // uk_contract_period 兜底 } catch (DuplicateKeyException e) { log.warn("账单已存在,跳过: contractId={}, periodNo={}", contractId, periodNo); return; }生成时机也有讲究:
- 跑批生成:T+1 凌晨扫描所有到期账期,实现简单,但账期结束后当天客户看不到账单;
- 懒生成:客户或业务员打开账单页时实时聚合,体验好,但要处理"账期内还有新订单进来"的情况。
我们的折中是:账期结束后 T+1 跑批固化账单,账期内页面用实时聚合预览,两者口径保持一致即可。
4.2 金额一律用"分",别用浮点
所有金额字段都是BIGINT(单位:分),只在展示层除 100。这不是老生常谈——核销场景要做多次加减和比例分摊,一旦出现0.1 + 0.2 != 0.3,对账的时候你会怀疑人生。
4.3 核销:支持指定核销,FIFO 兜底
客户经常只结其中几笔,比如"上个月的先给你结一半,那笔有争议的再等等"。所以核销要分两种模式:
- 指定核销:前端勾选具体订单 / 账单,生成
repayment_allocation明细; - FIFO 兜底:未指定时按账单
due_date升序逐笔冲抵。
CREATE TABLE repayment_allocation ( id BIGINT PRIMARY KEY, repayment_id BIGINT NOT NULL, statement_id BIGINT NOT NULL, allocated_amt BIGINT NOT NULL COMMENT '本次核销金额,单位分', KEY idx_repayment (repayment_id) );核销一定要留明细表,不要只更新statement.paid_amount。否则三个月后客户问"我 3 月那笔 5000 到底冲的是哪几单",你就只能摊手了。
一次还款核销多笔账单,要包在一个事务里,并且用乐观锁更新账单:
UPDATE statement SET paid_amount = paid_amount + #{amt}, status = #{newStatus}, version = version + 1 WHERE id = #{id} AND version = #{oldVersion} AND paid_amount + #{amt} <= total_amount;最后那个paid_amount + #{amt} <= total_amount是防超收的硬约束,别只靠上层校验。
4.4 逾期与提醒
定时任务(我们用 XXL-Job,按contract_id分片)扫两类账单:
- 即将到期:
due_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 3 DAY); - 已逾期:
due_date < CURDATE() AND status IN (1,2),按逾期天数分档提醒(3/7/15 天),避免每天都发造成骚扰。
一个低成本但效果明显的做法:给客户侧也开放只读账单视图,用一次性 token 授权,客户点开就能看到本期明细。很多催收根本不用催,客户自己看到账单就主动结了——催收难,很多时候不是不想付,是想不起来、算不清。
4.5 明细穿透
账单 → 订单 → 商品明细,层层可追。每笔货款对应哪几笔送货、每笔送货对应哪些商品规格,都能点进去。这个看似是 UI 的事,实际上决定了订单表要不要冗余contract_id和bill_period_no——建议冗余,否则穿透查询要 join 三次以上,客户一多就慢。
4.6 账期规则要在签约时写死
自然月(每月 1 号到月末,次月 5 号结算)和"签约日起滚动 30 天"是两套完全不同的算法,账单日和到期日都会错开。签约页面必须让业务员明确选一种,并且合约生效后不允许直接改 cycle_type,只能终止旧合约、签新合约,否则历史账单的解释成本会非常高。
五、踩过的坑
- 跨账期补单:账期已出账,业务员又补录了一笔上月送货单。解决:账单固化后新增订单落到"待归属"池,下一期账单优先吸收,并提供"追加到历史账单"的人工入口(会重新计算
total_amount,需要留操作日志)。 - 红冲退款:退货不是核销,是
total_amount的减项。单独走一条负向流水,不要混进repayment,否则财务报表口径会失真。 - 合约中途终止:终止时可能有未出账订单,要强制触发一次"提前出账",否则这笔钱永远不会被催收。
- 一个客户多个合约:批发客户常常"生鲜月结、冻品周结",合约与客户是 1—n,别想当然做成一对一。
六、小结
账期管理本身不复杂,难在三件事:每笔都清楚(明细穿透 + 核销留痕)、每期都提醒(定时任务 + 客户自助视图)、每次都可追(状态机 + 操作日志)。
这套模型上线后覆盖了批发、餐饮配送、生鲜供应链几类场景,核心表结构基本没再动过,改动主要集中在账期规则的表达上——业务方的"月结"永远比你想的复杂,比如"每月 25 号结账、次月 5 号付款、遇到周末顺延"。
你们在做类似系统时,账期规则是怎么抽象的?有没有遇到更奇葩的结算口径,欢迎评论区交流。