From 22133fe6041d3509b90fe60f2e225598026f4c5a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 4 Oct 2026 18:04:21 +0800 Subject: [PATCH] fix(firstleg): project completed loading lists from actual picked cargo --- .../mapper/LoadingListQueryMapper.java | 53 ++++- .../LoadingListActualCargoTest.java | 183 ++++++++++++++++++ 2 files changed, 231 insertions(+), 5 deletions(-) create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListActualCargoTest.java diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java index 1ebb4d447..22a4f2fbc 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java @@ -19,6 +19,35 @@ import java.util.List; /** 基于共享装柜计划表提供清单查询投影和状态推进持久化操作。 */ @Mapper public interface LoadingListQueryMapper { + // Completed projections use the same settled physical scope as LoadingCompletedEventMapper. + // Keep skipped plan rows for history and for the unfinished preload projection. + String ITEM_SCOPE = """ + AND (p.plan_status != 'COMPLETED' OR EXISTS ( + SELECT 1 FROM fl_loading_pick_item actual_pick + JOIN fl_loading_pick_task actual_task ON actual_task.id=actual_pick.pick_task_id + WHERE actual_task.loading_plan_id=p.id AND actual_pick.loading_plan_item_id=i.id + AND actual_pick.item_status IN ('PICKED','PARTIAL') + AND actual_pick.picked_package_count > 0 + AND actual_pick.picked_package_count=i.package_count)) + """; + String PALLET_ITEM_SCOPE = """ + AND (p.plan_status != 'COMPLETED' OR EXISTS ( + SELECT 1 FROM fl_loading_pick_item actual_pick + JOIN fl_loading_pick_task actual_task ON actual_task.id=actual_pick.pick_task_id + WHERE actual_task.loading_plan_id=p.id AND actual_pick.loading_plan_item_id=pallet_item.id + AND actual_pick.item_status IN ('PICKED','PARTIAL') + AND actual_pick.picked_package_count > 0 + AND actual_pick.picked_package_count=pallet_item.package_count)) + """; + String CUSTOMER_ACTUAL_SCOPE = """ + AND EXISTS ( + SELECT 1 FROM fl_loading_pick_item actual_pick + JOIN fl_loading_pick_task actual_task ON actual_task.id=actual_pick.pick_task_id + WHERE actual_task.loading_plan_id=p.id AND actual_pick.loading_plan_item_id=customer_item.id + AND actual_pick.item_status IN ('PICKED','PARTIAL') + AND actual_pick.picked_package_count > 0 + AND actual_pick.picked_package_count=customer_item.package_count) + """; String AGGREGATE_COLUMNS = """ p.id AS plan_id, p.plan_no, p.container_booking_id, p.plan_status, p.remark, p.created_by, p.created_by_name, p.created_at, p.version, @@ -50,28 +79,36 @@ public interface LoadingListQueryMapper { FROM fl_dropship_pallet_carton carton WHERE EXISTS (SELECT 1 FROM fl_loading_plan_item pallet_item WHERE pallet_item.loading_plan_id=p.id AND pallet_item.deleted=0 - AND pallet_item.pallet_id=carton.pallet_id)), 0) AS total_package_count, + AND pallet_item.pallet_id=carton.pallet_id + """ + PALLET_ITEM_SCOPE + """ + )), 0) AS total_package_count, COALESCE(SUM(CASE WHEN i.pallet_id IS NULL THEN i.gross_weight_kg ELSE 0 END), 0) + COALESCE((SELECT SUM(inbound.actual_gross_weight_kg/NULLIF(inbound.total_package_count,0)) FROM fl_dropship_pallet_carton carton JOIN fl_inbound_order inbound ON inbound.id=carton.inbound_id AND inbound.tenant_id=carton.tenant_id WHERE EXISTS (SELECT 1 FROM fl_loading_plan_item pallet_item WHERE pallet_item.loading_plan_id=p.id AND pallet_item.deleted=0 - AND pallet_item.pallet_id=carton.pallet_id)), 0) AS total_gross_weight_kg, + AND pallet_item.pallet_id=carton.pallet_id + """ + PALLET_ITEM_SCOPE + """ + )), 0) AS total_gross_weight_kg, COALESCE(SUM(CASE WHEN i.pallet_id IS NULL THEN i.net_weight_kg ELSE 0 END), 0) + COALESCE((SELECT SUM(inbound.actual_net_weight_kg/NULLIF(inbound.total_package_count,0)) FROM fl_dropship_pallet_carton carton JOIN fl_inbound_order inbound ON inbound.id=carton.inbound_id AND inbound.tenant_id=carton.tenant_id WHERE EXISTS (SELECT 1 FROM fl_loading_plan_item pallet_item WHERE pallet_item.loading_plan_id=p.id AND pallet_item.deleted=0 - AND pallet_item.pallet_id=carton.pallet_id)), 0) AS total_net_weight_kg, + AND pallet_item.pallet_id=carton.pallet_id + """ + PALLET_ITEM_SCOPE + """ + )), 0) AS total_net_weight_kg, COALESCE(SUM(CASE WHEN i.pallet_id IS NULL THEN i.volume_cbm ELSE 0 END), 0) + COALESCE((SELECT SUM(inbound.actual_volume_cbm/NULLIF(inbound.total_package_count,0)) FROM fl_dropship_pallet_carton carton JOIN fl_inbound_order inbound ON inbound.id=carton.inbound_id AND inbound.tenant_id=carton.tenant_id WHERE EXISTS (SELECT 1 FROM fl_loading_plan_item pallet_item WHERE pallet_item.loading_plan_id=p.id AND pallet_item.deleted=0 - AND pallet_item.pallet_id=carton.pallet_id)), 0) AS total_volume_cbm, + AND pallet_item.pallet_id=carton.pallet_id + """ + PALLET_ITEM_SCOPE + """ + )), 0) AS total_volume_cbm, COALESCE(SUM(i.product_quantity), 0) AS total_quantity """; @@ -115,8 +152,11 @@ public interface LoadingListQueryMapper { FROM fl_loading_plan p JOIN fl_container_booking b ON b.id = p.container_booking_id LEFT JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0 + """ + ITEM_SCOPE + """ WHERE p.plan_status = 'COMPLETED' - AND EXISTS (SELECT 1 FROM fl_loading_plan_item customer_item WHERE customer_item.loading_plan_id = p.id AND customer_item.deleted = 0 AND customer_item.account_id = #{query.customerId}) + AND EXISTS (SELECT 1 FROM fl_loading_plan_item customer_item WHERE customer_item.loading_plan_id = p.id AND customer_item.deleted = 0 AND customer_item.account_id = #{query.customerId} + """ + CUSTOMER_ACTUAL_SCOPE + """ + ) AND (p.plan_no LIKE CONCAT('%', #{query.keyword}, '%') OR b.container_no LIKE CONCAT('%', #{query.keyword}, '%') @@ -138,6 +178,7 @@ public interface LoadingListQueryMapper { FROM fl_loading_plan p JOIN fl_container_booking b ON b.id = p.container_booking_id LEFT JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0 + """ + ITEM_SCOPE + """ WHERE p.id = #{planId} GROUP BY """ + AGGREGATE_GROUP) @@ -149,6 +190,7 @@ public interface LoadingListQueryMapper { FROM fl_loading_plan p JOIN fl_container_booking b ON b.id = p.container_booking_id LEFT JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0 + """ + ITEM_SCOPE + """ WHERE p.container_booking_id = #{bookingId} GROUP BY """ + AGGREGATE_GROUP) @@ -224,6 +266,7 @@ public interface LoadingListQueryMapper { JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE' LEFT JOIN fl_inbound_order inbound ON inbound.id=i.inbound_id AND inbound.tenant_id=i.account_id WHERE i.loading_plan_id = #{planId} AND i.deleted = 0 + """ + ITEM_SCOPE + """ ORDER BY i.load_sequence, i.id """) List selectItems(@Param("planId") long planId); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListActualCargoTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListActualCargoTest.java new file mode 100644 index 000000000..f58575bb2 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListActualCargoTest.java @@ -0,0 +1,183 @@ +package com.baiyue.firstleg.repository; + +import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery; +import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery; +import com.baiyue.firstleg.entity.ContainerBookingEntity; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.entity.LoadingPlanEntity; +import com.baiyue.firstleg.entity.LoadingPlanItemEntity; +import com.baiyue.firstleg.mapper.LoadingListQueryMapper; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.lang.reflect.Modifier; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** Executes the real SQL, including pagination COUNT and correlated physical-pallet totals. */ +class LoadingListActualCargoTest { + private JdbcTemplate jdbc; + private LoadingListQueryMapper mapper; + + @BeforeEach + void setup() throws Exception { + var ds = new JdbcDataSource(); + ds.setURL("jdbc:h2:mem:loaded-list-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1"); + jdbc = new JdbcTemplate(ds); + table("fl_loading_plan", LoadingPlanEntity.class); + table("fl_loading_plan_item", LoadingPlanItemEntity.class); + table("fl_container_booking", ContainerBookingEntity.class); + table("fl_inbound_order", InboundOrderEntity.class); + jdbc.execute("CREATE TABLE fl_outbound_dispatch(container_booking_id BIGINT, confirmed_at TIMESTAMP, reversed_at TIMESTAMP, status VARCHAR)"); + jdbc.execute("CREATE TABLE fl_preload_loading_plan_link(loading_plan_id BIGINT, preload_plan_id BIGINT)"); + jdbc.execute("CREATE TABLE fl_preload_plan(id BIGINT, status VARCHAR)"); + jdbc.execute("CREATE TABLE fl_ocean_booking_container_allocation(container_booking_id BIGINT, ocean_booking_id BIGINT)"); + jdbc.execute("CREATE TABLE fl_ocean_booking(id BIGINT, status VARCHAR)"); + jdbc.execute("CREATE TABLE fl_loading_pick_task(id BIGINT, loading_plan_id BIGINT, task_status VARCHAR)"); + jdbc.execute("CREATE TABLE fl_loading_pick_item(loading_plan_item_id BIGINT, pick_task_id BIGINT, item_status VARCHAR, picked_package_count INT)"); + jdbc.execute("CREATE TABLE fl_dropship_pallet_carton(id BIGINT, pallet_id BIGINT, inbound_id BIGINT, tenant_id BIGINT)"); + jdbc.execute("CREATE TABLE fl_inbound_weight_result(tenant_id BIGINT, inbound_id BIGINT, deleted INT, recognition_status VARCHAR)"); + jdbc.update("INSERT INTO fl_container_booking(id,warehouse_id,record_status) VALUES(10,1,'ACTIVE')"); + jdbc.update("INSERT INTO fl_loading_plan(id,container_booking_id,plan_status,created_at) VALUES(1,10,'COMPLETED',CURRENT_TIMESTAMP)"); + jdbc.update("INSERT INTO fl_loading_pick_task VALUES(100,1,'COMPLETED'),(200,2,'COMPLETED')"); + var configuration = new MybatisConfiguration(); + configuration.setMapUnderscoreToCamelCase(true); + configuration.addMapper(LoadingListQueryMapper.class); + var pagination = new MybatisPlusInterceptor(); + pagination.addInnerInterceptor(new PaginationInnerInterceptor(DbType.H2)); + var factory = new MybatisSqlSessionFactoryBean(); + factory.setDataSource(ds); + factory.setConfiguration(configuration); + factory.setPlugins(pagination); + mapper = new SqlSessionTemplate(factory.getObject()).getMapper(LoadingListQueryMapper.class); + } + + @Test + void completedDetailAndListExcludeSkippedAndUnsettledRowsFromAllMeasures() { + ordinaryCargo(); + assertActualTotals(mapper.selectAggregate(1)); + assertActualTotals(mapper.selectAggregateByBooking(10)); + assertThat(mapper.selectItems(1)).extracting(LoadingListQueryMapper.ItemRow::getItemId) + .containsExactly(1L, 2L); + var page = mapper.selectConfirmedPage(new Page<>(1, 1), query(null)); + assertThat(page.getTotal()).isEqualTo(1); + assertThat(page.getRecords()).singleElement().satisfies(this::assertActualTotals); + } + + @Test + void customerWithOnlySkippedCargoCannotMatchCompletedListCountOrRows() { + ordinaryCargo(); + var skipped = mapper.selectConfirmedPage(new Page<>(1, 1), query(22L)); + assertThat(skipped.getTotal()).isZero(); + assertThat(skipped.getRecords()).isEmpty(); + var loaded = mapper.selectConfirmedPage(new Page<>(1, 1), query(11L)); + assertThat(loaded.getTotal()).isEqualTo(1); + assertThat(loaded.getRecords()).singleElement().satisfies(this::assertActualTotals); + } + + @Test + void emptyActualScopeDoesNotHideThePlanIdentityOrFallBackToPlannedTotals() { + item(1, 22, 1, null, "SKIPPED", 0, 100); + var row = mapper.selectAggregate(1); + assertThat(row.getPlanId()).isEqualTo(1); + assertThat(row.getPlanStatus()).isEqualTo("COMPLETED"); + assertThat(row.getTotalPackageCount()).isZero(); + assertThat(row.getTotalGrossWeightKg()).isEqualByComparingTo("0"); + assertThat(mapper.selectItems(1)).isEmpty(); + } + + @Test + void unfinishedPreloadKeepsPlannedCargoIncludingPendingAndSkippedItems() { + ordinaryCargo(); + jdbc.update("UPDATE fl_loading_plan SET plan_status='LOADING' WHERE id=1"); + assertThat(mapper.selectItems(1)).hasSize(6); + assertThat(mapper.selectAggregate(1).getTotalPackageCount()).isEqualTo(8); + var page = mapper.selectPreloadPage(new Page<>(1, 1), + new PreloadQuery(null, null, 22L, null, null, null, 1, 1)); + assertThat(page.getTotal()).isEqualTo(1); + assertThat(page.getRecords()).singleElement().satisfies(row -> + assertThat(row.getTotalPackageCount()).isEqualTo(8)); + } + + @Test + void physicalPalletTotalsIgnoreUnloadedPalletsAndDoNotCountSkuFragmentsTwice() { + item(1, 11, 2, 501L, "PICKED", 2, 100); + item(2, 11, 2, 501L, "PICKED", 2, 100); + item(3, 22, 1, 502L, "SKIPPED", 0, 100); + jdbc.update("INSERT INTO fl_inbound_order(id,tenant_id,total_package_count,actual_gross_weight_kg,actual_net_weight_kg,actual_volume_cbm) VALUES(700,11,2,20,16,4),(701,22,1,999,999,999)"); + jdbc.update("INSERT INTO fl_dropship_pallet_carton VALUES(1,501,700,11),(2,501,700,11),(3,502,701,22)"); + var actual = mapper.selectAggregate(1); + assertThat(actual.getTotalPackageCount()).isEqualTo(2); + assertThat(actual.getTotalGrossWeightKg()).isEqualByComparingTo("20"); + assertThat(actual.getTotalNetWeightKg()).isEqualByComparingTo("16"); + assertThat(actual.getTotalVolumeCbm()).isEqualByComparingTo("4"); + assertThat(actual.getTotalQuantity()).isEqualTo(40); + assertThat(mapper.selectItems(1)).hasSize(2); + assertThat(mapper.selectConfirmedPage(new Page<>(1, 1), query(22L)).getTotal()).isZero(); + jdbc.update("UPDATE fl_loading_plan SET plan_status='EDITING' WHERE id=1"); + assertThat(mapper.selectAggregate(1).getTotalPackageCount()).isEqualTo(3); + assertThat(mapper.selectAggregate(1).getTotalGrossWeightKg()).isEqualByComparingTo("1019"); + assertThat(mapper.selectItems(1)).hasSize(3); + } + + private void ordinaryCargo() { + item(1, 11, 2, null, "PICKED", 2, 100); + item(2, 11, 1, null, "PARTIAL", 1, 100); + item(3, 22, 1, null, "SKIPPED", 0, 100); + item(4, 22, 1, null, "PENDING", 0, 100); + item(5, 22, 1, null, "PICKED", 1, 200); // wrong task belongs to a different plan + item(6, 22, 2, null, "PARTIAL", 1, 100); // not reconciled to the final fragment + item(7, 11, 9, null, "PICKED", 9, 100); + jdbc.update("UPDATE fl_loading_plan_item SET deleted=1 WHERE id=7"); + } + + private void item(long id, long customer, int packages, Long pallet, String status, int picked, long task) { + jdbc.update("INSERT INTO fl_loading_plan_item(id,loading_plan_id,account_id,sku_code,package_count,product_quantity,gross_weight_kg,net_weight_kg,volume_cbm,pallet_id,pallet_count,deleted,load_sequence) VALUES(?,1,?,?,?,?,?,?,?,?,1,0,?)", + id, customer, "SKU-" + id, packages, packages * 10, packages * 5, packages * 4, packages, pallet, id); + jdbc.update("INSERT INTO fl_loading_pick_item VALUES(?,?,?,?)", id, task, status, picked); + } + + private ConfirmedQuery query(Long customer) { + return new ConfirmedQuery(null, customer, null, null, null, 1, 1); + } + + private void assertActualTotals(LoadingListQueryMapper.AggregateRow row) { + assertThat(row.getTotalPackageCount()).isEqualTo(3); + assertThat(row.getTotalQuantity()).isEqualTo(30); + assertThat(row.getSkuCount()).isEqualTo(2); + assertThat(row.getTotalGrossWeightKg()).isEqualByComparingTo("15"); + assertThat(row.getTotalNetWeightKg()).isEqualByComparingTo("12"); + assertThat(row.getTotalVolumeCbm()).isEqualByComparingTo("3"); + } + + private void table(String name, Class entity) { + var columns = new ArrayList(); + for (var type = entity; type != Object.class; type = type.getSuperclass()) { + for (var field : type.getDeclaredFields()) { + var annotation = field.getAnnotation(TableField.class); + if (Modifier.isStatic(field.getModifiers()) || annotation != null && !annotation.exist()) continue; + String column = field.getName().replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase(); + Class value = field.getType(); + String sqlType = value == Long.class || value == long.class ? "BIGINT" + : value == Integer.class || value == int.class || value == Boolean.class || value == boolean.class ? "INT" + : value == BigDecimal.class ? "DECIMAL(20,6)" + : value == java.time.LocalDateTime.class ? "TIMESTAMP" : "VARCHAR(2000)"; + columns.add(column + " " + sqlType); + } + } + jdbc.execute("CREATE TABLE " + name + " (" + String.join(",", columns) + ")"); + } +}