Pārlūkot izejas kodu

feat: 实现人员排期统计接口及任务完成优化

- 实现 GetScheduleStats 排期统计服务方法

- 添加排期统计 handler 接口 GetScheduleStats

- 优化任务列表查询:支持多项目ID和排期状态筛选

- 任务完成时支持指定完成日期并自动登记工时差额

- 优化工时看板数据查询逻辑

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
程健 1 mēnesi atpakaļ
vecāks
revīzija
43654a2d35

+ 17 - 0
opms_parent/app/handler/opsdev/ops_event_task.go

@@ -306,3 +306,20 @@ func (h *OpsEventTaskHandler) GetWorkHourDashboardData(ctx context.Context, req
 	return nil
 }
 
+// GetScheduleStats 人员排期统计
+func (h *OpsEventTaskHandler) GetScheduleStats(ctx context.Context, req *opsdevmodel.OpsEventTaskScheduleStatReq, rsp *comm_def.CommonMsg) error {
+	if err := gvalid.CheckStruct(ctx, req, nil); err != nil {
+		return myerrors.ValidError(err.Error())
+	}
+	s, err := services.NewOpsEventTaskService(ctx)
+	if err != nil {
+		return err
+	}
+	result, err := s.GetScheduleStats(req)
+	if err != nil {
+		return err
+	}
+	rsp.Data = result
+	return nil
+}
+

+ 21 - 1
opms_parent/app/service/opsdev/operation.go

@@ -36,6 +36,7 @@ type OperationService struct {
 	AttachmentDao                *opsdevdao.OpsOperationEventAttachmentDao
 	DeliveryProjectAttachmentDao *opsdevdao.OpsDeliveryProjectEventAttachmentDao
 	WorkHourDao                  *opsdevdao.OpsOperationWorkHourDao
+	DeliveryProjectDao           *opsdevdao.OpsDeliveryProjectDao
 }
 
 func NewOperationService(ctx context.Context) (svc *OperationService, err error) {
@@ -48,6 +49,7 @@ func NewOperationService(ctx context.Context) (svc *OperationService, err error)
 	svc.AttachmentDao = opsdevdao.NewOpsOperationEventAttachmentDao(svc.Tenant)
 	svc.DeliveryProjectAttachmentDao = opsdevdao.NewOpsDeliveryProjectEventAttachmentDao(svc.Tenant)
 	svc.WorkHourDao = opsdevdao.NewOpsOperationWorkHourDao(svc.Tenant)
+	svc.DeliveryProjectDao = opsdevdao.NewOpsDeliveryProjectDao(svc.Tenant)
 	return svc, nil
 }
 
@@ -792,8 +794,26 @@ func (s *OperationService) createDevTaskFromEvent(event *opsdevmodel.OpsOperatio
 	}
 
 	taskType := s.getDevTaskType(event.EventType)
