Sfoglia il codice sorgente

fix(order): updateExpress 发货字段双轨修复 —— 改快递与发货落库统一 logistics_no/logistics_company 列

根因:ship() 主流程写 logistics_no/logistics_company,但 updateExpress()
      写的是 tracking_number/express_company(另一组列)。详情展示/管理端/小
      程序全链路均读 logisticsNo/logisticsCompany 列,部分更新时微信同步
      回退读 trackingNumber 列,ship 落库列拿不到单号,静默跳过漏同步。

修复:
- ProductOrderService.updateExpress() 落库改为 setLogisticsNo/setLogisticsCompany;
- 微信同步回退读 order.getLogisticsNo()/getLogisticsCompany()(与 ship 一致);
- schema.sql 补齐 logistics_no/logistics_company 两列(迁移 1023-1034 已有 ALTER
  但从未同步进建表快照,违反 AGENTS.md『schema.sql 必须同步』规范),
  并标注 tracking_number/express_company 为历史列。

新增回归测试 ProductOrderServiceUpdateExpressFieldTest(3 用例:
updateExpress_onlyCompany 部分更新回退、updateExpress_fullUpdate 全量更新、
ship_writesLogisticsColumns 回归保护)。修复前 2 用例失败(精确命中根因:
logisticsCompany expected <中通快递> but was <顺丰速运>、新单号未进 logisticsNo);
修复后 3/3 通过。存量失败已用 git stash 在干净 HEAD 复跑排除。

编译验证:mvn compile BUILD SUCCESS。
Sisyphus Agent 6 giorni fa
parent
commit
2d58fff2bc

+ 7 - 4
cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java

@@ -1057,15 +1057,18 @@ public class ProductOrderService {
         if (order == null) {
             return Result.error("订单不存在");
         }
-        if (trackingNumber != null) order.setTrackingNumber(trackingNumber);
-        if (expressCompany != null) order.setExpressCompany(expressCompany);
+        // 与 ship() 保持同一组落库列(logisticsNo/logisticsCompany):
+        // 详情展示 ProductOrderService.getOrderDetail / ProductOrderDTO.from() 与管理端/小程序前端均读该组列,
+        // 旧写法写 trackingNumber/expressCompany 另一组列会导致前端展示不更新、微信同步回退读空。
+        if (trackingNumber != null) order.setLogisticsNo(trackingNumber);
+        if (expressCompany != null) order.setLogisticsCompany(expressCompany);
         if (shippingCost != null) order.setShippingCost(shippingCost);
         order.setUpdatedAt(new Date());
         orderMapper.updateById(order);
         // 快递信息更新后自动同步微信(部分更新时回退订单已存值)
         syncShippingInfoToWechat(order,
-                trackingNumber != null ? trackingNumber : order.getTrackingNumber(),
-                expressCompany != null ? expressCompany : order.getExpressCompany());
+                trackingNumber != null ? trackingNumber : order.getLogisticsNo(),
+                expressCompany != null ? expressCompany : order.getLogisticsCompany());
         return Result.success("快递信息已更新");
     }
 

+ 4 - 2
cfc-backend/src/main/resources/schema.sql

@@ -1335,8 +1335,10 @@ auditor_id BIGINT COMMENT '审核人ID',
 audited_at DATETIME COMMENT '审核时间',
 audit_reason TEXT COMMENT '审核拒绝原因',
     shipping_cost INT DEFAULT 0 COMMENT '快递费(分)',
-    tracking_number VARCHAR(100) DEFAULT NULL COMMENT '快递单号',
-    express_company VARCHAR(100) DEFAULT NULL COMMENT '快递公司',
+    logistics_no VARCHAR(64) DEFAULT NULL COMMENT '物流单号',
+    logistics_company VARCHAR(100) DEFAULT NULL COMMENT '物流公司',
+    tracking_number VARCHAR(100) DEFAULT NULL COMMENT '快递单号(历史列,发货/改快递已统一写 logistics_no/logistics_company)',
+    express_company VARCHAR(100) DEFAULT NULL COMMENT '快递公司(历史列,发货/改快递已统一写 logistics_no/logistics_company)',
     delivery_method INT DEFAULT NULL COMMENT '配送方式: 1=快递 2=自提 3=两者皆可 4=线上-可选人 5=线上-单人',
     pickup_code VARCHAR(64) DEFAULT NULL COMMENT '自提码',
     confirm_time DATETIME DEFAULT NULL COMMENT '确认收货时间',

