优化
This commit is contained in:
+19
@@ -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())
|
||||
|
||||
+10
@@ -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",
|
||||
|
||||
+3
-3
@@ -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")
|
||||
|
||||
+34
-1
@@ -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);
|
||||
|
||||
+90
-5
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user