ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

印刷报价系统实战:计价引擎、价格快照与规则配置化

印刷报价系统实战:计价引擎、价格快照与规则配置化

简介:印刷企业报价系统是一套面向印刷行业中小企业及入门开发者的桌面管理软件项目,围绕报价、客户、产品、材料与设备等核心业务展开,帮助用户快速核算印刷工艺、纸张类型与数量对应的价格,并跟踪客户历史订单、维护产品目录、监控材料库存与设备保养周期。整套压缩包共118个文件、约57.85MB,以33个C#源码文件和16个resources资源、15个resx界面资源为主,另含可执行程序、DLL、配置文件及Access数据库等,构成可运行、可查阅的完整工程。已有842人学习下载,说明其对业务理解和代码研读具有一定参考价值。读者可从中获取报价参数预设与自定义报价思路、客户与产品资料管理流程、材料库存预警及设备维护提醒的实现方式,也能对照源码和数据库理解基础CRUD操作;代码结构较初级,适合作为中小项目二次改造与学习范例。

1. 印刷报价这件事,为什么值得单独做一个系统

印刷厂业务员最怕的场景不是客户砍价,而是客户站在柜台前问一句“5000 份三折页、157 克铜版、双面四色、覆哑膜,多少钱”,然后全场的目光都落到老师傅身上。老师傅翻开纸张价格本,按几下计算器,嘴里念着开数、放数、千印价,三分钟后报出一个数字,旁边人再验算一遍,还是差两毛。这套印刷企业报价系统要做的,就是把老师傅脑子里那一套没写下来的算法固化成可配置的规则表,让业务员在浏览器里选参数、点计算,几十毫秒出结果,还能把报价单直接打印给客户。

它适合中小型印刷厂、包装厂、快印门店,也适合正在给这类企业做定制开发的技术团队。核心不是 CRUD,而是计价引擎:纸张吨价怎么折算成令价、拼版开数怎么取整、工艺费怎么按印量阶梯累加、客户协议价和标准价谁优先。这些算错一个环节,一单可能就白干。

2. 系统定位与技术选型:报价引擎和业务后台是怎么分工的

2.1 从一张报价单倒推出来的模块边界

拿到这个项目,我没有先看代码,而是让业务把最近三个月成交的报价单打印了二十张出来摊在桌上。看完就明白模块该怎么切了:一张报价单上的每一行,背后都对应一类基础数据,而整张单子的合计,对应一条计算链路。

最左边是基础数据域,管纸张、工艺、装订方式、客户、设备台时。这部分变更频率低但要求准,纸张吨价一涨,全厂报价都得跟着动。中间是计价引擎,纯计算,不碰数据库事务,输入一组印件参数和一份价格快照,输出明细行和总价。右边是报价单域,负责单据编号、版本、审批状态、导出 PDF、转订单。再外围还有权限和操作日志,印刷厂老板最关心的是“谁把价格改低了”。

这么切的好处是计价引擎可以单独跑单元测试。我一般会把历史成交单整理成 JSON 用例,每次改规则先跑一遍回归,不然改一处工艺费,别的品类全跟着漂。

模块边界定下来之后,接口就好设计了。计价引擎对外只暴露一个calc(QuoteRequest),请求里带印件参数和priceVersion,响应里带明细行列表和总价。报价单服务负责落库时把priceVersion和引擎算出的明细一起快照进去,后面价格表怎么改,历史单据都不受影响。这个快照设计是整个项目里最值得抄的一点,血泪经验:不做快照,客户三个月后回来对账,你会发现系统里的数字和当初打印出来的那张纸对不上,解释不清。

2.2 技术栈与目录结构:Spring Boot 加 Vue3 的分层

