历史数据同步后,修复新增商机无法保存的问题
parent
b56d509cc9
commit
5da2565298
|
|
@ -0,0 +1 @@
|
|||
|
||||
|
|
@ -0,0 +1 @@
|
|||
|
||||
|
|
@ -0,0 +1 @@
|
|||
|
||||
|
|
@ -1,9 +1,13 @@
|
|||
package com.unis.crm.common;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.sql.SQLException;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
|
|
@ -21,6 +25,7 @@ import org.springframework.validation.method.ParameterValidationResult;
|
|||
@RestControllerAdvice
|
||||
public class CrmGlobalExceptionHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(CrmGlobalExceptionHandler.class);
|
||||
private static final String CURRENT_USER_HEADER = "X-User-Id";
|
||||
private static final String UNAUTHORIZED_MESSAGE = "登录已失效,请重新登录";
|
||||
|
||||
|
|
@ -95,16 +100,63 @@ public class CrmGlobalExceptionHandler {
|
|||
return ResponseEntity.status(HttpStatus.NOT_ACCEPTABLE).build();
|
||||
}
|
||||
|
||||
@ExceptionHandler(DataIntegrityViolationException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleDataIntegrityViolation(
|
||||
DataIntegrityViolationException ex,
|
||||
HttpServletRequest request) {
|
||||
log.warn("Database constraint violation on {}", request.getRequestURI(), ex);
|
||||
return compatibleErrorResponse(HttpStatus.CONFLICT, resolveConstraintMessage(ex), request.getRequestURI());
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
public Map<String, Object> handleUnexpectedException(Exception ex, HttpServletRequest request) {
|
||||
public ResponseEntity<Map<String, Object>> handleUnexpectedException(Exception ex, HttpServletRequest request) {
|
||||
log.error("Unexpected request failure on {}", request.getRequestURI(), ex);
|
||||
return compatibleErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR, "系统内部错误,请稍后重试", request.getRequestURI());
|
||||
}
|
||||
|
||||
private String resolveConstraintMessage(Throwable throwable) {
|
||||
SQLException sqlException = findCause(throwable, SQLException.class);
|
||||
String databaseMessage = sqlException == null ? "" : String.valueOf(sqlException.getMessage());
|
||||
if (databaseMessage.contains("uk_crm_customer_code")) {
|
||||
return "客户编码生成冲突,请重试";
|
||||
}
|
||||
if (databaseMessage.contains("uk_crm_opportunity_code")) {
|
||||
return "商机编码生成冲突,请重试";
|
||||
}
|
||||
if (databaseMessage.contains("fk_crm_opportunity_channel_expansion")) {
|
||||
return "所选渠道不存在或已失效,请重新选择";
|
||||
}
|
||||
if (databaseMessage.contains("fk_crm_opportunity_sales_expansion")) {
|
||||
return "所选销售拓展不存在或已失效,请重新选择";
|
||||
}
|
||||
return "数据保存失败,请检查输入后重试";
|
||||
}
|
||||
|
||||
private <T extends Throwable> T findCause(Throwable throwable, Class<T> causeType) {
|
||||
Throwable current = throwable;
|
||||
while (current != null) {
|
||||
if (causeType.isInstance(current)) {
|
||||
return causeType.cast(current);
|
||||
}
|
||||
current = current.getCause();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, Object>> compatibleErrorResponse(
|
||||
HttpStatus status,
|
||||
String message,
|
||||
String path) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("code", "-1");
|
||||
body.put("msg", message);
|
||||
body.put("data", null);
|
||||
body.put("timestamp", OffsetDateTime.now().toString());
|
||||
body.put("status", HttpStatus.INTERNAL_SERVER_ERROR.value());
|
||||
body.put("error", HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase());
|
||||
body.put("message", ex.getMessage());
|
||||
body.put("path", request.getRequestURI());
|
||||
return body;
|
||||
body.put("status", status.value());
|
||||
body.put("error", status.getReasonPhrase());
|
||||
body.put("message", message);
|
||||
body.put("path", path);
|
||||
return ResponseEntity.status(status).body(body);
|
||||
}
|
||||
|
||||
private ResponseEntity<ApiResponse<Object>> errorResponse(HttpStatus status, String message) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
package com.unis.crm.common;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.databind.BeanDescription;
|
||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||
import com.fasterxml.jackson.databind.JavaType;
|
||||
import com.fasterxml.jackson.databind.SerializationConfig;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.module.SimpleModule;
|
||||
import com.fasterxml.jackson.databind.ser.BeanPropertyWriter;
|
||||
import com.fasterxml.jackson.databind.ser.BeanSerializerModifier;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class CrmIdJacksonConfig {
|
||||
|
||||
private static final long JS_MAX_SAFE_INTEGER = 9_007_199_254_740_991L;
|
||||
|
||||
public static Object toJsonCompatibleValue(Long id) {
|
||||
if (id == null) {
|
||||
return null;
|
||||
}
|
||||
return isJavaScriptSafeInteger(id) ? id : id.toString();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SimpleModule crmIdSerializationModule() {
|
||||
SimpleModule module = new SimpleModule("crm-id-serialization");
|
||||
module.setSerializerModifier(new CrmIdBeanSerializerModifier());
|
||||
return module;
|
||||
}
|
||||
|
||||
private static final class CrmIdBeanSerializerModifier extends BeanSerializerModifier {
|
||||
|
||||
@Override
|
||||
public List<BeanPropertyWriter> changeProperties(
|
||||
SerializationConfig config,
|
||||
BeanDescription beanDesc,
|
||||
List<BeanPropertyWriter> beanProperties) {
|
||||
for (BeanPropertyWriter property : beanProperties) {
|
||||
if (!isIdProperty(property.getName()) || property.getSerializer() != null) {
|
||||
continue;
|
||||
}
|
||||
JavaType type = property.getType();
|
||||
if (isLong(type)) {
|
||||
property.assignSerializer(new CompatibleLongIdSerializer());
|
||||
} else if (type.isCollectionLikeType() && isLong(type.getContentType())) {
|
||||
property.assignSerializer(new LongIdCollectionSerializer());
|
||||
}
|
||||
}
|
||||
return beanProperties;
|
||||
}
|
||||
|
||||
private boolean isIdProperty(String name) {
|
||||
return "id".equalsIgnoreCase(name) || name.endsWith("Id") || name.endsWith("Ids");
|
||||
}
|
||||
|
||||
private boolean isLong(JavaType type) {
|
||||
return type != null && (type.hasRawClass(Long.class) || type.hasRawClass(long.class));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class CompatibleLongIdSerializer extends JsonSerializer<Object> {
|
||||
|
||||
@Override
|
||||
public void serialize(Object value, JsonGenerator generator, SerializerProvider serializers) throws IOException {
|
||||
long id = ((Number) value).longValue();
|
||||
if (isJavaScriptSafeInteger(id)) {
|
||||
generator.writeNumber(id);
|
||||
} else {
|
||||
generator.writeString(Long.toString(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class LongIdCollectionSerializer extends JsonSerializer<Object> {
|
||||
|
||||
@Override
|
||||
public void serialize(Object value, JsonGenerator generator, SerializerProvider serializers) throws IOException {
|
||||
generator.writeStartArray();
|
||||
if (value instanceof Iterable<?> items) {
|
||||
for (Object item : items) {
|
||||
if (item == null) {
|
||||
generator.writeNull();
|
||||
} else {
|
||||
long id = ((Number) item).longValue();
|
||||
if (isJavaScriptSafeInteger(id)) {
|
||||
generator.writeNumber(id);
|
||||
} else {
|
||||
generator.writeString(Long.toString(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
generator.writeEndArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isJavaScriptSafeInteger(long value) {
|
||||
return value >= -JS_MAX_SAFE_INTEGER && value <= JS_MAX_SAFE_INTEGER;
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,8 @@ import org.springframework.stereotype.Component;
|
|||
public class OpportunitySchemaInitializer implements ApplicationRunner {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(OpportunitySchemaInitializer.class);
|
||||
private static final String CUSTOMER_CODE_SEQUENCE = "crm_customer_code_seq";
|
||||
private static final String OPPORTUNITY_CODE_SEQUENCE = "crm_opportunity_code_seq";
|
||||
|
||||
private final DataSource dataSource;
|
||||
|
||||
|
|
@ -26,37 +28,165 @@ public class OpportunitySchemaInitializer implements ApplicationRunner {
|
|||
@Override
|
||||
public void run(ApplicationArguments args) {
|
||||
try (Connection connection = dataSource.getConnection()) {
|
||||
if (!tableExists(connection, "crm_opportunity")) {
|
||||
return;
|
||||
if (tableExists(connection, "crm_opportunity")) {
|
||||
ensureOpportunitySchema(connection);
|
||||
}
|
||||
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)");
|
||||
statement.execute("alter table crm_opportunity add column if not exists project_ownership_location varchar(100)");
|
||||
statement.execute("alter table crm_opportunity add column if not exists latest_progress text");
|
||||
statement.execute("alter table crm_opportunity add column if not exists next_plan text");
|
||||
statement.execute("alter table crm_opportunity add column if not exists updated_by bigint");
|
||||
statement.execute("alter table crm_opportunity add column if not exists archived_at timestamptz");
|
||||
statement.execute("alter table crm_opportunity add column if not exists actual_signed_amount numeric(18, 2)");
|
||||
statement.execute("alter table crm_opportunity add column if not exists is_poc boolean not null default false");
|
||||
statement.execute("create index if not exists idx_crm_opportunity_archived_at on crm_opportunity(archived_at)");
|
||||
statement.execute("comment on column crm_opportunity.project_ownership_location is '业绩归属地编码,对应 cnarea.area_code'");
|
||||
statement.execute("comment on column crm_opportunity.latest_progress is '项目最新进展'");
|
||||
statement.execute("comment on column crm_opportunity.next_plan is '下一步销售计划'");
|
||||
statement.execute("comment on column crm_opportunity.updated_by is '更新人ID'");
|
||||
statement.execute("comment on column crm_opportunity.archived_at is '归档时间'");
|
||||
statement.execute("comment on column crm_opportunity.actual_signed_amount is '实际签约金额'");
|
||||
statement.execute("comment on column crm_opportunity.is_poc is '是否POC测试项目'");
|
||||
}
|
||||
ensureArchivedAtStorage(connection);
|
||||
ensureConfidenceGradeStorage(connection);
|
||||
migrateLegacyOmsProjectCode(connection);
|
||||
log.info("Ensured compatibility columns exist for crm_opportunity");
|
||||
ensureSequences(connection);
|
||||
log.info("Ensured CRM compatibility columns and sequences exist");
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Failed to initialize crm_opportunity schema compatibility", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureOpportunitySchema(Connection connection) throws SQLException {
|
||||
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)");
|
||||
statement.execute("alter table crm_opportunity add column if not exists project_ownership_location varchar(100)");
|
||||
statement.execute("alter table crm_opportunity add column if not exists latest_progress text");
|
||||
statement.execute("alter table crm_opportunity add column if not exists next_plan text");
|
||||
statement.execute("alter table crm_opportunity add column if not exists updated_by bigint");
|
||||
statement.execute("alter table crm_opportunity add column if not exists archived_at timestamptz");
|
||||
statement.execute("alter table crm_opportunity add column if not exists actual_signed_amount numeric(18, 2)");
|
||||
statement.execute("alter table crm_opportunity add column if not exists is_poc boolean not null default false");
|
||||
statement.execute("create index if not exists idx_crm_opportunity_archived_at on crm_opportunity(archived_at)");
|
||||
statement.execute("comment on column crm_opportunity.project_ownership_location is '业绩归属地编码,对应 cnarea.area_code'");
|
||||
statement.execute("comment on column crm_opportunity.latest_progress is '项目最新进展'");
|
||||
statement.execute("comment on column crm_opportunity.next_plan is '下一步销售计划'");
|
||||
statement.execute("comment on column crm_opportunity.updated_by is '更新人ID'");
|
||||
statement.execute("comment on column crm_opportunity.archived_at is '归档时间'");
|
||||
statement.execute("comment on column crm_opportunity.actual_signed_amount is '实际签约金额'");
|
||||
statement.execute("comment on column crm_opportunity.is_poc is '是否POC测试项目'");
|
||||
}
|
||||
ensureArchivedAtStorage(connection);
|
||||
ensureConfidenceGradeStorage(connection);
|
||||
migrateLegacyOmsProjectCode(connection);
|
||||
}
|
||||
|
||||
private void ensureSequences(Connection connection) throws SQLException {
|
||||
boolean originalAutoCommit = connection.getAutoCommit();
|
||||
try {
|
||||
connection.setAutoCommit(false);
|
||||
if (tableExists(connection, "crm_customer")) {
|
||||
ensureCodeSequence(connection, CUSTOMER_CODE_SEQUENCE, "crm_customer", "customer_code", "CUS");
|
||||
}
|
||||
if (tableExists(connection, "crm_opportunity")) {
|
||||
ensureCodeSequence(connection, OPPORTUNITY_CODE_SEQUENCE, "crm_opportunity", "opportunity_code", "OPP");
|
||||
}
|
||||
if (tableExists(connection, "crm_sales_expansion")) {
|
||||
ensureIdentitySequence(connection, "crm_sales_expansion", "id");
|
||||
}
|
||||
if (tableExists(connection, "crm_channel_expansion")) {
|
||||
ensureIdentitySequence(connection, "crm_channel_expansion", "id");
|
||||
}
|
||||
connection.commit();
|
||||
} catch (SQLException exception) {
|
||||
rollback(connection, exception);
|
||||
throw exception;
|
||||
} finally {
|
||||
connection.setAutoCommit(originalAutoCommit);
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureCodeSequence(
|
||||
Connection connection,
|
||||
String sequenceName,
|
||||
String tableName,
|
||||
String columnName,
|
||||
String codePrefix) throws SQLException {
|
||||
try (Statement statement = connection.createStatement()) {
|
||||
statement.execute("create sequence if not exists " + sequenceName + " start with 1 increment by 1 minvalue 1");
|
||||
}
|
||||
|
||||
lockSequence(connection, sequenceName);
|
||||
long maxSuffix = selectMaxNumericSuffix(connection, tableName, columnName, codePrefix);
|
||||
long nextSequenceValue = selectNextSequenceValue(connection, sequenceName);
|
||||
if (nextSequenceValue <= maxSuffix) {
|
||||
restartSequence(connection, sequenceName, increment(maxSuffix, sequenceName));
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureIdentitySequence(Connection connection, String tableName, String columnName) throws SQLException {
|
||||
String sequenceName = selectIdentitySequenceName(connection, tableName, columnName);
|
||||
if (sequenceName == null || sequenceName.isBlank()) {
|
||||
return;
|
||||
}
|
||||
lockSequence(connection, sequenceName);
|
||||
long maxId = selectMaxId(connection, tableName, columnName);
|
||||
long nextSequenceValue = selectNextSequenceValue(connection, sequenceName);
|
||||
if (nextSequenceValue <= maxId) {
|
||||
restartSequence(connection, sequenceName, increment(maxId, sequenceName));
|
||||
}
|
||||
}
|
||||
|
||||
private String selectIdentitySequenceName(Connection connection, String tableName, String columnName) throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement("select pg_get_serial_sequence(?, ?)")) {
|
||||
statement.setString(1, tableName);
|
||||
statement.setString(2, columnName);
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
return resultSet.next() ? resultSet.getString(1) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private long selectMaxId(Connection connection, String tableName, String columnName) throws SQLException {
|
||||
try (Statement statement = connection.createStatement();
|
||||
ResultSet resultSet = statement.executeQuery(
|
||||
"select coalesce(max(" + columnName + "), 0) from " + tableName)) {
|
||||
return resultSet.next() ? resultSet.getLong(1) : 0;
|
||||
}
|
||||
}
|
||||
|
||||
private void lockSequence(Connection connection, String sequenceName) throws SQLException {
|
||||
try (Statement statement = connection.createStatement()) {
|
||||
statement.execute("alter sequence " + sequenceName + " no cycle");
|
||||
}
|
||||
}
|
||||
|
||||
private void restartSequence(Connection connection, String sequenceName, long nextValue) throws SQLException {
|
||||
try (Statement statement = connection.createStatement()) {
|
||||
statement.execute("alter sequence " + sequenceName + " restart with " + nextValue);
|
||||
}
|
||||
}
|
||||
|
||||
private long increment(long value, String sequenceName) throws SQLException {
|
||||
if (value == Long.MAX_VALUE) {
|
||||
throw new SQLException("Sequence " + sequenceName + " has exhausted bigint values");
|
||||
}
|
||||
return value + 1;
|
||||
}
|
||||
|
||||
private void rollback(Connection connection, SQLException originalException) {
|
||||
try {
|
||||
connection.rollback();
|
||||
} catch (SQLException rollbackException) {
|
||||
originalException.addSuppressed(rollbackException);
|
||||
}
|
||||
}
|
||||
|
||||
private long selectMaxNumericSuffix(
|
||||
Connection connection,
|
||||
String tableName,
|
||||
String columnName,
|
||||
String codePrefix) throws SQLException {
|
||||
String sql = "select coalesce(max((substring(" + columnName + " from '([0-9]+)$'))::bigint), 0) from " + tableName
|
||||
+ " where " + columnName + " ~ '^" + codePrefix + "-[0-9]{8}-[0-9]+$'";
|
||||
try (Statement statement = connection.createStatement(); ResultSet resultSet = statement.executeQuery(sql)) {
|
||||
return resultSet.next() ? resultSet.getLong(1) : 0;
|
||||
}
|
||||
}
|
||||
|
||||
private long selectNextSequenceValue(Connection connection, String sequenceName) throws SQLException {
|
||||
try (Statement statement = connection.createStatement();
|
||||
ResultSet resultSet = statement.executeQuery(
|
||||
"select case when is_called then last_value + 1 else last_value end from " + sequenceName)) {
|
||||
if (!resultSet.next()) {
|
||||
throw new SQLException("Unable to read sequence " + sequenceName);
|
||||
}
|
||||
return resultSet.getLong(1);
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureArchivedAtStorage(Connection connection) throws SQLException {
|
||||
try (Statement statement = connection.createStatement()) {
|
||||
statement.execute("""
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.unis.crm.controller;
|
||||
|
||||
import com.unis.crm.common.ApiResponse;
|
||||
import com.unis.crm.common.CrmIdJacksonConfig;
|
||||
import com.unis.crm.common.CurrentUserUtils;
|
||||
import com.unis.crm.dto.expansion.CreateChannelExpansionRequest;
|
||||
import com.unis.crm.dto.expansion.CreateExpansionFollowUpRequest;
|
||||
|
|
@ -85,18 +86,20 @@ public class ExpansionController {
|
|||
|
||||
@PostMapping("/sales")
|
||||
@Log(type = "拓展管理", value = "新增售前拓展")
|
||||
public ApiResponse<Long> createSales(
|
||||
public ApiResponse<Object> createSales(
|
||||
@RequestHeader("X-User-Id") Long userId,
|
||||
@Valid @RequestBody CreateSalesExpansionRequest request) {
|
||||
return ApiResponse.success(expansionService.createSalesExpansion(CurrentUserUtils.requireCurrentUserId(userId), request));
|
||||
Long id = expansionService.createSalesExpansion(CurrentUserUtils.requireCurrentUserId(userId), request);
|
||||
return ApiResponse.success(CrmIdJacksonConfig.toJsonCompatibleValue(id));
|
||||
}
|
||||
|
||||
@PostMapping("/channel")
|
||||
@Log(type = "拓展管理", value = "新增渠道拓展")
|
||||
public ApiResponse<Long> createChannel(
|
||||
public ApiResponse<Object> createChannel(
|
||||
@RequestHeader("X-User-Id") Long userId,
|
||||
@Valid @RequestBody CreateChannelExpansionRequest request) {
|
||||
return ApiResponse.success(expansionService.createChannelExpansion(CurrentUserUtils.requireCurrentUserId(userId), request));
|
||||
Long id = expansionService.createChannelExpansion(CurrentUserUtils.requireCurrentUserId(userId), request);
|
||||
return ApiResponse.success(CrmIdJacksonConfig.toJsonCompatibleValue(id));
|
||||
}
|
||||
|
||||
@PutMapping("/sales/{id}")
|
||||
|
|
|
|||
|
|
@ -91,6 +91,10 @@ public interface OpportunityMapper {
|
|||
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
|
||||
int countOwnedOpportunity(@Param("userId") Long userId, @Param("id") Long id);
|
||||
|
||||
int countSalesExpansionById(@Param("id") Long id);
|
||||
|
||||
int countChannelExpansionById(@Param("id") Long id);
|
||||
|
||||
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
|
||||
Boolean selectArchived(@Param("userId") Long userId, @Param("id") Long id);
|
||||
|
||||
|
|
|
|||
|
|
@ -560,6 +560,16 @@ public class OpportunityServiceImpl implements OpportunityService {
|
|||
request.setLatestProgress(normalizeSnapshotText(request.getLatestProgress()));
|
||||
request.setNextPlan(normalizeSnapshotText(request.getNextPlan()));
|
||||
validateOperatorRelations(request.getOperatorName(), request.getSalesExpansionId(), request.getChannelExpansionId());
|
||||
validateExpansionRelations(request.getSalesExpansionId(), request.getChannelExpansionId());
|
||||
}
|
||||
|
||||
private void validateExpansionRelations(Long salesExpansionId, Long channelExpansionId) {
|
||||
if (salesExpansionId != null && opportunityMapper.countSalesExpansionById(salesExpansionId) <= 0) {
|
||||
throw new BusinessException("所选销售拓展不存在或已失效,请重新选择");
|
||||
}
|
||||
if (channelExpansionId != null && opportunityMapper.countChannelExpansionById(channelExpansionId) <= 0) {
|
||||
throw new BusinessException("所选渠道不存在或已失效,请重新选择");
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeSnapshotText(String value) {
|
||||
|
|
|
|||
|
|
@ -486,7 +486,7 @@
|
|||
updated_at
|
||||
) values (
|
||||
#{id},
|
||||
'CUS-' || to_char(current_date, 'YYYYMMDD') || '-' || lpad((coalesce((select count(1) from crm_customer), 0) + 1)::text, 3, '0'),
|
||||
'CUS-' || to_char(current_date, 'YYYYMMDD') || '-' || lpad(nextval('crm_customer_code_seq')::text, 6, '0'),
|
||||
#{customerName},
|
||||
#{userId},
|
||||
coalesce(#{source}, '主动开发'),
|
||||
|
|
@ -525,7 +525,7 @@
|
|||
created_at,
|
||||
updated_at
|
||||
) values (
|
||||
'OPP-' || to_char(current_date, 'YYYYMMDD') || '-' || lpad((coalesce((select count(1) from crm_opportunity), 0) + 1)::text, 3, '0'),
|
||||
'OPP-' || to_char(current_date, 'YYYYMMDD') || '-' || lpad(nextval('crm_opportunity_code_seq')::text, 6, '0'),
|
||||
#{request.opportunityName},
|
||||
#{customerId},
|
||||
#{userId},
|
||||
|
|
@ -564,6 +564,18 @@
|
|||
where o.id = #{id}
|
||||
</select>
|
||||
|
||||
<select id="countSalesExpansionById" resultType="int">
|
||||
select count(1)
|
||||
from crm_sales_expansion
|
||||
where id = #{id}
|
||||
</select>
|
||||
|
||||
<select id="countChannelExpansionById" resultType="int">
|
||||
select count(1)
|
||||
from crm_channel_expansion
|
||||
where id = #{id}
|
||||
</select>
|
||||
|
||||
<select id="selectArchived" resultType="java.lang.Boolean">
|
||||
select coalesce(archived, false)
|
||||
from crm_opportunity o
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
package com.unis.crm.common;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.unis.crm.dto.opportunity.CreateOpportunityRequest;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class CrmIdJacksonConfigTest {
|
||||
|
||||
@Test
|
||||
void shouldSerializeLongIdsAsStringsWithoutChangingCounts() throws Exception {
|
||||
ObjectMapper objectMapper = new ObjectMapper();
|
||||
objectMapper.registerModule(new CrmIdJacksonConfig().crmIdSerializationModule());
|
||||
|
||||
JsonNode json = objectMapper.readTree(objectMapper.writeValueAsString(new TestPayload()));
|
||||
|
||||
assertEquals("2074420816598036481", json.path("id").asText());
|
||||
assertTrue(json.path("id").isTextual());
|
||||
assertEquals(50L, json.path("count").asLong());
|
||||
assertTrue(json.path("count").isIntegralNumber());
|
||||
assertEquals(42L, json.path("userId").asLong());
|
||||
assertTrue(json.path("userId").isIntegralNumber());
|
||||
assertEquals("2074420816598036481", json.path("ownerUserIds").get(0).asText());
|
||||
assertTrue(json.path("ownerUserIds").get(0).isTextual());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDeserializeStringIdIntoLongRequestFieldWithoutPrecisionLoss() throws Exception {
|
||||
ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
CreateOpportunityRequest request = objectMapper.readValue(
|
||||
"{\"channelExpansionId\":\"2074420816598036481\"}",
|
||||
CreateOpportunityRequest.class);
|
||||
|
||||
assertEquals(2_074_420_816_598_036_481L, request.getChannelExpansionId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldKeepSafeResponseIdsNumericAndConvertUnsafeIdsToStrings() {
|
||||
assertEquals(42L, CrmIdJacksonConfig.toJsonCompatibleValue(42L));
|
||||
assertEquals(
|
||||
"2074420816598036481",
|
||||
CrmIdJacksonConfig.toJsonCompatibleValue(2_074_420_816_598_036_481L));
|
||||
}
|
||||
|
||||
static class TestPayload {
|
||||
public Long getId() {
|
||||
return 2_074_420_816_598_036_481L;
|
||||
}
|
||||
|
||||
public Long getCount() {
|
||||
return 50L;
|
||||
}
|
||||
|
||||
public Long getUserId() {
|
||||
return 42L;
|
||||
}
|
||||
|
||||
public List<Long> getOwnerUserIds() {
|
||||
return List.of(2_074_420_816_598_036_481L);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
package com.unis.crm.controller;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.unis.crm.common.ApiResponse;
|
||||
import com.unis.crm.dto.expansion.CreateChannelExpansionRequest;
|
||||
import com.unis.crm.dto.expansion.CreateSalesExpansionRequest;
|
||||
import com.unis.crm.service.ExpansionService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ExpansionControllerIdContractTest {
|
||||
|
||||
@Mock
|
||||
private ExpansionService expansionService;
|
||||
|
||||
@InjectMocks
|
||||
private ExpansionController expansionController;
|
||||
|
||||
@Test
|
||||
void createSalesShouldKeepSafeIdNumeric() {
|
||||
CreateSalesExpansionRequest request = new CreateSalesExpansionRequest();
|
||||
when(expansionService.createSalesExpansion(1L, request)).thenReturn(42L);
|
||||
|
||||
ApiResponse<Object> response = expansionController.createSales(1L, request);
|
||||
|
||||
assertInstanceOf(Long.class, response.getData());
|
||||
assertEquals(42L, response.getData());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createChannelShouldReturnUnsafeIdAsString() {
|
||||
CreateChannelExpansionRequest request = new CreateChannelExpansionRequest();
|
||||
long unsafeId = 2_074_420_816_598_036_481L;
|
||||
when(expansionService.createChannelExpansion(1L, request)).thenReturn(unsafeId);
|
||||
|
||||
ApiResponse<Object> response = expansionController.createChannel(1L, request);
|
||||
|
||||
assertInstanceOf(String.class, response.getData());
|
||||
assertEquals("2074420816598036481", response.getData());
|
||||
}
|
||||
}
|
||||
|
|
@ -3,13 +3,16 @@ package com.unis.crm.controller;
|
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||
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 java.sql.SQLException;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
|
@ -44,6 +47,22 @@ class ProtocolExceptionHandlingWebMvcTest {
|
|||
.andExpect(status().isUnsupportedMediaType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void databaseConstraintDetailsShouldNotLeakToClient() throws Exception {
|
||||
mockMvc.perform(get("/protocol-test/database-error"))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.msg").value("所选渠道不存在或已失效,请重新选择"))
|
||||
.andExpect(jsonPath("$.message").value("所选渠道不存在或已失效,请重新选择"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unexpectedExceptionDetailsShouldNotLeakToClient() throws Exception {
|
||||
mockMvc.perform(get("/protocol-test/unexpected-error"))
|
||||
.andExpect(status().isInternalServerError())
|
||||
.andExpect(jsonPath("$.msg").value("系统内部错误,请稍后重试"))
|
||||
.andExpect(jsonPath("$.message").value("系统内部错误,请稍后重试"));
|
||||
}
|
||||
|
||||
@RestController
|
||||
static class ProtocolTestController {
|
||||
|
||||
|
|
@ -51,5 +70,17 @@ class ProtocolExceptionHandlingWebMvcTest {
|
|||
public Map<String, Object> jsonOnly(@RequestBody Map<String, Object> body) {
|
||||
return body;
|
||||
}
|
||||
|
||||
@GetMapping("/protocol-test/database-error")
|
||||
public void databaseError() {
|
||||
throw new DataIntegrityViolationException(
|
||||
"mapper SQL leaked",
|
||||
new SQLException("violates constraint fk_crm_opportunity_channel_expansion"));
|
||||
}
|
||||
|
||||
@GetMapping("/protocol-test/unexpected-error")
|
||||
public void unexpectedError() {
|
||||
throw new IllegalStateException("sensitive mapper and SQL details");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -121,6 +121,24 @@ class OpportunityServiceImplTest {
|
|||
verify(opportunityMapper, never()).insertOpportunity(any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createOpportunity_shouldRejectMissingChannelBeforeWriting() {
|
||||
when(permissionService.hasPermi("opportunity:create")).thenReturn(true);
|
||||
long channelId = 2_074_420_816_598_036_481L;
|
||||
when(opportunityMapper.countChannelExpansionById(channelId)).thenReturn(0);
|
||||
CreateOpportunityRequest request = updateRequest("客户A");
|
||||
request.setOperatorName("渠道");
|
||||
request.setChannelExpansionId(channelId);
|
||||
|
||||
BusinessException exception = assertThrows(
|
||||
BusinessException.class,
|
||||
() -> opportunityService.createOpportunity(1L, request));
|
||||
|
||||
assertEquals("所选渠道不存在或已失效,请重新选择", exception.getMessage());
|
||||
verify(opportunityMapper, never()).insertCustomer(any(), any(), any(), any());
|
||||
verify(opportunityMapper, never()).insertOpportunity(any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateOpportunity_shouldKeepCurrentCustomerWhenCustomerNameUnchanged() {
|
||||
when(opportunityMapper.countOwnedOpportunity(1L, 10L)).thenReturn(1);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
export type CrmId = string | number;
|
||||
|
||||
export interface CaptchaResponse {
|
||||
captchaId: string;
|
||||
imageBase64: string;
|
||||
}
|
||||
|
||||
export interface TenantInfo {
|
||||
tenantId: number;
|
||||
tenantId: CrmId;
|
||||
tenantCode: string;
|
||||
tenantName: string;
|
||||
}
|
||||
|
|
@ -26,8 +28,8 @@ export interface LoginPayload {
|
|||
}
|
||||
|
||||
export interface UserProfile {
|
||||
userId: number;
|
||||
tenantId: number;
|
||||
userId: CrmId;
|
||||
tenantId: CrmId;
|
||||
username: string;
|
||||
displayName: string;
|
||||
email?: string;
|
||||
|
|
@ -41,14 +43,14 @@ export interface UserProfile {
|
|||
}
|
||||
|
||||
export interface UserRole {
|
||||
roleId?: number;
|
||||
roleId?: CrmId;
|
||||
roleCode?: string;
|
||||
roleName?: string;
|
||||
}
|
||||
|
||||
export interface UserPermission {
|
||||
permId?: number;
|
||||
parentId?: number;
|
||||
permId?: CrmId;
|
||||
parentId?: CrmId;
|
||||
name?: string;
|
||||
code: string;
|
||||
permType?: string;
|
||||
|
|
@ -57,12 +59,12 @@ export interface UserPermission {
|
|||
}
|
||||
|
||||
export interface AdminUserSummary {
|
||||
userId: number;
|
||||
userId: CrmId;
|
||||
username: string;
|
||||
displayName: string;
|
||||
status?: number;
|
||||
tenantId?: number;
|
||||
orgId?: number;
|
||||
tenantId?: CrmId;
|
||||
orgId?: CrmId;
|
||||
isAdmin?: boolean;
|
||||
isPlatformAdmin?: boolean;
|
||||
roleCodes?: string[];
|
||||
|
|
@ -108,13 +110,13 @@ export interface DashboardTodo {
|
|||
}
|
||||
|
||||
export interface DashboardActivity {
|
||||
id: number;
|
||||
id: CrmId;
|
||||
bizType?: string;
|
||||
bizId?: number;
|
||||
bizId?: CrmId;
|
||||
actionType?: string;
|
||||
title?: string;
|
||||
content?: string;
|
||||
operatorUserId?: number;
|
||||
operatorUserId?: CrmId;
|
||||
operatorName?: string;
|
||||
targetTab?: string;
|
||||
createdAt?: string;
|
||||
|
|
@ -122,13 +124,13 @@ export interface DashboardActivity {
|
|||
}
|
||||
|
||||
export interface DashboardMessage {
|
||||
id: number;
|
||||
reportId?: number;
|
||||
senderUserId?: number;
|
||||
id: CrmId;
|
||||
reportId?: CrmId;
|
||||
senderUserId?: CrmId;
|
||||
senderName?: string;
|
||||
reportDate?: string;
|
||||
bizType?: string;
|
||||
bizId?: number;
|
||||
bizId?: CrmId;
|
||||
bizName?: string;
|
||||
content?: string;
|
||||
lineIndexes?: string;
|
||||
|
|
@ -138,7 +140,7 @@ export interface DashboardMessage {
|
|||
}
|
||||
|
||||
export interface DashboardAnalyticsCard {
|
||||
id?: number;
|
||||
id?: CrmId;
|
||||
cardKey?: string;
|
||||
groupName?: string;
|
||||
title?: string;
|
||||
|
|
@ -180,7 +182,7 @@ export interface DashboardAnalyticsPanel {
|
|||
}
|
||||
|
||||
export interface DashboardHome {
|
||||
userId?: number;
|
||||
userId?: CrmId;
|
||||
realName?: string;
|
||||
jobTitle?: string;
|
||||
deptName?: string;
|
||||
|
|
@ -197,7 +199,7 @@ export interface DashboardHome {
|
|||
}
|
||||
|
||||
export interface ProfileOverview {
|
||||
userId?: number;
|
||||
userId?: CrmId;
|
||||
monthlyOpportunityCount?: number;
|
||||
monthlyExpansionCount?: number;
|
||||
averageScore?: number;
|
||||
|
|
@ -209,14 +211,14 @@ export interface ProfileOverview {
|
|||
}
|
||||
|
||||
export interface UpdateCurrentUserProfilePayload {
|
||||
userId?: number;
|
||||
userId?: CrmId;
|
||||
username?: string;
|
||||
displayName?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
pwdResetRequired?: number;
|
||||
isPlatformAdmin?: boolean;
|
||||
orgId?: number;
|
||||
orgId?: CrmId;
|
||||
}
|
||||
|
||||
export interface UpdateCurrentUserPasswordPayload {
|
||||
|
|
@ -230,17 +232,17 @@ export interface OwnerTransferConflict {
|
|||
}
|
||||
|
||||
export interface OwnerTransferItem {
|
||||
id: number;
|
||||
id: CrmId;
|
||||
name: string;
|
||||
code?: string;
|
||||
conflict: boolean;
|
||||
}
|
||||
|
||||
export interface OwnerTransferPreview {
|
||||
tenantId: number;
|
||||
fromUserId: number;
|
||||
tenantId: CrmId;
|
||||
fromUserId: CrmId;
|
||||
fromUserName: string;
|
||||
toUserId: number;
|
||||
toUserId: CrmId;
|
||||
toUserName: string;
|
||||
opportunityCount: number;
|
||||
salesExpansionCount: number;
|
||||
|
|
@ -252,14 +254,14 @@ export interface OwnerTransferPreview {
|
|||
}
|
||||
|
||||
export interface OwnerTransferSelection {
|
||||
opportunityIds?: number[];
|
||||
salesExpansionIds?: number[];
|
||||
channelExpansionIds?: number[];
|
||||
opportunityIds?: CrmId[];
|
||||
salesExpansionIds?: CrmId[];
|
||||
channelExpansionIds?: CrmId[];
|
||||
}
|
||||
|
||||
export interface OwnerTransferPayload {
|
||||
fromUserId: number;
|
||||
toUserId: number;
|
||||
fromUserId: CrmId;
|
||||
toUserId: CrmId;
|
||||
transferOpportunities: boolean;
|
||||
transferSalesExpansions: boolean;
|
||||
transferChannelExpansions: boolean;
|
||||
|
|
@ -267,16 +269,16 @@ export interface OwnerTransferPayload {
|
|||
}
|
||||
|
||||
export interface OwnerTransferResult {
|
||||
tenantId: number;
|
||||
fromUserId: number;
|
||||
toUserId: number;
|
||||
tenantId: CrmId;
|
||||
fromUserId: CrmId;
|
||||
toUserId: CrmId;
|
||||
transferredOpportunityCount: number;
|
||||
transferredSalesExpansionCount: number;
|
||||
transferredChannelExpansionCount: number;
|
||||
}
|
||||
|
||||
export interface WorkCheckIn {
|
||||
id: number;
|
||||
id: CrmId;
|
||||
date?: string;
|
||||
time?: string;
|
||||
locationText?: string;
|
||||
|
|
@ -286,14 +288,14 @@ export interface WorkCheckIn {
|
|||
latitude?: number;
|
||||
photoUrls?: string[];
|
||||
bizType?: "sales" | "channel" | "opportunity";
|
||||
bizId?: number;
|
||||
bizId?: CrmId;
|
||||
bizName?: string;
|
||||
userName?: string;
|
||||
deptName?: string;
|
||||
}
|
||||
|
||||
export interface WorkDailyReport {
|
||||
id: number;
|
||||
id: CrmId;
|
||||
date?: string;
|
||||
submitTime?: string;
|
||||
workContent?: string;
|
||||
|
|
@ -308,7 +310,7 @@ export interface WorkDailyReport {
|
|||
}
|
||||
|
||||
export interface WorkHistoryItem {
|
||||
id: number;
|
||||
id: CrmId;
|
||||
type?: string;
|
||||
date?: string;
|
||||
time?: string;
|
||||
|
|
@ -333,7 +335,7 @@ export interface UploadReportAttachmentOptions {
|
|||
}
|
||||
|
||||
export interface WorkReportToUser {
|
||||
userId: number;
|
||||
userId: CrmId;
|
||||
name: string;
|
||||
username?: string;
|
||||
}
|
||||
|
|
@ -411,7 +413,7 @@ export interface CreateWorkCheckInPayload {
|
|||
latitude?: number;
|
||||
photoUrls?: string[];
|
||||
bizType?: "sales" | "channel" | "opportunity";
|
||||
bizId?: number;
|
||||
bizId?: CrmId;
|
||||
bizName?: string;
|
||||
userName?: string;
|
||||
deptName?: string;
|
||||
|
|
@ -420,7 +422,7 @@ export interface CreateWorkCheckInPayload {
|
|||
export interface WorkReportLineItem {
|
||||
workDate: string;
|
||||
bizType: "sales" | "channel" | "opportunity";
|
||||
bizId: number;
|
||||
bizId: CrmId;
|
||||
bizName?: string;
|
||||
editorText?: string;
|
||||
content: string;
|
||||
|
|
@ -449,8 +451,8 @@ export interface CreateWorkDailyReportPayload {
|
|||
}
|
||||
|
||||
export interface OpportunityFollowUp {
|
||||
id: number;
|
||||
opportunityId?: number;
|
||||
id: CrmId;
|
||||
opportunityId?: CrmId;
|
||||
date?: string;
|
||||
type?: string;
|
||||
content?: string;
|
||||
|
|
@ -464,8 +466,8 @@ export interface OpportunityFollowUp {
|
|||
}
|
||||
|
||||
export interface OpportunityItem {
|
||||
id: number;
|
||||
ownerUserId?: number;
|
||||
id: CrmId;
|
||||
ownerUserId?: CrmId;
|
||||
code?: string;
|
||||
name?: string;
|
||||
client?: string;
|
||||
|
|
@ -490,15 +492,15 @@ export interface OpportunityItem {
|
|||
isPoc?: boolean;
|
||||
product?: string;
|
||||
source?: string;
|
||||
salesExpansionId?: number;
|
||||
salesExpansionId?: CrmId;
|
||||
salesExpansionName?: string;
|
||||
salesExpansionIntent?: string;
|
||||
salesExpansionActive?: boolean;
|
||||
channelExpansionId?: number;
|
||||
channelExpansionId?: CrmId;
|
||||
channelExpansionName?: string;
|
||||
channelExpansionIntent?: string;
|
||||
channelExpansionEstablishedDate?: string;
|
||||
preSalesId?: number;
|
||||
preSalesId?: CrmId;
|
||||
preSalesName?: string;
|
||||
competitorName?: string;
|
||||
latestProgress?: string;
|
||||
|
|
@ -527,7 +529,7 @@ export interface OpportunityMeta {
|
|||
}
|
||||
|
||||
export interface OmsPreSalesOption {
|
||||
userId: number;
|
||||
userId: CrmId;
|
||||
loginName?: string;
|
||||
userName?: string;
|
||||
}
|
||||
|
|
@ -545,8 +547,8 @@ export interface CreateOpportunityPayload {
|
|||
opportunityType?: string;
|
||||
productType?: string;
|
||||
source?: string;
|
||||
salesExpansionId?: number;
|
||||
channelExpansionId?: number;
|
||||
salesExpansionId?: CrmId;
|
||||
channelExpansionId?: CrmId;
|
||||
competitorName?: string;
|
||||
latestProgress?: string;
|
||||
nextPlan?: string;
|
||||
|
|
@ -555,7 +557,7 @@ export interface CreateOpportunityPayload {
|
|||
}
|
||||
|
||||
export interface PushOpportunityToOmsPayload {
|
||||
preSalesId?: number;
|
||||
preSalesId?: CrmId;
|
||||
preSalesName?: string;
|
||||
}
|
||||
|
||||
|
|
@ -567,8 +569,8 @@ export interface CreateOpportunityFollowUpPayload {
|
|||
}
|
||||
|
||||
export interface ExpansionFollowUp {
|
||||
id: number;
|
||||
bizId?: number;
|
||||
id: CrmId;
|
||||
bizId?: CrmId;
|
||||
bizType?: string;
|
||||
date?: string;
|
||||
type?: string;
|
||||
|
|
@ -580,8 +582,8 @@ export interface ExpansionFollowUp {
|
|||
}
|
||||
|
||||
export interface SalesExpansionItem {
|
||||
id: number;
|
||||
ownerUserId?: number;
|
||||
id: CrmId;
|
||||
ownerUserId?: CrmId;
|
||||
owner?: string;
|
||||
type: "sales";
|
||||
createdAt?: string;
|
||||
|
|
@ -612,7 +614,7 @@ export interface SalesExpansionItem {
|
|||
}
|
||||
|
||||
export interface RelatedProjectSummary {
|
||||
opportunityId: number;
|
||||
opportunityId: CrmId;
|
||||
opportunityCode?: string;
|
||||
opportunityName?: string;
|
||||
stageCode?: string;
|
||||
|
|
@ -621,8 +623,8 @@ export interface RelatedProjectSummary {
|
|||
}
|
||||
|
||||
export interface ChannelExpansionItem {
|
||||
id: number;
|
||||
ownerUserId?: number;
|
||||
id: CrmId;
|
||||
ownerUserId?: CrmId;
|
||||
owner?: string;
|
||||
type: "channel";
|
||||
createdAt?: string;
|
||||
|
|
@ -662,14 +664,14 @@ export interface ChannelExpansionItem {
|
|||
}
|
||||
|
||||
export interface ChannelExpansionContact {
|
||||
id?: number;
|
||||
id?: CrmId;
|
||||
name?: string;
|
||||
mobile?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface ChannelRelatedProjectSummary {
|
||||
opportunityId: number;
|
||||
opportunityId: CrmId;
|
||||
opportunityCode?: string;
|
||||
opportunityName?: string;
|
||||
stageCode?: string;
|
||||
|
|
@ -843,8 +845,8 @@ const AUTH_REQUIRED_MESSAGE_PATTERNS = [
|
|||
|
||||
type AccessTokenPayload = {
|
||||
exp?: number;
|
||||
userId?: number;
|
||||
tenantId?: number;
|
||||
userId?: CrmId;
|
||||
tenantId?: CrmId;
|
||||
};
|
||||
|
||||
function isSuccessCode(code: unknown) {
|
||||
|
|
@ -1223,8 +1225,9 @@ function getStoredUserId() {
|
|||
const rawProfile = sessionStorage.getItem("userProfile");
|
||||
if (rawProfile) {
|
||||
const profile = JSON.parse(rawProfile) as Partial<UserProfile>;
|
||||
if (typeof profile.userId === "number" && Number.isFinite(profile.userId)) {
|
||||
return profile.userId;
|
||||
const profileUserId = normalizeStoredId(profile.userId);
|
||||
if (profileUserId) {
|
||||
return profileUserId;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -1238,8 +1241,9 @@ function getStoredUserId() {
|
|||
}
|
||||
|
||||
const tokenPayload = getAccessTokenPayload(token);
|
||||
if (typeof tokenPayload.userId === "number" && Number.isFinite(tokenPayload.userId)) {
|
||||
return tokenPayload.userId;
|
||||
const tokenUserId = normalizeStoredId(tokenPayload.userId);
|
||||
if (tokenUserId) {
|
||||
return tokenUserId;
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
|
|
@ -1248,6 +1252,16 @@ function getStoredUserId() {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeStoredId(value: unknown): CrmId | undefined {
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
if (typeof value === "number" && Number.isSafeInteger(value) && value > 0) {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getStoredCurrentUserId() {
|
||||
return getStoredUserId();
|
||||
}
|
||||
|
|
@ -1369,7 +1383,7 @@ export async function completeDashboardTodo(todoId: string) {
|
|||
}, true);
|
||||
}
|
||||
|
||||
export async function readDashboardMessage(messageId: number) {
|
||||
export async function readDashboardMessage(messageId: CrmId) {
|
||||
return request<void>(`/api/dashboard/messages/${messageId}/read`, {
|
||||
method: "POST",
|
||||
}, true);
|
||||
|
|
@ -1398,14 +1412,14 @@ export async function updateCurrentUserPassword(payload: UpdateCurrentUserPasswo
|
|||
}, true);
|
||||
}
|
||||
|
||||
export async function updateUserProfileById(userId: number, payload: UpdateCurrentUserProfilePayload) {
|
||||
export async function updateUserProfileById(userId: CrmId, payload: UpdateCurrentUserProfilePayload) {
|
||||
return request<boolean>(`/api/sys/api/users/${userId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
}, true);
|
||||
}
|
||||
|
||||
export async function listAdminUsers(params?: { tenantId?: number; orgId?: number }) {
|
||||
export async function listAdminUsers(params?: { tenantId?: CrmId; orgId?: CrmId }) {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.tenantId !== undefined) {
|
||||
searchParams.set("tenantId", String(params.tenantId));
|
||||
|
|
@ -1417,7 +1431,7 @@ export async function listAdminUsers(params?: { tenantId?: number; orgId?: numbe
|
|||
return request<AdminUserSummary[]>(`/api/sys/api/users${query ? `?${query}` : ""}`, undefined, true);
|
||||
}
|
||||
|
||||
export async function previewOwnerTransfer(fromUserId: number, toUserId: number) {
|
||||
export async function previewOwnerTransfer(fromUserId: CrmId, toUserId: CrmId) {
|
||||
const params = new URLSearchParams({
|
||||
fromUserId: String(fromUserId),
|
||||
toUserId: String(toUserId),
|
||||
|
|
@ -1485,7 +1499,7 @@ export async function getWorkHistory(type: "checkin" | "report", page = 1, size
|
|||
return request<WorkHistoryPage>(`/api/work/history?${params.toString()}`, undefined, true);
|
||||
}
|
||||
|
||||
export async function getWorkReportHistoryItem(reportId: number) {
|
||||
export async function getWorkReportHistoryItem(reportId: CrmId) {
|
||||
return request<WorkHistoryItem>(`/api/work/history/reports/${reportId}`, undefined, true);
|
||||
}
|
||||
|
||||
|
|
@ -1605,7 +1619,7 @@ export async function getOpportunityOverview(keyword?: string, stage?: string) {
|
|||
return request<OpportunityOverview>(`/api/opportunities/overview${query ? `?${query}` : ""}`, undefined, true);
|
||||
}
|
||||
|
||||
export async function getOpportunityDetail(opportunityId: number) {
|
||||
export async function getOpportunityDetail(opportunityId: CrmId) {
|
||||
return request<OpportunityItem>(`/api/opportunities/${opportunityId}`, undefined, true);
|
||||
}
|
||||
|
||||
|
|
@ -1624,21 +1638,21 @@ export async function createOpportunity(payload: CreateOpportunityPayload) {
|
|||
}, true);
|
||||
}
|
||||
|
||||
export async function updateOpportunity(opportunityId: number, payload: CreateOpportunityPayload) {
|
||||
export async function updateOpportunity(opportunityId: CrmId, payload: CreateOpportunityPayload) {
|
||||
return request<number>(`/api/opportunities/${opportunityId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
}, true);
|
||||
}
|
||||
|
||||
export async function pushOpportunityToOms(opportunityId: number, payload?: PushOpportunityToOmsPayload) {
|
||||
export async function pushOpportunityToOms(opportunityId: CrmId, payload?: PushOpportunityToOmsPayload) {
|
||||
return request<number>(`/api/opportunities/${opportunityId}/push-oms`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload ?? {}),
|
||||
}, true);
|
||||
}
|
||||
|
||||
export async function createOpportunityFollowUp(opportunityId: number, payload: CreateOpportunityFollowUpPayload) {
|
||||
export async function createOpportunityFollowUp(opportunityId: CrmId, payload: CreateOpportunityFollowUpPayload) {
|
||||
return request<number>(`/api/opportunities/${opportunityId}/followups`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
|
|
@ -1675,7 +1689,7 @@ export async function getExpansionCityOptions(provinceName: string) {
|
|||
return request<ExpansionDictOption[]>(`/api/expansion/areas/cities?${params.toString()}`, undefined, true);
|
||||
}
|
||||
|
||||
export async function checkSalesExpansionDuplicate(employeeNo: string, excludeId?: number) {
|
||||
export async function checkSalesExpansionDuplicate(employeeNo: string, excludeId?: CrmId) {
|
||||
const params = new URLSearchParams({ employeeNo });
|
||||
if (excludeId) {
|
||||
params.set("excludeId", String(excludeId));
|
||||
|
|
@ -1683,7 +1697,7 @@ export async function checkSalesExpansionDuplicate(employeeNo: string, excludeId
|
|||
return request<ExpansionDuplicateCheck>(`/api/expansion/sales/duplicate-check?${params.toString()}`, undefined, true);
|
||||
}
|
||||
|
||||
export async function checkChannelExpansionDuplicate(channelName: string, excludeId?: number) {
|
||||
export async function checkChannelExpansionDuplicate(channelName: string, excludeId?: CrmId) {
|
||||
const params = new URLSearchParams({ channelName });
|
||||
if (excludeId) {
|
||||
params.set("excludeId", String(excludeId));
|
||||
|
|
@ -1692,27 +1706,27 @@ export async function checkChannelExpansionDuplicate(channelName: string, exclud
|
|||
}
|
||||
|
||||
export async function createSalesExpansion(payload: CreateSalesExpansionPayload) {
|
||||
return request<number>("/api/expansion/sales", {
|
||||
return request<CrmId>("/api/expansion/sales", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
}, true);
|
||||
}
|
||||
|
||||
export async function createChannelExpansion(payload: CreateChannelExpansionPayload) {
|
||||
return request<number>("/api/expansion/channel", {
|
||||
return request<CrmId>("/api/expansion/channel", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(serializeChannelExpansionPayload(payload)),
|
||||
}, true);
|
||||
}
|
||||
|
||||
export async function updateSalesExpansion(id: number, payload: UpdateSalesExpansionPayload) {
|
||||
export async function updateSalesExpansion(id: CrmId, payload: UpdateSalesExpansionPayload) {
|
||||
return request<void>(`/api/expansion/sales/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
}, true);
|
||||
}
|
||||
|
||||
export async function updateChannelExpansion(id: number, payload: UpdateChannelExpansionPayload) {
|
||||
export async function updateChannelExpansion(id: CrmId, payload: UpdateChannelExpansionPayload) {
|
||||
return request<void>(`/api/expansion/channel/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(serializeChannelExpansionPayload(payload)),
|
||||
|
|
@ -1721,7 +1735,7 @@ export async function updateChannelExpansion(id: number, payload: UpdateChannelE
|
|||
|
||||
export async function createExpansionFollowUp(
|
||||
bizType: "sales" | "channel",
|
||||
bizId: number,
|
||||
bizId: CrmId,
|
||||
payload: CreateExpansionFollowUpPayload,
|
||||
) {
|
||||
return request<number>(`/api/expansion/${bizType}/${bizId}/followups`, {
|
||||
|
|
|
|||
|
|
@ -715,9 +715,9 @@ export default function Dashboard() {
|
|||
const visibleHistoryTodos = showAllHistoryTodos ? historyTodos : historyTodos.slice(0, DASHBOARD_HISTORY_PREVIEW_COUNT);
|
||||
const activities = (home.activities?.length
|
||||
? home.activities
|
||||
: [{ id: 0, title: "无", content: "无", timeText: "无" }]) as DashboardActivity[];
|
||||
: [{ id: "0", title: "无", content: "无", timeText: "无" }]) as DashboardActivity[];
|
||||
const visibleActivities = showAllActivities ? activities : activities.slice(0, DASHBOARD_PREVIEW_COUNT);
|
||||
const hasMoreActivities = activities.length > DASHBOARD_PREVIEW_COUNT && activities[0]?.id !== 0;
|
||||
const hasMoreActivities = activities.length > DASHBOARD_PREVIEW_COUNT && activities[0]?.id !== "0";
|
||||
const hasMoreHistoryTodos = historyTodos.length > DASHBOARD_HISTORY_PREVIEW_COUNT;
|
||||
const showStatsCard = home.statsCardVisible !== false;
|
||||
const showTodoCard = home.todoCardVisible !== false;
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
updateSalesExpansion,
|
||||
type ChannelExpansionContact,
|
||||
type ChannelExpansionItem,
|
||||
type CrmId,
|
||||
type CreateChannelExpansionPayload,
|
||||
type CreateSalesExpansionPayload,
|
||||
type ExpansionDictOption,
|
||||
|
|
@ -32,7 +33,7 @@ import { cn } from "@/lib/utils";
|
|||
|
||||
type ExpansionItem = SalesExpansionItem | ChannelExpansionItem;
|
||||
type ExpansionTab = "sales" | "channel";
|
||||
type ExpansionLocationState = { tab?: ExpansionTab; selectedId?: number } | null;
|
||||
type ExpansionLocationState = { tab?: ExpansionTab; selectedId?: CrmId } | null;
|
||||
type ExpansionExportFilters = {
|
||||
keyword?: string;
|
||||
intent?: string;
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
updateOpportunity,
|
||||
type ChannelExpansionContact,
|
||||
type ChannelExpansionItem,
|
||||
type CrmId,
|
||||
type CreateChannelExpansionPayload,
|
||||
type CreateOpportunityPayload,
|
||||
type CreateSalesExpansionPayload,
|
||||
|
|
@ -86,7 +87,7 @@ const COMPETITOR_OPTIONS = [
|
|||
type CompetitorOption = (typeof COMPETITOR_OPTIONS)[number];
|
||||
type OperatorMode = "none" | "h3c" | "channel" | "both";
|
||||
type OpportunityArchiveTab = "active" | "archived";
|
||||
type OpportunityLocationState = { selectedId?: number; archiveTab?: OpportunityArchiveTab } | null;
|
||||
type OpportunityLocationState = { selectedId?: CrmId; archiveTab?: OpportunityArchiveTab } | null;
|
||||
type OpportunityExportFilters = {
|
||||
keyword?: string;
|
||||
expectedStartDate?: string;
|
||||
|
|
@ -769,8 +770,8 @@ function buildOpportunitySubmitPayload(
|
|||
|
||||
function validateOperatorRelations(
|
||||
operatorMode: OperatorMode,
|
||||
salesExpansionId: number | undefined,
|
||||
channelExpansionId: number | undefined,
|
||||
salesExpansionId: CrmId | undefined,
|
||||
channelExpansionId: CrmId | undefined,
|
||||
) {
|
||||
if (operatorMode === "h3c" && !salesExpansionId) {
|
||||
throw new Error("运作方选择“新华三”时,新华三负责人必须填写");
|
||||
|
|
@ -1290,7 +1291,7 @@ function OpportunityExportFilterModal({
|
|||
}
|
||||
|
||||
type SearchableOption = {
|
||||
value: number | string;
|
||||
value: CrmId;
|
||||
label: string;
|
||||
keywords?: string[];
|
||||
};
|
||||
|
|
@ -1372,7 +1373,7 @@ function SearchableSelect({
|
|||
onCreate,
|
||||
onQueryChange,
|
||||
}: {
|
||||
value?: number;
|
||||
value?: CrmId;
|
||||
options: SearchableOption[];
|
||||
placeholder: string;
|
||||
searchPlaceholder: string;
|
||||
|
|
@ -1380,7 +1381,7 @@ function SearchableSelect({
|
|||
loading?: boolean;
|
||||
createActionLabel?: string;
|
||||
className?: string;
|
||||
onChange: (value?: number) => void;
|
||||
onChange: (value?: CrmId) => void;
|
||||
onCreate?: (query: string) => void;
|
||||
onQueryChange?: (query: string) => void;
|
||||
}) {
|
||||
|
|
@ -1512,7 +1513,7 @@ function SearchableSelect({
|
|||
type="button"
|
||||
key={item.value}
|
||||
onClick={() => {
|
||||
onChange(Number(item.value));
|
||||
onChange(item.value);
|
||||
setOpen(false);
|
||||
resetQuery();
|
||||
}}
|
||||
|
|
@ -2594,14 +2595,14 @@ export default function Opportunities() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleSalesExpansionChange = (value?: number) => {
|
||||
const handleSalesExpansionChange = (value?: CrmId) => {
|
||||
setSelectedSalesExpansionOption(
|
||||
value ? salesExpansionSearchOptions.find((option) => option.value === value) ?? null : null,
|
||||
);
|
||||
handleChange("salesExpansionId", value);
|
||||
};
|
||||
|
||||
const handleChannelExpansionChange = (value?: number) => {
|
||||
const handleChannelExpansionChange = (value?: CrmId) => {
|
||||
setSelectedChannelExpansionOption(
|
||||
value ? channelExpansionSearchOptions.find((option) => option.value === value) ?? null : null,
|
||||
);
|
||||
|
|
@ -2716,7 +2717,7 @@ export default function Opportunities() {
|
|||
setQuickCreateSubmitting(true);
|
||||
|
||||
try {
|
||||
let createdId: number;
|
||||
let createdId: CrmId;
|
||||
if (quickCreateType === "sales") {
|
||||
const duplicateResult = await checkSalesExpansionDuplicate(quickSalesForm.employeeNo.trim());
|
||||
if (duplicateResult.duplicated) {
|
||||
|
|
@ -2796,7 +2797,7 @@ export default function Opportunities() {
|
|||
setCustomCompetitorName("");
|
||||
};
|
||||
|
||||
const reload = async (preferredSelectedId?: number) => {
|
||||
const reload = async (preferredSelectedId?: CrmId) => {
|
||||
const data = await getOpportunityOverview(keyword, filter);
|
||||
const nextItems = data.items ?? [];
|
||||
setItems(nextItems);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
listOwnerTransferTargetUsers,
|
||||
previewOwnerTransfer,
|
||||
type AdminUserSummary,
|
||||
type CrmId,
|
||||
type OwnerTransferItem,
|
||||
type OwnerTransferPayload,
|
||||
type OwnerTransferPreview,
|
||||
|
|
@ -31,9 +32,9 @@ import { cn } from "@/lib/utils";
|
|||
type TransferTab = "opportunities" | "sales" | "channels";
|
||||
type TransferKind = "opportunity" | "sales" | "channel";
|
||||
type SelectionState = {
|
||||
opportunityIds: number[];
|
||||
salesExpansionIds: number[];
|
||||
channelExpansionIds: number[];
|
||||
opportunityIds: CrmId[];
|
||||
salesExpansionIds: CrmId[];
|
||||
channelExpansionIds: CrmId[];
|
||||
};
|
||||
|
||||
type TransferConfirmState = {
|
||||
|
|
@ -61,7 +62,7 @@ const TAB_META: Array<{
|
|||
function getActiveTenantId() {
|
||||
const rawValue = localStorage.getItem("activeTenantId");
|
||||
const tenantId = Number(rawValue || 0);
|
||||
return Number.isFinite(tenantId) ? tenantId : 0;
|
||||
return Number.isSafeInteger(tenantId) ? tenantId : 0;
|
||||
}
|
||||
|
||||
function getDisplayUserName(user?: AdminUserSummary | null) {
|
||||
|
|
@ -83,6 +84,18 @@ function countLabel(value?: number) {
|
|||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
function normalizeSelectedId(value?: string): CrmId | undefined {
|
||||
const normalized = value?.trim();
|
||||
if (!normalized || normalized === "0") {
|
||||
return undefined;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isSameCrmId(left?: CrmId | null, right?: CrmId | null) {
|
||||
return left !== undefined && left !== null && right !== undefined && right !== null && String(left) === String(right);
|
||||
}
|
||||
|
||||
function createEmptySelectionState(): SelectionState {
|
||||
return {
|
||||
opportunityIds: [],
|
||||
|
|
@ -145,15 +158,15 @@ export default function OwnerTransfer({
|
|||
|
||||
const activeTenantId = getActiveTenantId();
|
||||
const tenantSelected = activeTenantId > 0;
|
||||
const numericFromUserId = fromUserId ? Number(fromUserId) : undefined;
|
||||
const numericToUserId = toUserId ? Number(toUserId) : undefined;
|
||||
const selectedFromUserId = normalizeSelectedId(fromUserId);
|
||||
const selectedToUserId = normalizeSelectedId(toUserId);
|
||||
const fromUser = useMemo(
|
||||
() => users.find((item) => item.userId === numericFromUserId) ?? currentUser,
|
||||
[currentUser, numericFromUserId, users],
|
||||
() => users.find((item) => isSameCrmId(item.userId, selectedFromUserId)) ?? currentUser,
|
||||
[currentUser, selectedFromUserId, users],
|
||||
);
|
||||
const toUser = useMemo(
|
||||
() => users.find((item) => item.userId === numericToUserId) ?? null,
|
||||
[numericToUserId, users],
|
||||
() => users.find((item) => isSameCrmId(item.userId, selectedToUserId)) ?? null,
|
||||
[selectedToUserId, users],
|
||||
);
|
||||
const selectedPreviewHasSalesConflict = Boolean(preview?.salesConflicts?.length);
|
||||
|
||||
|
|
@ -196,7 +209,7 @@ export default function OwnerTransfer({
|
|||
}
|
||||
}, [activeTenantId, tenantSelected]);
|
||||
|
||||
const loadPreview = useCallback(async (sourceUserId?: number, targetUserId?: number) => {
|
||||
const loadPreview = useCallback(async (sourceUserId?: CrmId, targetUserId?: CrmId) => {
|
||||
if (!currentUser?.userId) {
|
||||
setPreview(null);
|
||||
return;
|
||||
|
|
@ -205,10 +218,8 @@ export default function OwnerTransfer({
|
|||
!tenantSelected
|
||||
|| !sourceUserId
|
||||
|| !targetUserId
|
||||
|| sourceUserId <= 0
|
||||
|| targetUserId <= 0
|
||||
|| sourceUserId === targetUserId
|
||||
|| sourceUserId !== currentUser.userId
|
||||
|| isSameCrmId(sourceUserId, targetUserId)
|
||||
|| !isSameCrmId(sourceUserId, currentUser.userId)
|
||||
) {
|
||||
setPreview(null);
|
||||
return;
|
||||
|
|
@ -236,8 +247,8 @@ export default function OwnerTransfer({
|
|||
}, [loadUsers]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadPreview(numericFromUserId, numericToUserId);
|
||||
}, [loadPreview, numericFromUserId, numericToUserId]);
|
||||
void loadPreview(selectedFromUserId, selectedToUserId);
|
||||
}, [loadPreview, selectedFromUserId, selectedToUserId]);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchKeyword("");
|
||||
|
|
@ -351,11 +362,11 @@ export default function OwnerTransfer({
|
|||
}, [confirmState, runTransfer]);
|
||||
|
||||
const handleExecute = async () => {
|
||||
if (!numericFromUserId || !numericToUserId) {
|
||||
if (!selectedFromUserId || !selectedToUserId) {
|
||||
setPageError("请先选择原归属人和新归属人");
|
||||
return;
|
||||
}
|
||||
if (numericFromUserId === numericToUserId) {
|
||||
if (isSameCrmId(selectedFromUserId, selectedToUserId)) {
|
||||
setPageError("原归属人和新归属人不能相同");
|
||||
return;
|
||||
}
|
||||
|
|
@ -368,8 +379,8 @@ export default function OwnerTransfer({
|
|||
title: "确认执行转移?",
|
||||
description: "",
|
||||
payload: {
|
||||
fromUserId: numericFromUserId,
|
||||
toUserId: numericToUserId,
|
||||
fromUserId: selectedFromUserId,
|
||||
toUserId: selectedToUserId,
|
||||
transferOpportunities: selection.opportunityIds.length > 0,
|
||||
transferSalesExpansions: selection.salesExpansionIds.length > 0,
|
||||
transferChannelExpansions: selection.channelExpansionIds.length > 0,
|
||||
|
|
@ -392,7 +403,7 @@ export default function OwnerTransfer({
|
|||
};
|
||||
|
||||
const handleSingleTransfer = async (kind: TransferKind, item: OwnerTransferItem) => {
|
||||
if (!numericFromUserId || !numericToUserId) {
|
||||
if (!selectedFromUserId || !selectedToUserId) {
|
||||
setPageError("请先选择原归属人和新归属人");
|
||||
return;
|
||||
}
|
||||
|
|
@ -406,8 +417,8 @@ export default function OwnerTransfer({
|
|||
title: "确认转移当前数据?",
|
||||
description: "",
|
||||
payload: {
|
||||
fromUserId: numericFromUserId,
|
||||
toUserId: numericToUserId,
|
||||
fromUserId: selectedFromUserId,
|
||||
toUserId: selectedToUserId,
|
||||
transferOpportunities: kind === "opportunity",
|
||||
transferSalesExpansions: kind === "sales",
|
||||
transferChannelExpansions: kind === "channel",
|
||||
|
|
@ -426,7 +437,7 @@ export default function OwnerTransfer({
|
|||
});
|
||||
};
|
||||
|
||||
const toggleItemSelection = (kind: TransferKind, itemId: number) => {
|
||||
const toggleItemSelection = (kind: TransferKind, itemId: CrmId) => {
|
||||
const selectionKey = getSelectionKey(kind);
|
||||
setSelection((current) => {
|
||||
const currentIds = current[selectionKey];
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import {
|
|||
type ChannelExpansionItem,
|
||||
type ChannelExpansionContact,
|
||||
type CreateChannelExpansionPayload,
|
||||
type CrmId,
|
||||
type CreateOpportunityPayload,
|
||||
type CreateWorkCheckInPayload,
|
||||
type CreateWorkDailyReportPayload,
|
||||
|
|
@ -140,7 +141,7 @@ const LOCATION_DISPLAY_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000;
|
|||
const locationDisplayCache = new Map<string, string>();
|
||||
|
||||
type WorkRelationOption = {
|
||||
id: number;
|
||||
id: CrmId;
|
||||
label: string;
|
||||
};
|
||||
|
||||
|
|
@ -222,7 +223,7 @@ function createEmptyReportLine(): WorkReportLineItem {
|
|||
return {
|
||||
workDate: format(new Date(), "yyyy-MM-dd"),
|
||||
bizType: "sales",
|
||||
bizId: 0,
|
||||
bizId: "",
|
||||
bizName: "",
|
||||
editorText: "",
|
||||
content: "",
|
||||
|
|
@ -896,8 +897,10 @@ export default function Work() {
|
|||
const routeOpenReportId = useMemo(() => {
|
||||
const state = routerLocation.state as { openReportId?: unknown } | null;
|
||||
const rawId = state?.openReportId;
|
||||
const numericId = typeof rawId === "number" ? rawId : Number(rawId);
|
||||
return Number.isFinite(numericId) && numericId > 0 ? numericId : null;
|
||||
if (typeof rawId === "string" && rawId.trim()) {
|
||||
return rawId.trim();
|
||||
}
|
||||
return typeof rawId === "number" && Number.isSafeInteger(rawId) && rawId > 0 ? rawId : null;
|
||||
}, [routerLocation.state]);
|
||||
|
||||
const pickerOptions = useMemo(() => {
|
||||
|
|
@ -928,9 +931,9 @@ export default function Work() {
|
|||
: 0;
|
||||
const opportunityItemsById = useMemo(() => {
|
||||
const entries = opportunityItems
|
||||
.filter((item): item is OpportunityItem & { id: number } => typeof item.id === "number")
|
||||
.filter((item): item is OpportunityItem & { id: CrmId } => isValidCrmId(item.id))
|
||||
.map((item) => [item.id, item] as const);
|
||||
return new Map<number, OpportunityItem>(entries);
|
||||
return new Map<CrmId, OpportunityItem>(entries);
|
||||
}, [opportunityItems]);
|
||||
const quickOpportunityOperatorMode = useMemo(
|
||||
() => resolveOperatorMode(quickOpportunityForm.operatorName, quickOpportunityOperatorOptions),
|
||||
|
|
@ -2752,7 +2755,7 @@ export default function Work() {
|
|||
...salesOptions.map((item) => ({ value: String(item.id), label: item.label })),
|
||||
]}
|
||||
className={getFieldInputClass(Boolean(quickOpportunityFieldErrors.salesExpansionId))}
|
||||
onChange={(value) => handleQuickOpportunityChange("salesExpansionId", value ? Number(value) : undefined)}
|
||||
onChange={(value) => handleQuickOpportunityChange("salesExpansionId", value || undefined)}
|
||||
/>
|
||||
{quickOpportunityFieldErrors.salesExpansionId ? <p className="text-xs text-rose-500">{quickOpportunityFieldErrors.salesExpansionId}</p> : null}
|
||||
</label>
|
||||
|
|
@ -2771,7 +2774,7 @@ export default function Work() {
|
|||
...channelOptions.map((item) => ({ value: String(item.id), label: item.label })),
|
||||
]}
|
||||
className={getFieldInputClass(Boolean(quickOpportunityFieldErrors.channelExpansionId))}
|
||||
onChange={(value) => handleQuickOpportunityChange("channelExpansionId", value ? Number(value) : undefined)}
|
||||
onChange={(value) => handleQuickOpportunityChange("channelExpansionId", value || undefined)}
|
||||
/>
|
||||
{quickOpportunityFieldErrors.channelExpansionId ? <p className="text-xs text-rose-500">{quickOpportunityFieldErrors.channelExpansionId}</p> : null}
|
||||
</label>
|
||||
|
|
@ -5188,9 +5191,9 @@ function hasOnlySeeRole(user: UserProfile | null) {
|
|||
}
|
||||
|
||||
function buildSalesOptions(items: SalesExpansionItem[]): WorkRelationOption[] {
|
||||
const seenIds = new Set<number>();
|
||||
const seenIds = new Set<CrmId>();
|
||||
return items
|
||||
.filter((item): item is SalesExpansionItem & { id: number } => typeof item.id === "number")
|
||||
.filter((item): item is SalesExpansionItem & { id: CrmId } => isValidCrmId(item.id))
|
||||
.filter((item) => {
|
||||
if (seenIds.has(item.id)) {
|
||||
return false;
|
||||
|
|
@ -5202,9 +5205,9 @@ function buildSalesOptions(items: SalesExpansionItem[]): WorkRelationOption[] {
|
|||
}
|
||||
|
||||
function buildChannelOptions(items: ChannelExpansionItem[]): WorkRelationOption[] {
|
||||
const seenIds = new Set<number>();
|
||||
const seenIds = new Set<CrmId>();
|
||||
return items
|
||||
.filter((item): item is ChannelExpansionItem & { id: number } => typeof item.id === "number")
|
||||
.filter((item): item is ChannelExpansionItem & { id: CrmId } => isValidCrmId(item.id))
|
||||
.filter((item) => {
|
||||
if (seenIds.has(item.id)) {
|
||||
return false;
|
||||
|
|
@ -5216,9 +5219,9 @@ function buildChannelOptions(items: ChannelExpansionItem[]): WorkRelationOption[
|
|||
}
|
||||
|
||||
function buildOpportunityOptions(items: OpportunityItem[]): WorkRelationOption[] {
|
||||
const seenIds = new Set<number>();
|
||||
const seenIds = new Set<CrmId>();
|
||||
return items
|
||||
.filter((item): item is OpportunityItem & { id: number } => typeof item.id === "number")
|
||||
.filter((item): item is OpportunityItem & { id: CrmId } => isValidCrmId(item.id))
|
||||
.filter((item) => {
|
||||
if (seenIds.has(item.id)) {
|
||||
return false;
|
||||
|
|
@ -5229,6 +5232,13 @@ function buildOpportunityOptions(items: OpportunityItem[]): WorkRelationOption[]
|
|||
.map((item) => ({ id: item.id, label: item.name || `商机#${item.id}` }));
|
||||
}
|
||||
|
||||
function isValidCrmId(value: CrmId | null | undefined): value is CrmId {
|
||||
if (typeof value === "string") {
|
||||
return Boolean(value.trim());
|
||||
}
|
||||
return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
||||
}
|
||||
|
||||
function getBizTypeLabel(bizType: BizType) {
|
||||
if (bizType === "sales") {
|
||||
return "人员拓展";
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>UnisBase - 智能会议系统</title>
|
||||
<script type="module" crossorigin src="/assets/index-BaO1tuFT.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-CmIS0HGH.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CaWPk49l.css">
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ import PageHeader from "@/components/shared/PageHeader";
|
|||
import { usePermission } from "@/hooks/usePermission";
|
||||
import type { SysUser } from "@/types";
|
||||
import { executeOwnerTransfer, previewOwnerTransfer } from "@/features/owner-transfer/api";
|
||||
import type { OwnerTransferItem, OwnerTransferPayload, OwnerTransferPreview } from "@/features/owner-transfer/types";
|
||||
import type { CrmId, OwnerTransferItem, OwnerTransferPayload, OwnerTransferPreview } from "@/features/owner-transfer/types";
|
||||
import "./index.less";
|
||||
|
||||
interface FormValues {
|
||||
|
|
@ -66,6 +66,12 @@ function filterItems(items: OwnerTransferItem[], keyword: string) {
|
|||
return items.filter((item) => `${item.name || ""} ${item.code || ""}`.toLowerCase().includes(normalizedKeyword));
|
||||
}
|
||||
|
||||
function normalizeSelectedIds(ids: React.Key[]): CrmId[] {
|
||||
return ids.filter((id): id is CrmId =>
|
||||
typeof id === "string" || (typeof id === "number" && Number.isSafeInteger(id))
|
||||
);
|
||||
}
|
||||
|
||||
function hasBlockedTransferRole(user: SysUser) {
|
||||
return (user.roles ?? []).some((role) => {
|
||||
const normalizedRoleCode = role.roleCode?.trim().toUpperCase();
|
||||
|
|
@ -282,9 +288,9 @@ export default function OwnerTransferPage() {
|
|||
transferChannelExpansions: hasRowSelection ? selectedChannelRowKeys.length > 0 : values.transferChannelExpansions,
|
||||
selection: hasRowSelection
|
||||
? {
|
||||
opportunityIds: selectedOpportunityRowKeys.map((id) => Number(id)),
|
||||
salesExpansionIds: selectedSalesRowKeys.map((id) => Number(id)),
|
||||
channelExpansionIds: selectedChannelRowKeys.map((id) => Number(id)),
|
||||
opportunityIds: normalizeSelectedIds(selectedOpportunityRowKeys),
|
||||
salesExpansionIds: normalizeSelectedIds(selectedSalesRowKeys),
|
||||
channelExpansionIds: normalizeSelectedIds(selectedChannelRowKeys),
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
|
@ -369,9 +375,9 @@ export default function OwnerTransferPage() {
|
|||
transferSalesExpansions: selectedSalesRowKeys.length > 0,
|
||||
transferChannelExpansions: selectedChannelRowKeys.length > 0,
|
||||
selection: {
|
||||
opportunityIds: selectedOpportunityRowKeys.map((id) => Number(id)),
|
||||
salesExpansionIds: selectedSalesRowKeys.map((id) => Number(id)),
|
||||
channelExpansionIds: selectedChannelRowKeys.map((id) => Number(id)),
|
||||
opportunityIds: normalizeSelectedIds(selectedOpportunityRowKeys),
|
||||
salesExpansionIds: normalizeSelectedIds(selectedSalesRowKeys),
|
||||
channelExpansionIds: normalizeSelectedIds(selectedChannelRowKeys),
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
export type CrmId = string | number;
|
||||
|
||||
export interface OwnerTransferConflict {
|
||||
employeeNo: string;
|
||||
candidateName: string;
|
||||
}
|
||||
|
||||
export interface OwnerTransferItem {
|
||||
id: number;
|
||||
id: CrmId;
|
||||
name: string;
|
||||
code?: string;
|
||||
conflict: boolean;
|
||||
|
|
@ -26,9 +28,9 @@ export interface OwnerTransferPreview {
|
|||
}
|
||||
|
||||
export interface OwnerTransferSelection {
|
||||
opportunityIds?: number[];
|
||||
salesExpansionIds?: number[];
|
||||
channelExpansionIds?: number[];
|
||||
opportunityIds?: CrmId[];
|
||||
salesExpansionIds?: CrmId[];
|
||||
channelExpansionIds?: CrmId[];
|
||||
}
|
||||
|
||||
export interface OwnerTransferPayload {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,153 @@
|
|||
-- Fix historical customer-code duplicates before restoring the intended constraint.
|
||||
-- The first row keeps its business code; later duplicates receive a traceable migration code.
|
||||
begin;
|
||||
|
||||
set local lock_timeout = '10s';
|
||||
set local statement_timeout = '5min';
|
||||
|
||||
select pg_advisory_xact_lock(hashtext('20260713_fix_crm_codes_pg17'));
|
||||
|
||||
with ranked_customer_codes as (
|
||||
select
|
||||
id,
|
||||
row_number() over (partition by customer_code order by created_at nulls last, id) as row_no
|
||||
from crm_customer
|
||||
where customer_code is not null
|
||||
and btrim(customer_code) <> ''
|
||||
)
|
||||
update crm_customer customer
|
||||
set customer_code = 'CUS-MIG-' || customer.id::text,
|
||||
updated_at = now()
|
||||
from ranked_customer_codes duplicate
|
||||
where duplicate.id = customer.id
|
||||
and duplicate.row_no > 1;
|
||||
|
||||
do $$
|
||||
declare
|
||||
constraint_definition text;
|
||||
begin
|
||||
select pg_get_constraintdef(oid)
|
||||
into constraint_definition
|
||||
from pg_constraint
|
||||
where conrelid = 'crm_customer'::regclass
|
||||
and conname = 'uk_crm_customer_code';
|
||||
|
||||
if constraint_definition is not null
|
||||
and constraint_definition not ilike 'UNIQUE (customer_code)%' then
|
||||
alter table crm_customer drop constraint uk_crm_customer_code;
|
||||
constraint_definition := null;
|
||||
end if;
|
||||
|
||||
if constraint_definition is null then
|
||||
alter table crm_customer
|
||||
add constraint uk_crm_customer_code unique (customer_code);
|
||||
end if;
|
||||
end $$;
|
||||
|
||||
create sequence if not exists crm_customer_code_seq start with 1 increment by 1 minvalue 1;
|
||||
create sequence if not exists crm_opportunity_code_seq start with 1 increment by 1 minvalue 1;
|
||||
|
||||
-- Lock sequence consumers before reading their current values.
|
||||
alter sequence crm_customer_code_seq no cycle;
|
||||
alter sequence crm_opportunity_code_seq no cycle;
|
||||
|
||||
do $$
|
||||
declare
|
||||
sales_id_sequence regclass;
|
||||
channel_id_sequence regclass;
|
||||
begin
|
||||
if to_regclass('crm_sales_expansion') is not null then
|
||||
sales_id_sequence := pg_get_serial_sequence('crm_sales_expansion', 'id')::regclass;
|
||||
end if;
|
||||
if to_regclass('crm_channel_expansion') is not null then
|
||||
channel_id_sequence := pg_get_serial_sequence('crm_channel_expansion', 'id')::regclass;
|
||||
end if;
|
||||
|
||||
if sales_id_sequence is not null then
|
||||
execute format('alter sequence %s no cycle', sales_id_sequence);
|
||||
end if;
|
||||
if channel_id_sequence is not null then
|
||||
execute format('alter sequence %s no cycle', channel_id_sequence);
|
||||
end if;
|
||||
end $$;
|
||||
|
||||
-- ALTER SEQUENCE RESTART is transactional; setval() would survive a rollback.
|
||||
do $$
|
||||
declare
|
||||
customer_code_next bigint;
|
||||
opportunity_code_next bigint;
|
||||
begin
|
||||
select greatest(
|
||||
coalesce((
|
||||
select max(substring(customer_code from '([0-9]+)$')::bigint) + 1
|
||||
from crm_customer
|
||||
where customer_code ~ '^CUS-[0-9]{8}-[0-9]+$'
|
||||
), 1),
|
||||
(select case when is_called then last_value + 1 else last_value end from crm_customer_code_seq)
|
||||
)
|
||||
into customer_code_next;
|
||||
|
||||
select greatest(
|
||||
coalesce((
|
||||
select max(substring(opportunity_code from '([0-9]+)$')::bigint) + 1
|
||||
from crm_opportunity
|
||||
where opportunity_code ~ '^OPP-[0-9]{8}-[0-9]+$'
|
||||
), 1),
|
||||
(select case when is_called then last_value + 1 else last_value end from crm_opportunity_code_seq)
|
||||
)
|
||||
into opportunity_code_next;
|
||||
|
||||
execute format('alter sequence crm_customer_code_seq restart with %s', customer_code_next);
|
||||
execute format('alter sequence crm_opportunity_code_seq restart with %s', opportunity_code_next);
|
||||
end $$;
|
||||
|
||||
do $$
|
||||
declare
|
||||
sales_id_sequence regclass;
|
||||
channel_id_sequence regclass;
|
||||
sales_id_max bigint;
|
||||
sales_id_current_next bigint;
|
||||
channel_id_max bigint;
|
||||
channel_id_current_next bigint;
|
||||
begin
|
||||
if to_regclass('crm_sales_expansion') is not null then
|
||||
sales_id_sequence := pg_get_serial_sequence('crm_sales_expansion', 'id')::regclass;
|
||||
end if;
|
||||
if to_regclass('crm_channel_expansion') is not null then
|
||||
channel_id_sequence := pg_get_serial_sequence('crm_channel_expansion', 'id')::regclass;
|
||||
end if;
|
||||
|
||||
if sales_id_sequence is not null then
|
||||
select coalesce(max(id), 0) into sales_id_max from crm_sales_expansion;
|
||||
if sales_id_max = 9223372036854775807 then
|
||||
raise exception 'crm_sales_expansion identity sequence has exhausted bigint values';
|
||||
end if;
|
||||
execute format(
|
||||
'select case when is_called then last_value + 1 else last_value end from %s',
|
||||
sales_id_sequence
|
||||
) into sales_id_current_next;
|
||||
execute format(
|
||||
'alter sequence %s restart with %s',
|
||||
sales_id_sequence,
|
||||
greatest(sales_id_max + 1, sales_id_current_next)
|
||||
);
|
||||
end if;
|
||||
|
||||
if channel_id_sequence is not null then
|
||||
select coalesce(max(id), 0) into channel_id_max from crm_channel_expansion;
|
||||
if channel_id_max = 9223372036854775807 then
|
||||
raise exception 'crm_channel_expansion identity sequence has exhausted bigint values';
|
||||
end if;
|
||||
execute format(
|
||||
'select case when is_called then last_value + 1 else last_value end from %s',
|
||||
channel_id_sequence
|
||||
) into channel_id_current_next;
|
||||
execute format(
|
||||
'alter sequence %s restart with %s',
|
||||
channel_id_sequence,
|
||||
greatest(channel_id_max + 1, channel_id_current_next)
|
||||
);
|
||||
end if;
|
||||
end $$;
|
||||
|
||||
commit;
|
||||
|
|
@ -117,6 +117,8 @@ create table if not exists crm_customer (
|
|||
constraint uk_crm_customer_code unique (customer_code)
|
||||
);
|
||||
|
||||
create sequence if not exists crm_customer_code_seq start with 1 increment by 1 minvalue 1;
|
||||
|
||||
create table if not exists crm_opportunity (
|
||||
id bigint generated by default as identity primary key,
|
||||
opportunity_code varchar(50) not null,
|
||||
|
|
@ -156,6 +158,8 @@ create table if not exists crm_opportunity (
|
|||
constraint fk_crm_opportunity_customer foreign key (customer_id) references crm_customer(id)
|
||||
);
|
||||
|
||||
create sequence if not exists crm_opportunity_code_seq start with 1 increment by 1 minvalue 1;
|
||||
|
||||
create table if not exists crm_opportunity_followup (
|
||||
id bigint generated by default as identity primary key,
|
||||
opportunity_id bigint not null,
|
||||
|
|
|
|||
Loading…
Reference in New Issue