OMS对接

main
kangwenjing 2026-04-01 17:24:06 +08:00
parent 42c21cc4fc
commit dee5da7655
50 changed files with 4100 additions and 174 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -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) {

View File

@ -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();
}
} }

View File

@ -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;
} }

View File

@ -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();
}
}
}

View File

@ -0,0 +1,8 @@
package com.unis.crm.common;
public class UnauthorizedException extends RuntimeException {
public UnauthorizedException(String message) {
super(message);
}
}

View File

@ -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;
}
}

View File

@ -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();
}
}

View File

@ -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;
}
}

View File

@ -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,

View File

@ -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")

View File

@ -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;
}
}

View File

@ -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;
} }

View File

@ -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;
} }

View File

@ -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;
} }

View File

@ -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;
} }

View File

@ -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;
} }

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
} }

View File

@ -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;
}
} }

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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")

View File

@ -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,

View File

@ -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);

View File

@ -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;
}
}

View File

@ -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);
} }

View File

@ -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);

View File

@ -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) {
} }
} }

View File

@ -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}

View File

@ -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}

View File

@ -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},

View File

@ -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

View File

@ -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);
}
}

View File

@ -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());
}
}

View File

@ -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": "外部系统回写成交结果"
}'
```

View File

@ -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"

View File

@ -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",

View File

@ -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 || "无"} />

View File

@ -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>

View File

@ -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),

View File

@ -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;

View File

@ -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;

View File

@ -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系统项目编号';

View File

@ -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 '售前姓名';

View File

@ -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;

View File

@ -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;