Merge remote-tracking branch 'origin/dev' into dev
# Conflicts: # deploy/sql/海外区域统一增量-MySQL8.sql # lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java # lastleg-service/src/main/resources/db/lastleg.sql # lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml # lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java # scripts/README.md
This commit is contained in:
+5
-3
@@ -10,7 +10,7 @@ import com.baiyue.lastleg.dto.LastlegOrderSummary;
|
||||
import com.baiyue.lastleg.dto.LastlegOrderCommandDtos;
|
||||
import com.baiyue.lastleg.dto.ContainerPlanProgressDetail;
|
||||
import com.baiyue.lastleg.dto.ContainerPlanProgressSummary;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderFee;
|
||||
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
|
||||
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
|
||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||
import com.baiyue.lastleg.service.LastlegOrderService;
|
||||
@@ -132,12 +132,14 @@ public class LastlegOrderController {
|
||||
@PostMapping("/api/platform/v1/last-leg/orders/{id}/fees")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')")
|
||||
@Operation(summary = "Add a fee to one tail-leg order", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT)
|
||||
public ApiResponse<LastlegOrderFee> createFee(
|
||||
public ApiResponse<OverseasFeeSource> createFee(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@PathVariable long id,
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@Valid @RequestBody LastlegOrderRequests.CreateFee request) {
|
||||
return ApiResponse.success(orderService.createFee(platformScope(user, customerId), id, request, operatorName(user)));
|
||||
return ApiResponse.success(orderService.createFee(platformScope(user, customerId), id, request,
|
||||
operatorName(user), idempotencyKey));
|
||||
}
|
||||
|
||||
@PutMapping("/api/platform/v1/last-leg/orders/{id}/hold")
|
||||
|
||||
+18
-2
@@ -11,6 +11,7 @@ import java.util.Map;
|
||||
import java.util.Collections;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalDate;
|
||||
|
||||
public final class DeliveryRequests {
|
||||
private DeliveryRequests() {}
|
||||
@@ -45,8 +46,23 @@ public final class DeliveryRequests {
|
||||
@NotNull @Size(min = 1, max = 500) List<@NotBlank @Size(max = 128) String> cartonMarks) {}
|
||||
|
||||
public record FeeItem(@NotBlank @Size(max = 64) String feeType,
|
||||
@NotBlank @Size(max = 8) String currency,
|
||||
@NotNull @Min(0) BigDecimal amount,
|
||||
@NotBlank @Size(max = 128) String feeName,
|
||||
@NotBlank @jakarta.validation.constraints.Pattern(regexp = "RECEIVABLE|PAYABLE") String direction,
|
||||
@NotBlank @jakarta.validation.constraints.Pattern(regexp = "CNY|USD") String currency,
|
||||
@NotNull @jakarta.validation.constraints.DecimalMin(value = "0.00", inclusive = false) BigDecimal amount,
|
||||
@Min(1) Long supplierId,
|
||||
@Size(max = 200) String supplierName,
|
||||
@Size(max = 32) String supplierCategory,
|
||||
@jakarta.validation.constraints.Pattern(regexp = "ON_ACCOUNT|DIRECT_PAID") String settlementMode,
|
||||
LocalDateTime paidAt,
|
||||
@jakarta.validation.constraints.Pattern(regexp = "ONLINE|BANK_TRANSFER|CASH|OTHER") String paymentMethod,
|
||||
@Size(max = 128) String paymentReferenceNo,
|
||||
@Size(max = 20) List<@NotBlank String> paymentVoucherFileIds,
|
||||
@jakarta.validation.constraints.DecimalMin(value = "0.00", inclusive = false) BigDecimal chargeableQuantity,
|
||||
@Size(max = 32) String quantityUnit,
|
||||
@jakarta.validation.constraints.DecimalMin(value = "0.00", inclusive = false) BigDecimal unitPrice,
|
||||
LocalDate periodStart,
|
||||
LocalDate periodEnd,
|
||||
@Size(max = 500) String remark) {}
|
||||
|
||||
public record Query(@Min(1) long page, @Min(1) @Max(200) long size,
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
package com.baiyue.lastleg.delivery.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_delivery_fee")
|
||||
public class DeliveryFee {
|
||||
@TableId(type = IdType.ASSIGN_ID) private Long id;
|
||||
private String customerId;
|
||||
private Long deliveryOrderId;
|
||||
private String feeDirection;
|
||||
private String feeType;
|
||||
private String currency;
|
||||
private BigDecimal amount;
|
||||
private String remark;
|
||||
private String createdBy;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
package com.baiyue.lastleg.delivery.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.delivery.entity.DeliveryFee;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface DeliveryFeeMapper extends BaseMapper<DeliveryFee> {
|
||||
@Select("SELECT * FROM ll_delivery_fee WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryOrderId} ORDER BY id")
|
||||
List<DeliveryFee> findByDelivery(@Param("customerId") String customerId,
|
||||
@Param("deliveryOrderId") Long deliveryOrderId);
|
||||
}
|
||||
+31
-10
@@ -7,13 +7,14 @@ import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryResponses;
|
||||
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
|
||||
import com.baiyue.lastleg.delivery.entity.DeliveryFee;
|
||||
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
|
||||
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos;
|
||||
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
|
||||
import com.baiyue.lastleg.delivery.entity.DeliveryOperation;
|
||||
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
|
||||
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
|
||||
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
|
||||
import com.baiyue.lastleg.delivery.model.DeliveryStatusPolicy;
|
||||
import com.baiyue.lastleg.delivery.mapper.DeliveryFeeMapper;
|
||||
import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper;
|
||||
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
|
||||
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
|
||||
@@ -65,6 +66,7 @@ import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
|
||||
import java.math.BigDecimal;
|
||||
@@ -77,7 +79,7 @@ public class DeliveryTransferService {
|
||||
@Resource private OutboundOrderMapper outboundOrders;
|
||||
@Resource private OutboundPalletItemMapper outboundPallets;
|
||||
@Resource private DeliveryOrderMapper deliveryOrders;
|
||||
@Resource private DeliveryFeeMapper deliveryFees;
|
||||
@Resource private OverseasFeeSourceService feeSources;
|
||||
@Resource private DeliveryOperationMapper deliveryOperations;
|
||||
@Resource private OutboundPdaTaskMapper pdaTasks;
|
||||
@Resource private OutboundPdaPickMapper pdaPicks;
|
||||
@@ -207,9 +209,18 @@ public class DeliveryTransferService {
|
||||
delivery.setCreatedAt(now); delivery.setUpdatedAt(now); deliveryOrders.insert(delivery);
|
||||
List<DeliveryRequests.FeeItem> feeInputs = request.fees() == null ? List.of() : request.fees();
|
||||
for (DeliveryRequests.FeeItem input : feeInputs) {
|
||||
DeliveryFee fee = new DeliveryFee(); fee.setCustomerId(customerId); fee.setDeliveryOrderId(delivery.getId());
|
||||
fee.setFeeDirection("PAYABLE"); fee.setFeeType(input.feeType().trim()); fee.setCurrency(input.currency().trim().toUpperCase(Locale.ROOT));
|
||||
fee.setAmount(input.amount()); fee.setRemark(trim(input.remark())); fee.setCreatedBy(operatorId); fee.setCreatedAt(now); deliveryFees.insert(fee);
|
||||
feeSources.publishApproved(operatorId, childKey(request.idempotencyKey(), input.feeType()),
|
||||
new OverseasFeeSourceDtos.SaveRequest(customerId, delivery.getCustomerName(),
|
||||
"OVERSEAS_DELIVERY_FEE", "DELIVERY_ORDER", delivery.getId(), delivery.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(delivery.getOverseasWarehouseId()),
|
||||
input.feeType().trim(), 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(),
|
||||
input.periodStart(), input.periodEnd()));
|
||||
}
|
||||
recordOperation(customerId, delivery.getId(), operatorId, "CREATE_DELIVERY", "CREATED", now);
|
||||
bindTailOrders(customerId, items, outbound.getOutboundNo(), delivery.getDeliveryNo(), request.remark(), now);
|
||||
@@ -225,8 +236,9 @@ public class DeliveryTransferService {
|
||||
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 (!"USD".equalsIgnoreCase(input.currency().trim())) throw conflict("Delivery fees must use USD");
|
||||
if (input.amount().signum() < 0) throw conflict("Delivery fee amount cannot be negative");
|
||||
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 (Set.of("FLOOR", "MIXED").contains(request.loadingType().trim().toUpperCase(Locale.ROOT))
|
||||
@@ -719,7 +731,7 @@ public class DeliveryTransferService {
|
||||
new LambdaQueryWrapper<LastlegOrderDetail>().eq(LastlegOrderDetail::getCustomerId, customerId).in(LastlegOrderDetail::getOrderId, orderIds));
|
||||
List<DeliveryResponses.DeliverySku> skus = details.stream().map(detail -> sku(detail, ordersById.get(detail.getOrderId()))).toList();
|
||||
return new DeliveryResponses.DeliveryDetail(deliveryRow(delivery), skus,
|
||||
deliveryFees.findByDelivery(customerId, delivery.getId()).stream().map(this::feeRow).toList(),
|
||||
feeSources.listForBusiness(customerId, "DELIVERY_ORDER", delivery.getId()).stream().map(this::feeRow).toList(),
|
||||
deliveryOperations.findByDelivery(customerId, delivery.getId()).stream().map(this::operationRow).toList());
|
||||
}
|
||||
|
||||
@@ -776,7 +788,7 @@ public class DeliveryTransferService {
|
||||
}
|
||||
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())); }
|
||||
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(DeliveryFee f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getFeeDirection(), f.getFeeType(), f.getCurrency(), f.getAmount(), f.getRemark()); }
|
||||
private DeliveryResponses.DeliveryFeeRow feeRow(OverseasFeeSource f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getDirection(), f.getFeeItemCode(), f.getOriginalCurrency(), f.getOriginalAmount(), f.getReviewReason()); }
|
||||
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); }
|
||||
@@ -792,6 +804,15 @@ public class DeliveryTransferService {
|
||||
}
|
||||
private BigDecimal totalWeight(String customerId, List<DispatchItem> items) { List<Long> ids = items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).distinct().toList(); if (ids.isEmpty()) return BigDecimal.ZERO; return tailOrderDetails.selectList(new LambdaQueryWrapper<LastlegOrderDetail>().eq(LastlegOrderDetail::getCustomerId, customerId).in(LastlegOrderDetail::getOrderId, ids)).stream().map(LastlegOrderDetail::getGrossWeightKg).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add); }
|
||||
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
|
||||
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;
|
||||
}
|
||||
private static String required(String value, String message) {
|
||||
String normalized = trim(value);
|
||||
if (normalized == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
package com.baiyue.lastleg.dto;
|
||||
|
||||
import com.baiyue.lastleg.entity.LastlegOrderDetail;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderFee;
|
||||
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@@ -12,7 +12,7 @@ public record LastlegOrderDetailResponse(
|
||||
List<LastlegOrderCommandDtos.ManualNodeView> manualNodes,
|
||||
List<LastlegOrderDetail> cargo,
|
||||
List<History> statusHistory,
|
||||
List<LastlegOrderFee> fees,
|
||||
List<OverseasFeeSource> fees,
|
||||
List<Operation> operationHistory
|
||||
) {
|
||||
public record History(Long id, String fromStatus, String toStatus, String remark, LocalDateTime createdAt) {}
|
||||
|
||||
@@ -11,6 +11,7 @@ import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
public final class LastlegOrderRequests {
|
||||
@@ -81,11 +82,25 @@ public final class LastlegOrderRequests {
|
||||
}
|
||||
|
||||
public record CreateFee(
|
||||
@NotBlank @Size(max = 128) String feeItem,
|
||||
@NotBlank @Pattern(regexp = "[A-Z]{3}") String currency,
|
||||
@NotBlank @Size(max = 64) String feeItemCode,
|
||||
@NotBlank @Size(max = 200) String feeItemName,
|
||||
@NotBlank @Pattern(regexp = "RECEIVABLE|PAYABLE") String direction,
|
||||
@NotBlank @Pattern(regexp = "CNY|USD") String currency,
|
||||
@NotNull @DecimalMin(value = "0.01") BigDecimal amount,
|
||||
@NotBlank @Pattern(regexp = "PENDING_REVIEW|APPROVED|REJECTED") String status,
|
||||
@Size(max = 500) String supplierDescription
|
||||
@NotNull LocalDate businessDate,
|
||||
@Min(1) Long supplierId,
|
||||
@Size(max = 200) String supplierName,
|
||||
@Size(max = 32) String supplierCategory,
|
||||
@Pattern(regexp = "ON_ACCOUNT|DIRECT_PAID") String settlementMode,
|
||||
LocalDateTime paidAt,
|
||||
@Pattern(regexp = "ONLINE|BANK_TRANSFER|CASH|OTHER") String paymentMethod,
|
||||
@Size(max = 128) String paymentReferenceNo,
|
||||
@Size(max = 20) List<@NotBlank String> paymentVoucherFileIds,
|
||||
@DecimalMin(value = "0.00", inclusive = false) BigDecimal chargeableQuantity,
|
||||
@Size(max = 32) String quantityUnit,
|
||||
@DecimalMin(value = "0.00", inclusive = false) BigDecimal unitPrice,
|
||||
LocalDate periodStart,
|
||||
LocalDate periodEnd
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
package com.baiyue.lastleg.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_order_fee")
|
||||
public class LastlegOrderFee {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private String customerId;
|
||||
private Long orderId;
|
||||
private String feeItem;
|
||||
private String currency;
|
||||
private BigDecimal amount;
|
||||
private String status;
|
||||
private String supplierDescription;
|
||||
private String createdBy;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+9
-4
@@ -20,8 +20,8 @@ public final class OverseasFeeSourceDtos {
|
||||
public record SaveRequest(
|
||||
@NotBlank @Size(max = 128) String customerId,
|
||||
@Size(max = 200) String customerName,
|
||||
@NotBlank @Pattern(regexp = "OVERSEAS_INBOUND_FEE|OVERSEAS_PROCUREMENT_FEE|OVERSEAS_TRUCK_FEE|OVERSEAS_LOOSEBOARD_FEE|OVERSEAS_WAREHOUSE_FEE|OVERSEAS_CONTAINER_FEE") String sourceType,
|
||||
@NotBlank @Pattern(regexp = "OVERSEAS_INBOUND|OVERSEAS_PROCUREMENT|US_TRUCK|LOCAL_LOOSEBOARD|WAREHOUSE_SERVICE|OVERSEAS_CONTAINER") String businessType,
|
||||
@NotBlank @Pattern(regexp = "OVERSEAS_INBOUND_FEE|OVERSEAS_PROCUREMENT_FEE|OVERSEAS_TRUCK_FEE|OVERSEAS_LOOSEBOARD_FEE|OVERSEAS_WAREHOUSE_FEE|OVERSEAS_CONTAINER_FEE|OVERSEAS_ORDER_FEE|OVERSEAS_DELIVERY_FEE|OVERSEAS_RELABEL_FEE") String sourceType,
|
||||
@NotBlank @Pattern(regexp = "OVERSEAS_INBOUND|OVERSEAS_PROCUREMENT|PROCUREMENT_FEE|US_TRUCK|LOCAL_LOOSEBOARD|WAREHOUSE_SERVICE|OVERSEAS_CONTAINER|LASTLEG_ORDER|DELIVERY_ORDER|RELABEL_ORDER") String businessType,
|
||||
@Min(1) Long businessId,
|
||||
@NotBlank @Size(max = 128) String businessNo,
|
||||
@Min(1) Long orderId,
|
||||
@@ -43,8 +43,13 @@ public final class OverseasFeeSourceDtos {
|
||||
@Size(max = 128) String paymentReferenceNo,
|
||||
@Size(max = 20) List<@NotBlank String> paymentVoucherFileIds,
|
||||
@NotNull LocalDate businessDate,
|
||||
@NotBlank @Pattern(regexp = "CNY") String currency,
|
||||
@NotNull @DecimalMin(value = "0.00", inclusive = false) BigDecimal amount
|
||||
@NotBlank @Pattern(regexp = "CNY|USD") String currency,
|
||||
@NotNull @DecimalMin(value = "0.00", inclusive = false) BigDecimal amount,
|
||||
@DecimalMin(value = "0.00", inclusive = false) BigDecimal chargeableQuantity,
|
||||
@Size(max = 32) String quantityUnit,
|
||||
@DecimalMin(value = "0.00", inclusive = false) BigDecimal unitPrice,
|
||||
LocalDate periodStart,
|
||||
LocalDate periodEnd
|
||||
) { }
|
||||
|
||||
public record ActionRequest(@NotBlank @Size(max = 128) String idempotencyKey) { }
|
||||
|
||||
+9
@@ -46,6 +46,15 @@ public class OverseasFeeSource {
|
||||
private LocalDate businessDate;
|
||||
private String currency;
|
||||
private BigDecimal amount;
|
||||
private String originalCurrency;
|
||||
private BigDecimal originalAmount;
|
||||
private BigDecimal exchangeRate;
|
||||
private String exchangeRateMonth;
|
||||
private BigDecimal chargeableQuantity;
|
||||
private String quantityUnit;
|
||||
private BigDecimal unitPrice;
|
||||
private LocalDate periodStart;
|
||||
private LocalDate periodEnd;
|
||||
private String reviewStatus;
|
||||
private String publicationStatus;
|
||||
private String sourceStatus;
|
||||
|
||||
+26
@@ -2,7 +2,11 @@ package com.baiyue.lastleg.financefee.service;
|
||||
|
||||
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest;
|
||||
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ContainerCustomerOption;
|
||||
import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient;
|
||||
import feign.FeignException;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -14,6 +18,8 @@ import java.util.List;
|
||||
@RequiredArgsConstructor
|
||||
public class OverseasFeeBusinessGuard {
|
||||
private final JdbcTemplate jdbc;
|
||||
@Resource private DomesticWarehouseDirectoryClient warehouses;
|
||||
@Value("${baiyue.security.internal-service-token}") private String internalToken;
|
||||
|
||||
public List<ContainerCustomerOption> listContainerCustomers(long containerBookingId) {
|
||||
List<ContainerCustomerOption> customers = jdbc.query(
|
||||
@@ -37,13 +43,18 @@ public class OverseasFeeBusinessGuard {
|
||||
throw bad("业务记录ID不能为空");
|
||||
if (fee.warehouseId() == null)
|
||||
throw bad("海外仓服务费必须关联海外仓;入仓记录和入仓码按实际业务选填");
|
||||
requireActiveOverseasWarehouse(fee.warehouseId());
|
||||
return;
|
||||
}
|
||||
Table table = switch (fee.businessType()) {
|
||||
case "OVERSEAS_INBOUND" -> new Table("ll_inbound_plan", "plan_no");
|
||||
case "OVERSEAS_PROCUREMENT" -> new Table("ll_overseas_procurement_order", "order_no");
|
||||
case "PROCUREMENT_FEE" -> new Table("ll_overseas_procurement_fee", "fee_no");
|
||||
case "US_TRUCK" -> new Table("ll_us_truck_order", "order_no");
|
||||
case "LOCAL_LOOSEBOARD" -> new Table("ll_local_looseboard_order", "order_no");
|
||||
case "LASTLEG_ORDER" -> new Table("ll_order", "order_no");
|
||||
case "DELIVERY_ORDER" -> new Table("ll_delivery_order", "delivery_no");
|
||||
case "RELABEL_ORDER" -> new Table("ll_relabel_application", "relabel_no");
|
||||
default -> throw bad("业务类型与业务记录ID不匹配");
|
||||
};
|
||||
Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM " + table.name
|
||||
@@ -83,6 +94,21 @@ public class OverseasFeeBusinessGuard {
|
||||
if (owned == null || owned < 1) throw bad("该客户不属于费用关联的真实货柜");
|
||||
}
|
||||
|
||||
private void requireActiveOverseasWarehouse(long warehouseId) {
|
||||
try {
|
||||
var response = warehouses.activeWarehouse(warehouseId, internalToken);
|
||||
var warehouse = response == null || response.data() == null ? null : response.data().warehouse();
|
||||
if (warehouse == null || !response.successful() || !Long.valueOf(warehouseId).equals(warehouse.id())
|
||||
|| !Long.valueOf(0L).equals(warehouse.tenantId()) || !"ACTIVE".equals(warehouse.status())
|
||||
|| !List.of("OVERSEAS", "OVERSEAS_PARTNER").contains(warehouse.warehouseType())) {
|
||||
throw bad("海外仓不存在、已停用或类型不正确");
|
||||
}
|
||||
} catch (FeignException exception) {
|
||||
if (exception.status() >= 400 && exception.status() < 500) throw bad("海外仓不存在、已停用或类型不正确");
|
||||
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "仓库主数据暂不可用", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean blank(String value) { return value == null || value.isBlank(); }
|
||||
private static ResponseStatusException bad(String message) {
|
||||
return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package com.baiyue.lastleg.financefee.service;
|
||||
|
||||
import com.baiyue.lastleg.masterdata.exchange.entity.MonthlyExchangeRate;
|
||||
import com.baiyue.lastleg.masterdata.exchange.mapper.MonthlyExchangeRateMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class OverseasFeeCurrencyConverter {
|
||||
private static final DateTimeFormatter MONTH = DateTimeFormatter.ofPattern("yyyy-MM");
|
||||
private final MonthlyExchangeRateMapper rates;
|
||||
|
||||
public Conversion convert(String currency, BigDecimal amount, LocalDate businessDate) {
|
||||
String originalCurrency = currency == null ? null : currency.trim().toUpperCase();
|
||||
BigDecimal originalAmount = amount == null ? null : amount.setScale(2, RoundingMode.HALF_UP);
|
||||
if ("CNY".equals(originalCurrency)) {
|
||||
return new Conversion(originalCurrency, originalAmount, BigDecimal.ONE.setScale(6),
|
||||
businessDate.format(MONTH), originalAmount);
|
||||
}
|
||||
if (!"USD".equals(originalCurrency)) throw bad("海外费用原币只支持CNY或USD");
|
||||
String month = businessDate.format(MONTH);
|
||||
MonthlyExchangeRate rate = rates.findMonth(month);
|
||||
if (rate == null || !"ACTIVE".equals(rate.getStatus()) || rate.getUsdToCnyAverage() == null
|
||||
|| rate.getUsdToCnyAverage().signum() <= 0) {
|
||||
throw bad("业务月份缺少有效USD兑CNY汇率,不能发布财务费用");
|
||||
}
|
||||
BigDecimal exchangeRate = rate.getUsdToCnyAverage().setScale(6, RoundingMode.HALF_UP);
|
||||
return new Conversion(originalCurrency, originalAmount, exchangeRate, month,
|
||||
originalAmount.multiply(exchangeRate).setScale(2, RoundingMode.HALF_UP));
|
||||
}
|
||||
|
||||
private static ResponseStatusException bad(String message) {
|
||||
return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
|
||||
}
|
||||
|
||||
public record Conversion(String originalCurrency, BigDecimal originalAmount,
|
||||
BigDecimal exchangeRate, String exchangeRateMonth,
|
||||
BigDecimal settlementAmount) { }
|
||||
}
|
||||
+4
-1
@@ -66,7 +66,10 @@ public class OverseasFeeOutboxService {
|
||||
text(value.getSupplierId()), value.getSupplierName(), value.getSupplierCategory(),
|
||||
value.getSettlementMode(), instant(value.getPaidAt()), value.getPaymentMethod(),
|
||||
value.getPaymentReferenceNo(), value.getPaymentVoucherFileIds() == null ? List.of() : value.getPaymentVoucherFileIds(),
|
||||
value.getBusinessDate(), value.getCurrency(), value.getAmount(), value.getReviewStatus(),
|
||||
value.getBusinessDate(), value.getCurrency(), value.getAmount(),
|
||||
value.getOriginalCurrency(), value.getOriginalAmount(), value.getExchangeRate(), value.getExchangeRateMonth(),
|
||||
value.getChargeableQuantity(), value.getQuantityUnit(), value.getUnitPrice(), value.getPeriodStart(), value.getPeriodEnd(),
|
||||
value.getReviewStatus(),
|
||||
"PUBLISHED", action == null && hash == null ? "ACTIVE" : value.getSourceStatus(), hash);
|
||||
}
|
||||
|
||||
|
||||
+50
-3
@@ -19,7 +19,6 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.math.RoundingMode;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
@@ -40,6 +39,7 @@ public class OverseasFeeSourceService {
|
||||
private final OverseasFeeCommandMapper commands;
|
||||
private final OverseasFeeBusinessGuard businessGuard;
|
||||
private final OverseasFeeOutboxService outbox;
|
||||
private final OverseasFeeCurrencyConverter currencyConverter;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public List<OverseasFeeSource> list(String customerId, String businessType, String businessNo,
|
||||
@@ -53,6 +53,15 @@ public class OverseasFeeSourceService {
|
||||
.orderByDesc(OverseasFeeSource::getCreatedAt).orderByDesc(OverseasFeeSource::getId));
|
||||
}
|
||||
|
||||
public List<OverseasFeeSource> listForBusiness(String customerId, String businessType, long businessId) {
|
||||
return mapper.selectList(new LambdaQueryWrapper<OverseasFeeSource>()
|
||||
.eq(OverseasFeeSource::getCustomerId, customerId)
|
||||
.eq(OverseasFeeSource::getBusinessType, businessType)
|
||||
.eq(OverseasFeeSource::getBusinessId, businessId)
|
||||
.inSql(OverseasFeeSource::getId, "SELECT MAX(v.id) FROM ll_finance_fee_source v GROUP BY v.source_fee_id")
|
||||
.orderByAsc(OverseasFeeSource::getCreatedAt).orderByAsc(OverseasFeeSource::getId));
|
||||
}
|
||||
|
||||
public List<ContainerCustomerOption> listContainerCustomers(long containerBookingId) {
|
||||
return businessGuard.listContainerCustomers(containerBookingId);
|
||||
}
|
||||
@@ -70,6 +79,22 @@ public class OverseasFeeSourceService {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public OverseasFeeSource publishApproved(String operatorId, String key, SaveRequest request) {
|
||||
OverseasFeeSource value = create(operatorId, key, request);
|
||||
if ("DRAFT".equals(value.getReviewStatus()) || "REJECTED".equals(value.getReviewStatus())) {
|
||||
value = submit(value.getSourceFeeId(), operatorId, childKey(key, "submit"));
|
||||
}
|
||||
if ("PENDING".equals(value.getReviewStatus())) {
|
||||
value = review(value.getSourceFeeId(), operatorId,
|
||||
new ReviewRequest("APPROVE", "业务完成自动确认费用", childKey(key, "approve")));
|
||||
}
|
||||
if (!"APPROVED".equals(value.getReviewStatus()) || !"PUBLISHED".equals(value.getPublicationStatus())) {
|
||||
throw conflict("业务费用未能审核发布");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public OverseasFeeSource submit(long sourceFeeId, String operatorId, String key) {
|
||||
OverseasFeeCommand replay = replay(operatorId, "SUBMIT", key, sourceFeeId);
|
||||
@@ -151,8 +176,14 @@ public class OverseasFeeSourceService {
|
||||
value.setDirection(request.direction()); value.setSupplierId(request.supplierId()); value.setSupplierName(text(request.supplierName()));
|
||||
value.setSupplierCategory(text(request.supplierCategory())); value.setSettlementMode(text(request.settlementMode()));
|
||||
value.setPaidAt(request.paidAt()); value.setPaymentMethod(text(request.paymentMethod())); value.setPaymentReferenceNo(text(request.paymentReferenceNo()));
|
||||
value.setPaymentVoucherFileIds(request.paymentVoucherFileIds()); value.setBusinessDate(request.businessDate()); value.setCurrency(request.currency());
|
||||
value.setAmount(request.amount().setScale(2, RoundingMode.HALF_UP)); value.setReviewStatus("DRAFT");
|
||||
value.setPaymentVoucherFileIds(request.paymentVoucherFileIds()); value.setBusinessDate(request.businessDate());
|
||||
OverseasFeeCurrencyConverter.Conversion conversion = currencyConverter.convert(request.currency(), request.amount(), request.businessDate());
|
||||
value.setCurrency("CNY"); value.setAmount(conversion.settlementAmount());
|
||||
value.setOriginalCurrency(conversion.originalCurrency()); value.setOriginalAmount(conversion.originalAmount());
|
||||
value.setExchangeRate(conversion.exchangeRate()); value.setExchangeRateMonth(conversion.exchangeRateMonth());
|
||||
value.setChargeableQuantity(request.chargeableQuantity()); value.setQuantityUnit(text(request.quantityUnit()));
|
||||
value.setUnitPrice(request.unitPrice()); value.setPeriodStart(request.periodStart()); value.setPeriodEnd(request.periodEnd());
|
||||
value.setReviewStatus("DRAFT");
|
||||
value.setPublicationStatus("UNPUBLISHED"); value.setSourceStatus("ACTIVE"); value.setCreatedBy(operatorId); value.setUpdatedBy(operatorId);
|
||||
value.setCreatedAt(now()); value.setUpdatedAt(now());
|
||||
return value;
|
||||
@@ -162,16 +193,26 @@ public class OverseasFeeSourceService {
|
||||
String expected = switch (value.businessType()) {
|
||||
case "OVERSEAS_INBOUND" -> "OVERSEAS_INBOUND_FEE";
|
||||
case "OVERSEAS_PROCUREMENT" -> "OVERSEAS_PROCUREMENT_FEE";
|
||||
case "PROCUREMENT_FEE" -> "OVERSEAS_PROCUREMENT_FEE";
|
||||
case "US_TRUCK" -> "OVERSEAS_TRUCK_FEE";
|
||||
case "LOCAL_LOOSEBOARD" -> "OVERSEAS_LOOSEBOARD_FEE";
|
||||
case "WAREHOUSE_SERVICE" -> "OVERSEAS_WAREHOUSE_FEE";
|
||||
case "OVERSEAS_CONTAINER" -> "OVERSEAS_CONTAINER_FEE";
|
||||
case "LASTLEG_ORDER" -> "OVERSEAS_ORDER_FEE";
|
||||
case "DELIVERY_ORDER" -> "OVERSEAS_DELIVERY_FEE";
|
||||
case "RELABEL_ORDER" -> "OVERSEAS_RELABEL_FEE";
|
||||
default -> "";
|
||||
};
|
||||
if (!expected.equals(value.sourceType())) throw bad("来源类型与业务类型不匹配");
|
||||
if ("OVERSEAS_CONTAINER".equals(value.businessType())
|
||||
&& !OVERSEAS_CONTAINER_FEE_ITEMS.contains(value.feeItemCode().trim()))
|
||||
throw bad("海外货柜费用项目不受支持");
|
||||
if ((value.periodStart() == null) != (value.periodEnd() == null)
|
||||
|| value.periodStart() != null && value.periodStart().isAfter(value.periodEnd()))
|
||||
throw bad("计费期间必须同时填写且开始日期不能晚于结束日期");
|
||||
if ((value.chargeableQuantity() == null) != (text(value.quantityUnit()) == null)
|
||||
|| value.unitPrice() != null && value.chargeableQuantity() == null)
|
||||
throw bad("计费数量、单位和单价信息不完整");
|
||||
boolean payable = "PAYABLE".equals(value.direction());
|
||||
boolean files = value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty();
|
||||
if (!payable) {
|
||||
@@ -200,6 +241,12 @@ public class OverseasFeeSourceService {
|
||||
if (existing != null && !existing.getRequestHash().equals(hash(request))) throw conflict("幂等键已被不同请求使用");
|
||||
return existing;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
private void command(String operator, String action, String key, Object request, long sourceFeeId, int version) {
|
||||
OverseasFeeCommand value = new OverseasFeeCommand(); value.setId(IdWorker.getId()); value.setOperatorId(operator);
|
||||
value.setAction(action); value.setIdempotencyKey(key.trim()); value.setRequestHash(hash(request)); value.setSourceFeeId(sourceFeeId);
|
||||
|
||||
+8
@@ -134,6 +134,14 @@ public class ReceivingContainerController {
|
||||
return ApiResponse.success(service.receiveCarton(identity.operatorKey(), identity.operatorName(), request));
|
||||
}
|
||||
|
||||
@PostMapping("/current/receipts/pallet")
|
||||
public ApiResponse<ReceivingContainerResponses.Receipt> receiveWholePallet(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody ReceivingContainerRequests.WholePalletReceive request) {
|
||||
PdaWarehouseIdentity identity = currentIdentity(user);
|
||||
return ApiResponse.success(service.receiveWholePallet(identity.operatorKey(), identity.operatorName(), request));
|
||||
}
|
||||
|
||||
@PostMapping("/current/complete")
|
||||
public ApiResponse<ReceivingContainerResponses.Session> complete(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody ReceivingContainerRequests.Action request) {
|
||||
|
||||
+2
@@ -23,4 +23,6 @@ public final class ReceivingContainerRequests {
|
||||
@Positive int packageCount, @NotBlank @Size(max = 255) String idempotencyKey) { }
|
||||
public record CartonReceive(@NotBlank String cartonMark, @NotBlank String palletCode,
|
||||
@NotBlank @Size(max = 255) String idempotencyKey) { }
|
||||
public record WholePalletReceive(@NotBlank String sourcePalletNo,
|
||||
@NotBlank @Size(max = 255) String idempotencyKey) { }
|
||||
}
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@ public final class ReceivingContainerResponses {
|
||||
String fbaWarehouseCode, int plannedPalletCount, int totalPackages,
|
||||
int receivedPackages, int pendingPackages,
|
||||
boolean assigned, boolean mergeAllowed, boolean urgent,
|
||||
String mergeHint) { }
|
||||
String mergeHint, String orderType, boolean wholePalletInbound) { }
|
||||
|
||||
public record CartonResolved(String cartonMark, String inboundCode,
|
||||
String destinationType, String overseasWarehouseId,
|
||||
|
||||
-1
@@ -13,7 +13,6 @@ public class ReceivingContainerOperation {
|
||||
@TableId(type = IdType.INPUT)
|
||||
private Long id;
|
||||
private Long sessionId;
|
||||
private String customerId;
|
||||
private String operatorKey;
|
||||
private String action;
|
||||
private String idempotencyKey;
|
||||
|
||||
-1
@@ -15,7 +15,6 @@ import java.time.LocalDateTime;
|
||||
public class ReceivingContainerSession {
|
||||
@TableId(type = IdType.INPUT)
|
||||
private Long id;
|
||||
private String customerId;
|
||||
private String sourceContainerBookingId;
|
||||
private String containerFactHash;
|
||||
private String containerNo;
|
||||
|
||||
+10
-3
@@ -147,13 +147,20 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,
|
||||
"Reserved pallet customer binding is invalid");
|
||||
}
|
||||
WarehousePalletData pallet = lookupPallet(palletCode).data();
|
||||
if (pallet.id() != palletId || pallet.warehouseId() != warehouseId || pallet.locationId() != locationId
|
||||
WarehousePalletReference pallet;
|
||||
try {
|
||||
pallet = reference(lookupPallet(palletCode).data());
|
||||
} catch (ResponseStatusException exception) {
|
||||
if (exception.getStatusCode().value() != 404) throw exception;
|
||||
// 已生成计划可能保留早期展示码;确认过的资源绑定仍是唯一可信物理身份。
|
||||
pallet = resolvePallet(palletId);
|
||||
}
|
||||
if (pallet.palletId() != palletId || pallet.warehouseId() != warehouseId || pallet.locationId() != locationId
|
||||
|| !"OCCUPIED".equals(pallet.status())) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,
|
||||
"Scanned pallet does not match the reserved inbound plan resource");
|
||||
}
|
||||
return reference(pallet);
|
||||
return pallet;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+2
-1
@@ -58,7 +58,8 @@ public interface WarehouseResourceGateway {
|
||||
java.math.BigDecimal maxLoadKg, String status) { }
|
||||
record PalletBatchEnsure(long warehouseId, String batchKey, List<PalletBatchItemRequest> items) { }
|
||||
record PalletBatchItemRequest(long customerId, String businessKey, String inboundPlanNo,
|
||||
int sequenceNo, String palletType, String fbaWarehouseCode) { }
|
||||
int sequenceNo, String palletType, String fbaWarehouseCode,
|
||||
String palletCode) { }
|
||||
record PalletBatchCommand(long warehouseId, String batchKey, String commandKey) { }
|
||||
record PalletBatchCompensation(long warehouseId, String batchKey, String commandKey, String reason) { }
|
||||
record PalletBatchResponse(long batchId, String batchKey, String status, int attemptNo,
|
||||
|
||||
+8
-7
@@ -546,33 +546,34 @@ public class InboundPdaWorkflowService {
|
||||
@Transactional
|
||||
public PdaResponses.Task complete(String customerId, String planNo) {
|
||||
InboundPlan plan = requirePlan(customerId, planNo);
|
||||
return completeContainer(customerId, List.of(plan)).get(0);
|
||||
return completeContainer(List.of(plan)).get(0);
|
||||
}
|
||||
|
||||
/** Completes every plan of one physical container and emits exactly one container-level outbox event. */
|
||||
@Transactional
|
||||
public List<PdaResponses.Task> completeContainer(String customerId, List<InboundPlan> containerPlans) {
|
||||
public List<PdaResponses.Task> completeContainer(List<InboundPlan> containerPlans) {
|
||||
if (containerPlans == null || containerPlans.isEmpty()) throw conflict("Receiving container has no inbound plans");
|
||||
List<InboundPlan> orderedPlans = containerPlans.stream().sorted(Comparator.comparing(InboundPlan::getId)).toList();
|
||||
String containerNo = orderedPlans.get(0).getContainerNo();
|
||||
if (containerNo == null || containerNo.isBlank() || orderedPlans.stream().anyMatch(plan ->
|
||||
!customerId.equals(plan.getCustomerId()) || !containerNo.equals(plan.getContainerNo()))) {
|
||||
throw conflict("Inbound plans must belong to one customer and one physical container");
|
||||
plan.getCustomerId() == null || plan.getCustomerId().isBlank() || !containerNo.equals(plan.getContainerNo()))) {
|
||||
throw conflict("Inbound plans must have an owning customer and belong to one physical container");
|
||||
}
|
||||
List<CompletionContext> contexts = orderedPlans.stream().map(plan -> completionContext(customerId, plan)).toList();
|
||||
List<CompletionContext> contexts = orderedPlans.stream()
|
||||
.map(plan -> completionContext(plan.getCustomerId(), plan)).toList();
|
||||
boolean anyCompleted = contexts.stream().anyMatch(context -> "COMPLETED".equals(context.task().getStatus()));
|
||||
boolean allCompleted = contexts.stream().allMatch(context -> "COMPLETED".equals(context.task().getStatus()));
|
||||
if (allCompleted) return contexts.stream().map(context -> task(context.task(), context.plan().getPlanNo())).toList();
|
||||
if (anyCompleted) throw conflict("Container plans are only partially completed; manual reconciliation is required");
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
for (CompletionContext context : contexts) completePlan(customerId, context, now);
|
||||
for (CompletionContext context : contexts) completePlan(context.plan().getCustomerId(), context, now);
|
||||
String sourceContainerBookingId = sourceContainerBookingId(contexts);
|
||||
LocalDateTime photoUploadedAt = contexts.stream().map(context -> context.task().getUnloadPhotoUploadedAt())
|
||||
.max(LocalDateTime::compareTo).orElseThrow();
|
||||
long aggregateVersion = contexts.stream().map(CompletionContext::task)
|
||||
.map(InboundPdaTask::getVersion).filter(Objects::nonNull).mapToLong(Integer::longValue).max().orElse(1L);
|
||||
unloadCompletedOutbox.publishContainerCompleted(customerId, sourceContainerBookingId, containerNo,
|
||||
unloadCompletedOutbox.publishContainerCompleted(sourceContainerBookingId, containerNo,
|
||||
now.toInstant(ZoneOffset.UTC), photoUploadedAt.toInstant(ZoneOffset.UTC), aggregateVersion);
|
||||
return contexts.stream().map(context -> task(context.task(), context.plan().getPlanNo())).toList();
|
||||
}
|
||||
|
||||
+10
-11
@@ -47,7 +47,7 @@ public class InboundUnloadCompletedOutboxService {
|
||||
if (task.getUnloadPhotoUploadedAt() == null) {
|
||||
throw new IllegalStateException("Inbound task has no unload photo upload time");
|
||||
}
|
||||
publishContainerCompleted(task.getCustomerId(), sourceOrder.getSourceContainerBookingId(),
|
||||
publishContainerCompleted(sourceOrder.getSourceContainerBookingId(),
|
||||
plan.getContainerNo(), task.getCompletedAt().toInstant(ZoneOffset.UTC),
|
||||
task.getUnloadPhotoUploadedAt().toInstant(ZoneOffset.UTC),
|
||||
Math.max(1, task.getVersion() == null ? 1 : task.getVersion()));
|
||||
@@ -56,10 +56,9 @@ public class InboundUnloadCompletedOutboxService {
|
||||
/**
|
||||
* 柜级唯一发布入口。调用方只能在同一物理柜的全部计划完成后调用一次;重复调用保留首次冻结事实。
|
||||
*/
|
||||
public void publishContainerCompleted(String customerId, String sourceContainerBookingId,
|
||||
String containerNo, Instant unloadingCompletedAt,
|
||||
Instant unloadingPhotoUploadedAt, long aggregateVersion) {
|
||||
requireText(customerId, "customerId");
|
||||
public void publishContainerCompleted(String sourceContainerBookingId,
|
||||
String containerNo, Instant unloadingCompletedAt,
|
||||
Instant unloadingPhotoUploadedAt, long aggregateVersion) {
|
||||
requireText(sourceContainerBookingId, "sourceContainerBookingId");
|
||||
requireText(containerNo, "containerNo");
|
||||
Objects.requireNonNull(unloadingCompletedAt, "unloadingCompletedAt");
|
||||
@@ -71,7 +70,7 @@ public class InboundUnloadCompletedOutboxService {
|
||||
String eventKey = EventTypes.OVERSEAS_CONTAINER_UNLOADED + ':' + sourceContainerBookingId;
|
||||
LastlegEventOutbox existing = mapper.findByEventKey(eventKey);
|
||||
if (existing != null) {
|
||||
requireSameContainer(existing, customerId, sourceContainerBookingId, containerNo);
|
||||
requireSameContainer(existing, sourceContainerBookingId, containerNo);
|
||||
return;
|
||||
}
|
||||
var payload = new OverseasContainerUnloadedV1(sourceContainerBookingId,
|
||||
@@ -79,12 +78,12 @@ public class InboundUnloadCompletedOutboxService {
|
||||
JsonNode payloadNode = objectMapper.valueToTree(payload);
|
||||
String traceId = EventIdentifiers.newUuid();
|
||||
EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.OVERSEAS_CONTAINER_UNLOADED,
|
||||
1, Instant.now(), RegionCode.OVERSEAS, RegionCode.CN, customerId, "CONTAINER_BOOKING",
|
||||
1, Instant.now(), RegionCode.OVERSEAS, RegionCode.CN, "0", "CONTAINER_BOOKING",
|
||||
sourceContainerBookingId, Math.max(1, aggregateVersion), 1L, traceId, eventKey, null, payloadNode,
|
||||
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode))).requireValid();
|
||||
|
||||
LastlegEventOutbox event = new LastlegEventOutbox();
|
||||
event.setId(IdWorker.getId()); event.setCustomerId(customerId);
|
||||
event.setId(IdWorker.getId()); event.setCustomerId("0");
|
||||
event.setAggregateType(envelope.aggregateType()); event.setAggregateId(envelope.aggregateId());
|
||||
event.setEventType(envelope.eventType()); event.setEventVersion(envelope.eventVersion());
|
||||
event.setEventKey(eventKey); event.setPayloadJson(json(envelope)); event.setStatus("PENDING");
|
||||
@@ -94,17 +93,17 @@ public class InboundUnloadCompletedOutboxService {
|
||||
} catch (DuplicateKeyException exception) {
|
||||
LastlegEventOutbox raced = mapper.findByEventKey(eventKey);
|
||||
if (raced == null) throw exception;
|
||||
requireSameContainer(raced, customerId, sourceContainerBookingId, containerNo);
|
||||
requireSameContainer(raced, sourceContainerBookingId, containerNo);
|
||||
}
|
||||
}
|
||||
|
||||
private void requireSameContainer(LastlegEventOutbox existing, String customerId,
|
||||
private void requireSameContainer(LastlegEventOutbox existing,
|
||||
String sourceContainerBookingId, String containerNo) {
|
||||
try {
|
||||
EventEnvelope envelope = objectMapper.readValue(existing.getPayloadJson(), EventEnvelope.class);
|
||||
OverseasContainerUnloadedV1 frozen = objectMapper.treeToValue(
|
||||
envelope.payload(), OverseasContainerUnloadedV1.class);
|
||||
if (!Objects.equals(existing.getCustomerId(), customerId)
|
||||
if (!"0".equals(existing.getCustomerId())
|
||||
|| !Objects.equals(existing.getAggregateId(), sourceContainerBookingId)
|
||||
|| !Objects.equals(frozen.containerNo(), containerNo)) {
|
||||
throw new IllegalStateException("Unload-completed event key conflicts with another container");
|
||||
|
||||
+115
-90
@@ -59,6 +59,7 @@ import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class ReceivingContainerService {
|
||||
private static final String PLATFORM_SCOPE = "0";
|
||||
private static final String PHOTO_DOWNLOAD_PATH =
|
||||
"/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/{fileId}/download";
|
||||
private static final String PALLET_MERGE_HINT =
|
||||
@@ -125,10 +126,9 @@ public class ReceivingContainerService {
|
||||
}
|
||||
|
||||
public ReceivingContainerResponses.Session detail(String operatorKey, long overseasWarehouseId, String containerNo) {
|
||||
requireReadyContainer(containerNo, overseasWarehouseId, false);
|
||||
List<InboundPlan> containerPlans = requirePlans(containerNo);
|
||||
String customerId = requireSingleCustomer(containerPlans, containerNo);
|
||||
if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw conflict("当前柜已分配至其他海外接柜仓,无法在本仓操作");
|
||||
requireReceivingAvailable(customerId, containerNo);
|
||||
ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey));
|
||||
requireSessionWarehouse(current, overseasWarehouseId);
|
||||
if (current != null && !containerNo.equals(current.getContainerNo())) {
|
||||
@@ -141,7 +141,7 @@ public class ReceivingContainerService {
|
||||
WarehouseResourceGateway.WarehouseReference receivingWarehouse =
|
||||
warehouse.requireActiveSharedWarehouse(overseasWarehouseId);
|
||||
return snapshot(current != null && containerNo.equals(current.getContainerNo()) ? current : waitingView(
|
||||
customerId, operatorKey, receivingWarehouse, containerNo), containerPlans);
|
||||
operatorKey, receivingWarehouse, containerNo), containerPlans);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -158,9 +158,7 @@ public class ReceivingContainerService {
|
||||
return result;
|
||||
}
|
||||
List<InboundPlan> containerPlans = requirePlans(containerNo);
|
||||
String customerId = requireSingleCustomer(containerPlans, containerNo);
|
||||
if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw conflict("当前柜已分配至其他海外接柜仓,无法在本仓操作");
|
||||
requireReceivingAvailable(customerId, containerNo);
|
||||
String activeOperatorKey = activeOperatorKey(operatorKey);
|
||||
ReceivingContainerSession active = sessions.lockActiveByOperator(activeOperatorKey);
|
||||
requireSessionWarehouse(active, overseasWarehouseId);
|
||||
@@ -179,7 +177,7 @@ public class ReceivingContainerService {
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
ReceivingContainerSession session = new ReceivingContainerSession();
|
||||
session.setId(IdWorker.getId()); session.setCustomerId(customerId); session.setContainerNo(containerNo);
|
||||
session.setId(IdWorker.getId()); session.setContainerNo(containerNo);
|
||||
session.setSourceContainerBookingId(readiness.sourceContainerBookingId());
|
||||
session.setContainerFactHash(readiness.factHash());
|
||||
session.setOverseasWarehouseId(overseasWarehouseId);
|
||||
@@ -197,9 +195,8 @@ public class ReceivingContainerService {
|
||||
|
||||
public ReceivingContainerResponses.Photo uploadPhoto(String operatorKey, long operatorId, MultipartFile file) {
|
||||
ReceivingContainerSession session = requireCurrent(operatorKey, true);
|
||||
String customerId = session.getCustomerId();
|
||||
if (!"WAIT_SIGN".equals(session.getStatus())) throw conflict("Unload photos can only be uploaded before signing");
|
||||
UnloadPhotoFileGateway.UploadedFile uploaded = unloadPhotos.upload(customerId, session.getId(), operatorId, file);
|
||||
UnloadPhotoFileGateway.UploadedFile uploaded = unloadPhotos.upload(PLATFORM_SCOPE, session.getId(), operatorId, file);
|
||||
return new ReceivingContainerResponses.Photo(Long.toString(uploaded.fileId()), uploaded.fileName(), uploaded.contentType(), uploaded.sizeBytes());
|
||||
}
|
||||
|
||||
@@ -209,24 +206,24 @@ public class ReceivingContainerService {
|
||||
ReceivingContainerOperation replay = replay(operatorKey, "SIGN", request.idempotencyKey(), request);
|
||||
if (replay != null) return read(replay, ReceivingContainerResponses.Session.class);
|
||||
ReceivingContainerSession session = requireCurrent(operatorKey, true);
|
||||
String customerId = session.getCustomerId();
|
||||
if (!"WAIT_SIGN".equals(session.getStatus())) throw conflict("Container has already been signed");
|
||||
List<ReceivingContainerResponses.SignedPhoto> photos = validateSignPhotos(request);
|
||||
List<Long> photoFileIds = photos.stream().map(photo -> positiveFileId(photo.fileId())).toList();
|
||||
photoFileIds.forEach(fileId -> unloadPhotos.validate(customerId, session.getId(), fileId));
|
||||
photoFileIds.forEach(fileId -> unloadPhotos.validate(PLATFORM_SCOPE, session.getId(), fileId));
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
session.setSignPhotos(write(photos)); session.setStatus("SIGNED"); session.setSignedAt(now);
|
||||
session.setOperatorName(operatorName); session.setUpdatedAt(now); update(session);
|
||||
for (InboundPlan plan : requirePlans(customerId, session.getContainerNo())) {
|
||||
workflow.ensureSignedTask(customerId, plan, operatorKey, operatorName, photoFileIds, now);
|
||||
List<InboundPlan> containerPlans = requirePlans(session.getContainerNo());
|
||||
for (InboundPlan plan : containerPlans) {
|
||||
workflow.ensureSignedTask(plan.getCustomerId(), plan, operatorKey, operatorName, photoFileIds, now);
|
||||
}
|
||||
ReceivingContainerResponses.Session result = snapshot(session, requirePlans(customerId, session.getContainerNo()));
|
||||
ReceivingContainerResponses.Session result = snapshot(session, containerPlans);
|
||||
save(session, "SIGN", request.idempotencyKey(), request, result); return result;
|
||||
}
|
||||
|
||||
public ReceivingContainerResponses.Session current(String operatorKey) {
|
||||
ReceivingContainerSession session = requireCurrent(operatorKey, false);
|
||||
return snapshot(session, requirePlans(session.getCustomerId(), session.getContainerNo()));
|
||||
return snapshot(session, requirePlans(session.getContainerNo()));
|
||||
}
|
||||
|
||||
public UnloadPhotoFileGateway.DownloadedFile downloadCurrentPhoto(String operatorKey, String fileId) {
|
||||
@@ -236,7 +233,7 @@ public class ReceivingContainerService {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
|
||||
"Unload photo is not part of the current receiving session");
|
||||
}
|
||||
return unloadPhotos.download(session.getCustomerId(), session.getId(), parsedFileId);
|
||||
return unloadPhotos.download(PLATFORM_SCOPE, session.getId(), parsedFileId);
|
||||
}
|
||||
|
||||
public void requireCurrentWarehouse(String operatorKey, long overseasWarehouseId) {
|
||||
@@ -246,9 +243,8 @@ public class ReceivingContainerService {
|
||||
public ReceivingContainerResponses.CartonResolved resolveCarton(String operatorKey,
|
||||
ReceivingContainerRequests.CartonResolve request) {
|
||||
ReceivingContainerSession session = requireSigned(operatorKey);
|
||||
String customerId = session.getCustomerId();
|
||||
String inboundCode = cartonInboundCode(request.cartonMark());
|
||||
List<InboundRef> matches = refs(customerId, session.getContainerNo()).stream()
|
||||
List<InboundRef> matches = refs(session.getContainerNo()).stream()
|
||||
.filter(value -> inboundCode.equals(value.line().getInboundCode())).toList();
|
||||
CartonRef carton;
|
||||
if (matches.size() > 1) {
|
||||
@@ -256,9 +252,9 @@ public class ReceivingContainerService {
|
||||
} else if (matches.size() == 1 && "FBA".equals(destinationType(matches.get(0).line(), matches.get(0).order()))) {
|
||||
carton = new CartonRef(matches.get(0));
|
||||
} else if (matches.size() == 1) {
|
||||
carton = requireCartonRef(customerId, session.getContainerNo(), request.cartonMark());
|
||||
carton = requireCartonRef(matches.get(0).order().getCustomerId(), session.getContainerNo(), request.cartonMark());
|
||||
} else {
|
||||
carton = requireUnmatchedFbaCartonPrefix(customerId, inboundCode);
|
||||
carton = requireUnmatchedFbaCartonPrefix(inboundCode);
|
||||
}
|
||||
InboundRef ref = carton.ref();
|
||||
ReceivingContainerResponses.Inbound inbound = inbound(ref);
|
||||
@@ -273,8 +269,7 @@ public class ReceivingContainerService {
|
||||
public ReceivingContainerResponses.PalletResolved resolvePallet(String operatorKey,
|
||||
ReceivingContainerRequests.PalletResolve request) {
|
||||
ReceivingContainerSession session = requireSigned(operatorKey);
|
||||
String customerId = session.getCustomerId();
|
||||
InboundRef ref = requireInbound(customerId, session.getContainerNo(), request.inboundCode());
|
||||
InboundRef ref = requireInbound(session.getContainerNo(), request.inboundCode());
|
||||
PalletSelection selection = resolveReceivingPallet(ref, request.palletCode(), false);
|
||||
WarehouseResourceGateway.WarehousePalletReference pallet = selection.resource();
|
||||
return new ReceivingContainerResponses.PalletResolved(pallet.palletNo(), pallet.warehouseId(), pallet.status(),
|
||||
@@ -287,12 +282,15 @@ public class ReceivingContainerService {
|
||||
ReceivingContainerOperation replay = replay(operatorKey, "BULK_RECEIVE", request.idempotencyKey(), request);
|
||||
if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class);
|
||||
ReceivingContainerSession session = requireSigned(operatorKey);
|
||||
String customerId = session.getCustomerId();
|
||||
InboundRef ref = requireInbound(customerId, session.getContainerNo(), request.inboundCode());
|
||||
InboundRef ref = requireInbound(session.getContainerNo(), request.inboundCode());
|
||||
String customerId = ref.order().getCustomerId();
|
||||
if ("ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) {
|
||||
throw conflict("One-click dropship inbound must be operated by complete source pallet");
|
||||
}
|
||||
if (!"FBA".equals(destinationType(ref.line(), ref.order()))) {
|
||||
throw conflict("OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden");
|
||||
}
|
||||
List<InboundRef> fbaPlanRefs = requireFbaPlanRefs(customerId, session.getContainerNo(), ref);
|
||||
List<InboundRef> fbaPlanRefs = requireFbaPlanRefs(session.getContainerNo(), ref);
|
||||
List<FbaSkuAllocation> skuAllocations = allocateFbaSkuReceipts(customerId, fbaPlanRefs, request.packageCount());
|
||||
skuAllocations.forEach(allocation -> workflow.validateBulkResolved(customerId, allocation.ref().plan(),
|
||||
allocation.ref().line(), allocation.packages()));
|
||||
@@ -328,12 +326,14 @@ public class ReceivingContainerService {
|
||||
ReceivingContainerOperation replay = replay(operatorKey, "CARTON_RECEIVE", request.idempotencyKey(), request);
|
||||
if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class);
|
||||
ReceivingContainerSession session = requireSigned(operatorKey);
|
||||
String customerId = session.getCustomerId();
|
||||
List<InboundRef> currentRefs = refs(customerId, session.getContainerNo());
|
||||
List<InboundRef> currentRefs = refs(session.getContainerNo());
|
||||
if (currentRefs.stream().noneMatch(value ->
|
||||
"OVERSEAS".equals(destinationType(value.line(), value.order())))) {
|
||||
throw conflict("FBA destination must use bulk count");
|
||||
}
|
||||
String inboundCode = cartonInboundCode(request.cartonMark());
|
||||
InboundRef inboundRef = requireInbound(session.getContainerNo(), inboundCode);
|
||||
String customerId = inboundRef.order().getCustomerId();
|
||||
CartonRef carton = requireCartonRef(customerId, session.getContainerNo(), request.cartonMark());
|
||||
InboundRef ref = carton.ref();
|
||||
if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) {
|
||||
@@ -361,18 +361,58 @@ public class ReceivingContainerService {
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ReceivingContainerResponses.Receipt receiveWholePallet(String operatorKey, String operatorName,
|
||||
ReceivingContainerRequests.WholePalletReceive request) {
|
||||
ReceivingContainerOperation replay = replay(operatorKey, "PALLET_RECEIVE", request.idempotencyKey(), request);
|
||||
if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class);
|
||||
ReceivingContainerSession session = requireSigned(operatorKey);
|
||||
List<DropshipPalletRef> matches = new ArrayList<>();
|
||||
for (InboundRef ref : refs(session.getContainerNo())) {
|
||||
if (!"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) continue;
|
||||
String customerId = ref.order().getCustomerId();
|
||||
List<InboundPlanPallet> sourcePallets = pallets.selectList(new LambdaQueryWrapper<InboundPlanPallet>()
|
||||
.eq(InboundPlanPallet::getCustomerId, customerId)
|
||||
.eq(InboundPlanPallet::getPlanId, ref.plan().getId())
|
||||
.eq(InboundPlanPallet::getSourcePalletNo, request.sourcePalletNo()));
|
||||
for (InboundPlanPallet pallet : sourcePallets) {
|
||||
List<InboundPlanPalletAllocation> palletAllocations = allocations.selectList(
|
||||
new LambdaQueryWrapper<InboundPlanPalletAllocation>()
|
||||
.eq(InboundPlanPalletAllocation::getCustomerId, customerId)
|
||||
.eq(InboundPlanPalletAllocation::getPlanId, ref.plan().getId())
|
||||
.eq(InboundPlanPalletAllocation::getLineId, ref.line().getId())
|
||||
.eq(InboundPlanPalletAllocation::getPalletId, pallet.getId()));
|
||||
palletAllocations.forEach(allocation -> matches.add(new DropshipPalletRef(ref, pallet, allocation)));
|
||||
}
|
||||
}
|
||||
if (matches.isEmpty()) throw notFound("Source pallet is not part of the current container");
|
||||
if (matches.size() != 1) throw conflict("Source pallet is duplicated inside the current container");
|
||||
DropshipPalletRef match = matches.get(0);
|
||||
String customerId = match.ref().order().getCustomerId();
|
||||
int accepted = value(match.allocation().getPlannedPackageCount());
|
||||
workflow.pickWholePallet(customerId, match.ref().plan().getPlanNo(), match.allocation().getId(),
|
||||
new PdaRequests.WholePalletOperation(request.idempotencyKey() + ":PICK", operatorKey, operatorName));
|
||||
markReceiving(session);
|
||||
ReceivingContainerResponses.Inbound inbound = inbound(match.ref());
|
||||
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED",
|
||||
session.getContainerNo(), inbound.inboundCode(), inbound.destinationType(), match.pallet().getPalletNo(),
|
||||
accepted, inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus(),
|
||||
inbound.assigned(), inbound.mergeAllowed(), inbound.urgent(), inbound.mergeHint());
|
||||
save(session, "PALLET_RECEIVE", request.idempotencyKey(), request, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Transactional(isolation = Isolation.READ_COMMITTED)
|
||||
public ReceivingContainerResponses.Session complete(String operatorKey,
|
||||
ReceivingContainerRequests.Action request) {
|
||||
ReceivingContainerOperation replay = replay(operatorKey, "COMPLETE", request.idempotencyKey(), request);
|
||||
if (replay != null) return read(replay, ReceivingContainerResponses.Session.class);
|
||||
ReceivingContainerSession session = requireSigned(operatorKey);
|
||||
String customerId = session.getCustomerId();
|
||||
List<InboundPlan> containerPlans = requirePlans(customerId, session.getContainerNo());
|
||||
List<InboundPlan> containerPlans = requirePlans(session.getContainerNo());
|
||||
Totals totals = totals(containerPlans);
|
||||
if (totals.total() != totals.received()) throw conflict("Every inbound code must be fully received before completing the container");
|
||||
validateFbaSkuBalance(customerId, session.getContainerNo());
|
||||
workflow.completeContainer(customerId, containerPlans);
|
||||
validateFbaSkuBalance(session.getContainerNo());
|
||||
workflow.completeContainer(containerPlans);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
session.setStatus("COMPLETED"); session.setCompletedAt(now); session.setUpdatedAt(now);
|
||||
session.setActiveOperatorKey(null); session.setActiveContainerKey(null); update(session);
|
||||
@@ -446,7 +486,6 @@ public class ReceivingContainerService {
|
||||
Set<String> warehouseNames = orderFacts.stream().map(LastlegOrder::getOverseasWarehouseName)
|
||||
.map(ReceivingContainerService::trim).filter(Objects::nonNull).collect(Collectors.toSet());
|
||||
boolean warehouseSnapshotConsistent = warehouseCodes.size() == 1 && warehouseNames.size() == 1;
|
||||
Set<String> customers = orderFacts.stream().map(LastlegOrder::getCustomerId).collect(Collectors.toSet());
|
||||
String containerNo = orderFacts.stream().map(LastlegOrder::getContainerNo).filter(Objects::nonNull).findFirst().orElse(null);
|
||||
int totalOrders = orderFacts.size();
|
||||
int inboundCodes = (int) orderFacts.stream().map(LastlegOrder::getInboundCode).map(ReceivingContainerService::trim)
|
||||
@@ -456,14 +495,13 @@ public class ReceivingContainerService {
|
||||
return values.size() == 1 && plansById.containsKey(values.get(0).getPlanId());
|
||||
}).count();
|
||||
int planCount = matchedPlans.size();
|
||||
boolean allPlansReady = totalOrders > 0 && customers.size() == 1 && inboundCodes == totalOrders
|
||||
boolean allPlansReady = totalOrders > 0 && inboundCodes == totalOrders
|
||||
&& plannedOrders == totalOrders && planCount == totalOrders;
|
||||
List<InboundPlan> containerPlans = planFacts.stream()
|
||||
.filter(plan -> containerNo.equals(plan.getContainerNo())).toList();
|
||||
boolean allPlansCompleted = !containerPlans.isEmpty()
|
||||
&& containerPlans.stream().allMatch(plan -> "COMPLETED".equals(plan.getStatus()));
|
||||
String customerId = customers.size() == 1 ? customers.iterator().next() : null;
|
||||
boolean shippingReady = customerId != null && shippingGates.receivingAvailable(customerId, containerNo) == 1;
|
||||
boolean shippingReady = shippingGates.receivingAvailable(sourceContainerBookingId, containerNo) == 1;
|
||||
boolean operable = allPlansReady && shippingReady && warehouseSnapshotConsistent;
|
||||
int pending = Math.max(0, totalOrders - plannedOrders);
|
||||
String blockReason = operable ? null : (!warehouseSnapshotConsistent
|
||||
@@ -521,42 +559,28 @@ public class ReceivingContainerService {
|
||||
List<InboundPlan> values = plans.selectList(new LambdaQueryWrapper<InboundPlan>()
|
||||
.eq(InboundPlan::getContainerNo, containerNo).orderByAsc(InboundPlan::getId));
|
||||
if (values.isEmpty()) throw notFound("Receiving container was not found");
|
||||
requireSingleCustomer(values, containerNo);
|
||||
if (values.stream().anyMatch(value -> trim(value.getCustomerId()) == null)) {
|
||||
throw conflict("Every inbound plan in the receiving container must have an owning customer");
|
||||
}
|
||||
if (values.stream().allMatch(value -> "COMPLETED".equals(value.getStatus()))) {
|
||||
throw conflict("Receiving container is already completed");
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private List<InboundPlan> requirePlans(String customerId, String containerNo) {
|
||||
List<InboundPlan> values = plansForContainer(customerId, containerNo);
|
||||
if (values.isEmpty()) throw notFound("Receiving container was not found");
|
||||
if (values.stream().allMatch(value -> "COMPLETED".equals(value.getStatus()))) {
|
||||
throw conflict("Receiving container is already completed");
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private List<InboundPlan> plansForContainer(String customerId, String containerNo) {
|
||||
private List<InboundPlan> plansForContainer(String containerNo) {
|
||||
List<InboundPlan> values = plans.selectList(new LambdaQueryWrapper<InboundPlan>()
|
||||
.eq(InboundPlan::getCustomerId, customerId).eq(InboundPlan::getContainerNo, containerNo)
|
||||
.eq(InboundPlan::getContainerNo, containerNo)
|
||||
.orderByAsc(InboundPlan::getId));
|
||||
if (values.isEmpty()) throw notFound("Receiving container was not found");
|
||||
return values;
|
||||
}
|
||||
|
||||
private String requireSingleCustomer(List<InboundPlan> values, String containerNo) {
|
||||
Set<String> customers = values.stream().map(InboundPlan::getCustomerId).map(ReceivingContainerService::trim)
|
||||
.filter(Objects::nonNull).collect(Collectors.toCollection(java.util.LinkedHashSet::new));
|
||||
if (customers.size() != 1 || values.stream().anyMatch(value -> trim(value.getCustomerId()) == null)) {
|
||||
throw conflict("Receiving container must resolve to exactly one customer: " + containerNo);
|
||||
}
|
||||
return customers.iterator().next();
|
||||
}
|
||||
|
||||
private List<InboundRef> refs(String customerId, String containerNo) {
|
||||
private List<InboundRef> refs(String containerNo) {
|
||||
List<InboundRef> result = new ArrayList<>();
|
||||
for (InboundPlan plan : plansForContainer(customerId, containerNo)) {
|
||||
for (InboundPlan plan : plansForContainer(containerNo)) {
|
||||
String customerId = trim(plan.getCustomerId());
|
||||
if (customerId == null) throw conflict("Inbound plan has no owning customer");
|
||||
for (InboundPlanLine line : lines.selectList(new LambdaQueryWrapper<InboundPlanLine>()
|
||||
.eq(InboundPlanLine::getCustomerId, customerId).eq(InboundPlanLine::getPlanId, plan.getId())
|
||||
.orderByAsc(InboundPlanLine::getId))) {
|
||||
@@ -568,8 +592,8 @@ public class ReceivingContainerService {
|
||||
return result;
|
||||
}
|
||||
|
||||
private InboundRef requireInbound(String customerId, String containerNo, String inboundCode) {
|
||||
List<InboundRef> matches = refs(customerId, containerNo).stream()
|
||||
private InboundRef requireInbound(String containerNo, String inboundCode) {
|
||||
List<InboundRef> matches = refs(containerNo).stream()
|
||||
.filter(value -> inboundCode.equals(value.line().getInboundCode())).toList();
|
||||
if (matches.isEmpty()) throw notFound("Inbound code is not part of the current container");
|
||||
Set<Long> planIds = matches.stream().map(value -> value.plan().getId()).collect(Collectors.toSet());
|
||||
@@ -581,7 +605,8 @@ public class ReceivingContainerService {
|
||||
InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper<InboundPlanCarton>()
|
||||
.eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, cartonMark));
|
||||
if (carton == null) throw notFound("Carton mark was not found");
|
||||
List<InboundRef> matches = refs(customerId, containerNo).stream()
|
||||
List<InboundRef> matches = refs(containerNo).stream()
|
||||
.filter(value -> customerId.equals(value.order().getCustomerId()))
|
||||
.filter(value -> carton.getLineId() == null || Objects.equals(value.line().getId(), carton.getLineId()))
|
||||
.filter(value -> carton.getPlanId() == null || Objects.equals(value.plan().getId(), carton.getPlanId()))
|
||||
.filter(value -> Objects.equals(value.line().getOrderId(), carton.getOrderId())).toList();
|
||||
@@ -597,16 +622,15 @@ public class ReceivingContainerService {
|
||||
return inboundCode;
|
||||
}
|
||||
|
||||
private CartonRef requireUnmatchedFbaCartonPrefix(String customerId, String inboundCode) {
|
||||
private CartonRef requireUnmatchedFbaCartonPrefix(String inboundCode) {
|
||||
boolean exists = lines.selectList(new LambdaQueryWrapper<InboundPlanLine>()
|
||||
.eq(InboundPlanLine::getCustomerId, customerId)
|
||||
.eq(InboundPlanLine::getInboundCode, inboundCode)).stream().findAny().isPresent();
|
||||
if (exists) throw conflict("Carton mark belongs to another container");
|
||||
throw notFound("Carton mark was not found");
|
||||
}
|
||||
|
||||
private List<InboundRef> requireFbaPlanRefs(String customerId, String containerNo, InboundRef selected) {
|
||||
List<InboundRef> values = refs(customerId, containerNo).stream()
|
||||
private List<InboundRef> requireFbaPlanRefs(String containerNo, InboundRef selected) {
|
||||
List<InboundRef> values = refs(containerNo).stream()
|
||||
.filter(value -> Objects.equals(value.plan().getId(), selected.plan().getId()))
|
||||
.sorted(Comparator.comparing(value -> value.line().getId())).toList();
|
||||
if (values.isEmpty() || values.stream().anyMatch(value ->
|
||||
@@ -635,11 +659,12 @@ public class ReceivingContainerService {
|
||||
}
|
||||
|
||||
private PalletSelection resolveReceivingPallet(InboundRef ref, String palletCode, boolean requirePlanPallet) {
|
||||
String normalizedPalletCode = palletCode.trim();
|
||||
List<InboundPlanPallet> reserved = pallets.selectList(new LambdaQueryWrapper<InboundPlanPallet>()
|
||||
.eq(InboundPlanPallet::getCustomerId, ref.order().getCustomerId())
|
||||
.eq(InboundPlanPallet::getPlanId, ref.plan().getId())).stream()
|
||||
.filter(value -> sameDestination(ref.line(), value))
|
||||
.filter(value -> palletCode.trim().equals(value.getPalletNo()))
|
||||
.filter(value -> matchesPlannedPalletCode(value, normalizedPalletCode))
|
||||
.filter(this::hasConfirmedReservation)
|
||||
.toList();
|
||||
if (reserved.size() > 1) {
|
||||
@@ -651,11 +676,11 @@ public class ReceivingContainerService {
|
||||
pallet = reserved.get(0);
|
||||
long warehouseId = ref.plan().getOverseasWarehouseId() == null
|
||||
? Long.parseLong(ref.order().getOverseasWarehouseId()) : ref.plan().getOverseasWarehouseId();
|
||||
resource = warehouse.resolveReservedPallet(palletCode.trim(), ref.order().getCustomerId(), warehouseId,
|
||||
resource = warehouse.resolveReservedPallet(pallet.getPalletNo(), ref.order().getCustomerId(), warehouseId,
|
||||
pallet.getWarehousePalletId(), pallet.getWarehouseLocationId(), pallet.getWarehouseResourceLockId());
|
||||
} else {
|
||||
try {
|
||||
resource = warehouse.resolveAvailablePallet(palletCode.trim());
|
||||
resource = warehouse.resolveAvailablePallet(normalizedPalletCode);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw notFound("Pallet was not found");
|
||||
}
|
||||
@@ -666,6 +691,11 @@ public class ReceivingContainerService {
|
||||
return new PalletSelection(pallet, resource);
|
||||
}
|
||||
|
||||
private boolean matchesPlannedPalletCode(InboundPlanPallet pallet, String scannedCode) {
|
||||
return scannedCode.equals(trim(pallet.getPalletNo()))
|
||||
|| scannedCode.equals(trim(pallet.getSourcePalletNo()));
|
||||
}
|
||||
|
||||
private boolean hasConfirmedReservation(InboundPlanPallet pallet) {
|
||||
return pallet.getWarehousePalletId() != null && pallet.getWarehousePalletId() > 0
|
||||
&& pallet.getWarehouseLocationId() != null && pallet.getWarehouseLocationId() > 0
|
||||
@@ -705,8 +735,11 @@ public class ReceivingContainerService {
|
||||
private record PalletSelection(InboundPlanPallet pallet,
|
||||
WarehouseResourceGateway.WarehousePalletReference resource) { }
|
||||
|
||||
private record DropshipPalletRef(InboundRef ref, InboundPlanPallet pallet,
|
||||
InboundPlanPalletAllocation allocation) { }
|
||||
|
||||
private ReceivingContainerResponses.Session snapshot(ReceivingContainerSession session, List<InboundPlan> containerPlans) {
|
||||
List<ReceivingContainerResponses.Inbound> inbounds = refs(session.getCustomerId(), session.getContainerNo()).stream()
|
||||
List<ReceivingContainerResponses.Inbound> inbounds = refs(session.getContainerNo()).stream()
|
||||
.map(this::inbound).toList();
|
||||
int total = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum();
|
||||
int received = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum();
|
||||
@@ -729,7 +762,8 @@ public class ReceivingContainerService {
|
||||
ref.line().getDestinationWarehouseId(), ref.line().getDestinationWarehouseCode(),
|
||||
ref.line().getDestinationWarehouseName(), ref.line().getFbaWarehouseCode(),
|
||||
value(ref.line().getPlannedPalletCount()), total, received, Math.max(0, total - received),
|
||||
assigned(ref.plan()), mergeAllowed(ref.plan()), urgent(ref.plan()), mergeHint(ref.plan()));
|
||||
assigned(ref.plan()), mergeAllowed(ref.plan()), urgent(ref.plan()), mergeHint(ref.plan()),
|
||||
ref.order().getOrderType(), "ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType()));
|
||||
}
|
||||
|
||||
private boolean assigned(InboundPlan plan) {
|
||||
@@ -749,8 +783,8 @@ public class ReceivingContainerService {
|
||||
}
|
||||
|
||||
private Totals totals(List<InboundPlan> containerPlans) {
|
||||
List<ReceivingContainerResponses.Inbound> values = refs(containerPlans.get(0).getCustomerId(),
|
||||
containerPlans.get(0).getContainerNo()).stream().map(this::inbound).toList();
|
||||
List<ReceivingContainerResponses.Inbound> values = refs(containerPlans.get(0).getContainerNo())
|
||||
.stream().map(this::inbound).toList();
|
||||
List<String> destinationCodes = values.stream().map(this::destinationCode)
|
||||
.filter(Objects::nonNull).distinct().sorted().toList();
|
||||
return new Totals(values.size(), destinationCodes,
|
||||
@@ -856,14 +890,15 @@ public class ReceivingContainerService {
|
||||
}
|
||||
|
||||
private void recordSkuReceipt(ReceivingContainerSession session, FbaSkuAllocation sku, InboundPlanPallet pallet) {
|
||||
List<ReceivingSkuReceipt> existing = skuReceipts.lockLineSku(session.getCustomerId(),
|
||||
String customerId = sku.ref().order().getCustomerId();
|
||||
List<ReceivingSkuReceipt> existing = skuReceipts.lockLineSku(customerId,
|
||||
sku.ref().line().getId(), sku.skuCode());
|
||||
ReceivingSkuReceipt receipt = existing.stream()
|
||||
.filter(value -> Objects.equals(value.getPalletId(), pallet.getId())).findFirst().orElse(null);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
if (receipt == null) {
|
||||
receipt = new ReceivingSkuReceipt(); receipt.setId(IdWorker.getId());
|
||||
receipt.setCustomerId(session.getCustomerId()); receipt.setSessionId(session.getId());
|
||||
receipt.setCustomerId(customerId); receipt.setSessionId(session.getId());
|
||||
receipt.setPlanId(sku.ref().plan().getId()); receipt.setLineId(sku.ref().line().getId());
|
||||
receipt.setPalletId(pallet.getId()); receipt.setOrderId(sku.ref().order().getId());
|
||||
receipt.setInboundCode(sku.ref().line().getInboundCode()); receipt.setSkuCode(sku.skuCode());
|
||||
@@ -877,9 +912,11 @@ public class ReceivingContainerService {
|
||||
}
|
||||
}
|
||||
|
||||
private void validateFbaSkuBalance(String customerId, String containerNo) {
|
||||
for (InboundRef ref : refs(customerId, containerNo)) {
|
||||
private void validateFbaSkuBalance(String containerNo) {
|
||||
for (InboundRef ref : refs(containerNo)) {
|
||||
if (!"FBA".equals(destinationType(ref.line(), ref.order()))) continue;
|
||||
if ("ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) continue;
|
||||
String customerId = ref.order().getCustomerId();
|
||||
List<SkuExpectation> skuCodes = skuExpectations(ref);
|
||||
int received = 0;
|
||||
for (SkuExpectation sku : skuCodes) {
|
||||
@@ -914,10 +951,10 @@ public class ReceivingContainerService {
|
||||
if (sessions.updateById(session) != 1) throw conflict("Receiving container session changed concurrently");
|
||||
}
|
||||
|
||||
private ReceivingContainerSession waitingView(String customerId, String operatorKey,
|
||||
private ReceivingContainerSession waitingView(String operatorKey,
|
||||
WarehouseResourceGateway.WarehouseReference warehouse,
|
||||
String containerNo) {
|
||||
ReceivingContainerSession value = new ReceivingContainerSession(); value.setCustomerId(customerId);
|
||||
ReceivingContainerSession value = new ReceivingContainerSession();
|
||||
value.setOverseasWarehouseId(warehouse.warehouseId());
|
||||
value.setOverseasWarehouseCode(warehouse.warehouseCode());
|
||||
value.setOverseasWarehouseName(warehouse.warehouseName());
|
||||
@@ -925,12 +962,6 @@ public class ReceivingContainerService {
|
||||
return value;
|
||||
}
|
||||
|
||||
private boolean eligible(List<InboundPlan> containerPlans, long overseasWarehouseId) {
|
||||
String customerId = requireSingleCustomer(containerPlans, containerPlans.get(0).getContainerNo());
|
||||
return warehouseEligible(containerPlans, overseasWarehouseId)
|
||||
&& shippingGates.receivingAvailable(customerId, containerPlans.get(0).getContainerNo()) == 1;
|
||||
}
|
||||
|
||||
private boolean warehouseEligible(List<InboundPlan> containerPlans, long overseasWarehouseId) {
|
||||
Set<Long> assigned = containerPlans.stream().map(InboundPlan::getOverseasWarehouseId)
|
||||
.filter(Objects::nonNull).collect(Collectors.toSet());
|
||||
@@ -956,12 +987,6 @@ public class ReceivingContainerService {
|
||||
}
|
||||
}
|
||||
|
||||
private void requireReceivingAvailable(String customerId, String containerNo) {
|
||||
if (shippingGates.receivingAvailable(customerId, containerNo) != 1) {
|
||||
throw conflict("Container has not reached PICKUP_READY and is not available for overseas receiving");
|
||||
}
|
||||
}
|
||||
|
||||
private void requireSessionWarehouse(ReceivingContainerSession session, long overseasWarehouseId) {
|
||||
if (session != null && !Objects.equals(session.getOverseasWarehouseId(), overseasWarehouseId)) {
|
||||
throw conflict("Current container belongs to another overseas warehouse");
|
||||
@@ -981,7 +1006,7 @@ public class ReceivingContainerService {
|
||||
private void save(ReceivingContainerSession session, String action, String key, Object request, Object result) {
|
||||
key = idempotencyKey(key);
|
||||
ReceivingContainerOperation value = new ReceivingContainerOperation(); value.setId(IdWorker.getId());
|
||||
value.setSessionId(session.getId()); value.setCustomerId(session.getCustomerId());
|
||||
value.setSessionId(session.getId());
|
||||
value.setOperatorKey(session.getOperatorKey()); value.setAction(action); value.setIdempotencyKey(key);
|
||||
value.setRequestHash(hash(request)); value.setRequestJson(write(request)); value.setResultJson(write(result));
|
||||
value.setCreatedAt(LocalDateTime.now()); operations.insert(value);
|
||||
|
||||
+11
-3
@@ -117,7 +117,7 @@ public class ContainerInboundPlanConversionCoordinator {
|
||||
preparation.warehouseId(), command.getBatchKey(), preparation.items().stream().map(item ->
|
||||
new WarehouseResourceGateway.PalletBatchItemRequest(Long.parseLong(item.customerId()),
|
||||
item.businessKey(), item.planNo(), item.globalSequenceNo(), "STANDARD",
|
||||
item.fbaWarehouseCode())).toList());
|
||||
item.fbaWarehouseCode(), item.palletCode())).toList());
|
||||
WarehouseResourceGateway.PalletBatchResponse reservation;
|
||||
try {
|
||||
reservation = warehouse.ensurePalletBatch(ensure);
|
||||
@@ -207,10 +207,18 @@ public class ContainerInboundPlanConversionCoordinator {
|
||||
.flatMap(group -> group.orders().stream()).filter(item -> "READY".equals(item.readiness())).toList()) {
|
||||
long planId = IdWorker.getId();
|
||||
String planNo = numbers.next("INBOUND_PLAN", order.destinationWarehouseCode(), planId);
|
||||
List<String> sourcePalletNos = plans.sourcePalletNos(
|
||||
order.customerId(), Long.parseLong(order.orderId()), order.orderType());
|
||||
boolean warehouseGeneratedPallets = trimToNull(order.fbaWarehouseCode()) == null
|
||||
&& !"ONE_CLICK_DROPSHIP".equals(order.orderType());
|
||||
if (!warehouseGeneratedPallets && sourcePalletNos.size() != order.suggestedPalletCount()) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "头程托盘码数量与柜级预览不一致");
|
||||
}
|
||||
for (int localSequence = 1; localSequence <= order.suggestedPalletCount(); localSequence++) {
|
||||
items.add(new PreparedItem(order.customerId(), Long.parseLong(order.orderId()), planId, planNo,
|
||||
localSequence, ++globalSequence, "CIP:" + generationId + ":" + globalSequence,
|
||||
trimToNull(order.fbaWarehouseCode())));
|
||||
trimToNull(order.fbaWarehouseCode()), warehouseGeneratedPallets
|
||||
? null : sourcePalletNos.get(localSequence - 1)));
|
||||
}
|
||||
}
|
||||
Preparation preparation = new Preparation(warehouseId, List.copyOf(items));
|
||||
@@ -373,5 +381,5 @@ public class ContainerInboundPlanConversionCoordinator {
|
||||
public record Preparation(long warehouseId, List<PreparedItem> items) { }
|
||||
public record PreparedItem(String customerId, long orderId, long planId, String planNo,
|
||||
int localSequenceNo, int globalSequenceNo, String businessKey,
|
||||
String fbaWarehouseCode) { }
|
||||
String fbaWarehouseCode, String palletCode) { }
|
||||
}
|
||||
|
||||
+8
-2
@@ -22,7 +22,9 @@ import java.util.Map;
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class DestuffingPalletCostService {
|
||||
static final String DESTUFF_BUSINESS_TYPE = "OVERSEAS_CONTAINER";
|
||||
static final String READY_BOOKINGS_SQL = "SELECT o.source_container_booking_id FROM ll_inbound_plan p JOIN ll_order o ON o.id=p.source_order_id JOIN ll_inbound_pda_task t ON t.plan_id=p.id AND t.customer_id=p.customer_id LEFT JOIN ll_destuff_pallet_cost c ON c.source_container_booking_id=o.source_container_booking_id WHERE o.source_container_booking_id IS NOT NULL AND c.id IS NULL GROUP BY o.source_container_booking_id HAVING COUNT(*)=SUM(CASE WHEN t.status='COMPLETED' THEN 1 ELSE 0 END) ORDER BY MAX(t.completed_at) DESC LIMIT 50";
|
||||
static final String RECEIVED_PALLET_IDS_SQL = "SELECT DISTINCT pp.warehouse_pallet_id FROM ll_inbound_plan_pallet pp JOIN ll_inbound_plan p ON p.id=pp.plan_id JOIN ll_order o ON o.id=p.source_order_id WHERE o.source_container_booking_id=? AND pp.warehouse_pallet_id IS NOT NULL AND pp.actual_package_count>0 AND pp.actual_volume_cbm>0 ORDER BY pp.warehouse_pallet_id";
|
||||
private final JdbcTemplate jdbc;
|
||||
private final PalletPhysicalGateway pallets;
|
||||
private final OverseasFeeSourceMapper feeSources;
|
||||
@@ -38,7 +40,7 @@ public class DestuffingPalletCostService {
|
||||
if (exists != null && exists > 0) return;
|
||||
List<Map<String,Object>> plans = jdbc.queryForList("SELECT p.id,p.customer_id,p.container_no,p.overseas_warehouse_id,t.status FROM ll_inbound_plan p JOIN ll_order o ON o.id=p.source_order_id LEFT JOIN ll_inbound_pda_task t ON t.plan_id=p.id AND t.customer_id=p.customer_id WHERE o.source_container_booking_id=? ORDER BY p.customer_id,p.id", bookingId);
|
||||
if (plans.isEmpty() || plans.stream().anyMatch(row -> !"COMPLETED".equals(row.get("status")))) return;
|
||||
List<Long> palletIds = jdbc.queryForList("SELECT DISTINCT pp.warehouse_pallet_id FROM ll_inbound_plan_pallet pp JOIN ll_inbound_plan p ON p.id=pp.plan_id JOIN ll_order o ON o.id=p.source_order_id WHERE o.source_container_booking_id=? AND pp.warehouse_pallet_id IS NOT NULL ORDER BY pp.warehouse_pallet_id", Long.class, bookingId);
|
||||
List<Long> palletIds = jdbc.queryForList(RECEIVED_PALLET_IDS_SQL, Long.class, bookingId);
|
||||
BigDecimal totalCost = BigDecimal.ZERO;
|
||||
for (Long palletId : palletIds) totalCost = totalCost.add(pallets.resolve(palletId).unitPrice());
|
||||
totalCost = totalCost.setScale(2, RoundingMode.HALF_UP);
|
||||
@@ -79,10 +81,14 @@ public class DestuffingPalletCostService {
|
||||
private OverseasFeeSource fee(long costId, String bookingId, String containerNo, Long warehouseId, CustomerVolume volume, BigDecimal amount, String operator) {
|
||||
LocalDateTime now=LocalDateTime.now(ZoneOffset.UTC); OverseasFeeSource v=new OverseasFeeSource();
|
||||
v.setId(IdWorker.getId()); v.setSourceFeeId(IdWorker.getId()); v.setSourceVersion(1); v.setCustomerId(volume.customerId());
|
||||
v.setSourceType("OVERSEAS_CONTAINER_FEE"); v.setSourceNo("DESTUFF-"+bookingId); v.setBusinessType("CONTAINER_DESTUFFING");
|
||||
v.setSourceType("OVERSEAS_CONTAINER_FEE"); v.setSourceNo("DESTUFF-"+bookingId); v.setBusinessType(DESTUFF_BUSINESS_TYPE);
|
||||
v.setBusinessId(costId); v.setBusinessNo("DESTUFF-"+bookingId); v.setContainerBookingId(parseLong(bookingId)); v.setContainerNo(containerNo);
|
||||
v.setWarehouseId(warehouseId); v.setFeeItemCode("DESTUFF_PALLET"); v.setFeeItemName("拆柜打托费"); v.setDirection("RECEIVABLE");
|
||||
v.setPaymentVoucherFileIds(List.of()); v.setBusinessDate(LocalDate.now()); v.setCurrency("CNY"); v.setAmount(amount);
|
||||
v.setOriginalCurrency("CNY"); v.setOriginalAmount(amount); v.setExchangeRate(BigDecimal.ONE.setScale(6));
|
||||
v.setExchangeRateMonth(LocalDate.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM")));
|
||||
v.setChargeableQuantity(volume.volume()); v.setQuantityUnit("CBM");
|
||||
v.setUnitPrice(volume.volume().signum() > 0 ? amount.divide(volume.volume(), 6, RoundingMode.HALF_UP) : null);
|
||||
v.setReviewStatus("APPROVED"); v.setPublicationStatus("PUBLISHED"); v.setSourceStatus("ACTIVE"); v.setReviewerId(operator);
|
||||
v.setReviewedAt(now); v.setCreatedBy(operator); v.setUpdatedBy(operator); v.setCreatedAt(now); v.setUpdatedAt(now); return v;
|
||||
}
|
||||
|
||||
+22
-1
@@ -27,6 +27,7 @@ import com.baiyue.lastleg.dropship.service.DropshipOrderService;
|
||||
import com.baiyue.common.event.ContainerNumbers;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderCommandRecord;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderCommandRecordMapper;
|
||||
import com.baiyue.lastleg.mapper.LastlegSourcePalletMapper;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -65,8 +66,16 @@ public class InboundPlanService {
|
||||
@Resource private DropshipOrderService dropshipOrders;
|
||||
@Resource private WarehouseResourceGateway warehouseGateway;
|
||||
@Resource private LastlegOrderCommandRecordMapper commandMapper;
|
||||
@Resource private LastlegSourcePalletMapper sourcePalletMapper;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
|
||||
public List<String> sourcePalletNos(String customerId, long orderId, String orderType) {
|
||||
return "ONE_CLICK_DROPSHIP".equals(orderType)
|
||||
? dropshipOrders.palletSnapshots(customerId, orderId).stream()
|
||||
.map(DropshipPalletSnapshot::sourcePalletNo).toList()
|
||||
: sourcePalletMapper.palletNos(customerId, orderId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public InboundPlanResponse convertManual(ManualInboundPlanRequest request, String idempotencyKey,
|
||||
String operatorId) {
|
||||
@@ -393,6 +402,10 @@ public class InboundPlanService {
|
||||
PalletAllocator.Result allocation = wholePallets == null && manual == null ? PalletAllocator.allocate(orders.stream()
|
||||
.map(order -> new PalletAllocator.LineInput(order.orderId(), warehouseKey(order), volume(order),
|
||||
order.packageCount(), order.recommendedPalletCount())).toList()) : null;
|
||||
if (allocation != null && sourcePalletNos != null && !sourcePalletNos.isEmpty()
|
||||
&& sourcePalletNos.size() != allocation.pallets().size()) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "头程托盘码数量与尾程计划托盘数不一致");
|
||||
}
|
||||
if (wholePallets != null) validateWholePallets(orders, wholePallets);
|
||||
plan.setPalletCount(manual != null ? manual.palletCount()
|
||||
: wholePallets == null ? allocation.pallets().size() : wholePallets.size());
|
||||
@@ -479,6 +492,7 @@ public class InboundPlanService {
|
||||
List<Integer> pendingIndexes = new ArrayList<>();
|
||||
List<List<SourceOrderSnapshot>> snapshots = new ArrayList<>();
|
||||
List<List<WholePalletInput>> wholePallets = new ArrayList<>();
|
||||
List<List<String>> standardPalletNos = new ArrayList<>();
|
||||
for (int index = 0; index < refs.size(); index++) {
|
||||
InboundPlanOrderMapper.ContainerOrderRef ref = refs.get(index);
|
||||
InboundPlan existing = existingPlans.get(index);
|
||||
@@ -517,6 +531,13 @@ public class InboundPlanService {
|
||||
validateWholePallets(orders, sourcePallets);
|
||||
}
|
||||
wholePallets.add(sourcePallets);
|
||||
List<String> standardNos = "ONE_CLICK_DROPSHIP".equals(ref.orderType()) ? List.of()
|
||||
: sourcePalletMapper.palletNos(ref.customerId(), ref.orderId());
|
||||
boolean standardFba = sourcePallets == null && orders.stream()
|
||||
.anyMatch(order -> trimToNull(order.fbaWarehouseCode()) != null);
|
||||
if (standardFba && standardNos.isEmpty()) throw new ResponseStatusException(
|
||||
HttpStatus.CONFLICT, "散货订单缺少头程托盘码快照");
|
||||
standardPalletNos.add(standardNos);
|
||||
}
|
||||
requireSingleWarehouseId(warehouseIds);
|
||||
|
||||
@@ -531,7 +552,7 @@ public class InboundPlanService {
|
||||
Map<Integer, WarehouseResourceGateway.PalletBatchItem> reserved = prepared == null ? Map.of()
|
||||
: prepared.pallets().stream().collect(Collectors.toMap(PreparedPallet::sequenceNo,
|
||||
PreparedPallet::reservation));
|
||||
created.put(refIndex, createPlan(ref.customerId(), ref.orderId(), snapshots.get(pendingIndex), List.of(),
|
||||
created.put(refIndex, createPlan(ref.customerId(), ref.orderId(), snapshots.get(pendingIndex), standardPalletNos.get(pendingIndex),
|
||||
wholePallets.get(pendingIndex), null, null, prepared, reserved));
|
||||
}
|
||||
List<InboundPlanResponse> result = new ArrayList<>(refs.size());
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
package com.baiyue.lastleg.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderFee;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface LastlegOrderFeeMapper extends BaseMapper<LastlegOrderFee> {
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package com.baiyue.lastleg.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface LastlegSourcePalletMapper {
|
||||
@Insert("""
|
||||
INSERT INTO ll_order_source_pallet
|
||||
(id,customer_id,order_id,source_pallet_no,sequence_no,source_event_id,created_at)
|
||||
VALUES (#{id},#{customerId},#{orderId},#{palletNo},#{sequenceNo},#{eventId},CURRENT_TIMESTAMP(3))
|
||||
""")
|
||||
int insert(@Param("id") long id, @Param("customerId") String customerId,
|
||||
@Param("orderId") long orderId, @Param("palletNo") String palletNo,
|
||||
@Param("sequenceNo") int sequenceNo, @Param("eventId") String eventId);
|
||||
|
||||
@Select("""
|
||||
SELECT source_pallet_no FROM ll_order_source_pallet
|
||||
WHERE customer_id=#{customerId} AND order_id=#{orderId}
|
||||
ORDER BY sequence_no,id
|
||||
""")
|
||||
List<String> palletNos(@Param("customerId") String customerId, @Param("orderId") long orderId);
|
||||
}
|
||||
+1
-1
@@ -52,7 +52,7 @@ public interface PdaWarehouseMapper extends BaseMapper<PdaOperationHistory> {
|
||||
"FROM ll_pda_operation_history h WHERE h.overseas_warehouse_id=#{warehouseId} AND h.operator_key=#{operatorKey} " +
|
||||
"<if test='operationType != null and operationType != \"\"'>AND h.operation_type=#{operationType} </if>" +
|
||||
"UNION ALL SELECT o.id,CONCAT('RECEIVING_',o.action),s.container_no,'COMPLETED'," +
|
||||
"CONCAT('柜号作业 ',s.container_no),s.customer_id,o.created_at " +
|
||||
"CONCAT('柜号作业 ',s.container_no),NULL customer_id,o.created_at " +
|
||||
"FROM ll_receiving_container_operation o JOIN ll_receiving_container_session s ON s.id=o.session_id " +
|
||||
"WHERE s.overseas_warehouse_id=#{warehouseId} AND o.operator_key=#{operatorKey} " +
|
||||
"<if test='operationType != null and operationType != \"\"'>AND CONCAT('RECEIVING_',o.action)=#{operationType} </if>" +
|
||||
|
||||
+21
@@ -16,6 +16,8 @@ import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFeeMapper;
|
||||
import com.baiyue.lastleg.procurement.gateway.ProcurementFileGateway;
|
||||
import com.baiyue.lastleg.wallet.WalletCommandOutboxService;
|
||||
import com.baiyue.lastleg.wallet.WalletResultHandler;
|
||||
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos;
|
||||
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
|
||||
import com.baiyue.common.event.WalletCommandCompletedV1;
|
||||
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper;
|
||||
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementStatusHistoryMapper;
|
||||
@@ -33,7 +35,9 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -62,6 +66,7 @@ public class OverseasProcurementService implements WalletResultHandler {
|
||||
@Resource private OverseasProcurementFeeMapper procurementFees;
|
||||
@Resource private ProcurementFileGateway fileGateway;
|
||||
@Resource private WalletCommandOutboxService walletCommands;
|
||||
@Resource private OverseasFeeSourceService feeSources;
|
||||
|
||||
@Transactional
|
||||
public OverseasProcurementResponses.Detail create(String customerId, String operatorId,
|
||||
@@ -220,6 +225,7 @@ public class OverseasProcurementService implements WalletResultHandler {
|
||||
if ("FREEZE".equals(result.operation()) && "SUCCEEDED".equals(result.result())) {
|
||||
OverseasProcurementOrder order = orders.selectById(row.getOrderId());
|
||||
if (order != null && order.getInboundQuantity() >= order.getPurchaseQuantity()) {
|
||||
publishFeeSource(order, row);
|
||||
walletCommands.publish("SETTLE", result.customerId(), "PROCUREMENT_FEE", row.getFeeNo(), null,
|
||||
"海外采购完成入库,结算费用", 2L);
|
||||
row.setWalletStatus("SETTLE_PENDING"); procurementFees.updateById(row);
|
||||
@@ -313,6 +319,7 @@ public class OverseasProcurementService implements WalletResultHandler {
|
||||
if (order.getInboundQuantity() >= order.getPurchaseQuantity()) {
|
||||
for (OverseasProcurementFee fee : procurementFees.selectList(new LambdaQueryWrapper<OverseasProcurementFee>()
|
||||
.eq(OverseasProcurementFee::getOrderId, order.getId()).eq(OverseasProcurementFee::getWalletStatus, "FROZEN"))) {
|
||||
publishFeeSource(order, fee);
|
||||
walletCommands.publish("SETTLE", Long.parseLong(customerId), "PROCUREMENT_FEE", fee.getFeeNo(), null,
|
||||
"海外采购完成入库,结算费用", 2L);
|
||||
fee.setWalletStatus("SETTLE_PENDING"); fee.setUpdatedAt(now); procurementFees.updateById(fee);
|
||||
@@ -321,6 +328,16 @@ public class OverseasProcurementService implements WalletResultHandler {
|
||||
return detail(customerId, orderNo);
|
||||
}
|
||||
|
||||
private void publishFeeSource(OverseasProcurementOrder order, OverseasProcurementFee fee) {
|
||||
feeSources.publishApproved(StringUtils.hasText(fee.getCreatedBy()) ? fee.getCreatedBy() : "SYSTEM", "PROCUREMENT:" + fee.getFeeNo(),
|
||||
new OverseasFeeSourceDtos.SaveRequest(order.getCustomerId(), order.getCustomerName(),
|
||||
"OVERSEAS_PROCUREMENT_FEE", "PROCUREMENT_FEE", fee.getId(), fee.getFeeNo(),
|
||||
order.getId(), null, null, null, null, null, parseLong(order.getPurchaseWarehouseId()),
|
||||
fee.getFeeItemCode(), fee.getFeeItemName(), "RECEIVABLE", null, null, null, null,
|
||||
null, null, null, List.of(), LocalDate.now(), fee.getCurrency(), fee.getAmount(),
|
||||
BigDecimal.ONE, "ITEM", fee.getAmount(), null, null));
|
||||
}
|
||||
|
||||
private OverseasProcurementResponses.Detail replay(String customerId, OverseasProcurementOrder order, String hash) {
|
||||
if (!hash.equals(order.getRequestHash())) {
|
||||
throw conflict("Idempotency key was reused with different procurement data");
|
||||
@@ -491,6 +508,10 @@ public class OverseasProcurementService implements WalletResultHandler {
|
||||
}
|
||||
|
||||
private static long value(Long value) { return value == null ? 0 : value; }
|
||||
private static Long parseLong(String value) {
|
||||
try { return StringUtils.hasText(value) ? Long.parseLong(value.trim()) : null; }
|
||||
catch (NumberFormatException ignored) { return null; }
|
||||
}
|
||||
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
|
||||
private static String defaultCurrency(String value) { return trim(value) == null ? "USD" : value.trim().toUpperCase(); }
|
||||
private static void setIfText(String value, java.util.function.Consumer<String> setter) {
|
||||
|
||||
+22
-3
@@ -6,6 +6,8 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
|
||||
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos;
|
||||
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
|
||||
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
|
||||
@@ -45,9 +47,11 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -84,6 +88,7 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
@Resource private OutboundOrderMapper outboundOrders;
|
||||
@Resource private OutboundPalletItemMapper outboundItems;
|
||||
@Resource private OutboundPdaTaskMapper outboundTasks;
|
||||
@Resource private OverseasFeeSourceService feeSources;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public String customerId(String relabelNo) { return require(null, relabelNo).getCustomerId(); }
|
||||
@@ -228,7 +233,6 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
balance.getProductNameZh(), available, value(balance.getVersion()), positions);
|
||||
}).filter(candidate -> candidate.availablePackages() > 0).toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public RelabelResponses.Detail create(String customerId, String operatorId, RelabelRequests.Create request) {
|
||||
if (!StringUtils.hasText(request.operationWarehouseId())) throw badRequest("Operation warehouse is required");
|
||||
@@ -401,11 +405,24 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
application.setStatus(next);
|
||||
application.setUpdatedAt(now);
|
||||
applications.updateById(application);
|
||||
List<LastlegOrder> linkedOrders = tailOrders(application);
|
||||
if (linkedOrders.isEmpty()) throw conflict("Linked tail-leg order is missing");
|
||||
if (CUSTOMER_CONFIRM_PENDING.equals(next)) {
|
||||
application.setFulfillmentStatus("WAIT_CUSTOMER_CONFIRMATION");
|
||||
applications.updateById(application);
|
||||
} else if ("COMPLETED".equals(next)) {
|
||||
if (!StringUtils.hasText(application.getOutboundOrderNo())) releaseInventory(customerId, operatorId, application);
|
||||
BigDecimal quantity = BigDecimal.valueOf(Objects.requireNonNullElse(application.getTotalPackageCount(), 0));
|
||||
feeSources.publishApproved(operatorId, "RELABEL:" + relabelNo,
|
||||
new OverseasFeeSourceDtos.SaveRequest(customerId, linkedOrders.get(0).getCustomerName(),
|
||||
"OVERSEAS_RELABEL_FEE", "RELABEL_ORDER", application.getId(), relabelNo,
|
||||
application.getLastlegOrderId(), null, null, null, null, null,
|
||||
parseLong(application.getOverseasWarehouseId()), "RELABEL_SERVICE", "换标费", "RECEIVABLE",
|
||||
null, null, null, null, null, null, null, List.of(), LocalDate.now(),
|
||||
application.getChargeCurrency(), application.getChargeAmount(),
|
||||
quantity.signum() > 0 ? quantity : null, quantity.signum() > 0 ? "BOX" : null,
|
||||
quantity.signum() > 0 ? application.getChargeAmount().divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null,
|
||||
null, null));
|
||||
walletCommands.publish("SETTLE", Long.parseLong(customerId), "RELABEL_ORDER", relabelNo, null,
|
||||
"换标完成,结算费用", 2L);
|
||||
application.setWalletStatus("SETTLE_PENDING"); applications.updateById(application);
|
||||
@@ -417,8 +434,6 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
application.setWalletStatus("RELEASE_PENDING"); applications.updateById(application);
|
||||
}
|
||||
}
|
||||
List<LastlegOrder> linkedOrders = tailOrders(application);
|
||||
if (linkedOrders.isEmpty()) throw conflict("Linked tail-leg order is missing");
|
||||
for (LastlegOrder order : linkedOrders) {
|
||||
String orderBefore = order.getCurrentStatus();
|
||||
String mainStatus = STATUSES.contains(orderBefore) ? "PENDING_ASSIGNMENT" : orderBefore;
|
||||
@@ -753,6 +768,10 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
.digest(objectMapper.copy().findAndRegisterModules().writeValueAsString(value).getBytes(StandardCharsets.UTF_8))); }
|
||||
catch (Exception exception) { throw new IllegalStateException(exception); }
|
||||
}
|
||||
private static Long parseLong(String value) {
|
||||
try { return StringUtils.hasText(value) ? Long.parseLong(value.trim()) : null; }
|
||||
catch (NumberFormatException ignored) { return null; }
|
||||
}
|
||||
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
|
||||
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
|
||||
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||
|
||||
@@ -15,6 +15,8 @@ public final class OverseasPermissions {
|
||||
public static final String RELABEL_ORDER_LIST = "platform:overseas-warehouse:relabel-order:list";
|
||||
public static final String RELABEL_ORDER_CREATE = "platform:overseas-warehouse:relabel-order:create";
|
||||
public static final String RELABEL_ORDER_STATUS = "platform:overseas-warehouse:relabel-order:status";
|
||||
public static final String RETURN_INVENTORY_LIST = "platform:overseas-warehouse:return-inventory:list";
|
||||
public static final String RETURN_CARTONS_LIST = "platform:overseas-warehouse:return-cartons:list";
|
||||
public static final String INVENTORY_LIST = "platform:overseas-warehouse:inventory:list";
|
||||
public static final String INVENTORY_EXPORT = "platform:overseas-warehouse:inventory:export";
|
||||
public static final String INVENTORY_EDIT = "platform:overseas-warehouse:inventory:edit";
|
||||
|
||||
@@ -14,7 +14,6 @@ import com.baiyue.lastleg.dto.ContainerPlanProgressSummary;
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderDetail;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderFee;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderOperationLog;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderManualNode;
|
||||
import com.baiyue.lastleg.entity.ReceivingWarehouseOperation;
|
||||
@@ -25,7 +24,9 @@ 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.mapper.LastlegOrderFeeMapper;
|
||||
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;
|
||||
@@ -64,7 +65,7 @@ public class LastlegOrderService {
|
||||
@Resource private LastlegOrderMapper orderMapper;
|
||||
@Resource private LastlegOrderDetailMapper detailMapper;
|
||||
@Resource private LastlegOrderStatusHistoryMapper historyMapper;
|
||||
@Resource private LastlegOrderFeeMapper feeMapper;
|
||||
@Resource private OverseasFeeSourceService feeSources;
|
||||
@Resource private LastlegOrderOperationLogMapper operationLogMapper;
|
||||
@Resource private LastlegOrderManualNodeMapper manualNodeMapper;
|
||||
@Resource private InboundPlanCartonMapper cartonMapper;
|
||||
@@ -246,9 +247,7 @@ public class LastlegOrderService {
|
||||
.eq(LastlegOrderStatusHistory::getCustomerId, order.getCustomerId())
|
||||
.eq(LastlegOrderStatusHistory::getOrderId, orderId)
|
||||
.orderByAsc(LastlegOrderStatusHistory::getCreatedAt));
|
||||
List<LastlegOrderFee> fees = feeMapper.selectList(new LambdaQueryWrapper<LastlegOrderFee>()
|
||||
.eq(LastlegOrderFee::getCustomerId, order.getCustomerId())
|
||||
.eq(LastlegOrderFee::getOrderId, orderId).orderByAsc(LastlegOrderFee::getCreatedAt));
|
||||
List<OverseasFeeSource> fees = feeSources.listForBusiness(order.getCustomerId(), "LASTLEG_ORDER", orderId);
|
||||
List<LastlegOrderOperationLog> operations = operationLogMapper.selectList(
|
||||
new LambdaQueryWrapper<LastlegOrderOperationLog>()
|
||||
.eq(LastlegOrderOperationLog::getCustomerId, order.getCustomerId())
|
||||
@@ -270,24 +269,21 @@ public class LastlegOrderService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public LastlegOrderFee createFee(String customerId, long orderId, LastlegOrderRequests.CreateFee request,
|
||||
String operatorName) {
|
||||
public OverseasFeeSource createFee(String customerId, long orderId, LastlegOrderRequests.CreateFee request,
|
||||
String operatorName, String idempotencyKey) {
|
||||
LastlegOrder order = findOwned(customerId, orderId);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LastlegOrderFee fee = new LastlegOrderFee();
|
||||
fee.setId(IdWorker.getId());
|
||||
fee.setCustomerId(order.getCustomerId());
|
||||
fee.setOrderId(orderId);
|
||||
fee.setFeeItem(request.feeItem().trim());
|
||||
fee.setCurrency(request.currency());
|
||||
fee.setAmount(request.amount());
|
||||
fee.setStatus(request.status());
|
||||
fee.setSupplierDescription(StringUtils.hasText(request.supplierDescription())
|
||||
? request.supplierDescription().trim() : null);
|
||||
fee.setCreatedBy(operatorName);
|
||||
fee.setCreatedAt(now);
|
||||
fee.setUpdatedAt(now);
|
||||
feeMapper.insert(fee);
|
||||
OverseasFeeSource fee = feeSources.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.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()));
|
||||
fee = feeSources.submit(fee.getSourceFeeId(), operatorName, childKey(idempotencyKey, "submit"));
|
||||
|
||||
LastlegOrderOperationLog operation = new LastlegOrderOperationLog();
|
||||
operation.setId(IdWorker.getId());
|
||||
@@ -295,7 +291,8 @@ public class LastlegOrderService {
|
||||
operation.setOrderId(orderId);
|
||||
operation.setOperationNode("新增费用");
|
||||
operation.setOperatorName(operatorName);
|
||||
operation.setResult("新增" + fee.getFeeItem() + ":" + fee.getCurrency() + " " + fee.getAmount().toPlainString());
|
||||
operation.setResult("新增" + fee.getFeeItemName() + ":" + fee.getOriginalCurrency() + " "
|
||||
+ fee.getOriginalAmount().toPlainString() + ",已提交财务审核");
|
||||
operation.setCreatedAt(now);
|
||||
operationLogMapper.insert(operation);
|
||||
return fee;
|
||||
@@ -585,6 +582,17 @@ public class LastlegOrderService {
|
||||
? null : order.getInboundCode();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private static String csv(Object value) {
|
||||
if (value == null) return "";
|
||||
String text = String.valueOf(value);
|
||||
|
||||
+16
-1
@@ -45,6 +45,7 @@ public class LoadingCompletedConsumer {
|
||||
@Resource private DropshipOrderService dropshipOrders;
|
||||
@Resource private InventoryCommandService inventory;
|
||||
@Resource private ContainerShippingGateMapper shippingGates;
|
||||
@Resource private LastlegSourcePalletMapper sourcePallets;
|
||||
|
||||
@Transactional(isolation = Isolation.READ_COMMITTED)
|
||||
public int consume(EventEnvelope envelope) {
|
||||
@@ -116,7 +117,7 @@ public class LoadingCompletedConsumer {
|
||||
|
||||
private void freezeContainerIdentity(EventEnvelope envelope, String sourceContainerBookingId,
|
||||
String containerNo) {
|
||||
shippingGates.initialize(sourceContainerBookingId, envelope.tenantId(), "",
|
||||
shippingGates.initialize(sourceContainerBookingId, "",
|
||||
containerNo, 0, envelope.handoverVersion());
|
||||
ContainerShippingGate frozen = shippingGates.lock(sourceContainerBookingId);
|
||||
if (frozen == null) {
|
||||
@@ -278,6 +279,20 @@ public class LoadingCompletedConsumer {
|
||||
|
||||
Map<String, List<String>> dropshipLabels = "ONE_CLICK_DROPSHIP".equals(orderType)
|
||||
&& "OVERSEAS".equals(destinationType) ? dropshipReceivingLabels(source, rawSource) : Map.of();
|
||||
if ("FIRST_LEG".equals(orderType) && event.eventVersion() >= 2) {
|
||||
List<FirstLegLoadingPlanCompletedV1.PalletSnapshot> pallets = source.pallets() == null ? List.of() : source.pallets();
|
||||
Set<String> palletNos = new HashSet<>();
|
||||
for (int palletIndex = 0; palletIndex < pallets.size(); palletIndex++) {
|
||||
String palletNo = pallets.get(palletIndex).palletNo();
|
||||
if (palletNo == null || palletNo.isBlank() || palletNo.length() > 128 || !palletNos.add(palletNo)) {
|
||||
throw new IllegalArgumentException("散货来源托盘码为空、重复或过长");
|
||||
}
|
||||
sourcePallets.insert(IdWorker.getId(), customerId, order.getId(), palletNo.trim(), palletIndex + 1, event.eventId());
|
||||
}
|
||||
if (!pallets.isEmpty() && order.getPalletCount() > 0 && pallets.size() != order.getPalletCount()) {
|
||||
throw new IllegalArgumentException("散货来源托盘码数量与托盘数不一致");
|
||||
}
|
||||
}
|
||||
for(var item:source.items()) {
|
||||
LastlegOrderDetail detail=new LastlegOrderDetail();
|
||||
detail.setCustomerId(customerId);
|
||||
|
||||
-1
@@ -12,7 +12,6 @@ import java.time.LocalDateTime;
|
||||
public class ContainerShippingGate {
|
||||
@TableId(type = IdType.INPUT)
|
||||
private String containerBookingId;
|
||||
private String customerId;
|
||||
private String oceanBookingId;
|
||||
private String containerNo;
|
||||
private String status;
|
||||
|
||||
+8
-9
@@ -12,18 +12,17 @@ public interface ContainerShippingGateMapper extends BaseMapper<ContainerShippin
|
||||
@Select("SELECT * FROM ll_container_shipping_gate WHERE container_booking_id=#{containerBookingId} FOR UPDATE")
|
||||
ContainerShippingGate lock(@Param("containerBookingId") String containerBookingId);
|
||||
|
||||
@Insert("INSERT IGNORE INTO ll_container_shipping_gate(container_booking_id,customer_id,ocean_booking_id," +
|
||||
@Insert("INSERT IGNORE INTO ll_container_shipping_gate(container_booking_id,ocean_booking_id," +
|
||||
"container_no,status,node_sequence,aggregate_version,handover_version,created_at,updated_at) VALUES(" +
|
||||
"#{containerBookingId},#{customerId},#{oceanBookingId},#{containerNo},'AWAITING_PICKUP_READY',0," +
|
||||
"#{containerBookingId},#{oceanBookingId},#{containerNo},'AWAITING_PICKUP_READY',0," +
|
||||
"#{aggregateVersion},#{handoverVersion},NOW(3),NOW(3))")
|
||||
int initialize(@Param("containerBookingId") String containerBookingId, @Param("customerId") String customerId,
|
||||
int initialize(@Param("containerBookingId") String containerBookingId,
|
||||
@Param("oceanBookingId") String oceanBookingId, @Param("containerNo") String containerNo,
|
||||
@Param("aggregateVersion") long aggregateVersion, @Param("handoverVersion") long handoverVersion);
|
||||
|
||||
@Select("SELECT CASE WHEN COUNT(DISTINCT o.source_container_booking_id)=1 " +
|
||||
"AND MIN(CASE WHEN g.status='RECEIVING_AVAILABLE' THEN 1 ELSE 0 END)=1 THEN 1 ELSE 0 END " +
|
||||
"FROM ll_order o LEFT JOIN ll_container_shipping_gate g " +
|
||||
"ON g.container_booking_id=o.source_container_booking_id AND g.customer_id=o.customer_id " +
|
||||
"WHERE o.customer_id=#{customerId} AND o.container_no=#{containerNo}")
|
||||
int receivingAvailable(@Param("customerId") String customerId, @Param("containerNo") String containerNo);
|
||||
@Select("SELECT CASE WHEN COUNT(*)=1 AND MIN(CASE WHEN status='RECEIVING_AVAILABLE' THEN 1 ELSE 0 END)=1 " +
|
||||
"THEN 1 ELSE 0 END FROM ll_container_shipping_gate " +
|
||||
"WHERE container_booking_id=#{containerBookingId} AND container_no=#{containerNo}")
|
||||
int receivingAvailable(@Param("containerBookingId") String containerBookingId,
|
||||
@Param("containerNo") String containerNo);
|
||||
}
|
||||
|
||||
+4
-6
@@ -49,11 +49,10 @@ public class ContainerShippingNodeConsumer {
|
||||
return 1;
|
||||
}
|
||||
|
||||
gates.initialize(payload.containerBookingId(), payload.customerId(), payload.oceanBookingId(),
|
||||
gates.initialize(payload.containerBookingId(), payload.oceanBookingId(),
|
||||
payload.containerNo(), 0, 1);
|
||||
ContainerShippingGate gate = gates.lock(payload.containerBookingId());
|
||||
if (gate == null || !payload.customerId().equals(gate.getCustomerId())
|
||||
|| !payload.containerNo().equals(gate.getContainerNo())) {
|
||||
if (gate == null || !payload.containerNo().equals(gate.getContainerNo())) {
|
||||
throw new IllegalArgumentException("船节点柜号快照与装柜交接不一致");
|
||||
}
|
||||
apply(gate, envelope, payload);
|
||||
@@ -108,9 +107,8 @@ public class ContainerShippingNodeConsumer {
|
||||
}
|
||||
|
||||
private void validateEnvelopePayload(EventEnvelope envelope, FirstLegContainerShippingNodeRecordedV1 payload) {
|
||||
if (!envelope.tenantId().equals(payload.customerId())
|
||||
|| !envelope.aggregateId().equals(payload.containerBookingId())) {
|
||||
throw new IllegalArgumentException("船节点交接客户或聚合标识不一致");
|
||||
if (!"0".equals(envelope.tenantId()) || !envelope.aggregateId().equals(payload.containerBookingId())) {
|
||||
throw new IllegalArgumentException("船节点交接平台作用域或聚合标识不一致");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user