fix(firstleg): project completed loading lists from actual picked cargo

This commit is contained in:
2026-10-04 18:04:21 +08:00
parent cbd5dc0ca2
commit 22133fe604
2 changed files with 231 additions and 5 deletions
@@ -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'
<if test="query.customerId != null">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})</if>
<if test="query.customerId != null">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 + """
)</if>
<if test="query.keyword != null and query.keyword != ''">
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<ItemRow> selectItems(@Param("planId") long planId);
@@ -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<String>();
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) + ")");
}
}