This commit is contained in:
2026-08-20 18:02:38 +08:00
parent b55b049e77
commit 1cd8b6a473
210 changed files with 6974 additions and 825 deletions
@@ -3,11 +3,15 @@ package com.baiyue.lastleg;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 尾程微服务预留模块启动入口,初始化 Spring Boot 应用并扫描尾程 Mapper。
*/
@MapperScan("com.baiyue.lastleg.mapper")
@EnableFeignClients(basePackages = "com.baiyue.lastleg")
@EnableScheduling
@SpringBootApplication(scanBasePackages = "com.baiyue")
public class BaiyueLastlegApplication {
public static void main(String[] args) { SpringApplication.run(BaiyueLastlegApplication.class, args); }
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.client;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.event.EventEnvelope;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
/** 将海外业务 Outbox 投递到国内区域事件桥。 */
@FeignClient(name = "event-bridge-service", contextId = "overseasContainerEventBridgeClient",
url = "${baiyue.firstleg.event-bridge-url:}")
public interface EventBridgeClient {
@PostMapping("/internal/event-bridge/events")
ApiResponse<Acknowledgement> receive(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody EventEnvelope envelope);
record Acknowledgement(String eventId, String result, String receivedAt) { }
}
@@ -8,6 +8,7 @@ import com.baiyue.lastleg.dto.LastlegOrderDetailResponse;
import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.dto.LastlegOrderSummary;
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
import com.baiyue.lastleg.security.OverseasPermissions;
import com.baiyue.lastleg.service.LastlegOrderService;
import com.baiyue.lastleg.service.LoadingCompletedConsumer;
import io.swagger.v3.oas.annotations.Operation;
@@ -19,6 +20,7 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@@ -48,7 +50,8 @@ public class LastlegOrderController {
}
@GetMapping("/api/platform/v1/last-leg/orders")
@Operation(summary = "Page tail-leg order rows")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')")
@Operation(summary = "Page tail-leg order rows", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_LIST)
public ApiResponse<PageResult<LastlegOrderSummary>> list(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@@ -57,7 +60,8 @@ public class LastlegOrderController {
}
@GetMapping("/api/platform/v1/last-leg/orders/{id}")
@Operation(summary = "Get one inbound-code-level tail-leg order")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')")
@Operation(summary = "Get one inbound-code-level tail-leg order", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_LIST)
public ApiResponse<LastlegOrderDetailResponse> detail(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@@ -66,7 +70,8 @@ public class LastlegOrderController {
}
@GetMapping(value = "/api/platform/v1/last-leg/orders/export", produces = "text/csv")
@Operation(summary = "Export filtered tail-leg orders as UTF-8 CSV")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EXPORT + "')")
@Operation(summary = "Export filtered tail-leg orders as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EXPORT)
public ResponseEntity<byte[]> export(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@@ -79,7 +84,8 @@ public class LastlegOrderController {
}
@PutMapping("/api/platform/v1/last-leg/orders/{id}/destination")
@Operation(summary = "Transfer one order to a destination warehouse")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')")
@Operation(summary = "Transfer one order to a destination warehouse", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT)
public ApiResponse<Integer> transferOne(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@@ -89,7 +95,8 @@ public class LastlegOrderController {
}
@PutMapping("/api/platform/v1/last-leg/orders/destination")
@Operation(summary = "Transfer multiple orders to a destination warehouse")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')")
@Operation(summary = "Transfer multiple orders to a destination warehouse", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT)
public ApiResponse<Integer> transferBatch(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@@ -10,12 +10,14 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@RestController
@ConditionalOnProperty(name = "lastleg.future-outbound-api.enabled", havingValue = "true")
@RequestMapping("/api/platform/v1/last-leg")
@Tag(name = "Outbound and delivery")
public class DeliveryTransferController {
@@ -1,6 +1,5 @@
package com.baiyue.lastleg.delivery.dto;
import com.baiyue.common.core.api.PageResult;
import java.time.LocalDateTime;
public final class DeliveryResponses {
@@ -17,11 +16,5 @@ public final class DeliveryResponses {
String overseasWarehouseId, String status, int palletCount,
int packageCount, LocalDateTime createdAt) {}
public record PalletRow(Long id, Long warehousePalletId, String palletNo, String inboundCode,
String orderRefs, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
int packageCount) {}
public record OutboundDetail(OutboundRow outbound, DeliveryRow delivery,
PageResult<PalletRow> pallets) {}
public record OutboundDetail(OutboundRow outbound, DeliveryRow delivery) {}
}
@@ -136,14 +136,7 @@ public class DeliveryTransferService {
OutboundOrder outbound = outboundOrders.findByNo(customerId, outboundNo);
if (outbound == null) throw notFound("Outbound order not found");
DeliveryOrder delivery = requireDelivery(customerId, outbound.getId());
Page<OutboundPalletItem> page = outboundPallets.selectPage(new Page<>(query.page(), query.size()),
new LambdaQueryWrapper<OutboundPalletItem>().eq(OutboundPalletItem::getCustomerId, customerId)
.eq(OutboundPalletItem::getOutboundOrderId, outbound.getId())
.eq(StringUtils.hasText(query.destinationWarehouseCode()),
OutboundPalletItem::getDestinationWarehouseCode, query.destinationWarehouseCode())
.orderByAsc(OutboundPalletItem::getId));
return new DeliveryResponses.OutboundDetail(outboundRow(outbound), deliveryRow(delivery),
page(page, page.getRecords().stream().map(this::palletRow).toList()));
return new DeliveryResponses.OutboundDetail(outboundRow(outbound), deliveryRow(delivery));
}
public PageResult<DeliveryResponses.DeliveryRow> deliveryPage(String customerId, DeliveryRequests.Query query) {
@@ -206,7 +199,6 @@ public class DeliveryTransferService {
}
private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getCreatedAt()); }
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getStatus(), d.getPalletCount(), d.getPackageCount(), d.getCreatedAt()); }
private DeliveryResponses.PalletRow palletRow(OutboundPalletItem p) { return new DeliveryResponses.PalletRow(p.getId(), p.getWarehousePalletId(), p.getPalletNo(), p.getInboundCode(), p.getOrderRefs(), p.getDestinationWarehouseId(), p.getDestinationWarehouseCode(), p.getDestinationWarehouseName(), p.getPackageCount()); }
private static <T> PageResult<T> page(Page<?> page, List<T> records) { return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); }
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); }
@@ -15,6 +15,7 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
@@ -31,6 +32,7 @@ import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@RestController
@ConditionalOnProperty(name = "lastleg.future-outbound-api.enabled", havingValue = "true")
@RequestMapping("/api/platform/v1/last-leg/dispatches")
@Tag(name = "Tail-leg vehicle dispatch")
public class DispatchController {
@@ -7,13 +7,15 @@ import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses;
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/platform/v1/last-leg/dispatches/{dispatchNo}/pda")
@ConditionalOnProperty(name = "lastleg.future-outbound-api.enabled", havingValue = "true")
@RequestMapping("/api/pda/v1/last-leg/dispatches/{dispatchNo}")
public class DispatchPdaController {
public static final String OPERATE_PERMISSION = "lastleg:pda:operate";
@Resource private DispatchPdaWorkflowService service;
@@ -1,27 +1,15 @@
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.time.LocalDateTime;
import java.util.List;
public record LastlegOrderDetailResponse(
LastlegOrder order,
LastlegOrderSummary order,
List<LastlegOrderDetail> cargo,
List<LastlegOrderStatusHistory> statusHistory,
List<DropshipPallet> dropshipPallets
List<History> statusHistory
) {
public record DropshipPallet(
Long profileId,
String sourcePalletNo,
Long warehousePalletId,
String warehousePalletNo,
Long inventoryPositionId,
String status,
List<DropshipSkuInventory> skuInventory,
List<DropshipCarton> cartons
) {}
public record History(Long id, String fromStatus, String toStatus, String remark, LocalDateTime createdAt) {}
}
@@ -26,9 +26,6 @@ public record LastlegOrderSummary(
Boolean detained,
Integer palletCount,
Integer packageCount,
Integer pickedPackageCount,
Integer shippedPackageCount,
Integer remainingPackageCount,
BigDecimal volumeCbm,
Integer productQuantity,
BigDecimal volumeWeightKg,
@@ -36,8 +33,6 @@ public record LastlegOrderSummary(
LocalDateTime actualArrivalAt,
LocalDateTime estimatedWarehouseAt,
String inboundOrderNo,
String outboundOrderNo,
String deliveryOrderNo,
String relabelOrderNo,
String relabelStatus,
String palletMergeFlag,
@@ -46,6 +41,7 @@ public record LastlegOrderSummary(
String privateRemark,
String customerRemark,
String warehouseRemark,
LocalDateTime createdAt
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
}
@@ -23,6 +23,8 @@ public class LastlegEventOutbox {
private Integer retryCount;
private LocalDateTime nextRetryAt;
private LocalDateTime publishedAt;
private LocalDateTime lockedAt;
private String lockedBy;
private String lastError;
private LocalDateTime createdAt;
}
@@ -1,20 +1,25 @@
package com.baiyue.lastleg.inboundplan.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.inboundplan.dto.ConvertInboundPlanRequest;
import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest;
import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanSummary;
import com.baiyue.lastleg.inboundplan.dto.SupplementPalletRequest;
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
import com.baiyue.lastleg.inboundplan.service.InboundPlanService;
import com.baiyue.lastleg.security.OverseasPermissions;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@@ -25,15 +30,27 @@ public class InboundPlanController {
@Resource private InboundPlanService service;
@Resource private InboundFbaAppointmentService appointmentService;
@GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')")
@Operation(summary = "Page inbound plans without PDA, internal-event or outbound workflow fields", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST)
public ApiResponse<PageResult<InboundPlanSummary>> page(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid InboundPlanRequests.Query query) {
return ApiResponse.success(service.page(scope(user, customerId), query));
}
@PostMapping("/convert")
@Operation(summary = "Convert one inbound order to one inbound plan")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_CREATE + "')")
@Operation(summary = "Convert one inbound order to one inbound plan", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_CREATE)
public ApiResponse<InboundPlanResponse> convert(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ConvertInboundPlanRequest request) {
return ApiResponse.success(service.convert(scope(user, request.customerId()), request.orderId()));
}
@GetMapping("/{planNo}")
@Operation(summary = "Get an inbound plan with lines, pallets and allocations")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')")
@Operation(summary = "Get an inbound plan with lines, pallets and allocations", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST)
public ApiResponse<InboundPlanResponse> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@RequestParam(required = false) String customerId) {
@@ -41,7 +58,8 @@ public class InboundPlanController {
}
@PostMapping("/{planNo}/pallets/supplement")
@Operation(summary = "Add one printable supplemental pallet for a plan destination warehouse")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_EDIT + "')")
@Operation(summary = "Add one printable supplemental pallet for a plan destination warehouse", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_EDIT)
public ApiResponse<InboundPlanResponse.Pallet> supplementPallet(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@Valid @RequestBody SupplementPalletRequest request) {
@@ -49,7 +67,8 @@ public class InboundPlanController {
}
@PostMapping("/fba-appointments/batch")
@Operation(summary = "Create one FBA appointment for selected inbound lines")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_EDIT + "')")
@Operation(summary = "Create one FBA appointment for selected inbound lines", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_EDIT)
public ApiResponse<FbaAppointmentResponse> batchFbaAppointment(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody BatchFbaAppointmentRequest request) {
@@ -59,7 +78,8 @@ public class InboundPlanController {
}
@GetMapping("/fba-appointments/{appointmentNo}")
@Operation(summary = "Get an FBA appointment and its selected inbound lines")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')")
@Operation(summary = "Get an FBA appointment and its selected inbound lines", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST)
public ApiResponse<FbaAppointmentResponse> fbaAppointmentDetail(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String appointmentNo,
@@ -0,0 +1,30 @@
package com.baiyue.lastleg.inboundplan.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import java.time.LocalDateTime;
public final class InboundPlanRequests {
private InboundPlanRequests() {
}
public record Query(
@Min(1) long page,
@Min(1) @Max(200) long size,
String planNo,
String inboundCode,
String status,
String overseasWarehouseId,
String destinationWarehouseCode,
String fbaWarehouseCode,
String appointmentStatus,
LocalDateTime createdFrom,
LocalDateTime createdTo
) {
public Query {
if (page == 0) page = 1;
if (size == 0) size = 20;
}
}
}
@@ -0,0 +1,32 @@
package com.baiyue.lastleg.inboundplan.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Schema(description = "Inbound plan row for the platform browser API; outbound workflow fields are intentionally excluded")
public record InboundPlanSummary(
Long id,
String planNo,
Long sourceOrderId,
String customerId,
String customerName,
String orderType,
String inboundCode,
String containerNo,
String overseasWarehouseId,
String overseasWarehouseName,
String destinationWarehouseId,
String destinationWarehouseCode,
String destinationWarehouseName,
String fbaWarehouseCode,
Integer packageCount,
BigDecimal totalVolumeCbm,
Integer palletCount,
String status,
String appointmentStatus,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
}
@@ -1,13 +1,50 @@
package com.baiyue.lastleg.inboundplan.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanSummary;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
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 InboundPlanMapper extends BaseMapper<InboundPlan> {
String PAGE_FROM = " FROM ll_inbound_plan p JOIN ll_order o ON o.id=p.source_order_id AND o.customer_id=p.customer_id " +
"LEFT JOIN ll_inbound_plan_line l ON l.plan_id=p.id AND l.order_id=p.source_order_id " +
"WHERE p.customer_id=#{customerId}" +
"<if test='q.planNo != null and q.planNo != \"\"'> AND p.plan_no LIKE CONCAT('%',#{q.planNo},'%')</if>" +
"<if test='q.inboundCode != null and q.inboundCode != \"\"'> AND l.inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')</if>" +
"<if test='q.status != null and q.status != \"\"'> AND p.status=#{q.status}</if>" +
"<if test='q.overseasWarehouseId != null and q.overseasWarehouseId != \"\"'> AND o.overseas_warehouse_id=#{q.overseasWarehouseId}</if>" +
"<if test='q.destinationWarehouseCode != null and q.destinationWarehouseCode != \"\"'> AND l.destination_warehouse_code=#{q.destinationWarehouseCode}</if>" +
"<if test='q.fbaWarehouseCode != null and q.fbaWarehouseCode != \"\"'> AND l.fba_warehouse_code=#{q.fbaWarehouseCode}</if>" +
"<if test='q.appointmentStatus != null and q.appointmentStatus != \"\" and q.appointmentStatus != \"NOT_APPOINTED\"'> " +
"AND EXISTS (SELECT 1 FROM ll_inbound_fba_appointment_item ai JOIN ll_inbound_fba_appointment a ON a.id=ai.appointment_id " +
"WHERE ai.plan_id=p.id AND a.customer_id=#{customerId} AND a.appointment_status=#{q.appointmentStatus})</if>" +
"<if test='q.appointmentStatus == \"NOT_APPOINTED\"'> AND NOT EXISTS (SELECT 1 FROM ll_inbound_fba_appointment_item ai " +
"WHERE ai.plan_id=p.id)</if>" +
"<if test='q.createdFrom != null'> AND p.created_at &gt;=#{q.createdFrom}</if>" +
"<if test='q.createdTo != null'> AND p.created_at &lt;=#{q.createdTo}</if>";
@Select("<script>SELECT p.id,p.plan_no,p.source_order_id,p.customer_id,o.customer_name,o.order_type," +
"l.inbound_code,p.container_no,o.overseas_warehouse_id,o.overseas_warehouse_name," +
"l.destination_warehouse_id,l.destination_warehouse_code,l.destination_warehouse_name,l.fba_warehouse_code," +
"COALESCE(l.required_package_count,o.package_count,0) package_count,p.total_volume_cbm,p.pallet_count,p.status," +
"COALESCE((SELECT a.appointment_status FROM ll_inbound_fba_appointment_item ai " +
"JOIN ll_inbound_fba_appointment a ON a.id=ai.appointment_id WHERE ai.plan_id=p.id " +
"ORDER BY a.created_at DESC,a.id DESC LIMIT 1),'NOT_APPOINTED') appointment_status,p.created_at,p.updated_at" +
PAGE_FROM + " ORDER BY p.created_at DESC,p.id DESC LIMIT #{limit} OFFSET #{offset}</script>")
List<InboundPlanSummary> pageRows(@Param("customerId") String customerId,
@Param("q") InboundPlanRequests.Query query,
@Param("offset") long offset,
@Param("limit") long limit);
@Select("<script>SELECT COUNT(*)" + PAGE_FROM + "</script>")
long countRows(@Param("customerId") String customerId, @Param("q") InboundPlanRequests.Query query);
@Select("SELECT * FROM ll_inbound_plan WHERE customer_id=#{customerId} AND source_order_id=#{sourceOrderId} LIMIT 1")
InboundPlan findBySourceOrder(@Param("customerId") String customerId, @Param("sourceOrderId") Long sourceOrderId);
}
@@ -17,7 +17,7 @@ import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/platform/v1/last-leg/inbound-plans/{planNo}/pallets")
@RequestMapping("/api/pda/v1/last-leg/inbound-plans/{planNo}/pallets")
public class InboundPalletAssignmentController {
@Resource private InboundPdaWorkflowService service;
@@ -21,7 +21,7 @@ import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@RestController
@RequestMapping("/api/platform/v1/last-leg/inbound-plans/{planNo}/pda")
@RequestMapping("/api/pda/v1/last-leg/inbound-plans/{planNo}")
public class InboundPdaController {
public static final String OPERATE_PERMISSION = "lastleg:pda:operate";
@Resource private InboundPdaWorkflowService service;
@@ -20,6 +20,9 @@ public class InboundStockReceipt {
private Long lineId;
private Long orderId;
private String inboundCode;
private String skuCode;
private String productNameZh;
private String productNameEn;
private Long warehousePalletId;
private String palletNo;
private Long warehouseLocationId;
@@ -2,6 +2,7 @@ package com.baiyue.lastleg.inboundplan.pda.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
@@ -13,6 +14,7 @@ import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.dropship.service.DropshipOrderService;
@@ -34,6 +36,7 @@ import java.util.stream.Collectors;
@Service
public class InboundStockReceiptService {
@Resource private LastlegOrderMapper orders;
@Resource private LastlegOrderDetailMapper orderDetails;
@Resource private InboundStockReceiptMapper receipts;
@Resource private InventoryCommandService inventory;
@Resource private DropshipOrderService dropshipOrders;
@@ -79,9 +82,22 @@ public class InboundStockReceiptService {
if (pickedPackages != value(line.getRequiredPackageCount())) {
throw conflict("PDA picked packages must equal the inbound order package total");
}
List<SkuRemainder> skuRemainders = "ONE_CLICK_DROPSHIP".equals(order.getOrderType())
? List.of() : skuRemainders(order.getId(), pickedPackages);
for (InboundPlanPalletAllocation allocation : lineAllocations) {
created.add(persist(customerId, plan, task, line, allocation,
requirePallet(palletById, allocation), order, receivedAt));
InboundPlanPallet pallet = requirePallet(palletById, allocation);
if ("ONE_CLICK_DROPSHIP".equals(order.getOrderType())) {
created.add(persist(customerId, plan, task, line, allocation, pallet, order,
SkuSnapshot.unspecified(value(allocation.getPickedPackageCount())), receivedAt));
} else {
long allocationPackages = value(allocation.getPickedPackageCount());
for (SkuSnapshot sku : consume(skuRemainders, allocationPackages)) {
created.add(persist(customerId, plan, task, line, allocation, pallet, order, sku, receivedAt));
}
}
}
if (skuRemainders.stream().anyMatch(value -> value.remaining > 0)) {
throw conflict("Inbound SKU package snapshots were not fully received");
}
}
activateDropship(customerId, task.getId(), orderById, created);
@@ -112,20 +128,21 @@ public class InboundStockReceiptService {
private InboundStockReceipt persist(String customerId, InboundPlan plan, InboundPdaTask task, InboundPlanLine line,
InboundPlanPalletAllocation allocation, InboundPlanPallet pallet,
LastlegOrder order, LocalDateTime receivedAt) {
long packages = value(allocation.getPickedPackageCount());
LastlegOrder order, SkuSnapshot sku, LocalDateTime receivedAt) {
long packages = sku.packages();
if (packages <= 0 || pallet.getWarehousePalletId() == null) {
throw conflict("Stock-in requires picked packages and an assigned warehouse pallet");
}
String destinationCode = required(first(line.getDestinationWarehouseCode(), order.getFbaWarehouseCode()),
"Destination warehouse code is required for stock-in");
String operationKey = "PDA_STOCK_IN:" + task.getId() + ":" + allocation.getId();
String operationKey = "PDA_STOCK_IN:" + task.getId() + ":" + allocation.getId() + ":" + sku.skuCode();
String overseasWarehouseId = required(order.getOverseasWarehouseId(),
"Actual overseas warehouse is required for stock-in");
InventoryCommandResponse result = inventory.stockIn(customerId, task.getOperatorId(),
new InventoryCommandRequests.StockIn(null, order.getCustomerName(), overseasWarehouseId,
null, order.getOverseasWarehouseName(), line.getDestinationWarehouseId(), destinationCode,
line.getDestinationWarehouseName(), order.getId(), plan.getPlanNo(), line.getInboundCode(),
sku.skuCode(), sku.productNameZh(), sku.productNameEn(),
pallet.getWarehousePalletId(), pallet.getPalletNo(), plan.getPlanNo(),
pallet.getWarehouseLocationId(), null, packages, order.getOrderNo(),
order.getSourceEventId(), operationKey,
@@ -140,6 +157,9 @@ public class InboundStockReceiptService {
receipt.setLineId(line.getId());
receipt.setOrderId(order.getId());
receipt.setInboundCode(line.getInboundCode());
receipt.setSkuCode(sku.skuCode());
receipt.setProductNameZh(sku.productNameZh());
receipt.setProductNameEn(sku.productNameEn());
receipt.setWarehousePalletId(pallet.getWarehousePalletId());
receipt.setPalletNo(pallet.getPalletNo());
receipt.setWarehouseLocationId(pallet.getWarehouseLocationId());
@@ -154,6 +174,46 @@ public class InboundStockReceiptService {
return receipt;
}
private List<SkuRemainder> skuRemainders(Long orderId, long expectedPackages) {
List<LastlegOrderDetail> details = orderDetails.selectList(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<LastlegOrderDetail>()
.eq(LastlegOrderDetail::getOrderId, orderId).orderByAsc(LastlegOrderDetail::getId));
Map<String, SkuRemainder> grouped = new java.util.LinkedHashMap<>();
for (LastlegOrderDetail detail : details) {
String skuCode = trim(detail.getSkuCode());
long packages = value(detail.getPackageCount());
if (skuCode == null || packages <= 0) continue;
SkuRemainder existing = grouped.get(skuCode);
if (existing == null) {
grouped.put(skuCode, new SkuRemainder(skuCode, trim(detail.getProductNameZh()),
trim(detail.getProductNameEn()), packages));
} else {
existing.remaining += packages;
}
}
long actualPackages = grouped.values().stream().mapToLong(value -> value.remaining).sum();
if (grouped.isEmpty() || actualPackages != expectedPackages) {
throw conflict("Inbound SKU package snapshots must exactly match the received package total");
}
return new java.util.ArrayList<>(grouped.values());
}
private static List<SkuSnapshot> consume(List<SkuRemainder> remainders, long packages) {
List<SkuSnapshot> result = new java.util.ArrayList<>();
long pending = packages;
for (SkuRemainder remainder : remainders) {
if (pending == 0) break;
if (remainder.remaining == 0) continue;
long accepted = Math.min(pending, remainder.remaining);
result.add(new SkuSnapshot(remainder.skuCode, remainder.productNameZh,
remainder.productNameEn, accepted));
remainder.remaining -= accepted;
pending -= accepted;
}
if (pending != 0) throw conflict("PDA received packages exceed the frozen SKU package snapshots");
return result;
}
private void activateDropship(String customerId, Long taskId, Map<Long, LastlegOrder> orderById,
List<InboundStockReceipt> stockReceipts) {
Map<Long, List<InboundStockReceipt>> receiptsByOrder = stockReceipts.stream()
@@ -190,4 +250,24 @@ public class InboundStockReceiptService {
private static ResponseStatusException conflict(String message) {
return new ResponseStatusException(HttpStatus.CONFLICT, message);
}
private static final class SkuRemainder {
private final String skuCode;
private final String productNameZh;
private final String productNameEn;
private long remaining;
private SkuRemainder(String skuCode, String productNameZh, String productNameEn, long remaining) {
this.skuCode = skuCode;
this.productNameZh = productNameZh;
this.productNameEn = productNameEn;
this.remaining = remaining;
}
}
private record SkuSnapshot(String skuCode, String productNameZh, String productNameEn, long packages) {
private static SkuSnapshot unspecified(long packages) {
return new SkuSnapshot("UNSPECIFIED", null, null, packages);
}
}
}
@@ -0,0 +1,54 @@
package com.baiyue.lastleg.inboundplan.pda.service;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventEnvelopeValidator;
import com.baiyue.lastleg.client.EventBridgeClient;
import com.baiyue.lastleg.entity.LastlegEventOutbox;
import com.baiyue.lastleg.repository.UnloadCompletedOutboxRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.UUID;
/** 至少一次投递海外卸柜完成事件;国内事件桥按 eventId 幂等接收。 */
@Component
@Slf4j
public class InboundUnloadCompletedOutboxRelay {
@Resource private UnloadCompletedOutboxRepository repository;
@Resource private EventBridgeClient client;
@Resource private ObjectMapper objectMapper;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
@Value("${baiyue.firstleg.outbox-relay.lease-seconds:60}") private long leaseSeconds;
@Value("${baiyue.firstleg.outbox-relay.retry-seconds:30}") private long retrySeconds;
private final String workerId = UUID.randomUUID().toString();
@Scheduled(fixedDelayString = "${baiyue.firstleg.outbox-relay.fixed-delay-ms:5000}")
public void relay() {
LocalDateTime now = LocalDateTime.now();
LocalDateTime expiredLeaseBefore = now.minusSeconds(Math.max(leaseSeconds, 10));
for (LastlegEventOutbox candidate : repository.pending(50, now, expiredLeaseBefore)) {
if (!repository.claim(candidate.getId(), workerId, now, expiredLeaseBefore)) continue;
LastlegEventOutbox row = repository.claimed(candidate.getId(), workerId);
if (row == null) continue;
try {
EventEnvelope event = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class);
EventEnvelopeValidator.requireValid(event);
var response = client.receive(internalToken, event);
if (response == null || !response.successful() || response.data() == null
|| !event.eventId().equals(response.data().eventId())) {
throw new IllegalStateException("国内事件桥未确认海外卸柜完成事件");
}
repository.published(row.getId(), workerId, LocalDateTime.now());
} catch (Exception exception) {
repository.retry(row.getId(), workerId, LocalDateTime.now().plusSeconds(Math.max(retrySeconds, 1)),
exception.getMessage());
log.warn("海外卸柜完成事件投递失败: eventKey={}, reason={}", row.getEventKey(), exception.getMessage());
}
}
}
}
@@ -2,7 +2,10 @@ package com.baiyue.lastleg.inboundplan.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanSummary;
import com.baiyue.lastleg.inboundplan.dto.SupplementPalletRequest;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
@@ -36,6 +39,19 @@ public class InboundPlanService {
@Resource private InboundPlanPalletAllocationMapper allocationMapper;
@Resource private InboundPlanOrderMapper orderMapper;
@Transactional(readOnly = true)
public PageResult<InboundPlanSummary> page(String customerId, InboundPlanRequests.Query query) {
if (query.createdFrom() != null && query.createdTo() != null
&& query.createdFrom().isAfter(query.createdTo())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "createdFrom must not be after createdTo");
}
long total = planMapper.countRows(customerId, query);
List<InboundPlanSummary> records = total == 0 ? List.of()
: planMapper.pageRows(customerId, query, Math.multiplyExact(query.page() - 1, query.size()), query.size());
long pages = total == 0 ? 0 : (total + query.size() - 1) / query.size();
return new PageResult<>(records, query.page(), query.size(), total, pages);
}
@Transactional
public InboundPlanResponse convert(String customerId, Long selectedOrderId) {
List<SourceOrderSnapshot> orders = orderMapper.lockOrder(customerId, selectedOrderId);
@@ -5,12 +5,14 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.security.OverseasPermissions;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -21,12 +23,13 @@ import org.springframework.web.server.ResponseStatusException;
@RequestMapping("/api/platform/v1/last-leg/inventory")
@Tag(name = "Tail-leg inbound-order package inventory commands")
public class InventoryCommandController {
public static final String MANAGE_PERMISSION = "lastleg:inventory:manage";
public static final String ADJUST_PERMISSION = "lastleg:inventory:adjust";
public static final String MANAGE_PERMISSION = OverseasPermissions.INVENTORY_EDIT;
public static final String ADJUST_PERMISSION = OverseasPermissions.INVENTORY_ADJUST;
@Resource private InventoryCommandService service;
@PostMapping("/hold")
@Operation(summary = "Move available inbound-order packages to hold")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_EDIT + "')")
@Operation(summary = "Move available inbound-order packages to hold", description = "Required authority: " + OverseasPermissions.INVENTORY_EDIT)
public ApiResponse<InventoryCommandResponse> hold(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Move request) {
requirePermission(user, MANAGE_PERMISSION);
@@ -34,47 +37,17 @@ public class InventoryCommandController {
}
@PostMapping("/release")
@Operation(summary = "Release held inbound-order packages")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_EDIT + "')")
@Operation(summary = "Release held inbound-order packages", description = "Required authority: " + OverseasPermissions.INVENTORY_EDIT)
public ApiResponse<InventoryCommandResponse> release(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Move request) {
requirePermission(user, MANAGE_PERMISSION);
return ApiResponse.success(service.release(scope(user, request.customerId()), operator(user), request));
}
@PostMapping("/allocate")
@Operation(summary = "Allocate available inbound-order packages to an outbound order")
public ApiResponse<InventoryCommandResponse> allocate(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Move request) {
requirePermission(user, MANAGE_PERMISSION);
return ApiResponse.success(service.allocate(scope(user, request.customerId()), operator(user), request));
}
@PostMapping("/unallocate")
@Operation(summary = "Cancel an outbound allocation")
public ApiResponse<InventoryCommandResponse> unallocate(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Move request) {
requirePermission(user, MANAGE_PERMISSION);
return ApiResponse.success(service.unallocate(scope(user, request.customerId()), operator(user), request));
}
@PostMapping("/pick")
@Operation(summary = "Confirm picked inbound-order packages")
public ApiResponse<InventoryCommandResponse> pick(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Move request) {
requirePermission(user, MANAGE_PERMISSION);
return ApiResponse.success(service.pick(scope(user, request.customerId()), operator(user), request));
}
@PostMapping("/ship")
@Operation(summary = "Ship picked inbound-order packages and report whether the pallet is empty")
public ApiResponse<InventoryCommandResponse> ship(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Move request) {
requirePermission(user, MANAGE_PERMISSION);
return ApiResponse.success(service.ship(scope(user, request.customerId()), operator(user), request));
}
@PostMapping("/adjust")
@Operation(summary = "Adjust inbound-order package inventory after a physical count")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_ADJUST + "')")
@Operation(summary = "Adjust inbound-order package inventory after a physical count", description = "Required authority: " + OverseasPermissions.INVENTORY_ADJUST)
public ApiResponse<InventoryCommandResponse> adjust(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody InventoryCommandRequests.Adjustment request) {
requirePermission(user, ADJUST_PERMISSION);
@@ -22,6 +22,9 @@ public final class InventoryCommandRequests {
@NotNull Long inboundOrderId,
@NotBlank String inboundOrderNo,
@NotBlank String inboundCode,
String skuCode,
String productNameZh,
String productNameEn,
@NotNull Long palletId,
@NotBlank String palletNo,
String batchNo,
@@ -175,8 +175,9 @@ public class InventoryCommandService {
}
private InventoryBalance balance(String customerId, InventoryCommandRequests.StockIn request) {
String skuCode = sku(request.skuCode());
InventoryBalance balance = balances.lockDimension(customerId, request.overseasWarehouseId(),
request.destinationWarehouseCode(), request.inboundOrderId(), request.inboundCode());
request.destinationWarehouseCode(), request.inboundOrderId(), request.inboundCode(), skuCode);
if (balance != null) return balance;
balance = new InventoryBalance();
balance.setId(IdWorker.getId());
@@ -191,6 +192,9 @@ public class InventoryCommandService {
balance.setInboundOrderId(request.inboundOrderId());
balance.setInboundOrderNo(request.inboundOrderNo().trim());
balance.setInboundCode(request.inboundCode().trim());
balance.setSkuCode(skuCode);
balance.setProductNameZh(trim(request.productNameZh()));
balance.setProductNameEn(trim(request.productNameEn()));
zero(balance);
balance.setVersion(0L);
balance.setCreatedAt(LocalDateTime.now());
@@ -200,7 +204,7 @@ public class InventoryCommandService {
return balance;
} catch (DuplicateKeyException exception) {
InventoryBalance concurrent = balances.lockDimension(customerId, request.overseasWarehouseId(),
request.destinationWarehouseCode(), request.inboundOrderId(), request.inboundCode());
request.destinationWarehouseCode(), request.inboundOrderId(), request.inboundCode(), skuCode);
if (concurrent != null) return concurrent;
throw exception;
}
@@ -314,6 +318,7 @@ public class InventoryCommandService {
ledger.setPalletNo(position.getPalletNo());
ledger.setBatchNo(position.getBatchNo());
ledger.setWarehouseLocationCode(position.getWarehouseLocationCode());
ledger.setSkuCode(balance.getSkuCode());
ledger.setBusinessType(businessType);
ledger.setBusinessOrderId(balance.getInboundOrderId());
ledger.setBusinessOrderNo(trim(businessOrderNo));
@@ -434,6 +439,11 @@ public class InventoryCommandService {
return value == null || value.isBlank() ? null : value.trim();
}
private static String sku(String value) {
String normalized = trim(value);
return normalized == null ? "UNSPECIFIED" : normalized;
}
private static String remarkSuffix(String remark) {
String value = trim(remark);
return value == null ? "" : "; " + value;
@@ -24,6 +24,9 @@ public class InventoryBalance {
private Long inboundOrderId;
private String inboundOrderNo;
private String inboundCode;
private String skuCode;
private String productNameZh;
private String productNameEn;
private Long availablePackages;
private Long holdPackages;
private Long allocatedPackages;
@@ -12,22 +12,24 @@ public interface InventoryBalanceMapper extends BaseMapper<InventoryBalance> {
@Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} " +
"AND overseas_warehouse_id=#{overseasWarehouseId} " +
"AND destination_warehouse_code=#{destinationWarehouseCode} " +
"AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode} LIMIT 1")
"AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode} AND sku_code=#{skuCode} LIMIT 1")
InventoryBalance findDimension(@Param("customerId") String customerId,
@Param("overseasWarehouseId") String overseasWarehouseId,
@Param("destinationWarehouseCode") String destinationWarehouseCode,
@Param("inboundOrderId") Long inboundOrderId,
@Param("inboundCode") String inboundCode);
@Param("inboundCode") String inboundCode,
@Param("skuCode") String skuCode);
@Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} " +
"AND overseas_warehouse_id=#{overseasWarehouseId} " +
"AND destination_warehouse_code=#{destinationWarehouseCode} " +
"AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode} LIMIT 1 FOR UPDATE")
"AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode} AND sku_code=#{skuCode} LIMIT 1 FOR UPDATE")
InventoryBalance lockDimension(@Param("customerId") String customerId,
@Param("overseasWarehouseId") String overseasWarehouseId,
@Param("destinationWarehouseCode") String destinationWarehouseCode,
@Param("inboundOrderId") Long inboundOrderId,
@Param("inboundCode") String inboundCode);
@Param("inboundCode") String inboundCode,
@Param("skuCode") String skuCode);
@Select("<script>SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId}" +
"<if test='overseasWarehouseId != null and overseasWarehouseId != \"\"'> AND overseas_warehouse_id=#{overseasWarehouseId}</if>" +
@@ -9,6 +9,7 @@ import com.baiyue.lastleg.inventory.query.dto.InventoryLedgerView;
import com.baiyue.lastleg.inventory.query.dto.InventoryPositionView;
import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests;
import com.baiyue.lastleg.inventory.query.service.InventoryQueryService;
import com.baiyue.lastleg.security.OverseasPermissions;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
@@ -18,6 +19,7 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
@@ -35,7 +37,8 @@ public class InventoryQueryController {
@Resource private InventoryQueryService service;
@GetMapping(BASE_PATH + "/summary")
@Operation(summary = "Get inbound-order package inventory totals")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_LIST + "')")
@Operation(summary = "Get inbound-order package inventory totals", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST)
public ApiResponse<InventoryDashboardResponse> summary(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@@ -44,7 +47,8 @@ public class InventoryQueryController {
}
@GetMapping(BASE_PATH + "/balances")
@Operation(summary = "Page inventory by customer, warehouse and inbound order")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_LIST + "')")
@Operation(summary = "Page inventory by customer, warehouse and inbound order", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST)
public ApiResponse<PageResult<InventoryBalanceView>> balances(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@@ -53,7 +57,8 @@ public class InventoryQueryController {
}
@GetMapping(BASE_PATH + "/balances/{balanceId}/positions")
@Operation(summary = "List pallet and batch positions for one inbound-order balance")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_LIST + "')")
@Operation(summary = "List pallet and batch positions for one inbound-order balance", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST)
public ApiResponse<List<InventoryPositionView>> positions(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@@ -63,7 +68,8 @@ public class InventoryQueryController {
}
@GetMapping(BASE_PATH + "/ledger")
@Operation(summary = "Page append-only inventory movements")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_LIST + "')")
@Operation(summary = "Page append-only inventory movements", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST)
public ApiResponse<PageResult<InventoryLedgerView>> ledgers(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@@ -72,7 +78,8 @@ public class InventoryQueryController {
}
@GetMapping(value = BASE_PATH + "/balances/export", produces = "text/csv")
@Operation(summary = "Export filtered inbound-order inventory as UTF-8 CSV")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_EXPORT + "')")
@Operation(summary = "Export filtered inbound-order inventory as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.INVENTORY_EXPORT)
public ResponseEntity<byte[]> exportBalances(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@@ -81,7 +88,8 @@ public class InventoryQueryController {
}
@GetMapping(value = BASE_PATH + "/ledger/export", produces = "text/csv")
@Operation(summary = "Export filtered inventory movements as UTF-8 CSV")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_EXPORT + "')")
@Operation(summary = "Export filtered inventory movements as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.INVENTORY_EXPORT)
public ResponseEntity<byte[]> exportLedgers(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@@ -15,6 +15,9 @@ public record InventoryBalanceView(
Long inboundOrderId,
String inboundOrderNo,
String inboundCode,
String skuCode,
String productNameZh,
String productNameEn,
long totalPackages,
long availablePackages,
long holdPackages,
@@ -19,6 +19,8 @@ public final class InventoryQueryRequests {
String inboundOrderNo,
String inboundCode,
String businessOrderNo,
String skuCode,
String productName,
String inventoryStatus
) {
public BalanceQuery {
@@ -19,6 +19,8 @@ public interface InventoryQueryMapper {
"<if test='q.inboundOrderNo != null and q.inboundOrderNo != \"\"'> AND inbound_order_no LIKE CONCAT('%',#{q.inboundOrderNo},'%')</if>" +
"<if test='q.inboundCode != null and q.inboundCode != \"\"'> AND inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')</if>" +
"<if test='q.businessOrderNo != null and q.businessOrderNo != \"\"'> AND EXISTS (SELECT 1 FROM ll_inventory_ledger l WHERE l.balance_id=ll_inventory_balance.id AND l.customer_id=#{customerId} AND l.business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%'))</if>" +
"<if test='q.skuCode != null and q.skuCode != \"\"'> AND sku_code LIKE CONCAT('%',#{q.skuCode},'%')</if>" +
"<if test='q.productName != null and q.productName != \"\"'> AND (product_name_zh LIKE CONCAT('%',#{q.productName},'%') OR product_name_en LIKE CONCAT('%',#{q.productName},'%'))</if>" +
"<choose>" +
"<when test='q.inventoryStatus == \"AVAILABLE\"'> AND available_packages &gt; 0</when>" +
"<when test='q.inventoryStatus == \"HOLD\"'> AND hold_packages &gt; 0</when>" +
@@ -37,6 +39,8 @@ public interface InventoryQueryMapper {
"<if test='q.inboundOrderNo != null and q.inboundOrderNo != \"\"'> AND b.inbound_order_no LIKE CONCAT('%',#{q.inboundOrderNo},'%')</if>" +
"<if test='q.inboundCode != null and q.inboundCode != \"\"'> AND b.inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')</if>" +
"<if test='q.businessOrderNo != null and q.businessOrderNo != \"\"'> AND EXISTS (SELECT 1 FROM ll_inventory_ledger l WHERE l.balance_id=b.id AND l.customer_id=#{customerId} AND l.business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%'))</if>" +
"<if test='q.skuCode != null and q.skuCode != \"\"'> AND b.sku_code LIKE CONCAT('%',#{q.skuCode},'%')</if>" +
"<if test='q.productName != null and q.productName != \"\"'> AND (b.product_name_zh LIKE CONCAT('%',#{q.productName},'%') OR b.product_name_en LIKE CONCAT('%',#{q.productName},'%'))</if>" +
"<choose>" +
"<when test='q.inventoryStatus == \"AVAILABLE\"'> AND b.available_packages &gt; 0</when>" +
"<when test='q.inventoryStatus == \"HOLD\"'> AND b.hold_packages &gt; 0</when>" +
@@ -78,12 +78,12 @@ public class InventoryQueryService {
validateStatus(query.inventoryStatus(), BALANCE_STATUSES);
List<String> rows = new ArrayList<>();
rows.add("customerId,customerName,overseasWarehouseCode,overseasWarehouseName,destinationWarehouseCode," +
"destinationWarehouseName,inboundOrderId,inboundOrderNo,inboundCode,totalPackages,availablePackages," +
"destinationWarehouseName,inboundOrderId,inboundOrderNo,inboundCode,skuCode,productNameZh,productNameEn,totalPackages,availablePackages," +
"holdPackages,allocatedPackages,pickedPackages,inTransitPackages,receivingPackages,procurementPackages");
mapper.balances(customerId, query, 0, EXPORT_LIMIT).stream().map(this::balanceView).forEach(v -> rows.add(
String.join(",", csv(v.customerId()), csv(v.customerName()), csv(v.overseasWarehouseCode()), csv(v.overseasWarehouseName()),
csv(v.destinationWarehouseCode()), csv(v.destinationWarehouseName()), csv(v.inboundOrderId()),
csv(v.inboundOrderNo()), csv(v.inboundCode()),
csv(v.inboundOrderNo()), csv(v.inboundCode()), csv(v.skuCode()), csv(v.productNameZh()), csv(v.productNameEn()),
csv(v.totalPackages()), csv(v.availablePackages()), csv(v.holdPackages()), csv(v.allocatedPackages()),
csv(v.pickedPackages()), csv(v.inTransitPackages()), csv(v.receivingPackages()),
csv(v.procurementPackages()))));
@@ -116,7 +116,8 @@ public class InventoryQueryService {
return new InventoryBalanceView(value.getId(), value.getCustomerId(), value.getCustomerName(), value.getOverseasWarehouseId(),
value.getOverseasWarehouseCode(), value.getOverseasWarehouseName(), value.getDestinationWarehouseId(),
value.getDestinationWarehouseCode(), value.getDestinationWarehouseName(), value.getInboundOrderId(),
value.getInboundOrderNo(), value.getInboundCode(), availablePackages + holdPackages + allocatedPackages + pickedPackages
value.getInboundOrderNo(), value.getInboundCode(), value.getSkuCode(), value.getProductNameZh(), value.getProductNameEn(),
availablePackages + holdPackages + allocatedPackages + pickedPackages
+ inTransitPackages + receivingPackages + procurementPackages,
availablePackages, holdPackages, allocatedPackages, pickedPackages, inTransitPackages,
receivingPackages, procurementPackages, value.getUpdatedAt());
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.looseboard;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan("com.baiyue.lastleg.looseboard.mapper")
public class LooseboardMapperConfiguration {
}
@@ -0,0 +1,97 @@
package com.baiyue.lastleg.looseboard.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.looseboard.dto.LocalLooseboardRequests;
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardResponses;
import com.baiyue.lastleg.looseboard.service.LocalLooseboardService;
import com.baiyue.lastleg.security.OverseasPermissions;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.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.PutMapping;
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/local-looseboard-orders")
public class PlatformLocalLooseboardController {
@Resource private LocalLooseboardService service;
@GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.LOOSEBOARD_LIST + "')")
public ApiResponse<PageResult<LocalLooseboardResponses.Summary>> page(
@AuthenticationPrincipal AuthenticatedUser user, @Valid LocalLooseboardRequests.Query query) {
requirePlatform(user);
return ApiResponse.success(service.page(query));
}
@PostMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.LOOSEBOARD_CREATE + "')")
public ApiResponse<LocalLooseboardResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String customerId, @Valid @RequestBody LocalLooseboardRequests.Create request) {
requirePermission(user, OverseasPermissions.LOOSEBOARD_CREATE);
return ApiResponse.success(service.create(requiredCustomer(customerId), operator(user), request));
}
@GetMapping("/{orderNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LOOSEBOARD_LIST + "')")
public ApiResponse<LocalLooseboardResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String customerId, @PathVariable String orderNo) {
requirePlatform(user);
return ApiResponse.success(service.detail(requiredCustomer(customerId), orderNo));
}
@PutMapping("/{orderNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LOOSEBOARD_EDIT + "')")
public ApiResponse<LocalLooseboardResponses.Detail> update(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String customerId, @PathVariable String orderNo,
@Valid @RequestBody LocalLooseboardRequests.Update request) {
requirePermission(user, OverseasPermissions.LOOSEBOARD_EDIT);
return ApiResponse.success(service.update(requiredCustomer(customerId), orderNo, operator(user), request));
}
@PatchMapping("/{orderNo}/status")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LOOSEBOARD_STATUS + "')")
public ApiResponse<LocalLooseboardResponses.Detail> changeStatus(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String customerId, @PathVariable String orderNo,
@Valid @RequestBody LocalLooseboardRequests.ChangeStatus request) {
requirePermission(user, OverseasPermissions.LOOSEBOARD_STATUS);
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, 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) {
if (customerId == null || customerId.isBlank() || "0".equals(customerId.trim())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Target customerId is required");
}
return customerId.trim();
}
private String operator(AuthenticatedUser user) {
return user.userId() == null ? user.username() : String.valueOf(user.userId());
}
}
@@ -0,0 +1,72 @@
package com.baiyue.lastleg.looseboard.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.Pattern;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDate;
public final class LocalLooseboardRequests {
private LocalLooseboardRequests() { }
public record Create(
@NotBlank @Pattern(regexp = "SHARED_TRUCK|PRIORITY_CONSOLIDATION") String transportMode,
@NotBlank @Pattern(regexp = "YES|NO") String palletRequired,
@NotBlank @Pattern(regexp = "PORT_PICKUP|WAREHOUSE_TO_WAREHOUSE|WAREHOUSE_TO_DOOR") String transportType,
@NotBlank @Size(max = 255) String origin,
@Size(max = 500) String originAddress,
@NotBlank @Size(max = 255) String destination,
@Size(max = 500) String destinationAddress,
@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,
@Size(max = 128) String contactName,
@Size(max = 128) String contactInfo,
LocalDate requestedDate,
@Size(max = 1000) String remark,
@NotBlank @Size(max = 255) String idempotencyKey) { }
public record Update(
@NotNull @Min(0) Long version,
@NotBlank @Pattern(regexp = "SHARED_TRUCK|PRIORITY_CONSOLIDATION") String transportMode,
@NotBlank @Pattern(regexp = "YES|NO") String palletRequired,
@NotBlank @Pattern(regexp = "PORT_PICKUP|WAREHOUSE_TO_WAREHOUSE|WAREHOUSE_TO_DOOR") String transportType,
@NotBlank @Size(max = 255) String origin,
@Size(max = 500) String originAddress,
@NotBlank @Size(max = 255) String destination,
@Size(max = 500) String destinationAddress,
@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,
@Size(max = 128) String contactName,
@Size(max = 128) String contactInfo,
LocalDate requestedDate,
@Size(max = 1000) String remark) { }
public record Query(
@Min(1) long page,
@Min(1) @Max(200) long size,
String customerId,
String orderNo,
String routeKeyword,
String status,
String transportMode,
LocalDate requestedFrom,
LocalDate requestedTo) { }
public record ChangeStatus(
@NotNull @Min(0) Long version,
@NotBlank @Size(max = 32) String status,
@Size(max = 1000) String remark) { }
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.looseboard.dto;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
public final class LocalLooseboardResponses {
private LocalLooseboardResponses() { }
public record Summary(
Long id, String customerId, String orderNo, String transportMode, String palletRequired,
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 operationType, String fromStatus, String toStatus, String operatorId,
String changeDetail, String remark, LocalDateTime createdAt) { }
public record Detail(
Summary summary, String originAddress, String destinationAddress, String remark,
String createdBy, List<History> histories) { }
}
@@ -1,4 +1,4 @@
package com.baiyue.lastleg.procurement.entity;
package com.baiyue.lastleg.looseboard.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
@@ -8,17 +8,16 @@ import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_overseas_procurement_outbound")
public class OverseasProcurementOutbound {
@TableName("ll_local_looseboard_history")
public class LocalLooseboardHistory {
@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 operationType;
private String fromStatus;
private String toStatus;
private String operatorId;
private String changeDetail;
private String remark;
private String createdBy;
private LocalDateTime createdAt;
}
@@ -0,0 +1,42 @@
package com.baiyue.lastleg.looseboard.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_local_looseboard_order")
public class LocalLooseboardOrder {
@TableId(type = IdType.INPUT) private Long id;
private String customerId;
private String orderNo;
private String transportMode;
private String palletRequired;
private String transportType;
private String origin;
private String originAddress;
private String destination;
private String destinationAddress;
private String containerType;
private Integer packageCount;
private BigDecimal totalWeightKg;
private BigDecimal totalVolumeCbm;
private BigDecimal budgetAmount;
private String currency;
private String contactName;
private String contactInfo;
private LocalDate requestedDate;
private String status;
private String remark;
private String idempotencyKey;
private String requestHash;
private String createdBy;
private Long version;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.looseboard.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardHistory;
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 LocalLooseboardHistoryMapper extends BaseMapper<LocalLooseboardHistory> {
@Select("SELECT * FROM ll_local_looseboard_history WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY created_at,id")
List<LocalLooseboardHistory> listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId);
}
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.looseboard.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface LocalLooseboardOrderMapper extends BaseMapper<LocalLooseboardOrder> {
@Select("SELECT * FROM ll_local_looseboard_order WHERE customer_id=#{customerId} AND order_no=#{orderNo} LIMIT 1")
LocalLooseboardOrder findNo(@Param("customerId") String customerId, @Param("orderNo") String orderNo);
@Select("SELECT * FROM ll_local_looseboard_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1")
LocalLooseboardOrder findKey(@Param("customerId") String customerId, @Param("key") String key);
@Select("SELECT * FROM ll_local_looseboard_order WHERE customer_id=#{customerId} AND order_no=#{orderNo} LIMIT 1 FOR UPDATE")
LocalLooseboardOrder lockNo(@Param("customerId") String customerId, @Param("orderNo") String orderNo);
}
@@ -0,0 +1,302 @@
package com.baiyue.lastleg.looseboard.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.looseboard.dto.LocalLooseboardRequests;
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardResponses;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardHistory;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardHistoryMapper;
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOrderMapper;
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.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
@Service
public class LocalLooseboardService {
public static final String PENDING_ACCEPTANCE = "PENDING_ACCEPTANCE";
public static final String DISPATCHED = "DISPATCHED";
public static final String IN_TRANSIT = "IN_TRANSIT";
public static final String COMPLETED = "COMPLETED";
public static final String CANCELLED = "CANCELLED";
private static final Set<String> STATUSES = Set.of(PENDING_ACCEPTANCE, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED);
private static final Set<String> MODES = Set.of("SHARED_TRUCK", "PRIORITY_CONSOLIDATION");
private static final Map<String, Set<String>> TRANSITIONS = Map.of(
PENDING_ACCEPTANCE, Set.of(DISPATCHED, CANCELLED),
DISPATCHED, Set.of(IN_TRANSIT, CANCELLED),
IN_TRANSIT, Set.of(COMPLETED));
@Resource private LocalLooseboardOrderMapper orders;
@Resource private LocalLooseboardHistoryMapper histories;
@Resource private ObjectMapper objectMapper;
@Transactional
public LocalLooseboardResponses.Detail create(String customerId, String operatorId,
LocalLooseboardRequests.Create request) {
String key = request.idempotencyKey().trim();
String requestHash = hash(request);
LocalLooseboardOrder replay = orders.findKey(customerId, key);
if (replay != null) return replay(customerId, replay, requestHash);
LocalDateTime now = LocalDateTime.now();
LocalLooseboardOrder order = new LocalLooseboardOrder();
order.setId(IdWorker.getId());
order.setCustomerId(customerId);
order.setOrderNo("LB" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + order.getId());
apply(order, request.transportMode(), request.palletRequired(), request.transportType(), request.origin(),
request.originAddress(), request.destination(), request.destinationAddress(), request.containerType(),
request.packageCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(),
request.currency(), request.contactName(), request.contactInfo(), request.requestedDate(), request.remark());
order.setStatus(PENDING_ACCEPTANCE);
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) {
LocalLooseboardOrder concurrent = orders.findKey(customerId, key);
if (concurrent != null) return replay(customerId, concurrent, requestHash);
throw exception;
}
history(order, "CREATE", null, PENDING_ACCEPTANCE, operatorId, null, request.remark(), now);
return detail(customerId, order.getOrderNo());
}
public PageResult<LocalLooseboardResponses.Summary> page(LocalLooseboardRequests.Query query) {
validateQuery(query);
LambdaQueryWrapper<LocalLooseboardOrder> wrapper = new LambdaQueryWrapper<LocalLooseboardOrder>()
.eq(StringUtils.hasText(query.customerId()), LocalLooseboardOrder::getCustomerId, trim(query.customerId()))
.like(StringUtils.hasText(query.orderNo()), LocalLooseboardOrder::getOrderNo, trim(query.orderNo()))
.and(StringUtils.hasText(query.routeKeyword()), value -> value
.like(LocalLooseboardOrder::getOrigin, trim(query.routeKeyword()))
.or().like(LocalLooseboardOrder::getDestination, trim(query.routeKeyword())))
.eq(StringUtils.hasText(query.status()), LocalLooseboardOrder::getStatus,
StringUtils.hasText(query.status()) ? norm(query.status()) : null)
.eq(StringUtils.hasText(query.transportMode()), LocalLooseboardOrder::getTransportMode,
StringUtils.hasText(query.transportMode()) ? norm(query.transportMode()) : null)
.ge(query.requestedFrom() != null, LocalLooseboardOrder::getRequestedDate, query.requestedFrom())
.le(query.requestedTo() != null, LocalLooseboardOrder::getRequestedDate, query.requestedTo())
.orderByDesc(LocalLooseboardOrder::getCreatedAt).orderByDesc(LocalLooseboardOrder::getId);
Page<LocalLooseboardOrder> result = orders.selectPage(Page.of(query.page(), query.size()), wrapper);
return new PageResult<>(result.getRecords().stream().map(this::summary).toList(), result.getCurrent(),
result.getSize(), result.getTotal(), result.getPages());
}
public LocalLooseboardResponses.Detail detail(String customerId, String orderNo) {
LocalLooseboardOrder order = orders.findNo(customerId, orderNo);
if (order == null) throw notFound();
return response(order);
}
@Transactional
public LocalLooseboardResponses.Detail update(String customerId, String orderNo, String operatorId,
LocalLooseboardRequests.Update request) {
LocalLooseboardOrder order = lock(customerId, orderNo);
if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry");
if (!PENDING_ACCEPTANCE.equals(order.getStatus())) {
throw conflict("Only pending-acceptance looseboard orders can be edited");
}
Map<String, Object> before = snapshot(order);
apply(order, request.transportMode(), request.palletRequired(), request.transportType(), request.origin(),
request.originAddress(), request.destination(), request.destinationAddress(), request.containerType(),
request.packageCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(),
request.currency(), request.contactName(), request.contactInfo(), request.requestedDate(), request.remark());
Map<String, Object> after = snapshot(order);
if (before.equals(after)) return response(order);
LocalDateTime now = LocalDateTime.now();
touch(order, now);
orders.updateById(order);
history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId,
changeDetail(before, after), null, now);
return response(order);
}
@Transactional
public LocalLooseboardResponses.Detail changeStatus(String customerId, String orderNo, String operatorId,
LocalLooseboardRequests.ChangeStatus request) {
LocalLooseboardOrder 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 looseboard order status");
String from = order.getStatus();
if (next.equals(from)) return response(order);
if (!TRANSITIONS.getOrDefault(from, Set.of()).contains(next)) {
throw conflict("Unsupported looseboard order transition: " + from + " -> " + next);
}
LocalDateTime now = LocalDateTime.now();
order.setStatus(next);
touch(order, now);
orders.updateById(order);
history(order, "STATUS", from, next, operatorId, null, request.remark(), now);
return response(order);
}
private void apply(LocalLooseboardOrder order, String mode, String palletRequired, String transportType,
String origin, String originAddress, String destination, String destinationAddress,
String containerType, Integer packageCount, java.math.BigDecimal totalWeightKg,
java.math.BigDecimal totalVolumeCbm, java.math.BigDecimal budgetAmount, String currency,
String contactName, String contactInfo, java.time.LocalDate requestedDate, String remark) {
order.setTransportMode(norm(mode));
order.setPalletRequired(norm(palletRequired));
order.setTransportType(norm(transportType));
order.setOrigin(origin.trim());
order.setOriginAddress(trim(originAddress));
order.setDestination(destination.trim());
order.setDestinationAddress(trim(destinationAddress));
order.setContainerType(trim(containerType));
order.setPackageCount(packageCount);
order.setTotalWeightKg(totalWeightKg);
order.setTotalVolumeCbm(totalVolumeCbm);
order.setBudgetAmount(budgetAmount);
order.setCurrency(StringUtils.hasText(currency) ? norm(currency) : "USD");
order.setContactName(trim(contactName));
order.setContactInfo(trim(contactInfo));
order.setRequestedDate(requestedDate);
order.setRemark(trim(remark));
}
private LocalLooseboardResponses.Detail replay(String customerId, LocalLooseboardOrder order, String hash) {
if (!hash.equals(order.getRequestHash())) throw conflict("Idempotency key was reused with different looseboard data");
return detail(customerId, order.getOrderNo());
}
private LocalLooseboardOrder lock(String customerId, String orderNo) {
LocalLooseboardOrder order = orders.lockNo(customerId, orderNo);
if (order == null) throw notFound();
return order;
}
private LocalLooseboardResponses.Detail response(LocalLooseboardOrder order) {
return new LocalLooseboardResponses.Detail(summary(order), order.getOriginAddress(),
order.getDestinationAddress(), order.getRemark(), order.getCreatedBy(),
histories.listOrder(order.getCustomerId(), order.getId()).stream()
.map(value -> new LocalLooseboardResponses.History(value.getOperationType(), value.getFromStatus(),
value.getToStatus(), value.getOperatorId(), value.getChangeDetail(), value.getRemark(),
value.getCreatedAt())).toList());
}
private LocalLooseboardResponses.Summary summary(LocalLooseboardOrder order) {
return new LocalLooseboardResponses.Summary(order.getId(), order.getCustomerId(), order.getOrderNo(),
order.getTransportMode(), order.getPalletRequired(), order.getTransportType(), order.getOrigin(),
order.getDestination(), order.getContainerType(), Objects.requireNonNullElse(order.getPackageCount(), 0),
order.getTotalWeightKg(), order.getTotalVolumeCbm(), order.getBudgetAmount(), order.getCurrency(),
order.getContactName(), order.getContactInfo(), order.getRequestedDate(), order.getStatus(),
value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt());
}
private void history(LocalLooseboardOrder order, String type, String from, String to, String operator,
String changeDetail, String remark, LocalDateTime now) {
LocalLooseboardHistory history = new LocalLooseboardHistory();
history.setId(IdWorker.getId());
history.setOrderId(order.getId());
history.setCustomerId(order.getCustomerId());
history.setOperationType(type);
history.setFromStatus(from);
history.setToStatus(to);
history.setOperatorId(operator);
history.setChangeDetail(changeDetail);
history.setRemark(trim(remark));
history.setCreatedAt(now);
histories.insert(history);
}
private void validateQuery(LocalLooseboardRequests.Query query) {
if (StringUtils.hasText(query.status()) && !STATUSES.contains(norm(query.status()))) {
throw badRequest("Unsupported looseboard order status");
}
if (StringUtils.hasText(query.transportMode()) && !MODES.contains(norm(query.transportMode()))) {
throw badRequest("Unsupported looseboard transport mode");
}
if (query.requestedFrom() != null && query.requestedTo() != null
&& query.requestedFrom().isAfter(query.requestedTo())) {
throw badRequest("requestedFrom must not be after requestedTo");
}
}
private Map<String, Object> snapshot(LocalLooseboardOrder order) {
Map<String, Object> value = new LinkedHashMap<>();
value.put("transportMode", order.getTransportMode());
value.put("palletRequired", order.getPalletRequired());
value.put("transportType", order.getTransportType());
value.put("origin", order.getOrigin());
value.put("originAddress", order.getOriginAddress());
value.put("destination", order.getDestination());
value.put("destinationAddress", order.getDestinationAddress());
value.put("containerType", order.getContainerType());
value.put("packageCount", order.getPackageCount());
value.put("totalWeightKg", order.getTotalWeightKg());
value.put("totalVolumeCbm", order.getTotalVolumeCbm());
value.put("budgetAmount", order.getBudgetAmount());
value.put("currency", order.getCurrency());
value.put("contactName", order.getContactName());
value.put("contactInfo", order.getContactInfo());
value.put("requestedDate", order.getRequestedDate());
value.put("remark", order.getRemark());
return value;
}
private String changeDetail(Map<String, Object> before, Map<String, Object> after) {
Map<String, Object> changes = new LinkedHashMap<>();
before.forEach((field, oldValue) -> {
Object newValue = after.get(field);
if (!Objects.equals(oldValue, newValue)) {
Map<String, Object> pair = new LinkedHashMap<>();
pair.put("before", oldValue);
pair.put("after", newValue);
changes.put(field, pair);
}
});
try {
return objectMapper.writeValueAsString(changes);
} catch (JsonProcessingException exception) {
throw new IllegalStateException("Cannot serialize looseboard change detail", exception);
}
}
private String hash(Object value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(objectMapper.writeValueAsBytes(value)));
} catch (JsonProcessingException | NoSuchAlgorithmException exception) {
throw new IllegalStateException("Cannot hash looseboard request", exception);
}
}
private static void touch(LocalLooseboardOrder 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 norm(String value) { return value.trim().toUpperCase(); }
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() {
return new ResponseStatusException(HttpStatus.NOT_FOUND, "Local looseboard order not found");
}
}
@@ -0,0 +1,58 @@
package com.baiyue.lastleg.masterdata.exchange.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.masterdata.exchange.dto.MonthlyExchangeRateDtos;
import com.baiyue.lastleg.masterdata.exchange.service.MonthlyExchangeRateService;
import com.baiyue.lastleg.security.OverseasPermissions;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.nio.charset.StandardCharsets;
@RestController
@RequestMapping("/api/platform/v1/last-leg/master-data/exchange-rates")
@Tag(name = "Monthly exchange rates / 月度汇率")
public class PlatformMonthlyExchangeRateController {
@Resource private MonthlyExchangeRateService service;
@GetMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCHANGE_RATE_LIST + "')")
@Operation(summary = "Page monthly exchange rates", description = "Required authority: platform:overseas-master-data:exchange-rate:list")
public ApiResponse<PageResult<MonthlyExchangeRateDtos.Response>> page(@Valid MonthlyExchangeRateDtos.Query query) {
return ApiResponse.success(service.page(query));
}
@GetMapping("/summary") @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCHANGE_RATE_LIST + "')")
public ApiResponse<MonthlyExchangeRateDtos.Summary> summary() { return ApiResponse.success(service.summary()); }
@GetMapping("/{month}") @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCHANGE_RATE_LIST + "')")
public ApiResponse<MonthlyExchangeRateDtos.Response> detail(@PathVariable String month) { return ApiResponse.success(service.detail(month)); }
@PostMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCHANGE_RATE_CREATE + "')")
public ApiResponse<MonthlyExchangeRateDtos.Response> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody MonthlyExchangeRateDtos.Create request) { return ApiResponse.success(service.create(operator(user), request)); }
@PutMapping("/{month}") @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCHANGE_RATE_EDIT + "')")
public ApiResponse<MonthlyExchangeRateDtos.Response> update(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String month, @Valid @RequestBody MonthlyExchangeRateDtos.Update request) {
return ApiResponse.success(service.update(month, operator(user), request));
}
@PatchMapping("/{month}/status") @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCHANGE_RATE_STATUS + "')")
public ApiResponse<MonthlyExchangeRateDtos.Response> status(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String month, @Valid @RequestBody MonthlyExchangeRateDtos.ChangeStatus request) {
return ApiResponse.success(service.changeStatus(month, operator(user), request));
}
@GetMapping(value = "/export", produces = "text/csv")
@PreAuthorize("hasAuthority('" + OverseasPermissions.EXCHANGE_RATE_EXPORT + "')")
public ResponseEntity<byte[]> export(@Valid MonthlyExchangeRateDtos.Query query) {
return ResponseEntity.ok().contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=monthly-exchange-rates.csv")
.body(service.exportCsv(query));
}
private String operator(AuthenticatedUser user) { return user == null ? "UNKNOWN" : user.userId() == null ? user.username() : String.valueOf(user.userId()); }
}
@@ -0,0 +1,36 @@
package com.baiyue.lastleg.masterdata.exchange.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.Pattern;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDateTime;
public final class MonthlyExchangeRateDtos {
private MonthlyExchangeRateDtos() { }
public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size,
@Pattern(regexp = "\\d{4}-(0[1-9]|1[0-2])") String monthFrom,
@Pattern(regexp = "\\d{4}-(0[1-9]|1[0-2])") String monthTo,
String status) {
public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; }
}
public record Create(@NotBlank @Pattern(regexp = "\\d{4}-(0[1-9]|1[0-2])") String month,
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal usdToCnyAverage,
@Size(max = 1000) String remark) { }
public record Update(@NotNull @Min(0) Long recordVersion,
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal usdToCnyAverage,
@Size(max = 1000) String remark) { }
public record ChangeStatus(@NotBlank String status, @NotNull @Min(0) Long recordVersion,
@Size(max = 500) String remark) { }
public record Response(String month, String baseCurrency, String quoteCurrency,
BigDecimal usdToCnyAverage, String status, long recordVersion,
String updatedBy, LocalDateTime updatedAt, String remark) { }
public record Summary(String latestMonth, BigDecimal latestUsdToCnyAverage,
String updatedBy, LocalDateTime updatedAt) { }
}
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.masterdata.exchange.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_monthly_exchange_rate")
public class MonthlyExchangeRate {
@TableId(type = IdType.INPUT) private Long id;
private String rateMonth;
private String baseCurrency;
private String quoteCurrency;
private BigDecimal usdToCnyAverage;
private String status;
private String remark;
private Long recordVersion;
private String createdBy;
private String updatedBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.masterdata.exchange.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_monthly_exchange_rate_history")
public class MonthlyExchangeRateHistory {
@TableId(type = IdType.INPUT) private Long id;
private Long exchangeRateId;
private BigDecimal beforeRate;
private BigDecimal afterRate;
private String beforeStatus;
private String afterStatus;
private String operatorId;
private String remark;
private LocalDateTime createdAt;
}
@@ -0,0 +1,8 @@
package com.baiyue.lastleg.masterdata.exchange.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.masterdata.exchange.entity.MonthlyExchangeRateHistory;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface MonthlyExchangeRateHistoryMapper extends BaseMapper<MonthlyExchangeRateHistory> { }
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.masterdata.exchange.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.masterdata.exchange.entity.MonthlyExchangeRate;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface MonthlyExchangeRateMapper extends BaseMapper<MonthlyExchangeRate> {
@Select("SELECT * FROM ll_monthly_exchange_rate WHERE rate_month=#{month} LIMIT 1")
MonthlyExchangeRate findMonth(@Param("month") String month);
@Select("SELECT * FROM ll_monthly_exchange_rate WHERE rate_month=#{month} LIMIT 1 FOR UPDATE")
MonthlyExchangeRate lockMonth(@Param("month") String month);
@Select("SELECT * FROM ll_monthly_exchange_rate WHERE status='ACTIVE' ORDER BY rate_month DESC LIMIT 1")
MonthlyExchangeRate latestActive();
}
@@ -0,0 +1,156 @@
package com.baiyue.lastleg.masterdata.exchange.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
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.masterdata.exchange.dto.MonthlyExchangeRateDtos;
import com.baiyue.lastleg.masterdata.exchange.entity.MonthlyExchangeRate;
import com.baiyue.lastleg.masterdata.exchange.entity.MonthlyExchangeRateHistory;
import com.baiyue.lastleg.masterdata.exchange.mapper.MonthlyExchangeRateHistoryMapper;
import com.baiyue.lastleg.masterdata.exchange.mapper.MonthlyExchangeRateMapper;
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.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.Locale;
@Service
public class MonthlyExchangeRateService {
private static final String ACTIVE = "ACTIVE";
private static final String INACTIVE = "INACTIVE";
@Resource private MonthlyExchangeRateMapper rates;
@Resource private MonthlyExchangeRateHistoryMapper histories;
public PageResult<MonthlyExchangeRateDtos.Response> page(MonthlyExchangeRateDtos.Query query) {
validateRange(query.monthFrom(), query.monthTo());
String status = optionalStatus(query.status());
Page<MonthlyExchangeRate> page = rates.selectPage(Page.of(query.page(), query.size()),
new LambdaQueryWrapper<MonthlyExchangeRate>()
.ge(StringUtils.hasText(query.monthFrom()), MonthlyExchangeRate::getRateMonth, query.monthFrom())
.le(StringUtils.hasText(query.monthTo()), MonthlyExchangeRate::getRateMonth, query.monthTo())
.eq(status != null, MonthlyExchangeRate::getStatus, status)
.orderByDesc(MonthlyExchangeRate::getRateMonth));
return new PageResult<>(page.getRecords().stream().map(this::response).toList(), page.getCurrent(),
page.getSize(), page.getTotal(), page.getPages());
}
public MonthlyExchangeRateDtos.Response detail(String month) { return response(required(month)); }
public MonthlyExchangeRateDtos.Summary summary() {
MonthlyExchangeRate value = rates.latestActive();
return value == null ? new MonthlyExchangeRateDtos.Summary(null, null, null, null)
: new MonthlyExchangeRateDtos.Summary(value.getRateMonth(), value.getUsdToCnyAverage(),
value.getUpdatedBy(), value.getUpdatedAt());
}
@Transactional
public MonthlyExchangeRateDtos.Response create(String operator, MonthlyExchangeRateDtos.Create request) {
String month = month(request.month()); LocalDateTime now = LocalDateTime.now();
MonthlyExchangeRate value = new MonthlyExchangeRate(); value.setId(IdWorker.getId());
value.setRateMonth(month); value.setBaseCurrency("USD"); value.setQuoteCurrency("CNY");
value.setUsdToCnyAverage(request.usdToCnyAverage()); value.setStatus(ACTIVE);
value.setRemark(trim(request.remark())); value.setRecordVersion(0L);
value.setCreatedBy(operator); value.setUpdatedBy(operator); value.setCreatedAt(now); value.setUpdatedAt(now);
try { rates.insert(value); }
catch (DuplicateKeyException exception) { throw conflict("该月份汇率已存在"); }
history(value.getId(), null, value.getUsdToCnyAverage(), null, ACTIVE, operator, request.remark());
return detail(month);
}
@Transactional
public MonthlyExchangeRateDtos.Response update(String month, String operator,
MonthlyExchangeRateDtos.Update request) {
MonthlyExchangeRate current = locked(month); requireVersion(current, request.recordVersion());
int updated = rates.update(null, new LambdaUpdateWrapper<MonthlyExchangeRate>()
.eq(MonthlyExchangeRate::getId, current.getId())
.eq(MonthlyExchangeRate::getRecordVersion, request.recordVersion())
.set(MonthlyExchangeRate::getUsdToCnyAverage, request.usdToCnyAverage())
.set(MonthlyExchangeRate::getRemark, trim(request.remark()))
.set(MonthlyExchangeRate::getUpdatedBy, operator).set(MonthlyExchangeRate::getUpdatedAt, LocalDateTime.now())
.setSql("record_version = record_version + 1"));
if (updated != 1) throw conflict("汇率已被其他操作修改,请刷新后重试");
history(current.getId(), current.getUsdToCnyAverage(), request.usdToCnyAverage(),
current.getStatus(), current.getStatus(), operator, request.remark());
return detail(current.getRateMonth());
}
@Transactional
public MonthlyExchangeRateDtos.Response changeStatus(String month, String operator,
MonthlyExchangeRateDtos.ChangeStatus request) {
MonthlyExchangeRate current = locked(month); requireVersion(current, request.recordVersion());
String status = status(request.status());
if (status.equals(current.getStatus())) return response(current);
int updated = rates.update(null, new LambdaUpdateWrapper<MonthlyExchangeRate>()
.eq(MonthlyExchangeRate::getId, current.getId())
.eq(MonthlyExchangeRate::getRecordVersion, request.recordVersion())
.set(MonthlyExchangeRate::getStatus, status).set(MonthlyExchangeRate::getUpdatedBy, operator)
.set(MonthlyExchangeRate::getUpdatedAt, LocalDateTime.now()).setSql("record_version = record_version + 1"));
if (updated != 1) throw conflict("汇率已被其他操作修改,请刷新后重试");
history(current.getId(), current.getUsdToCnyAverage(), current.getUsdToCnyAverage(),
current.getStatus(), status, operator, request.remark());
return detail(current.getRateMonth());
}
public byte[] exportCsv(MonthlyExchangeRateDtos.Query query) {
validateRange(query.monthFrom(), query.monthTo());
String status = optionalStatus(query.status());
var rows = rates.selectList(new LambdaQueryWrapper<MonthlyExchangeRate>()
.ge(StringUtils.hasText(query.monthFrom()), MonthlyExchangeRate::getRateMonth, query.monthFrom())
.le(StringUtils.hasText(query.monthTo()), MonthlyExchangeRate::getRateMonth, query.monthTo())
.eq(status != null, MonthlyExchangeRate::getStatus, status)
.orderByDesc(MonthlyExchangeRate::getRateMonth)).stream().map(this::response).toList();
StringBuilder csv = new StringBuilder("\uFEFF月份,基准币种,报价币种,月平均汇率,状态,更新人,更新时间\r\n");
for (var row : rows) csv.append(row.month()).append(',').append(row.baseCurrency()).append(',')
.append(row.quoteCurrency()).append(',').append(row.usdToCnyAverage()).append(',')
.append(row.status()).append(',').append(cell(row.updatedBy())).append(',').append(row.updatedAt()).append("\r\n");
return csv.toString().getBytes(StandardCharsets.UTF_8);
}
private void history(long id, java.math.BigDecimal beforeRate, java.math.BigDecimal afterRate,
String beforeStatus, String afterStatus, String operator, String remark) {
MonthlyExchangeRateHistory value = new MonthlyExchangeRateHistory(); value.setId(IdWorker.getId());
value.setExchangeRateId(id); value.setBeforeRate(beforeRate); value.setAfterRate(afterRate);
value.setBeforeStatus(beforeStatus); value.setAfterStatus(afterStatus); value.setOperatorId(operator);
value.setRemark(trim(remark)); value.setCreatedAt(LocalDateTime.now()); histories.insert(value);
}
private MonthlyExchangeRate required(String month) {
MonthlyExchangeRate value = rates.findMonth(month(month));
if (value == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "月度汇率不存在"); return value;
}
private MonthlyExchangeRate locked(String month) {
MonthlyExchangeRate value = rates.lockMonth(month(month));
if (value == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "月度汇率不存在"); return value;
}
private MonthlyExchangeRateDtos.Response response(MonthlyExchangeRate value) {
return new MonthlyExchangeRateDtos.Response(value.getRateMonth(), value.getBaseCurrency(),
value.getQuoteCurrency(), value.getUsdToCnyAverage(), value.getStatus(), version(value.getRecordVersion()),
value.getUpdatedBy(), value.getUpdatedAt(), value.getRemark());
}
private void requireVersion(MonthlyExchangeRate value, long expected) {
if (version(value.getRecordVersion()) != expected) throw conflict("汇率已被其他操作修改,请刷新后重试");
}
private void validateRange(String from, String to) {
if (StringUtils.hasText(from) && StringUtils.hasText(to) && from.compareTo(to) > 0) throw badRequest("monthFrom不能晚于monthTo");
}
private String month(String value) {
if (value == null || !value.matches("\\d{4}-(0[1-9]|1[0-2])")) throw badRequest("月份格式必须为yyyy-MM"); return value;
}
private String optionalStatus(String value) { return StringUtils.hasText(value) ? status(value) : null; }
private String status(String value) {
String result = value.trim().toUpperCase(Locale.ROOT);
if (!ACTIVE.equals(result) && !INACTIVE.equals(result)) throw badRequest("汇率状态只支持ACTIVE/INACTIVE"); return result;
}
private static long version(Long value) { return value == null ? 0L : value; }
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static String cell(Object value) { return "\"" + String.valueOf(value == null ? "" : value).replace("\"", "\"\"") + "\""; }
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); }
}
@@ -0,0 +1,18 @@
package com.baiyue.lastleg.masterdata.quote.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import java.util.List;
@FeignClient(name = "warehouse-service", contextId = "domesticPartnerWarehouseClient",
url = "${baiyue.clients.domestic-warehouse.url:}")
public interface DomesticPartnerWarehouseClient {
@GetMapping("/internal/warehouses/overseas-partners")
ApiResponse<List<Option>> activePartners(
@RequestHeader("X-Internal-Service-Token") String internalToken);
record Option(Long id, String warehouseCode, String warehouseName, String countryCode) { }
}
@@ -0,0 +1,107 @@
package com.baiyue.lastleg.masterdata.quote.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.masterdata.quote.dto.OverseasQuoteRequests;
import com.baiyue.lastleg.masterdata.quote.dto.OverseasQuoteResponses;
import com.baiyue.lastleg.masterdata.quote.service.OverseasQuoteService;
import com.baiyue.lastleg.security.OverseasPermissions;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
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.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
import java.util.List;
@RestController
@RequestMapping("/api/platform/v1/last-leg/master-data/overseas-quotes")
@Tag(name = "Overseas warehouse quotes / 海外仓报价")
public class PlatformOverseasQuoteController {
@Resource private OverseasQuoteService service;
@GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.QUOTE_LIST + "')")
@Operation(summary = "Page overseas warehouse quotes",
description = "Required authority: platform:overseas-master-data:quote:list")
public ApiResponse<PageResult<OverseasQuoteResponses.Summary>> page(
@Valid OverseasQuoteRequests.Query query) {
return ApiResponse.success(service.page(query));
}
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.QUOTE_LIST + "')")
@Operation(summary = "Get overseas warehouse quote detail",
description = "Required authority: platform:overseas-master-data:quote:list")
public ApiResponse<OverseasQuoteResponses.Detail> detail(@PathVariable long id) {
return ApiResponse.success(service.detail(id));
}
@GetMapping("/warehouse-options")
@PreAuthorize("hasAuthority('" + OverseasPermissions.QUOTE_LIST + "')")
@Operation(summary = "List cached active overseas partner warehouse options",
description = "Required authority: platform:overseas-master-data:quote:list; the browser never calls CN internal APIs")
public ApiResponse<List<OverseasQuoteResponses.PartnerWarehouseOption>> warehouseOptions() {
return ApiResponse.success(service.warehouseOptions());
}
@PostMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.QUOTE_CREATE + "')")
@Operation(summary = "Create an inactive overseas warehouse quote",
description = "Required authority: platform:overseas-master-data:quote:create")
public ApiResponse<OverseasQuoteResponses.Detail> create(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody OverseasQuoteRequests.Create request) {
return ApiResponse.success(service.create(operator(user), request));
}
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.QUOTE_EDIT + "')")
@Operation(summary = "Update an inactive overseas warehouse quote",
description = "Required authority: platform:overseas-master-data:quote:edit")
public ApiResponse<OverseasQuoteResponses.Detail> update(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long id,
@Valid @RequestBody OverseasQuoteRequests.Update request) {
return ApiResponse.success(service.update(id, operator(user), request));
}
@PatchMapping("/{id}/status")
@PreAuthorize("hasAuthority('" + OverseasPermissions.QUOTE_STATUS + "')")
@Operation(summary = "Enable or disable an overseas warehouse quote",
description = "Required authority: platform:overseas-master-data:quote:status")
public ApiResponse<OverseasQuoteResponses.Detail> status(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long id,
@Valid @RequestBody OverseasQuoteRequests.ChangeStatus request) {
return ApiResponse.success(service.changeStatus(id, operator(user), request));
}
@GetMapping(value = "/export", produces = "text/csv")
@PreAuthorize("hasAuthority('" + OverseasPermissions.QUOTE_EXPORT + "')")
@Operation(summary = "Export filtered overseas warehouse quotes",
description = "Required authority: platform:overseas-master-data:quote:export")
public ResponseEntity<byte[]> export(@Valid OverseasQuoteRequests.Query query) {
return ResponseEntity.ok()
.contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=overseas-quotes.csv")
.body(service.exportCsv(query));
}
private String operator(AuthenticatedUser user) {
if (user == null) return "UNKNOWN";
return user.userId() == null ? user.username() : String.valueOf(user.userId());
}
}
@@ -0,0 +1,60 @@
package com.baiyue.lastleg.masterdata.quote.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMin;
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.Size;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
public final class OverseasQuoteRequests {
private OverseasQuoteRequests() { }
public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size,
String keyword, String warehouseId, String providerName,
String currency, String status) {
public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; }
}
public record Fee(@NotBlank @Size(max = 128) String item,
@NotBlank @Size(max = 64) String billingUnit,
@NotNull @DecimalMin("0") BigDecimal unitPrice,
@DecimalMin("0") BigDecimal minimumCharge,
@Size(max = 500) String remark) { }
public record TransitPrice(@NotBlank @Size(max = 64) String fbaWarehouseCode,
@NotBlank @Size(max = 64) String deliveryMethod,
@DecimalMin("0") BigDecimal estimatedMiles,
@NotNull @DecimalMin("0") BigDecimal price,
@DecimalMin("0") BigDecimal minimumCharge,
@Size(max = 500) String remark) { }
public record Create(@NotBlank @Size(max = 128) String name,
@NotBlank String warehouseId,
@NotBlank @Size(max = 128) String providerName,
@NotBlank @Size(max = 3) String currency,
@NotNull LocalDate effectiveStart,
@NotNull LocalDate effectiveEnd,
@NotBlank @Size(max = 64) String versionCode,
@NotEmpty List<@Valid Fee> warehouseFees,
List<@Valid TransitPrice> transitPrices,
@Size(max = 1000) String remark) { }
public record Update(@NotNull @Min(0) Long recordVersion,
@NotBlank @Size(max = 128) String name,
@NotBlank @Size(max = 128) String providerName,
@NotBlank @Size(max = 3) String currency,
@NotNull LocalDate effectiveStart,
@NotNull LocalDate effectiveEnd,
@NotEmpty List<@Valid Fee> warehouseFees,
List<@Valid TransitPrice> transitPrices,
@Size(max = 1000) String remark) { }
public record ChangeStatus(@NotBlank String status, @NotNull @Min(0) Long recordVersion) { }
}
@@ -0,0 +1,30 @@
package com.baiyue.lastleg.masterdata.quote.dto;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
public final class OverseasQuoteResponses {
private OverseasQuoteResponses() { }
public record PartnerWarehouseOption(String warehouseId, String warehouseCode,
String warehouseName, String countryCode) { }
public record Summary(String id, String name, String warehouseId, String warehouseCode,
String warehouseName, String countryCode, String providerName,
String currency, LocalDate effectiveStart, LocalDate effectiveEnd,
String versionCode, String status, long recordVersion,
String updatedBy, LocalDateTime updatedAt, String remark) { }
public record Fee(String item, String billingUnit, BigDecimal unitPrice,
BigDecimal minimumCharge, String remark) { }
public record TransitPrice(String fbaWarehouseCode, String deliveryMethod,
BigDecimal estimatedMiles, BigDecimal price,
BigDecimal minimumCharge, String remark) { }
public record Detail(Summary summary, List<Fee> warehouseFees,
List<TransitPrice> transitPrices,
String createdBy, LocalDateTime createdAt) { }
}
@@ -0,0 +1,32 @@
package com.baiyue.lastleg.masterdata.quote.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.LocalDate;
import java.time.LocalDateTime;
@Data
@TableName("ll_overseas_quote")
public class OverseasQuote {
@TableId(type = IdType.INPUT) private Long id;
private String name;
private Long warehouseId;
private String warehouseCode;
private String warehouseName;
private String countryCode;
private String providerName;
private String currency;
private LocalDate effectiveStart;
private LocalDate effectiveEnd;
private String versionCode;
private String status;
private String remark;
private Long recordVersion;
private String createdBy;
private String updatedBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.masterdata.quote.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;
@Data
@TableName("ll_overseas_quote_fee")
public class OverseasQuoteFee {
@TableId(type = IdType.INPUT) private Long id;
private Long quoteId;
private Integer sequenceNo;
private String item;
private String billingUnit;
private BigDecimal unitPrice;
private BigDecimal minimumCharge;
private String remark;
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.masterdata.quote.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;
@Data
@TableName("ll_overseas_quote_transit_price")
public class OverseasQuoteTransitPrice {
@TableId(type = IdType.INPUT) private Long id;
private Long quoteId;
private Integer sequenceNo;
private String fbaWarehouseCode;
private String deliveryMethod;
private BigDecimal estimatedMiles;
private BigDecimal price;
private BigDecimal minimumCharge;
private String remark;
}
@@ -0,0 +1,8 @@
package com.baiyue.lastleg.masterdata.quote.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.masterdata.quote.entity.OverseasQuoteFee;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface OverseasQuoteFeeMapper extends BaseMapper<OverseasQuoteFee> { }
@@ -0,0 +1,28 @@
package com.baiyue.lastleg.masterdata.quote.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.masterdata.quote.entity.OverseasQuote;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.time.LocalDate;
@Mapper
public interface OverseasQuoteMapper extends BaseMapper<OverseasQuote> {
@Select("SELECT * FROM ll_overseas_quote WHERE id=#{id} LIMIT 1 FOR UPDATE")
OverseasQuote lockById(@Param("id") long id);
@Select("""
SELECT COUNT(*) FROM ll_overseas_quote
WHERE warehouse_id=#{warehouseId} AND provider_name=#{providerName} AND currency=#{currency}
AND status='ACTIVE' AND id&lt;&gt;#{excludedId}
AND effective_start&lt;=#{effectiveEnd} AND effective_end&gt;=#{effectiveStart}
""")
int countActiveOverlap(@Param("warehouseId") long warehouseId,
@Param("providerName") String providerName,
@Param("currency") String currency,
@Param("effectiveStart") LocalDate effectiveStart,
@Param("effectiveEnd") LocalDate effectiveEnd,
@Param("excludedId") long excludedId);
}
@@ -0,0 +1,8 @@
package com.baiyue.lastleg.masterdata.quote.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.masterdata.quote.entity.OverseasQuoteTransitPrice;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface OverseasQuoteTransitPriceMapper extends BaseMapper<OverseasQuoteTransitPrice> { }
@@ -0,0 +1,227 @@
package com.baiyue.lastleg.masterdata.quote.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
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.masterdata.quote.dto.OverseasQuoteRequests;
import com.baiyue.lastleg.masterdata.quote.dto.OverseasQuoteResponses;
import com.baiyue.lastleg.masterdata.quote.entity.OverseasQuote;
import com.baiyue.lastleg.masterdata.quote.entity.OverseasQuoteFee;
import com.baiyue.lastleg.masterdata.quote.entity.OverseasQuoteTransitPrice;
import com.baiyue.lastleg.masterdata.quote.mapper.OverseasQuoteFeeMapper;
import com.baiyue.lastleg.masterdata.quote.mapper.OverseasQuoteMapper;
import com.baiyue.lastleg.masterdata.quote.mapper.OverseasQuoteTransitPriceMapper;
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.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Locale;
@Service
public class OverseasQuoteService {
public static final String ACTIVE = "ACTIVE";
public static final String INACTIVE = "INACTIVE";
@Resource private OverseasQuoteMapper quotes;
@Resource private OverseasQuoteFeeMapper fees;
@Resource private OverseasQuoteTransitPriceMapper transitPrices;
@Resource private PartnerWarehouseDirectory warehouses;
public PageResult<OverseasQuoteResponses.Summary> page(OverseasQuoteRequests.Query query) {
Long warehouseId = parseOptionalId(query.warehouseId());
String status = normalizeOptionalStatus(query.status());
var wrapper = new LambdaQueryWrapper<OverseasQuote>()
.and(StringUtils.hasText(query.keyword()), value -> value
.like(OverseasQuote::getName, query.keyword().trim())
.or().like(OverseasQuote::getWarehouseName, query.keyword().trim())
.or().like(OverseasQuote::getProviderName, query.keyword().trim())
.or().like(OverseasQuote::getVersionCode, query.keyword().trim()))
.eq(warehouseId != null, OverseasQuote::getWarehouseId, warehouseId)
.like(StringUtils.hasText(query.providerName()), OverseasQuote::getProviderName,
StringUtils.hasText(query.providerName()) ? query.providerName().trim() : null)
.eq(StringUtils.hasText(query.currency()), OverseasQuote::getCurrency,
StringUtils.hasText(query.currency()) ? query.currency().trim().toUpperCase(Locale.ROOT) : null)
.eq(status != null, OverseasQuote::getStatus, status)
.orderByDesc(OverseasQuote::getUpdatedAt).orderByDesc(OverseasQuote::getId);
Page<OverseasQuote> page = quotes.selectPage(Page.of(query.page(), query.size()), wrapper);
return new PageResult<>(page.getRecords().stream().map(this::summary).toList(), page.getCurrent(),
page.getSize(), page.getTotal(), page.getPages());
}
public OverseasQuoteResponses.Detail detail(long id) {
OverseasQuote quote = required(id);
List<OverseasQuoteResponses.Fee> feeRows = fees.selectList(new LambdaQueryWrapper<OverseasQuoteFee>()
.eq(OverseasQuoteFee::getQuoteId, id).orderByAsc(OverseasQuoteFee::getSequenceNo))
.stream().map(value -> new OverseasQuoteResponses.Fee(value.getItem(), value.getBillingUnit(),
value.getUnitPrice(), value.getMinimumCharge(), value.getRemark())).toList();
List<OverseasQuoteResponses.TransitPrice> transitRows = transitPrices.selectList(
new LambdaQueryWrapper<OverseasQuoteTransitPrice>().eq(OverseasQuoteTransitPrice::getQuoteId, id)
.orderByAsc(OverseasQuoteTransitPrice::getSequenceNo))
.stream().map(value -> new OverseasQuoteResponses.TransitPrice(value.getFbaWarehouseCode(),
value.getDeliveryMethod(), value.getEstimatedMiles(), value.getPrice(),
value.getMinimumCharge(), value.getRemark())).toList();
return new OverseasQuoteResponses.Detail(summary(quote), feeRows, transitRows,
quote.getCreatedBy(), quote.getCreatedAt());
}
public List<OverseasQuoteResponses.PartnerWarehouseOption> warehouseOptions() {
return warehouses.options();
}
@Transactional
public OverseasQuoteResponses.Detail create(String operator, OverseasQuoteRequests.Create request) {
validateDates(request.effectiveStart(), request.effectiveEnd());
long warehouseId = parseId(request.warehouseId());
var warehouse = warehouses.required(warehouseId);
LocalDateTime now = LocalDateTime.now();
OverseasQuote quote = new OverseasQuote();
quote.setId(IdWorker.getId()); quote.setName(request.name().trim()); quote.setWarehouseId(warehouseId);
quote.setWarehouseCode(warehouse.warehouseCode()); quote.setWarehouseName(warehouse.warehouseName());
quote.setCountryCode(warehouse.countryCode()); quote.setProviderName(request.providerName().trim());
quote.setCurrency(currency(request.currency())); quote.setEffectiveStart(request.effectiveStart());
quote.setEffectiveEnd(request.effectiveEnd()); quote.setVersionCode(request.versionCode().trim());
quote.setStatus(INACTIVE); quote.setRemark(trim(request.remark())); quote.setRecordVersion(0L);
quote.setCreatedBy(operator); quote.setUpdatedBy(operator); quote.setCreatedAt(now); quote.setUpdatedAt(now);
try { quotes.insert(quote); }
catch (DuplicateKeyException exception) { throw conflict("同一合作仓、报价方和版本号已存在"); }
replaceChildren(quote.getId(), request.warehouseFees(), request.transitPrices());
return detail(quote.getId());
}
@Transactional
public OverseasQuoteResponses.Detail update(long id, String operator, OverseasQuoteRequests.Update request) {
validateDates(request.effectiveStart(), request.effectiveEnd());
OverseasQuote current = locked(id);
requireVersion(current, request.recordVersion());
if (ACTIVE.equals(current.getStatus())) throw conflict("启用中的报价不能直接编辑,请先停用");
int updated = quotes.update(null, new LambdaUpdateWrapper<OverseasQuote>()
.eq(OverseasQuote::getId, id).eq(OverseasQuote::getRecordVersion, request.recordVersion())
.set(OverseasQuote::getName, request.name().trim())
.set(OverseasQuote::getProviderName, request.providerName().trim())
.set(OverseasQuote::getCurrency, currency(request.currency()))
.set(OverseasQuote::getEffectiveStart, request.effectiveStart())
.set(OverseasQuote::getEffectiveEnd, request.effectiveEnd())
.set(OverseasQuote::getRemark, trim(request.remark()))
.set(OverseasQuote::getUpdatedBy, operator).set(OverseasQuote::getUpdatedAt, LocalDateTime.now())
.setSql("record_version = record_version + 1"));
if (updated != 1) throw conflict("报价已被其他操作修改,请刷新后重试");
replaceChildren(id, request.warehouseFees(), request.transitPrices());
return detail(id);
}
@Transactional
public OverseasQuoteResponses.Detail changeStatus(long id, String operator,
OverseasQuoteRequests.ChangeStatus request) {
OverseasQuote current = locked(id);
requireVersion(current, request.recordVersion());
String status = normalizeStatus(request.status());
if (status.equals(current.getStatus())) return detail(id);
if (ACTIVE.equals(status) && quotes.countActiveOverlap(current.getWarehouseId(), current.getProviderName(),
current.getCurrency(), current.getEffectiveStart(), current.getEffectiveEnd(), id) > 0) {
throw conflict("同一合作仓、报价方和币种存在生效区间重叠的启用报价");
}
int updated = quotes.update(null, new LambdaUpdateWrapper<OverseasQuote>()
.eq(OverseasQuote::getId, id).eq(OverseasQuote::getRecordVersion, request.recordVersion())
.set(OverseasQuote::getStatus, status).set(OverseasQuote::getUpdatedBy, operator)
.set(OverseasQuote::getUpdatedAt, LocalDateTime.now())
.setSql("record_version = record_version + 1"));
if (updated != 1) throw conflict("报价已被其他操作修改,请刷新后重试");
return detail(id);
}
public byte[] exportCsv(OverseasQuoteRequests.Query query) {
OverseasQuoteRequests.Query exportQuery = new OverseasQuoteRequests.Query(1, 200, query.keyword(),
query.warehouseId(), query.providerName(), query.currency(), query.status());
StringBuilder csv = new StringBuilder("\uFEFF报价ID,报价名称,合作仓编码,合作仓名称,报价方,币种,生效开始,生效结束,版本号,状态,更新时间\r\n");
for (var row : page(exportQuery).records()) {
csv.append(cell(row.id())).append(',').append(cell(row.name())).append(',')
.append(cell(row.warehouseCode())).append(',').append(cell(row.warehouseName())).append(',')
.append(cell(row.providerName())).append(',').append(cell(row.currency())).append(',')
.append(row.effectiveStart()).append(',').append(row.effectiveEnd()).append(',')
.append(cell(row.versionCode())).append(',').append(row.status()).append(',')
.append(row.updatedAt()).append("\r\n");
}
return csv.toString().getBytes(StandardCharsets.UTF_8);
}
private void replaceChildren(long quoteId, List<OverseasQuoteRequests.Fee> feeRows,
List<OverseasQuoteRequests.TransitPrice> transitRows) {
fees.delete(new LambdaQueryWrapper<OverseasQuoteFee>().eq(OverseasQuoteFee::getQuoteId, quoteId));
transitPrices.delete(new LambdaQueryWrapper<OverseasQuoteTransitPrice>()
.eq(OverseasQuoteTransitPrice::getQuoteId, quoteId));
int sequence = 1;
for (var row : feeRows) {
OverseasQuoteFee value = new OverseasQuoteFee(); value.setId(IdWorker.getId()); value.setQuoteId(quoteId);
value.setSequenceNo(sequence++); value.setItem(row.item().trim());
value.setBillingUnit(row.billingUnit().trim()); value.setUnitPrice(row.unitPrice());
value.setMinimumCharge(zeroIfNull(row.minimumCharge())); value.setRemark(trim(row.remark()));
fees.insert(value);
}
sequence = 1;
for (var row : transitRows == null ? List.<OverseasQuoteRequests.TransitPrice>of() : transitRows) {
OverseasQuoteTransitPrice value = new OverseasQuoteTransitPrice(); value.setId(IdWorker.getId());
value.setQuoteId(quoteId); value.setSequenceNo(sequence++);
value.setFbaWarehouseCode(row.fbaWarehouseCode().trim().toUpperCase(Locale.ROOT));
value.setDeliveryMethod(row.deliveryMethod().trim().toUpperCase(Locale.ROOT));
value.setEstimatedMiles(zeroIfNull(row.estimatedMiles())); value.setPrice(row.price());
value.setMinimumCharge(zeroIfNull(row.minimumCharge())); value.setRemark(trim(row.remark()));
transitPrices.insert(value);
}
}
private OverseasQuoteResponses.Summary summary(OverseasQuote value) {
return new OverseasQuoteResponses.Summary(value.getId().toString(), value.getName(),
value.getWarehouseId().toString(), value.getWarehouseCode(), value.getWarehouseName(),
value.getCountryCode(), value.getProviderName(), value.getCurrency(), value.getEffectiveStart(),
value.getEffectiveEnd(), value.getVersionCode(), value.getStatus(), version(value.getRecordVersion()),
value.getUpdatedBy(), value.getUpdatedAt(), value.getRemark());
}
private OverseasQuote required(long id) {
OverseasQuote value = quotes.selectById(id);
if (value == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "海外仓报价不存在");
return value;
}
private OverseasQuote locked(long id) {
OverseasQuote value = quotes.lockById(id);
if (value == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "海外仓报价不存在");
return value;
}
private void requireVersion(OverseasQuote value, long expected) {
if (version(value.getRecordVersion()) != expected) throw conflict("报价已被其他操作修改,请刷新后重试");
}
private void validateDates(java.time.LocalDate start, java.time.LocalDate end) {
if (end.isBefore(start)) throw badRequest("报价结束日期不能早于开始日期");
}
private Long parseOptionalId(String value) { return StringUtils.hasText(value) ? parseId(value) : null; }
private long parseId(String value) {
try { long id = Long.parseLong(value.trim()); if (id <= 0) throw new NumberFormatException(); return id; }
catch (NumberFormatException exception) { throw badRequest("warehouseId 无效"); }
}
private String normalizeOptionalStatus(String value) { return StringUtils.hasText(value) ? normalizeStatus(value) : null; }
private String normalizeStatus(String value) {
String status = value.trim().toUpperCase(Locale.ROOT);
if (!ACTIVE.equals(status) && !INACTIVE.equals(status)) throw badRequest("报价状态只支持 ACTIVE/INACTIVE");
return status;
}
private String currency(String value) {
String currency = value.trim().toUpperCase(Locale.ROOT);
if (currency.length() != 3) throw badRequest("币种必须为三位代码");
return currency;
}
private static long version(Long value) { return value == null ? 0L : value; }
private static BigDecimal zeroIfNull(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; }
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static String cell(Object value) { return "\"" + String.valueOf(value == null ? "" : value).replace("\"", "\"\"") + "\""; }
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); }
}
@@ -0,0 +1,57 @@
package com.baiyue.lastleg.masterdata.quote.service;
import com.baiyue.lastleg.masterdata.quote.client.DomesticPartnerWarehouseClient;
import com.baiyue.lastleg.masterdata.quote.dto.OverseasQuoteResponses;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
/** Single-key immutable snapshot cache for active domestic overseas-partner warehouses. */
@Component
public class PartnerWarehouseDirectory {
@Resource private DomesticPartnerWarehouseClient client;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
@Value("${baiyue.clients.domestic-warehouse.cache-ttl:30m}") private Duration ttl;
private Clock clock = Clock.systemUTC();
private volatile Snapshot snapshot;
public List<OverseasQuoteResponses.PartnerWarehouseOption> options() {
Snapshot current = snapshot;
Instant now = clock.instant();
if (current != null && now.isBefore(current.expiresAt())) return current.options();
synchronized (this) {
current = snapshot;
now = clock.instant();
if (current != null && now.isBefore(current.expiresAt())) return current.options();
var response = client.activePartners(internalToken);
if (response == null || !response.successful() || response.data() == null) {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
"国内海外合作仓候选暂不可用");
}
List<OverseasQuoteResponses.PartnerWarehouseOption> options = response.data().stream()
.filter(value -> value != null && value.id() != null)
.map(value -> new OverseasQuoteResponses.PartnerWarehouseOption(value.id().toString(),
value.warehouseCode(), value.warehouseName(), value.countryCode()))
.toList();
snapshot = new Snapshot(options, now.plus(ttl));
return snapshot.options();
}
}
public OverseasQuoteResponses.PartnerWarehouseOption required(long warehouseId) {
return options().stream().filter(value -> Long.toString(warehouseId).equals(value.warehouseId()))
.findFirst().orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST,
"所选海外合作仓不存在或已停用"));
}
private record Snapshot(List<OverseasQuoteResponses.PartnerWarehouseOption> options, Instant expiresAt) {
private Snapshot { options = List.copyOf(options); }
}
}
@@ -0,0 +1,58 @@
package com.baiyue.lastleg.masterdata.vehicle.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.masterdata.vehicle.dto.VehicleDtos;
import com.baiyue.lastleg.masterdata.vehicle.service.VehicleService;
import com.baiyue.lastleg.security.OverseasPermissions;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.nio.charset.StandardCharsets;
@RestController
@RequestMapping("/api/platform/v1/last-leg/master-data/vehicles")
@Tag(name = "Vehicle master data / 车辆主数据")
public class PlatformVehicleController {
@Resource private VehicleService service;
@GetMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_LIST + "')")
@Operation(summary = "Page vehicle master data", description = "Required authority: platform:overseas-master-data:vehicle:list")
public ApiResponse<PageResult<VehicleDtos.Response>> page(@Valid VehicleDtos.Query query) { return ApiResponse.success(service.page(query)); }
@GetMapping("/{id}") @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_LIST + "')")
@Operation(summary = "Get vehicle master data detail", description = "Required authority: platform:overseas-master-data:vehicle:list; excludes delivery fulfillment history")
public ApiResponse<VehicleDtos.Response> detail(@PathVariable long id) { return ApiResponse.success(service.detail(id)); }
@PostMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_CREATE + "')")
public ApiResponse<VehicleDtos.Response> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody VehicleDtos.Create request) { return ApiResponse.success(service.create(operator(user), request)); }
@PutMapping("/{id}") @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_EDIT + "')")
public ApiResponse<VehicleDtos.Response> update(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long id,
@Valid @RequestBody VehicleDtos.Update request) { return ApiResponse.success(service.update(id, operator(user), request)); }
@PatchMapping("/{id}/status") @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_STATUS + "')")
public ApiResponse<VehicleDtos.Response> status(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long id,
@Valid @RequestBody VehicleDtos.ChangeStatus request) { return ApiResponse.success(service.changeStatus(id, operator(user), request)); }
@GetMapping(value = "/export", produces = "text/csv")
@PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_EXPORT + "')")
public ResponseEntity<byte[]> export(@Valid VehicleDtos.Query query) {
return ResponseEntity.ok().contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=vehicles.csv").body(service.exportCsv(query));
}
private String operator(AuthenticatedUser user) {
if (user == null) return "UNKNOWN"; return user.userId() == null ? user.username() : String.valueOf(user.userId());
}
}
@@ -0,0 +1,47 @@
package com.baiyue.lastleg.masterdata.vehicle.dto;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Digits;
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.LocalDateTime;
public final class VehicleDtos {
private VehicleDtos() { }
public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size,
@Size(max = 128) String keyword, String ownership, String region, String status) {
public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; }
}
public record Create(@NotBlank @Size(max = 64) String plateNumber,
@NotBlank @Size(max = 128) String model,
@NotNull @DecimalMin(value = "0", inclusive = false) @Digits(integer = 8, fraction = 2) BigDecimal fuelConsumptionPer100Km,
@NotNull @Min(0) Long mileageKm,
@Size(max = 128) String driverOrDispatchCompany,
@Size(max = 64) String phone,
@NotBlank String region, @NotBlank String ownership,
@NotBlank String status, @Size(max = 1000) String remark) { }
public record Update(@NotNull @Min(0) Long recordVersion,
@NotBlank @Size(max = 64) String plateNumber,
@NotBlank @Size(max = 128) String model,
@NotNull @DecimalMin(value = "0", inclusive = false) @Digits(integer = 8, fraction = 2) BigDecimal fuelConsumptionPer100Km,
@NotNull @Min(0) Long mileageKm,
@Size(max = 128) String driverOrDispatchCompany,
@Size(max = 64) String phone,
@NotBlank String region, @NotBlank String ownership,
@Size(max = 1000) String remark) { }
public record ChangeStatus(@NotBlank String status, @NotNull @Min(0) Long recordVersion) { }
public record Response(String id, String plateNumber, String model, BigDecimal fuelConsumptionPer100Km,
long mileageKm, String driverOrDispatchCompany, String phone, String region,
String ownership, String status, String remark, long recordVersion,
String updatedBy, LocalDateTime updatedAt) { }
}
@@ -0,0 +1,30 @@
package com.baiyue.lastleg.masterdata.vehicle.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_vehicle")
public class Vehicle {
@TableId(type = IdType.INPUT) private Long id;
private String plateNumber;
private String model;
private BigDecimal fuelConsumptionPer100Km;
private Long mileageKm;
private String driverOrDispatchCompany;
private String phone;
private String region;
private String ownership;
private String status;
private String remark;
private Long recordVersion;
private String createdBy;
private String updatedBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,13 @@
package com.baiyue.lastleg.masterdata.vehicle.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.masterdata.vehicle.entity.Vehicle;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface VehicleMapper extends BaseMapper<Vehicle> {
@Select("SELECT * FROM ll_vehicle WHERE id=#{id} LIMIT 1 FOR UPDATE")
Vehicle lockById(@Param("id") long id);
}
@@ -0,0 +1,143 @@
package com.baiyue.lastleg.masterdata.vehicle.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
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.masterdata.vehicle.dto.VehicleDtos;
import com.baiyue.lastleg.masterdata.vehicle.entity.Vehicle;
import com.baiyue.lastleg.masterdata.vehicle.mapper.VehicleMapper;
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.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.Locale;
import java.util.Set;
@Service
public class VehicleService {
private static final Set<String> OWNERSHIPS = Set.of("OWNED", "DISPATCHED");
private static final Set<String> REGIONS = Set.of("DOMESTIC", "OVERSEAS");
private static final Set<String> STATUSES = Set.of("IN_SERVICE", "DISPATCHED", "MAINTENANCE", "INACTIVE", "RETURNED");
@Resource private VehicleMapper vehicles;
public PageResult<VehicleDtos.Response> page(VehicleDtos.Query query) {
Page<Vehicle> page = vehicles.selectPage(Page.of(query.page(), query.size()), wrapper(query));
return new PageResult<>(page.getRecords().stream().map(this::response).toList(), page.getCurrent(),
page.getSize(), page.getTotal(), page.getPages());
}
public VehicleDtos.Response detail(long id) { return response(required(id)); }
@Transactional
public VehicleDtos.Response create(String operator, VehicleDtos.Create request) {
LocalDateTime now = LocalDateTime.now();
Vehicle value = new Vehicle(); value.setId(IdWorker.getId());
value.setPlateNumber(requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT));
value.setModel(requiredText(request.model(), "车型")); value.setFuelConsumptionPer100Km(request.fuelConsumptionPer100Km());
value.setMileageKm(request.mileageKm()); value.setDriverOrDispatchCompany(trim(request.driverOrDispatchCompany()));
value.setPhone(trim(request.phone())); value.setRegion(region(request.region())); value.setOwnership(ownership(request.ownership()));
value.setStatus(status(request.status())); value.setRemark(trim(request.remark())); value.setRecordVersion(0L);
value.setCreatedBy(operator); value.setUpdatedBy(operator); value.setCreatedAt(now); value.setUpdatedAt(now);
try { vehicles.insert(value); } catch (DuplicateKeyException exception) { throw conflict("车牌号已存在"); }
return detail(value.getId());
}
@Transactional
public VehicleDtos.Response update(long id, String operator, VehicleDtos.Update request) {
Vehicle current = locked(id); requireVersion(current, request.recordVersion());
if (request.mileageKm() < current.getMileageKm()) throw badRequest("当前公里码不能小于已有公里码");
int updated;
try {
updated = vehicles.update(null, new LambdaUpdateWrapper<Vehicle>()
.eq(Vehicle::getId, id).eq(Vehicle::getRecordVersion, request.recordVersion())
.set(Vehicle::getPlateNumber, requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT))
.set(Vehicle::getModel, requiredText(request.model(), "车型"))
.set(Vehicle::getFuelConsumptionPer100Km, request.fuelConsumptionPer100Km())
.set(Vehicle::getMileageKm, request.mileageKm())
.set(Vehicle::getDriverOrDispatchCompany, trim(request.driverOrDispatchCompany()))
.set(Vehicle::getPhone, trim(request.phone())).set(Vehicle::getRegion, region(request.region()))
.set(Vehicle::getOwnership, ownership(request.ownership())).set(Vehicle::getRemark, trim(request.remark()))
.set(Vehicle::getUpdatedBy, operator).set(Vehicle::getUpdatedAt, LocalDateTime.now())
.setSql("record_version = record_version + 1"));
} catch (DuplicateKeyException exception) { throw conflict("车牌号已存在"); }
if (updated != 1) throw conflict("车辆档案已被其他操作修改,请刷新后重试");
return detail(id);
}
@Transactional
public VehicleDtos.Response changeStatus(long id, String operator, VehicleDtos.ChangeStatus request) {
Vehicle current = locked(id); requireVersion(current, request.recordVersion());
String status = status(request.status());
if (status.equals(current.getStatus())) return response(current);
int updated = vehicles.update(null, new LambdaUpdateWrapper<Vehicle>()
.eq(Vehicle::getId, id).eq(Vehicle::getRecordVersion, request.recordVersion())
.set(Vehicle::getStatus, status).set(Vehicle::getUpdatedBy, operator)
.set(Vehicle::getUpdatedAt, LocalDateTime.now()).setSql("record_version = record_version + 1"));
if (updated != 1) throw conflict("车辆档案已被其他操作修改,请刷新后重试");
return detail(id);
}
public byte[] exportCsv(VehicleDtos.Query query) {
var rows = vehicles.selectList(wrapper(query)).stream().map(this::response).toList();
StringBuilder csv = new StringBuilder("\uFEFF车辆ID,车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,更新时间\r\n");
for (var row : rows) csv.append(row.id()).append(',').append(cell(row.plateNumber())).append(',')
.append(cell(row.model())).append(',').append(row.fuelConsumptionPer100Km()).append(',').append(row.mileageKm()).append(',')
.append(cell(row.driverOrDispatchCompany())).append(',').append(cell(row.phone())).append(',')
.append(row.region()).append(',').append(row.ownership()).append(',').append(row.status()).append(',')
.append(row.updatedAt()).append("\r\n");
return csv.toString().getBytes(StandardCharsets.UTF_8);
}
private LambdaQueryWrapper<Vehicle> wrapper(VehicleDtos.Query query) {
String keyword = trim(query.keyword()); String ownership = optional(query.ownership(), OWNERSHIPS, "车辆归属");
String region = optional(query.region(), REGIONS, "车辆区域"); String status = optional(query.status(), STATUSES, "车辆状态");
return new LambdaQueryWrapper<Vehicle>()
.and(StringUtils.hasText(keyword), w -> w.like(Vehicle::getPlateNumber, keyword)
.or().like(Vehicle::getModel, keyword).or().like(Vehicle::getDriverOrDispatchCompany, keyword))
.eq(ownership != null, Vehicle::getOwnership, ownership).eq(region != null, Vehicle::getRegion, region)
.eq(status != null, Vehicle::getStatus, status).orderByDesc(Vehicle::getUpdatedAt).orderByDesc(Vehicle::getId);
}
private Vehicle required(long id) {
Vehicle value = vehicles.selectById(id); if (value == null) throw notFound(); return value;
}
private Vehicle locked(long id) {
Vehicle value = vehicles.lockById(id); if (value == null) throw notFound(); return value;
}
private VehicleDtos.Response response(Vehicle value) {
return new VehicleDtos.Response(String.valueOf(value.getId()), value.getPlateNumber(), value.getModel(),
value.getFuelConsumptionPer100Km(), value.getMileageKm(), value.getDriverOrDispatchCompany(), value.getPhone(),
value.getRegion(), value.getOwnership(), value.getStatus(), value.getRemark(), version(value.getRecordVersion()),
value.getUpdatedBy(), value.getUpdatedAt());
}
private void requireVersion(Vehicle value, long expected) {
if (version(value.getRecordVersion()) != expected) throw conflict("车辆档案已被其他操作修改,请刷新后重试");
}
private static String ownership(String value) { return required(value, OWNERSHIPS, "车辆归属"); }
private static String region(String value) { return required(value, REGIONS, "车辆区域"); }
private static String status(String value) { return required(value, STATUSES, "车辆状态"); }
private static String optional(String value, Set<String> allowed, String label) {
return StringUtils.hasText(value) ? required(value, allowed, label) : null;
}
private static String required(String value, Set<String> allowed, String label) {
if (!StringUtils.hasText(value)) throw badRequest(label + "不能为空");
String normalized = value.trim().toUpperCase(Locale.ROOT);
if (!allowed.contains(normalized)) throw badRequest(label + "不合法"); return normalized;
}
private static String requiredText(String value, String label) {
if (!StringUtils.hasText(value)) throw badRequest(label + "不能为空"); return value.trim();
}
private static long version(Long value) { return value == null ? 0L : value; }
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static String cell(Object value) { return "\"" + String.valueOf(value == null ? "" : value).replace("\"", "\"\"") + "\""; }
private static ResponseStatusException notFound() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "车辆档案不存在"); }
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); }
}
@@ -6,10 +6,13 @@ 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 com.baiyue.lastleg.security.OverseasPermissions;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -20,12 +23,16 @@ 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/overseas-procurements")
public class PlatformOverseasProcurementController {
@Resource private OverseasProcurementService service;
@GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.PURCHASE_LIST + "')")
@Operation(summary = "Page overseas procurement orders", description = "Required authority: " + OverseasPermissions.PURCHASE_LIST)
public ApiResponse<PageResult<OverseasProcurementResponses.Summary>> page(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@@ -34,7 +41,29 @@ public class PlatformOverseasProcurementController {
return ApiResponse.success(service.page(trim(customerId), query));
}
@GetMapping("/customer-options")
@PreAuthorize("hasAuthority('" + OverseasPermissions.PURCHASE_LIST + "')")
@Operation(summary = "List procurement customer options", description = "Required authority: " + OverseasPermissions.PURCHASE_LIST)
public ApiResponse<List<OverseasProcurementResponses.CustomerOption>> customerOptions(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String keyword,
@RequestParam(defaultValue = "50") int limit) {
requirePlatform(user);
return ApiResponse.success(service.customerOptions(keyword, limit));
}
@GetMapping("/warehouse-options")
@PreAuthorize("hasAuthority('" + OverseasPermissions.PURCHASE_LIST + "')")
@Operation(summary = "List procurement warehouse options", description = "Required authority: " + OverseasPermissions.PURCHASE_LIST)
public ApiResponse<List<OverseasProcurementResponses.WarehouseOption>> warehouseOptions(
@AuthenticationPrincipal AuthenticatedUser user) {
requirePlatform(user);
return ApiResponse.success(service.warehouseOptions());
}
@GetMapping("/{orderNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.PURCHASE_LIST + "')")
@Operation(summary = "Get an overseas procurement order", description = "Required authority: " + OverseasPermissions.PURCHASE_LIST)
public ApiResponse<OverseasProcurementResponses.Detail> detail(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
@PathVariable String orderNo) {
@@ -43,6 +72,8 @@ public class PlatformOverseasProcurementController {
}
@PostMapping("/{orderNo}/review")
@PreAuthorize("hasAuthority('" + OverseasPermissions.PURCHASE_REVIEW + "')")
@Operation(summary = "Review an overseas procurement order", description = "Required authority: " + OverseasPermissions.PURCHASE_REVIEW)
public ApiResponse<OverseasProcurementResponses.Detail> review(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
@PathVariable String orderNo, @Valid @RequestBody OverseasProcurementRequests.Review request) {
@@ -51,19 +82,13 @@ public class PlatformOverseasProcurementController {
}
@PatchMapping("/{orderNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.PURCHASE_EDIT + "')")
@Operation(summary = "Update an overseas procurement order", description = "Required authority: " + OverseasPermissions.PURCHASE_EDIT)
public ApiResponse<OverseasProcurementResponses.Detail> update(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
@PathVariable String orderNo, @Valid @RequestBody OverseasProcurementRequests.Update request) {
requirePermission(user, OverseasProcurementService.MANAGE_PERMISSION);
return ApiResponse.success(service.update(requiredCustomer(customerId), orderNo, request));
}
@PostMapping("/{orderNo}/outbounds")
public ApiResponse<OverseasProcurementResponses.Detail> addOutbound(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
@PathVariable String orderNo, @Valid @RequestBody OverseasProcurementRequests.AddOutbound request) {
requirePermission(user, OverseasProcurementService.MANAGE_PERMISSION);
return ApiResponse.success(service.addOutbound(requiredCustomer(customerId), orderNo, operator(user), request));
return ApiResponse.success(service.update(requiredCustomer(customerId), orderNo, operator(user), request));
}
private void requirePlatform(AuthenticatedUser user) {
@@ -1,5 +1,6 @@
package com.baiyue.lastleg.procurement.dto;
import com.fasterxml.jackson.databind.JsonNode;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
@@ -41,23 +42,15 @@ public final class OverseasProcurementRequests {
@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,
JsonNode productSpecification,
JsonNode 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) {}
JsonNode purchaseWarehouseId,
JsonNode purchaseWarehouseName,
JsonNode domesticCarrier,
JsonNode domesticTrackingNo,
JsonNode overseasCarrier,
JsonNode overseasTrackingNo,
JsonNode estimatedArrivalAt,
JsonNode remark) {}
}
@@ -12,15 +12,17 @@ public final class OverseasProcurementResponses {
String purchaseCategory, String productName, String productSpecification,
long purchaseQuantity, BigDecimal budgetUnitPrice, String currency,
String purchaseWarehouseId, String purchaseWarehouseName,
long inboundQuantity, long outboundQuantity, long inventoryQuantity,
long inboundQuantity, 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 CustomerOption(String customerId, String customerName) {}
public record WarehouseOption(
String warehouseId, String warehouseCode, String warehouseName, String countryCode) {}
public record History(
String fromStatus, String toStatus, String operatorId, String remark,
String operationType, String fromStatus, String toStatus, String operatorId,
String changeDetail, String remark,
LocalDateTime createdAt) {}
public record Detail(
@@ -30,5 +32,5 @@ public final class OverseasProcurementResponses {
String overseasCarrier, String overseasTrackingNo,
LocalDateTime estimatedArrivalAt, String remark,
String createdBy, String reviewedBy, String reviewRemark, LocalDateTime reviewedAt,
List<Outbound> outbounds, List<History> histories) {}
List<History> histories) {}
}
@@ -28,7 +28,6 @@ public class OverseasProcurementOrder {
private String purchaseWarehouseId;
private String purchaseWarehouseName;
private Long inboundQuantity;
private Long outboundQuantity;
private String domesticCarrier;
private String domesticTrackingNo;
private String overseasCarrier;
@@ -13,9 +13,11 @@ public class OverseasProcurementStatusHistory {
@TableId(type = IdType.INPUT) private Long id;
private Long orderId;
private String customerId;
private String operationType;
private String fromStatus;
private String toStatus;
private String operatorId;
private String changeDetail;
private String remark;
private LocalDateTime createdAt;
}
@@ -5,7 +5,8 @@ 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;
import java.util.List;
@Mapper
public interface OverseasProcurementOrderMapper extends BaseMapper<OverseasProcurementOrder> {
@@ -18,8 +19,10 @@ public interface OverseasProcurementOrderMapper extends BaseMapper<OverseasProcu
@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);
@Select("<script>SELECT customer_id,MAX(customer_name) AS customer_name " +
"FROM ll_overseas_procurement_order " +
"<if test='keyword != null and keyword != &quot;&quot;'>" +
"WHERE customer_id LIKE CONCAT('%',#{keyword},'%') OR customer_name LIKE CONCAT('%',#{keyword},'%')" +
"</if> GROUP BY customer_id ORDER BY MAX(customer_name),customer_id LIMIT #{limit}</script>")
List<OverseasProcurementOrder> customerOptions(@Param("keyword") String keyword, @Param("limit") int limit);
}
@@ -1,16 +0,0 @@
package com.baiyue.lastleg.procurement.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementOutbound;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface OverseasProcurementOutboundMapper extends BaseMapper<OverseasProcurementOutbound> {
@Select("SELECT * FROM ll_overseas_procurement_outbound WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY outbound_at DESC,id DESC")
List<OverseasProcurementOutbound> listOrder(@Param("customerId") String customerId,
@Param("orderId") Long orderId);
}
@@ -4,15 +4,16 @@ 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.masterdata.quote.service.PartnerWarehouseDirectory;
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.baiyue.lastleg.security.OverseasPermissions;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
@@ -26,21 +27,26 @@ import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.HexFormat;
import java.util.Objects;
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 REVIEW_PERMISSION = OverseasPermissions.PURCHASE_REVIEW;
public static final String MANAGE_PERMISSION = OverseasPermissions.PURCHASE_EDIT;
public static final String PENDING_REVIEW = "PENDING_REVIEW";
public static final String APPROVED = "APPROVED";
public static final String REJECTED = "REJECTED";
private static final Set<String> STATUSES = Set.of(PENDING_REVIEW, APPROVED, REJECTED);
@Resource private OverseasProcurementOrderMapper orders;
@Resource private OverseasProcurementOutboundMapper outbounds;
@Resource private OverseasProcurementStatusHistoryMapper histories;
@Resource private PartnerWarehouseDirectory warehouseDirectory;
@Resource private ObjectMapper objectMapper;
@Transactional
@@ -68,7 +74,6 @@ public class OverseasProcurementService {
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);
@@ -84,10 +89,25 @@ public class OverseasProcurementService {
if (concurrent != null) return replay(customerId, concurrent, requestHash);
throw exception;
}
history(order, null, PENDING_REVIEW, operatorId, request.remark(), now);
history(order, "CREATE", null, PENDING_REVIEW, operatorId, null, request.remark(), now);
return detail(customerId, order.getOrderNo());
}
public List<OverseasProcurementResponses.CustomerOption> customerOptions(String keyword, int limit) {
if (limit < 1 || limit > 200) throw badRequest("limit must be between 1 and 200");
return orders.customerOptions(trim(keyword), limit).stream()
.map(value -> new OverseasProcurementResponses.CustomerOption(value.getCustomerId(),
Objects.requireNonNullElse(trim(value.getCustomerName()), value.getCustomerId())))
.toList();
}
public List<OverseasProcurementResponses.WarehouseOption> warehouseOptions() {
return warehouseDirectory.options().stream()
.map(value -> new OverseasProcurementResponses.WarehouseOption(value.warehouseId(),
value.warehouseCode(), value.warehouseName(), value.countryCode()))
.toList();
}
public PageResult<OverseasProcurementResponses.Summary> page(
String customerId, OverseasProcurementRequests.Query query) {
validateQuery(query);
@@ -122,10 +142,10 @@ public class OverseasProcurementService {
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());
new OverseasProcurementResponses.History(value.getOperationType(), value.getFromStatus(),
value.getToStatus(), value.getOperatorId(), value.getChangeDetail(), value.getRemark(),
value.getCreatedAt())).toList());
}
@Transactional
@@ -146,64 +166,38 @@ public class OverseasProcurementService {
order.setReviewedAt(now);
touch(order, now);
orders.updateById(order);
history(order, PENDING_REVIEW, next, operatorId, request.remark(), now);
history(order, "REVIEW", PENDING_REVIEW, next, operatorId, null, request.remark(), now);
return detail(customerId, orderNo);
}
@Transactional
public OverseasProcurementResponses.Detail update(String customerId, String orderNo,
public OverseasProcurementResponses.Detail update(String customerId, String orderNo, String operatorId,
OverseasProcurementRequests.Update request) {
OverseasProcurementOrder order = lock(customerId, orderNo);
if (!request.version().equals(order.getVersion())) throw conflict("Order was changed; refresh and retry");
Map<String, Object> before = editableSnapshot(order);
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());
patchText(request.productSpecification(), 500, "productSpecification", order::setProductSpecification);
patchText(request.productUrl(), 2000, "productUrl", order::setProductUrl);
patchWarehouse(order, request.purchaseWarehouseId(), request.purchaseWarehouseName());
patchText(request.domesticCarrier(), 128, "domesticCarrier", order::setDomesticCarrier);
patchText(request.domesticTrackingNo(), 128, "domesticTrackingNo", order::setDomesticTrackingNo);
patchText(request.overseasCarrier(), 128, "overseasCarrier", order::setOverseasCarrier);
patchText(request.overseasTrackingNo(), 128, "overseasTrackingNo", order::setOverseasTrackingNo);
patchDateTime(request.estimatedArrivalAt(), order::setEstimatedArrivalAt);
patchText(request.remark(), 1000, "remark", order::setRemark);
Map<String, Object> after = editableSnapshot(order);
if (before.equals(after)) return detail(customerId, orderNo);
LocalDateTime now = LocalDateTime.now();
touch(order, 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");
}
history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId,
changeDetail(before, after), null, now);
return detail(customerId, orderNo);
}
@@ -222,29 +216,24 @@ public class OverseasProcurementService {
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());
order.getCurrency(), order.getPurchaseWarehouseId(), order.getPurchaseWarehouseName(), inbound, inbound,
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) {
private void history(OverseasProcurementOrder order, String operationType, String from, String to,
String operator, String changeDetail, String remark, LocalDateTime now) {
OverseasProcurementStatusHistory history = new OverseasProcurementStatusHistory();
history.setId(IdWorker.getId());
history.setOrderId(order.getId());
history.setCustomerId(order.getCustomerId());
history.setOperationType(operationType);
history.setFromStatus(from);
history.setToStatus(to);
history.setOperatorId(operator);
history.setChangeDetail(changeDetail);
history.setRemark(trim(remark));
history.setCreatedAt(now);
histories.insert(history);
@@ -267,6 +256,91 @@ public class OverseasProcurementService {
}
}
private void patchWarehouse(OverseasProcurementOrder order, JsonNode warehouseId, JsonNode warehouseName) {
if (warehouseId == null) {
if (warehouseName != null) throw badRequest("purchaseWarehouseName cannot be changed without purchaseWarehouseId");
return;
}
String id = nullableText(warehouseId, 128, "purchaseWarehouseId");
if (id == null) {
order.setPurchaseWarehouseId(null);
order.setPurchaseWarehouseName(null);
return;
}
long numericId;
try {
numericId = Long.parseLong(id);
} catch (NumberFormatException exception) {
throw badRequest("purchaseWarehouseId must be a numeric overseas partner warehouse ID");
}
var option = warehouseDirectory.required(numericId);
order.setPurchaseWarehouseId(option.warehouseId());
order.setPurchaseWarehouseName(option.warehouseName());
}
private static void patchText(JsonNode node, int maxLength, String field,
java.util.function.Consumer<String> setter) {
if (node != null) setter.accept(nullableText(node, maxLength, field));
}
private static String nullableText(JsonNode node, int maxLength, String field) {
if (node.isNull()) return null;
if (!node.isTextual()) throw badRequest(field + " must be a string or null");
String value = trim(node.textValue());
if (value != null && value.length() > maxLength) throw badRequest(field + " exceeds max length " + maxLength);
return value;
}
private static void patchDateTime(JsonNode node, java.util.function.Consumer<LocalDateTime> setter) {
if (node == null) return;
String value = nullableText(node, 64, "estimatedArrivalAt");
if (value == null) {
setter.accept(null);
return;
}
try {
setter.accept(LocalDateTime.parse(value));
} catch (DateTimeParseException exception) {
throw badRequest("estimatedArrivalAt must be an ISO local date-time or null");
}
}
private static Map<String, Object> editableSnapshot(OverseasProcurementOrder order) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("purchaseCategory", order.getPurchaseCategory());
result.put("productName", order.getProductName());
result.put("productSpecification", order.getProductSpecification());
result.put("productUrl", order.getProductUrl());
result.put("inboundQuantity", value(order.getInboundQuantity()));
result.put("purchaseWarehouseId", order.getPurchaseWarehouseId());
result.put("purchaseWarehouseName", order.getPurchaseWarehouseName());
result.put("domesticCarrier", order.getDomesticCarrier());
result.put("domesticTrackingNo", order.getDomesticTrackingNo());
result.put("overseasCarrier", order.getOverseasCarrier());
result.put("overseasTrackingNo", order.getOverseasTrackingNo());
result.put("estimatedArrivalAt", order.getEstimatedArrivalAt());
result.put("remark", order.getRemark());
return result;
}
private String changeDetail(Map<String, Object> before, Map<String, Object> after) {
Map<String, Object> changes = new LinkedHashMap<>();
before.forEach((field, oldValue) -> {
Object newValue = after.get(field);
if (!Objects.equals(oldValue, newValue)) {
Map<String, Object> pair = new LinkedHashMap<>();
pair.put("before", oldValue);
pair.put("after", newValue);
changes.put(field, pair);
}
});
try {
return objectMapper.writeValueAsString(changes);
} catch (JsonProcessingException exception) {
throw new IllegalStateException("Cannot serialize procurement change detail", exception);
}
}
private static void touch(OverseasProcurementOrder order, LocalDateTime now) {
order.setVersion(value(order.getVersion()) + 1);
order.setUpdatedAt(now);
@@ -278,9 +352,6 @@ public class OverseasProcurementService {
private static void setIfText(String value, java.util.function.Consumer<String> setter) {
if (StringUtils.hasText(value)) setter.accept(value.trim());
}
private static void setIfPresent(String value, java.util.function.Consumer<String> setter) {
if (value != null) setter.accept(trim(value));
}
private static ResponseStatusException badRequest(String message) {
return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
}
@@ -6,10 +6,13 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.relabel.dto.RelabelRequests;
import com.baiyue.lastleg.relabel.dto.RelabelResponses;
import com.baiyue.lastleg.relabel.service.RelabelApplicationService;
import com.baiyue.lastleg.security.OverseasPermissions;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@@ -19,21 +22,29 @@ public class RelabelApplicationController {
@Resource private RelabelApplicationService service;
@PostMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_CREATE + "')")
@Operation(summary = "Create a relabel application", description = "Required authority: " + OverseasPermissions.RELABEL_ORDER_CREATE)
public ApiResponse<RelabelResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @Valid @RequestBody RelabelRequests.Create request) {
return ApiResponse.success(service.create(scope(user, customerId), operator(user), request));
}
@GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')")
@Operation(summary = "Page relabel applications", description = "Required authority: " + OverseasPermissions.RELABEL_ORDER_LIST)
public ApiResponse<PageResult<RelabelResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @Valid RelabelRequests.Query query) {
return ApiResponse.success(service.page(scope(user, customerId), query));
}
@GetMapping("/{relabelNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')")
@Operation(summary = "Get a relabel application", description = "Required authority: " + OverseasPermissions.RELABEL_ORDER_LIST)
public ApiResponse<RelabelResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @PathVariable String relabelNo) {
return ApiResponse.success(service.detail(scope(user, customerId), relabelNo));
}
@PutMapping("/{relabelNo}/status")
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_STATUS + "')")
@Operation(summary = "Change relabel application status", description = "Required authority: " + OverseasPermissions.RELABEL_ORDER_STATUS)
public ApiResponse<RelabelResponses.Detail> status(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @PathVariable String relabelNo,
@Valid @RequestBody RelabelRequests.ChangeStatus request) {
@@ -0,0 +1,82 @@
package com.baiyue.lastleg.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baiyue.common.event.EventTypes;
import com.baiyue.lastleg.entity.LastlegEventOutbox;
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
@Repository
public class UnloadCompletedOutboxRepository {
@Resource private LastlegEventOutboxMapper mapper;
public List<LastlegEventOutbox> pending(int limit, LocalDateTime now, LocalDateTime expiredLeaseBefore) {
return mapper.selectList(new LambdaQueryWrapper<LastlegEventOutbox>()
.eq(LastlegEventOutbox::getEventType, EventTypes.OVERSEAS_CONTAINER_UNLOADED)
.and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING")
.and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt)
.or().le(LastlegEventOutbox::getNextRetryAt, now))
.or(stale -> stale.eq(LastlegEventOutbox::getStatus, "PROCESSING")
.le(LastlegEventOutbox::getLockedAt, expiredLeaseBefore)))
.orderByAsc(LastlegEventOutbox::getCreatedAt).orderByAsc(LastlegEventOutbox::getId)
.last("LIMIT " + Math.min(Math.max(limit, 1), 100)));
}
public boolean claim(long id, String worker, LocalDateTime now, LocalDateTime expiredLeaseBefore) {
return mapper.update(null, new LambdaUpdateWrapper<LastlegEventOutbox>()
.eq(LastlegEventOutbox::getId, id)
.eq(LastlegEventOutbox::getEventType, EventTypes.OVERSEAS_CONTAINER_UNLOADED)
.and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING")
.or(stale -> stale.eq(LastlegEventOutbox::getStatus, "PROCESSING")
.le(LastlegEventOutbox::getLockedAt, expiredLeaseBefore)))
.set(LastlegEventOutbox::getStatus, "PROCESSING")
.set(LastlegEventOutbox::getLockedBy, worker)
.set(LastlegEventOutbox::getLockedAt, now)) == 1;
}
public LastlegEventOutbox claimed(long id, String worker) {
return mapper.selectOne(new LambdaQueryWrapper<LastlegEventOutbox>()
.eq(LastlegEventOutbox::getId, id)
.eq(LastlegEventOutbox::getEventType, EventTypes.OVERSEAS_CONTAINER_UNLOADED)
.eq(LastlegEventOutbox::getStatus, "PROCESSING")
.eq(LastlegEventOutbox::getLockedBy, worker));
}
public void published(long id, String worker, LocalDateTime now) {
mapper.update(null, claimedBase(id, worker)
.set(LastlegEventOutbox::getStatus, "PUBLISHED")
.set(LastlegEventOutbox::getPublishedAt, now)
.set(LastlegEventOutbox::getNextRetryAt, null)
.set(LastlegEventOutbox::getLockedBy, null)
.set(LastlegEventOutbox::getLockedAt, null)
.set(LastlegEventOutbox::getLastError, null));
}
public void retry(long id, String worker, LocalDateTime retryAt, String error) {
mapper.update(null, claimedBase(id, worker)
.setSql("retry_count = retry_count + 1")
.set(LastlegEventOutbox::getStatus, "PENDING")
.set(LastlegEventOutbox::getNextRetryAt, retryAt)
.set(LastlegEventOutbox::getLockedBy, null)
.set(LastlegEventOutbox::getLockedAt, null)
.set(LastlegEventOutbox::getLastError, truncate(error)));
}
private LambdaUpdateWrapper<LastlegEventOutbox> claimedBase(long id, String worker) {
return new LambdaUpdateWrapper<LastlegEventOutbox>()
.eq(LastlegEventOutbox::getId, id)
.eq(LastlegEventOutbox::getEventType, EventTypes.OVERSEAS_CONTAINER_UNLOADED)
.eq(LastlegEventOutbox::getStatus, "PROCESSING")
.eq(LastlegEventOutbox::getLockedBy, worker);
}
private static String truncate(String value) {
String safe = value == null || value.isBlank() ? "unknown" : value.replaceAll("[\\r\\n\\t]+", " ");
return safe.length() > 500 ? safe.substring(0, 500) : safe;
}
}
@@ -0,0 +1,46 @@
package com.baiyue.lastleg.security;
/** Canonical Admin authorities for fourth-phase overseas APIs. */
public final class OverseasPermissions {
public static final String LAST_MILE_ORDER_LIST = "platform:overseas-warehouse:last-mile-order:list";
public static final String LAST_MILE_ORDER_EDIT = "platform:overseas-warehouse:last-mile-order:edit";
public static final String LAST_MILE_ORDER_EXPORT = "platform:overseas-warehouse:last-mile-order:export";
public static final String INBOUND_ORDER_LIST = "platform:overseas-warehouse:inbound-order:list";
public static final String INBOUND_ORDER_CREATE = "platform:overseas-warehouse:inbound-order:create";
public static final String INBOUND_ORDER_EDIT = "platform:overseas-warehouse:inbound-order:edit";
public static final String RELABEL_ORDER_LIST = "platform:overseas-warehouse:relabel-order:list";
public static final String RELABEL_ORDER_CREATE = "platform:overseas-warehouse:relabel-order:create";
public static final String RELABEL_ORDER_STATUS = "platform:overseas-warehouse:relabel-order:status";
public static final String INVENTORY_LIST = "platform:overseas-warehouse:inventory:list";
public static final String INVENTORY_EXPORT = "platform:overseas-warehouse:inventory:export";
public static final String INVENTORY_EDIT = "platform:overseas-warehouse:inventory:edit";
public static final String INVENTORY_ADJUST = "platform:overseas-warehouse:inventory:adjust";
public static final String PURCHASE_LIST = "platform:overseas-business:purchase:list";
public static final String PURCHASE_EDIT = "platform:overseas-business:purchase:edit";
public static final String PURCHASE_REVIEW = "platform:overseas-business:purchase:review";
public static final String TRUCK_LIST = "platform:overseas-business:truck:list";
public static final String TRUCK_CREATE = "platform:overseas-business:truck:create";
public static final String TRUCK_EDIT = "platform:overseas-business:truck:edit";
public static final String TRUCK_STATUS = "platform:overseas-business:truck:status";
public static final String LOOSEBOARD_LIST = "platform:overseas-business:looseboard:list";
public static final String LOOSEBOARD_CREATE = "platform:overseas-business:looseboard:create";
public static final String LOOSEBOARD_EDIT = "platform:overseas-business:looseboard:edit";
public static final String LOOSEBOARD_STATUS = "platform:overseas-business:looseboard:status";
public static final String QUOTE_LIST = "platform:overseas-master-data:quote:list";
public static final String QUOTE_CREATE = "platform:overseas-master-data:quote:create";
public static final String QUOTE_EDIT = "platform:overseas-master-data:quote:edit";
public static final String QUOTE_STATUS = "platform:overseas-master-data:quote:status";
public static final String QUOTE_EXPORT = "platform:overseas-master-data:quote:export";
public static final String EXCHANGE_RATE_LIST = "platform:overseas-master-data:exchange-rate:list";
public static final String EXCHANGE_RATE_CREATE = "platform:overseas-master-data:exchange-rate:create";
public static final String EXCHANGE_RATE_EDIT = "platform:overseas-master-data:exchange-rate:edit";
public static final String EXCHANGE_RATE_STATUS = "platform:overseas-master-data:exchange-rate:status";
public static final String EXCHANGE_RATE_EXPORT = "platform:overseas-master-data:exchange-rate:export";
public static final String VEHICLE_LIST = "platform:overseas-master-data:vehicle:list";
public static final String VEHICLE_CREATE = "platform:overseas-master-data:vehicle:create";
public static final String VEHICLE_EDIT = "platform:overseas-master-data:vehicle:edit";
public static final String VEHICLE_STATUS = "platform:overseas-master-data:vehicle:status";
public static final String VEHICLE_EXPORT = "platform:overseas-master-data:vehicle:export";
private OverseasPermissions() { }
}
@@ -6,11 +6,6 @@ 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;
@@ -29,8 +24,6 @@ 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 {
@@ -41,9 +34,6 @@ public class LastlegOrderService {
@Resource private LastlegOrderMapper orderMapper;
@Resource private LastlegOrderDetailMapper detailMapper;
@Resource private LastlegOrderStatusHistoryMapper historyMapper;
@Resource private DropshipOrderProfileMapper dropshipProfiles;
@Resource private DropshipSkuInventoryMapper dropshipSkuInventory;
@Resource private DropshipCartonMapper dropshipCartons;
public PageResult<LastlegOrderSummary> page(String customerId, LastlegOrderRequests.Query query) {
Page<LastlegOrder> page = orderMapper.selectPage(
@@ -60,23 +50,9 @@ public class LastlegOrderService {
new LambdaQueryWrapper<LastlegOrderStatusHistory>()
.eq(LastlegOrderStatusHistory::getOrderId, orderId)
.orderByAsc(LastlegOrderStatusHistory::getCreatedAt));
List<LastlegOrderDetailResponse.DropshipPallet> pallets = dropshipPallets(customerId, order);
return new LastlegOrderDetailResponse(order, cargo, history, pallets);
}
private List<LastlegOrderDetailResponse.DropshipPallet> dropshipPallets(String customerId, LastlegOrder order) {
if (!"ONE_CLICK_DROPSHIP".equals(order.getOrderType())) return List.of();
var profiles = dropshipProfiles.listOrder(customerId, order.getId());
if (profiles.isEmpty()) return List.of();
Map<Long, List<DropshipSkuInventory>> skuByProfile = dropshipSkuInventory
.listOrder(customerId, order.getId()).stream().collect(Collectors.groupingBy(DropshipSkuInventory::getProfileId));
Map<Long, List<DropshipCarton>> cartonsByProfile = dropshipCartons
.listOrder(customerId, order.getId()).stream().collect(Collectors.groupingBy(DropshipCarton::getProfileId));
return profiles.stream().map(profile -> new LastlegOrderDetailResponse.DropshipPallet(
profile.getId(), profile.getSourcePalletNo(), profile.getWarehousePalletId(), profile.getPalletNo(),
profile.getInventoryPositionId(), profile.getStatus(),
skuByProfile.getOrDefault(profile.getId(), List.of()),
cartonsByProfile.getOrDefault(profile.getId(), List.of()))).toList();
return new LastlegOrderDetailResponse(summary(order), cargo, history.stream()
.map(value -> new LastlegOrderDetailResponse.History(value.getId(), value.getFromStatus(),
value.getToStatus(), value.getRemark(), value.getCreatedAt())).toList());
}
@Transactional
@@ -126,20 +102,16 @@ public class LastlegOrderService {
List<LastlegOrder> orders = orderMapper.selectList(filter(customerId, query)
.orderByDesc(LastlegOrder::getCreatedAt).last("LIMIT 10000"));
List<String> lines = new ArrayList<>();
lines.add("ID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜状态,是否异常,是否暂扣,托数,箱数,已拣箱数,已出库箱数,剩余箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,出库单号,派送单号,换标单号,换标状态,合托标记,主流程状态,子单状态,备注(仅自己),备注(客户),备注(仓库)");
lines.add("ID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜状态,是否异常,是否暂扣,托数,箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,换标单号,换标状态,合托标记,主流程状态,子单状态,备注(仅自己),备注(客户),备注(仓库)");
for (LastlegOrder order : orders) {
lines.add(String.join(",", List.of(
csv(order.getId()), csv(order.getOrderNo()), csv(order.getOrderType()), csv(order.getInboundCode()),
csv(order.getSourceOrderNo()), csv(order.getCustomerName()), csv(order.getOverseasWarehouseName()),
csv(order.getTransferWarehouseName()), csv(order.getTransferWarehouseCode()), csv(order.getFbaWarehouseCode()),
csv(order.getContainerNo()), csv(order.getContainerStatus()), csv(order.getAbnormal()), csv(order.getDetained()),
csv(order.getPalletCount()), csv(order.getPackageCount()), csv(order.getPickedPackageCount()),
csv(order.getShippedPackageCount()), csv(Math.max(0, Objects.requireNonNullElse(order.getPackageCount(), 0)
- Objects.requireNonNullElse(order.getShippedPackageCount(), 0))),
csv(order.getVolumeCbm()), csv(order.getProductQuantity()),
csv(order.getPalletCount()), csv(order.getPackageCount()), csv(order.getVolumeCbm()), csv(order.getProductQuantity()),
csv(order.getVolumeWeightKg()), csv(order.getEstimatedArrivalAt()), csv(order.getActualArrivalAt()),
csv(order.getEstimatedWarehouseAt()), csv(order.getInboundOrderNo()), csv(order.getOutboundOrderNo()),
csv(order.getDeliveryOrderNo()), csv(order.getRelabelOrderNo()), csv(order.getRelabelStatus()),
csv(order.getEstimatedWarehouseAt()), csv(order.getInboundOrderNo()), csv(order.getRelabelOrderNo()), csv(order.getRelabelStatus()),
csv(order.getPalletMergeFlag()), csv(order.getCurrentStatus()), csv(order.getSubOrderStatus()), csv(order.getPrivateRemark()),
csv(order.getCustomerRemark()), csv(order.getWarehouseRemark()))));
}
@@ -184,13 +156,11 @@ public class LastlegOrderService {
o.getInboundCode(), o.getSourceOrderNo(), o.getCustomerName(), o.getOverseasWarehouseId(),
o.getOverseasWarehouseName(), o.getTransferWarehouseId(), o.getTransferWarehouseCode(),
o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), o.getContainerStatus(),
o.getAbnormal(), o.getDetained(), o.getPalletCount(), o.getPackageCount(), o.getPickedPackageCount(),
o.getShippedPackageCount(), Math.max(0, Objects.requireNonNullElse(o.getPackageCount(), 0)
- Objects.requireNonNullElse(o.getShippedPackageCount(), 0)), o.getVolumeCbm(),
o.getAbnormal(), o.getDetained(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(),
o.getProductQuantity(), o.getVolumeWeightKg(), o.getEstimatedArrivalAt(), o.getActualArrivalAt(),
o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), o.getOutboundOrderNo(), o.getDeliveryOrderNo(),
o.getRelabelOrderNo(), o.getRelabelStatus(), o.getPalletMergeFlag(), o.getCurrentStatus(), o.getSubOrderStatus(),
o.getPrivateRemark(), o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt());
o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), o.getRelabelOrderNo(), o.getRelabelStatus(),
o.getPalletMergeFlag(), o.getCurrentStatus(), o.getSubOrderStatus(), o.getPrivateRemark(),
o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt(), o.getUpdatedAt());
}
private static String csv(Object value) {
@@ -394,12 +394,17 @@ public class WarehouseTransferService {
if (!"SHIPPED".equals(allocation.getStatus())) throw conflict("Transfer allocation is not awaiting receipt");
String operationKey = "WAREHOUSE_TRANSFER_RECEIPT:" + transferNo + ":" + allocation.getInventoryPositionId();
InventoryBalance sourceBalance = inventoryCommands.lockBalance(customerId, allocation.getInventoryBalanceId());
if (sourceBalance == null || !customerId.equals(sourceBalance.getCustomerId())) {
throw conflict("Source inventory balance is missing for warehouse transfer receipt");
}
InventoryCommandResponse result = inventory.stockIn(customerId, operatorId,
new InventoryCommandRequests.StockIn(customerId, null,
request.destinationOverseasWarehouseId().trim(), trim(request.destinationOverseasWarehouseCode()),
trim(request.destinationOverseasWarehouseName()), trim(request.destinationWarehouseId()),
request.destinationWarehouseCode().trim(), trim(request.destinationWarehouseName()),
transfer.getLastlegOrderId(), transfer.getLastlegOrderNo(), transferNo,
sourceBalance.getSkuCode(), sourceBalance.getProductNameZh(), sourceBalance.getProductNameEn(),
request.destinationPalletId(), request.destinationPalletNo().trim(),
trim(request.batchNo()) == null ? transferNo : request.batchNo().trim(), request.warehouseLocationId(),
trim(request.warehouseLocationCode()), allocation.getPackageCount(), transferNo,
@@ -45,7 +45,7 @@ public class MerchantUsTruckOrderController {
@PatchMapping("/{orderNo}")
public ApiResponse<UsTruckOrderResponses.Detail> update(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.Update request) {
return ApiResponse.success(service.update(customer(user), orderNo, request));
return ApiResponse.success(service.update(customer(user), orderNo, operator(user), request));
}
private String customer(AuthenticatedUser user) {
@@ -6,10 +6,13 @@ 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 com.baiyue.lastleg.security.OverseasPermissions;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -26,32 +29,59 @@ public class PlatformUsTruckOrderController {
@Resource private UsTruckOrderService service;
@GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_LIST + "')")
@Operation(summary = "Page local truck orders", description = "Required authority: " + OverseasPermissions.TRUCK_LIST)
public ApiResponse<PageResult<UsTruckOrderResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid UsTruckOrderRequests.Query query) {
requirePlatform(user);
return ApiResponse.success(service.page(null, query));
}
@PostMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_CREATE + "')")
@Operation(summary = "Create a local truck order", description = "Required authority: " + OverseasPermissions.TRUCK_CREATE)
public ApiResponse<UsTruckOrderResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String customerId, @Valid @RequestBody UsTruckOrderRequests.Create request) {
requirePermission(user, OverseasPermissions.TRUCK_CREATE);
return ApiResponse.success(service.create(requiredCustomer(customerId), operator(user), request));
}
@GetMapping("/{orderNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_LIST + "')")
@Operation(summary = "Get a local truck order", description = "Required authority: " + OverseasPermissions.TRUCK_LIST)
public ApiResponse<UsTruckOrderResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String customerId, @PathVariable String orderNo) {
requirePlatform(user);
return ApiResponse.success(service.detail(requiredCustomer(customerId), orderNo));
}
@PatchMapping("/{orderNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_EDIT + "')")
@Operation(summary = "Update a local truck order", description = "Required authority: " + OverseasPermissions.TRUCK_EDIT)
public ApiResponse<UsTruckOrderResponses.Detail> update(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String customerId, @PathVariable String orderNo,
@Valid @RequestBody UsTruckOrderRequests.Update request) {
requirePermission(user, OverseasPermissions.TRUCK_EDIT);
return ApiResponse.success(service.update(requiredCustomer(customerId), orderNo, operator(user), request));
}
@PatchMapping("/{orderNo}/status")
@PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')")
@Operation(summary = "Change local truck order status", description = "Required authority: " + OverseasPermissions.TRUCK_STATUS)
public ApiResponse<UsTruckOrderResponses.Detail> changeStatus(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String customerId, @PathVariable String orderNo,
@Valid @RequestBody UsTruckOrderRequests.ChangeStatus request) {
requirePermission(user);
requirePermission(user, OverseasPermissions.TRUCK_STATUS);
return ApiResponse.success(service.changeStatus(requiredCustomer(customerId), orderNo, operator(user), request));
}
@PostMapping("/{orderNo}/receive-pallets")
@PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')")
@Operation(summary = "Receive pallets for a local truck order", description = "Required authority: " + OverseasPermissions.TRUCK_STATUS)
public ApiResponse<UsTruckOrderResponses.Detail> receivePallets(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String customerId, @PathVariable String orderNo,
@Valid @RequestBody UsTruckOrderRequests.ReceivePallets request) {
requirePermission(user);
requirePermission(user, OverseasPermissions.TRUCK_STATUS);
return ApiResponse.success(service.receivePallets(requiredCustomer(customerId), orderNo, operator(user), request));
}
@@ -62,11 +92,10 @@ public class PlatformUsTruckOrderController {
}
}
private void requirePermission(AuthenticatedUser user) {
private void requirePermission(AuthenticatedUser user, String permission) {
requirePlatform(user);
if (user.permissions() == null || !user.permissions().contains(UsTruckOrderService.MANAGE_PERMISSION)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
UsTruckOrderService.MANAGE_PERMISSION + " permission is required");
if (user.permissions() == null || !user.permissions().contains(permission)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, permission + " permission is required");
}
}
@@ -12,6 +12,7 @@ import com.baiyue.lastleg.trucking.entity.UsTruckOrderStatusHistory;
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderMapper;
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderPalletMapper;
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderStatusHistoryMapper;
import com.baiyue.lastleg.security.OverseasPermissions;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
@@ -35,7 +36,7 @@ import java.util.function.Consumer;
@Service
public class UsTruckOrderService {
public static final String MANAGE_PERMISSION = "lastleg:us-trucking:manage";
public static final String MANAGE_PERMISSION = OverseasPermissions.TRUCK_STATUS;
public static final String PENDING_REVIEW = "PENDING_REVIEW";
/** Kept for source compatibility; new orders use PENDING_REVIEW. */
@Deprecated public static final String PENDING_CONFIRMATION = PENDING_REVIEW;
@@ -133,7 +134,8 @@ public class UsTruckOrderService {
}
@Transactional
public UsTruckOrderResponses.Detail update(String customerId, String orderNo, UsTruckOrderRequests.Update request) {
public UsTruckOrderResponses.Detail update(String customerId, String orderNo, String operatorId,
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_REVIEW.equals(order.getStatus())) throw conflict("Only pending review orders can be edited");
@@ -147,7 +149,9 @@ public class UsTruckOrderService {
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);
touch(order); orders.updateById(order);
history(order, order.getStatus(), order.getStatus(), operatorId, "UPDATE", LocalDateTime.now());
return detail(customerId, orderNo);
}
@Transactional
@@ -18,7 +18,7 @@ spring:
- optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true
cloud:
nacos:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8858}
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
username: ${NACOS_USERNAME:nacos}
password: ${NACOS_PASSWORD:nacos}
discovery:
@@ -35,6 +35,16 @@ spring:
management.endpoints.web.exposure.include: health,info,prometheus
baiyue:
clients:
domestic-warehouse:
url: ${DOMESTIC_WAREHOUSE_URL:}
cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m}
firstleg:
event-bridge-url: ${FIRSTLEG_EVENT_BRIDGE_URL:}
outbox-relay:
fixed-delay-ms: ${FIRSTLEG_OUTBOX_RELAY_FIXED_DELAY_MS:5000}
lease-seconds: ${FIRSTLEG_OUTBOX_RELAY_LEASE_SECONDS:60}
retry-seconds: ${FIRSTLEG_OUTBOX_RELAY_RETRY_SECONDS:30}
security:
internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes}
jwt-mode: jwks
+117 -17
View File
@@ -519,11 +519,82 @@ CREATE TABLE IF NOT EXISTS ll_event_outbox (
event_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', payload_json LONGTEXT NOT NULL COMMENT '事件载荷JSON',
status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '发布状态', retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数',
next_retry_at DATETIME(3) NULL COMMENT '下次重试时间', published_at DATETIME(3) NULL COMMENT '发布时间',
locked_at DATETIME(3) NULL COMMENT '投递租约获取时间', locked_by VARCHAR(128) NULL COMMENT '投递租约持有者',
last_error VARCHAR(1000) NULL COMMENT '最后一次发布错误', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id), UNIQUE KEY uk_ll_event_outbox_key (event_key),
KEY idx_ll_event_outbox_publish (status, next_retry_at, created_at)
) ENGINE=InnoDB COMMENT='尾程跨区业务事件发件箱';
CREATE TABLE IF NOT EXISTS ll_overseas_quote (
id BIGINT NOT NULL COMMENT '报价ID', name VARCHAR(128) NOT NULL COMMENT '报价名称',
warehouse_id BIGINT NOT NULL COMMENT '国内平台海外合作仓ID', warehouse_code VARCHAR(64) NOT NULL COMMENT '合作仓编码快照',
warehouse_name VARCHAR(128) NOT NULL COMMENT '合作仓名称快照', country_code VARCHAR(2) NOT NULL COMMENT '国家二字码快照',
provider_name VARCHAR(128) NOT NULL COMMENT '报价方', currency CHAR(3) NOT NULL COMMENT '币种',
effective_start DATE NOT NULL COMMENT '生效开始日期', effective_end DATE NOT NULL COMMENT '生效结束日期',
version_code VARCHAR(64) NOT NULL COMMENT '业务版本号', status VARCHAR(16) NOT NULL DEFAULT 'INACTIVE' COMMENT '状态:ACTIVE/INACTIVE',
remark VARCHAR(1000) NULL COMMENT '备注', record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
created_by VARCHAR(128) NOT NULL COMMENT '创建人', updated_by VARCHAR(128) NOT NULL COMMENT '更新人',
created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间',
PRIMARY KEY (id), UNIQUE KEY uk_ll_quote_warehouse_provider_version (warehouse_id,provider_name,version_code),
KEY idx_ll_quote_page (status,updated_at,id), KEY idx_ll_quote_effective (warehouse_id,provider_name,currency,effective_start,effective_end)
) ENGINE=InnoDB COMMENT='海外仓报价主表';
CREATE TABLE IF NOT EXISTS ll_overseas_quote_fee (
id BIGINT NOT NULL COMMENT '费用明细ID', quote_id BIGINT NOT NULL COMMENT '报价ID', sequence_no INT NOT NULL COMMENT '显示顺序',
item VARCHAR(128) NOT NULL COMMENT '费用项目', billing_unit VARCHAR(64) NOT NULL COMMENT '计费单位',
unit_price DECIMAL(18,4) NOT NULL COMMENT '单价', minimum_charge DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '最低收费',
remark VARCHAR(500) NULL COMMENT '备注', PRIMARY KEY (id), UNIQUE KEY uk_ll_quote_fee_sequence (quote_id,sequence_no),
KEY idx_ll_quote_fee_quote (quote_id)
) ENGINE=InnoDB COMMENT='海外仓报价仓库费用';
CREATE TABLE IF NOT EXISTS ll_overseas_quote_transit_price (
id BIGINT NOT NULL COMMENT '中转价格ID', quote_id BIGINT NOT NULL COMMENT '报价ID', sequence_no INT NOT NULL COMMENT '显示顺序',
fba_warehouse_code VARCHAR(64) NOT NULL COMMENT 'FBA仓编码', delivery_method VARCHAR(64) NOT NULL COMMENT '报价运输方式',
estimated_miles DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '预估英里数', price DECIMAL(18,4) NOT NULL COMMENT '报价金额',
minimum_charge DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '最低收费', remark VARCHAR(500) NULL COMMENT '备注',
PRIMARY KEY (id), UNIQUE KEY uk_ll_quote_transit_sequence (quote_id,sequence_no),
KEY idx_ll_quote_transit_quote (quote_id)
) ENGINE=InnoDB COMMENT='海外仓报价中转价格明细';
CREATE TABLE IF NOT EXISTS ll_monthly_exchange_rate (
id BIGINT NOT NULL COMMENT '月度汇率ID', rate_month CHAR(7) NOT NULL COMMENT '汇率月份,格式yyyy-MM',
base_currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '基准币种', quote_currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '报价币种',
usd_to_cny_average DECIMAL(18,8) NOT NULL COMMENT '美元兑人民币月平均汇率', status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE/INACTIVE',
remark VARCHAR(1000) NULL COMMENT '备注', record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
created_by VARCHAR(128) NOT NULL COMMENT '创建人', updated_by VARCHAR(128) NOT NULL COMMENT '更新人',
created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间',
PRIMARY KEY (id), UNIQUE KEY uk_ll_exchange_rate_month (rate_month),
KEY idx_ll_exchange_rate_page (status,rate_month)
) ENGINE=InnoDB COMMENT='美元兑人民币月度平均汇率';
CREATE TABLE IF NOT EXISTS ll_monthly_exchange_rate_history (
id BIGINT NOT NULL COMMENT '汇率变更历史ID', exchange_rate_id BIGINT NOT NULL COMMENT '月度汇率ID',
before_rate DECIMAL(18,8) NULL COMMENT '变更前汇率', after_rate DECIMAL(18,8) NOT NULL COMMENT '变更后汇率',
before_status VARCHAR(16) NULL COMMENT '变更前状态', after_status VARCHAR(16) NOT NULL COMMENT '变更后状态',
operator_id VARCHAR(128) NOT NULL COMMENT '操作人', remark VARCHAR(1000) NULL COMMENT '变更说明',
created_at DATETIME(3) NOT NULL COMMENT '创建时间', PRIMARY KEY (id),
KEY idx_ll_exchange_rate_history (exchange_rate_id,created_at)
) ENGINE=InnoDB COMMENT='月度汇率变更历史';
CREATE TABLE IF NOT EXISTS ll_vehicle (
id BIGINT NOT NULL COMMENT '车辆档案ID', plate_number VARCHAR(64) NOT NULL COMMENT '车牌号',
model VARCHAR(128) NOT NULL COMMENT '车型', fuel_consumption_per100_km DECIMAL(10,2) NOT NULL COMMENT '百公里油耗,单位升',
mileage_km BIGINT NOT NULL COMMENT '当前公里码,单位公里', driver_or_dispatch_company VARCHAR(128) NULL COMMENT '司机姓名或外派单位',
phone VARCHAR(64) NULL COMMENT '司机或外派单位联系电话', region VARCHAR(16) NOT NULL COMMENT '区域:DOMESTIC/OVERSEAS',
ownership VARCHAR(16) NOT NULL COMMENT '车辆归属:OWNED/DISPATCHED',
status VARCHAR(32) NOT NULL COMMENT '状态:IN_SERVICE/DISPATCHED/MAINTENANCE/INACTIVE/RETURNED',
remark VARCHAR(1000) NULL COMMENT '备注', record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
created_by VARCHAR(128) NOT NULL COMMENT '创建人', updated_by VARCHAR(128) NOT NULL COMMENT '更新人',
created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间',
PRIMARY KEY (id), UNIQUE KEY uk_ll_vehicle_plate (plate_number),
KEY idx_ll_vehicle_page (ownership,region,status,updated_at,id)
) ENGINE=InnoDB COMMENT='平台车辆主数据';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='locked_at'), 'SELECT 1', 'ALTER TABLE ll_event_outbox ADD COLUMN locked_at DATETIME(3) NULL COMMENT ''投递租约获取时间'' AFTER published_at'));
PREPARE ll_outbox_lease_stmt FROM @ddl; EXECUTE ll_outbox_lease_stmt; DEALLOCATE PREPARE ll_outbox_lease_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='locked_by'), 'SELECT 1', 'ALTER TABLE ll_event_outbox ADD COLUMN locked_by VARCHAR(128) NULL COMMENT ''投递租约持有者'' AFTER locked_at'));
PREPARE ll_outbox_lease_stmt FROM @ddl; EXECUTE ll_outbox_lease_stmt; DEALLOCATE PREPARE ll_outbox_lease_stmt;
CREATE TABLE IF NOT EXISTS ll_inbound_pda_operation (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', task_id BIGINT NOT NULL COMMENT 'PDA任务ID',
allocation_id BIGINT NULL COMMENT '分配记录ID', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键',
@@ -604,6 +675,9 @@ CREATE TABLE IF NOT EXISTS ll_inventory_balance (
inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID',
inbound_order_no VARCHAR(128) NULL COMMENT '入仓单号快照',
inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照',
sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码;无SKU的专项库存使用UNSPECIFIED',
product_name_zh VARCHAR(255) NULL COMMENT '中文品名快照',
product_name_en VARCHAR(255) NULL COMMENT '英文品名快照',
available_packages BIGINT NOT NULL DEFAULT 0 COMMENT '可用箱数', hold_packages BIGINT NOT NULL DEFAULT 0 COMMENT '冻结箱数',
allocated_packages BIGINT NOT NULL DEFAULT 0 COMMENT '已分配箱数', picked_packages BIGINT NOT NULL DEFAULT 0 COMMENT '已拣箱数',
in_transit_packages BIGINT NOT NULL DEFAULT 0 COMMENT '在途箱数', receiving_packages BIGINT NOT NULL DEFAULT 0 COMMENT '收货处理中箱数',
@@ -612,10 +686,11 @@ CREATE TABLE IF NOT EXISTS ll_inventory_balance (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_inventory_dimension (customer_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code),
UNIQUE KEY uk_ll_inventory_dimension (customer_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code,sku_code),
KEY idx_ll_inventory_list (customer_id,overseas_warehouse_id,updated_at),
KEY idx_ll_inventory_inbound (customer_id,inbound_code)
) ENGINE=InnoDB COMMENT='入仓单维度实时库存余额';
KEY idx_ll_inventory_inbound (customer_id,inbound_code),
KEY idx_ll_inventory_sku (customer_id,sku_code,updated_at)
) ENGINE=InnoDB COMMENT='入仓单与SKU维度实时库存余额';
CREATE TABLE IF NOT EXISTS ll_inventory_position (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', balance_id BIGINT NOT NULL COMMENT '库存余额记录ID',
@@ -669,13 +744,15 @@ CREATE TABLE IF NOT EXISTS ll_inbound_stock_receipt (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
plan_id BIGINT NOT NULL COMMENT '入仓计划ID', task_id BIGINT NOT NULL COMMENT 'PDA任务ID', allocation_id BIGINT NOT NULL COMMENT '分配记录ID',
line_id BIGINT NOT NULL COMMENT '入仓计划明细ID', order_id BIGINT NOT NULL COMMENT '订单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照',
sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码快照', product_name_zh VARCHAR(255) NULL COMMENT '中文品名快照',
product_name_en VARCHAR(255) NULL COMMENT '英文品名快照',
warehouse_pallet_id BIGINT NOT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(64) NOT NULL COMMENT '托盘号',
warehouse_location_id BIGINT NULL COMMENT '仓库库位ID', warehouse_location_code VARCHAR(128) NULL COMMENT '仓库库位编码',
batch_no VARCHAR(128) NOT NULL COMMENT '库存批次号', received_packages BIGINT NOT NULL COMMENT '实收箱数',
inventory_balance_id BIGINT NOT NULL COMMENT '库存余额记录ID', inventory_position_id BIGINT NOT NULL COMMENT '库存仓位记录ID',
inventory_operation_key VARCHAR(255) NOT NULL COMMENT '库存操作幂等键', received_at DATETIME(3) NOT NULL COMMENT '实收时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id), UNIQUE KEY uk_ll_stock_receipt_task_allocation (customer_id,task_id,allocation_id),
PRIMARY KEY (id), UNIQUE KEY uk_ll_stock_receipt_task_allocation_sku (customer_id,task_id,allocation_id,sku_code),
KEY idx_ll_stock_receipt_plan (customer_id,plan_id,line_id), KEY idx_ll_stock_receipt_inventory (inventory_balance_id,inventory_position_id)
) ENGINE=InnoDB COMMENT='入仓PDA库存实收快照';
@@ -1080,7 +1157,7 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order (
currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种', receiving_address VARCHAR(1000) NULL COMMENT '收货地址',
contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名', contact_phone VARCHAR(64) NOT NULL COMMENT '联系人电话',
purchase_warehouse_id VARCHAR(128) NULL COMMENT '采购入仓仓库ID', purchase_warehouse_name VARCHAR(255) NULL COMMENT '采购入仓仓库名称',
inbound_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '累计入库数量', outbound_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '累计出库数量',
inbound_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '采购单据累计入库数量',
domestic_carrier VARCHAR(128) NULL COMMENT '境内承运商', domestic_tracking_no VARCHAR(128) NULL COMMENT '境内物流单号',
overseas_carrier VARCHAR(128) NULL COMMENT '海外承运商', overseas_tracking_no VARCHAR(128) NULL COMMENT '海外物流单号',
estimated_arrival_at DATETIME(3) NULL COMMENT '预计到达时间', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态',
@@ -1095,22 +1172,14 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order (
KEY idx_ll_overseas_procurement_warehouse(customer_id,purchase_warehouse_id,created_at)
) ENGINE=InnoDB COMMENT='海外采购订单';
CREATE TABLE IF NOT EXISTS ll_overseas_procurement_outbound (
id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
quantity BIGINT NOT NULL COMMENT '数量', outbound_no VARCHAR(128) NOT NULL COMMENT '出库单号', carrier VARCHAR(128) NULL COMMENT '承运商',
tracking_no VARCHAR(128) NULL COMMENT '物流跟踪号', outbound_at DATETIME(3) NOT NULL COMMENT '出库时间',
remark VARCHAR(1000) NULL COMMENT '备注', created_by VARCHAR(128) NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', 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='海外采购出库记录';
CREATE TABLE IF NOT EXISTS ll_overseas_procurement_status_history (
id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
operation_type VARCHAR(32) NOT NULL COMMENT '操作类型:CREATE、REVIEW或UPDATE',
from_status VARCHAR(32) NULL COMMENT '变更前状态', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID',
remark VARCHAR(1000) NULL COMMENT '备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '备注',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY(id), KEY idx_ll_overseas_procurement_history(customer_id,order_id,created_at)
) ENGINE=InnoDB COMMENT='海外采购审核历史';
) ENGINE=InnoDB COMMENT='海外采购操作流水';
CREATE TABLE IF NOT EXISTS ll_us_truck_order (
id BIGINT NOT NULL COMMENT '主键ID',
@@ -1183,3 +1252,34 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order_status_history (
PRIMARY KEY(id),
KEY idx_ll_us_truck_history(customer_id,order_id,created_at)
) ENGINE=InnoDB COMMENT='美国卡车订单状态历史';
CREATE TABLE IF NOT EXISTS ll_local_looseboard_order (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
order_no VARCHAR(64) NOT NULL COMMENT '本土散板订单号', transport_mode VARCHAR(32) NOT NULL COMMENT '运输方式:拼车或优先拼载',
pallet_required VARCHAR(8) NOT NULL COMMENT '是否需要打盘:YES或NO', transport_type VARCHAR(32) NOT NULL COMMENT '运输类型',
origin VARCHAR(255) NOT NULL COMMENT '起运地', origin_address VARCHAR(500) NULL COMMENT '起运详细地址',
destination VARCHAR(255) NOT NULL COMMENT '目的地', destination_address VARCHAR(500) NULL COMMENT '目的详细地址、码头或仓号',
container_type VARCHAR(32) NULL COMMENT '柜型', package_count INT NOT NULL DEFAULT 0 COMMENT '货物件数',
total_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '总重量(千克)',
total_volume_cbm DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '总体积(立方米)',
budget_amount DECIMAL(18,2) NULL COMMENT '预算总价', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '预算币种',
contact_name VARCHAR(128) NULL COMMENT '联系人姓名', contact_info VARCHAR(128) NULL COMMENT '联系人联系方式',
requested_date DATE NULL COMMENT '要求服务日期', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ACCEPTANCE' COMMENT '本土散板订单状态',
remark VARCHAR(1000) NULL COMMENT '备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '创建幂等键',
request_hash CHAR(64) NOT NULL COMMENT '创建请求内容哈希', created_by VARCHAR(128) NULL COMMENT '创建人ID',
version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_local_looseboard_no(customer_id,order_no),
UNIQUE KEY uk_ll_local_looseboard_key(customer_id,idempotency_key),
KEY idx_ll_local_looseboard_list(customer_id,status,created_at),
KEY idx_ll_local_looseboard_date(customer_id,requested_date,created_at)
) ENGINE=InnoDB COMMENT='本土散板订单';
CREATE TABLE IF NOT EXISTS ll_local_looseboard_history (
id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '本土散板订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
operation_type VARCHAR(32) NOT NULL COMMENT '操作类型:CREATE、UPDATE或STATUS', from_status VARCHAR(32) NULL COMMENT '变更前状态',
to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID',
change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '操作备注',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
KEY idx_ll_local_looseboard_history(customer_id,order_id,created_at)
) ENGINE=InnoDB COMMENT='本土散板操作流水';
@@ -0,0 +1,312 @@
openapi: 3.0.3
info:
title: Lastleg Overseas Business API
version: 1.0.0
paths:
/api/platform/v1/last-leg/overseas-procurements:
get:
summary: 分页查询海外采购订单
description: 权限 platform:overseas-business:purchase:list;customerId 为空时查询全部客户。
security: [{ bearerAuth: [] }]
parameters:
- { name: customerId, in: query, schema: { type: string } }
- { name: page, in: query, required: true, schema: { type: integer, minimum: 1 } }
- { name: size, in: query, required: true, schema: { type: integer, minimum: 1, maximum: 200 } }
- { name: orderNo, in: query, schema: { type: string } }
- { name: customerKeyword, in: query, schema: { type: string } }
- { name: purchaseCategory, in: query, schema: { type: string } }
- { name: productName, in: query, schema: { type: string } }
- { name: status, in: query, schema: { $ref: '#/components/schemas/ProcurementStatus' } }
- { name: purchaseWarehouseId, in: query, schema: { type: string } }
responses: { '200': { description: 统一 ApiResponse 包装的采购订单分页 } }
/api/platform/v1/last-leg/overseas-procurements/customer-options:
get:
summary: 查询已有采购订单的客户候选
description: 权限 platform:overseas-business:purchase:list;候选来自尾程库采购订单客户快照,不跨库查询 IAM。
security: [{ bearerAuth: [] }]
parameters:
- { name: keyword, in: query, schema: { type: string } }
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 50 } }
responses:
'200':
description: 客户候选
content: { application/json: { schema: { $ref: '#/components/schemas/CustomerOptionResponse' } } }
/api/platform/v1/last-leg/overseas-procurements/warehouse-options:
get:
summary: 查询启用的海外合作采购仓候选
description: 权限 platform:overseas-business:purchase:list;复用服务端缓存目录,浏览器不访问国内内部 API。
security: [{ bearerAuth: [] }]
responses:
'200':
description: 采购仓候选
content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseOptionResponse' } } }
/api/platform/v1/last-leg/overseas-procurements/{orderNo}:
parameters:
- { name: orderNo, in: path, required: true, schema: { type: string } }
- { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
get:
summary: 查询海外采购订单详情及操作流水
description: 权限 platform:overseas-business:purchase:list。
security: [{ bearerAuth: [] }]
responses: { '200': { description: 统一 ApiResponse 包装的采购订单详情 } }
patch:
summary: 编辑海外采购订单
description: 权限 platform:overseas-business:purchase:edit;可选字段缺失表示不修改,显式 null 或空串表示清空。采购仓名称由 warehouse ID 对应候选快照决定。采购出库不属于本 API。
security: [{ bearerAuth: [] }]
requestBody:
required: true
content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementUpdate' } } }
responses: { '200': { description: 统一 ApiResponse 包装的采购订单详情 }, '409': { description: 乐观锁冲突或状态不允许 } }
/api/platform/v1/last-leg/overseas-procurements/{orderNo}/review:
post:
summary: 审核海外采购订单
description: 权限 platform:overseas-business:purchase:review。
security: [{ bearerAuth: [] }]
parameters:
- { name: orderNo, in: path, required: true, schema: { type: string } }
- { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
requestBody:
required: true
content: { application/json: { schema: { type: object, required: [decision], properties: { decision: { type: string, enum: [APPROVE, REJECT] }, remark: { type: string, nullable: true, maxLength: 1000 } } } } }
responses: { '200': { description: 统一 ApiResponse 包装的采购订单详情 } }
/api/merchant/v1/last-leg/overseas-procurements:
post:
summary: 客户创建海外采购订单
description: 客户 ID 只取认证身份;新单固定进入 PENDING_REVIEW。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementCreate' } } } }
responses: { '200': { description: 统一 ApiResponse 包装的采购订单详情 } }
get:
summary: 客户分页查询自己的海外采购订单
security: [{ bearerAuth: [] }]
responses: { '200': { description: 统一 ApiResponse 包装的采购订单分页 } }
/api/merchant/v1/last-leg/overseas-procurements/{orderNo}:
get:
summary: 客户查询自己的海外采购订单详情及流水
security: [{ bearerAuth: [] }]
parameters: [{ name: orderNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 统一 ApiResponse 包装的采购订单详情 } }
/api/platform/v1/last-leg/us-truck-orders:
get:
summary: 分页查询本土卡车订单
description: 权限 platform:overseas-business:truck:list。
security: [{ bearerAuth: [] }]
responses: { '200': { description: 统一 ApiResponse 包装的卡车订单分页 } }
post:
summary: 平台为指定客户创建本土卡车订单
description: 权限 platform:overseas-business:truck:create;customerId 必填且只作为可信服务端隔离键。
security: [{ bearerAuth: [] }]
parameters: [{ name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckCreate' } } } }
responses: { '200': { description: 统一 ApiResponse 包装的卡车订单详情 } }
/api/platform/v1/last-leg/us-truck-orders/{orderNo}:
parameters:
- { name: orderNo, in: path, required: true, schema: { type: string } }
- { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
get:
summary: 查询本土卡车订单详情
description: 权限 platform:overseas-business:truck:list。
security: [{ bearerAuth: [] }]
responses: { '200': { description: 统一 ApiResponse 包装的卡车订单详情 } }
patch:
summary: 平台普通编辑本土卡车订单
description: 权限 platform:overseas-business:truck:edit;仅待审核订单可编辑,不能修改状态。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckUpdate' } } } }
responses: { '200': { description: 统一 ApiResponse 包装的卡车订单详情 } }
/api/platform/v1/last-leg/us-truck-orders/{orderNo}/status:
patch:
summary: 推进本土卡车订单状态
description: 权限 platform:overseas-business:truck:status;普通编辑接口不能修改状态。
security: [{ bearerAuth: [] }]
parameters:
- { name: orderNo, in: path, required: true, schema: { type: string } }
- { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
requestBody: { required: true, content: { application/json: { schema: { type: object, required: [version, status], properties: { version: { type: integer, format: int64, minimum: 0 }, status: { $ref: '#/components/schemas/TruckStatus' }, remark: { type: string, nullable: true, maxLength: 1000 } } } } } }
responses: { '200': { description: 统一 ApiResponse 包装的卡车订单详情 }, '409': { description: 非法状态迁移 } }
/api/platform/v1/last-leg/us-truck-orders/{orderNo}/receive-pallets:
post:
summary: 记录本土卡车订单实收托盘
description: 权限 platform:overseas-business:truck:status;幂等记录实收,不创建海外仓出库或派送单。
security: [{ bearerAuth: [] }]
parameters:
- { name: orderNo, in: path, required: true, schema: { type: string } }
- { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
responses: { '200': { description: 统一 ApiResponse 包装的卡车订单详情 } }
/api/platform/v1/last-leg/local-looseboard-orders:
get:
summary: 分页查询本土散板订单
description: 权限 platform:overseas-business:looseboard:list。
security: [{ bearerAuth: [] }]
responses: { '200': { description: 统一 ApiResponse 包装的散板订单分页 } }
post:
summary: 创建本土散板订单
description: 权限 platform:overseas-business:looseboard:create;独立稳定子域,不复用卡车端点。
security: [{ bearerAuth: [] }]
parameters: [{ name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardCreate' } } } }
responses: { '200': { description: 统一 ApiResponse 包装的散板订单详情 } }
/api/platform/v1/last-leg/local-looseboard-orders/{orderNo}:
parameters:
- { name: orderNo, in: path, required: true, schema: { type: string } }
- { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
get:
summary: 查询本土散板订单详情及操作流水
description: 权限 platform:overseas-business:looseboard:list。
security: [{ bearerAuth: [] }]
responses: { '200': { description: 统一 ApiResponse 包装的散板订单详情 } }
put:
summary: 全量更新待接单的本土散板订单
description: 权限 platform:overseas-business:looseboard:edit;可空字段传 null 或空串即清空;状态由独立端点推进。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardUpdate' } } } }
responses: { '200': { description: 统一 ApiResponse 包装的散板订单详情 }, '409': { description: 版本或状态冲突 } }
/api/platform/v1/last-leg/local-looseboard-orders/{orderNo}/status:
patch:
summary: 推进或取消本土散板订单
description: 权限 platform:overseas-business:looseboard:status;只允许 PENDING_ACCEPTANCE→DISPATCHED→IN_TRANSIT→COMPLETED,未完成节点按规则取消。
security: [{ bearerAuth: [] }]
parameters:
- { name: orderNo, in: path, required: true, schema: { type: string } }
- { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
requestBody: { required: true, content: { application/json: { schema: { type: object, required: [version, status], properties: { version: { type: integer, format: int64, minimum: 0 }, status: { $ref: '#/components/schemas/LooseboardStatus' }, remark: { type: string, nullable: true, maxLength: 1000 } } } } } }
responses: { '200': { description: 统一 ApiResponse 包装的散板订单详情 }, '409': { description: 非法状态迁移 } }
components:
schemas:
ProcurementStatus: { type: string, enum: [PENDING_REVIEW, APPROVED, REJECTED] }
ProcurementCreate:
type: object
required: [purchaseCategory, productName, purchaseQuantity, contactName, contactPhone, idempotencyKey]
properties:
customerName: { type: string, nullable: true, maxLength: 255 }
purchaseCategory: { type: string, maxLength: 128 }
productName: { type: string, maxLength: 255 }
productSpecification: { type: string, nullable: true, maxLength: 500 }
productUrl: { type: string, nullable: true, maxLength: 2000 }
purchaseQuantity: { type: integer, format: int64, minimum: 1 }
budgetUnitPrice: { type: number, nullable: true, minimum: 0 }
currency: { type: string, minLength: 3, maxLength: 3, default: USD }
receivingAddress: { type: string, nullable: true, maxLength: 1000 }
contactName: { type: string, maxLength: 128 }
contactPhone: { type: string, maxLength: 64 }
remark: { type: string, nullable: true, maxLength: 1000 }
idempotencyKey: { type: string, maxLength: 255 }
NullableTextPatch: { type: string, nullable: true, description: 缺失不修改;null 或空串清空 }
ProcurementUpdate:
type: object
required: [version]
properties:
version: { type: integer, format: int64, minimum: 0 }
purchaseCategory: { type: string, maxLength: 128 }
productName: { type: string, maxLength: 255 }
productSpecification: { $ref: '#/components/schemas/NullableTextPatch' }
productUrl: { $ref: '#/components/schemas/NullableTextPatch' }
inboundQuantity: { type: integer, format: int64, minimum: 0 }
purchaseWarehouseId: { $ref: '#/components/schemas/NullableTextPatch' }
purchaseWarehouseName: { $ref: '#/components/schemas/NullableTextPatch' }
domesticCarrier: { $ref: '#/components/schemas/NullableTextPatch' }
domesticTrackingNo: { $ref: '#/components/schemas/NullableTextPatch' }
overseasCarrier: { $ref: '#/components/schemas/NullableTextPatch' }
overseasTrackingNo: { $ref: '#/components/schemas/NullableTextPatch' }
estimatedArrivalAt: { type: string, format: date-time, nullable: true, description: 缺失不修改;null 或空串清空 }
remark: { $ref: '#/components/schemas/NullableTextPatch' }
CustomerOptionResponse:
type: object
properties:
code: { type: string, enum: ['200'] }
data: { type: array, items: { type: object, properties: { customerId: { type: string }, customerName: { type: string } } } }
WarehouseOptionResponse:
type: object
properties:
code: { type: string, enum: ['200'] }
data: { type: array, items: { type: object, properties: { warehouseId: { type: string }, warehouseCode: { type: string }, warehouseName: { type: string }, countryCode: { type: string } } } }
TruckStatus: { type: string, enum: [PENDING_REVIEW, WAITING_INBOUND, RECEIVING, WAITING_CONSOLIDATION, PALLET_OVERAGE_REVIEW, PARTIALLY_ALLOCATED, ALLOCATED, PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, REJECTED, CANCELLED] }
TruckCreate:
type: object
required: [origin, destination, palletCount, totalWeightKg, totalVolumeCbm, contactName, contactInfo, requestedDate, idempotencyKey]
properties:
transportType: { type: string, enum: [PORT_PICKUP, WAREHOUSE_TO_WAREHOUSE, WAREHOUSE_TO_DOOR] }
origin: { type: string, maxLength: 255 }
originAddress: { type: string, nullable: true, maxLength: 500 }
destination: { type: string, maxLength: 255 }
destinationAddress: { type: string, nullable: true, maxLength: 500 }
containerType: { type: string, nullable: true, maxLength: 32 }
palletCount: { type: integer, minimum: 1 }
packageCount: { type: integer, nullable: true, minimum: 0 }
totalWeightKg: { type: number, minimum: 0 }
totalVolumeCbm: { type: number, minimum: 0 }
budgetAmount: { type: number, nullable: true, minimum: 0 }
currency: { type: string, minLength: 3, maxLength: 3, default: USD }
contactName: { type: string, maxLength: 128 }
contactInfo: { type: string, maxLength: 128 }
requestedDate: { type: string, format: date }
remark: { type: string, nullable: true, maxLength: 1000 }
idempotencyKey: { type: string, maxLength: 255 }
TruckUpdate:
type: object
required: [version]
properties:
version: { type: integer, format: int64, minimum: 0 }
transportType: { type: string, enum: [PORT_PICKUP, WAREHOUSE_TO_WAREHOUSE, WAREHOUSE_TO_DOOR] }
origin: { type: string, maxLength: 255 }
originAddress: { type: string, nullable: true, maxLength: 500 }
destination: { type: string, maxLength: 255 }
destinationAddress: { type: string, nullable: true, maxLength: 500 }
containerType: { type: string, nullable: true, maxLength: 32 }
palletCount: { type: integer, minimum: 1 }
packageCount: { type: integer, minimum: 0 }
totalWeightKg: { type: number, minimum: 0 }
totalVolumeCbm: { type: number, minimum: 0 }
budgetAmount: { type: number, nullable: true, minimum: 0 }
currency: { type: string, minLength: 3, maxLength: 3 }
contactName: { type: string, maxLength: 128 }
contactInfo: { type: string, maxLength: 128 }
requestedDate: { type: string, format: date }
remark: { type: string, nullable: true, maxLength: 1000 }
LooseboardStatus: { type: string, enum: [PENDING_ACCEPTANCE, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED] }
LooseboardCreate:
type: object
required: [transportMode, palletRequired, transportType, origin, destination, packageCount, totalWeightKg, totalVolumeCbm, idempotencyKey]
properties:
transportMode: { type: string, enum: [SHARED_TRUCK, PRIORITY_CONSOLIDATION] }
palletRequired: { type: string, enum: [YES, NO] }
transportType: { type: string, enum: [PORT_PICKUP, WAREHOUSE_TO_WAREHOUSE, WAREHOUSE_TO_DOOR] }
origin: { type: string, maxLength: 255 }
originAddress: { type: string, nullable: true, maxLength: 500 }
destination: { type: string, maxLength: 255 }
destinationAddress: { type: string, nullable: true, maxLength: 500 }
containerType: { type: string, nullable: true, maxLength: 32 }
packageCount: { type: integer, minimum: 0 }
totalWeightKg: { type: number, minimum: 0 }
totalVolumeCbm: { type: number, minimum: 0 }
budgetAmount: { type: number, nullable: true, minimum: 0 }
currency: { type: string, minLength: 3, maxLength: 3, default: USD }
contactName: { type: string, nullable: true, maxLength: 128 }
contactInfo: { type: string, nullable: true, maxLength: 128 }
requestedDate: { type: string, format: date, nullable: true }
remark: { type: string, nullable: true, maxLength: 1000 }
idempotencyKey: { type: string, maxLength: 255 }
LooseboardUpdate:
type: object
required: [version, transportMode, palletRequired, transportType, origin, destination, packageCount, totalWeightKg, totalVolumeCbm]
properties:
version: { type: integer, format: int64, minimum: 0 }
transportMode: { type: string, enum: [SHARED_TRUCK, PRIORITY_CONSOLIDATION] }
palletRequired: { type: string, enum: [YES, NO] }
transportType: { type: string, enum: [PORT_PICKUP, WAREHOUSE_TO_WAREHOUSE, WAREHOUSE_TO_DOOR] }
origin: { type: string, maxLength: 255 }
originAddress: { type: string, nullable: true, maxLength: 500 }
destination: { type: string, maxLength: 255 }
destinationAddress: { type: string, nullable: true, maxLength: 500 }
containerType: { type: string, nullable: true, maxLength: 32 }
packageCount: { type: integer, minimum: 0 }
totalWeightKg: { type: number, minimum: 0 }
totalVolumeCbm: { type: number, minimum: 0 }
budgetAmount: { type: number, nullable: true, minimum: 0 }
currency: { type: string, minLength: 3, maxLength: 3, default: USD }
contactName: { type: string, nullable: true, maxLength: 128 }
contactInfo: { type: string, nullable: true, maxLength: 128 }
requestedDate: { type: string, format: date, nullable: true }
remark: { type: string, nullable: true, maxLength: 1000 }
securitySchemes:
bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
@@ -0,0 +1,273 @@
openapi: 3.0.3
info:
title: Lastleg Overseas Master Data API
version: 1.0.0
description: 海外仓报价、月度汇率与车辆主数据;车辆契约不包含派送履约历史。
paths:
/api/platform/v1/last-leg/master-data/overseas-quotes:
get:
summary: 分页查询海外仓报价
description: '权限:platform:overseas-master-data:quote:list'
parameters:
- $ref: '#/components/parameters/Page'
- $ref: '#/components/parameters/Size'
- { name: keyword, in: query, schema: { type: string } }
- { name: warehouseId, in: query, schema: { type: string } }
- { name: providerName, in: query, schema: { type: string } }
- { name: currency, in: query, schema: { type: string } }
- { name: status, in: query, schema: { $ref: '#/components/schemas/ActiveStatus' } }
responses: { '200': { description: 业务响应,data为PageResult<QuoteSummary> } }
post:
summary: 创建海外仓报价
description: '权限:platform:overseas-master-data:quote:create;服务端读取国内overseas-partners并缓存合作仓目录30分钟'
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/QuoteCreate' } } } }
responses: { '200': { description: 业务响应,data为报价详情 } }
/api/platform/v1/last-leg/master-data/overseas-quotes/warehouse-options:
get:
summary: 查询缓存的有效海外合作仓选项
description: '权限:platform:overseas-master-data:quote:list;浏览器不直接调用国内内部接口'
responses: { '200': { description: 业务响应 } }
/api/platform/v1/last-leg/master-data/overseas-quotes/export:
get:
summary: 导出海外仓报价
description: '权限:platform:overseas-master-data:quote:export;UTF-8 BOM CSV'
parameters:
- { name: keyword, in: query, schema: { type: string } }
- { name: warehouseId, in: query, schema: { type: string } }
- { name: providerName, in: query, schema: { type: string } }
- { name: currency, in: query, schema: { type: string } }
- { name: status, in: query, schema: { $ref: '#/components/schemas/ActiveStatus' } }
responses: { '200': { description: CSV, content: { text/csv: { schema: { type: string, format: binary } } } } }
/api/platform/v1/last-leg/master-data/overseas-quotes/{id}:
parameters:
- { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
get:
summary: 查询海外仓报价详情
description: '权限:platform:overseas-master-data:quote:list'
responses: { '200': { description: 业务响应 } }
put:
summary: 编辑未生效的海外仓报价
description: '权限:platform:overseas-master-data:quote:edit;recordVersion乐观锁'
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/QuoteUpdate' } } } }
responses: { '200': { description: 业务响应 } }
/api/platform/v1/last-leg/master-data/overseas-quotes/{id}/status:
patch:
summary: 启停海外仓报价
description: '权限:platform:overseas-master-data:quote:status'
parameters:
- { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/QuoteStatusChange' } } } }
responses: { '200': { description: 业务响应 } }
/api/platform/v1/last-leg/master-data/exchange-rates:
get:
summary: 分页查询月度汇率
description: '权限:platform:overseas-master-data:exchange-rate:list'
parameters:
- $ref: '#/components/parameters/Page'
- $ref: '#/components/parameters/Size'
- { name: monthFrom, in: query, schema: { type: string, pattern: '^\d{4}-(0[1-9]|1[0-2])$' } }
- { name: monthTo, in: query, schema: { type: string, pattern: '^\d{4}-(0[1-9]|1[0-2])$' } }
- { name: status, in: query, schema: { $ref: '#/components/schemas/ActiveStatus' } }
responses: { '200': { description: 业务响应,data为PageResult<MonthlyExchangeRate> } }
post:
summary: 创建月度汇率
description: '权限:platform:overseas-master-data:exchange-rate:create;月份唯一,初始状态ACTIVE'
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ExchangeRateCreate' } } } }
responses: { '200': { description: 业务响应,data为MonthlyExchangeRate } }
/api/platform/v1/last-leg/master-data/exchange-rates/summary:
get:
summary: 查询最新有效月度汇率摘要
description: '权限:platform:overseas-master-data:exchange-rate:list'
responses: { '200': { description: 业务响应,data含latestMonth/latestUsdToCnyAverage/updatedBy/updatedAt } }
/api/platform/v1/last-leg/master-data/exchange-rates/export:
get:
summary: 导出筛选后的全部月度汇率
description: '权限:platform:overseas-master-data:exchange-rate:export;UTF-8 BOM CSV'
parameters:
- { name: monthFrom, in: query, schema: { type: string } }
- { name: monthTo, in: query, schema: { type: string } }
- { name: status, in: query, schema: { $ref: '#/components/schemas/ActiveStatus' } }
responses: { '200': { description: CSV, content: { text/csv: { schema: { type: string, format: binary } } } } }
/api/platform/v1/last-leg/master-data/exchange-rates/{month}:
parameters:
- { name: month, in: path, required: true, schema: { type: string, pattern: '^\d{4}-(0[1-9]|1[0-2])$' } }
get:
summary: 查询月度汇率详情
description: '权限:platform:overseas-master-data:exchange-rate:list'
responses: { '200': { description: 业务响应,data为MonthlyExchangeRate } }
put:
summary: 编辑月度汇率
description: '权限:platform:overseas-master-data:exchange-rate:edit;recordVersion冲突返回业务错误'
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ExchangeRateUpdate' } } } }
responses: { '200': { description: 业务响应 } }
/api/platform/v1/last-leg/master-data/exchange-rates/{month}/status:
patch:
summary: 变更月度汇率状态
description: '权限:platform:overseas-master-data:exchange-rate:status'
parameters:
- { name: month, in: path, required: true, schema: { type: string } }
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ExchangeRateStatus' } } } }
responses: { '200': { description: 业务响应 } }
/api/platform/v1/last-leg/master-data/vehicles:
get:
summary: 分页查询车辆主数据
description: '权限:platform:overseas-master-data:vehicle:list'
parameters:
- $ref: '#/components/parameters/Page'
- $ref: '#/components/parameters/Size'
- { name: keyword, in: query, description: 车牌/车型/司机或外派单位, schema: { type: string } }
- { name: ownership, in: query, schema: { $ref: '#/components/schemas/VehicleOwnership' } }
- { name: region, in: query, schema: { $ref: '#/components/schemas/VehicleRegion' } }
- { name: status, in: query, schema: { $ref: '#/components/schemas/VehicleStatus' } }
responses: { '200': { description: 业务响应,data为PageResult<Vehicle> } }
post:
summary: 创建车辆主数据
description: '权限:platform:overseas-master-data:vehicle:create;车牌号唯一'
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/VehicleCreate' } } } }
responses: { '200': { description: 业务响应,data为Vehicle } }
/api/platform/v1/last-leg/master-data/vehicles/export:
get:
summary: 导出筛选后的全部车辆主数据
description: '权限:platform:overseas-master-data:vehicle:export;UTF-8 BOM CSV'
parameters:
- { name: keyword, in: query, schema: { type: string } }
- { name: ownership, in: query, schema: { $ref: '#/components/schemas/VehicleOwnership' } }
- { name: region, in: query, schema: { $ref: '#/components/schemas/VehicleRegion' } }
- { name: status, in: query, schema: { $ref: '#/components/schemas/VehicleStatus' } }
responses: { '200': { description: CSV, content: { text/csv: { schema: { type: string, format: binary } } } } }
/api/platform/v1/last-leg/master-data/vehicles/{id}:
parameters:
- { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
get:
summary: 查询车辆档案详情
description: '权限:platform:overseas-master-data:vehicle:list;不包含派送履约或车辆更换历史'
responses: { '200': { description: 业务响应,data为Vehicle } }
put:
summary: 编辑车辆主数据
description: '权限:platform:overseas-master-data:vehicle:edit;状态由独立接口变更'
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/VehicleUpdate' } } } }
responses: { '200': { description: 业务响应 } }
/api/platform/v1/last-leg/master-data/vehicles/{id}/status:
patch:
summary: 变更车辆状态
description: '权限:platform:overseas-master-data:vehicle:status;使用recordVersion乐观锁'
parameters:
- { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/VehicleStatusChange' } } } }
responses: { '200': { description: 业务响应 } }
components:
parameters:
Page: { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
Size: { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
schemas:
ActiveStatus: { type: string, enum: [ACTIVE, INACTIVE] }
QuoteFee:
type: object
required: [item, billingUnit, unitPrice]
properties:
item: { type: string, maxLength: 128 }
billingUnit: { type: string, maxLength: 64 }
unitPrice: { type: number, minimum: 0 }
minimumCharge: { type: number, minimum: 0, nullable: true }
remark: { type: string, maxLength: 500, nullable: true }
QuoteTransitPrice:
type: object
required: [fbaWarehouseCode, deliveryMethod, price]
properties:
fbaWarehouseCode: { type: string, maxLength: 64 }
deliveryMethod: { type: string, maxLength: 64 }
estimatedMiles: { type: number, minimum: 0, nullable: true }
price: { type: number, minimum: 0 }
minimumCharge: { type: number, minimum: 0, nullable: true }
remark: { type: string, maxLength: 500, nullable: true }
QuoteCreate:
type: object
required: [name, warehouseId, providerName, currency, effectiveStart, effectiveEnd, versionCode, warehouseFees]
properties:
name: { type: string, maxLength: 128 }
warehouseId: { type: string, description: 国内平台海外合作仓ID }
providerName: { type: string, maxLength: 128 }
currency: { type: string, minLength: 3, maxLength: 3 }
effectiveStart: { type: string, format: date }
effectiveEnd: { type: string, format: date }
versionCode: { type: string, maxLength: 64 }
warehouseFees: { type: array, minItems: 1, items: { $ref: '#/components/schemas/QuoteFee' } }
transitPrices: { type: array, items: { $ref: '#/components/schemas/QuoteTransitPrice' } }
remark: { type: string, maxLength: 1000, nullable: true }
QuoteUpdate:
type: object
required: [recordVersion, name, providerName, currency, effectiveStart, effectiveEnd, warehouseFees]
properties:
recordVersion: { type: integer, format: int64, minimum: 0 }
name: { type: string, maxLength: 128 }
providerName: { type: string, maxLength: 128 }
currency: { type: string, minLength: 3, maxLength: 3 }
effectiveStart: { type: string, format: date }
effectiveEnd: { type: string, format: date }
warehouseFees: { type: array, minItems: 1, items: { $ref: '#/components/schemas/QuoteFee' } }
transitPrices: { type: array, items: { $ref: '#/components/schemas/QuoteTransitPrice' } }
remark: { type: string, maxLength: 1000, nullable: true }
QuoteStatusChange:
type: object
required: [status, recordVersion]
properties:
status: { $ref: '#/components/schemas/ActiveStatus' }
recordVersion: { type: integer, format: int64, minimum: 0 }
ExchangeRateCreate:
type: object
required: [month, usdToCnyAverage]
properties:
month: { type: string, example: '2026-08' }
usdToCnyAverage: { type: number, exclusiveMinimum: true, minimum: 0, example: 7.18540000 }
remark: { type: string, maxLength: 1000, nullable: true }
ExchangeRateUpdate:
type: object
required: [recordVersion, usdToCnyAverage]
properties:
recordVersion: { type: integer, format: int64, minimum: 0 }
usdToCnyAverage: { type: number, exclusiveMinimum: true, minimum: 0 }
remark: { type: string, maxLength: 1000, nullable: true }
ExchangeRateStatus:
type: object
required: [status, recordVersion]
properties:
status: { $ref: '#/components/schemas/ActiveStatus' }
recordVersion: { type: integer, format: int64, minimum: 0 }
remark: { type: string, maxLength: 500, nullable: true }
VehicleOwnership: { type: string, enum: [OWNED, DISPATCHED] }
VehicleRegion: { type: string, enum: [DOMESTIC, OVERSEAS] }
VehicleStatus: { type: string, enum: [IN_SERVICE, DISPATCHED, MAINTENANCE, INACTIVE, RETURNED] }
VehicleCreate:
type: object
required: [plateNumber, model, fuelConsumptionPer100Km, mileageKm, region, ownership, status]
properties:
plateNumber: { type: string, maxLength: 64 }
model: { type: string, maxLength: 128 }
fuelConsumptionPer100Km: { type: number, exclusiveMinimum: true, minimum: 0 }
mileageKm: { type: integer, format: int64, minimum: 0 }
driverOrDispatchCompany: { type: string, maxLength: 128, nullable: true }
phone: { type: string, maxLength: 64, nullable: true }
region: { $ref: '#/components/schemas/VehicleRegion' }
ownership: { $ref: '#/components/schemas/VehicleOwnership' }
status: { $ref: '#/components/schemas/VehicleStatus' }
remark: { type: string, maxLength: 1000, nullable: true }
VehicleUpdate:
type: object
required: [recordVersion, plateNumber, model, fuelConsumptionPer100Km, mileageKm, region, ownership]
properties:
recordVersion: { type: integer, format: int64, minimum: 0 }
plateNumber: { type: string, maxLength: 64 }
model: { type: string, maxLength: 128 }
fuelConsumptionPer100Km: { type: number, exclusiveMinimum: true, minimum: 0 }
mileageKm: { type: integer, format: int64, minimum: 0, description: 不得小于已有公里码 }
driverOrDispatchCompany: { type: string, maxLength: 128, nullable: true }
phone: { type: string, maxLength: 64, nullable: true }
region: { $ref: '#/components/schemas/VehicleRegion' }
ownership: { $ref: '#/components/schemas/VehicleOwnership' }
remark: { type: string, maxLength: 1000, nullable: true }
VehicleStatusChange:
type: object
required: [status, recordVersion]
properties:
status: { $ref: '#/components/schemas/VehicleStatus' }
recordVersion: { type: integer, format: int64, minimum: 0 }
@@ -1,7 +1,10 @@
package com.baiyue.lastleg.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.dto.LastlegOrderRequests;
import com.baiyue.lastleg.dto.LastlegOrderSummary;
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
import com.baiyue.lastleg.service.LastlegOrderService;
import com.baiyue.lastleg.service.LoadingCompletedConsumer;
@@ -12,6 +15,7 @@ import org.springframework.test.util.ReflectionTestUtils;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
@@ -24,7 +28,9 @@ class LastlegOrderControllerTest {
@Test
void customerCannotOverrideItsTenantScope() {
controller.list(user(100L, "MERCHANT"), "200", query);
ApiResponse<PageResult<LastlegOrderSummary>> response =
controller.list(user(100L, "MERCHANT"), "200", query);
assertNotNull(response);
verify(service).page(eq("100"), eq(query));
}
@@ -0,0 +1,61 @@
package com.baiyue.lastleg.controller;
import com.baiyue.lastleg.delivery.controller.DeliveryTransferController;
import com.baiyue.lastleg.dispatch.controller.DispatchController;
import com.baiyue.lastleg.dispatch.pda.controller.DispatchPdaController;
import com.baiyue.lastleg.inboundplan.pda.controller.InboundPalletAssignmentController;
import com.baiyue.lastleg.inboundplan.pda.controller.InboundPdaController;
import com.baiyue.lastleg.inventory.command.controller.InventoryCommandController;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.web.bind.annotation.RequestMapping;
import java.util.Arrays;
import java.util.Set;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class Phase4BrowserApiBoundaryTest {
@Test
void inboundPdaAndUnloadPhotoApisUseThePdaAccessDomain() {
assertTrue(basePath(InboundPdaController.class).startsWith("/api/pda/v1/"));
assertTrue(basePath(InboundPalletAssignmentController.class).startsWith("/api/pda/v1/"));
assertFalse(basePath(InboundPdaController.class).contains("/api/platform/"));
}
@Test
void excludedOutboundControllersAreDisabledUnlessExplicitlyEnabledLater() {
for (Class<?> type : new Class<?>[] {
DispatchController.class, DeliveryTransferController.class, DispatchPdaController.class
}) {
ConditionalOnProperty condition = type.getAnnotation(ConditionalOnProperty.class);
assertTrue(condition != null, type.getSimpleName());
assertTrue(Arrays.asList(condition.name()).contains("lastleg.future-outbound-api.enabled"));
assertFalse(condition.matchIfMissing());
}
}
@Test
void platformInventoryCommandsDoNotMapOutboundAllocationPickingOrShipping() {
Set<String> paths = Arrays.stream(InventoryCommandController.class.getDeclaredMethods())
.flatMap(method -> Arrays.stream(method.getAnnotations()))
.filter(annotation -> annotation.annotationType().getName().startsWith("org.springframework.web.bind.annotation"))
.flatMap(annotation -> {
try {
return Arrays.stream((String[]) annotation.annotationType().getMethod("value").invoke(annotation));
} catch (ReflectiveOperationException ignored) {
return java.util.stream.Stream.empty();
}
}).collect(Collectors.toSet());
assertFalse(paths.contains("/allocate"));
assertFalse(paths.contains("/unallocate"));
assertFalse(paths.contains("/pick"));
assertFalse(paths.contains("/ship"));
}
private static String basePath(Class<?> type) {
return type.getAnnotation(RequestMapping.class).value()[0];
}
}
@@ -1,7 +1,6 @@
package com.baiyue.lastleg.delivery.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
import com.baiyue.lastleg.delivery.dto.DeliveryResponses;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
@@ -204,32 +203,20 @@ class DeliveryTransferServiceTest {
}
@Test
void outboundDetailReturnsPagedPalletDestinationPackagesAndInboundCode() {
void outboundDetailDoesNotExposeExcludedPalletOrOutboundFulfillmentRows() {
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101");
DeliveryOrder delivery = delivery(401L, outbound, "DEL-401");
OutboundPalletItem first = pallet(501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12);
OutboundPalletItem second = pallet(502L, "PLT-502", "LAX9", "Los Angeles", "IN-B", 8);
when(outboundOrders.findByNo("TENANT-A", "OUT-301")).thenReturn(outbound);
when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery);
when(outboundPallets.selectPage(any(Page.class), any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked") Page<OutboundPalletItem> page = invocation.getArgument(0);
page.setRecords(List.of(first, second));
page.setTotal(2);
return page;
});
DeliveryResponses.OutboundDetail detail = service.outboundDetail("TENANT-A", "OUT-301",
new DeliveryRequests.PalletQuery(1, 20, null));
PageResult<DeliveryResponses.PalletRow> pallets = detail.pallets();
assertEquals(2, pallets.total());
assertEquals(2, pallets.records().size());
assertEquals("ONT8", pallets.records().get(0).destinationWarehouseCode());
assertEquals("Ontario", pallets.records().get(0).destinationWarehouseName());
assertEquals(12, pallets.records().get(0).packageCount());
assertEquals("IN-A", pallets.records().get(0).inboundCode());
assertEquals("LAX9", pallets.records().get(1).destinationWarehouseCode());
assertEquals(8, pallets.records().get(1).packageCount());
assertEquals("OUT-301", detail.outbound().outboundNo());
assertEquals("DEL-401", detail.delivery().deliveryNo());
assertTrue(java.util.Arrays.stream(DeliveryResponses.OutboundDetail.class.getRecordComponents())
.noneMatch(component -> "pallets".equals(component.getName())));
verify(outboundPallets, never()).selectPage(any(Page.class), any());
}
@Test
@@ -10,11 +10,13 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
class InboundPlanSchemaContractTest {
@Test
void planIsUniqueByTenantAndActualContainerNumber() throws IOException {
void planIsUniqueByCustomerAndSourceOrderWhileContainerCanBeShared() throws IOException {
try (var stream = getClass().getClassLoader().getResourceAsStream("db/lastleg.sql")) {
assertTrue(stream != null, "lastleg schema must be available");
String schema = new String(stream.readAllBytes(), StandardCharsets.UTF_8).toLowerCase();
assertTrue(schema.contains("unique key uk_ll_inbound_plan_container (customer_id, container_no)"));
assertTrue(schema.contains("unique key uk_ll_inbound_plan_source_order (customer_id, source_order_id)"));
assertTrue(schema.contains("key idx_ll_inbound_plan_container (customer_id, container_no)"));
assertFalse(schema.contains("unique key uk_ll_inbound_plan_container"));
assertTrue(schema.contains("default 'pending_assignment'"));
assertTrue(schema.contains("required_package_count"));
assertTrue(schema.contains("planned_package_count"));
@@ -0,0 +1,28 @@
package com.baiyue.lastleg.inboundplan.controller;
import io.swagger.v3.oas.annotations.Operation;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Map;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
class InboundPlanPermissionContractTest {
private static final String LIST = "platform:overseas-warehouse:inbound-order:list";
@Test
void listAndDetailUseOnlyCanonicalInboundOrderListAuthority() {
Map<String, Method> methods = Arrays.stream(InboundPlanController.class.getDeclaredMethods())
.collect(Collectors.toMap(Method::getName, method -> method));
for (String name : new String[] {"page", "detail", "fbaAppointmentDetail"}) {
Method method = methods.get(name);
assertEquals("hasAuthority('" + LIST + "')", method.getAnnotation(PreAuthorize.class).value());
assertEquals("Required authority: " + LIST, method.getAnnotation(Operation.class).description());
}
}
}
@@ -13,6 +13,7 @@ import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.dropship.service.DropshipOrderService;
@@ -42,6 +43,7 @@ import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class InboundStockReceiptServiceTest {
@Mock LastlegOrderMapper orders;
@Mock LastlegOrderDetailMapper orderDetails;
@Mock InboundStockReceiptMapper receipts;
@Mock InventoryCommandService inventory;
@Mock DropshipOrderService dropshipOrders;
@@ -52,6 +54,7 @@ class InboundStockReceiptServiceTest {
void setUp() {
service = new InboundStockReceiptService();
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "orderDetails", orderDetails);
ReflectionTestUtils.setField(service, "receipts", receipts);
ReflectionTestUtils.setField(service, "inventory", inventory);
ReflectionTestUtils.setField(service, "dropshipOrders", dropshipOrders);
@@ -74,6 +77,7 @@ class InboundStockReceiptServiceTest {
assertEquals(5L, request.getValue().packages());
assertEquals(101L, request.getValue().inboundOrderId());
assertEquals("IN-1", request.getValue().inboundCode());
assertEquals("SKU-1", request.getValue().skuCode());
ArgumentCaptor<InboundStockReceipt> receipt = ArgumentCaptor.forClass(InboundStockReceipt.class);
verify(receipts).insert(receipt.capture());
assertEquals(5L, receipt.getValue().getReceivedPackages());
@@ -84,10 +88,11 @@ class InboundStockReceiptServiceTest {
}
@Test
void storesOneInboundOrderAcrossMultiplePalletsWithoutSkuSplitting() {
void splitsOneInboundOrderAcrossSkuAndPalletDimensions() {
Fixture fixture = fixture(10, 5, 5, 10);
fixture.detail.setPackageCount(5);
LastlegOrderDetail second = detail(202L, "SKU-2", 5, 7);
when(orderDetails.selectList(any())).thenReturn(List.of(fixture.detail, second));
InboundPlanPallet secondPallet = pallet(22L, 2002L);
InboundPlanPalletAllocation secondAllocation = allocation(32L, 12L, 22L, 5);
when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order));
@@ -187,6 +192,7 @@ class InboundStockReceiptServiceTest {
order.setCurrentStatus("INBOUND_PLANNED");
org.mockito.Mockito.lenient().when(orders.lockById("100", 101L)).thenReturn(order);
LastlegOrderDetail detail = detail(201L, "SKU-1", required, quantity);
org.mockito.Mockito.lenient().when(orderDetails.selectList(any())).thenReturn(List.of(detail));
return new Fixture(plan, task, line, order, detail, allocation(31L, 12L, 21L, picked), pallet(21L, 2001L));
}
@@ -0,0 +1,98 @@
package com.baiyue.lastleg.inboundplan.pda.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.OverseasContainerUnloadedV1;
import com.baiyue.common.event.RegionCode;
import com.baiyue.lastleg.client.EventBridgeClient;
import com.baiyue.lastleg.entity.LastlegEventOutbox;
import com.baiyue.lastleg.repository.UnloadCompletedOutboxRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
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.time.Instant;
import java.time.LocalDateTime;
import java.util.List;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class InboundUnloadCompletedOutboxRelayTest {
@Mock private UnloadCompletedOutboxRepository repository;
@Mock private EventBridgeClient client;
private ObjectMapper objectMapper;
private InboundUnloadCompletedOutboxRelay relay;
@BeforeEach
void setUp() {
objectMapper = new ObjectMapper().registerModule(new JavaTimeModule());
relay = new InboundUnloadCompletedOutboxRelay();
ReflectionTestUtils.setField(relay, "repository", repository);
ReflectionTestUtils.setField(relay, "client", client);
ReflectionTestUtils.setField(relay, "objectMapper", objectMapper);
ReflectionTestUtils.setField(relay, "internalToken", "internal-token");
ReflectionTestUtils.setField(relay, "leaseSeconds", 60L);
ReflectionTestUtils.setField(relay, "retrySeconds", 30L);
}
@Test
void marksOutboxPublishedAfterDomesticBridgeAcknowledgesSameEvent() throws Exception {
EventEnvelope event = event();
LastlegEventOutbox row = row(event);
when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row));
when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true);
when(repository.claimed(eq(9001L), anyString())).thenReturn(row);
when(client.receive(eq("internal-token"), any(EventEnvelope.class)))
.thenReturn(ApiResponse.success(new EventBridgeClient.Acknowledgement(
event.eventId(), "已接收", "2026-08-20T03:01:00")));
relay.relay();
verify(repository).published(eq(9001L), anyString(), any(LocalDateTime.class));
verify(repository, never()).retry(anyLong(), anyString(), any(), anyString());
}
@Test
void releasesLeaseForRetryWhenBridgeDeliveryFails() throws Exception {
EventEnvelope event = event();
LastlegEventOutbox row = row(event);
when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row));
when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true);
when(repository.claimed(eq(9001L), anyString())).thenReturn(row);
when(client.receive(eq("internal-token"), any(EventEnvelope.class)))
.thenThrow(new IllegalStateException("bridge unavailable"));
relay.relay();
verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class), eq("bridge unavailable"));
verify(repository, never()).published(anyLong(), anyString(), any());
}
private LastlegEventOutbox row(EventEnvelope event) throws Exception {
LastlegEventOutbox row = new LastlegEventOutbox();
row.setId(9001L);
row.setEventKey("OverseasContainerUnloaded:7001");
row.setPayloadJson(objectMapper.writeValueAsString(event));
return row;
}
private EventEnvelope event() {
var payload = objectMapper.valueToTree(new OverseasContainerUnloadedV1("1001", "MSCU1234567",
Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null));
return new EventEnvelope("00000000-0000-4000-8000-000000000009",
EventTypes.OVERSEAS_CONTAINER_UNLOADED, 1, Instant.parse("2026-08-20T03:00:01Z"),
RegionCode.OVERSEAS, RegionCode.CN, "1", "CONTAINER_BOOKING", "1001", 7L, 1L,
"trace-12345678", "correlation-12345678", null, payload,
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
}
}
@@ -1,6 +1,8 @@
package com.baiyue.lastleg.inboundplan.service;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanSummary;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
@@ -22,6 +24,7 @@ import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.List;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -115,6 +118,35 @@ class InboundPlanServiceTest {
verify(orderMapper, never()).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any());
}
@Test
void pagesInboundPlansWithinCustomerScope() {
InboundPlanRequests.Query query = new InboundPlanRequests.Query(
2, 20, "LIP", "RK", "PENDING_ASSIGNMENT", "WH-1", "ONT8", "ONT8",
"NOT_APPOINTED", null, null);
InboundPlanSummary row = new InboundPlanSummary(1L, "LIP1", 2L, "100", "Customer A",
"FBA", "RK-1", "CONT-1", "WH-1", "LA Warehouse", "ONT8", "ONT8", "ONT8",
"ONT8", 10, BigDecimal.ONE, 1, "PENDING_ASSIGNMENT", "NOT_APPOINTED", null, null);
when(planMapper.countRows("100", query)).thenReturn(21L);
when(planMapper.pageRows("100", query, 20L, 20L)).thenReturn(List.of(row));
var result = service.page("100", query);
assertEquals(21L, result.total());
assertEquals(2L, result.pages());
assertEquals("RK-1", result.records().get(0).inboundCode());
verify(planMapper).pageRows("100", query, 20L, 20L);
}
@Test
void rejectsReversedInboundPlanDateRange() {
InboundPlanRequests.Query query = new InboundPlanRequests.Query(1, 20, null, null, null, null,
null, null, null, LocalDateTime.of(2026, 8, 20, 0, 0),
LocalDateTime.of(2026, 8, 19, 0, 0));
assertThrows(ResponseStatusException.class, () -> service.page("100", query));
verify(planMapper, never()).countRows(any(), any());
}
@Test
void rejectsMissingDestinationWarehouse() {
SourceOrderSnapshot invalid = new SourceOrderSnapshot(1L, "100", "CONT-1", "RK-1",
@@ -28,12 +28,13 @@ class InventorySchemaContractTest {
}
assertTrue(schema.contains("create table if not exists ll_inventory_balance"));
assertTrue(schema.contains("unique key uk_ll_inventory_dimension (customer_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code)"));
assertTrue(schema.contains("unique key uk_ll_inventory_dimension (customer_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code,sku_code)"));
assertTrue(schema.contains("inbound_order_id bigint not null"));
assertTrue(schema.contains("inbound_code varchar(128) not null"));
assertTrue(schema.contains("available_packages bigint not null default 0"));
assertTrue(schema.contains("in_transit_packages bigint not null default 0"));
assertFalse(tableDefinition(schema, "ll_inventory_balance").contains("sku_code"));
assertTrue(tableDefinition(schema, "ll_inventory_balance").contains("sku_code"));
assertTrue(tableDefinition(schema, "ll_inventory_balance").contains("product_name_zh"));
assertFalse(tableDefinition(schema, "ll_inventory_balance").contains("_quantity"));
assertTrue(schema.contains("create table if not exists ll_inventory_position"));
assertTrue(schema.contains("unique key uk_ll_inventory_position (balance_id,pallet_id,batch_no)"));
@@ -52,7 +53,7 @@ class InventorySchemaContractTest {
assertTrue(schema.contains("unique key uk_ll_inventory_ledger_idempotency (customer_id,idempotency_key)"));
assertTrue(schema.contains("create table if not exists ll_inventory_operation"));
assertTrue(schema.contains("create table if not exists ll_inbound_stock_receipt"));
assertTrue(schema.contains("unique key uk_ll_stock_receipt_task_allocation (customer_id,task_id,allocation_id)"));
assertTrue(schema.contains("unique key uk_ll_stock_receipt_task_allocation_sku (customer_id,task_id,allocation_id,sku_code)"));
assertFalse(tableDefinition(schema, "ll_inventory_ledger").contains("updated_at"));
}
@@ -97,7 +98,8 @@ class InventorySchemaContractTest {
assertNotNull(InventoryBalance.class.getDeclaredField("inboundOrderId"));
assertNotNull(InventoryBalance.class.getDeclaredField("inboundCode"));
assertNotNull(InventoryLedger.class.getDeclaredField("inboundOrderId"));
assertThrowsNoField(InventoryBalance.class, "skuCode");
assertNotNull(InventoryBalance.class.getDeclaredField("skuCode"));
assertNotNull(InventoryBalance.class.getDeclaredField("productNameZh"));
assertThrowsNoField(InventoryBalance.class, "availableQuantity");
assertThrowsNoField(InventoryPosition.class, "skuCode");
assertThrowsNoField(InventoryPosition.class, "productQuantity");
@@ -63,7 +63,7 @@ class InventoryCommandServiceTest {
void stockInUpdatesBalancePositionAndLedgerInOneTransaction() throws Exception {
assertTrue(InventoryCommandService.class.getMethod("stockIn", String.class, String.class,
InventoryCommandRequests.StockIn.class).getAnnotation(Transactional.class) != null);
when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1")).thenReturn(null);
when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1", "SKU-1")).thenReturn(null);
var result = service.stockIn("100", "7", stockIn("IN-1", "stock-in-1", 12, 24));
assertEquals(12, result.availablePackages());
@@ -71,6 +71,7 @@ class InventoryCommandServiceTest {
ArgumentCaptor<InventoryBalance> balance = ArgumentCaptor.forClass(InventoryBalance.class);
verify(balances).updateById(balance.capture());
assertEquals(12L, balance.getValue().getAvailablePackages());
assertEquals("SKU-1", balance.getValue().getSkuCode());
verify(positions).insert(any(InventoryPosition.class));
verify(ledgers).insert(any(InventoryLedger.class));
verify(operations).updateById(any(InventoryOperation.class));
@@ -78,7 +79,7 @@ class InventoryCommandServiceTest {
@Test
void stockInAllowsPalletPositionWithZeroOptionalPackages() {
when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1")).thenReturn(null);
when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1", "SKU-1")).thenReturn(null);
var result = service.stockIn("100", "7", stockIn("IN-1", "stock-in-zero", 0, 0));
@@ -104,7 +105,7 @@ class InventoryCommandServiceTest {
@Test
void replayUsesPersistedResultWithoutMutatingInventoryAgain() throws Exception {
var request = stockIn("IN-1", "stock-in-1", 12, 24);
when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1")).thenReturn(null);
when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1", "SKU-1")).thenReturn(null);
service.stockIn("100", "7", request);
ArgumentCaptor<InventoryOperation> operation = ArgumentCaptor.forClass(InventoryOperation.class);
verify(operations).insert(operation.capture());
@@ -138,7 +139,8 @@ class InventoryCommandServiceTest {
private InventoryCommandRequests.StockIn stockIn(String inbound, String key, long packages, long quantity) {
return new InventoryCommandRequests.StockIn("100", "Customer 100", "WH-1", "WH1", "Warehouse 1",
"DEST-1", "ONT8", "ONT8", 101L, inbound, "IN-CODE-1", 300L, "PLT-1", null, null, null,
"DEST-1", "ONT8", "ONT8", 101L, inbound, "IN-CODE-1", "SKU-1", "Product 1", null,
300L, "PLT-1", null, null, null,
packages, "ORD-1", "EVT-1", key, null);
}
@@ -18,7 +18,7 @@ class InventoryQueryControllerTest {
private final InventoryQueryService service = mock(InventoryQueryService.class);
private final InventoryQueryController controller = controller();
private final InventoryQueryRequests.BalanceQuery query = new InventoryQueryRequests.BalanceQuery(
1, 20, null, null, null, null, null, null, null, null);
1, 20, null, null, null, null, null, null, null, null, null, null);
@Test
void merchantCannotOverrideCustomerScope() {
@@ -120,12 +120,11 @@ class InventoryQueryServiceTest {
String csv = new String(service.exportBalances("100", query), StandardCharsets.UTF_8);
assertTrue(csv.contains("inboundOrderNo,inboundCode,totalPackages"));
assertFalse(csv.contains("skuCode"));
assertTrue(csv.contains("inboundOrderNo,inboundCode,skuCode,productNameZh,productNameEn,totalPackages"));
assertFalse(csv.contains("Quantity"));
}
private static InventoryQueryRequests.BalanceQuery balanceQuery(String status) {
return new InventoryQueryRequests.BalanceQuery(1, 20, null, null, null, null, null, null, null, status);
return new InventoryQueryRequests.BalanceQuery(1, 20, null, null, null, null, null, null, null, null, null, status);
}
}
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.looseboard;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertTrue;
class LocalLooseboardSchemaContractTest {
@Test
void baselineContainsIndependentLooseboardOrderAndHistory() 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_local_looseboard_order"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_local_looseboard_history"));
assertTrue(sql.contains("operation_type VARCHAR(32) NOT NULL"));
assertTrue(sql.contains("uk_ll_local_looseboard_no(customer_id,order_no)"));
}
}
@@ -0,0 +1,60 @@
package com.baiyue.lastleg.looseboard.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests;
import com.baiyue.lastleg.looseboard.service.LocalLooseboardService;
import com.baiyue.lastleg.security.OverseasPermissions;
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.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 PlatformLocalLooseboardControllerTest {
@Mock LocalLooseboardService service;
private PlatformLocalLooseboardController controller;
@BeforeEach
void setUp() {
controller = new PlatformLocalLooseboardController();
ReflectionTestUtils.setField(controller, "service", service);
}
@Test
void createUsesExplicitCustomerAndOperator() {
var request = create();
controller.create(user(Set.of(OverseasPermissions.LOOSEBOARD_CREATE)), "100", request);
verify(service).create("100", "7", request);
}
@Test
void updateRequiresIndependentEditPermission() {
var request = new LocalLooseboardRequests.Update(0L, "SHARED_TRUCK", "NO", "PORT_PICKUP",
"LA", null, "Ontario", null, null, 1, BigDecimal.ZERO, BigDecimal.ZERO,
null, "USD", null, null, null, null);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> controller.update(user(Set.of(OverseasPermissions.LOOSEBOARD_CREATE)), "100", "LB1", request));
assertEquals(403, error.getStatusCode().value());
}
private LocalLooseboardRequests.Create create() {
return new LocalLooseboardRequests.Create("SHARED_TRUCK", "NO", "PORT_PICKUP", "LA", null,
"Ontario", null, null, 1, BigDecimal.ZERO, BigDecimal.ZERO, null, "USD",
null, null, LocalDate.now(), null, "key-1");
}
private AuthenticatedUser user(Set<String> permissions) {
return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB", permissions);
}
}
@@ -0,0 +1,113 @@
package com.baiyue.lastleg.looseboard.service;
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardHistory;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardHistoryMapper;
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOrderMapper;
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 LocalLooseboardServiceTest {
@Mock LocalLooseboardOrderMapper orders;
@Mock LocalLooseboardHistoryMapper histories;
private LocalLooseboardService service;
@BeforeEach
void setUp() {
service = new LocalLooseboardService();
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "histories", histories);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
}
@Test
void createIsTenantScopedIdempotentAndStartsPendingAcceptance() {
AtomicReference<LocalLooseboardOrder> saved = new AtomicReference<>();
when(orders.insert(any(LocalLooseboardOrder.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("100", detail.summary().customerId());
assertEquals(LocalLooseboardService.PENDING_ACCEPTANCE, detail.summary().status());
assertEquals("SHARED_TRUCK", detail.summary().transportMode());
verify(histories).insert(org.mockito.ArgumentMatchers.<LocalLooseboardHistory>argThat(value ->
"CREATE".equals(value.getOperationType())));
}
@Test
void ordinaryUpdateCannotChangeAcceptedOrder() {
LocalLooseboardOrder order = order(LocalLooseboardService.DISPATCHED, 0L);
when(orders.lockNo("100", "LB1")).thenReturn(order);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> service.update("100", "LB1", "7", update(0L)));
assertEquals(409, error.getStatusCode().value());
}
@Test
void statusTransitionWritesHistoryAndVersion() {
LocalLooseboardOrder order = order(LocalLooseboardService.PENDING_ACCEPTANCE, 0L);
when(orders.lockNo("100", "LB1")).thenReturn(order);
when(histories.listOrder("100", 1L)).thenReturn(List.of());
var detail = service.changeStatus("100", "LB1", "7",
new LocalLooseboardRequests.ChangeStatus(0L, "DISPATCHED", "accepted"));
assertEquals(LocalLooseboardService.DISPATCHED, detail.summary().status());
assertEquals(1L, detail.summary().version());
verify(histories).insert(org.mockito.ArgumentMatchers.<LocalLooseboardHistory>argThat(value ->
"STATUS".equals(value.getOperationType()) && "DISPATCHED".equals(value.getToStatus())));
}
private LocalLooseboardRequests.Create create(String key) {
return new LocalLooseboardRequests.Create("SHARED_TRUCK", "YES", "WAREHOUSE_TO_WAREHOUSE",
"Los Angeles", "Address A", "Ontario", "Address B", "40HQ", 12,
new BigDecimal("100.000"), new BigDecimal("2.500000"), new BigDecimal("500.00"),
"USD", "Alice", "+1-555-0100", LocalDate.now().plusDays(2), null, key);
}
private LocalLooseboardRequests.Update update(long version) {
return new LocalLooseboardRequests.Update(version, "SHARED_TRUCK", "YES", "WAREHOUSE_TO_WAREHOUSE",
"Los Angeles", "Address A", "Ontario", "Address B", "40HQ", 12,
new BigDecimal("100.000"), new BigDecimal("2.500000"), new BigDecimal("500.00"),
"USD", "Alice", "+1-555-0100", LocalDate.now().plusDays(2), null);
}
private LocalLooseboardOrder order(String status, long version) {
LocalLooseboardOrder order = new LocalLooseboardOrder();
order.setId(1L); order.setCustomerId("100"); order.setOrderNo("LB1");
order.setTransportMode("SHARED_TRUCK"); order.setPalletRequired("YES");
order.setTransportType("WAREHOUSE_TO_WAREHOUSE"); order.setOrigin("Los Angeles");
order.setDestination("Ontario"); order.setPackageCount(12); order.setCurrency("USD");
order.setStatus(status); order.setVersion(version); order.setCreatedAt(LocalDateTime.now());
order.setUpdatedAt(order.getCreatedAt());
return order;
}
}
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.masterdata;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class OverseasMasterDataOpenApiContractTest {
@Test
void documentsExchangeRateAndVehicleApisWithoutDeliveryFulfillment() throws Exception {
String yaml = Files.readString(Path.of("src/main/resources/openapi/overseas-master-data-v1.yaml"));
assertThat(yaml).contains("/api/platform/v1/last-leg/master-data/overseas-quotes:")
.contains("缓存合作仓目录30分钟")
.contains("/api/platform/v1/last-leg/master-data/exchange-rates:")
.contains("/api/platform/v1/last-leg/master-data/vehicles:")
.contains("platform:overseas-master-data:exchange-rate:export")
.contains("platform:overseas-master-data:vehicle:status")
.contains("不包含派送履约或车辆更换历史")
.doesNotContain("deliveryOrders:")
.doesNotContain("replacementHistory:");
}
}

Some files were not shown because too many files have changed in this diff Show More