fix(dropship): generate pallet numbers atomically and extend full regressions

This commit is contained in:
2026-09-09 12:19:16 +08:00
100 changed files with 2889 additions and 234 deletions
@@ -92,6 +92,15 @@ public class PlatformDropshipPalletizationController {
return ApiResponse.success(service.bindPending(taskId, request, user.userId(), user.username()));
}
@PostMapping("/tasks/{taskId}/pallets/create-and-bind")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<Task> createAndBind(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@Valid @RequestBody DropshipPalletizationDtos.CreateAndBindRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.createAndBind(taskId, request, user.userId(), user.username()));
}
@PostMapping("/tasks/{taskId}/cartons/{cartonId}/move")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<Task> moveCarton(@AuthenticationPrincipal AuthenticatedUser user,
@@ -15,6 +15,9 @@ public final class DropshipPalletizationDtos {
public record ClaimRequest(@Positive long warehouseId) {}
public record CreateAndBindRequest(@Positive long warehouseId,
@NotBlank @Size(max = 128) String operationId) {}
public enum DiscrepancyAction { MATCH, ACCEPT_VARIANCE }
public record CountCartonRequest(@Positive long warehouseId,
@@ -13,6 +13,11 @@ import java.util.List;
/** 一件代发逐箱清点、待绑托与封托事实。 */
@Mapper
public interface DropshipPalletizationMapper {
@Select("SELECT id FROM fl_dropship_pallet_task WHERE id=#{taskId} AND warehouse_id=#{warehouseId} FOR UPDATE")
Long lockTask(@Param("taskId") long taskId,@Param("warehouseId") long warehouseId);
@Select("SELECT * FROM fl_dropship_pallet WHERE open_operation_id=#{operationId}")
PalletRow palletByOpenOperation(@Param("operationId") String operationId);
@Insert("""
INSERT IGNORE INTO fl_dropship_pallet_task
(id,tenant_id,inbound_id,warehouse_id,task_status,total_cartons,counted_cartons,allocated_cartons,discrepancy_cartons,version,created_at,updated_at)
@@ -34,6 +34,7 @@ import com.baiyue.firstleg.oceanbooking.entity.OceanBookingOperationEntity;
@Service
public class OceanBookingDocumentService {
public static final String BUSINESS_TYPE = "OCEAN_BOOKING_DOCUMENT";
private static final long FILE_TENANT_ID = 0L;
private static final Set<String> DOCUMENT_TYPES = Set.of("BOOKING_CONFIRMATION", "BILL_OF_LADING");
@Resource private OceanBookingRepository repository;
@@ -54,7 +55,7 @@ public class OceanBookingDocumentService {
OceanBookingEntity booking = lockDraft(oceanBookingId, expectedVersion);
OceanBookingFileClient.Metadata metadata;
try {
var response = files.upload(internalToken, operatorId, booking.getCustomerId(), BUSINESS_TYPE,
var response = files.upload(internalToken, operatorId, FILE_TENANT_ID, BUSINESS_TYPE,
Long.toString(oceanBookingId), file);
if (response == null || !response.successful() || response.data() == null) {
throw bad("订舱文件上传失败");
@@ -115,7 +116,7 @@ public class OceanBookingDocumentService {
}
metadata(booking, document.getFileId());
try {
ResponseEntity<byte[]> response = files.download(internalToken, booking.getCustomerId(), BUSINESS_TYPE,
ResponseEntity<byte[]> response = files.download(internalToken, FILE_TENANT_ID, BUSINESS_TYPE,
Long.toString(oceanBookingId), document.getFileId());
byte[] content = response.getBody();
if (!response.getStatusCode().is2xxSuccessful() || content == null || content.length == 0) {
@@ -132,9 +133,6 @@ public class OceanBookingDocumentService {
}
public void requireActiveValidDocument(OceanBookingEntity booking) {
if (booking.getCustomerId() == null || booking.getCustomerId() <= 0) {
throw conflict("订舱缺少客户归属,无法校验文件");
}
boolean valid = false;
for (OceanBookingDocumentEntity document : repository.activeDocuments(booking.getId())) {
if (!DOCUMENT_TYPES.contains(document.getDocumentType())) continue;
@@ -178,9 +176,6 @@ public class OceanBookingDocumentService {
if (booking == null) throw new BizException(ResultCode.NOT_FOUND, "订舱单不存在");
if (!Integer.valueOf(expectedVersion).equals(booking.getVersion())) throw concurrent();
if (!"DRAFT".equals(booking.getStatus())) throw conflict("已放舱订舱文件永久只读");
if (booking.getCustomerId() == null || booking.getCustomerId() <= 0) {
throw bad("请先保存订舱客户后再上传文件");
}
return booking;
}
@@ -192,7 +187,7 @@ public class OceanBookingDocumentService {
private OceanBookingFileClient.Metadata metadata(OceanBookingEntity booking, long fileId) {
try {
var response = files.metadata(internalToken, booking.getCustomerId(), BUSINESS_TYPE,
var response = files.metadata(internalToken, FILE_TENANT_ID, BUSINESS_TYPE,
Long.toString(booking.getId()), fileId);
if (response == null || !response.successful() || response.data() == null) {
throw new BizException(ResultCode.NOT_FOUND, "订舱文件不存在或归属不匹配");
@@ -207,7 +202,7 @@ public class OceanBookingDocumentService {
}
private static void validateMetadata(OceanBookingEntity booking, OceanBookingFileClient.Metadata metadata) {
if (metadata.fileId() == null || !Objects.equals(metadata.tenantId(), booking.getCustomerId())
if (metadata.fileId() == null || !Objects.equals(metadata.tenantId(), FILE_TENANT_ID)
|| !BUSINESS_TYPE.equals(metadata.businessType())
|| !Long.toString(booking.getId()).equals(metadata.businessId())) {
throw new BizException(ResultCode.VALIDATION_FAILED, "订舱文件归属不匹配");
@@ -50,6 +50,7 @@ public class OceanBookingService {
@Resource private WarehousePartnerClient partners;
@Resource private FirstlegDocumentNumberGenerator documentNumbers;
@Resource private OceanBookingDocumentService documents;
@Resource private com.baiyue.firstleg.service.IamCustomerSnapshotService customers;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
@Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId;
@@ -200,7 +201,7 @@ public class OceanBookingService {
private void validateDraftStructure(DraftData data) {
boolean hasCustomerId = data.customerId() != null;
boolean hasCustomerName = StringUtils.hasText(data.customerName());
if (hasCustomerId != hasCustomerName) throw badRequest("客户ID和客户名称必须同时填写或同时为空");
if (!hasCustomerId && hasCustomerName) throw badRequest("客户名称不能脱离客户ID提交");
if (data.parties() != null) {
Set<String> types = new HashSet<>();
for (PartyRequest party : data.parties()) {
@@ -221,8 +222,7 @@ public class OceanBookingService {
private static void validateRange(LocalDateTime from, LocalDateTime to) { if (from != null && to != null && to.isBefore(from)) throw badRequest("预计到港时间不能早于预计开船时间"); }
private static void validateRelease(OceanBookingEntity booking, List<OceanBookingPartyEntity> parties,
List<OceanBookingContainerRequirementEntity> requirements) {
if (booking.getCustomerId() == null || !StringUtils.hasText(booking.getCustomerName())
|| !StringUtils.hasText(booking.getChannelCode()) || booking.getWarehouseId() == null
if (!StringUtils.hasText(booking.getChannelCode()) || booking.getWarehouseId() == null
|| !StringUtils.hasText(booking.getBillOfLadingNo()) || !StringUtils.hasText(booking.getVesselName())
|| !StringUtils.hasText(booking.getVoyageNo()) || !StringUtils.hasText(booking.getServiceTerm())
|| !StringUtils.hasText(booking.getFreightTerm()) || booking.getEstimatedSailingAt() == null
@@ -258,8 +258,9 @@ public class OceanBookingService {
}
private record ResolvedDraft(String warehouseName, String channelName, String shippingCompanyName,
String carrierCode, String bookingAgentName) {}
String carrierCode, String bookingAgentName, String customerName) {}
private ResolvedDraft resolveDraft(DraftData d) {
String customerName = resolveCustomer(d.customerId(), d.customerName());
String warehouseName = null; String channelName = null;
if (d.warehouseId() != null && StringUtils.hasText(d.channelCode())) {
WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(d.warehouseId());
@@ -269,10 +270,10 @@ public class OceanBookingService {
var carrier = resolveCarrier(d.shippingCompanyId(), d.shippingCompanyName(), d.carrierCode());
var agent = resolveBookingAgent(d.bookingAgentId(), d.bookingAgentName(), carrier == null ? null : carrier.carrierCode());
return new ResolvedDraft(warehouseName, channelName, carrier == null ? null : carrier.partnerName(),
carrier == null ? null : carrier.carrierCode(), agent == null ? null : agent.name());
carrier == null ? null : carrier.carrierCode(), agent == null ? null : agent.name(), customerName);
}
private static void copy(OceanBookingEntity v, DraftData d, ResolvedDraft resolved) {
v.setCustomerId(d.customerId()); v.setCustomerName(trim(d.customerName()));
v.setCustomerId(d.customerId()); v.setCustomerName(resolved.customerName());
v.setChannelCode(trim(d.channelCode())); v.setChannelName(resolved.channelName());
v.setWarehouseId(d.warehouseId()); v.setWarehouseName(resolved.warehouseName());
v.setBillOfLadingNo(trim(d.billOfLadingNo()));
@@ -288,10 +289,20 @@ public class OceanBookingService {
}
private void validateMasterData(OceanBookingEntity booking) {
resolveCustomer(booking.getCustomerId(), booking.getCustomerName());
resolveCarrier(booking.getShippingCompanyId(), booking.getShippingCompanyName(), booking.getCarrierCode());
resolveBookingAgent(booking.getBookingAgentId(), booking.getBookingAgentName(), booking.getCarrierCode());
}
private String resolveCustomer(Long id, String name) {
if (id == null) {
if (StringUtils.hasText(name)) throw badRequest("客户名称不能脱离客户ID提交");
return null;
}
if (id <= 0) throw badRequest("请选择有效客户");
return customers.active(id, name).tenantName();
}
private WarehousePartnerClient.Snapshot resolveCarrier(Long id, String suppliedName, String suppliedCode) {
if (id == null) {
if (StringUtils.hasText(suppliedName) || StringUtils.hasText(suppliedCode)) throw badRequest("船公司名称和编码不能脱离shippingCompanyId提交");
@@ -84,7 +84,7 @@ public class ContainerFeeDetailService {
SaveRequest common = request.fee();
FeeItemEntity item = requireItem(common.feeItemId(), common.direction());
BigDecimal total = common.amount().setScale(2, RoundingMode.HALF_UP);
BigDecimal allocated = request.allocations().stream().map(Allocation::amount)
BigDecimal allocated = request.allocations().stream().map(value -> allocationAmount(value.amount()))
.reduce(BigDecimal.ZERO, BigDecimal::add).setScale(2, RoundingMode.HALF_UP);
if (allocated.compareTo(total) != 0) throw conflict("柜级费用分摊合计必须等于费用总额");
Map<InboundAllocationKey, Long> ownership = new HashMap<>();
@@ -158,7 +158,7 @@ public class ContainerFeeDetailService {
throw conflict("只有完整的当前已审核分摊组可以调整");
Map<Long, BigDecimal> amounts = new HashMap<>();
for (AllocationAmount allocation : request.allocations()) {
if (amounts.put(allocation.feeId(), allocation.amount().setScale(2, RoundingMode.HALF_UP)) != null)
if (amounts.put(allocation.feeId(), allocationAmount(allocation.amount())) != null)
throw conflict("分摊费用行不得重复");
}
if (!amounts.keySet().equals(rows.stream().map(ContainerFeeDetailEntity::getId).collect(java.util.stream.Collectors.toSet())))
@@ -372,6 +372,12 @@ public class ContainerFeeDetailService {
return new BizException(ResultCode.CONFLICT, message);
}
private static BigDecimal allocationAmount(BigDecimal amount) {
BigDecimal rounded = amount.setScale(2, RoundingMode.HALF_UP);
if (rounded.signum() <= 0) throw conflict("分摊金额按两位小数取整后必须大于0");
return rounded;
}
private static long containerBookingId(ContainerFeeDetailEntity value) {
if (value.getContainerBookingId() == null || value.getContainerBookingId() <= 0) {
throw conflict("柜次费用缺少真实货柜关联");
@@ -21,6 +21,30 @@ import java.util.*;
@Service
public class DropshipPalletizationService {
@Resource private DropshipPalletizationMapper mapper;
@Resource private FirstlegDocumentNumberGenerator documentNumbers;
@Transactional
public Task createAndBind(long taskId,CreateAndBindRequest request,long operatorId,String operatorName){
if(mapper.lockTask(taskId,request.warehouseId())==null) throw new BizException(ResultCode.NOT_FOUND,"未找到当前仓库的一件代发清点组托任务");
TaskRow task=requireTask(taskId,request.warehouseId());
if(!Objects.equals(task.getAssigneeId(),operatorId)) throw conflict("任务不属于当前操作员");
String operation=operation(request.operationId());
PalletRow replay=mapper.palletByOpenOperation(operation);
if(replay!=null){
if(!Objects.equals(replay.getTaskId(),taskId)||!Objects.equals(replay.getWarehouseId(),request.warehouseId())
||!Objects.equals(replay.getTenantId(),task.getTenantId())) throw conflict("创建托盘幂等号已用于不同任务");
return view(task);
}
if(!"CLAIMED".equals(task.getTaskStatus())||!"WAIT_PALLETIZE".equals(task.getProcessStatus())) throw conflict("当前任务不允许创建托盘");
int expected=mapper.unboundCount(taskId);
if(expected==0) throw conflict("当前没有待绑托箱,请先清点箱码");
long palletId=IdWorker.getId();
String palletNo=documentNumbers.next("DP");
mapper.insertPallet(palletId,taskId,task.getTenantId(),task.getInboundId(),task.getWarehouseId(),palletNo,operation,operatorId,operatorName);
int bound=mapper.bindPending(taskId,palletId,operation,operatorId,operatorName);
if(bound!=expected||mapper.adjustBound(taskId,operatorId,bound)!=1) throw conflict("待绑托箱已变化,请刷新后重试");
return view(requireTask(taskId,request.warehouseId()));
}
public void createTask(InboundOrderEntity inbound) {
if (!"ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType())) return;
@@ -23,7 +23,7 @@ public final class TerminalDrayageDtos {
public record ActualTimeRequest(@NotNull @Min(0) Integer expectedVersion,
@NotNull LocalDateTime actualAt,
@Size(max = 500) String reason) {}
@Size(max = 500) String remark) {}
public record Warning(String status, LocalDateTime deadlineAt,
long overdueHours, long overdueDays) {}
@@ -147,14 +147,14 @@ public class TerminalDrayageService {
throw badRequest("实际提柜时间不能早于当前实际船节点时间");
}
advanceNode(context, CarrierScheduleNode.PICKED_UP, request.actualAt(), action, key,
request.reason(), operatorId, operatorName);
request.remark(), operatorId, operatorName);
int fromVersion = version(value);
String fromStatus = status(context, value);
value.setActualPickupAt(request.actualAt());
applyAuthoritativeDeadline(context, value);
persist(value, fromVersion, operatorId, operatorName);
return finish(requiredContext(containerBookingId), value, action, key, request, fromStatus,
fromVersion, request.reason(), operatorId, operatorName);
fromVersion, request.remark(), operatorId, operatorName);
}
@Transactional
@@ -174,14 +174,14 @@ public class TerminalDrayageService {
if (request.actualAt().isBefore(earliest)) throw badRequest("实际还柜时间不能早于提柜或卸柜完成时间");
if (actualEmptyReturnAt(context, value) != null) throw conflict("实际还柜时间已确认");
advanceNode(context, CarrierScheduleNode.EMPTY_RETURNED, request.actualAt(), action, key,
request.reason(), operatorId, operatorName);
request.remark(), operatorId, operatorName);
int fromVersion = version(value);
String fromStatus = status(context, value);
applyAuthoritativeDeadline(context, value);
value.setActualEmptyReturnAt(request.actualAt());
persist(value, fromVersion, operatorId, operatorName);
return finish(requiredContext(containerBookingId), value, action, key, request, fromStatus,
fromVersion, request.reason(), operatorId, operatorName);
fromVersion, request.remark(), operatorId, operatorName);
}
private void advanceNode(WorkContext context, CarrierScheduleNode target, LocalDateTime actualAt,