fix(delivery): expose signed quantity and publish correct signature instant

This commit is contained in:
2026-10-03 23:09:24 +08:00
parent 0c8ede3889
commit 1f7ddf7bc9
11 changed files with 162 additions and 16 deletions
@@ -18,6 +18,30 @@ import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class OverseasDeliveryCompletedConsumerTest {
@Test
void preservesTheInstantOfAShanghaiSignatureInUtcInboundAndHistory() {
var inbounds = mock(InboundOrderMapper.class);
var histories = mock(InboundStatusHistoryMapper.class);
var inbox = mock(ContainerEventInboxMapper.class);
var orders = mock(OrderFulfillmentCompletionClient.class);
var json = new ObjectMapper().registerModule(new JavaTimeModule());
var consumer = new OverseasDeliveryCompletedConsumer(inbounds, histories, inbox, orders, json);
var inbound = new InboundOrderEntity();
inbound.setId(101L); inbound.setTenantId(88L); inbound.setOrderId(201L);
inbound.setOrderNo("FL-201"); inbound.setInboundCode("IN-1"); inbound.setDestinationCode("ONT8");
inbound.setProcessStatus("LOADED");
when(inbounds.selectActiveByIdForUpdate(101L)).thenReturn(inbound);
when(inbounds.completeOverseasDelivery(eq(88L), eq(101L), any())).thenReturn(1);
when(inbounds.countNotDeliveredByOrder(88L, 201L)).thenReturn(1);
var signature = java.time.LocalDateTime.of(2026, 10, 3, 22, 13)
.atZone(java.time.ZoneId.of("Asia/Shanghai")).toInstant();
consumer.consume(event(json, "201", signature));
var expected = java.time.LocalDateTime.of(2026, 10, 3, 14, 13);
verify(inbounds).completeOverseasDelivery(88L, 101L, expected);
verify(histories).insert(argThat((com.baiyue.firstleg.entity.InboundStatusHistoryEntity history) -> expected.equals(history.getOccurredAt())
&& "LOADED".equals(history.getFromStatus()) && "DELIVERED".equals(history.getToStatus())));
verifyNoInteractions(orders);
}
@Test
void completesOnlyEventSourceInboundAndOrder() {
InboundOrderMapper inbounds = mock(InboundOrderMapper.class);
@@ -111,7 +135,9 @@ class OverseasDeliveryCompletedConsumerTest {
private EventEnvelope event(ObjectMapper json) { return event(json, "201"); }
private EventEnvelope event(ObjectMapper json, String rootOrderId) {
Instant time = Instant.parse("2026-09-15T08:00:00Z");
return event(json, rootOrderId, Instant.parse("2026-09-15T08:00:00Z"));
}
private EventEnvelope event(ObjectMapper json, String rootOrderId, Instant time) {
var payload = new OverseasDeliveryCompletedV1("1", "DL-ONT8", "11", "LM-11",
"101", "IN-1", "201", "FL-201", rootOrderId, "ONT8", time);
var node = json.valueToTree(payload);
@@ -3,6 +3,7 @@ package com.baiyue.lastleg.delivery.dto;
import java.time.LocalDateTime;
import java.math.BigDecimal;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonInclude;
public final class DeliveryResponses {
private DeliveryResponses() {}
@@ -27,7 +28,8 @@ public final class DeliveryResponses {
String loadingType, String vehicleType, String vehicleSize,
String vehiclePlate, String driverName, String driverPhone,
String appointmentAccount, String sellerName, int palletCount,
int packageCount, BigDecimal totalWeightKg, BigDecimal totalVolumeCbm,
int packageCount, @JsonInclude(JsonInclude.Include.ALWAYS) Integer signedPackages,
BigDecimal totalWeightKg, BigDecimal totalVolumeCbm,
LocalDateTime plannedDeliveryAt, LocalDateTime dispatchedAt,
LocalDateTime expectedArrivalAt, LocalDateTime signedAt,
String remark, LocalDateTime createdAt, long version, List<String> allowedActions,
@@ -21,7 +21,7 @@ import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.ZoneId;
import java.util.*;
@Service
@@ -86,7 +86,8 @@ public class DeliveryDocumentService {
List<DeliveryDocumentDtos.File> values = bindings.getOrDefault(category, List.of());
return new Category(category, values.isEmpty() ? "MISSING" : "UPLOADED", values.size(), values);
}).toList();
LocalDateTime now = LocalDateTime.now();
ZoneId executionZone = ZoneId.systemDefault();
LocalDateTime now = LocalDateTime.now(executionZone);
boolean receiptSaved = !bindings.getOrDefault(DeliveryDocumentCategory.RECEIPT, List.of()).isEmpty();
boolean autoCompleted = receiptSaved && order.getSignedAt() == null && !"DELIVERED".equals(order.getStatus());
int signedPackages = 0;
@@ -113,7 +114,7 @@ public class DeliveryDocumentService {
orders.updateById(order);
if (autoCompleted) {
var completion = completeRelatedOrders(order, operatorId, now);
deliveryCompletedOutbox.publish(order, completion.orders(), completion.proofs(), now.toInstant(ZoneOffset.UTC));
deliveryCompletedOutbox.publish(order, completion.orders(), completion.proofs(), now.atZone(executionZone).toInstant());
customerVisits.ensureForCompletedDelivery(order, now);
}
Detail result = detail(order, false);
@@ -1438,7 +1438,7 @@ public class DeliveryTransferService {
allocateMetric(candidate.getSourceVolumeWeightKg(), candidate.getSourcePackageCount(), available, 3),
allocateMetric(candidate.getSourceVolumeCbm(), candidate.getSourcePackageCount(), available, 4));
}
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), DeliveryDocumentSnapshot.summary(d.getDocumentsJson()), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName(), d.getDestinationType(), d.getDeliveryAddress(), d.getContactName(), d.getContactPhone(), d.getPostalCode()); }
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getSignedPackages(), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), DeliveryDocumentSnapshot.summary(d.getDocumentsJson()), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName(), d.getDestinationType(), d.getDeliveryAddress(), d.getContactName(), d.getContactPhone(), d.getPostalCode()); }
private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), null, d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); }
private DeliveryResponses.DeliveryFeeRow feeRow(OverseasFeeSource f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getDirection(), f.getFeeItemCode(), f.getOriginalCurrency(), f.getOriginalAmount(), f.getReviewReason(), f.getReviewStatus(), f.getSourceStatus(), f.getVoidStatus()); }
private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); }
@@ -848,14 +848,14 @@ paths:
summary: 分页查询平台派送单
description: 权限 platform:overseas-warehouse:delivery:list;customerId 仅为可选平台筛选条件。同一排车按目的FBA仓显示为不同派送单,每行返回目的仓快照和服务端计算的 allowedActions,页面不得自行推导状态门禁;完成动作不再由人工触发。
security: [{ bearerAuth: [] }]
responses: { '200': { description: 派送单分页 } }
responses: { '200': { description: 派送单分页, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryOrderPageResponse' } } } } }
/api/platform/v1/last-leg/delivery-orders/{deliveryNo}:
get:
summary: 查询平台派送单详情
description: 权限 platform:overseas-warehouse:delivery:list;列表权限包含详情只读;order.allowedActions 为当前可执行动作。
security: [{ bearerAuth: [] }]
parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: '派送单详情,SKU与BOL打印数据只包含当前派送单目的FBA仓;fees附reviewStatus、sourceStatus、voidStatus(NOT_REQUESTED/PENDING_FINANCE/FINANCE_ATTENTION/CONFIRMED),不得把待财务确认或待处理显示为已作废。feeCoverage为可空的原费用服务覆盖确认快照,只包含当前派送所属货物、引用的原费用版本及服务、确认人和时间;不代表新增收费或原费用已付款。' }, '404': { description: 派送单不存在 } }
responses: { '200': { description: '派送单详情,SKU与BOL打印数据只包含当前派送单目的FBA仓;fees附reviewStatus、sourceStatus、voidStatus(NOT_REQUESTED/PENDING_FINANCE/FINANCE_ATTENTION/CONFIRMED),不得把待财务确认或待处理显示为已作废。feeCoverage为可空的原费用服务覆盖确认快照,只包含当前派送所属货物、引用的原费用版本及服务、确认人和时间;不代表新增收费或原费用已付款。', content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryOrderDetailResponse' } } } }, '404': { description: 派送单不存在 } }
/api/platform/v1/last-leg/exception-orders:
get:
summary: 分页查询异常订单
@@ -1052,7 +1052,7 @@ paths:
- { name: deliveryMethod, in: query, schema: { type: string } }
- { name: plannedFrom, in: query, schema: { type: string, format: date-time } }
- { name: plannedTo, in: query, schema: { type: string, format: date-time } }
responses: { '200': { description: 当前客户派送单分页 }, '403': { description: 缺少客户派送查看权限 } }
responses: { '200': { description: 当前客户派送单分页, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryOrderPageResponse' } } } }, '403': { description: 缺少客户派送查看权限 } }
post:
summary: 客户从自己的排车单创建派送单
description: 权限 merchant:warehouse:delivery:create;客户范围只取认证 Token,dispatchNo 必填,不接收 customerId;同一幂等键或已转换排车重复调用返回既有单据。已关联车次时请求不得覆盖车次维护的车牌、司机和电话。
@@ -1066,7 +1066,7 @@ paths:
description: 权限 merchant:warehouse:delivery:list;列表权限包含详情只读,客户范围只取认证 Token,不接收 customerId。
security: [{ bearerAuth: [] }]
parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 当前客户派送单详情 }, '403': { description: 缺少客户派送查看权限 }, '404': { description: 当前客户下派送单不存在 } }
responses: { '200': { description: 当前客户派送单详情, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryOrderDetailResponse' } } } }, '403': { description: 缺少客户派送查看权限 }, '404': { description: 当前客户下派送单不存在 } }
/api/merchant/v1/last-leg/orders:
get:
summary: 客户分页查询自己的海外尾程订单
@@ -1748,6 +1748,35 @@ paths:
responses: { '200': { description: 退货箱唛打印数据 }, '404': { description: 当前仓没有该退货箱 } }
components:
schemas:
DeliveryOrderRow:
type: object
required: [deliveryNo, packageCount, signedPackages]
properties:
deliveryNo: { type: string }
packageCount: { type: integer, minimum: 0 }
signedPackages:
type: integer
nullable: true
minimum: 0
description: 数据库冻结的实际签收箱数;字段始终返回,null 表示未知,0 与正整数保留原值,不以发货箱数替代。
signedAt: { type: string, format: date-time, nullable: true }
DeliveryOrderPageResponse:
type: object
properties:
code: { type: string }
data:
type: object
properties:
records: { type: array, items: { $ref: '#/components/schemas/DeliveryOrderRow' } }
DeliveryOrderDetailResponse:
type: object
properties:
code: { type: string }
data:
type: object
required: [order]
properties:
order: { $ref: '#/components/schemas/DeliveryOrderRow' }
ReturnOrderCreate:
type: object
required: [overseasWarehouseId, needRelabel, idempotencyKey, items]
@@ -61,12 +61,28 @@ class DeliveryLongIdJsonContractTest {
});
}
private DeliveryResponses.DeliveryRow deliveryRow() {
@Test
void signedPackagesMustPreserveNullZeroAndPositiveDatabaseFacts() {
contextRunner.run(context -> {
ObjectMapper objectMapper = context.getBean(ObjectMapper.class);
objectMapper.setSerializationInclusion(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL);
for (Integer count : new Integer[] {null, 0, 3}) {
JsonNode json = objectMapper.readTree(objectMapper.writeValueAsString(deliveryRow(count)));
assertThat(json.has("signedPackages")).isTrue();
if (count == null) assertThat(json.get("signedPackages").isNull()).isTrue();
else assertThat(json.get("signedPackages").intValue()).isEqualTo(count);
}
});
}
private DeliveryResponses.DeliveryRow deliveryRow() { return deliveryRow(null); }
private DeliveryResponses.DeliveryRow deliveryRow(Integer signedPackages) {
return new DeliveryResponses.DeliveryRow(
DELIVERY_ID,
null, null, "PS-1", null, null, null, null, "CREATED", null,
null, null, null, null, null, null, null, null, null, null, null,
0, 0, null, null, null, null, null, null, null, null, 0, java.util.List.of(), null,
0, 0, signedPackages, null, null, null, null, null, null, null, null, 0, java.util.List.of(), null,
com.baiyue.lastleg.delivery.service.DeliveryDocumentSnapshot.summary(null), null, null, null,
null, null, null, null, null);
}
@@ -34,7 +34,8 @@ class DeliveryCompletedOutboxServiceTest {
delivery.setPackageCount(10); delivery.setSignedPackages(10);
service.publish(delivery, List.of(order(11, "101", "201", "IN-1", "ONT8"),
order(12, "102", "202", "IN-2", "ACY2")), proofs(), Instant.parse("2026-09-15T08:00:00Z"));
order(12, "102", "202", "IN-2", "ACY2")), proofs(),
java.time.LocalDateTime.of(2026, 10, 3, 22, 13).atZone(java.time.ZoneId.of("Asia/Shanghai")).toInstant());
ArgumentCaptor<LastlegEventOutbox> rows = ArgumentCaptor.forClass(LastlegEventOutbox.class);
verify(mapper, times(2)).insert(rows.capture());
@@ -48,6 +49,13 @@ class DeliveryCompletedOutboxServiceTest {
.containsExactly("101", "102");
assertThat(payloads).extracting(OverseasDeliveryCompletedV1::fbaWarehouseCode)
.containsExactly("ONT8", "ACY2");
assertThat(payloads).extracting(OverseasDeliveryCompletedV1::completedAt)
.containsOnly(Instant.parse("2026-10-03T14:13:00Z"));
for (var row : rows.getAllValues()) {
assertThat(json.readValue(row.getPayloadJson(), EventEnvelope.class).occurredAt())
.isEqualTo(Instant.parse("2026-10-03T14:13:00Z"));
assertThat(row.getCreatedAt()).isEqualTo(java.time.LocalDateTime.of(2026, 10, 3, 14, 13));
}
}
@Test
@@ -18,6 +18,13 @@ import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.MockMultipartFile;
import java.time.LocalDateTime;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.TimeZone;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.junit.jupiter.api.parallel.ResourceLock;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
@@ -174,6 +181,27 @@ class DeliveryDocumentServiceTest {
assertThat(order.getStatus()).isEqualTo("WAIT_DELIVERY");
assertThat(order.getVersion()).isZero();
}
@ParameterizedTest
@ValueSource(strings = { "Asia/Shanghai", "America/Los_Angeles" })
@ResourceLock("java.util.TimeZone.default")
void signatureEventUsesTheExecutionZoneInsteadOfReinterpretingLocalTimeAsUtc(String zone) {
TimeZone previous = TimeZone.getDefault();
try {
TimeZone.setDefault(TimeZone.getTimeZone(zone));
order.setStatus("WAIT_DELIVERY");
var outbound = new OutboundOrder(); outbound.setStatus("OUTBOUNDED");
when(outboundOrders.findByNo("100", "OUT-1")).thenReturn(outbound);
when(orders.lockByNo("100", "DL-1")).thenReturn(order);
when(files.metadata("100", 10, RECEIPT, 13)).thenReturn(file(13));
Instant before = Instant.now();
service.save("100", "DL-1", "9", request(0, "receipt-zone", List.of(new Binding(RECEIPT, List.of(13L)))));
ArgumentCaptor<Instant> completed = ArgumentCaptor.forClass(Instant.class);
verify(deliveryCompletedOutbox).publish(eq(order), anyList(), anyMap(), completed.capture());
assertThat(completed.getValue()).isEqualTo(order.getSignedAt().atZone(ZoneId.of(zone)).toInstant());
assertThat(completed.getValue()).isBetween(before, Instant.now());
assertThat(completed.getValue()).isNotEqualTo(order.getSignedAt().toInstant(ZoneOffset.UTC));
} finally { TimeZone.setDefault(previous); }
}
@Test void bolMayBePreparedBeforeOutboundWithoutCompletingDelivery() {
order.setStatus("WAIT_DELIVERY");
when(orders.lockByNo("100","DL-1")).thenReturn(order);
@@ -544,6 +544,18 @@ class DeliveryTransferServiceTest {
verify(tailOrders).selectBatchIds(List.of(901L));
}
@Test
void deliveryRowMapsOnlyTheNullableStoredSignedQuantityWithoutShipmentFallback() {
DeliveryOrder order = new DeliveryOrder();
order.setId(401L); order.setDeliveryNo("DEL-401"); order.setPackageCount(9);
for (Integer signed : new Integer[] {null, 0, 3}) {
order.setSignedPackages(signed);
DeliveryResponses.DeliveryRow row = org.springframework.test.util.ReflectionTestUtils.invokeMethod(service, "deliveryRow", order);
assertEquals(signed, row.signedPackages());
assertEquals(9, row.packageCount());
}
}
@Test
void globalOutboundCancelIsIdempotentForAnUnexecutedOrder() {
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key");
@@ -65,6 +65,29 @@ function fixture(fault = '') {
}
const run = f => verifyFleetReturnRedispatch(f.context, 'test', f.trip, f.input, f.dependencies)
for (const signedPackages of [undefined, null, '5', -1, 1.5]) test(`unknown or invalid original signed quantity ${signedPackages} stops before redispatch`, async () => {
const f = fixture(), save = f.dependencies.saveReceiptAndComplete
f.dependencies.saveReceiptAndComplete = async (...args) => {
const result = await save(...args)
result.order.signedPackages = signedPackages
return result
}
f.dependencies.prepareFleetTrip = () => assert.fail('invalid signed quantity must stop before preparing redispatch')
await assert.rejects(run(f), /stored quantity/)
assert.equal(f.trip.returnRedispatch.originalSignatures.length, 0)
})
for (const signedPackages of [undefined, null, '2', 0, 3]) test(`redispatch requires actual numeric two signed boxes: ${signedPackages}`, async () => {
const f = fixture(), save = f.dependencies.saveReceiptAndComplete
f.dependencies.saveReceiptAndComplete = async (...args) => {
const result = await save(...args)
if (args[1].deliveryNo === 'DL-NEW') result.order.signedPackages = signedPackages
return result
}
f.dependencies.finishFleetTrip = () => assert.fail('invalid new signature must not release trip resources')
await assert.rejects(run(f), /two boxes actually signed/)
})
test('reviewed candidate recovery retains DELIVERING and never repeats original delivery start', async () => {
const f = fixture(); f.input.sourceSelectionRecovery = true
assert.equal((await run(f)).status, 'HTTP_CHAIN_VERIFIED_SOURCE_EVENT_EVIDENCE_REQUIRED')
@@ -154,7 +154,8 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input,
for (const [index, number] of input.deliveryNos.entries()) {
const current = await delivery(number)
const signed = await deps.saveReceiptAndComplete(io, current.order, customerId, 171 + index)
check('Original remaining cargo actually signed', signed?.order?.signedAt)
check('Original remaining cargo actually signed with a stored quantity', signed?.order?.signedAt
&& Number.isSafeInteger(signed.order.signedPackages) && signed.order.signedPackages >= 0)
proof.originalSignatures.push(copy(signed.order))
}
const beforeA = await tail(a.relation), beforeB = await tail(b.relation)
@@ -201,12 +202,12 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input,
Object.assign(proof.preSignatureGate, { status: 'VERIFIED', audit })
await context.checkpoint()
const newSignature = await deps.saveReceiptAndComplete(io, newDelivery.order, customerId, 271)
check('Redispatched two boxes actually signed', newSignature?.order?.signedAt && Number(newSignature.order.signedPackages) === 2)
check('Redispatched two boxes actually signed', newSignature?.order?.signedAt && newSignature.order.signedPackages === 2)
const afterA = await tail(a.relation), afterB = await tail(b.relation)
check('A completes only after redispatch; B remains complete', afterA.order.subOrderStatus?.includes('DELIVERY:DELIVERED') && afterB.order.subOrderStatus === beforeB.order.subOrderStatus)
for (const original of proof.originalSignatures) {
const current = (await delivery(original.deliveryNo)).order
check('Original signed facts stay immutable', current.signedAt === original.signedAt && Number(current.signedPackages) === Number(original.signedPackages) && Number(current.packageCount) === Number(original.packageCount))
check('Original signed facts stay immutable', current.signedAt === original.signedAt && current.signedPackages === original.signedPackages && Number(current.packageCount) === Number(original.packageCount))
}
proof.afterRedispatch = { A: afterA, B: afterB, newSignature, departed }
proof.domesticAfterRedispatch = await domesticBoundary(a, b, true)