历史数据同步后,修复新增商机无法保存的问题
parent
b56d509cc9
commit
5da2565298
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
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,9 +28,17 @@ 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);
|
||||||
}
|
}
|
||||||
|
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()) {
|
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_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 pre_sales_name varchar(100)");
|
||||||
|
|
@ -51,9 +61,129 @@ public class OpportunitySchemaInitializer implements ApplicationRunner {
|
||||||
ensureArchivedAtStorage(connection);
|
ensureArchivedAtStorage(connection);
|
||||||
ensureConfidenceGradeStorage(connection);
|
ensureConfidenceGradeStorage(connection);
|
||||||
migrateLegacyOmsProjectCode(connection);
|
migrateLegacyOmsProjectCode(connection);
|
||||||
log.info("Ensured compatibility columns exist for crm_opportunity");
|
}
|
||||||
|
|
||||||
|
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) {
|
} catch (SQLException exception) {
|
||||||
throw new IllegalStateException("Failed to initialize crm_opportunity schema compatibility", 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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`, {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
|
|
|
||||||
|
|
@ -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 "人员拓展";
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue