Selaa lähdekoodia

feat: 工时统计模块、看板数据接口、运维事件处理优化

- 新增工时统计功能:Handler/Service/Model(周/月维度,运维+研发工时统计)
- 新增看板 GetWorkHourDashboardData 接口(含用户过滤、超期统计)
- 运维事件处理增加 operate_type 字段写入,修复 Insert 1364 错误
- 运维事件 Service 增加工时记录关联(WorkHourDao)
- 运维事件类型映射 eventTypeToDevTaskType
- 运维事件操作、任务工时管理增强
- 新增 DAO/Model:ops_operation_work_hour
- 数据库迁移文件:migration_ops_work_hour.sql
- 注册 WorkHourStat Handler
程健 1 kuukausi sitten
vanhempi
commit
4e4b3a9dae
25 muutettua tiedostoa jossa 1943 lisäystä ja 589 poistoa
  1. 41 38
      opms_parent/app/dao/opsdev/internal/ops_operation_event.go
  2. 435 0
      opms_parent/app/dao/opsdev/internal/ops_operation_work_hour.go
  3. 36 0
      opms_parent/app/dao/opsdev/ops_operation_work_hour.go
  4. 14 0
      opms_parent/app/handler/cust/product_consult_record.go
  5. 32 0
      opms_parent/app/handler/opsdev/operation.go
  6. 18 0
      opms_parent/app/handler/opsdev/ops_event_task.go
  7. 28 0
      opms_parent/app/handler/opsdev/work_hour_stat.go
  8. 7 0
      opms_parent/app/model/cust/product_consult_record.go
  9. 1 0
      opms_parent/app/model/opsdev/internal/ops_operation_event.go
  10. 27 0
      opms_parent/app/model/opsdev/internal/ops_operation_work_hour.go
  11. 30 4
      opms_parent/app/model/opsdev/operation.go
  12. 2 0
      opms_parent/app/model/opsdev/ops_delivery_project.go
  13. 41 0
      opms_parent/app/model/opsdev/ops_event_task_work_hour_dashboard.go
  14. 12 0
      opms_parent/app/model/opsdev/ops_operation_work_hour.go
  15. 36 0
      opms_parent/app/model/opsdev/work_hour_stat.go
  16. 32 21
      opms_parent/app/service/contract/ctr_contract.go
  17. 51 0
      opms_parent/app/service/cust/product_consult_record.go
  18. 26 0
      opms_parent/app/service/opsdev/delivery_project.go
  19. 127 14
      opms_parent/app/service/opsdev/delivery_project_event.go
  20. 613 458
      opms_parent/app/service/opsdev/operation.go
  21. 146 50
      opms_parent/app/service/opsdev/ops_event_task.go
  22. 154 0
      opms_parent/app/service/opsdev/work_hour_stat.go
  23. 2 0
      opms_parent/main.go
  24. 2 4
      opms_parent/schema/function.sql
  25. 30 0
      opms_parent/schema/migration_ops_work_hour.sql

+ 41 - 38
opms_parent/app/dao/opsdev/internal/ops_operation_event.go

@@ -32,6 +32,7 @@ type opsOperationEventColumns struct {
 	EventType        string // 事件类型:10-操作咨询 20-数据处理 30-系统BUG 40-功能调整 50-二开需求 90-其他问题
 	EventStatus      string // 事件状态:10-待处理 20-处理中 30-转研发 70-挂起 80-已关闭
 	EventResult      string // 事件结果:10-已解决 20-部分解决 30-未解决
+	TotalWorkHour    string // 累计工时(小时)
 	ContractId       string // 关联合同ID
 	ContractName     string // 合同名称
 	CustId           string // 关联客户ID
@@ -81,6 +82,7 @@ var (
 			EventType:        "event_type",
 			EventStatus:      "event_status",
 			EventResult:      "event_result",
+			TotalWorkHour:    "total_work_hour",
 			ContractId:       "contract_id",
 			ContractName:     "contract_name",
 			CustId:           "cust_id",
@@ -130,44 +132,45 @@ func NewOpsOperationEventDao(tenant string) OpsOperationEventDao {
 			EventTitle:       "event_title",
 			EventDesc:        "event_desc",
 			EventType:        "event_type",
-			EventStatus:      "event_status",
-			EventResult:      "event_result",
-			ContractId:       "contract_id",
-			ContractName:     "contract_name",
-			CustId:           "cust_id",
-			CustName:         "cust_name",
-			ProductLine:      "product_line",
-			IsBig:            "is_big",
-			IsOps:            "is_ops",
-			PriorityLevel:    "priority_level",
-			FeedbackSource:   "feedback_source",
-			FeedbackReporter: "feedback_reporter",
-			FeedbackDate:     "feedback_date",
-			OpsUserId:        "ops_user_id",
-			OpsUserName:      "ops_user_name",
-			AssignTime:       "assign_time",
-			CompleteTime:     "complete_time",
-			CompleteDesc:     "complete_desc",
-			Attribute1:       "attribute1",
-			Attribute2:       "attribute2",
-			Attribute3:       "attribute3",
-			Attribute4:       "attribute4",
-			Attribute5:       "attribute5",
-			Attribute6:       "attribute6",
-			Attribute7:       "attribute7",
-			Attribute8:       "attribute8",
-			Attribute9:       "attribute9",
-			Remark:           "remark",
-			CreatedBy:        "created_by",
-			CreatedName:      "created_name",
-			CreatedTime:      "created_time",
-			UpdatedBy:        "updated_by",
-			UpdatedName:      "updated_name",
-			UpdatedTime:      "updated_time",
-			DeletedTime:      "deleted_time",
-		},
-	}
-	return dao
+		EventStatus:      "event_status",
+		EventResult:      "event_result",
+		TotalWorkHour:    "total_work_hour",
+		ContractId:       "contract_id",
+		ContractName:     "contract_name",
+		CustId:           "cust_id",
+		CustName:         "cust_name",
+		ProductLine:      "product_line",
+		IsBig:            "is_big",
+		IsOps:            "is_ops",
+		PriorityLevel:    "priority_level",
+		FeedbackSource:   "feedback_source",
+		FeedbackReporter: "feedback_reporter",
+		FeedbackDate:     "feedback_date",
+		OpsUserId:        "ops_user_id",
+		OpsUserName:      "ops_user_name",
+		AssignTime:       "assign_time",
+		CompleteTime:     "complete_time",
+		CompleteDesc:     "complete_desc",
+		Attribute1:       "attribute1",
+		Attribute2:       "attribute2",
+		Attribute3:       "attribute3",
+		Attribute4:       "attribute4",
+		Attribute5:       "attribute5",
+		Attribute6:       "attribute6",
+		Attribute7:       "attribute7",
+		Attribute8:       "attribute8",
+		Attribute9:       "attribute9",
+		Remark:           "remark",
+		CreatedBy:        "created_by",
+		CreatedName:      "created_name",
+		CreatedTime:      "created_time",
+		UpdatedBy:        "updated_by",
+		UpdatedName:      "updated_name",
+		UpdatedTime:      "updated_time",
+		DeletedTime:      "deleted_time",
+	},
+}
+return dao
 }
 
 // Ctx is a chaining function, which creates and returns a new DB that is a shallow copy

+ 435 - 0
opms_parent/app/dao/opsdev/internal/ops_operation_work_hour.go