+
+	projectId := 0
+	if event.ContractId > 0 {
+		var project opsdevmodel.OpsDeliveryProject
+		err := s.DeliveryProjectDao.FieldsEx(s.DeliveryProjectDao.Columns.DeletedTime).
+			Where(s.DeliveryProjectDao.Columns.ContractId, event.ContractId).
+			OrderDesc(s.DeliveryProjectDao.Columns.Id).
+			Limit(1).
+			Scan(&project)
+		if err != nil {
+			g.Log().Warningf("createDevTaskFromEvent: query project by contractId=%d failed, err=%v", event.ContractId, err)
+		} else if project.Id > 0 {
+			projectId = project.Id
+		} else {
+			g.Log().Warningf("createDevTaskFromEvent: no project found for contractId=%d", event.ContractId)
+		}
+	}
+
 	taskReq := &opsdevmodel.OpsEventTaskAddReq{
-		ProjectId:   0,
+		ProjectId:   projectId,
 		TaskTitle:   event.EventTitle,
 		TaskDesc:    event.EventDesc,
 		TaskType:    taskType,

+ 215 - 90
opms_parent/app/service/opsdev/ops_event_task.go

@@ -3,6 +3,7 @@ package opsdev
 import (
 	"context"
 	"fmt"
+	"math"
 	"strings"
 
 	"dashoo.cn/opms_libary/myerrors"
@@ -49,11 +50,16 @@ func NewOpsEventTaskService(ctx context.Context) (svc *OpsEventTaskService, err
 func (s *OpsEventTaskService) GetList(req *opsdevmodel.OpsEventTaskSearchReq) (total int, list []*opsdevmodel.OpsEventTaskRsp, err error) {
 	db := s.TaskDao.FieldsEx(s.TaskDao.Columns.DeletedTime)
 
-	// 项目ID筛选
+	// 项目ID筛选(单选)
 	if req.ProjectId > 0 {
 		db = db.Where(s.TaskDao.Columns.ProjectId, req.ProjectId)
 	}
 
+	// 项目ID列表筛选(多选,优先于单选)
+	if len(req.ProjectIds) > 0 {
+		db = db.Where(s.TaskDao.Columns.ProjectId+" in (?)", req.ProjectIds)
+	}
+
 	// 关联事件ID筛选
 	if req.EventId > 0 {
 		db = db.Where(s.TaskDao.Columns.EventId, req.EventId)
@@ -89,6 +95,13 @@ func (s *OpsEventTaskService) GetList(req *opsdevmodel.OpsEventTaskSearchReq) (t
 		db = db.Where(s.TaskDao.Columns.ReleaseVersion + " is null")
 	}
 
+	// 排期状态筛选(plan_start_time/plan_end_time 为 datetime 类型,需用 IS NULL)
+	if req.ScheduleStatus == "scheduled" {
+		db = db.Where(s.TaskDao.Columns.PlanStartTime + " is not null").Where(s.TaskDao.Columns.PlanEndTime + " is not null")
+	} else if req.ScheduleStatus == "unscheduled" {
+		db = db.Where(s.TaskDao.Columns.PlanStartTime + " is null or " + s.TaskDao.Columns.PlanEndTime + " is null")
+	}
+
 	// 计划结束日期范围筛选
 	if req.PlanEndDateStart != "" {
 		db = db.Where(s.TaskDao.Columns.PlanEndTime+" >= ?", req.PlanEndDateStart+" 00:00:00")
@@ -595,10 +608,18 @@ func (s *OpsEventTaskService) Complete(req *opsdevmodel.OpsEventTaskCompleteReq)
 
 // doComplete 执行完成任务(可在下游任务编号冲突时重试)
 func (s *OpsEventTaskService) doComplete(req *opsdevmodel.OpsEventTaskCompleteReq, entity *opsdevmodel.OpsEventTask) error {
+	// 解析完成日期
+	completeTime := gtime.Now()
+	if req.CompleteDate != "" {
+		if parsed := gtime.NewFromStrFormat(req.CompleteDate, "Y-m-d"); parsed != nil {
+			completeTime = parsed
+		}
+	}
+
 	// 构造更新数据
 	data := g.Map{
 		s.TaskDao.Columns.TaskStatus:     opsdevmodel.TaskStatusCompleted, // 已完成
-		s.TaskDao.Columns.CompleteTime:   gtime.Now(),
+		s.TaskDao.Columns.CompleteTime:   completeTime,
 		s.TaskDao.Columns.ActualWorkHour: req.ActualWorkHour,
 		s.TaskDao.Columns.Remark:         req.Remark,
 	}
@@ -624,7 +645,26 @@ func (s *OpsEventTaskService) doComplete(req *opsdevmodel.OpsEventTaskCompleteRe
 			return myerrors.DbError("完成任务失败")
 		}
 
-		// 2. 创建过程记录
+		// 2. 检查实际工时差异,自动登记工时差额
+		delta := req.ActualWorkHour - entity.ActualWorkHour
+		if delta != 0 {
+			workHourData := g.Map{
+				s.WorkHourDao.Columns.TaskId:         req.Id,
+				s.WorkHourDao.Columns.OpsUserId:      s.GetCxtUserId(),
+				s.WorkHourDao.Columns.OpsUserName:    s.GetCxtUserName(),
+				s.WorkHourDao.Columns.ActualWorkDate: completeTime.Format("Y-m-d"),
+				s.WorkHourDao.Columns.ActualWorkHour: delta,
+				s.WorkHourDao.Columns.Remark:         "完成任务自动登记工时差额",
+			}
+			service.SetCreatedInfo(workHourData, s.GetCxtUserId(), s.GetCxtUserName())
+			_, err = s.WorkHourDao.TX(tx).Data(workHourData).Insert()
+			if err != nil {
+				g.Log().Error(err)
+				return myerrors.DbError("登记工时差额失败")
+			}
+		}
+
+		// 3. 创建过程记录
 		var handleContent string
 		if entity.TaskType == opsdevmodel.TaskTypeFeatureTest && req.TestResult != "" {
 			testResultText := "通过"
@@ -662,7 +702,7 @@ func (s *OpsEventTaskService) doComplete(req *opsdevmodel.OpsEventTaskCompleteRe
 
 		recordId, _ := result.LastInsertId()
 
-		// 3. 保存附件
+		// 4. 保存附件
 		if len(req.Attachments) > 0 {
 			for _, att := range req.Attachments {
 				attData := g.Map{
@@ -682,8 +722,8 @@ func (s *OpsEventTaskService) doComplete(req *opsdevmodel.OpsEventTaskCompleteRe
 			}
 		}
 
-		// 4. 根据任务类型自动创建下游任务
-		// 4.1 功能开发完成时,自动创建功能测试任务(原4.2)
+		// 5. 根据任务类型自动创建下游任务
+		// 5.1 功能开发完成时,自动创建功能测试任务(原4.2)
 		if entity.TaskType == opsdevmodel.TaskTypeFeatureDev {
 			testTaskData := g.Map{
 				s.TaskDao.Columns.TaskNo:       testTaskNo,
@@ -728,7 +768,7 @@ func (s *OpsEventTaskService) doComplete(req *opsdevmodel.OpsEventTaskCompleteRe
 			}
 		}
 
-		// 4.2b BUG任务完成时,自动创建功能测试任务
+		// 5.2b BUG任务完成时,自动创建功能测试任务
 		if entity.TaskType == opsdevmodel.TaskTypeBug {
 			testTaskData := g.Map{
 				s.TaskDao.Columns.TaskNo:       testTaskNo,
@@ -785,7 +825,7 @@ func (s *OpsEventTaskService) doComplete(req *opsdevmodel.OpsEventTaskCompleteRe
 			}
 		}
 
-		// 4.3 系统发版完成时,保存关联的研发任务到ops_event_task_release表
+		// 5.3 系统发版完成时,保存关联的研发任务到ops_event_task_release表
 		if entity.TaskType == opsdevmodel.TaskTypeSystemReleaseEvt && req.IsReleaseComplete && len(req.DevTaskIds) > 0 {
 			for _, devTaskId := range req.DevTaskIds {
 				releaseData := g.Map{
@@ -803,7 +843,7 @@ func (s *OpsEventTaskService) doComplete(req *opsdevmodel.OpsEventTaskCompleteRe
 			}
 		}
 
-		// 5. 任务完成时自动更新关联的交付事件状态为完成并生成过程记录
+		// 6. 任务完成时自动更新关联的交付事件状态为完成并生成过程记录
 		if entity.EventId > 0 && entity.EventType == opsdevmodel.EventTypeDelivery {
 			// 触发事件自动完成的任务类型:10(需求评审)、30(功能测试-通过)、38(系统发版)、40(系统发版/硬件发货)、41(硬件安装)
 			if s.shouldAutoCompleteEvent(entity.TaskType, req.TestResult) {
@@ -1340,89 +1380,85 @@ func (s *OpsEventTaskService) GetWorkHourList(req *opsdevmodel.OpsEventTaskWorkH
 
 // GetDashboardData 获取工作台看板数据(周视图)
 func (s *OpsEventTaskService) GetDashboardData(startDate, endDate string) (*opsdevmodel.OpsEventTaskWorkHourDashboardRsp, error) {
-	type workHourRow struct {
-		TaskId         int     `json:"taskId"`
-		ActualWorkDate string  `json:"actualWorkDate"`
-		ActualWorkHour float64 `json:"actualWorkHour"`
-		OpsUserId      int     `json:"opsUserId"`
-		TaskNo         string  `json:"taskNo"`
-		TaskTitle      string  `json:"taskTitle"`
-		TaskType       string  `json:"taskType"`
-		TaskStatus     string  `json:"taskStatus"`
-		Priority       string  `json:"priority"`
-		ProjectName    string  `json:"projectName"`
-		TaskActualHour float64 `json:"taskActualHour"`
-		EstimateHour   float64 `json:"estimateHour"`
-		PlanEndTime    string  `json:"planEndTime"`
-	}
-
 	userId := s.GetCxtUserId()
 	db := g.DB(s.Tenant)
-	sql := `
-		SELECT
-			wh.task_id, DATE(wh.actual_work_date) AS actual_work_date, wh.actual_work_hour, wh.ops_user_id,
-			t.id, t.task_no, t.task_title, t.task_type, t.task_status,
-			t.priority, t.project_name, t.actual_work_hour AS task_actual_hour,
-			t.estimate_work_hour, IFNULL(t.plan_end_time, '') AS plan_end_time
-		FROM ops_event_task_work_hour wh
-		LEFT JOIN ops_event_task t ON wh.task_id = t.id AND t.deleted_time IS NULL
-		WHERE wh.actual_work_date >= ? AND wh.actual_work_date <= ?
-		  AND wh.ops_user_id = ?
-		  AND wh.deleted_time IS NULL
-		ORDER BY wh.task_id, wh.actual_work_date
-	`
 
-	var rows []workHourRow
-	err := db.GetScan(&rows, sql, startDate, endDate+" 23:59:59", userId)
+	type taskRow struct {
+		Id               int     `json:"id"`
+		TaskNo           string  `json:"taskNo"`
+		TaskTitle        string  `json:"taskTitle"`
+		TaskType         string  `json:"taskType"`
+		TaskStatus       string  `json:"taskStatus"`
+		Priority         string  `json:"priority"`
+		ProjectName      string  `json:"projectName"`
+		ActualWorkHour   float64 `json:"actualWorkHour"`
+		EstimateWorkHour float64 `json:"estimateWorkHour"`
+		PlanStartDate    string  `json:"planStartDate"`
+		PlanEndTime      string  `json:"planEndTime"`
+	}
+	taskSQL := `
+		SELECT id, task_no, task_title, task_type, task_status,
+			priority, project_name, actual_work_hour, estimate_work_hour,
+			DATE(plan_start_time) AS plan_start_date,
+			IFNULL(DATE(plan_end_time), '') AS plan_end_time
+		FROM ops_event_task
+		WHERE ops_user_id = ?
+		  AND plan_start_time >= ? AND plan_start_time <= ?
+		  AND deleted_time IS NULL
+		ORDER BY plan_start_time, priority
+	`
+	var taskRows []taskRow
+	err := db.GetScan(&taskRows, taskSQL, userId, startDate, endDate+" 23:59:59")
 	if err != nil {
 		g.Log().Error(err)
-		return nil, myerrors.DbError("查询工时数据失败")
+		return nil, myerrors.DbError("查询任务数据失败")
 	}
 
-	type dayAcc struct {
-		totalHours float64
-		taskMap    map[int]*opsdevmodel.DashboardTaskRsp
+	type hourRow struct {
+		WorkDate  string  `json:"workDate"`
+		TotalHour float64 `json:"totalHour"`
 	}
-
-	dayMap := make(map[string]*dayAcc, 7)
-	seenTasks := make(map[int]bool)
-
-	for i := range rows {
-		row := &rows[i]
-		date := row.ActualWorkDate
-		if _, ok := dayMap[date]; !ok {
-			dayMap[date] = &dayAcc{taskMap: make(map[int]*opsdevmodel.DashboardTaskRsp)}
-		}
-		acc := dayMap[date]
-		acc.totalHours += row.ActualWorkHour
-
-		if _, ok := seenTasks[row.TaskId]; !ok {
-			seenTasks[row.TaskId] = true
-		}
-		if _, ok := acc.taskMap[row.TaskId]; !ok {
-			planEndTime := ""
-			if len(row.PlanEndTime) >= 10 {
-				planEndTime = row.PlanEndTime[:10]
-			}
-			acc.taskMap[row.TaskId] = &opsdevmodel.DashboardTaskRsp{
-				Id:               row.TaskId,
-				TaskNo:           row.TaskNo,
-				TaskTitle:        row.TaskTitle,
-				TaskType:         row.TaskType,
-				TaskStatus:       row.TaskStatus,
-				Priority:         row.Priority,
-				ProjectName:      row.ProjectName,
-				ActualWorkHour:   row.TaskActualHour,
-				EstimateWorkHour: row.EstimateHour,
-				PlanEndTime:      planEndTime,
-			}
-		}
+	hourSQL := `
+		SELECT DATE(actual_work_date) AS work_date, SUM(actual_work_hour) AS total_hour
+		FROM ops_event_task_work_hour
+		WHERE ops_user_id = ?
+		  AND actual_work_date >= ? AND actual_work_date <= ?
+		  AND deleted_time IS NULL
+		GROUP BY DATE(actual_work_date)
+	`
+	var hourRows []hourRow
+	err = db.GetScan(&hourRows, hourSQL, userId, startDate, endDate+" 23:59:59")
+	if err != nil {
+		g.Log().Error(err)
+		return nil, myerrors.DbError("查询每日工时失败")
+	}
+
+	hourMap := make(map[string]float64, 7)
+	for i := range hourRows {
+		hourMap[hourRows[i].WorkDate] = hourRows[i].TotalHour
+	}
+
+	taskMap := make(map[string][]*opsdevmodel.DashboardTaskRsp, 7)
+	for i := range taskRows {
+		row := &taskRows[i]
+		date := row.PlanStartDate
+		taskMap[date] = append(taskMap[date], &opsdevmodel.DashboardTaskRsp{
+			Id:               row.Id,
+			TaskNo:           row.TaskNo,
+			TaskTitle:        row.TaskTitle,
+			TaskType:         row.TaskType,
+			TaskStatus:       row.TaskStatus,
+			Priority:         row.Priority,
+			ProjectName:      row.ProjectName,
+			ActualWorkHour:   row.ActualWorkHour,
+			EstimateWorkHour: row.EstimateWorkHour,
+			PlanEndTime:      row.PlanEndTime,
+		})
 	}
 
-	userName := s.GetCxtUserName()
 	overdueCount, err := db.GetValue(
-		"SELECT COUNT(1) FROM ops_event_task WHERE plan_end_time < NOW() AND ops_user_name = ? AND task_status NOT IN (?,?) AND deleted_time IS NULL",
-		userName,
+		"SELECT COUNT(1) FROM ops_event_task WHERE plan_end_time < NOW() AND ops_user_id = ? AND task_status NOT IN (?,?) AND deleted_time IS NULL",
+		userId,
 		opsdevmodel.TaskStatusCompleted, opsdevmodel.TaskStatusCancelled,
 	)
 	if err != nil {
@@ -1442,20 +1478,17 @@ func (s *OpsEventTaskService) GetDashboardData(startDate, endDate string) (*opsd
 		if i >= 5 {
 			targetHours = 0
 		}
-		totalHours := 0.0
-		var tasks []*opsdevmodel.DashboardTaskRsp
-		if acc, ok := dayMap[date]; ok {
-			totalHours = acc.totalHours
-			for _, t := range acc.taskMap {
-				tasks = append(tasks, t)
-			}
-		}
+		totalHours := hourMap[date]
 		weekTotal += totalHours
+		dayTasks := taskMap[date]
+		if dayTasks == nil {
+			dayTasks = []*opsdevmodel.DashboardTaskRsp{}
+		}
 		days = append(days, &opsdevmodel.DashboardDayRsp{
 			Date:        date,
 			TotalHours:  totalHours,
 			TargetHours: targetHours,
-			Tasks:       tasks,
+			Tasks:       dayTasks,
 		})
 	}
 
@@ -1473,4 +1506,96 @@ func (s *OpsEventTaskService) canAddWorkHour(status string) bool {
 	return status == opsdevmodel.TaskStatusProcessing
 }
 
+// GetScheduleStats 获取人员排期统计(按周、按天、按人汇总任务数和预估工时)
+func (s *OpsEventTaskService) GetScheduleStats(req *opsdevmodel.OpsEventTaskScheduleStatReq) (*opsdevmodel.OpsEventTaskScheduleStatRsp, error) {
+	type dayRow struct {
+		OpsUserId        int     `json:"opsUserId"`
+		OpsUserName      string  `json:"opsUserName"`
+		PlanDate         string  `json:"planDate"`
+		TaskCount        int     `json:"taskCount"`
+		EstimateWorkHour float64 `json:"estimateWorkHour"`
+	}
+
+	sql := `
+		SELECT
+			ops_user_id,
+			ops_user_name,
+			DATE(plan_start_time) AS plan_date,
+			COUNT(*) AS task_count,
+			SUM(COALESCE(estimate_work_hour, 0)) AS estimate_work_hour
+		FROM ops_event_task
+		WHERE deleted_time IS NULL
+			AND task_status != ?
+			AND plan_start_time >= ?
+			AND plan_start_time < ?
+	`
+
+	args := []interface{}{opsdevmodel.TaskStatusCancelled, req.WeekStart, req.WeekEnd + " 23:59:59"}
+
+	if req.ProjectId > 0 {
+		sql += " AND project_id = ?"
+		args = append(args, req.ProjectId)
+	}
+
+	sql += `
+		GROUP BY ops_user_id, ops_user_name, DATE(plan_start_time)
+		ORDER BY ops_user_name, plan_date
+	`
+
+	var rows []dayRow
+	err := g.DB(s.Tenant).GetScan(&rows, sql, args...)
+	if err != nil {
+		g.Log().Error(err)
+		return nil, myerrors.DbError("查询排期统计数据失败")
+	}
+
+	// 按用户聚合,补全 7 天数据
+	userMap := make(map[int]*opsdevmodel.OpsEventTaskUserScheduleStat)
+	for _, row := range rows {
+		user, ok := userMap[row.OpsUserId]
+		if !ok {
+			user = &opsdevmodel.OpsEventTaskUserScheduleStat{
+				OpsUserId:   row.OpsUserId,
+				OpsUserName: row.OpsUserName,
+				DayStats:    make([]*opsdevmodel.OpsEventTaskUserDayStat, 7),
+			}
+			// 初始化 7 天空数据
+			weekStart := gtime.New(req.WeekStart)
+			for i := 0; i < 7; i++ {
+				day := weekStart.AddDate(0, 0, i)
+				user.DayStats[i] = &opsdevmodel.OpsEventTaskUserDayStat{
+					Date:             day.Format("Y-m-d"),
+					TaskCount:        0,
+					EstimateWorkHour: 0,
+				}
+			}
+			userMap[row.OpsUserId] = user
+		}
+
+		// 查找对应 day index
+		for _, ds := range user.DayStats {
+			if ds.Date == row.PlanDate {
+				ds.TaskCount = row.TaskCount
+				ds.EstimateWorkHour = row.EstimateWorkHour
+				break
+			}
+		}
+	}
+
+	// 计算周合计
+	list := make([]*opsdevmodel.OpsEventTaskUserScheduleStat, 0, len(userMap))
+	for _, user := range userMap {
+		weekTotal := &opsdevmodel.OpsEventTaskUserDayStat{Date: "合计"}
+		for _, ds := range user.DayStats {
+			weekTotal.TaskCount += ds.TaskCount
+			weekTotal.EstimateWorkHour += ds.EstimateWorkHour
+		}
+		weekTotal.EstimateWorkHour = math.Round(weekTotal.EstimateWorkHour*10) / 10
+		user.WeekTotal = weekTotal
+		list = append(list, user)
+	}
+
+	return &opsdevmodel.OpsEventTaskScheduleStatRsp{List: list}, nil
+}
+