news 2026/9/30 9:02:44

洗衣店管理系统实战:订单状态机与计费规则的设计与实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
洗衣店管理系统实战:订单状态机与计费规则的设计与实现

简介:基于Java洗衣店管理系统设计与实现是一份面向计算机相关专业毕业设计参考的完整论文文档,系统采用B/S结构,基于JSP技术、Java语言和MySQL数据库开发,为用户提供消费记录、衣服清洗、修补、赔偿查询,为管理员提供会员卡、订单及营业额统计等管理功能。文件为单个docx格式,共32页,约3.11MB,内容涵盖摘要、目录、绪论、系统开发环境、需求分析、系统设计等完整章节,详细说明了系统架构、数据库管理、用户界面及安全性、可扩展性等设计要点,并贯穿“操作简单,功能实用”的设计理念。已有154人学习/下载,适合正在选题或撰写洗衣店管理系统类毕设论文的读者参考,可帮助快速梳理论文结构与技术路线,也能为JSP+Java+MySQL项目开发与文档撰写提供范例。

1. 洗衣店管理系统不是增删改查:先想清楚它和图书管理系统的本质区别

一个基于 Java 的洗衣店管理系统,听起来像是典型的课设题目——无非是客户表、订单表、商品表,配上几个增删改查页面。但真做过的人会告诉你,洗衣店业务里藏着两个让新手集体翻车的东西:计费规则和衣物状态流转。按件洗、按重量洗、会员折扣、取衣时限、逾期保管费,这些规则叠在一起,订单表设计不好,后面每加一个功能都要改表结构。再加上洗衣进度要从「收衣」走到「上机」「洗涤」「烘干」「整烫」「取衣」六个状态,任何一步漏了记录,门店对账就变成玄学。

这个管理系统适合两类人:一类是做 Java 课程设计或毕业设计的学生,需要在一个真实业务场景里展示面向对象建模、分层架构和数据库设计能力;另一类是真正要帮小洗衣店做信息化的小团队,需要一个能跑在普通电脑上、不需要高成本服务器、店员半天就能学会使用的系统。本文按我做过的一个方案讲:Java + Swing/JavaFX 做桌面端,MySQL 存数据,MyBatis 管持久层,订单状态机控制整个洗衣流程。这套组合不需要复杂的分布式环境,一台 Windows 电脑就能开发调试,数据一致性用本地事务加乐观锁就能解决,部署时用启动脚本一键拉起。

2. 订单状态机与计费规则:建表之前先把业务闭环画清楚

2.1 六状态流转为什么必须用状态机而不是一个状态字段

很多初学方案里,洗衣订单就一个status字段,用数字 0 到 5 表示不同阶段。看起来没问题,但真实业务里状态不是随便跳的——你不能从「收衣」直接跳到「取衣」,也不能在「洗涤中」把衣物标记为「已上机」,因为每一步都关联着操作员、时间和实物位置。直接在 Service 层写if (status == 1) { status = 2; }的后果是,三个月后加新功能的人根本不知道哪些跳转是合法的,改一处崩三处。

我一般先把状态流转图画出来再写代码。六个状态定义如下:

  • 0 已收衣:前台录入衣物信息,生成取衣小票
  • 1 已上机:衣物进入洗涤设备,记录设备编号
  • 2 洗涤中:设备运行中,预计完成时间
  • 3 已烘干:烘干完成,进入整烫环节
  • 4 已整烫:衣物整理完毕,等待取衣
  • 5 已取衣:客户取走衣物,订单关闭

状态机的核心价值在于把跳转规则收拢到一个地方。用 Java 枚举加一张跳转表,比散落在各个 Service 方法里的 if-else 好维护得多。后面想加「返洗」状态,只需要在枚举里加一个值,跳转表里多写两条边,不用去翻所有调用方。

