From dee5da7655b62f33fb63418ead620163cdf4444f Mon Sep 17 00:00:00 2001 From: kangwenjing <1138819403@qq.com> Date: Wed, 1 Apr 2026 17:24:06 +0800 Subject: [PATCH] =?UTF-8?q?OMS=E5=AF=B9=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .DS_Store | Bin 10244 -> 10244 bytes .../unis/crm/UnisCrmBackendApplication.java | 4 +- .../crm/common/CrmGlobalExceptionHandler.java | 57 ++ .../com/unis/crm/common/CurrentUserUtils.java | 4 +- .../common/OpportunitySchemaInitializer.java | 171 ++++++ .../crm/common/UnauthorizedException.java | 8 + .../crm/config/InternalAuthProperties.java | 42 ++ .../InternalIntegrationSecurityConfig.java | 24 + .../com/unis/crm/config/OmsProperties.java | 98 ++++ .../crm/controller/ExpansionController.java | 10 + .../crm/controller/OpportunityController.java | 13 +- .../OpportunityIntegrationController.java | 58 ++ .../expansion/ChannelExpansionItemDTO.java | 45 ++ .../CreateChannelExpansionRequest.java | 18 + .../crm/dto/expansion/ExpansionMetaDTO.java | 22 + .../UpdateChannelExpansionRequest.java | 18 + .../opportunity/CreateOpportunityRequest.java | 23 +- .../opportunity/CurrentUserAccountDTO.java | 32 + .../dto/opportunity/OmsPreSalesOptionDTO.java | 32 + .../OpportunityIntegrationTargetDTO.java | 86 +++ .../dto/opportunity/OpportunityItemDTO.java | 24 +- .../dto/opportunity/OpportunityMetaDTO.java | 15 +- .../OpportunityOmsPushDataDTO.java | 214 +++++++ .../PushOpportunityToOmsRequest.java | 23 + .../UpdateOpportunityIntegrationRequest.java | 265 +++++++++ .../com/unis/crm/mapper/ExpansionMapper.java | 4 + .../unis/crm/mapper/OpportunityMapper.java | 38 +- .../unis/crm/service/ExpansionService.java | 4 + .../java/com/unis/crm/service/OmsClient.java | 455 ++++++++++++++ .../unis/crm/service/OpportunityService.java | 10 +- .../service/impl/ExpansionServiceImpl.java | 44 +- .../service/impl/OpportunityServiceImpl.java | 454 +++++++++++++- .../src/main/resources/application-prod.yml | 13 +- backend/src/main/resources/application.yml | 13 +- .../mapper/expansion/ExpansionMapper.xml | 58 +- .../mapper/opportunity/OpportunityMapper.xml | 180 +++++- .../AuthHeaderHandlingWebMvcTest.java | 83 +++ ...tunityIntegrationControllerWebMvcTest.java | 88 +++ docs/opportunity-integration-api.md | 115 ++++ frontend/src/components/AdaptiveSelect.tsx | 53 +- frontend/src/lib/auth.ts | 44 +- frontend/src/pages/Expansion.tsx | 214 ++++++- frontend/src/pages/Opportunities.tsx | 555 +++++++++++++++--- frontend/vite.config.ts | 3 +- ..._add_city_and_certification_level_pg17.sql | 12 + .../alter_fill_column_comments_pg17.sql | 237 ++++++++ ..._opportunity_add_oms_project_code_pg17.sql | 4 + ...r_opportunity_add_presales_fields_pg17.sql | 6 + sql/archive/create_cnarea_pg17.sql | 39 ++ sql/init_full_pg17.sql | 242 +++++++- 50 files changed, 4100 insertions(+), 174 deletions(-) create mode 100644 backend/src/main/java/com/unis/crm/common/OpportunitySchemaInitializer.java create mode 100644 backend/src/main/java/com/unis/crm/common/UnauthorizedException.java create mode 100644 backend/src/main/java/com/unis/crm/config/InternalAuthProperties.java create mode 100644 backend/src/main/java/com/unis/crm/config/InternalIntegrationSecurityConfig.java create mode 100644 backend/src/main/java/com/unis/crm/config/OmsProperties.java create mode 100644 backend/src/main/java/com/unis/crm/controller/OpportunityIntegrationController.java create mode 100644 backend/src/main/java/com/unis/crm/dto/opportunity/CurrentUserAccountDTO.java create mode 100644 backend/src/main/java/com/unis/crm/dto/opportunity/OmsPreSalesOptionDTO.java create mode 100644 backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityIntegrationTargetDTO.java create mode 100644 backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityOmsPushDataDTO.java create mode 100644 backend/src/main/java/com/unis/crm/dto/opportunity/PushOpportunityToOmsRequest.java create mode 100644 backend/src/main/java/com/unis/crm/dto/opportunity/UpdateOpportunityIntegrationRequest.java create mode 100644 backend/src/main/java/com/unis/crm/service/OmsClient.java create mode 100644 backend/src/test/java/com/unis/crm/controller/AuthHeaderHandlingWebMvcTest.java create mode 100644 backend/src/test/java/com/unis/crm/controller/OpportunityIntegrationControllerWebMvcTest.java create mode 100644 docs/opportunity-integration-api.md create mode 100644 sql/archive/alter_channel_expansion_add_city_and_certification_level_pg17.sql create mode 100644 sql/archive/alter_fill_column_comments_pg17.sql create mode 100644 sql/archive/alter_opportunity_add_oms_project_code_pg17.sql create mode 100644 sql/archive/alter_opportunity_add_presales_fields_pg17.sql create mode 100644 sql/archive/create_cnarea_pg17.sql diff --git a/.DS_Store b/.DS_Store index 5a67d32fb3e9cbb09073fdec2eec4e410a45936f..b72a67d05745f5c3f23cba0e795ee2d082ab84f8 100644 GIT binary patch delta 14 VcmZn(XbIS0A;ieI*;44VC;%j{1cLwo delta 14 VcmZn(XbIS0A;ieA*;44VC;%j>1cCqn diff --git a/backend/src/main/java/com/unis/crm/UnisCrmBackendApplication.java b/backend/src/main/java/com/unis/crm/UnisCrmBackendApplication.java index bc81b68e..f9e520f9 100644 --- a/backend/src/main/java/com/unis/crm/UnisCrmBackendApplication.java +++ b/backend/src/main/java/com/unis/crm/UnisCrmBackendApplication.java @@ -1,6 +1,8 @@ package com.unis.crm; import com.unis.crm.config.WecomProperties; +import com.unis.crm.config.InternalAuthProperties; +import com.unis.crm.config.OmsProperties; import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @@ -8,7 +10,7 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties @SpringBootApplication(scanBasePackages = "com.unis.crm") @MapperScan("com.unis.crm.mapper") -@EnableConfigurationProperties(WecomProperties.class) +@EnableConfigurationProperties({WecomProperties.class, OmsProperties.class, InternalAuthProperties.class}) public class UnisCrmBackendApplication { public static void main(String[] args) { diff --git a/backend/src/main/java/com/unis/crm/common/CrmGlobalExceptionHandler.java b/backend/src/main/java/com/unis/crm/common/CrmGlobalExceptionHandler.java index 25805531..bdd66db2 100644 --- a/backend/src/main/java/com/unis/crm/common/CrmGlobalExceptionHandler.java +++ b/backend/src/main/java/com/unis/crm/common/CrmGlobalExceptionHandler.java @@ -5,14 +5,22 @@ import java.time.OffsetDateTime; import java.util.LinkedHashMap; import java.util.Map; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingRequestHeaderException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.method.annotation.HandlerMethodValidationException; +import org.springframework.validation.method.ParameterValidationResult; @RestControllerAdvice public class CrmGlobalExceptionHandler { + private static final String CURRENT_USER_HEADER = "X-User-Id"; + private static final String UNAUTHORIZED_MESSAGE = "登录已失效,请重新登录"; + @ExceptionHandler(BusinessException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) public ApiResponse handleBusinessException(BusinessException ex) { @@ -41,6 +49,27 @@ public class CrmGlobalExceptionHandler { return ApiResponse.fail(ex.getMessage()); } + @ExceptionHandler(UnauthorizedException.class) + public ResponseEntity> handleUnauthorizedException(UnauthorizedException ex) { + return errorResponse(HttpStatus.UNAUTHORIZED, ex.getMessage()); + } + + @ExceptionHandler(MissingRequestHeaderException.class) + public ResponseEntity> 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> handleHandlerMethodValidationException(HandlerMethodValidationException ex) { + if (containsInvalidCurrentUserHeader(ex)) { + return errorResponse(HttpStatus.UNAUTHORIZED, UNAUTHORIZED_MESSAGE); + } + return errorResponse(HttpStatus.BAD_REQUEST, ex.getBody().getDetail()); + } + @ExceptionHandler(Exception.class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public Map handleUnexpectedException(Exception ex, HttpServletRequest request) { @@ -52,4 +81,32 @@ public class CrmGlobalExceptionHandler { body.put("path", request.getRequestURI()); return body; } + + private ResponseEntity> 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(); + } } diff --git a/backend/src/main/java/com/unis/crm/common/CurrentUserUtils.java b/backend/src/main/java/com/unis/crm/common/CurrentUserUtils.java index 5e015c09..fc9fb714 100644 --- a/backend/src/main/java/com/unis/crm/common/CurrentUserUtils.java +++ b/backend/src/main/java/com/unis/crm/common/CurrentUserUtils.java @@ -2,12 +2,14 @@ package com.unis.crm.common; public final class CurrentUserUtils { + private static final String UNAUTHORIZED_MESSAGE = "登录已失效,请重新登录"; + private CurrentUserUtils() { } public static Long requireCurrentUserId(Long headerUserId) { if (headerUserId == null || headerUserId <= 0) { - throw new BusinessException("未识别到当前登录用户"); + throw new UnauthorizedException(UNAUTHORIZED_MESSAGE); } return headerUserId; } diff --git a/backend/src/main/java/com/unis/crm/common/OpportunitySchemaInitializer.java b/backend/src/main/java/com/unis/crm/common/OpportunitySchemaInitializer.java new file mode 100644 index 00000000..c64fcc73 --- /dev/null +++ b/backend/src/main/java/com/unis/crm/common/OpportunitySchemaInitializer.java @@ -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(); + } + } +} diff --git a/backend/src/main/java/com/unis/crm/common/UnauthorizedException.java b/backend/src/main/java/com/unis/crm/common/UnauthorizedException.java new file mode 100644 index 00000000..afd89d2a --- /dev/null +++ b/backend/src/main/java/com/unis/crm/common/UnauthorizedException.java @@ -0,0 +1,8 @@ +package com.unis.crm.common; + +public class UnauthorizedException extends RuntimeException { + + public UnauthorizedException(String message) { + super(message); + } +} diff --git a/backend/src/main/java/com/unis/crm/config/InternalAuthProperties.java b/backend/src/main/java/com/unis/crm/config/InternalAuthProperties.java new file mode 100644 index 00000000..3ebab57e --- /dev/null +++ b/backend/src/main/java/com/unis/crm/config/InternalAuthProperties.java @@ -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; + } +} diff --git a/backend/src/main/java/com/unis/crm/config/InternalIntegrationSecurityConfig.java b/backend/src/main/java/com/unis/crm/config/InternalIntegrationSecurityConfig.java new file mode 100644 index 00000000..9a0071d2 --- /dev/null +++ b/backend/src/main/java/com/unis/crm/config/InternalIntegrationSecurityConfig.java @@ -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(); + } +} diff --git a/backend/src/main/java/com/unis/crm/config/OmsProperties.java b/backend/src/main/java/com/unis/crm/config/OmsProperties.java new file mode 100644 index 00000000..b28084d0 --- /dev/null +++ b/backend/src/main/java/com/unis/crm/config/OmsProperties.java @@ -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; + } +} diff --git a/backend/src/main/java/com/unis/crm/controller/ExpansionController.java b/backend/src/main/java/com/unis/crm/controller/ExpansionController.java index 1a62017e..bb387f0e 100644 --- a/backend/src/main/java/com/unis/crm/controller/ExpansionController.java +++ b/backend/src/main/java/com/unis/crm/controller/ExpansionController.java @@ -5,12 +5,14 @@ import com.unis.crm.common.CurrentUserUtils; import com.unis.crm.dto.expansion.CreateChannelExpansionRequest; import com.unis.crm.dto.expansion.CreateExpansionFollowUpRequest; import com.unis.crm.dto.expansion.CreateSalesExpansionRequest; +import com.unis.crm.dto.expansion.DictOptionDTO; import com.unis.crm.dto.expansion.ExpansionMetaDTO; import com.unis.crm.dto.expansion.ExpansionOverviewDTO; import com.unis.crm.dto.expansion.UpdateChannelExpansionRequest; import com.unis.crm.dto.expansion.UpdateSalesExpansionRequest; import com.unis.crm.service.ExpansionService; import jakarta.validation.Valid; +import java.util.List; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -37,6 +39,14 @@ public class ExpansionController { return ApiResponse.success(expansionService.getMeta()); } + @GetMapping("/areas/cities") + public ApiResponse> getCityOptions( + @RequestHeader("X-User-Id") Long userId, + @RequestParam("provinceName") String provinceName) { + CurrentUserUtils.requireCurrentUserId(userId); + return ApiResponse.success(expansionService.getCityOptions(provinceName)); + } + @GetMapping("/overview") public ApiResponse getOverview( @RequestHeader("X-User-Id") Long userId, diff --git a/backend/src/main/java/com/unis/crm/controller/OpportunityController.java b/backend/src/main/java/com/unis/crm/controller/OpportunityController.java index dfb2b873..51e5b6e6 100644 --- a/backend/src/main/java/com/unis/crm/controller/OpportunityController.java +++ b/backend/src/main/java/com/unis/crm/controller/OpportunityController.java @@ -4,10 +4,13 @@ import com.unis.crm.common.ApiResponse; import com.unis.crm.common.CurrentUserUtils; import com.unis.crm.dto.opportunity.CreateOpportunityFollowUpRequest; import com.unis.crm.dto.opportunity.CreateOpportunityRequest; +import com.unis.crm.dto.opportunity.OmsPreSalesOptionDTO; import com.unis.crm.dto.opportunity.OpportunityMetaDTO; import com.unis.crm.dto.opportunity.OpportunityOverviewDTO; +import com.unis.crm.dto.opportunity.PushOpportunityToOmsRequest; import com.unis.crm.service.OpportunityService; import jakarta.validation.Valid; +import java.util.List; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -41,6 +44,11 @@ public class OpportunityController { return ApiResponse.success(opportunityService.getOverview(CurrentUserUtils.requireCurrentUserId(userId), keyword, stage)); } + @GetMapping("/oms/pre-sales") + public ApiResponse> getOmsPreSalesOptions(@RequestHeader("X-User-Id") Long userId) { + return ApiResponse.success(opportunityService.getOmsPreSalesOptions(CurrentUserUtils.requireCurrentUserId(userId))); + } + @PostMapping public ApiResponse createOpportunity( @RequestHeader("X-User-Id") Long userId, @@ -59,8 +67,9 @@ public class OpportunityController { @PostMapping("/{opportunityId}/push-oms") public ApiResponse pushToOms( @RequestHeader("X-User-Id") Long userId, - @PathVariable("opportunityId") Long opportunityId) { - return ApiResponse.success(opportunityService.pushToOms(CurrentUserUtils.requireCurrentUserId(userId), opportunityId)); + @PathVariable("opportunityId") Long opportunityId, + @RequestBody(required = false) PushOpportunityToOmsRequest request) { + return ApiResponse.success(opportunityService.pushToOms(CurrentUserUtils.requireCurrentUserId(userId), opportunityId, request)); } @PostMapping("/{opportunityId}/followups") diff --git a/backend/src/main/java/com/unis/crm/controller/OpportunityIntegrationController.java b/backend/src/main/java/com/unis/crm/controller/OpportunityIntegrationController.java new file mode 100644 index 00000000..9f4312f6 --- /dev/null +++ b/backend/src/main/java/com/unis/crm/controller/OpportunityIntegrationController.java @@ -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 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; + } +} diff --git a/backend/src/main/java/com/unis/crm/dto/expansion/ChannelExpansionItemDTO.java b/backend/src/main/java/com/unis/crm/dto/expansion/ChannelExpansionItemDTO.java index 493298ae..9450fd5f 100644 --- a/backend/src/main/java/com/unis/crm/dto/expansion/ChannelExpansionItemDTO.java +++ b/backend/src/main/java/com/unis/crm/dto/expansion/ChannelExpansionItemDTO.java @@ -9,9 +9,14 @@ public class ChannelExpansionItemDTO { private String type; private String channelCode; private String name; + private String provinceCode; private String province; + private String cityCode; + private String city; private String officeAddress; + private String channelIndustryCode; private String channelIndustry; + private String certificationLevel; private String annualRevenue; private String revenue; private Integer size; @@ -71,6 +76,14 @@ public class ChannelExpansionItemDTO { return province; } + public String getProvinceCode() { + return provinceCode; + } + + public void setProvinceCode(String provinceCode) { + this.provinceCode = provinceCode; + } + public void setProvince(String province) { this.province = province; } @@ -79,6 +92,22 @@ public class ChannelExpansionItemDTO { return officeAddress; } + public String getCityCode() { + return cityCode; + } + + public void setCityCode(String cityCode) { + this.cityCode = cityCode; + } + + public String getCity() { + return city; + } + + public void setCity(String city) { + this.city = city; + } + public void setOfficeAddress(String officeAddress) { this.officeAddress = officeAddress; } @@ -87,10 +116,26 @@ public class ChannelExpansionItemDTO { return channelIndustry; } + public String getChannelIndustryCode() { + return channelIndustryCode; + } + + public void setChannelIndustryCode(String channelIndustryCode) { + this.channelIndustryCode = channelIndustryCode; + } + public void setChannelIndustry(String channelIndustry) { this.channelIndustry = channelIndustry; } + public String getCertificationLevel() { + return certificationLevel; + } + + public void setCertificationLevel(String certificationLevel) { + this.certificationLevel = certificationLevel; + } + public String getAnnualRevenue() { return annualRevenue; } diff --git a/backend/src/main/java/com/unis/crm/dto/expansion/CreateChannelExpansionRequest.java b/backend/src/main/java/com/unis/crm/dto/expansion/CreateChannelExpansionRequest.java index e72690c7..3b26753e 100644 --- a/backend/src/main/java/com/unis/crm/dto/expansion/CreateChannelExpansionRequest.java +++ b/backend/src/main/java/com/unis/crm/dto/expansion/CreateChannelExpansionRequest.java @@ -13,6 +13,8 @@ public class CreateChannelExpansionRequest { private String channelCode; private String officeAddress; private String channelIndustry; + private String city; + private String certificationLevel; @NotBlank(message = "渠道名称不能为空") @Size(max = 200, message = "渠道名称不能超过200字符") @@ -64,6 +66,22 @@ public class CreateChannelExpansionRequest { this.channelIndustry = channelIndustry; } + public String getCity() { + return city; + } + + public void setCity(String city) { + this.city = city; + } + + public String getCertificationLevel() { + return certificationLevel; + } + + public void setCertificationLevel(String certificationLevel) { + this.certificationLevel = certificationLevel; + } + public String getChannelName() { return channelName; } diff --git a/backend/src/main/java/com/unis/crm/dto/expansion/ExpansionMetaDTO.java b/backend/src/main/java/com/unis/crm/dto/expansion/ExpansionMetaDTO.java index 2cb275d5..0bae7749 100644 --- a/backend/src/main/java/com/unis/crm/dto/expansion/ExpansionMetaDTO.java +++ b/backend/src/main/java/com/unis/crm/dto/expansion/ExpansionMetaDTO.java @@ -6,6 +6,8 @@ public class ExpansionMetaDTO { private List officeOptions; private List industryOptions; + private List provinceOptions; + private List certificationLevelOptions; private List channelAttributeOptions; private List internalAttributeOptions; private String nextChannelCode; @@ -16,11 +18,15 @@ public class ExpansionMetaDTO { public ExpansionMetaDTO( List officeOptions, List industryOptions, + List provinceOptions, + List certificationLevelOptions, List channelAttributeOptions, List internalAttributeOptions, String nextChannelCode) { this.officeOptions = officeOptions; this.industryOptions = industryOptions; + this.provinceOptions = provinceOptions; + this.certificationLevelOptions = certificationLevelOptions; this.channelAttributeOptions = channelAttributeOptions; this.internalAttributeOptions = internalAttributeOptions; this.nextChannelCode = nextChannelCode; @@ -42,6 +48,22 @@ public class ExpansionMetaDTO { this.industryOptions = industryOptions; } + public List getProvinceOptions() { + return provinceOptions; + } + + public void setProvinceOptions(List provinceOptions) { + this.provinceOptions = provinceOptions; + } + + public List getCertificationLevelOptions() { + return certificationLevelOptions; + } + + public void setCertificationLevelOptions(List certificationLevelOptions) { + this.certificationLevelOptions = certificationLevelOptions; + } + public List getChannelAttributeOptions() { return channelAttributeOptions; } diff --git a/backend/src/main/java/com/unis/crm/dto/expansion/UpdateChannelExpansionRequest.java b/backend/src/main/java/com/unis/crm/dto/expansion/UpdateChannelExpansionRequest.java index da612412..ae0d0c5c 100644 --- a/backend/src/main/java/com/unis/crm/dto/expansion/UpdateChannelExpansionRequest.java +++ b/backend/src/main/java/com/unis/crm/dto/expansion/UpdateChannelExpansionRequest.java @@ -12,6 +12,8 @@ public class UpdateChannelExpansionRequest { private String channelCode; private String officeAddress; private String channelIndustry; + private String city; + private String certificationLevel; @NotBlank(message = "渠道名称不能为空") @Size(max = 200, message = "渠道名称不能超过200字符") @@ -55,6 +57,22 @@ public class UpdateChannelExpansionRequest { this.channelIndustry = channelIndustry; } + public String getCity() { + return city; + } + + public void setCity(String city) { + this.city = city; + } + + public String getCertificationLevel() { + return certificationLevel; + } + + public void setCertificationLevel(String certificationLevel) { + this.certificationLevel = certificationLevel; + } + public String getChannelName() { return channelName; } diff --git a/backend/src/main/java/com/unis/crm/dto/opportunity/CreateOpportunityRequest.java b/backend/src/main/java/com/unis/crm/dto/opportunity/CreateOpportunityRequest.java index bfc40e89..85e79600 100644 --- a/backend/src/main/java/com/unis/crm/dto/opportunity/CreateOpportunityRequest.java +++ b/backend/src/main/java/com/unis/crm/dto/opportunity/CreateOpportunityRequest.java @@ -1,9 +1,8 @@ package com.unis.crm.dto.opportunity; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; import java.math.BigDecimal; import java.time.LocalDate; @@ -31,10 +30,9 @@ public class CreateOpportunityRequest { private LocalDate expectedCloseDate; - @NotNull(message = "把握度不能为空") - @Min(value = 0, message = "把握度不能低于0") - @Max(value = 100, message = "把握度不能高于100") - private Integer confidencePct; + @NotBlank(message = "把握度不能为空") + @Pattern(regexp = "^(A|B|C|a|b|c|40|60|80)$", message = "把握度仅支持A、B、C") + private String confidencePct; private String stage; private String opportunityType; @@ -43,7 +41,6 @@ public class CreateOpportunityRequest { private Long salesExpansionId; private Long channelExpansionId; private String competitorName; - private Boolean pushedToOms; private String description; public Long getId() { @@ -102,11 +99,11 @@ public class CreateOpportunityRequest { this.expectedCloseDate = expectedCloseDate; } - public Integer getConfidencePct() { + public String getConfidencePct() { return confidencePct; } - public void setConfidencePct(Integer confidencePct) { + public void setConfidencePct(String confidencePct) { this.confidencePct = confidencePct; } @@ -166,14 +163,6 @@ public class CreateOpportunityRequest { this.competitorName = competitorName; } - public Boolean getPushedToOms() { - return pushedToOms; - } - - public void setPushedToOms(Boolean pushedToOms) { - this.pushedToOms = pushedToOms; - } - public String getDescription() { return description; } diff --git a/backend/src/main/java/com/unis/crm/dto/opportunity/CurrentUserAccountDTO.java b/backend/src/main/java/com/unis/crm/dto/opportunity/CurrentUserAccountDTO.java new file mode 100644 index 00000000..8a33a209 --- /dev/null +++ b/backend/src/main/java/com/unis/crm/dto/opportunity/CurrentUserAccountDTO.java @@ -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; + } +} diff --git a/backend/src/main/java/com/unis/crm/dto/opportunity/OmsPreSalesOptionDTO.java b/backend/src/main/java/com/unis/crm/dto/opportunity/OmsPreSalesOptionDTO.java new file mode 100644 index 00000000..48b6d3e4 --- /dev/null +++ b/backend/src/main/java/com/unis/crm/dto/opportunity/OmsPreSalesOptionDTO.java @@ -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; + } +} diff --git a/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityIntegrationTargetDTO.java b/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityIntegrationTargetDTO.java new file mode 100644 index 00000000..9cbbc943 --- /dev/null +++ b/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityIntegrationTargetDTO.java @@ -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; + } +} diff --git a/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityItemDTO.java b/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityItemDTO.java index fc63c938..88b7d2f2 100644 --- a/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityItemDTO.java +++ b/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityItemDTO.java @@ -16,7 +16,7 @@ public class OpportunityItemDTO { private String operatorName; private BigDecimal amount; private String date; - private Integer confidence; + private String confidence; private String stageCode; private String stage; private String type; @@ -28,6 +28,8 @@ public class OpportunityItemDTO { private String salesExpansionName; private Long channelExpansionId; private String channelExpansionName; + private Long preSalesId; + private String preSalesName; private String competitorName; private String latestProgress; private String nextPlan; @@ -114,11 +116,11 @@ public class OpportunityItemDTO { this.date = date; } - public Integer getConfidence() { + public String getConfidence() { return confidence; } - public void setConfidence(Integer confidence) { + public void setConfidence(String confidence) { this.confidence = confidence; } @@ -210,6 +212,22 @@ public class OpportunityItemDTO { this.channelExpansionName = channelExpansionName; } + public Long getPreSalesId() { + return preSalesId; + } + + public void setPreSalesId(Long preSalesId) { + this.preSalesId = preSalesId; + } + + public String getPreSalesName() { + return preSalesName; + } + + public void setPreSalesName(String preSalesName) { + this.preSalesName = preSalesName; + } + public String getCompetitorName() { return competitorName; } diff --git a/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityMetaDTO.java b/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityMetaDTO.java index 311e212f..1a31aba1 100644 --- a/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityMetaDTO.java +++ b/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityMetaDTO.java @@ -6,13 +6,18 @@ public class OpportunityMetaDTO { private List stageOptions; private List operatorOptions; + private List projectLocationOptions; public OpportunityMetaDTO() { } - public OpportunityMetaDTO(List stageOptions, List operatorOptions) { + public OpportunityMetaDTO( + List stageOptions, + List operatorOptions, + List projectLocationOptions) { this.stageOptions = stageOptions; this.operatorOptions = operatorOptions; + this.projectLocationOptions = projectLocationOptions; } public List getStageOptions() { @@ -30,4 +35,12 @@ public class OpportunityMetaDTO { public void setOperatorOptions(List operatorOptions) { this.operatorOptions = operatorOptions; } + + public List getProjectLocationOptions() { + return projectLocationOptions; + } + + public void setProjectLocationOptions(List projectLocationOptions) { + this.projectLocationOptions = projectLocationOptions; + } } diff --git a/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityOmsPushDataDTO.java b/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityOmsPushDataDTO.java new file mode 100644 index 00000000..74f7f7d4 --- /dev/null +++ b/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityOmsPushDataDTO.java @@ -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; + } +} diff --git a/backend/src/main/java/com/unis/crm/dto/opportunity/PushOpportunityToOmsRequest.java b/backend/src/main/java/com/unis/crm/dto/opportunity/PushOpportunityToOmsRequest.java new file mode 100644 index 00000000..e56924bb --- /dev/null +++ b/backend/src/main/java/com/unis/crm/dto/opportunity/PushOpportunityToOmsRequest.java @@ -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; + } +} diff --git a/backend/src/main/java/com/unis/crm/dto/opportunity/UpdateOpportunityIntegrationRequest.java b/backend/src/main/java/com/unis/crm/dto/opportunity/UpdateOpportunityIntegrationRequest.java new file mode 100644 index 00000000..a05bf048 --- /dev/null +++ b/backend/src/main/java/com/unis/crm/dto/opportunity/UpdateOpportunityIntegrationRequest.java @@ -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; + } +} diff --git a/backend/src/main/java/com/unis/crm/mapper/ExpansionMapper.java b/backend/src/main/java/com/unis/crm/mapper/ExpansionMapper.java index b38d7173..90f11a94 100644 --- a/backend/src/main/java/com/unis/crm/mapper/ExpansionMapper.java +++ b/backend/src/main/java/com/unis/crm/mapper/ExpansionMapper.java @@ -23,6 +23,10 @@ public interface ExpansionMapper { List selectDictItems(@Param("typeCode") String typeCode); + List selectProvinceAreaOptions(); + + List selectCityAreaOptionsByProvinceName(@Param("provinceName") String provinceName); + String selectNextChannelCode(); @DataScope(tableAlias = "s", ownerColumn = "owner_user_id") diff --git a/backend/src/main/java/com/unis/crm/mapper/OpportunityMapper.java b/backend/src/main/java/com/unis/crm/mapper/OpportunityMapper.java index 86f94df1..1667eb5d 100644 --- a/backend/src/main/java/com/unis/crm/mapper/OpportunityMapper.java +++ b/backend/src/main/java/com/unis/crm/mapper/OpportunityMapper.java @@ -2,9 +2,13 @@ package com.unis.crm.mapper; import com.unis.crm.dto.opportunity.CreateOpportunityFollowUpRequest; import com.unis.crm.dto.opportunity.CreateOpportunityRequest; +import com.unis.crm.dto.opportunity.CurrentUserAccountDTO; import com.unis.crm.dto.opportunity.OpportunityDictOptionDTO; import com.unis.crm.dto.opportunity.OpportunityFollowUpDTO; +import com.unis.crm.dto.opportunity.OpportunityIntegrationTargetDTO; import com.unis.crm.dto.opportunity.OpportunityItemDTO; +import com.unis.crm.dto.opportunity.OpportunityOmsPushDataDTO; +import com.unis.crm.dto.opportunity.UpdateOpportunityIntegrationRequest; import java.util.List; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; @@ -15,6 +19,8 @@ public interface OpportunityMapper { List selectDictItems(@Param("typeCode") String typeCode); + List selectProvinceAreaOptions(); + String selectDictLabel( @Param("typeCode") String typeCode, @Param("itemValue") String itemValue); @@ -44,12 +50,32 @@ public interface OpportunityMapper { @Param("customerId") Long customerId, @Param("request") CreateOpportunityRequest request); + @DataScope(tableAlias = "o", ownerColumn = "owner_user_id") + int updateOpportunityCode( + @Param("userId") Long userId, + @Param("opportunityId") Long opportunityId, + @Param("opportunityCode") String opportunityCode); + @DataScope(tableAlias = "o", ownerColumn = "owner_user_id") int countOwnedOpportunity(@Param("userId") Long userId, @Param("id") Long id); @DataScope(tableAlias = "o", ownerColumn = "owner_user_id") Boolean selectPushedToOms(@Param("userId") Long userId, @Param("id") Long id); + @DataScope(tableAlias = "o", ownerColumn = "owner_user_id") + OpportunityOmsPushDataDTO selectOpportunityOmsPushData( + @Param("userId") Long userId, + @Param("opportunityId") Long opportunityId); + + CurrentUserAccountDTO selectCurrentUserAccount(@Param("userId") Long userId); + + @DataScope(tableAlias = "o", ownerColumn = "owner_user_id") + int updateOpportunityPreSales( + @Param("userId") Long userId, + @Param("opportunityId") Long opportunityId, + @Param("preSalesId") Long preSalesId, + @Param("preSalesName") String preSalesName); + @DataScope(tableAlias = "o", ownerColumn = "owner_user_id") int updateOpportunity( @Param("userId") Long userId, @@ -57,10 +83,18 @@ public interface OpportunityMapper { @Param("customerId") Long customerId, @Param("request") CreateOpportunityRequest request); + OpportunityIntegrationTargetDTO selectOpportunityIntegrationTarget( + @Param("opportunityCode") String opportunityCode); + + int updateOpportunityByIntegration( + @Param("opportunityId") Long opportunityId, + @Param("request") UpdateOpportunityIntegrationRequest request); + @DataScope(tableAlias = "o", ownerColumn = "owner_user_id") - int pushOpportunityToOms( + int markOpportunityOmsPushed( @Param("userId") Long userId, - @Param("opportunityId") Long opportunityId); + @Param("opportunityId") Long opportunityId, + @Param("opportunityCode") String opportunityCode); int insertOpportunityFollowUp( @Param("userId") Long userId, diff --git a/backend/src/main/java/com/unis/crm/service/ExpansionService.java b/backend/src/main/java/com/unis/crm/service/ExpansionService.java index 6be1f84c..bd269511 100644 --- a/backend/src/main/java/com/unis/crm/service/ExpansionService.java +++ b/backend/src/main/java/com/unis/crm/service/ExpansionService.java @@ -3,15 +3,19 @@ package com.unis.crm.service; import com.unis.crm.dto.expansion.CreateChannelExpansionRequest; import com.unis.crm.dto.expansion.CreateExpansionFollowUpRequest; import com.unis.crm.dto.expansion.CreateSalesExpansionRequest; +import com.unis.crm.dto.expansion.DictOptionDTO; import com.unis.crm.dto.expansion.ExpansionMetaDTO; import com.unis.crm.dto.expansion.ExpansionOverviewDTO; import com.unis.crm.dto.expansion.UpdateChannelExpansionRequest; import com.unis.crm.dto.expansion.UpdateSalesExpansionRequest; +import java.util.List; public interface ExpansionService { ExpansionMetaDTO getMeta(); + List getCityOptions(String provinceName); + ExpansionOverviewDTO getOverview(Long userId, String keyword); Long createSalesExpansion(Long userId, CreateSalesExpansionRequest request); diff --git a/backend/src/main/java/com/unis/crm/service/OmsClient.java b/backend/src/main/java/com/unis/crm/service/OmsClient.java new file mode 100644 index 00000000..3ed771f8 --- /dev/null +++ b/backend/src/main/java/com/unis/crm/service/OmsClient.java @@ -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 SUCCESS_CODES = Set.of("0", "200", "success", "SUCCESS"); + private static final List 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 listPreSalesUsers() { + JsonNode data = sendGet(omsProperties.getUserInfoPath(), Map.of( + "userCode", "", + "roleName", defaultText(omsProperties.getPreSalesRoleName(), "售前"))); + List 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 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 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 buildPartner(OpportunityOmsPushDataDTO opportunity) { + Map 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 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 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 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 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 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 splitMultiValue(String rawValue) { + List 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; + } +} diff --git a/backend/src/main/java/com/unis/crm/service/OpportunityService.java b/backend/src/main/java/com/unis/crm/service/OpportunityService.java index b73a0c2d..10ed11dc 100644 --- a/backend/src/main/java/com/unis/crm/service/OpportunityService.java +++ b/backend/src/main/java/com/unis/crm/service/OpportunityService.java @@ -2,8 +2,12 @@ package com.unis.crm.service; import com.unis.crm.dto.opportunity.CreateOpportunityFollowUpRequest; import com.unis.crm.dto.opportunity.CreateOpportunityRequest; +import com.unis.crm.dto.opportunity.OmsPreSalesOptionDTO; import com.unis.crm.dto.opportunity.OpportunityMetaDTO; import com.unis.crm.dto.opportunity.OpportunityOverviewDTO; +import com.unis.crm.dto.opportunity.PushOpportunityToOmsRequest; +import com.unis.crm.dto.opportunity.UpdateOpportunityIntegrationRequest; +import java.util.List; public interface OpportunityService { @@ -11,11 +15,15 @@ public interface OpportunityService { OpportunityOverviewDTO getOverview(Long userId, String keyword, String stage); + List getOmsPreSalesOptions(Long userId); + Long createOpportunity(Long userId, CreateOpportunityRequest request); Long updateOpportunity(Long userId, Long opportunityId, CreateOpportunityRequest request); - Long pushToOms(Long userId, Long opportunityId); + Long updateOpportunityByIntegration(UpdateOpportunityIntegrationRequest request); + + Long pushToOms(Long userId, Long opportunityId, PushOpportunityToOmsRequest request); Long createFollowUp(Long userId, Long opportunityId, CreateOpportunityFollowUpRequest request); } diff --git a/backend/src/main/java/com/unis/crm/service/impl/ExpansionServiceImpl.java b/backend/src/main/java/com/unis/crm/service/impl/ExpansionServiceImpl.java index 145f7371..f3413917 100644 --- a/backend/src/main/java/com/unis/crm/service/impl/ExpansionServiceImpl.java +++ b/backend/src/main/java/com/unis/crm/service/impl/ExpansionServiceImpl.java @@ -38,6 +38,7 @@ public class ExpansionServiceImpl implements ExpansionService { private static final String OFFICE_TYPE_CODE = "tz_bsc"; private static final String INDUSTRY_TYPE_CODE = "tz_sshy"; + private static final String CERTIFICATION_LEVEL_TYPE_CODE = "tz_rzjb"; private static final String CHANNEL_ATTRIBUTE_TYPE_CODE = "tz_qdsx"; private static final String INTERNAL_ATTRIBUTE_TYPE_CODE = "tz_xhsnbsx"; private static final String MULTI_VALUE_CUSTOM_PREFIX = "__custom__:"; @@ -56,6 +57,8 @@ public class ExpansionServiceImpl implements ExpansionService { return new ExpansionMetaDTO( loadDictOptions(OFFICE_TYPE_CODE), loadDictOptions(INDUSTRY_TYPE_CODE), + expansionMapper.selectProvinceAreaOptions(), + loadDictOptions(CERTIFICATION_LEVEL_TYPE_CODE), loadDictOptions(CHANNEL_ATTRIBUTE_TYPE_CODE), loadDictOptions(INTERNAL_ATTRIBUTE_TYPE_CODE), expansionMapper.selectNextChannelCode()); @@ -66,10 +69,20 @@ public class ExpansionServiceImpl implements ExpansionService { Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), + Collections.emptyList(), + Collections.emptyList(), null); } } + @Override + public List getCityOptions(String provinceName) { + if (isBlank(provinceName)) { + return Collections.emptyList(); + } + return expansionMapper.selectCityAreaOptionsByProvinceName(provinceName.trim()); + } + @Override public ExpansionOverviewDTO getOverview(Long userId, String keyword) { String normalizedKeyword = normalizeKeyword(keyword); @@ -81,7 +94,7 @@ public class ExpansionServiceImpl implements ExpansionService { attachChannelFollowUps(userId, channelItems); attachChannelContacts(userId, channelItems); attachChannelRelatedProjects(userId, channelItems); - fillChannelAttributeDisplay(channelItems); + fillChannelDisplayFields(channelItems); return new ExpansionOverviewDTO(salesItems, channelItems); } @@ -258,15 +271,19 @@ public class ExpansionServiceImpl implements ExpansionService { return expansionMapper.selectDictItems(typeCode); } - private void fillChannelAttributeDisplay(List channelItems) { + private void fillChannelDisplayFields(List channelItems) { if (channelItems == null || channelItems.isEmpty()) { return; } + Map industryLabels = toDictLabelMap(loadDictOptions(INDUSTRY_TYPE_CODE)); + Map certificationLevelLabels = toDictLabelMap(loadDictOptions(CERTIFICATION_LEVEL_TYPE_CODE)); Map channelAttributeLabels = toDictLabelMap(loadDictOptions(CHANNEL_ATTRIBUTE_TYPE_CODE)); Map internalAttributeLabels = toDictLabelMap(loadDictOptions(INTERNAL_ATTRIBUTE_TYPE_CODE)); for (ChannelExpansionItemDTO item : channelItems) { + item.setChannelIndustry(formatMultiValueDisplay(item.getChannelIndustryCode(), industryLabels)); + item.setCertificationLevel(formatSingleValueDisplay(item.getCertificationLevel(), certificationLevelLabels)); item.setChannelAttribute(formatMultiValueDisplay(item.getChannelAttributeCode(), channelAttributeLabels)); item.setInternalAttribute(formatMultiValueDisplay(item.getInternalAttributeCode(), internalAttributeLabels)); } @@ -319,6 +336,13 @@ public class ExpansionServiceImpl implements ExpansionService { return uniqueValues.isEmpty() ? "无" : String.join("、", uniqueValues); } + private String formatSingleValueDisplay(String rawValue, Map labelMap) { + if (isBlank(rawValue)) { + return "无"; + } + return labelMap.getOrDefault(rawValue.trim(), rawValue.trim()); + } + private String decodeCustomText(String rawValue) { if (isBlank(rawValue)) { return ""; @@ -405,9 +429,11 @@ public class ExpansionServiceImpl implements ExpansionService { private void fillChannelDefaults(CreateChannelExpansionRequest request) { request.setChannelName(normalizeRequiredText(request.getChannelName(), "渠道名称不能为空")); - request.setProvince(normalizeRequiredText(request.getProvince(), "请填写省份")); + request.setProvince(normalizeRequiredText(request.getProvince(), "请选择省份")); + request.setCity(normalizeRequiredText(request.getCity(), "请选择市")); + request.setCertificationLevel(normalizeRequiredText(request.getCertificationLevel(), "请选择认证级别")); request.setOfficeAddress(normalizeRequiredText(request.getOfficeAddress(), "请填写办公地址")); - request.setChannelIndustry(normalizeRequiredText(request.getChannelIndustry(), "请填写聚焦行业")); + request.setChannelIndustry(normalizeRequiredText(request.getChannelIndustry(), "请选择聚焦行业")); request.setAnnualRevenue(requirePositiveAmount(request.getAnnualRevenue(), "请填写年营收")); request.setStaffSize(requirePositiveInteger(request.getStaffSize(), "请填写人员规模")); if (request.getContactEstablishedDate() == null) { @@ -432,9 +458,11 @@ public class ExpansionServiceImpl implements ExpansionService { private void fillChannelDefaults(UpdateChannelExpansionRequest request) { request.setChannelName(normalizeRequiredText(request.getChannelName(), "渠道名称不能为空")); - request.setProvince(normalizeRequiredText(request.getProvince(), "请填写省份")); + request.setProvince(normalizeRequiredText(request.getProvince(), "请选择省份")); + request.setCity(normalizeRequiredText(request.getCity(), "请选择市")); + request.setCertificationLevel(normalizeRequiredText(request.getCertificationLevel(), "请选择认证级别")); request.setOfficeAddress(normalizeRequiredText(request.getOfficeAddress(), "请填写办公地址")); - request.setChannelIndustry(normalizeRequiredText(request.getChannelIndustry(), "请填写聚焦行业")); + request.setChannelIndustry(normalizeRequiredText(request.getChannelIndustry(), "请选择聚焦行业")); request.setAnnualRevenue(requirePositiveAmount(request.getAnnualRevenue(), "请填写年营收")); request.setStaffSize(requirePositiveInteger(request.getStaffSize(), "请填写人员规模")); if (request.getContactEstablishedDate() == null) { @@ -534,6 +562,10 @@ public class ExpansionServiceImpl implements ExpansionService { return trimmed.isEmpty() ? null : trimmed; } + private String normalizeOptionalText(String value) { + return trimToNull(value); + } + private BigDecimal requirePositiveAmount(BigDecimal value, String message) { if (value == null || value.compareTo(BigDecimal.ZERO) <= 0) { throw new BusinessException(message); diff --git a/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java b/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java index 4c5210ee..244217b9 100644 --- a/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java +++ b/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java @@ -2,13 +2,20 @@ package com.unis.crm.service.impl; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.unis.crm.common.BusinessException; +import com.unis.crm.dto.opportunity.CurrentUserAccountDTO; import com.unis.crm.dto.opportunity.CreateOpportunityFollowUpRequest; import com.unis.crm.dto.opportunity.CreateOpportunityRequest; +import com.unis.crm.dto.opportunity.OmsPreSalesOptionDTO; import com.unis.crm.dto.opportunity.OpportunityMetaDTO; import com.unis.crm.dto.opportunity.OpportunityFollowUpDTO; +import com.unis.crm.dto.opportunity.OpportunityIntegrationTargetDTO; import com.unis.crm.dto.opportunity.OpportunityItemDTO; +import com.unis.crm.dto.opportunity.OpportunityOmsPushDataDTO; import com.unis.crm.dto.opportunity.OpportunityOverviewDTO; +import com.unis.crm.dto.opportunity.PushOpportunityToOmsRequest; +import com.unis.crm.dto.opportunity.UpdateOpportunityIntegrationRequest; import com.unis.crm.mapper.OpportunityMapper; +import com.unis.crm.service.OmsClient; import com.unis.crm.service.OpportunityService; import java.math.BigDecimal; import java.util.Collections; @@ -16,25 +23,32 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service public class OpportunityServiceImpl implements OpportunityService { private static final String STAGE_TYPE_CODE = "sj_xmjd"; private static final String OPERATOR_TYPE_CODE = "sj_yzf"; + private static final Logger log = LoggerFactory.getLogger(OpportunityServiceImpl.class); private final OpportunityMapper opportunityMapper; + private final OmsClient omsClient; - public OpportunityServiceImpl(OpportunityMapper opportunityMapper) { + public OpportunityServiceImpl(OpportunityMapper opportunityMapper, OmsClient omsClient) { this.opportunityMapper = opportunityMapper; + this.omsClient = omsClient; } @Override public OpportunityMetaDTO getMeta() { return new OpportunityMetaDTO( opportunityMapper.selectDictItems(STAGE_TYPE_CODE), - opportunityMapper.selectDictItems(OPERATOR_TYPE_CODE)); + opportunityMapper.selectDictItems(OPERATOR_TYPE_CODE), + opportunityMapper.selectProvinceAreaOptions()); } @Override @@ -47,6 +61,15 @@ public class OpportunityServiceImpl implements OpportunityService { } @Override + public List getOmsPreSalesOptions(Long userId) { + if (userId == null || userId <= 0) { + throw new BusinessException("登录用户不存在"); + } + return omsClient.listPreSalesUsers(); + } + + @Override + @Transactional public Long createOpportunity(Long userId, CreateOpportunityRequest request) { fillDefaults(request); Long customerId = opportunityMapper.selectOwnedCustomerIdByName(userId, request.getCustomerName().trim()); @@ -59,10 +82,13 @@ public class OpportunityServiceImpl implements OpportunityService { if (request.getId() == null) { throw new BusinessException("商机新增失败"); } + + syncCreatedOpportunityCodeStrict(userId, request.getId()); return request.getId(); } @Override + @Transactional public Long updateOpportunity(Long userId, Long opportunityId, CreateOpportunityRequest request) { if (opportunityId == null || opportunityId <= 0) { throw new BusinessException("商机不存在"); @@ -70,9 +96,6 @@ public class OpportunityServiceImpl implements OpportunityService { if (opportunityMapper.countOwnedOpportunity(userId, opportunityId) <= 0) { throw new BusinessException("无权编辑该商机"); } - if (Boolean.TRUE.equals(opportunityMapper.selectPushedToOms(userId, opportunityId))) { - throw new BusinessException("该商机已推送 OMS,不能再编辑"); - } fillDefaults(request); Long customerId = opportunityMapper.selectOwnedCustomerIdByName(userId, request.getCustomerName().trim()); @@ -85,11 +108,26 @@ public class OpportunityServiceImpl implements OpportunityService { if (updated <= 0) { throw new BusinessException("商机更新失败"); } + + syncUpdatedOpportunityCodeBestEffort(userId, opportunityId); return opportunityId; } @Override - public Long pushToOms(Long userId, Long opportunityId) { + @Transactional + public Long updateOpportunityByIntegration(UpdateOpportunityIntegrationRequest request) { + OpportunityIntegrationTargetDTO target = requireOpportunityIntegrationTarget(request); + normalizeIntegrationUpdateRequest(request, target); + + int updated = opportunityMapper.updateOpportunityByIntegration(target.getId(), request); + if (updated <= 0) { + throw new BusinessException("商机更新失败"); + } + return target.getId(); + } + + @Override + public Long pushToOms(Long userId, Long opportunityId, PushOpportunityToOmsRequest request) { if (opportunityId == null || opportunityId <= 0) { throw new BusinessException("商机不存在"); } @@ -99,7 +137,37 @@ public class OpportunityServiceImpl implements OpportunityService { if (Boolean.TRUE.equals(opportunityMapper.selectPushedToOms(userId, opportunityId))) { throw new BusinessException("该商机已推送 OMS,请勿重复操作"); } - int updated = opportunityMapper.pushOpportunityToOms(userId, opportunityId); + + OpportunityOmsPushDataDTO pushData = opportunityMapper.selectOpportunityOmsPushData(userId, opportunityId); + if (pushData == null) { + throw new BusinessException("未找到商机推送数据"); + } + validatePushBaseData(pushData); + + OmsPreSalesOptionDTO selectedPreSales = resolveSelectedPreSales(pushData, request, omsClient.listPreSalesUsers()); + int preSalesUpdated = opportunityMapper.updateOpportunityPreSales( + userId, + opportunityId, + selectedPreSales.getUserId(), + selectedPreSales.getUserName()); + if (preSalesUpdated <= 0) { + throw new BusinessException("保存售前信息失败"); + } + pushData.setPreSalesId(selectedPreSales.getUserId()); + pushData.setPreSalesName(selectedPreSales.getUserName()); + + CurrentUserAccountDTO currentUser = requireCurrentUserAccount(userId); + OmsPreSalesOptionDTO currentOmsUser = omsClient.ensureUserExists( + currentUser.getUsername().trim(), + currentUser.getDisplayName().trim()); + String existingOpportunityCode = normalizeOpportunityCode(pushData.getOpportunityCode()); + String returnedOpportunityCode = omsClient.createProject( + pushData, + resolveOmsCreateBy(currentOmsUser), + existingOpportunityCode); + String targetOpportunityCode = resolveOpportunityCodeForUpdate(existingOpportunityCode, returnedOpportunityCode); + + int updated = opportunityMapper.markOpportunityOmsPushed(userId, opportunityId, targetOpportunityCode); if (updated <= 0) { throw new BusinessException("推送 OMS 失败"); } @@ -229,16 +297,14 @@ public class OpportunityServiceImpl implements OpportunityService { private void fillDefaults(CreateOpportunityRequest request) { request.setCustomerName(normalizeRequiredText(request.getCustomerName(), "最终客户不能为空")); request.setOpportunityName(normalizeRequiredText(request.getOpportunityName(), "项目名称不能为空")); - request.setProjectLocation(normalizeRequiredText(request.getProjectLocation(), "请填写项目地")); + request.setProjectLocation(normalizeRequiredText(request.getProjectLocation(), "请选择项目地")); request.setOperatorName(normalizeRequiredText(request.getOperatorName(), "请选择运作方")); request.setAmount(requirePositiveAmount(request.getAmount(), "请填写预计金额")); request.setDescription(normalizeOptionalText(request.getDescription())); if (request.getExpectedCloseDate() == null) { throw new BusinessException("请选择预计下单时间"); } - if (request.getConfidencePct() == null || request.getConfidencePct() <= 0) { - throw new BusinessException("请选择项目把握度"); - } + request.setConfidencePct(normalizeConfidenceGrade(request.getConfidencePct(), "请选择项目把握度")); if (isBlank(request.getStage())) { throw new BusinessException("请选择项目阶段"); } @@ -253,16 +319,100 @@ public class OpportunityServiceImpl implements OpportunityService { if (isBlank(request.getSource())) { request.setSource("主动开发"); } - if (request.getPushedToOms() == null) { - request.setPushedToOms(Boolean.FALSE); - } - if (request.getConfidencePct() == null) { - request.setConfidencePct(50); - } request.setCompetitorName(normalizeRequiredText(request.getCompetitorName(), "请选择竞争对手")); validateOperatorRelations(request.getOperatorName(), request.getSalesExpansionId(), request.getChannelExpansionId()); } + private CurrentUserAccountDTO requireCurrentUserAccount(Long userId) { + CurrentUserAccountDTO currentUser = opportunityMapper.selectCurrentUserAccount(userId); + if (currentUser == null || isBlank(currentUser.getUsername())) { + throw new BusinessException("未找到当前登录用户账号"); + } + if (isBlank(currentUser.getDisplayName())) { + currentUser.setDisplayName(currentUser.getUsername()); + } + return currentUser; + } + + private String resolveOmsCreateBy(OmsPreSalesOptionDTO omsUser) { + if (omsUser == null || omsUser.getUserId() == null) { + return null; + } + return String.valueOf(omsUser.getUserId()); + } + + private void syncCreatedOpportunityCodeBestEffort(Long userId, Long opportunityId) { + try { + OpportunityOmsPushDataDTO pushData = opportunityMapper.selectOpportunityOmsPushData(userId, opportunityId); + if (pushData == null) { + log.warn("Skip OMS create sync because push data is missing, opportunityId={}", opportunityId); + return; + } + validatePushBaseData(pushData); + + CurrentUserAccountDTO currentUser = requireCurrentUserAccount(userId); + OmsPreSalesOptionDTO currentOmsUser = omsClient.ensureUserExists( + currentUser.getUsername().trim(), + currentUser.getDisplayName().trim()); + String opportunityCode = omsClient.createProject(pushData, resolveOmsCreateBy(currentOmsUser)); + int codeUpdated = opportunityMapper.updateOpportunityCode(userId, opportunityId, opportunityCode); + if (codeUpdated <= 0) { + log.warn("OMS create sync succeeded but failed to persist opportunity code, opportunityId={}", opportunityId); + } + } catch (BusinessException exception) { + log.warn("Create opportunity OMS sync failed, opportunityId={}, reason={}", opportunityId, exception.getMessage()); + } + } + + private void syncCreatedOpportunityCodeStrict(Long userId, Long opportunityId) { + OpportunityOmsPushDataDTO pushData = opportunityMapper.selectOpportunityOmsPushData(userId, opportunityId); + if (pushData == null) { + throw new BusinessException("未找到商机同步数据"); + } + validatePushBaseData(pushData); + + CurrentUserAccountDTO currentUser = requireCurrentUserAccount(userId); + OmsPreSalesOptionDTO currentOmsUser = omsClient.ensureUserExists( + currentUser.getUsername().trim(), + currentUser.getDisplayName().trim()); + String opportunityCode = omsClient.createProject(pushData, resolveOmsCreateBy(currentOmsUser)); + int codeUpdated = opportunityMapper.updateOpportunityCode(userId, opportunityId, opportunityCode); + if (codeUpdated <= 0) { + throw new BusinessException("保存商机编号失败"); + } + } + + private void syncUpdatedOpportunityCodeBestEffort(Long userId, Long opportunityId) { + try { + OpportunityOmsPushDataDTO pushData = opportunityMapper.selectOpportunityOmsPushData(userId, opportunityId); + if (pushData == null) { + log.warn("Skip OMS update sync because push data is missing, opportunityId={}", opportunityId); + return; + } + validatePushBaseData(pushData); + + CurrentUserAccountDTO currentUser = requireCurrentUserAccount(userId); + OmsPreSalesOptionDTO currentOmsUser = omsClient.ensureUserExists( + currentUser.getUsername().trim(), + currentUser.getDisplayName().trim()); + String existingOpportunityCode = normalizeOpportunityCode(pushData.getOpportunityCode()); + String returnedOpportunityCode = omsClient.createProject( + pushData, + resolveOmsCreateBy(currentOmsUser), + existingOpportunityCode); + String targetOpportunityCode = resolveOpportunityCodeForUpdate(existingOpportunityCode, returnedOpportunityCode); + + if (!Objects.equals(existingOpportunityCode, targetOpportunityCode)) { + int codeUpdated = opportunityMapper.updateOpportunityCode(userId, opportunityId, targetOpportunityCode); + if (codeUpdated <= 0) { + log.warn("OMS update sync succeeded but failed to persist opportunity code, opportunityId={}", opportunityId); + } + } + } catch (BusinessException exception) { + log.warn("Update opportunity OMS sync failed, opportunityId={}, reason={}", opportunityId, exception.getMessage()); + } + } + private String normalizeKeyword(String keyword) { if (keyword == null) { return null; @@ -305,6 +455,43 @@ public class OpportunityServiceImpl implements OpportunityService { return trimmed.isEmpty() ? null : trimmed; } + private String normalizeOpportunityCode(String value) { + return normalizeOptionalText(value); + } + + private String resolveOpportunityCodeForUpdate(String existingOpportunityCode, String returnedOpportunityCode) { + String normalizedExisting = normalizeOpportunityCode(existingOpportunityCode); + if (normalizedExisting != null && !normalizedExisting.toUpperCase().startsWith("OPP-")) { + return normalizedExisting; + } + return normalizeOpportunityCode(returnedOpportunityCode); + } + + private String normalizeConfidenceGrade(String value, String blankMessage) { + String normalized = normalizeOptionalText(value); + if (normalized == null) { + throw new BusinessException(blankMessage); + } + + String upper = normalized.toUpperCase(); + if ("A".equals(upper) || "B".equals(upper) || "C".equals(upper)) { + return upper; + } + + if (normalized.matches("\\d+(\\.\\d+)?")) { + double numericValue = Double.parseDouble(normalized); + if (numericValue >= 80) { + return "A"; + } + if (numericValue >= 60) { + return "B"; + } + return "C"; + } + + throw new BusinessException("项目把握度仅支持A、B、C"); + } + private BigDecimal requirePositiveAmount(BigDecimal value, String message) { if (value == null || value.compareTo(BigDecimal.ZERO) <= 0) { throw new BusinessException(message); @@ -324,6 +511,150 @@ public class OpportunityServiceImpl implements OpportunityService { return null; } + private OpportunityIntegrationTargetDTO requireOpportunityIntegrationTarget(UpdateOpportunityIntegrationRequest request) { + if (request == null) { + throw new BusinessException("请求参数不能为空"); + } + + String normalizedOpportunityCode = trimToNull(request.getOpportunityCode()); + request.setOpportunityCode(normalizedOpportunityCode); + + OpportunityIntegrationTargetDTO target = opportunityMapper.selectOpportunityIntegrationTarget(normalizedOpportunityCode); + if (target == null || target.getId() == null) { + throw new BusinessException("商机不存在"); + } + return target; + } + + private void normalizeIntegrationUpdateRequest( + UpdateOpportunityIntegrationRequest request, + OpportunityIntegrationTargetDTO target) { + if (request.getOpportunityName() != null) { + request.setOpportunityName(normalizeRequiredText(request.getOpportunityName(), "商机名称不能为空")); + } + if (request.getProjectLocation() != null) { + request.setProjectLocation(normalizeRequiredText(request.getProjectLocation(), "项目地不能为空")); + } + if (request.getOperatorName() != null) { + request.setOperatorName(normalizeRequiredText(request.getOperatorName(), "运作方不能为空")); + } + if (request.getAmount() != null) { + request.setAmount(requirePositiveAmount(request.getAmount(), "请填写预计金额")); + } + if (request.getConfidencePct() != null) { + request.setConfidencePct(normalizeConfidenceGrade(request.getConfidencePct(), "请选择项目把握度")); + } + if (request.getSalesExpansionId() != null && request.getSalesExpansionId() <= 0) { + throw new BusinessException("salesExpansionId 必须大于0"); + } + if (request.getChannelExpansionId() != null && request.getChannelExpansionId() <= 0) { + throw new BusinessException("channelExpansionId 必须大于0"); + } + if (request.getPreSalesId() != null && request.getPreSalesId() <= 0) { + throw new BusinessException("preSalesId 必须大于0"); + } + if (request.getStage() != null) { + request.setStage(normalizeStageValue(request.getStage())); + } + if (request.getOpportunityType() != null) { + request.setOpportunityType(normalizeRequiredText(request.getOpportunityType(), "建设类型不能为空")); + } + if (request.getProductType() != null) { + request.setProductType(trimToEmpty(request.getProductType())); + } + if (request.getSource() != null) { + request.setSource(trimToEmpty(request.getSource())); + } + if (request.getPreSalesName() != null) { + request.setPreSalesName(trimToEmpty(request.getPreSalesName())); + } + if (request.getCompetitorName() != null) { + request.setCompetitorName(trimToEmpty(request.getCompetitorName())); + } + if (request.getDescription() != null) { + request.setDescription(trimToEmpty(request.getDescription())); + } + request.setStatus(resolveIntegrationStatus(request.getStatus(), request.getStage())); + autoFillOmsPushTime(request); + validateIntegrationOperatorRelations(request, target); + } + + private void autoFillOmsPushTime(UpdateOpportunityIntegrationRequest request) { + if (Boolean.TRUE.equals(request.getPushedToOms()) && request.getOmsPushTime() == null) { + request.setOmsPushTime(java.time.OffsetDateTime.now()); + } + } + + private void validateIntegrationOperatorRelations( + UpdateOpportunityIntegrationRequest request, + OpportunityIntegrationTargetDTO target) { + boolean operatorRelatedUpdated = request.getOperatorName() != null + || request.getSalesExpansionId() != null + || request.getChannelExpansionId() != null; + if (!operatorRelatedUpdated) { + return; + } + + String effectiveOperatorName = request.getOperatorName() != null + ? request.getOperatorName() + : target.getOperatorName(); + Long effectiveSalesExpansionId = request.getSalesExpansionId() != null + ? request.getSalesExpansionId() + : target.getSalesExpansionId(); + Long effectiveChannelExpansionId = request.getChannelExpansionId() != null + ? request.getChannelExpansionId() + : target.getChannelExpansionId(); + + if (isBlank(effectiveOperatorName)) { + throw new BusinessException("运作方不能为空"); + } + validateOperatorRelations(effectiveOperatorName, effectiveSalesExpansionId, effectiveChannelExpansionId); + } + + private String resolveIntegrationStatus(String status, String stage) { + String normalizedStage = trimToNull(stage); + String normalizedStatus = trimToNull(status); + if (normalizedStage != null) { + if ("won".equals(normalizedStage)) { + if (normalizedStatus != null && !"won".equalsIgnoreCase(normalizedStatus.trim())) { + throw new BusinessException("项目阶段为已成交时,状态必须为won"); + } + return "won"; + } + if ("lost".equals(normalizedStage)) { + if (normalizedStatus != null && !"lost".equalsIgnoreCase(normalizedStatus.trim())) { + throw new BusinessException("项目阶段为已放弃时,状态必须为lost"); + } + return "lost"; + } + if (normalizedStatus == null) { + return "active"; + } + } + if (normalizedStatus == null) { + return null; + } + + String lowerCaseStatus = normalizedStatus.toLowerCase(); + return switch (lowerCaseStatus) { + case "active", "closed" -> lowerCaseStatus; + case "won", "lost" -> throw new BusinessException("更新状态为won或lost时,请同步传入对应的项目阶段"); + default -> throw new BusinessException("商机状态无效: " + normalizedStatus); + }; + } + + private String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private String trimToEmpty(String value) { + return value == null ? null : value.trim(); + } + private String toStageCode(String value) { return switch (value) { case "初步沟通", "initial_contact" -> "initial_contact"; @@ -358,7 +689,9 @@ public class OpportunityServiceImpl implements OpportunityService { private void validateOperatorRelations(String operatorName, Long salesExpansionId, Long channelExpansionId) { String normalizedOperator = normalizeOperatorToken(operatorName); boolean hasH3c = normalizedOperator.contains("新华三") || normalizedOperator.contains("h3c"); - boolean hasChannel = normalizedOperator.contains("渠道") || normalizedOperator.contains("channel"); + boolean hasChannel = normalizedOperator.contains("渠道") + || normalizedOperator.contains("channel") + || normalizedOperator.contains("dls"); if (hasH3c && hasChannel) { if (salesExpansionId == null && channelExpansionId == null) { @@ -389,6 +722,91 @@ public class OpportunityServiceImpl implements OpportunityService { .replace('+', '+'); } + private void validatePushBaseData(OpportunityOmsPushDataDTO pushData) { + if (isBlank(pushData.getOpportunityName())) { + throw new BusinessException("商机名称不能为空"); + } + if (isBlank(pushData.getCustomerName())) { + throw new BusinessException("最终客户不能为空"); + } + if (isBlank(pushData.getOperatorName())) { + throw new BusinessException("运作方不能为空"); + } + if (pushData.getAmount() == null || pushData.getAmount().compareTo(BigDecimal.ZERO) <= 0) { + throw new BusinessException("预计金额不能为空"); + } + if (isBlank(pushData.getExpectedCloseDate())) { + throw new BusinessException("预计下单时间不能为空"); + } + pushData.setConfidencePct(normalizeConfidenceGrade(pushData.getConfidencePct(), "项目把握度不能为空")); + if (isBlank(pushData.getStage()) && isBlank(pushData.getStageCode())) { + throw new BusinessException("项目阶段不能为空"); + } + if (isBlank(pushData.getOpportunityType())) { + throw new BusinessException("建设类型不能为空"); + } + if (operatorRequiresChannel(pushData.getOperatorName()) && isBlank(pushData.getChannelName())) { + throw new BusinessException("推送 OMS 前请先关联渠道名称"); + } + } + + private boolean operatorRequiresChannel(String operatorName) { + String normalizedOperator = normalizeOperatorToken(operatorName); + return normalizedOperator.contains("渠道") + || normalizedOperator.contains("channel") + || normalizedOperator.contains("dls"); + } + + private OmsPreSalesOptionDTO resolveSelectedPreSales( + OpportunityOmsPushDataDTO pushData, + PushOpportunityToOmsRequest request, + List 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) { } } diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml index 195e5515..9fd2bd97 100644 --- a/backend/src/main/resources/application-prod.yml +++ b/backend/src/main/resources/application-prod.yml @@ -43,7 +43,7 @@ unisbase: - /api/wecom/sso/** internal-auth: enabled: true - secret: change-me-internal-secret + secret: f0eb247f84db4e328fb27ce8ff6e7be96e73a53a7e9c4793395ad10d999e0d77 header-name: X-Internal-Secret app: upload-path: /Users/kangwenjing/Downloads/crm/uploads @@ -67,3 +67,14 @@ unisbase: state-ttl-seconds: 300 ticket-ttl-seconds: 180 access-token-safety-seconds: 120 + oms: + enabled: ${OMS_ENABLED:true} + base-url: ${OMS_BASE_URL:http://10.100.52.135:28080} + api-key: ${OMS_API_KEY:c7f858d0-30b8-4b7f-9ea1-0ccf5ceb1c54} + api-key-header: ${OMS_API_KEY_HEADER:apiKey} + user-info-path: ${OMS_USER_INFO_PATH:/api/v1/user/info} + user-add-path: ${OMS_USER_ADD_PATH:/api/v1/user/add} + project-add-path: ${OMS_PROJECT_ADD_PATH:/api/v1/project/add} + pre-sales-role-name: ${OMS_PRE_SALES_ROLE_NAME:售前} + connect-timeout-seconds: ${OMS_CONNECT_TIMEOUT_SECONDS:5} + read-timeout-seconds: ${OMS_READ_TIMEOUT_SECONDS:15} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 600a1398..c647435a 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -48,7 +48,7 @@ unisbase: - /api/wecom/sso/** internal-auth: enabled: true - secret: change-me-internal-secret + secret: f0eb247f84db4e328fb27ce8ff6e7be96e73a53a7e9c4793395ad10d999e0d77 header-name: X-Internal-Secret app: upload-path: /Users/kangwenjing/Downloads/crm/uploads @@ -72,3 +72,14 @@ unisbase: state-ttl-seconds: 300 ticket-ttl-seconds: 180 access-token-safety-seconds: 120 + oms: + enabled: ${OMS_ENABLED:true} + base-url: ${OMS_BASE_URL:http://10.100.52.135:28080} + api-key: ${OMS_API_KEY:c7f858d0-30b8-4b7f-9ea1-0ccf5ceb1c54} + api-key-header: ${OMS_API_KEY_HEADER:apiKey} + user-info-path: ${OMS_USER_INFO_PATH:/api/v1/user/info} + user-add-path: ${OMS_USER_ADD_PATH:/api/v1/user/add} + project-add-path: ${OMS_PROJECT_ADD_PATH:/api/v1/project/add} + pre-sales-role-name: ${OMS_PRE_SALES_ROLE_NAME:售前} + connect-timeout-seconds: ${OMS_CONNECT_TIMEOUT_SECONDS:5} + read-timeout-seconds: ${OMS_READ_TIMEOUT_SECONDS:15} diff --git a/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml b/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml index fa249ee6..cd18f4a0 100644 --- a/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml +++ b/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml @@ -15,6 +15,32 @@ order by sort_order asc nulls last, dict_item_id asc + + + + + + + + update crm_opportunity o + set opportunity_code = #{opportunityCode}, + updated_at = now() + where o.id = #{opportunityId} + + + + + + + + update crm_opportunity o + set pre_sales_id = #{preSalesId}, + pre_sales_name = #{preSalesName}, + updated_at = now() + where o.id = #{opportunityId} + + update crm_opportunity o set opportunity_name = #{request.opportunityName}, @@ -268,11 +352,6 @@ sales_expansion_id = #{request.salesExpansionId}, channel_expansion_id = #{request.channelExpansionId}, competitor_name = #{request.competitorName}, - pushed_to_oms = #{request.pushedToOms}, - oms_push_time = case - when #{request.pushedToOms} then coalesce(oms_push_time, now()) - else null - end, description = #{request.description}, status = case when #{request.stage} = 'won' then 'won' @@ -283,10 +362,95 @@ where o.id = #{opportunityId} - + + + + update crm_opportunity o + + + opportunity_name = #{request.opportunityName}, + + + project_location = #{request.projectLocation}, + + + operator_name = #{request.operatorName}, + + + amount = #{request.amount}, + + + expected_close_date = #{request.expectedCloseDate}, + + + confidence_pct = #{request.confidencePct}, + + + stage = #{request.stage}, + + + opportunity_type = #{request.opportunityType}, + + + product_type = nullif(#{request.productType}, ''), + + + source = nullif(#{request.source}, ''), + + + sales_expansion_id = #{request.salesExpansionId}, + + + channel_expansion_id = #{request.channelExpansionId}, + + + pre_sales_id = #{request.preSalesId}, + + + pre_sales_name = nullif(#{request.preSalesName}, ''), + + + competitor_name = nullif(#{request.competitorName}, ''), + + + archived = #{request.archived}, + + + pushed_to_oms = #{request.pushedToOms}, + + + oms_push_time = #{request.omsPushTime}, + + + description = nullif(#{request.description}, ''), + + + status = #{request.status}, + + updated_at = now() + + where o.id = #{opportunityId} + + + update crm_opportunity o set pushed_to_oms = true, oms_push_time = coalesce(oms_push_time, now()), + opportunity_code = #{opportunityCode}, updated_at = now() where o.id = #{opportunityId} and coalesce(pushed_to_oms, false) = false diff --git a/backend/src/test/java/com/unis/crm/controller/AuthHeaderHandlingWebMvcTest.java b/backend/src/test/java/com/unis/crm/controller/AuthHeaderHandlingWebMvcTest.java new file mode 100644 index 00000000..94402a22 --- /dev/null +++ b/backend/src/test/java/com/unis/crm/controller/AuthHeaderHandlingWebMvcTest.java @@ -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); + } +} diff --git a/backend/src/test/java/com/unis/crm/controller/OpportunityIntegrationControllerWebMvcTest.java b/backend/src/test/java/com/unis/crm/controller/OpportunityIntegrationControllerWebMvcTest.java new file mode 100644 index 00000000..9e7b1fe7 --- /dev/null +++ b/backend/src/test/java/com/unis/crm/controller/OpportunityIntegrationControllerWebMvcTest.java @@ -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()); + } +} diff --git a/docs/opportunity-integration-api.md b/docs/opportunity-integration-api.md new file mode 100644 index 00000000..cbbb13eb --- /dev/null +++ b/docs/opportunity-integration-api.md @@ -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": "外部系统回写成交结果" + }' +``` + diff --git a/frontend/src/components/AdaptiveSelect.tsx b/frontend/src/components/AdaptiveSelect.tsx index 62dfcd01..69f0f06d 100644 --- a/frontend/src/components/AdaptiveSelect.tsx +++ b/frontend/src/components/AdaptiveSelect.tsx @@ -15,6 +15,8 @@ type AdaptiveSelectBaseProps = { sheetTitle?: string; disabled?: boolean; className?: string; + searchable?: boolean; + searchPlaceholder?: string; }; type AdaptiveSelectSingleProps = AdaptiveSelectBaseProps & { @@ -66,11 +68,14 @@ export function AdaptiveSelect({ sheetTitle, disabled = false, className, + searchable = false, + searchPlaceholder = "请输入关键字搜索", value, multiple = false, onChange, }: AdaptiveSelectProps) { const [open, setOpen] = useState(false); + const [searchKeyword, setSearchKeyword] = useState(""); const containerRef = useRef(null); const isMobile = useIsMobileViewport(); const selectedValues = multiple @@ -82,6 +87,14 @@ export function AdaptiveSelect({ .filter((label): label is string => Boolean(label)) .join("、") : placeholder; + const normalizedSearchKeyword = searchKeyword.trim().toLowerCase(); + const visibleOptions = searchable && normalizedSearchKeyword + ? options.filter((option) => { + const label = option.label.toLowerCase(); + const optionValue = option.value.toLowerCase(); + return label.includes(normalizedSearchKeyword) || optionValue.includes(normalizedSearchKeyword); + }) + : options; useEffect(() => { if (!open || isMobile) { @@ -120,6 +133,12 @@ export function AdaptiveSelect({ }; }, [isMobile, open]); + useEffect(() => { + if (!open && searchKeyword) { + setSearchKeyword(""); + } + }, [open, searchKeyword]); + const handleSelect = (nextValue: string) => { if (multiple) { const currentValues = Array.isArray(value) ? value : []; @@ -187,7 +206,24 @@ export function AdaptiveSelect({ exit={{ opacity: 0, y: 8 }} className="absolute z-30 mt-2 w-full rounded-2xl border border-slate-200 bg-white p-2 shadow-2xl dark:border-slate-800 dark:bg-slate-900" > -
{options.map(renderOption)}
+ {searchable ? ( +
+ 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" + /> +
+ ) : null} +
+ {visibleOptions.length > 0 ? visibleOptions.map(renderOption) : ( +
+ 未找到匹配选项 +
+ )} +
) : null} @@ -223,7 +259,20 @@ export function AdaptiveSelect({
- {options.map(renderOption)} + {searchable ? ( + 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) : ( +
+ 未找到匹配选项 +
+ )} {multiple ? ( + ); + })} +
+
+ + +
+ + ); + + return ( +
+ + + + {open && !isMobile ? ( + +
+

竞争对手

+

支持多选,选择“其他”后可手动录入。

+
+ {renderOptions()} +
+ ) : null} +
+ + + {open && isMobile ? ( + <> + setOpen(false)} + /> + +
+
+
+

竞争对手

+

支持多选,选择“其他”后可手动录入。

+
+ +
+
+ {renderOptions()} +
+
+
+ + ) : null} +
+
+ ); +} + export default function Opportunities() { const isMobileViewport = useIsMobileViewport(); const isWecomBrowser = useIsWecomBrowser(); @@ -585,10 +855,16 @@ export default function Opportunities() { const [items, setItems] = useState([]); const [salesExpansionOptions, setSalesExpansionOptions] = useState([]); const [channelExpansionOptions, setChannelExpansionOptions] = useState([]); + const [omsPreSalesOptions, setOmsPreSalesOptions] = useState([]); const [stageOptions, setStageOptions] = useState([]); const [operatorOptions, setOperatorOptions] = useState([]); + const [projectLocationOptions, setProjectLocationOptions] = useState([]); const [form, setForm] = useState(defaultForm); - const [competitorSelection, setCompetitorSelection] = useState(""); + const [pushPreSalesId, setPushPreSalesId] = useState(undefined); + const [pushPreSalesName, setPushPreSalesName] = useState(""); + const [loadingOmsPreSales, setLoadingOmsPreSales] = useState(false); + const [selectedCompetitors, setSelectedCompetitors] = useState([]); + const [customCompetitorName, setCustomCompetitorName] = useState(""); const [fieldErrors, setFieldErrors] = useState>>({}); const [detailTab, setDetailTab] = useState<"sales" | "channel" | "followups">("sales"); const hasForegroundModal = createOpen || editOpen || pushConfirmOpen; @@ -650,11 +926,13 @@ export default function Opportunities() { if (!cancelled) { setStageOptions((data.stageOptions ?? []).filter((item) => item.value)); setOperatorOptions((data.operatorOptions ?? []).filter((item) => item.value)); + setProjectLocationOptions((data.projectLocationOptions ?? []).filter((item) => item.value)); } } catch { if (!cancelled) { setStageOptions([]); setOperatorOptions([]); + setProjectLocationOptions([]); } } } @@ -680,6 +958,20 @@ export default function Opportunities() { { label: "全部", value: "全部" }, ...stageOptions.map((item) => ({ label: item.label || item.value || "", value: item.value || "" })), ].filter((item) => item.value); + const normalizedProjectLocation = form.projectLocation?.trim() || ""; + const projectLocationSelectOptions = [ + { value: "", label: "请选择" }, + ...projectLocationOptions.map((item) => ({ + label: item.label || item.value || "", + value: item.value || "", + })), + ...( + normalizedProjectLocation + && !projectLocationOptions.some((item) => (item.value || "").trim() === normalizedProjectLocation) + ? [{ value: normalizedProjectLocation, label: normalizedProjectLocation }] + : [] + ), + ]; const activeStageFilterLabel = stageFilterOptions.find((item) => item.value === filter)?.label || "全部"; const selectedSalesExpansion = selectedItem?.salesExpansionId ? salesExpansionOptions.find((item) => item.id === selectedItem.salesExpansionId) ?? null @@ -689,9 +981,11 @@ export default function Opportunities() { : null; const selectedSalesExpansionName = selectedItem?.salesExpansionName || selectedSalesExpansion?.name || ""; const selectedChannelExpansionName = selectedItem?.channelExpansionName || selectedChannelExpansion?.name || ""; + const selectedPreSalesName = selectedItem?.preSalesName || "无"; const operatorMode = resolveOperatorMode(form.operatorName, operatorOptions); const showSalesExpansionField = operatorMode === "h3c" || operatorMode === "both"; const showChannelExpansionField = operatorMode === "channel" || operatorMode === "both"; + const showCustomCompetitorInput = selectedCompetitors.includes("其他"); const salesExpansionSearchOptions: SearchableOption[] = salesExpansionOptions.map((item) => ({ value: item.id, label: item.name || `拓展人员#${item.id}`, @@ -702,12 +996,24 @@ export default function Opportunities() { label: item.name || `渠道#${item.id}`, keywords: [item.channelCode || "", item.province || "", item.primaryContactName || "", item.primaryContactMobile || ""], })); + const omsPreSalesSearchOptions: SearchableOption[] = [ + ...(pushPreSalesId && pushPreSalesName && !omsPreSalesOptions.some((item) => item.userId === pushPreSalesId) + ? [{ value: pushPreSalesId, label: pushPreSalesName, keywords: [] }] + : []), + ...omsPreSalesOptions.map((item) => ({ + value: item.userId, + label: item.userName || item.loginName || `售前#${item.userId}`, + keywords: [item.loginName || ""], + })), + ]; useEffect(() => { if (selectedItem) { setDetailTab("sales"); } else { setPushConfirmOpen(false); + setPushPreSalesId(undefined); + setPushPreSalesName(""); } }, [selectedItem]); @@ -717,10 +1023,10 @@ export default function Opportunities() { } }, [archiveTab, selectedItem, visibleItems]); - const getConfidenceColor = (score: number) => { - const normalizedScore = Number(getConfidenceOptionValue(score)); - if (normalizedScore >= 80) return "text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10 border-emerald-200 dark:border-emerald-500/20"; - if (normalizedScore >= 60) return "text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-500/10 border-amber-200 dark:border-amber-500/20"; + const getConfidenceColor = (score?: string | number | null) => { + const normalizedGrade = normalizeConfidenceGrade(score); + if (normalizedGrade === "A") return "text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10 border-emerald-200 dark:border-emerald-500/20"; + if (normalizedGrade === "B") return "text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-500/10 border-amber-200 dark:border-amber-500/20"; return "text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-500/10 border-rose-200 dark:border-rose-500/20"; }; @@ -739,7 +1045,8 @@ export default function Opportunities() { setError(""); setFieldErrors({}); setForm(defaultForm); - setCompetitorSelection(""); + setSelectedCompetitors([]); + setCustomCompetitorName(""); setCreateOpen(true); }; @@ -750,7 +1057,8 @@ export default function Opportunities() { setError(""); setFieldErrors({}); setForm(defaultForm); - setCompetitorSelection(""); + setSelectedCompetitors([]); + setCustomCompetitorName(""); }; const reload = async (preferredSelectedId?: number) => { @@ -768,7 +1076,7 @@ export default function Opportunities() { } setError(""); - const validationErrors = validateOpportunityForm(form, competitorSelection, operatorMode); + const validationErrors = validateOpportunityForm(form, selectedCompetitors, customCompetitorName, operatorMode); if (Object.keys(validationErrors).length > 0) { setFieldErrors(validationErrors); setError("请先完整填写商机必填字段"); @@ -778,7 +1086,7 @@ export default function Opportunities() { setSubmitting(true); try { - await createOpportunity(buildOpportunitySubmitPayload(form, competitorSelection, operatorMode)); + await createOpportunity(buildOpportunitySubmitPayload(form, selectedCompetitors, customCompetitorName, operatorMode)); await reload(); resetCreateState(); } catch (createError) { @@ -791,14 +1099,12 @@ export default function Opportunities() { if (!selectedItem) { return; } - if (selectedItem.pushedToOms) { - setError("该商机已推送 OMS,不能再编辑"); - return; - } setError(""); setFieldErrors({}); setForm(toFormFromItem(selectedItem)); - setCompetitorSelection(getCompetitorSelection(selectedItem.competitorName)); + const competitorState = parseCompetitorState(selectedItem.competitorName); + setSelectedCompetitors(competitorState.selections); + setCustomCompetitorName(competitorState.customName); setEditOpen(true); }; @@ -808,7 +1114,7 @@ export default function Opportunities() { } setError(""); - const validationErrors = validateOpportunityForm(form, competitorSelection, operatorMode); + const validationErrors = validateOpportunityForm(form, selectedCompetitors, customCompetitorName, operatorMode); if (Object.keys(validationErrors).length > 0) { setFieldErrors(validationErrors); setError("请先完整填写商机必填字段"); @@ -818,7 +1124,7 @@ export default function Opportunities() { setSubmitting(true); try { - await updateOpportunity(selectedItem.id, buildOpportunitySubmitPayload(form, competitorSelection, operatorMode)); + await updateOpportunity(selectedItem.id, buildOpportunitySubmitPayload(form, selectedCompetitors, customCompetitorName, operatorMode)); await reload(selectedItem.id); resetCreateState(); } catch (updateError) { @@ -836,7 +1142,11 @@ export default function Opportunities() { setError(""); try { - await pushOpportunityToOms(selectedItem.id); + const payload: PushOpportunityToOmsPayload = { + preSalesId: pushPreSalesId, + preSalesName: pushPreSalesName.trim() || undefined, + }; + await pushOpportunityToOms(selectedItem.id, payload); await reload(selectedItem.id); } catch (pushError) { setError(pushError instanceof Error ? pushError.message : "推送 OMS 失败"); @@ -845,14 +1155,62 @@ export default function Opportunities() { } }; - const handleOpenPushConfirm = () => { + const syncPushPreSalesSelection = (item: OpportunityItem | null, options: OmsPreSalesOption[]) => { + if (!item) { + setPushPreSalesId(undefined); + setPushPreSalesName(""); + return; + } + + const matchedById = item.preSalesId + ? options.find((option) => option.userId === item.preSalesId) + : undefined; + if (matchedById) { + setPushPreSalesId(matchedById.userId); + setPushPreSalesName(matchedById.userName || matchedById.loginName || ""); + return; + } + + const matchedByName = item.preSalesName + ? options.find((option) => (option.userName || "") === item.preSalesName) + : undefined; + if (matchedByName) { + setPushPreSalesId(matchedByName.userId); + setPushPreSalesName(matchedByName.userName || matchedByName.loginName || ""); + return; + } + + setPushPreSalesId(item.preSalesId); + setPushPreSalesName(item.preSalesName || ""); + }; + + const handleOpenPushConfirm = async () => { if (!selectedItem || selectedItem.pushedToOms || pushingOms) { return; } + + setError(""); + syncPushPreSalesSelection(selectedItem, omsPreSalesOptions); setPushConfirmOpen(true); + setLoadingOmsPreSales(true); + + try { + const data = await getOpportunityOmsPreSalesOptions(); + setOmsPreSalesOptions(data); + syncPushPreSalesSelection(selectedItem, data); + } catch (loadError) { + setOmsPreSalesOptions([]); + setError(loadError instanceof Error ? loadError.message : "加载售前人员失败"); + } finally { + setLoadingOmsPreSales(false); + } }; const handleConfirmPushToOms = async () => { + if (!pushPreSalesId && !pushPreSalesName.trim()) { + setError("请选择售前人员"); + return; + } setPushConfirmOpen(false); await handlePushToOms(); }; @@ -943,7 +1301,7 @@ export default function Opportunities() {

{opp.name || "未命名商机"}

-

{opp.pushedToOms ? "已推送 OMS" : "未推送 OMS"}

+

商机编号:{opp.code || "待生成"}

@@ -1061,7 +1419,16 @@ export default function Opportunities() {
@@ -1171,17 +1538,14 @@ export default function Opportunities() { - {competitorSelection === "其他" ? ( + {showCustomCompetitorInput ? (
@@ -1298,6 +1661,26 @@ export default function Opportunities() {

当前商机

{selectedItem.name || selectedItem.code || `#${selectedItem.id}`}

+
+

售前人员

+ { + const matched = omsPreSalesOptions.find((item) => item.userId === value); + setPushPreSalesId(value); + setPushPreSalesName(matched?.userName || matched?.loginName || ""); + setError(""); + }} + /> +

+ {loadingOmsPreSales ? "正在从 OMS 拉取售前人员列表。" : "推送前必须选择售前,系统会把售前ID和姓名回写到 CRM 商机表。"} +

+
+ {error ?
{error}
: null}
@@ -1373,13 +1755,13 @@ export default function Opportunities() { } /> } /> + } /> ¥{formatAmount(selectedItem.amount)}} icon={} /> } /> } /> } /> - @@ -1493,15 +1875,20 @@ export default function Opportunities() {
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index eda746bf..b2ef4ee7 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -8,8 +8,9 @@ export default defineConfig(({mode}) => { const env = loadEnv(mode, '.', ''); const certPath = path.resolve(__dirname, '.cert/dev.crt'); const keyPath = path.resolve(__dirname, '.cert/dev.key'); + const enableHttps = String(env.VITE_DEV_HTTPS || '').trim().toLowerCase() === 'true'; const https = - fs.existsSync(certPath) && fs.existsSync(keyPath) + enableHttps && fs.existsSync(certPath) && fs.existsSync(keyPath) ? { cert: fs.readFileSync(certPath), key: fs.readFileSync(keyPath), diff --git a/sql/archive/alter_channel_expansion_add_city_and_certification_level_pg17.sql b/sql/archive/alter_channel_expansion_add_city_and_certification_level_pg17.sql new file mode 100644 index 00000000..5b8cc883 --- /dev/null +++ b/sql/archive/alter_channel_expansion_add_city_and_certification_level_pg17.sql @@ -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; diff --git a/sql/archive/alter_fill_column_comments_pg17.sql b/sql/archive/alter_fill_column_comments_pg17.sql new file mode 100644 index 00000000..ec8824b0 --- /dev/null +++ b/sql/archive/alter_fill_column_comments_pg17.sql @@ -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; diff --git a/sql/archive/alter_opportunity_add_oms_project_code_pg17.sql b/sql/archive/alter_opportunity_add_oms_project_code_pg17.sql new file mode 100644 index 00000000..c876c318 --- /dev/null +++ b/sql/archive/alter_opportunity_add_oms_project_code_pg17.sql @@ -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系统项目编号'; diff --git a/sql/archive/alter_opportunity_add_presales_fields_pg17.sql b/sql/archive/alter_opportunity_add_presales_fields_pg17.sql new file mode 100644 index 00000000..2ccd88d5 --- /dev/null +++ b/sql/archive/alter_opportunity_add_presales_fields_pg17.sql @@ -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 '售前姓名'; diff --git a/sql/archive/create_cnarea_pg17.sql b/sql/archive/create_cnarea_pg17.sql new file mode 100644 index 00000000..6f0159cb --- /dev/null +++ b/sql/archive/create_cnarea_pg17.sql @@ -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; diff --git a/sql/init_full_pg17.sql b/sql/init_full_pg17.sql index 97f1cd8c..79ee8031 100644 --- a/sql/init_full_pg17.sql +++ b/sql/init_full_pg17.sql @@ -53,6 +53,28 @@ begin end; $$; +create or replace function comment_on_column_if_exists(p_table_name text, p_column_name text, p_comment_text text) +returns void +language plpgsql +as $$ +begin + if exists ( + select 1 + from information_schema.columns c + where c.table_schema = current_schema() + and c.table_name = p_table_name + and c.column_name = p_column_name + ) then + execute format( + 'comment on column %I.%I is %L', + p_table_name, + p_column_name, + p_comment_text + ); + end if; +end; +$$; + -- ===================================================================== -- Section 2. Base tables -- ===================================================================== @@ -103,11 +125,13 @@ create table if not exists crm_opportunity ( owner_user_id bigint not null, sales_expansion_id bigint, channel_expansion_id bigint, + pre_sales_id bigint, + pre_sales_name varchar(100), project_location varchar(100), operator_name varchar(100), amount numeric(18, 2) not null default 0, expected_close_date date, - confidence_pct smallint not null default 0 check (confidence_pct between 0 and 100), + confidence_pct varchar(1) not null default 'C' check (confidence_pct in ('A', 'B', 'C')), stage varchar(50) not null default 'initial_contact', opportunity_type varchar(50), product_type varchar(100), @@ -169,9 +193,11 @@ create table if not exists crm_channel_expansion ( id bigint generated by default as identity primary key, channel_code varchar(50), province varchar(50), + city varchar(50), channel_name varchar(200) not null, office_address varchar(255), channel_industry varchar(100), + certification_level varchar(100), annual_revenue numeric(18, 2), staff_size integer check (staff_size is null or staff_size >= 0), contact_established_date date, @@ -521,6 +547,8 @@ END $$; ALTER TABLE IF EXISTS crm_opportunity ADD COLUMN IF NOT EXISTS sales_expansion_id bigint, ADD COLUMN IF NOT EXISTS channel_expansion_id bigint, + ADD COLUMN IF NOT EXISTS pre_sales_id bigint, + ADD COLUMN IF NOT EXISTS pre_sales_name varchar(100), ADD COLUMN IF NOT EXISTS project_location varchar(100), ADD COLUMN IF NOT EXISTS operator_name varchar(100), ADD COLUMN IF NOT EXISTS competitor_name varchar(200); @@ -554,6 +582,8 @@ ALTER TABLE IF EXISTS crm_channel_expansion ADD COLUMN IF NOT EXISTS channel_code varchar(50), ADD COLUMN IF NOT EXISTS office_address varchar(255), ADD COLUMN IF NOT EXISTS channel_industry varchar(100), + ADD COLUMN IF NOT EXISTS city varchar(50), + ADD COLUMN IF NOT EXISTS certification_level varchar(100), ADD COLUMN IF NOT EXISTS contact_established_date date, ADD COLUMN IF NOT EXISTS intent_level varchar(20), ADD COLUMN IF NOT EXISTS has_desktop_exp boolean, @@ -693,4 +723,214 @@ CREATE INDEX IF NOT EXISTS idx_crm_opportunity_followup_source CREATE INDEX IF NOT EXISTS idx_crm_channel_expansion_contact_channel ON crm_channel_expansion_contact(channel_expansion_id); +-- Column comments +WITH column_comments(table_name, column_name, comment_text) AS ( + VALUES + ('sys_user', 'id', '用户主键'), + ('sys_user', 'user_id', '用户ID'), + ('sys_user', 'user_code', '工号/员工编号'), + ('sys_user', 'username', '登录账号'), + ('sys_user', 'real_name', '姓名'), + ('sys_user', 'display_name', '显示名称'), + ('sys_user', 'mobile', '手机号'), + ('sys_user', 'phone', '手机号'), + ('sys_user', 'email', '邮箱'), + ('sys_user', 'org_id', '所属组织ID'), + ('sys_user', 'job_title', '职位'), + ('sys_user', 'status', '用户状态'), + ('sys_user', 'hire_date', '入职日期'), + ('sys_user', 'avatar_url', '头像地址'), + ('sys_user', 'password_hash', '密码哈希'), + ('sys_user', 'created_at', '创建时间'), + ('sys_user', 'updated_at', '更新时间'), + ('sys_user', 'is_deleted', '逻辑删除标记'), + ('sys_user', 'pwd_reset_required', '首次登录是否需要重置密码'), + ('sys_user', 'is_platform_admin', '是否平台管理员'), + + ('crm_customer', 'id', '客户主键'), + ('crm_customer', 'customer_code', '客户编码'), + ('crm_customer', 'customer_name', '客户名称'), + ('crm_customer', 'customer_type', '客户类型'), + ('crm_customer', 'industry', '行业'), + ('crm_customer', 'province', '省份'), + ('crm_customer', 'city', '城市'), + ('crm_customer', 'address', '详细地址'), + ('crm_customer', 'owner_user_id', '当前负责人ID'), + ('crm_customer', 'source', '客户来源'), + ('crm_customer', 'status', '客户状态'), + ('crm_customer', 'remark', '备注说明'), + ('crm_customer', 'created_at', '创建时间'), + ('crm_customer', 'updated_at', '更新时间'), + + ('crm_opportunity', 'id', '商机主键'), + ('crm_opportunity', 'opportunity_code', '商机编号'), + ('crm_opportunity', 'opportunity_name', '商机名称'), + ('crm_opportunity', 'customer_id', '客户ID'), + ('crm_opportunity', 'owner_user_id', '商机负责人ID'), + ('crm_opportunity', 'sales_expansion_id', '关联销售拓展ID'), + ('crm_opportunity', 'channel_expansion_id', '关联渠道拓展ID'), + ('crm_opportunity', 'pre_sales_id', '售前ID'), + ('crm_opportunity', 'pre_sales_name', '售前姓名'), + ('crm_opportunity', 'project_location', '项目所在地'), + ('crm_opportunity', 'operator_name', '运作方'), + ('crm_opportunity', 'amount', '商机金额'), + ('crm_opportunity', 'expected_close_date', '预计结单日期'), + ('crm_opportunity', 'confidence_pct', '把握度等级(A/B/C)'), + ('crm_opportunity', 'stage', '商机阶段'), + ('crm_opportunity', 'opportunity_type', '商机类型'), + ('crm_opportunity', 'product_type', '产品类型'), + ('crm_opportunity', 'source', '商机来源'), + ('crm_opportunity', 'competitor_name', '竞品名称'), + ('crm_opportunity', 'archived', '是否归档'), + ('crm_opportunity', 'pushed_to_oms', '是否已推送OMS'), + ('crm_opportunity', 'oms_push_time', '推送OMS时间'), + ('crm_opportunity', 'description', '商机说明/备注'), + ('crm_opportunity', 'status', '商机状态'), + ('crm_opportunity', 'created_at', '创建时间'), + ('crm_opportunity', 'updated_at', '更新时间'), + + ('crm_opportunity_followup', 'id', '跟进记录主键'), + ('crm_opportunity_followup', 'opportunity_id', '商机ID'), + ('crm_opportunity_followup', 'followup_time', '跟进时间'), + ('crm_opportunity_followup', 'followup_type', '跟进方式'), + ('crm_opportunity_followup', 'content', '跟进内容'), + ('crm_opportunity_followup', 'next_action', '下一步动作'), + ('crm_opportunity_followup', 'followup_user_id', '跟进人ID'), + ('crm_opportunity_followup', 'source_type', '来源类型'), + ('crm_opportunity_followup', 'source_id', '来源记录ID'), + ('crm_opportunity_followup', 'created_at', '创建时间'), + ('crm_opportunity_followup', 'updated_at', '更新时间'), + + ('crm_sales_expansion', 'id', '销售拓展主键'), + ('crm_sales_expansion', 'employee_no', '工号/员工编号'), + ('crm_sales_expansion', 'candidate_name', '候选人姓名'), + ('crm_sales_expansion', 'office_name', '办事处/代表处'), + ('crm_sales_expansion', 'mobile', '手机号'), + ('crm_sales_expansion', 'email', '邮箱'), + ('crm_sales_expansion', 'target_dept', '所属部门'), + ('crm_sales_expansion', 'industry', '所属行业'), + ('crm_sales_expansion', 'title', '职务'), + ('crm_sales_expansion', 'intent_level', '合作意向'), + ('crm_sales_expansion', 'stage', '跟进阶段'), + ('crm_sales_expansion', 'has_desktop_exp', '是否有云桌面经验'), + ('crm_sales_expansion', 'in_progress', '是否持续跟进中'), + ('crm_sales_expansion', 'employment_status', '候选人状态'), + ('crm_sales_expansion', 'expected_join_date', '预计入职日期'), + ('crm_sales_expansion', 'owner_user_id', '负责人ID'), + ('crm_sales_expansion', 'remark', '备注说明'), + ('crm_sales_expansion', 'created_at', '创建时间'), + ('crm_sales_expansion', 'updated_at', '更新时间'), + + ('crm_channel_expansion', 'id', '渠道拓展主键'), + ('crm_channel_expansion', 'channel_code', '渠道编码'), + ('crm_channel_expansion', 'province', '省份'), + ('crm_channel_expansion', 'city', '市'), + ('crm_channel_expansion', 'channel_name', '渠道名称'), + ('crm_channel_expansion', 'office_address', '办公地址'), + ('crm_channel_expansion', 'channel_industry', '聚焦行业'), + ('crm_channel_expansion', 'certification_level', '认证级别'), + ('crm_channel_expansion', 'industry', '行业(兼容旧字段)'), + ('crm_channel_expansion', 'annual_revenue', '年营收'), + ('crm_channel_expansion', 'staff_size', '人员规模'), + ('crm_channel_expansion', 'contact_established_date', '建立联系日期'), + ('crm_channel_expansion', 'intent_level', '合作意向'), + ('crm_channel_expansion', 'has_desktop_exp', '是否有云桌面经验'), + ('crm_channel_expansion', 'contact_name', '主联系人姓名(兼容旧结构)'), + ('crm_channel_expansion', 'contact_title', '主联系人职务(兼容旧结构)'), + ('crm_channel_expansion', 'contact_mobile', '主联系人电话(兼容旧结构)'), + ('crm_channel_expansion', 'channel_attribute', '渠道属性编码,多个值逗号分隔'), + ('crm_channel_expansion', 'internal_attribute', '新华三内部属性编码,多个值逗号分隔'), + ('crm_channel_expansion', 'stage', '渠道合作阶段'), + ('crm_channel_expansion', 'landed_flag', '是否已落地'), + ('crm_channel_expansion', 'expected_sign_date', '预计签约日期'), + ('crm_channel_expansion', 'owner_user_id', '负责人ID'), + ('crm_channel_expansion', 'remark', '备注说明'), + ('crm_channel_expansion', 'created_at', '创建时间'), + ('crm_channel_expansion', 'updated_at', '更新时间'), + + ('crm_channel_expansion_contact', 'id', '联系人主键'), + ('crm_channel_expansion_contact', 'channel_expansion_id', '渠道拓展ID'), + ('crm_channel_expansion_contact', 'contact_name', '联系人姓名'), + ('crm_channel_expansion_contact', 'contact_mobile', '联系人电话'), + ('crm_channel_expansion_contact', 'contact_title', '联系人职务'), + ('crm_channel_expansion_contact', 'sort_order', '排序号'), + ('crm_channel_expansion_contact', 'created_at', '创建时间'), + ('crm_channel_expansion_contact', 'updated_at', '更新时间'), + + ('crm_expansion_followup', 'id', '跟进记录主键'), + ('crm_expansion_followup', 'biz_type', '业务类型'), + ('crm_expansion_followup', 'biz_id', '业务对象ID'), + ('crm_expansion_followup', 'followup_time', '跟进时间'), + ('crm_expansion_followup', 'followup_type', '跟进方式'), + ('crm_expansion_followup', 'content', '跟进内容'), + ('crm_expansion_followup', 'next_action', '下一步动作'), + ('crm_expansion_followup', 'followup_user_id', '跟进人ID'), + ('crm_expansion_followup', 'visit_start_time', '拜访开始时间'), + ('crm_expansion_followup', 'evaluation_content', '评估内容'), + ('crm_expansion_followup', 'next_plan', '后续规划'), + ('crm_expansion_followup', 'source_type', '来源类型'), + ('crm_expansion_followup', 'source_id', '来源记录ID'), + ('crm_expansion_followup', 'created_at', '创建时间'), + ('crm_expansion_followup', 'updated_at', '更新时间'), + + ('work_checkin', 'id', '打卡记录主键'), + ('work_checkin', 'user_id', '打卡人ID'), + ('work_checkin', 'checkin_date', '打卡日期'), + ('work_checkin', 'checkin_time', '打卡时间'), + ('work_checkin', 'biz_type', '关联对象类型'), + ('work_checkin', 'biz_id', '关联对象ID'), + ('work_checkin', 'biz_name', '关联对象名称'), + ('work_checkin', 'longitude', '经度'), + ('work_checkin', 'latitude', '纬度'), + ('work_checkin', 'location_text', '打卡地点'), + ('work_checkin', 'remark', '备注说明(含现场照片元数据)'), + ('work_checkin', 'user_name', '打卡人姓名快照'), + ('work_checkin', 'dept_name', '所属部门快照'), + ('work_checkin', 'status', '打卡状态'), + ('work_checkin', 'created_at', '创建时间'), + ('work_checkin', 'updated_at', '更新时间'), + + ('work_daily_report', 'id', '日报主键'), + ('work_daily_report', 'user_id', '提交人ID'), + ('work_daily_report', 'report_date', '日报日期'), + ('work_daily_report', 'work_content', '今日工作内容(含结构化明细元数据)'), + ('work_daily_report', 'tomorrow_plan', '明日工作计划(含结构化计划项元数据)'), + ('work_daily_report', 'source_type', '提交来源'), + ('work_daily_report', 'submit_time', '提交时间'), + ('work_daily_report', 'status', '日报状态'), + ('work_daily_report', 'score', '日报评分'), + ('work_daily_report', 'created_at', '创建时间'), + ('work_daily_report', 'updated_at', '更新时间'), + + ('work_daily_report_comment', 'id', '点评记录主键'), + ('work_daily_report_comment', 'report_id', '日报ID'), + ('work_daily_report_comment', 'reviewer_user_id', '点评人ID'), + ('work_daily_report_comment', 'score', '点评评分'), + ('work_daily_report_comment', 'comment_content', '点评内容'), + ('work_daily_report_comment', 'reviewed_at', '点评时间'), + ('work_daily_report_comment', 'created_at', '创建时间'), + + ('work_todo', 'id', '待办主键'), + ('work_todo', 'user_id', '所属用户ID'), + ('work_todo', 'title', '待办标题'), + ('work_todo', 'biz_type', '业务类型'), + ('work_todo', 'biz_id', '业务对象ID'), + ('work_todo', 'due_date', '截止时间'), + ('work_todo', 'status', '待办状态'), + ('work_todo', 'priority', '优先级'), + ('work_todo', 'created_at', '创建时间'), + ('work_todo', 'updated_at', '更新时间'), + + ('sys_activity_log', 'id', '动态主键'), + ('sys_activity_log', 'biz_type', '业务类型'), + ('sys_activity_log', 'biz_id', '业务对象ID'), + ('sys_activity_log', 'action_type', '动作类型'), + ('sys_activity_log', 'title', '动态标题'), + ('sys_activity_log', 'content', '动态内容'), + ('sys_activity_log', 'operator_user_id', '操作人ID'), + ('sys_activity_log', 'created_at', '创建时间') +) +SELECT comment_on_column_if_exists(table_name, column_name, comment_text) +FROM column_comments; + commit;