OMS对接
parent
42c21cc4fc
commit
dee5da7655
|
|
@ -1,6 +1,8 @@
|
||||||
package com.unis.crm;
|
package com.unis.crm;
|
||||||
|
|
||||||
import com.unis.crm.config.WecomProperties;
|
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.mybatis.spring.annotation.MapperScan;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
|
@ -8,7 +10,7 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
|
||||||
|
|
||||||
@SpringBootApplication(scanBasePackages = "com.unis.crm")
|
@SpringBootApplication(scanBasePackages = "com.unis.crm")
|
||||||
@MapperScan("com.unis.crm.mapper")
|
@MapperScan("com.unis.crm.mapper")
|
||||||
@EnableConfigurationProperties(WecomProperties.class)
|
@EnableConfigurationProperties({WecomProperties.class, OmsProperties.class, InternalAuthProperties.class})
|
||||||
public class UnisCrmBackendApplication {
|
public class UnisCrmBackendApplication {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,22 @@ import java.time.OffsetDateTime;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
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.ExceptionHandler;
|
||||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
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
|
@RestControllerAdvice
|
||||||
public class CrmGlobalExceptionHandler {
|
public class CrmGlobalExceptionHandler {
|
||||||
|
|
||||||
|
private static final String CURRENT_USER_HEADER = "X-User-Id";
|
||||||
|
private static final String UNAUTHORIZED_MESSAGE = "登录已失效,请重新登录";
|
||||||
|
|
||||||
@ExceptionHandler(BusinessException.class)
|
@ExceptionHandler(BusinessException.class)
|
||||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||||
public ApiResponse<Object> handleBusinessException(BusinessException ex) {
|
public ApiResponse<Object> handleBusinessException(BusinessException ex) {
|
||||||
|
|
@ -41,6 +49,27 @@ public class CrmGlobalExceptionHandler {
|
||||||
return ApiResponse.fail(ex.getMessage());
|
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)
|
@ExceptionHandler(Exception.class)
|
||||||
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
|
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
public Map<String, Object> handleUnexpectedException(Exception ex, HttpServletRequest request) {
|
public Map<String, Object> handleUnexpectedException(Exception ex, HttpServletRequest request) {
|
||||||
|
|
@ -52,4 +81,32 @@ public class CrmGlobalExceptionHandler {
|
||||||
body.put("path", request.getRequestURI());
|
body.put("path", request.getRequestURI());
|
||||||
return body;
|
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 {
|
public final class CurrentUserUtils {
|
||||||
|
|
||||||
|
private static final String UNAUTHORIZED_MESSAGE = "登录已失效,请重新登录";
|
||||||
|
|
||||||
private CurrentUserUtils() {
|
private CurrentUserUtils() {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Long requireCurrentUserId(Long headerUserId) {
|
public static Long requireCurrentUserId(Long headerUserId) {
|
||||||
if (headerUserId == null || headerUserId <= 0) {
|
if (headerUserId == null || headerUserId <= 0) {
|
||||||
throw new BusinessException("未识别到当前登录用户");
|
throw new UnauthorizedException(UNAUTHORIZED_MESSAGE);
|
||||||
}
|
}
|
||||||
return headerUserId;
|
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.CreateChannelExpansionRequest;
|
||||||
import com.unis.crm.dto.expansion.CreateExpansionFollowUpRequest;
|
import com.unis.crm.dto.expansion.CreateExpansionFollowUpRequest;
|
||||||
import com.unis.crm.dto.expansion.CreateSalesExpansionRequest;
|
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.ExpansionMetaDTO;
|
||||||
import com.unis.crm.dto.expansion.ExpansionOverviewDTO;
|
import com.unis.crm.dto.expansion.ExpansionOverviewDTO;
|
||||||
import com.unis.crm.dto.expansion.UpdateChannelExpansionRequest;
|
import com.unis.crm.dto.expansion.UpdateChannelExpansionRequest;
|
||||||
import com.unis.crm.dto.expansion.UpdateSalesExpansionRequest;
|
import com.unis.crm.dto.expansion.UpdateSalesExpansionRequest;
|
||||||
import com.unis.crm.service.ExpansionService;
|
import com.unis.crm.service.ExpansionService;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
import java.util.List;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
|
@ -37,6 +39,14 @@ public class ExpansionController {
|
||||||
return ApiResponse.success(expansionService.getMeta());
|
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")
|
@GetMapping("/overview")
|
||||||
public ApiResponse<ExpansionOverviewDTO> getOverview(
|
public ApiResponse<ExpansionOverviewDTO> getOverview(
|
||||||
@RequestHeader("X-User-Id") Long userId,
|
@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.common.CurrentUserUtils;
|
||||||
import com.unis.crm.dto.opportunity.CreateOpportunityFollowUpRequest;
|
import com.unis.crm.dto.opportunity.CreateOpportunityFollowUpRequest;
|
||||||
import com.unis.crm.dto.opportunity.CreateOpportunityRequest;
|
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.OpportunityMetaDTO;
|
||||||
import com.unis.crm.dto.opportunity.OpportunityOverviewDTO;
|
import com.unis.crm.dto.opportunity.OpportunityOverviewDTO;
|
||||||
|
import com.unis.crm.dto.opportunity.PushOpportunityToOmsRequest;
|
||||||
import com.unis.crm.service.OpportunityService;
|
import com.unis.crm.service.OpportunityService;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
import java.util.List;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
|
@ -41,6 +44,11 @@ public class OpportunityController {
|
||||||
return ApiResponse.success(opportunityService.getOverview(CurrentUserUtils.requireCurrentUserId(userId), keyword, stage));
|
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
|
@PostMapping
|
||||||
public ApiResponse<Long> createOpportunity(
|
public ApiResponse<Long> createOpportunity(
|
||||||
@RequestHeader("X-User-Id") Long userId,
|
@RequestHeader("X-User-Id") Long userId,
|
||||||
|
|
@ -59,8 +67,9 @@ public class OpportunityController {
|
||||||
@PostMapping("/{opportunityId}/push-oms")
|
@PostMapping("/{opportunityId}/push-oms")
|
||||||
public ApiResponse<Long> pushToOms(
|
public ApiResponse<Long> pushToOms(
|
||||||
@RequestHeader("X-User-Id") Long userId,
|
@RequestHeader("X-User-Id") Long userId,
|
||||||
@PathVariable("opportunityId") Long opportunityId) {
|
@PathVariable("opportunityId") Long opportunityId,
|
||||||
return ApiResponse.success(opportunityService.pushToOms(CurrentUserUtils.requireCurrentUserId(userId), opportunityId));
|
@RequestBody(required = false) PushOpportunityToOmsRequest request) {
|
||||||
|
return ApiResponse.success(opportunityService.pushToOms(CurrentUserUtils.requireCurrentUserId(userId), opportunityId, request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{opportunityId}/followups")
|
@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 type;
|
||||||
private String channelCode;
|
private String channelCode;
|
||||||
private String name;
|
private String name;
|
||||||
|
private String provinceCode;
|
||||||
private String province;
|
private String province;
|
||||||
|
private String cityCode;
|
||||||
|
private String city;
|
||||||
private String officeAddress;
|
private String officeAddress;
|
||||||
|
private String channelIndustryCode;
|
||||||
private String channelIndustry;
|
private String channelIndustry;
|
||||||
|
private String certificationLevel;
|
||||||
private String annualRevenue;
|
private String annualRevenue;
|
||||||
private String revenue;
|
private String revenue;
|
||||||
private Integer size;
|
private Integer size;
|
||||||
|
|
@ -71,6 +76,14 @@ public class ChannelExpansionItemDTO {
|
||||||
return province;
|
return province;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getProvinceCode() {
|
||||||
|
return provinceCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setProvinceCode(String provinceCode) {
|
||||||
|
this.provinceCode = provinceCode;
|
||||||
|
}
|
||||||
|
|
||||||
public void setProvince(String province) {
|
public void setProvince(String province) {
|
||||||
this.province = province;
|
this.province = province;
|
||||||
}
|
}
|
||||||
|
|
@ -79,6 +92,22 @@ public class ChannelExpansionItemDTO {
|
||||||
return officeAddress;
|
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) {
|
public void setOfficeAddress(String officeAddress) {
|
||||||
this.officeAddress = officeAddress;
|
this.officeAddress = officeAddress;
|
||||||
}
|
}
|
||||||
|
|
@ -87,10 +116,26 @@ public class ChannelExpansionItemDTO {
|
||||||
return channelIndustry;
|
return channelIndustry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getChannelIndustryCode() {
|
||||||
|
return channelIndustryCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setChannelIndustryCode(String channelIndustryCode) {
|
||||||
|
this.channelIndustryCode = channelIndustryCode;
|
||||||
|
}
|
||||||
|
|
||||||
public void setChannelIndustry(String channelIndustry) {
|
public void setChannelIndustry(String channelIndustry) {
|
||||||
this.channelIndustry = channelIndustry;
|
this.channelIndustry = channelIndustry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getCertificationLevel() {
|
||||||
|
return certificationLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCertificationLevel(String certificationLevel) {
|
||||||
|
this.certificationLevel = certificationLevel;
|
||||||
|
}
|
||||||
|
|
||||||
public String getAnnualRevenue() {
|
public String getAnnualRevenue() {
|
||||||
return annualRevenue;
|
return annualRevenue;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ public class CreateChannelExpansionRequest {
|
||||||
private String channelCode;
|
private String channelCode;
|
||||||
private String officeAddress;
|
private String officeAddress;
|
||||||
private String channelIndustry;
|
private String channelIndustry;
|
||||||
|
private String city;
|
||||||
|
private String certificationLevel;
|
||||||
|
|
||||||
@NotBlank(message = "渠道名称不能为空")
|
@NotBlank(message = "渠道名称不能为空")
|
||||||
@Size(max = 200, message = "渠道名称不能超过200字符")
|
@Size(max = 200, message = "渠道名称不能超过200字符")
|
||||||
|
|
@ -64,6 +66,22 @@ public class CreateChannelExpansionRequest {
|
||||||
this.channelIndustry = channelIndustry;
|
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() {
|
public String getChannelName() {
|
||||||
return channelName;
|
return channelName;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ public class ExpansionMetaDTO {
|
||||||
|
|
||||||
private List<DictOptionDTO> officeOptions;
|
private List<DictOptionDTO> officeOptions;
|
||||||
private List<DictOptionDTO> industryOptions;
|
private List<DictOptionDTO> industryOptions;
|
||||||
|
private List<DictOptionDTO> provinceOptions;
|
||||||
|
private List<DictOptionDTO> certificationLevelOptions;
|
||||||
private List<DictOptionDTO> channelAttributeOptions;
|
private List<DictOptionDTO> channelAttributeOptions;
|
||||||
private List<DictOptionDTO> internalAttributeOptions;
|
private List<DictOptionDTO> internalAttributeOptions;
|
||||||
private String nextChannelCode;
|
private String nextChannelCode;
|
||||||
|
|
@ -16,11 +18,15 @@ public class ExpansionMetaDTO {
|
||||||
public ExpansionMetaDTO(
|
public ExpansionMetaDTO(
|
||||||
List<DictOptionDTO> officeOptions,
|
List<DictOptionDTO> officeOptions,
|
||||||
List<DictOptionDTO> industryOptions,
|
List<DictOptionDTO> industryOptions,
|
||||||
|
List<DictOptionDTO> provinceOptions,
|
||||||
|
List<DictOptionDTO> certificationLevelOptions,
|
||||||
List<DictOptionDTO> channelAttributeOptions,
|
List<DictOptionDTO> channelAttributeOptions,
|
||||||
List<DictOptionDTO> internalAttributeOptions,
|
List<DictOptionDTO> internalAttributeOptions,
|
||||||
String nextChannelCode) {
|
String nextChannelCode) {
|
||||||
this.officeOptions = officeOptions;
|
this.officeOptions = officeOptions;
|
||||||
this.industryOptions = industryOptions;
|
this.industryOptions = industryOptions;
|
||||||
|
this.provinceOptions = provinceOptions;
|
||||||
|
this.certificationLevelOptions = certificationLevelOptions;
|
||||||
this.channelAttributeOptions = channelAttributeOptions;
|
this.channelAttributeOptions = channelAttributeOptions;
|
||||||
this.internalAttributeOptions = internalAttributeOptions;
|
this.internalAttributeOptions = internalAttributeOptions;
|
||||||
this.nextChannelCode = nextChannelCode;
|
this.nextChannelCode = nextChannelCode;
|
||||||
|
|
@ -42,6 +48,22 @@ public class ExpansionMetaDTO {
|
||||||
this.industryOptions = industryOptions;
|
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() {
|
public List<DictOptionDTO> getChannelAttributeOptions() {
|
||||||
return channelAttributeOptions;
|
return channelAttributeOptions;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ public class UpdateChannelExpansionRequest {
|
||||||
private String channelCode;
|
private String channelCode;
|
||||||
private String officeAddress;
|
private String officeAddress;
|
||||||
private String channelIndustry;
|
private String channelIndustry;
|
||||||
|
private String city;
|
||||||
|
private String certificationLevel;
|
||||||
|
|
||||||
@NotBlank(message = "渠道名称不能为空")
|
@NotBlank(message = "渠道名称不能为空")
|
||||||
@Size(max = 200, message = "渠道名称不能超过200字符")
|
@Size(max = 200, message = "渠道名称不能超过200字符")
|
||||||
|
|
@ -55,6 +57,22 @@ public class UpdateChannelExpansionRequest {
|
||||||
this.channelIndustry = channelIndustry;
|
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() {
|
public String getChannelName() {
|
||||||
return channelName;
|
return channelName;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
package com.unis.crm.dto.opportunity;
|
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.NotBlank;
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
import jakarta.validation.constraints.Size;
|
import jakarta.validation.constraints.Size;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
|
@ -31,10 +30,9 @@ public class CreateOpportunityRequest {
|
||||||
|
|
||||||
private LocalDate expectedCloseDate;
|
private LocalDate expectedCloseDate;
|
||||||
|
|
||||||
@NotNull(message = "把握度不能为空")
|
@NotBlank(message = "把握度不能为空")
|
||||||
@Min(value = 0, message = "把握度不能低于0")
|
@Pattern(regexp = "^(A|B|C|a|b|c|40|60|80)$", message = "把握度仅支持A、B、C")
|
||||||
@Max(value = 100, message = "把握度不能高于100")
|
private String confidencePct;
|
||||||
private Integer confidencePct;
|
|
||||||
|
|
||||||
private String stage;
|
private String stage;
|
||||||
private String opportunityType;
|
private String opportunityType;
|
||||||
|
|
@ -43,7 +41,6 @@ public class CreateOpportunityRequest {
|
||||||
private Long salesExpansionId;
|
private Long salesExpansionId;
|
||||||
private Long channelExpansionId;
|
private Long channelExpansionId;
|
||||||
private String competitorName;
|
private String competitorName;
|
||||||
private Boolean pushedToOms;
|
|
||||||
private String description;
|
private String description;
|
||||||
|
|
||||||
public Long getId() {
|
public Long getId() {
|
||||||
|
|
@ -102,11 +99,11 @@ public class CreateOpportunityRequest {
|
||||||
this.expectedCloseDate = expectedCloseDate;
|
this.expectedCloseDate = expectedCloseDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Integer getConfidencePct() {
|
public String getConfidencePct() {
|
||||||
return confidencePct;
|
return confidencePct;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setConfidencePct(Integer confidencePct) {
|
public void setConfidencePct(String confidencePct) {
|
||||||
this.confidencePct = confidencePct;
|
this.confidencePct = confidencePct;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -166,14 +163,6 @@ public class CreateOpportunityRequest {
|
||||||
this.competitorName = competitorName;
|
this.competitorName = competitorName;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Boolean getPushedToOms() {
|
|
||||||
return pushedToOms;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setPushedToOms(Boolean pushedToOms) {
|
|
||||||
this.pushedToOms = pushedToOms;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getDescription() {
|
public String getDescription() {
|
||||||
return description;
|
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 String operatorName;
|
||||||
private BigDecimal amount;
|
private BigDecimal amount;
|
||||||
private String date;
|
private String date;
|
||||||
private Integer confidence;
|
private String confidence;
|
||||||
private String stageCode;
|
private String stageCode;
|
||||||
private String stage;
|
private String stage;
|
||||||
private String type;
|
private String type;
|
||||||
|
|
@ -28,6 +28,8 @@ public class OpportunityItemDTO {
|
||||||
private String salesExpansionName;
|
private String salesExpansionName;
|
||||||
private Long channelExpansionId;
|
private Long channelExpansionId;
|
||||||
private String channelExpansionName;
|
private String channelExpansionName;
|
||||||
|
private Long preSalesId;
|
||||||
|
private String preSalesName;
|
||||||
private String competitorName;
|
private String competitorName;
|
||||||
private String latestProgress;
|
private String latestProgress;
|
||||||
private String nextPlan;
|
private String nextPlan;
|
||||||
|
|
@ -114,11 +116,11 @@ public class OpportunityItemDTO {
|
||||||
this.date = date;
|
this.date = date;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Integer getConfidence() {
|
public String getConfidence() {
|
||||||
return confidence;
|
return confidence;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setConfidence(Integer confidence) {
|
public void setConfidence(String confidence) {
|
||||||
this.confidence = confidence;
|
this.confidence = confidence;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -210,6 +212,22 @@ public class OpportunityItemDTO {
|
||||||
this.channelExpansionName = channelExpansionName;
|
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() {
|
public String getCompetitorName() {
|
||||||
return competitorName;
|
return competitorName;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,18 @@ public class OpportunityMetaDTO {
|
||||||
|
|
||||||
private List<OpportunityDictOptionDTO> stageOptions;
|
private List<OpportunityDictOptionDTO> stageOptions;
|
||||||
private List<OpportunityDictOptionDTO> operatorOptions;
|
private List<OpportunityDictOptionDTO> operatorOptions;
|
||||||
|
private List<OpportunityDictOptionDTO> projectLocationOptions;
|
||||||
|
|
||||||
public OpportunityMetaDTO() {
|
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.stageOptions = stageOptions;
|
||||||
this.operatorOptions = operatorOptions;
|
this.operatorOptions = operatorOptions;
|
||||||
|
this.projectLocationOptions = projectLocationOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<OpportunityDictOptionDTO> getStageOptions() {
|
public List<OpportunityDictOptionDTO> getStageOptions() {
|
||||||
|
|
@ -30,4 +35,12 @@ public class OpportunityMetaDTO {
|
||||||
public void setOperatorOptions(List<OpportunityDictOptionDTO> operatorOptions) {
|
public void setOperatorOptions(List<OpportunityDictOptionDTO> operatorOptions) {
|
||||||
this.operatorOptions = 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> selectDictItems(@Param("typeCode") String typeCode);
|
||||||
|
|
||||||
|
List<DictOptionDTO> selectProvinceAreaOptions();
|
||||||
|
|
||||||
|
List<DictOptionDTO> selectCityAreaOptionsByProvinceName(@Param("provinceName") String provinceName);
|
||||||
|
|
||||||
String selectNextChannelCode();
|
String selectNextChannelCode();
|
||||||
|
|
||||||
@DataScope(tableAlias = "s", ownerColumn = "owner_user_id")
|
@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.CreateOpportunityFollowUpRequest;
|
||||||
import com.unis.crm.dto.opportunity.CreateOpportunityRequest;
|
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.OpportunityDictOptionDTO;
|
||||||
import com.unis.crm.dto.opportunity.OpportunityFollowUpDTO;
|
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.OpportunityItemDTO;
|
||||||
|
import com.unis.crm.dto.opportunity.OpportunityOmsPushDataDTO;
|
||||||
|
import com.unis.crm.dto.opportunity.UpdateOpportunityIntegrationRequest;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
import org.apache.ibatis.annotations.Param;
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
@ -15,6 +19,8 @@ public interface OpportunityMapper {
|
||||||
|
|
||||||
List<OpportunityDictOptionDTO> selectDictItems(@Param("typeCode") String typeCode);
|
List<OpportunityDictOptionDTO> selectDictItems(@Param("typeCode") String typeCode);
|
||||||
|
|
||||||
|
List<OpportunityDictOptionDTO> selectProvinceAreaOptions();
|
||||||
|
|
||||||
String selectDictLabel(
|
String selectDictLabel(
|
||||||
@Param("typeCode") String typeCode,
|
@Param("typeCode") String typeCode,
|
||||||
@Param("itemValue") String itemValue);
|
@Param("itemValue") String itemValue);
|
||||||
|
|
@ -44,12 +50,32 @@ public interface OpportunityMapper {
|
||||||
@Param("customerId") Long customerId,
|
@Param("customerId") Long customerId,
|
||||||
@Param("request") CreateOpportunityRequest request);
|
@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")
|
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
|
||||||
int countOwnedOpportunity(@Param("userId") Long userId, @Param("id") Long id);
|
int countOwnedOpportunity(@Param("userId") Long userId, @Param("id") Long id);
|
||||||
|
|
||||||
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
|
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
|
||||||
Boolean selectPushedToOms(@Param("userId") Long userId, @Param("id") Long 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")
|
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
|
||||||
int updateOpportunity(
|
int updateOpportunity(
|
||||||
@Param("userId") Long userId,
|
@Param("userId") Long userId,
|
||||||
|
|
@ -57,10 +83,18 @@ public interface OpportunityMapper {
|
||||||
@Param("customerId") Long customerId,
|
@Param("customerId") Long customerId,
|
||||||
@Param("request") CreateOpportunityRequest request);
|
@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")
|
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
|
||||||
int pushOpportunityToOms(
|
int markOpportunityOmsPushed(
|
||||||
@Param("userId") Long userId,
|
@Param("userId") Long userId,
|
||||||
@Param("opportunityId") Long opportunityId);
|
@Param("opportunityId") Long opportunityId,
|
||||||
|
@Param("opportunityCode") String opportunityCode);
|
||||||
|
|
||||||
int insertOpportunityFollowUp(
|
int insertOpportunityFollowUp(
|
||||||
@Param("userId") Long userId,
|
@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.CreateChannelExpansionRequest;
|
||||||
import com.unis.crm.dto.expansion.CreateExpansionFollowUpRequest;
|
import com.unis.crm.dto.expansion.CreateExpansionFollowUpRequest;
|
||||||
import com.unis.crm.dto.expansion.CreateSalesExpansionRequest;
|
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.ExpansionMetaDTO;
|
||||||
import com.unis.crm.dto.expansion.ExpansionOverviewDTO;
|
import com.unis.crm.dto.expansion.ExpansionOverviewDTO;
|
||||||
import com.unis.crm.dto.expansion.UpdateChannelExpansionRequest;
|
import com.unis.crm.dto.expansion.UpdateChannelExpansionRequest;
|
||||||
import com.unis.crm.dto.expansion.UpdateSalesExpansionRequest;
|
import com.unis.crm.dto.expansion.UpdateSalesExpansionRequest;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
public interface ExpansionService {
|
public interface ExpansionService {
|
||||||
|
|
||||||
ExpansionMetaDTO getMeta();
|
ExpansionMetaDTO getMeta();
|
||||||
|
|
||||||
|
List<DictOptionDTO> getCityOptions(String provinceName);
|
||||||
|
|
||||||
ExpansionOverviewDTO getOverview(Long userId, String keyword);
|
ExpansionOverviewDTO getOverview(Long userId, String keyword);
|
||||||
|
|
||||||
Long createSalesExpansion(Long userId, CreateSalesExpansionRequest request);
|
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.CreateOpportunityFollowUpRequest;
|
||||||
import com.unis.crm.dto.opportunity.CreateOpportunityRequest;
|
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.OpportunityMetaDTO;
|
||||||
import com.unis.crm.dto.opportunity.OpportunityOverviewDTO;
|
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 {
|
public interface OpportunityService {
|
||||||
|
|
||||||
|
|
@ -11,11 +15,15 @@ public interface OpportunityService {
|
||||||
|
|
||||||
OpportunityOverviewDTO getOverview(Long userId, String keyword, String stage);
|
OpportunityOverviewDTO getOverview(Long userId, String keyword, String stage);
|
||||||
|
|
||||||
|
List<OmsPreSalesOptionDTO> getOmsPreSalesOptions(Long userId);
|
||||||
|
|
||||||
Long createOpportunity(Long userId, CreateOpportunityRequest request);
|
Long createOpportunity(Long userId, CreateOpportunityRequest request);
|
||||||
|
|
||||||
Long updateOpportunity(Long userId, Long opportunityId, 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);
|
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 OFFICE_TYPE_CODE = "tz_bsc";
|
||||||
private static final String INDUSTRY_TYPE_CODE = "tz_sshy";
|
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 CHANNEL_ATTRIBUTE_TYPE_CODE = "tz_qdsx";
|
||||||
private static final String INTERNAL_ATTRIBUTE_TYPE_CODE = "tz_xhsnbsx";
|
private static final String INTERNAL_ATTRIBUTE_TYPE_CODE = "tz_xhsnbsx";
|
||||||
private static final String MULTI_VALUE_CUSTOM_PREFIX = "__custom__:";
|
private static final String MULTI_VALUE_CUSTOM_PREFIX = "__custom__:";
|
||||||
|
|
@ -56,6 +57,8 @@ public class ExpansionServiceImpl implements ExpansionService {
|
||||||
return new ExpansionMetaDTO(
|
return new ExpansionMetaDTO(
|
||||||
loadDictOptions(OFFICE_TYPE_CODE),
|
loadDictOptions(OFFICE_TYPE_CODE),
|
||||||
loadDictOptions(INDUSTRY_TYPE_CODE),
|
loadDictOptions(INDUSTRY_TYPE_CODE),
|
||||||
|
expansionMapper.selectProvinceAreaOptions(),
|
||||||
|
loadDictOptions(CERTIFICATION_LEVEL_TYPE_CODE),
|
||||||
loadDictOptions(CHANNEL_ATTRIBUTE_TYPE_CODE),
|
loadDictOptions(CHANNEL_ATTRIBUTE_TYPE_CODE),
|
||||||
loadDictOptions(INTERNAL_ATTRIBUTE_TYPE_CODE),
|
loadDictOptions(INTERNAL_ATTRIBUTE_TYPE_CODE),
|
||||||
expansionMapper.selectNextChannelCode());
|
expansionMapper.selectNextChannelCode());
|
||||||
|
|
@ -66,10 +69,20 @@ public class ExpansionServiceImpl implements ExpansionService {
|
||||||
Collections.emptyList(),
|
Collections.emptyList(),
|
||||||
Collections.emptyList(),
|
Collections.emptyList(),
|
||||||
Collections.emptyList(),
|
Collections.emptyList(),
|
||||||
|
Collections.emptyList(),
|
||||||
|
Collections.emptyList(),
|
||||||
null);
|
null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<DictOptionDTO> getCityOptions(String provinceName) {
|
||||||
|
if (isBlank(provinceName)) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
return expansionMapper.selectCityAreaOptionsByProvinceName(provinceName.trim());
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ExpansionOverviewDTO getOverview(Long userId, String keyword) {
|
public ExpansionOverviewDTO getOverview(Long userId, String keyword) {
|
||||||
String normalizedKeyword = normalizeKeyword(keyword);
|
String normalizedKeyword = normalizeKeyword(keyword);
|
||||||
|
|
@ -81,7 +94,7 @@ public class ExpansionServiceImpl implements ExpansionService {
|
||||||
attachChannelFollowUps(userId, channelItems);
|
attachChannelFollowUps(userId, channelItems);
|
||||||
attachChannelContacts(userId, channelItems);
|
attachChannelContacts(userId, channelItems);
|
||||||
attachChannelRelatedProjects(userId, channelItems);
|
attachChannelRelatedProjects(userId, channelItems);
|
||||||
fillChannelAttributeDisplay(channelItems);
|
fillChannelDisplayFields(channelItems);
|
||||||
|
|
||||||
return new ExpansionOverviewDTO(salesItems, channelItems);
|
return new ExpansionOverviewDTO(salesItems, channelItems);
|
||||||
}
|
}
|
||||||
|
|
@ -258,15 +271,19 @@ public class ExpansionServiceImpl implements ExpansionService {
|
||||||
return expansionMapper.selectDictItems(typeCode);
|
return expansionMapper.selectDictItems(typeCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void fillChannelAttributeDisplay(List<ChannelExpansionItemDTO> channelItems) {
|
private void fillChannelDisplayFields(List<ChannelExpansionItemDTO> channelItems) {
|
||||||
if (channelItems == null || channelItems.isEmpty()) {
|
if (channelItems == null || channelItems.isEmpty()) {
|
||||||
return;
|
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> channelAttributeLabels = toDictLabelMap(loadDictOptions(CHANNEL_ATTRIBUTE_TYPE_CODE));
|
||||||
Map<String, String> internalAttributeLabels = toDictLabelMap(loadDictOptions(INTERNAL_ATTRIBUTE_TYPE_CODE));
|
Map<String, String> internalAttributeLabels = toDictLabelMap(loadDictOptions(INTERNAL_ATTRIBUTE_TYPE_CODE));
|
||||||
|
|
||||||
for (ChannelExpansionItemDTO item : channelItems) {
|
for (ChannelExpansionItemDTO item : channelItems) {
|
||||||
|
item.setChannelIndustry(formatMultiValueDisplay(item.getChannelIndustryCode(), industryLabels));
|
||||||
|
item.setCertificationLevel(formatSingleValueDisplay(item.getCertificationLevel(), certificationLevelLabels));
|
||||||
item.setChannelAttribute(formatMultiValueDisplay(item.getChannelAttributeCode(), channelAttributeLabels));
|
item.setChannelAttribute(formatMultiValueDisplay(item.getChannelAttributeCode(), channelAttributeLabels));
|
||||||
item.setInternalAttribute(formatMultiValueDisplay(item.getInternalAttributeCode(), internalAttributeLabels));
|
item.setInternalAttribute(formatMultiValueDisplay(item.getInternalAttributeCode(), internalAttributeLabels));
|
||||||
}
|
}
|
||||||
|
|
@ -319,6 +336,13 @@ public class ExpansionServiceImpl implements ExpansionService {
|
||||||
return uniqueValues.isEmpty() ? "无" : String.join("、", uniqueValues);
|
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) {
|
private String decodeCustomText(String rawValue) {
|
||||||
if (isBlank(rawValue)) {
|
if (isBlank(rawValue)) {
|
||||||
return "";
|
return "";
|
||||||
|
|
@ -405,9 +429,11 @@ public class ExpansionServiceImpl implements ExpansionService {
|
||||||
|
|
||||||
private void fillChannelDefaults(CreateChannelExpansionRequest request) {
|
private void fillChannelDefaults(CreateChannelExpansionRequest request) {
|
||||||
request.setChannelName(normalizeRequiredText(request.getChannelName(), "渠道名称不能为空"));
|
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.setOfficeAddress(normalizeRequiredText(request.getOfficeAddress(), "请填写办公地址"));
|
||||||
request.setChannelIndustry(normalizeRequiredText(request.getChannelIndustry(), "请填写聚焦行业"));
|
request.setChannelIndustry(normalizeRequiredText(request.getChannelIndustry(), "请选择聚焦行业"));
|
||||||
request.setAnnualRevenue(requirePositiveAmount(request.getAnnualRevenue(), "请填写年营收"));
|
request.setAnnualRevenue(requirePositiveAmount(request.getAnnualRevenue(), "请填写年营收"));
|
||||||
request.setStaffSize(requirePositiveInteger(request.getStaffSize(), "请填写人员规模"));
|
request.setStaffSize(requirePositiveInteger(request.getStaffSize(), "请填写人员规模"));
|
||||||
if (request.getContactEstablishedDate() == null) {
|
if (request.getContactEstablishedDate() == null) {
|
||||||
|
|
@ -432,9 +458,11 @@ public class ExpansionServiceImpl implements ExpansionService {
|
||||||
|
|
||||||
private void fillChannelDefaults(UpdateChannelExpansionRequest request) {
|
private void fillChannelDefaults(UpdateChannelExpansionRequest request) {
|
||||||
request.setChannelName(normalizeRequiredText(request.getChannelName(), "渠道名称不能为空"));
|
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.setOfficeAddress(normalizeRequiredText(request.getOfficeAddress(), "请填写办公地址"));
|
||||||
request.setChannelIndustry(normalizeRequiredText(request.getChannelIndustry(), "请填写聚焦行业"));
|
request.setChannelIndustry(normalizeRequiredText(request.getChannelIndustry(), "请选择聚焦行业"));
|
||||||
request.setAnnualRevenue(requirePositiveAmount(request.getAnnualRevenue(), "请填写年营收"));
|
request.setAnnualRevenue(requirePositiveAmount(request.getAnnualRevenue(), "请填写年营收"));
|
||||||
request.setStaffSize(requirePositiveInteger(request.getStaffSize(), "请填写人员规模"));
|
request.setStaffSize(requirePositiveInteger(request.getStaffSize(), "请填写人员规模"));
|
||||||
if (request.getContactEstablishedDate() == null) {
|
if (request.getContactEstablishedDate() == null) {
|
||||||
|
|
@ -534,6 +562,10 @@ public class ExpansionServiceImpl implements ExpansionService {
|
||||||
return trimmed.isEmpty() ? null : trimmed;
|
return trimmed.isEmpty() ? null : trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String normalizeOptionalText(String value) {
|
||||||
|
return trimToNull(value);
|
||||||
|
}
|
||||||
|
|
||||||
private BigDecimal requirePositiveAmount(BigDecimal value, String message) {
|
private BigDecimal requirePositiveAmount(BigDecimal value, String message) {
|
||||||
if (value == null || value.compareTo(BigDecimal.ZERO) <= 0) {
|
if (value == null || value.compareTo(BigDecimal.ZERO) <= 0) {
|
||||||
throw new BusinessException(message);
|
throw new BusinessException(message);
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,20 @@ package com.unis.crm.service.impl;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||||
import com.unis.crm.common.BusinessException;
|
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.CreateOpportunityFollowUpRequest;
|
||||||
import com.unis.crm.dto.opportunity.CreateOpportunityRequest;
|
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.OpportunityMetaDTO;
|
||||||
import com.unis.crm.dto.opportunity.OpportunityFollowUpDTO;
|
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.OpportunityItemDTO;
|
||||||
|
import com.unis.crm.dto.opportunity.OpportunityOmsPushDataDTO;
|
||||||
import com.unis.crm.dto.opportunity.OpportunityOverviewDTO;
|
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.mapper.OpportunityMapper;
|
||||||
|
import com.unis.crm.service.OmsClient;
|
||||||
import com.unis.crm.service.OpportunityService;
|
import com.unis.crm.service.OpportunityService;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
|
@ -16,25 +23,32 @@ import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class OpportunityServiceImpl implements OpportunityService {
|
public class OpportunityServiceImpl implements OpportunityService {
|
||||||
|
|
||||||
private static final String STAGE_TYPE_CODE = "sj_xmjd";
|
private static final String STAGE_TYPE_CODE = "sj_xmjd";
|
||||||
private static final String OPERATOR_TYPE_CODE = "sj_yzf";
|
private static final String OPERATOR_TYPE_CODE = "sj_yzf";
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(OpportunityServiceImpl.class);
|
||||||
|
|
||||||
private final OpportunityMapper opportunityMapper;
|
private final OpportunityMapper opportunityMapper;
|
||||||
|
private final OmsClient omsClient;
|
||||||
|
|
||||||
public OpportunityServiceImpl(OpportunityMapper opportunityMapper) {
|
public OpportunityServiceImpl(OpportunityMapper opportunityMapper, OmsClient omsClient) {
|
||||||
this.opportunityMapper = opportunityMapper;
|
this.opportunityMapper = opportunityMapper;
|
||||||
|
this.omsClient = omsClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public OpportunityMetaDTO getMeta() {
|
public OpportunityMetaDTO getMeta() {
|
||||||
return new OpportunityMetaDTO(
|
return new OpportunityMetaDTO(
|
||||||
opportunityMapper.selectDictItems(STAGE_TYPE_CODE),
|
opportunityMapper.selectDictItems(STAGE_TYPE_CODE),
|
||||||
opportunityMapper.selectDictItems(OPERATOR_TYPE_CODE));
|
opportunityMapper.selectDictItems(OPERATOR_TYPE_CODE),
|
||||||
|
opportunityMapper.selectProvinceAreaOptions());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
@ -47,6 +61,15 @@ public class OpportunityServiceImpl implements OpportunityService {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@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) {
|
public Long createOpportunity(Long userId, CreateOpportunityRequest request) {
|
||||||
fillDefaults(request);
|
fillDefaults(request);
|
||||||
Long customerId = opportunityMapper.selectOwnedCustomerIdByName(userId, request.getCustomerName().trim());
|
Long customerId = opportunityMapper.selectOwnedCustomerIdByName(userId, request.getCustomerName().trim());
|
||||||
|
|
@ -59,10 +82,13 @@ public class OpportunityServiceImpl implements OpportunityService {
|
||||||
if (request.getId() == null) {
|
if (request.getId() == null) {
|
||||||
throw new BusinessException("商机新增失败");
|
throw new BusinessException("商机新增失败");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
syncCreatedOpportunityCodeStrict(userId, request.getId());
|
||||||
return request.getId();
|
return request.getId();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@Transactional
|
||||||
public Long updateOpportunity(Long userId, Long opportunityId, CreateOpportunityRequest request) {
|
public Long updateOpportunity(Long userId, Long opportunityId, CreateOpportunityRequest request) {
|
||||||
if (opportunityId == null || opportunityId <= 0) {
|
if (opportunityId == null || opportunityId <= 0) {
|
||||||
throw new BusinessException("商机不存在");
|
throw new BusinessException("商机不存在");
|
||||||
|
|
@ -70,9 +96,6 @@ public class OpportunityServiceImpl implements OpportunityService {
|
||||||
if (opportunityMapper.countOwnedOpportunity(userId, opportunityId) <= 0) {
|
if (opportunityMapper.countOwnedOpportunity(userId, opportunityId) <= 0) {
|
||||||
throw new BusinessException("无权编辑该商机");
|
throw new BusinessException("无权编辑该商机");
|
||||||
}
|
}
|
||||||
if (Boolean.TRUE.equals(opportunityMapper.selectPushedToOms(userId, opportunityId))) {
|
|
||||||
throw new BusinessException("该商机已推送 OMS,不能再编辑");
|
|
||||||
}
|
|
||||||
|
|
||||||
fillDefaults(request);
|
fillDefaults(request);
|
||||||
Long customerId = opportunityMapper.selectOwnedCustomerIdByName(userId, request.getCustomerName().trim());
|
Long customerId = opportunityMapper.selectOwnedCustomerIdByName(userId, request.getCustomerName().trim());
|
||||||
|
|
@ -85,11 +108,26 @@ public class OpportunityServiceImpl implements OpportunityService {
|
||||||
if (updated <= 0) {
|
if (updated <= 0) {
|
||||||
throw new BusinessException("商机更新失败");
|
throw new BusinessException("商机更新失败");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
syncUpdatedOpportunityCodeBestEffort(userId, opportunityId);
|
||||||
return opportunityId;
|
return opportunityId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@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) {
|
if (opportunityId == null || opportunityId <= 0) {
|
||||||
throw new BusinessException("商机不存在");
|
throw new BusinessException("商机不存在");
|
||||||
}
|
}
|
||||||
|
|
@ -99,7 +137,37 @@ public class OpportunityServiceImpl implements OpportunityService {
|
||||||
if (Boolean.TRUE.equals(opportunityMapper.selectPushedToOms(userId, opportunityId))) {
|
if (Boolean.TRUE.equals(opportunityMapper.selectPushedToOms(userId, opportunityId))) {
|
||||||
throw new BusinessException("该商机已推送 OMS,请勿重复操作");
|
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) {
|
if (updated <= 0) {
|
||||||
throw new BusinessException("推送 OMS 失败");
|
throw new BusinessException("推送 OMS 失败");
|
||||||
}
|
}
|
||||||
|
|
@ -229,16 +297,14 @@ public class OpportunityServiceImpl implements OpportunityService {
|
||||||
private void fillDefaults(CreateOpportunityRequest request) {
|
private void fillDefaults(CreateOpportunityRequest request) {
|
||||||
request.setCustomerName(normalizeRequiredText(request.getCustomerName(), "最终客户不能为空"));
|
request.setCustomerName(normalizeRequiredText(request.getCustomerName(), "最终客户不能为空"));
|
||||||
request.setOpportunityName(normalizeRequiredText(request.getOpportunityName(), "项目名称不能为空"));
|
request.setOpportunityName(normalizeRequiredText(request.getOpportunityName(), "项目名称不能为空"));
|
||||||
request.setProjectLocation(normalizeRequiredText(request.getProjectLocation(), "请填写项目地"));
|
request.setProjectLocation(normalizeRequiredText(request.getProjectLocation(), "请选择项目地"));
|
||||||
request.setOperatorName(normalizeRequiredText(request.getOperatorName(), "请选择运作方"));
|
request.setOperatorName(normalizeRequiredText(request.getOperatorName(), "请选择运作方"));
|
||||||
request.setAmount(requirePositiveAmount(request.getAmount(), "请填写预计金额"));
|
request.setAmount(requirePositiveAmount(request.getAmount(), "请填写预计金额"));
|
||||||
request.setDescription(normalizeOptionalText(request.getDescription()));
|
request.setDescription(normalizeOptionalText(request.getDescription()));
|
||||||
if (request.getExpectedCloseDate() == null) {
|
if (request.getExpectedCloseDate() == null) {
|
||||||
throw new BusinessException("请选择预计下单时间");
|
throw new BusinessException("请选择预计下单时间");
|
||||||
}
|
}
|
||||||
if (request.getConfidencePct() == null || request.getConfidencePct() <= 0) {
|
request.setConfidencePct(normalizeConfidenceGrade(request.getConfidencePct(), "请选择项目把握度"));
|
||||||
throw new BusinessException("请选择项目把握度");
|
|
||||||
}
|
|
||||||
if (isBlank(request.getStage())) {
|
if (isBlank(request.getStage())) {
|
||||||
throw new BusinessException("请选择项目阶段");
|
throw new BusinessException("请选择项目阶段");
|
||||||
}
|
}
|
||||||
|
|
@ -253,16 +319,100 @@ public class OpportunityServiceImpl implements OpportunityService {
|
||||||
if (isBlank(request.getSource())) {
|
if (isBlank(request.getSource())) {
|
||||||
request.setSource("主动开发");
|
request.setSource("主动开发");
|
||||||
}
|
}
|
||||||
if (request.getPushedToOms() == null) {
|
|
||||||
request.setPushedToOms(Boolean.FALSE);
|
|
||||||
}
|
|
||||||
if (request.getConfidencePct() == null) {
|
|
||||||
request.setConfidencePct(50);
|
|
||||||
}
|
|
||||||
request.setCompetitorName(normalizeRequiredText(request.getCompetitorName(), "请选择竞争对手"));
|
request.setCompetitorName(normalizeRequiredText(request.getCompetitorName(), "请选择竞争对手"));
|
||||||
validateOperatorRelations(request.getOperatorName(), request.getSalesExpansionId(), request.getChannelExpansionId());
|
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) {
|
private String normalizeKeyword(String keyword) {
|
||||||
if (keyword == null) {
|
if (keyword == null) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -305,6 +455,43 @@ public class OpportunityServiceImpl implements OpportunityService {
|
||||||
return trimmed.isEmpty() ? null : trimmed;
|
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) {
|
private BigDecimal requirePositiveAmount(BigDecimal value, String message) {
|
||||||
if (value == null || value.compareTo(BigDecimal.ZERO) <= 0) {
|
if (value == null || value.compareTo(BigDecimal.ZERO) <= 0) {
|
||||||
throw new BusinessException(message);
|
throw new BusinessException(message);
|
||||||
|
|
@ -324,6 +511,150 @@ public class OpportunityServiceImpl implements OpportunityService {
|
||||||
return null;
|
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) {
|
private String toStageCode(String value) {
|
||||||
return switch (value) {
|
return switch (value) {
|
||||||
case "初步沟通", "initial_contact" -> "initial_contact";
|
case "初步沟通", "initial_contact" -> "initial_contact";
|
||||||
|
|
@ -358,7 +689,9 @@ public class OpportunityServiceImpl implements OpportunityService {
|
||||||
private void validateOperatorRelations(String operatorName, Long salesExpansionId, Long channelExpansionId) {
|
private void validateOperatorRelations(String operatorName, Long salesExpansionId, Long channelExpansionId) {
|
||||||
String normalizedOperator = normalizeOperatorToken(operatorName);
|
String normalizedOperator = normalizeOperatorToken(operatorName);
|
||||||
boolean hasH3c = normalizedOperator.contains("新华三") || normalizedOperator.contains("h3c");
|
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 (hasH3c && hasChannel) {
|
||||||
if (salesExpansionId == null && channelExpansionId == null) {
|
if (salesExpansionId == null && channelExpansionId == null) {
|
||||||
|
|
@ -389,6 +722,91 @@ public class OpportunityServiceImpl implements OpportunityService {
|
||||||
.replace('+', '+');
|
.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) {
|
private record CommunicationRecord(String time, String content) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ unisbase:
|
||||||
- /api/wecom/sso/**
|
- /api/wecom/sso/**
|
||||||
internal-auth:
|
internal-auth:
|
||||||
enabled: true
|
enabled: true
|
||||||
secret: change-me-internal-secret
|
secret: f0eb247f84db4e328fb27ce8ff6e7be96e73a53a7e9c4793395ad10d999e0d77
|
||||||
header-name: X-Internal-Secret
|
header-name: X-Internal-Secret
|
||||||
app:
|
app:
|
||||||
upload-path: /Users/kangwenjing/Downloads/crm/uploads
|
upload-path: /Users/kangwenjing/Downloads/crm/uploads
|
||||||
|
|
@ -67,3 +67,14 @@ unisbase:
|
||||||
state-ttl-seconds: 300
|
state-ttl-seconds: 300
|
||||||
ticket-ttl-seconds: 180
|
ticket-ttl-seconds: 180
|
||||||
access-token-safety-seconds: 120
|
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/**
|
- /api/wecom/sso/**
|
||||||
internal-auth:
|
internal-auth:
|
||||||
enabled: true
|
enabled: true
|
||||||
secret: change-me-internal-secret
|
secret: f0eb247f84db4e328fb27ce8ff6e7be96e73a53a7e9c4793395ad10d999e0d77
|
||||||
header-name: X-Internal-Secret
|
header-name: X-Internal-Secret
|
||||||
app:
|
app:
|
||||||
upload-path: /Users/kangwenjing/Downloads/crm/uploads
|
upload-path: /Users/kangwenjing/Downloads/crm/uploads
|
||||||
|
|
@ -72,3 +72,14 @@ unisbase:
|
||||||
state-ttl-seconds: 300
|
state-ttl-seconds: 300
|
||||||
ticket-ttl-seconds: 180
|
ticket-ttl-seconds: 180
|
||||||
access-token-safety-seconds: 120
|
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
|
order by sort_order asc nulls last, dict_item_id asc
|
||||||
</select>
|
</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 id="selectNextChannelCode" resultType="java.lang.String">
|
||||||
select 'QD-' || to_char(current_date, 'YYYYMMDD') || '-' || lpad((
|
select 'QD-' || to_char(current_date, 'YYYYMMDD') || '-' || lpad((
|
||||||
coalesce((
|
coalesce((
|
||||||
|
|
@ -93,9 +119,14 @@
|
||||||
'channel' as type,
|
'channel' as type,
|
||||||
coalesce(c.channel_code, '') as channelCode,
|
coalesce(c.channel_code, '') as channelCode,
|
||||||
c.channel_name as name,
|
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.office_address, '无') as officeAddress,
|
||||||
|
coalesce(c.channel_industry, c.industry, '') as channelIndustryCode,
|
||||||
coalesce(c.channel_industry, c.industry, '无') as channelIndustry,
|
coalesce(c.channel_industry, c.industry, '无') as channelIndustry,
|
||||||
|
coalesce(c.certification_level, '无') as certificationLevel,
|
||||||
coalesce(cast(c.annual_revenue as varchar), '') as annualRevenue,
|
coalesce(cast(c.annual_revenue as varchar), '') as annualRevenue,
|
||||||
case
|
case
|
||||||
when c.annual_revenue is null then '无'
|
when c.annual_revenue is null then '无'
|
||||||
|
|
@ -143,6 +174,12 @@
|
||||||
order by cc.sort_order asc nulls last, cc.id asc
|
order by cc.sort_order asc nulls last, cc.id asc
|
||||||
limit 1
|
limit 1
|
||||||
) primary_contact on true
|
) 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
|
left join sys_dict_item channel_attribute_dict
|
||||||
on channel_attribute_dict.type_code = 'tz_qdsx'
|
on channel_attribute_dict.type_code = 'tz_qdsx'
|
||||||
and channel_attribute_dict.item_value = c.channel_attribute
|
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_code, '') ilike concat('%', #{keyword}, '%')
|
||||||
or coalesce(c.channel_industry, c.industry, '') ilike concat('%', #{keyword}, '%')
|
or coalesce(c.channel_industry, c.industry, '') ilike concat('%', #{keyword}, '%')
|
||||||
or coalesce(c.province, '') 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 (
|
or exists (
|
||||||
select 1
|
select 1
|
||||||
from crm_channel_expansion_contact cc
|
from crm_channel_expansion_contact cc
|
||||||
|
|
@ -309,9 +359,11 @@
|
||||||
insert into crm_channel_expansion (
|
insert into crm_channel_expansion (
|
||||||
channel_code,
|
channel_code,
|
||||||
province,
|
province,
|
||||||
|
city,
|
||||||
channel_name,
|
channel_name,
|
||||||
office_address,
|
office_address,
|
||||||
channel_industry,
|
channel_industry,
|
||||||
|
certification_level,
|
||||||
annual_revenue,
|
annual_revenue,
|
||||||
staff_size,
|
staff_size,
|
||||||
contact_established_date,
|
contact_established_date,
|
||||||
|
|
@ -336,9 +388,11 @@
|
||||||
), 0) + 1
|
), 0) + 1
|
||||||
)::text, 3, '0'),
|
)::text, 3, '0'),
|
||||||
#{request.province},
|
#{request.province},
|
||||||
|
#{request.city},
|
||||||
#{request.channelName},
|
#{request.channelName},
|
||||||
#{request.officeAddress},
|
#{request.officeAddress},
|
||||||
#{request.channelIndustry},
|
#{request.channelIndustry},
|
||||||
|
#{request.certificationLevel},
|
||||||
#{request.annualRevenue},
|
#{request.annualRevenue},
|
||||||
#{request.staffSize},
|
#{request.staffSize},
|
||||||
#{request.contactEstablishedDate},
|
#{request.contactEstablishedDate},
|
||||||
|
|
@ -421,8 +475,10 @@
|
||||||
update crm_channel_expansion c
|
update crm_channel_expansion c
|
||||||
set channel_name = #{request.channelName},
|
set channel_name = #{request.channelName},
|
||||||
province = #{request.province},
|
province = #{request.province},
|
||||||
|
city = #{request.city},
|
||||||
office_address = #{request.officeAddress},
|
office_address = #{request.officeAddress},
|
||||||
channel_industry = #{request.channelIndustry},
|
channel_industry = #{request.channelIndustry},
|
||||||
|
certification_level = #{request.certificationLevel},
|
||||||
annual_revenue = #{request.annualRevenue},
|
annual_revenue = #{request.annualRevenue},
|
||||||
staff_size = #{request.staffSize},
|
staff_size = #{request.staffSize},
|
||||||
contact_established_date = #{request.contactEstablishedDate},
|
contact_established_date = #{request.contactEstablishedDate},
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,15 @@
|
||||||
order by sort_order asc nulls last, dict_item_id asc
|
order by sort_order asc nulls last, dict_item_id asc
|
||||||
</select>
|
</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 id="selectDictLabel" resultType="java.lang.String">
|
||||||
select item_label
|
select item_label
|
||||||
from sys_dict_item
|
from sys_dict_item
|
||||||
|
|
@ -61,6 +70,8 @@
|
||||||
coalesce(se.candidate_name, '') as salesExpansionName,
|
coalesce(se.candidate_name, '') as salesExpansionName,
|
||||||
o.channel_expansion_id as channelExpansionId,
|
o.channel_expansion_id as channelExpansionId,
|
||||||
coalesce(ce.channel_name, '') as channelExpansionName,
|
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(o.competitor_name, '') as competitorName,
|
||||||
coalesce((
|
coalesce((
|
||||||
select
|
select
|
||||||
|
|
@ -226,8 +237,8 @@
|
||||||
#{request.salesExpansionId},
|
#{request.salesExpansionId},
|
||||||
#{request.channelExpansionId},
|
#{request.channelExpansionId},
|
||||||
#{request.competitorName},
|
#{request.competitorName},
|
||||||
#{request.pushedToOms},
|
false,
|
||||||
case when #{request.pushedToOms} then now() else null end,
|
null,
|
||||||
#{request.description},
|
#{request.description},
|
||||||
case
|
case
|
||||||
when #{request.stage} = 'won' then 'won'
|
when #{request.stage} = 'won' then 'won'
|
||||||
|
|
@ -252,6 +263,79 @@
|
||||||
limit 1
|
limit 1
|
||||||
</select>
|
</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 id="updateOpportunity">
|
||||||
update crm_opportunity o
|
update crm_opportunity o
|
||||||
set opportunity_name = #{request.opportunityName},
|
set opportunity_name = #{request.opportunityName},
|
||||||
|
|
@ -268,11 +352,6 @@
|
||||||
sales_expansion_id = #{request.salesExpansionId},
|
sales_expansion_id = #{request.salesExpansionId},
|
||||||
channel_expansion_id = #{request.channelExpansionId},
|
channel_expansion_id = #{request.channelExpansionId},
|
||||||
competitor_name = #{request.competitorName},
|
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},
|
description = #{request.description},
|
||||||
status = case
|
status = case
|
||||||
when #{request.stage} = 'won' then 'won'
|
when #{request.stage} = 'won' then 'won'
|
||||||
|
|
@ -283,10 +362,95 @@
|
||||||
where o.id = #{opportunityId}
|
where o.id = #{opportunityId}
|
||||||
</update>
|
</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
|
update crm_opportunity o
|
||||||
set pushed_to_oms = true,
|
set pushed_to_oms = true,
|
||||||
oms_push_time = coalesce(oms_push_time, now()),
|
oms_push_time = coalesce(oms_push_time, now()),
|
||||||
|
opportunity_code = #{opportunityCode},
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
where o.id = #{opportunityId}
|
where o.id = #{opportunityId}
|
||||||
and coalesce(pushed_to_oms, false) = false
|
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;
|
sheetTitle?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
searchable?: boolean;
|
||||||
|
searchPlaceholder?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AdaptiveSelectSingleProps = AdaptiveSelectBaseProps & {
|
type AdaptiveSelectSingleProps = AdaptiveSelectBaseProps & {
|
||||||
|
|
@ -66,11 +68,14 @@ export function AdaptiveSelect({
|
||||||
sheetTitle,
|
sheetTitle,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
className,
|
className,
|
||||||
|
searchable = false,
|
||||||
|
searchPlaceholder = "请输入关键字搜索",
|
||||||
value,
|
value,
|
||||||
multiple = false,
|
multiple = false,
|
||||||
onChange,
|
onChange,
|
||||||
}: AdaptiveSelectProps) {
|
}: AdaptiveSelectProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [searchKeyword, setSearchKeyword] = useState("");
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const isMobile = useIsMobileViewport();
|
const isMobile = useIsMobileViewport();
|
||||||
const selectedValues = multiple
|
const selectedValues = multiple
|
||||||
|
|
@ -82,6 +87,14 @@ export function AdaptiveSelect({
|
||||||
.filter((label): label is string => Boolean(label))
|
.filter((label): label is string => Boolean(label))
|
||||||
.join("、")
|
.join("、")
|
||||||
: placeholder;
|
: 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(() => {
|
useEffect(() => {
|
||||||
if (!open || isMobile) {
|
if (!open || isMobile) {
|
||||||
|
|
@ -120,6 +133,12 @@ export function AdaptiveSelect({
|
||||||
};
|
};
|
||||||
}, [isMobile, open]);
|
}, [isMobile, open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open && searchKeyword) {
|
||||||
|
setSearchKeyword("");
|
||||||
|
}
|
||||||
|
}, [open, searchKeyword]);
|
||||||
|
|
||||||
const handleSelect = (nextValue: string) => {
|
const handleSelect = (nextValue: string) => {
|
||||||
if (multiple) {
|
if (multiple) {
|
||||||
const currentValues = Array.isArray(value) ? value : [];
|
const currentValues = Array.isArray(value) ? value : [];
|
||||||
|
|
@ -187,7 +206,24 @@ export function AdaptiveSelect({
|
||||||
exit={{ opacity: 0, y: 8 }}
|
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"
|
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>
|
</motion.div>
|
||||||
) : null}
|
) : null}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
@ -223,7 +259,20 @@ export function AdaptiveSelect({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-[60vh] space-y-2 overflow-y-auto px-4 py-4 pb-[calc(1rem+env(safe-area-inset-bottom))]">
|
<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 ? (
|
{multiple ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -250,7 +250,7 @@ export interface OpportunityItem {
|
||||||
operatorName?: string;
|
operatorName?: string;
|
||||||
amount?: number;
|
amount?: number;
|
||||||
date?: string;
|
date?: string;
|
||||||
confidence?: number;
|
confidence?: string;
|
||||||
stageCode?: string;
|
stageCode?: string;
|
||||||
stage?: string;
|
stage?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
|
|
@ -262,6 +262,8 @@ export interface OpportunityItem {
|
||||||
salesExpansionName?: string;
|
salesExpansionName?: string;
|
||||||
channelExpansionId?: number;
|
channelExpansionId?: number;
|
||||||
channelExpansionName?: string;
|
channelExpansionName?: string;
|
||||||
|
preSalesId?: number;
|
||||||
|
preSalesName?: string;
|
||||||
competitorName?: string;
|
competitorName?: string;
|
||||||
latestProgress?: string;
|
latestProgress?: string;
|
||||||
nextPlan?: string;
|
nextPlan?: string;
|
||||||
|
|
@ -281,6 +283,13 @@ export interface OpportunityDictOption {
|
||||||
export interface OpportunityMeta {
|
export interface OpportunityMeta {
|
||||||
stageOptions?: OpportunityDictOption[];
|
stageOptions?: OpportunityDictOption[];
|
||||||
operatorOptions?: OpportunityDictOption[];
|
operatorOptions?: OpportunityDictOption[];
|
||||||
|
projectLocationOptions?: OpportunityDictOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OmsPreSalesOption {
|
||||||
|
userId: number;
|
||||||
|
loginName?: string;
|
||||||
|
userName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateOpportunityPayload {
|
export interface CreateOpportunityPayload {
|
||||||
|
|
@ -290,7 +299,7 @@ export interface CreateOpportunityPayload {
|
||||||
operatorName?: string;
|
operatorName?: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
expectedCloseDate: string;
|
expectedCloseDate: string;
|
||||||
confidencePct: number;
|
confidencePct: string;
|
||||||
stage?: string;
|
stage?: string;
|
||||||
opportunityType?: string;
|
opportunityType?: string;
|
||||||
productType?: string;
|
productType?: string;
|
||||||
|
|
@ -302,6 +311,11 @@ export interface CreateOpportunityPayload {
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PushOpportunityToOmsPayload {
|
||||||
|
preSalesId?: number;
|
||||||
|
preSalesName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateOpportunityFollowUpPayload {
|
export interface CreateOpportunityFollowUpPayload {
|
||||||
followUpType: string;
|
followUpType: string;
|
||||||
content: string;
|
content: string;
|
||||||
|
|
@ -362,9 +376,14 @@ export interface ChannelExpansionItem {
|
||||||
type: "channel";
|
type: "channel";
|
||||||
channelCode?: string;
|
channelCode?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
provinceCode?: string;
|
||||||
province?: string;
|
province?: string;
|
||||||
|
cityCode?: string;
|
||||||
|
city?: string;
|
||||||
officeAddress?: string;
|
officeAddress?: string;
|
||||||
|
channelIndustryCode?: string;
|
||||||
channelIndustry?: string;
|
channelIndustry?: string;
|
||||||
|
certificationLevel?: string;
|
||||||
annualRevenue?: string;
|
annualRevenue?: string;
|
||||||
revenue?: string;
|
revenue?: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
|
|
@ -416,6 +435,8 @@ export interface ExpansionDictOption {
|
||||||
export interface ExpansionMeta {
|
export interface ExpansionMeta {
|
||||||
officeOptions?: ExpansionDictOption[];
|
officeOptions?: ExpansionDictOption[];
|
||||||
industryOptions?: ExpansionDictOption[];
|
industryOptions?: ExpansionDictOption[];
|
||||||
|
provinceOptions?: ExpansionDictOption[];
|
||||||
|
certificationLevelOptions?: ExpansionDictOption[];
|
||||||
channelAttributeOptions?: ExpansionDictOption[];
|
channelAttributeOptions?: ExpansionDictOption[];
|
||||||
internalAttributeOptions?: ExpansionDictOption[];
|
internalAttributeOptions?: ExpansionDictOption[];
|
||||||
nextChannelCode?: string;
|
nextChannelCode?: string;
|
||||||
|
|
@ -442,9 +463,11 @@ export interface CreateSalesExpansionPayload {
|
||||||
export interface CreateChannelExpansionPayload {
|
export interface CreateChannelExpansionPayload {
|
||||||
channelCode?: string;
|
channelCode?: string;
|
||||||
officeAddress?: string;
|
officeAddress?: string;
|
||||||
channelIndustry?: string;
|
channelIndustry?: string[];
|
||||||
channelName: string;
|
channelName: string;
|
||||||
province?: string;
|
province?: string;
|
||||||
|
city?: string;
|
||||||
|
certificationLevel?: string;
|
||||||
annualRevenue?: number;
|
annualRevenue?: number;
|
||||||
staffSize?: number;
|
staffSize?: number;
|
||||||
contactEstablishedDate?: string;
|
contactEstablishedDate?: string;
|
||||||
|
|
@ -507,6 +530,9 @@ function serializeChannelExpansionPayload(payload: CreateChannelExpansionPayload
|
||||||
const { channelAttributeCustom, ...rest } = payload;
|
const { channelAttributeCustom, ...rest } = payload;
|
||||||
return {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
|
city: payload.city,
|
||||||
|
certificationLevel: payload.certificationLevel,
|
||||||
|
channelIndustry: encodeExpansionMultiValue(payload.channelIndustry),
|
||||||
channelAttribute: encodeExpansionMultiValue(payload.channelAttribute, channelAttributeCustom),
|
channelAttribute: encodeExpansionMultiValue(payload.channelAttribute, channelAttributeCustom),
|
||||||
internalAttribute: encodeExpansionMultiValue(payload.internalAttribute),
|
internalAttribute: encodeExpansionMultiValue(payload.internalAttribute),
|
||||||
};
|
};
|
||||||
|
|
@ -819,6 +845,10 @@ export async function getOpportunityMeta() {
|
||||||
return request<OpportunityMeta>("/api/opportunities/meta", undefined, true);
|
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) {
|
export async function createOpportunity(payload: CreateOpportunityPayload) {
|
||||||
return request<number>("/api/opportunities", {
|
return request<number>("/api/opportunities", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -833,9 +863,10 @@ export async function updateOpportunity(opportunityId: number, payload: CreateOp
|
||||||
}, true);
|
}, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function pushOpportunityToOms(opportunityId: number) {
|
export async function pushOpportunityToOms(opportunityId: number, payload?: PushOpportunityToOmsPayload) {
|
||||||
return request<number>(`/api/opportunities/${opportunityId}/push-oms`, {
|
return request<number>(`/api/opportunities/${opportunityId}/push-oms`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload ?? {}),
|
||||||
}, true);
|
}, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -859,6 +890,11 @@ export async function getExpansionMeta() {
|
||||||
return request<ExpansionMeta>("/api/expansion/meta", undefined, true);
|
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) {
|
export async function createSalesExpansion(payload: CreateSalesExpansionPayload) {
|
||||||
return request<number>("/api/expansion/sales", {
|
return request<number>("/api/expansion/sales", {
|
||||||
method: "POST",
|
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 { Search, Plus, MapPin, Building2, User, Phone, X, Clock, FileText, Calendar, ChevronRight } from "lucide-react";
|
||||||
import { motion, AnimatePresence } from "motion/react";
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
createChannelExpansion,
|
createChannelExpansion,
|
||||||
createSalesExpansion,
|
createSalesExpansion,
|
||||||
decodeExpansionMultiValue,
|
decodeExpansionMultiValue,
|
||||||
|
getExpansionCityOptions,
|
||||||
getExpansionMeta,
|
getExpansionMeta,
|
||||||
getExpansionOverview,
|
getExpansionOverview,
|
||||||
updateChannelExpansion,
|
updateChannelExpansion,
|
||||||
|
|
@ -38,8 +39,10 @@ type SalesCreateField =
|
||||||
type ChannelField =
|
type ChannelField =
|
||||||
| "channelName"
|
| "channelName"
|
||||||
| "province"
|
| "province"
|
||||||
|
| "city"
|
||||||
| "officeAddress"
|
| "officeAddress"
|
||||||
| "channelIndustry"
|
| "channelIndustry"
|
||||||
|
| "certificationLevel"
|
||||||
| "annualRevenue"
|
| "annualRevenue"
|
||||||
| "staffSize"
|
| "staffSize"
|
||||||
| "contactEstablishedDate"
|
| "contactEstablishedDate"
|
||||||
|
|
@ -74,8 +77,10 @@ const defaultChannelForm: CreateChannelExpansionPayload = {
|
||||||
channelCode: "",
|
channelCode: "",
|
||||||
channelName: "",
|
channelName: "",
|
||||||
province: "",
|
province: "",
|
||||||
|
city: "",
|
||||||
officeAddress: "",
|
officeAddress: "",
|
||||||
channelIndustry: "",
|
channelIndustry: [],
|
||||||
|
certificationLevel: "",
|
||||||
contactEstablishedDate: "",
|
contactEstablishedDate: "",
|
||||||
intentLevel: "medium",
|
intentLevel: "medium",
|
||||||
hasDesktopExp: false,
|
hasDesktopExp: false,
|
||||||
|
|
@ -148,13 +153,19 @@ function validateChannelForm(form: CreateChannelExpansionPayload, channelOtherOp
|
||||||
errors.channelName = "请填写渠道名称";
|
errors.channelName = "请填写渠道名称";
|
||||||
}
|
}
|
||||||
if (!form.province?.trim()) {
|
if (!form.province?.trim()) {
|
||||||
errors.province = "请填写省份";
|
errors.province = "请选择省份";
|
||||||
|
}
|
||||||
|
if (!form.city?.trim()) {
|
||||||
|
errors.city = "请选择市";
|
||||||
}
|
}
|
||||||
if (!form.officeAddress?.trim()) {
|
if (!form.officeAddress?.trim()) {
|
||||||
errors.officeAddress = "请填写办公地址";
|
errors.officeAddress = "请填写办公地址";
|
||||||
}
|
}
|
||||||
if (!form.channelIndustry?.trim()) {
|
if (!form.certificationLevel?.trim()) {
|
||||||
errors.channelIndustry = "请填写聚焦行业";
|
errors.certificationLevel = "请选择认证级别";
|
||||||
|
}
|
||||||
|
if ((form.channelIndustry?.length ?? 0) <= 0) {
|
||||||
|
errors.channelIndustry = "请选择聚焦行业";
|
||||||
}
|
}
|
||||||
if (!form.annualRevenue || form.annualRevenue <= 0) {
|
if (!form.annualRevenue || form.annualRevenue <= 0) {
|
||||||
errors.annualRevenue = "请填写年营收";
|
errors.annualRevenue = "请填写年营收";
|
||||||
|
|
@ -223,9 +234,11 @@ function normalizeChannelPayload(payload: CreateChannelExpansionPayload): Create
|
||||||
return {
|
return {
|
||||||
channelCode: normalizeOptionalText(payload.channelCode),
|
channelCode: normalizeOptionalText(payload.channelCode),
|
||||||
officeAddress: normalizeOptionalText(payload.officeAddress),
|
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(),
|
channelName: payload.channelName.trim(),
|
||||||
province: normalizeOptionalText(payload.province),
|
province: normalizeOptionalText(payload.province),
|
||||||
|
city: normalizeOptionalText(payload.city),
|
||||||
|
certificationLevel: normalizeOptionalText(payload.certificationLevel),
|
||||||
annualRevenue: payload.annualRevenue || undefined,
|
annualRevenue: payload.annualRevenue || undefined,
|
||||||
staffSize: payload.staffSize || undefined,
|
staffSize: payload.staffSize || undefined,
|
||||||
contactEstablishedDate: normalizeOptionalText(payload.contactEstablishedDate),
|
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({
|
function ModalShell({
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
|
|
@ -337,6 +365,10 @@ export default function Expansion() {
|
||||||
const [channelData, setChannelData] = useState<ChannelExpansionItem[]>([]);
|
const [channelData, setChannelData] = useState<ChannelExpansionItem[]>([]);
|
||||||
const [officeOptions, setOfficeOptions] = useState<ExpansionDictOption[]>([]);
|
const [officeOptions, setOfficeOptions] = useState<ExpansionDictOption[]>([]);
|
||||||
const [industryOptions, setIndustryOptions] = 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 [channelAttributeOptions, setChannelAttributeOptions] = useState<ExpansionDictOption[]>([]);
|
||||||
const [internalAttributeOptions, setInternalAttributeOptions] = useState<ExpansionDictOption[]>([]);
|
const [internalAttributeOptions, setInternalAttributeOptions] = useState<ExpansionDictOption[]>([]);
|
||||||
const [nextChannelCode, setNextChannelCode] = useState("");
|
const [nextChannelCode, setNextChannelCode] = useState("");
|
||||||
|
|
@ -363,6 +395,36 @@ export default function Expansion() {
|
||||||
const [editChannelForm, setEditChannelForm] = useState<CreateChannelExpansionPayload>(defaultChannelForm);
|
const [editChannelForm, setEditChannelForm] = useState<CreateChannelExpansionPayload>(defaultChannelForm);
|
||||||
const hasForegroundModal = createOpen || editOpen;
|
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(() => {
|
useEffect(() => {
|
||||||
const requestedTab = (location.state as { tab?: ExpansionTab } | null)?.tab;
|
const requestedTab = (location.state as { tab?: ExpansionTab } | null)?.tab;
|
||||||
if (requestedTab === "sales" || requestedTab === "channel") {
|
if (requestedTab === "sales" || requestedTab === "channel") {
|
||||||
|
|
@ -373,12 +435,14 @@ export default function Expansion() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
async function loadMeta() {
|
async function loadMetaOptions() {
|
||||||
try {
|
try {
|
||||||
const data = await getExpansionMeta();
|
const data = await loadMeta();
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setOfficeOptions(data.officeOptions ?? []);
|
setOfficeOptions(data.officeOptions ?? []);
|
||||||
setIndustryOptions(data.industryOptions ?? []);
|
setIndustryOptions(data.industryOptions ?? []);
|
||||||
|
setProvinceOptions(data.provinceOptions ?? []);
|
||||||
|
setCertificationLevelOptions(data.certificationLevelOptions ?? []);
|
||||||
setChannelAttributeOptions(data.channelAttributeOptions ?? []);
|
setChannelAttributeOptions(data.channelAttributeOptions ?? []);
|
||||||
setInternalAttributeOptions(data.internalAttributeOptions ?? []);
|
setInternalAttributeOptions(data.internalAttributeOptions ?? []);
|
||||||
setNextChannelCode(data.nextChannelCode ?? "");
|
setNextChannelCode(data.nextChannelCode ?? "");
|
||||||
|
|
@ -387,6 +451,8 @@ export default function Expansion() {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setOfficeOptions([]);
|
setOfficeOptions([]);
|
||||||
setIndustryOptions([]);
|
setIndustryOptions([]);
|
||||||
|
setProvinceOptions([]);
|
||||||
|
setCertificationLevelOptions([]);
|
||||||
setChannelAttributeOptions([]);
|
setChannelAttributeOptions([]);
|
||||||
setInternalAttributeOptions([]);
|
setInternalAttributeOptions([]);
|
||||||
setNextChannelCode("");
|
setNextChannelCode("");
|
||||||
|
|
@ -394,12 +460,12 @@ export default function Expansion() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void loadMeta();
|
void loadMetaOptions();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, []);
|
}, [loadMeta]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
@ -543,6 +609,7 @@ export default function Expansion() {
|
||||||
setInvalidCreateChannelContactRows([]);
|
setInvalidCreateChannelContactRows([]);
|
||||||
setSalesForm(defaultSalesForm);
|
setSalesForm(defaultSalesForm);
|
||||||
setChannelForm(defaultChannelForm);
|
setChannelForm(defaultChannelForm);
|
||||||
|
setCreateCityOptions([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetEditState = () => {
|
const resetEditState = () => {
|
||||||
|
|
@ -553,22 +620,35 @@ export default function Expansion() {
|
||||||
setInvalidEditChannelContactRows([]);
|
setInvalidEditChannelContactRows([]);
|
||||||
setEditSalesForm(defaultSalesForm);
|
setEditSalesForm(defaultSalesForm);
|
||||||
setEditChannelForm(defaultChannelForm);
|
setEditChannelForm(defaultChannelForm);
|
||||||
|
setEditCityOptions([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenCreate = () => {
|
const handleOpenCreate = async () => {
|
||||||
setCreateError("");
|
setCreateError("");
|
||||||
setSalesCreateFieldErrors({});
|
setSalesCreateFieldErrors({});
|
||||||
setChannelCreateFieldErrors({});
|
setChannelCreateFieldErrors({});
|
||||||
setInvalidCreateChannelContactRows([]);
|
setInvalidCreateChannelContactRows([]);
|
||||||
|
try {
|
||||||
|
await loadMeta();
|
||||||
|
} catch {}
|
||||||
|
setCreateCityOptions([]);
|
||||||
setCreateOpen(true);
|
setCreateOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenEdit = () => {
|
const handleOpenEdit = async () => {
|
||||||
if (!selectedItem) {
|
if (!selectedItem) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setEditError("");
|
setEditError("");
|
||||||
|
let latestIndustryOptions = industryOptions;
|
||||||
|
let latestCertificationLevelOptions = certificationLevelOptions;
|
||||||
|
try {
|
||||||
|
const meta = await loadMeta();
|
||||||
|
latestIndustryOptions = meta.industryOptions ?? [];
|
||||||
|
latestCertificationLevelOptions = meta.certificationLevelOptions ?? [];
|
||||||
|
} catch {}
|
||||||
|
|
||||||
if (selectedItem.type === "sales") {
|
if (selectedItem.type === "sales") {
|
||||||
setSalesEditFieldErrors({});
|
setSalesEditFieldErrors({});
|
||||||
setEditSalesForm({
|
setEditSalesForm({
|
||||||
|
|
@ -586,14 +666,24 @@ export default function Expansion() {
|
||||||
} else {
|
} else {
|
||||||
const parsedChannelAttributes = decodeExpansionMultiValue(selectedItem.channelAttributeCode);
|
const parsedChannelAttributes = decodeExpansionMultiValue(selectedItem.channelAttributeCode);
|
||||||
const parsedInternalAttributes = decodeExpansionMultiValue(selectedItem.internalAttributeCode);
|
const parsedInternalAttributes = decodeExpansionMultiValue(selectedItem.internalAttributeCode);
|
||||||
|
const normalizedProvinceName = selectedItem.province === "无" ? "" : selectedItem.province ?? "";
|
||||||
|
const normalizedCityName = selectedItem.city === "无" ? "" : selectedItem.city ?? "";
|
||||||
setChannelEditFieldErrors({});
|
setChannelEditFieldErrors({});
|
||||||
setInvalidEditChannelContactRows([]);
|
setInvalidEditChannelContactRows([]);
|
||||||
setEditChannelForm({
|
setEditChannelForm({
|
||||||
channelCode: selectedItem.channelCode ?? "",
|
channelCode: selectedItem.channelCode ?? "",
|
||||||
channelName: selectedItem.name ?? "",
|
channelName: selectedItem.name ?? "",
|
||||||
province: selectedItem.province === "无" ? "" : selectedItem.province ?? "",
|
province: normalizedProvinceName,
|
||||||
|
city: normalizedCityName,
|
||||||
officeAddress: selectedItem.officeAddress === "无" ? "" : selectedItem.officeAddress ?? "",
|
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,
|
annualRevenue: selectedItem.annualRevenue ? Number(selectedItem.annualRevenue) : undefined,
|
||||||
staffSize: selectedItem.size ?? undefined,
|
staffSize: selectedItem.size ?? undefined,
|
||||||
contactEstablishedDate: selectedItem.establishedDate === "无" ? "" : selectedItem.establishedDate ?? "",
|
contactEstablishedDate: selectedItem.establishedDate === "无" ? "" : selectedItem.establishedDate ?? "",
|
||||||
|
|
@ -612,6 +702,7 @@ export default function Expansion() {
|
||||||
}))
|
}))
|
||||||
: [createEmptyChannelContact()],
|
: [createEmptyChannelContact()],
|
||||||
});
|
});
|
||||||
|
void loadCityOptions(normalizedProvinceName, true);
|
||||||
}
|
}
|
||||||
setEditOpen(true);
|
setEditOpen(true);
|
||||||
};
|
};
|
||||||
|
|
@ -868,7 +959,11 @@ export default function Expansion() {
|
||||||
isEdit = false,
|
isEdit = false,
|
||||||
fieldErrors?: Partial<Record<ChannelField, string>>,
|
fieldErrors?: Partial<Record<ChannelField, string>>,
|
||||||
invalidContactRows: number[] = [],
|
invalidContactRows: number[] = [],
|
||||||
) => (
|
) => {
|
||||||
|
const cityOptions = isEdit ? editCityOptions : createCityOptions;
|
||||||
|
const cityDisabled = !form.province?.trim();
|
||||||
|
|
||||||
|
return (
|
||||||
<div className="crm-form-grid">
|
<div className="crm-form-grid">
|
||||||
<label className="space-y-2">
|
<label className="space-y-2">
|
||||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">编码</span>
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">编码</span>
|
||||||
|
|
@ -885,9 +980,54 @@ export default function Expansion() {
|
||||||
</label>
|
</label>
|
||||||
<label className="space-y-2">
|
<label className="space-y-2">
|
||||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">省份<RequiredMark /></span>
|
<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}
|
{fieldErrors?.province ? <p className="text-xs text-rose-500">{fieldErrors.province}</p> : null}
|
||||||
</label>
|
</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">
|
<label className="space-y-2">
|
||||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">办公地址<RequiredMark /></span>
|
<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))} />
|
<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>
|
||||||
<label className="space-y-2">
|
<label className="space-y-2">
|
||||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">聚焦行业<RequiredMark /></span>
|
<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}
|
{fieldErrors?.channelIndustry ? <p className="text-xs text-rose-500">{fieldErrors.channelIndustry}</p> : null}
|
||||||
</label>
|
</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">
|
<label className="space-y-2">
|
||||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">年营收<RequiredMark /></span>
|
<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))} />
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="crm-page-stack">
|
<div className="crm-page-stack">
|
||||||
|
|
@ -1121,7 +1295,7 @@ export default function Expansion() {
|
||||||
<div className="min-w-0">
|
<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>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className={`crm-pill shrink-0 ${item.intent === "高" ? "crm-pill-rose" : item.intent === "中" ? "crm-pill-amber" : "crm-pill-neutral"}`}>
|
<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">
|
<p className="break-anywhere mt-1 text-xs leading-5 text-slate-500 dark:text-slate-400 sm:text-sm">
|
||||||
{selectedItem.type === "sales"
|
{selectedItem.type === "sales"
|
||||||
? `${selectedItem.officeName || "无"} · ${selectedItem.dept || "无"} · ${selectedItem.title || "无"}`
|
? `${selectedItem.officeName || "无"} · ${selectedItem.dept || "无"} · ${selectedItem.title || "无"}`
|
||||||
: `${selectedItem.province || "无"} · ${selectedItem.channelIndustry || "无"} · ${selectedItem.officeAddress || "无"}`}
|
: `${selectedItem.province || "无"} · ${selectedItem.city || "无"} · ${selectedItem.channelIndustry || "无"} · ${selectedItem.certificationLevel || "无"}`}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
{selectedItem.type === "sales" ? (
|
{selectedItem.type === "sales" ? (
|
||||||
|
|
@ -1263,6 +1437,8 @@ export default function Expansion() {
|
||||||
<>
|
<>
|
||||||
<DetailItem label="编码" value={selectedItem.channelCode || "无"} />
|
<DetailItem label="编码" value={selectedItem.channelCode || "无"} />
|
||||||
<DetailItem label="省份" value={selectedItem.province || "无"} />
|
<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.officeAddress || "无"} className="sm:col-span-2" />
|
||||||
<DetailItem label="聚焦行业" value={selectedItem.channelIndustry || "无"} icon={<Building2 className="h-3 w-3" />} />
|
<DetailItem label="聚焦行业" value={selectedItem.channelIndustry || "无"} icon={<Building2 className="h-3 w-3" />} />
|
||||||
<DetailItem label="营收规模" value={selectedItem.revenue || "无"} />
|
<DetailItem label="营收规模" value={selectedItem.revenue || "无"} />
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
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 { 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 { 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 { AdaptiveSelect } from "@/components/AdaptiveSelect";
|
||||||
import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser";
|
import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -9,11 +9,13 @@ import { cn } from "@/lib/utils";
|
||||||
const detailBadgeClass = "crm-btn-chip text-[11px] font-semibold";
|
const detailBadgeClass = "crm-btn-chip text-[11px] font-semibold";
|
||||||
|
|
||||||
const CONFIDENCE_OPTIONS = [
|
const CONFIDENCE_OPTIONS = [
|
||||||
{ value: "80", label: "A" },
|
{ value: "A", label: "A" },
|
||||||
{ value: "60", label: "B" },
|
{ value: "B", label: "B" },
|
||||||
{ value: "40", label: "C" },
|
{ value: "C", label: "C" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
type ConfidenceGrade = (typeof CONFIDENCE_OPTIONS)[number]["value"];
|
||||||
|
|
||||||
const COMPETITOR_OPTIONS = [
|
const COMPETITOR_OPTIONS = [
|
||||||
"深信服",
|
"深信服",
|
||||||
"锐捷",
|
"锐捷",
|
||||||
|
|
@ -24,7 +26,7 @@ const COMPETITOR_OPTIONS = [
|
||||||
"其他",
|
"其他",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type CompetitorOption = (typeof COMPETITOR_OPTIONS)[number] | "";
|
type CompetitorOption = (typeof COMPETITOR_OPTIONS)[number];
|
||||||
type OperatorMode = "none" | "h3c" | "channel" | "both";
|
type OperatorMode = "none" | "h3c" | "channel" | "both";
|
||||||
type OpportunityArchiveTab = "active" | "archived";
|
type OpportunityArchiveTab = "active" | "archived";
|
||||||
type OpportunityField =
|
type OpportunityField =
|
||||||
|
|
@ -48,7 +50,7 @@ const defaultForm: CreateOpportunityPayload = {
|
||||||
operatorName: "",
|
operatorName: "",
|
||||||
amount: 0,
|
amount: 0,
|
||||||
expectedCloseDate: "",
|
expectedCloseDate: "",
|
||||||
confidencePct: 40,
|
confidencePct: "C",
|
||||||
stage: "",
|
stage: "",
|
||||||
opportunityType: "新建",
|
opportunityType: "新建",
|
||||||
productType: "VDI云桌面",
|
productType: "VDI云桌面",
|
||||||
|
|
@ -73,7 +75,7 @@ function toFormFromItem(item: OpportunityItem): CreateOpportunityPayload {
|
||||||
projectLocation: item.projectLocation || "",
|
projectLocation: item.projectLocation || "",
|
||||||
amount: item.amount || 0,
|
amount: item.amount || 0,
|
||||||
expectedCloseDate: item.date || "",
|
expectedCloseDate: item.date || "",
|
||||||
confidencePct: item.confidence ?? 50,
|
confidencePct: normalizeConfidenceGrade(item.confidence),
|
||||||
stage: item.stageCode || item.stage || "",
|
stage: item.stageCode || item.stage || "",
|
||||||
opportunityType: item.type || "新建",
|
opportunityType: item.type || "新建",
|
||||||
productType: item.product || "VDI云桌面",
|
productType: item.product || "VDI云桌面",
|
||||||
|
|
@ -86,31 +88,111 @@ function toFormFromItem(item: OpportunityItem): CreateOpportunityPayload {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getConfidenceOptionValue(score?: number) {
|
function normalizeConfidenceGrade(value?: string | number | null): ConfidenceGrade {
|
||||||
const value = score ?? 0;
|
if (value === null || value === undefined) {
|
||||||
if (value >= 80) return "80";
|
return "C";
|
||||||
if (value >= 60) return "60";
|
}
|
||||||
return "40";
|
|
||||||
|
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) {
|
function getConfidenceOptionValue(score?: string | number | null) {
|
||||||
const matchedOption = CONFIDENCE_OPTIONS.find((item) => item.value === getConfidenceOptionValue(score));
|
return normalizeConfidenceGrade(score);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfidenceLabel(score?: string | number | null) {
|
||||||
|
const matchedOption = CONFIDENCE_OPTIONS.find((item) => item.value === normalizeConfidenceGrade(score));
|
||||||
return matchedOption?.label || "C";
|
return matchedOption?.label || "C";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getConfidenceBadgeClass(score?: number) {
|
function getConfidenceBadgeClass(score?: string | number | null) {
|
||||||
const normalizedScore = Number(getConfidenceOptionValue(score));
|
const normalizedGrade = normalizeConfidenceGrade(score);
|
||||||
if (normalizedScore >= 80) return "crm-pill crm-pill-emerald";
|
if (normalizedGrade === "A") return "crm-pill crm-pill-emerald";
|
||||||
if (normalizedScore >= 60) return "crm-pill crm-pill-amber";
|
if (normalizedGrade === "B") return "crm-pill crm-pill-amber";
|
||||||
return "crm-pill crm-pill-rose";
|
return "crm-pill crm-pill-rose";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCompetitorSelection(value?: string): CompetitorOption {
|
function normalizeCompetitorSelections(selected: CompetitorOption[]) {
|
||||||
const competitor = value?.trim() || "";
|
const deduped = Array.from(new Set(selected));
|
||||||
if (!competitor) {
|
if (deduped.includes("无")) {
|
||||||
return "";
|
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) {
|
function normalizeOperatorToken(value?: string) {
|
||||||
|
|
@ -148,18 +230,15 @@ function resolveOperatorMode(operatorValue: string | undefined, operatorOptions:
|
||||||
|
|
||||||
function buildOpportunitySubmitPayload(
|
function buildOpportunitySubmitPayload(
|
||||||
form: CreateOpportunityPayload,
|
form: CreateOpportunityPayload,
|
||||||
competitorSelection: CompetitorOption,
|
selectedCompetitors: CompetitorOption[],
|
||||||
|
customCompetitorName: string,
|
||||||
operatorMode: OperatorMode,
|
operatorMode: OperatorMode,
|
||||||
): CreateOpportunityPayload {
|
): CreateOpportunityPayload {
|
||||||
const normalizedCompetitorName = competitorSelection === "其他"
|
|
||||||
? form.competitorName?.trim()
|
|
||||||
: competitorSelection || undefined;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...form,
|
...form,
|
||||||
salesExpansionId: operatorMode === "h3c" || operatorMode === "both" ? form.salesExpansionId : undefined,
|
salesExpansionId: operatorMode === "h3c" || operatorMode === "both" ? form.salesExpansionId : undefined,
|
||||||
channelExpansionId: operatorMode === "channel" || operatorMode === "both" ? form.channelExpansionId : undefined,
|
channelExpansionId: operatorMode === "channel" || operatorMode === "both" ? form.channelExpansionId : undefined,
|
||||||
competitorName: normalizedCompetitorName || undefined,
|
competitorName: buildCompetitorValue(selectedCompetitors, customCompetitorName),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -200,13 +279,14 @@ function RequiredMark() {
|
||||||
|
|
||||||
function validateOpportunityForm(
|
function validateOpportunityForm(
|
||||||
form: CreateOpportunityPayload,
|
form: CreateOpportunityPayload,
|
||||||
competitorSelection: CompetitorOption,
|
selectedCompetitors: CompetitorOption[],
|
||||||
|
customCompetitorName: string,
|
||||||
operatorMode: OperatorMode,
|
operatorMode: OperatorMode,
|
||||||
) {
|
) {
|
||||||
const errors: Partial<Record<OpportunityField, string>> = {};
|
const errors: Partial<Record<OpportunityField, string>> = {};
|
||||||
|
|
||||||
if (!form.projectLocation?.trim()) {
|
if (!form.projectLocation?.trim()) {
|
||||||
errors.projectLocation = "请填写项目地";
|
errors.projectLocation = "请选择项目地";
|
||||||
}
|
}
|
||||||
if (!form.opportunityName?.trim()) {
|
if (!form.opportunityName?.trim()) {
|
||||||
errors.opportunityName = "请填写项目名称";
|
errors.opportunityName = "请填写项目名称";
|
||||||
|
|
@ -223,7 +303,7 @@ function validateOpportunityForm(
|
||||||
if (!form.expectedCloseDate?.trim()) {
|
if (!form.expectedCloseDate?.trim()) {
|
||||||
errors.expectedCloseDate = "请选择预计下单时间";
|
errors.expectedCloseDate = "请选择预计下单时间";
|
||||||
}
|
}
|
||||||
if (!form.confidencePct || form.confidencePct <= 0) {
|
if (!form.confidencePct?.trim()) {
|
||||||
errors.confidencePct = "请选择项目把握度";
|
errors.confidencePct = "请选择项目把握度";
|
||||||
}
|
}
|
||||||
if (!form.stage?.trim()) {
|
if (!form.stage?.trim()) {
|
||||||
|
|
@ -232,10 +312,10 @@ function validateOpportunityForm(
|
||||||
if (!form.opportunityType?.trim()) {
|
if (!form.opportunityType?.trim()) {
|
||||||
errors.opportunityType = "请选择建设类型";
|
errors.opportunityType = "请选择建设类型";
|
||||||
}
|
}
|
||||||
if (!competitorSelection) {
|
if (selectedCompetitors.length === 0) {
|
||||||
errors.competitorName = "请选择竞争对手";
|
errors.competitorName = "请至少选择一个竞争对手";
|
||||||
} else if (competitorSelection === "其他" && !form.competitorName?.trim()) {
|
} else if (selectedCompetitors.includes("其他") && !customCompetitorName.trim()) {
|
||||||
errors.competitorName = "请选择“其他”时,请填写具体竞争对手";
|
errors.competitorName = "已选择“其他”,请填写其他竞争对手";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (operatorMode === "h3c" && !form.salesExpansionId) {
|
if (operatorMode === "h3c" && !form.salesExpansionId) {
|
||||||
|
|
@ -335,6 +415,17 @@ type SearchableOption = {
|
||||||
keywords?: string[];
|
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() {
|
function useIsMobileViewport() {
|
||||||
const [isMobile, setIsMobile] = useState(() => {
|
const [isMobile, setIsMobile] = useState(() => {
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
|
|
@ -385,9 +476,10 @@ function SearchableSelect({
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const isMobile = useIsMobileViewport();
|
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 normalizedQuery = query.trim().toLowerCase();
|
||||||
const filteredOptions = options.filter((item) => {
|
const filteredOptions = normalizedOptions.filter((item) => {
|
||||||
if (!normalizedQuery) {
|
if (!normalizedQuery) {
|
||||||
return true;
|
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() {
|
export default function Opportunities() {
|
||||||
const isMobileViewport = useIsMobileViewport();
|
const isMobileViewport = useIsMobileViewport();
|
||||||
const isWecomBrowser = useIsWecomBrowser();
|
const isWecomBrowser = useIsWecomBrowser();
|
||||||
|
|
@ -585,10 +855,16 @@ export default function Opportunities() {
|
||||||
const [items, setItems] = useState<OpportunityItem[]>([]);
|
const [items, setItems] = useState<OpportunityItem[]>([]);
|
||||||
const [salesExpansionOptions, setSalesExpansionOptions] = useState<SalesExpansionItem[]>([]);
|
const [salesExpansionOptions, setSalesExpansionOptions] = useState<SalesExpansionItem[]>([]);
|
||||||
const [channelExpansionOptions, setChannelExpansionOptions] = useState<ChannelExpansionItem[]>([]);
|
const [channelExpansionOptions, setChannelExpansionOptions] = useState<ChannelExpansionItem[]>([]);
|
||||||
|
const [omsPreSalesOptions, setOmsPreSalesOptions] = useState<OmsPreSalesOption[]>([]);
|
||||||
const [stageOptions, setStageOptions] = useState<OpportunityDictOption[]>([]);
|
const [stageOptions, setStageOptions] = useState<OpportunityDictOption[]>([]);
|
||||||
const [operatorOptions, setOperatorOptions] = useState<OpportunityDictOption[]>([]);
|
const [operatorOptions, setOperatorOptions] = useState<OpportunityDictOption[]>([]);
|
||||||
|
const [projectLocationOptions, setProjectLocationOptions] = useState<OpportunityDictOption[]>([]);
|
||||||
const [form, setForm] = useState<CreateOpportunityPayload>(defaultForm);
|
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 [fieldErrors, setFieldErrors] = useState<Partial<Record<OpportunityField, string>>>({});
|
||||||
const [detailTab, setDetailTab] = useState<"sales" | "channel" | "followups">("sales");
|
const [detailTab, setDetailTab] = useState<"sales" | "channel" | "followups">("sales");
|
||||||
const hasForegroundModal = createOpen || editOpen || pushConfirmOpen;
|
const hasForegroundModal = createOpen || editOpen || pushConfirmOpen;
|
||||||
|
|
@ -650,11 +926,13 @@ export default function Opportunities() {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setStageOptions((data.stageOptions ?? []).filter((item) => item.value));
|
setStageOptions((data.stageOptions ?? []).filter((item) => item.value));
|
||||||
setOperatorOptions((data.operatorOptions ?? []).filter((item) => item.value));
|
setOperatorOptions((data.operatorOptions ?? []).filter((item) => item.value));
|
||||||
|
setProjectLocationOptions((data.projectLocationOptions ?? []).filter((item) => item.value));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setStageOptions([]);
|
setStageOptions([]);
|
||||||
setOperatorOptions([]);
|
setOperatorOptions([]);
|
||||||
|
setProjectLocationOptions([]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -680,6 +958,20 @@ export default function Opportunities() {
|
||||||
{ label: "全部", value: "全部" },
|
{ label: "全部", value: "全部" },
|
||||||
...stageOptions.map((item) => ({ label: item.label || item.value || "", value: item.value || "" })),
|
...stageOptions.map((item) => ({ label: item.label || item.value || "", value: item.value || "" })),
|
||||||
].filter((item) => 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 activeStageFilterLabel = stageFilterOptions.find((item) => item.value === filter)?.label || "全部";
|
||||||
const selectedSalesExpansion = selectedItem?.salesExpansionId
|
const selectedSalesExpansion = selectedItem?.salesExpansionId
|
||||||
? salesExpansionOptions.find((item) => item.id === selectedItem.salesExpansionId) ?? null
|
? salesExpansionOptions.find((item) => item.id === selectedItem.salesExpansionId) ?? null
|
||||||
|
|
@ -689,9 +981,11 @@ export default function Opportunities() {
|
||||||
: null;
|
: null;
|
||||||
const selectedSalesExpansionName = selectedItem?.salesExpansionName || selectedSalesExpansion?.name || "";
|
const selectedSalesExpansionName = selectedItem?.salesExpansionName || selectedSalesExpansion?.name || "";
|
||||||
const selectedChannelExpansionName = selectedItem?.channelExpansionName || selectedChannelExpansion?.name || "";
|
const selectedChannelExpansionName = selectedItem?.channelExpansionName || selectedChannelExpansion?.name || "";
|
||||||
|
const selectedPreSalesName = selectedItem?.preSalesName || "无";
|
||||||
const operatorMode = resolveOperatorMode(form.operatorName, operatorOptions);
|
const operatorMode = resolveOperatorMode(form.operatorName, operatorOptions);
|
||||||
const showSalesExpansionField = operatorMode === "h3c" || operatorMode === "both";
|
const showSalesExpansionField = operatorMode === "h3c" || operatorMode === "both";
|
||||||
const showChannelExpansionField = operatorMode === "channel" || operatorMode === "both";
|
const showChannelExpansionField = operatorMode === "channel" || operatorMode === "both";
|
||||||
|
const showCustomCompetitorInput = selectedCompetitors.includes("其他");
|
||||||
const salesExpansionSearchOptions: SearchableOption[] = salesExpansionOptions.map((item) => ({
|
const salesExpansionSearchOptions: SearchableOption[] = salesExpansionOptions.map((item) => ({
|
||||||
value: item.id,
|
value: item.id,
|
||||||
label: item.name || `拓展人员#${item.id}`,
|
label: item.name || `拓展人员#${item.id}`,
|
||||||
|
|
@ -702,12 +996,24 @@ export default function Opportunities() {
|
||||||
label: item.name || `渠道#${item.id}`,
|
label: item.name || `渠道#${item.id}`,
|
||||||
keywords: [item.channelCode || "", item.province || "", item.primaryContactName || "", item.primaryContactMobile || ""],
|
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(() => {
|
useEffect(() => {
|
||||||
if (selectedItem) {
|
if (selectedItem) {
|
||||||
setDetailTab("sales");
|
setDetailTab("sales");
|
||||||
} else {
|
} else {
|
||||||
setPushConfirmOpen(false);
|
setPushConfirmOpen(false);
|
||||||
|
setPushPreSalesId(undefined);
|
||||||
|
setPushPreSalesName("");
|
||||||
}
|
}
|
||||||
}, [selectedItem]);
|
}, [selectedItem]);
|
||||||
|
|
||||||
|
|
@ -717,10 +1023,10 @@ export default function Opportunities() {
|
||||||
}
|
}
|
||||||
}, [archiveTab, selectedItem, visibleItems]);
|
}, [archiveTab, selectedItem, visibleItems]);
|
||||||
|
|
||||||
const getConfidenceColor = (score: number) => {
|
const getConfidenceColor = (score?: string | number | null) => {
|
||||||
const normalizedScore = Number(getConfidenceOptionValue(score));
|
const normalizedGrade = normalizeConfidenceGrade(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 (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 (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";
|
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";
|
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("");
|
setError("");
|
||||||
setFieldErrors({});
|
setFieldErrors({});
|
||||||
setForm(defaultForm);
|
setForm(defaultForm);
|
||||||
setCompetitorSelection("");
|
setSelectedCompetitors([]);
|
||||||
|
setCustomCompetitorName("");
|
||||||
setCreateOpen(true);
|
setCreateOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -750,7 +1057,8 @@ export default function Opportunities() {
|
||||||
setError("");
|
setError("");
|
||||||
setFieldErrors({});
|
setFieldErrors({});
|
||||||
setForm(defaultForm);
|
setForm(defaultForm);
|
||||||
setCompetitorSelection("");
|
setSelectedCompetitors([]);
|
||||||
|
setCustomCompetitorName("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const reload = async (preferredSelectedId?: number) => {
|
const reload = async (preferredSelectedId?: number) => {
|
||||||
|
|
@ -768,7 +1076,7 @@ export default function Opportunities() {
|
||||||
}
|
}
|
||||||
|
|
||||||
setError("");
|
setError("");
|
||||||
const validationErrors = validateOpportunityForm(form, competitorSelection, operatorMode);
|
const validationErrors = validateOpportunityForm(form, selectedCompetitors, customCompetitorName, operatorMode);
|
||||||
if (Object.keys(validationErrors).length > 0) {
|
if (Object.keys(validationErrors).length > 0) {
|
||||||
setFieldErrors(validationErrors);
|
setFieldErrors(validationErrors);
|
||||||
setError("请先完整填写商机必填字段");
|
setError("请先完整填写商机必填字段");
|
||||||
|
|
@ -778,7 +1086,7 @@ export default function Opportunities() {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createOpportunity(buildOpportunitySubmitPayload(form, competitorSelection, operatorMode));
|
await createOpportunity(buildOpportunitySubmitPayload(form, selectedCompetitors, customCompetitorName, operatorMode));
|
||||||
await reload();
|
await reload();
|
||||||
resetCreateState();
|
resetCreateState();
|
||||||
} catch (createError) {
|
} catch (createError) {
|
||||||
|
|
@ -791,14 +1099,12 @@ export default function Opportunities() {
|
||||||
if (!selectedItem) {
|
if (!selectedItem) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (selectedItem.pushedToOms) {
|
|
||||||
setError("该商机已推送 OMS,不能再编辑");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setError("");
|
setError("");
|
||||||
setFieldErrors({});
|
setFieldErrors({});
|
||||||
setForm(toFormFromItem(selectedItem));
|
setForm(toFormFromItem(selectedItem));
|
||||||
setCompetitorSelection(getCompetitorSelection(selectedItem.competitorName));
|
const competitorState = parseCompetitorState(selectedItem.competitorName);
|
||||||
|
setSelectedCompetitors(competitorState.selections);
|
||||||
|
setCustomCompetitorName(competitorState.customName);
|
||||||
setEditOpen(true);
|
setEditOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -808,7 +1114,7 @@ export default function Opportunities() {
|
||||||
}
|
}
|
||||||
|
|
||||||
setError("");
|
setError("");
|
||||||
const validationErrors = validateOpportunityForm(form, competitorSelection, operatorMode);
|
const validationErrors = validateOpportunityForm(form, selectedCompetitors, customCompetitorName, operatorMode);
|
||||||
if (Object.keys(validationErrors).length > 0) {
|
if (Object.keys(validationErrors).length > 0) {
|
||||||
setFieldErrors(validationErrors);
|
setFieldErrors(validationErrors);
|
||||||
setError("请先完整填写商机必填字段");
|
setError("请先完整填写商机必填字段");
|
||||||
|
|
@ -818,7 +1124,7 @@ export default function Opportunities() {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateOpportunity(selectedItem.id, buildOpportunitySubmitPayload(form, competitorSelection, operatorMode));
|
await updateOpportunity(selectedItem.id, buildOpportunitySubmitPayload(form, selectedCompetitors, customCompetitorName, operatorMode));
|
||||||
await reload(selectedItem.id);
|
await reload(selectedItem.id);
|
||||||
resetCreateState();
|
resetCreateState();
|
||||||
} catch (updateError) {
|
} catch (updateError) {
|
||||||
|
|
@ -836,7 +1142,11 @@ export default function Opportunities() {
|
||||||
setError("");
|
setError("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await pushOpportunityToOms(selectedItem.id);
|
const payload: PushOpportunityToOmsPayload = {
|
||||||
|
preSalesId: pushPreSalesId,
|
||||||
|
preSalesName: pushPreSalesName.trim() || undefined,
|
||||||
|
};
|
||||||
|
await pushOpportunityToOms(selectedItem.id, payload);
|
||||||
await reload(selectedItem.id);
|
await reload(selectedItem.id);
|
||||||
} catch (pushError) {
|
} catch (pushError) {
|
||||||
setError(pushError instanceof Error ? pushError.message : "推送 OMS 失败");
|
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) {
|
if (!selectedItem || selectedItem.pushedToOms || pushingOms) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setError("");
|
||||||
|
syncPushPreSalesSelection(selectedItem, omsPreSalesOptions);
|
||||||
setPushConfirmOpen(true);
|
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 () => {
|
const handleConfirmPushToOms = async () => {
|
||||||
|
if (!pushPreSalesId && !pushPreSalesName.trim()) {
|
||||||
|
setError("请选择售前人员");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setPushConfirmOpen(false);
|
setPushConfirmOpen(false);
|
||||||
await handlePushToOms();
|
await handlePushToOms();
|
||||||
};
|
};
|
||||||
|
|
@ -943,7 +1301,7 @@ export default function Opportunities() {
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0 flex-1">
|
<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>
|
<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>
|
||||||
<div className="shrink-0 flex items-center gap-2 pl-2">
|
<div className="shrink-0 flex items-center gap-2 pl-2">
|
||||||
<span className={getConfidenceBadgeClass(opp.confidence)}>
|
<span className={getConfidenceBadgeClass(opp.confidence)}>
|
||||||
|
|
@ -1061,7 +1419,16 @@ export default function Opportunities() {
|
||||||
<div className="crm-form-grid">
|
<div className="crm-form-grid">
|
||||||
<label className="space-y-2">
|
<label className="space-y-2">
|
||||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">项目地<RequiredMark /></span>
|
<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}
|
{fieldErrors.projectLocation ? <p className="text-xs text-rose-500">{fieldErrors.projectLocation}</p> : null}
|
||||||
</label>
|
</label>
|
||||||
<label className="space-y-2 sm:col-span-2">
|
<label className="space-y-2 sm:col-span-2">
|
||||||
|
|
@ -1148,7 +1515,7 @@ export default function Opportunities() {
|
||||||
className={cn(
|
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" : "",
|
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}
|
{fieldErrors.confidencePct ? <p className="text-xs text-rose-500">{fieldErrors.confidencePct}</p> : null}
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -1171,17 +1538,14 @@ export default function Opportunities() {
|
||||||
</label>
|
</label>
|
||||||
<label className="space-y-2">
|
<label className="space-y-2">
|
||||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">竞争对手<RequiredMark /></span>
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">竞争对手<RequiredMark /></span>
|
||||||
<AdaptiveSelect
|
<CompetitorMultiSelect
|
||||||
value={competitorSelection}
|
value={selectedCompetitors}
|
||||||
placeholder="请选择"
|
options={COMPETITOR_OPTIONS}
|
||||||
sheetTitle="竞争对手"
|
placeholder="请选择竞争对手"
|
||||||
options={COMPETITOR_OPTIONS.map((item) => ({ value: item, label: item }))}
|
|
||||||
className={cn(
|
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" : "",
|
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) => {
|
onChange={(nextValue) => {
|
||||||
const nextSelection = value as CompetitorOption;
|
|
||||||
setCompetitorSelection(nextSelection);
|
|
||||||
setFieldErrors((current) => {
|
setFieldErrors((current) => {
|
||||||
if (!current.competitorName) {
|
if (!current.competitorName) {
|
||||||
return current;
|
return current;
|
||||||
|
|
@ -1190,22 +1554,21 @@ export default function Opportunities() {
|
||||||
delete next.competitorName;
|
delete next.competitorName;
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
if (nextSelection === "其他") {
|
setSelectedCompetitors(nextValue);
|
||||||
handleChange("competitorName", getCompetitorSelection(form.competitorName) === "其他" ? form.competitorName || "" : "");
|
if (!nextValue.includes("其他")) {
|
||||||
return;
|
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>
|
</label>
|
||||||
{competitorSelection === "其他" ? (
|
{showCustomCompetitorInput ? (
|
||||||
<label className="space-y-2">
|
<label className="space-y-2">
|
||||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">其他竞争对手<RequiredMark /></span>
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">其他竞争对手<RequiredMark /></span>
|
||||||
<input
|
<input
|
||||||
value={getCompetitorSelection(form.competitorName) === "其他" ? form.competitorName || "" : ""}
|
value={customCompetitorName}
|
||||||
onChange={(e) => handleChange("competitorName", e.target.value)}
|
onChange={(e) => setCustomCompetitorName(e.target.value)}
|
||||||
placeholder="请输入竞争对手名称"
|
placeholder="请输入其他竞争对手"
|
||||||
className={getFieldInputClass(Boolean(fieldErrors.competitorName))}
|
className={getFieldInputClass(Boolean(fieldErrors.competitorName))}
|
||||||
/>
|
/>
|
||||||
{fieldErrors.competitorName ? <p className="text-xs text-rose-500">{fieldErrors.competitorName}</p> : null}
|
{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">
|
<div className="min-w-0">
|
||||||
<h3 className="text-base font-semibold text-slate-900 dark:text-white">确认推送 OMS</h3>
|
<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">
|
<p className="mt-1 text-sm leading-6 text-slate-500 dark:text-slate-400">
|
||||||
推送 OMS 后不允许修改,是否确认推送?
|
请选择售前后同步到 OMS,系统会携带当前商机编码供 OMS 判断。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1298,6 +1661,26 @@ export default function Opportunities() {
|
||||||
<p className="text-xs text-slate-400 dark:text-slate-500">当前商机</p>
|
<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>
|
<p className="mt-1 font-medium text-slate-900 dark:text-white">{selectedItem.name || selectedItem.code || `#${selectedItem.id}`}</p>
|
||||||
</div>
|
</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>
|
||||||
<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">
|
<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
|
<button
|
||||||
|
|
@ -1354,12 +1737,11 @@ export default function Opportunities() {
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<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>
|
<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>
|
</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>
|
<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">
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
<span className="crm-pill crm-pill-neutral">{selectedItem.stage || "初步沟通"}</span>
|
<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>
|
||||||
</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.client || "无"} icon={<Building className="h-3 w-3" />} />
|
||||||
<DetailItem label="运作方" value={selectedItem.operatorName || "无"} />
|
<DetailItem label="运作方" value={selectedItem.operatorName || "无"} />
|
||||||
<DetailItem label="新华三负责人" value={selectedSalesExpansionName || "未关联"} icon={<User className="h-3 w-3" />} />
|
<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={<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={selectedItem.date || "待定"} icon={<Calendar className="h-3 w-3" />} />
|
||||||
<DetailItem label="项目把握度" value={getConfidenceLabel(selectedItem.confidence)} />
|
<DetailItem label="项目把握度" value={getConfidenceLabel(selectedItem.confidence)} />
|
||||||
<DetailItem label="项目阶段" value={selectedItem.stage || "无"} icon={<Activity className="h-3 w-3" />} />
|
<DetailItem label="项目阶段" value={selectedItem.stage || "无"} icon={<Activity className="h-3 w-3" />} />
|
||||||
<DetailItem label="竞争对手" value={selectedItem.competitorName || "无"} />
|
<DetailItem label="竞争对手" value={selectedItem.competitorName || "无"} />
|
||||||
<DetailItem label="建设类型" value={selectedItem.type || "新建"} icon={<Tag className="h-3 w-3" />} />
|
<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.latestProgress || "暂无日报回写进展"} className="md:col-span-2" />
|
||||||
<DetailItem label="后续规划" value={selectedItem.nextPlan || "暂无日报回写规划"} 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" />
|
<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">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={handleOpenEdit}
|
onClick={handleOpenEdit}
|
||||||
disabled={Boolean(selectedItem.pushedToOms)}
|
className="crm-btn crm-btn-secondary inline-flex h-11 items-center justify-center"
|
||||||
className="crm-btn crm-btn-secondary inline-flex h-11 items-center justify-center disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
>
|
>
|
||||||
编辑商机
|
编辑商机
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleOpenPushConfirm}
|
type="button"
|
||||||
|
onClick={() => void handleOpenPushConfirm()}
|
||||||
disabled={Boolean(selectedItem.pushedToOms) || pushingOms}
|
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"}
|
{selectedItem.pushedToOms ? "已推送 OMS" : pushingOms ? "推送中..." : "推送 OMS"}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,9 @@ export default defineConfig(({mode}) => {
|
||||||
const env = loadEnv(mode, '.', '');
|
const env = loadEnv(mode, '.', '');
|
||||||
const certPath = path.resolve(__dirname, '.cert/dev.crt');
|
const certPath = path.resolve(__dirname, '.cert/dev.crt');
|
||||||
const keyPath = path.resolve(__dirname, '.cert/dev.key');
|
const keyPath = path.resolve(__dirname, '.cert/dev.key');
|
||||||
|
const enableHttps = String(env.VITE_DEV_HTTPS || '').trim().toLowerCase() === 'true';
|
||||||
const https =
|
const https =
|
||||||
fs.existsSync(certPath) && fs.existsSync(keyPath)
|
enableHttps && fs.existsSync(certPath) && fs.existsSync(keyPath)
|
||||||
? {
|
? {
|
||||||
cert: fs.readFileSync(certPath),
|
cert: fs.readFileSync(certPath),
|
||||||
key: fs.readFileSync(keyPath),
|
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;
|
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
|
-- Section 2. Base tables
|
||||||
-- =====================================================================
|
-- =====================================================================
|
||||||
|
|
@ -103,11 +125,13 @@ create table if not exists crm_opportunity (
|
||||||
owner_user_id bigint not null,
|
owner_user_id bigint not null,
|
||||||
sales_expansion_id bigint,
|
sales_expansion_id bigint,
|
||||||
channel_expansion_id bigint,
|
channel_expansion_id bigint,
|
||||||
|
pre_sales_id bigint,
|
||||||
|
pre_sales_name varchar(100),
|
||||||
project_location varchar(100),
|
project_location varchar(100),
|
||||||
operator_name varchar(100),
|
operator_name varchar(100),
|
||||||
amount numeric(18, 2) not null default 0,
|
amount numeric(18, 2) not null default 0,
|
||||||
expected_close_date date,
|
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',
|
stage varchar(50) not null default 'initial_contact',
|
||||||
opportunity_type varchar(50),
|
opportunity_type varchar(50),
|
||||||
product_type varchar(100),
|
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,
|
id bigint generated by default as identity primary key,
|
||||||
channel_code varchar(50),
|
channel_code varchar(50),
|
||||||
province varchar(50),
|
province varchar(50),
|
||||||
|
city varchar(50),
|
||||||
channel_name varchar(200) not null,
|
channel_name varchar(200) not null,
|
||||||
office_address varchar(255),
|
office_address varchar(255),
|
||||||
channel_industry varchar(100),
|
channel_industry varchar(100),
|
||||||
|
certification_level varchar(100),
|
||||||
annual_revenue numeric(18, 2),
|
annual_revenue numeric(18, 2),
|
||||||
staff_size integer check (staff_size is null or staff_size >= 0),
|
staff_size integer check (staff_size is null or staff_size >= 0),
|
||||||
contact_established_date date,
|
contact_established_date date,
|
||||||
|
|
@ -521,6 +547,8 @@ END $$;
|
||||||
ALTER TABLE IF EXISTS crm_opportunity
|
ALTER TABLE IF EXISTS crm_opportunity
|
||||||
ADD COLUMN IF NOT EXISTS sales_expansion_id bigint,
|
ADD COLUMN IF NOT EXISTS sales_expansion_id bigint,
|
||||||
ADD COLUMN IF NOT EXISTS channel_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 project_location varchar(100),
|
||||||
ADD COLUMN IF NOT EXISTS operator_name varchar(100),
|
ADD COLUMN IF NOT EXISTS operator_name varchar(100),
|
||||||
ADD COLUMN IF NOT EXISTS competitor_name varchar(200);
|
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 channel_code varchar(50),
|
||||||
ADD COLUMN IF NOT EXISTS office_address varchar(255),
|
ADD COLUMN IF NOT EXISTS office_address varchar(255),
|
||||||
ADD COLUMN IF NOT EXISTS channel_industry varchar(100),
|
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 contact_established_date date,
|
||||||
ADD COLUMN IF NOT EXISTS intent_level varchar(20),
|
ADD COLUMN IF NOT EXISTS intent_level varchar(20),
|
||||||
ADD COLUMN IF NOT EXISTS has_desktop_exp boolean,
|
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
|
CREATE INDEX IF NOT EXISTS idx_crm_channel_expansion_contact_channel
|
||||||
ON crm_channel_expansion_contact(channel_expansion_id);
|
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;
|
commit;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue