功能提交

This commit is contained in:
2026-08-18 16:31:55 +08:00
parent d2c532f807
commit 31cf37c528
60 changed files with 2983 additions and 21 deletions
+4
View File
@@ -23,6 +23,8 @@
| 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java`、`HttpWarehouseResourceGateway.java` |
| 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java`、`transfer/service/WarehouseTransferService.java` |
| 一件代发接收、SKU/箱唛库存 | `dropship/service/DropshipOrderService.java`、`dropship/entity/`、`dropship/mapper/` |
| 货物废弃、待审锁箱、审批扣减 | `discard/controller/CargoDiscardController.java`、`discard/service/CargoDiscardService.java` |
| 海外采购、客户下单、采购审核、手工出库与采购库存 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java` |
| 数据库 | `src/main/resources/db/lastleg.sql` |
| 测试 | `src/test/java/com/baiyue/lastleg/` |
@@ -41,6 +43,8 @@
- 平台查询范围由可信身份/平台权限决定;商户查询只能使用认证身份中的客户 ID。
- 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。
- 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。
- 货物废弃只使用可核对 SKU 和箱唛的一件代发库存;提交申请时将箱锁定为 `DISCARD_PENDING`,具备 `lastleg:cargo-discard:approve` 权限的审核人通过后才扣减 SKU 箱数并写逐箱流水,拒绝时释放锁箱。
- 海外采购客户下单固定进入 `PENDING_REVIEW`;审核通过后后台才可维护入库量和新增出库记录。采购库存固定按 `inbound_quantity - outbound_quantity` 计算,禁止直接覆盖库存量或产生负库存;审核、编辑和出库均须按可信租户范围执行。
## 修改与验证
@@ -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,66 @@
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&gt;=#{packages}")
int deductSkuInventory(@Param("customerId") String customerId, @Param("id") Long id,
@Param("packages") int packages);
}
@@ -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,325 @@
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.DropshipCartonLedger;
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
import com.baiyue.lastleg.dropship.mapper.DropshipCartonLedgerMapper;
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 DropshipCartonLedgerMapper cartonLedgers;
@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));
if (APPROVED.equals(next)) approve(customerId, application, applicationItems, linksByItem, operatorId, now());
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, request.remark(), 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 approve(String customerId, CargoDiscardApplication application, List<CargoDiscardItem> applicationItems,
Map<Long, List<CargoDiscardCarton>> linksByItem, String operatorId, LocalDateTime occurredAt) {
for (CargoDiscardItem item : applicationItems) {
DropshipSkuInventory sku = inventory.lockSkuInventory(customerId, item.getSkuInventoryId());
if (sku == null || inventory.deductSkuInventory(customerId, sku.getId(), item.getDiscardPackages()) != 1) {
throw conflict("SKU inventory is insufficient during discard approval: " + item.getSkuCode());
}
List<CargoDiscardCarton> links = linksByItem.getOrDefault(item.getId(), List.of());
if (links.size() != item.getDiscardPackages()) 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");
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("Approved by " + operatorId + remarkSuffix(application.getRemark()));
fact.setOccurredAt(occurredAt); fact.setCreatedAt(occurredAt); cartonLedgers.insert(fact);
}
}
}
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 String remarkSuffix(String value) { String normalized = trim(value); return normalized == null ? "" : "; " + normalized; }
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; }
}
}
@@ -2,7 +2,11 @@ package com.baiyue.lastleg.dropship.mapper;
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.*;
import java.util.List;
public interface DropshipCartonMapper extends BaseMapper<DropshipCarton> {
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} AND carton_mark=#{cartonMark} LIMIT 1")
DropshipCarton find(@Param("customerId") String customerId, @Param("orderId") Long orderId, @Param("cartonMark") String cartonMark);
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY profile_id,carton_sequence")
List<DropshipCarton> listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId);
}
@@ -2,5 +2,12 @@ package com.baiyue.lastleg.dropship.mapper;
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
public interface DropshipSkuInventoryMapper extends BaseMapper<DropshipSkuInventory> {}
import java.util.List;
public interface DropshipSkuInventoryMapper extends BaseMapper<DropshipSkuInventory> {
@Select("SELECT * FROM ll_dropship_sku_inventory WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY profile_id,sku_code")
List<DropshipSkuInventory> listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId);
}
@@ -3,12 +3,25 @@ package com.baiyue.lastleg.dto;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
import java.util.List;
public record LastlegOrderDetailResponse(
LastlegOrder order,
List<LastlegOrderDetail> cargo,
List<LastlegOrderStatusHistory> statusHistory
List<LastlegOrderStatusHistory> statusHistory,
List<DropshipPallet> dropshipPallets
) {
public record DropshipPallet(
Long profileId,
String sourcePalletNo,
Long warehousePalletId,
String warehousePalletNo,
Long inventoryPositionId,
String status,
List<DropshipSkuInventory> skuInventory,
List<DropshipCarton> cartons
) {}
}
@@ -0,0 +1,56 @@
package com.baiyue.lastleg.procurement.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.procurement.dto.OverseasProcurementRequests;
import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses;
import com.baiyue.lastleg.procurement.service.OverseasProcurementService;
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.RestController;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/merchant/v1/last-leg/overseas-procurements")
public class MerchantOverseasProcurementController {
@Resource private OverseasProcurementService service;
@PostMapping
public ApiResponse<OverseasProcurementResponses.Detail> create(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody OverseasProcurementRequests.Create request) {
return ApiResponse.success(service.create(customer(user), operator(user), request));
}
@GetMapping
public ApiResponse<PageResult<OverseasProcurementResponses.Summary>> page(
@AuthenticationPrincipal AuthenticatedUser user, @Valid OverseasProcurementRequests.Query query) {
return ApiResponse.success(service.page(customer(user), query));
}
@GetMapping("/{orderNo}")
public ApiResponse<OverseasProcurementResponses.Detail> detail(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String orderNo) {
return ApiResponse.success(service.detail(customer(user), orderNo));
}
private String customer(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required");
}
return String.valueOf(user.tenantId());
}
private String operator(AuthenticatedUser user) {
return user.userId() == null ? user.username() : String.valueOf(user.userId());
}
}
@@ -0,0 +1,96 @@
package com.baiyue.lastleg.procurement.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.procurement.dto.OverseasProcurementRequests;
import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses;
import com.baiyue.lastleg.procurement.service.OverseasProcurementService;
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.PatchMapping;
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;
@RestController
@RequestMapping("/api/platform/v1/last-leg/overseas-procurements")
public class PlatformOverseasProcurementController {
@Resource private OverseasProcurementService service;
@GetMapping
public ApiResponse<PageResult<OverseasProcurementResponses.Summary>> page(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid OverseasProcurementRequests.Query query) {
requirePlatform(user);
return ApiResponse.success(service.page(trim(customerId), query));
}
@GetMapping("/{orderNo}")
public ApiResponse<OverseasProcurementResponses.Detail> detail(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
@PathVariable String orderNo) {
requirePlatform(user);
return ApiResponse.success(service.detail(requiredCustomer(customerId), orderNo));
}
@PostMapping("/{orderNo}/review")
public ApiResponse<OverseasProcurementResponses.Detail> review(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
@PathVariable String orderNo, @Valid @RequestBody OverseasProcurementRequests.Review request) {
requirePermission(user, OverseasProcurementService.REVIEW_PERMISSION);
return ApiResponse.success(service.review(requiredCustomer(customerId), orderNo, operator(user), request));
}
@PatchMapping("/{orderNo}")
public ApiResponse<OverseasProcurementResponses.Detail> update(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
@PathVariable String orderNo, @Valid @RequestBody OverseasProcurementRequests.Update request) {
requirePermission(user, OverseasProcurementService.MANAGE_PERMISSION);
return ApiResponse.success(service.update(requiredCustomer(customerId), orderNo, request));
}
@PostMapping("/{orderNo}/outbounds")
public ApiResponse<OverseasProcurementResponses.Detail> addOutbound(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
@PathVariable String orderNo, @Valid @RequestBody OverseasProcurementRequests.AddOutbound request) {
requirePermission(user, OverseasProcurementService.MANAGE_PERMISSION);
return ApiResponse.success(service.addOutbound(requiredCustomer(customerId), orderNo, operator(user), request));
}
private void requirePlatform(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform identity is required");
}
}
private void requirePermission(AuthenticatedUser user, String permission) {
requirePlatform(user);
if (user.permissions() == null || !user.permissions().contains(permission)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, permission + " permission is required");
}
}
private String requiredCustomer(String customerId) {
String value = trim(customerId);
if (value == null || "0".equals(value)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Target customerId is required");
}
return value;
}
private String operator(AuthenticatedUser user) {
return user.userId() == null ? user.username() : String.valueOf(user.userId());
}
private String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
}
@@ -0,0 +1,63 @@
package com.baiyue.lastleg.procurement.dto;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDateTime;
public final class OverseasProcurementRequests {
private OverseasProcurementRequests() {}
public record Create(
@Size(max = 255) String customerName,
@NotBlank @Size(max = 128) String purchaseCategory,
@NotBlank @Size(max = 255) String productName,
@Size(max = 500) String productSpecification,
@Size(max = 2000) String productUrl,
@NotNull @Positive Long purchaseQuantity,
@DecimalMin(value = "0.00") BigDecimal budgetUnitPrice,
@Size(min = 3, max = 3) String currency,
@Size(max = 1000) String receivingAddress,
@NotBlank @Size(max = 128) String contactName,
@NotBlank @Size(max = 64) String contactPhone,
@Size(max = 1000) String remark,
@NotBlank @Size(max = 255) String idempotencyKey) {}
public record Query(
@Min(1) long page, @Min(1) @Max(200) long size,
String orderNo, String customerKeyword, String purchaseCategory,
String productName, String status, String purchaseWarehouseId,
LocalDateTime createdFrom, LocalDateTime createdTo) {}
public record Review(@NotBlank String decision, @Size(max = 1000) String remark) {}
public record Update(
@NotNull @Min(0) Long version,
@Size(max = 128) String purchaseCategory,
@Size(max = 255) String productName,
@Size(max = 500) String productSpecification,
@Size(max = 2000) String productUrl,
@Min(0) Long inboundQuantity,
@Size(max = 128) String purchaseWarehouseId,
@Size(max = 255) String purchaseWarehouseName,
@Size(max = 128) String domesticCarrier,
@Size(max = 128) String domesticTrackingNo,
@Size(max = 128) String overseasCarrier,
@Size(max = 128) String overseasTrackingNo,
LocalDateTime estimatedArrivalAt,
@Size(max = 1000) String remark) {}
public record AddOutbound(
@NotNull @Positive Long quantity,
@NotBlank @Size(max = 128) String outboundNo,
@Size(max = 128) String carrier,
@Size(max = 128) String trackingNo,
LocalDateTime outboundAt,
@Size(max = 1000) String remark) {}
}
@@ -0,0 +1,34 @@
package com.baiyue.lastleg.procurement.dto;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
public final class OverseasProcurementResponses {
private OverseasProcurementResponses() {}
public record Summary(
Long id, String orderNo, String customerId, String customerName,
String purchaseCategory, String productName, String productSpecification,
long purchaseQuantity, BigDecimal budgetUnitPrice, String currency,
String purchaseWarehouseId, String purchaseWarehouseName,
long inboundQuantity, long outboundQuantity, long inventoryQuantity,
String status, long version, LocalDateTime createdAt, LocalDateTime updatedAt) {}
public record Outbound(
Long id, long quantity, String outboundNo, String carrier, String trackingNo,
LocalDateTime outboundAt, String remark, String createdBy, LocalDateTime createdAt) {}
public record History(
String fromStatus, String toStatus, String operatorId, String remark,
LocalDateTime createdAt) {}
public record Detail(
Summary summary, String productUrl, String receivingAddress,
String contactName, String contactPhone,
String domesticCarrier, String domesticTrackingNo,
String overseasCarrier, String overseasTrackingNo,
LocalDateTime estimatedArrivalAt, String remark,
String createdBy, String reviewedBy, String reviewRemark, LocalDateTime reviewedAt,
List<Outbound> outbounds, List<History> histories) {}
}
@@ -0,0 +1,48 @@
package com.baiyue.lastleg.procurement.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_overseas_procurement_order")
public class OverseasProcurementOrder {
@TableId(type = IdType.INPUT) private Long id;
private String customerId;
private String customerName;
private String orderNo;
private String purchaseCategory;
private String productName;
private String productSpecification;
private String productUrl;
private Long purchaseQuantity;
private BigDecimal budgetUnitPrice;
private String currency;
private String receivingAddress;
private String contactName;
private String contactPhone;
private String purchaseWarehouseId;
private String purchaseWarehouseName;
private Long inboundQuantity;
private Long outboundQuantity;
private String domesticCarrier;
private String domesticTrackingNo;
private String overseasCarrier;
private String overseasTrackingNo;
private LocalDateTime estimatedArrivalAt;
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 Long version;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.procurement.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_overseas_procurement_outbound")
public class OverseasProcurementOutbound {
@TableId(type = IdType.INPUT) private Long id;
private Long orderId;
private String customerId;
private Long quantity;
private String outboundNo;
private String carrier;
private String trackingNo;
private LocalDateTime outboundAt;
private String remark;
private String createdBy;
private LocalDateTime createdAt;
}
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.procurement.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_overseas_procurement_status_history")
public class OverseasProcurementStatusHistory {
@TableId(type = IdType.INPUT) private Long id;
private Long orderId;
private String customerId;
private String fromStatus;
private String toStatus;
private String operatorId;
private String remark;
private LocalDateTime createdAt;
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.procurement.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface OverseasProcurementOrderMapper extends BaseMapper<OverseasProcurementOrder> {
@Select("SELECT * FROM ll_overseas_procurement_order WHERE customer_id=#{customerId} AND order_no=#{orderNo} LIMIT 1")
OverseasProcurementOrder findNo(@Param("customerId") String customerId, @Param("orderNo") String orderNo);
@Select("SELECT * FROM ll_overseas_procurement_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1")
OverseasProcurementOrder findKey(@Param("customerId") String customerId, @Param("key") String key);
@Select("SELECT * FROM ll_overseas_procurement_order WHERE customer_id=#{customerId} AND order_no=#{orderNo} LIMIT 1 FOR UPDATE")
OverseasProcurementOrder lockNo(@Param("customerId") String customerId, @Param("orderNo") String orderNo);
@Update("UPDATE ll_overseas_procurement_order SET outbound_quantity=outbound_quantity+#{quantity},version=version+1," +
"updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND customer_id=#{customerId} " +
"AND inbound_quantity-outbound_quantity>=#{quantity}")
int addOutbound(@Param("id") Long id, @Param("customerId") String customerId, @Param("quantity") long quantity);
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.procurement.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementOutbound;
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 OverseasProcurementOutboundMapper extends BaseMapper<OverseasProcurementOutbound> {
@Select("SELECT * FROM ll_overseas_procurement_outbound WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY outbound_at DESC,id DESC")
List<OverseasProcurementOutbound> listOrder(@Param("customerId") String customerId,
@Param("orderId") Long orderId);
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.procurement.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementStatusHistory;
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 OverseasProcurementStatusHistoryMapper extends BaseMapper<OverseasProcurementStatusHistory> {
@Select("SELECT * FROM ll_overseas_procurement_status_history WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY created_at,id")
List<OverseasProcurementStatusHistory> listOrder(@Param("customerId") String customerId,
@Param("orderId") Long orderId);
}
@@ -0,0 +1,293 @@
package com.baiyue.lastleg.procurement.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.procurement.dto.OverseasProcurementRequests;
import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementOutbound;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementStatusHistory;
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper;
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOutboundMapper;
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementStatusHistoryMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
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.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HexFormat;
import java.util.Set;
@Service
public class OverseasProcurementService {
public static final String REVIEW_PERMISSION = "lastleg:overseas-procurement:review";
public static final String MANAGE_PERMISSION = "lastleg:overseas-procurement:manage";
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 OverseasProcurementOrderMapper orders;
@Resource private OverseasProcurementOutboundMapper outbounds;
@Resource private OverseasProcurementStatusHistoryMapper histories;
@Resource private ObjectMapper objectMapper;
@Transactional
public OverseasProcurementResponses.Detail create(String customerId, String operatorId,
OverseasProcurementRequests.Create request) {
String key = request.idempotencyKey().trim();
String requestHash = hash(request);
OverseasProcurementOrder replay = orders.findKey(customerId, key);
if (replay != null) return replay(customerId, replay, requestHash);
LocalDateTime now = LocalDateTime.now();
OverseasProcurementOrder order = new OverseasProcurementOrder();
order.setId(IdWorker.getId());
order.setCustomerId(customerId);
order.setCustomerName(trim(request.customerName()));
order.setOrderNo("HC" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + order.getId());
order.setPurchaseCategory(request.purchaseCategory().trim());
order.setProductName(request.productName().trim());
order.setProductSpecification(trim(request.productSpecification()));
order.setProductUrl(trim(request.productUrl()));
order.setPurchaseQuantity(request.purchaseQuantity());
order.setBudgetUnitPrice(request.budgetUnitPrice());
order.setCurrency(defaultCurrency(request.currency()));
order.setReceivingAddress(trim(request.receivingAddress()));
order.setContactName(request.contactName().trim());
order.setContactPhone(request.contactPhone().trim());
order.setInboundQuantity(0L);
order.setOutboundQuantity(0L);
order.setStatus(PENDING_REVIEW);
order.setRemark(trim(request.remark()));
order.setIdempotencyKey(key);
order.setRequestHash(requestHash);
order.setCreatedBy(operatorId);
order.setVersion(0L);
order.setCreatedAt(now);
order.setUpdatedAt(now);
try {
orders.insert(order);
} catch (DuplicateKeyException exception) {
OverseasProcurementOrder concurrent = orders.findKey(customerId, key);
if (concurrent != null) return replay(customerId, concurrent, requestHash);
throw exception;
}
history(order, null, PENDING_REVIEW, operatorId, request.remark(), now);
return detail(customerId, order.getOrderNo());
}
public PageResult<OverseasProcurementResponses.Summary> page(
String customerId, OverseasProcurementRequests.Query query) {
validateQuery(query);
LambdaQueryWrapper<OverseasProcurementOrder> wrapper = new LambdaQueryWrapper<OverseasProcurementOrder>()
.eq(StringUtils.hasText(customerId), OverseasProcurementOrder::getCustomerId, customerId)
.like(StringUtils.hasText(query.orderNo()), OverseasProcurementOrder::getOrderNo, query.orderNo())
.and(StringUtils.hasText(query.customerKeyword()), value -> value
.like(OverseasProcurementOrder::getCustomerId, query.customerKeyword())
.or().like(OverseasProcurementOrder::getCustomerName, query.customerKeyword()))
.like(StringUtils.hasText(query.purchaseCategory()), OverseasProcurementOrder::getPurchaseCategory,
query.purchaseCategory())
.like(StringUtils.hasText(query.productName()), OverseasProcurementOrder::getProductName,
query.productName())
.eq(StringUtils.hasText(query.status()), OverseasProcurementOrder::getStatus,
StringUtils.hasText(query.status()) ? query.status().trim().toUpperCase() : null)
.eq(StringUtils.hasText(query.purchaseWarehouseId()), OverseasProcurementOrder::getPurchaseWarehouseId,
query.purchaseWarehouseId())
.ge(query.createdFrom() != null, OverseasProcurementOrder::getCreatedAt, query.createdFrom())
.le(query.createdTo() != null, OverseasProcurementOrder::getCreatedAt, query.createdTo())
.orderByDesc(OverseasProcurementOrder::getCreatedAt)
.orderByDesc(OverseasProcurementOrder::getId);
Page<OverseasProcurementOrder> result = orders.selectPage(Page.of(query.page(), query.size()), wrapper);
return new PageResult<>(result.getRecords().stream().map(this::summary).toList(), query.page(), query.size(),
result.getTotal(), result.getPages());
}
public OverseasProcurementResponses.Detail detail(String customerId, String orderNo) {
OverseasProcurementOrder order = orders.findNo(customerId, orderNo);
if (order == null) throw notFound("Overseas procurement order not found");
return new OverseasProcurementResponses.Detail(summary(order), order.getProductUrl(),
order.getReceivingAddress(), order.getContactName(), order.getContactPhone(),
order.getDomesticCarrier(), order.getDomesticTrackingNo(), order.getOverseasCarrier(),
order.getOverseasTrackingNo(), order.getEstimatedArrivalAt(), order.getRemark(),
order.getCreatedBy(), order.getReviewedBy(), order.getReviewRemark(), order.getReviewedAt(),
outbounds.listOrder(customerId, order.getId()).stream().map(this::outbound).toList(),
histories.listOrder(customerId, order.getId()).stream().map(value ->
new OverseasProcurementResponses.History(value.getFromStatus(), value.getToStatus(),
value.getOperatorId(), value.getRemark(), value.getCreatedAt())).toList());
}
@Transactional
public OverseasProcurementResponses.Detail review(String customerId, String orderNo, String operatorId,
OverseasProcurementRequests.Review request) {
String next = switch (request.decision().trim().toUpperCase()) {
case "APPROVE", APPROVED -> APPROVED;
case "REJECT", REJECTED -> REJECTED;
default -> throw badRequest("Review decision must be APPROVE or REJECT");
};
OverseasProcurementOrder order = lock(customerId, orderNo);
if (next.equals(order.getStatus())) return detail(customerId, orderNo);
if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("Overseas procurement order was already reviewed");
LocalDateTime now = LocalDateTime.now();
order.setStatus(next);
order.setReviewedBy(operatorId);
order.setReviewRemark(trim(request.remark()));
order.setReviewedAt(now);
touch(order, now);
orders.updateById(order);
history(order, PENDING_REVIEW, next, operatorId, request.remark(), now);
return detail(customerId, orderNo);
}
@Transactional
public OverseasProcurementResponses.Detail update(String customerId, String orderNo,
OverseasProcurementRequests.Update request) {
OverseasProcurementOrder order = lock(customerId, orderNo);
if (!request.version().equals(order.getVersion())) throw conflict("Order was changed; refresh and retry");
if (request.inboundQuantity() != null) {
if (!APPROVED.equals(order.getStatus())) throw conflict("Only an approved procurement order can receive stock");
if (request.inboundQuantity() < value(order.getOutboundQuantity())) {
throw conflict("Inbound quantity cannot be lower than the recorded outbound quantity");
}
order.setInboundQuantity(request.inboundQuantity());
}
setIfText(request.purchaseCategory(), order::setPurchaseCategory);
setIfText(request.productName(), order::setProductName);
setIfPresent(request.productSpecification(), order::setProductSpecification);
setIfPresent(request.productUrl(), order::setProductUrl);
setIfPresent(request.purchaseWarehouseId(), order::setPurchaseWarehouseId);
setIfPresent(request.purchaseWarehouseName(), order::setPurchaseWarehouseName);
setIfPresent(request.domesticCarrier(), order::setDomesticCarrier);
setIfPresent(request.domesticTrackingNo(), order::setDomesticTrackingNo);
setIfPresent(request.overseasCarrier(), order::setOverseasCarrier);
setIfPresent(request.overseasTrackingNo(), order::setOverseasTrackingNo);
if (request.estimatedArrivalAt() != null) order.setEstimatedArrivalAt(request.estimatedArrivalAt());
setIfPresent(request.remark(), order::setRemark);
touch(order, LocalDateTime.now());
orders.updateById(order);
return detail(customerId, orderNo);
}
@Transactional
public OverseasProcurementResponses.Detail addOutbound(String customerId, String orderNo, String operatorId,
OverseasProcurementRequests.AddOutbound request) {
OverseasProcurementOrder order = lock(customerId, orderNo);
if (!APPROVED.equals(order.getStatus())) throw conflict("Only an approved procurement order can ship stock");
if (orders.addOutbound(order.getId(), customerId, request.quantity()) != 1) {
throw conflict("Outbound quantity exceeds current procurement inventory");
}
OverseasProcurementOutbound outbound = new OverseasProcurementOutbound();
outbound.setId(IdWorker.getId());
outbound.setOrderId(order.getId());
outbound.setCustomerId(customerId);
outbound.setQuantity(request.quantity());
outbound.setOutboundNo(request.outboundNo().trim());
outbound.setCarrier(trim(request.carrier()));
outbound.setTrackingNo(trim(request.trackingNo()));
outbound.setOutboundAt(request.outboundAt() == null ? LocalDateTime.now() : request.outboundAt());
outbound.setRemark(trim(request.remark()));
outbound.setCreatedBy(operatorId);
outbound.setCreatedAt(LocalDateTime.now());
try {
outbounds.insert(outbound);
} catch (DuplicateKeyException exception) {
throw conflict("Outbound number already exists for this procurement order");
}
return detail(customerId, orderNo);
}
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");
}
return detail(customerId, order.getOrderNo());
}
private OverseasProcurementOrder lock(String customerId, String orderNo) {
OverseasProcurementOrder order = orders.lockNo(customerId, orderNo);
if (order == null) throw notFound("Overseas procurement order not found");
return order;
}
private OverseasProcurementResponses.Summary summary(OverseasProcurementOrder order) {
long inbound = value(order.getInboundQuantity());
long outbound = value(order.getOutboundQuantity());
return new OverseasProcurementResponses.Summary(order.getId(), order.getOrderNo(), order.getCustomerId(),
order.getCustomerName(), order.getPurchaseCategory(), order.getProductName(),
order.getProductSpecification(), value(order.getPurchaseQuantity()), order.getBudgetUnitPrice(),
order.getCurrency(), order.getPurchaseWarehouseId(), order.getPurchaseWarehouseName(), inbound, outbound,
inbound - outbound, order.getStatus(), value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt());
}
private OverseasProcurementResponses.Outbound outbound(OverseasProcurementOutbound value) {
return new OverseasProcurementResponses.Outbound(value.getId(), value(value.getQuantity()), value.getOutboundNo(),
value.getCarrier(), value.getTrackingNo(), value.getOutboundAt(), value.getRemark(), value.getCreatedBy(),
value.getCreatedAt());
}
private void history(OverseasProcurementOrder order, String from, String to, String operator,
String remark, LocalDateTime now) {
OverseasProcurementStatusHistory history = new OverseasProcurementStatusHistory();
history.setId(IdWorker.getId());
history.setOrderId(order.getId());
history.setCustomerId(order.getCustomerId());
history.setFromStatus(from);
history.setToStatus(to);
history.setOperatorId(operator);
history.setRemark(trim(remark));
history.setCreatedAt(now);
histories.insert(history);
}
private void validateQuery(OverseasProcurementRequests.Query query) {
if (StringUtils.hasText(query.status()) && !STATUSES.contains(query.status().trim().toUpperCase())) {
throw badRequest("Unsupported procurement 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 HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json));
} catch (JsonProcessingException | NoSuchAlgorithmException exception) {
throw new IllegalStateException("Cannot hash procurement request", exception);
}
}
private static void touch(OverseasProcurementOrder order, LocalDateTime now) {
order.setVersion(value(order.getVersion()) + 1);
order.setUpdatedAt(now);
}
private static long value(Long value) { return value == null ? 0 : value; }
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) {
if (StringUtils.hasText(value)) setter.accept(value.trim());
}
private static void setIfPresent(String value, java.util.function.Consumer<String> setter) {
if (value != null) setter.accept(trim(value));
}
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);
}
}
@@ -174,12 +174,15 @@ public class RelabelApplicationService {
LastlegOrder order = orders.selectById(application.getLastlegOrderId());
if (order == null || !customerId.equals(order.getCustomerId())) throw conflict("Linked tail-leg order is missing");
String orderBefore = order.getCurrentStatus();
order.setCurrentStatus(next);
// A return-relabel tail order joins the normal inbound queue after labels are finished.
String orderStatus = "COMPLETED".equals(next) && "RETURN_RELABEL".equals(order.getOrderType())
? "PENDING_ASSIGNMENT" : next;
order.setCurrentStatus(orderStatus);
order.setRelabelStatus(next);
order.setUpdatedAt(now);
orders.updateById(order);
insertHistory(customerId, application.getId(), before, next, operatorId, request.remark(), now);
insertOrderHistory(order.getId(), orderBefore, next, request.remark(), now);
insertOrderHistory(order.getId(), orderBefore, orderStatus, request.remark(), now);
return detail(customerId, relabelNo);
}
@@ -6,6 +6,11 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.dto.LastlegOrderDetailResponse;
import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.dto.LastlegOrderSummary;
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
import com.baiyue.lastleg.dropship.mapper.DropshipCartonMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipSkuInventoryMapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
@@ -24,6 +29,8 @@ import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Map;
import java.util.stream.Collectors;
@Service
public class LastlegOrderService {
@@ -34,6 +41,9 @@ public class LastlegOrderService {
@Resource private LastlegOrderMapper orderMapper;
@Resource private LastlegOrderDetailMapper detailMapper;
@Resource private LastlegOrderStatusHistoryMapper historyMapper;
@Resource private DropshipOrderProfileMapper dropshipProfiles;
@Resource private DropshipSkuInventoryMapper dropshipSkuInventory;
@Resource private DropshipCartonMapper dropshipCartons;
public PageResult<LastlegOrderSummary> page(String customerId, LastlegOrderRequests.Query query) {
Page<LastlegOrder> page = orderMapper.selectPage(
@@ -50,7 +60,23 @@ public class LastlegOrderService {
new LambdaQueryWrapper<LastlegOrderStatusHistory>()
.eq(LastlegOrderStatusHistory::getOrderId, orderId)
.orderByAsc(LastlegOrderStatusHistory::getCreatedAt));
return new LastlegOrderDetailResponse(order, cargo, history);
List<LastlegOrderDetailResponse.DropshipPallet> pallets = dropshipPallets(customerId, order);
return new LastlegOrderDetailResponse(order, cargo, history, pallets);
}
private List<LastlegOrderDetailResponse.DropshipPallet> dropshipPallets(String customerId, LastlegOrder order) {
if (!"ONE_CLICK_DROPSHIP".equals(order.getOrderType())) return List.of();
var profiles = dropshipProfiles.listOrder(customerId, order.getId());
if (profiles.isEmpty()) return List.of();
Map<Long, List<DropshipSkuInventory>> skuByProfile = dropshipSkuInventory
.listOrder(customerId, order.getId()).stream().collect(Collectors.groupingBy(DropshipSkuInventory::getProfileId));
Map<Long, List<DropshipCarton>> cartonsByProfile = dropshipCartons
.listOrder(customerId, order.getId()).stream().collect(Collectors.groupingBy(DropshipCarton::getProfileId));
return profiles.stream().map(profile -> new LastlegOrderDetailResponse.DropshipPallet(
profile.getId(), profile.getSourcePalletNo(), profile.getWarehousePalletId(), profile.getPalletNo(),
profile.getInventoryPositionId(), profile.getStatus(),
skuByProfile.getOrDefault(profile.getId(), List.of()),
cartonsByProfile.getOrDefault(profile.getId(), List.of()))).toList();
}
@Transactional
@@ -89,14 +89,14 @@ public class LoadingCompletedConsumer {
return;
}
LastlegOrder order=new LastlegOrder();
String orderType = rawSource.path("orderType").asText(event.payload().path("orderType").asText("FIRST_LEG"));
String customerId = rawSource.path("customerId")
.asText(event.payload().path("customerId").asText(event.tenantId()));
String orderType = source.orderType();
String customerId = source.customerId() == null || source.customerId().isBlank()
? event.tenantId() : source.customerId();
if (!event.tenantId().equals(customerId)) {
throw new IllegalArgumentException("Order customerId does not match the trusted event customer");
}
List<DropshipRequests.Pallet> dropshipPallets = "ONE_CLICK_DROPSHIP".equals(orderType)
? pallets(rawSource) : List.of();
? pallets(source, rawSource) : List.of();
if ("ONE_CLICK_DROPSHIP".equals(orderType) && dropshipPallets.isEmpty()) {
throw new IllegalArgumentException("One-click dropship event has no pallet snapshot");
}
@@ -146,7 +146,17 @@ public class LoadingCompletedConsumer {
}
}
private List<DropshipRequests.Pallet> pallets(JsonNode source) {
private List<DropshipRequests.Pallet> pallets(FirstLegLoadingPlanCompletedV1.Order source, JsonNode rawSource) {
if (source.pallets() != null && !source.pallets().isEmpty()) {
return source.pallets().stream().map(pallet -> new DropshipRequests.Pallet(pallet.palletNo(),
pallet.skuPackages().stream().map(sku -> new DropshipRequests.SkuPackages(
sku.skuCode(), sku.productName(), sku.packageCount(), sku.productQuantity(),
sku.cartonMarks())).toList())).toList();
}
return legacyPallets(rawSource);
}
private List<DropshipRequests.Pallet> legacyPallets(JsonNode source) {
List<DropshipRequests.Pallet> result = new ArrayList<>();
JsonNode nodes = source.path("pallets");
if (nodes.isArray()) {
@@ -29,6 +29,21 @@ public class WarehouseTransferController {
return ApiResponse.success(service.detail(scope(user, customerId), transferNo));
}
@PostMapping("/{transferNo}/review")
public ApiResponse<WarehouseTransferResponses.Detail> review(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @PathVariable String transferNo,
@Valid @RequestBody WarehouseTransferRequests.Review request) {
requireApprovalPermission(user);
return ApiResponse.success(service.review(scope(user, customerId), transferNo, operator(user), request));
}
private void requireApprovalPermission(AuthenticatedUser user) {
if (user == null || user.permissions() == null
|| !user.permissions().contains("lastleg:warehouse-transfer:approve")) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Warehouse transfer approval permission is required");
}
}
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) {
@@ -30,6 +30,9 @@ public final class WarehouseTransferRequests {
@NotEmpty @Size(max = 500) List<@Valid Item> items
) {}
public record Review(@NotBlank @Size(max = 32) String decision,
@Size(max = 1000) String remark) {}
public record Item(
@NotBlank @Size(max = 128) String skuCode,
@NotBlank @Size(max = 255) String productNameZh,
@@ -9,4 +9,6 @@ import org.apache.ibatis.annotations.Select;
WarehouseTransferOrder findKey(@Param("customerId") String customerId, @Param("key") String key);
@Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customerId} AND transfer_no=#{transferNo} LIMIT 1")
WarehouseTransferOrder findNo(@Param("customerId") String customerId, @Param("transferNo") String transferNo);
@Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customerId} AND transfer_no=#{transferNo} LIMIT 1 FOR UPDATE")
WarehouseTransferOrder lockNo(@Param("customerId") String customerId, @Param("transferNo") String transferNo);
}
@@ -43,7 +43,11 @@ import java.util.List;
@Service
public class WarehouseTransferService {
public static final String ORDER_TYPE = "WAREHOUSE_TRANSFER";
public static final String RETURN_RELABEL_ORDER_TYPE = "RETURN_RELABEL";
public static final String PENDING_REVIEW = "PENDING_REVIEW";
public static final String APPROVED = "APPROVED";
public static final String REJECTED = "REJECTED";
public static final String PENDING_ASSIGNMENT = "PENDING_ASSIGNMENT";
@Resource private WarehouseTransferOrderMapper transfers;
@Resource private WarehouseTransferItemMapper transferItems;
@@ -124,7 +128,8 @@ public class WarehouseTransferService {
insertTransferItem(customerId, transferId, source, now);
insertOrderDetail(order.getId(), transferNo, index, source, request, now);
}
insertOrderHistory(order.getId(), PENDING_REVIEW, transferNo, now);
insertOrderHistory(order.getId(), null, PENDING_REVIEW, transferNo,
"Warehouse transfer submitted for review", now);
if (request.needRelabel()) createRelabel(customerId, operatorId, transfer, order, request, now);
return detail(customerId, transferNo);
}
@@ -148,8 +153,11 @@ public class WarehouseTransferService {
RelabelApplication relabel = relabelApplications.findNo(customerId, transfer.getRelabelNo());
relabelStatus = relabel == null ? null : relabel.getStatus();
}
LastlegOrder tailOrder = orders.selectById(transfer.getLastlegOrderId());
return new WarehouseTransferResponses.Detail(transfer.getId(), transfer.getTransferNo(), transfer.getInboundOrderId(),
transfer.getInboundCode(), transfer.getLastlegOrderId(), transfer.getLastlegOrderNo(), ORDER_TYPE, PENDING_REVIEW,
transfer.getInboundCode(), transfer.getLastlegOrderId(), transfer.getLastlegOrderNo(),
tailOrder == null ? ORDER_TYPE : tailOrder.getOrderType(),
tailOrder == null ? (transfer.getStatus() == null ? PENDING_REVIEW : transfer.getStatus()) : tailOrder.getCurrentStatus(),
Boolean.TRUE.equals(transfer.getNeedRelabel()), transfer.getRelabelApplicationId(), transfer.getRelabelNo(),
relabelStatus,
transfer.getOperatingWarehouseId(), transfer.getFbaWarehouseCode(), transfer.getDeliveryAddress(),
@@ -157,16 +165,48 @@ public class WarehouseTransferService {
transfer.getTrackingNo(), transfer.getFbaBoxNos(), transfer.getRemark(), sources, items, transfer.getCreatedAt());
}
@Transactional
public WarehouseTransferResponses.Detail review(String customerId, String transferNo, String operatorId,
WarehouseTransferRequests.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");
};
WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo);
if (transfer == null) throw notFound("Warehouse transfer order not found");
if (!PENDING_REVIEW.equals(transfer.getStatus())) {
if (next.equals(transfer.getStatus())) return detail(customerId, transferNo);
throw conflict("Warehouse transfer order was already reviewed");
}
LastlegOrder order = orders.selectById(transfer.getLastlegOrderId());
if (order == null || !customerId.equals(order.getCustomerId())) throw conflict("Linked tail-leg order is missing");
LocalDateTime now = LocalDateTime.now();
String before = order.getCurrentStatus();
transfer.setStatus(next); transfer.setUpdatedAt(now); transfers.updateById(transfer);
String orderStatus = APPROVED.equals(next)
? (Boolean.TRUE.equals(transfer.getNeedRelabel()) ? RelabelApplicationService.PENDING_PROCESSING : PENDING_ASSIGNMENT)
: "CANCELLED";
order.setCurrentStatus(orderStatus); order.setUpdatedAt(now); orders.updateById(order);
insertOrderHistory(order.getId(), before, orderStatus, transferNo,
"Warehouse transfer reviewed: " + next, now);
return detail(customerId, transferNo);
}
private LastlegOrder createTailOrder(String customerId, Long inboundOrderId, String transferNo,
WarehouseTransferRequests.Create request, String requestHash, LocalDateTime now) {
LastlegOrder order = new LastlegOrder();
order.setId(IdWorker.getId()); order.setOrderNo(orderNoGenerator.next()); order.setCustomerId(customerId);
order.setOrderType(ORDER_TYPE); order.setInboundCode(request.inboundCode().trim()); order.setSourceRegion("CUSTOMER");
order.setOrderType(request.needRelabel() ? RETURN_RELABEL_ORDER_TYPE : ORDER_TYPE);
order.setInboundCode(request.inboundCode().trim()); order.setSourceRegion("CUSTOMER");
order.setSourceInboundId("WAREHOUSE_TRANSFER:" + transferNo); order.setRootInboundId(String.valueOf(inboundOrderId));
order.setSourceOrderNo(transferNo); order.setOverseasWarehouseId(request.operatingWarehouseId().trim());
order.setFbaWarehouseCode(request.fbaWarehouseCode().trim()); order.setPackageCount(request.packageCount());
order.setContainerNo(transferNo); order.setTransferWarehouseId(request.operatingWarehouseId().trim());
order.setTransferWarehouseCode(request.operatingWarehouseId().trim());
order.setPalletCount(0); order.setPickedPackageCount(0); order.setShippedPackageCount(0);
order.setEstimatedWarehouseAt(request.estimatedArrivalAt()); order.setInboundOrderNo(request.inboundCode().trim());
order.setEstimatedWarehouseAt(request.estimatedArrivalAt());
order.setCurrentStatus(PENDING_REVIEW); order.setCustomerRemark(trim(request.remark()));
order.setSourceEventId("WAREHOUSE_TRANSFER:" + transferNo); order.setSourceLoadingPlanId(ORDER_TYPE);
order.setSourcePayloadHash("sha256:" + requestHash); order.setCreatedAt(now); order.setUpdatedAt(now);
@@ -229,10 +269,12 @@ public class WarehouseTransferService {
order.setUpdatedAt(now); orders.updateById(order);
}
private void insertOrderHistory(long orderId, String status, String transferNo, LocalDateTime now) {
private void insertOrderHistory(long orderId, String fromStatus, String toStatus, String transferNo,
String remark, LocalDateTime now) {
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory();
history.setOrderId(orderId); history.setToStatus(status); history.setEventId("WAREHOUSE_TRANSFER:" + transferNo);
history.setRemark("Warehouse transfer submitted for review"); history.setCreatedAt(now); orderHistories.insert(history);
history.setOrderId(orderId); history.setFromStatus(fromStatus); history.setToStatus(toStatus);
history.setEventId("WAREHOUSE_TRANSFER:" + transferNo); history.setRemark(remark);
history.setCreatedAt(now); orderHistories.insert(history);
}
private String hash(Object value) {
@@ -0,0 +1,62 @@
package com.baiyue.lastleg.trucking.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.trucking.dto.UsTruckOrderRequests;
import com.baiyue.lastleg.trucking.dto.UsTruckOrderResponses;
import com.baiyue.lastleg.trucking.service.UsTruckOrderService;
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.PatchMapping;
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.RestController;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/merchant/v1/last-leg/us-truck-orders")
public class MerchantUsTruckOrderController {
@Resource private UsTruckOrderService service;
@PostMapping
public ApiResponse<UsTruckOrderResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody UsTruckOrderRequests.Create request) {
return ApiResponse.success(service.create(customer(user), operator(user), request));
}
@GetMapping
public ApiResponse<PageResult<UsTruckOrderResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid UsTruckOrderRequests.Query query) {
return ApiResponse.success(service.page(customer(user), query));
}
@GetMapping("/{orderNo}")
public ApiResponse<UsTruckOrderResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String orderNo) {
return ApiResponse.success(service.detail(customer(user), orderNo));
}
@PatchMapping("/{orderNo}")
public ApiResponse<UsTruckOrderResponses.Detail> update(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.Update request) {
return ApiResponse.success(service.update(customer(user), orderNo, request));
}
private String customer(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required");
}
return String.valueOf(user.tenantId());
}
private String operator(AuthenticatedUser user) {
return user.userId() == null ? user.username() : String.valueOf(user.userId());
}
}
@@ -0,0 +1,74 @@
package com.baiyue.lastleg.trucking.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.trucking.dto.UsTruckOrderRequests;
import com.baiyue.lastleg.trucking.dto.UsTruckOrderResponses;
import com.baiyue.lastleg.trucking.service.UsTruckOrderService;
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.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
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;
@RestController
@RequestMapping("/api/platform/v1/last-leg/us-truck-orders")
public class PlatformUsTruckOrderController {
@Resource private UsTruckOrderService service;
@GetMapping
public ApiResponse<PageResult<UsTruckOrderResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid UsTruckOrderRequests.Query query) {
requirePlatform(user);
return ApiResponse.success(service.page(null, query));
}
@GetMapping("/{orderNo}")
public ApiResponse<UsTruckOrderResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String customerId, @PathVariable String orderNo) {
requirePlatform(user);
return ApiResponse.success(service.detail(requiredCustomer(customerId), orderNo));
}
@PatchMapping("/{orderNo}/status")
public ApiResponse<UsTruckOrderResponses.Detail> changeStatus(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String customerId, @PathVariable String orderNo,
@Valid @RequestBody UsTruckOrderRequests.ChangeStatus request) {
requirePermission(user);
return ApiResponse.success(service.changeStatus(requiredCustomer(customerId), orderNo, operator(user), request));
}
private void requirePlatform(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform identity is required");
}
}
private void requirePermission(AuthenticatedUser user) {
requirePlatform(user);
if (user.permissions() == null || !user.permissions().contains(UsTruckOrderService.MANAGE_PERMISSION)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
UsTruckOrderService.MANAGE_PERMISSION + " permission is required");
}
}
private String requiredCustomer(String customerId) {
if (customerId == null || customerId.isBlank() || "0".equals(customerId.trim())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Target customerId is required");
}
return customerId.trim();
}
private String operator(AuthenticatedUser user) {
return user.userId() == null ? user.username() : String.valueOf(user.userId());
}
}
@@ -0,0 +1,61 @@
package com.baiyue.lastleg.trucking.dto;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDate;
public final class UsTruckOrderRequests {
private UsTruckOrderRequests() {}
public record Create(
@NotBlank @Size(max = 32) String transportType,
@NotBlank @Size(max = 255) String origin,
@Size(max = 500) String originAddress,
@NotBlank @Size(max = 255) String destination,
@Size(max = 500) String destinationAddress,
@NotBlank @Size(max = 32) String containerType,
@NotNull @Min(0) Integer packageCount,
@NotNull @DecimalMin("0.00") BigDecimal totalWeightKg,
@NotNull @DecimalMin("0.00") BigDecimal totalVolumeCbm,
@DecimalMin("0.00") BigDecimal budgetAmount,
@Size(min = 3, max = 3) String currency,
@NotBlank @Size(max = 128) String contactName,
@NotBlank @Size(max = 128) String contactInfo,
@NotNull LocalDate requestedDate,
@Size(max = 1000) String remark,
@NotBlank @Size(max = 255) String idempotencyKey) {}
public record Query(
@Min(1) long page, @Min(1) @Max(200) long size,
String orderNo, String routeKeyword, String status, String transportType,
String customerId, LocalDate requestedFrom, LocalDate requestedTo) {}
public record Update(
@NotNull @Min(0) Long version,
@Size(max = 32) String transportType,
@Size(max = 255) String origin,
@Size(max = 500) String originAddress,
@Size(max = 255) String destination,
@Size(max = 500) String destinationAddress,
@Size(max = 32) String containerType,
@Min(0) Integer packageCount,
@DecimalMin("0.00") BigDecimal totalWeightKg,
@DecimalMin("0.00") BigDecimal totalVolumeCbm,
@DecimalMin("0.00") BigDecimal budgetAmount,
@Size(min = 3, max = 3) String currency,
@Size(max = 128) String contactName,
@Size(max = 128) String contactInfo,
LocalDate requestedDate,
@Size(max = 1000) String remark) {}
public record ChangeStatus(
@NotNull @Min(0) Long version,
@NotBlank @Size(max = 32) String status,
@Size(max = 1000) String remark) {}
}
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.trucking.dto;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
public final class UsTruckOrderResponses {
private UsTruckOrderResponses() {}
public record Summary(
Long id, String customerId, String orderNo, String transportType,
String origin, String destination, String containerType, int packageCount,
BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, BigDecimal budgetAmount,
String currency, String contactName, String contactInfo, LocalDate requestedDate,
String status, long version, LocalDateTime createdAt, LocalDateTime updatedAt) {}
public record History(
String fromStatus, String toStatus, String operatorId, String remark, LocalDateTime createdAt) {}
public record Detail(
Summary summary, String originAddress, String destinationAddress, String remark,
String createdBy, List<History> histories) {}
}
@@ -0,0 +1,40 @@
package com.baiyue.lastleg.trucking.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.LocalDate;
import java.time.LocalDateTime;
@Data
@TableName("ll_us_truck_order")
public class UsTruckOrder {
@TableId(type = IdType.INPUT) private Long id;
private String customerId;
private String orderNo;
private String transportType;
private String origin;
private String originAddress;
private String destination;
private String destinationAddress;
private String containerType;
private Integer packageCount;
private BigDecimal totalWeightKg;
private BigDecimal totalVolumeCbm;
private BigDecimal budgetAmount;
private String currency;
private String contactName;
private String contactInfo;
private LocalDate requestedDate;
private String status;
private String remark;
private String idempotencyKey;
private String requestHash;
private String createdBy;
private Long version;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.trucking.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_us_truck_order_status_history")
public class UsTruckOrderStatusHistory {
@TableId(type = IdType.INPUT) private Long id;
private Long orderId;
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.trucking.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.trucking.entity.UsTruckOrder;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface UsTruckOrderMapper extends BaseMapper<UsTruckOrder> {
@Select("SELECT * FROM ll_us_truck_order WHERE customer_id=#{customerId} AND order_no=#{orderNo} LIMIT 1")
UsTruckOrder findNo(@Param("customerId") String customerId, @Param("orderNo") String orderNo);
@Select("SELECT * FROM ll_us_truck_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1")
UsTruckOrder findKey(@Param("customerId") String customerId, @Param("key") String key);
@Select("SELECT * FROM ll_us_truck_order WHERE customer_id=#{customerId} AND order_no=#{orderNo} LIMIT 1 FOR UPDATE")
UsTruckOrder lockNo(@Param("customerId") String customerId, @Param("orderNo") String orderNo);
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.trucking.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.trucking.entity.UsTruckOrderStatusHistory;
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 UsTruckOrderStatusHistoryMapper extends BaseMapper<UsTruckOrderStatusHistory> {
@Select("SELECT * FROM ll_us_truck_order_status_history WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY created_at,id")
List<UsTruckOrderStatusHistory> listOrder(@Param("customerId") String customerId,
@Param("orderId") Long orderId);
}
@@ -0,0 +1,153 @@
package com.baiyue.lastleg.trucking.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.trucking.dto.UsTruckOrderRequests;
import com.baiyue.lastleg.trucking.dto.UsTruckOrderResponses;
import com.baiyue.lastleg.trucking.entity.UsTruckOrder;
import com.baiyue.lastleg.trucking.entity.UsTruckOrderStatusHistory;
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderMapper;
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderStatusHistoryMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
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.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HexFormat;
import java.util.List;
import java.util.Set;
import java.util.function.Consumer;
@Service
public class UsTruckOrderService {
public static final String MANAGE_PERMISSION = "lastleg:us-trucking:manage";
public static final String PENDING_CONFIRMATION = "PENDING_CONFIRMATION";
public static final String PENDING_DISPATCH = "PENDING_DISPATCH";
public static final String DISPATCHED = "DISPATCHED";
public static final String IN_TRANSIT = "IN_TRANSIT";
public static final String COMPLETED = "COMPLETED";
public static final String CANCELLED = "CANCELLED";
private static final Set<String> STATUSES = Set.of(PENDING_CONFIRMATION, PENDING_DISPATCH, DISPATCHED,
IN_TRANSIT, COMPLETED, CANCELLED);
private static final Set<String> TRANSPORT_TYPES = Set.of("PORT_PICKUP", "WAREHOUSE_TO_WAREHOUSE", "WAREHOUSE_TO_DOOR");
@Resource private UsTruckOrderMapper orders;
@Resource private UsTruckOrderStatusHistoryMapper histories;
@Resource private ObjectMapper objectMapper;
@Transactional
public UsTruckOrderResponses.Detail create(String customerId, String operatorId, UsTruckOrderRequests.Create request) {
String key = request.idempotencyKey().trim();
String hash = hash(request);
UsTruckOrder replay = orders.findKey(customerId, key);
if (replay != null) return replay(customerId, replay, hash);
validateTransport(request.transportType());
LocalDateTime now = LocalDateTime.now();
UsTruckOrder order = new UsTruckOrder();
order.setId(IdWorker.getId());
order.setCustomerId(customerId);
order.setOrderNo("TK" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + order.getId());
order.setTransportType(request.transportType().trim().toUpperCase());
order.setOrigin(trim(request.origin())); order.setOriginAddress(trim(request.originAddress()));
order.setDestination(trim(request.destination())); order.setDestinationAddress(trim(request.destinationAddress()));
order.setContainerType(request.containerType().trim().toUpperCase()); order.setPackageCount(request.packageCount());
order.setTotalWeightKg(request.totalWeightKg()); order.setTotalVolumeCbm(request.totalVolumeCbm());
order.setBudgetAmount(request.budgetAmount()); order.setCurrency(defaultCurrency(request.currency()));
order.setContactName(request.contactName().trim()); order.setContactInfo(request.contactInfo().trim());
order.setRequestedDate(request.requestedDate()); order.setStatus(PENDING_CONFIRMATION);
order.setRemark(trim(request.remark())); order.setIdempotencyKey(key); order.setRequestHash(hash);
order.setCreatedBy(operatorId); order.setVersion(0L); order.setCreatedAt(now); order.setUpdatedAt(now);
try { orders.insert(order); }
catch (DuplicateKeyException e) {
UsTruckOrder concurrent = orders.findKey(customerId, key);
if (concurrent != null) return replay(customerId, concurrent, hash);
throw e;
}
history(order, null, PENDING_CONFIRMATION, operatorId, request.remark(), now);
return detail(customerId, order.getOrderNo());
}
public PageResult<UsTruckOrderResponses.Summary> page(String customerId, UsTruckOrderRequests.Query query) {
validateQuery(query);
LambdaQueryWrapper<UsTruckOrder> w = new LambdaQueryWrapper<UsTruckOrder>()
.eq(StringUtils.hasText(customerId), UsTruckOrder::getCustomerId, customerId)
.like(StringUtils.hasText(query.orderNo()), UsTruckOrder::getOrderNo, query.orderNo())
.and(StringUtils.hasText(query.routeKeyword()), x -> x.like(UsTruckOrder::getOrigin, query.routeKeyword())
.or().like(UsTruckOrder::getDestination, query.routeKeyword()))
.eq(StringUtils.hasText(query.status()), UsTruckOrder::getStatus,
StringUtils.hasText(query.status()) ? norm(query.status()) : null)
.eq(StringUtils.hasText(query.transportType()), UsTruckOrder::getTransportType,
StringUtils.hasText(query.transportType()) ? norm(query.transportType()) : null)
.eq(StringUtils.hasText(query.customerId()), UsTruckOrder::getCustomerId, query.customerId())
.ge(query.requestedFrom() != null, UsTruckOrder::getRequestedDate, query.requestedFrom())
.le(query.requestedTo() != null, UsTruckOrder::getRequestedDate, query.requestedTo())
.orderByDesc(UsTruckOrder::getCreatedAt).orderByDesc(UsTruckOrder::getId);
Page<UsTruckOrder> result = orders.selectPage(Page.of(query.page(), query.size()), w);
return new PageResult<>(result.getRecords().stream().map(this::summary).toList(), result.getCurrent(),
result.getSize(), result.getTotal(), result.getPages());
}
public UsTruckOrderResponses.Detail detail(String customerId, String orderNo) {
UsTruckOrder order = orders.findNo(customerId, orderNo);
if (order == null) throw notFound();
return detail(order, histories.listOrder(customerId, order.getId()));
}
@Transactional
public UsTruckOrderResponses.Detail update(String customerId, String orderNo, UsTruckOrderRequests.Update request) {
UsTruckOrder order = lock(customerId, orderNo);
if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry");
if (!PENDING_CONFIRMATION.equals(order.getStatus())) throw conflict("Only pending orders can be edited");
if (request.transportType() != null) { validateTransport(request.transportType()); order.setTransportType(norm(request.transportType())); }
setText(request.origin(), order::setOrigin); setPresent(request.originAddress(), order::setOriginAddress);
setText(request.destination(), order::setDestination); setPresent(request.destinationAddress(), order::setDestinationAddress);
setText(request.containerType(), v -> order.setContainerType(norm(v))); if (request.packageCount() != null) order.setPackageCount(request.packageCount());
if (request.totalWeightKg() != null) order.setTotalWeightKg(request.totalWeightKg()); if (request.totalVolumeCbm() != null) order.setTotalVolumeCbm(request.totalVolumeCbm());
if (request.budgetAmount() != null) order.setBudgetAmount(request.budgetAmount()); if (request.currency() != null) order.setCurrency(defaultCurrency(request.currency()));
setText(request.contactName(), order::setContactName); setText(request.contactInfo(), order::setContactInfo);
if (request.requestedDate() != null) order.setRequestedDate(request.requestedDate()); setPresent(request.remark(), order::setRemark);
touch(order); orders.updateById(order); return detail(customerId, orderNo);
}
@Transactional
public UsTruckOrderResponses.Detail changeStatus(String customerId, String orderNo, String operatorId,
UsTruckOrderRequests.ChangeStatus request) {
UsTruckOrder order = lock(customerId, orderNo);
if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry");
String next = norm(request.status()); if (!STATUSES.contains(next)) throw badRequest("Unsupported truck order status");
String from = order.getStatus(); if (!next.equals(from)) { order.setStatus(next); touch(order); orders.updateById(order); history(order, from, next, operatorId, request.remark(), LocalDateTime.now()); }
return detail(customerId, orderNo);
}
private UsTruckOrder lock(String customerId, String orderNo) { UsTruckOrder o = orders.lockNo(customerId, orderNo); if (o == null) throw notFound(); return o; }
private UsTruckOrderResponses.Detail replay(String customerId, UsTruckOrder order, String hash) { if (!hash.equals(order.getRequestHash())) throw conflict("Idempotency key was reused with different truck data"); return detail(customerId, order.getOrderNo()); }
private UsTruckOrderResponses.Detail detail(UsTruckOrder o, List<UsTruckOrderStatusHistory> hs) { return new UsTruckOrderResponses.Detail(summary(o), o.getOriginAddress(), o.getDestinationAddress(), o.getRemark(), o.getCreatedBy(), hs.stream().map(h -> new UsTruckOrderResponses.History(h.getFromStatus(), h.getToStatus(), h.getOperatorId(), h.getRemark(), h.getCreatedAt())).toList()); }
private UsTruckOrderResponses.Summary summary(UsTruckOrder o) { return new UsTruckOrderResponses.Summary(o.getId(), o.getCustomerId(), o.getOrderNo(), o.getTransportType(), o.getOrigin(), o.getDestination(), o.getContainerType(), value(o.getPackageCount()), o.getTotalWeightKg(), o.getTotalVolumeCbm(), o.getBudgetAmount(), o.getCurrency(), o.getContactName(), o.getContactInfo(), o.getRequestedDate(), o.getStatus(), value(o.getVersion()), o.getCreatedAt(), o.getUpdatedAt()); }
private void history(UsTruckOrder o, String from, String to, String operator, String remark, LocalDateTime at) { UsTruckOrderStatusHistory h = new UsTruckOrderStatusHistory(); h.setId(IdWorker.getId()); h.setOrderId(o.getId()); h.setCustomerId(o.getCustomerId()); h.setFromStatus(from); h.setToStatus(to); h.setOperatorId(operator); h.setRemark(trim(remark)); h.setCreatedAt(at); histories.insert(h); }
private void validateQuery(UsTruckOrderRequests.Query q) { if (StringUtils.hasText(q.status()) && !STATUSES.contains(norm(q.status()))) throw badRequest("Unsupported truck order status"); if (StringUtils.hasText(q.transportType())) validateTransport(q.transportType()); if (q.requestedFrom() != null && q.requestedTo() != null && q.requestedFrom().isAfter(q.requestedTo())) throw badRequest("requestedFrom must not be after requestedTo"); }
private void validateTransport(String v) { if (!TRANSPORT_TYPES.contains(norm(v))) throw badRequest("Unsupported transport type"); }
private String hash(Object v) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(objectMapper.writeValueAsBytes(v))); } catch (JsonProcessingException | NoSuchAlgorithmException e) { throw new IllegalStateException("Cannot hash truck request", e); } }
private static void touch(UsTruckOrder o) { o.setVersion(value(o.getVersion()) + 1); o.setUpdatedAt(LocalDateTime.now()); }
private static void setText(String v, Consumer<String> c) { if (StringUtils.hasText(v)) c.accept(v.trim()); }
private static void setPresent(String v, Consumer<String> c) { if (v != null) c.accept(trim(v)); }
private static String trim(String v) { return v == null || v.isBlank() ? null : v.trim(); }
private static String norm(String v) { return v.trim().toUpperCase(); }
private static String defaultCurrency(String v) { return StringUtils.hasText(v) ? norm(v) : "USD"; }
private static int value(Integer v) { return v == null ? 0 : v; } private static long value(Long v) { return v == null ? 0 : v; }
private static ResponseStatusException badRequest(String m) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, m); }
private static ResponseStatusException conflict(String m) { return new ResponseStatusException(HttpStatus.CONFLICT, m); }
private static ResponseStatusException notFound() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "US truck order not found"); }
}
+131
View File
@@ -928,3 +928,134 @@ CREATE TABLE IF NOT EXISTS ll_dropship_carton_ledger (
occurred_at DATETIME(3) NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY(id),
UNIQUE KEY uk_ll_dropship_ledger_key(customer_id,idempotency_key), KEY idx_ll_dropship_ledger_sku(customer_id,sku_code,carton_mark)
) ENGINE=InnoDB COMMENT='Immutable dropship SKU/carton-mark package ledger';
CREATE TABLE IF NOT EXISTS ll_cargo_discard_application (
id BIGINT NOT NULL COMMENT 'Snowflake ID', customer_id VARCHAR(128) NOT NULL,
discard_no VARCHAR(64) NOT NULL, overseas_warehouse_id VARCHAR(128) NOT NULL,
overseas_warehouse_name VARCHAR(255) NULL, inbound_codes VARCHAR(2000) NOT NULL,
total_packages INT NOT NULL, fee_per_package DECIMAL(18,2) NOT NULL,
total_fee DECIMAL(18,2) NOT NULL, currency CHAR(3) NOT NULL DEFAULT 'USD',
status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW', remark VARCHAR(1000) NULL,
idempotency_key VARCHAR(255) NOT NULL, request_hash CHAR(64) NOT NULL,
created_by VARCHAR(128) NULL, reviewed_by VARCHAR(128) NULL, review_remark VARCHAR(1000) NULL,
reviewed_at DATETIME(3) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), UNIQUE KEY uk_ll_cargo_discard_no(customer_id,discard_no),
UNIQUE KEY uk_ll_cargo_discard_key(customer_id,idempotency_key),
KEY idx_ll_cargo_discard_list(customer_id,status,created_at),
KEY idx_ll_cargo_discard_warehouse(customer_id,overseas_warehouse_id,created_at)
) ENGINE=InnoDB COMMENT='Cargo discard application requiring warehouse approval';
CREATE TABLE IF NOT EXISTS ll_cargo_discard_item (
id BIGINT NOT NULL, application_id BIGINT NOT NULL, customer_id VARCHAR(128) NOT NULL,
order_id BIGINT NOT NULL, inbound_code VARCHAR(128) NOT NULL, profile_id BIGINT NOT NULL,
sku_inventory_id BIGINT NOT NULL, sku_code VARCHAR(128) NOT NULL, product_name VARCHAR(255) NULL,
available_packages_snapshot INT NOT NULL, discard_packages INT NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY(id),
UNIQUE KEY uk_ll_cargo_discard_item(application_id,profile_id,sku_code),
KEY idx_ll_cargo_discard_item_list(customer_id,application_id),
KEY idx_ll_cargo_discard_item_inbound(customer_id,inbound_code,sku_code)
) ENGINE=InnoDB COMMENT='Frozen SKU quantities in a cargo discard application';
CREATE TABLE IF NOT EXISTS ll_cargo_discard_carton (
id BIGINT NOT NULL, application_id BIGINT NOT NULL, item_id BIGINT NOT NULL,
customer_id VARCHAR(128) NOT NULL, carton_id BIGINT NOT NULL, carton_mark VARCHAR(128) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY(id),
UNIQUE KEY uk_ll_cargo_discard_carton(application_id,carton_id),
KEY idx_ll_cargo_discard_carton_application(customer_id,application_id,item_id),
KEY idx_ll_cargo_discard_carton_identity(customer_id,carton_id)
) ENGINE=InnoDB COMMENT='Cartons reserved while a cargo discard application awaits review';
CREATE TABLE IF NOT EXISTS ll_cargo_discard_status_history (
id BIGINT NOT NULL, application_id BIGINT NOT NULL, customer_id VARCHAR(128) NOT NULL,
from_status VARCHAR(32) NULL, to_status VARCHAR(32) NOT NULL, operator_id VARCHAR(128) NULL,
remark VARCHAR(1000) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY(id),
KEY idx_ll_cargo_discard_history(customer_id,application_id,created_at)
) ENGINE=InnoDB COMMENT='Immutable cargo discard approval history';
CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order (
id BIGINT NOT NULL COMMENT 'Snowflake ID', customer_id VARCHAR(128) NOT NULL,
customer_name VARCHAR(255) NULL, order_no VARCHAR(64) NOT NULL,
purchase_category VARCHAR(128) NOT NULL, product_name VARCHAR(255) NOT NULL,
product_specification VARCHAR(500) NULL, product_url VARCHAR(2000) NULL,
purchase_quantity BIGINT NOT NULL, budget_unit_price DECIMAL(18,2) NULL,
currency CHAR(3) NOT NULL DEFAULT 'USD', receiving_address VARCHAR(1000) NULL,
contact_name VARCHAR(128) NOT NULL, contact_phone VARCHAR(64) NOT NULL,
purchase_warehouse_id VARCHAR(128) NULL, purchase_warehouse_name VARCHAR(255) NULL,
inbound_quantity BIGINT NOT NULL DEFAULT 0, outbound_quantity BIGINT NOT NULL DEFAULT 0,
domestic_carrier VARCHAR(128) NULL, domestic_tracking_no VARCHAR(128) NULL,
overseas_carrier VARCHAR(128) NULL, overseas_tracking_no VARCHAR(128) NULL,
estimated_arrival_at DATETIME(3) NULL, status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW',
remark VARCHAR(1000) NULL, idempotency_key VARCHAR(255) NOT NULL, request_hash CHAR(64) NOT NULL,
created_by VARCHAR(128) NULL, reviewed_by VARCHAR(128) NULL, review_remark VARCHAR(1000) NULL,
reviewed_at DATETIME(3) NULL, version BIGINT NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), UNIQUE KEY uk_ll_overseas_procurement_order_no(customer_id,order_no),
UNIQUE KEY uk_ll_overseas_procurement_order_key(customer_id,idempotency_key),
KEY idx_ll_overseas_procurement_list(customer_id,status,created_at),
KEY idx_ll_overseas_procurement_warehouse(customer_id,purchase_warehouse_id,created_at)
) ENGINE=InnoDB COMMENT='Overseas procurement order awaiting business approval';
CREATE TABLE IF NOT EXISTS ll_overseas_procurement_outbound (
id BIGINT NOT NULL, order_id BIGINT NOT NULL, customer_id VARCHAR(128) NOT NULL,
quantity BIGINT NOT NULL, outbound_no VARCHAR(128) NOT NULL, carrier VARCHAR(128) NULL,
tracking_no VARCHAR(128) NULL, outbound_at DATETIME(3) NOT NULL,
remark VARCHAR(1000) NULL, created_by VARCHAR(128) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY(id),
UNIQUE KEY uk_ll_overseas_procurement_outbound_no(customer_id,order_id,outbound_no),
KEY idx_ll_overseas_procurement_outbound_order(customer_id,order_id,outbound_at)
) ENGINE=InnoDB COMMENT='Manual outbound records for overseas procurement inventory';
CREATE TABLE IF NOT EXISTS ll_overseas_procurement_status_history (
id BIGINT NOT NULL, order_id BIGINT NOT NULL, customer_id VARCHAR(128) NOT NULL,
from_status VARCHAR(32) NULL, to_status VARCHAR(32) NOT NULL, operator_id VARCHAR(128) NULL,
remark VARCHAR(1000) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), KEY idx_ll_overseas_procurement_history(customer_id,order_id,created_at)
) ENGINE=InnoDB COMMENT='Immutable overseas procurement review history';
CREATE TABLE IF NOT EXISTS ll_us_truck_order (
id BIGINT NOT NULL COMMENT 'Snowflake primary key',
customer_id VARCHAR(128) NOT NULL COMMENT 'Trusted tenant identifier',
order_no VARCHAR(64) NOT NULL COMMENT 'US trucking order number',
transport_type VARCHAR(32) NOT NULL COMMENT 'PORT_PICKUP/WAREHOUSE_TO_WAREHOUSE/WAREHOUSE_TO_DOOR',
origin VARCHAR(255) NOT NULL COMMENT 'Origin city, port or state',
origin_address VARCHAR(500) NULL COMMENT 'Origin street address',
destination VARCHAR(255) NOT NULL COMMENT 'Destination city, warehouse or state',
destination_address VARCHAR(500) NULL COMMENT 'Destination street, terminal or warehouse address',
container_type VARCHAR(32) NOT NULL COMMENT 'Container or truck equipment type',
package_count INT NOT NULL DEFAULT 0 COMMENT 'Package count',
total_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT 'Total weight in kilograms',
total_volume_cbm DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT 'Total volume in cubic meters',
budget_amount DECIMAL(18,2) NULL COMMENT 'Customer budget total',
currency VARCHAR(3) NOT NULL DEFAULT 'USD' COMMENT 'ISO currency code',
contact_name VARCHAR(128) NOT NULL COMMENT 'Shipment contact name',
contact_info VARCHAR(128) NOT NULL COMMENT 'Phone, email or messaging account',
requested_date DATE NOT NULL COMMENT 'Requested pickup or service date',
status VARCHAR(32) NOT NULL COMMENT 'Current business status',
remark VARCHAR(1000) NULL COMMENT 'Order remark',
idempotency_key VARCHAR(255) NOT NULL COMMENT 'Customer request idempotency key',
request_hash CHAR(64) NOT NULL COMMENT 'SHA-256 request hash',
created_by VARCHAR(128) NULL COMMENT 'Creating user identifier',
version BIGINT NOT NULL DEFAULT 0 COMMENT 'Optimistic concurrency version',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE KEY uk_ll_us_truck_order_no(customer_id,order_no),
UNIQUE KEY uk_ll_us_truck_order_key(customer_id,idempotency_key),
KEY idx_ll_us_truck_order_list(customer_id,status,created_at),
KEY idx_ll_us_truck_order_date(customer_id,requested_date,created_at)
) ENGINE=InnoDB COMMENT='US domestic trucking customer order';
CREATE TABLE IF NOT EXISTS ll_us_truck_order_status_history (
id BIGINT NOT NULL COMMENT 'Snowflake primary key',
order_id BIGINT NOT NULL COMMENT 'US trucking order ID',
customer_id VARCHAR(128) NOT NULL COMMENT 'Trusted tenant identifier',
from_status VARCHAR(32) NULL COMMENT 'Previous status',
to_status VARCHAR(32) NOT NULL COMMENT 'New status',
operator_id VARCHAR(128) NULL COMMENT 'Operator identifier',
remark VARCHAR(1000) NULL COMMENT 'Status change note',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
KEY idx_ll_us_truck_history(customer_id,order_id,created_at)
) ENGINE=InnoDB COMMENT='Immutable US trucking order status history';
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.discard;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CargoDiscardSchemaContractTest {
@Test
void baselineContainsDiscardApplicationReservationAndHistoryTables() throws Exception {
String sql;
try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) {
sql = new String(input.readAllBytes(), StandardCharsets.UTF_8);
}
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_cargo_discard_application"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_cargo_discard_item"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_cargo_discard_carton"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_cargo_discard_status_history"));
assertTrue(sql.contains("UNIQUE KEY uk_ll_cargo_discard_carton(application_id,carton_id)"));
}
}
@@ -0,0 +1,50 @@
package com.baiyue.lastleg.discard.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.discard.dto.CargoDiscardRequests;
import com.baiyue.lastleg.discard.service.CargoDiscardService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class CargoDiscardControllerTest {
@Mock CargoDiscardService service;
private CargoDiscardController controller;
@BeforeEach
void setUp() {
controller = new CargoDiscardController();
ReflectionTestUtils.setField(controller, "service", service);
}
@Test
void reviewRequiresExplicitApprovalPermission() {
AuthenticatedUser user = user(Set.of());
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> controller.review(
user, null, "FH-1", new CargoDiscardRequests.Review("APPROVE", null)));
assertEquals(403, error.getStatusCode().value());
}
@Test
void merchantTenantComesFromTrustedIdentityWhenApproving() {
AuthenticatedUser user = user(Set.of(CargoDiscardService.APPROVAL_PERMISSION));
CargoDiscardRequests.Review request = new CargoDiscardRequests.Review("APPROVE", "ok");
controller.review(user, "999", "FH-1", request);
verify(service).review("100", "FH-1", "7", request);
}
private AuthenticatedUser user(Set<String> permissions) {
return new AuthenticatedUser(7L, 100L, 100L, 8L, "reviewer", "MERCHANT", "WEB", permissions);
}
}
@@ -0,0 +1,183 @@
package com.baiyue.lastleg.discard.service;
import com.baiyue.lastleg.discard.dto.CargoDiscardRequests;
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.dropship.entity.DropshipCarton;
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
import com.baiyue.lastleg.dropship.mapper.DropshipCartonLedgerMapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CargoDiscardServiceTest {
@Mock CargoDiscardApplicationMapper applications;
@Mock CargoDiscardItemMapper items;
@Mock CargoDiscardCartonMapper discardCartons;
@Mock CargoDiscardStatusHistoryMapper histories;
@Mock CargoDiscardInventoryMapper inventory;
@Mock DropshipCartonLedgerMapper cartonLedgers;
@Mock LastlegOrderMapper orders;
private CargoDiscardService service;
private final AtomicReference<CargoDiscardApplication> storedApplication = new AtomicReference<>();
private final List<CargoDiscardItem> storedItems = new ArrayList<>();
private final List<CargoDiscardCarton> storedLinks = new ArrayList<>();
private final List<CargoDiscardStatusHistory> storedHistories = new ArrayList<>();
@BeforeEach
void setUp() {
service = new CargoDiscardService();
ReflectionTestUtils.setField(service, "applications", applications);
ReflectionTestUtils.setField(service, "items", items);
ReflectionTestUtils.setField(service, "discardCartons", discardCartons);
ReflectionTestUtils.setField(service, "histories", histories);
ReflectionTestUtils.setField(service, "inventory", inventory);
ReflectionTestUtils.setField(service, "cartonLedgers", cartonLedgers);
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
ReflectionTestUtils.setField(service, "feePerPackage", new BigDecimal("5.00"));
lenient().when(applications.insert(any(CargoDiscardApplication.class))).thenAnswer(invocation -> { storedApplication.set(invocation.getArgument(0)); return 1; });
lenient().when(items.insert(any(CargoDiscardItem.class))).thenAnswer(invocation -> { storedItems.add(invocation.getArgument(0)); return 1; });
lenient().when(discardCartons.insert(any(CargoDiscardCarton.class))).thenAnswer(invocation -> { storedLinks.add(invocation.getArgument(0)); return 1; });
lenient().when(histories.insert(any(CargoDiscardStatusHistory.class))).thenAnswer(invocation -> { storedHistories.add(invocation.getArgument(0)); return 1; });
lenient().when(applications.findNo(any(), any())).thenAnswer(invocation -> storedApplication.get());
lenient().when(items.listApplication(any(), anyLong())).thenAnswer(invocation -> List.copyOf(storedItems));
lenient().when(discardCartons.listApplication(any(), anyLong())).thenAnswer(invocation -> List.copyOf(storedLinks));
lenient().when(histories.listApplication(any(), anyLong())).thenAnswer(invocation -> List.copyOf(storedHistories));
}
@Test
void createReservesExactSkuCartonsWithoutDeductingInventory() {
LastlegOrder order = order();
DropshipSkuInventory sku = skuInventory();
when(orders.selectById(101L)).thenReturn(order);
when(inventory.countAvailable("100", 101L, "SKU-1")).thenReturn(3L);
when(inventory.lockAvailable("100", 101L, "SKU-1", 2)).thenReturn(List.of(carton(1L), carton(2L)));
when(inventory.lockSkuInventoryDimension("100", 101L, 201L, "SKU-1")).thenReturn(sku);
when(inventory.countAvailableProfile("100", 101L, 201L, "SKU-1")).thenReturn(3L);
when(inventory.changeCartonStatus(eq("100"), anyLong(), eq("AVAILABLE"), eq("DISCARD_PENDING"))).thenReturn(1);
var detail = service.create("100", "7", new CargoDiscardRequests.Create(
List.of(new CargoDiscardRequests.Item(101L, "SKU-1", 2)), "discard-create-1", "damaged"));
assertEquals(CargoDiscardService.PENDING_REVIEW, detail.summary().status());
assertEquals(2, detail.summary().totalPackages());
assertEquals(new BigDecimal("10.00"), detail.summary().totalFee());
assertEquals(2, storedLinks.size());
verify(inventory, never()).deductSkuInventory(any(), anyLong(), anyInt());
}
@Test
void approvalDeductsSkuInventoryAndWritesOneLedgerPerCarton() {
CargoDiscardApplication application = pendingApplication();
storedApplication.set(application);
CargoDiscardItem item = item();
storedItems.add(item);
storedLinks.add(link(item.getId(), 1L));
storedLinks.add(link(item.getId(), 2L));
when(applications.lockNo("100", "FH-1")).thenReturn(application);
when(inventory.lockSkuInventory("100", 301L)).thenReturn(skuInventory());
when(inventory.deductSkuInventory("100", 301L, 2)).thenReturn(1);
when(inventory.lockCarton(eq("100"), anyLong())).thenAnswer(invocation -> carton(invocation.getArgument(1)));
when(inventory.changeCartonStatus(eq("100"), anyLong(), eq("DISCARD_PENDING"), eq("DISCARDED"))).thenReturn(1);
var detail = service.review("100", "FH-1", "9", new CargoDiscardRequests.Review("APPROVE", "ok"));
assertEquals(CargoDiscardService.APPROVED, detail.summary().status());
verify(inventory).deductSkuInventory("100", 301L, 2);
verify(cartonLedgers, times(2)).insert(any(com.baiyue.lastleg.dropship.entity.DropshipCartonLedger.class));
verify(inventory, times(2)).changeCartonStatus(eq("100"), anyLong(), eq("DISCARD_PENDING"), eq("DISCARDED"));
}
@Test
void rejectionReleasesReservedCartonsWithoutDeduction() {
CargoDiscardApplication application = pendingApplication();
storedApplication.set(application);
CargoDiscardItem item = item();
storedItems.add(item);
storedLinks.add(link(item.getId(), 1L));
when(applications.lockNo("100", "FH-1")).thenReturn(application);
when(inventory.changeCartonStatus("100", 1L, "DISCARD_PENDING", "AVAILABLE")).thenReturn(1);
var detail = service.review("100", "FH-1", "9", new CargoDiscardRequests.Review("REJECT", "not damaged"));
assertEquals(CargoDiscardService.REJECTED, detail.summary().status());
verify(inventory, never()).deductSkuInventory(any(), anyLong(), anyInt());
verify(cartonLedgers, never()).insert(any(com.baiyue.lastleg.dropship.entity.DropshipCartonLedger.class));
}
private LastlegOrder order() {
LastlegOrder order = new LastlegOrder();
order.setId(101L); order.setCustomerId("100"); order.setOrderType("ONE_CLICK_DROPSHIP");
order.setInboundCode("RK-1"); order.setOverseasWarehouseId("WH-1"); order.setOverseasWarehouseName("LA");
return order;
}
private DropshipSkuInventory skuInventory() {
DropshipSkuInventory value = new DropshipSkuInventory();
value.setId(301L); value.setCustomerId("100"); value.setOrderId(101L); value.setProfileId(201L);
value.setSkuCode("SKU-1"); value.setProductName("Box"); value.setPackageCount(3L);
return value;
}
private DropshipCarton carton(Long id) {
DropshipCarton value = new DropshipCarton();
value.setId(id); value.setCustomerId("100"); value.setOrderId(101L); value.setProfileId(201L);
value.setSkuCode("SKU-1"); value.setCartonMark("C-" + id); value.setStatus("DISCARD_PENDING");
return value;
}
private CargoDiscardApplication pendingApplication() {
CargoDiscardApplication value = new CargoDiscardApplication();
value.setId(401L); value.setCustomerId("100"); value.setDiscardNo("FH-1"); value.setInboundCodes("RK-1");
value.setOverseasWarehouseId("WH-1"); value.setTotalPackages(2); value.setFeePerPackage(new BigDecimal("5.00"));
value.setTotalFee(new BigDecimal("10.00")); value.setCurrency("USD"); value.setStatus(CargoDiscardService.PENDING_REVIEW);
return value;
}
private CargoDiscardItem item() {
CargoDiscardItem value = new CargoDiscardItem();
value.setId(501L); value.setApplicationId(401L); value.setCustomerId("100"); value.setOrderId(101L);
value.setInboundCode("RK-1"); value.setProfileId(201L); value.setSkuInventoryId(301L);
value.setSkuCode("SKU-1"); value.setAvailablePackagesSnapshot(3); value.setDiscardPackages(2);
return value;
}
private CargoDiscardCarton link(Long itemId, Long cartonId) {
CargoDiscardCarton value = new CargoDiscardCarton();
value.setId(600L + cartonId); value.setApplicationId(401L); value.setItemId(itemId);
value.setCustomerId("100"); value.setCartonId(cartonId); value.setCartonMark("C-" + cartonId);
return value;
}
}
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.procurement;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertTrue;
class OverseasProcurementSchemaContractTest {
@Test
void baselineContainsOrderOutboundAndReviewHistoryTables() throws Exception {
String sql;
try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) {
sql = new String(input.readAllBytes(), StandardCharsets.UTF_8);
}
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_outbound"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_status_history"));
assertTrue(sql.contains("UNIQUE KEY uk_ll_overseas_procurement_outbound_no(customer_id,order_id,outbound_no)"));
}
}
@@ -0,0 +1,68 @@
package com.baiyue.lastleg.procurement.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.procurement.dto.OverseasProcurementRequests;
import com.baiyue.lastleg.procurement.service.OverseasProcurementService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class OverseasProcurementControllerTest {
@Mock OverseasProcurementService service;
private MerchantOverseasProcurementController merchant;
private PlatformOverseasProcurementController platform;
@BeforeEach
void setUp() {
merchant = new MerchantOverseasProcurementController();
platform = new PlatformOverseasProcurementController();
ReflectionTestUtils.setField(merchant, "service", service);
ReflectionTestUtils.setField(platform, "service", service);
}
@Test
void merchantAlwaysUsesTenantFromTrustedIdentity() {
var query = new OverseasProcurementRequests.Query(1, 20, null, null, null, null, null, null, null, null);
merchant.page(user(100L, "MERCHANT", Set.of()), query);
verify(service).page("100", query);
}
@Test
void reviewRequiresExplicitPermission() {
var request = new OverseasProcurementRequests.Review("APPROVE", null);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> platform.review(user(0L, "PLATFORM", Set.of()), "100", "HC1", request));
assertEquals(403, error.getStatusCode().value());
}
@Test
void merchantEndpointRejectsPlatformIdentity() {
var query = new OverseasProcurementRequests.Query(1, 20, null, null, null, null, null, null, null, null);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> merchant.page(user(0L, "PLATFORM", Set.of()), query));
assertEquals(403, error.getStatusCode().value());
}
@Test
void platformReviewUsesExplicitTargetTenant() {
var request = new OverseasProcurementRequests.Review("APPROVE", "ok");
platform.review(user(0L, "PLATFORM", Set.of(OverseasProcurementService.REVIEW_PERMISSION)),
"100", "HC1", request);
verify(service).review("100", "HC1", "7", request);
}
private AuthenticatedUser user(long tenantId, String appCode, Set<String> permissions) {
return new AuthenticatedUser(7L, tenantId, tenantId, 8L, "operator", appCode, "WEB", permissions);
}
}
@@ -0,0 +1,103 @@
package com.baiyue.lastleg.procurement.service;
import com.baiyue.lastleg.procurement.dto.OverseasProcurementRequests;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder;
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper;
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOutboundMapper;
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementStatusHistoryMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class OverseasProcurementServiceTest {
@Mock OverseasProcurementOrderMapper orders;
@Mock OverseasProcurementOutboundMapper outbounds;
@Mock OverseasProcurementStatusHistoryMapper histories;
private OverseasProcurementService service;
@BeforeEach
void setUp() {
service = new OverseasProcurementService();
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "outbounds", outbounds);
ReflectionTestUtils.setField(service, "histories", histories);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
}
@Test
void customerOrderAlwaysStartsPendingReviewWithZeroInventory() {
AtomicReference<OverseasProcurementOrder> saved = new AtomicReference<>();
when(orders.insert(any(OverseasProcurementOrder.class))).thenAnswer(invocation -> {
saved.set(invocation.getArgument(0));
return 1;
});
when(orders.findNo(anyString(), anyString())).thenAnswer(ignored -> saved.get());
when(outbounds.listOrder(anyString(), any())).thenReturn(List.of());
when(histories.listOrder(anyString(), any())).thenReturn(List.of());
var detail = service.create("100", "7", new OverseasProcurementRequests.Create(
"Acme", "Office", "Labels", "100x150", "https://example.test/product", 500L,
new BigDecimal("26.50"), "USD", "Ningbo", "Buyer", "13800000000", null, "key-1"));
assertEquals(OverseasProcurementService.PENDING_REVIEW, detail.summary().status());
assertEquals(0, detail.summary().inboundQuantity());
assertEquals(0, detail.summary().inventoryQuantity());
}
@Test
void pendingOrderCannotReceiveInventory() {
OverseasProcurementOrder order = order(OverseasProcurementService.PENDING_REVIEW, 0, 0);
when(orders.lockNo("100", "HC1")).thenReturn(order);
var request = new OverseasProcurementRequests.Update(0L, null, null, null, null,
100L, null, null, null, null, null, null, null, null);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> service.update("100", "HC1", request));
assertEquals(409, error.getStatusCode().value());
}
@Test
void outboundCannotExceedDerivedInventory() {
OverseasProcurementOrder order = order(OverseasProcurementService.APPROVED, 100, 80);
when(orders.lockNo("100", "HC1")).thenReturn(order);
when(orders.addOutbound(order.getId(), "100", 30)).thenReturn(0);
var request = new OverseasProcurementRequests.AddOutbound(30L, "OUT-1", null, null,
LocalDateTime.now(), null);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> service.addOutbound("100", "HC1", "7", request));
assertEquals(409, error.getStatusCode().value());
}
private OverseasProcurementOrder order(String status, long inbound, long outbound) {
OverseasProcurementOrder order = new OverseasProcurementOrder();
order.setId(1L);
order.setCustomerId("100");
order.setOrderNo("HC1");
order.setStatus(status);
order.setInboundQuantity(inbound);
order.setOutboundQuantity(outbound);
order.setPurchaseQuantity(500L);
order.setVersion(0L);
order.setCreatedAt(LocalDateTime.now());
order.setUpdatedAt(order.getCreatedAt());
return order;
}
}
@@ -183,6 +183,24 @@ class RelabelApplicationServiceTest {
verify(applications).findNo("TENANT-B", "FBR-1");
}
@Test
void completedReturnRelabelOrderEntersPendingAssignment() {
RelabelApplication application = application(11L, "FBR-1", "TENANT-A", "key-1", "hash", "PROCESSING");
application.setLastlegOrderId(21L);
LastlegOrder order = order(21L, "TENANT-A", "LMO-RELABEL-1", "PROCESSING");
order.setOrderType("RETURN_RELABEL");
when(applications.lockNo("TENANT-A", "FBR-1")).thenReturn(application);
stubDetail(application);
when(orders.selectById(21L)).thenReturn(order);
service.changeStatus("TENANT-A", "FBR-1", "operator-2",
new RelabelRequests.ChangeStatus("COMPLETED", "finished"));
assertEquals("COMPLETED", application.getStatus());
assertEquals("PENDING_ASSIGNMENT", order.getCurrentStatus());
assertEquals("COMPLETED", order.getRelabelStatus());
}
private void stubDetail(RelabelApplication application) {
when(applications.findNo(application.getCustomerId(), application.getRelabelNo())).thenReturn(application);
when(items.selectList(any())).thenReturn(List.of(item(31L, application.getId(), "SKU-A", 10, 6)));
@@ -7,6 +7,12 @@ import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile;
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
import com.baiyue.lastleg.dropship.mapper.DropshipCartonMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipSkuInventoryMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -31,6 +37,9 @@ class LastlegOrderServiceTest {
@Mock LastlegOrderMapper orders;
@Mock LastlegOrderDetailMapper details;
@Mock LastlegOrderStatusHistoryMapper histories;
@Mock DropshipOrderProfileMapper dropshipProfiles;
@Mock DropshipSkuInventoryMapper dropshipSkuInventory;
@Mock DropshipCartonMapper dropshipCartons;
LastlegOrderService service;
@BeforeEach
@@ -39,6 +48,35 @@ class LastlegOrderServiceTest {
ReflectionTestUtils.setField(service, "orderMapper", orders);
ReflectionTestUtils.setField(service, "detailMapper", details);
ReflectionTestUtils.setField(service, "historyMapper", histories);
ReflectionTestUtils.setField(service, "dropshipProfiles", dropshipProfiles);
ReflectionTestUtils.setField(service, "dropshipSkuInventory", dropshipSkuInventory);
ReflectionTestUtils.setField(service, "dropshipCartons", dropshipCartons);
}
@Test
void detailGroupsDropshipSkuAndCartonsByPallet() {
LastlegOrder order = order(11L, "100", "PENDING_ASSIGNMENT", null);
order.setOrderType("ONE_CLICK_DROPSHIP");
when(orders.selectOne(any())).thenReturn(order);
when(details.selectList(any())).thenReturn(List.of());
when(histories.selectList(any())).thenReturn(List.of());
DropshipOrderProfile first = profile(1L, "SOURCE-P-1", 101L, "WH-P-1");
DropshipOrderProfile second = profile(2L, "SOURCE-P-2", 102L, "WH-P-2");
when(dropshipProfiles.listOrder("100", 11L)).thenReturn(List.of(first, second));
DropshipSkuInventory sku = new DropshipSkuInventory();
sku.setProfileId(1L); sku.setSkuCode("SKU-A"); sku.setPackageCount(1L); sku.setProductQuantity(10L);
when(dropshipSkuInventory.listOrder("100", 11L)).thenReturn(List.of(sku));
DropshipCarton carton = new DropshipCarton();
carton.setProfileId(1L); carton.setSkuCode("SKU-A"); carton.setCartonMark("MARK-1");
when(dropshipCartons.listOrder("100", 11L)).thenReturn(List.of(carton));
var response = service.detail("100", 11L);
assertEquals(2, response.dropshipPallets().size());
assertEquals("SOURCE-P-1", response.dropshipPallets().get(0).sourcePalletNo());
assertEquals(1, response.dropshipPallets().get(0).skuInventory().size());
assertEquals("MARK-1", response.dropshipPallets().get(0).cartons().get(0).getCartonMark());
assertTrue(response.dropshipPallets().get(1).skuInventory().isEmpty());
}
@Test
@@ -94,4 +132,13 @@ class LastlegOrderServiceTest {
order.setInboundOrderNo(inboundOrderNo);
return order;
}
private static DropshipOrderProfile profile(long id, String sourcePalletNo,
long warehousePalletId, String palletNo) {
DropshipOrderProfile profile = new DropshipOrderProfile();
profile.setId(id); profile.setSourcePalletNo(sourcePalletNo);
profile.setWarehousePalletId(warehousePalletId); profile.setPalletNo(palletNo);
profile.setStatus("RECEIVED");
return profile;
}
}
@@ -1,12 +1,13 @@
package com.baiyue.lastleg.service;
import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com.baiyue.lastleg.mapper.*; import com.fasterxml.jackson.databind.*; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.time.Instant; import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*;
import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com.baiyue.lastleg.mapper.*; import com.baiyue.lastleg.dropship.dto.DropshipRequests; import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.fasterxml.jackson.databind.*; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.time.Instant; import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*;
@ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class)
class LoadingCompletedConsumerTest {
@Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service;
@BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"objectMapper",json); ReflectionTestUtils.setField(service,"orderNoGenerator",new LastlegOrderNoGenerator()); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); }
@Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock DropshipOrderService dropshipOrders; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service;
@BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"objectMapper",json); ReflectionTestUtils.setField(service,"orderNoGenerator",new LastlegOrderNoGenerator()); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); }
@Test void createsOneOrderPerInboundAndAllSnapshots(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); verify(orders,times(2)).insert(any(LastlegOrder.class)); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor<LastlegOrder> c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); }
@Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU1")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU1"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); }
@Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); }
@Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); }
@Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","ONE_CLICK_DROPSHIP",List.of(pallet)); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","CONT-D","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor<DropshipRequests.Receive> captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); assertEquals("100",captor.getValue().customerId()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); }
private EventEnvelope event(){ var item1=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-1","产品","Product",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("12"),new BigDecimal("1.2"),"wh-1","ONT8","FBA ONT8","FBA-1","SHIP-1","MARK-1"); var item2=new FirstLegLoadingPlanCompletedV1.Item("d-2","SKU-2","产品2","Product2",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("9"),new BigDecimal("0.8"),"wh-2","LAX9","FBA LAX9","FBA-2","SHIP-2","MARK-2"); var p=new FirstLegLoadingPlanCompletedV1("plan-1","LP1","book-1","MSCU1","CAB1","ow-1","洛杉矶海外仓",List.of(new FirstLegLoadingPlanCompletedV1.Order("in-1","root-1","ord-1","FL1","root-o1","RK1","wh-1","ONT8","FBA ONT8",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("1.2"),List.of(item1)),new FirstLegLoadingPlanCompletedV1.Order("in-2","root-2","ord-2","FL2","root-o2","RK2","wh-2","LAX9","FBA LAX9",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("0.8"),List.of(item2)))); JsonNode node=json.valueToTree(p); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-1",2,1L,"123e4567-e89b-12d3-a456-426614174001","FIRSTLEG_LOADING_COMPLETED:plan-1:100:1",null,node,hash); }
}
@@ -89,7 +89,7 @@ class WarehouseTransferServiceTest {
ArgumentCaptor<LastlegOrder> tailOrder = ArgumentCaptor.forClass(LastlegOrder.class);
verify(orders).insert(tailOrder.capture());
assertEquals(WarehouseTransferService.ORDER_TYPE, tailOrder.getValue().getOrderType());
assertEquals(WarehouseTransferService.RETURN_RELABEL_ORDER_TYPE, tailOrder.getValue().getOrderType());
assertEquals(WarehouseTransferService.PENDING_REVIEW, tailOrder.getValue().getCurrentStatus());
assertEquals("LMO-TRANSFER-1", tailOrder.getValue().getOrderNo());
verify(sourceOrders).insert(any(WarehouseTransferSourceOrder.class));
@@ -118,6 +118,29 @@ class WarehouseTransferServiceTest {
assertNull(result.relabelNo());
}
@Test
void approvedTransferWithoutRelabelEntersPendingAssignment() {
WarehouseTransferOrder transfer = persistedTransfer();
transfer.setStatus(WarehouseTransferService.PENDING_REVIEW);
transfer.setNeedRelabel(false);
LastlegOrder tailOrder = historicalOrder();
tailOrder.setId(31L); tailOrder.setCurrentStatus(WarehouseTransferService.PENDING_REVIEW);
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(orders.selectById(31L)).thenReturn(tailOrder);
when(transferItems.selectList(any())).thenReturn(List.of());
when(sourceOrders.selectList(any())).thenReturn(List.of());
var result = service.review("TENANT-A", "WTR-1", "USER-1",
new WarehouseTransferRequests.Review("APPROVE", "approved"));
assertEquals(WarehouseTransferService.APPROVED, transfer.getStatus());
assertEquals(WarehouseTransferService.PENDING_ASSIGNMENT, tailOrder.getCurrentStatus());
assertEquals(WarehouseTransferService.PENDING_ASSIGNMENT, result.orderStatus());
verify(transfers).updateById(transfer);
verify(orders).updateById(tailOrder);
}
@Test
void selectedOrderMustBelongToTenantAndHaveInboundBinding() {
when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L);
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.trucking;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertTrue;
class UsTruckOrderSchemaContractTest {
@Test
void baselineContainsOrderAndImmutableHistoryTables() throws Exception {
String sql;
try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) {
sql = new String(input.readAllBytes(), StandardCharsets.UTF_8);
}
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_us_truck_order"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_us_truck_order_status_history"));
assertTrue(sql.contains("UNIQUE KEY uk_ll_us_truck_order_key(customer_id,idempotency_key)"));
assertTrue(sql.contains("KEY idx_ll_us_truck_history(customer_id,order_id,created_at)"));
}
}
@@ -0,0 +1,57 @@
package com.baiyue.lastleg.trucking.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests;
import com.baiyue.lastleg.trucking.service.UsTruckOrderService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class UsTruckOrderControllerTest {
@Mock UsTruckOrderService service;
private MerchantUsTruckOrderController merchant;
private PlatformUsTruckOrderController platform;
@BeforeEach
void setUp() {
merchant = new MerchantUsTruckOrderController(); platform = new PlatformUsTruckOrderController();
ReflectionTestUtils.setField(merchant, "service", service); ReflectionTestUtils.setField(platform, "service", service);
}
@Test
void merchantListAlwaysUsesTrustedTenant() {
var query = query(); merchant.page(user(100L, "MERCHANT", Set.of()), query);
verify(service).page("100", query);
}
@Test
void platformStatusChangeRequiresManagePermission() {
var request = new UsTruckOrderRequests.ChangeStatus(0L, "DISPATCHED", null);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> platform.changeStatus(user(0L, "PLATFORM", Set.of()), "100", "TK1", request));
assertEquals(403, error.getStatusCode().value());
}
@Test
void platformStatusChangeUsesExplicitTargetTenant() {
var request = new UsTruckOrderRequests.ChangeStatus(0L, "DISPATCHED", "truck assigned");
platform.changeStatus(user(0L, "PLATFORM", Set.of(UsTruckOrderService.MANAGE_PERMISSION)), "100", "TK1", request);
verify(service).changeStatus("100", "TK1", "7", request);
}
private UsTruckOrderRequests.Query query() { return new UsTruckOrderRequests.Query(1, 20, null, null, null, null, null, null, null); }
private AuthenticatedUser user(long tenant, String app, Set<String> permissions) {
return new AuthenticatedUser(7L, tenant, tenant, 8L, "operator", app, "WEB", permissions);
}
}
@@ -0,0 +1,93 @@
package com.baiyue.lastleg.trucking.service;
import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests;
import com.baiyue.lastleg.trucking.entity.UsTruckOrder;
import com.baiyue.lastleg.trucking.entity.UsTruckOrderStatusHistory;
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderMapper;
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderStatusHistoryMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class UsTruckOrderServiceTest {
@Mock UsTruckOrderMapper orders;
@Mock UsTruckOrderStatusHistoryMapper histories;
private UsTruckOrderService service;
@BeforeEach
void setUp() {
service = new UsTruckOrderService();
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "histories", histories);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
}
@Test
void customerOrderStartsPendingConfirmationAndUsesUsdByDefault() {
AtomicReference<UsTruckOrder> saved = new AtomicReference<>();
when(orders.insert(any(UsTruckOrder.class))).thenAnswer(call -> { saved.set(call.getArgument(0)); return 1; });
when(orders.findNo(anyString(), anyString())).thenAnswer(call -> saved.get());
when(histories.listOrder(anyString(), any())).thenReturn(List.of());
var detail = service.create("100", "7", create("key-1"));
assertEquals(UsTruckOrderService.PENDING_CONFIRMATION, detail.summary().status());
assertEquals("USD", detail.summary().currency());
assertEquals("100", detail.summary().customerId());
}
@Test
void statusChangePersistsHistoryAndIncrementsVersion() {
UsTruckOrder order = order(UsTruckOrderService.PENDING_CONFIRMATION, 0L);
when(orders.lockNo("100", "TK1")).thenReturn(order);
when(orders.findNo("100", "TK1")).thenReturn(order);
when(histories.listOrder("100", 1L)).thenReturn(List.of());
var result = service.changeStatus("100", "TK1", "7",
new UsTruckOrderRequests.ChangeStatus(0L, "PENDING_DISPATCH", "accepted"));
assertEquals(UsTruckOrderService.PENDING_DISPATCH, result.summary().status());
assertEquals(1, result.summary().version());
verify(histories).insert(any(UsTruckOrderStatusHistory.class));
}
@Test
void staleStatusChangeIsRejected() {
when(orders.lockNo("100", "TK1")).thenReturn(order(UsTruckOrderService.DISPATCHED, 2L));
ResponseStatusException error = assertThrows(ResponseStatusException.class, () ->
service.changeStatus("100", "TK1", "7", new UsTruckOrderRequests.ChangeStatus(1L, "IN_TRANSIT", null)));
assertEquals(409, error.getStatusCode().value());
}
private UsTruckOrderRequests.Create create(String key) {
return new UsTruckOrderRequests.Create("PORT_PICKUP", "Los Angeles Port", "Pier 1", "Ontario, CA",
"Warehouse A", "40HQ", 100, new BigDecimal("12600"), new BigDecimal("55.2"),
new BigDecimal("3800"), null, "Alice", "+1-555-0100", LocalDate.now().plusDays(2), null, key);
}
private UsTruckOrder order(String status, long version) {
UsTruckOrder order = new UsTruckOrder(); order.setId(1L); order.setCustomerId("100"); order.setOrderNo("TK1");
order.setTransportType("PORT_PICKUP"); order.setOrigin("Los Angeles"); order.setDestination("Ontario");
order.setContainerType("40HQ"); order.setPackageCount(1); order.setStatus(status); order.setVersion(version);
order.setCreatedAt(LocalDateTime.now()); order.setUpdatedAt(order.getCreatedAt()); return order;
}
}
+111
View File
@@ -1304,3 +1304,114 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE
PREPARE dropship_add_profile_sku_stmt FROM @ddl; EXECUTE dropship_add_profile_sku_stmt; DEALLOCATE PREPARE dropship_add_profile_sku_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_sku_inventory' AND index_name='idx_ll_dropship_sku_order'), 'SELECT 1', 'ALTER TABLE ll_dropship_sku_inventory ADD KEY idx_ll_dropship_sku_order(customer_id,order_id,sku_code)'));
PREPARE dropship_add_order_sku_idx_stmt FROM @ddl; EXECUTE dropship_add_order_sku_idx_stmt; DEALLOCATE PREPARE dropship_add_order_sku_idx_stmt;
-- ============================================================================
-- 2026-08-18 / 021 cargo discard application, carton reservation and approval.
-- ============================================================================
USE baiyue_lastleg;
CREATE TABLE IF NOT EXISTS ll_cargo_discard_application (
id BIGINT NOT NULL COMMENT 'Snowflake ID', customer_id VARCHAR(128) NOT NULL,
discard_no VARCHAR(64) NOT NULL, overseas_warehouse_id VARCHAR(128) NOT NULL,
overseas_warehouse_name VARCHAR(255) NULL, inbound_codes VARCHAR(2000) NOT NULL,
total_packages INT NOT NULL, fee_per_package DECIMAL(18,2) NOT NULL,
total_fee DECIMAL(18,2) NOT NULL, currency CHAR(3) NOT NULL DEFAULT 'USD',
status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW', remark VARCHAR(1000) NULL,
idempotency_key VARCHAR(255) NOT NULL, request_hash CHAR(64) NOT NULL,
created_by VARCHAR(128) NULL, reviewed_by VARCHAR(128) NULL, review_remark VARCHAR(1000) NULL,
reviewed_at DATETIME(3) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), UNIQUE KEY uk_ll_cargo_discard_no(customer_id,discard_no),
UNIQUE KEY uk_ll_cargo_discard_key(customer_id,idempotency_key),
KEY idx_ll_cargo_discard_list(customer_id,status,created_at),
KEY idx_ll_cargo_discard_warehouse(customer_id,overseas_warehouse_id,created_at)
) ENGINE=InnoDB COMMENT='Cargo discard application requiring warehouse approval';
CREATE TABLE IF NOT EXISTS ll_cargo_discard_item (
id BIGINT NOT NULL, application_id BIGINT NOT NULL, customer_id VARCHAR(128) NOT NULL,
order_id BIGINT NOT NULL, inbound_code VARCHAR(128) NOT NULL, profile_id BIGINT NOT NULL,
sku_inventory_id BIGINT NOT NULL, sku_code VARCHAR(128) NOT NULL, product_name VARCHAR(255) NULL,
available_packages_snapshot INT NOT NULL, discard_packages INT NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY(id),
UNIQUE KEY uk_ll_cargo_discard_item(application_id,profile_id,sku_code),
KEY idx_ll_cargo_discard_item_list(customer_id,application_id),
KEY idx_ll_cargo_discard_item_inbound(customer_id,inbound_code,sku_code)
) ENGINE=InnoDB COMMENT='Frozen SKU quantities in a cargo discard application';
CREATE TABLE IF NOT EXISTS ll_cargo_discard_carton (
id BIGINT NOT NULL, application_id BIGINT NOT NULL, item_id BIGINT NOT NULL,
customer_id VARCHAR(128) NOT NULL, carton_id BIGINT NOT NULL, carton_mark VARCHAR(128) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY(id),
UNIQUE KEY uk_ll_cargo_discard_carton(application_id,carton_id),
KEY idx_ll_cargo_discard_carton_application(customer_id,application_id,item_id),
KEY idx_ll_cargo_discard_carton_identity(customer_id,carton_id)
) ENGINE=InnoDB COMMENT='Cartons reserved while a cargo discard application awaits review';
CREATE TABLE IF NOT EXISTS ll_cargo_discard_status_history (
id BIGINT NOT NULL, application_id BIGINT NOT NULL, customer_id VARCHAR(128) NOT NULL,
from_status VARCHAR(32) NULL, to_status VARCHAR(32) NOT NULL, operator_id VARCHAR(128) NULL,
remark VARCHAR(1000) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY(id),
KEY idx_ll_cargo_discard_history(customer_id,application_id,created_at)
) ENGINE=InnoDB COMMENT='Immutable cargo discard approval history';
-- ============================================================================
-- 2026-08-18 / 022 overseas procurement approval, inventory and outbound flow.
-- ============================================================================
USE baiyue_lastleg;
CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order (
id BIGINT NOT NULL, customer_id VARCHAR(128) NOT NULL, customer_name VARCHAR(255) NULL,
order_no VARCHAR(64) NOT NULL, purchase_category VARCHAR(128) NOT NULL, product_name VARCHAR(255) NOT NULL,
product_specification VARCHAR(500) NULL, product_url VARCHAR(2000) NULL, purchase_quantity BIGINT NOT NULL,
budget_unit_price DECIMAL(18,2) NULL, currency CHAR(3) NOT NULL DEFAULT 'USD', receiving_address VARCHAR(1000) NULL,
contact_name VARCHAR(128) NOT NULL, contact_phone VARCHAR(64) NOT NULL, purchase_warehouse_id VARCHAR(128) NULL,
purchase_warehouse_name VARCHAR(255) NULL, inbound_quantity BIGINT NOT NULL DEFAULT 0, outbound_quantity BIGINT NOT NULL DEFAULT 0,
domestic_carrier VARCHAR(128) NULL, domestic_tracking_no VARCHAR(128) NULL, overseas_carrier VARCHAR(128) NULL,
overseas_tracking_no VARCHAR(128) NULL, estimated_arrival_at DATETIME(3) NULL, status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW',
remark VARCHAR(1000) NULL, idempotency_key VARCHAR(255) NOT NULL, request_hash CHAR(64) NOT NULL,
created_by VARCHAR(128) NULL, reviewed_by VARCHAR(128) NULL, review_remark VARCHAR(1000) NULL, reviewed_at DATETIME(3) NULL,
version BIGINT NOT NULL DEFAULT 0, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), PRIMARY KEY(id),
UNIQUE KEY uk_ll_overseas_procurement_order_no(customer_id,order_no), UNIQUE KEY uk_ll_overseas_procurement_order_key(customer_id,idempotency_key),
KEY idx_ll_overseas_procurement_list(customer_id,status,created_at), KEY idx_ll_overseas_procurement_warehouse(customer_id,purchase_warehouse_id,created_at)
) ENGINE=InnoDB COMMENT='Overseas procurement order awaiting business approval';
CREATE TABLE IF NOT EXISTS ll_overseas_procurement_outbound (
id BIGINT NOT NULL, order_id BIGINT NOT NULL, customer_id VARCHAR(128) NOT NULL, quantity BIGINT NOT NULL,
outbound_no VARCHAR(128) NOT NULL, carrier VARCHAR(128) NULL, tracking_no VARCHAR(128) NULL, outbound_at DATETIME(3) NOT NULL,
remark VARCHAR(1000) NULL, created_by VARCHAR(128) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY(id),
UNIQUE KEY uk_ll_overseas_procurement_outbound_no(customer_id,order_id,outbound_no), KEY idx_ll_overseas_procurement_outbound_order(customer_id,order_id,outbound_at)
) ENGINE=InnoDB COMMENT='Manual outbound records for overseas procurement inventory';
CREATE TABLE IF NOT EXISTS ll_overseas_procurement_status_history (
id BIGINT NOT NULL, order_id BIGINT NOT NULL, customer_id VARCHAR(128) NOT NULL,
from_status VARCHAR(32) NULL, to_status VARCHAR(32) NOT NULL, operator_id VARCHAR(128) NULL, remark VARCHAR(1000) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY(id), KEY idx_ll_overseas_procurement_history(customer_id,order_id,created_at)
) ENGINE=InnoDB COMMENT='Immutable overseas procurement review history';
-- 2026-08-18 / 023 US domestic trucking customer and platform workflow.
USE baiyue_lastleg;
CREATE TABLE IF NOT EXISTS ll_us_truck_order (
id BIGINT NOT NULL, customer_id VARCHAR(128) NOT NULL, order_no VARCHAR(64) NOT NULL,
transport_type VARCHAR(32) NOT NULL, origin VARCHAR(255) NOT NULL, origin_address VARCHAR(500) NULL,
destination VARCHAR(255) NOT NULL, destination_address VARCHAR(500) NULL, container_type VARCHAR(32) NOT NULL,
package_count INT NOT NULL DEFAULT 0, total_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0,
total_volume_cbm DECIMAL(18,6) NOT NULL DEFAULT 0, budget_amount DECIMAL(18,2) NULL,
currency VARCHAR(3) NOT NULL DEFAULT 'USD', contact_name VARCHAR(128) NOT NULL,
contact_info VARCHAR(128) NOT NULL, requested_date DATE NOT NULL, status VARCHAR(32) NOT NULL,
remark VARCHAR(1000) NULL, idempotency_key VARCHAR(255) NOT NULL, request_hash CHAR(64) NOT NULL,
created_by VARCHAR(128) NULL, version BIGINT NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), UNIQUE KEY uk_ll_us_truck_order_no(customer_id,order_no),
UNIQUE KEY uk_ll_us_truck_order_key(customer_id,idempotency_key),
KEY idx_ll_us_truck_order_list(customer_id,status,created_at),
KEY idx_ll_us_truck_order_date(customer_id,requested_date,created_at)
) ENGINE=InnoDB COMMENT='US domestic trucking customer order';
CREATE TABLE IF NOT EXISTS ll_us_truck_order_status_history (
id BIGINT NOT NULL, order_id BIGINT NOT NULL, customer_id VARCHAR(128) NOT NULL,
from_status VARCHAR(32) NULL, to_status VARCHAR(32) NOT NULL, operator_id VARCHAR(128) NULL,
remark VARCHAR(1000) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), KEY idx_ll_us_truck_history(customer_id,order_id,created_at)
) ENGINE=InnoDB COMMENT='Immutable US trucking order status history';