Test #1

Merged
admin merged 1726 commits from test into main 2026-09-17 11:52:45 +08:00
14 changed files with 743 additions and 3 deletions
Showing only changes of commit 93e1540e3f - Show all commits
@@ -16586,16 +16586,50 @@ CREATE TABLE IF NOT EXISTS ll_exception_order (
KEY idx_ll_exception_inbound(customer_id,inbound_code)
) ENGINE=InnoDB COMMENT='海外仓异常订单';
CREATE TABLE IF NOT EXISTS ll_customer_visit (
id BIGINT NOT NULL COMMENT '客户回访记录ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
delivery_order_id BIGINT NOT NULL COMMENT '关联派送单ID',
delivery_no VARCHAR(64) NOT NULL COMMENT '关联派送单号',
customer_name VARCHAR(255) NULL COMMENT '客户名称快照',
contact_name VARCHAR(128) NULL COMMENT '客户联系人姓名快照',
contact_phone VARCHAR(64) NULL COMMENT '客户联系人电话快照',
destination VARCHAR(500) NULL COMMENT '派送目的仓编码汇总快照',
delivery_completed_at DATETIME(3) NOT NULL COMMENT '派送完成时间',
status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '回访状态:PENDING待回访或COMPLETED已回访',
selected_last_mile_order_id BIGINT NULL COMMENT '本次回访选择的尾程订单ID',
selected_last_mile_order_no VARCHAR(64) NULL COMMENT '本次回访选择的尾程订单号',
selected_inbound_order_id BIGINT NULL COMMENT '本次回访选择的入仓单ID',
selected_inbound_code VARCHAR(128) NULL COMMENT '本次回访选择的入仓码',
visit_result VARCHAR(32) NULL COMMENT '最近一次回访结果',
customer_feedback VARCHAR(2000) NULL COMMENT '最近一次客户反馈',
remark VARCHAR(1000) NULL COMMENT '最近一次回访备注',
attachment_json JSON NULL COMMENT '当前回访资料文件元数据列表',
visited_by VARCHAR(128) NULL COMMENT '最近一次回访操作人ID',
visited_at DATETIME(3) NULL COMMENT '最近一次回访时间',
visit_count INT NOT NULL DEFAULT 0 COMMENT '累计保存回访次数',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY(id),
UNIQUE KEY uk_ll_customer_visit_delivery(customer_id,delivery_order_id),
KEY idx_ll_customer_visit_list(customer_id,status,delivery_completed_at),
KEY idx_ll_customer_visit_delivery_no(customer_id,delivery_no)
) ENGINE=InnoDB COMMENT='派送单维度客户回访记录';
INSERT INTO baiyue_iam.iam_permission
(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted)
VALUES
(5107070,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:exception-order:list','海外异常订单','/overseas-warehouse/exception-orders','GET',770,'ACTIVE',1,0),
(5107071,0,'PLATFORM','BUTTON',5107070,'platform:overseas-warehouse:exception-order:create','新增海外异常订单',NULL,'POST',771,'ACTIVE',1,0),
(5107072,0,'PLATFORM','BUTTON',5107070,'platform:overseas-warehouse:delivery:return','派送单标记退货',NULL,'POST',772,'ACTIVE',1,0)
,(5107080,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:customer-visit:list','客户回访','/overseas-warehouse/customer-visits','GET',780,'ACTIVE',1,0)
,(5107081,0,'PLATFORM','BUTTON',5107080,'platform:overseas-warehouse:customer-visit:edit','保存客户回访',NULL,'PUT',781,'ACTIVE',1,0)
,(5107082,0,'PLATFORM','BUTTON',5107080,'platform:overseas-warehouse:delivery:status','编辑派送单状态',NULL,'PUT',782,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),route_path=VALUES(route_path),http_method=VALUES(http_method),sort_no=VALUES(sort_no),status='ACTIVE',built_in=1,deleted=0;
INSERT IGNORE INTO baiyue_iam.iam_role_permission (tenant_id,role_id,permission_id,created_at)
SELECT 0,role.id,permission.id,NOW(3) FROM baiyue_iam.iam_role role
JOIN baiyue_iam.iam_permission permission ON permission.id BETWEEN 5107070 AND 5107072 AND permission.tenant_id=0
JOIN baiyue_iam.iam_permission permission ON permission.id BETWEEN 5107070 AND 5107082 AND permission.tenant_id=0
WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN' AND role.status='ACTIVE' AND role.deleted=0;
CALL baiyue_firstleg.assert_unified_final_schema();
@@ -24,7 +24,8 @@ public class InternalBusinessFileService {
"CONTAINER_DELIVERY_ORDER",
"CONTAINER_PICKUP_ORDER",
"OVERSEAS_UNLOAD_PHOTO",
"OVERSEAS_RELABEL_PHOTO");
"OVERSEAS_RELABEL_PHOTO",
"OVERSEAS_CUSTOMER_VISIT");
@Resource private FileObjectMapper files;
@Resource private BusinessFileStorage storage;
@@ -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 {
}
}
}
@@ -0,0 +1,94 @@
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.boot.autoconfigure.condition.ConditionalOnProperty;
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
@ConditionalOnProperty(name = "lastleg.future-outbound-api.enabled", havingValue = "true")
@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); }
}
@@ -51,6 +51,9 @@ baiyue:
relabel-photo-file:
connect-timeout: ${LASTLEG_RELABEL_PHOTO_FILE_CONNECT_TIMEOUT:3s}
read-timeout: ${LASTLEG_RELABEL_PHOTO_FILE_READ_TIMEOUT:30s}
customer-visit-file:
connect-timeout: ${LASTLEG_CUSTOMER_VISIT_FILE_CONNECT_TIMEOUT:3s}
read-timeout: ${LASTLEG_CUSTOMER_VISIT_FILE_READ_TIMEOUT:30s}
warehouse-resource:
connect-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_CONNECT_TIMEOUT:3s}
read-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_READ_TIMEOUT:5s}
@@ -1720,3 +1720,33 @@ CREATE TABLE IF NOT EXISTS ll_exception_order (
KEY idx_ll_exception_list(customer_id,status,exception_at), KEY idx_ll_exception_tail(customer_id,last_mile_order_no),
KEY idx_ll_exception_inbound(customer_id,inbound_code)
) ENGINE=InnoDB COMMENT='海外仓异常订单';
CREATE TABLE IF NOT EXISTS ll_customer_visit (
id BIGINT NOT NULL COMMENT '客户回访记录ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
delivery_order_id BIGINT NOT NULL COMMENT '关联派送单ID',
delivery_no VARCHAR(64) NOT NULL COMMENT '关联派送单号',
customer_name VARCHAR(255) NULL COMMENT '客户名称快照',
contact_name VARCHAR(128) NULL COMMENT '客户联系人姓名快照',
contact_phone VARCHAR(64) NULL COMMENT '客户联系人电话快照',
destination VARCHAR(500) NULL COMMENT '派送目的仓编码汇总快照',
delivery_completed_at DATETIME(3) NOT NULL COMMENT '派送完成时间',
status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '回访状态:PENDING待回访或COMPLETED已回访',
selected_last_mile_order_id BIGINT NULL COMMENT '本次回访选择的尾程订单ID',
selected_last_mile_order_no VARCHAR(64) NULL COMMENT '本次回访选择的尾程订单号',
selected_inbound_order_id BIGINT NULL COMMENT '本次回访选择的入仓单ID',
selected_inbound_code VARCHAR(128) NULL COMMENT '本次回访选择的入仓码',
visit_result VARCHAR(32) NULL COMMENT '最近一次回访结果',
customer_feedback VARCHAR(2000) NULL COMMENT '最近一次客户反馈',
remark VARCHAR(1000) NULL COMMENT '最近一次回访备注',
attachment_json JSON NULL COMMENT '当前回访资料文件元数据列表',
visited_by VARCHAR(128) NULL COMMENT '最近一次回访操作人ID',
visited_at DATETIME(3) NULL COMMENT '最近一次回访时间',
visit_count INT NOT NULL DEFAULT 0 COMMENT '累计保存回访次数',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY(id),
UNIQUE KEY uk_ll_customer_visit_delivery(customer_id,delivery_order_id),
KEY idx_ll_customer_visit_list(customer_id,status,delivery_completed_at),
KEY idx_ll_customer_visit_delivery_no(customer_id,delivery_no)
) ENGINE=InnoDB COMMENT='派送单维度客户回访记录';
+30
View File
@@ -3865,3 +3865,33 @@ CREATE TABLE IF NOT EXISTS ll_exception_order (
KEY idx_ll_exception_list(customer_id,status,exception_at), KEY idx_ll_exception_tail(customer_id,last_mile_order_no),
KEY idx_ll_exception_inbound(customer_id,inbound_code)
) ENGINE=InnoDB COMMENT='海外仓异常订单';
CREATE TABLE IF NOT EXISTS ll_customer_visit (
id BIGINT NOT NULL COMMENT '客户回访记录ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
delivery_order_id BIGINT NOT NULL COMMENT '关联派送单ID',
delivery_no VARCHAR(64) NOT NULL COMMENT '关联派送单号',
customer_name VARCHAR(255) NULL COMMENT '客户名称快照',
contact_name VARCHAR(128) NULL COMMENT '客户联系人姓名快照',
contact_phone VARCHAR(64) NULL COMMENT '客户联系人电话快照',
destination VARCHAR(500) NULL COMMENT '派送目的仓编码汇总快照',
delivery_completed_at DATETIME(3) NOT NULL COMMENT '派送完成时间',
status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '回访状态:PENDING待回访或COMPLETED已回访',
selected_last_mile_order_id BIGINT NULL COMMENT '本次回访选择的尾程订单ID',
selected_last_mile_order_no VARCHAR(64) NULL COMMENT '本次回访选择的尾程订单号',
selected_inbound_order_id BIGINT NULL COMMENT '本次回访选择的入仓单ID',
selected_inbound_code VARCHAR(128) NULL COMMENT '本次回访选择的入仓码',
visit_result VARCHAR(32) NULL COMMENT '最近一次回访结果',
customer_feedback VARCHAR(2000) NULL COMMENT '最近一次客户反馈',
remark VARCHAR(1000) NULL COMMENT '最近一次回访备注',
attachment_json JSON NULL COMMENT '当前回访资料文件元数据列表',
visited_by VARCHAR(128) NULL COMMENT '最近一次回访操作人ID',
visited_at DATETIME(3) NULL COMMENT '最近一次回访时间',
visit_count INT NOT NULL DEFAULT 0 COMMENT '累计保存回访次数',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY(id),
UNIQUE KEY uk_ll_customer_visit_delivery(customer_id,delivery_order_id),
KEY idx_ll_customer_visit_list(customer_id,status,delivery_completed_at),
KEY idx_ll_customer_visit_delivery_no(customer_id,delivery_no)
) ENGINE=InnoDB COMMENT='派送单维度客户回访记录';