淘车湾项目实战四_结算单分析及代码实现
淘车湾项目实战(四):结算单分析及代码实现
本章位置:第三阶段 Java 企业项目 + AI 助手
对应课程:淘车湾项目实战——使用 AI 实现结算单分析及代码实现
前置知识:淘车湾项目实战(一)(二)(三)、若依脚手架、SpringBoot、MyBatis、事务、BigDecimal、Vue3、Element Plus
后续衔接:结算单明细、Activiti7 流程审批、我的待办/已办、流程图高亮
学习目标:完整实现结算单主流程,掌握工单到结算的业务关系、金额计算、重复结算防护、状态机、支付状态与审批状态拆分、DataScope、DTO/VO、事务和 Vue3 结算页面设计。
一、本章最终要做出什么
本章完成后,系统至少支持:
从已完工工单生成结算单
结算单分页查询
结算单详情
修改草稿结算单
提交结算
优惠金额计算
应收金额计算
支付状态维护
审批状态维护
重复结算防护
门店数据权限
结算导出
Vue3 结算列表
Vue3 结算编辑/详情
二、结算单在整个业务链中的位置
前面已经有:
预约
↓
工单
↓
工单服务项目
当技师完成施工后:
工单已完工
接下来进入:
结算
完整链路:
Appointment
↓
WorkOrder
↓
WorkOrderItem
↓
Settlement
↓
SettlementItem
↓
Approval
↓
Payment
↓
Completed
三、结算单不是“付款记录”
很多初学者会把:
结算单
理解成:
支付记录
这是不准确的。
结算单主要表示:
这次维修最终应该收多少钱
支付表示:
客户实际上付了多少钱
两者:
不是一个概念
四、为什么要拆成多个状态
结算业务至少有三个维度:
业务状态
审批状态
支付状态
不要:
一个 status
把所有情况全塞进去
五、业务状态
例如:
0 草稿
1 已提交
2 已完成
3 已作废
六、审批状态
例如:
0 无需审批
1 待审批
2 审批中
3 审批通过
4 审批拒绝
七、支付状态
例如:
0 未支付
1 部分支付
2 已支付
八、为什么不能一个 status 全部表示
如果只用一个:
status
你很快会出现:
待审批未支付
审批通过未支付
审批通过部分支付
审批通过已支付
审批拒绝未支付
无需审批已支付
状态组合:
越来越多
最终:
无法维护
九、正确做法
分别保存:
status
approval_status
payment_status
三个字段各管一件事。
十、结算表回顾
CREATE TABLE car_settlement (
settlement_id BIGINT NOT NULL AUTO_INCREMENT,
settlement_no VARCHAR(40) NOT NULL,
work_order_id BIGINT NOT NULL,
customer_id BIGINT NOT NULL,
vehicle_id BIGINT NOT NULL,
dept_id BIGINT NOT NULL,
total_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,
discount_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,
receivable_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,
paid_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,
payment_status CHAR(1) NOT NULL DEFAULT '0',
approval_status CHAR(1) NOT NULL DEFAULT '0',
process_instance_id VARCHAR(64),
status CHAR(1) NOT NULL DEFAULT '0',
remark VARCHAR(500),
create_by VARCHAR(64),
create_time DATETIME,
update_by VARCHAR(64),
update_time DATETIME,
PRIMARY KEY (settlement_id),
UNIQUE KEY uk_settlement_no (
settlement_no
),
UNIQUE KEY uk_settlement_work_order (
work_order_id
),
KEY idx_settlement_dept_status (
dept_id,
status
),
KEY idx_settlement_approval (
approval_status
)
);
十一、为什么 work_order_id 要 UNIQUE
当前业务规则:
一张工单
只能生成一张最终结算单
所以:
work_order_id UNIQUE
是最直接的数据库约束。
十二、如果以后允许多次结算怎么办
例如:
分期结算
多次结算
那就不能:
UNIQUE(work_order_id)
所以唯一约束:
必须跟真实业务规则一致
十三、结算编号
例如:
ST202609100001
结构:
ST
+
yyyyMMdd
+
四位流水
十四、为什么结算编号和主键分开
数据库主键:
settlement_id
用于:
内部关联
结算编号:
settlement_no
用于:
业务查询
打印
对账
用户展示
十五、Redis 生成流水号
Key:
seq:settlement:20260910
十六、生成示意
private String generateSettlementNo() {
String date =
LocalDate.now()
.format(
DateTimeFormatter
.BASIC_ISO_DATE
);
String key =
"seq:settlement:"
+ date;
Long seq =
stringRedisTemplate
.opsForValue()
.increment(
key
);
if (
seq != null
&&
seq == 1L
) {
stringRedisTemplate
.expire(
key,
Duration.ofDays(
2
)
);
}
return "ST"
+ date
+ String.format(
"%04d",
seq
);
}
十七、数据库 UNIQUE 仍然要保留
即使使用 Redis INCR:
也不能移除
数据库:
uk_settlement_no
仍然是:
最终兜底
十八、结算单从哪里生成
不是:
前端随便点一个按钮
而是从:
已完工 / 待结算工单
生成。
十九、推荐工单状态
上一章预留:
0 待派工
1 待施工
2 施工中
3 已完工
4 待结算
5 已结算
6 已取消
二十、什么时候允许生成结算单
推荐:
工单 status = 4
待结算
才允许:
生成结算
二十一、为什么不是施工中就能结算
因为:
服务还没结束
项目和数量:
可能还会变化
二十二、结算生成前置条件
工单存在
工单属于当前用户可访问门店
工单状态 = 待结算
工单有至少一个有效服务明细
工单尚未生成结算单
二十三、结算创建 DTO
第一版推荐:
public class SettlementCreateDTO {
@NotNull(
message = "工单不能为空"
)
private Long workOrderId;
@NotNull(
message = "优惠金额不能为空"
)
@DecimalMin(
value = "0.00",
message = "优惠金额不能小于0"
)
private BigDecimal discountAmount;
private String remark;
}
二十四、为什么 DTO 不让前端传 totalAmount
因为:
totalAmount
必须以后端工单明细为准
前端:
不可信
二十五、为什么不让前端传 receivableAmount
因为:
receivableAmount
=
totalAmount
-
discountAmount
应该:
后端计算
二十六、为什么不让前端传 customerId / vehicleId / deptId
这些:
都能从 workOrder
得到
前端再传:
反而可能篡改
二十七、后端根据 workOrderId 获取
customerId
vehicleId
deptId
workOrderNo
项目明细
金额
二十八、结算总金额怎么计算
来源:
car_work_order_item
每条:
amount
总额:
SUM(amount)
二十九、不要用前端列表求和结果作为最终金额
浏览器:
随时能被改
三十、Service 重新计算
BigDecimal totalAmount =
workOrderItemMapper
.sumAmountByWorkOrderId(
workOrderId
);
三十一、SQL
SELECT
COALESCE(
SUM(amount),
0
)
FROM car_work_order_item
WHERE
work_order_id = ?
AND status = '0';
这里假设:
0 = 正常计费项目
具体:
以项目状态设计为准
三十二、为什么用 COALESCE
如果没有明细:
SUM()
可能返回:
NULL
所以:
COALESCE(SUM(...), 0)
更安全。
三十三、金额计算公式
总金额
=
所有有效工单明细金额之和
应收金额
=
总金额 - 优惠金额
三十四、优惠金额不能超过总金额
if (
discountAmount.compareTo(
totalAmount
) > 0
) {
throw new ServiceException(
"优惠金额不能大于总金额"
);
}
三十五、应收金额
BigDecimal receivableAmount =
totalAmount.subtract(
discountAmount
);
三十六、不要使用 double
错误:
double receivable =
total - discount;
正确:
BigDecimal
三十七、结算创建流程
查询工单
↓
校验 DataScope
↓
校验工单状态
↓
校验未重复结算
↓
查询工单明细
↓
计算 totalAmount
↓
校验 discountAmount
↓
计算 receivableAmount
↓
判断是否需要审批
↓
生成 settlementNo
↓
插入 settlement
↓
更新 workOrder 状态
↓
提交事务
三十八、为什么创建结算还要更新工单
工单原状态:
待结算
生成结算后:
可以变成
结算中
或者继续:
待结算
取决于状态设计。
课程第一版推荐:
生成结算后
工单 status = 已结算处理中 / 已生成结算
为了简化现有状态,可以直接:
工单 status = 5 已结算
但更严谨可以后面再细分。
三十九、当前课程推荐简化
为了不让状态过多:
生成结算单后
work_order.status = 5
结算单自己:
继续处理审批和支付
四十、为什么工单和结算各自有状态
工单:
施工生命周期
结算:
财务生命周期
不要:
把财务状态塞回工单
四十一、结算 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(
dto.getWorkOrderId()
)
> 0
) {
throw new ServiceException(
"当前工单已生成结算单"
);
}
BigDecimal totalAmount =
workOrderItemMapper
.sumAmountByWorkOrderId(
dto.getWorkOrderId()
);
if (
totalAmount == null
||
totalAmount.compareTo(
BigDecimal.ZERO
) <= 0
) {
throw new ServiceException(
"工单没有可结算项目"
);
}
BigDecimal discountAmount =
dto.getDiscountAmount();
if (
discountAmount.compareTo(
totalAmount
) > 0
) {
throw new ServiceException(
"优惠金额不能大于总金额"
);
}
BigDecimal receivableAmount =
totalAmount.subtract(
discountAmount
);
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(
PaymentStatus
.UNPAID
.getCode()
);
settlement.setApprovalStatus(
determineApprovalStatus(
discountAmount,
receivableAmount
)
);
settlement.setStatus(
SettlementStatus
.DRAFT
.getCode()
);
settlement.setRemark(
dto.getRemark()
);
settlementMapper
.insertSettlement(
settlement
);
int rows =
workOrderMapper
.updateStatus(
workOrder.getWorkOrderId(),
WorkOrderStatus
.WAIT_SETTLEMENT
.getCode(),
WorkOrderStatus
.SETTLED
.getCode()
);
if (
rows == 0
) {
throw new ServiceException(
"工单状态已变化,请刷新后重试"
);
}
return settlement
.getSettlementId();
}
四十二、为什么 countByWorkOrderId 还不够
并发:
A count = 0
B count = 0
然后:
同时 INSERT
所以必须保留:
UNIQUE(work_order_id)
四十三、三层重复结算防护
HTTP
@RepeatSubmit
业务
count + 工单状态
数据库
UNIQUE(work_order_id)
四十四、为什么还需要工单状态条件更新
两个请求:
同时读到 WAIT_SETTLEMENT
一个更新成功:
4 → 5
另一个:
WHERE status = 4
更新:
0 行
说明:
状态已被别人抢先处理
四十五、重复结算异常处理
数据库唯一冲突:
DuplicateKeyException
不要直接返回:
Duplicate entry ...
应该转换:
该工单已生成结算单,请勿重复操作
四十六、审批什么时候需要
例如业务规则:
优惠金额超过 500
或:
优惠比例超过 20%
需要审批。
四十七、第一版建议规则
为了课程简单:
discountAmount > 500
→ 需要审批
否则:
无需审批
四十八、为什么现在先写规则方法
private String determineApprovalStatus(
BigDecimal discountAmount,
BigDecimal receivableAmount
) {
}
后面接 Activiti 时:
直接替换/扩展
四十九、示例
private String determineApprovalStatus(
BigDecimal discountAmount,
BigDecimal receivableAmount
) {
if (
discountAmount.compareTo(
new BigDecimal("500.00")
) > 0
) {
return ApprovalStatus
.PENDING
.getCode();
}
return ApprovalStatus
.NOT_REQUIRED
.getCode();
}
五十、为什么不把规则写在 Controller
因为:
审批判断属于业务逻辑
应该:
Service
五十一、审批规则以后可能越来越复杂
例如:
优惠比例
金额
门店
客户等级
特殊套餐
后面可以抽:
ApprovalRuleService
但当前:
不必过度设计
五十二、SettlementStatus 枚举
public enum SettlementStatus {
DRAFT(
"0",
"草稿"
),
SUBMITTED(
"1",
"已提交"
),
COMPLETED(
"2",
"已完成"
),
VOID(
"3",
"已作废"
);
private final String code;
private final String description;
SettlementStatus(
String code,
String description
) {
this.code = code;
this.description = description;
}
public String getCode() {
return code;
}
}
五十三、PaymentStatus
public enum PaymentStatus {
UNPAID("0"),
PART_PAID("1"),
PAID("2");
private final String code;
PaymentStatus(
String code
) {
this.code = code;
}
public String getCode() {
return code;
}
}
五十四、ApprovalStatus
public enum ApprovalStatus {
NOT_REQUIRED("0"),
PENDING("1"),
PROCESSING("2"),
APPROVED("3"),
REJECTED("4");
private final String code;
ApprovalStatus(
String code
) {
this.code = code;
}
public String getCode() {
return code;
}
}
五十五、字典设计
建议:
car_settlement_status
car_payment_status
car_approval_status
五十六、为什么不是所有都用一个字典
这三个:
含义完全不同
所以:
必须分开
五十七、结算修改
只能修改:
草稿
五十八、允许修改什么
课程第一版:
discountAmount
remark
五十九、不能修改
workOrderId
customerId
vehicleId
deptId
totalAmount
这些:
由工单决定
六十、UpdateDTO
public class SettlementUpdateDTO {
@NotNull
private Long settlementId;
@NotNull
@DecimalMin("0.00")
private BigDecimal discountAmount;
private String remark;
}
六十一、修改时为什么重新查 totalAmount
不要直接:
使用 settlement.totalAmount
如果在草稿阶段:
工单明细可能被合法调整
是否允许?
课程建议:
生成结算后工单明细冻结
这样:
totalAmount 不再变化
六十二、冻结工单明细更合理
结算生成以后:
不要再修改工单项目
否则:
结算与工单金额不一致
六十三、因此修改草稿结算
可以:
使用 settlement.totalAmount
重新计算:
receivableAmount
六十四、修改 Service
@Transactional(
rollbackFor = Exception.class
)
public void updateSettlement(
SettlementUpdateDTO dto
) {
CarSettlement settlement =
getAccessibleSettlement(
dto.getSettlementId()
);
if (
settlement == null
) {
throw new ServiceException(
"结算单不存在或无权限"
);
}
if (
!SettlementStatus
.DRAFT
.getCode()
.equals(
settlement.getStatus()
)
) {
throw new ServiceException(
"当前结算单状态不允许修改"
);
}
if (
dto.getDiscountAmount()
.compareTo(
settlement.getTotalAmount()
)
> 0
) {
throw new ServiceException(
"优惠金额不能大于总金额"
);
}
BigDecimal receivableAmount =
settlement
.getTotalAmount()
.subtract(
dto.getDiscountAmount()
);
settlement.setDiscountAmount(
dto.getDiscountAmount()
);
settlement.setReceivableAmount(
receivableAmount
);
settlement.setApprovalStatus(
determineApprovalStatus(
dto.getDiscountAmount(),
receivableAmount
)
);
settlement.setRemark(
dto.getRemark()
);
settlementMapper
.updateSettlement(
settlement
);
}
六十五、提交结算是什么意思
草稿阶段:
可以修改
提交后:
进入正式流程
六十六、提交条件
status = 草稿
金额合法
结算明细完整
如果无需审批
→ 可以直接进入待支付/正式状态
如果需要审批
→ 启动审批流程
六十七、本章还没正式接 Activiti
所以提交时:
先更新状态
Activiti:
下一章/后续章节接入
六十八、提交接口
POST /car/settlement/{id}/submit
六十九、权限
car:settlement:submit
七十、提交 Controller
@PreAuthorize(
"@ss.hasPermi('car:settlement:submit')"
)
@Log(
title = "结算管理",
businessType =
BusinessType.UPDATE
)
@RepeatSubmit(
interval = 3000
)
@PostMapping(
"/{id}/submit"
)
public AjaxResult submit(
@PathVariable Long id
) {
settlementService
.submitSettlement(
id
);
return success();
}
七十一、提交 Service
@Transactional(
rollbackFor = Exception.class
)
public void submitSettlement(
Long settlementId
) {
CarSettlement settlement =
getAccessibleSettlement(
settlementId
);
if (
settlement == null
) {
throw new ServiceException(
"结算单不存在或无权限"
);
}
if (
!SettlementStatus
.DRAFT
.getCode()
.equals(
settlement.getStatus()
)
) {
throw new ServiceException(
"当前结算单不能提交"
);
}
int rows =
settlementMapper
.updateStatus(
settlementId,
SettlementStatus
.DRAFT
.getCode(),
SettlementStatus
.SUBMITTED
.getCode()
);
if (
rows == 0
) {
throw new ServiceException(
"结算单状态已变化,请刷新后重试"
);
}
// 后续章节:
// 如果需要审批
// 启动 Activiti 流程
}
七十二、为什么提交也用 oldStatus 条件
和预约一样:
防并发重复状态转换
七十三、如果无需审批怎么办
后续可以:
直接 approval_status = NOT_REQUIRED
然后:
等待支付
七十四、如果需要审批
后面接 Activiti:
approval_status = PROCESSING
process_instance_id = xxx
七十五、为什么现在预留 process_instance_id
它用于:
业务结算单
和:
Activiti 流程实例
建立关联。
七十六、业务主键和流程实例关系
car_settlement.settlement_id
是:
业务 ID
process_instance_id
是:
流程引擎实例 ID
七十七、不要把 Activiti 表当业务表
Activiti:
负责流程
业务表:
负责结算业务
二者:
通过 ID 关联
七十八、结算查询 DTO
public class SettlementQueryDTO
extends BaseEntity {
private String settlementNo;
private String workOrderNo;
private String customerName;
private String phone;
private String plateNo;
private Long deptId;
private String status;
private String paymentStatus;
private String approvalStatus;
private LocalDateTime beginTime;
private LocalDateTime endTime;
}
七十九、列表 VO
public class SettlementListVO {
private Long settlementId;
private String settlementNo;
private String workOrderNo;
private String customerName;
private String phone;
private String plateNo;
private String deptName;
private BigDecimal totalAmount;
private BigDecimal discountAmount;
private BigDecimal receivableAmount;
private BigDecimal paidAmount;
private String paymentStatus;
private String approvalStatus;
private String status;
private LocalDateTime createTime;
}
八十、为什么列表 VO 要关联多个表
结算页面需要:
客户名
车牌
工单号
门店名
这些:
不是 settlement 表自身字段
八十一、列表 SQL
<select
id="selectSettlementList"
resultType="...SettlementListVO"
>
SELECT
s.settlement_id,
s.settlement_no,
wo.work_order_no,
c.customer_name,
c.phone,
v.plate_no,
d.dept_name,
s.total_amount,
s.discount_amount,
s.receivable_amount,
s.paid_amount,
s.payment_status,
s.approval_status,
s.status,
s.create_time
FROM car_settlement s
JOIN car_work_order wo
ON wo.work_order_id =
s.work_order_id
JOIN car_customer c
ON c.customer_id =
s.customer_id
JOIN car_vehicle v
ON v.vehicle_id =
s.vehicle_id
LEFT JOIN sys_dept d
ON d.dept_id =
s.dept_id
WHERE 1 = 1
<if test="settlementNo != null
and settlementNo != ''">
AND s.settlement_no =
#{settlementNo}
</if>
<if test="workOrderNo != null
and workOrderNo != ''">
AND wo.work_order_no =
#{workOrderNo}
</if>
<if test="customerName != null
and customerName != ''">
AND c.customer_name
LIKE CONCAT(
'%',
#{customerName},
'%'
)
</if>
<if test="phone != null
and phone != ''">
AND c.phone =
#{phone}
</if>
<if test="plateNo != null
and plateNo != ''">
AND v.plate_no
LIKE CONCAT(
'%',
#{plateNo},
'%'
)
</if>
<if test="deptId != null">
AND s.dept_id =
#{deptId}
</if>
<if test="status != null
and status != ''">
AND s.status =
#{status}
</if>
<if test="paymentStatus != null
and paymentStatus != ''">
AND s.payment_status =
#{paymentStatus}
</if>
<if test="approvalStatus != null
and approvalStatus != ''">
AND s.approval_status =
#{approvalStatus}
</if>
${params.dataScope}
ORDER BY
s.create_time DESC
</select>
八十二、DataScope
结算单属于:
门店业务数据
Service:
@DataScope(
deptAlias = "s"
)
public List<SettlementListVO>
selectSettlementList(
SettlementQueryDTO query
) {
return settlementMapper
.selectSettlementList(
query
);
}
八十三、为什么 deptAlias = s
SQL:
s.dept_id
所以:
alias 必须一致
八十四、结算详情也要防水平越权
不要:
selectById(id)
直接返回。
应该:
带 DataScope
或:
检查 dept 归属
八十五、写操作也一样
A 门店用户不能:
修改 B 门店结算单
即使:
有 car:settlement:edit
八十六、功能权限 + 数据权限
完整判断:
@PreAuthorize
决定能不能做“修改结算”
DataScope / Ownership
决定能修改哪张结算单
八十七、详情 VO
public class SettlementDetailVO {
private Long settlementId;
private String settlementNo;
private Long workOrderId;
private String workOrderNo;
private String customerName;
private String phone;
private String plateNo;
private String deptName;
private BigDecimal totalAmount;
private BigDecimal discountAmount;
private BigDecimal receivableAmount;
private BigDecimal paidAmount;
private String paymentStatus;
private String approvalStatus;
private String processInstanceId;
private String status;
private String remark;
private List<SettlementItemVO>
items;
}
八十八、为什么详情里有 items
结算详情必须知道:
钱是怎么组成的
例如:
机油 300
机滤 100
检测 80
总额:
480
八十九、但结算明细完整实现放下一章
本章先:
设计接口和 VO
下一章:
完整实现 car_settlement_item
九十、结算权限设计
car:settlement:list
car:settlement:query
car:settlement:add
car:settlement:edit
car:settlement:submit
car:settlement:approve
car:settlement:pay
car:settlement:void
car:settlement:export
九十一、为什么 approve 单独权限
审批:
不是普通修改
必须:
单独授权
九十二、为什么 pay 也单独权限
支付记录:
属于财务敏感操作
不应该:
普通服务顾问都能操作
九十三、角色建议
服务顾问:
list
query
add
edit
submit
九十四、财务:
list
query
pay
export
九十五、审批人:
list
query
approve
九十六、管理员:
全部
九十七、优惠权限是否需要单独拆
复杂企业系统可能:
discount
单独权限。
当前课程:
暂时放 edit
即可。
九十八、支付逻辑第一版
支付金额:
paidAmount
九十九、支付后状态
如果:
paidAmount = 0
未支付
如果:
0 < paidAmount < receivableAmount
部分支付
如果:
paidAmount >= receivableAmount
已支付
一百、支付状态应该后端计算
不要让前端传:
paymentStatus
一百零一、支付 DTO
public class SettlementPayDTO {
@NotNull
private Long settlementId;
@NotNull
@DecimalMin(
value = "0.01",
message = "支付金额必须大于0"
)
private BigDecimal amount;
}
一百零二、当前项目是否允许多次支付
为了体现部分支付:
可以
一百零三、支付 Service 核心
查询结算
↓
检查可支付
↓
新 paidAmount
=
旧 paidAmount + 本次 amount
↓
不能超过应收
↓
更新 paymentStatus
↓
如果已付清
→ 结算状态可进入完成
一百零四、为什么支付记录最好独立表
只在 settlement 保存:
paidAmount
无法知道:
每次付了多少
什么时候付
谁操作
支付方式
一百零五、课程第一版可以先只做 paidAmount
但更合理的后续扩展:
car_payment_record
一百零六、不要把可选扩展抢在主流程前做
当前重点:
结算单核心逻辑
一百零七、支付前审批限制
如果:
approval_status = PROCESSING
不应该:
允许支付
一百零八、允许支付条件
无需审批
或:
审批通过
一百零九、拒绝状态
如果:
审批拒绝
通常:
退回草稿修改
后续 Activiti 章节:
再定义
一百一十、支付 Service 示例
@Transactional(
rollbackFor = Exception.class
)
public void pay(
SettlementPayDTO dto
) {
CarSettlement settlement =
getAccessibleSettlement(
dto.getSettlementId()
);
if (
settlement == null
) {
throw new ServiceException(
"结算单不存在或无权限"
);
}
boolean canPay =
ApprovalStatus
.NOT_REQUIRED
.getCode()
.equals(
settlement
.getApprovalStatus()
)
||
ApprovalStatus
.APPROVED
.getCode()
.equals(
settlement
.getApprovalStatus()
);
if (
!canPay
) {
throw new ServiceException(
"当前结算单尚未通过审批,不能支付"
);
}
BigDecimal newPaidAmount =
settlement
.getPaidAmount()
.add(
dto.getAmount()
);
if (
newPaidAmount.compareTo(
settlement
.getReceivableAmount()
)
> 0
) {
throw new ServiceException(
"支付金额不能超过应收金额"
);
}
String paymentStatus;
if (
newPaidAmount.compareTo(
settlement
.getReceivableAmount()
)
== 0
) {
paymentStatus =
PaymentStatus
.PAID
.getCode();
} else {
paymentStatus =
PaymentStatus
.PART_PAID
.getCode();
}
int rows =
settlementMapper
.updatePayment(
dto.getSettlementId(),
settlement
.getPaidAmount(),
newPaidAmount,
paymentStatus
);
if (
rows == 0
) {
throw new ServiceException(
"支付状态已变化,请刷新后重试"
);
}
}
一百一十一、为什么 updatePayment 带旧 paidAmount
防止:
两个财务人员
同时支付
一百一十二、SQL
UPDATE car_settlement
SET
paid_amount = #{newPaidAmount},
payment_status = #{paymentStatus},
update_time = NOW()
WHERE
settlement_id = #{settlementId}
AND paid_amount = #{oldPaidAmount};
一百一十三、这是金额并发控制
两个请求:
都读到 paidAmount = 100
A:
更新 100 → 200
成功
B:
WHERE paid_amount = 100
更新:
0 行
一百一十四、为什么金额业务不能只靠前端禁用按钮
因为:
并发来源可能不是同一个浏览器
一百一十五、完整结算状态流
第一版:
草稿
↓
提交
↓
已提交
如果无需审批:
可以支付
如果需要审批:
Activiti 审批
支付完成:
已完成
一百一十六、结算完成条件
推荐:
支付已完成
+
审批已通过/无需审批
一百一十七、完成状态由后端自动更新
不要前端:
手动点“设置已完成”
一百一十八、自动完成
在支付后:
如果 paidAmount == receivableAmount
可以:
status = COMPLETED
一百一十九、为什么
完成应该:
由业务事实触发
而不是:
人工随便改
一百二十、作废
什么时候允许:
草稿
或:
未支付且未审批通过
具体:
按业务
一百二十一、已支付结算单不能随便作废
否则:
账务错误
一百二十二、复杂项目应走退款/冲正
当前课程:
先不扩展
一百二十三、结算页面
前端:
src/views/car/settlement/index.vue
一百二十四、查询区域
结算编号
工单编号
客户姓名
手机号
车牌号
门店
结算状态
支付状态
审批状态
创建时间范围
一百二十五、列表列
结算编号
工单编号
客户
车牌
门店
总金额
优惠金额
应收金额
已付金额
支付状态
审批状态
结算状态
创建时间
操作
一百二十六、金额列格式化
建议:
¥ 1,280.00
前端:
只负责展示
一百二十七、字典
const {
car_settlement_status,
car_payment_status,
car_approval_status
} =
proxy.useDict(
'car_settlement_status',
'car_payment_status',
'car_approval_status'
)
一百二十八、状态展示
<dict-tag
:options="car_settlement_status"
:value="row.status"
/>
一百二十九、支付状态
<dict-tag
:options="car_payment_status"
:value="row.paymentStatus"
/>
一百三十、审批状态
<dict-tag
:options="car_approval_status"
:value="row.approvalStatus"
/>
一百三十一、操作按钮显示规则
草稿:
修改
提交
作废
一百三十二、已提交 + 无需审批
支付
一百三十三、审批中
查看审批
一百三十四、审批通过
支付
一百三十五、已支付完成
详情
打印/导出
一百三十六、按钮必须双重判断
例如支付按钮:
业务状态允许
+
当前用户有 pay 权限
一百三十七、Vue 示例
<el-button
v-if="
row.paymentStatus !== '2'
&&
(
row.approvalStatus === '0'
||
row.approvalStatus === '3'
)
"
v-hasPermi="[
'car:settlement:pay'
]"
link
type="primary"
@click="
handlePay(row)
"
>
支付
</el-button>
一百三十八、后端仍要再次检查
不能:
只依赖 v-if
一百三十九、生成结算入口放哪里
工单列表:
待结算
状态下显示:
生成结算
一百四十、生成结算按钮权限
car:settlement:add
一百四十一、生成结算 Dialog
显示:
工单号
客户
车辆
服务项目总金额
优惠金额
应收金额
备注
一百四十二、前端总金额可以展示后端预览
推荐先调用:
GET /car/settlement/preview/{workOrderId}
一百四十三、为什么 Preview 很好用
前端不需要:
自己计算
后端返回:
工单当前真实总金额
一百四十四、PreviewVO
public class SettlementPreviewVO {
private Long workOrderId;
private String workOrderNo;
private String customerName;
private String plateNo;
private BigDecimal totalAmount;
private List<SettlementPreviewItemVO>
items;
}
一百四十五、Preview 不创建数据
只是:
查看准备结算的数据
一百四十六、用户填写优惠后
前端可以实时显示:
预计应收
但提交时:
后端重新计算
一百四十七、Vue 计算
const receivableAmount =
computed(
() => {
const total =
Number(
form.value.totalAmount
|| 0
)
const discount =
Number(
form.value.discountAmount
|| 0
)
return Math.max(
total - discount,
0
)
}
)
一百四十八、为什么前端 Number 这里只是展示
真实数据库金额:
后端 BigDecimal
一百四十九、结算 API
import request from '@/utils/request'
export function listSettlement(
query
) {
return request({
url:
'/car/settlement/list',
method:
'get',
params:
query
})
}
export function getSettlement(
id
) {
return request({
url:
`/car/settlement/${id}`,
method:
'get'
})
}
export function previewSettlement(
workOrderId
) {
return request({
url:
`/car/settlement/preview/${workOrderId}`,
method:
'get'
})
}
export function addSettlement(
data
) {
return request({
url:
'/car/settlement',
method:
'post',
data
})
}
export function updateSettlement(
data
) {
return request({
url:
'/car/settlement',
method:
'put',
data
})
}
一百五十、提交 API
export function submitSettlement(
id
) {
return request({
url:
`/car/settlement/${id}/submit`,
method:
'post'
})
}
一百五十一、支付 API
export function paySettlement(
id,
amount
) {
return request({
url:
`/car/settlement/${id}/pay`,
method:
'post',
data: {
amount
}
})
}
一百五十二、Controller 路径
/car/settlement
一百五十三、列表接口
@PreAuthorize(
"@ss.hasPermi('car:settlement:list')"
)
@GetMapping("/list")
public TableDataInfo list(
SettlementQueryDTO query
) {
startPage();
List<SettlementListVO> list =
settlementService
.selectSettlementList(
query
);
return getDataTable(
list
);
}
一百五十四、预览接口
@PreAuthorize(
"@ss.hasPermi('car:settlement:add')"
)
@GetMapping(
"/preview/{workOrderId}"
)
public AjaxResult preview(
@PathVariable
Long workOrderId
) {
return success(
settlementService
.preview(
workOrderId
)
);
}
一百五十五、创建接口
@PreAuthorize(
"@ss.hasPermi('car:settlement:add')"
)
@Log(
title = "结算管理",
businessType =
BusinessType.INSERT
)
@RepeatSubmit(
interval = 3000
)
@PostMapping
public AjaxResult add(
@Valid
@RequestBody
SettlementCreateDTO dto
) {
return success(
settlementService
.createSettlement(
dto
)
);
}
一百五十六、为什么预览和创建都要权限
预览本质上:
可以看到工单金额
也属于:
敏感业务数据
一百五十七、数据权限不能只做列表
Preview:
也必须检查 workOrder
属于当前可访问门店
一百五十八、支付接口
@PreAuthorize(
"@ss.hasPermi('car:settlement:pay')"
)
@Log(
title = "结算支付",
businessType =
BusinessType.UPDATE
)
@RepeatSubmit(
interval = 3000
)
@PostMapping(
"/{id}/pay"
)
public AjaxResult pay(
@PathVariable Long id,
@Valid
@RequestBody
SettlementPayDTO dto
) {
dto.setSettlementId(
id
);
settlementService
.pay(
dto
);
return success();
}
一百五十九、为什么支付接口不能普通 edit
因为:
支付是独立业务动作
需要:
专门权限
金额校验
并发控制
日志
一百六十、支付行为是否需要 @RepeatSubmit
可以。
防:
快速双击支付
但仍然不能:
只靠这个
一百六十一、支付必须数据库条件更新
因为财务场景:
并发更敏感
一百六十二、DataScope 与财务角色
财务如果:
总部财务
可以:
全部数据
门店财务:
本门店
这正好:
使用若依 role.dataScope
一百六十三、为什么不在代码写 financeRole
错误:
if (
roleName.equals(
"总部财务"
)
) {
}
正确:
角色 dataScope 配置
一百六十四、结算导出
权限:
car:settlement:export
一百六十五、导出字段
建议:
结算编号
工单编号
客户
车牌
门店
总金额
优惠金额
应收金额
已付金额
支付状态
审批状态
结算状态
创建时间
一百六十六、ExportVO
public class SettlementExportVO {
@Excel(
name = "结算编号"
)
private String settlementNo;
@Excel(
name = "工单编号"
)
private String workOrderNo;
@Excel(
name = "客户姓名"
)
private String customerName;
@Excel(
name = "车牌号"
)
private String plateNo;
@Excel(
name = "总金额"
)
private BigDecimal totalAmount;
@Excel(
name = "优惠金额"
)
private BigDecimal discountAmount;
@Excel(
name = "应收金额"
)
private BigDecimal receivableAmount;
@Excel(
name = "已付金额"
)
private BigDecimal paidAmount;
@Excel(
name = "支付状态",
dictType =
"car_payment_status"
)
private String paymentStatus;
@Excel(
name = "审批状态",
dictType =
"car_approval_status"
)
private String approvalStatus;
@Excel(
name = "结算状态",
dictType =
"car_settlement_status"
)
private String status;
}
一百六十七、导出必须复用 DataScope
门店 A:
只能导出 A 门店结算
一百六十八、为什么结算数据比预约更敏感
它包含:
金额
优惠
支付
审批
属于:
财务业务
所以权限更严格。
一百六十九、结算单是否允许物理删除
建议:
不允许
一百七十、为什么
结算单属于:
财务历史
应该:
作废
而不是:
DELETE
一百七十一、草稿结算可不可以删
课程也建议:
不物理删
统一:
VOID
更容易审计。
一百七十二、作废接口
POST /car/settlement/{id}/void
一百七十三、作废权限
car:settlement:void
一百七十四、作废前条件
例如:
未支付
未完成审批
当前状态不是完成
一百七十五、作废后工单怎么办
如果结算作废:
工单是否重新回到待结算
课程推荐:
是
这样:
可以重新生成一张正确结算
一百七十六、这必须事务
settlement → VOID
+
work_order → WAIT_SETTLEMENT
同一个事务。
一百七十七、为什么 work_order_id UNIQUE 会冲突
旧结算单虽然:
VOID
但记录还在。
如果重新生成:
同一个 work_order_id
会违反:
UNIQUE(work_order_id)
一百七十八、这说明什么
数据库约束必须:
与作废后重生成规则匹配
一百七十九、两种方案
方案 A:
作废后不允许重新生成
只修改原结算。
一百八十、方案 B
允许重新生成:
就不能简单 UNIQUE(work_order_id)
可能需要:
version / active flag
一百八十一、课程第一版推荐方案 A
最简单:
一张工单始终只有一张结算单
错误时:
草稿阶段修改
提交后:
不能随意重建
这样:
UNIQUE(work_order_id)
保持合理。
一百八十二、所以本章不提供物理删除和重建
这是为了:
降低财务数据复杂度
一百八十三、工单完成后冻结项目
一旦生成结算:
工单项目不能再修改
一百八十四、为什么
否则:
工单金额
和
结算金额
会不一致。
一百八十五、修改工单项目时要检查
是否已存在 settlement
如果有:
拒绝修改
一百八十六、这就是跨模块约束
工单模块:
需要知道结算状态
一百八十七、但不要形成循环依赖
可以:
通过 Mapper 查询
或者:
定义领域 Service
课程第一版:
简单调用即可
一百八十八、金额审计
建议关键字段:
totalAmount
discountAmount
receivableAmount
paidAmount
都:
保存数据库
一百八十九、为什么 receivableAmount 明明能算还要存
它属于:
最终结算快照
保存后:
审计更方便
一百九十、金额快照和冗余字段区别
这里是:
有业务意义的冗余
不是:
无脑重复
一百九十一、金额公式必须后端统一
建议工具:
private BigDecimal calculateReceivable(
BigDecimal total,
BigDecimal discount
) {
...
}
避免:
多个方法各自算
一百九十二、BigDecimal 常量
不要频繁:
new BigDecimal(0.1)
错误风险。
推荐:
new BigDecimal("0.10")
或:
BigDecimal.valueOf(0.1)
金额常量优先:
字符串构造
一百九十三、为什么 new BigDecimal(0.1) 不推荐
因为:
double 0.1
本身无法精确表示
会把误差:
带入 BigDecimal
一百九十四、支付金额精度
数据库:
DECIMAL(12,2)
后端 DTO:
最多两位小数
可以增加:
@Digits(
integer = 10,
fraction = 2
)
一百九十五、结算优惠也一样
@Digits(
integer = 10,
fraction = 2
)
一百九十六、不要让 0.001 元进入系统
除非:
业务支持三位小数
当前:
两位
一百九十七、结算编号重复 Bug
检查:
Redis seq Key
数据库 UNIQUE
多环境是否共用 DB
一百九十八、常见 Bug 1:同工单生成两张结算
检查:
@RepeatSubmit
countByWorkOrderId
work_order status old-value update
UNIQUE(work_order_id)
一百九十九、常见 Bug 2:优惠金额大于总额
说明:
后端没有校验
二百、常见 Bug 3:前端改 totalAmount 成 1 元成功
说明:
后端信了前端金额
正确:
后端从工单明细 SUM
二百零一、常见 Bug 4:修改服务项目价格后结算金额变化
说明:
结算使用当前标准价
错误。
应该:
工单明细快照价
二百零二、常见 Bug 5:结算生成后工单还能改项目
需要:
工单修改前检查 settlement
二百零三、常见 Bug 6:A 门店能查 B 门店结算
检查:
@DataScope
deptAlias
${params.dataScope}
二百零四、常见 Bug 7:列表限制了,详情没限制
这是:
水平越权
二百零五、常见 Bug 8:财务双击支付金额翻倍
检查:
@RepeatSubmit
oldPaidAmount 条件更新
二百零六、常见 Bug 9:支付金额超过应收
后端必须:
compareTo
二百零七、常见 Bug 10:approvalStatus 前端可改
不能:
让普通 edit DTO
包含:
approvalStatus
二百零八、常见 Bug 11:processInstanceId 前端传入
错误。
它应该:
Activiti 启动流程后
由后端保存
二百零九、常见 Bug 12:事务中异常被吞
如果:
insert settlement 成功
update workOrder 失败
必须:
全部回滚
二百一十、常见 Bug 13:页面显示 999.999999
检查:
数据库类型
BigDecimal
前端格式化
二百一十一、常见 Bug 14:提交两次
使用:
WHERE status = DRAFT
第二次:
rows = 0
二百一十二、常见 Bug 15:审批中还能修改优惠
后端应:
只允许草稿修改
二百一十三、为什么状态规则一定放 Service
Controller:
只负责 HTTP
Service:
负责业务
二百一十四、Service 方法建议
preview
createSettlement
updateSettlement
submitSettlement
pay
getSettlementDetail
selectSettlementList
二百一十五、Mapper 方法建议
selectSettlementList
selectSettlementById
countByWorkOrderId
insertSettlement
updateSettlement
updateStatus
updatePayment
二百一十六、不要一个 updateById 解决所有动作
因为:
不同动作有不同约束
二百一十七、业务动作显式方法更安全
submitSettlement
paySettlement
approveSettlement
比:
updateSettlementStatus
更清晰。
二百一十八、Vue 生成结算 Dialog
打开:
previewSettlement(workOrderId)
拿:
工单信息
项目总金额
二百一十九、优惠输入
<el-input-number
v-model="
form.discountAmount
"
:min="0"
:max="
form.totalAmount
"
:precision="2"
/>
二百二十、为什么前端 max 只是体验
攻击者:
仍可绕过
后端:
必须 compareTo
二百二十一、应收金额展示
<el-descriptions-item
label="应收金额"
>
¥ {{ receivableAmount }}
</el-descriptions-item>
二百二十二、支付 Dialog
输入:
本次支付金额
同时显示:
应收
已付
剩余应付
二百二十三、剩余应付
receivableAmount - paidAmount
二百二十四、前端 max
remainingAmount
二百二十五、后端仍二次计算
不能:
信 remainingAmount
二百二十六、完整安全链
Vue
↓
v-hasPermi
↓
HTTP
↓
@PreAuthorize
↓
DataScope
↓
Status Check
↓
Amount Check
↓
Conditional UPDATE
↓
Database
二百二十七、完整生成结算链
WorkOrder WAIT_SETTLEMENT
↓
Preview
↓
Create Settlement
↓
SUM WorkOrderItem
↓
Discount
↓
Receivable
↓
Approval Rule
↓
INSERT Settlement
↓
WorkOrder State Update
↓
Commit
二百二十八、完整支付链
Settlement
↓
Check Approval
↓
Check Current Paid Amount
↓
Add Payment
↓
Compare Receivable
↓
Update Payment Status
↓
If Fully Paid
→ Settlement Completed
二百二十九、测试用例 1
工单状态:
WAIT_SETTLEMENT
且有明细。
生成结算:
成功
二百三十、测试用例 2
工单:
施工中
生成结算:
拒绝
二百三十一、测试用例 3
工单:
没有有效明细
生成:
拒绝
二百三十二、测试用例 4
同工单生成两次:
第二次失败
二百三十三、测试用例 5
优惠:
100
总额:
1000
应收:
900
二百三十四、测试用例 6
优惠:
1200
总额:
1000
预期:
拒绝
二百三十五、测试用例 7
优惠:
600
审批规则:
> 500
预期:
approvalStatus = 待审批
二百三十六、测试用例 8
优惠:
100
预期:
无需审批
二百三十七、测试用例 9
草稿修改优惠:
成功
二百三十八、测试用例 10
已提交修改优惠:
拒绝
二百三十九、测试用例 11
提交两次:
第二次失败
二百四十、测试用例 12
审批中支付:
拒绝
二百四十一、测试用例 13
无需审批支付:
允许
二百四十二、测试用例 14
应收 1000:
第一次支付 300
状态:
部分支付
二百四十三、测试用例 15
再支付:
700
状态:
已支付
二百四十四、测试用例 16
剩余 700,
尝试支付:
800
预期:
拒绝
二百四十五、测试用例 17
两个请求:
同时支付 500
应该:
一个成功
另一个状态冲突
二百四十六、测试用例 18
A 门店用户:
访问 B 店结算 ID
预期:
拒绝
二百四十七、测试用例 19
无 pay 权限:
POST /pay
预期:
403
二百四十八、测试用例 20
导出:
A 门店
只能:
导出 A 门店数据
二百四十九、数据库检查
重复结算:
SELECT
work_order_id,
COUNT(*)
FROM car_settlement
GROUP BY work_order_id
HAVING COUNT(*) > 1;
应该:
0 行
二百五十、金额一致性检查
SELECT
settlement_id,
total_amount,
discount_amount,
receivable_amount
FROM car_settlement
WHERE
receivable_amount
<>
total_amount
-
discount_amount;
应该:
0 行
二百五十一、支付超额检查
SELECT *
FROM car_settlement
WHERE
paid_amount
>
receivable_amount;
应该:
0 行
二百五十二、支付状态一致性
例如:
SELECT *
FROM car_settlement
WHERE
payment_status = '2'
AND paid_amount
<>
receivable_amount;
应该:
0 行
二百五十三、Git 提交建议
结算表与实体:
git commit -m "feat: add settlement domain model"
二百五十四、生成结算:
git commit -m "feat: generate settlement from work order"
二百五十五、结算状态:
git commit -m "feat: add settlement lifecycle"
二百五十六、支付:
git commit -m "feat: add settlement payment handling"
二百五十七、Vue 页面:
git commit -m "feat: add settlement management UI"
二百五十八、Cursor 提示词:结算分析
当前项目基于 RuoYi-Vue springboot3 + RuoYi-Vue3,
业务模块为 ruoyi-car。
请只分析当前工单和结算相关真实代码,不修改。
检查:
1. workOrder 什么状态允许生成结算
2. car_settlement 是否 work_order_id 唯一
3. totalAmount 是否来自工单明细后端汇总
4. discountAmount 是否校验 <= totalAmount
5. receivableAmount 是否由后端计算
6. customerId/vehicleId/deptId 是否从工单获取
7. 是否存在重复结算风险
8. 是否有门店 DataScope
9. 是否预留 processInstanceId
10. 是否允许前端修改 approvalStatus/paymentStatus
二百五十九、Cursor 提示词:生成结算
请实现从 car_work_order 生成 car_settlement。
要求:
1. 只有 WAIT_SETTLEMENT 工单允许
2. 一张工单只能一张结算
3. totalAmount 从 car_work_order_item SUM
4. 不信任前端 totalAmount
5. discountAmount >= 0 且 <= totalAmount
6. receivableAmount 后端计算
7. customerId/vehicleId/deptId 从工单复制
8. 生成 settlementNo
9. 创建结算和更新工单状态同一事务
10. 使用条件状态 UPDATE
11. 复用 @PreAuthorize、@Log、@RepeatSubmit
12. 不修改若依认证核心
二百六十、Cursor 提示词:支付
请实现结算支付业务。
要求:
1. 使用 BigDecimal
2. 本次支付金额 > 0
3. 累计 paidAmount 不得超过 receivableAmount
4. approvalStatus 只有无需审批或审批通过才能支付
5. 部分支付更新为 PART_PAID
6. 全额支付更新为 PAID
7. paidAmount 更新使用旧值条件防并发覆盖
8. 已付清后自动更新结算完成状态
9. 使用 @Transactional
10. 支付接口单独 car:settlement:pay 权限
二百六十一、Cursor 提示词:Vue3 结算页
请基于当前 RuoYi-Vue3 风格实现 car/settlement/index.vue。
要求:
1. 查询:结算号、工单号、客户、车牌、门店、三个状态
2. useDict 显示 settlement/payment/approval 三种状态
3. 金额统一两位小数
4. 草稿显示修改、提交
5. 审批通过或无需审批且未付清时显示支付
6. v-hasPermi 做按钮权限
7. 生成结算时先调用 preview 接口
8. 前端应收金额仅用于展示
9. 后端仍重新计算金额
10. 不允许前端直接修改 status、paymentStatus、approvalStatus
二百六十二、Cursor 提示词:安全审查
请审查结算模块安全与一致性。
重点:
1. totalAmount 是否前端可控
2. receivableAmount 是否前端可控
3. approvalStatus 是否前端可控
4. paymentStatus 是否前端可控
5. 一张工单能否重复结算
6. 支付是否可能并发超额
7. 列表/详情/支付是否都做门店数据权限
8. 是否存在已提交后仍可修改优惠
9. 是否存在工单生成结算后仍可改明细
10. 事务是否存在 this.xxx 自调用或异常吞掉
二百六十三、IDEA 调试断点
重点:
createSettlement
updateSettlement
submitSettlement
pay
determineApprovalStatus
updateStatus
updatePayment
二百六十四、调试生成结算
观察:
workOrder.status
totalAmount
discountAmount
receivableAmount
approvalStatus
insertSettlement
workOrder update rows
二百六十五、调试支付
观察:
oldPaidAmount
request amount
newPaidAmount
receivableAmount
paymentStatus
update rows
二百六十六、为什么结算模块是企业项目分水岭
预约:
偏流程
服务项目:
偏主数据
结算:
开始进入金额 + 状态 + 并发 + 审批
难度:
明显提高
二百六十七、本章最重要的工程思维
金额不能信前端
状态不能信前端
权限不能信前端
业务归属不能信前端
并发不能只靠按钮禁用
事务不能只看有没有注解
数据库 UNIQUE 仍然重要
二百六十八、面试题 1:为什么结算单和支付状态分开
答:
结算单表示应收业务事实,
支付状态表示客户实际付款情况。
一张已提交结算单可能仍未支付,
也可能部分支付,
因此二者是不同生命周期,
应该使用独立字段管理。
二百六十九、面试题 2:为什么审批状态也单独保存
答:
审批只表示结算是否经过审批流程,
与支付和结算业务状态不是同一维度。
拆开后可以清晰表达:
已提交 + 审批中 + 未支付
或者
已提交 + 审批通过 + 部分支付。
二百七十、面试题 3:为什么 totalAmount 不让前端传
答:
前端请求可以被篡改。
结算总金额应该根据数据库中的工单明细重新汇总,
只有后端掌握真实计费数据。
前端传入的金额最多只能作为展示参考,
不能作为最终结算依据。
二百七十一、面试题 4:为什么 receivableAmount 也由后端算
答:
应收金额是 totalAmount - discountAmount 的业务结果。
如果让前端直接传 receivableAmount,
用户可以篡改最终应收。
因此后端应统一校验优惠金额并计算应收金额。
二百七十二、面试题 5:如何防止同一工单重复生成结算单
答:
可以多层防护:
HTTP 层使用 @RepeatSubmit 减少快速重复提交;
业务层检查工单状态和已有结算;
状态更新使用 oldStatus 条件;
数据库对 work_order_id 建唯一约束作为最终兜底。
二百七十三、面试题 6:为什么支付更新要带旧 paidAmount
答:
两个并发支付请求可能同时读取相同的已付金额。
更新 SQL 加上旧 paidAmount 条件后,
第一个请求成功更新,
第二个请求因为旧值已经变化而更新 0 行,
从而避免并发覆盖和超额支付。
二百七十四、面试题 7:为什么结算生成后冻结工单明细
答:
结算总金额已经基于工单明细确定。
如果生成结算后还能修改工单项目,
工单金额和结算金额会不一致。
因此生成结算后应该冻结相关工单明细,
需要修改时应走明确的回退或作废流程。
二百七十五、面试题 8:为什么结算使用 DataScope
答:
结算单属于具体门店的业务和财务数据。
保存 dept_id 后可以复用若依 @DataScope,
让不同门店或不同角色只查看其允许范围内的结算数据。
不仅列表需要限制,
详情、支付、审批和导出同样需要防止水平越权。
二百七十六、面试题 9:为什么财务单据不建议物理删除
答:
结算单属于业务和财务历史,
通常需要审计和追溯。
物理删除会丢失历史记录,
更合理的方式是通过作废、冲正等业务状态保留记录。
二百七十七、面试题 10:为什么 processInstanceId 要放业务表
答:
Activiti 负责流程运行,
car_settlement 负责业务数据。
业务表保存 processInstanceId,
可以把结算单和对应流程实例关联起来,
方便查询审批进度、待办、历史和流程图。
二百七十八、结算模块知识树
Settlement
│
├─ Source
│ ├─ WorkOrder
│ └─ WorkOrderItem
│
├─ Amount
│ ├─ totalAmount
│ ├─ discountAmount
│ ├─ receivableAmount
│ └─ paidAmount
│
├─ Status
│ ├─ settlementStatus
│ ├─ paymentStatus
│ └─ approvalStatus
│
├─ Consistency
│ ├─ @Transactional
│ ├─ @RepeatSubmit
│ ├─ oldStatus
│ ├─ oldPaidAmount
│ └─ UNIQUE(workOrderId)
│
├─ Security
│ ├─ @PreAuthorize
│ ├─ @DataScope
│ └─ Horizontal Authorization
│
├─ Vue
│ ├─ List
│ ├─ Preview
│ ├─ Edit
│ ├─ Submit
│ └─ Pay
│
└─ Workflow
├─ approvalStatus
└─ processInstanceId
二百七十九、完整结算生成总图
WorkOrder
WAIT_SETTLEMENT
↓
Preview
↓
SettlementCreateDTO
↓
Service
↓
DataScope
↓
Check WorkOrder
↓
Check Existing Settlement
↓
SUM WorkOrderItem
↓
Validate Discount
↓
Calculate Receivable
↓
Determine Approval
↓
Generate SettlementNo
↓
INSERT Settlement
↓
Conditional UPDATE WorkOrder
↓
Commit
二百八十、完整支付总图
Settlement
↓
Check Permission
↓
Check DataScope
↓
Check Approval
↓
Check Remaining Amount
↓
newPaid =
oldPaid + amount
↓
Compare Receivable
↓
Conditional UPDATE paidAmount
↓
Update paymentStatus
↓
If Paid
→ Settlement Completed
二百八十一、本章最终验收
你应该能够独立完成:
1. 从工单预览结算
2. 从待结算工单创建结算单
3. 后端汇总工单明细金额
4. BigDecimal 金额计算
5. 优惠金额校验
6. 应收金额计算
7. 自动判断是否需要审批
8. 结算编号生成
9. 防止重复结算
10. 工单状态与结算创建事务
11. 草稿修改
12. 提交结算
13. 支付状态处理
14. 部分支付
15. 全额支付
16. 支付并发控制
17. DataScope
18. 详情水平越权防护
19. Vue3 结算列表
20. 结算 Preview Dialog
21. 支付 Dialog
22. 状态字典
23. 权限按钮
24. Excel 导出
25. processInstanceId 预留
二百八十二、本章最重要的工程原则
1. 结算、支付、审批是三个不同状态维度
2. totalAmount 必须后端根据工单明细计算
3. receivableAmount 必须后端计算
4. discountAmount 必须 <= totalAmount
5. 金额全部 BigDecimal + DECIMAL
6. 一张工单只能生成一张结算单
7. @RepeatSubmit 不是最终幂等保证
8. UNIQUE(work_order_id) 是重要数据库兜底
9. 状态更新使用 oldStatus 条件
10. 支付更新使用 oldPaidAmount 条件
11. 结算生成后应冻结工单计费明细
12. 财务业务不建议物理删除
13. 列表、详情、支付、审批、导出都要考虑 DataScope
14. approvalStatus / paymentStatus 不能由普通前端 edit 直接修改
15. processInstanceId 只能由后端流程引擎写入
二百八十三、下一篇
按照课程表,下一篇进入:
《淘车湾项目实战(五):结算单明细分析与前后端实现》
下一章会重点处理:
car_settlement_item
工单项目到结算明细快照
source_type
source_id
服务单项来源
套餐来源
原价
优惠
最终金额
结算明细生成
批量插入
主表 + 明细事务
前端动态明细表
后端重新计算
禁止前端伪造明细金额
结算明细详情
结算打印/展示
为 Activiti 审批提供完整业务数据