9 天以前 3411691fba52492dd417e4d7707c84f6250fff4f
feat(crm): 添加销售目标管理功能

- 新增销售目标相关字典类型和错误码定义
- 创建销售目标数据表和初始化SQL脚本
- 实现销售目标的增删改查和状态变更功能
- 添加达成分析功能和统计数据查询
- 集成前端销售计划和达成分析页面
- 完善销售目标相关的DTO和枚举类定义
已修改2个文件
已添加22个文件
2106 ■■■■■ 文件已修改
.playwright-mcp/page-2026-08-12T08-14-30-909Z.yml 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.playwright-mcp/page-2026-08-12T08-14-46-176Z.yml 194 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.playwright-mcp/page-2026-08-12T08-15-00-361Z.yml 215 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.playwright-mcp/page-2026-08-12T08-16-45-289Z.yml 215 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/crm_sales_target_integration.md 141 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql/crm_sales_target_init.sql 75 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sales-target-analysis.png 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/salesTarget/CrmSalesTargetController.java 104 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/salesTarget/vo/CrmSalesTargetChangeStatusReqVO.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/salesTarget/vo/CrmSalesTargetPageReqVO.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/salesTarget/vo/CrmSalesTargetRespVO.java 130 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/salesTarget/vo/CrmSalesTargetSaveReqVO.java 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/salesTarget/vo/analysis/CrmSalesTargetAnalysisReqVO.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/salesTarget/CrmSalesTargetDO.java 107 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/salesTarget/CrmSalesTargetAchievementMapper.java 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/salesTarget/CrmSalesTargetMapper.java 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/DictTypeConstants.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/ErrorCodeConstants.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/salesTarget/CrmSalesTargetDimensionEnum.java 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/salesTarget/CrmSalesTargetPeriodTypeEnum.java 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/salesTarget/CrmSalesTargetStatusEnum.java 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/salesTarget/CrmSalesTargetService.java 81 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/salesTarget/CrmSalesTargetServiceImpl.java 368 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/resources/mapper/salesTarget/CrmSalesTargetAchievementMapper.xml 84 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.playwright-mcp/page-2026-08-12T08-14-30-909Z.yml
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1 @@
- generic [active]: å·¥ä¸šAI智造平台
.playwright-mcp/page-2026-08-12T08-14-46-176Z.yml
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,194 @@
- generic [ref=e7]:
  - complementary [ref=e8]:
    - generic [ref=e9]:
      - generic [ref=e10] [cursor=pointer]
      - link "工业AI智造平台 å·¥ä¸šAI智造平台" [ref=e15] [cursor=pointer]:
        - /url: javascript:void 0
        - img "工业AI智造平台" [ref=e18]
        - generic [ref=e19]: å·¥ä¸šAI智造平台
      - menu [ref=e22]:
        - menuitem "首页" [ref=e23] [cursor=pointer]
        - listitem [ref=e30]:
          - generic [ref=e31] [cursor=pointer]: åŸºç¡€æ•°æ®
        - listitem [ref=e39]:
          - generic [ref=e40] [cursor=pointer]: é‡‡è´­ç®¡ç†
        - listitem [ref=e46]:
          - generic [ref=e47] [cursor=pointer]: è¥é”€ç®¡ç†
          - list [ref=e577]:
            - menuitem "待办事项" [ref=e578] [cursor=pointer]
            - menuitem "线索管理" [ref=e583] [cursor=pointer]
            - menuitem "商机管理" [ref=e588] [cursor=pointer]
            - menuitem "销售报价" [ref=e593] [cursor=pointer]
            - menuitem "合同管理" [ref=e599] [cursor=pointer]
            - menuitem "销售订单" [ref=e605] [cursor=pointer]
            - menuitem "客户管理" [ref=e610] [cursor=pointer]
            - menuitem "联系人管理" [ref=e615] [cursor=pointer]
            - menuitem "客户公海" [ref=e620] [cursor=pointer]
            - menuitem "开票管理" [ref=e625] [cursor=pointer]
            - menuitem "回款管理" [ref=e630] [cursor=pointer]
            - menuitem "回款计划" [ref=e637] [cursor=pointer]
            - menuitem "销售计划" [ref=e642] [cursor=pointer]
            - menuitem "达成分析" [ref=e647] [cursor=pointer]
            - listitem [ref=e653]:
              - generic [ref=e654] [cursor=pointer]: æ•°æ®ç»Ÿè®¡
            - listitem [ref=e660]:
              - generic [ref=e661] [cursor=pointer]: ç³»ç»Ÿé…ç½®
        - listitem [ref=e53]:
          - generic [ref=e54] [cursor=pointer]: è®¾å¤‡ç®¡ç†
        - listitem [ref=e61]:
          - generic [ref=e62] [cursor=pointer]: ä»“储物流
        - listitem [ref=e70]:
          - generic [ref=e71] [cursor=pointer]: ç”Ÿäº§ç®¡æŽ§
        - listitem [ref=e78]:
          - generic [ref=e79] [cursor=pointer]: è´¨é‡ç®¡ç†
        - listitem [ref=e85]:
          - generic [ref=e86] [cursor=pointer]: å†³ç­–BI大屏
        - listitem [ref=e92]:
          - generic [ref=e93] [cursor=pointer]: ååŒåŠžå…¬
        - listitem [ref=e99]:
          - generic [ref=e100] [cursor=pointer]: äººåŠ›èµ„æº
        - listitem [ref=e106]:
          - generic [ref=e107] [cursor=pointer]: å”®åŽç®¡ç†
        - listitem [ref=e113]:
          - generic [ref=e114] [cursor=pointer]: åŸºç¡€è®¾æ–½
        - listitem [ref=e120]:
          - generic [ref=e121] [cursor=pointer]: ç³»ç»Ÿç®¡ç†
        - listitem [ref=e127]:
          - generic [ref=e128] [cursor=pointer]: IM å³æ—¶é€šè®¯
      - generic [ref=e136] [cursor=pointer]
  - generic [ref=e141]:
    - generic [ref=e142]:
      - banner [ref=e143]:
        - button [ref=e144] [cursor=pointer]
        - button [ref=e147] [cursor=pointer]
        - navigation "breadcrumb" [ref=e152]:
          - list [ref=e153]:
            - listitem [ref=e154]:
              - link "首页" [disabled] [ref=e155]
        - generic [ref=e162]:
          - generic [ref=e164] [cursor=pointer]:
            - generic [ref=e168]: æœç´¢
            - generic [ref=e169]: Ctrl K
          - button [ref=e171] [cursor=pointer]
          - button "dark" [ref=e176] [cursor=pointer]
          - button [ref=e185] [cursor=pointer]
          - button [ref=e191] [cursor=pointer]
          - button [ref=e194] [cursor=pointer]
          - button [ref=e200] [cursor=pointer]:
            - button [ref=e202]
          - button "工作台" [ref=e206] [cursor=pointer]
          - button [ref=e210] [cursor=pointer]:
            - img "超级管理员" [ref=e215]
      - generic [ref=e217]:
        - generic [ref=e218]: é¦–页
        - generic [ref=e239]:
          - button [ref=e240] [cursor=pointer]
          - generic [ref=e247] [cursor=pointer]
          - generic [ref=e251] [cursor=pointer]
    - main [ref=e258]:
      - generic [ref=e261]:
        - generic [ref=e262]:
          - generic [ref=e263]:
            - heading "下午好,欢迎回来" [level=1] [ref=e264]
            - paragraph [ref=e265]: 2026å¹´8月12日 æ˜ŸæœŸä¸‰
          - generic [ref=e266]: ç³»ç»Ÿè¿è¡Œä¸­
        - generic [ref=e271]:
          - generic [ref=e280] [cursor=pointer]:
            - generic [ref=e281]: ç”Ÿäº§è®¢å•
            - generic [ref=e282]:
              - generic [ref=e283]: "25"
              - generic [ref=e285]: è¿›è¡Œä¸­
            - generic [ref=e286]:
              - text: å¾…排产 15
              - generic [ref=e288]: "|"
              - text: å·²å®Œæˆ 5
          - generic [ref=e300] [cursor=pointer]:
            - generic [ref=e301]: ä»Šæ—¥äº§é‡
            - generic [ref=e302]:
              - generic [ref=e303]: "0"
              - generic [ref=e305]: ä»¶
            - generic [ref=e306]: æ˜¨æ—¥ 0 ä»¶
          - generic [ref=e317] [cursor=pointer]:
            - generic [ref=e318]: è´¨é‡åˆæ ¼çއ
            - generic [ref=e319]:
              - generic [ref=e320]: "0.0"
              - generic [ref=e322]: "%"
            - generic [ref=e323]: åˆæ ¼ 0 | ä¸è‰¯ 0
          - generic [ref=e335] [cursor=pointer]:
            - generic [ref=e336]: è®¾å¤‡è¿è¡Œçއ
            - generic [ref=e337]:
              - generic [ref=e338]: "33.3"
              - generic [ref=e340]: "%"
            - generic [ref=e341]:
              - generic [ref=e342]: åœæœº 1
              - generic [ref=e343]: "|"
              - generic [ref=e344]: ç»´æŠ¤ 1
        - generic [ref=e347]:
          - generic [ref=e357] [cursor=pointer]:
            - generic [ref=e358]: é‡‡è´­è®¢å•
            - generic [ref=e359]:
              - generic [ref=e360]: "10"
              - generic [ref=e362]: ç¬”
            - generic [ref=e363]: ERP é‡‡è´­ç®¡ç†
          - generic [ref=e374] [cursor=pointer]:
            - generic [ref=e375]: é”€å”®è®¢å•
            - generic [ref=e376]:
              - generic [ref=e377]: "22"
              - generic [ref=e379]: ç¬”
            - generic [ref=e380]: ERP é”€å”®ç®¡ç†
          - generic [ref=e392] [cursor=pointer]:
            - generic [ref=e393]: ç‰©æ–™åº“å­˜
            - generic [ref=e394]:
              - generic [ref=e395]: "15"
              - generic [ref=e397]: æ¡
            - generic [ref=e398]: MES åº“存台账
          - generic [ref=e410] [cursor=pointer]:
            - generic [ref=e411]: æˆ‘的待办
            - generic [ref=e412]:
              - generic [ref=e413]: "1"
              - generic [ref=e415]: é¡¹
            - generic [ref=e416]: BPM å¾…审批
        - generic [ref=e419]:
          - generic [ref=e420]:
            - heading "AI æ™ºèƒ½åˆ†æž" [level=3] [ref=e421]
            - generic [ref=e422]: 25 ä¸ªå·¥å•可分析
          - generic [ref=e423]:
            - generic [ref=e431] [cursor=pointer]:
              - generic [ref=e432]: AI ç‰©æ–™é¢„测
              - generic [ref=e433]: é¢„测物料需求与短缺风险
            - generic [ref=e442] [cursor=pointer]:
              - generic [ref=e443]: AI æ—¶é•¿é¢„测
              - generic [ref=e444]: é¢„测各工序生产耗时
            - generic [ref=e451] [cursor=pointer]:
              - generic [ref=e452]: AI é£Žé™©é¢„测
              - generic [ref=e453]: è¯†åˆ«æ½œåœ¨ç”Ÿäº§é£Žé™©å¹¶ç»™å‡ºå»ºè®®
            - generic [ref=e463] [cursor=pointer]:
              - generic [ref=e464]: AI äº¤ä»˜è¯„ä¼°
              - generic [ref=e465]: è¯„估订单交付准时率与延期因素
        - generic [ref=e468]:
          - heading "生产趋势(近7天)" [level=3] [ref=e471]
          - generic [ref=e476]:
            - generic [ref=e477]:
              - generic [ref=e483] [cursor=pointer]:
                - generic [ref=e484]: å®‰ç¯å‘¼å«
                - generic [ref=e485]:
                  - generic [ref=e486]: "0"
                  - generic [ref=e488]: æœªå¤„ç½®
              - generic [ref=e494] [cursor=pointer]:
                - generic [ref=e495]: ç»´ä¿®å·¥å•
                - generic [ref=e496]:
                  - generic [ref=e497]: "0"
                  - generic [ref=e499]: å¾…处理
            - heading "工单状态分布" [level=4] [ref=e501]
        - generic [ref=e509]:
          - heading "快捷操作" [level=3] [ref=e510]
          - generic [ref=e511]:
            - generic [ref=e512] [cursor=pointer]: ç”Ÿäº§å·¥å•
            - generic [ref=e519] [cursor=pointer]: ç”Ÿäº§æŠ¥å·¥
            - generic [ref=e526] [cursor=pointer]: è´¨æ£€ç®¡ç†
            - generic [ref=e534] [cursor=pointer]: è®¾å¤‡ç‚¹æ£€
            - generic [ref=e541] [cursor=pointer]: ç”Ÿäº§æŽ’程
            - generic [ref=e548] [cursor=pointer]: å®‰ç¯å‘¼å«
            - generic [ref=e553] [cursor=pointer]: ç‰©æ–™åº“å­˜
            - generic [ref=e561] [cursor=pointer]: å·¥è‰ºè·¯çº¿
            - generic [ref=e569] [cursor=pointer]: æ•°æ®æŠ¥è¡¨
