diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnPackingTraceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnPackingTraceMapper.java index 93b7ba81c..9989681fb 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnPackingTraceMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnPackingTraceMapper.java @@ -10,6 +10,13 @@ import java.util.List; /** Immutable movements retain original return ownership when cartons are merged again. */ @Mapper public interface ReturnPackingTraceMapper { + // Collapse fragments within one operation; separate physical operations remain separate history. + String HISTORY_SELECT = "SELECT JSON_UNQUOTE(JSON_EXTRACT(o.result_json,'$.planNo')) AS plan_no," + + "t.source_carton_mark,t.target_carton_mark,t.return_no,t.sku_code,SUM(t.quantity) AS quantity," + + "MIN(t.created_at) AS created_at FROM ll_return_packing_trace t " + + "JOIN ll_return_inventory_operation o ON o.id=t.operation_id "; + String HISTORY_GROUP = " GROUP BY t.operation_id,JSON_UNQUOTE(JSON_EXTRACT(o.result_json,'$.planNo'))," + + "t.source_carton_mark,t.target_carton_mark,t.return_no,t.sku_code "; record Trace(long id, String sourceCartonMark, String targetCartonMark, String returnNo, String skuCode, long quantity, LocalDateTime createdAt) { } record Ownership(String returnNo, long quantity) { } @@ -28,15 +35,18 @@ public interface ReturnPackingTraceMapper { @Select("SELECT id,source_carton_mark,target_carton_mark,return_no,sku_code,quantity,created_at FROM ll_return_packing_trace WHERE customer_id=#{customerId} AND return_no=#{returnNo} ORDER BY created_at,id") List listReturn(@Param("customerId") String customerId, @Param("returnNo") String returnNo); - @Select("SELECT JSON_UNQUOTE(JSON_EXTRACT(o.result_json,'$.planNo')) AS plan_no,t.source_carton_mark,t.target_carton_mark,t.return_no,t.sku_code,t.quantity,t.created_at FROM ll_return_packing_trace t JOIN ll_return_inventory_operation o ON o.id=t.operation_id WHERE t.customer_id=#{customerId} AND t.return_no=#{returnNo} AND t.source_carton_mark<>'' ORDER BY t.created_at,t.id") + @Select(HISTORY_SELECT + "WHERE t.customer_id=#{customerId} AND t.return_no=#{returnNo} AND t.source_carton_mark<>''" + + HISTORY_GROUP + "ORDER BY MIN(t.created_at),MIN(t.id)") List returnHistory(@Param("customerId") String customerId, @Param("returnNo") String returnNo); - @Select("SELECT JSON_UNQUOTE(JSON_EXTRACT(o.result_json,'$.planNo')) AS plan_no,t.source_carton_mark,t.target_carton_mark,t.return_no,t.sku_code,t.quantity,t.created_at FROM ll_return_packing_trace t JOIN ll_return_inventory_operation o ON o.id=t.operation_id WHERE t.customer_id=#{customerId} AND JSON_UNQUOTE(JSON_EXTRACT(o.result_json,'$.planNo'))=#{planNo} AND t.source_carton_mark<>'' ORDER BY t.created_at,t.id") + @Select(HISTORY_SELECT + "WHERE t.customer_id=#{customerId} AND JSON_UNQUOTE(JSON_EXTRACT(o.result_json,'$.planNo'))=#{planNo} AND t.source_carton_mark<>''" + + HISTORY_GROUP + "ORDER BY MIN(t.created_at),MIN(t.id)") List planHistory(@Param("customerId") String customerId, @Param("planNo") String planNo); @Select("SELECT JSON_UNQUOTE(JSON_EXTRACT(o.result_json,'$.operatorName')) FROM ll_return_inventory_operation o WHERE JSON_UNQUOTE(JSON_EXTRACT(o.result_json,'$.planNo'))=#{planNo} AND o.operation_type='MERGE' ORDER BY o.id LIMIT 1") String planOperator(@Param("planNo") String planNo); - @Select("SELECT JSON_UNQUOTE(JSON_EXTRACT(o.result_json,'$.planNo')) AS plan_no,t.source_carton_mark,t.target_carton_mark,t.return_no,t.sku_code,t.quantity,t.created_at FROM ll_return_packing_trace t JOIN ll_return_inventory_operation o ON o.id=t.operation_id WHERE t.customer_id=#{customerId} AND o.operation_type='MERGE' AND t.source_carton_mark<>'' AND (t.source_carton_mark=#{mark} OR t.target_carton_mark=#{mark}) ORDER BY t.created_at DESC,t.id DESC") + @Select(HISTORY_SELECT + "WHERE t.customer_id=#{customerId} AND o.operation_type='MERGE' AND t.source_carton_mark<>'' AND (t.source_carton_mark=#{mark} OR t.target_carton_mark=#{mark})" + + HISTORY_GROUP + "ORDER BY MIN(t.created_at) DESC,MIN(t.id) DESC") List listCartonHistory(@Param("customerId") String customerId, @Param("mark") String mark); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java index 748ec70f7..1af1d5427 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java @@ -1190,7 +1190,13 @@ public class ReturnInventoryService { if (origins.length != 1) throw conflict("历史混箱缺少逐 SKU 来源明细,不能继续合并"); result.add(new com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper.Ownership(origins[0], quantity - traced)); } - return result; + // Existing trace and the original untracked remainder can belong to the same return. + // Coalesce before checking for multiple owners or recording a new movement. + Map byReturn = new LinkedHashMap<>(); + for (var origin : result) byReturn.merge(origin.returnNo(), origin.quantity(), Math::addExact); + return byReturn.entrySet().stream() + .map(entry -> new com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper.Ownership(entry.getKey(), entry.getValue())) + .toList(); } private ReturnInventoryResponses.MergeResult replayMerge(ReturnInventoryOperation operation, String requestHash) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java index 8ce4717df..79e1b1b6c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java @@ -373,6 +373,47 @@ class ReturnInventoryServiceTest { verify(balances, never()).adjustCartonQuantity(anyString(), anyString(), anyString(), anyLong()); } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(longs = {3L, 6L}) + void mergeCoalescesTrackedAndOriginalRemainderOwnedByTheSameReturn(long quantity) { + var source = prepareConcurrentSource(); + source.setTotalQuantity(6L); + when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 6L))); + when(packingTrace.ownership("10001", "RC-SOURCE", "SKU-A")).thenReturn(List.of( + new com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper.Ownership("RT-1", 3L))); + when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-TARGET"); + when(balances.adjustCartonQuantity(anyString(), anyString(), anyString(), anyLong())).thenReturn(1); + + service.merge(new PdaWarehouseIdentity("operator-2", "操作员2", 42L), mergeRequest(quantity)); + + verify(packingTrace).insert(anyLong(), eq("10001"), anyLong(), eq("RC-SOURCE"), + eq("RC-TARGET"), eq("RT-1"), eq("SKU-A"), eq(quantity), any()); + verify(packingTrace, org.mockito.Mockito.times(1)).insert(anyLong(), anyString(), anyLong(), + anyString(), anyString(), anyString(), anyString(), anyLong(), any()); + assertThat(source.getTotalQuantity()).isEqualTo(6L - quantity); + } + + @Test + void partialMergeStillRejectsActuallyDifferentReturnOwners() { + var source = prepareConcurrentSource(); + source.setSourceReturnNo("RT-1,RT-2"); source.setTotalQuantity(6L); + var otherOrder = completedOrder(); otherOrder.setId(12L); otherOrder.setReturnNo("RT-2"); + org.mockito.Mockito.reset(orders); + when(orders.findByNos("42", List.of("RT-1", "RT-2"))).thenReturn(List.of(completedOrder(), otherOrder)); + when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 6L))); + when(packingTrace.ownership("10001", "RC-SOURCE", "SKU-A")).thenReturn(List.of( + new com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper.Ownership("RT-1", 3L), + new com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper.Ownership("RT-2", 3L))); + var error = assertThrows(ResponseStatusException.class, () -> service.merge( + new PdaWarehouseIdentity("operator-2", "操作员2", 42L), + new ReturnInventoryRequests.Merge(List.of("RT-1", "RT-2"), + List.of(new ReturnInventoryRequests.ValidItem("SKU-A", 3L)), + BigDecimal.ONE, BigDecimal.ONE, BigDecimal.ONE, BigDecimal.ONE, "mixed-owner-key"))); + assertThat(error.getReason()).contains("不能自动猜测来源归属"); + verify(packingTrace, never()).insert(anyLong(), anyString(), anyLong(), anyString(), + anyString(), anyString(), anyString(), anyLong(), any()); + } + @Test void partiallyConsumedSourceUsesCurrentLockedSkuQuantityNotEarlierSnapshot() { ReturnInventoryCarton locked = prepareConcurrentSource(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnPackingTracePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnPackingTracePersistenceTest.java new file mode 100644 index 000000000..1737f8276 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnPackingTracePersistenceTest.java @@ -0,0 +1,101 @@ +package com.baiyue.lastleg.returnreceipt.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; + +public class ReturnPackingTracePersistenceTest { + private JdbcTemplate jdbc; + private ReturnPackingTraceMapper mapper; + + @BeforeEach + void setUp() throws Exception { + var ds = new DriverManagerDataSource("jdbc:h2:mem:packing-trace-" + UUID.randomUUID() + + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + jdbc = new JdbcTemplate(ds); + jdbc.execute("CREATE ALIAS JSON_EXTRACT FOR '" + getClass().getName() + ".jsonExtract'"); + jdbc.execute("CREATE ALIAS JSON_UNQUOTE FOR '" + getClass().getName() + ".jsonUnquote'"); + jdbc.execute("CREATE TABLE ll_return_inventory_operation (id BIGINT PRIMARY KEY, operation_type VARCHAR(40), result_json VARCHAR(1000))"); + jdbc.execute("CREATE TABLE ll_return_packing_trace (id BIGINT PRIMARY KEY, customer_id VARCHAR(80), operation_id BIGINT, " + + "source_carton_mark VARCHAR(80), target_carton_mark VARCHAR(80), return_no VARCHAR(80), sku_code VARCHAR(80), quantity BIGINT, created_at TIMESTAMP)"); + var config = new MybatisConfiguration(); + config.setMapUnderscoreToCamelCase(true); + config.addMapper(ReturnPackingTraceMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); + factory.setDataSource(ds); factory.setConfiguration(config); + mapper = new SqlSessionTemplate(factory.getObject()).getMapper(ReturnPackingTraceMapper.class); + operation(1, "PLAN"); + trace(1, 1, "C", "SOURCE", "TARGET", "RETURN", "SKU", 3); + trace(2, 1, "C", "SOURCE", "TARGET", "RETURN", "SKU", 3); + } + + @Test + void allDetailQueriesSumHistoricalFragmentsWithoutChangingRawTrace() { + trace(3, 1, "C", "SOURCE-B", "TARGET", "RETURN-B", "SKU-B", 15); + assertThat(mapper.planHistory("C", "PLAN")) + .extracting(row -> row.skuCode(), row -> row.quantity()) + .containsExactly(tuple("SKU", 6L), tuple("SKU-B", 15L)); + assertThat(mapper.returnHistory("C", "RETURN")).singleElement() + .satisfies(row -> assertThat(row.quantity()).isEqualTo(6)); + assertThat(mapper.listCartonHistory("C", "TARGET")) + .extracting(row -> row.skuCode(), row -> row.quantity()) + .containsExactly(tuple("SKU-B", 15L), tuple("SKU", 6L)); + assertThat(mapper.listReturn("C", "RETURN")).hasSize(2); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_return_packing_trace", Long.class)).isEqualTo(3); + } + + @Test + void groupingKeepsOperationReturnSkuAndCartonBoundariesAndCustomerIsolation() { + operation(2, "PLAN"); operation(3, "OTHER-PLAN"); + trace(3, 2, "C", "SOURCE", "TARGET", "RETURN", "SKU", 2); + trace(4, 3, "C", "SOURCE", "TARGET", "RETURN", "SKU", 1); + trace(5, 1, "C", "SOURCE", "TARGET", "RETURN-B", "SKU", 4); + trace(6, 1, "C", "SOURCE-B", "TARGET", "RETURN", "SKU", 5); + trace(7, 1, "C", "SOURCE", "TARGET-B", "RETURN", "SKU", 7); + trace(8, 1, "C", "SOURCE", "TARGET", "RETURN", "SKU-B", 8); + trace(9, 1, "OTHER-CUSTOMER", "SOURCE", "TARGET", "RETURN", "SKU", 99); + trace(10, 1, "C", "", "TARGET", "RETURN", "SKU", 100); + + assertThat(mapper.planHistory("C", "PLAN")) + .extracting(row -> row.sourceCartonMark(), row -> row.targetCartonMark(), row -> row.returnNo(), row -> row.skuCode(), row -> row.quantity()) + .containsExactly(tuple("SOURCE", "TARGET", "RETURN", "SKU", 6L), + tuple("SOURCE", "TARGET", "RETURN", "SKU", 2L), + tuple("SOURCE", "TARGET", "RETURN-B", "SKU", 4L), + tuple("SOURCE-B", "TARGET", "RETURN", "SKU", 5L), + tuple("SOURCE", "TARGET-B", "RETURN", "SKU", 7L), + tuple("SOURCE", "TARGET", "RETURN", "SKU-B", 8L)); + assertThat(mapper.returnHistory("C", "RETURN")).extracting(row -> row.quantity()) + .containsExactly(6L, 2L, 1L, 5L, 7L, 8L); + assertThat(mapper.listCartonHistory("C", "TARGET")).extracting(row -> row.quantity()) + .containsExactly(8L, 5L, 4L, 1L, 2L, 6L); + assertThat(mapper.planHistory("MISSING", "PLAN")).isEmpty(); + assertThat(mapper.returnHistory("MISSING", "RETURN")).isEmpty(); + assertThat(mapper.listCartonHistory("MISSING", "TARGET")).isEmpty(); + } + + private void operation(long id, String plan) { + jdbc.update("INSERT INTO ll_return_inventory_operation VALUES(?, 'MERGE', ?)", id, "{\"planNo\":\"" + plan + "\"}"); + } + + private void trace(long id, long operation, String customer, String source, String target, String owner, String sku, long quantity) { + jdbc.update("INSERT INTO ll_return_packing_trace VALUES(?,?,?,?,?,?,?,?,TIMESTAMP '2026-10-15 16:13:36.447')", + id, customer, operation, source, target, owner, sku, quantity); + } + + public static String jsonExtract(String json, String path) throws Exception { + return new ObjectMapper().readTree(json).path(path.substring(2)).asText(null); + } + + public static String jsonUnquote(String value) { return value; } +} diff --git a/scripts/README.md b/scripts/README.md index 1db17b634..c9b554614 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -518,3 +518,6 @@ Admin 全量中的 `phase4-cross-region-events` 使用内部 `test-event-fault-e `db/warehouse-transfer-carton-counting-incremental.sql`:仓储转运逐箱测量与清点增量,显式 USE baiyue_lastleg;逐列检查并新增箱子尺寸、重量、实点件数、清点操作人、时间、幂等字段、箱所属转运托盘ID及独立SKU申报件数 declared_quantity,可重复执行,无业务DML、不回填历史事实。适用于明确授权的本地或开发MySQL8,凭据由操作者外部提供;失败保留原错误,修复后重跑,不清理单据。已同步尾程基线和海外统一增量。 `local-warehouse-transfer-carton-acceptance.mjs`:仅本机统一Gateway的仓储转运逐箱验收;命令前显式 `node --env-file=<本地配置文件>`,必须提供 ADMIN_TEST_GATEWAY、ADMIN_TEST_USERNAME/PASSWORD、LOCAL_TRANSFER_PDA_PASSWORD,可选 OVERSEAS_TEST_CUSTOMER_ID、TRANSFER_CARTON_OUTPUT、TRANSFER_KEEP_PDA。复用既有有效客户和海外仓,以业务HTTP创建4笔带批次标识的新单,覆盖单箱、多SKU分箱多托、短收及超收、SKU申报件数与预计箱数独立持久化且PDA实收不覆盖原申报、先生成多托盘再逐箱选择托盘、箱级测量清点及托盘体积重量自动汇总、错误SKU/零件数/缺少差异原因拒绝、幂等重放、箱标签、打托绑定、测试照片和入库完成回查。测试照片为合成PNG,不证明真机拍照或物理打印。创建本轮PDA账号,默认finally停用;仅显式 TRANSFER_KEEP_PDA=true 时保留供本轮页面验收后人工停用。不改主档、不写业务数据库、不删除单据;报告不保存凭据或Token,失败保留已创建单号,排查后用新批次重跑,不自动清库或续跑既有单。 + + +`db/warehouse-transfer-consolidated-incremental-20261015.sql`:仓储转运近期本地增量汇总,固定 `USE baiyue_lastleg`,适用已有 Lastleg 基线的本地/开发 MySQL 8。依次合入 `warehouse-transfer-outbound-incremental.sql` 和 `warehouse-transfer-carton-counting-incremental.sql`,前者已覆盖旧 `dev-warehouse-transfer-pallet-lifecycle-incremental.sql`,无须重复跑旧生命周期补丁。包含审核、打托/照片、换标、目的地/出库/派送结构、实收箱数,以及逐箱尺寸、重量、实点件数、清点幂等、差异原因、箱所属托盘和独立 SKU 申报件数。只执行 DDL/元数据查询,无业务 DML;不回填单据或修改库存。实例和凭据由操作者在数据库客户端外部提供,无内置连接信息或报告文件。需支持 DELIMITER 和 CREATE ROUTINE/ALTER/INDEX 权限,整文件串行执行,遇错停止,不使用 --force;可按缺失结构检查重复执行,不修复任意同名异构旧表。末尾 `missing_carton_and_quantity_columns=0`、`inbound_pallet_unique_index=READY`、`migration_result=WAREHOUSE_TRANSFER_RECENT_SCHEMA_READY` 表示本轮列/索引核对通过;同客户重复托盘号会报告 INDEX_BLOCKED,不擅自更改旧托盘号。仅整理文件不表示目标库已经执行;连接必须由操作者明确选中,不重放 `deploy/sql/海外区域统一增量-MySQL8.sql` 空库基线。