diff --git a/backend/crm/src/main/java/cn/cordys/crm/clue/service/ClueOwnerHistoryService.java b/backend/crm/src/main/java/cn/cordys/crm/clue/service/ClueOwnerHistoryService.java index 363092ad39..d95b6eff99 100644 --- a/backend/crm/src/main/java/cn/cordys/crm/clue/service/ClueOwnerHistoryService.java +++ b/backend/crm/src/main/java/cn/cordys/crm/clue/service/ClueOwnerHistoryService.java @@ -70,12 +70,20 @@ private List buildListData(String orgId, List List configs = dictConfigMapper.selectListByLambda(configLambdaQueryWrapper); boolean showReason = CollectionUtils.isNotEmpty(configs) && configs.getFirst().getEnabled(); - Map userDeptMap = baseService.getUserDeptMapByUserIds(new ArrayList<>(ownerIds), orgId); + Map userDeptMap = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + Map originUserDeptMap = baseService.getUserDeptMapByUserIds(new ArrayList<>(ownerIds), orgId); + userDeptMap.putAll(originUserDeptMap); - Map userNameMap = baseService.getUserNameMap(userIds); + Map userNameMap = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + userNameMap.putAll(baseService.getUserNameMap(userIds)); - List dictList = dictMapper.selectByIds(new ArrayList<>(reasonIds)); - Map dictNameMap = dictList.stream().collect(Collectors.toMap(Dict::getId, Dict::getName)); + Map dictNameMap = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + if (!reasonIds.isEmpty()) { + List dictList = dictMapper.selectByIds(new ArrayList<>(reasonIds)); + for (Dict dict : dictList) { + dictNameMap.put(dict.getId(), dict.getName()); + } + } return owners .stream() @@ -93,12 +101,20 @@ private List buildListData(String orgId, List } else { clueOwner.setReasonName(dictNameMap.get(clueOwner.getReasonId())); } - clueOwner.setOwnerName(userNameMap.get(clueOwner.getOwner())); - clueOwner.setOperatorName(userNameMap.get(clueOwner.getOperator())); + clueOwner.setOwnerName(getUserNameOrDefault(userNameMap, clueOwner.getOwner())); + clueOwner.setOperatorName(getUserNameOrDefault(userNameMap, clueOwner.getOperator())); return clueOwner; }).toList(); } + private String getUserNameOrDefault(Map userNameMap, String userId) { + if (StringUtils.isBlank(userId)) { + return "未知用户"; + } + String name = userNameMap.get(userId); + return StringUtils.isNotBlank(name) ? name : "未知用户"; + } + public ClueOwner add(Clue clue, String userId, Boolean addReason) { ClueOwner clueOwner = new ClueOwner(); clueOwner.setOwner(clue.getOwner()); diff --git a/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerOwnerHistoryService.java b/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerOwnerHistoryService.java index 374447e838..1cca6b2c0a 100644 --- a/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerOwnerHistoryService.java +++ b/backend/crm/src/main/java/cn/cordys/crm/customer/service/CustomerOwnerHistoryService.java @@ -66,12 +66,20 @@ private List buildListData(String orgId, List userDeptMap = baseService.getUserDeptMapByUserIds(new ArrayList<>(ownerIds), orgId); + Map userDeptMap = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + Map originUserDeptMap = baseService.getUserDeptMapByUserIds(new ArrayList<>(ownerIds), orgId); + userDeptMap.putAll(originUserDeptMap); - Map userNameMap = baseService.getUserNameMap(userIds); + Map userNameMap = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + userNameMap.putAll(baseService.getUserNameMap(userIds)); - List dictList = dictMapper.selectByIds(new ArrayList<>(reasonIds)); - Map dictNameMap = dictList.stream().collect(Collectors.toMap(Dict::getId, Dict::getName)); + Map dictNameMap = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + if (!reasonIds.isEmpty()) { + List dictList = dictMapper.selectByIds(new ArrayList<>(reasonIds)); + for (Dict dict : dictList) { + dictNameMap.put(dict.getId(), dict.getName()); + } + } LambdaQueryWrapper configLambdaQueryWrapper = new LambdaQueryWrapper<>(); configLambdaQueryWrapper.eq(DictConfig::getModule, DictModule.CUSTOMER_POOL_RS.name()).eq(DictConfig::getOrganizationId, orgId); @@ -94,12 +102,20 @@ private List buildListData(String orgId, List userNameMap, String userId) { + if (StringUtils.isBlank(userId)) { + return "未知用户"; + } + String name = userNameMap.get(userId); + return StringUtils.isNotBlank(name) ? name : "未知用户"; + } + public CustomerOwner add(Customer customer, String userId, Boolean addReason) { CustomerOwner customerOwner = new CustomerOwner(); customerOwner.setOwner(customer.getOwner()); diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/controller/OpportunityController.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/controller/OpportunityController.java index 773c0a11dd..381f90c895 100644 --- a/backend/crm/src/main/java/cn/cordys/crm/opportunity/controller/OpportunityController.java +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/controller/OpportunityController.java @@ -132,7 +132,7 @@ public OpportunityDetailResponse get(@PathVariable String id) { @RequiresPermissions(value = {PermissionConstants.OPPORTUNITY_MANAGEMENT_UPDATE, PermissionConstants.OPPORTUNITY_MANAGEMENT_RESIGN}, logical = Logical.OR) @Operation(summary = "更新商机阶段") public void updateStage(@RequestBody OpportunityStageRequest request) { - opportunityService.updateStage(request, OrganizationContext.getOrganizationId()); + opportunityService.updateStage(request, OrganizationContext.getOrganizationId(), SessionUtils.getUserId()); } @PostMapping("/batch/update") diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/controller/OpportunityStageHistoryController.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/controller/OpportunityStageHistoryController.java new file mode 100644 index 0000000000..d820de3b40 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/controller/OpportunityStageHistoryController.java @@ -0,0 +1,32 @@ +package cn.cordys.crm.opportunity.controller; + +import cn.cordys.common.constants.PermissionConstants; +import cn.cordys.context.OrganizationContext; +import cn.cordys.crm.opportunity.dto.response.OpportunityStageHistoryResponse; +import cn.cordys.crm.opportunity.service.OpportunityStageHistoryService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + + +@Tag(name = "商机阶段变更历史") +@RestController +@RequestMapping("/opportunity/stage/history") +public class OpportunityStageHistoryController { + @Resource + private OpportunityStageHistoryService opportunityStageHistoryService; + + @GetMapping("/list/{opportunityId}") + @RequiresPermissions(PermissionConstants.OPPORTUNITY_MANAGEMENT_READ) + @Operation(summary = "商机阶段变更历史列表") + public List list(@PathVariable String opportunityId) { + return opportunityStageHistoryService.list(opportunityId, OrganizationContext.getOrganizationId()); + } +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/domain/OpportunityStageHistory.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/domain/OpportunityStageHistory.java new file mode 100644 index 0000000000..d46e54f83b --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/domain/OpportunityStageHistory.java @@ -0,0 +1,32 @@ +package cn.cordys.crm.opportunity.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Table; +import lombok.Data; + + +@Data +@Table(name = "opportunity_stage_history") +public class OpportunityStageHistory { + + @Schema(description = "id") + private String id; + + @Schema(description = "商机id") + private String opportunityId; + + @Schema(description = "原阶段id") + private String fromStage; + + @Schema(description = "新阶段id") + private String toStage; + + @Schema(description = "变更时间") + private Long changeTime; + + @Schema(description = "操作人") + private String operator; + + @Schema(description = "失败原因id(当变更为失败阶段时)") + private String failureReasonId; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/response/OpportunityStageHistoryResponse.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/response/OpportunityStageHistoryResponse.java new file mode 100644 index 0000000000..983ccbc7c9 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/dto/response/OpportunityStageHistoryResponse.java @@ -0,0 +1,21 @@ +package cn.cordys.crm.opportunity.dto.response; + +import cn.cordys.crm.opportunity.domain.OpportunityStageHistory; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + + +@Data +public class OpportunityStageHistoryResponse extends OpportunityStageHistory { + @Schema(description = "原阶段名称") + private String fromStageName; + + @Schema(description = "新阶段名称") + private String toStageName; + + @Schema(description = "操作人名称") + private String operatorName; + + @Schema(description = "失败原因名称") + private String failureReasonName; +} diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/service/OpportunityService.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/service/OpportunityService.java index 64304be31f..9eec5c1c4c 100644 --- a/backend/crm/src/main/java/cn/cordys/crm/opportunity/service/OpportunityService.java +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/service/OpportunityService.java @@ -142,6 +142,8 @@ public class OpportunityService { private ExtOpportunityStageConfigMapper extOpportunityStageConfigMapper; @Resource private DataScopeService dataScopeService; + @Resource + private OpportunityStageHistoryService opportunityStageHistoryService; public PagerWithOption> list(OpportunityPageRequest request, String userId, String orgId, DeptDataPermissionDTO deptDataPermission, Boolean source) { @@ -397,10 +399,10 @@ public void delete(String id, String userId, String orgId) { Optional.ofNullable(opportunity).ifPresentOrElse(item -> { opportunityMapper.deleteByPrimaryKey(opportunity.getId()); opportunityFieldService.deleteByResourceId(opportunity.getId()); + opportunityStageHistoryService.deleteByOpportunityId(opportunity.getId()); }, () -> { throw new GenericException("opportunity_not_found"); }); - // 添加日志上下文 OperationLogContext.setResourceName(opportunity.getName()); commonNoticeSendService.sendNotice(NotificationConstants.Module.OPPORTUNITY, @@ -414,7 +416,6 @@ public void delete(String id, String userId, String orgId) { */ public void transfer(OpportunityTransferRequest request, String userId, String orgId) { List stageConfigList = extOpportunityStageConfigMapper.getStageConfigList(orgId); - // 过滤出成功阶段 StageConfigResponse successConfig = stageConfigList.stream().filter(config -> Strings.CI.equals(config.getType(), OpportunityStageType.END.name()) && Strings.CI.equals(config.getRate(), "100") ).findFirst().get(); @@ -428,16 +429,32 @@ public void transfer(OpportunityTransferRequest request, String userId, String o } List ids = opportunityList.stream().map(Opportunity::getId).toList(); - long nextPos = getNextPos(orgId, stageConfigList.getFirst().getId()); + String firstStageId = stageConfigList.getFirst().getId(); + Map oldStageMap = opportunityList.stream() + .collect(Collectors.toMap(Opportunity::getId, Opportunity::getStage)); + + long nextPos = getNextPos(orgId, firstStageId); SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH); ExtOpportunityMapper batchUpdateMapper = sqlSession.getMapper(ExtOpportunityMapper.class); for (int i = 0; i < ids.size(); i++) { - batchUpdateMapper.transfer(request.getOwner(), userId, ids.get(i), System.currentTimeMillis(), nextPos + i, stageConfigList.getFirst().getId()); + batchUpdateMapper.transfer(request.getOwner(), userId, ids.get(i), System.currentTimeMillis(), nextPos + i, firstStageId); } sqlSession.flushStatements(); SqlSessionUtils.closeSqlSession(sqlSession, sqlSessionFactory); - // 记录日志 + opportunityList.forEach(opportunity -> { + String oldStage = oldStageMap.get(opportunity.getId()); + if (!Strings.CI.equals(oldStage, firstStageId)) { + opportunityStageHistoryService.add( + opportunity.getId(), + oldStage, + firstStageId, + userId, + null + ); + } + }); + List logs = new ArrayList<>(); opportunityList.forEach(opportunity -> { Customer originCustomer = new Customer(); @@ -479,6 +496,7 @@ public void batchDelete(List ids, String userId, String orgId) { } opportunityMapper.deleteByIds(toDoIds); opportunityFieldService.deleteByResourceIds(toDoIds); + opportunityStageHistoryService.deleteByOpportunityIds(toDoIds); List logs = new ArrayList<>(); opportunityList.forEach(opportunity -> { LogDTO logDTO = new LogDTO(opportunity.getOrganizationId(), opportunity.getId(), userId, LogType.DELETE, LogModule.OPPORTUNITY_INDEX, opportunity.getName()); @@ -487,7 +505,6 @@ public void batchDelete(List ids, String userId, String orgId) { }); logService.batchAdd(logs); - // 消息通知 opportunityList.forEach(opportunity -> commonNoticeSendService.sendNotice(NotificationConstants.Module.OPPORTUNITY, NotificationConstants.Event.BUSINESS_DELETED, opportunity.getName(), userId, @@ -633,14 +650,19 @@ public List batchGetSimpleByIds(List ids) { * * @param request * @param orgId + * @param operatorId */ @OperationLog(module = LogModule.OPPORTUNITY_INDEX, type = LogType.UPDATE, resourceId = "{#request.id}") - public void updateStage(OpportunityStageRequest request, String orgId) { + public void updateStage(OpportunityStageRequest request, String orgId, String operatorId) { final Opportunity oldOpportunity = opportunityMapper.selectByPrimaryKey(request.getId()); if (oldOpportunity == null) { throw new GenericException(Translator.get("opportunity_not_found")); } + if (Strings.CI.equals(oldOpportunity.getStage(), request.getStage())) { + return; + } + final List stageConfigList = extOpportunityStageConfigMapper.getStageConfigList(orgId); final Optional successOpt = stageConfigList.stream() @@ -676,6 +698,14 @@ public void updateStage(OpportunityStageRequest request, String orgId) { opportunityMapper.update(newOpportunity); + opportunityStageHistoryService.add( + request.getId(), + oldOpportunity.getStage(), + request.getStage(), + operatorId, + isFailStage ? request.getFailureReason() : null + ); + final Map originalVal = new HashMap<>(1); originalVal.put("stage", stageMap.get(oldOpportunity.getStage())); final Map modifiedVal = new HashMap<>(1); @@ -840,14 +870,15 @@ public void batchUpdate(ResourceBatchEditRequest request, String userId, String * @param userId */ public void sort(OpportunitySortRequest request, String userId) { - //拖拽节点 Opportunity opportunity = opportunityMapper.selectByPrimaryKey(request.getDragNodeId()); if (opportunity == null) { throw new GenericException(Translator.get("opportunity_not_found")); } + + boolean stageChanged = !Strings.CI.equals(opportunity.getStage(), request.getStage()); + Long pos = DEFAULT_POS; if (StringUtils.isNotBlank(request.getDropNodeId())) { - //放入节点 Opportunity dropNode = opportunityMapper.selectByPrimaryKey(request.getDropNodeId()); pos = dropNode.getPos(); if (request.getDropPosition() == -1) { @@ -866,6 +897,16 @@ public void sort(OpportunitySortRequest request, String userId) { dragOpportunity.setUpdateUser(userId); dragOpportunity.setUpdateTime(System.currentTimeMillis()); opportunityMapper.updateById(dragOpportunity); + + if (stageChanged) { + opportunityStageHistoryService.add( + request.getDragNodeId(), + opportunity.getStage(), + request.getStage(), + userId, + null + ); + } } public List chart(ChartAnalysisRequest request, String userId, String orgId, DeptDataPermissionDTO deptDataPermission) { diff --git a/backend/crm/src/main/java/cn/cordys/crm/opportunity/service/OpportunityStageHistoryService.java b/backend/crm/src/main/java/cn/cordys/crm/opportunity/service/OpportunityStageHistoryService.java new file mode 100644 index 0000000000..78b7271f72 --- /dev/null +++ b/backend/crm/src/main/java/cn/cordys/crm/opportunity/service/OpportunityStageHistoryService.java @@ -0,0 +1,148 @@ +package cn.cordys.crm.opportunity.service; + +import cn.cordys.common.dto.UserDeptDTO; +import cn.cordys.common.service.BaseService; +import cn.cordys.common.uid.IDGenerator; +import cn.cordys.common.util.BeanUtils; +import cn.cordys.crm.opportunity.domain.OpportunityStageHistory; +import cn.cordys.crm.opportunity.dto.response.OpportunityStageHistoryResponse; +import cn.cordys.crm.opportunity.dto.response.StageConfigResponse; +import cn.cordys.crm.opportunity.mapper.ExtOpportunityStageConfigMapper; +import cn.cordys.crm.system.constants.DictModule; +import cn.cordys.crm.system.domain.Dict; +import cn.cordys.crm.system.domain.DictConfig; +import cn.cordys.mybatis.BaseMapper; +import cn.cordys.mybatis.lambda.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + + +@Service +@Transactional(rollbackFor = Exception.class) +public class OpportunityStageHistoryService { + @Resource + private BaseMapper opportunityStageHistoryMapper; + @Resource + private BaseMapper dictConfigMapper; + @Resource + private BaseMapper dictMapper; + @Resource + private BaseService baseService; + @Resource + private ExtOpportunityStageConfigMapper extOpportunityStageConfigMapper; + + + public List list(String opportunityId, String orgId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(OpportunityStageHistory::getOpportunityId, opportunityId); + wrapper.orderByDesc(OpportunityStageHistory::getChangeTime); + List historyList = opportunityStageHistoryMapper.selectListByLambda(wrapper); + return buildListData(orgId, historyList); + } + + private List buildListData(String orgId, List historyList) { + if (CollectionUtils.isEmpty(historyList)) { + return List.of(); + } + + Set operatorIds = new HashSet<>(); + Set stageIds = new HashSet<>(); + Set reasonIds = new HashSet<>(); + + for (OpportunityStageHistory history : historyList) { + operatorIds.add(history.getOperator()); + stageIds.add(history.getFromStage()); + stageIds.add(history.getToStage()); + if (StringUtils.isNotBlank(history.getFailureReasonId())) { + reasonIds.add(history.getFailureReasonId()); + } + } + + Map operatorNameMap = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + operatorNameMap.putAll(baseService.getUserNameMap(operatorIds)); + + List stageConfigList = extOpportunityStageConfigMapper.getStageConfigList(orgId); + Map stageNameMap = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + for (StageConfigResponse config : stageConfigList) { + stageNameMap.put(config.getId(), config.getName()); + } + + Map reasonNameMap = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + if (!reasonIds.isEmpty()) { + List dictList = dictMapper.selectByIds(new ArrayList<>(reasonIds)); + for (Dict dict : dictList) { + reasonNameMap.put(dict.getId(), dict.getName()); + } + } + + return historyList + .stream() + .map(item -> { + OpportunityStageHistoryResponse response = + BeanUtils.copyBean(new OpportunityStageHistoryResponse(), item); + response.setOperatorName(getOperatorNameOrDefault(operatorNameMap, item.getOperator())); + response.setFromStageName(getStageNameOrDefault(stageNameMap, item.getFromStage())); + response.setToStageName(getStageNameOrDefault(stageNameMap, item.getToStage())); + if (StringUtils.isNotBlank(item.getFailureReasonId())) { + response.setFailureReasonName(getNameIgnoreCase(reasonNameMap, item.getFailureReasonId())); + } + return response; + }).toList(); + } + + private String getNameIgnoreCase(Map nameMap, String key) { + if (StringUtils.isBlank(key)) { + return null; + } + return nameMap.get(key); + } + + private String getStageNameOrDefault(Map stageNameMap, String stageId) { + if (StringUtils.isBlank(stageId)) { + return "未知阶段"; + } + String name = stageNameMap.get(stageId); + return StringUtils.isNotBlank(name) ? name : "未知阶段"; + } + + private String getOperatorNameOrDefault(Map operatorNameMap, String operatorId) { + if (StringUtils.isBlank(operatorId)) { + return "未知用户"; + } + String name = operatorNameMap.get(operatorId); + return StringUtils.isNotBlank(name) ? name : "未知用户"; + } + + public void add(String opportunityId, String fromStage, String toStage, String operatorId, String failureReasonId) { + OpportunityStageHistory history = new OpportunityStageHistory(); + history.setId(IDGenerator.nextStr()); + history.setOpportunityId(opportunityId); + history.setFromStage(fromStage); + history.setToStage(toStage); + history.setChangeTime(System.currentTimeMillis()); + history.setOperator(operatorId); + history.setFailureReasonId(failureReasonId); + opportunityStageHistoryMapper.insert(history); + } + + public void deleteByOpportunityId(String opportunityId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(OpportunityStageHistory::getOpportunityId, opportunityId); + opportunityStageHistoryMapper.deleteByLambda(wrapper); + } + + public void deleteByOpportunityIds(List opportunityIds) { + if (CollectionUtils.isEmpty(opportunityIds)) { + return; + } + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.in(OpportunityStageHistory::getOpportunityId, opportunityIds); + opportunityStageHistoryMapper.deleteByLambda(wrapper); + } +} diff --git a/frontend/packages/lib-shared/api/modules/opportunity.ts b/frontend/packages/lib-shared/api/modules/opportunity.ts index 39955e3b5c..e2a180db81 100644 --- a/frontend/packages/lib-shared/api/modules/opportunity.ts +++ b/frontend/packages/lib-shared/api/modules/opportunity.ts @@ -73,6 +73,7 @@ import { UpdateQuotationUrl, UpdateQuotationViewUrl, VoidQuotationUrl, + GetOptStageHistoryListUrl, } from '@lib/shared/api/requrls/opportunity'; import type { ChartResponseDataItem, @@ -108,6 +109,7 @@ import type { OpportunityItem, OpportunityPageQueryParams, OpportunityStageConfig, + OpportunityStageHistoryItem, QuotationItem, QuotationQueryParams, SaveOpportunityParams, @@ -282,6 +284,11 @@ export default function useProductApi(CDR: CordysAxios) { return CDR.get({ url: GetOpportunityStageConfigUrl }, { ignoreCancelToken: true }); } + // 获取商机阶段变更历史列表 + function getOptStageHistoryList(opportunityId: string) { + return CDR.get({ url: `${GetOptStageHistoryListUrl}/${opportunityId}` }); + } + // 删除商机阶段 function deleteOpportunityStage(id: string) { return CDR.get({ url: `${DeleteOpportunityStageUrl}/${id}` }); @@ -518,6 +525,7 @@ export default function useProductApi(CDR: CordysAxios) { sortOpportunityStage, addOpportunityStage, getOpportunityStageConfig, + getOptStageHistoryList, deleteOpportunityStage, generateOpportunityChart, getQuotationTab, diff --git a/frontend/packages/lib-shared/api/requrls/opportunity.ts b/frontend/packages/lib-shared/api/requrls/opportunity.ts index eed1daf723..ca8c8d7b0c 100644 --- a/frontend/packages/lib-shared/api/requrls/opportunity.ts +++ b/frontend/packages/lib-shared/api/requrls/opportunity.ts @@ -80,3 +80,6 @@ export const DownloadQuotationUrl = '/opportunity/quotation/download'; export const PreCheckOptImportUrl = '/opportunity/import/pre-check'; export const DownloadOptTemplateUrl = '/opportunity/template/download'; export const ImportOpportunityUrl = '/opportunity/import'; + +// 商机阶段变更历史 +export const GetOptStageHistoryListUrl = '/opportunity/stage/history/list'; diff --git a/frontend/packages/lib-shared/models/opportunity.ts b/frontend/packages/lib-shared/models/opportunity.ts index 78b621e36e..495ff17727 100644 --- a/frontend/packages/lib-shared/models/opportunity.ts +++ b/frontend/packages/lib-shared/models/opportunity.ts @@ -188,3 +188,17 @@ export interface BatchOperationResult { skip?: number; errorMessages?: string; } + +export interface OpportunityStageHistoryItem { + id: string; + opportunityId: string; + fromStage: string; + fromStageName: string; + toStage: string; + toStageName: string; + changeTime: number; + operator: string; + operatorName: string; + failureReasonId?: string; + failureReasonName?: string; +} diff --git a/frontend/packages/web/src/views/opportunity/components/optOverviewDrawer.vue b/frontend/packages/web/src/views/opportunity/components/optOverviewDrawer.vue index d3591cb191..8e7ab5fb73 100644 --- a/frontend/packages/web/src/views/opportunity/components/optOverviewDrawer.vue +++ b/frontend/packages/web/src/views/opportunity/components/optOverviewDrawer.vue @@ -77,6 +77,60 @@ :source-name="titleName" /> + +
+ + + +
+
+ + {{ item.fromStageName || '-' }} + + + + + + {{ item.toStageName || '-' }} + +
+
+ {{ item.operatorName || '-' }} + {{ dayjs(item.changeTime).format('YYYY-MM-DD HH:mm:ss') }} +
+
+ {{ t('common.fail') }}: + {{ item.failureReasonName }} +
+
+
+
+ +
+