淘车湾项目实战五_结算单明细分析与前后端实现
淘车湾项目实战(五):结算单明细分析与前后端实现
本章位置:第三阶段 Java 企业项目 + AI 助手
对应课程:淘车湾项目实战——结算单明细部分分析及前端页面手动搭建及后端代码实现
前置知识:淘车湾项目实战(一)~(四)、若依脚手架、SpringBoot、MyBatis、事务、BigDecimal、Vue3、Element Plus
后续衔接:Activiti7 审批流程、流程审核信息、待办/已办、流程图高亮
学习目标:完整实现结算单明细,掌握工单明细到结算明细的快照复制、来源追踪、金额拆分、批量插入、主从表事务、前端动态明细表、后端金额重算以及结算详情展示。
一、本章为什么非常重要
上一章已经完成:
car_settlement
也就是:
结算单主表
它解决的是:
这张结算单总共多少钱
优惠多少
应收多少
支付多少
审批状态是什么
但是还缺一个问题:
这些钱到底是怎么组成的?
例如:
更换机油 300
机滤 100
空调清洗 200
--------------------------------
合计 600
优惠 100
应收 500
这些明细:
必须保存
所以本章正式实现:
car_settlement_item
二、结算主表和明细表关系
car_settlement
1
:
N
car_settlement_item
一张结算单:
可以有多条结算明细
三、为什么不能只靠工单明细实时查询
有人会想:
结算详情
直接 JOIN car_work_order_item
不就行了吗?
不推荐。
因为:
结算
属于已经确定的财务事实
而工单明细:
理论上属于施工业务数据
即使你已经限制:
结算后不能改工单
财务快照仍然应该:
独立保存
四、最重要的快照思想
ServiceItem
当前标准价格
WorkOrderItem
施工时实际价格快照
SettlementItem
结算时最终计费快照
三层不能混淆。
五、为什么结算明细还要再复制一次
例如:
工单项目原价 300
结算时:
给客户优惠 50
最终:
收 250
工单明细:
记录施工事实
结算明细:
记录财务事实
所以它需要:
originalAmount
discountAmount
finalAmount
六、结算明细表
CREATE TABLE car_settlement_item (
settlement_item_id BIGINT NOT NULL AUTO_INCREMENT,
settlement_id BIGINT NOT NULL,
source_type VARCHAR(20) NOT NULL,
source_id BIGINT,
item_name VARCHAR(100) NOT NULL,
unit_price DECIMAL(12,2) NOT NULL DEFAULT 0.00,
quantity INT NOT NULL DEFAULT 1,
original_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,
discount_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,
final_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,
remark VARCHAR(500),
create_by VARCHAR(64),
create_time DATETIME,
update_by VARCHAR(64),
update_time DATETIME,
PRIMARY KEY (settlement_item_id),
KEY idx_settlement_item_settlement (
settlement_id
)
);
七、字段解释
settlement_item_id
结算明细主键
settlement_id
所属结算单
source_type
来源类型
source_id
来源业务 ID
item_name
结算时项目名称快照
unit_price
结算时单价
quantity
数量
original_amount
原金额
discount_amount
明细优惠
final_amount
最终金额
八、source_type 是什么
建议:
SERVICE_ITEM
PACKAGE
甚至以后可以扩展:
MATERIAL
LABOR
OTHER
九、为什么需要 source_type
结算明细可能来自:
单个服务项目
也可能来自:
服务套餐
如果只存:
source_id = 10
你不知道它是:
service_item_id = 10
还是:
package_id = 10
所以:
source_type + source_id
一起表达来源。
十、是否必须保存 source_id
不是绝对必须。
但保存有好处:
方便回溯来源
例如:
这条结算明细
来自哪个服务项目
十一、历史展示是否还要依赖 source_id
不能。
即使:
source_id
指向一个服务项目
页面历史显示仍应该优先使用:
item_name
unit_price
original_amount
final_amount
这些快照字段。
十二、如果服务项目被删除怎么办
历史结算:
仍然能显示
因为:
结算明细已经保存快照
十三、但前面我们仍建议主数据不要随便物理删除
快照:
不是鼓励乱删主数据
只是:
额外保护历史业务
十四、原金额公式
originalAmount
=
unitPrice * quantity
十五、最终金额公式
finalAmount
=
originalAmount
-
discountAmount
十六、明细优惠不能大于原金额
必须满足:
0
<=
discountAmount
<=
originalAmount
十七、总金额关系
结算主表:
totalAmount
应该等于:
SUM(settlement_item.original_amount)
十八、优惠金额关系
主表:
discountAmount
应该等于:
SUM(settlement_item.discount_amount)
十九、应收金额关系
主表:
receivableAmount
应该等于:
SUM(settlement_item.final_amount)
二十、这三个关系必须保持
主表总额
=
明细原价合计
主表优惠
=
明细优惠合计
主表应收
=
明细最终金额合计
二十一、上一章的简化方案需要升级
上一章:
主表直接保存 totalAmount
discountAmount
receivableAmount
本章加入明细后:
主表金额
应该由明细汇总得到
二十二、为什么这样更可靠
否则可能:
主表优惠 100
但明细优惠合计:
80
导致:
账不平
二十三、结算明细从哪里来
第一来源:
car_work_order_item
二十四、工单明细回顾
典型字段:
work_order_item_id
work_order_id
service_item_id
item_name
unit_price
quantity
amount
technician_user_id
status
二十五、生成结算时
每条正常工单明细:
复制成一条 settlement_item
二十六、映射关系
work_order_item.item_name
→
settlement_item.item_name
work_order_item.unit_price
→
settlement_item.unit_price
work_order_item.quantity
→
settlement_item.quantity
work_order_item.amount
→
settlement_item.original_amount
二十七、默认优惠
第一版可以:
discount_amount = 0
最终:
final_amount = original_amount
二十八、然后前端允许调整明细优惠
这是本章推荐的做法。
二十九、为什么优惠要落到明细
如果只保存:
主表 discountAmount = 100
你不知道:
到底优惠在哪个项目
三十、明细优惠的好处
可以:
清楚解释价格构成
导出
打印
审批
审计
三十一、是否允许只做整单优惠
也可以。
如果业务明确:
只支持整单优惠
那就不用分摊。
但是课程后面:
审批
结算明细
会更适合:
保留明细优惠
三十二、整单优惠如何分摊到明细
这是一个真实问题。
例如:
A = 300
B = 200
总额 = 500
整单优惠 = 100
需要决定:
A 优惠多少
B 优惠多少
三十三、最简单方案
按比例分摊:
A 占 60%
→ 优惠 60
B 占 40%
→ 优惠 40
三十四、公式
某明细优惠:
itemOriginal
/
totalOriginal
*
totalDiscount
三十五、为什么分摊会有小数问题
例如:
3 个项目
整单优惠 100
比例计算后:
33.33
33.33
33.33
合计:
99.99
少了:
0.01
三十六、如何处理尾差
常见:
最后一条承担尾差
三十七、示意
前 N-1 条:
正常四舍五入
最后一条:
总优惠
-
前面优惠合计
三十八、课程第一版推荐什么
为了业务更直观:
前端允许给每条明细输入优惠金额
后端:
汇总明细优惠
这样不用:
做比例分摊
三十九、这也更适合审批
审批人可以看:
哪一项优惠最多
四十、结算创建 DTO 要升级
之前:
SettlementCreateDTO
只有 workOrderId + discountAmount
现在改成:
public class SettlementCreateDTO {
@NotNull(
message = "工单不能为空"
)
private Long workOrderId;
@NotEmpty(
message = "结算明细不能为空"
)
private List<SettlementItemCreateDTO>
items;
private String remark;
}
四十一、明细 DTO
public class SettlementItemCreateDTO {
@NotNull(
message = "工单明细不能为空"
)
private Long workOrderItemId;
@NotNull(
message = "优惠金额不能为空"
)
@DecimalMin("0.00")
@Digits(
integer = 10,
fraction = 2
)
private BigDecimal discountAmount;
private String remark;
}
四十二、为什么前端只传 workOrderItemId + discountAmount
不要让前端传:
itemName
unitPrice
quantity
originalAmount
finalAmount
这些:
全部由后端根据工单明细重新生成
四十三、这是本章最重要安全原则之一
前端应该只告诉后端:
我要结算哪条工单明细
以及这条明细申请优惠多少
四十四、后端自己查
itemName
unitPrice
quantity
originalAmount
四十五、finalAmount 也由后端计算
originalAmount
-
discountAmount
四十六、为什么 workOrderItemId 必须属于当前 workOrderId
攻击者可以构造:
{
"workOrderId": 100,
"items": [
{
"workOrderItemId": 999,
"discountAmount": 0
}
]
}
其中 999:
可能属于另一张工单
四十七、所以必须校验归属
所有 workOrderItemId
必须 belong to workOrderId
四十八、不能只检查“ID 存在”
必须检查:
ID + WorkOrder
关系。
四十九、推荐一次批量查询
SELECT *
FROM car_work_order_item
WHERE
work_order_id = #{workOrderId}
AND work_order_item_id IN (...)
AND status = '0';
五十、为什么不是每条循环查
避免:
N+1
五十一、提交明细不能重复
前端 items 里:
同一个 workOrderItemId
不能出现两次。
五十二、Set 去重
Set<Long> ids =
dto.getItems()
.stream()
.map(
SettlementItemCreateDTO
::getWorkOrderItemId
)
.collect(
Collectors.toSet()
);
if (
ids.size()
!=
dto.getItems().size()
) {
throw new ServiceException(
"结算明细存在重复项目"
);
}
五十三、是否必须把工单所有正常明细都结算
课程第一版推荐:
是
五十四、为什么
避免:
漏结一条
所以:
提交 item 数
==
工单有效明细数
五十五、什么时候允许部分结算
复杂业务:
分批结算
才允许。
当前:
不做
五十六、完整创建结算流程升级版
查 WorkOrder
↓
检查门店权限
↓
检查 WAIT_SETTLEMENT
↓
检查未存在 Settlement
↓
获取有效 WorkOrderItems
↓
校验前端 items 数量一致
↓
校验 ID 都属于当前工单
↓
逐条计算:
original
discount
final
↓
汇总 total
discount
receivable
↓
判断审批状态
↓
生成 settlementNo
↓
INSERT settlement
↓
BATCH INSERT settlement_item
↓
UPDATE work_order status
↓
COMMIT
五十七、最重要:主表和明细必须一个事务
如果:
主表 insert 成功
明细:
batch insert 失败
必须:
全部回滚
五十八、否则会出现
一张没有明细的财务结算单
这是:
严重数据错误
五十九、SettlementItem 实体
public class CarSettlementItem
extends BaseEntity {
private Long settlementItemId;
private Long settlementId;
private String sourceType;
private Long sourceId;
private String itemName;
private BigDecimal unitPrice;
private Integer quantity;
private BigDecimal originalAmount;
private BigDecimal discountAmount;
private BigDecimal finalAmount;
}
六十、是否需要 workOrderItemId 字段
推荐可以增加:
work_order_item_id
比只用:
source_id
更直观。
六十一、改进表结构
可以增加:
ALTER TABLE car_settlement_item
ADD work_order_item_id BIGINT NULL
AFTER settlement_id;
六十二、为什么
结算明细直接来源是:
工单明细
所以保留:
work_order_item_id
有助于:
审计和追踪
六十三、推荐最终字段
settlement_item_id
settlement_id
work_order_item_id
source_type
source_id
item_name
unit_price
quantity
original_amount
discount_amount
final_amount
六十四、source_type/source_id 仍然有价值吗
有。
因为 workOrderItem 本身可能来自:
单项
套餐
所以:
work_order_item_id
表示:
直接来源
source_type + source_id
表示:
业务配置来源
六十五、例如
settlement_item
↓
work_order_item_id = 500
↓
source_type = PACKAGE
source_id = 10
说明:
结算明细来自工单明细 500
而该工单明细来自套餐 10
六十六、工单明细也建议保存 source_type/source_id
这样链路:
Package
↓
WorkOrderItem
↓
SettlementItem
来源清楚。
六十七、后端构造 SettlementItem
private CarSettlementItem
buildSettlementItem(
Long settlementId,
CarWorkOrderItem workItem,
BigDecimal discountAmount
) {
BigDecimal originalAmount =
workItem.getAmount();
if (
discountAmount.compareTo(
originalAmount
) > 0
) {
throw new ServiceException(
"明细优惠不能大于原金额"
);
}
BigDecimal finalAmount =
originalAmount.subtract(
discountAmount
);
CarSettlementItem item =
new CarSettlementItem();
item.setSettlementId(
settlementId
);
item.setWorkOrderItemId(
workItem.getWorkOrderItemId()
);
item.setSourceType(
workItem.getSourceType()
);
item.setSourceId(
workItem.getSourceId()
);
item.setItemName(
workItem.getItemName()
);
item.setUnitPrice(
workItem.getUnitPrice()
);
item.setQuantity(
workItem.getQuantity()
);
item.setOriginalAmount(
originalAmount
);
item.setDiscountAmount(
discountAmount
);
item.setFinalAmount(
finalAmount
);
return item;
}
六十八、为什么 originalAmount 用 workItem.amount
因为:
它是工单发生时已经确定的金额快照
不要:
再查当前 service_item.standard_price
六十九、这是非常重要的一点
结算:
不能根据当前主数据重新计价
应该根据:
工单快照价
七十、否则改价会影响历史
例如:
工单时价格 300
结算前基础服务项目改成:
350
如果结算再查当前价格:
客户凭空多付 50
明显错误。
七十一、金额汇总
BigDecimal totalAmount =
settlementItems
.stream()
.map(
CarSettlementItem
::getOriginalAmount
)
.reduce(
BigDecimal.ZERO,
BigDecimal::add
);
七十二、优惠汇总
BigDecimal discountAmount =
settlementItems
.stream()
.map(
CarSettlementItem
::getDiscountAmount
)
.reduce(
BigDecimal.ZERO,
BigDecimal::add
);
七十三、应收汇总
BigDecimal receivableAmount =
settlementItems
.stream()
.map(
CarSettlementItem
::getFinalAmount
)
.reduce(
BigDecimal.ZERO,
BigDecimal::add
);
七十四、还需要再校验一次公式
推荐:
BigDecimal expected =
totalAmount.subtract(
discountAmount
);
if (
expected.compareTo(
receivableAmount
) != 0
) {
throw new ServiceException(
"结算金额计算异常"
);
}
七十五、为什么明明自己算还要校验
属于:
防御性编程
如果未来代码改动:
出现错误
可以更早发现。
七十六、批量插入明细
Mapper:
int batchInsert(
@Param("list")
List<CarSettlementItem> list
);
七十七、XML
<insert id="batchInsert">
INSERT INTO car_settlement_item
(
settlement_id,
work_order_item_id,
source_type,
source_id,
item_name,
unit_price,
quantity,
original_amount,
discount_amount,
final_amount,
create_by,
create_time
)
VALUES
<foreach
collection="list"
item="item"
separator=","
>
(
#{item.settlementId},
#{item.workOrderItemId},
#{item.sourceType},
#{item.sourceId},
#{item.itemName},
#{item.unitPrice},
#{item.quantity},
#{item.originalAmount},
#{item.discountAmount},
#{item.finalAmount},
#{item.createBy},
NOW()
)
</foreach>
</insert>
七十八、为什么 batchInsert
避免:
循环 N 次 INSERT
七十九、创建主表前能不能先构造明细
可以先构造:
没有 settlementId 的临时明细
然后:
算金额
主表插入拿到:
settlementId
再:
给明细 set settlementId
八十、推荐顺序
1. 查工单明细
2. 构造临时结算明细
3. 计算金额
4. INSERT settlement
5. 获取 settlementId
6. 回填 settlementId
7. BATCH INSERT items
8. UPDATE workOrder
八十一、这样主表金额和明细金额天然一致
因为:
主表金额
就是从准备插入的明细汇总出来
八十二、完整 Service 示例
@Transactional(
rollbackFor = Exception.class
)
public Long createSettlement(
SettlementCreateDTO dto
) {
CarWorkOrder workOrder =
workOrderService
.getAccessibleWorkOrder(
dto.getWorkOrderId()
);
if (
workOrder == null
) {
throw new ServiceException(
"工单不存在或无权限"
);
}
if (
!WorkOrderStatus
.WAIT_SETTLEMENT
.getCode()
.equals(
workOrder.getStatus()
)
) {
throw new ServiceException(
"当前工单不能生成结算"
);
}
if (
settlementMapper
.countByWorkOrderId(
workOrder.getWorkOrderId()
)
> 0
) {
throw new ServiceException(
"当前工单已生成结算单"
);
}
Set<Long> requestIds =
dto.getItems()
.stream()
.map(
SettlementItemCreateDTO
::getWorkOrderItemId
)
.collect(
Collectors.toSet()
);
if (
requestIds.size()
!=
dto.getItems().size()
) {
throw new ServiceException(
"结算明细存在重复项目"
);
}
List<CarWorkOrderItem> workItems =
workOrderItemMapper
.selectValidItemsByIds(
workOrder.getWorkOrderId(),
requestIds
);
if (
workItems.size()
!=
requestIds.size()
) {
throw new ServiceException(
"存在无效或不属于当前工单的结算明细"
);
}
int validCount =
workOrderItemMapper
.countValidByWorkOrderId(
workOrder.getWorkOrderId()
);
if (
validCount
!=
workItems.size()
) {
throw new ServiceException(
"必须结算当前工单全部有效项目"
);
}
Map<Long, BigDecimal> discountMap =
dto.getItems()
.stream()
.collect(
Collectors.toMap(
SettlementItemCreateDTO
::getWorkOrderItemId,
SettlementItemCreateDTO
::getDiscountAmount
)
);
List<CarSettlementItem> settlementItems =
new ArrayList<>();
for (
CarWorkOrderItem workItem
:
workItems
) {
BigDecimal itemDiscount =
discountMap.get(
workItem.getWorkOrderItemId()
);
settlementItems.add(
buildSettlementItem(
null,
workItem,
itemDiscount
)
);
}
BigDecimal totalAmount =
sumOriginal(
settlementItems
);
BigDecimal discountAmount =
sumDiscount(
settlementItems
);
BigDecimal receivableAmount =
sumFinal(
settlementItems
);
CarSettlement settlement =
new CarSettlement();
settlement.setSettlementNo(
generateSettlementNo()
);
settlement.setWorkOrderId(
workOrder.getWorkOrderId()
);
settlement.setCustomerId(
workOrder.getCustomerId()
);
settlement.setVehicleId(
workOrder.getVehicleId()
);
settlement.setDeptId(
workOrder.getDeptId()
);
settlement.setTotalAmount(
totalAmount
);
settlement.setDiscountAmount(
discountAmount
);
settlement.setReceivableAmount(
receivableAmount
);
settlement.setPaidAmount(
BigDecimal.ZERO
);
settlement.setPaymentStatus("0");
settlement.setApprovalStatus(
determineApprovalStatus(
discountAmount,
receivableAmount
)
);
settlement.setStatus("0");
settlement.setRemark(
dto.getRemark()
);
settlementMapper
.insertSettlement(
settlement
);
for (
CarSettlementItem item
:
settlementItems
) {
item.setSettlementId(
settlement.getSettlementId()
);
}
settlementItemMapper
.batchInsert(
settlementItems
);
int rows =
workOrderMapper
.updateStatus(
workOrder.getWorkOrderId(),
WorkOrderStatus
.WAIT_SETTLEMENT
.getCode(),
WorkOrderStatus
.SETTLED
.getCode()
);
if (
rows == 0
) {
throw new ServiceException(
"工单状态已变化,请刷新后重试"
);
}
return settlement
.getSettlementId();
}
八十三、为什么 discountMap 很方便
前端 DTO:
workOrderItemId
→
discountAmount
通过:
Map
可以快速匹配工单明细。
八十四、为什么 Collectors.toMap 前要先去重
如果同一个 ID 重复:
toMap
可能直接:
Duplicate key
所以:
先做业务去重检查
提示更友好。
八十五、结算明细是否允许修改
只允许:
结算单状态 = 草稿
修改:
明细优惠金额
八十六、不允许修改
itemName
unitPrice
quantity
originalAmount
因为这些来自:
工单快照
八十七、为什么 quantity 也不允许改
如果结算时数量发现不对:
应该先修正工单
但如果结算已经生成:
当前规则下工单已冻结
所以应该:
在生成结算之前确认工单准确
八十八、课程第一版推荐流程
工单确认无误
↓
标记待结算
↓
生成结算
↓
只允许调整优惠
八十九、修改结算 DTO
可以继续使用:
public class SettlementUpdateDTO {
@NotNull
private Long settlementId;
@NotEmpty
private List<SettlementItemDiscountDTO>
items;
private String remark;
}
九十、DiscountDTO
public class SettlementItemDiscountDTO {
@NotNull
private Long settlementItemId;
@NotNull
@DecimalMin("0.00")
@Digits(
integer = 10,
fraction = 2
)
private BigDecimal discountAmount;
}
九十一、为什么修改用 settlementItemId
因为结算明细已经:
生成并保存
修改时直接:
针对已有结算明细
九十二、修改流程
查 Settlement
↓
必须 DRAFT
↓
查全部 SettlementItems
↓
校验前端 ID 全属于当前 Settlement
↓
更新每条 discountAmount/finalAmount
↓
重新汇总主表金额
↓
UPDATE Settlement
↓
COMMIT
九十三、为什么修改不能只更新主表 discountAmount
那样:
明细和主表不一致
九十四、修改时主表 totalAmount 是否变化
不变。
因为:
原始项目金额不变
变化的是:
discountAmount
receivableAmount
九十五、修改明细优惠示例
@Transactional(
rollbackFor = Exception.class
)
public void updateSettlement(
SettlementUpdateDTO dto
) {
CarSettlement settlement =
getAccessibleSettlement(
dto.getSettlementId()
);
requireDraft(
settlement
);
List<CarSettlementItem> items =
settlementItemMapper
.selectBySettlementId(
settlement.getSettlementId()
);
Map<Long, CarSettlementItem> itemMap =
items.stream()
.collect(
Collectors.toMap(
CarSettlementItem
::getSettlementItemId,
Function.identity()
)
);
if (
dto.getItems().size()
!=
items.size()
) {
throw new ServiceException(
"结算明细数量不一致"
);
}
for (
SettlementItemDiscountDTO input
:
dto.getItems()
) {
CarSettlementItem item =
itemMap.get(
input.getSettlementItemId()
);
if (
item == null
) {
throw new ServiceException(
"存在非法结算明细"
);
}
if (
input.getDiscountAmount()
.compareTo(
item.getOriginalAmount()
)
> 0
) {
throw new ServiceException(
"明细优惠不能大于原金额"
);
}
item.setDiscountAmount(
input.getDiscountAmount()
);
item.setFinalAmount(
item.getOriginalAmount()
.subtract(
input.getDiscountAmount()
)
);
}
settlementItemMapper
.batchUpdateAmounts(
items
);
BigDecimal totalDiscount =
sumDiscount(
items
);
BigDecimal totalReceivable =
sumFinal(
items
);
settlement.setDiscountAmount(
totalDiscount
);
settlement.setReceivableAmount(
totalReceivable
);
settlement.setApprovalStatus(
determineApprovalStatus(
totalDiscount,
totalReceivable
)
);
settlement.setRemark(
dto.getRemark()
);
settlementMapper
.updateSettlement(
settlement
);
}
九十六、为什么更新优惠后重新判断审批
例如:
原优惠 100
无需审批
改成:
800
可能:
需要审批
所以:
approvalStatus
必须重新计算。
九十七、提交后为什么不能再改优惠
因为:
审批依据已经生成
如果审批中还改金额:
审批人看到的数据
和最终结算
可能不一致
九十八、这是后续接 Activiti 的关键
提交后:
业务数据冻结
审批针对:
一个稳定版本
九十九、结算详情后端查询
Controller:
GET /car/settlement/{id}
返回:
主表
+
items
一百、详情查询流程
查可访问 Settlement
↓
查 customer/vehicle/workOrder/dept
↓
查 SettlementItems
↓
组装 SettlementDetailVO
一百零一、SettlementItemVO
public class SettlementItemVO {
private Long settlementItemId;
private Long workOrderItemId;
private String sourceType;
private Long sourceId;
private String itemName;
private BigDecimal unitPrice;
private Integer quantity;
private BigDecimal originalAmount;
private BigDecimal discountAmount;
private BigDecimal finalAmount;
}
一百零二、详情明细 SQL
SELECT
settlement_item_id,
settlement_id,
work_order_item_id,
source_type,
source_id,
item_name,
unit_price,
quantity,
original_amount,
discount_amount,
final_amount,
remark
FROM car_settlement_item
WHERE
settlement_id = ?
ORDER BY
settlement_item_id;
一百零三、为什么不要 JOIN 当前 service_item
历史详情:
直接显示快照
不要:
再 JOIN 当前服务项目名称和价格
一百零四、否则会发生历史漂移
基础项目改名:
“机油更换”
→
“发动机润滑油更换”
历史结算:
也变了
可能:
不符合审计
一百零五、来源名称怎么显示
可以额外:
显示来源类型
例如:
单项
套餐
一百零六、source_type 字典
建议:
car_service_source_type
数据:
SERVICE_ITEM → 服务单项
PACKAGE → 服务套餐
一百零七、结算页面手动搭建
这一章前端:
不建议只依赖代码生成器
因为:
结算明细是动态表格
更适合:
手工搭建
一百零八、页面结构
查询区
结算主表列表
生成/编辑结算 Dialog
结算详情 Dialog
一百零九、生成结算 Dialog
上方:
工单信息
中间:
结算明细表格
下方:
总金额
优惠合计
应收金额
备注
一百一十、明细表格列
项目名称
来源
单价
数量
原金额
优惠金额
最终金额
一百一十一、为什么优惠金额用 input-number
草稿阶段:
允许调整
一百一十二、Vue 示例
<el-table
:data="form.items"
border
>
<el-table-column
prop="itemName"
label="项目名称"
min-width="160"
/>
<el-table-column
label="单价"
width="120"
>
<template #default="{ row }">
¥ {{ formatMoney(row.unitPrice) }}
</template>
</el-table-column>
<el-table-column
prop="quantity"
label="数量"
width="80"
/>
<el-table-column
label="原金额"
width="120"
>
<template #default="{ row }">
¥ {{ formatMoney(row.originalAmount) }}
</template>
</el-table-column>
<el-table-column
label="优惠金额"
width="160"
>
<template #default="{ row }">
<el-input-number
v-model="row.discountAmount"
:min="0"
:max="Number(row.originalAmount)"
:precision="2"
:step="10"
/>
</template>
</el-table-column>
<el-table-column
label="最终金额"
width="120"
>
<template #default="{ row }">
¥ {{ formatMoney(calcFinal(row)) }}
</template>
</el-table-column>
</el-table>
一百一十三、前端 calcFinal
function calcFinal(
row
) {
const original =
Number(
row.originalAmount
|| 0
)
const discount =
Number(
row.discountAmount
|| 0
)
return Math.max(
original - discount,
0
)
}
一百一十四、再次强调
前端计算:
只用于展示
后端:
必须重新 BigDecimal 计算
一百一十五、总金额 computed
const totalAmount =
computed(
() =>
form.value.items
.reduce(
(
sum,
item
) =>
sum
+
Number(
item.originalAmount
|| 0
),
0
)
)
一百一十六、优惠合计
const discountAmount =
computed(
() =>
form.value.items
.reduce(
(
sum,
item
) =>
sum
+
Number(
item.discountAmount
|| 0
),
0
)
)
一百一十七、应收
const receivableAmount =
computed(
() =>
Math.max(
totalAmount.value
-
discountAmount.value,
0
)
)
一百一十八、提交时 Payload
生成结算:
const payload = {
workOrderId:
form.value.workOrderId,
items:
form.value.items.map(
item => ({
workOrderItemId:
item.workOrderItemId,
discountAmount:
item.discountAmount
})
),
remark:
form.value.remark
}
一百一十九、为什么不提交这些
不要:
itemName
unitPrice
quantity
originalAmount
finalAmount
totalAmount
receivableAmount
一百二十、因为这些都是后端可信数据
前端只提交:
ID
+
业务允许用户输入的优惠
一百二十一、编辑草稿 Payload
const payload = {
settlementId:
form.value.settlementId,
items:
form.value.items.map(
item => ({
settlementItemId:
item.settlementItemId,
discountAmount:
item.discountAmount
})
),
remark:
form.value.remark
}
一百二十二、生成结算前 Preview
调用:
GET /car/settlement/preview/{workOrderId}
返回:
工单基础信息
+
工单项目
一百二十三、PreviewItemVO
public class SettlementPreviewItemVO {
private Long workOrderItemId;
private String sourceType;
private Long sourceId;
private String itemName;
private BigDecimal unitPrice;
private Integer quantity;
private BigDecimal originalAmount;
}
一百二十四、Preview 前端默认
discountAmount = 0
一百二十五、为什么 Preview 不能返回“可编辑 unitPrice”
如果允许:
前端改施工快照价
结算规则就乱了。
一百二十六、如果业务确实要改单价怎么办
那应该:
回到工单调整
并重新确认:
工单
当前课程:
结算只允许改优惠
一百二十七、提交后页面
明细表:
全部 readonly
一百二十八、是否审批中也 readonly
必须:
readonly
一百二十九、审批拒绝后怎么办
后续 Activiti 章节建议:
退回草稿
然后:
允许重新调整优惠
一百三十、所以未来状态转换
DRAFT
↓ submit
SUBMITTED / PROCESSING
↓ reject
DRAFT
或:
REJECTED
再通过:
reopen
回草稿。
课程后面再决定。
一百三十一、结算明细是否需要 DataScope
明细本身:
没有 dept_id
它通过:
settlement_id
归属主表。
一百三十二、所以不要直接暴露
GET /settlementItem/{id}
然后纯主键查询。
一百三十三、更安全的方式
明细:
只通过结算详情查询
先验证:
Settlement DataScope
再查询:
items
一百三十四、如果必须单独明细接口
SQL 要 JOIN:
car_settlement
检查:
dept_id
一百三十五、这是水平越权防护
不能认为:
明细只是子表
所以不需要权限
一百三十六、更新明细更不能直接根据 itemId
必须:
先验证所属 Settlement
一百三十七、权限设计
结算明细通常:
不单独设计大量权限码
复用主结算:
car:settlement:query
car:settlement:add
car:settlement:edit
一百三十八、为什么
明细:
是结算单组成部分
不是:
独立业务模块
一百三十九、是否要给 settlementItem 单独菜单
不需要。
前端:
嵌在结算详情/编辑中
一百四十、Mapper 建议
CarSettlementItemMapper:
selectBySettlementId
batchInsert
batchUpdateAmounts
deleteBySettlementId
一百四十一、为什么保留 deleteBySettlementId
如果草稿重建策略以后需要:
整批重建明细
会用到。
一百四十二、当前更新策略
推荐:
只更新优惠和 finalAmount
而不是:
删除重建
一百四十三、为什么
结算明细生成后:
项目集合冻结
所以没有:
新增/删除项目
只改:
优惠
一百四十四、这样审计更稳定
明细 ID:
保持不变
一百四十五、批量更新金额怎么写
MyBatis 可以:
foreach 多条 UPDATE
但很多数据库驱动:
多语句执行配置麻烦
一百四十六、简单方案
明细数量少:
循环 update
也可以。
一百四十七、这和 N+1 查询不同
N+1 查询通常:
大量 SELECT
这里:
少量明细 UPDATE
课程项目:
可以接受
一百四十八、如果追求批量
可使用:
CASE WHEN
例如:
UPDATE car_settlement_item
SET
discount_amount =
CASE settlement_item_id
WHEN 1 THEN 10
WHEN 2 THEN 20
END,
final_amount =
CASE settlement_item_id
WHEN 1 THEN 90
WHEN 2 THEN 180
END
WHERE
settlement_item_id IN (1,2);
一百四十九、当前课程需要吗
不需要。
优先:
代码清晰
一百五十、金额计算工具方法
推荐集中:
private BigDecimal safe(
BigDecimal value
) {
return value == null
? BigDecimal.ZERO
: value;
}
一百五十一、统一 scale
如果业务要求:
两位小数
可以:
value.setScale(
2,
RoundingMode.HALF_UP
)
一百五十二、但是什么时候 rounding
项目价格本身数据库已经:
DECIMAL(12,2)
很多加减:
无需额外 rounding
只有比例分摊时:
特别需要
一百五十三、不要到处随意 setScale
否则:
不同方法舍入规则不一致
一百五十四、如果未来整单优惠按比例分摊
统一:
RoundingMode.HALF_UP
并:
最后一条补尾差
一百五十五、结算详情页面
建议:
el-descriptions
+
el-table
一百五十六、上半部分
显示:
结算号
工单号
客户
车牌
门店
结算状态
审批状态
支付状态
一百五十七、下半部分明细
项目
来源
单价
数量
原金额
优惠
最终金额
一百五十八、底部汇总
总金额
优惠合计
应收金额
已付金额
剩余金额
一百五十九、为什么审批人很需要这个页面
审批不能只看到:
优惠 800
还应该看:
哪些项目
分别优惠多少
一百六十、所以本章是 Activiti 的业务数据基础
下一阶段:
流程审批
会直接读取:
SettlementDetailVO
一百六十一、审批任务页面需要的信息
例如:
结算单号
客户
车辆
总金额
总优惠
优惠比例
应收金额
明细列表
申请人
门店
一百六十二、优惠比例
可以计算:
discountAmount
/
totalAmount
一百六十三、注意除零
如果:
totalAmount = 0
不能直接除。
但前面创建规则已经:
无有效金额不生成结算
一百六十四、审批规则也可以升级
之前:
discount > 500
后面可以:
discount > 500
OR
discountRate > 20%
一百六十五、为什么不要现在写复杂规则引擎
课程当前:
先接通流程
复杂审批规则:
以后再抽象
一百六十六、结算打印
虽然课程不要求打印插件,
详情页面本身应该:
结构清楚
未来打印:
直接复用数据
一百六十七、Excel 导出明细
有两种:
一张结算一行
或:
一条明细一行
一百六十八、如果需要完整账单
更适合:
一条明细一行
例如:
结算号
工单号
项目名
单价
数量
原金额
优惠
最终金额
一百六十九、但会重复主表字段
这是:
报表型展开
可以接受。
一百七十、结算明细 ExportVO
public class SettlementItemExportVO {
@Excel(
name = "结算编号"
)
private String settlementNo;
@Excel(
name = "工单编号"
)
private String workOrderNo;
@Excel(
name = "服务项目"
)
private String itemName;
@Excel(
name = "单价"
)
private BigDecimal unitPrice;
@Excel(
name = "数量"
)
private Integer quantity;
@Excel(
name = "原金额"
)
private BigDecimal originalAmount;
@Excel(
name = "优惠金额"
)
private BigDecimal discountAmount;
@Excel(
name = "最终金额"
)
private BigDecimal finalAmount;
}
一百七十一、为什么导出也不能查当前服务项目价格
还是:
历史快照
一百七十二、数据库一致性检查 1
检查主表总额:
SELECT
s.settlement_id,
s.total_amount,
SUM(i.original_amount)
AS item_total
FROM car_settlement s
JOIN car_settlement_item i
ON i.settlement_id =
s.settlement_id
GROUP BY
s.settlement_id,
s.total_amount
HAVING
s.total_amount
<>
SUM(i.original_amount);
应该:
0 行
一百七十三、一致性检查 2
主表优惠:
SELECT
s.settlement_id,
s.discount_amount,
SUM(i.discount_amount)
AS item_discount
FROM car_settlement s
JOIN car_settlement_item i
ON i.settlement_id =
s.settlement_id
GROUP BY
s.settlement_id,
s.discount_amount
HAVING
s.discount_amount
<>
SUM(i.discount_amount);
应该:
0 行
一百七十四、一致性检查 3
应收:
SELECT
s.settlement_id,
s.receivable_amount,
SUM(i.final_amount)
AS item_final
FROM car_settlement s
JOIN car_settlement_item i
ON i.settlement_id =
s.settlement_id
GROUP BY
s.settlement_id,
s.receivable_amount
HAVING
s.receivable_amount
<>
SUM(i.final_amount);
应该:
0 行
一百七十五、一致性检查 4
明细公式:
SELECT *
FROM car_settlement_item
WHERE
final_amount
<>
original_amount
-
discount_amount;
应该:
0 行
一百七十六、一致性检查 5
优惠不能超原价:
SELECT *
FROM car_settlement_item
WHERE
discount_amount
>
original_amount;
应该:
0 行
一百七十七、一致性检查 6
明细不能负数:
SELECT *
FROM car_settlement_item
WHERE
final_amount < 0;
应该:
0 行
一百七十八、一致性检查 7
空结算单:
SELECT
s.settlement_id,
s.settlement_no
FROM car_settlement s
LEFT JOIN car_settlement_item i
ON i.settlement_id =
s.settlement_id
WHERE
i.settlement_item_id IS NULL;
正常业务下:
0 行
一百七十九、数据库唯一约束还能加什么
如果一条工单明细:
只能出现在同一结算一次
可考虑:
UNIQUE (
settlement_id,
work_order_item_id
)
一百八十、推荐加
ALTER TABLE car_settlement_item
ADD UNIQUE KEY uk_settlement_work_item (
settlement_id,
work_order_item_id
);
一百八十一、为什么
即使应用层重复:
数据库仍阻止
一百八十二、这和前面的思想一致
前端去重
+
Service 去重
+
数据库 UNIQUE
多层防护。
一百八十三、常见 Bug 1:结算金额和明细合计不一致
原因:
主表金额单独算
明细金额又单独算
正确:
主表直接汇总准备插入的结算明细
一百八十四、常见 Bug 2:前端把某项目价格改成 0.01 后成功结算
说明:
后端信任了前端 unitPrice
一百八十五、正确
前端不提交:
unitPrice
后端:
从 WorkOrderItem 快照读取
一百八十六、常见 Bug 3:A 工单混入 B 工单明细
说明:
只校验 ID 存在
没校验 workOrderId 归属
一百八十七、常见 Bug 4:同一明细提交两次
检查:
DTO 去重
UNIQUE(settlement_id, work_order_item_id)
一百八十八、常见 Bug 5:修改草稿时只改主表优惠
结果:
主表和明细账不平
一百八十九、常见 Bug 6:审批中还能改优惠
后端:
缺少 DRAFT 状态校验
一百九十、常见 Bug 7:服务项目改价后结算详情变化
说明:
详情 JOIN 了当前 service_item
错误。
一百九十一、常见 Bug 8:明细为空但主表存在
说明:
事务失败
或:
异常被吞
一百九十二、常见 Bug 9:batchInsert 失败主表没回滚
检查:
@Transactional
Spring Bean 调用
异常有没有被 catch
一百九十三、常见 Bug 10:结算明细可单独查别人门店
说明:
子表接口没走主表 DataScope
一百九十四、常见 Bug 11:优惠 10.001 成功入库
检查:
@Digits
DECIMAL scale
一百九十五、常见 Bug 12:优惠大于原金额
后端:
缺少 compareTo
一百九十六、常见 Bug 13:前端 totalAmount 正确,但后端不同
应以:
后端
为准。
前端:
重新刷新显示后端结果
一百九十七、常见 Bug 14:提交后还能增删明细
业务设计错误。
生成结算后:
明细集合冻结
只在草稿:
改优惠
一百九十八、常见 Bug 15:审批人看不到优惠明细
说明:
审批页只查主表
后续应:
复用 SettlementDetailVO
一百九十九、前端详情按钮
<el-button
v-hasPermi="[
'car:settlement:query'
]"
link
type="primary"
@click="handleDetail(row)"
>
详情
</el-button>
二百、编辑按钮
只显示:
status == DRAFT
同时:
car:settlement:edit
二百零一、提交按钮
只显示:
DRAFT
并:
car:settlement:submit
二百零二、审批中页面
所有金额:
readonly
二百零三、为什么前端状态限制仍然只是体验
后端:
必须再次校验
二百零四、后端修改入口
PUT /car/settlement
必须:
requireDraft()
二百零五、不要开放 settlementItem CRUD Controller
这是一个非常重要的工程建议。
结算明细:
属于 Settlement 聚合内部
二百零六、为什么
如果你开放:
POST /settlementItem
PUT /settlementItem
DELETE /settlementItem
前端就可以:
绕过主表业务规则
二百零七、正确
明细变更:
只能通过 SettlementService
统一处理。
二百零八、这就是聚合思想
可以简单理解:
Settlement
是主对象
SettlementItem
是它的内部组成
外部:
通过 Settlement 操作整个业务
二百零九、这个思想以后很重要
例如:
订单
+
订单明细
也一样。
二百一十、Controller 应该保持简单
@PostMapping
public AjaxResult add(
@Valid
@RequestBody
SettlementCreateDTO dto
) {
return success(
settlementService
.createSettlement(
dto
)
);
}
不要在 Controller:
自己组明细
自己算钱
自己写事务
二百一十一、所有结算核心规则放 Service
包括:
校验工单
校验明细
计算金额
保存主表
保存子表
状态更新
二百一十二、日志
生成结算:
@Log INSERT
修改优惠:
@Log UPDATE
提交:
@Log UPDATE
二百一十三、明细优惠日志
如果审计要求更严格:
可以记录旧优惠 / 新优惠
当前课程:
操作日志足够
二百一十四、支付前数据一致性再校验
支付时可以额外检查:
主表金额
和:
明细金额
是否一致。
二百一十五、为什么
财务操作前:
多一道保护
二百一十六、是否每次支付都 SUM 明细
小项目:
可以
大型项目:
不一定
因为主表已经是:
汇总快照
只要修改路径受控:
主表可信
二百一十七、课程推荐
提交结算前:
做一次一致性校验
支付时:
直接使用冻结后的主表
二百一十八、提交结算完整校验
Settlement = DRAFT
明细数量 > 0
SUM original = totalAmount
SUM discount = discountAmount
SUM final = receivableAmount
每条 discount <= original
final >= 0
全部满足:
才能 submit
二百一十九、为什么 submit 是一个重要边界
提交前:
可编辑
提交后:
冻结
二百二十、这就是“草稿 → 正式业务”的分界
很多企业系统都有:
Draft
↓
Submit
↓
Frozen
二百二十一、Activiti 也应该在 submit 触发
不是:
创建草稿就启动审批
二百二十二、为什么
用户还在:
改优惠
流程就启动:
非常混乱
二百二十三、正确
草稿编辑完
↓
提交
↓
判断是否需审批
↓
需要
→ startProcessInstance
二百二十四、下一章 Activiti 会做
businessKey = settlementId
或:
SETTLEMENT:{id}
二百二十五、为什么 businessKey 很重要
流程引擎:
知道流程实例
业务系统:
知道结算单
businessKey:
连接两者
二百二十六、当前本章先准备
Settlement:
processInstanceId
approvalStatus
以及:
完整 DetailVO
二百二十七、这样下一章接 Activiti 会很顺
审批页面:
根据 businessId
查 SettlementDetailVO
然后:
审批人看到完整金额明细
二百二十八、Git 提交建议
明细表:
git commit -m "feat: add settlement item model"
二百二十九、生成结算明细:
git commit -m "feat: build settlement items from work order"
二百三十、草稿优惠修改:
git commit -m "feat: support settlement item discount editing"
二百三十一、Vue 动态明细:
git commit -m "feat: add settlement detail editor"
二百三十二、金额一致性:
git commit -m "feat: validate settlement amount consistency"
二百三十三、Cursor 提示词 1:分析明细模型
当前项目基于 RuoYi-Vue springboot3 + RuoYi-Vue3,
业务模块为 ruoyi-car。
请只分析当前:
car_work_order_item
car_settlement
car_settlement_item
检查:
1. 结算明细是否保存历史价格快照
2. 是否保留 workOrderItemId
3. sourceType/sourceId 是否能追踪单项/套餐来源
4. 主表 totalAmount 是否等于明细 originalAmount 合计
5. 主表 discountAmount 是否等于明细优惠合计
6. receivableAmount 是否等于 finalAmount 合计
7. 是否存在前端可控 unitPrice/finalAmount
8. 是否存在跨工单明细混入风险
二百三十四、Cursor 提示词 2:实现生成结算明细
请实现从 car_work_order_item
生成 car_settlement_item。
要求:
1. 前端只传 workOrderItemId 和 discountAmount
2. itemName/unitPrice/quantity/originalAmount 从工单明细读取
3. 不查询当前 service_item.standard_price 作为结算价
4. 所有 workOrderItemId 必须属于当前 workOrderId
5. 不允许重复明细
6. 当前课程要求一次结算覆盖全部有效工单明细
7. discountAmount >= 0 且 <= originalAmount
8. finalAmount = originalAmount - discountAmount
9. 主表金额从准备插入的明细汇总
10. settlement + items + workOrder status 必须同一事务
11. 批量插入 settlement_item
二百三十五、Cursor 提示词 3:草稿编辑
请实现结算草稿优惠编辑。
要求:
1. 只允许 SettlementStatus.DRAFT
2. 只允许修改每条 settlementItem 的 discountAmount
3. 不允许修改 itemName、unitPrice、quantity、originalAmount
4. 校验所有 settlementItemId 属于当前 settlement
5. discount <= original
6. final = original - discount
7. 更新明细后重新汇总主表 discountAmount 和 receivableAmount
8. 重新计算 approvalStatus
9. 主表和明细更新同一事务
二百三十六、Cursor 提示词 4:Vue 页面
请手动实现若依 Vue3 风格结算编辑页面。
要求:
1. 使用 el-descriptions 展示工单和客户信息
2. 使用 el-table 展示结算明细
3. 明细列:名称、来源、单价、数量、原金额、优惠、最终金额
4. 草稿状态优惠金额可编辑
5. 非草稿全部只读
6. 前端 computed 显示总额/优惠/应收
7. 创建时只提交 workOrderItemId + discountAmount
8. 编辑时只提交 settlementItemId + discountAmount
9. 不提交 unitPrice/finalAmount 作为可信值
10. 使用 v-hasPermi 控制编辑、提交权限
二百三十七、Cursor 提示词 5:安全审查
请审查结算主从表的安全与一致性。
重点:
1. 是否开放独立 settlementItem CRUD 导致绕过主表
2. 是否能把其他工单明细加入当前结算
3. 是否信任前端 unitPrice/originalAmount/finalAmount
4. 是否允许同一工单明细重复结算
5. 是否存在明细优惠大于原金额
6. 主表金额是否可能和明细不一致
7. 提交后是否还能修改金额
8. 详情接口是否存在跨门店水平越权
9. 主表 insert 成功明细失败是否能回滚
10. 是否错误 JOIN 当前服务项目价格展示历史
二百三十八、IDEA 调试重点
断点:
createSettlement
buildSettlementItem
sumOriginal
sumDiscount
sumFinal
updateSettlement
submitSettlement
二百三十九、调试创建结算
重点观察:
request workOrderItemIds
database workItems
discountMap
settlementItems
totalAmount
discountAmount
receivableAmount
settlementId
batchInsert
二百四十、故意制造异常
例如:
batchInsert 前 throw new RuntimeException
检查:
car_settlement
是否:
也回滚
二百四十一、这能再次验证事务
不要只看:
@Transactional 写了
必须:
实际测试
二百四十二、测试用例 1
工单:
3 条项目
全部优惠:
0
生成:
3 条 settlement_item
二百四十三、测试用例 2
原金额:
300
优惠:
50
最终:
250
二百四十四、测试用例 3
优惠:
350
原金额:
300
预期:
拒绝
二百四十五、测试用例 4
前端传:
其他工单 workOrderItemId
预期:
拒绝
二百四十六、测试用例 5
同 itemId:
重复两次
预期:
拒绝
二百四十七、测试用例 6
漏掉工单一个有效项目:
拒绝
二百四十八、测试用例 7
前端伪造:
unitPrice = 0.01
后端 DTO:
根本不接收
二百四十九、测试用例 8
服务项目基础价格修改后:
结算历史不变
二百五十、测试用例 9
草稿:
修改优惠
主表:
discount/receivable
同步更新。
二百五十一、测试用例 10
已提交:
修改优惠
预期:
拒绝
二百五十二、测试用例 11
batchInsert 故意失败:
主表也回滚
二百五十三、测试用例 12
A 店用户:
查看 B 店 settlementId
预期:
拒绝
二百五十四、测试用例 13
直接访问:
B 店 settlementItemId
如果无独立接口:
无法绕过
二百五十五、测试用例 14
主表金额手工改错后:
提交
预期:
一致性校验失败
二百五十六、测试用例 15
结算明细 discount:
10.001
预期:
参数校验失败
二百五十七、面试题 1:为什么结算还需要明细表
答:
结算主表只能说明整张账单的总金额、优惠和应收。
结算明细用于记录每个计费项目的名称、单价、数量、
原金额、优惠和最终金额。
这样才能支持账单展示、审计、导出和审批。
二百五十八、面试题 2:为什么结算明细不能实时查服务项目价格
答:
服务项目属于当前配置主数据,
价格可能在未来修改。
结算属于已经发生的财务事实,
必须基于工单发生时的价格快照结算。
否则基础价格修改会改变历史账单。
二百五十九、面试题 3:为什么结算明细再保存一层价格快照
答:
工单明细表示施工业务事实,
结算明细表示最终计费事实。
结算阶段还可能产生优惠,
所以需要保存 originalAmount、discountAmount 和 finalAmount。
这样工单和财务两个生命周期可以独立追溯。
二百六十、面试题 4:为什么前端只传 workOrderItemId 和 discountAmount
答:
浏览器请求可以被篡改。
itemName、unitPrice、quantity 和 originalAmount
都属于服务端已经掌握的工单业务数据,
不应该再次相信客户端传值。
后端只接收业务允许用户输入的优惠,
其余数据从工单快照重新读取和计算。
二百六十一、面试题 5:如何防止把其他工单明细加入当前结算
答:
查询结算明细来源时,
SQL 不仅根据 workOrderItemId 查询,
还必须同时限制 workOrderId。
同时比较请求明细数量和实际查询结果数量,
发现不属于当前工单的 ID 时直接拒绝。
二百六十二、面试题 6:为什么主表金额应该从明细汇总
答:
如果主表金额和明细金额分别独立计算,
很容易出现不一致。
更可靠的方式是先构造最终准备保存的结算明细,
再从这些明细统一汇总 totalAmount、
discountAmount 和 receivableAmount,
最后一起保存。
二百六十三、面试题 7:为什么主表和明细必须同一事务
答:
结算主表和明细共同组成一张完整账单。
如果主表插入成功但明细失败,
数据库会出现没有项目组成的空结算单。
因此创建主表、批量插入明细以及更新工单状态
必须处于同一个事务中。
二百六十四、面试题 8:为什么提交后冻结结算明细
答:
提交后可能进入审批和支付流程。
审批人必须针对一份稳定的金额数据进行审批。
如果审批过程中仍然允许修改优惠或明细,
审批内容和最终实际结算可能不一致。
所以提交是草稿和正式业务之间的冻结边界。
二百六十五、面试题 9:为什么不单独开放 SettlementItem CRUD
答:
SettlementItem 是 Settlement 的内部组成部分。
如果单独开放新增、删除和修改接口,
客户端可能绕过结算主表的状态、金额和权限规则,
直接篡改财务明细。
更合理的是所有明细变更都通过 SettlementService 统一完成。
二百六十六、面试题 10:为什么还要数据库 UNIQUE
答:
前端和 Service 层都可以做去重,
但并发情况下应用层判断仍可能同时通过。
对 settlement_id + work_order_item_id
建立唯一约束,
可以作为数据库最终防线,
避免同一工单明细在同一结算中重复保存。
二百六十七、结算明细知识树
Settlement
│
├─ Settlement Main
│ ├─ totalAmount
│ ├─ discountAmount
│ └─ receivableAmount
│
├─ Settlement Item
│ ├─ workOrderItemId
│ ├─ sourceType
│ ├─ sourceId
│ ├─ itemName
│ ├─ unitPrice
│ ├─ quantity
│ ├─ originalAmount
│ ├─ discountAmount
│ └─ finalAmount
│
├─ Snapshot
│ ├─ ServiceItem Current Data
│ ├─ WorkOrder Business Snapshot
│ └─ Settlement Financial Snapshot
│
├─ Security
│ ├─ Do Not Trust Frontend Price
│ ├─ WorkOrder Ownership Check
│ ├─ Settlement DataScope
│ └─ No Independent Item CRUD
│
├─ Consistency
│ ├─ Transaction
│ ├─ Main = Item Sum
│ ├─ UNIQUE Relation
│ └─ Frozen After Submit
│
└─ Vue
├─ Detail Table
├─ Discount Input
├─ Total Computed
├─ Readonly After Submit
└─ Settlement Detail
二百六十八、完整结算创建最终流程图
WorkOrder
↓
WorkOrderItems
↓
Settlement Preview
↓
User enters item discounts
↓
POST SettlementCreateDTO
↓
@PreAuthorize
↓
@RepeatSubmit
↓
Service @Transactional
↓
Validate WorkOrder
↓
Validate DataScope
↓
Validate Item IDs
↓
Load WorkOrder Snapshots
↓
Build SettlementItems
↓
Calculate:
Original
Discount
Final
↓
Aggregate Main Amounts
↓
Determine Approval
↓
INSERT Settlement
↓
BATCH INSERT SettlementItems
↓
Conditional UPDATE WorkOrder
↓
Commit
二百六十九、完整草稿修改流程
Settlement DRAFT
↓
Load SettlementItems
↓
User modifies discount only
↓
PUT Settlement
↓
Validate Item Ownership
↓
Recalculate Final Amount
↓
Update Item Discounts
↓
Recalculate Main Discount
↓
Recalculate Receivable
↓
Recalculate Approval Requirement
↓
Commit
二百七十、本章最终验收
你应该能够独立完成:
1. car_settlement_item 表
2. workOrderItemId 来源追踪
3. sourceType/sourceId
4. 工单明细复制成结算快照
5. 原金额计算
6. 明细优惠
7. 最终金额
8. 主表金额汇总
9. 前端不能控制单价
10. 跨工单明细校验
11. 明细去重
12. 一次结算覆盖全部有效工单项目
13. settlement + items 同事务
14. 批量插入明细
15. 草稿修改优惠
16. 提交后冻结
17. 重新计算审批状态
18. 结算详情 VO
19. Vue3 动态明细表
20. 优惠 input-number
21. 前端金额 computed
22. DataScope
23. 防水平越权
24. Excel 明细导出
25. 金额一致性 SQL 检查
二百七十一、本章最重要的工程原则
1. 工单明细是施工快照,结算明细是财务快照
2. 历史价格不能依赖当前服务项目主数据
3. 前端不能传最终可信单价和金额
4. 所有 workOrderItemId 必须属于当前工单
5. 同一明细不能重复结算
6. 主表金额必须从结算明细汇总
7. total = SUM(original)
8. discount = SUM(item discount)
9. receivable = SUM(final)
10. final = original - discount
11. discount 不能大于 original
12. 主表 + 明细 + 工单状态必须同一事务
13. 结算明细不应该独立开放 CRUD
14. 草稿可以改优惠,提交后冻结
15. 审批必须针对稳定结算数据
二百七十二、下一篇
按照课程表,下一篇正式进入:
《淘车湾项目实战(六):集成 Activiti7 与审批流程定义页面实现》
下一章会开始真正接入工作流:
Activiti7 依赖与配置
ACT_* 表
BPMN 文件
流程定义
部署流程
启动流程实例
businessKey
processInstanceId
结算单提交后启动审批
UserTask
Assignee
CandidateGroup
角色与审批人映射
TaskService
RuntimeService
RepositoryService
审批流程定义页面
流程部署列表
启动结算审批
审批状态同步
事务边界
若依 Security 当前用户与 Activiti 用户任务结合