简介:这是一份典当业务管理信息系统的软件工程硕士学位论文,面向金融信息化学习者及系统研发人员,针对传统典当业务手工处理效率低、易出错等痛点,给出完整的数字化解决方案。论文基于UML建模、Java三层架构和SQL Server数据库,围绕需求分析、系统架构设计、系统详细设计和测试展开,覆盖业务模型、基本业务程序、整体业务流程、数据流图、数据库E-R图及主要表结构等关键内容,能帮助读者理解MIS项目从调研到落地的全生命周期。全文从典当行实际业务流程出发,梳理了当物评估、抵押登记、赎当结算等环节的信息管理需求,并通过黑箱测试与白箱测试验证系统的稳定性和安全性。资源包内共1个doc文档,大小437KB,虽为单文件但结构完整,目录层次清晰,可直接按章节阅读或参考其数据库建表思路。目前已有113人学习,适合作软件工程、管理信息系统课程设计、毕业设计或中小型金融业务系统开发的参考范本。
1. 典当业务管理信息系统:先搞清业务再谈代码
“典当业务管理信息系统”不是普通的进销存,也不是简化的小额贷款软件——它要把当票、当物、当户、息费计算、收当、续当、赎当、绝当整套流程串成一条闭环数据链。我第一次接手这类项目时,以为把“客户+金额+日期”三张表做完就收工,结果死在综合费率计算和绝当状态流转上,光返工就花了两周。这篇笔记从业务模型、数据库设计、接口实现到上线对账,给你一条能落地的路径。适合刚接触行业管理系统的后端开发,也适合典当行里想用软件替代Excel台账的从业者——你先看懂业务,再动手建表写接口,后面能省掉大量返工。
2. 需求与业务流程:把典当行的一天翻译成系统功能
2.1 从收当到绝当:六张表就能覆盖的完整状态机
典当业务的起点是“收当”,终点要么是“赎当”拿回当物,要么是“绝当”由当行处置当物。中间还有“续当”延长当期。这四件事看起来简单,但真正混乱的往往在状态字段——很多初版表格只存一个“状态”字符串,结果程序到处写if (status.equals("已续当")),加一个新状态就全局翻车。
我一般会把状态定义成整型枚举,放在当票主表里,宁可多写几行枚举转换,也不要让业务逻辑散落在字符串判断里。常见状态机如下:
| 状态码 | 状态名 | 允许的操作 | 下一个状态 |
|---|---|---|---|
| 10 | 收当审核中 | 复核通过/驳回 | 20 在当 |
| 20 | 在当 | 赎当/续当 | 30 已赎当 / 40 已续当 |
| 40 | 已续当 | 赎当/再续当 | 30 已赎当 / 40 已续当 |
| 50 | 绝当待处理 | 处置/清收 | 60 已绝当处置 |
| 60 | 已绝当处置 | 无 | 无 |
这六张表分别是:当票主表、当物明细表、客户表、收当流水表、续当记录表、费用流水表。别在初版就加一堆冗余字段,比如“是否逾期”“剩余天数”这都能从日期算出来;否则最容易出现“表里存了是否逾期,但没人更新它,界面上逾期状态永远是对的,一查库全是旧值”这种尴尬。
做状态机的时候要注意:原生数据库表里没有状态机概念,我用的是整型状态加一张操作日志表。每次收当、续当、赎当都往操作日志写一行,记录操作人、操作类型、变更前后状态、操作时间。好处是出了问题你可以直接拉日志追责,不需要打开审计总开关。
2.2 费率与利息:综合费、息费、逾期罚息的计算规则
典当行不是银行,它的收入主要来自综合费和利息。综合费通常在放款时一次性预扣,利息可以按日计算到期一次性收,也可以按月结。不同地区、不同当物费率差异很大,代码里不能写死费率数字,必须做成可配置项,而且费率配置要带生效时间,否则改一次费率,历史账单全算错。
常见计算口径:
- 综合费 = 当金 × 综合费率 × 当期天数 ÷ 当月天数
- 利息 = 当金 × 年利率 ÷ 365 × 实际占用天数
- 逾期罚息 = 逾期本金 × 日罚息率 × 逾期天数
最关键的坑在于“当月天数”。有的业务人员口头说“按月息费率”,但月息是按30天算还是按自然月算?一定要在需求阶段确定,否则同一个接口在不同月份算出的金额会不一样。我做过的某家典当行约定:当期一个月按30天,不满一个月的按实际天数,逾期按自然日。配置表里加一个字段calendar_type,值为30/360或actual/actual,计算逻辑里统一从配置读取。
费用计算建议放在独立的领域服务里,而不是散落在各个Controller中。因为后续报表、短信提醒、对账都要复用这套算法。算法里所有金额用分存储,前端展示时再转成元,避免浮点误差。
2.3 角色与权限:柜员、复核、店长、财务各自能看到什么
典当业务有个特点:钱和物高度敏感,所以权限必须够细。常见角色至少分四类:
- 柜员:录当票、扫描当物照片、发起收当
- 复核员:复核当票、确认放款
- 店长:查看本店所有业务、调整费率(需留痕)
- 财务:查看资金流水、生成报表,但不能改当票
权限矩阵里最容易被漏掉的是“数据权限”——柜员默认只能看自己当天录的单,店长能看本店全量,总行财务能跨店看。很多系统只做了按钮权限,结果A门店的柜员在客户搜索框里打一个姓,把B门店客户也查出来了,这在典当行业非常忌讳。
我的做法是在所有查询语句里强制拼一个store_id过滤条件,而不是依赖前端传参。后端从登录态里取当前用户的店长ID,不信任任何前端传过来的店ID。同时给客户表加一个manager_id归属字段,客户归属第一次录单的柜员,后续跨店转单必须走“客户转移”功能,这样数据边界清晰,也方便业绩核算。
3. 数据库设计:当票号、当物描述与资金流水怎么建模
3.1 核心表结构:当票主表、当物明细表、收当流水表
当票是整张系统的核心。我建表的思路是:主表只放业务主键和状态字段,当物明细单独拆表,金额字段全用DECIMAL(12,2),业务时间用DATETIME,避免被DATE类型吞掉时区问题。
CREATE TABLE pawn_ticket ( id BIGINT PRIMARY KEY AUTO_INCREMENT, ticket_no VARCHAR(32) NOT NULL COMMENT '当票号,业务唯一', store_id INT NOT NULL COMMENT '门店ID', customer_id BIGINT NOT NULL COMMENT '当户ID', principal_amount DECIMAL(12,2) NOT NULL COMMENT '当金本金', comprehensive_fee DECIMAL(12,2) DEFAULT 0 COMMENT '预扣综合费', interest_amount DECIMAL(12,2) DEFAULT 0 COMMENT '应收利息', ticket_status TINYINT NOT NULL DEFAULT 10 COMMENT '状态:10收当审核,20在当,40已续当,30已赎当,50绝当,60已处置', loan_date DATETIME NOT NULL COMMENT '收当放款日期', due_date DATETIME NOT NULL COMMENT '到期日期', repay_date DATETIME NULL COMMENT '实际赎当日期', created_by BIGINT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_ticket_no (ticket_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='当票主表'; CREATE TABLE pawn_goods ( id BIGINT PRIMARY KEY AUTO_INCREMENT, ticket_id BIGINT NOT NULL, goods_name VARCHAR(64) NOT NULL COMMENT '当物名称,如黄金首饰', goods_desc VARCHAR(255) NOT NULL COMMENT '成色、重量、特征描述', goods_pic_url VARCHAR(255) NULL COMMENT '当物照片路径', estimate_amount DECIMAL(12,2) NOT NULL COMMENT '评估金额', loan_amount DECIMAL(12,2) NOT NULL COMMENT '实际放款金额', KEY idx_ticket_id (ticket_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='当物明细表'; CREATE TABLE fund_flow ( id BIGINT PRIMARY KEY AUTO_INCREMENT, ticket_id BIGINT NOT NULL, flow_type TINYINT NOT NULL COMMENT '1放款 2收息 3还本 4收综合费 5逾期罚息', amount DECIMAL(12,2) NOT NULL, pay_method TINYINT NOT NULL COMMENT '1现金 2转账 3扫码', operator_id BIGINT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_ticket_id (ticket_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='资金流水表';逻辑说明:当票主表里的principal_amount、comprehensive_fee、interest_amount三个字段是“应计金额”,实际收付记录全部写到fund_flow。这样对账时只要把当票主表的金额与流水表累计金额做差值,就能查出有没有漏收、错收。
参数说明:这里没有把费率字段放在当票主表里,因为费率可能随政策调整,应该在fee_config表里存快照。我的习惯是为每张当票保存一个fee_snapshotJSON字段,把收当当时的综合费率、利息率、计息口径都记下来,以后就算费率改了历史单据仍按旧费率计算,避免纠纷。
3.2 当票号生成规则:不依赖数据库自增的防重设计
当票号一旦出现重复,后续打印当票、做报表、对接司法鉴定都会出大问题。数据库自增主键在这里不够用——你没法保证跨店唯一,也看不出业务日期和门店。我使用的生成规则是:
当票号 = 门店编码(2位) + 受理日期(yyyyMMdd) + 当日序号(4位) + 校验位(1位)示例:01 20250608 0007 3,这个编号在打印当票时看起来很规整,也方便人工读。
当日序号不能简单查SELECT COUNT(*)然后 +1,两个并发请求会拿到同一个序号。常见做法是用 Redis 的INCR来实现原子自增:
public String generateTicketNo(Long storeId, LocalDate date) { String dateStr = date.format(DateTimeFormatter.BASIC_ISO_DATE); String key = "PAWN:TICKET:" + storeId + ":" + dateStr; long seq = redisTemplate.opsForValue().increment(key); // 设置过期时间为当天剩余秒数,防止 key 积累 redisTemplate.expire(key, Duration.ofSeconds(LocalDateTime.now().until(LocalDate.now().plusDays(1).atStartOfDay(), ChronoUnit.SECONDS))); String storeCode = String.format("%02d", storeId); String seqStr = String.format("%04d", seq); String ticketNoWithoutCheck = storeCode + dateStr + seqStr; return ticketNoWithoutCheck + checkCode(ticketNoWithoutCheck); }逻辑说明:checkCode是把前面的字符串通过 Luhn 算法算出一个校验位,这样以后录入当票号时做一次校验,手抖多打一位也能识别。这套方案的好处是每天每个门店序号从 1 重新开始,方便档案管理;Redis 挂了会有单点风险,我一般再加一层synchronized + 数据库唯一约束双保险,万一 Redis 恢复后序号重了,唯一索引会立刻报错。
参数说明:increment是原子操作,expire保证第二天自动重置;当天申请的 key 如果没有在当天用完,第二天也不会残留。注意不能用get再set,那两步不是原子操作。
3.3 用SQL计算到期日与应付金额的边界条件
到期日计算是整个系统最容易出界的地方。比如“当期一个月”到底是 30 天还是到下月同一天?逾期从到期日次日 0 点算,还是到期日当天 18 点算?这些问题不问业务人员,代码写出来一定是错的。
我通常在数据库里直接写一个计算函数,并在前后端共用同一条规则。下面是一个按自然日计算的到期日函数:
-- 到期日 = 放款日期 + 当期末天数(自然日) CREATE FUNCTION calc_due_date(loan_date DATETIME, term_days INT) RETURNS DATETIME DETERMINISTIC BEGIN RETURN DATE_ADD(loan_date, INTERVAL term_days DAY); END;绝当日的常见规则是:到期日后第 5 天为绝当日,也就是due_date + 5 days。但要确认这个 5 天是自然日还是工作日,二者差距很大。我服务过的客户里,绝大多数用的是自然日,只有一家坚持“工作日”,因为他们的绝当处置要法院配合。所以我把绝当日也写成配置项,不写死。
应付金额的计算不建议用 SQL 里的浮点除法,应该在应用层用 BigDecimal 算好金额再落库。下面给一个赎当金额计算的核心代码:
public BigDecimal calcRepayAmount(PawnTicket ticket, LocalDate repayDate) { long overdueDays = Duration.between( ticket.getDueDate().toLocalDate().atStartOfDay(), repayDate.atStartOfDay() ).toDays(); BigDecimal principal = ticket.getPrincipalAmount(); BigDecimal interest = ticket.getInterestAmount(); if (overdueDays <= 0) { return principal.add(interest); } // 逾期罚息 = 本金 * 日罚息率 * 逾期天数 BigDecimal dailyPenaltyRate = feeConfigRepository.getCurrentPenaltyRate(); BigDecimal penalty = principal.multiply(dailyPenaltyRate) .multiply(BigDecimal.valueOf(overdueDays)); return principal.add(interest).add(penalty); }逻辑说明:先用Duration算出到期日与实际还款日之间相差的天数,避免自己写日期相减再除 86400000 这类晦涩代码;逾期罚息以本金为基数,而不是本金加利息。这一点要和业务确认清楚,有的典当行按“本金+利息”一起罚,差异非常大。
参数说明:dailyPenaltyRate对应配置表里的日息,比如万分之五就传0.0005。如果逾期跨越月底,还要判断是否需要分段计息,我通常只按连续自然日算,等业务提出要求再加分段逻辑。
4. 核心功能实现:从模拟当票到放款、赎当的接口代码
4.1 收当接口:事务里同时写入当票、当物和流水
收当是写入最重的接口,一次操作要同时落三张表:当票主表、当物明细表、资金流水表。此外还要更新客户表的累计当金余额。如果不用事务,任何一张表写入失败都会造成两边数据对不上,就像当票打了但钱没放,柜台上一团糟。
下面是一个基于 Spring Boot 的收当 Service 方法,用@Transactional保证整块操作原子性:
@Service public class PawnService { @Transactional(rollbackFor = Exception.class) public PawnTicket receipt(ReceiptCommand cmd) { // 1. 校验当物、费率配置,防止误操作 FeeConfig fee = feeConfigRepository.findByStoreIdAndDate(cmd.getStoreId(), LocalDate.now()); // 2. 生成当票主表 PawnTicket ticket = new PawnTicket(); ticket.setTicketNo(generateTicketNo(cmd.getStoreId(), LocalDate.now())); ticket.setStoreId(cmd.getStoreId()); ticket.setCustomerId(cmd.getCustomerId()); ticket.setPrincipalAmount(cmd.getPrincipalAmount()); // 综合费 = 当金 * 综合费率(月) / 30 * 当期天数 BigDecimal comprehensive = calcComprehensiveFee(cmd.getPrincipalAmount(), fee, cmd.getTermDays()); ticket.setComprehensiveFee(comprehensive); ticket.setInterestAmount(calcInterest(cmd.getPrincipalAmount(), fee, cmd.getTermDays())); ticket.setTicketStatus(10); ticket.setLoanDate(LocalDateTime.now()); ticket.setDueDate(calcDueDate(LocalDateTime.now(), cmd.getTermDays())); pawnTicketRepository.insert(ticket); // 3. 写入当物明细 for (GoodsItem item : cmd.getGoodsList()) { pawnGoodsRepository.insert(new PawnGoods(ticket.getId(), item)); } // 4. 写入放款资金流水 fundFlowRepository.insert(new FundFlow(ticket.getId(), 1, cmd.getPrincipalAmount(), cmd.getPayMethod(), cmd.getOperatorId())); return ticket; } }逻辑说明:可以看到我在 Service 入口处先取了一次费率配置,把它作为本次收当的单据快照。这里没有把整张FeeConfig冗余到当票里,而是把费率快照存为 JSON 字段,是为了避免后续改费率时旧单据失效。ticketStatus初始为 10(收当审核中),实际业务中可能需要复核员确认后才改成 20,所以放款流水也先不填实际放款时间。
参数说明:ReceiptCommand是前端接口入参,包含storeId、customerId、principalAmount、termDays、goodsList、payMethod、operatorId。要注意rollbackFor = Exception.class:Spring 默认只回滚 RuntimeException,这里的rollbackFor让任何异常都回滚,避免“流水没写但主表已提交”的脏数据。
4.2 续当接口:更新到期日并追加应收费用
续当的业务含义是:当户在到期前申请延长当期,不归还本金,只补交下一期综合费和利息。好的产品设计会保留一张renewal_records表,记录每一次续当的时间、新到期日、加收费用金额,而不是简单覆盖当票主表的到期日。
@Transactional(rollbackFor = Exception.class) public PawnTicket renew(Long ticketId, int extraDays, Long operatorId) { PawnTicket ticket = pawnTicketRepository.findById(ticketId); // 只有“在当”和“已续当”状态才能继续续当 if (ticket.getTicketStatus() != 20 && ticket.getTicketStatus() != 40) { throw new IllegalStateException("当前状态不可续当"); } if (LocalDateTime.now().isAfter(ticket.getDueDate())) { throw new IllegalStateException("已逾期或已绝当,不能续当"); } // 计算新到期日 LocalDateTime newDueDate = ticket.getDueDate().plusDays(extraDays); // 计算续当应缴费用:综合费+利息,这里按新增天数算 BigDecimal renewalFee = calcRenewalFee(ticket.getPrincipalAmount(), extraDays); // 写入续当记录 RenewalRecord record = new RenewalRecord(); record.setTicketId(ticketId); record.setOldDueDate(ticket.getDueDate()); record.setNewDueDate(newDueDate); record.setExtraDays(extraDays); record.setFeeAmount(renewalFee); renewalRecordRepository.insert(record); // 更新当票主表 ticket.setDueDate(newDueDate); ticket.setTicketStatus(40); pawnTicketRepository.update(ticket); // 写入费用流水 fundFlowRepository.insert(new FundFlow(ticketId, 4, renewalFee, payMethod, operatorId)); return ticket; }逻辑说明:续当不是把上一期费用抹平,而是在原到期日基础上增加extraDays,所以新到期日必须用旧到期日加天数,而不是“今天 + extraDays”。否则当户晚来一周,你给他从今天算起,等于白送一周。
这里最容易翻车的是:续当费用到底按整期收还是按实际天数收?现实中多数典当行会重新签一整期,比如再续 30 天,收费金额按整期综合费;如果只续 5 天则按 5 天算。我用extraDays传入天数,费用函数按实际天数计算,再由前端控制最少续当天数,这样规则清晰。
参数说明:renewalFee的流水flowType我使用 4(收综合费)而不是 2(收利息),因为续当补缴通常两者合并,这里简化成 4。真实业务里最好拆出两个流水,方便财务对账。
4.3 赎当与绝当处理:用一条update完成状态流转
赎当接口的难点不在 update 本身,而在“计算应付多少钱”和“校验当物是否在库”。如果只把状态从 20 改成 30,却忘了收回当物,柜面一盘点就少了东西。所以我通常会先锁定当票行记录,再写流水,最后更新状态。
@Transactional(rollbackFor = Exception.class) public void redeem(Long ticketId, Long operatorId, int payMethod) { PawnTicket ticket = pawnTicketRepository.selectForUpdate(ticketId); if (ticket.getTicketStatus() != 20 && ticket.getTicketStatus() != 40) { throw new IllegalStateException("该当票当前状态不可赎当"); } BigDecimal repayAmount = calcRepayAmount(ticket, LocalDate.now()); // 物理核验:当物是否在库由前端流程控制,这里只做状态校验 // 1. 更新当票状态 ticket.setTicketStatus(30); ticket.setRepayDate(LocalDateTime.now()); pawnTicketRepository.update(ticket); // 2. 写入还款资金流水 fundFlowRepository.insert(new FundFlow(ticketId, 3, repayAmount, payMethod, operatorId)); // 3. 释放客户可当额度(如果系统有此概念) customerRepository.releaseLine(ticket.getCustomerId(), ticket.getPrincipalAmount()); }逻辑说明:selectForUpdate是行级锁,防止两个界面同时操作同一张当票——比如柜台正在赎当,后台定时任务把它批量绝当了,这会导致状态错乱。如果系统没有并发处理经验,宁可先锁再查,也不要先查再锁。流水类型 3 表示还本,利息和罚息应该再各写一条流水,这里为了示例合并了,实际项目里我强烈建议拆开。
绝当处理一般不需要人工逐单点击。常见做法是定时任务每天早上扫描所有已到期且超过宽限期的当票,批量把状态改成 50(绝当待处理)。脚本里要注意“只有状态是 20 或 40 且当前时间超过绝当日”的才处理,不要碰已赎当、已处置的单,否则会出现“已经赎完的当票又被打上绝当标签”的笑话。每个店可以配置不同的绝当宽限天数。
5. 避坑:典当系统最典型的7个坑及排查路径
5.1 金额用 double 导致对不上账
现象:系统里某张当票应收 10000 元,综合费 1500 元,算出来界面显示 1500.000000000001,财务打印报表时账目差一分钱。
原因:Java 的double在计算费率时存在二进制浮点误差,多次累计后误差放大。数据库里的DECIMAL与 Java 的Double映射后也会丢失精度。
解决:所有金额字段一律用BigDecimal,数据库用DECIMAL(12,2),禁止把金额放进double或float。在写 Java 代码时,用字符串构造函数:new BigDecimal("1500.00"),而不是new BigDecimal(1500.00)。项目里全局审计一遍,凡是有getAmount()返回 Double 的地方全部替换。
5.2 当票号并发重号
现象:高峰期同时提交两张当票,两张单子拿到同一个序号,后写入的一方被唯一索引拦住,界面报错,但第一张已经生成,第二张不知道去哪了。
原因:用了SELECT max(seq) + 1这种方式生成序号,两个事务同时读到当前最大值,各加 1,导致重号。数据库隔离级别为 RR 时不会锁住间隙,问题更容易暴露。
解决:用 Redis INCR 生成每日序号,并在数据库表上建立唯一索引作为兜底;如果不用 Redis,可以在 MySQL 里用SELECT ... FOR UPDATE锁一行序号表,但并发不高时也能用。更稳妥的方案是直接用雪花算法生成业务号,再加日期前缀,只是当票号没那么易读。我的建议是“可读性优先”,用 Redis 方案即可。
5.3 续当直接覆盖原到期日,历史记录全丢
现象:同一张当票续当了三次,到第四次续当时,系统里只能看到最近一次续当的到期日,之前三次的费用明细全没了。财务要查旧账时无从下手。
原因:续当接口直接 update 了主表due_date,没有维护续当记录表,也没有保留历史到期日。
解决:建立renewal_records表,每次续当都插入一条记录,包含old_due_date、new_due_date、extra_days、fee_amount、operator_id。主表due_date只保留当前有效值。这个坑在需求阶段就要坚持加表,不然后期补数据极其痛苦。
5.4 绝当日期计算规则没确认,批量绝当月完全不一样
现象:同一批逾期当票,有人认为绝当日是到期日后第 5 个自然日,有人认为是第 5 个工作日;系统按自然日计算,业务按工作日拿准绝当日期,两边结果不一致,导致绝当处置单迟迟不能生成。
原因:需求文档里只写了“到期后 5 天内未赎当即为绝当”,但没定义“天”是自然日还是工作日,也没有考虑节假日顺延。
解决:在系统配置里增加grace_days_type配置项,可配置自然日或工作日;计算绝当日时统一走一个calcForfeitDate函数。上线前拿三个月的历史单据做回测,把系统计算的绝当日与人工登记的绝当日逐单比对,能发现很多隐藏规则差异。
5.5 数据权限漏配,跨门店查询客户
现象:新上线一周,店长反馈柜员能看到其他门店的当户姓名和当物照片,严重越权。
原因:查询接口只校验了“是否登录”,没有按门店维度过滤。柜员直接传一个客户ID,后端无条件返回。
解决:提供统一的数据权限过滤器,所有涉及客户和当票的查询都从当前登录人所在门店推导store_id条件。这里有个细节:管理后台的列表查询要用MyBatis 拦截器自动追加store_id = ?,而不是在每个 mapper 手写,以免后人漏写。
5.6 综合费预扣与提前赎回的退费逻辑
现象:收当时预扣了整个当期综合费,当户第三天就来赎当,系统没有退还剩余天数的费用,客户上门投诉。
原因:业务规则是“提前赎当按天退综合费”,但需求阶段没讲清楚,开发默认收费不退还。
解决:提前在费用规则里定义退费公式应退 = 预扣综合费 - 已占用天数 × 日综合费,并生成一条负数资金流水记录退费。我建议这类规则不要在代码里写死,做成fee_config里的refund_rule字段,不同门店可能不一样。
6. 上线后的报表与对账:让系统真正被财务信任
系统上线只是开始,真正的挑战是每天早上的日结对账。财务不会看你界面上的“今日放款 12 万”,她要的是系统里的当票汇总和资金流水绝对对得上。我的经验是在项目交付时额外做一个“试算报表”:每天凌晨跑一次全量当日业务数据,按门店、按柜员维度输出收当笔数、放款金额、综合费应收、利息应收、罚息应收、实收金额六列。财务拿到这张表,再用她自己的 Excel 台账一核,数字能对上,她才会慢慢信任系统。
我在这套系统上犯过一个特别蠢的错:因为收当、赎当用了两套金额计算逻辑,导致同一张当票在业务报表和财务流水里的应付金额不一致。后来我把所有金额计算收敛到同一个FeeCalculatorService,并加了一条单测用例,把历史一百张当票的结果全部压测比对,才算把账目抹平。所以你在开发时,不管是收当、续当还是赎当,最后更新金额的方法一定要走同一个服务,别图方便在 Controller 里直接 BigDecimal 乘除法。
另一个值得做的功能是“当票全生命周期时间轴”。柜员可能调走,复核员可能离职,但系统要能回答“这张当票从收当到绝当,每一步谁在什么时间做了什么”。我一般会在详情页放一条垂直时间轴:收当、复核、放款、续当、赎当、绝当,每步打时间戳。这功能开发成本不高,但对内部审计和纠纷处理价值非常大,建议你在落地时就规划上,别等上线后业务天天打电话来查单时才补。
说到这,差不多把我做这个系统踩过的坑和最值得复用的经验讲完了。如果你也在做类似系统,最好先花两周把业务流程和费率规则梳理清楚,再让开发动手。我这个教训就是一开始太急着写代码,结果光是绝当日期就改了四版。希望帮到你。
本文还有配套的精品资源,点击获取