优化
This commit is contained in:
@@ -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) { }
|
||||
}
|
||||
+12
-5
@@ -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,
|
||||
|
||||
+2
@@ -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
-8
@@ -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) {}
|
||||
}
|
||||
|
||||
+1
-9
@@ -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); }
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
+3
-1
@@ -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;
|
||||
|
||||
+5
-17
@@ -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;
|
||||
}
|
||||
|
||||
+25
-5
@@ -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,
|
||||
|
||||
+30
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -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
|
||||
) {
|
||||
}
|
||||
+37
@@ -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 >=#{q.createdFrom}</if>" +
|
||||
"<if test='q.createdTo != null'> AND p.created_at <=#{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);
|
||||
}
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+3
@@ -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;
|
||||
|
||||
+85
-5
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+54
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
@@ -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);
|
||||
|
||||
+10
-37
@@ -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);
|
||||
|
||||
+3
@@ -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,
|
||||
|
||||
+12
-2
@@ -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;
|
||||
|
||||
+3
@@ -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;
|
||||
|
||||
+6
-4
@@ -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>" +
|
||||
|
||||
+14
-6
@@ -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,
|
||||
|
||||
+3
@@ -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,
|
||||
|
||||
+2
@@ -19,6 +19,8 @@ public final class InventoryQueryRequests {
|
||||
String inboundOrderNo,
|
||||
String inboundCode,
|
||||
String businessOrderNo,
|
||||
String skuCode,
|
||||
String productName,
|
||||
String inventoryStatus
|
||||
) {
|
||||
public BalanceQuery {
|
||||
|
||||
+4
@@ -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 > 0</when>" +
|
||||
"<when test='q.inventoryStatus == \"HOLD\"'> AND hold_packages > 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 > 0</when>" +
|
||||
"<when test='q.inventoryStatus == \"HOLD\"'> AND b.hold_packages > 0</when>" +
|
||||
|
||||
+4
-3
@@ -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());
|
||||
|
||||
+9
@@ -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 {
|
||||
}
|
||||
+97
@@ -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());
|
||||
}
|
||||
}
|
||||
+72
@@ -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) { }
|
||||
}
|
||||
+25
@@ -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) { }
|
||||
}
|
||||
+8
-9
@@ -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;
|
||||
}
|
||||
+42
@@ -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;
|
||||
}
|
||||
+15
@@ -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);
|
||||
}
|
||||
+19
@@ -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);
|
||||
}
|
||||
+302
@@ -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");
|
||||
}
|
||||
}
|
||||
+58
@@ -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()); }
|
||||
}
|
||||
+36
@@ -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) { }
|
||||
}
|
||||
+26
@@ -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;
|
||||
}
|
||||
+23
@@ -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;
|
||||
}
|
||||
+8
@@ -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> { }
|
||||
+19
@@ -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();
|
||||
}
|
||||
+156
@@ -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); }
|
||||
}
|
||||
+18
@@ -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) { }
|
||||
}
|
||||
+107
@@ -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());
|
||||
}
|
||||
}
|
||||
+60
@@ -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) { }
|
||||
}
|
||||
+30
@@ -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) { }
|
||||
}
|
||||
+32
@@ -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;
|
||||
}
|
||||
+21
@@ -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;
|
||||
}
|
||||
+22
@@ -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;
|
||||
}
|
||||
+8
@@ -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> { }
|
||||
+28
@@ -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<>#{excludedId}
|
||||
AND effective_start<=#{effectiveEnd} AND effective_end>=#{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);
|
||||
}
|
||||
+8
@@ -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> { }
|
||||
+227
@@ -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); }
|
||||
}
|
||||
+57
@@ -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); }
|
||||
}
|
||||
}
|
||||
+58
@@ -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());
|
||||
}
|
||||
}
|
||||
+47
@@ -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) { }
|
||||
}
|
||||
+30
@@ -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;
|
||||
}
|
||||
+13
@@ -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);
|
||||
}
|
||||
+143
@@ -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); }
|
||||
}
|
||||
+34
-9
@@ -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) {
|
||||
|
||||
+11
-18
@@ -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) {}
|
||||
}
|
||||
|
||||
+8
-6
@@ -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) {}
|
||||
}
|
||||
|
||||
-1
@@ -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;
|
||||
|
||||
+2
@@ -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;
|
||||
}
|
||||
|
||||
+8
-5
@@ -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 != ""'>" +
|
||||
"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);
|
||||
}
|
||||
|
||||
-16
@@ -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);
|
||||
}
|
||||
+139
-68
@@ -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);
|
||||
}
|
||||
|
||||
+11
@@ -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) {
|
||||
|
||||
+82
@@ -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) {
|
||||
|
||||
+5
@@ -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,
|
||||
|
||||
+1
-1
@@ -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) {
|
||||
|
||||
+35
-6
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
-3
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
+7
-1
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
+61
@@ -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];
|
||||
}
|
||||
}
|
||||
+6
-19
@@ -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
|
||||
|
||||
+4
-2
@@ -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"));
|
||||
|
||||
+28
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-1
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
+98
@@ -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)));
|
||||
}
|
||||
}
|
||||
+32
@@ -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",
|
||||
|
||||
+6
-4
@@ -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");
|
||||
|
||||
+6
-4
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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() {
|
||||
|
||||
+2
-3
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+21
@@ -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)"));
|
||||
}
|
||||
}
|
||||
+60
@@ -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);
|
||||
}
|
||||
}
|
||||
+113
@@ -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;
|
||||
}
|
||||
}
|
||||
+24
@@ -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
Reference in New Issue
Block a user