diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 3d6f4f558..f9bd01018 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -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(); diff --git a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java index 86268a298..6ee3f5737 100644 --- a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java +++ b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java @@ -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; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientProperties.java b/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientProperties.java index be6fd6900..0cec9a7e7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientProperties.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientProperties.java @@ -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 { } } } - diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/controller/CustomerVisitController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/controller/CustomerVisitController.java new file mode 100644 index 000000000..c213639e1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/controller/CustomerVisitController.java @@ -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 page(@AuthenticationPrincipal AuthenticatedUser user, + @Valid CustomerVisitRequests.Query query) { + return ApiResponse.success(service.page(scope(user), query)); + } + + @GetMapping("/customer-visits/{visitId}") + public ApiResponse 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 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 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 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 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()); + } + +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/dto/CustomerVisitRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/dto/CustomerVisitRequests.java new file mode 100644 index 000000000..5c7ffcb36 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/dto/CustomerVisitRequests.java @@ -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 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) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/dto/CustomerVisitResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/dto/CustomerVisitResponses.java new file mode 100644 index 000000000..943fb2f28 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/dto/CustomerVisitResponses.java @@ -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 attachments) { } + + public record Page(List 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 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) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/entity/CustomerVisit.java b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/entity/CustomerVisit.java new file mode 100644 index 000000000..d750d39c1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/entity/CustomerVisit.java @@ -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; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/gateway/CustomerVisitFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/gateway/CustomerVisitFileGateway.java new file mode 100644 index 000000000..b3d81e99d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/gateway/CustomerVisitFileGateway.java @@ -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) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/gateway/HttpCustomerVisitFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/gateway/HttpCustomerVisitFileGateway.java new file mode 100644 index 000000000..a301603e2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/gateway/HttpCustomerVisitFileGateway.java @@ -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) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/mapper/CustomerVisitMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/mapper/CustomerVisitMapper.java new file mode 100644 index 000000000..f52254c71 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/mapper/CustomerVisitMapper.java @@ -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 { + @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); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/service/CustomerVisitService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/service/CustomerVisitService.java new file mode 100644 index 000000000..416d19088 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/service/CustomerVisitService.java @@ -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 RESULTS = Set.of("SATISFIED", "NORMAL", "FOLLOW_UP", "NO_CONTACT"); + private static final long MAX_FILE_BYTES = 20L * 1024L * 1024L; + private static final Set 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 filter = filter(customerId, query); + Page result = visits.selectPage(new Page<>(query.page(), query.size()), + filter.orderByDesc(CustomerVisit::getDeliveryCompletedAt).orderByDesc(CustomerVisit::getId)); + LambdaQueryWrapper countScope = new LambdaQueryWrapper() + .eq(StringUtils.hasText(customerId), CustomerVisit::getCustomerId, customerId); + long visitable = visits.selectCount(countScope); + long pending = visits.selectCount(new LambdaQueryWrapper() + .eq(StringUtils.hasText(customerId), CustomerVisit::getCustomerId, customerId) + .eq(CustomerVisit::getStatus, "PENDING")); + long completed = visits.selectCount(new LambdaQueryWrapper() + .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() + .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 attachments = new ArrayList<>(); + Map 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 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 inboundOptions(CustomerVisit visit) { + List orders = lastMileOrders.selectList(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, visit.getCustomerId()) + .eq(LastlegOrder::getDeliveryOrderNo, visit.getDeliveryNo()) + .orderByAsc(LastlegOrder::getInboundCode)); + if (orders.isEmpty()) return List.of(); + List codes = orders.stream().map(LastlegOrder::getInboundCode).filter(StringUtils::hasText).distinct().toList(); + Map byCode = new LinkedHashMap<>(); + if (!codes.isEmpty()) inboundOrders.selectList(new LambdaQueryWrapper() + .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 orders(DeliveryOrder delivery) { + return lastMileOrders.selectList(new LambdaQueryWrapper() + .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 filter(String customerId, CustomerVisitRequests.Query query) { + return new LambdaQueryWrapper() + .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() + .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 values) { + try { return objectMapper.writeValueAsString(values); } + catch (JsonProcessingException exception) { throw new IllegalStateException("无法保存回访资料", exception); } + } + + private List 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); } +} diff --git a/lastleg-service/src/main/resources/application.yml b/lastleg-service/src/main/resources/application.yml index c9eaf0cdc..dfdda1790 100644 --- a/lastleg-service/src/main/resources/application.yml +++ b/lastleg-service/src/main/resources/application.yml @@ -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} diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 6d969864d..4c6221194 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -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='派送单维度客户回访记录'; diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index c246f4826..40ffbc876 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -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='派送单维度客户回访记录';