淘车湾项目实战五_结算单明细分析与前后端实现

O泡李华 7

淘车湾项目实战(五):结算单明细分析与前后端实现

本章位置:第三阶段 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 用户任务结合