fix(dispatch): calculate received pallet cargo from current stock

This commit is contained in:
2026-10-23 12:14:14 +08:00
parent ecc76d4f91
commit ab70c63988
7 changed files with 255 additions and 22 deletions
@@ -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("<script>" +
"SELECT DISTINCT customer_id FROM ll_inbound_plan WHERE id IN " +
"<foreach collection='planIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
@@ -60,11 +109,11 @@ public interface DispatchCandidateMapper {
"MIN(l.order_id) AS orderId," +
"MIN(ip.customer_id) AS customerId,MIN(o.customer_name) AS customerName," +
"MIN(ip.plan_no) AS planNo,MIN(o.order_type) AS orderType,MIN(o.volume_weight_kg) AS volumeWeightKg," +
"CASE WHEN MIN(CASE WHEN " + SOURCE_GROSS_WEIGHT +
"CASE WHEN NOT " + PLAN_ONLY + " THEN " + CURRENT_GROSS_WEIGHT + " ELSE CASE WHEN MIN(CASE WHEN " + SOURCE_GROSS_WEIGHT +
" &gt;0 AND (COALESCE(l.volume_cbm,0)&lt;=0 OR COALESCE(a.allocated_volume_cbm,0)&gt;0) THEN 1 ELSE 0 END)=1 THEN " +
"NULLIF(SUM(CASE WHEN COALESCE(l.volume_cbm,0)&gt;0 THEN " + SOURCE_GROSS_WEIGHT +
" * a.allocated_volume_cbm / l.volume_cbm ELSE " + SOURCE_GROSS_WEIGHT +
" / NULLIF((SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a2 WHERE a2.line_id=a.line_id),0) END),0) ELSE NULL END AS grossWeightKg," +
" / NULLIF((SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a2 WHERE a2.line_id=a.line_id),0) END),0) ELSE NULL END END AS grossWeightKg," +
"MIN(ip.container_no) AS containerNo," +
CONFIRMED_APPOINTMENT_AT + " AS appointmentAt,NULL AS expectedLoadingAt," +
// 仓ID文本统一保留128字符,避免混合CASE的NONE排序规则及无长度CHAR截断;来源优先级与身份等值不变。
@@ -88,7 +137,8 @@ public interface DispatchCandidateMapper {
"CASE WHEN NOT " + PLAN_ONLY + " THEN COALESCE((SELECT SUM(pos.available_packages) " +
"FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id),0) " +
"ELSE COALESCE(SUM(CASE WHEN a.picked_package_count &gt; 0 THEN a.picked_package_count " +
"ELSE a.planned_package_count END),0) END AS packageCount,p.allocated_volume_cbm AS volumeCbm," +
"ELSE a.planned_package_count END),0) END AS packageCount,CASE WHEN " + PLAN_ONLY +
" THEN p.allocated_volume_cbm ELSE " + CURRENT_VOLUME + " END AS volumeCbm," +
"CASE WHEN " + PLAN_ONLY + " THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," +
"(SELECT GROUP_CONCAT(DISTINCT c.carton_mark ORDER BY c.carton_mark SEPARATOR ',') " +
"FROM ll_inbound_plan_carton c WHERE c.plan_pallet_id=p.id AND NOT EXISTS (" +
@@ -170,11 +220,11 @@ public interface DispatchCandidateMapper {
"MIN(l.order_id) AS orderId," +
"MIN(ip.customer_id) AS customerId,MIN(o.customer_name) AS customerName," +
"MIN(ip.plan_no) AS planNo,MIN(o.order_type) AS orderType,MIN(o.volume_weight_kg) AS volumeWeightKg," +
"CASE WHEN MIN(CASE WHEN " + SOURCE_GROSS_WEIGHT +
"CASE WHEN NOT " + PLAN_ONLY + " THEN " + CURRENT_GROSS_WEIGHT + " ELSE CASE WHEN MIN(CASE WHEN " + SOURCE_GROSS_WEIGHT +
" &gt;0 AND (COALESCE(l.volume_cbm,0)&lt;=0 OR COALESCE(a.allocated_volume_cbm,0)&gt;0) THEN 1 ELSE 0 END)=1 THEN " +
"NULLIF(SUM(CASE WHEN COALESCE(l.volume_cbm,0)&gt;0 THEN " + SOURCE_GROSS_WEIGHT +
" * a.allocated_volume_cbm / l.volume_cbm ELSE " + SOURCE_GROSS_WEIGHT +
" / NULLIF((SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a2 WHERE a2.line_id=a.line_id),0) END),0) ELSE NULL END AS grossWeightKg," +
" / NULLIF((SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a2 WHERE a2.line_id=a.line_id),0) END),0) ELSE NULL END END AS grossWeightKg," +
"MIN(ip.container_no) AS containerNo," +
CONFIRMED_APPOINTMENT_AT + " AS appointmentAt,NULL AS expectedLoadingAt," +
"CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(pos.overseas_warehouse_id) " +
@@ -197,7 +247,8 @@ public interface DispatchCandidateMapper {
"CASE WHEN NOT " + PLAN_ONLY + " THEN COALESCE((SELECT SUM(pos.available_packages) " +
"FROM ll_inventory_position pos WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id),0) " +
"ELSE COALESCE(SUM(CASE WHEN a.picked_package_count &gt; 0 THEN a.picked_package_count " +
"ELSE a.planned_package_count END),0) END AS packageCount,p.allocated_volume_cbm AS volumeCbm," +
"ELSE a.planned_package_count END),0) END AS packageCount,CASE WHEN " + PLAN_ONLY +
" THEN p.allocated_volume_cbm ELSE " + CURRENT_VOLUME + " END AS volumeCbm," +
"CASE WHEN " + PLAN_ONLY + " THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," +
"(SELECT GROUP_CONCAT(DISTINCT c.carton_mark ORDER BY c.carton_mark SEPARATOR ',') " +
"FROM ll_inbound_plan_carton c WHERE c.plan_pallet_id=p.id AND NOT EXISTS (" +
@@ -21,6 +21,12 @@ public interface DispatchItemMapper extends BaseMapper<DispatchItem> {
record ReceiptDispatch(String dispatchNo, Long tripId, Long id) { }
/** 逐托交接数量与收货货量;显式写入NULL,避免未知毛重/体积继续保留旧计划值。 */
@Update("UPDATE ll_dispatch_item SET warehouse_pallet_id=#{warehousePalletId},pallet_no=#{palletNo},"
+ "source_stage=#{sourceStage},package_count=#{packageCount},gross_weight_kg=#{grossWeightKg},volume_cbm=#{volumeCbm} "
+ "WHERE id=#{id} AND customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} AND warehouse_pallet_id IS NULL")
int confirmReceipt(DispatchItem item);
/** 平台整车读取全客户明细,后续库存与费用仍按每条真实客户执行。 */
@Select("SELECT * FROM ll_dispatch_item WHERE dispatch_order_id=#{dispatchOrderId} ORDER BY customer_id,id")
List<DispatchItem> findWholeDispatch(@Param("dispatchOrderId") Long dispatchOrderId);
@@ -22,12 +22,14 @@ public interface DispatchSourceMapper {
record ItemPicked(Long itemId,long packages) { }
/** 收货交接只读已确认逐票实收,不以可用库存、出货资格或计划均分反推箱数。 */
@Select("""
@Select("<script>" + """
SELECT p.id AS planPalletId,p.plan_id AS planId,p.customer_id AS customerId,
p.warehouse_pallet_id AS warehousePalletId,p.pallet_no AS palletNo,
CAST(ip.overseas_warehouse_id AS CHAR(128)) AS overseasWarehouseId,
MIN(l.destination_type) AS destinationType,MIN(l.fba_warehouse_code) AS fbaWarehouseCode,
p.delivery_address AS deliveryAddress,SUM(a.counted_package_count) AS packageCount,'RECEIVED' AS sourceStage
p.delivery_address AS deliveryAddress,SUM(a.counted_package_count) AS packageCount,'RECEIVED' AS sourceStage,
""" + DispatchCandidateMapper.RECEIVED_GROSS_WEIGHT + " AS grossWeightKg,"
+ DispatchCandidateMapper.RECEIVED_VOLUME + " AS volumeCbm " + """
FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id
JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND a.customer_id=p.customer_id
JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id
@@ -35,7 +37,7 @@ public interface DispatchSourceMapper {
GROUP BY p.id,p.plan_id,p.customer_id,p.warehouse_pallet_id,p.pallet_no,ip.overseas_warehouse_id,p.delivery_address
HAVING COUNT(*)=COUNT(a.counted_package_count) AND COUNT(DISTINCT l.destination_type)=1
AND COUNT(DISTINCT COALESCE(l.fba_warehouse_code,''))=1
""")
""" + "</script>")
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
List<com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate> confirmedPlan(@Param("customer") String customer,@Param("id") long id);
@@ -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<DispatchPdaPick> picks(String customer,long dispatch){return picks.wholeDispatch(customer,dispatch);}
}
@@ -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("原计划来源已变化");
@@ -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<String>();
@@ -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<String>();
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("&gt;",">").replace("&lt;","<");
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("&gt;",">").replace("&lt;","<");
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)");
}
}
@@ -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");