From ab70c63988a578f4628e84a50aaad9c60d0c435c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 23 Oct 2026 12:14:14 +0800 Subject: [PATCH] fix(dispatch): calculate received pallet cargo from current stock --- .../mapper/DispatchCandidateMapper.java | 63 +++++++- .../dispatch/mapper/DispatchItemMapper.java | 6 + .../dispatch/mapper/DispatchSourceMapper.java | 8 +- .../repository/DispatchPickingRepository.java | 4 +- .../DispatchInboundReceiptService.java | 3 + .../dispatch/DispatchPhysicalWeightTest.java | 139 +++++++++++++++++- ...DispatchInboundReceiptPersistenceTest.java | 54 ++++++- 7 files changed, 255 insertions(+), 22 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java index 06e0e54a7..23968c094 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java @@ -40,6 +40,55 @@ public interface DispatchCandidateMapper { "COALESCE((SELECT CASE WHEN COUNT(*)>0 AND COUNT(*)=COUNT(d.gross_weight_kg) AND MIN(d.gross_weight_kg)>0 " + "THEN SUM(d.gross_weight_kg) END FROM ll_order_detail d WHERE d.customer_id=ip.customer_id AND d.order_id=l.order_id)," + "(SELECT inbound.gross_weight_kg FROM ll_inbound_order inbound WHERE inbound.id=l.order_id AND inbound.customer_id=ip.customer_id LIMIT 1))"; + /** 收货毛重按逐票确认箱数分摊;零贡献不占重量,任一有效来源缺失毛重时整托保持未知。 */ + String RECEIVED_GROSS_WEIGHT = + "CASE WHEN MIN(CASE WHEN a.counted_package_count=0 THEN 1 WHEN " + SOURCE_GROSS_WEIGHT + + " >0 AND l.required_package_count>0 AND a.counted_package_count>0 THEN 1 ELSE 0 END)=1 THEN " + + "ROUND(SUM(CASE WHEN a.counted_package_count=0 THEN 0 ELSE " + SOURCE_GROSS_WEIGHT + + " * a.counted_package_count / NULLIF(l.required_package_count,0) END),3) END"; + /** 体积采用收货端记录的逐来源体积,不将计划均分值或部分来源合计冒充完整收货量。 */ + String RECEIVED_VOLUME = + "CASE WHEN MIN(CASE WHEN a.counted_package_count=0 OR a.actual_volume_cbm>0 THEN 1 ELSE 0 END)=1 " + + "THEN NULLIF(SUM(CASE WHEN a.counted_package_count=0 THEN 0 ELSE a.actual_volume_cbm END),0) END"; + /** 已收货候选与箱数共用当前库存范围;入仓身份沿余额关联,不能把尾程订单ID当转运入仓ID。 */ + String CURRENT_STOCK_FROM = + " FROM ll_inventory_position stock LEFT JOIN ll_inventory_balance balance " + + "ON balance.id=stock.balance_id AND balance.customer_id=stock.customer_id " + + "LEFT JOIN ll_inbound_order inbound ON inbound.id=balance.inbound_order_id AND inbound.customer_id=stock.customer_id " + + "WHERE stock.customer_id=ip.customer_id AND stock.pallet_id=p.warehouse_pallet_id AND stock.available_packages>0"; + String CURRENT_SOURCE_WEIGHT = + "COALESCE((SELECT CASE WHEN COUNT(*)>0 AND COUNT(*)=COUNT(d.gross_weight_kg) AND MIN(d.gross_weight_kg)>0 " + + "THEN SUM(d.gross_weight_kg) END FROM ll_order_detail d WHERE d.customer_id=stock.customer_id AND d.order_id=inbound.id),inbound.gross_weight_kg)"; + /** 拆合托和部分出库后的估算按各真实入仓来源剩余箱数汇总,任一来源不明时不返回部分合计。 */ + String CURRENT_STOCK_WEIGHT = + "(SELECT CASE WHEN MIN(CASE WHEN inbound.package_count>0 AND " + CURRENT_SOURCE_WEIGHT + + " >0 THEN 1 ELSE 0 END)=1 THEN ROUND(SUM(" + CURRENT_SOURCE_WEIGHT + + " * stock.available_packages / NULLIF(inbound.package_count,0)),3) END" + CURRENT_STOCK_FROM + ")"; + String CURRENT_STOCK_VOLUME = + "(SELECT CASE WHEN MIN(CASE WHEN inbound.package_count>0 AND inbound.volume_cbm>0 THEN 1 ELSE 0 END)=1 " + + "THEN ROUND(SUM(inbound.volume_cbm * stock.available_packages / NULLIF(inbound.package_count,0)),4) END" + CURRENT_STOCK_FROM + ")"; + /** 仅当前整托仍为同一转运来源、全量可用且未拆合换绑时,保留该托实测;不按订单总重均摊已有实测。 */ + String CURRENT_MEASUREMENT_FROM = + " FROM ll_warehouse_transfer_pallet measurement JOIN ll_warehouse_transfer_order transfer " + + "ON transfer.id=measurement.transfer_order_id AND transfer.customer_id=measurement.customer_id " + + "WHERE measurement.customer_id=ip.customer_id AND measurement.warehouse_pallet_id=p.warehouse_pallet_id " + + "AND transfer.lastleg_order_id=ip.source_order_id AND measurement.status='COMPLETED' AND measurement.completed_at IS NOT NULL " + + "AND measurement.carton_count>0 AND measurement.carton_count=(SELECT SUM(stock.available_packages) FROM ll_inventory_position stock " + + "WHERE stock.customer_id=ip.customer_id AND stock.pallet_id=p.warehouse_pallet_id) " + + "AND NOT EXISTS(SELECT 1 FROM ll_inventory_position stock LEFT JOIN ll_inventory_balance balance " + + "ON balance.id=stock.balance_id AND balance.customer_id=stock.customer_id " + + "WHERE stock.pallet_id=p.warehouse_pallet_id AND (stock.available_packages+stock.hold_packages+stock.allocated_packages+stock.picked_packages+stock.receiving_packages)>0 " + + "AND (stock.customer_id!=ip.customer_id OR balance.inbound_order_id IS NULL OR balance.inbound_order_id!=transfer.inbound_order_id " + + "OR stock.overseas_warehouse_id!=transfer.operating_warehouse_id OR stock.hold_packages+stock.allocated_packages+stock.picked_packages+stock.receiving_packages>0)) " + + "AND NOT EXISTS(SELECT 1 FROM ll_inventory_ledger movement WHERE movement.pallet_id=p.warehouse_pallet_id " + + "AND movement.created_at>=measurement.completed_at AND movement.business_type IN " + + "('RELABEL_PALLET_DISASSEMBLE','PALLET_ASSEMBLE','PALLET_DISASSEMBLE','PALLET_SWAP','PALLET_UNBIND'))"; + String CURRENT_GROSS_WEIGHT = + "COALESCE((SELECT CASE WHEN COUNT(*)=1 AND MAX(measurement.gross_weight_kg)>0 THEN MAX(measurement.gross_weight_kg) END" + + CURRENT_MEASUREMENT_FROM + ")," + CURRENT_STOCK_WEIGHT + ")"; + String CURRENT_VOLUME = + "COALESCE((SELECT CASE WHEN COUNT(*)=1 AND MAX(measurement.volume_cbm)>0 THEN MAX(measurement.volume_cbm) END" + + CURRENT_MEASUREMENT_FROM + ")," + CURRENT_STOCK_VOLUME + ")"; @Select("") @Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE) List confirmedPlan(@Param("customer") String customer,@Param("id") long id); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchPickingRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchPickingRepository.java index f32b74661..3c2381991 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchPickingRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchPickingRepository.java @@ -28,8 +28,8 @@ public class DispatchPickingRepository { public int pick(DispatchPdaPick pick){return picks.insert(pick);} /** 本明细实际已扫码数量。 */ public long itemPicked(String customer,long task,long item){return picks.totalItem(customer,task,item);} - /** 自然收货后保存已经核实的同一计划来源实托身份。 */ - public int materialize(DispatchItem item){return items.updateById(item);} + /** 自然收货后保存同一来源实托、箱数和货量,允许未知货量清除计划估算。 */ + public int materialize(DispatchItem item){return items.confirmReceipt(item);} /** 按真实客户取本车扫码,不借主单客户头。 */ public List picks(String customer,long dispatch){return picks.wholeDispatch(customer,dispatch);} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptService.java index d07d57d93..3970a395d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptService.java @@ -75,6 +75,9 @@ public class DispatchInboundReceiptService implements InboundDispatchReceiptGate for(var entry:received.entrySet()) { entry.getKey().setWarehousePalletId(entry.getValue().warehousePalletId()); entry.getKey().setPackageCount(entry.getValue().packageCount()); + // 原预排的均分重量体积在确认入库时一并交接;未知值也必须覆盖旧计划快照。 + entry.getKey().setGrossWeightKg(entry.getValue().grossWeightKg()); + entry.getKey().setVolumeCbm(entry.getValue().volumeCbm()); } for(var item:pending.stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) { if(sources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw conflict("原计划来源已变化"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchPhysicalWeightTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchPhysicalWeightTest.java index a63efb2d8..d7b72f197 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchPhysicalWeightTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchPhysicalWeightTest.java @@ -10,10 +10,11 @@ import java.util.UUID; import static org.assertj.core.api.Assertions.*; class DispatchPhysicalWeightTest { + /** 计划估算不能用材积重补毛重,混合来源缺失时保持未知。 */ @Test void billingVolumeWeightCannotFillMissingPhysicalWeight() { var jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "")); jdbc.execute("CREATE TABLE ll_order_detail(customer_id VARCHAR(64),order_id BIGINT,gross_weight_kg DECIMAL(12,3))"); - jdbc.execute("CREATE TABLE ll_inbound_order(id BIGINT,customer_id VARCHAR(64),gross_weight_kg DECIMAL(12,3))"); + currentStockSchema(jdbc); jdbc.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(line_id BIGINT)"); jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(1)"); var tested = new java.util.HashSet(); @@ -30,15 +31,17 @@ class DispatchPhysicalWeightTest { assertThat(jdbc.queryForObject("SELECT " + projection + " FROM (SELECT 80 gross_weight_kg) wtp", BigDecimal.class)).isEqualByComparingTo("80"); continue; } - String query = "SELECT " + projection + " FROM (SELECT '10' AS customer_id) ip " - + "CROSS JOIN (SELECT 1 AS order_id,10 AS volume_cbm) l " - + "CROSS JOIN (SELECT 1 AS line_id,5 AS allocated_volume_cbm) a " - + "CROSS JOIN (SELECT 999 AS volume_weight_kg) o"; + String query = "SELECT " + projection + " FROM (SELECT '10' AS customer_id,1 AS source_order_id) ip " + + "CROSS JOIN (SELECT 1 AS order_id,10 AS volume_cbm,20 AS required_package_count) l " + + "CROSS JOIN (SELECT 1 AS line_id,5 AS allocated_volume_cbm,10 AS counted_package_count) a " + + "CROSS JOIN (SELECT 999 AS volume_weight_kg) o " + + "CROSS JOIN (SELECT CAST(NULL AS BIGINT) AS warehouse_pallet_id,'RESERVED' AS assignment_status,0 AS actual_package_count) p " + + "GROUP BY p.warehouse_pallet_id,p.assignment_status,p.actual_package_count,ip.customer_id,ip.source_order_id"; assertThat(jdbc.queryForObject(query, BigDecimal.class)).as(method.getName() + " missing gross weight").isNull(); if (projection.trim().equals("NULL")) continue; // Relabel candidates have no measured pallet weight source yet. jdbc.update("INSERT INTO ll_order_detail VALUES('10',1,40)"); assertThat(jdbc.queryForObject(query, BigDecimal.class)).as(method.getName() + " physical weight allocation").isEqualByComparingTo("20"); - String mixed = query.replace("SELECT 1 AS order_id,10 AS volume_cbm", "SELECT 1 AS order_id,10 AS volume_cbm UNION ALL SELECT 2,10"); + String mixed = query.replace("SELECT 1 AS order_id,10 AS volume_cbm,20 AS required_package_count", "SELECT 1 AS order_id,10 AS volume_cbm,20 AS required_package_count UNION ALL SELECT 2,10,20"); assertThat(jdbc.queryForObject(mixed, BigDecimal.class)).as(method.getName() + " incomplete aggregate").isNull(); jdbc.update("INSERT INTO ll_order_detail VALUES('10',2,40)"); assertThat(jdbc.queryForObject(mixed, BigDecimal.class)).as(method.getName() + " complete aggregate").isEqualByComparingTo("40"); @@ -47,4 +50,128 @@ class DispatchPhysicalWeightTest { } assertThat(tested).containsExactlyInAnyOrder("available","wholeAvailable","readSelected","lockSelected"); } + + /** 执行正式候选投影,21箱两托计划可均分,实收10+11后必须切换到对应货量。 */ + @Test void receivedCandidatesUseTenAndElevenCartonsInsteadOfEqualPlanShares() { + var jdbc=new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","")); + jdbc.execute("CREATE TABLE ll_order_detail(customer_id VARCHAR,order_id BIGINT,gross_weight_kg DECIMAL(18,3))"); + currentStockSchema(jdbc); + jdbc.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(line_id BIGINT)"); + jdbc.update("INSERT INTO ll_order_detail VALUES('10',1,570.5),('other',1,9999)"); + jdbc.update("INSERT INTO ll_inbound_order VALUES(1,'10',570.5,21,3.528)"); + jdbc.update("INSERT INTO ll_inventory_balance VALUES(1,'10',1)"); + jdbc.update("INSERT INTO ll_inventory_position VALUES(1,'10',1,501,'9',10,0,0,0,0)"); + var tested=new java.util.HashSet(); + for(var method:DispatchCandidateMapper.class.getDeclaredMethods()) { + if(!java.util.Set.of("available","wholeAvailable","readSelected","lockSelected").contains(method.getName()))continue; + String sql=String.join(" ",method.getAnnotation(Select.class).value()).replace(">",">").replace("<","<"); + String weight=sql.substring(sql.indexOf("AS volumeWeightKg,")+"AS volumeWeightKg,".length(),sql.indexOf(" AS grossWeightKg,")); + String volume=sql.substring(sql.indexOf("AS packageCount,")+"AS packageCount,".length(),sql.indexOf(" AS volumeCbm,")); + String from=" FROM (SELECT '10' AS customer_id,1 AS source_order_id) ip " + + "CROSS JOIN (SELECT 1 AS order_id,3.528 AS volume_cbm,21 AS required_package_count) l " + + "CROSS JOIN (SELECT 1 AS line_id,1.764 AS allocated_volume_cbm,10 AS counted_package_count,1.680 AS actual_volume_cbm) a " + + "CROSS JOIN (SELECT 501 AS warehouse_pallet_id,'RECEIVING' AS assignment_status,10 AS actual_package_count,1.764 AS allocated_volume_cbm) p " + + "GROUP BY p.warehouse_pallet_id,p.assignment_status,p.actual_package_count,p.allocated_volume_cbm,ip.customer_id,ip.source_order_id"; + assertThat(jdbc.queryForObject("SELECT "+weight+from,BigDecimal.class)).as(method.getName()).isEqualByComparingTo("271.667"); + assertThat(jdbc.queryForObject("SELECT "+volume+from,BigDecimal.class)).isEqualByComparingTo("1.680"); + String second=from.replace("10 AS counted_package_count,1.680 AS actual_volume_cbm","11 AS counted_package_count,1.848 AS actual_volume_cbm"); + jdbc.update("UPDATE ll_inventory_position SET available_packages=11"); + assertThat(jdbc.queryForObject("SELECT "+weight+second,BigDecimal.class)).isEqualByComparingTo("298.833"); + assertThat(jdbc.queryForObject("SELECT "+volume+second,BigDecimal.class)).isEqualByComparingTo("1.848"); + String plan=from.replace("'RECEIVING' AS assignment_status,10 AS actual_package_count","'RESERVED' AS assignment_status,0 AS actual_package_count"); + assertThat(jdbc.queryForObject("SELECT "+weight+plan,BigDecimal.class)).isEqualByComparingTo("285.25"); + assertThat(jdbc.queryForObject("SELECT "+volume+plan,BigDecimal.class)).isEqualByComparingTo("1.764"); + jdbc.update("UPDATE ll_inbound_order SET package_count=0"); + assertThat(jdbc.queryForObject("SELECT "+weight+from,BigDecimal.class)).isNull(); + jdbc.update("UPDATE ll_inbound_order SET package_count=21,volume_cbm=0"); + assertThat(jdbc.queryForObject("SELECT "+volume+from,BigDecimal.class)).isNull(); + jdbc.update("UPDATE ll_inbound_order SET volume_cbm=3.528"); + jdbc.update("INSERT INTO ll_inventory_position VALUES(2,'10',2,501,'9',1,0,0,0,0)"); + assertThat(jdbc.queryForObject("SELECT "+weight+from,BigDecimal.class)).isNull(); + jdbc.update("DELETE FROM ll_inventory_position WHERE id=2"); + jdbc.update("UPDATE ll_inventory_position SET available_packages=10"); + tested.add(method.getName()); + } + assertThat(tested).containsExactlyInAnyOrder("available","wholeAvailable","readSelected","lockSelected"); + } + + /** 拆出、部分出库及合入其他来源后,按当前来源组成重算;同客户同箱数也不能复用旧货量。 */ + @Test void currentStockMeasurementsFollowSplitPartialShipmentAndMergedSources() { + var jdbc=measurementDatabase(); + jdbc.update("INSERT INTO ll_inbound_order VALUES(1,'10',200,20,4),(2,'10',300,10,3),(3,'other',9000,10,9)"); + jdbc.update("INSERT INTO ll_inventory_balance VALUES(1,'10',1),(2,'10',2),(3,'other',3)"); + jdbc.update("INSERT INTO ll_inventory_position VALUES(1,'10',1,501,'9',10,0,0,0,0),(3,'other',3,501,'9',10,0,0,0,0)"); + assertMetrics(jdbc,501,"100","2"); + jdbc.update("UPDATE ll_inventory_position SET available_packages=5 WHERE id=1"); + jdbc.update("INSERT INTO ll_inventory_position VALUES(2,'10',1,502,'9',5,0,0,0,0)"); + assertMetrics(jdbc,501,"50","1");assertMetrics(jdbc,502,"50","1"); + jdbc.update("UPDATE ll_inventory_position SET available_packages=2,picked_packages=3 WHERE id=1"); + assertMetrics(jdbc,501,"20","0.4"); + jdbc.update("INSERT INTO ll_inventory_position VALUES(4,'10',2,501,'9',8,0,0,0,0)"); + assertMetrics(jdbc,501,"260","2.8"); + // 任一有效库存来源无法解析时不能只返回其他来源的部分合计。 + jdbc.update("UPDATE ll_inventory_balance SET inbound_order_id=999 WHERE id=2"); + assertMetrics(jdbc,501,null,null); + } + + /** 尾程订单9001与入仓快照7001不同,仍按库存真实关联读取各托实测,而不是均摊总重量。 */ + @Test void transferMeasurementsUseLinkedInboundAndPreserveDifferentPalletWeights() { + var jdbc=measurementDatabase(); + jdbc.update("INSERT INTO ll_inbound_order VALUES(7001,'10',300,20,3),(9001,'other',9000,20,9)"); + jdbc.update("INSERT INTO ll_inventory_balance VALUES(1,'10',7001)"); + jdbc.update("INSERT INTO ll_inventory_position VALUES(1,'10',1,501,'9',10,0,0,0,0),(2,'10',1,502,'9',10,0,0,0,0)"); + jdbc.update("INSERT INTO ll_warehouse_transfer_order VALUES(80,'10',9001,7001,'9')"); + jdbc.update("INSERT INTO ll_warehouse_transfer_pallet VALUES(80,'10',501,'COMPLETED',TIMESTAMP '2026-10-23 01:00:00',10,100,1),(80,'10',502,'COMPLETED',TIMESTAMP '2026-10-23 01:00:00',10,200,2)"); + assertMetrics(jdbc,501,"100","1");assertMetrics(jdbc,502,"200","2"); + jdbc.update("UPDATE ll_inventory_position SET available_packages=5 WHERE id=1"); + assertMetrics(jdbc,501,"75","0.75"); + jdbc.update("UPDATE ll_inventory_position SET available_packages=10 WHERE id=1"); + jdbc.update("INSERT INTO ll_inventory_ledger VALUES(501,TIMESTAMP '2026-10-23 02:00:00','PALLET_DISASSEMBLE')"); + assertMetrics(jdbc,501,"150","1.5"); + } + + /** 同托混入其他来源或冻结货物后,旧整托测量不能冒充当前可用份额;未知单项分别保留。 */ + @Test void changedCompositionAndMissingMetricsCannotReuseWholePalletMeasurements() { + var jdbc=measurementDatabase(); + jdbc.update("INSERT INTO ll_inbound_order VALUES(7001,'10',300,20,3),(7002,'10',400,20,4)"); + jdbc.update("INSERT INTO ll_inventory_balance VALUES(1,'10',7001),(2,'10',7002)"); + jdbc.update("INSERT INTO ll_inventory_position VALUES(1,'10',1,501,'9',5,0,0,0,0),(2,'10',2,501,'9',5,0,0,0,0)"); + jdbc.update("INSERT INTO ll_warehouse_transfer_order VALUES(80,'10',9001,7001,'9')"); + jdbc.update("INSERT INTO ll_warehouse_transfer_pallet VALUES(80,'10',501,'COMPLETED',TIMESTAMP '2026-10-23 01:00:00',10,100,1)"); + assertMetrics(jdbc,501,"175","1.75"); + jdbc.update("DELETE FROM ll_inventory_position WHERE id=2"); + jdbc.update("UPDATE ll_inventory_position SET available_packages=10,hold_packages=5"); + assertMetrics(jdbc,501,"150","1.5"); + jdbc.update("UPDATE ll_inbound_order SET gross_weight_kg=NULL WHERE id=7001"); + assertMetrics(jdbc,501,null,"1.5"); + jdbc.update("UPDATE ll_inbound_order SET package_count=0 WHERE id=7001"); + assertMetrics(jdbc,501,null,null); + } + + /** 每个场景独享内存数据,执行生产SQL而不连接共享环境。 */ + private static JdbcTemplate measurementDatabase() { + var jdbc=new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","")); + jdbc.execute("CREATE TABLE ll_order_detail(customer_id VARCHAR,order_id BIGINT,gross_weight_kg DECIMAL(18,3))"); + currentStockSchema(jdbc);return jdbc; + } + + /** 直接执行候选共用的生产货量表达式,分别检查未知值和精度。 */ + private static void assertMetrics(JdbcTemplate jdbc,long pallet,String weight,String volume) { + String sql=("SELECT "+DispatchCandidateMapper.CURRENT_GROSS_WEIGHT+" AS weight,"+DispatchCandidateMapper.CURRENT_VOLUME+" AS volume " + +"FROM (SELECT '10' AS customer_id,9001 AS source_order_id) ip CROSS JOIN (SELECT CAST(? AS BIGINT) AS warehouse_pallet_id) p") + .replace(">",">").replace("<","<"); + var values=jdbc.queryForObject(sql,(rs,n)->new BigDecimal[]{rs.getBigDecimal("weight"),rs.getBigDecimal("volume")},pallet); + if(weight==null)assertThat(values[0]).isNull();else assertThat(values[0]).isEqualByComparingTo(weight); + if(volume==null)assertThat(values[1]).isNull();else assertThat(values[1]).isEqualByComparingTo(volume); + } + + /** 与生产库存来源及转运实测相同的最小表结构,不借旧计划数量充当当前库存。 */ + private static void currentStockSchema(JdbcTemplate jdbc) { + jdbc.execute("CREATE TABLE ll_inbound_order(id BIGINT,customer_id VARCHAR,gross_weight_kg DECIMAL(18,3),package_count INT,volume_cbm DECIMAL(18,4))"); + jdbc.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR,inbound_order_id BIGINT)"); + jdbc.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR,balance_id BIGINT,pallet_id BIGINT,overseas_warehouse_id VARCHAR,available_packages INT,hold_packages INT,allocated_packages INT,picked_packages INT,receiving_packages INT)"); + jdbc.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT,customer_id VARCHAR,lastleg_order_id BIGINT,inbound_order_id BIGINT,operating_warehouse_id VARCHAR)"); + jdbc.execute("CREATE TABLE ll_warehouse_transfer_pallet(transfer_order_id BIGINT,customer_id VARCHAR,warehouse_pallet_id BIGINT,status VARCHAR,completed_at TIMESTAMP,carton_count INT,gross_weight_kg DECIMAL(18,3),volume_cbm DECIMAL(18,4))"); + jdbc.execute("CREATE TABLE ll_inventory_ledger(pallet_id BIGINT,created_at TIMESTAMP,business_type VARCHAR)"); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptPersistenceTest.java index 2f48967cc..412c304be 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptPersistenceTest.java @@ -16,6 +16,7 @@ import org.junit.jupiter.api.Test; import org.mybatis.spring.SqlSessionTemplate; import org.springframework.test.util.ReflectionTestUtils; import java.time.*; +import java.math.BigDecimal; import java.util.*; import static org.assertj.core.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; @@ -23,30 +24,66 @@ import static org.mockito.Mockito.*; /** 使用真实排车、预约绑定和MyBatis验证收货交接,再连续整托出货;库存外部命令沿原夹具隔离。 */ class DispatchInboundReceiptPersistenceTest { - /** 直接读取9+11实收,不读10+10计划估算,也不依赖可用库存或出货冻结状态。 */ + /** 直接读取9+11实收及对应货量,不读10+10计划估算,也不依赖可用库存或出货冻结状态。 */ @Test void confirmedSourceMapperReadsReceiptCountsWithoutInventoryAvailability() throws Exception { var ds=new org.springframework.jdbc.datasource.DriverManagerDataSource("jdbc:h2:mem:receipt-source-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); var db=new org.springframework.jdbc.core.JdbcTemplate(ds); db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR,overseas_warehouse_id BIGINT,status VARCHAR)"); db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,customer_id VARCHAR,warehouse_pallet_id BIGINT,pallet_no VARCHAR,delivery_address VARCHAR)"); - db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,customer_id VARCHAR,destination_type VARCHAR,fba_warehouse_code VARCHAR)"); - db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT,customer_id VARCHAR,planned_package_count INT,counted_package_count INT)"); + db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,customer_id VARCHAR,destination_type VARCHAR,fba_warehouse_code VARCHAR,order_id BIGINT,required_package_count INT)"); + db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT,customer_id VARCHAR,planned_package_count INT,counted_package_count INT,actual_volume_cbm DECIMAL(18,4))"); + db.execute("CREATE TABLE ll_order_detail(customer_id VARCHAR,order_id BIGINT,gross_weight_kg DECIMAL(18,3))"); + db.execute("CREATE TABLE ll_inbound_order(id BIGINT,customer_id VARCHAR,gross_weight_kg DECIMAL(18,3))"); + db.update("INSERT INTO ll_order_detail VALUES('101',9001,570.5),('other',9001,9999)"); db.update("INSERT INTO ll_inbound_plan VALUES(1,'101',9,'COMPLETED')"); - db.update("INSERT INTO ll_inbound_plan_line VALUES(1,'101','FBA','ONT8')"); + db.update("INSERT INTO ll_inbound_plan_line VALUES(1,'101','FBA','ONT8',9001,20)"); for(int n=1;n<=2;n++) { db.update("INSERT INTO ll_inbound_plan_pallet VALUES(?,1,'101',?,?,NULL)",n,500+n,"P-"+n); - db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(?,1,'101',10,?)",n,n==1?9:11); + db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(?,1,'101',10,?,?)",n,n==1?9:11,new BigDecimal(n==1?"1.5876":"1.9404")); } var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(DispatchSourceMapper.class); var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);factory.afterPropertiesSet(); var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DispatchSourceMapper.class); assertThat(mapper.confirmedPlan("101",1).get(0).packageCount()).isEqualTo(9); assertThat(mapper.confirmedPlan("101",2).get(0).packageCount()).isEqualTo(11); + assertThat(mapper.confirmedPlan("101",1).get(0).grossWeightKg()).isEqualByComparingTo("256.725"); + assertThat(mapper.confirmedPlan("101",2).get(0).grossWeightKg()).isEqualByComparingTo("313.775"); + assertThat(mapper.confirmedPlan("101",1).get(0).volumeCbm()).isEqualByComparingTo("1.5876"); + assertThat(mapper.confirmedPlan("101",2).get(0).volumeCbm()).isEqualByComparingTo("1.9404"); + db.update("DELETE FROM ll_order_detail WHERE customer_id='101'"); + assertThat(mapper.confirmedPlan("101",1).get(0).grossWeightKg()).isNull(); assertThat(mapper.confirmedPlan("other",1)).isEmpty(); db.update("UPDATE ll_inbound_plan SET status='RECEIVING'"); assertThat(mapper.confirmedPlan("101",1)).isEmpty(); } + /** 原排车重量体积随实收交接持久化,重复确认不覆盖已经交接的快照。 */ + @Test void receiptReplacesEqualPlannedMeasurementsWithUnequalReceivedMeasurements() throws Exception { + var f=new Fixture(); + f.p.f.db.update("UPDATE ll_dispatch_item SET gross_weight_kg=285.25,volume_cbm=1.764"); + f.p.f.candidates.get(1L).grossWeightKg(new BigDecimal("256.725")); + f.p.f.candidates.get(1L).volumeCbm(new BigDecimal("1.5876")); + f.p.f.candidates.get(2L).grossWeightKg(new BigDecimal("313.775")); + f.p.f.candidates.get(2L).volumeCbm(new BigDecimal("1.9404")); + f.confirm();f.confirm(); + var items=f.p.f.repository.cargo(f.p.f.repository.find(f.p.number).getId()); + items=items.stream().sorted(Comparator.comparing(com.baiyue.lastleg.dispatch.entity.DispatchItem::getPlanPalletId)).toList(); + assertThat(items.get(0).getGrossWeightKg()).isEqualByComparingTo("256.725"); + assertThat(items.get(1).getGrossWeightKg()).isEqualByComparingTo("313.775"); + assertThat(items.get(0).getVolumeCbm()).isEqualByComparingTo("1.5876"); + assertThat(items.get(1).getVolumeCbm()).isEqualByComparingTo("1.9404"); + assertThat(f.quantities()).containsExactly(9,11); + } + + /** 收货来源缺失货量时显式清空旧估算,不能因MyBatis忽略NULL而显示过期计划值。 */ + @Test void missingReceivedMeasurementsClearPlannedValues() throws Exception { + var f=new Fixture(); + f.p.f.db.update("UPDATE ll_dispatch_item SET gross_weight_kg=285.25,volume_cbm=1.764"); + f.p.f.candidates.values().forEach(c->{c.grossWeightKg(null);c.volumeCbm(null);}); + f.confirm(); + assertThat(f.p.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item WHERE gross_weight_kg IS NOT NULL OR volume_cbm IS NOT NULL",Integer.class)).isZero(); + } + /** 入库确认立即将10+10交接为9+11,尚未扫码也已核平预约;出货不再补改数量。 */ @Test void receiptConfirmsNineAndElevenBeforeAnyPickingAndBothPalletsCanLeave() throws Exception { var f=new Fixture();f.confirm(); @@ -103,6 +140,7 @@ class DispatchInboundReceiptPersistenceTest { var f=new Fixture();long dispatch=f.p.f.repository.find(f.p.number).getId(); f.p.f.db.update("UPDATE ll_dispatch_item SET destination_type='PRIVATE'"); f.p.f.db.update("UPDATE ll_inbound_plan_line SET destination_type='PRIVATE'"); + f.p.f.db.update("UPDATE ll_order SET destination_type='PRIVATE' WHERE id=9001"); f.p.f.db.update("UPDATE ll_dispatch_appointment_allocation SET batch_key=?,destination_key='CUSTOMER:9001'","DISPATCH:"+dispatch); f.p.f.candidates.values().forEach(c->c.destinationType("PRIVATE")); f.p.f.db.update("UPDATE ll_dispatch_order SET review_basis_hash=?",(String)ReflectionTestUtils.invokeMethod(f.p.f.service,"basis",f.p.f.repository.find(f.p.number))); @@ -149,6 +187,12 @@ class DispatchInboundReceiptPersistenceTest { p.f.db.update("UPDATE ll_dispatch_item SET customer_id='101',order_id=9001,destination_type='FBA',fba_warehouse_code='ONT8'"); p.f.db.update("UPDATE ll_inventory_position SET customer_id='101',available_packages=0"); p.f.db.update("UPDATE ll_inbound_stock_receipt SET customer_id='101'"); + // 审核按当前库存及已拣归属核平;补齐实托、库存账和订单之间的真实读取关系。 + p.f.db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,customer_id VARCHAR(64),warehouse_pallet_id BIGINT)"); + p.f.db.execute("ALTER TABLE ll_order ADD (inbound_code VARCHAR(128),destination_type VARCHAR(20),fba_warehouse_code VARCHAR(128))"); + p.f.db.update("UPDATE ll_order SET inbound_code='IN-1',destination_type='FBA',fba_warehouse_code='ONT8' WHERE id=9001"); + p.f.db.update("INSERT INTO ll_inventory_balance(id,customer_id,inbound_code) VALUES(21,'101','IN-1'),(22,'101','IN-1')"); + p.f.db.update("INSERT INTO ll_inbound_plan_pallet VALUES(1,'101',501),(2,'101',502)"); p.f.db.update("UPDATE ll_dispatch_order SET estimated_arrival_at=DATEADD('HOUR',1,planned_departure_at)"); p.f.candidates.get(2L).customerId("101");p.f.candidates.get(2L).orderId(9001L);p.f.candidates.get(2L).fbaWarehouseCode("ONT8"); p.f.candidates.get(2L).destinationWarehouseCode("ONT8");