From c99a927ccaa6ddf27583b619642317b1dd56d215 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 19 Jan 2026 20:58:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E4=B8=80=E7=BA=A7?= =?UTF-8?q?=E9=9C=80=E6=B1=82=E9=83=A8=E9=97=A8=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增一级需求部门类型和品牌关联管理 - 新增一级需求部门任务创建、审核、明细完善流程 - 新增一级需求部门数据分析模块 - 任务卡片显示一级需求人信息 - 部门管理增加品牌关联配置 --- .../controller/AnalyticsController.java | 52 +- .../controller/DepartmentController.java | 41 + .../designhub/controller/TaskController.java | 44 + .../designhub/entity/DeptBrandRelation.java | 71 ++ .../main/java/com/designhub/entity/Task.java | 67 ++ .../mapper/DeptBrandRelationMapper.java | 73 ++ .../designhub/service/AnalyticsService.java | 292 ++++++- .../designhub/service/DepartmentService.java | 148 +++- .../service/TaskApprovalService.java | 201 +++++ .../com/designhub/service/TaskService.java | 229 ++++- .../designhub/vo/BrandTaskDistributionVO.java | 75 ++ .../designhub/vo/PrimaryDeptRankingVO.java | 83 ++ .../designhub/vo/PrimaryDeptStatisticsVO.java | 99 +++ .../vo/PrimaryTaskFlowEfficiencyVO.java | 57 ++ database/V13__add_primary_demand_dept.sql | 55 ++ docs/一级需求部门功能开发文档.md | 819 ++++++++++++++++++ frontend/src/api/analytics.ts | 111 +++ frontend/src/api/department.ts | 47 +- frontend/src/api/task.ts | 37 + frontend/src/views/Analytics.vue | 179 +++- frontend/src/views/Settings.vue | 70 +- frontend/src/views/TaskHall.vue | 21 + 更新日志_2026-01-19.md | 100 +++ 23 files changed, 2921 insertions(+), 50 deletions(-) create mode 100644 backend/src/main/java/com/designhub/entity/DeptBrandRelation.java create mode 100644 backend/src/main/java/com/designhub/mapper/DeptBrandRelationMapper.java create mode 100644 backend/src/main/java/com/designhub/vo/BrandTaskDistributionVO.java create mode 100644 backend/src/main/java/com/designhub/vo/PrimaryDeptRankingVO.java create mode 100644 backend/src/main/java/com/designhub/vo/PrimaryDeptStatisticsVO.java create mode 100644 backend/src/main/java/com/designhub/vo/PrimaryTaskFlowEfficiencyVO.java create mode 100644 database/V13__add_primary_demand_dept.sql create mode 100644 docs/一级需求部门功能开发文档.md create mode 100644 更新日志_2026-01-19.md diff --git a/backend/src/main/java/com/designhub/controller/AnalyticsController.java b/backend/src/main/java/com/designhub/controller/AnalyticsController.java index 480a4bd..b960c47 100644 --- a/backend/src/main/java/com/designhub/controller/AnalyticsController.java +++ b/backend/src/main/java/com/designhub/controller/AnalyticsController.java @@ -3,18 +3,15 @@ package com.designhub.controller; import com.designhub.common.Result; import com.designhub.service.AnalyticsService; import com.designhub.util.JwtUtil; -import com.designhub.vo.DeductionDetailVO; -import com.designhub.vo.DemandStatisticsVO; -import com.designhub.vo.DesignerPerformanceVO; -import com.designhub.vo.DimensionAnalysisVO; -import com.designhub.vo.ProcessEfficiencyVO; -import com.designhub.vo.WarningTaskVO; +import com.designhub.vo.*; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.web.bind.annotation.*; +import java.time.LocalDateTime; import java.util.List; /** @@ -113,5 +110,48 @@ public class AnalyticsController { return Result.success(analyticsService.getDimensionAnalysis(dateRange, month)); } + + // ==================== 一级需求部门统计接口 ==================== + + @Operation(summary = "获取一级需求部门统计数据") + @GetMapping("/primary-dept/statistics") + public Result getPrimaryDeptStatistics( + @RequestParam(required = false) Long deptId, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startTime, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endTime + ) { + log.info("获取一级需求部门统计数据: deptId={}, startTime={}, endTime={}", deptId, startTime, endTime); + return Result.success(analyticsService.getPrimaryDeptStatistics(deptId, startTime, endTime)); + } + + @Operation(summary = "获取一级需求部门排名") + @GetMapping("/primary-dept/ranking") + public Result> getPrimaryDeptRanking( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startTime, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endTime + ) { + log.info("获取一级需求部门排名: startTime={}, endTime={}", startTime, endTime); + return Result.success(analyticsService.getPrimaryDeptRanking(startTime, endTime)); + } + + @Operation(summary = "获取一级需求流转效率分析") + @GetMapping("/primary-task/flow-efficiency") + public Result getPrimaryTaskFlowEfficiency( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startTime, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endTime + ) { + log.info("获取一级需求流转效率: startTime={}, endTime={}", startTime, endTime); + return Result.success(analyticsService.getPrimaryTaskFlowEfficiency(startTime, endTime)); + } + + @Operation(summary = "获取品牌任务分布(一级需求)") + @GetMapping("/primary-task/brand-distribution") + public Result> getPrimaryTaskBrandDistribution( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startTime, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endTime + ) { + log.info("获取品牌任务分布: startTime={}, endTime={}", startTime, endTime); + return Result.success(analyticsService.getPrimaryTaskBrandDistribution(startTime, endTime)); + } } diff --git a/backend/src/main/java/com/designhub/controller/DepartmentController.java b/backend/src/main/java/com/designhub/controller/DepartmentController.java index 3921788..6ea99c0 100644 --- a/backend/src/main/java/com/designhub/controller/DepartmentController.java +++ b/backend/src/main/java/com/designhub/controller/DepartmentController.java @@ -5,7 +5,9 @@ import com.designhub.dto.DepartmentCreateRequest; import com.designhub.dto.DepartmentLimitSettingsRequest; import com.designhub.dto.DepartmentScoreAdjustRequest; import com.designhub.dto.DepartmentUpdateRequest; +import com.designhub.entity.Brand; import com.designhub.entity.Department; +import com.designhub.entity.DeptBrandRelation; import com.designhub.entity.TeamScorePool; import com.designhub.service.DepartmentService; import com.designhub.service.TeamScorePoolService; @@ -109,6 +111,45 @@ public class DepartmentController { public Result getScorePool(@PathVariable Long deptId) { return Result.success(teamScorePoolService.getByDeptId(deptId)); } + + // ==================== 品牌关联管理 ==================== + + @Operation(summary = "设置部门负责的品牌") + @PostMapping("/{deptId}/responsible-brands") + public Result setResponsibleBrands(@PathVariable Long deptId, @RequestBody List brandIds) { + departmentService.setDeptResponsibleBrands(deptId, brandIds); + return Result.success(); + } + + @Operation(summary = "获取部门负责的品牌列表") + @GetMapping("/{deptId}/responsible-brands") + public Result> getResponsibleBrands(@PathVariable Long deptId) { + return Result.success(departmentService.getDeptResponsibleBrands(deptId)); + } + + @Operation(summary = "获取部门负责的品牌ID列表") + @GetMapping("/{deptId}/responsible-brand-ids") + public Result> getResponsibleBrandIds(@PathVariable Long deptId) { + return Result.success(departmentService.getDeptResponsibleBrandIds(deptId)); + } + + @Operation(summary = "获取所有品牌-部门关联关系") + @GetMapping("/brand-relations") + public Result> getAllBrandDeptRelations() { + return Result.success(departmentService.getAllBrandDeptRelations()); + } + + @Operation(summary = "根据品牌ID获取负责的需求部门") + @GetMapping("/by-brand/{brandId}") + public Result getDemandDeptByBrandId(@PathVariable Long brandId) { + return Result.success(departmentService.getDemandDeptByBrandId(brandId)); + } + + @Operation(summary = "获取所有一级需求部门") + @GetMapping("/primary-demand") + public Result> getPrimaryDemandDepartments() { + return Result.success(departmentService.getPrimaryDemandDepartments()); + } } diff --git a/backend/src/main/java/com/designhub/controller/TaskController.java b/backend/src/main/java/com/designhub/controller/TaskController.java index 3a28974..a13215e 100644 --- a/backend/src/main/java/com/designhub/controller/TaskController.java +++ b/backend/src/main/java/com/designhub/controller/TaskController.java @@ -5,6 +5,7 @@ import com.designhub.common.Result; import com.designhub.dto.TaskAssignRequest; import com.designhub.dto.TaskAuditRequest; import com.designhub.entity.Task; +import com.designhub.service.TaskApprovalService; import com.designhub.service.TaskService; import com.designhub.util.JwtUtil; import com.designhub.vo.DashboardStatsVO; @@ -31,6 +32,7 @@ import java.util.stream.Collectors; public class TaskController { private final TaskService taskService; + private final TaskApprovalService taskApprovalService; private final JwtUtil jwtUtil; @Operation(summary = "创建任务", description = "发起新的设计任务") @@ -517,5 +519,47 @@ public class TaskController { return Result.error(e.getMessage()); } } + + // ==================== 一级需求部门任务接口 ==================== + + @Operation(summary = "审核一级需求部门任务", description = "需求部门负责人审核通过并指定完善人") + @PostMapping("/{id}/approve-primary") + public Result approvePrimaryTask( + @PathVariable Long id, + @RequestParam Long completerId, + @RequestParam(required = false) String comment, + @RequestHeader("Authorization") String authHeader) { + log.info("审核一级需求任务: taskId={}, completerId={}", id, completerId); + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long userId = jwtUtil.getUserIdFromToken(token); + taskApprovalService.approvePrimaryTask(id, userId, completerId, comment); + return Result.success(); + } + + @Operation(summary = "驳回一级需求部门任务", description = "需求部门负责人驳回") + @PostMapping("/{id}/reject-primary") + public Result rejectPrimaryTask( + @PathVariable Long id, + @RequestParam String reason, + @RequestHeader("Authorization") String authHeader) { + log.info("驳回一级需求任务: taskId={}, reason={}", id, reason); + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long userId = jwtUtil.getUserIdFromToken(token); + taskApprovalService.rejectPrimaryTask(id, userId, reason); + return Result.success(); + } + + @Operation(summary = "完善任务明细", description = "需求部门成员完善一级需求任务的明细") + @PostMapping("/{id}/complete-detail") + public Result completeTaskDetail( + @PathVariable Long id, + @RequestBody java.util.List taskItems, + @RequestHeader("Authorization") String authHeader) { + log.info("完善任务明细: taskId={}, itemCount={}", id, taskItems != null ? taskItems.size() : 0); + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long userId = jwtUtil.getUserIdFromToken(token); + taskApprovalService.completeTaskDetail(id, userId, taskItems); + return Result.success(); + } } diff --git a/backend/src/main/java/com/designhub/entity/DeptBrandRelation.java b/backend/src/main/java/com/designhub/entity/DeptBrandRelation.java new file mode 100644 index 0000000..e7db1a3 --- /dev/null +++ b/backend/src/main/java/com/designhub/entity/DeptBrandRelation.java @@ -0,0 +1,71 @@ +package com.designhub.entity; + +import com.baomidou.mybatisplus.annotation.*; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 部门品牌负责关系实体类 + * 用于关联需求部门与其负责的品牌 + */ +@TableName("dept_brand_relation") +public class DeptBrandRelation implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 主键ID + */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 部门ID(需求部门) + */ + private Long deptId; + + /** + * 品牌ID + */ + private Long brandId; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + // ==================== 非数据库字段 ==================== + + /** + * 部门名称(查询时返回) + */ + @TableField(exist = false) + private String deptName; + + /** + * 品牌名称(查询时返回) + */ + @TableField(exist = false) + private String brandName; + + // ==================== Getters and Setters ==================== + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public Long getDeptId() { return deptId; } + public void setDeptId(Long deptId) { this.deptId = deptId; } + + public Long getBrandId() { return brandId; } + public void setBrandId(Long brandId) { this.brandId = brandId; } + + public LocalDateTime getCreateTime() { return createTime; } + public void setCreateTime(LocalDateTime createTime) { this.createTime = createTime; } + + public String getDeptName() { return deptName; } + public void setDeptName(String deptName) { this.deptName = deptName; } + + public String getBrandName() { return brandName; } + public void setBrandName(String brandName) { this.brandName = brandName; } +} diff --git a/backend/src/main/java/com/designhub/entity/Task.java b/backend/src/main/java/com/designhub/entity/Task.java index 8fdcf3e..7ea4639 100644 --- a/backend/src/main/java/com/designhub/entity/Task.java +++ b/backend/src/main/java/com/designhub/entity/Task.java @@ -292,6 +292,31 @@ public class Task implements Serializable { */ private java.time.LocalDateTime receivingApprovalTime; + /** + * 一级部门发起人ID + */ + private Long primaryPublisherId; + + /** + * 一级部门ID + */ + private Long primaryPublisherDeptId; + + /** + * 明细完善人ID + */ + private Long detailCompleterId; + + /** + * 是否一级部门任务:0-否,1-是 + */ + private Integer isPrimaryTask; + + /** + * 明细是否已完善:0-否,1-是 + */ + private Integer detailCompleted; + /** * 逻辑删除:0-否 1-是 */ @@ -382,6 +407,24 @@ public class Task implements Serializable { @TableField(exist = false) private String typeDetails; + /** + * 一级部门发起人姓名(查询时返回,不映射到数据库) + */ + @TableField(exist = false) + private String primaryPublisherName; + + /** + * 一级部门名称(查询时返回,不映射到数据库) + */ + @TableField(exist = false) + private String primaryPublisherDeptName; + + /** + * 明细完善人姓名(查询时返回,不映射到数据库) + */ + @TableField(exist = false) + private String detailCompleterName; + // ==================== Getters and Setters (手动添加以解决Lombok编译问题) ==================== public Long getId() { return id; } @@ -549,6 +592,21 @@ public class Task implements Serializable { public LocalDateTime getReceivingApprovalTime() { return receivingApprovalTime; } public void setReceivingApprovalTime(LocalDateTime receivingApprovalTime) { this.receivingApprovalTime = receivingApprovalTime; } + public Long getPrimaryPublisherId() { return primaryPublisherId; } + public void setPrimaryPublisherId(Long primaryPublisherId) { this.primaryPublisherId = primaryPublisherId; } + + public Long getPrimaryPublisherDeptId() { return primaryPublisherDeptId; } + public void setPrimaryPublisherDeptId(Long primaryPublisherDeptId) { this.primaryPublisherDeptId = primaryPublisherDeptId; } + + public Long getDetailCompleterId() { return detailCompleterId; } + public void setDetailCompleterId(Long detailCompleterId) { this.detailCompleterId = detailCompleterId; } + + public Integer getIsPrimaryTask() { return isPrimaryTask; } + public void setIsPrimaryTask(Integer isPrimaryTask) { this.isPrimaryTask = isPrimaryTask; } + + public Integer getDetailCompleted() { return detailCompleted; } + public void setDetailCompleted(Integer detailCompleted) { this.detailCompleted = detailCompleted; } + public Integer getDeleted() { return deleted; } public void setDeleted(Integer deleted) { this.deleted = deleted; } @@ -602,5 +660,14 @@ public class Task implements Serializable { public String getTypeDetails() { return typeDetails; } public void setTypeDetails(String typeDetails) { this.typeDetails = typeDetails; } + + public String getPrimaryPublisherName() { return primaryPublisherName; } + public void setPrimaryPublisherName(String primaryPublisherName) { this.primaryPublisherName = primaryPublisherName; } + + public String getPrimaryPublisherDeptName() { return primaryPublisherDeptName; } + public void setPrimaryPublisherDeptName(String primaryPublisherDeptName) { this.primaryPublisherDeptName = primaryPublisherDeptName; } + + public String getDetailCompleterName() { return detailCompleterName; } + public void setDetailCompleterName(String detailCompleterName) { this.detailCompleterName = detailCompleterName; } } diff --git a/backend/src/main/java/com/designhub/mapper/DeptBrandRelationMapper.java b/backend/src/main/java/com/designhub/mapper/DeptBrandRelationMapper.java new file mode 100644 index 0000000..d1e20d9 --- /dev/null +++ b/backend/src/main/java/com/designhub/mapper/DeptBrandRelationMapper.java @@ -0,0 +1,73 @@ +package com.designhub.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.designhub.entity.DeptBrandRelation; +import org.apache.ibatis.annotations.*; + +import java.util.List; + +/** + * 部门品牌关系Mapper + */ +@Mapper +public interface DeptBrandRelationMapper extends BaseMapper { + + /** + * 根据品牌ID获取负责的部门ID + */ + @Select("SELECT dept_id FROM dept_brand_relation WHERE brand_id = #{brandId}") + Long getDeptIdByBrandId(@Param("brandId") Long brandId); + + /** + * 根据部门ID获取负责的品牌ID列表 + */ + @Select("SELECT brand_id FROM dept_brand_relation WHERE dept_id = #{deptId}") + List getBrandIdsByDeptId(@Param("deptId") Long deptId); + + /** + * 删除部门的所有品牌关联 + */ + @Delete("DELETE FROM dept_brand_relation WHERE dept_id = #{deptId}") + int deleteByDeptId(@Param("deptId") Long deptId); + + /** + * 删除品牌的关联(用于重新分配品牌) + */ + @Delete("DELETE FROM dept_brand_relation WHERE brand_id = #{brandId}") + int deleteByBrandId(@Param("brandId") Long brandId); + + /** + * 批量插入部门品牌关联 + */ + @Insert("") + int batchInsert(@Param("deptId") Long deptId, @Param("brandIds") List brandIds); + + /** + * 获取部门负责的品牌详情列表 + */ + @Select("SELECT r.*, b.brand_name as brand_name " + + "FROM dept_brand_relation r " + + "LEFT JOIN brand b ON r.brand_id = b.id " + + "WHERE r.dept_id = #{deptId}") + List getRelationsWithBrandByDeptId(@Param("deptId") Long deptId); + + /** + * 获取所有品牌-部门关联关系(带部门和品牌名称) + */ + @Select("SELECT r.*, d.dept_name as dept_name, b.brand_name as brand_name " + + "FROM dept_brand_relation r " + + "LEFT JOIN sys_dept d ON r.dept_id = d.id " + + "LEFT JOIN brand b ON r.brand_id = b.id") + List getAllRelationsWithNames(); + + /** + * 检查品牌是否已被分配 + */ + @Select("SELECT COUNT(*) FROM dept_brand_relation WHERE brand_id = #{brandId}") + int countByBrandId(@Param("brandId") Long brandId); +} diff --git a/backend/src/main/java/com/designhub/service/AnalyticsService.java b/backend/src/main/java/com/designhub/service/AnalyticsService.java index 18a6d79..6197a8e 100644 --- a/backend/src/main/java/com/designhub/service/AnalyticsService.java +++ b/backend/src/main/java/com/designhub/service/AnalyticsService.java @@ -3,12 +3,7 @@ package com.designhub.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.designhub.entity.*; import com.designhub.mapper.*; -import com.designhub.vo.DeductionDetailVO; -import com.designhub.vo.DemandStatisticsVO; -import com.designhub.vo.DesignerPerformanceVO; -import com.designhub.vo.DimensionAnalysisVO; -import com.designhub.vo.ProcessEfficiencyVO; -import com.designhub.vo.WarningTaskVO; +import com.designhub.vo.*; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -45,6 +40,7 @@ public class AnalyticsService { private final BrandMapper brandMapper; private final CategoryMapper categoryMapper; private final PlatformMapper platformMapper; + private final DepartmentService departmentService; private final com.designhub.util.WorkHoursCalculator workHoursCalculator; /** @@ -2383,5 +2379,289 @@ public class AnalyticsService { return result; } + + // ==================== 一级需求部门统计 ==================== + + /** + * 获取一级需求部门统计数据 + */ + public PrimaryDeptStatisticsVO getPrimaryDeptStatistics(Long deptId, LocalDateTime startTime, LocalDateTime endTime) { + log.info("获取一级需求部门统计: deptId={}, startTime={}, endTime={}", deptId, startTime, endTime); + + PrimaryDeptStatisticsVO vo = new PrimaryDeptStatisticsVO(); + + // 查询一级需求部门的任务 + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(Task::getIsPrimaryTask, 1) + .ge(Task::getCreateTime, startTime) + .le(Task::getCreateTime, endTime) + .eq(Task::getDeleted, 0); + + if (deptId != null) { + queryWrapper.eq(Task::getPrimaryPublisherDeptId, deptId); + } + + List tasks = taskMapper.selectList(queryWrapper); + + // 统计各项数据 + vo.setTotalTasks(tasks.size()); + + long approvedCount = tasks.stream() + .filter(t -> !"rejected_by_demand".equals(t.getApprovalStatus()) && !"draft".equals(t.getStatus())) + .count(); + vo.setApprovedTasks((int) approvedCount); + + long rejectedCount = tasks.stream() + .filter(t -> "rejected_by_demand".equals(t.getApprovalStatus())) + .count(); + vo.setRejectedTasks((int) rejectedCount); + + long pendingCount = tasks.stream() + .filter(t -> "pending_primary_approval".equals(t.getApprovalStatus()) || + "pending_detail_completion".equals(t.getApprovalStatus())) + .count(); + vo.setPendingTasks((int) pendingCount); + + long inProgressCount = tasks.stream() + .filter(t -> "in_progress".equals(t.getStatus())) + .count(); + vo.setInProgressTasks((int) inProgressCount); + + long completedCount = tasks.stream() + .filter(t -> "completed".equals(t.getStatus())) + .count(); + vo.setCompletedTasks((int) completedCount); + + // 计算消耗积分 + BigDecimal consumedScore = tasks.stream() + .filter(t -> t.getScore() != null && !"draft".equals(t.getStatus()) && !"rejected_by_demand".equals(t.getApprovalStatus())) + .map(Task::getScore) + .reduce(BigDecimal.ZERO, BigDecimal::add); + vo.setConsumedScore(consumedScore); + + // TODO: 计算增长率(需要对比上一周期) + vo.setTaskGrowthRate(BigDecimal.ZERO); + vo.setApprovalGrowthRate(BigDecimal.ZERO); + vo.setRejectGrowthRate(BigDecimal.ZERO); + vo.setScoreGrowthRate(BigDecimal.ZERO); + + return vo; + } + + /** + * 获取一级需求部门排名 + */ + public List getPrimaryDeptRanking(LocalDateTime startTime, LocalDateTime endTime) { + log.info("获取一级需求部门排名: startTime={}, endTime={}", startTime, endTime); + + List result = new java.util.ArrayList<>(); + + // 查询所有一级需求部门的任务 + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(Task::getIsPrimaryTask, 1) + .ge(Task::getCreateTime, startTime) + .le(Task::getCreateTime, endTime) + .eq(Task::getDeleted, 0); + + List allTasks = taskMapper.selectList(queryWrapper); + + // 按一级部门分组 + java.util.Map> tasksByDept = allTasks.stream() + .filter(t -> t.getPrimaryPublisherDeptId() != null) + .collect(java.util.stream.Collectors.groupingBy(Task::getPrimaryPublisherDeptId)); + + for (java.util.Map.Entry> entry : tasksByDept.entrySet()) { + Long deptId = entry.getKey(); + List deptTasks = entry.getValue(); + + PrimaryDeptRankingVO vo = new PrimaryDeptRankingVO(); + vo.setDeptId(deptId); + + // 获取部门名称 + Department dept = departmentMapper.selectById(deptId); + vo.setDeptName(dept != null ? dept.getDeptName() : "未知部门"); + + vo.setTaskCount(deptTasks.size()); + + // 计算审核通过率 + long approvedCount = deptTasks.stream() + .filter(t -> !"rejected_by_demand".equals(t.getApprovalStatus()) && !"draft".equals(t.getStatus())) + .count(); + if (deptTasks.size() > 0) { + vo.setApprovalRate(BigDecimal.valueOf(approvedCount * 100.0 / deptTasks.size()) + .setScale(2, RoundingMode.HALF_UP)); + } else { + vo.setApprovalRate(BigDecimal.ZERO); + } + + // 计算消耗积分 + BigDecimal consumedScore = deptTasks.stream() + .filter(t -> t.getScore() != null) + .map(Task::getScore) + .reduce(BigDecimal.ZERO, BigDecimal::add); + vo.setConsumedScore(consumedScore); + + // 计算平均响应时间(从发起到审核的时间) + double avgResponseHours = deptTasks.stream() + .filter(t -> t.getDemandApprovalTime() != null && t.getCreateTime() != null) + .mapToDouble(t -> { + long minutes = java.time.Duration.between(t.getCreateTime(), t.getDemandApprovalTime()).toMinutes(); + return minutes / 60.0; + }) + .average() + .orElse(0.0); + vo.setAvgResponseHours(Math.round(avgResponseHours * 10) / 10.0); + + // 完成和进行中数量 + vo.setCompletedCount((int) deptTasks.stream().filter(t -> "completed".equals(t.getStatus())).count()); + vo.setInProgressCount((int) deptTasks.stream().filter(t -> "in_progress".equals(t.getStatus())).count()); + + result.add(vo); + } + + // 按任务数量降序排序并设置排名 + result.sort((a, b) -> Integer.compare(b.getTaskCount(), a.getTaskCount())); + for (int i = 0; i < result.size(); i++) { + result.get(i).setRank(i + 1); + } + + return result; + } + + /** + * 获取一级需求流转效率分析 + */ + public PrimaryTaskFlowEfficiencyVO getPrimaryTaskFlowEfficiency(LocalDateTime startTime, LocalDateTime endTime) { + log.info("获取一级需求流转效率: startTime={}, endTime={}", startTime, endTime); + + PrimaryTaskFlowEfficiencyVO vo = new PrimaryTaskFlowEfficiencyVO(); + + // 查询已完成的一级需求任务 + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(Task::getIsPrimaryTask, 1) + .eq(Task::getStatus, "completed") + .ge(Task::getCreateTime, startTime) + .le(Task::getCreateTime, endTime) + .eq(Task::getDeleted, 0); + + List completedTasks = taskMapper.selectList(queryWrapper); + vo.setTaskCount(completedTasks.size()); + + if (completedTasks.isEmpty()) { + vo.setAvgSubmitToReviewHours(0.0); + vo.setAvgReviewToCompleteHours(0.0); + vo.setAvgCompleteToPublishHours(0.0); + vo.setAvgPublishToFinishHours(0.0); + vo.setAvgTotalHours(0.0); + return vo; + } + + // 计算各阶段平均耗时 + double avgSubmitToReview = completedTasks.stream() + .filter(t -> t.getDemandApprovalTime() != null && t.getCreateTime() != null) + .mapToDouble(t -> java.time.Duration.between(t.getCreateTime(), t.getDemandApprovalTime()).toMinutes() / 60.0) + .average() + .orElse(0.0); + vo.setAvgSubmitToReviewHours(Math.round(avgSubmitToReview * 10) / 10.0); + + // 审核到完善(这里简化处理,实际需要记录明细完善时间) + vo.setAvgReviewToCompleteHours(0.0); // TODO: 需要增加明细完善时间字段 + + // 完善到发布(接单部门审核时间) + double avgCompleteToPublish = completedTasks.stream() + .filter(t -> t.getReceivingApprovalTime() != null && t.getDemandApprovalTime() != null) + .mapToDouble(t -> java.time.Duration.between(t.getDemandApprovalTime(), t.getReceivingApprovalTime()).toMinutes() / 60.0) + .average() + .orElse(0.0); + vo.setAvgCompleteToPublishHours(Math.round(avgCompleteToPublish * 10) / 10.0); + + // 发布到完成 + double avgPublishToFinish = completedTasks.stream() + .filter(t -> t.getCompleteTime() != null && t.getReceivingApprovalTime() != null) + .mapToDouble(t -> java.time.Duration.between(t.getReceivingApprovalTime(), t.getCompleteTime()).toMinutes() / 60.0) + .average() + .orElse(0.0); + vo.setAvgPublishToFinishHours(Math.round(avgPublishToFinish * 10) / 10.0); + + // 总耗时 + double avgTotal = completedTasks.stream() + .filter(t -> t.getCompleteTime() != null && t.getCreateTime() != null) + .mapToDouble(t -> java.time.Duration.between(t.getCreateTime(), t.getCompleteTime()).toMinutes() / 60.0) + .average() + .orElse(0.0); + vo.setAvgTotalHours(Math.round(avgTotal * 10) / 10.0); + + return vo; + } + + /** + * 获取品牌任务分布(一级需求) + */ + public List getPrimaryTaskBrandDistribution(LocalDateTime startTime, LocalDateTime endTime) { + log.info("获取品牌任务分布: startTime={}, endTime={}", startTime, endTime); + + List result = new java.util.ArrayList<>(); + + // 查询一级需求部门的任务 + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(Task::getIsPrimaryTask, 1) + .ge(Task::getCreateTime, startTime) + .le(Task::getCreateTime, endTime) + .eq(Task::getDeleted, 0); + + List allTasks = taskMapper.selectList(queryWrapper); + int totalTaskCount = allTasks.size(); + + // 按品牌分组 + java.util.Map> tasksByBrand = allTasks.stream() + .filter(t -> t.getBrandId() != null) + .collect(java.util.stream.Collectors.groupingBy(Task::getBrandId)); + + for (java.util.Map.Entry> entry : tasksByBrand.entrySet()) { + Long brandId = entry.getKey(); + List brandTasks = entry.getValue(); + + BrandTaskDistributionVO vo = new BrandTaskDistributionVO(); + vo.setBrandId(brandId); + + // 获取品牌名称 + Brand brand = brandMapper.selectById(brandId); + vo.setBrandName(brand != null ? brand.getName() : "未知品牌"); + + vo.setTaskCount(brandTasks.size()); + + // 计算占比 + if (totalTaskCount > 0) { + vo.setPercentage(BigDecimal.valueOf(brandTasks.size() * 100.0 / totalTaskCount) + .setScale(2, RoundingMode.HALF_UP)); + } else { + vo.setPercentage(BigDecimal.ZERO); + } + + // 获取负责部门 + Department responsibleDept = departmentService.getDemandDeptByBrandId(brandId); + if (responsibleDept != null) { + vo.setResponsibleDeptId(responsibleDept.getId()); + vo.setResponsibleDeptName(responsibleDept.getDeptName()); + } + + // 计算消耗积分 + BigDecimal consumedScore = brandTasks.stream() + .filter(t -> t.getScore() != null) + .map(Task::getScore) + .reduce(BigDecimal.ZERO, BigDecimal::add); + vo.setConsumedScore(consumedScore); + + // 完成数量 + vo.setCompletedCount((int) brandTasks.stream().filter(t -> "completed".equals(t.getStatus())).count()); + + result.add(vo); + } + + // 按任务数量降序排序 + result.sort((a, b) -> Integer.compare(b.getTaskCount(), a.getTaskCount())); + + return result; + } } diff --git a/backend/src/main/java/com/designhub/service/DepartmentService.java b/backend/src/main/java/com/designhub/service/DepartmentService.java index a8d5438..743af82 100644 --- a/backend/src/main/java/com/designhub/service/DepartmentService.java +++ b/backend/src/main/java/com/designhub/service/DepartmentService.java @@ -5,13 +5,18 @@ import com.designhub.dto.DepartmentCreateRequest; import com.designhub.dto.DepartmentLimitSettingsRequest; import com.designhub.dto.DepartmentScoreAdjustRequest; import com.designhub.dto.DepartmentUpdateRequest; +import com.designhub.entity.Brand; import com.designhub.entity.Department; +import com.designhub.entity.DeptBrandRelation; import com.designhub.entity.User; import com.designhub.exception.BusinessException; +import com.designhub.mapper.BrandMapper; import com.designhub.mapper.DepartmentMapper; +import com.designhub.mapper.DeptBrandRelationMapper; import com.designhub.mapper.UserMapper; import com.designhub.vo.DepartmentOverviewVO; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.CollectionUtils; @@ -28,6 +33,7 @@ import java.util.stream.Collectors; /** * 部门管理Service */ +@Slf4j @Service @RequiredArgsConstructor public class DepartmentService { @@ -35,6 +41,8 @@ public class DepartmentService { private final DepartmentMapper departmentMapper; private final TeamScorePoolService teamScorePoolService; private final UserMapper userMapper; + private final DeptBrandRelationMapper deptBrandRelationMapper; + private final BrandMapper brandMapper; /** * 获取所有部门列表 @@ -126,9 +134,9 @@ public class DepartmentService { departmentMapper.insert(department); - // 初始化积分池 + // 初始化积分池(需求部门和一级需求部门都需要积分池) teamScorePoolService.upsertPool(department.getId(), department.getDeptName()); - if ("demand".equals(request.getDeptType())) { + if ("demand".equals(request.getDeptType()) || "primary_demand".equals(request.getDeptType())) { if (request.getInitialScoreBalance() != null) { teamScorePoolService.setBalance(department.getId(), request.getInitialScoreBalance()); department.setScoreBalance(request.getInitialScoreBalance()); @@ -227,9 +235,9 @@ public class DepartmentService { departmentMapper.updateById(existDept); - // 同步积分池 + // 同步积分池(需求部门和一级需求部门都需要积分池) teamScorePoolService.upsertPool(existDept.getId(), existDept.getDeptName()); - if ("demand".equals(existDept.getDeptType()) && + if (("demand".equals(existDept.getDeptType()) || "primary_demand".equals(existDept.getDeptType())) && (request.getLimitEnabled() != null || request.getLimitPeriod() != null || request.getPeriodLimit() != null || request.getAlertThreshold() != null)) { teamScorePoolService.updateLimitConfig( @@ -253,8 +261,8 @@ public class DepartmentService { if (department == null) { throw new BusinessException("部门不存在"); } - if (!"demand".equals(department.getDeptType())) { - throw new BusinessException("只有需求部门需要配置积分限制"); + if (!"demand".equals(department.getDeptType()) && !"primary_demand".equals(department.getDeptType())) { + throw new BusinessException("只有需求部门或一级需求部门需要配置积分限制"); } department.setLimitEnabled(request.getLimitEnabled()); @@ -301,8 +309,8 @@ public class DepartmentService { } if ("head".equals(type)) { - if (!"demand".equals(department.getDeptType())) { - throw new BusinessException("只有需求部门可以设置负责人"); + if (!"demand".equals(department.getDeptType()) && !"primary_demand".equals(department.getDeptType())) { + throw new BusinessException("只有需求部门或一级需求部门可以设置负责人"); } department.setLeaderId(userId); } else if ("supervisor".equals(type)) { @@ -326,8 +334,8 @@ public class DepartmentService { if (department == null) { throw new BusinessException("部门不存在"); } - if (!"demand".equals(department.getDeptType())) { - throw new BusinessException("只有需求部门可以调整积分"); + if (!"demand".equals(department.getDeptType()) && !"primary_demand".equals(department.getDeptType())) { + throw new BusinessException("只有需求部门或一级需求部门可以调整积分"); } if (request.getAmount() == null || request.getAmount() <= 0) { throw new BusinessException("调整积分必须大于0"); @@ -342,10 +350,126 @@ public class DepartmentService { } private void validateDeptType(String deptType) { - if (!"demand".equals(deptType) && !"receiving".equals(deptType)) { - throw new BusinessException("部门类型必须是 demand 或 receiving"); + if (!"demand".equals(deptType) && !"receiving".equals(deptType) && !"primary_demand".equals(deptType)) { + throw new BusinessException("部门类型必须是 demand、receiving 或 primary_demand"); } } + + // ==================== 品牌关联管理 ==================== + + /** + * 根据品牌ID获取负责的需求部门 + */ + public Department getDemandDeptByBrandId(Long brandId) { + if (brandId == null) { + return null; + } + Long deptId = deptBrandRelationMapper.getDeptIdByBrandId(brandId); + if (deptId == null) { + return null; + } + return departmentMapper.selectById(deptId); + } + + /** + * 设置部门负责的品牌 + */ + @Transactional(rollbackFor = Exception.class) + public void setDeptResponsibleBrands(Long deptId, List brandIds) { + Department department = getDepartmentById(deptId); + if (department == null) { + throw new BusinessException("部门不存在"); + } + if (!"demand".equals(department.getDeptType())) { + throw new BusinessException("只有需求部门可以设置负责品牌"); + } + + // 检查品牌是否已被其他部门负责 + if (!CollectionUtils.isEmpty(brandIds)) { + for (Long brandId : brandIds) { + Long existingDeptId = deptBrandRelationMapper.getDeptIdByBrandId(brandId); + if (existingDeptId != null && !existingDeptId.equals(deptId)) { + Brand brand = brandMapper.selectById(brandId); + Department existingDept = departmentMapper.selectById(existingDeptId); + throw new BusinessException("品牌 [" + (brand != null ? brand.getName() : brandId) + + "] 已被部门 [" + (existingDept != null ? existingDept.getDeptName() : existingDeptId) + "] 负责"); + } + } + } + + // 删除原有关联 + deptBrandRelationMapper.deleteByDeptId(deptId); + + // 批量插入新关联 + if (!CollectionUtils.isEmpty(brandIds)) { + deptBrandRelationMapper.batchInsert(deptId, brandIds); + } + + log.info("设置部门 {} 负责品牌: {}", deptId, brandIds); + } + + /** + * 获取部门负责的品牌列表 + */ + public List getDeptResponsibleBrands(Long deptId) { + List brandIds = deptBrandRelationMapper.getBrandIdsByDeptId(deptId); + if (CollectionUtils.isEmpty(brandIds)) { + return Collections.emptyList(); + } + return brandMapper.selectBatchIds(brandIds); + } + + /** + * 获取部门负责的品牌ID列表 + */ + public List getDeptResponsibleBrandIds(Long deptId) { + return deptBrandRelationMapper.getBrandIdsByDeptId(deptId); + } + + /** + * 获取所有品牌-部门关联关系 + */ + public List getAllBrandDeptRelations() { + return deptBrandRelationMapper.getAllRelationsWithNames(); + } + + /** + * 检查部门是否为一级需求部门 + */ + public boolean isPrimaryDemandDept(Long deptId) { + if (deptId == null) { + return false; + } + Department dept = getDepartmentById(deptId); + return dept != null && "primary_demand".equals(dept.getDeptType()); + } + + /** + * 检查部门是否可以发起任务(需求部门或一级需求部门) + */ + public boolean canPublishTask(Long deptId) { + if (deptId == null) { + return false; + } + Department dept = getDepartmentById(deptId); + if (dept == null) { + return false; + } + String deptType = dept.getDeptType(); + return "demand".equals(deptType) || "primary_demand".equals(deptType); + } + + /** + * 获取所有一级需求部门 + */ + public List getPrimaryDemandDepartments() { + return departmentMapper.selectList( + new LambdaQueryWrapper() + .eq(Department::getDeptType, "primary_demand") + .eq(Department::getStatus, 1) + .orderByAsc(Department::getSortOrder) + ); + } private Integer defaultLimitEnabled(Integer limitEnabled) { return limitEnabled != null ? limitEnabled : 0; diff --git a/backend/src/main/java/com/designhub/service/TaskApprovalService.java b/backend/src/main/java/com/designhub/service/TaskApprovalService.java index f28f829..9454e86 100644 --- a/backend/src/main/java/com/designhub/service/TaskApprovalService.java +++ b/backend/src/main/java/com/designhub/service/TaskApprovalService.java @@ -46,6 +46,207 @@ public class TaskApprovalService { private final DingTalkService dingTalkService; private final com.designhub.util.WorkHoursCalculator workHoursCalculator; + // ==================== 一级需求部门任务审批 ==================== + + /** + * 审核一级需求部门任务(需求部门负责人审核) + * @param taskId 任务ID + * @param approverId 审核人ID(需求部门负责人) + * @param completerId 指定的明细完善人ID + * @param comment 审核备注 + */ + @Transactional(rollbackFor = Exception.class) + public void approvePrimaryTask(Long taskId, Long approverId, Long completerId, String comment) { + // 1. 验证任务和权限 + Task task = getTaskForApproval(taskId, "pending_primary_approval"); + validateApprover(task, approverId, "demand_leader"); + + // 2. 验证完善人 + if (completerId == null) { + throw new BusinessException(ResultCode.PARAM_ERROR, "请指定明细完善人"); + } + User completer = userMapper.selectById(completerId); + if (completer == null) { + throw new BusinessException(ResultCode.PARAM_ERROR, "指定的完善人不存在"); + } + + // 3. 更新任务状态为待完善明细 + task.setApprovalStatus("pending_detail_completion"); + task.setDetailCompleterId(completerId); + task.setDemandApprovalTime(LocalDateTime.now()); + task.setCurrentApproverId(completerId); + task.setCurrentApproverRole("detail_completer"); + taskMapper.updateById(task); + + // 4. 记录审核日志 + logApproval(taskId, approverId, 1, "approve_primary", comment); + + // 5. 发送通知给完善人 + notificationService.sendNotification( + completerId, + "新任务待完善明细", + String.format("任务【%s】需要您完善任务明细", task.getTitle()), + "task_detail_completion", + taskId + ); + + // 6. 发送通知给一级部门发起人 + if (task.getPrimaryPublisherId() != null) { + notificationService.sendNotification( + task.getPrimaryPublisherId(), + "任务审核通过", + String.format("您发起的任务【%s】已通过审核,正在等待明细完善", task.getTitle()), + "task_approved", + taskId + ); + } + + // 7. 发送钉钉通知给完善人 + try { + if (completer.getDingUserId() != null) { + String title = "新任务待完善明细"; + StringBuilder content = new StringBuilder(); + content.append("任务名称:").append(task.getTitle()).append("\n"); + content.append("任务编号:").append(task.getTaskNo()).append("\n"); + content.append("请尽快完善任务明细"); + dingTalkService.sendWorkNotification( + java.util.Collections.singletonList(completer.getDingUserId()), + title, + content.toString() + ); + } + } catch (Exception e) { + log.error("发送钉钉通知失败", e); + } + + log.info("一级需求任务审核通过,任务ID:{},审核人:{},完善人:{}", taskId, approverId, completerId); + } + + /** + * 驳回一级需求部门任务 + */ + @Transactional(rollbackFor = Exception.class) + public void rejectPrimaryTask(Long taskId, Long approverId, String reason) { + Task task = getTaskForApproval(taskId, "pending_primary_approval"); + validateApprover(task, approverId, "demand_leader"); + + task.setApprovalStatus("rejected_by_demand"); + task.setStatus("draft"); + task.setRejectReason(reason); + task.setCurrentApproverId(null); + task.setCurrentApproverRole(null); + taskMapper.updateById(task); + + logApproval(taskId, approverId, 1, "reject_primary", reason); + + // 通知一级部门发起人 + if (task.getPrimaryPublisherId() != null) { + notificationService.sendNotification( + task.getPrimaryPublisherId(), + "任务已被驳回", + String.format("您发起的任务【%s】被驳回,原因:%s", task.getTitle(), reason), + "task_reject", + taskId + ); + + // 发送钉钉通知 + try { + User publisher = userMapper.selectById(task.getPrimaryPublisherId()); + if (publisher != null && publisher.getDingUserId() != null) { + String title = "任务已被驳回"; + String content = String.format("任务【%s】被驳回,原因:%s", task.getTitle(), reason); + dingTalkService.sendWorkNotification( + java.util.Collections.singletonList(publisher.getDingUserId()), + title, + content + ); + } + } catch (Exception e) { + log.error("发送钉钉通知失败", e); + } + } + + log.info("一级需求任务被驳回,任务ID:{},审核人:{},原因:{}", taskId, approverId, reason); + } + + /** + * 完善任务明细并提交 + * @param taskId 任务ID + * @param completerId 完善人ID + * @param taskItems 任务明细(子任务列表) + */ + @Transactional(rollbackFor = Exception.class) + public void completeTaskDetail(Long taskId, Long completerId, List taskItems) { + Task task = getTaskForApproval(taskId, "pending_detail_completion"); + + // 验证完善人权限 + if (!completerId.equals(task.getDetailCompleterId())) { + throw new BusinessException(ResultCode.FORBIDDEN, "您不是该任务的指定完善人"); + } + + // 验证任务明细 + if (taskItems == null || taskItems.isEmpty()) { + throw new BusinessException(ResultCode.PARAM_ERROR, "请填写任务明细"); + } + + // 设置任务为流水线任务 + task.setIsPipeline(1); + task.setPipelineMode("sequential"); // 默认顺序模式 + + // 计算总积分和工时(一级任务的积分已经由一级部门设置,这里不重新计算) + // 但需要验证明细的积分总和不超过预估积分 + + // 标记明细已完善 + task.setDetailCompleted(1); + + // 更新发布人为完善人(这样后续审核流程中发布人是需求部门的人) + task.setPublisherId(completerId); + User completer = userMapper.selectById(completerId); + if (completer != null && completer.getDeptId() != null) { + task.setPublisherDeptId(completer.getDeptId()); + } + + // 进入原有需求部门审核流程 + task.setApprovalStatus("pending_demand_approval"); + Department demandDept = departmentMapper.selectById(task.getPublisherDeptId()); + if (demandDept != null && demandDept.getLeaderId() != null) { + task.setCurrentApproverId(demandDept.getLeaderId()); + task.setCurrentApproverRole("demand_leader"); + } + + taskMapper.updateById(task); + + // 创建子任务 + taskService.createTaskItemsFromDTO(task.getId(), taskItems); + + // 记录日志 + logApproval(taskId, completerId, 0, "complete_detail", "任务明细已完善"); + + // 通知一级部门发起人 + if (task.getPrimaryPublisherId() != null) { + notificationService.sendNotification( + task.getPrimaryPublisherId(), + "任务明细已完善", + String.format("任务【%s】明细已完善,进入正常审核流程", task.getTitle()), + "task_detail_completed", + taskId + ); + } + + // 通知需求部门负责人 + if (demandDept != null && demandDept.getLeaderId() != null) { + notificationService.sendNotification( + demandDept.getLeaderId(), + "新任务待审核", + String.format("任务【%s】需要您审核", task.getTitle()), + "task_approval", + taskId + ); + } + + log.info("一级需求任务明细已完善,任务ID:{},完善人:{}", taskId, completerId); + } + /** * 需求部门负责人审核通过 */ diff --git a/backend/src/main/java/com/designhub/service/TaskService.java b/backend/src/main/java/com/designhub/service/TaskService.java index 697b724..1ae9f7d 100644 --- a/backend/src/main/java/com/designhub/service/TaskService.java +++ b/backend/src/main/java/com/designhub/service/TaskService.java @@ -72,7 +72,8 @@ public class TaskService { public Long createTask(Task task) { log.info("创建任务: {}", task.getTitle()); - // 验证用户权限:只有需求部门可以发起任务(管理员除外) + // 验证用户权限:只有需求部门或一级需求部门可以发起任务(管理员除外) + boolean isPrimaryTask = false; if (task.getPublisherId() != null) { var user = userService.getUserById(task.getPublisherId()); if (user == null) { @@ -92,9 +93,13 @@ public class TaskService { throw new BusinessException(ResultCode.FORBIDDEN, "用户部门不存在"); } - if (!"demand".equals(department.getDeptType())) { - throw new BusinessException(ResultCode.FORBIDDEN, "只有需求部门可以发起任务,您所在的部门是接单部门"); + String deptType = department.getDeptType(); + if (!"demand".equals(deptType) && !"primary_demand".equals(deptType)) { + throw new BusinessException(ResultCode.FORBIDDEN, "只有需求部门或一级需求部门可以发起任务,您所在的部门是接单部门"); } + + // 判断是否为一级需求部门发起的任务 + isPrimaryTask = "primary_demand".equals(deptType); } // 设置发布人部门ID @@ -102,6 +107,18 @@ public class TaskService { task.setPublisherDeptId(user.getDeptId()); } } + + // 如果是一级需求部门发起的任务,设置相关字段 + if (isPrimaryTask) { + task.setIsPrimaryTask(1); + task.setPrimaryPublisherId(task.getPublisherId()); + task.setPrimaryPublisherDeptId(task.getPublisherDeptId()); + task.setDetailCompleted(0); // 明细未完善 + log.info("一级需求部门发起任务: publisherId={}, deptId={}", task.getPublisherId(), task.getPublisherDeptId()); + } else { + task.setIsPrimaryTask(0); + task.setDetailCompleted(1); // 普通任务明细已完善 + } // 验证任务类型并计算积分(支持多类型) if (task.getQuantity() != null) { @@ -228,6 +245,7 @@ public class TaskService { // 检查是否是管理员 boolean isAdmin = isAdminUser(user.getId()); + boolean isPrimaryTask = false; if (!isAdmin) { if (user.getDeptId() == null) { @@ -239,9 +257,13 @@ public class TaskService { throw new BusinessException(ResultCode.FORBIDDEN, "用户部门不存在"); } - if (!"demand".equals(department.getDeptType())) { - throw new BusinessException(ResultCode.FORBIDDEN, "只有需求部门可以发起任务"); + String deptType = department.getDeptType(); + if (!"demand".equals(deptType) && !"primary_demand".equals(deptType)) { + throw new BusinessException(ResultCode.FORBIDDEN, "只有需求部门或一级需求部门可以发起任务"); } + + // 判断是否为一级需求部门发起的任务 + isPrimaryTask = "primary_demand".equals(deptType); } // 验证流水线模式 @@ -342,6 +364,19 @@ public class TaskService { task.setProgress(0); task.setIsUrgent(0); task.setIsDelayed(0); + + // 设置一级需求部门相关字段 + if (isPrimaryTask) { + task.setIsPrimaryTask(1); + task.setPrimaryPublisherId(publisherId); + task.setPrimaryPublisherDeptId(user.getDeptId()); + task.setDetailCompleted(0); // 明细未完善 + log.info("一级需求部门发起流水线任务: publisherId={}, deptId={}", publisherId, user.getDeptId()); + } else { + task.setIsPrimaryTask(0); + task.setDetailCompleted(1); // 普通任务明细已完善 + } + // 设置发布类型(从前端传递,默认为recruitment) task.setReleaseType(dto.getReleaseType() != null ? dto.getReleaseType() : "recruitment"); // 如果是assigned模式,设置接收人 @@ -3295,22 +3330,15 @@ public class TaskService { */ private void initApprovalStatus(Task task) { try { - // 设置审核状态为待需求部门审核 - task.setApprovalStatus("pending_demand_approval"); + // 判断是否为一级需求部门发起的任务 + boolean isPrimaryTask = task.getIsPrimaryTask() != null && task.getIsPrimaryTask() == 1; - // 确定需求部门负责人 - if (task.getPublisherDeptId() != null) { - Department demandDept = departmentService.getDepartmentById(task.getPublisherDeptId()); - if (demandDept != null && demandDept.getLeaderId() != null) { - task.setCurrentApproverId(demandDept.getLeaderId()); - task.setCurrentApproverRole("demand_leader"); - log.info("任务已设置待审核状态,等待需求部门负责人审核:leaderId={}", demandDept.getLeaderId()); - } else { - log.warn("需求部门未配置负责人,任务将直接发布:deptId={}", task.getPublisherDeptId()); - // 如果没有配置负责人,跳过审核直接发布 - task.setApprovalStatus("approved"); - task.setStatus("pending"); - } + if (isPrimaryTask) { + // 一级需求部门任务:根据品牌路由到对应需求部门审核 + initPrimaryTaskApprovalStatus(task); + } else { + // 普通需求部门任务:发送给本部门负责人审核 + initNormalTaskApprovalStatus(task); } } catch (Exception e) { log.error("初始化审核状态失败", e); @@ -3319,6 +3347,62 @@ public class TaskService { task.setStatus("pending"); } } + + /** + * 初始化普通任务审核状态 + */ + private void initNormalTaskApprovalStatus(Task task) { + // 设置审核状态为待需求部门审核 + task.setApprovalStatus("pending_demand_approval"); + + // 确定需求部门负责人 + if (task.getPublisherDeptId() != null) { + Department demandDept = departmentService.getDepartmentById(task.getPublisherDeptId()); + if (demandDept != null && demandDept.getLeaderId() != null) { + task.setCurrentApproverId(demandDept.getLeaderId()); + task.setCurrentApproverRole("demand_leader"); + log.info("任务已设置待审核状态,等待需求部门负责人审核:leaderId={}", demandDept.getLeaderId()); + } else { + log.warn("需求部门未配置负责人,任务将直接发布:deptId={}", task.getPublisherDeptId()); + // 如果没有配置负责人,跳过审核直接发布 + task.setApprovalStatus("approved"); + task.setStatus("pending"); + } + } + } + + /** + * 初始化一级需求部门任务审核状态 + */ + private void initPrimaryTaskApprovalStatus(Task task) { + // 根据品牌找到对应的需求部门 + Long brandId = task.getBrandId(); + if (brandId == null) { + log.warn("一级需求任务未选择品牌,无法路由到需求部门"); + throw new BusinessException(ResultCode.PARAM_ERROR, "一级需求部门发起任务必须选择品牌"); + } + + Department demandDept = departmentService.getDemandDeptByBrandId(brandId); + if (demandDept == null) { + Brand brand = brandMapper.selectById(brandId); + String brandName = brand != null ? brand.getName() : String.valueOf(brandId); + log.warn("品牌 {} 未配置负责的需求部门", brandName); + throw new BusinessException(ResultCode.PARAM_ERROR, "品牌 [" + brandName + "] 未配置负责的需求部门,请联系管理员"); + } + + if (demandDept.getLeaderId() == null) { + log.warn("需求部门 {} 未配置负责人", demandDept.getDeptName()); + throw new BusinessException(ResultCode.PARAM_ERROR, "需求部门 [" + demandDept.getDeptName() + "] 未配置负责人,请联系管理员"); + } + + // 设置审核状态为待一级审核(由需求部门负责人审核) + task.setApprovalStatus("pending_primary_approval"); + task.setCurrentApproverId(demandDept.getLeaderId()); + task.setCurrentApproverRole("demand_leader"); + + log.info("一级需求任务已设置待审核状态,品牌={}, 路由到需求部门={}, 负责人={}", + brandId, demandDept.getDeptName(), demandDept.getLeaderId()); + } /** * 发送审核通知(两级审核流程)- 包含丰富的任务详情 @@ -3708,5 +3792,110 @@ public class TaskService { public User getUserById(Long userId) { return userService.getUserById(userId); } + + /** + * 从DTO创建任务子项(用于一级需求部门任务明细完善) + */ + @Transactional(rollbackFor = Exception.class) + public void createTaskItemsFromDTO(Long taskId, List taskItems) { + Task task = taskMapper.selectById(taskId); + if (task == null) { + throw new BusinessException(ResultCode.NOT_FOUND, "任务不存在"); + } + + log.info("为任务 {} 创建子任务,共 {} 个", taskId, taskItems.size()); + + int itemIndex = 1; + BigDecimal totalScore = BigDecimal.ZERO; + BigDecimal totalHours = BigDecimal.ZERO; + int totalQuantity = 0; + + for (com.designhub.dto.PipelineTaskDTO.TaskItemDTO itemDto : taskItems) { + // 获取任务类型信息 + var demandType = configService.getDemandTypeById(itemDto.getTaskTypeId()); + if (demandType == null) { + throw new BusinessException(ResultCode.PARAM_ERROR, "任务类型不存在: " + itemDto.getTaskTypeId()); + } + + // 计算积分和工时 + BigDecimal itemScore = demandType.getUnitScore().multiply(BigDecimal.valueOf(itemDto.getQuantity())); + BigDecimal itemHours = demandType.getEstimatedHours() != null + ? demandType.getEstimatedHours().multiply(BigDecimal.valueOf(itemDto.getQuantity())) + : BigDecimal.ZERO; + + totalScore = totalScore.add(itemScore); + totalHours = totalHours.add(itemHours); + totalQuantity += itemDto.getQuantity(); + + // 创建子任务 + TaskItem item = new TaskItem(); + item.setTaskId(taskId); + item.setItemNo(task.getTaskNo() + "-" + String.format("%02d", itemIndex)); + item.setTaskTypeId(itemDto.getTaskTypeId()); + item.setTaskTypeName(demandType.getName()); + item.setQuantity(itemDto.getQuantity()); + item.setUnitScore(demandType.getUnitScore()); + item.setTotalScore(itemScore.setScale(2, java.math.RoundingMode.HALF_UP)); + item.setEstimatedHours(itemHours); + item.setSortOrder(itemIndex); + item.setProgress(0); + item.setStatus("pending"); + item.setIsLocked(itemIndex == 1 ? 0 : 1); // 第一个子任务解锁,其他锁定 + + // 设置接单部门(如果任务类型有配置) + if (demandType.getAllowedDeptIds() != null && !demandType.getAllowedDeptIds().isEmpty()) { + String[] deptIds = demandType.getAllowedDeptIds().split(","); + if (deptIds.length == 1) { + item.setReceiverDeptId(Long.parseLong(deptIds[0].trim())); + } + } + + // 如果指定了设计师 + if (itemDto.getReceiverId() != null) { + item.setReceiverId(itemDto.getReceiverId()); + User receiver = userService.getUserById(itemDto.getReceiverId()); + if (receiver != null) { + item.setReceiverName(receiver.getRealName()); + if (item.getReceiverDeptId() == null && receiver.getDeptId() != null) { + item.setReceiverDeptId(receiver.getDeptId()); + } + } + } + + // 构建任务类型明细 JSON + java.util.List> typeDetailsList = new java.util.ArrayList<>(); + java.util.Map detail = new java.util.LinkedHashMap<>(); + detail.put("typeId", itemDto.getTaskTypeId()); + detail.put("typeName", demandType.getName()); + detail.put("quantity", itemDto.getQuantity()); + detail.put("unitScore", demandType.getUnitScore()); + detail.put("totalScore", itemScore.setScale(2, java.math.RoundingMode.HALF_UP).doubleValue()); + typeDetailsList.add(detail); + + try { + com.fasterxml.jackson.databind.ObjectMapper objectMapper = new com.fasterxml.jackson.databind.ObjectMapper(); + item.setTypeDetails(objectMapper.writeValueAsString(typeDetailsList)); + } catch (Exception e) { + log.warn("序列化任务类型明细失败: {}", e.getMessage()); + } + + taskItemMapper.insert(item); + log.info("创建子任务: itemNo={}, typeId={}, score={}", item.getItemNo(), itemDto.getTaskTypeId(), itemScore); + + itemIndex++; + } + + // 更新主任务的汇总信息 + task.setQuantity(totalQuantity); + task.setEstimatedHours(totalHours); + // 注意:一级任务的积分由一级部门设定,这里不覆盖 + // 但如果明细积分超过预估积分,需要警告 + if (task.getScore() != null && totalScore.compareTo(task.getScore()) > 0) { + log.warn("任务明细积分总和 {} 超过预估积分 {},请注意", totalScore, task.getScore()); + } + + taskMapper.updateById(task); + log.info("任务 {} 子任务创建完成,共 {} 个子任务,总积分 {}", taskId, taskItems.size(), totalScore); + } } diff --git a/backend/src/main/java/com/designhub/vo/BrandTaskDistributionVO.java b/backend/src/main/java/com/designhub/vo/BrandTaskDistributionVO.java new file mode 100644 index 0000000..d47ef93 --- /dev/null +++ b/backend/src/main/java/com/designhub/vo/BrandTaskDistributionVO.java @@ -0,0 +1,75 @@ +package com.designhub.vo; + +import java.math.BigDecimal; + +/** + * 品牌任务分布VO(一级需求) + */ +public class BrandTaskDistributionVO { + + /** + * 品牌ID + */ + private Long brandId; + + /** + * 品牌名称 + */ + private String brandName; + + /** + * 任务数 + */ + private Integer taskCount; + + /** + * 占比(百分比) + */ + private BigDecimal percentage; + + /** + * 负责部门ID + */ + private Long responsibleDeptId; + + /** + * 负责部门名称 + */ + private String responsibleDeptName; + + /** + * 消耗积分 + */ + private BigDecimal consumedScore; + + /** + * 完成任务数 + */ + private Integer completedCount; + + // ==================== Getters and Setters ==================== + + public Long getBrandId() { return brandId; } + public void setBrandId(Long brandId) { this.brandId = brandId; } + + public String getBrandName() { return brandName; } + public void setBrandName(String brandName) { this.brandName = brandName; } + + public Integer getTaskCount() { return taskCount; } + public void setTaskCount(Integer taskCount) { this.taskCount = taskCount; } + + public BigDecimal getPercentage() { return percentage; } + public void setPercentage(BigDecimal percentage) { this.percentage = percentage; } + + public Long getResponsibleDeptId() { return responsibleDeptId; } + public void setResponsibleDeptId(Long responsibleDeptId) { this.responsibleDeptId = responsibleDeptId; } + + public String getResponsibleDeptName() { return responsibleDeptName; } + public void setResponsibleDeptName(String responsibleDeptName) { this.responsibleDeptName = responsibleDeptName; } + + public BigDecimal getConsumedScore() { return consumedScore; } + public void setConsumedScore(BigDecimal consumedScore) { this.consumedScore = consumedScore; } + + public Integer getCompletedCount() { return completedCount; } + public void setCompletedCount(Integer completedCount) { this.completedCount = completedCount; } +} diff --git a/backend/src/main/java/com/designhub/vo/PrimaryDeptRankingVO.java b/backend/src/main/java/com/designhub/vo/PrimaryDeptRankingVO.java new file mode 100644 index 0000000..33e50c1 --- /dev/null +++ b/backend/src/main/java/com/designhub/vo/PrimaryDeptRankingVO.java @@ -0,0 +1,83 @@ +package com.designhub.vo; + +import java.math.BigDecimal; + +/** + * 一级需求部门排名VO + */ +public class PrimaryDeptRankingVO { + + /** + * 排名 + */ + private Integer rank; + + /** + * 部门ID + */ + private Long deptId; + + /** + * 部门名称 + */ + private String deptName; + + /** + * 发起任务数 + */ + private Integer taskCount; + + /** + * 审核通过率(百分比) + */ + private BigDecimal approvalRate; + + /** + * 消耗积分 + */ + private BigDecimal consumedScore; + + /** + * 平均响应时间(小时) + */ + private Double avgResponseHours; + + /** + * 完成任务数 + */ + private Integer completedCount; + + /** + * 进行中任务数 + */ + private Integer inProgressCount; + + // ==================== Getters and Setters ==================== + + public Integer getRank() { return rank; } + public void setRank(Integer rank) { this.rank = rank; } + + public Long getDeptId() { return deptId; } + public void setDeptId(Long deptId) { this.deptId = deptId; } + + public String getDeptName() { return deptName; } + public void setDeptName(String deptName) { this.deptName = deptName; } + + public Integer getTaskCount() { return taskCount; } + public void setTaskCount(Integer taskCount) { this.taskCount = taskCount; } + + public BigDecimal getApprovalRate() { return approvalRate; } + public void setApprovalRate(BigDecimal approvalRate) { this.approvalRate = approvalRate; } + + public BigDecimal getConsumedScore() { return consumedScore; } + public void setConsumedScore(BigDecimal consumedScore) { this.consumedScore = consumedScore; } + + public Double getAvgResponseHours() { return avgResponseHours; } + public void setAvgResponseHours(Double avgResponseHours) { this.avgResponseHours = avgResponseHours; } + + public Integer getCompletedCount() { return completedCount; } + public void setCompletedCount(Integer completedCount) { this.completedCount = completedCount; } + + public Integer getInProgressCount() { return inProgressCount; } + public void setInProgressCount(Integer inProgressCount) { this.inProgressCount = inProgressCount; } +} diff --git a/backend/src/main/java/com/designhub/vo/PrimaryDeptStatisticsVO.java b/backend/src/main/java/com/designhub/vo/PrimaryDeptStatisticsVO.java new file mode 100644 index 0000000..d5b1d43 --- /dev/null +++ b/backend/src/main/java/com/designhub/vo/PrimaryDeptStatisticsVO.java @@ -0,0 +1,99 @@ +package com.designhub.vo; + +import java.math.BigDecimal; + +/** + * 一级需求部门统计VO + */ +public class PrimaryDeptStatisticsVO { + + /** + * 发起任务数 + */ + private Integer totalTasks; + + /** + * 审核通过数 + */ + private Integer approvedTasks; + + /** + * 审核驳回数 + */ + private Integer rejectedTasks; + + /** + * 消耗积分 + */ + private BigDecimal consumedScore; + + /** + * 任务增长率(与上期对比) + */ + private BigDecimal taskGrowthRate; + + /** + * 通过增长率 + */ + private BigDecimal approvalGrowthRate; + + /** + * 驳回增长率 + */ + private BigDecimal rejectGrowthRate; + + /** + * 积分增长率 + */ + private BigDecimal scoreGrowthRate; + + /** + * 待审核数 + */ + private Integer pendingTasks; + + /** + * 进行中数 + */ + private Integer inProgressTasks; + + /** + * 已完成数 + */ + private Integer completedTasks; + + // ==================== Getters and Setters ==================== + + public Integer getTotalTasks() { return totalTasks; } + public void setTotalTasks(Integer totalTasks) { this.totalTasks = totalTasks; } + + public Integer getApprovedTasks() { return approvedTasks; } + public void setApprovedTasks(Integer approvedTasks) { this.approvedTasks = approvedTasks; } + + public Integer getRejectedTasks() { return rejectedTasks; } + public void setRejectedTasks(Integer rejectedTasks) { this.rejectedTasks = rejectedTasks; } + + public BigDecimal getConsumedScore() { return consumedScore; } + public void setConsumedScore(BigDecimal consumedScore) { this.consumedScore = consumedScore; } + + public BigDecimal getTaskGrowthRate() { return taskGrowthRate; } + public void setTaskGrowthRate(BigDecimal taskGrowthRate) { this.taskGrowthRate = taskGrowthRate; } + + public BigDecimal getApprovalGrowthRate() { return approvalGrowthRate; } + public void setApprovalGrowthRate(BigDecimal approvalGrowthRate) { this.approvalGrowthRate = approvalGrowthRate; } + + public BigDecimal getRejectGrowthRate() { return rejectGrowthRate; } + public void setRejectGrowthRate(BigDecimal rejectGrowthRate) { this.rejectGrowthRate = rejectGrowthRate; } + + public BigDecimal getScoreGrowthRate() { return scoreGrowthRate; } + public void setScoreGrowthRate(BigDecimal scoreGrowthRate) { this.scoreGrowthRate = scoreGrowthRate; } + + public Integer getPendingTasks() { return pendingTasks; } + public void setPendingTasks(Integer pendingTasks) { this.pendingTasks = pendingTasks; } + + public Integer getInProgressTasks() { return inProgressTasks; } + public void setInProgressTasks(Integer inProgressTasks) { this.inProgressTasks = inProgressTasks; } + + public Integer getCompletedTasks() { return completedTasks; } + public void setCompletedTasks(Integer completedTasks) { this.completedTasks = completedTasks; } +} diff --git a/backend/src/main/java/com/designhub/vo/PrimaryTaskFlowEfficiencyVO.java b/backend/src/main/java/com/designhub/vo/PrimaryTaskFlowEfficiencyVO.java new file mode 100644 index 0000000..2aab8d6 --- /dev/null +++ b/backend/src/main/java/com/designhub/vo/PrimaryTaskFlowEfficiencyVO.java @@ -0,0 +1,57 @@ +package com.designhub.vo; + +/** + * 一级需求流转效率VO + */ +public class PrimaryTaskFlowEfficiencyVO { + + /** + * 发起到审核平均耗时(小时) + */ + private Double avgSubmitToReviewHours; + + /** + * 审核到完善平均耗时(小时) + */ + private Double avgReviewToCompleteHours; + + /** + * 完善到发布平均耗时(小时) + */ + private Double avgCompleteToPublishHours; + + /** + * 发布到完成平均耗时(小时) + */ + private Double avgPublishToFinishHours; + + /** + * 平均总耗时(小时) + */ + private Double avgTotalHours; + + /** + * 统计任务数 + */ + private Integer taskCount; + + // ==================== Getters and Setters ==================== + + public Double getAvgSubmitToReviewHours() { return avgSubmitToReviewHours; } + public void setAvgSubmitToReviewHours(Double avgSubmitToReviewHours) { this.avgSubmitToReviewHours = avgSubmitToReviewHours; } + + public Double getAvgReviewToCompleteHours() { return avgReviewToCompleteHours; } + public void setAvgReviewToCompleteHours(Double avgReviewToCompleteHours) { this.avgReviewToCompleteHours = avgReviewToCompleteHours; } + + public Double getAvgCompleteToPublishHours() { return avgCompleteToPublishHours; } + public void setAvgCompleteToPublishHours(Double avgCompleteToPublishHours) { this.avgCompleteToPublishHours = avgCompleteToPublishHours; } + + public Double getAvgPublishToFinishHours() { return avgPublishToFinishHours; } + public void setAvgPublishToFinishHours(Double avgPublishToFinishHours) { this.avgPublishToFinishHours = avgPublishToFinishHours; } + + public Double getAvgTotalHours() { return avgTotalHours; } + public void setAvgTotalHours(Double avgTotalHours) { this.avgTotalHours = avgTotalHours; } + + public Integer getTaskCount() { return taskCount; } + public void setTaskCount(Integer taskCount) { this.taskCount = taskCount; } +} diff --git a/database/V13__add_primary_demand_dept.sql b/database/V13__add_primary_demand_dept.sql new file mode 100644 index 0000000..c60f268 --- /dev/null +++ b/database/V13__add_primary_demand_dept.sql @@ -0,0 +1,55 @@ +-- V13__add_primary_demand_dept.sql +-- 一级需求部门功能数据库迁移脚本 +-- 创建日期:2026-01-19 + +-- ===================================================== +-- 1. 系统配置:一级需求部门是否允许填写任务明细 +-- ===================================================== +INSERT INTO sys_config (config_key, config_value, config_group, config_name, description, value_type, built_in, sort_order, create_time, update_time, deleted) +VALUES ('primary_dept_can_fill_detail', 'false', 'task', '一级需求部门可填写明细', '开启后一级需求部门发布任务时可以填写任务明细,关闭后不允许填写', 'boolean', 0, 100, NOW(), NOW(), 0); + +-- ===================================================== +-- 2. 任务表新增字段 +-- ===================================================== +-- 一级部门发起人ID +ALTER TABLE task ADD COLUMN IF NOT EXISTS primary_publisher_id BIGINT COMMENT '一级部门发起人ID'; + +-- 一级部门ID +ALTER TABLE task ADD COLUMN IF NOT EXISTS primary_publisher_dept_id BIGINT COMMENT '一级部门ID'; + +-- 明细完善人ID +ALTER TABLE task ADD COLUMN IF NOT EXISTS detail_completer_id BIGINT COMMENT '明细完善人ID'; + +-- 是否一级部门任务 +ALTER TABLE task ADD COLUMN IF NOT EXISTS is_primary_task TINYINT DEFAULT 0 COMMENT '是否一级部门任务:0-否,1-是'; + +-- 明细是否已完善 +ALTER TABLE task ADD COLUMN IF NOT EXISTS detail_completed TINYINT DEFAULT 0 COMMENT '明细是否已完善:0-否,1-是'; + +-- ===================================================== +-- 3. 创建品牌-部门关联表 +-- ===================================================== +CREATE TABLE IF NOT EXISTS dept_brand_relation ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + dept_id BIGINT NOT NULL COMMENT '部门ID(需求部门)', + brand_id BIGINT NOT NULL COMMENT '品牌ID', + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + UNIQUE KEY uk_brand_id (brand_id), + INDEX idx_dept_id (dept_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='部门品牌负责关系表'; + +-- ===================================================== +-- 4. 为现有任务设置默认值 +-- ===================================================== +UPDATE task SET is_primary_task = 0 WHERE is_primary_task IS NULL; +UPDATE task SET detail_completed = 1 WHERE detail_completed IS NULL; + +-- ===================================================== +-- 5. 添加索引优化查询性能 +-- ===================================================== +-- 一级任务查询索引 +CREATE INDEX IF NOT EXISTS idx_task_is_primary ON task(is_primary_task); +-- 一级发起人查询索引 +CREATE INDEX IF NOT EXISTS idx_task_primary_publisher ON task(primary_publisher_id); +-- 一级部门查询索引 +CREATE INDEX IF NOT EXISTS idx_task_primary_dept ON task(primary_publisher_dept_id); diff --git a/docs/一级需求部门功能开发文档.md b/docs/一级需求部门功能开发文档.md new file mode 100644 index 0000000..70737b6 --- /dev/null +++ b/docs/一级需求部门功能开发文档.md @@ -0,0 +1,819 @@ +# 一级需求部门功能开发文档 + +## 📋 需求概述 + +### 业务背景 +当前系统中部门类型分为"需求部门"和"接单部门"。现需要新增"一级需求部门"类型,用于支持更上层的需求发起流程。一级需求部门可以发起简化的需求任务,由下级需求部门负责完善任务明细后再发布到设计部门。 + +### 核心功能 +1. 新增"一级需求部门"部门类型 +2. 一级需求部门可发起简化需求(任务明细可不填) +3. 需求部门可设置负责的品牌 +4. 根据品牌自动路由到对应需求部门审核 +5. 需求部门完善任务明细后继续原有流程 +6. 任务卡片显示"一级部门需求人"信息 + +--- + +## 🔄 业务流程 + +### 一级需求部门发起任务流程 + +``` +┌─────────────────┐ +│ 一级需求部门用户 │ +│ 发起需求 │ +│ (可不填明细) │ +└────────┬────────┘ + │ 选择品牌 + ▼ +┌─────────────────┐ +│ 系统根据品牌 │ +│ 路由到对应 │ +│ 需求部门负责人 │ +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ 需求部门负责人 │ +│ 审核需求 │ +│ 指定成员完善 │ +└────────┬────────┘ + │ 审核通过 + ▼ +┌─────────────────┐ +│ 需求部门成员 │ +│ 完善任务明细 │ +│ 提交发布 │ +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ 进入原有流程 │ +│ (需求部门审核 │ +│ → 接单部门) │ +└─────────────────┘ +``` + +### 任务状态流转 + +| 阶段 | 状态 | 说明 | +|-----|------|------| +| 一级部门发起 | `pending_primary_approval` | 待一级审核(需求部门负责人审核) | +| 需求部门审核通过 | `pending_detail_completion` | 待完善明细 | +| 明细完善提交 | `pending_demand_approval` | 进入原有需求部门审核流程 | + +### 积分流转逻辑 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 积分流转说明 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 一级需求部门发起任务 │ +│ │ │ +│ ▼ │ +│ ┌─────────────────┐ │ +│ │ 校验一级部门积分 │ ← 积分不足则不允许发起 │ +│ └────────┬────────┘ │ +│ │ 积分充足 │ +│ ▼ │ +│ ┌─────────────────┐ │ +│ │ 暂不扣除积分 │ ← 等待需求部门审核 │ +│ └────────┬────────┘ │ +│ │ │ +│ ┌─────┴─────┐ │ +│ ▼ ▼ │ +│ [审核通过] [审核驳回] │ +│ │ │ │ +│ │ ▼ │ +│ │ ┌───────────┐ │ +│ │ │ 不扣积分 │ ← 驳回不产生积分变动 │ +│ │ └───────────┘ │ +│ ▼ │ +│ ┌─────────────────┐ │ +│ │ 需求部门完善明细 │ │ +│ │ 提交发布 │ │ +│ └────────┬────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────┐ │ +│ │ 扣除一级部门积分 │ ← 只扣一级部门,不扣需求部门 │ +│ └────────┬────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────┐ │ +│ │ 任务进入正常流程 │ │ +│ └─────────────────┘ │ +│ │ +│ 【任务撤回/取消时】 │ +│ 积分返还给一级需求部门(非需求部门) │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +| 场景 | 一级部门积分 | 需求部门积分 | +|-----|------------|-------------| +| 一级部门发起任务 | 校验余额,暂不扣除 | 不变 | +| 需求部门审核驳回 | 不变 | 不变 | +| 需求部门完善后发布 | **扣除** | 不变 | +| 任务撤回/取消 | **返还** | 不变 | +| 任务完成 | 不变 | 不变 | + +--- + +## 📊 数据库设计 + +### 0. 系统配置表 `sys_config` 新增配置项 + +```sql +-- 新增系统配置:一级需求部门是否允许填写任务明细 +INSERT INTO sys_config (config_key, config_value, config_name, config_group, description, create_time) +VALUES ('primary_dept_can_fill_detail', 'false', '一级需求部门可填写明细', 'task', '开启后一级需求部门发布任务时可以填写任务明细,关闭后不允许填写', NOW()); +``` + +| 配置项 | 默认值 | 说明 | +|-------|-------|------| +| primary_dept_can_fill_detail | false | 一级需求部门是否允许填写任务明细 | + +### 1. 部门表 `sys_dept` 修改 + +```sql +-- 新增字段 +ALTER TABLE sys_dept ADD COLUMN responsible_brand_ids VARCHAR(500) COMMENT '负责的品牌ID列表(逗号分隔)'; + +-- 修改 dept_type 字段说明 +-- dept_type: primary_demand-一级需求部门, demand-需求部门, receiving-接单部门 +``` + +| 字段名 | 类型 | 说明 | +|-------|------|------| +| responsible_brand_ids | VARCHAR(500) | 负责的品牌ID列表(逗号分隔),仅需求部门使用 | + +### 2. 任务表 `task` 修改 + +```sql +-- 新增字段 +ALTER TABLE task ADD COLUMN primary_publisher_id BIGINT COMMENT '一级部门发起人ID'; +ALTER TABLE task ADD COLUMN primary_publisher_dept_id BIGINT COMMENT '一级部门ID'; +ALTER TABLE task ADD COLUMN detail_completer_id BIGINT COMMENT '明细完善人ID'; +ALTER TABLE task ADD COLUMN is_primary_task TINYINT DEFAULT 0 COMMENT '是否一级部门任务:0-否,1-是'; +ALTER TABLE task ADD COLUMN detail_completed TINYINT DEFAULT 0 COMMENT '明细是否已完善:0-否,1-是'; +``` + +| 字段名 | 类型 | 说明 | +|-------|------|------| +| primary_publisher_id | BIGINT | 一级部门发起人ID | +| primary_publisher_dept_id | BIGINT | 一级部门ID | +| detail_completer_id | BIGINT | 明细完善人ID(需求部门指定的成员) | +| is_primary_task | TINYINT | 是否一级部门任务:0-否,1-是 | +| detail_completed | TINYINT | 明细是否已完善:0-否,1-是 | + +### 3. 新增品牌-部门关联表 `dept_brand_relation` + +```sql +CREATE TABLE dept_brand_relation ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + dept_id BIGINT NOT NULL COMMENT '部门ID(需求部门)', + brand_id BIGINT NOT NULL COMMENT '品牌ID', + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + UNIQUE KEY uk_brand_id (brand_id), -- 一个品牌只能对应一个需求部门 + INDEX idx_dept_id (dept_id) +) COMMENT '部门品牌负责关系表'; +``` + +> ⚠️ **注意**: +> - `brand_id` 设置唯一索引,确保一个品牌只能被一个需求部门负责 +> - **支持多品牌对应一个需求部门**:一个需求部门可以负责多个品牌 + +**品牌-部门关系示例**: +| 需求部门 | 负责品牌 | +|---------|---------| +| 设计一部 | 品牌A、品牌B、品牌C | +| 设计二部 | 品牌D、品牌E | +| 设计三部 | 品牌F | + +--- + +## 🔧 后端开发 + +### 1. 实体类修改 + +#### Department.java +```java +// 新增字段 +private String responsibleBrandIds; // 负责的品牌ID列表 + +// dept_type 可选值新增 +// primary_demand - 一级需求部门 +// demand - 需求部门 +// receiving - 接单部门 +``` + +#### Task.java +```java +// 新增字段 +private Long primaryPublisherId; // 一级部门发起人ID +private Long primaryPublisherDeptId; // 一级部门ID +private Long detailCompleterId; // 明细完善人ID +private Integer isPrimaryTask; // 是否一级部门任务 +private Integer detailCompleted; // 明细是否已完善 +``` + +### 2. 新增实体类 + +#### DeptBrandRelation.java +```java +@TableName("dept_brand_relation") +public class DeptBrandRelation { + private Long id; + private Long deptId; + private Long brandId; + private LocalDateTime createTime; +} +``` + +### 3. Service 层修改 + +#### DepartmentService.java +```java +// 新增方法 +// 根据品牌ID获取负责的需求部门 +Department getDemandDeptByBrandId(Long brandId); + +// 设置部门负责的品牌 +void setDeptResponsibleBrands(Long deptId, List brandIds); + +// 获取部门负责的品牌列表 +List getDeptResponsibleBrands(Long deptId); +``` + +#### TaskService.java +```java +// 新增方法 +// 一级部门创建任务(简化版) +Task createPrimaryTask(TaskCreateDTO dto, Long userId); + +// 需求部门审核一级任务并指定完善人 +void approvePrimaryTask(Long taskId, Long completerId, Long approverId); + +// 完善任务明细 +void completeTaskDetail(Long taskId, TaskDetailDTO dto, Long userId); +``` + +### 4. Controller 层修改 + +#### TaskController.java +```java +// 新增接口 +@PostMapping("/primary") +Result createPrimaryTask(@RequestBody TaskCreateDTO dto); + +@PostMapping("/{id}/approve-primary") +Result approvePrimaryTask(@PathVariable Long id, @RequestParam Long completerId); + +@PostMapping("/{id}/complete-detail") +Result completeTaskDetail(@PathVariable Long id, @RequestBody TaskDetailDTO dto); +``` + +#### DepartmentController.java +```java +// 新增接口 +@PostMapping("/{id}/responsible-brands") +Result setResponsibleBrands(@PathVariable Long id, @RequestBody List brandIds); + +@GetMapping("/{id}/responsible-brands") +Result> getResponsibleBrands(@PathVariable Long id); +``` + +### 5. 审核状态新增 + +```java +// ApprovalStatus 新增状态 +PENDING_PRIMARY_APPROVAL("pending_primary_approval", "待一级审核"), +PENDING_DETAIL_COMPLETION("pending_detail_completion", "待完善明细"), +``` + +### 6. 数据分析Service扩展 + +#### AnalyticsService.java +```java +// 新增方法 + +/** + * 获取一级需求部门统计数据 + */ +PrimaryDeptStatisticsVO getPrimaryDeptStatistics(Long deptId, LocalDateTime startTime, LocalDateTime endTime); + +/** + * 获取一级需求部门排名 + */ +List getPrimaryDeptRanking(LocalDateTime startTime, LocalDateTime endTime); + +/** + * 获取一级需求流转效率分析 + */ +PrimaryTaskFlowEfficiencyVO getPrimaryTaskFlowEfficiency(LocalDateTime startTime, LocalDateTime endTime); + +/** + * 获取品牌任务分布(一级需求) + */ +List getPrimaryTaskBrandDistribution(LocalDateTime startTime, LocalDateTime endTime); +``` + +#### 新增VO类 + +```java +// PrimaryDeptStatisticsVO.java - 一级需求部门统计 +public class PrimaryDeptStatisticsVO { + private Integer totalTasks; // 发起任务数 + private Integer approvedTasks; // 审核通过数 + private Integer rejectedTasks; // 审核驳回数 + private BigDecimal consumedScore; // 消耗积分 + private BigDecimal taskGrowthRate; // 任务增长率 + private BigDecimal approvalGrowthRate; // 通过增长率 + private BigDecimal rejectGrowthRate; // 驳回增长率 + private BigDecimal scoreGrowthRate; // 积分增长率 +} + +// PrimaryDeptRankingVO.java - 一级需求部门排名 +public class PrimaryDeptRankingVO { + private Integer rank; // 排名 + private Long deptId; // 部门ID + private String deptName; // 部门名称 + private Integer taskCount; // 发起任务数 + private BigDecimal approvalRate; // 审核通过率 + private BigDecimal consumedScore; // 消耗积分 + private Double avgResponseHours; // 平均响应时间(小时) +} + +// PrimaryTaskFlowEfficiencyVO.java - 一级需求流转效率 +public class PrimaryTaskFlowEfficiencyVO { + private Double avgSubmitToReviewHours; // 发起到审核平均耗时 + private Double avgReviewToCompleteHours; // 审核到完善平均耗时 + private Double avgCompleteToPublishHours; // 完善到发布平均耗时 + private Double avgPublishToFinishHours; // 发布到完成平均耗时 + private Double avgTotalHours; // 平均总耗时 +} + +// BrandTaskDistributionVO.java - 品牌任务分布 +public class BrandTaskDistributionVO { + private Long brandId; // 品牌ID + private String brandName; // 品牌名称 + private Integer taskCount; // 任务数 + private BigDecimal percentage; // 占比 + private Long responsibleDeptId; // 负责部门ID + private String responsibleDeptName; // 负责部门名称 +} +``` + +### 7. 数据分析Controller扩展 + +#### AnalyticsController.java +```java +// 新增接口 + +@GetMapping("/primary-dept/statistics") +@Operation(summary = "获取一级需求部门统计数据") +Result getPrimaryDeptStatistics( + @RequestParam(required = false) Long deptId, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startTime, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endTime +); + +@GetMapping("/primary-dept/ranking") +@Operation(summary = "获取一级需求部门排名") +Result> getPrimaryDeptRanking( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startTime, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endTime +); + +@GetMapping("/primary-task/flow-efficiency") +@Operation(summary = "获取一级需求流转效率分析") +Result getPrimaryTaskFlowEfficiency( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startTime, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endTime +); + +@GetMapping("/primary-task/brand-distribution") +@Operation(summary = "获取品牌任务分布(一级需求)") +Result> getPrimaryTaskBrandDistribution( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startTime, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endTime +); +``` + +--- + +## 🎨 前端开发 + +### 1. 系统设置页面修改 + +#### 新增"一级需求部门设置"配置项 +- **配置名称**:一级需求部门可填写任务明细 +- **配置说明**:开启后一级需求部门发布任务时可以填写任务明细,关闭后不允许填写 +- **控件类型**:开关(Switch) +- **默认值**:关闭 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 一级需求部门设置 │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ 一级需求部门可填写任务明细 [关闭 ○━━━━━━━━━━━○ 开启] │ +│ │ +│ 说明:关闭后,一级需求部门发布任务时无法填写任务明细, │ +│ 任务明细将完全由需求部门成员填写。 │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 2. 部门管理页面修改 + +#### 部门类型选择 +- 新增"一级需求部门"选项 +- 部门类型:一级需求部门 / 需求部门 / 接单部门 + +#### 需求部门设置 +- 新增"负责品牌"多选配置 +- 仅当部门类型为"需求部门"时显示 + +### 3. 任务发起页面修改 + +#### 一级需求部门用户 +- 根据系统配置 `primary_dept_can_fill_detail` 控制任务明细区域 + - **开启**:显示任务明细填写区域(选填) + - **关闭**:隐藏或禁用任务明细填写区域,显示提示"任务明细将由需求部门填写" +- 必填项:品牌、基本需求描述、积分预算 +- 品牌选择后显示提示:"该任务将发送至 [XX部门] 审核" + +#### 表单字段变化 +| 字段 | 普通需求部门 | 一级需求部门(开启明细填写) | 一级需求部门(关闭明细填写) | +|-----|------------|---------------------------|---------------------------| +| 品牌 | 必填 | 必填 | 必填 | +| 任务标题 | 必填 | 必填 | 必填 | +| 需求描述 | 必填 | 必填 | 必填 | +| 任务明细 | 必填 | 选填 | **隐藏/禁用** | +| 预估积分 | 必填 | 必填 | 必填 | +| 附件 | 选填 | 选填 | 选填 | +| 指定设计师 | 可选 | **不支持** | **不支持** | +| 指定需求部门 | 可选 | **不支持**(通过品牌自动路由) | **不支持** | + +> 💡 **系统配置**:`primary_dept_can_fill_detail` +> - **开启(true)**:一级需求部门可以选填任务明细 +> - **关闭(false)**:一级需求部门不显示/禁用任务明细填写区域,明细完全由需求部门填写 + +#### 需求部门完善明细时的表单权限 +| 字段 | 是否可编辑 | 说明 | +|-----|-----------|------| +| 品牌 | ❌ 不可编辑 | 一级部门已选定 | +| 任务标题 | ❌ 不可编辑 | 一级部门已填写 | +| 需求描述 | ❌ 不可编辑 | 一级部门已填写 | +| 预估积分 | ❌ 不可编辑 | 一级部门已填写 | +| 附件(基本) | ❌ 不可编辑 | 一级部门已上传 | +| 任务明细 | ✅ 可编辑 | 需求部门完善 | +| 明细附件 | ✅ 可编辑 | 需求部门补充 | + +### 4. 任务大厅页面修改 + +#### 任务卡片 +- 新增显示"一级部门需求人"字段(仅一级部门任务显示) +- 格式:`一级需求人: 张三 (运营一部)` + +#### 筛选条件 +- 新增"任务来源"筛选:全部 / 普通需求 / 一级需求 + +### 5. 需求部门工作台 + +#### 新增"待审核一级需求"列表 +- 显示一级部门发起的待审核任务 +- 操作:审核通过(指定完善人)/ 驳回 + +#### 新增"待完善明细"列表 +- 显示被指定需要完善明细的任务 +- 操作:编辑完善 / 提交发布 + +### 6. 审核流程页面 + +#### 一级任务审核弹窗 +- 显示任务基本信息 +- 选择完善人(需求部门成员下拉) +- 审核通过 / 驳回 + +### 7. 数据分析页面修改 + +#### 新增"一级需求部门统计"模块 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 一级需求部门统计 [本月 ▼] │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ 发起任务数 │ │ 审核通过数 │ │ 审核驳回数 │ │ 消耗积分 │ │ +│ │ 128 │ │ 115 │ │ 8 │ │ 12,580 │ │ +│ │ ↑ 12.5% │ │ ↑ 15.2% │ │ ↓ 20.0% │ │ ↑ 18.3% │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +#### 一级需求部门排名 + +| 排名 | 部门名称 | 发起任务数 | 审核通过率 | 消耗积分 | 平均响应时间 | +|-----|---------|-----------|-----------|---------|-------------| +| 1 | 运营一部 | 45 | 95.2% | 4,580 | 2.3小时 | +| 2 | 运营二部 | 38 | 92.1% | 3,820 | 3.1小时 | +| 3 | 市场部 | 32 | 88.5% | 2,960 | 4.2小时 | + +#### 一级需求流转效率分析 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 一级需求流转效率 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 发起 ──────► 审核 ──────► 完善 ──────► 发布 ──────► 完成 │ +│ 2.3h 4.5h 1.2h 24.6h │ +│ │ +│ 平均总耗时:32.6 小时 │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +#### 品牌任务分布(一级需求) + +| 品牌 | 任务数 | 占比 | 负责部门 | +|-----|-------|-----|---------| +| 品牌A | 45 | 35.2% | 设计一部 | +| 品牌B | 32 | 25.0% | 设计一部 | +| 品牌C | 28 | 21.9% | 设计二部 | +| 其他 | 23 | 17.9% | - | + +#### 统计指标说明 + +| 指标 | 计算方式 | 说明 | +|-----|---------|------| +| 发起任务数 | COUNT(is_primary_task = 1) | 一级部门发起的任务总数 | +| 审核通过率 | 通过数 / 总数 × 100% | 需求部门审核通过的比例 | +| 消耗积分 | SUM(score) WHERE is_primary_task = 1 | 一级部门消耗的总积分 | +| 平均响应时间 | AVG(审核时间 - 发起时间) | 需求部门响应一级需求的平均时间 | +| 平均完善时间 | AVG(完善提交时间 - 审核通过时间) | 明细完善的平均耗时 | +| 平均总耗时 | AVG(任务完成时间 - 发起时间) | 从发起到完成的平均时间 | + +#### 数据筛选条件 + +- **时间范围**:今日 / 本周 / 本月 / 本季度 / 自定义 +- **一级部门**:全部 / 指定部门 +- **品牌**:全部 / 指定品牌 +- **任务状态**:全部 / 进行中 / 已完成 + +--- + +## 📱 通知设计 + +> **核心原则**:一级需求部门的通知机制与需求部门完全一致,包括站内通知和钉钉通知 + +### 通知场景汇总 + +| 场景 | 通知对象 | 通知内容 | 通知方式 | +|-----|---------|---------|---------| +| 一级部门发起任务 | 品牌对应的需求部门负责人 | `[运营一部-张三] 发起了新的一级需求任务 [任务标题],请审核` | 站内 + 钉钉 | +| 需求部门审核通过 | 被指定的明细完善人 | `您被指定完善任务 [任务标题] 的明细,请尽快处理` | 站内 + 钉钉 | +| 需求部门审核通过 | 一级部门发起人 | `您发起的任务 [任务标题] 已通过审核,正在等待明细完善` | 站内 + 钉钉 | +| 需求部门驳回 | 一级部门发起人 | `您发起的任务 [任务标题] 被驳回,原因:[驳回原因]` | 站内 + 钉钉 | +| 明细完善提交 | 一级部门发起人 | `任务 [任务标题] 明细已完善,进入正常审核流程` | 站内 + 钉钉 | +| 任务进入审核流程 | 需求部门负责人 | `任务 [任务标题] 待审核` | 站内 + 钉钉 | +| 任务被抢单 | 一级部门发起人 | `任务 [任务标题] 已被设计师 YYY 抢单` | 站内 + 钉钉 | +| 任务完成 | 一级部门发起人 | `任务 [任务标题] 已完成` | 站内 + 钉钉 | + +### 钉钉通知说明 + +一级需求部门成员的钉钉通知配置与需求部门**完全一致**: + +1. **通知触发**:与需求部门相同的事件触发相同的通知 +2. **通知格式**:使用相同的消息模板 +3. **通知链接**:点击可跳转到任务详情页 +4. **通知权限**: + - 一级部门发起人可收到自己任务的所有状态变更通知 + - 与需求部门发起人收到的通知类型完全一致 + +### 通知模板示例 + +``` +【任务状态变更】 +任务名称:XXX +任务状态:待完善明细 +操作人:张三 +操作时间:2026-01-19 15:30:00 +备注:请尽快完善任务明细 + +点击查看详情 >> +``` + +--- + +## ✅ 验收标准 + +### 功能验收 +- [ ] 可以设置部门为"一级需求部门"类型 +- [ ] 需求部门可以配置负责的品牌 +- [ ] 一级需求部门用户可以发起简化需求(明细可不填) +- [ ] 一级需求部门发起任务需要有积分 +- [ ] 任务根据品牌自动路由到对应需求部门 +- [ ] 需求部门负责人可以审核并指定完善人 +- [ ] 被指定人可以完善任务明细并提交 +- [ ] 完善后的任务进入原有审核流程 +- [ ] 任务卡片正确显示"一级部门需求人" +- [ ] 各环节通知正常发送 + +### 权限验收 +- [ ] 只有一级需求部门用户可以发起一级需求 +- [ ] 只有需求部门负责人可以审核一级需求 +- [ ] 只有被指定的完善人可以编辑任务明细 +- [ ] 积分校验正常(一级部门也需要有积分) + +### 数据分析验收 +- [ ] 一级需求部门统计卡片数据正确显示 +- [ ] 一级需求部门排名列表正确排序 +- [ ] 一级需求流转效率各阶段耗时正确计算 +- [ ] 品牌任务分布占比正确 +- [ ] 时间筛选功能正常工作 +- [ ] 部门/品牌筛选功能正常工作 +- [ ] 同比/环比增长率计算正确 + +### 数据验收 +- [ ] 任务数据正确记录一级发起人信息 +- [ ] 品牌-部门关联关系正确保存 +- [ ] 积分扣减和返还逻辑正确 + +--- + +## 📅 开发计划(建议) + +| 阶段 | 内容 | 预估工时 | +|-----|------|---------| +| 第一阶段 | 数据库设计与实体类修改 | 0.5天 | +| 第二阶段 | 后端Service和Controller开发(任务流程) | 1.5天 | +| 第三阶段 | 后端数据分析接口开发 | 1天 | +| 第四阶段 | 前端部门管理页面修改 | 0.5天 | +| 第五阶段 | 前端任务发起页面修改 | 1天 | +| 第六阶段 | 前端任务大厅和工作台修改 | 1天 | +| 第七阶段 | 前端数据分析页面修改 | 1天 | +| 第八阶段 | 通知功能开发 | 0.5天 | +| 第九阶段 | 联调测试 | 1天 | +| **合计** | | **8天** | + +--- + +## 👁️ 一级需求部门权限说明 + +### 权限对比 + +| 功能模块 | 需求部门 | 一级需求部门 | 说明 | +|---------|---------|-------------|------| +| 任务大厅 | ✅ 可查看 | ✅ 可查看 | 相同 | +| 我发起的任务 | ✅ 可查看 | ✅ 可查看 | 相同 | +| 任务详情 | ✅ 可查看 | ✅ 可查看 | 相同 | +| 数据分析 | ✅ 可查看 | ✅ 可查看 | 相同 | +| 设计师大厅 | ✅ 可查看 | ✅ 可查看 | 相同 | +| 通知消息 | ✅ 可接收 | ✅ 可接收 | 相同 | +| 发起任务 | ✅ 完整表单 | ✅ 简化表单 | 一级部门明细可选/禁用 | +| 审核任务 | ✅ 可审核 | ❌ 不可审核 | 一级部门只发起不审核 | +| 完善明细 | ✅ 可完善 | ❌ 不可完善 | 一级部门任务由需求部门完善 | +| 指定设计师 | ✅ 可指定 | ❌ 不可指定 | 一级部门不支持 | +| 指定需求部门 | ✅ 可指定 | ❌ 不可指定 | 一级部门通过品牌路由 | + +### 核心原则 +> **一级需求部门的查看权限与需求部门完全一致**,仅在任务发起流程上有差异(简化表单、品牌路由) + +--- + +## ⚠️ 注意事项 + +1. **积分逻辑**:一级需求部门发起任务时需要校验积分余额,积分从一级部门扣除 +2. **品牌路由**:如果品牌没有对应的需求部门,需要给出明确提示 +3. **权限控制**:严格控制各环节的操作权限,查看权限与需求部门一致 +4. **数据兼容**:现有任务数据的 `is_primary_task` 默认为 0 +5. **审核超时**:考虑是否需要增加审核超时提醒机制 + +--- + +## ✅ 已确认事项 + +| 序号 | 问题 | 确认结果 | +|-----|------|---------| +| 1 | 一级需求部门的积分池是独立的还是共用的? | **独立的**。一级需求部门任务只扣一级部门积分,需求部门完善明细后发布不扣需求部门积分 | +| 2 | 一级需求被驳回后,积分是否立即返还? | **是**。驳回后积分立即返还给一级需求部门 | +| 3 | 明细完善人是否可以修改一级部门填写的基本信息? | **不可以**。需求部门成员只能编辑任务明细,不能修改基本信息 | +| 4 | 一个品牌是否可以被多个需求部门负责? | **不可以**。一个品牌只能对应一个需求部门,但**一个需求部门可以负责多个品牌** | +| 5 | 是否需要支持一级部门直接指定需求部门? | **不支持**。一级需求部门不支持指定设计师和指定需求部门,只能通过品牌路由 | + +--- + +## 🔍 系统集成复核 + +### 现有系统架构分析 + +#### 1. 部门类型(Department.deptType) +- **现有值**:`demand`(需求部门)、`receiving`(接单部门) +- **新增值**:`primary_demand`(一级需求部门) +- ✅ 兼容性良好,只需扩展枚举值 + +#### 2. 任务审批流程(Task.approvalStatus) +- **现有状态**: + - `pending_demand_approval` - 待需求部门审核 + - `pending_receiving_approval` - 待接单部门审核 + - `approved` - 已通过 +- **新增状态**: + - `pending_primary_approval` - 待一级审核(需求部门审核一级任务) + - `pending_detail_completion` - 待完善明细 +- ✅ 状态扩展,与现有流程兼容 + +#### 3. 积分扣除时机(现有逻辑) +- **现有逻辑**:接单部门审核通过后扣除积分(`TaskApprovalService.approveReceivingLevel`) +- **一级任务逻辑**:需求部门完善明细后发布时扣除一级部门积分 +- ⚠️ 需要在 `TaskService` 中增加判断:如果是一级任务,扣除 `primaryPublisherDeptId` 的积分 + +#### 4. 任务创建权限检查(现有逻辑) +```java +// 现有代码检查 deptType == "demand" +if (!"demand".equals(department.getDeptType())) { + throw new BusinessException(ResultCode.FORBIDDEN, "只有需求部门可以发起任务"); +} +``` +- ⚠️ 需要修改为:`"demand".equals(deptType) || "primary_demand".equals(deptType)` + +#### 5. 系统配置表(sys_config) +- **现有结构**:完全匹配,可直接使用 +- **字段**:`config_key`, `config_value`, `config_group`, `config_name`, `description`, `value_type` +- ✅ 新增配置项完全兼容 + +#### 6. 前端部门类型选择(Settings.vue) +- **现有实现**:Radio 组件,`demand` / `receiving` 两个选项 +- ⚠️ 需要增加 `primary_demand` 选项 +- ⚠️ 需要增加"负责品牌"配置(仅需求部门显示) + +### 需要特别注意的集成点 + +| 序号 | 集成点 | 现有代码位置 | 修改说明 | +|-----|-------|-------------|---------| +| 1 | 任务创建权限 | `TaskService.createTask()` | 允许 `primary_demand` 类型部门发起任务 | +| 2 | 任务创建权限 | `TaskService.createPipelineTask()` | 同上 | +| 3 | 积分扣除 | `TaskApprovalService.approveReceivingLevel()` | 一级任务扣除一级部门积分 | +| 4 | 积分返还 | `TaskService.withdrawTask()` | 一级任务返还给一级部门 | +| 5 | 审批流程初始化 | `TaskService.initApprovalStatus()` | 一级任务路由到需求部门 | +| 6 | 前端部门类型 | `Settings.vue` 部门表单 | 增加一级需求部门选项 | +| 7 | 前端任务发起 | `CreateTask.vue` | 根据部门类型调整表单 | +| 8 | 前端任务卡片 | `TaskHall.vue` | 显示一级需求人信息 | + +### 数据库迁移脚本 + +```sql +-- V13__add_primary_demand_dept.sql + +-- 1. 系统配置:一级需求部门是否允许填写任务明细 +INSERT INTO sys_config (config_key, config_value, config_group, config_name, description, value_type, built_in, sort_order, create_time, update_time, deleted) +VALUES ('primary_dept_can_fill_detail', 'false', 'task', '一级需求部门可填写明细', '开启后一级需求部门发布任务时可以填写任务明细,关闭后不允许填写', 'boolean', 0, 100, NOW(), NOW(), 0); + +-- 2. 部门表新增字段(用于需求部门设置负责品牌,备用) +-- 注意:主要使用 dept_brand_relation 表管理关系 +-- ALTER TABLE sys_dept ADD COLUMN responsible_brand_ids VARCHAR(500) COMMENT '负责的品牌ID列表(逗号分隔)'; + +-- 3. 任务表新增字段 +ALTER TABLE task ADD COLUMN primary_publisher_id BIGINT COMMENT '一级部门发起人ID'; +ALTER TABLE task ADD COLUMN primary_publisher_dept_id BIGINT COMMENT '一级部门ID'; +ALTER TABLE task ADD COLUMN detail_completer_id BIGINT COMMENT '明细完善人ID'; +ALTER TABLE task ADD COLUMN is_primary_task TINYINT DEFAULT 0 COMMENT '是否一级部门任务:0-否,1-是'; +ALTER TABLE task ADD COLUMN detail_completed TINYINT DEFAULT 0 COMMENT '明细是否已完善:0-否,1-是'; + +-- 4. 创建品牌-部门关联表 +CREATE TABLE IF NOT EXISTS dept_brand_relation ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + dept_id BIGINT NOT NULL COMMENT '部门ID(需求部门)', + brand_id BIGINT NOT NULL COMMENT '品牌ID', + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + UNIQUE KEY uk_brand_id (brand_id), + INDEX idx_dept_id (dept_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='部门品牌负责关系表'; + +-- 5. 为现有任务设置默认值 +UPDATE task SET is_primary_task = 0 WHERE is_primary_task IS NULL; +UPDATE task SET detail_completed = 1 WHERE detail_completed IS NULL; +``` + +### 风险评估 + +| 风险项 | 风险等级 | 缓解措施 | +|-------|---------|---------| +| 现有任务数据兼容 | 低 | 新增字段有默认值,不影响现有数据 | +| 审批流程复杂度增加 | 中 | 增加充分的状态校验和日志记录 | +| 积分扣除逻辑变更 | 中 | 增加单元测试,确保各场景积分正确 | +| 前端表单逻辑复杂 | 中 | 抽取公共组件,按部门类型条件渲染 | + +--- + +**文档版本**:v1.2 +**创建日期**:2026年1月19日 +**最后更新**:2026年1月19日 +**状态**:已确认,系统集成复核完成,待开发 diff --git a/frontend/src/api/analytics.ts b/frontend/src/api/analytics.ts index 986cb8a..4cb595c 100644 --- a/frontend/src/api/analytics.ts +++ b/frontend/src/api/analytics.ts @@ -54,6 +54,7 @@ export interface OverviewData { monthDemands: number; completedDemands: number; pendingDemands: number; + inProgressDemands: number; // 进行中的任务数 canceledDemands: number; totalScoreUsed: number; todayScoreUsed: number; @@ -501,3 +502,113 @@ export const getDimensionAnalysis = async ( platformAnalysis: [] }; }; + +// ==================== 一级需求部门统计接口 ==================== + +// 一级需求部门统计数据 +export interface PrimaryDeptStatistics { + totalTasks: number; + approvedTasks: number; + rejectedTasks: number; + consumedScore: number; + taskGrowthRate: number; + approvalGrowthRate: number; + rejectGrowthRate: number; + scoreGrowthRate: number; + pendingTasks: number; + inProgressTasks: number; + completedTasks: number; +} + +// 一级需求部门排名 +export interface PrimaryDeptRanking { + rank: number; + deptId: number; + deptName: string; + taskCount: number; + approvalRate: number; + consumedScore: number; + avgResponseHours: number; + completedCount: number; + inProgressCount: number; +} + +// 一级需求流转效率 +export interface PrimaryTaskFlowEfficiency { + avgSubmitToReviewHours: number; + avgReviewToCompleteHours: number; + avgCompleteToPublishHours: number; + avgPublishToFinishHours: number; + avgTotalHours: number; + taskCount: number; +} + +// 品牌任务分布 +export interface BrandTaskDistribution { + brandId: number; + brandName: string; + taskCount: number; + percentage: number; + responsibleDeptId?: number; + responsibleDeptName?: string; + consumedScore: number; + completedCount: number; +} + +// 获取一级需求部门统计数据 +export const getPrimaryDeptStatistics = async ( + startTime: string, + endTime: string, + deptId?: number +): Promise => { + const params: any = { startTime, endTime }; + if (deptId) params.deptId = deptId; + + const response = await request.get('/analytics/primary-dept/statistics', { params }); + if (response && typeof response === 'object' && 'data' in response) { + return response.data as PrimaryDeptStatistics; + } + return response as unknown as PrimaryDeptStatistics; +}; + +// 获取一级需求部门排名 +export const getPrimaryDeptRanking = async ( + startTime: string, + endTime: string +): Promise => { + const params = { startTime, endTime }; + + const response = await request.get('/analytics/primary-dept/ranking', { params }); + if (response && typeof response === 'object' && 'data' in response) { + return response.data as PrimaryDeptRanking[]; + } + return response as unknown as PrimaryDeptRanking[]; +}; + +// 获取一级需求流转效率 +export const getPrimaryTaskFlowEfficiency = async ( + startTime: string, + endTime: string +): Promise => { + const params = { startTime, endTime }; + + const response = await request.get('/analytics/primary-task/flow-efficiency', { params }); + if (response && typeof response === 'object' && 'data' in response) { + return response.data as PrimaryTaskFlowEfficiency; + } + return response as unknown as PrimaryTaskFlowEfficiency; +}; + +// 获取品牌任务分布 +export const getPrimaryTaskBrandDistribution = async ( + startTime: string, + endTime: string +): Promise => { + const params = { startTime, endTime }; + + const response = await request.get('/analytics/primary-task/brand-distribution', { params }); + if (response && typeof response === 'object' && 'data' in response) { + return response.data as BrandTaskDistribution[]; + } + return response as unknown as BrandTaskDistribution[]; +}; diff --git a/frontend/src/api/department.ts b/frontend/src/api/department.ts index 69b0e0d..dd8887a 100644 --- a/frontend/src/api/department.ts +++ b/frontend/src/api/department.ts @@ -1,6 +1,6 @@ import request from '@/utils/request' -export type DeptType = 'demand' | 'receiving' +export type DeptType = 'demand' | 'receiving' | 'primary_demand' export interface DepartmentOverview { id: number @@ -97,3 +97,48 @@ export const getDepartmentList = (params?: any) => { return request.get('/departments', { params }) } +// ==================== 品牌关联管理 ==================== + +export interface Brand { + id: number + name: string + brandCode?: string +} + +export interface DeptBrandRelation { + id: number + deptId: number + brandId: number + deptName?: string + brandName?: string +} + +// 设置部门负责的品牌 +export const setDeptResponsibleBrands = (deptId: number, brandIds: number[]) => { + return request.post(`/departments/${deptId}/responsible-brands`, brandIds) +} + +// 获取部门负责的品牌列表 +export const getDeptResponsibleBrands = (deptId: number) => { + return request.get(`/departments/${deptId}/responsible-brands`) +} + +// 获取部门负责的品牌ID列表 +export const getDeptResponsibleBrandIds = (deptId: number) => { + return request.get(`/departments/${deptId}/responsible-brand-ids`) +} + +// 获取所有品牌-部门关联关系 +export const getAllBrandDeptRelations = () => { + return request.get('/departments/brand-relations') +} + +// 根据品牌ID获取负责的需求部门 +export const getDemandDeptByBrandId = (brandId: number) => { + return request.get(`/departments/by-brand/${brandId}`) +} + +// 获取所有一级需求部门 +export const getPrimaryDemandDepartments = () => { + return request.get('/departments/primary-demand') +} diff --git a/frontend/src/api/task.ts b/frontend/src/api/task.ts index 754e89b..2acd11b 100644 --- a/frontend/src/api/task.ts +++ b/frontend/src/api/task.ts @@ -72,6 +72,15 @@ export interface Task { previewImage?: string // 素材下载链接 downloadUrl?: string + // 一级需求部门相关字段 + primaryPublisherId?: number // 一级部门发起人ID + primaryPublisherDeptId?: number // 一级部门ID + primaryPublisherName?: string // 一级部门发起人姓名 + primaryPublisherDeptName?: string // 一级部门名称 + detailCompleterId?: number // 明细完善人ID + detailCompleterName?: string // 明细完善人姓名 + isPrimaryTask?: number // 是否一级部门任务:0-否,1-是 + detailCompleted?: number // 明细是否已完善:0-否,1-是 } // 任务子项(流水线任务) @@ -755,3 +764,31 @@ export const exportTaskHall = async (params: ExportTaskHallParams = {}) => { document.body.removeChild(link) window.URL.revokeObjectURL(downloadUrl) } + +// ==================== 一级需求部门任务接口 ==================== + +// 审核一级需求部门任务 +export const approvePrimaryTask = (taskId: number, completerId: number, comment?: string) => { + return request.post(`/tasks/${taskId}/approve-primary`, null, { + params: { completerId, comment } + }) +} + +// 驳回一级需求部门任务 +export const rejectPrimaryTask = (taskId: number, reason: string) => { + return request.post(`/tasks/${taskId}/reject-primary`, null, { + params: { reason } + }) +} + +// 完善任务明细 +export interface TaskItemDTO { + taskTypeId: number + quantity: number + receiverId?: number + selectedDeptId?: number +} + +export const completeTaskDetail = (taskId: number, taskItems: TaskItemDTO[]) => { + return request.post(`/tasks/${taskId}/complete-detail`, taskItems) +} diff --git a/frontend/src/views/Analytics.vue b/frontend/src/views/Analytics.vue index ece557b..ef22d24 100644 --- a/frontend/src/views/Analytics.vue +++ b/frontend/src/views/Analytics.vue @@ -581,6 +581,97 @@ + + + +
+ + + +
+
发起任务数
+
{{ primaryDeptStats.totalTasks || 0 }}
+
+
+ +
+
审核通过数
+
{{ primaryDeptStats.approvedTasks || 0 }}
+
+
+ +
+
审核驳回数
+
{{ primaryDeptStats.rejectedTasks || 0 }}
+
+
+ +
+
消耗积分
+
{{ formatScore(primaryDeptStats.consumedScore) }}
+
+
+
+ + +
+
+