@@ -0,0 +1,435 @@
+// ==========================================================================
+// This is auto-generated by gf cli tool. DO NOT EDIT THIS FILE MANUALLY.
+// ==========================================================================
+
+package internal
+
+import (
+	"context"
+	"database/sql"
+	"time"
+
+	opsdev "dashoo.cn/opms_parent/app/model/opsdev"
+	"github.com/gogf/gf/database/gdb"
+	"github.com/gogf/gf/frame/g"
+	"github.com/gogf/gf/frame/gmvc"
+)
+
+// OpsOperationWorkHourDao is the manager for logic model data accessing
+// and custom defined data operations functions management.
+type OpsOperationWorkHourDao struct {
+	gmvc.M
+	DB      gdb.DB
+	Table   string
+	Columns opsOperationWorkHourColumns
+}
+
+// OpsOperationWorkHourColumns defines and stores column names for table ops_operation_work_hour.
+type opsOperationWorkHourColumns struct {
+	Id          string // ID
+	EventId     string // 关联事件ID(运维事件表id)
+	OpsUserId   string // 执行人ID
+	OpsUserName string // 执行人姓名
+	WorkDate    string // 工作日期
+	WorkHour    string // 工作时长(小时)
+	Remark      string // 工作说明
+	CreatedBy   string // 创建人ID
+	CreatedName string // 创建人
+	CreatedTime string // 创建时间
+	UpdatedBy   string // 更新人ID
+	UpdatedName string // 更新人
+	UpdatedTime string // 更新时间
+	DeletedTime string // 删除时间
+}
+
+var (
+	// OpsOperationWorkHour is globally public accessible object for table ops_operation_work_hour operations.
+	OpsOperationWorkHour = OpsOperationWorkHourDao{
+		M:     g.DB("default").Model("ops_operation_work_hour").Safe(),
+		DB:    g.DB("default"),
+		Table: "ops_operation_work_hour",
+		Columns: opsOperationWorkHourColumns{
+			Id:          "id",
+			EventId:     "event_id",
+			OpsUserId:   "ops_user_id",
+			OpsUserName: "ops_user_name",
+			WorkDate:    "work_date",
+			WorkHour:    "work_hour",
+			Remark:      "remark",
+			CreatedBy:   "created_by",
+			CreatedName: "created_name",
+			CreatedTime: "created_time",
+			UpdatedBy:   "updated_by",
+			UpdatedName: "updated_name",
+			UpdatedTime: "updated_time",
+			DeletedTime: "deleted_time",
+		},
+	}
+)
+
+func NewOpsOperationWorkHourDao(tenant string) OpsOperationWorkHourDao {
+	var dao OpsOperationWorkHourDao
+	dao = OpsOperationWorkHourDao{
+		M:     g.DB(tenant).Model("ops_operation_work_hour").Safe(),
+		DB:    g.DB(tenant),
+		Table: "ops_operation_work_hour",
+		Columns: opsOperationWorkHourColumns{
+			Id:          "id",
+			EventId:     "event_id",
+			OpsUserId:   "ops_user_id",
+			OpsUserName: "ops_user_name",
+			WorkDate:    "work_date",
+			WorkHour:    "work_hour",
+			Remark:      "remark",
+			CreatedBy:   "created_by",
+			CreatedName: "created_name",
+			CreatedTime: "created_time",
+			UpdatedBy:   "updated_by",
+			UpdatedName: "updated_name",
+			UpdatedTime: "updated_time",
+			DeletedTime: "deleted_time",
+		},
+	}
+	return dao
+}
+
+// Ctx is a chaining function, which creates and returns a new DB that is a shallow copy
+// of current DB object and with given context in it.
+// Note that this returned DB object can be used only once, so do not assign it to
+// a global or package variable for long using.
+func (d *OpsOperationWorkHourDao) Ctx(ctx context.Context) *OpsOperationWorkHourDao {
+	return &OpsOperationWorkHourDao{M: d.M.Ctx(ctx)}
+}
+
+// As sets an alias name for current table.
+func (d *OpsOperationWorkHourDao) As(as string) *OpsOperationWorkHourDao {
+	return &OpsOperationWorkHourDao{M: d.M.As(as)}
+}
+
+// TX sets the transaction for current operation.
+func (d *OpsOperationWorkHourDao) TX(tx *gdb.TX) *OpsOperationWorkHourDao {
+	return &OpsOperationWorkHourDao{M: d.M.TX(tx)}
+}
+
+// Master marks the following operation on master node.
+func (d *OpsOperationWorkHourDao) Master() *OpsOperationWorkHourDao {
+	return &OpsOperationWorkHourDao{M: d.M.Master()}
+}
+
+// Slave marks the following operation on slave node.
+// Note that it makes sense only if there's any slave node configured.
+func (d *OpsOperationWorkHourDao) Slave() *OpsOperationWorkHourDao {
+	return &OpsOperationWorkHourDao{M: d.M.Slave()}
+}
+
+// Args sets custom arguments for model operation.
+func (d *OpsOperationWorkHourDao) Args(args ...interface{}) *OpsOperationWorkHourDao {
+	return &OpsOperationWorkHourDao{M: d.M.Args(args...)}
+}
+
+// LeftJoin does "LEFT JOIN ... ON ..." statement on the model.
+// The parameter <table> can be joined table and its joined condition,
+// and also with its alias name, like:
+// Table("user").LeftJoin("user_detail", "user_detail.uid=user.uid")
+// Table("user", "u").LeftJoin("user_detail", "ud", "ud.uid=u.uid")
+func (d *OpsOperationWorkHourDao) LeftJoin(table ...string) *OpsOperationWorkHourDao {
+	return &OpsOperationWorkHourDao{M: d.M.LeftJoin(table...)}
+}
+
+// RightJoin does "RIGHT JOIN ... ON ..." statement on the model.
+// The parameter <table> can be joined table and its joined condition,
+// and also with its alias name, like:
+// Table("user").RightJoin("user_detail", "user_detail.uid=user.uid")
+// Table("user", "u").RightJoin("user_detail", "ud", "ud.uid=u.uid")
+func (d *OpsOperationWorkHourDao) RightJoin(table ...string) *OpsOperationWorkHourDao {
+	return &OpsOperationWorkHourDao{M: d.M.RightJoin(table...)}
+}
+
+// InnerJoin does "INNER JOIN ... ON ..." statement on the model.
+// The parameter <table> can be joined table and its joined condition,
+// and also with its alias name, like:
+// Table("user").InnerJoin("user_detail", "user_detail.uid=user.uid")
+// Table("user", "u").InnerJoin("user_detail", "ud", "ud.uid=u.uid")
+func (d *OpsOperationWorkHourDao) InnerJoin(table ...string) *OpsOperationWorkHourDao {
+	return &OpsOperationWorkHourDao{M: d.M.InnerJoin(table...)}
+}
+
+// Fields sets the operation fields of the model, multiple fields joined using char ','.
+// The parameter <fieldNamesOrMapStruct> can be type of string/map/*map/struct/*struct.
+func (d *OpsOperationWorkHourDao) Fields(fieldNamesOrMapStruct ...interface{}) *OpsOperationWorkHourDao {
+	return &OpsOperationWorkHourDao{M: d.M.Fields(fieldNamesOrMapStruct...)}
+}
+
+// FieldsEx sets the excluded operation fields of the model, multiple fields joined using char ','.
+// The parameter <fieldNamesOrMapStruct> can be type of string/map/*map/struct/*struct.
+func (d *OpsOperationWorkHourDao) FieldsEx(fieldNamesOrMapStruct ...interface{}) *OpsOperationWorkHourDao {
+	return &OpsOperationWorkHourDao{M: d.M.FieldsEx(fieldNamesOrMapStruct...)}
+}
+
+// Option sets the extra operation option for the model.
+func (d *OpsOperationWorkHourDao) Option(option int) *OpsOperationWorkHourDao {
+	return &OpsOperationWorkHourDao{M: d.M.Option(option)}
+}
+
+// OmitEmpty sets OPTION_OMITEMPTY option for the model, which automatically filers
+// the data and where attributes for empty values.
+func (d *OpsOperationWorkHourDao) OmitEmpty() *OpsOperationWorkHourDao {
+	return &OpsOperationWorkHourDao{M: d.M.OmitEmpty()}
+}
+
+// Filter marks filtering the fields which does not exist in the fields of the operated table.
+func (d *OpsOperationWorkHourDao) Filter() *OpsOperationWorkHourDao {
+	return &OpsOperationWorkHourDao{M: d.M.Filter()}
+}
+
+// Where sets the condition statement for the model. The parameter <where> can be type of
+// string/map/gmap/slice/struct/*struct, etc. Note that, if it's called more than one times,
+// multiple conditions will be joined into where statement using "AND".
+// Eg:
+// Where("uid=10000")
+// Where("uid", 10000)
+// Where("money>? AND name like ?", 99999, "vip_%")
+// Where("uid", 1).Where("name", "john")
+// Where("status IN (?)", g.Slice{1,2,3})
+// Where("age IN(?,?)", 18, 50)
+// Where(User{ Id : 1, UserName : "john"})
+func (d *OpsOperationWorkHourDao) Where(where interface{}, args ...interface{}) *OpsOperationWorkHourDao {
+	return &OpsOperationWorkHourDao{M: d.M.Where(where, args...)}
+}
+
+// WherePri does the same logic as M.Where except that if the parameter <where>
+// is a single condition like int/string/float/slice, it treats the condition as the primary
+// key value. That is, if primary key is "id" and given <where> parameter as "123", the
+// WherePri function treats the condition as "id=123", but M.Where treats the condition
+// as string "123".
+func (d *OpsOperationWorkHourDao) WherePri(where interface{}, args ...interface{}) *OpsOperationWorkHourDao {
+	return &OpsOperationWorkHourDao{M: d.M.WherePri(where, args...)}
+}
+
+// And adds "AND" condition to the where statement.
+func (d *OpsOperationWorkHourDao) And(where interface{}, args ...interface{}) *OpsOperationWorkHourDao {
+	return &OpsOperationWorkHourDao{M: d.M.And(where, args...)}
+}
+
+// Or adds "OR" condition to the where statement.
+func (d *OpsOperationWorkHourDao) Or(where interface{}, args ...interface{}) *OpsOperationWorkHourDao {
+	return &OpsOperationWorkHourDao{M: d.M.Or(where, args...)}
+}
+
+// Group sets the "GROUP BY" statement for the model.
+func (d *OpsOperationWorkHourDao) Group(groupBy string) *OpsOperationWorkHourDao {
+	return &OpsOperationWorkHourDao{M: d.M.Group(groupBy)}
+}
+
+// Order sets the "ORDER BY" statement for the model.
+func (d *OpsOperationWorkHourDao) Order(orderBy ...string) *OpsOperationWorkHourDao {
+	return &OpsOperationWorkHourDao{M: d.M.Order(orderBy...)}
+}
+
+// Limit sets the "LIMIT" statement for the model.
+// The parameter <limit> can be either one or two number, if passed two number is passed,
+// it then sets "LIMIT limit[0],limit[1]" statement for the model, or else it sets "LIMIT limit[0]"
+// statement.
+func (d *OpsOperationWorkHourDao) Limit(limit ...int) *OpsOperationWorkHourDao {
+	return &OpsOperationWorkHourDao{M: d.M.Limit(limit...)}
+}
+
+// Offset sets the "OFFSET" statement for the model.
+// It only makes sense for some databases like SQLServer, PostgreSQL, etc.
+func (d *OpsOperationWorkHourDao) Offset(offset int) *OpsOperationWorkHourDao {
+	return &OpsOperationWorkHourDao{M: d.M.Offset(offset)}
+}
+
+// Page sets the paging number for the model.
+// The parameter <page> is started from 1 for paging.
+// Note that, it differs that the Limit function start from 0 for "LIMIT" statement.
+func (d *OpsOperationWorkHourDao) Page(page, limit int) *OpsOperationWorkHourDao {
+	return &OpsOperationWorkHourDao{M: d.M.Page(page, limit)}
+}
+
+// Batch sets the batch operation number for the model.
+func (d *OpsOperationWorkHourDao) Batch(batch int) *OpsOperationWorkHourDao {
+	return &OpsOperationWorkHourDao{M: d.M.Batch(batch)}
+}
+
+// Cache sets the cache feature for the model. It caches the result of the sql, which means
+// if there's another same sql request, it just reads and returns the result from cache, it
+// but not committed and executed into the database.
+//
+// If the parameter <duration> < 0, which means it clear the cache with given <name>.
+// If the parameter <duration> = 0, which means it never expires.
+// If the parameter <duration> > 0, which means it expires after <duration>.
+//
+// The optional parameter <name> is used to bind a name to the cache, which means you can later
+// control the cache like changing the <duration> or clearing the cache with specified <name>.
+//
+// Note that, the cache feature is disabled if the model is operating on a transaction.
+func (d *OpsOperationWorkHourDao) Cache(duration time.Duration, name ...string) *OpsOperationWorkHourDao {
+	return &OpsOperationWorkHourDao{M: d.M.Cache(duration, name...)}
+}
+
+// Data sets the operation data for the model.
+// The parameter <data> can be type of string/map/gmap/slice/struct/*struct, etc.
+// Eg:
+// Data("uid=10000")
+// Data("uid", 10000)
+// Data(g.Map{"uid": 10000, "name":"john"})
+// Data(g.Slice{g.Map{"uid": 10000, "name":"john"}, g.Map{"uid": 20000, "name":"smith"})
+func (d *OpsOperationWorkHourDao) Data(data ...interface{}) *OpsOperationWorkHourDao {
+	return &OpsOperationWorkHourDao{M: d.M.Data(data...)}
+}
+
+// All does "SELECT FROM ..." statement for the model.
+// It retrieves the records from table and returns the result as []*model.OpsOperationWorkHour.
+// It returns nil if there's no record retrieved with the given conditions from table.
+//
+// The optional parameter <where> is the same as the parameter of M.Where function,
+// see M.Where.
+func (d *OpsOperationWorkHourDao) All(where ...interface{}) ([]*opsdev.OpsOperationWorkHour, error) {
+	all, err := d.M.All(where...)
+	if err != nil {
+		return nil, err
+	}
+	var entities []*opsdev.OpsOperationWorkHour
+	if err = all.Structs(&entities); err != nil && err != sql.ErrNoRows {
+		return nil, err
+	}
+	return entities, nil
+}
+
+// One retrieves one record from table and returns the result as *model.OpsOperationWorkHour.
+// It returns nil if there's no record retrieved with the given conditions from table.
+//
+// The optional parameter <where> is the same as the parameter of M.Where function,
+// see M.Where.
+func (d *OpsOperationWorkHourDao) One(where ...interface{}) (*opsdev.OpsOperationWorkHour, error) {
+	one, err := d.M.One(where...)
+	if err != nil {
+		return nil, err
+	}
+	var entity *opsdev.OpsOperationWorkHour
+	if err = one.Struct(&entity); err != nil && err != sql.ErrNoRows {
+		return nil, err
+	}
+	return entity, nil
+}
+
+// FindOne retrieves and returns a single Record by M.WherePri and M.One.
+// Also see M.WherePri and M.One.
+func (d *OpsOperationWorkHourDao) FindOne(where ...interface{}) (*opsdev.OpsOperationWorkHour, error) {
+	one, err := d.M.FindOne(where...)
+	if err != nil {
+		return nil, err
+	}
+	var entity *opsdev.OpsOperationWorkHour
+	if err = one.Struct(&entity); err != nil && err != sql.ErrNoRows {
+		return nil, err
+	}
+	return entity, nil
+}
+
+// FindAll retrieves and returns Result by by M.WherePri and M.All.
+// Also see M.WherePri and M.All.
+func (d *OpsOperationWorkHourDao) FindAll(where ...interface{}) ([]*opsdev.OpsOperationWorkHour, error) {
+	all, err := d.M.FindAll(where...)
+	if err != nil {
+		return nil, err
+	}
+	var entities []*opsdev.OpsOperationWorkHour
+	if err = all.Structs(&entities); err != nil && err != sql.ErrNoRows {
+		return nil, err
+	}
+	return entities, nil
+}
+
+// Struct retrieves one record from table and converts it into given struct.
+// The parameter <pointer> should be type of *struct/**struct. If type **struct is given,
+// it can create the struct internally during converting.
+//
+// The optional parameter <where> is the same as the parameter of Model.Where function,
+// see Model.Where.
+//
+// Note that it returns sql.ErrNoRows if there's no record retrieved with the given conditions
+// from table and <pointer> is not nil.
+//
+// Eg:
+// user := new(User)
+// err  := dao.User.Where("id", 1).Struct(user)
+//
+// user := (*User)(nil)
+// err  := dao.User.Where("id", 1).Struct(&user)
+func (d *OpsOperationWorkHourDao) Struct(pointer interface{}, where ...interface{}) error {
+	return d.M.Struct(pointer, where...)
+}
+
+// Structs retrieves records from table and converts them into given struct slice.
+// The parameter <pointer> should be type of *[]struct/*[]*struct. It can create and fill the struct
+// slice internally during converting.
+//
+// The optional parameter <where> is the same as the parameter of Model.Where function,
+// see Model.Where.
+//
+// Note that it returns sql.ErrNoRows if there's no record retrieved with the given conditions
+// from table and <pointer> is not empty.
+//
+// Eg:
+// users := ([]User)(nil)
+// err   := dao.User.Structs(&users)
+//
+// users := ([]*User)(nil)
+// err   := dao.User.Structs(&users)
+func (d *OpsOperationWorkHourDao) Structs(pointer interface{}, where ...interface{}) error {
+	return d.M.Structs(pointer, where...)
+}
+
+// Scan automatically calls Struct or Structs function according to the type of parameter <pointer>.
+// It calls function Struct if <pointer> is type of *struct/**struct.
+// It calls function Structs if <pointer> is type of *[]struct/*[]*struct.
+//
+// The optional parameter <where> is the same as the parameter of Model.Where function,
+// see Model.Where.
+//
+// Note that it returns sql.ErrNoRows if there's no record retrieved and given pointer is not empty or nil.
+//
+// Eg:
+// user  := new(User)
+// err   := dao.User.Where("id", 1).Scan(user)
+//
+// user  := (*User)(nil)
+// err   := dao.User.Where("id", 1).Scan(&user)
+//
+// users := ([]User)(nil)
+// err   := dao.User.Scan(&users)
+//
+// users := ([]*User)(nil)
+// err   := dao.User.Scan(&users)
+func (d *OpsOperationWorkHourDao) Scan(pointer interface{}, where ...interface{}) error {
+	return d.M.Scan(pointer, where...)
+}
+
+// Chunk iterates the table with given size and callback function.
+func (d *OpsOperationWorkHourDao) Chunk(limit int, callback func(entities []*opsdev.OpsOperationWorkHour, err error) bool) {
+	d.M.Chunk(limit, func(result gdb.Result, err error) bool {
+		var entities []*opsdev.OpsOperationWorkHour
+		err = result.Structs(&entities)
+		if err == sql.ErrNoRows {
+			return false
+		}
+		return callback(entities, err)
+	})
+}
+
+// LockUpdate sets the lock for update for current operation.
+func (d *OpsOperationWorkHourDao) LockUpdate() *OpsOperationWorkHourDao {
+	return &OpsOperationWorkHourDao{M: d.M.LockUpdate()}
+}
+
+// LockShared sets the lock in share mode for current operation.
+func (d *OpsOperationWorkHourDao) LockShared() *OpsOperationWorkHourDao {
+	return &OpsOperationWorkHourDao{M: d.M.LockShared()}
+}
+
+// Unscoped enables/disables the soft deleting feature.
+func (d *OpsOperationWorkHourDao) Unscoped() *OpsOperationWorkHourDao {
+	return &OpsOperationWorkHourDao{M: d.M.Unscoped()}
+}

