提交
This commit is contained in:
+6
@@ -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(
|
||||
|
||||
+12
@@ -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,
|
||||
|
||||
+79
@@ -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) {}
|
||||
}
|
||||
+10
@@ -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) {}
|
||||
}
|
||||
+41
@@ -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");
|
||||
|
||||
+5
-12
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user