淘车湾项目实战三_服务单项与套餐模块分析及实现

O泡李华 4

淘车湾项目实战(三):服务单项与套餐模块分析及实现

本章位置:第三阶段 Java 企业项目 + AI 助手
对应课程:淘车湾项目实战——服务单项与套餐模块分析及实现
前置知识:淘车湾项目实战(一)(二)、若依脚手架、SpringBoot、MyBatis、事务、Vue3、Element Plus
后续衔接:服务工单、结算单、结算明细、Activiti 审批
学习目标:完整实现服务单项与服务套餐模块,掌握单项 CRUD、启停状态、BigDecimal 金额、套餐与服务项目多对多关系、关联表批量维护、事务、删除校验、详情查询、Vue3 套餐编辑器和权限控制。


一、本章最终要做出什么

本章完成后,系统应支持:

服务项目管理

服务套餐管理

套餐内服务项目维护

启用 / 停用

价格维护

套餐详情

套餐新增

套餐修改

套餐删除校验

服务项目删除校验

权限控制

字典显示

Excel 导出

二、为什么服务单项和套餐要分开

服务单项:

一次具体服务

例如:

更换机油

轮胎换位

刹车系统检查

空调清洗

套餐:

多个服务单项的组合

例如:

基础保养套餐
=
更换机油
+
更换机滤
+
车辆基础检测

三、为什么不能只用一个表

错误设计:

service

一张表同时保存:

单项
套餐
套餐明细

后面会变成:

大量 type 判断
字段大量 NULL
关系混乱

四、正确结构

car_service_item

保存:

单个服务项目
car_service_package

保存:

套餐基本信息
car_service_package_item

保存:

套餐与服务项目关系

五、这是典型多对多

一个套餐:

包含多个服务项目

一个服务项目:

也可以出现在多个套餐中

所以:

Package N : M ServiceItem

六、关系图

car_service_package
        │
        │ 1
        │
        └────< car_service_package_item >────┐
                                             │
                                             │ N
                                             ↓
                                  car_service_item

七、为什么不能存 item_ids

错误:

item_ids = "1,2,5,8"

会带来:

无法正常 JOIN
无法做唯一约束
无法高效检查引用
无法保存 quantity / sortOrder
SQL 难维护

正确做法:

使用中间关联表

八、服务项目表

CREATE TABLE car_service_item (
    service_item_id BIGINT NOT NULL AUTO_INCREMENT,
    item_code VARCHAR(50) NOT NULL,
    item_name VARCHAR(100) NOT NULL,
    category VARCHAR(30),
    standard_price DECIMAL(10,2) NOT NULL DEFAULT 0.00,
    estimated_minutes INT,
    status CHAR(1) DEFAULT '0',
    remark VARCHAR(500),
    create_by VARCHAR(64),
    create_time DATETIME,
    update_by VARCHAR(64),
    update_time DATETIME,
    PRIMARY KEY (service_item_id),
    UNIQUE KEY uk_service_item_code (item_code),
    KEY idx_service_item_status (status)
);

九、服务项目核心字段

service_item_id
主键

item_code
业务编码

item_name
服务名称

category
服务分类

standard_price
标准价格

estimated_minutes
预计工时

status
启用/停用

十、为什么 item_code 要唯一

例如:

SVC-OIL-001
SVC-TIRE-001

业务编码用于:

搜索
导出
业务识别
系统对接

所以:

数据库 UNIQUE

必须保留。


十一、服务分类

课程第一版推荐使用若依数据字典:

car_service_category

例如:

MAINTENANCE → 保养
REPAIR      → 维修
CLEANING    → 清洁
INSPECTION  → 检测

如果以后分类需要:

多级树
图片
排序
复杂属性

再独立建表。


十二、金额类型

数据库:

DECIMAL(10,2)

Java:

BigDecimal

不要:

double
float

十三、BigDecimal 数值比较

推荐:

if (
    price.compareTo(
        BigDecimal.ZERO
    ) < 0
) {
    throw new ServiceException(
        "价格不能小于0"
    );
}

金额数值比较时不要简单依赖:

equals()

因为:

10.0
和
10.00

scale 不同。


十四、服务项目状态

服务项目状态适合复用:

sys_normal_disable

例如:

0 正常
1 停用

这里没有必要再设计复杂状态机。


十五、服务项目 CreateDTO

public class ServiceItemCreateDTO {

    @NotBlank(
        message = "项目编码不能为空"
    )
    @Size(max = 50)
    private String itemCode;

    @NotBlank(
        message = "项目名称不能为空"
    )
    @Size(max = 100)
    private String itemName;

    @NotBlank(
        message = "服务分类不能为空"
    )
    private String category;

    @NotNull(
        message = "标准价格不能为空"
    )
    @DecimalMin(
        value = "0.00",
        message = "价格不能小于0"
    )
    private BigDecimal standardPrice;

    @Min(
        value = 0,
        message = "预计时长不能小于0"
    )
    private Integer estimatedMinutes;

    private String remark;
}

十六、为什么价格允许 0

例如:

免费检测

可以:

standard_price = 0

所以:

>= 0

即可。


十七、修改 DTO

public class ServiceItemUpdateDTO {

    @NotNull
    private Long serviceItemId;

    @NotBlank
    private String itemName;

    @NotBlank
    private String category;

    @NotNull
    @DecimalMin("0.00")
    private BigDecimal standardPrice;

    @Min(0)
    private Integer estimatedMinutes;

    private String remark;
}

第一版建议:

itemCode 创建后不允许普通修改

十八、为什么业务编码创建后尽量不改

业务编码通常会被:

Excel
日志
外部接口
业务人员

长期引用。

因此:

编码稳定

更好维护。


十九、服务项目接口

建议:

