This commit is contained in:
2026-09-01 11:09:14 +08:00
191 changed files with 6486 additions and 403 deletions
@@ -10,6 +10,7 @@ import java.time.Duration;
public class LastlegRestClientProperties {
private ClientTimeouts unloadPhotoFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30));
private ClientTimeouts relabelPhotoFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30));
private ClientTimeouts customerVisitFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30));
private ClientTimeouts warehouseResource = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(5));
private ClientTimeouts palletPhysical = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(5));
@@ -17,6 +18,8 @@ public class LastlegRestClientProperties {
public void setUnloadPhotoFile(ClientTimeouts value) { unloadPhotoFile = required(value, "unload-photo-file"); }
public ClientTimeouts getRelabelPhotoFile() { return relabelPhotoFile; }
public void setRelabelPhotoFile(ClientTimeouts value) { relabelPhotoFile = required(value, "relabel-photo-file"); }
public ClientTimeouts getCustomerVisitFile() { return customerVisitFile; }
public void setCustomerVisitFile(ClientTimeouts value) { customerVisitFile = required(value, "customer-visit-file"); }
public ClientTimeouts getWarehouseResource() { return warehouseResource; }
public void setWarehouseResource(ClientTimeouts value) { warehouseResource = required(value, "warehouse-resource"); }
public ClientTimeouts getPalletPhysical() { return palletPhysical; }
@@ -51,4 +54,3 @@ public class LastlegRestClientProperties {
}
}
}
@@ -9,6 +9,7 @@ import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.dto.LastlegOrderSummary;
import com.baiyue.lastleg.dto.ContainerPlanProgressDetail;
import com.baiyue.lastleg.dto.ContainerPlanProgressSummary;
import com.baiyue.lastleg.entity.LastlegOrderFee;
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
import com.baiyue.lastleg.security.OverseasPermissions;
import com.baiyue.lastleg.service.LastlegOrderService;
@@ -107,6 +108,54 @@ public class LastlegOrderController {
return ApiResponse.success(orderService.detail(platformScope(user, customerId), id));
}
@PostMapping("/api/platform/v1/last-leg/orders/{id}/fees")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')")
@Operation(summary = "Add a fee to one tail-leg order", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT)
public ApiResponse<LastlegOrderFee> createFee(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@PathVariable long id,
@Valid @RequestBody LastlegOrderRequests.CreateFee request) {
return ApiResponse.success(orderService.createFee(platformScope(user, customerId), id, request, operatorName(user)));
}
@PutMapping("/api/platform/v1/last-leg/orders/{id}/detained")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')")
@Operation(summary = "Set tail-leg order hold status", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT)
public ApiResponse<Void> setDetained(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@PathVariable long id,
@Valid @RequestBody LastlegOrderRequests.SetDetained request) {
orderService.setDetained(platformScope(user, customerId), id, request,
String.valueOf(user.userId()), operatorName(user));
return ApiResponse.success(null);
}
@PutMapping("/api/platform/v1/last-leg/orders/{id}/pallet-merge-required")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')")
@Operation(summary = "Set the PDA pallet-merge reminder for one tail-leg order",
description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT)
public ApiResponse<Void> setPalletMergeRequired(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@PathVariable long id,
@Valid @RequestBody LastlegOrderRequests.SetPalletMergeRequired request) {
orderService.setPalletMergeRequired(platformScope(user, customerId), id, request, operatorName(user));
return ApiResponse.success(null);
}
@PutMapping("/api/platform/v1/last-leg/orders/pallet-merge-required/batch")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')")
@Operation(summary = "Mark a selected tail-leg order group for PDA pallet merge")
public ApiResponse<Void> setPalletMergeBatch(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid @RequestBody LastlegOrderRequests.SetPalletMergeBatch request) {
orderService.setPalletMergeBatch(platformScope(user, customerId), request, operatorName(user));
return ApiResponse.success(null);
}
@GetMapping(value = "/api/platform/v1/last-leg/orders/export", produces = "text/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)
@@ -156,4 +205,8 @@ public class LastlegOrderController {
return requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)
? null : requestedCustomer.trim();
}
private String operatorName(AuthenticatedUser user) {
return user.username() == null || user.username().isBlank() ? String.valueOf(user.userId()) : user.username();
}
}
@@ -0,0 +1,92 @@
package com.baiyue.lastleg.customervisit.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.customervisit.dto.CustomerVisitRequests;
import com.baiyue.lastleg.customervisit.dto.CustomerVisitResponses;
import com.baiyue.lastleg.customervisit.service.CustomerVisitService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
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.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/platform/v1/last-leg")
public class CustomerVisitController {
@Resource private CustomerVisitService service;
@GetMapping("/customer-visits")
public ApiResponse<CustomerVisitResponses.Page> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid CustomerVisitRequests.Query query) {
return ApiResponse.success(service.page(scope(user), query));
}
@GetMapping("/customer-visits/{visitId}")
public ApiResponse<CustomerVisitResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@PathVariable long visitId) {
return ApiResponse.success(service.detail(requiredScope(user, customerId), visitId));
}
@PutMapping("/customer-visits/{visitId}")
public ApiResponse<CustomerVisitResponses.Detail> save(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@PathVariable long visitId,
@Valid @RequestBody CustomerVisitRequests.Save request) {
return ApiResponse.success(service.save(requiredScope(user, customerId), operator(user), visitId, request));
}
@PostMapping(value = "/customer-visits/{visitId}/attachments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<CustomerVisitResponses.UploadedFile> upload(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@PathVariable long visitId,
@RequestPart("file") MultipartFile file) {
if (user.userId() == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "当前用户缺少有效ID");
return ApiResponse.success(service.upload(requiredScope(user, customerId), user.userId(), visitId, file));
}
@PostMapping("/delivery-orders/{deliveryNo}/complete")
public ApiResponse<CustomerVisitResponses.Completed> completeDelivery(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@PathVariable String deliveryNo) {
return ApiResponse.success(service.completeDelivery(requiredScope(user, customerId), operator(user), deliveryNo));
}
@PutMapping("/delivery-orders/{deliveryNo}/status")
public ApiResponse<CustomerVisitResponses.Completed> updateDeliveryStatus(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@PathVariable String deliveryNo, @Valid @RequestBody CustomerVisitRequests.DeliveryStatus request) {
return ApiResponse.success(service.updateDeliveryStatus(
requiredScope(user, customerId), operator(user), deliveryNo, request));
}
private String scope(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
return "PLATFORM".equals(user.appCode()) && user.tenantId() == 0L ? null : String.valueOf(user.tenantId());
}
private String requiredScope(AuthenticatedUser user, String requestedCustomer) {
String own = scope(user);
if (own != null) return own;
if (!StringUtils.hasText(requestedCustomer)) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请选择客户");
return requestedCustomer.trim();
}
private String operator(AuthenticatedUser user) {
return user.userId() == null ? user.username() : String.valueOf(user.userId());
}
}
@@ -0,0 +1,35 @@
package com.baiyue.lastleg.customervisit.dto;
import jakarta.validation.Valid;
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.time.LocalDateTime;
import java.util.List;
public final class CustomerVisitRequests {
private CustomerVisitRequests() { }
public record Query(@Min(1) long page, @Min(1) @Max(200) long size,
String keyword, String status, String customerId,
LocalDateTime completedFrom, LocalDateTime completedTo) {
public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
}
public record Save(@NotNull Long selectedInboundOrderId,
@NotBlank @Size(max = 32) String visitResult,
@Size(max = 2000) String customerFeedback,
@Size(max = 1000) String remark,
@Valid @Size(max = 20) List<Attachment> attachments) { }
public record Attachment(@NotNull Long fileId,
@NotBlank @Size(max = 255) String fileName,
@Size(max = 128) String contentType,
@NotNull @Min(0) Long sizeBytes) { }
public record DeliveryStatus(@NotBlank @Size(max = 32) String status,
@Size(max = 500) String remark) { }
}
@@ -0,0 +1,31 @@
package com.baiyue.lastleg.customervisit.dto;
import java.time.LocalDateTime;
import java.util.List;
public final class CustomerVisitResponses {
private CustomerVisitResponses() { }
public record Row(Long id, String customerId, String deliveryNo, String customerName,
String contactName, String contactPhone, String destination,
LocalDateTime deliveryCompletedAt, String status,
String selectedInboundCode, String visitResult, LocalDateTime visitedAt,
int visitCount, List<Attachment> attachments) { }
public record Page(List<Row> records, long current, long size, long total, long pages,
long visitable, long pending, long completed) { }
public record InboundOption(Long inboundOrderId, String inboundCode, Long lastMileOrderId,
String lastMileOrderNo, String destinationWarehouseCode,
String destinationWarehouseName, int packageCount) { }
public record Attachment(Long fileId, String fileName, String contentType, long sizeBytes) { }
public record Detail(Row visit, List<InboundOption> inboundOrders,
String customerFeedback, String remark, String visitedBy) { }
public record UploadedFile(Long fileId, String fileName, String contentType, long sizeBytes) { }
public record Completed(Long visitId, String deliveryNo, String deliveryStatus,
String visitStatus, boolean replayed) { }
}
@@ -0,0 +1,37 @@
package com.baiyue.lastleg.customervisit.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_customer_visit")
public class CustomerVisit {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String customerId;
private Long deliveryOrderId;
private String deliveryNo;
private String customerName;
private String contactName;
private String contactPhone;
private String destination;
private LocalDateTime deliveryCompletedAt;
private String status;
private Long selectedLastMileOrderId;
private String selectedLastMileOrderNo;
private Long selectedInboundOrderId;
private String selectedInboundCode;
private String visitResult;
private String customerFeedback;
private String remark;
private String attachmentJson;
private String visitedBy;
private LocalDateTime visitedAt;
private Integer visitCount;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,10 @@
package com.baiyue.lastleg.customervisit.gateway;
import org.springframework.web.multipart.MultipartFile;
public interface CustomerVisitFileGateway {
UploadedFile upload(String customerId, long visitId, long operatorId, MultipartFile file);
void validate(String customerId, long visitId, long fileId);
record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) { }
}
@@ -0,0 +1,101 @@
package com.baiyue.lastleg.customervisit.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.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
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 HttpCustomerVisitFileGateway implements CustomerVisitFileGateway {
private static final String BUSINESS_TYPE = "OVERSEAS_CUSTOMER_VISIT";
@Resource private RestClient.Builder restClientBuilder;
@Resource private LastlegRestClientFactory restClientFactory;
@Resource private LastlegRestClientProperties restClientProperties;
@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 = restClientFactory.build(restClientBuilder, baseUrl, restClientProperties.getCustomerVisitFile());
}
@Override
public UploadedFile upload(String customerId, long visitId, 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, "无法读取回访资料", exception);
}
MetadataEnvelope response = client.post()
.uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}",
tenantId(customerId), BUSINESS_TYPE, visitId)
.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 ResponseStatusException(result.getStatusCode().is4xxClientError()
? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, "回访资料上传失败");
}).body(MetadataEnvelope.class);
requireSuccess(response, "回访资料上传失败");
Metadata data = response == null ? null : response.data();
if (data == null || data.fileId() == null) throw new ResponseStatusException(
HttpStatus.SERVICE_UNAVAILABLE, "文件服务未返回回访资料元数据");
return new UploadedFile(data.fileId(), data.fileName(), data.contentType(),
data.sizeBytes() == null ? 0L : data.sizeBytes());
}
@Override
public void validate(String customerId, long visitId, long fileId) {
long tenantId = tenantId(customerId);
MetadataEnvelope response = client.get()
.uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}",
tenantId, BUSINESS_TYPE, visitId, fileId)
.header("X-Internal-Service-Token", internalToken).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> {
throw new ResponseStatusException(result.getStatusCode().is4xxClientError()
? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, "回访资料不存在或归属不正确");
}).body(MetadataEnvelope.class);
requireSuccess(response, "回访资料不存在或归属不正确");
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(visitId).equals(data.businessId())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "回访资料与当前记录不匹配");
}
}
private static long tenantId(String customerId) {
try { return Long.parseLong(customerId); }
catch (NumberFormatException exception) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "客户ID格式无效", exception);
}
}
private static void requireSuccess(MetadataEnvelope response, String fallback) {
if (response == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, fallback);
if ("200".equals(response.code())) return;
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
response.message() == null || response.message().isBlank() ? fallback : response.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,17 @@
package com.baiyue.lastleg.customervisit.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.customervisit.entity.CustomerVisit;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface CustomerVisitMapper extends BaseMapper<CustomerVisit> {
@Select("SELECT * FROM ll_customer_visit WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryOrderId} LIMIT 1")
CustomerVisit findByDelivery(@Param("customerId") String customerId,
@Param("deliveryOrderId") Long deliveryOrderId);
@Select("SELECT * FROM ll_customer_visit WHERE customer_id=#{customerId} AND id=#{id} LIMIT 1 FOR UPDATE")
CustomerVisit lockById(@Param("customerId") String customerId, @Param("id") Long id);
}
@@ -0,0 +1,315 @@
package com.baiyue.lastleg.customervisit.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.lastleg.customervisit.dto.CustomerVisitRequests;
import com.baiyue.lastleg.customervisit.dto.CustomerVisitResponses;
import com.baiyue.lastleg.customervisit.entity.CustomerVisit;
import com.baiyue.lastleg.customervisit.gateway.CustomerVisitFileGateway;
import com.baiyue.lastleg.customervisit.mapper.CustomerVisitMapper;
import com.baiyue.lastleg.delivery.entity.DeliveryOperation;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper;
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
import com.baiyue.lastleg.entity.LastlegInboundOrder;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.mapper.LastlegInboundOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
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.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
@Service
public class CustomerVisitService {
private static final Set<String> RESULTS = Set.of("SATISFIED", "NORMAL", "FOLLOW_UP", "NO_CONTACT");
private static final long MAX_FILE_BYTES = 20L * 1024L * 1024L;
private static final Set<String> FILE_EXTENSIONS = Set.of(
"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "jpg", "jpeg", "png", "webp");
@Resource private CustomerVisitMapper visits;
@Resource private DeliveryOrderMapper deliveryOrders;
@Resource private DeliveryOperationMapper deliveryOperations;
@Resource private LastlegOrderMapper lastMileOrders;
@Resource private LastlegInboundOrderMapper inboundOrders;
@Resource private LastlegOrderStatusHistoryMapper histories;
@Resource private CustomerVisitFileGateway files;
@Resource private ObjectMapper objectMapper;
public CustomerVisitResponses.Page page(String scopedCustomerId, CustomerVisitRequests.Query query) {
String customerId = customerScope(scopedCustomerId, query.customerId());
LambdaQueryWrapper<CustomerVisit> filter = filter(customerId, query);
Page<CustomerVisit> result = visits.selectPage(new Page<>(query.page(), query.size()),
filter.orderByDesc(CustomerVisit::getDeliveryCompletedAt).orderByDesc(CustomerVisit::getId));
LambdaQueryWrapper<CustomerVisit> countScope = new LambdaQueryWrapper<CustomerVisit>()
.eq(StringUtils.hasText(customerId), CustomerVisit::getCustomerId, customerId);
long visitable = visits.selectCount(countScope);
long pending = visits.selectCount(new LambdaQueryWrapper<CustomerVisit>()
.eq(StringUtils.hasText(customerId), CustomerVisit::getCustomerId, customerId)
.eq(CustomerVisit::getStatus, "PENDING"));
long completed = visits.selectCount(new LambdaQueryWrapper<CustomerVisit>()
.eq(StringUtils.hasText(customerId), CustomerVisit::getCustomerId, customerId)
.eq(CustomerVisit::getStatus, "COMPLETED"));
return new CustomerVisitResponses.Page(result.getRecords().stream().map(this::row).toList(),
result.getCurrent(), result.getSize(), result.getTotal(), result.getPages(), visitable, pending, completed);
}
public CustomerVisitResponses.Detail detail(String customerId, long visitId) {
CustomerVisit visit = required(customerId, visitId);
return detail(visit);
}
@Transactional
public CustomerVisitResponses.Completed completeDelivery(String customerId, String operatorId, String deliveryNo) {
DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo);
if (delivery == null) throw notFound("派送单不存在");
if (Set.of("RETURN_PENDING", "REJECTED", "CANCELLED").contains(delivery.getStatus())) {
throw conflict("当前派送状态不能标记为派送完成");
}
boolean replayed = "DELIVERED".equals(delivery.getStatus());
LocalDateTime now = replayed && delivery.getSignedAt() != null ? delivery.getSignedAt() : LocalDateTime.now();
if (!replayed) {
delivery.setStatus("DELIVERED");
delivery.setSignedAt(now);
delivery.setUpdatedAt(now);
deliveryOrders.updateById(delivery);
for (LastlegOrder order : lastMileOrders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getCustomerId, customerId)
.eq(LastlegOrder::getDeliveryOrderNo, deliveryNo))) {
String from = order.getCurrentStatus();
order.setCurrentStatus("DELIVERED");
order.setSubOrderStatus("DELIVERY:DELIVERED");
order.setUpdatedAt(now);
lastMileOrders.updateById(order);
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory();
history.setCustomerId(customerId); history.setOrderId(order.getId()); history.setFromStatus(from);
history.setToStatus("DELIVERED"); history.setEventId("DELIVERY_COMPLETED:" + deliveryNo);
history.setRemark("派送完成"); history.setCreatedAt(now); histories.insert(history);
}
recordOperation(delivery, operatorId, "COMPLETE_DELIVERY", "派送完成并生成客户回访记录", now);
}
CustomerVisit visit = ensureVisit(delivery, now);
return new CustomerVisitResponses.Completed(visit.getId(), deliveryNo, delivery.getStatus(), visit.getStatus(), replayed);
}
@Transactional
public CustomerVisitResponses.Completed updateDeliveryStatus(String customerId, String operatorId,
String deliveryNo,
CustomerVisitRequests.DeliveryStatus request) {
String target = trim(request.status()).toUpperCase(Locale.ROOT);
if (!Set.of("WAIT_DELIVERY", "DELIVERING", "DELIVERED", "REJECTED", "CANCELLED").contains(target)) {
throw badRequest("不支持的派送状态");
}
if ("DELIVERED".equals(target)) return completeDelivery(customerId, operatorId, deliveryNo);
DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo);
if (delivery == null) throw notFound("派送单不存在");
if ("RETURN_PENDING".equals(delivery.getStatus())) throw conflict("退货处理中的派送单不能直接修改状态");
if (target.equals(delivery.getStatus())) {
CustomerVisit existing = visits.findByDelivery(customerId, delivery.getId());
return new CustomerVisitResponses.Completed(existing == null ? null : existing.getId(), deliveryNo,
delivery.getStatus(), existing == null ? null : existing.getStatus(), true);
}
String from = delivery.getStatus();
LocalDateTime now = LocalDateTime.now();
delivery.setStatus(target);
if ("DELIVERING".equals(target) && delivery.getDispatchedAt() == null) delivery.setDispatchedAt(now);
delivery.setUpdatedAt(now);
deliveryOrders.updateById(delivery);
String result = "派送状态由 " + from + " 修改为 " + target;
if (StringUtils.hasText(request.remark())) result += ",备注:" + trim(request.remark());
recordOperation(delivery, operatorId, "UPDATE_DELIVERY_STATUS", result, now);
CustomerVisit existing = visits.findByDelivery(customerId, delivery.getId());
return new CustomerVisitResponses.Completed(existing == null ? null : existing.getId(), deliveryNo,
delivery.getStatus(), existing == null ? null : existing.getStatus(), false);
}
@Transactional
public CustomerVisitResponses.Detail save(String customerId, String operatorId, long visitId,
CustomerVisitRequests.Save request) {
CustomerVisit visit = visits.lockById(customerId, visitId);
if (visit == null) throw notFound("回访记录不存在");
CustomerVisitResponses.InboundOption selected = inboundOptions(visit).stream()
.filter(option -> Objects.equals(option.inboundOrderId(), request.selectedInboundOrderId()))
.findFirst().orElseThrow(() -> conflict("所选入仓单不属于该派送单"));
String result = trim(request.visitResult()).toUpperCase(Locale.ROOT);
if (!RESULTS.contains(result)) throw badRequest("不支持的回访结果");
List<CustomerVisitResponses.Attachment> attachments = new ArrayList<>();
Map<Long, CustomerVisitResponses.Attachment> unique = new LinkedHashMap<>();
if (request.attachments() != null) {
for (CustomerVisitRequests.Attachment attachment : request.attachments()) {
files.validate(customerId, visitId, attachment.fileId());
unique.put(attachment.fileId(), new CustomerVisitResponses.Attachment(attachment.fileId(),
trim(attachment.fileName()), trim(attachment.contentType()), attachment.sizeBytes()));
}
}
attachments.addAll(unique.values());
LocalDateTime now = LocalDateTime.now();
visit.setSelectedInboundOrderId(selected.inboundOrderId());
visit.setSelectedInboundCode(selected.inboundCode());
visit.setSelectedLastMileOrderId(selected.lastMileOrderId());
visit.setSelectedLastMileOrderNo(selected.lastMileOrderNo());
visit.setVisitResult(result);
visit.setCustomerFeedback(trim(request.customerFeedback()));
visit.setRemark(trim(request.remark()));
visit.setAttachmentJson(writeAttachments(attachments));
visit.setStatus("COMPLETED");
visit.setVisitedBy(operatorId);
visit.setVisitedAt(now);
visit.setVisitCount(Objects.requireNonNullElse(visit.getVisitCount(), 0) + 1);
visit.setUpdatedAt(now);
visits.updateById(visit);
DeliveryOrder delivery = deliveryOrders.findByNo(customerId, visit.getDeliveryNo());
if (delivery != null) recordOperation(delivery, operatorId, "CUSTOMER_VISIT",
"已完成第" + visit.getVisitCount() + "次客户回访,入仓单 " + selected.inboundCode(), now);
return detail(visit);
}
public CustomerVisitResponses.UploadedFile upload(String customerId, long operatorId, long visitId,
MultipartFile file) {
required(customerId, visitId);
validateFile(file);
CustomerVisitFileGateway.UploadedFile uploaded = files.upload(customerId, visitId, operatorId, file);
return new CustomerVisitResponses.UploadedFile(uploaded.fileId(), uploaded.fileName(),
uploaded.contentType(), uploaded.sizeBytes());
}
private CustomerVisit ensureVisit(DeliveryOrder delivery, LocalDateTime completedAt) {
CustomerVisit existing = visits.findByDelivery(delivery.getCustomerId(), delivery.getId());
if (existing != null) return existing;
List<LastlegOrder> orders = orders(delivery);
CustomerVisit value = new CustomerVisit();
value.setId(IdWorker.getId()); value.setCustomerId(delivery.getCustomerId());
value.setDeliveryOrderId(delivery.getId()); value.setDeliveryNo(delivery.getDeliveryNo());
value.setCustomerName(delivery.getCustomerName());
value.setDestination(orders.stream().map(LastlegOrder::getFbaWarehouseCode)
.filter(StringUtils::hasText).distinct().reduce((left, right) -> left + ", " + right).orElse(null));
value.setDeliveryCompletedAt(completedAt); value.setStatus("PENDING"); value.setVisitCount(0);
value.setAttachmentJson("[]"); value.setCreatedAt(LocalDateTime.now()); value.setUpdatedAt(value.getCreatedAt());
try { visits.insert(value); }
catch (DuplicateKeyException exception) {
CustomerVisit concurrent = visits.findByDelivery(delivery.getCustomerId(), delivery.getId());
if (concurrent != null) return concurrent;
throw exception;
}
return value;
}
private CustomerVisitResponses.Detail detail(CustomerVisit visit) {
return new CustomerVisitResponses.Detail(row(visit), inboundOptions(visit), visit.getCustomerFeedback(),
visit.getRemark(), visit.getVisitedBy());
}
private List<CustomerVisitResponses.InboundOption> inboundOptions(CustomerVisit visit) {
List<LastlegOrder> orders = lastMileOrders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getCustomerId, visit.getCustomerId())
.eq(LastlegOrder::getDeliveryOrderNo, visit.getDeliveryNo())
.orderByAsc(LastlegOrder::getInboundCode));
if (orders.isEmpty()) return List.of();
List<String> codes = orders.stream().map(LastlegOrder::getInboundCode).filter(StringUtils::hasText).distinct().toList();
Map<String, LastlegInboundOrder> byCode = new LinkedHashMap<>();
if (!codes.isEmpty()) inboundOrders.selectList(new LambdaQueryWrapper<LastlegInboundOrder>()
.eq(LastlegInboundOrder::getCustomerId, visit.getCustomerId()).in(LastlegInboundOrder::getInboundCode, codes))
.forEach(value -> byCode.putIfAbsent(value.getInboundCode(), value));
return orders.stream().map(order -> {
LastlegInboundOrder inbound = byCode.get(order.getInboundCode());
return new CustomerVisitResponses.InboundOption(inbound == null ? null : inbound.getId(),
order.getInboundCode(), order.getId(), order.getOrderNo(), order.getFbaWarehouseCode(),
inbound == null ? null : inbound.getDestinationWarehouseName(),
Objects.requireNonNullElse(order.getPackageCount(), 0));
}).filter(option -> option.inboundOrderId() != null).toList();
}
private List<LastlegOrder> orders(DeliveryOrder delivery) {
return lastMileOrders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getCustomerId, delivery.getCustomerId())
.eq(LastlegOrder::getDeliveryOrderNo, delivery.getDeliveryNo()));
}
private CustomerVisitResponses.Row row(CustomerVisit value) {
return new CustomerVisitResponses.Row(value.getId(), value.getCustomerId(), value.getDeliveryNo(),
value.getCustomerName(), value.getContactName(), value.getContactPhone(), value.getDestination(),
value.getDeliveryCompletedAt(), value.getStatus(), value.getSelectedInboundCode(), value.getVisitResult(),
value.getVisitedAt(), Objects.requireNonNullElse(value.getVisitCount(), 0), readAttachments(value.getAttachmentJson()));
}
private LambdaQueryWrapper<CustomerVisit> filter(String customerId, CustomerVisitRequests.Query query) {
return new LambdaQueryWrapper<CustomerVisit>()
.eq(StringUtils.hasText(customerId), CustomerVisit::getCustomerId, customerId)
.and(StringUtils.hasText(query.keyword()), wrapper -> wrapper
.like(CustomerVisit::getDeliveryNo, trim(query.keyword()))
.or().like(CustomerVisit::getCustomerName, trim(query.keyword()))
.or().like(CustomerVisit::getContactName, trim(query.keyword()))
.or().like(CustomerVisit::getContactPhone, trim(query.keyword())))
.eq(StringUtils.hasText(query.status()), CustomerVisit::getStatus, trim(query.status()))
.ge(query.completedFrom() != null, CustomerVisit::getDeliveryCompletedAt, query.completedFrom())
.le(query.completedTo() != null, CustomerVisit::getDeliveryCompletedAt, query.completedTo());
}
private CustomerVisit required(String customerId, long visitId) {
CustomerVisit value = visits.selectOne(new LambdaQueryWrapper<CustomerVisit>()
.eq(CustomerVisit::getCustomerId, customerId).eq(CustomerVisit::getId, visitId));
if (value == null) throw notFound("回访记录不存在");
return value;
}
private void recordOperation(DeliveryOrder delivery, String operatorId, String node, String result, LocalDateTime now) {
DeliveryOperation operation = new DeliveryOperation();
operation.setCustomerId(delivery.getCustomerId()); operation.setDeliveryOrderId(delivery.getId());
operation.setOperationNode(node); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId);
operation.setResult(result); operation.setCreatedAt(now); deliveryOperations.insert(operation);
}
private String writeAttachments(List<CustomerVisitResponses.Attachment> values) {
try { return objectMapper.writeValueAsString(values); }
catch (JsonProcessingException exception) { throw new IllegalStateException("无法保存回访资料", exception); }
}
private List<CustomerVisitResponses.Attachment> readAttachments(String value) {
if (!StringUtils.hasText(value)) return List.of();
try { return objectMapper.readValue(value, new TypeReference<>() { }); }
catch (JsonProcessingException exception) { throw new IllegalStateException("回访资料数据损坏", exception); }
}
private static void validateFile(MultipartFile file) {
if (file == null || file.isEmpty()) throw badRequest("请选择回访资料文件");
if (file.getSize() > MAX_FILE_BYTES) throw badRequest("单个回访资料不能超过20MB");
String name = Objects.requireNonNullElse(file.getOriginalFilename(), "");
int dot = name.lastIndexOf('.');
String extension = dot < 0 ? "" : name.substring(dot + 1).toLowerCase(Locale.ROOT);
if (!FILE_EXTENSIONS.contains(extension)) throw badRequest("仅支持PDF、Office、JPG、PNG和WEBP文件");
}
private static String customerScope(String scopedCustomerId, String requestedCustomerId) {
if (StringUtils.hasText(scopedCustomerId)) {
if (StringUtils.hasText(requestedCustomerId) && !scopedCustomerId.equals(trim(requestedCustomerId))) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "不能访问其他客户数据");
}
return scopedCustomerId;
}
return trim(requestedCustomerId);
}
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}
@@ -6,18 +6,18 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
import com.baiyue.lastleg.delivery.dto.DeliveryResponses;
import com.baiyue.lastleg.delivery.service.DeliveryTransferService;
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.boot.autoconfigure.condition.ConditionalOnProperty;
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.*;
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 {
@@ -32,11 +32,19 @@ public class DeliveryTransferController {
return ApiResponse.success(service.transfer(scope(user, customerId), dispatchNo, operator(user), request));
}
@PostMapping("/outbound-orders/one-click-dropship")
@Operation(summary = "Create one-click dropship outbound and push a PDA picking task")
public ApiResponse<DeliveryResponses.PdaTask> createOneClick(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@Valid @RequestBody DeliveryRequests.OneClickOutbound request) {
return ApiResponse.success(service.createOneClick(scope(user, customerId), operator(user), request));
}
@GetMapping("/outbound-orders")
public ApiResponse<PageResult<DeliveryResponses.OutboundRow>> outboundPage(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@Valid DeliveryRequests.Query query) {
return ApiResponse.success(service.outboundPage(scope(user, customerId), query));
return ApiResponse.success(service.outboundPage(listScope(user, customerId), query));
}
@GetMapping("/outbound-orders/{outboundNo}")
@@ -46,18 +54,30 @@ public class DeliveryTransferController {
return ApiResponse.success(service.outboundDetail(scope(user, customerId), outboundNo, query));
}
@PostMapping("/outbound-orders/{outboundNo}/cancel")
@Operation(summary = "Cancel outbound and linked delivery, restoring inventory")
public ApiResponse<Void> cancelOutbound(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@PathVariable String outboundNo,
@Valid @RequestBody DeliveryRequests.Cancel request) {
service.cancelOutbound(scope(user, customerId), operator(user), outboundNo, request);
return ApiResponse.success(null);
}
@GetMapping("/delivery-orders")
@PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_LIST + "')")
public ApiResponse<PageResult<DeliveryResponses.DeliveryRow>> deliveryPage(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@Valid DeliveryRequests.Query query) {
return ApiResponse.success(service.deliveryPage(scope(user, customerId), query));
return ApiResponse.success(service.deliveryPage(listScope(user, customerId), query));
}
@GetMapping("/delivery-orders/{deliveryNo}")
public ApiResponse<DeliveryResponses.OutboundDetail> deliveryDetail(
@PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_LIST + "')")
public ApiResponse<DeliveryResponses.DeliveryDetail> deliveryDetail(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@PathVariable String deliveryNo, @Valid DeliveryRequests.PalletQuery query) {
return ApiResponse.success(service.deliveryDetail(scope(user, customerId), deliveryNo, query));
@PathVariable String deliveryNo) {
return ApiResponse.success(service.deliveryDetail(scope(user, customerId), deliveryNo));
}
private String scope(AuthenticatedUser user, String requestedCustomer) {
@@ -73,4 +93,9 @@ public class DeliveryTransferController {
private String operator(AuthenticatedUser user) {
return user.userId() == null ? user.username() : String.valueOf(user.userId());
}
private String listScope(AuthenticatedUser user, String requestedCustomer) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) return requestedCustomer == null || requestedCustomer.isBlank() ? null : requestedCustomer.trim();
return String.valueOf(user.tenantId());
}
}
@@ -0,0 +1,46 @@
package com.baiyue.lastleg.delivery.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.delivery.dto.DeliveryRequests;
import com.baiyue.lastleg.delivery.dto.DeliveryResponses;
import com.baiyue.lastleg.delivery.service.DeliveryTransferService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
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/merchant/v1/last-leg/delivery-orders")
public class MerchantDeliveryController {
@Resource private DeliveryTransferService service;
@GetMapping
public ApiResponse<PageResult<DeliveryResponses.DeliveryRow>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid DeliveryRequests.Query query) {
return ApiResponse.success(service.deliveryPage(customer(user), query));
}
@PostMapping
public ApiResponse<DeliveryResponses.TransferResult> create(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String dispatchNo,
@Valid @RequestBody DeliveryRequests.Transfer request) {
return ApiResponse.success(service.transfer(customer(user), dispatchNo, operator(user), request));
}
@GetMapping("/{deliveryNo}")
public ApiResponse<DeliveryResponses.DeliveryDetail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String deliveryNo) {
return ApiResponse.success(service.deliveryDetail(customer(user), deliveryNo));
}
private String customer(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, "Merchant identity is required");
return String.valueOf(user.tenantId());
}
private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); }
}
@@ -0,0 +1,41 @@
package com.baiyue.lastleg.delivery.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
import com.baiyue.lastleg.delivery.dto.DeliveryResponses;
import com.baiyue.lastleg.delivery.service.DeliveryTransferService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
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/pda/v1/last-leg/outbound-tasks")
public class OutboundPdaController {
@Resource private DeliveryTransferService service;
@PostMapping("/{taskNo}/pick")
public ApiResponse<DeliveryResponses.PdaTask> pick(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String taskNo, @RequestParam(required=false) String customerId,
@Valid @RequestBody DeliveryRequests.PdaPick request) {
return ApiResponse.success(service.pdaPick(scope(user, customerId), operator(user), taskNo, request));
}
@GetMapping
public ApiResponse<java.util.List<DeliveryResponses.PdaTask>> list(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required=false) String customerId, @RequestParam(required=false) String status) {
return ApiResponse.success(service.pdaTasks(scope(user, customerId), status));
}
@PostMapping("/{taskNo}/complete")
public ApiResponse<DeliveryResponses.PdaTask> complete(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String taskNo, @RequestParam(required=false) String customerId) {
return ApiResponse.success(service.completePda(scope(user, customerId), taskNo));
}
private String scope(AuthenticatedUser user, String requested) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if (requested != null && !requested.isBlank()) return requested.trim();
return String.valueOf(user.tenantId());
}
private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); }
}
@@ -3,16 +3,43 @@ package com.baiyue.lastleg.delivery.dto;
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 jakarta.validation.Valid;
import java.util.List;
import java.math.BigDecimal;
import java.time.LocalDateTime;
public final class DeliveryRequests {
private DeliveryRequests() {}
public record Transfer(@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark) {}
@NotNull LocalDateTime plannedDeliveryAt,
@NotBlank @Size(max = 32) String deliveryMethod,
@Size(max = 128) String waybillNo,
@Size(max = 255) String carrierName,
@NotBlank @Size(max = 64) String pickupMethod,
@NotBlank @Size(max = 64) String loadingType,
@Size(max = 64) String vehicleType,
@Size(max = 64) String vehicleSize,
@Size(max = 128) String driverName,
@Size(max = 64) String driverPhone,
@Size(max = 64) String vehiclePlate,
@Size(max = 128) String appointmentAccount,
@Size(max = 128) String sellerName,
@Size(max = 1000) String remark,
@NotNull @Valid @Size(min = 1, max = 20) List<FeeItem> fees) {}
public record FeeItem(@NotBlank @Size(max = 64) String feeType,
@NotBlank @Size(max = 8) String currency,
@NotNull @Min(0) BigDecimal amount,
@Size(max = 500) String remark) {}
public record Query(@Min(1) long page, @Min(1) @Max(200) long size,
String documentNo, String warehouseId, String status) {
String documentNo, String warehouseId, String status,
String dispatchNo, String waybillNo, String vehiclePlate,
String driverName, String deliveryMethod,
LocalDateTime plannedFrom, LocalDateTime plannedTo) {
public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
}
@@ -20,4 +47,14 @@ public final class DeliveryRequests {
String destinationWarehouseCode) {
public PalletQuery { if (page == 0) page = 1; if (size == 0) size = 20; }
}
public record OneClickOutbound(@NotBlank @Size(max=1000) String recipient,
@NotNull @Size(min=1, max=200) List<@Valid OneClickItem> items,
@Size(max=1000) String remark,
@NotBlank @Size(max=255) String idempotencyKey) {}
public record OneClickItem(@NotNull Long inventoryPositionId, @NotNull @Min(1) Long packages,
@NotBlank @Size(max=128) String sku) {}
public record PdaPick(@NotNull Long outboundPalletItemId, @NotNull Long inventoryPositionId,
@NotNull @Min(1) Long packages, @NotBlank @Size(max=255) String idempotencyKey) {}
public record Cancel(@NotBlank @Size(max=1000) String reason) {}
}
@@ -1,6 +1,8 @@
package com.baiyue.lastleg.delivery.dto;
import java.time.LocalDateTime;
import java.math.BigDecimal;
import java.util.List;
public final class DeliveryResponses {
private DeliveryResponses() {}
@@ -12,9 +14,32 @@ public final class DeliveryResponses {
public record OutboundRow(Long id, String outboundNo, String dispatchNo, String overseasWarehouseId,
String status, int palletCount, int packageCount, LocalDateTime createdAt) {}
public record DeliveryRow(Long id, String deliveryNo, String outboundNo, String dispatchNo,
String overseasWarehouseId, String status, int palletCount,
int packageCount, LocalDateTime createdAt) {}
public record DeliveryRow(Long id, String customerId, String customerName, String deliveryNo,
String outboundNo, String dispatchNo, String overseasWarehouseId,
String overseasWarehouseName, String status, String waybillNo,
String deliveryMethod, String carrierName, String pickupMethod,
String loadingType, String vehicleType, String vehicleSize,
String vehiclePlate, String driverName, String driverPhone,
String appointmentAccount, String sellerName, int palletCount,
int packageCount, BigDecimal totalWeightKg, BigDecimal totalVolumeCbm,
LocalDateTime plannedDeliveryAt, LocalDateTime dispatchedAt,
LocalDateTime expectedArrivalAt, LocalDateTime signedAt,
String remark, LocalDateTime createdAt) {}
public record DeliverySku(String inboundCode, String customerName, String destinationWarehouseCode,
String receivingAddress, String shipmentNo, String fbaTrackingNo,
String skuCode, String productName, int packageCount,
BigDecimal weightKg, BigDecimal volumeCbm) {}
public record DeliveryFeeRow(Long id, String feeDirection, String feeType, String currency,
BigDecimal amount, String remark) {}
public record DeliveryOperationRow(Long id, LocalDateTime operatedAt, String operationNode,
String operatorName, String result) {}
public record DeliveryDetail(DeliveryRow order, List<DeliverySku> skus,
List<DeliveryFeeRow> fees, List<DeliveryOperationRow> operations) {}
public record OutboundDetail(OutboundRow outbound, DeliveryRow delivery) {}
public record PdaTask(Long id, String taskNo, String status, String outboundNo, LocalDateTime pushedAt, LocalDateTime completedAt) {}
}
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.delivery.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_delivery_fee")
public class DeliveryFee {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String customerId;
private Long deliveryOrderId;
private String feeDirection;
private String feeType;
private String currency;
private BigDecimal amount;
private String remark;
private String createdBy;
private LocalDateTime createdAt;
}
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.delivery.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_delivery_operation")
public class DeliveryOperation {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String customerId;
private Long deliveryOrderId;
private String operationNode;
private String operatorId;
private String operatorName;
private String result;
private LocalDateTime createdAt;
}
@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** Delivery document linked one-to-one to its outbound document and dispatch. */
@@ -20,9 +21,30 @@ public class DeliveryOrder {
private Long dispatchOrderId;
private String dispatchNo;
private String overseasWarehouseId;
private String overseasWarehouseName;
private String customerName;
private String status;
private String waybillNo;
private String deliveryMethod;
private String carrierName;
private String pickupMethod;
private String loadingType;
private String vehicleType;
private String vehicleSize;
private String vehiclePlate;
private String driverName;
private String driverPhone;
private String appointmentAccount;
private String sellerName;
private Integer packageCount;
private Integer palletCount;
private BigDecimal totalWeightKg;
private BigDecimal totalVolumeCbm;
private LocalDateTime plannedDeliveryAt;
private LocalDateTime dispatchedAt;
private LocalDateTime expectedArrivalAt;
private LocalDateTime signedAt;
private String remark;
private String idempotencyKey;
private String createdBy;
private LocalDateTime createdAt;
@@ -18,6 +18,8 @@ public class OutboundOrder {
private Long dispatchOrderId;
private String dispatchNo;
private String overseasWarehouseId;
private String outboundType;
private String recipientInfo;
private String status;
private Integer packageCount;
private Integer palletCount;
@@ -21,6 +21,9 @@ public class OutboundPalletItem {
private String destinationWarehouseCode;
private String destinationWarehouseName;
private Integer packageCount;
private Integer pickedPackageCount;
private String status;
private String skuCode;
private String orderRefs;
private String inboundCode;
private LocalDateTime createdAt;
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.delivery.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_outbound_pda_pick")
public class OutboundPdaPick {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String customerId;
private Long taskId;
private Long outboundPalletItemId;
private Long inventoryPositionId;
private Long packages;
private String idempotencyKey;
private LocalDateTime pickedAt;
}
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.delivery.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_outbound_pda_task")
public class OutboundPdaTask {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String customerId;
private Long outboundOrderId;
private String taskNo;
private String status;
private LocalDateTime pushedAt;
private LocalDateTime completedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.delivery.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.delivery.entity.DeliveryFee;
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 DeliveryFeeMapper extends BaseMapper<DeliveryFee> {
@Select("SELECT * FROM ll_delivery_fee WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryOrderId} ORDER BY id")
List<DeliveryFee> findByDelivery(@Param("customerId") String customerId,
@Param("deliveryOrderId") Long deliveryOrderId);
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.delivery.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.delivery.entity.DeliveryOperation;
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 DeliveryOperationMapper extends BaseMapper<DeliveryOperation> {
@Select("SELECT * FROM ll_delivery_operation WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryOrderId} ORDER BY created_at,id")
List<DeliveryOperation> findByDelivery(@Param("customerId") String customerId,
@Param("deliveryOrderId") Long deliveryOrderId);
}
@@ -14,4 +14,6 @@ public interface OutboundOrderMapper extends BaseMapper<OutboundOrder> {
OutboundOrder findByIdempotency(@Param("customerId") String customerId, @Param("key") String key);
@Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1 FOR UPDATE")
OutboundOrder lockByDispatch(@Param("customerId") String customerId, @Param("dispatchOrderId") Long dispatchOrderId);
@Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND outbound_no=#{outboundNo} LIMIT 1 FOR UPDATE")
OutboundOrder lockByNo(@Param("customerId") String customerId, @Param("outboundNo") String outboundNo);
}
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.delivery.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.delivery.entity.OutboundPdaPick;
import org.apache.ibatis.annotations.*;
import java.util.List;
@Mapper
public interface OutboundPdaPickMapper extends BaseMapper<OutboundPdaPick> {
@Select("SELECT * FROM ll_outbound_pda_pick WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1")
OutboundPdaPick findByKey(@Param("customerId") String customerId, @Param("key") String key);
@Select("SELECT * FROM ll_outbound_pda_pick WHERE customer_id=#{customerId} AND task_id IN (SELECT id FROM ll_outbound_pda_task WHERE customer_id=#{customerId} AND outbound_order_id=#{outboundId})")
List<OutboundPdaPick> findByOutbound(@Param("customerId") String customerId, @Param("outboundId") Long outboundId);
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.delivery.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.delivery.entity.OutboundPdaTask;
import org.apache.ibatis.annotations.*;
import java.util.List;
@Mapper
public interface OutboundPdaTaskMapper extends BaseMapper<OutboundPdaTask> {
@Select("SELECT * FROM ll_outbound_pda_task WHERE customer_id=#{customerId} AND outbound_order_id=#{outboundOrderId} LIMIT 1 FOR UPDATE")
OutboundPdaTask lockByOutbound(@Param("customerId") String customerId, @Param("outboundOrderId") Long outboundOrderId);
@Select("SELECT * FROM ll_outbound_pda_task WHERE customer_id=#{customerId} AND task_no=#{taskNo} LIMIT 1 FOR UPDATE")
OutboundPdaTask lockByNo(@Param("customerId") String customerId, @Param("taskNo") String taskNo);
@Select("SELECT * FROM ll_outbound_pda_task WHERE customer_id=#{customerId} AND (#{status} IS NULL OR status=#{status}) ORDER BY pushed_at DESC LIMIT 200")
List<OutboundPdaTask> list(@Param("customerId") String customerId, @Param("status") String status);
}
@@ -7,9 +7,13 @@ 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;
import com.baiyue.lastleg.delivery.entity.DeliveryFee;
import com.baiyue.lastleg.delivery.entity.DeliveryOperation;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
import com.baiyue.lastleg.delivery.mapper.DeliveryFeeMapper;
import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
@@ -18,12 +22,27 @@ import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper;
import com.baiyue.lastleg.delivery.entity.OutboundPdaTask;
import com.baiyue.lastleg.delivery.entity.OutboundPdaPick;
import com.baiyue.lastleg.delivery.mapper.OutboundPdaTaskMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundPdaPickMapper;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile;
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipCartonMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipSkuInventoryMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
@@ -34,6 +53,8 @@ import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import java.math.BigDecimal;
import java.util.*;
@Service
@@ -43,10 +64,21 @@ public class DeliveryTransferService {
@Resource private OutboundOrderMapper outboundOrders;
@Resource private OutboundPalletItemMapper outboundPallets;
@Resource private DeliveryOrderMapper deliveryOrders;
@Resource private DeliveryFeeMapper deliveryFees;
@Resource private DeliveryOperationMapper deliveryOperations;
@Resource private OutboundPdaTaskMapper pdaTasks;
@Resource private OutboundPdaPickMapper pdaPicks;
@Resource private InventoryPositionMapper inventoryPositions;
@Resource private InventoryCommandService inventoryCommands;
@Resource private DropshipOrderProfileMapper dropshipProfiles;
@Resource private DropshipCartonMapper dropshipCartons;
@Resource private DropshipSkuInventoryMapper dropshipSkuInventory;
@Resource private InboundPlanPalletAllocationMapper planAllocations;
@Resource private InboundPlanLineMapper planLines;
@Resource private LastlegOrderMapper tailOrders;
@Resource private LastlegOrderStatusHistoryMapper histories;
@Resource private LastlegOrderDetailMapper tailOrderDetails;
@Resource private LastlegOrderNoGenerator orderNoGenerator;
@Transactional
public DeliveryResponses.TransferResult transfer(String customerId, String dispatchNo, String operatorId,
@@ -70,13 +102,14 @@ public class DeliveryTransferService {
throw conflict("All dispatch pallets must be received before transfer to delivery");
}
if (!StringUtils.hasText(dispatch.getOverseasWarehouseId())) throw conflict("Dispatch warehouse is required");
validateTransferFees(request);
LocalDateTime now = LocalDateTime.now();
OutboundOrder outbound = new OutboundOrder();
outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId);
outbound.setOutboundNo("OUT" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + outbound.getId());
outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND", dispatch.getOverseasWarehouseId(), outbound.getId()));
outbound.setDispatchOrderId(dispatch.getId()); outbound.setDispatchNo(dispatch.getDispatchNo());
outbound.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); outbound.setStatus("WAIT_OUTBOUND");
outbound.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); outbound.setStatus("WAIT_OUTBOUND"); outbound.setOutboundType("DISPATCH_DELIVERY");
outbound.setPackageCount(items.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum());
outbound.setPalletCount(items.size()); outbound.setIdempotencyKey(request.idempotencyKey().trim());
outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now);
@@ -103,27 +136,179 @@ public class DeliveryTransferService {
item.setDestinationWarehouseId(source.getDestinationWarehouseId());
item.setDestinationWarehouseCode(source.getDestinationWarehouseCode());
item.setDestinationWarehouseName(source.getDestinationWarehouseName());
item.setPackageCount(Objects.requireNonNullElse(source.getPackageCount(), 0));
item.setPackageCount(Objects.requireNonNullElse(source.getPackageCount(), 0)); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK");
item.setOrderRefs(source.getOrderRefs()); item.setInboundCode(source.getInboundCode()); item.setCreatedAt(now);
outboundPallets.insert(item);
}
DeliveryOrder delivery = new DeliveryOrder();
delivery.setId(IdWorker.getId()); delivery.setCustomerId(customerId);
delivery.setDeliveryNo("DEL" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + delivery.getId());
delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY", dispatch.getOverseasWarehouseId(), delivery.getId()));
delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo());
delivery.setDispatchOrderId(dispatch.getId()); delivery.setDispatchNo(dispatch.getDispatchNo());
delivery.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); delivery.setStatus("WAIT_DELIVERY");
delivery.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); delivery.setOverseasWarehouseName(dispatch.getOverseasWarehouseName());
LastlegOrder firstOrder = firstTailOrder(customerId, items);
delivery.setCustomerName(firstOrder == null ? null : firstOrder.getCustomerName()); delivery.setStatus("WAIT_DELIVERY");
delivery.setWaybillNo(trim(request.waybillNo())); delivery.setDeliveryMethod(request.deliveryMethod().trim());
delivery.setCarrierName(firstText(request.carrierName(), dispatch.getCarrierName())); delivery.setPickupMethod(trim(request.pickupMethod()));
delivery.setLoadingType(trim(request.loadingType())); delivery.setVehicleType(trim(request.vehicleType())); delivery.setVehicleSize(trim(request.vehicleSize()));
delivery.setVehiclePlate(firstText(request.vehiclePlate(), dispatch.getVehiclePlate())); delivery.setDriverName(firstText(request.driverName(), dispatch.getDriverName()));
delivery.setDriverPhone(firstText(request.driverPhone(), dispatch.getDriverPhone())); delivery.setAppointmentAccount(trim(request.appointmentAccount()));
delivery.setSellerName(trim(request.sellerName())); delivery.setPlannedDeliveryAt(request.plannedDeliveryAt());
delivery.setPackageCount(outbound.getPackageCount()); delivery.setPalletCount(outbound.getPalletCount());
delivery.setTotalVolumeCbm(items.stream().map(DispatchItem::getVolumeCbm).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add));
delivery.setTotalWeightKg(totalWeight(customerId, items)); delivery.setExpectedArrivalAt(dispatch.getEstimatedArrivalAt()); delivery.setRemark(trim(request.remark()));
delivery.setIdempotencyKey(request.idempotencyKey().trim()); delivery.setCreatedBy(operatorId);
delivery.setCreatedAt(now); delivery.setUpdatedAt(now); deliveryOrders.insert(delivery);
List<DeliveryRequests.FeeItem> feeInputs = request.fees() == null ? List.of() : request.fees();
for (DeliveryRequests.FeeItem input : feeInputs) {
DeliveryFee fee = new DeliveryFee(); fee.setCustomerId(customerId); fee.setDeliveryOrderId(delivery.getId());
fee.setFeeDirection("PAYABLE"); fee.setFeeType(input.feeType().trim()); fee.setCurrency(input.currency().trim().toUpperCase(Locale.ROOT));
fee.setAmount(input.amount()); fee.setRemark(trim(input.remark())); fee.setCreatedBy(operatorId); fee.setCreatedAt(now); deliveryFees.insert(fee);
}
recordOperation(customerId, delivery.getId(), operatorId, "CREATE_DELIVERY", "CREATED", now);
bindTailOrders(customerId, items, outbound.getOutboundNo(), delivery.getDeliveryNo(), request.remark(), now);
return result(outbound, delivery, false);
}
private void validateTransferFees(DeliveryRequests.Transfer request) {
List<DeliveryRequests.FeeItem> inputs = request.fees() == null ? List.of() : request.fees();
if (inputs.isEmpty()) throw conflict("Delivery fee is required");
Set<String> allowed = Set.of("DELIVERY_FEE", "LOADING_FEE", "APPOINTMENT_FEE");
Set<String> seen = new HashSet<>();
for (DeliveryRequests.FeeItem input : inputs) {
String feeType = input.feeType().trim().toUpperCase(Locale.ROOT);
if (!allowed.contains(feeType)) throw conflict("Unsupported delivery fee type: " + feeType);
if (!seen.add(feeType)) throw conflict("Duplicate delivery fee type: " + feeType);
if (!"USD".equalsIgnoreCase(input.currency().trim())) throw conflict("Delivery fees must use USD");
if (input.amount().signum() < 0) throw conflict("Delivery fee amount cannot be negative");
}
if (!seen.contains("DELIVERY_FEE")) throw conflict("Delivery fee is required");
if (Set.of("FLOOR", "MIXED").contains(request.loadingType().trim().toUpperCase(Locale.ROOT))
&& !seen.contains("LOADING_FEE")) {
throw conflict("Loading fee is required for floor or mixed loading");
}
if ("BUY_APPOINTMENT".equalsIgnoreCase(trim(request.appointmentAccount()))) {
if (!StringUtils.hasText(request.sellerName())) throw conflict("Seller name is required when buying an appointment");
if (!seen.contains("APPOINTMENT_FEE")) throw conflict("Appointment fee is required when buying an appointment");
}
}
@Transactional
public DeliveryResponses.PdaTask createOneClick(String customerId, String operatorId, DeliveryRequests.OneClickOutbound request) {
OutboundOrder existing = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim());
if (existing != null) return taskResult(existing, pdaTasks.lockByOutbound(customerId, existing.getId()));
LocalDateTime now = LocalDateTime.now();
InventoryPosition firstPosition = inventoryPositions.selectById(request.items().get(0).inventoryPositionId());
if (firstPosition == null || !customerId.equals(firstPosition.getCustomerId())) throw notFound("Inventory position not found");
String selectedWarehouseId = firstPosition.getOverseasWarehouseId();
OutboundOrder outbound = new OutboundOrder();
outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId);
outbound.setOutboundNo(orderNoGenerator.next("ONE_CLICK_DROPSHIP", selectedWarehouseId, outbound.getId()));
outbound.setOutboundType("ONE_CLICK_DROPSHIP"); outbound.setStatus("WAIT_PICK"); outbound.setRecipientInfo(request.recipient().trim());
outbound.setPackageCount(request.items().stream().mapToInt(i -> i.packages().intValue()).sum()); outbound.setPalletCount(request.items().size());
outbound.setIdempotencyKey(request.idempotencyKey().trim()); outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now);
outboundOrders.insert(outbound);
for (DeliveryRequests.OneClickItem input : request.items()) {
InventoryPosition position = inventoryPositions.selectById(input.inventoryPositionId());
if (position == null || !customerId.equals(position.getCustomerId())) throw notFound("Inventory position not found");
DropshipOrderProfile profile = dropshipProfiles.findPosition(customerId, position.getId());
if (profile == null) throw conflict("Selected pallet is not one-click dropship inventory");
if (selectedWarehouseId == null) selectedWarehouseId = position.getOverseasWarehouseId();
else if (!Objects.equals(selectedWarehouseId, position.getOverseasWarehouseId())) throw conflict("One outbound order must use inventory from the same overseas warehouse");
if (value(position.getAvailablePackages()) < input.packages()) throw conflict("Insufficient available inventory for pallet " + position.getPalletNo());
OutboundPalletItem item = new OutboundPalletItem(); item.setId(IdWorker.getId()); item.setCustomerId(customerId); item.setOutboundOrderId(outbound.getId());
item.setWarehousePalletId(position.getPalletId()); item.setPalletNo(position.getPalletNo()); item.setPackageCount(input.packages().intValue()); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK"); item.setSkuCode(input.sku().trim()); item.setCreatedAt(now);
outboundPallets.insert(item);
}
outbound.setOverseasWarehouseId(selectedWarehouseId); outboundOrders.updateById(outbound);
OutboundPdaTask task = new OutboundPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setOutboundOrderId(outbound.getId());
task.setTaskNo("PDA" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + task.getId()); task.setStatus("PENDING"); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task);
return taskResult(outbound, task);
}
@Transactional
public DeliveryResponses.PdaTask pdaPick(String customerId, String operatorId, String taskNo, DeliveryRequests.PdaPick request) {
OutboundPdaTask task = pdaTasks.lockByNo(customerId, taskNo); if (task == null) throw notFound("PDA task not found");
OutboundPdaPick replay = pdaPicks.findByKey(customerId, request.idempotencyKey().trim()); if (replay != null) return taskResult(outboundOrders.selectById(task.getOutboundOrderId()), task);
OutboundOrder outbound = outboundOrders.lockByNo(customerId, outboundNo(task.getOutboundOrderId()));
OutboundPalletItem item = outboundPallets.selectById(request.outboundPalletItemId());
if (item == null || !task.getOutboundOrderId().equals(item.getOutboundOrderId())) throw notFound("Outbound pallet item not found");
if (request.packages() > item.getPackageCount() - value(item.getPickedPackageCount())) throw conflict("Picked quantity exceeds outbound pallet quantity");
InventoryPosition selectedPosition = inventoryPositions.selectById(request.inventoryPositionId());
if (selectedPosition == null || !customerId.equals(selectedPosition.getCustomerId()) || !Objects.equals(selectedPosition.getPalletId(), item.getWarehousePalletId())) throw conflict("Scanned inventory position does not belong to the outbound pallet");
DropshipOrderProfile profile = dropshipProfiles.findPosition(customerId, request.inventoryPositionId());
if (profile == null) throw conflict("Scanned pallet is not one-click dropship inventory");
List<DropshipCarton> cartons = dropshipCartons.lockAvailable(customerId, profile.getId(), item.getSkuCode(), request.packages());
if (cartons.size() != request.packages()) throw conflict("Insufficient available one-click dropship cartons");
InventoryCommandRequests.Move move = new InventoryCommandRequests.Move(request.inventoryPositionId(), request.packages(), outbound.getOutboundNo(), outbound.getOutboundNo(), null, request.idempotencyKey() + ":ALLOCATE", null);
inventoryCommands.allocate(customerId, operatorId, move);
inventoryCommands.pick(customerId, operatorId, new InventoryCommandRequests.Move(request.inventoryPositionId(), request.packages(), outbound.getOutboundNo(), outbound.getOutboundNo(), null, request.idempotencyKey() + ":PICK", null));
inventoryCommands.ship(customerId, operatorId, new InventoryCommandRequests.Move(request.inventoryPositionId(), request.packages(), outbound.getOutboundNo(), outbound.getOutboundNo(), null, request.idempotencyKey() + ":SHIP", null));
if (dropshipSkuInventory.deduct(customerId, profile.getId(), item.getSkuCode(), request.packages()) != 1) throw conflict("Insufficient one-click dropship SKU inventory");
for (DropshipCarton carton : cartons) { carton.setStatus("SHIPPED"); carton.setOutboundOrderNo(outbound.getOutboundNo()); carton.setVersion((carton.getVersion() == null ? 0 : carton.getVersion()) + 1); carton.setUpdatedAt(LocalDateTime.now()); dropshipCartons.updateById(carton); }
OutboundPdaPick pick = new OutboundPdaPick(); pick.setId(IdWorker.getId()); pick.setCustomerId(customerId); pick.setTaskId(task.getId()); pick.setOutboundPalletItemId(item.getId()); pick.setInventoryPositionId(request.inventoryPositionId()); pick.setPackages(request.packages()); pick.setIdempotencyKey(request.idempotencyKey().trim()); pick.setPickedAt(LocalDateTime.now()); pdaPicks.insert(pick);
item.setPickedPackageCount(value(item.getPickedPackageCount()) + request.packages().intValue()); item.setStatus(item.getPickedPackageCount() >= item.getPackageCount() ? "PICKED" : "PARTIAL_PICKED"); outboundPallets.updateById(item);
List<OutboundPalletItem> pickedItems = outboundPallets.findByOutbound(customerId, outbound.getId());
if (!pickedItems.isEmpty() && pickedItems.stream().allMatch(i -> "PICKED".equals(i.getStatus()))) {
outbound.setStatus("PICKED"); outbound.setUpdatedAt(LocalDateTime.now()); outboundOrders.updateById(outbound);
}
task.setStatus("IN_PROGRESS"); task.setUpdatedAt(LocalDateTime.now()); pdaTasks.updateById(task);
return taskResult(outbound, task);
}
@Transactional
public DeliveryResponses.PdaTask completePda(String customerId, String taskNo) {
OutboundPdaTask task = pdaTasks.lockByNo(customerId, taskNo); if (task == null) throw notFound("PDA task not found");
OutboundOrder outbound = outboundOrders.selectById(task.getOutboundOrderId());
List<OutboundPalletItem> items = outboundPallets.findByOutbound(customerId, outbound.getId());
if (items.stream().anyMatch(i -> value(i.getPickedPackageCount()) < value(i.getPackageCount()))) throw conflict("All outbound pallets must be picked before completion");
task.setStatus("COMPLETED"); task.setCompletedAt(LocalDateTime.now()); task.setUpdatedAt(LocalDateTime.now()); pdaTasks.updateById(task); outbound.setStatus("OUTBOUNDED"); outbound.setUpdatedAt(LocalDateTime.now()); outboundOrders.updateById(outbound); return taskResult(outbound, task);
}
@Transactional
public void cancelOutbound(String customerId, String operatorId, String outboundNo, DeliveryRequests.Cancel request) {
OutboundOrder outbound = outboundOrders.lockByNo(customerId, outboundNo);
if (outbound == null) throw notFound("Outbound order not found");
if ("CANCELLED".equals(outbound.getStatus())) return;
List<OutboundPalletItem> outboundItems = outboundPallets.findByOutbound(customerId, outbound.getId());
if (outboundItems.isEmpty() || outboundItems.stream().anyMatch(i -> !"PICKED".equals(i.getStatus())))
throw conflict("Outbound order can only be cancelled after picking is completed");
DeliveryOrder existingDelivery = deliveryOrders.lockByOutbound(customerId, outbound.getId());
if (existingDelivery != null && !"WAIT_DELIVERY".equals(existingDelivery.getStatus()))
throw conflict("Outbound order cannot be cancelled while delivery is in progress");
LocalDateTime now = LocalDateTime.now();
for (OutboundPdaPick pick : pdaPicks.findByOutbound(customerId, outbound.getId())) {
inventoryCommands.adjust(customerId, operatorId,
new InventoryCommandRequests.Adjustment(pick.getInventoryPositionId(), pick.getPackages(), "OUTBOUND_CANCEL",
outbound.getOutboundNo(), outbound.getOutboundNo() + ":CANCEL:" + pick.getId(), request.reason()));
OutboundPalletItem pickedItem = outboundItems.stream().filter(i -> Objects.equals(i.getId(), pick.getOutboundPalletItemId())).findFirst().orElse(null);
DropshipOrderProfile profile = dropshipProfiles.findPosition(customerId, pick.getInventoryPositionId());
if (profile != null && pickedItem != null) dropshipSkuInventory.restore(customerId, profile.getId(), pickedItem.getSkuCode(), pick.getPackages());
}
dropshipCartons.restoreOutbound(customerId, outbound.getOutboundNo());
DeliveryOrder delivery = existingDelivery;
if (delivery != null) { delivery.setStatus("CANCELLED"); delivery.setUpdatedAt(now); deliveryOrders.updateById(delivery); }
outbound.setStatus("CANCELLED"); outbound.setUpdatedAt(now); outboundOrders.updateById(outbound);
for (OutboundPalletItem item : outboundItems) { item.setStatus("CANCELLED"); outboundPallets.updateById(item); }
if (delivery != null) {
for (LastlegOrder order : tailOrders.selectList(new LambdaQueryWrapper<LastlegOrder>().eq(LastlegOrder::getCustomerId, customerId).eq(LastlegOrder::getOutboundOrderNo, outboundNo))) {
String from = order.getCurrentStatus(); order.setCurrentStatus("RECEIVED"); order.setSubOrderStatus(null); order.setOutboundOrderNo(null); order.setDeliveryOrderNo(null); order.setUpdatedAt(now); tailOrders.updateById(order);
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setCustomerId(customerId); history.setOrderId(order.getId()); history.setFromStatus(from); history.setToStatus("RECEIVED"); history.setEventId("CANCEL_OUTBOUND:" + outboundNo); history.setRemark(request.reason().trim()); history.setCreatedAt(now); histories.insert(history);
}
}
}
public List<DeliveryResponses.PdaTask> pdaTasks(String customerId, String status) { return pdaTasks.list(customerId, StringUtils.hasText(status) ? status : null).stream().map(task -> taskResult(outboundOrders.selectById(task.getOutboundOrderId()), task)).toList(); }
private DeliveryResponses.PdaTask taskResult(OutboundOrder outbound, OutboundPdaTask task) { return new DeliveryResponses.PdaTask(task.getId(), task.getTaskNo(), task.getStatus(), outbound.getOutboundNo(), task.getPushedAt(), task.getCompletedAt()); }
private String outboundNo(Long id) { OutboundOrder value = outboundOrders.selectById(id); return value == null ? "" : value.getOutboundNo(); }
private int value(Integer value) { return value == null ? 0 : value; }
private long value(Long value) { return value == null ? 0L : value; }
public PageResult<DeliveryResponses.OutboundRow> outboundPage(String customerId, DeliveryRequests.Query query) {
Page<OutboundOrder> page = outboundOrders.selectPage(new Page<>(query.page(), query.size()),
new LambdaQueryWrapper<OutboundOrder>().eq(OutboundOrder::getCustomerId, customerId)
new LambdaQueryWrapper<OutboundOrder>().eq(StringUtils.hasText(customerId), OutboundOrder::getCustomerId, customerId)
.like(StringUtils.hasText(query.documentNo()), OutboundOrder::getOutboundNo, query.documentNo())
.eq(StringUtils.hasText(query.warehouseId()), OutboundOrder::getOverseasWarehouseId, query.warehouseId())
.eq(StringUtils.hasText(query.status()), OutboundOrder::getStatus, query.status())
@@ -141,21 +326,34 @@ public class DeliveryTransferService {
public PageResult<DeliveryResponses.DeliveryRow> deliveryPage(String customerId, DeliveryRequests.Query query) {
Page<DeliveryOrder> page = deliveryOrders.selectPage(new Page<>(query.page(), query.size()),
new LambdaQueryWrapper<DeliveryOrder>().eq(DeliveryOrder::getCustomerId, customerId)
new LambdaQueryWrapper<DeliveryOrder>().eq(StringUtils.hasText(customerId), DeliveryOrder::getCustomerId, customerId)
.like(StringUtils.hasText(query.documentNo()), DeliveryOrder::getDeliveryNo, query.documentNo())
.eq(StringUtils.hasText(query.warehouseId()), DeliveryOrder::getOverseasWarehouseId, query.warehouseId())
.eq(StringUtils.hasText(query.status()), DeliveryOrder::getStatus, query.status())
.like(StringUtils.hasText(query.dispatchNo()), DeliveryOrder::getDispatchNo, query.dispatchNo())
.like(StringUtils.hasText(query.waybillNo()), DeliveryOrder::getWaybillNo, query.waybillNo())
.like(StringUtils.hasText(query.vehiclePlate()), DeliveryOrder::getVehiclePlate, query.vehiclePlate())
.like(StringUtils.hasText(query.driverName()), DeliveryOrder::getDriverName, query.driverName())
.eq(StringUtils.hasText(query.deliveryMethod()), DeliveryOrder::getDeliveryMethod, query.deliveryMethod())
.ge(query.plannedFrom() != null, DeliveryOrder::getPlannedDeliveryAt, query.plannedFrom())
.le(query.plannedTo() != null, DeliveryOrder::getPlannedDeliveryAt, query.plannedTo())
.orderByDesc(DeliveryOrder::getCreatedAt));
return page(page, page.getRecords().stream().map(this::deliveryRow).toList());
}
public DeliveryResponses.OutboundDetail deliveryDetail(String customerId, String deliveryNo,
DeliveryRequests.PalletQuery query) {
public DeliveryResponses.DeliveryDetail deliveryDetail(String customerId, String deliveryNo) {
DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo);
if (delivery == null) throw notFound("Delivery order not found");
OutboundOrder outbound = outboundOrders.selectById(delivery.getOutboundOrderId());
if (outbound == null || !customerId.equals(outbound.getCustomerId())) throw notFound("Outbound order not found");
return outboundDetail(customerId, outbound.getOutboundNo(), query);
List<DispatchItem> items = dispatchItems.findByDispatchOrder(customerId, delivery.getDispatchOrderId());
List<Long> orderIds = items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).distinct().toList();
Map<Long, LastlegOrder> ordersById = orderIds.isEmpty() ? Map.of() : tailOrders.selectBatchIds(orderIds).stream()
.filter(order -> customerId.equals(order.getCustomerId())).collect(java.util.stream.Collectors.toMap(LastlegOrder::getId, order -> order));
List<LastlegOrderDetail> details = orderIds.isEmpty() ? List.of() : tailOrderDetails.selectList(
new LambdaQueryWrapper<LastlegOrderDetail>().eq(LastlegOrderDetail::getCustomerId, customerId).in(LastlegOrderDetail::getOrderId, orderIds));
List<DeliveryResponses.DeliverySku> skus = details.stream().map(detail -> sku(detail, ordersById.get(detail.getOrderId()))).toList();
return new DeliveryResponses.DeliveryDetail(deliveryRow(delivery), skus,
deliveryFees.findByDelivery(customerId, delivery.getId()).stream().map(this::feeRow).toList(),
deliveryOperations.findByDelivery(customerId, delivery.getId()).stream().map(this::operationRow).toList());
}
private void bindTailOrders(String customerId, List<DispatchItem> items, String outboundNo, String deliveryNo,
@@ -180,7 +378,7 @@ public class DeliveryTransferService {
order.setDeliveryOrderNo(deliveryNo); order.setCurrentStatus("WAIT_OUTBOUND"); order.setUpdatedAt(now);
order.setSubOrderStatus("OUTBOUND:WAIT_OUTBOUND;DELIVERY:WAIT_DELIVERY");
tailOrders.updateById(order);
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setOrderId(order.getId());
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setCustomerId(customerId); history.setOrderId(order.getId());
history.setFromStatus(from); history.setToStatus("WAIT_OUTBOUND");
history.setEventId("TRANSFER_DELIVERY:" + deliveryNo);
history.setRemark(StringUtils.hasText(remark) ? remark.trim() : "Transferred to outbound and delivery documents");
@@ -198,7 +396,15 @@ public class DeliveryTransferService {
d.getDeliveryNo(), d.getStatus(), o.getPalletCount(), o.getPackageCount(), replayed);
}
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.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt()); }
private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), null, d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); }
private DeliveryResponses.DeliveryFeeRow feeRow(DeliveryFee f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getFeeDirection(), f.getFeeType(), f.getCurrency(), f.getAmount(), f.getRemark()); }
private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); }
private void recordOperation(String customerId, Long deliveryId, String operatorId, String node, String result, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(customerId); operation.setDeliveryOrderId(deliveryId); operation.setOperationNode(node); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setResult(result); operation.setCreatedAt(now); deliveryOperations.insert(operation); }
private LastlegOrder firstTailOrder(String customerId, List<DispatchItem> items) { return items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).map(tailOrders::selectById).filter(Objects::nonNull).filter(order -> customerId.equals(order.getCustomerId())).findFirst().orElse(null); }
private BigDecimal totalWeight(String customerId, List<DispatchItem> items) { List<Long> ids = items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).distinct().toList(); if (ids.isEmpty()) return BigDecimal.ZERO; return tailOrderDetails.selectList(new LambdaQueryWrapper<LastlegOrderDetail>().eq(LastlegOrderDetail::getCustomerId, customerId).in(LastlegOrderDetail::getOrderId, ids)).stream().map(LastlegOrderDetail::getGrossWeightKg).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add); }
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static String firstText(String primary, String fallback) { return StringUtils.hasText(primary) ? primary.trim() : trim(fallback); }
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); }
@@ -44,6 +44,7 @@ import java.util.Set;
@Service
public class CargoDiscardService {
@Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator;
public static final String APPROVAL_PERMISSION = "lastleg:cargo-discard:approve";
public static final String PENDING_REVIEW = "PENDING_REVIEW";
public static final String APPROVED = "APPROVED";
@@ -105,7 +106,7 @@ public class CargoDiscardService {
LocalDateTime now = LocalDateTime.now();
CargoDiscardApplication application = new CargoDiscardApplication();
application.setId(IdWorker.getId()); application.setCustomerId(customerId);
application.setDiscardNo("FH" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + application.getId());
application.setDiscardNo(orderNoGenerator.next("DISCARD", warehouseId, application.getId()));
application.setOverseasWarehouseId(warehouseId); application.setOverseasWarehouseName(warehouseName);
application.setInboundCodes(orderCache.values().stream().map(LastlegOrder::getInboundCode)
.distinct().sorted().collect(java.util.stream.Collectors.joining(",")));
@@ -13,7 +13,6 @@ 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;
@@ -27,7 +26,6 @@ import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@RestController
@ConditionalOnProperty(name = "lastleg.future-outbound-api.enabled", havingValue = "true")
@RequestMapping("/api/platform/v1/last-leg/dispatches")
@Tag(name = "Tail-leg vehicle dispatch")
public class DispatchController {
@@ -7,14 +7,12 @@ 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
@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";
@@ -7,7 +7,8 @@ public final class DispatchPdaResponses {
public record Task(Long id, String taskNo, String dispatchNo, String vehiclePlate, String warehouseId, String status,
List<Item> items) {}
public record Item(Long dispatchItemId, String palletNo, String destinationWarehouseCode,
String destinationWarehouseName, String fbaWarehouseCode, int scheduledPackages,
String destinationWarehouseName, String fbaWarehouseCode, String warehouseRouteLabel,
int scheduledPackages,
long pickedPackages, List<Position> positions) {}
public record Position(Long id, Long balanceId, long availablePackages, long allocatedPackages,
long pickedPackages) {}
@@ -119,6 +119,11 @@ public class DispatchPdaWorkflowService {
inventory.pick(customerId, operatorId, new InventoryCommandRequests.Move(request.positionId(),
request.packages(), order.getDispatchNo(), order.getDispatchNo(), null,
keyBase + ":PICK", request.remark()));
// A successful PDA scan is the inventory deduction point. Departure only
// changes outbound/dispatch facts and must not deduct the same quantity again.
inventory.ship(customerId, operatorId, new InventoryCommandRequests.Move(request.positionId(),
request.packages(), order.getDispatchNo(), order.getDispatchNo(), null,
keyBase + ":SHIP", request.remark()));
DispatchPdaPick pick = new DispatchPdaPick();
pick.setId(IdWorker.getId()); pick.setCustomerId(customerId); pick.setTaskId(task.getId());
pick.setDispatchItemId(itemId); pick.setInventoryPositionId(request.positionId()); pick.setPackages(request.packages());
@@ -149,6 +154,14 @@ public class DispatchPdaWorkflowService {
if (replay != null) return read(replay, DispatchPdaResponses.PickingCompleted.class).withReplay();
requireVehicle(order);
DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId());
if (task != null && "COMPLETED".equals(task.getStatus())) {
List<DispatchItem> completedItems = items.findByDispatchOrder(customerId, order.getId());
long planned = completedItems.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull)
.mapToLong(Integer::longValue).sum();
long picked = picks.listTask(customerId, task.getId()).stream().mapToLong(p -> n(p.getPackages())).sum();
return new DispatchPdaResponses.PickingCompleted(order.getDispatchNo(), order.getStatus(), planned,
picked, Math.max(0, planned - picked), picked < planned, true);
}
if (task == null || !("PDA_PENDING".equals(task.getStatus()) || "PICKING".equals(task.getStatus()))) {
throw conflict("Dispatch PDA task is not available for completion");
}
@@ -197,14 +210,7 @@ public class DispatchPdaWorkflowService {
}
List<DispatchPdaPick> dispatchPicks = picks.listTask(customerId, task.getId());
if (dispatchPicks.isEmpty()) throw conflict("Dispatch has no PDA picks to ship");
long shipped = 0;
for (DispatchPdaPick pick : dispatchPicks) {
String key = "DISPATCH:" + order.getDispatchNo() + ":PICK:" + pick.getId() + ":SHIP";
inventory.ship(customerId, operatorId, new InventoryCommandRequests.Move(
pick.getInventoryPositionId(), n(pick.getPackages()),
outbound.getOutboundNo(), outbound.getOutboundNo(), null, key, request.remark()));
shipped += n(pick.getPackages());
}
long shipped = dispatchPicks.stream().mapToLong(p -> n(p.getPackages())).sum();
LocalDateTime now = LocalDateTime.now();
pickAttribution.depart(customerId, outbound.getOutboundNo(), order.getDispatchNo(), dispatchPicks, now);
outbound.setStatus("OUTBOUNDED");
@@ -227,7 +233,8 @@ public class DispatchPdaWorkflowService {
List<InventoryPosition> palletPositions = item.getWarehousePalletId() == null ? List.of()
: positions.listPallet(customerId, item.getWarehousePalletId());
values.add(new DispatchPdaResponses.Item(item.getId(), item.getPalletNo(), item.getDestinationWarehouseCode(),
item.getDestinationWarehouseName(), item.getFbaWarehouseCode(), item.getPackageCount(),
item.getDestinationWarehouseName(), item.getFbaWarehouseCode(),
warehouseRouteLabel(item.getFbaWarehouseCode(), item.getDestinationWarehouseCode()), item.getPackageCount(),
picks.totalItem(customerId, task.getId(), item.getId()), palletPositions.stream().map(p ->
new DispatchPdaResponses.Position(p.getId(), p.getBalanceId(), n(p.getAvailablePackages()),
n(p.getAllocatedPackages()), n(p.getPickedPackages()))).toList()));
@@ -270,6 +277,12 @@ public class DispatchPdaWorkflowService {
private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { throw new IllegalStateException(e); } }
private static long n(Long value) { return value == null ? 0 : value; }
private static long n(Integer value) { return value == null ? 0 : value; }
static String warehouseRouteLabel(String originalFbaWarehouseCode, String destinationWarehouseCode) {
String original = trim(originalFbaWarehouseCode);
String destination = trim(destinationWarehouseCode);
return original != null && destination != null && !original.equalsIgnoreCase(destination)
? original + " -> " + destination : null;
}
private static String trim(String value) { return value == null || value.trim().isEmpty() ? null : value.trim(); }
private static String required(String value, String message) { String result = trim(value); if (result == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message); return result; }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
@@ -84,6 +84,7 @@ public class DispatchPickOrderAttributionService {
long shipped = value(before.getShippedPackageCount()) + entry.getValue();
String to = shipped == value(before.getPackageCount()) ? "COMPLETED" : "PARTIALLY_SHIPPED";
LastlegOrderStatusHistory row = new LastlegOrderStatusHistory();
row.setCustomerId(customerId);
row.setOrderId(entry.getKey());
row.setFromStatus(before.getCurrentStatus());
row.setToStatus(to);
@@ -31,6 +31,7 @@ import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
@@ -48,6 +49,7 @@ public class DispatchService {
@Resource private DispatchDraftMapper drafts;
@Resource private DispatchDraftContainerMapper draftContainers;
@Resource private DispatchCandidateMapper candidates;
@Resource private LastlegOrderNoGenerator orderNoGenerator;
public String resolveCustomerScopeForPlans(List<Long> planIds) {
return requireSingleCustomerScope(candidates.findCustomerScopesByPlanIds(normalizeIds(planIds, "Plan IDs")),
@@ -239,7 +241,7 @@ public class DispatchService {
DispatchOrder dispatch = new DispatchOrder();
dispatch.setId(IdWorker.getId());
dispatch.setCustomerId(customerId);
dispatch.setDispatchNo("PC" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + dispatch.getId());
dispatch.setDispatchNo(orderNoGenerator.next("DISPATCH", selected.get(0).overseasWarehouseId(), dispatch.getId()));
dispatch.setOverseasWarehouseId(selected.get(0).overseasWarehouseId());
dispatch.setOverseasWarehouseName(selected.get(0).overseasWarehouseName());
dispatch.setRoute(StringUtils.hasText(request.route()) ? request.route().trim() : route(selected));
@@ -15,4 +15,8 @@ public interface DropshipCartonMapper extends BaseMapper<DropshipCarton> {
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY profile_id,carton_sequence")
List<DropshipCarton> listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId);
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND profile_id=#{profileId} AND sku_code=#{sku} AND status='AVAILABLE' ORDER BY carton_sequence LIMIT #{packages} FOR UPDATE")
List<DropshipCarton> lockAvailable(@Param("customerId") String customerId, @Param("profileId") Long profileId, @Param("sku") String sku, @Param("packages") long packages);
@Update("UPDATE ll_dropship_carton SET status='AVAILABLE', outbound_order_no=NULL, updated_at=NOW(3) WHERE customer_id=#{customerId} AND outbound_order_no=#{outboundNo} AND status='SHIPPED'")
int restoreOutbound(@Param("customerId") String customerId, @Param("outboundNo") String outboundNo);
}
@@ -9,4 +9,6 @@ import java.util.List;
public interface DropshipOrderProfileMapper extends BaseMapper<DropshipOrderProfile> {
@Select("SELECT * FROM ll_dropship_order_profile WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY source_pallet_no")
List<DropshipOrderProfile> listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId);
@Select("SELECT * FROM ll_dropship_order_profile WHERE customer_id=#{customerId} AND inventory_position_id=#{positionId} LIMIT 1")
DropshipOrderProfile findPosition(@Param("customerId") String customerId, @Param("positionId") Long positionId);
}
@@ -6,6 +6,7 @@ import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
@@ -13,4 +14,8 @@ import java.util.List;
public interface DropshipSkuInventoryMapper extends BaseMapper<DropshipSkuInventory> {
@Select("SELECT * FROM ll_dropship_sku_inventory WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY profile_id,sku_code")
List<DropshipSkuInventory> listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId);
@Update("UPDATE ll_dropship_sku_inventory SET package_count=package_count-#{packages},updated_at=NOW(3) WHERE customer_id=#{customerId} AND profile_id=#{profileId} AND sku_code=#{sku} AND package_count>=#{packages}")
int deduct(@Param("customerId") String customerId, @Param("profileId") Long profileId, @Param("sku") String sku, @Param("packages") long packages);
@Update("UPDATE ll_dropship_sku_inventory SET package_count=package_count+#{packages},updated_at=NOW(3) WHERE customer_id=#{customerId} AND profile_id=#{profileId} AND sku_code=#{sku}")
int restore(@Param("customerId") String customerId, @Param("profileId") Long profileId, @Param("sku") String sku, @Param("packages") long packages);
}
@@ -0,0 +1,8 @@
package com.baiyue.lastleg.dropship.model;
import java.util.List;
public record DropshipPalletSnapshot(String sourcePalletNo, long packageCount,
List<Sku> skus) {
public record Sku(String skuCode, String productName, long packageCount, long productQuantity) {}
}
@@ -7,6 +7,7 @@ import com.baiyue.lastleg.dropship.mapper.*;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.dropship.model.DropshipPalletSnapshot;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
@@ -109,6 +110,21 @@ public class DropshipOrderService {
return profile;
}
@Transactional(readOnly = true)
public List<DropshipPalletSnapshot> palletSnapshots(String customerId, Long orderId) {
List<DropshipOrderProfile> orderProfiles = profiles.listOrder(customerId, orderId);
Map<Long, List<DropshipSkuInventory>> skusByProfile = skuInventory.listOrder(customerId, orderId).stream()
.collect(Collectors.groupingBy(DropshipSkuInventory::getProfileId));
return orderProfiles.stream().map(profile -> {
List<DropshipPalletSnapshot.Sku> skus = skusByProfile.getOrDefault(profile.getId(), List.of()).stream()
.map(sku -> new DropshipPalletSnapshot.Sku(sku.getSkuCode(), sku.getProductName(),
value(sku.getPackageCount()), value(sku.getProductQuantity())))
.toList();
return new DropshipPalletSnapshot(profile.getSourcePalletNo(),
skus.stream().mapToLong(DropshipPalletSnapshot.Sku::packageCount).sum(), skus);
}).toList();
}
@Transactional
public void activateAfterStockIn(String customerId, Long orderId, Long taskId,
List<InboundStockReceipt> receipts) {
@@ -118,20 +134,18 @@ public class DropshipOrderService {
if (profilesForOrder.stream().anyMatch(profile -> "ACTIVE".equals(profile.getStatus()))) {
throw conflict("Dropship pallet activation is incomplete; repair profiles before retrying");
}
Map<Long, List<InboundStockReceipt>> receiptsByPallet = receipts.stream()
.filter(receipt -> receipt.getWarehousePalletId() != null)
.collect(Collectors.groupingBy(InboundStockReceipt::getWarehousePalletId));
Map<String, List<InboundStockReceipt>> receiptsByPallet = receipts.stream()
.filter(receipt -> receipt.getPalletNo() != null)
.collect(Collectors.groupingBy(InboundStockReceipt::getPalletNo));
if (receiptsByPallet.size() != profilesForOrder.size()) {
throw conflict("PDA pallet count does not match dropship source pallet count");
}
List<DropshipOrderProfile> orderedProfiles = profilesForOrder.stream()
.sorted(java.util.Comparator.comparing(DropshipOrderProfile::getSourcePalletNo)).toList();
List<List<InboundStockReceipt>> orderedReceipts = receiptsByPallet.values().stream()
.sorted(java.util.Comparator.comparing(value -> value.get(0).getWarehousePalletId())).toList();
LocalDateTime now = LocalDateTime.now();
for (int index = 0; index < orderedProfiles.size(); index++) {
DropshipOrderProfile profile = orderedProfiles.get(index);
List<InboundStockReceipt> actual = orderedReceipts.get(index);
for (DropshipOrderProfile profile : profilesForOrder) {
List<InboundStockReceipt> actual = receiptsByPallet.get(profile.getSourcePalletNo());
if (actual == null || actual.isEmpty()) {
throw conflict("PDA receipt is missing for dropship source pallet " + profile.getSourcePalletNo());
}
List<DropshipCarton> profileCartons = cartons.listProfile(customerId, profile.getId());
long expected = profileCartons.size();
long received = actual.stream().mapToLong(value -> value.getReceivedPackages() == null ? 0 : value.getReceivedPackages()).sum();
@@ -1,6 +1,7 @@
package com.baiyue.lastleg.dto;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.entity.LastlegOrderFee;
import java.time.LocalDateTime;
@@ -9,7 +10,10 @@ import java.util.List;
public record LastlegOrderDetailResponse(
LastlegOrderSummary order,
List<LastlegOrderDetail> cargo,
List<History> statusHistory
List<History> statusHistory,
List<LastlegOrderFee> fees,
List<Operation> operationHistory
) {
public record History(Long id, String fromStatus, String toStatus, String remark, LocalDateTime createdAt) {}
public record Operation(Long id, String operationNode, String operatorName, String result, LocalDateTime createdAt) {}
}
@@ -5,7 +5,11 @@ import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
@@ -14,8 +18,8 @@ public final class LastlegOrderRequests {
}
public record Query(
@Min(1) long page,
@Min(1) @Max(200) long size,
@Min(1) Long page,
@Min(1) @Max(200) Long size,
String orderNo,
String orderType,
String inboundCode,
@@ -31,8 +35,8 @@ public final class LastlegOrderRequests {
LocalDateTime createdTo
) {
public Query {
if (page == 0) page = 1;
if (size == 0) size = 20;
if (page == null || page == 0) page = 1L;
if (size == null || size == 0) size = 20L;
}
}
@@ -68,4 +72,30 @@ public final class LastlegOrderRequests {
return new TransferDestination(List.of(orderId), warehouseId, warehouseCode, warehouseName, remark);
}
}
public record CreateFee(
@NotBlank @Size(max = 128) String feeItem,
@NotBlank @Pattern(regexp = "[A-Z]{3}") String currency,
@NotNull @DecimalMin(value = "0.01") BigDecimal amount,
@NotBlank @Pattern(regexp = "PENDING_REVIEW|APPROVED|REJECTED") String status,
@Size(max = 500) String supplierDescription
) {
}
public record SetDetained(
@NotNull Boolean detained,
@NotBlank @Size(max = 500) String remark
) {
}
public record SetPalletMergeRequired(
@NotNull Boolean palletMergeRequired
) {
}
public record SetPalletMergeBatch(
@NotEmpty @Size(max = 200) List<Long> orderIds,
@NotNull Boolean palletMergeRequired
) {
}
}
@@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
@Schema(description = "按入仓单编码粒度展示的尾程订单列表行")
public record LastlegOrderSummary(
@@ -40,6 +41,9 @@ public record LastlegOrderSummary(
String relabelOrderNo,
String relabelStatus,
String palletMergeFlag,
String palletMergeBatchId,
@Schema(description = "同一合托组内其他来源入仓单的入仓码")
List<String> palletMergeInboundNos,
String currentStatus,
String subOrderStatus,
String privateRemark,
@@ -52,6 +52,7 @@ public class LastlegOrder {
private String relabelOrderNo;
private String relabelStatus;
private String palletMergeFlag;
private String palletMergeBatchId;
private String currentStatus;
private String subOrderStatus;
private String privateRemark;
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.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_order_fee")
public class LastlegOrderFee {
@TableId(type = IdType.INPUT) private Long id;
private String customerId;
private Long orderId;
private String feeItem;
private String currency;
private BigDecimal amount;
private String status;
private String supplierDescription;
private String createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.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_order_operation_log")
public class LastlegOrderOperationLog {
@TableId(type = IdType.INPUT) private Long id;
private String customerId;
private Long orderId;
private String operationNode;
private String operatorName;
private String result;
private LocalDateTime createdAt;
}
@@ -0,0 +1,73 @@
package com.baiyue.lastleg.exceptionorder.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.exceptionorder.dto.ExceptionOrderRequests;
import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderResponses;
import com.baiyue.lastleg.exceptionorder.service.ExceptionOrderService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@RestController
@RequestMapping("/api/platform/v1/last-leg")
public class ExceptionOrderController {
@Resource private ExceptionOrderService service;
@GetMapping("/exception-orders")
public ApiResponse<PageResult<ExceptionOrderResponses.Row>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid ExceptionOrderRequests.Query query) {
return ApiResponse.success(service.page(scope(user), query));
}
@GetMapping("/exception-orders/resolve")
public ApiResponse<ExceptionOrderResponses.Relation> resolve(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@RequestParam(required = false) String inboundCode,
@RequestParam(required = false) String lastMileOrderNo) {
return ApiResponse.success(service.resolve(scope(user), customerId, inboundCode, lastMileOrderNo));
}
@PostMapping("/exception-orders")
public ApiResponse<ExceptionOrderResponses.Created> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ExceptionOrderRequests.Create request) {
return ApiResponse.success(service.create(scope(user), operator(user), request));
}
@GetMapping("/delivery-orders/{deliveryNo}/return-inbound-options")
public ApiResponse<List<ExceptionOrderResponses.Relation>> returnOptions(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@PathVariable String deliveryNo) {
return ApiResponse.success(service.returnCandidates(requiredScope(user, customerId), deliveryNo));
}
@PostMapping("/delivery-orders/{deliveryNo}/mark-return")
public ApiResponse<ExceptionOrderResponses.Created> markReturn(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@PathVariable String deliveryNo,
@Valid @RequestBody ExceptionOrderRequests.MarkReturn request) {
return ApiResponse.success(service.markReturn(requiredScope(user, customerId), operator(user), deliveryNo, request));
}
private String scope(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
return "PLATFORM".equals(user.appCode()) && user.tenantId() == 0L ? null : String.valueOf(user.tenantId());
}
private String requiredScope(AuthenticatedUser user, String requestedCustomer) {
String own = scope(user);
if (own != null) return own;
if (requestedCustomer == null || requestedCustomer.isBlank()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请选择客户");
return requestedCustomer.trim();
}
private String operator(AuthenticatedUser user) {
return user.userId() == null ? user.username() : String.valueOf(user.userId());
}
}
@@ -0,0 +1,34 @@
package com.baiyue.lastleg.exceptionorder.dto;
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.time.LocalDateTime;
public final class ExceptionOrderRequests {
private ExceptionOrderRequests() {}
public record Query(@Min(1) long page, @Min(1) @Max(200) long size,
String keyword, String exceptionType, String status,
String customerId, String warehouseId,
LocalDateTime createdFrom, LocalDateTime createdTo) {
public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
}
public record Create(String customerId, @Size(max = 128) String inboundCode,
@Size(max = 64) String lastMileOrderNo,
@NotBlank @Size(max = 32) String exceptionType,
@NotBlank @Size(max = 1000) String reason,
@Min(1) Integer packageCount,
@Size(max = 128) String skuCode) {}
public record MarkReturn(@NotNull Long inboundOrderId,
@NotBlank @Size(max = 1000) String reason,
@NotBlank @Size(max = 32) String returnDirection,
@Size(max = 128) String returnLogisticsNo,
@Min(1) Integer packageCount,
@Size(max = 128) String skuCode) {}
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.exceptionorder.dto;
import java.time.LocalDateTime;
public final class ExceptionOrderResponses {
private ExceptionOrderResponses() {}
public record Row(Long id, String exceptionNo, String exceptionType, String status,
String reason, String customerId, String customerName,
String overseasWarehouseId, String overseasWarehouseName,
Long lastMileOrderId, String lastMileOrderNo,
Long inboundOrderId, String inboundCode,
Long deliveryOrderId, String deliveryNo, String outboundNo,
String skuCode, Integer packageCount, String returnDirection,
String returnLogisticsNo, String handlingAction,
String handlerName, LocalDateTime exceptionAt, LocalDateTime updatedAt) {}
public record Relation(Long lastMileOrderId, String lastMileOrderNo,
Long inboundOrderId, String inboundCode,
String customerId, String customerName,
String overseasWarehouseId, String overseasWarehouseName,
String deliveryNo, String outboundNo, Integer availablePackages) {}
public record Created(Long id, String exceptionNo, String status) {}
}
@@ -0,0 +1,42 @@
package com.baiyue.lastleg.exceptionorder.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_exception_order")
public class ExceptionOrder {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String customerId;
private String exceptionNo;
private String exceptionType;
private String status;
private String reason;
private Long lastMileOrderId;
private String lastMileOrderNo;
private Long inboundOrderId;
private String inboundCode;
private Long deliveryOrderId;
private String deliveryNo;
private String outboundNo;
private String customerName;
private String overseasWarehouseId;
private String overseasWarehouseName;
private String skuCode;
private Integer packageCount;
private String returnDirection;
private String returnLogisticsNo;
private String handlingAction;
private String handlerId;
private String handlerName;
private String sourceType;
private String createdBy;
private LocalDateTime exceptionAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.exceptionorder.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrder;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface ExceptionOrderMapper extends BaseMapper<ExceptionOrder> {
@Select("SELECT * FROM ll_exception_order WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryOrderId} " +
"AND exception_type='RETURN' LIMIT 1")
ExceptionOrder findDeliveryReturn(@Param("customerId") String customerId,
@Param("deliveryOrderId") Long deliveryOrderId);
}
@@ -0,0 +1,247 @@
package com.baiyue.lastleg.exceptionorder.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.delivery.entity.DeliveryOperation;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper;
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
import com.baiyue.lastleg.entity.LastlegInboundOrder;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderRequests;
import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderResponses;
import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrder;
import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderMapper;
import com.baiyue.lastleg.mapper.LastlegInboundOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
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.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Objects;
@Service
public class ExceptionOrderService {
@Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator;
@Resource private ExceptionOrderMapper exceptionOrders;
@Resource private LastlegOrderMapper lastMileOrders;
@Resource private LastlegInboundOrderMapper inboundOrders;
@Resource private DeliveryOrderMapper deliveryOrders;
@Resource private DeliveryOperationMapper deliveryOperations;
public PageResult<ExceptionOrderResponses.Row> page(String scopedCustomerId, ExceptionOrderRequests.Query query) {
String customerId = customerScope(scopedCustomerId, query.customerId());
Page<ExceptionOrder> result = exceptionOrders.selectPage(new Page<>(query.page(), query.size()),
new LambdaQueryWrapper<ExceptionOrder>()
.eq(StringUtils.hasText(customerId), ExceptionOrder::getCustomerId, customerId)
.and(StringUtils.hasText(query.keyword()), wrapper -> wrapper
.like(ExceptionOrder::getExceptionNo, trim(query.keyword()))
.or().like(ExceptionOrder::getLastMileOrderNo, trim(query.keyword()))
.or().like(ExceptionOrder::getInboundCode, trim(query.keyword()))
.or().like(ExceptionOrder::getDeliveryNo, trim(query.keyword()))
.or().like(ExceptionOrder::getCustomerName, trim(query.keyword()))
.or().like(ExceptionOrder::getReason, trim(query.keyword())))
.eq(StringUtils.hasText(query.exceptionType()), ExceptionOrder::getExceptionType, trim(query.exceptionType()))
.eq(StringUtils.hasText(query.status()), ExceptionOrder::getStatus, trim(query.status()))
.eq(StringUtils.hasText(query.warehouseId()), ExceptionOrder::getOverseasWarehouseId, trim(query.warehouseId()))
.ge(query.createdFrom() != null, ExceptionOrder::getExceptionAt, query.createdFrom())
.le(query.createdTo() != null, ExceptionOrder::getExceptionAt, query.createdTo())
.orderByDesc(ExceptionOrder::getExceptionAt).orderByDesc(ExceptionOrder::getId));
return new PageResult<>(result.getRecords().stream().map(this::row).toList(), result.getCurrent(),
result.getSize(), result.getTotal(), result.getPages());
}
public ExceptionOrderResponses.Relation resolve(String scopedCustomerId, String requestedCustomerId,
String inboundCode, String lastMileOrderNo) {
return relation(scopedCustomerId, requestedCustomerId, inboundCode, lastMileOrderNo);
}
public List<ExceptionOrderResponses.Relation> returnCandidates(String customerId, String deliveryNo) {
DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo);
if (delivery == null) throw notFound("派送单不存在");
return lastMileOrders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getCustomerId, customerId)
.eq(LastlegOrder::getDeliveryOrderNo, deliveryNo)
.orderByAsc(LastlegOrder::getOrderNo)).stream()
.map(order -> relation(customerId, customerId, order.getInboundCode(), order.getOrderNo()))
.distinct().toList();
}
@Transactional
public ExceptionOrderResponses.Created create(String scopedCustomerId, String operatorId,
ExceptionOrderRequests.Create request) {
ExceptionOrderResponses.Relation relation = relation(scopedCustomerId, request.customerId(),
request.inboundCode(), request.lastMileOrderNo());
return insert(relation, null, normalizeType(request.exceptionType()), request.reason(), request.packageCount(),
request.skuCode(), null, null, "MANUAL", operatorId);
}
@Transactional
public ExceptionOrderResponses.Created markReturn(String customerId, String operatorId, String deliveryNo,
ExceptionOrderRequests.MarkReturn request) {
DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo);
if (delivery == null) throw notFound("派送单不存在");
ExceptionOrder existing = exceptionOrders.findDeliveryReturn(customerId, delivery.getId());
if (existing != null) return new ExceptionOrderResponses.Created(existing.getId(), existing.getExceptionNo(), existing.getStatus());
List<ExceptionOrderResponses.Relation> candidates = returnCandidates(customerId, deliveryNo);
ExceptionOrderResponses.Relation selected = candidates.stream()
.filter(candidate -> Objects.equals(candidate.inboundOrderId(), request.inboundOrderId()))
.findFirst().orElseThrow(() -> conflict("所选入仓单不属于该派送单"));
ExceptionOrderResponses.Created created = insert(selected, delivery, "RETURN", request.reason(),
request.packageCount(), request.skuCode(), request.returnDirection(), request.returnLogisticsNo(),
"DELIVERY_RETURN", operatorId);
LocalDateTime now = LocalDateTime.now();
delivery.setStatus("RETURN_PENDING");
delivery.setUpdatedAt(now);
deliveryOrders.updateById(delivery);
LastlegOrder tail = lastMileOrders.selectById(selected.lastMileOrderId());
if (tail != null) {
tail.setAbnormal(true);
tail.setSubOrderStatus("RETURN_PENDING");
tail.setUpdatedAt(now);
lastMileOrders.updateById(tail);
}
DeliveryOperation operation = new DeliveryOperation();
operation.setCustomerId(customerId); operation.setDeliveryOrderId(delivery.getId());
operation.setOperationNode("MARK_RETURN"); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId);
operation.setResult("已生成异常退货单 " + created.exceptionNo()); operation.setCreatedAt(now);
deliveryOperations.insert(operation);
return created;
}
private ExceptionOrderResponses.Relation relation(String scopedCustomerId, String requestedCustomerId,
String inboundCode, String lastMileOrderNo) {
String customerId = customerScope(scopedCustomerId, requestedCustomerId);
String inbound = trim(inboundCode);
String tailNo = trim(lastMileOrderNo);
if (!StringUtils.hasText(inbound) && !StringUtils.hasText(tailNo)) {
throw badRequest("入仓码和尾程订单号至少填写一个");
}
LastlegOrder tail = StringUtils.hasText(tailNo) ? uniqueTail(customerId, tailNo) : null;
LastlegInboundOrder inboundOrder = StringUtils.hasText(inbound) ? uniqueInbound(customerId, inbound) : null;
if (tail != null && inboundOrder != null && !Objects.equals(tail.getInboundCode(), inboundOrder.getInboundCode())) {
throw conflict("尾程订单与入仓单不是同一关联关系");
}
if (tail == null && inboundOrder != null) tail = optionalTail(inboundOrder.getCustomerId(), inboundOrder.getInboundCode());
if (inboundOrder == null && tail != null) inboundOrder = optionalInbound(tail.getCustomerId(), tail.getInboundCode());
if (tail == null && inboundOrder == null) throw notFound("未找到对应单据");
String resolvedCustomer = tail != null ? tail.getCustomerId() : inboundOrder.getCustomerId();
if (tail != null && inboundOrder != null && !Objects.equals(tail.getCustomerId(), inboundOrder.getCustomerId())) {
throw conflict("尾程订单与入仓单不属于同一客户");
}
return new ExceptionOrderResponses.Relation(
tail == null ? null : tail.getId(), tail == null ? null : tail.getOrderNo(),
inboundOrder == null ? null : inboundOrder.getId(), inboundOrder == null ? null : inboundOrder.getInboundCode(),
resolvedCustomer, tail == null ? null : tail.getCustomerName(),
tail == null ? null : tail.getOverseasWarehouseId(), tail == null ? null : tail.getOverseasWarehouseName(),
tail == null ? null : tail.getDeliveryOrderNo(), tail == null ? null : tail.getOutboundOrderNo(),
tail == null ? inboundOrder.getPackageCount() : tail.getPackageCount());
}
private ExceptionOrderResponses.Created insert(ExceptionOrderResponses.Relation relation, DeliveryOrder delivery,
String type, String reason, Integer packages, String sku,
String direction, String logisticsNo, String sourceType,
String operatorId) {
if (packages != null && relation.availablePackages() != null && packages > relation.availablePackages()) {
throw conflict("异常箱数不能大于订单箱数 " + relation.availablePackages());
}
LocalDateTime now = LocalDateTime.now();
ExceptionOrder value = new ExceptionOrder();
value.setId(IdWorker.getId()); value.setCustomerId(relation.customerId());
value.setExceptionNo(orderNoGenerator.next("EXCEPTION", relation.overseasWarehouseId(), value.getId()));
value.setExceptionType(type); value.setStatus("PENDING"); value.setReason(trim(reason));
value.setLastMileOrderId(relation.lastMileOrderId()); value.setLastMileOrderNo(relation.lastMileOrderNo());
value.setInboundOrderId(relation.inboundOrderId()); value.setInboundCode(relation.inboundCode());
value.setDeliveryOrderId(delivery == null ? null : delivery.getId());
value.setDeliveryNo(delivery == null ? relation.deliveryNo() : delivery.getDeliveryNo());
value.setOutboundNo(delivery == null ? relation.outboundNo() : delivery.getOutboundNo());
value.setCustomerName(relation.customerName());
value.setOverseasWarehouseId(delivery == null ? relation.overseasWarehouseId() : delivery.getOverseasWarehouseId());
value.setOverseasWarehouseName(delivery == null ? relation.overseasWarehouseName() : delivery.getOverseasWarehouseName());
value.setSkuCode(trim(sku)); value.setPackageCount(packages); value.setReturnDirection(trim(direction));
value.setReturnLogisticsNo(trim(logisticsNo));
if (StringUtils.hasText(direction)) {
value.setHandlingAction("OVERSEAS_WAREHOUSE".equals(direction) ? "退回海外仓" : "CUSTOMER".equals(direction) ? "退回客户" : "其他退货处理");
value.setHandlerId(operatorId); value.setHandlerName(operatorId);
}
value.setSourceType(sourceType); value.setCreatedBy(operatorId);
value.setExceptionAt(now); value.setCreatedAt(now); value.setUpdatedAt(now);
try { exceptionOrders.insert(value); }
catch (DuplicateKeyException exception) { throw conflict("该关联单据已存在异常退货单"); }
return new ExceptionOrderResponses.Created(value.getId(), value.getExceptionNo(), value.getStatus());
}
private LastlegOrder uniqueTail(String customerId, String orderNo) {
List<LastlegOrder> values = lastMileOrders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId)
.eq(LastlegOrder::getOrderNo, orderNo).last("LIMIT 2"));
if (values.isEmpty()) throw notFound("尾程订单不存在");
if (values.size() > 1) throw conflict("尾程订单号不唯一,请先选择客户");
return values.get(0);
}
private LastlegInboundOrder uniqueInbound(String customerId, String inboundCode) {
List<LastlegInboundOrder> values = inboundOrders.selectList(new LambdaQueryWrapper<LastlegInboundOrder>()
.eq(StringUtils.hasText(customerId), LastlegInboundOrder::getCustomerId, customerId)
.eq(LastlegInboundOrder::getInboundCode, inboundCode).last("LIMIT 2"));
if (values.isEmpty()) throw notFound("入仓单不存在");
if (values.size() > 1) throw conflict("入仓码不唯一,请先选择客户");
return values.get(0);
}
private LastlegOrder optionalTail(String customerId, String inboundCode) {
List<LastlegOrder> values = lastMileOrders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getCustomerId, customerId).eq(LastlegOrder::getInboundCode, inboundCode).last("LIMIT 2"));
if (values.size() > 1) throw conflict("该入仓码关联了多个尾程订单,请修复关联关系后重试");
return values.size() == 1 ? values.get(0) : null;
}
private LastlegInboundOrder optionalInbound(String customerId, String inboundCode) {
if (!StringUtils.hasText(inboundCode)) return null;
List<LastlegInboundOrder> values = inboundOrders.selectList(new LambdaQueryWrapper<LastlegInboundOrder>()
.eq(LastlegInboundOrder::getCustomerId, customerId).eq(LastlegInboundOrder::getInboundCode, inboundCode).last("LIMIT 2"));
if (values.size() > 1) throw conflict("该尾程订单关联了多个入仓单,请修复关联关系后重试");
return values.size() == 1 ? values.get(0) : null;
}
private ExceptionOrderResponses.Row row(ExceptionOrder value) {
return new ExceptionOrderResponses.Row(value.getId(), value.getExceptionNo(), value.getExceptionType(), value.getStatus(),
value.getReason(), value.getCustomerId(), value.getCustomerName(), value.getOverseasWarehouseId(),
value.getOverseasWarehouseName(), value.getLastMileOrderId(), value.getLastMileOrderNo(),
value.getInboundOrderId(), value.getInboundCode(), value.getDeliveryOrderId(), value.getDeliveryNo(),
value.getOutboundNo(), value.getSkuCode(), value.getPackageCount(), value.getReturnDirection(),
value.getReturnLogisticsNo(), value.getHandlingAction(), value.getHandlerName(),
value.getExceptionAt(), value.getUpdatedAt());
}
private String customerScope(String scopedCustomerId, String requestedCustomerId) {
if (StringUtils.hasText(scopedCustomerId)) {
if (StringUtils.hasText(requestedCustomerId) && !scopedCustomerId.equals(trim(requestedCustomerId))) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "不能访问其他客户数据");
}
return scopedCustomerId;
}
return trim(requestedCustomerId);
}
private static String normalizeType(String value) {
String normalized = trim(value).toUpperCase();
if (!java.util.Set.of("RETURN", "REJECT", "DAMAGED", "OTHER").contains(normalized)) throw badRequest("不支持的异常类型");
return normalized;
}
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}
@@ -12,6 +12,7 @@ import com.baiyue.lastleg.inboundplan.dto.InboundPlanListResponse;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
import com.baiyue.lastleg.inboundplan.dto.SupplementPalletRequest;
import com.baiyue.lastleg.inboundplan.dto.UpdateFbaAppointmentRequest;
import com.baiyue.lastleg.inboundplan.dto.UpdateInboundPlanRequest;
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
import com.baiyue.lastleg.inboundplan.service.InboundPlanService;
import com.baiyue.lastleg.security.OverseasPermissions;
@@ -75,6 +76,15 @@ public class InboundPlanController {
return ApiResponse.success(service.detailByPlanNo(scope(user, customerId), planNo));
}
@PutMapping("/{planNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_EDIT + "')")
@Operation(summary = "编辑入仓计划")
public ApiResponse<InboundPlanResponse> update(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@Valid @RequestBody UpdateInboundPlanRequest request) {
return ApiResponse.success(service.update(scope(user, request.customerId()), planNo, request));
}
@PostMapping("/{planNo}/pallets/supplement")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_EDIT + "')")
/** 为计划目标仓新增一个可打印的补充托盘。 */
@@ -17,6 +17,7 @@ public record InboundPlanListResponse(
String fbaWarehouseCode,
String customerName,
Integer packageCount,
Integer pickedPackageCount,
BigDecimal volumeCbm,
Integer palletCount,
String status,
@@ -25,6 +26,20 @@ public record InboundPlanListResponse(
LocalDateTime appointmentAt,
LocalDateTime expectedLoadingAt,
String dispatchStatus,
LocalDateTime createdAt
LocalDateTime createdAt,
Integer version
) {
public InboundPlanListResponse(Long lineId, Long planId, String planNo, String customerId,
Long orderId, String inboundCode, String orderType,
String warehouseId, String warehouseCode, String warehouseName,
String fbaWarehouseCode, String customerName, Integer packageCount,
BigDecimal volumeCbm, Integer palletCount, String status,
String appointmentNo, String appointmentStatus,
LocalDateTime appointmentAt, LocalDateTime expectedLoadingAt,
String dispatchStatus, LocalDateTime createdAt) {
this(lineId, planId, planNo, customerId, orderId, inboundCode, orderType, warehouseId,
warehouseCode, warehouseName, fbaWarehouseCode, customerName, packageCount, 0,
volumeCbm, palletCount, status, appointmentNo, appointmentStatus, appointmentAt,
expectedLoadingAt, dispatchStatus, createdAt, 0);
}
}
@@ -14,10 +14,29 @@ public record InboundPlanResponse(
int lineCount,
int palletCount,
BigDecimal totalVolumeCbm,
int expectedPackageCount,
int pickedPackageCount,
List<Line> lines,
List<Pallet> pallets,
List<Allocation> allocations
List<Allocation> allocations,
int version
) {
public InboundPlanResponse(Long id, String planNo, String customerId, Long sourceOrderId,
String containerNo, String status, int lineCount, int palletCount,
BigDecimal totalVolumeCbm, List<Line> lines, List<Pallet> pallets,
List<Allocation> allocations) {
this(id, planNo, customerId, sourceOrderId, containerNo, status, lineCount, palletCount,
totalVolumeCbm, lines, pallets, allocations, 0);
}
public InboundPlanResponse(Long id, String planNo, String customerId, Long sourceOrderId,
String containerNo, String status, int lineCount, int palletCount,
BigDecimal totalVolumeCbm, List<Line> lines, List<Pallet> pallets,
List<Allocation> allocations, int version) {
this(id, planNo, customerId, sourceOrderId, containerNo, status, lineCount, palletCount,
totalVolumeCbm, lines.stream().mapToInt(Line::requiredPackageCount).sum(),
allocations.stream().mapToInt(Allocation::pickedPackageCount).sum(), lines, pallets, allocations, version);
}
public record Line(Long id, Long orderId, String inboundCode, String fbaWarehouseCode, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
BigDecimal volumeCbm, int requiredPackageCount, int plannedPalletCount) {
@@ -0,0 +1,12 @@
package com.baiyue.lastleg.inboundplan.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
public record UpdateInboundPlanRequest(
@NotBlank String customerId,
@NotNull Long overseasWarehouseId,
String warehouseCode,
String warehouseName,
@NotNull Integer version
) {}
@@ -3,6 +3,7 @@ package com.baiyue.lastleg.inboundplan.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.Version;
import lombok.Data;
import java.math.BigDecimal;
@@ -24,4 +25,6 @@ public class InboundPlan {
private BigDecimal totalVolumeCbm;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@Version
private Integer version;
}
@@ -24,19 +24,17 @@ public interface InboundPlanOrderMapper {
@Select("SELECT o.id AS orderId,o.customer_id AS customerId,o.container_no AS containerNo," +
"o.inbound_code AS inboundCode,o.current_status AS currentStatus,o.inbound_order_no AS inboundOrderNo," +
"COALESCE(o.volume_cbm,0) AS volumeCbm,COALESCE(o.package_count,0) AS packageCount," +
"COALESCE(o.pallet_count,0) AS recommendedPalletCount," +
"NULLIF(o.fba_warehouse_code,'') AS fbaWarehouseCode," +
"COALESCE(NULLIF(o.transfer_warehouse_id,''),MIN(NULLIF(d.destination_warehouse_id,''))) AS destinationWarehouseId," +
"COALESCE(NULLIF(o.transfer_warehouse_code,''),MIN(NULLIF(d.destination_warehouse_code,''))) AS destinationWarehouseCode," +
"COALESCE(NULLIF(o.transfer_warehouse_name,''),MIN(NULLIF(d.destination_warehouse_name,''))) AS destinationWarehouseName," +
"COUNT(DISTINCT COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#')) AS destinationCount," +
"SUM(CASE WHEN d.id IS NOT NULL " +
"AND COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) IS NOT NULL " +
"AND COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#') " +
"<> COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) THEN 1 ELSE 0 END) " +
"AS destinationMismatchCount " +
"CASE WHEN COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) IS NOT NULL THEN 1 " +
"ELSE COUNT(DISTINCT COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#')) END AS destinationCount," +
"0 AS destinationMismatchCount " +
"FROM ll_order o LEFT JOIN ll_order_detail d ON d.order_id=o.id " +
"WHERE o.customer_id=#{customerId} AND o.id=#{orderId} " +
"GROUP BY o.id,o.customer_id,o.container_no,o.inbound_code,o.current_status,o.inbound_order_no,o.volume_cbm,o.package_count,o.fba_warehouse_code," +
"GROUP BY o.id,o.customer_id,o.container_no,o.inbound_code,o.current_status,o.inbound_order_no,o.volume_cbm,o.package_count,o.pallet_count,o.fba_warehouse_code," +
"o.transfer_warehouse_id,o.transfer_warehouse_code,o.transfer_warehouse_name " +
"ORDER BY o.id FOR UPDATE")
List<SourceOrderSnapshot> lockOrder(@Param("customerId") String customerId,
@@ -30,8 +30,8 @@ public interface InboundPlanQueryMapper {
"<if test='q.fbaWarehouseCode != null and q.fbaWarehouseCode != \"\"'> AND l.fba_warehouse_code LIKE CONCAT('%',#{q.fbaWarehouseCode},'%')</if>" +
"<choose><when test='q.appointmentStatus == \"UNAPPOINTED\"'> AND ai.id IS NULL</when>" +
"<when test='q.appointmentStatus != null and q.appointmentStatus != \"\"'> AND a.appointment_status=#{q.appointmentStatus}</when></choose>" +
"<choose><when test='q.dispatchStatus == \"SCHEDULED\"'> AND EXISTS (SELECT 1 FROM ll_dispatch_item di WHERE di.order_id=l.order_id <if test='customerId != null and customerId != \"\"'> AND di.customer_id=#{customerId}</if>)</when>" +
"<when test='q.dispatchStatus == \"UNSCHEDULED\"'> AND NOT EXISTS (SELECT 1 FROM ll_dispatch_item di WHERE di.order_id=l.order_id <if test='customerId != null and customerId != \"\"'> AND di.customer_id=#{customerId}</if>)</when></choose>" +
"<choose><when test='q.dispatchStatus == \"SCHEDULED\"'> AND EXISTS (SELECT 1 FROM ll_dispatch_item di WHERE <if test='customerId != null and customerId != \"\"'>di.customer_id=#{customerId} AND </if>di.order_id=l.order_id)</when>" +
"<when test='q.dispatchStatus == \"UNSCHEDULED\"'> AND NOT EXISTS (SELECT 1 FROM ll_dispatch_item di WHERE <if test='customerId != null and customerId != \"\"'>di.customer_id=#{customerId} AND </if>di.order_id=l.order_id)</when></choose>" +
"<if test='q.createdFrom != null'> AND p.created_at &gt;=#{q.createdFrom}</if>" +
"<if test='q.createdTo != null'> AND p.created_at &lt;=#{q.createdTo}</if>";
@@ -39,12 +39,15 @@ public interface InboundPlanQueryMapper {
"l.order_id orderId,l.inbound_code inboundCode,o.order_type orderType," +
"l.destination_warehouse_id warehouseId,l.destination_warehouse_code warehouseCode," +
"l.destination_warehouse_name warehouseName,l.fba_warehouse_code fbaWarehouseCode," +
"o.customer_name customerName,l.required_package_count packageCount,l.volume_cbm volumeCbm," +
"o.customer_name customerName,l.required_package_count packageCount," +
"COALESCE((SELECT SUM(pa.picked_package_count) FROM ll_inbound_plan_pallet_allocation pa WHERE pa.line_id=l.id),0) pickedPackageCount," +
"l.volume_cbm volumeCbm," +
"l.planned_pallet_count palletCount,p.status status,a.appointment_no appointmentNo," +
"a.appointment_status appointmentStatus,a.appointment_at appointmentAt," +
"a.expected_loading_at expectedLoadingAt," +
"CASE WHEN EXISTS (SELECT 1 FROM ll_dispatch_item di WHERE di.customer_id=#{customerId} AND di.order_id=l.order_id)" +
" THEN 'SCHEDULED' ELSE 'UNSCHEDULED' END dispatchStatus,p.created_at createdAt" +
"CASE WHEN EXISTS (SELECT 1 FROM ll_dispatch_item di WHERE " +
"<if test='customerId != null and customerId != \"\"'>di.customer_id=#{customerId} AND </if>di.order_id=l.order_id)" +
" THEN 'SCHEDULED' ELSE 'UNSCHEDULED' END dispatchStatus,p.created_at createdAt,p.version version" +
FROM_AND_FILTER + " ORDER BY p.created_at DESC,l.id DESC LIMIT #{limit} OFFSET #{offset}</script>")
List<InboundPlanListResponse> rows(@Param("customerId") String customerId,
@Param("q") InboundPlanRequests.Query query,
@@ -11,6 +11,7 @@ public record SourceOrderSnapshot(
String inboundOrderNo,
BigDecimal volumeCbm,
int packageCount,
int recommendedPalletCount,
String fbaWarehouseCode,
String destinationWarehouseId,
String destinationWarehouseCode,
@@ -18,13 +19,23 @@ public record SourceOrderSnapshot(
int destinationCount,
int destinationMismatchCount
) {
public SourceOrderSnapshot(Long orderId, String customerId, String containerNo, String inboundCode,
String currentStatus, String inboundOrderNo, BigDecimal volumeCbm,
int packageCount, String fbaWarehouseCode, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
int destinationCount, int destinationMismatchCount) {
this(orderId, customerId, containerNo, inboundCode, currentStatus, inboundOrderNo, volumeCbm,
packageCount, 0, fbaWarehouseCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, destinationCount, destinationMismatchCount);
}
public SourceOrderSnapshot(Long orderId, String customerId, String containerNo, String inboundCode,
String currentStatus, String inboundOrderNo, BigDecimal volumeCbm,
int packageCount, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
int destinationCount, int destinationMismatchCount) {
this(orderId, customerId, containerNo, inboundCode, currentStatus, inboundOrderNo, volumeCbm,
packageCount, null, destinationWarehouseId, destinationWarehouseCode,
packageCount, 0, null, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, destinationCount, destinationMismatchCount);
}
}
@@ -17,11 +17,22 @@ public final class PdaRequests {
public record PackageOperation(@Positive int packageCount, @NotBlank String idempotencyKey,
String operatorId, String operatorName) {}
public record WholePalletOperation(@NotBlank String idempotencyKey,
String operatorId, String operatorName) {}
public record ConfirmMerge(@Positive long sourceAllocationId, @Positive int packageCount,
@NotBlank String idempotencyKey, String operatorId, String operatorName) {}
public record ScanCarton(@NotBlank String cartonMark, @Positive long planPalletId,
@NotBlank String idempotencyKey, String operatorId, String operatorName) {}
public record ScanCarton(@NotBlank String cartonMark, Long planPalletId, Integer packageCount,
@NotBlank String idempotencyKey, String operatorId, String operatorName) {
/** Backward-compatible single-carton request with an explicitly selected pallet. */
public ScanCarton(String cartonMark, long planPalletId, String idempotencyKey,
String operatorId, String operatorName) {
this(cartonMark, planPalletId, 1, idempotencyKey, operatorId, operatorName);
}
}
public record ForceComplete(@NotBlank String idempotencyKey, String operatorId, String operatorName) {}
public record RemoveCarton(@NotBlank String cartonMark, @NotBlank String idempotencyKey,
String operatorId, String operatorName) {}
@@ -10,9 +10,34 @@ public final class PdaResponses {
public record Task(long taskId, String taskNo, String planNo, String status,
LocalDateTime pushedAt, LocalDateTime startedAt, LocalDateTime completedAt,
List<Long> unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt,
LocalDateTime estimatedEmptyReturnAt,
LocalDateTime estimatedEmptyReturnAt, int expectedPackageCount,
int pickedPackageCount, String orderType, boolean wholePalletInbound,
List<DropshipPallet> dropshipPallets, String inboundMode,
List<Pallet> pallets, List<Allocation> allocations,
List<ExpectedCarton> expectedCartons) {}
List<ExpectedCarton> expectedCartons) {
public Task(long taskId, String taskNo, String planNo, String status,
LocalDateTime pushedAt, LocalDateTime startedAt, LocalDateTime completedAt,
List<Long> unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt,
LocalDateTime estimatedEmptyReturnAt, int expectedPackageCount,
int pickedPackageCount) {
this(taskId, taskNo, planNo, status, pushedAt, startedAt, completedAt,
unloadPhotoFileIds, unloadPhotoUploadedAt, estimatedEmptyReturnAt,
expectedPackageCount, pickedPackageCount, null, false, List.of(), null,
List.of(), List.of(), List.of());
}
public Task(long taskId, String taskNo, String planNo, String status,
LocalDateTime pushedAt, LocalDateTime startedAt, LocalDateTime completedAt,
List<Long> unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt,
LocalDateTime estimatedEmptyReturnAt) {
this(taskId, taskNo, planNo, status, pushedAt, startedAt, completedAt,
unloadPhotoFileIds, unloadPhotoUploadedAt, estimatedEmptyReturnAt, 0, 0);
}
}
public record DropshipPallet(String sourcePalletNo, long planPalletId, long allocationId,
long packageCount, List<DropshipSku> skus) {}
public record DropshipSku(String skuCode, String productName, long packageCount, long productQuantity) {}
public record Pallet(long planPalletId, String palletNo, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
@@ -46,5 +71,10 @@ public final class PdaResponses {
boolean sourcePalletScrapped) {}
public record Carton(String result, String cartonMark, long planPalletId, long allocationId,
int palletActualPackageCount) {}
int palletActualPackageCount, String palletNo, int scannedPackageCount) {
public Carton(String result, String cartonMark, long planPalletId, long allocationId,
int palletActualPackageCount) {
this(result, cartonMark, planPalletId, allocationId, palletActualPackageCount, null, 1);
}
}
}
@@ -30,14 +30,28 @@ public final class ReceivingContainerResponses {
String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
String fbaWarehouseCode, int plannedPalletCount, int totalPackages,
int receivedPackages, int pendingPackages) { }
int receivedPackages, int pendingPackages,
boolean palletMergeRequired, String palletMergeHint) { }
public record CartonResolved(String cartonMark, String inboundCode,
String destinationType, String overseasWarehouseId,
String overseasWarehouseName, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
String fbaWarehouseCode, int plannedPalletCount, int totalPackages,
int receivedPackages, int pendingPackages) { }
int receivedPackages, int pendingPackages,
boolean palletMergeRequired, String palletMergeHint) {
public CartonResolved(String cartonMark, String inboundCode, String destinationType,
String overseasWarehouseId, String overseasWarehouseName,
String destinationWarehouseId, String destinationWarehouseCode,
String destinationWarehouseName, String fbaWarehouseCode,
int plannedPalletCount, int totalPackages, int receivedPackages,
int pendingPackages) {
this(cartonMark, inboundCode, destinationType, overseasWarehouseId, overseasWarehouseName,
destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName,
fbaWarehouseCode, plannedPalletCount, totalPackages, receivedPackages,
pendingPackages, false, null);
}
}
public record PalletResolved(String palletCode, long warehouseId, String status,
String fbaWarehouseCode, String inboundCode,
@@ -45,7 +59,15 @@ public final class ReceivingContainerResponses {
public record Receipt(String result, String containerNo, String inboundCode,
String destinationType, String palletCode, int acceptedPackages,
int receivedPackages, int pendingPackages, String sessionStatus) { }
int receivedPackages, int pendingPackages, String sessionStatus,
boolean palletMergeRequired, String palletMergeHint) {
public Receipt(String result, String containerNo, String inboundCode,
String destinationType, String palletCode, int acceptedPackages,
int receivedPackages, int pendingPackages, String sessionStatus) {
this(result, containerNo, inboundCode, destinationType, palletCode, acceptedPackages,
receivedPackages, pendingPackages, sessionStatus, false, null);
}
}
public record Photo(String fileId, String fileName, String contentType, long sizeBytes) { }
}
@@ -17,6 +17,8 @@ import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
import com.baiyue.lastleg.inboundplan.pda.mapper.InboundPalletMergeMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.InboundPdaOperationMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.InboundPdaTaskMapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import org.springframework.stereotype.Repository;
import jakarta.annotation.Resource;
@@ -32,6 +34,7 @@ public class PdaWorkflowRepository {
@Resource private InboundPdaOperationMapper operations;
@Resource private InboundPalletMergeMapper merges;
@Resource private InboundPlanCartonMapper cartons;
@Resource private LastlegOrderMapper orders;
public InboundPlan lockPlan(String customerId, String planNo) {
return plans.selectOne(new LambdaQueryWrapper<InboundPlan>()
@@ -93,6 +96,8 @@ public class PdaWorkflowRepository {
.eq(InboundPlanPalletAllocation::getPlanId, planId));
}
public LastlegOrder order(Long orderId) { return orderId == null ? null : orders.selectById(orderId); }
public void insert(InboundPdaTask value) { tasks.insert(value); }
public void insert(InboundPdaOperation value) { operations.insert(value); }
public void insert(InboundPalletMerge value) { merges.insert(value); }
@@ -14,6 +14,8 @@ 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.baiyue.lastleg.dropship.model.DropshipPalletSnapshot;
import com.baiyue.lastleg.dropship.service.DropshipOrderService;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
@@ -54,6 +56,7 @@ public class InboundPdaWorkflowService {
@Resource private InboundUnloadCompletedOutboxService unloadCompletedOutbox;
@Resource private LastlegOrderMapper orders;
@Resource private UnloadPhotoFileGateway unloadPhotoFiles;
@Resource private DropshipOrderService dropshipOrders;
@Resource private ObjectMapper sourceObjectMapper;
private ObjectMapper objectMapper;
@@ -77,6 +80,9 @@ public class InboundPdaWorkflowService {
}
InboundPdaTask task = repository.lockTask(customerId, plan.getId());
LocalDateTime now = LocalDateTime.now();
if (task != null && terminal(task.getStatus())) {
throw conflict("PDA inbound task is already completed");
}
if (task == null) {
task = new InboundPdaTask();
task.setId(IdWorker.getId());
@@ -95,7 +101,7 @@ public class InboundPdaWorkflowService {
plan.setStatus("PDA_PENDING");
plan.setUpdatedAt(now);
repository.update(plan);
PdaResponses.Task result = task(task, planNo);
PdaResponses.Task result = taskResponse(task, plan, planNo);
saveOperation(customerId, task.getId(), null, "PUSH", request.idempotencyKey(), request, result);
return result;
}
@@ -136,7 +142,7 @@ public class InboundPdaWorkflowService {
InboundPlan plan = requirePlan(customerId, planNo);
InboundPdaTask task = repository.task(customerId, plan.getId());
if (task == null) throw notFound("PDA task not found");
return task(task, planNo);
return taskResponse(task, plan, planNo);
}
@Transactional
@@ -255,7 +261,7 @@ public class InboundPdaWorkflowService {
public PdaResponses.Operation count(String customerId, String planNo, long allocationId,
PdaRequests.PackageOperation request) {
InboundPlan plan = requirePlan(customerId, planNo);
requireLegacyCounting(plan);
requireCountMode(plan);
InboundPdaOperation replay = replay(customerId, "COUNT", request.idempotencyKey(), request);
if (replay != null) return read(replay, PdaResponses.Operation.class);
InboundPdaTask task = requireActiveTask(customerId, plan);
@@ -283,7 +289,7 @@ public class InboundPdaWorkflowService {
public PdaResponses.Operation pick(String customerId, String planNo, long allocationId,
PdaRequests.PackageOperation request) {
InboundPlan plan = requirePlan(customerId, planNo);
requireLegacyCounting(plan);
requireCountMode(plan);
InboundPdaOperation replay = replay(customerId, "PICK", request.idempotencyKey(), request);
if (replay != null) return read(replay, PdaResponses.Operation.class);
InboundPdaTask task = requireActiveTask(customerId, plan);
@@ -322,10 +328,44 @@ public class InboundPdaWorkflowService {
return result;
}
@Transactional
public PdaResponses.Operation pickWholePallet(String customerId, String planNo, long allocationId,
PdaRequests.WholePalletOperation request) {
InboundPlan plan = requirePlan(customerId, planNo);
requireDropship(plan);
InboundPdaOperation replay = replay(customerId, "PICK_WHOLE_PALLET", request.idempotencyKey(), request);
if (replay != null) return read(replay, PdaResponses.Operation.class);
InboundPdaTask task = requireActiveTask(customerId, plan);
InboundPlanPalletAllocation allocation = requireAllocation(plan.getId(), allocationId);
InboundPlanLine line = requireLine(plan.getId(), allocation.getLineId());
int planned = value(allocation.getPlannedPackageCount());
if (planned <= 0) throw conflict("One-click dropship pallet has no planned packages");
if (value(allocation.getPickedPackageCount()) != 0) {
throw conflict("One-click dropship pallet has already been partially operated");
}
allocation.setCountedPackageCount(planned);
allocation.setPickedPackageCount(planned);
allocation.setActualVolumeCbm(volume(allocation.getAllocatedVolumeCbm()));
allocation.setStatus("PICKED");
bump(allocation);
repository.update(allocation);
InboundPlanPallet pallet = requirePallet(plan.getId(), allocation.getPalletId());
pallet.setActualPackageCount(planned);
pallet.setActualVolumeCbm(volume(allocation.getAllocatedVolumeCbm()));
repository.update(pallet);
startPicking(task, plan, request.operatorId(), request.operatorName());
PdaResponses.Operation result = operation("WHOLE_PALLET_PICKED", allocation, line,
List.of(), 0, planned, 0);
saveOperation(customerId, task.getId(), allocationId, "PICK_WHOLE_PALLET",
request.idempotencyKey(), request, result);
return result;
}
/** 海外首次扫描箱唛:仅接受本柜国内冻结且已归属本计划的箱。 */
@Transactional
public PdaResponses.Carton scanCarton(String customerId, String planNo, PdaRequests.ScanCarton request) {
InboundPlan plan = requirePlan(customerId, planNo);
requireCartonScanMode(plan);
InboundPdaOperation replay = replay(customerId, "SCAN_CARTON", request.idempotencyKey(), request);
if (replay != null) return read(replay, PdaResponses.Carton.class);
InboundPdaTask task = requireActiveTask(customerId, plan);
@@ -336,16 +376,23 @@ public class InboundPdaWorkflowService {
if (!("UNASSIGNED".equals(carton.getStatus()) || "EXPECTED".equals(carton.getStatus()))) {
throw conflict("Carton mark has already been scanned; remove it before scanning again");
}
InboundPlanPallet pallet = requirePallet(plan.getId(), request.planPalletId());
long requestedPalletId = request.planPalletId() == null ? 0L : request.planPalletId();
InboundPlanPallet pallet = requestedPalletId > 0
? requirePallet(plan.getId(), requestedPalletId)
: autoPallet(plan, carton);
if (isNotAssigned(pallet)) throw conflict("Planned pallet must be assigned and locked before carton scanning");
if (!sameWarehouse(carton, pallet)) throw conflict("Carton and pallet destination warehouses must match");
InboundPlanLine line = carton.getLineId() == null
? repository.lineForCarton(plan.getId(), carton)
: requireLine(plan.getId(), carton.getLineId());
if (line == null) throw notFound("Carton mark does not belong to cargo in this inbound plan");
if (repository.scannedCartons(plan.getId(), line.getId()) >= value(line.getRequiredPackageCount())) {
int requestedCount = request.packageCount() == null ? 1 : request.packageCount();
if (requestedCount != 1) throw conflict("Overseas warehouse carton scanning accepts exactly one carton per scan");
int alreadyScanned = (int) repository.scannedCartons(plan.getId(), line.getId());
if (alreadyScanned + requestedCount > value(line.getRequiredPackageCount())) {
throw conflict("This inbound plan line has reached its required carton count");
}
List<InboundPlanCarton> cartonsToScan = List.of(carton);
InboundPlanPalletAllocation allocation = repository.lockAllocation(plan.getId(), line.getId(), pallet.getId());
if (allocation == null) {
allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId());
@@ -353,23 +400,29 @@ public class InboundPdaWorkflowService {
allocation.setLineId(line.getId()); allocation.setPalletId(pallet.getId()); allocation.setAllocatedVolumeCbm(ZERO_VOLUME);
allocation.setPlannedPackageCount(0); allocation.setCountedPackageCount(0); allocation.setPickedPackageCount(0);
allocation.setActualVolumeCbm(ZERO_VOLUME); allocation.setStatus("PICKING"); allocation.setVersion(0); repository.insert(allocation);
} else if (value(allocation.getPlannedPackageCount()) > 0
&& value(allocation.getPickedPackageCount()) + requestedCount > value(allocation.getPlannedPackageCount())) {
throw conflict("Requested carton quantity exceeds the recommended pallet package count");
}
BigDecimal perPackage = perPackageVolume(line);
carton.setOrderId(line.getOrderId()); carton.setPlanId(plan.getId()); carton.setLineId(line.getId());
carton.setPlanPalletId(pallet.getId()); carton.setWarehousePalletId(pallet.getWarehousePalletId());
carton.setWarehouseLocationId(pallet.getWarehouseLocationId()); carton.setPdaTaskId(task.getId());
carton.setStatus("ON_PALLET"); carton.setBoundAt(LocalDateTime.now()); carton.setUpdatedAt(LocalDateTime.now());
carton.setVersion(value(carton.getVersion()) + 1); repository.update(carton);
allocation.setCountedPackageCount(value(allocation.getCountedPackageCount()) + 1);
allocation.setPickedPackageCount(value(allocation.getPickedPackageCount()) + 1);
allocation.setActualVolumeCbm(volume(allocation.getActualVolumeCbm()).add(perPackage).setScale(4, RoundingMode.HALF_UP));
LocalDateTime now = LocalDateTime.now();
for (InboundPlanCarton item : cartonsToScan) {
item.setOrderId(line.getOrderId()); item.setPlanId(plan.getId()); item.setLineId(line.getId());
item.setPlanPalletId(pallet.getId()); item.setWarehousePalletId(pallet.getWarehousePalletId());
item.setWarehouseLocationId(pallet.getWarehouseLocationId()); item.setPdaTaskId(task.getId());
item.setStatus("ON_PALLET"); item.setBoundAt(now); item.setUpdatedAt(now);
item.setVersion(value(item.getVersion()) + 1); repository.update(item);
}
allocation.setCountedPackageCount(value(allocation.getCountedPackageCount()) + requestedCount);
allocation.setPickedPackageCount(value(allocation.getPickedPackageCount()) + requestedCount);
allocation.setActualVolumeCbm(volume(allocation.getActualVolumeCbm()).add(perPackage.multiply(BigDecimal.valueOf(requestedCount))).setScale(4, RoundingMode.HALF_UP));
allocation.setStatus(value(allocation.getPickedPackageCount()) >= value(allocation.getPlannedPackageCount()) ? "PICKED" : "PICKING");
bump(allocation); repository.update(allocation);
pallet.setActualPackageCount(value(pallet.getActualPackageCount()) + 1);
pallet.setActualVolumeCbm(volume(pallet.getActualVolumeCbm()).add(perPackage).setScale(4, RoundingMode.HALF_UP));
pallet.setActualPackageCount(value(pallet.getActualPackageCount()) + requestedCount);
pallet.setActualVolumeCbm(volume(pallet.getActualVolumeCbm()).add(perPackage.multiply(BigDecimal.valueOf(requestedCount))).setScale(4, RoundingMode.HALF_UP));
repository.update(pallet); startPicking(task, plan, request.operatorId(), request.operatorName());
PdaResponses.Carton result = new PdaResponses.Carton("SCANNED", carton.getCartonMark(), pallet.getId(),
allocation.getId(), pallet.getActualPackageCount());
allocation.getId(), pallet.getActualPackageCount(), pallet.getPalletNo(), requestedCount);
saveOperation(customerId, task.getId(), allocation.getId(), "SCAN_CARTON", request.idempotencyKey(), request, result);
return result;
}
@@ -377,6 +430,7 @@ public class InboundPdaWorkflowService {
@Transactional
public PdaResponses.Carton removeCarton(String customerId, String planNo, PdaRequests.RemoveCarton request) {
InboundPlan plan = requirePlan(customerId, planNo);
requireCartonScanMode(plan);
InboundPdaOperation replay = replay(customerId, "REMOVE_CARTON", request.idempotencyKey(), request);
if (replay != null) return read(replay, PdaResponses.Carton.class);
InboundPdaTask task = requireActiveTask(customerId, plan);
@@ -542,6 +596,7 @@ public class InboundPdaWorkflowService {
if ("COMPLETED".equals(task.getStatus())) {
return new CompletionContext(plan, task, List.of(), List.of(), List.of(), List.of());
}
if ("PARTIAL_INBOUND".equals(task.getStatus())) throw conflict("PDA inbound task was partially completed");
List<Long> unloadPhotoFileIds = unloadPhotoFileIds(task);
if (unloadPhotoFileIds.isEmpty() || task.getUnloadPhotoUploadedAt() == null
|| task.getEstimatedEmptyReturnAt() == null) {
@@ -589,6 +644,41 @@ public class InboundPdaWorkflowService {
repository.update(plan);
}
/** Explicit operator decision to receive only the cartons actually picked. */
@Transactional
public PdaResponses.Task partialComplete(String customerId, String planNo, PdaRequests.ForceComplete request) {
InboundPlan plan = requirePlan(customerId, planNo);
InboundPdaOperation replay = replay(customerId, "PARTIAL_COMPLETE", request.idempotencyKey(), request);
if (replay != null) return read(replay, PdaResponses.Task.class);
InboundPdaTask task = requireActiveTask(customerId, plan);
List<Long> unloadPhotoFileIds = unloadPhotoFileIds(task);
if (unloadPhotoFileIds.isEmpty() || task.getUnloadPhotoUploadedAt() == null
|| task.getEstimatedEmptyReturnAt() == null) {
throw conflict("Upload unload photos before partially completing the PDA inbound task");
}
List<InboundPlanLine> lines = repository.lines(plan.getId());
List<InboundPlanPalletAllocation> allocations = repository.allocations(plan.getId());
int expected = lines.stream().mapToInt(line -> value(line.getRequiredPackageCount())).sum();
int picked = allocations.stream().mapToInt(allocation -> value(allocation.getPickedPackageCount())).sum();
if (picked <= 0) throw conflict("At least one carton must be picked before partial inbound completion");
if (picked >= expected) throw conflict("All cartons are picked; use normal inbound completion");
LocalDateTime now = LocalDateTime.now();
stockReceipts.stockIn(customerId, plan, task, lines, allocations, repository.pallets(plan.getId()), now, true);
for (InboundPlanCarton carton : repository.cartons(plan.getId())) {
if ("ON_PALLET".equals(carton.getStatus())) {
carton.setStatus("AVAILABLE"); carton.setReceivedAt(now); carton.setUpdatedAt(now);
carton.setVersion(value(carton.getVersion()) + 1); repository.update(carton);
}
}
task.setStatus("PARTIAL_INBOUND"); task.setCompletedAt(now); task.setUpdatedAt(now);
task.setVersion(value(task.getVersion()) + 1); repository.update(task);
plan.setStatus("PARTIAL_INBOUND"); plan.setUpdatedAt(now); repository.update(plan);
unloadCompletedOutbox.publish(plan, task, unloadPhotoFileIds);
PdaResponses.Task result = taskResponse(task, plan, planNo);
saveOperation(customerId, task.getId(), null, "PARTIAL_COMPLETE", request.idempotencyKey(), request, result);
return result;
}
@Transactional
public PdaResponses.Task uploadUnloadPhotos(String customerId, String planNo, PdaRequests.UnloadPhotos request) {
InboundPlan plan = requirePlan(customerId, planNo);
@@ -605,7 +695,7 @@ public class InboundPdaWorkflowService {
task.setUpdatedAt(uploadedAt);
task.setVersion(value(task.getVersion()) + 1);
repository.update(task);
PdaResponses.Task result = task(task, planNo);
PdaResponses.Task result = taskResponse(task, plan, planNo);
saveOperation(customerId, task.getId(), null, "UPLOAD_UNLOAD_PHOTOS", request.idempotencyKey(), request, result);
return result;
}
@@ -749,12 +839,34 @@ public class InboundPdaWorkflowService {
repository.update(plan);
}
private void requireCountMode(InboundPlan plan) {
InboundMode mode = mode(plan);
if (mode == InboundMode.DROPSHIP) {
throw conflict("One-click dropship inbound must be picked by complete source pallet");
}
if (mode == InboundMode.OWN_OVERSEAS) {
throw conflict("Overseas warehouse inbound must be operated by one-carton scanning");
}
}
/** Backward-compatible guard for pallet merge, which remains a legacy quantity workflow. */
private void requireLegacyCounting(InboundPlan plan) {
if (isDropship(plan)) {
throw conflict("One-click dropship inbound must be picked by complete source pallet");
}
if (repository.hasCartonLabelPool(plan.getId())) {
throw conflict("This inbound plan has a carton manifest and must be operated by carton scanning");
}
}
private void requireCartonScanMode(InboundPlan plan) {
if (mode(plan) != InboundMode.OWN_OVERSEAS) {
throw conflict(mode(plan) == InboundMode.FBA
? "FBA inbound must be operated by package quantity, not carton scanning"
: "One-click dropship inbound must be operated by complete source pallet");
}
}
private boolean isNotAssigned(InboundPlanPallet pallet) {
return pallet.getWarehousePalletId() == null || pallet.getWarehouseLocationId() == null
|| pallet.getWarehouseResourceLockId() == null
@@ -770,7 +882,7 @@ public class InboundPdaWorkflowService {
private InboundPdaTask requireActiveTask(String customerId, InboundPlan plan) {
InboundPdaTask task = repository.lockTask(customerId, plan.getId());
if (task == null) throw conflict("Push the inbound plan to PDA before operating");
if ("COMPLETED".equals(task.getStatus())) throw conflict("PDA task is already completed");
if (terminal(task.getStatus())) throw conflict("PDA task is already completed");
return task;
}
@@ -786,6 +898,24 @@ public class InboundPdaWorkflowService {
return value;
}
private InboundPlanPallet autoPallet(InboundPlan plan, InboundPlanCarton carton) {
List<InboundPlanPallet> candidates = repository.pallets(plan.getId()).stream()
.filter(pallet -> sameWarehouse(carton, pallet))
.filter(pallet -> !isNotAssigned(pallet))
.sorted(Comparator.comparingInt(pallet -> value(pallet.getSequenceNo())))
.toList();
if (candidates.isEmpty()) throw conflict("No assigned pallet exists for the carton destination warehouse");
Map<Long, Integer> plannedByPallet = new HashMap<>();
for (InboundPlanPalletAllocation allocation : repository.allocations(plan.getId())) {
plannedByPallet.merge(allocation.getPalletId(), value(allocation.getPlannedPackageCount()), Integer::sum);
}
InboundPlanPallet selected = candidates.stream()
.filter(pallet -> value(pallet.getActualPackageCount()) < plannedByPallet.getOrDefault(pallet.getId(), Integer.MAX_VALUE))
.findFirst().orElse(null);
if (selected == null) throw conflict("All assigned pallets for the carton destination warehouse are full");
return repository.lockPallet(plan.getId(), selected.getId());
}
private InboundPlanLine requireLine(Long planId, Long id) {
InboundPlanLine value = repository.line(planId, id);
if (value == null) throw notFound("Inbound plan line not found");
@@ -801,6 +931,30 @@ public class InboundPdaWorkflowService {
volume(allocation.getActualVolumeCbm()), remaining, requested, merge, candidates);
}
private PdaResponses.Task taskResponse(InboundPdaTask task, InboundPlan plan, String planNo) {
int expected = repository.lines(plan.getId()).stream().mapToInt(line -> value(line.getRequiredPackageCount())).sum();
List<InboundPlanPalletAllocation> allocations = repository.allocations(plan.getId());
int picked = allocations.stream().mapToInt(allocation -> value(allocation.getPickedPackageCount())).sum();
LastlegOrder order = repository.order(plan.getSourceOrderId());
String orderType = order == null ? null : order.getOrderType();
boolean wholePalletInbound = "ONE_CLICK_DROPSHIP".equals(orderType);
Map<String, InboundPlanPallet> palletsByNo = wholePalletInbound
? repository.pallets(plan.getId()).stream().collect(java.util.stream.Collectors.toMap(
InboundPlanPallet::getPalletNo, value -> value)) : Map.of();
Map<Long, InboundPlanPalletAllocation> allocationsByPallet = wholePalletInbound
? allocations.stream().collect(java.util.stream.Collectors.toMap(
InboundPlanPalletAllocation::getPalletId, value -> value)) : Map.of();
List<PdaResponses.DropshipPallet> dropshipPallets = wholePalletInbound
? dropshipOrders.palletSnapshots(task.getCustomerId(), order.getId()).stream()
.map(pallet -> dropshipPallet(pallet, palletsByNo, allocationsByPallet)).toList() : List.of();
PdaResponses.Task workInstructions = task(task, planNo);
return new PdaResponses.Task(task.getId(), task.getTaskNo(), planNo, task.getStatus(),
task.getPushedAt(), task.getStartedAt(), task.getCompletedAt(), unloadPhotoFileIds(task),
task.getUnloadPhotoUploadedAt(), task.getEstimatedEmptyReturnAt(), expected, picked,
orderType, wholePalletInbound, dropshipPallets, mode(plan).name(),
workInstructions.pallets(), workInstructions.allocations(), workInstructions.expectedCartons());
}
private PdaResponses.Task task(InboundPdaTask task, String planNo) {
List<InboundPlanPallet> planPallets = repository.pallets(task.getPlanId());
List<InboundPlanPalletAllocation> planAllocations = repository.allocations(task.getPlanId());
@@ -830,8 +984,10 @@ public class InboundPdaWorkflowService {
.toList();
return new PdaResponses.Task(task.getId(), task.getTaskNo(), planNo, task.getStatus(),
task.getPushedAt(), task.getStartedAt(), task.getCompletedAt(), unloadPhotoFileIds(task),
task.getUnloadPhotoUploadedAt(), task.getEstimatedEmptyReturnAt(), pallets, allocations,
expectedCartons);
task.getUnloadPhotoUploadedAt(), task.getEstimatedEmptyReturnAt(),
lines.values().stream().mapToInt(line -> value(line.getRequiredPackageCount())).sum(),
planAllocations.stream().mapToInt(value -> InboundPdaWorkflowService.value(value.getPickedPackageCount())).sum(),
null, false, List.of(), null, pallets, allocations, expectedCartons);
}
private long recommendedPallet(InboundPlanCarton carton, List<InboundPlanPallet> pallets) {
@@ -842,6 +998,39 @@ public class InboundPdaWorkflowService {
.map(InboundPlanPallet::getId).findFirst().orElse(0L);
}
private PdaResponses.DropshipPallet dropshipPallet(DropshipPalletSnapshot pallet,
Map<String, InboundPlanPallet> palletsByNo,
Map<Long, InboundPlanPalletAllocation> allocationsByPallet) {
InboundPlanPallet planPallet = palletsByNo.get(pallet.sourcePalletNo());
if (planPallet == null) throw conflict("Dropship source pallet is missing from the inbound plan");
InboundPlanPalletAllocation allocation = allocationsByPallet.get(planPallet.getId());
if (allocation == null) throw conflict("Dropship source pallet has no inbound allocation");
return new PdaResponses.DropshipPallet(pallet.sourcePalletNo(), planPallet.getId(), allocation.getId(),
pallet.packageCount(), pallet.skus().stream()
.map(sku -> new PdaResponses.DropshipSku(sku.skuCode(), sku.productName(),
sku.packageCount(), sku.productQuantity())).toList());
}
private void requireDropship(InboundPlan plan) {
if (!isDropship(plan)) {
throw conflict("Whole-pallet picking is only available for one-click dropship inbound plans");
}
}
private boolean isDropship(InboundPlan plan) {
LastlegOrder order = repository.order(plan.getSourceOrderId());
return order != null && "ONE_CLICK_DROPSHIP".equals(order.getOrderType());
}
private InboundMode mode(InboundPlan plan) {
if (isDropship(plan)) return InboundMode.DROPSHIP;
List<InboundPlanLine> lines = repository.lines(plan.getId());
return lines.stream().anyMatch(line -> line.getFbaWarehouseCode() != null
&& !line.getFbaWarehouseCode().isBlank()) ? InboundMode.FBA : InboundMode.OWN_OVERSEAS;
}
private enum InboundMode { DROPSHIP, OWN_OVERSEAS, FBA }
private List<Long> unloadPhotoFileIds(InboundPdaTask task) {
if (task.getUnloadPhotoFileIds() == null || task.getUnloadPhotoFileIds().isBlank()) return List.of();
try {
@@ -945,6 +1134,7 @@ public class InboundPdaWorkflowService {
List<InboundPlanCarton> cartons) { }
private static int value(Integer value) { return value == null ? 0 : value; }
private static boolean terminal(String status) { return "COMPLETED".equals(status) || "PARTIAL_INBOUND".equals(status); }
private static BigDecimal volume(BigDecimal value) { return value == null ? ZERO_VOLUME : value; }
private static BigDecimal nonNegative(BigDecimal value) { return value.signum() < 0 ? ZERO_VOLUME : value.setScale(4, RoundingMode.HALF_UP); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
@@ -20,6 +20,7 @@ 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;
import com.baiyue.lastleg.dropship.model.DropshipPalletSnapshot;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
@@ -49,6 +50,13 @@ public class InboundStockReceiptService {
public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task,
List<InboundPlanLine> lines, List<InboundPlanPalletAllocation> allocations,
List<InboundPlanPallet> pallets, LocalDateTime receivedAt) {
stockIn(customerId, plan, task, lines, allocations, pallets, receivedAt, false);
}
@Transactional
public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task,
List<InboundPlanLine> lines, List<InboundPlanPalletAllocation> allocations,
List<InboundPlanPallet> pallets, LocalDateTime receivedAt, boolean allowPartial) {
Map<Long, LastlegOrder> orderById = orders.selectBatchIds(lines.stream()
.map(InboundPlanLine::getOrderId).collect(Collectors.toSet())).stream()
.filter(order -> customerId.equals(order.getCustomerId()))
@@ -70,8 +78,8 @@ public class InboundStockReceiptService {
Set<Long> receivedAllocationIds = existing.stream().map(InboundStockReceipt::getAllocationId)
.filter(Objects::nonNull).collect(Collectors.toSet());
if (receivedAllocationIds.equals(expectedAllocationIds)) {
activateDropship(customerId, task.getId(), orderById, existing);
completeOrders(customerId, plan, orderById, receivedAt);
if (!allowPartial) activateDropship(customerId, task.getId(), orderById, existing);
completeOrders(customerId, plan, orderById, receivedAt, allowPartial);
return;
}
throw conflict("PDA stock-in receipts are incomplete; repair the task receipts before retrying");
@@ -85,7 +93,9 @@ public class InboundStockReceiptService {
.getOrDefault(line.getId(), List.of());
long pickedPackages = lineAllocations.stream()
.mapToLong(allocation -> value(allocation.getPickedPackageCount())).sum();
if (pickedPackages != value(line.getRequiredPackageCount())) {
if (pickedPackages == 0 && allowPartial) continue;
if ((!allowPartial && pickedPackages != value(line.getRequiredPackageCount()))
|| (allowPartial && pickedPackages > value(line.getRequiredPackageCount()))) {
throw conflict("PDA picked packages must equal the inbound order package total");
}
List<ReceivingSkuReceipt> exactSkuReceipts = skuReceiptsByLine.getOrDefault(line.getId(), List.of());
@@ -95,12 +105,25 @@ public class InboundStockReceiptService {
continue;
}
List<SkuRemainder> skuRemainders = "ONE_CLICK_DROPSHIP".equals(order.getOrderType())
? List.of() : skuRemainders(order.getId(), pickedPackages);
? List.of() : skuRemainders(order.getId(), value(line.getRequiredPackageCount()));
Map<String, DropshipPalletSnapshot> dropshipPallets = "ONE_CLICK_DROPSHIP".equals(order.getOrderType())
? dropshipOrders.palletSnapshots(customerId, order.getId()).stream()
.collect(Collectors.toMap(DropshipPalletSnapshot::sourcePalletNo, value -> value))
: Map.of();
for (InboundPlanPalletAllocation allocation : lineAllocations) {
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));
if (value(allocation.getPickedPackageCount()) != value(allocation.getPlannedPackageCount())) {
throw conflict("One-click dropship inbound must receive a complete source pallet");
}
DropshipPalletSnapshot sourcePallet = dropshipPallets.get(pallet.getPalletNo());
if (sourcePallet == null || sourcePallet.packageCount() != value(allocation.getPickedPackageCount())) {
throw conflict("One-click dropship source pallet SKU package total does not match PDA receipt");
}
for (DropshipPalletSnapshot.Sku sku : sourcePallet.skus()) {
created.add(persist(customerId, plan, task, line, allocation, pallet, order,
new SkuSnapshot(sku.skuCode(), sku.productName(), null, sku.packageCount()), receivedAt));
}
} else {
long allocationPackages = value(allocation.getPickedPackageCount());
for (SkuSnapshot sku : consume(skuRemainders, allocationPackages)) {
@@ -108,12 +131,12 @@ public class InboundStockReceiptService {
}
}
}
if (skuRemainders.stream().anyMatch(value -> value.remaining > 0)) {
if (!allowPartial && skuRemainders.stream().anyMatch(value -> value.remaining > 0)) {
throw conflict("Inbound SKU package snapshots were not fully received");
}
}
activateDropship(customerId, task.getId(), orderById, created);
completeOrders(customerId, plan, orderById, receivedAt);
if (!allowPartial) activateDropship(customerId, task.getId(), orderById, created);
completeOrders(customerId, plan, orderById, receivedAt, allowPartial);
}
private List<InboundStockReceipt> persistExactSkuReceipts(String customerId, InboundPlan plan,
@@ -159,14 +182,16 @@ public class InboundStockReceiptService {
}
private void completeOrders(String customerId, InboundPlan plan, Map<Long, LastlegOrder> orderById,
LocalDateTime completedAt) {
LocalDateTime completedAt, boolean partial) {
String subOrderStatus = partial ? "INBOUND_PLAN:PARTIAL_INBOUND" : "INBOUND_PLAN:COMPLETED";
for (LastlegOrder snapshot : orderById.values()) {
LastlegOrder order = orders.lockById(customerId, snapshot.getId());
if (order == null) throw conflict("Inbound tail-leg order disappeared while completing stock-in");
if ("INBOUND_COMPLETED".equals(order.getCurrentStatus())) continue;
if ("INBOUND_COMPLETED".equals(order.getCurrentStatus())
&& subOrderStatus.equals(order.getSubOrderStatus())) continue;
String before = order.getCurrentStatus();
order.setCurrentStatus("INBOUND_COMPLETED");
order.setSubOrderStatus("INBOUND_PLAN:COMPLETED");
order.setSubOrderStatus(subOrderStatus);
order.setUpdatedAt(completedAt);
orders.updateById(order);
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory();
@@ -174,8 +199,11 @@ public class InboundStockReceiptService {
history.setOrderId(order.getId());
history.setFromStatus(before);
history.setToStatus("INBOUND_COMPLETED");
history.setEventId("PDA_INBOUND_COMPLETE:" + plan.getPlanNo());
history.setRemark("PDA inbound completed and inventory stocked in");
history.setEventId((partial ? "PDA_PARTIAL_INBOUND_COMPLETE:" : "PDA_INBOUND_COMPLETE:")
+ plan.getPlanNo());
history.setRemark(partial
? "PDA inbound partially completed and actual inventory stocked in"
: "PDA inbound completed and inventory stocked in");
history.setCreatedAt(completedAt);
orderHistories.insert(history);
}
@@ -207,15 +235,17 @@ public class InboundStockReceiptService {
if (overseasWarehouseId.equals(trim(order.getSourceWarehouseId()))) {
throw conflict("Actual overseas warehouse must not use the domestic source warehouse");
}
InventoryCommandResponse result = inventory.stockIn(customerId, task.getOperatorId(),
new InventoryCommandRequests.StockIn(null, customerName, overseasWarehouseId,
null, overseasWarehouseName, line.getDestinationWarehouseId(), destinationCode,
InventoryCommandRequests.StockIn stockIn = new InventoryCommandRequests.StockIn(null, customerName,
overseasWarehouseId, null, overseasWarehouseName, 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,
"PDA inbound completion for " + line.getInboundCode()));
"PDA inbound completion for " + line.getInboundCode());
InventoryCommandResponse result = Boolean.TRUE.equals(order.getDetained())
? inventory.stockInHeld(customerId, task.getOperatorId(), stockIn)
: inventory.stockIn(customerId, task.getOperatorId(), stockIn);
InboundStockReceipt receipt = new InboundStockReceipt();
receipt.setId(IdWorker.getId());
@@ -334,9 +364,5 @@ public class InboundStockReceiptService {
}
}
private record SkuSnapshot(String skuCode, String productNameZh, String productNameEn, long packages) {
private static SkuSnapshot unspecified(long packages) {
return new SkuSnapshot("UNSPECIFIED", null, null, packages);
}
}
private record SkuSnapshot(String skuCode, String productNameZh, String productNameEn, long packages) {}
}
@@ -15,7 +15,7 @@ import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.UUID;
/** 至少一次投递海外卸柜完成事件;国内事件桥按 eventId 幂等接收。 */
/** 至少一次投递海外到国内业务事件;国内事件桥按 eventId 幂等接收。 */
@Component
@Slf4j
public class InboundUnloadCompletedOutboxRelay {
@@ -44,7 +44,7 @@ public class InboundUnloadCompletedOutboxRelay {
} 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());
log.warn("海外到国内事件投递失败: eventKey={}, reason={}", row.getEventKey(), exception.getMessage());
}
}
}
@@ -53,7 +53,7 @@ public class InboundUnloadCompletedOutboxRelay {
com.baiyue.common.core.api.ApiResponse<EventBridgeClient.Acknowledgement> response) {
if (response == null) throw new IllegalStateException("国内事件桥返回空响应");
if (!response.successful()) {
throw new IllegalStateException("国内事件桥拒绝海外卸柜完成事件: code=" + response.code()
throw new IllegalStateException("国内事件桥拒绝海外事件: code=" + response.code()
+ ", message=" + response.message() + ", requestId=" + response.requestId());
}
if (response.data() == null) throw new IllegalStateException("国内事件桥成功响应缺少确认数据");
@@ -61,6 +61,7 @@ import java.util.stream.Collectors;
public class ReceivingContainerService {
private static final String PHOTO_DOWNLOAD_PATH =
"/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/{fileId}/download";
private static final String PALLET_MERGE_HINT = "该入仓单已标记合托,请按合托要求拣货";
@Resource private InboundPlanMapper plans;
@Resource private InboundPlanLineMapper lines;
@@ -258,7 +259,8 @@ public class ReceivingContainerService {
inbound.inboundCode(), inbound.destinationType(), inbound.overseasWarehouseId(),
inbound.overseasWarehouseName(), inbound.destinationWarehouseId(),
inbound.destinationWarehouseCode(), inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(),
inbound.plannedPalletCount(), inbound.totalPackages(), inbound.receivedPackages(), inbound.pendingPackages());
inbound.plannedPalletCount(), inbound.totalPackages(), inbound.receivedPackages(), inbound.pendingPackages(),
inbound.palletMergeRequired(), inbound.palletMergeHint());
}
public ReceivingContainerResponses.PalletResolved resolvePallet(String operatorKey,
@@ -309,7 +311,8 @@ public class ReceivingContainerService {
int total = fbaPlanRefs.stream().mapToInt(refValue -> value(refValue.line().getRequiredPackageCount())).sum();
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED",
session.getContainerNo(), ref.line().getInboundCode(), "FBA", resource.palletNo(), accepted,
received, Math.max(0, total - received), session.getStatus());
received, Math.max(0, total - received), session.getStatus(), palletMergeRequired(ref.order()),
palletMergeHint(ref.order()));
save(session, "BULK_RECEIVE", request.idempotencyKey(), request, result);
return result;
} catch (RuntimeException failure) {
@@ -347,7 +350,8 @@ public class ReceivingContainerService {
ReceivingContainerResponses.Inbound inbound = inbound(ref);
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED",
session.getContainerNo(), inbound.inboundCode(), inbound.destinationType(), resource.palletNo(), 1,
inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus());
inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus(),
inbound.palletMergeRequired(), inbound.palletMergeHint());
save(session, "CARTON_RECEIVE", request.idempotencyKey(), request, result);
return result;
} catch (RuntimeException failure) {
@@ -666,7 +670,17 @@ public class ReceivingContainerService {
: String.valueOf(ref.plan().getOverseasWarehouseId()), ref.order().getOverseasWarehouseName(),
ref.line().getDestinationWarehouseId(), ref.line().getDestinationWarehouseCode(),
ref.line().getDestinationWarehouseName(), ref.line().getFbaWarehouseCode(),
value(ref.line().getPlannedPalletCount()), total, received, Math.max(0, total - received));
value(ref.line().getPlannedPalletCount()), total, received, Math.max(0, total - received),
palletMergeRequired(ref.order()), palletMergeHint(ref.order()));
}
private boolean palletMergeRequired(LastlegOrder order) {
return order != null && ("REQUIRED".equals(order.getPalletMergeFlag())
|| "MERGED".equals(order.getPalletMergeFlag()));
}
private String palletMergeHint(LastlegOrder order) {
return palletMergeRequired(order) ? PALLET_MERGE_HINT : null;
}
private Totals totals(List<InboundPlan> containerPlans) {
@@ -7,6 +7,7 @@ import com.baiyue.lastleg.inboundplan.dto.InboundPlanListResponse;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse;
import com.baiyue.lastleg.inboundplan.dto.SupplementPalletRequest;
import com.baiyue.lastleg.inboundplan.dto.UpdateInboundPlanRequest;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
@@ -33,10 +34,12 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.stream.Collectors;
@Service
public class InboundPlanService {
@Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator;
@Resource private InboundPlanMapper planMapper;
@Resource private InboundPlanLineMapper lineMapper;
@Resource private InboundPlanPalletMapper palletMapper;
@@ -61,6 +64,37 @@ public class InboundPlanService {
@Transactional
public InboundPlanResponse convert(String customerId, Long selectedOrderId) {
return convert(customerId, selectedOrderId, List.of());
}
@Transactional
public InboundPlanResponse convert(String customerId, Long selectedOrderId, List<String> sourcePalletNos) {
return convert(customerId, selectedOrderId, sourcePalletNos, null);
}
@Transactional
public InboundPlanResponse convertDropship(String customerId, Long selectedOrderId,
List<WholePalletInput> sourcePallets) {
if (sourcePallets == null || sourcePallets.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"One-click dropship inbound requires source pallets");
}
HashSet<String> palletNos = new HashSet<>();
for (WholePalletInput pallet : sourcePallets) {
if (pallet == null || trimToNull(pallet.palletNo()) == null || pallet.packageCount() <= 0) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"One-click dropship source pallet number and package count are required");
}
if (!palletNos.add(pallet.palletNo().trim())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Duplicate one-click dropship source pallet number: " + pallet.palletNo());
}
}
return convert(customerId, selectedOrderId, List.of(), List.copyOf(sourcePallets));
}
private InboundPlanResponse convert(String customerId, Long selectedOrderId, List<String> sourcePalletNos,
List<WholePalletInput> wholePallets) {
if (customerId == null || customerId.isBlank() || "0".equals(customerId.trim())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "平台生成入库计划必须指定客户");
}
@@ -75,16 +109,17 @@ public class InboundPlanService {
return detail(customerId, existing);
}
validateOrders(orders);
return createPlan(customerId, selectedOrderId, orders);
return createPlan(customerId, selectedOrderId, orders, sourcePalletNos, wholePallets);
}
private InboundPlanResponse createPlan(String customerId, Long selectedOrderId,
List<SourceOrderSnapshot> orders) {
List<SourceOrderSnapshot> orders, List<String> sourcePalletNos,
List<WholePalletInput> wholePallets) {
String containerNo = requiredContainerNo(orders.get(0).containerNo());
LocalDateTime now = LocalDateTime.now();
InboundPlan plan = new InboundPlan();
plan.setId(IdWorker.getId());
plan.setPlanNo("LIP" + plan.getId());
plan.setPlanNo(orderNoGenerator.next("INBOUND_PLAN", orders.get(0).destinationWarehouseCode(), plan.getId()));
plan.setCustomerId(customerId);
plan.setSourceOrderId(selectedOrderId);
plan.setContainerNo(containerNo);
@@ -98,20 +133,27 @@ public class InboundPlanService {
Map<Long, SourceOrderSnapshot> orderById = orders.stream()
.collect(Collectors.toMap(SourceOrderSnapshot::orderId, value -> value));
Map<Long, InboundPlanLine> lineByOrder = createLines(plan, orders);
PalletAllocator.Result allocation = PalletAllocator.allocate(orders.stream()
PalletAllocator.Result allocation = wholePallets == null ? PalletAllocator.allocate(orders.stream()
.map(order -> new PalletAllocator.LineInput(order.orderId(), warehouseKey(order), volume(order),
order.packageCount()))
.toList());
plan.setPalletCount(allocation.pallets().size());
order.packageCount(), order.recommendedPalletCount())).toList()) : null;
if (wholePallets != null) validateWholePallets(orders, wholePallets);
plan.setPalletCount(wholePallets == null ? allocation.pallets().size() : wholePallets.size());
planMapper.insert(plan);
lineByOrder.values().forEach(lineMapper::insert);
Map<PalletAllocator.PalletDraft, InboundPlanPallet> persistedPallets = createPallets(plan, allocation, orderById);
createAllocations(plan, allocation, lineByOrder, persistedPallets);
Map<Long, Integer> palletCounts = allocation.allocations().stream().collect(Collectors.groupingBy(
PalletAllocator.AllocationDraft::orderId,
Collectors.collectingAndThen(Collectors.mapping(item -> item.pallet().sequenceNo(), Collectors.toSet()), java.util.Set::size)));
Map<Long, Integer> palletCounts;
if (wholePallets == null) {
Map<PalletAllocator.PalletDraft, InboundPlanPallet> persistedPallets = createPallets(plan, allocation,
orderById, sourcePalletNos == null ? List.of() : sourcePalletNos);
createAllocations(plan, allocation, lineByOrder, persistedPallets);
palletCounts = allocation.allocations().stream().collect(Collectors.groupingBy(
PalletAllocator.AllocationDraft::orderId, Collectors.collectingAndThen(Collectors.mapping(
item -> item.pallet().sequenceNo(), Collectors.toSet()), java.util.Set::size)));
} else {
SourceOrderSnapshot order = orders.get(0);
createWholePallets(plan, order, lineByOrder.get(order.orderId()), wholePallets);
palletCounts = Map.of(order.orderId(), wholePallets.size());
}
for (SourceOrderSnapshot order : orders) {
int count = palletCounts.getOrDefault(order.orderId(), 0);
InboundPlanLine line = lineByOrder.get(order.orderId());
@@ -180,7 +222,7 @@ public class InboundPlanService {
List<InboundPlanResponse> created = new ArrayList<>(refs.size());
for (int index = 0; index < refs.size(); index++) {
InboundPlanOrderMapper.ContainerOrderRef ref = refs.get(index);
created.add(createPlan(ref.customerId(), ref.orderId(), snapshots.get(index)));
created.add(createPlan(ref.customerId(), ref.orderId(), snapshots.get(index), List.of(), null));
}
return List.copyOf(created);
}
@@ -201,6 +243,53 @@ public class InboundPlanService {
}
}
private void validateWholePallets(List<SourceOrderSnapshot> orders, List<WholePalletInput> pallets) {
if (orders.size() != 1) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"One-click dropship inbound plan must belong to one inbound order");
}
int packages = pallets.stream().mapToInt(WholePalletInput::packageCount).sum();
if (packages != orders.get(0).packageCount()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"One-click dropship pallet package total must equal inbound order package count");
}
}
private void createWholePallets(InboundPlan plan, SourceOrderSnapshot order, InboundPlanLine line,
List<WholePalletInput> sourcePallets) {
BigDecimal remainingVolume = volume(order);
int remainingPackages = order.packageCount();
for (int index = 0; index < sourcePallets.size(); index++) {
WholePalletInput source = sourcePallets.get(index);
BigDecimal palletVolume = index == sourcePallets.size() - 1 ? remainingVolume
: volume(order).multiply(BigDecimal.valueOf(source.packageCount()))
.divide(BigDecimal.valueOf(order.packageCount()), 4, java.math.RoundingMode.HALF_UP);
if (palletVolume.compareTo(remainingVolume) > 0) palletVolume = remainingVolume;
InboundPlanPallet pallet = new InboundPlanPallet();
pallet.setId(IdWorker.getId()); pallet.setCustomerId(plan.getCustomerId()); pallet.setPlanId(plan.getId());
pallet.setPalletNo(source.palletNo().trim()); pallet.setDestinationWarehouseId(order.destinationWarehouseId());
pallet.setDestinationWarehouseCode(order.destinationWarehouseCode());
pallet.setDestinationWarehouseName(order.destinationWarehouseName());
pallet.setCapacityCbm(PalletAllocator.PALLET_CAPACITY_CBM); pallet.setAllocatedVolumeCbm(palletVolume);
pallet.setSequenceNo(index + 1); pallet.setAssignmentStatus("UNASSIGNED");
pallet.setActualVolumeCbm(BigDecimal.ZERO); pallet.setActualPackageCount(0); palletMapper.insert(pallet);
InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation();
allocation.setId(IdWorker.getId()); allocation.setCustomerId(plan.getCustomerId()); allocation.setPlanId(plan.getId());
allocation.setLineId(line.getId()); allocation.setPalletId(pallet.getId());
allocation.setAllocatedVolumeCbm(palletVolume); allocation.setPlannedPackageCount(source.packageCount());
allocation.setCountedPackageCount(0); allocation.setPickedPackageCount(0);
allocation.setActualVolumeCbm(BigDecimal.ZERO); allocation.setStatus("PENDING_COUNT"); allocation.setVersion(0);
allocationMapper.insert(allocation);
remainingVolume = remainingVolume.subtract(palletVolume);
remainingPackages -= source.packageCount();
}
if (remainingPackages != 0 || remainingVolume.signum() != 0) {
throw new IllegalStateException("Failed to conserve one-click dropship pallet totals");
}
}
public record WholePalletInput(String palletNo, int packageCount) {}
@Transactional(readOnly = true)
public InboundPlanResponse detailByPlanNo(String customerId, String planNo) {
@@ -214,6 +303,44 @@ public class InboundPlanService {
return detail(customerId, plan);
}
@Transactional
public InboundPlanResponse update(String customerId, String planNo, UpdateInboundPlanRequest request) {
InboundPlan plan = planMapper.selectOne(new LambdaQueryWrapper<InboundPlan>()
.eq(InboundPlan::getPlanNo, planNo).eq(customerId != null, InboundPlan::getCustomerId, customerId).last("FOR UPDATE"));
if (plan == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found");
if (!java.util.Set.of("PENDING_ASSIGNMENT", "ASSIGNED").contains(plan.getStatus()))
throw new ResponseStatusException(HttpStatus.CONFLICT, "Inbound plan has started and cannot be edited");
int currentVersion = plan.getVersion() == null ? 0 : plan.getVersion();
if (request.version() != currentVersion)
throw new ResponseStatusException(HttpStatus.CONFLICT, "Inbound plan version is stale; reload before editing");
plan.setOverseasWarehouseId(request.overseasWarehouseId());
plan.setUpdatedAt(LocalDateTime.now());
planMapper.updateById(plan);
// Keep the denormalized destination snapshot used by list queries in sync with the plan.
List<InboundPlanLine> lines = lineMapper.selectList(new LambdaQueryWrapper<InboundPlanLine>()
.eq(InboundPlanLine::getPlanId, plan.getId())
.eq(InboundPlanLine::getCustomerId, plan.getCustomerId()));
for (InboundPlanLine line : lines) {
line.setDestinationWarehouseId(String.valueOf(request.overseasWarehouseId()));
if (trimToNull(request.warehouseCode()) != null) line.setDestinationWarehouseCode(request.warehouseCode().trim());
if (trimToNull(request.warehouseName()) != null) line.setDestinationWarehouseName(request.warehouseName().trim());
lineMapper.updateById(line);
}
// Pallets carry the same destination snapshot and are consumed by PDA/resource assignment.
// Update them in this transaction so a plan edit cannot leave lines and pallets pointing at
// different warehouses.
List<InboundPlanPallet> pallets = palletMapper.selectList(new LambdaQueryWrapper<InboundPlanPallet>()
.eq(InboundPlanPallet::getPlanId, plan.getId())
.eq(InboundPlanPallet::getCustomerId, plan.getCustomerId()));
for (InboundPlanPallet pallet : pallets) {
pallet.setDestinationWarehouseId(String.valueOf(request.overseasWarehouseId()));
if (trimToNull(request.warehouseCode()) != null) pallet.setDestinationWarehouseCode(request.warehouseCode().trim());
if (trimToNull(request.warehouseName()) != null) pallet.setDestinationWarehouseName(request.warehouseName().trim());
palletMapper.updateById(pallet);
}
return detail(customerId, plan);
}
@Transactional
public InboundPlanResponse.Pallet supplementPallet(String customerId, String planNo, SupplementPalletRequest request) {
InboundPlan plan = planMapper.selectOne(new LambdaQueryWrapper<InboundPlan>()
@@ -265,7 +392,8 @@ public class InboundPlanService {
}
private Map<PalletAllocator.PalletDraft, InboundPlanPallet> createPallets(
InboundPlan plan, PalletAllocator.Result allocation, Map<Long, SourceOrderSnapshot> orderById) {
InboundPlan plan, PalletAllocator.Result allocation, Map<Long, SourceOrderSnapshot> orderById,
List<String> sourcePalletNos) {
Map<String, SourceOrderSnapshot> warehouseSamples = orderById.values().stream()
.collect(Collectors.toMap(this::warehouseKey, value -> value, (left, right) -> left));
Map<PalletAllocator.PalletDraft, InboundPlanPallet> result = new HashMap<>();
@@ -275,7 +403,10 @@ public class InboundPlanService {
pallet.setId(IdWorker.getId());
pallet.setCustomerId(plan.getCustomerId());
pallet.setPlanId(plan.getId());
pallet.setPalletNo(plan.getPlanNo() + "-P" + String.format("%03d", result.size() + 1));
int palletIndex = result.size();
String sourcePalletNo = palletIndex < sourcePalletNos.size() ? sourcePalletNos.get(palletIndex) : null;
pallet.setPalletNo(sourcePalletNo == null || sourcePalletNo.isBlank()
? plan.getPlanNo() + "-P" + String.format("%03d", palletIndex + 1) : sourcePalletNo.trim());
pallet.setDestinationWarehouseId(sample.destinationWarehouseId());
pallet.setDestinationWarehouseCode(sample.destinationWarehouseCode());
pallet.setDestinationWarehouseName(sample.destinationWarehouseName());
@@ -319,7 +450,7 @@ public class InboundPlanService {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Inbound code " + order.inboundCode() + " is not ready for an inbound plan");
}
if (order.destinationCount() != 1 || order.destinationMismatchCount() != 0 || warehouseKey(order) == null) {
if (order.destinationCount() != 1 || warehouseKey(order) == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Inbound code " + order.inboundCode()
+ " must have one destination warehouse consistent with all cargo details");
@@ -365,7 +496,8 @@ public class InboundPlanService {
pallet.getActualPackageCount())).toList(),
allocations.stream().map(item -> new InboundPlanResponse.Allocation(item.getLineId(), item.getPalletId(),
item.getAllocatedVolumeCbm(), item.getPlannedPackageCount(), item.getCountedPackageCount(),
item.getPickedPackageCount(), item.getActualVolumeCbm(), item.getStatus(), item.getVersion())).toList());
item.getPickedPackageCount(), item.getActualVolumeCbm(), item.getStatus(), item.getVersion())).toList(),
plan.getVersion() == null ? 0 : plan.getVersion());
}
private BigDecimal volume(SourceOrderSnapshot order) {
@@ -34,6 +34,23 @@ public final class PalletAllocator {
PalletDraft current = null;
int sequence = 0;
for (LineInput line : group) {
if (line.recommendedPalletCount() > 0) {
int minimumByVolume = line.volumeCbm().divide(PALLET_CAPACITY_CBM, 0, RoundingMode.CEILING).intValue();
int count = Math.max(line.recommendedPalletCount(), minimumByVolume);
BigDecimal remaining = line.volumeCbm();
for (int index = 0; index < count; index++) {
PalletDraft recommended = new PalletDraft(line.warehouseKey(), ++sequence, BigDecimal.ZERO);
pallets.add(recommended);
int remainingPallets = count - index;
BigDecimal allocated = remainingPallets == 1 ? remaining
: remaining.divide(BigDecimal.valueOf(remainingPallets), 4, RoundingMode.HALF_UP);
recommended.add(allocated);
allocations.add(new AllocationDraft(line.orderId(), recommended, allocated, 0));
remaining = remaining.subtract(allocated);
}
current = null;
continue;
}
BigDecimal remaining = line.volumeCbm();
while (remaining.signum() > 0) {
if (current == null || current.remainingCapacity().signum() == 0) {
@@ -85,7 +102,11 @@ public final class PalletAllocator {
return List.copyOf(result);
}
public record LineInput(Long orderId, String warehouseKey, BigDecimal volumeCbm, int packageCount) {
public record LineInput(Long orderId, String warehouseKey, BigDecimal volumeCbm, int packageCount,
int recommendedPalletCount) {
public LineInput(Long orderId, String warehouseKey, BigDecimal volumeCbm, int packageCount) {
this(orderId, warehouseKey, volumeCbm, packageCount, 0);
}
}
public static final class PalletDraft {
@@ -6,6 +6,8 @@ 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 InventoryCommandMapper {
@Select("SELECT * FROM ll_inventory_position WHERE id=#{positionId} LIMIT 1")
@@ -22,4 +24,17 @@ public interface InventoryCommandMapper {
@Select("SELECT COALESCE(SUM(available_packages+hold_packages+allocated_packages+picked_packages+receiving_packages),0) " +
"FROM ll_inventory_position WHERE customer_id=#{customerId} AND pallet_id=#{palletId}")
long palletPackages(@Param("customerId") String customerId, @Param("palletId") Long palletId);
@Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} " +
"AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode} " +
"AND in_transit_packages>0 ORDER BY id FOR UPDATE")
List<InventoryBalance> lockInboundTransitBalances(@Param("customerId") String customerId,
@Param("inboundOrderId") Long inboundOrderId,
@Param("inboundCode") String inboundCode);
@Select("SELECT p.* FROM ll_inventory_position p JOIN ll_inventory_balance b ON b.id=p.balance_id " +
"WHERE p.customer_id=#{customerId} AND b.inbound_order_id=#{inboundOrderId} " +
"AND p.hold_packages>0 ORDER BY p.id FOR UPDATE")
List<InventoryPosition> lockInboundHeldPositions(@Param("customerId") String customerId,
@Param("inboundOrderId") Long inboundOrderId);
}
@@ -27,6 +27,7 @@ import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.HexFormat;
import java.util.List;
@Service
public class InventoryCommandService {
@@ -59,23 +60,38 @@ public class InventoryCommandService {
@Transactional
public InventoryCommandResponse stockIn(String customerId, String operatorId,
InventoryCommandRequests.StockIn request) {
return stockIn(customerId, operatorId, request, false);
}
@Transactional
public InventoryCommandResponse stockInHeld(String customerId, String operatorId,
InventoryCommandRequests.StockIn request) {
return stockIn(customerId, operatorId, request, true);
}
private InventoryCommandResponse stockIn(String customerId, String operatorId,
InventoryCommandRequests.StockIn request, boolean held) {
return execute(customerId, operatorId, "STOCK_IN", request.idempotencyKey(), request, operation -> {
List<InventoryBalance> transitBalances = commands.lockInboundTransitBalances(customerId,
request.inboundOrderId(), request.inboundCode());
InventoryBalance balance = balance(customerId, request);
InventoryPosition position = position(customerId, balance, request);
long before = value(position.getAvailablePackages());
position.setAvailablePackages(before + request.packages());
long before = held ? value(position.getHoldPackages()) : value(position.getAvailablePackages());
if (held) position.setHoldPackages(before + request.packages());
else position.setAvailablePackages(before + request.packages());
position.setStatus("OCCUPIED");
position.setVersion(value(position.getVersion()) + 1);
position.setUpdatedAt(LocalDateTime.now());
positions.updateById(position);
long transferred = Math.min(value(balance.getInTransitPackages()), request.packages());
balance.setInTransitPackages(value(balance.getInTransitPackages()) - transferred);
balance.setAvailablePackages(value(balance.getAvailablePackages()) + request.packages());
long transferred = transferInTransit(customerId, operatorId, operation.getId(), balance,
transitBalances, request);
if (held) balance.setHoldPackages(value(balance.getHoldPackages()) + request.packages());
else balance.setAvailablePackages(value(balance.getAvailablePackages()) + request.packages());
touch(balance);
balances.updateById(balance);
ledger(customerId, operatorId, operation.getId(), balance, position, sku(request.skuCode()), "STOCK_IN",
transferred == request.packages() ? "IN_TRANSIT" : "OUTSIDE", "AVAILABLE",
transferred == request.packages() ? "IN_TRANSIT" : "OUTSIDE", held ? "HOLD" : "AVAILABLE",
before, request.packages(), before + request.packages(),
request.businessOrderNo(), request.inboundOrderNo(), null, request.sourceEventId(),
request.idempotencyKey(), request.remark());
@@ -83,6 +99,44 @@ public class InventoryCommandService {
});
}
@Transactional
public void releaseInboundOrderHold(String customerId, String operatorId, long inboundOrderId,
String businessOrderNo, String remark) {
for (InventoryPosition position : commands.lockInboundHeldPositions(customerId, inboundOrderId)) {
long packages = value(position.getHoldPackages());
if (packages == 0) continue;
release(customerId, operatorId, new InventoryCommandRequests.Move(position.getId(), packages,
businessOrderNo, null, null, "ORDER_HOLD_RELEASE:" + inboundOrderId + ":" + position.getId(), remark));
}
}
private long transferInTransit(String customerId, String operatorId, Long operationId,
InventoryBalance target, List<InventoryBalance> sources,
InventoryCommandRequests.StockIn request) {
long pending = request.packages();
long transferred = 0;
for (InventoryBalance source : sources) {
if (pending == 0) break;
boolean targetDimension = source.getId().equals(target.getId());
long sourceBefore = value(targetDimension ? target.getInTransitPackages() : source.getInTransitPackages());
long moved = Math.min(sourceBefore, pending);
if (moved == 0) continue;
if (targetDimension) target.setInTransitPackages(sourceBefore - moved);
else source.setInTransitPackages(sourceBefore - moved);
transferred += moved;
pending -= moved;
if (targetDimension) continue;
touch(source);
balances.updateById(source);
ledger(customerId, operatorId, operationId, source, null, sku(request.skuCode()),
"STOCK_IN_TRANSIT_RELEASE", "IN_TRANSIT", "STOCK_IN", sourceBefore, -moved,
sourceBefore - moved, request.businessOrderNo(), request.inboundOrderNo(), null,
request.sourceEventId(), request.idempotencyKey() + ":TRANSIT:" + source.getId(),
"In-transit inventory transferred to overseas warehouse " + target.getOverseasWarehouseId());
}
return transferred;
}
@Transactional
public InventoryCommandResponse hold(String customerId, String operatorId, InventoryCommandRequests.Move request) {
return move(customerId, operatorId, "HOLD", Bucket.AVAILABLE, Bucket.HOLD, request);
@@ -24,6 +24,7 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
@@ -80,28 +81,30 @@ public class InventoryQueryController {
@GetMapping(value = BASE_PATH + "/balances/export", produces = "text/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(
public ResponseEntity<StreamingResponseBody> exportBalances(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid InventoryQueryRequests.BalanceQuery query) {
return csv(service.exportBalances(platformScope(user, customerId), query), "inventory-balances.csv");
String scope = platformScope(user, customerId);
return csv(output -> service.writeBalancesCsv(scope, query, output), "inventory-balances.csv");
}
@GetMapping(value = BASE_PATH + "/ledger/export", produces = "text/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(
public ResponseEntity<StreamingResponseBody> exportLedgers(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid InventoryQueryRequests.LedgerQuery query) {
return csv(service.exportLedgers(platformScope(user, customerId), query), "inventory-ledger.csv");
String scope = platformScope(user, customerId);
return csv(output -> service.writeLedgersCsv(scope, query, output), "inventory-ledger.csv");
}
private static ResponseEntity<byte[]> csv(byte[] body, String filename) {
private static ResponseEntity<StreamingResponseBody> csv(StreamingResponseBody stream, String filename) {
return ResponseEntity.ok()
.contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + filename)
.body(body);
.body(stream);
}
private String platformScope(AuthenticatedUser user, String requestedCustomerId) {
@@ -17,13 +17,14 @@ import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.util.List;
import java.util.Set;
@Service
public class InventoryQueryService {
private static final int EXPORT_LIMIT = 10_000;
private static final Set<String> BALANCE_STATUSES = Set.of(
"AVAILABLE", "HOLD", "ALLOCATED", "PICKED", "IN_TRANSIT", "RECEIVING", "PROCUREMENT");
private static final Set<String> POSITION_STATUSES = Set.of(
@@ -79,34 +80,74 @@ public class InventoryQueryService {
}
public byte[] exportBalances(String customerId, InventoryQueryRequests.BalanceQuery query) {
try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
validateStatus(query.inventoryStatus(), BALANCE_STATUSES);
output.write(("\uFEFFcustomerId,customerName,overseasWarehouseCode,overseasWarehouseName,destinationWarehouseCode,destinationWarehouseName,inboundOrderId,inboundOrderNo,inboundCode,skuCode,productNameZh,productNameEn,totalPackages,availablePackages,holdPackages,allocatedPackages,pickedPackages,inTransitPackages,receivingPackages,procurementPackages\r\n").getBytes(StandardCharsets.UTF_8));
for (InventoryBalance value : mapper.balances(customerId, query, 0, 10_000)) {
InventoryBalanceView v = balanceView(value);
output.write((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.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())) + "\r\n").getBytes(StandardCharsets.UTF_8));
}
return output.toByteArray();
} catch (IOException e) { throw new IllegalStateException("Unable to export inventory balances", e); }
}
public void writeBalancesCsv(String customerId, InventoryQueryRequests.BalanceQuery query, OutputStream output)
throws IOException {
validateStatus(query.inventoryStatus(), BALANCE_STATUSES);
List<String> rows = new ArrayList<>();
rows.add("customerId,customerName,overseasWarehouseCode,overseasWarehouseName,destinationWarehouseCode," +
output.write(("\uFEFFcustomerId,customerName,overseasWarehouseCode,overseasWarehouseName,destinationWarehouseCode," +
"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(
"holdPackages,allocatedPackages,pickedPackages,inTransitPackages,receivingPackages,procurementPackages\r\n").getBytes(StandardCharsets.UTF_8));
final int batchSize = 1000;
long offset = 0;
while (true) {
List<InventoryBalanceView> batch = mapper.balances(customerId, query, offset, batchSize).stream().map(this::balanceView).toList();
if (batch.isEmpty()) break;
for (InventoryBalanceView v : batch) output.write((
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.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()))));
return ("\uFEFF" + String.join("\r\n", rows)).getBytes(StandardCharsets.UTF_8);
csv(v.procurementPackages())) + "\r\n").getBytes(StandardCharsets.UTF_8));
output.flush();
if (batch.size() < batchSize) break;
offset += batchSize;
}
}
public byte[] exportLedgers(String customerId, InventoryQueryRequests.LedgerQuery query) {
try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
validateDateRange(query);
output.write(("\uFEFFoccurredAt,customerId,overseasWarehouseId,destinationWarehouseCode,inboundOrderId,inboundOrderNo,inboundCode,businessType,businessOrderNo,outboundOrderNo,palletNo,batchNo,statusFrom,statusTo,beforePackages,deltaPackages,afterPackages,operatorId,remark\r\n").getBytes(StandardCharsets.UTF_8));
for (InventoryLedger value : mapper.ledgers(customerId, query, 0, 10_000)) {
InventoryLedgerView v = ledgerView(value);
output.write((String.join(",", csv(v.occurredAt()), csv(v.customerId()), csv(v.overseasWarehouseId()), csv(v.destinationWarehouseCode()), csv(v.inboundOrderId()), csv(v.inboundOrderNo()), csv(v.inboundCode()), csv(v.businessType()), csv(v.businessOrderNo()), csv(v.outboundOrderNo()), csv(v.palletNo()), csv(v.batchNo()), csv(v.statusFrom()), csv(v.statusTo()), csv(v.beforePackages()), csv(v.deltaPackages()), csv(v.afterPackages()), csv(v.operatorId()), csv(v.remark())) + "\r\n").getBytes(StandardCharsets.UTF_8));
}
return output.toByteArray();
} catch (IOException e) { throw new IllegalStateException("Unable to export inventory ledger", e); }
}
public void writeLedgersCsv(String customerId, InventoryQueryRequests.LedgerQuery query, OutputStream output)
throws IOException {
validateDateRange(query);
List<String> rows = new ArrayList<>();
rows.add("occurredAt,customerId,customerName,overseasWarehouseId,destinationWarehouseCode,inboundOrderId,inboundOrderNo," +
output.write(("\uFEFFoccurredAt,customerId,customerName,overseasWarehouseId,destinationWarehouseCode,inboundOrderId,inboundOrderNo," +
"inboundCode,businessType,businessOrderNo,outboundOrderNo,palletNo,batchNo,statusFrom,statusTo," +
"beforePackages,deltaPackages,afterPackages,operatorId,remark");
mapper.ledgers(customerId, query, 0, EXPORT_LIMIT).stream().map(this::ledgerView).forEach(v -> rows.add(
"beforePackages,deltaPackages,afterPackages,operatorId,remark\r\n").getBytes(StandardCharsets.UTF_8));
final int batchSize = 1000;
long offset = 0;
while (true) {
List<InventoryLedgerView> batch = mapper.ledgers(customerId, query, offset, batchSize).stream().map(this::ledgerView).toList();
if (batch.isEmpty()) break;
for (InventoryLedgerView v : batch) output.write((
String.join(",", csv(v.occurredAt()), csv(v.customerId()), csv(v.customerName()), csv(v.overseasWarehouseId()),
csv(v.destinationWarehouseCode()), csv(v.inboundOrderId()), csv(v.inboundOrderNo()), csv(v.inboundCode()),
csv(v.businessType()), csv(v.businessOrderNo()), csv(v.outboundOrderNo()), csv(v.palletNo()), csv(v.batchNo()),
csv(v.statusFrom()), csv(v.statusTo()), csv(v.beforePackages()), csv(v.deltaPackages()),
csv(v.afterPackages()), csv(v.operatorId()), csv(v.remark()))));
return ("\uFEFF" + String.join("\r\n", rows)).getBytes(StandardCharsets.UTF_8);
csv(v.afterPackages()), csv(v.operatorId()), csv(v.remark())) + "\r\n").getBytes(StandardCharsets.UTF_8));
output.flush();
if (batch.size() < batchSize) break;
offset += batchSize;
}
}
private InventoryBalanceView balanceView(InventoryBalance value) {
@@ -70,7 +70,7 @@ public class LocalLooseboardService {
LocalLooseboardOrder order = new LocalLooseboardOrder();
order.setId(IdWorker.getId());
order.setCustomerId(customerId);
order.setOrderNo("LB" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + order.getId());
order.setOrderNo(lastlegOrderNoGenerator.next("LOOSEBOARD", request.destination(), 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(),
@@ -257,7 +257,7 @@ public class LocalLooseboardService {
private LastlegOrder createLastlegOrder(LocalLooseboardOrder source, String requestHash, LocalDateTime now) {
LastlegOrder order = new LastlegOrder();
order.setId(IdWorker.getId());
order.setOrderNo(lastlegOrderNoGenerator.next());
order.setOrderNo(lastlegOrderNoGenerator.next("LOOSEBOARD", source.getDestination(), order.getId()));
order.setCustomerId(source.getCustomerId());
order.setOrderType("LOCAL_LOOSEBOARD");
order.setInboundCode(source.getOrderNo());
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.entity.LastlegOrderFee;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface LastlegOrderFeeMapper extends BaseMapper<LastlegOrderFee> {
}
@@ -78,6 +78,28 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
List<ContainerPlanProgressDetail.Item> findContainerProgressItems(
@Param("sourceContainerBookingId") String sourceContainerBookingId);
record PalletMergeRelationRow(Long orderId, String relatedInboundNo, String mergeGroupNo) {}
@Select("<script>SELECT relation.order_id AS orderId," +
"COALESCE(NULLIF(related.inbound_code,''),NULLIF(related.inbound_order_no,''),related.source_order_no) AS relatedInboundNo, " +
"relation.merge_group_no AS mergeGroupNo FROM (" +
"SELECT marked.customer_id,marked.id AS order_id,related.id AS related_order_id," +
"marked.pallet_merge_batch_id AS merge_group_no FROM ll_order marked " +
"JOIN ll_order related ON related.customer_id=marked.customer_id " +
"AND related.pallet_merge_batch_id=marked.pallet_merge_batch_id AND related.id&lt;&gt;marked.id " +
"WHERE marked.pallet_merge_batch_id IS NOT NULL AND marked.id IN " +
"<foreach collection='orderIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
"UNION ALL " +
"SELECT customer_id,source_order_id AS order_id,target_order_id AS related_order_id,merge_group_no " +
"FROM ll_inbound_pallet_merge WHERE status='CONFIRMED' AND source_order_id IN " +
"<foreach collection='orderIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
"UNION ALL SELECT customer_id,target_order_id AS order_id,source_order_id AS related_order_id,merge_group_no " +
"FROM ll_inbound_pallet_merge WHERE status='CONFIRMED' AND target_order_id IN " +
"<foreach collection='orderIds' item='id' open='(' separator=',' close=')'>#{id}</foreach>) relation " +
"JOIN ll_order related ON related.id=relation.related_order_id AND related.customer_id=relation.customer_id " +
"ORDER BY relation.order_id,relation.merge_group_no,related.order_no</script>")
List<PalletMergeRelationRow> findPalletMergeRelations(@Param("orderIds") List<Long> orderIds);
@Select("SELECT * FROM ll_order WHERE customer_id=#{customerId} AND source_region=#{sourceRegion} " +
"AND source_inbound_id=#{sourceInboundId} LIMIT 1")
LastlegOrder findSource(@Param("customerId") String customerId,
@@ -116,4 +138,18 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
int claimReceivingWarehouse(@Param("customerId") String customerId, @Param("orderId") long orderId,
@Param("warehouseId") String warehouseId,
@Param("warehouseName") String warehouseName);
@Update("UPDATE ll_order SET pallet_merge_flag=#{flag}," +
"pallet_merge_batch_id=CASE WHEN #{flag} IS NULL THEN NULL ELSE pallet_merge_batch_id END," +
"updated_at=#{updatedAt} " +
"WHERE customer_id=#{customerId} AND id=#{id}")
int updatePalletMergeFlag(@Param("customerId") String customerId, @Param("id") long id,
@Param("flag") String flag,
@Param("updatedAt") java.time.LocalDateTime updatedAt);
@Update("UPDATE ll_order SET pallet_merge_flag=#{flag},pallet_merge_batch_id=#{batchId},updated_at=#{updatedAt} " +
"WHERE customer_id=#{customerId} AND id=#{id}")
int updatePalletMergeBatch(@Param("customerId") String customerId, @Param("id") long id,
@Param("flag") String flag, @Param("batchId") String batchId,
@Param("updatedAt") java.time.LocalDateTime updatedAt);
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.entity.LastlegOrderOperationLog;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface LastlegOrderOperationLogMapper extends BaseMapper<LastlegOrderOperationLog> {
}
@@ -56,5 +56,15 @@ public final class OverseasProcurementRequests {
JsonNode overseasCarrier,
JsonNode overseasTrackingNo,
JsonNode estimatedArrivalAt,
JsonNode remark) {}
JsonNode remark,
JsonNode outboundDetails) {
public Update(Long version, String purchaseCategory, String productName, JsonNode productSpecification,
JsonNode productUrl, Long inboundQuantity, JsonNode purchaseWarehouseId, JsonNode purchaseWarehouseName,
JsonNode domesticCarrier, JsonNode domesticTrackingNo, JsonNode overseasCarrier, JsonNode overseasTrackingNo,
JsonNode estimatedArrivalAt, JsonNode remark) {
this(version, purchaseCategory, productName, productSpecification, productUrl, inboundQuantity,
purchaseWarehouseId, purchaseWarehouseName, domesticCarrier, domesticTrackingNo, overseasCarrier,
overseasTrackingNo, estimatedArrivalAt, remark, null);
}
}
}
@@ -7,12 +7,14 @@ import java.util.List;
public final class OverseasProcurementResponses {
private OverseasProcurementResponses() {}
public record OutboundDetail(String id, long quantity, String carrier, String trackingNo, LocalDateTime shippedAt, String remark) {}
public record Summary(
Long id, String orderNo, String customerId, String customerName,
String purchaseCategory, String productName, String productSpecification,
long purchaseQuantity, BigDecimal budgetUnitPrice, String currency,
String purchaseWarehouseId, String purchaseWarehouseName,
long inboundQuantity, long inventoryQuantity,
long inboundQuantity, long outboundQuantity, long inventoryQuantity,
String status, long version, LocalDateTime createdAt, LocalDateTime updatedAt) {}
public record CustomerOption(String customerId, String customerName) {}
@@ -32,5 +34,5 @@ public final class OverseasProcurementResponses {
String overseasCarrier, String overseasTrackingNo,
LocalDateTime estimatedArrivalAt, String remark,
String createdBy, String reviewedBy, String reviewRemark, LocalDateTime reviewedAt,
List<History> histories) {}
List<History> histories, List<OutboundDetail> outboundDetails) {}
}
@@ -30,6 +30,7 @@ public class OverseasProcurementOrder {
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String purchaseWarehouseId;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String purchaseWarehouseName;
private Long inboundQuantity;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String outboundDetails;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String domesticCarrier;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String domesticTrackingNo;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String overseasCarrier;
@@ -37,6 +37,7 @@ import java.util.Set;
@Service
public class OverseasProcurementService {
@Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator;
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";
@@ -62,7 +63,7 @@ public class OverseasProcurementService {
order.setId(IdWorker.getId());
order.setCustomerId(customerId);
order.setCustomerName(trim(request.customerName()));
order.setOrderNo("HC" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + order.getId());
order.setOrderNo(orderNoGenerator.next("PROCUREMENT", request.receivingAddress(), order.getId()));
order.setPurchaseCategory(request.purchaseCategory().trim());
order.setProductName(request.productName().trim());
order.setProductSpecification(trim(request.productSpecification()));
@@ -147,7 +148,7 @@ public class OverseasProcurementService {
new OverseasProcurementResponses.History(value.getOperationType(), value.getFromStatus(),
value.getToStatus(), value.getOperatorId(), value.getIdempotencyKey(),
value.getChangeDetail(), value.getRemark(),
value.getCreatedAt())).toList());
value.getCreatedAt())).toList(), outboundDetails(order));
}
@Transactional
@@ -204,6 +205,7 @@ public class OverseasProcurementService {
patchText(request.overseasTrackingNo(), 128, "overseasTrackingNo", order::setOverseasTrackingNo);
patchDateTime(request.estimatedArrivalAt(), order::setEstimatedArrivalAt);
patchText(request.remark(), 1000, "remark", order::setRemark);
if (request.outboundDetails() != null) order.setOutboundDetails(normalizeOutbound(request.outboundDetails(), order.getInboundQuantity()));
Map<String, Object> after = editableSnapshot(order);
if (before.equals(after)) return detail(customerId, orderNo);
LocalDateTime now = LocalDateTime.now();
@@ -229,11 +231,16 @@ public class OverseasProcurementService {
private OverseasProcurementResponses.Summary summary(OverseasProcurementOrder order) {
long inbound = value(order.getInboundQuantity());
long outbound = outboundDetails(order).stream().mapToLong(OverseasProcurementResponses.OutboundDetail::quantity).sum();
String displayStatus = order.getStatus();
if (APPROVED.equals(order.getStatus()) && inbound > 0) {
displayStatus = outbound == 0 ? "PENDING_OUTBOUND" : outbound < inbound ? "PARTIALLY_OUTBOUND" : "OUTBOUNDED";
}
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, inbound,
order.getStatus(), value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt());
order.getCurrency(), order.getPurchaseWarehouseId(), order.getPurchaseWarehouseName(), inbound, outbound, Math.max(0, inbound - outbound),
displayStatus, value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt());
}
private void history(OverseasProcurementOrder order, String operationType, String from, String to,
@@ -335,9 +342,26 @@ public class OverseasProcurementService {
result.put("overseasTrackingNo", order.getOverseasTrackingNo());
result.put("estimatedArrivalAt", order.getEstimatedArrivalAt());
result.put("remark", order.getRemark());
result.put("outboundDetails", order.getOutboundDetails());
return result;
}
private List<OverseasProcurementResponses.OutboundDetail> outboundDetails(OverseasProcurementOrder order) {
if (!StringUtils.hasText(order.getOutboundDetails())) return List.of();
try { return objectMapper.readValue(order.getOutboundDetails(), objectMapper.getTypeFactory().constructCollectionType(List.class, OverseasProcurementResponses.OutboundDetail.class)); }
catch (JsonProcessingException e) { return List.of(); }
}
private String normalizeOutbound(JsonNode node, Long inbound) {
if (!node.isArray()) throw badRequest("outboundDetails must be an array");
long total = 0;
for (JsonNode item : node) {
if (!item.isObject() || item.path("quantity").asLong(-1) < 0) throw badRequest("outboundDetails quantity must be non-negative");
total += item.path("quantity").asLong();
}
if (total > value(inbound)) throw badRequest("outbound quantity cannot exceed inbound quantity");
try { return objectMapper.writeValueAsString(node); } catch (JsonProcessingException e) { throw badRequest("Invalid outboundDetails"); }
}
private String changeDetail(Map<String, Object> before, Map<String, Object> after) {
Map<String, Object> changes = new LinkedHashMap<>();
before.forEach((field, oldValue) -> {
@@ -67,10 +67,10 @@ public class RelabelApplicationService {
}
LocalDateTime now = LocalDateTime.now();
long applicationId = IdWorker.getId();
String relabelNo = "FBR" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + applicationId;
String relabelNo = orderNoGenerator.next("RELABEL", request.overseasWarehouseId(), applicationId);
LastlegOrder order = new LastlegOrder();
order.setId(IdWorker.getId());
order.setOrderNo(orderNoGenerator.next());
order.setOrderNo(orderNoGenerator.next("RELABEL", request.overseasWarehouseId(), order.getId()));
order.setCustomerId(customerId);
order.setOrderType("RELABEL_APPLICATION");
order.setInboundCode(relabelNo);
@@ -13,11 +13,14 @@ import java.util.List;
@Repository
public class UnloadCompletedOutboxRepository {
private static final List<String> OVERSEAS_TO_CN_TYPES = List.of(
EventTypes.OVERSEAS_CONTAINER_UNLOADED,
EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED);
@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)
.in(LastlegEventOutbox::getEventType, OVERSEAS_TO_CN_TYPES)
.and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING")
.and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt)
.or().le(LastlegEventOutbox::getNextRetryAt, now))
@@ -30,7 +33,7 @@ public class UnloadCompletedOutboxRepository {
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)
.in(LastlegEventOutbox::getEventType, OVERSEAS_TO_CN_TYPES)
.and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING")
.and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt)
.or().le(LastlegEventOutbox::getNextRetryAt, now))
@@ -44,7 +47,7 @@ public class UnloadCompletedOutboxRepository {
public LastlegEventOutbox claimed(long id, String worker) {
return mapper.selectOne(new LambdaQueryWrapper<LastlegEventOutbox>()
.eq(LastlegEventOutbox::getId, id)
.eq(LastlegEventOutbox::getEventType, EventTypes.OVERSEAS_CONTAINER_UNLOADED)
.in(LastlegEventOutbox::getEventType, OVERSEAS_TO_CN_TYPES)
.eq(LastlegEventOutbox::getStatus, "PROCESSING")
.eq(LastlegEventOutbox::getLockedBy, worker));
}
@@ -72,7 +75,7 @@ public class UnloadCompletedOutboxRepository {
private LambdaUpdateWrapper<LastlegEventOutbox> claimedBase(long id, String worker) {
return new LambdaUpdateWrapper<LastlegEventOutbox>()
.eq(LastlegEventOutbox::getId, id)
.eq(LastlegEventOutbox::getEventType, EventTypes.OVERSEAS_CONTAINER_UNLOADED)
.in(LastlegEventOutbox::getEventType, OVERSEAS_TO_CN_TYPES)
.eq(LastlegEventOutbox::getStatus, "PROCESSING")
.eq(LastlegEventOutbox::getLockedBy, worker);
}
@@ -19,6 +19,7 @@ public final class OverseasPermissions {
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 DELIVERY_LIST = "platform:overseas-warehouse:delivery:list";
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";
@@ -0,0 +1,73 @@
package com.baiyue.lastleg.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventIdentifiers;
import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.OverseasInboundOrderDetainedChangedV1;
import com.baiyue.common.event.RegionCode;
import com.baiyue.lastleg.entity.LastlegEventOutbox;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
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.stereotype.Service;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
/** 在尾程业务事务内冻结按来源入仓单回传国内的暂扣状态事件。 */
@Service
public class InboundOrderDetainedOutboxService {
@Resource private LastlegEventOutboxMapper mapper;
@Resource private ObjectMapper objectMapper;
public void publish(LastlegOrder order, boolean detained, String reason, String operatorId,
String operatorName, Instant changedAt, long changeVersion) {
requireText(order.getSourceInboundId(), "sourceInboundId");
requireText(order.getInboundCode(), "inboundCode");
requireText(order.getCustomerId(), "customerId");
var payload = new OverseasInboundOrderDetainedChangedV1(order.getSourceInboundId(),
order.getInboundCode(), String.valueOf(order.getId()), order.getOrderNo(), detained,
reason, operatorId, operatorName, changedAt);
JsonNode payloadNode = objectMapper.valueToTree(payload);
String eventKey = EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED + ':'
+ order.getSourceInboundId() + ':' + changeVersion;
EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(),
EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, 1, changedAt,
RegionCode.OVERSEAS, RegionCode.CN, order.getCustomerId(), "INBOUND_ORDER",
order.getSourceInboundId(), changeVersion, 1L, EventIdentifiers.newUuid(),
eventKey, order.getSourceEventId(), payloadNode,
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode))).requireValid();
LastlegEventOutbox event = new LastlegEventOutbox();
event.setId(IdWorker.getId());
event.setCustomerId(order.getCustomerId());
event.setAggregateType(envelope.aggregateType());
event.setAggregateId(envelope.aggregateId());
event.setEventType(envelope.eventType());
event.setEventVersion(envelope.eventVersion());
event.setEventKey(eventKey);
event.setPayloadJson(json(envelope));
event.setStatus("PENDING");
event.setRetryCount(0);
event.setCreatedAt(LocalDateTime.ofInstant(changedAt, ZoneOffset.UTC));
mapper.insert(event);
}
private static void requireText(String value, String field) {
if (value == null || value.isBlank()) throw new IllegalStateException(field + " is required");
}
private String json(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException exception) {
throw new IllegalStateException("Cannot serialize inbound detained event", exception);
}
}
}
@@ -4,9 +4,13 @@ import com.baiyue.lastleg.mapper.LastlegOrderNoDailySequenceMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
@Component
public class LastlegOrderNoGenerator {
@@ -26,4 +30,61 @@ public class LastlegOrderNoGenerator {
}
return "LMO-" + BUSINESS_DATE.format(businessDate) + "-" + String.format("%04d", sequence);
}
public String next(String businessType, String warehouse, long uniqueId) {
String type = normalizeType(businessType);
return type + "-" + warehouseCode(warehouse) + "-"
+ LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + "-"
+ String.format(Locale.ROOT, "%06d", Math.floorMod(uniqueId, 1_000_000));
}
private String normalizeType(String value) {
String code = value == null ? "LMO" : value.trim().toUpperCase(Locale.ROOT);
return switch (code) {
case "FIRST_LEG" -> "FL";
case "ONE_CLICK_DROPSHIP" -> "OD";
case "RELABEL_APPLICATION", "RELABEL" -> "RL";
case "WAREHOUSE_TRANSFER", "TRANSFER" -> "WT";
case "LOCAL_LOOSEBOARD", "LOOSEBOARD" -> "LB";
case "DISPATCH", "PICKUP_CAR" -> "DS";
case "OUTBOUND" -> "OB";
case "DELIVERY" -> "DL";
case "TRUCK" -> "TR";
case "EXCEPTION" -> "EX";
case "DISCARD" -> "CD";
case "PROCUREMENT" -> "PR";
case "INBOUND_PLAN" -> "IP";
default -> {
String normalized = code.replaceAll("[^A-Z0-9]", "");
yield normalized.isEmpty() ? "LMO" : normalized.substring(0, Math.min(4, normalized.length()));
}
};
}
public String warehouseCode(String value) {
String code = value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
if (code.isEmpty()) return "WH";
if (code.matches(".*(LAX|ONT|NYC|JFK|LGB|EWR|SFO|SEA|DFW|ORD|ATL|MIA|PHX|LAS|YYZ|YVR|AMS|LHR|FRA|CDG|HKG|NRT|SYD).*")) {
var match = java.util.regex.Pattern.compile("(LAX|ONT|NYC|JFK|LGB|EWR|SFO|SEA|DFW|ORD|ATL|MIA|PHX|LAS|YYZ|YVR|AMS|LHR|FRA|CDG|HKG|NRT|SYD)").matcher(code);
if (match.find()) return match.group(1);
}
if (code.matches("[A-Z0-9]{2,8}")) return code;
return "WH";
}
/** The physical container number is the tail-leg business order grouping boundary. */
public String forContainerNo(String containerNo) {
if (containerNo == null || containerNo.isBlank()) {
throw new IllegalArgumentException("containerNo is required");
}
String normalized = containerNo.trim();
try {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(normalized.getBytes(StandardCharsets.UTF_8));
long suffix = java.nio.ByteBuffer.wrap(digest, 0, 8).getLong();
return next("FIRST_LEG", null, suffix);
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 is unavailable", exception);
}
}
}
@@ -1,6 +1,7 @@
package com.baiyue.lastleg.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.common.event.ContainerNumbers;
@@ -12,11 +13,16 @@ import com.baiyue.lastleg.dto.ContainerPlanProgressSummary;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.entity.LastlegOrderFee;
import com.baiyue.lastleg.entity.LastlegOrderOperationLog;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.mapper.LastlegOrderFeeMapper;
import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper;
import com.baiyue.lastleg.model.LastlegOrderType;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
@@ -26,9 +32,15 @@ import org.springframework.util.StringUtils;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@Service
@@ -36,17 +48,24 @@ public class LastlegOrderService {
public static final String PENDING_ASSIGNMENT = "PENDING_ASSIGNMENT";
public static final String DESTINATION_ASSIGNED = "DESTINATION_ASSIGNED";
public static final String INBOUND_PLANNED = "INBOUND_PLANNED";
public static final String PALLET_MERGE_REQUIRED = "REQUIRED";
public static final String PALLET_MERGED = "MERGED";
@Resource private LastlegOrderMapper orderMapper;
@Resource private LastlegOrderDetailMapper detailMapper;
@Resource private LastlegOrderStatusHistoryMapper historyMapper;
@Resource private LastlegOrderFeeMapper feeMapper;
@Resource private LastlegOrderOperationLogMapper operationLogMapper;
@Resource private InboundPlanCartonMapper cartonMapper;
@Resource private FbaWarehouseValidator fbaWarehouseValidator;
@Resource private InventoryCommandService inventory;
@Resource private InboundOrderDetainedOutboxService detainedOutbox;
public PageResult<LastlegOrderSummary> page(String customerId, LastlegOrderRequests.Query query) {
Page<LastlegOrder> page = orderMapper.selectPage(
new Page<>(query.page(), query.size()), filter(customerId, query).orderByDesc(LastlegOrder::getCreatedAt));
return new PageResult<>(page.getRecords().stream().map(this::summary).toList(),
Map<Long, List<LastlegOrderMapper.PalletMergeRelationRow>> mergeRelations = palletMergeRelations(page.getRecords());
return new PageResult<>(page.getRecords().stream().map(order -> summary(order, mergeRelations.get(order.getId()))).toList(),
page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
}
@@ -100,9 +119,165 @@ public class LastlegOrderService {
.eq(LastlegOrderStatusHistory::getCustomerId, order.getCustomerId())
.eq(LastlegOrderStatusHistory::getOrderId, orderId)
.orderByAsc(LastlegOrderStatusHistory::getCreatedAt));
return new LastlegOrderDetailResponse(summary(order), cargo, history.stream()
List<LastlegOrderFee> fees = feeMapper.selectList(new LambdaQueryWrapper<LastlegOrderFee>()
.eq(LastlegOrderFee::getCustomerId, order.getCustomerId())
.eq(LastlegOrderFee::getOrderId, orderId).orderByAsc(LastlegOrderFee::getCreatedAt));
List<LastlegOrderOperationLog> operations = operationLogMapper.selectList(
new LambdaQueryWrapper<LastlegOrderOperationLog>()
.eq(LastlegOrderOperationLog::getCustomerId, order.getCustomerId())
.eq(LastlegOrderOperationLog::getOrderId, orderId)
.orderByAsc(LastlegOrderOperationLog::getCreatedAt));
return new LastlegOrderDetailResponse(summary(order, palletMergeRelations(List.of(order)).get(order.getId())), cargo, history.stream()
.map(value -> new LastlegOrderDetailResponse.History(value.getId(), value.getFromStatus(),
value.getToStatus(), value.getRemark(), value.getCreatedAt())).toList());
value.getToStatus(), value.getRemark(), value.getCreatedAt())).toList(), fees, operations.stream()
.map(value -> new LastlegOrderDetailResponse.Operation(value.getId(), value.getOperationNode(),
value.getOperatorName(), value.getResult(), value.getCreatedAt())).toList());
}
@Transactional
public LastlegOrderFee createFee(String customerId, long orderId, LastlegOrderRequests.CreateFee request,
String operatorName) {
LastlegOrder order = findOwned(customerId, orderId);
LocalDateTime now = LocalDateTime.now();
LastlegOrderFee fee = new LastlegOrderFee();
fee.setId(IdWorker.getId());
fee.setCustomerId(order.getCustomerId());
fee.setOrderId(orderId);
fee.setFeeItem(request.feeItem().trim());
fee.setCurrency(request.currency());
fee.setAmount(request.amount());
fee.setStatus(request.status());
fee.setSupplierDescription(StringUtils.hasText(request.supplierDescription())
? request.supplierDescription().trim() : null);
fee.setCreatedBy(operatorName);
fee.setCreatedAt(now);
fee.setUpdatedAt(now);
feeMapper.insert(fee);
LastlegOrderOperationLog operation = new LastlegOrderOperationLog();
operation.setId(IdWorker.getId());
operation.setCustomerId(order.getCustomerId());
operation.setOrderId(orderId);
operation.setOperationNode("新增费用");
operation.setOperatorName(operatorName);
operation.setResult("新增" + fee.getFeeItem() + ":" + fee.getCurrency() + " " + fee.getAmount().toPlainString());
operation.setCreatedAt(now);
operationLogMapper.insert(operation);
return fee;
}
@Transactional
public void setDetained(String customerId, long orderId, LastlegOrderRequests.SetDetained request,
String operatorName) {
setDetained(customerId, orderId, request, null, operatorName);
}
@Transactional
public void setDetained(String customerId, long orderId, LastlegOrderRequests.SetDetained request,
String operatorId, String operatorName) {
LastlegOrder current = findOwned(customerId, orderId);
LastlegOrder order = orderMapper.lockById(current.getCustomerId(), orderId);
if (order == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Tail-leg order not found");
boolean detained = request.detained();
if (Boolean.TRUE.equals(order.getDetained()) == detained) {
throw new ResponseStatusException(HttpStatus.CONFLICT, detained ? "Order is already held" : "Order is not held");
}
if (detained && "INBOUND_COMPLETED".equals(order.getCurrentStatus())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Completed inbound orders cannot be newly held");
}
LocalDateTime now = LocalDateTime.now();
order.setDetained(detained);
order.setUpdatedAt(now);
orderMapper.updateById(order);
if (!detained && "INBOUND_COMPLETED".equals(order.getCurrentStatus())) {
inventory.releaseInboundOrderHold(order.getCustomerId(), operatorName, orderId, order.getOrderNo(), request.remark().trim());
}
LastlegOrderOperationLog operation = new LastlegOrderOperationLog();
operation.setId(IdWorker.getId());
operation.setCustomerId(order.getCustomerId());
operation.setOrderId(orderId);
operation.setOperationNode(detained ? "暂扣" : "解除暂扣");
operation.setOperatorName(operatorName);
operation.setResult(request.remark().trim());
operation.setCreatedAt(now);
operationLogMapper.insert(operation);
if (StringUtils.hasText(order.getSourceInboundId())) {
detainedOutbox.publish(order, detained, request.remark().trim(), operatorId, operatorName,
now.toInstant(ZoneOffset.UTC), operation.getId());
}
}
@Transactional
public void setPalletMergeRequired(String customerId, long orderId,
LastlegOrderRequests.SetPalletMergeRequired request,
String operatorName) {
LastlegOrder current = findOwned(customerId, orderId);
LastlegOrder order = orderMapper.lockById(current.getCustomerId(), orderId);
if (order == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Tail-leg order not found");
if ("INBOUND_COMPLETED".equals(order.getCurrentStatus())) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Completed inbound orders cannot change the pallet merge mark");
}
if (PALLET_MERGED.equals(order.getPalletMergeFlag())) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"A completed pallet merge cannot be unmarked");
}
String target = request.palletMergeRequired() ? PALLET_MERGE_REQUIRED : null;
if (Objects.equals(target, order.getPalletMergeFlag())) return;
LocalDateTime now = LocalDateTime.now();
if (orderMapper.updatePalletMergeFlag(order.getCustomerId(), orderId, target, now) != 1) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Pallet merge mark was not updated");
}
LastlegOrderOperationLog operation = new LastlegOrderOperationLog();
operation.setId(IdWorker.getId());
operation.setCustomerId(order.getCustomerId());
operation.setOrderId(orderId);
operation.setOperationNode(request.palletMergeRequired() ? "标记合托" : "取消合托标记");
operation.setOperatorName(operatorName);
operation.setResult(request.palletMergeRequired()
? "PDA扫描本单箱唛时提示需要合托" : "已取消PDA合托提示");
operation.setCreatedAt(now);
operationLogMapper.insert(operation);
}
@Transactional
public void setPalletMergeBatch(String customerId, LastlegOrderRequests.SetPalletMergeBatch request,
String operatorName) {
List<Long> ids = request.orderIds().stream().filter(Objects::nonNull).distinct().toList();
if (ids.isEmpty()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "At least one order is required");
List<LastlegOrder> orders = orderMapper.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId)
.in(LastlegOrder::getId, ids).last("FOR UPDATE"));
if (orders.size() != ids.size()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more tail-leg orders were not found");
}
if (orders.stream().map(LastlegOrder::getCustomerId).distinct().count() != 1) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Pallet merge orders must belong to one customer");
}
if (orders.stream().map(order -> StringUtils.hasText(order.getTransferWarehouseCode())
? order.getTransferWarehouseCode() : order.getFbaWarehouseCode()).filter(Objects::nonNull).distinct().count() > 1) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Pallet merge orders must have one destination warehouse");
}
if (orders.stream().anyMatch(order -> "INBOUND_COMPLETED".equals(order.getCurrentStatus())
|| PALLET_MERGED.equals(order.getPalletMergeFlag()))) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Completed pallet merge orders cannot be changed");
}
String batchId = request.palletMergeRequired()
? "PMG-" + java.util.UUID.randomUUID().toString().replace("-", "") : null;
String flag = request.palletMergeRequired() ? PALLET_MERGE_REQUIRED : null;
LocalDateTime now = LocalDateTime.now();
for (LastlegOrder order : orders) {
if (orderMapper.updatePalletMergeBatch(order.getCustomerId(), order.getId(), flag, batchId, now) != 1) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Pallet merge group was not updated");
}
LastlegOrderOperationLog operation = new LastlegOrderOperationLog();
operation.setId(IdWorker.getId()); operation.setCustomerId(order.getCustomerId());
operation.setOrderId(order.getId());
operation.setOperationNode(request.palletMergeRequired() ? "批量标记合托" : "取消合托标记");
operation.setOperatorName(operatorName);
operation.setResult(request.palletMergeRequired() ? "PDA合托分组 " + batchId : "已取消PDA合托分组");
operation.setCreatedAt(now); operationLogMapper.insert(operation);
}
}
@Transactional
@@ -163,12 +338,25 @@ public class LastlegOrderService {
}
public byte[] exportCsv(String customerId, LastlegOrderRequests.Query query) {
List<LastlegOrder> orders = orderMapper.selectList(filter(customerId, query)
.orderByDesc(LastlegOrder::getCreatedAt).last("LIMIT 10000"));
List<String> lines = new ArrayList<>();
lines.add("ID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜状态,是否异常,是否暂扣,托数,箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,换标单号,换标状态,合托标记,主流程状态,子单状态,备注(仅自己),备注(客户),备注(仓库)");
for (LastlegOrder order : orders) {
lines.add(String.join(",", List.of(
try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
writeExportCsv(customerId, query, output);
return output.toByteArray();
} catch (IOException e) {
throw new IllegalStateException("Unable to export tail-leg orders", e);
}
}
public void writeExportCsv(String customerId, LastlegOrderRequests.Query query, OutputStream output)
throws IOException {
output.write("\uFEFFID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜状态,是否异常,是否暂扣,托数,箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,换标单号,换标状态,合托标记,主流程状态,子单状态,备注(仅自己),备注(客户),备注(仓库)\r\n".getBytes(StandardCharsets.UTF_8));
final int batchSize = 1000;
long page = 1;
while (true) {
List<LastlegOrder> orders = orderMapper.selectPage(new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(page, batchSize),
filter(customerId, query).orderByDesc(LastlegOrder::getCreatedAt).orderByDesc(LastlegOrder::getId)).getRecords();
if (orders.isEmpty()) break;
for (LastlegOrder order : orders) {
String line = String.join(",", List.of(
csv(order.getId()), csv(order.getOrderNo()), csv(LastlegOrderType.labelOf(order.getOrderType())),
csv(order.getInboundCode()),
csv(order.getSourceOrderNo()), csv(order.getCustomerName()), csv(order.getOverseasWarehouseName()),
@@ -178,9 +366,13 @@ public class LastlegOrderService {
csv(order.getVolumeWeightKg()), csv(order.getEstimatedArrivalAt()), csv(order.getActualArrivalAt()),
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()))));
csv(order.getCustomerRemark()), csv(order.getWarehouseRemark()))) + "\r\n";
output.write(line.getBytes(StandardCharsets.UTF_8));
}
output.flush();
if (orders.size() < batchSize) break;
page++;
}
return ("\uFEFF" + String.join("\r\n", lines)).getBytes(StandardCharsets.UTF_8);
}
private LambdaQueryWrapper<LastlegOrder> filter(String customerId, LastlegOrderRequests.Query query) {
@@ -224,7 +416,20 @@ public class LastlegOrderService {
}
}
private LastlegOrderSummary summary(LastlegOrder o) {
private Map<Long, List<LastlegOrderMapper.PalletMergeRelationRow>> palletMergeRelations(List<LastlegOrder> orders) {
if (orders.isEmpty()) return Map.of();
Map<Long, List<LastlegOrderMapper.PalletMergeRelationRow>> result = new LinkedHashMap<>();
List<LastlegOrderMapper.PalletMergeRelationRow> rows = orderMapper.findPalletMergeRelations(
orders.stream().map(LastlegOrder::getId).toList());
if (rows == null) return Map.of();
for (LastlegOrderMapper.PalletMergeRelationRow row : rows) {
result.computeIfAbsent(row.orderId(), ignored -> new ArrayList<>()).add(row);
}
return result;
}
private LastlegOrderSummary summary(LastlegOrder o, List<LastlegOrderMapper.PalletMergeRelationRow> relations) {
List<LastlegOrderMapper.PalletMergeRelationRow> related = relations == null ? List.of() : relations;
return new LastlegOrderSummary(o.getId(), o.getOrderNo(), o.getOrderType(),
LastlegOrderType.labelOf(o.getOrderType()), o.getDestinationType(), o.getSourceInboundId(),
o.getInboundCode(), o.getSourceOrderNo(), o.getCustomerId(), o.getCustomerName(), o.getOverseasWarehouseId(),
@@ -233,7 +438,9 @@ public class LastlegOrderService {
o.getAbnormal(), o.getDetained(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(),
o.getProductQuantity(), o.getVolumeWeightKg(), o.getEstimatedArrivalAt(), o.getActualArrivalAt(),
o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), o.getRelabelOrderNo(), o.getRelabelStatus(),
o.getPalletMergeFlag(), o.getCurrentStatus(), o.getSubOrderStatus(), o.getPrivateRemark(),
o.getPalletMergeFlag(), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::mergeGroupNo).distinct().findFirst().orElse(null),
related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::relatedInboundNo).filter(Objects::nonNull).distinct().toList(),
o.getCurrentStatus(), o.getSubOrderStatus(), o.getPrivateRemark(),
o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt(), o.getUpdatedAt());
}
@@ -82,8 +82,8 @@ public class LoadingCompletedConsumer {
// The loading-plan and container-shipping events are independent aggregate streams.
// Seeding the shipping version from the loading-plan version makes a valid first node
// (aggregateVersion=0) look stale whenever the loading plan has a higher version.
String orderNo = orderNoGenerator.next();
JsonNode rawOrders = envelope.payload().path("orders");
String orderNo = orderNoGenerator.forContainerNo(payload.containerNo());
for (int index = 0; index < payload.orders().size(); index++) {
var source = payload.orders().get(index);
JsonNode rawSource = rawOrders.isArray() && index < rawOrders.size() ? rawOrders.get(index) : objectMapper.createObjectNode();
@@ -216,7 +216,9 @@ public class LoadingCompletedConsumer {
order.setOverseasWarehouseId(null); order.setOverseasWarehouseName(null);
order.setFbaWarehouseCode(source.destinationWarehouseCode()); order.setContainerNo(containerNo);
order.setContainerStatus("待转柜"); order.setAbnormal(false); order.setDetained(false);
order.setPackageCount(source.packageCount()); order.setPalletCount(dropshipPallets.size());
order.setPackageCount(source.packageCount());
order.setPalletCount("ONE_CLICK_DROPSHIP".equals(orderType)
? dropshipPallets.size() : Objects.requireNonNullElse(source.palletCount(), 0));
order.setProductQuantity(source.productQuantity()); order.setVolumeCbm(source.volumeCbm());
order.setEstimatedWarehouseAt(parsePlannedArrival(payload.estimatedWarehouseArrivalAt()));
order.setVolumeWeightKg(source.items().stream().map(FirstLegLoadingPlanCompletedV1.Item::dimensionalWeightKg)
@@ -117,7 +117,7 @@ public class WarehouseTransferService {
LocalDateTime now = LocalDateTime.now();
long transferId = IdWorker.getId();
String transferNo = "WTR" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + transferId;
String transferNo = orderNoGenerator.next("TRANSFER", request.operatingWarehouseId(), transferId);
LastlegOrder order = createTailOrder(customerId, inboundOrderId, transferNo, request, requestHash, now);
WarehouseTransferOrder transfer = new WarehouseTransferOrder();
@@ -464,7 +464,7 @@ public class WarehouseTransferService {
private LastlegOrder createTailOrder(String customerId, Long inboundOrderId, String transferNo,
WarehouseTransferRequests.Create request, String requestHash, LocalDateTime now) {
LastlegOrder order = new LastlegOrder();
order.setId(IdWorker.getId()); order.setOrderNo(orderNoGenerator.next()); order.setCustomerId(customerId);
order.setId(IdWorker.getId()); order.setOrderNo(orderNoGenerator.next("TRANSFER", request.operatingWarehouseId(), order.getId())); order.setCustomerId(customerId);
order.setOrderType(request.needRelabel() ? RETURN_RELABEL_ORDER_TYPE : ORDER_TYPE);
order.setInboundCode(request.inboundCode().trim()); order.setSourceRegion("CUSTOMER");
order.setSourceInboundId("WAREHOUSE_TRANSFER:" + transferNo); order.setRootInboundId(String.valueOf(inboundOrderId));
@@ -505,7 +505,7 @@ public class WarehouseTransferService {
private void createRelabel(String customerId, String operatorId, WarehouseTransferOrder transfer, LastlegOrder order,
WarehouseTransferRequests.Create request, LocalDateTime now) {
long applicationId = IdWorker.getId();
String relabelNo = "FBR" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + applicationId;
String relabelNo = orderNoGenerator.next("RELABEL", request.operatingWarehouseId(), applicationId);
RelabelApplication application = new RelabelApplication();
application.setId(applicationId); application.setCustomerId(customerId); application.setRelabelNo(relabelNo);
application.setLastlegOrderId(order.getId()); application.setCustomerOrderNo(transfer.getTransferNo());
@@ -39,6 +39,7 @@ import java.util.function.Consumer;
@Service
public class UsTruckOrderService {
@Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator;
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. */
@@ -79,7 +80,7 @@ public class UsTruckOrderService {
UsTruckOrder order = new UsTruckOrder();
order.setId(IdWorker.getId());
order.setCustomerId(customerId);
order.setOrderNo("TK" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + order.getId());
order.setOrderNo(orderNoGenerator.next("TRUCK", request.origin(), order.getId()));
order.setTransportType(StringUtils.hasText(request.transportType()) ? norm(request.transportType()) : null);
order.setOrigin(trim(request.origin())); order.setOriginAddress(trim(request.originAddress()));
order.setDestination(trim(request.destination())); order.setDestinationAddress(trim(request.destinationAddress()));