public enum OrderStatus { RECEIVED(0, "已收衣"), ON_MACHINE(1, "已上机"), WASHING(2, "洗涤中"), DRIED(3, "已烘干"), IRONED(4, "已整烫"), PICKED_UP(5, "已取衣"); private final int code; private final String desc; // 合法跳转表:当前状态 -> 可以跳转到的状态集合 private static final Map<OrderStatus, Set<OrderStatus>> TRANSITIONS = new HashMap<>(); static { TRANSITIONS.put(RECEIVED, EnumSet.of(ON_MACHINE, PICKED_UP)); // 收衣后可直接取衣(未洗涤,如只熨烫) TRANSITIONS.put(ON_MACHINE, EnumSet.of(WASHING)); TRANSITIONS.put(WASHING, EnumSet.of(DRIED, WASHING)); // WASHING 允许自跳,用于延长洗涤时间 TRANSITIONS.put(DRIED, EnumSet.of(IRONED)); TRANSITIONS.put(IRONED, EnumSet.of(PICKED_UP)); TRANSITIONS.put(PICKED_UP, EnumSet.noneOf(OrderStatus.class)); // 终态 } public boolean canTransitionTo(OrderStatus target) { Set<OrderStatus> allowed = TRANSITIONS.get(this); return allowed != null && allowed.contains(target); } }

这段枚举解决的不只是「合法跳转」问题,它还顺带让业务规则可以复用。比如洗衣店的实际场景中,有只熨烫不水洗的订单,那么状态路径就是「已收衣 -> 已整烫 -> 已取衣」,跳转表里 RECEIVED 到 IRONED 这条边就是为这个场景留的。另一个细节是 WASHING 允许自跳,因为大件衣物洗涤时间可能超过预设值,操作员需要延长状态停留时间。

2.2 计费规则的三种模型:按件、按重量、按会员等级

计费是整个系统里最容易改出 bug 的地方。常见的洗衣店有三种计费方式,它们不是互斥的,而是会叠加:

  • 按件计费:每类衣物有固定单价,比如衬衫 15 元、羽绒服 45 元。这种方式需要一张「衣物类型表」,存类型名称、单价、预计洗涤时长。
  • 按重量计费:往往用于床品、窗帘,进店先称重,每公斤单价乘以重量。重量要留小数点后两位,系统里用DECIMAL(5,2)存,避免浮点误差。
  • 会员折扣:会员卡分等级,普通会员 9 折、金卡 8 折、黑金卡 7 折,同时还有充值赠送规则。折扣落在订单明细级别,而不是订单总额级别,因为洗衣店经常出现「整单里部分衣物不打折」的情况。

订单表里如果只存一个total_price,后面统计会员消费、按衣物类型汇总收入都会变得很难做。我的做法是拆三层:订单主表存客户、状态、应收总额;订单明细表存每件衣物的类型、数量、单价、折扣率;支付记录表存实收金额和支付方式。折扣只在明细节上算,主表的应收总额是明细的聚合结果。

-- 订单明细表:每一件衣物一行 CREATE TABLE order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, clothes_type_id BIGINT NOT NULL COMMENT '关联衣物类型表', clothes_name VARCHAR(50) NOT NULL COMMENT '冗余衣物名称,防止类型表改名影响历史订单', quantity INT NOT NULL DEFAULT 1, unit_price DECIMAL(6,2) NOT NULL COMMENT '单价,来自衣物类型表,下单时快照', discount_rate DECIMAL(3,2) NOT NULL DEFAULT 1.00 COMMENT '0.80表示八折', item_total DECIMAL(8,2) NOT NULL COMMENT '小计 = 数量 * 单价 * 折扣率', remark VARCHAR(200) COMMENT '污渍、破损等特殊说明', PRIMARY KEY (id), KEY idx_order_id (order_id) );

clothes_name字段冗余存储是有意为之——衣物类型表里的价格可以调整,但历史订单的明细应当保持下单时的快照,不能跟着类型表变动。用DECIMAL而不是float/double是账务系统的常识:浮点数存金额会出现 0.1 + 0.2 = 0.30000000000000004 的问题,Java 侧配合BigDecimal使用。

订单主表里应收总额不直接暴露给 Service 层随意 set,而是由明细汇总而来。可以在 Service 层计算,也可以用触发器,但为了便于排查问题,我倾向在事务里用 Java 代码计算后写入,同时保留一个核对 SQL 用于定期检查主表和明细表是否对得上。计费规则中的「逾期保管费」是另一个经典坑:衣物洗好超过 30 天未取,每天加收 2 元。这个规则不要在查询时实时算,而是在取衣时用定时任务扫描「待取衣超过 N 天」的订单,生成逾期费用记录,前台取衣时看到的是已经算好的金额。

2.3 数据库建模:五张核心表的主键设计与关联关系

整个系统最少需要五张核心表:客户表、衣物类型表、订单主表、订单明细表、支付记录表。如果还要管会员卡余额,则再加一张会员卡表和一张充值流水表。主键我统一用BIGINT AUTO_INCREMENT,不做分布式主键——单店场景不需要雪花算法,带了 UUID 反而让索引变慢。

