From 31cf37c52886eefd8656c5cebc1667395a5701e8 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Tue, 18 Aug 2026 16:31:55 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lastleg/AGENTS.md | 4 + .../controller/CargoDiscardController.java | 84 +++++ .../discard/dto/CargoDiscardRequests.java | 30 ++ .../discard/dto/CargoDiscardResponses.java | 30 ++ .../entity/CargoDiscardApplication.java | 34 ++ .../discard/entity/CargoDiscardCarton.java | 20 ++ .../discard/entity/CargoDiscardItem.java | 25 ++ .../entity/CargoDiscardStatusHistory.java | 21 ++ .../mapper/CargoDiscardApplicationMapper.java | 19 + .../mapper/CargoDiscardCartonMapper.java | 16 + .../mapper/CargoDiscardInventoryMapper.java | 66 ++++ .../mapper/CargoDiscardItemMapper.java | 16 + .../CargoDiscardStatusHistoryMapper.java | 16 + .../model/CargoDiscardCandidateRow.java | 20 ++ .../discard/service/CargoDiscardService.java | 325 ++++++++++++++++++ .../dropship/mapper/DropshipCartonMapper.java | 4 + .../mapper/DropshipSkuInventoryMapper.java | 9 +- .../dto/LastlegOrderDetailResponse.java | 15 +- ...MerchantOverseasProcurementController.java | 56 +++ ...PlatformOverseasProcurementController.java | 96 ++++++ .../dto/OverseasProcurementRequests.java | 63 ++++ .../dto/OverseasProcurementResponses.java | 34 ++ .../entity/OverseasProcurementOrder.java | 48 +++ .../entity/OverseasProcurementOutbound.java | 24 ++ .../OverseasProcurementStatusHistory.java | 21 ++ .../OverseasProcurementOrderMapper.java | 25 ++ .../OverseasProcurementOutboundMapper.java | 16 + ...verseasProcurementStatusHistoryMapper.java | 16 + .../service/OverseasProcurementService.java | 293 ++++++++++++++++ .../service/RelabelApplicationService.java | 7 +- .../lastleg/service/LastlegOrderService.java | 28 +- .../service/LoadingCompletedConsumer.java | 20 +- .../WarehouseTransferController.java | 15 + .../dto/WarehouseTransferRequests.java | 3 + .../mapper/WarehouseTransferOrderMapper.java | 2 + .../service/WarehouseTransferService.java | 56 ++- .../MerchantUsTruckOrderController.java | 62 ++++ .../PlatformUsTruckOrderController.java | 74 ++++ .../trucking/dto/UsTruckOrderRequests.java | 61 ++++ .../trucking/dto/UsTruckOrderResponses.java | 24 ++ .../lastleg/trucking/entity/UsTruckOrder.java | 40 +++ .../entity/UsTruckOrderStatusHistory.java | 21 ++ .../trucking/mapper/UsTruckOrderMapper.java | 19 + .../UsTruckOrderStatusHistoryMapper.java | 16 + .../trucking/service/UsTruckOrderService.java | 153 +++++++++ lastleg/src/main/resources/db/lastleg.sql | 131 +++++++ .../CargoDiscardSchemaContractTest.java | 22 ++ .../CargoDiscardControllerTest.java | 50 +++ .../service/CargoDiscardServiceTest.java | 183 ++++++++++ ...OverseasProcurementSchemaContractTest.java | 21 ++ .../OverseasProcurementControllerTest.java | 68 ++++ .../OverseasProcurementServiceTest.java | 103 ++++++ .../RelabelApplicationServiceTest.java | 18 + .../service/LastlegOrderServiceTest.java | 47 +++ .../service/LoadingCompletedConsumerTest.java | 7 +- .../service/WarehouseTransferServiceTest.java | 25 +- .../UsTruckOrderSchemaContractTest.java | 21 ++ .../UsTruckOrderControllerTest.java | 57 +++ .../service/UsTruckOrderServiceTest.java | 93 +++++ scripts/db/incremental.sql | 111 ++++++ 60 files changed, 2983 insertions(+), 21 deletions(-) create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardRequests.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardApplication.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardCarton.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardItem.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardStatusHistory.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardApplicationMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardCartonMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardItemMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardStatusHistoryMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardCandidateRow.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantOverseasProcurementController.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/procurement/controller/PlatformOverseasProcurementController.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOutbound.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementStatusHistory.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementOrderMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementOutboundMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementStatusHistoryMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/trucking/controller/MerchantUsTruckOrderController.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderRequests.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderResponses.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrder.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderStatusHistory.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderStatusHistoryMapper.java create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java create mode 100644 lastleg/src/test/java/com/baiyue/lastleg/discard/CargoDiscardSchemaContractTest.java create mode 100644 lastleg/src/test/java/com/baiyue/lastleg/discard/controller/CargoDiscardControllerTest.java create mode 100644 lastleg/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java create mode 100644 lastleg/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementSchemaContractTest.java create mode 100644 lastleg/src/test/java/com/baiyue/lastleg/procurement/controller/OverseasProcurementControllerTest.java create mode 100644 lastleg/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java create mode 100644 lastleg/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderSchemaContractTest.java create mode 100644 lastleg/src/test/java/com/baiyue/lastleg/trucking/controller/UsTruckOrderControllerTest.java create mode 100644 lastleg/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java diff --git a/lastleg/AGENTS.md b/lastleg/AGENTS.md index 69d0d4f..19fbd5c 100644 --- a/lastleg/AGENTS.md +++ b/lastleg/AGENTS.md @@ -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` 计算,禁止直接覆盖库存量或产生负库存;审核、编辑和出库均须按可信租户范围执行。 ## 修改与验证 diff --git a/lastleg/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java b/lastleg/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java new file mode 100644 index 0000000..e0a1a32 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java @@ -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> 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 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> 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 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 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()); + } +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardRequests.java b/lastleg/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardRequests.java new file mode 100644 index 0000000..415d0f8 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardRequests.java @@ -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) {} +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java b/lastleg/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java new file mode 100644 index 0000000..7d906b4 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java @@ -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 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 items, + List histories) {} +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardApplication.java b/lastleg/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardApplication.java new file mode 100644 index 0000000..369ece1 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardApplication.java @@ -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; +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardCarton.java b/lastleg/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardCarton.java new file mode 100644 index 0000000..b8319c5 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardCarton.java @@ -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; +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardItem.java b/lastleg/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardItem.java new file mode 100644 index 0000000..7912792 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardItem.java @@ -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; +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardStatusHistory.java b/lastleg/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardStatusHistory.java new file mode 100644 index 0000000..e2caa34 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardStatusHistory.java @@ -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; +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardApplicationMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardApplicationMapper.java new file mode 100644 index 0000000..64a70ec --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardApplicationMapper.java @@ -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 { + @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); +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardCartonMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardCartonMapper.java new file mode 100644 index 0000000..e672a85 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardCartonMapper.java @@ -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 { + @Select("SELECT * FROM ll_cargo_discard_carton WHERE customer_id=#{customerId} AND application_id=#{applicationId} ORDER BY id") + List listApplication(@Param("customerId") String customerId, + @Param("applicationId") Long applicationId); +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java new file mode 100644 index 0000000..e3d7927 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java @@ -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("") + List 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 lockAvailable(@Param("customerId") String customerId, @Param("orderId") Long orderId, + @Param("skuCode") String skuCode, @Param("limit") int limit); + + @Update("UPDATE ll_dropship_carton SET status=#{toStatus},version=version+1,updated_at=NOW(3) " + + "WHERE customer_id=#{customerId} AND id=#{cartonId} AND status=#{fromStatus}") + int changeCartonStatus(@Param("customerId") String customerId, @Param("cartonId") Long cartonId, + @Param("fromStatus") String fromStatus, @Param("toStatus") String toStatus); + + @Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND id=#{cartonId} LIMIT 1 FOR UPDATE") + DropshipCarton lockCarton(@Param("customerId") String customerId, @Param("cartonId") Long cartonId); + + @Select("SELECT * FROM ll_dropship_sku_inventory WHERE customer_id=#{customerId} AND id=#{id} LIMIT 1 FOR UPDATE") + DropshipSkuInventory lockSkuInventory(@Param("customerId") String customerId, @Param("id") Long id); + + @Select("SELECT * FROM ll_dropship_sku_inventory WHERE customer_id=#{customerId} AND order_id=#{orderId} " + + "AND profile_id=#{profileId} AND sku_code=#{skuCode} LIMIT 1 FOR UPDATE") + DropshipSkuInventory lockSkuInventoryDimension(@Param("customerId") String customerId, + @Param("orderId") Long orderId, + @Param("profileId") Long profileId, + @Param("skuCode") String skuCode); + + @Update("UPDATE ll_dropship_sku_inventory SET package_count=package_count-#{packages},updated_at=NOW(3) " + + "WHERE customer_id=#{customerId} AND id=#{id} AND package_count>=#{packages}") + int deductSkuInventory(@Param("customerId") String customerId, @Param("id") Long id, + @Param("packages") int packages); +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardItemMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardItemMapper.java new file mode 100644 index 0000000..19ea10c --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardItemMapper.java @@ -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 { + @Select("SELECT * FROM ll_cargo_discard_item WHERE customer_id=#{customerId} AND application_id=#{applicationId} ORDER BY inbound_code,sku_code,profile_id") + List listApplication(@Param("customerId") String customerId, + @Param("applicationId") Long applicationId); +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardStatusHistoryMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardStatusHistoryMapper.java new file mode 100644 index 0000000..816a41e --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardStatusHistoryMapper.java @@ -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 { + @Select("SELECT * FROM ll_cargo_discard_status_history WHERE customer_id=#{customerId} AND application_id=#{applicationId} ORDER BY created_at,id") + List listApplication(@Param("customerId") String customerId, + @Param("applicationId") Long applicationId); +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardCandidateRow.java b/lastleg/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardCandidateRow.java new file mode 100644 index 0000000..90abc3e --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardCandidateRow.java @@ -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; +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java b/lastleg/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java new file mode 100644 index 0000000..ce9f58e --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java @@ -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 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 candidates(String customerId, String inboundCode) { + List rows = inventory.candidates(customerId, trim(inboundCode)); + Map 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 requestedDimensions = new LinkedHashSet<>(); + Map 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 page(String customerId, CargoDiscardRequests.Query query) { + validateQuery(query); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .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 result = applications.selectPage(Page.of(query.page(), query.size()), wrapper); + List 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 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 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 applicationItems = items.listApplication(customerId, application.getId()); + List links = discardCartons.listApplication(customerId, application.getId()); + Map> 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 selected = inventory.lockAvailable(customerId, order.getId(), skuCode, input.discardPackages()); + if (selected.size() != input.discardPackages()) throw conflict("Available cargo changed; retry the discard request"); + Map> byProfile = selected.stream() + .collect(java.util.stream.Collectors.groupingBy(DropshipCarton::getProfileId, LinkedHashMap::new, + java.util.stream.Collectors.toList())); + for (Map.Entry> 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 applicationItems, + Map> 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 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 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 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 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; } + } +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonMapper.java index d29fc4d..b4db9f3 100644 --- a/lastleg/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonMapper.java +++ b/lastleg/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonMapper.java @@ -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 { @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 listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId); } diff --git a/lastleg/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipSkuInventoryMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipSkuInventoryMapper.java index 224fe5b..b0f1c7a 100644 --- a/lastleg/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipSkuInventoryMapper.java +++ b/lastleg/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipSkuInventoryMapper.java @@ -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 {} +import java.util.List; + +public interface DropshipSkuInventoryMapper extends BaseMapper { + @Select("SELECT * FROM ll_dropship_sku_inventory WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY profile_id,sku_code") + List listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId); +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java b/lastleg/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java index 8807051..c73e5fb 100644 --- a/lastleg/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java +++ b/lastleg/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java @@ -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 cargo, - List statusHistory + List statusHistory, + List dropshipPallets ) { + public record DropshipPallet( + Long profileId, + String sourcePalletNo, + Long warehousePalletId, + String warehousePalletNo, + Long inventoryPositionId, + String status, + List skuInventory, + List cartons + ) {} } diff --git a/lastleg/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantOverseasProcurementController.java b/lastleg/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantOverseasProcurementController.java new file mode 100644 index 0000000..d8bb670 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantOverseasProcurementController.java @@ -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 create( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody OverseasProcurementRequests.Create request) { + return ApiResponse.success(service.create(customer(user), operator(user), request)); + } + + @GetMapping + public ApiResponse> page( + @AuthenticationPrincipal AuthenticatedUser user, @Valid OverseasProcurementRequests.Query query) { + return ApiResponse.success(service.page(customer(user), query)); + } + + @GetMapping("/{orderNo}") + public ApiResponse 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()); + } +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/procurement/controller/PlatformOverseasProcurementController.java b/lastleg/src/main/java/com/baiyue/lastleg/procurement/controller/PlatformOverseasProcurementController.java new file mode 100644 index 0000000..2d0e71a --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/procurement/controller/PlatformOverseasProcurementController.java @@ -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> 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 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 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 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 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(); } +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java b/lastleg/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java new file mode 100644 index 0000000..a361ed7 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java @@ -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) {} +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java b/lastleg/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java new file mode 100644 index 0000000..a3d3dd5 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java @@ -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 outbounds, List histories) {} +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java b/lastleg/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java new file mode 100644 index 0000000..49c3b18 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java @@ -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; +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOutbound.java b/lastleg/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOutbound.java new file mode 100644 index 0000000..696c6d4 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOutbound.java @@ -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; +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementStatusHistory.java b/lastleg/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementStatusHistory.java new file mode 100644 index 0000000..68776e3 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementStatusHistory.java @@ -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; +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementOrderMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementOrderMapper.java new file mode 100644 index 0000000..e22af6a --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementOrderMapper.java @@ -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 { + @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); +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementOutboundMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementOutboundMapper.java new file mode 100644 index 0000000..3f96d94 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementOutboundMapper.java @@ -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 { + @Select("SELECT * FROM ll_overseas_procurement_outbound WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY outbound_at DESC,id DESC") + List listOrder(@Param("customerId") String customerId, + @Param("orderId") Long orderId); +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementStatusHistoryMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementStatusHistoryMapper.java new file mode 100644 index 0000000..b35f731 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementStatusHistoryMapper.java @@ -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 { + @Select("SELECT * FROM ll_overseas_procurement_status_history WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY created_at,id") + List listOrder(@Param("customerId") String customerId, + @Param("orderId") Long orderId); +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java b/lastleg/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java new file mode 100644 index 0000000..d955c88 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java @@ -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 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 page( + String customerId, OverseasProcurementRequests.Query query) { + validateQuery(query); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .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 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 setter) { + if (StringUtils.hasText(value)) setter.accept(value.trim()); + } + private static void setIfPresent(String value, java.util.function.Consumer 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); + } +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java b/lastleg/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java index db47704..935dc14 100644 --- a/lastleg/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java +++ b/lastleg/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java @@ -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); } diff --git a/lastleg/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java b/lastleg/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java index d3b69fe..3870d14 100644 --- a/lastleg/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java +++ b/lastleg/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java @@ -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 page(String customerId, LastlegOrderRequests.Query query) { Page page = orderMapper.selectPage( @@ -50,7 +60,23 @@ public class LastlegOrderService { new LambdaQueryWrapper() .eq(LastlegOrderStatusHistory::getOrderId, orderId) .orderByAsc(LastlegOrderStatusHistory::getCreatedAt)); - return new LastlegOrderDetailResponse(order, cargo, history); + List pallets = dropshipPallets(customerId, order); + return new LastlegOrderDetailResponse(order, cargo, history, pallets); + } + + private List 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> skuByProfile = dropshipSkuInventory + .listOrder(customerId, order.getId()).stream().collect(Collectors.groupingBy(DropshipSkuInventory::getProfileId)); + Map> 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 diff --git a/lastleg/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java b/lastleg/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java index 2cdd2fb..49fa9ca 100644 --- a/lastleg/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java +++ b/lastleg/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -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 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 pallets(JsonNode source) { + private List 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 legacyPallets(JsonNode source) { List result = new ArrayList<>(); JsonNode nodes = source.path("pallets"); if (nodes.isArray()) { diff --git a/lastleg/src/main/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferController.java b/lastleg/src/main/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferController.java index a47dc82..62d02e9 100644 --- a/lastleg/src/main/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferController.java +++ b/lastleg/src/main/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferController.java @@ -29,6 +29,21 @@ public class WarehouseTransferController { return ApiResponse.success(service.detail(scope(user, customerId), transferNo)); } + @PostMapping("/{transferNo}/review") + public ApiResponse 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) { diff --git a/lastleg/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java b/lastleg/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java index ed94b6f..ad0453c 100644 --- a/lastleg/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java +++ b/lastleg/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java @@ -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, diff --git a/lastleg/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java index fdbc237..e062027 100644 --- a/lastleg/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java +++ b/lastleg/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java @@ -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); } diff --git a/lastleg/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java b/lastleg/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java index 70d5a16..d37384a 100644 --- a/lastleg/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java +++ b/lastleg/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java @@ -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) { diff --git a/lastleg/src/main/java/com/baiyue/lastleg/trucking/controller/MerchantUsTruckOrderController.java b/lastleg/src/main/java/com/baiyue/lastleg/trucking/controller/MerchantUsTruckOrderController.java new file mode 100644 index 0000000..2c30b59 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/trucking/controller/MerchantUsTruckOrderController.java @@ -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 create(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody UsTruckOrderRequests.Create request) { + return ApiResponse.success(service.create(customer(user), operator(user), request)); + } + + @GetMapping + public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, + @Valid UsTruckOrderRequests.Query query) { + return ApiResponse.success(service.page(customer(user), query)); + } + + @GetMapping("/{orderNo}") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String orderNo) { + return ApiResponse.success(service.detail(customer(user), orderNo)); + } + + @PatchMapping("/{orderNo}") + public ApiResponse 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()); + } +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java b/lastleg/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java new file mode 100644 index 0000000..7ff16e6 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java @@ -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> page(@AuthenticationPrincipal AuthenticatedUser user, + @Valid UsTruckOrderRequests.Query query) { + requirePlatform(user); + return ApiResponse.success(service.page(null, query)); + } + + @GetMapping("/{orderNo}") + public ApiResponse 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 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()); + } +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderRequests.java b/lastleg/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderRequests.java new file mode 100644 index 0000000..95695c8 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderRequests.java @@ -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) {} +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderResponses.java b/lastleg/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderResponses.java new file mode 100644 index 0000000..b306641 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderResponses.java @@ -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 histories) {} +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrder.java b/lastleg/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrder.java new file mode 100644 index 0000000..8cc45be --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrder.java @@ -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; +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderStatusHistory.java b/lastleg/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderStatusHistory.java new file mode 100644 index 0000000..be4fee4 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderStatusHistory.java @@ -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; +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderMapper.java new file mode 100644 index 0000000..f6c4308 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderMapper.java @@ -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 { + @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); +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderStatusHistoryMapper.java b/lastleg/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderStatusHistoryMapper.java new file mode 100644 index 0000000..8875ec5 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderStatusHistoryMapper.java @@ -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 { + @Select("SELECT * FROM ll_us_truck_order_status_history WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY created_at,id") + List listOrder(@Param("customerId") String customerId, + @Param("orderId") Long orderId); +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java b/lastleg/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java new file mode 100644 index 0000000..2a9a520 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java @@ -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 STATUSES = Set.of(PENDING_CONFIRMATION, PENDING_DISPATCH, DISPATCHED, + IN_TRANSIT, COMPLETED, CANCELLED); + private static final Set 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 page(String customerId, UsTruckOrderRequests.Query query) { + validateQuery(query); + LambdaQueryWrapper w = new LambdaQueryWrapper() + .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 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 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 c) { if (StringUtils.hasText(v)) c.accept(v.trim()); } + private static void setPresent(String v, Consumer 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"); } +} diff --git a/lastleg/src/main/resources/db/lastleg.sql b/lastleg/src/main/resources/db/lastleg.sql index 1f9b26a..60547a9 100644 --- a/lastleg/src/main/resources/db/lastleg.sql +++ b/lastleg/src/main/resources/db/lastleg.sql @@ -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'; diff --git a/lastleg/src/test/java/com/baiyue/lastleg/discard/CargoDiscardSchemaContractTest.java b/lastleg/src/test/java/com/baiyue/lastleg/discard/CargoDiscardSchemaContractTest.java new file mode 100644 index 0000000..9d4a5bd --- /dev/null +++ b/lastleg/src/test/java/com/baiyue/lastleg/discard/CargoDiscardSchemaContractTest.java @@ -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)")); + } +} diff --git a/lastleg/src/test/java/com/baiyue/lastleg/discard/controller/CargoDiscardControllerTest.java b/lastleg/src/test/java/com/baiyue/lastleg/discard/controller/CargoDiscardControllerTest.java new file mode 100644 index 0000000..8dc5970 --- /dev/null +++ b/lastleg/src/test/java/com/baiyue/lastleg/discard/controller/CargoDiscardControllerTest.java @@ -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 permissions) { + return new AuthenticatedUser(7L, 100L, 100L, 8L, "reviewer", "MERCHANT", "WEB", permissions); + } +} diff --git a/lastleg/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java b/lastleg/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java new file mode 100644 index 0000000..26fd12d --- /dev/null +++ b/lastleg/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java @@ -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 storedApplication = new AtomicReference<>(); + private final List storedItems = new ArrayList<>(); + private final List storedLinks = new ArrayList<>(); + private final List 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; + } +} diff --git a/lastleg/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementSchemaContractTest.java b/lastleg/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementSchemaContractTest.java new file mode 100644 index 0000000..d9d7cdb --- /dev/null +++ b/lastleg/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementSchemaContractTest.java @@ -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)")); + } +} diff --git a/lastleg/src/test/java/com/baiyue/lastleg/procurement/controller/OverseasProcurementControllerTest.java b/lastleg/src/test/java/com/baiyue/lastleg/procurement/controller/OverseasProcurementControllerTest.java new file mode 100644 index 0000000..61559b9 --- /dev/null +++ b/lastleg/src/test/java/com/baiyue/lastleg/procurement/controller/OverseasProcurementControllerTest.java @@ -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 permissions) { + return new AuthenticatedUser(7L, tenantId, tenantId, 8L, "operator", appCode, "WEB", permissions); + } +} diff --git a/lastleg/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java b/lastleg/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java new file mode 100644 index 0000000..3088017 --- /dev/null +++ b/lastleg/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java @@ -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 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; + } +} diff --git a/lastleg/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java b/lastleg/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java index e655340..52496ca 100644 --- a/lastleg/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java +++ b/lastleg/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java @@ -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))); diff --git a/lastleg/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java b/lastleg/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java index bd08fac..b2eae4f 100644 --- a/lastleg/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java +++ b/lastleg/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java @@ -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; + } } diff --git a/lastleg/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java b/lastleg/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java index 8269a4d..30aa475 100644 --- a/lastleg/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java +++ b/lastleg/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java @@ -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 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 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); } } diff --git a/lastleg/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java b/lastleg/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java index e519685..cf36fb9 100644 --- a/lastleg/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java +++ b/lastleg/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java @@ -89,7 +89,7 @@ class WarehouseTransferServiceTest { ArgumentCaptor 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); diff --git a/lastleg/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderSchemaContractTest.java b/lastleg/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderSchemaContractTest.java new file mode 100644 index 0000000..6d90521 --- /dev/null +++ b/lastleg/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderSchemaContractTest.java @@ -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)")); + } +} diff --git a/lastleg/src/test/java/com/baiyue/lastleg/trucking/controller/UsTruckOrderControllerTest.java b/lastleg/src/test/java/com/baiyue/lastleg/trucking/controller/UsTruckOrderControllerTest.java new file mode 100644 index 0000000..e70f33d --- /dev/null +++ b/lastleg/src/test/java/com/baiyue/lastleg/trucking/controller/UsTruckOrderControllerTest.java @@ -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 permissions) { + return new AuthenticatedUser(7L, tenant, tenant, 8L, "operator", app, "WEB", permissions); + } +} diff --git a/lastleg/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java b/lastleg/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java new file mode 100644 index 0000000..e301c5d --- /dev/null +++ b/lastleg/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java @@ -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 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; + } +} diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index abe1ec7..6e45183 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -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';