赊销账期怎么管?一套“合约客户“结算系统的设计与实现 做批发、餐饮供货、生鲜配送这类 B2B 生意先拿货、后付款是常态。口头约定一句月底一起结看似简单落到系统里却涉及合约管理、账单生成、结算核销、逾期提醒一整套链路。最近在团队里做了一版合约客户结算模块从建模到上线踩了不少坑整理出来供有类似需求的同学参考。一、业务背景账期管理的三个难题记账难赊销订单散落在微信、电话、纸质单据里靠本子记、靠脑子想对账难结了几笔、欠了几笔月底翻聊天记录对半天催收难口头约定没有抓手催急了伤客情不催现金流紧张。系统要解决的就是把口头约定变成数据约束。二、核心模型四张主表撑起账期结算抽象下来是四个实体实体职责关键字段合约 Contract约定结算周期按周 / 按月 / 自定义天数记录起止时间、状态contract_id, customer_id, cycle_type, cycle_days, settle_day, start_date, end_date, status, grace_days订单 Order挂在合约下的记账入口每笔赊销自动关联order_id, contract_id, amount, bill_period_no, created_at账单 Statement按账期聚合生成回答这一期该结多少statement_id, contract_id, period_no, total_amount, paid_amount, status, due_date流水 Repayment还款记录支持部分还款、多笔核销repayment_id, contract_id, amount, pay_time, pay_channel关系很清晰客户 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; }生成时机也有讲究跑批生成T1 凌晨扫描所有到期账期实现简单但账期结束后当天客户看不到账单懒生成客户或业务员打开账单页时实时聚合体验好但要处理账期内还有新订单进来的情况。我们的折中是账期结束后 T1 跑批固化账单账期内页面用实时聚合预览两者口径保持一致即可。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 号付款、遇到周末顺延。你们在做类似系统时账期规则是怎么抽象的有没有遇到更奇葩的结算口径欢迎评论区交流。