.playwright-mcp/page-2026-08-12T08-15-00-361Z.yml
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,215 @@
- generic [ref=e7]:
  - complementary [ref=e8]:
    - generic [ref=e9]:
      - generic [ref=e10] [cursor=pointer]
      - link "工业AI智造平台 å·¥ä¸šAI智造平台" [ref=e15] [cursor=pointer]:
        - /url: javascript:void 0
        - img "工业AI智造平台" [ref=e18]
        - generic [ref=e19]: å·¥ä¸šAI智造平台
      - menu [ref=e22]:
        - menuitem "首页" [ref=e23] [cursor=pointer]
        - listitem [ref=e30]:
          - generic [ref=e31] [cursor=pointer]: åŸºç¡€æ•°æ®
        - listitem [ref=e39]:
          - generic [ref=e40] [cursor=pointer]: é‡‡è´­ç®¡ç†
        - listitem [ref=e46]:
          - generic [ref=e47] [cursor=pointer]: è¥é”€ç®¡ç†
          - list [ref=e577]:
            - menuitem "待办事项" [ref=e578] [cursor=pointer]
            - menuitem "线索管理" [ref=e583] [cursor=pointer]
            - menuitem "商机管理" [ref=e588] [cursor=pointer]
            - menuitem "销售报价" [ref=e593] [cursor=pointer]
            - menuitem "合同管理" [ref=e599] [cursor=pointer]
            - menuitem "销售订单" [ref=e605] [cursor=pointer]
            - menuitem "客户管理" [ref=e610] [cursor=pointer]
            - menuitem "联系人管理" [ref=e615] [cursor=pointer]
            - menuitem "客户公海" [ref=e620] [cursor=pointer]
            - menuitem "开票管理" [ref=e625] [cursor=pointer]
            - menuitem "回款管理" [ref=e630] [cursor=pointer]
            - menuitem "回款计划" [ref=e637] [cursor=pointer]
            - menuitem "销售计划" [ref=e642] [cursor=pointer]
            - menuitem "达成分析" [active] [ref=e647] [cursor=pointer]
            - listitem [ref=e653]:
              - generic [ref=e654] [cursor=pointer]: æ•°æ®ç»Ÿè®¡
            - listitem [ref=e660]:
              - generic [ref=e661] [cursor=pointer]: ç³»ç»Ÿé…ç½®
        - listitem [ref=e53]:
          - generic [ref=e54] [cursor=pointer]: è®¾å¤‡ç®¡ç†
        - listitem [ref=e61]:
          - generic [ref=e62] [cursor=pointer]: ä»“储物流
        - listitem [ref=e70]:
          - generic [ref=e71] [cursor=pointer]: ç”Ÿäº§ç®¡æŽ§
        - listitem [ref=e78]:
          - generic [ref=e79] [cursor=pointer]: è´¨é‡ç®¡ç†
        - listitem [ref=e85]:
          - generic [ref=e86] [cursor=pointer]: å†³ç­–BI大屏
        - listitem [ref=e92]:
          - generic [ref=e93] [cursor=pointer]: ååŒåŠžå…¬
        - listitem [ref=e99]:
          - generic [ref=e100] [cursor=pointer]: äººåŠ›èµ„æº
        - listitem [ref=e106]:
          - generic [ref=e107] [cursor=pointer]: å”®åŽç®¡ç†
        - listitem [ref=e113]:
          - generic [ref=e114] [cursor=pointer]: åŸºç¡€è®¾æ–½
        - listitem [ref=e120]:
          - generic [ref=e121] [cursor=pointer]: ç³»ç»Ÿç®¡ç†
        - listitem [ref=e127]:
          - generic [ref=e128] [cursor=pointer]: IM å³æ—¶é€šè®¯
      - generic [ref=e136] [cursor=pointer]
  - generic [ref=e141]:
    - generic [ref=e142]:
      - banner [ref=e143]:
        - button [ref=e144] [cursor=pointer]
        - button [ref=e147] [cursor=pointer]
        - navigation "breadcrumb" [ref=e152]:
          - list [ref=e153]:
            - listitem [ref=e668]:
              - link "营销管理" [ref=e669] [cursor=pointer]:
                - /url: javascript:void 0
              - listitem [ref=e673]
            - listitem [ref=e676]:
              - link "达成分析" [disabled] [ref=e677]
        - generic [ref=e162]:
          - generic [ref=e164] [cursor=pointer]:
            - generic [ref=e168]: æœç´¢
            - generic [ref=e169]: Ctrl K
          - button [ref=e171] [cursor=pointer]
          - button "dark" [ref=e176] [cursor=pointer]
          - button [ref=e185] [cursor=pointer]
          - button [ref=e191] [cursor=pointer]
          - button [ref=e194] [cursor=pointer]
          - button [ref=e200] [cursor=pointer]:
            - button [ref=e202]
          - button "工作台" [ref=e206] [cursor=pointer]
          - button [ref=e210] [cursor=pointer]:
            - img "超级管理员" [ref=e215]
      - generic [ref=e217]:
        - generic [ref=e222]:
          - generic [ref=e223] [cursor=pointer]: é¦–页
          - generic [ref=e684] [cursor=pointer]: è¾¾æˆåˆ†æž
        - generic [ref=e239]:
          - button [ref=e240] [cursor=pointer]
          - generic [ref=e247] [cursor=pointer]
          - generic [ref=e251] [cursor=pointer]
    - main [ref=e258]:
      - generic [ref=e705]:
        - generic [ref=e707]:
          - generic [ref=e708]:
            - generic [ref=e709]:
              - generic [ref=e710]: "*"
              - text: å‘¨æœŸç±»åž‹
            - generic [ref=e716] [cursor=pointer]:
              - combobox "* å‘¨æœŸç±»åž‹" [ref=e718]
              - generic "年度" [ref=e719]
          - generic [ref=e720]:
            - generic [ref=e721]:
              - generic [ref=e722]: "*"
              - text: å‘¨æœŸå€¼
            - textbox "* å‘¨æœŸå€¼" [ref=e727]:
              - /placeholder: å¦‚ 2026 / 2026-Q1 / 2026-07
              - text: "2026"
          - generic [ref=e728]:
            - generic [ref=e729]: ç›®æ ‡ç»´åº¦
            - generic [ref=e735] [cursor=pointer]:
              - combobox "目标维度" [ref=e737]
              - generic: å…¨éƒ¨
          - generic [ref=e738]:
            - generic [ref=e739]: çŠ¶æ€
            - generic [ref=e745] [cursor=pointer]:
              - combobox "状态" [ref=e747]
              - generic: å…¨éƒ¨
          - generic [ref=e748]:
            - button "重 ç½®" [ref=e749] [cursor=pointer]
            - button "查 è¯¢" [ref=e751] [cursor=pointer]
            - generic [ref=e753] [cursor=pointer]: æ”¶èµ·
        - generic [ref=e758]:
          - tablist [ref=e759]:
            - generic [ref=e761]:
              - tab "合同金额" [selected] [ref=e763] [cursor=pointer]
              - tab "回款金额" [ref=e765] [cursor=pointer]
              - tab "商机金额" [ref=e767] [cursor=pointer]
              - tab "新增客户" [ref=e769] [cursor=pointer]
              - tab "新增合同" [ref=e771] [cursor=pointer]
          - generic:
            - generic:
              - tabpanel "合同金额"
        - generic [ref=e784]:
          - generic [ref=e785]:
            - table [ref=e789]:
              - rowgroup [ref=e809]:
                - row [ref=e810]:
                  - columnheader "目标对象" [ref=e811]
                  - columnheader "合同金额目标" [ref=e816]
                  - columnheader "实际合同金额" [ref=e821]
                  - columnheader "合同达成率(%)" [ref=e826]
                  - columnheader "回款金额目标" [ref=e831]
                  - columnheader "实际回款金额" [ref=e836]
                  - columnheader "回款达成率(%)" [ref=e841]
                  - columnheader "商机金额目标" [ref=e846]
                  - columnheader "实际商机金额" [ref=e851]
                  - columnheader "商机达成率(%)" [ref=e856]
                  - columnheader "新增客户目标" [ref=e861]
                  - columnheader "实际新增客户" [ref=e866]
                  - columnheader "客户达成率(%)" [ref=e871]
                  - columnheader "新增合同目标" [ref=e876]
                  - columnheader "实际新增合同" [ref=e881]
                  - columnheader "合同数达成率(%)" [ref=e886]
            - table [ref=e894]:
              - rowgroup [ref=e914]:
                - row [ref=e915]:
                  - cell "超级管理员" [ref=e916]
                  - cell "10000.00" [ref=e919]
                  - cell "32377.00" [ref=e923]
                  - cell "323.77%" [ref=e927]
                  - cell "10000.00" [ref=e930]
                  - cell "100.00" [ref=e934]
                  - cell "1%" [ref=e938]
                  - cell "10000.00" [ref=e941]
                  - cell "0.00" [ref=e945]
                  - cell "0%" [ref=e949]
                  - cell "10000" [ref=e952]
                  - cell "3" [ref=e956]
                  - cell "0.03%" [ref=e960]
                  - cell "10000" [ref=e963]
                  - cell "5" [ref=e967]
                  - cell "0.05%" [ref=e971]
                - row [ref=e974]:
                  - cell "超级管理员" [ref=e975]
                  - cell "500000.00" [ref=e978]
                  - cell "32377.00" [ref=e982]
                  - cell "6.48%" [ref=e986]
                  - cell "300000.00" [ref=e989]
                  - cell "100.00" [ref=e993]
                  - cell "0.03%" [ref=e997]
                  - cell "600000.00" [ref=e1000]
                  - cell "0.00" [ref=e1004]
                  - cell "0%" [ref=e1008]
                  - cell "20" [ref=e1011]
                  - cell "3" [ref=e1015]
                  - cell "15%" [ref=e1019]
                  - cell "30" [ref=e1022]
                  - cell "5" [ref=e1026]
                  - cell "16.67%" [ref=e1030]
          - generic:
            - generic [ref=e1033]:
              - table [ref=e1036]:
                - rowgroup [ref=e1056]:
                  - row [ref=e1057]:
                    - columnheader "目标名称" [ref=e1058]
              - table [ref=e1065]:
                - rowgroup [ref=e1068]:
                  - row [ref=e1069]:
                    - cell "酒泉" [ref=e1070]
                  - row [ref=e1074]:
                    - cell "2026年度销售目标-张三" [ref=e1075]
            - generic [ref=e1079]:
              - table [ref=e1082]:
                - rowgroup [ref=e1102]:
                  - row [ref=e1103]:
                    - columnheader "状态" [ref=e1104]
              - table [ref=e1111]:
                - rowgroup [ref=e1114]:
                  - row [ref=e1115]:
                    - cell "已生效" [ref=e1116]
                  - row [ref=e1120]:
                    - cell "已生效" [ref=e1121]