技术选型上没太多纠结。这类企业内部系统,国内最常见也最稳的组合就是后端 Spring Boot 3 + MyBatis-Plus + MySQL 8,缓存用 Redis 存价格表快照,导出用 iText 或 PDFBox,前端 Vue3 + Element Plus + Vite。理由很实在:印刷厂的 IT 环境通常就一台应用服务器加一台数据库,运维不懂容器编排,jar 包扔上去java -jar能跑起来最重要;而业务表格多、下拉多、批量录入多,Element Plus 的表格和表单组件能省掉大量重复劳动。

目录结构大致是这样,我按职责而不是按技术分层,找代码的时候不用来回跳:

print-quote/ ├── quote-common/ # 通用工具:金额、单位、枚举 │ └── src/main/java/.../common/ │ ├── Money.java # 基于 BigDecimal 的金额包装 │ └── enums/ # 纸张类型、工艺类型、装订方式 ├── quote-domain/ # 领域模型与计价引擎,不依赖 Spring │ └── src/main/java/.../engine/ │ ├── QuoteEngine.java # 计价入口 │ ├── model/ # QuoteRequest / QuoteItem / QuoteResult │ └── rule/ # 各类计价规则实现 ├── quote-admin/ # Web 层:Controller、鉴权、导出 │ └── src/main/java/.../admin/ │ ├── controller/ │ ├── service/ │ └── mapper/ ├── quote-web/ # Vue3 前端 │ ├── src/views/quote/ # 报价单录入、计算、打印 │ ├── src/views/base/ # 纸张、工艺、客户维护 │ └── src/api/ └── sql/ # 建表脚本与初始化数据

把计价引擎单独抽成quote-domain模块、且不依赖 Spring 容器,是刻意为之。这样它就是一个纯 Java 库,跑单测不需要起上下文,几秒钟跑完几百条历史用例。很多同类项目把计算逻辑写在 Service 里,注入了 Mapper 之后到处查库,一条报价要发七八次 SQL,改起来牵一发动全身,最后没人敢动。

quote-common里那个Money包装类也别省。金额一律BigDecimal,setScale(2, RoundingMode.HALF_UP),禁止出现double。印刷报价里单价经常是每张几分几厘,用 double 累加几十行明细,最后差个几分钱是常态,客户一对账就翻车。

前后端交互上,报价页面的参数联动比较多:选了纸张规格,开数下拉要跟着变;选了装订方式,工艺列表要过滤。我一般把这些联动规则做成后端下发的一份配置,前端只负责渲染,避免规则在两端各写一份,改了一边忘另一边。

提示:quote-domain里不要出现任何@Autowired,需要的数据通过方法参数传进去。这条约束守住了,后面做规则回归测试会轻松很多。

3. 报价核心链路:从印件参数到最终单价的完整计算过程

3.1 印张与开数:先把纸算明白

整个计价链路里,最容易出错也最影响结果的就是印张数。印刷报价的底层单位不是“份”,是“印张”——一张全开纸印一面叫一个印张。客户要 5000 份 A4 三折页,实际要先确定用多大开数的纸、一份拼几个、放多少损耗。

举个具体的:成品是 A4(210×285mm)三折页,用大度纸(889×1194mm)拼版,一张大度纸能出 16 个 A4,这就是常说的 16 开。那么 5000 份需要 5000/16 = 312.5 张大纸,向上取整 313 张,再加上调机放数。

