Merge remote-tracking branch 'origin/test' into test
This commit is contained in:
+10
-1
@@ -157,11 +157,20 @@ public record FirstLegLoadingPlanCompletedV2(
|
||||
/** 托盘下来源入库明细、SKU、箱数、数量及箱号冻结快照;同 SKU 多明细按 sourceInboundDetailId 精确区分。 */
|
||||
public record SkuPackageSnapshot(String sourceInboundDetailId, String skuCode, String productName,
|
||||
int packageCount, int productQuantity,
|
||||
List<String> cartonMarks) {
|
||||
List<String> cartonMarks, java.util.Map<String, Integer> cartonQuantities) {
|
||||
public SkuPackageSnapshot {
|
||||
if (sourceInboundDetailId == null || sourceInboundDetailId.isBlank()) {
|
||||
throw new IllegalArgumentException("sourceInboundDetailId is required for each pallet SKU snapshot");
|
||||
}
|
||||
if (cartonMarks == null || cartonQuantities == null || cartonMarks.size() != packageCount
|
||||
|| new java.util.HashSet<>(cartonMarks).size() != packageCount
|
||||
|| !cartonQuantities.keySet().equals(new java.util.HashSet<>(cartonMarks))
|
||||
|| cartonQuantities.values().stream().anyMatch(quantity -> quantity == null || quantity <= 0)
|
||||
|| cartonQuantities.values().stream().mapToLong(Integer::longValue).sum() != productQuantity) {
|
||||
throw new IllegalArgumentException("cartonQuantities must exactly cover cartonMarks and productQuantity");
|
||||
}
|
||||
cartonMarks = List.copyOf(cartonMarks);
|
||||
cartonQuantities = java.util.Map.copyOf(cartonQuantities);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ components:
|
||||
items: {$ref: '#/components/schemas/FirstLegSkuPackageSnapshotV2'}
|
||||
FirstLegSkuPackageSnapshotV2:
|
||||
type: object
|
||||
required: [sourceInboundDetailId, skuCode, productName, packageCount, productQuantity, cartonMarks]
|
||||
required: [sourceInboundDetailId, skuCode, productName, packageCount, productQuantity, cartonMarks, cartonQuantities]
|
||||
properties:
|
||||
sourceInboundDetailId: {type: string, minLength: 1, description: Exact source inbound detail identity matching the order item; never infer from SKU because multiple details may share a SKU}
|
||||
skuCode: {type: string}
|
||||
@@ -74,6 +74,10 @@ components:
|
||||
packageCount: {type: integer, minimum: 1}
|
||||
productQuantity: {type: integer, minimum: 1}
|
||||
cartonMarks: {type: array, items: {type: string}, minItems: 1}
|
||||
cartonQuantities:
|
||||
type: object
|
||||
description: 箱唛到逐箱实收件数的完整映射;键与 cartonMarks 相同,件数合计等于 productQuantity
|
||||
additionalProperties: {type: integer, minimum: 1}
|
||||
OverseasContainerUnloadedV1:
|
||||
type: object
|
||||
required: [sourceContainerBookingId, containerNo, unloadingCompletedAt, unloadingPhotoUploadedAt]
|
||||
|
||||
+18
-4
@@ -11,15 +11,29 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class FirstLegLoadingPlanCompletedV2Test {
|
||||
@Test
|
||||
void exactCartonQuantitiesRejectMissingMarksAndIncorrectTotals() {
|
||||
var marks = List.of("BOX-A", "BOX-B");
|
||||
var snapshot = new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot("101", "SKU", "产品", 2, 20,
|
||||
marks, java.util.Map.of("BOX-A", 7, "BOX-B", 13));
|
||||
assertThat(snapshot.cartonQuantities().get("BOX-B")).isEqualTo(13);
|
||||
assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot("101", "SKU", "产品", 2, 20,
|
||||
marks, java.util.Map.of("BOX-A", 20))).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot("101", "SKU", "产品", 2, 20,
|
||||
marks, java.util.Map.of("BOX-A", 7, "BOX-B", 12))).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot("101", "SKU", "产品", 2, 20,
|
||||
marks, java.util.Map.of("BOX-A", 0, "BOX-B", 20))).isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void palletSkuRequiresExplicitSourceDetailIncludingJsonDeserialization() throws Exception {
|
||||
var mapper = new ObjectMapper();
|
||||
var first = new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot("101", "SAME", "产品", 1, 10, List.of("BOX-101"));
|
||||
var second = new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot("102", "SAME", "产品", 1, 20, List.of("BOX-102"));
|
||||
var first = new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot("101", "SAME", "产品", 1, 10, List.of("BOX-101"), java.util.Map.of("BOX-101", 10));
|
||||
var second = new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot("102", "SAME", "产品", 1, 20, List.of("BOX-102"), java.util.Map.of("BOX-102", 20));
|
||||
assertThat(mapper.readValue(mapper.writeValueAsString(first), FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot.class))
|
||||
.isEqualTo(first).isNotEqualTo(second);
|
||||
for (String invalid : new String[]{null, "", " "}) {
|
||||
assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot(invalid, "SAME", "产品", 1, 10, List.of("BOX-101")))
|
||||
assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot(invalid, "SAME", "产品", 1, 10, List.of("BOX-101"), java.util.Map.of("BOX-101", 10)))
|
||||
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("sourceInboundDetailId");
|
||||
}
|
||||
assertThatThrownBy(() -> mapper.readValue("{\"skuCode\":\"SAME\",\"packageCount\":1,\"productQuantity\":10,\"cartonMarks\":[\"BOX-101\"]}",
|
||||
@@ -81,7 +95,7 @@ class FirstLegLoadingPlanCompletedV2Test {
|
||||
@Test
|
||||
void carriesDropshipPalletAndCartonSnapshots() throws Exception {
|
||||
var sku = new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot(
|
||||
"101", "SKU", "产品", 2, 20, List.of("BOX-001", "BOX-002"));
|
||||
"101", "SKU", "产品", 2, 20, List.of("BOX-001", "BOX-002"), java.util.Map.of("BOX-001", 7, "BOX-002", 13));
|
||||
var pallet = new FirstLegLoadingPlanCompletedV2.PalletSnapshot("PLT-01", List.of(sku));
|
||||
var order = new FirstLegLoadingPlanCompletedV2.Order(
|
||||
"2", "2", "3", "ORD", "3", "RK", null, null, null,
|
||||
|
||||
@@ -10240,13 +10240,13 @@ CREATE TABLE IF NOT EXISTS fl_truck_appointment (
|
||||
store_name VARCHAR(128) NULL COMMENT '到仓装柜地点名称快照', store_contact VARCHAR(64) NULL COMMENT '到仓地点联系人快照', store_phone VARCHAR(32) NULL COMMENT '到仓地点联系电话快照', store_address VARCHAR(255) NULL COMMENT '到仓装柜地点地址快照',
|
||||
store_city VARCHAR(64) NULL COMMENT '到仓地点城市快照', store_zipcode VARCHAR(16) NULL COMMENT '到仓地点邮编快照', package_count INT UNSIGNED NULL COMMENT '预约货物包装件数快照', gross_weight_kg DECIMAL(18,3) NULL COMMENT '预约货物毛重,单位千克',
|
||||
volume_cbm DECIMAL(18,4) NULL COMMENT '预约货物体积,单位立方米', pallet_count INT UNSIGNED NULL COMMENT '预约托盘数量', po_no VARCHAR(255) NULL COMMENT '关联采购单号', planned_loading_at DATETIME(3) NULL COMMENT '预约到仓装柜时间',
|
||||
actual_arrived_warehouse_at DATETIME(3) NULL COMMENT '车辆实际到达收货仓时间', actual_loading_at DATETIME(3) NULL COMMENT '实际开始装柜时间', actual_departed_at DATETIME(3) NULL COMMENT '车辆实际离仓时间;DEPARTED后供LOADED门禁使用', transit_minutes INT UNSIGNED NULL COMMENT '从到仓到离仓的运输作业耗时,单位分钟', payable_amount DECIMAL(18,2) NULL COMMENT '应付拖车服务商金额;币种由currency指定',
|
||||
currency VARCHAR(8) NOT NULL DEFAULT 'RMB' COMMENT '应付金额币种;仅RMB或USD', status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '拖车预约状态:DRAFT草稿、PENDING_DISPATCH待派车、DISPATCHED已派车、PICKUP_IN_PROGRESS提货中、ARRIVED_WAREHOUSE已到仓、LOADING装柜中、DEPARTED已离仓、COMPLETED已完成、RESCHEDULED已改约、EXCEPTION异常、CANCELLED已取消', active_ocean_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') THEN ocean_booking_id ELSE NULL END) STORED COMMENT '非终态预约的订舱唯一键', revision INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '拖车预约业务修订号;每次状态或改约递增',
|
||||
actual_arrived_warehouse_at DATETIME(3) NULL COMMENT '车辆实际到达收货仓时间', actual_loading_at DATETIME(3) NULL COMMENT '实际开始装柜时间', actual_departed_at DATETIME(3) NULL COMMENT '订舱全部要求货柜确认国内离仓的最终时间', transit_minutes INT UNSIGNED NULL COMMENT '从到仓到离仓的运输作业耗时,单位分钟', payable_amount DECIMAL(18,2) NULL COMMENT '应付拖车服务商金额;币种由currency指定',
|
||||
currency VARCHAR(8) NOT NULL DEFAULT 'RMB' COMMENT '应付金额币种;国内拖车仅RMB,财务以CNY入池', status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '拖车预约状态:DRAFT草稿、PENDING_DISPATCH待派车、DISPATCHED已派车、PICKUP_IN_PROGRESS提货中、ARRIVED_WAREHOUSE已到仓、LOADING装柜中、COMPLETED已完成、RESCHEDULED已改约、EXCEPTION异常、CANCELLED已取消', active_ocean_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') THEN ocean_booking_id ELSE NULL END) STORED COMMENT '非终态预约的订舱唯一键', revision INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '拖车预约业务修订号;每次状态或改约递增',
|
||||
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', remark VARCHAR(1000) NULL COMMENT '平台内部备注或异常说明', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '拖车预约创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '拖车预约最后更新时间', PRIMARY KEY(id), UNIQUE KEY uk_truck_appointment_no(appointment_no), UNIQUE KEY uk_truck_active_ocean_booking(active_ocean_booking_id),
|
||||
KEY idx_truck_appointment_status(status,updated_at), KEY idx_truck_appointment_ocean(ocean_booking_id),
|
||||
CONSTRAINT chk_truck_appointment_status CHECK(status IN ('DRAFT','PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED','COMPLETED','RESCHEDULED','EXCEPTION','CANCELLED')),
|
||||
CONSTRAINT chk_truck_appointment_currency CHECK(currency IN ('RMB','USD'))
|
||||
CONSTRAINT chk_truck_appointment_status CHECK(status IN ('DRAFT','PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','COMPLETED','RESCHEDULED','EXCEPTION','CANCELLED')),
|
||||
CONSTRAINT chk_truck_appointment_currency CHECK(currency IN ('RMB'))
|
||||
) ENGINE=InnoDB COMMENT='三期拖车预约派车聚合';
|
||||
CREATE TABLE IF NOT EXISTS fl_truck_appointment_history (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '拖车预约历史ID', appointment_id BIGINT UNSIGNED NOT NULL COMMENT '所属拖车预约ID', revision INT UNSIGNED NOT NULL COMMENT '该预约业务修订号', action VARCHAR(32) NOT NULL COMMENT '触发状态或改约的业务动作',
|
||||
@@ -15141,7 +15141,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_third_party_call_log', 'signatu
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_third_party_call_log', 'signature_value', '第三方签名原值');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_third_party_call_log', 'tenant_id', '第三方未知/未解析主体哨兵值;不是平台客户记录或平台作用域,不得用于业务数据授权');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'actual_arrived_warehouse_at', '车辆实际到达收货仓时间');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'actual_departed_at', '车辆实际离仓时间;DEPARTED后供LOADED门禁使用');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'actual_departed_at', '订舱全部要求货柜确认国内离仓的最终时间');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'actual_loading_at', '实际开始装柜时间');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'active_ocean_booking_id', '非终态预约的订舱唯一键');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'appointment_no', '平台拖车预约编号');
|
||||
@@ -15150,7 +15150,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'carrier_id
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'carrier_name', '拖车服务商名称快照');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'container_booking_id', '停用保留列;订舱级拖车单不写入、不读取');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'created_at', '拖车预约创建时间');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'currency', '应付金额币种;仅RMB或USD');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'currency', '应付金额币种;国内拖车仅RMB,财务以CNY入池');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'driver_id_card', '司机身份证号;平台内部敏感信息');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'driver_name', '司机姓名');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'driver_phone', '司机联系电话');
|
||||
@@ -15168,7 +15168,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'planned_lo
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'po_no', '关联采购单号');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'remark', '平台内部备注或异常说明');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'revision', '拖车预约业务修订号;每次状态或改约递增');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'status', '拖车预约状态:DRAFT草稿、PENDING_DISPATCH待派车、DISPATCHED已派车、PICKUP_IN_PROGRESS提货中、ARRIVED_WAREHOUSE已到仓、LOADING装柜中、DEPARTED已离仓、COMPLETED已完成、RESCHEDULED已改约、EXCEPTION异常、CANCELLED已取消');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'status', '拖车预约状态:DRAFT草稿、PENDING_DISPATCH待派车、DISPATCHED已派车、PICKUP_IN_PROGRESS提货中、ARRIVED_WAREHOUSE已到仓、LOADING装柜中、COMPLETED已完成、RESCHEDULED已改约、EXCEPTION异常、CANCELLED已取消');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'store_address', '到仓装柜地点地址快照');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'store_city', '到仓地点城市快照');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'store_contact', '到仓地点联系人快照');
|
||||
|
||||
@@ -1528,7 +1528,7 @@ CREATE TABLE IF NOT EXISTS ll_outbound_pallet_item (
|
||||
destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照',
|
||||
package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', picked_package_count INT NOT NULL DEFAULT 0 COMMENT '已拣箱数', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_PICK' COMMENT '拣货状态', sku_code VARCHAR(128) NULL COMMENT '一件代发SKU', order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_ll_outbound_pallet (customer_id,outbound_order_id,warehouse_pallet_id),
|
||||
UNIQUE KEY uk_ll_outbound_pallet (customer_id,outbound_order_id,warehouse_pallet_id,inbound_code,sku_code),
|
||||
KEY idx_ll_outbound_pallet_list (customer_id,outbound_order_id), KEY idx_ll_outbound_pallet_destination (customer_id,destination_warehouse_code)
|
||||
) ENGINE=InnoDB COMMENT='出库托盘明细';
|
||||
|
||||
@@ -1968,6 +1968,7 @@ CREATE TABLE IF NOT EXISTS ll_dropship_order_profile (
|
||||
CREATE TABLE IF NOT EXISTS ll_dropship_carton (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', profile_id BIGINT NOT NULL COMMENT '托盘档案ID',
|
||||
warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛',
|
||||
product_quantity BIGINT NOT NULL COMMENT '该箱实收商品件数',
|
||||
carton_sequence INT NOT NULL COMMENT '箱唛序号', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_INBOUND' COMMENT '业务状态', outbound_order_no VARCHAR(128) NULL COMMENT '出库单号',
|
||||
version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_dropship_carton(customer_id,order_id,carton_mark),
|
||||
|
||||
@@ -92,7 +92,7 @@ public class FinanceFeeSourceEventService {
|
||||
snapshot.sourceType(), snapshot.feeId());
|
||||
jdbc.update("UPDATE fin_cost SET status='VOIDED',voided_at=CURRENT_TIMESTAMP(3),void_reason='来源版本已替代',version=version+1,updated_by=0 WHERE cost_origin='FIRSTLEG_DIRECT_PAID' AND source_system='FIRSTLEG' AND source_type=? AND source_fee_id=? AND status='CONFIRMED'",
|
||||
snapshot.sourceType(), snapshot.feeId());
|
||||
jdbc.update("INSERT INTO fin_fee_source(source_system,source_fee_id,source_fee_version,customer_id,source_type,source_no,direction,order_id,inbound_id,container_id,container_booking_id,fee_item_code,fee_item_name,amount,currency_code,settlement_mode,paid_at,payment_method,payment_reference_no,payment_voucher_file_ids,source_hash,source_snapshot,status) VALUES ('FIRSTLEG',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'AVAILABLE')",
|
||||
jdbc.update("INSERT INTO fin_fee_source(source_system,source_fee_id,source_fee_version,customer_id,source_type,source_no,direction,order_id,inbound_id,container_id,container_booking_id,fee_item_code,fee_item_name,amount,currency_code,settlement_mode,paid_at,payment_method,payment_reference_no,payment_voucher_file_ids,source_hash,source_snapshot,review_status,status) VALUES ('FIRSTLEG',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'APPROVED','AVAILABLE')",
|
||||
snapshot.feeId(), snapshot.feeVersion(), snapshot.customerId(), snapshot.sourceType(),
|
||||
snapshot.inboundCode() == null || snapshot.inboundCode().isBlank() ? snapshot.containerNo() : snapshot.inboundCode(),
|
||||
snapshot.direction(), snapshot.orderId(), snapshot.inboundId(), snapshot.containerId(),
|
||||
|
||||
@@ -50,7 +50,8 @@ class FinanceFeeSourceEventServiceTest {
|
||||
|
||||
assertThat(service.consume(event)).isEqualTo(101L);
|
||||
verify(firstleg).inbound("test-token", 21, 31, 1);
|
||||
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class));
|
||||
verify(jdbc).update(argThat(sql -> sql.startsWith("INSERT INTO fin_fee_source")
|
||||
&& sql.contains("review_status,status") && sql.contains("'APPROVED','AVAILABLE'")), any(Object[].class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -63,7 +64,8 @@ class FinanceFeeSourceEventServiceTest {
|
||||
verify(firstleg).container("test-token", 41, 31, 1);
|
||||
verify(jdbc).update(contains("status='SUPERSEDED'"), eq("CONTAINER_FEE"), eq(31L));
|
||||
ArgumentCaptor<Object[]> values = ArgumentCaptor.forClass(Object[].class);
|
||||
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), values.capture());
|
||||
verify(jdbc).update(argThat(sql -> sql.startsWith("INSERT INTO fin_fee_source")
|
||||
&& sql.contains("review_status,status") && sql.contains("'APPROVED','AVAILABLE'")), values.capture());
|
||||
assertThat(values.getValue()[8]).as("fin_fee_source.container_id keeps loadingPlanId")
|
||||
.isEqualTo(41L);
|
||||
assertThat(values.getValue()[9]).as("fin_fee_source.container_booking_id stores physical container")
|
||||
@@ -107,7 +109,8 @@ class FinanceFeeSourceEventServiceTest {
|
||||
verify(supplierValidator).requireActive(71L, "TRUCKING");
|
||||
verify(jdbc, never()).update(startsWith("INSERT INTO fin_cost"), any(Object[].class));
|
||||
ArgumentCaptor<Object[]> values = ArgumentCaptor.forClass(Object[].class);
|
||||
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), values.capture());
|
||||
verify(jdbc).update(argThat(sql -> sql.startsWith("INSERT INTO fin_fee_source")
|
||||
&& sql.contains("review_status,status") && sql.contains("'APPROVED','AVAILABLE'")), values.capture());
|
||||
assertThat(values.getValue()[20].toString())
|
||||
.contains("\"supplierId\":71", "\"supplierName\":\"现有供应商\"", "\"supplierCategory\":\"TRUCKING\"");
|
||||
}
|
||||
@@ -145,7 +148,8 @@ class FinanceFeeSourceEventServiceTest {
|
||||
assertThat(service.consume(event)).isEqualTo(101L);
|
||||
|
||||
ArgumentCaptor<Object[]> values = ArgumentCaptor.forClass(Object[].class);
|
||||
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), values.capture());
|
||||
verify(jdbc).update(argThat(sql -> sql.startsWith("INSERT INTO fin_fee_source")
|
||||
&& sql.contains("review_status,status") && sql.contains("'APPROVED','AVAILABLE'")), values.capture());
|
||||
assertThat(values.getValue()[14]).isEqualTo("DIRECT_PAID");
|
||||
assertThat(values.getValue()[16]).isEqualTo("BANK_TRANSFER");
|
||||
assertThat(values.getValue()[18]).isEqualTo("[901]");
|
||||
@@ -171,7 +175,8 @@ class FinanceFeeSourceEventServiceTest {
|
||||
|
||||
assertThat(service.consume(event)).isEqualTo(101L);
|
||||
verify(jdbc).update(contains("status='SUPERSEDED'"), eq("INBOUND_FEE"), eq(31L));
|
||||
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class));
|
||||
verify(jdbc).update(argThat(sql -> sql.startsWith("INSERT INTO fin_fee_source")
|
||||
&& sql.contains("review_status,status") && sql.contains("'APPROVED','AVAILABLE'")), any(Object[].class));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+2
-2
@@ -49,7 +49,7 @@
|
||||
| 目的港码头拖车与还空预警 | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`mapper/TerminalDrayageMapper.java`;货柜实际到港后引用 Warehouse `ACTIVE/TRUCKING` 供应商,依次选择车队、预约提柜、确认实际提柜,海外卸柜收货完成后确认还柜;服务端按卸柜完成时间加 72 小时返回报空预警,实际提柜和还柜通过现有 `CarrierScheduleQueryService` 推进 `PICKED_UP/EMPTY_RETURNED`,不复制船节点或车队主档。契约 `src/main/resources/openapi/terminal-drayage-v1.yaml`,基线 `src/main/resources/db/phase3-terminal-drayage-schema.sql` 邮件由 `terminaldrayage/service/TerminalDrayageMailService.java` 入队,`TerminalDrayageMailDispatcher.java` 顺序投递,模板与 SMTP 配置见 `docs/terminal-drayage-mail.md`;首次选队不发信,换队事务内生成取消与请求两封邮件。 |
|
||||
| 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后按 `containerBookingId` 柜级发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,信封固定使用 `tenantId=0` 平台技术范围且 Payload 不携带客户,复用 `LoadingCompletedOutboxRepository`/Relay 投 Event Bridge;海外消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` |
|
||||
| Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 |
|
||||
| 订舱级拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java` 的 `/candidates`、`service/TruckAppointmentService.java`、`repository/TruckAppointmentRepository.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;拖车契约 `src/main/resources/openapi/truck-appointments-v1.yaml`。预约只绑定 `RELEASED` 订舱,候选排除缺提单/有效订舱文件或已有非终态预约的订舱;API 不含客户、柜次、装柜计划或真实柜号,每订舱最多一张非终态预约,PDA 完成不推进拖车状态 |
|
||||
| 订舱级拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java` 的 `/candidates`、`service/TruckAppointmentService.java`、`repository/TruckAppointmentRepository.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;拖车契约 `src/main/resources/openapi/truck-appointments-v1.yaml`。预约只绑定 `RELEASED` 订舱,候选排除缺提单/有效订舱文件或已有非终态预约的订舱;API 不含客户、柜次、装柜计划或真实柜号,每订舱最多一张有效预约,已有完成单不得重建收费;PDA 完成不推进拖车状态,全部要求货柜确认国内离仓后由 `TruckAppointmentCompletionService` 自动完成并经 `TruckAppointmentFeeService` 发布应付 |
|
||||
| 整柜出口报关 | `customs/container/controller/PlatformContainerCustomsController.java`、`customs/container/controller/MerchantContainerCustomsController.java`、`customs/container/service/ContainerCustomsService.java`、`customs/container/mapper/ContainerCustomsMapper.java`;装柜完成后按柜收集退税资料,多对多覆盖入仓码,开始报关冻结,完成文件逐资料一对一回传,全买单柜由平台确认并通过导出中心取明细;契约 `src/main/resources/openapi/customs-v1.yaml`,基线 `src/main/resources/db/phase3-container-customs-schema.sql`;消费者为 Admin `../../apps/admin-web/src/modules/container-loading/components/ContainerCustomsPanel.vue` 和 Client `../../apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue` |
|
||||
| 买单订单明细导出 Provider | `customs/container/controller/LoadingListBuyDeclarationExportController.java`、`service/LoadingListBuyDeclarationExportService.java`;只为 File 导出中心提供 `LOADING_LIST_BUY_DECLARATION_ITEMS` 的已完成装柜实际买单行,不在 Firstleg 生成文件 |
|
||||
| 跨模型桥接 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` |
|
||||
@@ -74,7 +74,7 @@
|
||||
- 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。
|
||||
- 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。
|
||||
- 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。
|
||||
- 拖车预约是平台级订舱聚合,不是客户或柜次资源:请求、实体、查询、响应和附件不得恢复 `customerId/customerName`,公开 DTO 也不得出现 `containerBookingId/loadingPlanId/cabinetNo/containerNo`。创建候选只返回有提单号、ACTIVE `BOOKING_CONFIRMATION` 或 `BILL_OF_LADING` 且没有非终态拖车单的 `RELEASED` 订舱;创建或编辑只冻结订舱仓库/提单与 TRUCKING 供应商快照。每订舱通过查询锁与唯一约束最多一张非终态预约,重复创建返回现有派车单号;PDA 装柜完成不得改变拖车状态。附件使用 `tenantId=0 + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId`,提单文件继续归订舱。
|
||||
- 拖车预约是平台级订舱聚合,不是客户或柜次资源:请求、实体、查询、响应和附件不得恢复 `customerId/customerName`,公开 DTO 也不得出现 `containerBookingId/loadingPlanId/cabinetNo/containerNo`。创建候选只返回有提单号、ACTIVE `BOOKING_CONFIRMATION` 或 `BILL_OF_LADING` 且没有非终态拖车单的 `RELEASED` 订舱;创建或编辑只冻结订舱仓库/提单与 TRUCKING 供应商快照。每订舱通过查询锁与唯一约束最多一张非终态预约,重复创建返回现有派车单号;PDA 装柜完成不得改变拖车状态;只有订舱全部柜型需求均已关联且每柜全部入仓绑定确认国内离仓,才自动完成(含草稿/晚提交)。离仓、拖车创建/编辑/操作统一先锁订舱,完成状态、`AUTO_COMPLETE` 历史、按实装体积守恒分摊的柜次应付及 Finance Outbox 同事务落库;取消/异常单不自动恢复,已有有效或完成单不得重复创建。应付拖车费仅人民币 RMB(财务快照为 CNY),固定使用 `FEE_TYPE_108`,关联 ACTIVE/TRUCKING 供应商,零金额不生成费用;正金额缺归属、有效体积或费用配置时失败关闭,不丢费、不伪造均分。手工 DEPART/COMPLETE 动作已移除。附件使用 `tenantId=0 + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId`,提单文件继续归订舱。
|
||||
- 海外仓业务船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/海外仓业务/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 的正常业务查询槽位固定为 `Asia/Shanghai` 每日 09:00、12:30、16:00;每分钟任务只扫描已到期 `next_allowed_at`,不是每分钟请求船司。成功、无变化、需人工或永久失败进入下一固定槽位;仅技术性 `RETRYABLE_FAILURE` 在当前槽内按15/30分钟退避,含首次最多3次且不得跨下一槽,实际 `EMPTY_RETURNED` 后停止。错过的正常槽位直接顺延,不在服务恢复时补跑;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在国内货柜履约另建查询任务。
|
||||
- 国内出口报关在装柜完成后启用,不再阻塞 `LOADED`。整柜 `fl_customs_container.status=COMPLETED` 才通过离仓报关门禁;完成后以系统身份、确定性幂等键立即尝试确认国内离仓,其他门禁未齐时由受控定时补偿重试,禁止部分入仓成功或重复创建离仓/船司查询事实。
|
||||
- 国内出仓确认以真实 `containerBookingId` 聚合全部入仓绑定并锁定校验;装柜证据按物理货柜只计算一次,PDA `taskId` 仅用于来源审计和任务授权,File 所有权仍固定 `tenantId=0 + containerBookingId`;拖车状态和独立船期只作参考。全部门禁在同一事务通过后推进所有技术行、记录一条可见历史并幂等创建 `CarrierScheduleQuery/DEPARTED_WAREHOUSE`,不允许单入仓局部成功。
|
||||
|
||||
@@ -55,7 +55,6 @@ public interface PrintPdfClient {
|
||||
String downloadFileName, List<DropshipPalletDocument> documents) {}
|
||||
|
||||
record DropshipPalletDocument(String palletNo, String destinationWarehouse,
|
||||
int palletSequence, int totalPallets,
|
||||
String barcodeContent,
|
||||
List<DropshipPalletInboundRow> inboundRows,
|
||||
List<DropshipPalletSkuRow> skuRows) {}
|
||||
|
||||
+11
@@ -150,6 +150,17 @@ public class PlatformDropshipPalletizationController {
|
||||
return ApiResponse.success(service.inboundPallets(accountId, inboundId));
|
||||
}
|
||||
|
||||
@GetMapping("/inbound-orders/{inboundId}/pallets/print")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:inbound:list')")
|
||||
public ResponseEntity<byte[]> printInboundPallets(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long inboundId,
|
||||
@RequestParam @Positive long accountId,
|
||||
@RequestHeader(value = "X-Request-Id", required = false) String requestId) {
|
||||
FirstlegScopeGuard.platform(user, accountId);
|
||||
return printService.generateDropshipPallets(
|
||||
service.inboundPallets(accountId, inboundId), service.inboundCode(accountId, inboundId), requestId);
|
||||
}
|
||||
|
||||
@GetMapping("/inbound-orders/{inboundId}/pallets/{palletId}/print")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:inbound:list')")
|
||||
public ResponseEntity<byte[]> printPallet(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
|
||||
@@ -2,5 +2,5 @@ package com.baiyue.firstleg.domain;
|
||||
|
||||
public enum TruckAppointmentStatus {
|
||||
DRAFT, PENDING_DISPATCH, DISPATCHED, PICKUP_IN_PROGRESS, ARRIVED_WAREHOUSE,
|
||||
LOADING, DEPARTED, COMPLETED, RESCHEDULED, EXCEPTION, CANCELLED
|
||||
LOADING, COMPLETED, RESCHEDULED, EXCEPTION, CANCELLED
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ public final class TruckAppointmentDtos { private TruckAppointmentDtos(){}
|
||||
public record Query(@Size(max=64) String keyword, com.baiyue.firstleg.domain.TruckAppointmentStatus status, @Positive Long oceanBookingId, @Min(1) long page, @Min(1) @Max(200) long size){ public Query{if(page==0)page=1;if(size==0)size=20;} }
|
||||
public record CandidateQuery(@Size(max=64) String keyword,@Min(1) long page,@Min(1) @Max(200) long size){public CandidateQuery{if(page==0)page=1;if(size==0)size=20;}}
|
||||
public record Candidate(Long oceanBookingId,String oceanBookingNo,String billOfLadingNo,Long warehouseId,String warehouseName,String channelCode,String channelName,Boolean bookingDocumentReady){}
|
||||
public record SaveRequest(@Positive Long outboundOrderId,@NotNull @Positive Long oceanBookingId,@Positive Long warehouseId,@NotNull @Positive Long carrierId,@Size(max=128) String carrierName,@Size(max=128) String billOfLadingNo,@Size(max=64) String fleetContact,@Size(max=32) String fleetPhone,@Size(max=64) String driverName,@Size(max=32) String driverPhone,@Size(max=32) String driverIdCard,@Size(max=32) String vehicleNo,@Size(max=128) String storeName,@Size(max=64) String storeContact,@Size(max=32) String storePhone,@Size(max=255) String storeAddress,@Size(max=64) String storeCity,@Size(max=16) String storeZipcode,@PositiveOrZero Integer packageCount,@DecimalMin("0") BigDecimal grossWeightKg,@DecimalMin("0") BigDecimal volumeCbm,@PositiveOrZero Integer palletCount,@Size(max=255) String poNo,@NotNull LocalDateTime plannedLoadingAt,@PositiveOrZero Integer transitMinutes,@DecimalMin("0") BigDecimal payableAmount,@Pattern(regexp="RMB|USD") String currency,@Size(max=1000) String remark,@NotNull @PositiveOrZero Integer expectedVersion){}
|
||||
public record SaveRequest(@Positive Long outboundOrderId,@NotNull @Positive Long oceanBookingId,@Positive Long warehouseId,@NotNull @Positive Long carrierId,@Size(max=128) String carrierName,@Size(max=128) String billOfLadingNo,@Size(max=64) String fleetContact,@Size(max=32) String fleetPhone,@Size(max=64) String driverName,@Size(max=32) String driverPhone,@Size(max=32) String driverIdCard,@Size(max=32) String vehicleNo,@Size(max=128) String storeName,@Size(max=64) String storeContact,@Size(max=32) String storePhone,@Size(max=255) String storeAddress,@Size(max=64) String storeCity,@Size(max=16) String storeZipcode,@PositiveOrZero Integer packageCount,@DecimalMin("0") BigDecimal grossWeightKg,@DecimalMin("0") BigDecimal volumeCbm,@PositiveOrZero Integer palletCount,@Size(max=255) String poNo,@NotNull LocalDateTime plannedLoadingAt,@PositiveOrZero Integer transitMinutes,@DecimalMin("0") @Digits(integer=16,fraction=2) BigDecimal payableAmount,@Pattern(regexp="RMB") String currency,@Size(max=1000) String remark,@NotNull @PositiveOrZero Integer expectedVersion){}
|
||||
public record ActionRequest(@NotNull @PositiveOrZero Integer expectedVersion,@Size(max=500) String reason){}
|
||||
public record AttachmentRequest(@NotNull @Positive Long fileId,@NotBlank @Pattern(regexp="DISPATCH_ORDER|DRIVER_DOCUMENT|VEHICLE_DOCUMENT|OTHER") String attachmentType,@Size(max=255) String fileName,@Size(max=128) String fileHash){ }
|
||||
}
|
||||
|
||||
@@ -254,15 +254,6 @@ public interface DropshipPalletizationMapper {
|
||||
""")
|
||||
List<PrintSkuRow> printSkuRows(@Param("palletId") long palletId);
|
||||
|
||||
@Select("""
|
||||
SELECT (SELECT COUNT(*) FROM fl_dropship_pallet listed
|
||||
WHERE listed.task_id=target.task_id AND listed.id<=target.id) sequence_no,
|
||||
(SELECT COUNT(*) FROM fl_dropship_pallet listed
|
||||
WHERE listed.task_id=target.task_id) total
|
||||
FROM fl_dropship_pallet target
|
||||
WHERE target.id=#{palletId}
|
||||
""")
|
||||
PalletPosition palletPosition(@Param("palletId") long palletId);
|
||||
@Select("""
|
||||
SELECT p.id pallet_id,p.pallet_no,'SEALED_PALLET' unit_type,TRUE indivisible,COUNT(DISTINCT c.id) carton_count,
|
||||
(SELECT COUNT(DISTINCT sku.sku_code) FROM fl_dropship_pallet_carton pc
|
||||
@@ -306,6 +297,5 @@ public interface DropshipPalletizationMapper {
|
||||
record PalletMetrics(BigDecimal weightKg,BigDecimal volumeCbm) {}
|
||||
@Getter @Setter class PrintInboundRow { private Long tenantId,inboundId; private String inboundCode,destinationWarehouse; private Integer cartonCount,itemQuantity; }
|
||||
@Getter @Setter class PrintSkuRow { private String skuCode; private Integer cartonCount,itemQuantity,minUnitsPerCarton,maxUnitsPerCarton; }
|
||||
record PalletPosition(int sequenceNo,int total) {}
|
||||
@Getter @Setter class PalletLine { private Long palletId,tenantId,accountId,inboundId,orderId,warehouseId,destinationWarehouseId,inboundDetailId; private String palletNo,palletStatus,orderNo,inboundCode,channelCode,channelName,destinationWarehouseCode,destinationWarehouseName,customerName,skuCode,cargoName; private Integer cartonCount,actualSkuQuantity; private BigDecimal unitGrossWeightKg,unitNetWeightKg,unitVolumeCbm; }
|
||||
}
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
|
||||
import com.baiyue.firstleg.entity.TruckAppointmentEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface TruckAppointmentCompletionMapper {
|
||||
@Select("SELECT ocean_booking_id FROM fl_ocean_booking_container_allocation WHERE container_booking_id=#{containerId}")
|
||||
Long bookingForContainer(@Param("containerId") long containerId);
|
||||
|
||||
@Select("SELECT * FROM fl_truck_appointment WHERE ocean_booking_id=#{bookingId} "
|
||||
+ "AND status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') ORDER BY id FOR UPDATE")
|
||||
List<TruckAppointmentEntity> activeForUpdate(@Param("bookingId") long bookingId);
|
||||
|
||||
// Current reads are essential: another container can have committed while waiting for the booking lock.
|
||||
@Select("""
|
||||
SELECT r.id requirementId,r.quantity requiredQuantity,c.id containerId,c.container_no containerNo,
|
||||
c.record_status recordStatus,p.id planId,p.plan_status planStatus,
|
||||
i.id itemId,i.account_id customerId,i.order_id orderId,i.order_no orderNo,
|
||||
i.inbound_id inboundId,i.inbound_code inboundCode,i.volume_cbm volumeCbm,
|
||||
d.status dispatchStatus,d.confirmed_at departedAt
|
||||
FROM fl_ocean_booking_container_requirement r
|
||||
LEFT JOIN fl_ocean_booking_container_allocation a ON a.requirement_id=r.id AND a.ocean_booking_id=r.ocean_booking_id
|
||||
LEFT JOIN fl_container_booking c ON c.id=a.container_booking_id
|
||||
LEFT JOIN fl_loading_plan p ON p.container_booking_id=c.id
|
||||
LEFT JOIN fl_loading_plan_item i ON i.loading_plan_id=p.id AND i.deleted=0
|
||||
LEFT JOIN fl_outbound_dispatch d ON d.container_booking_id=c.id
|
||||
AND d.inbound_id=i.inbound_id AND d.tenant_id=i.account_id
|
||||
WHERE r.ocean_booking_id=#{bookingId}
|
||||
ORDER BY r.id,c.id,p.id,i.id
|
||||
FOR UPDATE
|
||||
""")
|
||||
List<DepartureRow> departuresForUpdate(@Param("bookingId") long bookingId);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_truck_appointment SET status='COMPLETED',actual_departed_at=#{departedAt},
|
||||
version=version+1,revision=revision+1,updated_at=CURRENT_TIMESTAMP(3)
|
||||
WHERE id=#{id} AND version=#{version} AND status NOT IN ('COMPLETED','CANCELLED','EXCEPTION')
|
||||
""")
|
||||
int complete(@Param("id") long id, @Param("version") int version,
|
||||
@Param("departedAt") LocalDateTime departedAt);
|
||||
|
||||
record DepartureRow(Long requirementId, Integer requiredQuantity, Long containerId, String containerNo,
|
||||
String recordStatus, Long planId, String planStatus, Long itemId,
|
||||
Long customerId, Long orderId, String orderNo, Long inboundId, String inboundCode,
|
||||
BigDecimal volumeCbm, String dispatchStatus, LocalDateTime departedAt) { }
|
||||
}
|
||||
@@ -11,15 +11,15 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.myba
|
||||
AND d.valid=1 AND d.document_type IN ('BOOKING_CONFIRMATION','BILL_OF_LADING'))
|
||||
AND o.status='RELEASED' AND o.bill_of_lading_no IS NOT NULL AND o.bill_of_lading_no<>''
|
||||
AND NOT EXISTS (SELECT 1 FROM fl_truck_appointment t WHERE t.ocean_booking_id=o.id
|
||||
AND t.status NOT IN ('COMPLETED','CANCELLED','EXCEPTION'))
|
||||
AND t.status NOT IN ('CANCELLED','EXCEPTION'))
|
||||
AND (#{q.keyword} IS NULL OR o.booking_no LIKE CONCAT('%',#{q.keyword},'%')
|
||||
OR o.bill_of_lading_no LIKE CONCAT('%',#{q.keyword},'%'))
|
||||
ORDER BY o.updated_at DESC,o.id DESC
|
||||
</script>
|
||||
""") Page<Candidate> candidates(Page<Candidate> p,@Param("q") CandidateQuery q);
|
||||
@Select("SELECT * FROM fl_truck_appointment WHERE ocean_booking_id=#{oceanBookingId} AND status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') ORDER BY created_at,id LIMIT 1 FOR UPDATE") TruckAppointmentEntity firstActiveForUpdate(@Param("oceanBookingId") long oceanBookingId);
|
||||
@Select("SELECT * FROM fl_truck_appointment WHERE ocean_booking_id=#{oceanBookingId} AND status NOT IN ('CANCELLED','EXCEPTION') ORDER BY created_at,id LIMIT 1 FOR UPDATE") TruckAppointmentEntity firstActiveForUpdate(@Param("oceanBookingId") long oceanBookingId);
|
||||
@Select("<script>SELECT a.*,o.booking_no ocean_booking_no,o.warehouse_name warehouse_name,o.shipping_company_id,o.shipping_company_name,o.booking_agent_id,o.booking_agent_name,(SELECT COUNT(*) FROM fl_truck_appointment_attachment x WHERE x.appointment_id=a.id) attachment_count FROM fl_truck_appointment a LEFT JOIN fl_ocean_booking o ON o.id=a.ocean_booking_id WHERE (#{q.keyword} IS NULL OR a.appointment_no LIKE CONCAT('%',#{q.keyword},'%') OR o.booking_no LIKE CONCAT('%',#{q.keyword},'%') OR a.vehicle_no LIKE CONCAT('%',#{q.keyword},'%') OR a.carrier_name LIKE CONCAT('%',#{q.keyword},'%')) AND (#{q.status} IS NULL OR a.status=#{q.status}) AND (#{q.oceanBookingId} IS NULL OR a.ocean_booking_id=#{q.oceanBookingId}) ORDER BY a.updated_at DESC,a.id DESC</script>") Page<TruckAppointmentEntity> page(Page<TruckAppointmentEntity> p,@Param("q") Query q);
|
||||
@Update("UPDATE fl_truck_appointment SET status=#{toStatus}, actual_arrived_warehouse_at=CASE WHEN #{toStatus}='ARRIVED_WAREHOUSE' THEN CURRENT_TIMESTAMP(3) ELSE actual_arrived_warehouse_at END, actual_loading_at=CASE WHEN #{toStatus}='LOADING' THEN CURRENT_TIMESTAMP(3) ELSE actual_loading_at END, actual_departed_at=CASE WHEN #{toStatus}='DEPARTED' THEN CURRENT_TIMESTAMP(3) ELSE actual_departed_at END, revision=revision+1, version=version+1, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int transition(@Param("id") long id,@Param("fromStatus") String from,@Param("toStatus") String to,@Param("version") int version);
|
||||
@Update("UPDATE fl_truck_appointment SET status=#{toStatus}, actual_arrived_warehouse_at=CASE WHEN #{toStatus}='ARRIVED_WAREHOUSE' THEN CURRENT_TIMESTAMP(3) ELSE actual_arrived_warehouse_at END, actual_loading_at=CASE WHEN #{toStatus}='LOADING' THEN CURRENT_TIMESTAMP(3) ELSE actual_loading_at END, revision=revision+1, version=version+1, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int transition(@Param("id") long id,@Param("fromStatus") String from,@Param("toStatus") String to,@Param("version") int version);
|
||||
@Update("UPDATE fl_truck_appointment SET status='DISPATCHED',bill_of_lading_no=#{billOfLadingNo},revision=revision+1,version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int dispatch(@Param("id") long id,@Param("fromStatus") String fromStatus,@Param("billOfLadingNo") String billOfLadingNo,@Param("version") int version);
|
||||
@Update("UPDATE fl_truck_appointment SET planned_loading_at=#{at}, revision=revision+1, version=version+1, status='RESCHEDULED', remark=#{reason}, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND version=#{version}") int reschedule(@Param("id") long id,@Param("at") java.time.LocalDateTime at,@Param("reason") String reason,@Param("version") int version);
|
||||
@Update("UPDATE fl_truck_appointment SET outbound_order_id=#{e.outboundOrderId},ocean_booking_id=#{e.oceanBookingId},warehouse_id=#{e.warehouseId},carrier_id=#{e.carrierId},carrier_name=#{e.carrierName},bill_of_lading_no=#{e.billOfLadingNo},fleet_contact=#{e.fleetContact},fleet_phone=#{e.fleetPhone},driver_name=#{e.driverName},driver_phone=#{e.driverPhone},driver_id_card=#{e.driverIdCard},vehicle_no=#{e.vehicleNo},store_name=#{e.storeName},store_contact=#{e.storeContact},store_phone=#{e.storePhone},store_address=#{e.storeAddress},store_city=#{e.storeCity},store_zipcode=#{e.storeZipcode},package_count=#{e.packageCount},gross_weight_kg=#{e.grossWeightKg},volume_cbm=#{e.volumeCbm},pallet_count=#{e.palletCount},po_no=#{e.poNo},planned_loading_at=#{e.plannedLoadingAt},transit_minutes=#{e.transitMinutes},payable_amount=#{e.payableAmount},currency=#{e.currency},remark=#{e.remark},version=version+1,revision=revision+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{e.id} AND version=#{version}") int updateEdit(@Param("e") TruckAppointmentEntity e,@Param("version") int version);
|
||||
|
||||
+8
-6
@@ -1,6 +1,7 @@
|
||||
package com.baiyue.firstleg.outbound.dispatch.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.firstleg.service.TruckAppointmentCompletionService;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.outbound.dispatch.domain.OutboundDispatchStatus;
|
||||
@@ -28,13 +29,16 @@ public class OutboundDispatchService {
|
||||
private final OutboundDispatchRepository repository;
|
||||
private final Map<String, OutboundDispatchGateContributor> contributors;
|
||||
private final CarrierScheduleQueryService carrierSchedules;
|
||||
private final TruckAppointmentCompletionService truckCompletion;
|
||||
|
||||
@Autowired
|
||||
public OutboundDispatchService(OutboundDispatchRepository repository,
|
||||
List<OutboundDispatchGateContributor> contributors,
|
||||
CarrierScheduleQueryService carrierSchedules) {
|
||||
CarrierScheduleQueryService carrierSchedules,
|
||||
TruckAppointmentCompletionService truckCompletion) {
|
||||
this.repository = repository;
|
||||
this.carrierSchedules = carrierSchedules;
|
||||
this.truckCompletion = truckCompletion;
|
||||
Map<String, OutboundDispatchGateContributor> indexed = new HashMap<>();
|
||||
for (OutboundDispatchGateContributor contributor : contributors) {
|
||||
if (indexed.put(contributor.code(), contributor) != null) {
|
||||
@@ -44,11 +48,6 @@ public class OutboundDispatchService {
|
||||
this.contributors = Map.copyOf(indexed);
|
||||
}
|
||||
|
||||
public OutboundDispatchService(OutboundDispatchRepository repository,
|
||||
List<OutboundDispatchGateContributor> contributors) {
|
||||
this(repository, contributors, null);
|
||||
}
|
||||
|
||||
public List<Aggregate> list(Query query) {
|
||||
return List.of(aggregate(bindings(query.containerBookingId())));
|
||||
}
|
||||
@@ -73,6 +72,7 @@ public class OutboundDispatchService {
|
||||
public Detail confirm(long containerBookingId, long operatorId, String operatorName,
|
||||
String key, ActionRequest request) {
|
||||
validateKey(key);
|
||||
Long bookingId = truckCompletion.lockForContainer(containerBookingId);
|
||||
String hash = hash("CONFIRM", containerBookingId, request);
|
||||
Detail replay = replay(containerBookingId, operatorId, "CONFIRM_DISPATCH", key, hash);
|
||||
if (replay != null) return replay;
|
||||
@@ -92,6 +92,7 @@ public class OutboundDispatchService {
|
||||
}
|
||||
}
|
||||
OutboundDispatchEntity representative = states.get(0);
|
||||
if (bookingId != null) truckCompletion.completeForBooking(bookingId);
|
||||
history(representative, "CONFIRM_DISPATCH", OutboundDispatchStatus.READY_FOR_DISPATCH.name(),
|
||||
OutboundDispatchStatus.WAITING_FOR_LOADING.name(), request.remark(), operatorId, operatorName);
|
||||
|
||||
@@ -113,6 +114,7 @@ public class OutboundDispatchService {
|
||||
public Detail reverse(long containerBookingId, long operatorId, String operatorName,
|
||||
String key, ActionRequest request) {
|
||||
validateKey(key);
|
||||
truckCompletion.lockForContainer(containerBookingId);
|
||||
String hash = hash("REVERSE", containerBookingId, request);
|
||||
Detail replay = replay(containerBookingId, operatorId, "REVERSE_DISPATCH", key, hash);
|
||||
if (replay != null) return replay;
|
||||
|
||||
+1
-5
@@ -172,12 +172,8 @@ public class InboundCartonLabelPrintService {
|
||||
.stream().map(row -> new PrintPdfClient.DropshipPalletSkuRow(
|
||||
row.getSkuCode(), number(row.getCartonCount()), number(row.getItemQuantity()),
|
||||
unitsPerCarton(row))).toList();
|
||||
DropshipPalletizationMapper.PalletPosition position =
|
||||
palletizationMapper.palletPosition(pallet.palletId());
|
||||
return new PrintPdfClient.DropshipPalletDocument(
|
||||
pallet.palletNo(), destinationWarehouse,
|
||||
position == null ? 1 : position.sequenceNo(), position == null ? 1 : position.total(),
|
||||
pallet.palletNo(), inboundRows, skuRows);
|
||||
pallet.palletNo(), destinationWarehouse, pallet.palletNo(), inboundRows, skuRows);
|
||||
}
|
||||
|
||||
private String unitsPerCarton(DropshipPalletizationMapper.PrintSkuRow row) {
|
||||
|
||||
@@ -264,7 +264,8 @@ public class InboundMeasurementService {
|
||||
"即使只有一箱,也必须上传并确认至少一张称重图片后再提交测量审核");
|
||||
}
|
||||
|
||||
requireConfirmedPackageTotal(inbound, items);
|
||||
int latestDeclaredPackageCount = loadLatestDeclaration(inbound).snapshot().totalPackageCount();
|
||||
requireConfirmedPackageTotal(latestDeclaredPackageCount, items);
|
||||
measurement.setStatus(STATUS_SUBMITTED);
|
||||
if (!repository.updateMeasurement(measurement)) {
|
||||
throw concurrentModification();
|
||||
@@ -275,6 +276,9 @@ public class InboundMeasurementService {
|
||||
inbound.setMeasureCompletedAt(LocalDateTime.now());
|
||||
inbound.setActualGrossWeightKg(confirmedTotals.grossWeightKg());
|
||||
inbound.setActualNetWeightKg(confirmedTotals.netWeightKg());
|
||||
// Once the warehouse confirms every carton, downstream split/loading must use the
|
||||
// same package total that was just validated against the live Order declaration.
|
||||
inbound.setTotalPackageCount(latestDeclaredPackageCount);
|
||||
if ("ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType())) {
|
||||
if (inbound.getPalletCount() == null || inbound.getPalletCount() < 1) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "请先录入预计托数并保存,再提交测量审核");
|
||||
@@ -566,7 +570,7 @@ public class InboundMeasurementService {
|
||||
LatestDeclaration latestDeclaration) {
|
||||
InboundMeasurementResponses.DeclarationSnapshot declaration = latestDeclaration.snapshot();
|
||||
return new InboundMeasurementResponses.Detail(
|
||||
inbound.getId(), inbound.getInboundCode(), inbound.getCompanyName(), inbound.getChannelCode(),
|
||||
inbound.getId(), inbound.getInboundCode(), customerDisplayName(inbound), inbound.getChannelCode(),
|
||||
inbound.getChannelName(), inbound.getFulfillmentType(), inbound.getPalletCount(),
|
||||
inbound.getProcessStatus(), inbound.getMeasureReviewStatus(),
|
||||
RECOMMENDED_PALLET_TYPE,
|
||||
@@ -740,23 +744,28 @@ public class InboundMeasurementService {
|
||||
) {
|
||||
}
|
||||
|
||||
private void requireConfirmedPackageTotal(InboundOrderEntity inbound,
|
||||
private void requireConfirmedPackageTotal(int latestDeclaredPackageCount,
|
||||
List<InboundMeasurementItemEntity> items) {
|
||||
if (inbound.getTotalPackageCount() == null || inbound.getTotalPackageCount() < 1) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "入库单原始总箱数无效,不能提交测量");
|
||||
if (latestDeclaredPackageCount < 1) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "客户最新申报总箱数无效,不能提交测量");
|
||||
}
|
||||
long confirmed = items.stream()
|
||||
.map(InboundMeasurementItemEntity::getPackageCount)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.mapToLong(Integer::longValue)
|
||||
.sum();
|
||||
if (confirmed != inbound.getTotalPackageCount()) {
|
||||
if (confirmed != latestDeclaredPackageCount) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED,
|
||||
"人工确认箱数合计" + confirmed + "箱,与入库单总箱数"
|
||||
+ inbound.getTotalPackageCount() + "箱不一致");
|
||||
"人工确认箱数合计" + confirmed + "箱,与客户最新申报总箱数"
|
||||
+ latestDeclaredPackageCount + "箱不一致");
|
||||
}
|
||||
}
|
||||
|
||||
private static String customerDisplayName(InboundOrderEntity inbound) {
|
||||
if (StringUtils.hasText(inbound.getCompanyName())) return inbound.getCompanyName();
|
||||
return StringUtils.hasText(inbound.getCustomerName()) ? inbound.getCustomerName() : null;
|
||||
}
|
||||
|
||||
private InboundMeasurementResponses.WeightResult toWeight(InboundWeightResultEntity weight) {
|
||||
return toWeight(weight, weight.getId() == null ? null
|
||||
: palletAllocations.findByWeightResult(weight.getTenantId(), weight.getInboundId(), weight.getId()));
|
||||
|
||||
+19
-54
@@ -293,61 +293,28 @@ public class LoadingCompletedOutboxService {
|
||||
|
||||
private List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> palletSnapshots(List<Row> values, List<PalletRow> source,
|
||||
Map<String, PalletManifest> manifests) {
|
||||
if (source.isEmpty()) return List.of();
|
||||
if (source.stream().anyMatch(item -> Boolean.TRUE.equals(item.getActualBinding()))) {
|
||||
return actualPalletSnapshots(values, source, manifests);
|
||||
if (source.isEmpty() || source.stream().anyMatch(item -> !Boolean.TRUE.equals(item.getActualBinding()))) {
|
||||
throw new BizException(ResultCode.CONFLICT, "一件代发必须提供真实逐箱实收托盘快照");
|
||||
}
|
||||
Map<String, List<PalletRow>> byPallet = source.stream().collect(java.util.stream.Collectors.groupingBy(
|
||||
p -> (p.getAllocationNo() == null ? "" : p.getAllocationNo()) + "|" + p.getPalletCode(),
|
||||
LinkedHashMap::new, java.util.stream.Collectors.toList()));
|
||||
int loadedPackages = byPallet.values().stream().map(list -> list.get(0)).map(PalletRow::getAllocatedPackageCount)
|
||||
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
|
||||
if (loadedPackages != sumInt(values, Row::getPackageCount)) {
|
||||
throw new BizException(ResultCode.CONFLICT, "一键代发实装箱数与托盘已拣箱数不一致");
|
||||
}
|
||||
Map<Long, DetailSnapshot> details = new LinkedHashMap<>();
|
||||
values.stream().sorted(Comparator.comparing(Row::getSourceInboundDetailId)).forEach(row ->
|
||||
details.putIfAbsent(row.getSourceInboundDetailId(), new DetailSnapshot(row.getSourceInboundDetailId(),
|
||||
row.getSkuCode(), row.getProductNameZh(), row.getPackageCount(), row.getProductQuantity(), new ArrayList<>())));
|
||||
source.stream().filter(item -> item.getBoxCode() != null).forEach(item -> {
|
||||
DetailSnapshot detail = details.get(item.getSourceInboundDetailId());
|
||||
if (detail != null && !detail.marks().contains(item.getBoxCode())) detail.marks().add(item.getBoxCode());
|
||||
});
|
||||
Map<Long, Integer> remainingPackages = details.values().stream().collect(java.util.stream.Collectors.toMap(
|
||||
DetailSnapshot::detailId, DetailSnapshot::packageCount, (a,b)->a, LinkedHashMap::new));
|
||||
Map<Long, Integer> remainingMarks = details.values().stream().collect(java.util.stream.Collectors.toMap(
|
||||
DetailSnapshot::detailId, ignored -> 0, (a,b)->a, LinkedHashMap::new));
|
||||
List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> result = new ArrayList<>();
|
||||
for (List<PalletRow> palletRows : byPallet.values()) {
|
||||
PalletRow first = palletRows.get(0);
|
||||
int capacity = first.getAllocatedPackageCount() == null ? 0 : first.getAllocatedPackageCount();
|
||||
List<FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot> sku = new ArrayList<>();
|
||||
for (DetailSnapshot detail : details.values()) {
|
||||
int remaining = remainingPackages.getOrDefault(detail.detailId(), 0);
|
||||
if (capacity <= 0 || remaining <= 0) continue;
|
||||
int count = Math.min(capacity, remaining);
|
||||
int totalQuantity = detail.productQuantity() == null ? count
|
||||
: (int) Math.round((double) detail.productQuantity() * count / Math.max(1, detail.packageCount()));
|
||||
int markStart = remainingMarks.getOrDefault(detail.detailId(), 0);
|
||||
int markEnd = Math.min(detail.marks().size(), markStart + count);
|
||||
List<String> marks = new ArrayList<>(detail.marks().subList(markStart, markEnd));
|
||||
if (marks.size() != count) throw new BizException(ResultCode.CONFLICT, "一键代发箱唛数量与托盘箱数不一致");
|
||||
sku.add(new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot(detail.detailId().toString(), detail.skuCode(),
|
||||
detail.productName(), count, totalQuantity, marks));
|
||||
remainingPackages.put(detail.detailId(), remaining - count);
|
||||
remainingMarks.put(detail.detailId(), markEnd);
|
||||
capacity -= count;
|
||||
}
|
||||
if (!sku.isEmpty()) result.add(new FirstLegLoadingPlanCompletedV2.PalletSnapshot(
|
||||
first.getPalletCode() == null ? "PALLET-" + first.getAllocationNo() : first.getPalletCode(), sku));
|
||||
}
|
||||
if (remainingPackages.values().stream().mapToInt(Integer::intValue).sum() != 0) {
|
||||
throw new BizException(ResultCode.CONFLICT, "一键代发托盘未覆盖全部实装货物明细");
|
||||
}
|
||||
return result;
|
||||
return actualPalletSnapshots(values, source, manifests);
|
||||
}
|
||||
|
||||
/** 现场逐箱扫描已给出精确箱到托关系,不再使用旧的按托盘容量顺序推算。 */
|
||||
private Map<String, Integer> cartonQuantities(List<PalletRow> rows, List<String> marks) {
|
||||
Map<String, Integer> quantities = new LinkedHashMap<>();
|
||||
for (PalletRow row : rows) {
|
||||
if (!marks.contains(row.getBoxCode())) continue;
|
||||
Integer quantity = row.getActualSkuQuantity();
|
||||
if (quantity == null || quantity <= 0 || quantities.putIfAbsent(row.getBoxCode(), quantity) != null) {
|
||||
throw new BizException(ResultCode.CONFLICT, "一件代发箱唛缺少有效的逐箱实点 SKU 数量");
|
||||
}
|
||||
}
|
||||
if (quantities.size() != marks.size()) {
|
||||
throw new BizException(ResultCode.CONFLICT, "一件代发逐箱实点 SKU 数量未覆盖全部箱唛");
|
||||
}
|
||||
return quantities;
|
||||
}
|
||||
|
||||
private List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> actualPalletSnapshots(
|
||||
List<Row> values, List<PalletRow> source, Map<String, PalletManifest> manifests) {
|
||||
Map<Long, Row> details = values.stream().collect(java.util.stream.Collectors.toMap(
|
||||
@@ -373,7 +340,7 @@ public class LoadingCompletedOutboxService {
|
||||
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
|
||||
if (quantity <= 0) throw new BizException(ResultCode.CONFLICT, "一件代发现场托盘缺少实点 SKU 数量");
|
||||
skus.add(new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot(detail.getSourceInboundDetailId().toString(), detail.getSkuCode(),
|
||||
detail.getProductNameZh(), marks.size(), quantity, marks));
|
||||
detail.getProductNameZh(), marks.size(), quantity, marks, cartonQuantities(detailRows.getValue(), marks)));
|
||||
actualPackages += marks.size();
|
||||
}
|
||||
PalletManifest manifest = manifests.get(pallet.getKey());
|
||||
@@ -415,8 +382,6 @@ public class LoadingCompletedOutboxService {
|
||||
return "ONE_CLICK_DROPSHIP".equals(value);
|
||||
}
|
||||
|
||||
private record DetailSnapshot(Long detailId, String skuCode, String productName,
|
||||
Integer packageCount, Integer productQuantity, List<String> marks) {}
|
||||
private record PalletManifest(int version, int packageCount, int fragmentCount, String hash) {}
|
||||
|
||||
private void insert(EventEnvelope envelope) {
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.entity.TruckAppointmentHistoryEntity;
|
||||
import com.baiyue.firstleg.mapper.TruckAppointmentCompletionMapper;
|
||||
import com.baiyue.firstleg.mapper.TruckAppointmentCompletionMapper.DepartureRow;
|
||||
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
|
||||
import com.baiyue.firstleg.repository.TruckAppointmentRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class TruckAppointmentCompletionService {
|
||||
private final TruckAppointmentCompletionMapper mapper;
|
||||
private final OceanBookingRepository bookings;
|
||||
private final TruckAppointmentRepository appointments;
|
||||
private final TruckAppointmentFeeService fees;
|
||||
|
||||
/** All writers lock the booking before appointment/outbound rows, serializing multiple container departures. */
|
||||
public void lockBooking(long bookingId) {
|
||||
if (bookings.findForUpdate(bookingId) == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "关联订舱不存在");
|
||||
}
|
||||
}
|
||||
|
||||
public Long lockForContainer(long containerId) {
|
||||
Long bookingId = mapper.bookingForContainer(containerId);
|
||||
if (bookingId != null) lockBooking(bookingId);
|
||||
return bookingId;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void completeForBooking(long bookingId) {
|
||||
lockBooking(bookingId);
|
||||
var active = mapper.activeForUpdate(bookingId);
|
||||
if (active.isEmpty()) return;
|
||||
List<DepartureRow> rows = mapper.departuresForUpdate(bookingId);
|
||||
if (!allDeparted(rows)) return;
|
||||
LocalDateTime departedAt = rows.stream().map(DepartureRow::departedAt)
|
||||
.max(LocalDateTime::compareTo).orElseThrow();
|
||||
for (var appointment : active) {
|
||||
// Fees, their immutable snapshots, Outbox and completion history share this transaction.
|
||||
fees.publish(appointment, rows, departedAt);
|
||||
if (mapper.complete(appointment.getId(), appointment.getVersion(), departedAt) != 1) {
|
||||
throw new BizException(ResultCode.CONFLICT, "拖车预约已变化,请刷新后重试");
|
||||
}
|
||||
var history = new TruckAppointmentHistoryEntity();
|
||||
history.setId(IdWorker.getId());
|
||||
history.setAppointmentId(appointment.getId());
|
||||
history.setRevision(appointment.getRevision() + 1);
|
||||
history.setAction("AUTO_COMPLETE");
|
||||
history.setFromStatus(appointment.getStatus().name());
|
||||
history.setToStatus("COMPLETED");
|
||||
history.setReason("订舱全部要求货柜已确认离开国内仓库,自动完成拖车并发布应付拖车费");
|
||||
history.setOperatorId(0L);
|
||||
history.setOperatorName("系统");
|
||||
history.setCreatedAt(LocalDateTime.now());
|
||||
appointments.history(history);
|
||||
}
|
||||
}
|
||||
|
||||
static boolean allDeparted(List<DepartureRow> rows) {
|
||||
if (rows.isEmpty()) return false;
|
||||
if (rows.stream().anyMatch(row -> row.requirementId() == null || row.requiredQuantity() == null
|
||||
|| row.requiredQuantity() <= 0 || row.containerId() == null || !"ACTIVE".equals(row.recordStatus())
|
||||
|| row.planId() == null || !"COMPLETED".equals(row.planStatus()) || row.itemId() == null
|
||||
|| !"WAITING_FOR_LOADING".equals(row.dispatchStatus()) || row.departedAt() == null)) return false;
|
||||
return rows.stream().collect(Collectors.groupingBy(DepartureRow::requirementId)).values().stream()
|
||||
.allMatch(group -> group.stream().map(DepartureRow::containerId).distinct().count()
|
||||
== group.get(0).requiredQuantity());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.*;
|
||||
import com.baiyue.firstleg.entity.TruckAppointmentEntity;
|
||||
import com.baiyue.firstleg.mapper.TruckAppointmentCompletionMapper.DepartureRow;
|
||||
import com.baiyue.firstleg.repository.FeeItemRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class TruckAppointmentFeeService {
|
||||
private static final String TRUCKING_FEE_CODE = "FEE_TYPE_108";
|
||||
private final FeeItemRepository feeItems;
|
||||
private final ContainerFeeDetailService containerFees;
|
||||
private final WarehouseSupplierSnapshotService suppliers;
|
||||
|
||||
public void publish(TruckAppointmentEntity appointment, List<DepartureRow> rows, LocalDateTime departedAt) {
|
||||
BigDecimal total = appointment.getPayableAmount();
|
||||
if (total == null || total.signum() == 0) return;
|
||||
if (!"RMB".equals(appointment.getCurrency())) throw conflict("国内拖车应付只支持人民币");
|
||||
var item = feeItems.findItemByCode(TRUCKING_FEE_CODE);
|
||||
if (item == null || !"ACTIVE".equals(item.getStatus()) || !Boolean.TRUE.equals(item.getPickupPayable())) {
|
||||
throw conflict("国内拖车费项目不存在、已停用或未启用应付");
|
||||
}
|
||||
suppliers.trucking(appointment.getCarrierId(), null);
|
||||
List<Share> shares = allocate(total, rows);
|
||||
Map<Long, List<Share>> plans = new LinkedHashMap<>();
|
||||
shares.forEach(share -> plans.computeIfAbsent(share.owner().planId(), ignored -> new ArrayList<>()).add(share));
|
||||
for (var entry : plans.entrySet()) {
|
||||
BigDecimal planAmount = entry.getValue().stream().map(Share::amount).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
var fee = new SaveRequest(item.getId(), "PAYABLE", departedAt.toLocalDate(), planAmount, "CNY",
|
||||
appointment.getCarrierId(), "ON_ACCOUNT", null, null, null, null,
|
||||
"国内拖车单 " + appointment.getAppointmentNo() + ";按实装体积分摊;拖车总额 " + total.toPlainString() + " 元");
|
||||
var allocations = entry.getValue().stream().map(share -> new Allocation(share.owner().customerId(),
|
||||
share.owner().orderId(), share.owner().orderNo(), share.owner().inboundId(), share.owner().inboundCode(), share.amount())).toList();
|
||||
var created = containerFees.createAllocationGroup(entry.getKey(), 0L, new AllocationGroupRequest(fee, allocations));
|
||||
containerFees.approveAllocationGroup(entry.getKey(), created.get(0).getAllocationGroupId(), 0L, "系统(拖车离仓完成)");
|
||||
}
|
||||
}
|
||||
|
||||
/** Largest remainders allocate exact cents, with deterministic ties and no zero-amount finance sources. */
|
||||
static List<Share> allocate(BigDecimal total, List<DepartureRow> rows) {
|
||||
if (total.signum() <= 0 || total.scale() > 2) throw conflict("拖车费必须为正数且最多两位小数");
|
||||
Map<Owner, BigDecimal> volumes = new LinkedHashMap<>();
|
||||
for (DepartureRow row : rows) {
|
||||
if (row.customerId() == null || row.customerId() <= 0 || row.orderId() == null || row.inboundId() == null
|
||||
|| row.orderNo() == null || row.orderNo().isBlank() || row.inboundCode() == null || row.inboundCode().isBlank()
|
||||
|| row.volumeCbm() == null || row.volumeCbm().signum() <= 0) {
|
||||
throw conflict("拖车费分摊缺少柜内订单归属或有效实装体积");
|
||||
}
|
||||
var owner = new Owner(row.planId(), row.customerId(), row.orderId(), row.orderNo(), row.inboundId(), row.inboundCode());
|
||||
volumes.merge(owner, row.volumeCbm(), BigDecimal::add);
|
||||
}
|
||||
if (volumes.isEmpty()) throw conflict("拖车费没有可分摊的柜内订单");
|
||||
var owners = volumes.keySet().stream().sorted(Comparator.comparing(Owner::planId)
|
||||
.thenComparing(Owner::customerId).thenComparing(Owner::inboundId)).toList();
|
||||
BigDecimal volume = volumes.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
BigDecimal cents = total.movePointRight(2).setScale(0, RoundingMode.UNNECESSARY);
|
||||
var amounts = new ArrayList<BigDecimal>();
|
||||
var remainders = new ArrayList<BigDecimal>();
|
||||
for (var owner : owners) {
|
||||
BigDecimal numerator = cents.multiply(volumes.get(owner));
|
||||
BigDecimal floor = numerator.divide(volume, 0, RoundingMode.DOWN);
|
||||
amounts.add(floor);
|
||||
remainders.add(numerator.subtract(floor.multiply(volume)));
|
||||
}
|
||||
int remaining = cents.subtract(amounts.stream().reduce(BigDecimal.ZERO, BigDecimal::add)).intValueExact();
|
||||
var ranked = java.util.stream.IntStream.range(0, owners.size()).boxed()
|
||||
.sorted(Comparator.<Integer, BigDecimal>comparing(remainders::get).reversed().thenComparingInt(Integer::intValue)).toList();
|
||||
for (int i = 0; i < remaining; i++) amounts.set(ranked.get(i), amounts.get(ranked.get(i)).add(BigDecimal.ONE));
|
||||
return java.util.stream.IntStream.range(0, owners.size()).filter(i -> amounts.get(i).signum() > 0)
|
||||
.mapToObj(i -> new Share(owners.get(i), amounts.get(i).movePointLeft(2))).toList();
|
||||
}
|
||||
|
||||
record Owner(Long planId, Long customerId, Long orderId, String orderNo, Long inboundId, String inboundCode) { }
|
||||
record Share(Owner owner, BigDecimal amount) { }
|
||||
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); }
|
||||
}
|
||||
@@ -32,8 +32,9 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
@Service
|
||||
public class TruckAppointmentService {
|
||||
private static final Set<String> ATTACHMENT_TYPES = Set.of("DISPATCH_ORDER", "DRIVER_DOCUMENT", "VEHICLE_DOCUMENT", "OTHER");
|
||||
private static final Set<String> CURRENCIES = Set.of("RMB", "USD");
|
||||
private static final Set<String> CURRENCIES = Set.of("RMB");
|
||||
@Resource TruckAppointmentRepository repo;
|
||||
@Resource TruckAppointmentCompletionService completion;
|
||||
@Resource OceanBookingRepository oceanBookings;
|
||||
@Resource OceanBookingDocumentService oceanBookingDocuments;
|
||||
@Resource TruckAppointmentFileClient files;
|
||||
@@ -46,10 +47,23 @@ public class TruckAppointmentService {
|
||||
@Transactional public TruckAppointmentAttachmentEntity upload(long id,String type,MultipartFile file,long uid){String attachmentType=attachmentType(type);required(id);try{var response=files.upload(internalToken,uid,0L,"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),file);if(response==null||!response.successful()||response.data()==null)throw bad("附件上传失败");var m=response.data();TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(m.fileId());a.setAttachmentType(attachmentType);a.setFileName(m.fileName());a.setFileHash(m.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");}}
|
||||
@Transactional public TruckAppointmentAttachmentEntity attach(long id,AttachmentRequest r,long uid){String attachmentType=attachmentType(r.attachmentType());required(id);TruckAppointmentFileClient.Metadata metadata;try{var response=files.metadata(internalToken,0L,"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),r.fileId());if(response==null||!response.successful()||response.data()==null)throw bad("附件不存在或归属不匹配");metadata=response.data();}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");} TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(metadata.fileId());a.setAttachmentType(attachmentType);a.setFileName(metadata.fileName());a.setFileHash(metadata.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}
|
||||
@Transactional public void deleteAttachment(long id,long attachmentId){required(id);if(repo.deleteAttachment(id,attachmentId)!=1)throw new BizException(ResultCode.NOT_FOUND,"附件引用不存在");}
|
||||
@Transactional public TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()==null||r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0");validateRequired(r);TruckAppointmentEntity active=repo.firstActiveForUpdate(r.oceanBookingId());if(active!=null)throw existingAppointmentConflict(active);AssociationContext links=validateAssociations(r);var carrier=suppliers.trucking(r.carrierId(),r.carrierName());TruckAppointmentEntity e=new TruckAppointmentEntity();e.setId(IdWorker.getId());e.setAppointmentNo(documentNumbers.next("TA"));e.setStatus(TruckAppointmentStatus.DRAFT);e.setRevision(0);e.setVersion(0);e.setCreatedAt(LocalDateTime.now());e.setUpdatedAt(e.getCreatedAt());copy(e,r,links,carrier.name());try{repo.insert(e);}catch(DuplicateKeyException exception){if(exception.getMessage()!=null&&exception.getMessage().contains("uk_truck_active_ocean_booking"))throw existingAppointmentConflict(repo.firstActiveForUpdate(r.oceanBookingId()));throw exception;}record(e,null,e.getStatus().name(),"CREATE",null,uid,name);return enrich(e);}
|
||||
@Transactional public TruckAppointmentEntity edit(long id,long uid,String name,SaveRequest r){validateRequired(r);TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion());if(!(e.getStatus()==TruckAppointmentStatus.DRAFT||e.getStatus()==TruckAppointmentStatus.PENDING_DISPATCH||e.getStatus()==TruckAppointmentStatus.RESCHEDULED))throw conflict("当前状态不可编辑");TruckAppointmentEntity active=repo.firstActiveForUpdate(r.oceanBookingId());if(active!=null&&!Objects.equals(active.getId(),e.getId()))throw existingAppointmentConflict(active);AssociationContext links=validateAssociations(r);var carrier=suppliers.trucking(r.carrierId(),r.carrierName());copy(e,r,links,carrier.name());try{if(repo.updateEdit(e,r.expectedVersion())!=1)throw conflict("版本已变化,请刷新后重试");}catch(DuplicateKeyException exception){if(exception.getMessage()!=null&&exception.getMessage().contains("uk_truck_active_ocean_booking"))throw existingAppointmentConflict(repo.firstActiveForUpdate(r.oceanBookingId()));throw exception;}record(e,e.getStatus().name(),e.getStatus().name(),"EDIT",null,uid,name);return enrich(required(id));}
|
||||
@Transactional public TruckAppointmentEntity action(long id,String action,ActionRequest r,String idem,String clientHash,long uid,String name){String requestHash=hash(action,id,r);verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion());if("RESCHEDULE".equals(action))throw conflict("改约请使用专用接口");TruckAppointmentStatus from=e.getStatus(),to=next(from,action);String dispatchBill="DISPATCH".equals(action)?requireDispatchReady(e):null;int changed="DISPATCH".equals(action)?repo.dispatch(id,from.name(),dispatchBill,e.getVersion()):repo.transition(id,from.name(),to.name(),e.getVersion());if(changed!=1)throw conflict("状态或版本已变化,请刷新后重试");TruckAppointmentEntity after=enrich(required(id));record(after,from.name(),to.name(),action,r.reason(),uid,name);return completeOperation(id,action,idem,after);}
|
||||
@Transactional public TruckAppointmentEntity reschedule(long id,LocalDateTime at,ActionRequest r,String idem,String clientHash,long uid,String name){String action="RESCHEDULE";String requestHash=hash(action,id,new ReschedulePayload(at,r));verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion()); if(e.getStatus()==TruckAppointmentStatus.COMPLETED||e.getStatus()==TruckAppointmentStatus.CANCELLED)throw conflict("当前状态不可改约"); String from=e.getStatus().name(); if(repo.reschedule(id,at,r.reason(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试");TruckAppointmentEntity after=enrich(required(id));record(after,from,"RESCHEDULED",action,r.reason(),uid,name);return completeOperation(id,action,idem,after);}
|
||||
@Transactional public TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()==null||r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0");validateRequired(r);completion.lockBooking(r.oceanBookingId());TruckAppointmentEntity active=repo.firstActiveForUpdate(r.oceanBookingId());if(active!=null)throw existingAppointmentConflict(active);AssociationContext links=validateAssociations(r);var carrier=suppliers.trucking(r.carrierId(),r.carrierName());TruckAppointmentEntity e=new TruckAppointmentEntity();e.setId(IdWorker.getId());e.setAppointmentNo(documentNumbers.next("TA"));e.setStatus(TruckAppointmentStatus.DRAFT);e.setRevision(0);e.setVersion(0);e.setCreatedAt(LocalDateTime.now());e.setUpdatedAt(e.getCreatedAt());copy(e,r,links,carrier.name());try{repo.insert(e);}catch(DuplicateKeyException exception){if(exception.getMessage()!=null&&exception.getMessage().contains("uk_truck_active_ocean_booking"))throw existingAppointmentConflict(repo.firstActiveForUpdate(r.oceanBookingId()));throw exception;}record(e,null,e.getStatus().name(),"CREATE",null,uid,name);completion.completeForBooking(e.getOceanBookingId());return enrich(required(e.getId()));}
|
||||
@Transactional public TruckAppointmentEntity edit(long id,long uid,String name,SaveRequest r){validateRequired(r);TruckAppointmentEntity e=required(id);completion.lockBooking(Math.min(e.getOceanBookingId(),r.oceanBookingId()));if(!Objects.equals(e.getOceanBookingId(),r.oceanBookingId()))completion.lockBooking(Math.max(e.getOceanBookingId(),r.oceanBookingId()));checkVersion(e,r.expectedVersion());if(!(e.getStatus()==TruckAppointmentStatus.DRAFT||e.getStatus()==TruckAppointmentStatus.PENDING_DISPATCH||e.getStatus()==TruckAppointmentStatus.RESCHEDULED))throw conflict("当前状态不可编辑");TruckAppointmentEntity active=repo.firstActiveForUpdate(r.oceanBookingId());if(active!=null&&!Objects.equals(active.getId(),e.getId()))throw existingAppointmentConflict(active);AssociationContext links=validateAssociations(r);var carrier=suppliers.trucking(r.carrierId(),r.carrierName());copy(e,r,links,carrier.name());try{if(repo.updateEdit(e,r.expectedVersion())!=1)throw conflict("版本已变化,请刷新后重试");}catch(DuplicateKeyException exception){if(exception.getMessage()!=null&&exception.getMessage().contains("uk_truck_active_ocean_booking"))throw existingAppointmentConflict(repo.firstActiveForUpdate(r.oceanBookingId()));throw exception;}record(e,e.getStatus().name(),e.getStatus().name(),"EDIT",null,uid,name);completion.completeForBooking(e.getOceanBookingId());return enrich(required(id));}
|
||||
@Transactional
|
||||
public TruckAppointmentEntity action(long id,String action,ActionRequest r,String idem,String clientHash,long uid,String name){
|
||||
String requestHash=hash(action,id,r);verifyClientHash(clientHash,requestHash);
|
||||
TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;
|
||||
TruckAppointmentEntity e=required(id);completion.lockBooking(e.getOceanBookingId());checkVersion(e,r.expectedVersion());
|
||||
if("RESCHEDULE".equals(action))throw conflict("改约请使用专用接口");
|
||||
TruckAppointmentStatus from=e.getStatus(),to=next(from,action);
|
||||
String dispatchBill="DISPATCH".equals(action)?requireDispatchReady(e):null;
|
||||
int changed="DISPATCH".equals(action)?repo.dispatch(id,from.name(),dispatchBill,e.getVersion()):repo.transition(id,from.name(),to.name(),e.getVersion());
|
||||
if(changed!=1)throw conflict("状态或版本已变化,请刷新后重试");
|
||||
record(required(id),from.name(),to.name(),action,r.reason(),uid,name);
|
||||
completion.completeForBooking(e.getOceanBookingId());
|
||||
return completeOperation(id,action,idem,enrich(required(id)));
|
||||
}
|
||||
@Transactional public TruckAppointmentEntity reschedule(long id,LocalDateTime at,ActionRequest r,String idem,String clientHash,long uid,String name){String action="RESCHEDULE";String requestHash=hash(action,id,new ReschedulePayload(at,r));verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id);completion.lockBooking(e.getOceanBookingId());checkVersion(e,r.expectedVersion()); if(e.getStatus()==TruckAppointmentStatus.COMPLETED||e.getStatus()==TruckAppointmentStatus.CANCELLED)throw conflict("当前状态不可改约"); TruckAppointmentEntity active=repo.firstActiveForUpdate(e.getOceanBookingId());if(active!=null&&!Objects.equals(active.getId(),e.getId()))throw existingAppointmentConflict(active);String from=e.getStatus().name(); if(repo.reschedule(id,at,r.reason(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试");TruckAppointmentEntity after=enrich(required(id));record(after,from,"RESCHEDULED",action,r.reason(),uid,name);completion.completeForBooking(e.getOceanBookingId());return completeOperation(id,action,idem,enrich(required(id)));}
|
||||
private AssociationContext validateAssociations(SaveRequest r){
|
||||
OceanBookingEntity booking=oceanBookings.find(r.oceanBookingId());
|
||||
if(booking==null)throw new BizException(ResultCode.NOT_FOUND,"关联订舱不存在");
|
||||
@@ -59,9 +73,9 @@ public class TruckAppointmentService {
|
||||
oceanBookingDocuments.requireActiveValidDocument(booking);
|
||||
return new AssociationContext(booking,warehouseId,billOfLadingNo);
|
||||
}
|
||||
private static void validateRequired(SaveRequest r){if(r.oceanBookingId()==null||r.oceanBookingId()<=0)throw bad("oceanBookingId不能为空");if(r.carrierId()==null||r.carrierId()<=0)throw bad("carrierId不能为空");if(r.plannedLoadingAt()==null)throw bad("plannedLoadingAt不能为空");if(r.expectedVersion()==null||r.expectedVersion()<0)throw bad("expectedVersion不合法");}
|
||||
private static void validateRequired(SaveRequest r){if(r.oceanBookingId()==null||r.oceanBookingId()<=0)throw bad("oceanBookingId不能为空");if(r.carrierId()==null||r.carrierId()<=0)throw bad("carrierId不能为空");if(r.plannedLoadingAt()==null)throw bad("plannedLoadingAt不能为空");if(r.expectedVersion()==null||r.expectedVersion()<0)throw bad("expectedVersion不合法");if(r.payableAmount()!=null&&(r.payableAmount().signum()<0||r.payableAmount().scale()>2))throw bad("拖车费必须为非负数且最多两位小数");}
|
||||
private static Long mergeWarehouse(Long requested,Long linked,String source){if(requested!=null&&linked!=null&&!Objects.equals(requested,linked))throw bad("warehouseId与"+source+"不一致");return requested!=null?requested:linked;}
|
||||
private TruckAppointmentStatus next(TruckAppointmentStatus s,String a){return switch(a){case"SUBMIT"->s==TruckAppointmentStatus.DRAFT?TruckAppointmentStatus.PENDING_DISPATCH:badStatus();case"DISPATCH"->s==TruckAppointmentStatus.PENDING_DISPATCH||s==TruckAppointmentStatus.RESCHEDULED?TruckAppointmentStatus.DISPATCHED:badStatus();case"PICKUP"->s==TruckAppointmentStatus.DISPATCHED?TruckAppointmentStatus.PICKUP_IN_PROGRESS:badStatus();case"ARRIVE"->s==TruckAppointmentStatus.PICKUP_IN_PROGRESS?TruckAppointmentStatus.ARRIVED_WAREHOUSE:badStatus();case"LOAD"->s==TruckAppointmentStatus.ARRIVED_WAREHOUSE?TruckAppointmentStatus.LOADING:badStatus();case"DEPART"->s==TruckAppointmentStatus.LOADING?TruckAppointmentStatus.DEPARTED:badStatus();case"COMPLETE"->s==TruckAppointmentStatus.DEPARTED?TruckAppointmentStatus.COMPLETED:badStatus();case"EXCEPTION"->s!=TruckAppointmentStatus.COMPLETED&&s!=TruckAppointmentStatus.CANCELLED?TruckAppointmentStatus.EXCEPTION:badStatus();case"CANCEL"->s!=TruckAppointmentStatus.COMPLETED?TruckAppointmentStatus.CANCELLED:badStatus();default->badStatus();};}
|
||||
private TruckAppointmentStatus next(TruckAppointmentStatus s,String a){return switch(a){case"SUBMIT"->s==TruckAppointmentStatus.DRAFT?TruckAppointmentStatus.PENDING_DISPATCH:badStatus();case"DISPATCH"->s==TruckAppointmentStatus.PENDING_DISPATCH||s==TruckAppointmentStatus.RESCHEDULED?TruckAppointmentStatus.DISPATCHED:badStatus();case"PICKUP"->s==TruckAppointmentStatus.DISPATCHED?TruckAppointmentStatus.PICKUP_IN_PROGRESS:badStatus();case"ARRIVE"->s==TruckAppointmentStatus.PICKUP_IN_PROGRESS?TruckAppointmentStatus.ARRIVED_WAREHOUSE:badStatus();case"LOAD"->s==TruckAppointmentStatus.ARRIVED_WAREHOUSE?TruckAppointmentStatus.LOADING:badStatus();case"EXCEPTION"->s!=TruckAppointmentStatus.COMPLETED&&s!=TruckAppointmentStatus.CANCELLED?TruckAppointmentStatus.EXCEPTION:badStatus();case"CANCEL"->s!=TruckAppointmentStatus.COMPLETED?TruckAppointmentStatus.CANCELLED:badStatus();default->badStatus();};}
|
||||
private TruckAppointmentStatus badStatus(){throw conflict("不允许的状态动作");} private TruckAppointmentEntity required(long id){TruckAppointmentEntity e=repo.find(id);if(e==null)throw new BizException(ResultCode.NOT_FOUND,"拖车预约不存在");return e;} private void checkVersion(TruckAppointmentEntity e,int v){if(e.getVersion()!=v)throw conflict("版本已变化,请刷新后重试");} private static BizException conflict(String m){return new BizException(ResultCode.CONFLICT,m);} private static BizException bad(String m){return new BizException(ResultCode.BAD_REQUEST,m);}
|
||||
private TruckAppointmentEntity enrich(TruckAppointmentEntity value){if(value.getOceanBookingId()!=null){OceanBookingEntity booking=oceanBookings.find(value.getOceanBookingId());if(booking!=null){value.setOceanBookingNo(booking.getBookingNo());value.setWarehouseName(booking.getWarehouseName());value.setShippingCompanyId(booking.getShippingCompanyId());value.setShippingCompanyName(booking.getShippingCompanyName());value.setBookingAgentId(booking.getBookingAgentId());value.setBookingAgentName(booking.getBookingAgentName());}}value.setAttachmentCount((long)repo.attachments(value.getId()).size());return value;}
|
||||
private void copy(TruckAppointmentEntity e,SaveRequest r,AssociationContext links,String carrierName){e.setOutboundOrderId(r.outboundOrderId());e.setOceanBookingId(r.oceanBookingId());e.setContainerBookingId(null);e.setLoadingPlanId(null);e.setWarehouseId(links.warehouseId());e.setCarrierId(r.carrierId());e.setCarrierName(carrierName);e.setBillOfLadingNo(links.billOfLadingNo());e.setFleetContact(r.fleetContact());e.setFleetPhone(r.fleetPhone());e.setDriverName(r.driverName());e.setDriverPhone(r.driverPhone());e.setDriverIdCard(r.driverIdCard());e.setVehicleNo(r.vehicleNo());e.setStoreName(r.storeName());e.setStoreContact(r.storeContact());e.setStorePhone(r.storePhone());e.setStoreAddress(r.storeAddress());e.setStoreCity(r.storeCity());e.setStoreZipcode(r.storeZipcode());e.setPackageCount(r.packageCount());e.setGrossWeightKg(r.grossWeightKg());e.setVolumeCbm(r.volumeCbm());e.setPalletCount(r.palletCount());e.setPoNo(r.poNo());e.setPlannedLoadingAt(r.plannedLoadingAt());e.setTransitMinutes(r.transitMinutes());e.setPayableAmount(r.payableAmount());e.setCurrency(currency(r.currency()));e.setRemark(r.remark());}
|
||||
@@ -82,8 +96,8 @@ public class TruckAppointmentService {
|
||||
}
|
||||
private static String releasedBookingBill(OceanBookingEntity booking,String requested){String authoritative=trim(booking.getBillOfLadingNo());if(!StringUtils.hasText(authoritative))throw conflict("关联已放舱订舱缺少提单号");if(StringUtils.hasText(requested)&&!authoritative.equals(requested))throw bad("billOfLadingNo与关联订舱不一致");return authoritative;}
|
||||
private static String attachmentType(String value){String normalized=trim(value);if(!ATTACHMENT_TYPES.contains(normalized))throw bad("不支持的拖车附件类型");return normalized;}
|
||||
private static String currency(String value){if(!StringUtils.hasText(value))return "RMB";String normalized=value.trim();if(!CURRENCIES.contains(normalized))throw bad("currency仅支持RMB或USD");return normalized;}
|
||||
private static BizException existingAppointmentConflict(TruckAppointmentEntity existing){String appointmentNo=existing==null?null:trim(existing.getAppointmentNo());return conflict(StringUtils.hasText(appointmentNo)?"该订舱已存在拖车预约 "+appointmentNo+",请继续编辑":"该订舱已存在拖车预约,请刷新后继续编辑");}
|
||||
private static String currency(String value){if(!StringUtils.hasText(value))return "RMB";String normalized=value.trim();if(!CURRENCIES.contains(normalized))throw bad("国内拖车应付只支持人民币RMB");return normalized;}
|
||||
private static BizException existingAppointmentConflict(TruckAppointmentEntity existing){String appointmentNo=existing==null?null:trim(existing.getAppointmentNo());return conflict(StringUtils.hasText(appointmentNo)?"该订舱已存在拖车预约 "+appointmentNo+(existing.getStatus()==TruckAppointmentStatus.COMPLETED?",请查看已完成拖车单":",请继续编辑"):"该订舱已存在拖车预约,请刷新后继续编辑");}
|
||||
private static String trim(String value){return StringUtils.hasText(value)?value.trim():null;}
|
||||
private record AssociationContext(OceanBookingEntity booking,Long warehouseId,String billOfLadingNo){}
|
||||
private void record(TruckAppointmentEntity e,String from,String to,String action,String reason,long uid,String name){TruckAppointmentHistoryEntity h=new TruckAppointmentHistoryEntity();h.setId(IdWorker.getId());h.setAppointmentId(e.getId());h.setRevision(e.getRevision());h.setAction(action);h.setFromStatus(from);h.setToStatus(to);h.setReason(reason);h.setOperatorId(uid);h.setOperatorName(name);h.setCreatedAt(LocalDateTime.now());repo.history(h);}
|
||||
|
||||
@@ -30,11 +30,11 @@ CREATE TABLE IF NOT EXISTS fl_truck_appointment (
|
||||
planned_loading_at DATETIME(3) NULL COMMENT '预约到仓装柜时间',
|
||||
actual_arrived_warehouse_at DATETIME(3) NULL COMMENT '车辆实际到达收货仓时间',
|
||||
actual_loading_at DATETIME(3) NULL COMMENT '实际开始装柜时间',
|
||||
actual_departed_at DATETIME(3) NULL COMMENT '车辆实际离仓时间;DEPARTED后供LOADED门禁使用',
|
||||
actual_departed_at DATETIME(3) NULL COMMENT '订舱全部要求货柜确认国内离仓的最终时间',
|
||||
transit_minutes INT UNSIGNED NULL COMMENT '从到仓到离仓的运输作业耗时,单位分钟',
|
||||
payable_amount DECIMAL(18,2) NULL COMMENT '应付拖车服务商金额;币种由currency指定',
|
||||
currency VARCHAR(8) NOT NULL DEFAULT 'RMB' COMMENT '应付金额币种;仅RMB或USD',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '拖车预约状态:DRAFT草稿、PENDING_DISPATCH待派车、DISPATCHED已派车、PICKUP_IN_PROGRESS提货中、ARRIVED_WAREHOUSE已到仓、LOADING装柜中、DEPARTED已离仓、COMPLETED已完成、RESCHEDULED已改约、EXCEPTION异常、CANCELLED已取消',
|
||||
currency VARCHAR(8) NOT NULL DEFAULT 'RMB' COMMENT '应付金额币种;国内拖车仅RMB,财务以CNY入池',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '拖车预约状态:DRAFT草稿、PENDING_DISPATCH待派车、DISPATCHED已派车、PICKUP_IN_PROGRESS提货中、ARRIVED_WAREHOUSE已到仓、LOADING装柜中、COMPLETED已完成、RESCHEDULED已改约、EXCEPTION异常、CANCELLED已取消',
|
||||
active_ocean_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') THEN ocean_booking_id ELSE NULL END) STORED COMMENT '非终态预约的订舱唯一键',
|
||||
revision INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '拖车预约业务修订号;每次状态或改约递增',
|
||||
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||
@@ -43,8 +43,8 @@ CREATE TABLE IF NOT EXISTS fl_truck_appointment (
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '拖车预约最后更新时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_truck_appointment_no(appointment_no), UNIQUE KEY uk_truck_active_ocean_booking(active_ocean_booking_id),
|
||||
KEY idx_truck_appointment_status(status,updated_at), KEY idx_truck_appointment_ocean(ocean_booking_id),
|
||||
CONSTRAINT chk_truck_appointment_status CHECK(status IN ('DRAFT','PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED','COMPLETED','RESCHEDULED','EXCEPTION','CANCELLED')),
|
||||
CONSTRAINT chk_truck_appointment_currency CHECK(currency IN ('RMB','USD'))
|
||||
CONSTRAINT chk_truck_appointment_status CHECK(status IN ('DRAFT','PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','COMPLETED','RESCHEDULED','EXCEPTION','CANCELLED')),
|
||||
CONSTRAINT chk_truck_appointment_currency CHECK(currency IN ('RMB'))
|
||||
) ENGINE=InnoDB COMMENT='三期拖车预约派车聚合';
|
||||
CREATE TABLE IF NOT EXISTS fl_truck_appointment_history (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '拖车预约历史ID', appointment_id BIGINT UNSIGNED NOT NULL COMMENT '所属拖车预约ID', revision INT UNSIGNED NOT NULL COMMENT '该预约业务修订号', action VARCHAR(32) NOT NULL COMMENT '触发状态或改约的业务动作',
|
||||
@@ -97,7 +97,7 @@ CALL phase3_truck_add_column(
|
||||
);
|
||||
CALL phase3_truck_add_column(
|
||||
'actual_departed_at',
|
||||
'DATETIME(3) NULL COMMENT ''车辆实际离仓时间;DEPARTED后供LOADED门禁使用'' AFTER actual_loading_at'
|
||||
'DATETIME(3) NULL COMMENT ''订舱全部要求货柜确认国内离仓的最终时间'' AFTER actual_loading_at'
|
||||
);
|
||||
CALL phase3_truck_add_column(
|
||||
'active_ocean_booking_id',
|
||||
|
||||
@@ -68,7 +68,7 @@ paths:
|
||||
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/{palletId}/print:
|
||||
get:
|
||||
summary: PDA 获取预生成托盘码的 A4 托盘信息单
|
||||
description: 测量审核通过并生成托盘码后即可获取;每个托盘固定一页 A4,包含托盘号及其 Code 128 一维码、目的仓、托盘序号、入仓码/客户昵称/箱数/件数汇总以及 SKU/箱数/件数/每箱件数汇总,不展示作业状态、重量或体积。空托显示暂无已归托 SKU。仅当前仓库、当前操作员可访问。
|
||||
description: 测量审核通过并生成托盘码后即可获取;每个托盘固定一页 A4,包含托盘号及其 Code 128 一维码、目的仓、入仓码/客户昵称/箱数/件数汇总以及 SKU/箱数/件数/每箱件数汇总,不展示托盘序号、作业状态、重量或体积。空托显示暂无已归托 SKU。仅当前仓库、当前操作员可访问。
|
||||
x-required-authority: platform:firstleg:pda:view
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/TaskId'}
|
||||
@@ -89,6 +89,13 @@ paths:
|
||||
summary: Admin 查询入仓单权威托盘结果
|
||||
x-required-authority: platform:firstleg:inbound:list
|
||||
responses: {'200': {description: OK}}
|
||||
/api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets/print:
|
||||
get:
|
||||
summary: Admin 合并打印入仓单关联的全部一件代发托盘
|
||||
description: 测量审核预生成托盘码后即可打印;每托一页,空托也保留托盘码和当前入仓单信息,供电脑连接打印机后在 PDA 作业前贴码。
|
||||
x-required-authority: platform:firstleg:inbound:list
|
||||
responses:
|
||||
'200': {description: 多页 A4 托盘信息单 PDF, content: {application/pdf: {schema: {type: string, format: binary}}}}
|
||||
/api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets/{palletId}/print:
|
||||
get:
|
||||
summary: 获取包含托盘码、目的仓、入仓码和 SKU 汇总的 A4 托盘信息单
|
||||
|
||||
@@ -2,7 +2,7 @@ openapi: 3.0.3
|
||||
info:
|
||||
title: Truck Appointment API
|
||||
version: 2.0.0
|
||||
description: A truck appointment belongs to one RELEASED ocean booking, independently of when or how that booking's internal containers are linked. Each booking may have at most one non-terminal appointment. The bill-of-lading number and warehouse are frozen from the booking, which must own at least one active BOOKING_CONFIRMATION or BILL_OF_LADING file. Container identities and real container numbers remain in the container/loading modules and are not part of this API. PDA loading completion does not change a booking-level truck appointment.
|
||||
description: A truck appointment belongs to one RELEASED ocean booking, independently of when or how that booking's internal containers are linked. Each booking may have at most one non-terminal appointment. The bill-of-lading number and warehouse are frozen from the booking, which must own at least one active BOOKING_CONFIRMATION or BILL_OF_LADING file. Container identities and real container numbers remain in the container/loading modules and are not part of this API. PDA loading completion alone does not complete the appointment. All required containers must be allocated and confirmed departed from the domestic warehouse. Completion atomically creates approved ON_ACCOUNT container payable fees allocated by loaded volume and publishes their immutable snapshots through the existing Finance Outbox. Create, edit, submit and reschedule also recheck departure facts, covering late appointments. Zero amounts create no fee. Cancelled and exception appointments are excluded. RMB is the only appointment currency and maps to CNY in Finance.
|
||||
paths:
|
||||
/api/platform/v1/truck-appointments:
|
||||
get:
|
||||
@@ -21,7 +21,7 @@ paths:
|
||||
records: {type: array, items: {$ref: '#/components/schemas/DetailStableReferences'}}
|
||||
post:
|
||||
summary: Create a truck appointment for a released booking
|
||||
description: If the booking already has a non-terminal appointment, the server returns A0409 with the existing appointment number and instructs the operator to continue editing. The database booking-level unique key provides the same guarantee for concurrent creates.
|
||||
description: If the booking already has an active or completed appointment, the server returns A0409 with the existing appointment number and instructs the operator to continue editing. The database booking-level unique key provides the same guarantee for concurrent creates.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -29,11 +29,11 @@ paths:
|
||||
schema: {$ref: '#/components/schemas/SaveRequest'}
|
||||
responses:
|
||||
'200': {description: 'Created appointment', content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}}}
|
||||
'409': {description: 'A0409 - this booking already has a non-terminal appointment; message includes its appointment number and asks the operator to continue editing'}
|
||||
'409': {description: 'A0409 - this booking already has an active or completed appointment; message includes its appointment number and asks the operator to continue editing'}
|
||||
/api/platform/v1/truck-appointments/candidates:
|
||||
get:
|
||||
summary: Query authoritative create-time ocean-booking candidates
|
||||
description: Requires platform:firstleg:truck-appointment:list. Returns one row per RELEASED booking with a bill of lading and an active booking-owned confirmation or bill file, excluding bookings that already have a non-terminal truck appointment. Container allocation and loading status do not affect eligibility. Edit pages display their current booking from appointment detail rather than injecting it into this create-only result.
|
||||
description: Requires platform:firstleg:truck-appointment:list. Returns one row per RELEASED booking with a bill of lading and an active booking-owned confirmation or bill file, excluding bookings that already have an active or completed truck appointment. Container allocation and loading status do not affect eligibility. Edit pages display their current booking from appointment detail rather than injecting it into this create-only result.
|
||||
parameters:
|
||||
- {name: keyword, in: query, required: false, schema: {type: string, maxLength: 64}, description: Matches booking number or bill of lading}
|
||||
- {name: page, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, default: 1}}
|
||||
@@ -63,7 +63,7 @@ paths:
|
||||
- {$ref: '#/components/parameters/RequestHash'}
|
||||
post:
|
||||
summary: Apply an idempotent appointment lifecycle action
|
||||
description: All actions transition the booking-level appointment as one aggregate. PICKUP does not accept or write a container number. ARRIVE, LOAD and DEPART persist their actual event timestamps atomically with the status transition.
|
||||
description: All actions transition the booking-level appointment as one aggregate. PICKUP does not accept or write a container number. ARRIVE and LOAD persist their actual event timestamps. Manual DEPART and COMPLETE actions are not supported; confirmation of all required containers departing the domestic warehouse completes the appointment automatically.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -144,7 +144,7 @@ components:
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
enum: [SUBMIT, DISPATCH, PICKUP, ARRIVE, LOAD, DEPART, COMPLETE, EXCEPTION, CANCEL]
|
||||
enum: [SUBMIT, DISPATCH, PICKUP, ARRIVE, LOAD, EXCEPTION, CANCEL]
|
||||
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}}
|
||||
RequestHash: {name: X-Request-Hash, in: header, required: false, schema: {type: string, pattern: '^[0-9a-fA-F]{64}$'}}
|
||||
schemas:
|
||||
@@ -227,8 +227,8 @@ components:
|
||||
carrierName: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint only; the persisted snapshot comes from Warehouse supplier master data.}
|
||||
billOfLadingNo: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint; the persisted snapshot is always frozen from the selected RELEASED booking.}
|
||||
plannedLoadingAt: {type: string, format: date-time}
|
||||
payableAmount: {type: number, format: double, minimum: 0, nullable: true, description: Amount payable to the trucking supplier.}
|
||||
currency: {type: string, enum: [RMB, USD], default: RMB, description: Payable amount currency.}
|
||||
payableAmount: {type: number, format: double, minimum: 0, nullable: true, description: 应付拖车费,最多两位小数;全部订舱需求货柜确认国内离仓后按实装体积分摊生成应付来源;零金额不生成费用。}
|
||||
currency: {type: string, enum: [RMB], default: RMB, description: 人民币拖车费;完成时以CNY进入应付费用来源。}
|
||||
expectedVersion: {type: integer, minimum: 0, description: Must be 0 on create; current version on edit.}
|
||||
DetailStableReferences:
|
||||
type: object
|
||||
@@ -248,5 +248,5 @@ components:
|
||||
carrierName: {type: string}
|
||||
billOfLadingNo: {type: string, maxLength: 128, nullable: true, description: Frozen bill of lading snapshot used by dispatch readiness.}
|
||||
payableAmount: {type: number, format: double, minimum: 0, nullable: true}
|
||||
currency: {type: string, enum: [RMB, USD]}
|
||||
currency: {type: string, enum: [RMB]}
|
||||
attachmentCount: {type: integer, format: int64, minimum: 0, description: Number of attachment references owned by this appointment}
|
||||
|
||||
+2
-2
@@ -19,7 +19,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class TruckAppointmentControllerContractTest {
|
||||
@Test void exposesDedicatedEndpointAndPermissions() {String p=PlatformTruckAppointmentController.class.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class).value()[0];assertThat(p).isEqualTo("/api/platform/v1/truck-appointments"); for(String n:new String[]{"page","candidates","detail","history","attachments","attach","upload","deleteAttachment","create","edit","action","reschedule"}) assertThat(java.util.Arrays.stream(PlatformTruckAppointmentController.class.getDeclaredMethods()).anyMatch(m->m.getName().equals(n))).isTrue();}
|
||||
@Test void schemaHasBookingLevelUniqueAggregateWithoutCustomerScope() throws Exception {String s=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("fl_truck_appointment","bill_of_lading_no VARCHAR(128)","active_ocean_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS","UNIQUE KEY uk_truck_active_ocean_booking(active_ocean_booking_id)","ALTER TABLE fl_truck_appointment DROP INDEX uk_truck_active_container","chk_truck_appointment_currency CHECK(currency IN ('RMB','USD'))").doesNotContain("customer_id", "customer_name", "receivable_amount");}
|
||||
@Test void schemaHasBookingLevelUniqueAggregateWithoutCustomerScope() throws Exception {String s=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("fl_truck_appointment","bill_of_lading_no VARCHAR(128)","active_ocean_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS","UNIQUE KEY uk_truck_active_ocean_booking(active_ocean_booking_id)","ALTER TABLE fl_truck_appointment DROP INDEX uk_truck_active_container","chk_truck_appointment_currency CHECK(currency IN ('RMB'))").doesNotContain("customer_id", "customer_name", "receivable_amount", "'DEPARTED'");}
|
||||
@Test void existingAppointmentTableReceivesAllLateColumns() throws Exception {
|
||||
String moduleSql=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);
|
||||
Path direct=Path.of("deploy","sql","头程数据库统一增量-MySQL8.sql");
|
||||
@@ -34,7 +34,7 @@ class TruckAppointmentControllerContractTest {
|
||||
@Test void openApiPublishesBookingLevelCandidateContract() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("/api/platform/v1/truck-appointments/candidates:","Paged booking candidates","bookingDocumentReady","Container allocation and loading status do not affect eligibility").doesNotContain("requirementId:","containerBookingId:","loadingPlanId:");}
|
||||
@Test void openApiRequiresReleasedBookingWithoutContainerWriteProtocol() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("required: [oceanBookingId, carrierId, plannedLoadingAt, expectedVersion]","RELEASED ocean booking","PICKUP does not accept or write a container number","schema: {$ref: '#/components/schemas/DetailEnvelope'}").doesNotContain("containerBookingId:","loadingPlanId:","containerNo:","customerId", "customerName");}
|
||||
@Test void openApiKeepsOnlyNonBookingTruckAttachments() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("AttachmentRequest:","required: [fileId, attachmentType]","required: [attachmentType, file]","TRUCK_APPOINTMENT_ATTACHMENT","Deletes the appointment reference only","DetailStableReferences:","oceanBookingNo:","shippingCompanyName:","bookingAgentName:","billOfLadingNo:").doesNotContain("enum: [BILL_OF_LADING, DISPATCH_ORDER");}
|
||||
@Test void openApiExposesOnlyPayableWithFixedCurrencies() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("payableAmount:","enum: [RMB, USD]","default: RMB").doesNotContain("receivableAmount");}
|
||||
@Test void openApiExposesOnlyPayableWithFixedCurrencies() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("payableAmount:","enum: [RMB]","default: RMB").doesNotContain("receivableAmount");}
|
||||
@Test void methodsHavePlatformPermissionsAndAuthenticatedScope() {
|
||||
Map<String,String> expected=Map.ofEntries(
|
||||
Map.entry("page","platform:firstleg:truck-appointment:list"), Map.entry("candidates","platform:firstleg:truck-appointment:list"), Map.entry("detail","platform:firstleg:truck-appointment:list"),
|
||||
|
||||
+9
-2
@@ -22,6 +22,7 @@ import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class OutboundDispatchServiceTest {
|
||||
private final com.baiyue.firstleg.service.TruckAppointmentCompletionService truckCompletion = mock(com.baiyue.firstleg.service.TruckAppointmentCompletionService.class);
|
||||
private OutboundDispatchRepository repository;
|
||||
|
||||
@BeforeEach
|
||||
@@ -52,7 +53,7 @@ class OutboundDispatchServiceTest {
|
||||
when(customs.code()).thenReturn("CUSTOMS_CLEARANCE");
|
||||
when(customs.assess(any())).thenReturn(new GateItem(
|
||||
"CUSTOMS_CLEARANCE", "报关完成", true, "CLEARANCE_COMPLETED", "CLEARANCE_COMPLETED"));
|
||||
OutboundDispatchService service = new OutboundDispatchService(repository, List.of(customs), null);
|
||||
OutboundDispatchService service = new OutboundDispatchService(repository, List.of(customs), null, truckCompletion);
|
||||
List<OutboundDispatchReadMapper.Binding> tenBindings = LongStream.rangeClosed(11L, 20L)
|
||||
.mapToObj(id -> binding(id, "I-" + id)).toList();
|
||||
stubContainer(tenBindings);
|
||||
@@ -124,6 +125,7 @@ class OutboundDispatchServiceTest {
|
||||
|
||||
@Test
|
||||
void confirmLocksAndAdvancesAllBindingsButWritesOneHistoryAndOneShippingFact() {
|
||||
when(truckCompletion.lockForContainer(22L)).thenReturn(66L);
|
||||
CarrierScheduleQueryService carrierSchedules = mock(CarrierScheduleQueryService.class);
|
||||
OutboundDispatchService service = service(carrierSchedules);
|
||||
stubContainer();
|
||||
@@ -141,6 +143,11 @@ class OutboundDispatchServiceTest {
|
||||
verify(repository).lockBindings(22L);
|
||||
verify(repository).confirm(31L, 0, 9L, "admin", "ok");
|
||||
verify(repository).confirm(32L, 0, 9L, "admin", "ok");
|
||||
var departureOrder = org.mockito.Mockito.inOrder(truckCompletion, repository);
|
||||
departureOrder.verify(truckCompletion).lockForContainer(22L);
|
||||
departureOrder.verify(repository).confirm(31L, 0, 9L, "admin", "ok");
|
||||
departureOrder.verify(repository).confirm(32L, 0, 9L, "admin", "ok");
|
||||
departureOrder.verify(truckCompletion).completeForBooking(66L);
|
||||
verify(repository, times(1)).history(argThat(value -> "CONFIRM_DISPATCH".equals(value.getAction())
|
||||
&& value.getContainerBookingId().equals(22L)));
|
||||
verify(carrierSchedules, times(1)).ensureFromOutboundConfirm(7L, 66L, 22L, 9L, "admin");
|
||||
@@ -271,7 +278,7 @@ class OutboundDispatchServiceTest {
|
||||
|
||||
private OutboundDispatchService service(CarrierScheduleQueryService carrierSchedules) {
|
||||
return new OutboundDispatchService(repository,
|
||||
List.of(passing("CUSTOMS_CLEARANCE")), carrierSchedules);
|
||||
List.of(passing("CUSTOMS_CLEARANCE")), carrierSchedules, truckCompletion);
|
||||
}
|
||||
|
||||
private void stubContainer() {
|
||||
|
||||
+3
-1
@@ -16,7 +16,8 @@ class DropshipPalletizationSqlContractTest {
|
||||
void inboundPalletReadAndPrintUseTheRegisteredPlatformInboundPermission() throws Exception {
|
||||
var controller = com.baiyue.firstleg.controller.PlatformDropshipPalletizationController.class;
|
||||
for (var method : controller.getDeclaredMethods()) {
|
||||
if (method.getName().equals("inboundPallets") || method.getName().equals("printPallet")) {
|
||||
if (method.getName().equals("inboundPallets") || method.getName().equals("printPallet")
|
||||
|| method.getName().equals("printInboundPallets")) {
|
||||
assertThat(method.getAnnotation(org.springframework.security.access.prepost.PreAuthorize.class).value())
|
||||
.isEqualTo("hasAuthority('platform:firstleg:inbound:list')");
|
||||
}
|
||||
@@ -24,6 +25,7 @@ class DropshipPalletizationSqlContractTest {
|
||||
String api = new ClassPathResource("openapi/dropship-palletization-v1.yaml")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
assertThat(api).contains("x-required-authority: platform:firstleg:inbound:list")
|
||||
.contains("/api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets/print:")
|
||||
.doesNotContain("x-required-authority: firstleg:inbound:list");
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -20,7 +20,7 @@ class TruckAppointmentMapperContractTest {
|
||||
String candidates = TruckAppointmentMapper.class.getMethod("candidates", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, com.baiyue.firstleg.dto.TruckAppointmentDtos.CandidateQuery.class).getAnnotation(Select.class).value()[0];
|
||||
assertThat(candidates).contains("FROM fl_ocean_booking o", "o.status='RELEASED'", "d.valid=1",
|
||||
"'BOOKING_CONFIRMATION','BILL_OF_LADING'", "t.ocean_booking_id=o.id",
|
||||
"t.status NOT IN ('COMPLETED','CANCELLED','EXCEPTION')", "bookingDocumentReady");
|
||||
"t.status NOT IN ('CANCELLED','EXCEPTION')", "bookingDocumentReady");
|
||||
assertThat(candidates).doesNotContain("fl_ocean_booking_container_allocation", "fl_container_booking",
|
||||
"loadingPlanId", "containerBookingId", "cabinetNo", "containerNo");
|
||||
String transition = TruckAppointmentMapper.class.getMethod("transition", long.class, String.class, String.class, int.class).getAnnotation(Update.class).value()[0];
|
||||
@@ -29,7 +29,7 @@ class TruckAppointmentMapperContractTest {
|
||||
assertThat(dispatch).contains("status='DISPATCHED'", "bill_of_lading_no=#{billOfLadingNo}", "status=#{fromStatus}", "version=#{version}", "revision=revision+1");
|
||||
String firstActive = TruckAppointmentMapper.class.getMethod("firstActiveForUpdate", long.class).getAnnotation(Select.class).value()[0];
|
||||
assertThat(firstActive).contains("ocean_booking_id=#{oceanBookingId}", "ORDER BY created_at,id", "LIMIT 1 FOR UPDATE")
|
||||
.contains("'COMPLETED','CANCELLED','EXCEPTION'");
|
||||
.contains("'CANCELLED','EXCEPTION'");
|
||||
String reschedule = TruckAppointmentMapper.class.getMethod("reschedule", long.class, java.time.LocalDateTime.class, String.class, int.class).getAnnotation(Update.class).value()[0];
|
||||
assertThat(reschedule).contains("status='RESCHEDULED'", "version=#{version}", "revision=revision+1");
|
||||
String edit = TruckAppointmentMapper.class.getMethod("updateEdit", TruckAppointmentEntity.class, int.class).getAnnotation(Update.class).value()[0];
|
||||
|
||||
+4
-8
@@ -45,7 +45,7 @@ class InboundCartonLabelPrintServiceTest {
|
||||
@Test
|
||||
void dropshipPalletLabelIsAvailableBeforeSeal() {
|
||||
Pallet open = dropshipPallet("OPEN");
|
||||
stubPalletFacts(90001L, 1, 2);
|
||||
stubPalletFacts(90001L);
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_PDF);
|
||||
when(print.generateDropshipPalletA4Documents(org.mockito.ArgumentMatchers.anyString(),
|
||||
@@ -66,8 +66,6 @@ class InboundCartonLabelPrintServiceTest {
|
||||
assertThat(request.getValue().documents()).singleElement().satisfies(document -> {
|
||||
assertThat(document.palletNo()).isEqualTo("DP-260911-0001");
|
||||
assertThat(document.destinationWarehouse()).isEqualTo("NJDF");
|
||||
assertThat(document.palletSequence()).isEqualTo(1);
|
||||
assertThat(document.totalPallets()).isEqualTo(2);
|
||||
assertThat(document.inboundRows()).singleElement().satisfies(row -> {
|
||||
assertThat(row.inboundCode()).isEqualTo("PCHK2608040174");
|
||||
assertThat(row.customerNickname()).isEqualTo("小虫科技");
|
||||
@@ -88,8 +86,8 @@ class InboundCartonLabelPrintServiceTest {
|
||||
Pallet first = dropshipPallet("OPEN");
|
||||
Pallet second = new Pallet(90002L, "DP-260911-0002", "OPEN", 0, 0, 0,
|
||||
BigDecimal.ZERO, BigDecimal.ZERO, 1, List.of(), List.of(), null);
|
||||
stubPalletFacts(90001L, 1, 2);
|
||||
stubPalletFacts(90002L, 2, 2);
|
||||
stubPalletFacts(90001L);
|
||||
stubPalletFacts(90002L);
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_PDF);
|
||||
when(print.generateDropshipPalletA4Documents(org.mockito.ArgumentMatchers.anyString(),
|
||||
@@ -242,7 +240,7 @@ class InboundCartonLabelPrintServiceTest {
|
||||
BigDecimal.ONE, BigDecimal.ONE, 1, List.of(item), List.of(), null);
|
||||
}
|
||||
|
||||
private void stubPalletFacts(long palletId, int sequence, int total) {
|
||||
private void stubPalletFacts(long palletId) {
|
||||
DropshipPalletizationMapper.PrintInboundRow inbound =
|
||||
new DropshipPalletizationMapper.PrintInboundRow();
|
||||
inbound.setTenantId(10001L);
|
||||
@@ -260,8 +258,6 @@ class InboundCartonLabelPrintServiceTest {
|
||||
sku.setMaxUnitsPerCarton(19);
|
||||
when(palletization.printSkuRows(palletId))
|
||||
.thenReturn(palletId == 90001L ? List.of(sku) : List.of());
|
||||
when(palletization.palletPosition(palletId))
|
||||
.thenReturn(new DropshipPalletizationMapper.PalletPosition(sequence, total));
|
||||
when(customerSnapshots.customerNickname(10001L)).thenReturn("小虫科技");
|
||||
}
|
||||
}
|
||||
|
||||
+46
-1
@@ -376,6 +376,19 @@ class InboundMeasurementServiceTest {
|
||||
verify(orderCargoClient, times(1)).listForCartonLabels("internal-token", 1L, 80L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailFallsBackToCustomerNameWhenLegacyCompanyNameIsEmpty() {
|
||||
InboundOrderEntity inbound = inbound("MEASURING");
|
||||
inbound.setCustomerName("柏悦测试客户");
|
||||
inbound.setCompanyName(null);
|
||||
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
|
||||
when(repository.listWeights(1L, 10L)).thenReturn(List.of());
|
||||
|
||||
InboundMeasurementResponses.Detail result = service.detail(1L, 10L);
|
||||
|
||||
assertThat(result.companyName()).isEqualTo("柏悦测试客户");
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailRejectsCargoBatchWhoseOrderOwnershipDoesNotMatchInbound() {
|
||||
InboundOrderEntity inbound = inbound("MEASURE_PENDING");
|
||||
@@ -1167,7 +1180,39 @@ class InboundMeasurementServiceTest {
|
||||
assertThat(inbound.getPalletCount()).isEqualTo(1);
|
||||
assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("1.2345");
|
||||
assertThat(inbound.getTotalPackageCount()).isEqualTo(3);
|
||||
verify(inbound, never()).setTotalPackageCount(any());
|
||||
verify(inbound).setTotalPackageCount(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void submitUsesLatestOrderDeclarationAndRefreshesStaleInboundPackageTotal() {
|
||||
InboundOrderEntity inbound = inbound("MEASURING");
|
||||
inbound.setTotalPackageCount(20);
|
||||
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
|
||||
measurement.setId(30L);
|
||||
measurement.setStatus("DRAFT");
|
||||
measurement.setVersion(2);
|
||||
measurement.setTotalPackageCount(18);
|
||||
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
|
||||
item.setPackageCount(18);
|
||||
InboundWeightResultEntity weight = weight(20L, "197.000", "KG", "CONFIRMED", 1);
|
||||
weight.setGrossWeightKg(new BigDecimal("197.000"));
|
||||
weight.setNetWeightKg(new BigDecimal("181.000"));
|
||||
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
|
||||
when(repository.findMeasurement(1L, 10L, 30L)).thenReturn(measurement);
|
||||
when(repository.listItems(1L, 30L)).thenReturn(List.of(item));
|
||||
when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight));
|
||||
when(repository.updateMeasurement(measurement)).thenReturn(true);
|
||||
when(repository.updateInbound(inbound)).thenReturn(true);
|
||||
when(orderCargoClient.listForCartonLabels("internal-token", 1L, 80L)).thenReturn(
|
||||
ApiResponse.success(List.of(new OrderCargoClient.CargoLabelBinding(
|
||||
80001L, 80L, 1, "测试货物", null, 18, new BigDecimal("60"),
|
||||
new BigDecimal("50"), new BigDecimal("50"), "SHIP-001"))));
|
||||
|
||||
service.submit(1L, 10L, 9L,
|
||||
new InboundMeasurementRequests.SubmitRequest(30L, 2));
|
||||
|
||||
assertThat(inbound.getTotalPackageCount()).isEqualTo(18);
|
||||
assertThat(inbound.getProcessStatus()).isEqualTo("MEASURE_PENDING");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+14
-5
@@ -290,7 +290,7 @@ class LoadingCompletedOutboxServiceTest {
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(booleans = {true, false})
|
||||
void freezesRepeatedSkuCartonsBySourceDetailForBothPalletSources(boolean actualBinding) throws Exception {
|
||||
void freezesActualSkuCartonsAndRejectsInferredPalletSources(boolean actualBinding) throws Exception {
|
||||
Row first = row(11, 100, 101); first.setFulfillmentType("ONE_CLICK_DROPSHIP");
|
||||
first.setSkuCode("SAME-SKU"); first.setProductQuantity(10);
|
||||
Row second = row(11, 100, 102); second.setFulfillmentType("ONE_CLICK_DROPSHIP");
|
||||
@@ -305,6 +305,10 @@ class LoadingCompletedOutboxServiceTest {
|
||||
when(snapshots.palletRows(90L)).thenReturn(List.of(
|
||||
pallet(11,100,1,"DP-260909-0001",2,102,"SAME-SKU",1,20,"BOX-102"),
|
||||
pallet(11,100,1,"DP-260909-0001",2,101,"SAME-SKU",1,10,"BOX-101")));
|
||||
assertThatThrownBy(() -> service.publish(90L)).isInstanceOf(com.baiyue.common.core.exception.BizException.class)
|
||||
.hasMessageContaining("真实逐箱实收");
|
||||
verify(outbox, org.mockito.Mockito.never()).insert(org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class));
|
||||
return;
|
||||
}
|
||||
service.publish(90L);
|
||||
service.publish(90L);
|
||||
@@ -320,6 +324,7 @@ class LoadingCompletedOutboxServiceTest {
|
||||
assertThat(sku.path("cartonMarks")).hasSize(1);
|
||||
assertThat(sku.path("cartonMarks").get(0).asText()).isEqualTo("BOX-" + detail);
|
||||
assertThat(sku.path("productQuantity").asInt()).isEqualTo("101".equals(detail) ? 10 : 20);
|
||||
assertThat(sku.path("cartonQuantities").path("BOX-" + detail).asInt()).isEqualTo("101".equals(detail) ? 10 : 20);
|
||||
}
|
||||
assertThat(frozen.get(0).path("sourceInboundDetailId"))
|
||||
.isNotEqualTo(frozen.get(1).path("sourceInboundDetailId"));
|
||||
@@ -332,10 +337,11 @@ class LoadingCompletedOutboxServiceTest {
|
||||
Row second = row(11, 100, 102); second.setFulfillmentType("ONE_CLICK_DROPSHIP");
|
||||
second.setPackageCount(1); second.setProductQuantity(5); second.setSkuCode("SKU-B");
|
||||
when(snapshots.rows(90L)).thenReturn(List.of(first, second));
|
||||
when(snapshots.palletRows(90L)).thenReturn(List.of(
|
||||
pallet(11,100,1,"PLT-01",2,101,"SKU-A",2,20,"BOX-001"),
|
||||
pallet(11,100,1,"PLT-01",2,101,"SKU-A",2,20,"BOX-002"),
|
||||
pallet(11,100,2,"PLT-02",1,102,"SKU-B",1,5,"BOX-003")));
|
||||
when(snapshots.workPalletCount(90L)).thenReturn(2);
|
||||
when(snapshots.actualPalletRows(90L)).thenReturn(List.of(
|
||||
actualPallet(11,100,"PLT-01",101,"SKU-A","BOX-001",7),
|
||||
actualPallet(11,100,"PLT-01",101,"SKU-A","BOX-002",13),
|
||||
actualPallet(11,100,"PLT-02",102,"SKU-B","BOX-003",5)));
|
||||
|
||||
service.publish(90L);
|
||||
|
||||
@@ -346,6 +352,8 @@ class LoadingCompletedOutboxServiceTest {
|
||||
assertThat(event.payload().at("/orders/0/customerId").asText()).isEqualTo("11");
|
||||
assertThat(event.payload().at("/orders/0/pallets")).hasSize(2);
|
||||
assertThat(event.payload().at("/orders/0/pallets/0/skuPackages/0/cartonMarks")).hasSize(2);
|
||||
assertThat(event.payload().at("/orders/0/pallets/0/skuPackages/0/cartonQuantities/BOX-001").asInt()).isEqualTo(7);
|
||||
assertThat(event.payload().at("/orders/0/pallets/0/skuPackages/0/cartonQuantities/BOX-002").asInt()).isEqualTo(13);
|
||||
assertThat(event.payload().at("/orders/0/palletCount").asInt()).isEqualTo(2);
|
||||
}
|
||||
|
||||
@@ -463,6 +471,7 @@ class LoadingCompletedOutboxServiceTest {
|
||||
String sku,String boxCode,int actualSkuQuantity) {
|
||||
PalletRow row = pallet(account,inbound,0,palletNo,1,detail,sku,1,1,boxCode);
|
||||
row.setActualBinding(true);
|
||||
row.setPalletId(Integer.toUnsignedLong(palletNo.hashCode()) + 1L);
|
||||
row.setActualSkuQuantity(actualSkuQuantity);
|
||||
return row;
|
||||
}
|
||||
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.firstleg.domain.TruckAppointmentStatus;
|
||||
import com.baiyue.firstleg.entity.TruckAppointmentEntity;
|
||||
import com.baiyue.firstleg.entity.TruckAppointmentHistoryEntity;
|
||||
import com.baiyue.firstleg.mapper.TruckAppointmentCompletionMapper;
|
||||
import com.baiyue.firstleg.mapper.TruckAppointmentCompletionMapper.DepartureRow;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
|
||||
import com.baiyue.firstleg.repository.TruckAppointmentRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
|
||||
class TruckAppointmentCompletionServiceTest {
|
||||
static final LocalDateTime DEPARTED = LocalDateTime.of(2026, 9, 14, 18, 41);
|
||||
private final TruckAppointmentCompletionMapper mapper = mock(TruckAppointmentCompletionMapper.class);
|
||||
private final OceanBookingRepository bookings = mock(OceanBookingRepository.class);
|
||||
private final TruckAppointmentRepository appointments = mock(TruckAppointmentRepository.class);
|
||||
private final TruckAppointmentFeeService fees = mock(TruckAppointmentFeeService.class);
|
||||
private final TruckAppointmentCompletionService service = new TruckAppointmentCompletionService(mapper, bookings, appointments, fees);
|
||||
|
||||
static DepartureRow row(long container, int quantity, String status, String volume) {
|
||||
return new DepartureRow(9L, quantity, container, "CONT" + container, "ACTIVE", container * 10,
|
||||
"COMPLETED", container * 100, container * 1000, container * 10000, "ORDER" + container,
|
||||
container * 100000, "INBOUND" + container, new BigDecimal(volume), status,
|
||||
"WAITING_FOR_LOADING".equals(status) ? DEPARTED : null);
|
||||
}
|
||||
|
||||
@Test void loadingCompletionAloneDoesNotCompleteTruck() {
|
||||
assertThat(TruckAppointmentCompletionService.allDeparted(List.of(row(1, 1, "READY_FOR_DISPATCH", "2")))).isFalse();
|
||||
}
|
||||
|
||||
@Test void allRequiredContainersAndEveryInboundMustHaveDeparted() {
|
||||
var first = row(1, 2, "WAITING_FOR_LOADING", "2");
|
||||
assertThat(TruckAppointmentCompletionService.allDeparted(List.of(first))).isFalse();
|
||||
assertThat(TruckAppointmentCompletionService.allDeparted(List.of(first, row(2, 2, "READY_FOR_DISPATCH", "4")))).isFalse();
|
||||
assertThat(TruckAppointmentCompletionService.allDeparted(List.of(first, row(2, 2, "WAITING_FOR_LOADING", "4")))).isTrue();
|
||||
assertThat(TruckAppointmentCompletionService.allDeparted(List.of(row(1, 1, "WAITING_FOR_LOADING", "2"), row(1, 1, null, "4")))).isFalse();
|
||||
assertThat(TruckAppointmentCompletionService.allDeparted(List.of())).isFalse();
|
||||
}
|
||||
|
||||
@Test void missingRequirementAllocationCannotBeHiddenByAnInnerJoin() {
|
||||
var missing = new DepartureRow(10L, 1, null, null, null, null, null, null, null, null, null, null, null, null, null, null);
|
||||
assertThat(TruckAppointmentCompletionService.allDeparted(List.of(row(1, 1, "WAITING_FOR_LOADING", "2"), missing))).isFalse();
|
||||
}
|
||||
|
||||
@Test void completesDraftOrLateSubmittedTruckAndPublishesExactlyOnce() {
|
||||
var appointment = new TruckAppointmentEntity();
|
||||
appointment.setId(11L); appointment.setVersion(1); appointment.setRevision(1);
|
||||
appointment.setStatus(TruckAppointmentStatus.PENDING_DISPATCH);
|
||||
var rows = List.of(row(1, 1, "WAITING_FOR_LOADING", "2"));
|
||||
when(bookings.findForUpdate(99L)).thenReturn(new OceanBookingEntity());
|
||||
when(mapper.activeForUpdate(99L)).thenReturn(List.of(appointment), List.of());
|
||||
when(mapper.departuresForUpdate(99L)).thenReturn(rows);
|
||||
when(mapper.complete(11L, 1, DEPARTED)).thenReturn(1);
|
||||
|
||||
service.completeForBooking(99L);
|
||||
service.completeForBooking(99L);
|
||||
|
||||
verify(fees, times(1)).publish(appointment, rows, DEPARTED);
|
||||
var history = ArgumentCaptor.forClass(TruckAppointmentHistoryEntity.class);
|
||||
verify(appointments, times(1)).history(history.capture());
|
||||
assertThat(history.getValue().getAction()).isEqualTo("AUTO_COMPLETE");
|
||||
assertThat(history.getValue().getFromStatus()).isEqualTo("PENDING_DISPATCH");
|
||||
assertThat(history.getValue().getToStatus()).isEqualTo("COMPLETED");
|
||||
assertThat(history.getValue().getRevision()).isEqualTo(2);
|
||||
var order = inOrder(bookings, mapper, fees, appointments);
|
||||
order.verify(bookings).findForUpdate(99L);
|
||||
order.verify(mapper).activeForUpdate(99L);
|
||||
order.verify(mapper).departuresForUpdate(99L);
|
||||
order.verify(fees).publish(appointment, rows, DEPARTED);
|
||||
order.verify(mapper).complete(11L, 1, DEPARTED);
|
||||
order.verify(appointments).history(any());
|
||||
}
|
||||
|
||||
@Test void feeFailureDoesNotLeaveACompletedTruck() {
|
||||
var appointment = new TruckAppointmentEntity();
|
||||
when(bookings.findForUpdate(99L)).thenReturn(new OceanBookingEntity());
|
||||
when(mapper.activeForUpdate(99L)).thenReturn(List.of(appointment));
|
||||
when(mapper.departuresForUpdate(99L)).thenReturn(List.of(row(1, 1, "WAITING_FOR_LOADING", "2")));
|
||||
doThrow(new IllegalStateException("fee unavailable")).when(fees).publish(any(), any(), any());
|
||||
assertThatThrownBy(() -> service.completeForBooking(99L)).hasMessage("fee unavailable");
|
||||
verify(mapper, never()).complete(anyLong(), anyInt(), any());
|
||||
verifyNoInteractions(appointments);
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.AllocationGroupRequest;
|
||||
import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
|
||||
import com.baiyue.firstleg.entity.FeeItemEntity;
|
||||
import com.baiyue.firstleg.entity.TruckAppointmentEntity;
|
||||
import com.baiyue.firstleg.repository.FeeItemRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
import static com.baiyue.firstleg.service.TruckAppointmentCompletionServiceTest.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
|
||||
class TruckAppointmentFeeServiceTest {
|
||||
private final FeeItemRepository items = mock(FeeItemRepository.class);
|
||||
private final ContainerFeeDetailService containers = mock(ContainerFeeDetailService.class);
|
||||
private final WarehouseSupplierSnapshotService suppliers = mock(WarehouseSupplierSnapshotService.class);
|
||||
private final TruckAppointmentFeeService service = new TruckAppointmentFeeService(items, containers, suppliers);
|
||||
|
||||
@Test void allocatesAcrossCustomersAndContainersByVolumeWithExactCents() {
|
||||
var shares = TruckAppointmentFeeService.allocate(new BigDecimal("100.00"), List.of(
|
||||
row(3, 3, "WAITING_FOR_LOADING", "1"), row(1, 3, "WAITING_FOR_LOADING", "1"), row(2, 3, "WAITING_FOR_LOADING", "1")));
|
||||
assertThat(shares).extracting(TruckAppointmentFeeService.Share::amount)
|
||||
.containsExactly(new BigDecimal("33.34"), new BigDecimal("33.33"), new BigDecimal("33.33"));
|
||||
assertThat(shares.stream().map(TruckAppointmentFeeService.Share::amount).reduce(BigDecimal.ZERO, BigDecimal::add)).isEqualByComparingTo("100.00");
|
||||
}
|
||||
|
||||
@Test void tinyAmountsNeverPublishZeroOrNegativeSources() {
|
||||
var shares = TruckAppointmentFeeService.allocate(new BigDecimal("0.01"), List.of(
|
||||
row(1, 2, "WAITING_FOR_LOADING", "1"), row(2, 2, "WAITING_FOR_LOADING", "2")));
|
||||
assertThat(shares).hasSize(1);
|
||||
assertThat(shares.get(0).owner().planId()).isEqualTo(20L);
|
||||
assertThat(shares.get(0).amount()).isEqualByComparingTo("0.01");
|
||||
}
|
||||
|
||||
@Test void missingVolumeRejectsInsteadOfInventingEqualCosts() {
|
||||
assertThatThrownBy(() -> TruckAppointmentFeeService.allocate(new BigDecimal("100.00"),
|
||||
List.of(row(1, 1, "WAITING_FOR_LOADING", "0")))).hasMessageContaining("实装体积");
|
||||
}
|
||||
|
||||
@Test void publishesApprovedCnyOnAccountSourcesWithTruckAndSupplierReferences() {
|
||||
var appointment = appointment();
|
||||
var item = new FeeItemEntity(); item.setId(108L); item.setStatus("ACTIVE"); item.setPickupPayable(true);
|
||||
when(items.findItemByCode("FEE_TYPE_108")).thenReturn(item);
|
||||
var created = new ContainerFeeDetailEntity(); created.setAllocationGroupId(500L);
|
||||
when(containers.createAllocationGroup(anyLong(), eq(0L), any())).thenReturn(List.of(created));
|
||||
service.publish(appointment, List.of(row(1, 1, "WAITING_FOR_LOADING", "5")), DEPARTED);
|
||||
var request = ArgumentCaptor.forClass(AllocationGroupRequest.class);
|
||||
verify(containers).createAllocationGroup(eq(10L), eq(0L), request.capture());
|
||||
assertThat(request.getValue().fee().direction()).isEqualTo("PAYABLE");
|
||||
assertThat(request.getValue().fee().currency()).isEqualTo("CNY");
|
||||
assertThat(request.getValue().fee().settlementMode()).isEqualTo("ON_ACCOUNT");
|
||||
assertThat(request.getValue().fee().supplierId()).isEqualTo(8L);
|
||||
assertThat(request.getValue().fee().remark()).contains("TA-260914-0111");
|
||||
assertThat(request.getValue().fee().businessDate()).isEqualTo(DEPARTED.toLocalDate());
|
||||
assertThat(request.getValue().allocations().get(0).amount()).isEqualByComparingTo("100");
|
||||
verify(suppliers).trucking(8L, null);
|
||||
verify(containers).approveAllocationGroup(10L, 500L, 0L, "系统(拖车离仓完成)");
|
||||
}
|
||||
|
||||
@Test void zeroFeeCreatesNoPayableAndUnsupportedCurrencyFailsClosed() {
|
||||
var appointment = appointment(); appointment.setPayableAmount(BigDecimal.ZERO);
|
||||
service.publish(appointment, List.of(), DEPARTED);
|
||||
verifyNoInteractions(items, containers, suppliers);
|
||||
appointment.setPayableAmount(BigDecimal.ONE); appointment.setCurrency("USD");
|
||||
assertThatThrownBy(() -> service.publish(appointment, List.of(), DEPARTED)).hasMessageContaining("人民币");
|
||||
}
|
||||
|
||||
private TruckAppointmentEntity appointment() {
|
||||
var appointment = new TruckAppointmentEntity(); appointment.setAppointmentNo("TA-260914-0111");
|
||||
appointment.setCarrierId(8L); appointment.setCurrency("RMB"); appointment.setPayableAmount(new BigDecimal("100.00"));
|
||||
return appointment;
|
||||
}
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.firstleg.client.FeePaymentVoucherFileClient;
|
||||
import com.baiyue.firstleg.client.WarehouseSupplierClient;
|
||||
import com.baiyue.firstleg.entity.*;
|
||||
import com.baiyue.firstleg.mapper.ContainerFeeSourceSnapshotMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundOutboxMapper;
|
||||
import com.baiyue.firstleg.mapper.TruckAppointmentCompletionMapper.DepartureRow;
|
||||
import com.baiyue.firstleg.repository.*;
|
||||
import com.fasterxml.jackson.databind.json.JsonMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static com.baiyue.firstleg.service.TruckAppointmentCompletionServiceTest.DEPARTED;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** Real business services from trucking through approval, immutable snapshot and Finance Outbox. */
|
||||
class TruckAppointmentPayableFlowTest {
|
||||
@Test void truckTotalBecomesCustomerOwnedPublishedPayablesWithoutRegisteringPayment() throws Exception {
|
||||
var repository = mock(ContainerFeeDetailRepository.class);
|
||||
var loading = mock(SmartLoadingRepository.class);
|
||||
var items = mock(FeeItemRepository.class);
|
||||
var suppliers = mock(WarehouseSupplierSnapshotService.class);
|
||||
var snapshotMapper = mock(ContainerFeeSourceSnapshotMapper.class);
|
||||
var outboxMapper = mock(InboundOutboxMapper.class);
|
||||
var mapper = JsonMapper.builder().findAndAddModules().build();
|
||||
var snapshots = new ContainerFeeSourceSnapshotRepository(snapshotMapper);
|
||||
var outbox = new FinanceFeeSourceOutboxService(outboxMapper, mapper);
|
||||
var payments = new FeePaymentSnapshotValidator(mock(FeePaymentVoucherFileClient.class), "test");
|
||||
var containerFees = new ContainerFeeDetailService(repository, loading, items, outbox, suppliers, snapshots, payments);
|
||||
var trucking = new TruckAppointmentFeeService(items, containerFees, suppliers);
|
||||
var snapshotQueries = new FinanceFeeSourceSnapshotService(items, snapshots);
|
||||
|
||||
var item = new FeeItemEntity(); item.setId(108L); item.setFeeCode("FEE_TYPE_108");
|
||||
item.setFeeTypeName("拖车费"); item.setStatus("ACTIVE"); item.setPickupPayable(true); item.setSupplierCategory("TRUCKING");
|
||||
when(items.findItemByCode("FEE_TYPE_108")).thenReturn(item);
|
||||
when(items.findItem(108L)).thenReturn(item);
|
||||
var supplier = new WarehouseSupplierClient.Snapshot(8L, "测试车队", "TRUCKING", "ACTIVE", List.of());
|
||||
when(suppliers.trucking(8L, null)).thenReturn(supplier);
|
||||
when(suppliers.fee(8L, "TRUCKING")).thenReturn(supplier);
|
||||
var plan = new LoadingPlanEntity(); plan.setId(10L); plan.setContainerBookingId(1L);
|
||||
var container = new ContainerBookingEntity(); container.setId(1L); container.setContainerNo("TEST1234567");
|
||||
when(loading.plan(10L)).thenReturn(plan); when(loading.booking(1L)).thenReturn(container);
|
||||
var loaded = new ArrayList<LoadingPlanItemEntity>();
|
||||
var departures = new ArrayList<DepartureRow>();
|
||||
for (long customer = 1; customer <= 2; customer++) {
|
||||
var load = new LoadingPlanItemEntity(); load.setAccountId(customer); load.setOrderId(customer * 100);
|
||||
load.setOrderNo("ORDER-" + customer); load.setInboundId(customer * 1000); load.setInboundCode("IN-" + customer);
|
||||
loaded.add(load);
|
||||
departures.add(new DepartureRow(9L, 1, 1L, "TEST1234567", "ACTIVE", 10L, "COMPLETED", customer,
|
||||
customer, load.getOrderId(), load.getOrderNo(), load.getInboundId(), load.getInboundCode(),
|
||||
BigDecimal.valueOf(customer), "WAITING_FOR_LOADING", DEPARTED));
|
||||
}
|
||||
when(loading.items(10L)).thenReturn(loaded);
|
||||
Map<Long, ContainerFeeDetailEntity> saved = new LinkedHashMap<>();
|
||||
doAnswer(call -> { var value = (ContainerFeeDetailEntity) call.getArgument(0); saved.put(value.getId(), value); return null; })
|
||||
.when(repository).insert(any());
|
||||
when(repository.listGroup(eq(10L), anyLong())).thenAnswer(call -> List.copyOf(saved.values()));
|
||||
when(repository.find(anyLong(), eq(10L), anyLong())).thenAnswer(call -> saved.get(call.getArgument(2)));
|
||||
when(repository.updateSupplierSnapshot(anyLong(), eq(10L), anyLong(), eq(8L), eq("测试车队"), eq("TRUCKING"), eq(0L))).thenReturn(1);
|
||||
when(repository.review(anyLong(), eq(10L), anyLong(), eq("APPROVED"), eq(0L), anyString(), isNull()))
|
||||
.thenAnswer(call -> { var value = saved.get(call.getArgument(2)); value.setReviewStatus("APPROVED"); value.setReviewedAt(DEPARTED); return 1; });
|
||||
|
||||
var appointment = new TruckAppointmentEntity(); appointment.setAppointmentNo("TA-260914-0111");
|
||||
appointment.setCarrierId(8L); appointment.setCurrency("RMB"); appointment.setPayableAmount(new BigDecimal("100.00"));
|
||||
trucking.publish(appointment, departures, DEPARTED);
|
||||
|
||||
assertThat(saved.values()).hasSize(2).allSatisfy(value -> {
|
||||
assertThat(value.getDirection()).isEqualTo("PAYABLE");
|
||||
assertThat(value.getReviewStatus()).isEqualTo("APPROVED");
|
||||
assertThat(value.getSettlementMode()).isEqualTo("ON_ACCOUNT");
|
||||
assertThat(value.getPaidAt()).isNull();
|
||||
assertThat(value.getRemark()).contains("TA-260914-0111");
|
||||
});
|
||||
assertThat(saved.values().stream().map(ContainerFeeDetailEntity::getAmount).reduce(BigDecimal.ZERO, BigDecimal::add))
|
||||
.isEqualByComparingTo("100.00");
|
||||
var frozen = ArgumentCaptor.forClass(ContainerFeeSourceSnapshotEntity.class);
|
||||
verify(snapshotMapper, times(2)).insert(frozen.capture());
|
||||
var events = ArgumentCaptor.forClass(InboundOutboxEntity.class);
|
||||
verify(outboxMapper, times(2)).insert(events.capture());
|
||||
for (int i = 0; i < 2; i++) {
|
||||
var source = frozen.getAllValues().get(i);
|
||||
when(snapshotMapper.selectOne(any())).thenReturn(source);
|
||||
var snapshot = snapshotQueries.container(1L, source.getSourceFeeId(), 1);
|
||||
assertThat(snapshot.publicationStatus()).isEqualTo("PUBLISHED");
|
||||
assertThat(snapshot.currency()).isEqualTo("CNY");
|
||||
assertThat(snapshot.customerId()).isEqualTo(source.getTenantId());
|
||||
assertThat(snapshot.orderId()).isEqualTo(source.getAllocationOrderId());
|
||||
assertThat(snapshot.supplierId()).isEqualTo(8L);
|
||||
assertThat(snapshot.sourceHash()).hasSize(64);
|
||||
var event = mapper.readTree(events.getAllValues().get(i).getPayloadJson());
|
||||
assertThat(event.path("eventType").asText()).isEqualTo("FIRSTLEG_FEE_PUBLISHED");
|
||||
assertThat(event.path("aggregateType").asText()).isEqualTo("CONTAINER");
|
||||
assertThat(event.path("payload").path("feeId").asText()).isEqualTo(source.getSourceFeeId().toString());
|
||||
assertThat(event.path("tenantId").asText()).isEqualTo(source.getTenantId().toString());
|
||||
assertThat(events.getAllValues().get(i).getPublishStatus()).isEqualTo("PENDING");
|
||||
}
|
||||
}
|
||||
}
|
||||
+69
-11
@@ -41,6 +41,7 @@ import static org.mockito.Mockito.*;
|
||||
|
||||
class TruckAppointmentServiceTest {
|
||||
@Mock TruckAppointmentRepository repository;
|
||||
@Mock TruckAppointmentCompletionService completion;
|
||||
@Mock OceanBookingRepository oceanBookings;
|
||||
@Mock WarehouseSupplierSnapshotService suppliers;
|
||||
@Mock TruckAppointmentFileClient files;
|
||||
@@ -51,6 +52,11 @@ class TruckAppointmentServiceTest {
|
||||
|
||||
@BeforeEach void setUp() { MockitoAnnotations.openMocks(this);
|
||||
service.internalToken = "test-internal-token";
|
||||
lenient().doAnswer(invocation -> {
|
||||
TruckAppointmentEntity inserted = invocation.getArgument(0);
|
||||
lenient().when(repository.find(inserted.getId())).thenReturn(inserted);
|
||||
return null;
|
||||
}).when(repository).insert(any());
|
||||
lenient().when(documentNumbers.next("TA")).thenReturn("TA-260818-0001");
|
||||
when(suppliers.trucking(any(), any())).thenReturn(
|
||||
new WarehouseSupplierClient.Snapshot(8L, "Canonical Carrier", "TRUCKING", "ACTIVE", java.util.List.of()));
|
||||
@@ -88,6 +94,63 @@ class TruckAppointmentServiceTest {
|
||||
verifyNoInteractions(oceanBookings);
|
||||
}
|
||||
|
||||
@Test void createAfterDepartureReturnsCompletedVersion() {
|
||||
doAnswer(invocation -> {
|
||||
var inserted = org.mockito.Mockito.mockingDetails(repository).getInvocations().stream()
|
||||
.filter(call -> call.getMethod().getName().equals("insert"))
|
||||
.map(call -> (TruckAppointmentEntity) call.getArguments()[0]).findFirst().orElseThrow();
|
||||
inserted.setStatus(TruckAppointmentStatus.COMPLETED); inserted.setVersion(1);
|
||||
return null;
|
||||
}).when(completion).completeForBooking(99L);
|
||||
var result = service.create(7L, "admin", validRequest());
|
||||
assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.COMPLETED);
|
||||
assertThat(result.getVersion()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test void completedBookingCannotCreateAnotherAutomaticCharge() {
|
||||
var existing = entity(TruckAppointmentStatus.COMPLETED, 3);
|
||||
existing.setAppointmentNo("TA-260914-0111");
|
||||
when(repository.firstActiveForUpdate(99L)).thenReturn(existing);
|
||||
assertThatThrownBy(() -> service.create(7L, "admin", validRequest())).hasMessageContaining("TA-260914-0111");
|
||||
verify(repository, never()).insert(any());
|
||||
}
|
||||
|
||||
@Test void lateSubmitReturnsAutomaticCompletionInsteadOfPendingDispatch() {
|
||||
var before = entity(TruckAppointmentStatus.DRAFT, 0);
|
||||
var submitted = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1);
|
||||
when(repository.find(1L)).thenReturn(before, submitted);
|
||||
when(repository.claimOperation(anyLong(), eq(1L), eq("SUBMIT"), eq("late-submit"), anyString())).thenReturn(true);
|
||||
when(repository.transition(1L, "DRAFT", "PENDING_DISPATCH", 0)).thenReturn(1);
|
||||
when(repository.completeOperation(eq(1L), eq("SUBMIT"), eq("late-submit"), anyString())).thenReturn(1);
|
||||
doAnswer(invocation -> { submitted.setStatus(TruckAppointmentStatus.COMPLETED); submitted.setVersion(2); return null; })
|
||||
.when(completion).completeForBooking(99L);
|
||||
var result = service.action(1L, "SUBMIT", new ActionRequest(0, null), "late-submit", null, 7L, "admin");
|
||||
assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.COMPLETED);
|
||||
assertThat(result.getVersion()).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test void reschedulingAnExceptionCannotDuplicateAnAlreadyCompletedBookingCharge() {
|
||||
var failed = entity(TruckAppointmentStatus.EXCEPTION, 1);
|
||||
var completed = entity(TruckAppointmentStatus.COMPLETED, 2); completed.setId(2L);
|
||||
completed.setAppointmentNo("TA-260914-0111");
|
||||
when(repository.find(1L)).thenReturn(failed);
|
||||
when(repository.firstActiveForUpdate(99L)).thenReturn(completed);
|
||||
when(repository.claimOperation(anyLong(), eq(1L), eq("RESCHEDULE"), eq("reschedule-old"), anyString())).thenReturn(true);
|
||||
assertThatThrownBy(() -> service.reschedule(1L, LocalDateTime.now(), new ActionRequest(1, null),
|
||||
"reschedule-old", null, 7L, "admin")).hasMessageContaining("已完成拖车单");
|
||||
verify(repository, never()).reschedule(anyLong(), any(), any(), anyInt());
|
||||
}
|
||||
|
||||
@Test void manualCompleteAndDepartureCannotBypassActualDeparture() {
|
||||
for (String action : List.of("COMPLETE", "DEPART")) {
|
||||
when(repository.find(1L)).thenReturn(entity(TruckAppointmentStatus.LOADING, 4));
|
||||
when(repository.claimOperation(anyLong(), eq(1L), eq(action), eq(action), anyString())).thenReturn(true);
|
||||
assertThatThrownBy(() -> service.action(1L, action, new ActionRequest(4, null), action, null, 7L, "admin"))
|
||||
.isInstanceOf(BizException.class);
|
||||
}
|
||||
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt());
|
||||
}
|
||||
|
||||
@Test void createRequiresZeroVersionAndInitialisesDraft() {
|
||||
SaveRequest invalid = request(1);
|
||||
assertThatThrownBy(() -> service.create(7L, "admin", invalid)).isInstanceOf(BizException.class);
|
||||
@@ -174,17 +237,12 @@ class TruckAppointmentServiceTest {
|
||||
assertThat(inserted.getValue().getCurrency()).isEqualTo("RMB");
|
||||
}
|
||||
|
||||
@Test void createAcceptsUsd() {
|
||||
@Test void createRejectsUsdBeforeWriting() {
|
||||
SaveRequest request = validRequest();
|
||||
when(request.currency()).thenReturn("USD");
|
||||
when(request.payableAmount()).thenReturn(new java.math.BigDecimal("123.45"));
|
||||
|
||||
service.create(7L, "admin", request);
|
||||
|
||||
ArgumentCaptor<TruckAppointmentEntity> inserted = ArgumentCaptor.forClass(TruckAppointmentEntity.class);
|
||||
verify(repository).insert(inserted.capture());
|
||||
assertThat(inserted.getValue().getCurrency()).isEqualTo("USD");
|
||||
assertThat(inserted.getValue().getPayableAmount()).isEqualByComparingTo("123.45");
|
||||
assertThatThrownBy(() -> service.create(7L, "admin", request))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("人民币");
|
||||
verify(repository, never()).insert(any());
|
||||
}
|
||||
|
||||
@Test void createRejectsUnsupportedCurrencyWithoutWriting() {
|
||||
@@ -192,7 +250,7 @@ class TruckAppointmentServiceTest {
|
||||
when(request.currency()).thenReturn("CNY");
|
||||
|
||||
assertThatThrownBy(() -> service.create(7L, "admin", request))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("RMB或USD");
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("人民币");
|
||||
|
||||
verify(repository, never()).insert(any());
|
||||
verify(repository, never()).history(any());
|
||||
@@ -534,7 +592,7 @@ class TruckAppointmentServiceTest {
|
||||
}
|
||||
|
||||
private static TruckAppointmentEntity entity(TruckAppointmentStatus status, int version) {
|
||||
TruckAppointmentEntity e = new TruckAppointmentEntity(); e.setId(1L); e.setStatus(status); e.setVersion(version); e.setRevision(version); return e;
|
||||
TruckAppointmentEntity e = new TruckAppointmentEntity(); e.setId(1L); e.setOceanBookingId(99L); e.setStatus(status); e.setVersion(version); e.setRevision(version); return e;
|
||||
}
|
||||
|
||||
private static SaveRequest request(int expectedVersion) {
|
||||
|
||||
+26
-3
@@ -265,13 +265,21 @@ public class DeliveryTransferService {
|
||||
outbound.setPackageCount(request.items().stream().mapToInt(i -> i.packages().intValue()).sum()); outbound.setPalletCount(request.items().size());
|
||||
outbound.setIdempotencyKey(request.idempotencyKey().trim()); outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now);
|
||||
outboundOrders.insert(outbound);
|
||||
Set<Long> physicalPalletIds = new HashSet<>();
|
||||
for (DeliveryRequests.OneClickItem input : request.items()) {
|
||||
InventoryPosition position = inventoryPositions.selectById(input.inventoryPositionId());
|
||||
if (position == null || !customerId.equals(position.getCustomerId())) throw notFound("Inventory position not found");
|
||||
InventoryBalance balance = inventoryBalances.selectById(position.getBalanceId());
|
||||
if (balance == null || !customerId.equals(balance.getCustomerId()) || !StringUtils.hasText(balance.getInboundCode())) {
|
||||
throw conflict("一件代发出库库存缺少所属入仓码");
|
||||
}
|
||||
OutboundPalletItem item = new OutboundPalletItem(); item.setId(IdWorker.getId()); item.setCustomerId(customerId); item.setOutboundOrderId(outbound.getId());
|
||||
item.setInboundCode(balance.getInboundCode());
|
||||
physicalPalletIds.add(position.getPalletId());
|
||||
item.setWarehousePalletId(position.getPalletId()); item.setPalletNo(position.getPalletNo()); item.setPackageCount(input.packages().intValue()); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK"); item.setSkuCode(input.sku().trim()); item.setCartonMarks(String.join(",", input.cartonMarks() == null ? List.of() : input.cartonMarks())); item.setCreatedAt(now);
|
||||
outboundPallets.insert(item);
|
||||
}
|
||||
outbound.setPalletCount(physicalPalletIds.size());
|
||||
outbound.setOverseasWarehouseId(selectedWarehouseId); outboundOrders.updateById(outbound);
|
||||
OutboundPdaTask task = new OutboundPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setOutboundOrderId(outbound.getId());
|
||||
task.setTaskNo("PDA" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + task.getId()); task.setStatus("PENDING"); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task);
|
||||
@@ -476,6 +484,11 @@ public class DeliveryTransferService {
|
||||
if (!warehouseId.equals(selectedPosition.getOverseasWarehouseId())) {
|
||||
throw conflict("Scanned inventory position does not belong to the bound warehouse");
|
||||
}
|
||||
InventoryBalance selectedBalance = inventoryBalances.selectById(selectedPosition.getBalanceId());
|
||||
if (selectedBalance == null || !customerId.equals(selectedBalance.getCustomerId())
|
||||
|| !StringUtils.hasText(item.getInboundCode()) || !item.getInboundCode().equals(selectedBalance.getInboundCode())) {
|
||||
throw conflict("所选库存不属于出库明细的入仓码");
|
||||
}
|
||||
DropshipOrderProfile profile = dropshipProfiles.findPosition(customerId, request.inventoryPositionId());
|
||||
if (profile == null) throw conflict("Scanned pallet is not one-click dropship inventory");
|
||||
List<DropshipCarton> cartons = StringUtils.hasText(item.getCartonMarks())
|
||||
@@ -488,7 +501,12 @@ public class DeliveryTransferService {
|
||||
inventoryCommands.allocate(customerId, operatorId, move);
|
||||
inventoryCommands.pick(customerId, operatorId, new InventoryCommandRequests.Move(request.inventoryPositionId(), request.packages(), outbound.getOutboundNo(), outbound.getOutboundNo(), null, request.idempotencyKey() + ":PICK", null));
|
||||
inventoryCommands.ship(customerId, operatorId, new InventoryCommandRequests.Move(request.inventoryPositionId(), request.packages(), outbound.getOutboundNo(), outbound.getOutboundNo(), null, request.idempotencyKey() + ":SHIP", null));
|
||||
if (dropshipSkuInventory.deduct(customerId, profile.getId(), item.getSkuCode(), request.packages()) != 1) throw conflict("Insufficient one-click dropship SKU inventory");
|
||||
long productQuantity = 0;
|
||||
for (DropshipCarton carton : cartons) {
|
||||
if (carton.getProductQuantity() == null || carton.getProductQuantity() <= 0) throw conflict("出库箱唛缺少逐箱实收件数");
|
||||
productQuantity = Math.addExact(productQuantity, carton.getProductQuantity());
|
||||
}
|
||||
if (dropshipSkuInventory.deduct(customerId, profile.getId(), item.getSkuCode(), request.packages(), productQuantity) != 1) throw conflict("Insufficient one-click dropship SKU inventory");
|
||||
for (DropshipCarton carton : cartons) { carton.setStatus("SHIPPED"); carton.setOutboundOrderNo(outbound.getOutboundNo()); carton.setVersion((carton.getVersion() == null ? 0 : carton.getVersion()) + 1); carton.setUpdatedAt(LocalDateTime.now()); dropshipCartons.updateById(carton); }
|
||||
OutboundPdaPick pick = new OutboundPdaPick(); pick.setId(IdWorker.getId()); pick.setCustomerId(customerId); pick.setTaskId(task.getId()); pick.setOutboundPalletItemId(item.getId()); pick.setInventoryPositionId(request.inventoryPositionId()); pick.setPackages(request.packages()); pick.setIdempotencyKey(request.idempotencyKey().trim()); pick.setPickedAt(LocalDateTime.now()); pdaPicks.insert(pick);
|
||||
item.setPickedPackageCount(value(item.getPickedPackageCount()) + request.packages().intValue()); item.setStatus(item.getPickedPackageCount() >= item.getPackageCount() ? "PICKED" : "PARTIAL_PICKED"); outboundPallets.updateById(item);
|
||||
@@ -630,6 +648,8 @@ public class DeliveryTransferService {
|
||||
long available = value(position.getAvailablePackages());
|
||||
if (available <= 0 || !warehouseId.equals(position.getOverseasWarehouseId())) continue;
|
||||
InventoryBalance balance = inventoryBalances.selectById(position.getBalanceId());
|
||||
if (StringUtils.hasText(item.getInboundCode()) && (balance == null
|
||||
|| !item.getInboundCode().equals(balance.getInboundCode()))) continue;
|
||||
DropshipOrderProfile profile = dropshipProfiles.findPosition(customerId, position.getId());
|
||||
List<String> marks = profile == null ? List.of() : dropshipCartons.listProfile(customerId, profile.getId())
|
||||
.stream().filter(carton -> "AVAILABLE".equals(carton.getStatus()))
|
||||
@@ -698,10 +718,13 @@ public class DeliveryTransferService {
|
||||
OutboundOrder outbound = outboundOrders.findGlobalByNo(trim(outboundNo));
|
||||
if (outbound == null) throw notFound("Outbound order not found");
|
||||
String customerId = outbound.getCustomerId();
|
||||
DeliveryOrder delivery = requireDelivery(customerId, outbound.getId());
|
||||
DeliveryOrder delivery = deliveryOrders.lockByOutbound(customerId, outbound.getId());
|
||||
if (delivery == null && "DISPATCH_DELIVERY".equals(outbound.getOutboundType())) {
|
||||
throw new IllegalStateException("Delivery document is missing for dispatch outbound order");
|
||||
}
|
||||
List<DeliveryResponses.OutboundPalletRow> pallets = outboundPallets.findByOutbound(customerId, outbound.getId()).stream()
|
||||
.map(this::outboundPalletRow).toList();
|
||||
return new DeliveryResponses.OutboundDetail(outboundRow(outbound), deliveryRow(delivery), pallets);
|
||||
return new DeliveryResponses.OutboundDetail(outboundRow(outbound), delivery == null ? null : deliveryRow(delivery), pallets);
|
||||
}
|
||||
|
||||
public PageResult<DeliveryResponses.DeliveryRow> deliveryPage(String customerId, DeliveryRequests.Query query) {
|
||||
|
||||
@@ -15,5 +15,6 @@ public final class DropshipRequests {
|
||||
@NotBlank @Size(max = 64) String manifestHash) {}
|
||||
public record SkuPackages(@NotBlank String skuCode, String productName, @Positive long packageCount,
|
||||
@Positive long productQuantity,
|
||||
@NotEmpty List<@NotBlank String> cartonMarks) {}
|
||||
@NotEmpty List<@NotBlank String> cartonMarks,
|
||||
@NotEmpty java.util.Map<@NotBlank String, @Positive Long> cartonQuantities) {}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ public class DropshipCarton {
|
||||
private String palletNo;
|
||||
private String skuCode;
|
||||
private String cartonMark;
|
||||
private Long productQuantity;
|
||||
private Integer cartonSequence;
|
||||
private String status;
|
||||
private String outboundOrderNo;
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ public interface DropshipOrderProfileMapper extends BaseMapper<DropshipOrderProf
|
||||
"AND NOT EXISTS (SELECT 1 FROM ll_outbound_pallet_item oi JOIN ll_outbound_order oo " +
|
||||
"ON oo.id=oi.outbound_order_id AND oo.customer_id=oi.customer_id " +
|
||||
"WHERE oi.customer_id=p.customer_id AND oi.warehouse_pallet_id=p.warehouse_pallet_id " +
|
||||
"AND oi.sku_code=s.sku_code AND oo.status IN ('WAIT_PICK','PICKING','PICKED')) ";
|
||||
"AND oi.inbound_code=b.inbound_code AND oi.sku_code=s.sku_code AND oo.status IN ('WAIT_PICK','PICKING','PICKED')) ";
|
||||
|
||||
@Select("SELECT * FROM ll_dropship_order_profile WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY source_pallet_no")
|
||||
List<DropshipOrderProfile> listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId);
|
||||
|
||||
+2
-2
@@ -14,8 +14,8 @@ import java.util.List;
|
||||
public interface DropshipSkuInventoryMapper extends BaseMapper<DropshipSkuInventory> {
|
||||
@Select("SELECT * FROM ll_dropship_sku_inventory WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY profile_id,sku_code")
|
||||
List<DropshipSkuInventory> listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId);
|
||||
@Update("UPDATE ll_dropship_sku_inventory SET package_count=package_count-#{packages},updated_at=NOW(3) WHERE customer_id=#{customerId} AND profile_id=#{profileId} AND sku_code=#{sku} AND package_count>=#{packages}")
|
||||
int deduct(@Param("customerId") String customerId, @Param("profileId") Long profileId, @Param("sku") String sku, @Param("packages") long packages);
|
||||
@Update("UPDATE ll_dropship_sku_inventory SET package_count=package_count-#{packages},product_quantity=product_quantity-#{quantity},updated_at=NOW(3) WHERE customer_id=#{customerId} AND profile_id=#{profileId} AND sku_code=#{sku} AND package_count>=#{packages} AND product_quantity>=#{quantity}")
|
||||
int deduct(@Param("customerId") String customerId, @Param("profileId") Long profileId, @Param("sku") String sku, @Param("packages") long packages, @Param("quantity") long quantity);
|
||||
@Update("UPDATE ll_dropship_sku_inventory SET package_count=package_count+#{packages},updated_at=NOW(3) WHERE customer_id=#{customerId} AND profile_id=#{profileId} AND sku_code=#{sku}")
|
||||
int restore(@Param("customerId") String customerId, @Param("profileId") Long profileId, @Param("sku") String sku, @Param("packages") long packages);
|
||||
}
|
||||
|
||||
+7
@@ -83,6 +83,7 @@ public class DropshipOrderService {
|
||||
carton.setId(IdWorker.getId()); carton.setCustomerId(request.customerId()); carton.setOrderId(order.getId());
|
||||
carton.setProfileId(profile.getId()); carton.setWarehousePalletId(null); carton.setPalletNo(null);
|
||||
carton.setSkuCode(input.skuCode()); carton.setCartonMark(mark.trim()); carton.setCartonSequence(++sequence);
|
||||
carton.setProductQuantity(input.cartonQuantities().get(mark));
|
||||
carton.setStatus("PENDING_INBOUND"); carton.setVersion(0L); carton.setCreatedAt(now); carton.setUpdatedAt(now);
|
||||
cartons.insert(carton);
|
||||
ledger.insert(stockInFact(request, order, carton, now));
|
||||
@@ -102,6 +103,12 @@ public class DropshipOrderService {
|
||||
if (item.packageCount() != item.cartonMarks().size()) {
|
||||
throw conflict("SKU packageCount must equal cartonMarks count: " + item.skuCode());
|
||||
}
|
||||
if (item.cartonQuantities() == null
|
||||
|| !item.cartonQuantities().keySet().equals(new java.util.HashSet<>(item.cartonMarks()))
|
||||
|| item.cartonQuantities().values().stream().anyMatch(value -> value == null || value <= 0)
|
||||
|| item.cartonQuantities().values().stream().mapToLong(Long::longValue).sum() != item.productQuantity()) {
|
||||
throw conflict("逐箱实收件数必须完整且与 SKU 汇总件数一致");
|
||||
}
|
||||
if (!skus.add(item.skuCode().trim())) throw conflict("Duplicate SKU on pallet: " + item.skuCode());
|
||||
for (String mark : item.cartonMarks()) {
|
||||
if (!marks.add(mark.trim())) throw conflict("Duplicate carton mark: " + mark);
|
||||
|
||||
+30
-11
@@ -507,18 +507,24 @@ public class LoadingCompletedConsumer {
|
||||
JsonNode rawPallets = rawSource.path("pallets");
|
||||
for (int palletIndex = 0; palletIndex < source.pallets().size(); palletIndex++) {
|
||||
FirstLegLoadingPlanCompletedV1.PalletSnapshot pallet = source.pallets().get(palletIndex);
|
||||
Map<String, DropshipRequests.SkuPackages> inventorySkus = new java.util.LinkedHashMap<>();
|
||||
for (var sku : pallet.skuPackages()) {
|
||||
inventorySkus.merge(sku.skuCode(), new DropshipRequests.SkuPackages(sku.skuCode(),
|
||||
sku.productName(), sku.packageCount(), sku.productQuantity(), sku.cartonMarks()), (left, right) -> {
|
||||
List<String> marks = new ArrayList<>(left.cartonMarks()); marks.addAll(right.cartonMarks());
|
||||
return new DropshipRequests.SkuPackages(left.skuCode(), left.productName(),
|
||||
Math.addExact(left.packageCount(), right.packageCount()),
|
||||
Math.addExact(left.productQuantity(), right.productQuantity()), List.copyOf(marks));
|
||||
});
|
||||
}
|
||||
JsonNode rawPallet = rawPallets.isArray() && palletIndex < rawPallets.size()
|
||||
? rawPallets.get(palletIndex) : objectMapper.createObjectNode();
|
||||
Map<String, DropshipRequests.SkuPackages> inventorySkus = new java.util.LinkedHashMap<>();
|
||||
for (int skuIndex = 0; skuIndex < pallet.skuPackages().size(); skuIndex++) {
|
||||
var sku = pallet.skuPackages().get(skuIndex);
|
||||
inventorySkus.merge(sku.skuCode(), new DropshipRequests.SkuPackages(sku.skuCode(),
|
||||
sku.productName(), sku.packageCount(), sku.productQuantity(), sku.cartonMarks(),
|
||||
cartonQuantities(rawPallet.path("skuPackages").path(skuIndex))), (left, right) -> {
|
||||
List<String> marks = new ArrayList<>(left.cartonMarks()); marks.addAll(right.cartonMarks());
|
||||
Map<String, Long> quantities = new java.util.LinkedHashMap<>(left.cartonQuantities());
|
||||
right.cartonQuantities().forEach((mark, quantity) -> {
|
||||
if (quantities.putIfAbsent(mark, quantity) != null) throw new IllegalArgumentException("Duplicate carton quantity: " + mark);
|
||||
});
|
||||
return new DropshipRequests.SkuPackages(left.skuCode(), left.productName(),
|
||||
Math.addExact(left.packageCount(), right.packageCount()),
|
||||
Math.addExact(left.productQuantity(), right.productQuantity()), List.copyOf(marks), Map.copyOf(quantities));
|
||||
});
|
||||
}
|
||||
result.add(new DropshipRequests.Pallet(pallet.palletNo(), List.copyOf(inventorySkus.values()),
|
||||
positiveManifest(rawPallet, "manifestVersion"), positiveManifest(rawPallet, "manifestPackageCount"),
|
||||
positiveManifest(rawPallet, "manifestFragmentCount"), requiredManifestHash(rawPallet)));
|
||||
@@ -567,6 +573,19 @@ public class LoadingCompletedConsumer {
|
||||
return value.toLowerCase(java.util.Locale.ROOT);
|
||||
}
|
||||
|
||||
private Map<String, Long> cartonQuantities(JsonNode sku) {
|
||||
JsonNode node = sku.path("cartonQuantities");
|
||||
if (!node.isObject()) throw new IllegalArgumentException("One-click dropship cartonQuantities is required");
|
||||
Map<String, Long> result = new java.util.LinkedHashMap<>();
|
||||
node.fields().forEachRemaining(entry -> {
|
||||
if (!entry.getValue().isIntegralNumber() || !entry.getValue().canConvertToLong() || entry.getValue().longValue() <= 0) {
|
||||
throw new IllegalArgumentException("Invalid carton quantity: " + entry.getKey());
|
||||
}
|
||||
result.put(entry.getKey(), entry.getValue().longValue());
|
||||
});
|
||||
return Map.copyOf(result);
|
||||
}
|
||||
|
||||
private List<DropshipRequests.SkuPackages> skuPackages(JsonNode source) {
|
||||
List<DropshipRequests.SkuPackages> result = new ArrayList<>();
|
||||
JsonNode nodes = source.path("skuPackages");
|
||||
@@ -585,7 +604,7 @@ public class LoadingCompletedConsumer {
|
||||
}
|
||||
if (sku != null && !sku.isBlank() && packageCount > 0 && productQuantity > 0 && !marks.isEmpty()) {
|
||||
String productName = item.path("productName").asText(item.path("productNameZh").asText(null));
|
||||
result.add(new DropshipRequests.SkuPackages(sku, productName, packageCount, productQuantity, marks));
|
||||
result.add(new DropshipRequests.SkuPackages(sku, productName, packageCount, productQuantity, marks, cartonQuantities(item)));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -1435,7 +1435,7 @@ CREATE TABLE IF NOT EXISTS ll_outbound_pallet_item (
|
||||
destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照',
|
||||
package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', picked_package_count INT NOT NULL DEFAULT 0 COMMENT '已拣箱数', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_PICK' COMMENT '拣货状态', sku_code VARCHAR(128) NULL COMMENT '一件代发SKU', order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_ll_outbound_pallet (customer_id,outbound_order_id,warehouse_pallet_id),
|
||||
UNIQUE KEY uk_ll_outbound_pallet (customer_id,outbound_order_id,warehouse_pallet_id,inbound_code,sku_code),
|
||||
KEY idx_ll_outbound_pallet_list (customer_id,outbound_order_id), KEY idx_ll_outbound_pallet_destination (customer_id,destination_warehouse_code)
|
||||
) ENGINE=InnoDB COMMENT='出库托盘明细';
|
||||
|
||||
@@ -1877,6 +1877,7 @@ CREATE TABLE IF NOT EXISTS ll_dropship_order_profile (
|
||||
CREATE TABLE IF NOT EXISTS ll_dropship_carton (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', profile_id BIGINT NOT NULL COMMENT '托盘档案ID',
|
||||
warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛',
|
||||
product_quantity BIGINT NOT NULL COMMENT '该箱实收商品件数',
|
||||
carton_sequence INT NOT NULL COMMENT '箱唛序号', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_INBOUND' COMMENT '业务状态', outbound_order_no VARCHAR(128) NULL COMMENT '出库单号',
|
||||
version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_dropship_carton(customer_id,order_id,carton_mark),
|
||||
|
||||
@@ -57,12 +57,16 @@ components:
|
||||
items: { $ref: '#/components/schemas/DropshipSkuPackages' }
|
||||
DropshipSkuPackages:
|
||||
type: object
|
||||
required: [skuCode, packageCount, productQuantity, cartonMarks]
|
||||
required: [skuCode, packageCount, productQuantity, cartonMarks, cartonQuantities]
|
||||
properties:
|
||||
skuCode: { type: string, minLength: 1 }
|
||||
productName: { type: string, nullable: true }
|
||||
packageCount: { type: integer, format: int64, minimum: 1 }
|
||||
productQuantity: { type: integer, format: int64, minimum: 1 }
|
||||
cartonQuantities:
|
||||
type: object
|
||||
description: 箱唛到逐箱实收商品件数的完整映射,键必须与 cartonMarks 一致,值合计必须等于 productQuantity
|
||||
additionalProperties: { type: integer, format: int64, minimum: 1 }
|
||||
cartonMarks:
|
||||
type: array
|
||||
minItems: 1
|
||||
|
||||
@@ -664,7 +664,7 @@ paths:
|
||||
description: 权限 platform:overseas-warehouse:outbound:list;只按全局唯一 outboundNo 定位并从记录解析客户范围,不接收 customerId。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters: [{ name: outboundNo, in: path, required: true, schema: { type: string } }]
|
||||
responses: { '200': { description: '出库单、出库托盘及关联派送单详情;pallets[].palletNo 为 Warehouse 权威实物托盘码' }, '404': { description: 出库单不存在 } }
|
||||
responses: { '200': { description: '出库单、出库托盘及关联派送单详情;一件代发等独立出库无派送单时 delivery 为 null,排车转派送必须存在派送单;pallets[].palletNo 为 Warehouse 权威实物托盘码' }, '404': { description: 出库单不存在 } }
|
||||
/api/platform/v1/last-leg/outbound-orders/{outboundNo}/cancel:
|
||||
post:
|
||||
summary: 取消未执行出库单
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ class DeliverySchemaContractTest {
|
||||
assertTrue(schema.contains("unique key uk_ll_outbound_idempotency (customer_id,idempotency_key)"));
|
||||
assertTrue(schema.contains("unique key uk_ll_outbound_dispatch (customer_id,dispatch_order_id)"));
|
||||
assertTrue(schema.contains("create table if not exists ll_outbound_pallet_item"));
|
||||
assertTrue(schema.contains("unique key uk_ll_outbound_pallet (customer_id,outbound_order_id,warehouse_pallet_id)"));
|
||||
assertTrue(schema.contains("unique key uk_ll_outbound_pallet (customer_id,outbound_order_id,warehouse_pallet_id,inbound_code,sku_code)"));
|
||||
assertTrue(schema.contains("destination_warehouse_code varchar(128) null"));
|
||||
assertTrue(schema.contains("order_refs varchar(2000) null"));
|
||||
assertTrue(schema.contains("inbound_code varchar(128) null"));
|
||||
|
||||
+97
-1
@@ -279,6 +279,31 @@ class DeliveryTransferServiceTest {
|
||||
verify(outboundOrders, never()).insert((OutboundOrder) any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void dedicatedOutboundDetailDoesNotRequireADeliveryDocument() {
|
||||
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "dropship-301");
|
||||
outbound.setOutboundType("ONE_CLICK_DROPSHIP");
|
||||
outbound.setStatus("OUTBOUNDED");
|
||||
when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound);
|
||||
when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(
|
||||
pallet(501L, "DP-260914-0001", "ONT8", "Ontario", "IN-A", 4)));
|
||||
|
||||
DeliveryResponses.OutboundDetail detail = service.outboundDetail("OUT-301");
|
||||
|
||||
assertEquals("OUTBOUNDED", detail.outbound().status());
|
||||
org.junit.jupiter.api.Assertions.assertNull(detail.delivery());
|
||||
assertEquals("IN-A", detail.pallets().get(0).inboundCode());
|
||||
assertEquals(4, detail.pallets().get(0).packageCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void dispatchOutboundStillRequiresItsDeliveryDocument() {
|
||||
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "dispatch-301");
|
||||
outbound.setOutboundType("DISPATCH_DELIVERY");
|
||||
when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound);
|
||||
assertThrows(IllegalStateException.class, () -> service.outboundDetail("OUT-301"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void outboundDetailIncludesPalletCodesForDisplay() {
|
||||
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101");
|
||||
@@ -406,6 +431,33 @@ class DeliveryTransferServiceTest {
|
||||
verify(outboundPallets, never()).findByOutbound(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sharedPalletOutboundFreezesEachInboundAndCountsOnePhysicalPallet() {
|
||||
for (long i = 1; i <= 2; i++) {
|
||||
when(dropshipProfiles.lockOutboundCandidate("TENANT-A", 900L + i, "SKU-1"))
|
||||
.thenReturn(dropshipCandidate(900L + i, 1000L + i, "WH-1", "DP-SHARED", "SKU-1", 1L));
|
||||
when(dropshipCartons.lockAvailable("TENANT-A", 1000L + i, "SKU-1", 1L))
|
||||
.thenReturn(List.of(new DropshipCarton()));
|
||||
InventoryPosition position = new InventoryPosition();
|
||||
position.setId(900L + i); position.setCustomerId("TENANT-A"); position.setBalanceId(800L + i);
|
||||
position.setPalletId(501L); position.setPalletNo("DP-SHARED");
|
||||
InventoryBalance balance = new InventoryBalance();
|
||||
balance.setCustomerId("TENANT-A"); balance.setInboundCode("IN-" + i);
|
||||
when(inventoryPositions.selectById(900L + i)).thenReturn(position);
|
||||
when(inventoryBalances.selectById(800L + i)).thenReturn(balance);
|
||||
}
|
||||
service.createOneClick("TENANT-A", "U-1", new DeliveryRequests.OneClickOutbound("recipient", List.of(
|
||||
new DeliveryRequests.OneClickItem(901L, 1L, "SKU-1"),
|
||||
new DeliveryRequests.OneClickItem(902L, 1L, "SKU-1")), null, "shared-pallet"));
|
||||
ArgumentCaptor<OutboundPalletItem> items = ArgumentCaptor.forClass(OutboundPalletItem.class);
|
||||
verify(outboundPallets, org.mockito.Mockito.times(2)).insert(items.capture());
|
||||
assertEquals(List.of("IN-1", "IN-2"), items.getAllValues().stream().map(OutboundPalletItem::getInboundCode).toList());
|
||||
ArgumentCaptor<OutboundOrder> order = ArgumentCaptor.forClass(OutboundOrder.class);
|
||||
verify(outboundOrders).updateById(order.capture());
|
||||
assertEquals(1, order.getValue().getPalletCount());
|
||||
assertEquals(2, order.getValue().getPackageCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void oneClickOutboundRejectsSummarySkuWithoutAvailableDropshipCartons() {
|
||||
DropshipOutboundCandidate candidate = dropshipCandidate(901L, 1001L, "WH-1", "PLT-501", "MIXED", 1L);
|
||||
@@ -539,17 +591,26 @@ class DeliveryTransferServiceTest {
|
||||
DropshipOrderProfile profile = new DropshipOrderProfile(); profile.setId(1001L);
|
||||
DropshipCarton first = new DropshipCarton(); first.setStatus("AVAILABLE"); first.setSkuCode("SKU-1"); first.setCartonMark("CTN-A");
|
||||
DropshipCarton second = new DropshipCarton(); second.setStatus("AVAILABLE"); second.setSkuCode("SKU-1"); second.setCartonMark("CTN-B");
|
||||
InventoryPosition otherInbound = new InventoryPosition();
|
||||
otherInbound.setId(902L); otherInbound.setCustomerId("TENANT-A"); otherInbound.setBalanceId(802L);
|
||||
otherInbound.setOverseasWarehouseId("WH-1"); otherInbound.setPalletId(501L);
|
||||
otherInbound.setAvailablePackages(2L);
|
||||
InventoryBalance otherBalance = new InventoryBalance();
|
||||
otherBalance.setId(802L); otherBalance.setCustomerId("TENANT-A"); otherBalance.setInboundCode("IN-B");
|
||||
otherBalance.setSkuCode("SKU-1");
|
||||
when(pdaTasks.findByNoAndWarehouse("PDA-701", "WH-1")).thenReturn(task);
|
||||
when(outboundOrders.selectById(301L)).thenReturn(outbound);
|
||||
when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item));
|
||||
when(inventoryPositions.listPallet("TENANT-A", 501L)).thenReturn(List.of(position));
|
||||
when(inventoryPositions.listPallet("TENANT-A", 501L)).thenReturn(List.of(position, otherInbound));
|
||||
when(inventoryBalances.selectById(801L)).thenReturn(balance);
|
||||
when(inventoryBalances.selectById(802L)).thenReturn(otherBalance);
|
||||
when(dropshipProfiles.findPosition("TENANT-A", 901L)).thenReturn(profile);
|
||||
when(dropshipCartons.listProfile("TENANT-A", 1001L)).thenReturn(List.of(first, second));
|
||||
|
||||
DeliveryResponses.PdaTaskDetail detail = service.pdaTaskDetail("WH-1", "PDA-701");
|
||||
|
||||
assertEquals(2, detail.items().get(0).positions().size());
|
||||
verify(dropshipProfiles, never()).findPosition("TENANT-A", 902L);
|
||||
assertEquals(List.of("CTN-A", "CTN-B"), detail.items().get(0).positions().stream()
|
||||
.map(DeliveryResponses.PdaPickPosition::cartonMark).toList());
|
||||
DeliveryResponses.PdaPickPosition projected = detail.items().get(0).positions().get(0);
|
||||
@@ -588,6 +649,41 @@ class DeliveryTransferServiceTest {
|
||||
assertEquals("CTN-A", detail.items().get(0).positions().get(0).cartonMark());
|
||||
}
|
||||
|
||||
@Test
|
||||
void dropshipPartialPicksDeductExactCartonProductQuantities() {
|
||||
var commands = org.mockito.Mockito.mock(com.baiyue.lastleg.inventory.command.service.InventoryCommandService.class);
|
||||
ReflectionTestUtils.setField(service, "inventoryCommands", commands);
|
||||
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "quantity-test");
|
||||
outbound.setOverseasWarehouseId("WH-1"); outbound.setOutboundType("ONE_CLICK_DROPSHIP");
|
||||
OutboundPdaTask task = new OutboundPdaTask(); task.setId(701L); task.setCustomerId("TENANT-A");
|
||||
task.setOutboundOrderId(301L); task.setTaskNo("PDA-701"); task.setStatus("PENDING");
|
||||
OutboundPalletItem item = pallet(501L, "DP-1", "ONT8", "Ontario", "IN-A", 2);
|
||||
item.setSkuCode("SKU-1");
|
||||
InventoryPosition position = new InventoryPosition(); position.setId(901L); position.setCustomerId("TENANT-A");
|
||||
position.setBalanceId(801L); position.setPalletId(501L); position.setOverseasWarehouseId("WH-1");
|
||||
InventoryBalance balance = new InventoryBalance(); balance.setCustomerId("TENANT-A"); balance.setInboundCode("IN-A");
|
||||
DropshipOrderProfile profile = new DropshipOrderProfile(); profile.setId(1001L);
|
||||
DropshipCarton first = new DropshipCarton(); first.setId(1101L); first.setProductQuantity(7L);
|
||||
DropshipCarton second = new DropshipCarton(); second.setId(1102L); second.setProductQuantity(13L);
|
||||
when(pdaTasks.lockByNoAndWarehouse("PDA-701", "WH-1")).thenReturn(task);
|
||||
when(outboundOrders.selectById(301L)).thenReturn(outbound);
|
||||
when(outboundOrders.lockByNo("TENANT-A", "OUT-301")).thenReturn(outbound);
|
||||
when(outboundPallets.selectById(item.getId())).thenReturn(item);
|
||||
when(inventoryPositions.selectById(901L)).thenReturn(position);
|
||||
when(inventoryBalances.selectById(801L)).thenReturn(balance);
|
||||
when(dropshipProfiles.findPosition("TENANT-A", 901L)).thenReturn(profile);
|
||||
when(dropshipCartons.lockAvailable("TENANT-A", 1001L, "SKU-1", 1L)).thenReturn(List.of(first), List.of(second));
|
||||
when(dropshipSkuInventory.deduct("TENANT-A", 1001L, "SKU-1", 1L, 7L)).thenReturn(1);
|
||||
when(dropshipSkuInventory.deduct("TENANT-A", 1001L, "SKU-1", 1L, 13L)).thenReturn(1);
|
||||
when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item));
|
||||
service.pdaPick("WH-1", "U-1", "PDA-701", new DeliveryRequests.PdaPick(item.getId(), 901L, 1L, "pick-7"));
|
||||
service.pdaPick("WH-1", "U-1", "PDA-701", new DeliveryRequests.PdaPick(item.getId(), 901L, 1L, "pick-13"));
|
||||
verify(dropshipSkuInventory).deduct("TENANT-A", 1001L, "SKU-1", 1L, 7L);
|
||||
verify(dropshipSkuInventory).deduct("TENANT-A", 1001L, "SKU-1", 1L, 13L);
|
||||
assertEquals("SHIPPED", first.getStatus()); assertEquals("SHIPPED", second.getStatus());
|
||||
assertEquals(2, item.getPickedPackageCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void outboundPdaPickReplayDoesNotDeductInventoryAgainAndRejectsChangedRequest() {
|
||||
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key");
|
||||
|
||||
+2
-2
@@ -59,7 +59,7 @@ class DropshipOrderControllerContractTest {
|
||||
"name: X-Internal-Service-Token",
|
||||
"required: [customerId, orderId, warehouseId, idempotencyKey, pallets]",
|
||||
"required: [palletNo, skuPackages]",
|
||||
"required: [skuCode, packageCount, productQuantity, cartonMarks]",
|
||||
"required: [skuCode, packageCount, productQuantity, cartonMarks, cartonQuantities]",
|
||||
"data: { type: integer, format: int32, minimum: 0 }");
|
||||
assertThat(yaml.indexOf("name: X-Internal-Service-Token"))
|
||||
.isLessThan(yaml.indexOf("requestBody:"));
|
||||
@@ -69,6 +69,6 @@ class DropshipOrderControllerContractTest {
|
||||
return new DropshipRequests.Receive("100", 10L, 20L, "event-1",
|
||||
List.of(new DropshipRequests.Pallet("PALLET-1",
|
||||
List.of(new DropshipRequests.SkuPackages("SKU-1", "Product", 1, 2,
|
||||
List.of("CARTON-1"))), 1, 1, 1, "0".repeat(64))));
|
||||
List.of("CARTON-1"), java.util.Map.of("CARTON-1", 2L))), 1, 1, 1, "0".repeat(64))));
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -52,10 +52,10 @@ class DropshipOrderServiceTest {
|
||||
List.of(
|
||||
new DropshipRequests.Pallet("SOURCE-P-1", List.of(
|
||||
new DropshipRequests.SkuPackages("SKU-A", "Product A", 2, 20,
|
||||
List.of("MARK-1", "MARK-2"))), 1, 2, 1, "0".repeat(64)),
|
||||
List.of("MARK-1", "MARK-2"), java.util.Map.of("MARK-1", 7L, "MARK-2", 13L))), 1, 2, 1, "0".repeat(64)),
|
||||
new DropshipRequests.Pallet("SOURCE-P-2", List.of(
|
||||
new DropshipRequests.SkuPackages("SKU-A", "Product A", 1, 10,
|
||||
List.of("MARK-3"))), 1, 1, 1, "1".repeat(64))));
|
||||
List.of("MARK-3"), java.util.Map.of("MARK-3", 10L))), 1, 1, 1, "1".repeat(64))));
|
||||
|
||||
assertThat(service.receive(request)).isEqualTo(3);
|
||||
verifyNoInteractions(warehouse);
|
||||
@@ -76,6 +76,8 @@ class DropshipOrderServiceTest {
|
||||
.containsOnlyNulls();
|
||||
ArgumentCaptor<DropshipCarton> cartonCaptor = ArgumentCaptor.forClass(DropshipCarton.class);
|
||||
verify(cartons, times(3)).insert(cartonCaptor.capture());
|
||||
assertThat(cartonCaptor.getAllValues()).extracting(DropshipCarton::getProductQuantity)
|
||||
.containsExactly(7L, 13L, 10L);
|
||||
assertThat(cartonCaptor.getAllValues()).extracting(DropshipCarton::getStatus)
|
||||
.containsOnly("PENDING_INBOUND");
|
||||
ArgumentCaptor<DropshipCartonLedger> ledgerCaptor = ArgumentCaptor.forClass(DropshipCartonLedger.class);
|
||||
|
||||
+24
-1
@@ -141,6 +141,18 @@ class LoadingCompletedConsumerTest {
|
||||
}
|
||||
verify(dropshipOrders).receive(any());
|
||||
}
|
||||
@Test void preservesNonUniformCartonQuantitiesAcrossTheEventBoundary() {
|
||||
EventEnvelope original=overseasDropshipEvent(false);
|
||||
var node=(com.fasterxml.jackson.databind.node.ObjectNode)original.payload().deepCopy();
|
||||
var sku=(com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0).path("pallets").get(0).path("skuPackages").get(0);
|
||||
var quantities=sku.putObject("cartonQuantities");
|
||||
quantities.put("RK1#5-1",6).put("RK1#5-2",7).put("RK1#5-3",8).put("RK1#5-4",9).put("RK1#5-5",20);
|
||||
service.consume(rehash(original,node));
|
||||
var captured=ArgumentCaptor.forClass(DropshipRequests.Receive.class);
|
||||
verify(dropshipOrders).receive(captured.capture());
|
||||
assertEquals(Map.of("RK1#5-1",6L,"RK1#5-2",7L,"RK1#5-3",8L,"RK1#5-4",9L,"RK1#5-5",20L),
|
||||
captured.getValue().pallets().get(0).skuPackages().get(0).cartonQuantities());
|
||||
}
|
||||
@Test void rejectsAmbiguousDropshipSkuSourceBeforeWritingReceivingLabels() {
|
||||
var failure=assertThrows(IllegalArgumentException.class,()->service.consume(overseasDropshipEvent(true)));
|
||||
assertTrue(failure.getMessage().contains("来源明细"));
|
||||
@@ -159,6 +171,7 @@ class LoadingCompletedConsumerTest {
|
||||
var secondSku=(com.fasterxml.jackson.databind.node.ObjectNode)secondPallet.path("skuPackages").get(0);
|
||||
var marks=secondSku.putArray("cartonMarks"); for(int n=6;n<=10;n++)marks.add("RK1#10-"+n);
|
||||
((com.fasterxml.jackson.databind.node.ArrayNode)order.path("pallets")).add(secondPallet);
|
||||
addManifest(secondPallet, 5);
|
||||
|
||||
service.consume(rehash(original,node));
|
||||
|
||||
@@ -230,5 +243,15 @@ class LoadingCompletedConsumerTest {
|
||||
private EventEnvelope event(){ var item1=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-1","产品","Product",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("12"),new BigDecimal("1.2"),"wh-1","ONT8","FBA ONT8","FBA-1","SHIP-1","MARK-1"); var item2=new FirstLegLoadingPlanCompletedV1.Item("d-2","SKU-2","产品2","Product2",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("9"),new BigDecimal("0.8"),"wh-2","LAX9","FBA LAX9","FBA-2","SHIP-2","MARK-2"); var p=new FirstLegLoadingPlanCompletedV1("plan-1","LP1","book-1","MSCU6639870","CAB1","ow-1","洛杉矶海外仓",List.of(new FirstLegLoadingPlanCompletedV1.Order("in-1","root-1","ord-1","FL1","root-o1","RK1","wh-1","ONT8","FBA ONT8",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("1.2"),List.of(item1),"100","Customer 100","FIRST_LEG",List.of()),new FirstLegLoadingPlanCompletedV1.Order("in-2","root-2","ord-2","FL2","root-o2","RK2","wh-2","LAX9","FBA LAX9",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("0.8"),List.of(item2),"100","Customer 100","FIRST_LEG",List.of()))); JsonNode node=json.valueToTree(p); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-1",2,1L,"123e4567-e89b-12d3-a456-426614174001","FIRSTLEG_LOADING_COMPLETED:plan-1:100:1",null,node,hash); }
|
||||
private JsonNode v2Payload(EventEnvelope source){ com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); node.set("sourceContainerBookingId",node.remove("containerBookingId")); return node; }
|
||||
private EventEnvelope eventForPayload(FirstLegLoadingPlanCompletedV1 payload, long version){ JsonNode node=json.valueToTree(payload); for(JsonNode order:node.path("orders")) for(JsonNode pallet:order.path("pallets")) addManifest(pallet,order.path("packageCount").asInt()); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174099",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN",payload.loadingPlanId(),2,version,"123e4567-e89b-12d3-a456-426614174098","FIRSTLEG_LOADING_COMPLETED:"+payload.loadingPlanId()+":100:1",null,node,hash); }
|
||||
private void addManifest(JsonNode pallet,int packageCount){ var object=(com.fasterxml.jackson.databind.node.ObjectNode)pallet; object.put("manifestVersion",1).put("manifestPackageCount",packageCount).put("manifestFragmentCount",1).put("manifestHash","a".repeat(64)); }
|
||||
private void addManifest(JsonNode pallet,int packageCount){
|
||||
var object=(com.fasterxml.jackson.databind.node.ObjectNode)pallet;
|
||||
object.put("manifestVersion",1).put("manifestPackageCount",packageCount).put("manifestFragmentCount",1).put("manifestHash","a".repeat(64));
|
||||
// These fixtures explicitly represent uniform cartons; production must never infer this ratio.
|
||||
for(JsonNode sku:object.path("skuPackages")) {
|
||||
int count=sku.path("packageCount").asInt(), quantity=sku.path("productQuantity").asInt();
|
||||
assertTrue(count>0 && quantity%count==0);
|
||||
var quantities=((com.fasterxml.jackson.databind.node.ObjectNode)sku).putObject("cartonQuantities");
|
||||
for(JsonNode mark:sku.path("cartonMarks")) quantities.put(mark.asText(),quantity/count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
|
||||
- 服务发现名:`print-service`。
|
||||
- 内部地址:`POST /internal/print/v1/pdfs/inbound-marks`。
|
||||
- 一件代发 A4 托盘信息单:`POST /internal/print/v1/pdfs/dropship-pallet-a4-documents`;模板固定为 `DROPSHIP_PALLET_A4_V2`,采用黑白打印版,每托一页 A4,包含托盘 Code 128 一维码、目的仓、托盘序号、最多 10 行入仓码/客户昵称汇总和最多 34 行 SKU 汇总,不展示作业状态、重量或体积。
|
||||
- 一件代发 A4 托盘信息单:`POST /internal/print/v1/pdfs/dropship-pallet-a4-documents`;模板固定为 `DROPSHIP_PALLET_A4_V2`,采用黑白打印版,每托一页 A4,包含托盘 Code 128 一维码、目的仓、最多 10 行入仓码/客户昵称汇总和最多 34 行 SKU 汇总,不展示托盘序号、作业状态、重量或体积。
|
||||
- A4 海运单据内部地址:`POST /internal/print/v1/pdfs/freight-a4-documents`。
|
||||
- 海外仓排车 BOL 内部地址:`POST /internal/print/v1/pdfs/dispatch-bol-documents`;模板固定为 `DISPATCH_BILL_OF_LADING_A4_V2`,每个 document 对应一张 A4、包含 1-26 行明细。调用方首张按 18 行、续页按 26 行稳定分页;同页连续相同柜号和托盘号由 Print 纵向合并显示。
|
||||
- 本地直连:`http://127.0.0.1:9207`。
|
||||
|
||||
Binary file not shown.
@@ -15,7 +15,6 @@ public final class DropshipPalletA4PdfDtos {
|
||||
public record PageSpec(BigDecimal widthMm, BigDecimal heightMm) {}
|
||||
|
||||
public record Document(String palletNo, String destinationWarehouse,
|
||||
int palletSequence, int totalPallets,
|
||||
String barcodeContent, List<InboundRow> inboundRows,
|
||||
List<SkuRow> skuRows) {}
|
||||
|
||||
|
||||
@@ -35,9 +35,6 @@ public class DropshipPalletA4Renderer {
|
||||
stream.setLineWidth(PdfUnits.mmToPt(.25));
|
||||
border(stream, units);
|
||||
colorText(stream, units, font, "一件代发备货托", 14, 18, 22, INK);
|
||||
rightText(stream, units, font,
|
||||
"第 " + data.palletSequence() + " 托 / 共 " + data.totalPallets() + " 托",
|
||||
10, 192, 22, new Color(75, 75, 75));
|
||||
|
||||
barcodeRenderer.draw(stream, barcodeMatrix, PdfUnits.mmToPt(18), units.topDownMmToPdfY(51),
|
||||
PdfUnits.mmToPt(80), PdfUnits.mmToPt(22));
|
||||
@@ -157,12 +154,6 @@ public class DropshipPalletA4Renderer {
|
||||
colorText(s, u, f, fit(f, value, size, PdfUnits.mmToPt(widthMm)), size, x, y, color);
|
||||
}
|
||||
|
||||
private void rightText(PDPageContentStream s, PdfUnits u, PDFont f, String value, float size,
|
||||
double right, double y, Color color) throws IOException {
|
||||
double widthMm = f.getStringWidth(value) / 1000 * size / PdfUnits.mmToPt(1);
|
||||
colorText(s, u, f, value, size, right - widthMm, y, color);
|
||||
}
|
||||
|
||||
private void centeredCell(PDPageContentStream s, PdfUnits u, PDFont f, String value, float size,
|
||||
double x1, double x2, double y) throws IOException {
|
||||
String fitted = fit(f, value, size, PdfUnits.mmToPt(x2 - x1));
|
||||
|
||||
@@ -68,8 +68,6 @@ public class DropshipPalletA4PdfService {
|
||||
|
||||
private DropshipPalletA4PdfDtos.Document normalizeDocument(DropshipPalletA4PdfDtos.Document value, int index) {
|
||||
if (value == null) invalid("documents[" + index + "] is required");
|
||||
if (value.palletSequence() <= 0 || value.totalPallets() <= 0
|
||||
|| value.palletSequence() > value.totalPallets()) invalid("documents[" + index + "] pallet position is invalid");
|
||||
if (value.inboundRows() == null || value.inboundRows().isEmpty()
|
||||
|| value.inboundRows().size() > MAX_INBOUND_ROWS) invalid("documents[" + index + "].inboundRows exceeds the one-page A4 capacity");
|
||||
if (value.skuRows() == null || value.skuRows().size() > MAX_SKU_ROWS) invalid("documents[" + index + "].skuRows exceeds the one-page A4 capacity");
|
||||
@@ -86,8 +84,8 @@ public class DropshipPalletA4PdfService {
|
||||
row.itemQuantity(), text(row.unitsPerCarton(), 30)));
|
||||
}
|
||||
return new DropshipPalletA4PdfDtos.Document(text(value.palletNo(), 80),
|
||||
text(value.destinationWarehouse(), 200), value.palletSequence(), value.totalPallets(),
|
||||
barcode(value.barcodeContent()), List.copyOf(inboundRows), List.copyOf(rows));
|
||||
text(value.destinationWarehouse(), 200), barcode(value.barcodeContent()),
|
||||
List.copyOf(inboundRows), List.copyOf(rows));
|
||||
}
|
||||
|
||||
private String barcode(String value) {
|
||||
|
||||
@@ -290,12 +290,10 @@ components:
|
||||
items: { $ref: '#/components/schemas/DropshipPalletA4Document' }
|
||||
DropshipPalletA4Document:
|
||||
type: object
|
||||
required: [palletNo, destinationWarehouse, palletSequence, totalPallets, barcodeContent, inboundRows, skuRows]
|
||||
required: [palletNo, destinationWarehouse, barcodeContent, inboundRows, skuRows]
|
||||
properties:
|
||||
palletNo: { type: string, maxLength: 80 }
|
||||
destinationWarehouse: { type: string, maxLength: 200 }
|
||||
palletSequence: { type: integer, minimum: 1 }
|
||||
totalPallets: { type: integer, minimum: 1 }
|
||||
barcodeContent:
|
||||
type: string
|
||||
maxLength: 80
|
||||
|
||||
@@ -274,8 +274,6 @@ class InternalPdfControllerTest {
|
||||
"documents": [{
|
||||
"palletNo": "DP-260911-0034",
|
||||
"destinationWarehouse": "NJDF",
|
||||
"palletSequence": 1,
|
||||
"totalPallets": 3,
|
||||
"barcodeContent": "DP-260911-0034",
|
||||
"inboundRows": [{
|
||||
"inboundCode": "NYEXX2609110249",
|
||||
|
||||
+3
-4
@@ -27,7 +27,7 @@ class DropshipPalletA4PdfServiceGenerationTest {
|
||||
@Test
|
||||
void generatesOneA4PageWithPalletBarcodeAndSkuDetails() throws Exception {
|
||||
DropshipPalletA4PdfDtos.Document document = new DropshipPalletA4PdfDtos.Document(
|
||||
"DP-260911-0034", "NJDF", 1, 3, "DP-260911-0034",
|
||||
"DP-260911-0034", "NJDF", "DP-260911-0034",
|
||||
List.of(new DropshipPalletA4PdfDtos.InboundRow(
|
||||
"NYEXX2609110249", "小虫科技", 3, 90)),
|
||||
List.of(
|
||||
@@ -64,8 +64,7 @@ class DropshipPalletA4PdfServiceGenerationTest {
|
||||
"SKU-" + String.format("%03d", index), 2, index * 5, "5-10"));
|
||||
}
|
||||
DropshipPalletA4PdfDtos.Document document = new DropshipPalletA4PdfDtos.Document(
|
||||
"DP-260911-0034", "NJDF", 1, 1,
|
||||
"DP-260911-0034", inboundRows, rows);
|
||||
"DP-260911-0034", "NJDF", "DP-260911-0034", inboundRows, rows);
|
||||
var generated = service.generate(new DropshipPalletA4PdfDtos.Request(
|
||||
DropshipPalletA4PdfDtos.TEMPLATE,
|
||||
new DropshipPalletA4PdfDtos.PageSpec(new BigDecimal("210"), new BigDecimal("297")),
|
||||
@@ -85,7 +84,7 @@ class DropshipPalletA4PdfServiceGenerationTest {
|
||||
rows.add(new DropshipPalletA4PdfDtos.SkuRow("SKU-" + index, 1, 1, "1"));
|
||||
}
|
||||
DropshipPalletA4PdfDtos.Document document = new DropshipPalletA4PdfDtos.Document(
|
||||
"DP-260911-0034", "NJDF", 1, 1, "DP-260911-0034",
|
||||
"DP-260911-0034", "NJDF", "DP-260911-0034",
|
||||
List.of(new DropshipPalletA4PdfDtos.InboundRow("INBOUND", "客户", 35, 35)), rows);
|
||||
|
||||
assertThatThrownBy(() -> service.generate(new DropshipPalletA4PdfDtos.Request(
|
||||
|
||||
@@ -11,7 +11,7 @@ const OPTIONAL_MODULES = ['manual-order-splitting-fixture', 'manual-order-splitt
|
||||
'manual-pda-ready-fixture', 'manual-pda-ready-verify',
|
||||
'manual-pda-batch-fixture', 'manual-pda-batch-verify',
|
||||
'manual-loading-complete-fixture', 'manual-loading-complete-verify',
|
||||
'coload-groups']
|
||||
'coload-groups', 'dropship-pallet-matrix']
|
||||
const AVAILABLE_MODULES = new Set([...CONFIGURED_MODULES, ...OPTIONAL_MODULES])
|
||||
const A0_LOCATION_PAGE_SIZE = 200
|
||||
const A0_MAX_LOCATION_MUTATIONS = 8
|
||||
@@ -42,6 +42,7 @@ export function planA0LocationIsolation(locations, requiredSlots = 1,
|
||||
}
|
||||
|
||||
const MODULE_DEPENDENCIES = new Map([
|
||||
['dropship-pallet-matrix', ['merchant-qualification-fixture']],
|
||||
['suppliers', ['merchant-qualification-fixture']],
|
||||
['supplemental', ['merchant-qualification-fixture']],
|
||||
['orders', ['merchant-qualification-fixture']],
|
||||
|
||||
@@ -37,6 +37,10 @@ export async function run(context) {
|
||||
|
||||
## 可用上下文
|
||||
|
||||
`dropship-pallet-matrix.mjs` 验证下述四场景整托手工加入、整托移除、智能推荐、独立订舱与文件放舱、真实柜号关联、预装确认、PDA 整托拣货和五类证据上传;异步装柜完成必须等到任务 `COMPLETED` 并逐票回查 `LOADED`。智能装柜的托盘汇总与装柜详情逐票组成分别核对,不能把共托汇总行的首票编号当作全托归属。随后经正式报关、国内离仓及人工船节点接口推进至 `PICKUP_READY`,再按柜生成逐票海外入库计划,检查来源托盘完整绑定、共享托盘身份一致和同键重放。海外收货使用本轮创建并绑定接柜仓的 PDA 操作员,上传三类照片、签收、逐实物托盘收货、逐票箱数核平和完成入库,同键重放及同托新键扫码均不得重复入账。随后从本轮逐票库存候选创建一件代发专用出库,携带原始箱唛由 PDA 拣货、完成,核对终态、幂等和可用库存扣减;finally 停用本轮操作员。出库重放按业务身份和数据库毫秒时间精度核对。离线合同为 `dropship-pallet-matrix-contract.test.mjs`;脚本覆盖不等于真实 HTTP 已通过,须检查当轮报告和库存审计。
|
||||
|
||||
- `dropship-pallet-matrix` 通过总入口 `--modules dropship-pallet-matrix` 执行,自动依赖客户资质准备。通过正式 HTTP 创建五票订单,覆盖单票多托、单票单托、单票移箱合托、多票共享开放托盘;会创建并保留测试数据,不自动清理。报告 `createdResources.dropshipPalletMatrix` 保存逐票箱唛、托盘及各阶段证据,失败即停止并保留阻断;重跑创建新批次,不借用历史资源。`fullFlowVerified=false`,不能代替海外收货及一件代发专用出库的完整验收。
|
||||
|
||||
- 一件代发任务 `Task.pallets` 只列实际参与绑箱的托盘;未使用的预生成托盘通过 `/api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets?accountId=...` 读取,客户范围取本轮任务。脚本同时核对 `generatedPalletCount/palletSetupReady`,不因空的任务托盘列表新增资源。
|
||||
- `phase3-container-documents` 从本轮货柜详情读取渠道:海铁验证 DO 上传被明确拒绝且聚合中没有 DO,其他渠道继续验证 DO 上传、重放及零漂移;缺渠道不猜测适用性。
|
||||
- `phase4-overseas-pda-operations` 用三个独立逐箱任务分别验证正常换标、跳过和异常,异常不得重复使用前两箱。前一步失败时保留失败并停止该链,不继续使用空响应版本。
|
||||
@@ -116,8 +120,8 @@ export async function run(context) {
|
||||
| PDA 人工领取、逐箱扫码、原子批量提交与待复核核验 | `manual-pda-batch-fixture.mjs`、`manual-pda-batch-verify.mjs`(可选,不进入默认全量) | `manual-pda-batch-contract.test.mjs` |
|
||||
| 五类装柜证据、PDA 最终完成与 LOADED 终态核验 | `manual-loading-complete-fixture.mjs`、`manual-loading-complete-verify.mjs`(可选,不进入默认全量) | `manual-loading-complete-contract.test.mjs` |
|
||||
| 订舱共享主数据前置与订舱验收 | `ocean-booking-fixture.mjs`、`ocean-bookings.mjs`、`phase3-port-schedules.mjs`、`phase3-carrier-schedule-queries.mjs`(DRAFT 可编辑;RELEASED 冻结;船司查询只创建并写当前 marker 登记任务) | `ocean-booking-prototype-contract.test.mjs`、`ocean-booking-fixture-contract.test.mjs`、`phase3-carrier-schedule-queries.test.mjs` |
|
||||
| 订舱级拖车预约完整生命周期 | `truck-appointments.mjs`(自包含创建并放舱,不创建或关联柜次;每订舱最多一张非终态拖车单;覆盖候选、附件、费用、幂等及 `SUBMIT → DISPATCH → PICKUP → ARRIVE → LOAD → DEPART → COMPLETE`) | `truck-appointments-contract.test.mjs` |
|
||||
| 订舱与拖车服务中心跨链路 | `service-center-flow.mjs`(依赖 `ocean-bookings,truck-appointments`;复用本轮 `RELEASED` 订舱创建订舱级拖车,验证派车单、完整状态链、历史、审计和关联零漂移) | 由入口依赖合同和拖车直接合同共同覆盖;真实 HTTP 缺本轮订舱或承运商时 fail-closed |
|
||||
| 订舱级拖车预约完整生命周期 | `truck-appointments.mjs`(自包含创建并放舱,不创建或关联柜次;已有有效或已完成拖车单的订舱不可重复创建;覆盖候选、附件、人民币费用、幂等及 `SUBMIT → DISPATCH → PICKUP → ARRIVE → LOAD`,拒绝手工离仓/完成;自动完成与应付由真实国内离仓链路验证) | `truck-appointments-contract.test.mjs` |
|
||||
| 订舱与拖车服务中心跨链路 | `service-center-flow.mjs`(依赖 `ocean-bookings,truck-appointments`;复用本轮 `RELEASED` 订舱创建订舱级拖车,验证派车单、离仓前状态链、历史、审计和关联零漂移) | 由入口依赖合同和拖车直接合同共同覆盖;真实 HTTP 缺本轮订舱或承运商时 fail-closed |
|
||||
| Admin 补充能力与保护边界 | `supplemental.mjs`、`supplemental-boundaries.mjs` | `supplemental-boundaries-contract.test.mjs`;只按本轮资源验证补充能力、负向和保护边界 |
|
||||
| 装柜清单三备注 | `orders.mjs`、`phase3-loading.mjs`(客户备注、最新内部备注、仓库备注;仓库备注 PATCH/清空/精确重放/旧版本零写) | `loading-list-remarks-contract.test.mjs` |
|
||||
| 消息中心管理 | `notifications.mjs` | `notifications-contract.test.mjs` |
|
||||
@@ -276,3 +280,6 @@ node scripts/admin-test-env-acceptance.mjs --modules merchant-qualification-fixt
|
||||
```powershell
|
||||
node --test scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs
|
||||
```
|
||||
|
||||
|
||||
国内拖车自动完成与应付回归由 `phase3-outbound-dispatch.mjs` 在真实离仓后只读核验:订舱全部柜数、逐柜离仓、唯一 `AUTO_COMPLETE`、按拖车编号追溯柜次应付、金额守恒及 Finance 来源入池(有界等待 Outbox)。该模块的原有确认/撤销边界操作仍会写目标环境,执行须获得对应环境授权。`manual-state-ocean-trucking.mjs` 只构造可人工操作的状态,不伪造已完成拖车;自动终态由上述离仓链路覆盖。
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
|
||||
import { sameOutboundTask } from './dropship-pallet-matrix.mjs'
|
||||
|
||||
test('outbound replay preserves business identity and database millisecond timestamps', () => {
|
||||
const task = { id: '1', taskNo: 'PDA1', outboundNo: 'OD1', status: 'PENDING', pushedAt: '2026-09-14T20:24:45.920278604' }
|
||||
assert.equal(sameOutboundTask(task, { ...task, pushedAt: '2026-09-14T20:24:45.92' }), true)
|
||||
assert.equal(sameOutboundTask({ ...task, pushedAt: '2026-09-14T20:24:45.9999' },
|
||||
{ ...task, pushedAt: '2026-09-14T20:24:46' }), true)
|
||||
for (const key of ['id', 'taskNo', 'outboundNo', 'status', 'pushedAt']) {
|
||||
assert.equal(sameOutboundTask(task, { ...task, [key]: 'changed' }), false)
|
||||
}
|
||||
})
|
||||
|
||||
const source = readFileSync(new URL('./dropship-pallet-matrix.mjs', import.meta.url), 'utf8')
|
||||
|
||||
test('dedicated outbound uses exact source cartons and verifies completion and stock deduction', () => {
|
||||
assert.match(source, /await outboundOverseas\(group, picker.token\)/)
|
||||
assert.match(source, /one-click-dropship\?customerId=/)
|
||||
assert.match(source, /position.inboundCode === candidate.inboundCode/)
|
||||
assert.match(source, /cartonMarks: inputs\[index\].cartonMarks/)
|
||||
assert.match(source, /outbound.outbound\?\.status === 'OUTBOUNDED'/)
|
||||
assert.match(source, /!remaining.some/)
|
||||
})
|
||||
|
||||
test('dropship matrix has a real customer prerequisite without generic dispatch fixtures', () => {
|
||||
const modules = normalizeModuleOrder(['dropship-pallet-matrix'])
|
||||
assert.ok(modules.includes('merchant-qualification-fixture'))
|
||||
assert.ok(modules.includes('dropship-pallet-matrix'))
|
||||
assert.ok(!modules.includes('phase5-overseas-fulfillment'))
|
||||
})
|
||||
|
||||
test('all four physical pallet shapes are explicitly produced', () => {
|
||||
assert.match(source, /prepare\('SINGLE-MULTI', 2\)/)
|
||||
assert.match(source, /prepare\('SINGLE-SINGLE', 1\)/)
|
||||
assert.match(source, /prepare\('SINGLE-MERGE', 2, \{ mergeToFirstPallet: true \}\)/)
|
||||
assert.match(source, /prepare\('MULTI-MERGE-A', 1, \{ deferSeal: true \}\)/)
|
||||
assert.match(source, /prepare\('MULTI-MERGE-B', 1, \{ targetPalletNo \}\)/)
|
||||
assert.match(source, /await verify\(first, 1\)/)
|
||||
assert.match(source, /await verify\(second, 1\)/)
|
||||
})
|
||||
|
||||
test('allocation checks both physical summaries and uncollapsed per-inbound quantities', () => {
|
||||
assert.match(source, /first-leg\/loading-plans\/\$\{plan.id\}/)
|
||||
assert.match(source, /const actual = detail.items.filter/)
|
||||
assert.match(source, /plan.items\?\.length === pallets.length/)
|
||||
assert.match(source, /actual.reduce\([\s\S]*?record.expectedCartonMarks.length/)
|
||||
assert.match(source, /await conserve\('手工加入后'\)/)
|
||||
assert.match(source, /await conserve\('智能推荐后'\)/)
|
||||
assert.match(source, /fullFlowVerified: false/)
|
||||
})
|
||||
|
||||
test('each scenario reaches physical loading through released booking and asynchronous completion', () => {
|
||||
assert.match(source, /ocean-booking-link/)
|
||||
assert.match(source, /readiness\?\.canConfirm === true/)
|
||||
assert.match(source, /action: 'PICK_PALLET'/)
|
||||
assert.match(source, /inventorySnapshotToken: inventory.snapshotToken/)
|
||||
for (const type of ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']) {
|
||||
assert.ok(source.includes(`'${type}'`))
|
||||
}
|
||||
assert.match(source, /await waitForCompletion\(context/)
|
||||
assert.match(source, /finalTask\?\.taskStatus === 'COMPLETED'/)
|
||||
assert.match(source, /inbound.summary\?\.processStatus === 'LOADED'/)
|
||||
})
|
||||
|
||||
test('shipping uses the current group and requires real departure and pickup readiness', () => {
|
||||
assert.match(source, /for \(const group of groups\) await progressShipping\(group\)/)
|
||||
assert.match(source, /containerBookingId: String\(record.loading.cabinet.id\)/)
|
||||
assert.match(source, /\['customs', completeCustoms\]/)
|
||||
assert.match(source, /\['departure', confirmDomesticDeparture\]/)
|
||||
assert.match(source, /\['pickup-ready', runUntilPickupReady\]/)
|
||||
assert.match(source, /confirmedStatus === 'WAITING_FOR_LOADING'/)
|
||||
assert.match(source, /phase4CarrierPickupReady\?\.ready === true/)
|
||||
})
|
||||
|
||||
test('overseas plans preserve per-inbound ownership and shared physical identity', () => {
|
||||
assert.match(source, /inbound-plans\/convert-container/)
|
||||
assert.match(source, /matching.length === 1/)
|
||||
assert.match(source, /JSON.stringify\(expectedNos\) === JSON.stringify\(actualNos\)/)
|
||||
assert.match(source, /byPallet.get\(pallet.palletNo\) === identity/)
|
||||
assert.match(source, /JSON.stringify\(replay\) === JSON.stringify\(plans\)/)
|
||||
})
|
||||
|
||||
test('overseas receipt uses scoped PDA identity and scans each physical pallet once', () => {
|
||||
assert.match(source, /await createPicker\(context/)
|
||||
assert.match(source, /await disablePicker\(context/)
|
||||
assert.match(source, /const sourceNos = \[\.\.\.new Set\(group.flatMap/)
|
||||
assert.match(source, /current\/receipts\/pallet/)
|
||||
assert.match(source, /duplicate.result === 'ALREADY_RECEIVED'/)
|
||||
assert.match(source, /Number\(inbound.receivedPackages\) === item.expectedCartonMarks.length/)
|
||||
assert.match(source, /completed.status === 'COMPLETED'/)
|
||||
assert.match(source, /fullFlowVerified: false/)
|
||||
})
|
||||
@@ -0,0 +1,490 @@
|
||||
import { run as createOrder } from './orders.mjs'
|
||||
import { run as measure, completeDropshipPalletization } from './inbound-measurement.mjs'
|
||||
import { channelGroup } from './smart-loading-channel-group.mjs'
|
||||
import { ensureInitialCalculated } from './inbound-fees.mjs'
|
||||
import { oceanBookingDraft, resolveOceanBookingAgent } from './ocean-booking-fixture.mjs'
|
||||
import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs'
|
||||
import { iso6346ContainerNumber } from './iso6346-container-number.mjs'
|
||||
import { uploadEvidence, markerSuffix } from './phase3-loading.mjs'
|
||||
import { waitForCompletion } from './phase3-loading-finalize.mjs'
|
||||
import { run as completeCustoms } from './phase3-customs.mjs'
|
||||
import { run as verifyCarrierQuery } from './phase3-carrier-schedule-queries.mjs'
|
||||
import { runUntilPickupReady } from './phase4-carrier-nodes.mjs'
|
||||
import { resolvePickerAccess, createPicker, disablePicker, uploadPhotos } from './phase4-overseas-pda.mjs'
|
||||
|
||||
const MODULE = 'dropship-pallet-matrix'
|
||||
const BASE = '/api/platform/v1/first-leg/pda-dropship'
|
||||
const OPTIONS = { scenario: MODULE, deploymentBlocker: true }
|
||||
|
||||
export function sameOutboundTask(left, right) {
|
||||
const databaseTime = (value) => {
|
||||
const match = typeof value === 'string' && value.match(/^(.*T\d{2}:\d{2}:\d{2})(?:\.(\d+))?$/)
|
||||
return match ? Date.parse(match[1]) + Math.round(Number(`0.${match[2] || '0'}`) * 1000) : NaN
|
||||
}
|
||||
return ['id', 'taskNo', 'outboundNo', 'status'].every((key) => left?.[key] === right?.[key])
|
||||
&& ['pushedAt', 'completedAt'].every((key) => left?.[key] == null && right?.[key] == null
|
||||
|| Number.isFinite(databaseTime(left?.[key])) && databaseTime(left[key]) === databaseTime(right?.[key]))
|
||||
}
|
||||
|
||||
// Domestic producer stage: these retained loaded fixtures must subsequently be verified
|
||||
// through overseas receipt and dedicated dropship outbound, not generic dispatch.
|
||||
export async function run(context) {
|
||||
const marker = context.marker
|
||||
const savedOptions = context.options.prepareDropshipManualCount
|
||||
const savedContext = { ...context.context }
|
||||
const records = []
|
||||
context.createdResources.dropshipPalletMatrix = { marker, stage: 'DOMESTIC_PALLETIZATION', records,
|
||||
fullFlowVerified: false }
|
||||
const check = (name, response, passed, details = '') => {
|
||||
context.assert(MODULE, name, response, passed, details, OPTIONS)
|
||||
if (!passed) throw new Error(name)
|
||||
}
|
||||
async function prepare(label, palletCount, mode = {}) {
|
||||
context.marker = `${marker}-${label}`
|
||||
for (const key of ['orders', 'inboundMeasurement', 'ocrInbound', 'testInbound', 'dropshipManualCount']) {
|
||||
delete context.createdResources[key]
|
||||
}
|
||||
for (const key of ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']) delete context.context[key]
|
||||
context.context.orderDestinationType = 'OVERSEAS'
|
||||
context.context.orderFulfillmentType = 'ONE_CLICK_DROPSHIP'
|
||||
context.context.orderProfile = { taxMode: 'BUY_DECLARATION', pickupMethod: 'SELF_EXPRESS', containerMode: 'LCL' }
|
||||
context.context.waitSplitVariant = { skuCount: 1, packageCountPerSku: 4, packageCounts: [4], palletCount }
|
||||
context.options.prepareDropshipManualCount = true
|
||||
const start = context.steps.length
|
||||
await context.executeModule(`${MODULE}-${label}-order`, () => createOrder(context))
|
||||
await context.executeModule(`${MODULE}-${label}-measurement`, () => measure(context))
|
||||
check(`${label}本轮订单和测量前置全部通过`, null,
|
||||
!context.steps.slice(start).some((step) => !step.passed))
|
||||
const source = context.createdResources.dropshipManualCount
|
||||
check(`${label}存在本轮未清点任务`, null, Boolean(source?.taskId))
|
||||
const task = await completeDropshipPalletization(context, {
|
||||
...source, measurement: { actualPackageCount: source.totalCartons, expectedPalletCount: palletCount }, ...mode,
|
||||
})
|
||||
check(`${label}托盘作业断言全部通过`, null,
|
||||
Boolean(task) && !context.steps.slice(start).some((step) => !step.passed))
|
||||
const record = { label, order: { ...context.createdResources.orders },
|
||||
measurement: { ...context.createdResources.inboundMeasurement }, source: { ...source }, task,
|
||||
expectedCartonMarks: Array.from({ length: source.totalCartons }, (_, index) =>
|
||||
`${source.inboundCode}#${source.totalCartons}-${index + 1}`) }
|
||||
records.push(record)
|
||||
return record
|
||||
}
|
||||
async function verify(record, expectedPallets) {
|
||||
const { taskId, warehouseId, inboundId } = record.source
|
||||
const response = await context.request('GET', `${BASE}/tasks/${taskId}?warehouseId=${warehouseId}`)
|
||||
const task = context.apiSuccess(MODULE, `${record.label}回查权威任务`, response, '', OPTIONS)
|
||||
const cartons = (task?.pallets || []).flatMap((pallet) => pallet.cartons || [])
|
||||
.filter((carton) => String(carton.inboundId) === String(inboundId))
|
||||
check(`${record.label}逐票箱唛归属和数量守恒`, response,
|
||||
task?.taskStatus === 'COMPLETED' && task.pallets?.length === expectedPallets
|
||||
&& task.pallets.every((pallet) => pallet.status === 'SEALED')
|
||||
&& cartons.length === record.expectedCartonMarks.length
|
||||
&& JSON.stringify(cartons.map((carton) => carton.boxCode).sort())
|
||||
=== JSON.stringify([...record.expectedCartonMarks].sort()))
|
||||
record.task = task
|
||||
const inboundResponse = await context.request('GET',
|
||||
`/api/platform/inbounds/${inboundId}?tenantId=${task.tenantId}`)
|
||||
const inbound = context.apiSuccess(MODULE, `${record.label}封托后回查入仓状态`, inboundResponse, '', OPTIONS)
|
||||
check(`${record.label}实际进入待分柜状态`, inboundResponse,
|
||||
inbound?.summary?.processStatus === 'WAIT_SPLIT')
|
||||
record.summary = inbound.summary
|
||||
record.domesticVerified = true
|
||||
}
|
||||
async function allocate(group) {
|
||||
const label = group.map((item) => item.label).join('+')
|
||||
context.marker = `${marker}-${group[0].label}-LOAD`
|
||||
const smart = '/api/platform/v1/first-leg/smart-loading'
|
||||
async function command(name, method, path, body, headers = {}) {
|
||||
const response = await context.request(method, path, body, undefined, headers)
|
||||
const value = context.apiSuccess(MODULE, `${label}${name}`, response, '', OPTIONS)
|
||||
check(`${label}${name}成功`, response, response?.body?.code === '200' && Boolean(value))
|
||||
return value
|
||||
}
|
||||
const pallets = [...new Map(group.flatMap((item) => item.task.pallets)
|
||||
.map((pallet) => [String(pallet.palletId), pallet])).values()]
|
||||
for (const record of group) {
|
||||
const fee = await ensureInitialCalculated(context, { ...record.measurement,
|
||||
tenantId: String(record.task.tenantId), inboundId: record.source.inboundId,
|
||||
finalStatus: 'APPROVED', processStatus: record.summary.processStatus }, OPTIONS)
|
||||
check(`${record.label}初始费用已计算`, null, Boolean(fee))
|
||||
}
|
||||
const summary = group[0].summary
|
||||
const cabinet = await command('创建本场景模拟柜', 'POST', '/api/platform/v1/containers', {
|
||||
channelGroupCode: channelGroup(summary.channelCode), warehouseId: String(summary.warehouseId),
|
||||
containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000,
|
||||
})
|
||||
let plan = await command('读取自动生成装柜计划', 'GET', `${smart}/plans/by-booking/${cabinet.id}`)
|
||||
const path = `${smart}/plans/${plan.id}`
|
||||
async function conserve(name) {
|
||||
// Smart-loading items are physical pallet summaries. The loading detail is the
|
||||
// authoritative per-inbound composition; shared pallets must satisfy both views.
|
||||
const detail = await command(`${name}逐票装柜组成`, 'GET',
|
||||
`/api/platform/v1/first-leg/loading-plans/${plan.id}?warehouseId=${summary.warehouseId}`)
|
||||
const expectedIds = new Set(group.map((record) => String(record.source.inboundId)))
|
||||
check(`${label}${name}没有混入其他订单`, null,
|
||||
detail.items?.every((item) => expectedIds.has(String(item.inboundId))))
|
||||
check(`${label}${name}整托汇总不重复计算箱数`, null,
|
||||
plan.items?.length === pallets.length
|
||||
&& plan.items.reduce((sum, item) => sum + Number(item.packageCount), 0)
|
||||
=== group.reduce((sum, record) => sum + record.expectedCartonMarks.length, 0))
|
||||
for (const record of group) {
|
||||
const actual = detail.items.filter((item) => String(item.inboundId) === record.source.inboundId)
|
||||
check(`${record.label}${name}分配箱数与托盘身份正确`, null,
|
||||
actual.reduce((sum, item) => sum + Number(item.packageCount), 0) === record.expectedCartonMarks.length
|
||||
&& actual.every((item) => record.task.pallets.some((pallet) => String(pallet.palletId) === String(item.palletId))))
|
||||
}
|
||||
}
|
||||
for (const pallet of pallets) {
|
||||
plan = await command('手工整托加入', 'POST', `${path}/pallet-assignments`, {
|
||||
palletId: String(pallet.palletId), expectedVersion: Number(plan.version),
|
||||
operationId: `${context.marker}-add-${pallet.palletId}`,
|
||||
})
|
||||
}
|
||||
await conserve('手工加入后')
|
||||
for (const pallet of pallets) {
|
||||
plan = await command('按物理托盘完整移除', 'DELETE', `${path}/pallet-assignments/${pallet.palletId}`, {
|
||||
expectedPlanVersion: Number(plan.version), operationId: `${context.marker}-remove-${pallet.palletId}`,
|
||||
})
|
||||
}
|
||||
check(`${label}整托移除不残留其他票明细`, null, plan.items?.length === 0)
|
||||
plan = await command('智能推荐整托加入', 'POST', `${path}/recommend`, {
|
||||
expectedVersion: Number(plan.version), operationId: `${context.marker}-recommend`,
|
||||
customerId: String(group[0].task.tenantId), inboundCode: group[0].source.inboundCode,
|
||||
})
|
||||
await conserve('智能推荐后')
|
||||
for (const record of group) record.loading = { cabinet, plan, allocationVerified: true }
|
||||
const carrier = await ensureMatsonCarrierPartner(context, MODULE, OPTIONS)
|
||||
const agent = await resolveOceanBookingAgent(context, MODULE, 'MATSON', OPTIONS)
|
||||
check(`${label}真实船司和订舱代理齐全`, null, Boolean(carrier?.id && agent?.id))
|
||||
const suffix = markerSuffix(context.marker)
|
||||
const draft = oceanBookingDraft(context, suffix, {
|
||||
customerId: String(group[0].task.tenantId), customerName: summary.customerName,
|
||||
warehouseId: String(summary.warehouseId), channelCode: summary.channelCode,
|
||||
}, { carrierCode: 'MATSON', shippingCompanyId: String(carrier.id), shippingCompanyName: carrier.name,
|
||||
bookingAgentId: String(agent.id), bookingAgentName: agent.name })
|
||||
const booking = await command('创建独立订舱', 'POST', '/api/platform/v1/ocean-bookings',
|
||||
{ expectedVersion: 0, data: draft }, { 'Idempotency-Key': `${context.marker}-booking` })
|
||||
const bookingId = String(booking.oceanBooking.id)
|
||||
const form = new FormData()
|
||||
form.append('expectedVersion', String(booking.oceanBooking.version))
|
||||
form.append('documentType', 'BOOKING_CONFIRMATION')
|
||||
form.append('file', new Blob([`HTTP acceptance ${context.marker}`], { type: 'text/plain' }), 'booking-confirmation.txt')
|
||||
const docResponse = await context.requestMultipart('POST',
|
||||
`/api/platform/v1/ocean-bookings/${bookingId}/documents/upload`, form, context.token,
|
||||
{ 'Idempotency-Key': `${context.marker}-booking-document` })
|
||||
const doc = context.apiSuccess(MODULE, `${label}上传订舱确认文件`, docResponse, '', OPTIONS)
|
||||
check(`${label}订舱文件已持久化`, docResponse, Boolean(doc?.document?.id))
|
||||
const released = await command('放舱', 'POST', `/api/platform/v1/ocean-bookings/${bookingId}/release`,
|
||||
{ expectedVersion: Number(doc.bookingVersion), reason: context.marker },
|
||||
{ 'Idempotency-Key': `${context.marker}-release` })
|
||||
const latest = await command('回查模拟柜版本', 'GET', `/api/platform/v1/containers/domestic/${cabinet.id}`)
|
||||
const containerNo = iso6346ContainerNumber(context.marker)
|
||||
const linked = await command('关联订舱并补真实柜号', 'POST', `/api/platform/v1/containers/${cabinet.id}/ocean-booking-link`, {
|
||||
oceanBookingId: bookingId, requirementId: String(released.containerRequirements[0].id),
|
||||
containerNo, expectedVersion: Number(latest.container.version),
|
||||
})
|
||||
const loadingDetail = await command('读取整份预装确认门禁', 'GET',
|
||||
`/api/platform/v1/first-leg/loading-plans/${plan.id}?warehouseId=${summary.warehouseId}`)
|
||||
check(`${label}全部成员可以确认预装`, null, loadingDetail.summary?.readiness?.canConfirm === true)
|
||||
const confirmed = await command('确认整份预装', 'POST', `/api/platform/v1/first-leg/loading-plans/${plan.id}/confirm`,
|
||||
{ expectedVersion: Number(loadingDetail.summary.version) }, { 'Idempotency-Key': `${context.marker}-confirm` })
|
||||
const taskId = String(confirmed.readiness.pdaTaskId)
|
||||
const pda = `/api/platform/v1/first-leg/pda-loading/tasks/${taskId}`
|
||||
const task = await command('读取装柜PDA任务', 'GET', `${pda}?warehouseId=${summary.warehouseId}`)
|
||||
const claimed = await command('领取装柜任务', 'POST', `${pda}/claim`, {
|
||||
expectedAssignmentVersion: Number(task.assignment?.version || 0), operationId: `${context.marker}-claim`,
|
||||
})
|
||||
const inventory = await command('读取整托库存快照', 'GET', `${pda}/inventory-snapshot?warehouseId=${summary.warehouseId}`)
|
||||
const batch = {
|
||||
warehouseId: String(summary.warehouseId), loadingPlanId: String(plan.id), containerBookingId: String(cabinet.id),
|
||||
expectedVersion: Number(claimed.version), inventorySnapshotToken: inventory.snapshotToken,
|
||||
items: claimed.items.map((item) => ({ itemId: String(item.id), version: Number(item.version),
|
||||
action: 'PICK_PALLET', palletNo: item.palletNo })),
|
||||
}
|
||||
check(`${label}PDA全部任务均为封托单元`, null,
|
||||
claimed.items.length > 0 && claimed.items.every((item) => item.unitType === 'SEALED_PALLET'))
|
||||
const picked = await command('整托拣货', 'POST', `${pda}/batch`, batch,
|
||||
{ 'Idempotency-Key': `${context.marker}-pick` })
|
||||
check(`${label}整托拣货等待最终确认`, null, picked.workflowStatus === 'AWAITING_REVIEW')
|
||||
for (const evidenceType of ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']) {
|
||||
const response = await uploadEvidence(context, taskId, String(summary.warehouseId), evidenceType, 0,
|
||||
evidenceType, { sealNo: evidenceType === 'SEAL' ? `SEAL-${suffix}` : null })
|
||||
const evidence = context.apiSuccess(MODULE, `${label}上传${evidenceType}装柜证据`, response, '', OPTIONS)
|
||||
check(`${label}${evidenceType}已绑定本柜`, response,
|
||||
String(evidence?.containerBookingId) === String(cabinet.id)
|
||||
&& evidence?.evidences?.some((item) => item.evidenceType === evidenceType && item.status !== 'VOID'))
|
||||
}
|
||||
const current = await command('读取最终任务版本', 'GET', `${pda}?warehouseId=${summary.warehouseId}`)
|
||||
const completed = await command('最终完成装柜', 'POST', `${pda}/complete`, {
|
||||
expectedTaskVersion: Number(current.version), operationId: `${context.marker}-complete`, remark: context.marker,
|
||||
}, { 'Idempotency-Key': `${context.marker}-complete` })
|
||||
const terminal = await waitForCompletion(context, `${pda}?warehouseId=${summary.warehouseId}`, `${pda}/operations`)
|
||||
const finalTask = terminal.task
|
||||
check(`${label}任务真实完成`, terminal.taskResponse, finalTask?.taskStatus === 'COMPLETED',
|
||||
`status=${finalTask?.taskStatus}, attempts=${terminal.attempts}`)
|
||||
for (const record of group) {
|
||||
const inbound = await command(`${record.label}回查入仓单实装状态`, 'GET',
|
||||
`/api/platform/inbounds/${record.source.inboundId}?tenantId=${record.task.tenantId}`)
|
||||
check(`${record.label}装柜完成回写`, null, inbound.summary?.processStatus === 'LOADED')
|
||||
record.loading = { cabinet: linked, plan, task: finalTask, completed, booking: released,
|
||||
allocationVerified: true, loadingVerified: true, containerNo }
|
||||
}
|
||||
}
|
||||
async function progressShipping(group) {
|
||||
const record = group[0]
|
||||
context.marker = `${marker}-${record.label}-SHIPPING`
|
||||
context.createdResources.orders = { ...record.order, tenantId: String(record.task.tenantId),
|
||||
inboundId: record.source.inboundId, warehouseId: String(record.summary.warehouseId) }
|
||||
context.createdResources.phase3Loading = {
|
||||
marker: context.marker, tenantId: String(record.task.tenantId), orderId: record.order.orderId,
|
||||
inboundId: record.source.inboundId, warehouseId: String(record.summary.warehouseId),
|
||||
containerBookingId: String(record.loading.cabinet.id), loadingPlanId: String(record.loading.plan.id),
|
||||
oceanBookingId: String(record.loading.booking.oceanBooking.id),
|
||||
containerNo: record.loading.containerNo, finalStatus: 'LOADED',
|
||||
}
|
||||
for (const key of ['phase3Customs', 'phase3OutboundDispatch', 'phase3CarrierScheduleQuery', 'phase4CarrierPickupReady']) {
|
||||
delete context.createdResources[key]
|
||||
}
|
||||
async function confirmDomesticDeparture() {
|
||||
const bookingId = String(record.loading.cabinet.id)
|
||||
const response = await context.request('GET', `/api/platform/v1/first-leg/outbound-dispatches/${bookingId}`)
|
||||
const detail = context.apiSuccess(MODULE, `${record.label}回查报关后的实际离仓事实`, response, '', OPTIONS)
|
||||
const aggregate = detail?.aggregate
|
||||
check(`${record.label}报关完成自动离仓且全柜逐票已装`, response,
|
||||
String(aggregate?.containerBookingId) === bookingId
|
||||
&& aggregate?.dispatchStatus === 'WAITING_FOR_LOADING'
|
||||
&& aggregate.inbounds?.length === group.length
|
||||
&& group.every((item) => aggregate.inbounds.some((inbound) =>
|
||||
String(inbound.inboundId) === String(item.source.inboundId) && inbound.inboundStatus === 'LOADED'))
|
||||
&& detail.timeline?.some((item) => item.module === 'OUTBOUND_DISPATCH' && item.action === 'CONFIRM_DISPATCH'))
|
||||
context.createdResources.phase3OutboundDispatch = { marker: context.marker, containerBookingId: bookingId,
|
||||
tenantId: String(record.task.tenantId), confirmedStatus: aggregate.dispatchStatus }
|
||||
}
|
||||
for (const [name, action] of [['customs', completeCustoms], ['departure', confirmDomesticDeparture],
|
||||
['carrier-query', verifyCarrierQuery], ['pickup-ready', runUntilPickupReady]]) {
|
||||
const start = context.steps.length
|
||||
await context.executeModule(`${MODULE}-${record.label}-${name}`, () => action(context))
|
||||
check(`${record.label}${name}阶段全部通过`, null,
|
||||
!context.steps.slice(start).some((step) => !step.passed))
|
||||
}
|
||||
check(`${record.label}已完成国内离仓并达到可提柜节点`, null,
|
||||
context.createdResources.phase3OutboundDispatch?.confirmedStatus === 'WAITING_FOR_LOADING'
|
||||
&& context.createdResources.phase4CarrierPickupReady?.ready === true)
|
||||
for (const item of group) item.shipping = {
|
||||
departure: context.createdResources.phase3OutboundDispatch,
|
||||
carrier: context.createdResources.phase3CarrierScheduleQuery,
|
||||
pickupReady: context.createdResources.phase4CarrierPickupReady, shippingVerified: true,
|
||||
}
|
||||
}
|
||||
async function convertOverseas(group) {
|
||||
const label = group[0].label
|
||||
const loading = group[0].loading
|
||||
const bookingId = String(loading.cabinet.id)
|
||||
const base = '/api/platform/v1/last-leg'
|
||||
async function call(name, method, path, body, headers) {
|
||||
const response = await context.requestOverseas(method, path, body, headers)
|
||||
const data = context.apiSuccess(MODULE, `${label}${name}`, response, '', OPTIONS)
|
||||
check(`${label}${name}成功`, response, response?.body?.code === '200' && data != null)
|
||||
return data
|
||||
}
|
||||
let summary
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
const response = await context.requestOverseas('GET', `${base}/orders/container-summaries/${bookingId}`)
|
||||
summary = response?.body?.code === '200' ? response.body.data : null
|
||||
if (summary?.orders?.length === group.length) break
|
||||
if (attempt < 59) await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
}
|
||||
check(`${label}跨区事件逐票建单完整`, null,
|
||||
summary?.orders?.length === group.length && group.every((record) =>
|
||||
summary.orders.some((order) => order.inboundCode === record.source.inboundCode
|
||||
&& String(order.customerId) === String(record.task.tenantId))))
|
||||
const preview = await call('入库计划预览', 'GET', `${base}/inbound-plans/container-preview?sourceContainerBookingId=${bookingId}`)
|
||||
check(`${label}全柜入库计划就绪`, null, preview.ready === true && Number(preview.totalOrderCount) === group.length)
|
||||
const body = { sourceContainerBookingId: bookingId, snapshotVersion: Number(preview.snapshotVersion),
|
||||
snapshotHash: preview.snapshotHash }
|
||||
const headers = { 'Idempotency-Key': `${marker}-${label}-convert` }
|
||||
const plans = await call('按柜生成真实入库计划', 'POST', `${base}/inbound-plans/convert-container`, body, headers)
|
||||
check(`${label}每票独立计划`, null, Array.isArray(plans) && plans.length === group.length)
|
||||
for (const record of group) {
|
||||
const matching = plans.filter((plan) => plan.lines?.some((line) => line.inboundCode === record.source.inboundCode))
|
||||
check(`${record.label}计划精确关联来源入仓码`, null, matching.length === 1)
|
||||
const target = matching[0]
|
||||
const expectedNos = record.task.pallets.map((pallet) => pallet.palletNo).sort()
|
||||
const actualNos = (target.pallets || []).map((pallet) => pallet.palletNo).sort()
|
||||
check(`${record.label}来源托盘完整绑定海外真实资源`, null,
|
||||
JSON.stringify(expectedNos) === JSON.stringify(actualNos)
|
||||
&& target.pallets.every((pallet) => pallet.warehousePalletId && pallet.warehouseLocationId))
|
||||
record.overseas = { plan: target, warehouseId: String(preview.overseasWarehouseId),
|
||||
summary: summary.orders.find((order) => order.inboundCode === record.source.inboundCode),
|
||||
planVerified: true }
|
||||
}
|
||||
const byPallet = new Map()
|
||||
for (const plan of plans) for (const pallet of plan.pallets) {
|
||||
const identity = `${pallet.warehousePalletId}:${pallet.warehouseLocationId}`
|
||||
check(`${label}同一来源托盘只绑定一个海外实体`, null,
|
||||
!byPallet.has(pallet.palletNo) || byPallet.get(pallet.palletNo) === identity)
|
||||
byPallet.set(pallet.palletNo, identity)
|
||||
}
|
||||
const replay = await call('计划转换同键重放', 'POST', `${base}/inbound-plans/convert-container`, body, headers)
|
||||
check(`${label}计划重放不新增或改变实物资源`, null, JSON.stringify(replay) === JSON.stringify(plans))
|
||||
}
|
||||
async function receiveOverseas(group) {
|
||||
const record = group[0]
|
||||
const label = record.label
|
||||
// The PDA account helper keeps the marker tail; retain the run identity there.
|
||||
context.marker = `${label}-RECEIVE-${marker}`
|
||||
const base = '/api/pda/v1/last-leg/receiving-containers'
|
||||
const access = await resolvePickerAccess(context)
|
||||
check(`${label}海外PDA角色可用`, null, Boolean(access))
|
||||
const picker = await createPicker(context, records.indexOf(record) + 1, record.overseas.warehouseId, access)
|
||||
try {
|
||||
check(`${label}海外PDA账号和安全投影可用`, null, Boolean(picker?.token))
|
||||
async function call(name, method, path, body) {
|
||||
const response = await context.requestOverseas(method, path, body, {}, picker.token)
|
||||
const data = context.apiSuccess(MODULE, `${label}${name}`, response, '', OPTIONS)
|
||||
check(`${label}${name}成功`, response, response?.body?.code === '200' && data != null)
|
||||
return data
|
||||
}
|
||||
let candidate
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
const response = await context.requestOverseas('GET', `${base}/candidates`, undefined, {}, picker.token)
|
||||
candidate = response?.body?.data?.find?.((item) => item.containerNo === record.loading.containerNo)
|
||||
if (candidate?.operable && candidate?.allPlansReady) break
|
||||
if (attempt < 59) await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
}
|
||||
check(`${label}本柜船节点和入库计划已跨区收敛`, null,
|
||||
candidate?.operable === true && candidate?.allPlansReady === true)
|
||||
await call('准备拆柜会话', 'POST', `${base}/${record.loading.containerNo}/prepare`,
|
||||
{ idempotencyKey: `${context.marker}-prepare` })
|
||||
const photos = await uploadPhotos(context, picker.token)
|
||||
check(`${label}三类卸柜照片齐全`, null, photos?.length === 3)
|
||||
const signed = await call('照片绑定并签收', 'POST', `${base}/current/sign`,
|
||||
{ photos, idempotencyKey: `${context.marker}-sign` })
|
||||
check(`${label}签收成功`, null, signed.status === 'SIGNED')
|
||||
const sourceNos = [...new Set(group.flatMap((item) => item.task.pallets.map((pallet) => pallet.palletNo)))]
|
||||
let accepted = 0
|
||||
for (const [index, sourcePalletNo] of sourceNos.entries()) {
|
||||
const body = { sourcePalletNo, idempotencyKey: `${context.marker}-pallet-${index}` }
|
||||
const received = await call('按来源实物托盘整托收货', 'POST', `${base}/current/receipts/pallet`, body)
|
||||
accepted += Number(received.acceptedPackages)
|
||||
const replay = await call('整托收货同键重放', 'POST', `${base}/current/receipts/pallet`, body)
|
||||
check(`${label}实收重放结果一致`, null, JSON.stringify(replay) === JSON.stringify(received))
|
||||
const duplicate = await call('同托新键重复扫描', 'POST', `${base}/current/receipts/pallet`,
|
||||
{ sourcePalletNo, idempotencyKey: `${context.marker}-duplicate-${index}` })
|
||||
check(`${label}同托重复扫描不重复收货`, null,
|
||||
duplicate.result === 'ALREADY_RECEIVED' && Number(duplicate.acceptedPackages) === 0)
|
||||
}
|
||||
check(`${label}整柜实收总箱数守恒`, null,
|
||||
accepted === group.reduce((sum, item) => sum + item.expectedCartonMarks.length, 0))
|
||||
const current = await call('逐票回查实收', 'GET', `${base}/current`)
|
||||
check(`${label}逐票实收核平且没有遗漏`, null, Number(current.pendingPackages) === 0
|
||||
&& current.inbounds?.length === group.length
|
||||
&& group.every((item) => current.inbounds.some((inbound) => inbound.inboundCode === item.source.inboundCode
|
||||
&& Number(inbound.receivedPackages) === item.expectedCartonMarks.length)))
|
||||
const body = { idempotencyKey: `${context.marker}-complete` }
|
||||
const completed = await call('完成海外拆柜入库', 'POST', `${base}/current/complete`, body)
|
||||
const replay = await call('完成入库同键重放', 'POST', `${base}/current/complete`, body)
|
||||
check(`${label}海外入库完成且重放稳定`, null, completed.status === 'COMPLETED'
|
||||
&& JSON.stringify(replay) === JSON.stringify(completed))
|
||||
for (const item of group) item.overseas.receiving = { completed, current, receivingVerified: true }
|
||||
await outboundOverseas(group, picker.token)
|
||||
} finally {
|
||||
await disablePicker(context, picker, records.indexOf(record) + 1)
|
||||
}
|
||||
}
|
||||
async function outboundOverseas(group, token) {
|
||||
const label = group[0].label
|
||||
const base = '/api/platform/v1/last-leg/outbound-orders'
|
||||
const customerId = String(group[0].task.tenantId)
|
||||
const warehouseId = group[0].overseas.warehouseId
|
||||
async function call(name, method, path, body, pda = false) {
|
||||
const response = await context.requestOverseas(method, path, body, {}, pda ? token : context.overseasToken)
|
||||
const value = context.apiSuccess(MODULE, `${label}${name}`, response, '', OPTIONS)
|
||||
check(`${label}${name}成功`, response, response?.body?.code === '200' && value != null)
|
||||
return value
|
||||
}
|
||||
const candidatePath = `${base}/one-click-dropship/candidates?customerId=${customerId}&warehouseId=${warehouseId}`
|
||||
const all = await call('读取专用出库可用库存', 'GET', candidatePath)
|
||||
const selected = all.filter((candidate) => group.some((record) => record.source.inboundCode === candidate.inboundCode))
|
||||
const inputs = selected.map((candidate) => {
|
||||
const record = group.find((item) => item.source.inboundCode === candidate.inboundCode)
|
||||
const pallet = record.task.pallets.find((item) => item.palletNo === candidate.palletNo)
|
||||
const cartonMarks = (pallet?.cartons || []).filter((carton) =>
|
||||
String(carton.inboundId) === String(record.source.inboundId)).map((carton) => carton.boxCode)
|
||||
check(`${label}专用出库候选逐票逐托箱数准确`, null,
|
||||
cartonMarks.length > 0 && Number(candidate.availablePackages) === cartonMarks.length
|
||||
&& String(candidate.customerId) === customerId && String(candidate.overseasWarehouseId) === warehouseId)
|
||||
return { inventoryPositionId: candidate.inventoryPositionId, packages: cartonMarks.length,
|
||||
sku: candidate.skuCode, cartonMarks }
|
||||
})
|
||||
check(`${label}专用出库候选覆盖全部票和箱`, null, group.every((record) =>
|
||||
selected.filter((item) => item.inboundCode === record.source.inboundCode)
|
||||
.reduce((sum, item) => sum + Number(item.availablePackages), 0) === record.expectedCartonMarks.length))
|
||||
const body = { recipient: `HTTP acceptance ${context.marker}`, items: inputs,
|
||||
remark: context.marker, idempotencyKey: `${context.marker}-outbound-create` }
|
||||
const task = await call('创建一件代发专用出库', 'POST', `${base}/one-click-dropship?customerId=${customerId}`, body)
|
||||
for (const record of group) record.outbound = { task, selected, completed: false }
|
||||
const replay = await call('专用出库创建同键重放', 'POST', `${base}/one-click-dropship?customerId=${customerId}`, body)
|
||||
check(`${label}专用出库重放不重复建单`, null, sameOutboundTask(replay, task))
|
||||
const pdaPath = `/api/pda/v1/last-leg/outbound-tasks/${task.taskNo}`
|
||||
const detail = await call('PDA读取专用出库任务', 'GET', pdaPath, undefined, true)
|
||||
check(`${label}PDA出库明细完整`, null, detail.items?.length === inputs.length)
|
||||
for (const [index, candidate] of selected.entries()) {
|
||||
const item = detail.items.find((row) => row.palletNo === candidate.palletNo && row.skuCode === candidate.skuCode
|
||||
&& row.inboundCode === candidate.inboundCode)
|
||||
check(`${label}PDA拣货来源仓位和入仓码一致`, null, Boolean(item)
|
||||
&& item.positions?.some((position) => String(position.inventoryPositionId) === String(candidate.inventoryPositionId)
|
||||
&& position.inboundCode === candidate.inboundCode))
|
||||
const pick = { outboundPalletItemId: item.outboundPalletItemId,
|
||||
inventoryPositionId: candidate.inventoryPositionId, packages: inputs[index].packages,
|
||||
cartonMarks: inputs[index].cartonMarks, idempotencyKey: `${context.marker}-outbound-pick-${index}` }
|
||||
const picked = await call('PDA专用出库拣货', 'POST', `${pdaPath}/pick`, pick, true)
|
||||
const repeated = await call('PDA出库拣货同键重放', 'POST', `${pdaPath}/pick`, pick, true)
|
||||
check(`${label}PDA出库拣货重放不重复扣减`, null, sameOutboundTask(repeated, picked))
|
||||
}
|
||||
const completion = { idempotencyKey: `${context.marker}-outbound-complete` }
|
||||
const completed = await call('PDA完成专用出库', 'POST', `${pdaPath}/complete`, completion, true)
|
||||
const repeated = await call('PDA专用出库完成重放', 'POST', `${pdaPath}/complete`, completion, true)
|
||||
check(`${label}专用出库任务完成且重放稳定`, null,
|
||||
completed.status === 'COMPLETED' && sameOutboundTask(repeated, completed),
|
||||
JSON.stringify({ completed, repeated }))
|
||||
const outbound = await call('回查专用出库单终态', 'GET', `${base}/${task.outboundNo}`)
|
||||
check(`${label}专用出库单真实完成且箱数核平`, null,
|
||||
outbound.outbound?.status === 'OUTBOUNDED' && outbound.outbound?.outboundType === 'ONE_CLICK_DROPSHIP'
|
||||
&& Number(outbound.outbound.palletCount) === new Set(selected.map((item) => item.palletNo)).size
|
||||
&& Number(outbound.outbound.packageCount) === inputs.reduce((sum, item) => sum + item.packages, 0)
|
||||
&& outbound.pallets.every((item) => Number(item.packageCount) === Number(item.pickedPackageCount)))
|
||||
const remaining = await call('回查专用出库可用库存扣减', 'GET', candidatePath)
|
||||
check(`${label}本轮每票库存全部扣减`, null,
|
||||
!remaining.some((candidate) => group.some((record) => record.source.inboundCode === candidate.inboundCode)))
|
||||
for (const record of group) record.outbound = { task: completed, selected, detail: outbound, completed: true }
|
||||
}
|
||||
try {
|
||||
await verify(await prepare('SINGLE-MULTI', 2), 2)
|
||||
await verify(await prepare('SINGLE-SINGLE', 1), 1)
|
||||
await verify(await prepare('SINGLE-MERGE', 2, { mergeToFirstPallet: true }), 1)
|
||||
const first = await prepare('MULTI-MERGE-A', 1, { deferSeal: true })
|
||||
const targetPalletNo = first.task.pallets[0].palletNo
|
||||
const second = await prepare('MULTI-MERGE-B', 1, { targetPalletNo })
|
||||
await verify(first, 1)
|
||||
await verify(second, 1)
|
||||
check('多票共托物理身份相同且两票保持独立归属', null,
|
||||
first.task.pallets[0].palletNo === second.task.pallets[0].palletNo
|
||||
&& first.source.inboundCode !== second.source.inboundCode
|
||||
&& first.task.pallets[0].cartons.length === first.expectedCartonMarks.length + second.expectedCartonMarks.length)
|
||||
const groups = [[records[0]], [records[1]], [records[2]], [first, second]]
|
||||
for (const group of groups) await allocate(group)
|
||||
context.createdResources.dropshipPalletMatrix.stage = 'DOMESTIC_LOADING_COMPLETED'
|
||||
for (const group of groups) await progressShipping(group)
|
||||
context.createdResources.dropshipPalletMatrix.stage = 'SHIPPING_PICKUP_READY'
|
||||
for (const group of groups) {
|
||||
await convertOverseas(group)
|
||||
await receiveOverseas(group)
|
||||
}
|
||||
context.createdResources.dropshipPalletMatrix.stage = 'DEDICATED_OUTBOUND_COMPLETED'
|
||||
} finally {
|
||||
context.marker = marker
|
||||
context.options.prepareDropshipManualCount = savedOptions
|
||||
context.context = savedContext
|
||||
}
|
||||
}
|
||||
@@ -88,7 +88,8 @@ async function waitForCompletedMeasurement(context, measurementPath, expectedSta
|
||||
return { response, measurement, attempts }
|
||||
}
|
||||
|
||||
export async function completeDropshipPalletization(context, { inboundId, inboundCode, warehouseId, measurement }) {
|
||||
export async function completeDropshipPalletization(context, { inboundId, inboundCode, warehouseId, measurement,
|
||||
targetPalletNo = null, deferSeal = false, mergeToFirstPallet = false }) {
|
||||
const pendingResponse = await context.request('GET',
|
||||
`/api/platform/v1/first-leg/pda-dropship/tasks/pending?warehouseId=${warehouseId}&keyword=${encodeURIComponent(inboundCode)}`)
|
||||
const pending = context.apiSuccess(MODULE, '查询一件代发待合托任务', pendingResponse,
|
||||
@@ -154,7 +155,7 @@ export async function completeDropshipPalletization(context, { inboundId, inboun
|
||||
discrepancyAction: 'MATCH',
|
||||
discrepancyRemark: null,
|
||||
})),
|
||||
palletNo: index === 0 ? null : generatedPallets[Math.min(index, generatedPallets.length - 1)].palletNo,
|
||||
palletNo: index === 0 ? null : (targetPalletNo || generatedPallets[Math.min(index, generatedPallets.length - 1)].palletNo),
|
||||
operationId: `${context.marker}-dropship-count-${index + 1}`.slice(0, 128),
|
||||
}
|
||||
const countResponse = await context.request('POST',
|
||||
@@ -186,16 +187,19 @@ export async function completeDropshipPalletization(context, { inboundId, inboun
|
||||
&& Number(replayed?.unboundCartons) === Number(counted?.unboundCartons),
|
||||
`counted=${replayed?.countedCartons ?? 'missing'}, unbound=${replayed?.unboundCartons ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const firstPallet = generatedPallets[0]
|
||||
const firstPallet = targetPalletNo ? { palletNo: targetPalletNo } : generatedPallets[0]
|
||||
const bindPayload = { warehouseId, palletNo: firstPallet.palletNo,
|
||||
operationId: `${context.marker}-dropship-bind-first`.slice(0, 128) }
|
||||
const bindResponse = await context.request('POST',
|
||||
`/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/pallets/bind-pending`, bindPayload)
|
||||
const bound = context.apiSuccess(MODULE, '后扫预生成托盘码并绑定全部待组托箱', bindResponse,
|
||||
`taskId=${taskId}`, OPTIONS)
|
||||
const pallet = bound?.pallets?.find((item) => asId(item.palletId) === asId(firstPallet.palletId)) || null
|
||||
const pallet = bound?.pallets?.find((item) => item.palletNo === firstPallet.palletNo) || null
|
||||
context.assert(MODULE, '待组托箱绑定到OPEN托盘且不再游离', bindResponse,
|
||||
Number(bound?.unboundCartons) === 0 && Number(pallet?.cartonCount) === 1
|
||||
Number(bound?.unboundCartons) === 0
|
||||
&& (targetPalletNo
|
||||
? pallet?.cartons?.filter((item) => asId(item.inboundId) === inboundId).length === 1
|
||||
: Number(pallet?.cartonCount) === 1)
|
||||
&& pallet?.status === 'OPEN' && pallet?.palletNo === firstPallet.palletNo
|
||||
&& bound?.pallets?.length === 1,
|
||||
`unbound=${bound?.unboundCartons ?? 'missing'}, palletStatus=${pallet?.status ?? 'missing'}`, OPTIONS)
|
||||
@@ -207,7 +211,8 @@ export async function completeDropshipPalletization(context, { inboundId, inboun
|
||||
&& Number(replayBind?.boundCartons) === Number(bound?.boundCartons)
|
||||
&& Number(replayBind?.unboundCartons) === 0, '', OPTIONS)
|
||||
} else {
|
||||
const expectedPallet = generatedPallets[Math.min(index, generatedPallets.length - 1)]
|
||||
const expectedPallet = targetPalletNo ? { palletNo: targetPalletNo }
|
||||
: generatedPallets[Math.min(index, generatedPallets.length - 1)]
|
||||
context.assert(MODULE, `第${index + 1}箱直接进入当前OPEN托盘`, countResponse,
|
||||
counted?.pallet?.palletNo === expectedPallet.palletNo && counted?.pallet?.status === 'OPEN',
|
||||
`palletNo=${counted?.pallet?.palletNo ?? 'missing'}, status=${counted?.pallet?.status ?? 'missing'}`, OPTIONS)
|
||||
@@ -218,10 +223,40 @@ export async function completeDropshipPalletization(context, { inboundId, inboun
|
||||
context.assert(MODULE, '预生成托盘覆盖测量冻结的全部箱数', countedTaskResponse,
|
||||
totalCartons > 0 && countedCartons === totalCartons
|
||||
&& Number(sealed?.boundCartons) === totalCartons
|
||||
&& sealed?.pallets?.length === expectedPalletCount
|
||||
&& sealed?.pallets?.length === (targetPalletNo ? 1 : expectedPalletCount)
|
||||
&& sealed?.pallets?.every((item) => Number(item?.cartonCount) > 0),
|
||||
`counted=${countedCartons}, bound=${sealed?.boundCartons ?? 'missing'}, expected=${totalCartons || 'missing'}`, OPTIONS)
|
||||
|
||||
if (mergeToFirstPallet) {
|
||||
const target = sealed?.pallets?.[0]
|
||||
const sources = (sealed?.pallets || []).slice(1)
|
||||
context.assert(MODULE, '单票合托前确有多个实物托盘', countedTaskResponse,
|
||||
Boolean(target) && sources.length > 0, '', OPTIONS)
|
||||
if (!target || sources.length === 0) return null
|
||||
for (const source of sources) {
|
||||
for (const carton of source.cartons || []) {
|
||||
const payload = { warehouseId, targetPalletNo: target.palletNo,
|
||||
operationId: `${context.marker}-merge-${carton.cartonId}`.slice(0, 128) }
|
||||
const path = `/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/cartons/${carton.cartonId}/move`
|
||||
const movedResponse = await context.request('POST', path, payload)
|
||||
sealed = context.apiSuccess(MODULE, '通过正式移箱接口合并单票托盘', movedResponse, '', OPTIONS)
|
||||
const replayResponse = await context.request('POST', path, payload)
|
||||
const replay = context.apiSuccess(MODULE, '单票合托移箱同键重放', replayResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '移箱重放保持总箱数守恒', replayResponse,
|
||||
Number(replay?.boundCartons) === totalCartons && Number(replay?.unboundCartons) === 0, '', OPTIONS)
|
||||
}
|
||||
}
|
||||
context.assert(MODULE, '单票合托后仅一个实物托盘且箱唛不丢失', null,
|
||||
sealed?.pallets?.length === 1 && sealed.pallets[0].palletNo === target.palletNo
|
||||
&& sealed.pallets[0].cartons?.length === totalCartons
|
||||
&& new Set(sealed.pallets[0].cartons.map((item) => item.boxCode)).size === totalCartons,
|
||||
'', OPTIONS)
|
||||
}
|
||||
if (deferSeal) {
|
||||
context.assert(MODULE, '多票合托首票归托后保持开放等待下一票', null,
|
||||
sealed?.taskStatus !== 'COMPLETED' && sealed?.pallets?.every((item) => item.status === 'OPEN'), '', OPTIONS)
|
||||
return { ...sealed, sealedCartonMarks, sealedSourcePalletNos: [] }
|
||||
}
|
||||
let lastSealResponse = countedTaskResponse
|
||||
for (let index = 0; index < (sealed?.pallets || []).length; index += 1) {
|
||||
const pallet = sealed.pallets.find((item) => item.status === 'OPEN')
|
||||
|
||||
@@ -5,7 +5,7 @@ const TRUCK_BASE = '/api/platform/v1/truck-appointments'
|
||||
const OCEAN_LIST_STATUSES = ['DRAFT', 'RELEASED']
|
||||
const OCEAN_STATUSES = [...OCEAN_LIST_STATUSES]
|
||||
const TRUCK_STATUSES = ['DRAFT', 'PENDING_DISPATCH', 'DISPATCHED', 'PICKUP_IN_PROGRESS', 'ARRIVED_WAREHOUSE',
|
||||
'LOADING', 'DEPARTED', 'COMPLETED', 'RESCHEDULED', 'EXCEPTION', 'CANCELLED']
|
||||
'LOADING', 'RESCHEDULED', 'EXCEPTION', 'CANCELLED']
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function code(response) { return String(response?.body?.code ?? 'missing') }
|
||||
@@ -153,7 +153,7 @@ async function buildTruck(context, oceanBooking, status, index) {
|
||||
if (status === 'EXCEPTION') return truckAction(context, value, 'exception')
|
||||
if (status === 'CANCELLED') return truckAction(context, value, 'cancel')
|
||||
const chain = [['submit', 'PENDING_DISPATCH'], ['dispatch', 'DISPATCHED'], ['pickup', 'PICKUP_IN_PROGRESS'],
|
||||
['arrive', 'ARRIVED_WAREHOUSE'], ['load', 'LOADING'], ['depart', 'DEPARTED'], ['complete', 'COMPLETED']]
|
||||
['arrive', 'ARRIVED_WAREHOUSE'], ['load', 'LOADING']]
|
||||
for (const [endpoint, target] of chain) {
|
||||
value = await truckAction(context, value, endpoint)
|
||||
if (target === status) return value
|
||||
|
||||
@@ -150,7 +150,7 @@ function blockResourceIdentity(resource) {
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForCompletion(context, taskDetailPath, operationsPath, attempts = 90) {
|
||||
export async function waitForCompletion(context, taskDetailPath, operationsPath, attempts = 90) {
|
||||
let taskResponse = null
|
||||
let task = null
|
||||
let operationsResponse = null
|
||||
|
||||
@@ -87,7 +87,7 @@ async function request(context, method, path, body, headers = {}) {
|
||||
} catch (error) { return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } }
|
||||
}
|
||||
|
||||
async function uploadEvidence(context, taskId, warehouseId, evidenceType, expectedVersion, keySuffix,
|
||||
export async function uploadEvidence(context, taskId, warehouseId, evidenceType, expectedVersion, keySuffix,
|
||||
{ sealNo = null, remark = context.marker, fileSuffix = evidenceType } = {}) {
|
||||
const query = new URLSearchParams({ warehouseId: String(warehouseId), expectedVersion: String(expectedVersion),
|
||||
evidenceType, remark })
|
||||
|
||||
@@ -29,6 +29,83 @@ async function request(context, method, path, body, headers = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyTruckDeparturePayables(context, aggregate) {
|
||||
const appointmentId = id(aggregate?.truckAppointmentId)
|
||||
if (!appointmentId) return
|
||||
const truckResponse = await request(context, 'GET', `/api/platform/v1/truck-appointments/${appointmentId}`)
|
||||
const truck = context.apiSuccess(MODULE, '回查国内离仓关联拖车', truckResponse, '', OPTIONS)
|
||||
if (!truck) return
|
||||
const bookingResponse = await request(context, 'GET', `/api/platform/v1/ocean-bookings/${id(truck.oceanBookingId)}`)
|
||||
const booking = context.apiSuccess(MODULE, '核对拖车订舱全部要求货柜', bookingResponse, '', OPTIONS)
|
||||
if (!booking) return
|
||||
const expectedCount = (booking.containerRequirements || []).reduce((sum, item) => sum + Number(item.quantity), 0)
|
||||
const departed = []
|
||||
for (const container of booking.containers || []) {
|
||||
const response = await request(context, 'GET', `${BASE}/${id(container.containerBookingId)}`)
|
||||
const detail = context.apiSuccess(MODULE, '回查拖车关联货柜离仓事实', response, '', OPTIONS)
|
||||
if (detail?.aggregate) departed.push(detail.aggregate)
|
||||
}
|
||||
const ready = expectedCount > 0 && departed.length === expectedCount
|
||||
&& departed.every(item => item.dispatchStatus === 'WAITING_FOR_LOADING')
|
||||
context.assert(MODULE, '全部要求货柜离仓才自动完成拖车', truckResponse,
|
||||
ready ? truck.status === 'COMPLETED' : truck.status !== 'COMPLETED',
|
||||
`ready=${ready}, status=${truck.status}`, OPTIONS)
|
||||
if (!ready || truck.status !== 'COMPLETED') return
|
||||
const historyResponse = await request(context, 'GET', `/api/platform/v1/truck-appointments/${appointmentId}/history`)
|
||||
const history = context.apiSuccess(MODULE, '回查拖车自动完成历史', historyResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '拖车自动完成仅记录一次', historyResponse,
|
||||
Array.isArray(history) && history.filter(item => item.action === 'AUTO_COMPLETE').length === 1, '', OPTIONS)
|
||||
const fees = new Map()
|
||||
for (const container of departed) {
|
||||
const customers = [...new Set(container.inbounds.map(item => id(item.customerId)))]
|
||||
for (const customer of customers) {
|
||||
const response = await request(context, 'GET', `/api/platform/v1/loading-plans/${id(container.loadingPlanId)}/fee-details?accountId=${customer}`)
|
||||
const details = context.apiSuccess(MODULE, '回查拖车生成的柜次应付', response, '', OPTIONS)
|
||||
for (const fee of Array.isArray(details) ? details : []) {
|
||||
if (fee.direction === 'PAYABLE' && String(fee.remark || '').includes(`国内拖车单 ${truck.appointmentNo};`)) fees.set(id(fee.id), fee)
|
||||
}
|
||||
}
|
||||
}
|
||||
const totalCents = [...fees.values()].reduce((sum, fee) => sum + Math.round(Number(fee.amount) * 100), 0)
|
||||
context.assert(MODULE, '拖车应付分摊守恒且自动审核', null,
|
||||
totalCents === Math.round(Number(truck.payableAmount || 0) * 100)
|
||||
&& [...fees.values()].every(fee => fee.currency === 'CNY' && fee.reviewStatus === 'APPROVED'
|
||||
&& fee.settlementMode === 'ON_ACCOUNT' && id(fee.supplierId) === id(truck.carrierId)),
|
||||
`sourceCount=${fees.size}, totalCents=${totalCents}`, OPTIONS)
|
||||
const imported = new Map()
|
||||
for (let attempt = 0; attempt < 12 && imported.size < fees.size; attempt++) {
|
||||
for (const customer of new Set([...fees.values()].map(fee => id(fee.tenantId)))) {
|
||||
for (let page = 1; ; page++) {
|
||||
const response = await request(context, 'GET', `/api/platform/finance/v1/fee-sources?customerId=${customer}&direction=PAYABLE&page=${page}&pageSize=200`)
|
||||
const rows = context.apiSuccess(MODULE, '回查拖车应付财务入池', response, '', OPTIONS)
|
||||
if (!Array.isArray(rows)) break
|
||||
rows.filter(row => row.sourceSystem === 'FIRSTLEG' && row.sourceType === 'CONTAINER_FEE'
|
||||
&& fees.has(id(row.sourceFeeId))).forEach(row => imported.set(id(row.sourceFeeId), row))
|
||||
if (page * 200 >= Number(response.body?.page?.total || 0)) break
|
||||
}
|
||||
}
|
||||
if (imported.size < fees.size) await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
}
|
||||
context.assert(MODULE, '拖车费用真实流转到财务应付来源', null,
|
||||
imported.size === fees.size && [...imported.values()].every(row => row.currency === 'CNY'
|
||||
&& row.direction === 'PAYABLE' && row.reviewStatus === 'APPROVED'
|
||||
&& Math.round(Number(row.amount) * 100) === Math.round(Number(fees.get(id(row.sourceFeeId)).amount) * 100)),
|
||||
`expected=${fees.size}, imported=${imported.size}`, OPTIONS)
|
||||
const candidates = new Map()
|
||||
for (const customer of new Set([...fees.values()].map(fee => id(fee.tenantId)))) {
|
||||
const response = await request(context, 'GET', `/api/platform/finance/v1/fee-pool?customerId=${customer}&direction=PAYABLE&supplierId=${id(truck.carrierId)}`)
|
||||
const rows = context.apiSuccess(MODULE, '回查拖车费可用于应付结算', response, '', OPTIONS)
|
||||
for (const row of Array.isArray(rows) ? rows : []) {
|
||||
if (row.sourceType === 'CONTAINER_FEE' && fees.has(id(row.sourceId))) candidates.set(id(row.sourceId), row)
|
||||
}
|
||||
}
|
||||
context.assert(MODULE, '拖车费已进入应付结算候选且未付款', null,
|
||||
candidates.size === fees.size && [...candidates.values()].every(row => row.direction === 'PAYABLE'
|
||||
&& row.settlementMode === 'ON_ACCOUNT' && row.status === 'AVAILABLE' && !row.paidAt
|
||||
&& Math.round(Number(row.amount) * 100) === Math.round(Number(fees.get(id(row.sourceId)).amount) * 100)),
|
||||
`expected=${fees.size}, candidates=${candidates.size}`, OPTIONS)
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const fixture = context.createdResources?.phase3Loading
|
||||
|| context.createdResources?.phase3ContainerLoading
|
||||
@@ -80,6 +157,7 @@ export async function run(context) {
|
||||
`truck=${aggregate?.truckAppointmentStatus || 'none'}, ocean=${aggregate?.oceanBookingStatus || 'none'}`, OPTIONS)
|
||||
|
||||
if (aggregate?.dispatchStatus === 'WAITING_FOR_LOADING') {
|
||||
await verifyTruckDeparturePayables(context, aggregate)
|
||||
context.assert(MODULE, '整柜报关完成后系统已自动确认国内离仓', detailResponse,
|
||||
aggregate?.inbounds?.every?.((item) => item?.inboundStatus === 'LOADED')
|
||||
&& detail?.timeline?.some?.((item) => item.module === 'OUTBOUND_DISPATCH'
|
||||
@@ -178,6 +256,7 @@ export async function run(context) {
|
||||
&& item.action === 'REVERSE_DISPATCH'),
|
||||
`dispatch=${finalDetail?.aggregate?.dispatchStatus || 'missing'}, inbounds=${finalDetail?.aggregate?.inbounds?.length ?? 'missing'}, version=${finalDetail?.aggregate?.version ?? 'missing'}`, OPTIONS)
|
||||
if (confirmed?.aggregate) {
|
||||
await verifyTruckDeparturePayables(context, confirmed.aggregate)
|
||||
context.createdResources.phase3OutboundDispatch = {
|
||||
marker: context.marker, inboundId, containerBookingId,
|
||||
orderId: id(inboundBinding?.orderId), tenantId: id(inboundBinding?.tenantId),
|
||||
|
||||
@@ -262,7 +262,7 @@ async function verifyWarehouseQueries(context, token, inboundCode, pallet, desti
|
||||
: null
|
||||
}
|
||||
|
||||
async function uploadPhotos(context, token) {
|
||||
export async function uploadPhotos(context, token) {
|
||||
const malicious = await context.requestOverseasMultipart('POST', '/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files', {
|
||||
name: `${context.marker}-forged.png`, type: 'image/png', content: Uint8Array.from(Buffer.from('<script>alert(1)</script>')),
|
||||
}, token)
|
||||
|
||||
@@ -114,8 +114,6 @@ export async function run(context) {
|
||||
['跨链路开始提货', 'pickup', 'PICKUP_IN_PROGRESS'],
|
||||
['跨链路拖车到仓', 'arrive', 'ARRIVED_WAREHOUSE'],
|
||||
['跨链路开始装货', 'load', 'LOADING'],
|
||||
['跨链路拖车离仓', 'depart', 'DEPARTED'],
|
||||
['完成跨链路拖车', 'complete', 'COMPLETED'],
|
||||
]) {
|
||||
const action = await actionRequest(context, path, endpoint, version)
|
||||
const changed = context.apiSuccess(MODULE, name, action, '', OPTIONS)
|
||||
@@ -125,9 +123,9 @@ export async function run(context) {
|
||||
version = Number(changed.version)
|
||||
}
|
||||
const detail = await context.request('GET', `${TRUCK}/${appointmentId}`)
|
||||
const final = context.apiSuccess(MODULE, '回查跨服务链路安全终态', detail, '', OPTIONS)
|
||||
context.assert(MODULE, '跨服务链路终态持久化且关联订舱未漂移', detail,
|
||||
final?.status === 'COMPLETED' && id(final?.oceanBookingId) === id(ocean.id)
|
||||
const final = context.apiSuccess(MODULE, '回查跨服务链路离仓前状态', detail, '', OPTIONS)
|
||||
context.assert(MODULE, '跨服务链路离仓前状态持久化且关联订舱未漂移', detail,
|
||||
final?.status === 'LOADING' && id(final?.oceanBookingId) === id(ocean.id)
|
||||
&& !Object.hasOwn(final || {}, 'containerBookingId')
|
||||
&& !Object.hasOwn(final || {}, 'loadingPlanId'),
|
||||
`status=${final?.status || 'missing'}, oceanBookingId=${final?.oceanBookingId || 'missing'}, containerBookingId=${final?.containerBookingId || 'n/a'}, loadingPlanId=${final?.loadingPlanId || 'n/a'}`, OPTIONS)
|
||||
@@ -135,19 +133,19 @@ export async function run(context) {
|
||||
const historyResponse = await context.request('GET', `${TRUCK}/${appointmentId}/history`)
|
||||
const history = context.apiSuccess(MODULE, '查询跨服务链路拖车预约状态历史', historyResponse, '', OPTIONS)
|
||||
const actions = Array.isArray(history) ? history.filter((item) => item?.action !== 'CREATE') : []
|
||||
const expectedActions = ['SUBMIT', 'DISPATCH', 'PICKUP', 'ARRIVE', 'LOAD', 'DEPART', 'COMPLETE']
|
||||
const expectedActions = ['SUBMIT', 'DISPATCH', 'PICKUP', 'ARRIVE', 'LOAD']
|
||||
context.assert(MODULE, '跨服务链路历史包含完整状态链、操作者、时间和备注', historyResponse,
|
||||
expectedActions.every((action) => actions.some((item) => item.action === action
|
||||
&& id(item.appointmentId) === appointmentId && Boolean(item.operatorId)
|
||||
&& Boolean(item.operatorName) && Boolean(item.createdAt) && item.reason === context.marker)),
|
||||
`history=${history?.length ?? 'n/a'}, actions=${actions.map((item) => item.action).join(',')}`,
|
||||
OPTIONS)
|
||||
if (final?.status === 'COMPLETED') {
|
||||
if (final?.status === 'LOADING') {
|
||||
context.createdResources.serviceCenterFlow = {
|
||||
appointmentId, oceanBookingId: ocean.id,
|
||||
containerBookingId: null,
|
||||
loadingPlanId: null,
|
||||
marker: context.marker, finalStatus: 'COMPLETED',
|
||||
marker: context.marker, finalStatus: 'LOADING',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,15 +53,15 @@ test('HTTP lifecycle covers uniqueness idempotency attachments and every action'
|
||||
'重复创建未改变版本和状态', '上传并关联DISPATCH_ORDER拖车附件', '查询拖车附件列表',
|
||||
'删除首次上传的拖车附件引用', '重新引用平台归属拖车附件',
|
||||
'删除重新引用的拖车附件', '恢复一份DISPATCH_ORDER拖车附件供后续状态流转',
|
||||
'编辑订舱级拖车为USD应付费用', '整单推进SUBMIT', 'SUBMIT同键同载荷重放',
|
||||
'编辑订舱级拖车为RMB应付费用', '整单推进SUBMIT', 'SUBMIT同键同载荷重放',
|
||||
'SUBMIT重放版本零漂移', 'SUBMIT同键异载荷A0409',
|
||||
'COMPLETED后订舱恢复候选', 'COMPLETED后同订舱可新建下一张拖车单',
|
||||
'禁止手工离仓或完成拖车',
|
||||
'CANCELLED后订舱恢复候选', 'CANCELLED后同订舱可再次新建',
|
||||
'EXCEPTION后订舱恢复候选', 'EXCEPTION后同订舱可再次新建']) {
|
||||
assert.match(source, new RegExp(label), label)
|
||||
}
|
||||
assert.match(source, /\['DISPATCH', '派车'\], \['PICKUP', '提柜'\], \['ARRIVE', '到仓'\]/)
|
||||
assert.match(source, /\['LOAD', '装货'\], \['DEPART', '离仓'\], \['COMPLETE', '完成'\]/)
|
||||
assert.match(source, /\['LOAD', '装货'\]/)
|
||||
assert.match(source, /context\.apiSuccess\(MODULE, `整单推进\$\{name\}`/)
|
||||
})
|
||||
|
||||
|
||||
@@ -270,10 +270,10 @@ export async function run(context) {
|
||||
`attachmentId=${id(replacement?.id) || 'missing'}, fileId=${id(replacement?.fileId) || 'missing'}`, OPTIONS)
|
||||
|
||||
const editedResponse = await request(context, 'PUT', path,
|
||||
truckAppointmentPayload(context, identity.suffix, identity, Number(appointment.version), { currency: 'USD', payableAmount: 12.34 }))
|
||||
appointment = context.apiSuccess(MODULE, '编辑订舱级拖车为USD应付费用', editedResponse, '', OPTIONS)
|
||||
truckAppointmentPayload(context, identity.suffix, identity, Number(appointment.version), { currency: 'RMB', payableAmount: 12.34 }))
|
||||
appointment = context.apiSuccess(MODULE, '编辑订舱级拖车为RMB应付费用', editedResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '编辑响应费用正确且无柜次协议', editedResponse,
|
||||
appointment?.currency === 'USD' && Number(appointment?.payableAmount) === 12.34 && !hasForbiddenTruckFields(appointment), '', OPTIONS)
|
||||
appointment?.currency === 'RMB' && Number(appointment?.payableAmount) === 12.34 && !hasForbiddenTruckFields(appointment), '', OPTIONS)
|
||||
|
||||
const submitKey = `${context.marker}-truck-submit`.slice(0, 128)
|
||||
const submitBody = { expectedVersion: Number(appointment.version), reason: '幂等提交' }
|
||||
@@ -289,27 +289,24 @@ export async function run(context) {
|
||||
codeOf(conflictResponse) === 'A0409', `code=${codeOf(conflictResponse)}`, OPTIONS)
|
||||
|
||||
for (const [name, reason] of [['DISPATCH', '派车'], ['PICKUP', '提柜'], ['ARRIVE', '到仓'],
|
||||
['LOAD', '装货'], ['DEPART', '离仓'], ['COMPLETE', '完成']]) {
|
||||
['LOAD', '装货']]) {
|
||||
appointment = await action(context, path, appointment, name, reason)
|
||||
context.assert(MODULE, `${name}响应无柜次协议`, null, Boolean(appointment) && !hasForbiddenTruckFields(appointment),
|
||||
`status=${appointment?.status || 'missing'}`, OPTIONS)
|
||||
}
|
||||
const finalDetailResponse = await request(context, 'GET', path)
|
||||
const finalDetail = context.apiSuccess(MODULE, '回查COMPLETED订舱级拖车详情', finalDetailResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '完整生命周期完成且详情无柜次协议', finalDetailResponse,
|
||||
finalDetail?.status === 'COMPLETED' && truckAppointmentFieldGaps(finalDetail).length === 0
|
||||
const finalDetail = context.apiSuccess(MODULE, '回查待离仓订舱级拖车详情', finalDetailResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '未确认国内离仓时保持装柜中且详情无柜次协议', finalDetailResponse,
|
||||
finalDetail?.status === 'LOADING' && truckAppointmentFieldGaps(finalDetail).length === 0
|
||||
&& !hasForbiddenTruckFields(finalDetail)
|
||||
&& id(finalDetail?.oceanBookingId) === identity.oceanBookingId,
|
||||
`appointmentNo=${finalDetail?.appointmentNo || 'missing'}, status=${finalDetail?.status || 'missing'}, fieldGaps=${truckAppointmentFieldGaps(finalDetail).join('|') || 'none'}`, OPTIONS)
|
||||
const afterCompleteCandidate = await candidate(context, identity)
|
||||
context.assert(MODULE, 'COMPLETED后订舱恢复候选', afterCompleteCandidate.response, Boolean(afterCompleteCandidate.item),
|
||||
`oceanBookingNo=${identity.oceanBookingNo}`, OPTIONS)
|
||||
const rebuiltResponse = await request(context, 'POST', TRUCKS, base)
|
||||
let rebuilt = context.apiSuccess(MODULE, 'COMPLETED后同订舱可新建下一张拖车单', rebuiltResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '重建拖车单保持订舱级响应', rebuiltResponse,
|
||||
rebuilt?.status === 'DRAFT' && !hasForbiddenTruckFields(rebuilt)
|
||||
&& id(rebuilt?.oceanBookingId) === identity.oceanBookingId,
|
||||
`appointmentNo=${rebuilt?.appointmentNo || 'missing'}`, OPTIONS)
|
||||
for (const obsoleteAction of ['depart', 'complete']) {
|
||||
const rejected = await request(context, 'POST', `${path}/${obsoleteAction}`,
|
||||
{ expectedVersion: Number(finalDetail.version) }, { 'Idempotency-Key': `${context.marker}-${obsoleteAction}`.slice(0, 128) })
|
||||
context.assert(MODULE, '禁止手工离仓或完成拖车', rejected, codeOf(rejected) === 'A0409', '', OPTIONS)
|
||||
}
|
||||
let rebuilt = finalDetail
|
||||
const cancelled = await action(context, `${TRUCKS}/${id(rebuilt?.id)}`, rebuilt, 'CANCEL', '取消测试单')
|
||||
const afterCancelCandidate = await candidate(context, identity)
|
||||
context.assert(MODULE, 'CANCELLED后订舱恢复候选', afterCancelCandidate.response,
|
||||
|
||||
@@ -466,8 +466,8 @@ Assert-NotContains $truckOpenApi 'customerId:' '拖车 OpenAPI 客户字段'
|
||||
Assert-NotContains $truckOpenApi 'containerBookingId:' '拖车 OpenAPI 柜次字段'
|
||||
Assert-NotContains $truckOpenApi 'loadingPlanId:' '拖车 OpenAPI 装柜计划字段'
|
||||
Assert-Contains $truckOpenApi 'Each booking may have at most one non-terminal appointment' '拖车订舱级唯一性'
|
||||
Assert-Contains $truckOpenApi 'PDA loading completion does not change a booking-level truck appointment' '拖车与 PDA 生命周期解耦'
|
||||
Assert-Contains $truckOpenApi 'excluding bookings that already have a non-terminal truck appointment' '拖车创建候选过滤'
|
||||
Assert-Contains $truckOpenApi 'PDA loading completion alone does not complete the appointment' '拖车与 PDA 生命周期解耦'
|
||||
Assert-Contains $truckOpenApi 'excluding bookings that already have an active or completed truck appointment' '拖车创建候选过滤'
|
||||
Assert-Contains $firstlegAgents '目的港码头拖车与还空预警' 'Firstleg 目的港码头拖车入口'
|
||||
Assert-Contains $firstlegAgents '按卸柜完成时间加 72 小时' 'Firstleg 还空预警口径'
|
||||
Assert-Contains $terminalDrayageOpenApi '/api/platform/v1/terminal-drayages/{containerBookingId}/fleet:' '码头拖车车队命令 OpenAPI'
|
||||
|
||||
Reference in New Issue
Block a user