.playwright-mcp/page-2026-08-12T08-16-45-289Z.yml
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,215 @@
- generic [ref=e7]:
  - complementary [ref=e8]:
    - generic [ref=e9]:
      - generic [ref=e10] [cursor=pointer]
      - link "工业AI智造平台 å·¥ä¸šAI智造平台" [ref=e15] [cursor=pointer]:
        - /url: javascript:void 0
        - img "工业AI智造平台" [ref=e18]
        - generic [ref=e19]: å·¥ä¸šAI智造平台
      - menu [ref=e22]:
        - menuitem "首页" [ref=e23] [cursor=pointer]
        - listitem [ref=e30]:
          - generic [ref=e31] [cursor=pointer]: åŸºç¡€æ•°æ®
        - listitem [ref=e39]:
          - generic [ref=e40] [cursor=pointer]: é‡‡è´­ç®¡ç†
        - listitem [ref=e46]:
          - generic [ref=e47] [cursor=pointer]: è¥é”€ç®¡ç†
          - list [ref=e577]:
            - menuitem "待办事项" [ref=e578] [cursor=pointer]
            - menuitem "线索管理" [ref=e583] [cursor=pointer]
            - menuitem "商机管理" [ref=e588] [cursor=pointer]
            - menuitem "销售报价" [ref=e593] [cursor=pointer]
            - menuitem "合同管理" [ref=e599] [cursor=pointer]
            - menuitem "销售订单" [ref=e605] [cursor=pointer]
            - menuitem "客户管理" [ref=e610] [cursor=pointer]
            - menuitem "联系人管理" [ref=e615] [cursor=pointer]
            - menuitem "客户公海" [ref=e620] [cursor=pointer]
            - menuitem "开票管理" [ref=e625] [cursor=pointer]
            - menuitem "回款管理" [ref=e630] [cursor=pointer]
            - menuitem "回款计划" [ref=e637] [cursor=pointer]
            - menuitem "销售计划" [ref=e642] [cursor=pointer]
            - menuitem "达成分析" [ref=e647] [cursor=pointer]
            - listitem [ref=e653]:
              - generic [ref=e654] [cursor=pointer]: æ•°æ®ç»Ÿè®¡
            - listitem [ref=e660]:
              - generic [ref=e661] [cursor=pointer]: ç³»ç»Ÿé…ç½®
        - listitem [ref=e53]:
          - generic [ref=e54] [cursor=pointer]: è®¾å¤‡ç®¡ç†
        - listitem [ref=e61]:
          - generic [ref=e62] [cursor=pointer]: ä»“储物流
        - listitem [ref=e70]:
          - generic [ref=e71] [cursor=pointer]: ç”Ÿäº§ç®¡æŽ§
        - listitem [ref=e78]:
          - generic [ref=e79] [cursor=pointer]: è´¨é‡ç®¡ç†
        - listitem [ref=e85]:
          - generic [ref=e86] [cursor=pointer]: å†³ç­–BI大屏
        - listitem [ref=e92]:
          - generic [ref=e93] [cursor=pointer]: ååŒåŠžå…¬
        - listitem [ref=e99]:
          - generic [ref=e100] [cursor=pointer]: äººåŠ›èµ„æº
        - listitem [ref=e106]:
          - generic [ref=e107] [cursor=pointer]: å”®åŽç®¡ç†
        - listitem [ref=e113]:
          - generic [ref=e114] [cursor=pointer]: åŸºç¡€è®¾æ–½
        - listitem [ref=e120]:
          - generic [ref=e121] [cursor=pointer]: ç³»ç»Ÿç®¡ç†
        - listitem [ref=e127]:
          - generic [ref=e128] [cursor=pointer]: IM å³æ—¶é€šè®¯
      - generic [ref=e136] [cursor=pointer]
  - generic [ref=e141]:
    - generic [ref=e142]:
      - banner [ref=e143]:
        - button [ref=e144] [cursor=pointer]
        - button [ref=e147] [cursor=pointer]
        - navigation "breadcrumb" [ref=e152]:
          - list [ref=e153]:
            - listitem [ref=e668]:
              - link "营销管理" [ref=e669] [cursor=pointer]:
                - /url: javascript:void 0
              - listitem [ref=e673]
            - listitem [ref=e676]:
              - link "达成分析" [disabled] [ref=e677]
        - generic [ref=e162]:
          - generic [ref=e164] [cursor=pointer]:
            - generic [ref=e168]: æœç´¢
            - generic [ref=e169]: Ctrl K
          - button [ref=e171] [cursor=pointer]
          - button "dark" [ref=e176] [cursor=pointer]
          - button [ref=e185] [cursor=pointer]
          - button [ref=e191] [cursor=pointer]
          - button [ref=e194] [cursor=pointer]
          - button [ref=e200] [cursor=pointer]:
            - button [ref=e202]
          - button "工作台" [ref=e206] [cursor=pointer]
          - button [ref=e210] [cursor=pointer]:
            - img "超级管理员" [ref=e215]
      - generic [ref=e217]:
        - generic [ref=e222]:
          - generic [ref=e223] [cursor=pointer]: é¦–页
          - generic [ref=e684] [cursor=pointer]: è¾¾æˆåˆ†æž
        - generic [ref=e239]:
          - button [ref=e240] [cursor=pointer]
          - generic [ref=e247] [cursor=pointer]
          - generic [ref=e251] [cursor=pointer]
    - main [ref=e258]:
      - generic [ref=e705]:
        - generic [ref=e707]:
          - generic [ref=e708]:
            - generic [ref=e709]:
              - generic [ref=e710]: "*"
              - text: å‘¨æœŸç±»åž‹
            - generic [ref=e716] [cursor=pointer]:
              - combobox "* å‘¨æœŸç±»åž‹" [ref=e718]
              - generic "年度" [ref=e719]
          - generic [ref=e720]:
            - generic [ref=e721]:
              - generic [ref=e722]: "*"
              - text: å‘¨æœŸå€¼
            - textbox "* å‘¨æœŸå€¼" [ref=e727]:
              - /placeholder: å¦‚ 2026 / 2026-Q1 / 2026-07
              - text: "2026"
          - generic [ref=e728]:
            - generic [ref=e729]: ç›®æ ‡ç»´åº¦
            - generic [ref=e735] [cursor=pointer]:
              - combobox "目标维度" [ref=e737]
              - generic: å…¨éƒ¨
          - generic [ref=e738]:
            - generic [ref=e739]: çŠ¶æ€
            - generic [ref=e745] [cursor=pointer]:
              - combobox "状态" [ref=e747]
              - generic: å…¨éƒ¨
          - generic [ref=e748]:
            - button "重 ç½®" [ref=e749] [cursor=pointer]
            - button "查 è¯¢" [ref=e751] [cursor=pointer]
            - generic [ref=e753] [cursor=pointer]: æ”¶èµ·
        - generic [ref=e758]:
          - tablist [ref=e759]:
            - generic [ref=e761]:
              - tab "合同金额" [ref=e763] [cursor=pointer]
              - tab "回款金额" [active] [selected] [ref=e765] [cursor=pointer]
              - tab "商机金额" [ref=e767] [cursor=pointer]
              - tab "新增客户" [ref=e769] [cursor=pointer]
              - tab "新增合同" [ref=e771] [cursor=pointer]
          - generic:
            - generic:
              - tabpanel "回款金额"
        - generic [ref=e784]:
          - generic [ref=e785]:
            - table [ref=e789]:
              - rowgroup [ref=e809]:
                - row [ref=e810]:
                  - columnheader "目标对象" [ref=e811]
                  - columnheader "合同金额目标" [ref=e816]
                  - columnheader "实际合同金额" [ref=e821]
                  - columnheader "合同达成率(%)" [ref=e826]
                  - columnheader "回款金额目标" [ref=e831]
                  - columnheader "实际回款金额" [ref=e836]
                  - columnheader "回款达成率(%)" [ref=e841]
                  - columnheader "商机金额目标" [ref=e846]
                  - columnheader "实际商机金额" [ref=e851]
                  - columnheader "商机达成率(%)" [ref=e856]
                  - columnheader "新增客户目标" [ref=e861]
                  - columnheader "实际新增客户" [ref=e866]
                  - columnheader "客户达成率(%)" [ref=e871]
                  - columnheader "新增合同目标" [ref=e876]
                  - columnheader "实际新增合同" [ref=e881]
                  - columnheader "合同数达成率(%)" [ref=e886]
            - table [ref=e894]:
              - rowgroup [ref=e914]:
                - row [ref=e915]:
                  - cell "超级管理员" [ref=e916]
                  - cell "10000.00" [ref=e919]
                  - cell "32377.00" [ref=e923]
                  - cell "323.77%" [ref=e927]
                  - cell "10000.00" [ref=e930]
                  - cell "100.00" [ref=e934]
                  - cell "1%" [ref=e938]
                  - cell "10000.00" [ref=e941]
                  - cell "0.00" [ref=e945]
                  - cell "0%" [ref=e949]
                  - cell "10000" [ref=e952]
                  - cell "3" [ref=e956]
                  - cell "0.03%" [ref=e960]
                  - cell "10000" [ref=e963]
                  - cell "5" [ref=e967]
                  - cell "0.05%" [ref=e971]
                - row [ref=e974]:
                  - cell "超级管理员" [ref=e975]
                  - cell "500000.00" [ref=e978]
                  - cell "32377.00" [ref=e982]
                  - cell "6.48%" [ref=e986]
                  - cell "300000.00" [ref=e989]
                  - cell "100.00" [ref=e993]
                  - cell "0.03%" [ref=e997]
                  - cell "600000.00" [ref=e1000]
                  - cell "0.00" [ref=e1004]
                  - cell "0%" [ref=e1008]
                  - cell "20" [ref=e1011]
                  - cell "3" [ref=e1015]
                  - cell "15%" [ref=e1019]
                  - cell "30" [ref=e1022]
                  - cell "5" [ref=e1026]
                  - cell "16.67%" [ref=e1030]
          - generic:
            - generic [ref=e1033]:
              - table [ref=e1036]:
                - rowgroup [ref=e1056]:
                  - row [ref=e1057]:
                    - columnheader "目标名称" [ref=e1058]
              - table [ref=e1065]:
                - rowgroup [ref=e1068]:
                  - row [ref=e1069]:
                    - cell "酒泉" [ref=e1070]
                  - row [ref=e1074]:
                    - cell "2026年度销售目标-张三" [ref=e1075]
            - generic [ref=e1079]:
              - table [ref=e1082]:
                - rowgroup [ref=e1102]:
                  - row [ref=e1103]:
                    - columnheader "状态" [ref=e1104]
              - table [ref=e1111]:
                - rowgroup [ref=e1114]:
                  - row [ref=e1115]:
                    - cell "已生效" [ref=e1116]
                  - row [ref=e1120]:
                    - cell "已生效" [ref=e1121]
