This commit is contained in:
2026-08-20 15:58:25 +08:00
parent cd34cd426e
commit 2847ad1b2d
45 changed files with 921 additions and 68 deletions
@@ -3,12 +3,14 @@ package com.baiyue.lastleg;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 尾程微服务预留模块启动入口,初始化 Spring Boot 应用并扫描尾程 Mapper。
*/
@MapperScan("com.baiyue.lastleg.mapper")
@SpringBootApplication(scanBasePackages = "com.baiyue")
@EnableScheduling
public class BaiyueLastlegApplication {
public static void main(String[] args) { SpringApplication.run(BaiyueLastlegApplication.class, args); }
}
@@ -19,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.PostMapping;
@@ -49,6 +50,7 @@ public class LastlegOrderController {
}
@GetMapping("/api/platform/v1/last-leg/orders")
@PreAuthorize("hasAuthority('platform:lastleg:order:list')")
/** 分页查询尾程订单列表。 */
@Operation(summary = "分页查询尾程订单")
public ApiResponse<PageResult<LastlegOrderSummary>> list(
@@ -59,6 +61,7 @@ public class LastlegOrderController {
}
@GetMapping("/api/platform/v1/last-leg/orders/{id}")
@PreAuthorize("hasAuthority('platform:lastleg:order:list')")
/** 查询指定入仓单维度的尾程订单详情。 */
@Operation(summary = "查询尾程订单详情")
public ApiResponse<LastlegOrderDetailResponse> detail(
@@ -69,6 +72,7 @@ public class LastlegOrderController {
}
@GetMapping(value = "/api/platform/v1/last-leg/orders/export", produces = "text/csv")
@PreAuthorize("hasAuthority('platform:lastleg:order:export')")
/** 按筛选条件导出尾程订单 CSV 文件。 */
@Operation(summary = "导出尾程订单")
public ResponseEntity<byte[]> export(
@@ -83,6 +87,7 @@ public class LastlegOrderController {
}
@PutMapping("/api/platform/v1/last-leg/orders/{id}/destination")
@PreAuthorize("hasAuthority('platform:lastleg:order:transfer')")
/** 将单个尾程订单转移到目标仓库。 */
@Operation(summary = "单个订单转移仓库")
public ApiResponse<Integer> transferOne(
@@ -94,6 +99,7 @@ public class LastlegOrderController {
}
@PutMapping("/api/platform/v1/last-leg/orders/destination")
@PreAuthorize("hasAuthority('platform:lastleg:order:transfer')")
/** 将多个尾程订单批量转移到目标仓库。 */
@Operation(summary = "批量订单转移仓库")
public ApiResponse<Integer> transferBatch(
@@ -17,6 +17,7 @@ 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.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -38,6 +39,7 @@ public class DispatchController {
@Resource private DispatchPdaWorkflowService pdaWorkflow;
@GetMapping("/candidates")
@PreAuthorize("hasAuthority('platform:lastleg:dispatch:list')")
/** 查询可用于智能预派车的未锁定托盘。 */
@Operation(summary = "查询可派车托盘")
public ApiResponse<List<DispatchPalletCandidate>> candidates(
@@ -50,6 +52,7 @@ public class DispatchController {
}
@PostMapping("/pre-arrange")
@PreAuthorize("hasAuthority('platform:lastleg:dispatch:create')")
/** 创建待出发派车单并锁定选中的托盘。 */
@Operation(summary = "创建预派车单")
public ApiResponse<DispatchResponse> preArrange(
@@ -62,6 +65,7 @@ public class DispatchController {
}
@GetMapping
@PreAuthorize("hasAuthority('platform:lastleg:dispatch:list')")
/** 分页查询派车单。 */
@Operation(summary = "分页查询派车单")
public ApiResponse<PageResult<DispatchResponse>> page(
@@ -72,6 +76,7 @@ public class DispatchController {
}
@GetMapping("/{dispatchNo}")
@PreAuthorize("hasAuthority('platform:lastleg:dispatch:list')")
/** 查询派车单详情及托盘目的地。 */
@Operation(summary = "查询派车单详情")
public ApiResponse<DispatchResponse> detail(
@@ -82,6 +87,7 @@ public class DispatchController {
}
@PutMapping("/{dispatchNo}")
@PreAuthorize("hasAuthority('platform:lastleg:dispatch:edit')")
/** 编辑车辆、司机、预约、路线和出发信息。 */
@Operation(summary = "编辑派车单")
public ApiResponse<DispatchResponse> update(
@@ -93,6 +99,7 @@ public class DispatchController {
}
@DeleteMapping("/{dispatchNo}")
@PreAuthorize("hasAuthority('platform:lastleg:dispatch:delete')")
/** 取消待处理派车单并释放托盘锁定。 */
@Operation(summary = "取消派车单")
public ApiResponse<Void> cancel(
@@ -24,5 +24,7 @@ public class LastlegEventOutbox {
private LocalDateTime nextRetryAt;
private LocalDateTime publishedAt;
private String lastError;
private String lockedBy;
private LocalDateTime lockedAt;
private LocalDateTime createdAt;
}
@@ -56,7 +56,6 @@ public class LastlegOrder {
private String warehouseRemark;
private String sourceEventId;
private String sourceLoadingPlanId;
private String sourceContainerBookingId;
private Long sourceLoadingPlanVersion;
private Long sourceHandoverVersion;
private String sourcePayloadHash;
@@ -15,6 +15,7 @@ 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;
@@ -26,6 +27,7 @@ public class InboundPlanController {
@Resource private InboundFbaAppointmentService appointmentService;
@PostMapping("/convert")
@PreAuthorize("hasAuthority('platform:lastleg:inbound-plan:create')")
/** 将入仓单转换为入仓计划。 */
@Operation(summary = "入仓单转入仓计划")
public ApiResponse<InboundPlanResponse> convert(@AuthenticationPrincipal AuthenticatedUser user,
@@ -34,6 +36,7 @@ public class InboundPlanController {
}
@GetMapping("/{planNo}")
@PreAuthorize("hasAuthority('platform:lastleg:inbound-plan:list')")
/** 查询入仓计划及其明细、托盘和分配信息。 */
@Operation(summary = "查询入仓计划详情")
public ApiResponse<InboundPlanResponse> detail(@AuthenticationPrincipal AuthenticatedUser user,
@@ -43,6 +46,7 @@ public class InboundPlanController {
}
@PostMapping("/{planNo}/pallets/supplement")
@PreAuthorize("hasAuthority('platform:lastleg:inbound-plan:edit')")
/** 为计划目标仓新增一个可打印的补充托盘。 */
@Operation(summary = "新增补充托盘")
public ApiResponse<InboundPlanResponse.Pallet> supplementPallet(@AuthenticationPrincipal AuthenticatedUser user,
@@ -52,6 +56,7 @@ public class InboundPlanController {
}
@PostMapping("/fba-appointments/batch")
@PreAuthorize("hasAuthority('platform:lastleg:inbound-plan:appointment')")
/** 为选中的入仓明细创建 FBA 预约。 */
@Operation(summary = "创建 FBA 预约")
public ApiResponse<FbaAppointmentResponse> batchFbaAppointment(
@@ -63,6 +68,7 @@ public class InboundPlanController {
}
@GetMapping("/fba-appointments/{appointmentNo}")
@PreAuthorize("hasAuthority('platform:lastleg:inbound-plan:list')")
/** 查询 FBA 预约及其选中的入仓明细。 */
@Operation(summary = "查询 FBA 预约详情")
public ApiResponse<FbaAppointmentResponse> fbaAppointmentDetail(
@@ -16,6 +16,9 @@ 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.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.http.MediaType;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@@ -114,6 +117,15 @@ public class InboundPdaController {
return ApiResponse.success(service.uploadUnloadPhotos(scope(user, customerId), planNo, request));
}
@PostMapping(value = "/unload-photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<PdaResponses.UnloadPhotoFile> uploadUnloadPhotoFile(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo,
@RequestParam(required = false) String customerId, @RequestPart("file") MultipartFile file) {
requireOperate(user);
return ApiResponse.success(service.uploadUnloadPhotoFile(
scope(user, customerId), planNo, user.userId(), file));
}
private String scope(AuthenticatedUser user, String requestedCustomer) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
@@ -12,6 +12,8 @@ public final class PdaResponses {
List<Long> unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt,
LocalDateTime estimatedEmptyReturnAt) {}
public record UnloadPhotoFile(long fileId, String fileName, String contentType, long sizeBytes) {}
public record Operation(String result, long allocationId, int requiredPackageCount,
int countedPackageCount, int pickedPackageCount,
BigDecimal actualVolumeCbm, Integer remainingPackageCount,
@@ -0,0 +1,79 @@
package com.baiyue.lastleg.inboundplan.pda.gateway;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
@Component
public class HttpUnloadPhotoFileGateway implements UnloadPhotoFileGateway {
private static final String BUSINESS_TYPE = "OVERSEAS_UNLOAD_PHOTO";
@Resource private RestClient.Builder restClientBuilder;
@Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
private RestClient client;
@PostConstruct
void initializeClient() { client = restClientBuilder.baseUrl(baseUrl).build(); }
@Override
public UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file) {
long tenantId = tenantId(customerId);
MultipartBodyBuilder body = new MultipartBodyBuilder();
try {
body.part("file", new ByteArrayResource(file.getBytes()) {
@Override public String getFilename() { return file.getOriginalFilename(); }
}).contentType(file.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM
: MediaType.parseMediaType(file.getContentType()));
} catch (IOException exception) {
throw new IllegalArgumentException("Cannot read unload photo", exception);
}
MetadataEnvelope response = client.post()
.uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}",
tenantId, BUSINESS_TYPE, taskId)
.header("X-Internal-Service-Token", internalToken).header("X-Operator-Id", String.valueOf(operatorId))
.contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> {
throw new IllegalArgumentException("Unload photo upload failed");
}).body(MetadataEnvelope.class);
Metadata data = response == null ? null : response.data();
if (data == null || data.fileId() == null) throw new IllegalArgumentException("File service returned no photo metadata");
return new UploadedFile(data.fileId(), data.fileName(), data.contentType(),
data.sizeBytes() == null ? 0 : data.sizeBytes());
}
@Override
public void validate(String customerId, long taskId, long fileId) {
long tenantId = tenantId(customerId);
MetadataEnvelope response = client.get()
.uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}",
tenantId, BUSINESS_TYPE, taskId, fileId)
.header("X-Internal-Service-Token", internalToken)
.retrieve().onStatus(HttpStatusCode::isError, (request, result) -> {
throw new IllegalArgumentException("Unload photo does not exist or its ownership is invalid");
}).body(MetadataEnvelope.class);
Metadata data = response == null ? null : response.data();
if (data == null || data.fileId() == null || data.fileId() != fileId
|| data.tenantId() == null || data.tenantId() != tenantId
|| !BUSINESS_TYPE.equals(data.businessType()) || !String.valueOf(taskId).equals(data.businessId())) {
throw new IllegalArgumentException("Unload photo metadata does not match the PDA task");
}
}
private long tenantId(String customerId) {
try { return Long.parseLong(customerId); }
catch (NumberFormatException exception) { throw new IllegalArgumentException("Customer ID must be numeric", exception); }
}
private record MetadataEnvelope(Metadata data) {}
private record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
String fileName, String contentType, Long sizeBytes) {}
}
@@ -0,0 +1,10 @@
package com.baiyue.lastleg.inboundplan.pda.gateway;
import org.springframework.web.multipart.MultipartFile;
public interface UnloadPhotoFileGateway {
UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file);
void validate(String customerId, long taskId, long fileId);
record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) {}
}
@@ -12,6 +12,7 @@ import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaOperation;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway;
import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.MapperFeature;
@@ -21,6 +22,7 @@ import com.fasterxml.jackson.core.type.TypeReference;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
@@ -40,12 +42,14 @@ import java.util.Objects;
@Service
public class InboundPdaWorkflowService {
private static final long MAX_UNLOAD_PHOTO_SIZE = 10L * 1024 * 1024;
private static final BigDecimal ZERO_VOLUME = new BigDecimal("0.0000");
@Resource private PdaWorkflowRepository repository;
@Resource private WarehouseResourceGateway warehouse;
@Resource private InboundStockReceiptService stockReceipts;
@Resource private InboundUnloadCompletedOutboxService unloadCompletedOutbox;
@Resource private UnloadPhotoFileGateway unloadPhotoFiles;
@Resource private ObjectMapper sourceObjectMapper;
private ObjectMapper objectMapper;
@@ -421,6 +425,7 @@ public class InboundPdaWorkflowService {
InboundPdaTask task = requireActiveTask(customerId, plan);
List<Long> fileIds = request.fileIds().stream().distinct().sorted().toList();
if (fileIds.size() != request.fileIds().size()) throw conflict("Unload photo file IDs cannot be duplicated");
fileIds.forEach(fileId -> validateUnloadPhoto(customerId, task.getId(), fileId));
LocalDateTime uploadedAt = LocalDateTime.now();
task.setUnloadPhotoFileIds(write(fileIds));
task.setUnloadPhotoUploadedAt(uploadedAt);
@@ -433,6 +438,21 @@ public class InboundPdaWorkflowService {
return result;
}
public PdaResponses.UnloadPhotoFile uploadUnloadPhotoFile(String customerId, String planNo,
long operatorId, MultipartFile file) {
validateUnloadPhotoFile(file);
InboundPlan plan = requirePlan(customerId, planNo);
InboundPdaTask task = requireActiveTask(customerId, plan);
final UnloadPhotoFileGateway.UploadedFile uploaded;
try {
uploaded = unloadPhotoFiles.upload(customerId, task.getId(), operatorId, file);
} catch (IllegalArgumentException exception) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, exception.getMessage(), exception);
}
return new PdaResponses.UnloadPhotoFile(uploaded.fileId(), uploaded.fileName(),
uploaded.contentType(), uploaded.sizeBytes());
}
private void moveSourceAllocation(Long planId, InboundPlanPalletAllocation source, Long targetPalletId,
int packageCount, BigDecimal movedVolume) {
source.setPickedPackageCount(value(source.getPickedPackageCount()) - packageCount);
@@ -623,6 +643,27 @@ public class InboundPdaWorkflowService {
}
}
private void validateUnloadPhoto(String customerId, long taskId, long fileId) {
try {
unloadPhotoFiles.validate(customerId, taskId, fileId);
} catch (IllegalArgumentException exception) {
throw conflict("Unload photo does not exist or does not belong to this PDA task");
}
}
private static void validateUnloadPhotoFile(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unload photo cannot be empty");
}
if (file.getSize() > MAX_UNLOAD_PHOTO_SIZE) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unload photo cannot exceed 10 MB");
}
String contentType = file.getContentType();
if (!"image/jpeg".equalsIgnoreCase(contentType) && !"image/png".equalsIgnoreCase(contentType)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unload photo must be JPG or PNG");
}
}
private BigDecimal perPackageVolume(InboundPlanLine line) {
int required = value(line.getRequiredPackageCount());
if (required <= 0) throw conflict("Inbound line required package count must be positive");
@@ -9,11 +9,9 @@ import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.OverseasContainerUnloadedV1;
import com.baiyue.common.event.RegionCode;
import com.baiyue.lastleg.entity.LastlegEventOutbox;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -29,26 +27,21 @@ import java.util.List;
@Service
public class InboundUnloadCompletedOutboxService {
@Resource private LastlegEventOutboxMapper mapper;
@Resource private LastlegOrderMapper orders;
@Resource private ObjectMapper objectMapper;
public void publish(InboundPlan plan, InboundPdaTask task, List<Long> photoFileIds) {
LastlegOrder sourceOrder = orders.selectById(plan.getSourceOrderId());
if (sourceOrder == null || sourceOrder.getSourceContainerBookingId() == null
|| sourceOrder.getSourceContainerBookingId().isBlank()) {
throw new IllegalStateException("Inbound plan has no source container booking ID");
}
Instant photoUploadedAt = task.getUnloadPhotoUploadedAt().toInstant(ZoneOffset.UTC);
var payload = new OverseasContainerUnloadedV1(plan.getContainerNo(), photoUploadedAt,
Instant unloadingCompletedAt = task.getCompletedAt().toInstant(ZoneOffset.UTC);
var payload = new OverseasContainerUnloadedV1(plan.getContainerNo(), unloadingCompletedAt,
photoUploadedAt, null);
JsonNode payloadNode = objectMapper.valueToTree(payload);
// Multiple inbound plans can share one container; upload epoch keeps their container events ordered.
long aggregateVersion = photoUploadedAt.toEpochMilli();
// Multiple inbound plans can share one container; completion epoch keeps their container events ordered.
long aggregateVersion = unloadingCompletedAt.toEpochMilli();
String eventKey = EventTypes.OVERSEAS_CONTAINER_UNLOADED + ':' + task.getId();
String traceId = EventIdentifiers.newUuid();
EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.OVERSEAS_CONTAINER_UNLOADED,
1, Instant.now(), RegionCode.OVERSEAS, RegionCode.CN, task.getCustomerId(), "CONTAINER_BOOKING",
sourceOrder.getSourceContainerBookingId(), aggregateVersion, 1L, traceId, eventKey, null, payloadNode,
plan.getContainerNo(), aggregateVersion, 1L, traceId, eventKey, null, payloadNode,
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode))).requireValid();
LastlegEventOutbox event = new LastlegEventOutbox();
@@ -11,6 +11,7 @@ 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,11 +22,12 @@ import org.springframework.web.server.ResponseStatusException;
@RequestMapping("/api/platform/v1/last-leg/inventory")
@Tag(name = "尾程入仓单库存操作")
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 = "platform:lastleg:inventory:manage";
public static final String ADJUST_PERMISSION = "platform:lastleg:inventory:adjust";
@Resource private InventoryCommandService service;
@PostMapping("/hold")
@PreAuthorize("hasAuthority('platform:lastleg:inventory:manage')")
/** 将可用入仓单箱数转为冻结。 */
@Operation(summary = "冻结入仓单库存")
public ApiResponse<InventoryCommandResponse> hold(@AuthenticationPrincipal AuthenticatedUser user,
@@ -35,6 +37,7 @@ public class InventoryCommandController {
}
@PostMapping("/release")
@PreAuthorize("hasAuthority('platform:lastleg:inventory:manage')")
/** 释放已冻结的入仓单箱数。 */
@Operation(summary = "释放入仓单库存")
public ApiResponse<InventoryCommandResponse> release(@AuthenticationPrincipal AuthenticatedUser user,
@@ -44,6 +47,7 @@ public class InventoryCommandController {
}
@PostMapping("/allocate")
@PreAuthorize("hasAuthority('platform:lastleg:inventory:manage')")
/** 将可用入仓单箱数分配给出库单。 */
@Operation(summary = "分配出库库存")
public ApiResponse<InventoryCommandResponse> allocate(@AuthenticationPrincipal AuthenticatedUser user,
@@ -53,6 +57,7 @@ public class InventoryCommandController {
}
@PostMapping("/unallocate")
@PreAuthorize("hasAuthority('platform:lastleg:inventory:manage')")
/** 取消出库库存分配。 */
@Operation(summary = "取消出库分配")
public ApiResponse<InventoryCommandResponse> unallocate(@AuthenticationPrincipal AuthenticatedUser user,
@@ -62,6 +67,7 @@ public class InventoryCommandController {
}
@PostMapping("/pick")
@PreAuthorize("hasAuthority('platform:lastleg:inventory:manage')")
/** 确认入仓单箱数已拣货。 */
@Operation(summary = "确认拣货")
public ApiResponse<InventoryCommandResponse> pick(@AuthenticationPrincipal AuthenticatedUser user,
@@ -71,6 +77,7 @@ public class InventoryCommandController {
}
@PostMapping("/ship")
@PreAuthorize("hasAuthority('platform:lastleg:inventory:manage')")
/** 出库已拣货箱数并返回托盘是否为空。 */
@Operation(summary = "出库拣货库存")
public ApiResponse<InventoryCommandResponse> ship(@AuthenticationPrincipal AuthenticatedUser user,
@@ -80,6 +87,7 @@ public class InventoryCommandController {
}
@PostMapping("/adjust")
@PreAuthorize("hasAuthority('platform:lastleg:inventory:adjust')")
/** 根据实物盘点结果调整入仓单库存。 */
@Operation(summary = "盘点调整库存")
public ApiResponse<InventoryCommandResponse> adjust(@AuthenticationPrincipal AuthenticatedUser user,
@@ -18,6 +18,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,6 +36,7 @@ public class InventoryQueryController {
@Resource private InventoryQueryService service;
@GetMapping(BASE_PATH + "/summary")
@PreAuthorize("hasAuthority('platform:lastleg:inventory:list')")
/** 查询入仓单库存汇总。 */
@Operation(summary = "查询库存汇总")
public ApiResponse<InventoryDashboardResponse> summary(
@@ -45,6 +47,7 @@ public class InventoryQueryController {
}
@GetMapping(BASE_PATH + "/balances")
@PreAuthorize("hasAuthority('platform:lastleg:inventory:list')")
/** 按客户、仓库和入仓单分页查询库存。 */
@Operation(summary = "分页查询库存余额")
public ApiResponse<PageResult<InventoryBalanceView>> balances(
@@ -55,6 +58,7 @@ public class InventoryQueryController {
}
@GetMapping(BASE_PATH + "/balances/{balanceId}/positions")
@PreAuthorize("hasAuthority('platform:lastleg:inventory:list')")
/** 查询库存余额对应的托盘和批次位置。 */
@Operation(summary = "查询库存位置")
public ApiResponse<List<InventoryPositionView>> positions(
@@ -66,6 +70,7 @@ public class InventoryQueryController {
}
@GetMapping(BASE_PATH + "/ledger")
@PreAuthorize("hasAuthority('platform:lastleg:inventory:list')")
/** 分页查询只增不改的库存流水。 */
@Operation(summary = "分页查询库存流水")
public ApiResponse<PageResult<InventoryLedgerView>> ledgers(
@@ -76,6 +81,7 @@ public class InventoryQueryController {
}
@GetMapping(value = BASE_PATH + "/balances/export", produces = "text/csv")
@PreAuthorize("hasAuthority('platform:lastleg:inventory:export')")
/** 导出筛选后的入仓单库存 CSV 文件。 */
@Operation(summary = "导出库存余额")
public ResponseEntity<byte[]> exportBalances(
@@ -86,6 +92,7 @@ public class InventoryQueryController {
}
@GetMapping(value = BASE_PATH + "/ledger/export", produces = "text/csv")
@PreAuthorize("hasAuthority('platform:lastleg:inventory:export')")
/** 导出筛选后的库存流水 CSV 文件。 */
@Operation(summary = "导出库存流水")
public ResponseEntity<byte[]> exportLedgers(
@@ -3,7 +3,47 @@ package com.baiyue.lastleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.entity.LastlegEventOutbox;
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.time.LocalDateTime;
import java.util.List;
@Mapper
public interface LastlegEventOutboxMapper extends BaseMapper<LastlegEventOutbox> {
@Select("""
SELECT id FROM ll_event_outbox
WHERE event_type='OverseasContainerUnloaded' AND retry_count &lt; #{maxAttempts}
AND (status='PENDING' OR (status='PROCESSING' AND locked_at &lt;= #{staleBefore}))
AND (next_retry_at IS NULL OR next_retry_at &lt;= #{now})
ORDER BY created_at,id LIMIT #{limit}
""")
List<Long> relayCandidates(@Param("limit") int limit, @Param("maxAttempts") int maxAttempts,
@Param("now") LocalDateTime now, @Param("staleBefore") LocalDateTime staleBefore);
@Update("""
UPDATE ll_event_outbox SET status='PROCESSING',locked_by=#{worker},locked_at=#{now}
WHERE id=#{id} AND (status='PENDING' OR (status='PROCESSING' AND locked_at &lt;= #{staleBefore}))
""")
int claim(@Param("id") long id, @Param("worker") String worker, @Param("now") LocalDateTime now,
@Param("staleBefore") LocalDateTime staleBefore);
@Select("SELECT * FROM ll_event_outbox WHERE id=#{id} AND status='PROCESSING' AND locked_by=#{worker}")
LastlegEventOutbox claimed(@Param("id") long id, @Param("worker") String worker);
@Update("""
UPDATE ll_event_outbox SET status='PUBLISHED',published_at=#{now},next_retry_at=NULL,
last_error=NULL,locked_by=NULL,locked_at=NULL
WHERE id=#{id} AND status='PROCESSING' AND locked_by=#{worker}
""")
int markPublished(@Param("id") long id, @Param("worker") String worker, @Param("now") LocalDateTime now);
@Update("""
UPDATE ll_event_outbox SET status=#{status},retry_count=retry_count+1,next_retry_at=#{retryAt},
last_error=#{error},locked_by=NULL,locked_at=NULL
WHERE id=#{id} AND status='PROCESSING' AND locked_by=#{worker}
""")
int markFailed(@Param("id") long id, @Param("worker") String worker, @Param("status") String status,
@Param("retryAt") LocalDateTime retryAt, @Param("error") String error);
}
@@ -10,6 +10,7 @@ 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,6 +27,7 @@ public class PlatformOverseasProcurementController {
@Resource private OverseasProcurementService service;
@GetMapping
@PreAuthorize("hasAuthority('platform:lastleg:overseas-procurement:list')")
public ApiResponse<PageResult<OverseasProcurementResponses.Summary>> page(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@@ -35,6 +37,7 @@ public class PlatformOverseasProcurementController {
}
@GetMapping("/{orderNo}")
@PreAuthorize("hasAuthority('platform:lastleg:overseas-procurement:list')")
public ApiResponse<OverseasProcurementResponses.Detail> detail(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
@PathVariable String orderNo) {
@@ -43,6 +46,7 @@ public class PlatformOverseasProcurementController {
}
@PostMapping("/{orderNo}/review")
@PreAuthorize("hasAuthority('platform:lastleg:overseas-procurement:review')")
public ApiResponse<OverseasProcurementResponses.Detail> review(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
@PathVariable String orderNo, @Valid @RequestBody OverseasProcurementRequests.Review request) {
@@ -51,6 +55,7 @@ public class PlatformOverseasProcurementController {
}
@PatchMapping("/{orderNo}")
@PreAuthorize("hasAuthority('platform:lastleg:overseas-procurement:edit')")
public ApiResponse<OverseasProcurementResponses.Detail> update(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
@PathVariable String orderNo, @Valid @RequestBody OverseasProcurementRequests.Update request) {
@@ -59,10 +64,11 @@ public class PlatformOverseasProcurementController {
}
@PostMapping("/{orderNo}/outbounds")
@PreAuthorize("hasAuthority('platform:lastleg:overseas-procurement:outbound')")
public ApiResponse<OverseasProcurementResponses.Detail> addOutbound(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
@PathVariable String orderNo, @Valid @RequestBody OverseasProcurementRequests.AddOutbound request) {
requirePermission(user, OverseasProcurementService.MANAGE_PERMISSION);
requirePermission(user, "platform:lastleg:overseas-procurement:outbound");
return ApiResponse.success(service.addOutbound(requiredCustomer(customerId), orderNo, operator(user), request));
}
@@ -31,8 +31,8 @@ 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 = "platform:lastleg:overseas-procurement:review";
public static final String MANAGE_PERMISSION = "platform:lastleg:overseas-procurement:edit";
public static final String PENDING_REVIEW = "PENDING_REVIEW";
public static final String APPROVED = "APPROVED";
public static final String REJECTED = "REJECTED";
@@ -10,6 +10,7 @@ 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 +20,25 @@ public class RelabelApplicationController {
@Resource private RelabelApplicationService service;
@PostMapping
@PreAuthorize("hasAuthority('platform:lastleg:relabel: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('platform:lastleg:relabel: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('platform:lastleg:relabel: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('platform:lastleg:relabel:status')")
public ApiResponse<RelabelResponses.Detail> status(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @PathVariable String relabelNo,
@Valid @RequestBody RelabelRequests.ChangeStatus request) {
@@ -0,0 +1,56 @@
package com.baiyue.lastleg.service;
import com.baiyue.lastleg.entity.LastlegEventOutbox;
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.lang.management.ManagementFactory;
import java.time.LocalDateTime;
import java.util.UUID;
@Component
@Slf4j
public class ContainerUnloadOutboxRelay {
private static final int MAX_ATTEMPTS = 12;
@Resource private LastlegEventOutboxMapper outbox;
@Resource private EventBridgePublisher publisher;
private final String worker = ManagementFactory.getRuntimeMXBean().getName() + ':' + UUID.randomUUID();
@Scheduled(fixedDelayString = "${baiyue.container-unload-relay.fixed-delay:3000}")
public void relay() {
LocalDateTime now = LocalDateTime.now();
LocalDateTime staleBefore = now.minusMinutes(2);
for (Long id : outbox.relayCandidates(50, MAX_ATTEMPTS, now, staleBefore)) {
relayOne(id, now, staleBefore);
}
}
void relayOne(long id, LocalDateTime now, LocalDateTime staleBefore) {
if (outbox.claim(id, worker, now, staleBefore) != 1) return;
LastlegEventOutbox row = outbox.claimed(id, worker);
if (row == null) return;
try {
publisher.publish(row.getPayloadJson());
outbox.markPublished(id, worker, LocalDateTime.now());
} catch (Exception exception) {
int attempt = row.getRetryCount() == null ? 1 : row.getRetryCount() + 1;
boolean exhausted = attempt >= MAX_ATTEMPTS;
LocalDateTime retryAt = exhausted ? null : LocalDateTime.now().plusSeconds(backoffSeconds(attempt));
outbox.markFailed(id, worker, exhausted ? "FAILED" : "PENDING", retryAt,
abbreviate(exception.getMessage()));
log.warn("海外卸柜事件投递失败 id={}, attempt={}, reason={}", id, attempt, exception.getMessage());
}
}
private static long backoffSeconds(int attempt) {
return Math.min(900L, 5L << Math.min(Math.max(attempt - 1, 0), 8));
}
private static String abbreviate(String value) {
String safe = value == null || value.isBlank() ? "未知投递错误" : value;
return safe.length() <= 1000 ? safe : safe.substring(0, 1000);
}
}
@@ -0,0 +1,39 @@
package com.baiyue.lastleg.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
@Component
public class EventBridgePublisher {
@Resource private ObjectMapper objectMapper;
@Value("${baiyue.clients.event-bridge.url:http://127.0.0.1:9312}") private String baseUrl;
@Value("${baiyue.security.internal-service-token:}") private String serviceToken;
private HttpClient httpClient;
@PostConstruct
void initialize() {
httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3)).build();
}
public void publish(String envelopeJson) throws Exception {
HttpRequest request = HttpRequest.newBuilder(URI.create(baseUrl + "/internal/event-bridge/events"))
.timeout(Duration.ofSeconds(10)).header("Content-Type", "application/json")
.header("X-Internal-Service-Token", serviceToken)
.POST(HttpRequest.BodyPublishers.ofString(envelopeJson)).build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
JsonNode body = objectMapper.readTree(response.body());
if (response.statusCode() / 100 != 2 || body == null || !"200".equals(body.path("code").asText())) {
throw new IllegalStateException("事件桥接收失败: HTTP " + response.statusCode());
}
}
}
@@ -120,8 +120,7 @@ public class LoadingCompletedConsumer {
order.setVolumeWeightKg(source.items().stream().map(FirstLegLoadingPlanCompletedV1.Item::dimensionalWeightKg)
.filter(Objects::nonNull).reduce(BigDecimal.ZERO,BigDecimal::add));
order.setCurrentStatus("PENDING_ASSIGNMENT"); order.setSourceEventId(event.eventId());
order.setSourceLoadingPlanId(payload.loadingPlanId()); order.setSourceContainerBookingId(payload.containerBookingId());
order.setSourceLoadingPlanVersion(event.aggregateVersion());
order.setSourceLoadingPlanId(payload.loadingPlanId()); order.setSourceLoadingPlanVersion(event.aggregateVersion());
order.setSourceHandoverVersion(event.handoverVersion()); order.setSourcePayloadHash(orderHash);
order.setCreatedAt(LocalDateTime.now()); order.setUpdatedAt(LocalDateTime.now());
try {
@@ -10,6 +10,7 @@ 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,6 +27,7 @@ public class PlatformUsTruckOrderController {
@Resource private UsTruckOrderService service;
@GetMapping
@PreAuthorize("hasAuthority('platform:lastleg:us-trucking:list')")
public ApiResponse<PageResult<UsTruckOrderResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid UsTruckOrderRequests.Query query) {
requirePlatform(user);
@@ -33,6 +35,7 @@ public class PlatformUsTruckOrderController {
}
@GetMapping("/{orderNo}")
@PreAuthorize("hasAuthority('platform:lastleg:us-trucking:list')")
public ApiResponse<UsTruckOrderResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String customerId, @PathVariable String orderNo) {
requirePlatform(user);
@@ -40,6 +43,7 @@ public class PlatformUsTruckOrderController {
}
@PatchMapping("/{orderNo}/status")
@PreAuthorize("hasAuthority('platform:lastleg:us-trucking:status')")
public ApiResponse<UsTruckOrderResponses.Detail> changeStatus(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String customerId, @PathVariable String orderNo,
@Valid @RequestBody UsTruckOrderRequests.ChangeStatus request) {
@@ -48,10 +52,11 @@ public class PlatformUsTruckOrderController {
}
@PostMapping("/{orderNo}/receive-pallets")
@PreAuthorize("hasAuthority('platform:lastleg:us-trucking:receive')")
public ApiResponse<UsTruckOrderResponses.Detail> receivePallets(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String customerId, @PathVariable String orderNo,
@Valid @RequestBody UsTruckOrderRequests.ReceivePallets request) {
requirePermission(user);
requirePermission(user, "platform:lastleg:us-trucking:receive");
return ApiResponse.success(service.receivePallets(requiredCustomer(customerId), orderNo, operator(user), request));
}
@@ -63,10 +68,14 @@ public class PlatformUsTruckOrderController {
}
private void requirePermission(AuthenticatedUser user) {
requirePermission(user, UsTruckOrderService.MANAGE_PERMISSION);
}
private void requirePermission(AuthenticatedUser user, String permission) {
requirePlatform(user);
if (user.permissions() == null || !user.permissions().contains(UsTruckOrderService.MANAGE_PERMISSION)) {
if (user.permissions() == null || !user.permissions().contains(permission)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
UsTruckOrderService.MANAGE_PERMISSION + " permission is required");
permission + " permission is required");
}
}
@@ -35,7 +35,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 = "platform:lastleg:us-trucking: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;