Merge remote-tracking branch 'origin/test' into dev

# Conflicts:
#	scripts/admin-test-env-modules/inbound-fees-contract.test.mjs
This commit is contained in:
2026-09-09 01:48:32 +08:00
11 changed files with 99 additions and 8 deletions
@@ -4461,6 +4461,8 @@ CALL unified_assert_column('baiyue_firstleg', 'fl_carrier_fba_rate', 'destinatio
CALL unified_assert_column('baiyue_firstleg', 'fl_carrier_fba_rate', 'destination_warehouse_id'); CALL unified_assert_column('baiyue_firstleg', 'fl_carrier_fba_rate', 'destination_warehouse_id');
CALL unified_assert_column('baiyue_firstleg', 'fl_carrier_fba_rate', 'destination_name'); CALL unified_assert_column('baiyue_firstleg', 'fl_carrier_fba_rate', 'destination_name');
CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_fee', 'price_book_version_id'); CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_fee', 'price_book_version_id');
CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_fee', 'baseline_chargeable_quantity');
CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_fee', 'tolerance_percent');
CALL unified_assert_column('baiyue_file', 'file_export_task', 'execution_deadline_at'); CALL unified_assert_column('baiyue_file', 'file_export_task', 'execution_deadline_at');
DROP PROCEDURE IF EXISTS unified_assert_table; DROP PROCEDURE IF EXISTS unified_assert_table;
@@ -13526,6 +13528,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'actual_volume_cb
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'actual_weight_kg', '试算时实际重量kg'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'actual_weight_kg', '试算时实际重量kg');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'additional_amount', '附加费金额'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'additional_amount', '附加费金额');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'business_type', '业务类型快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'business_type', '业务类型快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'baseline_chargeable_quantity', '系统按权威测量与生效报价计算的原始计费量');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'calculated_at', '试算时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'calculated_at', '试算时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'calculated_by', '试算人ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'calculated_by', '试算人ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'carrier_id', '历史承运商ID;渠道价格可为空'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'carrier_id', '历史承运商ID;渠道价格可为空');
@@ -13576,6 +13579,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'sent_by', '发
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'subtotal_amount', '基础费用金额'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'subtotal_amount', '基础费用金额');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'tax_mode', '订单买单或退税模式快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'tax_mode', '订单买单或退税模式快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'tenant_id', '客户ID和数据隔离键'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'tenant_id', '客户ID和数据隔离键');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'tolerance_percent', '人工调整允许的最大偏差百分比,边界包含');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'total_amount', '总金额'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'total_amount', '总金额');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'updated_at', '更新时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'updated_at', '更新时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'actual_gross_weight_kg', '仓库实测毛重快照,单位千克'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'actual_gross_weight_kg', '仓库实测毛重快照,单位千克');
@@ -48,6 +48,7 @@ public record ContainerInboundPlanPreviewResponse(
String destinationWarehouseId, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseCode,
String destinationWarehouseName, String destinationWarehouseName,
String fbaWarehouseCode,
int packageCount, int packageCount,
BigDecimal volumeCbm, BigDecimal volumeCbm,
Integer suggestedPalletCount, Integer suggestedPalletCount,
@@ -210,7 +210,7 @@ public class ContainerInboundPlanConversionCoordinator {
for (int localSequence = 1; localSequence <= order.suggestedPalletCount(); localSequence++) { for (int localSequence = 1; localSequence <= order.suggestedPalletCount(); localSequence++) {
items.add(new PreparedItem(order.customerId(), Long.parseLong(order.orderId()), planId, planNo, items.add(new PreparedItem(order.customerId(), Long.parseLong(order.orderId()), planId, planNo,
localSequence, ++globalSequence, "CIP:" + generationId + ":" + globalSequence, localSequence, ++globalSequence, "CIP:" + generationId + ":" + globalSequence,
"FBA".equals(order.destinationType()) ? order.destinationWarehouseCode() : null)); trimToNull(order.fbaWarehouseCode())));
} }
} }
Preparation preparation = new Preparation(warehouseId, List.copyOf(items)); Preparation preparation = new Preparation(warehouseId, List.copyOf(items));
@@ -224,7 +224,7 @@ public class InboundPlanService {
if (warehouseId == null || !Objects.equals(warehouseId, trimToNull(row.overseasWarehouseId()))) { if (warehouseId == null || !Objects.equals(warehouseId, trimToNull(row.overseasWarehouseId()))) {
blockers.add("RECEIVING_WAREHOUSE_INCONSISTENT"); blockers.add("RECEIVING_WAREHOUSE_INCONSISTENT");
} }
boolean fba = "FBA".equals(destinationType(row)); boolean fba = isFbaDestination(row);
if (fba ? trimToNull(row.fbaWarehouseCode()) == null if (fba ? trimToNull(row.fbaWarehouseCode()) == null
: row.destinationCount() != 1 || trimToNull(row.destinationWarehouseId()) == null : row.destinationCount() != 1 || trimToNull(row.destinationWarehouseId()) == null
&& trimToNull(row.destinationWarehouseCode()) == null) blockers.add("DESTINATION_NOT_READY"); && trimToNull(row.destinationWarehouseCode()) == null) blockers.add("DESTINATION_NOT_READY");
@@ -234,13 +234,14 @@ public class InboundPlanService {
Integer suggested = suggestedPalletCount(row, blockers); Integer suggested = suggestedPalletCount(row, blockers);
if (!planned && (suggested == null || suggested <= 0)) blockers.add("PALLET_COUNT_NOT_READY"); if (!planned && (suggested == null || suggested <= 0)) blockers.add("PALLET_COUNT_NOT_READY");
String destinationType = destinationType(row); String destinationType = destinationType(row);
String destinationCode = "FBA".equals(destinationType) && trimToNull(row.fbaWarehouseCode()) != null String fbaWarehouseCode = isFbaDestination(row) ? trimToNull(row.fbaWarehouseCode()) : null;
String destinationCode = fbaWarehouseCode != null
? row.fbaWarehouseCode().trim() : row.destinationWarehouseCode(); ? row.fbaWarehouseCode().trim() : row.destinationWarehouseCode();
String readiness = planned ? "PLANNED" : blockers.isEmpty() ? "READY" : "BLOCKED"; String readiness = planned ? "PLANNED" : blockers.isEmpty() ? "READY" : "BLOCKED";
return new ContainerInboundPlanPreviewResponse.Order(row.customerId(), row.customerName(), return new ContainerInboundPlanPreviewResponse.Order(row.customerId(), row.customerName(),
String.valueOf(row.orderId()), trimToNull(row.orderNo()) == null ? row.sourceOrderNo() : row.orderNo(), String.valueOf(row.orderId()), trimToNull(row.orderNo()) == null ? row.sourceOrderNo() : row.orderNo(),
row.inboundCode(), row.orderType(), destinationType, row.destinationWarehouseId(), row.inboundCode(), row.orderType(), destinationType, row.destinationWarehouseId(),
destinationCode, row.destinationWarehouseName(), row.packageCount(), row.volumeCbm(), destinationCode, row.destinationWarehouseName(), fbaWarehouseCode, row.packageCount(), row.volumeCbm(),
suggested, row.plannedPalletCount(), row.planNo(), row.planStatus(), row.version(), readiness, suggested, row.plannedPalletCount(), row.planNo(), row.planStatus(), row.version(), readiness,
List.copyOf(blockers)); List.copyOf(blockers));
} }
@@ -258,7 +259,7 @@ public class InboundPlanService {
|| row.packageCount() > 0 && row.volumeCbm().signum() == 0) return null; || row.packageCount() > 0 && row.volumeCbm().signum() == 0) return null;
try { try {
return PalletAllocator.allocate(List.of(new PalletAllocator.LineInput(row.orderId(), return PalletAllocator.allocate(List.of(new PalletAllocator.LineInput(row.orderId(),
nullSafe(row.destinationWarehouseId()) + "|" + nullSafe("FBA".equals(destinationType(row)) nullSafe(row.destinationWarehouseId()) + "|" + nullSafe(isFbaDestination(row)
? row.fbaWarehouseCode() : row.destinationWarehouseCode()), ? row.fbaWarehouseCode() : row.destinationWarehouseCode()),
row.volumeCbm(), row.packageCount(), row.recommendedPalletCount()))).pallets().size(); row.volumeCbm(), row.packageCount(), row.recommendedPalletCount()))).pallets().size();
} catch (IllegalArgumentException exception) { } catch (IllegalArgumentException exception) {
@@ -268,10 +269,16 @@ public class InboundPlanService {
private static String destinationType(InboundPlanOrderMapper.ContainerPreviewOrderRow row) { private static String destinationType(InboundPlanOrderMapper.ContainerPreviewOrderRow row) {
if ("ONE_CLICK_DROPSHIP".equals(row.orderType())) return "ONE_CLICK_DROPSHIP"; if ("ONE_CLICK_DROPSHIP".equals(row.orderType())) return "ONE_CLICK_DROPSHIP";
if ("FBA".equals(row.destinationType()) || trimToNull(row.fbaWarehouseCode()) != null) return "FBA"; if (isFbaDestination(row)) return "FBA";
return "PRIVATE_ADDRESS"; return "PRIVATE_ADDRESS";
} }
private static boolean isFbaDestination(InboundPlanOrderMapper.ContainerPreviewOrderRow row) {
String explicit = trimToNull(row.destinationType());
if (explicit != null) return "FBA".equalsIgnoreCase(explicit);
return trimToNull(row.fbaWarehouseCode()) != null;
}
private static int typeRank(String type) { private static int typeRank(String type) {
return switch (type) { case "FBA" -> 0; case "ONE_CLICK_DROPSHIP" -> 1; default -> 2; }; return switch (type) { case "FBA" -> 0; case "ONE_CLICK_DROPSHIP" -> 1; default -> 2; };
} }
@@ -247,7 +247,8 @@ public class LoadingCompletedConsumer {
order.setOverseasWarehouseCode(DEFAULT_RECEIVING_WAREHOUSE_CODE); order.setOverseasWarehouseCode(DEFAULT_RECEIVING_WAREHOUSE_CODE);
order.setOverseasWarehouseName(DEFAULT_RECEIVING_WAREHOUSE_NAME); order.setOverseasWarehouseName(DEFAULT_RECEIVING_WAREHOUSE_NAME);
order.setReceivingWarehouseVersion(0); order.setReceivingWarehouseVersion(0);
order.setFbaWarehouseCode(source.destinationWarehouseCode()); order.setContainerNo(containerNo); order.setFbaWarehouseCode("FBA".equals(destinationType) ? source.destinationWarehouseCode() : null);
order.setContainerNo(containerNo);
order.setContainerStatus("待转柜"); order.setAbnormal(false); order.setDetained(false); order.setContainerStatus("待转柜"); order.setAbnormal(false); order.setDetained(false);
order.setPackageCount(source.packageCount()); order.setPackageCount(source.packageCount());
order.setPalletCount("ONE_CLICK_DROPSHIP".equals(orderType) order.setPalletCount("ONE_CLICK_DROPSHIP".equals(orderType)
@@ -2537,6 +2537,7 @@ components:
destinationWarehouseId: { type: string, nullable: true } destinationWarehouseId: { type: string, nullable: true }
destinationWarehouseCode: { type: string, nullable: true } destinationWarehouseCode: { type: string, nullable: true }
destinationWarehouseName: { type: string, nullable: true } destinationWarehouseName: { type: string, nullable: true }
fbaWarehouseCode: { type: string, nullable: true, description: 底层目的类型为 FBA 时的目的仓编码;一件代发展示分类不改变该事实 }
packageCount: { type: integer } packageCount: { type: integer }
volumeCbm: { type: number } volumeCbm: { type: number }
suggestedPalletCount: { type: integer, nullable: true } suggestedPalletCount: { type: integer, nullable: true }
@@ -225,6 +225,47 @@ class ContainerInboundPlanConversionCoordinatorTest {
verify(warehouse).confirmPalletBatch(any()); verify(warehouse).confirmPalletBatch(any());
} }
@Test
void preparationCarriesUnderlyingFbaCodeForDropshipWithoutClassifyingOverseasDropshipAsFba() {
ConvertContainerInboundPlanRequest request = request();
var fba = previewOrder("100", "11", "RK-FBA", "ABE3", "ABE3");
var overseas = previewOrder("200", "12", "RK-OVERSEAS", "US-LAX-01", null);
var preview = new ContainerInboundPlanPreviewResponse(BOOKING_ID, "MSCU6639870", "31", "US-LAX-01",
"Los Angeles", 7L, SNAPSHOT_HASH, 2, 2, 0, 2, 2, 0, 2, 0, true,
List.of(new ContainerInboundPlanPreviewResponse.Group("ONE_CLICK_DROPSHIP", null, "ABE3", "Amazon ABE3",
1, 1, 1, 0, 1, 0, List.of(fba)),
new ContainerInboundPlanPreviewResponse.Group("ONE_CLICK_DROPSHIP", "31", "US-LAX-01", "Los Angeles",
1, 1, 1, 0, 1, 0, List.of(overseas))));
when(plans.previewContainer(BOOKING_ID)).thenReturn(preview);
when(numbers.next(eq("INBOUND_PLAN"), any(String.class), any(Long.class)))
.thenReturn("IP-FBA", "IP-OVERSEAS");
ContainerPlanConversionCommand command = ReflectionTestUtils.invokeMethod(coordinator, "prepareCommand",
1L, 2L, BOOKING_ID, "FIRST-KEY", request,
ReflectionTestUtils.invokeMethod(coordinator, "hash",
List.of(BOOKING_ID, request.snapshotVersion(), request.snapshotHash())));
ContainerInboundPlanConversionCoordinator.Preparation preparation;
try {
preparation = objectMapper.readValue(command.getPreparationJson(),
ContainerInboundPlanConversionCoordinator.Preparation.class);
} catch (Exception exception) {
throw new AssertionError(exception);
}
assertEquals(java.util.Arrays.asList("ABE3", null), preparation.items().stream()
.map(ContainerInboundPlanConversionCoordinator.PreparedItem::fbaWarehouseCode).toList());
}
private ContainerInboundPlanPreviewResponse.Order previewOrder(String customerId, String orderId,
String inboundCode, String destinationCode,
String fbaWarehouseCode) {
return new ContainerInboundPlanPreviewResponse.Order(customerId, "Customer " + customerId, orderId,
"LM-" + orderId, inboundCode, "ONE_CLICK_DROPSHIP", "ONE_CLICK_DROPSHIP",
fbaWarehouseCode == null ? "31" : null, destinationCode,
fbaWarehouseCode == null ? "Los Angeles" : "Amazon " + destinationCode,
fbaWarehouseCode, 10, BigDecimal.ONE, 1, null, null, null, 0, "READY", List.of());
}
private ConvertContainerInboundPlanRequest request() { private ConvertContainerInboundPlanRequest request() {
return new ConvertContainerInboundPlanRequest(BOOKING_ID, 7L, SNAPSHOT_HASH); return new ConvertContainerInboundPlanRequest(BOOKING_ID, 7L, SNAPSHOT_HASH);
} }
@@ -2,6 +2,7 @@ package com.baiyue.lastleg.inboundplan.service;
import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.baiyue.lastleg.dropship.service.DropshipOrderService;
import com.baiyue.lastleg.dropship.model.DropshipPalletSnapshot; import com.baiyue.lastleg.dropship.model.DropshipPalletSnapshot;
import com.baiyue.lastleg.inboundplan.dto.ContainerInboundPlanPreviewResponse;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanOrderMapper; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanOrderMapper;
import com.fasterxml.jackson.databind.json.JsonMapper; import com.fasterxml.jackson.databind.json.JsonMapper;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@@ -82,6 +83,26 @@ class ContainerInboundPlanPreviewTest {
assertFalse(result.ready()); assertFalse(result.ready());
} }
@Test
void dropshipPreviewKeepsUnderlyingFbaFactSeparateFromItsDisplayCategory() {
when(orderMapper.containerPreviewOrders("BOOKING-DROPSHIP")).thenReturn(List.of(
row(1L, "LM-FBA", "ONE_CLICK_DROPSHIP", "FBA", "ABE3", "Amazon ABE3", 1, null, null, 1),
row(2L, "LM-OVERSEAS", "ONE_CLICK_DROPSHIP", "OVERSEAS", "US-LAX-01", "洛杉矶仓", 1, null, null, 2)
));
when(dropshipOrders.palletSnapshots("100", 1L)).thenReturn(List.of(
new DropshipPalletSnapshot("SRC-P1", 10, List.of())));
when(dropshipOrders.palletSnapshots("100", 2L)).thenReturn(List.of(
new DropshipPalletSnapshot("SRC-P2", 10, List.of())));
var result = service.previewContainer("BOOKING-DROPSHIP");
assertEquals(java.util.Arrays.asList("ABE3", null), result.groups().stream()
.flatMap(group -> group.orders().stream())
.map(ContainerInboundPlanPreviewResponse.Order::fbaWarehouseCode).toList());
assertEquals(List.of("ONE_CLICK_DROPSHIP", "ONE_CLICK_DROPSHIP"), result.groups().stream()
.map(ContainerInboundPlanPreviewResponse.Group::destinationType).toList());
}
private InboundPlanOrderMapper.ContainerPreviewOrderRow row( private InboundPlanOrderMapper.ContainerPreviewOrderRow row(
long id, String orderNo, String orderType, String destinationType, String destinationCode, String destinationName, long id, String orderNo, String orderType, String destinationType, String destinationCode, String destinationName,
int recommendedPallets, String planNo, Integer plannedPallets, int version) { int recommendedPallets, String planNo, Integer plannedPallets, int version) {
@@ -67,7 +67,9 @@ class LoadingCompletedConsumerTest {
source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(), source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),
2L,source.traceId(),source.correlationId(),source.causationId(),node,hash); 2L,source.traceId(),source.correlationId(),source.causationId(),node,hash);
assertEquals(2,service.consume(v2)); assertEquals(2,service.consume(v2));
verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor<LastlegOrder> orderCaptor=ArgumentCaptor.forClass(LastlegOrder.class);
verify(orders,times(2)).insert(orderCaptor.capture());
assertTrue(orderCaptor.getAllValues().stream().allMatch(order->order.getFbaWarehouseCode()==null));
ArgumentCaptor<com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton> cartonCaptor= ArgumentCaptor<com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton> cartonCaptor=
ArgumentCaptor.forClass(com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton.class); ArgumentCaptor.forClass(com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton.class);
verify(cartons,times(8)).insert(cartonCaptor.capture()); verify(cartons,times(8)).insert(cartonCaptor.capture());
@@ -34,3 +34,8 @@ test('initial fee preparation is reusable without confirming or publishing the f
assert.match(helper, /feeStatus === 'CALCULATED'/) assert.match(helper, /feeStatus === 'CALCULATED'/)
assert.doesNotMatch(helper, /\/fees\/confirm|\/fees\/send/) assert.doesNotMatch(helper, /\/fees\/confirm|\/fees\/send/)
}) })
test('automatic fee adjustment confirms the ten percent tolerance boundary', async () => {
const source = await readFile(moduleFile, 'utf8')
assert.match(source, /reason: `\$\{context\.marker\}-自动费用版本调整`, toleranceConfirmed: true/)
})
@@ -50,6 +50,14 @@ test('国内统一初始化支持真实空库且不越界创建海外业务库',
) )
}) })
test('国内统一初始化补齐入库费用人工调整基线字段', () => {
assert.match(sql, /baseline_chargeable_quantity DECIMAL\(18,6\) NOT NULL/)
assert.match(sql, /SET baseline_chargeable_quantity = chargeable_quantity[\s\S]*?WHERE baseline_chargeable_quantity IS NULL/)
assert.match(sql, /tolerance_percent DECIMAL\(5,2\) NOT NULL DEFAULT 10\.00/)
assert.match(sql, /unified_assert_column\('baiyue_firstleg', 'fl_inbound_fee', 'baseline_chargeable_quantity'\)/)
assert.match(sql, /unified_assert_column\('baiyue_firstleg', 'fl_inbound_fee', 'tolerance_percent'\)/)
})
test('三期权限迁移删除退役权限并只保留 canonical 权限', () => { test('三期权限迁移删除退役权限并只保留 canonical 权限', () => {
for (const source of [sql, oceanBookingSchema]) { for (const source of [sql, oceanBookingSchema]) {
assert.match(source, /DELETE FROM iam_permission WHERE id IN \(5102141,5102143,5102144,5102145\)/) assert.match(source, /DELETE FROM iam_permission WHERE id IN \(5102141,5102143,5102144,5102145\)/)