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
+1 -1
View File
@@ -79,7 +79,7 @@
- 客户箱规配置必须恰好保持四档且默认算法在四行一致;`ADD` 以实测尺寸为基准对三个维度分别增加命中箱规的尺寸值,`MULTIPLY` 使用当前客户唯一默认计费规则,并由服务端按规则中的规格档位选择系数。测量行必须保存实际尺寸、选用规则和算法快照,页面不得自行重算后覆盖服务端结果;计价使用用户确认的重量/体积选择值并纳入幂等请求哈希。
- 报关、订舱和拖车引用的供应商必须通过 `WarehouseSupplierSnapshotService` 按 ID 向 Warehouse 校验:报关使用 `CUSTOMS`,订舱使用 `BOOKING`(不区分国内/国外 scope),国内拖车使用 `TRUCKING + truckingScope=DOMESTIC`,清关提柜/目的港码头拖车使用 `TRUCKING + truckingScope=OVERSEAS`;供应商必须为 `ACTIVE`。请求中的名称只能作为一致性提示,持久化名称必须来自 Warehouse 快照,禁止用自由文本绕过 ID、类别或启用状态校验。修改任一供应商引用 DTO、OpenAPI 或内部客户端时,必须同步检查三条业务链、Warehouse `supplier-v1.yaml`、数据库快照字段和对应契约测试。
- Order 渠道变更门禁必须检查装柜或后续状态、PDA task、预装、分柜及同柜组兼容性;任何不确定结果均失败关闭。`ORDER_DECLARATION_UPDATED_V1` 消费必须校验订单号/入仓码归属,永久保留 `eventId + payload hash` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。
- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。
- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后运输、预报货量、三方和柜型需求只读;仅三项实际货量允许通过 `OceanBookingService.updateActualCargo` 按编辑权限、版本、幂等键和原因补录并保存前后审计快照。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。
- 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。
- 订舱号遵循 `../../../docs/业务单据编号契约.md`;不得以实体 ID 拼接生成。提单号在订舱主表全局唯一,柜次表只保存快照;真实集装箱号只在同一订舱内唯一,不同提单允许复用。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。
- 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。
@@ -126,6 +126,17 @@ public class PlatformOceanBookingController {
@PutMapping("/{oceanBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:edit')")
@BusinessAudit(event="OCEAN_BOOKING_UPDATE_DRAFT", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="编辑海运订舱草稿")
public ApiResponse<Detail> updateDraft(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody UpdateRequest request) { validateOptionalCustomer(user, request.data().customerId()); return ApiResponse.success(service.updateDraft(oceanBookingId, user.userId(), user.username(), key, request)); }
/** 客服在放舱后补录实货量,沿用订舱编辑权限并记录审计。 */
@PutMapping("/{oceanBookingId}/actual-cargo")
@PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:edit')")
@BusinessAudit(event="OCEAN_BOOKING_UPDATE_ACTUAL_CARGO", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="补录订舱实际货量")
public ApiResponse<Detail> updateActualCargo(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ActualCargoRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.updateActualCargo(oceanBookingId, user.userId(), user.username(), key, request));
}
@DeleteMapping("/{oceanBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:delete')")
@BusinessAudit(event="OCEAN_BOOKING_DELETE_DRAFT", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="删除海运订舱草稿")
public ApiResponse<Void> deleteDraft(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); service.deleteDraft(oceanBookingId, user.userId(), user.username(), key, request); return ApiResponse.success(null); }
@@ -45,10 +45,12 @@ public final class OceanBookingDtos {
String customerName, String warehouseName, String channelName) {}
public record PartyRequest(@NotBlank @Size(max = 24) String partyType,
@NotBlank @Size(max = 255) String partyName,
@NotNull @Size(max = 255) String partyName,
@Size(max = 1000) String address,
@Size(max = 128) String contactName,
@Size(max = 64) String contactPhone) {}
@Size(max = 64) String contactPhone,
@jakarta.validation.constraints.Email @Size(max = 255) String contactEmail,
@Size(max = 128) String taxNo) {}
public record ContainerRequirementRequest(@NotBlank @Size(max = 32) String containerType,
@NotBlank @Size(max = 32) String containerKind,
@@ -76,7 +78,31 @@ public final class OceanBookingDtos {
LocalDateTime estimatedArrivalAt,
@Size(max = 1000) String remark,
@Valid @Size(max = 3) List<PartyRequest> parties,
@Valid @Size(max = 20) List<ContainerRequirementRequest> containerRequirements) {}
@Valid @Size(max = 20) List<ContainerRequirementRequest> containerRequirements,
@Size(max = 64) String freightTerm,
@Size(max = 64) String serviceTerm,
@Pattern(regexp = "CARTONS|PCS|PALLETS") String packageUnit,
@Size(max = 1000) String cargoDescription,
@Size(max = 1000) String marksAndNos,
@Min(1) @Max(999999999) Integer declaredPackageCount,
@jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false)
@jakarta.validation.constraints.Digits(integer = 12, fraction = 3) BigDecimal declaredGrossWeightKg,
@jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false)
@jakarta.validation.constraints.Digits(integer = 12, fraction = 3) BigDecimal declaredVolumeCbm,
@Min(1) @Max(999999999) Integer actualPackageCount,
@jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false)
@jakarta.validation.constraints.Digits(integer = 12, fraction = 3) BigDecimal actualGrossWeightKg,
@jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false)
@jakarta.validation.constraints.Digits(integer = 12, fraction = 3) BigDecimal actualVolumeCbm) {}
/** 放舱后的受控实货量补录,不接收运输资料。 */
public record ActualCargoRequest(@NotNull @Min(0) Integer expectedVersion,
@Min(1) @Max(999999999) Integer actualPackageCount,
@jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false)
@jakarta.validation.constraints.Digits(integer = 12, fraction = 3) BigDecimal actualGrossWeightKg,
@jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false)
@jakarta.validation.constraints.Digits(integer = 12, fraction = 3) BigDecimal actualVolumeCbm,
@NotBlank @Size(max = 1000) String reason) {}
public record CreateRequest(@NotNull @Min(0) Integer expectedVersion,
@Valid @NotNull DraftData data) {}
@@ -40,6 +40,17 @@ public class OceanBookingEntity {
private LocalDateTime estimatedSailingAt;
private LocalDateTime estimatedArrivalAt;
private String remark;
private String freightTerm;
private String serviceTerm;
private String packageUnit;
private String cargoDescription;
private String marksAndNos;
private Integer declaredPackageCount;
private java.math.BigDecimal declaredGrossWeightKg;
private java.math.BigDecimal declaredVolumeCbm;
private Integer actualPackageCount;
private java.math.BigDecimal actualGrossWeightKg;
private java.math.BigDecimal actualVolumeCbm;
private Long createdBy;
private String createdByName;
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
@@ -17,4 +17,6 @@ public class OceanBookingPartyEntity {
private String address;
private String contactName;
private String contactPhone;
private String contactEmail;
private String taxNo;
}
@@ -25,8 +25,17 @@ public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
""")
int touchDraft(@Param("id") long id, @Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@Update("UPDATE fl_ocean_booking SET customer_id=#{value.customerId},customer_name=#{value.customerName},channel_code=#{value.channelCode},channel_name=#{value.channelName},warehouse_id=#{value.warehouseId},warehouse_name=#{value.warehouseName},bill_of_lading_no=#{value.billOfLadingNo},shipping_company_id=#{value.shippingCompanyId},shipping_company_name=#{value.shippingCompanyName},carrier_code=#{value.carrierCode},booking_agent_id=#{value.bookingAgentId},booking_agent_name=#{value.bookingAgentName},vessel_name=#{value.vesselName},voyage_no=#{value.voyageNo},departure_port_code=#{value.departurePortCode},departure_port_name=#{value.departurePortName},destination_port_code=#{value.destinationPortCode},destination_port_name=#{value.destinationPortName},place_of_delivery=#{value.placeOfDelivery},estimated_sailing_at=#{value.estimatedSailingAt},estimated_arrival_at=#{value.estimatedArrivalAt},remark=#{value.remark},updated_by=#{operatorId},updated_by_name=#{operatorName},version=version+1,updated_at=NOW() WHERE id=#{id} AND status='DRAFT' AND version=#{version}")
@Update("UPDATE fl_ocean_booking SET customer_id=#{value.customerId},customer_name=#{value.customerName},channel_code=#{value.channelCode},channel_name=#{value.channelName},warehouse_id=#{value.warehouseId},warehouse_name=#{value.warehouseName},bill_of_lading_no=#{value.billOfLadingNo},shipping_company_id=#{value.shippingCompanyId},shipping_company_name=#{value.shippingCompanyName},carrier_code=#{value.carrierCode},booking_agent_id=#{value.bookingAgentId},booking_agent_name=#{value.bookingAgentName},vessel_name=#{value.vesselName},voyage_no=#{value.voyageNo},departure_port_code=#{value.departurePortCode},departure_port_name=#{value.departurePortName},destination_port_code=#{value.destinationPortCode},destination_port_name=#{value.destinationPortName},place_of_delivery=#{value.placeOfDelivery},estimated_sailing_at=#{value.estimatedSailingAt},estimated_arrival_at=#{value.estimatedArrivalAt},remark=#{value.remark},freight_term=#{value.freightTerm},service_term=#{value.serviceTerm},package_unit=#{value.packageUnit},cargo_description=#{value.cargoDescription},marks_and_nos=#{value.marksAndNos},declared_package_count=#{value.declaredPackageCount},declared_gross_weight_kg=#{value.declaredGrossWeightKg},declared_volume_cbm=#{value.declaredVolumeCbm},actual_package_count=#{value.actualPackageCount},actual_gross_weight_kg=#{value.actualGrossWeightKg},actual_volume_cbm=#{value.actualVolumeCbm},updated_by=#{operatorId},updated_by_name=#{operatorName},version=version+1,updated_at=NOW() WHERE id=#{id} AND status='DRAFT' AND version=#{version}")
int updateDraft(@Param("id") long id, @Param("value") OceanBookingEntity value, @Param("version") int version, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
/** 已放舱补实只更新白名单字段,保留乐观锁及状态门禁。 */
@Update("""
UPDATE fl_ocean_booking SET actual_package_count=#{value.actualPackageCount},
actual_gross_weight_kg=#{value.actualGrossWeightKg},actual_volume_cbm=#{value.actualVolumeCbm},
updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{value.id} AND status='RELEASED' AND version=#{version}
""")
int updateActualCargo(@Param("value") OceanBookingEntity value, @Param("version") int version,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@Delete("DELETE FROM fl_ocean_booking WHERE id=#{id} AND status='DRAFT' AND version=#{version}")
int deleteDraft(@Param("id") long id, @Param("version") int version);
@Select("""
@@ -54,6 +54,10 @@ public class OceanBookingRepository {
public int touchDraft(long id, int version, long operatorId, String operatorName) {
return bookings.touchDraft(id, version, operatorId, operatorName);
}
/** 只更新已放舱订舱的实际货量,运输快照保持冻结。 */
public int updateActualCargo(OceanBookingEntity value, int version, long operatorId, String operatorName) {
return bookings.updateActualCargo(value, version, operatorId, operatorName);
}
public int deleteDraft(long id, int version) {
return bookings.deleteDraft(id, version);
}
@@ -46,7 +46,7 @@ public class OceanBookingPrintService {
String fileName = booking.getBookingNo() + "-" + suffix + ".pdf";
PrintPdfClient.FreightA4PdfRequest request = new PrintPdfClient.FreightA4PdfRequest(
template, A4, fileName, List.of(new PrintPdfClient.FreightDocument(
fields(template, detail), List.of(emptyCargo()))));
fields(template, detail), List.of(cargo(template, booking)))));
try {
ResponseEntity<byte[]> response = print.generateFreightA4Documents(internalToken, requestId, request);
MediaType contentType = response.getHeaders().getContentType();
@@ -75,14 +75,21 @@ public class OceanBookingPrintService {
fields.put("portOfLoading", first(booking.getDeparturePortName(), booking.getDeparturePortCode()));
fields.put("portOfDischarge", first(booking.getDestinationPortName(), booking.getDestinationPortCode()));
fields.put("placeOfDelivery", text(booking.getPlaceOfDelivery()));
fields.put("serviceTerm", "");
fields.put("freightTerm", "");
fields.put("totalPackagesInWords", "");
fields.put("serviceTerm", text(booking.getServiceTerm()));
fields.put("freightTerm", text(booking.getFreightTerm()));
Integer count = SHIPPING_INSTRUCTION.equals(template) ? booking.getDeclaredPackageCount() : booking.getActualPackageCount();
fields.put("totalPackagesInWords", packageWords(count, booking.getPackageUnit()));
if (SHIPPING_INSTRUCTION.equals(template)) {
fields.put("toCompany", text(booking.getBookingAgentName()));
fields.put("jobNumber", text(booking.getBookingNo()));
fields.put("etdDate", date(booking.getEstimatedSailingAt()));
fields.put("cargoQuantity", "");
fields.put("cargoQuantity", detail.containerRequirements().stream()
.map(value -> value.getQuantity() + "*" + value.getContainerType()).collect(Collectors.joining(" + ")));
OceanBookingPartyEntity shipper = parties.get("SHIPPER");
fields.put("picName", shipper == null ? "" : text(shipper.getContactName()));
fields.put("picTel", shipper == null ? "" : text(shipper.getContactPhone()));
fields.put("apName", text(booking.getBookingAgentName()));
fields.put("printDate", java.time.LocalDate.now(java.time.ZoneId.of("Asia/Shanghai")).toString());
fields.put("carrier", text(booking.getShippingCompanyName()));
fields.put("bookingAgent", text(booking.getBookingAgentName()));
fields.put("remark", text(booking.getRemark()));
@@ -98,8 +105,43 @@ public class OceanBookingPrintService {
return fields;
}
private static PrintPdfClient.CargoRow emptyCargo() {
return new PrintPdfClient.CargoRow("—", "—", "—", "—", "—", "—");
/** 两类单据严格分别读取委、实数据,不以柜型容量或预报量代替实装量。 */
private static PrintPdfClient.CargoRow cargo(String template, OceanBookingEntity booking) {
boolean declared = SHIPPING_INSTRUCTION.equals(template);
Integer count = declared ? booking.getDeclaredPackageCount() : booking.getActualPackageCount();
BigDecimal weight = declared ? booking.getDeclaredGrossWeightKg() : booking.getActualGrossWeightKg();
BigDecimal volume = declared ? booking.getDeclaredVolumeCbm() : booking.getActualVolumeCbm();
return new PrintPdfClient.CargoRow(display(booking.getMarksAndNos()), count == null ? "—" : count.toString(),
display(booking.getPackageUnit()), display(booking.getCargoDescription()),
weight == null ? "—" : weight.setScale(3, java.math.RoundingMode.HALF_UP).toPlainString() + " KGS",
volume == null ? "—" : volume.setScale(3, java.math.RoundingMode.HALF_UP).toPlainString() + " CBM");
}
/** 打印空字段使用统一占位符。 */
private static String display(String value) { return text(value).isEmpty() ? "—" : text(value); }
/** 件数大写跟随所选数据和包装单位生成,未录入时明确留空。 */
private static String packageWords(Integer count, String unit) {
if (count == null || !org.springframework.util.StringUtils.hasText(unit)) return "";
String packaging = count == 1 ? switch (unit) {
case "CARTONS" -> "CARTON";
case "PALLETS" -> "PALLET";
case "PCS" -> "PIECE";
default -> unit;
} : ("PCS".equals(unit) ? "PIECES" : unit);
return "SAY " + words(count) + " " + packaging + " ONLY";
}
/** 将受件数范围约束的正整数转换为英文数量。 */
private static String words(int value) {
String[] small = {"ZERO", "ONE", "TWO", "THREE", "FOUR", "FIVE", "SIX", "SEVEN", "EIGHT", "NINE", "TEN",
"ELEVEN", "TWELVE", "THIRTEEN", "FOURTEEN", "FIFTEEN", "SIXTEEN", "SEVENTEEN", "EIGHTEEN", "NINETEEN"};
String[] tens = {"", "", "TWENTY", "THIRTY", "FORTY", "FIFTY", "SIXTY", "SEVENTY", "EIGHTY", "NINETY"};
if (value < 20) return small[value];
if (value < 100) return tens[value / 10] + (value % 10 == 0 ? "" : " " + words(value % 10));
int divisor = value >= 1000000 ? 1000000 : value >= 1000 ? 1000 : 100;
String scale = divisor == 1000000 ? "MILLION" : divisor == 1000 ? "THOUSAND" : "HUNDRED";
return words(value / divisor) + " " + scale + (value % divisor == 0 ? "" : " " + words(value % divisor));
}
private static String requirementSummary(List<OceanBookingContainerRequirementEntity> values) {
@@ -110,7 +152,8 @@ public class OceanBookingPrintService {
private static String party(OceanBookingPartyEntity value) {
if (value == null) return "";
return List.of(text(value.getPartyName()), text(value.getAddress()),
join(value.getContactName(), value.getContactPhone())).stream()
join(value.getContactName(), value.getContactPhone()), text(value.getContactEmail()),
text(value.getTaxNo()).isEmpty() ? "" : "TAX NO: " + text(value.getTaxNo())).stream()
.filter(part -> !part.isEmpty()).collect(Collectors.joining("\n"));
}
@@ -145,6 +145,26 @@ public class OceanBookingService {
addHistory(id, OceanBookingStatus.DRAFT.name(), OceanBookingStatus.DRAFT.name(), action, null, before, after, operatorId, operatorName);
return complete(id, operatorId, action, key, detail(after));
}
/** 放舱后仅补录实货量,版本、幂等结果和修改前后快照在同一事务保存。 */
@Transactional
public Detail updateActualCargo(long id, long operatorId, String name, String key, ActualCargoRequest request) {
String action = "UPDATE_ACTUAL_CARGO";
Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request));
if (replay != null) return replay;
OceanBookingEntity before = requiredVersionForUpdate(id, request.expectedVersion());
requireStatus(before, OceanBookingStatus.RELEASED);
if (!StringUtils.hasText(request.reason())) throw badRequest("请填写实货量补录或修正原因");
validateCargo(request.actualPackageCount(), request.actualGrossWeightKg(), request.actualVolumeCbm());
OceanBookingEntity after = objectMapper.convertValue(before, OceanBookingEntity.class);
after.setActualPackageCount(request.actualPackageCount());
after.setActualGrossWeightKg(request.actualGrossWeightKg());
after.setActualVolumeCbm(request.actualVolumeCbm());
if (repository.updateActualCargo(after, request.expectedVersion(), operatorId, name) != 1) throw concurrent();
OceanBookingEntity saved = required(id);
addHistory(id, before.getStatus(), saved.getStatus(), action, request.reason(), before, saved, operatorId, name);
return complete(id, operatorId, action, key, detail(saved));
}
@Transactional
public void deleteDraft(long id, long operatorId, String operatorName, String key, ActionRequest request) {
String action = "DELETE_DRAFT";
@@ -209,7 +229,18 @@ public class OceanBookingService {
private OceanBookingEntity requiredVersionForUpdate(long id, int version) { OceanBookingEntity value = repository.findForUpdate(id); if (value == null) throw new BizException(ResultCode.NOT_FOUND, "订舱单不存在"); if (!Integer.valueOf(version).equals(value.getVersion())) throw concurrent(); return value; }
private static void requireStatus(OceanBookingEntity value, OceanBookingStatus status) { if (!status.name().equals(value.getStatus())) throw conflict("订舱状态不允许当前操作"); }
/** 未采集使用空值;已填写的件数、重量和体积必须为正数且不超出存储精度。 */
private static void validateCargo(Integer count, java.math.BigDecimal weight, java.math.BigDecimal volume) {
if (count != null && (count < 1 || count > 999999999)) throw badRequest("货物件数必须为1到999999999的整数");
for (java.math.BigDecimal number : new java.math.BigDecimal[] {weight, volume}) {
if (number != null && (number.signum() <= 0 || number.scale() > 3
|| number.precision() - number.scale() > 12)) throw badRequest("货物重量和体积必须为正数,最多12位整数和3位小数");
}
}
private void validateDraftStructure(DraftData data) {
validateCargo(data.declaredPackageCount(), data.declaredGrossWeightKg(), data.declaredVolumeCbm());
validateCargo(data.actualPackageCount(), data.actualGrossWeightKg(), data.actualVolumeCbm());
boolean hasCustomerId = data.customerId() != null;
boolean hasCustomerName = StringUtils.hasText(data.customerName());
if (!hasCustomerId && hasCustomerName) throw badRequest("客户名称不能脱离客户ID提交");
@@ -242,6 +273,7 @@ public class OceanBookingService {
|| !StringUtils.hasText(booking.getPlaceOfDelivery())
|| !StringUtils.hasText(booking.getCarrierCode())
|| booking.getBookingAgentId() == null || parties == null || parties.size() != 3
|| (parties != null && parties.stream().anyMatch(party -> !StringUtils.hasText(party.getPartyName())))
|| requirements == null || requirements.isEmpty()) throw badRequest("订舱资料尚未填写完整,无法发布");
validateRange(booking.getEstimatedSailingAt(), booking.getEstimatedArrivalAt());
}
@@ -298,6 +330,17 @@ public class OceanBookingService {
v.setPlaceOfDelivery(trim(d.placeOfDelivery()));
v.setEstimatedSailingAt(d.estimatedSailingAt()); v.setEstimatedArrivalAt(d.estimatedArrivalAt());
v.setRemark(trim(d.remark()));
v.setFreightTerm(trim(d.freightTerm()));
v.setServiceTerm(trim(d.serviceTerm()));
v.setPackageUnit(trim(d.packageUnit()));
v.setCargoDescription(trim(d.cargoDescription()));
v.setMarksAndNos(trim(d.marksAndNos()));
v.setDeclaredPackageCount(d.declaredPackageCount());
v.setDeclaredGrossWeightKg(d.declaredGrossWeightKg());
v.setDeclaredVolumeCbm(d.declaredVolumeCbm());
v.setActualPackageCount(d.actualPackageCount());
v.setActualGrossWeightKg(d.actualGrossWeightKg());
v.setActualVolumeCbm(d.actualVolumeCbm());
}
private void requireUniqueBillOfLadingNo(String billOfLadingNo, Long excludedId) {
@@ -370,7 +413,7 @@ public class OceanBookingService {
return normalized;
}
private static List<OceanBookingPartyEntity> parties(long id, List<PartyRequest> requests) { if (requests == null) return List.of(); return requests.stream().map(r -> { OceanBookingPartyEntity v = new OceanBookingPartyEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setPartyType(r.partyType().trim().toUpperCase()); v.setPartyName(r.partyName().trim()); v.setAddress(trim(r.address())); v.setContactName(trim(r.contactName())); v.setContactPhone(trim(r.contactPhone())); return v; }).toList(); }
private static List<OceanBookingPartyEntity> parties(long id, List<PartyRequest> requests) { if (requests == null) return List.of(); return requests.stream().map(r -> { OceanBookingPartyEntity v = new OceanBookingPartyEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setPartyType(r.partyType().trim().toUpperCase()); v.setPartyName(r.partyName().trim()); v.setAddress(trim(r.address())); v.setContactName(trim(r.contactName())); v.setContactPhone(trim(r.contactPhone())); v.setContactEmail(trim(r.contactEmail())); v.setTaxNo(trim(r.taxNo())); return v; }).toList(); }
private static List<OceanBookingContainerRequirementEntity> requirements(long id, List<ContainerRequirementRequest> requests) { if (requests == null) return List.of(); int[] sort = {0}; return requests.stream().map(r -> { var specification = ContainerSpecificationCatalog.requireMatch(r.containerType(), r.capacityCbm(), r.maxWeightKg()); OceanBookingContainerRequirementEntity v = new OceanBookingContainerRequirementEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setContainerType(specification.containerType()); v.setContainerKind(r.containerKind().trim().toUpperCase()); v.setCapacityCbm(specification.capacityCbm()); v.setMaxWeightKg(specification.maxWeightKg()); v.setQuantity(r.quantity()); v.setSortNo(sort[0]++); return v; }).toList(); }
private WarehouseMasterDataClient.Warehouse requireDomesticTransitWarehouse(long warehouseId) {
try {
@@ -4,7 +4,7 @@ USE baiyue_firstleg;
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草稿、RELEASED已发布且永久只读',
status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、RELEASED已放舱,运输快照冻结且实货量受控补录',
customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照',
customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照',
channel_code VARCHAR(64) NULL COMMENT '订舱计划物流渠道组编码快照',
@@ -27,6 +27,17 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
estimated_sailing_at DATETIME(3) NULL COMMENT '当前确认预计开船时间',
estimated_arrival_at DATETIME(3) NULL COMMENT '当前确认预计到港时间',
remark VARCHAR(1000) NULL COMMENT '平台内部备注',
freight_term VARCHAR(64) NULL COMMENT '运费条款',
service_term VARCHAR(64) NULL COMMENT '运输条款',
package_unit VARCHAR(16) NULL COMMENT '包装单位:CARTONS、PCS或PALLETS',
cargo_description VARCHAR(1000) NULL COMMENT '货物品名及托运要求',
marks_and_nos VARCHAR(1000) NULL COMMENT '唛头和号码',
declared_package_count INT UNSIGNED NULL COMMENT '订舱预报件数(委)',
declared_gross_weight_kg DECIMAL(15,3) NULL COMMENT '订舱预报毛重kg(委)',
declared_volume_cbm DECIMAL(15,3) NULL COMMENT '订舱预报体积cbm(委)',
actual_package_count INT UNSIGNED NULL COMMENT '客服手工实货件数',
actual_gross_weight_kg DECIMAL(15,3) NULL COMMENT '客服手工实货毛重kg',
actual_volume_cbm DECIMAL(15,3) NULL COMMENT '客服手工实货体积cbm',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建操作人姓名快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '订舱记录创建时间',
@@ -44,6 +55,14 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
('DRAFT','RELEASED')),
CONSTRAINT chk_ocean_booking_carrier_code CHECK
(carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')),
CONSTRAINT chk_ocean_booking_cargo CHECK (
(package_unit IS NULL OR package_unit IN ('CARTONS','PCS','PALLETS'))
AND (declared_package_count IS NULL OR declared_package_count BETWEEN 1 AND 999999999)
AND (actual_package_count IS NULL OR actual_package_count BETWEEN 1 AND 999999999)
AND (declared_gross_weight_kg IS NULL OR declared_gross_weight_kg > 0)
AND (actual_gross_weight_kg IS NULL OR actual_gross_weight_kg > 0)
AND (declared_volume_cbm IS NULL OR declared_volume_cbm > 0)
AND (actual_volume_cbm IS NULL OR actual_volume_cbm > 0)),
CONSTRAINT chk_ocean_booking_schedule CHECK
(estimated_sailing_at IS NULL OR estimated_arrival_at IS NULL OR estimated_arrival_at >= estimated_sailing_at)
) ENGINE=InnoDB COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载';
@@ -110,6 +129,8 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_party (
address VARCHAR(1000) NULL COMMENT '关系方地址快照',
contact_name VARCHAR(128) NULL COMMENT '关系方联系人快照',
contact_phone VARCHAR(64) NULL COMMENT '关系方联系电话快照',
contact_email VARCHAR(255) NULL COMMENT '关系方联系邮箱',
tax_no VARCHAR(128) NULL COMMENT '关系方税号',
PRIMARY KEY (id),
UNIQUE KEY uk_ocean_booking_party (ocean_booking_id,party_type),
CONSTRAINT fk_ocean_booking_party FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id) ON DELETE CASCADE,
@@ -211,8 +232,47 @@ DROP TRIGGER IF EXISTS trg_ocean_booking_before_update;
DELIMITER $$
CREATE TRIGGER trg_ocean_booking_before_update BEFORE UPDATE ON fl_ocean_booking FOR EACH ROW
BEGIN
IF OLD.status = 'RELEASED' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱订舱不可修改';
-- 已放舱只允许实货量及其审计元数据变化,运输和预报快照保持冻结。
IF OLD.status = 'RELEASED' AND NOT (
(NEW.id <=> OLD.id)
AND (NEW.booking_no <=> OLD.booking_no)
AND (NEW.status <=> OLD.status)
AND (NEW.customer_id <=> OLD.customer_id)
AND (NEW.customer_name <=> OLD.customer_name)
AND (NEW.channel_code <=> OLD.channel_code)
AND (NEW.channel_name <=> OLD.channel_name)
AND (NEW.warehouse_id <=> OLD.warehouse_id)
AND (NEW.warehouse_name <=> OLD.warehouse_name)
AND (NEW.bill_of_lading_no <=> OLD.bill_of_lading_no)
AND (NEW.shipping_company_id <=> OLD.shipping_company_id)
AND (NEW.shipping_company_name <=> OLD.shipping_company_name)
AND (NEW.carrier_code <=> OLD.carrier_code)
AND (NEW.booking_agent_id <=> OLD.booking_agent_id)
AND (NEW.booking_agent_name <=> OLD.booking_agent_name)
AND (NEW.vessel_name <=> OLD.vessel_name)
AND (NEW.voyage_no <=> OLD.voyage_no)
AND (NEW.departure_port_code <=> OLD.departure_port_code)
AND (NEW.departure_port_name <=> OLD.departure_port_name)
AND (NEW.destination_port_code <=> OLD.destination_port_code)
AND (NEW.destination_port_name <=> OLD.destination_port_name)
AND (NEW.place_of_delivery <=> OLD.place_of_delivery)
AND (NEW.estimated_sailing_at <=> OLD.estimated_sailing_at)
AND (NEW.estimated_arrival_at <=> OLD.estimated_arrival_at)
AND (NEW.remark <=> OLD.remark)
AND (NEW.freight_term <=> OLD.freight_term)
AND (NEW.service_term <=> OLD.service_term)
AND (NEW.package_unit <=> OLD.package_unit)
AND (NEW.cargo_description <=> OLD.cargo_description)
AND (NEW.marks_and_nos <=> OLD.marks_and_nos)
AND (NEW.declared_package_count <=> OLD.declared_package_count)
AND (NEW.declared_gross_weight_kg <=> OLD.declared_gross_weight_kg)
AND (NEW.declared_volume_cbm <=> OLD.declared_volume_cbm)
AND (NEW.created_by <=> OLD.created_by)
AND (NEW.created_by_name <=> OLD.created_by_name)
AND (NEW.created_at <=> OLD.created_at)
AND NEW.version = OLD.version + 1
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱运输资料不可修改,仅允许补录实货量';
END IF;
IF OLD.status <> NEW.status AND NOT (
(OLD.status='DRAFT' AND NEW.status='RELEASED')) THEN
@@ -1,8 +1,8 @@
openapi: 3.0.3
info:
title: Ocean Booking API
version: 2.1.0
description: Booking-owned files use File businessType OCEAN_BOOKING_DOCUMENT and businessId=oceanBookingId. Release requires at least one active BOOKING_CONFIRMATION or BILL_OF_LADING file. Released bookings and their file links are permanently read-only.
version: 2.2.0
description: Booking-owned files use File businessType OCEAN_BOOKING_DOCUMENT and businessId=oceanBookingId. Release requires at least one active BOOKING_CONFIRMATION or BILL_OF_LADING file. Released transport and declared cargo snapshots and file links are read-only. Manual actual cargo may be supplemented with version, idempotency and audit reason.
paths:
/api/platform/v1/ocean-bookings:
get:
@@ -29,6 +29,14 @@ paths:
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}}}}
responses: {'200': {description: Deleted}}
/api/platform/v1/ocean-bookings/{oceanBookingId}/actual-cargo:
put:
summary: Supplement manual actual cargo on a RELEASED booking
description: Only actual package count, gross weight and volume may change. Transport and declared cargo remain frozen. Requires expectedVersion, Idempotency-Key and a reason; saves before/after audit snapshots atomically. Null means not recorded, never zero or a fallback to declared cargo.
x-permission: platform:firstleg:ocean-booking:edit
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActualCargoRequest'}}}}
responses: {'200': {description: Updated RELEASED detail}, '409': {description: State or version conflict}}
/api/platform/v1/ocean-bookings/{oceanBookingId}/release:
post:
summary: Validate and permanently release a DRAFT
@@ -89,7 +97,7 @@ paths:
/api/platform/v1/ocean-bookings/{oceanBookingId}/prints/shipping-instruction:
get:
summary: Render the current saved booking as an export shipping instruction PDF
description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1 and synchronously proxies the generated PDF. Container requirements are not treated as actual cargo quantity, weight, volume, marks, packaging, or goods descriptions.
description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1 and synchronously proxies the generated PDF. Cargo uses saved declared values; container requirements describe requested containers only. Count in words is generated from the declared count and packageUnit.
x-permission: platform:firstleg:ocean-booking:list
parameters: [{$ref: '#/components/parameters/OceanBookingId'}]
responses:
@@ -97,7 +105,7 @@ paths:
/api/platform/v1/ocean-bookings/{oceanBookingId}/prints/bill-of-lading-confirmation:
get:
summary: Render the current saved booking as a bill-of-lading confirmation PDF
description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to BILL_OF_LADING_CONFIRMATION_A4_V1 and synchronously proxies the generated PDF. Container requirements remain a container summary and are not treated as actual cargo facts.
description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to BILL_OF_LADING_CONFIRMATION_A4_V1 and synchronously proxies the generated PDF. Cargo uses saved manually entered actual values only; missing actual data displays — and never falls back to declared values. This is a confirmation copy; the externally issued bill of lading remains an uploaded attachment.
x-permission: platform:firstleg:ocean-booking:list
parameters: [{$ref: '#/components/parameters/OceanBookingId'}]
responses:
@@ -194,10 +202,31 @@ components:
estimatedSailingAt: {type: string, format: date-time, nullable: true}
estimatedArrivalAt: {type: string, format: date-time, nullable: true}
remark: {type: string, nullable: true}
freightTerm: {type: string, maxLength: 64, nullable: true, description: 运费条款;新建页面默认FREIGHT PREPAID}
serviceTerm: {type: string, maxLength: 64, nullable: true, description: 运输条款;新建页面默认CY-CY}
packageUnit: {type: string, enum: [CARTONS, PCS, PALLETS], nullable: true}
cargoDescription: {type: string, maxLength: 1000, nullable: true}
marksAndNos: {type: string, maxLength: 1000, nullable: true}
declaredPackageCount: {type: integer, minimum: 1, maximum: 999999999, nullable: true}
declaredGrossWeightKg: {type: number, minimum: 0, exclusiveMinimum: true, maximum: 999999999999.999, multipleOf: 0.001, nullable: true}
declaredVolumeCbm: {type: number, minimum: 0, exclusiveMinimum: true, maximum: 999999999999.999, multipleOf: 0.001, nullable: true}
actualPackageCount: {type: integer, minimum: 1, maximum: 999999999, nullable: true}
actualGrossWeightKg: {type: number, minimum: 0, exclusiveMinimum: true, maximum: 999999999999.999, multipleOf: 0.001, nullable: true}
actualVolumeCbm: {type: number, minimum: 0, exclusiveMinimum: true, maximum: 999999999999.999, multipleOf: 0.001, nullable: true}
parties: {type: array, nullable: true, items: {$ref: '#/components/schemas/PartyRequest'}}
containerRequirements: {type: array, nullable: true, items: {$ref: '#/components/schemas/ContainerRequirementRequest'}}
PartyRequest: {type: object, required: [partyType, partyName], properties: {partyType: {type: string, enum: [SHIPPER, CONSIGNEE, NOTIFY_PARTY]}, partyName: {type: string}, address: {type: string, nullable: true}, contactName: {type: string, nullable: true}, contactPhone: {type: string, nullable: true}}}
PartyRequest: {type: object, required: [partyType, partyName], properties: {partyType: {type: string, enum: [SHIPPER, CONSIGNEE, NOTIFY_PARTY]}, partyName: {type: string}, address: {type: string, nullable: true}, contactName: {type: string, nullable: true}, contactPhone: {type: string, nullable: true}, contactEmail: {type: string, format: email, maxLength: 255, nullable: true}, taxNo: {type: string, maxLength: 128, nullable: true}}}
ContainerRequirementRequest: {type: object, required: [containerType, containerKind, quantity, capacityCbm, maxWeightKg], properties: {containerType: {type: string, enum: [40GP, 40HQ, 45HQ], default: 40HQ}, containerKind: {type: string}, quantity: {type: integer, minimum: 1}, capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0, description: 统一实装上限;40GP=58、40HQ=68、45HQ=86}, maxWeightKg: {type: number, enum: [19500], description: 统一货重上限}}}
ActualCargoRequest:
type: object
additionalProperties: false
required: [expectedVersion, reason]
properties:
expectedVersion: {type: integer, minimum: 0}
reason: {type: string, minLength: 1, maxLength: 1000}
actualPackageCount: {type: integer, minimum: 1, maximum: 999999999, nullable: true}
actualGrossWeightKg: {type: number, minimum: 0, exclusiveMinimum: true, maximum: 999999999999.999, multipleOf: 0.001, nullable: true}
actualVolumeCbm: {type: number, minimum: 0, exclusiveMinimum: true, maximum: 999999999999.999, multipleOf: 0.001, nullable: true}
ReleaseRequest: {type: object, required: [expectedVersion], properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, nullable: true}}}
ActionRequest: {type: object, required: [expectedVersion], properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, nullable: true}}}
DocumentType: {type: string, enum: [BOOKING_CONFIRMATION, BILL_OF_LADING]}
@@ -311,6 +340,8 @@ components:
required: [oceanBooking, parties, containerRequirements, containers, documents, histories]
properties:
oceanBooking:
allOf:
- {$ref: '#/components/schemas/DraftData'}
type: object
properties:
bookingNo: {type: string, pattern: '^OB[0-9]{12}$', example: OB202608180001, description: 平台订舱号;按北京时间创建日逐日编号}
@@ -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) {