优化
This commit is contained in:
+5
-2
@@ -60,10 +60,12 @@ class ContainerControllerContractTest {
|
||||
.contains("/api/platform/v1/containers/{id}/transport:")
|
||||
.contains("/api/platform/v1/containers/{id}/ocean-booking-link:")
|
||||
.contains("首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜")
|
||||
.contains("尚无装柜计划/PDA/证据/单证的 realContainerBookingId")
|
||||
.contains("RELEASED oceanBookingId")
|
||||
.contains("未满配 requirementId")
|
||||
.contains("唯一 containerNo")
|
||||
.contains("解绑或换绑仅允许完全空的 WAIT_LOADING 模拟柜")
|
||||
.contains("任何排货、PDA、有效装柜证据或业务单证事实均拒绝")
|
||||
.contains("realContainerBookingId")
|
||||
.doesNotContain("realContainerBookingId")
|
||||
.contains("/api/platform/v1/containers/{id}/clearance/document/upload:")
|
||||
.contains("multipart/form-data")
|
||||
.contains("ContainerDocumentUploadRequest")
|
||||
@@ -75,6 +77,7 @@ class ContainerControllerContractTest {
|
||||
.doesNotContain("tenantId")
|
||||
.doesNotContain("accountId");
|
||||
assertThat(openapi)
|
||||
.contains("required: [oceanBookingId, requirementId, containerNo, expectedVersion]")
|
||||
.contains("required: [id, bookingNo, cabinetNo, sourceType, recordStatus")
|
||||
.contains("sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING]")
|
||||
.contains("recordStatus: {type: string, enum: [ACTIVE, MERGED]")
|
||||
|
||||
+5
@@ -45,6 +45,11 @@ class PlatformSmartLoadingControllerContractTest {
|
||||
.contains("SmartLoadingDeleteRequest:")
|
||||
.contains("SmartLoadingWarehouseRemarkRequest:")
|
||||
.contains("/items/{itemId}/warehouse-remark:")
|
||||
.contains("需要 platform:firstleg:preload:edit 权限")
|
||||
.contains("warehouseRemark: {type: string, nullable: true, maxLength: 1000")
|
||||
.contains("成功响应中的 version 是递增后的 planVersion")
|
||||
.contains("items[].version 是递增后的 itemVersion")
|
||||
.contains("数量调整或动态增量不会覆盖已有 warehouseRemark")
|
||||
.contains("canAssign: {type: boolean}")
|
||||
.contains("canEdit: {type: boolean}")
|
||||
.contains("pickedPackageCount: {type: integer, minimum: 0}")
|
||||
|
||||
+8
-26
@@ -23,10 +23,9 @@ class PlatformOceanBookingControllerContractTest {
|
||||
Map.entry("channels", "platform:firstleg:ocean-booking:list"),
|
||||
Map.entry("detail", "platform:firstleg:ocean-booking:list"),
|
||||
Map.entry("create", "platform:firstleg:ocean-booking:create"),
|
||||
Map.entry("release", "platform:firstleg:ocean-booking:release"),
|
||||
Map.entry("cancel", "platform:firstleg:ocean-booking:cancel"),
|
||||
Map.entry("requestChange", "platform:firstleg:ocean-booking:change"),
|
||||
Map.entry("confirmChange", "platform:firstleg:ocean-booking:change"));
|
||||
Map.entry("updateDraft", "platform:firstleg:ocean-booking:edit"),
|
||||
Map.entry("deleteDraft", "platform:firstleg:ocean-booking:delete"),
|
||||
Map.entry("release", "platform:firstleg:ocean-booking:release"));
|
||||
permissions.forEach((name, permission) -> {
|
||||
Method method = method(name);
|
||||
assertThat(method.getAnnotation(PreAuthorize.class).value())
|
||||
@@ -51,28 +50,11 @@ class PlatformOceanBookingControllerContractTest {
|
||||
String yaml = Files.readString(Path.of("src/main/resources/openapi/ocean-bookings-v1.yaml"));
|
||||
assertThat(yaml).contains("/api/platform/v1/ocean-bookings/channels:",
|
||||
"originWarehouseId", "platform:firstleg:ocean-booking:list", "ChannelOption")
|
||||
.contains("required: [channelCode, warehouseId, billOfLadingNo, vesselName, voyageNo, serviceTerm, freightTerm, estimatedSailingAt, estimatedArrivalAt, parties, containers]")
|
||||
.contains("containerNo: {type: string, maxLength: 64")
|
||||
.contains("containers: {type: array, minItems: 1, maxItems: 20")
|
||||
.contains("required: [containerBookingId, cabinetNo, containerNo, requirementId, allocationId")
|
||||
.contains("warehouseName is resolved and frozen server-side")
|
||||
.contains("shippingCompanyId:", "departurePortCode:", "destinationPortCode:",
|
||||
"placeOfDelivery:", "remark:", "PartyRequest:")
|
||||
.contains("/{oceanBookingId}/cancel:", "/{oceanBookingId}/change-request:",
|
||||
"/{oceanBookingId}/change-confirm:")
|
||||
.contains("persists the booking directly as CONFIRMED")
|
||||
.contains("RELEASED remains a separate later action")
|
||||
.contains("request must repeat the same bill of lading, vessel, voyage, ETD and ETA values; mismatches are rejected")
|
||||
.contains("CONFIRMED, CHANGE_PENDING, RELEASED, CANCELLED")
|
||||
.contains("DRAFT is internal to the create transaction and legacy SUBMITTED/FAILED values are invalid")
|
||||
.contains("customerId: {type: integer, format: int64, minimum: 1, nullable: true")
|
||||
.contains("customerName: {type: string, maxLength: 128, nullable: true")
|
||||
.contains("serviceTerm: {type: string, maxLength: 32, description: Required free-text")
|
||||
.contains("freightTerm: {type: string, maxLength: 32, description: Required free-text")
|
||||
.contains("billOfLadingNo: {type: string, maxLength: 128, description: Required offline-confirmed")
|
||||
.contains("estimatedArrivalAt: {type: string, format: date-time, description: Required offline-confirmed ETA")
|
||||
.doesNotContain("Must be omitted until release")
|
||||
.doesNotContain("required: [customerId, customerName, channelCode, channelName");
|
||||
.contains("Create an editable DRAFT", "Replace a DRAFT", "Delete a DRAFT")
|
||||
.contains("Validate and permanently release a DRAFT")
|
||||
.contains("containerRequirements:", "ContainerRequirementRequest:")
|
||||
.contains("never requires or creates a real container number")
|
||||
.doesNotContain("change-request", "change-confirm", "/cancel:", "containers[].containerNo");
|
||||
}
|
||||
|
||||
private static Method method(String name) {
|
||||
|
||||
+2
-4
@@ -33,9 +33,7 @@ class OceanBookingMapperContractTest {
|
||||
.reduce("", (left, right) -> left + '\n' + right);
|
||||
assertThat(sql)
|
||||
.contains("status=#{fromStatus} AND version=#{expectedVersion}")
|
||||
.contains("status='CONFIRMED' AND version=#{expectedVersion}")
|
||||
.contains("status='CHANGE_PENDING' AND version=#{expectedVersion}")
|
||||
.doesNotContain("SUBMITTED")
|
||||
.doesNotContain("FAILED");
|
||||
.contains("status='DRAFT' AND version=#{version}")
|
||||
.doesNotContain("CONFIRMED", "CHANGE_PENDING", "CANCELLED", "SUBMITTED", "FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
+13
-17
@@ -22,39 +22,34 @@ class OceanBookingSqlContractTest {
|
||||
.contains("customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'")
|
||||
.contains("MODIFY COLUMN customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照'")
|
||||
.contains("MODIFY COLUMN customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'")
|
||||
.contains("service_term VARCHAR(32) NOT NULL COMMENT '运输服务条款快照'")
|
||||
.contains("freight_term VARCHAR(32) NOT NULL COMMENT '海运费支付条款快照'")
|
||||
.contains("service_term VARCHAR(32) NULL COMMENT '运输服务条款快照;发布前必填'")
|
||||
.contains("freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照;发布前必填'")
|
||||
.contains("container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '明确引用现有柜次ID'")
|
||||
.contains("REFERENCES fl_container_booking(id)")
|
||||
.contains("REFERENCES fl_ocean_booking(id) ON DELETE CASCADE")
|
||||
.doesNotContain("ALTER TABLE fl_container_booking");
|
||||
}
|
||||
|
||||
@Test
|
||||
void enforcesStateIdempotencyAndAuditContracts() throws Exception {
|
||||
assertThat(sql())
|
||||
.contains("OLD.status='DRAFT' AND NEW.status='CONFIRMED'")
|
||||
.contains("OLD.status='CONFIRMED' AND NEW.status IN ('RELEASED','CHANGE_PENDING','CANCELLED')")
|
||||
.contains("OLD.status='CHANGE_PENDING' AND NEW.status IN ('CONFIRMED','CANCELLED')")
|
||||
.contains("OLD.status IN ('RELEASED','CANCELLED')")
|
||||
.contains("OLD.status='DRAFT' AND NEW.status='RELEASED'")
|
||||
.contains("OLD.status = 'RELEASED'")
|
||||
.contains("UNIQUE KEY uk_ocean_operation (operator_id,operation,idempotency_key)")
|
||||
.contains("request_hash CHAR(64) NOT NULL")
|
||||
.contains("('CONFIRMED','RELEASED') THEN")
|
||||
.doesNotContain("('CONFIRMED','CHANGE_PENDING') THEN")
|
||||
.contains("<> 'RELEASED' THEN")
|
||||
.contains("订舱状态历史不可修改")
|
||||
.contains("UNIQUE KEY uk_ocean_allocation_container (container_booking_id)");
|
||||
.contains("UNIQUE KEY uk_ocean_allocation_container (container_booking_id)")
|
||||
.doesNotContain("CONFIRMED", "CHANGE_PENDING", "CANCELLED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void reconcilesExistingDatabasesWithoutGuessingLegacyBusinessData() throws Exception {
|
||||
assertThat(sql())
|
||||
.contains("reconcile_ocean_booking_legacy")
|
||||
.contains("service_term/freight_term 为空的真实行")
|
||||
.contains("status IN ('DRAFT','SUBMITTED','FAILED')")
|
||||
.contains("不会伪造 CONFIRMED")
|
||||
.contains("DROP CHECK chk_ocean_booking_status")
|
||||
.contains("ADD CONSTRAINT chk_ocean_booking_status CHECK")
|
||||
.contains("DROP COLUMN failure_reason")
|
||||
.doesNotContain("UPDATE fl_ocean_booking SET status='CONFIRMED'");
|
||||
.doesNotContain("UPDATE fl_ocean_booking SET status=");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -62,11 +57,12 @@ class OceanBookingSqlContractTest {
|
||||
assertThat(sql())
|
||||
.contains("platform:firstleg:ocean-booking:list")
|
||||
.contains("platform:firstleg:ocean-booking:create")
|
||||
.contains("platform:firstleg:ocean-booking:edit")
|
||||
.contains("platform:firstleg:ocean-booking:delete")
|
||||
.contains("platform:firstleg:ocean-booking:release")
|
||||
.contains("platform:firstleg:ocean-booking:cancel")
|
||||
.contains("platform:firstleg:ocean-booking:change")
|
||||
.contains("platform:firstleg:ocean-booking:allocate")
|
||||
.doesNotContain("platform:firstleg:ocean-booking:view");
|
||||
.doesNotContain("platform:firstleg:ocean-booking:view",
|
||||
"platform:firstleg:ocean-booking:cancel", "platform:firstleg:ocean-booking:change");
|
||||
}
|
||||
|
||||
private static String sql() throws Exception {
|
||||
|
||||
+169
-350
@@ -1,39 +1,27 @@
|
||||
package com.baiyue.firstleg.oceanbooking.service;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.client.WarehouseMasterDataClient;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ActionRequest;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ChangeRequest;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ContainerRequest;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.CreateRequest;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Detail;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.DraftData;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Query;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.PartyRequest;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ReleaseRequest;
|
||||
import com.baiyue.firstleg.service.LoadingPickService;
|
||||
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
|
||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||
import com.baiyue.firstleg.client.WarehouseSupplierClient;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.*;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingOperationEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRepository;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
|
||||
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
@@ -45,392 +33,223 @@ import static org.mockito.Mockito.*;
|
||||
class OceanBookingServiceTest {
|
||||
@Mock private OceanBookingRepository repository;
|
||||
@Mock private PriceBookQueryRepository priceBooks;
|
||||
@Mock private LoadingPickService loadingPickService;
|
||||
@Mock private WarehouseSupplierSnapshotService suppliers;
|
||||
@Mock private WarehouseMasterDataClient warehouses;
|
||||
@InjectMocks private OceanBookingService service;
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
objectMapper = new ObjectMapper().findAndRegisterModules();
|
||||
var field = OceanBookingService.class.getDeclaredField("objectMapper");
|
||||
field.setAccessible(true); field.set(service, new ObjectMapper().findAndRegisterModules());
|
||||
field.setAccessible(true);
|
||||
field.set(service, objectMapper);
|
||||
when(repository.parties(anyLong())).thenReturn(List.of());
|
||||
when(repository.requirements(anyLong())).thenReturn(List.of());
|
||||
when(repository.containers(anyLong())).thenReturn(List.of());
|
||||
when(repository.allocations(anyLong())).thenReturn(List.of());
|
||||
when(repository.histories(anyLong())).thenReturn(List.of());
|
||||
when(suppliers.booking(any(), any())).thenReturn(null);
|
||||
when(warehouses.active(any(), eq(0L), eq(701L))).thenReturn(ApiResponse.success(
|
||||
new WarehouseMasterDataClient.Detail(new WarehouseMasterDataClient.Warehouse(
|
||||
701L, 0L, "SZX", "Warehouse深圳中转仓", "DOMESTIC", "ACTIVE"))));
|
||||
when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
|
||||
when(repository.completeOperation(anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(1);
|
||||
when(repository.transition(anyLong(), eq("DRAFT"), eq("CONFIRMED"), isNull(), eq(0),
|
||||
anyLong(), anyString())).thenReturn(1);
|
||||
when(repository.completeOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsCancellationAfterRelease() {
|
||||
when(repository.find(1L)).thenReturn(booking("RELEASED", 4));
|
||||
void savesBlankDraftWithoutCreatingContainersOrAllocations() {
|
||||
AtomicReference<OceanBookingEntity> inserted = stubCreatedDraft();
|
||||
Detail result = service.create(9L, "admin", "draft-empty",
|
||||
new CreateRequest(0, partialDraft(null)));
|
||||
|
||||
assertThatThrownBy(() -> service.cancel(1L, 9L, "admin", "cancel-1",
|
||||
new ActionRequest(4, "错误"))).isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("不可逆");
|
||||
|
||||
verify(repository, never()).transition(anyLong(), anyString(), anyString(), any(), anyInt(), anyLong(), anyString());
|
||||
assertThat(result.oceanBooking().getStatus()).isEqualTo("DRAFT");
|
||||
assertThat(inserted.get().getCustomerId()).isNull();
|
||||
verify(repository).replaceRequirements(inserted.get().getId(), List.of());
|
||||
verify(repository, never()).insertContainer(any());
|
||||
verify(repository, never()).insert(any(com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity.class));
|
||||
verifyNoInteractions(priceBooks, suppliers, warehouses);
|
||||
}
|
||||
|
||||
@Test
|
||||
void opensAllocatedContainerPdaTasksAfterRelease() {
|
||||
OceanBookingEntity confirmed = booking("CONFIRMED", 3);
|
||||
confirmed.setBillOfLadingNo("BL-001"); confirmed.setVesselName("VESSEL");
|
||||
confirmed.setVoyageNo("V001");
|
||||
confirmed.setEstimatedSailingAt(LocalDateTime.of(2026, 9, 1, 0, 0));
|
||||
confirmed.setEstimatedArrivalAt(LocalDateTime.of(2026, 9, 20, 0, 0));
|
||||
OceanBookingEntity released = booking("RELEASED", 4);
|
||||
var allocation = new OceanBookingContainerAllocationEntity();
|
||||
allocation.setContainerBookingId(12L);
|
||||
when(repository.find(1L)).thenReturn(confirmed, released);
|
||||
when(repository.release(eq(1L), any(ReleaseRequest.class), eq(9L), eq("admin"))).thenReturn(1);
|
||||
when(repository.allocations(1L)).thenReturn(List.of(allocation));
|
||||
var request = new ReleaseRequest(3, "BL-001", "VESSEL", "V001",
|
||||
LocalDateTime.of(2026, 9, 1, 0, 0), LocalDateTime.of(2026, 9, 20, 0, 0), "放舱");
|
||||
|
||||
Detail result = service.release(1L, 9L, "admin", "release-1", request);
|
||||
|
||||
assertThat(result.oceanBooking().getStatus()).isEqualTo("RELEASED");
|
||||
verify(loadingPickService).beginIfReady(12L, 9L, "admin");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsReleaseWhenTransportFieldsDifferFromRegisteredSnapshot() {
|
||||
OceanBookingEntity confirmed = booking("CONFIRMED", 3);
|
||||
confirmed.setBillOfLadingNo("BL-001"); confirmed.setVesselName("VESSEL");
|
||||
confirmed.setVoyageNo("V001");
|
||||
confirmed.setEstimatedSailingAt(LocalDateTime.of(2026, 9, 1, 0, 0));
|
||||
confirmed.setEstimatedArrivalAt(LocalDateTime.of(2026, 9, 20, 0, 0));
|
||||
when(repository.find(1L)).thenReturn(confirmed);
|
||||
|
||||
var request = new ReleaseRequest(3, "BL-CHANGED", "VESSEL", "V001",
|
||||
LocalDateTime.of(2026, 9, 1, 0, 0), LocalDateTime.of(2026, 9, 20, 0, 0), "放舱");
|
||||
|
||||
assertThatThrownBy(() -> service.release(1L, 9L, "admin", "release-mismatch", request))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("必须与已登记");
|
||||
|
||||
verify(repository, never()).release(anyLong(), any(), anyLong(), anyString());
|
||||
verifyNoInteractions(loadingPickService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsCurrentVoyagePendingUntilChangeConfirmation() {
|
||||
OceanBookingEntity current = booking("CONFIRMED", 5);
|
||||
current.setChannelCode("CHANNEL-A");
|
||||
current.setVesselName("OLD"); current.setVoyageNo("V1");
|
||||
OceanBookingEntity pending = booking("CHANGE_PENDING", 6);
|
||||
pending.setVesselName("OLD"); pending.setVoyageNo("V1"); pending.setPendingVesselName("NEW"); pending.setPendingVoyageNo("V2");
|
||||
when(repository.find(1L)).thenReturn(current, pending);
|
||||
when(repository.requestChange(eq(1L), any(), eq("改船"), eq(5), eq(9L), eq("admin"))).thenReturn(1);
|
||||
|
||||
Detail result = service.requestChange(1L, 9L, "admin", "change-1",
|
||||
new ChangeRequest(5, "NEW", "V2", LocalDateTime.of(2026, 9, 1, 0, 0),
|
||||
LocalDateTime.of(2026, 9, 20, 0, 0), "改船"));
|
||||
|
||||
assertThat(result.oceanBooking().getStatus()).isEqualTo("CHANGE_PENDING");
|
||||
assertThat(result.oceanBooking().getVesselName()).isEqualTo("OLD");
|
||||
assertThat(result.oceanBooking().getPendingVesselName()).isEqualTo("NEW");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDraftAndSubmittedStatusesFromOperationalList() {
|
||||
for (String status : List.of("DRAFT", "SUBMITTED", "FAILED")) {
|
||||
assertThatThrownBy(() -> service.page(new Query(null, status, null, 1, 20)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("状态");
|
||||
}
|
||||
verify(repository, never()).page(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void passesOnlyConfirmedLifecycleStatusesToOperationalList() {
|
||||
service.page(new Query(null, "CHANGE_PENDING", null, 1, 20));
|
||||
org.mockito.ArgumentCaptor<Query> query = org.mockito.ArgumentCaptor.forClass(Query.class);
|
||||
verify(repository).page(query.capture());
|
||||
assertThat(query.getValue().status()).isEqualTo("CHANGE_PENDING");
|
||||
}
|
||||
|
||||
@Test
|
||||
void listsOnlyChannelsFromCurrentActivePriceBook() {
|
||||
ChannelRow channel = channel("SEA-US", "美西海运");
|
||||
when(priceBooks.activeVersionId()).thenReturn(51L);
|
||||
when(priceBooks.channels(51L, 701L)).thenReturn(List.of(channel));
|
||||
when(priceBooks.channelTaxModes(51L, 701L)).thenReturn(List.of());
|
||||
|
||||
var result = service.channels(701L);
|
||||
|
||||
assertThat(result).singleElement().satisfies(value -> {
|
||||
assertThat(value.channelCode()).isEqualTo("SEA-US");
|
||||
assertThat(value.channelName()).isEqualTo("美西海运");
|
||||
assertThat(value.priceBookVersionId()).isEqualTo(51L);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void registersOfflineBookingDirectlyAsConfirmedWithCompleteTransportSnapshot() {
|
||||
ChannelRow channel = channel("SEA-US", "价格簿美西海运");
|
||||
when(priceBooks.activeVersionId()).thenReturn(51L);
|
||||
when(priceBooks.channels(51L, 701L)).thenReturn(List.of(channel));
|
||||
AtomicReference<OceanBookingEntity> inserted = stubSuccessfulCreation();
|
||||
|
||||
Detail result = service.create(9L, "admin", "create-channel", new CreateRequest(0, draft()));
|
||||
|
||||
assertThat(inserted.get().getStatus()).isEqualTo("DRAFT");
|
||||
assertThat(result.oceanBooking().getStatus()).isEqualTo("CONFIRMED");
|
||||
assertThat(result.oceanBooking().getChannelCode()).isEqualTo("SEA-US");
|
||||
assertThat(result.oceanBooking().getChannelName()).isEqualTo("价格簿美西海运");
|
||||
assertThat(result.oceanBooking().getWarehouseName()).isEqualTo("Warehouse深圳中转仓");
|
||||
assertThat(result.oceanBooking().getBillOfLadingNo()).isEqualTo("BL-001");
|
||||
assertThat(result.oceanBooking().getVesselName()).isEqualTo("VESSEL");
|
||||
assertThat(result.oceanBooking().getVoyageNo()).isEqualTo("V001");
|
||||
assertThat(result.oceanBooking().getEstimatedSailingAt())
|
||||
.isEqualTo(LocalDateTime.of(2026, 9, 1, 0, 0));
|
||||
assertThat(result.oceanBooking().getEstimatedArrivalAt())
|
||||
.isEqualTo(LocalDateTime.of(2026, 9, 20, 0, 0));
|
||||
ArgumentCaptor<OceanBookingStatusHistoryEntity> history =
|
||||
ArgumentCaptor.forClass(OceanBookingStatusHistoryEntity.class);
|
||||
verify(repository).insert(history.capture());
|
||||
assertThat(history.getValue().getFromStatus()).isEqualTo("DRAFT");
|
||||
assertThat(history.getValue().getToStatus()).isEqualTo("CONFIRMED");
|
||||
assertThat(history.getValue().getAction()).isEqualTo("CREATE");
|
||||
verify(repository).transition(inserted.get().getId(), "DRAFT", "CONFIRMED", null, 0, 9L,
|
||||
"admin");
|
||||
ArgumentCaptor<ContainerBookingEntity> containers = ArgumentCaptor.forClass(ContainerBookingEntity.class);
|
||||
verify(repository).insertContainer(containers.capture());
|
||||
assertThat(containers.getValue().getContainerNo()).isEqualTo("MSCU-001");
|
||||
assertThat(containers.getValue().getSourceType()).isEqualTo("OCEAN_BOOKING");
|
||||
assertThat(containers.getValue().getRecordStatus()).isEqualTo("ACTIVE");
|
||||
assertThat(containers.getValue().getWarehouseName()).isEqualTo("Warehouse深圳中转仓");
|
||||
ArgumentCaptor<OceanBookingContainerAllocationEntity> allocation =
|
||||
ArgumentCaptor.forClass(OceanBookingContainerAllocationEntity.class);
|
||||
verify(repository).insert(allocation.capture());
|
||||
assertThat(allocation.getValue().getContainerBookingId()).isEqualTo(containers.getValue().getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsOneRequirementContainerAndAllocationPerRealContainerUnit() {
|
||||
when(priceBooks.activeVersionId()).thenReturn(51L);
|
||||
when(priceBooks.channels(51L, 701L)).thenReturn(List.of(channel("SEA-US", "价格簿美西海运")));
|
||||
stubSuccessfulCreation();
|
||||
DraftData source = draft();
|
||||
List<ContainerRequest> requested = List.of(
|
||||
new ContainerRequest("MSCU-001", "40HC", "DRY", new BigDecimal("76"), new BigDecimal("28000")),
|
||||
new ContainerRequest("MSCU-002", "20GP", "DRY", new BigDecimal("33"), new BigDecimal("28000")));
|
||||
|
||||
service.create(9L, "admin", "create-two-containers",
|
||||
new CreateRequest(0, withContainers(source, requested)));
|
||||
void freezesServerSnapshotsAndPersistsQuantityRequirementsOnly() {
|
||||
stubMasterData();
|
||||
AtomicReference<OceanBookingEntity> inserted = stubCreatedDraft();
|
||||
Detail result = service.create(9L, "admin", "draft-complete", new CreateRequest(0, completeDraft()));
|
||||
|
||||
assertThat(result.oceanBooking().getStatus()).isEqualTo("DRAFT");
|
||||
assertThat(inserted.get().getChannelName()).isEqualTo("美西海运");
|
||||
assertThat(inserted.get().getWarehouseName()).isEqualTo("深圳中转仓");
|
||||
assertThat(inserted.get().getBookingAgentName()).isEqualTo("订舱代理");
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<List<OceanBookingContainerRequirementEntity>> requirements =
|
||||
ArgumentCaptor.forClass(List.class);
|
||||
verify(repository).replaceRequirements(anyLong(), requirements.capture());
|
||||
assertThat(requirements.getValue()).hasSize(2)
|
||||
.allSatisfy(requirement -> assertThat(requirement.getQuantity()).isEqualTo(1));
|
||||
assertThat(requirements.getValue()).extracting(OceanBookingContainerRequirementEntity::getContainerType)
|
||||
.containsExactly("40HC", "20GP");
|
||||
|
||||
ArgumentCaptor<ContainerBookingEntity> containers = ArgumentCaptor.forClass(ContainerBookingEntity.class);
|
||||
verify(repository, times(2)).insertContainer(containers.capture());
|
||||
assertThat(containers.getAllValues()).extracting(ContainerBookingEntity::getContainerNo)
|
||||
.containsExactly("MSCU-001", "MSCU-002");
|
||||
assertThat(containers.getAllValues()).extracting(ContainerBookingEntity::getId).doesNotHaveDuplicates();
|
||||
assertThat(containers.getAllValues()).extracting(ContainerBookingEntity::getCabinetNo).doesNotHaveDuplicates();
|
||||
|
||||
ArgumentCaptor<OceanBookingContainerAllocationEntity> allocations =
|
||||
ArgumentCaptor.forClass(OceanBookingContainerAllocationEntity.class);
|
||||
verify(repository, times(2)).insert(allocations.capture());
|
||||
assertThat(allocations.getAllValues()).extracting(OceanBookingContainerAllocationEntity::getContainerBookingId)
|
||||
.containsExactlyElementsOf(containers.getAllValues().stream().map(ContainerBookingEntity::getId).toList());
|
||||
ArgumentCaptor<List<OceanBookingContainerRequirementEntity>> captor = ArgumentCaptor.forClass(List.class);
|
||||
verify(repository).replaceRequirements(eq(inserted.get().getId()), captor.capture());
|
||||
assertThat(captor.getValue()).singleElement().satisfies(value -> {
|
||||
assertThat(value.getContainerType()).isEqualTo("40HC");
|
||||
assertThat(value.getQuantity()).isEqualTo(2);
|
||||
});
|
||||
verify(repository, never()).insertContainer(any());
|
||||
verify(repository, never()).insert(any(com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDuplicateContainerNumbersWithinOneRegistrationBeforeWriting() {
|
||||
DraftData source = draft();
|
||||
List<ContainerRequest> duplicated = List.of(
|
||||
new ContainerRequest("MSCU-001", "40HC", "DRY", new BigDecimal("76"), new BigDecimal("28000")),
|
||||
new ContainerRequest("MSCU-001", "40HC", "DRY", new BigDecimal("76"), new BigDecimal("28000")));
|
||||
void updatesOnlyDraftWithExpectedVersion() {
|
||||
when(repository.find(1L)).thenReturn(booking("DRAFT", 3), booking("DRAFT", 4));
|
||||
when(repository.updateDraft(any(), eq(3), eq(9L), eq("admin"))).thenReturn(1);
|
||||
Detail result = service.updateDraft(1L, 9L, "admin", "update-1",
|
||||
new UpdateRequest(3, partialDraft("备注")));
|
||||
|
||||
assertThatThrownBy(() -> service.create(9L, "admin", "duplicate-in-request",
|
||||
new CreateRequest(0, withContainers(source, duplicated))))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("不能重复");
|
||||
|
||||
verify(repository, never()).insert(any(OceanBookingEntity.class));
|
||||
verifyNoInteractions(priceBooks);
|
||||
assertThat(result.oceanBooking().getVersion()).isEqualTo(4);
|
||||
verify(repository).replaceParties(1L, List.of());
|
||||
verify(repository).replaceRequirements(1L, List.of());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsContainerNumberAlreadyRegisteredBeforeCreatingAggregate() {
|
||||
when(priceBooks.activeVersionId()).thenReturn(51L);
|
||||
when(priceBooks.channels(51L, 701L)).thenReturn(List.of(channel("SEA-US", "价格簿美西海运")));
|
||||
when(repository.containerNoExists("MSCU-001")).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> service.create(9L, "admin", "duplicate-existing",
|
||||
new CreateRequest(0, draft())))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("集装箱号已存在");
|
||||
|
||||
verify(repository, never()).insert(any(OceanBookingEntity.class));
|
||||
void rejectsReleaseWhenPersistedAggregateIsIncompleteWithoutWriting() {
|
||||
when(repository.find(1L)).thenReturn(booking("DRAFT", 3));
|
||||
assertThatThrownBy(() -> service.release(1L, 9L, "admin", "release-missing",
|
||||
new ReleaseRequest(3, "确认")))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("尚未填写完整");
|
||||
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void savesEmptyCustomerSnapshotAndFreeTextTransportTerms() {
|
||||
ChannelRow channel = channel("SEA-US", "价格簿美西海运");
|
||||
when(priceBooks.activeVersionId()).thenReturn(51L);
|
||||
when(priceBooks.channels(51L, 701L)).thenReturn(List.of(channel));
|
||||
stubSuccessfulCreation();
|
||||
void releasesCompleteAggregateAndReplaysFrozenResult() throws Exception {
|
||||
OceanBookingEntity draft = completeBooking("DRAFT", 3);
|
||||
OceanBookingEntity released = completeBooking("RELEASED", 4);
|
||||
when(repository.find(1L)).thenReturn(draft, released);
|
||||
when(repository.parties(1L)).thenReturn(parties());
|
||||
when(repository.requirements(1L)).thenReturn(List.of(requirement()));
|
||||
when(repository.transition(1L, "DRAFT", "RELEASED", 3, 9L, "admin")).thenReturn(1);
|
||||
ReleaseRequest request = new ReleaseRequest(3, "确认放舱");
|
||||
|
||||
Detail result = service.create(9L, "admin", "create-without-customer",
|
||||
new CreateRequest(0, draft(null, null, "CY-CY", "NINGBO")));
|
||||
|
||||
assertThat(result.oceanBooking().getCustomerId()).isNull();
|
||||
assertThat(result.oceanBooking().getCustomerName()).isNull();
|
||||
assertThat(result.oceanBooking().getServiceTerm()).isEqualTo("CY-CY");
|
||||
assertThat(result.oceanBooking().getFreightTerm()).isEqualTo("NINGBO");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsPartialCustomerSnapshot() {
|
||||
assertThatThrownBy(() -> service.create(9L, "admin", "partial-customer-id",
|
||||
new CreateRequest(0, draft(100L, null, null, null))))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("客户ID和客户名称必须同时填写或同时为空");
|
||||
assertThatThrownBy(() -> service.create(9L, "admin", "partial-customer-name",
|
||||
new CreateRequest(0, draft(null, "客户", null, null))))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("客户ID和客户名称必须同时填写或同时为空");
|
||||
|
||||
verifyNoInteractions(priceBooks);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingRequiredTransportTerms() {
|
||||
assertThatThrownBy(() -> service.create(9L, "admin", "missing-service-term",
|
||||
new CreateRequest(0, draft(100L, "客户", null, "NINGBO"))))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("Service Term和Freight Term不能为空");
|
||||
assertThatThrownBy(() -> service.create(9L, "admin", "blank-freight-term",
|
||||
new CreateRequest(0, draft(100L, "客户", "CY-CY", " "))))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("Service Term和Freight Term不能为空");
|
||||
|
||||
verifyNoInteractions(priceBooks);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsOfflineBookingWhenEtaIsEarlierThanEtd() {
|
||||
DraftData source = draft();
|
||||
DraftData invalid = new DraftData(source.customerId(), source.customerName(), source.channelCode(),
|
||||
source.warehouseId(), source.billOfLadingNo(), source.shippingCompanyId(),
|
||||
source.shippingCompanyName(), source.bookingAgentId(), source.bookingAgentName(),
|
||||
source.vesselName(), source.voyageNo(), source.departurePortCode(), source.departurePortName(),
|
||||
source.destinationPortCode(), source.destinationPortName(), source.placeOfDelivery(),
|
||||
source.serviceTerm(), source.freightTerm(), source.estimatedArrivalAt(), source.estimatedSailingAt(),
|
||||
source.remark(), source.parties(), source.containers());
|
||||
|
||||
assertThatThrownBy(() -> service.create(9L, "admin", "invalid-range", new CreateRequest(0, invalid)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("预计到港时间不能早于预计开船时间");
|
||||
|
||||
verifyNoInteractions(priceBooks);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsOfflineBookingWithoutCompleteTransportSnapshot() {
|
||||
DraftData source = draft();
|
||||
DraftData missingBill = new DraftData(source.customerId(), source.customerName(), source.channelCode(),
|
||||
source.warehouseId(), null, source.shippingCompanyId(),
|
||||
source.shippingCompanyName(), source.bookingAgentId(), source.bookingAgentName(),
|
||||
source.vesselName(), source.voyageNo(), source.departurePortCode(), source.departurePortName(),
|
||||
source.destinationPortCode(), source.destinationPortName(), source.placeOfDelivery(),
|
||||
source.serviceTerm(), source.freightTerm(), source.estimatedSailingAt(), source.estimatedArrivalAt(),
|
||||
source.remark(), source.parties(), source.containers());
|
||||
|
||||
assertThatThrownBy(() -> service.create(9L, "admin", "missing-transport",
|
||||
new CreateRequest(0, missingBill))).isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("提单号、船名、航次、ETD和ETA不能为空");
|
||||
|
||||
verifyNoInteractions(priceBooks);
|
||||
}
|
||||
|
||||
@Test
|
||||
void replaysCreateAfterChannelIsNoLongerActive() throws Exception {
|
||||
ChannelRow channel = channel("SEA-US", "价格簿美西海运");
|
||||
CreateRequest request = new CreateRequest(0, draft());
|
||||
when(priceBooks.activeVersionId()).thenReturn(51L, (Long) null);
|
||||
when(priceBooks.channels(51L, 701L)).thenReturn(List.of(channel));
|
||||
stubSuccessfulCreation();
|
||||
|
||||
Detail created = service.create(9L, "admin", "create-replay-channel", request);
|
||||
Detail first = service.release(1L, 9L, "admin", "release-1", request);
|
||||
assertThat(first.oceanBooking().getStatus()).isEqualTo("RELEASED");
|
||||
|
||||
OceanBookingOperationEntity operation = new OceanBookingOperationEntity();
|
||||
operation.setRequestHash(hashForCreate(request)); operation.setStatus("SUCCESS");
|
||||
operation.setResultJson(new ObjectMapper().findAndRegisterModules().writeValueAsString(created));
|
||||
when(repository.claimOperation(isNull(), eq(9L), eq("CREATE"), eq("create-replay-channel"), anyString()))
|
||||
.thenReturn(false);
|
||||
when(repository.operation(9L, "CREATE", "create-replay-channel")).thenReturn(operation);
|
||||
operation.setRequestHash(hash("RELEASE", 1L, request));
|
||||
operation.setStatus("SUCCESS");
|
||||
operation.setResultJson(objectMapper.writeValueAsString(first));
|
||||
when(repository.claimOperation(1L, 9L, "RELEASE", "release-1", operation.getRequestHash())).thenReturn(false);
|
||||
when(repository.operation(9L, "RELEASE", "release-1")).thenReturn(operation);
|
||||
|
||||
Detail replay = service.create(9L, "admin", "create-replay-channel", request);
|
||||
|
||||
assertThat(replay.oceanBooking().getChannelName()).isEqualTo("价格簿美西海运");
|
||||
verify(priceBooks, times(1)).activeVersionId();
|
||||
assertThat(service.release(1L, 9L, "admin", "release-1", request).oceanBooking().getStatus())
|
||||
.isEqualTo("RELEASED");
|
||||
verify(repository, times(1)).transition(1L, "DRAFT", "RELEASED", 3, 9L, "admin");
|
||||
}
|
||||
|
||||
private static ChannelRow channel(String code, String name) {
|
||||
ChannelRow value = new ChannelRow(); value.setPriceBookVersionId(51L); value.setChannelCode(code);
|
||||
value.setChannelName(name); value.setPriceModel("FBA_WAREHOUSE_RATE"); return value;
|
||||
@Test
|
||||
void releasedAggregateRejectsEditDeleteAndRepeatRelease() {
|
||||
when(repository.find(1L)).thenReturn(booking("RELEASED", 4));
|
||||
assertThatThrownBy(() -> service.updateDraft(1L, 9L, "admin", "edit-released",
|
||||
new UpdateRequest(4, partialDraft(null)))).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> service.deleteDraft(1L, 9L, "admin", "delete-released",
|
||||
new ActionRequest(4, null))).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> service.release(1L, 9L, "admin", "release-again",
|
||||
new ReleaseRequest(4, null))).isInstanceOf(BizException.class);
|
||||
verify(repository, never()).deleteDraft(anyLong(), anyInt());
|
||||
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
private static DraftData draft() {
|
||||
return draft(100L, "客户", "CY-CY", "NINGBO");
|
||||
@Test
|
||||
void deleteDraftKeepsIdempotencyDetachedFromDeletedAggregate() {
|
||||
when(repository.find(1L)).thenReturn(booking("DRAFT", 2));
|
||||
when(repository.deleteDraft(1L, 2)).thenReturn(1);
|
||||
service.deleteDraft(1L, 9L, "admin", "delete-1", new ActionRequest(2, "作废草稿"));
|
||||
|
||||
verify(repository).claimOperation(isNull(), eq(9L), eq("DELETE_DRAFT"), eq("delete-1"), anyString());
|
||||
verify(repository).completeOperation(isNull(), eq(9L), eq("DELETE_DRAFT"), eq("delete-1"), anyString());
|
||||
}
|
||||
|
||||
private static DraftData draft(Long customerId, String customerName, String serviceTerm, String freightTerm) {
|
||||
List<PartyRequest> parties = List.of(
|
||||
new PartyRequest("SHIPPER", "发货人", null, null, null),
|
||||
new PartyRequest("CONSIGNEE", "收货人", null, null, null),
|
||||
new PartyRequest("NOTIFY_PARTY", "通知人", null, null, null));
|
||||
List<ContainerRequest> containers = List.of(
|
||||
new ContainerRequest("MSCU-001", "40HC", "DRY", new BigDecimal("76"), new BigDecimal("28000")));
|
||||
return new DraftData(customerId, customerName, "SEA-US", 701L, "BL-001",
|
||||
81L, "船公司", null, null, "VESSEL", "V001", "CNSZX", "深圳", "USLAX", "洛杉矶",
|
||||
null, serviceTerm, freightTerm, LocalDateTime.of(2026, 9, 1, 0, 0),
|
||||
LocalDateTime.of(2026, 9, 20, 0, 0), null, parties, containers);
|
||||
@Test
|
||||
void supportsOnlyDraftAndReleasedListStatuses() {
|
||||
service.page(new Query(null, "DRAFT", null, 1, 20));
|
||||
service.page(new Query(null, "RELEASED", null, 1, 20));
|
||||
assertThatThrownBy(() -> service.page(new Query(null, "CONFIRMED", null, 1, 20)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("状态");
|
||||
verify(repository, times(2)).page(any());
|
||||
}
|
||||
|
||||
private static DraftData withContainers(DraftData source, List<ContainerRequest> containers) {
|
||||
return new DraftData(source.customerId(), source.customerName(), source.channelCode(), source.warehouseId(),
|
||||
source.billOfLadingNo(), source.shippingCompanyId(), source.shippingCompanyName(),
|
||||
source.bookingAgentId(), source.bookingAgentName(), source.vesselName(), source.voyageNo(),
|
||||
source.departurePortCode(), source.departurePortName(), source.destinationPortCode(),
|
||||
source.destinationPortName(), source.placeOfDelivery(), source.serviceTerm(), source.freightTerm(),
|
||||
source.estimatedSailingAt(), source.estimatedArrivalAt(), source.remark(), source.parties(), containers);
|
||||
@Test
|
||||
void releaseRejectsInvalidScheduleWithoutTransition() {
|
||||
OceanBookingEntity draft = completeBooking("DRAFT", 3);
|
||||
draft.setEstimatedArrivalAt(draft.getEstimatedSailingAt().minusDays(1));
|
||||
when(repository.find(1L)).thenReturn(draft);
|
||||
when(repository.parties(1L)).thenReturn(parties());
|
||||
when(repository.requirements(1L)).thenReturn(List.of(requirement()));
|
||||
assertThatThrownBy(() -> service.release(1L, 9L, "admin", "release-range",
|
||||
new ReleaseRequest(3, null))).isInstanceOf(BizException.class).hasMessageContaining("不能早于");
|
||||
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
private AtomicReference<OceanBookingEntity> stubSuccessfulCreation() {
|
||||
private AtomicReference<OceanBookingEntity> stubCreatedDraft() {
|
||||
AtomicReference<OceanBookingEntity> inserted = new AtomicReference<>();
|
||||
doAnswer(invocation -> { inserted.set(invocation.getArgument(0)); return null; })
|
||||
.when(repository).insert(any(OceanBookingEntity.class));
|
||||
when(repository.find(anyLong())).thenAnswer(invocation -> {
|
||||
OceanBookingEntity confirmed = new OceanBookingEntity();
|
||||
BeanUtils.copyProperties(inserted.get(), confirmed);
|
||||
confirmed.setStatus("CONFIRMED");
|
||||
confirmed.setVersion(1);
|
||||
return confirmed;
|
||||
});
|
||||
when(repository.find(anyLong())).thenAnswer(invocation -> inserted.get());
|
||||
return inserted;
|
||||
}
|
||||
|
||||
private String hashForCreate(CreateRequest request) throws Exception {
|
||||
var method = OceanBookingService.class.getDeclaredMethod("hash", String.class, Long.class, Object.class);
|
||||
method.setAccessible(true);
|
||||
return (String) method.invoke(service, "CREATE", (Long) null, request);
|
||||
private void stubMasterData() {
|
||||
ChannelRow row = new ChannelRow();
|
||||
row.setPriceBookVersionId(51L); row.setChannelCode("SEA-US"); row.setChannelName("美西海运");
|
||||
row.setPriceModel("FBA_WAREHOUSE_RATE");
|
||||
when(priceBooks.activeVersionId()).thenReturn(51L);
|
||||
when(priceBooks.channels(51L, 701L)).thenReturn(List.of(row));
|
||||
when(warehouses.active(any(), eq(0L), eq(701L))).thenReturn(ApiResponse.success(
|
||||
new WarehouseMasterDataClient.Detail(new WarehouseMasterDataClient.Warehouse(
|
||||
701L, 0L, "SZX", "深圳中转仓", "DOMESTIC", "ACTIVE"))));
|
||||
when(suppliers.booking(81L, null)).thenReturn(
|
||||
new WarehouseSupplierClient.Snapshot(81L, "订舱代理", "BOOKING", "ACTIVE"));
|
||||
}
|
||||
|
||||
private static DraftData partialDraft(String remark) {
|
||||
return new DraftData(null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null, null, null, null, null, null, remark, null, null);
|
||||
}
|
||||
|
||||
private static DraftData completeDraft() {
|
||||
return new DraftData(100L, "客户", "SEA-US", 701L, "BL-001", 71L, "船公司",
|
||||
81L, null, "VESSEL", "V001", "CNSZX", "深圳", "USLAX", "洛杉矶",
|
||||
"LAX", "CY-CY", "PREPAID", LocalDateTime.of(2026, 9, 1, 0, 0),
|
||||
LocalDateTime.of(2026, 9, 20, 0, 0), null,
|
||||
List.of(new PartyRequest("SHIPPER", "发货人", null, null, null),
|
||||
new PartyRequest("CONSIGNEE", "收货人", null, null, null),
|
||||
new PartyRequest("NOTIFY_PARTY", "通知人", null, null, null)),
|
||||
List.of(new ContainerRequirementRequest("40HC", "DRY", 2,
|
||||
new BigDecimal("76"), new BigDecimal("28000"))));
|
||||
}
|
||||
|
||||
private static OceanBookingEntity completeBooking(String status, int version) {
|
||||
OceanBookingEntity value = booking(status, version);
|
||||
value.setCustomerId(100L); value.setCustomerName("客户"); value.setChannelCode("SEA-US");
|
||||
value.setWarehouseId(701L); value.setBillOfLadingNo("BL-001"); value.setShippingCompanyId(71L);
|
||||
value.setBookingAgentId(81L); value.setVesselName("VESSEL"); value.setVoyageNo("V001");
|
||||
value.setServiceTerm("CY-CY"); value.setFreightTerm("PREPAID");
|
||||
value.setEstimatedSailingAt(LocalDateTime.of(2026, 9, 1, 0, 0));
|
||||
value.setEstimatedArrivalAt(LocalDateTime.of(2026, 9, 20, 0, 0));
|
||||
return value;
|
||||
}
|
||||
|
||||
private static OceanBookingEntity booking(String status, int version) {
|
||||
OceanBookingEntity value = new OceanBookingEntity(); value.setId(1L); value.setBookingNo("OB1");
|
||||
value.setStatus(status); value.setCustomerId(100L); value.setCustomerName("客户");
|
||||
value.setChannelCode("CHANNEL-A"); value.setVersion(version);
|
||||
OceanBookingEntity value = new OceanBookingEntity();
|
||||
value.setId(1L); value.setBookingNo("OB1"); value.setStatus(status); value.setVersion(version);
|
||||
return value;
|
||||
}
|
||||
|
||||
private static List<com.baiyue.firstleg.oceanbooking.entity.OceanBookingPartyEntity> parties() {
|
||||
return List.of("SHIPPER", "CONSIGNEE", "NOTIFY_PARTY").stream().map(type -> {
|
||||
var value = new com.baiyue.firstleg.oceanbooking.entity.OceanBookingPartyEntity();
|
||||
value.setPartyType(type); value.setPartyName(type); return value;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
private static OceanBookingContainerRequirementEntity requirement() {
|
||||
var value = new OceanBookingContainerRequirementEntity();
|
||||
value.setId(11L); value.setContainerType("40HC"); value.setContainerKind("DRY");
|
||||
value.setQuantity(1); value.setCapacityCbm(new BigDecimal("76"));
|
||||
value.setMaxWeightKg(new BigDecimal("28000")); return value;
|
||||
}
|
||||
|
||||
private String hash(String action, Long id, Object request) throws Exception {
|
||||
var method = OceanBookingService.class.getDeclaredMethod("hash", String.class, Long.class, Object.class);
|
||||
method.setAccessible(true);
|
||||
return (String) method.invoke(service, action, id, request);
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -26,15 +26,15 @@ class Phase3BookingTruckPermissionSqlContractTest {
|
||||
"platform:firstleg:ocean-booking:list",
|
||||
"platform:firstleg:ocean-booking:create",
|
||||
"platform:firstleg:ocean-booking:release",
|
||||
"platform:firstleg:ocean-booking:cancel",
|
||||
"platform:firstleg:ocean-booking:change");
|
||||
"platform:firstleg:ocean-booking:edit",
|
||||
"platform:firstleg:ocean-booking:delete");
|
||||
|
||||
private static final Set<String> OCEAN_SQL_PERMISSIONS = Set.of(
|
||||
"platform:firstleg:ocean-booking:list",
|
||||
"platform:firstleg:ocean-booking:create",
|
||||
"platform:firstleg:ocean-booking:release",
|
||||
"platform:firstleg:ocean-booking:cancel",
|
||||
"platform:firstleg:ocean-booking:change",
|
||||
"platform:firstleg:ocean-booking:edit",
|
||||
"platform:firstleg:ocean-booking:delete",
|
||||
"platform:firstleg:ocean-booking:allocate");
|
||||
|
||||
private static final Set<String> TRUCK_PERMISSIONS = Set.of(
|
||||
|
||||
+4
-2
@@ -62,11 +62,13 @@ class Phase3FinalUnifiedSqlContractTest {
|
||||
void unifiedOceanBookingBlockIncludesExistingSchemaReconciliation() throws Exception {
|
||||
String sql = normalise(Files.readString(UNIFIED));
|
||||
assertThat(sql)
|
||||
.contains("reconcile_ocean_booking_legacy")
|
||||
.contains("DROP CHECK chk_ocean_booking_status")
|
||||
.contains("ADD CONSTRAINT chk_ocean_booking_status CHECK")
|
||||
.contains("DROP COLUMN failure_reason")
|
||||
.contains("不会伪造 CONFIRMED")
|
||||
.contains("(status IN ('DRAFT','RELEASED'))")
|
||||
.contains("platform:firstleg:ocean-booking:edit")
|
||||
.contains("platform:firstleg:ocean-booking:delete")
|
||||
.doesNotContain("reconcile_ocean_booking_legacy")
|
||||
.doesNotContain("UPDATE fl_ocean_booking SET status='CONFIRMED'");
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingUnlinkRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest;
|
||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||
import com.baiyue.firstleg.entity.LoadingPlanEntity;
|
||||
@@ -32,12 +33,11 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
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.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
@@ -282,94 +282,83 @@ class ContainerServiceTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("首次关联允许已有预排货的编辑计划且保持内部柜和计划身份")
|
||||
void linksConfirmedBookingAfterPreloading() {
|
||||
void linksReleasedBookingAfterPreloading() {
|
||||
ContainerBookingEntity container = booking("EDITING", 2);
|
||||
container.setWarehouseId(101L); container.setChannelCode("SEA-US"); container.setContainerType("40HC");
|
||||
container.setCapacityCbm(new BigDecimal("76.0000")); container.setMaxWeightKg(new BigDecimal("28000.000"));
|
||||
ContainerBookingEntity realContainer = realContainer(41L, 21L, 31L);
|
||||
OceanBookingEntity booking = new OceanBookingEntity();
|
||||
booking.setId(21L); booking.setStatus("CONFIRMED"); booking.setWarehouseId(101L); booking.setChannelCode("SEA-US");
|
||||
booking.setId(21L); booking.setStatus("RELEASED"); booking.setWarehouseId(101L); booking.setChannelCode("SEA-US");
|
||||
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
|
||||
requirement.setId(31L); requirement.setQuantity(1); requirement.setContainerType("40HC"); requirement.setCapacityCbm(new BigDecimal("76.0000")); requirement.setMaxWeightKg(new BigDecimal("28000.000"));
|
||||
OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity();
|
||||
allocation.setOceanBookingId(21L); allocation.setRequirementId(31L); allocation.setContainerBookingId(41L);
|
||||
when(repository.find(11L)).thenReturn(container);
|
||||
when(repository.findForUpdate(41L)).thenReturn(realContainer);
|
||||
when(repository.find(11L)).thenReturn(container, container);
|
||||
when(oceanBookings.find(21L)).thenReturn(booking);
|
||||
when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement);
|
||||
when(oceanBookings.allocationByContainer(41L)).thenReturn(allocation);
|
||||
when(repository.consumeRealSource(41L, 11L, 9L, "admin")).thenReturn(1);
|
||||
when(repository.mergeRealSource(11L, 41L, "MSCU7654321", 2, 9L, "admin")).thenReturn(1);
|
||||
when(oceanBookings.reassignAllocation(41L, 11L)).thenReturn(1);
|
||||
when(oceanBookings.allocations(21L)).thenReturn(List.of());
|
||||
when(repository.linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq("MSCU7654321"),
|
||||
same(booking), eq(2), eq(9L), eq("admin"))).thenReturn(1);
|
||||
|
||||
ContainerBookingEntity result = service.linkOceanBooking(11L, 9L, "admin", new OceanBookingLinkRequest(41L, 2));
|
||||
ContainerBookingEntity result = service.linkOceanBooking(11L, 9L, "admin",
|
||||
new OceanBookingLinkRequest(21L, 31L, "MSCU7654321", 2));
|
||||
|
||||
assertThat(result).isSameAs(container);
|
||||
assertThat(container.getCabinetNo()).isEqualTo("柜-001");
|
||||
verify(repository, never()).hasActiveLoadingItems(11L);
|
||||
verify(repository).consumeRealSource(41L, 11L, 9L, "admin");
|
||||
verify(repository).mergeRealSource(11L, 41L, "MSCU7654321", 2, 9L, "admin");
|
||||
verify(oceanBookings).reassignAllocation(41L, 11L);
|
||||
verify(repository).linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq("MSCU7654321"),
|
||||
same(booking), eq(2), eq(9L), eq("admin"));
|
||||
verify(oceanBookings).insert(any(OceanBookingContainerAllocationEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("首次关联允许消费已放舱的真实订舱柜")
|
||||
void linksReleasedBooking() {
|
||||
@DisplayName("未发布订舱不可关联")
|
||||
void rejectsDraftBooking() {
|
||||
ContainerBookingEntity container = booking("WAIT_LOADING", 2);
|
||||
container.setWarehouseId(101L); container.setChannelCode("SEA-US"); container.setContainerType("40HC");
|
||||
container.setCapacityCbm(new BigDecimal("76.0000")); container.setMaxWeightKg(new BigDecimal("28000.000"));
|
||||
ContainerBookingEntity realContainer = realContainer(41L, 21L, 31L);
|
||||
OceanBookingEntity booking = new OceanBookingEntity();
|
||||
booking.setId(21L); booking.setStatus("RELEASED"); booking.setWarehouseId(101L); booking.setChannelCode("SEA-US");
|
||||
booking.setId(21L); booking.setStatus("DRAFT");
|
||||
when(repository.find(11L)).thenReturn(container);
|
||||
when(oceanBookings.find(21L)).thenReturn(booking);
|
||||
|
||||
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
|
||||
new OceanBookingLinkRequest(21L, 31L, "MSCU7654321", 2)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("已发布");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("订舱箱型需求达到数量上限时拒绝关联")
|
||||
void rejectsRequirementOverAllocation() {
|
||||
ContainerBookingEntity container = booking("WAIT_LOADING", 2);
|
||||
OceanBookingEntity booking = new OceanBookingEntity();
|
||||
booking.setId(21L); booking.setStatus("RELEASED");
|
||||
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
|
||||
requirement.setId(31L); requirement.setContainerType("40HC");
|
||||
requirement.setCapacityCbm(new BigDecimal("76.0000")); requirement.setMaxWeightKg(new BigDecimal("28000.000"));
|
||||
requirement.setId(31L); requirement.setQuantity(1);
|
||||
OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity();
|
||||
allocation.setOceanBookingId(21L); allocation.setRequirementId(31L); allocation.setContainerBookingId(41L);
|
||||
allocation.setRequirementId(31L);
|
||||
when(repository.find(11L)).thenReturn(container);
|
||||
when(repository.findForUpdate(41L)).thenReturn(realContainer);
|
||||
when(oceanBookings.find(21L)).thenReturn(booking);
|
||||
when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement);
|
||||
when(oceanBookings.allocationByContainer(41L)).thenReturn(allocation);
|
||||
when(repository.consumeRealSource(41L, 11L, 9L, "admin")).thenReturn(1);
|
||||
when(repository.mergeRealSource(11L, 41L, "MSCU7654321", 2, 9L, "admin")).thenReturn(1);
|
||||
when(oceanBookings.reassignAllocation(41L, 11L)).thenReturn(1);
|
||||
when(oceanBookings.allocations(21L)).thenReturn(List.of(allocation));
|
||||
|
||||
service.linkOceanBooking(11L, 9L, "admin", new OceanBookingLinkRequest(41L, 2));
|
||||
|
||||
verify(oceanBookings).reassignAllocation(41L, 11L);
|
||||
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
|
||||
new OceanBookingLinkRequest(21L, 31L, "MSCU7654321", 2)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("数量上限");
|
||||
verify(repository, never()).linkReleasedBooking(anyLong(), anyLong(), anyLong(), anyString(),
|
||||
any(), anyInt(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已合并的真实订舱柜不可再次关联")
|
||||
void rejectsConsumedRealContainer() {
|
||||
ContainerBookingEntity container = booking("WAIT_LOADING", 2);
|
||||
ContainerBookingEntity consumed = realContainer(41L, 21L, 31L);
|
||||
consumed.setRecordStatus("MERGED"); consumed.setMergedIntoContainerBookingId(99L);
|
||||
when(repository.find(11L)).thenReturn(container);
|
||||
when(repository.findForUpdate(41L)).thenReturn(consumed);
|
||||
@DisplayName("完全空的模拟柜直接解绑并删除当前allocation,不依赖MERGED来源柜")
|
||||
void unlinksEmptySimulationDirectly() {
|
||||
ContainerBookingEntity linked = booking("WAIT_LOADING", 2);
|
||||
linked.setOceanBookingId(21L);
|
||||
ContainerBookingEntity detached = booking("WAIT_LOADING", 3);
|
||||
when(repository.find(11L)).thenReturn(linked, detached);
|
||||
when(repository.unlinkReleasedBooking(11L, 2, 9L, "admin")).thenReturn(1);
|
||||
|
||||
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
|
||||
new OceanBookingLinkRequest(41L, 2)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("已被消费");
|
||||
ContainerBookingEntity result = service.unlinkOceanBooking(11L, 9L, "admin",
|
||||
new OceanBookingUnlinkRequest(2, "换绑"));
|
||||
|
||||
verifyNoInteractions(oceanBookings);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已有装柜计划的真实订舱柜不可被模拟柜消费")
|
||||
void rejectsRealContainerWithLoadingPlan() {
|
||||
ContainerBookingEntity container = booking("WAIT_LOADING", 2);
|
||||
ContainerBookingEntity realContainer = realContainer(41L, 21L, 31L);
|
||||
when(repository.find(11L)).thenReturn(container);
|
||||
when(repository.findForUpdate(41L)).thenReturn(realContainer);
|
||||
when(repository.hasLoadingPlan(41L)).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
|
||||
new OceanBookingLinkRequest(41L, 2)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("已有装柜计划");
|
||||
|
||||
verifyNoInteractions(oceanBookings);
|
||||
assertThat(result).isSameAs(detached);
|
||||
verify(repository).unlinkReleasedBooking(11L, 2, 9L, "admin");
|
||||
verify(oceanBookings).deleteAllocation(11L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -379,21 +368,12 @@ class ContainerServiceTest {
|
||||
when(repository.find(11L)).thenReturn(container);
|
||||
when(repository.hasEvidence(11L)).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin", new OceanBookingLinkRequest(41L, 2)))
|
||||
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
|
||||
new OceanBookingLinkRequest(21L, 31L, "MSCU7654321", 2)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("证据");
|
||||
verifyNoInteractions(oceanBookings);
|
||||
}
|
||||
|
||||
private static ContainerBookingEntity realContainer(long id, long oceanBookingId, long requirementId) {
|
||||
ContainerBookingEntity value = new ContainerBookingEntity();
|
||||
value.setId(id); value.setContainerNo("MSCU7654321"); value.setCabinetNo("CAB-REAL");
|
||||
value.setSourceType("OCEAN_BOOKING"); value.setRecordStatus("ACTIVE");
|
||||
value.setOceanBookingId(oceanBookingId); value.setOceanBookingRequirementId(requirementId);
|
||||
value.setWarehouseId(101L); value.setChannelCode("SEA-US"); value.setContainerType("40HC");
|
||||
value.setCapacityCbm(new BigDecimal("76.0000")); value.setMaxWeightKg(new BigDecimal("28000.000"));
|
||||
return value;
|
||||
}
|
||||
|
||||
private static ApiResponse<WarehouseMasterDataClient.Detail> activeWarehouse(long warehouseId,
|
||||
String type,
|
||||
String name) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.client.OrderCargoClient;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.AssignRequest;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.AdjustRequest;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.DeleteRequest;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.Plan;
|
||||
@@ -255,7 +256,7 @@ class SmartLoadingServiceTest {
|
||||
assertThat(saved.getProductNameEn()).isEqualTo("English name");
|
||||
assertThat(saved.getUnitsPerCarton()).isEqualTo(4);
|
||||
assertThat(saved.getProductQuantity()).isEqualTo(20);
|
||||
assertThat(saved.getNetWeightKg()).isEqualByComparingTo("5");
|
||||
assertThat(saved.getNetWeightKg()).isEqualByComparingTo("7.5");
|
||||
assertThat(saved.getCustomerRemark()).isEqualTo("客户备注");
|
||||
assertThat(saved.getInternalRemark()).isEqualTo("最新内部备注");
|
||||
assertThat(saved.getInternalRemark()).isNotEqualTo("货物备注");
|
||||
@@ -382,7 +383,7 @@ class SmartLoadingServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatesAndClearsWarehouseRemarkWithBothVersions() {
|
||||
void updatesAndClearsBlankWarehouseRemarkWithBothVersions() {
|
||||
LoadingPlanEntity plan = plan("LOADING", 2);
|
||||
LoadingPlanItemEntity line = item(8, 9, 20, 1);
|
||||
line.setWarehouseRemark("旧仓库备注");
|
||||
@@ -393,7 +394,7 @@ class SmartLoadingServiceTest {
|
||||
when(repository.updateWarehouseRemark(1L, 8L, 2, 0, null, 7L, "u")).thenReturn(1);
|
||||
|
||||
Plan result = service.updateWarehouseRemark(1L, 8L,
|
||||
new WarehouseRemarkRequest(null, 2, 0, "warehouse-clear"), 7L, "u");
|
||||
new WarehouseRemarkRequest(" ", 2, 0, "warehouse-clear"), 7L, "u");
|
||||
|
||||
assertThat(result.status()).isEqualTo("LOADING");
|
||||
verify(loadingPickService).lockMutable(1L);
|
||||
@@ -404,6 +405,74 @@ class SmartLoadingServiceTest {
|
||||
assertThat(history.getValue().getBeforeSnapshot()).contains("旧仓库备注");
|
||||
}
|
||||
|
||||
@Test
|
||||
void warehouseRemarkConflictProducesNoHistory() {
|
||||
LoadingPlanEntity plan = plan("LOADING", 2);
|
||||
LoadingPlanItemEntity line = item(8, 9, 20, 1);
|
||||
when(repository.plan(1L)).thenReturn(plan);
|
||||
when(repository.item(1L, 8L)).thenReturn(line);
|
||||
when(repository.updateWarehouseRemark(1L, 8L, 2, 0, "新备注", 7L, "u")).thenReturn(0);
|
||||
|
||||
BizException error = catchThrowableOfType(() -> service.updateWarehouseRemark(1L, 8L,
|
||||
new WarehouseRemarkRequest("新备注", 2, 0, "warehouse-conflict"), 7L, "u"), BizException.class);
|
||||
|
||||
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
|
||||
verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void completedPlanRejectsWarehouseRemarkBeforeAnyWrite() {
|
||||
when(repository.plan(1L)).thenReturn(plan("COMPLETED", 4));
|
||||
|
||||
BizException error = catchThrowableOfType(() -> service.updateWarehouseRemark(1L, 8L,
|
||||
new WarehouseRemarkRequest("新备注", 4, 0, "warehouse-completed"), 7L, "u"), BizException.class);
|
||||
|
||||
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
|
||||
verify(loadingPickService, never()).lockMutable(anyLong());
|
||||
verify(repository, never()).item(anyLong(), anyLong());
|
||||
verify(repository, never()).updateWarehouseRemark(anyLong(), anyLong(), anyInt(), anyInt(),
|
||||
any(), anyLong(), anyString());
|
||||
verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void exactWarehouseRemarkReplayIsReadOnly() {
|
||||
String requestHash = sha256("WAREHOUSE_REMARK|1|8|备注|2|0");
|
||||
when(repository.operation("warehouse-replay"))
|
||||
.thenReturn(history(1L, 8L, "WAREHOUSE_REMARK", requestHash));
|
||||
when(repository.plan(1L)).thenReturn(plan("LOADING", 3));
|
||||
when(repository.booking(99L)).thenReturn(booking());
|
||||
when(repository.items(1L)).thenReturn(List.of(item(8, 9, 20, 1)));
|
||||
|
||||
Plan result = service.updateWarehouseRemark(1L, 8L,
|
||||
new WarehouseRemarkRequest("备注", 2, 0, "warehouse-replay"), 7L, "u");
|
||||
|
||||
assertThat(result.version()).isEqualTo(3);
|
||||
verify(loadingPickService, never()).lockMutable(anyLong());
|
||||
verify(repository, never()).updateWarehouseRemark(anyLong(), anyLong(), anyInt(), anyInt(),
|
||||
any(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void quantityAdjustmentPreservesWarehouseRemark() {
|
||||
LoadingPlanEntity plan = plan("LOADING", 2);
|
||||
LoadingPlanItemEntity line = item(8, 9, 20, 1);
|
||||
line.setWarehouseRemark("装柜时注意外箱");
|
||||
when(repository.plan(1L)).thenReturn(plan, plan("LOADING", 3));
|
||||
when(repository.item(1L, 8L)).thenReturn(line);
|
||||
when(repository.booking(99L)).thenReturn(booking());
|
||||
when(repository.candidate(100L, 10L, 20L)).thenReturn(candidate(10));
|
||||
when(repository.items(1L)).thenReturn(List.of(line));
|
||||
when(repository.touch(1L, 2, 7L, "u")).thenReturn(1);
|
||||
when(repository.update(line)).thenReturn(1);
|
||||
|
||||
service.adjust(1L, 8L, new AdjustRequest(2, 2, 0, "adjust-remark"), 7L, "u");
|
||||
|
||||
ArgumentCaptor<LoadingPlanItemEntity> updated = ArgumentCaptor.forClass(LoadingPlanItemEntity.class);
|
||||
verify(repository).update(updated.capture());
|
||||
assertThat(updated.getValue().getWarehouseRemark()).isEqualTo("装柜时注意外箱");
|
||||
}
|
||||
|
||||
@Test
|
||||
void preservesDownstreamCode() {
|
||||
stubAssignment();
|
||||
|
||||
Reference in New Issue
Block a user