This commit is contained in:
2026-09-18 09:59:04 +08:00
106 changed files with 5113 additions and 471 deletions
@@ -14,6 +14,14 @@ import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.MediaType;
import org.springframework.http.HttpHeaders;
import org.springframework.http.CacheControl;
import org.springframework.http.ContentDisposition;
import org.springframework.http.ResponseEntity;
import java.nio.charset.StandardCharsets;
import java.util.List;
@RestController
@RequestMapping("/api/merchant/v1/last-leg/warehouse-transfers")
@@ -27,6 +35,30 @@ public class MerchantWarehouseTransferController {
return ApiResponse.success(service.create(customer(user), operator(user), request));
}
@PutMapping("/{transferNo}")
public ApiResponse<WarehouseTransferResponses.Detail> updateDraft(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo,
@Valid @RequestBody WarehouseTransferRequests.Create request) {
return ApiResponse.success(service.updateDraft(customer(user), transferNo, operator(user), request));
}
/**
* POST 兼容入口:部分本地网关只放行 POST,草稿保存不能因此阻断提交。
* 与 PUT 更新草稿使用完全相同的服务逻辑和租户校验。
*/
@PostMapping("/{transferNo}/save-draft")
public ApiResponse<WarehouseTransferResponses.Detail> saveDraft(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo,
@Valid @RequestBody WarehouseTransferRequests.Create request) {
return ApiResponse.success(service.updateDraft(customer(user), transferNo, operator(user), request));
}
@PostMapping("/{transferNo}/submit")
public ApiResponse<WarehouseTransferResponses.Detail> submit(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String transferNo) {
return ApiResponse.success(service.submit(customer(user), transferNo, operator(user)));
}
@GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRANSFER_LIST + "')")
public ApiResponse<PageResult<WarehouseTransferResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@@ -41,6 +73,29 @@ public class MerchantWarehouseTransferController {
return ApiResponse.success(service.detail(customer(user), transferNo));
}
@GetMapping("/{transferNo}/carton-label")
public ApiResponse<WarehouseTransferResponses.Label> cartonLabel(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String transferNo) {
return ApiResponse.success(service.cartonLabel(customer(user), transferNo));
}
@GetMapping("/{transferNo}/measurement-photos/files")
public ApiResponse<List<WarehouseTransferResponses.MeasurementPhoto>> measurementPhotos(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo) {
return ApiResponse.success(service.measurementPhotos(customer(user), transferNo));
}
@GetMapping("/{transferNo}/measurement-photos/files/{fileId}/download")
public ResponseEntity<byte[]> downloadMeasurementPhoto(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String transferNo,
@PathVariable long fileId) {
var file = service.downloadMeasurementPhoto(customer(user), transferNo, fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.inline().filename(file.fileName(), StandardCharsets.UTF_8).build().toString())
.contentType(MediaType.parseMediaType(file.contentType())).body(file.content());
}
@PostMapping("/{transferNo}/confirm-measurement")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRANSFER_CONFIRM + "')")
public ApiResponse<WarehouseTransferResponses.Detail> confirmMeasurement(
@@ -16,7 +16,16 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.http.MediaType;
import org.springframework.http.HttpHeaders;
import org.springframework.http.CacheControl;
import org.springframework.http.ContentDisposition;
import org.springframework.http.ResponseEntity;
import org.springframework.web.multipart.MultipartFile;
import java.nio.charset.StandardCharsets;
import java.util.List;
@RestController
@@ -32,6 +41,42 @@ public class PdaWarehouseTransferController {
return ApiResponse.success(service.pdaCandidates(identity.overseasWarehouseId()));
}
@GetMapping("/{transferNo}")
public ApiResponse<WarehouseTransferResponses.Detail> detail(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.pdaDetail(identity.overseasWarehouseId(), transferNo));
}
@PostMapping(value = "/{transferNo}/measurement-photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<WarehouseTransferResponses.MeasurementPhoto> uploadMeasurementPhoto(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo,
@RequestParam("file") MultipartFile file) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
long operatorUserId = user.userId() == null ? 0L : user.userId();
return ApiResponse.success(service.uploadMeasurementPhoto(identity.operatorKey(), operatorUserId,
identity.overseasWarehouseId(), transferNo, file));
}
@GetMapping("/{transferNo}/measurement-photos/files")
public ApiResponse<List<WarehouseTransferResponses.MeasurementPhoto>> measurementPhotos(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.pdaMeasurementPhotos(identity.overseasWarehouseId(), transferNo));
}
@GetMapping("/{transferNo}/measurement-photos/files/{fileId}/download")
public ResponseEntity<byte[]> downloadMeasurementPhoto(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String transferNo,
@PathVariable long fileId) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
var file = service.downloadPdaMeasurementPhoto(identity.overseasWarehouseId(), transferNo, fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.inline().filename(file.fileName(), StandardCharsets.UTF_8).build().toString())
.contentType(MediaType.parseMediaType(file.contentType())).body(file.content());
}
@PostMapping("/{transferNo}/palletize")
public ApiResponse<WarehouseTransferResponses.Detail> palletize(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo,
@@ -40,4 +85,49 @@ public class PdaWarehouseTransferController {
return ApiResponse.success(service.palletize(identity.operatorKey(), identity.overseasWarehouseId(),
transferNo, request));
}
@PostMapping("/{transferNo}/pallets/drafts")
public ApiResponse<WarehouseTransferResponses.Detail> savePalletDraft(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo,
@Valid @RequestBody WarehouseTransferRequests.PalletDraft request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.savePalletDraft(identity.overseasWarehouseId(), transferNo, request));
}
@PostMapping("/{transferNo}/pallets/{palletId}/generate")
public ApiResponse<WarehouseTransferResponses.Detail> generatePallet(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, @PathVariable long palletId,
@Valid @RequestBody WarehouseTransferRequests.PalletAction request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.generatePallet(identity.overseasWarehouseId(), transferNo, palletId, request));
}
@PostMapping("/{transferNo}/pallets/{palletId}/bind")
public ApiResponse<WarehouseTransferResponses.Detail> bindPallet(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, @PathVariable long palletId,
@Valid @RequestBody WarehouseTransferRequests.PalletBind request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.bindPallet(identity.overseasWarehouseId(), transferNo, palletId, request));
}
@org.springframework.web.bind.annotation.DeleteMapping("/{transferNo}/pallets/{palletId}")
public ApiResponse<WarehouseTransferResponses.Detail> deletePalletDraft(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, @PathVariable long palletId) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.deletePalletDraft(identity.overseasWarehouseId(), transferNo, palletId));
}
@GetMapping("/{transferNo}/pallets/{warehousePalletId}/label")
public ResponseEntity<byte[]> palletLabel(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String transferNo,
@PathVariable long warehousePalletId,
@RequestHeader(value = "X-Request-Id", required = false) String requestId) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
var file = service.printPdaPalletLabel(identity.overseasWarehouseId(), transferNo,
warehousePalletId, requestId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment().filename(file.fileName(), StandardCharsets.UTF_8).build().toString())
.contentType(MediaType.parseMediaType(file.contentType())).body(file.content());
}
}
@@ -24,14 +24,14 @@ public class WarehouseTransferController {
@PostMapping
public ApiResponse<WarehouseTransferResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @Valid @RequestBody WarehouseTransferRequests.Create request) {
return ApiResponse.success(service.create(scope(user, customerId), operator(user), request));
return ApiResponse.success(service.createAndSubmit(scope(user, customerId), operator(user), request));
}
@GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')")
public ApiResponse<PageResult<WarehouseTransferResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @Valid WarehouseTransferRequests.Query query) {
return ApiResponse.success(service.page(scope(user, customerId), query));
return ApiResponse.success(service.page(scope(user, customerId), query, false));
}
@GetMapping("/{transferNo}")
@@ -56,6 +56,17 @@ public class WarehouseTransferController {
return ApiResponse.success(service.review(scope(user, customerId), transferNo, operator(user), request));
}
@PostMapping("/bind-destination")
@PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CREATE + "')")
public ApiResponse<java.util.List<WarehouseTransferResponses.Detail>> bindDestination(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody WarehouseTransferRequests.BindDestination request) {
if (user == null || user.tenantId() == null || user.tenantId() != 0L || !"PLATFORM".equals(user.appCode())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform identity is required");
}
return ApiResponse.success(service.bindDestination(operator(user), request));
}
@PostMapping("/{transferNo}/allocations")
public ApiResponse<WarehouseTransferResponses.Detail> allocate(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @PathVariable String transferNo,
@@ -21,7 +21,17 @@ public final class WarehouseTransferRequests {
@Size(max = 200) List<@NotNull Long> sourceLastlegOrderIds,
@NotBlank @Size(max = 128) String operatingWarehouseId,
@Size(max = 128) String fbaWarehouseCode,
@Size(max = 16) String destinationType,
@Size(max = 128) String destinationWarehouseId,
@Size(max = 128) String destinationWarehouseCode,
@Size(max = 255) String destinationWarehouseName,
@Size(max = 1000) String privateDestinationAddress,
@Size(max = 32) String privateDestinationPostalCode,
@Size(max = 128) String privateDestinationContactName,
@Size(max = 64) String privateDestinationContactPhone,
@NotBlank @Size(max = 1000) String deliveryAddress,
@Size(max = 128) String pickupContactName,
@Size(max = 64) String pickupContactPhone,
@Positive int packageCount,
@NotNull @DecimalMin(value = "0.0") BigDecimal volumeCbm,
@NotBlank @Size(max = 64) String deliveryMethod,
@@ -46,12 +56,29 @@ public final class WarehouseTransferRequests {
String deliveryMethod, LocalDateTime estimatedArrivalAt, String fbaShipmentNo,
String trackingNo, String fbaBoxNos, String remark, boolean needRelabel,
String idempotencyKey, List<Item> items) {
this(inboundCode, sourceLastlegOrderIds, operatingWarehouseId, fbaWarehouseCode, deliveryAddress,
packageCount, volumeCbm, deliveryMethod, estimatedArrivalAt, null, fbaShipmentNo, trackingNo,
this(inboundCode, sourceLastlegOrderIds, operatingWarehouseId, fbaWarehouseCode,
null, null, null, null, null, null, null, null,
deliveryAddress, null, null, packageCount, volumeCbm, deliveryMethod, estimatedArrivalAt, null, fbaShipmentNo, trackingNo,
fbaBoxNos, remark, needRelabel, null, null, null, null, null, idempotencyKey, items, null);
}
}
public record DestinationSelection(
@NotBlank @Size(max = 128) String customerId,
@NotBlank @Size(max = 64) String transferNo) {}
public record BindDestination(
@NotEmpty @Size(max = 100) List<@Valid DestinationSelection> transfers,
@NotBlank @jakarta.validation.constraints.Pattern(regexp = "FBA|PRIVATE") String destinationType,
@Size(max = 128) String destinationWarehouseId,
@Size(max = 128) String destinationWarehouseCode,
@Size(max = 255) String destinationWarehouseName,
@Size(max = 1000) String privateDestinationAddress,
@Size(max = 32) String privateDestinationPostalCode,
@Size(max = 128) String privateDestinationContactName,
@Size(max = 64) String privateDestinationContactPhone,
@NotBlank @Size(max = 255) String idempotencyKey) {}
/** 审核通过备注选填;驳回必须提供非空原因,由服务在任何状态写入前校验。 */
public record Review(@NotBlank @Size(max = 32) String decision,
@Size(max = 1000) String remark) {}
@@ -72,19 +99,34 @@ public final class WarehouseTransferRequests {
public record Palletize(
@NotBlank @Size(max = 255) String idempotencyKey,
@NotEmpty @Size(max = 100) List<@Valid Pallet> pallets
@NotEmpty @Size(max = 20) List<@NotNull Long> photoFileIds
) {}
public record PalletDraft(
Long palletId,
@NotBlank @Size(max = 255) String idempotencyKey,
@Positive int cartonCount,
@NotNull @DecimalMin("0.01") BigDecimal lengthCm,
@NotNull @DecimalMin("0.01") BigDecimal widthCm,
@NotNull @DecimalMin("0.01") BigDecimal heightCm,
@NotNull @DecimalMin("0.000001") BigDecimal volumeCbm,
@NotNull @DecimalMin("0.01") BigDecimal grossWeightKg
) {}
public record PalletAction(@NotBlank @Size(max = 255) String idempotencyKey) {}
public record PalletBind(@NotBlank @Size(max = 128) String palletCode,
@NotBlank @Size(max = 255) String idempotencyKey) {}
public record Pallet(
@Size(max = 128) String palletCode,
@Positive int cartonCount,
@NotNull @DecimalMin("0.01") BigDecimal lengthCm,
@NotNull @DecimalMin("0.01") BigDecimal widthCm,
@NotNull @DecimalMin("0.01") BigDecimal heightCm,
@NotNull @DecimalMin("0.000001") BigDecimal volumeCbm,
@NotNull @DecimalMin("0.01") BigDecimal grossWeightKg,
@NotEmpty @Size(max = 500) List<@Valid PalletItem> items
@NotNull @DecimalMin("0.01") BigDecimal grossWeightKg
) {}
public record PalletItem(@NotNull Long transferItemId, @Positive int packageCount) {}
public record CustomerConfirm(@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark) {}
@@ -106,8 +148,8 @@ public final class WarehouseTransferRequests {
) {}
public record Item(
@NotBlank @Size(max = 128) String skuCode,
@NotBlank @Size(max = 255) String productNameZh,
@Size(max = 128) String skuCode,
@Size(max = 255) String productNameZh,
@Positive int packageCount,
@Size(max = 255) String shippingMark
) {}
@@ -115,7 +157,7 @@ public final class WarehouseTransferRequests {
public record RelabelItem(
@NotBlank @Size(max = 32) String operationType,
@NotEmpty @Size(max = 500) List<@NotBlank @Size(max = 255) String> customerCartonMarks,
@NotBlank @Size(max = 128) String originalSku,
@Size(max = 128) String originalSku,
@Size(max = 128) String newSku,
@Positive int quantity,
@Size(max = 255) String fbaCartonMark,
@@ -9,12 +9,16 @@ public final class WarehouseTransferResponses {
public record Item(Long id, String skuCode, String productNameZh, int packageCount, String shippingMark) {}
public record Carton(String cartonMark, String customerCartonMark, int cartonIndex, int totalCartons, String status) {}
public record Label(String transferNo, List<Carton> cartons, String printableHtml) {}
public record DestinationResult(String customerId, String transferNo) {}
public record PalletItem(Long transferItemId, String skuCode, int packageCount) {}
public record MeasurementPhoto(Long fileId, String fileName, String contentType, long sizeBytes,
LocalDateTime uploadedAt) {}
public record Pallet(Long id, int sequenceNo, Long warehousePalletId, String palletNo,
Long warehouseLocationId, String warehouseLocationCode, int cartonCount,
BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm,
BigDecimal grossWeightKg, BigDecimal volumeCbm, String status,
List<PalletItem> items, LocalDateTime completedAt) {}
String fbaWarehouseCode, String labelDownloadPath,
List<PalletItem> items, LocalDateTime boundAt, LocalDateTime completedAt) {}
public record RelabelBinding(String operationType, String customerCartonMark, String originalSku,
String newSku, int quantity, String fbaCartonMark,
String destinationWarehouseCode, String remark) {}
@@ -24,19 +28,28 @@ public final class WarehouseTransferResponses {
Long destinationInventoryPositionId) {}
public record Summary(Long id, String transferNo, String inboundCode, Long lastlegOrderId,
String lastlegOrderNo, String operatingWarehouseId, String fbaWarehouseCode,
String destinationType, String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, String deliveryMethod, boolean needRelabel, String relabelNo,
String status, LocalDateTime estimatedArrivalAt, LocalDateTime createdAt) {}
public record Detail(Long id, String transferNo, Long inboundOrderId, String inboundCode,
Long lastlegOrderId, String lastlegOrderNo, String orderType, String orderStatus,
String status,
boolean needRelabel, Long relabelApplicationId, String relabelNo, String relabelStatus,
String operatingWarehouseId, String fbaWarehouseCode, String deliveryAddress,
int packageCount, String deliveryMethod, LocalDateTime estimatedArrivalAt,
String operatingWarehouseId, String fbaWarehouseCode, String deliveryAddress,
String destinationType, String destinationWarehouseId, String destinationWarehouseCode,
String destinationWarehouseName, String privateDestinationAddress,
String privateDestinationPostalCode,
String privateDestinationContactName, String privateDestinationContactPhone,
String pickupContactName, String pickupContactPhone,
int packageCount, BigDecimal volumeCbm, String deliveryMethod, LocalDateTime estimatedArrivalAt,
LocalDateTime estimatedShipAt,
String fbaShipmentNo, String trackingNo, String fbaBoxNos, String remark,
LocalDateTime labelDeadline, String relabelContactName, String relabelContactPhone,
String relabelDeliveryMethod,
String reviewDecision, String reviewRemark, String reviewedBy, LocalDateTime reviewedAt,
List<SourceOrder> sourceOrders, List<Item> items, List<Carton> cartons,
List<Pallet> pallets, List<RelabelBinding> relabelBindings,
List<Allocation> allocations, LocalDateTime customerConfirmedAt,
List<Allocation> allocations, List<MeasurementPhoto> measurementPhotos,
LocalDateTime customerConfirmedAt,
LocalDateTime actualShipAt, LocalDateTime createdAt) {}
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.transfer.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_warehouse_transfer_measurement_photo")
public class WarehouseTransferMeasurementPhoto {
@TableId(type = IdType.INPUT) private Long id;
private Long transferOrderId;
private String customerId;
private Long fileId;
private String fileName;
private String contentType;
private Long sizeBytes;
private String uploadedBy;
private LocalDateTime uploadedAt;
private LocalDateTime submittedAt;
}
@@ -18,7 +18,17 @@ public class WarehouseTransferOrder {
private String lastlegOrderNo;
private String operatingWarehouseId;
private String fbaWarehouseCode;
private String destinationType;
private String destinationWarehouseId;
private String destinationWarehouseCode;
private String destinationWarehouseName;
private String privateDestinationAddress;
private String privateDestinationPostalCode;
private String privateDestinationContactName;
private String privateDestinationContactPhone;
private String deliveryAddress;
private String pickupContactName;
private String pickupContactPhone;
private Integer packageCount;
private String deliveryMethod;
private LocalDateTime estimatedArrivalAt;
@@ -50,6 +60,8 @@ public class WarehouseTransferOrder {
private String palletizeRequestHash;
private String confirmationIdempotencyKey;
private String confirmationRequestHash;
private String destinationBindingIdempotencyKey;
private String destinationBindingRequestHash;
private LocalDateTime customerConfirmedAt;
private String idempotencyKey;
private String requestHash;
@@ -27,6 +27,11 @@ public class WarehouseTransferPallet {
private BigDecimal grossWeightKg;
private BigDecimal volumeCbm;
private String status;
private String draftIdempotencyKey;
private String draftRequestHash;
private String generateIdempotencyKey;
private String bindIdempotencyKey;
private LocalDateTime boundAt;
private LocalDateTime completedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@@ -0,0 +1,106 @@
package com.baiyue.lastleg.transfer.gateway;
import com.baiyue.lastleg.config.LastlegRestClientFactory;
import com.baiyue.lastleg.config.LastlegRestClientProperties;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.*;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
@Component
public class HttpTransferMeasurementPhotoFileGateway implements TransferMeasurementPhotoFileGateway {
private static final String TYPE = "OVERSEAS_TRANSFER_MEASUREMENT_PHOTO";
@Resource private RestClient.Builder restClientBuilder;
@Resource private LastlegRestClientFactory restClientFactory;
@Resource private LastlegRestClientProperties restClientProperties;
@Value("${baiyue.clients.file.url:http://127.0.0.1:9206}") private String baseUrl;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
private RestClient client;
@PostConstruct void initializeClient() {
client = restClientFactory.build(restClientBuilder, baseUrl, restClientProperties.getRelabelPhotoFile());
}
@Override public UploadedFile upload(String customerId, long transferId, long operatorId, MultipartFile file) {
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 ResponseStatusException(HttpStatus.BAD_REQUEST, "Cannot read transfer measurement photo", exception);
}
MetadataEnvelope response = client.post().uri("/internal/file/v1/business-files/{tenant}/{type}/{business}",
tenant(customerId), TYPE, transferId)
.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 failure(result.getStatusCode(), "Transfer measurement photo upload failed"); })
.body(MetadataEnvelope.class);
Metadata data = success(response, "Transfer measurement photo upload failed");
return new UploadedFile(data.fileId(), data.fileName(), data.contentType(), data.sizeBytes() == null ? 0 : data.sizeBytes());
}
@Override public void validate(String customerId, long transferId, long fileId) {
long tenant = tenant(customerId);
MetadataEnvelope response = client.get().uri("/internal/file/v1/business-files/{tenant}/{type}/{business}/{file}",
tenant, TYPE, transferId, fileId).header("X-Internal-Service-Token", internalToken).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> { throw failure(result.getStatusCode(), "Transfer measurement photo ownership is invalid"); })
.body(MetadataEnvelope.class);
Metadata data = success(response, "Transfer measurement photo ownership is invalid");
if (data.fileId() != fileId || data.tenantId() == null || data.tenantId() != tenant
|| !TYPE.equals(data.businessType()) || !String.valueOf(transferId).equals(data.businessId())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Transfer measurement photo ownership is invalid");
}
}
@Override public DownloadedFile download(String customerId, long transferId, long fileId) {
validate(customerId, transferId, fileId);
ResponseEntity<byte[]> response = client.get().uri("/internal/file/v1/business-files/{tenant}/{type}/{business}/{file}/content",
tenant(customerId), TYPE, transferId, fileId).header("X-Internal-Service-Token", internalToken).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> { throw failure(result.getStatusCode(), "Transfer measurement photo download failed"); })
.toEntity(byte[].class);
String name = response.getHeaders().getContentDisposition().getFilename();
String type = response.getHeaders().getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM_VALUE
: response.getHeaders().getContentType().toString();
return new DownloadedFile(response.getBody() == null ? new byte[0] : response.getBody(),
name == null ? "transfer-measurement-photo" : name, type);
}
private static long tenant(String value) {
try { return Long.parseLong(value); }
catch (NumberFormatException exception) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Customer ID must be numeric", exception); }
}
private static Metadata success(MetadataEnvelope response, String fallback) {
if (response == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, fallback);
if (!"200".equals(response.code())) {
HttpStatus status = switch (String.valueOf(response.code())) {
case "A0400", "A0422" -> HttpStatus.BAD_REQUEST;
case "A0403" -> HttpStatus.FORBIDDEN;
case "A0404" -> HttpStatus.NOT_FOUND;
default -> HttpStatus.SERVICE_UNAVAILABLE;
};
throw new ResponseStatusException(status,
response.message() == null || response.message().isBlank() ? fallback : response.message());
}
if (response.data() == null || response.data().fileId() == null) {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
"File service returned no transfer measurement photo metadata");
}
return response.data();
}
private static ResponseStatusException failure(HttpStatusCode status, String message) {
return new ResponseStatusException(status.is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, message);
}
private record MetadataEnvelope(String code, String message, Metadata data) {}
private record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
String fileName, String contentType, Long sizeBytes) {}
}
@@ -0,0 +1,12 @@
package com.baiyue.lastleg.transfer.gateway;
import org.springframework.web.multipart.MultipartFile;
public interface TransferMeasurementPhotoFileGateway {
UploadedFile upload(String customerId, long transferId, long operatorId, MultipartFile file);
void validate(String customerId, long transferId, long fileId);
DownloadedFile download(String customerId, long transferId, long fileId);
record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) {}
record DownloadedFile(byte[] content, String fileName, String contentType) {}
}
@@ -0,0 +1,33 @@
package com.baiyue.lastleg.transfer.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferMeasurementPhoto;
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 WarehouseTransferMeasurementPhotoMapper extends BaseMapper<WarehouseTransferMeasurementPhoto> {
@Select("SELECT * FROM ll_warehouse_transfer_measurement_photo WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} ORDER BY uploaded_at,id")
List<WarehouseTransferMeasurementPhoto> listOrder(@Param("customerId") String customerId,
@Param("transferOrderId") Long transferOrderId);
@Select("SELECT * FROM ll_warehouse_transfer_measurement_photo WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} AND submitted_at IS NOT NULL ORDER BY submitted_at,id")
List<WarehouseTransferMeasurementPhoto> listSubmitted(@Param("customerId") String customerId,
@Param("transferOrderId") Long transferOrderId);
@Select("SELECT * FROM ll_warehouse_transfer_measurement_photo WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} AND file_id=#{fileId} LIMIT 1")
WarehouseTransferMeasurementPhoto findFile(@Param("customerId") String customerId,
@Param("transferOrderId") Long transferOrderId,
@Param("fileId") Long fileId);
@Select("SELECT * FROM ll_warehouse_transfer_measurement_photo WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} AND file_id=#{fileId} AND submitted_at IS NOT NULL LIMIT 1")
WarehouseTransferMeasurementPhoto findSubmittedFile(@Param("customerId") String customerId,
@Param("transferOrderId") Long transferOrderId,
@Param("fileId") Long fileId);
@Update("<script>UPDATE ll_warehouse_transfer_measurement_photo SET submitted_at=#{submittedAt} WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} AND file_id IN <foreach collection='fileIds' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
int markSubmitted(@Param("customerId") String customerId, @Param("transferOrderId") Long transferOrderId,
@Param("fileIds") java.util.Collection<Long> fileIds,
@Param("submittedAt") java.time.LocalDateTime submittedAt);
}
@@ -14,6 +14,8 @@ import org.apache.ibatis.annotations.Update;
WarehouseTransferOrder lockNo(@Param("customerId") String customerId, @Param("transferNo") String transferNo);
@Select("SELECT * FROM ll_warehouse_transfer_order WHERE transfer_no=#{transferNo} AND operating_warehouse_id=#{warehouseId} LIMIT 1 FOR UPDATE")
WarehouseTransferOrder lockForPda(@Param("transferNo") String transferNo, @Param("warehouseId") String warehouseId);
@Select("SELECT * FROM ll_warehouse_transfer_order WHERE transfer_no=#{transferNo} AND operating_warehouse_id=#{warehouseId} LIMIT 1")
WarehouseTransferOrder findNoByWarehouse(@Param("transferNo") String transferNo, @Param("warehouseId") String warehouseId);
@Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} LIMIT 1 FOR UPDATE")
WarehouseTransferOrder lockByRelabelNo(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo);
@Select("SELECT * FROM ll_warehouse_transfer_order WHERE operating_warehouse_id=#{warehouseId} AND status='WAIT_PDA_RECEIVING' ORDER BY created_at,id")
@@ -13,4 +13,9 @@ public interface WarehouseTransferPalletMapper extends BaseMapper<WarehouseTrans
@Select("SELECT * FROM ll_warehouse_transfer_pallet WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} ORDER BY sequence_no")
List<WarehouseTransferPallet> listOrder(@Param("customerId") String customerId,
@Param("transferOrderId") Long transferOrderId);
@Select("SELECT * FROM ll_warehouse_transfer_pallet WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} AND id=#{id} FOR UPDATE")
WarehouseTransferPallet lockOne(@Param("customerId") String customerId,
@Param("transferOrderId") Long transferOrderId,
@Param("id") Long id);
}
@@ -0,0 +1,37 @@
package com.baiyue.lastleg.transfer.model;
import java.util.Arrays;
/** 仓储转运单状态。状态编码持久化到数据库,中文名称用于接口和前端展示。 */
public enum WarehouseTransferStatus {
DRAFT("草稿"),
PENDING_REVIEW("待审核"),
APPROVED("审核通过"),
REJECTED("审核驳回"),
WAIT_PDA_RECEIVING("待PDA收货"),
INBOUND_MEASURING("入库测量中"),
WAIT_CUSTOMER_CONFIRMATION("待客户确认实测"),
WAIT_RELABEL("待换标"),
WAIT_FBA_DESTINATION("待确认目的仓"),
READY_FOR_DISPATCH("待排车"),
WAIT_OUTBOUND("待出库"),
PENDING_ASSIGNMENT("待分配"),
ALLOCATED("已分配库存"),
OUTBOUNDED("已出库"),
PARTIALLY_RECEIVED("部分收货"),
RECEIVED("已收货"),
INBOUND_COMPLETED("入库完成"),
COMPLETED("已完成");
private final String label;
WarehouseTransferStatus(String label) { this.label = label; }
public String code() { return name(); }
public String label() { return label; }
public static WarehouseTransferStatus fromCode(String code) {
return Arrays.stream(values()).filter(value -> value.name().equals(code)).findFirst()
.orElseThrow(() -> new IllegalArgumentException("Unsupported warehouse transfer status: " + code));
}
}