news 2026/10/18 6:49:11

赊销账期怎么管?一套“合约客户“结算系统的设计与实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
赊销账期怎么管?一套“合约客户“结算系统的设计与实现

做批发、餐饮供货、生鲜配送这类 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; }

生成时机也有讲究:

  • 跑批生成: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,只能终止旧合约、签新合约,否则历史账单的解释成本会非常高。

五、踩过的坑

  1. 跨账期补单:账期已出账,业务员又补录了一笔上月送货单。解决:账单固化后新增订单落到"待归属"池,下一期账单优先吸收,并提供"追加到历史账单"的人工入口(会重新计算total_amount,需要留操作日志)。
  2. 红冲退款:退货不是核销,是total_amount的减项。单独走一条负向流水,不要混进repayment,否则财务报表口径会失真。
  3. 合约中途终止:终止时可能有未出账订单,要强制触发一次"提前出账",否则这笔钱永远不会被催收。
  4. 一个客户多个合约:批发客户常常"生鲜月结、冻品周结",合约与客户是 1—n,别想当然做成一对一。

六、小结

账期管理本身不复杂,难在三件事:每笔都清楚(明细穿透 + 核销留痕)、每期都提醒(定时任务 + 客户自助视图)、每次都可追(状态机 + 操作日志)。

这套模型上线后覆盖了批发、餐饮配送、生鲜供应链几类场景,核心表结构基本没再动过,改动主要集中在账期规则的表达上——业务方的"月结"永远比你想的复杂,比如"每月 25 号结账、次月 5 号付款、遇到周末顺延"。

你们在做类似系统时,账期规则是怎么抽象的?有没有遇到更奇葩的结算口径,欢迎评论区交流。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/18 6:46:27

从拆解到提交:开放式任务的高效完成路径

拿到“第五章作业”这个标题&#xff0c;我愣了几秒钟。没有正文、没有关键词、甚至没有摘要&#xff0c;就这五个字&#xff0c;反而让我想起很多相似的局面——期末周深夜的宿舍、临时通知的项目报告、还有那些“就剩最后一章没搞定”的焦虑时刻。这一章通常不是什么开篇序曲…

作者头像 李华
网站建设 2026/10/18 6:41:16

现代AI计算硬件与基础设施技术体系完整拆解

这些年做AI基础设施相关工作&#xff0c;我有一个很深的感触&#xff1a;大部分团队讨论AI项目时&#xff0c;优先聊的是模型结构、训练技巧和数据质量&#xff0c;但真正决定项目上限的&#xff0c;往往是那些不怎么上发布会PPT的东西——现代AI计算硬件与基础设施技术体系。同…

作者头像 李华
网站建设 2026/10/18 6:41:16

DVWA暴力破解实验:从认证漏洞到防御工程全解析

1. 这不是“暴力破解演示”&#xff0c;而是一次对认证机制脆弱性的深度解剖DVWA靶场里的Brute Force模块&#xff0c;常被新手当成“练手小玩具”——点几下按钮、跑个字典、看到“登录成功”就截图发朋友圈。但我在某高校网络安全实训课带过三届学生后发现&#xff1a;超过70…

作者头像 李华
网站建设 2026/10/18 6:39:14

基于Vue+Node.js的充电桩预约管理系统开发实战

1. 项目总体设计与技术选型做充电桩预约管理系统&#xff0c;最先要考虑的不是写代码&#xff0c;而是想清楚一个问题&#xff1a;这套系统到底要管哪些事&#xff1f;电动车充电和汽油车加油不一样&#xff0c;充电时间动辄半小时到几小时&#xff0c;用户不可能一直守在车旁边…

作者头像 李华
网站建设 2026/10/18 6:36:22

PyTorch入门实战:Tensor、自动求导与图像分类全流程

1. 先想明白&#xff1a;你究竟在跟 PyTorch 学什么前阵子有个朋友问我&#xff0c;说他想学 PyTorch&#xff0c;但打开教程看到一堆nn.Module、DataLoader、backward()&#xff0c;整个人就懵了。他说自己不是程序员&#xff0c;只是做数据分析的&#xff0c;被领导安排要训练…

作者头像 李华