fix: close phase4 overseas acceptance gaps

This commit is contained in:
2026-08-24 21:00:22 +08:00
parent 005c3f54e2
commit 3607bebbfd
42 changed files with 860 additions and 203 deletions
@@ -47,7 +47,7 @@ class ReceivingContainerContractTest {
}
@Test
void schemaContainsCustomerScopedPlanChildrenAndContainerSessionLocks() throws IOException {
void schemaContainsCustomerScopedPlanChildrenAndGlobalPlatformPickerLocks() throws IOException {
String schema = resource("db/lastleg.sql").toLowerCase();
String unified = java.nio.file.Files.readString(
java.nio.file.Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"), StandardCharsets.UTF_8).toLowerCase();
@@ -62,7 +62,10 @@ class ReceivingContainerContractTest {
assertTrue(schema.contains("create table if not exists ll_receiving_container_session"));
assertTrue(schema.contains("unique key uk_ll_receiving_active_operator(active_operator_key)"));
assertTrue(schema.contains("unique key uk_ll_receiving_active_container(active_container_key)"));
assertTrue(schema.contains("平台操作账号全局唯一锁键"));
assertTrue(schema.contains("柜号全局唯一锁键"));
assertTrue(schema.contains("create table if not exists ll_receiving_container_operation"));
assertTrue(schema.contains("unique key uk_ll_receiving_operation(operator_key,idempotency_key)"));
assertTrue(schema.contains("create table if not exists ll_receiving_sku_receipt"));
assertTrue(schema.contains("unique key uk_ll_receiving_sku_pallet (customer_id,line_id,pallet_id,sku_code)"));
String receivingSession = tableBlock(schema, "ll_receiving_container_session");
@@ -75,6 +78,11 @@ class ReceivingContainerContractTest {
assertTrue(unified.contains("customer_id is null or v.customer_id=''"));
assertTrue(unified.contains("customer_id is null or a.customer_id=''"));
assertTrue(unified.contains("signal sqlstate '45000'"));
assertTrue(unified.contains("assert_receiving_global_locks_migratable"));
assertTrue(unified.contains("concat('operator:',operator_key)"));
assertTrue(unified.contains("concat('container:',upper(trim(container_no)))"));
assertTrue(unified.contains("assert_receiving_operation_global_key_migratable"));
assertTrue(unified.contains("group_concat(column_name order by seq_in_index)= 'operator_key,idempotency_key'"));
}
@Test
@@ -94,6 +102,8 @@ class ReceivingContainerContractTest {
assertTrue(successResponses.stream().allMatch(line ->
line.contains("content: { application/json: { schema: { $ref: '#/components/schemas/Receiving")));
assertTrue(receivingPaths.contains("返回 WAIT_SIGN、SIGNED 或 RECEIVING 当前柜"));
assertTrue(receivingPaths.contains("仅 PLATFORM WAREHOUSE_PICKER 可用"));
assertFalse(receivingPaths.contains("客户与操作账号均从 PDA Token 获取"));
assertTrue(yaml.contains("enum: [LICENSE_PLATE, CONTAINER_SEAL, DOOR_OPEN]"));
assertFalse(receivingPaths.contains("photoFileIds"));
}
@@ -30,25 +30,32 @@ class ReceivingContainerControllerTest {
}
@Test
void candidatesDerivesCustomerAndOperatorOnlyFromPdaIdentity() {
when(service.candidates("100", "42")).thenReturn(List.of(new ReceivingContainerResponses.Candidate(
void candidatesDerivesOperatorFromPlatformWarehousePickerIdentity() {
when(service.candidates("42")).thenReturn(List.of(new ReceivingContainerResponses.Candidate(
"CONT-1", "WAIT_SIGN", 2, 2, List.of("ONT8"), 4, 20, 0, false)));
controller.candidates(pda());
controller.candidates(picker());
verify(service).candidates("100", "42");
verify(service).candidates("42");
}
@Test
void platformIdentityCannotEnterPdaReceivingApi() {
AuthenticatedUser platform = new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB",
Set.of("lastleg:pda:operate"));
assertThatThrownBy(() -> controller.candidates(platform)).isInstanceOf(ResponseStatusException.class)
void merchantIdentityCannotEnterPdaReceivingApiEvenWithForgedAuthority() {
AuthenticatedUser merchant = new AuthenticatedUser(7L, 100L, 100L, 8L, "merchant", "MERCHANT", "WEB",
Set.of(ReceivingContainerController.OPERATE_PERMISSION));
assertThatThrownBy(() -> controller.candidates(merchant)).isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("401");
}
private static AuthenticatedUser pda() {
return new AuthenticatedUser(42L, 100L, 100L, 8L, "receiver", "PDA", "PDA",
Set.of("lastleg:pda:operate"));
@Test
void platformIdentityWithoutPickerAuthorityIsForbidden() {
AuthenticatedUser platform = new AuthenticatedUser(7L, 0L, 0L, 8L, "admin", "PLATFORM", "WEB", Set.of());
assertThatThrownBy(() -> controller.candidates(platform)).isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("403");
}
private static AuthenticatedUser picker() {
return new AuthenticatedUser(42L, 0L, 0L, 8L, "receiver", "PLATFORM", "PDA",
Set.of(ReceivingContainerController.OPERATE_PERMISSION));
}
}
@@ -35,6 +35,7 @@ import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
@@ -87,35 +88,56 @@ class ReceivingContainerServiceBehaviorTest {
when(lines.selectList(any())).thenReturn(List.of());
when(sessions.insert(any(ReceivingContainerSession.class))).thenReturn(1);
when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1);
service.prepare("100", "7", "receiver", "CONT-A", new ReceivingContainerRequests.Action("same-key"));
service.prepare("7", "receiver", "CONT-A", new ReceivingContainerRequests.Action("same-key"));
ArgumentCaptor<ReceivingContainerSession> selected = ArgumentCaptor.forClass(ReceivingContainerSession.class);
verify(sessions).insert(selected.capture());
assertThat(selected.getValue().getCustomerId()).isEqualTo("100");
assertThat(selected.getValue().getActiveOperatorKey()).isEqualTo("OPERATOR:7");
assertThat(selected.getValue().getActiveContainerKey()).isEqualTo("CONTAINER:CONT-A");
ArgumentCaptor<ReceivingContainerOperation> saved = ArgumentCaptor.forClass(ReceivingContainerOperation.class);
verify(operations).insert(saved.capture());
when(operations.find("100", "7", "same-key")).thenReturn(saved.getValue());
when(operations.find("7", "same-key")).thenReturn(saved.getValue());
assertThatThrownBy(() -> service.prepare("100", "7", "receiver", "CONT-B",
assertThatThrownBy(() -> service.prepare("7", "receiver", "CONT-B",
new ReceivingContainerRequests.Action("same-key"))).isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("409");
}
@Test
void pickerCandidatesSpanCustomersButEachContainerMustResolveOneCustomer() {
InboundPlan first = plan("CONT-A");
InboundPlan second = plan("CONT-B"); second.setId(3L); second.setCustomerId("200");
when(plans.selectList(any())).thenReturn(List.of(first, second), List.of(first), List.of(second));
when(lines.selectList(any())).thenReturn(List.of());
assertThat(service.candidates("7")).extracting("containerNo").containsExactly("CONT-A", "CONT-B");
InboundPlan ambiguous = plan("CONT-A"); ambiguous.setId(4L); ambiguous.setCustomerId("200");
org.mockito.Mockito.reset(plans, sessions);
when(plans.selectList(any())).thenReturn(List.of(first, ambiguous));
assertThatThrownBy(() -> service.candidates("7")).isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("exactly one customer");
}
@Test
void waitSignSelectionAlreadyLocksAccountFromViewingAnotherContainer() {
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B")));
when(sessions.findActiveByOperator("100", "100:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
assertThatThrownBy(() -> service.detail("100", "7", "CONT-B"))
when(sessions.findActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
assertThatThrownBy(() -> service.detail("7", "CONT-B"))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
}
@Test
void prepareEnforcesBothActiveAccountAndActiveContainerUniqueness() {
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B")));
when(sessions.lockActiveByOperator("100", "100:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
assertThatThrownBy(() -> service.prepare("100", "7", "receiver", "CONT-B",
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
assertThatThrownBy(() -> service.prepare("7", "receiver", "CONT-B",
new ReceivingContainerRequests.Action("account-lock"))).isInstanceOf(ResponseStatusException.class);
verify(sessions, never()).insert(any(ReceivingContainerSession.class));
org.mockito.Mockito.reset(sessions, operations);
when(sessions.lockActiveByContainer("100", "100:CONT-B")).thenReturn(session("CONT-B", "WAIT_SIGN"));
assertThatThrownBy(() -> service.prepare("100", "7", "receiver", "CONT-B",
when(sessions.lockActiveByContainer("CONTAINER:CONT-B")).thenReturn(session("CONT-B", "WAIT_SIGN"));
assertThatThrownBy(() -> service.prepare("7", "receiver", "CONT-B",
new ReceivingContainerRequests.Action("container-lock"))).isInstanceOf(ResponseStatusException.class);
verify(sessions, never()).insert(any(ReceivingContainerSession.class));
}
@@ -123,7 +145,7 @@ class ReceivingContainerServiceBehaviorTest {
@Test
void destinationModesAreHardRejectedBeforeWarehouseCalls() {
stubCurrent("OVERSEAS", "CONT-A");
assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver",
assertThatThrownBy(() -> service.receiveBulk("7", "receiver",
new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "bulk-key")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verifyNoInteractions(warehouse);
@@ -132,7 +154,7 @@ class ReceivingContainerServiceBehaviorTest {
stubCurrent("FBA", "CONT-A");
InboundPlanCarton carton = carton(1L, "BOX-1");
when(cartons.selectOne(any())).thenReturn(carton);
assertThatThrownBy(() -> service.receiveCarton("100", "7", "receiver",
assertThatThrownBy(() -> service.receiveCarton("7", "receiver",
new ReceivingContainerRequests.CartonReceive("BOX-1", "PAL-1", "carton-key")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verifyNoInteractions(warehouse);
@@ -142,7 +164,7 @@ class ReceivingContainerServiceBehaviorTest {
void crossContainerCartonAndMismatchedPalletAreRejectedBeforeReservation() {
stubCurrent("OVERSEAS", "CONT-A");
when(cartons.selectOne(any())).thenReturn(carton(999L, "BOX-X"));
assertThatThrownBy(() -> service.receiveCarton("100", "7", "receiver",
assertThatThrownBy(() -> service.receiveCarton("7", "receiver",
new ReceivingContainerRequests.CartonReceive("BOX-X", "PAL-1", "cross-key")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verifyNoInteractions(warehouse);
@@ -151,7 +173,7 @@ class ReceivingContainerServiceBehaviorTest {
stubCurrent("FBA", "CONT-A");
stubSku();
when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(101L, "FBA-1"));
assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver",
assertThatThrownBy(() -> service.receiveBulk("7", "receiver",
new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "pallet-key")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any());
@@ -165,7 +187,7 @@ class ReceivingContainerServiceBehaviorTest {
when(pallets.selectList(any())).thenReturn(List.of(planPallet()));
doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "over receipt"))
.when(workflow).validateBulkResolved(any(), any(), any(), anyInt());
assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver",
assertThatThrownBy(() -> service.receiveBulk("7", "receiver",
new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 2, "over-key")))
.isInstanceOf(ResponseStatusException.class);
verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any());
@@ -177,7 +199,7 @@ class ReceivingContainerServiceBehaviorTest {
when(pallets.selectList(any())).thenReturn(List.of(planPallet()));
doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "duplicate carton"))
.when(workflow).validateCartonResolved(any(), any(), any(), any(), any());
assertThatThrownBy(() -> service.receiveCarton("100", "7", "receiver",
assertThatThrownBy(() -> service.receiveCarton("7", "receiver",
new ReceivingContainerRequests.CartonReceive("BOX-1", "PAL-1", "duplicate-key")))
.isInstanceOf(ResponseStatusException.class);
verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any());
@@ -189,11 +211,24 @@ class ReceivingContainerServiceBehaviorTest {
InboundPlanPalletAllocation partial = new InboundPlanPalletAllocation();
partial.setLineId(10L); partial.setPickedPackageCount(1);
when(allocations.selectList(any())).thenReturn(List.of(partial));
assertThatThrownBy(() -> service.complete("100", "7", new ReceivingContainerRequests.Action("complete-key")))
assertThatThrownBy(() -> service.complete("7", new ReceivingContainerRequests.Action("complete-key")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verify(workflow, never()).completeContainer(any(), any());
}
@Test
void completedRequestReplaysWithoutAnActiveCustomerSession() {
ReceivingContainerOperation operation = new ReceivingContainerOperation();
operation.setOperatorKey("7"); operation.setAction("COMPLETE"); operation.setIdempotencyKey("complete-replay");
operation.setRequestHash(hash(new ReceivingContainerRequests.Action("complete-replay")));
operation.setResultJson("{\"containerNo\":\"CONT-A\",\"status\":\"COMPLETED\",\"operatorName\":\"receiver\",\"version\":1,\"photos\":[],\"totalPackages\":2,\"receivedPackages\":2,\"pendingPackages\":0,\"inbounds\":[]}");
when(operations.find("7", "complete-replay")).thenReturn(operation);
assertThat(service.complete("7", new ReceivingContainerRequests.Action("complete-replay")).status())
.isEqualTo("COMPLETED");
verifyNoInteractions(sessions);
}
@Test
void localFailureAfterRemoteReservationTriggersReleaseCompensation() {
stubCurrent("FBA", "CONT-A");
@@ -204,7 +239,7 @@ class ReceivingContainerServiceBehaviorTest {
ResponseStatusException failure = new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "race");
doThrow(failure).when(workflow).receiveBulkResolved(any(), any(), any(), any(), anyInt(), any(), any());
assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver",
assertThatThrownBy(() -> service.receiveBulk("7", "receiver",
new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "compensate-key")))
.isSameAs(failure);
verify(workflow).compensateReservation("100", 55L, failure);
@@ -212,7 +247,7 @@ class ReceivingContainerServiceBehaviorTest {
@Test
void signRequiresEachTypedPhotoExactlyOnceAndRestoresRoles() {
when(sessions.lockActiveByOperator("100", "100:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-A")));
when(lines.selectList(any())).thenReturn(List.of());
when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1);
@@ -222,7 +257,7 @@ class ReceivingContainerServiceBehaviorTest {
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 1L),
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.CONTAINER_SEAL, 2L)), "sign-key");
var response = service.sign("100", "7", "receiver", request);
var response = service.sign("7", "receiver", request);
org.assertj.core.api.Assertions.assertThat(response.photos()).extracting("role")
.containsExactly(ReceivingPhotoRole.LICENSE_PLATE, ReceivingPhotoRole.CONTAINER_SEAL,
@@ -234,26 +269,26 @@ class ReceivingContainerServiceBehaviorTest {
@Test
void signRejectsDuplicateRolesBeforeFileValidation() {
when(sessions.lockActiveByOperator("100", "100:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
ReceivingContainerRequests.Sign request = new ReceivingContainerRequests.Sign(List.of(
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 1L),
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 2L),
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.DOOR_OPEN, 3L)), "invalid-sign");
assertThatThrownBy(() -> service.sign("100", "7", "receiver", request))
assertThatThrownBy(() -> service.sign("7", "receiver", request))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verifyNoInteractions(unloadPhotos);
}
@Test
void signRejectsDuplicateFileIdsBeforeFileValidation() {
when(sessions.lockActiveByOperator("100", "100:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
ReceivingContainerRequests.Sign request = new ReceivingContainerRequests.Sign(List.of(
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 1L),
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.CONTAINER_SEAL, 1L),
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.DOOR_OPEN, 3L)), "invalid-files");
assertThatThrownBy(() -> service.sign("100", "7", "receiver", request))
assertThatThrownBy(() -> service.sign("7", "receiver", request))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verifyNoInteractions(unloadPhotos);
}
@@ -267,7 +302,7 @@ class ReceivingContainerServiceBehaviorTest {
received.setReceivedPackages(2);
when(skuReceipts.listLineSku("100", 10L, "SKU-1")).thenReturn(List.of(received));
assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver",
assertThatThrownBy(() -> service.receiveBulk("7", "receiver",
new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "sku-over")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verifyNoInteractions(warehouse);
@@ -277,7 +312,7 @@ class ReceivingContainerServiceBehaviorTest {
InboundPlan plan = plan(containerNo);
InboundPlanLine line = line(destinationType);
LastlegOrder order = order(destinationType);
when(sessions.lockActiveByOperator("100", "100:7")).thenReturn(session(containerNo, "SIGNED"));
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session(containerNo, "SIGNED"));
when(plans.selectList(any())).thenReturn(List.of(plan));
when(lines.selectList(any())).thenReturn(List.of(line));
when(orders.selectById(1L)).thenReturn(order);
@@ -342,4 +377,17 @@ class ReceivingContainerServiceBehaviorTest {
return new WarehouseResourceGateway.WarehousePalletReference(30L, "PAL-1", warehouseId, 40L,
"AVAILABLE", fbaCode, 0);
}
private String hash(Object value) {
try {
String json = new ObjectMapper().findAndRegisterModules().copy()
.configure(com.fasterxml.jackson.databind.MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true)
.configure(com.fasterxml.jackson.databind.SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true)
.writeValueAsString(value);
return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256")
.digest(json.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
} catch (Exception exception) {
throw new AssertionError(exception);
}
}
}
@@ -12,6 +12,7 @@ import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -108,9 +109,12 @@ class InventorySchemaContractTest {
assertThrowsNoField(InventoryPosition.class, "productQuantity");
assertNotNull(InventoryLedger.class.getDeclaredField("skuCode"));
assertNotNull(InventoryLedger.class.getDeclaredField("cartonMark"));
assertNotNull(InventoryLedger.class.getDeclaredField("beforeQuantity"));
assertNotNull(InventoryLedger.class.getDeclaredField("deltaQuantity"));
assertNotNull(InventoryLedger.class.getDeclaredField("afterQuantity"));
for (String field : List.of("beforeQuantity", "deltaQuantity", "afterQuantity")) {
var annotation = InventoryLedger.class.getDeclaredField(field)
.getAnnotation(com.baomidou.mybatisplus.annotation.TableField.class);
assertNotNull(annotation, field);
assertFalse(annotation.exist(), field);
}
}
private static void assertThrowsNoField(Class<?> type, String fieldName) {
@@ -1,5 +1,7 @@
package com.baiyue.lastleg.procurement.service;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baiyue.lastleg.procurement.dto.OverseasProcurementRequests;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder;
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper;
@@ -21,6 +23,7 @@ import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
@@ -155,4 +158,15 @@ class OverseasProcurementServiceTest {
order.setUpdatedAt(order.getCreatedAt());
return order;
}
@Test
void nullablePatchFieldsArePersistedWhenExplicitlyCleared() throws Exception {
for (String field : List.of("productSpecification", "productUrl", "purchaseWarehouseId",
"purchaseWarehouseName", "domesticCarrier", "domesticTrackingNo", "overseasCarrier",
"overseasTrackingNo", "estimatedArrivalAt", "remark")) {
TableField annotation = OverseasProcurementOrder.class.getDeclaredField(field).getAnnotation(TableField.class);
assertNotNull(annotation, field);
assertEquals(FieldStrategy.ALWAYS, annotation.updateStrategy(), field);
}
}
}