+ 36 - 0
opms_parent/app/dao/opsdev/ops_operation_work_hour.go

@@ -0,0 +1,36 @@
+// ============================================================================
+// This is auto-generated by gf cli tool only once. Fill this file as you wish.
+// ============================================================================
+
+package opsdev
+
+import (
+	"dashoo.cn/opms_parent/app/dao/opsdev/internal"
+)
+
+// opsOperationWorkHourDao is the manager for logic model data accessing
+// and custom defined data operations functions management. You can define
+// methods on it to extend its functionality as you wish.
+type opsOperationWorkHourDao struct {
+	internal.OpsOperationWorkHourDao
+}
+
+var (
+	// OpsOperationWorkHour is globally public accessible object for table ops_operation_work_hour operations.
+	OpsOperationWorkHour = opsOperationWorkHourDao{
+		internal.OpsOperationWorkHour,
+	}
+)
+
+type OpsOperationWorkHourDao struct {
+	internal.OpsOperationWorkHourDao
+}
+
+func NewOpsOperationWorkHourDao(tenant string) *OpsOperationWorkHourDao {
+	dao := internal.NewOpsOperationWorkHourDao(tenant)
+	return &OpsOperationWorkHourDao{
+		dao,
+	}
+}
+
+// Fill with you ideas below.

+ 14 - 0
opms_parent/app/handler/cust/product_consult_record.go

@@ -114,3 +114,17 @@ func (c *ProductConsultRecordHandler) GetOperateEntity(ctx context.Context, req
 	rsp.Data = data
 	return nil
 }
+
+// ChangeIncharge 变更对接人
+func (c *ProductConsultRecordHandler) ChangeIncharge(ctx context.Context, req *model.ChangeInchargeReq, rsp *comm_def.CommonMsg) error {
+	g.Log().Infof("ProductConsultRecordHandler.ChangeIncharge request %#v ", *req)
+	s, err := service.NewProductConsultRecordService(ctx)
+	if err != nil {
+		return err
+	}
+	err = s.ChangeIncharge(ctx, req)
+	if err != nil {
+		return err
+	}
+	return nil
+}

+ 32 - 0
opms_parent/app/handler/opsdev/operation.go

@@ -198,6 +198,37 @@ func (h *OperationHandler) AddRecord(ctx context.Context, req *opsdevmodel.AddRe
 	return nil
 }
 