一级需求部门排名

+
+ + + +
+ + +
+
+

品牌任务分布(一级需求)

+
+ + + +
+
+
@@ -675,6 +766,9 @@ import { getWarningTasks, getDesignerDeductionDetails, getDimensionAnalysis, + getPrimaryDeptStatistics, + getPrimaryDeptRanking, + getPrimaryTaskBrandDistribution, type DemandStatistics, type DesignerPerformance, type ProcessEfficiency, @@ -682,7 +776,10 @@ import { type DeductionDetail, type DelayDeductionItem, type ProgressDeductionItem, - type DimensionAnalysis + type DimensionAnalysis, + type PrimaryDeptStatistics, + type PrimaryDeptRanking, + type BrandTaskDistribution } from '@/api/analytics' import { message } from 'ant-design-vue' import * as echarts from 'echarts' @@ -788,6 +885,23 @@ const dimensionData = reactive({ platformAnalysis: [] }) +// 一级需求部门统计数据 +const primaryDeptStats = reactive({ + totalTasks: 0, + approvedTasks: 0, + rejectedTasks: 0, + consumedScore: 0, + taskGrowthRate: 0, + approvalGrowthRate: 0, + rejectGrowthRate: 0, + scoreGrowthRate: 0, + pendingTasks: 0, + inProgressTasks: 0, + completedTasks: 0 +}) +const primaryDeptRanking = ref([]) +const brandDistribution = ref([]) + // 部门筛选 const selectedDeptId = ref(undefined) const departmentList = ref([]) @@ -1014,6 +1128,28 @@ const platformColumns = [ { title: '总积分', key: 'totalScore', align: 'center', width: 100 } ] +// 一级需求部门排名列 +const primaryDeptColumns = [ + { title: '排名', key: 'rank', dataIndex: 'rank', align: 'center', width: 60 }, + { title: '部门名称', dataIndex: 'deptName', key: 'deptName', ellipsis: true }, + { title: '发起任务数', dataIndex: 'taskCount', key: 'taskCount', align: 'center', width: 100 }, + { title: '审核通过率', key: 'approvalRate', align: 'center', width: 100 }, + { title: '消耗积分', key: 'consumedScore', align: 'center', width: 100 }, + { title: '平均响应时间', key: 'avgResponseHours', align: 'center', width: 120 }, + { title: '已完成', dataIndex: 'completedCount', key: 'completedCount', align: 'center', width: 80 }, + { title: '进行中', dataIndex: 'inProgressCount', key: 'inProgressCount', align: 'center', width: 80 } +] + +// 品牌任务分布列 +const brandDistributionColumns = [ + { title: '品牌名称', dataIndex: 'brandName', key: 'brandName', ellipsis: true }, + { title: '任务数', dataIndex: 'taskCount', key: 'taskCount', align: 'center', width: 80 }, + { title: '占比', key: 'percentage', align: 'center', width: 150 }, + { title: '负责部门', dataIndex: 'responsibleDeptName', key: 'responsibleDeptName', ellipsis: true }, + { title: '消耗积分', key: 'consumedScore', align: 'center', width: 100 }, + { title: '已完成', dataIndex: 'completedCount', key: 'completedCount', align: 'center', width: 80 } +] + // 方法 const handleMonthChange = (value: string | null) => { selectedMonth.value = value @@ -1038,6 +1174,33 @@ const loadAllData = async () => { try { const month = selectedMonth.value || undefined + // 计算时间范围(用于一级需求部门统计) + const now = dayjs() + let startTime: string + let endTime: string = now.format('YYYY-MM-DDTHH:mm:ss') + + if (month) { + startTime = dayjs(month).startOf('month').format('YYYY-MM-DDTHH:mm:ss') + endTime = dayjs(month).endOf('month').format('YYYY-MM-DDTHH:mm:ss') + } else { + switch (dateRange.value) { + case 'week': + startTime = now.subtract(7, 'day').format('YYYY-MM-DDTHH:mm:ss') + break + case 'quarter': + startTime = now.subtract(3, 'month').format('YYYY-MM-DDTHH:mm:ss') + break + case 'year': + startTime = now.subtract(1, 'year').format('YYYY-MM-DDTHH:mm:ss') + break + case 'all': + startTime = '2020-01-01T00:00:00' + break + default: // month + startTime = now.subtract(1, 'month').format('YYYY-MM-DDTHH:mm:ss') + } + } + // 并行加载所有数据 const [demandData, designerData, efficiencyData, warningsData, dimensionAnalysisData] = await Promise.all([ getDemandStatistics(dateRange.value, undefined, undefined, month), @@ -1062,6 +1225,20 @@ const loadAllData = async () => { // 更新多维度分析数据 Object.assign(dimensionData, dimensionAnalysisData) + // 加载一级需求部门统计数据 + try { + const [primaryStats, primaryRanking, brandDist] = await Promise.all([ + getPrimaryDeptStatistics(startTime, endTime), + getPrimaryDeptRanking(startTime, endTime), + getPrimaryTaskBrandDistribution(startTime, endTime) + ]) + Object.assign(primaryDeptStats, primaryStats) + primaryDeptRanking.value = primaryRanking + brandDistribution.value = brandDist + } catch (e) { + console.warn('加载一级需求部门统计失败:', e) + } + await nextTick() // 渲染当前Tab的图表 diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue index 7f9575f..657565e 100644 --- a/frontend/src/views/Settings.vue +++ b/frontend/src/views/Settings.vue @@ -2010,9 +2010,25 @@ 需求部门(只能发需求) + 一级需求部门(发起简化需求) 接单部门(只能接任务) + + + +
+ 一个品牌只能由一个需求部门负责,一级需求部门发起任务时会根据选择的品牌自动路由到对应的需求部门 +
+
({ periodLimit: 500, alertThreshold: 80, overLimitAction: 'alert', - initialScoreBalance: 0 + initialScoreBalance: 0, + responsibleBrandIds: [] }) const formData = reactive({ @@ -2852,6 +2872,19 @@ const receivingDepartments = computed(() => { return departments.value.filter(dept => dept.deptType === 'receiving') }) +// 品牌选项(用于需求部门的负责品牌选择) +const brandOptions = computed(() => { + return brands.value.map(brand => ({ + label: brand.name || brand.brandName, + value: brand.id + })) +}) + +// 品牌搜索过滤 +const filterBrandOption = (input: string, option: any) => { + return option.label?.toLowerCase().includes(input.toLowerCase()) +} + // 一级分类列表(parentId为null的需求类型) const level1DemandTypes = computed(() => { return demandTypes.value.filter(dt => !dt.parentId) @@ -3814,6 +3847,17 @@ const showDepartmentModal = async (item?: DepartmentOverview) => { } editingItem.value = item ?? null if (item) { + // 加载已有的品牌关联 + let responsibleBrandIds: number[] = [] + if (item.deptType === 'demand' && item.id) { + try { + const brandIdsResult = await getDeptResponsibleBrandIds(item.id) + responsibleBrandIds = brandIdsResult?.data || brandIdsResult || [] + } catch (e) { + console.warn('加载品牌关联失败:', e) + } + } + formData.department = { ...defaultDepartmentForm(), id: item.id, @@ -3831,7 +3875,8 @@ const showDepartmentModal = async (item?: DepartmentOverview) => { periodLimit: item.periodLimit ?? undefined, alertThreshold: item.alertThreshold ?? 80, overLimitAction: (item.overLimitAction as 'alert' | 'block') || 'alert', - initialScoreBalance: item.scoreBalance ?? 0 + initialScoreBalance: item.scoreBalance ?? 0, + responsibleBrandIds } } else { formData.department = defaultDepartmentForm() @@ -4015,7 +4060,8 @@ const saveCategory = async () => { } } -const isDemandDept = computed(() => formData.department.deptType === 'demand') +const isDemandDept = computed(() => formData.department.deptType === 'demand' || formData.department.deptType === 'primary_demand') +const isPrimaryDemandDept = computed(() => formData.department.deptType === 'primary_demand') const isReceivingDept = computed(() => formData.department.deptType === 'receiving') // Computed属性用于处理leaderId和parentId的null到undefined的转换,以兼容a-select @@ -4056,14 +4102,30 @@ const saveDepartment = async () => { try { const isEdit = !!editingItem.value?.id const payload = buildDepartmentPayload(!isEdit) + let deptId: number + if (isEdit && editingItem.value?.id) { delete payload.initialScoreBalance await updateDepartmentApi(editingItem.value.id, payload) + deptId = editingItem.value.id message.success('更新成功') } else { - await createDepartmentApi(payload) + const result = await createDepartmentApi(payload) + deptId = result?.data?.id || result?.id message.success('创建成功') } + + // 如果是需求部门,保存负责的品牌 + if (formData.department.deptType === 'demand' && deptId) { + const brandIds = formData.department.responsibleBrandIds || [] + try { + await setDeptResponsibleBrands(deptId, brandIds) + } catch (brandError: any) { + console.error('保存品牌关联失败:', brandError) + message.warning('部门保存成功,但品牌关联保存失败:' + (brandError?.response?.data?.message || brandError?.message)) + } + } + closeModal('department') await loadDepartments() } catch (error: any) { diff --git a/frontend/src/views/TaskHall.vue b/frontend/src/views/TaskHall.vue index fb53c08..44ca8dd 100644 --- a/frontend/src/views/TaskHall.vue +++ b/frontend/src/views/TaskHall.vue @@ -220,6 +220,13 @@ 发布人: {{ task.publisherName || '未知' }} + +
+ + + + 一级需求人: {{ task.primaryPublisherName }} ({{ task.primaryPublisherDeptName || '一级部门' }}) +
@@ -416,6 +423,13 @@ 发起人: {{ task.publisherName || '--' }}
+ +
+ + + + 一级需求人: {{ task.primaryPublisherName }} +
@@ -986,6 +1000,13 @@ 发起人: {{ task.publisherName || '--' }}
+ +
+ + + + 一级需求人: {{ task.primaryPublisherName }} +
diff --git a/更新日志_2026-01-19.md b/更新日志_2026-01-19.md new file mode 100644 index 0000000..6dcac51 --- /dev/null +++ b/更新日志_2026-01-19.md @@ -0,0 +1,100 @@ +# 系统更新日志 - 2026年1月19日 + +## 📋 本次更新内容 + +### 1. 🔄 任务撤回编辑功能(新增) + +**功能说明:** +- 需求部门用户可以在"我发起的任务"列表中撤回尚未被抢单的任务进行编辑 +- 撤回后任务变为草稿状态,可重新编辑后发布 +- 撤回时会自动返还已扣除的部门积分 + +**使用条件:** +- 任务状态为"待接单"或"待审核" +- 任务尚未被设计师抢单 +- 只有任务发起人可以操作 + +**操作步骤:** +1. 进入任务大厅 → "我发起的任务"标签页 +2. 找到需要撤回的任务,点击"撤回编辑"按钮 +3. 确认撤回后,系统自动跳转到任务编辑页面 +4. 编辑完成后重新提交发布 + +--- + +### 2. 🔔 通知"全部已读"功能修复 + +**问题描述:** +- 点击通知列表中的"全部已读"按钮时报错 + +**修复内容:** +- 修复了数据库表名引用错误的问题 +- 现在可以正常一键标记所有通知为已读 + +--- + +### 3. 📊 数据分析页面"积分消耗趋势"图表修复 + +**问题描述:** +- 数据分析页面的"积分消耗趋势"图表显示数据全为0 + +**修复内容:** +- 修正了数据源字段的引用 +- 现在图表可以正确显示每日的积分消耗数据 + +--- + +### 4. 📉 设计师绩效扣分明细数据修复 + +**问题描述:** +- 数据分析页面 → 设计师绩效 → 点击设计师的扣分数值,弹窗显示的扣分明细与排行榜显示的扣分总数不一致 +- 例如:排行榜显示某设计师有扣分,但扣分明细弹窗显示为0 + +**修复内容:** +- 统一了扣分计算逻辑,确保排行榜扣分和扣分明细弹窗的数据一致 +- 修正了子任务截止时间的计算方式 + +--- + +### 5. ⏰ 扣分明细增加"抢单时间"字段 + +**功能说明:** +- 在设计师绩效的扣分明细弹窗中,新增显示"抢单时间"列 +- 方便查看任务的完整时间线:抢单时间 → 截止时间 → 实际完成时间 + +--- + +## 📝 使用说明 + +### 任务撤回编辑功能详解 + +| 任务状态 | 是否可撤回 | 说明 | +|---------|-----------|------| +| 待接单 | ✅ 可以 | 审核通过但未被抢单的任务 | +| 待审核 | ✅ 可以 | 提交后等待审核的任务 | +| 进行中 | ❌ 不可以 | 已被设计师接单 | +| 已完成 | ❌ 不可以 | 任务已完结 | +| 草稿 | - | 使用"重新编辑提交"按钮 | +| 已驳回 | - | 使用"重新编辑提交"按钮 | + +**注意事项:** +- 撤回"待接单"状态的任务时,系统会自动返还已扣除的部门积分 +- 撤回后的任务需要重新提交审核 + +--- + +## 🔧 技术更新 + +- 后端新增 `/api/tasks/{id}/withdraw` 接口 +- 前端任务大厅页面新增撤回按钮和相关逻辑 +- 修复 NotificationMapper 表名引用错误 +- 修复 AnalyticsService 积分数据字段引用 +- 统一设计师绩效扣分计算逻辑 +- 扣分明细VO新增 claimTime 字段 + +--- + +如有任何问题,请及时反馈! + +**更新时间:** 2026年1月19日 +**版本:** v1.x.x