GET    /car/serviceItem/list
GET    /car/serviceItem/{id}
POST   /car/serviceItem
PUT    /car/serviceItem
POST   /car/serviceItem/{id}/status
DELETE /car/serviceItem/{ids}
POST   /car/serviceItem/export

二十、权限码

car:serviceItem:list
car:serviceItem:query
car:serviceItem:add
car:serviceItem:edit
car:serviceItem:remove
car:serviceItem:export
car:serviceItem:changeStatus

二十一、为什么 changeStatus 单独权限

修改价格:

配置权限

启停:

上下架权限

可以:

分别授权

二十二、列表查询 DTO

public class ServiceItemQueryDTO
        extends BaseEntity {

    private String itemCode;

    private String itemName;

    private String category;

    private String status;

    private BigDecimal minPrice;

    private BigDecimal maxPrice;
}

二十三、列表 SQL

<select
    id="selectServiceItemList"
    resultType="com.ruoyi.car.domain.CarServiceItem"
>
    SELECT
        service_item_id,
        item_code,
        item_name,
        category,
        standard_price,
        estimated_minutes,
        status,
        remark,
        create_by,
        create_time,
        update_by,
        update_time
    FROM car_service_item
    WHERE 1 = 1

    <if test="itemCode != null and itemCode != ''">
        AND item_code = #{itemCode}
    </if>

    <if test="itemName != null and itemName != ''">
        AND item_name
            LIKE CONCAT('%', #{itemName}, '%')
    </if>

    <if test="category != null and category != ''">
        AND category = #{category}
    </if>

    <if test="status != null and status != ''">
        AND status = #{status}
    </if>

    <if test="minPrice != null">
        AND standard_price
            <![CDATA[ >= ]]>
            #{minPrice}
    </if>

    <if test="maxPrice != null">
        AND standard_price
            <![CDATA[ <= ]]>
            #{maxPrice}
    </if>

    ORDER BY service_item_id DESC
</select>

二十四、为什么服务项目第一版不做 DataScope

当前设计:

服务项目 = 公司统一服务目录

所以:

所有门店共用

不按 dept_id 隔离。

如果以后不同门店有不同价格,可以扩展:

car_store_service_price

二十五、新增服务项目 Service

@Transactional(
    rollbackFor = Exception.class
)
public Long createServiceItem(
        ServiceItemCreateDTO dto
) {

    if (
        serviceItemMapper
            .countByItemCode(
                dto.getItemCode()
            )
        > 0
    ) {
        throw new ServiceException(
            "服务项目编码已存在"
        );
    }

    CarServiceItem item =
            new CarServiceItem();

    item.setItemCode(
        dto.getItemCode()
    );

    item.setItemName(
        dto.getItemName()
    );

    item.setCategory(
        dto.getCategory()
    );

    item.setStandardPrice(
        dto.getStandardPrice()
    );

    item.setEstimatedMinutes(
        dto.getEstimatedMinutes()
    );

    item.setStatus("0");

    item.setRemark(
        dto.getRemark()
    );

    serviceItemMapper
        .insertServiceItem(
            item
        );

    return item.getServiceItemId();
}

二十六、为什么应用层查重还要 UNIQUE

两个并发线程:

A count = 0
B count = 0

然后:

同时 INSERT

应用层检查并不能最终保证唯一。

数据库:

UNIQUE(item_code)

才是最终兜底。


二十七、服务项目修改与历史价格

服务项目价格:

允许修改

但历史工单:

绝对不能跟着变化

后面工单明细必须保存:

item_name
unit_price

作为快照。


二十八、主数据与历史事实

必须区分:

car_service_item
当前服务配置

car_work_order_item
实际发生业务时的快照

car_settlement_item
最终结算时的计费快照

二十九、服务项目停用

停用意味着:

不能再进入新的业务

但:

历史记录仍保留

三十、为什么主数据优先停用而不是删除

如果服务项目已经被:

套餐
工单
结算

引用,直接删除容易:

破坏历史关系

所以企业系统常见:

停用 > 删除

三十一、StatusDTO

public class ServiceItemStatusDTO {

    @NotNull
    private Long serviceItemId;

    @Pattern(
        regexp = "0|1"
    )
    private String status;
}

三十二、停用前检查启用套餐引用

SQL:

SELECT COUNT(*)
FROM car_service_package_item pi
JOIN car_service_package p
  ON p.package_id = pi.package_id
WHERE
    pi.service_item_id = ?
    AND p.status = '0';

如果:

count > 0

提示:

该服务项目正被启用套餐使用,请先调整相关套餐

三十三、删除服务项目前检查

至少:

套餐是否引用
历史工单是否引用

如果被引用:

拒绝物理删除

建议用户:

停用

三十四、服务套餐表

CREATE TABLE car_service_package (
    package_id BIGINT NOT NULL AUTO_INCREMENT,
    package_code VARCHAR(50) NOT NULL,
    package_name VARCHAR(100) NOT NULL,
    package_price DECIMAL(10,2) NOT NULL DEFAULT 0.00,
    status CHAR(1) DEFAULT '0',
    remark VARCHAR(500),
    create_by VARCHAR(64),
    create_time DATETIME,
    update_by VARCHAR(64),
    update_time DATETIME,
    PRIMARY KEY (package_id),
    UNIQUE KEY uk_package_code (
        package_code
    )
);

三十五、套餐项目关联表

CREATE TABLE car_service_package_item (
    id BIGINT NOT NULL AUTO_INCREMENT,
    package_id BIGINT NOT NULL,
    service_item_id BIGINT NOT NULL,
    quantity INT NOT NULL DEFAULT 1,
    sort_order INT NOT NULL DEFAULT 0,
    PRIMARY KEY (id),
    UNIQUE KEY uk_package_item (
        package_id,
        service_item_id
    ),
    KEY idx_package_item_service (
        service_item_id
    )
);

三十六、为什么关联表有 quantity

例如:

某服务项目

可能在套餐中:

数量 > 1

即使第一版:

通常为 1

保留 quantity 更合理。


三十七、为什么有 sort_order

套餐详情:

服务项目展示顺序

需要稳定控制。


三十八、套餐价格为什么单独存

套餐价不一定等于:

所有标准价之和

例如:

单项原价合计 600
套餐价 499

所以:

package_price

必须独立。


三十九、套餐 CreateDTO

public class ServicePackageCreateDTO {

    @NotBlank(
        message = "套餐编码不能为空"
    )
    private String packageCode;

    @NotBlank(
        message = "套餐名称不能为空"
    )
    private String packageName;

    @NotNull(
        message = "套餐价格不能为空"
    )
    @DecimalMin("0.00")
    private BigDecimal packagePrice;

    @NotEmpty(
        message = "套餐至少包含一个服务项目"
    )
    private List<ServicePackageItemDTO>
            items;

    private String remark;
}

四十、套餐明细 DTO

public class ServicePackageItemDTO {

    @NotNull
    private Long serviceItemId;

    @NotNull
    @Min(1)
    private Integer quantity;

    @NotNull
    @Min(0)
    private Integer sortOrder;
}

四十一、为什么必须至少一个服务项目

空套餐:

没有业务意义

所以:

@NotEmpty

后端必须校验。


四十二、为什么不只传 List

如果只传:

serviceItemIds

以后要加入:

quantity
sortOrder

会重新改接口。

对象列表:

扩展性更好

四十三、套餐创建完整流程

校验套餐编码
↓
校验套餐价格
↓
校验 items 非空
↓
校验 serviceItemId 不重复
↓
批量查询服务项目
↓
确认全部存在
↓
确认全部启用
↓
INSERT package
↓
获取 packageId
↓
BATCH INSERT package_item
↓
COMMIT

四十四、为什么同一项目不能重复加入套餐

因为关联表已经规定:

UNIQUE(package_id, service_item_id)

业务上同一项目需要数量时:

使用 quantity

不要:

插两条相同项目

四十五、Java 去重

Set<Long> itemIds =
        dto.getItems()
            .stream()
            .map(
                ServicePackageItemDTO
                    ::getServiceItemId
            )
            .collect(
                Collectors.toSet()
            );

if (
    itemIds.size()
    !=
    dto.getItems().size()
) {
    throw new ServiceException(
        "套餐中存在重复服务项目"
    );
}

四十六、为什么批量查询项目

错误:

for each itemId
    SELECT service_item

会形成:

N+1 查询

正确:

SELECT *
FROM car_service_item
WHERE service_item_id IN (...);

四十七、批量校验存在性

如果请求:

1,2,999

数据库只查到:

1,2

说明:

999 无效

四十八、为什么必须校验状态

停用项目:

不能加入新套餐

即使前端已经过滤:

后端也必须再次校验

四十九、套餐新增 Service

@Transactional(
    rollbackFor = Exception.class
)
public Long createPackage(
        ServicePackageCreateDTO dto
) {

    checkPackageCode(
        dto.getPackageCode()
    );

    Set<Long> itemIds =
            validateAndGetItemIds(
                dto.getItems()
            );

    List<CarServiceItem> items =
            serviceItemMapper
                .selectByIds(
                    itemIds
                );

    validateServiceItems(
        itemIds,
        items
    );

    CarServicePackage entity =
            new CarServicePackage();

    entity.setPackageCode(
        dto.getPackageCode()
    );

    entity.setPackageName(
        dto.getPackageName()
    );

    entity.setPackagePrice(
        dto.getPackagePrice()
    );

    entity.setStatus("0");

    entity.setRemark(
        dto.getRemark()
    );

    packageMapper
        .insertPackage(
            entity
        );

    List<CarServicePackageItem> relations =
            buildRelations(
                entity.getPackageId(),
                dto.getItems()
            );

    packageItemMapper
        .batchInsert(
            relations
        );

    return entity.getPackageId();
}

五十、为什么套餐创建必须事务

如果:

主表 INSERT 成功

但:

关联表 INSERT 失败

会产生:

空套餐

所以必须:

一个事务

五十一、关联表批量插入

<insert id="batchInsert">
    INSERT INTO car_service_package_item
    (
        package_id,
        service_item_id,
        quantity,
        sort_order
    )
    VALUES

    <foreach
        collection="list"
        item="item"
        separator=","
    >
        (
            #{item.packageId},
            #{item.serviceItemId},
            #{item.quantity},
            #{item.sortOrder}
        )
    </foreach>
</insert>

五十二、为什么批量 INSERT

循环 INSERT:

N 次数据库往返

批量:

一次 SQL

通常:

更高效

五十三、主键回填

插入套餐后必须:

拿到 packageId

否则:

无法插入关联表

MyBatis 要确认:

useGeneratedKeys
keyProperty

配置正确。


五十四、套餐 UpdateDTO

public class ServicePackageUpdateDTO {

    @NotNull
    private Long packageId;

    @NotBlank
    private String packageName;

    @NotNull
    @DecimalMin("0.00")
    private BigDecimal packagePrice;

    @NotEmpty
    private List<ServicePackageItemDTO>
            items;

    private String remark;
}

第一版:

packageCode 不允许普通修改

五十五、套餐修改推荐策略

对于小型套餐明细:

UPDATE package
↓
DELETE old package_item
↓
BATCH INSERT new package_item

五十六、为什么这种策略适合当前项目

套餐一般:

只有几个到十几个项目

相比精细计算:

哪些新增
哪些删除
哪些修改

直接重建关系:

更简单
更不容易出错

五十七、缺点

如果套餐有:

几十万条明细

这种方案:

不合适

但当前课程:

完全够用

五十八、套餐修改 Service

@Transactional(
    rollbackFor = Exception.class
)
public void updatePackage(
        ServicePackageUpdateDTO dto
) {

    CarServicePackage entity =
            getRequiredPackage(
                dto.getPackageId()
            );

    Set<Long> itemIds =
            validateAndGetItemIds(
                dto.getItems()
            );

    List<CarServiceItem> serviceItems =
            serviceItemMapper
                .selectByIds(
                    itemIds
                );

    validateServiceItems(
        itemIds,
        serviceItems
    );

    entity.setPackageName(
        dto.getPackageName()
    );

    entity.setPackagePrice(
        dto.getPackagePrice()
    );

    entity.setRemark(
        dto.getRemark()
    );

    packageMapper
        .updatePackage(
            entity
        );

    packageItemMapper
        .deleteByPackageId(
            dto.getPackageId()
        );

    List<CarServicePackageItem> relations =
            buildRelations(
                dto.getPackageId(),
                dto.getItems()
            );

    packageItemMapper
        .batchInsert(
            relations
        );
}

五十九、为什么不能吞异常

假设:

UPDATE 主表成功
DELETE 旧明细成功
INSERT 新明细失败

如果异常被 catch 后不再抛出:

事务可能提交

结果:

套餐明细被删空

所以:

异常必须继续抛出

六十、事务失效注意

不要:

this.updatePackage(...)

期望一定经过 Spring AOP 事务代理。

同类内部自调用:

可能绕过代理

需要:

通过 Spring Bean 正常调用

六十一、套餐详情 VO

public class ServicePackageDetailVO {

    private Long packageId;

    private String packageCode;

    private String packageName;

    private BigDecimal packagePrice;

    private String status;

    private String remark;

    private List<ServicePackageItemVO>
            items;
}

六十二、套餐明细 VO

public class ServicePackageItemVO {

    private Long serviceItemId;

    private String itemCode;

    private String itemName;

    private String category;

    private BigDecimal standardPrice;

    private Integer quantity;

    private Integer sortOrder;
}

六十三、套餐详情查询

建议:

查询主表
+
查询明细

两次 SQL。


六十四、为什么不强求一条复杂 JOIN

虽然 MyBatis 可以:

resultMap + collection

但两次查询:

逻辑简单
更适合当前学习阶段

六十五、明细 SQL

SELECT
    pi.service_item_id,
    si.item_code,
    si.item_name,
    si.category,
    si.standard_price,
    pi.quantity,
    pi.sort_order
FROM car_service_package_item pi
JOIN car_service_item si
  ON si.service_item_id =
     pi.service_item_id
WHERE
    pi.package_id = ?
ORDER BY
    pi.sort_order,
    pi.id;

六十六、套餐列表不要加载全部明细

列表只需要:

套餐编码
套餐名称
套餐价
项目数量
状态

详情才加载:

完整 items

六十七、项目数量

可以:

SELECT
    p.package_id,
    p.package_code,
    p.package_name,
    p.package_price,
    p.status,
    COUNT(pi.id) AS item_count
FROM car_service_package p
LEFT JOIN car_service_package_item pi
  ON pi.package_id = p.package_id
GROUP BY
    p.package_id,
    p.package_code,
    p.package_name,
    p.package_price,
    p.status;

六十八、MySQL ONLY_FULL_GROUP_BY

新 MySQL 环境下:

GROUP BY

要写规范。

不要照抄旧教程:

SELECT p.*, COUNT(...)
GROUP BY p.package_id

然后遇到:

ONLY_FULL_GROUP_BY 报错

六十九、套餐状态

0 正常
1 停用

同样可以:

sys_normal_disable

七十、停用套餐

停用后:

不能进入新的工单

但:

历史工单保持不变

七十一、套餐重新启用前需要校验

因为套餐停用期间:

某些服务项目
可能已经停用

所以启用套餐前:

重新检查所有项目

七十二、启用条件

至少一个服务项目
+
所有服务项目存在
+
所有服务项目状态正常

七十三、为什么服务项目被启用套餐引用时不允许停用

否则会出现:

套餐状态 = 正常

但里面:

某服务项目 = 停用

新业务会产生:

配置不一致

课程第一版选择:

先调整/停用套餐
再停用服务项目

七十四、套餐删除

如果套餐从未被业务使用:

可以删除

删除事务:

DELETE package_item
↓
DELETE package

七十五、被历史工单引用

不能:

物理删除

应该:

停用

七十六、为什么不用 ON DELETE CASCADE 静默处理服务项目

假设删除一个服务项目:

自动从所有套餐中消失

业务人员甚至可能:

不知道套餐内容被改了

因此主数据删除:

应先显式检查引用

七十七、套餐与项目价格的关系

例如:

A 项目 300
B 项目 200
C 项目 100

原价:

600

套餐:

499

所以:

package_price

独立存在。


七十八、套餐价格能否高于单项合计

技术上:

可以

业务上是否允许:

由需求决定

不要无需求就加:

packagePrice <= total

强校验。


七十九、套餐原价合计

编辑页面可以计算:

sum(
    standardPrice * quantity
)

用于:

展示参考

八十、注意这不是历史价格

套餐配置页面显示:

当前项目标准价

以后服务项目价格变了:

参考原价会变化

这没问题。

因为套餐是:

当前配置模板

八十一、历史工单为什么必须快照

真实业务发生时:

不能再实时依赖配置模板

工单创建时:

复制 itemName
复制 unitPrice
复制 quantity

后面改服务项目价格:

历史工单不变

八十二、配置模板 vs 业务实例

这是本章最重要概念:

ServiceItem / Package
=
模板、当前配置

WorkOrderItem
=
真实业务实例

SettlementItem
=
最终计费历史

八十三、套餐接口

GET    /car/package/list
GET    /car/package/{id}
POST   /car/package
PUT    /car/package
POST   /car/package/{id}/status
DELETE /car/package/{ids}
POST   /car/package/export

八十四、套餐权限

car:package:list
car:package:query
car:package:add
car:package:edit
car:package:remove
car:package:export
car:package:changeStatus

八十五、Controller 新增

@PreAuthorize(
    "@ss.hasPermi('car:package:add')"
)
@Log(
    title = "服务套餐",
    businessType =
        BusinessType.INSERT
)
@RepeatSubmit(
    interval = 3000
)
@PostMapping
public AjaxResult add(
        @Valid
        @RequestBody
        ServicePackageCreateDTO dto
) {

    return success(
        packageService
            .createPackage(
                dto
            )
    );
}

八十六、Controller 修改

@PreAuthorize(
    "@ss.hasPermi('car:package:edit')"
)
@Log(
    title = "服务套餐",
    businessType =
        BusinessType.UPDATE
)
@RepeatSubmit(
    interval = 3000
)
@PutMapping
public AjaxResult edit(
        @Valid
        @RequestBody
        ServicePackageUpdateDTO dto
) {

    packageService
        .updatePackage(
            dto
        );

    return success();
}

八十七、为什么套餐编辑建议 RepeatSubmit

套餐更新包含:

主表 UPDATE
旧明细 DELETE
新明细 INSERT

用户快速双击会:

增加并发冲突

@RepeatSubmit 可以减少:

重复 HTTP 请求

但仍必须:

事务 + UNIQUE

八十八、服务项目 Options API

套餐选择项目时不需要返回完整分页实体。

可以提供:

GET /car/serviceItem/options

只返回:

id
code
name
category
price

八十九、OptionVO

public class ServiceItemOptionVO {

    private Long serviceItemId;

    private String itemCode;

    private String itemName;

    private String category;

    private BigDecimal standardPrice;
}

九十、Options 只返回正常项目

WHERE status = '0'

后端过滤:

比前端拉全部再过滤可靠

九十一、Vue 页面结构

服务项目:

src/views/car/serviceItem/index.vue

服务套餐:

src/views/car/package/index.vue

套餐复杂编辑建议拆:

components/PackageForm.vue
components/ServiceItemSelector.vue

九十二、为什么套餐页应该拆组件

套餐页同时包含:

列表
基础表单
明细表格
项目选择 Dialog
金额展示

全部写一个文件:

很快难维护

九十三、服务项目页面

查询区域:

项目编码
项目名称
服务分类
状态

表格:

项目编码
项目名称
分类
标准价格
预计时长
状态
创建时间
操作

九十四、字典

const {
    car_service_category,
    sys_normal_disable
} =
    proxy.useDict(
        'car_service_category',
        'sys_normal_disable'
    )

九十五、分类展示

<dict-tag
    :options="car_service_category"
    :value="row.category"
/>

九十六、状态展示

<dict-tag
    :options="sys_normal_disable"
    :value="row.status"
/>

九十七、编辑时 itemCode 禁止修改

<el-input
    v-model="form.itemCode"
    :disabled="!!form.serviceItemId"
/>

九十八、价格输入

<el-input-number
    v-model="form.standardPrice"
    :min="0"
    :precision="2"
    :step="10"
/>

九十九、套餐编辑器

布局:

套餐基本信息
--------------------
套餐编码
套餐名称
套餐价格
备注

服务项目明细
--------------------
[选择服务项目]

项目编码 | 名称 | 分类 | 标准价 | 数量 | 排序 | 删除

一百、明细状态

const packageItems =
    ref([])

元素:

{
    serviceItemId: 1,
    itemCode: 'SVC-OIL-001',
    itemName: '更换机油',
    category: 'MAINTENANCE',
    standardPrice: 300,
    quantity: 1,
    sortOrder: 1
}

一百零一、选择项目 Dialog

支持:

项目名称搜索
分类筛选
多选

只显示:

正常项目

一百零二、前端防重复选择

function selectable(
    row
) {

    return !packageItems.value
        .some(
            item =>
                item.serviceItemId
                ===
                row.serviceItemId
        )
}

一百零三、后端仍要防重复

因为用户可以:

绕过 Vue
直接 Postman

所以:

后端 Set 去重
+
数据库 UNIQUE

一百零四、添加选择项

function addSelectedItems(
    selectedRows
) {

    const existingIds =
        new Set(
            packageItems.value
                .map(
                    item =>
                        item.serviceItemId
                )
        )

    selectedRows.forEach(
        item => {

            if (
                existingIds.has(
                    item.serviceItemId
                )
            ) {
                return
            }

            packageItems.value.push({
                serviceItemId:
                    item.serviceItemId,
                itemCode:
                    item.itemCode,
                itemName:
                    item.itemName,
                category:
                    item.category,
                standardPrice:
                    item.standardPrice,
                quantity: 1,
                sortOrder:
                    packageItems.value.length
                    + 1
            })
        }
    )
}

一百零五、删除明细

function removePackageItem(
    index
) {

    packageItems.value
        .splice(
            index,
            1
        )

    resetSortOrder()
}

一百零六、重新排序

function resetSortOrder() {

    packageItems.value
        .forEach(
            (
                item,
                index
            ) => {

                item.sortOrder =
                    index + 1
            }
        )
}

一百零七、数量输入

<el-input-number
    v-model="row.quantity"
    :min="1"
    :max="99"
/>

一百零八、前端原价合计

const originalTotal =
    computed(
        () => {

            return packageItems.value
                .reduce(
                    (
                        total,
                        item
                    ) => {

                        return total
                            +
                            Number(
                                item.standardPrice
                                || 0
                            )
                            *
                            Number(
                                item.quantity
                                || 0
                            )
                    },
                    0
                )
        }
    )

一百零九、为什么前端 Number 这里只能用于展示

真实价格:

不能依赖前端

后端必须:

根据 serviceItemId
重新查询数据库

一百一十、提交 Payload

const payload = {
    packageId:
        form.value.packageId,
    packageName:
        form.value.packageName,
    packagePrice:
        form.value.packagePrice,
    remark:
        form.value.remark,
    items:
        packageItems.value
            .map(
                item => ({
                    serviceItemId:
                        item.serviceItemId,
                    quantity:
                        item.quantity,
                    sortOrder:
                        item.sortOrder
                })
            )
}

一百一十一、为什么不提交 standardPrice

前端:

不可信

攻击者可以改:

{
    "serviceItemId": 1,
    "standardPrice": 0.01
}

所以服务项目真实价格:

必须后端查 DB

一百一十二、套餐价可以前端传吗

可以。

因为:

它本身就是管理员配置项

但必须:

后端校验
+
权限控制

普通员工:

不能修改套餐价格

一百一十三、套餐列表

建议字段:

套餐编码
套餐名称
套餐价
项目数量
状态
创建时间
操作

一百一十四、套餐详情

显示:

套餐基本信息
项目列表
当前项目原价合计
套餐价
参考优惠金额

一百一十五、参考优惠

originalTotal - packagePrice

只用于:

当前配置页面展示

不要把它当:

历史结算金额

一百一十六、套餐启停

状态接口:

POST /car/package/{id}/status

一百一十七、停用套餐

不删除明细。

原因:

以后可以重新启用

一百一十八、启用套餐前校验

套餐存在
↓
至少一个项目
↓
所有项目存在
↓
所有项目正常
↓
UPDATE status = 0

一百一十九、套餐删除

如果允许物理删除:

先检查历史引用
↓
DELETE package_item
↓
DELETE package

整个动作:

事务

一百二十、删除 Service 示例

@Transactional(
    rollbackFor = Exception.class
)
public void deletePackage(
        Long packageId
) {

    getRequiredPackage(
        packageId
    );

    if (
        workOrderMapper
            .countByPackageId(
                packageId
            )
        > 0
    ) {
        throw new ServiceException(
            "套餐已产生业务记录,不能删除,请停用"
        );
    }

    packageItemMapper
        .deleteByPackageId(
            packageId
        );

    packageMapper
        .deleteById(
            packageId
        );
}

一百二十一、服务项目导出

建议使用:

ServiceItemExportVO

一百二十二、ExportVO

public class ServiceItemExportVO {

    @Excel(
        name = "项目编码"
    )
    private String itemCode;

    @Excel(
        name = "项目名称"
    )
    private String itemName;

    @Excel(
        name = "服务分类",
        dictType =
            "car_service_category"
    )
    private String category;

    @Excel(
        name = "标准价格"
    )
    private BigDecimal standardPrice;

    @Excel(
        name = "预计时长(分钟)"
    )
    private Integer estimatedMinutes;

    @Excel(
        name = "状态",
        dictType =
            "sys_normal_disable"
    )
    private String status;
}

一百二十三、为什么不用 Entity 直接导出

避免:

内部 ID
审计字段
不需要的业务字段

一起导出。


一百二十四、缓存要不要做

服务项目确实:

读多写少

以后可以缓存:

正常服务项目 options

但当前阶段:

不强制

一百二十五、为什么不为了“用了 Redis”而缓存

缓存会增加:

失效
一致性
调试

复杂度。

项目第一优先级:

数据库和业务逻辑正确

一百二十六、后续工单会怎么使用本章数据

工单新增:

选择单项
或
选择套餐

选择套餐:

读取套餐明细
↓
展开成实际服务项目
↓
复制项目名称
↓
复制价格
↓
写入 work_order_item

一百二十七、为什么不是工单永远引用套餐

因为套餐以后可以:

修改项目
修改价格
停用

历史工单:

不能被改动

一百二十八、这是“快照”思想

配置数据
可以变化

业务历史
必须稳定

一百二十九、测试用例:服务项目

1:

正常新增服务项目

预期:

成功

2:

重复 itemCode

预期:

拒绝

3:

standardPrice < 0

预期:

拒绝

4:

停用未被引用项目

预期:

成功

5:

停用被启用套餐引用的项目

预期:

拒绝

一百三十、测试用例:套餐

1:

正常创建 3 个项目套餐

预期:

1 条 package
3 条 package_item

2:

items 为空

预期:

拒绝

3:

同项目重复两次

预期:

拒绝

4:

传不存在 serviceItemId

预期:

拒绝

5:

传停用项目

预期:

拒绝

6:

packageCode 重复

预期:

拒绝

一百三十一、事务测试

修改套餐:

UPDATE package
↓
DELETE old relations
↓
故意让 batchInsert 报错

最后检查:

package
和
old relations

都应该:

恢复原状

一百三十二、为什么这个测试特别重要

它能验证:

@Transactional
是否真实生效

而不是:

只看代码上有没有注解

一百三十三、常见 Bug 1:新增套餐后 packageId 为 null

检查:

AUTO_INCREMENT
useGeneratedKeys
keyProperty

一百三十四、常见 Bug 2:批量 INSERT 失败

检查:

foreach collection
字段名
逗号
Mapper 参数

一百三十五、常见 Bug 3:套餐修改后明细重复

通常:

旧关系没删

或:

前端 items 自己重复

一百三十六、常见 Bug 4:项目顺序乱

检查:

sort_order
ORDER BY sort_order, id

一百三十七、常见 Bug 5:停用项目还能选

检查:

options SQL
是否 status = '0'

一百三十八、常见 Bug 6:前端禁选但 Postman 可以加入停用项目

说明:

后端没有再次校验

一百三十九、常见 Bug 7:修改项目价格后历史订单变化

说明:

历史业务没有价格快照

后面工单模块必须:

复制 unit_price

一百四十、常见 Bug 8:499.00 变成 499.00000001

检查:

Java 是否 double
数据库是否 FLOAT/DOUBLE

正确:

BigDecimal + DECIMAL

一百四十一、常见 Bug 9:删除项目导致套餐报错

原因:

没有引用检查

一百四十二、常见 Bug 10:修改套餐只保存了一半

检查:

@Transactional
是否异常被吞
是否同类自调用

一百四十三、常见 Bug 11:普通员工能改价格

检查:

@PreAuthorize
角色菜单权限
v-hasPermi

后端:

必须最终阻止

一百四十四、角色权限建议

系统管理员:

全部

服务配置管理员:

serviceItem:add/edit/status
package:add/edit/status

门店管理员:

list/query

服务顾问:

list/query

一百四十五、为什么“能选择项目”不等于“能修改项目”

使用权限:

读

配置权限:

写

应该:

分开

一百四十六、数据库检查 SQL

查看套餐关系:

SELECT
    p.package_name,
    si.item_name,
    pi.quantity,
    pi.sort_order
FROM car_service_package_item pi
JOIN car_service_package p
  ON p.package_id = pi.package_id
JOIN car_service_item si
  ON si.service_item_id =
     pi.service_item_id
ORDER BY
    p.package_id,
    pi.sort_order;

一百四十七、检查重复关系

SELECT
    package_id,
    service_item_id,
    COUNT(*)
FROM car_service_package_item
GROUP BY
    package_id,
    service_item_id
HAVING COUNT(*) > 1;

应该:

0 行

一百四十八、检查空套餐

SELECT
    p.package_id,
    p.package_name
FROM car_service_package p
LEFT JOIN car_service_package_item pi
  ON pi.package_id = p.package_id
WHERE pi.id IS NULL;

按照当前规则:

应该 0 行

一百四十九、检查启用套餐内停用项目

SELECT
    p.package_name,
    si.item_name
FROM car_service_package p
JOIN car_service_package_item pi
  ON pi.package_id = p.package_id
JOIN car_service_item si
  ON si.service_item_id =
     pi.service_item_id
WHERE
    p.status = '0'
    AND si.status = '1';

当前业务规则下:

应该 0 行

一百五十、Git 提交建议

服务项目:

git commit -m "feat: add service item management"

套餐主模块:

git commit -m "feat: add service package management"

关联表:

git commit -m "feat: manage package item relations"

Vue 编辑器:

git commit -m "feat: add service package editor"

一百五十一、Cursor 提示词:服务项目

当前项目基于官方 RuoYi-Vue springboot3 + RuoYi-Vue3,
业务模块为 ruoyi-car。

请先阅读当前项目真实代码。

实现 car_service_item 模块。

要求:
1. standardPrice 使用 BigDecimal
2. item_code 数据库唯一
3. item_code 创建后普通编辑不允许修改
4. category 使用 car_service_category 字典
5. status 使用 sys_normal_disable
6. 复用 @PreAuthorize、@Log、BaseController
7. 被启用套餐引用时不允许停用
8. 被套餐或历史业务引用时不允许物理删除
9. 不修改若依认证核心

一百五十二、Cursor 提示词:套餐后端

请实现:
car_service_package
car_service_package_item

要求:
1. 一个套餐至少一个项目
2. 同一项目不能重复
3. 只允许使用启用项目
4. 创建套餐必须事务
5. 修改套餐采用:
   update package
   delete old relations
   batch insert new relations
6. package_code 唯一
7. package_code 创建后不允许普通修改
8. 关联表 UNIQUE(package_id, service_item_id)
9. 前端传来的 standardPrice 不可信
10. 后端根据 serviceItemId 查询真实项目
11. 不要循环逐个查询 serviceItem,使用批量查询

一百五十三、Cursor 提示词:套餐 Vue3

请基于当前 RuoYi-Vue3 页面风格实现套餐编辑器。

结构:
1. package/index.vue
2. PackageForm.vue
3. ServiceItemSelector.vue

要求:
1. Element Plus
2. useDict
3. v-hasPermi
4. 服务项目 Dialog 支持多选
5. 已选项目禁止重复选择
6. 明细支持 quantity、sortOrder
7. 展示当前项目原价合计
8. packagePrice 两位小数
9. 提交只传 serviceItemId、quantity、sortOrder
10. 不把 standardPrice 当后端可信数据

一百五十四、Cursor 提示词:事务检查

请只审查服务套餐事务,不修改。

检查:
1. create package + relations 是否同一事务
2. update package + delete old + insert new 是否同一事务
3. delete package + relations 是否同一事务
4. 是否 catch 后吞异常
5. 是否存在 this.xxx() 事务失效
6. batchInsert 失败是否完整回滚
7. UNIQUE 冲突是否转换成友好业务异常

一百五十五、Cursor 提示词:安全检查

请审查服务单项和套餐模块。

重点:
1. 金额是否 BigDecimal
2. 是否信任前端 standardPrice
3. 是否允许重复项目
4. 停用项目是否还能加入套餐
5. 被引用项目是否能删除
6. 普通员工是否能修改价格
7. 是否存在越权修改
8. 是否有 N+1 查询
9. 多表修改是否缺事务
10. 历史工单是否错误依赖当前标准价格

一百五十六、IDEA 调试重点

断点:

createServiceItem
changeStatus
createPackage
updatePackage
validateServiceItems
batchInsert

一百五十七、事务验证

修改套餐时:

故意让最后 batchInsert 抛异常

确认:

主表修改
和
旧明细删除

全部:

回滚

一百五十八、面试题:为什么套餐与项目用关联表

答:

套餐和服务项目是典型多对多关系。

关联表能够正常使用 JOIN、索引和唯一约束,
并能保存数量、排序等关系本身的属性。

相比把多个 ID 以逗号字符串保存,
关联表更符合关系数据库设计。

一百五十九、面试题:为什么金额要 BigDecimal

答:

float/double 是二进制浮点数,
对十进制金额可能产生精度误差。

因此 Java 金额使用 BigDecimal,
MySQL 使用 DECIMAL。

一百六十、面试题:为什么套餐修改用删除旧关系再重建

答:

套餐明细通常数量不大。

在事务中先删除旧关联,
再根据当前表单批量插入新关联,
实现简单、维护成本低,
也不容易遗漏新增、删除、排序变化。

一百六十一、面试题:为什么套餐修改必须事务

答:

套餐修改包含主表更新、旧明细删除、
新明细插入多个数据库操作。

任何一步失败都不能只保留部分结果,
所以整个业务动作必须处于同一个事务中。

一百六十二、面试题:为什么不能信前端价格

答:

浏览器属于不可信客户端,
请求参数可以被用户修改。

套餐选择项目时,
后端应该根据 serviceItemId 查询真实主数据。

后续工单和结算金额同样必须由后端计算。

一百六十三、面试题:为什么改服务项目价格不能影响历史业务

答:

service_item 是当前配置主数据,
价格未来可以修改。

已经发生的工单和结算属于历史事实,
必须保存当时的名称和价格快照,
不能实时读取当前标准价。

一百六十四、面试题:为什么停用优于删除

答:

服务项目和套餐可能已经被历史业务引用。

物理删除容易破坏历史关系。

停用可以阻止它继续进入新业务,
同时保留历史数据完整性。

一百六十五、面试题:为什么要批量查询服务项目

答:

如果套餐有多个项目,
循环逐个查询会形成 N+1 数据库访问。

使用 WHERE id IN (...)
一次查询出所有项目,
再统一校验存在性和状态,
性能和代码结构都更好。

一百六十六、面试题:为什么启用套餐还要重新校验内部项目

答:

套餐停用期间,
内部服务项目可能已经被停用。

如果不重新检查,
会出现套餐正常但内部项目不可用的状态。

因此套餐重新启用前应验证所有明细项目仍可用。

一百六十七、知识树

Service Catalog
│
├─ Service Item
│  ├─ itemCode
│  ├─ itemName
│  ├─ category
│  ├─ standardPrice
│  ├─ estimatedMinutes
│  └─ status
│
├─ Service Package
│  ├─ packageCode
│  ├─ packageName
│  ├─ packagePrice
│  └─ status
│
├─ Package Relation
│  ├─ packageId
│  ├─ serviceItemId
│  ├─ quantity
│  └─ sortOrder
│
├─ Consistency
│  ├─ UNIQUE Code
│  ├─ UNIQUE Relation
│  ├─ Transaction
│  ├─ Batch Insert
│  └─ Reference Check
│
├─ Security
│  ├─ @PreAuthorize
│  ├─ v-hasPermi
│  └─ Price Permission
│
├─ Vue
│  ├─ Item CRUD
│  ├─ Package Form
│  ├─ Item Selector
│  └─ Dynamic Detail Table
│
└─ Snapshot
   ├─ Current Standard Price
   ├─ Current Package Price
   └─ Future WorkOrder Snapshot

一百六十八、完整套餐创建链路

Vue PackageForm
↓
选择项目
↓
POST /car/package
↓
@PreAuthorize
↓
@RepeatSubmit
↓
Service @Transactional
↓
校验 packageCode
↓
校验 items 去重
↓
批量查 ServiceItem
↓
校验存在 + 启用
↓
INSERT Package
↓
获取 packageId
↓
BATCH INSERT Relations
↓
Commit

一百六十九、完整套餐修改链路

PUT /car/package
↓
@PreAuthorize
↓
Service @Transactional
↓
查询套餐
↓
校验项目
↓
UPDATE Package
↓
DELETE Old Relations
↓
BATCH INSERT New Relations
↓
Commit

一百七十、完整停用项目链路

POST /serviceItem/{id}/status
↓
查询项目
↓
如果目标 = 停用
↓
查询启用套餐引用
├─ 有
│  → 拒绝
└─ 无
   ↓
   UPDATE status

一百七十一、本章最终验收

你应该能够独立完成:

1. 服务项目新增
2. 服务项目修改
3. 服务项目查询
4. 服务项目启停
5. 服务项目删除校验
6. 服务分类字典
7. 套餐新增
8. 套餐修改
9. 套餐详情
10. 套餐启停
11. 套餐删除
12. 多对多关联表
13. 批量保存套餐明细
14. 事务回滚验证
15. 防止重复明细
16. 停用项目不可加入套餐
17. 启用套餐重新验证内部项目
18. Vue3 套餐编辑器
19. 服务项目选择 Dialog
20. BigDecimal / DECIMAL 金额链路
21. 权限控制
22. Excel 导出

一百七十二、本章最重要的工程原则

1. 套餐和项目是多对多,必须使用关联表

2. 金额 Java 使用 BigDecimal,MySQL 使用 DECIMAL

3. 前端传来的标准价格不能直接信任

4. 主数据优先停用,不要随便物理删除

5. 删除前必须检查业务引用

6. 套餐创建、修改、删除涉及多表,必须事务

7. 小规模套餐明细使用“删除旧关系 + 批量重建”简单可靠

8. 项目批量查询,避免 N+1

9. UNIQUE 是业务编码和关联唯一性的最终兜底

10. 当前配置和历史业务必须分开

11. 项目改价不能改变历史工单

12. 后续创建工单时必须复制项目与价格快照

一百七十三、下一篇

按照课程表,下一篇进入:

《淘车湾项目实战(四):结算单分析及代码实现》

下一章会开始处理:

服务工单完成
↓
生成结算单
↓
金额汇总
↓
优惠
↓
应收
↓
支付状态
↓
审批状态

重点包括:

car_settlement

结算编号

工单与结算一对一

重复结算防护

BigDecimal 金额计算

总金额

优惠金额

应收金额

结算状态机

支付状态

审批状态

process_instance_id 预留

DTO / VO

事务

权限

门店 DataScope

Vue3 结算页面

为后续 Activiti 审批做准备