package com.baiyue.lastleg.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.event.ContainerNumbers; import com.baiyue.lastleg.dto.LastlegOrderDetailResponse; import com.baiyue.lastleg.dto.LastlegOrderCommandDtos; import com.baiyue.lastleg.dto.LastlegOrderRequests; import com.baiyue.lastleg.dto.LastlegOrderSummary; import com.baiyue.lastleg.dto.ContainerPlanProgressDetail; import com.baiyue.lastleg.dto.ContainerPlanProgressSummary; import com.baiyue.lastleg.dto.ContainerArrivalTimeView; import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.entity.LastlegOrderDetail; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; import com.baiyue.lastleg.entity.LastlegOrderOperationLog; import com.baiyue.lastleg.entity.LastlegOrderManualNode; import com.baiyue.lastleg.entity.ReceivingWarehouseOperation; import com.baiyue.lastleg.entity.ContainerArrivalTimeOperation; import com.baiyue.lastleg.client.OrderPreDepartureClient; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos; import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService; import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper; import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper; import com.baiyue.lastleg.mapper.ReceivingWarehouseOperationMapper; import com.baiyue.lastleg.mapper.ContainerArrivalTimeOperationMapper; import com.baiyue.lastleg.model.LastlegOrderType; import com.baiyue.lastleg.model.LastlegOrderStatus; import com.baiyue.lastleg.shipping.entity.ContainerShippingGate; import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper; import jakarta.annotation.Resource; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import org.springframework.beans.factory.annotation.Value; import org.springframework.web.server.ResponseStatusException; import java.nio.charset.StandardCharsets; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; import java.math.BigDecimal; import java.time.LocalDateTime; import java.security.MessageDigest; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; @Service public class LastlegOrderService { public static final String PENDING_ASSIGNMENT = "PENDING_ASSIGNMENT"; public static final String DESTINATION_ASSIGNED = "DESTINATION_ASSIGNED"; public static final String INBOUND_PLANNED = "INBOUND_PLANNED"; public static final String PALLET_MERGE_REQUIRED = "REQUIRED"; public static final String PALLET_MERGED = "MERGED"; private static final List MANUAL_NODE_CODES = List.of( "DOMESTIC_CUSTOMS_COMPLETED", "ESTIMATED_DEPARTURE", "ACTUAL_DEPARTURE", "ESTIMATED_ARRIVAL", "OVERSEAS_CUSTOMS_COMPLETED", "ACTUAL_ARRIVAL_WAITING_UNLOAD", "PICKUP_APPOINTED", "UNLOADED"); @Resource private LastlegOrderMapper orderMapper; @Resource private com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService returnReceipts; @Resource private LastlegOrderDetailMapper detailMapper; @Resource private LastlegOrderStatusHistoryMapper historyMapper; @Resource private OverseasFeeSourceService feeSources; @Resource private com.baiyue.lastleg.financefee.service.OverseasManualFeeService manualFees; @Resource private LastlegOrderOperationLogMapper operationLogMapper; @Resource private LastlegOrderManualNodeMapper manualNodeMapper; @Resource private InboundPlanCartonMapper cartonMapper; @Resource private FbaWarehouseValidator fbaWarehouseValidator; @Resource private InventoryCommandService inventory; @Resource private ReceivingWarehouseOperationMapper receivingWarehouseOperations; @Resource private ContainerArrivalTimeOperationMapper containerArrivalTimeOperations; @Resource private ContainerWarehouseArrivalService warehouseArrival; @Resource private WarehouseResourceGateway warehouseResourceGateway; @Resource private ContainerShippingGateMapper containerShippingGates; @Resource private ReturnOrderReferenceResolver returnOrderReferences; @Resource private com.baiyue.lastleg.transfer.service.TransferQuantityService transferQuantities; @Resource private OrderPreDepartureClient orderPreDepartureClient; @Value("${baiyue.security.internal-service-token:}") private String internalServiceToken; public PageResult page(String customerId, LastlegOrderRequests.Query query) { return page(customerId, query, null); } public PageResult page(String customerId, LastlegOrderRequests.Query query, String relabelNo) { if (query.sailingStage() == LastlegOrderRequests.SailingStage.BEFORE_DEPARTURE) { Long customer = parseLong(customerId); var response = orderPreDepartureClient.page(internalServiceToken, customer, query.orderNo(), query.inboundCode(), query.page().intValue(), query.size().intValue()); if (response == null || response.data() == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "订单服务暂时不可用"); List rows = response.data().records().stream().map(this::preDepartureSummary).toList(); return new PageResult<>(rows, response.data().page(), response.data().size(), response.data().total(), response.data().pages()); } if (query.sailingStage() == null && relabelNo == null) return aggregateAllPage(customerId, query); Page page = orderMapper.selectPage( new Page<>(query.page(), query.size()), filter(customerId, query, relabelNo).orderByDesc(LastlegOrder::getCreatedAt).orderByDesc(LastlegOrder::getId)); return new PageResult<>(nativeSummaries(page.getRecords()), page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); } /** 全部及已开船列表使用同一组业务事实,避免聚合列表丢失并托、排车和换标信息。 */ private List nativeSummaries(List orders) { Map> mergeRelations = palletMergeRelations(orders); returnOrderReferences.populateForRead(orders); refreshReturnVolumes(orders); Map containerNodes = containerNodes(orders); Map dispatchStatuses = dispatchStatuses(orders); Map weights = orderWeights(orders); Map> relabels = relabelSubOrders(orders); Map businessSources = businessSources(orders); var quantities = transferQuantities.forOrders(orders); return orders.stream().map(order -> summary(order, mergeRelations.get(order.getId()), StringUtils.hasText(order.getSourceContainerBookingId()) ? containerNodes.get(order.getSourceContainerBookingId()) : null, dispatchStatuses.getOrDefault(order.getId(), "UNSCHEDULED"), weights.get(order.getId()), relabels.getOrDefault(order.getId(), List.of()), businessSources.get(order.getId()), quantities.get(order.getId()))).toList(); } /** 全部页签合并订单服务的未开船头程数据与本服务已有尾程单,尾程记录优先覆盖同一来源订单。 */ private PageResult aggregateAllPage(String customerId, LastlegOrderRequests.Query query) { var source = orderPreDepartureClient.page(internalServiceToken, parseLong(customerId), query.orderNo(), query.inboundCode(), 1, 2000); if (source == null || source.data() == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "订单服务暂时不可用"); List existing = orderMapper.selectList(filter(customerId, query, null) .orderByDesc(LastlegOrder::getCreatedAt).orderByDesc(LastlegOrder::getId)); Map merged = new LinkedHashMap<>(); source.data().records().forEach(row -> { var summary = preDepartureSummary(row); merged.put(orderKey(summary.customerId(), summary.sourceOrderNo()), summary); }); for (LastlegOrderSummary order : nativeSummaries(existing)) { String key = orderKey(order.customerId(), StringUtils.hasText(order.sourceOrderNo()) ? order.sourceOrderNo() : order.orderNo()); merged.put(key, order); } List rows = merged.values().stream().sorted(java.util.Comparator.comparing(LastlegOrderSummary::createdAt, java.util.Comparator.nullsLast(java.util.Comparator.reverseOrder()))).toList(); int from = Math.min((query.page().intValue() - 1) * query.size().intValue(), rows.size()); int to = Math.min(from + query.size().intValue(), rows.size()); long total = rows.size(); return new PageResult<>(rows.subList(from, to), query.page(), query.size(), total, total == 0 ? 0 : (total + query.size() - 1) / query.size()); } private static String orderKey(String customerId, String orderNo) { return String.valueOf(customerId) + ":" + String.valueOf(orderNo); } /** 将订单服务权威头程摘要投影为只读尾程列表行;尚未生成尾程单的字段保持为空。 */ private LastlegOrderSummary preDepartureSummary(OrderPreDepartureClient.Row row) { LastlegOrder order = new LastlegOrder(); // 头程聚合行尚未生成尾程单,尾程订单号必须保持为空;来源头程订单号单独保留。 order.setId(row.orderId()); order.setOrderNo(null); order.setOrderType("FIRST_LEG"); order.setInboundCode(row.inboundCode()); order.setLxCode(row.lxCode()); order.setSourceOrderNo(row.orderNo()); order.setCustomerId(row.customerId() == null ? null : String.valueOf(row.customerId())); order.setCustomerName(row.customerName()); order.setSourceWarehouseName(row.receivingWarehouseName()); order.setOverseasWarehouseName(row.receivingWarehouseName()); order.setFbaWarehouseCode(row.destinationCode()); order.setContainerNo(row.containerNo()); order.setPackageCount(row.packageCount()); order.setVolumeCbm(row.volumeCbm()); order.setEstimatedArrivalAt(row.expectedArrivalAt()); order.setCurrentStatus(row.mainStatus()); order.setCreatedAt(row.createdAt()); order.setUpdatedAt(row.updatedAt()); order.setVersion(0); return summary(order, List.of(), null, "UNSCHEDULED", null, List.of(), null, null); } @Transactional(readOnly = true) public PageResult containerProgressPage( LastlegOrderRequests.ContainerProgressQuery query) { if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "createdFrom must not be after createdTo"); } long total = orderMapper.countContainerProgress(query); List records = total == 0 ? List.of() : orderMapper.pageContainerProgress(query, (query.page() - 1) * query.size(), query.size()) .stream().map(this::containerSummaryForRead).toList(); long pages = total == 0 ? 0 : (total + query.size() - 1) / query.size(); return new PageResult<>(records, query.page(), query.size(), total, pages); } @Transactional(readOnly = true) public ContainerPlanProgressDetail containerProgressDetail(String sourceContainerBookingId) { String bookingId = sourceContainerBookingId == null ? null : sourceContainerBookingId.trim(); if (!StringUtils.hasText(bookingId)) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "来源柜预订ID不能为空"); } ContainerPlanProgressSummary summary = orderMapper.findContainerProgress(bookingId); if (summary == null) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜不存在"); } return new ContainerPlanProgressDetail(containerSummaryForRead(summary), orderMapper.findContainerProgressItems(bookingId)); } /** 清关列表读取统一解析的柜级日期,默认推算不写入订单。 */ @Transactional(readOnly = true) public List containerArrivalTimes( LastlegOrderRequests.ContainerArrivalTimeQuery request) { LinkedHashSet bookingIds = new LinkedHashSet<>(); for (String value : request.sourceContainerBookingIds()) bookingIds.add(normalizeBookingId(value)); if (bookingIds.size() != request.sourceContainerBookingIds().size()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "来源柜预订ID不能重复"); } return warehouseArrival.resolve(List.copyOf(bookingIds)); } @Transactional public ContainerArrivalTimeView changeExpectedWarehouseArrival(String sourceContainerBookingId, String idempotencyKey, LastlegOrderRequests.ChangeExpectedWarehouseArrival request, String operatorName) { String bookingId = normalizeBookingId(sourceContainerBookingId); String key = normalizeIdempotencyKey(idempotencyKey); String requestHash = hash(bookingId + "\n" + request.estimatedWarehouseArrivalAt() + "\n" + request.expectedVersion() + "\n" + Objects.toString(request.remark(), "")); ContainerArrivalTimeOperation replay = containerArrivalTimeOperations.findByKey(key); if (replay != null) { if (!bookingId.equals(replay.getSourceContainerBookingId()) || !requestHash.equals(replay.getRequestHash())) { throw new ResponseStatusException(HttpStatus.CONFLICT, "Idempotency-Key 已用于其他预计到仓时间修改请求"); } return requireContainerArrivalTime(bookingId); } List orders = orderMapper.lockContainerOrders(bookingId); if (orders.isEmpty()) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜不存在"); int version = requireSingleArrivalTimeVersion(orders); if (version != request.expectedVersion()) { throw new ResponseStatusException(HttpStatus.CONFLICT, "预计到仓时间版本已变化,请刷新后重试"); } if (orderMapper.countContainerPlans(bookingId) > 0) { throw new ResponseStatusException(HttpStatus.CONFLICT, "该柜已生成入库计划,预计到仓时间不可修改"); } int updated = orderMapper.updateContainerExpectedWarehouseArrival(bookingId, request.estimatedWarehouseArrivalAt(), version); if (updated != orders.size()) { throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内预计到仓时间发生并发变化"); } ContainerArrivalTimeOperation operation = new ContainerArrivalTimeOperation(); operation.setId(IdWorker.getId()); operation.setSourceContainerBookingId(bookingId); operation.setIdempotencyKey(key); operation.setRequestHash(requestHash); operation.setResultVersion(version + 1); operation.setOperatorName(operatorName); operation.setRemark(request.remark() == null ? null : request.remark().trim()); operation.setCreatedAt(LocalDateTime.now()); containerArrivalTimeOperations.insert(operation); return requireContainerArrivalTime(bookingId); } /** 保存后沿同一读取入口返回人工日期、版本和计划冻结事实。 */ private ContainerArrivalTimeView requireContainerArrivalTime(String bookingId) { List values = warehouseArrival.resolve(List.of(bookingId)); if (values.size() != 1) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜不存在"); if (values.get(0).version() < 0) { throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内预计到仓时间版本不一致"); } return values.get(0); } private int requireSingleArrivalTimeVersion(List orders) { Set versions = orders.stream().map(LastlegOrder::getArrivalTimeVersion) .map(value -> value == null ? 0 : value).collect(Collectors.toSet()); if (versions.size() != 1) { throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内预计到仓时间版本不一致"); } return versions.iterator().next(); } @Transactional public ContainerPlanProgressSummary changeReceivingWarehouse(String sourceContainerBookingId, String idempotencyKey, LastlegOrderRequests.ChangeReceivingWarehouse request, String operatorName) { String bookingId = normalizeBookingId(sourceContainerBookingId); String key = normalizeIdempotencyKey(idempotencyKey); String requestHash = hash(bookingId + "\n" + request.overseasWarehouseId() + "\n" + request.expectedVersion() + "\n" + Objects.toString(request.remark(), "")); ReceivingWarehouseOperation replay = receivingWarehouseOperations.findByKey(key); if (replay != null) { if (!bookingId.equals(replay.getSourceContainerBookingId()) || !requestHash.equals(replay.getRequestHash())) { throw new ResponseStatusException(HttpStatus.CONFLICT, "Idempotency-Key 已用于其他接柜仓修改请求"); } return requireFrozenContainer(orderMapper.findContainerProgress(bookingId)); } List orders = orderMapper.lockContainerOrders(bookingId); if (orders.isEmpty()) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜不存在"); int version = requireSingleAssignmentVersion(orders); if (version != request.expectedVersion()) { throw new ResponseStatusException(HttpStatus.CONFLICT, "接柜仓分配版本已变化,请刷新后重试"); } if (orderMapper.countReceivingSessions(bookingId) > 0) { throw new ResponseStatusException(HttpStatus.CONFLICT, "海外PDA已建立接柜会话,接柜仓已永久锁定"); } WarehouseResourceGateway.WarehouseReference warehouse = warehouseResourceGateway.requireActiveSharedWarehouse(request.overseasWarehouseId()); for (LastlegOrder order : orders) { inventory.rebindReceivingWarehouse(order.getCustomerId(), order.getId(), warehouse.warehouseId(), warehouse.warehouseCode(), warehouse.warehouseName(), operatorName, key); } int updated = orderMapper.updateContainerReceivingWarehouse(bookingId, Long.toString(warehouse.warehouseId()), warehouse.warehouseCode(), warehouse.warehouseName(), version); if (updated != orders.size()) { throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内接柜仓分配发生并发变化"); } orderMapper.updateContainerPlanReceivingWarehouse(bookingId, warehouse.warehouseId()); ReceivingWarehouseOperation operation = new ReceivingWarehouseOperation(); operation.setId(IdWorker.getId()); operation.setSourceContainerBookingId(bookingId); operation.setIdempotencyKey(key); operation.setRequestHash(requestHash); operation.setResultVersion(version + 1); operation.setOperatorName(operatorName); operation.setRemark(request.remark() == null ? null : request.remark().trim()); operation.setCreatedAt(LocalDateTime.now()); receivingWarehouseOperations.insert(operation); return requireFrozenContainer(orderMapper.findContainerProgress(bookingId)); } private ContainerPlanProgressSummary requireFrozenContainer(ContainerPlanProgressSummary summary) { if (summary == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜不存在"); String issue = containerSnapshotIssue(summary); if (issue != null) throw new ResponseStatusException(HttpStatus.CONFLICT, issue); return summary; } private ContainerPlanProgressSummary containerSummaryForRead(ContainerPlanProgressSummary summary) { String issue = containerSnapshotIssue(summary); if (issue == null) return summary; boolean invalidContainer = !hasCanonicalContainerNo(summary.containerNo()); return new ContainerPlanProgressSummary(summary.sourceContainerBookingId(), summary.containerNo(), summary.overseasWarehouseId(), summary.overseasWarehouseCode(), summary.overseasWarehouseName(), summary.assignmentVersion(), invalidContainer ? false : summary.canChangeReceivingWarehouse(), invalidContainer ? issue : summary.receivingWarehouseLockReason(), summary.totalOrderCount(), summary.inboundCodeCount(), summary.missingInboundCodeCount(), summary.planCount(), summary.plannedInboundCodeCount(), summary.pendingPlanCount(), summary.resourcePendingPlanCount(), summary.plannedArrivalAt(), false, issue); } private String containerSnapshotIssue(ContainerPlanProgressSummary summary) { if (!hasCanonicalContainerNo(summary.containerNo())) { return "来源柜真实集装箱号缺失、格式无效或存在漂移"; } if (!StringUtils.hasText(summary.overseasWarehouseId()) || !StringUtils.hasText(summary.overseasWarehouseCode()) || !StringUtils.hasText(summary.overseasWarehouseName()) || summary.assignmentVersion() < 0) { return "柜内接柜仓业务快照不一致"; } return null; } private boolean hasCanonicalContainerNo(String containerNo) { try { ContainerNumbers.requireCanonical(containerNo); return true; } catch (IllegalArgumentException exception) { return false; } } private String normalizeBookingId(String value) { String result = value == null ? null : value.trim(); if (!StringUtils.hasText(result) || result.length() > 128) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "来源柜预订ID不能为空"); } return result; } private String normalizeIdempotencyKey(String value) { String result = value == null ? null : value.trim(); if (!StringUtils.hasText(result) || result.length() > 255) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Idempotency-Key 必填且不得超过255字符"); } return result; } private int requireSingleAssignmentVersion(List orders) { Integer version = orders.get(0).getReceivingWarehouseVersion(); if (version == null || orders.stream().anyMatch(order -> !Objects.equals(version, order.getReceivingWarehouseVersion()) || !StringUtils.hasText(order.getOverseasWarehouseId()) || !StringUtils.hasText(order.getOverseasWarehouseCode()) || !StringUtils.hasText(order.getOverseasWarehouseName()))) { throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内接柜仓分配事实不一致"); } return version; } private String hash(String value) { try { return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") .digest(value.getBytes(StandardCharsets.UTF_8))); } catch (Exception exception) { throw new IllegalStateException(exception); } } /** 返回订单详情及统一费用审核、余额锁扣状态。 */ public LastlegOrderDetailResponse detail(String customerId, long orderId) { LastlegOrder order = findOwned(customerId, orderId); returnOrderReferences.populateForRead(List.of(order)); refreshReturnVolumes(List.of(order)); List cargo = detailMapper.selectList(new LambdaQueryWrapper() .eq(LastlegOrderDetail::getCustomerId, order.getCustomerId()) .eq(LastlegOrderDetail::getOrderId, orderId).orderByAsc(LastlegOrderDetail::getId)); List history = historyMapper.selectList( new LambdaQueryWrapper() .eq(LastlegOrderStatusHistory::getCustomerId, order.getCustomerId()) .eq(LastlegOrderStatusHistory::getOrderId, orderId) .orderByAsc(LastlegOrderStatusHistory::getCreatedAt)); List orderFees = new java.util.ArrayList<>( feeSources.listForBusiness(order.getCustomerId(), "LASTLEG_ORDER", orderId)); // 换标费用以申请为主业务,履约详情同时保留旧订单费用和新申请费用。 if ("RELABEL_APPLICATION".equals(order.getOrderType())) orderFees.addAll(manualFees.relabelFeesForOrder(order.getCustomerId(), orderId)); List fees = manualFees.describe(orderFees); List operations = operationLogMapper.selectList( new LambdaQueryWrapper() .eq(LastlegOrderOperationLog::getCustomerId, order.getCustomerId()) .eq(LastlegOrderOperationLog::getOrderId, orderId) .orderByAsc(LastlegOrderOperationLog::getCreatedAt)); List manualNodes = manualNodeMapper.selectList( new LambdaQueryWrapper() .eq(LastlegOrderManualNode::getCustomerId, order.getCustomerId()) .eq(LastlegOrderManualNode::getOrderId, orderId) .orderByAsc(LastlegOrderManualNode::getId)).stream() .map(value -> new LastlegOrderCommandDtos.ManualNodeView( value.getNodeCode(), value.getNodeAt(), value.getSource())) .sorted(java.util.Comparator.comparingInt(value -> MANUAL_NODE_CODES.indexOf(value.nodeCode()))).toList(); return new LastlegOrderDetailResponse(summary(order, palletMergeRelations(List.of(order)).get(order.getId()), containerNode(order), dispatchStatuses(List.of(order)).getOrDefault(order.getId(), "UNSCHEDULED"), orderWeights(List.of(order)).get(order.getId()), relabelSubOrders(List.of(order)).getOrDefault(order.getId(), List.of()), businessSources(List.of(order)).get(order.getId()), transferQuantities.forOrders(List.of(order)).get(order.getId())), manualNodes, cargo, history.stream() .map(value -> new LastlegOrderDetailResponse.History(value.getId(), value.getFromStatus(), value.getToStatus(), value.getRemark(), value.getCreatedAt())).toList(), fees, operations.stream() .map(value -> new LastlegOrderDetailResponse.Operation(value.getId(), value.getOperationNode(), value.getOperatorName(), value.getResult(), value.getCreatedAt())).toList(), "RETURN_RELABEL".equals(order.getOrderType()) && StringUtils.hasText(order.getSourceOrderNo()) ? returnReceipts.detail(order.getCustomerId(), order.getSourceOrderNo()) : null); } /** 订单详情统一委托人工费用服务,头程待审核入账,海外业务先锁款。 */ @Transactional public OverseasFeeSource createFee(String customerId, long orderId, LastlegOrderRequests.CreateFee request, String operatorName, String idempotencyKey) { LastlegOrder order = findOwned(customerId, orderId); LocalDateTime now = LocalDateTime.now(); OverseasFeeSource fee = manualFees.create(operatorName, idempotencyKey, new OverseasFeeSourceDtos.SaveRequest(order.getCustomerId(), order.getCustomerName(), "OVERSEAS_ORDER_FEE", "LASTLEG_ORDER", orderId, order.getOrderNo(), orderId, null, order.getInboundCode(), null, parseLong(order.getSourceContainerBookingId()), order.getContainerNo(), parseLong(order.getOverseasWarehouseId()), request.feeItemCode(), request.feeItemName(), request.feeRemark(), request.direction(), request.supplierId(), request.supplierName(), request.supplierCategory(), request.settlementMode(), request.paidAt(), request.paymentMethod(), request.paymentReferenceNo(), request.paymentVoucherFileIds(), request.businessDate(), request.currency(), request.amount(), request.chargeableQuantity(), request.quantityUnit(), request.unitPrice(), request.periodStart(), request.periodEnd())); LastlegOrderOperationLog operation = new LastlegOrderOperationLog(); operation.setId(IdWorker.getId()); operation.setCustomerId(order.getCustomerId()); operation.setOrderId(orderId); operation.setOperationNode("新增费用"); operation.setOperatorName(operatorName); operation.setResult("新增" + fee.getFeeItemName() + ":" + fee.getOriginalCurrency() + " " + fee.getOriginalAmount().toPlainString() + ",已提交财务审核"); operation.setCreatedAt(now); operationLogMapper.insert(operation); return fee; } @Transactional public void setPalletMergeRequired(String customerId, long orderId, LastlegOrderRequests.SetPalletMergeRequired request, String operatorName) { LastlegOrder current = findOwned(customerId, orderId); LastlegOrder order = orderMapper.lockById(current.getCustomerId(), orderId); if (order == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Tail-leg order not found"); if ("INBOUND_COMPLETED".equals(order.getCurrentStatus())) { throw new ResponseStatusException(HttpStatus.CONFLICT, "Completed inbound orders cannot change the pallet merge mark"); } if (PALLET_MERGED.equals(order.getPalletMergeFlag())) { throw new ResponseStatusException(HttpStatus.CONFLICT, "A completed pallet merge cannot be unmarked"); } String target = request.palletMergeRequired() ? PALLET_MERGE_REQUIRED : null; if (Objects.equals(target, order.getPalletMergeFlag())) return; LocalDateTime now = LocalDateTime.now(); if (orderMapper.updatePalletMergeFlag(order.getCustomerId(), orderId, target, now) != 1) { throw new ResponseStatusException(HttpStatus.CONFLICT, "Pallet merge mark was not updated"); } LastlegOrderOperationLog operation = new LastlegOrderOperationLog(); operation.setId(IdWorker.getId()); operation.setCustomerId(order.getCustomerId()); operation.setOrderId(orderId); operation.setOperationNode(request.palletMergeRequired() ? "标记合托" : "取消合托标记"); operation.setOperatorName(operatorName); operation.setResult(request.palletMergeRequired() ? "PDA扫描本单箱唛时提示需要合托" : "已取消PDA合托提示"); operation.setCreatedAt(now); operationLogMapper.insert(operation); } @Transactional public void setPalletMergeBatch(String customerId, LastlegOrderRequests.SetPalletMergeBatch request, String operatorName) { List ids = request.orderIds().stream().filter(Objects::nonNull).distinct().toList(); if (ids.isEmpty()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "At least one order is required"); List orders = orderMapper.selectList(new LambdaQueryWrapper() .eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId) .in(LastlegOrder::getId, ids).last("FOR UPDATE")); if (orders.size() != ids.size()) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more tail-leg orders were not found"); } if (orders.stream().map(LastlegOrder::getCustomerId).distinct().count() != 1) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Pallet merge orders must belong to one customer"); } if (orders.stream().map(order -> StringUtils.hasText(order.getTransferWarehouseCode()) ? order.getTransferWarehouseCode() : order.getFbaWarehouseCode()).filter(Objects::nonNull).distinct().count() > 1) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Pallet merge orders must have one destination warehouse"); } if (orders.stream().anyMatch(order -> "INBOUND_COMPLETED".equals(order.getCurrentStatus()) || PALLET_MERGED.equals(order.getPalletMergeFlag()))) { throw new ResponseStatusException(HttpStatus.CONFLICT, "Completed pallet merge orders cannot be changed"); } String batchId = request.palletMergeRequired() ? "PMG-" + java.util.UUID.randomUUID().toString().replace("-", "") : null; String flag = request.palletMergeRequired() ? PALLET_MERGE_REQUIRED : null; LocalDateTime now = LocalDateTime.now(); for (LastlegOrder order : orders) { if (orderMapper.updatePalletMergeBatch(order.getCustomerId(), order.getId(), flag, batchId, now) != 1) { throw new ResponseStatusException(HttpStatus.CONFLICT, "Pallet merge group was not updated"); } LastlegOrderOperationLog operation = new LastlegOrderOperationLog(); operation.setId(IdWorker.getId()); operation.setCustomerId(order.getCustomerId()); operation.setOrderId(order.getId()); operation.setOperationNode(request.palletMergeRequired() ? "批量标记合托" : "取消合托标记"); operation.setOperatorName(operatorName); operation.setResult(request.palletMergeRequired() ? "PDA合托分组 " + batchId : "已取消PDA合托分组"); operation.setCreatedAt(now); operationLogMapper.insert(operation); } } @Transactional public int transferDestination(String customerId, LastlegOrderRequests.TransferDestination request) { List ids = request.orderIds().stream().filter(Objects::nonNull).distinct().toList(); if (ids.isEmpty()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "At least one order is required"); } FbaWarehouseValidator.VerifiedFbaWarehouse target = fbaWarehouseValidator.required( request.warehouseId(), request.warehouseCode(), request.warehouseName()); String warehouseId = Long.toString(target.id()); List orders = orderMapper.selectList(new LambdaQueryWrapper() .eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId) .in(LastlegOrder::getId, ids).last("FOR UPDATE")); if (orders.size() != ids.size()) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more tail-leg orders were not found"); } for (LastlegOrder order : orders) { ensureTransferable(order); } LocalDateTime now = LocalDateTime.now(); for (LastlegOrder order : orders) { String fromStatus = order.getCurrentStatus(); order.setTransferWarehouseId(warehouseId); order.setTransferWarehouseCode(target.code()); order.setTransferWarehouseName(target.name()); order.setCurrentStatus(DESTINATION_ASSIGNED); order.setUpdatedAt(now); orderMapper.updateById(order); LastlegOrderDetail detailUpdate = new LastlegOrderDetail(); detailUpdate.setDestinationWarehouseId(warehouseId); detailUpdate.setDestinationWarehouseCode(target.code()); detailUpdate.setDestinationWarehouseName(target.name()); detailMapper.update(detailUpdate, new LambdaQueryWrapper() .eq(LastlegOrderDetail::getCustomerId, order.getCustomerId()) .eq(LastlegOrderDetail::getOrderId, order.getId())); InboundPlanCarton cartonUpdate = new InboundPlanCarton(); cartonUpdate.setDestinationWarehouseId(warehouseId); cartonUpdate.setDestinationWarehouseCode(target.code()); cartonUpdate.setDestinationWarehouseName(target.name()); cartonMapper.update(cartonUpdate, new LambdaQueryWrapper() .eq(InboundPlanCarton::getCustomerId, order.getCustomerId()) .eq(InboundPlanCarton::getOrderId, order.getId())); LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setCustomerId(order.getCustomerId()); history.setOrderId(order.getId()); history.setFromStatus(fromStatus); history.setToStatus(DESTINATION_ASSIGNED); history.setRemark(StringUtils.hasText(request.remark()) ? request.remark() : "Destination warehouse assigned: " + target.code()); history.setCreatedAt(now); historyMapper.insert(history); } return orders.size(); } /** 与主列表复用同一筛选,汇总只纳入仓储转运且不使用分页参数。 */ public com.baiyue.lastleg.transfer.dto.TransferQuantityTotals transferQuantityTotals( String customerId, LastlegOrderRequests.Query query, String relabelNo) { return transferQuantities.totals(filter(customerId, query, relabelNo).eq(LastlegOrder::getOrderType,"WAREHOUSE_TRANSFER")); } private LambdaQueryWrapper filter(String customerId, LastlegOrderRequests.Query query) { return filter(customerId, query, null); } private LambdaQueryWrapper filter(String customerId, LastlegOrderRequests.Query query, String relabelNo) { return com.baiyue.lastleg.query.LastlegOrderFilter.create(customerId, query, relabelNo); } private LastlegOrder findOwned(String customerId, long orderId) { LastlegOrder order = orderMapper.selectOne(new LambdaQueryWrapper() .eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId) .eq(LastlegOrder::getId, orderId)); if (order == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Tail-leg order not found"); return order; } private void ensureTransferable(LastlegOrder order) { if (StringUtils.hasText(order.getInboundOrderNo()) || INBOUND_PLANNED.equals(order.getCurrentStatus())) { throw new ResponseStatusException(HttpStatus.CONFLICT, "Destination warehouse cannot be changed after inbound plan creation: " + order.getId()); } } private Map> palletMergeRelations(List orders) { if (orders.isEmpty()) return Map.of(); Map> result = new LinkedHashMap<>(); List rows = orderMapper.findPalletMergeRelations( orders.stream().map(LastlegOrder::getId).toList()); if (rows == null) return Map.of(); for (LastlegOrderMapper.PalletMergeRelationRow row : rows) { result.computeIfAbsent(row.orderId(), ignored -> new ArrayList<>()).add(row); } return result; } private Map dispatchStatuses(List orders) { if (orders.isEmpty()) return Map.of(); List rows = orderMapper.findDispatchStatuses( orders.stream().map(LastlegOrder::getId).toList()); if (rows == null) return Map.of(); return rows.stream().collect(Collectors.toMap(LastlegOrderMapper.DispatchStatusRow::orderId, LastlegOrderMapper.DispatchStatusRow::dispatchStatus)); } private void refreshReturnVolumes(List orders) { List ids = orders.stream().filter(order -> "RETURN_RELABEL".equals(order.getOrderType())).map(LastlegOrder::getId).toList(); if (ids.isEmpty()) return; Map volumes = orderMapper.findReturnVolumes(ids).stream() .collect(Collectors.toMap(LastlegOrderMapper.ReturnVolumeRow::orderId, LastlegOrderMapper.ReturnVolumeRow::volumeCbm)); for (LastlegOrder order : orders) if (volumes.containsKey(order.getId())) order.setVolumeCbm(volumes.get(order.getId())); } private Map orderWeights(List orders) { if (orders.isEmpty()) return Map.of(); List rows = orderMapper.findOrderWeights( orders.stream().map(LastlegOrder::getId).toList()); if (rows == null) return Map.of(); return rows.stream().collect(Collectors.toMap(LastlegOrderMapper.OrderWeightRow::orderId, row -> row)); } private Map> relabelSubOrders(List orders) { if (orders.isEmpty()) return Map.of(); var rows = orderMapper.findRelabelSubOrders(orders.stream().map(LastlegOrder::getId).toList()); return rows == null ? Map.of() : rows.stream().collect(Collectors.groupingBy(com.baiyue.lastleg.dto.RelabelSubOrder::lastlegOrderId)); } private Map businessSources(List orders) { if (orders.isEmpty()) return Map.of(); return orderMapper.findBusinessSources(orders.stream().map(LastlegOrder::getId).toList()).stream() .collect(Collectors.toMap(LastlegOrderMapper.BusinessSourceRow::orderId, row -> { String label = "US_TRUCK".equals(row.businessType()) ? "本土卡车" : LastlegOrderType.labelOf(row.businessType()); if (row.poolSource()) label += " · 拼车需求池"; else if ("LOCAL_LOOSEBOARD".equals(row.businessType())) label += " · 独立下单"; else if (StringUtils.hasText(row.externalBookingNo())) label += " · 外接来货"; return new LastlegOrderSummary.BusinessSource(row.businessType(), label, row.businessOrderNo(), row.externalBookingNo()); })); } private LastlegOrderSummary summary(LastlegOrder o, List relations, String containerNode, String dispatchStatus, LastlegOrderMapper.OrderWeightRow weight, List relabelApplications, LastlegOrderSummary.BusinessSource businessSource, com.baiyue.lastleg.transfer.dto.TransferQuantityView transferQuantity) { List related = relations == null ? List.of() : relations; return new LastlegOrderSummary(o.getId(), displayOrderNo(o), o.getOrderType(), LastlegOrderType.labelOf(o.getOrderType()), o.getDestinationType(), o.getSourceInboundId(), displayInboundCode(o), o.getLxCode(), o.getSourceOrderNo(), o.getCustomerId(), o.getCustomerName(), o.getSourceContainerBookingId(), o.getOverseasWarehouseId(), o.getOverseasWarehouseCode(), o.getOverseasWarehouseName(), o.getTransferWarehouseId(), o.getTransferWarehouseCode(), o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), containerNode, o.getAbnormal(), o.getDetained(), o.getHoldChargeable(), o.getHoldReason(), Boolean.TRUE.equals(o.getManualNodeOverride()), Boolean.TRUE.equals(o.getManualNodeOverride()) ? "MANUAL" : "CONTAINER", o.getVersion() == null ? 0 : o.getVersion(), o.getPalletCount(), o.getPackageCount(), o.getReceivedPackageCount(), packageDifference(o), weight == null ? BigDecimal.ZERO : weight.grossWeightKg(), weight == null ? BigDecimal.ZERO : weight.netWeightKg(), o.getVolumeCbm(), o.getProductQuantity(), o.getVolumeWeightKg(), o.getEstimatedArrivalAt(), o.getActualArrivalAt(), o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), dispatchStatus, o.getRelabelOrderNo(), relabelBusinessStatus(o.getRelabelStatus()), o.getPalletMergeFlag(), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::mergeGroupNo).distinct().findFirst().orElse(null), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::relatedInboundNo).filter(Objects::nonNull).distinct().toList(), o.getCurrentStatus(), o.getSubOrderStatus(), o.getPrivateRemark(), o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt(), o.getUpdatedAt(), relabelApplications, businessSource, transferQuantity); } private static Integer packageDifference(LastlegOrder order) { if (order.getReceivedPackageCount() == null) return null; return order.getReceivedPackageCount() - java.util.Objects.requireNonNullElse(order.getPackageCount(), 0); } static String displayOrderNo(LastlegOrder order) { return ("WAREHOUSE_TRANSFER".equals(order.getOrderType()) || "RETURN_RELABEL".equals(order.getOrderType())) && StringUtils.hasText(order.getSourceOrderNo()) ? order.getSourceOrderNo() : order.getOrderNo(); } private String containerNode(LastlegOrder order) { if (!StringUtils.hasText(order.getSourceContainerBookingId())) return null; ContainerShippingGate gate = containerShippingGates.selectById(order.getSourceContainerBookingId()); return gate == null ? null : gate.getStandardNode(); } private Map containerNodes(List orders) { List bookingIds = orders.stream().map(LastlegOrder::getSourceContainerBookingId) .filter(StringUtils::hasText).distinct().toList(); if (bookingIds.isEmpty()) return Map.of(); Map result = new LinkedHashMap<>(); containerShippingGates.selectBatchIds(bookingIds).forEach(gate -> { if (StringUtils.hasText(gate.getStandardNode())) { result.put(gate.getContainerBookingId(), gate.getStandardNode()); } }); return result; } private static String displayInboundCode(LastlegOrder order) { String orderType = order.getOrderType(); return "RELABEL_APPLICATION".equals(orderType) || "LOCAL_LOOSEBOARD".equals(orderType) ? null : order.getInboundCode(); } private static String relabelBusinessStatus(String workflowStatus) { if ("PROCESSING".equals(workflowStatus)) return "PROCESSING"; if (!StringUtils.hasText(workflowStatus) || "NOT_REQUIRED".equals(workflowStatus)) return workflowStatus; return Set.of("READY_FOR_OUTBOUND", "COMPLETED").contains(workflowStatus) ? "COMPLETED" : "PENDING_PROCESSING"; } private static Long parseLong(String value) { try { return StringUtils.hasText(value) ? Long.parseLong(value.trim()) : null; } catch (NumberFormatException ignored) { return null; } } private static String childKey(String key, String suffix) { String prefix = key == null ? "" : key.trim(); int maxPrefix = Math.max(0, 128 - suffix.length() - 1); return prefix.substring(0, Math.min(prefix.length(), maxPrefix)) + ':' + suffix; } }