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
@@ -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 {