This commit is contained in:
2026-10-22 11:56:47 +08:00
parent 5d71af1ac9
commit de96e47d74
71 changed files with 1296 additions and 332 deletions
@@ -32,6 +32,7 @@ class PlatformOceanBookingControllerContractTest {
Map.entry("uploadDocument", "platform:firstleg:ocean-booking:edit"),
Map.entry("invalidateDocument", "platform:firstleg:ocean-booking:edit"),
Map.entry("create", "platform:firstleg:ocean-booking:create"),
Map.entry("updateActualCargo", "platform:firstleg:ocean-booking:edit"),
Map.entry("updateDraft", "platform:firstleg:ocean-booking:edit"),
Map.entry("deleteDraft", "platform:firstleg:ocean-booking:delete"),
Map.entry("release", "platform:firstleg:ocean-booking:release"));
@@ -47,6 +48,24 @@ class PlatformOceanBookingControllerContractTest {
});
}
@Test
@SuppressWarnings("unchecked")
void actualCargoContractExposesOnlySupplementFieldsAndReusesEditPermission() throws Exception {
assertThat(method("updateActualCargo").getAnnotation(org.springframework.web.bind.annotation.PutMapping.class).value())
.containsExactly("/{oceanBookingId}/actual-cargo");
Map<String, Object> document = new org.yaml.snakeyaml.Yaml().load(
Files.readString(Path.of("src/main/resources/openapi/ocean-bookings-v1.yaml")));
var paths = (Map<String, Object>) document.get("paths");
var endpoint = (Map<String, Object>) paths.get("/api/platform/v1/ocean-bookings/{oceanBookingId}/actual-cargo");
var operation = (Map<String, Object>) endpoint.get("put");
assertThat(operation).containsEntry("x-permission", "platform:firstleg:ocean-booking:edit");
var schemas = (Map<String, Object>) ((Map<String, Object>) document.get("components")).get("schemas");
var request = (Map<String, Object>) schemas.get("ActualCargoRequest");
assertThat((Map<String, Object>) request.get("properties")).containsOnlyKeys(
"expectedVersion", "reason", "actualPackageCount", "actualGrossWeightKg", "actualVolumeCbm");
assertThat((java.util.List<String>) request.get("required")).contains("expectedVersion", "reason");
}
@Test
void pathVariablesUseExplicitAggregateNames() {
assertThat(Arrays.stream(PlatformOceanBookingController.class.getDeclaredMethods())
@@ -46,6 +46,16 @@ class OceanBookingMapperContractTest {
.doesNotContain("CONFIRMED", "CHANGE_PENDING", "CANCELLED", "SUBMITTED", "FAILED");
}
@Test
void releasedSupplementSqlCannotWriteTransportOrDeclaredCargo() throws Exception {
String sql = String.join("\n", OceanBookingMapper.class.getDeclaredMethod("updateActualCargo",
com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity.class, int.class, long.class, String.class)
.getAnnotation(Update.class).value());
assertThat(sql).contains("status='RELEASED' AND version=#{version}", "version=version+1",
"actual_package_count=#{value.actualPackageCount}", "actual_gross_weight_kg=#{value.actualGrossWeightKg}", "actual_volume_cbm=#{value.actualVolumeCbm}")
.doesNotContain("bill_of_lading_no=", "vessel_name=", "declared_", "warehouse_id=", "package_unit=");
}
@Test
void linkCandidatesUsesOneBoundedSetQueryAndExactRemainingSemantics() throws Exception {
String sql = String.join("\n", OceanBookingMapper.class.getDeclaredMethod("selectLinkCandidates",
@@ -27,8 +27,8 @@ class OceanBookingSqlContractTest {
.contains("MODIFY COLUMN customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'")
.contains("UNIQUE KEY uk_ocean_booking_bill_of_lading (bill_of_lading_no)")
.contains("ADD UNIQUE KEY uk_ocean_booking_bill_of_lading (bill_of_lading_no)")
.doesNotContain("service_term")
.doesNotContain("freight_term")
.contains("service_term", "declared_package_count", "actual_package_count")
.contains("freight_term", "contact_email", "tax_no")
.contains("carrier_code VARCHAR(16) NULL COMMENT '船司标准编码")
.contains("carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')")
.contains("container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '明确引用现有柜次ID'")
@@ -49,7 +49,7 @@ class OceanBookingSqlContractTest {
void enforcesStateIdempotencyAndAuditContracts() throws Exception {
assertThat(sql())
.contains("OLD.status='DRAFT' AND NEW.status='RELEASED'")
.contains("OLD.status = 'RELEASED'")
.contains("OLD.status = 'RELEASED'", "NEW.declared_package_count <=> OLD.declared_package_count", "NEW.bill_of_lading_no <=> OLD.bill_of_lading_no", "NEW.version = OLD.version + 1")
.contains("UNIQUE KEY uk_ocean_operation (operator_id,operation,idempotency_key)")
.contains("request_hash CHAR(64) NOT NULL")
.contains("<> 'RELEASED' THEN")
@@ -49,7 +49,7 @@ class OceanBookingPrintServiceTest {
assertThat(document.fields()).containsEntry("jobNumber", "OB-1")
.containsEntry("shipper", "Shipper\nAddress")
.containsEntry("oceanVesselVoyage", "Vessel / V001")
.containsEntry("cargoQuantity", "—")
.containsEntry("cargoQuantity", "2*40HQ")
.containsEntry("totalPackagesInWords", "—")
.containsKeys("toCompany", "consignee", "notifyParty", "cargoQuantity",
"portOfLoading", "portOfDischarge", "placeOfDelivery", "totalPackagesInWords");
@@ -95,6 +95,39 @@ class OceanBookingPrintServiceTest {
.containsEntry("totalPackagesInWords", "—");
}
@Test void routesDeclaredAndActualCargoSeparatelyAndGeneratesWords() {
Detail detail = detail("RELEASED");
var booking = detail.oceanBooking();
booking.setPackageUnit("CARTONS"); booking.setDeclaredPackageCount(500); booking.setActualPackageCount(480);
booking.setDeclaredGrossWeightKg(new BigDecimal("15000")); booking.setDeclaredVolumeCbm(new BigDecimal("68"));
booking.setActualGrossWeightKg(new BigDecimal("14320.125")); booking.setActualVolumeCbm(new BigDecimal("65.321"));
booking.setCargoDescription("HOUSEHOLD GOODS"); booking.setMarksAndNos("N/M");
booking.setServiceTerm("CY-CY"); booking.setFreightTerm("FREIGHT PREPAID");
detail.parties().get(0).setContactEmail("ops@example.test"); detail.parties().get(0).setTaxNo("TEST-TAX");
when(bookings.detail(10L)).thenReturn(detail);
when(print.generateFreightA4Documents(any(), any(), any()))
.thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF).body(new byte[]{1}));
service.shippingInstruction(10L, "split"); service.billConfirmation(10L, "split");
var capture = ArgumentCaptor.forClass(PrintPdfClient.FreightA4PdfRequest.class);
verify(print, times(2)).generateFreightA4Documents(any(), any(), capture.capture());
var declared = capture.getAllValues().get(0).documents().get(0);
var actual = capture.getAllValues().get(1).documents().get(0);
assertThat(declared.cargoRows().get(0).quantity()).isEqualTo("500");
assertThat(declared.cargoRows().get(0).grossWeight()).isEqualTo("15000.000 KGS");
assertThat(declared.fields()).containsEntry("totalPackagesInWords", "SAY FIVE HUNDRED CARTONS ONLY")
.containsEntry("serviceTerm", "CY-CY").containsEntry("freightTerm", "FREIGHT PREPAID");
assertThat(declared.fields().get("shipper")).contains("ops@example.test", "TAX NO: TEST-TAX");
assertThat(actual.cargoRows().get(0).quantity()).isEqualTo("480");
assertThat(actual.cargoRows().get(0).grossWeight()).isEqualTo("14320.125 KGS");
assertThat(actual.cargoRows().get(0).measurement()).isEqualTo("65.321 CBM");
assertThat(actual.fields()).containsEntry("totalPackagesInWords", "SAY FOUR HUNDRED EIGHTY CARTONS ONLY");
booking.setActualPackageCount(null); booking.setActualGrossWeightKg(null); booking.setActualVolumeCbm(null);
service.billConfirmation(10L, "missing");
verify(print).generateFreightA4Documents(any(), eq("missing"), capture.capture());
assertThat(capture.getValue().documents().get(0).cargoRows().get(0).quantity()).isEqualTo("—");
assertThat(capture.getValue().documents().get(0).fields()).containsEntry("totalPackagesInWords", "—");
}
private static Detail detail(String status) {
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(10L); booking.setBookingNo("OB-1"); booking.setStatus(status);
@@ -386,6 +386,91 @@ class OceanBookingServiceTest {
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString());
}
@Test
void supplementsReleasedActualCargoWithVersionAndBeforeAfterAudit() {
OceanBookingEntity before = completeBooking("RELEASED", 3);
before.setDeclaredPackageCount(500);
before.setActualPackageCount(450);
AtomicReference<OceanBookingEntity> stored = new AtomicReference<>(before);
when(repository.find(1L)).thenAnswer(invocation -> stored.get());
when(repository.updateActualCargo(any(), eq(3), eq(9L), eq("admin"))).thenAnswer(invocation -> {
OceanBookingEntity after = invocation.getArgument(0);
after.setVersion(4); stored.set(after); return 1;
});
var request = new ActualCargoRequest(3, 480, new BigDecimal("14000.125"), new BigDecimal("65.321"), "修正实装货量");
var result = service.updateActualCargo(1L, 9L, "admin", "actual-save", request);
assertThat(result.oceanBooking().getActualPackageCount()).isEqualTo(480);
assertThat(result.oceanBooking().getDeclaredPackageCount()).isEqualTo(500);
assertThat(result.oceanBooking().getBillOfLadingNo()).isEqualTo("BL-001");
assertThat(before.getActualPackageCount()).isEqualTo(450);
var history = ArgumentCaptor.forClass(com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity.class);
verify(repository).insert(history.capture());
assertThat(history.getValue().getAction()).isEqualTo("UPDATE_ACTUAL_CARGO");
assertThat(history.getValue().getBeforeSnapshot()).contains("\"actualPackageCount\":450");
assertThat(history.getValue().getAfterSnapshot()).contains("\"actualPackageCount\":480");
assertThat(history.getValue().getReason()).isEqualTo("修正实装货量");
verify(repository, never()).updateDraft(any(), anyInt(), anyLong(), any());
verify(repository, never()).replaceRequirements(anyLong(), any());
verifyNoInteractions(documents, priceBooks, suppliers, warehouses);
}
@Test
void rejectsActualCargoOnDraftOrStaleVersionAndRequiresReason() {
OceanBookingEntity value = completeBooking("DRAFT", 3);
when(repository.find(1L)).thenReturn(value);
var request = new ActualCargoRequest(3, 100, null, null, "补录");
assertThatThrownBy(() -> service.updateActualCargo(1L, 9L, "admin", "actual-draft", request)).isInstanceOf(BizException.class);
value.setStatus("RELEASED");
assertThatThrownBy(() -> service.updateActualCargo(1L, 9L, "admin", "actual-stale",
new ActualCargoRequest(2, 100, null, null, "补录"))).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.updateActualCargo(1L, 9L, "admin", "actual-no-reason",
new ActualCargoRequest(3, 100, null, null, " "))).isInstanceOf(BizException.class).hasMessageContaining("原因");
assertThatThrownBy(() -> service.updateActualCargo(1L, 9L, "admin", "actual-negative",
new ActualCargoRequest(3, 100, new BigDecimal("-1"), null, "补录"))).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.updateActualCargo(1L, 9L, "admin", "actual-precision",
new ActualCargoRequest(3, 100, null, new BigDecimal("1.0001"), "补录"))).isInstanceOf(BizException.class);
when(repository.updateActualCargo(any(), anyInt(), anyLong(), any())).thenReturn(0);
assertThatThrownBy(() -> service.updateActualCargo(1L, 9L, "admin", "actual-concurrent", request)).isInstanceOf(BizException.class);
verify(repository, never()).insert(any(com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity.class));
}
@Test
void replayedActualCargoDoesNotWriteAgain() throws Exception {
var request = new ActualCargoRequest(3, 100, null, null, "补录");
var replay = new Detail(completeBooking("RELEASED", 4), List.of(), List.of(), List.of(), List.of(), List.of());
var operation = new OceanBookingOperationEntity();
operation.setRequestHash(hash("UPDATE_ACTUAL_CARGO", 1L, request));
operation.setStatus("SUCCESS"); operation.setResultJson(objectMapper.writeValueAsString(replay));
when(repository.claimOperation(1L, 9L, "UPDATE_ACTUAL_CARGO", "actual-replay", operation.getRequestHash())).thenReturn(false);
when(repository.operation(9L, "UPDATE_ACTUAL_CARGO", "actual-replay")).thenReturn(operation);
assertThat(service.updateActualCargo(1L, 9L, "admin", "actual-replay", request).oceanBooking().getVersion()).isEqualTo(4);
verify(repository, never()).updateActualCargo(any(), anyInt(), anyLong(), any());
}
@Test
void savesUnnamedPartyContactDraftButRequiresNamesBeforeRelease() throws Exception {
var tree = (com.fasterxml.jackson.databind.node.ObjectNode) objectMapper.valueToTree(partialDraft(null));
tree.putArray("parties").addObject().put("partyType", "SHIPPER").put("partyName", "")
.put("contactEmail", "ops@example.test").put("taxNo", "TEST-TAX");
stubCreatedDraft();
service.create(9L, "admin", "partial-party", new CreateRequest(0, objectMapper.treeToValue(tree, DraftData.class)));
@SuppressWarnings("unchecked")
ArgumentCaptor<List<com.baiyue.firstleg.oceanbooking.entity.OceanBookingPartyEntity>> capture = ArgumentCaptor.forClass(List.class);
verify(repository).replaceParties(anyLong(), capture.capture());
assertThat(capture.getValue()).singleElement().satisfies(party -> {
assertThat(party.getContactEmail()).isEqualTo("ops@example.test");
assertThat(party.getTaxNo()).isEqualTo("TEST-TAX");
});
var draft = completeBooking("DRAFT", 3);
when(repository.find(1L)).thenReturn(draft);
var parties = parties(); parties.get(0).setPartyName("");
when(repository.parties(1L)).thenReturn(parties);
when(repository.requirements(1L)).thenReturn(List.of(requirement()));
assertThatThrownBy(() -> service.release(1L, 9L, "admin", "missing-party", new ReleaseRequest(3, null)))
.isInstanceOf(BizException.class).hasMessageContaining("尚未填写完整");
verify(repository, never()).transition(anyLong(), any(), any(), anyInt(), anyLong(), any());
}
private AtomicReference<OceanBookingEntity> stubCreatedDraft() {
AtomicReference<OceanBookingEntity> inserted = new AtomicReference<>();
doAnswer(invocation -> { inserted.set(invocation.getArgument(0)); return null; })
@@ -412,7 +497,7 @@ class OceanBookingServiceTest {
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, remark, null, null);
null, null, null, null, null, null, null, null, null, remark, null, null, null, null, null, null, null, null, null, null, null, null, null);
}
private static DraftData completeDraft() {
@@ -420,11 +505,11 @@ class OceanBookingServiceTest {
81L, null, "VESSEL", "V001", "CNSZX", "深圳", "USLAX", "洛杉矶",
"LAX", 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 PartyRequest("SHIPPER", "发货人", null, null, null, null, null),
new PartyRequest("CONSIGNEE", "收货人", null, null, null, null, null),
new PartyRequest("NOTIFY_PARTY", "通知人", null, null, null, null, null)),
List.of(new ContainerRequirementRequest("40HQ", "DRY", 2,
new BigDecimal("68"), new BigDecimal("19500"))));
new BigDecimal("68"), new BigDecimal("19500"))), null, null, null, null, null, null, null, null, null, null, null);
}
private static OceanBookingEntity completeBooking(String status, int version) {