docs/crm_sales_target_integration.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,141 @@
# è¥é”€ç®¡ç† - é”€å”®è®¡åˆ’(销售目标)前端联调方案
## æ¶‰åŠé¡µé¢
- è¥é”€ç®¡ç† â†’ é”€å”®è®¡åˆ’(菜单 1065000,组件 `crm/salesTarget/index`)
- è¥é”€ç®¡ç† â†’ è¾¾æˆåˆ†æžï¼ˆèœå• 1065100,组件 `crm/salesTarget/analysis/index`)
## ä¸šåŠ¡æµç¨‹ä¸Žæ•°æ®å¸¦å…¥
1. **设定目标**:管理员在「销售计划」页按周期(年/å­£/月)+ ç»´åº¦ï¼ˆä¸ªäºº/部门/产品)设定销售目标。目标对象(用户/部门/物料)从系统下拉选择,目标指标(合同金额、回款金额、商机金额、新增客户数、新增合同数)手工录入。
2. **周期自动换算**:前端只提交 `periodType`(1å¹´/2季度/3月)与 `periodValue`(如 `2026`、`2026-Q1`、`2026-07`),后台自动换算周期起止时间并落库;列表/详情展示换算后的起止时间。
3. **状态自动判定**:后台保存时按当前时间与周期自动判定状态(未生效/已生效/已结束),前端展示即可;仅「作废/恢复」由用户手动触发。
4. **达成统计自动带入**:列表、详情、达成分析均返回实际达成(合同金额、回款金额、商机金额、新增客户数、新增合同数)与达成率,前端直接展示,无需另行调用统计接口。
5. **达成分析**:用户在「达成分析」页选择周期(必选)+ ç»´åº¦ï¼ˆå¯é€‰ï¼‰+ çŠ¶æ€ï¼ˆå¯é€‰ï¼‰ï¼ŒåŽå°æŒ‰å‘¨æœŸè¿‡æ»¤è¿”å›žç›®æ ‡åˆ—è¡¨ï¼ˆå«å®žé™…è¾¾æˆä¸Žè¾¾æˆçŽ‡ï¼‰ã€‚
## API
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| POST | `/crm/sales-target/create` | åˆ›å»ºé”€å”®ç›®æ ‡ |
| PUT | `/crm/sales-target/update` | æ›´æ–°é”€å”®ç›®æ ‡ |
| PUT | `/crm/sales-target/change-status` | å˜æ›´ç›®æ ‡çŠ¶æ€ï¼ˆä½œåºŸ/恢复/生效) |
| DELETE | `/crm/sales-target/delete?id=` | åˆ é™¤é”€å”®ç›®æ ‡ |
| GET | `/crm/sales-target/get?id=` | ç›®æ ‡è¯¦æƒ…(含实际达成+达成率) |
| GET | `/crm/sales-target/page` | ç›®æ ‡åˆ†é¡µï¼ˆå«å®žé™…达成+达成率) |
| GET | `/crm/sales-target/export-excel` | å¯¼å‡º Excel |
| GET | `/crm/sales-target/get-achievement-analysis` | è¾¾æˆåˆ†æž |
### åˆ›å»º/更新请求参数(CrmSalesTargetSaveReqVO)
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| id | Long | æ›´æ–°æ—¶å¿…å¡« | ç›®æ ‡ç¼–号 |
| name | String | æ˜¯ | ç›®æ ‡åç§° |
| periodType | Integer | æ˜¯ | å‘¨æœŸç±»åž‹ï¼ˆå­—å…¸ crm_sales_target_period_type:1å¹´/2季度/3月) |
| periodValue | String | æ˜¯ | å‘¨æœŸå€¼ï¼Œå¦‚ `2026` / `2026-Q1` / `2026-07` |
| targetDimension | Integer | æ˜¯ | ç›®æ ‡ç»´åº¦ï¼ˆå­—å…¸ crm_sales_target_dimension:1个人/2部门/3产品) |
| targetUserId | Long | ç»´åº¦=个人时必填 | ç›®æ ‡ç”¨æˆ·ç¼–号 |
| targetDeptId | Long | ç»´åº¦=部门时必填 | ç›®æ ‡éƒ¨é—¨ç¼–号 |
| targetItemId | Long | ç»´åº¦=产品时必填 | ç›®æ ‡ç‰©æ–™ç¼–号(关联 MDM) |
| contractTarget | BigDecimal | å¦ | åˆåŒé‡‘额目标(元) |
| receivableTarget | BigDecimal | å¦ | å›žæ¬¾é‡‘额目标(元) |
| businessTarget | BigDecimal | å¦ | å•†æœºé‡‘额目标(元) |
| customerTarget | Integer | å¦ | æ–°å¢žå®¢æˆ·æ•°ç›®æ ‡ |
| contractCountTarget | Integer | å¦ | æ–°å¢žåˆåŒæ•°ç›®æ ‡ |
| remark | String | å¦ | å¤‡æ³¨ |
### çŠ¶æ€å˜æ›´è¯·æ±‚å‚æ•°ï¼ˆCrmSalesTargetChangeStatusReqVO)
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| id | Long | æ˜¯ | ç›®æ ‡ç¼–号 |
| status | Integer | æ˜¯ | ç›®æ ‡çŠ¶æ€ï¼ˆå­—å…¸ crm_sales_target_status:0未生效/1已生效/2已结束/3已作废) |
### è¾¾æˆåˆ†æžè¯·æ±‚参数(CrmSalesTargetAnalysisReqVO)
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| periodType | Integer | æ˜¯ | å‘¨æœŸç±»åž‹ |
| periodValue | String | æ˜¯ | å‘¨æœŸå€¼ |
| targetDimension | Integer | å¦ | ç›®æ ‡ç»´åº¦ï¼Œä¸ºç©ºæŸ¥å…¨éƒ¨ |
| status | Integer | å¦ | çŠ¶æ€ï¼Œä¸ºç©ºæŸ¥å…¨éƒ¨ |
### å“åº”字段(CrmSalesTargetRespVO)
目标基础字段(name/periodType/periodValue/startTime/endTime/targetDimension/status/remark/createTime/creator)之上,新增以下字段:
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| targetUserName | String | ç›®æ ‡å¯¹è±¡åç§°ï¼ˆç»´åº¦=个人时为昵称) |
| targetDeptName | String | ç›®æ ‡éƒ¨é—¨åç§°ï¼ˆç»´åº¦=部门) |
| targetItemName | String | ç›®æ ‡ç‰©æ–™åç§°ï¼ˆç»´åº¦=产品) |
| actualContractPrice | BigDecimal | å®žé™…合同金额(元) |
| actualReceivablePrice | BigDecimal | å®žé™…回款金额(元) |
| actualBusinessPrice | BigDecimal | å®žé™…商机金额(元) |
| actualCustomerCount | Long | å®žé™…新增客户数 |
| actualContractCount | Long | å®žé™…新增合同数 |
| contractRate | BigDecimal | åˆåŒé‡‘额达成率(%) |
| receivableRate | BigDecimal | å›žæ¬¾é‡‘额达成率(%) |
| businessRate | BigDecimal | å•†æœºé‡‘额达成率(%) |
| customerRate | BigDecimal | æ–°å¢žå®¢æˆ·æ•°è¾¾æˆçŽ‡ï¼ˆ%) |
| contractCountRate | BigDecimal | æ–°å¢žåˆåŒæ•°è¾¾æˆçŽ‡ï¼ˆ%) |
| creatorName | String | åˆ›å»ºäººåç§° |
**响应示例:**
```json
{
  "code": 0,
  "data": {
    "id": 1,
    "name": "2026年度销售目标-张三",
    "periodType": 1,
    "periodValue": "2026",
    "startTime": "2026-01-01 00:00:00",
    "endTime": "2027-01-01 00:00:00",
    "targetDimension": 1,
    "targetUserId": 1,
    "targetUserName": "张三",
    "contractTarget": 500000.00,
    "actualContractPrice": 32377.00,
    "contractRate": 6.48,
    "status": 1
  }
}
```
## å­—段展示规则
| å­—段 | å±•示位置 | è¯´æ˜Ž |
|------|----------|------|
| ç›®æ ‡åç§° | åˆ—表、详情 | ä¸»æ ‡é¢˜ |
| å‘¨æœŸ | åˆ—表、详情 | å‘¨æœŸç±»åž‹å­—典转文本 + å‘¨æœŸå€¼ï¼Œå¦‚「年度 2026」 |
| ç›®æ ‡å¯¹è±¡ | åˆ—表、详情 | æŒ‰ç»´åº¦å±•示目标用户/部门/物料名称(targetUserName/targetDeptName/targetItemName) |
| ç›®æ ‡æŒ‡æ ‡ | åˆ—表、详情、分析 | åˆåŒé‡‘额/回款金额/商机金额/新增客户数/新增合同数目标 |
| å®žé™…达成 | åˆ—表、详情、分析 | ä¸Žç›®æ ‡æŒ‡æ ‡ä¸€ä¸€å¯¹åº”的实际值 |
| è¾¾æˆçއ | åˆ—表、详情、分析 | ç™¾åˆ†æ¯”,保留两位小数;目标为 0 æˆ–空时达成率为空(展示为 `-`) |
| çŠ¶æ€ | åˆ—表、详情 | å­—典转文本,已作废需置灰/禁止编辑 |
| åˆ›å»ºäºº | åˆ—表、详情 | åˆ›å»ºäººæ˜µç§° |
## ä¸šåŠ¡è§„åˆ™è¯´æ˜Ž
| åœºæ™¯ | è§„则 |
|------|------|
| è¾¾æˆçŽ‡è®¡ç®— | å®žé™…值 Ã· ç›®æ ‡å€¼ Ã— 100,保留 2 ä½å°æ•°ï¼›ç›®æ ‡ä¸ºç©ºæˆ– â‰¤0 æ—¶è¾¾æˆçŽ‡è¿”å›ž null |
| å‘¨æœŸæ¢ç®— | å‰ç«¯æ— éœ€è®¡ç®—起止时间,后台按 periodValue æ¢ç®—;年 `2026`、季 `2026-Q1`、月 `2026-07` |
| çŠ¶æ€åˆ¤å®š | åŽå°æŒ‰å½“前时间自动判定 æœªç”Ÿæ•ˆ/已生效/已结束;前端新增编辑时无需手动选状态 |
| ä½œåºŸ/恢复 | å·²ä½œåºŸçš„目标被编辑后仍保持作废状态,需先「恢复」再编辑生效 |
| ç»Ÿè®¡å£å¾„ | åˆåŒé‡‘额/数量只统计审核通过(audit_status=20)的合同;回款只统计审核通过的回款;商机只统计赢单(end_status=1);客户按创建时间统计 |
| äº§å“ç»´åº¦ | ä»…统计合同金额与合同数,回款/商机/客户数为 0 |
| éƒ¨é—¨ç»´åº¦ | å«å­éƒ¨é—¨ä¸‹æ‰€æœ‰ç”¨æˆ·çš„æ•°æ® |
| æ•°æ®æƒé™ | ç›®æ ‡ä¸ºç®¡ç†é…ç½®ï¼Œä¸æŽ¥å…¥ CRM æ•°æ®æƒé™ï¼Œåˆ—表展示全部(可按需按创建人过滤) |
## æ³¨æ„äº‹é¡¹
- è¾¾æˆçŽ‡å­—æ®µå¯èƒ½ä¸º null,前端展示需兜底为 `-`,避免显示 0。
- å‘¨æœŸå€¼æ ¼å¼ä¸¥æ ¼ï¼šå¹´ `YYYY`、季 `YYYY-Q1`~`YYYY-Q4`、月 `YYYY-MM`,前端表单校验需按此约束。
- ç›®æ ‡ç»´åº¦ä¸Žç›®æ ‡å¯¹è±¡äº’斥:维度=个人填 userId,部门填 deptId,产品填 itemId,其余留空;前端按维度切换表单项。
- è¾¾æˆåˆ†æžæŽ¥å£çš„ periodType + periodValue ä¸ºå¿…填,未选周期时不应发起请求。
- è¾¾æˆç»Ÿè®¡æŸ¥è¯¢ä¸ºé€æ¡ç›®æ ‡è®¡ç®—(约 5 æ¡å° SQL),目标数量级几十~几百时性能可接受;若后续目标量级增大需改造为聚合统计,需提前知会后端。
- ã€Œé”€å”®è®¡åˆ’」页排序建议按创建时间倒序,与后台返回顺序一致(按 id å€’序)。
docs/sql/crm_sales_target_init.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,75 @@
-- =====================================================
-- CRM é”€å”®ç›®æ ‡ï¼ˆé”€å”®è®¡åˆ’)功能初始化脚本
-- 1. å»ºè¡¨ crm_sales_target
-- 2. æ•°æ®å­—典(周期类型 / ç›®æ ‡ç»´åº¦ / çŠ¶æ€ï¼‰
-- 3. èœå•与按钮权限(营销管理 -> é”€å”®è®¡åˆ’ / è¾¾æˆåˆ†æžï¼‰
-- =====================================================
-- 1. å»ºè¡¨
CREATE TABLE IF NOT EXISTS `crm_sales_target` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `name` varchar(128) NOT NULL COMMENT '目标名称',
  `period_type` int NOT NULL COMMENT '周期类型(字典 crm_sales_target_period_type: 1å¹´/2季度/3月)',
  `period_value` varchar(32) NOT NULL COMMENT '周期值,如 2026 / 2026-Q1 / 2026-07',
  `start_time` datetime NOT NULL COMMENT '周期开始时间(保存时由周期计算写入)',
  `end_time` datetime NOT NULL COMMENT '周期结束时间(保存时由周期计算写入)',
  `target_dimension` int NOT NULL COMMENT '目标维度(字典 crm_sales_target_dimension: 1个人/2部门/3产品)',
  `target_user_id` bigint DEFAULT NULL COMMENT '目标用户编号(维度=个人)',
  `target_dept_id` bigint DEFAULT NULL COMMENT '目标部门编号(维度=部门)',
  `target_item_id` bigint DEFAULT NULL COMMENT '目标物料编号(维度=产品,关联 MDM)',
  `contract_target` decimal(18,2) DEFAULT NULL COMMENT '合同金额目标(元)',
  `receivable_target` decimal(18,2) DEFAULT NULL COMMENT '回款金额目标(元)',
  `business_target` decimal(18,2) DEFAULT NULL COMMENT '商机金额目标(元)',
  `customer_target` int DEFAULT NULL COMMENT '新增客户数目标',
  `contract_count_target` int DEFAULT NULL COMMENT '新增合同数目标',
  `status` int NOT NULL DEFAULT 0 COMMENT '状态(字典 crm_sales_target_status: 0未生效/1已生效/2已结束/3已作废)',
  `remark` varchar(255) DEFAULT NULL COMMENT '备注',
  `creator` varchar(64) DEFAULT NULL COMMENT '创建者',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `updater` varchar(64) DEFAULT NULL COMMENT '更新者',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间',
  `deleted` tinyint NOT NULL DEFAULT 0 COMMENT '是否删除',
  PRIMARY KEY (`id`),
  KEY `idx_period` (`period_type`,`period_value`),
  KEY `idx_dimension` (`target_dimension`,`target_user_id`,`target_dept_id`,`target_item_id`),
  KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CRM é”€å”®ç›®æ ‡';
-- 2. æ•°æ®å­—典类型
INSERT INTO `system_dict_type` (`id`, `name`, `type`, `status`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`, `deleted_time`)
VALUES (1065300, '销售目标周期类型', 'crm_sales_target_period_type', 0, '销售目标的计划周期类型', '1', NOW(), '1', NOW(), b'0', NULL),
       (1065301, '销售目标维度', 'crm_sales_target_dimension', 0, '销售目标按什么维度设定', '1', NOW(), '1', NOW(), b'0', NULL),
       (1065302, '销售目标状态', 'crm_sales_target_status', 0, '销售目标的生命周期状态', '1', NOW(), '1', NOW(), b'0', NULL);
-- 2.1 æ•°æ®å­—典数据
INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
VALUES
  -- å‘¨æœŸç±»åž‹
  (1065310, 1, '年度', '1', 'crm_sales_target_period_type', 0, 'primary', '', '', '1', NOW(), '1', NOW(), b'0'),
  (1065311, 2, '季度', '2', 'crm_sales_target_period_type', 0, 'success', '', '', '1', NOW(), '1', NOW(), b'0'),
  (1065312, 3, '月度', '3', 'crm_sales_target_period_type', 0, 'warning', '', '', '1', NOW(), '1', NOW(), b'0'),
  -- ç›®æ ‡ç»´åº¦
  (1065320, 1, '个人', '1', 'crm_sales_target_dimension', 0, 'primary', '', '', '1', NOW(), '1', NOW(), b'0'),
  (1065321, 2, '部门', '2', 'crm_sales_target_dimension', 0, 'success', '', '', '1', NOW(), '1', NOW(), b'0'),
  (1065322, 3, '产品', '3', 'crm_sales_target_dimension', 0, 'warning', '', '', '1', NOW(), '1', NOW(), b'0'),
  -- çŠ¶æ€
  (1065330, 0, '未生效', '0', 'crm_sales_target_status', 0, 'info', '', '', '1', NOW(), '1', NOW(), b'0'),
  (1065331, 1, '已生效', '1', 'crm_sales_target_status', 0, 'success', '', '', '1', NOW(), '1', NOW(), b'0'),
  (1065332, 2, '已结束', '2', 'crm_sales_target_status', 0, 'warning', '', '', '1', NOW(), '1', NOW(), b'0'),
  (1065333, 3, '已作废', '3', 'crm_sales_target_status', 0, 'danger', '', '', '1', NOW(), '1', NOW(), b'0');
-- 3. èœå•:营销管理(id=2617) -> é”€å”®è®¡åˆ’ / è¾¾æˆåˆ†æž
INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
VALUES
  (1065000, '销售计划', '', 2, 62, 2617, 'salesTarget', 'ep:data-line', 'crm/salesTarget/index', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
  (1065100, '达成分析', '', 2, 63, 2617, 'salesTarget/analysis', 'ep:pie-chart', 'crm/salesTarget/analysis/index', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0');
-- 3.1 é”€å”®è®¡åˆ’按钮权限
INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
VALUES
  (1065001, '销售计划查询', 'crm:sales-target:query', 3, 1, 1065000, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
  (1065002, '销售计划创建', 'crm:sales-target:create', 3, 2, 1065000, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
  (1065003, '销售计划更新', 'crm:sales-target:update', 3, 3, 1065000, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
  (1065004, '销售计划删除', 'crm:sales-target:delete', 3, 4, 1065000, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
  (1065005, '销售计划导出', 'crm:sales-target:export', 3, 5, 1065000, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
  (1065101, '达成分析查询', 'crm:sales-target:query', 3, 1, 1065100, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0');
sales-target-analysis.png
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/salesTarget/CrmSalesTargetController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,104 @@
package cn.iocoder.yudao.module.crm.controller.admin.salesTarget;
import cn.iocoder.yudao.framework.apilog.core.annotation.ApiAccessLog;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils;
import cn.iocoder.yudao.module.crm.controller.admin.salesTarget.vo.CrmSalesTargetChangeStatusReqVO;
import cn.iocoder.yudao.module.crm.controller.admin.salesTarget.vo.CrmSalesTargetPageReqVO;
import cn.iocoder.yudao.module.crm.controller.admin.salesTarget.vo.CrmSalesTargetRespVO;
import cn.iocoder.yudao.module.crm.controller.admin.salesTarget.vo.CrmSalesTargetSaveReqVO;
import cn.iocoder.yudao.module.crm.controller.admin.salesTarget.vo.analysis.CrmSalesTargetAnalysisReqVO;
import cn.iocoder.yudao.module.crm.service.salesTarget.CrmSalesTargetService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.util.List;
import static cn.iocoder.yudao.framework.apilog.core.enums.OperateTypeEnum.EXPORT;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
import static cn.iocoder.yudao.framework.common.pojo.PageParam.PAGE_SIZE_NONE;
@Tag(name = "管理后台 - CRM é”€å”®ç›®æ ‡")
@RestController
@RequestMapping("/crm/sales-target")
@Validated
public class CrmSalesTargetController {
    @Resource
    private CrmSalesTargetService salesTargetService;
    @PostMapping("/create")
    @Operation(summary = "创建销售目标")
    @PreAuthorize("@ss.hasPermission('crm:sales-target:create')")
    public CommonResult<Long> createSalesTarget(@Valid @RequestBody CrmSalesTargetSaveReqVO createReqVO) {
        return success(salesTargetService.createSalesTarget(createReqVO));
    }
    @PutMapping("/update")
    @Operation(summary = "更新销售目标")
    @PreAuthorize("@ss.hasPermission('crm:sales-target:update')")
    public CommonResult<Boolean> updateSalesTarget(@Valid @RequestBody CrmSalesTargetSaveReqVO updateReqVO) {
        salesTargetService.updateSalesTarget(updateReqVO);
        return success(true);
    }
    @PutMapping("/change-status")
    @Operation(summary = "变更销售目标状态", description = "作废/恢复/生效等")
    @PreAuthorize("@ss.hasPermission('crm:sales-target:update')")
    public CommonResult<Boolean> changeSalesTargetStatus(@Valid @RequestBody CrmSalesTargetChangeStatusReqVO reqVO) {
        salesTargetService.changeSalesTargetStatus(reqVO);
        return success(true);
    }
    @DeleteMapping("/delete")
    @Operation(summary = "删除销售目标")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('crm:sales-target:delete')")
    public CommonResult<Boolean> deleteSalesTarget(@RequestParam("id") Long id) {
        salesTargetService.deleteSalesTarget(id);
        return success(true);
    }
    @GetMapping("/get")
    @Operation(summary = "获得销售目标", description = "含实际达成与达成率")
    @Parameter(name = "id", description = "编号", required = true, example = "1024")
    @PreAuthorize("@ss.hasPermission('crm:sales-target:query')")
    public CommonResult<CrmSalesTargetRespVO> getSalesTarget(@RequestParam("id") Long id) {
        return success(salesTargetService.getSalesTarget(id));
    }
    @GetMapping("/page")
    @Operation(summary = "获得销售目标分页", description = "含实际达成与达成率")
    @PreAuthorize("@ss.hasPermission('crm:sales-target:query')")
    public CommonResult<PageResult<CrmSalesTargetRespVO>> getSalesTargetPage(@Valid CrmSalesTargetPageReqVO pageReqVO) {
        return success(salesTargetService.getSalesTargetPage(pageReqVO));
    }
    @GetMapping("/export-excel")
    @Operation(summary = "导出销售目标 Excel")
    @PreAuthorize("@ss.hasPermission('crm:sales-target:export')")
    @ApiAccessLog(operateType = EXPORT)
    public void exportSalesTargetExcel(@Valid CrmSalesTargetPageReqVO exportReqVO,
                                       HttpServletResponse response) throws IOException {
        exportReqVO.setPageSize(PAGE_SIZE_NONE);
        List<CrmSalesTargetRespVO> list = salesTargetService.getSalesTargetList(exportReqVO);
        ExcelUtils.write(response, "销售目标.xls", "数据", CrmSalesTargetRespVO.class, list);
    }
    @GetMapping("/get-achievement-analysis")
    @Operation(summary = "获得销售目标达成分析", description = "按周期 + ç»´åº¦è¿‡æ»¤ç›®æ ‡ï¼Œè¿”回目标 vs å®žé™… + è¾¾æˆçއ")
    @PreAuthorize("@ss.hasPermission('crm:sales-target:query')")
    public CommonResult<List<CrmSalesTargetRespVO>> getSalesTargetAnalysis(@Valid CrmSalesTargetAnalysisReqVO reqVO) {
        return success(salesTargetService.getSalesTargetAnalysis(reqVO));
    }
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/salesTarget/vo/CrmSalesTargetChangeStatusReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,22 @@
package cn.iocoder.yudao.module.crm.controller.admin.salesTarget.vo;
import cn.iocoder.yudao.framework.common.validation.InEnum;
import cn.iocoder.yudao.module.crm.enums.salesTarget.CrmSalesTargetStatusEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Schema(description = "管理后台 - CRM é”€å”®ç›®æ ‡çŠ¶æ€å˜æ›´ Request VO")
@Data
public class CrmSalesTargetChangeStatusReqVO {
    @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "编号不能为空")
    private Long id;
    @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "3")
    @NotNull(message = "状态不能为空")
    @InEnum(CrmSalesTargetStatusEnum.class)
    private Integer status;
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/salesTarget/vo/CrmSalesTargetPageReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,30 @@
package cn.iocoder.yudao.module.crm.controller.admin.salesTarget.vo;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
@Schema(description = "管理后台 - CRM é”€å”®ç›®æ ‡åˆ†é¡µ Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class CrmSalesTargetPageReqVO extends PageParam {
    @Schema(description = "目标名称", example = "2026 ä¸ŠåŠå¹´é”€å”®ç›®æ ‡")
    private String name;
    @Schema(description = "周期类型", example = "1")
    private Integer periodType;
    @Schema(description = "周期值,如 2026 / 2026-Q1 / 2026-07", example = "2026-Q1")
    private String periodValue;
    @Schema(description = "目标维度", example = "1")
    private Integer targetDimension;
    @Schema(description = "状态", example = "1")
    private Integer status;
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/salesTarget/vo/CrmSalesTargetRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,130 @@
package cn.iocoder.yudao.module.crm.controller.admin.salesTarget.vo;
import cn.iocoder.yudao.framework.excel.core.annotations.DictFormat;
import cn.iocoder.yudao.framework.excel.core.convert.DictConvert;
import cn.iocoder.yudao.module.crm.enums.DictTypeConstants;
import cn.idev.excel.annotation.ExcelIgnoreUnannotated;
import cn.idev.excel.annotation.ExcelProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Schema(description = "管理后台 - CRM é”€å”®ç›®æ ‡ Response VO")
@Data
@ExcelIgnoreUnannotated
public class CrmSalesTargetRespVO {
    @Schema(description = "编号", example = "1")
    @ExcelProperty("编号")
    private Long id;
    @Schema(description = "目标名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026 ä¸ŠåŠå¹´é”€å”®ç›®æ ‡")
    @ExcelProperty("目标名称")
    private String name;
    @Schema(description = "周期类型", example = "1")
    @ExcelProperty(value = "周期类型", converter = DictConvert.class)
    @DictFormat(DictTypeConstants.CRM_SALES_TARGET_PERIOD_TYPE)
    private Integer periodType;
    @Schema(description = "周期值,如 2026 / 2026-Q1 / 2026-07", example = "2026-Q1")
    @ExcelProperty("周期值")
    private String periodValue;
    @Schema(description = "周期开始时间", example = "2026-01-01 00:00:00")
    private LocalDateTime startTime;
    @Schema(description = "周期结束时间", example = "2026-03-31 23:59:59")
    private LocalDateTime endTime;
    @Schema(description = "目标维度", example = "1")
    @ExcelProperty(value = "目标维度", converter = DictConvert.class)
    @DictFormat(DictTypeConstants.CRM_SALES_TARGET_DIMENSION)
    private Integer targetDimension;
    @Schema(description = "目标用户编号(维度=个人)", example = "1")
    private Long targetUserId;
    @Schema(description = "目标用户名称", example = "张三")
    @ExcelProperty("目标对象")
    private String targetUserName;
    @Schema(description = "目标部门编号(维度=部门)", example = "100")
    private Long targetDeptId;
    @Schema(description = "目标部门名称", example = "销售部")
    private String targetDeptName;
    @Schema(description = "目标物料编号(维度=产品)", example = "1")
    private Long targetItemId;
    @Schema(description = "目标物料名称", example = "螺丝")
    private String targetItemName;
    @Schema(description = "合同金额目标(元)", example = "1000000")
    @ExcelProperty("合同金额目标")
    private BigDecimal contractTarget;
    @Schema(description = "回款金额目标(元)", example = "800000")
    @ExcelProperty("回款金额目标")
    private BigDecimal receivableTarget;
    @Schema(description = "商机金额目标(元)", example = "1500000")
    @ExcelProperty("商机金额目标")
    private BigDecimal businessTarget;
    @Schema(description = "新增客户数目标", example = "50")
    @ExcelProperty("新增客户数目标")
    private Integer customerTarget;
    @Schema(description = "新增合同数目标", example = "30")
    @ExcelProperty("新增合同数目标")
    private Integer contractCountTarget;
    @Schema(description = "实际合同金额(元)", example = "500000")
    @ExcelProperty("实际合同金额")
    private BigDecimal actualContractPrice;
    @Schema(description = "实际回款金额(元)", example = "300000")
    @ExcelProperty("实际回款金额")
    private BigDecimal actualReceivablePrice;
    @Schema(description = "实际商机金额(元)", example = "800000")
    @ExcelProperty("实际商机金额")
    private BigDecimal actualBusinessPrice;
    @Schema(description = "实际新增客户数", example = "20")
    @ExcelProperty("实际新增客户数")
    private Long actualCustomerCount;
    @Schema(description = "实际新增合同数", example = "15")
    @ExcelProperty("实际新增合同数")
    private Long actualContractCount;
    @Schema(description = "合同达成率(百分比)", example = "50.0")
    @ExcelProperty("合同达成率(%)")
    private BigDecimal contractRate;
    @Schema(description = "回款达成率(百分比)", example = "37.5")
    @ExcelProperty("回款达成率(%)")
    private BigDecimal receivableRate;
    @Schema(description = "商机达成率(百分比)", example = "53.3")
    @ExcelProperty("商机达成率(%)")
    private BigDecimal businessRate;
    @Schema(description = "新增客户数达成率(百分比)", example = "40.0")
    @ExcelProperty("新增客户数达成率(%)")
    private BigDecimal customerRate;
    @Schema(description = "新增合同数达成率(百分比)", example = "50.0")
    @ExcelProperty("新增合同数达成率(%)")
    private BigDecimal contractCountRate;
    @Schema(description = "状态", example = "1")
    @ExcelProperty(value = "状态", converter = DictConvert.class)
    @DictFormat(DictTypeConstants.CRM_SALES_TARGET_STATUS)
    private Integer status;
    @Schema(description = "备注", example = "备注")
    @ExcelProperty("备注")
    private String remark;
    @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
    @ExcelProperty("创建时间")
    private LocalDateTime createTime;
    @Schema(description = "创建人", example = "1")
    private String creator;
    @Schema(description = "创建人名称", example = "超级管理员")
    @ExcelProperty("创建人")
    private String creatorName;
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/salesTarget/vo/CrmSalesTargetSaveReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,65 @@
package cn.iocoder.yudao.module.crm.controller.admin.salesTarget.vo;
import cn.iocoder.yudao.framework.common.validation.InEnum;
import cn.iocoder.yudao.module.crm.enums.salesTarget.CrmSalesTargetDimensionEnum;
import cn.iocoder.yudao.module.crm.enums.salesTarget.CrmSalesTargetPeriodTypeEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.math.BigDecimal;
@Schema(description = "管理后台 - CRM é”€å”®ç›®æ ‡æ–°å¢ž/修改 Request VO")
@Data
public class CrmSalesTargetSaveReqVO {
    @Schema(description = "编号", example = "1")
    private Long id;
    @Schema(description = "目标名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026 ä¸ŠåŠå¹´é”€å”®ç›®æ ‡")
    @NotEmpty(message = "目标名称不能为空")
    private String name;
    @Schema(description = "周期类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "周期类型不能为空")
    @InEnum(CrmSalesTargetPeriodTypeEnum.class)
    private Integer periodType;
    @Schema(description = "周期值,如 2026 / 2026-Q1 / 2026-07", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-Q1")
    @NotEmpty(message = "周期值不能为空")
    private String periodValue;
    @Schema(description = "目标维度", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "目标维度不能为空")
    @InEnum(CrmSalesTargetDimensionEnum.class)
    private Integer targetDimension;
    @Schema(description = "目标用户编号(维度=个人)", example = "1")
    private Long targetUserId;
    @Schema(description = "目标部门编号(维度=部门)", example = "100")
    private Long targetDeptId;
    @Schema(description = "目标物料编号(维度=产品,关联 MDM)", example = "1")
    private Long targetItemId;
    @Schema(description = "合同金额目标(元)", example = "1000000")
    private BigDecimal contractTarget;
    @Schema(description = "回款金额目标(元)", example = "800000")
    private BigDecimal receivableTarget;
    @Schema(description = "商机金额目标(元)", example = "1500000")
    private BigDecimal businessTarget;
    @Schema(description = "新增客户数目标", example = "50")
    private Integer customerTarget;
    @Schema(description = "新增合同数目标", example = "30")
    private Integer contractCountTarget;
    @Schema(description = "备注", example = "备注")
    private String remark;
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/salesTarget/vo/analysis/CrmSalesTargetAnalysisReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,31 @@
package cn.iocoder.yudao.module.crm.controller.admin.salesTarget.vo.analysis;
import cn.iocoder.yudao.framework.common.validation.InEnum;
import cn.iocoder.yudao.module.crm.enums.salesTarget.CrmSalesTargetDimensionEnum;
import cn.iocoder.yudao.module.crm.enums.salesTarget.CrmSalesTargetPeriodTypeEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Schema(description = "管理后台 - CRM é”€å”®ç›®æ ‡è¾¾æˆåˆ†æž Request VO")
@Data
public class CrmSalesTargetAnalysisReqVO {
    @Schema(description = "周期类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "周期类型不能为空")
    @InEnum(CrmSalesTargetPeriodTypeEnum.class)
    private Integer periodType;
    @Schema(description = "周期值,如 2026 / 2026-Q1 / 2026-07", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026")
    @NotEmpty(message = "周期值不能为空")
    private String periodValue;
    @Schema(description = "状态,为空则全部", example = "1")
    private Integer status;
    @Schema(description = "目标维度,为空则全部", example = "1")
    @InEnum(CrmSalesTargetDimensionEnum.class)
    private Integer targetDimension;
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/dataobject/salesTarget/CrmSalesTargetDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,107 @@
package cn.iocoder.yudao.module.crm.dal.dataobject.salesTarget;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import cn.iocoder.yudao.module.crm.enums.DictTypeConstants;
import cn.iocoder.yudao.module.crm.enums.salesTarget.CrmSalesTargetDimensionEnum;
import cn.iocoder.yudao.module.crm.enums.salesTarget.CrmSalesTargetPeriodTypeEnum;
import cn.iocoder.yudao.module.crm.enums.salesTarget.CrmSalesTargetStatusEnum;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
 * CRM é”€å”®ç›®æ ‡ DO
 *
 * @author è¶…级管理员
 */
@TableName("crm_sales_target")
@KeySequence("crm_sales_target_seq") // ç”¨äºŽ Oracle、PostgreSQL、Kingbase、DB2、H2 æ•°æ®åº“的主键自增。如果是 MySQL ç­‰æ•°æ®åº“,可不写。
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CrmSalesTargetDO extends BaseDO {
    /**
     * ç¼–号
     */
    @TableId
    private Long id;
    /**
     * ç›®æ ‡åç§°
     */
    private String name;
    /**
     * å‘¨æœŸç±»åž‹
     *
     * å­—å…¸ {@link DictTypeConstants#CRM_SALES_TARGET_PERIOD_TYPE} / æžšä¸¾ {@link CrmSalesTargetPeriodTypeEnum}
     */
    private Integer periodType;
    /**
     * å‘¨æœŸå€¼ï¼Œå¦‚ 2026 / 2026-Q1 / 2026-07
     */
    private String periodValue;
    /**
     * å‘¨æœŸå¼€å§‹æ—¶é—´ï¼ˆä¿å­˜æ—¶ç”±å‘¨æœŸè®¡ç®—写入)
     */
    private LocalDateTime startTime;
    /**
     * å‘¨æœŸç»“束时间(保存时由周期计算写入)
     */
    private LocalDateTime endTime;
    /**
     * ç›®æ ‡ç»´åº¦
     *
     * å­—å…¸ {@link DictTypeConstants#CRM_SALES_TARGET_DIMENSION} / æžšä¸¾ {@link CrmSalesTargetDimensionEnum}
     */
    private Integer targetDimension;
    /**
     * ç›®æ ‡ç”¨æˆ·ç¼–号(维度=个人)
     */
    private Long targetUserId;
    /**
     * ç›®æ ‡éƒ¨é—¨ç¼–号(维度=部门)
     */
    private Long targetDeptId;
    /**
     * ç›®æ ‡ç‰©æ–™ç¼–号(维度=产品,关联 MDM)
     */
    private Long targetItemId;
    /**
     * åˆåŒé‡‘额目标(元)
     */
    private BigDecimal contractTarget;
    /**
     * å›žæ¬¾é‡‘额目标(元)
     */
    private BigDecimal receivableTarget;
    /**
     * å•†æœºé‡‘额目标(元)
     */
    private BigDecimal businessTarget;
    /**
     * æ–°å¢žå®¢æˆ·æ•°ç›®æ ‡
     */
    private Integer customerTarget;
    /**
     * æ–°å¢žåˆåŒæ•°ç›®æ ‡
     */
    private Integer contractCountTarget;
    /**
     * çŠ¶æ€
     *
     * å­—å…¸ {@link DictTypeConstants#CRM_SALES_TARGET_STATUS} / æžšä¸¾ {@link CrmSalesTargetStatusEnum}
     */
    private Integer status;
    /**
     * å¤‡æ³¨
     */
    private String remark;
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/salesTarget/CrmSalesTargetAchievementMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,62 @@
package cn.iocoder.yudao.module.crm.dal.mysql.salesTarget;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Collection;
/**
 * é”€å”®ç›®æ ‡è¾¾æˆç»Ÿè®¡ Mapper
 *
 * ç»Ÿè®¡å®žé™…达成金额/数量,从 CRM çŽ°æœ‰ä¸šåŠ¡è¡¨æŒ‰å‘¨æœŸèŒƒå›´ + å½’属人(或物料)聚合。
 *
 * @author è¶…级管理员
 */
@Mapper
public interface CrmSalesTargetAchievementMapper {
    /**
     * åˆåŒé‡‘额(个人/部门维度)
     */
    BigDecimal selectContractPriceByUserIds(@Param("userIds") Collection<Long> userIds,
                                            @Param("start") LocalDateTime start, @Param("end") LocalDateTime end);
    /**
     * åˆåŒæ•°é‡ï¼ˆä¸ªäºº/部门维度)
     */
    Long selectContractCountByUserIds(@Param("userIds") Collection<Long> userIds,
                                      @Param("start") LocalDateTime start, @Param("end") LocalDateTime end);
    /**
     * å›žæ¬¾é‡‘额(个人/部门维度)
     */
    BigDecimal selectReceivablePriceByUserIds(@Param("userIds") Collection<Long> userIds,
                                              @Param("start") LocalDateTime start, @Param("end") LocalDateTime end);
    /**
     * å•†æœºé‡‘额(个人/部门维度,赢单)
     */
    BigDecimal selectBusinessPriceByUserIds(@Param("userIds") Collection<Long> userIds,
                                            @Param("start") LocalDateTime start, @Param("end") LocalDateTime end);
    /**
     * æ–°å¢žå®¢æˆ·æ•°ï¼ˆä¸ªäºº/部门维度)
     */
    Long selectCustomerCountByUserIds(@Param("userIds") Collection<Long> userIds,
                                      @Param("start") LocalDateTime start, @Param("end") LocalDateTime end);
    /**
     * åˆåŒé‡‘额(产品维度,按合同产品明细归属物料)
     */
    BigDecimal selectContractPriceByItemId(@Param("itemId") Long itemId,
                                           @Param("start") LocalDateTime start, @Param("end") LocalDateTime end);
    /**
     * åˆåŒæ•°é‡ï¼ˆäº§å“ç»´åº¦ï¼ŒæŒ‰åˆåŒäº§å“æ˜Žç»†å½’属物料)
     */
    Long selectContractCountByItemId(@Param("itemId") Long itemId,
                                     @Param("start") LocalDateTime start, @Param("end") LocalDateTime end);
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/salesTarget/CrmSalesTargetMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,38 @@
package cn.iocoder.yudao.module.crm.dal.mysql.salesTarget;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.crm.controller.admin.salesTarget.vo.CrmSalesTargetPageReqVO;
import cn.iocoder.yudao.module.crm.dal.dataobject.salesTarget.CrmSalesTargetDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
/**
 * é”€å”®ç›®æ ‡ Mapper
 *
 * @author è¶…级管理员
 */
@Mapper
public interface CrmSalesTargetMapper extends BaseMapperX<CrmSalesTargetDO> {
    default PageResult<CrmSalesTargetDO> selectPage(CrmSalesTargetPageReqVO reqVO) {
        return selectPage(reqVO, buildQuery(reqVO));
    }
    default List<CrmSalesTargetDO> selectList(CrmSalesTargetPageReqVO reqVO) {
        return selectList(buildQuery(reqVO));
    }
    private LambdaQueryWrapperX<CrmSalesTargetDO> buildQuery(CrmSalesTargetPageReqVO reqVO) {
        return new LambdaQueryWrapperX<CrmSalesTargetDO>()
                .likeIfPresent(CrmSalesTargetDO::getName, reqVO.getName())
                .eqIfPresent(CrmSalesTargetDO::getPeriodType, reqVO.getPeriodType())
                .eqIfPresent(CrmSalesTargetDO::getPeriodValue, reqVO.getPeriodValue())
                .eqIfPresent(CrmSalesTargetDO::getTargetDimension, reqVO.getTargetDimension())
                .eqIfPresent(CrmSalesTargetDO::getStatus, reqVO.getStatus())
                .orderByDesc(CrmSalesTargetDO::getId);
    }
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/DictTypeConstants.java
@@ -16,5 +16,8 @@
    String CRM_FOLLOW_UP_TYPE = "crm_follow_up_type"; // CRM è·Ÿè¿›æ–¹å¼
    String CRM_RECEIVABLE_RETURN_TYPE = "crm_receivable_return_type"; // CRM å›žæ¬¾æ–¹å¼
    String CRM_INVOICE_TYPE = "crm_invoice_type"; // CRM å‘票类型
    String CRM_SALES_TARGET_PERIOD_TYPE = "crm_sales_target_period_type"; // CRM é”€å”®ç›®æ ‡å‘¨æœŸç±»åž‹
    String CRM_SALES_TARGET_DIMENSION = "crm_sales_target_dimension"; // CRM é”€å”®ç›®æ ‡ç»´åº¦
    String CRM_SALES_TARGET_STATUS = "crm_sales_target_status"; // CRM é”€å”®ç›®æ ‡çŠ¶æ€
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/ErrorCodeConstants.java
@@ -136,4 +136,8 @@
    ErrorCode RECEIVABLE_CREATE_FAIL_INVOICE_CONTRACT_MISMATCH = new ErrorCode(1_020_016_011, "创建回款失败,原因:关联的开票与所选合同不匹配");
    ErrorCode RECEIVABLE_CREATE_FAIL_INVOICE_PRICE_EXCEEDS = new ErrorCode(1_020_016_012, "创建回款失败,原因:回款金额超出该开票剩余可回款金额,目前可回款:{} å…ƒ");
    ErrorCode CRM_SALES_TARGET_NOT_EXISTS = new ErrorCode(1_020_017_000, "销售目标不存在");
    ErrorCode CRM_SALES_TARGET_PERIOD_VALUE_INVALID = new ErrorCode(1_020_017_001, "周期值【{}】格式不正确,应为 {}");
    ErrorCode CRM_SALES_TARGET_DIMENSION_TARGET_MISSING = new ErrorCode(1_020_017_002, "请填写目标{}编号");
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/salesTarget/CrmSalesTargetDimensionEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,45 @@
package cn.iocoder.yudao.module.crm.enums.salesTarget;
import cn.iocoder.yudao.framework.common.core.ArrayValuable;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.Arrays;
/**
 * é”€å”®ç›®æ ‡çš„目标维度枚举
 *
 * @author è¶…级管理员
 */
@RequiredArgsConstructor
@Getter
public enum CrmSalesTargetDimensionEnum implements ArrayValuable<Integer> {
    USER(1, "个人"),
    DEPT(2, "部门"),
    ITEM(3, "产品");
    public static final Integer[] ARRAYS = Arrays.stream(values()).map(CrmSalesTargetDimensionEnum::getType).toArray(Integer[]::new);
    /**
     * ç»´åº¦ç±»åž‹
     */
    private final Integer type;
    /**
     * ç»´åº¦åç§°
     */
    private final String name;
    @Override
    public Integer[] array() {
        return ARRAYS;
    }
    public static CrmSalesTargetDimensionEnum fromType(Integer type) {
        return Arrays.stream(values())
                .filter(value -> value.getType().equals(type))
                .findFirst()
                .orElse(null);
    }
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/salesTarget/CrmSalesTargetPeriodTypeEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,45 @@
package cn.iocoder.yudao.module.crm.enums.salesTarget;
import cn.iocoder.yudao.framework.common.core.ArrayValuable;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.Arrays;
/**
 * é”€å”®ç›®æ ‡çš„周期类型枚举
 *
 * @author è¶…级管理员
 */
@RequiredArgsConstructor
@Getter
public enum CrmSalesTargetPeriodTypeEnum implements ArrayValuable<Integer> {
    YEAR(1, "年度"),
    QUARTER(2, "季度"),
    MONTH(3, "月度");
    public static final Integer[] ARRAYS = Arrays.stream(values()).map(CrmSalesTargetPeriodTypeEnum::getType).toArray(Integer[]::new);
    /**
     * å‘¨æœŸç±»åž‹
     */
    private final Integer type;
    /**
     * å‘¨æœŸåç§°
     */
    private final String name;
    @Override
    public Integer[] array() {
        return ARRAYS;
    }
    public static CrmSalesTargetPeriodTypeEnum fromType(Integer type) {
        return Arrays.stream(values())
                .filter(value -> value.getType().equals(type))
                .findFirst()
                .orElse(null);
    }
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/salesTarget/CrmSalesTargetStatusEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,46 @@
package cn.iocoder.yudao.module.crm.enums.salesTarget;
import cn.iocoder.yudao.framework.common.core.ArrayValuable;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.Arrays;
/**
 * é”€å”®ç›®æ ‡çš„状态枚举
 *
 * @author è¶…级管理员
 */
@RequiredArgsConstructor
@Getter
public enum CrmSalesTargetStatusEnum implements ArrayValuable<Integer> {
    NOT_ACTIVE(0, "未生效"),
    ACTIVE(1, "已生效"),
    END(2, "已结束"),
    INVALID(3, "已作废");
    public static final Integer[] ARRAYS = Arrays.stream(values()).map(CrmSalesTargetStatusEnum::getStatus).toArray(Integer[]::new);
    /**
     * çŠ¶æ€
     */
    private final Integer status;
    /**
     * çŠ¶æ€åç§°
     */
    private final String name;
    @Override
    public Integer[] array() {
        return ARRAYS;
    }
    public static CrmSalesTargetStatusEnum fromStatus(Integer status) {
        return Arrays.stream(values())
                .filter(value -> value.getStatus().equals(status))
                .findFirst()
                .orElse(null);
    }
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/salesTarget/CrmSalesTargetService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,81 @@
package cn.iocoder.yudao.module.crm.service.salesTarget;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.crm.controller.admin.salesTarget.vo.CrmSalesTargetChangeStatusReqVO;
import cn.iocoder.yudao.module.crm.controller.admin.salesTarget.vo.CrmSalesTargetPageReqVO;
import cn.iocoder.yudao.module.crm.controller.admin.salesTarget.vo.CrmSalesTargetRespVO;
import cn.iocoder.yudao.module.crm.controller.admin.salesTarget.vo.CrmSalesTargetSaveReqVO;
import cn.iocoder.yudao.module.crm.controller.admin.salesTarget.vo.analysis.CrmSalesTargetAnalysisReqVO;
import jakarta.validation.Valid;
import java.util.List;
/**
 * CRM é”€å”®ç›®æ ‡ Service æŽ¥å£
 *
 * @author è¶…级管理员
 */
public interface CrmSalesTargetService {
    /**
     * åˆ›å»ºé”€å”®ç›®æ ‡
     *
     * @param createReqVO åˆ›å»ºä¿¡æ¯
     * @return ç¼–号
     */
    Long createSalesTarget(@Valid CrmSalesTargetSaveReqVO createReqVO);
    /**
     * æ›´æ–°é”€å”®ç›®æ ‡
     *
     * @param updateReqVO æ›´æ–°ä¿¡æ¯
     */
    void updateSalesTarget(@Valid CrmSalesTargetSaveReqVO updateReqVO);
    /**
     * å˜æ›´é”€å”®ç›®æ ‡çŠ¶æ€ï¼ˆä½œåºŸ/恢复等)
     *
     * @param reqVO çŠ¶æ€å˜æ›´ä¿¡æ¯
     */
    void changeSalesTargetStatus(@Valid CrmSalesTargetChangeStatusReqVO reqVO);
    /**
     * åˆ é™¤é”€å”®ç›®æ ‡
     *
     * @param id ç¼–号
     */
    void deleteSalesTarget(Long id);
    /**
     * èŽ·å¾—é”€å”®ç›®æ ‡ï¼ˆå«å®žé™…è¾¾æˆï¼‰
     *
     * @param id ç¼–号
     * @return é”€å”®ç›®æ ‡
     */
    CrmSalesTargetRespVO getSalesTarget(Long id);
    /**
     * èŽ·å¾—é”€å”®ç›®æ ‡åˆ†é¡µï¼ˆå«å®žé™…è¾¾æˆï¼‰
     *
     * @param pageReqVO åˆ†é¡µæŸ¥è¯¢
     * @return é”€å”®ç›®æ ‡åˆ†é¡µ
     */
    PageResult<CrmSalesTargetRespVO> getSalesTargetPage(CrmSalesTargetPageReqVO pageReqVO);
    /**
     * èŽ·å¾—é”€å”®ç›®æ ‡åˆ—è¡¨ï¼ˆå…¨é‡ï¼Œç”¨äºŽ Excel å¯¼å‡ºï¼‰
     *
     * @param reqVO æŸ¥è¯¢æ¡ä»¶
     * @return é”€å”®ç›®æ ‡åˆ—表
     */
    List<CrmSalesTargetRespVO> getSalesTargetList(CrmSalesTargetPageReqVO reqVO);
    /**
     * èŽ·å¾—é”€å”®ç›®æ ‡è¾¾æˆåˆ†æžåˆ—è¡¨ï¼ˆæŒ‰å‘¨æœŸ+维度过滤,含实际达成与达成率)
     *
     * @param reqVO åˆ†æžæŸ¥è¯¢
     * @return è¾¾æˆåˆ†æžåˆ—表
     */
    List<CrmSalesTargetRespVO> getSalesTargetAnalysis(CrmSalesTargetAnalysisReqVO reqVO);
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/salesTarget/CrmSalesTargetServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,368 @@
package cn.iocoder.yudao.module.crm.service.salesTarget;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.crm.controller.admin.salesTarget.vo.CrmSalesTargetChangeStatusReqVO;
import cn.iocoder.yudao.module.crm.controller.admin.salesTarget.vo.CrmSalesTargetPageReqVO;
import cn.iocoder.yudao.module.crm.controller.admin.salesTarget.vo.CrmSalesTargetRespVO;
import cn.iocoder.yudao.module.crm.controller.admin.salesTarget.vo.CrmSalesTargetSaveReqVO;
import cn.iocoder.yudao.module.crm.controller.admin.salesTarget.vo.analysis.CrmSalesTargetAnalysisReqVO;
import cn.iocoder.yudao.module.crm.dal.dataobject.salesTarget.CrmSalesTargetDO;
import cn.iocoder.yudao.module.crm.dal.mysql.salesTarget.CrmSalesTargetAchievementMapper;
import cn.iocoder.yudao.module.crm.dal.mysql.salesTarget.CrmSalesTargetMapper;
import cn.iocoder.yudao.module.crm.enums.salesTarget.CrmSalesTargetDimensionEnum;
import cn.iocoder.yudao.module.crm.enums.salesTarget.CrmSalesTargetPeriodTypeEnum;
import cn.iocoder.yudao.module.crm.enums.salesTarget.CrmSalesTargetStatusEnum;
import cn.iocoder.yudao.module.mdm.api.item.MdmItemApi;
import cn.iocoder.yudao.module.mdm.api.item.dto.MdmItemRespDTO;
import cn.iocoder.yudao.module.system.api.dept.DeptApi;
import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.time.YearMonth;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
import static cn.iocoder.yudao.module.crm.enums.ErrorCodeConstants.*;
/**
 * é”€å”®ç›®æ ‡ Service å®žçŽ°ç±»
 *
 * @author è¶…级管理员
 */
@Service
@Validated
public class CrmSalesTargetServiceImpl implements CrmSalesTargetService {
    private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
    private static final Pattern YEAR_PATTERN = Pattern.compile("^\\d{4}$");
    private static final Pattern QUARTER_PATTERN = Pattern.compile("^(\\d{4})-Q([1-4])$");
    private static final Pattern MONTH_PATTERN = Pattern.compile("^(\\d{4})-(0[1-9]|1[0-2])$");
    private static final DateTimeFormatter MONTH_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM");
    @Resource
    private CrmSalesTargetMapper salesTargetMapper;
    @Resource
    private CrmSalesTargetAchievementMapper achievementMapper;
    @Resource
    private AdminUserApi adminUserApi;
    @Resource
    private DeptApi deptApi;
    @Resource
    private MdmItemApi mdmItemApi;
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long createSalesTarget(CrmSalesTargetSaveReqVO createReqVO) {
        // 1. æ ¡éªŒç›®æ ‡ç»´åº¦å¯¹è±¡
        validateTargetDimension(createReqVO);
        // 2. è®¡ç®—周期起止,并插入
        CrmSalesTargetDO salesTarget = BeanUtils.toBean(createReqVO, CrmSalesTargetDO.class);
        LocalDateTime[] period = calculatePeriod(createReqVO.getPeriodType(), createReqVO.getPeriodValue());
        salesTarget.setStartTime(period[0]).setEndTime(period[1])
                .setStatus(calcStatusByPeriod(period[0], period[1]));
        salesTargetMapper.insert(salesTarget);
        return salesTarget.getId();
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateSalesTarget(CrmSalesTargetSaveReqVO updateReqVO) {
        // 1. æ ¡éªŒå­˜åœ¨
        CrmSalesTargetDO salesTarget = validateSalesTargetExists(updateReqVO.getId());
        // 2. æ ¡éªŒç›®æ ‡ç»´åº¦å¯¹è±¡
        validateTargetDimension(updateReqVO);
        // 3. æ›´æ–°
        CrmSalesTargetDO updateDO = BeanUtils.toBean(updateReqVO, CrmSalesTargetDO.class);
        LocalDateTime[] period = calculatePeriod(updateReqVO.getPeriodType(), updateReqVO.getPeriodValue());
        updateDO.setStartTime(period[0]).setEndTime(period[1]);
        // æ‰‹åŠ¨ä½œåºŸçš„çŠ¶æ€ä¸å› ç¼–è¾‘è€Œè¦†ç›–
        updateDO.setStatus(Objects.equals(salesTarget.getStatus(), CrmSalesTargetStatusEnum.INVALID.getStatus())
                ? CrmSalesTargetStatusEnum.INVALID.getStatus() : calcStatusByPeriod(period[0], period[1]));
        salesTargetMapper.updateById(updateDO);
    }
    @Override
    public void changeSalesTargetStatus(CrmSalesTargetChangeStatusReqVO reqVO) {
        // 1. æ ¡éªŒå­˜åœ¨
        validateSalesTargetExists(reqVO.getId());
        // 2. æ›´æ–°çŠ¶æ€
        salesTargetMapper.updateById(new CrmSalesTargetDO().setId(reqVO.getId()).setStatus(reqVO.getStatus()));
    }
    @Override
    public void deleteSalesTarget(Long id) {
        // 1. æ ¡éªŒå­˜åœ¨
        validateSalesTargetExists(id);
        // 2. åˆ é™¤
        salesTargetMapper.deleteById(id);
    }
    @Override
    public CrmSalesTargetRespVO getSalesTarget(Long id) {
        CrmSalesTargetDO salesTarget = validateSalesTargetExists(id);
        return convertListToRespVO(Collections.singletonList(salesTarget)).get(0);
    }
    @Override
    public PageResult<CrmSalesTargetRespVO> getSalesTargetPage(CrmSalesTargetPageReqVO pageReqVO) {
        PageResult<CrmSalesTargetDO> pageResult = salesTargetMapper.selectPage(pageReqVO);
        if (CollUtil.isEmpty(pageResult.getList())) {
            return PageResult.empty();
        }
        return new PageResult<>(convertListToRespVO(pageResult.getList()), pageResult.getTotal());
    }
    @Override
    public List<CrmSalesTargetRespVO> getSalesTargetList(CrmSalesTargetPageReqVO reqVO) {
        List<CrmSalesTargetDO> list = salesTargetMapper.selectList(reqVO);
        return convertListToRespVO(list);
    }
    @Override
    public List<CrmSalesTargetRespVO> getSalesTargetAnalysis(CrmSalesTargetAnalysisReqVO reqVO) {
        // æŒ‰å‘¨æœŸ + ç»´åº¦è¿‡æ»¤æŸ¥è¯¢å…¨éƒ¨ç›®æ ‡ï¼ˆå«å·²ç»“束),用于达成分析看板
        List<CrmSalesTargetDO> list = salesTargetMapper.selectList(new cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX<CrmSalesTargetDO>()
                .eqIfPresent(CrmSalesTargetDO::getStatus, reqVO.getStatus())
                .eqIfPresent(CrmSalesTargetDO::getTargetDimension, reqVO.getTargetDimension())
                .eq(CrmSalesTargetDO::getPeriodType, reqVO.getPeriodType())
                .eq(CrmSalesTargetDO::getPeriodValue, reqVO.getPeriodValue())
                .orderByDesc(CrmSalesTargetDO::getId));
        return convertListToRespVO(list);
    }
    // ========== ç§æœ‰æ–¹æ³• ==========
    private CrmSalesTargetDO validateSalesTargetExists(Long id) {
        CrmSalesTargetDO salesTarget = salesTargetMapper.selectById(id);
        if (salesTarget == null) {
            throw exception(CRM_SALES_TARGET_NOT_EXISTS);
        }
        return salesTarget;
    }
    private void validateTargetDimension(CrmSalesTargetSaveReqVO reqVO) {
        if (Objects.equals(reqVO.getTargetDimension(), CrmSalesTargetDimensionEnum.USER.getType())
                && reqVO.getTargetUserId() == null) {
            throw exception(CRM_SALES_TARGET_DIMENSION_TARGET_MISSING, "用户");
        }
        if (Objects.equals(reqVO.getTargetDimension(), CrmSalesTargetDimensionEnum.DEPT.getType())
                && reqVO.getTargetDeptId() == null) {
            throw exception(CRM_SALES_TARGET_DIMENSION_TARGET_MISSING, "部门");
        }
        if (Objects.equals(reqVO.getTargetDimension(), CrmSalesTargetDimensionEnum.ITEM.getType())
                && reqVO.getTargetItemId() == null) {
            throw exception(CRM_SALES_TARGET_DIMENSION_TARGET_MISSING, "物料");
        }
    }
    /**
     * æ ¹æ®å‘¨æœŸç±»åž‹ + å‘¨æœŸå€¼è®¡ç®—周期起止时间(半开区间 [start, end))
     */
    private LocalDateTime[] calculatePeriod(Integer periodType, String periodValue) {
        if (Objects.equals(periodType, CrmSalesTargetPeriodTypeEnum.YEAR.getType())) {
            if (!YEAR_PATTERN.matcher(periodValue).matches()) {
                throw exception(CRM_SALES_TARGET_PERIOD_VALUE_INVALID, periodValue, "2026");
            }
            int year = Integer.parseInt(periodValue);
            return new LocalDateTime[]{LocalDateTime.of(year, 1, 1, 0, 0), LocalDateTime.of(year + 1, 1, 1, 0, 0)};
        }
        if (Objects.equals(periodType, CrmSalesTargetPeriodTypeEnum.QUARTER.getType())) {
            Matcher matcher = QUARTER_PATTERN.matcher(periodValue);
            if (!matcher.matches()) {
                throw exception(CRM_SALES_TARGET_PERIOD_VALUE_INVALID, periodValue, "2026-Q1");
            }
            int year = Integer.parseInt(matcher.group(1));
            int startMonth = (Integer.parseInt(matcher.group(2)) - 1) * 3 + 1;
            return new LocalDateTime[]{
                    YearMonth.of(year, startMonth).atDay(1).atStartOfDay(),
                    YearMonth.of(year, startMonth).plusMonths(3).atDay(1).atStartOfDay()};
        }
        if (Objects.equals(periodType, CrmSalesTargetPeriodTypeEnum.MONTH.getType())) {
            if (!MONTH_PATTERN.matcher(periodValue).matches()) {
                throw exception(CRM_SALES_TARGET_PERIOD_VALUE_INVALID, periodValue, "2026-07");
            }
            YearMonth yearMonth = YearMonth.parse(periodValue, MONTH_FORMAT);
            return new LocalDateTime[]{
                    yearMonth.atDay(1).atStartOfDay(),
                    yearMonth.plusMonths(1).atDay(1).atStartOfDay()};
        }
        throw exception(CRM_SALES_TARGET_PERIOD_VALUE_INVALID, periodValue, "å¹´/季度/月");
    }
    /**
     * æ ¹æ®å½“前时间与周期起止,自动判定目标状态
     */
    private Integer calcStatusByPeriod(LocalDateTime startTime, LocalDateTime endTime) {
        LocalDateTime now = LocalDateTime.now();
        if (now.isBefore(startTime)) {
            return CrmSalesTargetStatusEnum.NOT_ACTIVE.getStatus();
        }
        if (now.isAfter(endTime)) {
            return CrmSalesTargetStatusEnum.END.getStatus();
        }
        return CrmSalesTargetStatusEnum.ACTIVE.getStatus();
    }
    /**
     * å°† DO åˆ—表转换为 RespVO åˆ—表,填充对象名称、创建人名称与实际达成数据
     */
    private List<CrmSalesTargetRespVO> convertListToRespVO(List<CrmSalesTargetDO> targetList) {
        if (CollUtil.isEmpty(targetList)) {
            return Collections.emptyList();
        }
        // 1. æ”¶é›†å…³è” ID,批量查询名称
        Set<Long> userIds = new HashSet<>();
        Set<Long> deptIds = new HashSet<>();
        Set<Long> itemIds = new HashSet<>();
        for (CrmSalesTargetDO target : targetList) {
            if (Objects.equals(target.getTargetDimension(), CrmSalesTargetDimensionEnum.USER.getType())) {
                userIds.add(target.getTargetUserId());
            } else if (Objects.equals(target.getTargetDimension(), CrmSalesTargetDimensionEnum.DEPT.getType())) {
                deptIds.add(target.getTargetDeptId());
            } else if (Objects.equals(target.getTargetDimension(), CrmSalesTargetDimensionEnum.ITEM.getType())) {
                itemIds.add(target.getTargetItemId());
            }
            if (StrUtil.isNumeric(target.getCreator())) { // åˆ›å»ºäººï¼ˆç”¨æˆ·ç¼–号字符串)
                userIds.add(Long.valueOf(target.getCreator()));
            }
        }
        Map<Long, AdminUserRespDTO> userMap = CollUtil.isNotEmpty(userIds) ? adminUserApi.getUserMap(userIds) : Collections.emptyMap();
        Map<Long, DeptRespDTO> deptMap = CollUtil.isNotEmpty(deptIds) ? deptApi.getDeptMap(deptIds) : Collections.emptyMap();
        Map<Long, MdmItemRespDTO> itemMap = CollUtil.isNotEmpty(itemIds) ? mdmItemApi.getItemMap(itemIds) : Collections.emptyMap();
        // 2. è½¬æ¢å¹¶å¡«å……
        return convertList(targetList, target -> {
            CrmSalesTargetRespVO respVO = BeanUtils.toBean(target, CrmSalesTargetRespVO.class);
            // ç›®æ ‡å¯¹è±¡åç§°
            if (Objects.equals(target.getTargetDimension(), CrmSalesTargetDimensionEnum.USER.getType())) {
                AdminUserRespDTO user = userMap.get(target.getTargetUserId());
                if (user != null) {
                    respVO.setTargetUserName(user.getNickname());
                }
            } else if (Objects.equals(target.getTargetDimension(), CrmSalesTargetDimensionEnum.DEPT.getType())) {
                DeptRespDTO dept = deptMap.get(target.getTargetDeptId());
                if (dept != null) {
                    respVO.setTargetDeptName(dept.getName());
                }
            } else if (Objects.equals(target.getTargetDimension(), CrmSalesTargetDimensionEnum.ITEM.getType())) {
                MdmItemRespDTO item = itemMap.get(target.getTargetItemId());
                if (item != null) {
                    respVO.setTargetItemName(item.getName());
                }
            }
            // åˆ›å»ºäººåç§°
            if (StrUtil.isNumeric(target.getCreator())) {
                AdminUserRespDTO creator = userMap.get(Long.valueOf(target.getCreator()));
                if (creator != null) {
                    respVO.setCreatorName(creator.getNickname());
                }
            }
            // å®žé™…达成与达成率
            fillAchievement(target, respVO);
            return respVO;
        });
    }
    /**
     * è®¡ç®—实际达成金额/数量与达成率
     */
    private void fillAchievement(CrmSalesTargetDO target, CrmSalesTargetRespVO respVO) {
        if (Objects.equals(target.getTargetDimension(), CrmSalesTargetDimensionEnum.ITEM.getType())) {
            // äº§å“ç»´åº¦ï¼šä»…合同金额与合同数可按物料统计
            BigDecimal contractPrice = achievementMapper.selectContractPriceByItemId(target.getTargetItemId(),
                    target.getStartTime(), target.getEndTime());
            Long contractCount = achievementMapper.selectContractCountByItemId(target.getTargetItemId(),
                    target.getStartTime(), target.getEndTime());
            respVO.setActualContractPrice(contractPrice);
            respVO.setActualContractCount(contractCount);
            respVO.setActualReceivablePrice(BigDecimal.ZERO);
            respVO.setActualBusinessPrice(BigDecimal.ZERO);
            respVO.setActualCustomerCount(0L);
        } else {
            Set<Long> userIds = resolveTargetUserIds(target);
            if (CollUtil.isEmpty(userIds)) { // éƒ¨é—¨ä¸‹æ— ç”¨æˆ·
                respVO.setActualContractPrice(BigDecimal.ZERO);
                respVO.setActualContractCount(0L);
                respVO.setActualReceivablePrice(BigDecimal.ZERO);
                respVO.setActualBusinessPrice(BigDecimal.ZERO);
                respVO.setActualCustomerCount(0L);
            } else {
                respVO.setActualContractPrice(achievementMapper.selectContractPriceByUserIds(userIds,
                        target.getStartTime(), target.getEndTime()));
                respVO.setActualContractCount(achievementMapper.selectContractCountByUserIds(userIds,
                        target.getStartTime(), target.getEndTime()));
                respVO.setActualReceivablePrice(achievementMapper.selectReceivablePriceByUserIds(userIds,
                        target.getStartTime(), target.getEndTime()));
                respVO.setActualBusinessPrice(achievementMapper.selectBusinessPriceByUserIds(userIds,
                        target.getStartTime(), target.getEndTime()));
                respVO.setActualCustomerCount(achievementMapper.selectCustomerCountByUserIds(userIds,
                        target.getStartTime(), target.getEndTime()));
            }
        }
        // è¾¾æˆçއ = å®žé™… / ç›®æ ‡ * 100
        respVO.setContractRate(calcRate(respVO.getActualContractPrice(), target.getContractTarget()));
        respVO.setReceivableRate(calcRate(respVO.getActualReceivablePrice(), target.getReceivableTarget()));
        respVO.setBusinessRate(calcRate(respVO.getActualBusinessPrice(), target.getBusinessTarget()));
        respVO.setCustomerRate(calcRate(respVO.getActualCustomerCount(), target.getCustomerTarget()));
        respVO.setContractCountRate(calcRate(respVO.getActualContractCount(), target.getContractCountTarget()));
    }
    /**
     * è§£æžç›®æ ‡çš„统计用户集合:个人维度为单用户,部门维度为部门及子部门下所有用户
     */
    private Set<Long> resolveTargetUserIds(CrmSalesTargetDO target) {
        if (Objects.equals(target.getTargetDimension(), CrmSalesTargetDimensionEnum.USER.getType())) {
            return target.getTargetUserId() == null ? Collections.emptySet() : Collections.singleton(target.getTargetUserId());
        }
        // éƒ¨é—¨ç»´åº¦ï¼šå«å­éƒ¨é—¨
        List<Long> deptIds = new ArrayList<>();
        deptIds.add(target.getTargetDeptId());
        List<DeptRespDTO> childDepts = deptApi.getChildDeptList(target.getTargetDeptId());
        if (CollUtil.isNotEmpty(childDepts)) {
            deptIds.addAll(convertList(childDepts, DeptRespDTO::getId));
        }
        return convertSet(adminUserApi.getUserListByDeptIds(deptIds), AdminUserRespDTO::getId);
    }
    private BigDecimal calcRate(BigDecimal actual, BigDecimal target) {
        if (target == null || target.compareTo(BigDecimal.ZERO) <= 0) {
            return null;
        }
        return (actual == null ? BigDecimal.ZERO : actual)
                .multiply(HUNDRED).divide(target, 2, RoundingMode.HALF_UP);
    }
    private BigDecimal calcRate(Long actual, Integer target) {
        if (target == null || target <= 0) {
            return null;
        }
        return BigDecimal.valueOf(actual == null ? 0 : actual)
                .multiply(HUNDRED).divide(BigDecimal.valueOf(target), 2, RoundingMode.HALF_UP);
    }
}
yudao-module-crm/src/main/resources/mapper/salesTarget/CrmSalesTargetAchievementMapper.xml
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="cn.iocoder.yudao.module.crm.dal.mysql.salesTarget.CrmSalesTargetAchievementMapper">
    <!-- åˆåŒé‡‘额(个人/部门维度) -->
    <select id="selectContractPriceByUserIds" resultType="java.math.BigDecimal">
        SELECT IFNULL(SUM(total_price), 0) FROM crm_contract
        WHERE deleted = 0
        AND audit_status = ${@cn.iocoder.yudao.module.crm.enums.common.CrmAuditStatusEnum@APPROVE.status}
        AND order_date BETWEEN #{start} AND #{end}
        AND owner_user_id IN
            <foreach collection="userIds" item="userId" open="(" close=")" separator=",">
                #{userId}
            </foreach>
    </select>
    <!-- åˆåŒæ•°é‡ï¼ˆä¸ªäºº/部门维度) -->
    <select id="selectContractCountByUserIds" resultType="java.lang.Long">
        SELECT COUNT(1) FROM crm_contract
        WHERE deleted = 0
        AND audit_status = ${@cn.iocoder.yudao.module.crm.enums.common.CrmAuditStatusEnum@APPROVE.status}
        AND order_date BETWEEN #{start} AND #{end}
        AND owner_user_id IN
            <foreach collection="userIds" item="userId" open="(" close=")" separator=",">
                #{userId}
            </foreach>
    </select>
    <!-- å›žæ¬¾é‡‘额(个人/部门维度) -->
    <select id="selectReceivablePriceByUserIds" resultType="java.math.BigDecimal">
        SELECT IFNULL(SUM(price), 0) FROM crm_receivable
        WHERE deleted = 0
        AND audit_status = ${@cn.iocoder.yudao.module.crm.enums.common.CrmAuditStatusEnum@APPROVE.status}
        AND return_time BETWEEN #{start} AND #{end}
        AND owner_user_id IN
            <foreach collection="userIds" item="userId" open="(" close=")" separator=",">
                #{userId}
            </foreach>
    </select>
    <!-- å•†æœºé‡‘额(个人/部门维度,仅赢单) -->
    <select id="selectBusinessPriceByUserIds" resultType="java.math.BigDecimal">
        SELECT IFNULL(SUM(total_price), 0) FROM crm_business
        WHERE deleted = 0
        AND end_status = ${@cn.iocoder.yudao.module.crm.enums.business.CrmBusinessEndStatusEnum@WIN.status}
        AND deal_time BETWEEN #{start} AND #{end}
        AND owner_user_id IN
            <foreach collection="userIds" item="userId" open="(" close=")" separator=",">
                #{userId}
            </foreach>
    </select>
    <!-- æ–°å¢žå®¢æˆ·æ•°ï¼ˆä¸ªäºº/部门维度) -->
    <select id="selectCustomerCountByUserIds" resultType="java.lang.Long">
        SELECT COUNT(1) FROM crm_customer
        WHERE deleted = 0
        AND create_time BETWEEN #{start} AND #{end}
        AND owner_user_id IN
            <foreach collection="userIds" item="userId" open="(" close=")" separator=",">
                #{userId}
            </foreach>
    </select>
    <!-- åˆåŒé‡‘额(产品维度,按合同产品明细归属物料) -->
    <select id="selectContractPriceByItemId" resultType="java.math.BigDecimal">
        SELECT IFNULL(SUM(cp.total_price), 0) FROM crm_contract c
        INNER JOIN crm_contract_product cp ON cp.contract_id = c.id
        WHERE c.deleted = 0 AND cp.deleted = 0
        AND c.audit_status = ${@cn.iocoder.yudao.module.crm.enums.common.CrmAuditStatusEnum@APPROVE.status}
        AND cp.item_id = #{itemId}
        AND c.order_date BETWEEN #{start} AND #{end}
    </select>
    <!-- åˆåŒæ•°é‡ï¼ˆäº§å“ç»´åº¦ï¼ŒæŒ‰åˆåŒäº§å“æ˜Žç»†å½’属物料) -->
    <select id="selectContractCountByItemId" resultType="java.lang.Long">
        SELECT COUNT(DISTINCT c.id) FROM crm_contract c
        INNER JOIN crm_contract_product cp ON cp.contract_id = c.id
        WHERE c.deleted = 0 AND cp.deleted = 0
        AND c.audit_status = ${@cn.iocoder.yudao.module.crm.enums.common.CrmAuditStatusEnum@APPROVE.status}
        AND cp.item_id = #{itemId}
        AND c.order_date BETWEEN #{start} AND #{end}
    </select>
</mapper>