This commit is contained in:
2026-08-14 19:59:26 +08:00
parent 7d4256dbd2
commit 993b3dfe03
15 changed files with 282 additions and 30 deletions
@@ -37,10 +37,10 @@ public class PlatformOceanBookingController {
@PostMapping @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:create')")
@BusinessAudit(event="OCEAN_BOOKING_CREATE", resourceType="OCEAN_BOOKING", description="创建海运订舱单", captureBefore=false)
public ApiResponse<Detail> create(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CreateRequest request) { FirstlegScopeGuard.platform(user, request.data().customerId()); return ApiResponse.success(service.create(user.userId(), user.username(), key, request)); }
public ApiResponse<Detail> create(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CreateRequest request) { validateOptionalCustomer(user, request.data().customerId()); return ApiResponse.success(service.create(user.userId(), user.username(), key, request)); }
@PutMapping("/{oceanBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:edit')")
@BusinessAudit(event="OCEAN_BOOKING_UPDATE", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="编辑海运订舱草稿")
public ApiResponse<Detail> updateDraft(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody UpdateDraftRequest request) { FirstlegScopeGuard.platform(user, request.data().customerId()); return ApiResponse.success(service.updateDraft(oceanBookingId, user.userId(), user.username(), key, request)); }
public ApiResponse<Detail> updateDraft(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody UpdateDraftRequest request) { validateOptionalCustomer(user, request.data().customerId()); return ApiResponse.success(service.updateDraft(oceanBookingId, user.userId(), user.username(), key, request)); }
@PostMapping("/{oceanBookingId}/submit") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:submit')")
@BusinessAudit(event="OCEAN_BOOKING_SUBMIT", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="提交海运订舱")
public ApiResponse<Detail> submit(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.submit(oceanBookingId, user.userId(), user.username(), key, request)); }
@@ -68,4 +68,5 @@ public class PlatformOceanBookingController {
@PostMapping("/{oceanBookingId}/allocations") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:allocate')")
@BusinessAudit(event="OCEAN_BOOKING_ALLOCATE_CONTAINER", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="分配订舱柜次")
public ApiResponse<Detail> allocate(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody AllocationRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.allocate(oceanBookingId, user.userId(), user.username(), key, request)); }
private static void validateOptionalCustomer(AuthenticatedUser user, Long customerId) { FirstlegScopeGuard.platform(user); if (customerId != null) FirstlegScopeGuard.platform(user, customerId); }
}
@@ -39,8 +39,8 @@ public final class OceanBookingDtos {
@NotNull @jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false) BigDecimal maxWeightKg,
@NotNull @Positive Integer quantity) {}
public record DraftData(@NotNull @Positive Long customerId,
@NotBlank @Size(max = 128) String customerName,
public record DraftData(@Positive Long customerId,
@Size(max = 128) String customerName,
@NotBlank @Size(max = 64) String channelCode,
@NotNull @Positive Long warehouseId,
@NotBlank @Size(max = 128) String warehouseName,
@@ -56,8 +56,8 @@ public final class OceanBookingDtos {
@Size(max = 32) String destinationPortCode,
@Size(max = 128) String destinationPortName,
@Size(max = 128) String placeOfDelivery,
@Size(max = 32) String serviceTerm,
@Size(max = 32) String freightTerm,
@NotBlank @Size(max = 32) String serviceTerm,
@NotBlank @Size(max = 32) String freightTerm,
LocalDateTime estimatedSailingAt,
LocalDateTime estimatedArrivalAt,
@Size(max = 1000) String remark,
@@ -286,6 +286,12 @@ public class OceanBookingService {
private static void requireStatus(OceanBookingEntity value, OceanBookingStatus status) { if (!status.name().equals(value.getStatus())) throw conflict("订舱状态不允许当前操作"); }
private void validateDraftStructure(DraftData data) {
boolean hasCustomerId = data.customerId() != null;
boolean hasCustomerName = StringUtils.hasText(data.customerName());
if (hasCustomerId != hasCustomerName) throw badRequest("客户ID和客户名称必须同时填写或同时为空");
if (!StringUtils.hasText(data.serviceTerm()) || !StringUtils.hasText(data.freightTerm())) {
throw badRequest("Service Term和Freight Term不能为空");
}
if (data.estimatedSailingAt() != null || data.estimatedArrivalAt() != null
|| StringUtils.hasText(data.billOfLadingNo()) || StringUtils.hasText(data.vesselName())
|| StringUtils.hasText(data.voyageNo())) {
@@ -329,7 +335,7 @@ public class OceanBookingService {
}
private static void copy(OceanBookingEntity v, DraftData d, String resolvedChannelName) {
v.setCustomerId(d.customerId()); v.setCustomerName(d.customerName().trim());
v.setCustomerId(d.customerId()); v.setCustomerName(trim(d.customerName()));
v.setChannelCode(d.channelCode().trim()); v.setChannelName(resolvedChannelName);
v.setWarehouseId(d.warehouseId()); v.setWarehouseName(d.warehouseName().trim());
v.setBillOfLadingNo(null);
@@ -5,8 +5,8 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
id BIGINT UNSIGNED NOT NULL COMMENT '海运订舱ID',
booking_no VARCHAR(40) NOT NULL COMMENT '平台海运订舱编号',
status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、SUBMITTED已提交、CONFIRMED已确认、RELEASED已放舱、FAILED失败、CHANGE_PENDING船期待变更、CANCELLED已取消',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照',
customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照',
customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照',
channel_code VARCHAR(64) NOT NULL COMMENT '订舱渠道编码快照',
channel_name VARCHAR(128) NOT NULL COMMENT '订舱渠道名称快照',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '国内中转仓ID快照',
@@ -23,8 +23,8 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
destination_port_code VARCHAR(32) NULL COMMENT '目的港编码快照',
destination_port_name VARCHAR(128) NULL COMMENT '目的港名称快照',
place_of_delivery VARCHAR(128) NULL COMMENT '最终交货地快照',
service_term VARCHAR(32) NULL COMMENT '运输服务条款快照',
freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照',
service_term VARCHAR(32) NOT NULL COMMENT '运输服务条款快照',
freight_term VARCHAR(32) NOT NULL COMMENT '海运费支付条款快照',
estimated_sailing_at DATETIME(3) NULL COMMENT '当前确认预计开船时间',
estimated_arrival_at DATETIME(3) NULL COMMENT '当前确认预计到港时间',
pending_vessel_name VARCHAR(128) NULL COMMENT '待确认变更船名',
@@ -40,13 +40,15 @@ components:
data: {$ref: '#/components/schemas/DraftData'}
DraftData:
type: object
required: [customerId, customerName, channelCode, warehouseId, warehouseName, parties, containerRequirements]
required: [channelCode, warehouseId, warehouseName, serviceTerm, freightTerm, parties, containerRequirements]
properties:
customerId: {type: integer, format: int64, minimum: 1}
customerName: {type: string, maxLength: 128}
customerId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional customer snapshot; must be supplied together with customerName.}
customerName: {type: string, maxLength: 128, nullable: true, description: Optional customer snapshot; must be supplied together with customerId.}
channelCode: {type: string, maxLength: 64, description: Selected from GET /api/platform/v1/ocean-bookings/channels; server validates it against the current ACTIVE price book and freezes channelName.}
warehouseId: {type: integer, format: int64, minimum: 1}
warehouseName: {type: string, maxLength: 128}
serviceTerm: {type: string, maxLength: 32, description: Required free-text Service Term snapshot, for example CY-CY.}
freightTerm: {type: string, maxLength: 32, description: Required free-text Freight Term snapshot, for example NINGBO.}
billOfLadingNo: {type: string, nullable: true, deprecated: true, description: Must be omitted until release}
vesselName: {type: string, nullable: true, deprecated: true, description: Must be omitted until release}
voyageNo: {type: string, nullable: true, deprecated: true, description: Must be omitted until release}
@@ -57,7 +57,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: [customerId, customerName, channelCode, warehouseId, warehouseName")
.contains("required: [channelCode, warehouseId, warehouseName, serviceTerm, freightTerm, parties, containerRequirements]")
.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")
.doesNotContain("required: [customerId, customerName, channelCode, channelName");
}
@@ -18,6 +18,10 @@ class OceanBookingSqlContractTest {
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_allocation")
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_status_history")
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_operation")
.contains("customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照'")
.contains("customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'")
.contains("service_term VARCHAR(32) NOT NULL COMMENT '运输服务条款快照'")
.contains("freight_term VARCHAR(32) NOT NULL COMMENT '海运费支付条款快照'")
.contains("container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '明确引用现有柜次ID'")
.contains("REFERENCES fl_container_booking(id)")
.doesNotContain("ALTER TABLE fl_container_booking");
@@ -214,6 +214,49 @@ class OceanBookingServiceTest {
assertThat(result.oceanBooking().getChannelName()).isEqualTo("价格簿美西海运");
}
@Test
void savesEmptyCustomerSnapshotAndFreeTextTransportTerms() {
ChannelRow channel = channel("SEA-US", "价格簿美西海运");
when(priceBooks.activeVersionId()).thenReturn(51L);
when(priceBooks.channels(51L, 701L)).thenReturn(List.of(channel));
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 -> inserted.get());
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 replaysCreateAfterChannelIsNoLongerActive() throws Exception {
ChannelRow channel = channel("SEA-US", "价格簿美西海运");
@@ -280,15 +323,19 @@ class OceanBookingServiceTest {
}
private static DraftData draft() {
return draft(100L, "客户", "CY-CY", "NINGBO");
}
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<ContainerRequirementRequest> requirements = List.of(
new ContainerRequirementRequest("40HQ", "DRY", BigDecimal.ONE, BigDecimal.TEN, 1));
return new DraftData(100L, "客户", "SEA-US", 701L, "深圳仓", null,
return new DraftData(customerId, customerName, "SEA-US", 701L, "深圳仓", null,
81L, "船公司", null, null, null, null, "CNSZX", "深圳", "USLAX", "洛杉矶",
null, null, null, null, null, null, parties, requirements);
null, serviceTerm, freightTerm, null, null, null, parties, requirements);
}
private String hashForSubmit() throws Exception {