历史数据同步后,修复新增商机无法保存的问题

main
kangwenjing 2026-07-14 09:00:38 +08:00
parent b56d509cc9
commit 5da2565298
25 changed files with 870 additions and 185 deletions

View File

@ -1,9 +1,13 @@
package com.unis.crm.common; package com.unis.crm.common;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import java.sql.SQLException;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.Map; 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.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MethodArgumentNotValidException;
@ -21,6 +25,7 @@ import org.springframework.validation.method.ParameterValidationResult;
@RestControllerAdvice @RestControllerAdvice
public class CrmGlobalExceptionHandler { 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 CURRENT_USER_HEADER = "X-User-Id";
private static final String UNAUTHORIZED_MESSAGE = "登录已失效,请重新登录"; private static final String UNAUTHORIZED_MESSAGE = "登录已失效,请重新登录";
@ -95,16 +100,63 @@ public class CrmGlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.NOT_ACCEPTABLE).build(); 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) @ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public ResponseEntity<Map<String, Object>> handleUnexpectedException(Exception ex, HttpServletRequest request) {
public 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<>(); 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("timestamp", OffsetDateTime.now().toString());
body.put("status", HttpStatus.INTERNAL_SERVER_ERROR.value()); body.put("status", status.value());
body.put("error", HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase()); body.put("error", status.getReasonPhrase());
body.put("message", ex.getMessage()); body.put("message", message);
body.put("path", request.getRequestURI()); body.put("path", path);
return body; return ResponseEntity.status(status).body(body);
} }
private ResponseEntity<ApiResponse<Object>> errorResponse(HttpStatus status, String message) { private ResponseEntity<ApiResponse<Object>> errorResponse(HttpStatus status, String message) {

View File

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

View File

@ -16,6 +16,8 @@ import org.springframework.stereotype.Component;
public class OpportunitySchemaInitializer implements ApplicationRunner { public class OpportunitySchemaInitializer implements ApplicationRunner {
private static final Logger log = LoggerFactory.getLogger(OpportunitySchemaInitializer.class); 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; private final DataSource dataSource;
@ -26,37 +28,165 @@ public class OpportunitySchemaInitializer implements ApplicationRunner {
@Override @Override
public void run(ApplicationArguments args) { public void run(ApplicationArguments args) {
try (Connection connection = dataSource.getConnection()) { try (Connection connection = dataSource.getConnection()) {
if (!tableExists(connection, "crm_opportunity")) { if (tableExists(connection, "crm_opportunity")) {
return; ensureOpportunitySchema(connection);
} }
try (Statement statement = connection.createStatement()) { ensureSequences(connection);
statement.execute("alter table crm_opportunity add column if not exists pre_sales_id bigint"); log.info("Ensured CRM compatibility columns and sequences exist");
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");
} catch (SQLException exception) { } catch (SQLException exception) {
throw new IllegalStateException("Failed to initialize crm_opportunity schema compatibility", 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 { private void ensureArchivedAtStorage(Connection connection) throws SQLException {
try (Statement statement = connection.createStatement()) { try (Statement statement = connection.createStatement()) {
statement.execute(""" statement.execute("""

View File

@ -1,6 +1,7 @@
package com.unis.crm.controller; package com.unis.crm.controller;
import com.unis.crm.common.ApiResponse; import com.unis.crm.common.ApiResponse;
import com.unis.crm.common.CrmIdJacksonConfig;
import com.unis.crm.common.CurrentUserUtils; import com.unis.crm.common.CurrentUserUtils;
import com.unis.crm.dto.expansion.CreateChannelExpansionRequest; import com.unis.crm.dto.expansion.CreateChannelExpansionRequest;
import com.unis.crm.dto.expansion.CreateExpansionFollowUpRequest; import com.unis.crm.dto.expansion.CreateExpansionFollowUpRequest;
@ -85,18 +86,20 @@ public class ExpansionController {
@PostMapping("/sales") @PostMapping("/sales")
@Log(type = "拓展管理", value = "新增售前拓展") @Log(type = "拓展管理", value = "新增售前拓展")
public ApiResponse<Long> createSales( public ApiResponse<Object> createSales(
@RequestHeader("X-User-Id") Long userId, @RequestHeader("X-User-Id") Long userId,
@Valid @RequestBody CreateSalesExpansionRequest request) { @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") @PostMapping("/channel")
@Log(type = "拓展管理", value = "新增渠道拓展") @Log(type = "拓展管理", value = "新增渠道拓展")
public ApiResponse<Long> createChannel( public ApiResponse<Object> createChannel(
@RequestHeader("X-User-Id") Long userId, @RequestHeader("X-User-Id") Long userId,
@Valid @RequestBody CreateChannelExpansionRequest request) { @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}") @PutMapping("/sales/{id}")

View File

@ -91,6 +91,10 @@ public interface OpportunityMapper {
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id") @DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
int countOwnedOpportunity(@Param("userId") Long userId, @Param("id") Long id); int countOwnedOpportunity(@Param("userId") Long userId, @Param("id") Long id);
int countSalesExpansionById(@Param("id") Long id);
int countChannelExpansionById(@Param("id") Long id);
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id") @DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
Boolean selectArchived(@Param("userId") Long userId, @Param("id") Long id); Boolean selectArchived(@Param("userId") Long userId, @Param("id") Long id);

View File

@ -560,6 +560,16 @@ public class OpportunityServiceImpl implements OpportunityService {
request.setLatestProgress(normalizeSnapshotText(request.getLatestProgress())); request.setLatestProgress(normalizeSnapshotText(request.getLatestProgress()));
request.setNextPlan(normalizeSnapshotText(request.getNextPlan())); request.setNextPlan(normalizeSnapshotText(request.getNextPlan()));
validateOperatorRelations(request.getOperatorName(), request.getSalesExpansionId(), request.getChannelExpansionId()); 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) { private String normalizeSnapshotText(String value) {

View File

@ -486,7 +486,7 @@
updated_at updated_at
) values ( ) values (
#{id}, #{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}, #{customerName},
#{userId}, #{userId},
coalesce(#{source}, '主动开发'), coalesce(#{source}, '主动开发'),
@ -525,7 +525,7 @@
created_at, created_at,
updated_at updated_at
) values ( ) 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}, #{request.opportunityName},
#{customerId}, #{customerId},
#{userId}, #{userId},
@ -564,6 +564,18 @@
where o.id = #{id} where o.id = #{id}
</select> </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 id="selectArchived" resultType="java.lang.Boolean">
select coalesce(archived, false) select coalesce(archived, false)
from crm_opportunity o from crm_opportunity o

View File

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

View File

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

View File

@ -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.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 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.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.unis.crm.common.CrmGlobalExceptionHandler; import com.unis.crm.common.CrmGlobalExceptionHandler;
import java.sql.SQLException;
import java.util.Map; import java.util.Map;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@ -44,6 +47,22 @@ class ProtocolExceptionHandlingWebMvcTest {
.andExpect(status().isUnsupportedMediaType()); .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 @RestController
static class ProtocolTestController { static class ProtocolTestController {
@ -51,5 +70,17 @@ class ProtocolExceptionHandlingWebMvcTest {
public Map<String, Object> jsonOnly(@RequestBody Map<String, Object> body) { public Map<String, Object> jsonOnly(@RequestBody Map<String, Object> body) {
return 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");
}
} }
} }

View File

@ -121,6 +121,24 @@ class OpportunityServiceImplTest {
verify(opportunityMapper, never()).insertOpportunity(any(), any(), any()); 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 @Test
void updateOpportunity_shouldKeepCurrentCustomerWhenCustomerNameUnchanged() { void updateOpportunity_shouldKeepCurrentCustomerWhenCustomerNameUnchanged() {
when(opportunityMapper.countOwnedOpportunity(1L, 10L)).thenReturn(1); when(opportunityMapper.countOwnedOpportunity(1L, 10L)).thenReturn(1);

View File

@ -1,10 +1,12 @@
export type CrmId = string | number;
export interface CaptchaResponse { export interface CaptchaResponse {
captchaId: string; captchaId: string;
imageBase64: string; imageBase64: string;
} }
export interface TenantInfo { export interface TenantInfo {
tenantId: number; tenantId: CrmId;
tenantCode: string; tenantCode: string;
tenantName: string; tenantName: string;
} }
@ -26,8 +28,8 @@ export interface LoginPayload {
} }
export interface UserProfile { export interface UserProfile {
userId: number; userId: CrmId;
tenantId: number; tenantId: CrmId;
username: string; username: string;
displayName: string; displayName: string;
email?: string; email?: string;
@ -41,14 +43,14 @@ export interface UserProfile {
} }
export interface UserRole { export interface UserRole {
roleId?: number; roleId?: CrmId;
roleCode?: string; roleCode?: string;
roleName?: string; roleName?: string;
} }
export interface UserPermission { export interface UserPermission {
permId?: number; permId?: CrmId;
parentId?: number; parentId?: CrmId;
name?: string; name?: string;
code: string; code: string;
permType?: string; permType?: string;
@ -57,12 +59,12 @@ export interface UserPermission {
} }
export interface AdminUserSummary { export interface AdminUserSummary {
userId: number; userId: CrmId;
username: string; username: string;
displayName: string; displayName: string;
status?: number; status?: number;
tenantId?: number; tenantId?: CrmId;
orgId?: number; orgId?: CrmId;
isAdmin?: boolean; isAdmin?: boolean;
isPlatformAdmin?: boolean; isPlatformAdmin?: boolean;
roleCodes?: string[]; roleCodes?: string[];
@ -108,13 +110,13 @@ export interface DashboardTodo {
} }
export interface DashboardActivity { export interface DashboardActivity {
id: number; id: CrmId;
bizType?: string; bizType?: string;
bizId?: number; bizId?: CrmId;
actionType?: string; actionType?: string;
title?: string; title?: string;
content?: string; content?: string;
operatorUserId?: number; operatorUserId?: CrmId;
operatorName?: string; operatorName?: string;
targetTab?: string; targetTab?: string;
createdAt?: string; createdAt?: string;
@ -122,13 +124,13 @@ export interface DashboardActivity {
} }
export interface DashboardMessage { export interface DashboardMessage {
id: number; id: CrmId;
reportId?: number; reportId?: CrmId;
senderUserId?: number; senderUserId?: CrmId;
senderName?: string; senderName?: string;
reportDate?: string; reportDate?: string;
bizType?: string; bizType?: string;
bizId?: number; bizId?: CrmId;
bizName?: string; bizName?: string;
content?: string; content?: string;
lineIndexes?: string; lineIndexes?: string;
@ -138,7 +140,7 @@ export interface DashboardMessage {
} }
export interface DashboardAnalyticsCard { export interface DashboardAnalyticsCard {
id?: number; id?: CrmId;
cardKey?: string; cardKey?: string;
groupName?: string; groupName?: string;
title?: string; title?: string;
@ -180,7 +182,7 @@ export interface DashboardAnalyticsPanel {
} }
export interface DashboardHome { export interface DashboardHome {
userId?: number; userId?: CrmId;
realName?: string; realName?: string;
jobTitle?: string; jobTitle?: string;
deptName?: string; deptName?: string;
@ -197,7 +199,7 @@ export interface DashboardHome {
} }
export interface ProfileOverview { export interface ProfileOverview {
userId?: number; userId?: CrmId;
monthlyOpportunityCount?: number; monthlyOpportunityCount?: number;
monthlyExpansionCount?: number; monthlyExpansionCount?: number;
averageScore?: number; averageScore?: number;
@ -209,14 +211,14 @@ export interface ProfileOverview {
} }
export interface UpdateCurrentUserProfilePayload { export interface UpdateCurrentUserProfilePayload {
userId?: number; userId?: CrmId;
username?: string; username?: string;
displayName?: string; displayName?: string;
email?: string; email?: string;
phone?: string; phone?: string;
pwdResetRequired?: number; pwdResetRequired?: number;
isPlatformAdmin?: boolean; isPlatformAdmin?: boolean;
orgId?: number; orgId?: CrmId;
} }
export interface UpdateCurrentUserPasswordPayload { export interface UpdateCurrentUserPasswordPayload {
@ -230,17 +232,17 @@ export interface OwnerTransferConflict {
} }
export interface OwnerTransferItem { export interface OwnerTransferItem {
id: number; id: CrmId;
name: string; name: string;
code?: string; code?: string;
conflict: boolean; conflict: boolean;
} }
export interface OwnerTransferPreview { export interface OwnerTransferPreview {
tenantId: number; tenantId: CrmId;
fromUserId: number; fromUserId: CrmId;
fromUserName: string; fromUserName: string;
toUserId: number; toUserId: CrmId;
toUserName: string; toUserName: string;
opportunityCount: number; opportunityCount: number;
salesExpansionCount: number; salesExpansionCount: number;
@ -252,14 +254,14 @@ export interface OwnerTransferPreview {
} }
export interface OwnerTransferSelection { export interface OwnerTransferSelection {
opportunityIds?: number[]; opportunityIds?: CrmId[];
salesExpansionIds?: number[]; salesExpansionIds?: CrmId[];
channelExpansionIds?: number[]; channelExpansionIds?: CrmId[];
} }
export interface OwnerTransferPayload { export interface OwnerTransferPayload {
fromUserId: number; fromUserId: CrmId;
toUserId: number; toUserId: CrmId;
transferOpportunities: boolean; transferOpportunities: boolean;
transferSalesExpansions: boolean; transferSalesExpansions: boolean;
transferChannelExpansions: boolean; transferChannelExpansions: boolean;
@ -267,16 +269,16 @@ export interface OwnerTransferPayload {
} }
export interface OwnerTransferResult { export interface OwnerTransferResult {
tenantId: number; tenantId: CrmId;
fromUserId: number; fromUserId: CrmId;
toUserId: number; toUserId: CrmId;
transferredOpportunityCount: number; transferredOpportunityCount: number;
transferredSalesExpansionCount: number; transferredSalesExpansionCount: number;
transferredChannelExpansionCount: number; transferredChannelExpansionCount: number;
} }
export interface WorkCheckIn { export interface WorkCheckIn {
id: number; id: CrmId;
date?: string; date?: string;
time?: string; time?: string;
locationText?: string; locationText?: string;
@ -286,14 +288,14 @@ export interface WorkCheckIn {
latitude?: number; latitude?: number;
photoUrls?: string[]; photoUrls?: string[];
bizType?: "sales" | "channel" | "opportunity"; bizType?: "sales" | "channel" | "opportunity";
bizId?: number; bizId?: CrmId;
bizName?: string; bizName?: string;
userName?: string; userName?: string;
deptName?: string; deptName?: string;
} }
export interface WorkDailyReport { export interface WorkDailyReport {
id: number; id: CrmId;
date?: string; date?: string;
submitTime?: string; submitTime?: string;
workContent?: string; workContent?: string;
@ -308,7 +310,7 @@ export interface WorkDailyReport {
} }
export interface WorkHistoryItem { export interface WorkHistoryItem {
id: number; id: CrmId;
type?: string; type?: string;
date?: string; date?: string;
time?: string; time?: string;
@ -333,7 +335,7 @@ export interface UploadReportAttachmentOptions {
} }
export interface WorkReportToUser { export interface WorkReportToUser {
userId: number; userId: CrmId;
name: string; name: string;
username?: string; username?: string;
} }
@ -411,7 +413,7 @@ export interface CreateWorkCheckInPayload {
latitude?: number; latitude?: number;
photoUrls?: string[]; photoUrls?: string[];
bizType?: "sales" | "channel" | "opportunity"; bizType?: "sales" | "channel" | "opportunity";
bizId?: number; bizId?: CrmId;
bizName?: string; bizName?: string;
userName?: string; userName?: string;
deptName?: string; deptName?: string;
@ -420,7 +422,7 @@ export interface CreateWorkCheckInPayload {
export interface WorkReportLineItem { export interface WorkReportLineItem {
workDate: string; workDate: string;
bizType: "sales" | "channel" | "opportunity"; bizType: "sales" | "channel" | "opportunity";
bizId: number; bizId: CrmId;
bizName?: string; bizName?: string;
editorText?: string; editorText?: string;
content: string; content: string;
@ -449,8 +451,8 @@ export interface CreateWorkDailyReportPayload {
} }
export interface OpportunityFollowUp { export interface OpportunityFollowUp {
id: number; id: CrmId;
opportunityId?: number; opportunityId?: CrmId;
date?: string; date?: string;
type?: string; type?: string;
content?: string; content?: string;
@ -464,8 +466,8 @@ export interface OpportunityFollowUp {
} }
export interface OpportunityItem { export interface OpportunityItem {
id: number; id: CrmId;
ownerUserId?: number; ownerUserId?: CrmId;
code?: string; code?: string;
name?: string; name?: string;
client?: string; client?: string;
@ -490,15 +492,15 @@ export interface OpportunityItem {
isPoc?: boolean; isPoc?: boolean;
product?: string; product?: string;
source?: string; source?: string;
salesExpansionId?: number; salesExpansionId?: CrmId;
salesExpansionName?: string; salesExpansionName?: string;
salesExpansionIntent?: string; salesExpansionIntent?: string;
salesExpansionActive?: boolean; salesExpansionActive?: boolean;
channelExpansionId?: number; channelExpansionId?: CrmId;
channelExpansionName?: string; channelExpansionName?: string;
channelExpansionIntent?: string; channelExpansionIntent?: string;
channelExpansionEstablishedDate?: string; channelExpansionEstablishedDate?: string;
preSalesId?: number; preSalesId?: CrmId;
preSalesName?: string; preSalesName?: string;
competitorName?: string; competitorName?: string;
latestProgress?: string; latestProgress?: string;
@ -527,7 +529,7 @@ export interface OpportunityMeta {
} }
export interface OmsPreSalesOption { export interface OmsPreSalesOption {
userId: number; userId: CrmId;
loginName?: string; loginName?: string;
userName?: string; userName?: string;
} }
@ -545,8 +547,8 @@ export interface CreateOpportunityPayload {
opportunityType?: string; opportunityType?: string;
productType?: string; productType?: string;
source?: string; source?: string;
salesExpansionId?: number; salesExpansionId?: CrmId;
channelExpansionId?: number; channelExpansionId?: CrmId;
competitorName?: string; competitorName?: string;
latestProgress?: string; latestProgress?: string;
nextPlan?: string; nextPlan?: string;
@ -555,7 +557,7 @@ export interface CreateOpportunityPayload {
} }
export interface PushOpportunityToOmsPayload { export interface PushOpportunityToOmsPayload {
preSalesId?: number; preSalesId?: CrmId;
preSalesName?: string; preSalesName?: string;
} }
@ -567,8 +569,8 @@ export interface CreateOpportunityFollowUpPayload {
} }
export interface ExpansionFollowUp { export interface ExpansionFollowUp {
id: number; id: CrmId;
bizId?: number; bizId?: CrmId;
bizType?: string; bizType?: string;
date?: string; date?: string;
type?: string; type?: string;
@ -580,8 +582,8 @@ export interface ExpansionFollowUp {
} }
export interface SalesExpansionItem { export interface SalesExpansionItem {
id: number; id: CrmId;
ownerUserId?: number; ownerUserId?: CrmId;
owner?: string; owner?: string;
type: "sales"; type: "sales";
createdAt?: string; createdAt?: string;
@ -612,7 +614,7 @@ export interface SalesExpansionItem {
} }
export interface RelatedProjectSummary { export interface RelatedProjectSummary {
opportunityId: number; opportunityId: CrmId;
opportunityCode?: string; opportunityCode?: string;
opportunityName?: string; opportunityName?: string;
stageCode?: string; stageCode?: string;
@ -621,8 +623,8 @@ export interface RelatedProjectSummary {
} }
export interface ChannelExpansionItem { export interface ChannelExpansionItem {
id: number; id: CrmId;
ownerUserId?: number; ownerUserId?: CrmId;
owner?: string; owner?: string;
type: "channel"; type: "channel";
createdAt?: string; createdAt?: string;
@ -662,14 +664,14 @@ export interface ChannelExpansionItem {
} }
export interface ChannelExpansionContact { export interface ChannelExpansionContact {
id?: number; id?: CrmId;
name?: string; name?: string;
mobile?: string; mobile?: string;
title?: string; title?: string;
} }
export interface ChannelRelatedProjectSummary { export interface ChannelRelatedProjectSummary {
opportunityId: number; opportunityId: CrmId;
opportunityCode?: string; opportunityCode?: string;
opportunityName?: string; opportunityName?: string;
stageCode?: string; stageCode?: string;
@ -843,8 +845,8 @@ const AUTH_REQUIRED_MESSAGE_PATTERNS = [
type AccessTokenPayload = { type AccessTokenPayload = {
exp?: number; exp?: number;
userId?: number; userId?: CrmId;
tenantId?: number; tenantId?: CrmId;
}; };
function isSuccessCode(code: unknown) { function isSuccessCode(code: unknown) {
@ -1223,8 +1225,9 @@ function getStoredUserId() {
const rawProfile = sessionStorage.getItem("userProfile"); const rawProfile = sessionStorage.getItem("userProfile");
if (rawProfile) { if (rawProfile) {
const profile = JSON.parse(rawProfile) as Partial<UserProfile>; const profile = JSON.parse(rawProfile) as Partial<UserProfile>;
if (typeof profile.userId === "number" && Number.isFinite(profile.userId)) { const profileUserId = normalizeStoredId(profile.userId);
return profile.userId; if (profileUserId) {
return profileUserId;
} }
} }
} catch { } catch {
@ -1238,8 +1241,9 @@ function getStoredUserId() {
} }
const tokenPayload = getAccessTokenPayload(token); const tokenPayload = getAccessTokenPayload(token);
if (typeof tokenPayload.userId === "number" && Number.isFinite(tokenPayload.userId)) { const tokenUserId = normalizeStoredId(tokenPayload.userId);
return tokenPayload.userId; if (tokenUserId) {
return tokenUserId;
} }
} catch { } catch {
return undefined; return undefined;
@ -1248,6 +1252,16 @@ function getStoredUserId() {
return undefined; 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() { export function getStoredCurrentUserId() {
return getStoredUserId(); return getStoredUserId();
} }
@ -1369,7 +1383,7 @@ export async function completeDashboardTodo(todoId: string) {
}, true); }, true);
} }
export async function readDashboardMessage(messageId: number) { export async function readDashboardMessage(messageId: CrmId) {
return request<void>(`/api/dashboard/messages/${messageId}/read`, { return request<void>(`/api/dashboard/messages/${messageId}/read`, {
method: "POST", method: "POST",
}, true); }, true);
@ -1398,14 +1412,14 @@ export async function updateCurrentUserPassword(payload: UpdateCurrentUserPasswo
}, true); }, 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}`, { return request<boolean>(`/api/sys/api/users/${userId}`, {
method: "PUT", method: "PUT",
body: JSON.stringify(payload), body: JSON.stringify(payload),
}, true); }, true);
} }
export async function listAdminUsers(params?: { tenantId?: number; orgId?: number }) { export async function listAdminUsers(params?: { tenantId?: CrmId; orgId?: CrmId }) {
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
if (params?.tenantId !== undefined) { if (params?.tenantId !== undefined) {
searchParams.set("tenantId", String(params.tenantId)); 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); 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({ const params = new URLSearchParams({
fromUserId: String(fromUserId), fromUserId: String(fromUserId),
toUserId: String(toUserId), 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); 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); 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); 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); return request<OpportunityItem>(`/api/opportunities/${opportunityId}`, undefined, true);
} }
@ -1624,21 +1638,21 @@ export async function createOpportunity(payload: CreateOpportunityPayload) {
}, true); }, true);
} }
export async function updateOpportunity(opportunityId: number, payload: CreateOpportunityPayload) { export async function updateOpportunity(opportunityId: CrmId, payload: CreateOpportunityPayload) {
return request<number>(`/api/opportunities/${opportunityId}`, { return request<number>(`/api/opportunities/${opportunityId}`, {
method: "PUT", method: "PUT",
body: JSON.stringify(payload), body: JSON.stringify(payload),
}, true); }, 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`, { return request<number>(`/api/opportunities/${opportunityId}/push-oms`, {
method: "POST", method: "POST",
body: JSON.stringify(payload ?? {}), body: JSON.stringify(payload ?? {}),
}, true); }, true);
} }
export async function createOpportunityFollowUp(opportunityId: number, payload: CreateOpportunityFollowUpPayload) { export async function createOpportunityFollowUp(opportunityId: CrmId, payload: CreateOpportunityFollowUpPayload) {
return request<number>(`/api/opportunities/${opportunityId}/followups`, { return request<number>(`/api/opportunities/${opportunityId}/followups`, {
method: "POST", method: "POST",
body: JSON.stringify(payload), 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); 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 }); const params = new URLSearchParams({ employeeNo });
if (excludeId) { if (excludeId) {
params.set("excludeId", String(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); 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 }); const params = new URLSearchParams({ channelName });
if (excludeId) { if (excludeId) {
params.set("excludeId", String(excludeId)); params.set("excludeId", String(excludeId));
@ -1692,27 +1706,27 @@ export async function checkChannelExpansionDuplicate(channelName: string, exclud
} }
export async function createSalesExpansion(payload: CreateSalesExpansionPayload) { export async function createSalesExpansion(payload: CreateSalesExpansionPayload) {
return request<number>("/api/expansion/sales", { return request<CrmId>("/api/expansion/sales", {
method: "POST", method: "POST",
body: JSON.stringify(payload), body: JSON.stringify(payload),
}, true); }, true);
} }
export async function createChannelExpansion(payload: CreateChannelExpansionPayload) { export async function createChannelExpansion(payload: CreateChannelExpansionPayload) {
return request<number>("/api/expansion/channel", { return request<CrmId>("/api/expansion/channel", {
method: "POST", method: "POST",
body: JSON.stringify(serializeChannelExpansionPayload(payload)), body: JSON.stringify(serializeChannelExpansionPayload(payload)),
}, true); }, true);
} }
export async function updateSalesExpansion(id: number, payload: UpdateSalesExpansionPayload) { export async function updateSalesExpansion(id: CrmId, payload: UpdateSalesExpansionPayload) {
return request<void>(`/api/expansion/sales/${id}`, { return request<void>(`/api/expansion/sales/${id}`, {
method: "PUT", method: "PUT",
body: JSON.stringify(payload), body: JSON.stringify(payload),
}, true); }, true);
} }
export async function updateChannelExpansion(id: number, payload: UpdateChannelExpansionPayload) { export async function updateChannelExpansion(id: CrmId, payload: UpdateChannelExpansionPayload) {
return request<void>(`/api/expansion/channel/${id}`, { return request<void>(`/api/expansion/channel/${id}`, {
method: "PUT", method: "PUT",
body: JSON.stringify(serializeChannelExpansionPayload(payload)), body: JSON.stringify(serializeChannelExpansionPayload(payload)),
@ -1721,7 +1735,7 @@ export async function updateChannelExpansion(id: number, payload: UpdateChannelE
export async function createExpansionFollowUp( export async function createExpansionFollowUp(
bizType: "sales" | "channel", bizType: "sales" | "channel",
bizId: number, bizId: CrmId,
payload: CreateExpansionFollowUpPayload, payload: CreateExpansionFollowUpPayload,
) { ) {
return request<number>(`/api/expansion/${bizType}/${bizId}/followups`, { return request<number>(`/api/expansion/${bizType}/${bizId}/followups`, {

View File

@ -715,9 +715,9 @@ export default function Dashboard() {
const visibleHistoryTodos = showAllHistoryTodos ? historyTodos : historyTodos.slice(0, DASHBOARD_HISTORY_PREVIEW_COUNT); const visibleHistoryTodos = showAllHistoryTodos ? historyTodos : historyTodos.slice(0, DASHBOARD_HISTORY_PREVIEW_COUNT);
const activities = (home.activities?.length const activities = (home.activities?.length
? home.activities ? 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 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 hasMoreHistoryTodos = historyTodos.length > DASHBOARD_HISTORY_PREVIEW_COUNT;
const showStatsCard = home.statsCardVisible !== false; const showStatsCard = home.statsCardVisible !== false;
const showTodoCard = home.todoCardVisible !== false; const showTodoCard = home.todoCardVisible !== false;

View File

@ -19,6 +19,7 @@ import {
updateSalesExpansion, updateSalesExpansion,
type ChannelExpansionContact, type ChannelExpansionContact,
type ChannelExpansionItem, type ChannelExpansionItem,
type CrmId,
type CreateChannelExpansionPayload, type CreateChannelExpansionPayload,
type CreateSalesExpansionPayload, type CreateSalesExpansionPayload,
type ExpansionDictOption, type ExpansionDictOption,
@ -32,7 +33,7 @@ import { cn } from "@/lib/utils";
type ExpansionItem = SalesExpansionItem | ChannelExpansionItem; type ExpansionItem = SalesExpansionItem | ChannelExpansionItem;
type ExpansionTab = "sales" | "channel"; type ExpansionTab = "sales" | "channel";
type ExpansionLocationState = { tab?: ExpansionTab; selectedId?: number } | null; type ExpansionLocationState = { tab?: ExpansionTab; selectedId?: CrmId } | null;
type ExpansionExportFilters = { type ExpansionExportFilters = {
keyword?: string; keyword?: string;
intent?: string; intent?: string;

View File

@ -23,6 +23,7 @@ import {
updateOpportunity, updateOpportunity,
type ChannelExpansionContact, type ChannelExpansionContact,
type ChannelExpansionItem, type ChannelExpansionItem,
type CrmId,
type CreateChannelExpansionPayload, type CreateChannelExpansionPayload,
type CreateOpportunityPayload, type CreateOpportunityPayload,
type CreateSalesExpansionPayload, type CreateSalesExpansionPayload,
@ -86,7 +87,7 @@ const COMPETITOR_OPTIONS = [
type CompetitorOption = (typeof COMPETITOR_OPTIONS)[number]; type CompetitorOption = (typeof COMPETITOR_OPTIONS)[number];
type OperatorMode = "none" | "h3c" | "channel" | "both"; type OperatorMode = "none" | "h3c" | "channel" | "both";
type OpportunityArchiveTab = "active" | "archived"; type OpportunityArchiveTab = "active" | "archived";
type OpportunityLocationState = { selectedId?: number; archiveTab?: OpportunityArchiveTab } | null; type OpportunityLocationState = { selectedId?: CrmId; archiveTab?: OpportunityArchiveTab } | null;
type OpportunityExportFilters = { type OpportunityExportFilters = {
keyword?: string; keyword?: string;
expectedStartDate?: string; expectedStartDate?: string;
@ -769,8 +770,8 @@ function buildOpportunitySubmitPayload(
function validateOperatorRelations( function validateOperatorRelations(
operatorMode: OperatorMode, operatorMode: OperatorMode,
salesExpansionId: number | undefined, salesExpansionId: CrmId | undefined,
channelExpansionId: number | undefined, channelExpansionId: CrmId | undefined,
) { ) {
if (operatorMode === "h3c" && !salesExpansionId) { if (operatorMode === "h3c" && !salesExpansionId) {
throw new Error("运作方选择“新华三”时,新华三负责人必须填写"); throw new Error("运作方选择“新华三”时,新华三负责人必须填写");
@ -1290,7 +1291,7 @@ function OpportunityExportFilterModal({
} }
type SearchableOption = { type SearchableOption = {
value: number | string; value: CrmId;
label: string; label: string;
keywords?: string[]; keywords?: string[];
}; };
@ -1372,7 +1373,7 @@ function SearchableSelect({
onCreate, onCreate,
onQueryChange, onQueryChange,
}: { }: {
value?: number; value?: CrmId;
options: SearchableOption[]; options: SearchableOption[];
placeholder: string; placeholder: string;
searchPlaceholder: string; searchPlaceholder: string;
@ -1380,7 +1381,7 @@ function SearchableSelect({
loading?: boolean; loading?: boolean;
createActionLabel?: string; createActionLabel?: string;
className?: string; className?: string;
onChange: (value?: number) => void; onChange: (value?: CrmId) => void;
onCreate?: (query: string) => void; onCreate?: (query: string) => void;
onQueryChange?: (query: string) => void; onQueryChange?: (query: string) => void;
}) { }) {
@ -1512,7 +1513,7 @@ function SearchableSelect({
type="button" type="button"
key={item.value} key={item.value}
onClick={() => { onClick={() => {
onChange(Number(item.value)); onChange(item.value);
setOpen(false); setOpen(false);
resetQuery(); resetQuery();
}} }}
@ -2594,14 +2595,14 @@ export default function Opportunities() {
} }
}; };
const handleSalesExpansionChange = (value?: number) => { const handleSalesExpansionChange = (value?: CrmId) => {
setSelectedSalesExpansionOption( setSelectedSalesExpansionOption(
value ? salesExpansionSearchOptions.find((option) => option.value === value) ?? null : null, value ? salesExpansionSearchOptions.find((option) => option.value === value) ?? null : null,
); );
handleChange("salesExpansionId", value); handleChange("salesExpansionId", value);
}; };
const handleChannelExpansionChange = (value?: number) => { const handleChannelExpansionChange = (value?: CrmId) => {
setSelectedChannelExpansionOption( setSelectedChannelExpansionOption(
value ? channelExpansionSearchOptions.find((option) => option.value === value) ?? null : null, value ? channelExpansionSearchOptions.find((option) => option.value === value) ?? null : null,
); );
@ -2716,7 +2717,7 @@ export default function Opportunities() {
setQuickCreateSubmitting(true); setQuickCreateSubmitting(true);
try { try {
let createdId: number; let createdId: CrmId;
if (quickCreateType === "sales") { if (quickCreateType === "sales") {
const duplicateResult = await checkSalesExpansionDuplicate(quickSalesForm.employeeNo.trim()); const duplicateResult = await checkSalesExpansionDuplicate(quickSalesForm.employeeNo.trim());
if (duplicateResult.duplicated) { if (duplicateResult.duplicated) {
@ -2796,7 +2797,7 @@ export default function Opportunities() {
setCustomCompetitorName(""); setCustomCompetitorName("");
}; };
const reload = async (preferredSelectedId?: number) => { const reload = async (preferredSelectedId?: CrmId) => {
const data = await getOpportunityOverview(keyword, filter); const data = await getOpportunityOverview(keyword, filter);
const nextItems = data.items ?? []; const nextItems = data.items ?? [];
setItems(nextItems); setItems(nextItems);

View File

@ -22,6 +22,7 @@ import {
listOwnerTransferTargetUsers, listOwnerTransferTargetUsers,
previewOwnerTransfer, previewOwnerTransfer,
type AdminUserSummary, type AdminUserSummary,
type CrmId,
type OwnerTransferItem, type OwnerTransferItem,
type OwnerTransferPayload, type OwnerTransferPayload,
type OwnerTransferPreview, type OwnerTransferPreview,
@ -31,9 +32,9 @@ import { cn } from "@/lib/utils";
type TransferTab = "opportunities" | "sales" | "channels"; type TransferTab = "opportunities" | "sales" | "channels";
type TransferKind = "opportunity" | "sales" | "channel"; type TransferKind = "opportunity" | "sales" | "channel";
type SelectionState = { type SelectionState = {
opportunityIds: number[]; opportunityIds: CrmId[];
salesExpansionIds: number[]; salesExpansionIds: CrmId[];
channelExpansionIds: number[]; channelExpansionIds: CrmId[];
}; };
type TransferConfirmState = { type TransferConfirmState = {
@ -61,7 +62,7 @@ const TAB_META: Array<{
function getActiveTenantId() { function getActiveTenantId() {
const rawValue = localStorage.getItem("activeTenantId"); const rawValue = localStorage.getItem("activeTenantId");
const tenantId = Number(rawValue || 0); const tenantId = Number(rawValue || 0);
return Number.isFinite(tenantId) ? tenantId : 0; return Number.isSafeInteger(tenantId) ? tenantId : 0;
} }
function getDisplayUserName(user?: AdminUserSummary | null) { function getDisplayUserName(user?: AdminUserSummary | null) {
@ -83,6 +84,18 @@ function countLabel(value?: number) {
return typeof value === "number" && Number.isFinite(value) ? value : 0; 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 { function createEmptySelectionState(): SelectionState {
return { return {
opportunityIds: [], opportunityIds: [],
@ -145,15 +158,15 @@ export default function OwnerTransfer({
const activeTenantId = getActiveTenantId(); const activeTenantId = getActiveTenantId();
const tenantSelected = activeTenantId > 0; const tenantSelected = activeTenantId > 0;
const numericFromUserId = fromUserId ? Number(fromUserId) : undefined; const selectedFromUserId = normalizeSelectedId(fromUserId);
const numericToUserId = toUserId ? Number(toUserId) : undefined; const selectedToUserId = normalizeSelectedId(toUserId);
const fromUser = useMemo( const fromUser = useMemo(
() => users.find((item) => item.userId === numericFromUserId) ?? currentUser, () => users.find((item) => isSameCrmId(item.userId, selectedFromUserId)) ?? currentUser,
[currentUser, numericFromUserId, users], [currentUser, selectedFromUserId, users],
); );
const toUser = useMemo( const toUser = useMemo(
() => users.find((item) => item.userId === numericToUserId) ?? null, () => users.find((item) => isSameCrmId(item.userId, selectedToUserId)) ?? null,
[numericToUserId, users], [selectedToUserId, users],
); );
const selectedPreviewHasSalesConflict = Boolean(preview?.salesConflicts?.length); const selectedPreviewHasSalesConflict = Boolean(preview?.salesConflicts?.length);
@ -196,7 +209,7 @@ export default function OwnerTransfer({
} }
}, [activeTenantId, tenantSelected]); }, [activeTenantId, tenantSelected]);
const loadPreview = useCallback(async (sourceUserId?: number, targetUserId?: number) => { const loadPreview = useCallback(async (sourceUserId?: CrmId, targetUserId?: CrmId) => {
if (!currentUser?.userId) { if (!currentUser?.userId) {
setPreview(null); setPreview(null);
return; return;
@ -205,10 +218,8 @@ export default function OwnerTransfer({
!tenantSelected !tenantSelected
|| !sourceUserId || !sourceUserId
|| !targetUserId || !targetUserId
|| sourceUserId <= 0 || isSameCrmId(sourceUserId, targetUserId)
|| targetUserId <= 0 || !isSameCrmId(sourceUserId, currentUser.userId)
|| sourceUserId === targetUserId
|| sourceUserId !== currentUser.userId
) { ) {
setPreview(null); setPreview(null);
return; return;
@ -236,8 +247,8 @@ export default function OwnerTransfer({
}, [loadUsers]); }, [loadUsers]);
useEffect(() => { useEffect(() => {
void loadPreview(numericFromUserId, numericToUserId); void loadPreview(selectedFromUserId, selectedToUserId);
}, [loadPreview, numericFromUserId, numericToUserId]); }, [loadPreview, selectedFromUserId, selectedToUserId]);
useEffect(() => { useEffect(() => {
setSearchKeyword(""); setSearchKeyword("");
@ -351,11 +362,11 @@ export default function OwnerTransfer({
}, [confirmState, runTransfer]); }, [confirmState, runTransfer]);
const handleExecute = async () => { const handleExecute = async () => {
if (!numericFromUserId || !numericToUserId) { if (!selectedFromUserId || !selectedToUserId) {
setPageError("请先选择原归属人和新归属人"); setPageError("请先选择原归属人和新归属人");
return; return;
} }
if (numericFromUserId === numericToUserId) { if (isSameCrmId(selectedFromUserId, selectedToUserId)) {
setPageError("原归属人和新归属人不能相同"); setPageError("原归属人和新归属人不能相同");
return; return;
} }
@ -368,8 +379,8 @@ export default function OwnerTransfer({
title: "确认执行转移?", title: "确认执行转移?",
description: "", description: "",
payload: { payload: {
fromUserId: numericFromUserId, fromUserId: selectedFromUserId,
toUserId: numericToUserId, toUserId: selectedToUserId,
transferOpportunities: selection.opportunityIds.length > 0, transferOpportunities: selection.opportunityIds.length > 0,
transferSalesExpansions: selection.salesExpansionIds.length > 0, transferSalesExpansions: selection.salesExpansionIds.length > 0,
transferChannelExpansions: selection.channelExpansionIds.length > 0, transferChannelExpansions: selection.channelExpansionIds.length > 0,
@ -392,7 +403,7 @@ export default function OwnerTransfer({
}; };
const handleSingleTransfer = async (kind: TransferKind, item: OwnerTransferItem) => { const handleSingleTransfer = async (kind: TransferKind, item: OwnerTransferItem) => {
if (!numericFromUserId || !numericToUserId) { if (!selectedFromUserId || !selectedToUserId) {
setPageError("请先选择原归属人和新归属人"); setPageError("请先选择原归属人和新归属人");
return; return;
} }
@ -406,8 +417,8 @@ export default function OwnerTransfer({
title: "确认转移当前数据?", title: "确认转移当前数据?",
description: "", description: "",
payload: { payload: {
fromUserId: numericFromUserId, fromUserId: selectedFromUserId,
toUserId: numericToUserId, toUserId: selectedToUserId,
transferOpportunities: kind === "opportunity", transferOpportunities: kind === "opportunity",
transferSalesExpansions: kind === "sales", transferSalesExpansions: kind === "sales",
transferChannelExpansions: kind === "channel", 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); const selectionKey = getSelectionKey(kind);
setSelection((current) => { setSelection((current) => {
const currentIds = current[selectionKey]; const currentIds = current[selectionKey];

View File

@ -34,6 +34,7 @@ import {
type ChannelExpansionItem, type ChannelExpansionItem,
type ChannelExpansionContact, type ChannelExpansionContact,
type CreateChannelExpansionPayload, type CreateChannelExpansionPayload,
type CrmId,
type CreateOpportunityPayload, type CreateOpportunityPayload,
type CreateWorkCheckInPayload, type CreateWorkCheckInPayload,
type CreateWorkDailyReportPayload, type CreateWorkDailyReportPayload,
@ -140,7 +141,7 @@ const LOCATION_DISPLAY_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000;
const locationDisplayCache = new Map<string, string>(); const locationDisplayCache = new Map<string, string>();
type WorkRelationOption = { type WorkRelationOption = {
id: number; id: CrmId;
label: string; label: string;
}; };
@ -222,7 +223,7 @@ function createEmptyReportLine(): WorkReportLineItem {
return { return {
workDate: format(new Date(), "yyyy-MM-dd"), workDate: format(new Date(), "yyyy-MM-dd"),
bizType: "sales", bizType: "sales",
bizId: 0, bizId: "",
bizName: "", bizName: "",
editorText: "", editorText: "",
content: "", content: "",
@ -896,8 +897,10 @@ export default function Work() {
const routeOpenReportId = useMemo(() => { const routeOpenReportId = useMemo(() => {
const state = routerLocation.state as { openReportId?: unknown } | null; const state = routerLocation.state as { openReportId?: unknown } | null;
const rawId = state?.openReportId; const rawId = state?.openReportId;
const numericId = typeof rawId === "number" ? rawId : Number(rawId); if (typeof rawId === "string" && rawId.trim()) {
return Number.isFinite(numericId) && numericId > 0 ? numericId : null; return rawId.trim();
}
return typeof rawId === "number" && Number.isSafeInteger(rawId) && rawId > 0 ? rawId : null;
}, [routerLocation.state]); }, [routerLocation.state]);
const pickerOptions = useMemo(() => { const pickerOptions = useMemo(() => {
@ -928,9 +931,9 @@ export default function Work() {
: 0; : 0;
const opportunityItemsById = useMemo(() => { const opportunityItemsById = useMemo(() => {
const entries = opportunityItems 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); .map((item) => [item.id, item] as const);
return new Map<number, OpportunityItem>(entries); return new Map<CrmId, OpportunityItem>(entries);
}, [opportunityItems]); }, [opportunityItems]);
const quickOpportunityOperatorMode = useMemo( const quickOpportunityOperatorMode = useMemo(
() => resolveOperatorMode(quickOpportunityForm.operatorName, quickOpportunityOperatorOptions), () => resolveOperatorMode(quickOpportunityForm.operatorName, quickOpportunityOperatorOptions),
@ -2752,7 +2755,7 @@ export default function Work() {
...salesOptions.map((item) => ({ value: String(item.id), label: item.label })), ...salesOptions.map((item) => ({ value: String(item.id), label: item.label })),
]} ]}
className={getFieldInputClass(Boolean(quickOpportunityFieldErrors.salesExpansionId))} 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} {quickOpportunityFieldErrors.salesExpansionId ? <p className="text-xs text-rose-500">{quickOpportunityFieldErrors.salesExpansionId}</p> : null}
</label> </label>
@ -2771,7 +2774,7 @@ export default function Work() {
...channelOptions.map((item) => ({ value: String(item.id), label: item.label })), ...channelOptions.map((item) => ({ value: String(item.id), label: item.label })),
]} ]}
className={getFieldInputClass(Boolean(quickOpportunityFieldErrors.channelExpansionId))} 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} {quickOpportunityFieldErrors.channelExpansionId ? <p className="text-xs text-rose-500">{quickOpportunityFieldErrors.channelExpansionId}</p> : null}
</label> </label>
@ -5188,9 +5191,9 @@ function hasOnlySeeRole(user: UserProfile | null) {
} }
function buildSalesOptions(items: SalesExpansionItem[]): WorkRelationOption[] { function buildSalesOptions(items: SalesExpansionItem[]): WorkRelationOption[] {
const seenIds = new Set<number>(); const seenIds = new Set<CrmId>();
return items 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) => { .filter((item) => {
if (seenIds.has(item.id)) { if (seenIds.has(item.id)) {
return false; return false;
@ -5202,9 +5205,9 @@ function buildSalesOptions(items: SalesExpansionItem[]): WorkRelationOption[] {
} }
function buildChannelOptions(items: ChannelExpansionItem[]): WorkRelationOption[] { function buildChannelOptions(items: ChannelExpansionItem[]): WorkRelationOption[] {
const seenIds = new Set<number>(); const seenIds = new Set<CrmId>();
return items 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) => { .filter((item) => {
if (seenIds.has(item.id)) { if (seenIds.has(item.id)) {
return false; return false;
@ -5216,9 +5219,9 @@ function buildChannelOptions(items: ChannelExpansionItem[]): WorkRelationOption[
} }
function buildOpportunityOptions(items: OpportunityItem[]): WorkRelationOption[] { function buildOpportunityOptions(items: OpportunityItem[]): WorkRelationOption[] {
const seenIds = new Set<number>(); const seenIds = new Set<CrmId>();
return items 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) => { .filter((item) => {
if (seenIds.has(item.id)) { if (seenIds.has(item.id)) {
return false; return false;
@ -5229,6 +5232,13 @@ function buildOpportunityOptions(items: OpportunityItem[]): WorkRelationOption[]
.map((item) => ({ id: item.id, label: item.name || `商机#${item.id}` })); .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) { function getBizTypeLabel(bizType: BizType) {
if (bizType === "sales") { if (bizType === "sales") {
return "人员拓展"; return "人员拓展";

View File

@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/logo.svg" /> <link rel="icon" type="image/svg+xml" href="/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>UnisBase - 智能会议系统</title> <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"> <link rel="stylesheet" crossorigin href="/assets/index-CaWPk49l.css">
</head> </head>
<body> <body>

View File

@ -27,7 +27,7 @@ import PageHeader from "@/components/shared/PageHeader";
import { usePermission } from "@/hooks/usePermission"; import { usePermission } from "@/hooks/usePermission";
import type { SysUser } from "@/types"; import type { SysUser } from "@/types";
import { executeOwnerTransfer, previewOwnerTransfer } from "@/features/owner-transfer/api"; 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"; import "./index.less";
interface FormValues { interface FormValues {
@ -66,6 +66,12 @@ function filterItems(items: OwnerTransferItem[], keyword: string) {
return items.filter((item) => `${item.name || ""} ${item.code || ""}`.toLowerCase().includes(normalizedKeyword)); 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) { function hasBlockedTransferRole(user: SysUser) {
return (user.roles ?? []).some((role) => { return (user.roles ?? []).some((role) => {
const normalizedRoleCode = role.roleCode?.trim().toUpperCase(); const normalizedRoleCode = role.roleCode?.trim().toUpperCase();
@ -282,9 +288,9 @@ export default function OwnerTransferPage() {
transferChannelExpansions: hasRowSelection ? selectedChannelRowKeys.length > 0 : values.transferChannelExpansions, transferChannelExpansions: hasRowSelection ? selectedChannelRowKeys.length > 0 : values.transferChannelExpansions,
selection: hasRowSelection selection: hasRowSelection
? { ? {
opportunityIds: selectedOpportunityRowKeys.map((id) => Number(id)), opportunityIds: normalizeSelectedIds(selectedOpportunityRowKeys),
salesExpansionIds: selectedSalesRowKeys.map((id) => Number(id)), salesExpansionIds: normalizeSelectedIds(selectedSalesRowKeys),
channelExpansionIds: selectedChannelRowKeys.map((id) => Number(id)), channelExpansionIds: normalizeSelectedIds(selectedChannelRowKeys),
} }
: undefined, : undefined,
}; };
@ -369,9 +375,9 @@ export default function OwnerTransferPage() {
transferSalesExpansions: selectedSalesRowKeys.length > 0, transferSalesExpansions: selectedSalesRowKeys.length > 0,
transferChannelExpansions: selectedChannelRowKeys.length > 0, transferChannelExpansions: selectedChannelRowKeys.length > 0,
selection: { selection: {
opportunityIds: selectedOpportunityRowKeys.map((id) => Number(id)), opportunityIds: normalizeSelectedIds(selectedOpportunityRowKeys),
salesExpansionIds: selectedSalesRowKeys.map((id) => Number(id)), salesExpansionIds: normalizeSelectedIds(selectedSalesRowKeys),
channelExpansionIds: selectedChannelRowKeys.map((id) => Number(id)), channelExpansionIds: normalizeSelectedIds(selectedChannelRowKeys),
}, },
}; };

View File

@ -1,10 +1,12 @@
export type CrmId = string | number;
export interface OwnerTransferConflict { export interface OwnerTransferConflict {
employeeNo: string; employeeNo: string;
candidateName: string; candidateName: string;
} }
export interface OwnerTransferItem { export interface OwnerTransferItem {
id: number; id: CrmId;
name: string; name: string;
code?: string; code?: string;
conflict: boolean; conflict: boolean;
@ -26,9 +28,9 @@ export interface OwnerTransferPreview {
} }
export interface OwnerTransferSelection { export interface OwnerTransferSelection {
opportunityIds?: number[]; opportunityIds?: CrmId[];
salesExpansionIds?: number[]; salesExpansionIds?: CrmId[];
channelExpansionIds?: number[]; channelExpansionIds?: CrmId[];
} }
export interface OwnerTransferPayload { export interface OwnerTransferPayload {

View File

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

View File

@ -117,6 +117,8 @@ create table if not exists crm_customer (
constraint uk_crm_customer_code unique (customer_code) 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 ( create table if not exists crm_opportunity (
id bigint generated by default as identity primary key, id bigint generated by default as identity primary key,
opportunity_code varchar(50) not null, 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) 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 ( create table if not exists crm_opportunity_followup (
id bigint generated by default as identity primary key, id bigint generated by default as identity primary key,
opportunity_id bigint not null, opportunity_id bigint not null,