客户表和会员卡表为什么要分开?因为洗衣店存在「非会员临时洗」的场景。第一次来洗衣服的顾客可能只洗一件衬衫,不想办卡,但他仍然要在系统里留下联系方式,方便通知取衣。所以客户表是基础信息,会员卡是扩展信息,两者一对一但允许客户没有卡。订单表通过customer_id关联客户,而不是关联会员卡,这样非会员订单也能正常走流程。

CREATE TABLE customer ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, phone VARCHAR(20) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_phone (phone) ); CREATE TABLE membership_card ( id BIGINT PRIMARY KEY AUTO_INCREMENT, customer_id BIGINT NOT NULL UNIQUE, card_no VARCHAR(32) NOT NULL UNIQUE, level TINYINT NOT NULL DEFAULT 1 COMMENT '1普通 2金卡 3黑金', balance DECIMAL(10,2) NOT NULL DEFAULT 0.00, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_card_no (card_no) );

会员卡余额是一个高频更新字段。每次消费如果走余额支付,就是一次「读余额 -> 扣余额 -> 写回去」的操作。单机场景下用数据库行锁就能解决,不需要引入 Redis 分布式锁。但要注意:扣余额必须发生在数据库事务里,而不是先在 Java 内存里算好再更新,否则两个窗口同时操作一张卡,就会出现余额被多扣或变成负数的问题。这个点在第四章展开讲。

订单表和明细表的关系是典型的一对多。查询订单详情时用JOIN order_item查出所有衣物行,然后组装成一个订单 VO 对象返回给界面层。订单状态放在主表里,明细表不带状态——整单状态是一致的,不存在「同一单里一件洗好了另一件还没」的情况。如果以后要做「按件取衣」,那要拆分订单或者给明细加状态,属于另一个量级的业务复杂度,初版不要碰。

3. 用 Java 实现核心业务流程:收衣下单、进度流转、取衣结算

3.1 收衣下单事务:明细校验与会员折扣的落库顺序

收衣是洗衣店每天最频繁的操作,这个动作在一个事务里要完成四件事:创建订单主表记录、逐条插入明细、计算应收总额、如果是会员还要判断是否用余额支付。这个事务的难点在于明细数据来自界面层多行输入,Service 方法要接收一个订单 DTO,里面嵌套着明细 DTO 列表。

这里 I 用 Spring 的@Transactional管理事务。有个细节:@Transactional默认只在抛出 RuntimeException 时回滚,如果业务代码用 try-catch 吞掉了异常,事务就不会回滚。所以 Service 层内部不要捕获异常,统一抛到外层由全局异常处理器处理。