+func (h *OperationHandler) AddWorkHour(ctx context.Context, req *opsdevmodel.OpsOperationWorkHourAddReq, rsp *comm_def.CommonMsg) error {
+	if err := gvalid.CheckStruct(ctx, req, nil); err != nil {
+		return myerrors.ValidError(err.Error())
+	}
+	srv, err := opsdevSrv.NewOperationService(ctx)
+	if err != nil {
+		return err
+	}
+	if err := srv.AddWorkHour(req); err != nil {
+		return err
+	}
+	rsp.Data = g.Map{"message": "添加成功"}
+	return nil
+}
+
+func (h *OperationHandler) GetWorkHourList(ctx context.Context, req *opsdevmodel.OpsOperationWorkHourListReq, rsp *comm_def.CommonMsg) error {
+	if err := gvalid.CheckStruct(ctx, req, nil); err != nil {
+		return myerrors.ValidError(err.Error())
+	}
+	srv, err := opsdevSrv.NewOperationService(ctx)
+	if err != nil {
+		return err
+	}
+	list, err := srv.GetWorkHourList(req)
+	if err != nil {
+		return err
+	}
+	rsp.Data = g.Map{"list": list}
+	return nil
+}
+
 func (h *OperationHandler) DeleteAttachment(ctx context.Context, req *comm_def.IdReq, rsp *comm_def.CommonMsg) error {
 	if req.Id <= 0 {
 		return myerrors.ValidError("参数有误!")
@@ -251,3 +282,4 @@ func (h *OperationHandler) ExportNonClosed(ctx context.Context, req *opsdevmodel
 	rsp.Data = content
 	return nil
 }
+

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

@@ -288,3 +288,21 @@ func (h *OpsEventTaskHandler) GetWorkHourList(ctx context.Context, req *opsdevmo
 	rsp.Data = g.Map{"list": list}
 	return nil
 }
+
+// GetWorkHourDashboardData 获取工作台工时看板数据
+func (h *OpsEventTaskHandler) GetWorkHourDashboardData(ctx context.Context, req *opsdevmodel.OpsEventTaskWorkHourDashboardReq, rsp *comm_def.CommonMsg) error {
+	if req.StartDate == "" || req.EndDate == "" {
+		return myerrors.ValidError("日期参数不能为空")
+	}
+	s, err := services.NewOpsEventTaskService(ctx)
+	if err != nil {
+		return err
+	}
+	data, err := s.GetDashboardData(req.StartDate, req.EndDate)
+	if err != nil {
+		return err
+	}
+	rsp.Data = data
+	return nil
+}
+

+ 28 - 0
opms_parent/app/handler/opsdev/work_hour_stat.go

@@ -0,0 +1,28 @@
+package opsdev
+
+import (
+	"context"
+
+	"dashoo.cn/common_definition/comm_def"
+	opsdevmodel "dashoo.cn/opms_parent/app/model/opsdev"
+	opsdevSrv "dashoo.cn/opms_parent/app/service/opsdev"
+)
+
+// WorkHourStatHandler 工时统计 RPC Handler
+type WorkHourStatHandler struct{}
+
+// GetStat 获取工时统计数据
+func (h *WorkHourStatHandler) GetStat(ctx context.Context, req *opsdevmodel.WorkHourStatReq, rsp *comm_def.CommonMsg) error {
+	srv, err := opsdevSrv.NewWorkHourStatService(ctx)
+	if err != nil {
+		return err
+	}
+
+	data, err := srv.GetStat(req)
+	if err != nil {
+		return err
+	}
+
+	rsp.Data = data
+	return nil
+}

+ 7 - 0
opms_parent/app/model/cust/product_consult_record.go

@@ -20,6 +20,7 @@ type ProductConsultRecordListReq struct {
 	State        string `json:"state"`        // 状态
 	InchargeName string `json:"inchargeName"` // 对接人(销售工程师)
 	Unit         string `json:"unit"`         // 单位名称
+	Province     string `json:"province"`     // 所在省
 }
 
 type ProductConsultRecordAddReq struct {
@@ -90,3 +91,9 @@ type GetOperateRsp struct {
 	HasLast bool `json:"hasLast"`
 	HasNext bool `json:"hasNext"`
 }
+
+type ChangeInchargeReq struct {
+	Ids          []int  `json:"ids" v:"required#请输入Id"`
+	InchargeId   int    `json:"inchargeId" v:"required#请输入对接人ID"`
+	InchargeName string `json:"inchargeName" v:"required#请输入对接人名称"`
+}

+ 1 - 0
opms_parent/app/model/opsdev/internal/ops_operation_event.go

@@ -16,6 +16,7 @@ type OpsOperationEvent struct {
 	EventDesc        string      `orm:"event_desc"        json:"eventDesc"`        // 事件描述
 	EventType        string      `orm:"event_type"        json:"eventType"`        // 事件类型(10-操作咨询 20-数据处理 30-系统BUG 40-功能调整 50-二开需求 90-其他问题)
 	EventStatus      string      `orm:"event_status"      json:"eventStatus"`      // 事件状态(10 待处理、20处理中、30 转研发、70 已完成、80已关闭)
+	TotalWorkHour    float64     `orm:"total_work_hour"   json:"totalWorkHour"`    // 累计工时(小时)
 	ContractId       int         `orm:"contract_id"       json:"contractId"`       // 关联合同
 	ContractName     string      `orm:"contract_name"     json:"contractName"`     // 合同名称
 	CustId           int         `orm:"cust_id"           json:"custId"`           // 关联客户

+ 27 - 0
opms_parent/app/model/opsdev/internal/ops_operation_work_hour.go

@@ -0,0 +1,27 @@
+// ==========================================================================
+// This is auto-generated by gf cli tool. DO NOT EDIT THIS FILE MANUALLY.
+// ==========================================================================
+
+package internal
+
+import (
+	"github.com/gogf/gf/os/gtime"
+)
+
+// OpsOperationWorkHour is the golang structure for table ops_operation_work_hour.
+type OpsOperationWorkHour struct {
+	Id          int         `orm:"id,primary"       json:"id"`           // ID
+	EventId     int         `orm:"event_id"         json:"eventId"`      // 关联事件ID(运维事件表id)
+	OpsUserId   int         `orm:"ops_user_id"      json:"opsUserId"`   // 执行人ID
+	OpsUserName string      `orm:"ops_user_name"    json:"opsUserName"`  // 执行人姓名
+	WorkDate    *gtime.Time `orm:"work_date"        json:"workDate"`     // 工作日期
+	WorkHour    float64     `orm:"work_hour"        json:"workHour"`     // 工作时长(小时)
+	Remark      string      `orm:"remark"           json:"remark"`       // 工作说明
+	CreatedBy   int         `orm:"created_by"       json:"createdBy"`    // 创建人ID
+	CreatedName string      `orm:"created_name"     json:"createdName"`  // 创建人
+	CreatedTime *gtime.Time `orm:"created_time"     json:"createdTime"`  // 创建时间
+	UpdatedBy   int         `orm:"updated_by"       json:"updatedBy"`    // 更新人ID
+	UpdatedName string      `orm:"updated_name"     json:"updatedName"`  // 更新人
+	UpdatedTime *gtime.Time `orm:"updated_time"     json:"updatedTime"`  // 更新时间
+	DeletedTime *gtime.Time `orm:"deleted_time"     json:"deletedTime"`  // 删除时间
+}

+ 30 - 4
opms_parent/app/model/opsdev/operation.go

@@ -51,10 +51,11 @@ type UpdateOpsOperationEventReq struct {
 
 // OpsOperationEventProcessReq 运维事件处理请求
 type OpsOperationEventProcessReq struct {
-	Id            int64  `json:"id"            v:"min:1#任务ID不能为空"`
-	HandleContent string `json:"handleContent" v:"required#处理内容不能为空"`
-	HandleResult  string `json:"handleResult"`
-	OperateType   string `json:"operateType"  v:"required#操作类型不能为空"`
+	Id              int64   `json:"id"              v:"min:1#任务ID不能为空"`
+	HandleContent   string  `json:"handleContent"   v:"required#处理内容不能为空"`
+	HandleResult    string  `json:"handleResult"`
+	OperateType     string  `json:"operateType"     v:"required#操作类型不能为空"`
+	AdjustWorkHour  float64 `json:"adjustWorkHour"` // 关单时调整累计工时(可选,仅 operateType=80 时生效)
 }
 
 // OpsOperationEventRecordSearchReq 运维事件处理记录搜索请求
@@ -171,6 +172,21 @@ type OpsOperationEventHistoryExportContent struct {
 	Content string `json:"content"` // 导出内容(base64编码)
 }
 
+// OpsOperationWorkHourListReq 查询工时登记列表请求
+type OpsOperationWorkHourListReq struct {
+	EventId int `json:"eventId" v:"required#事件ID不能为空"` // 事件ID
+}
+
+// OpsOperationWorkHourListRsp 工时登记响应
+type OpsOperationWorkHourListRsp struct {
+	Id          int     `json:"id"`          // ID
+	WorkDate    string  `json:"workDate"`    // 工作日期
+	WorkHour    float64 `json:"workHour"`    // 工作时长
+	Remark      string  `json:"remark"`      // 工作说明
+	CreatedName string  `json:"createdName"` // 登记人
+	CreatedTime string  `json:"createdTime"` // 登记时间
+}
+
 type OpsOperationEventExport struct {
 	Columns []string `json:"columns"`
 }
@@ -186,3 +202,13 @@ type OpsOperationEventExportData struct {
 	PriorityLevel string `json:"priorityLevel" export:"优先级"`
 	HandleProcess string `json:"handleProcess" export:"处理过程"`
 }
+
+// OpsOperationWorkHourAddReq 添加工时记录请求
+type OpsOperationWorkHourAddReq struct {
+	EventId  int     `json:"eventId"  v:"min:1#事件ID不能为空"`
+	WorkDate string  `json:"workDate" v:"required#工作日期不能为空"`
+	WorkHour float64 `json:"workHour" v:"min:0.5#工作时长不能小于0.5小时"`
+	Remark   string  `json:"remark"`
+}
+
+

+ 2 - 0
opms_parent/app/model/opsdev/ops_delivery_project.go

@@ -72,6 +72,8 @@ type OpsDeliveryProjectUpdateReq struct {
 	PlanAcceptTime   string `json:"planAcceptTime"`   // 计划验收时间
 	DeliveryNode     string `json:"deliveryNode"`
 	Remark           string `json:"remark"`
+	Attribute3       string `json:"attribute3"` // 运维负责人姓名
+	Attribute4       int    `json:"attribute4"` // 运维负责人ID
 }
 
 // AssignDeliveryUserReq 指派/改派交付负责人请求

+ 41 - 0
opms_parent/app/model/opsdev/ops_event_task_work_hour_dashboard.go

@@ -0,0 +1,41 @@
+// ==========================================================================
+// Dashboard DTOs — 工作台看板 请求/响应模型
+// ==========================================================================
+
+package opsdev
+
+// OpsEventTaskWorkHourDashboardReq 工作台看板请求
+type OpsEventTaskWorkHourDashboardReq struct {
+	StartDate string `json:"startDate"`
+	EndDate   string `json:"endDate"`
+}
+
+// OpsEventTaskWorkHourDashboardRsp 工作台看板响应
+type OpsEventTaskWorkHourDashboardRsp struct {
+	WeekTargetHours float64             `json:"weekTargetHours"`
+	WeekTotalHours  float64             `json:"weekTotalHours"`
+	OverdueCount    int                 `json:"overdueCount"`
+	Days            []*DashboardDayRsp  `json:"days"`
+}
+
+// DashboardDayRsp 每日看板数据
+type DashboardDayRsp struct {
+	Date        string              `json:"date"`
+	TotalHours  float64             `json:"totalHours"`
+	TargetHours float64             `json:"targetHours"`
+	Tasks       []*DashboardTaskRsp `json:"tasks"`
+}
+
+// DashboardTaskRsp 工作台任务卡片
+type DashboardTaskRsp 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"`
+	PlanEndTime      string  `json:"planEndTime"`
+}

+ 12 - 0
opms_parent/app/model/opsdev/ops_operation_work_hour.go

@@ -0,0 +1,12 @@
+// ==========================================================================
+// This is auto-generated by gf cli tool. Fill this file as you wish.
+// ==========================================================================
+
+package opsdev
+
+import (
+	"dashoo.cn/opms_parent/app/model/opsdev/internal"
+)
+
+// OpsOperationWorkHour is the golang structure for table ops_operation_work_hour.
+type OpsOperationWorkHour internal.OpsOperationWorkHour

+ 36 - 0
opms_parent/app/model/opsdev/work_hour_stat.go

@@ -0,0 +1,36 @@
+package opsdev
+
+// WorkHourStatReq 工时统计请求
+type WorkHourStatReq struct {
+	Mode      string `json:"mode"`
+	StartDate string `json:"startDate"`
+	EndDate   string `json:"endDate"`
+	UserIds   []int  `json:"userIds"`
+}
+
+// WorkHourStatRsp 工时统计响应
+type WorkHourStatRsp struct {
+	Header  []DayHeader `json:"header"`
+	Persons []PersonRow `json:"persons"`
+}
+
+// DayHeader 日期列头
+type DayHeader struct {
+	Label string `json:"label"`
+	Date  string `json:"date"`
+}
+
+// PersonRow 单人员工时行
+type PersonRow struct {
+	UserId      int                `json:"userId"`
+	UserName    string             `json:"userName"`
+	DailyHours  map[string]DayData `json:"dailyHours"`
+	TotalOpHour float64            `json:"totalOpHour"`
+	TotalRdHour float64            `json:"totalRdHour"`
+}
+
+// DayData 单日工时明细
+type DayData struct {
+	OpHour float64 `json:"opHour"`
+	RdHour float64 `json:"rdHour"`
+}

+ 32 - 21
opms_parent/app/service/contract/ctr_contract.go

@@ -2,12 +2,10 @@ package service
 
 import (
 	"context"
-	worksrv "dashoo.cn/opms_parent/app/service/work"
 	"database/sql"
 	"encoding/base64"
 	"encoding/json"
 	"fmt"
-	"github.com/gogf/gf/util/gconv"
 	"io"
 	"net/http"
 	"strconv"
@@ -15,6 +13,9 @@ import (
 	"sync"
 	"time"
 
+	worksrv "dashoo.cn/opms_parent/app/service/work"
+	"github.com/gogf/gf/util/gconv"
+
 	basedao "dashoo.cn/opms_parent/app/dao/base"
 	dao "dashoo.cn/opms_parent/app/dao/contract"
 	custdao "dashoo.cn/opms_parent/app/dao/cust"
@@ -298,7 +299,7 @@ func (s CtrContractService) List(ctx context.Context, req *model.CtrContractList
 	if req.PageNum != 0 {
 		dao = dao.Page(req.GetPage())
 	}
-	orderby := "a.contract_sign_time desc"
+	orderby := "RIGHT(a.contract_code, 8) DESC"
 	if req.OrderBy != "" {
 		orderby = req.OrderBy
 	}
@@ -564,9 +565,9 @@ func (s CtrContractService) UpdateAdvanceContractData(tx *gdb.TX, advanceId, con
 	// 2. 更新交付项目表,将临时合同信息更新为正式合同信息
 	// 根据提前执行申请ID找到对应的交付项目记录
 	_, err = tx.Update("ops_delivery_project", g.Map{
-		"contract_id":   contractId,
-		"contract_no":   contractCode,
-		"attribute_1":   advanceId, // 将提前执行申请ID保存到attribute1
+		"contract_id": contractId,
+		"contract_no": contractCode,
+		"attribute_1": advanceId, // 将提前执行申请ID保存到attribute1
 	}, "contract_id = ?", advanceId)
 	return err
 }
@@ -1825,33 +1826,43 @@ func (s CtrContractService) CtrContractMaintenanceExport(ctx context.Context, re
 
 // SearchContract 合同模糊搜索(运维模块调用)
 func (s CtrContractService) SearchContract(ctx context.Context, req *model.CtrContractSearchReq) ([]*model.CtrContractSearchRsp, error) {
-	dao := s.Dao.FieldsEx(s.Dao.C.DeletedTime)
+	m := g.DB(s.Tenant).Model("ctr_contract", "c").
+		InnerJoin("ops_delivery_project", "p", "c.id = p.contract_id").
+		Fields("DISTINCT c.id, c.contract_code, c.contract_name, c.cust_id, c.cust_name, c.signatory_unit, c.product_line, c.is_big, c.software_maintenance_end_time").
+		Where("c.deleted_time IS NULL").
+		Where("p.project_status", "50")
+
 	if req.SearchText != "" {
 		likestr := fmt.Sprintf("%%%s%%", req.SearchText)
-		dao = dao.Where("(contract_code LIKE ? OR cust_name LIKE ? OR signatory_unit LIKE ?)", likestr, likestr, likestr)
+		m = m.Where("(c.contract_code LIKE ? OR c.cust_name LIKE ? OR c.signatory_unit LIKE ?)", likestr, likestr, likestr)
 	}
-	var list []*model.CtrContract
-	err := dao.Scan(&list)
+
+	m = m.Order("c.id DESC").Limit(100)
+
+	result, err := m.All()
 	if err != nil {
 		g.Log().Error(err)
 		return nil, myerrors.DbError("搜索合同失败")
 	}
 
 	rspList := make([]*model.CtrContractSearchRsp, 0)
-	for _, item := range list {
+	for _, record := range result {
+		item := record.Map()
 		maintenanceEndTime := ""
-		if item.SoftwareMaintenanceEndTime != nil {
-			maintenanceEndTime = item.SoftwareMaintenanceEndTime.Format("Y-m-d")
+		if item["software_maintenance_end_time"] != nil {
+			if t, ok := item["software_maintenance_end_time"].(*gtime.Time); ok {
+				maintenanceEndTime = t.Format("Y-m-d")
+			}
 		}
 		rspList = append(rspList, &model.CtrContractSearchRsp{
-			Id:                         item.Id,
-			ContractCode:               item.ContractCode,
-			ContractName:               item.ContractName,
-			CustId:                     item.CustId,
-			CustName:                   item.CustName,
-			SignatoryUnit:              item.SignatoryUnit,
-			ProductLine:                item.ProductLine,
-			IsBig:                      item.IsBig,
+			Id:                         gconv.Int(item["id"]),
+			ContractCode:               gconv.String(item["contract_code"]),
+			ContractName:               gconv.String(item["contract_name"]),
+			CustId:                     gconv.Int(item["cust_id"]),
+			CustName:                   gconv.String(item["cust_name"]),
+			SignatoryUnit:              gconv.String(item["signatory_unit"]),
+			ProductLine:                gconv.String(item["product_line"]),
+			IsBig:                      gconv.String(item["is_big"]),
 			SoftwareMaintenanceEndTime: maintenanceEndTime,
 		})
 	}

+ 51 - 0
opms_parent/app/service/cust/product_consult_record.go

@@ -69,6 +69,9 @@ func (s ProductConsultRecordService) List(ctx context.Context, req *model.Produc
 		likestr := fmt.Sprintf("%%%s%%", req.Unit)
 		dao = dao.Where("unit like ?", likestr)
 	}
+	if req.Province != "" {
+		dao = dao.Where("province = ?", req.Province)
+	}
 	// 权限限制(销售工程师看自己的)
 	if service.StringsContains(s.userInfo.Roles, "StrategicProjectTeam") {
 
@@ -255,6 +258,54 @@ func (s ProductConsultRecordService) Delete(ctx context.Context, id []int) error
 	return err
 }
 
+func (s ProductConsultRecordService) ChangeIncharge(ctx context.Context, req *model.ChangeInchargeReq) error {
+	validErr := gvalid.CheckStruct(ctx, req, nil)
+	if validErr != nil {
+		return myerrors.TipsError(validErr.Current().Error())
+	}
+
+	if len(req.Ids) == 0 {
+		return myerrors.TipsError("请选择要变更的记录")
+	}
+
+	var records []*model.ProductConsultRecord
+	err := s.Dao.Where("Id IN (?)", req.Ids).Structs(&records)
+	if err != nil {
+		return err
+	}
+
+	now := gtime.Now()
+	log := fmt.Sprintf("【%s】%s将对接人由", now.Format("Y-m-d H:i"), s.userInfo.NickName)
+
+	for _, ent := range records {
+		oldIncharge := ent.InchargeName
+		if oldIncharge == "" {
+			oldIncharge = "无"
+		}
+		newRemark := ent.Remark
+		if newRemark != "" {
+			newRemark += "\n"
+		}
+		newRemark += log + oldIncharge + "变更为" + req.InchargeName
+
+		dao := &s.Dao.ProductConsultRecordDao
+		toupdate := map[string]interface{}{}
+		toupdate["incharge_id"] = req.InchargeId
+		toupdate["incharge_name"] = req.InchargeName
+		toupdate["remark"] = newRemark
+		toupdate["updated_by"] = int(s.userInfo.Id)
+		toupdate["updated_name"] = s.userInfo.NickName
+		toupdate["updated_time"] = now
+
+		_, err = dao.Where("Id", ent.Id).Data(toupdate).Update()
+		if err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
 func (s ProductConsultRecordService) GetOperateEntity(ctx context.Context, req *model.GetOperateReq) (*model.GetOperateRsp, error) {
 	// 数据校验
 	if req.Id == 0 {

+ 26 - 0
opms_parent/app/service/opsdev/delivery_project.go

@@ -225,6 +225,11 @@ func (s *DeliveryProjectService) buildPermissionWhere() string {
 		where += fmt.Sprintf(" OR sales_user_id = %d", s.CxtUser.Id)
 	}
 
+	// 运维工程师:查看运维负责人是自己的项目
+	if service.StringsContains(s.CxtUser.Roles, "OperationsEngineer") {
+		where += fmt.Sprintf(" OR attribute4 = %d", s.CxtUser.Id)
+	}
+
 	// 项目经理、项目交付经理:查看项目负责人是自己的项目
 	if service.StringsContains(s.CxtUser.Roles, "ProjectManager") ||
 		service.StringsContains(s.CxtUser.Roles, "ProjectDeliveryManager") {
@@ -335,6 +340,14 @@ func (s *DeliveryProjectService) UpdateById(req *opsdevmodel.OpsDeliveryProjectU
 		return errors.New("ID不能为空")
 	}
 
+	// 权限校验:交付负责人和运维负责人的变更需要研发总监或研发主管角色
+	hasManageRole := service.StringsContains(s.CxtUser.Roles, "ResearchAndDevelopmentDirector") ||
+		service.StringsContains(s.CxtUser.Roles, "ResearchAndDevelopmentSupervisor")
+
+	if !hasManageRole && (req.DeliveryUserId > 0 || req.Attribute4 > 0) {
+		return errors.New("只有研发总监或研发主管才能变更交付负责人或运维负责人")
+	}
+
 	data := g.Map{
 		"project_name":       req.ProjectName,
 		"project_status":     req.ProjectStatus,
@@ -367,6 +380,11 @@ func (s *DeliveryProjectService) UpdateById(req *opsdevmodel.OpsDeliveryProjectU
 		data["plan_accept_time"] = nil
 	}
 
+	if req.Attribute4 > 0 {
+		data["attribute4"] = req.Attribute4
+		data["attribute3"] = req.Attribute3
+	}
+
 	_, err := s.Dao.Where("id", req.Id).Data(data).Update()
 	return err
 }
@@ -387,6 +405,14 @@ func (s *DeliveryProjectService) AssignDeliveryUser(req *opsdevmodel.AssignDeliv
 		return errors.New("项目ID不能为空")
 	}
 
+	// 权限校验:指派/改派交付负责人需要研发总监或研发主管角色
+	hasManageRole := service.StringsContains(s.CxtUser.Roles, "ResearchAndDevelopmentDirector") ||
+		service.StringsContains(s.CxtUser.Roles, "ResearchAndDevelopmentSupervisor")
+
+	if !hasManageRole {
+		return errors.New("只有研发总监或研发主管才能指派或改派交付负责人")
+	}
+
 	// 查询项目是否存在
 	project, err := s.Dao.Where("id", req.ProjectId).FindOne()
 	if err != nil {

+ 127 - 14
opms_parent/app/service/opsdev/delivery_project_event.go

@@ -3,6 +3,7 @@ package opsdev
 import (
 	"context"
 	"fmt"
+	"strconv"
 	"strings"
 
 	"dashoo.cn/opms_libary/myerrors"
@@ -29,12 +30,13 @@ var eventTypeToAutoTaskType = map[string]string{
 // DeliveryProjectEventService 交付项目事件业务逻辑实现类
 type DeliveryProjectEventService struct {
 	*service.ContextService
-	EventDao      *eventdao.OpsDeliveryProjectEventDao
-	RecordDao     *eventdao.OpsDeliveryProjectEventRecordDao
-	AttachmentDao *eventdao.OpsDeliveryProjectEventAttachmentDao
-	ProjectDao    *eventdao.OpsDeliveryProjectDao
-	TaskDao       *eventdao.OpsEventTaskDao
-	TaskRecordDao *eventdao.OpsEventTaskRecordDao
+	EventDao          *eventdao.OpsDeliveryProjectEventDao
+	RecordDao         *eventdao.OpsDeliveryProjectEventRecordDao
+	AttachmentDao     *eventdao.OpsDeliveryProjectEventAttachmentDao
+	ProjectDao        *eventdao.OpsDeliveryProjectDao
+	TaskDao           *eventdao.OpsEventTaskDao
+	TaskRecordDao     *eventdao.OpsEventTaskRecordDao
+	TaskAttachmentDao *eventdao.OpsEventTaskAttachmentDao
 }
 
 // NewDeliveryProjectEventService 初始化service
@@ -49,6 +51,7 @@ func NewDeliveryProjectEventService(ctx context.Context) (svc *DeliveryProjectEv
 	svc.ProjectDao = eventdao.NewOpsDeliveryProjectDao(svc.Tenant)
 	svc.TaskDao = eventdao.NewOpsEventTaskDao(svc.Tenant)
 	svc.TaskRecordDao = eventdao.NewOpsEventTaskRecordDao(svc.Tenant)
+	svc.TaskAttachmentDao = eventdao.NewOpsEventTaskAttachmentDao(svc.Tenant)
 	return svc, nil
 }
 
@@ -350,11 +353,33 @@ func (s *DeliveryProjectEventService) Create(req *eventmodel.OpsDeliveryProjectE
 			}
 			service.SetCreatedInfo(taskRecordData, s.GetCxtUserId(), s.GetCxtUserName())
 
-			_, err = s.TaskRecordDao.TX(tx).Data(taskRecordData).Insert()
+			taskRecordResult, err := s.TaskRecordDao.TX(tx).Data(taskRecordData).Insert()
 			if err != nil {
 				g.Log().Error(err)
 				return myerrors.DbError("新增任务过程记录失败")
 			}
+
+			taskRecordId, _ := taskRecordResult.LastInsertId()
+
+			// 5. 复制交付事件附件到研发任务
+			if len(req.Attachments) > 0 {
+				for _, att := range req.Attachments {
+					attData := g.Map{
+						s.TaskAttachmentDao.Columns.TaskId:       taskId,
+						s.TaskAttachmentDao.Columns.TaskRecordId: int(taskRecordId),
+						s.TaskAttachmentDao.Columns.FileName:     att.FileName,
+						s.TaskAttachmentDao.Columns.FileUrl:      att.FileUrl,
+						s.TaskAttachmentDao.Columns.FileType:     att.FileType,
+					}
+					service.SetCreatedInfo(attData, s.GetCxtUserId(), s.GetCxtUserName())
+
+					_, err = s.TaskAttachmentDao.TX(tx).Data(attData).Insert()
+					if err != nil {
+						g.Log().Error(err)
+						return myerrors.DbError("复制任务附件失败")
+					}
+				}
+			}
 		}
 
 		return nil
@@ -437,9 +462,88 @@ func (s *DeliveryProjectEventService) UpdateById(req *eventmodel.OpsDeliveryProj
 		return myerrors.DbError("更新事件失败")
 	}
 
+	// 事件关闭时,根据事件类型自动推进关联项目的状态和节点(只允许前进,不允许回退)
+	if req.DeliveryEventStatus == eventmodel.DeliveryEventStatusClosed {
+		s.advanceProjectStatusOnEventClose(entity.ProjectId, req.DeliveryEventType)
+	}
+
 	return nil
 }
 
+func (s *DeliveryProjectEventService) advanceProjectStatusOnEventClose(projectId int, eventType string) {
+	targetStatus, targetNode := s.getTargetStatusAndNode(eventType)
+	if targetStatus == "" && targetNode == "" {
+		return
+	}
+
+	var project eventmodel.OpsDeliveryProject
+	err := s.ProjectDao.Fields(
+		s.ProjectDao.Columns.Id,
+		s.ProjectDao.Columns.ProjectStatus,
+		s.ProjectDao.Columns.DeliveryNode,
+	).WherePri(s.ProjectDao.Columns.Id, projectId).Scan(&project)
+	if err != nil || project.Id <= 0 {
+		g.Log().Error("查询项目数据失败:", err)
+		return
+	}
+
+	updateData := g.Map{}
+	currentStatus := project.ProjectStatus
+	currentNode := project.DeliveryNode
+
+	if targetStatus != "" && s.isForwardTransition(currentStatus, targetStatus) {
+		updateData[s.ProjectDao.Columns.ProjectStatus] = targetStatus
+	}
+	if targetNode != "" && s.isForwardTransition(currentNode, targetNode) {
+		updateData[s.ProjectDao.Columns.DeliveryNode] = targetNode
+	}
+
+	if len(updateData) == 0 {
+		return
+	}
+
+	service.SetUpdatedInfo(updateData, s.GetCxtUserId(), s.GetCxtUserName())
+	_, err = s.ProjectDao.Data(updateData).WherePri(s.ProjectDao.Columns.Id, projectId).Update()
+	if err != nil {
+		g.Log().Error("更新项目状态失败:", err)
+	}
+}
+
+func (s *DeliveryProjectEventService) getTargetStatusAndNode(eventType string) (status string, node string) {
+	statusNodeMap := map[string][2]string{
+		"10": {"20", "10"},
+		"15": {"20", "15"},
+		"20": {"", "20"},
+		"30": {"", "30"},
+		"31": {"", "30"},
+		"32": {"", "30"},
+		"33": {"", "30"},
+		"35": {"", "30"},
+		"37": {"", "30"},
+		"38": {"", "30"},
+		"39": {"", "30"},
+		"40": {"", "30"},
+		"41": {"", "30"},
+		"42": {"", "30"},
+		"50": {"", "30"},
+		"55": {"40", "40"},
+		"60": {"50", "60"},
+	}
+	if entry, ok := statusNodeMap[eventType]; ok {
+		return entry[0], entry[1]
+	}
+	return "", ""
+}
+
+func (s *DeliveryProjectEventService) isForwardTransition(current, target string) bool {
+	currentNum, err1 := strconv.Atoi(current)
+	targetNum, err2 := strconv.Atoi(target)
+	if err1 != nil || err2 != nil {
+		return target > current
+	}
+	return targetNum > currentNum
+}
+
 // DeleteByIds 根据ID批量删除
 func (s *DeliveryProjectEventService) DeleteByIds(ids []int64) error {
 	if len(ids) == 0 {
@@ -760,15 +864,22 @@ func (s *DeliveryProjectEventService) AddRecord(req *eventmodel.OpsDeliveryProje
 	})
 }
 
-// generateTaskNo 生成任务编号(与OpsEventTaskService保持统一格式)
+// generateTaskNo 生成任务编号(与OpsEventTaskService保持统一格式,使用数据库序列防并发冲突
 func (s *DeliveryProjectEventService) generateTaskNo() string {
 	now := gtime.Now()
 	prefix := "TSK" + now.Format("Ymd")
 
+	// 优先使用数据库序列(并发安全),序列异常时降级为查最大+1
+	seqVal, err := s.TaskDao.DB.GetValue("SELECT next_day_reset_val('task_no_seq')")
+	if err == nil {
+		return prefix + fmt.Sprintf("%04d", seqVal.Int())
+	}
+
+	// 降级方案:查询当天最大序号+1(不保证并发安全,仅在序列不可用时兜底)
 	var maxNoResult struct {
 		TaskNo string
 	}
-	err := s.TaskDao.Where(s.TaskDao.Columns.TaskNo+" like ?", prefix+"%").
+	err = s.TaskDao.Where(s.TaskDao.Columns.TaskNo+" like ?", prefix+"%").
 		Order(s.TaskDao.Columns.TaskNo + " desc").
 		Fields(s.TaskDao.Columns.TaskNo).
 		Scan(&maxNoResult)
@@ -803,17 +914,20 @@ func (s *DeliveryProjectEventService) getProjectName(projectId int) string {
 	return project.ProjectName
 }
 
-// generateEventNo 生成事件编码
+// generateEventNo 生成事件编码(使用数据库序列防并发冲突,与generateTaskNo保持一致)
 func (s *DeliveryProjectEventService) generateEventNo() string {
-	// 格式: EVT + 年月日 + 4位序号
 	now := gtime.Now()
 	prefix := "EVT" + now.Format("Ymd")
 
-	// 查询当天最大序号
+	seqVal, err := s.EventDao.DB.GetValue("SELECT next_day_reset_val('event_no_seq')")
+	if err == nil {
+		return prefix + fmt.Sprintf("%04d", seqVal.Int())
+	}
+
 	var maxNoResult struct {
 		DeliveryEventNo string
 	}
-	err := s.EventDao.Where(s.EventDao.Columns.DeliveryEventNo+" like ?", prefix+"%").
+	err = s.EventDao.Where(s.EventDao.Columns.DeliveryEventNo+" like ?", prefix+"%").
 		Order(s.EventDao.Columns.DeliveryEventNo + " desc").
 		Fields(s.EventDao.Columns.DeliveryEventNo).
 		Scan(&maxNoResult)
@@ -821,7 +935,6 @@ func (s *DeliveryProjectEventService) generateEventNo() string {
 		return prefix + "0001"
 	}
 
-	// 提取序号并+1
 	maxNoStr := maxNoResult.DeliveryEventNo
 	if len(maxNoStr) >= len(prefix)+4 {
 		seq := maxNoStr[len(prefix):]

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 613 - 458
opms_parent/app/service/opsdev/operation.go


+ 146 - 50
opms_parent/app/service/opsdev/ops_event_task.go

@@ -607,10 +607,7 @@ func (s *OpsEventTaskService) doComplete(req *opsdevmodel.OpsEventTaskCompleteRe
 	service.SetUpdatedInfo(data, s.GetCxtUserId(), s.GetCxtUserName())
 
 	// 预生成下游任务编号(避免事务内查询重复)
-	var devTaskNo, testTaskNo string
-	if entity.TaskType == opsdevmodel.TaskTypeReqReview {
-		devTaskNo = s.generateTaskNo()
-	}
+	var testTaskNo string
 	if entity.TaskType == opsdevmodel.TaskTypeFeatureDev {
 		testTaskNo = s.generateTaskNo()
 	}
@@ -686,52 +683,7 @@ func (s *OpsEventTaskService) doComplete(req *opsdevmodel.OpsEventTaskCompleteRe
 		}
 
 		// 4. 根据任务类型自动创建下游任务
-		// 4.1 需求评审完成时,自动创建功能开发任务
-		if entity.TaskType == opsdevmodel.TaskTypeReqReview {
-			devTaskData := g.Map{
-				s.TaskDao.Columns.TaskNo:       devTaskNo,
-				s.TaskDao.Columns.ProjectId:    entity.ProjectId,
-				s.TaskDao.Columns.ProjectName:  entity.ProjectName,
-				s.TaskDao.Columns.EventId:      entity.EventId,
-				s.TaskDao.Columns.EventType:    entity.EventType,
-				s.TaskDao.Columns.TaskTitle:    entity.TaskTitle,
-				s.TaskDao.Columns.TaskDesc:     entity.TaskDesc,
-				s.TaskDao.Columns.FunctionName: entity.FunctionName,
-				s.TaskDao.Columns.TaskType:     opsdevmodel.TaskTypeFeatureDev, // 功能开发
-				s.TaskDao.Columns.TaskStatus:   opsdevmodel.TaskStatusTodo,     // 待处理
-				s.TaskDao.Columns.Priority:     entity.Priority,
-				s.TaskDao.Columns.TaskParentId: req.Id,
-			}
-			service.SetCreatedInfo(devTaskData, s.GetCxtUserId(), s.GetCxtUserName())
-
-			devResult, err := s.TaskDao.TX(tx).Data(devTaskData).Insert()
-			if err != nil {
-				g.Log().Error(err)
-				if isDuplicateEntryError(err) {
-					return err
-				}
-				return myerrors.DbError("自动创建功能开发任务失败")
-			}
-
-			devTaskId, _ := devResult.LastInsertId()
-
-			// 创建功能开发任务的过程记录
-			devRecordData := g.Map{
-				s.RecordDao.Columns.TaskId:         devTaskId,
-				s.RecordDao.Columns.HandleUserId:   s.GetCxtUserId(),
-				s.RecordDao.Columns.HandleUserName: s.GetCxtUserName(),
-				s.RecordDao.Columns.HandleContent:  "创建功能开发任务<br/>说明: 由需求评审任务自动创建",
-			}
-			service.SetCreatedInfo(devRecordData, s.GetCxtUserId(), s.GetCxtUserName())
-
-			_, err = s.RecordDao.TX(tx).Data(devRecordData).Insert()
-			if err != nil {
-				g.Log().Error(err)
-				return myerrors.DbError("新增功能开发任务过程记录失败")
-			}
-		}
-
-		// 4.2 功能开发完成时,自动创建功能测试任务
+		// 4.1 功能开发完成时,自动创建功能测试任务(原4.2)
 		if entity.TaskType == opsdevmodel.TaskTypeFeatureDev {
 			testTaskData := g.Map{
 				s.TaskDao.Columns.TaskNo:       testTaskNo,
@@ -792,6 +744,18 @@ func (s *OpsEventTaskService) doComplete(req *opsdevmodel.OpsEventTaskCompleteRe
 				s.TaskDao.Columns.Priority:     entity.Priority,
 				s.TaskDao.Columns.TaskParentId: req.Id,
 			}
+
+			if entity.TaskParentId > 0 {
+				var parentTask opsdevmodel.OpsEventTask
+				err := s.TaskDao.TX(tx).FieldsEx(s.TaskDao.Columns.DeletedTime).
+					WherePri(s.TaskDao.Columns.Id, entity.TaskParentId).
+					Scan(&parentTask)
+				if err == nil && parentTask.Id > 0 {
+					testTaskData[s.TaskDao.Columns.OpsUserId] = parentTask.OpsUserId
+					testTaskData[s.TaskDao.Columns.OpsUserName] = parentTask.OpsUserName
+				}
+			}
+
 			service.SetCreatedInfo(testTaskData, s.GetCxtUserId(), s.GetCxtUserName())
 
 			testResult, err := s.TaskDao.TX(tx).Data(testTaskData).Insert()
@@ -1374,7 +1338,139 @@ func (s *OpsEventTaskService) GetWorkHourList(req *opsdevmodel.OpsEventTaskWorkH
 	return list, nil
 }
 
+// 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)
+	if err != nil {
+		g.Log().Error(err)
+		return nil, myerrors.DbError("查询工时数据失败")
+	}
+
+	type dayAcc struct {
+		totalHours float64
+		taskMap    map[int]*opsdevmodel.DashboardTaskRsp
+	}
+
+	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,
+			}
+		}
+	}
+
+	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,
+		opsdevmodel.TaskStatusCompleted, opsdevmodel.TaskStatusCancelled,
+	)
+	if err != nil {
+		g.Log().Error(err)
+		return nil, myerrors.DbError("查询超期任务数失败")
+	}
+
+	var weekTotal float64
+	days := make([]*opsdevmodel.DashboardDayRsp, 0, 7)
+	for i := 0; i < 7; i++ {
+		date := startDate
+		if i > 0 {
+			d := gtime.New(startDate).AddDate(0, 0, i)
+			date = d.Format("Y-m-d")
+		}
+		targetHours := 8.0
+		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)
+			}
+		}
+		weekTotal += totalHours
+		days = append(days, &opsdevmodel.DashboardDayRsp{
+			Date:        date,
+			TotalHours:  totalHours,
+			TargetHours: targetHours,
+			Tasks:       tasks,
+		})
+	}
+
+	weekTargetHours := 40.0
+	return &opsdevmodel.OpsEventTaskWorkHourDashboardRsp{
+		WeekTargetHours: weekTargetHours,
+		WeekTotalHours:  weekTotal,
+		OverdueCount:    overdueCount.Int(),
+		Days:            days,
+	}, nil
+}
+
 // canAddWorkHour 检查是否可以登记工时(仅处理中状态)
 func (s *OpsEventTaskService) canAddWorkHour(status string) bool {
 	return status == opsdevmodel.TaskStatusProcessing
 }
+
+

+ 154 - 0
opms_parent/app/service/opsdev/work_hour_stat.go

@@ -0,0 +1,154 @@
+package opsdev
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	"dashoo.cn/opms_libary/myerrors"
+	opsdevmodel "dashoo.cn/opms_parent/app/model/opsdev"
+	"dashoo.cn/opms_parent/app/service"
+
+	"github.com/gogf/gf/frame/g"
+)
+
+// rawHourRow UNION ALL 查询返回的中间行
+type rawHourRow struct {
+	OpsUserId   int
+	OpsUserName string
+	WorkDate    string
+	OpHour      float64
+	RdHour      float64
+}
+
+// WorkHourStatService 工时统计服务
+type WorkHourStatService struct {
+	*service.ContextService
+}
+
+// NewWorkHourStatService 创建工时统计服务实例
+func NewWorkHourStatService(ctx context.Context) (svc *WorkHourStatService, err error) {
+	svc = new(WorkHourStatService)
+	if svc.ContextService, err = svc.Init(ctx); err != nil {
+		return nil, err
+	}
+	return svc, nil
+}
+
+// GetStat 获取工时统计
+func (s *WorkHourStatService) GetStat(req *opsdevmodel.WorkHourStatReq) (*opsdevmodel.WorkHourStatRsp, error) {
+	startDate, err := time.Parse("2006-01-02", req.StartDate)
+	if err != nil {
+		return nil, myerrors.TipsError("开始日期格式错误: " + err.Error())
+	}
+	endDate, err := time.Parse("2006-01-02", req.EndDate)
+	if err != nil {
+		return nil, myerrors.TipsError("结束日期格式错误: " + err.Error())
+	}
+
+	var allDates []string
+	var dayHeaders []opsdevmodel.DayHeader
+	weekdayNames := []string{"周日", "周一", "周二", "周三", "周四", "周五", "周六"}
+
+	for d := startDate; !d.After(endDate); d = d.AddDate(0, 0, 1) {
+		dateStr := d.Format("2006-01-02")
+		allDates = append(allDates, dateStr)
+
+		label := d.Format("01-02")
+		if req.Mode == "week" {
+			label = weekdayNames[d.Weekday()]
+		}
+		dayHeaders = append(dayHeaders, opsdevmodel.DayHeader{
+			Label: label,
+			Date:  dateStr,
+		})
+	}
+
+	unionSQL := fmt.Sprintf(`
+		SELECT ops_user_id, ops_user_name, DATE(work_date) AS work_date,
+		       SUM(work_hour) AS op_hour, 0.0 AS rd_hour
+		FROM ops_operation_work_hour
+		WHERE work_date >= ? AND work_date <= ? AND deleted_time IS NULL
+		GROUP BY ops_user_id, ops_user_name, DATE(work_date)
+		UNION ALL
+		SELECT ops_user_id, ops_user_name, DATE(actual_work_date) AS work_date,
+		       0.0 AS op_hour, SUM(actual_work_hour) AS rd_hour
+		FROM ops_event_task_work_hour
+		WHERE actual_work_date >= ? AND actual_work_date <= ? AND deleted_time IS NULL
+		GROUP BY ops_user_id, ops_user_name, DATE(actual_work_date)
+	`)
+
+	startDT := req.StartDate + " 00:00:00"
+	endDT := req.EndDate + " 23:59:59"
+
+	var rawRows []rawHourRow
+	if err := g.DB(s.Tenant).GetScan(&rawRows, unionSQL, startDT, endDT, startDT, endDT); err != nil {
+		return nil, myerrors.TipsError("查询工时数据失败: " + err.Error())
+	}
+
+	type personAgg struct {
+		OpsUserId   int
+		OpsUserName string
+		DailyHours  map[string]opsdevmodel.DayData
+	}
+
+	personMap := make(map[int]*personAgg)
+
+	for i := range rawRows {
+		row := &rawRows[i]
+		p, ok := personMap[row.OpsUserId]
+		if !ok {
+			p = &personAgg{
+				OpsUserId:   row.OpsUserId,
+				OpsUserName: row.OpsUserName,
+				DailyHours:  make(map[string]opsdevmodel.DayData),
+			}
+			personMap[row.OpsUserId] = p
+		}
+
+		dd := p.DailyHours[row.WorkDate]
+		dd.OpHour += row.OpHour
+		dd.RdHour += row.RdHour
+		p.DailyHours[row.WorkDate] = dd
+	}
+
+	var persons []opsdevmodel.PersonRow
+
+	for _, p := range personMap {
+		if len(req.UserIds) > 0 {
+			found := false
+			for _, uid := range req.UserIds {
+				if uid == p.OpsUserId {
+					found = true
+					break
+				}
+			}
+			if !found {
+				continue
+			}
+		}
+
+		dailyHours := make(map[string]opsdevmodel.DayData)
+		var totalOp, totalRd float64
+
+		for _, dateStr := range allDates {
+			dd := p.DailyHours[dateStr]
+			dailyHours[dateStr] = dd
+			totalOp += dd.OpHour
+			totalRd += dd.RdHour
+		}
+
+		persons = append(persons, opsdevmodel.PersonRow{
+			UserId:      p.OpsUserId,
+			UserName:    p.OpsUserName,
+			DailyHours:  dailyHours,
+			TotalOpHour: totalOp,
+			TotalRdHour: totalRd,
+		})
+	}
+
+	return &opsdevmodel.WorkHourStatRsp{
+		Header:  dayHeaders,
+		Persons: persons,
+	}, nil
+}

+ 2 - 0
opms_parent/main.go

@@ -90,6 +90,8 @@ func main() {
 	s.RegisterName("CtrContractEvent", new(contract.CtrContractEvent), "")
 	// 注册 OPS 任务处理器
 	s.RegisterName("OpsEventTask", new(opsdev.OpsEventTaskHandler), "")
+	// 注册工时统计处理器
+	s.RegisterName("WorkHourStat", new(opsdev.WorkHourStatHandler), "")
 
 	// 首页
 	s.RegisterName("Home", new(home.HomeHandler), "")

+ 2 - 4
opms_parent/schema/function.sql

@@ -30,7 +30,7 @@ update plat_sequence set year=2023,current_value=49  where name = 'contract_code
 DROP FUNCTION IF EXISTS next_year_reset_val;
 DELIMITER $
 CREATE FUNCTION next_year_reset_val (seq_name VARCHAR(50)) RETURNS INTEGER LANGUAGE SQL DETERMINISTIC CONTAINS SQL SQL SECURITY DEFINER COMMENT '' BEGIN
-UPDATE plat_sequence SET current_value=0, `year`=year(now()) WHERE `year` = year(now())-1;
+UPDATE plat_sequence SET current_value=0, `year`=year(now()) WHERE `name` = seq_name AND `year` = year(now())-1;
 UPDATE plat_sequence SET current_value=current_value+increment WHERE name = seq_name;
 RETURN currval(seq_name);
 END $
@@ -57,9 +57,7 @@ alter table plat_sequence add `day` date DEFAULT NULL COMMENT '日期' after `ye
 DROP FUNCTION IF EXISTS next_day_reset_val;
 DELIMITER $
 CREATE FUNCTION next_day_reset_val (seq_name VARCHAR(50)) RETURNS INTEGER LANGUAGE SQL DETERMINISTIC CONTAINS SQL SQL SECURITY DEFINER COMMENT '' BEGIN
-    -- 如果日期不是今天,重置序列为0
-    UPDATE plat_sequence SET current_value=0, `day`=CURDATE() WHERE `day` IS NULL OR `day` != CURDATE();
-    -- 递增序列号
+    UPDATE plat_sequence SET current_value=0, `day`=CURDATE() WHERE name = seq_name AND (`day` IS NULL OR `day` != CURDATE());
     UPDATE plat_sequence SET current_value=current_value+increment WHERE name = seq_name;
     RETURN currval(seq_name);
 END $

+ 30 - 0
opms_parent/schema/migration_ops_work_hour.sql

@@ -0,0 +1,30 @@
+-- ============================================================================
+-- Migration: 运维工时功能 - 数据库变更
+-- Description: 运维事件工时登记功能所需的数据库变更
+-- 1. 在 ops_operation_event 表新增 total_work_hour 字段
+-- 2. 新建 ops_operation_work_hour 表
+-- ============================================================================
+
+-- 1. 在运维事件表新增累计工时字段
+ALTER TABLE `ops_operation_event`
+    ADD COLUMN `total_work_hour` DECIMAL(8,2) DEFAULT 0.00 COMMENT '累计工时(小时)'
+    AFTER `complete_desc`;
+
+-- 2. 新建运维工时登记表
+CREATE TABLE `ops_operation_work_hour` (
+    `id`            BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
+    `event_id`      BIGINT UNSIGNED NOT NULL COMMENT '关联运维事件ID',
+    `ops_user_id`   INT             NOT NULL COMMENT '登记人ID',
+    `ops_user_name` VARCHAR(64)     NOT NULL COMMENT '登记人姓名',
+    `work_date`     DATETIME        NOT NULL COMMENT '工作日期',
+    `work_hour`     DECIMAL(8,2)    NOT NULL COMMENT '工时(小时)',
+    `remark`        TEXT            NULL     COMMENT '工作说明',
+    `created_by`    INT             NOT NULL COMMENT '创建者',
+    `created_name`  VARCHAR(64)     NOT NULL COMMENT '创建人',
+    `created_time`  DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `updated_by`    INT             NULL     COMMENT '更新者',
+    `updated_name`  VARCHAR(64)     NULL     COMMENT '更新人',
+    `updated_time`  DATETIME        NULL     COMMENT '更新时间',
+    `deleted_time`  DATETIME        NULL     COMMENT '删除时间',
+    INDEX `idx_event_id` (`event_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='运维事件工时登记表';

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä