fix: bind received dispatches to current per-order inventory
This commit is contained in:
+4
-3
@@ -133,7 +133,8 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
|
||||
return new Allocation(row.palletId(),row.batchKey(),version,row.customerId(),row.sourceKey(),row.destinationKey(),row.packages());
|
||||
}
|
||||
private void validatePhysical(String customer,PhysicalItem item,List<Allocation> selected,boolean received,boolean picked) {
|
||||
var physical=picked?mapper.pickedPhysical(item.itemId(),customer):mapper.physical(item.planPalletId(),customer);
|
||||
var physical=picked?mapper.pickedPhysical(item.itemId(),customer)
|
||||
:received?mapper.receivedPhysical(item.planPalletId(),customer):mapper.physical(item.planPalletId(),customer);
|
||||
if(physical.isEmpty()) throw conflict("托盘缺少真实逐票分配,不能按整票或整托总数猜测批次");
|
||||
var canonical=AppointmentWorkPhysicalSources.canonical(physical,sources);
|
||||
var remaining=new HashMap<String,Long>();
|
||||
@@ -259,8 +260,8 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
|
||||
}
|
||||
}
|
||||
private List<AppointmentWorkDispatchMapper.Bound> privateAllocation(String customer,long dispatch,PhysicalItem item) {
|
||||
var physical=mapper.physical(item.planPalletId(),customer);
|
||||
if(physical.isEmpty()) return List.of();
|
||||
var physical="RECEIVED".equals(item.sourceStage())?mapper.receivedPhysical(item.planPalletId(),customer):mapper.physical(item.planPalletId(),customer);
|
||||
if(physical.isEmpty()) throw conflict("托盘缺少真实逐票库存归属,不能推算分配数量");
|
||||
if(physical.stream().anyMatch(p->p.destinationKey()==null)) throw conflict("实物目的地尚未核实");
|
||||
var canonical=AppointmentWorkPhysicalSources.canonical(physical,sources); var rows=new ArrayList<AppointmentWorkDispatchMapper.Bound>();
|
||||
for(var part:physical) {
|
||||
|
||||
+15
@@ -122,6 +122,21 @@ public interface AppointmentWorkDispatchMapper {
|
||||
List<Candidate> candidates(long id);
|
||||
@Select("SELECT l.order_id,l.customer_id,CASE WHEN l.destination_type='PRIVATE' THEN CONCAT('CUSTOMER:',l.order_id) ELSE CONCAT('FBA:',l.fba_warehouse_code) END destination_key,CASE WHEN COUNT(a.planned_package_count)=COUNT(a.line_id) THEN SUM(a.planned_package_count) END planned_packages,CASE WHEN COUNT(a.counted_package_count)=COUNT(a.line_id) THEN SUM(a.counted_package_count) END counted_packages FROM ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id WHERE a.pallet_id=#{pallet} AND a.customer_id=#{customer} GROUP BY l.order_id,l.customer_id,l.destination_type,l.fba_warehouse_code")
|
||||
List<Physical> physical(@Param("pallet") long pallet,@Param("customer") String customer);
|
||||
/** 已收货托盘按当前库位逐票归属读取;移托后的库存不能继续使用原收货分配箱数。 */
|
||||
@Select("""
|
||||
SELECT o.id order_id,o.customer_id,
|
||||
CASE WHEN o.destination_type='PRIVATE' THEN CONCAT('CUSTOMER:',o.id) ELSE CONCAT('FBA:',o.fba_warehouse_code) END destination_key,
|
||||
NULL planned_packages,
|
||||
SUM(COALESCE(pos.available_packages,0)+COALESCE(pos.allocated_packages,0)+COALESCE(pos.picked_packages,0)) counted_packages
|
||||
FROM ll_inbound_plan_pallet pp
|
||||
JOIN ll_inventory_position pos ON pos.pallet_id=pp.warehouse_pallet_id AND pos.customer_id=pp.customer_id
|
||||
JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id
|
||||
JOIN ll_order o ON o.customer_id=b.customer_id AND o.inbound_code=b.inbound_code
|
||||
WHERE pp.id=#{pallet} AND pp.customer_id=#{customer}
|
||||
AND COALESCE(pos.available_packages,0)+COALESCE(pos.allocated_packages,0)+COALESCE(pos.picked_packages,0)>0
|
||||
GROUP BY o.id,o.customer_id,o.destination_type,o.fba_warehouse_code
|
||||
""")
|
||||
List<Physical> receivedPhysical(@Param("pallet") long pallet,@Param("customer") String customer);
|
||||
@Select("SELECT a.order_id,a.customer_id,CASE WHEN i.destination_type='PRIVATE' THEN CONCAT('CUSTOMER:',a.order_id) ELSE CONCAT('FBA:',i.fba_warehouse_code) END destination_key,NULL planned_packages,SUM(a.packages) counted_packages FROM ll_dispatch_pda_pick_order a JOIN ll_dispatch_pda_pick p ON p.id=a.pick_id AND p.customer_id=a.customer_id JOIN ll_dispatch_item i ON i.id=p.dispatch_item_id AND i.customer_id=p.customer_id WHERE i.id=#{item} AND a.customer_id=#{customer} GROUP BY a.order_id,a.customer_id,i.destination_type,i.fba_warehouse_code")
|
||||
List<Physical> pickedPhysical(@Param("item") long item,@Param("customer") String customer);
|
||||
@Select("SELECT * FROM ll_inbound_fba_appointment WHERE id=#{id} FOR UPDATE")
|
||||
|
||||
+4
@@ -38,6 +38,8 @@ class AppointmentWorkPreArrangementPersistenceTest {
|
||||
doReturn(List.of(new AppointmentWorkDispatchMapper.Candidate(101L,"P-B",11L,"IP-B","B","RECEIVED",false),new AppointmentWorkDispatchMapper.Candidate(102L,"P-D",12L,"IP-D","D","RECEIVED",false))).when(mapper).candidates(batch.id());
|
||||
doReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"B","FBA:ONT8",60,60))).when(mapper).physical(101L,"B");
|
||||
doReturn(List.of(new AppointmentWorkDispatchMapper.Physical(2L,"D","FBA:ONT8",40,40))).when(mapper).physical(102L,"D");
|
||||
doReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"B","FBA:ONT8",null,60))).when(mapper).receivedPhysical(101L,"B");
|
||||
doReturn(List.of(new AppointmentWorkDispatchMapper.Physical(2L,"D","FBA:ONT8",null,40))).when(mapper).receivedPhysical(102L,"D");
|
||||
var jdbc=new JdbcTemplate(context.getBean(DataSource.class));jdbc.update("INSERT INTO ll_dispatch_order(id,dispatch_no,record_version,status) VALUES(20,'DP-B',0,'ACTIVE')");
|
||||
jdbc.update("INSERT INTO ll_dispatch_item(id,customer_id,dispatch_order_id,plan_pallet_id,source_stage,package_count) VALUES(201,'B',20,101,'RECEIVED',60)");
|
||||
var candidates=new AppointmentWorkDispatchCandidates(mapper,batches,sources,new AppointmentDispatchAvailability(store));
|
||||
@@ -59,10 +61,12 @@ class AppointmentWorkPreArrangementPersistenceTest {
|
||||
assertTrue(assertThrows(ResponseStatusException.class,()->binding.requireDeparture("DP-B")).getReason().contains("缺少真实逐票分配"));
|
||||
// Unchanged real-source guards still reject an unverified pallet and an allocation owned by another customer.
|
||||
doReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"B","FBA:ONT8",60,null))).when(mapper).physical(101L,"B");
|
||||
doReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"B","FBA:ONT8",null,null))).when(mapper).receivedPhysical(101L,"B");
|
||||
assertFalse(candidates.read(batch.batchKey()).candidates().get(0).blockReasons().isEmpty());
|
||||
assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->binding.bindItems(20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(201L,"B",101L,"RECEIVED",60)),after.allocations())));
|
||||
assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->binding.bindItems(20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(201L,"D",101L,"RECEIVED",60)),after.allocations())));
|
||||
doReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"B","FBA:ONT8",61,61))).when(mapper).physical(101L,"B");
|
||||
doReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"B","FBA:ONT8",null,61))).when(mapper).receivedPhysical(101L,"B");
|
||||
var excessive=candidates.read(batch.batchKey()).candidates().get(0);assertFalse(excessive.blockReasons().isEmpty());
|
||||
assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->binding.bindItems(20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(201L,"B",101L,"RECEIVED",61)),excessive.allocations())));
|
||||
assertTrue(mapper.byDispatch(20).isEmpty());
|
||||
|
||||
+24
-2
@@ -58,7 +58,7 @@ class AppointmentWorkDispatchBindingTest {
|
||||
when(mapper.pickedPhysical(30,"C")).thenReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"C","FBA:ONT8",null,4)));
|
||||
when(sources.canonicalOrders(anyList())).thenReturn(List.of(new AppointmentCoverageService.SourceRef("C","ORDER:1",null)));
|
||||
assertDoesNotThrow(()->service.requireDeparture("D-20"));
|
||||
when(mapper.physical(10,"C")).thenReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"C","FBA:ONT8",4,4)));
|
||||
when(mapper.receivedPhysical(10,"C")).thenReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"C","FBA:ONT8",null,4)));
|
||||
var departure=java.time.Instant.parse("2026-10-18T12:00:00Z");
|
||||
var arrival=java.time.Instant.parse("2026-10-18T17:00:00Z");
|
||||
clearInvocations(mapper);
|
||||
@@ -109,7 +109,7 @@ class AppointmentWorkDispatchBindingTest {
|
||||
@Test void privateDispatchBindsOriginalPhysicalSourceAndDepartureRequiresActualPickedQuantity() {
|
||||
var mapper=mock(AppointmentWorkDispatchMapper.class);var appointments=mock(InboundFbaAppointmentService.class);var sources=mock(AppointmentSourceService.class);var changes=mock(AppointmentWorkChanges.class);
|
||||
var service=new AppointmentWorkDispatchBinding(mapper,appointments,sources,changes,mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class),mock(AppointmentWorkDispatchCandidates.class));
|
||||
when(mapper.physical(10,"C")).thenReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"C","CUSTOMER:1",4,4)));
|
||||
when(mapper.receivedPhysical(10,"C")).thenReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"C","CUSTOMER:1",null,4)));
|
||||
when(sources.canonicalOrders(anyList())).thenReturn(List.of(new AppointmentCoverageService.SourceRef("C","ORDER:1",null)));
|
||||
var physical=new AppointmentWorkDispatchMapper.DispatchPhysical(30,10L,"C","RECEIVED",4);
|
||||
when(mapper.dispatchPhysicalById(20)).thenReturn(List.of(physical));when(mapper.dispatchPhysical("D-20")).thenReturn(List.of(physical));
|
||||
@@ -138,6 +138,7 @@ class AppointmentWorkDispatchBindingTest {
|
||||
var service=new AppointmentWorkDispatchBinding(mapper,appointments,sources,changes,mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class),mock(AppointmentWorkDispatchCandidates.class));
|
||||
var part=new AppointmentWorkDispatchMapper.Physical(1L,"C","FBA:ONT8",4,4);
|
||||
when(mapper.physical(10,"C")).thenReturn(List.of(part));
|
||||
when(mapper.receivedPhysical(10,"C")).thenReturn(List.of(part));
|
||||
when(sources.canonicalOrders(anyList())).thenReturn(List.of(new AppointmentCoverageService.SourceRef("C","ORDER:1",null)));
|
||||
var batch=mock(FbaAppointmentResponse.class);when(batch.appointmentStatus()).thenReturn("SUCCESS");when(batch.replacementBatchKeys()).thenReturn(List.of());
|
||||
when(appointments.relatedBatchSnapshots(anyList())).thenReturn(List.of(batch));
|
||||
@@ -152,4 +153,25 @@ class AppointmentWorkDispatchBindingTest {
|
||||
verify(mapper,times(3)).insert(new AppointmentWorkDispatchMapper.Bound(20,30,10,"DISPATCH:20","C","ORDER:1","FBA:ONT8",4));
|
||||
verify(changes,times(3)).requirementsChanged();
|
||||
}
|
||||
|
||||
@Test void receivedBindingUsesCurrentPerOrderInventoryAndRejectsTheOldReceiptTotal() {
|
||||
var mapper=mock(AppointmentWorkDispatchMapper.class);var sources=mock(AppointmentSourceService.class);
|
||||
var service=new AppointmentWorkDispatchBinding(mapper,mock(InboundFbaAppointmentService.class),sources,
|
||||
mock(AppointmentWorkChanges.class),mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class),mock(AppointmentWorkDispatchCandidates.class));
|
||||
when(mapper.receivedPhysical(10,"C")).thenReturn(List.of(
|
||||
new AppointmentWorkDispatchMapper.Physical(1L,"C","FBA:ONT8",null,1),
|
||||
new AppointmentWorkDispatchMapper.Physical(2L,"C","FBA:ONT8",null,2)));
|
||||
when(sources.canonicalOrders(anyList())).thenReturn(List.of(
|
||||
new AppointmentCoverageService.SourceRef("C","ORDER:1",null),new AppointmentCoverageService.SourceRef("C","ORDER:2",null)));
|
||||
when(mapper.dispatchPhysicalById(20)).thenReturn(List.of(new AppointmentWorkDispatchMapper.DispatchPhysical(30,10L,"C","RECEIVED",3)));
|
||||
service.bindItems(20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(30L,"C",10L,"RECEIVED",3)),List.of());
|
||||
verify(mapper).insert(new AppointmentWorkDispatchMapper.Bound(20,30,10,"DISPATCH:20","C","ORDER:1","FBA:ONT8",1));
|
||||
verify(mapper).insert(new AppointmentWorkDispatchMapper.Bound(20,30,10,"DISPATCH:20","C","ORDER:2","FBA:ONT8",2));
|
||||
verify(mapper,never()).physical(anyLong(),anyString());
|
||||
when(mapper.dispatchPhysicalById(20)).thenReturn(List.of(new AppointmentWorkDispatchMapper.DispatchPhysical(30,10L,"C","RECEIVED",4)));
|
||||
clearInvocations(mapper);
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.bindItems(20,
|
||||
List.of(new AppointmentWorkDispatchPort.PhysicalItem(30L,"C",10L,"RECEIVED",4)),List.of()));
|
||||
verify(mapper,never()).insert(any());
|
||||
}
|
||||
}
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package com.baiyue.lastleg.inboundplan.work;
|
||||
|
||||
import java.util.UUID;
|
||||
import org.apache.ibatis.mapping.Environment;
|
||||
import org.apache.ibatis.session.Configuration;
|
||||
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
|
||||
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class AppointmentWorkReceivedPhysicalTest {
|
||||
@Test void currentPalletSourcesKeepActualOrderCountsAfterMovesAndIncludeReservedOrPickedStock() {
|
||||
var ds = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
var db = new JdbcTemplate(ds);
|
||||
db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,customer_id VARCHAR,warehouse_pallet_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR,pallet_id BIGINT,balance_id BIGINT,available_packages INT,allocated_packages INT,picked_packages INT,hold_packages INT)");
|
||||
db.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR,inbound_code VARCHAR)");
|
||||
db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR,inbound_code VARCHAR,destination_type VARCHAR,fba_warehouse_code VARCHAR)");
|
||||
db.update("INSERT INTO ll_inbound_plan_pallet VALUES(10,'C',99)");
|
||||
db.update("INSERT INTO ll_inventory_balance VALUES(1,'C','IN1'),(2,'C','IN2'),(3,'OTHER','IN1')");
|
||||
db.update("INSERT INTO ll_order VALUES(101,'C','IN1','FBA','ONT8'),(102,'C','IN2','PRIVATE',NULL),(201,'OTHER','IN1','FBA','ONT8')");
|
||||
db.update("INSERT INTO ll_inventory_position VALUES(1,'C',99,1,0,1,0,5),(2,'C',99,2,1,0,1,0),(3,'OTHER',99,3,99,0,0,0),(4,'C',100,1,99,0,0,0)");
|
||||
var config = new Configuration(new Environment("test", new JdbcTransactionFactory(), ds));
|
||||
config.setMapUnderscoreToCamelCase(true); config.addMapper(AppointmentWorkDispatchMapper.class);
|
||||
try (var session = new SqlSessionFactoryBuilder().build(config).openSession()) {
|
||||
var mapper = session.getMapper(AppointmentWorkDispatchMapper.class);
|
||||
var rows = mapper.receivedPhysical(10,"C");
|
||||
assertEquals(2,rows.size());
|
||||
assertEquals(1,rows.stream().filter(r->r.orderId()==101).findFirst().orElseThrow().countedPackages());
|
||||
assertEquals(2,rows.stream().filter(r->r.orderId()==102).findFirst().orElseThrow().countedPackages());
|
||||
assertEquals("CUSTOMER:102",rows.stream().filter(r->r.orderId()==102).findFirst().orElseThrow().destinationKey());
|
||||
assertTrue(rows.stream().allMatch(r->r.plannedPackages()==null && r.customerId().equals("C")));
|
||||
assertTrue(mapper.receivedPhysical(10,"OTHER").isEmpty());
|
||||
db.update("UPDATE ll_inventory_position SET available_packages=0,picked_packages=0 WHERE id=2");
|
||||
session.clearCache(); rows=mapper.receivedPhysical(10,"C");
|
||||
assertEquals(1,rows.size()); assertEquals(1,rows.get(0).countedPackages());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user