@Service public class OrderService { @Autowired private OrderMapper orderMapper; @Autowired private OrderItemMapper orderItemMapper; @Autowired private MembershipCardMapper cardMapper; @Transactional(rollbackFor = Exception.class) public Long createOrder(OrderCreateDTO dto) { // 1. 计算明细金额并构建明细实体 BigDecimal totalAmount = BigDecimal.ZERO; List<OrderItem> items = new ArrayList<>(); for (OrderItemDTO itemDTO : dto.getItems()) { BigDecimal unitPrice = itemDTO.getUnitPrice(); BigDecimal discount = itemDTO.getDiscountRate(); BigDecimal itemTotal = unitPrice .multiply(discount) .multiply(BigDecimal.valueOf(itemDTO.getQuantity())) .setScale(2, RoundingMode.HALF_UP); totalAmount = totalAmount.add(itemTotal); OrderItem item = new OrderItem(); item.setClothesTypeId(itemDTO.getClothesTypeId()); item.setClothesName(itemDTO.getClothesName()); item.setQuantity(itemDTO.getQuantity()); item.setUnitPrice(unitPrice); item.setDiscountRate(discount); item.setItemTotal(itemTotal); items.add(item); } // 2. 保存订单主表,状态为已收衣 Order order = new Order(); order.setCustomerId(dto.getCustomerId()); order.setStatus(OrderStatus.RECEIVED); order.setTotalAmount(totalAmount); orderMapper.insert(order); // 3. 保存明细,注意明细需要拿到订单生成的自增主键 for (OrderItem item : items) { item.setOrderId(order.getId()); orderItemMapper.insert(item); } return order.getId(); } }

这段代码里最容易被忽略的是RoundingMode.HALF_UP。金额计算不能默认用BigDecimal(double)构造,那是坑;也不能让计算结果带着多位小数落库。setScale(2, RoundingMode.HALF_UP)强制四舍五入到分,且HALF_UP是银行家舍入以外的常见选择,符合日常对钱的认知。折扣率从界面传入时,界面上显示的是「8 折」,传给后端的是0.80。

事务中先插主表再插明细的顺序是必要的——明细表的外键order_id依赖主表自增主键,MyBatis 的useGeneratedKeys="true"配置能让insert后直接把自增 ID 写回实体对象的id字段。order.getId()拿到的不是 null,而是数据库分配的真实主键。如果这里拿不到,检查 Mapper XML 里是否忘了配置keyProperty="id"。

3.2 进度流转接口:如何防止状态被重复提交

状态流转的操作路径是:前台选择订单 -> 点击「下一状态」按钮 -> 后端校验当前状态 -> 更新状态并写流转记录。后端的核心逻辑是校验「当前状态是否允许跳转到目标状态」。注意这里必须是数据库中的当前状态,而不是界面传过来的旧状态——两个窗口同时操作同一订单时,界面显示的状态可能已经过期。

@Transactional(rollbackFor = Exception.class) public void transitionOrder(Long orderId, OrderStatus targetStatus, Long operatorId) { // 1. 加锁读取当前状态,防止并发下状态错乱 Order order = orderMapper.selectByIdForUpdate(orderId); if (order == null) { throw new BusinessException("订单不存在"); } OrderStatus currentStatus = OrderStatus.of(order.getStatusCode()); if (!currentStatus.canTransitionTo(targetStatus)) { throw new BusinessException( String.format("订单状态不可从%s流转到%s", currentStatus.getDesc(), targetStatus.getDesc())); } // 2. 执行状态变更 order.setStatusCode(targetStatus.getCode()); order.setUpdatedAt(LocalDateTime.now()); orderMapper.updateStatus(order); // 3. 写入状态流转日志 OrderStatusLog log = new OrderStatusLog(); log.setOrderId(orderId); log.setFromStatus(currentStatus.getCode()); log.setToStatus(targetStatus.getCode()); log.setOperatorId(operatorId); log.setCreatedAt(LocalDateTime.now()); orderStatusLogMapper.insert(log); }

selectByIdForUpdate是关键。它会在数据库层面给这一行加排他锁,直到事务提交才释放。两个操作员同时点「取衣」,第一个事务拿到锁并完成更新,第二个事务等锁释放后拿到的是最新状态,此时状态已经是「已取衣」,跳转校验会直接拒绝。没有这行锁的话,两个事务可能同时读到「已整烫」状态,然后都认为可以跳转到「已取衣」,产生两条取衣记录。

状态流转日志表看起来是额外工作,但对洗衣店非常实用。遇到客户投诉「我的衣服为什么一直没人洗」时,查一下日志就知道每一步的操作人、操作时间,是排班问题还是系统漏登记一目了然。后续要做运营分析——哪个环节耗时最长、哪个操作员效率最低——也依赖这张日志表。

流转日志不用记录所有字段快照,只记from_status、to_status、operator_id、created_at四个字段足够。将来要重建订单时间线,按created_at排序就能还原整个过程。

3.3 取衣结算:余额支付与找零计算的一次性完成

取衣是订单的终态操作,这个接口的复杂之处在于支付方式可能是混合的——会员余额付一部分、现金付剩下的。同时还要处理「逾期保管费已经生成」的情况。取衣事务包含:锁定订单、校验状态为已整烫、计算应付总金额(含保管费)、扣会员卡余额、记录支付流水、更新订单状态为已取衣。

会员卡扣款必须做余额充足校验和一个原子扣减,我把这个动作封装成一条 SQL 而不是 Java 里「select -> set -> update」。因为后者的并发问题在 3.2 已经出现过一次,这里直接写条件更新更稳。

@Transactional(rollbackFor = Exception.class) public void pickupOrder(Long orderId, BigDecimal cashAmount, BigDecimal cardAmount, Long operatorId) { Order order = orderMapper.selectByIdForUpdate(orderId); if (order == null || !order.getStatusCode().equals(OrderStatus.IRONED.getCode())) { throw new BusinessException("订单不在待取衣状态"); } BigDecimal overdueFee = BigDecimal.ZERO; if (order.getOverdueDays() > 0) { overdueFee = calculateOverdueFee(order); } BigDecimal payable = order.getTotalAmount().add(overdueFee); // 校验支付总额一致 BigDecimal paid = cashAmount.add(cardAmount); if (paid.compareTo(payable) != 0) { throw new BusinessException("实收金额与应收金额不一致"); } // 余额支付部分,原子扣减 if (cardAmount.compareTo(BigDecimal.ZERO) > 0) { int rows = cardMapper.deductBalance(order.getCustomerId(), cardAmount); if (rows == 0) { throw new BusinessException("会员卡余额不足"); } // 插入会员卡流水 cardFlowMapper.insert(new CardFlow(order.getCustomerId(), "消费", cardAmount.negate(), orderId)); } // 记录支付流水、更新订单状态 if (cashAmount.compareTo(BigDecimal.ZERO) > 0) { paymentMapper.insert(new Payment(orderId, "现金", cashAmount, operatorId)); } order.setStatusCode(OrderStatus.PICKED_UP.getCode()); order.setActualAmount(payable); orderMapper.updateStatus(order); }

cardMapper.deductBalance的 SQL 是条件更新的典型范式。UPDATE membership_card SET balance = balance - #{amount} WHERE customer_id = #{customerId} AND balance >= #{amount}。这个语句有两个作用:一是把「读取余额 -> 判断充足 -> 扣减」三步合并为一步,天然防并发;二是返回值rows为 0 时说明余额不足,直接抛业务异常。

这里有个细节:cashAmount与cardAmount是由界面传入的,理论上可以伪造。正规做法是后端自己查订单总额、查会员卡余额,再决定怎么组合支付,而不是信任前端算好的数字。但很多课设级别的系统把前端数据直接拿过来用,导致「实收金额与应收金额不一致」的校验形同虚设。实际做的时候可以把支付金额的拆分逻辑也放后端:前端只传「客户愿意用多少余额支付」,后端校验是否小于订单余额和应付金额。

3.4 查询页面的典型实现:多条件组合查询与分页

洗衣店前台经常需要按手机号、订单号、时间段找订单。「今天这个客户送来三件衣服,取走一件,剩下两件在哪」——这种查询要用多条件动态拼接 SQL。MyBatis 的<if>标签是最常用的方案,注意避免一个常见反模式:只在 Service 层判断参数是否为 null,然后拼不同 SQL——那会让 Mapper XML 里的 SQL 重复且难维护。

<select id="selectPage" resultType="com.laundry.entity.Order"> SELECT id, order_no, customer_id, status_code, total_amount, created_at, updated_at FROM `order` <where> <if test="orderNo != null and orderNo != ''"> AND order_no = #{orderNo} </if> <if test="customerId != null"> AND customer_id = #{customerId} </if> <if test="statusCode != null"> AND status_code = #{statusCode} </if> <if test="startTime != null"> AND created_at &gt;= #{startTime} </if> <if test="endTime != null"> AND created_at &lt;= #{endTime} </if> </where> ORDER BY id DESC LIMIT #{offset}, #{pageSize} </select>

注意三个细节。第一,<where>标签会自动去掉第一个AND,不用手写WHERE 1=1——后者虽然也能跑,但不够干净。第二,&gt;=和&lt;=是 XML 里的转义写法,直接写>=会让 XML 解析报错,这是每个 MyBatis 新手都会踩的坑。第三,分页用LIMIT #{offset}, #{pageSize},offset = (page - 1) * pageSize,在 Service 层算好传入,或用 PageHelper 插件自动生成。如果数据量超过几万行,这个查询仍然够用;单店洗衣订单一年大概几千到一万单,MySQL 毫无压力。

时间范围查询要注意索引使用。created_at上建立索引后,BETWEEN和>=/<=都能命中。但如果把created_at包在函数里比如DATE(created_at) = #{date},索引就失效了,全表扫描在数据量大时会有明显卡顿。所以范围查询用两个参数传时间点,而不是传日期字符串。

4. 数据一致性设计:会员卡扣款和库存扣减为什么不能信 Java 代码

4.1 单机事务能解决什么,不能解决什么

洗衣店管理系统是典型的单机数据库应用,不需要分布式事务、不需要消息队列。Java 服务连一个 MySQL 实例,所有的写操作在同一个数据库连接里完成,@Transactional保证原子性。但「同一个事务」有个前提:Service 方法里的所有 Mapper 调用必须拿到同一个 Connection,这由 Spring 的事务管理器控制。如果代码里自己new SqlSession或手动去开了一个新连接,那个连接不参与当前事务,就会出现「主表提交成功、明细表没写进去」的恐怖事故。

单机事务能保证的是:事务内的所有 SQL 要么全部成功、要么全部回滚。它不能解决的是跨请求的并发问题。比如两个操作员同时给同一个客户取衣,第一个事务扣了卡余额,第二个事务也扣了卡余额——如果没有锁,两次都将结果写回去,最后卡余额变成负数。解决办法是第四章开头说的悲观锁select ... for update,或者对余额扣减用条件更新 SQL。

这里要强调一个常见的误用:很多人会给@Transactional加propagation = Propagation.REQUIRES_NEW,以为这样更安全。实际上每次新开事务都意味着独立提交,如果一个业务方法内嵌的另一个方法走 REQUIRES_NEW,内层方法提交后外层方法抛异常回滚,内层已经提交的就不会回滚。这会导致数据不一致。洗衣店系统里全部用默认的 REQUIRED 传播行为就够了。

4.2 乐观锁 vs 悲观锁:小门店该选哪种

两种并发控制策略各有适用场景。洗衣店的实际并发量很低——一台收银机,两个店员,同时操作同一订单的概率极小。在这种场景下,悲观锁简单直接,缺点是锁等待会让界面偶发卡顿,但误操作带来的数据错误远比几百毫秒延迟更严重。我的选择是:订单状态流转用悲观锁,会员卡余额扣减用条件更新,库存数变更用乐观锁。

库存场景在洗衣店主要出现在「洗涤用品」管理上——洗衣液、包装袋、衣架这些耗材。每次入库、领用,都涉及库存数量的增减。不同操作员同时领用同一种耗材的概率虽然低,但一旦出现,丢失更新的后果是月底盘点对不上。乐观锁给库存表加一个version字段,更新时带上版本号,影响行数为 0 则重试。

public void deductStock(Long stockId, Integer quantity) { boolean success = false; int retryCount = 0; while (!success && retryCount < 3) { int rows = stockMapper.deductWithVersion(stockId, quantity, currentVersion); if (rows == 1) { success = true; } else { // 重查最新版本号后重试 currentVersion = stockMapper.selectVersion(stockId); retryCount++; } } if (!success) { throw new BusinessException("库存更新失败,请重试"); } }

对应的 SQL 是UPDATE stock SET quantity = quantity - #{quantity}, version = version + 1 WHERE id = #{stockId} AND version = #{currentVersion}。这里没有用select for update,因为库存操作不像订单状态流转那样需要读取整行数据做业务判断,只需要保证扣减不外泄。乐观锁在低并发下几乎没有性能损耗,而且不会阻塞其他操作。

值得多说一句的是:重试逻辑不要写成无限循环。设置 3 次重试上限,超过直接抛异常让操作员手工处理。还有一种情况是quantity扣成负数——库存表应该在quantity字段上加CHECK (quantity >= 0)约束,或者在更新 SQL 里加AND quantity >= #{quantity}条件,数据库层面兜底,Java 代码只是第一道防线。

4.3 定时任务与事务的边界:逾期保管费计算的另类坑

逾期保管费的计算逻辑是:统计所有状态为「已整烫」且取衣时间超过 30 天的订单,按每天 2 元生成待缴费用。这个任务每天跑一次,通常安排在凌晨用 Quartz 或 Spring@Scheduled触发。

这个任务很容易写错的地方在于重复计费。如果任务跑了两次,或者第一次没跑完就中断,第二天再跑会把前一天的保管费再叠加一次。解决思路是:在订单表上加一个overdue_fee_calculated_date字段,每次计算时记录计算到哪天,避免同一订单同一时间段被重复加钱。

@Scheduled(cron = "0 30 2 * * ?") @Transactional(rollbackFor = Exception.class) public void calculateOverdueFee() { LocalDate today = LocalDate.now(); List<Order> pendingOrders = orderMapper.selectIronedOrdersBefore( today.minusDays(30)); for (Order order : pendingOrders) { // 上次计算日期为空或早于今天,才执行本次计算 LocalDate lastCalc = order.getOverdueCalcDate(); if (lastCalc == null || lastCalc.isBefore(today)) { int overdueDays = (int) ChronoUnit.DAYS.between( order.getIronedTime(), today); order.setOverdueFee(BigDecimal.valueOf(overdueDays * 2)); order.setOverdueCalcDate(today); orderMapper.updateOverdueFee(order); } } }

@Transactional加在定时任务方法上有个隐患:如果任务里处理了 500 个订单,只要其中一个抛异常,整个事务回滚,前面 499 个的更新全部白做。这其实是一个合理的行为——要么全成功要么全失败,避免部分更新让对账变得更复杂。但如果订单量很大,一个事务跑几个小时会占用数据库连接太久。折中方案是把每个订单的更新拆成独立事务,用编程式事务管理器逐条提交,失败的单条记录日志后跳过。单店规模用整批事务即可,但要把异常捕获在循环外,防止一个脏数据让整个任务挂掉。

@Scheduled的执行频率要避开营业高峰期。凌晨 2 点半是比较好的时间,门店已经结束营业,数据库基本空闲。如果任务跑了超过 10 分钟,要检查是不是没有给ironed_time建索引,全表扫描在这种定时任务里会拖慢整个 MySQL 实例。

5. 避坑指南:洗衣店管理系统最常见的 5 个翻车现场

5.1 衣物类型改了单价,历史订单金额全变了

现象:运营人员把「羽绒服」的单价从 45 元改成 60 元后,半个月前的历史订单明细里显示的金额也变成了 60 元,月底对账怎么都对不上。

原因:订单明细表里的unit_price只是普通字段,没有做下单时快照,或者下单价是从衣物类型表里直接 JOIN 查出来的,跟着类型表的修改而变动。

解决:下单时把单价、折扣率、衣物名称全部冗余写入order_item表。详情查询用order_item里的字段,永远不要实时 JOINclothes_type取价格。后续如果发生「补差价」业务,在明细表里加一个price_adjust字段做正负调整,不要改原单价。

5.2 客户退衣后订单消失了,月末统计少一单

现象:客户洗到一半不要了,要求取消订单,前台把订单记录删掉,月底统计「成交订单数」比实际接待人数少。

原因:把「取消」实现了 DELETE 操作。洗衣单是有价值的数据,即便不产生收入,也记录了客户来过、业务量有多大。删除后所有对账数据都会缺漏。

解决:订单状态再加一个「已取消」状态,放进枚举和跳转表里,取消操作也走状态流转逻辑。统计时用状态维度区分——已取衣算成交,已取消算流失。SELECT时默认过滤掉已取消订单,但保留数据本身。

5.3 会员卡充值赠送金额被直接写进余额

现象:活动期间充 200 送 50,前台给客户充值时在余额字段直接写了 250。月底财务发现赠送金额没做账,收入和负债对不上。

原因:把「充值赠送」和「实际充入金额」混在一起,只更新了余额字段,没有记录赠送的业务含义。

解决:会员卡表拆成balance(可用余额)和bonus_balance(赠送余额)两个字段,或者保留一个字段但必须记录充值流水,流水里区分「本金」和「赠送」。扣款时约定先扣赠送余额再扣本金——这个规则要在代码里固定,不能每次随心情改。

5.4 取衣界面显示「已取衣」,但客户说没取到

现象:订单状态显示 5 已取衣,但客户坚持没来取,监控调出来发现确实没取。

原因:取衣操作被重复提交了。前台操作员点了取衣按钮后,界面没刷新,又点了一次;或者两个窗口各提交了一次。第二个请求把状态从「已整烫」改成「已取衣」,又被后续操作覆盖了整烫时间。

解决:取衣接口必须有状态校验——只有IRONED状态才能跳转到PICKED_UP,这个校验不能写在界面上,必须写在后端事务里。再配合select for update锁住订单行,重复提交的第二个请求会拿到更新后的状态并直接报错,而不是继续覆盖。

5.5 系统启动时数据库连接失败,全部功能不可用

现象:门店电脑重启后,Java 服务先启动了,MySQL 还没起来,系统报「Cannot create PoolableConnectionFactory」,点击任何菜单都白屏。

原因:HikariCP 连接池在启动时初始化失败,而服务本身没退出,只是所有数据库操作都拿不到连接。加上 MySQL 服务是手动启动的,店员不知道要去启动数据库。

解决:写一个启动脚本,先检测 MySQL 端口通了再启动 Java 服务。Windows 上用批处理脚本,mysqladmin ping检测数据库在线,timeout循环重试,最多等 30 秒;Linux 上用systemctl start mysqld先确保服务在线。同时把 HikariCP 的initializationFailTimeout设成负数,让连接池初始化失败后服务还能启动,后续重试建立连接。

6. 如何验证这套系统真的能扛住真实门店:一个月的数据复盘与三张必查报表

系统上线一周后,不要只看「能不能跑」,要开始做数据质量验证。我建议做三件事:第一,导出门店一个月的订单流水,和纸质小票逐单核对;第二,跑一遍会员卡余额对账脚本,确认每张卡的账实相符;第三,分析状态流转耗时分布,找出流程瓶颈。

第一张必查报表是各环节平均耗时。从状态流转日志表里按from_status分组,计算每两个状态之间的平均耗时。「洗涤中 -> 已烘干」如果平均耗时 8 小时而实际设备只需要 4 小时,说明操作员没有及时做状态变更登记,要培训操作习惯。这张表用一条聚合 SQL 就能出,不必写额外代码。

SELECT from_status, to_status, COUNT(*) AS cnt, TIMESTAMPDIFF(MINUTE, MIN(created_at), MAX(created_at)) / COUNT(*) AS avg_minutes FROM order_status_log WHERE created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) GROUP BY from_status, to_status ORDER BY avg_minutes DESC;

第二张必查报表是按衣物类型的收入排行。从order_item表按clothes_name分组汇总item_total。这张表如果发现 80% 的收入来自三种衣物,洗衣店的宣传和定价策略都要往那边倾斜。第三张是会员充值消耗比,也就是「期内充值总额 / 期内消费总额」。这个比值长期大于 1.5 说明客户充了钱不消费,可能服务的复购出了问题;比值小于 0.8 说明大家在快速消耗余额,可能是计费有误或者有大量取衣未记录。

进阶功能里最实用的是「一键导出 Excel 对账单」。Java 侧用 POI 库把订单列表输出成.xlsx文件,列包含:订单号、客户姓名、手机号、衣物明细、订单金额、实收金额、支付方式、取衣时间。这个导出功能不要用 Swing 窗口拼字符串,直接生成文件放到桌面对账目录,门店财务自己打开即用。POI 的SXSSFWorkbook比XSSFWorkbook内存占用小很多,导出一个月几千行数据时体验差距明显。

最后一个让系统真正「活起来」的细节是取衣短信提醒。衣物到达「已整烫」状态后,给客户手机发一条短信(用阿里云短信 API 或聚联云),内容是「您的衣物已洗好,请于 X 月 X 日前到店取衣,逾期将收取保管费」。这个功能一个月能减少至少 30% 的逾期保管费投诉。短信发送放在状态流转事务完成之后,用独立线程池异步发送,不要阻塞主事务。

做完这三个验证,你对这套系统的掌握程度就不再是「写完了功能」,而是「知道系统每天在做什么、哪里会出错、数据能不能解释业务」。这也是我做了几个 Java 管理系统后最深的体会:管理系统的难点从来不是单表增删改查,而是状态、金额、时间这三个维度的交叉校验。把这些边界想清楚,课设答辩被问住的可能性会大幅降低,真正上线也不会每天半夜被门店电话叫醒。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/30 9:02:30

从零开始AI工程落地:数据、训练到部署的完整实操指南

从零开始做 AI 工程&#xff0c;听起来像是一条又长又卷的路。我入行这几年&#xff0c;见过太多人把“跑通一个 Jupyter Notebook”当成“搞定了 AI”&#xff0c;结果一上生产环境就翻车&#xff1a;模型推理慢到超时、数据分布一变精度就崩、显卡 OOM 却不知道日志在哪看。这…

作者头像 李华
网站建设 2026/9/30 9:02:29

传统村落图景分类实战:数据集构建、迁移学习与避坑指南

简介&#xff1a;这份PDF为华南理工大学覃巧华等学者发表于《城市规划》2020年第7期的学术论文《基于卷积神经网络的传统村落图景分类研究》。论文面向规划师、建筑师及城乡建设者&#xff0c;提出一种基于卷积神经网络的自动识别与分类方法&#xff0c;利用自建样本分布均衡的…

作者头像 李华
网站建设 2026/9/30 9:00:41

前端工程师转型AI Agent开发的零废弃技能路径

1. 这不是“转行”&#xff0c;是前端工程师的自然进化路径最近三个月&#xff0c;我陆续和17位明确想“从前端转向AI Agent开发”的朋友做过深度交流。他们中&#xff0c;有工作3年的Vue中级开发者&#xff0c;有带团队的React技术负责人&#xff0c;也有刚毕业两年、在中小厂…

作者头像 李华
网站建设 2026/9/30 8:59:55

二叉树遍历底层原理与递归改迭代:彻底解决空指针和栈溢出

上个月团队做 Java 基础面试&#xff0c;连续几个候选人卡在同一个问题上&#xff1a;写一个二叉树的前序遍历。代码是背下来了&#xff0c;但一问到递归改迭代、为什么中间节点先出栈、极端情况会不会爆栈&#xff0c;就讲不清楚了。这事让我很有感触。二叉树遍历是一道典型的…

作者头像 李华
网站建设 2026/9/30 8:58:10

apt-get install 默认安装路径与FHS文件系统规范解析

1. 项目概述&#xff1a;搞懂 apt-get install 的默认安装路径&#xff0c;不是查文档&#xff0c;是看系统怎么“落子” 你执行 sudo apt-get install openssh-server &#xff0c;敲完回车&#xff0c;服务就跑起来了&#xff1b;你又试了 sudo apt-get install fcitx fci…

作者头像 李华