/** * 计算印件的用纸张数 * @param qty 成品数量 * @param piecesPer 每张全开纸的可拼成品数(开数) * @param wasteRate 放数比例,如 0.03 表示 3% */ public int calcSheets(int qty, int piecesPer, BigDecimal wasteRate) { if (piecesPer <= 0) { throw new IllegalArgumentException("开数必须大于 0"); } // 先按成品数量反推整张纸数,向上取整,不能有半张纸 int base = (int) Math.ceil(qty * 1.0 / piecesPer); // 放数按整张纸计,同样向上取整 int waste = (int) Math.ceil(base * wasteRate.doubleValue()); return base + waste; }

piecesPer就是开数,它不能靠猜,得从拼版规则来。同一张全开纸,成品尺寸不同、留边不同、咬口位置不同,能拼出的数量完全不同。我一般会在纸张开数表里预置常见组合(大度 16 开、正度 16 开、大度 8 开……),成品尺寸落到某个区间就匹配对应开数,同时允许业务手工覆盖。这一步做死板了,遇到异形包装盒就全废。

wasteRate是放数,印刷行业叫“加放”。短版活放数比例高,长版活比例低,四色比单色多,有后道工序的还要再加。这东西没有国标,全看厂里自己的经验值,所以必须做成可配置,且按“印量区间 + 色数 + 工艺数”维度配。我在项目里是单独一张waste_rule表,查不到匹配规则时回落到一个默认值,不至于算不出来。

这里还要提一句纸张规格的换算。纸张采购按吨计价,但报价必须按张。大度纸每张面积 889×1194mm ≈ 1.061 平方米,正度纸 787×1092mm ≈ 0.859 平方米。每令 500 张,令价和吨价的关系在下一章的换算方法里会详细写,核心就是体积重量那一套:单张重量 = 克重 × 面积 ÷ 1000,单位是千克。

拼版这块还有个小坑:成品尺寸不是正好整除纸幅的,剩下的边角料通常报废。有些系统为了显示“省纸”会允许混合拼版,但实际生产排不了那么灵活,所以开数表里的值建议取保守整数值,别为了几个百分点把生产搞乱。

3.2 工艺累加与阶梯价:覆膜、烫金、模切怎么叠上去

印张和纸张费算完,只是毛坯。真正拉开报价差距的是后道工艺:覆膜、烫金、UV、压纹、模切、裱糊、糊盒、骑马钉、胶装、锁线。这些工序的计价方式各不相同,有的按面积算,有的按张数算,有的按“开机费 + 单价”算,有的按印量走阶梯。

统一抽象成一条规则接口是关键:

public interface ProcessRule { /** 该规则支持哪种工艺编码 */ String supportCode(); /** * 计算某项工艺的费用 * @param ctx 计价上下文,含张数、面积、色数、印量 * @param cfg 该工艺的单价配置 */ BigDecimal calc(QuoteContext ctx, ProcessConfig cfg); }

覆膜属于按面积计价的典型。它的单价一般是“每平方米多少钱”,起步价另算。因为覆膜机有最小走纸尺寸,500 份的数量开一次机不划算,所以规则里要判断max(起步价, 面积 × 单价)。

public class LaminationRule implements ProcessRule { @Override public String supportCode() { return "LAMINATION"; } @Override public BigDecimal calc(QuoteContext ctx, ProcessConfig cfg) { // 覆膜面积 = 单张成品面积 × 张数 × 面数(单面/双面) BigDecimal area = ctx.getSheetArea().multiply(new BigDecimal(ctx.getSheets())); BigDecimal byArea = area.multiply(cfg.getUnitPrice()); // 起步价兜底:小批量时按开机费收 BigDecimal startFee = cfg.getStartFee() == null ? BigDecimal.ZERO : cfg.getStartFee(); return byArea.max(startFee).setScale(2, RoundingMode.HALF_UP); } }

烫金就完全不一样了,它按“烫印面积 + 版费”算,而且要区分普通烫和立体烫,版费按烫金版块数收。模切按“模切版费 + 每千张模切费”算,模切版费又跟刀模复杂度和尺寸挂钩。装订里骑马钉按“每千份”算,胶装还要按书脊厚度分档,薄的胶装和厚的胶装单价能差一倍。

阶梯价是另一个高频需求。印量越大,千印单价越低,业务上叫“放价”。这个不能写成 if-else,得用区间表:

public BigDecimal calcByLadder(int qty, List<LadderPrice> ladders) { // ladders 已按 minQty 升序排列,如 [0,1.2], [3000,1.0], [10000,0.85] BigDecimal unit = ladders.get(0).getUnitPrice(); for (LadderPrice lp : ladders) { if (qty >= lp.getMinQty()) { unit = lp.getUnitPrice(); } else { break; } } return unit.multiply(BigDecimal.valueOf(qty)).setScale(2, RoundingMode.HALF_UP); }

注意这里我特意写成“命中即覆盖”而不是“分段累进”。印刷行业的阶梯价绝大多数是整单套用当前档位单价,不是分段计价,这点和个税完全不同。早期我按分段累进写过一版,业务对账时直接说数字全错,返工了两天。

工艺还有个顺序问题:有些工艺会互相影响价格,比如先覆膜再烫金,烫金的难度和废品率上升,报价里通常要加系数。我在QuoteContext里放了一个appliedProcesses集合,规则实现里可以根据已应用的工艺调整单价系数,避免写死组合表。

3.3 报价单落库与版本快照

算出来的结果要落库,但落库不能只存一个总价。业务后续要按明细对账、要拆分成生产工单、要统计各工序的成本占比,所以明细行必须完整保存,并且带上当时的单价依据。

CREATE TABLE quote_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, quote_no VARCHAR(32) NOT NULL COMMENT '报价单号', customer_id BIGINT NOT NULL, price_version VARCHAR(20) NOT NULL COMMENT '计价时的价格表版本', total_amount DECIMAL(14,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0草稿 1已报 2已成交 3作废', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_quote_no (quote_no) ); CREATE TABLE quote_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, item_type VARCHAR(20) NOT NULL COMMENT 'PAPER/PRINT/PROCESS/BIND', item_name VARCHAR(64) NOT NULL, qty DECIMAL(14,3) NOT NULL, unit_price DECIMAL(14,4) NOT NULL, amount DECIMAL(14,2) NOT NULL, calc_note VARCHAR(255) COMMENT '计算说明,如: 313张*16开*令价180', UNIQUE KEY uk_order_item (order_id, id) );

calc_note这个字段是给业务看的,也是给自己留的后悔药。客户问“这个 1200 是怎么来的”,业务员能直接把说明念出来,而不需要开发去翻日志。报价单一旦提交就禁止修改明细,要改就作废重开或者升版本,这样历史数据永远可追溯。

4. 基础数据与价格体系:纸张、工艺、客户等级怎么建模

4.1 纸张价格表与吨价/令价换算

纸张是报价里占比最大的成本项,通常能到 50%~70%。纸厂报价按吨,印刷厂内部核算按令,报价单给客户按张,三种单位来回换,这是印刷报价玄学味最重的地方。

换算逻辑本身不复杂,但一定要写成工具方法并单测覆盖:

/** * 吨价换算令价 * @param tonPrice 吨价,元/吨 * @param gramWeight 克重,g/㎡ * @param widthMm 纸幅宽,mm * @param heightMm 纸幅长,mm * @param sheetsPerReam 每令张数,通常 500 */ public BigDecimal tonToReam(BigDecimal tonPrice, int gramWeight, int widthMm, int heightMm, int sheetsPerReam) { // 单张面积,单位 ㎡ BigDecimal area = BigDecimal.valueOf(widthMm) .multiply(BigDecimal.valueOf(heightMm)) .divide(BigDecimal.valueOf(1_000_000), 6, RoundingMode.HALF_UP); // 单张重量(kg) = 克重(g/㎡) × 面积(㎡) / 1000 BigDecimal perSheetKg = BigDecimal.valueOf(gramWeight) .multiply(area) .divide(BigDecimal.valueOf(1000), 8, RoundingMode.HALF_UP); // 每吨价格 → 每公斤价格 = tonPrice / 1000 BigDecimal perKg = tonPrice.divide(BigDecimal.valueOf(1000), 6, RoundingMode.HALF_UP); return perSheetKg.multiply(perKg) .multiply(BigDecimal.valueOf(sheetsPerReam)) .setScale(2, RoundingMode.HALF_UP); }

拿 157 克大度铜版纸、吨价 6500 元举例:面积 1.061 ㎡,单张重 0.1666 kg,每公斤 6.5 元,单张约 1.083 元,一令 500 张约 541.5 元。这个数字业务一眼就能判断对不对,所以换算方法必须准到分。

纸价表设计上,我建议按“纸张品类 + 克重 + 规格 + 生效日期”做维度,而不是一张纸一条记录。同一款铜版纸有 105/128/157/200/250 克,如果一条条录,维护量爆炸且容易漏。用“品类 + 克重”作为基础行,规格作为变体,前端选品类和克重后自动带出规格下拉。

CREATE TABLE paper_price ( id BIGINT PRIMARY KEY AUTO_INCREMENT, category VARCHAR(32) NOT NULL COMMENT '铜版/双胶/白卡/牛皮', gram_weight INT NOT NULL COMMENT '克重 g/㎡', spec_code VARCHAR(32) NOT NULL COMMENT 'DADU/ZHENGDU/自定义', width_mm INT NOT NULL, height_mm INT NOT NULL, ton_price DECIMAL(12,2) NOT NULL COMMENT '吨价', effect_date DATE NOT NULL COMMENT '生效日期', UNIQUE KEY uk_paper (category, gram_weight, spec_code, effect_date) );

用生效日期而不是直接改价,是为了让历史报价可回溯。纸价一涨,新增一条记录,旧记录保留,计价时按报价日期取最近一条生效记录。这个设计在纸价波动大的年份能救命。

4.2 工艺单价与阶梯规则表设计

工艺这块的复杂度在于“同一工艺在不同条件下的计价方式不同”。覆膜按面积,烫金按面积加版费,模切按张数加版费,UV 按面积,压纹按张数。硬编码成一堆 if 很快就会失控。

我的做法是两层:一层process_config存工艺的基础参数(计价方式、单价、起步价、版费),一层process_ladder存阶梯单价。规则类只认计价方式枚举,具体数值从配置里取。

CREATE TABLE process_config ( id BIGINT PRIMARY KEY AUTO_INCREMENT, code VARCHAR(32) NOT NULL COMMENT 'LAMINATION/HOT_STAMP/DIE_CUT/UV', name VARCHAR(64) NOT NULL, price_mode VARCHAR(16) NOT NULL COMMENT 'AREA/SHEET/QTY/PIECE', unit_price DECIMAL(12,4) NOT NULL, start_fee DECIMAL(12,2) DEFAULT 0 COMMENT '起步价/开机费', plate_fee DECIMAL(12,2) DEFAULT 0 COMMENT '版费,按块计', UNIQUE KEY uk_code (code) ); CREATE TABLE process_ladder ( id BIGINT PRIMARY KEY AUTO_INCREMENT, process_code VARCHAR(32) NOT NULL, min_qty INT NOT NULL, unit_price DECIMAL(12,4) NOT NULL, UNIQUE KEY uk_ladder (process_code, min_qty) );

price_mode是分派依据,枚举值直接对应规则实现。新增一种计价方式时,加一个枚举值加一个ProcessRule实现类,配置表不用动。这样业务提“我们新上了一台数码UV,按平方米算但起步价 300”,只需要在后台加一条配置。

有个细节要提醒:版费是按“块”算的,烫金一个版块、模切一把刀模,都可能不止一块。所以plate_fee存的是单块价格,实际计算时乘上版块数,版块数由业务在报价单上填。别把版费算进单价里,不然小批量报价会高得离谱,客户直接跑掉。

4.3 客户协议价与折扣优先级

价格体系里最容易被忽略、上线后最容易吵架的是折扣优先级。厂里通常有三套价:标准价、客户协议价、临时特价。业务员在系统里点计算,到底该用哪套?

我的处理是明确一条优先级链,写在代码注释里,也写在后台的说明文案里:

  1. 报价单上手工填的临时价(最高优先,需审批)
  2. 客户协议价(按客户 + 品类匹配,有生效期)
  3. 客户等级折扣(A/B/C 级对应不同折扣率)
  4. 标准价
public BigDecimal resolvePrice(Long customerId, String category, BigDecimal manualPrice) { // 1. 手工特价优先,但必须有审批标记 if (manualPrice != null) { return manualPrice; } // 2. 客户协议价,按品类匹配且未过期 AgreementPrice ap = agreementMapper.findValid(customerId, category, LocalDate.now()); if (ap != null) { return ap.getPrice(); } // 3. 客户等级折扣,作用于标准价 Customer c = customerMapper.selectById(customerId); BigDecimal std = standardPriceMapper.find(category); return std.multiply(c.getDiscountRate()).setScale(2, RoundingMode.HALF_UP); }

这里有个血泪教训:协议价一定要带生效期和失效期,且失效后自动回落。早期没做失效控制,一个客户的年度协议到期半年了,系统还在按老价格报,几十单做下来全是亏的,最后是靠财务对账才发现的。现在我会在协议价表上加expire_date,并且每天早上跑一个定时任务,把即将到期的协议价推送给业务提醒续签。

客户等级折扣也别设太多档,A/B/C 三档足够,档位一多,业务自己都记不住该给客户定哪档,最后全靠手工改价,价格体系形同虚设。

5. 常见问题排查:报价结果和老师傅手算差几毛钱,问题出在哪

上线第一个月,几乎每天都有业务拿着报价单来找:“系统算的和我手算差了 3 毛 7。”这类问题看着玄学,其实九成集中在下面几个点上。我把它们整理成排查清单,新同事接手项目先看这一页。

5.1 损耗放数对不上

现象:同一笔单子,系统算出的用纸张数比老师傅少 5 张,总价差几块钱。

原因:放数规则没匹配上。系统里放数按“印量区间 + 色数”配,老师傅那套经验值是按“印量 + 色数 + 是否有后道”三维来的,遇到要覆膜的单子,他会额外再加 1%。系统查不到匹配规则时回落到了默认 2%,少了这 1%。

解决:把放数规则的匹配维度补齐,并在calc_note里打印实际命中的是哪条规则。现在报价单明细里会写“放数 3%(规则:长版四色带后道)”,业务一看就知道有没有走对。另外加一个兜底日志:规则未命中时打 WARN,运营每天扫一眼日志就知道有没有配置缺口。

5.2 金额精度与舍入

现象:明细行加起来和总价差 1 分钱,财务对账对不上。

原因:明细行各自setScale(2)之后再加总,和先把原始值加总再舍入,结果会不一样。项目早期一部分代码先舍入再加,一部分先加再舍入,混着来必然对不上。

解决:统一口径——明细行按原始精度计算,只在展示和落库时舍入一次总价,明细行的amount字段存舍入后的值用于展示,但总价用未舍入的BigDecimal之和再舍入。同时把double全部清掉,Money类里禁止出现doubleValue()。这条改完之后,对账问题基本消失。

5.3 拼版开数取整方向

现象:某个尺寸的包装盒,系统算出的开数是 7,实际生产只能拼 6 个,报价报低了。

原因:开数用面积相除取整算的,忽略了咬口和留边。889×1194 的纸除以 300×300 的成品,面积上能出 11 个,实际因为咬口和刀口留边,横竖都排不下那么多。

解决:开数不再用面积算,改成按“纸幅尺寸 ÷(成品尺寸 + 留边)”分别算横竖两个方向能排几个,相乘取整,再减去咬口占用的位置。这套逻辑封装成SheetLayoutCalculator,前端在选成品尺寸时实时显示可拼开数,业务能直接看到“这尺寸只能拼 6 个”,不会再出现报价按 7 个算的情况。

5.4 价格表缓存导致改价不生效

现象:财务在后台把铜版纸吨价从 6500 改成 6800,业务刷新页面报价,还是按老价格算。

原因:价格表放 Redis 缓存了,改价后没清缓存,key 也没带版本号。并发场景下更麻烦,两个业务同时报价,一个拿到新价一个拿到旧价,同一单报出两个结果。

解决:价格表引入版本号,每次改价生成新版本并写入 Redis(key 形如price:paper:v20240612),报价时请求里带版本号,不带的取最新版本。旧版本缓存设 TTL 保留 24 小时,给正在填单的业务留出窗口,避免他们填到一半价格变了。这个方案上线后再没出现过改价不生效的投诉。

5.5 报价单导出排版错乱

现象:导出的 PDF 报价单,中文字体变成方块,长客户名把表格撑破。

原因:iText 默认字体不含中文字形,必须显式注册中文字体文件;表格列宽用了固定值,客户名超过 12 个字就溢出。

解决:在导出服务启动时加载一份中文字体(放 resources 下,随 jar 打包),表格列宽改成百分比 + 最小宽度组合,超长文本自动换行并限制两行。导出这块建议单独写一个模板类,别在 Controller 里拼 PDF,后面客户要加公司 logo、加二维码,改起来才不至于牵一发动全身。

注意:这五条里,1、2、3 条是计算问题,4、5 条是工程问题。排查时先确认是不是计算问题,用一条已知答案的历史单据跑一遍,能快速定位是规则配错了还是代码写错了。

6. 二次开发:把报价规则从代码里挪出去

这套系统真正需要改的地方,八成不是页面,是计价规则。厂里换了台模切机、纸厂换了个供应商、客户要求按新方式算装订费,每一次都来找开发改代码、发版、重启,节奏完全跟不上业务。我在第二个版本做了一件事:把规则的可变部分尽可能挪到配置里,代码只保留“怎么算”的骨架。

具体做法是给process_config加一个formula字段,存一段受限的表达式,比如覆膜的max(area * unit_price, start_fee),模切的plate_count * plate_fee + sheets / 1000 * unit_price。表达式引擎选轻量的(Aviator 或 SpEL 都行),变量名固定为规格里的那几个:area、sheets、qty、unit_price、start_fee、plate_fee、plate_count。规则类先查配置里有没有表达式,有就执行表达式,没有就走默认实现。

改造时要注意两件事。一是表达式必须做白名单校验,只允许四则运算、max、min、ceil、round这几个函数,禁止方法调用和属性访问,不然就是个安全黑洞。二是每次保存表达式都要跑一遍历史用例回归,把最近半年成交的报价单按当时的参数重算,结果和实际成交价的偏差超过 1% 就拦下来,不允许保存。

回归验证这块,我一般准备三个数据集:正常单、边界单(刚好卡在阶梯档位上下)、异常单(缺工艺、缺客户协议价)。用 JUnit 参数化跑,一个@ParameterizedTest喂 JSON 文件,断言总价和预期值的差在 0.01 以内。这套用例现在已经攒到四百多条,每次改引擎跑一遍,二十秒出结果,比上线后等业务投诉强太多。

改造点改配置改代码建议
工艺单价、起步价、版费是否后台维护,走版本
阶梯价档位是否后台维护
计价公式结构变化是否写表达式,必须跑回归
新增一种计价方式否是加枚举 + 规则实现类
放数规则维度是否配表,未命中打 WARN
导出模板版式部分是单独模板类,别散在 Controller

最后说个习惯。从那以后我每次动计价引擎,哪怕只是改一行小数位数,都强制先跑一遍那四百条历史用例,跑完再看 diff。有一次一个很小的改动把“按千张计价”改成了“按张计价”,单测没覆盖到骑马钉,是回归用例里一张 3000 份的样本把它拦下来的。印刷报价这东西,纸价、工艺、放数、拼版四个变量一乘,错了很难靠眼睛看出来,只能靠数据兜着。希望这套思路能帮到正在做同类系统的你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进