This commit is contained in:
2026-10-15 16:30:37 +08:00
parent c6bd6af5ce
commit 628b1a5732
5 changed files with 165 additions and 4 deletions
@@ -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<Trace> 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<com.baiyue.lastleg.returnreceipt.dto.ReturnOrderResponses.PackingDetail> 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<com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryResponses.MergeHistory> 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<com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryResponses.MergeHistory> listCartonHistory(@Param("customerId") String customerId, @Param("mark") String mark);
}
@@ -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<String, Long> 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) {
@@ -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();
@@ -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; }
}