OMS对接
parent
42c21cc4fc
commit
dee5da7655
|
|
@ -1,6 +1,8 @@
|
|||
package com.unis.crm;
|
||||
|
||||
import com.unis.crm.config.WecomProperties;
|
||||
import com.unis.crm.config.InternalAuthProperties;
|
||||
import com.unis.crm.config.OmsProperties;
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
|
@ -8,7 +10,7 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
|
|||
|
||||
@SpringBootApplication(scanBasePackages = "com.unis.crm")
|
||||
@MapperScan("com.unis.crm.mapper")
|
||||
@EnableConfigurationProperties(WecomProperties.class)
|
||||
@EnableConfigurationProperties({WecomProperties.class, OmsProperties.class, InternalAuthProperties.class})
|
||||
public class UnisCrmBackendApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
|
|
|||
|
|
@ -5,14 +5,22 @@ import java.time.OffsetDateTime;
|
|||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.MissingRequestHeaderException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.method.annotation.HandlerMethodValidationException;
|
||||
import org.springframework.validation.method.ParameterValidationResult;
|
||||
|
||||
@RestControllerAdvice
|
||||
public class CrmGlobalExceptionHandler {
|
||||
|
||||
private static final String CURRENT_USER_HEADER = "X-User-Id";
|
||||
private static final String UNAUTHORIZED_MESSAGE = "登录已失效,请重新登录";
|
||||
|
||||
@ExceptionHandler(BusinessException.class)
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
public ApiResponse<Object> handleBusinessException(BusinessException ex) {
|
||||
|
|
@ -41,6 +49,27 @@ public class CrmGlobalExceptionHandler {
|
|||
return ApiResponse.fail(ex.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(UnauthorizedException.class)
|
||||
public ResponseEntity<ApiResponse<Object>> handleUnauthorizedException(UnauthorizedException ex) {
|
||||
return errorResponse(HttpStatus.UNAUTHORIZED, ex.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(MissingRequestHeaderException.class)
|
||||
public ResponseEntity<ApiResponse<Object>> handleMissingRequestHeaderException(MissingRequestHeaderException ex) {
|
||||
if (CURRENT_USER_HEADER.equals(ex.getHeaderName())) {
|
||||
return errorResponse(HttpStatus.UNAUTHORIZED, UNAUTHORIZED_MESSAGE);
|
||||
}
|
||||
return errorResponse(HttpStatus.BAD_REQUEST, ex.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(HandlerMethodValidationException.class)
|
||||
public ResponseEntity<ApiResponse<Object>> handleHandlerMethodValidationException(HandlerMethodValidationException ex) {
|
||||
if (containsInvalidCurrentUserHeader(ex)) {
|
||||
return errorResponse(HttpStatus.UNAUTHORIZED, UNAUTHORIZED_MESSAGE);
|
||||
}
|
||||
return errorResponse(HttpStatus.BAD_REQUEST, ex.getBody().getDetail());
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
public Map<String, Object> handleUnexpectedException(Exception ex, HttpServletRequest request) {
|
||||
|
|
@ -52,4 +81,32 @@ public class CrmGlobalExceptionHandler {
|
|||
body.put("path", request.getRequestURI());
|
||||
return body;
|
||||
}
|
||||
|
||||
private ResponseEntity<ApiResponse<Object>> errorResponse(HttpStatus status, String message) {
|
||||
return ResponseEntity.status(status).body(ApiResponse.fail(message));
|
||||
}
|
||||
|
||||
private boolean containsInvalidCurrentUserHeader(HandlerMethodValidationException ex) {
|
||||
for (ParameterValidationResult result : ex.getAllValidationResults()) {
|
||||
RequestHeader requestHeader = result.getMethodParameter().getParameterAnnotation(RequestHeader.class);
|
||||
if (requestHeader == null) {
|
||||
continue;
|
||||
}
|
||||
String headerName = resolveHeaderName(requestHeader, result);
|
||||
if (CURRENT_USER_HEADER.equals(headerName)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private String resolveHeaderName(RequestHeader requestHeader, ParameterValidationResult result) {
|
||||
if (!requestHeader.name().isBlank()) {
|
||||
return requestHeader.name();
|
||||
}
|
||||
if (!requestHeader.value().isBlank()) {
|
||||
return requestHeader.value();
|
||||
}
|
||||
return result.getMethodParameter().getParameterName();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@ package com.unis.crm.common;
|
|||
|
||||
public final class CurrentUserUtils {
|
||||
|
||||
private static final String UNAUTHORIZED_MESSAGE = "登录已失效,请重新登录";
|
||||
|
||||
private CurrentUserUtils() {
|
||||
}
|
||||
|
||||
public static Long requireCurrentUserId(Long headerUserId) {
|
||||
if (headerUserId == null || headerUserId <= 0) {
|
||||
throw new BusinessException("未识别到当前登录用户");
|
||||
throw new UnauthorizedException(UNAUTHORIZED_MESSAGE);
|
||||
}
|
||||
return headerUserId;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,171 @@
|
|||
package com.unis.crm.common;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import javax.sql.DataSource;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class OpportunitySchemaInitializer implements ApplicationRunner {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(OpportunitySchemaInitializer.class);
|
||||
|
||||
private final DataSource dataSource;
|
||||
|
||||
public OpportunitySchemaInitializer(DataSource dataSource) {
|
||||
this.dataSource = dataSource;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(ApplicationArguments args) {
|
||||
try (Connection connection = dataSource.getConnection()) {
|
||||
if (!tableExists(connection, "crm_opportunity")) {
|
||||
return;
|
||||
}
|
||||
try (Statement statement = connection.createStatement()) {
|
||||
statement.execute("alter table crm_opportunity add column if not exists pre_sales_id bigint");
|
||||
statement.execute("alter table crm_opportunity add column if not exists pre_sales_name varchar(100)");
|
||||
}
|
||||
ensureConfidenceGradeStorage(connection);
|
||||
migrateLegacyOmsProjectCode(connection);
|
||||
log.info("Ensured compatibility columns exist for crm_opportunity");
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Failed to initialize crm_opportunity schema compatibility", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureConfidenceGradeStorage(Connection connection) throws SQLException {
|
||||
String dataType = findColumnDataType(connection, "crm_opportunity", "confidence_pct");
|
||||
if (dataType == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try (Statement statement = connection.createStatement()) {
|
||||
statement.execute("alter table crm_opportunity drop constraint if exists crm_opportunity_confidence_pct_check");
|
||||
if (isNumericType(dataType)) {
|
||||
statement.execute("""
|
||||
alter table crm_opportunity
|
||||
alter column confidence_pct type varchar(1)
|
||||
using case
|
||||
when confidence_pct >= 80 then 'A'
|
||||
when confidence_pct >= 60 then 'B'
|
||||
else 'C'
|
||||
end
|
||||
""");
|
||||
} else {
|
||||
statement.execute("""
|
||||
update crm_opportunity
|
||||
set confidence_pct = case
|
||||
when upper(btrim(confidence_pct)) = 'A' then 'A'
|
||||
when upper(btrim(confidence_pct)) = 'B' then 'B'
|
||||
when upper(btrim(confidence_pct)) = 'C' then 'C'
|
||||
when btrim(confidence_pct) ~ '^[0-9]+(\\.[0-9]+)?$' and btrim(confidence_pct)::numeric >= 80 then 'A'
|
||||
when btrim(confidence_pct) ~ '^[0-9]+(\\.[0-9]+)?$' and btrim(confidence_pct)::numeric >= 60 then 'B'
|
||||
else 'C'
|
||||
end
|
||||
where confidence_pct is distinct from case
|
||||
when upper(btrim(confidence_pct)) = 'A' then 'A'
|
||||
when upper(btrim(confidence_pct)) = 'B' then 'B'
|
||||
when upper(btrim(confidence_pct)) = 'C' then 'C'
|
||||
when btrim(confidence_pct) ~ '^[0-9]+(\\.[0-9]+)?$' and btrim(confidence_pct)::numeric >= 80 then 'A'
|
||||
when btrim(confidence_pct) ~ '^[0-9]+(\\.[0-9]+)?$' and btrim(confidence_pct)::numeric >= 60 then 'B'
|
||||
else 'C'
|
||||
end
|
||||
""");
|
||||
statement.execute("alter table crm_opportunity alter column confidence_pct type varchar(1)");
|
||||
}
|
||||
statement.execute("alter table crm_opportunity alter column confidence_pct set default 'C'");
|
||||
statement.execute("alter table crm_opportunity alter column confidence_pct set not null");
|
||||
statement.execute("""
|
||||
do $$
|
||||
begin
|
||||
if not exists (
|
||||
select 1
|
||||
from pg_constraint
|
||||
where conname = 'crm_opportunity_confidence_pct_check'
|
||||
) then
|
||||
alter table crm_opportunity
|
||||
add constraint crm_opportunity_confidence_pct_check
|
||||
check (confidence_pct in ('A', 'B', 'C'));
|
||||
end if;
|
||||
end
|
||||
$$;
|
||||
""");
|
||||
statement.execute("comment on column crm_opportunity.confidence_pct is '把握度等级(A/B/C)'");
|
||||
}
|
||||
}
|
||||
|
||||
private String findColumnDataType(Connection connection, String tableName, String columnName) throws SQLException {
|
||||
String sql = """
|
||||
select data_type
|
||||
from information_schema.columns
|
||||
where table_schema = current_schema()
|
||||
and table_name = ?
|
||||
and column_name = ?
|
||||
limit 1
|
||||
""";
|
||||
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setString(1, tableName);
|
||||
statement.setString(2, columnName);
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
if (resultSet.next()) {
|
||||
return resultSet.getString("data_type");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isNumericType(String dataType) {
|
||||
return "smallint".equalsIgnoreCase(dataType)
|
||||
|| "integer".equalsIgnoreCase(dataType)
|
||||
|| "bigint".equalsIgnoreCase(dataType)
|
||||
|| "numeric".equalsIgnoreCase(dataType)
|
||||
|| "decimal".equalsIgnoreCase(dataType);
|
||||
}
|
||||
|
||||
private void migrateLegacyOmsProjectCode(Connection connection) throws SQLException {
|
||||
String legacyColumnType = findColumnDataType(connection, "crm_opportunity", "oms_project_code");
|
||||
if (legacyColumnType == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try (Statement statement = connection.createStatement()) {
|
||||
statement.execute("""
|
||||
update crm_opportunity
|
||||
set opportunity_code = btrim(oms_project_code)
|
||||
where coalesce(nullif(btrim(oms_project_code), ''), '') <> ''
|
||||
and opportunity_code like 'OPP-%'
|
||||
and opportunity_code is distinct from btrim(oms_project_code)
|
||||
and not exists (
|
||||
select 1
|
||||
from crm_opportunity other
|
||||
where other.id <> crm_opportunity.id
|
||||
and other.opportunity_code = btrim(crm_opportunity.oms_project_code)
|
||||
)
|
||||
""");
|
||||
statement.execute("alter table crm_opportunity drop column if exists oms_project_code");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean tableExists(Connection connection, String tableName) throws SQLException {
|
||||
if (existsInSchema(connection, null, tableName)) {
|
||||
return true;
|
||||
}
|
||||
return existsInSchema(connection, "public", tableName);
|
||||
}
|
||||
|
||||
private boolean existsInSchema(Connection connection, String schemaName, String tableName) throws SQLException {
|
||||
try (ResultSet resultSet = connection.getMetaData()
|
||||
.getTables(connection.getCatalog(), schemaName, tableName, new String[]{"TABLE"})) {
|
||||
return resultSet.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.unis.crm.common;
|
||||
|
||||
public class UnauthorizedException extends RuntimeException {
|
||||
|
||||
public UnauthorizedException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package com.unis.crm.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@ConfigurationProperties(prefix = "unisbase.internal-auth")
|
||||
public class InternalAuthProperties {
|
||||
|
||||
private boolean enabled = true;
|
||||
private String secret = "change-me-internal-secret";
|
||||
private String headerName = "X-Internal-Secret";
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public String getSecret() {
|
||||
return secret;
|
||||
}
|
||||
|
||||
public void setSecret(String secret) {
|
||||
this.secret = secret;
|
||||
}
|
||||
|
||||
public String getHeaderName() {
|
||||
return headerName;
|
||||
}
|
||||
|
||||
public void setHeaderName(String headerName) {
|
||||
this.headerName = headerName;
|
||||
}
|
||||
|
||||
public String resolveHeaderName() {
|
||||
if (headerName == null || headerName.isBlank()) {
|
||||
return "X-Internal-Secret";
|
||||
}
|
||||
return headerName;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package com.unis.crm.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
@Configuration
|
||||
public class InternalIntegrationSecurityConfig {
|
||||
|
||||
@Bean
|
||||
@Order(1)
|
||||
public SecurityFilterChain internalIntegrationSecurityFilterChain(HttpSecurity http) throws Exception {
|
||||
http.securityMatcher("/api/opportunities/integration/**")
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.cors(Customizer.withDefaults())
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
|
||||
return http.build();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
package com.unis.crm.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@ConfigurationProperties(prefix = "unisbase.app.oms")
|
||||
public class OmsProperties {
|
||||
|
||||
private boolean enabled = true;
|
||||
private String baseUrl;
|
||||
private String apiKey;
|
||||
private String apiKeyHeader = "apiKey";
|
||||
private String userInfoPath = "/api/v1/user/info";
|
||||
private String userAddPath = "/api/v1/user/add";
|
||||
private String projectAddPath = "/api/v1/project/add";
|
||||
private String preSalesRoleName = "售前";
|
||||
private long connectTimeoutSeconds = 5;
|
||||
private long readTimeoutSeconds = 15;
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public String getBaseUrl() {
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
public void setBaseUrl(String baseUrl) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
public String getApiKey() {
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
public void setApiKey(String apiKey) {
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
public String getApiKeyHeader() {
|
||||
return apiKeyHeader;
|
||||
}
|
||||
|
||||
public void setApiKeyHeader(String apiKeyHeader) {
|
||||
this.apiKeyHeader = apiKeyHeader;
|
||||
}
|
||||
|
||||
public String getUserInfoPath() {
|
||||
return userInfoPath;
|
||||
}
|
||||
|
||||
public void setUserInfoPath(String userInfoPath) {
|
||||
this.userInfoPath = userInfoPath;
|
||||
}
|
||||
|
||||
public String getUserAddPath() {
|
||||
return userAddPath;
|
||||
}
|
||||
|
||||
public void setUserAddPath(String userAddPath) {
|
||||
this.userAddPath = userAddPath;
|
||||
}
|
||||
|
||||
public String getProjectAddPath() {
|
||||
return projectAddPath;
|
||||
}
|
||||
|
||||
public void setProjectAddPath(String projectAddPath) {
|
||||
this.projectAddPath = projectAddPath;
|
||||
}
|
||||
|
||||
public String getPreSalesRoleName() {
|
||||
return preSalesRoleName;
|
||||
}
|
||||
|
||||
public void setPreSalesRoleName(String preSalesRoleName) {
|
||||
this.preSalesRoleName = preSalesRoleName;
|
||||
}
|
||||
|
||||
public long getConnectTimeoutSeconds() {
|
||||
return connectTimeoutSeconds;
|
||||
}
|
||||
|
||||
public void setConnectTimeoutSeconds(long connectTimeoutSeconds) {
|
||||
this.connectTimeoutSeconds = connectTimeoutSeconds;
|
||||
}
|
||||
|
||||
public long getReadTimeoutSeconds() {
|
||||
return readTimeoutSeconds;
|
||||
}
|
||||
|
||||
public void setReadTimeoutSeconds(long readTimeoutSeconds) {
|
||||
this.readTimeoutSeconds = readTimeoutSeconds;
|
||||
}
|
||||
}
|
||||
|
|
@ -5,12 +5,14 @@ import com.unis.crm.common.CurrentUserUtils;
|
|||
import com.unis.crm.dto.expansion.CreateChannelExpansionRequest;
|
||||
import com.unis.crm.dto.expansion.CreateExpansionFollowUpRequest;
|
||||
import com.unis.crm.dto.expansion.CreateSalesExpansionRequest;
|
||||
import com.unis.crm.dto.expansion.DictOptionDTO;
|
||||
import com.unis.crm.dto.expansion.ExpansionMetaDTO;
|
||||
import com.unis.crm.dto.expansion.ExpansionOverviewDTO;
|
||||
import com.unis.crm.dto.expansion.UpdateChannelExpansionRequest;
|
||||
import com.unis.crm.dto.expansion.UpdateSalesExpansionRequest;
|
||||
import com.unis.crm.service.ExpansionService;
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.List;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
|
|
@ -37,6 +39,14 @@ public class ExpansionController {
|
|||
return ApiResponse.success(expansionService.getMeta());
|
||||
}
|
||||
|
||||
@GetMapping("/areas/cities")
|
||||
public ApiResponse<List<DictOptionDTO>> getCityOptions(
|
||||
@RequestHeader("X-User-Id") Long userId,
|
||||
@RequestParam("provinceName") String provinceName) {
|
||||
CurrentUserUtils.requireCurrentUserId(userId);
|
||||
return ApiResponse.success(expansionService.getCityOptions(provinceName));
|
||||
}
|
||||
|
||||
@GetMapping("/overview")
|
||||
public ApiResponse<ExpansionOverviewDTO> getOverview(
|
||||
@RequestHeader("X-User-Id") Long userId,
|
||||
|
|
|
|||
|
|
@ -4,10 +4,13 @@ import com.unis.crm.common.ApiResponse;
|
|||
import com.unis.crm.common.CurrentUserUtils;
|
||||
import com.unis.crm.dto.opportunity.CreateOpportunityFollowUpRequest;
|
||||
import com.unis.crm.dto.opportunity.CreateOpportunityRequest;
|
||||
import com.unis.crm.dto.opportunity.OmsPreSalesOptionDTO;
|
||||
import com.unis.crm.dto.opportunity.OpportunityMetaDTO;
|
||||
import com.unis.crm.dto.opportunity.OpportunityOverviewDTO;
|
||||
import com.unis.crm.dto.opportunity.PushOpportunityToOmsRequest;
|
||||
import com.unis.crm.service.OpportunityService;
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.List;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
|
|
@ -41,6 +44,11 @@ public class OpportunityController {
|
|||
return ApiResponse.success(opportunityService.getOverview(CurrentUserUtils.requireCurrentUserId(userId), keyword, stage));
|
||||
}
|
||||
|
||||
@GetMapping("/oms/pre-sales")
|
||||
public ApiResponse<List<OmsPreSalesOptionDTO>> getOmsPreSalesOptions(@RequestHeader("X-User-Id") Long userId) {
|
||||
return ApiResponse.success(opportunityService.getOmsPreSalesOptions(CurrentUserUtils.requireCurrentUserId(userId)));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ApiResponse<Long> createOpportunity(
|
||||
@RequestHeader("X-User-Id") Long userId,
|
||||
|
|
@ -59,8 +67,9 @@ public class OpportunityController {
|
|||
@PostMapping("/{opportunityId}/push-oms")
|
||||
public ApiResponse<Long> pushToOms(
|
||||
@RequestHeader("X-User-Id") Long userId,
|
||||
@PathVariable("opportunityId") Long opportunityId) {
|
||||
return ApiResponse.success(opportunityService.pushToOms(CurrentUserUtils.requireCurrentUserId(userId), opportunityId));
|
||||
@PathVariable("opportunityId") Long opportunityId,
|
||||
@RequestBody(required = false) PushOpportunityToOmsRequest request) {
|
||||
return ApiResponse.success(opportunityService.pushToOms(CurrentUserUtils.requireCurrentUserId(userId), opportunityId, request));
|
||||
}
|
||||
|
||||
@PostMapping("/{opportunityId}/followups")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
package com.unis.crm.controller;
|
||||
|
||||
import com.unis.crm.common.ApiResponse;
|
||||
import com.unis.crm.common.UnauthorizedException;
|
||||
import com.unis.crm.config.InternalAuthProperties;
|
||||
import com.unis.crm.dto.opportunity.UpdateOpportunityIntegrationRequest;
|
||||
import com.unis.crm.service.OpportunityService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.Objects;
|
||||
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.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/opportunities/integration")
|
||||
public class OpportunityIntegrationController {
|
||||
|
||||
private static final String UNAUTHORIZED_MESSAGE = "内部接口鉴权失败";
|
||||
|
||||
private final OpportunityService opportunityService;
|
||||
private final InternalAuthProperties internalAuthProperties;
|
||||
|
||||
public OpportunityIntegrationController(
|
||||
OpportunityService opportunityService,
|
||||
InternalAuthProperties internalAuthProperties) {
|
||||
this.opportunityService = opportunityService;
|
||||
this.internalAuthProperties = internalAuthProperties;
|
||||
}
|
||||
|
||||
@PutMapping("/update")
|
||||
public ApiResponse<Long> updateOpportunity(
|
||||
HttpServletRequest httpServletRequest,
|
||||
@Valid @RequestBody UpdateOpportunityIntegrationRequest request) {
|
||||
validateInternalSecret(httpServletRequest);
|
||||
return ApiResponse.success(opportunityService.updateOpportunityByIntegration(request));
|
||||
}
|
||||
|
||||
private void validateInternalSecret(HttpServletRequest httpServletRequest) {
|
||||
if (!internalAuthProperties.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
String expectedSecret = trimToNull(internalAuthProperties.getSecret());
|
||||
String actualSecret = trimToNull(httpServletRequest.getHeader(internalAuthProperties.resolveHeaderName()));
|
||||
if (expectedSecret == null || !Objects.equals(expectedSecret, actualSecret)) {
|
||||
throw new UnauthorizedException(UNAUTHORIZED_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
private String trimToNull(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
return trimmed.isEmpty() ? null : trimmed;
|
||||
}
|
||||
}
|
||||
|
|
@ -9,9 +9,14 @@ public class ChannelExpansionItemDTO {
|
|||
private String type;
|
||||
private String channelCode;
|
||||
private String name;
|
||||
private String provinceCode;
|
||||
private String province;
|
||||
private String cityCode;
|
||||
private String city;
|
||||
private String officeAddress;
|
||||
private String channelIndustryCode;
|
||||
private String channelIndustry;
|
||||
private String certificationLevel;
|
||||
private String annualRevenue;
|
||||
private String revenue;
|
||||
private Integer size;
|
||||
|
|
@ -71,6 +76,14 @@ public class ChannelExpansionItemDTO {
|
|||
return province;
|
||||
}
|
||||
|
||||
public String getProvinceCode() {
|
||||
return provinceCode;
|
||||
}
|
||||
|
||||
public void setProvinceCode(String provinceCode) {
|
||||
this.provinceCode = provinceCode;
|
||||
}
|
||||
|
||||
public void setProvince(String province) {
|
||||
this.province = province;
|
||||
}
|
||||
|
|
@ -79,6 +92,22 @@ public class ChannelExpansionItemDTO {
|
|||
return officeAddress;
|
||||
}
|
||||
|
||||
public String getCityCode() {
|
||||
return cityCode;
|
||||
}
|
||||
|
||||
public void setCityCode(String cityCode) {
|
||||
this.cityCode = cityCode;
|
||||
}
|
||||
|
||||
public String getCity() {
|
||||
return city;
|
||||
}
|
||||
|
||||
public void setCity(String city) {
|
||||
this.city = city;
|
||||
}
|
||||
|
||||
public void setOfficeAddress(String officeAddress) {
|
||||
this.officeAddress = officeAddress;
|
||||
}
|
||||
|
|
@ -87,10 +116,26 @@ public class ChannelExpansionItemDTO {
|
|||
return channelIndustry;
|
||||
}
|
||||
|
||||
public String getChannelIndustryCode() {
|
||||
return channelIndustryCode;
|
||||
}
|
||||
|
||||
public void setChannelIndustryCode(String channelIndustryCode) {
|
||||
this.channelIndustryCode = channelIndustryCode;
|
||||
}
|
||||
|
||||
public void setChannelIndustry(String channelIndustry) {
|
||||
this.channelIndustry = channelIndustry;
|
||||
}
|
||||
|
||||
public String getCertificationLevel() {
|
||||
return certificationLevel;
|
||||
}
|
||||
|
||||
public void setCertificationLevel(String certificationLevel) {
|
||||
this.certificationLevel = certificationLevel;
|
||||
}
|
||||
|
||||
public String getAnnualRevenue() {
|
||||
return annualRevenue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ public class CreateChannelExpansionRequest {
|
|||
private String channelCode;
|
||||
private String officeAddress;
|
||||
private String channelIndustry;
|
||||
private String city;
|
||||
private String certificationLevel;
|
||||
|
||||
@NotBlank(message = "渠道名称不能为空")
|
||||
@Size(max = 200, message = "渠道名称不能超过200字符")
|
||||
|
|
@ -64,6 +66,22 @@ public class CreateChannelExpansionRequest {
|
|||
this.channelIndustry = channelIndustry;
|
||||
}
|
||||
|
||||
public String getCity() {
|
||||
return city;
|
||||
}
|
||||
|
||||
public void setCity(String city) {
|
||||
this.city = city;
|
||||
}
|
||||
|
||||
public String getCertificationLevel() {
|
||||
return certificationLevel;
|
||||
}
|
||||
|
||||
public void setCertificationLevel(String certificationLevel) {
|
||||
this.certificationLevel = certificationLevel;
|
||||
}
|
||||
|
||||
public String getChannelName() {
|
||||
return channelName;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ public class ExpansionMetaDTO {
|
|||
|
||||
private List<DictOptionDTO> officeOptions;
|
||||
private List<DictOptionDTO> industryOptions;
|
||||
private List<DictOptionDTO> provinceOptions;
|
||||
private List<DictOptionDTO> certificationLevelOptions;
|
||||
private List<DictOptionDTO> channelAttributeOptions;
|
||||
private List<DictOptionDTO> internalAttributeOptions;
|
||||
private String nextChannelCode;
|
||||
|
|
@ -16,11 +18,15 @@ public class ExpansionMetaDTO {
|
|||
public ExpansionMetaDTO(
|
||||
List<DictOptionDTO> officeOptions,
|
||||
List<DictOptionDTO> industryOptions,
|
||||
List<DictOptionDTO> provinceOptions,
|
||||
List<DictOptionDTO> certificationLevelOptions,
|
||||
List<DictOptionDTO> channelAttributeOptions,
|
||||
List<DictOptionDTO> internalAttributeOptions,
|
||||
String nextChannelCode) {
|
||||
this.officeOptions = officeOptions;
|
||||
this.industryOptions = industryOptions;
|
||||
this.provinceOptions = provinceOptions;
|
||||
this.certificationLevelOptions = certificationLevelOptions;
|
||||
this.channelAttributeOptions = channelAttributeOptions;
|
||||
this.internalAttributeOptions = internalAttributeOptions;
|
||||
this.nextChannelCode = nextChannelCode;
|
||||
|
|
@ -42,6 +48,22 @@ public class ExpansionMetaDTO {
|
|||
this.industryOptions = industryOptions;
|
||||
}
|
||||
|
||||
public List<DictOptionDTO> getProvinceOptions() {
|
||||
return provinceOptions;
|
||||
}
|
||||
|
||||
public void setProvinceOptions(List<DictOptionDTO> provinceOptions) {
|
||||
this.provinceOptions = provinceOptions;
|
||||
}
|
||||
|
||||
public List<DictOptionDTO> getCertificationLevelOptions() {
|
||||
return certificationLevelOptions;
|
||||
}
|
||||
|
||||
public void setCertificationLevelOptions(List<DictOptionDTO> certificationLevelOptions) {
|
||||
this.certificationLevelOptions = certificationLevelOptions;
|
||||
}
|
||||
|
||||
public List<DictOptionDTO> getChannelAttributeOptions() {
|
||||
return channelAttributeOptions;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ public class UpdateChannelExpansionRequest {
|
|||
private String channelCode;
|
||||
private String officeAddress;
|
||||
private String channelIndustry;
|
||||
private String city;
|
||||
private String certificationLevel;
|
||||
|
||||
@NotBlank(message = "渠道名称不能为空")
|
||||
@Size(max = 200, message = "渠道名称不能超过200字符")
|
||||
|
|
@ -55,6 +57,22 @@ public class UpdateChannelExpansionRequest {
|
|||
this.channelIndustry = channelIndustry;
|
||||
}
|
||||
|
||||
public String getCity() {
|
||||
return city;
|
||||
}
|
||||
|
||||
public void setCity(String city) {
|
||||
this.city = city;
|
||||
}
|
||||
|
||||
public String getCertificationLevel() {
|
||||
return certificationLevel;
|
||||
}
|
||||
|
||||
public void setCertificationLevel(String certificationLevel) {
|
||||
this.certificationLevel = certificationLevel;
|
||||
}
|
||||
|
||||
public String getChannelName() {
|
||||
return channelName;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
package com.unis.crm.dto.opportunity;
|
||||
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
|
|
@ -31,10 +30,9 @@ public class CreateOpportunityRequest {
|
|||
|
||||
private LocalDate expectedCloseDate;
|
||||
|
||||
@NotNull(message = "把握度不能为空")
|
||||
@Min(value = 0, message = "把握度不能低于0")
|
||||
@Max(value = 100, message = "把握度不能高于100")
|
||||
private Integer confidencePct;
|
||||
@NotBlank(message = "把握度不能为空")
|
||||
@Pattern(regexp = "^(A|B|C|a|b|c|40|60|80)$", message = "把握度仅支持A、B、C")
|
||||
private String confidencePct;
|
||||
|
||||
private String stage;
|
||||
private String opportunityType;
|
||||
|
|
@ -43,7 +41,6 @@ public class CreateOpportunityRequest {
|
|||
private Long salesExpansionId;
|
||||
private Long channelExpansionId;
|
||||
private String competitorName;
|
||||
private Boolean pushedToOms;
|
||||
private String description;
|
||||
|
||||
public Long getId() {
|
||||
|
|
@ -102,11 +99,11 @@ public class CreateOpportunityRequest {
|
|||
this.expectedCloseDate = expectedCloseDate;
|
||||
}
|
||||
|
||||
public Integer getConfidencePct() {
|
||||
public String getConfidencePct() {
|
||||
return confidencePct;
|
||||
}
|
||||
|
||||
public void setConfidencePct(Integer confidencePct) {
|
||||
public void setConfidencePct(String confidencePct) {
|
||||
this.confidencePct = confidencePct;
|
||||
}
|
||||
|
||||
|
|
@ -166,14 +163,6 @@ public class CreateOpportunityRequest {
|
|||
this.competitorName = competitorName;
|
||||
}
|
||||
|
||||
public Boolean getPushedToOms() {
|
||||
return pushedToOms;
|
||||
}
|
||||
|
||||
public void setPushedToOms(Boolean pushedToOms) {
|
||||
this.pushedToOms = pushedToOms;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
package com.unis.crm.dto.opportunity;
|
||||
|
||||
public class CurrentUserAccountDTO {
|
||||
|
||||
private Long userId;
|
||||
private String username;
|
||||
private String displayName;
|
||||
|
||||
public Long getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public void setUserId(Long userId) {
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public void setUsername(String username) {
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
public void setDisplayName(String displayName) {
|
||||
this.displayName = displayName;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
package com.unis.crm.dto.opportunity;
|
||||
|
||||
public class OmsPreSalesOptionDTO {
|
||||
|
||||
private Long userId;
|
||||
private String loginName;
|
||||
private String userName;
|
||||
|
||||
public Long getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public void setUserId(Long userId) {
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
public String getLoginName() {
|
||||
return loginName;
|
||||
}
|
||||
|
||||
public void setLoginName(String loginName) {
|
||||
this.loginName = loginName;
|
||||
}
|
||||
|
||||
public String getUserName() {
|
||||
return userName;
|
||||
}
|
||||
|
||||
public void setUserName(String userName) {
|
||||
this.userName = userName;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
package com.unis.crm.dto.opportunity;
|
||||
|
||||
public class OpportunityIntegrationTargetDTO {
|
||||
|
||||
private Long id;
|
||||
private String opportunityCode;
|
||||
private Long ownerUserId;
|
||||
private String source;
|
||||
private String operatorName;
|
||||
private Long salesExpansionId;
|
||||
private Long channelExpansionId;
|
||||
private String stage;
|
||||
private String status;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getOpportunityCode() {
|
||||
return opportunityCode;
|
||||
}
|
||||
|
||||
public void setOpportunityCode(String opportunityCode) {
|
||||
this.opportunityCode = opportunityCode;
|
||||
}
|
||||
|
||||
public Long getOwnerUserId() {
|
||||
return ownerUserId;
|
||||
}
|
||||
|
||||
public void setOwnerUserId(Long ownerUserId) {
|
||||
this.ownerUserId = ownerUserId;
|
||||
}
|
||||
|
||||
public String getSource() {
|
||||
return source;
|
||||
}
|
||||
|
||||
public void setSource(String source) {
|
||||
this.source = source;
|
||||
}
|
||||
|
||||
public String getOperatorName() {
|
||||
return operatorName;
|
||||
}
|
||||
|
||||
public void setOperatorName(String operatorName) {
|
||||
this.operatorName = operatorName;
|
||||
}
|
||||
|
||||
public Long getSalesExpansionId() {
|
||||
return salesExpansionId;
|
||||
}
|
||||
|
||||
public void setSalesExpansionId(Long salesExpansionId) {
|
||||
this.salesExpansionId = salesExpansionId;
|
||||
}
|
||||
|
||||
public Long getChannelExpansionId() {
|
||||
return channelExpansionId;
|
||||
}
|
||||
|
||||
public void setChannelExpansionId(Long channelExpansionId) {
|
||||
this.channelExpansionId = channelExpansionId;
|
||||
}
|
||||
|
||||
public String getStage() {
|
||||
return stage;
|
||||
}
|
||||
|
||||
public void setStage(String stage) {
|
||||
this.stage = stage;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@ public class OpportunityItemDTO {
|
|||
private String operatorName;
|
||||
private BigDecimal amount;
|
||||
private String date;
|
||||
private Integer confidence;
|
||||
private String confidence;
|
||||
private String stageCode;
|
||||
private String stage;
|
||||
private String type;
|
||||
|
|
@ -28,6 +28,8 @@ public class OpportunityItemDTO {
|
|||
private String salesExpansionName;
|
||||
private Long channelExpansionId;
|
||||
private String channelExpansionName;
|
||||
private Long preSalesId;
|
||||
private String preSalesName;
|
||||
private String competitorName;
|
||||
private String latestProgress;
|
||||
private String nextPlan;
|
||||
|
|
@ -114,11 +116,11 @@ public class OpportunityItemDTO {
|
|||
this.date = date;
|
||||
}
|
||||
|
||||
public Integer getConfidence() {
|
||||
public String getConfidence() {
|
||||
return confidence;
|
||||
}
|
||||
|
||||
public void setConfidence(Integer confidence) {
|
||||
public void setConfidence(String confidence) {
|
||||
this.confidence = confidence;
|
||||
}
|
||||
|
||||
|
|
@ -210,6 +212,22 @@ public class OpportunityItemDTO {
|
|||
this.channelExpansionName = channelExpansionName;
|
||||
}
|
||||
|
||||
public Long getPreSalesId() {
|
||||
return preSalesId;
|
||||
}
|
||||
|
||||
public void setPreSalesId(Long preSalesId) {
|
||||
this.preSalesId = preSalesId;
|
||||
}
|
||||
|
||||
public String getPreSalesName() {
|
||||
return preSalesName;
|
||||
}
|
||||
|
||||
public void setPreSalesName(String preSalesName) {
|
||||
this.preSalesName = preSalesName;
|
||||
}
|
||||
|
||||
public String getCompetitorName() {
|
||||
return competitorName;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,13 +6,18 @@ public class OpportunityMetaDTO {
|
|||
|
||||
private List<OpportunityDictOptionDTO> stageOptions;
|
||||
private List<OpportunityDictOptionDTO> operatorOptions;
|
||||
private List<OpportunityDictOptionDTO> projectLocationOptions;
|
||||
|
||||
public OpportunityMetaDTO() {
|
||||
}
|
||||
|
||||
public OpportunityMetaDTO(List<OpportunityDictOptionDTO> stageOptions, List<OpportunityDictOptionDTO> operatorOptions) {
|
||||
public OpportunityMetaDTO(
|
||||
List<OpportunityDictOptionDTO> stageOptions,
|
||||
List<OpportunityDictOptionDTO> operatorOptions,
|
||||
List<OpportunityDictOptionDTO> projectLocationOptions) {
|
||||
this.stageOptions = stageOptions;
|
||||
this.operatorOptions = operatorOptions;
|
||||
this.projectLocationOptions = projectLocationOptions;
|
||||
}
|
||||
|
||||
public List<OpportunityDictOptionDTO> getStageOptions() {
|
||||
|
|
@ -30,4 +35,12 @@ public class OpportunityMetaDTO {
|
|||
public void setOperatorOptions(List<OpportunityDictOptionDTO> operatorOptions) {
|
||||
this.operatorOptions = operatorOptions;
|
||||
}
|
||||
|
||||
public List<OpportunityDictOptionDTO> getProjectLocationOptions() {
|
||||
return projectLocationOptions;
|
||||
}
|
||||
|
||||
public void setProjectLocationOptions(List<OpportunityDictOptionDTO> projectLocationOptions) {
|
||||
this.projectLocationOptions = projectLocationOptions;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,214 @@
|
|||
package com.unis.crm.dto.opportunity;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
public class OpportunityOmsPushDataDTO {
|
||||
|
||||
private Long opportunityId;
|
||||
private String opportunityCode;
|
||||
private String opportunityName;
|
||||
private String customerName;
|
||||
private String operatorName;
|
||||
private BigDecimal amount;
|
||||
private String expectedCloseDate;
|
||||
private String confidencePct;
|
||||
private String stage;
|
||||
private String stageCode;
|
||||
private String competitorName;
|
||||
private Long preSalesId;
|
||||
private String preSalesName;
|
||||
private String opportunityType;
|
||||
private String salesContactName;
|
||||
private String salesContactMobile;
|
||||
private String channelName;
|
||||
private String province;
|
||||
private String city;
|
||||
private String officeAddress;
|
||||
private String channelContactName;
|
||||
private String channelContactMobile;
|
||||
private String certificationLevel;
|
||||
|
||||
public Long getOpportunityId() {
|
||||
return opportunityId;
|
||||
}
|
||||
|
||||
public void setOpportunityId(Long opportunityId) {
|
||||
this.opportunityId = opportunityId;
|
||||
}
|
||||
|
||||
public String getOpportunityCode() {
|
||||
return opportunityCode;
|
||||
}
|
||||
|
||||
public void setOpportunityCode(String opportunityCode) {
|
||||
this.opportunityCode = opportunityCode;
|
||||
}
|
||||
|
||||
public String getOpportunityName() {
|
||||
return opportunityName;
|
||||
}
|
||||
|
||||
public void setOpportunityName(String opportunityName) {
|
||||
this.opportunityName = opportunityName;
|
||||
}
|
||||
|
||||
public String getCustomerName() {
|
||||
return customerName;
|
||||
}
|
||||
|
||||
public void setCustomerName(String customerName) {
|
||||
this.customerName = customerName;
|
||||
}
|
||||
|
||||
public String getOperatorName() {
|
||||
return operatorName;
|
||||
}
|
||||
|
||||
public void setOperatorName(String operatorName) {
|
||||
this.operatorName = operatorName;
|
||||
}
|
||||
|
||||
public BigDecimal getAmount() {
|
||||
return amount;
|
||||
}
|
||||
|
||||
public void setAmount(BigDecimal amount) {
|
||||
this.amount = amount;
|
||||
}
|
||||
|
||||
public String getExpectedCloseDate() {
|
||||
return expectedCloseDate;
|
||||
}
|
||||
|
||||
public void setExpectedCloseDate(String expectedCloseDate) {
|
||||
this.expectedCloseDate = expectedCloseDate;
|
||||
}
|
||||
|
||||
public String getConfidencePct() {
|
||||
return confidencePct;
|
||||
}
|
||||
|
||||
public void setConfidencePct(String confidencePct) {
|
||||
this.confidencePct = confidencePct;
|
||||
}
|
||||
|
||||
public String getStage() {
|
||||
return stage;
|
||||
}
|
||||
|
||||
public void setStage(String stage) {
|
||||
this.stage = stage;
|
||||
}
|
||||
|
||||
public String getStageCode() {
|
||||
return stageCode;
|
||||
}
|
||||
|
||||
public void setStageCode(String stageCode) {
|
||||
this.stageCode = stageCode;
|
||||
}
|
||||
|
||||
public String getCompetitorName() {
|
||||
return competitorName;
|
||||
}
|
||||
|
||||
public void setCompetitorName(String competitorName) {
|
||||
this.competitorName = competitorName;
|
||||
}
|
||||
|
||||
public Long getPreSalesId() {
|
||||
return preSalesId;
|
||||
}
|
||||
|
||||
public void setPreSalesId(Long preSalesId) {
|
||||
this.preSalesId = preSalesId;
|
||||
}
|
||||
|
||||
public String getPreSalesName() {
|
||||
return preSalesName;
|
||||
}
|
||||
|
||||
public void setPreSalesName(String preSalesName) {
|
||||
this.preSalesName = preSalesName;
|
||||
}
|
||||
|
||||
public String getOpportunityType() {
|
||||
return opportunityType;
|
||||
}
|
||||
|
||||
public void setOpportunityType(String opportunityType) {
|
||||
this.opportunityType = opportunityType;
|
||||
}
|
||||
|
||||
public String getSalesContactName() {
|
||||
return salesContactName;
|
||||
}
|
||||
|
||||
public void setSalesContactName(String salesContactName) {
|
||||
this.salesContactName = salesContactName;
|
||||
}
|
||||
|
||||
public String getSalesContactMobile() {
|
||||
return salesContactMobile;
|
||||
}
|
||||
|
||||
public void setSalesContactMobile(String salesContactMobile) {
|
||||
this.salesContactMobile = salesContactMobile;
|
||||
}
|
||||
|
||||
public String getChannelName() {
|
||||
return channelName;
|
||||
}
|
||||
|
||||
public void setChannelName(String channelName) {
|
||||
this.channelName = channelName;
|
||||
}
|
||||
|
||||
public String getProvince() {
|
||||
return province;
|
||||
}
|
||||
|
||||
public void setProvince(String province) {
|
||||
this.province = province;
|
||||
}
|
||||
|
||||
public String getCity() {
|
||||
return city;
|
||||
}
|
||||
|
||||
public void setCity(String city) {
|
||||
this.city = city;
|
||||
}
|
||||
|
||||
public String getOfficeAddress() {
|
||||
return officeAddress;
|
||||
}
|
||||
|
||||
public void setOfficeAddress(String officeAddress) {
|
||||
this.officeAddress = officeAddress;
|
||||
}
|
||||
|
||||
public String getChannelContactName() {
|
||||
return channelContactName;
|
||||
}
|
||||
|
||||
public void setChannelContactName(String channelContactName) {
|
||||
this.channelContactName = channelContactName;
|
||||
}
|
||||
|
||||
public String getChannelContactMobile() {
|
||||
return channelContactMobile;
|
||||
}
|
||||
|
||||
public void setChannelContactMobile(String channelContactMobile) {
|
||||
this.channelContactMobile = channelContactMobile;
|
||||
}
|
||||
|
||||
public String getCertificationLevel() {
|
||||
return certificationLevel;
|
||||
}
|
||||
|
||||
public void setCertificationLevel(String certificationLevel) {
|
||||
this.certificationLevel = certificationLevel;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package com.unis.crm.dto.opportunity;
|
||||
|
||||
public class PushOpportunityToOmsRequest {
|
||||
|
||||
private Long preSalesId;
|
||||
private String preSalesName;
|
||||
|
||||
public Long getPreSalesId() {
|
||||
return preSalesId;
|
||||
}
|
||||
|
||||
public void setPreSalesId(Long preSalesId) {
|
||||
this.preSalesId = preSalesId;
|
||||
}
|
||||
|
||||
public String getPreSalesName() {
|
||||
return preSalesName;
|
||||
}
|
||||
|
||||
public void setPreSalesName(String preSalesName) {
|
||||
this.preSalesName = preSalesName;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,265 @@
|
|||
package com.unis.crm.dto.opportunity;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import jakarta.validation.constraints.AssertTrue;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
public class UpdateOpportunityIntegrationRequest {
|
||||
|
||||
@Size(max = 50, message = "opportunityCode 不能超过50字符")
|
||||
private String opportunityCode;
|
||||
|
||||
@Size(max = 200, message = "商机名称不能超过200字符")
|
||||
private String opportunityName;
|
||||
|
||||
@Size(max = 100, message = "项目地不能超过100字符")
|
||||
private String projectLocation;
|
||||
|
||||
@Size(max = 100, message = "运作方不能超过100字符")
|
||||
private String operatorName;
|
||||
|
||||
private BigDecimal amount;
|
||||
|
||||
private LocalDate expectedCloseDate;
|
||||
|
||||
@Pattern(regexp = "^(A|B|C|a|b|c|40|60|80)$", message = "把握度仅支持A、B、C")
|
||||
private String confidencePct;
|
||||
|
||||
@Size(max = 50, message = "项目阶段不能超过50字符")
|
||||
private String stage;
|
||||
|
||||
@Size(max = 50, message = "建设类型不能超过50字符")
|
||||
private String opportunityType;
|
||||
|
||||
@Size(max = 100, message = "产品类型不能超过100字符")
|
||||
private String productType;
|
||||
|
||||
@Size(max = 50, message = "商机来源不能超过50字符")
|
||||
private String source;
|
||||
|
||||
private Long salesExpansionId;
|
||||
|
||||
private Long channelExpansionId;
|
||||
|
||||
private Long preSalesId;
|
||||
|
||||
@Size(max = 100, message = "售前姓名不能超过100字符")
|
||||
private String preSalesName;
|
||||
|
||||
@Size(max = 200, message = "竞品名称不能超过200字符")
|
||||
private String competitorName;
|
||||
|
||||
private Boolean archived;
|
||||
|
||||
private Boolean pushedToOms;
|
||||
|
||||
private OffsetDateTime omsPushTime;
|
||||
|
||||
@Size(max = 30, message = "状态不能超过30字符")
|
||||
private String status;
|
||||
|
||||
private String description;
|
||||
|
||||
@AssertTrue(message = "opportunityCode 不能为空")
|
||||
@JsonIgnore
|
||||
public boolean isTargetSpecified() {
|
||||
return opportunityCode != null && !opportunityCode.trim().isEmpty();
|
||||
}
|
||||
|
||||
@AssertTrue(message = "至少传入一个需要更新的字段")
|
||||
@JsonIgnore
|
||||
public boolean hasAnyUpdateField() {
|
||||
return opportunityName != null
|
||||
|| projectLocation != null
|
||||
|| operatorName != null
|
||||
|| amount != null
|
||||
|| expectedCloseDate != null
|
||||
|| confidencePct != null
|
||||
|| stage != null
|
||||
|| opportunityType != null
|
||||
|| productType != null
|
||||
|| source != null
|
||||
|| salesExpansionId != null
|
||||
|| channelExpansionId != null
|
||||
|| preSalesId != null
|
||||
|| preSalesName != null
|
||||
|| competitorName != null
|
||||
|| archived != null
|
||||
|| pushedToOms != null
|
||||
|| omsPushTime != null
|
||||
|| status != null
|
||||
|| description != null;
|
||||
}
|
||||
|
||||
public String getOpportunityCode() {
|
||||
return opportunityCode;
|
||||
}
|
||||
|
||||
public void setOpportunityCode(String opportunityCode) {
|
||||
this.opportunityCode = opportunityCode;
|
||||
}
|
||||
|
||||
public String getOpportunityName() {
|
||||
return opportunityName;
|
||||
}
|
||||
|
||||
public void setOpportunityName(String opportunityName) {
|
||||
this.opportunityName = opportunityName;
|
||||
}
|
||||
|
||||
public String getProjectLocation() {
|
||||
return projectLocation;
|
||||
}
|
||||
|
||||
public void setProjectLocation(String projectLocation) {
|
||||
this.projectLocation = projectLocation;
|
||||
}
|
||||
|
||||
public String getOperatorName() {
|
||||
return operatorName;
|
||||
}
|
||||
|
||||
public void setOperatorName(String operatorName) {
|
||||
this.operatorName = operatorName;
|
||||
}
|
||||
|
||||
public BigDecimal getAmount() {
|
||||
return amount;
|
||||
}
|
||||
|
||||
public void setAmount(BigDecimal amount) {
|
||||
this.amount = amount;
|
||||
}
|
||||
|
||||
public LocalDate getExpectedCloseDate() {
|
||||
return expectedCloseDate;
|
||||
}
|
||||
|
||||
public void setExpectedCloseDate(LocalDate expectedCloseDate) {
|
||||
this.expectedCloseDate = expectedCloseDate;
|
||||
}
|
||||
|
||||
public String getConfidencePct() {
|
||||
return confidencePct;
|
||||
}
|
||||
|
||||
public void setConfidencePct(String confidencePct) {
|
||||
this.confidencePct = confidencePct;
|
||||
}
|
||||
|
||||
public String getStage() {
|
||||
return stage;
|
||||
}
|
||||
|
||||
public void setStage(String stage) {
|
||||
this.stage = stage;
|
||||
}
|
||||
|
||||
public String getOpportunityType() {
|
||||
return opportunityType;
|
||||
}
|
||||
|
||||
public void setOpportunityType(String opportunityType) {
|
||||
this.opportunityType = opportunityType;
|
||||
}
|
||||
|
||||
public String getProductType() {
|
||||
return productType;
|
||||
}
|
||||
|
||||
public void setProductType(String productType) {
|
||||
this.productType = productType;
|
||||
}
|
||||
|
||||
public String getSource() {
|
||||
return source;
|
||||
}
|
||||
|
||||
public void setSource(String source) {
|
||||
this.source = source;
|
||||
}
|
||||
|
||||
public Long getSalesExpansionId() {
|
||||
return salesExpansionId;
|
||||
}
|
||||
|
||||
public void setSalesExpansionId(Long salesExpansionId) {
|
||||
this.salesExpansionId = salesExpansionId;
|
||||
}
|
||||
|
||||
public Long getChannelExpansionId() {
|
||||
return channelExpansionId;
|
||||
}
|
||||
|
||||
public void setChannelExpansionId(Long channelExpansionId) {
|
||||
this.channelExpansionId = channelExpansionId;
|
||||
}
|
||||
|
||||
public Long getPreSalesId() {
|
||||
return preSalesId;
|
||||
}
|
||||
|
||||
public void setPreSalesId(Long preSalesId) {
|
||||
this.preSalesId = preSalesId;
|
||||
}
|
||||
|
||||
public String getPreSalesName() {
|
||||
return preSalesName;
|
||||
}
|
||||
|
||||
public void setPreSalesName(String preSalesName) {
|
||||
this.preSalesName = preSalesName;
|
||||
}
|
||||
|
||||
public String getCompetitorName() {
|
||||
return competitorName;
|
||||
}
|
||||
|
||||
public void setCompetitorName(String competitorName) {
|
||||
this.competitorName = competitorName;
|
||||
}
|
||||
|
||||
public Boolean getArchived() {
|
||||
return archived;
|
||||
}
|
||||
|
||||
public void setArchived(Boolean archived) {
|
||||
this.archived = archived;
|
||||
}
|
||||
|
||||
public Boolean getPushedToOms() {
|
||||
return pushedToOms;
|
||||
}
|
||||
|
||||
public void setPushedToOms(Boolean pushedToOms) {
|
||||
this.pushedToOms = pushedToOms;
|
||||
}
|
||||
|
||||
public OffsetDateTime getOmsPushTime() {
|
||||
return omsPushTime;
|
||||
}
|
||||
|
||||
public void setOmsPushTime(OffsetDateTime omsPushTime) {
|
||||
this.omsPushTime = omsPushTime;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
}
|
||||
|
|
@ -23,6 +23,10 @@ public interface ExpansionMapper {
|
|||
|
||||
List<DictOptionDTO> selectDictItems(@Param("typeCode") String typeCode);
|
||||
|
||||
List<DictOptionDTO> selectProvinceAreaOptions();
|
||||
|
||||
List<DictOptionDTO> selectCityAreaOptionsByProvinceName(@Param("provinceName") String provinceName);
|
||||
|
||||
String selectNextChannelCode();
|
||||
|
||||
@DataScope(tableAlias = "s", ownerColumn = "owner_user_id")
|
||||
|
|
|
|||
|
|
@ -2,9 +2,13 @@ package com.unis.crm.mapper;
|
|||
|
||||
import com.unis.crm.dto.opportunity.CreateOpportunityFollowUpRequest;
|
||||
import com.unis.crm.dto.opportunity.CreateOpportunityRequest;
|
||||
import com.unis.crm.dto.opportunity.CurrentUserAccountDTO;
|
||||
import com.unis.crm.dto.opportunity.OpportunityDictOptionDTO;
|
||||
import com.unis.crm.dto.opportunity.OpportunityFollowUpDTO;
|
||||
import com.unis.crm.dto.opportunity.OpportunityIntegrationTargetDTO;
|
||||
import com.unis.crm.dto.opportunity.OpportunityItemDTO;
|
||||
import com.unis.crm.dto.opportunity.OpportunityOmsPushDataDTO;
|
||||
import com.unis.crm.dto.opportunity.UpdateOpportunityIntegrationRequest;
|
||||
import java.util.List;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
|
@ -15,6 +19,8 @@ public interface OpportunityMapper {
|
|||
|
||||
List<OpportunityDictOptionDTO> selectDictItems(@Param("typeCode") String typeCode);
|
||||
|
||||
List<OpportunityDictOptionDTO> selectProvinceAreaOptions();
|
||||
|
||||
String selectDictLabel(
|
||||
@Param("typeCode") String typeCode,
|
||||
@Param("itemValue") String itemValue);
|
||||
|
|
@ -44,12 +50,32 @@ public interface OpportunityMapper {
|
|||
@Param("customerId") Long customerId,
|
||||
@Param("request") CreateOpportunityRequest request);
|
||||
|
||||
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
|
||||
int updateOpportunityCode(
|
||||
@Param("userId") Long userId,
|
||||
@Param("opportunityId") Long opportunityId,
|
||||
@Param("opportunityCode") String opportunityCode);
|
||||
|
||||
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
|
||||
int countOwnedOpportunity(@Param("userId") Long userId, @Param("id") Long id);
|
||||
|
||||
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
|
||||
Boolean selectPushedToOms(@Param("userId") Long userId, @Param("id") Long id);
|
||||
|
||||
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
|
||||
OpportunityOmsPushDataDTO selectOpportunityOmsPushData(
|
||||
@Param("userId") Long userId,
|
||||
@Param("opportunityId") Long opportunityId);
|
||||
|
||||
CurrentUserAccountDTO selectCurrentUserAccount(@Param("userId") Long userId);
|
||||
|
||||
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
|
||||
int updateOpportunityPreSales(
|
||||
@Param("userId") Long userId,
|
||||
@Param("opportunityId") Long opportunityId,
|
||||
@Param("preSalesId") Long preSalesId,
|
||||
@Param("preSalesName") String preSalesName);
|
||||
|
||||
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
|
||||
int updateOpportunity(
|
||||
@Param("userId") Long userId,
|
||||
|
|
@ -57,10 +83,18 @@ public interface OpportunityMapper {
|
|||
@Param("customerId") Long customerId,
|
||||
@Param("request") CreateOpportunityRequest request);
|
||||
|
||||
OpportunityIntegrationTargetDTO selectOpportunityIntegrationTarget(
|
||||
@Param("opportunityCode") String opportunityCode);
|
||||
|
||||
int updateOpportunityByIntegration(
|
||||
@Param("opportunityId") Long opportunityId,
|
||||
@Param("request") UpdateOpportunityIntegrationRequest request);
|
||||
|
||||
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
|
||||
int pushOpportunityToOms(
|
||||
int markOpportunityOmsPushed(
|
||||
@Param("userId") Long userId,
|
||||
@Param("opportunityId") Long opportunityId);
|
||||
@Param("opportunityId") Long opportunityId,
|
||||
@Param("opportunityCode") String opportunityCode);
|
||||
|
||||
int insertOpportunityFollowUp(
|
||||
@Param("userId") Long userId,
|
||||
|
|
|
|||
|
|
@ -3,15 +3,19 @@ package com.unis.crm.service;
|
|||
import com.unis.crm.dto.expansion.CreateChannelExpansionRequest;
|
||||
import com.unis.crm.dto.expansion.CreateExpansionFollowUpRequest;
|
||||
import com.unis.crm.dto.expansion.CreateSalesExpansionRequest;
|
||||
import com.unis.crm.dto.expansion.DictOptionDTO;
|
||||
import com.unis.crm.dto.expansion.ExpansionMetaDTO;
|
||||
import com.unis.crm.dto.expansion.ExpansionOverviewDTO;
|
||||
import com.unis.crm.dto.expansion.UpdateChannelExpansionRequest;
|
||||
import com.unis.crm.dto.expansion.UpdateSalesExpansionRequest;
|
||||
import java.util.List;
|
||||
|
||||
public interface ExpansionService {
|
||||
|
||||
ExpansionMetaDTO getMeta();
|
||||
|
||||
List<DictOptionDTO> getCityOptions(String provinceName);
|
||||
|
||||
ExpansionOverviewDTO getOverview(Long userId, String keyword);
|
||||
|
||||
Long createSalesExpansion(Long userId, CreateSalesExpansionRequest request);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,455 @@
|
|||
package com.unis.crm.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.unis.crm.common.BusinessException;
|
||||
import com.unis.crm.config.OmsProperties;
|
||||
import com.unis.crm.dto.opportunity.OmsPreSalesOptionDTO;
|
||||
import com.unis.crm.dto.opportunity.OpportunityOmsPushDataDTO;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class OmsClient {
|
||||
|
||||
private static final Set<String> SUCCESS_CODES = Set.of("0", "200", "success", "SUCCESS");
|
||||
private static final List<String> PROJECT_CODE_FIELDS = List.of(
|
||||
"project_code",
|
||||
"projectCode",
|
||||
"projectNo",
|
||||
"omsProjectCode",
|
||||
"code",
|
||||
"projectId",
|
||||
"id");
|
||||
|
||||
private final OmsProperties omsProperties;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final HttpClient httpClient;
|
||||
|
||||
public OmsClient(OmsProperties omsProperties, ObjectMapper objectMapper) {
|
||||
this.omsProperties = omsProperties;
|
||||
this.objectMapper = objectMapper;
|
||||
this.httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(Math.max(1, omsProperties.getConnectTimeoutSeconds())))
|
||||
.build();
|
||||
}
|
||||
|
||||
public List<OmsPreSalesOptionDTO> listPreSalesUsers() {
|
||||
JsonNode data = sendGet(omsProperties.getUserInfoPath(), Map.of(
|
||||
"userCode", "",
|
||||
"roleName", defaultText(omsProperties.getPreSalesRoleName(), "售前")));
|
||||
List<OmsPreSalesOptionDTO> result = new ArrayList<>();
|
||||
if (data == null || !data.isArray()) {
|
||||
return result;
|
||||
}
|
||||
|
||||
for (JsonNode item : data) {
|
||||
OmsPreSalesOptionDTO option = new OmsPreSalesOptionDTO();
|
||||
option.setUserId(longValue(item, "userId"));
|
||||
option.setLoginName(textValue(item, "loginName"));
|
||||
option.setUserName(textValue(item, "userName"));
|
||||
if (option.getUserId() != null && !isBlank(option.getUserName())) {
|
||||
result.add(option);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public OmsPreSalesOptionDTO ensureUserExists(String loginName, String userName) {
|
||||
OmsPreSalesOptionDTO existingUser = findUserByLoginName(loginName);
|
||||
if (existingUser != null) {
|
||||
return existingUser;
|
||||
}
|
||||
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("userName", userName);
|
||||
payload.put("loginName", loginName);
|
||||
sendPost(omsProperties.getUserAddPath(), payload);
|
||||
|
||||
OmsPreSalesOptionDTO createdUser = findUserByLoginName(loginName);
|
||||
if (createdUser != null) {
|
||||
return createdUser;
|
||||
}
|
||||
|
||||
OmsPreSalesOptionDTO fallbackUser = new OmsPreSalesOptionDTO();
|
||||
fallbackUser.setLoginName(loginName);
|
||||
fallbackUser.setUserName(userName);
|
||||
return fallbackUser;
|
||||
}
|
||||
|
||||
public String createProject(OpportunityOmsPushDataDTO opportunity, String createBy) {
|
||||
return createProject(opportunity, createBy, null);
|
||||
}
|
||||
|
||||
public String createProject(
|
||||
OpportunityOmsPushDataDTO opportunity,
|
||||
String createBy,
|
||||
String existingOpportunityCode) {
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
if (!isBlank(existingOpportunityCode)) {
|
||||
payload.put("projectCode", existingOpportunityCode);
|
||||
}
|
||||
payload.put("projectName", opportunity.getOpportunityName());
|
||||
payload.put("operateInstitution", opportunity.getOperatorName());
|
||||
payload.put("h3cPerson", opportunity.getSalesContactName());
|
||||
payload.put("h3cPhone", opportunity.getSalesContactMobile());
|
||||
payload.put("estimatedAmount", decimalText(opportunity.getAmount()));
|
||||
payload.put("estimatedOrderTime", opportunity.getExpectedCloseDate());
|
||||
payload.put("projectGraspDegree", mapProjectGraspDegree(opportunity.getConfidencePct()));
|
||||
payload.put("projectStage", opportunity.getStage());
|
||||
payload.put("competitorList", splitMultiValue(opportunity.getCompetitorName()));
|
||||
payload.put("hzSupportUser", longText(opportunity.getPreSalesId()));
|
||||
payload.put("createBy", defaultText(createBy, ""));
|
||||
payload.put("constructionType", opportunity.getOpportunityType());
|
||||
payload.put("partner", buildPartner(opportunity));
|
||||
|
||||
JsonNode data = sendPost(omsProperties.getProjectAddPath(), payload);
|
||||
String projectCode = extractProjectCode(data);
|
||||
if (isBlank(projectCode)) {
|
||||
throw new BusinessException("OMS返回成功,但未返回项目编号");
|
||||
}
|
||||
return projectCode;
|
||||
}
|
||||
|
||||
private Map<String, Object> buildPartner(OpportunityOmsPushDataDTO opportunity) {
|
||||
Map<String, Object> partner = new LinkedHashMap<>();
|
||||
partner.put("partnerCode", null);
|
||||
partner.put("partnerName", opportunity.getChannelName());
|
||||
partner.put("province", opportunity.getProvince());
|
||||
partner.put("city", opportunity.getCity());
|
||||
partner.put("address", opportunity.getOfficeAddress());
|
||||
partner.put("contactPerson", opportunity.getChannelContactName());
|
||||
partner.put("contactPhone", opportunity.getChannelContactMobile());
|
||||
partner.put("level", opportunity.getCertificationLevel());
|
||||
return partner;
|
||||
}
|
||||
|
||||
private String mapProjectGraspDegree(String confidencePct) {
|
||||
if (isBlank(confidencePct)) {
|
||||
return null;
|
||||
}
|
||||
String normalized = confidencePct.trim().toUpperCase(Locale.ROOT);
|
||||
if ("A".equals(normalized) || "B".equals(normalized) || "C".equals(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (normalized.matches("\\d+(\\.\\d+)?")) {
|
||||
double numericValue = Double.parseDouble(normalized);
|
||||
if (numericValue >= 80) {
|
||||
return "A";
|
||||
}
|
||||
if (numericValue >= 60) {
|
||||
return "B";
|
||||
}
|
||||
return "C";
|
||||
}
|
||||
throw new BusinessException("项目把握度仅支持A、B、C");
|
||||
}
|
||||
|
||||
private boolean requiresPartner(String operatorName) {
|
||||
String normalized = normalizeText(operatorName);
|
||||
if (normalized == null) {
|
||||
return false;
|
||||
}
|
||||
String token = normalized.toLowerCase(Locale.ROOT).replaceAll("\\s+", "").replace('+', '+');
|
||||
return token.contains("渠道") || token.contains("channel") || token.contains("dls");
|
||||
}
|
||||
|
||||
private OmsPreSalesOptionDTO findUserByLoginName(String loginName) {
|
||||
JsonNode data = sendGet(omsProperties.getUserInfoPath(), Map.of(
|
||||
"userCode", defaultText(loginName, ""),
|
||||
"roleName", ""));
|
||||
if (data == null || !data.isArray() || data.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
JsonNode item = data.get(0);
|
||||
OmsPreSalesOptionDTO user = new OmsPreSalesOptionDTO();
|
||||
user.setUserId(longValue(item, "userId"));
|
||||
user.setLoginName(textValue(item, "loginName"));
|
||||
user.setUserName(textValue(item, "userName"));
|
||||
return user;
|
||||
}
|
||||
|
||||
private JsonNode sendGet(String path, Map<String, String> queryParams) {
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder(buildUri(path, queryParams))
|
||||
.timeout(Duration.ofSeconds(Math.max(1, omsProperties.getReadTimeoutSeconds())))
|
||||
.GET();
|
||||
applyCommonHeaders(builder);
|
||||
return sendRequest(builder.build());
|
||||
}
|
||||
|
||||
private JsonNode sendPost(String path, Object payload) {
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder(buildUri(path, null))
|
||||
.timeout(Duration.ofSeconds(Math.max(1, omsProperties.getReadTimeoutSeconds())));
|
||||
applyCommonHeaders(builder);
|
||||
builder.header("Content-Type", "application/json");
|
||||
try {
|
||||
builder.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(payload)));
|
||||
} catch (IOException exception) {
|
||||
throw new BusinessException("构建OMS请求失败");
|
||||
}
|
||||
return sendRequest(builder.build());
|
||||
}
|
||||
|
||||
private JsonNode sendRequest(HttpRequest request) {
|
||||
try {
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
||||
throw new BusinessException("OMS接口调用失败,HTTP状态码: " + response.statusCode() + ",响应: " + trimBody(response.body()));
|
||||
}
|
||||
JsonNode root = objectMapper.readTree(response.body());
|
||||
String code = normalizeText(root.path("code").asText(null));
|
||||
if (!isSuccessCode(code)) {
|
||||
String message = firstNonBlank(
|
||||
normalizeText(root.path("msg").asText(null)),
|
||||
normalizeText(root.path("message").asText(null)),
|
||||
trimBody(response.body()),
|
||||
"OMS接口调用失败");
|
||||
throw new BusinessException(message);
|
||||
}
|
||||
return root.path("data");
|
||||
} catch (IOException | InterruptedException exception) {
|
||||
if (exception instanceof InterruptedException) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
throw new BusinessException("OMS接口调用异常: " + exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private URI buildUri(String path, Map<String, String> queryParams) {
|
||||
validateConfigured();
|
||||
String baseUrl = normalizeBaseUrl(omsProperties.getBaseUrl());
|
||||
String normalizedPath = path == null || path.isBlank() ? "" : (path.startsWith("/") ? path : "/" + path);
|
||||
StringBuilder builder = new StringBuilder(baseUrl).append(normalizedPath);
|
||||
if (queryParams != null && !queryParams.isEmpty()) {
|
||||
boolean first = true;
|
||||
for (Map.Entry<String, String> entry : queryParams.entrySet()) {
|
||||
if (!first) {
|
||||
builder.append('&');
|
||||
} else {
|
||||
builder.append('?');
|
||||
first = false;
|
||||
}
|
||||
builder.append(encode(entry.getKey())).append('=').append(encode(entry.getValue()));
|
||||
}
|
||||
}
|
||||
return URI.create(builder.toString());
|
||||
}
|
||||
|
||||
private void applyCommonHeaders(HttpRequest.Builder builder) {
|
||||
builder.header(defaultText(omsProperties.getApiKeyHeader(), "apiKey"), defaultText(omsProperties.getApiKey(), ""));
|
||||
}
|
||||
|
||||
private void validateConfigured() {
|
||||
if (!omsProperties.isEnabled()) {
|
||||
throw new BusinessException("OMS推送未启用");
|
||||
}
|
||||
if (isBlank(omsProperties.getBaseUrl())) {
|
||||
throw new BusinessException("OMS基础地址未配置");
|
||||
}
|
||||
if (isBlank(omsProperties.getApiKey())) {
|
||||
throw new BusinessException("OMS apiKey未配置");
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeBaseUrl(String value) {
|
||||
String trimmed = normalizeText(value);
|
||||
if (trimmed == null) {
|
||||
return "";
|
||||
}
|
||||
return trimmed.endsWith("/") ? trimmed.substring(0, trimmed.length() - 1) : trimmed;
|
||||
}
|
||||
|
||||
private String encode(String value) {
|
||||
return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private boolean isSuccessCode(String code) {
|
||||
return code == null || SUCCESS_CODES.contains(code);
|
||||
}
|
||||
|
||||
private String longText(Long value) {
|
||||
return value == null ? null : String.valueOf(value);
|
||||
}
|
||||
|
||||
private String decimalText(java.math.BigDecimal value) {
|
||||
return value == null ? null : value.stripTrailingZeros().toPlainString();
|
||||
}
|
||||
|
||||
private String trimBody(String value) {
|
||||
String normalized = normalizeText(value);
|
||||
if (normalized == null) {
|
||||
return null;
|
||||
}
|
||||
return normalized.length() > 500 ? normalized.substring(0, 500) : normalized;
|
||||
}
|
||||
|
||||
private String extractProjectCode(JsonNode data) {
|
||||
String directValue = scalarText(data);
|
||||
if (!isBlank(directValue)) {
|
||||
return directValue;
|
||||
}
|
||||
|
||||
for (String fieldName : PROJECT_CODE_FIELDS) {
|
||||
String matched = findFieldValue(data, fieldName);
|
||||
if (!isBlank(matched)) {
|
||||
return matched;
|
||||
}
|
||||
}
|
||||
|
||||
return findLikelyProjectCode(data);
|
||||
}
|
||||
|
||||
private String findLikelyProjectCode(JsonNode node) {
|
||||
if (node == null || node.isNull()) {
|
||||
return null;
|
||||
}
|
||||
if (node.isObject()) {
|
||||
var fields = node.fields();
|
||||
while (fields.hasNext()) {
|
||||
Map.Entry<String, JsonNode> entry = fields.next();
|
||||
String key = entry.getKey().toLowerCase(Locale.ROOT);
|
||||
String scalarValue = scalarText(entry.getValue());
|
||||
if ((key.contains("code") || key.contains("no") || key.endsWith("id")) && !isBlank(scalarValue)) {
|
||||
return scalarValue;
|
||||
}
|
||||
String nested = findLikelyProjectCode(entry.getValue());
|
||||
if (!isBlank(nested)) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (node.isArray()) {
|
||||
for (JsonNode item : node) {
|
||||
String nested = findLikelyProjectCode(item);
|
||||
if (!isBlank(nested)) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String findFieldValue(JsonNode node, String fieldName) {
|
||||
if (node == null || node.isNull()) {
|
||||
return null;
|
||||
}
|
||||
if (node.isObject()) {
|
||||
String direct = scalarText(node.get(fieldName));
|
||||
if (!isBlank(direct)) {
|
||||
return direct;
|
||||
}
|
||||
var fields = node.fields();
|
||||
while (fields.hasNext()) {
|
||||
String nested = findFieldValue(fields.next().getValue(), fieldName);
|
||||
if (!isBlank(nested)) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (node.isArray()) {
|
||||
for (JsonNode item : node) {
|
||||
String nested = findFieldValue(item, fieldName);
|
||||
if (!isBlank(nested)) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private List<String> splitMultiValue(String rawValue) {
|
||||
List<String> values = new ArrayList<>();
|
||||
String normalized = normalizeText(rawValue);
|
||||
if (normalized == null) {
|
||||
return values;
|
||||
}
|
||||
for (String item : normalized.split("[,,、;;\\n]+")) {
|
||||
String candidate = normalizeText(item);
|
||||
if (candidate != null && !values.contains(candidate)) {
|
||||
values.add(candidate);
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private Long longValue(JsonNode node, String fieldName) {
|
||||
if (node == null || node.isNull()) {
|
||||
return null;
|
||||
}
|
||||
JsonNode child = node.get(fieldName);
|
||||
if (child == null || child.isNull()) {
|
||||
return null;
|
||||
}
|
||||
if (child.isIntegralNumber()) {
|
||||
return child.longValue();
|
||||
}
|
||||
String text = normalizeText(child.asText(null));
|
||||
if (text == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Long.parseLong(text);
|
||||
} catch (NumberFormatException exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String textValue(JsonNode node, String fieldName) {
|
||||
if (node == null || node.isNull()) {
|
||||
return null;
|
||||
}
|
||||
return normalizeText(node.path(fieldName).asText(null));
|
||||
}
|
||||
|
||||
private String scalarText(JsonNode node) {
|
||||
if (node == null || node.isNull() || node.isContainerNode()) {
|
||||
return null;
|
||||
}
|
||||
return normalizeText(node.asText(null));
|
||||
}
|
||||
|
||||
private String defaultText(String value, String fallback) {
|
||||
String normalized = normalizeText(value);
|
||||
return normalized == null ? fallback : normalized;
|
||||
}
|
||||
|
||||
private String normalizeText(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
return trimmed.isEmpty() ? null : trimmed;
|
||||
}
|
||||
|
||||
private boolean isBlank(String value) {
|
||||
return value == null || value.trim().isEmpty();
|
||||
}
|
||||
|
||||
private String firstNonBlank(String... values) {
|
||||
if (values == null) {
|
||||
return null;
|
||||
}
|
||||
for (String value : values) {
|
||||
if (!isBlank(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,8 +2,12 @@ package com.unis.crm.service;
|
|||
|
||||
import com.unis.crm.dto.opportunity.CreateOpportunityFollowUpRequest;
|
||||
import com.unis.crm.dto.opportunity.CreateOpportunityRequest;
|
||||
import com.unis.crm.dto.opportunity.OmsPreSalesOptionDTO;
|
||||
import com.unis.crm.dto.opportunity.OpportunityMetaDTO;
|
||||
import com.unis.crm.dto.opportunity.OpportunityOverviewDTO;
|
||||
import com.unis.crm.dto.opportunity.PushOpportunityToOmsRequest;
|
||||
import com.unis.crm.dto.opportunity.UpdateOpportunityIntegrationRequest;
|
||||
import java.util.List;
|
||||
|
||||
public interface OpportunityService {
|
||||
|
||||
|
|
@ -11,11 +15,15 @@ public interface OpportunityService {
|
|||
|
||||
OpportunityOverviewDTO getOverview(Long userId, String keyword, String stage);
|
||||
|
||||
List<OmsPreSalesOptionDTO> getOmsPreSalesOptions(Long userId);
|
||||
|
||||
Long createOpportunity(Long userId, CreateOpportunityRequest request);
|
||||
|
||||
Long updateOpportunity(Long userId, Long opportunityId, CreateOpportunityRequest request);
|
||||
|
||||
Long pushToOms(Long userId, Long opportunityId);
|
||||
Long updateOpportunityByIntegration(UpdateOpportunityIntegrationRequest request);
|
||||
|
||||
Long pushToOms(Long userId, Long opportunityId, PushOpportunityToOmsRequest request);
|
||||
|
||||
Long createFollowUp(Long userId, Long opportunityId, CreateOpportunityFollowUpRequest request);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ public class ExpansionServiceImpl implements ExpansionService {
|
|||
|
||||
private static final String OFFICE_TYPE_CODE = "tz_bsc";
|
||||
private static final String INDUSTRY_TYPE_CODE = "tz_sshy";
|
||||
private static final String CERTIFICATION_LEVEL_TYPE_CODE = "tz_rzjb";
|
||||
private static final String CHANNEL_ATTRIBUTE_TYPE_CODE = "tz_qdsx";
|
||||
private static final String INTERNAL_ATTRIBUTE_TYPE_CODE = "tz_xhsnbsx";
|
||||
private static final String MULTI_VALUE_CUSTOM_PREFIX = "__custom__:";
|
||||
|
|
@ -56,6 +57,8 @@ public class ExpansionServiceImpl implements ExpansionService {
|
|||
return new ExpansionMetaDTO(
|
||||
loadDictOptions(OFFICE_TYPE_CODE),
|
||||
loadDictOptions(INDUSTRY_TYPE_CODE),
|
||||
expansionMapper.selectProvinceAreaOptions(),
|
||||
loadDictOptions(CERTIFICATION_LEVEL_TYPE_CODE),
|
||||
loadDictOptions(CHANNEL_ATTRIBUTE_TYPE_CODE),
|
||||
loadDictOptions(INTERNAL_ATTRIBUTE_TYPE_CODE),
|
||||
expansionMapper.selectNextChannelCode());
|
||||
|
|
@ -66,10 +69,20 @@ public class ExpansionServiceImpl implements ExpansionService {
|
|||
Collections.emptyList(),
|
||||
Collections.emptyList(),
|
||||
Collections.emptyList(),
|
||||
Collections.emptyList(),
|
||||
Collections.emptyList(),
|
||||
null);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<DictOptionDTO> getCityOptions(String provinceName) {
|
||||
if (isBlank(provinceName)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return expansionMapper.selectCityAreaOptionsByProvinceName(provinceName.trim());
|
||||
}
|
||||
|
||||
@Override
|
||||
public ExpansionOverviewDTO getOverview(Long userId, String keyword) {
|
||||
String normalizedKeyword = normalizeKeyword(keyword);
|
||||
|
|
@ -81,7 +94,7 @@ public class ExpansionServiceImpl implements ExpansionService {
|
|||
attachChannelFollowUps(userId, channelItems);
|
||||
attachChannelContacts(userId, channelItems);
|
||||
attachChannelRelatedProjects(userId, channelItems);
|
||||
fillChannelAttributeDisplay(channelItems);
|
||||
fillChannelDisplayFields(channelItems);
|
||||
|
||||
return new ExpansionOverviewDTO(salesItems, channelItems);
|
||||
}
|
||||
|
|
@ -258,15 +271,19 @@ public class ExpansionServiceImpl implements ExpansionService {
|
|||
return expansionMapper.selectDictItems(typeCode);
|
||||
}
|
||||
|
||||
private void fillChannelAttributeDisplay(List<ChannelExpansionItemDTO> channelItems) {
|
||||
private void fillChannelDisplayFields(List<ChannelExpansionItemDTO> channelItems) {
|
||||
if (channelItems == null || channelItems.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, String> industryLabels = toDictLabelMap(loadDictOptions(INDUSTRY_TYPE_CODE));
|
||||
Map<String, String> certificationLevelLabels = toDictLabelMap(loadDictOptions(CERTIFICATION_LEVEL_TYPE_CODE));
|
||||
Map<String, String> channelAttributeLabels = toDictLabelMap(loadDictOptions(CHANNEL_ATTRIBUTE_TYPE_CODE));
|
||||
Map<String, String> internalAttributeLabels = toDictLabelMap(loadDictOptions(INTERNAL_ATTRIBUTE_TYPE_CODE));
|
||||
|
||||
for (ChannelExpansionItemDTO item : channelItems) {
|
||||
item.setChannelIndustry(formatMultiValueDisplay(item.getChannelIndustryCode(), industryLabels));
|
||||
item.setCertificationLevel(formatSingleValueDisplay(item.getCertificationLevel(), certificationLevelLabels));
|
||||
item.setChannelAttribute(formatMultiValueDisplay(item.getChannelAttributeCode(), channelAttributeLabels));
|
||||
item.setInternalAttribute(formatMultiValueDisplay(item.getInternalAttributeCode(), internalAttributeLabels));
|
||||
}
|
||||
|
|
@ -319,6 +336,13 @@ public class ExpansionServiceImpl implements ExpansionService {
|
|||
return uniqueValues.isEmpty() ? "无" : String.join("、", uniqueValues);
|
||||
}
|
||||
|
||||
private String formatSingleValueDisplay(String rawValue, Map<String, String> labelMap) {
|
||||
if (isBlank(rawValue)) {
|
||||
return "无";
|
||||
}
|
||||
return labelMap.getOrDefault(rawValue.trim(), rawValue.trim());
|
||||
}
|
||||
|
||||
private String decodeCustomText(String rawValue) {
|
||||
if (isBlank(rawValue)) {
|
||||
return "";
|
||||
|
|
@ -405,9 +429,11 @@ public class ExpansionServiceImpl implements ExpansionService {
|
|||
|
||||
private void fillChannelDefaults(CreateChannelExpansionRequest request) {
|
||||
request.setChannelName(normalizeRequiredText(request.getChannelName(), "渠道名称不能为空"));
|
||||
request.setProvince(normalizeRequiredText(request.getProvince(), "请填写省份"));
|
||||
request.setProvince(normalizeRequiredText(request.getProvince(), "请选择省份"));
|
||||
request.setCity(normalizeRequiredText(request.getCity(), "请选择市"));
|
||||
request.setCertificationLevel(normalizeRequiredText(request.getCertificationLevel(), "请选择认证级别"));
|
||||
request.setOfficeAddress(normalizeRequiredText(request.getOfficeAddress(), "请填写办公地址"));
|
||||
request.setChannelIndustry(normalizeRequiredText(request.getChannelIndustry(), "请填写聚焦行业"));
|
||||
request.setChannelIndustry(normalizeRequiredText(request.getChannelIndustry(), "请选择聚焦行业"));
|
||||
request.setAnnualRevenue(requirePositiveAmount(request.getAnnualRevenue(), "请填写年营收"));
|
||||
request.setStaffSize(requirePositiveInteger(request.getStaffSize(), "请填写人员规模"));
|
||||
if (request.getContactEstablishedDate() == null) {
|
||||
|
|
@ -432,9 +458,11 @@ public class ExpansionServiceImpl implements ExpansionService {
|
|||
|
||||
private void fillChannelDefaults(UpdateChannelExpansionRequest request) {
|
||||
request.setChannelName(normalizeRequiredText(request.getChannelName(), "渠道名称不能为空"));
|
||||
request.setProvince(normalizeRequiredText(request.getProvince(), "请填写省份"));
|
||||
request.setProvince(normalizeRequiredText(request.getProvince(), "请选择省份"));
|
||||
request.setCity(normalizeRequiredText(request.getCity(), "请选择市"));
|
||||
request.setCertificationLevel(normalizeRequiredText(request.getCertificationLevel(), "请选择认证级别"));
|
||||
request.setOfficeAddress(normalizeRequiredText(request.getOfficeAddress(), "请填写办公地址"));
|
||||
request.setChannelIndustry(normalizeRequiredText(request.getChannelIndustry(), "请填写聚焦行业"));
|
||||
request.setChannelIndustry(normalizeRequiredText(request.getChannelIndustry(), "请选择聚焦行业"));
|
||||
request.setAnnualRevenue(requirePositiveAmount(request.getAnnualRevenue(), "请填写年营收"));
|
||||
request.setStaffSize(requirePositiveInteger(request.getStaffSize(), "请填写人员规模"));
|
||||
if (request.getContactEstablishedDate() == null) {
|
||||
|
|
@ -534,6 +562,10 @@ public class ExpansionServiceImpl implements ExpansionService {
|
|||
return trimmed.isEmpty() ? null : trimmed;
|
||||
}
|
||||
|
||||
private String normalizeOptionalText(String value) {
|
||||
return trimToNull(value);
|
||||
}
|
||||
|
||||
private BigDecimal requirePositiveAmount(BigDecimal value, String message) {
|
||||
if (value == null || value.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
throw new BusinessException(message);
|
||||
|
|
|
|||
|
|
@ -2,13 +2,20 @@ package com.unis.crm.service.impl;
|
|||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.unis.crm.common.BusinessException;
|
||||
import com.unis.crm.dto.opportunity.CurrentUserAccountDTO;
|
||||
import com.unis.crm.dto.opportunity.CreateOpportunityFollowUpRequest;
|
||||
import com.unis.crm.dto.opportunity.CreateOpportunityRequest;
|
||||
import com.unis.crm.dto.opportunity.OmsPreSalesOptionDTO;
|
||||
import com.unis.crm.dto.opportunity.OpportunityMetaDTO;
|
||||
import com.unis.crm.dto.opportunity.OpportunityFollowUpDTO;
|
||||
import com.unis.crm.dto.opportunity.OpportunityIntegrationTargetDTO;
|
||||
import com.unis.crm.dto.opportunity.OpportunityItemDTO;
|
||||
import com.unis.crm.dto.opportunity.OpportunityOmsPushDataDTO;
|
||||
import com.unis.crm.dto.opportunity.OpportunityOverviewDTO;
|
||||
import com.unis.crm.dto.opportunity.PushOpportunityToOmsRequest;
|
||||
import com.unis.crm.dto.opportunity.UpdateOpportunityIntegrationRequest;
|
||||
import com.unis.crm.mapper.OpportunityMapper;
|
||||
import com.unis.crm.service.OmsClient;
|
||||
import com.unis.crm.service.OpportunityService;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Collections;
|
||||
|
|
@ -16,25 +23,32 @@ import java.util.List;
|
|||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class OpportunityServiceImpl implements OpportunityService {
|
||||
|
||||
private static final String STAGE_TYPE_CODE = "sj_xmjd";
|
||||
private static final String OPERATOR_TYPE_CODE = "sj_yzf";
|
||||
private static final Logger log = LoggerFactory.getLogger(OpportunityServiceImpl.class);
|
||||
|
||||
private final OpportunityMapper opportunityMapper;
|
||||
private final OmsClient omsClient;
|
||||
|
||||
public OpportunityServiceImpl(OpportunityMapper opportunityMapper) {
|
||||
public OpportunityServiceImpl(OpportunityMapper opportunityMapper, OmsClient omsClient) {
|
||||
this.opportunityMapper = opportunityMapper;
|
||||
this.omsClient = omsClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OpportunityMetaDTO getMeta() {
|
||||
return new OpportunityMetaDTO(
|
||||
opportunityMapper.selectDictItems(STAGE_TYPE_CODE),
|
||||
opportunityMapper.selectDictItems(OPERATOR_TYPE_CODE));
|
||||
opportunityMapper.selectDictItems(OPERATOR_TYPE_CODE),
|
||||
opportunityMapper.selectProvinceAreaOptions());
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
@ -47,6 +61,15 @@ public class OpportunityServiceImpl implements OpportunityService {
|
|||
}
|
||||
|
||||
@Override
|
||||
public List<OmsPreSalesOptionDTO> getOmsPreSalesOptions(Long userId) {
|
||||
if (userId == null || userId <= 0) {
|
||||
throw new BusinessException("登录用户不存在");
|
||||
}
|
||||
return omsClient.listPreSalesUsers();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public Long createOpportunity(Long userId, CreateOpportunityRequest request) {
|
||||
fillDefaults(request);
|
||||
Long customerId = opportunityMapper.selectOwnedCustomerIdByName(userId, request.getCustomerName().trim());
|
||||
|
|
@ -59,10 +82,13 @@ public class OpportunityServiceImpl implements OpportunityService {
|
|||
if (request.getId() == null) {
|
||||
throw new BusinessException("商机新增失败");
|
||||
}
|
||||
|
||||
syncCreatedOpportunityCodeStrict(userId, request.getId());
|
||||
return request.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public Long updateOpportunity(Long userId, Long opportunityId, CreateOpportunityRequest request) {
|
||||
if (opportunityId == null || opportunityId <= 0) {
|
||||
throw new BusinessException("商机不存在");
|
||||
|
|
@ -70,9 +96,6 @@ public class OpportunityServiceImpl implements OpportunityService {
|
|||
if (opportunityMapper.countOwnedOpportunity(userId, opportunityId) <= 0) {
|
||||
throw new BusinessException("无权编辑该商机");
|
||||
}
|
||||
if (Boolean.TRUE.equals(opportunityMapper.selectPushedToOms(userId, opportunityId))) {
|
||||
throw new BusinessException("该商机已推送 OMS,不能再编辑");
|
||||
}
|
||||
|
||||
fillDefaults(request);
|
||||
Long customerId = opportunityMapper.selectOwnedCustomerIdByName(userId, request.getCustomerName().trim());
|
||||
|
|
@ -85,11 +108,26 @@ public class OpportunityServiceImpl implements OpportunityService {
|
|||
if (updated <= 0) {
|
||||
throw new BusinessException("商机更新失败");
|
||||
}
|
||||
|
||||
syncUpdatedOpportunityCodeBestEffort(userId, opportunityId);
|
||||
return opportunityId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long pushToOms(Long userId, Long opportunityId) {
|
||||
@Transactional
|
||||
public Long updateOpportunityByIntegration(UpdateOpportunityIntegrationRequest request) {
|
||||
OpportunityIntegrationTargetDTO target = requireOpportunityIntegrationTarget(request);
|
||||
normalizeIntegrationUpdateRequest(request, target);
|
||||
|
||||
int updated = opportunityMapper.updateOpportunityByIntegration(target.getId(), request);
|
||||
if (updated <= 0) {
|
||||
throw new BusinessException("商机更新失败");
|
||||
}
|
||||
return target.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long pushToOms(Long userId, Long opportunityId, PushOpportunityToOmsRequest request) {
|
||||
if (opportunityId == null || opportunityId <= 0) {
|
||||
throw new BusinessException("商机不存在");
|
||||
}
|
||||
|
|
@ -99,7 +137,37 @@ public class OpportunityServiceImpl implements OpportunityService {
|
|||
if (Boolean.TRUE.equals(opportunityMapper.selectPushedToOms(userId, opportunityId))) {
|
||||
throw new BusinessException("该商机已推送 OMS,请勿重复操作");
|
||||
}
|
||||
int updated = opportunityMapper.pushOpportunityToOms(userId, opportunityId);
|
||||
|
||||
OpportunityOmsPushDataDTO pushData = opportunityMapper.selectOpportunityOmsPushData(userId, opportunityId);
|
||||
if (pushData == null) {
|
||||
throw new BusinessException("未找到商机推送数据");
|
||||
}
|
||||
validatePushBaseData(pushData);
|
||||
|
||||
OmsPreSalesOptionDTO selectedPreSales = resolveSelectedPreSales(pushData, request, omsClient.listPreSalesUsers());
|
||||
int preSalesUpdated = opportunityMapper.updateOpportunityPreSales(
|
||||
userId,
|
||||
opportunityId,
|
||||
selectedPreSales.getUserId(),
|
||||
selectedPreSales.getUserName());
|
||||
if (preSalesUpdated <= 0) {
|
||||
throw new BusinessException("保存售前信息失败");
|
||||
}
|
||||
pushData.setPreSalesId(selectedPreSales.getUserId());
|
||||
pushData.setPreSalesName(selectedPreSales.getUserName());
|
||||
|
||||
CurrentUserAccountDTO currentUser = requireCurrentUserAccount(userId);
|
||||
OmsPreSalesOptionDTO currentOmsUser = omsClient.ensureUserExists(
|
||||
currentUser.getUsername().trim(),
|
||||
currentUser.getDisplayName().trim());
|
||||
String existingOpportunityCode = normalizeOpportunityCode(pushData.getOpportunityCode());
|
||||
String returnedOpportunityCode = omsClient.createProject(
|
||||
pushData,
|
||||
resolveOmsCreateBy(currentOmsUser),
|
||||
existingOpportunityCode);
|
||||
String targetOpportunityCode = resolveOpportunityCodeForUpdate(existingOpportunityCode, returnedOpportunityCode);
|
||||
|
||||
int updated = opportunityMapper.markOpportunityOmsPushed(userId, opportunityId, targetOpportunityCode);
|
||||
if (updated <= 0) {
|
||||
throw new BusinessException("推送 OMS 失败");
|
||||
}
|
||||
|
|
@ -229,16 +297,14 @@ public class OpportunityServiceImpl implements OpportunityService {
|
|||
private void fillDefaults(CreateOpportunityRequest request) {
|
||||
request.setCustomerName(normalizeRequiredText(request.getCustomerName(), "最终客户不能为空"));
|
||||
request.setOpportunityName(normalizeRequiredText(request.getOpportunityName(), "项目名称不能为空"));
|
||||
request.setProjectLocation(normalizeRequiredText(request.getProjectLocation(), "请填写项目地"));
|
||||
request.setProjectLocation(normalizeRequiredText(request.getProjectLocation(), "请选择项目地"));
|
||||
request.setOperatorName(normalizeRequiredText(request.getOperatorName(), "请选择运作方"));
|
||||
request.setAmount(requirePositiveAmount(request.getAmount(), "请填写预计金额"));
|
||||
request.setDescription(normalizeOptionalText(request.getDescription()));
|
||||
if (request.getExpectedCloseDate() == null) {
|
||||
throw new BusinessException("请选择预计下单时间");
|
||||
}
|
||||
if (request.getConfidencePct() == null || request.getConfidencePct() <= 0) {
|
||||
throw new BusinessException("请选择项目把握度");
|
||||
}
|
||||
request.setConfidencePct(normalizeConfidenceGrade(request.getConfidencePct(), "请选择项目把握度"));
|
||||
if (isBlank(request.getStage())) {
|
||||
throw new BusinessException("请选择项目阶段");
|
||||
}
|
||||
|
|
@ -253,16 +319,100 @@ public class OpportunityServiceImpl implements OpportunityService {
|
|||
if (isBlank(request.getSource())) {
|
||||
request.setSource("主动开发");
|
||||
}
|
||||
if (request.getPushedToOms() == null) {
|
||||
request.setPushedToOms(Boolean.FALSE);
|
||||
}
|
||||
if (request.getConfidencePct() == null) {
|
||||
request.setConfidencePct(50);
|
||||
}
|
||||
request.setCompetitorName(normalizeRequiredText(request.getCompetitorName(), "请选择竞争对手"));
|
||||
validateOperatorRelations(request.getOperatorName(), request.getSalesExpansionId(), request.getChannelExpansionId());
|
||||
}
|
||||
|
||||
private CurrentUserAccountDTO requireCurrentUserAccount(Long userId) {
|
||||
CurrentUserAccountDTO currentUser = opportunityMapper.selectCurrentUserAccount(userId);
|
||||
if (currentUser == null || isBlank(currentUser.getUsername())) {
|
||||
throw new BusinessException("未找到当前登录用户账号");
|
||||
}
|
||||
if (isBlank(currentUser.getDisplayName())) {
|
||||
currentUser.setDisplayName(currentUser.getUsername());
|
||||
}
|
||||
return currentUser;
|
||||
}
|
||||
|
||||
private String resolveOmsCreateBy(OmsPreSalesOptionDTO omsUser) {
|
||||
if (omsUser == null || omsUser.getUserId() == null) {
|
||||
return null;
|
||||
}
|
||||
return String.valueOf(omsUser.getUserId());
|
||||
}
|
||||
|
||||
private void syncCreatedOpportunityCodeBestEffort(Long userId, Long opportunityId) {
|
||||
try {
|
||||
OpportunityOmsPushDataDTO pushData = opportunityMapper.selectOpportunityOmsPushData(userId, opportunityId);
|
||||
if (pushData == null) {
|
||||
log.warn("Skip OMS create sync because push data is missing, opportunityId={}", opportunityId);
|
||||
return;
|
||||
}
|
||||
validatePushBaseData(pushData);
|
||||
|
||||
CurrentUserAccountDTO currentUser = requireCurrentUserAccount(userId);
|
||||
OmsPreSalesOptionDTO currentOmsUser = omsClient.ensureUserExists(
|
||||
currentUser.getUsername().trim(),
|
||||
currentUser.getDisplayName().trim());
|
||||
String opportunityCode = omsClient.createProject(pushData, resolveOmsCreateBy(currentOmsUser));
|
||||
int codeUpdated = opportunityMapper.updateOpportunityCode(userId, opportunityId, opportunityCode);
|
||||
if (codeUpdated <= 0) {
|
||||
log.warn("OMS create sync succeeded but failed to persist opportunity code, opportunityId={}", opportunityId);
|
||||
}
|
||||
} catch (BusinessException exception) {
|
||||
log.warn("Create opportunity OMS sync failed, opportunityId={}, reason={}", opportunityId, exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void syncCreatedOpportunityCodeStrict(Long userId, Long opportunityId) {
|
||||
OpportunityOmsPushDataDTO pushData = opportunityMapper.selectOpportunityOmsPushData(userId, opportunityId);
|
||||
if (pushData == null) {
|
||||
throw new BusinessException("未找到商机同步数据");
|
||||
}
|
||||
validatePushBaseData(pushData);
|
||||
|
||||
CurrentUserAccountDTO currentUser = requireCurrentUserAccount(userId);
|
||||
OmsPreSalesOptionDTO currentOmsUser = omsClient.ensureUserExists(
|
||||
currentUser.getUsername().trim(),
|
||||
currentUser.getDisplayName().trim());
|
||||
String opportunityCode = omsClient.createProject(pushData, resolveOmsCreateBy(currentOmsUser));
|
||||
int codeUpdated = opportunityMapper.updateOpportunityCode(userId, opportunityId, opportunityCode);
|
||||
if (codeUpdated <= 0) {
|
||||
throw new BusinessException("保存商机编号失败");
|
||||
}
|
||||
}
|
||||
|
||||
private void syncUpdatedOpportunityCodeBestEffort(Long userId, Long opportunityId) {
|
||||
try {
|
||||
OpportunityOmsPushDataDTO pushData = opportunityMapper.selectOpportunityOmsPushData(userId, opportunityId);
|
||||
if (pushData == null) {
|
||||
log.warn("Skip OMS update sync because push data is missing, opportunityId={}", opportunityId);
|
||||
return;
|
||||
}
|
||||
validatePushBaseData(pushData);
|
||||
|
||||
CurrentUserAccountDTO currentUser = requireCurrentUserAccount(userId);
|
||||
OmsPreSalesOptionDTO currentOmsUser = omsClient.ensureUserExists(
|
||||
currentUser.getUsername().trim(),
|
||||
currentUser.getDisplayName().trim());
|
||||
String existingOpportunityCode = normalizeOpportunityCode(pushData.getOpportunityCode());
|
||||
String returnedOpportunityCode = omsClient.createProject(
|
||||
pushData,
|
||||
resolveOmsCreateBy(currentOmsUser),
|
||||
existingOpportunityCode);
|
||||
String targetOpportunityCode = resolveOpportunityCodeForUpdate(existingOpportunityCode, returnedOpportunityCode);
|
||||
|
||||
if (!Objects.equals(existingOpportunityCode, targetOpportunityCode)) {
|
||||
int codeUpdated = opportunityMapper.updateOpportunityCode(userId, opportunityId, targetOpportunityCode);
|
||||
if (codeUpdated <= 0) {
|
||||
log.warn("OMS update sync succeeded but failed to persist opportunity code, opportunityId={}", opportunityId);
|
||||
}
|
||||
}
|
||||
} catch (BusinessException exception) {
|
||||
log.warn("Update opportunity OMS sync failed, opportunityId={}, reason={}", opportunityId, exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeKeyword(String keyword) {
|
||||
if (keyword == null) {
|
||||
return null;
|
||||
|
|
@ -305,6 +455,43 @@ public class OpportunityServiceImpl implements OpportunityService {
|
|||
return trimmed.isEmpty() ? null : trimmed;
|
||||
}
|
||||
|
||||
private String normalizeOpportunityCode(String value) {
|
||||
return normalizeOptionalText(value);
|
||||
}
|
||||
|
||||
private String resolveOpportunityCodeForUpdate(String existingOpportunityCode, String returnedOpportunityCode) {
|
||||
String normalizedExisting = normalizeOpportunityCode(existingOpportunityCode);
|
||||
if (normalizedExisting != null && !normalizedExisting.toUpperCase().startsWith("OPP-")) {
|
||||
return normalizedExisting;
|
||||
}
|
||||
return normalizeOpportunityCode(returnedOpportunityCode);
|
||||
}
|
||||
|
||||
private String normalizeConfidenceGrade(String value, String blankMessage) {
|
||||
String normalized = normalizeOptionalText(value);
|
||||
if (normalized == null) {
|
||||
throw new BusinessException(blankMessage);
|
||||
}
|
||||
|
||||
String upper = normalized.toUpperCase();
|
||||
if ("A".equals(upper) || "B".equals(upper) || "C".equals(upper)) {
|
||||
return upper;
|
||||
}
|
||||
|
||||
if (normalized.matches("\\d+(\\.\\d+)?")) {
|
||||
double numericValue = Double.parseDouble(normalized);
|
||||
if (numericValue >= 80) {
|
||||
return "A";
|
||||
}
|
||||
if (numericValue >= 60) {
|
||||
return "B";
|
||||
}
|
||||
return "C";
|
||||
}
|
||||
|
||||
throw new BusinessException("项目把握度仅支持A、B、C");
|
||||
}
|
||||
|
||||
private BigDecimal requirePositiveAmount(BigDecimal value, String message) {
|
||||
if (value == null || value.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
throw new BusinessException(message);
|
||||
|
|
@ -324,6 +511,150 @@ public class OpportunityServiceImpl implements OpportunityService {
|
|||
return null;
|
||||
}
|
||||
|
||||
private OpportunityIntegrationTargetDTO requireOpportunityIntegrationTarget(UpdateOpportunityIntegrationRequest request) {
|
||||
if (request == null) {
|
||||
throw new BusinessException("请求参数不能为空");
|
||||
}
|
||||
|
||||
String normalizedOpportunityCode = trimToNull(request.getOpportunityCode());
|
||||
request.setOpportunityCode(normalizedOpportunityCode);
|
||||
|
||||
OpportunityIntegrationTargetDTO target = opportunityMapper.selectOpportunityIntegrationTarget(normalizedOpportunityCode);
|
||||
if (target == null || target.getId() == null) {
|
||||
throw new BusinessException("商机不存在");
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
private void normalizeIntegrationUpdateRequest(
|
||||
UpdateOpportunityIntegrationRequest request,
|
||||
OpportunityIntegrationTargetDTO target) {
|
||||
if (request.getOpportunityName() != null) {
|
||||
request.setOpportunityName(normalizeRequiredText(request.getOpportunityName(), "商机名称不能为空"));
|
||||
}
|
||||
if (request.getProjectLocation() != null) {
|
||||
request.setProjectLocation(normalizeRequiredText(request.getProjectLocation(), "项目地不能为空"));
|
||||
}
|
||||
if (request.getOperatorName() != null) {
|
||||
request.setOperatorName(normalizeRequiredText(request.getOperatorName(), "运作方不能为空"));
|
||||
}
|
||||
if (request.getAmount() != null) {
|
||||
request.setAmount(requirePositiveAmount(request.getAmount(), "请填写预计金额"));
|
||||
}
|
||||
if (request.getConfidencePct() != null) {
|
||||
request.setConfidencePct(normalizeConfidenceGrade(request.getConfidencePct(), "请选择项目把握度"));
|
||||
}
|
||||
if (request.getSalesExpansionId() != null && request.getSalesExpansionId() <= 0) {
|
||||
throw new BusinessException("salesExpansionId 必须大于0");
|
||||
}
|
||||
if (request.getChannelExpansionId() != null && request.getChannelExpansionId() <= 0) {
|
||||
throw new BusinessException("channelExpansionId 必须大于0");
|
||||
}
|
||||
if (request.getPreSalesId() != null && request.getPreSalesId() <= 0) {
|
||||
throw new BusinessException("preSalesId 必须大于0");
|
||||
}
|
||||
if (request.getStage() != null) {
|
||||
request.setStage(normalizeStageValue(request.getStage()));
|
||||
}
|
||||
if (request.getOpportunityType() != null) {
|
||||
request.setOpportunityType(normalizeRequiredText(request.getOpportunityType(), "建设类型不能为空"));
|
||||
}
|
||||
if (request.getProductType() != null) {
|
||||
request.setProductType(trimToEmpty(request.getProductType()));
|
||||
}
|
||||
if (request.getSource() != null) {
|
||||
request.setSource(trimToEmpty(request.getSource()));
|
||||
}
|
||||
if (request.getPreSalesName() != null) {
|
||||
request.setPreSalesName(trimToEmpty(request.getPreSalesName()));
|
||||
}
|
||||
if (request.getCompetitorName() != null) {
|
||||
request.setCompetitorName(trimToEmpty(request.getCompetitorName()));
|
||||
}
|
||||
if (request.getDescription() != null) {
|
||||
request.setDescription(trimToEmpty(request.getDescription()));
|
||||
}
|
||||
request.setStatus(resolveIntegrationStatus(request.getStatus(), request.getStage()));
|
||||
autoFillOmsPushTime(request);
|
||||
validateIntegrationOperatorRelations(request, target);
|
||||
}
|
||||
|
||||
private void autoFillOmsPushTime(UpdateOpportunityIntegrationRequest request) {
|
||||
if (Boolean.TRUE.equals(request.getPushedToOms()) && request.getOmsPushTime() == null) {
|
||||
request.setOmsPushTime(java.time.OffsetDateTime.now());
|
||||
}
|
||||
}
|
||||
|
||||
private void validateIntegrationOperatorRelations(
|
||||
UpdateOpportunityIntegrationRequest request,
|
||||
OpportunityIntegrationTargetDTO target) {
|
||||
boolean operatorRelatedUpdated = request.getOperatorName() != null
|
||||
|| request.getSalesExpansionId() != null
|
||||
|| request.getChannelExpansionId() != null;
|
||||
if (!operatorRelatedUpdated) {
|
||||
return;
|
||||
}
|
||||
|
||||
String effectiveOperatorName = request.getOperatorName() != null
|
||||
? request.getOperatorName()
|
||||
: target.getOperatorName();
|
||||
Long effectiveSalesExpansionId = request.getSalesExpansionId() != null
|
||||
? request.getSalesExpansionId()
|
||||
: target.getSalesExpansionId();
|
||||
Long effectiveChannelExpansionId = request.getChannelExpansionId() != null
|
||||
? request.getChannelExpansionId()
|
||||
: target.getChannelExpansionId();
|
||||
|
||||
if (isBlank(effectiveOperatorName)) {
|
||||
throw new BusinessException("运作方不能为空");
|
||||
}
|
||||
validateOperatorRelations(effectiveOperatorName, effectiveSalesExpansionId, effectiveChannelExpansionId);
|
||||
}
|
||||
|
||||
private String resolveIntegrationStatus(String status, String stage) {
|
||||
String normalizedStage = trimToNull(stage);
|
||||
String normalizedStatus = trimToNull(status);
|
||||
if (normalizedStage != null) {
|
||||
if ("won".equals(normalizedStage)) {
|
||||
if (normalizedStatus != null && !"won".equalsIgnoreCase(normalizedStatus.trim())) {
|
||||
throw new BusinessException("项目阶段为已成交时,状态必须为won");
|
||||
}
|
||||
return "won";
|
||||
}
|
||||
if ("lost".equals(normalizedStage)) {
|
||||
if (normalizedStatus != null && !"lost".equalsIgnoreCase(normalizedStatus.trim())) {
|
||||
throw new BusinessException("项目阶段为已放弃时,状态必须为lost");
|
||||
}
|
||||
return "lost";
|
||||
}
|
||||
if (normalizedStatus == null) {
|
||||
return "active";
|
||||
}
|
||||
}
|
||||
if (normalizedStatus == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String lowerCaseStatus = normalizedStatus.toLowerCase();
|
||||
return switch (lowerCaseStatus) {
|
||||
case "active", "closed" -> lowerCaseStatus;
|
||||
case "won", "lost" -> throw new BusinessException("更新状态为won或lost时,请同步传入对应的项目阶段");
|
||||
default -> throw new BusinessException("商机状态无效: " + normalizedStatus);
|
||||
};
|
||||
}
|
||||
|
||||
private String trimToNull(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
return trimmed.isEmpty() ? null : trimmed;
|
||||
}
|
||||
|
||||
private String trimToEmpty(String value) {
|
||||
return value == null ? null : value.trim();
|
||||
}
|
||||
|
||||
private String toStageCode(String value) {
|
||||
return switch (value) {
|
||||
case "初步沟通", "initial_contact" -> "initial_contact";
|
||||
|
|
@ -358,7 +689,9 @@ public class OpportunityServiceImpl implements OpportunityService {
|
|||
private void validateOperatorRelations(String operatorName, Long salesExpansionId, Long channelExpansionId) {
|
||||
String normalizedOperator = normalizeOperatorToken(operatorName);
|
||||
boolean hasH3c = normalizedOperator.contains("新华三") || normalizedOperator.contains("h3c");
|
||||
boolean hasChannel = normalizedOperator.contains("渠道") || normalizedOperator.contains("channel");
|
||||
boolean hasChannel = normalizedOperator.contains("渠道")
|
||||
|| normalizedOperator.contains("channel")
|
||||
|| normalizedOperator.contains("dls");
|
||||
|
||||
if (hasH3c && hasChannel) {
|
||||
if (salesExpansionId == null && channelExpansionId == null) {
|
||||
|
|
@ -389,6 +722,91 @@ public class OpportunityServiceImpl implements OpportunityService {
|
|||
.replace('+', '+');
|
||||
}
|
||||
|
||||
private void validatePushBaseData(OpportunityOmsPushDataDTO pushData) {
|
||||
if (isBlank(pushData.getOpportunityName())) {
|
||||
throw new BusinessException("商机名称不能为空");
|
||||
}
|
||||
if (isBlank(pushData.getCustomerName())) {
|
||||
throw new BusinessException("最终客户不能为空");
|
||||
}
|
||||
if (isBlank(pushData.getOperatorName())) {
|
||||
throw new BusinessException("运作方不能为空");
|
||||
}
|
||||
if (pushData.getAmount() == null || pushData.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
|
||||
throw new BusinessException("预计金额不能为空");
|
||||
}
|
||||
if (isBlank(pushData.getExpectedCloseDate())) {
|
||||
throw new BusinessException("预计下单时间不能为空");
|
||||
}
|
||||
pushData.setConfidencePct(normalizeConfidenceGrade(pushData.getConfidencePct(), "项目把握度不能为空"));
|
||||
if (isBlank(pushData.getStage()) && isBlank(pushData.getStageCode())) {
|
||||
throw new BusinessException("项目阶段不能为空");
|
||||
}
|
||||
if (isBlank(pushData.getOpportunityType())) {
|
||||
throw new BusinessException("建设类型不能为空");
|
||||
}
|
||||
if (operatorRequiresChannel(pushData.getOperatorName()) && isBlank(pushData.getChannelName())) {
|
||||
throw new BusinessException("推送 OMS 前请先关联渠道名称");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean operatorRequiresChannel(String operatorName) {
|
||||
String normalizedOperator = normalizeOperatorToken(operatorName);
|
||||
return normalizedOperator.contains("渠道")
|
||||
|| normalizedOperator.contains("channel")
|
||||
|| normalizedOperator.contains("dls");
|
||||
}
|
||||
|
||||
private OmsPreSalesOptionDTO resolveSelectedPreSales(
|
||||
OpportunityOmsPushDataDTO pushData,
|
||||
PushOpportunityToOmsRequest request,
|
||||
List<OmsPreSalesOptionDTO> options) {
|
||||
if (options == null || options.isEmpty()) {
|
||||
throw new BusinessException("OMS未返回售前人员列表");
|
||||
}
|
||||
|
||||
Long requestedId = request == null ? null : request.getPreSalesId();
|
||||
String requestedName = request == null ? null : normalizeOptionalText(request.getPreSalesName());
|
||||
Long existingId = pushData.getPreSalesId();
|
||||
String existingName = normalizeOptionalText(pushData.getPreSalesName());
|
||||
|
||||
if (requestedId != null) {
|
||||
for (OmsPreSalesOptionDTO option : options) {
|
||||
if (Objects.equals(option.getUserId(), requestedId)) {
|
||||
return option;
|
||||
}
|
||||
}
|
||||
throw new BusinessException("所选售前人员在OMS中不存在");
|
||||
}
|
||||
|
||||
if (!isBlank(requestedName)) {
|
||||
for (OmsPreSalesOptionDTO option : options) {
|
||||
if (requestedName.equals(option.getUserName())) {
|
||||
return option;
|
||||
}
|
||||
}
|
||||
throw new BusinessException("所选售前人员在OMS中不存在");
|
||||
}
|
||||
|
||||
if (existingId != null) {
|
||||
for (OmsPreSalesOptionDTO option : options) {
|
||||
if (Objects.equals(option.getUserId(), existingId)) {
|
||||
return option;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isBlank(existingName)) {
|
||||
for (OmsPreSalesOptionDTO option : options) {
|
||||
if (existingName.equals(option.getUserName())) {
|
||||
return option;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new BusinessException("推送 OMS 前请选择售前人员");
|
||||
}
|
||||
|
||||
private record CommunicationRecord(String time, String content) {
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ unisbase:
|
|||
- /api/wecom/sso/**
|
||||
internal-auth:
|
||||
enabled: true
|
||||
secret: change-me-internal-secret
|
||||
secret: f0eb247f84db4e328fb27ce8ff6e7be96e73a53a7e9c4793395ad10d999e0d77
|
||||
header-name: X-Internal-Secret
|
||||
app:
|
||||
upload-path: /Users/kangwenjing/Downloads/crm/uploads
|
||||
|
|
@ -67,3 +67,14 @@ unisbase:
|
|||
state-ttl-seconds: 300
|
||||
ticket-ttl-seconds: 180
|
||||
access-token-safety-seconds: 120
|
||||
oms:
|
||||
enabled: ${OMS_ENABLED:true}
|
||||
base-url: ${OMS_BASE_URL:http://10.100.52.135:28080}
|
||||
api-key: ${OMS_API_KEY:c7f858d0-30b8-4b7f-9ea1-0ccf5ceb1c54}
|
||||
api-key-header: ${OMS_API_KEY_HEADER:apiKey}
|
||||
user-info-path: ${OMS_USER_INFO_PATH:/api/v1/user/info}
|
||||
user-add-path: ${OMS_USER_ADD_PATH:/api/v1/user/add}
|
||||
project-add-path: ${OMS_PROJECT_ADD_PATH:/api/v1/project/add}
|
||||
pre-sales-role-name: ${OMS_PRE_SALES_ROLE_NAME:售前}
|
||||
connect-timeout-seconds: ${OMS_CONNECT_TIMEOUT_SECONDS:5}
|
||||
read-timeout-seconds: ${OMS_READ_TIMEOUT_SECONDS:15}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ unisbase:
|
|||
- /api/wecom/sso/**
|
||||
internal-auth:
|
||||
enabled: true
|
||||
secret: change-me-internal-secret
|
||||
secret: f0eb247f84db4e328fb27ce8ff6e7be96e73a53a7e9c4793395ad10d999e0d77
|
||||
header-name: X-Internal-Secret
|
||||
app:
|
||||
upload-path: /Users/kangwenjing/Downloads/crm/uploads
|
||||
|
|
@ -72,3 +72,14 @@ unisbase:
|
|||
state-ttl-seconds: 300
|
||||
ticket-ttl-seconds: 180
|
||||
access-token-safety-seconds: 120
|
||||
oms:
|
||||
enabled: ${OMS_ENABLED:true}
|
||||
base-url: ${OMS_BASE_URL:http://10.100.52.135:28080}
|
||||
api-key: ${OMS_API_KEY:c7f858d0-30b8-4b7f-9ea1-0ccf5ceb1c54}
|
||||
api-key-header: ${OMS_API_KEY_HEADER:apiKey}
|
||||
user-info-path: ${OMS_USER_INFO_PATH:/api/v1/user/info}
|
||||
user-add-path: ${OMS_USER_ADD_PATH:/api/v1/user/add}
|
||||
project-add-path: ${OMS_PROJECT_ADD_PATH:/api/v1/project/add}
|
||||
pre-sales-role-name: ${OMS_PRE_SALES_ROLE_NAME:售前}
|
||||
connect-timeout-seconds: ${OMS_CONNECT_TIMEOUT_SECONDS:5}
|
||||
read-timeout-seconds: ${OMS_READ_TIMEOUT_SECONDS:15}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,32 @@
|
|||
order by sort_order asc nulls last, dict_item_id asc
|
||||
</select>
|
||||
|
||||
<select id="selectProvinceAreaOptions" resultType="com.unis.crm.dto.expansion.DictOptionDTO">
|
||||
select
|
||||
name as label,
|
||||
name as value
|
||||
from cnarea
|
||||
where level = 1
|
||||
order by area_code asc, id asc
|
||||
</select>
|
||||
|
||||
<select id="selectCityAreaOptionsByProvinceName" resultType="com.unis.crm.dto.expansion.DictOptionDTO">
|
||||
select
|
||||
c.name as label,
|
||||
c.name as value
|
||||
from cnarea c
|
||||
where c.level = 2
|
||||
and c.parent_code = (
|
||||
select p.area_code
|
||||
from cnarea p
|
||||
where p.level = 1
|
||||
and p.name = #{provinceName}
|
||||
order by p.id asc
|
||||
limit 1
|
||||
)
|
||||
order by c.area_code asc, c.id asc
|
||||
</select>
|
||||
|
||||
<select id="selectNextChannelCode" resultType="java.lang.String">
|
||||
select 'QD-' || to_char(current_date, 'YYYYMMDD') || '-' || lpad((
|
||||
coalesce((
|
||||
|
|
@ -93,9 +119,14 @@
|
|||
'channel' as type,
|
||||
coalesce(c.channel_code, '') as channelCode,
|
||||
c.channel_name as name,
|
||||
coalesce(c.province, '无') as province,
|
||||
coalesce(c.province, '') as provinceCode,
|
||||
coalesce(province_area.name, nullif(c.province, ''), '无') as province,
|
||||
coalesce(c.city, '') as cityCode,
|
||||
coalesce(city_area.name, nullif(c.city, ''), '无') as city,
|
||||
coalesce(c.office_address, '无') as officeAddress,
|
||||
coalesce(c.channel_industry, c.industry, '') as channelIndustryCode,
|
||||
coalesce(c.channel_industry, c.industry, '无') as channelIndustry,
|
||||
coalesce(c.certification_level, '无') as certificationLevel,
|
||||
coalesce(cast(c.annual_revenue as varchar), '') as annualRevenue,
|
||||
case
|
||||
when c.annual_revenue is null then '无'
|
||||
|
|
@ -143,6 +174,12 @@
|
|||
order by cc.sort_order asc nulls last, cc.id asc
|
||||
limit 1
|
||||
) primary_contact on true
|
||||
left join cnarea province_area
|
||||
on province_area.level = 1
|
||||
and (province_area.name = c.province or province_area.area_code = c.province)
|
||||
left join cnarea city_area
|
||||
on city_area.level = 2
|
||||
and (city_area.name = c.city or city_area.area_code = c.city)
|
||||
left join sys_dict_item channel_attribute_dict
|
||||
on channel_attribute_dict.type_code = 'tz_qdsx'
|
||||
and channel_attribute_dict.item_value = c.channel_attribute
|
||||
|
|
@ -160,6 +197,19 @@
|
|||
or coalesce(c.channel_code, '') ilike concat('%', #{keyword}, '%')
|
||||
or coalesce(c.channel_industry, c.industry, '') ilike concat('%', #{keyword}, '%')
|
||||
or coalesce(c.province, '') ilike concat('%', #{keyword}, '%')
|
||||
or coalesce(c.city, '') ilike concat('%', #{keyword}, '%')
|
||||
or coalesce(c.certification_level, '') ilike concat('%', #{keyword}, '%')
|
||||
or coalesce(province_area.name, '') ilike concat('%', #{keyword}, '%')
|
||||
or coalesce(city_area.name, '') ilike concat('%', #{keyword}, '%')
|
||||
or exists (
|
||||
select 1
|
||||
from sys_dict_item industry_dict
|
||||
where industry_dict.type_code = 'tz_sshy'
|
||||
and industry_dict.status = 1
|
||||
and coalesce(industry_dict.is_deleted, 0) = 0
|
||||
and industry_dict.item_value = any(string_to_array(coalesce(c.channel_industry, c.industry, ''), ','))
|
||||
and industry_dict.item_label ilike concat('%', #{keyword}, '%')
|
||||
)
|
||||
or exists (
|
||||
select 1
|
||||
from crm_channel_expansion_contact cc
|
||||
|
|
@ -309,9 +359,11 @@
|
|||
insert into crm_channel_expansion (
|
||||
channel_code,
|
||||
province,
|
||||
city,
|
||||
channel_name,
|
||||
office_address,
|
||||
channel_industry,
|
||||
certification_level,
|
||||
annual_revenue,
|
||||
staff_size,
|
||||
contact_established_date,
|
||||
|
|
@ -336,9 +388,11 @@
|
|||
), 0) + 1
|
||||
)::text, 3, '0'),
|
||||
#{request.province},
|
||||
#{request.city},
|
||||
#{request.channelName},
|
||||
#{request.officeAddress},
|
||||
#{request.channelIndustry},
|
||||
#{request.certificationLevel},
|
||||
#{request.annualRevenue},
|
||||
#{request.staffSize},
|
||||
#{request.contactEstablishedDate},
|
||||
|
|
@ -421,8 +475,10 @@
|
|||
update crm_channel_expansion c
|
||||
set channel_name = #{request.channelName},
|
||||
province = #{request.province},
|
||||
city = #{request.city},
|
||||
office_address = #{request.officeAddress},
|
||||
channel_industry = #{request.channelIndustry},
|
||||
certification_level = #{request.certificationLevel},
|
||||
annual_revenue = #{request.annualRevenue},
|
||||
staff_size = #{request.staffSize},
|
||||
contact_established_date = #{request.contactEstablishedDate},
|
||||
|
|
|
|||
|
|
@ -15,6 +15,15 @@
|
|||
order by sort_order asc nulls last, dict_item_id asc
|
||||
</select>
|
||||
|
||||
<select id="selectProvinceAreaOptions" resultType="com.unis.crm.dto.opportunity.OpportunityDictOptionDTO">
|
||||
select
|
||||
name as label,
|
||||
name as value
|
||||
from cnarea
|
||||
where level = 1
|
||||
order by area_code asc, id asc
|
||||
</select>
|
||||
|
||||
<select id="selectDictLabel" resultType="java.lang.String">
|
||||
select item_label
|
||||
from sys_dict_item
|
||||
|
|
@ -61,6 +70,8 @@
|
|||
coalesce(se.candidate_name, '') as salesExpansionName,
|
||||
o.channel_expansion_id as channelExpansionId,
|
||||
coalesce(ce.channel_name, '') as channelExpansionName,
|
||||
o.pre_sales_id as preSalesId,
|
||||
coalesce(o.pre_sales_name, '') as preSalesName,
|
||||
coalesce(o.competitor_name, '') as competitorName,
|
||||
coalesce((
|
||||
select
|
||||
|
|
@ -226,8 +237,8 @@
|
|||
#{request.salesExpansionId},
|
||||
#{request.channelExpansionId},
|
||||
#{request.competitorName},
|
||||
#{request.pushedToOms},
|
||||
case when #{request.pushedToOms} then now() else null end,
|
||||
false,
|
||||
null,
|
||||
#{request.description},
|
||||
case
|
||||
when #{request.stage} = 'won' then 'won'
|
||||
|
|
@ -252,6 +263,79 @@
|
|||
limit 1
|
||||
</select>
|
||||
|
||||
<update id="updateOpportunityCode">
|
||||
update crm_opportunity o
|
||||
set opportunity_code = #{opportunityCode},
|
||||
updated_at = now()
|
||||
where o.id = #{opportunityId}
|
||||
</update>
|
||||
|
||||
<select id="selectOpportunityOmsPushData" resultType="com.unis.crm.dto.opportunity.OpportunityOmsPushDataDTO">
|
||||
select
|
||||
o.id as opportunityId,
|
||||
o.opportunity_code as opportunityCode,
|
||||
o.opportunity_name as opportunityName,
|
||||
coalesce(c.customer_name, '') as customerName,
|
||||
coalesce(o.operator_name, '') as operatorName,
|
||||
o.amount,
|
||||
to_char(o.expected_close_date, 'YYYY-MM-DD') as expectedCloseDate,
|
||||
o.confidence_pct as confidencePct,
|
||||
coalesce(o.stage, '') as stage,
|
||||
coalesce(o.stage, '') as stageCode,
|
||||
coalesce(o.competitor_name, '') as competitorName,
|
||||
o.pre_sales_id as preSalesId,
|
||||
coalesce(o.pre_sales_name, '') as preSalesName,
|
||||
coalesce(o.opportunity_type, '') as opportunityType,
|
||||
coalesce(se.candidate_name, '') as salesContactName,
|
||||
coalesce(se.mobile, '') as salesContactMobile,
|
||||
coalesce(ce.channel_name, '') as channelName,
|
||||
coalesce(province_area.name, nullif(ce.province, ''), '') as province,
|
||||
coalesce(city_area.name, nullif(ce.city, ''), '') as city,
|
||||
coalesce(ce.office_address, '') as officeAddress,
|
||||
coalesce(primary_contact.contact_name, ce.contact_name, '') as channelContactName,
|
||||
coalesce(primary_contact.contact_mobile, ce.contact_mobile, '') as channelContactMobile,
|
||||
coalesce(ce.certification_level, '') as certificationLevel
|
||||
from crm_opportunity o
|
||||
left join crm_customer c on c.id = o.customer_id
|
||||
left join crm_sales_expansion se on se.id = o.sales_expansion_id
|
||||
left join crm_channel_expansion ce on ce.id = o.channel_expansion_id
|
||||
left join (
|
||||
select distinct on (cc.channel_expansion_id)
|
||||
cc.channel_expansion_id,
|
||||
cc.contact_name,
|
||||
cc.contact_mobile
|
||||
from crm_channel_expansion_contact cc
|
||||
order by cc.channel_expansion_id, cc.sort_order asc nulls last, cc.id asc
|
||||
) primary_contact on primary_contact.channel_expansion_id = ce.id
|
||||
left join cnarea province_area
|
||||
on province_area.level = 1
|
||||
and (province_area.name = ce.province or province_area.area_code = ce.province)
|
||||
left join cnarea city_area
|
||||
on city_area.level = 2
|
||||
and (city_area.name = ce.city or city_area.area_code = ce.city)
|
||||
where o.id = #{opportunityId}
|
||||
limit 1
|
||||
</select>
|
||||
|
||||
<select id="selectCurrentUserAccount" resultType="com.unis.crm.dto.opportunity.CurrentUserAccountDTO">
|
||||
select
|
||||
u.user_id as userId,
|
||||
u.username,
|
||||
u.display_name as displayName
|
||||
from sys_user u
|
||||
where u.user_id = #{userId}
|
||||
and u.is_deleted = 0
|
||||
limit 1
|
||||
</select>
|
||||
|
||||
<update id="updateOpportunityPreSales">
|
||||
update crm_opportunity o
|
||||
set pre_sales_id = #{preSalesId},
|
||||
pre_sales_name = #{preSalesName},
|
||||
updated_at = now()
|
||||
where o.id = #{opportunityId}
|
||||
</update>
|
||||
|
||||
<update id="updateOpportunity">
|
||||
update crm_opportunity o
|
||||
set opportunity_name = #{request.opportunityName},
|
||||
|
|
@ -268,11 +352,6 @@
|
|||
sales_expansion_id = #{request.salesExpansionId},
|
||||
channel_expansion_id = #{request.channelExpansionId},
|
||||
competitor_name = #{request.competitorName},
|
||||
pushed_to_oms = #{request.pushedToOms},
|
||||
oms_push_time = case
|
||||
when #{request.pushedToOms} then coalesce(oms_push_time, now())
|
||||
else null
|
||||
end,
|
||||
description = #{request.description},
|
||||
status = case
|
||||
when #{request.stage} = 'won' then 'won'
|
||||
|
|
@ -283,10 +362,95 @@
|
|||
where o.id = #{opportunityId}
|
||||
</update>
|
||||
|
||||
<update id="pushOpportunityToOms">
|
||||
<select id="selectOpportunityIntegrationTarget" resultType="com.unis.crm.dto.opportunity.OpportunityIntegrationTargetDTO">
|
||||
select
|
||||
o.id,
|
||||
o.opportunity_code as opportunityCode,
|
||||
o.owner_user_id as ownerUserId,
|
||||
coalesce(o.source, '') as source,
|
||||
coalesce(o.operator_name, '') as operatorName,
|
||||
o.sales_expansion_id as salesExpansionId,
|
||||
o.channel_expansion_id as channelExpansionId,
|
||||
coalesce(o.stage, '') as stage,
|
||||
coalesce(o.status, 'active') as status
|
||||
from crm_opportunity o
|
||||
where o.opportunity_code = #{opportunityCode}
|
||||
limit 1
|
||||
</select>
|
||||
|
||||
<update id="updateOpportunityByIntegration">
|
||||
update crm_opportunity o
|
||||
<set>
|
||||
<if test="request.opportunityName != null">
|
||||
opportunity_name = #{request.opportunityName},
|
||||
</if>
|
||||
<if test="request.projectLocation != null">
|
||||
project_location = #{request.projectLocation},
|
||||
</if>
|
||||
<if test="request.operatorName != null">
|
||||
operator_name = #{request.operatorName},
|
||||
</if>
|
||||
<if test="request.amount != null">
|
||||
amount = #{request.amount},
|
||||
</if>
|
||||
<if test="request.expectedCloseDate != null">
|
||||
expected_close_date = #{request.expectedCloseDate},
|
||||
</if>
|
||||
<if test="request.confidencePct != null">
|
||||
confidence_pct = #{request.confidencePct},
|
||||
</if>
|
||||
<if test="request.stage != null">
|
||||
stage = #{request.stage},
|
||||
</if>
|
||||
<if test="request.opportunityType != null">
|
||||
opportunity_type = #{request.opportunityType},
|
||||
</if>
|
||||
<if test="request.productType != null">
|
||||
product_type = nullif(#{request.productType}, ''),
|
||||
</if>
|
||||
<if test="request.source != null">
|
||||
source = nullif(#{request.source}, ''),
|
||||
</if>
|
||||
<if test="request.salesExpansionId != null">
|
||||
sales_expansion_id = #{request.salesExpansionId},
|
||||
</if>
|
||||
<if test="request.channelExpansionId != null">
|
||||
channel_expansion_id = #{request.channelExpansionId},
|
||||
</if>
|
||||
<if test="request.preSalesId != null">
|
||||
pre_sales_id = #{request.preSalesId},
|
||||
</if>
|
||||
<if test="request.preSalesName != null">
|
||||
pre_sales_name = nullif(#{request.preSalesName}, ''),
|
||||
</if>
|
||||
<if test="request.competitorName != null">
|
||||
competitor_name = nullif(#{request.competitorName}, ''),
|
||||
</if>
|
||||
<if test="request.archived != null">
|
||||
archived = #{request.archived},
|
||||
</if>
|
||||
<if test="request.pushedToOms != null">
|
||||
pushed_to_oms = #{request.pushedToOms},
|
||||
</if>
|
||||
<if test="request.omsPushTime != null">
|
||||
oms_push_time = #{request.omsPushTime},
|
||||
</if>
|
||||
<if test="request.description != null">
|
||||
description = nullif(#{request.description}, ''),
|
||||
</if>
|
||||
<if test="request.status != null">
|
||||
status = #{request.status},
|
||||
</if>
|
||||
updated_at = now()
|
||||
</set>
|
||||
where o.id = #{opportunityId}
|
||||
</update>
|
||||
|
||||
<update id="markOpportunityOmsPushed">
|
||||
update crm_opportunity o
|
||||
set pushed_to_oms = true,
|
||||
oms_push_time = coalesce(oms_push_time, now()),
|
||||
opportunity_code = #{opportunityCode},
|
||||
updated_at = now()
|
||||
where o.id = #{opportunityId}
|
||||
and coalesce(pushed_to_oms, false) = false
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
package com.unis.crm.controller;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.unis.crm.common.CrmGlobalExceptionHandler;
|
||||
import com.unis.crm.service.DashboardService;
|
||||
import com.unis.crm.service.ExpansionService;
|
||||
import com.unis.crm.service.ProfileService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
|
||||
|
||||
class AuthHeaderHandlingWebMvcTest {
|
||||
|
||||
private MockMvc mockMvc;
|
||||
private ProfileService profileService;
|
||||
private DashboardService dashboardService;
|
||||
private ExpansionService expansionService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
profileService = mock(ProfileService.class);
|
||||
dashboardService = mock(DashboardService.class);
|
||||
expansionService = mock(ExpansionService.class);
|
||||
|
||||
LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean();
|
||||
validator.afterPropertiesSet();
|
||||
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(
|
||||
new ProfileController(profileService),
|
||||
new DashboardController(dashboardService),
|
||||
new ExpansionController(expansionService))
|
||||
.setControllerAdvice(new CrmGlobalExceptionHandler())
|
||||
.setValidator(validator)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void profileOverviewShouldReturnUnauthorizedWhenUserHeaderIsMissing() throws Exception {
|
||||
mockMvc.perform(get("/api/profile/overview"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value("-1"))
|
||||
.andExpect(jsonPath("$.msg").value("登录已失效,请重新登录"));
|
||||
|
||||
verifyNoInteractions(profileService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void profileOverviewShouldReturnUnauthorizedWhenUserHeaderIsInvalid() throws Exception {
|
||||
mockMvc.perform(get("/api/profile/overview").header("X-User-Id", "0"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value("-1"))
|
||||
.andExpect(jsonPath("$.msg").value("登录已失效,请重新登录"));
|
||||
|
||||
verifyNoInteractions(profileService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void dashboardHomeShouldReturnUnauthorizedWhenUserHeaderIsMissing() throws Exception {
|
||||
mockMvc.perform(get("/api/dashboard/home"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value("-1"))
|
||||
.andExpect(jsonPath("$.msg").value("登录已失效,请重新登录"));
|
||||
|
||||
verifyNoInteractions(dashboardService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void expansionMetaShouldReturnUnauthorizedWhenUserHeaderIsMissing() throws Exception {
|
||||
mockMvc.perform(get("/api/expansion/meta"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value("-1"))
|
||||
.andExpect(jsonPath("$.msg").value("登录已失效,请重新登录"));
|
||||
|
||||
verifyNoInteractions(expansionService);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
package com.unis.crm.controller;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.unis.crm.common.CrmGlobalExceptionHandler;
|
||||
import com.unis.crm.config.InternalAuthProperties;
|
||||
import com.unis.crm.service.OpportunityService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
|
||||
|
||||
class OpportunityIntegrationControllerWebMvcTest {
|
||||
|
||||
private static final String INTERNAL_SECRET_HEADER = "X-Internal-Secret";
|
||||
private static final String INTERNAL_SECRET_VALUE = "test-internal-secret";
|
||||
|
||||
private MockMvc mockMvc;
|
||||
private OpportunityService opportunityService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
opportunityService = mock(OpportunityService.class);
|
||||
|
||||
InternalAuthProperties internalAuthProperties = new InternalAuthProperties();
|
||||
internalAuthProperties.setEnabled(true);
|
||||
internalAuthProperties.setHeaderName(INTERNAL_SECRET_HEADER);
|
||||
internalAuthProperties.setSecret(INTERNAL_SECRET_VALUE);
|
||||
|
||||
LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean();
|
||||
validator.afterPropertiesSet();
|
||||
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(
|
||||
new OpportunityIntegrationController(opportunityService, internalAuthProperties))
|
||||
.setControllerAdvice(new CrmGlobalExceptionHandler())
|
||||
.setValidator(validator)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateOpportunityShouldReturnUnauthorizedWhenInternalSecretIsMissing() throws Exception {
|
||||
mockMvc.perform(put("/api/opportunities/integration/update")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"opportunityCode\":\"OPP-20260401-001\",\"stage\":\"won\"}"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value("-1"))
|
||||
.andExpect(jsonPath("$.msg").value("内部接口鉴权失败"));
|
||||
|
||||
verifyNoInteractions(opportunityService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateOpportunityShouldReturnUnauthorizedWhenInternalSecretIsInvalid() throws Exception {
|
||||
mockMvc.perform(put("/api/opportunities/integration/update")
|
||||
.header(INTERNAL_SECRET_HEADER, "bad-secret")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"opportunityCode\":\"OPP-20260401-001\",\"stage\":\"won\"}"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value("-1"))
|
||||
.andExpect(jsonPath("$.msg").value("内部接口鉴权失败"));
|
||||
|
||||
verifyNoInteractions(opportunityService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateOpportunityShouldDelegateToServiceWhenInternalSecretIsValid() throws Exception {
|
||||
when(opportunityService.updateOpportunityByIntegration(any())).thenReturn(1L);
|
||||
|
||||
mockMvc.perform(put("/api/opportunities/integration/update")
|
||||
.header(INTERNAL_SECRET_HEADER, INTERNAL_SECRET_VALUE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"opportunityCode\":\"OPP-20260401-001\",\"stage\":\"won\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value("0"))
|
||||
.andExpect(jsonPath("$.data").value(1L));
|
||||
|
||||
verify(opportunityService).updateOpportunityByIntegration(any());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
# 商机更新对接接口
|
||||
|
||||
## 接口信息
|
||||
|
||||
- 请求方式:`PUT`
|
||||
- 请求路径:`/api/opportunities/integration/update`
|
||||
- 请求头:
|
||||
- `Content-Type: application/json`
|
||||
- `X-Internal-Secret: <内部接口密钥>`
|
||||
|
||||
## 接口说明
|
||||
|
||||
- 通过 `opportunityCode` 定位商机
|
||||
- 按传入字段做部分更新,未传字段不修改
|
||||
- 不支持更新商机的最终客户
|
||||
|
||||
## 请求参数
|
||||
|
||||
### 必填
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| ----------------- | ------ | ---- |
|
||||
| `opportunityCode` | string | 商机编号 |
|
||||
|
||||
### 可选
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| -------------------- | ------- | -------------------------------------- |
|
||||
| `opportunityName` | string | 商机名称 |
|
||||
| `projectLocation` | string | 项目地 |
|
||||
| `operatorName` | string | 运作方 |
|
||||
| `amount` | number | 商机金额,必须大于 0 |
|
||||
| `expectedCloseDate` | string | 预计结单日期,格式 `YYYY-MM-DD` |
|
||||
| `confidencePct` | string | 把握度,建议传 `A`、`B`、`C` |
|
||||
| `stage` | string | 项目阶段,建议传 CRM 当前字典码值 |
|
||||
| `opportunityType` | string | 建设类型 |
|
||||
| `productType` | string | 产品类型 |
|
||||
| `source` | string | 商机来源 |
|
||||
| `salesExpansionId` | number | 销售拓展 ID,必须大于 0 |
|
||||
| `channelExpansionId` | number | 渠道拓展 ID,必须大于 0 |
|
||||
| `preSalesId` | number | 售前 ID,必须大于 0 |
|
||||
| `preSalesName` | string | 售前姓名 |
|
||||
| `competitorName` | string | 竞品名称 |
|
||||
| `archived` | boolean | 是否归档 |
|
||||
| `pushedToOms` | boolean | 是否已推送 OMS |
|
||||
| `omsPushTime` | string | 推送 OMS 时间,建议 ISO 8601 格式 |
|
||||
| `status` | string | 商机状态,支持 `active`、`closed`、`won`、`lost` |
|
||||
| `description` | string | 备注 |
|
||||
|
||||
## 关键规则
|
||||
|
||||
- 除 `opportunityCode` 外,至少还要传一个更新字段
|
||||
|
||||
## 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"opportunityCode": "OPP-20260401-001",
|
||||
"stage": "won",
|
||||
"status": "won",
|
||||
"confidencePct": "A",
|
||||
"amount": 2800000,
|
||||
"expectedCloseDate": "2026-04-30",
|
||||
"description": "外部系统回写成交结果"
|
||||
}
|
||||
```
|
||||
|
||||
## 返回示例
|
||||
|
||||
### 成功
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "0",
|
||||
"msg": "success",
|
||||
"data": 123
|
||||
}
|
||||
```
|
||||
|
||||
### 失败
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "-1",
|
||||
"msg": "商机不存在",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
## 常见错误
|
||||
|
||||
- `内部接口鉴权失败`
|
||||
- `商机不存在`
|
||||
- `opportunityCode 不能为空`
|
||||
- `至少传入一个需要更新的字段`
|
||||
- `项目阶段无效: xxx`
|
||||
- `项目把握度仅支持A、B、C`
|
||||
|
||||
## curl 示例
|
||||
|
||||
```bash
|
||||
curl -X PUT 'http://localhost:8080/api/opportunities/integration/update' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'X-Internal-Secret: f0eb247f84db4e328fb27ce8ff6e7be96e73a53a7e9c4793395ad10d999e0d77' \
|
||||
-d '{
|
||||
"opportunityCode": "OPP-20260401-001",
|
||||
"stage": "won",
|
||||
"status": "won",
|
||||
"confidencePct": "A",
|
||||
"amount": 2800000,
|
||||
"expectedCloseDate": "2026-04-30",
|
||||
"description": "外部系统回写成交结果"
|
||||
}'
|
||||
```
|
||||
|
||||
|
|
@ -15,6 +15,8 @@ type AdaptiveSelectBaseProps = {
|
|||
sheetTitle?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
searchable?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
};
|
||||
|
||||
type AdaptiveSelectSingleProps = AdaptiveSelectBaseProps & {
|
||||
|
|
@ -66,11 +68,14 @@ export function AdaptiveSelect({
|
|||
sheetTitle,
|
||||
disabled = false,
|
||||
className,
|
||||
searchable = false,
|
||||
searchPlaceholder = "请输入关键字搜索",
|
||||
value,
|
||||
multiple = false,
|
||||
onChange,
|
||||
}: AdaptiveSelectProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchKeyword, setSearchKeyword] = useState("");
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const isMobile = useIsMobileViewport();
|
||||
const selectedValues = multiple
|
||||
|
|
@ -82,6 +87,14 @@ export function AdaptiveSelect({
|
|||
.filter((label): label is string => Boolean(label))
|
||||
.join("、")
|
||||
: placeholder;
|
||||
const normalizedSearchKeyword = searchKeyword.trim().toLowerCase();
|
||||
const visibleOptions = searchable && normalizedSearchKeyword
|
||||
? options.filter((option) => {
|
||||
const label = option.label.toLowerCase();
|
||||
const optionValue = option.value.toLowerCase();
|
||||
return label.includes(normalizedSearchKeyword) || optionValue.includes(normalizedSearchKeyword);
|
||||
})
|
||||
: options;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || isMobile) {
|
||||
|
|
@ -120,6 +133,12 @@ export function AdaptiveSelect({
|
|||
};
|
||||
}, [isMobile, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open && searchKeyword) {
|
||||
setSearchKeyword("");
|
||||
}
|
||||
}, [open, searchKeyword]);
|
||||
|
||||
const handleSelect = (nextValue: string) => {
|
||||
if (multiple) {
|
||||
const currentValues = Array.isArray(value) ? value : [];
|
||||
|
|
@ -187,7 +206,24 @@ export function AdaptiveSelect({
|
|||
exit={{ opacity: 0, y: 8 }}
|
||||
className="absolute z-30 mt-2 w-full rounded-2xl border border-slate-200 bg-white p-2 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
|
||||
>
|
||||
<div className="max-h-72 space-y-1 overflow-y-auto pr-1">{options.map(renderOption)}</div>
|
||||
{searchable ? (
|
||||
<div className="mb-2">
|
||||
<input
|
||||
type="text"
|
||||
value={searchKeyword}
|
||||
onChange={(event) => setSearchKeyword(event.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
className="w-full rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700 outline-none transition-colors focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-700 dark:bg-slate-800/70 dark:text-slate-100"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="max-h-72 space-y-1 overflow-y-auto pr-1">
|
||||
{visibleOptions.length > 0 ? visibleOptions.map(renderOption) : (
|
||||
<div className="rounded-xl px-3 py-6 text-center text-sm text-slate-500 dark:text-slate-400">
|
||||
未找到匹配选项
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
|
|
@ -223,7 +259,20 @@ export function AdaptiveSelect({
|
|||
</button>
|
||||
</div>
|
||||
<div className="max-h-[60vh] space-y-2 overflow-y-auto px-4 py-4 pb-[calc(1rem+env(safe-area-inset-bottom))]">
|
||||
{options.map(renderOption)}
|
||||
{searchable ? (
|
||||
<input
|
||||
type="text"
|
||||
value={searchKeyword}
|
||||
onChange={(event) => setSearchKeyword(event.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
className="w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700 outline-none transition-colors focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-700 dark:bg-slate-800/70 dark:text-slate-100"
|
||||
/>
|
||||
) : null}
|
||||
{visibleOptions.length > 0 ? visibleOptions.map(renderOption) : (
|
||||
<div className="rounded-2xl border border-dashed border-slate-200 px-4 py-6 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
|
||||
未找到匹配选项
|
||||
</div>
|
||||
)}
|
||||
{multiple ? (
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -250,7 +250,7 @@ export interface OpportunityItem {
|
|||
operatorName?: string;
|
||||
amount?: number;
|
||||
date?: string;
|
||||
confidence?: number;
|
||||
confidence?: string;
|
||||
stageCode?: string;
|
||||
stage?: string;
|
||||
type?: string;
|
||||
|
|
@ -262,6 +262,8 @@ export interface OpportunityItem {
|
|||
salesExpansionName?: string;
|
||||
channelExpansionId?: number;
|
||||
channelExpansionName?: string;
|
||||
preSalesId?: number;
|
||||
preSalesName?: string;
|
||||
competitorName?: string;
|
||||
latestProgress?: string;
|
||||
nextPlan?: string;
|
||||
|
|
@ -281,6 +283,13 @@ export interface OpportunityDictOption {
|
|||
export interface OpportunityMeta {
|
||||
stageOptions?: OpportunityDictOption[];
|
||||
operatorOptions?: OpportunityDictOption[];
|
||||
projectLocationOptions?: OpportunityDictOption[];
|
||||
}
|
||||
|
||||
export interface OmsPreSalesOption {
|
||||
userId: number;
|
||||
loginName?: string;
|
||||
userName?: string;
|
||||
}
|
||||
|
||||
export interface CreateOpportunityPayload {
|
||||
|
|
@ -290,7 +299,7 @@ export interface CreateOpportunityPayload {
|
|||
operatorName?: string;
|
||||
amount: number;
|
||||
expectedCloseDate: string;
|
||||
confidencePct: number;
|
||||
confidencePct: string;
|
||||
stage?: string;
|
||||
opportunityType?: string;
|
||||
productType?: string;
|
||||
|
|
@ -302,6 +311,11 @@ export interface CreateOpportunityPayload {
|
|||
description?: string;
|
||||
}
|
||||
|
||||
export interface PushOpportunityToOmsPayload {
|
||||
preSalesId?: number;
|
||||
preSalesName?: string;
|
||||
}
|
||||
|
||||
export interface CreateOpportunityFollowUpPayload {
|
||||
followUpType: string;
|
||||
content: string;
|
||||
|
|
@ -362,9 +376,14 @@ export interface ChannelExpansionItem {
|
|||
type: "channel";
|
||||
channelCode?: string;
|
||||
name?: string;
|
||||
provinceCode?: string;
|
||||
province?: string;
|
||||
cityCode?: string;
|
||||
city?: string;
|
||||
officeAddress?: string;
|
||||
channelIndustryCode?: string;
|
||||
channelIndustry?: string;
|
||||
certificationLevel?: string;
|
||||
annualRevenue?: string;
|
||||
revenue?: string;
|
||||
size?: number;
|
||||
|
|
@ -416,6 +435,8 @@ export interface ExpansionDictOption {
|
|||
export interface ExpansionMeta {
|
||||
officeOptions?: ExpansionDictOption[];
|
||||
industryOptions?: ExpansionDictOption[];
|
||||
provinceOptions?: ExpansionDictOption[];
|
||||
certificationLevelOptions?: ExpansionDictOption[];
|
||||
channelAttributeOptions?: ExpansionDictOption[];
|
||||
internalAttributeOptions?: ExpansionDictOption[];
|
||||
nextChannelCode?: string;
|
||||
|
|
@ -442,9 +463,11 @@ export interface CreateSalesExpansionPayload {
|
|||
export interface CreateChannelExpansionPayload {
|
||||
channelCode?: string;
|
||||
officeAddress?: string;
|
||||
channelIndustry?: string;
|
||||
channelIndustry?: string[];
|
||||
channelName: string;
|
||||
province?: string;
|
||||
city?: string;
|
||||
certificationLevel?: string;
|
||||
annualRevenue?: number;
|
||||
staffSize?: number;
|
||||
contactEstablishedDate?: string;
|
||||
|
|
@ -507,6 +530,9 @@ function serializeChannelExpansionPayload(payload: CreateChannelExpansionPayload
|
|||
const { channelAttributeCustom, ...rest } = payload;
|
||||
return {
|
||||
...rest,
|
||||
city: payload.city,
|
||||
certificationLevel: payload.certificationLevel,
|
||||
channelIndustry: encodeExpansionMultiValue(payload.channelIndustry),
|
||||
channelAttribute: encodeExpansionMultiValue(payload.channelAttribute, channelAttributeCustom),
|
||||
internalAttribute: encodeExpansionMultiValue(payload.internalAttribute),
|
||||
};
|
||||
|
|
@ -819,6 +845,10 @@ export async function getOpportunityMeta() {
|
|||
return request<OpportunityMeta>("/api/opportunities/meta", undefined, true);
|
||||
}
|
||||
|
||||
export async function getOpportunityOmsPreSalesOptions() {
|
||||
return request<OmsPreSalesOption[]>("/api/opportunities/oms/pre-sales", undefined, true);
|
||||
}
|
||||
|
||||
export async function createOpportunity(payload: CreateOpportunityPayload) {
|
||||
return request<number>("/api/opportunities", {
|
||||
method: "POST",
|
||||
|
|
@ -833,9 +863,10 @@ export async function updateOpportunity(opportunityId: number, payload: CreateOp
|
|||
}, true);
|
||||
}
|
||||
|
||||
export async function pushOpportunityToOms(opportunityId: number) {
|
||||
export async function pushOpportunityToOms(opportunityId: number, payload?: PushOpportunityToOmsPayload) {
|
||||
return request<number>(`/api/opportunities/${opportunityId}/push-oms`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload ?? {}),
|
||||
}, true);
|
||||
}
|
||||
|
||||
|
|
@ -859,6 +890,11 @@ export async function getExpansionMeta() {
|
|||
return request<ExpansionMeta>("/api/expansion/meta", undefined, true);
|
||||
}
|
||||
|
||||
export async function getExpansionCityOptions(provinceName: string) {
|
||||
const params = new URLSearchParams({ provinceName });
|
||||
return request<ExpansionDictOption[]>(`/api/expansion/areas/cities?${params.toString()}`, undefined, true);
|
||||
}
|
||||
|
||||
export async function createSalesExpansion(payload: CreateSalesExpansionPayload) {
|
||||
return request<number>("/api/expansion/sales", {
|
||||
method: "POST",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import { Search, Plus, MapPin, Building2, User, Phone, X, Clock, FileText, Calendar, ChevronRight } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
|
@ -6,6 +6,7 @@ import {
|
|||
createChannelExpansion,
|
||||
createSalesExpansion,
|
||||
decodeExpansionMultiValue,
|
||||
getExpansionCityOptions,
|
||||
getExpansionMeta,
|
||||
getExpansionOverview,
|
||||
updateChannelExpansion,
|
||||
|
|
@ -38,8 +39,10 @@ type SalesCreateField =
|
|||
type ChannelField =
|
||||
| "channelName"
|
||||
| "province"
|
||||
| "city"
|
||||
| "officeAddress"
|
||||
| "channelIndustry"
|
||||
| "certificationLevel"
|
||||
| "annualRevenue"
|
||||
| "staffSize"
|
||||
| "contactEstablishedDate"
|
||||
|
|
@ -74,8 +77,10 @@ const defaultChannelForm: CreateChannelExpansionPayload = {
|
|||
channelCode: "",
|
||||
channelName: "",
|
||||
province: "",
|
||||
city: "",
|
||||
officeAddress: "",
|
||||
channelIndustry: "",
|
||||
channelIndustry: [],
|
||||
certificationLevel: "",
|
||||
contactEstablishedDate: "",
|
||||
intentLevel: "medium",
|
||||
hasDesktopExp: false,
|
||||
|
|
@ -148,13 +153,19 @@ function validateChannelForm(form: CreateChannelExpansionPayload, channelOtherOp
|
|||
errors.channelName = "请填写渠道名称";
|
||||
}
|
||||
if (!form.province?.trim()) {
|
||||
errors.province = "请填写省份";
|
||||
errors.province = "请选择省份";
|
||||
}
|
||||
if (!form.city?.trim()) {
|
||||
errors.city = "请选择市";
|
||||
}
|
||||
if (!form.officeAddress?.trim()) {
|
||||
errors.officeAddress = "请填写办公地址";
|
||||
}
|
||||
if (!form.channelIndustry?.trim()) {
|
||||
errors.channelIndustry = "请填写聚焦行业";
|
||||
if (!form.certificationLevel?.trim()) {
|
||||
errors.certificationLevel = "请选择认证级别";
|
||||
}
|
||||
if ((form.channelIndustry?.length ?? 0) <= 0) {
|
||||
errors.channelIndustry = "请选择聚焦行业";
|
||||
}
|
||||
if (!form.annualRevenue || form.annualRevenue <= 0) {
|
||||
errors.annualRevenue = "请填写年营收";
|
||||
|
|
@ -223,9 +234,11 @@ function normalizeChannelPayload(payload: CreateChannelExpansionPayload): Create
|
|||
return {
|
||||
channelCode: normalizeOptionalText(payload.channelCode),
|
||||
officeAddress: normalizeOptionalText(payload.officeAddress),
|
||||
channelIndustry: normalizeOptionalText(payload.channelIndustry),
|
||||
channelIndustry: Array.from(new Set((payload.channelIndustry ?? []).map((value) => value?.trim()).filter((value): value is string => Boolean(value)))),
|
||||
channelName: payload.channelName.trim(),
|
||||
province: normalizeOptionalText(payload.province),
|
||||
city: normalizeOptionalText(payload.city),
|
||||
certificationLevel: normalizeOptionalText(payload.certificationLevel),
|
||||
annualRevenue: payload.annualRevenue || undefined,
|
||||
staffSize: payload.staffSize || undefined,
|
||||
contactEstablishedDate: normalizeOptionalText(payload.contactEstablishedDate),
|
||||
|
|
@ -246,6 +259,21 @@ function normalizeChannelPayload(payload: CreateChannelExpansionPayload): Create
|
|||
};
|
||||
}
|
||||
|
||||
function normalizeOptionValue(rawValue: string | undefined, options: ExpansionDictOption[]) {
|
||||
const trimmed = rawValue?.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const matched = options.find((option) => option.value === trimmed || option.label === trimmed);
|
||||
return matched?.value ?? trimmed;
|
||||
}
|
||||
|
||||
function normalizeMultiOptionValues(rawValue: string | undefined, options: ExpansionDictOption[]) {
|
||||
const { values } = decodeExpansionMultiValue(rawValue);
|
||||
return Array.from(new Set(values.map((value) => normalizeOptionValue(value, options)).filter(Boolean)));
|
||||
}
|
||||
|
||||
function ModalShell({
|
||||
title,
|
||||
subtitle,
|
||||
|
|
@ -337,6 +365,10 @@ export default function Expansion() {
|
|||
const [channelData, setChannelData] = useState<ChannelExpansionItem[]>([]);
|
||||
const [officeOptions, setOfficeOptions] = useState<ExpansionDictOption[]>([]);
|
||||
const [industryOptions, setIndustryOptions] = useState<ExpansionDictOption[]>([]);
|
||||
const [provinceOptions, setProvinceOptions] = useState<ExpansionDictOption[]>([]);
|
||||
const [certificationLevelOptions, setCertificationLevelOptions] = useState<ExpansionDictOption[]>([]);
|
||||
const [createCityOptions, setCreateCityOptions] = useState<ExpansionDictOption[]>([]);
|
||||
const [editCityOptions, setEditCityOptions] = useState<ExpansionDictOption[]>([]);
|
||||
const [channelAttributeOptions, setChannelAttributeOptions] = useState<ExpansionDictOption[]>([]);
|
||||
const [internalAttributeOptions, setInternalAttributeOptions] = useState<ExpansionDictOption[]>([]);
|
||||
const [nextChannelCode, setNextChannelCode] = useState("");
|
||||
|
|
@ -363,6 +395,36 @@ export default function Expansion() {
|
|||
const [editChannelForm, setEditChannelForm] = useState<CreateChannelExpansionPayload>(defaultChannelForm);
|
||||
const hasForegroundModal = createOpen || editOpen;
|
||||
|
||||
const loadMeta = useCallback(async () => {
|
||||
const data = await getExpansionMeta();
|
||||
setOfficeOptions(data.officeOptions ?? []);
|
||||
setIndustryOptions(data.industryOptions ?? []);
|
||||
setProvinceOptions(data.provinceOptions ?? []);
|
||||
setCertificationLevelOptions(data.certificationLevelOptions ?? []);
|
||||
setChannelAttributeOptions(data.channelAttributeOptions ?? []);
|
||||
setInternalAttributeOptions(data.internalAttributeOptions ?? []);
|
||||
setNextChannelCode(data.nextChannelCode ?? "");
|
||||
return data;
|
||||
}, []);
|
||||
|
||||
const loadCityOptions = useCallback(async (provinceName?: string, isEdit = false) => {
|
||||
const setter = isEdit ? setEditCityOptions : setCreateCityOptions;
|
||||
const normalizedProvinceName = provinceName?.trim();
|
||||
if (!normalizedProvinceName) {
|
||||
setter([]);
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const options = await getExpansionCityOptions(normalizedProvinceName);
|
||||
setter(options ?? []);
|
||||
return options ?? [];
|
||||
} catch {
|
||||
setter([]);
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const requestedTab = (location.state as { tab?: ExpansionTab } | null)?.tab;
|
||||
if (requestedTab === "sales" || requestedTab === "channel") {
|
||||
|
|
@ -373,12 +435,14 @@ export default function Expansion() {
|
|||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadMeta() {
|
||||
async function loadMetaOptions() {
|
||||
try {
|
||||
const data = await getExpansionMeta();
|
||||
const data = await loadMeta();
|
||||
if (!cancelled) {
|
||||
setOfficeOptions(data.officeOptions ?? []);
|
||||
setIndustryOptions(data.industryOptions ?? []);
|
||||
setProvinceOptions(data.provinceOptions ?? []);
|
||||
setCertificationLevelOptions(data.certificationLevelOptions ?? []);
|
||||
setChannelAttributeOptions(data.channelAttributeOptions ?? []);
|
||||
setInternalAttributeOptions(data.internalAttributeOptions ?? []);
|
||||
setNextChannelCode(data.nextChannelCode ?? "");
|
||||
|
|
@ -387,6 +451,8 @@ export default function Expansion() {
|
|||
if (!cancelled) {
|
||||
setOfficeOptions([]);
|
||||
setIndustryOptions([]);
|
||||
setProvinceOptions([]);
|
||||
setCertificationLevelOptions([]);
|
||||
setChannelAttributeOptions([]);
|
||||
setInternalAttributeOptions([]);
|
||||
setNextChannelCode("");
|
||||
|
|
@ -394,12 +460,12 @@ export default function Expansion() {
|
|||
}
|
||||
}
|
||||
|
||||
void loadMeta();
|
||||
void loadMetaOptions();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
}, [loadMeta]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
|
@ -543,6 +609,7 @@ export default function Expansion() {
|
|||
setInvalidCreateChannelContactRows([]);
|
||||
setSalesForm(defaultSalesForm);
|
||||
setChannelForm(defaultChannelForm);
|
||||
setCreateCityOptions([]);
|
||||
};
|
||||
|
||||
const resetEditState = () => {
|
||||
|
|
@ -553,22 +620,35 @@ export default function Expansion() {
|
|||
setInvalidEditChannelContactRows([]);
|
||||
setEditSalesForm(defaultSalesForm);
|
||||
setEditChannelForm(defaultChannelForm);
|
||||
setEditCityOptions([]);
|
||||
};
|
||||
|
||||
const handleOpenCreate = () => {
|
||||
const handleOpenCreate = async () => {
|
||||
setCreateError("");
|
||||
setSalesCreateFieldErrors({});
|
||||
setChannelCreateFieldErrors({});
|
||||
setInvalidCreateChannelContactRows([]);
|
||||
try {
|
||||
await loadMeta();
|
||||
} catch {}
|
||||
setCreateCityOptions([]);
|
||||
setCreateOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenEdit = () => {
|
||||
const handleOpenEdit = async () => {
|
||||
if (!selectedItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEditError("");
|
||||
let latestIndustryOptions = industryOptions;
|
||||
let latestCertificationLevelOptions = certificationLevelOptions;
|
||||
try {
|
||||
const meta = await loadMeta();
|
||||
latestIndustryOptions = meta.industryOptions ?? [];
|
||||
latestCertificationLevelOptions = meta.certificationLevelOptions ?? [];
|
||||
} catch {}
|
||||
|
||||
if (selectedItem.type === "sales") {
|
||||
setSalesEditFieldErrors({});
|
||||
setEditSalesForm({
|
||||
|
|
@ -586,14 +666,24 @@ export default function Expansion() {
|
|||
} else {
|
||||
const parsedChannelAttributes = decodeExpansionMultiValue(selectedItem.channelAttributeCode);
|
||||
const parsedInternalAttributes = decodeExpansionMultiValue(selectedItem.internalAttributeCode);
|
||||
const normalizedProvinceName = selectedItem.province === "无" ? "" : selectedItem.province ?? "";
|
||||
const normalizedCityName = selectedItem.city === "无" ? "" : selectedItem.city ?? "";
|
||||
setChannelEditFieldErrors({});
|
||||
setInvalidEditChannelContactRows([]);
|
||||
setEditChannelForm({
|
||||
channelCode: selectedItem.channelCode ?? "",
|
||||
channelName: selectedItem.name ?? "",
|
||||
province: selectedItem.province === "无" ? "" : selectedItem.province ?? "",
|
||||
province: normalizedProvinceName,
|
||||
city: normalizedCityName,
|
||||
officeAddress: selectedItem.officeAddress === "无" ? "" : selectedItem.officeAddress ?? "",
|
||||
channelIndustry: selectedItem.channelIndustry === "无" ? "" : selectedItem.channelIndustry ?? "",
|
||||
channelIndustry: normalizeMultiOptionValues(
|
||||
selectedItem.channelIndustryCode ?? (selectedItem.channelIndustry === "无" ? "" : selectedItem.channelIndustry),
|
||||
latestIndustryOptions,
|
||||
),
|
||||
certificationLevel: normalizeOptionValue(
|
||||
selectedItem.certificationLevel === "无" ? "" : selectedItem.certificationLevel,
|
||||
latestCertificationLevelOptions,
|
||||
) || "",
|
||||
annualRevenue: selectedItem.annualRevenue ? Number(selectedItem.annualRevenue) : undefined,
|
||||
staffSize: selectedItem.size ?? undefined,
|
||||
contactEstablishedDate: selectedItem.establishedDate === "无" ? "" : selectedItem.establishedDate ?? "",
|
||||
|
|
@ -612,6 +702,7 @@ export default function Expansion() {
|
|||
}))
|
||||
: [createEmptyChannelContact()],
|
||||
});
|
||||
void loadCityOptions(normalizedProvinceName, true);
|
||||
}
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
|
@ -868,7 +959,11 @@ export default function Expansion() {
|
|||
isEdit = false,
|
||||
fieldErrors?: Partial<Record<ChannelField, string>>,
|
||||
invalidContactRows: number[] = [],
|
||||
) => (
|
||||
) => {
|
||||
const cityOptions = isEdit ? editCityOptions : createCityOptions;
|
||||
const cityDisabled = !form.province?.trim();
|
||||
|
||||
return (
|
||||
<div className="crm-form-grid">
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">编码</span>
|
||||
|
|
@ -885,9 +980,54 @@ export default function Expansion() {
|
|||
</label>
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">省份<RequiredMark /></span>
|
||||
<input value={form.province} onChange={(e) => onChange("province", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors?.province))} />
|
||||
<AdaptiveSelect
|
||||
value={form.province || ""}
|
||||
placeholder="请选择"
|
||||
sheetTitle="省份"
|
||||
searchable
|
||||
searchPlaceholder="搜索省份"
|
||||
options={[
|
||||
{ value: "", label: "请选择" },
|
||||
...provinceOptions.map((option) => ({
|
||||
value: option.value ?? "",
|
||||
label: option.label || "无",
|
||||
})),
|
||||
]}
|
||||
className={cn(
|
||||
fieldErrors?.province ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "",
|
||||
)}
|
||||
onChange={(value) => {
|
||||
const nextProvince = value || undefined;
|
||||
onChange("province", nextProvince);
|
||||
onChange("city", undefined);
|
||||
void loadCityOptions(nextProvince, isEdit);
|
||||
}}
|
||||
/>
|
||||
{fieldErrors?.province ? <p className="text-xs text-rose-500">{fieldErrors.province}</p> : null}
|
||||
</label>
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">市<RequiredMark /></span>
|
||||
<AdaptiveSelect
|
||||
value={form.city || ""}
|
||||
placeholder={cityDisabled ? "请先选择省份" : "请选择"}
|
||||
sheetTitle="市"
|
||||
searchable
|
||||
searchPlaceholder="搜索市"
|
||||
disabled={cityDisabled}
|
||||
options={[
|
||||
{ value: "", label: "请选择" },
|
||||
...cityOptions.map((option) => ({
|
||||
value: option.value ?? "",
|
||||
label: option.label || "无",
|
||||
})),
|
||||
]}
|
||||
className={cn(
|
||||
fieldErrors?.city ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "",
|
||||
)}
|
||||
onChange={(value) => onChange("city", value || undefined)}
|
||||
/>
|
||||
{fieldErrors?.city ? <p className="text-xs text-rose-500">{fieldErrors.city}</p> : null}
|
||||
</label>
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">办公地址<RequiredMark /></span>
|
||||
<input value={form.officeAddress || ""} onChange={(e) => onChange("officeAddress", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors?.officeAddress))} />
|
||||
|
|
@ -895,9 +1035,42 @@ export default function Expansion() {
|
|||
</label>
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">聚焦行业<RequiredMark /></span>
|
||||
<input value={form.channelIndustry || ""} onChange={(e) => onChange("channelIndustry", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors?.channelIndustry))} />
|
||||
<AdaptiveSelect
|
||||
multiple
|
||||
value={form.channelIndustry || []}
|
||||
placeholder="请选择"
|
||||
sheetTitle="聚焦行业"
|
||||
options={industryOptions.map((option) => ({
|
||||
value: option.value ?? "",
|
||||
label: option.label || "无",
|
||||
}))}
|
||||
className={cn(
|
||||
fieldErrors?.channelIndustry ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "",
|
||||
)}
|
||||
onChange={(value) => onChange("channelIndustry", value)}
|
||||
/>
|
||||
{fieldErrors?.channelIndustry ? <p className="text-xs text-rose-500">{fieldErrors.channelIndustry}</p> : null}
|
||||
</label>
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">认证级别<RequiredMark /></span>
|
||||
<AdaptiveSelect
|
||||
value={form.certificationLevel || ""}
|
||||
placeholder="请选择"
|
||||
sheetTitle="认证级别"
|
||||
options={[
|
||||
{ value: "", label: "请选择" },
|
||||
...certificationLevelOptions.map((option) => ({
|
||||
value: option.value ?? "",
|
||||
label: option.label || "无",
|
||||
})),
|
||||
]}
|
||||
className={cn(
|
||||
fieldErrors?.certificationLevel ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "",
|
||||
)}
|
||||
onChange={(value) => onChange("certificationLevel", value || undefined)}
|
||||
/>
|
||||
{fieldErrors?.certificationLevel ? <p className="text-xs text-rose-500">{fieldErrors.certificationLevel}</p> : null}
|
||||
</label>
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">年营收<RequiredMark /></span>
|
||||
<input type="number" value={form.annualRevenue ?? ""} onChange={(e) => onChange("annualRevenue", e.target.value ? Number(e.target.value) : undefined)} className={getFieldInputClass(Boolean(fieldErrors?.annualRevenue))} />
|
||||
|
|
@ -1020,6 +1193,7 @@ export default function Expansion() {
|
|||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="crm-page-stack">
|
||||
|
|
@ -1121,7 +1295,7 @@ export default function Expansion() {
|
|||
<div className="min-w-0">
|
||||
<h3 className="break-anywhere text-base font-semibold text-slate-900 dark:text-white sm:text-lg">{item.name || "无"}</h3>
|
||||
<p className="break-anywhere mt-1 text-xs leading-5 text-slate-500 dark:text-slate-400 sm:text-sm">
|
||||
{item.province || "无"} · {item.internalAttribute || "无"}
|
||||
{item.province || "无"} · {item.city || "无"} · {item.certificationLevel || "无"}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`crm-pill shrink-0 ${item.intent === "高" ? "crm-pill-rose" : item.intent === "中" ? "crm-pill-amber" : "crm-pill-neutral"}`}>
|
||||
|
|
@ -1230,7 +1404,7 @@ export default function Expansion() {
|
|||
<p className="break-anywhere mt-1 text-xs leading-5 text-slate-500 dark:text-slate-400 sm:text-sm">
|
||||
{selectedItem.type === "sales"
|
||||
? `${selectedItem.officeName || "无"} · ${selectedItem.dept || "无"} · ${selectedItem.title || "无"}`
|
||||
: `${selectedItem.province || "无"} · ${selectedItem.channelIndustry || "无"} · ${selectedItem.officeAddress || "无"}`}
|
||||
: `${selectedItem.province || "无"} · ${selectedItem.city || "无"} · ${selectedItem.channelIndustry || "无"} · ${selectedItem.certificationLevel || "无"}`}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{selectedItem.type === "sales" ? (
|
||||
|
|
@ -1263,6 +1437,8 @@ export default function Expansion() {
|
|||
<>
|
||||
<DetailItem label="编码" value={selectedItem.channelCode || "无"} />
|
||||
<DetailItem label="省份" value={selectedItem.province || "无"} />
|
||||
<DetailItem label="市" value={selectedItem.city || "无"} />
|
||||
<DetailItem label="认证级别" value={selectedItem.certificationLevel || "无"} />
|
||||
<DetailItem label="办公地址" value={selectedItem.officeAddress || "无"} className="sm:col-span-2" />
|
||||
<DetailItem label="聚焦行业" value={selectedItem.channelIndustry || "无"} icon={<Building2 className="h-3 w-3" />} />
|
||||
<DetailItem label="营收规模" value={selectedItem.revenue || "无"} />
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Search, Plus, ChevronRight, ChevronDown, Check, Building, Calendar, DollarSign, Activity, X, Clock, FileText, User, Tag, AlertTriangle, ListFilter } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { createOpportunity, getExpansionOverview, getOpportunityMeta, getOpportunityOverview, pushOpportunityToOms, updateOpportunity, type ChannelExpansionItem, type CreateOpportunityPayload, type OpportunityDictOption, type OpportunityFollowUp, type OpportunityItem, type SalesExpansionItem } from "@/lib/auth";
|
||||
import { createOpportunity, getExpansionOverview, getOpportunityMeta, getOpportunityOmsPreSalesOptions, getOpportunityOverview, pushOpportunityToOms, updateOpportunity, type ChannelExpansionItem, type CreateOpportunityPayload, type OmsPreSalesOption, type OpportunityDictOption, type OpportunityFollowUp, type OpportunityItem, type PushOpportunityToOmsPayload, type SalesExpansionItem } from "@/lib/auth";
|
||||
import { AdaptiveSelect } from "@/components/AdaptiveSelect";
|
||||
import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -9,11 +9,13 @@ import { cn } from "@/lib/utils";
|
|||
const detailBadgeClass = "crm-btn-chip text-[11px] font-semibold";
|
||||
|
||||
const CONFIDENCE_OPTIONS = [
|
||||
{ value: "80", label: "A" },
|
||||
{ value: "60", label: "B" },
|
||||
{ value: "40", label: "C" },
|
||||
{ value: "A", label: "A" },
|
||||
{ value: "B", label: "B" },
|
||||
{ value: "C", label: "C" },
|
||||
] as const;
|
||||
|
||||
type ConfidenceGrade = (typeof CONFIDENCE_OPTIONS)[number]["value"];
|
||||
|
||||
const COMPETITOR_OPTIONS = [
|
||||
"深信服",
|
||||
"锐捷",
|
||||
|
|
@ -24,7 +26,7 @@ const COMPETITOR_OPTIONS = [
|
|||
"其他",
|
||||
] as const;
|
||||
|
||||
type CompetitorOption = (typeof COMPETITOR_OPTIONS)[number] | "";
|
||||
type CompetitorOption = (typeof COMPETITOR_OPTIONS)[number];
|
||||
type OperatorMode = "none" | "h3c" | "channel" | "both";
|
||||
type OpportunityArchiveTab = "active" | "archived";
|
||||
type OpportunityField =
|
||||
|
|
@ -48,7 +50,7 @@ const defaultForm: CreateOpportunityPayload = {
|
|||
operatorName: "",
|
||||
amount: 0,
|
||||
expectedCloseDate: "",
|
||||
confidencePct: 40,
|
||||
confidencePct: "C",
|
||||
stage: "",
|
||||
opportunityType: "新建",
|
||||
productType: "VDI云桌面",
|
||||
|
|
@ -73,7 +75,7 @@ function toFormFromItem(item: OpportunityItem): CreateOpportunityPayload {
|
|||
projectLocation: item.projectLocation || "",
|
||||
amount: item.amount || 0,
|
||||
expectedCloseDate: item.date || "",
|
||||
confidencePct: item.confidence ?? 50,
|
||||
confidencePct: normalizeConfidenceGrade(item.confidence),
|
||||
stage: item.stageCode || item.stage || "",
|
||||
opportunityType: item.type || "新建",
|
||||
productType: item.product || "VDI云桌面",
|
||||
|
|
@ -86,31 +88,111 @@ function toFormFromItem(item: OpportunityItem): CreateOpportunityPayload {
|
|||
};
|
||||
}
|
||||
|
||||
function getConfidenceOptionValue(score?: number) {
|
||||
const value = score ?? 0;
|
||||
if (value >= 80) return "80";
|
||||
if (value >= 60) return "60";
|
||||
return "40";
|
||||
function normalizeConfidenceGrade(value?: string | number | null): ConfidenceGrade {
|
||||
if (value === null || value === undefined) {
|
||||
return "C";
|
||||
}
|
||||
|
||||
if (typeof value === "number") {
|
||||
if (value >= 80) return "A";
|
||||
if (value >= 60) return "B";
|
||||
return "C";
|
||||
}
|
||||
|
||||
const normalized = value.trim().toUpperCase();
|
||||
if (normalized === "A" || normalized === "B" || normalized === "C") {
|
||||
return normalized;
|
||||
}
|
||||
if (normalized === "80") {
|
||||
return "A";
|
||||
}
|
||||
if (normalized === "60") {
|
||||
return "B";
|
||||
}
|
||||
return "C";
|
||||
}
|
||||
|
||||
function getConfidenceLabel(score?: number) {
|
||||
const matchedOption = CONFIDENCE_OPTIONS.find((item) => item.value === getConfidenceOptionValue(score));
|
||||
function getConfidenceOptionValue(score?: string | number | null) {
|
||||
return normalizeConfidenceGrade(score);
|
||||
}
|
||||
|
||||
function getConfidenceLabel(score?: string | number | null) {
|
||||
const matchedOption = CONFIDENCE_OPTIONS.find((item) => item.value === normalizeConfidenceGrade(score));
|
||||
return matchedOption?.label || "C";
|
||||
}
|
||||
|
||||
function getConfidenceBadgeClass(score?: number) {
|
||||
const normalizedScore = Number(getConfidenceOptionValue(score));
|
||||
if (normalizedScore >= 80) return "crm-pill crm-pill-emerald";
|
||||
if (normalizedScore >= 60) return "crm-pill crm-pill-amber";
|
||||
function getConfidenceBadgeClass(score?: string | number | null) {
|
||||
const normalizedGrade = normalizeConfidenceGrade(score);
|
||||
if (normalizedGrade === "A") return "crm-pill crm-pill-emerald";
|
||||
if (normalizedGrade === "B") return "crm-pill crm-pill-amber";
|
||||
return "crm-pill crm-pill-rose";
|
||||
}
|
||||
|
||||
function getCompetitorSelection(value?: string): CompetitorOption {
|
||||
const competitor = value?.trim() || "";
|
||||
if (!competitor) {
|
||||
return "";
|
||||
function normalizeCompetitorSelections(selected: CompetitorOption[]) {
|
||||
const deduped = Array.from(new Set(selected));
|
||||
if (deduped.includes("无")) {
|
||||
return ["无"] as CompetitorOption[];
|
||||
}
|
||||
return (COMPETITOR_OPTIONS as readonly string[]).includes(competitor) ? (competitor as CompetitorOption) : "其他";
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function parseCompetitorState(value?: string) {
|
||||
const raw = value?.trim() || "";
|
||||
if (!raw) {
|
||||
return {
|
||||
selections: [] as CompetitorOption[],
|
||||
customName: "",
|
||||
};
|
||||
}
|
||||
|
||||
const tokens = raw
|
||||
.split(/[,,、;;\n]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const selections: CompetitorOption[] = [];
|
||||
const customValues: string[] = [];
|
||||
|
||||
tokens.forEach((token) => {
|
||||
if ((COMPETITOR_OPTIONS as readonly string[]).includes(token) && token !== "其他") {
|
||||
selections.push(token as CompetitorOption);
|
||||
return;
|
||||
}
|
||||
customValues.push(token);
|
||||
});
|
||||
|
||||
if (customValues.length > 0) {
|
||||
selections.push("其他");
|
||||
}
|
||||
|
||||
return {
|
||||
selections: normalizeCompetitorSelections(selections),
|
||||
customName: customValues.join("、"),
|
||||
};
|
||||
}
|
||||
|
||||
function buildCompetitorValue(selected: CompetitorOption[], customName?: string) {
|
||||
const normalizedSelections = normalizeCompetitorSelections(selected);
|
||||
if (!normalizedSelections.length) {
|
||||
return undefined;
|
||||
}
|
||||
if (normalizedSelections.includes("无")) {
|
||||
return "无";
|
||||
}
|
||||
|
||||
const values = normalizedSelections
|
||||
.filter((item) => item !== "其他")
|
||||
.map((item) => item.trim());
|
||||
|
||||
if (normalizedSelections.includes("其他")) {
|
||||
const trimmedCustomName = customName?.trim();
|
||||
if (trimmedCustomName) {
|
||||
values.push(trimmedCustomName);
|
||||
}
|
||||
}
|
||||
|
||||
const deduped = Array.from(new Set(values.filter(Boolean)));
|
||||
return deduped.length ? deduped.join("、") : undefined;
|
||||
}
|
||||
|
||||
function normalizeOperatorToken(value?: string) {
|
||||
|
|
@ -148,18 +230,15 @@ function resolveOperatorMode(operatorValue: string | undefined, operatorOptions:
|
|||
|
||||
function buildOpportunitySubmitPayload(
|
||||
form: CreateOpportunityPayload,
|
||||
competitorSelection: CompetitorOption,
|
||||
selectedCompetitors: CompetitorOption[],
|
||||
customCompetitorName: string,
|
||||
operatorMode: OperatorMode,
|
||||
): CreateOpportunityPayload {
|
||||
const normalizedCompetitorName = competitorSelection === "其他"
|
||||
? form.competitorName?.trim()
|
||||
: competitorSelection || undefined;
|
||||
|
||||
return {
|
||||
...form,
|
||||
salesExpansionId: operatorMode === "h3c" || operatorMode === "both" ? form.salesExpansionId : undefined,
|
||||
channelExpansionId: operatorMode === "channel" || operatorMode === "both" ? form.channelExpansionId : undefined,
|
||||
competitorName: normalizedCompetitorName || undefined,
|
||||
competitorName: buildCompetitorValue(selectedCompetitors, customCompetitorName),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -200,13 +279,14 @@ function RequiredMark() {
|
|||
|
||||
function validateOpportunityForm(
|
||||
form: CreateOpportunityPayload,
|
||||
competitorSelection: CompetitorOption,
|
||||
selectedCompetitors: CompetitorOption[],
|
||||
customCompetitorName: string,
|
||||
operatorMode: OperatorMode,
|
||||
) {
|
||||
const errors: Partial<Record<OpportunityField, string>> = {};
|
||||
|
||||
if (!form.projectLocation?.trim()) {
|
||||
errors.projectLocation = "请填写项目地";
|
||||
errors.projectLocation = "请选择项目地";
|
||||
}
|
||||
if (!form.opportunityName?.trim()) {
|
||||
errors.opportunityName = "请填写项目名称";
|
||||
|
|
@ -223,7 +303,7 @@ function validateOpportunityForm(
|
|||
if (!form.expectedCloseDate?.trim()) {
|
||||
errors.expectedCloseDate = "请选择预计下单时间";
|
||||
}
|
||||
if (!form.confidencePct || form.confidencePct <= 0) {
|
||||
if (!form.confidencePct?.trim()) {
|
||||
errors.confidencePct = "请选择项目把握度";
|
||||
}
|
||||
if (!form.stage?.trim()) {
|
||||
|
|
@ -232,10 +312,10 @@ function validateOpportunityForm(
|
|||
if (!form.opportunityType?.trim()) {
|
||||
errors.opportunityType = "请选择建设类型";
|
||||
}
|
||||
if (!competitorSelection) {
|
||||
errors.competitorName = "请选择竞争对手";
|
||||
} else if (competitorSelection === "其他" && !form.competitorName?.trim()) {
|
||||
errors.competitorName = "请选择“其他”时,请填写具体竞争对手";
|
||||
if (selectedCompetitors.length === 0) {
|
||||
errors.competitorName = "请至少选择一个竞争对手";
|
||||
} else if (selectedCompetitors.includes("其他") && !customCompetitorName.trim()) {
|
||||
errors.competitorName = "已选择“其他”,请填写其他竞争对手";
|
||||
}
|
||||
|
||||
if (operatorMode === "h3c" && !form.salesExpansionId) {
|
||||
|
|
@ -335,6 +415,17 @@ type SearchableOption = {
|
|||
keywords?: string[];
|
||||
};
|
||||
|
||||
function dedupeSearchableOptions(options: SearchableOption[]) {
|
||||
const seenValues = new Set<SearchableOption["value"]>();
|
||||
return options.filter((option) => {
|
||||
if (seenValues.has(option.value)) {
|
||||
return false;
|
||||
}
|
||||
seenValues.add(option.value);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function useIsMobileViewport() {
|
||||
const [isMobile, setIsMobile] = useState(() => {
|
||||
if (typeof window === "undefined") {
|
||||
|
|
@ -385,9 +476,10 @@ function SearchableSelect({
|
|||
const [query, setQuery] = useState("");
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const isMobile = useIsMobileViewport();
|
||||
const selectedOption = options.find((item) => item.value === value);
|
||||
const normalizedOptions = dedupeSearchableOptions(options);
|
||||
const selectedOption = normalizedOptions.find((item) => item.value === value);
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const filteredOptions = options.filter((item) => {
|
||||
const filteredOptions = normalizedOptions.filter((item) => {
|
||||
if (!normalizedQuery) {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -567,6 +659,184 @@ function SearchableSelect({
|
|||
);
|
||||
}
|
||||
|
||||
function CompetitorMultiSelect({
|
||||
value,
|
||||
options,
|
||||
placeholder,
|
||||
className,
|
||||
onChange,
|
||||
}: {
|
||||
value: CompetitorOption[];
|
||||
options: readonly CompetitorOption[];
|
||||
placeholder: string;
|
||||
className?: string;
|
||||
onChange: (value: CompetitorOption[]) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const isMobile = useIsMobileViewport();
|
||||
|
||||
const summary = value.length > 0 ? value.join("、") : placeholder;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || isMobile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
if (!containerRef.current?.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handlePointerDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handlePointerDown);
|
||||
};
|
||||
}, [isMobile, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !isMobile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
};
|
||||
}, [isMobile, open]);
|
||||
|
||||
const toggleOption = (option: CompetitorOption) => {
|
||||
const exists = value.includes(option);
|
||||
if (option === "无") {
|
||||
onChange(exists ? [] : ["无"]);
|
||||
return;
|
||||
}
|
||||
|
||||
const next = exists
|
||||
? value.filter((item) => item !== option)
|
||||
: normalizeCompetitorSelections([...value.filter((item) => item !== "无"), option]);
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const renderOptions = () => (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
{options.map((option) => {
|
||||
const active = value.includes(option);
|
||||
return (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => toggleOption(option)}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-xl px-3 py-2.5 text-left text-sm transition-colors",
|
||||
active
|
||||
? "bg-violet-50 text-violet-700 dark:bg-violet-500/10 dark:text-violet-300"
|
||||
: "text-slate-700 hover:bg-slate-50 dark:text-slate-200 dark:hover:bg-slate-800",
|
||||
)}
|
||||
>
|
||||
<span>{option}</span>
|
||||
{active ? <Check className="h-4 w-4 shrink-0" /> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between gap-2 border-t border-slate-100 pt-3 dark:border-slate-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange([])}
|
||||
className="rounded-xl px-3 py-2 text-sm text-slate-500 transition-colors hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-xl bg-violet-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-violet-700"
|
||||
>
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
className={cn(
|
||||
"crm-btn-sm crm-input-text flex w-full items-center justify-between rounded-xl border border-slate-200 bg-white text-left outline-none transition-colors hover:border-slate-300 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50 dark:hover:border-slate-700",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className={value.length > 0 ? "truncate text-slate-900 dark:text-white" : "text-slate-400 dark:text-slate-500"}>
|
||||
{summary}
|
||||
</span>
|
||||
<ChevronDown className={cn("h-4 w-4 shrink-0 text-slate-400 transition-transform", open && "rotate-180")} />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && !isMobile ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 8 }}
|
||||
className="absolute z-20 mt-2 w-full rounded-2xl border border-slate-200 bg-white p-3 shadow-xl dark:border-slate-800 dark:bg-slate-900"
|
||||
>
|
||||
<div className="mb-3">
|
||||
<p className="text-sm font-semibold text-slate-900 dark:text-white">竞争对手</p>
|
||||
<p className="crm-field-note mt-1">支持多选,选择“其他”后可手动录入。</p>
|
||||
</div>
|
||||
{renderOptions()}
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && isMobile ? (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[120] bg-slate-900/35 backdrop-blur-sm dark:bg-slate-950/70"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 24 }}
|
||||
className="fixed inset-x-0 bottom-0 z-[130] px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] pt-3"
|
||||
>
|
||||
<div className="mx-auto w-full max-w-lg rounded-3xl border border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-900">
|
||||
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4 dark:border-slate-800">
|
||||
<div>
|
||||
<p className="text-base font-semibold text-slate-900 dark:text-white">竞争对手</p>
|
||||
<p className="crm-field-note mt-1">支持多选,选择“其他”后可手动录入。</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-full p-2 text-slate-400 transition-colors hover:bg-slate-100 dark:hover:bg-slate-800"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-4 py-4 pb-[calc(1rem+env(safe-area-inset-bottom))]">
|
||||
{renderOptions()}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Opportunities() {
|
||||
const isMobileViewport = useIsMobileViewport();
|
||||
const isWecomBrowser = useIsWecomBrowser();
|
||||
|
|
@ -585,10 +855,16 @@ export default function Opportunities() {
|
|||
const [items, setItems] = useState<OpportunityItem[]>([]);
|
||||
const [salesExpansionOptions, setSalesExpansionOptions] = useState<SalesExpansionItem[]>([]);
|
||||
const [channelExpansionOptions, setChannelExpansionOptions] = useState<ChannelExpansionItem[]>([]);
|
||||
const [omsPreSalesOptions, setOmsPreSalesOptions] = useState<OmsPreSalesOption[]>([]);
|
||||
const [stageOptions, setStageOptions] = useState<OpportunityDictOption[]>([]);
|
||||
const [operatorOptions, setOperatorOptions] = useState<OpportunityDictOption[]>([]);
|
||||
const [projectLocationOptions, setProjectLocationOptions] = useState<OpportunityDictOption[]>([]);
|
||||
const [form, setForm] = useState<CreateOpportunityPayload>(defaultForm);
|
||||
const [competitorSelection, setCompetitorSelection] = useState<CompetitorOption>("");
|
||||
const [pushPreSalesId, setPushPreSalesId] = useState<number | undefined>(undefined);
|
||||
const [pushPreSalesName, setPushPreSalesName] = useState("");
|
||||
const [loadingOmsPreSales, setLoadingOmsPreSales] = useState(false);
|
||||
const [selectedCompetitors, setSelectedCompetitors] = useState<CompetitorOption[]>([]);
|
||||
const [customCompetitorName, setCustomCompetitorName] = useState("");
|
||||
const [fieldErrors, setFieldErrors] = useState<Partial<Record<OpportunityField, string>>>({});
|
||||
const [detailTab, setDetailTab] = useState<"sales" | "channel" | "followups">("sales");
|
||||
const hasForegroundModal = createOpen || editOpen || pushConfirmOpen;
|
||||
|
|
@ -650,11 +926,13 @@ export default function Opportunities() {
|
|||
if (!cancelled) {
|
||||
setStageOptions((data.stageOptions ?? []).filter((item) => item.value));
|
||||
setOperatorOptions((data.operatorOptions ?? []).filter((item) => item.value));
|
||||
setProjectLocationOptions((data.projectLocationOptions ?? []).filter((item) => item.value));
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setStageOptions([]);
|
||||
setOperatorOptions([]);
|
||||
setProjectLocationOptions([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -680,6 +958,20 @@ export default function Opportunities() {
|
|||
{ label: "全部", value: "全部" },
|
||||
...stageOptions.map((item) => ({ label: item.label || item.value || "", value: item.value || "" })),
|
||||
].filter((item) => item.value);
|
||||
const normalizedProjectLocation = form.projectLocation?.trim() || "";
|
||||
const projectLocationSelectOptions = [
|
||||
{ value: "", label: "请选择" },
|
||||
...projectLocationOptions.map((item) => ({
|
||||
label: item.label || item.value || "",
|
||||
value: item.value || "",
|
||||
})),
|
||||
...(
|
||||
normalizedProjectLocation
|
||||
&& !projectLocationOptions.some((item) => (item.value || "").trim() === normalizedProjectLocation)
|
||||
? [{ value: normalizedProjectLocation, label: normalizedProjectLocation }]
|
||||
: []
|
||||
),
|
||||
];
|
||||
const activeStageFilterLabel = stageFilterOptions.find((item) => item.value === filter)?.label || "全部";
|
||||
const selectedSalesExpansion = selectedItem?.salesExpansionId
|
||||
? salesExpansionOptions.find((item) => item.id === selectedItem.salesExpansionId) ?? null
|
||||
|
|
@ -689,9 +981,11 @@ export default function Opportunities() {
|
|||
: null;
|
||||
const selectedSalesExpansionName = selectedItem?.salesExpansionName || selectedSalesExpansion?.name || "";
|
||||
const selectedChannelExpansionName = selectedItem?.channelExpansionName || selectedChannelExpansion?.name || "";
|
||||
const selectedPreSalesName = selectedItem?.preSalesName || "无";
|
||||
const operatorMode = resolveOperatorMode(form.operatorName, operatorOptions);
|
||||
const showSalesExpansionField = operatorMode === "h3c" || operatorMode === "both";
|
||||
const showChannelExpansionField = operatorMode === "channel" || operatorMode === "both";
|
||||
const showCustomCompetitorInput = selectedCompetitors.includes("其他");
|
||||
const salesExpansionSearchOptions: SearchableOption[] = salesExpansionOptions.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.name || `拓展人员#${item.id}`,
|
||||
|
|
@ -702,12 +996,24 @@ export default function Opportunities() {
|
|||
label: item.name || `渠道#${item.id}`,
|
||||
keywords: [item.channelCode || "", item.province || "", item.primaryContactName || "", item.primaryContactMobile || ""],
|
||||
}));
|
||||
const omsPreSalesSearchOptions: SearchableOption[] = [
|
||||
...(pushPreSalesId && pushPreSalesName && !omsPreSalesOptions.some((item) => item.userId === pushPreSalesId)
|
||||
? [{ value: pushPreSalesId, label: pushPreSalesName, keywords: [] }]
|
||||
: []),
|
||||
...omsPreSalesOptions.map((item) => ({
|
||||
value: item.userId,
|
||||
label: item.userName || item.loginName || `售前#${item.userId}`,
|
||||
keywords: [item.loginName || ""],
|
||||
})),
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedItem) {
|
||||
setDetailTab("sales");
|
||||
} else {
|
||||
setPushConfirmOpen(false);
|
||||
setPushPreSalesId(undefined);
|
||||
setPushPreSalesName("");
|
||||
}
|
||||
}, [selectedItem]);
|
||||
|
||||
|
|
@ -717,10 +1023,10 @@ export default function Opportunities() {
|
|||
}
|
||||
}, [archiveTab, selectedItem, visibleItems]);
|
||||
|
||||
const getConfidenceColor = (score: number) => {
|
||||
const normalizedScore = Number(getConfidenceOptionValue(score));
|
||||
if (normalizedScore >= 80) return "text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10 border-emerald-200 dark:border-emerald-500/20";
|
||||
if (normalizedScore >= 60) return "text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-500/10 border-amber-200 dark:border-amber-500/20";
|
||||
const getConfidenceColor = (score?: string | number | null) => {
|
||||
const normalizedGrade = normalizeConfidenceGrade(score);
|
||||
if (normalizedGrade === "A") return "text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10 border-emerald-200 dark:border-emerald-500/20";
|
||||
if (normalizedGrade === "B") return "text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-500/10 border-amber-200 dark:border-amber-500/20";
|
||||
return "text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-500/10 border-rose-200 dark:border-rose-500/20";
|
||||
};
|
||||
|
||||
|
|
@ -739,7 +1045,8 @@ export default function Opportunities() {
|
|||
setError("");
|
||||
setFieldErrors({});
|
||||
setForm(defaultForm);
|
||||
setCompetitorSelection("");
|
||||
setSelectedCompetitors([]);
|
||||
setCustomCompetitorName("");
|
||||
setCreateOpen(true);
|
||||
};
|
||||
|
||||
|
|
@ -750,7 +1057,8 @@ export default function Opportunities() {
|
|||
setError("");
|
||||
setFieldErrors({});
|
||||
setForm(defaultForm);
|
||||
setCompetitorSelection("");
|
||||
setSelectedCompetitors([]);
|
||||
setCustomCompetitorName("");
|
||||
};
|
||||
|
||||
const reload = async (preferredSelectedId?: number) => {
|
||||
|
|
@ -768,7 +1076,7 @@ export default function Opportunities() {
|
|||
}
|
||||
|
||||
setError("");
|
||||
const validationErrors = validateOpportunityForm(form, competitorSelection, operatorMode);
|
||||
const validationErrors = validateOpportunityForm(form, selectedCompetitors, customCompetitorName, operatorMode);
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setFieldErrors(validationErrors);
|
||||
setError("请先完整填写商机必填字段");
|
||||
|
|
@ -778,7 +1086,7 @@ export default function Opportunities() {
|
|||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
await createOpportunity(buildOpportunitySubmitPayload(form, competitorSelection, operatorMode));
|
||||
await createOpportunity(buildOpportunitySubmitPayload(form, selectedCompetitors, customCompetitorName, operatorMode));
|
||||
await reload();
|
||||
resetCreateState();
|
||||
} catch (createError) {
|
||||
|
|
@ -791,14 +1099,12 @@ export default function Opportunities() {
|
|||
if (!selectedItem) {
|
||||
return;
|
||||
}
|
||||
if (selectedItem.pushedToOms) {
|
||||
setError("该商机已推送 OMS,不能再编辑");
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
setFieldErrors({});
|
||||
setForm(toFormFromItem(selectedItem));
|
||||
setCompetitorSelection(getCompetitorSelection(selectedItem.competitorName));
|
||||
const competitorState = parseCompetitorState(selectedItem.competitorName);
|
||||
setSelectedCompetitors(competitorState.selections);
|
||||
setCustomCompetitorName(competitorState.customName);
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
|
|
@ -808,7 +1114,7 @@ export default function Opportunities() {
|
|||
}
|
||||
|
||||
setError("");
|
||||
const validationErrors = validateOpportunityForm(form, competitorSelection, operatorMode);
|
||||
const validationErrors = validateOpportunityForm(form, selectedCompetitors, customCompetitorName, operatorMode);
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setFieldErrors(validationErrors);
|
||||
setError("请先完整填写商机必填字段");
|
||||
|
|
@ -818,7 +1124,7 @@ export default function Opportunities() {
|
|||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
await updateOpportunity(selectedItem.id, buildOpportunitySubmitPayload(form, competitorSelection, operatorMode));
|
||||
await updateOpportunity(selectedItem.id, buildOpportunitySubmitPayload(form, selectedCompetitors, customCompetitorName, operatorMode));
|
||||
await reload(selectedItem.id);
|
||||
resetCreateState();
|
||||
} catch (updateError) {
|
||||
|
|
@ -836,7 +1142,11 @@ export default function Opportunities() {
|
|||
setError("");
|
||||
|
||||
try {
|
||||
await pushOpportunityToOms(selectedItem.id);
|
||||
const payload: PushOpportunityToOmsPayload = {
|
||||
preSalesId: pushPreSalesId,
|
||||
preSalesName: pushPreSalesName.trim() || undefined,
|
||||
};
|
||||
await pushOpportunityToOms(selectedItem.id, payload);
|
||||
await reload(selectedItem.id);
|
||||
} catch (pushError) {
|
||||
setError(pushError instanceof Error ? pushError.message : "推送 OMS 失败");
|
||||
|
|
@ -845,14 +1155,62 @@ export default function Opportunities() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleOpenPushConfirm = () => {
|
||||
const syncPushPreSalesSelection = (item: OpportunityItem | null, options: OmsPreSalesOption[]) => {
|
||||
if (!item) {
|
||||
setPushPreSalesId(undefined);
|
||||
setPushPreSalesName("");
|
||||
return;
|
||||
}
|
||||
|
||||
const matchedById = item.preSalesId
|
||||
? options.find((option) => option.userId === item.preSalesId)
|
||||
: undefined;
|
||||
if (matchedById) {
|
||||
setPushPreSalesId(matchedById.userId);
|
||||
setPushPreSalesName(matchedById.userName || matchedById.loginName || "");
|
||||
return;
|
||||
}
|
||||
|
||||
const matchedByName = item.preSalesName
|
||||
? options.find((option) => (option.userName || "") === item.preSalesName)
|
||||
: undefined;
|
||||
if (matchedByName) {
|
||||
setPushPreSalesId(matchedByName.userId);
|
||||
setPushPreSalesName(matchedByName.userName || matchedByName.loginName || "");
|
||||
return;
|
||||
}
|
||||
|
||||
setPushPreSalesId(item.preSalesId);
|
||||
setPushPreSalesName(item.preSalesName || "");
|
||||
};
|
||||
|
||||
const handleOpenPushConfirm = async () => {
|
||||
if (!selectedItem || selectedItem.pushedToOms || pushingOms) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError("");
|
||||
syncPushPreSalesSelection(selectedItem, omsPreSalesOptions);
|
||||
setPushConfirmOpen(true);
|
||||
setLoadingOmsPreSales(true);
|
||||
|
||||
try {
|
||||
const data = await getOpportunityOmsPreSalesOptions();
|
||||
setOmsPreSalesOptions(data);
|
||||
syncPushPreSalesSelection(selectedItem, data);
|
||||
} catch (loadError) {
|
||||
setOmsPreSalesOptions([]);
|
||||
setError(loadError instanceof Error ? loadError.message : "加载售前人员失败");
|
||||
} finally {
|
||||
setLoadingOmsPreSales(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmPushToOms = async () => {
|
||||
if (!pushPreSalesId && !pushPreSalesName.trim()) {
|
||||
setError("请选择售前人员");
|
||||
return;
|
||||
}
|
||||
setPushConfirmOpen(false);
|
||||
await handlePushToOms();
|
||||
};
|
||||
|
|
@ -943,7 +1301,7 @@ export default function Opportunities() {
|
|||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="truncate text-base font-semibold text-slate-900 dark:text-white sm:text-lg">{opp.name || "未命名商机"}</h3>
|
||||
<p className="mt-1 truncate text-xs leading-5 text-slate-500 dark:text-slate-400 sm:text-sm">{opp.pushedToOms ? "已推送 OMS" : "未推送 OMS"}</p>
|
||||
<p className="mt-1 truncate text-xs leading-5 text-slate-500 dark:text-slate-400 sm:text-sm">商机编号:{opp.code || "待生成"}</p>
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center gap-2 pl-2">
|
||||
<span className={getConfidenceBadgeClass(opp.confidence)}>
|
||||
|
|
@ -1061,7 +1419,16 @@ export default function Opportunities() {
|
|||
<div className="crm-form-grid">
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">项目地<RequiredMark /></span>
|
||||
<input value={form.projectLocation || ""} onChange={(e) => handleChange("projectLocation", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors.projectLocation))} />
|
||||
<AdaptiveSelect
|
||||
value={form.projectLocation || ""}
|
||||
placeholder="请选择"
|
||||
sheetTitle="项目地"
|
||||
searchable
|
||||
searchPlaceholder="搜索项目地"
|
||||
options={projectLocationSelectOptions}
|
||||
className={getFieldInputClass(Boolean(fieldErrors.projectLocation))}
|
||||
onChange={(value) => handleChange("projectLocation", value)}
|
||||
/>
|
||||
{fieldErrors.projectLocation ? <p className="text-xs text-rose-500">{fieldErrors.projectLocation}</p> : null}
|
||||
</label>
|
||||
<label className="space-y-2 sm:col-span-2">
|
||||
|
|
@ -1148,7 +1515,7 @@ export default function Opportunities() {
|
|||
className={cn(
|
||||
fieldErrors.confidencePct ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "",
|
||||
)}
|
||||
onChange={(value) => handleChange("confidencePct", Number(value) || 40)}
|
||||
onChange={(value) => handleChange("confidencePct", normalizeConfidenceGrade(value))}
|
||||
/>
|
||||
{fieldErrors.confidencePct ? <p className="text-xs text-rose-500">{fieldErrors.confidencePct}</p> : null}
|
||||
</label>
|
||||
|
|
@ -1171,17 +1538,14 @@ export default function Opportunities() {
|
|||
</label>
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">竞争对手<RequiredMark /></span>
|
||||
<AdaptiveSelect
|
||||
value={competitorSelection}
|
||||
placeholder="请选择"
|
||||
sheetTitle="竞争对手"
|
||||
options={COMPETITOR_OPTIONS.map((item) => ({ value: item, label: item }))}
|
||||
<CompetitorMultiSelect
|
||||
value={selectedCompetitors}
|
||||
options={COMPETITOR_OPTIONS}
|
||||
placeholder="请选择竞争对手"
|
||||
className={cn(
|
||||
fieldErrors.competitorName ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "",
|
||||
)}
|
||||
onChange={(value) => {
|
||||
const nextSelection = value as CompetitorOption;
|
||||
setCompetitorSelection(nextSelection);
|
||||
onChange={(nextValue) => {
|
||||
setFieldErrors((current) => {
|
||||
if (!current.competitorName) {
|
||||
return current;
|
||||
|
|
@ -1190,22 +1554,21 @@ export default function Opportunities() {
|
|||
delete next.competitorName;
|
||||
return next;
|
||||
});
|
||||
if (nextSelection === "其他") {
|
||||
handleChange("competitorName", getCompetitorSelection(form.competitorName) === "其他" ? form.competitorName || "" : "");
|
||||
return;
|
||||
setSelectedCompetitors(nextValue);
|
||||
if (!nextValue.includes("其他")) {
|
||||
setCustomCompetitorName("");
|
||||
}
|
||||
handleChange("competitorName", nextSelection || "");
|
||||
}}
|
||||
/>
|
||||
{fieldErrors.competitorName && competitorSelection !== "其他" ? <p className="text-xs text-rose-500">{fieldErrors.competitorName}</p> : null}
|
||||
{fieldErrors.competitorName && !showCustomCompetitorInput ? <p className="text-xs text-rose-500">{fieldErrors.competitorName}</p> : null}
|
||||
</label>
|
||||
{competitorSelection === "其他" ? (
|
||||
{showCustomCompetitorInput ? (
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">其他竞争对手<RequiredMark /></span>
|
||||
<input
|
||||
value={getCompetitorSelection(form.competitorName) === "其他" ? form.competitorName || "" : ""}
|
||||
onChange={(e) => handleChange("competitorName", e.target.value)}
|
||||
placeholder="请输入竞争对手名称"
|
||||
value={customCompetitorName}
|
||||
onChange={(e) => setCustomCompetitorName(e.target.value)}
|
||||
placeholder="请输入其他竞争对手"
|
||||
className={getFieldInputClass(Boolean(fieldErrors.competitorName))}
|
||||
/>
|
||||
{fieldErrors.competitorName ? <p className="text-xs text-rose-500">{fieldErrors.competitorName}</p> : null}
|
||||
|
|
@ -1288,7 +1651,7 @@ export default function Opportunities() {
|
|||
<div className="min-w-0">
|
||||
<h3 className="text-base font-semibold text-slate-900 dark:text-white">确认推送 OMS</h3>
|
||||
<p className="mt-1 text-sm leading-6 text-slate-500 dark:text-slate-400">
|
||||
推送 OMS 后不允许修改,是否确认推送?
|
||||
请选择售前后同步到 OMS,系统会携带当前商机编码供 OMS 判断。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1298,6 +1661,26 @@ export default function Opportunities() {
|
|||
<p className="text-xs text-slate-400 dark:text-slate-500">当前商机</p>
|
||||
<p className="mt-1 font-medium text-slate-900 dark:text-white">{selectedItem.name || selectedItem.code || `#${selectedItem.id}`}</p>
|
||||
</div>
|
||||
<div className="crm-form-section mt-4">
|
||||
<p className="mb-2 text-xs text-slate-400 dark:text-slate-500">售前人员</p>
|
||||
<SearchableSelect
|
||||
value={pushPreSalesId}
|
||||
options={omsPreSalesSearchOptions}
|
||||
placeholder={loadingOmsPreSales ? "售前人员加载中..." : "请选择售前人员"}
|
||||
searchPlaceholder="搜索售前姓名或登录账号"
|
||||
emptyText={loadingOmsPreSales ? "正在加载售前人员..." : "未找到匹配的售前人员"}
|
||||
onChange={(value) => {
|
||||
const matched = omsPreSalesOptions.find((item) => item.userId === value);
|
||||
setPushPreSalesId(value);
|
||||
setPushPreSalesName(matched?.userName || matched?.loginName || "");
|
||||
setError("");
|
||||
}}
|
||||
/>
|
||||
<p className="mt-2 text-xs text-slate-400 dark:text-slate-500">
|
||||
{loadingOmsPreSales ? "正在从 OMS 拉取售前人员列表。" : "推送前必须选择售前,系统会把售前ID和姓名回写到 CRM 商机表。"}
|
||||
</p>
|
||||
</div>
|
||||
{error ? <div className="crm-alert crm-alert-error mt-4">{error}</div> : null}
|
||||
</div>
|
||||
<div className="flex flex-col-reverse gap-3 px-5 pb-[calc(1rem+env(safe-area-inset-bottom))] pt-1 sm:flex-row sm:justify-end sm:px-6 sm:pb-5">
|
||||
<button
|
||||
|
|
@ -1354,12 +1737,11 @@ export default function Opportunities() {
|
|||
<div>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-slate-400 dark:text-slate-500">{selectedItem.code || `#${selectedItem.id}`}</span>
|
||||
{selectedItem.pushedToOms ? <span className="crm-pill crm-pill-violet px-1.5 py-0.5 text-[10px]">已推送 OMS</span> : null}
|
||||
</div>
|
||||
<h3 className="line-clamp-1 text-lg font-bold leading-tight text-slate-900 dark:text-white sm:break-anywhere sm:line-clamp-none sm:text-xl">{selectedItem.name || "未命名商机"}</h3>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<span className="crm-pill crm-pill-neutral">{selectedItem.stage || "初步沟通"}</span>
|
||||
<span className={`rounded-full px-2.5 py-1 text-xs font-medium ${getConfidenceColor(selectedItem.confidence ?? 0)}`}>项目把握度 {getConfidenceLabel(selectedItem.confidence)}</span>
|
||||
<span className={`rounded-full px-2.5 py-1 text-xs font-medium ${getConfidenceColor(selectedItem.confidence)}`}>项目把握度 {getConfidenceLabel(selectedItem.confidence)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -1373,13 +1755,13 @@ export default function Opportunities() {
|
|||
<DetailItem label="最终客户" value={selectedItem.client || "无"} icon={<Building className="h-3 w-3" />} />
|
||||
<DetailItem label="运作方" value={selectedItem.operatorName || "无"} />
|
||||
<DetailItem label="新华三负责人" value={selectedSalesExpansionName || "未关联"} icon={<User className="h-3 w-3" />} />
|
||||
<DetailItem label="售前" value={selectedPreSalesName} icon={<User className="h-3 w-3" />} />
|
||||
<DetailItem label="预计金额(元)" value={<span className="text-rose-600 dark:text-rose-400">¥{formatAmount(selectedItem.amount)}</span>} icon={<DollarSign className="h-3 w-3" />} />
|
||||
<DetailItem label="预计下单时间" value={selectedItem.date || "待定"} icon={<Calendar className="h-3 w-3" />} />
|
||||
<DetailItem label="项目把握度" value={getConfidenceLabel(selectedItem.confidence)} />
|
||||
<DetailItem label="项目阶段" value={selectedItem.stage || "无"} icon={<Activity className="h-3 w-3" />} />
|
||||
<DetailItem label="竞争对手" value={selectedItem.competitorName || "无"} />
|
||||
<DetailItem label="建设类型" value={selectedItem.type || "新建"} icon={<Tag className="h-3 w-3" />} />
|
||||
<DetailItem label="OMS 推送状态" value={selectedItem.pushedToOms ? "已推送 OMS" : "未推送 OMS"} />
|
||||
<DetailItem label="项目最新进展" value={selectedItem.latestProgress || "暂无日报回写进展"} className="md:col-span-2" />
|
||||
<DetailItem label="后续规划" value={selectedItem.nextPlan || "暂无日报回写规划"} className="md:col-span-2" />
|
||||
<DetailItem label="备注说明" value={selectedItem.notes || "无"} className="md:col-span-2" />
|
||||
|
|
@ -1493,15 +1875,20 @@ export default function Opportunities() {
|
|||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
onClick={handleOpenEdit}
|
||||
disabled={Boolean(selectedItem.pushedToOms)}
|
||||
className="crm-btn crm-btn-secondary inline-flex h-11 items-center justify-center disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="crm-btn crm-btn-secondary inline-flex h-11 items-center justify-center"
|
||||
>
|
||||
编辑商机
|
||||
</button>
|
||||
<button
|
||||
onClick={handleOpenPushConfirm}
|
||||
type="button"
|
||||
onClick={() => void handleOpenPushConfirm()}
|
||||
disabled={Boolean(selectedItem.pushedToOms) || pushingOms}
|
||||
className="crm-btn-sm crm-btn-primary inline-flex items-center justify-center disabled:cursor-not-allowed disabled:opacity-60"
|
||||
className={cn(
|
||||
"crm-btn inline-flex h-11 items-center justify-center",
|
||||
selectedItem.pushedToOms
|
||||
? "cursor-not-allowed rounded-2xl border border-slate-200 bg-slate-50 text-slate-400 dark:border-slate-800 dark:bg-slate-800/40 dark:text-slate-500"
|
||||
: "crm-btn-primary",
|
||||
)}
|
||||
>
|
||||
{selectedItem.pushedToOms ? "已推送 OMS" : pushingOms ? "推送中..." : "推送 OMS"}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -8,8 +8,9 @@ export default defineConfig(({mode}) => {
|
|||
const env = loadEnv(mode, '.', '');
|
||||
const certPath = path.resolve(__dirname, '.cert/dev.crt');
|
||||
const keyPath = path.resolve(__dirname, '.cert/dev.key');
|
||||
const enableHttps = String(env.VITE_DEV_HTTPS || '').trim().toLowerCase() === 'true';
|
||||
const https =
|
||||
fs.existsSync(certPath) && fs.existsSync(keyPath)
|
||||
enableHttps && fs.existsSync(certPath) && fs.existsSync(keyPath)
|
||||
? {
|
||||
cert: fs.readFileSync(certPath),
|
||||
key: fs.readFileSync(keyPath),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
begin;
|
||||
|
||||
set search_path to public;
|
||||
|
||||
alter table if exists crm_channel_expansion
|
||||
add column if not exists city varchar(50),
|
||||
add column if not exists certification_level varchar(100);
|
||||
|
||||
comment on column crm_channel_expansion.city is '市';
|
||||
comment on column crm_channel_expansion.certification_level is '认证级别';
|
||||
|
||||
commit;
|
||||
|
|
@ -0,0 +1,237 @@
|
|||
begin;
|
||||
|
||||
set search_path to public;
|
||||
|
||||
create or replace function comment_on_column_if_exists(p_table_name text, p_column_name text, p_comment_text text)
|
||||
returns void
|
||||
language plpgsql
|
||||
as $$
|
||||
begin
|
||||
if exists (
|
||||
select 1
|
||||
from information_schema.columns c
|
||||
where c.table_schema = current_schema()
|
||||
and c.table_name = p_table_name
|
||||
and c.column_name = p_column_name
|
||||
) then
|
||||
execute format(
|
||||
'comment on column %I.%I is %L',
|
||||
p_table_name,
|
||||
p_column_name,
|
||||
p_comment_text
|
||||
);
|
||||
end if;
|
||||
end;
|
||||
$$;
|
||||
|
||||
with column_comments(table_name, column_name, comment_text) as (
|
||||
values
|
||||
('sys_user', 'id', '用户主键'),
|
||||
('sys_user', 'user_id', '用户ID'),
|
||||
('sys_user', 'user_code', '工号/员工编号'),
|
||||
('sys_user', 'username', '登录账号'),
|
||||
('sys_user', 'real_name', '姓名'),
|
||||
('sys_user', 'display_name', '显示名称'),
|
||||
('sys_user', 'mobile', '手机号'),
|
||||
('sys_user', 'phone', '手机号'),
|
||||
('sys_user', 'email', '邮箱'),
|
||||
('sys_user', 'org_id', '所属组织ID'),
|
||||
('sys_user', 'job_title', '职位'),
|
||||
('sys_user', 'status', '用户状态'),
|
||||
('sys_user', 'hire_date', '入职日期'),
|
||||
('sys_user', 'avatar_url', '头像地址'),
|
||||
('sys_user', 'password_hash', '密码哈希'),
|
||||
('sys_user', 'created_at', '创建时间'),
|
||||
('sys_user', 'updated_at', '更新时间'),
|
||||
('sys_user', 'is_deleted', '逻辑删除标记'),
|
||||
('sys_user', 'pwd_reset_required', '首次登录是否需要重置密码'),
|
||||
('sys_user', 'is_platform_admin', '是否平台管理员'),
|
||||
|
||||
('crm_customer', 'id', '客户主键'),
|
||||
('crm_customer', 'customer_code', '客户编码'),
|
||||
('crm_customer', 'customer_name', '客户名称'),
|
||||
('crm_customer', 'customer_type', '客户类型'),
|
||||
('crm_customer', 'industry', '行业'),
|
||||
('crm_customer', 'province', '省份'),
|
||||
('crm_customer', 'city', '城市'),
|
||||
('crm_customer', 'address', '详细地址'),
|
||||
('crm_customer', 'owner_user_id', '当前负责人ID'),
|
||||
('crm_customer', 'source', '客户来源'),
|
||||
('crm_customer', 'status', '客户状态'),
|
||||
('crm_customer', 'remark', '备注说明'),
|
||||
('crm_customer', 'created_at', '创建时间'),
|
||||
('crm_customer', 'updated_at', '更新时间'),
|
||||
|
||||
('crm_opportunity', 'id', '商机主键'),
|
||||
('crm_opportunity', 'opportunity_code', '商机编号'),
|
||||
('crm_opportunity', 'opportunity_name', '商机名称'),
|
||||
('crm_opportunity', 'customer_id', '客户ID'),
|
||||
('crm_opportunity', 'owner_user_id', '商机负责人ID'),
|
||||
('crm_opportunity', 'sales_expansion_id', '关联销售拓展ID'),
|
||||
('crm_opportunity', 'channel_expansion_id', '关联渠道拓展ID'),
|
||||
('crm_opportunity', 'pre_sales_id', '售前ID'),
|
||||
('crm_opportunity', 'pre_sales_name', '售前姓名'),
|
||||
('crm_opportunity', 'project_location', '项目所在地'),
|
||||
('crm_opportunity', 'operator_name', '运作方'),
|
||||
('crm_opportunity', 'amount', '商机金额'),
|
||||
('crm_opportunity', 'expected_close_date', '预计结单日期'),
|
||||
('crm_opportunity', 'confidence_pct', '把握度(0-100)'),
|
||||
('crm_opportunity', 'stage', '商机阶段'),
|
||||
('crm_opportunity', 'opportunity_type', '商机类型'),
|
||||
('crm_opportunity', 'product_type', '产品类型'),
|
||||
('crm_opportunity', 'source', '商机来源'),
|
||||
('crm_opportunity', 'competitor_name', '竞品名称'),
|
||||
('crm_opportunity', 'archived', '是否归档'),
|
||||
('crm_opportunity', 'pushed_to_oms', '是否已推送OMS'),
|
||||
('crm_opportunity', 'oms_project_code', 'OMS系统项目编号'),
|
||||
('crm_opportunity', 'oms_push_time', '推送OMS时间'),
|
||||
('crm_opportunity', 'description', '商机说明/备注'),
|
||||
('crm_opportunity', 'status', '商机状态'),
|
||||
('crm_opportunity', 'created_at', '创建时间'),
|
||||
('crm_opportunity', 'updated_at', '更新时间'),
|
||||
|
||||
('crm_opportunity_followup', 'id', '跟进记录主键'),
|
||||
('crm_opportunity_followup', 'opportunity_id', '商机ID'),
|
||||
('crm_opportunity_followup', 'followup_time', '跟进时间'),
|
||||
('crm_opportunity_followup', 'followup_type', '跟进方式'),
|
||||
('crm_opportunity_followup', 'content', '跟进内容'),
|
||||
('crm_opportunity_followup', 'next_action', '下一步动作'),
|
||||
('crm_opportunity_followup', 'followup_user_id', '跟进人ID'),
|
||||
('crm_opportunity_followup', 'source_type', '来源类型'),
|
||||
('crm_opportunity_followup', 'source_id', '来源记录ID'),
|
||||
('crm_opportunity_followup', 'created_at', '创建时间'),
|
||||
('crm_opportunity_followup', 'updated_at', '更新时间'),
|
||||
|
||||
('crm_sales_expansion', 'id', '销售拓展主键'),
|
||||
('crm_sales_expansion', 'employee_no', '工号/员工编号'),
|
||||
('crm_sales_expansion', 'candidate_name', '候选人姓名'),
|
||||
('crm_sales_expansion', 'office_name', '办事处/代表处'),
|
||||
('crm_sales_expansion', 'mobile', '手机号'),
|
||||
('crm_sales_expansion', 'email', '邮箱'),
|
||||
('crm_sales_expansion', 'target_dept', '所属部门'),
|
||||
('crm_sales_expansion', 'industry', '所属行业'),
|
||||
('crm_sales_expansion', 'title', '职务'),
|
||||
('crm_sales_expansion', 'intent_level', '合作意向'),
|
||||
('crm_sales_expansion', 'stage', '跟进阶段'),
|
||||
('crm_sales_expansion', 'has_desktop_exp', '是否有云桌面经验'),
|
||||
('crm_sales_expansion', 'in_progress', '是否持续跟进中'),
|
||||
('crm_sales_expansion', 'employment_status', '候选人状态'),
|
||||
('crm_sales_expansion', 'expected_join_date', '预计入职日期'),
|
||||
('crm_sales_expansion', 'owner_user_id', '负责人ID'),
|
||||
('crm_sales_expansion', 'remark', '备注说明'),
|
||||
('crm_sales_expansion', 'created_at', '创建时间'),
|
||||
('crm_sales_expansion', 'updated_at', '更新时间'),
|
||||
|
||||
('crm_channel_expansion', 'id', '渠道拓展主键'),
|
||||
('crm_channel_expansion', 'channel_code', '渠道编码'),
|
||||
('crm_channel_expansion', 'province', '省份'),
|
||||
('crm_channel_expansion', 'city', '市'),
|
||||
('crm_channel_expansion', 'channel_name', '渠道名称'),
|
||||
('crm_channel_expansion', 'office_address', '办公地址'),
|
||||
('crm_channel_expansion', 'channel_industry', '聚焦行业'),
|
||||
('crm_channel_expansion', 'certification_level', '认证级别'),
|
||||
('crm_channel_expansion', 'industry', '行业(兼容旧字段)'),
|
||||
('crm_channel_expansion', 'annual_revenue', '年营收'),
|
||||
('crm_channel_expansion', 'staff_size', '人员规模'),
|
||||
('crm_channel_expansion', 'contact_established_date', '建立联系日期'),
|
||||
('crm_channel_expansion', 'intent_level', '合作意向'),
|
||||
('crm_channel_expansion', 'has_desktop_exp', '是否有云桌面经验'),
|
||||
('crm_channel_expansion', 'contact_name', '主联系人姓名(兼容旧结构)'),
|
||||
('crm_channel_expansion', 'contact_title', '主联系人职务(兼容旧结构)'),
|
||||
('crm_channel_expansion', 'contact_mobile', '主联系人电话(兼容旧结构)'),
|
||||
('crm_channel_expansion', 'channel_attribute', '渠道属性编码,多个值逗号分隔'),
|
||||
('crm_channel_expansion', 'internal_attribute', '新华三内部属性编码,多个值逗号分隔'),
|
||||
('crm_channel_expansion', 'stage', '渠道合作阶段'),
|
||||
('crm_channel_expansion', 'landed_flag', '是否已落地'),
|
||||
('crm_channel_expansion', 'expected_sign_date', '预计签约日期'),
|
||||
('crm_channel_expansion', 'owner_user_id', '负责人ID'),
|
||||
('crm_channel_expansion', 'remark', '备注说明'),
|
||||
('crm_channel_expansion', 'created_at', '创建时间'),
|
||||
('crm_channel_expansion', 'updated_at', '更新时间'),
|
||||
|
||||
('crm_channel_expansion_contact', 'id', '联系人主键'),
|
||||
('crm_channel_expansion_contact', 'channel_expansion_id', '渠道拓展ID'),
|
||||
('crm_channel_expansion_contact', 'contact_name', '联系人姓名'),
|
||||
('crm_channel_expansion_contact', 'contact_mobile', '联系人电话'),
|
||||
('crm_channel_expansion_contact', 'contact_title', '联系人职务'),
|
||||
('crm_channel_expansion_contact', 'sort_order', '排序号'),
|
||||
('crm_channel_expansion_contact', 'created_at', '创建时间'),
|
||||
('crm_channel_expansion_contact', 'updated_at', '更新时间'),
|
||||
|
||||
('crm_expansion_followup', 'id', '跟进记录主键'),
|
||||
('crm_expansion_followup', 'biz_type', '业务类型'),
|
||||
('crm_expansion_followup', 'biz_id', '业务对象ID'),
|
||||
('crm_expansion_followup', 'followup_time', '跟进时间'),
|
||||
('crm_expansion_followup', 'followup_type', '跟进方式'),
|
||||
('crm_expansion_followup', 'content', '跟进内容'),
|
||||
('crm_expansion_followup', 'next_action', '下一步动作'),
|
||||
('crm_expansion_followup', 'followup_user_id', '跟进人ID'),
|
||||
('crm_expansion_followup', 'visit_start_time', '拜访开始时间'),
|
||||
('crm_expansion_followup', 'evaluation_content', '评估内容'),
|
||||
('crm_expansion_followup', 'next_plan', '后续规划'),
|
||||
('crm_expansion_followup', 'source_type', '来源类型'),
|
||||
('crm_expansion_followup', 'source_id', '来源记录ID'),
|
||||
('crm_expansion_followup', 'created_at', '创建时间'),
|
||||
('crm_expansion_followup', 'updated_at', '更新时间'),
|
||||
|
||||
('work_checkin', 'id', '打卡记录主键'),
|
||||
('work_checkin', 'user_id', '打卡人ID'),
|
||||
('work_checkin', 'checkin_date', '打卡日期'),
|
||||
('work_checkin', 'checkin_time', '打卡时间'),
|
||||
('work_checkin', 'biz_type', '关联对象类型'),
|
||||
('work_checkin', 'biz_id', '关联对象ID'),
|
||||
('work_checkin', 'biz_name', '关联对象名称'),
|
||||
('work_checkin', 'longitude', '经度'),
|
||||
('work_checkin', 'latitude', '纬度'),
|
||||
('work_checkin', 'location_text', '打卡地点'),
|
||||
('work_checkin', 'remark', '备注说明(含现场照片元数据)'),
|
||||
('work_checkin', 'user_name', '打卡人姓名快照'),
|
||||
('work_checkin', 'dept_name', '所属部门快照'),
|
||||
('work_checkin', 'status', '打卡状态'),
|
||||
('work_checkin', 'created_at', '创建时间'),
|
||||
('work_checkin', 'updated_at', '更新时间'),
|
||||
|
||||
('work_daily_report', 'id', '日报主键'),
|
||||
('work_daily_report', 'user_id', '提交人ID'),
|
||||
('work_daily_report', 'report_date', '日报日期'),
|
||||
('work_daily_report', 'work_content', '今日工作内容(含结构化明细元数据)'),
|
||||
('work_daily_report', 'tomorrow_plan', '明日工作计划(含结构化计划项元数据)'),
|
||||
('work_daily_report', 'source_type', '提交来源'),
|
||||
('work_daily_report', 'submit_time', '提交时间'),
|
||||
('work_daily_report', 'status', '日报状态'),
|
||||
('work_daily_report', 'score', '日报评分'),
|
||||
('work_daily_report', 'created_at', '创建时间'),
|
||||
('work_daily_report', 'updated_at', '更新时间'),
|
||||
|
||||
('work_daily_report_comment', 'id', '点评记录主键'),
|
||||
('work_daily_report_comment', 'report_id', '日报ID'),
|
||||
('work_daily_report_comment', 'reviewer_user_id', '点评人ID'),
|
||||
('work_daily_report_comment', 'score', '点评评分'),
|
||||
('work_daily_report_comment', 'comment_content', '点评内容'),
|
||||
('work_daily_report_comment', 'reviewed_at', '点评时间'),
|
||||
('work_daily_report_comment', 'created_at', '创建时间'),
|
||||
|
||||
('work_todo', 'id', '待办主键'),
|
||||
('work_todo', 'user_id', '所属用户ID'),
|
||||
('work_todo', 'title', '待办标题'),
|
||||
('work_todo', 'biz_type', '业务类型'),
|
||||
('work_todo', 'biz_id', '业务对象ID'),
|
||||
('work_todo', 'due_date', '截止时间'),
|
||||
('work_todo', 'status', '待办状态'),
|
||||
('work_todo', 'priority', '优先级'),
|
||||
('work_todo', 'created_at', '创建时间'),
|
||||
('work_todo', 'updated_at', '更新时间'),
|
||||
|
||||
('sys_activity_log', 'id', '动态主键'),
|
||||
('sys_activity_log', 'biz_type', '业务类型'),
|
||||
('sys_activity_log', 'biz_id', '业务对象ID'),
|
||||
('sys_activity_log', 'action_type', '动作类型'),
|
||||
('sys_activity_log', 'title', '动态标题'),
|
||||
('sys_activity_log', 'content', '动态内容'),
|
||||
('sys_activity_log', 'operator_user_id', '操作人ID'),
|
||||
('sys_activity_log', 'created_at', '创建时间')
|
||||
)
|
||||
select comment_on_column_if_exists(table_name, column_name, comment_text)
|
||||
from column_comments;
|
||||
|
||||
commit;
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
ALTER TABLE IF EXISTS crm_opportunity
|
||||
ADD COLUMN IF NOT EXISTS oms_project_code varchar(100);
|
||||
|
||||
COMMENT ON COLUMN crm_opportunity.oms_project_code IS 'OMS系统项目编号';
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
ALTER TABLE IF EXISTS crm_opportunity
|
||||
ADD COLUMN IF NOT EXISTS pre_sales_id bigint,
|
||||
ADD COLUMN IF NOT EXISTS pre_sales_name varchar(100);
|
||||
|
||||
COMMENT ON COLUMN crm_opportunity.pre_sales_id IS '售前ID';
|
||||
COMMENT ON COLUMN crm_opportunity.pre_sales_name IS '售前姓名';
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
begin;
|
||||
|
||||
set search_path to public;
|
||||
|
||||
create table if not exists cnarea (
|
||||
id integer generated by default as identity primary key,
|
||||
level smallint not null check (level >= 0),
|
||||
parent_code varchar(16) not null default '0',
|
||||
area_code varchar(16) not null default '0',
|
||||
zip_code varchar(6) not null default '000000',
|
||||
city_code char(6) not null default '',
|
||||
name varchar(50) not null default '',
|
||||
short_name varchar(50) not null default '',
|
||||
merger_name varchar(50) not null default '',
|
||||
pinyin varchar(30) not null default '',
|
||||
lng numeric(10, 6) not null default 0.000000,
|
||||
lat numeric(10, 6) not null default 0.000000,
|
||||
short_code varchar(8)
|
||||
);
|
||||
|
||||
create unique index if not exists uk_cnarea_code on cnarea(area_code);
|
||||
create index if not exists idx_cnarea_parent_code on cnarea(parent_code);
|
||||
|
||||
comment on table cnarea is '中国行政地区表';
|
||||
comment on column cnarea.id is '主键';
|
||||
comment on column cnarea.level is '层级';
|
||||
comment on column cnarea.parent_code is '父级行政代码';
|
||||
comment on column cnarea.area_code is '行政代码';
|
||||
comment on column cnarea.zip_code is '邮政编码';
|
||||
comment on column cnarea.city_code is '区号';
|
||||
comment on column cnarea.name is '名称';
|
||||
comment on column cnarea.short_name is '简称';
|
||||
comment on column cnarea.merger_name is '组合名';
|
||||
comment on column cnarea.pinyin is '拼音';
|
||||
comment on column cnarea.lng is '经度';
|
||||
comment on column cnarea.lat is '纬度';
|
||||
comment on column cnarea.short_code is '简短code';
|
||||
|
||||
commit;
|
||||
|
|
@ -53,6 +53,28 @@ begin
|
|||
end;
|
||||
$$;
|
||||
|
||||
create or replace function comment_on_column_if_exists(p_table_name text, p_column_name text, p_comment_text text)
|
||||
returns void
|
||||
language plpgsql
|
||||
as $$
|
||||
begin
|
||||
if exists (
|
||||
select 1
|
||||
from information_schema.columns c
|
||||
where c.table_schema = current_schema()
|
||||
and c.table_name = p_table_name
|
||||
and c.column_name = p_column_name
|
||||
) then
|
||||
execute format(
|
||||
'comment on column %I.%I is %L',
|
||||
p_table_name,
|
||||
p_column_name,
|
||||
p_comment_text
|
||||
);
|
||||
end if;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- =====================================================================
|
||||
-- Section 2. Base tables
|
||||
-- =====================================================================
|
||||
|
|
@ -103,11 +125,13 @@ create table if not exists crm_opportunity (
|
|||
owner_user_id bigint not null,
|
||||
sales_expansion_id bigint,
|
||||
channel_expansion_id bigint,
|
||||
pre_sales_id bigint,
|
||||
pre_sales_name varchar(100),
|
||||
project_location varchar(100),
|
||||
operator_name varchar(100),
|
||||
amount numeric(18, 2) not null default 0,
|
||||
expected_close_date date,
|
||||
confidence_pct smallint not null default 0 check (confidence_pct between 0 and 100),
|
||||
confidence_pct varchar(1) not null default 'C' check (confidence_pct in ('A', 'B', 'C')),
|
||||
stage varchar(50) not null default 'initial_contact',
|
||||
opportunity_type varchar(50),
|
||||
product_type varchar(100),
|
||||
|
|
@ -169,9 +193,11 @@ create table if not exists crm_channel_expansion (
|
|||
id bigint generated by default as identity primary key,
|
||||
channel_code varchar(50),
|
||||
province varchar(50),
|
||||
city varchar(50),
|
||||
channel_name varchar(200) not null,
|
||||
office_address varchar(255),
|
||||
channel_industry varchar(100),
|
||||
certification_level varchar(100),
|
||||
annual_revenue numeric(18, 2),
|
||||
staff_size integer check (staff_size is null or staff_size >= 0),
|
||||
contact_established_date date,
|
||||
|
|
@ -521,6 +547,8 @@ END $$;
|
|||
ALTER TABLE IF EXISTS crm_opportunity
|
||||
ADD COLUMN IF NOT EXISTS sales_expansion_id bigint,
|
||||
ADD COLUMN IF NOT EXISTS channel_expansion_id bigint,
|
||||
ADD COLUMN IF NOT EXISTS pre_sales_id bigint,
|
||||
ADD COLUMN IF NOT EXISTS pre_sales_name varchar(100),
|
||||
ADD COLUMN IF NOT EXISTS project_location varchar(100),
|
||||
ADD COLUMN IF NOT EXISTS operator_name varchar(100),
|
||||
ADD COLUMN IF NOT EXISTS competitor_name varchar(200);
|
||||
|
|
@ -554,6 +582,8 @@ ALTER TABLE IF EXISTS crm_channel_expansion
|
|||
ADD COLUMN IF NOT EXISTS channel_code varchar(50),
|
||||
ADD COLUMN IF NOT EXISTS office_address varchar(255),
|
||||
ADD COLUMN IF NOT EXISTS channel_industry varchar(100),
|
||||
ADD COLUMN IF NOT EXISTS city varchar(50),
|
||||
ADD COLUMN IF NOT EXISTS certification_level varchar(100),
|
||||
ADD COLUMN IF NOT EXISTS contact_established_date date,
|
||||
ADD COLUMN IF NOT EXISTS intent_level varchar(20),
|
||||
ADD COLUMN IF NOT EXISTS has_desktop_exp boolean,
|
||||
|
|
@ -693,4 +723,214 @@ CREATE INDEX IF NOT EXISTS idx_crm_opportunity_followup_source
|
|||
CREATE INDEX IF NOT EXISTS idx_crm_channel_expansion_contact_channel
|
||||
ON crm_channel_expansion_contact(channel_expansion_id);
|
||||
|
||||
-- Column comments
|
||||
WITH column_comments(table_name, column_name, comment_text) AS (
|
||||
VALUES
|
||||
('sys_user', 'id', '用户主键'),
|
||||
('sys_user', 'user_id', '用户ID'),
|
||||
('sys_user', 'user_code', '工号/员工编号'),
|
||||
('sys_user', 'username', '登录账号'),
|
||||
('sys_user', 'real_name', '姓名'),
|
||||
('sys_user', 'display_name', '显示名称'),
|
||||
('sys_user', 'mobile', '手机号'),
|
||||
('sys_user', 'phone', '手机号'),
|
||||
('sys_user', 'email', '邮箱'),
|
||||
('sys_user', 'org_id', '所属组织ID'),
|
||||
('sys_user', 'job_title', '职位'),
|
||||
('sys_user', 'status', '用户状态'),
|
||||
('sys_user', 'hire_date', '入职日期'),
|
||||
('sys_user', 'avatar_url', '头像地址'),
|
||||
('sys_user', 'password_hash', '密码哈希'),
|
||||
('sys_user', 'created_at', '创建时间'),
|
||||
('sys_user', 'updated_at', '更新时间'),
|
||||
('sys_user', 'is_deleted', '逻辑删除标记'),
|
||||
('sys_user', 'pwd_reset_required', '首次登录是否需要重置密码'),
|
||||
('sys_user', 'is_platform_admin', '是否平台管理员'),
|
||||
|
||||
('crm_customer', 'id', '客户主键'),
|
||||
('crm_customer', 'customer_code', '客户编码'),
|
||||
('crm_customer', 'customer_name', '客户名称'),
|
||||
('crm_customer', 'customer_type', '客户类型'),
|
||||
('crm_customer', 'industry', '行业'),
|
||||
('crm_customer', 'province', '省份'),
|
||||
('crm_customer', 'city', '城市'),
|
||||
('crm_customer', 'address', '详细地址'),
|
||||
('crm_customer', 'owner_user_id', '当前负责人ID'),
|
||||
('crm_customer', 'source', '客户来源'),
|
||||
('crm_customer', 'status', '客户状态'),
|
||||
('crm_customer', 'remark', '备注说明'),
|
||||
('crm_customer', 'created_at', '创建时间'),
|
||||
('crm_customer', 'updated_at', '更新时间'),
|
||||
|
||||
('crm_opportunity', 'id', '商机主键'),
|
||||
('crm_opportunity', 'opportunity_code', '商机编号'),
|
||||
('crm_opportunity', 'opportunity_name', '商机名称'),
|
||||
('crm_opportunity', 'customer_id', '客户ID'),
|
||||
('crm_opportunity', 'owner_user_id', '商机负责人ID'),
|
||||
('crm_opportunity', 'sales_expansion_id', '关联销售拓展ID'),
|
||||
('crm_opportunity', 'channel_expansion_id', '关联渠道拓展ID'),
|
||||
('crm_opportunity', 'pre_sales_id', '售前ID'),
|
||||
('crm_opportunity', 'pre_sales_name', '售前姓名'),
|
||||
('crm_opportunity', 'project_location', '项目所在地'),
|
||||
('crm_opportunity', 'operator_name', '运作方'),
|
||||
('crm_opportunity', 'amount', '商机金额'),
|
||||
('crm_opportunity', 'expected_close_date', '预计结单日期'),
|
||||
('crm_opportunity', 'confidence_pct', '把握度等级(A/B/C)'),
|
||||
('crm_opportunity', 'stage', '商机阶段'),
|
||||
('crm_opportunity', 'opportunity_type', '商机类型'),
|
||||
('crm_opportunity', 'product_type', '产品类型'),
|
||||
('crm_opportunity', 'source', '商机来源'),
|
||||
('crm_opportunity', 'competitor_name', '竞品名称'),
|
||||
('crm_opportunity', 'archived', '是否归档'),
|
||||
('crm_opportunity', 'pushed_to_oms', '是否已推送OMS'),
|
||||
('crm_opportunity', 'oms_push_time', '推送OMS时间'),
|
||||
('crm_opportunity', 'description', '商机说明/备注'),
|
||||
('crm_opportunity', 'status', '商机状态'),
|
||||
('crm_opportunity', 'created_at', '创建时间'),
|
||||
('crm_opportunity', 'updated_at', '更新时间'),
|
||||
|
||||
('crm_opportunity_followup', 'id', '跟进记录主键'),
|
||||
('crm_opportunity_followup', 'opportunity_id', '商机ID'),
|
||||
('crm_opportunity_followup', 'followup_time', '跟进时间'),
|
||||
('crm_opportunity_followup', 'followup_type', '跟进方式'),
|
||||
('crm_opportunity_followup', 'content', '跟进内容'),
|
||||
('crm_opportunity_followup', 'next_action', '下一步动作'),
|
||||
('crm_opportunity_followup', 'followup_user_id', '跟进人ID'),
|
||||
('crm_opportunity_followup', 'source_type', '来源类型'),
|
||||
('crm_opportunity_followup', 'source_id', '来源记录ID'),
|
||||
('crm_opportunity_followup', 'created_at', '创建时间'),
|
||||
('crm_opportunity_followup', 'updated_at', '更新时间'),
|
||||
|
||||
('crm_sales_expansion', 'id', '销售拓展主键'),
|
||||
('crm_sales_expansion', 'employee_no', '工号/员工编号'),
|
||||
('crm_sales_expansion', 'candidate_name', '候选人姓名'),
|
||||
('crm_sales_expansion', 'office_name', '办事处/代表处'),
|
||||
('crm_sales_expansion', 'mobile', '手机号'),
|
||||
('crm_sales_expansion', 'email', '邮箱'),
|
||||
('crm_sales_expansion', 'target_dept', '所属部门'),
|
||||
('crm_sales_expansion', 'industry', '所属行业'),
|
||||
('crm_sales_expansion', 'title', '职务'),
|
||||
('crm_sales_expansion', 'intent_level', '合作意向'),
|
||||
('crm_sales_expansion', 'stage', '跟进阶段'),
|
||||
('crm_sales_expansion', 'has_desktop_exp', '是否有云桌面经验'),
|
||||
('crm_sales_expansion', 'in_progress', '是否持续跟进中'),
|
||||
('crm_sales_expansion', 'employment_status', '候选人状态'),
|
||||
('crm_sales_expansion', 'expected_join_date', '预计入职日期'),
|
||||
('crm_sales_expansion', 'owner_user_id', '负责人ID'),
|
||||
('crm_sales_expansion', 'remark', '备注说明'),
|
||||
('crm_sales_expansion', 'created_at', '创建时间'),
|
||||
('crm_sales_expansion', 'updated_at', '更新时间'),
|
||||
|
||||
('crm_channel_expansion', 'id', '渠道拓展主键'),
|
||||
('crm_channel_expansion', 'channel_code', '渠道编码'),
|
||||
('crm_channel_expansion', 'province', '省份'),
|
||||
('crm_channel_expansion', 'city', '市'),
|
||||
('crm_channel_expansion', 'channel_name', '渠道名称'),
|
||||
('crm_channel_expansion', 'office_address', '办公地址'),
|
||||
('crm_channel_expansion', 'channel_industry', '聚焦行业'),
|
||||
('crm_channel_expansion', 'certification_level', '认证级别'),
|
||||
('crm_channel_expansion', 'industry', '行业(兼容旧字段)'),
|
||||
('crm_channel_expansion', 'annual_revenue', '年营收'),
|
||||
('crm_channel_expansion', 'staff_size', '人员规模'),
|
||||
('crm_channel_expansion', 'contact_established_date', '建立联系日期'),
|
||||
('crm_channel_expansion', 'intent_level', '合作意向'),
|
||||
('crm_channel_expansion', 'has_desktop_exp', '是否有云桌面经验'),
|
||||
('crm_channel_expansion', 'contact_name', '主联系人姓名(兼容旧结构)'),
|
||||
('crm_channel_expansion', 'contact_title', '主联系人职务(兼容旧结构)'),
|
||||
('crm_channel_expansion', 'contact_mobile', '主联系人电话(兼容旧结构)'),
|
||||
('crm_channel_expansion', 'channel_attribute', '渠道属性编码,多个值逗号分隔'),
|
||||
('crm_channel_expansion', 'internal_attribute', '新华三内部属性编码,多个值逗号分隔'),
|
||||
('crm_channel_expansion', 'stage', '渠道合作阶段'),
|
||||
('crm_channel_expansion', 'landed_flag', '是否已落地'),
|
||||
('crm_channel_expansion', 'expected_sign_date', '预计签约日期'),
|
||||
('crm_channel_expansion', 'owner_user_id', '负责人ID'),
|
||||
('crm_channel_expansion', 'remark', '备注说明'),
|
||||
('crm_channel_expansion', 'created_at', '创建时间'),
|
||||
('crm_channel_expansion', 'updated_at', '更新时间'),
|
||||
|
||||
('crm_channel_expansion_contact', 'id', '联系人主键'),
|
||||
('crm_channel_expansion_contact', 'channel_expansion_id', '渠道拓展ID'),
|
||||
('crm_channel_expansion_contact', 'contact_name', '联系人姓名'),
|
||||
('crm_channel_expansion_contact', 'contact_mobile', '联系人电话'),
|
||||
('crm_channel_expansion_contact', 'contact_title', '联系人职务'),
|
||||
('crm_channel_expansion_contact', 'sort_order', '排序号'),
|
||||
('crm_channel_expansion_contact', 'created_at', '创建时间'),
|
||||
('crm_channel_expansion_contact', 'updated_at', '更新时间'),
|
||||
|
||||
('crm_expansion_followup', 'id', '跟进记录主键'),
|
||||
('crm_expansion_followup', 'biz_type', '业务类型'),
|
||||
('crm_expansion_followup', 'biz_id', '业务对象ID'),
|
||||
('crm_expansion_followup', 'followup_time', '跟进时间'),
|
||||
('crm_expansion_followup', 'followup_type', '跟进方式'),
|
||||
('crm_expansion_followup', 'content', '跟进内容'),
|
||||
('crm_expansion_followup', 'next_action', '下一步动作'),
|
||||
('crm_expansion_followup', 'followup_user_id', '跟进人ID'),
|
||||
('crm_expansion_followup', 'visit_start_time', '拜访开始时间'),
|
||||
('crm_expansion_followup', 'evaluation_content', '评估内容'),
|
||||
('crm_expansion_followup', 'next_plan', '后续规划'),
|
||||
('crm_expansion_followup', 'source_type', '来源类型'),
|
||||
('crm_expansion_followup', 'source_id', '来源记录ID'),
|
||||
('crm_expansion_followup', 'created_at', '创建时间'),
|
||||
('crm_expansion_followup', 'updated_at', '更新时间'),
|
||||
|
||||
('work_checkin', 'id', '打卡记录主键'),
|
||||
('work_checkin', 'user_id', '打卡人ID'),
|
||||
('work_checkin', 'checkin_date', '打卡日期'),
|
||||
('work_checkin', 'checkin_time', '打卡时间'),
|
||||
('work_checkin', 'biz_type', '关联对象类型'),
|
||||
('work_checkin', 'biz_id', '关联对象ID'),
|
||||
('work_checkin', 'biz_name', '关联对象名称'),
|
||||
('work_checkin', 'longitude', '经度'),
|
||||
('work_checkin', 'latitude', '纬度'),
|
||||
('work_checkin', 'location_text', '打卡地点'),
|
||||
('work_checkin', 'remark', '备注说明(含现场照片元数据)'),
|
||||
('work_checkin', 'user_name', '打卡人姓名快照'),
|
||||
('work_checkin', 'dept_name', '所属部门快照'),
|
||||
('work_checkin', 'status', '打卡状态'),
|
||||
('work_checkin', 'created_at', '创建时间'),
|
||||
('work_checkin', 'updated_at', '更新时间'),
|
||||
|
||||
('work_daily_report', 'id', '日报主键'),
|
||||
('work_daily_report', 'user_id', '提交人ID'),
|
||||
('work_daily_report', 'report_date', '日报日期'),
|
||||
('work_daily_report', 'work_content', '今日工作内容(含结构化明细元数据)'),
|
||||
('work_daily_report', 'tomorrow_plan', '明日工作计划(含结构化计划项元数据)'),
|
||||
('work_daily_report', 'source_type', '提交来源'),
|
||||
('work_daily_report', 'submit_time', '提交时间'),
|
||||
('work_daily_report', 'status', '日报状态'),
|
||||
('work_daily_report', 'score', '日报评分'),
|
||||
('work_daily_report', 'created_at', '创建时间'),
|
||||
('work_daily_report', 'updated_at', '更新时间'),
|
||||
|
||||
('work_daily_report_comment', 'id', '点评记录主键'),
|
||||
('work_daily_report_comment', 'report_id', '日报ID'),
|
||||
('work_daily_report_comment', 'reviewer_user_id', '点评人ID'),
|
||||
('work_daily_report_comment', 'score', '点评评分'),
|
||||
('work_daily_report_comment', 'comment_content', '点评内容'),
|
||||
('work_daily_report_comment', 'reviewed_at', '点评时间'),
|
||||
('work_daily_report_comment', 'created_at', '创建时间'),
|
||||
|
||||
('work_todo', 'id', '待办主键'),
|
||||
('work_todo', 'user_id', '所属用户ID'),
|
||||
('work_todo', 'title', '待办标题'),
|
||||
('work_todo', 'biz_type', '业务类型'),
|
||||
('work_todo', 'biz_id', '业务对象ID'),
|
||||
('work_todo', 'due_date', '截止时间'),
|
||||
('work_todo', 'status', '待办状态'),
|
||||
('work_todo', 'priority', '优先级'),
|
||||
('work_todo', 'created_at', '创建时间'),
|
||||
('work_todo', 'updated_at', '更新时间'),
|
||||
|
||||
('sys_activity_log', 'id', '动态主键'),
|
||||
('sys_activity_log', 'biz_type', '业务类型'),
|
||||
('sys_activity_log', 'biz_id', '业务对象ID'),
|
||||
('sys_activity_log', 'action_type', '动作类型'),
|
||||
('sys_activity_log', 'title', '动态标题'),
|
||||
('sys_activity_log', 'content', '动态内容'),
|
||||
('sys_activity_log', 'operator_user_id', '操作人ID'),
|
||||
('sys_activity_log', 'created_at', '创建时间')
|
||||
)
|
||||
SELECT comment_on_column_if_exists(table_name, column_name, comment_text)
|
||||
FROM column_comments;
|
||||
|
||||
commit;
|
||||
|
|
|
|||
Loading…
Reference in New Issue