暂存提交

This commit is contained in:
2026-08-19 16:24:02 +08:00
parent f5c4bf130a
commit 6acb6178da
354 changed files with 25000 additions and 198 deletions
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.discard;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan("com.baiyue.lastleg.discard.mapper")
public class DiscardMapperConfiguration {
}
@@ -0,0 +1,84 @@
package com.baiyue.lastleg.discard.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.discard.dto.CargoDiscardRequests;
import com.baiyue.lastleg.discard.dto.CargoDiscardResponses;
import com.baiyue.lastleg.discard.service.CargoDiscardService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@RestController
@RequestMapping("/api/platform/v1/last-leg/cargo-discards")
public class CargoDiscardController {
@Resource private CargoDiscardService service;
@GetMapping("/inventory-candidates")
public ApiResponse<List<CargoDiscardResponses.Candidate>> candidates(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@RequestParam(required = false) String inboundCode) {
return ApiResponse.success(service.candidates(scope(user, customerId), inboundCode));
}
@PostMapping
public ApiResponse<CargoDiscardResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @Valid @RequestBody CargoDiscardRequests.Create request) {
return ApiResponse.success(service.create(scope(user, customerId), operator(user), request));
}
@GetMapping
public ApiResponse<PageResult<CargoDiscardResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @Valid CargoDiscardRequests.Query query) {
return ApiResponse.success(service.page(scope(user, customerId), query));
}
@GetMapping("/{discardNo}")
public ApiResponse<CargoDiscardResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @PathVariable String discardNo) {
return ApiResponse.success(service.detail(scope(user, customerId), discardNo));
}
@PostMapping("/{discardNo}/review")
public ApiResponse<CargoDiscardResponses.Detail> review(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @PathVariable String discardNo,
@Valid @RequestBody CargoDiscardRequests.Review request) {
requireApprovalPermission(user);
return ApiResponse.success(service.review(scope(user, customerId), discardNo, operator(user), request));
}
private String scope(AuthenticatedUser user, String requested) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
if (requested == null || requested.isBlank() || "0".equals(requested)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant");
}
return requested.trim();
}
return String.valueOf(user.tenantId());
}
private void requireApprovalPermission(AuthenticatedUser user) {
if (user == null || user.permissions() == null
|| !user.permissions().contains(CargoDiscardService.APPROVAL_PERMISSION)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Cargo discard approval permission is required");
}
}
private String operator(AuthenticatedUser user) {
return user.userId() == null ? user.username() : String.valueOf(user.userId());
}
}
@@ -0,0 +1,30 @@
package com.baiyue.lastleg.discard.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.List;
public final class CargoDiscardRequests {
private CargoDiscardRequests() {}
public record Create(@NotEmpty @Size(max = 200) List<@Valid Item> items,
@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark) {}
public record Item(@NotNull Long orderId, @NotBlank @Size(max = 128) String skuCode,
@Positive int discardPackages) {}
public record Review(@NotBlank String decision, @Size(max = 1000) String remark) {}
public record Query(@Min(1) long page, @Min(1) @Max(200) long size,
String discardNo, String inboundCode, String overseasWarehouseId,
String status, LocalDateTime createdFrom, LocalDateTime createdTo) {}
}
@@ -0,0 +1,30 @@
package com.baiyue.lastleg.discard.dto;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
public final class CargoDiscardResponses {
private CargoDiscardResponses() {}
public record Candidate(Long orderId, String inboundCode, String inboundOrderNo,
String overseasWarehouseId, String overseasWarehouseName,
LocalDateTime inboundCreatedAt, long availablePackages,
List<CandidateSku> skus) {}
public record CandidateSku(String skuCode, String productName, long availablePackages) {}
public record Summary(Long id, String discardNo, String inboundCodes,
String overseasWarehouseId, String overseasWarehouseName,
int totalPackages, BigDecimal totalFee, String currency, String status,
String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt) {}
public record Item(Long id, Long orderId, String inboundCode, String skuCode,
String productName, int availablePackagesSnapshot, int discardPackages) {}
public record History(String fromStatus, String toStatus, String operatorId,
String remark, LocalDateTime createdAt) {}
public record Detail(Summary summary, BigDecimal feePerPackage, String remark,
String reviewedBy, String reviewRemark, List<Item> items,
List<History> histories) {}
}
@@ -0,0 +1,34 @@
package com.baiyue.lastleg.discard.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_cargo_discard_application")
public class CargoDiscardApplication {
@TableId(type = IdType.INPUT) private Long id;
private String customerId;
private String discardNo;
private String overseasWarehouseId;
private String overseasWarehouseName;
private String inboundCodes;
private Integer totalPackages;
private BigDecimal feePerPackage;
private BigDecimal totalFee;
private String currency;
private String status;
private String remark;
private String idempotencyKey;
private String requestHash;
private String createdBy;
private String reviewedBy;
private String reviewRemark;
private LocalDateTime reviewedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.discard.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_cargo_discard_carton")
public class CargoDiscardCarton {
@TableId(type = IdType.INPUT) private Long id;
private Long applicationId;
private Long itemId;
private String customerId;
private Long cartonId;
private String cartonMark;
private LocalDateTime createdAt;
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.discard.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_cargo_discard_item")
public class CargoDiscardItem {
@TableId(type = IdType.INPUT) private Long id;
private Long applicationId;
private String customerId;
private Long orderId;
private String inboundCode;
private Long profileId;
private Long skuInventoryId;
private String skuCode;
private String productName;
private Integer availablePackagesSnapshot;
private Integer discardPackages;
private LocalDateTime createdAt;
}
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.discard.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_cargo_discard_status_history")
public class CargoDiscardStatusHistory {
@TableId(type = IdType.INPUT) private Long id;
private Long applicationId;
private String customerId;
private String fromStatus;
private String toStatus;
private String operatorId;
private String remark;
private LocalDateTime createdAt;
}
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.discard.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.discard.entity.CargoDiscardApplication;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface CargoDiscardApplicationMapper extends BaseMapper<CargoDiscardApplication> {
@Select("SELECT * FROM ll_cargo_discard_application WHERE customer_id=#{customerId} AND discard_no=#{discardNo} LIMIT 1")
CargoDiscardApplication findNo(@Param("customerId") String customerId, @Param("discardNo") String discardNo);
@Select("SELECT * FROM ll_cargo_discard_application WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1")
CargoDiscardApplication findKey(@Param("customerId") String customerId, @Param("key") String key);
@Select("SELECT * FROM ll_cargo_discard_application WHERE customer_id=#{customerId} AND discard_no=#{discardNo} LIMIT 1 FOR UPDATE")
CargoDiscardApplication lockNo(@Param("customerId") String customerId, @Param("discardNo") String discardNo);
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.discard.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.discard.entity.CargoDiscardCarton;
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 CargoDiscardCartonMapper extends BaseMapper<CargoDiscardCarton> {
@Select("SELECT * FROM ll_cargo_discard_carton WHERE customer_id=#{customerId} AND application_id=#{applicationId} ORDER BY id")
List<CargoDiscardCarton> listApplication(@Param("customerId") String customerId,
@Param("applicationId") Long applicationId);
}
@@ -0,0 +1,69 @@
package com.baiyue.lastleg.discard.mapper;
import com.baiyue.lastleg.discard.model.CargoDiscardCandidateRow;
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
@Mapper
public interface CargoDiscardInventoryMapper {
@Select("<script>SELECT o.id order_id,o.inbound_code,o.inbound_order_no,o.overseas_warehouse_id," +
"o.overseas_warehouse_name,o.created_at,p.id profile_id,s.id sku_inventory_id,s.sku_code,s.product_name," +
"COUNT(c.id) available_packages FROM ll_order o " +
"JOIN ll_dropship_order_profile p ON p.customer_id=o.customer_id AND p.order_id=o.id " +
"JOIN ll_dropship_sku_inventory s ON s.customer_id=o.customer_id AND s.order_id=o.id AND s.profile_id=p.id " +
"LEFT JOIN ll_dropship_carton c ON c.customer_id=o.customer_id AND c.order_id=o.id " +
"AND c.profile_id=p.id AND c.sku_code=s.sku_code AND c.status='AVAILABLE' " +
"WHERE o.customer_id=#{customerId} AND o.order_type='ONE_CLICK_DROPSHIP' " +
"<if test='inboundCode != null and inboundCode != \"\"'>AND o.inbound_code LIKE CONCAT('%',#{inboundCode},'%') </if>" +
"GROUP BY o.id,o.inbound_code,o.inbound_order_no,o.overseas_warehouse_id,o.overseas_warehouse_name,o.created_at," +
"p.id,s.id,s.sku_code,s.product_name HAVING COUNT(c.id)&gt;0 ORDER BY o.created_at DESC,o.id DESC,p.id,s.sku_code LIMIT 500</script>")
List<CargoDiscardCandidateRow> candidates(@Param("customerId") String customerId,
@Param("inboundCode") String inboundCode);
@Select("SELECT COUNT(*) FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} " +
"AND sku_code=#{skuCode} AND status='AVAILABLE'")
long countAvailable(@Param("customerId") String customerId, @Param("orderId") Long orderId,
@Param("skuCode") String skuCode);
@Select("SELECT COUNT(*) FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} " +
"AND profile_id=#{profileId} AND sku_code=#{skuCode} AND status='AVAILABLE'")
long countAvailableProfile(@Param("customerId") String customerId, @Param("orderId") Long orderId,
@Param("profileId") Long profileId, @Param("skuCode") String skuCode);
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} " +
"AND sku_code=#{skuCode} AND status='AVAILABLE' ORDER BY profile_id,carton_sequence,id LIMIT #{limit} FOR UPDATE")
List<DropshipCarton> lockAvailable(@Param("customerId") String customerId, @Param("orderId") Long orderId,
@Param("skuCode") String skuCode, @Param("limit") int limit);
@Update("UPDATE ll_dropship_carton SET status=#{toStatus},version=version+1,updated_at=NOW(3) " +
"WHERE customer_id=#{customerId} AND id=#{cartonId} AND status=#{fromStatus}")
int changeCartonStatus(@Param("customerId") String customerId, @Param("cartonId") Long cartonId,
@Param("fromStatus") String fromStatus, @Param("toStatus") String toStatus);
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND id=#{cartonId} LIMIT 1 FOR UPDATE")
DropshipCarton lockCarton(@Param("customerId") String customerId, @Param("cartonId") Long cartonId);
@Select("SELECT * FROM ll_dropship_sku_inventory WHERE customer_id=#{customerId} AND id=#{id} LIMIT 1 FOR UPDATE")
DropshipSkuInventory lockSkuInventory(@Param("customerId") String customerId, @Param("id") Long id);
@Select("SELECT * FROM ll_dropship_sku_inventory WHERE customer_id=#{customerId} AND order_id=#{orderId} " +
"AND profile_id=#{profileId} AND sku_code=#{skuCode} LIMIT 1 FOR UPDATE")
DropshipSkuInventory lockSkuInventoryDimension(@Param("customerId") String customerId,
@Param("orderId") Long orderId,
@Param("profileId") Long profileId,
@Param("skuCode") String skuCode);
@Update("UPDATE ll_dropship_sku_inventory SET package_count=package_count-#{packages},updated_at=NOW(3) " +
"WHERE customer_id=#{customerId} AND id=#{id} AND package_count>=#{packages}")
int deductSkuInventory(@Param("customerId") String customerId, @Param("id") Long id,
@Param("packages") int packages);
@Select("SELECT package_count FROM ll_dropship_sku_inventory WHERE customer_id=#{customerId} AND id=#{id}")
Long skuPackages(@Param("customerId") String customerId, @Param("id") Long id);
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.discard.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.discard.entity.CargoDiscardItem;
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 CargoDiscardItemMapper extends BaseMapper<CargoDiscardItem> {
@Select("SELECT * FROM ll_cargo_discard_item WHERE customer_id=#{customerId} AND application_id=#{applicationId} ORDER BY inbound_code,sku_code,profile_id")
List<CargoDiscardItem> listApplication(@Param("customerId") String customerId,
@Param("applicationId") Long applicationId);
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.discard.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.discard.entity.CargoDiscardStatusHistory;
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 CargoDiscardStatusHistoryMapper extends BaseMapper<CargoDiscardStatusHistory> {
@Select("SELECT * FROM ll_cargo_discard_status_history WHERE customer_id=#{customerId} AND application_id=#{applicationId} ORDER BY created_at,id")
List<CargoDiscardStatusHistory> listApplication(@Param("customerId") String customerId,
@Param("applicationId") Long applicationId);
}
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.discard.model;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class CargoDiscardCandidateRow {
private Long orderId;
private String inboundCode;
private String inboundOrderNo;
private String overseasWarehouseId;
private String overseasWarehouseName;
private LocalDateTime createdAt;
private Long profileId;
private Long skuInventoryId;
private String skuCode;
private String productName;
private Long availablePackages;
}
@@ -0,0 +1,98 @@
package com.baiyue.lastleg.discard.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.discard.entity.CargoDiscardApplication;
import com.baiyue.lastleg.discard.entity.CargoDiscardCarton;
import com.baiyue.lastleg.discard.entity.CargoDiscardItem;
import com.baiyue.lastleg.discard.mapper.CargoDiscardInventoryMapper;
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
import com.baiyue.lastleg.dropship.entity.DropshipCartonLedger;
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
import com.baiyue.lastleg.dropship.mapper.DropshipCartonLedgerMapper;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/** Final inventory mutation for a warehouse-approved cargo discard. */
@Service
public class CargoDiscardInventoryAdjustmentService {
@Resource private CargoDiscardInventoryMapper inventory;
@Resource private DropshipCartonLedgerMapper cartonLedgers;
@Transactional(propagation = Propagation.MANDATORY)
public AdjustmentResult discard(String customerId, CargoDiscardApplication application,
List<CargoDiscardItem> items,
Map<Long, List<CargoDiscardCarton>> linksByItem,
String operatorId, LocalDateTime occurredAt) {
long beforePackages = 0;
long discardedPackages = 0;
long afterPackages = 0;
for (CargoDiscardItem item : items) {
DropshipSkuInventory sku = inventory.lockSkuInventory(customerId, item.getSkuInventoryId());
if (sku == null) throw conflict("SKU inventory is missing during discard approval: " + item.getSkuCode());
long before = value(sku.getPackageCount());
int discarded = item.getDiscardPackages();
if (before < discarded || inventory.deductSkuInventory(customerId, sku.getId(), discarded) != 1) {
throw conflict("SKU inventory is insufficient during discard approval: " + item.getSkuCode());
}
List<CargoDiscardCarton> links = linksByItem.getOrDefault(item.getId(), List.of());
if (links.size() != discarded) throw conflict("Reserved discard cartons are incomplete");
for (CargoDiscardCarton link : links) {
DropshipCarton carton = inventory.lockCarton(customerId, link.getCartonId());
if (carton == null || inventory.changeCartonStatus(customerId, carton.getId(),
"DISCARD_PENDING", "DISCARDED") != 1) throw conflict("Reserved cargo status changed before approval");
cartonLedgers.insert(discardFact(customerId, application, item, carton, operatorId, occurredAt));
}
Long persistedAfter = inventory.skuPackages(customerId, sku.getId());
long expectedAfter = before - discarded;
if (persistedAfter == null || persistedAfter != expectedAfter) {
throw conflict("Cargo discard package conservation check failed for SKU " + item.getSkuCode());
}
beforePackages = Math.addExact(beforePackages, before);
discardedPackages = Math.addExact(discardedPackages, discarded);
afterPackages = Math.addExact(afterPackages, persistedAfter);
}
if (beforePackages - discardedPackages != afterPackages
|| discardedPackages != application.getTotalPackages()) {
throw conflict("Cargo discard application package conservation check failed");
}
return new AdjustmentResult(beforePackages, discardedPackages, afterPackages);
}
private DropshipCartonLedger discardFact(String customerId, CargoDiscardApplication application,
CargoDiscardItem item, DropshipCarton carton,
String operatorId, LocalDateTime occurredAt) {
DropshipCartonLedger fact = new DropshipCartonLedger();
fact.setId(IdWorker.getId()); fact.setCustomerId(customerId); fact.setOrderId(item.getOrderId());
fact.setCartonId(carton.getId()); fact.setCartonMark(carton.getCartonMark()); fact.setSkuCode(item.getSkuCode());
fact.setOperationType("DISCARD"); fact.setBeforePackages(1L); fact.setDeltaPackages(-1L); fact.setAfterPackages(0L);
fact.setBusinessOrderNo(application.getDiscardNo());
fact.setIdempotencyKey("CARGO_DISCARD:" + application.getId() + ":" + carton.getId());
fact.setRemark("Warehouse discard approved by " + operatorId + remarkSuffix(application.getRemark()));
fact.setOccurredAt(occurredAt); fact.setCreatedAt(occurredAt);
return fact;
}
private static long value(Long value) { return value == null ? 0L : value; }
private static String remarkSuffix(String value) {
return value == null || value.isBlank() ? "" : "; " + value.trim();
}
private static ResponseStatusException conflict(String message) {
return new ResponseStatusException(HttpStatus.CONFLICT, message);
}
public record AdjustmentResult(long beforePackages, long discardedPackages, long afterPackages) {
public String auditRemark(String reviewRemark) {
String prefix = reviewRemark == null || reviewRemark.isBlank() ? "" : reviewRemark.trim() + "; ";
return prefix + "inventoryBefore=" + beforePackages + ", discarded=" + discardedPackages
+ ", inventoryAfter=" + afterPackages;
}
}
}
@@ -0,0 +1,303 @@
package com.baiyue.lastleg.discard.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.discard.dto.CargoDiscardRequests;
import com.baiyue.lastleg.discard.dto.CargoDiscardResponses;
import com.baiyue.lastleg.discard.entity.CargoDiscardApplication;
import com.baiyue.lastleg.discard.entity.CargoDiscardCarton;
import com.baiyue.lastleg.discard.entity.CargoDiscardItem;
import com.baiyue.lastleg.discard.entity.CargoDiscardStatusHistory;
import com.baiyue.lastleg.discard.mapper.CargoDiscardApplicationMapper;
import com.baiyue.lastleg.discard.mapper.CargoDiscardCartonMapper;
import com.baiyue.lastleg.discard.mapper.CargoDiscardInventoryMapper;
import com.baiyue.lastleg.discard.mapper.CargoDiscardItemMapper;
import com.baiyue.lastleg.discard.mapper.CargoDiscardStatusHistoryMapper;
import com.baiyue.lastleg.discard.model.CargoDiscardCandidateRow;
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Service
public class CargoDiscardService {
public static final String APPROVAL_PERMISSION = "lastleg:cargo-discard:approve";
public static final String PENDING_REVIEW = "PENDING_REVIEW";
public static final String APPROVED = "APPROVED";
public static final String REJECTED = "REJECTED";
private static final Set<String> STATUSES = Set.of(PENDING_REVIEW, APPROVED, REJECTED);
@Resource private CargoDiscardApplicationMapper applications;
@Resource private CargoDiscardItemMapper items;
@Resource private CargoDiscardCartonMapper discardCartons;
@Resource private CargoDiscardStatusHistoryMapper histories;
@Resource private CargoDiscardInventoryMapper inventory;
@Resource private CargoDiscardInventoryAdjustmentService adjustments;
@Resource private LastlegOrderMapper orders;
@Resource private ObjectMapper objectMapper;
@Value("${baiyue.lastleg.cargo-discard.fee-per-package-usd:5.00}")
private BigDecimal feePerPackage = new BigDecimal("5.00");
public List<CargoDiscardResponses.Candidate> candidates(String customerId, String inboundCode) {
List<CargoDiscardCandidateRow> rows = inventory.candidates(customerId, trim(inboundCode));
Map<Long, CandidateAccumulator> grouped = new LinkedHashMap<>();
for (CargoDiscardCandidateRow row : rows) {
CandidateAccumulator value = grouped.computeIfAbsent(row.getOrderId(), ignored -> new CandidateAccumulator(row));
value.add(row);
}
return grouped.values().stream().map(CandidateAccumulator::response).toList();
}
@Transactional
public CargoDiscardResponses.Detail create(String customerId, String operatorId,
CargoDiscardRequests.Create request) {
String key = request.idempotencyKey().trim();
String requestHash = hash(request);
CargoDiscardApplication replay = applications.findKey(customerId, key);
if (replay != null) {
if (!requestHash.equals(replay.getRequestHash())) {
throw conflict("Idempotency key was reused with different cargo discard data");
}
return detail(customerId, replay.getDiscardNo());
}
Set<String> requestedDimensions = new LinkedHashSet<>();
Map<Long, LastlegOrder> orderCache = new HashMap<>();
String warehouseId = null;
String warehouseName = null;
int total = 0;
for (CargoDiscardRequests.Item input : request.items()) {
String dimension = input.orderId() + ":" + input.skuCode().trim();
if (!requestedDimensions.add(dimension)) throw badRequest("Duplicate order/SKU discard item: " + dimension);
LastlegOrder order = requireDropshipOrder(customerId, input.orderId());
orderCache.put(order.getId(), order);
if (warehouseId == null) {
warehouseId = required(order.getOverseasWarehouseId(), "Order has no operating overseas warehouse");
warehouseName = trim(order.getOverseasWarehouseName());
} else if (!warehouseId.equals(order.getOverseasWarehouseId())) {
throw conflict("One cargo discard application cannot span operating warehouses");
}
total = Math.addExact(total, input.discardPackages());
}
LocalDateTime now = LocalDateTime.now();
CargoDiscardApplication application = new CargoDiscardApplication();
application.setId(IdWorker.getId()); application.setCustomerId(customerId);
application.setDiscardNo("FH" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + application.getId());
application.setOverseasWarehouseId(warehouseId); application.setOverseasWarehouseName(warehouseName);
application.setInboundCodes(orderCache.values().stream().map(LastlegOrder::getInboundCode)
.distinct().sorted().collect(java.util.stream.Collectors.joining(",")));
application.setTotalPackages(total); application.setFeePerPackage(feePerPackage);
application.setTotalFee(feePerPackage.multiply(BigDecimal.valueOf(total)));
application.setCurrency("USD"); application.setStatus(PENDING_REVIEW);
application.setRemark(trim(request.remark())); application.setIdempotencyKey(key);
application.setRequestHash(requestHash); application.setCreatedBy(operatorId);
application.setCreatedAt(now); application.setUpdatedAt(now);
applications.insert(application);
for (CargoDiscardRequests.Item input : request.items()) {
reserve(customerId, application, orderCache.get(input.orderId()), input, now);
}
history(customerId, application.getId(), null, PENDING_REVIEW, operatorId, request.remark(), now);
return detail(customerId, application.getDiscardNo());
}
public PageResult<CargoDiscardResponses.Summary> page(String customerId, CargoDiscardRequests.Query query) {
validateQuery(query);
LambdaQueryWrapper<CargoDiscardApplication> wrapper = new LambdaQueryWrapper<CargoDiscardApplication>()
.eq(CargoDiscardApplication::getCustomerId, customerId)
.like(StringUtils.hasText(query.discardNo()), CargoDiscardApplication::getDiscardNo, query.discardNo())
.like(StringUtils.hasText(query.inboundCode()), CargoDiscardApplication::getInboundCodes, query.inboundCode())
.eq(StringUtils.hasText(query.overseasWarehouseId()), CargoDiscardApplication::getOverseasWarehouseId,
query.overseasWarehouseId())
.eq(StringUtils.hasText(query.status()), CargoDiscardApplication::getStatus, query.status())
.ge(query.createdFrom() != null, CargoDiscardApplication::getCreatedAt, query.createdFrom())
.le(query.createdTo() != null, CargoDiscardApplication::getCreatedAt, query.createdTo())
.orderByDesc(CargoDiscardApplication::getCreatedAt).orderByDesc(CargoDiscardApplication::getId);
Page<CargoDiscardApplication> result = applications.selectPage(Page.of(query.page(), query.size()), wrapper);
List<CargoDiscardResponses.Summary> records = result.getRecords().stream().map(this::summary).toList();
return new PageResult<>(records, query.page(), query.size(), result.getTotal(), result.getPages());
}
public CargoDiscardResponses.Detail detail(String customerId, String discardNo) {
CargoDiscardApplication application = applications.findNo(customerId, discardNo);
if (application == null) throw notFound("Cargo discard application not found");
List<CargoDiscardResponses.Item> itemViews = items.listApplication(customerId, application.getId()).stream()
.map(value -> new CargoDiscardResponses.Item(value.getId(), value.getOrderId(), value.getInboundCode(),
value.getSkuCode(), value.getProductName(), value.getAvailablePackagesSnapshot(), value.getDiscardPackages()))
.toList();
List<CargoDiscardResponses.History> historyViews = histories.listApplication(customerId, application.getId()).stream()
.map(value -> new CargoDiscardResponses.History(value.getFromStatus(), value.getToStatus(),
value.getOperatorId(), value.getRemark(), value.getCreatedAt())).toList();
return new CargoDiscardResponses.Detail(summary(application), application.getFeePerPackage(),
application.getRemark(), application.getReviewedBy(), application.getReviewRemark(), itemViews, historyViews);
}
@Transactional
public CargoDiscardResponses.Detail review(String customerId, String discardNo, String operatorId,
CargoDiscardRequests.Review request) {
String decision = request.decision().trim().toUpperCase();
String next = switch (decision) {
case "APPROVE", APPROVED -> APPROVED;
case "REJECT", REJECTED -> REJECTED;
default -> throw badRequest("Review decision must be APPROVE or REJECT");
};
CargoDiscardApplication application = applications.lockNo(customerId, discardNo);
if (application == null) throw notFound("Cargo discard application not found");
if (next.equals(application.getStatus())) return detail(customerId, discardNo);
if (!PENDING_REVIEW.equals(application.getStatus())) throw conflict("Cargo discard application was already reviewed");
List<CargoDiscardItem> applicationItems = items.listApplication(customerId, application.getId());
List<CargoDiscardCarton> links = discardCartons.listApplication(customerId, application.getId());
Map<Long, List<CargoDiscardCarton>> linksByItem = links.stream()
.collect(java.util.stream.Collectors.groupingBy(CargoDiscardCarton::getItemId));
String historyRemark = request.remark();
if (APPROVED.equals(next)) {
CargoDiscardInventoryAdjustmentService.AdjustmentResult result = adjustments.discard(
customerId, application, applicationItems, linksByItem, operatorId, now());
historyRemark = result.auditRemark(request.remark());
} else {
release(customerId, links);
}
LocalDateTime reviewedAt = now();
application.setStatus(next); application.setReviewedBy(operatorId);
application.setReviewRemark(trim(request.remark())); application.setReviewedAt(reviewedAt);
application.setUpdatedAt(reviewedAt); applications.updateById(application);
history(customerId, application.getId(), PENDING_REVIEW, next, operatorId, historyRemark, reviewedAt);
return detail(customerId, discardNo);
}
private void reserve(String customerId, CargoDiscardApplication application, LastlegOrder order,
CargoDiscardRequests.Item input, LocalDateTime now) {
String skuCode = input.skuCode().trim();
if (inventory.countAvailable(customerId, order.getId(), skuCode) < input.discardPackages()) {
throw conflict("Insufficient available packages for SKU " + skuCode + " on inbound " + order.getInboundCode());
}
List<DropshipCarton> selected = inventory.lockAvailable(customerId, order.getId(), skuCode, input.discardPackages());
if (selected.size() != input.discardPackages()) throw conflict("Available cargo changed; retry the discard request");
Map<Long, List<DropshipCarton>> byProfile = selected.stream()
.collect(java.util.stream.Collectors.groupingBy(DropshipCarton::getProfileId, LinkedHashMap::new,
java.util.stream.Collectors.toList()));
for (Map.Entry<Long, List<DropshipCarton>> entry : byProfile.entrySet()) {
DropshipSkuInventory sku = inventory.lockSkuInventoryDimension(customerId, order.getId(), entry.getKey(), skuCode);
if (sku == null) throw conflict("SKU inventory disappeared while reserving cargo");
CargoDiscardItem item = new CargoDiscardItem();
item.setId(IdWorker.getId()); item.setApplicationId(application.getId()); item.setCustomerId(customerId);
item.setOrderId(order.getId()); item.setInboundCode(order.getInboundCode()); item.setProfileId(entry.getKey());
item.setSkuInventoryId(sku.getId()); item.setSkuCode(skuCode); item.setProductName(sku.getProductName());
item.setAvailablePackagesSnapshot(Math.toIntExact(inventory.countAvailableProfile(
customerId, order.getId(), entry.getKey(), skuCode)));
item.setDiscardPackages(entry.getValue().size()); item.setCreatedAt(now); items.insert(item);
for (DropshipCarton carton : entry.getValue()) {
if (inventory.changeCartonStatus(customerId, carton.getId(), "AVAILABLE", "DISCARD_PENDING") != 1) {
throw conflict("Cargo was reserved concurrently; retry the discard request");
}
CargoDiscardCarton link = new CargoDiscardCarton();
link.setId(IdWorker.getId()); link.setApplicationId(application.getId()); link.setItemId(item.getId());
link.setCustomerId(customerId); link.setCartonId(carton.getId()); link.setCartonMark(carton.getCartonMark());
link.setCreatedAt(now); discardCartons.insert(link);
}
}
}
private void release(String customerId, List<CargoDiscardCarton> links) {
for (CargoDiscardCarton link : links) {
if (inventory.changeCartonStatus(customerId, link.getCartonId(), "DISCARD_PENDING", "AVAILABLE") != 1) {
throw conflict("Reserved cargo status changed before rejection");
}
}
}
private LastlegOrder requireDropshipOrder(String customerId, Long orderId) {
LastlegOrder order = orders.selectById(orderId);
if (order == null || !customerId.equals(order.getCustomerId())) throw notFound("Inbound order not found");
if (!"ONE_CLICK_DROPSHIP".equals(order.getOrderType())) {
throw conflict("Cargo discard currently requires SKU/carton inventory from a one-click dropship order");
}
return order;
}
private CargoDiscardResponses.Summary summary(CargoDiscardApplication value) {
return new CargoDiscardResponses.Summary(value.getId(), value.getDiscardNo(), value.getInboundCodes(),
value.getOverseasWarehouseId(), value.getOverseasWarehouseName(), value.getTotalPackages(),
value.getTotalFee(), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(),
value.getReviewedAt());
}
private void history(String customerId, Long applicationId, String from, String to, String operatorId,
String remark, LocalDateTime createdAt) {
CargoDiscardStatusHistory value = new CargoDiscardStatusHistory();
value.setId(IdWorker.getId()); value.setApplicationId(applicationId); value.setCustomerId(customerId);
value.setFromStatus(from); value.setToStatus(to); value.setOperatorId(operatorId);
value.setRemark(trim(remark)); value.setCreatedAt(createdAt); histories.insert(value);
}
private void validateQuery(CargoDiscardRequests.Query query) {
if (StringUtils.hasText(query.status()) && !STATUSES.contains(query.status())) throw badRequest("Unsupported discard status");
if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) {
throw badRequest("createdFrom must not be after createdTo");
}
}
private String hash(Object value) {
try {
byte[] json = objectMapper.writeValueAsBytes(value);
return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json));
} catch (JsonProcessingException | NoSuchAlgorithmException exception) {
throw new IllegalStateException("Cannot hash cargo discard request", exception);
}
}
private static LocalDateTime now() { return LocalDateTime.now(); }
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private static String required(String value, String message) { String result = trim(value); if (result == null) throw conflict(message); return result; }
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); }
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
private static final class CandidateAccumulator {
private final CargoDiscardCandidateRow first;
private final Map<String, CandidateSkuAccumulator> skus = new LinkedHashMap<>();
private CandidateAccumulator(CargoDiscardCandidateRow first) { this.first = first; }
private void add(CargoDiscardCandidateRow row) {
skus.computeIfAbsent(row.getSkuCode(), ignored -> new CandidateSkuAccumulator(row.getSkuCode(), row.getProductName()))
.packages += row.getAvailablePackages() == null ? 0 : row.getAvailablePackages();
}
private CargoDiscardResponses.Candidate response() {
List<CargoDiscardResponses.CandidateSku> skuViews = skus.values().stream()
.map(value -> new CargoDiscardResponses.CandidateSku(value.skuCode, value.productName, value.packages)).toList();
long packages = skuViews.stream().mapToLong(CargoDiscardResponses.CandidateSku::availablePackages).sum();
return new CargoDiscardResponses.Candidate(first.getOrderId(), first.getInboundCode(), first.getInboundOrderNo(),
first.getOverseasWarehouseId(), first.getOverseasWarehouseName(), first.getCreatedAt(), packages, skuViews);
}
}
private static final class CandidateSkuAccumulator {
private final String skuCode;
private final String productName;
private long packages;
private CandidateSkuAccumulator(String skuCode, String productName) { this.skuCode = skuCode; this.productName = productName; }
}
}