feat(fleet): enforce reviewed trips and original fee coverage for dispatch
This commit is contained in:
+9
-1
@@ -23,6 +23,14 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
public class DeliveryTransferController {
|
||||
@Resource private DeliveryTransferService service;
|
||||
|
||||
@GetMapping("/dispatches/{dispatchNo}/external-freight")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')")
|
||||
public ApiResponse<com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Preview> externalFreight(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo) {
|
||||
requirePlatform(user);
|
||||
return ApiResponse.success(service.externalFreight(dispatchNo));
|
||||
}
|
||||
|
||||
@PostMapping("/dispatches/{dispatchNo}/transfer-delivery")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_TRANSFER_DELIVERY + "')")
|
||||
@Operation(summary = "Create one pending outbound order and one delivery order per destination FBA warehouse")
|
||||
@@ -116,7 +124,7 @@ public class DeliveryTransferController {
|
||||
@PathVariable String outboundNo,
|
||||
@Valid @RequestBody DeliveryRequests.Cancel request) {
|
||||
requirePlatform(user);
|
||||
service.cancelOutbound(outboundNo, request);
|
||||
service.cancelOutbound(outboundNo, operator(user), request);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.baiyue.lastleg.delivery.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels;
|
||||
import com.baiyue.lastleg.delivery.service.DeliveryFeeCoverageService;
|
||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/platform/v1/last-leg/dispatches")
|
||||
public class PlatformDeliveryFeeCoverageController {
|
||||
private final DeliveryFeeCoverageService service;
|
||||
@GetMapping("/{dispatchNo}/fee-coverage")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')")
|
||||
public ApiResponse<DeliveryFeeCoverageModels.Preview> preview(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String dispatchNo) {
|
||||
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) throw new ResponseStatusException(HttpStatus.FORBIDDEN);
|
||||
return ApiResponse.success(service.preview(dispatchNo));
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.baiyue.lastleg.delivery.dto;
|
||||
|
||||
import com.baiyue.lastleg.client.FinanceFeeCoverageClient;
|
||||
import java.util.List;
|
||||
import java.time.Instant;
|
||||
import jakarta.validation.constraints.*;
|
||||
|
||||
public final class DeliveryFeeCoverageModels {
|
||||
private DeliveryFeeCoverageModels() { }
|
||||
public record Cargo(Long dispatchItemId, Long warehousePalletId, String palletNo, Long orderId,
|
||||
String sourceRegion, String sourceInboundId, String inboundCode, long packages, String dispatchInboundCode) { }
|
||||
public record Preview(String dispatchNo, boolean sourceComplete, String blockReason, String cargoHash,
|
||||
List<Cargo> cargo, List<FinanceFeeCoverageClient.Fee> feeSources) { }
|
||||
public record Selection(@NotNull @Positive Long financeFeeId, @Min(1) int sourceVersion,
|
||||
@NotBlank @Size(max=128) String sourceHash, @NotEmpty @Size(max=3) List<@NotBlank @Pattern(regexp="DELIVERY_FEE|LOADING_FEE|APPOINTMENT_FEE") String> services) { }
|
||||
public record Reference(FinanceFeeCoverageClient.Fee source, List<String> services) { }
|
||||
public record Evidence(String cargoHash, List<Cargo> cargo, List<Reference> references, String operatorId, Instant confirmedAt) { }
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import jakarta.validation.Valid;
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Selection;
|
||||
import java.util.List;
|
||||
import java.util.Collections;
|
||||
import java.math.BigDecimal;
|
||||
@@ -30,7 +31,10 @@ public final class DeliveryRequests {
|
||||
@Size(max = 128) String appointmentAccount,
|
||||
@Size(max = 128) String sellerName,
|
||||
@Size(max = 1000) String remark,
|
||||
@NotNull @Valid @Size(min = 1, max = 20) List<FeeItem> fees) {}
|
||||
@NotNull @Valid @Size(max = 20) List<FeeItem> fees,
|
||||
@Size(max = 64) String externalFreightHash,
|
||||
@Size(max = 64) String feeCoverageCargoHash,
|
||||
@Size(max = 2000) List<@NotNull @Valid Selection> feeCoverage) {}
|
||||
|
||||
public record FeeItem(@NotBlank @Size(max = 64) String feeType,
|
||||
@NotBlank @Size(max = 128) String feeName,
|
||||
|
||||
+3
-2
@@ -42,13 +42,14 @@ public final class DeliveryResponses {
|
||||
BigDecimal weightKg, BigDecimal volumeCbm) {}
|
||||
|
||||
public record DeliveryFeeRow(Long id, String feeDirection, String feeType, String currency,
|
||||
BigDecimal amount, String remark) {}
|
||||
BigDecimal amount, String remark, String reviewStatus, String sourceStatus, String voidStatus) {}
|
||||
|
||||
public record DeliveryOperationRow(Long id, LocalDateTime operatedAt, String operationNode,
|
||||
String operatorName, String result) {}
|
||||
|
||||
public record DeliveryDetail(DeliveryRow order, List<DeliverySku> skus,
|
||||
List<DeliveryFeeRow> fees, List<DeliveryOperationRow> operations) {}
|
||||
List<DeliveryFeeRow> fees, List<DeliveryOperationRow> operations,
|
||||
DeliveryFeeCoverageModels.Evidence feeCoverage) {}
|
||||
|
||||
/** palletNo is the warehouse-authoritative physical pallet code after inbound assignment. */
|
||||
public record OutboundPalletRow(Long id, String palletNo, String cartonMarks, String inboundCode,
|
||||
|
||||
@@ -30,6 +30,7 @@ public class OutboundOrder {
|
||||
private BigDecimal volumeCbm;
|
||||
private String idempotencyKey;
|
||||
private String requestHash;
|
||||
private String feeCoverageJson;
|
||||
private String createdBy;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
+2
@@ -10,6 +10,8 @@ import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface DeliveryOperationMapper extends BaseMapper<DeliveryOperation> {
|
||||
@Select("SELECT MAX(created_at) FROM ll_delivery_operation WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryOrderId} AND operation_node='RETURN_COMPLETED'")
|
||||
java.time.LocalDateTime returnedAt(@Param("customerId") String customerId, @Param("deliveryOrderId") Long deliveryOrderId);
|
||||
@Select("SELECT * FROM ll_delivery_operation WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryOrderId} ORDER BY created_at,id")
|
||||
List<DeliveryOperation> findByDelivery(@Param("customerId") String customerId,
|
||||
@Param("deliveryOrderId") Long deliveryOrderId);
|
||||
|
||||
+2
@@ -9,6 +9,8 @@ import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface DeliveryOrderMapper extends BaseMapper<DeliveryOrder> {
|
||||
@Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND outbound_order_id=#{outboundOrderId} ORDER BY id")
|
||||
List<DeliveryOrder> findByOutbound(@Param("customerId") String customerId, @Param("outboundOrderId") Long outboundOrderId);
|
||||
@Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND delivery_no=#{deliveryNo} LIMIT 1")
|
||||
DeliveryOrder findByNo(@Param("customerId") String customerId, @Param("deliveryNo") String deliveryNo);
|
||||
@Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND delivery_no=#{deliveryNo} LIMIT 1 FOR UPDATE")
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.baiyue.lastleg.delivery.mapper;
|
||||
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
import java.util.List;
|
||||
|
||||
/** Reconstruct every selected inbound source, including shared pallets and already picked quantities. */
|
||||
@Mapper
|
||||
public interface DispatchFeeCargoMapper {
|
||||
@Select("""
|
||||
SELECT di.id AS dispatchItemId,di.warehouse_pallet_id AS warehousePalletId,di.pallet_no AS palletNo,
|
||||
b.inbound_order_id AS orderId,o.source_region AS sourceRegion,o.source_inbound_id AS sourceInboundId,
|
||||
b.inbound_code AS inboundCode,SUM(p.available_packages+COALESCE(picked.packages,0)) AS packages,di.inbound_code AS dispatchInboundCode
|
||||
FROM ll_dispatch_item di
|
||||
JOIN ll_inventory_position p ON p.customer_id=di.customer_id AND p.pallet_id=di.warehouse_pallet_id
|
||||
AND p.overseas_warehouse_id=di.overseas_warehouse_id
|
||||
JOIN ll_inventory_balance b ON b.id=p.balance_id AND b.customer_id=di.customer_id
|
||||
LEFT JOIN ll_order o ON o.id=b.inbound_order_id AND o.customer_id=di.customer_id
|
||||
LEFT JOIN (SELECT pk.dispatch_item_id,pk.inventory_position_id,SUM(pk.packages) AS packages
|
||||
FROM ll_dispatch_pda_pick pk JOIN ll_dispatch_pda_task task ON task.id=pk.task_id AND task.customer_id=pk.customer_id
|
||||
WHERE task.dispatch_order_id=#{dispatchId} AND pk.customer_id=#{customerId}
|
||||
GROUP BY pk.dispatch_item_id,pk.inventory_position_id) picked
|
||||
ON picked.dispatch_item_id=di.id AND picked.inventory_position_id=p.id
|
||||
WHERE di.dispatch_order_id=#{dispatchId} AND di.customer_id=#{customerId}
|
||||
AND (di.plan_pallet_id IS NULL OR EXISTS (
|
||||
SELECT 1 FROM ll_inbound_plan_pallet_allocation a
|
||||
JOIN ll_inbound_plan_line line ON line.id=a.line_id AND line.customer_id=di.customer_id
|
||||
WHERE a.pallet_id=di.plan_pallet_id AND line.order_id=b.inbound_order_id))
|
||||
GROUP BY di.id,di.warehouse_pallet_id,di.pallet_no,b.inbound_order_id,o.source_region,o.source_inbound_id,b.inbound_code,di.inbound_code
|
||||
HAVING SUM(p.available_packages+COALESCE(picked.packages,0))>0
|
||||
ORDER BY di.id,b.inbound_order_id
|
||||
""")
|
||||
List<DeliveryFeeCoverageModels.Cargo> list(@Param("customerId") String customerId, @Param("dispatchId") long dispatchId);
|
||||
}
|
||||
+9
-5
@@ -27,6 +27,7 @@ import java.util.*;
|
||||
@Service
|
||||
public class DeliveryDocumentService {
|
||||
@Resource private DeliveryOrderMapper orders;
|
||||
@Resource private OutboundOrderMapper outboundOrders;
|
||||
@Resource private DeliveryDocumentRevisionMapper revisions;
|
||||
@Resource private DeliveryOperationMapper operations;
|
||||
@Resource private DeliveryDocumentFileGateway files;
|
||||
@@ -84,16 +85,19 @@ public class DeliveryDocumentService {
|
||||
return new Category(category, values.isEmpty() ? "MISSING" : "UPLOADED", values.size(), values);
|
||||
}).toList();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
boolean receiptSaved = !bindings.getOrDefault(DeliveryDocumentCategory.RECEIPT, List.of()).isEmpty();
|
||||
boolean autoCompleted = receiptSaved && !"DELIVERED".equals(order.getStatus());
|
||||
if (autoCompleted) {
|
||||
if (!DeliveryStatusPolicy.canTransition(order.getStatus(), "DELIVERED")) throw conflict("当前派送状态不允许通过签收文件完成");
|
||||
// A read avoids reversing outbound -> delivery lock order. An uncommitted departure remains ineligible.
|
||||
var outbound = outboundOrders.findByNo(customerId, order.getOutboundNo());
|
||||
if (outbound == null || !"OUTBOUNDED".equals(outbound.getStatus())) throw conflict("请先完成实际出库,再保存签收资料完成派送");
|
||||
}
|
||||
order.setBolNo(request.bolNo() == null ? "" : request.bolNo().trim());
|
||||
order.setDocumentsJson(write(categories));
|
||||
order.setVersion(version(order) + 1);
|
||||
order.setUpdatedAt(now);
|
||||
boolean receiptSaved = !bindings.getOrDefault(DeliveryDocumentCategory.RECEIPT, List.of()).isEmpty();
|
||||
boolean autoCompleted = receiptSaved && !"DELIVERED".equals(order.getStatus());
|
||||
if (autoCompleted) {
|
||||
if (!DeliveryStatusPolicy.canTransition(order.getStatus(), "DELIVERED")) {
|
||||
throw conflict("当前派送状态不允许通过签收文件完成");
|
||||
}
|
||||
order.setStatus("DELIVERED");
|
||||
order.setSignedAt(now);
|
||||
}
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
package com.baiyue.lastleg.delivery.model;
|
||||
package com.baiyue.lastleg.delivery.service;
|
||||
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*;
|
||||
import com.baiyue.lastleg.delivery.model.DeliveryDocumentCategory;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
package com.baiyue.lastleg.delivery.service;
|
||||
|
||||
import com.baiyue.lastleg.client.FinanceFeeCoverageClient;
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels;
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
|
||||
import java.time.Instant;
|
||||
import com.baiyue.lastleg.delivery.mapper.DispatchFeeCargoMapper;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class DeliveryFeeCoverageService {
|
||||
private final DispatchOrderMapper dispatches;
|
||||
private final DispatchItemMapper items;
|
||||
private final DispatchFeeCargoMapper sources;
|
||||
private final FinanceFeeCoverageClient finance;
|
||||
private final ObjectMapper json;
|
||||
private final String token;
|
||||
public DeliveryFeeCoverageService(DispatchOrderMapper dispatches, DispatchItemMapper items, DispatchFeeCargoMapper sources,
|
||||
FinanceFeeCoverageClient finance, ObjectMapper json, @Value("${baiyue.security.internal-service-token}") String token) {
|
||||
this.dispatches=dispatches; this.items=items; this.sources=sources; this.finance=finance; this.json=json; this.token=token;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
|
||||
public DeliveryFeeCoverageModels.Preview preview(String dispatchNo) {
|
||||
var dispatch = dispatches.findGlobalByDispatchNo(dispatchNo.trim());
|
||||
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "排车单不存在");
|
||||
var selected = items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId());
|
||||
var cargo = sources.list(dispatch.getCustomerId(), dispatch.getId());
|
||||
String reason = incomplete(selected, cargo);
|
||||
var inboundIds = new TreeSet<Long>();
|
||||
if (reason == null) for (var row : cargo) if ("FIRSTLEG".equals(row.sourceRegion())) {
|
||||
try { long id = Long.parseLong(row.sourceInboundId()); if (id < 1) throw new NumberFormatException(); inboundIds.add(id); }
|
||||
catch (RuntimeException invalid) { reason = "货物缺少原头程入仓单关联,请先核对来源"; break; }
|
||||
}
|
||||
List<FinanceFeeCoverageClient.Fee> fees = List.of();
|
||||
if (reason == null && !inboundIds.isEmpty()) fees = load(dispatch.getCustomerId(), List.copyOf(inboundIds));
|
||||
try {
|
||||
String hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(
|
||||
json.writeValueAsString(List.of(dispatch.getDispatchNo(), dispatch.getCustomerId(), cargo,
|
||||
selected.stream().map(item -> Arrays.asList(item.getId(), item.getPackageCount(), item.getOverseasWarehouseId(),
|
||||
item.getDestinationType(), item.getDestinationWarehouseCode(), item.getFbaWarehouseCode(),
|
||||
item.getDeliveryAddress(), item.getPostalCode())).toList())).getBytes(StandardCharsets.UTF_8)));
|
||||
return new DeliveryFeeCoverageModels.Preview(dispatch.getDispatchNo(), reason == null, reason, hash, cargo, fees);
|
||||
} catch (java.security.NoSuchAlgorithmException | com.fasterxml.jackson.core.JsonProcessingException error) { throw new IllegalStateException(error); }
|
||||
}
|
||||
private List<FinanceFeeCoverageClient.Fee> load(String customerId, List<Long> inboundIds) {
|
||||
try {
|
||||
var result = finance.sources(token, new FinanceFeeCoverageClient.Query(Long.valueOf(customerId), inboundIds));
|
||||
if (result == null || !result.successful() || result.data() == null) throw unavailable();
|
||||
if (result.data().stream().anyMatch(fee -> fee.financeFeeId() == null || fee.financeFeeId() < 1 || fee.sourceFeeId() == null
|
||||
|| !Objects.equals(fee.customerId(), Long.valueOf(customerId)) || fee.sourceVersion() < 1
|
||||
|| !inboundIds.contains(fee.sourceInboundId()) || fee.sourceHash() == null || fee.sourceHash().isBlank()
|
||||
|| fee.amount() == null || fee.amount().signum() < 0 || !"CNY".equals(fee.currency())
|
||||
|| !Set.of("INBOUND_FEE", "CONTAINER_FEE").contains(fee.sourceType())
|
||||
|| !Set.of("AVAILABLE", "SETTLED").contains(fee.status()))) throw unavailable();
|
||||
return result.data();
|
||||
} catch (RuntimeException error) { throw unavailable(); }
|
||||
}
|
||||
private static String incomplete(List<DispatchItem> selected, List<DeliveryFeeCoverageModels.Cargo> cargo) {
|
||||
if (selected.isEmpty()) return "排车单没有待发运货物";
|
||||
var totals = cargo.stream().collect(Collectors.groupingBy(DeliveryFeeCoverageModels.Cargo::dispatchItemId,
|
||||
Collectors.summingLong(DeliveryFeeCoverageModels.Cargo::packages)));
|
||||
var ids = selected.stream().map(DispatchItem::getId).collect(Collectors.toSet());
|
||||
if (!ids.equals(totals.keySet())) return "实际货物来源尚未齐全,请先完成收货并核对实物托盘";
|
||||
for (var item : selected) if (item.getPackageCount() == null || totals.get(item.getId()).longValue() != item.getPackageCount())
|
||||
return "实物库存及本次已拣数量与排车箱数不一致,请先核对货物范围";
|
||||
return null;
|
||||
}
|
||||
public record Decision(Set<String> includedServices, DeliveryFeeCoverageModels.Evidence evidence, Set<Long> externalItems) {
|
||||
public static Decision none() { return new Decision(Set.of(), null, Set.of()); }
|
||||
public List<DeliveryFeeCoverageModels.Cargo> uncovered(String service) {
|
||||
if (evidence == null) return List.of();
|
||||
return evidence.cargo().stream().filter(row -> !covers(row, service, evidence.references(), externalItems)).toList();
|
||||
}
|
||||
}
|
||||
public Decision confirm(String dispatchNo, DeliveryRequests.Transfer request, String operator, Set<Long> externalItems) {
|
||||
if (request.feeCoverage() == null || request.feeCoverage().isEmpty()) return Decision.none();
|
||||
var preview = preview(dispatchNo);
|
||||
if (!preview.sourceComplete()) throw conflict(preview.blockReason());
|
||||
if (!Objects.equals(preview.cargoHash(), request.feeCoverageCargoHash())) throw conflict("原费用对应货物已变化,请重新核对覆盖范围");
|
||||
var references = new ArrayList<DeliveryFeeCoverageModels.Reference>();
|
||||
var scopes = new HashSet<String>();
|
||||
for (var selection : request.feeCoverage()) {
|
||||
var fee = preview.feeSources().stream().filter(row -> Objects.equals(row.financeFeeId(), selection.financeFeeId())).findFirst()
|
||||
.orElseThrow(() -> conflict("原费用不属于本次实际货物,或已审核状态/有效性发生变化"));
|
||||
if (fee.sourceVersion() != selection.sourceVersion() || !Objects.equals(fee.sourceHash(), selection.sourceHash()))
|
||||
throw conflict("原费用版本已变化,请重新确认服务覆盖范围");
|
||||
var services = new TreeSet<>(selection.services());
|
||||
if (services.isEmpty() || !Set.of("DELIVERY_FEE", "LOADING_FEE", "APPOINTMENT_FEE").containsAll(services))
|
||||
throw conflict("请选择本票原费用明确包含的服务");
|
||||
for (var service : services) if (!scopes.add(fee.sourceInboundId() + ":" + service))
|
||||
throw conflict("同一票同一服务只能引用一项原费用");
|
||||
references.add(new DeliveryFeeCoverageModels.Reference(fee, List.copyOf(services)));
|
||||
}
|
||||
var included = new HashSet<String>();
|
||||
for (String service : List.of("DELIVERY_FEE", "LOADING_FEE", "APPOINTMENT_FEE"))
|
||||
if (preview.cargo().stream().allMatch(row -> covers(row, service, references, externalItems))) included.add(service);
|
||||
return new Decision(Set.copyOf(included), new DeliveryFeeCoverageModels.Evidence(preview.cargoHash(), preview.cargo(),
|
||||
List.copyOf(references), operator, Instant.now()), Set.copyOf(externalItems));
|
||||
}
|
||||
private static boolean covers(DeliveryFeeCoverageModels.Cargo cargo, String service,
|
||||
List<DeliveryFeeCoverageModels.Reference> references, Set<Long> externalItems) {
|
||||
if ("DELIVERY_FEE".equals(service) && externalItems.contains(cargo.dispatchItemId())) return true;
|
||||
return "FIRSTLEG".equals(cargo.sourceRegion()) && references.stream().anyMatch(reference ->
|
||||
reference.source().sourceInboundId().toString().equals(cargo.sourceInboundId()) && reference.services().contains(service));
|
||||
}
|
||||
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||
private static ResponseStatusException unavailable() { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "原费用来源暂不可用,请稍后重试"); }
|
||||
}
|
||||
+183
-26
@@ -1,5 +1,9 @@
|
||||
package com.baiyue.lastleg.delivery.service;
|
||||
|
||||
import com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard;
|
||||
import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway;
|
||||
import com.baiyue.lastleg.fleet.trip.FleetBookingFreight;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
@@ -123,6 +127,25 @@ public class DeliveryTransferService {
|
||||
@Resource private RelabelApplicationService relabelService;
|
||||
@Resource private LastlegOrderNoGenerator orderNoGenerator;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Resource private FleetDispatchBindingGuard fleetBindings;
|
||||
@Resource private FleetBookingFreight bookingFreight;
|
||||
@Resource private DeliveryFeeCoverageService feeCoverage;
|
||||
|
||||
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
|
||||
public FleetBookingFreight.Preview externalFreight(String dispatchNo) {
|
||||
DispatchOrder dispatch = dispatchOrders.findGlobalByDispatchNo(trim(dispatchNo));
|
||||
if (dispatch == null) throw notFound("Dispatch order not found");
|
||||
return externalFreight(dispatch, dispatchItems.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()));
|
||||
}
|
||||
|
||||
private FleetBookingFreight.Preview externalFreight(DispatchOrder dispatch, List<DispatchItem> items) {
|
||||
return bookingFreight.preview(dispatch.getId(), dispatch.getDispatchNo(), dispatch.getCustomerId(), items.stream()
|
||||
.map(item -> new FleetDispatchGateway.SourceCargo(item.getInboundCode(),
|
||||
item.getWarehousePalletId() == null ? null : item.getWarehousePalletId().toString(), item.getPalletNo(),
|
||||
Objects.requireNonNullElse(item.getPackageCount(), 0), FleetDispatchGateway.destinationKey(item.getDestinationType(),
|
||||
item.getDestinationWarehouseCode(), item.getFbaWarehouseCode(), item.getDeliveryAddress(), item.getPostalCode())))
|
||||
.toList());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public DeliveryResponses.TransferResult platformTransfer(String dispatchNo, String operatorId,
|
||||
@@ -135,6 +158,8 @@ public class DeliveryTransferService {
|
||||
@Transactional
|
||||
public DeliveryResponses.TransferResult merchantTransfer(String customerId, String dispatchNo, String operatorId,
|
||||
DeliveryRequests.Transfer request) {
|
||||
if (request.feeCoverage() != null && !request.feeCoverage().isEmpty())
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "原运费服务覆盖由平台确认");
|
||||
DispatchOrder found = dispatchOrders.findByDispatchNo(customerId, dispatchNo);
|
||||
if (found == null) throw notFound("Dispatch order not found");
|
||||
DispatchOrder dispatch = dispatchOrders.lockById(customerId, found.getId());
|
||||
@@ -145,16 +170,20 @@ public class DeliveryTransferService {
|
||||
private DeliveryResponses.TransferResult transferResolved(DispatchOrder dispatch, String operatorId,
|
||||
DeliveryRequests.Transfer request) {
|
||||
String customerId = dispatch.getCustomerId();
|
||||
String requestHash = transferRequestHash(request);
|
||||
OutboundOrder replay = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim());
|
||||
if (replay != null) {
|
||||
if (!dispatch.getId().equals(replay.getDispatchOrderId())) throw conflict("Idempotency key belongs to another dispatch");
|
||||
return result(replay, requireDeliveries(customerId, replay.getId()), true);
|
||||
return replayTransfer(replay, requestHash);
|
||||
}
|
||||
OutboundOrder existing = outboundOrders.lockByDispatch(customerId, dispatch.getId());
|
||||
if (existing != null) return result(existing, requireDeliveries(customerId, existing.getId()), true);
|
||||
if (existing != null) return replayTransfer(existing, requestHash);
|
||||
if (Set.of("CANCELLED", "DEPARTED", "PARTIAL_DEPARTED", "COMPLETED").contains(dispatch.getStatus())) {
|
||||
throw conflict("Dispatch cannot be transferred to delivery in its current status");
|
||||
}
|
||||
fleetBindings.requireMatchingAssignment(dispatch.getId(),
|
||||
new FleetDispatchGateway.Assignment(dispatch.getVehiclePlate(), dispatch.getDriverName(), dispatch.getDriverPhone()),
|
||||
new FleetDispatchGateway.Assignment(request.vehiclePlate(), request.driverName(), request.driverPhone()));
|
||||
dispatchItems.bindReceivedPallets(customerId, dispatch.getId());
|
||||
List<DispatchItem> items = dispatchItems.findByDispatchOrder(customerId, dispatch.getId());
|
||||
if (items.isEmpty()) throw conflict("Dispatch has no pallets");
|
||||
@@ -162,7 +191,16 @@ public class DeliveryTransferService {
|
||||
throw conflict("All dispatch pallets must be received before transfer to delivery");
|
||||
}
|
||||
if (!StringUtils.hasText(dispatch.getOverseasWarehouseId())) throw conflict("Dispatch warehouse is required");
|
||||
validateTransferFees(request);
|
||||
var freight = externalFreight(dispatch, items);
|
||||
if ((!freight.quotes().isEmpty() || StringUtils.hasText(request.externalFreightHash()))
|
||||
&& !Objects.equals(freight.snapshotHash(), request.externalFreightHash()))
|
||||
throw conflict("外接实收或约定运费已变化,请重新打开转派送核对");
|
||||
var quotedIncomingNos = freight.quotes().stream().map(FleetBookingFreight.Quote::incomingNo).collect(Collectors.toSet());
|
||||
var externalItems = items.stream().filter(item -> quotedIncomingNos.contains(item.getInboundCode())).map(DispatchItem::getId).collect(Collectors.toSet());
|
||||
var coverage = request.feeCoverage() == null || request.feeCoverage().isEmpty() ? DeliveryFeeCoverageService.Decision.none()
|
||||
: feeCoverage.confirm(dispatch.getDispatchNo(), request, operatorId, externalItems);
|
||||
validateTransferFees(request, freight.requiresDeliveryFee(), coverage.includedServices());
|
||||
if (!freight.priorInboundCodes().isEmpty()) feeSources.requireNoActiveDeliveryReceivables(customerId, freight.priorInboundCodes());
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
OutboundOrder outbound = new OutboundOrder();
|
||||
@@ -173,10 +211,15 @@ public class DeliveryTransferService {
|
||||
outbound.setLxCodes(resolveLxCodes(customerId, items));
|
||||
outbound.setPackageCount(items.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum());
|
||||
outbound.setPalletCount(items.size());
|
||||
outbound.setGrossWeightKg(sum(items, DispatchItem::getGrossWeightKg));
|
||||
outbound.setGrossWeightKg(sumKnownPhysicalWeight(items, DispatchItem::getGrossWeightKg));
|
||||
outbound.setVolumeWeightKg(sum(items, DispatchItem::getVolumeWeightKg));
|
||||
outbound.setVolumeCbm(sum(items, DispatchItem::getVolumeCbm));
|
||||
outbound.setIdempotencyKey(request.idempotencyKey().trim());
|
||||
outbound.setRequestHash(requestHash);
|
||||
if (coverage.evidence() != null) {
|
||||
try { outbound.setFeeCoverageJson(objectMapper.writeValueAsString(coverage.evidence())); }
|
||||
catch (JsonProcessingException exception) { throw new IllegalStateException("费用覆盖确认无法保存", exception); }
|
||||
}
|
||||
outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now);
|
||||
try {
|
||||
outboundOrders.insert(outbound);
|
||||
@@ -185,11 +228,11 @@ public class DeliveryTransferService {
|
||||
// the document created by the winning transaction rather than receive a false failure.
|
||||
OutboundOrder concurrent = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim());
|
||||
if (concurrent != null && dispatch.getId().equals(concurrent.getDispatchOrderId())) {
|
||||
return result(concurrent, requireDeliveries(customerId, concurrent.getId()), true);
|
||||
return replayTransfer(concurrent, requestHash);
|
||||
}
|
||||
concurrent = outboundOrders.lockByDispatch(customerId, dispatch.getId());
|
||||
if (concurrent != null) {
|
||||
return result(concurrent, requireDeliveries(customerId, concurrent.getId()), true);
|
||||
return replayTransfer(concurrent, requestHash);
|
||||
}
|
||||
throw conflict("Dispatch was concurrently transferred to delivery");
|
||||
}
|
||||
@@ -250,25 +293,57 @@ public class DeliveryTransferService {
|
||||
delivery.setPackageCount(groupItems.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum());
|
||||
delivery.setPalletCount(groupItems.size());
|
||||
delivery.setTotalVolumeCbm(sum(groupItems, DispatchItem::getVolumeCbm));
|
||||
delivery.setTotalWeightKg(sum(groupItems, DispatchItem::getGrossWeightKg)); delivery.setExpectedArrivalAt(dispatch.getEstimatedArrivalAt()); delivery.setRemark(trim(request.remark()));
|
||||
delivery.setTotalWeightKg(sumKnownPhysicalWeight(groupItems, DispatchItem::getGrossWeightKg)); delivery.setExpectedArrivalAt(dispatch.getEstimatedArrivalAt()); delivery.setRemark(trim(request.remark()));
|
||||
delivery.setIdempotencyKey(childKey(request.idempotencyKey(), "DEST-" + groupIndex++)); delivery.setCreatedBy(operatorId);
|
||||
delivery.setCreatedAt(now); delivery.setUpdatedAt(now); deliveryOrders.insert(delivery);
|
||||
recordOperation(customerId, delivery.getId(), operatorId, "CREATE_DELIVERY", "按目的FBA仓生成派送单", now);
|
||||
bindTailOrders(customerId, groupItems, outbound.getOutboundNo(), delivery.getDeliveryNo(), request.remark(), now);
|
||||
deliveries.add(delivery);
|
||||
}
|
||||
for (var quote : freight.quotes()) {
|
||||
var source = items.stream().filter(item -> quote.incomingNo().equals(item.getInboundCode())).findFirst().orElseThrow();
|
||||
var destination = deliveries.stream().filter(item -> deliveryGroupKey(source).equals(item.getDeliveryGroupKey())).findFirst().orElseThrow();
|
||||
recordOperation(customerId, destination.getId(), operatorId, "CONFIRM_EXTERNAL_FREIGHT",
|
||||
"外接预订 " + quote.bookingNo() + ";实收确认版本 " + quote.recordVersion() + ";约定运费 "
|
||||
+ quote.currency() + " " + quote.amount().toPlainString(), now);
|
||||
// An explicitly agreed free shipment remains a commercial fact, not a fabricated zero fee.
|
||||
if (quote.amount().signum() == 0) continue;
|
||||
feeSources.publishApproved(operatorId, "FLEET:" + quote.bookingNo() + ":" + dispatch.getDispatchNo() + ":FREIGHT",
|
||||
new OverseasFeeSourceDtos.SaveRequest(customerId, quote.customerName(),
|
||||
"OVERSEAS_DELIVERY_FEE", "DELIVERY_ORDER", destination.getId(), destination.getDeliveryNo(),
|
||||
null, null, quote.inboundCode(), null, null, null, parseLong(destination.getOverseasWarehouseId()),
|
||||
"DELIVERY_FEE", "外接运费(" + quote.bookingNo() + ")", "RECEIVABLE", null, null, null,
|
||||
null, null, null, null, null, LocalDate.now(), quote.currency(), quote.amount(), quote.quantity(),
|
||||
quote.quantityUnit(), quote.unitPrice(), null, null));
|
||||
}
|
||||
DeliveryOrder feeDelivery = deliveries.get(0);
|
||||
LastlegOrder firstOrder = firstTailOrder(customerId, items);
|
||||
List<DeliveryRequests.FeeItem> feeInputs = request.fees() == null ? List.of() : request.fees();
|
||||
var quotedIncoming = freight.quotes().stream().map(FleetBookingFreight.Quote::incomingNo).collect(Collectors.toSet());
|
||||
for (DeliveryRequests.FeeItem input : feeInputs) {
|
||||
feeSources.publishApproved(operatorId, childKey(request.idempotencyKey(), input.feeType()),
|
||||
new OverseasFeeSourceDtos.SaveRequest(customerId, feeDelivery.getCustomerName(),
|
||||
"OVERSEAS_DELIVERY_FEE", "DELIVERY_ORDER", feeDelivery.getId(), feeDelivery.getDeliveryNo(),
|
||||
String feeCode = input.feeType().trim().toUpperCase(Locale.ROOT);
|
||||
var uncovered = coverage.uncovered(feeCode);
|
||||
var uncoveredItemIds = uncovered.stream().map(com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Cargo::dispatchItemId).collect(Collectors.toSet());
|
||||
var chargeItems = coverage.evidence() != null && "RECEIVABLE".equals(input.direction())
|
||||
? items.stream().filter(item -> uncoveredItemIds.contains(item.getId())).toList()
|
||||
: "RECEIVABLE".equals(input.direction()) && "DELIVERY_FEE".equals(feeCode) && freight.requiresDeliveryFee()
|
||||
? items.stream().filter(item -> !quotedIncoming.contains(item.getInboundCode())).toList() : items;
|
||||
var feeDestination = "DELIVERY_FEE".equalsIgnoreCase(input.feeType()) && !chargeItems.isEmpty()
|
||||
? deliveries.stream().filter(item -> deliveryGroupKey(chargeItems.get(0)).equals(item.getDeliveryGroupKey())).findFirst().orElseThrow()
|
||||
: feeDelivery;
|
||||
LastlegOrder firstOrder = firstTailOrder(customerId, chargeItems);
|
||||
if (coverage.evidence() != null && "RECEIVABLE".equals(input.direction())) {
|
||||
var uncoveredOrderIds = uncovered.stream().map(com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Cargo::orderId).filter(Objects::nonNull).distinct().toList();
|
||||
firstOrder = uncoveredOrderIds.size() == 1 ? tailOrders.selectById(uncoveredOrderIds.get(0)) : null;
|
||||
if (firstOrder != null && !customerId.equals(firstOrder.getCustomerId())) throw conflict("新增费用来源客户不一致");
|
||||
}
|
||||
feeSources.publishApproved(operatorId, childKey(request.idempotencyKey(), input.direction() + ":" + feeCode),
|
||||
new OverseasFeeSourceDtos.SaveRequest(customerId, feeDestination.getCustomerName(),
|
||||
"OVERSEAS_DELIVERY_FEE", "DELIVERY_ORDER", feeDestination.getId(), feeDestination.getDeliveryNo(),
|
||||
firstOrder == null ? null : firstOrder.getId(), null,
|
||||
firstOrder == null ? null : firstOrder.getInboundCode(), null,
|
||||
firstOrder == null ? null : parseLong(firstOrder.getSourceContainerBookingId()),
|
||||
firstOrder == null ? null : firstOrder.getContainerNo(), parseLong(feeDelivery.getOverseasWarehouseId()),
|
||||
input.feeType().trim(), input.feeName().trim(), input.direction(), input.supplierId(),
|
||||
firstOrder == null ? null : firstOrder.getContainerNo(), parseLong(feeDestination.getOverseasWarehouseId()),
|
||||
feeCode, input.feeName().trim(), input.direction(), input.supplierId(),
|
||||
input.supplierName(), input.supplierCategory(), input.settlementMode(), input.paidAt(),
|
||||
input.paymentMethod(), input.paymentReferenceNo(), input.paymentVoucherFileIds(), LocalDate.now(),
|
||||
input.currency(), input.amount(), input.chargeableQuantity(), input.quantityUnit(), input.unitPrice(),
|
||||
@@ -277,27 +352,45 @@ public class DeliveryTransferService {
|
||||
return result(outbound, deliveries, false);
|
||||
}
|
||||
|
||||
private void validateTransferFees(DeliveryRequests.Transfer request) {
|
||||
private DeliveryResponses.TransferResult replayTransfer(OutboundOrder outbound, String requestHash) {
|
||||
if (!requestHash.equals(outbound.getRequestHash()))
|
||||
throw conflict("转派送请求与原已保存内容不一致,请查看原单据;费用调整须通过正式费用更正流程");
|
||||
return result(outbound, requireDeliveries(outbound.getCustomerId(), outbound.getId()), true);
|
||||
}
|
||||
|
||||
String transferRequestHash(DeliveryRequests.Transfer request) {
|
||||
// A new command key cannot change an already transferred dispatch's business content.
|
||||
com.fasterxml.jackson.databind.node.ObjectNode content = objectMapper.valueToTree(request);
|
||||
content.remove("idempotencyKey");
|
||||
if (request.feeCoverage() == null || request.feeCoverage().isEmpty()) {
|
||||
content.remove("feeCoverage"); content.remove("feeCoverageCargoHash");
|
||||
}
|
||||
return hash(content);
|
||||
}
|
||||
|
||||
private void validateTransferFees(DeliveryRequests.Transfer request, boolean requiresDeliveryFee, Set<String> covered) {
|
||||
List<DeliveryRequests.FeeItem> inputs = request.fees() == null ? List.of() : request.fees();
|
||||
if (inputs.isEmpty()) throw conflict("Delivery fee is required");
|
||||
Set<String> allowed = Set.of("DELIVERY_FEE", "LOADING_FEE", "APPOINTMENT_FEE");
|
||||
Set<String> seen = new HashSet<>();
|
||||
for (DeliveryRequests.FeeItem input : inputs) {
|
||||
String feeType = input.feeType().trim().toUpperCase(Locale.ROOT);
|
||||
if (!allowed.contains(feeType)) throw conflict("Unsupported delivery fee type: " + feeType);
|
||||
if (!seen.add(feeType)) throw conflict("Duplicate delivery fee type: " + feeType);
|
||||
if (!seen.add(input.direction() + ":" + feeType)) throw conflict("Duplicate delivery fee type: " + feeType);
|
||||
if ("RECEIVABLE".equals(input.direction()) && covered.contains(feeType)) throw conflict("本次货物的该服务已由原费用覆盖,请勿重复登记应收");
|
||||
if (!Set.of("CNY", "USD").contains(input.currency().trim().toUpperCase(Locale.ROOT)))
|
||||
throw conflict("Delivery fees only support CNY or USD");
|
||||
if (input.amount().signum() <= 0) throw conflict("Delivery fee amount must be greater than zero");
|
||||
}
|
||||
if (!seen.contains("DELIVERY_FEE")) throw conflict("Delivery fee is required");
|
||||
if (requiresDeliveryFee && !covered.contains("DELIVERY_FEE") && !seen.contains("RECEIVABLE:DELIVERY_FEE")) throw conflict("尚未有约定运费的货物须填写派送费");
|
||||
if (!requiresDeliveryFee && inputs.stream().anyMatch(item -> "DELIVERY_FEE".equalsIgnoreCase(item.feeType().trim())
|
||||
&& "RECEIVABLE".equals(item.direction()))) throw conflict("本排车全部货物已带入外接约定运费,请勿重复填写派送应收");
|
||||
if (Set.of("FLOOR", "MIXED").contains(request.loadingType().trim().toUpperCase(Locale.ROOT))
|
||||
&& !seen.contains("LOADING_FEE")) {
|
||||
&& !covered.contains("LOADING_FEE") && !seen.contains("RECEIVABLE:LOADING_FEE")) {
|
||||
throw conflict("Loading fee is required for floor or mixed loading");
|
||||
}
|
||||
if ("BUY_APPOINTMENT".equalsIgnoreCase(trim(request.appointmentAccount()))) {
|
||||
if (!StringUtils.hasText(request.sellerName())) throw conflict("Seller name is required when buying an appointment");
|
||||
if (!seen.contains("APPOINTMENT_FEE")) throw conflict("Appointment fee is required when buying an appointment");
|
||||
if (!covered.contains("APPOINTMENT_FEE") && !seen.contains("RECEIVABLE:APPOINTMENT_FEE")) throw conflict("Appointment fee is required when buying an appointment");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -976,10 +1069,21 @@ public class DeliveryTransferService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void cancelOutbound(String outboundNo, DeliveryRequests.Cancel request) {
|
||||
public void cancelOutbound(String outboundNo, String operatorId, DeliveryRequests.Cancel request) {
|
||||
OutboundOrder observed = outboundOrders.findGlobalByNo(trim(outboundNo));
|
||||
if (observed == null) throw notFound("Outbound order not found");
|
||||
// Dispatch picking/departure owns the same dispatch -> outbound lock order.
|
||||
DispatchOrder dispatch = observed.getDispatchOrderId() == null ? null
|
||||
: dispatchOrders.lockById(observed.getCustomerId(), observed.getDispatchOrderId());
|
||||
OutboundOrder outbound = outboundOrders.lockGlobalByNo(trim(outboundNo));
|
||||
if (outbound == null) throw notFound("Outbound order not found");
|
||||
if ("CANCELLED".equals(outbound.getStatus())) return;
|
||||
if (outbound.getDispatchOrderId() != null) {
|
||||
if (dispatch == null || !Objects.equals(dispatch.getId(), outbound.getDispatchOrderId())
|
||||
|| !"PENDING_DEPARTURE".equals(dispatch.getStatus()) || value(dispatch.getPickedPackageCount()) > 0
|
||||
|| value(dispatch.getShippedPackageCount()) > 0) throw conflict("排车已开始执行,不能直接取消出库");
|
||||
fleetBindings.requireUnbound(dispatch.getId());
|
||||
}
|
||||
if (!Set.of("WAIT_OUTBOUND", "WAIT_PICK").contains(outbound.getStatus())) {
|
||||
throw conflict("Only an unexecuted outbound order can be cancelled");
|
||||
}
|
||||
@@ -994,6 +1098,22 @@ public class DeliveryTransferService {
|
||||
throw conflict("Outbound order cannot be cancelled while delivery is in progress");
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
for (DeliveryOrder delivery : existingDeliveries) {
|
||||
int voidRequests = 0;
|
||||
for (var fee : feeSources.listForBusiness(customerId, "DELIVERY_ORDER", delivery.getId())) {
|
||||
if ("RECEIVABLE".equals(fee.getDirection()) && "DELIVERY_FEE".equals(fee.getFeeItemCode())
|
||||
&& "APPROVED".equals(fee.getReviewStatus()) && "ACTIVE".equals(fee.getSourceStatus())) {
|
||||
feeSources.voidSource(fee.getSourceFeeId(), operatorId, new OverseasFeeSourceDtos.VoidRequest(
|
||||
fee.getSourceVersion(), "取消出库 " + outboundNo + ";详见派送单取消原因",
|
||||
"CANCEL_OUTBOUND:" + outbound.getId() + ":" + fee.getSourceFeeId()));
|
||||
voidRequests++;
|
||||
}
|
||||
}
|
||||
recordOperation(customerId, delivery.getId(), operatorId, "CANCEL_OUTBOUND",
|
||||
"出库取消;" + voidRequests + "笔未履行派送应收已申请撤销,其他费用保留待核对", now);
|
||||
String reason = request.reason().trim();
|
||||
for (int offset = 0; offset < reason.length(); offset += 480)
|
||||
recordOperation(customerId, delivery.getId(), operatorId, "CANCEL_OUTBOUND_REASON",
|
||||
"(" + (offset / 480 + 1) + ") " + reason.substring(offset, Math.min(offset + 480, reason.length())), now);
|
||||
delivery.setStatus("CANCELLED"); delivery.setVersion(value(delivery.getVersion()) + 1);
|
||||
delivery.setUpdatedAt(now); deliveryOrders.updateById(delivery);
|
||||
}
|
||||
@@ -1019,9 +1139,26 @@ public class DeliveryTransferService {
|
||||
return;
|
||||
}
|
||||
if (!existingDeliveries.isEmpty()) {
|
||||
for (LastlegOrder order : tailOrders.selectList(new LambdaQueryWrapper<LastlegOrder>().eq(LastlegOrder::getCustomerId, customerId).eq(LastlegOrder::getOutboundOrderNo, outboundNo))) {
|
||||
String from = order.getCurrentStatus(); order.setCurrentStatus("RECEIVED"); order.setSubOrderStatus(null); order.setOutboundOrderNo(null); order.setDeliveryOrderNo(null); order.setUpdatedAt(now); tailOrders.updateById(order);
|
||||
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setCustomerId(customerId); history.setOrderId(order.getId()); history.setFromStatus(from); history.setToStatus("RECEIVED"); history.setEventId("CANCEL_OUTBOUND:" + outboundNo); history.setRemark(request.reason().trim()); history.setCreatedAt(now); histories.insert(history);
|
||||
var transferEvents = existingDeliveries.stream().map(delivery -> "TRANSFER_DELIVERY:" + delivery.getDeliveryNo()).toList();
|
||||
for (LastlegOrder selected : tailOrders.selectList(new LambdaQueryWrapper<LastlegOrder>().eq(LastlegOrder::getCustomerId, customerId)
|
||||
.eq(LastlegOrder::getOutboundOrderNo, outboundNo).orderByAsc(LastlegOrder::getId))) {
|
||||
var order = tailOrders.lockById(customerId, selected.getId());
|
||||
if (order == null || !outboundNo.equals(order.getOutboundOrderNo())) continue;
|
||||
var previous = histories.selectOne(new LambdaQueryWrapper<LastlegOrderStatusHistory>()
|
||||
.eq(LastlegOrderStatusHistory::getCustomerId, customerId).eq(LastlegOrderStatusHistory::getOrderId, order.getId())
|
||||
.in(LastlegOrderStatusHistory::getEventId, transferEvents)
|
||||
.orderByAsc(LastlegOrderStatusHistory::getCreatedAt, LastlegOrderStatusHistory::getId).last("LIMIT 1"));
|
||||
if (previous == null || !StringUtils.hasText(previous.getFromStatus())) throw conflict("缺少本次转派送前的订单状态记录,请先核对来源");
|
||||
String from = order.getCurrentStatus();
|
||||
if (tailOrders.update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<LastlegOrder>()
|
||||
.eq(LastlegOrder::getId, order.getId()).eq(LastlegOrder::getCustomerId, customerId)
|
||||
.eq(LastlegOrder::getOutboundOrderNo, outboundNo)
|
||||
.set(LastlegOrder::getCurrentStatus, previous.getFromStatus())
|
||||
.set(LastlegOrder::getSubOrderStatus, "OUTBOUND:CANCELLED;DELIVERY:CANCELLED")
|
||||
.set(LastlegOrder::getOutboundOrderNo, null).set(LastlegOrder::getDeliveryOrderNo, null)
|
||||
.set(LastlegOrder::getVersion, value(order.getVersion()) + 1).set(LastlegOrder::getUpdatedAt, now)) != 1)
|
||||
throw conflict("订单关联已变化,请刷新后重试");
|
||||
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setCustomerId(customerId); history.setOrderId(order.getId()); history.setFromStatus(from); history.setToStatus(previous.getFromStatus()); history.setEventId("CANCEL_OUTBOUND:" + outboundNo); history.setRemark(request.reason().trim()); history.setCreatedAt(now); histories.insert(history);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1225,7 +1362,22 @@ public class DeliveryTransferService {
|
||||
List<DeliveryResponses.DeliverySku> skus = details.stream().map(detail -> sku(detail, ordersById.get(detail.getOrderId()))).toList();
|
||||
return new DeliveryResponses.DeliveryDetail(deliveryRow(delivery), skus,
|
||||
feeSources.listForBusiness(customerId, "DELIVERY_ORDER", delivery.getId()).stream().map(this::feeRow).toList(),
|
||||
deliveryOperations.findByDelivery(customerId, delivery.getId()).stream().map(this::operationRow).toList());
|
||||
deliveryOperations.findByDelivery(customerId, delivery.getId()).stream().map(this::operationRow).toList(),
|
||||
feeCoverageEvidence(customerId, delivery.getOutboundNo(), items));
|
||||
}
|
||||
|
||||
private com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Evidence feeCoverageEvidence(String customerId, String outboundNo, List<DispatchItem> items) {
|
||||
var outbound = outboundNo == null ? null : outboundOrders.findByNo(customerId, outboundNo);
|
||||
if (outbound == null || outbound.getFeeCoverageJson() == null) return null;
|
||||
try {
|
||||
var evidence = objectMapper.readValue(outbound.getFeeCoverageJson(), com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Evidence.class);
|
||||
var ids = items.stream().map(DispatchItem::getId).collect(Collectors.toSet());
|
||||
var cargo = evidence.cargo().stream().filter(row -> ids.contains(row.dispatchItemId())).toList();
|
||||
var inboundIds = cargo.stream().filter(row -> "FIRSTLEG".equals(row.sourceRegion()))
|
||||
.map(com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Cargo::sourceInboundId).collect(Collectors.toSet());
|
||||
var references = evidence.references().stream().filter(row -> inboundIds.contains(row.source().sourceInboundId().toString())).toList();
|
||||
return new com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Evidence(evidence.cargoHash(), cargo, references, evidence.operatorId(), evidence.confirmedAt());
|
||||
} catch (JsonProcessingException exception) { throw new IllegalStateException("费用覆盖确认无法读取", exception); }
|
||||
}
|
||||
|
||||
private void bindTailOrders(String customerId, List<DispatchItem> items, String outboundNo, String deliveryNo,
|
||||
@@ -1286,9 +1438,9 @@ public class DeliveryTransferService {
|
||||
allocateMetric(candidate.getSourceVolumeWeightKg(), candidate.getSourcePackageCount(), available, 3),
|
||||
allocateMetric(candidate.getSourceVolumeCbm(), candidate.getSourcePackageCount(), available, 4));
|
||||
}
|
||||
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(d.getDocumentsJson()), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName(), d.getDestinationType(), d.getDeliveryAddress(), d.getContactName(), d.getContactPhone(), d.getPostalCode()); }
|
||||
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), DeliveryDocumentSnapshot.summary(d.getDocumentsJson()), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName(), d.getDestinationType(), d.getDeliveryAddress(), d.getContactName(), d.getContactPhone(), d.getPostalCode()); }
|
||||
private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), null, d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); }
|
||||
private DeliveryResponses.DeliveryFeeRow feeRow(OverseasFeeSource f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getDirection(), f.getFeeItemCode(), f.getOriginalCurrency(), f.getOriginalAmount(), f.getReviewReason()); }
|
||||
private DeliveryResponses.DeliveryFeeRow feeRow(OverseasFeeSource f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getDirection(), f.getFeeItemCode(), f.getOriginalCurrency(), f.getOriginalAmount(), f.getReviewReason(), f.getReviewStatus(), f.getSourceStatus(), f.getVoidStatus()); }
|
||||
private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); }
|
||||
private void recordOperation(String customerId, Long deliveryId, String operatorId, String node, String result, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(customerId); operation.setDeliveryOrderId(deliveryId); operation.setOperationNode(node); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setResult(result); operation.setCreatedAt(now); deliveryOperations.insert(operation); }
|
||||
private LastlegOrder firstTailOrder(String customerId, List<DispatchItem> items) { return items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).map(tailOrders::selectById).filter(Objects::nonNull).filter(order -> customerId.equals(order.getCustomerId())).findFirst().orElse(null); }
|
||||
@@ -1344,6 +1496,11 @@ public class DeliveryTransferService {
|
||||
java.util.function.Function<T, BigDecimal> getter) {
|
||||
return items.stream().map(getter).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
}
|
||||
private static <T> BigDecimal sumKnownPhysicalWeight(List<T> items,
|
||||
java.util.function.Function<T, BigDecimal> getter) {
|
||||
if (items.isEmpty() || items.stream().map(getter).anyMatch(weight -> weight == null || weight.signum() <= 0)) return null;
|
||||
return items.stream().map(getter).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
}
|
||||
private static BigDecimal allocateMetric(BigDecimal sourceValue, Long sourcePackages,
|
||||
long selectedPackages, int scale) {
|
||||
if (sourceValue == null || sourceValue.signum() == 0 || sourcePackages == null
|
||||
|
||||
Reference in New Issue
Block a user