简介:这是一套面向中小企业、销售系统开发者与ASP学习者的在线报价系统源码,可用于快速搭建产品价格查询、报价生成与后台维护模块,解决传统人工报价效率低、价格信息分散的问题。压缩包共16个文件,约22KB,以ASP服务端脚本为主体,配合CSS样式表、HTM页面、SQL数据库脚本及INC/ASA全局配置,构成前后台完整的小型Web应用;目录中可见产品、品牌、类型与账户管理等后台入口,以及前台报价与登录页面。已有2673人学习下载,说明其作为轻量级ASP项目参考具有一定关注度。读者可据此理解报价系统的数据表设计、前后台交互与权限入口组织,并在此基础上做二次开发,如扩展报价规则、调整界面样式或接入自有数据库,适合需要低成本搭建报价模块或研究ASP小型系统结构的开发者。
1. 从一通报价电话说起:这套系统把 Excel 顶掉了
上个月一个做工业配件的朋友打电话过来,说客户临时要「三百件、含税含运、月底前发货」的价格,销售翻了三个 Excel 才凑出一份报价,还漏算了一档阶梯价,最后签合同的时候才发现少报了四千多块。这种事故在中小团队里太常见了,后来我干脆找了套多功能在线报价系统源码,在自己环境跑起来,把这条链路整个替换掉。
它把产品、阶梯价、客户等级折扣、税率、报价单导出、免登录分享串成一条完整链路:销售在浏览器里选品、填数量,单价和总价实时试算,几秒出一张带公司抬头和有效期的 PDF。适合两类人——手里有几十上百个 SKU、报价还要走审批的中小团队,以及接私活想直接拿现成脚手架改的开发者。前后端分离、规则可插拔、数据能自己掌控,这三点就是我要的。下面按「看清结构 → 拆核心代码 → 本地跑通 → 避开坑 → 动手改」的顺序讲。
2. 技术栈与目录结构:先把这份源码的骨架摸清
2.1 技术选型:前后端分离,把计价逻辑压到服务端
这套在线报价系统走的是国内最常见的一档组合:后端 Spring Boot 3.x + MyBatis-Plus + MySQL 8,Redis 用来存分享链接的 token 和价格规则的短缓存,鉴权用 JWT 加拦截器,导出用 Freemarker 渲染 HTML 再交给 openhtmltopdf 转 PDF。前端 Vue 3 + Vite + Element Plus + Pinia,请求走 Axios 统一封装。
为什么要前后端分离,而不是后端直接套 Thymeleaf 出页面?核心原因在计价。报价的价格必须由服务端算,前端只负责展示和收集输入。如果页面和计算混在一个模板里,业务人员想改一条折扣规则就要动项目结构;分离之后,前端把lines丢给/api/quotation/preview,服务端返回每行的单价、金额和计算轨迹,前端照着渲染就行。等到二期要做小程序或者对接 CRM,接口原样复用,不用重写一套。
提示:如果拿到的源码是 PHP 或者 Node 版本,思路完全一致,把下文的
PriceRule接口换成对应的策略实现即可,讲的都是规则链和快照这两件事。
技术栈里有两个地方值得提前说清。一是数据库必须 MySQL 8,因为报价明细的计算轨迹我建议直接存 JSON 字段,省掉一张quotation_item_price_step表,MySQL 5.7 虽然支持 JSON 但函数和索引能力差一截。二是 Redis 不是可选项,分享链接和报价单号的并发生成都依赖它,本地跑的时候别图省事把它注释掉。
2.2 后端目录:按业务域拆包,而不是按 controller/service/mapper 三层切
拿到源码先别急着看 controller。这种报价系统的复杂度集中在「产品价格规则」「客户等级」「报价单状态」三块,如果包结构是按controller / service / mapper平铺的,模块边界会糊成一片。我习惯的拆法是按业务域:
quote-server/ ├── src/main/java/com/example/quote/ │ ├── QuoteApplication.java │ ├── common/ # 统一返回体、全局异常、JWT 拦截器 │ │ ├── R.java │ │ ├── BizException.java │ │ └── AuthInterceptor.java │ ├── module/ │ │ ├── product/ # 产品、规格、阶梯价配置 │ │ │ ├── ProductController.java │ │ │ ├── PriceTierService.java │ │ │ └── entity/PriceTier.java │ │ ├── customer/ # 客户与折扣等级 │ │ ├── quotation/ # 报价单主流程 │ │ │ ├── QuotationController.java │ │ │ ├── QuotationService.java │ │ │ └── price/ # 计价引擎,重点在这 │ │ │ ├── PriceRule.java │ │ │ ├── PricingEngine.java │ │ │ ├── TierPriceRule.java │ │ │ ├── CustomerDiscountRule.java │ │ │ └── TaxRule.java │ │ ├── template/ # PDF 模板管理 │ │ └── share/ # 免登录分享 │ └── infra/ # 持久化配置、Redis、导出工具 └── src/main/resources/ ├── application.yml ├── db/migration/ # Flyway 初始化脚本 └── template/quote-pdf.ftl # PDF 用到的 Freemarker 模板这个结构的关键点是module/quotation/price/单独成目录。它意味着计价规则是一个可以独立演进的子系统:新增一条「按工时加价」或者「满额返点」,只需要在这个目录下加一个实现类,主流程QuotationService一行都不用改。反过来,如果你看到的源码把折扣和税费直接写死在QuotationService.save()的一个if里,二次开发的成本会翻几倍,改之前先做好重构成规则链的准备。
infra/里放的是不承载业务语义的东西:MyBatis-Plus 分页插件配置、RedisTemplate 序列化方式、PDF 渲染器初始化。判断一个后端项目的分包质量,看infra这一层最直接——如果里面混进了QuotationMapper的使用逻辑,说明分层没守住。
2.3 前端目录与报价单编辑页的组件边界
前端最容易写崩的是报价单编辑页:一张单子可能几十行明细,每行都要能改数量、切规格、看阶梯价提示,还要实时汇总。组件边界划不好,改一个数量触发全表重渲染,输入框直接失去焦点,这种血泪经验做过表格的人都懂。
quote-web/src/ ├── api/ │ ├── quotation.js # 试算、保存、导出、分享 │ ├── product.js │ └── customer.js ├── views/ │ ├── quotation/ │ │ ├── List.vue # 报价单列表 + 状态筛选 │ │ ├── Editor.vue # 编辑页,只管编排 │ │ └── Detail.vue # 详情 + 审批 │ └── product/ ├── components/ │ ├── QuoteLineTable.vue # 明细表格,行级组件 │ ├── PriceTrace.vue # 展示每步价格变化 │ └── ShareDialog.vue ├── stores/quotation.js # Pinia,编辑器状态 └── router/index.jsEditor.vue不直接算价,它只做三件事:收集表单、调previewQuote拿结果、把结果交给子组件渲染。QuoteLineTable.vue的每一行都带独立 key,数量输入框用防抖触发试算,避免用户敲一位数字就发一次请求。PriceTrace.vue是个容易被忽略但很值钱的组件,它把服务端返回的steps数组画成「原价 1200 → 阶梯价 1100 → 九五折 1045 → 含税 1180.85」这样一行,销售拿去跟客户解释价格时非常有用,二次开发别把它删了。
2.4 配置文件里必须改的几项
application.yml里和默认值有关的东西基本都指向本地开发环境,跑之前逐项确认:
server: port: 8080 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/quote_system?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: change_me data: redis: host: 127.0.0.1 port: 6379 database: 0 quote: pdf: font-path: /usr/share/fonts/quote/SourceHanSans.ttf # 中文字体,必须绝对路径 share: expire-days: 7 # 分享链接默认有效期 pricing: amount-scale: 2 # 金额保留位数 price-scale: 4 # 单价中间精度三个参数值得单独说。font-path是 PDF 导出的命门,openhtmltopdf 不带中文字体,路径不对就是一堆方块,后面避坑章节会再提。share.expire-days控制免登录链接的存活时间,默认 7 天,对大多数报价场景够用,合同类报价建议收到 3 天。pricing.price-scale是单价的中间精度,用 4 位而不是 2 位,是为了让阶梯价乘折扣之后不提前丢精度,行金额最后才舍入到 2 位——这一步做错了,整张单子和客户算的差一分钱,扯皮能扯一下午。
3. 报价核心链路拆解:一条明细从选品到落库要过几道手
3.1 计价引擎:把所有规则排成一条责任链
整个项目的技术含量,八成在PricingEngine这几十行里。它做的事很朴素——注入所有PriceRule实现,按order排序,逐条作用在当前价格上,同时把每一步的前后值记录下来。但正是这个设计,让「加规则」「调顺序」「事后解释价格」三件事都变得便宜。
// module/quotation/price/PriceRule.java public interface PriceRule { /** 执行顺序,数字小的先算 */ int order(); /** 规则标识,会写进快照,事后对账靠它 */ String type(); /** current 是上一条规则算完的价格,返回本规则处理后的价格 */ BigDecimal apply(BigDecimal current, QuoteLineInput input, PricingContext ctx); }// module/quotation/price/PricingEngine.java @Service public class PricingEngine { private final List<PriceRule> rules; public PricingEngine(List<PriceRule> rules) { // Spring 注入全部实现,这里统一按 order 排序,避免顺序靠配置 this.rules = rules.stream() .sorted(Comparator.comparingInt(PriceRule::order)) .collect(Collectors.toList()); } public QuoteLineResult calculate(QuoteLineInput input, PricingContext ctx) { BigDecimal price = ctx.getListPrice(); // 产品标价,链路的起点 BigDecimal qty = input.getQty(); List<PriceStep> steps = new ArrayList<>(); for (PriceRule rule : rules) { BigDecimal before = price; price = rule.apply(price, input, ctx); if (price.compareTo(before) != 0) { // 只记录真正改变了价格的规则,快照体积能小一半 steps.add(new PriceStep(rule.type(), before, price)); } } // 单价保留 4 位,行金额才舍入到 2 位,避免多次舍入累积误差 BigDecimal unitPrice = price.setScale(4, RoundingMode.HALF_UP); BigDecimal amount = unitPrice.multiply(qty) .setScale(ctx.getAmountScale(), RoundingMode.HALF_UP); return new QuoteLineResult(unitPrice, amount, steps); } }calculate的入参有两个:input是这一行的用户输入,包含数量、规格、可选工时;ctx是这一行依赖的外部上下文,包含标价、阶梯价列表、客户折扣率、税率、金额精度。把上下文单独抽出来,好处是PricingEngine本身不碰数据库,单元测试里手搓一个PricingContext就能跑,不用起 Spring 容器。
steps这个返回值别小看。它记录的是「哪条规则把价格从多少改成了多少」,序列化之后存进明细表的 JSON 字段。客户三个月后打电话问「当时为什么给我报这个价」,销售点开报价单详情就能看到每一步的依据,不至于翻聊天记录。
3.2 三条默认规则:阶梯价、客户折扣、税
源码里默认给的三条规则,正好覆盖了大部分报价场景,也正好演示了order怎么用。
@Component public class TierPriceRule implements PriceRule { @Override public int order() { return 10; } // 先定档,再打折 @Override public String type() { return "TIER"; } @Override public BigDecimal apply(BigDecimal current, QuoteLineInput input, PricingContext ctx) { if (CollectionUtils.isEmpty(ctx.getTiers())) { return current; // 没配阶梯价就沿用标价 } BigDecimal qty = input.getQty(); return ctx.getTiers().stream() .filter(t -> qty.compareTo(t.getMinQty()) >= 0 && qty.compareTo(t.getMaxQty()) <= 0) .findFirst() .map(PriceTier::getUnitPrice) .orElseThrow(() -> new BizException("数量 " + qty + " 未匹配到价格区间")); } }阶梯价必须排在第一条。原因很直接:折扣和税都是基于「这个数量对应的单价」做乘法,如果先打九五折再去套阶梯价,阶梯表里的价格就形同虚设。PriceTier的区间用[minQty, maxQty]闭区间表示,最后一档的maxQty通常填一个极大值比如999999,否则大数量订单会直接抛「未匹配到价格区间」。这个异常我建议保留,宁可报错也不要静默回退到标价——静默回退意味着少收钱,而且没人会发现。
@Component public class CustomerDiscountRule implements PriceRule { @Override public int order() { return 20; } // 阶梯价之后 @Override public String type() { return "CUSTOMER_DISCOUNT"; } @Override public BigDecimal apply(BigDecimal current, QuoteLineInput input, PricingContext ctx) { BigDecimal rate = ctx.getCustomerDiscount(); // 0.95 表示九五折 if (rate == null || rate.compareTo(BigDecimal.ONE) >= 0) { return current; // 没折扣等级就原价 } return current.multiply(rate).setScale(4, RoundingMode.HALF_UP); } } @Component public class TaxRule implements PriceRule { @Override public int order() { return 90; } // 放最后,作用于已折扣价格 @Override public String type() { return "TAX"; } @Override public BigDecimal apply(BigDecimal current, QuoteLineInput input, PricingContext ctx) { if (!Boolean.TRUE.equals(ctx.getTaxIncluded())) { return current; // 不含税报价,税额单列展示 } return current.multiply(BigDecimal.ONE.add(ctx.getTaxRate())) .setScale(4, RoundingMode.HALF_UP); } }CustomerDiscountRule里的判空很关键。客户表里折扣等级是null的情况太常见了——新建客户还没分配等级,或者等级对应的折扣率没填。如果不判空直接multiply(null),一个新建客户的试算就抛 NPE,前端只会看到「系统异常」,排查半天。
TaxRule的order给到 90,意味着税率作用在折扣后的价格上,这符合「先折扣后计税」的常规做法。如果你们公司是「价税合计再打折」的口径,把order改成 95 或者干脆调整两条规则的顺序就行,不用动别的代码——这就是规则链的价值。
3.3 快照落库:商品改了价,历史报价单不能跟着变
这是整个系统里最容易被写错的一环。报价单明细表如果只存product_id,前端渲染时再去关联产品表取名称和价格,那么产品一旦调价,三个月前的报价单打开就变成新价格,客户截图对比直接炸锅。
正确的做法是下单即快照:名称、规格、单价、金额、计算轨迹全部在保存那一刻固化。
public Long save(QuotationSaveCmd cmd) { Quotation q = new Quotation(); q.setQuoteNo(quoteNoGenerator.next()); // 单号生成见避坑章节 q.setCustomerId(cmd.getCustomerId()); q.setStatus(QuotationStatus.DRAFT); q.setVersion(1); q.setCurrency(cmd.getCurrency()); BigDecimal total = BigDecimal.ZERO; for (QuoteLineResult r : cmd.getLines()) { QuotationItem item = new QuotationItem(); item.setProductId(r.getProductId()); // 只作溯源用,渲染不依赖它 item.setProductName(r.getProductName()); // 名称快照 item.setSpec(r.getSpec()); // 规格快照 item.setQty(r.getQty()); item.setUnitPrice(r.getUnitPrice()); // 已算好的单价 item.setAmount(r.getAmount()); // 每一步规则的前后价格写进 JSON,事后能解释"为什么是这个价" item.setPriceDetail(JSON.toJSONString(r.getSteps())); itemMapper.insert(item); total = total.add(r.getAmount()); } q.setTotalAmount(total.setScale(2, RoundingMode.HALF_UP)); quotationMapper.insert(q); return q.getId(); }productId保留但降级为溯源字段,前端详情页渲染完全用快照字段,不 join 产品表。这样做的代价是存储冗余——同样的商品名在每张单子存一份,但换来的是历史数据的绝对稳定,值。
priceDetail存 JSON 而不是拆一张步骤表,是权衡之后的结果:步骤数据的查询需求几乎为零,永远是「按报价单 ID 取出整条明细的所有步骤」,JSON 一把读出来反而更快,也省掉一次 join。
还有一个容易漏的点:Quotation上要不要存一份snapshot_json存整单快照?我的建议是存。报价单一旦进入「已发送」状态,把整单(含客户信息、抬头、条款)序列化一份塞进主表。这样做分享页面和 PDF 导出都可以只读这一个字段,不依赖任何业务表,性能和数据一致性都稳。
3.4 导出与分享:PDF 模板和免登录链接
导出 PDF 走的是「Freemarker 渲染 HTML → openhtmltopdf 转字节流」这条路,比 iText 手写表格坐标简单太多,改版式只要改.ftl模板。
@GetMapping("/{id}/export") public void exportPdf(@PathVariable Long id, HttpServletResponse response) { Map<String, Object> model = quotationService.buildPdfModel(id); response.setContentType("application/pdf"); response.setHeader("Content-Disposition", "attachment; filename=quote-" + id + ".pdf"); try (OutputStream os = response.getOutputStream()) { String html = templateEngine.process("quote-pdf", new Context(Locale.CHINA, model)); PdfRendererBuilder builder = new PdfRendererBuilder(); builder.withHtmlContent(html, "file:src/main/resources/template/"); builder.toStream(os); builder.run(); } catch (Exception e) { throw new BizException("导出失败:" + e.getMessage()); } }buildPdfModel负责把快照字段摊平成模板变量,baseUri那个file:前缀是给模板里的 CSS 和 logo 图片用的相对路径解析基础,写错了样式全丢。响应直接写OutputStream而不是先落盘再读,省一次 IO,也避免并发导出时文件名冲突。
分享链接的逻辑更短,但安全性全在这几行:
@Service public class ShareService { private final StringRedisTemplate redis; public String create(Long quotationId, int expireDays) { // 32 位随机串,不用自增 ID 或时间戳,防遍历 String token = UUID.randomUUID().toString().replace("-", ""); // 只存报价单 ID,访问时再鉴权读快照,避免两处数据不一致 redis.opsForValue().set("share:q:" + token, String.valueOf(quotationId), expireDays, TimeUnit.DAYS); return token; } }token用 UUID 去掉横线,长度够、碰撞概率可忽略。值只存报价单 ID,不存整份快照——因为快照会被审批流程更新(比如改了付款条款),存两份必然不一致。过期时间交给 Redis 的 TTL,不用自己写定时任务清理,这是最省事的做法。前端拿到的链接形如/s/{token},访问时后端先查 Redis 拿到 ID,再走一次只读的详情接口,顺带做一次访问日志,销售能知道客户看没看。
4. 本地跑通:从空库到点出第一张带抬头的报价单
4.1 环境与依赖,版本对不上会卡很久
跑之前把环境对齐,这套源码对版本的容忍度比想象中低:
| 依赖 | 建议版本 | 说明 |
|---|---|---|
| JDK | 17 | Spring Boot 3.x 最低要求,8 和 11 直接起不来 |
| Maven | 3.8+ | 用来拉依赖和打包 |
| MySQL | 8.0 | JSON 字段和窗口函数都用到 |
| Redis | 6.x+ | 分享 token、单号生成 |
| Node.js | 18+ | Vite 5 对 Node 版本有硬要求 |
| 中文字体 | 任意思源黑体 | PDF 导出必需,缺了就是方块 |
JDK 17 这条是硬门槛。很多人本地装的是 JDK 8,mvn spring-boot:run一跑就是Unsupported class file major version,报错信息还不在第一屏,容易误以为是依赖问题。先java -version确认,不对就换JAVA_HOME再跑。
# 确认版本,三个都要看 java -version mvn -v node -v # 后端依赖预下载,第一次会比较久 cd quote-server mvn -DskipTests dependency:go-offlinedependency:go-offline把依赖先拉到本地仓库,后面启动就不用边跑边下。如果公司网络对 Maven 中央仓库不友好,记得先配好settings.xml的镜像,不然卡在Downloading上一动不动。
4.2 数据库初始化:Flyway 自动建表加手工补数据
源码的resources/db/migration/下一般会有V1__init.sql和V2__seed.sql两个脚本,启动时 Flyway 自动执行。先建库:
CREATE DATABASE `quote_system` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;字符集必须是utf8mb4,报价单里有客户公司名带生僻字或者特殊符号,utf8存进去就是乱码或者直接报错。建完库不用手工跑建表语句,启动应用时 Flyway 会按版本号顺序执行。
V2__seed.sql里通常塞了演示数据:三个产品、两档阶梯价、两个客户等级。这里有个细节要注意——种子数据的product_id和customer_id是写死的,如果你后面手工清过表又重新插数据,自增 ID 可能对不上,试算时提示「客户不存在」。遇到这种问题,直接把库 drop 掉重建一次,比一行行对 ID 快。
-- 确认种子数据进没进去,这三张表都应该有记录 SELECT COUNT(*) FROM product; SELECT COUNT(*) FROM price_tier; SELECT COUNT(*) FROM customer_level;如果price_tier是空的,试算时TierPriceRule会直接返回标价,你会以为阶梯价功能没生效,其实是数据没初始化。这是新手最容易误判的一条。
4.3 后端启动与接口自测
cd quote-server mvn spring-boot:run -Dspring-boot.run.profiles=dev看到Started QuoteApplication in x.xxx seconds就算起来了。别急着开前端,先用 curl 把试算接口打通,能快速定位问题出在哪一层。
curl -X POST http://127.0.0.1:8080/api/quotation/preview \ -H "Content-Type: application/json" \ -H "Authorization: Bearer <登录拿到的token>" \ -d '{ "customerId": 1, "lines": [ { "productId": 1, "qty": 300 } ] }'返回体里重点看两个字段:unitPrice和steps。如果unitPrice等于产品标价,说明阶梯价没命中,回头查数量区间;如果steps是空数组,说明所有规则都没改变价格,大概率是客户折扣率没配。返回 401 就是 token 没带或者过期了,登录接口在/api/auth/login,默认账号密码看种子脚本。
# 保存成草稿,拿到报价单 ID curl -X POST http://127.0.0.1:8080/api/quotation \ -H "Content-Type: application/json" \ -H "Authorization: Bearer <token>" \ -d @quote-draft.json我一般会准备一个quote-draft.json放在项目根目录,把常用测试数据写进去,改参数只动这个文件,不用每次拼一长串命令。
4.4 前端启动与联调
cd quote-web npm install npm run devVite 默认起在 5173 端口,vite.config.ts里应该配了/api的代理指向http://127.0.0.1:8080。如果登录之后所有接口都 404,八成是代理没生效,检查server.proxy那一段的rewrite有没有把/api前缀去掉——后端接口路径带不带/api,两边必须一致。
联调阶段建议按这个顺序点一遍:新建客户 → 配一档阶梯价 → 进报价单编辑页选品填数量 → 看单价是否随数量跳档 → 保存 → 导出 PDF → 生成分享链接用无痕窗口打开。这条链路走通,说明前后端、计价、快照、导出、分享五块都是好的。任何一步卡住,对照下一章的排查清单,基本都在那五条里。
5. 避坑排查:五条跑不通、算不对的真实现象
5.1 金额总是差一分:double 和 BigDecimal 混用
现象:前端显示的合计和 PDF 上的合计对不上,差 0.01,偶尔差 0.02。
原因:某段代码把金额转成了double做中间运算。0.1 + 0.2在二进制浮点里是0.30000000000000004,累加几十行明细之后误差被放大,最后setScale一舍入就偏了。这类问题在明细行数少的时候测不出来,一到真实订单就现形。
解决:全链路禁止double。数据库用DECIMAL(18,2),Java 用BigDecimal,前端展示用字符串而不是Number。前端如果必须做汇总,也调服务端接口算,别在 JS 里reduce。我在PricingEngine里统一了price-scale=4、amount-scale=2,就是为了把舍入次数压到最少。
5.2 历史报价单集体变价:快照字段没存全
现象:改了一次产品标价,以前发出去的报价单打开全变成新价格,客户拿着旧 PDF 来对,数字对不上。
原因:明细表只存了product_id,详情接口渲染时 join 产品表取当前价格。这是设计层面的问题,不是 bug。
解决:按 3.3 的做法补齐快照字段,product_name、spec、unit_price、amount全部在保存时固化。已经上线的存量数据要写一个迁移脚本,把当前产品信息回填进历史明细。迁移完记得跑一次对账:随机抽二十张老单子,把 PDF 里的金额和库里total_amount对一遍。
5.3 报价单号重复:并发下的生成器
现象:两个销售同时保存,数据库报Duplicate entry for key 'uk_quote_no',或者更隐蔽——单号重复但唯一索引没建,两份不同的报价单撞了号。
原因:单号生成用了「当天日期 + 查表 count + 1」。两次请求同时查到 count 是 5,都生成006。这是典型的并发写读竞争。
解决:改用 Redis 的INCR,key 带日期,天然原子:
public String next() { String day = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); String key = "quote:no:" + day; Long seq = redis.opsForValue().increment(key); // 第一次生成时设过期,避免 key 永久堆积 if (seq != null && seq == 1L) { redis.expire(key, Duration.ofDays(2)); } return "Q" + day + String.format("%04d", seq); }同时把uk_quote_no唯一索引加上,让数据库兜底。Redis 挂了怎么办?退化成「时间戳 + 随机四位」,冲突概率极低,唯一索引还能拦住,前端提示重试一次即可。
5.4 导出 PDF 中文变方块、长表格被截断
现象:PDF 里客户名和产品名全是方框,或者明细超过 15 行之后第二页内容重叠、表头丢失。
原因:两个独立问题。方块是中文字体没加载,PdfRendererBuilder没调useFont;截断是 CSS 里没写分页规则,openhtmltopdf 对page-break的支持有自己的脾气。
解决:字体用绝对路径注册,别用 classpath 相对路径:
builder.useFont(new File("/usr/share/fonts/quote/SourceHanSans.ttf"), "SourceHan");模板 CSS 里给表头加thead { display: table-header-group; },让表头自动重复;每一行加tr { page-break-inside: avoid; },防止一行明细被劈成两半。这两个属性写上去,长报价单的分页基本就正常了。
5.5 分享链接能被猜出来、也撤不掉
现象:客户把链接转发给了第三方,销售想收回发现没有入口;或者链接是连续数字,被人从 100 遍历到 200。
原因:token 用了自增 ID 或者时间戳,且没有失效机制。
解决:按 3.4 用 UUID,Redis 存 TTL,再给分享记录加一张表存token / quotation_id / created_by / revoked。撤销就是把 Redis 的 key 删掉,同时把revoked置 1,访问接口先查 Redis 再查表,双保险。顺带在分享页加一个访问次数上限,比如超过 50 次自动失效,防止链接被大规模传播。
6. 二次开发:把计价规则换成自家公司的算法
真正落地的时候,默认那三条规则几乎一定要改。改法不在于动QuotationService,而是加一个PriceRule实现。举个最常见的需求:某公司报价除了产品本身,还要按安装工时加钱,工时单价随客户等级浮动。
@Component public class LaborHourRule implements PriceRule { @Override public int order() { return 15; } // 阶梯价之后、折扣之前 @Override public String type() { return "LABOR_HOUR"; } @Override public BigDecimal apply(BigDecimal current, QuoteLineInput input, PricingContext ctx) { BigDecimal hours = input.getLaborHours(); if (hours == null || hours.compareTo(BigDecimal.ZERO) <= 0) { return current; // 没填工时就不加价 } BigDecimal rate = ctx.getLaborRate(); // 元/小时,随客户等级不同 return current.add(hours.multiply(rate)) .setScale(4, RoundingMode.HALF_UP); } }order给 15 是有讲究的:工时费应该跟着产品单价一起享受客户折扣,所以要排在TierPriceRule(10)之后、CustomerDiscountRule(20)之前。如果给 25,工时费就被打折了两次,财务月底对账一定找你。加完这个类,Spring 自动注入,PricingEngine的构造函数会把它排进链里,主流程零改动。
改完必须验证,别靠肉眼点页面。我的做法是写一个参数化单测,把过去半年真实成交的三十张报价单做成测试用例,输入和期望输出都来自历史数据:
@ParameterizedTest @CsvSource({ "1, 300, 0, 1045.00", // 无工时,走阶梯价 + 九五折 "1, 300, 8, 1445.00", // 8 小时工时,工时费参与折扣 "2, 50, 2, 620.50" }) void 历史报价单回归(Long productId, int qty, int hours, String expect) { QuoteLineInput input = new QuoteLineInput(productId, qty, hours); QuoteLineResult r = engine.calculate(input, ctxOf(productId)); assertEquals(new BigDecimal(expect), r.getUnitPrice()); }这套回归跑一遍只要几秒,但能挡住九成以上的规则改错。新增规则或者调整order之后先跑它,全绿再去点页面,效率完全不一样。
再往上扩展,还有两个方向值得做。一是把规则参数搬进数据库配置表,让业务人员在后台改折扣率和工时单价,不用发版;二是给报价单加审批流,状态从「草稿」到「待审」到「已发送」,用状态机控制,避免销售直接发出超低价单子。前者要注意加缓存并设置合理的过期时间,后者要注意版本号——已发送的单子被修改时,不要覆盖原记录,而是version + 1生成新版本,老版本留档。
| 改造点 | 改动位置 | 风险 |
|---|---|---|
| 新增计价规则 | module/quotation/price/加实现类 | 低,注意 order |
| 调换规则顺序 | 改各实现的order() | 中,必须跑回归 |
| 规则参数可配 | 新增配置表 + 缓存 | 中,缓存要能主动刷 |
| 加审批状态 | 主表 status + 状态机 | 高,存量数据要迁移 |
从那以后我每次动计价规则,都强制先跑一遍历史单回归,再拿三张结构不同的单子手工导 PDF 看一眼,最后才放给业务试。这条流程没省过,也帮我躲过好几次「差一分钱」的电话。希望帮到你。
本文还有配套的精品资源,点击获取