+ 157 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/ProductOrderServiceUpdateExpressFieldTest.java

@@ -0,0 +1,157 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.ProductOrder;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.ProductOrderMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 改快递字段双轨回归测试:updateExpress() 必须与 ship() 统一写入 logistics_no/logistics_company 列。
+ *
+ * 根因:ship() 主流程写 logisticsNo/logisticsCompany,详情展示(ProductOrderService.getOrderDetail /
+ *       ProductOrderDTO)读 logisticsNo/logisticsCompany,管理端 OrderDetail.vue 与小程序 logistics.vue
+ *       同样读 logisticsNo;但 updateExpress() 写的是 trackingNumber/expressCompany(另一组列)。
+ *       后果:
+ *       1) 改完快递,前端展示的物流信息不更新(读的是另一组列);
+ *       2) 微信 upload_shipping_info 同步在部分更新时回退读 trackingNumber 列(ship 落库列为
+ *          logisticsNo),拿不到单号 -> log.info 静默跳过,微信侧永远收不到这次更新。
+ */
+public class ProductOrderServiceUpdateExpressFieldTest {
+
+    private static final String ORDER_NO = "O20261018001";
+
+    private ProductOrderService orderService;
+    private ProductOrderMapper orderMapper;
+    private UserMapper userMapper;
+    private WechatService wechatService;
+
+    @BeforeEach
+    public void setUp() {
+        orderService = new ProductOrderService();
+        orderMapper = mock(ProductOrderMapper.class);
+        userMapper = mock(UserMapper.class);
+        wechatService = mock(WechatService.class);
+        ReflectionTestUtils.setField(orderService, "orderMapper", orderMapper);
+        ReflectionTestUtils.setField(orderService, "userMapper", userMapper);
+        ReflectionTestUtils.setField(orderService, "wechatService", wechatService);
+    }
+
+    /** 构造一笔已由 ship() 正常发货的订单:单号/公司落在 logisticsNo/logisticsCompany 列 */
+    private ProductOrder shippedOrder() {
+        ProductOrder order = new ProductOrder();
+        order.setOrderNo(ORDER_NO);
+        order.setBuyerId(100L);
+        order.setDeliveryMethod(1); // 快递,非自提
+        order.setProductName("复合维生素");
+        order.setQuantity(2);
+        order.setLogisticsNo("SF1234567890");    // ship() 落库列
+        order.setLogisticsCompany("顺丰速运");
+        return order;
+    }
+
+    private void stubBuyerWithOpenid() {
+        User buyer = new User();
+        buyer.setOpenid("oABC123");
+        when(userMapper.selectById(100L)).thenReturn(buyer);
+        when(wechatService.uploadShippingInfo(any(), any(), any(), any(), any())).thenReturn(null);
+    }
+
+    /**
+     * 故障复现:部分更新(只改快递公司不传单号)。
+     * 期望:单号回退到订单已存值 logisticsNo,微信同步用该单号;
+     * 实际(修复前):回退读 trackingNumber 列为空 -> 无单号静默跳过微信同步,且公司写进了另一组列。
+     */
+    @Test
+    public void updateExpress_onlyCompany_trackingFallsBackToPersistedLogisticsNo() {
+        ProductOrder existing = shippedOrder();
+        when(orderMapper.selectOne(any())).thenReturn(existing);
+        stubBuyerWithOpenid();
+
+        orderService.updateExpress(ORDER_NO, null, "中通快递", null);
+
+        // 落库:公司必须更新到 logisticsCompany 列,单号保留在 logisticsNo 列,不得写 trackingNumber/expressCompany
+        ArgumentCaptor<ProductOrder> persisted = ArgumentCaptor.forClass(ProductOrder.class);
+        verify(orderMapper).updateById(persisted.capture());
+        ProductOrder saved = persisted.getValue();
+        assertEquals("SF1234567890", saved.getLogisticsNo(),
+                "原单号必须保留在 logisticsNo 列(ship 落库列)");
+        assertEquals("中通快递", saved.getLogisticsCompany(),
+                "快递公司必须落在 logisticsCompany 列(与 ship/展示一致)");
+        assertNull(saved.getTrackingNumber(),
+                "不得再写 trackingNumber 列:该列与 ship()/前端展示链路不一致");
+        assertNull(saved.getExpressCompany(),
+                "不得再写 expressCompany 列:该列与 ship()/前端展示链路不一致");
+
+        // 微信同步:必须拿到订单已存单号(修复前回退读空列,微信侧漏同步)
+        ArgumentCaptor<String> trackingNo = ArgumentCaptor.forClass(String.class);
+        verify(wechatService).uploadShippingInfo(eq(ORDER_NO), eq("oABC123"),
+                trackingNo.capture(), eq("中通快递"), any());
+        assertEquals("SF1234567890", trackingNo.getValue(),
+                "部分更新时必须回退到订单已存单号并同步微信");
+    }
+
+    /**
+     * 全量更新(单号+公司一起传):两个值都必须落到 logisticsNo/logisticsCompany 列并同步微信。
+     */
+    @Test
+    public void updateExpress_fullUpdate_writesLogisticsColumnsAndSyncsWechat() {
+        ProductOrder existing = shippedOrder();
+        when(orderMapper.selectOne(any())).thenReturn(existing);
+        stubBuyerWithOpenid();
+
+        orderService.updateExpress(ORDER_NO, "YT9876543210", "圆通速递", null);
+
+        ArgumentCaptor<ProductOrder> persisted = ArgumentCaptor.forClass(ProductOrder.class);
+        verify(orderMapper).updateById(persisted.capture());
+        ProductOrder saved = persisted.getValue();
+        assertEquals("YT9876543210", saved.getLogisticsNo(),
+                "新单号必须写入 logisticsNo 列");
+        assertEquals("圆通速递", saved.getLogisticsCompany(),
+                "新快递公司必须写入 logisticsCompany 列");
+        assertNull(saved.getTrackingNumber(), "不得写 trackingNumber 列");
+        assertNull(saved.getExpressCompany(), "不得写 expressCompany 列");
+
+        verify(wechatService).uploadShippingInfo(eq(ORDER_NO), eq("oABC123"),
+                eq("YT9876543210"), eq("圆通速递"), any());
+    }
+
+    /**
+     * 回归保护:ship() 发货继续写 logisticsNo/logisticsCompany 列(本次修复不得改坏主流程)。
+     */
+    @Test
+    public void ship_writesLogisticsColumns() {
+        ProductOrder existing = new ProductOrder();
+        existing.setOrderNo(ORDER_NO);
+        existing.setBuyerId(100L);
+        existing.setDeliveryMethod(1);
+        existing.setStatus("paid");
+        existing.setProductName("复合维生素");
+        existing.setQuantity(1);
+        when(orderMapper.selectOne(any())).thenReturn(existing);
+        stubBuyerWithOpenid();
+
+        orderService.ship(ORDER_NO, "JD0000000001", "京东物流");
+
+        ArgumentCaptor<ProductOrder> persisted = ArgumentCaptor.forClass(ProductOrder.class);
+        verify(orderMapper).updateById(persisted.capture());
+        ProductOrder saved = persisted.getValue();
+        assertEquals("JD0000000001", saved.getLogisticsNo());
+        assertEquals("京东物流", saved.getLogisticsCompany());
+        assertNull(saved.getTrackingNumber());
+        assertNull(saved.getExpressCompany());
+        verify(wechatService).uploadShippingInfo(eq(ORDER_NO), eq("oABC123"),
+                eq("JD0000000001"), eq("京东物流"), any());
+    }
+}