This commit is contained in:
2026-09-14 20:03:22 +08:00
parent d53de922db
commit ebc299d58a
28 changed files with 756 additions and 95 deletions
@@ -2,5 +2,5 @@ package com.baiyue.firstleg.domain;
public enum TruckAppointmentStatus {
DRAFT, PENDING_DISPATCH, DISPATCHED, PICKUP_IN_PROGRESS, ARRIVED_WAREHOUSE,
LOADING, DEPARTED, COMPLETED, RESCHEDULED, EXCEPTION, CANCELLED
LOADING, COMPLETED, RESCHEDULED, EXCEPTION, CANCELLED
}
@@ -4,7 +4,7 @@ public final class TruckAppointmentDtos { private TruckAppointmentDtos(){}
public record Query(@Size(max=64) String keyword, com.baiyue.firstleg.domain.TruckAppointmentStatus status, @Positive Long oceanBookingId, @Min(1) long page, @Min(1) @Max(200) long size){ public Query{if(page==0)page=1;if(size==0)size=20;} }
public record CandidateQuery(@Size(max=64) String keyword,@Min(1) long page,@Min(1) @Max(200) long size){public CandidateQuery{if(page==0)page=1;if(size==0)size=20;}}
public record Candidate(Long oceanBookingId,String oceanBookingNo,String billOfLadingNo,Long warehouseId,String warehouseName,String channelCode,String channelName,Boolean bookingDocumentReady){}
public record SaveRequest(@Positive Long outboundOrderId,@NotNull @Positive Long oceanBookingId,@Positive Long warehouseId,@NotNull @Positive Long carrierId,@Size(max=128) String carrierName,@Size(max=128) String billOfLadingNo,@Size(max=64) String fleetContact,@Size(max=32) String fleetPhone,@Size(max=64) String driverName,@Size(max=32) String driverPhone,@Size(max=32) String driverIdCard,@Size(max=32) String vehicleNo,@Size(max=128) String storeName,@Size(max=64) String storeContact,@Size(max=32) String storePhone,@Size(max=255) String storeAddress,@Size(max=64) String storeCity,@Size(max=16) String storeZipcode,@PositiveOrZero Integer packageCount,@DecimalMin("0") BigDecimal grossWeightKg,@DecimalMin("0") BigDecimal volumeCbm,@PositiveOrZero Integer palletCount,@Size(max=255) String poNo,@NotNull LocalDateTime plannedLoadingAt,@PositiveOrZero Integer transitMinutes,@DecimalMin("0") BigDecimal payableAmount,@Pattern(regexp="RMB|USD") String currency,@Size(max=1000) String remark,@NotNull @PositiveOrZero Integer expectedVersion){}
public record SaveRequest(@Positive Long outboundOrderId,@NotNull @Positive Long oceanBookingId,@Positive Long warehouseId,@NotNull @Positive Long carrierId,@Size(max=128) String carrierName,@Size(max=128) String billOfLadingNo,@Size(max=64) String fleetContact,@Size(max=32) String fleetPhone,@Size(max=64) String driverName,@Size(max=32) String driverPhone,@Size(max=32) String driverIdCard,@Size(max=32) String vehicleNo,@Size(max=128) String storeName,@Size(max=64) String storeContact,@Size(max=32) String storePhone,@Size(max=255) String storeAddress,@Size(max=64) String storeCity,@Size(max=16) String storeZipcode,@PositiveOrZero Integer packageCount,@DecimalMin("0") BigDecimal grossWeightKg,@DecimalMin("0") BigDecimal volumeCbm,@PositiveOrZero Integer palletCount,@Size(max=255) String poNo,@NotNull LocalDateTime plannedLoadingAt,@PositiveOrZero Integer transitMinutes,@DecimalMin("0") @Digits(integer=16,fraction=2) BigDecimal payableAmount,@Pattern(regexp="RMB") String currency,@Size(max=1000) String remark,@NotNull @PositiveOrZero Integer expectedVersion){}
public record ActionRequest(@NotNull @PositiveOrZero Integer expectedVersion,@Size(max=500) String reason){}
public record AttachmentRequest(@NotNull @Positive Long fileId,@NotBlank @Pattern(regexp="DISPATCH_ORDER|DRIVER_DOCUMENT|VEHICLE_DOCUMENT|OTHER") String attachmentType,@Size(max=255) String fileName,@Size(max=128) String fileHash){ }
}
@@ -0,0 +1,54 @@
package com.baiyue.firstleg.mapper;
import com.baiyue.firstleg.entity.TruckAppointmentEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface TruckAppointmentCompletionMapper {
@Select("SELECT ocean_booking_id FROM fl_ocean_booking_container_allocation WHERE container_booking_id=#{containerId}")
Long bookingForContainer(@Param("containerId") long containerId);
@Select("SELECT * FROM fl_truck_appointment WHERE ocean_booking_id=#{bookingId} "
+ "AND status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') ORDER BY id FOR UPDATE")
List<TruckAppointmentEntity> activeForUpdate(@Param("bookingId") long bookingId);
// Current reads are essential: another container can have committed while waiting for the booking lock.
@Select("""
SELECT r.id requirementId,r.quantity requiredQuantity,c.id containerId,c.container_no containerNo,
c.record_status recordStatus,p.id planId,p.plan_status planStatus,
i.id itemId,i.account_id customerId,i.order_id orderId,i.order_no orderNo,
i.inbound_id inboundId,i.inbound_code inboundCode,i.volume_cbm volumeCbm,
d.status dispatchStatus,d.confirmed_at departedAt
FROM fl_ocean_booking_container_requirement r
LEFT JOIN fl_ocean_booking_container_allocation a ON a.requirement_id=r.id AND a.ocean_booking_id=r.ocean_booking_id
LEFT JOIN fl_container_booking c ON c.id=a.container_booking_id
LEFT JOIN fl_loading_plan p ON p.container_booking_id=c.id
LEFT JOIN fl_loading_plan_item i ON i.loading_plan_id=p.id AND i.deleted=0
LEFT JOIN fl_outbound_dispatch d ON d.container_booking_id=c.id
AND d.inbound_id=i.inbound_id AND d.tenant_id=i.account_id
WHERE r.ocean_booking_id=#{bookingId}
ORDER BY r.id,c.id,p.id,i.id
FOR UPDATE
""")
List<DepartureRow> departuresForUpdate(@Param("bookingId") long bookingId);
@Update("""
UPDATE fl_truck_appointment SET status='COMPLETED',actual_departed_at=#{departedAt},
version=version+1,revision=revision+1,updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{id} AND version=#{version} AND status NOT IN ('COMPLETED','CANCELLED','EXCEPTION')
""")
int complete(@Param("id") long id, @Param("version") int version,
@Param("departedAt") LocalDateTime departedAt);
record DepartureRow(Long requirementId, Integer requiredQuantity, Long containerId, String containerNo,
String recordStatus, Long planId, String planStatus, Long itemId,
Long customerId, Long orderId, String orderNo, Long inboundId, String inboundCode,
BigDecimal volumeCbm, String dispatchStatus, LocalDateTime departedAt) { }
}
@@ -11,15 +11,15 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.myba
AND d.valid=1 AND d.document_type IN ('BOOKING_CONFIRMATION','BILL_OF_LADING'))
AND o.status='RELEASED' AND o.bill_of_lading_no IS NOT NULL AND o.bill_of_lading_no&lt;&gt;''
AND NOT EXISTS (SELECT 1 FROM fl_truck_appointment t WHERE t.ocean_booking_id=o.id
AND t.status NOT IN ('COMPLETED','CANCELLED','EXCEPTION'))
AND t.status NOT IN ('CANCELLED','EXCEPTION'))
AND (#{q.keyword} IS NULL OR o.booking_no LIKE CONCAT('%',#{q.keyword},'%')
OR o.bill_of_lading_no LIKE CONCAT('%',#{q.keyword},'%'))
ORDER BY o.updated_at DESC,o.id DESC
</script>
""") Page<Candidate> candidates(Page<Candidate> p,@Param("q") CandidateQuery q);
@Select("SELECT * FROM fl_truck_appointment WHERE ocean_booking_id=#{oceanBookingId} AND status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') ORDER BY created_at,id LIMIT 1 FOR UPDATE") TruckAppointmentEntity firstActiveForUpdate(@Param("oceanBookingId") long oceanBookingId);
@Select("SELECT * FROM fl_truck_appointment WHERE ocean_booking_id=#{oceanBookingId} AND status NOT IN ('CANCELLED','EXCEPTION') ORDER BY created_at,id LIMIT 1 FOR UPDATE") TruckAppointmentEntity firstActiveForUpdate(@Param("oceanBookingId") long oceanBookingId);
@Select("<script>SELECT a.*,o.booking_no ocean_booking_no,o.warehouse_name warehouse_name,o.shipping_company_id,o.shipping_company_name,o.booking_agent_id,o.booking_agent_name,(SELECT COUNT(*) FROM fl_truck_appointment_attachment x WHERE x.appointment_id=a.id) attachment_count FROM fl_truck_appointment a LEFT JOIN fl_ocean_booking o ON o.id=a.ocean_booking_id WHERE (#{q.keyword} IS NULL OR a.appointment_no LIKE CONCAT('%',#{q.keyword},'%') OR o.booking_no LIKE CONCAT('%',#{q.keyword},'%') OR a.vehicle_no LIKE CONCAT('%',#{q.keyword},'%') OR a.carrier_name LIKE CONCAT('%',#{q.keyword},'%')) AND (#{q.status} IS NULL OR a.status=#{q.status}) AND (#{q.oceanBookingId} IS NULL OR a.ocean_booking_id=#{q.oceanBookingId}) ORDER BY a.updated_at DESC,a.id DESC</script>") Page<TruckAppointmentEntity> page(Page<TruckAppointmentEntity> p,@Param("q") Query q);
@Update("UPDATE fl_truck_appointment SET status=#{toStatus}, actual_arrived_warehouse_at=CASE WHEN #{toStatus}='ARRIVED_WAREHOUSE' THEN CURRENT_TIMESTAMP(3) ELSE actual_arrived_warehouse_at END, actual_loading_at=CASE WHEN #{toStatus}='LOADING' THEN CURRENT_TIMESTAMP(3) ELSE actual_loading_at END, actual_departed_at=CASE WHEN #{toStatus}='DEPARTED' THEN CURRENT_TIMESTAMP(3) ELSE actual_departed_at END, revision=revision+1, version=version+1, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int transition(@Param("id") long id,@Param("fromStatus") String from,@Param("toStatus") String to,@Param("version") int version);
@Update("UPDATE fl_truck_appointment SET status=#{toStatus}, actual_arrived_warehouse_at=CASE WHEN #{toStatus}='ARRIVED_WAREHOUSE' THEN CURRENT_TIMESTAMP(3) ELSE actual_arrived_warehouse_at END, actual_loading_at=CASE WHEN #{toStatus}='LOADING' THEN CURRENT_TIMESTAMP(3) ELSE actual_loading_at END, revision=revision+1, version=version+1, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int transition(@Param("id") long id,@Param("fromStatus") String from,@Param("toStatus") String to,@Param("version") int version);
@Update("UPDATE fl_truck_appointment SET status='DISPATCHED',bill_of_lading_no=#{billOfLadingNo},revision=revision+1,version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int dispatch(@Param("id") long id,@Param("fromStatus") String fromStatus,@Param("billOfLadingNo") String billOfLadingNo,@Param("version") int version);
@Update("UPDATE fl_truck_appointment SET planned_loading_at=#{at}, revision=revision+1, version=version+1, status='RESCHEDULED', remark=#{reason}, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND version=#{version}") int reschedule(@Param("id") long id,@Param("at") java.time.LocalDateTime at,@Param("reason") String reason,@Param("version") int version);
@Update("UPDATE fl_truck_appointment SET outbound_order_id=#{e.outboundOrderId},ocean_booking_id=#{e.oceanBookingId},warehouse_id=#{e.warehouseId},carrier_id=#{e.carrierId},carrier_name=#{e.carrierName},bill_of_lading_no=#{e.billOfLadingNo},fleet_contact=#{e.fleetContact},fleet_phone=#{e.fleetPhone},driver_name=#{e.driverName},driver_phone=#{e.driverPhone},driver_id_card=#{e.driverIdCard},vehicle_no=#{e.vehicleNo},store_name=#{e.storeName},store_contact=#{e.storeContact},store_phone=#{e.storePhone},store_address=#{e.storeAddress},store_city=#{e.storeCity},store_zipcode=#{e.storeZipcode},package_count=#{e.packageCount},gross_weight_kg=#{e.grossWeightKg},volume_cbm=#{e.volumeCbm},pallet_count=#{e.palletCount},po_no=#{e.poNo},planned_loading_at=#{e.plannedLoadingAt},transit_minutes=#{e.transitMinutes},payable_amount=#{e.payableAmount},currency=#{e.currency},remark=#{e.remark},version=version+1,revision=revision+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{e.id} AND version=#{version}") int updateEdit(@Param("e") TruckAppointmentEntity e,@Param("version") int version);
@@ -1,6 +1,7 @@
package com.baiyue.firstleg.outbound.dispatch.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.firstleg.service.TruckAppointmentCompletionService;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.outbound.dispatch.domain.OutboundDispatchStatus;
@@ -28,13 +29,16 @@ public class OutboundDispatchService {
private final OutboundDispatchRepository repository;
private final Map<String, OutboundDispatchGateContributor> contributors;
private final CarrierScheduleQueryService carrierSchedules;
private final TruckAppointmentCompletionService truckCompletion;
@Autowired
public OutboundDispatchService(OutboundDispatchRepository repository,
List<OutboundDispatchGateContributor> contributors,
CarrierScheduleQueryService carrierSchedules) {
CarrierScheduleQueryService carrierSchedules,
TruckAppointmentCompletionService truckCompletion) {
this.repository = repository;
this.carrierSchedules = carrierSchedules;
this.truckCompletion = truckCompletion;
Map<String, OutboundDispatchGateContributor> indexed = new HashMap<>();
for (OutboundDispatchGateContributor contributor : contributors) {
if (indexed.put(contributor.code(), contributor) != null) {
@@ -44,11 +48,6 @@ public class OutboundDispatchService {
this.contributors = Map.copyOf(indexed);
}
public OutboundDispatchService(OutboundDispatchRepository repository,
List<OutboundDispatchGateContributor> contributors) {
this(repository, contributors, null);
}
public List<Aggregate> list(Query query) {
return List.of(aggregate(bindings(query.containerBookingId())));
}
@@ -73,6 +72,7 @@ public class OutboundDispatchService {
public Detail confirm(long containerBookingId, long operatorId, String operatorName,
String key, ActionRequest request) {
validateKey(key);
Long bookingId = truckCompletion.lockForContainer(containerBookingId);
String hash = hash("CONFIRM", containerBookingId, request);
Detail replay = replay(containerBookingId, operatorId, "CONFIRM_DISPATCH", key, hash);
if (replay != null) return replay;
@@ -92,6 +92,7 @@ public class OutboundDispatchService {
}
}
OutboundDispatchEntity representative = states.get(0);
if (bookingId != null) truckCompletion.completeForBooking(bookingId);
history(representative, "CONFIRM_DISPATCH", OutboundDispatchStatus.READY_FOR_DISPATCH.name(),
OutboundDispatchStatus.WAITING_FOR_LOADING.name(), request.remark(), operatorId, operatorName);
@@ -113,6 +114,7 @@ public class OutboundDispatchService {
public Detail reverse(long containerBookingId, long operatorId, String operatorName,
String key, ActionRequest request) {
validateKey(key);
truckCompletion.lockForContainer(containerBookingId);
String hash = hash("REVERSE", containerBookingId, request);
Detail replay = replay(containerBookingId, operatorId, "REVERSE_DISPATCH", key, hash);
if (replay != null) return replay;
@@ -0,0 +1,80 @@
package com.baiyue.firstleg.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.entity.TruckAppointmentHistoryEntity;
import com.baiyue.firstleg.mapper.TruckAppointmentCompletionMapper;
import com.baiyue.firstleg.mapper.TruckAppointmentCompletionMapper.DepartureRow;
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
import com.baiyue.firstleg.repository.TruckAppointmentRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class TruckAppointmentCompletionService {
private final TruckAppointmentCompletionMapper mapper;
private final OceanBookingRepository bookings;
private final TruckAppointmentRepository appointments;
private final TruckAppointmentFeeService fees;
/** All writers lock the booking before appointment/outbound rows, serializing multiple container departures. */
public void lockBooking(long bookingId) {
if (bookings.findForUpdate(bookingId) == null) {
throw new BizException(ResultCode.NOT_FOUND, "关联订舱不存在");
}
}
public Long lockForContainer(long containerId) {
Long bookingId = mapper.bookingForContainer(containerId);
if (bookingId != null) lockBooking(bookingId);
return bookingId;
}
@Transactional
public void completeForBooking(long bookingId) {
lockBooking(bookingId);
var active = mapper.activeForUpdate(bookingId);
if (active.isEmpty()) return;
List<DepartureRow> rows = mapper.departuresForUpdate(bookingId);
if (!allDeparted(rows)) return;
LocalDateTime departedAt = rows.stream().map(DepartureRow::departedAt)
.max(LocalDateTime::compareTo).orElseThrow();
for (var appointment : active) {
// Fees, their immutable snapshots, Outbox and completion history share this transaction.
fees.publish(appointment, rows, departedAt);
if (mapper.complete(appointment.getId(), appointment.getVersion(), departedAt) != 1) {
throw new BizException(ResultCode.CONFLICT, "拖车预约已变化,请刷新后重试");
}
var history = new TruckAppointmentHistoryEntity();
history.setId(IdWorker.getId());
history.setAppointmentId(appointment.getId());
history.setRevision(appointment.getRevision() + 1);
history.setAction("AUTO_COMPLETE");
history.setFromStatus(appointment.getStatus().name());
history.setToStatus("COMPLETED");
history.setReason("订舱全部要求货柜已确认离开国内仓库,自动完成拖车并发布应付拖车费");
history.setOperatorId(0L);
history.setOperatorName("系统");
history.setCreatedAt(LocalDateTime.now());
appointments.history(history);
}
}
static boolean allDeparted(List<DepartureRow> rows) {
if (rows.isEmpty()) return false;
if (rows.stream().anyMatch(row -> row.requirementId() == null || row.requiredQuantity() == null
|| row.requiredQuantity() <= 0 || row.containerId() == null || !"ACTIVE".equals(row.recordStatus())
|| row.planId() == null || !"COMPLETED".equals(row.planStatus()) || row.itemId() == null
|| !"WAITING_FOR_LOADING".equals(row.dispatchStatus()) || row.departedAt() == null)) return false;
return rows.stream().collect(Collectors.groupingBy(DepartureRow::requirementId)).values().stream()
.allMatch(group -> group.stream().map(DepartureRow::containerId).distinct().count()
== group.get(0).requiredQuantity());
}
}
@@ -0,0 +1,90 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.*;
import com.baiyue.firstleg.entity.TruckAppointmentEntity;
import com.baiyue.firstleg.mapper.TruckAppointmentCompletionMapper.DepartureRow;
import com.baiyue.firstleg.repository.FeeItemRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class TruckAppointmentFeeService {
private static final String TRUCKING_FEE_CODE = "FEE_TYPE_108";
private final FeeItemRepository feeItems;
private final ContainerFeeDetailService containerFees;
private final WarehouseSupplierSnapshotService suppliers;
public void publish(TruckAppointmentEntity appointment, List<DepartureRow> rows, LocalDateTime departedAt) {
BigDecimal total = appointment.getPayableAmount();
if (total == null || total.signum() == 0) return;
if (!"RMB".equals(appointment.getCurrency())) throw conflict("国内拖车应付只支持人民币");
var item = feeItems.findItemByCode(TRUCKING_FEE_CODE);
if (item == null || !"ACTIVE".equals(item.getStatus()) || !Boolean.TRUE.equals(item.getPickupPayable())) {
throw conflict("国内拖车费项目不存在、已停用或未启用应付");
}
suppliers.trucking(appointment.getCarrierId(), null);
List<Share> shares = allocate(total, rows);
Map<Long, List<Share>> plans = new LinkedHashMap<>();
shares.forEach(share -> plans.computeIfAbsent(share.owner().planId(), ignored -> new ArrayList<>()).add(share));
for (var entry : plans.entrySet()) {
BigDecimal planAmount = entry.getValue().stream().map(Share::amount).reduce(BigDecimal.ZERO, BigDecimal::add);
var fee = new SaveRequest(item.getId(), "PAYABLE", departedAt.toLocalDate(), planAmount, "CNY",
appointment.getCarrierId(), "ON_ACCOUNT", null, null, null, null,
"国内拖车单 " + appointment.getAppointmentNo() + ";按实装体积分摊;拖车总额 " + total.toPlainString() + " 元");
var allocations = entry.getValue().stream().map(share -> new Allocation(share.owner().customerId(),
share.owner().orderId(), share.owner().orderNo(), share.owner().inboundId(), share.owner().inboundCode(), share.amount())).toList();
var created = containerFees.createAllocationGroup(entry.getKey(), 0L, new AllocationGroupRequest(fee, allocations));
containerFees.approveAllocationGroup(entry.getKey(), created.get(0).getAllocationGroupId(), 0L, "系统(拖车离仓完成)");
}
}
/** Largest remainders allocate exact cents, with deterministic ties and no zero-amount finance sources. */
static List<Share> allocate(BigDecimal total, List<DepartureRow> rows) {
if (total.signum() <= 0 || total.scale() > 2) throw conflict("拖车费必须为正数且最多两位小数");
Map<Owner, BigDecimal> volumes = new LinkedHashMap<>();
for (DepartureRow row : rows) {
if (row.customerId() == null || row.customerId() <= 0 || row.orderId() == null || row.inboundId() == null
|| row.orderNo() == null || row.orderNo().isBlank() || row.inboundCode() == null || row.inboundCode().isBlank()
|| row.volumeCbm() == null || row.volumeCbm().signum() <= 0) {
throw conflict("拖车费分摊缺少柜内订单归属或有效实装体积");
}
var owner = new Owner(row.planId(), row.customerId(), row.orderId(), row.orderNo(), row.inboundId(), row.inboundCode());
volumes.merge(owner, row.volumeCbm(), BigDecimal::add);
}
if (volumes.isEmpty()) throw conflict("拖车费没有可分摊的柜内订单");
var owners = volumes.keySet().stream().sorted(Comparator.comparing(Owner::planId)
.thenComparing(Owner::customerId).thenComparing(Owner::inboundId)).toList();
BigDecimal volume = volumes.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal cents = total.movePointRight(2).setScale(0, RoundingMode.UNNECESSARY);
var amounts = new ArrayList<BigDecimal>();
var remainders = new ArrayList<BigDecimal>();
for (var owner : owners) {
BigDecimal numerator = cents.multiply(volumes.get(owner));
BigDecimal floor = numerator.divide(volume, 0, RoundingMode.DOWN);
amounts.add(floor);
remainders.add(numerator.subtract(floor.multiply(volume)));
}
int remaining = cents.subtract(amounts.stream().reduce(BigDecimal.ZERO, BigDecimal::add)).intValueExact();
var ranked = java.util.stream.IntStream.range(0, owners.size()).boxed()
.sorted(Comparator.<Integer, BigDecimal>comparing(remainders::get).reversed().thenComparingInt(Integer::intValue)).toList();
for (int i = 0; i < remaining; i++) amounts.set(ranked.get(i), amounts.get(ranked.get(i)).add(BigDecimal.ONE));
return java.util.stream.IntStream.range(0, owners.size()).filter(i -> amounts.get(i).signum() > 0)
.mapToObj(i -> new Share(owners.get(i), amounts.get(i).movePointLeft(2))).toList();
}
record Owner(Long planId, Long customerId, Long orderId, String orderNo, Long inboundId, String inboundCode) { }
record Share(Owner owner, BigDecimal amount) { }
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); }
}
@@ -32,8 +32,9 @@ import org.springframework.web.multipart.MultipartFile;
@Service
public class TruckAppointmentService {
private static final Set<String> ATTACHMENT_TYPES = Set.of("DISPATCH_ORDER", "DRIVER_DOCUMENT", "VEHICLE_DOCUMENT", "OTHER");
private static final Set<String> CURRENCIES = Set.of("RMB", "USD");
private static final Set<String> CURRENCIES = Set.of("RMB");
@Resource TruckAppointmentRepository repo;
@Resource TruckAppointmentCompletionService completion;
@Resource OceanBookingRepository oceanBookings;
@Resource OceanBookingDocumentService oceanBookingDocuments;
@Resource TruckAppointmentFileClient files;
@@ -46,10 +47,23 @@ public class TruckAppointmentService {
@Transactional public TruckAppointmentAttachmentEntity upload(long id,String type,MultipartFile file,long uid){String attachmentType=attachmentType(type);required(id);try{var response=files.upload(internalToken,uid,0L,"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),file);if(response==null||!response.successful()||response.data()==null)throw bad("附件上传失败");var m=response.data();TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(m.fileId());a.setAttachmentType(attachmentType);a.setFileName(m.fileName());a.setFileHash(m.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");}}
@Transactional public TruckAppointmentAttachmentEntity attach(long id,AttachmentRequest r,long uid){String attachmentType=attachmentType(r.attachmentType());required(id);TruckAppointmentFileClient.Metadata metadata;try{var response=files.metadata(internalToken,0L,"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),r.fileId());if(response==null||!response.successful()||response.data()==null)throw bad("附件不存在或归属不匹配");metadata=response.data();}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");} TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(metadata.fileId());a.setAttachmentType(attachmentType);a.setFileName(metadata.fileName());a.setFileHash(metadata.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}
@Transactional public void deleteAttachment(long id,long attachmentId){required(id);if(repo.deleteAttachment(id,attachmentId)!=1)throw new BizException(ResultCode.NOT_FOUND,"附件引用不存在");}
@Transactional public TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()==null||r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0");validateRequired(r);TruckAppointmentEntity active=repo.firstActiveForUpdate(r.oceanBookingId());if(active!=null)throw existingAppointmentConflict(active);AssociationContext links=validateAssociations(r);var carrier=suppliers.trucking(r.carrierId(),r.carrierName());TruckAppointmentEntity e=new TruckAppointmentEntity();e.setId(IdWorker.getId());e.setAppointmentNo(documentNumbers.next("TA"));e.setStatus(TruckAppointmentStatus.DRAFT);e.setRevision(0);e.setVersion(0);e.setCreatedAt(LocalDateTime.now());e.setUpdatedAt(e.getCreatedAt());copy(e,r,links,carrier.name());try{repo.insert(e);}catch(DuplicateKeyException exception){if(exception.getMessage()!=null&&exception.getMessage().contains("uk_truck_active_ocean_booking"))throw existingAppointmentConflict(repo.firstActiveForUpdate(r.oceanBookingId()));throw exception;}record(e,null,e.getStatus().name(),"CREATE",null,uid,name);return enrich(e);}
@Transactional public TruckAppointmentEntity edit(long id,long uid,String name,SaveRequest r){validateRequired(r);TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion());if(!(e.getStatus()==TruckAppointmentStatus.DRAFT||e.getStatus()==TruckAppointmentStatus.PENDING_DISPATCH||e.getStatus()==TruckAppointmentStatus.RESCHEDULED))throw conflict("当前状态不可编辑");TruckAppointmentEntity active=repo.firstActiveForUpdate(r.oceanBookingId());if(active!=null&&!Objects.equals(active.getId(),e.getId()))throw existingAppointmentConflict(active);AssociationContext links=validateAssociations(r);var carrier=suppliers.trucking(r.carrierId(),r.carrierName());copy(e,r,links,carrier.name());try{if(repo.updateEdit(e,r.expectedVersion())!=1)throw conflict("版本已变化,请刷新后重试");}catch(DuplicateKeyException exception){if(exception.getMessage()!=null&&exception.getMessage().contains("uk_truck_active_ocean_booking"))throw existingAppointmentConflict(repo.firstActiveForUpdate(r.oceanBookingId()));throw exception;}record(e,e.getStatus().name(),e.getStatus().name(),"EDIT",null,uid,name);return enrich(required(id));}
@Transactional public TruckAppointmentEntity action(long id,String action,ActionRequest r,String idem,String clientHash,long uid,String name){String requestHash=hash(action,id,r);verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion());if("RESCHEDULE".equals(action))throw conflict("改约请使用专用接口");TruckAppointmentStatus from=e.getStatus(),to=next(from,action);String dispatchBill="DISPATCH".equals(action)?requireDispatchReady(e):null;int changed="DISPATCH".equals(action)?repo.dispatch(id,from.name(),dispatchBill,e.getVersion()):repo.transition(id,from.name(),to.name(),e.getVersion());if(changed!=1)throw conflict("状态或版本已变化,请刷新后重试");TruckAppointmentEntity after=enrich(required(id));record(after,from.name(),to.name(),action,r.reason(),uid,name);return completeOperation(id,action,idem,after);}
@Transactional public TruckAppointmentEntity reschedule(long id,LocalDateTime at,ActionRequest r,String idem,String clientHash,long uid,String name){String action="RESCHEDULE";String requestHash=hash(action,id,new ReschedulePayload(at,r));verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion()); if(e.getStatus()==TruckAppointmentStatus.COMPLETED||e.getStatus()==TruckAppointmentStatus.CANCELLED)throw conflict("当前状态不可改约"); String from=e.getStatus().name(); if(repo.reschedule(id,at,r.reason(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试");TruckAppointmentEntity after=enrich(required(id));record(after,from,"RESCHEDULED",action,r.reason(),uid,name);return completeOperation(id,action,idem,after);}
@Transactional public TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()==null||r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0");validateRequired(r);completion.lockBooking(r.oceanBookingId());TruckAppointmentEntity active=repo.firstActiveForUpdate(r.oceanBookingId());if(active!=null)throw existingAppointmentConflict(active);AssociationContext links=validateAssociations(r);var carrier=suppliers.trucking(r.carrierId(),r.carrierName());TruckAppointmentEntity e=new TruckAppointmentEntity();e.setId(IdWorker.getId());e.setAppointmentNo(documentNumbers.next("TA"));e.setStatus(TruckAppointmentStatus.DRAFT);e.setRevision(0);e.setVersion(0);e.setCreatedAt(LocalDateTime.now());e.setUpdatedAt(e.getCreatedAt());copy(e,r,links,carrier.name());try{repo.insert(e);}catch(DuplicateKeyException exception){if(exception.getMessage()!=null&&exception.getMessage().contains("uk_truck_active_ocean_booking"))throw existingAppointmentConflict(repo.firstActiveForUpdate(r.oceanBookingId()));throw exception;}record(e,null,e.getStatus().name(),"CREATE",null,uid,name);completion.completeForBooking(e.getOceanBookingId());return enrich(required(e.getId()));}
@Transactional public TruckAppointmentEntity edit(long id,long uid,String name,SaveRequest r){validateRequired(r);TruckAppointmentEntity e=required(id);completion.lockBooking(Math.min(e.getOceanBookingId(),r.oceanBookingId()));if(!Objects.equals(e.getOceanBookingId(),r.oceanBookingId()))completion.lockBooking(Math.max(e.getOceanBookingId(),r.oceanBookingId()));checkVersion(e,r.expectedVersion());if(!(e.getStatus()==TruckAppointmentStatus.DRAFT||e.getStatus()==TruckAppointmentStatus.PENDING_DISPATCH||e.getStatus()==TruckAppointmentStatus.RESCHEDULED))throw conflict("当前状态不可编辑");TruckAppointmentEntity active=repo.firstActiveForUpdate(r.oceanBookingId());if(active!=null&&!Objects.equals(active.getId(),e.getId()))throw existingAppointmentConflict(active);AssociationContext links=validateAssociations(r);var carrier=suppliers.trucking(r.carrierId(),r.carrierName());copy(e,r,links,carrier.name());try{if(repo.updateEdit(e,r.expectedVersion())!=1)throw conflict("版本已变化,请刷新后重试");}catch(DuplicateKeyException exception){if(exception.getMessage()!=null&&exception.getMessage().contains("uk_truck_active_ocean_booking"))throw existingAppointmentConflict(repo.firstActiveForUpdate(r.oceanBookingId()));throw exception;}record(e,e.getStatus().name(),e.getStatus().name(),"EDIT",null,uid,name);completion.completeForBooking(e.getOceanBookingId());return enrich(required(id));}
@Transactional
public TruckAppointmentEntity action(long id,String action,ActionRequest r,String idem,String clientHash,long uid,String name){
String requestHash=hash(action,id,r);verifyClientHash(clientHash,requestHash);
TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;
TruckAppointmentEntity e=required(id);completion.lockBooking(e.getOceanBookingId());checkVersion(e,r.expectedVersion());
if("RESCHEDULE".equals(action))throw conflict("改约请使用专用接口");
TruckAppointmentStatus from=e.getStatus(),to=next(from,action);
String dispatchBill="DISPATCH".equals(action)?requireDispatchReady(e):null;
int changed="DISPATCH".equals(action)?repo.dispatch(id,from.name(),dispatchBill,e.getVersion()):repo.transition(id,from.name(),to.name(),e.getVersion());
if(changed!=1)throw conflict("状态或版本已变化,请刷新后重试");
record(required(id),from.name(),to.name(),action,r.reason(),uid,name);
completion.completeForBooking(e.getOceanBookingId());
return completeOperation(id,action,idem,enrich(required(id)));
}
@Transactional public TruckAppointmentEntity reschedule(long id,LocalDateTime at,ActionRequest r,String idem,String clientHash,long uid,String name){String action="RESCHEDULE";String requestHash=hash(action,id,new ReschedulePayload(at,r));verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id);completion.lockBooking(e.getOceanBookingId());checkVersion(e,r.expectedVersion()); if(e.getStatus()==TruckAppointmentStatus.COMPLETED||e.getStatus()==TruckAppointmentStatus.CANCELLED)throw conflict("当前状态不可改约"); TruckAppointmentEntity active=repo.firstActiveForUpdate(e.getOceanBookingId());if(active!=null&&!Objects.equals(active.getId(),e.getId()))throw existingAppointmentConflict(active);String from=e.getStatus().name(); if(repo.reschedule(id,at,r.reason(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试");TruckAppointmentEntity after=enrich(required(id));record(after,from,"RESCHEDULED",action,r.reason(),uid,name);completion.completeForBooking(e.getOceanBookingId());return completeOperation(id,action,idem,enrich(required(id)));}
private AssociationContext validateAssociations(SaveRequest r){
OceanBookingEntity booking=oceanBookings.find(r.oceanBookingId());
if(booking==null)throw new BizException(ResultCode.NOT_FOUND,"关联订舱不存在");
@@ -59,9 +73,9 @@ public class TruckAppointmentService {
oceanBookingDocuments.requireActiveValidDocument(booking);
return new AssociationContext(booking,warehouseId,billOfLadingNo);
}
private static void validateRequired(SaveRequest r){if(r.oceanBookingId()==null||r.oceanBookingId()<=0)throw bad("oceanBookingId不能为空");if(r.carrierId()==null||r.carrierId()<=0)throw bad("carrierId不能为空");if(r.plannedLoadingAt()==null)throw bad("plannedLoadingAt不能为空");if(r.expectedVersion()==null||r.expectedVersion()<0)throw bad("expectedVersion不合法");}
private static void validateRequired(SaveRequest r){if(r.oceanBookingId()==null||r.oceanBookingId()<=0)throw bad("oceanBookingId不能为空");if(r.carrierId()==null||r.carrierId()<=0)throw bad("carrierId不能为空");if(r.plannedLoadingAt()==null)throw bad("plannedLoadingAt不能为空");if(r.expectedVersion()==null||r.expectedVersion()<0)throw bad("expectedVersion不合法");if(r.payableAmount()!=null&&(r.payableAmount().signum()<0||r.payableAmount().scale()>2))throw bad("拖车费必须为非负数且最多两位小数");}
private static Long mergeWarehouse(Long requested,Long linked,String source){if(requested!=null&&linked!=null&&!Objects.equals(requested,linked))throw bad("warehouseId与"+source+"不一致");return requested!=null?requested:linked;}
private TruckAppointmentStatus next(TruckAppointmentStatus s,String a){return switch(a){case"SUBMIT"->s==TruckAppointmentStatus.DRAFT?TruckAppointmentStatus.PENDING_DISPATCH:badStatus();case"DISPATCH"->s==TruckAppointmentStatus.PENDING_DISPATCH||s==TruckAppointmentStatus.RESCHEDULED?TruckAppointmentStatus.DISPATCHED:badStatus();case"PICKUP"->s==TruckAppointmentStatus.DISPATCHED?TruckAppointmentStatus.PICKUP_IN_PROGRESS:badStatus();case"ARRIVE"->s==TruckAppointmentStatus.PICKUP_IN_PROGRESS?TruckAppointmentStatus.ARRIVED_WAREHOUSE:badStatus();case"LOAD"->s==TruckAppointmentStatus.ARRIVED_WAREHOUSE?TruckAppointmentStatus.LOADING:badStatus();case"DEPART"->s==TruckAppointmentStatus.LOADING?TruckAppointmentStatus.DEPARTED:badStatus();case"COMPLETE"->s==TruckAppointmentStatus.DEPARTED?TruckAppointmentStatus.COMPLETED:badStatus();case"EXCEPTION"->s!=TruckAppointmentStatus.COMPLETED&&s!=TruckAppointmentStatus.CANCELLED?TruckAppointmentStatus.EXCEPTION:badStatus();case"CANCEL"->s!=TruckAppointmentStatus.COMPLETED?TruckAppointmentStatus.CANCELLED:badStatus();default->badStatus();};}
private TruckAppointmentStatus next(TruckAppointmentStatus s,String a){return switch(a){case"SUBMIT"->s==TruckAppointmentStatus.DRAFT?TruckAppointmentStatus.PENDING_DISPATCH:badStatus();case"DISPATCH"->s==TruckAppointmentStatus.PENDING_DISPATCH||s==TruckAppointmentStatus.RESCHEDULED?TruckAppointmentStatus.DISPATCHED:badStatus();case"PICKUP"->s==TruckAppointmentStatus.DISPATCHED?TruckAppointmentStatus.PICKUP_IN_PROGRESS:badStatus();case"ARRIVE"->s==TruckAppointmentStatus.PICKUP_IN_PROGRESS?TruckAppointmentStatus.ARRIVED_WAREHOUSE:badStatus();case"LOAD"->s==TruckAppointmentStatus.ARRIVED_WAREHOUSE?TruckAppointmentStatus.LOADING:badStatus();case"EXCEPTION"->s!=TruckAppointmentStatus.COMPLETED&&s!=TruckAppointmentStatus.CANCELLED?TruckAppointmentStatus.EXCEPTION:badStatus();case"CANCEL"->s!=TruckAppointmentStatus.COMPLETED?TruckAppointmentStatus.CANCELLED:badStatus();default->badStatus();};}
private TruckAppointmentStatus badStatus(){throw conflict("不允许的状态动作");} private TruckAppointmentEntity required(long id){TruckAppointmentEntity e=repo.find(id);if(e==null)throw new BizException(ResultCode.NOT_FOUND,"拖车预约不存在");return e;} private void checkVersion(TruckAppointmentEntity e,int v){if(e.getVersion()!=v)throw conflict("版本已变化,请刷新后重试");} private static BizException conflict(String m){return new BizException(ResultCode.CONFLICT,m);} private static BizException bad(String m){return new BizException(ResultCode.BAD_REQUEST,m);}
private TruckAppointmentEntity enrich(TruckAppointmentEntity value){if(value.getOceanBookingId()!=null){OceanBookingEntity booking=oceanBookings.find(value.getOceanBookingId());if(booking!=null){value.setOceanBookingNo(booking.getBookingNo());value.setWarehouseName(booking.getWarehouseName());value.setShippingCompanyId(booking.getShippingCompanyId());value.setShippingCompanyName(booking.getShippingCompanyName());value.setBookingAgentId(booking.getBookingAgentId());value.setBookingAgentName(booking.getBookingAgentName());}}value.setAttachmentCount((long)repo.attachments(value.getId()).size());return value;}
private void copy(TruckAppointmentEntity e,SaveRequest r,AssociationContext links,String carrierName){e.setOutboundOrderId(r.outboundOrderId());e.setOceanBookingId(r.oceanBookingId());e.setContainerBookingId(null);e.setLoadingPlanId(null);e.setWarehouseId(links.warehouseId());e.setCarrierId(r.carrierId());e.setCarrierName(carrierName);e.setBillOfLadingNo(links.billOfLadingNo());e.setFleetContact(r.fleetContact());e.setFleetPhone(r.fleetPhone());e.setDriverName(r.driverName());e.setDriverPhone(r.driverPhone());e.setDriverIdCard(r.driverIdCard());e.setVehicleNo(r.vehicleNo());e.setStoreName(r.storeName());e.setStoreContact(r.storeContact());e.setStorePhone(r.storePhone());e.setStoreAddress(r.storeAddress());e.setStoreCity(r.storeCity());e.setStoreZipcode(r.storeZipcode());e.setPackageCount(r.packageCount());e.setGrossWeightKg(r.grossWeightKg());e.setVolumeCbm(r.volumeCbm());e.setPalletCount(r.palletCount());e.setPoNo(r.poNo());e.setPlannedLoadingAt(r.plannedLoadingAt());e.setTransitMinutes(r.transitMinutes());e.setPayableAmount(r.payableAmount());e.setCurrency(currency(r.currency()));e.setRemark(r.remark());}
@@ -82,8 +96,8 @@ public class TruckAppointmentService {
}
private static String releasedBookingBill(OceanBookingEntity booking,String requested){String authoritative=trim(booking.getBillOfLadingNo());if(!StringUtils.hasText(authoritative))throw conflict("关联已放舱订舱缺少提单号");if(StringUtils.hasText(requested)&&!authoritative.equals(requested))throw bad("billOfLadingNo与关联订舱不一致");return authoritative;}
private static String attachmentType(String value){String normalized=trim(value);if(!ATTACHMENT_TYPES.contains(normalized))throw bad("不支持的拖车附件类型");return normalized;}
private static String currency(String value){if(!StringUtils.hasText(value))return "RMB";String normalized=value.trim();if(!CURRENCIES.contains(normalized))throw bad("currency仅支持RMB或USD");return normalized;}
private static BizException existingAppointmentConflict(TruckAppointmentEntity existing){String appointmentNo=existing==null?null:trim(existing.getAppointmentNo());return conflict(StringUtils.hasText(appointmentNo)?"该订舱已存在拖车预约 "+appointmentNo+",请继续编辑":"该订舱已存在拖车预约,请刷新后继续编辑");}
private static String currency(String value){if(!StringUtils.hasText(value))return "RMB";String normalized=value.trim();if(!CURRENCIES.contains(normalized))throw bad("国内拖车应付只支持人民币RMB");return normalized;}
private static BizException existingAppointmentConflict(TruckAppointmentEntity existing){String appointmentNo=existing==null?null:trim(existing.getAppointmentNo());return conflict(StringUtils.hasText(appointmentNo)?"该订舱已存在拖车预约 "+appointmentNo+(existing.getStatus()==TruckAppointmentStatus.COMPLETED?",请查看已完成拖车单":",请继续编辑"):"该订舱已存在拖车预约,请刷新后继续编辑");}
private static String trim(String value){return StringUtils.hasText(value)?value.trim():null;}
private record AssociationContext(OceanBookingEntity booking,Long warehouseId,String billOfLadingNo){}
private void record(TruckAppointmentEntity e,String from,String to,String action,String reason,long uid,String name){TruckAppointmentHistoryEntity h=new TruckAppointmentHistoryEntity();h.setId(IdWorker.getId());h.setAppointmentId(e.getId());h.setRevision(e.getRevision());h.setAction(action);h.setFromStatus(from);h.setToStatus(to);h.setReason(reason);h.setOperatorId(uid);h.setOperatorName(name);h.setCreatedAt(LocalDateTime.now());repo.history(h);}