优化日程提醒模块

This commit is contained in:
2026-09-08 22:18:36 +08:00
parent 3d53f03246
commit 7f3de60423
17 changed files with 1516 additions and 270 deletions
+4 -4
View File
@@ -16,10 +16,10 @@ maxmemory = 10485760
# 数据库配置
# MySQL - 远程连接配置
mysqluser = go-platform
mysqlpass = FSmJCSJ5wk8pjjDC
# mysqlurls = 10.31.100.3:3306
mysqlurls = 212.64.112.158:3388
mysqluser = root
mysqlpass = Lzq920103
# mysqlurls = 10.31.100.4:3306
mysqlurls = 212.64.112.158:5506
mysqldb = go-platform
# ORM配置
+11 -5
View File
@@ -211,8 +211,6 @@ type employeeFileDTO struct {
Position string `json:"position"`
Phone string `json:"phone"`
Email string `json:"email"`
Education string `json:"education"`
Nation string `json:"nation"`
HomeAddress string `json:"home_address"`
AccountStatus int8 `json:"account_status"`
HireDate string `json:"hire_date"`
@@ -226,6 +224,8 @@ type employeeFileDTO struct {
func efFileCompleteness(file models.BackendEmployeeFile) int {
items := []bool{
strings.TrimSpace(file.IDCard) != "",
strings.TrimSpace(file.Education) != "",
strings.TrimSpace(file.Nation) != "",
strings.TrimSpace(file.PoliticalStatus) != "",
file.MaritalStatus > 0,
strings.TrimSpace(file.NativePlace) != "",
@@ -305,8 +305,6 @@ func (c *BackendEmployeeFileController) efAssembleDTOList(tid int, files []model
dto.Position = derefString(emp.Position)
dto.Phone = derefString(emp.Phone)
dto.Email = derefString(emp.Email)
dto.Education = derefString(emp.Education)
dto.Nation = derefString(emp.Nation)
dto.HomeAddress = derefString(emp.HomeAddress)
dto.AccountStatus = emp.AccountStatus
}
@@ -452,6 +450,8 @@ type employeeFilePayload struct {
IDCardFront string `json:"id_card_front"`
IDCardBack string `json:"id_card_back"`
EducationPhoto string `json:"education_photo"`
Education string `json:"education"`
Nation string `json:"nation"`
PoliticalStatus string `json:"political_status"`
MaritalStatus int8 `json:"marital_status"`
NativePlace string `json:"native_place"`
@@ -479,6 +479,8 @@ func (c *BackendEmployeeFileController) efParsePayload() (employeeFilePayload, b
payload.FileNo = strings.TrimSpace(payload.FileNo)
payload.IDCard = strings.TrimSpace(payload.IDCard)
payload.Education = strings.TrimSpace(payload.Education)
payload.Nation = strings.TrimSpace(payload.Nation)
payload.WorkEmail = strings.TrimSpace(payload.WorkEmail)
if payload.IDCard != "" && len(payload.IDCard) > 30 {
c.efErr(400, 400, "身份证号过长")
@@ -570,6 +572,8 @@ func (c *BackendEmployeeFileController) Create() {
IDCardFront: payload.IDCardFront,
IDCardBack: payload.IDCardBack,
EducationPhoto: payload.EducationPhoto,
Education: payload.Education,
Nation: payload.Nation,
PoliticalStatus: payload.PoliticalStatus,
MaritalStatus: payload.MaritalStatus,
NativePlace: payload.NativePlace,
@@ -629,6 +633,8 @@ func (c *BackendEmployeeFileController) Update() {
item.IDCard = payload.IDCard
// 证照字段由 UpdateCertificates 独立维护,基础资料保存不得覆盖已有附件。
item.PoliticalStatus = payload.PoliticalStatus
item.Education = payload.Education
item.Nation = payload.Nation
item.MaritalStatus = payload.MaritalStatus
item.NativePlace = payload.NativePlace
item.HouseholdAddress = payload.HouseholdAddress
@@ -645,7 +651,7 @@ func (c *BackendEmployeeFileController) Update() {
item.UpdateTime = &now
if _, err := models.Orm.Update(&item,
"FileNo", "IDCard", "PoliticalStatus", "MaritalStatus", "NativePlace",
"FileNo", "IDCard", "PoliticalStatus", "Education", "Nation", "MaritalStatus", "NativePlace",
"HouseholdAddress", "CurrentAddress", "EmergencyContact", "EmergencyPhone",
"EmergencyRelation", "WorkEmail", "HireDate", "RegularDate", "LeaveDate",
"EmploymentStatus", "Remark", "UpdateTime"); err != nil {
+309 -18
View File
@@ -52,6 +52,11 @@ func (c *BackendOaScheduleController) oasOk(data interface{}) {
_ = c.ServeJSON()
}
// Prepare 所有日程接口执行前补齐顺延所需字段,避免历史库缺列导致查询报错。
func (c *BackendOaScheduleController) Prepare() {
_ = models.EnsureOaScheduleCarryColumns()
}
var (
oasDatePattern = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
oasTimePattern = regexp.MustCompile(`^\d{2}:\d{2}$`)
@@ -86,8 +91,61 @@ func normalizeOaDate(s string) string {
return s
}
// oaScheduleFilter 日程列表筛选条件,用于构造 ORM 查询条件。
// 指针式的 Has*/On* 标记用于区分"未传参"与"传了默认值 0/空串"。
type oaScheduleFilter struct {
Keyword string
Priority int8
HasPriority bool
StartDate string
EndDate string
OnDate string
Status int8
HasStatus bool
Ongoing bool
}
// oaScheduleCond 构造日程查询条件,始终按租户 + 用户双重隔离。
// Ongoing 表示"进行中":跨天顺延中的任务,即被顺延出去的源日程(carry_over=1)
// 或由前序日程顺延而来的续做日程(source_id>0)。
func oaScheduleCond(tid int, userID int, f oaScheduleFilter) *orm.Condition {
cond := orm.NewCondition()
cond = cond.And("is_deleted", 0).And("tid", tid).And("user_id", userID)
if f.Keyword != "" {
cond = cond.And("title__icontains", f.Keyword)
}
if f.HasPriority {
cond = cond.And("priority", f.Priority)
}
if f.StartDate != "" {
cond = cond.And("schedule_date__gte", f.StartDate)
}
if f.EndDate != "" {
cond = cond.And("schedule_date__lte", f.EndDate)
}
if f.OnDate != "" {
cond = cond.And("schedule_date", f.OnDate)
}
if f.HasStatus {
cond = cond.And("status", f.Status)
}
if f.Ongoing {
sub := orm.NewCondition()
sub = sub.Or("source_id__gt", 0).Or("carry_over", 1)
cond = cond.AndCond(sub)
}
return cond
}
func oaScheduleQS(tid int, userID int, f oaScheduleFilter) orm.QuerySeter {
return models.Orm.QueryTable(new(models.OaSchedule)).
SetCond(oaScheduleCond(tid, userID, f))
}
// List GET /backend/oa/schedule/list
// 分页查询当前用户的日程,支持 keyword/status/priority/start_date/end_date 筛选。
// 分页查询当前用户的日程,支持 keyword/status/priority/start_date/end_date 筛选,
// 以及 category 待办分类:all-全部 today-今日 pending-未完成 ongoing-进行中 done-已完成。
// 返回值额外带 counts(各分类数量),供页面 tabs 展示角标。
// 日历视图传 start_date/end_date 且 pageSize 足够大时一次拉取区间全量数据。
func (c *BackendOaScheduleController) List() {
claims, err := c.oaScheduleClaims()
@@ -103,6 +161,7 @@ func (c *BackendOaScheduleController) List() {
priority := strings.TrimSpace(c.GetString("priority"))
startDate := strings.TrimSpace(c.GetString("start_date"))
endDate := strings.TrimSpace(c.GetString("end_date"))
category := strings.TrimSpace(c.GetString("category"))
if page < 1 {
page = 1
@@ -111,29 +170,54 @@ func (c *BackendOaScheduleController) List() {
pageSize = 20
}
qs := models.Orm.QueryTable(new(models.OaSchedule)).
Filter("is_deleted", 0).
Filter("tid", claims.TenantId).
Filter("user_id", claims.UserID)
if keyword != "" {
qs = qs.Filter("title__icontains", keyword)
}
base := oaScheduleFilter{Keyword: keyword}
if status == "0" || status == "1" {
statusVal, _ := strconv.Atoi(status)
qs = qs.Filter("status", int8(statusVal))
base.HasStatus = true
base.Status = int8(statusVal)
}
if priority == "0" || priority == "1" || priority == "2" {
priorityVal, _ := strconv.Atoi(priority)
qs = qs.Filter("priority", int8(priorityVal))
}
if isValidOaDate(startDate) {
qs = qs.Filter("schedule_date__gte", startDate)
}
if isValidOaDate(endDate) {
qs = qs.Filter("schedule_date__lte", endDate)
base.HasPriority = true
base.Priority = int8(priorityVal)
}
// 分类角标按全量统计(只受关键词/优先级影响),不随日历翻月或区间筛选跳动
countOf := func(f oaScheduleFilter) int64 {
n, _ := oaScheduleQS(claims.TenantId, claims.UserID, f).Count()
return n
}
today := time.Now().Format("2006-01-02")
counts := map[string]interface{}{
"all": countOf(base),
"today": countOf(oaScheduleFilter{Keyword: base.Keyword, Priority: base.Priority, HasPriority: base.HasPriority, OnDate: today}),
"pending": countOf(oaScheduleFilter{Keyword: base.Keyword, Priority: base.Priority, HasPriority: base.HasPriority, HasStatus: true, Status: 0}),
"ongoing": countOf(oaScheduleFilter{Keyword: base.Keyword, Priority: base.Priority, HasPriority: base.HasPriority, Ongoing: true}),
"done": countOf(oaScheduleFilter{Keyword: base.Keyword, Priority: base.Priority, HasPriority: base.HasPriority, HasStatus: true, Status: 1}),
}
// 列表数据 = 基础筛选 + 日期区间 + 当前分类
listFilter := base
if isValidOaDate(startDate) {
listFilter.StartDate = startDate
}
if isValidOaDate(endDate) {
listFilter.EndDate = endDate
}
switch category {
case "today":
listFilter.OnDate = today
case "pending":
listFilter.HasStatus = true
listFilter.Status = 0
case "ongoing":
listFilter.Ongoing = true
case "done":
listFilter.HasStatus = true
listFilter.Status = 1
}
qs := oaScheduleQS(claims.TenantId, claims.UserID, listFilter)
total, _ := qs.Count()
var list []models.OaSchedule
@@ -153,7 +237,11 @@ func (c *BackendOaScheduleController) List() {
list[i].ScheduleDate = normalizeOaDate(list[i].ScheduleDate)
}
c.oasOk(map[string]interface{}{"list": list, "total": total})
c.oasOk(map[string]interface{}{
"list": list,
"total": total,
"counts": counts,
})
}
// Stats GET /backend/oa/schedule/stats
@@ -436,3 +524,206 @@ func (c *BackendOaScheduleController) FinishSchedule() {
c.oasOk(map[string]interface{}{"status": newStatus})
}
// nextOaDate 返回 dateStr 之后的第 days 天,格式仍为 "YYYY-MM-DD"。
func nextOaDate(dateStr string, days int) string {
t, err := time.ParseInLocation("2006-01-02", dateStr, time.Local)
if err != nil {
return ""
}
return t.AddDate(0, 0, days).Format("2006-01-02")
}
// oaScheduleRootID 取顺延链的源头日程ID:本身就是顺延出来的则沿用其来源,否则为自身。
func oaScheduleRootID(item *models.OaSchedule) uint64 {
if item.SourceID > 0 {
return item.SourceID
}
return item.ID
}
// carryOaSchedule 把一条待办日程顺延到目标日期:
// 1) 原日程标记完成(status=1, carry_over=1, carry_to_date=目标日期);
// 2) 在目标日期生成一条同内容的待办,source_id 指向顺延链源头。
// 返回新生成的日程与失败原因(空串表示成功)。
func carryOaSchedule(item *models.OaSchedule, targetDate string, claims *jwtutil.Claims, now time.Time) (*models.OaSchedule, string) {
if item.Status == 1 {
return nil, "该日程已完成,无需顺延"
}
if !isValidOaDate(targetDate) {
return nil, "顺延日期无效"
}
if targetDate <= normalizeOaDate(item.ScheduleDate) {
return nil, "顺延日期需晚于当前日程日期"
}
rootID := oaScheduleRootID(item)
// 同一条顺延链在目标日期只允许存在一条,避免重复顺延刷出多份待办。
exist := models.Orm.QueryTable(new(models.OaSchedule)).
Filter("is_deleted", 0).
Filter("tid", claims.TenantId).
Filter("user_id", claims.UserID).
Filter("source_id", rootID).
Filter("schedule_date", targetDate).
Exist()
if exist {
return nil, "该日期已存在顺延的日程"
}
next := &models.OaSchedule{
Tid: item.Tid,
UserID: item.UserID,
UserName: item.UserName,
Title: item.Title,
Content: item.Content,
ScheduleDate: targetDate,
StartTime: item.StartTime,
EndTime: item.EndTime,
AllDay: item.AllDay,
Color: item.Color,
Priority: item.Priority,
Status: 0,
SourceID: rootID,
CarryCount: item.CarryCount + 1,
IsDeleted: 0,
CreateTime: now,
UpdateTime: &now,
}
if _, err := models.Orm.Insert(next); err != nil {
return nil, "生成顺延日程失败"
}
item.Status = 1
item.FinishTime = &now
item.CarryOver = 1
item.CarryToDate = targetDate
item.UpdateTime = &now
if _, err := models.Orm.Update(item,
"Status", "FinishTime", "CarryOver", "CarryToDate", "UpdateTime"); err != nil {
return nil, "标记原日程完成失败"
}
return next, ""
}
// CarrySchedule POST /backend/oa/schedule/carry/:id
// 将单条待办顺延到指定日期(默认下一天):原日程标记完成,目标日期自动生成新待办。
func (c *BackendOaScheduleController) CarrySchedule() {
claims, err := c.oaScheduleClaims()
if err != nil {
c.oasJsonErr(401, 401, "未登录或无权限")
return
}
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if err != nil || id == 0 {
c.oasJsonErr(400, 400, "无效ID")
return
}
// 目标日期可选,不传则默认顺延到下一天
var payload struct {
TargetDate string `json:"target_date"`
}
if raw, e := io.ReadAll(c.Ctx.Request.Body); e == nil && len(raw) > 0 {
_ = json.Unmarshal(raw, &payload)
}
payload.TargetDate = strings.TrimSpace(payload.TargetDate)
var item models.OaSchedule
err = models.Orm.QueryTable(new(models.OaSchedule)).
Filter("id", id).
Filter("is_deleted", 0).
Filter("tid", claims.TenantId).
Filter("user_id", claims.UserID).
One(&item)
if err != nil {
c.oasJsonErr(404, 404, "日程不存在")
return
}
targetDate := payload.TargetDate
if targetDate == "" {
targetDate = nextOaDate(normalizeOaDate(item.ScheduleDate), 1)
}
now := time.Now()
next, msg := carryOaSchedule(&item, targetDate, claims, now)
if msg != "" {
c.oasJsonErr(400, 400, msg)
return
}
c.oasOk(map[string]interface{}{
"new_id": next.ID,
"schedule_date": next.ScheduleDate,
"carry_count": next.CarryCount,
})
}
// CarryPending POST /backend/oa/schedule/carry-pending
// 批量顺延:把指定日期(默认今天)全部未完成的日程一次性顺延到目标日期(默认下一天)。
// 已在目标日期存在顺延记录的会被跳过,不会重复生成。
func (c *BackendOaScheduleController) CarryPending() {
claims, err := c.oaScheduleClaims()
if err != nil {
c.oasJsonErr(401, 401, "未登录或无权限")
return
}
var payload struct {
Date string `json:"date"`
TargetDate string `json:"target_date"`
}
if raw, e := io.ReadAll(c.Ctx.Request.Body); e == nil && len(raw) > 0 {
_ = json.Unmarshal(raw, &payload)
}
payload.Date = strings.TrimSpace(payload.Date)
payload.TargetDate = strings.TrimSpace(payload.TargetDate)
now := time.Now()
date := payload.Date
if !isValidOaDate(date) {
date = now.Format("2006-01-02")
}
targetDate := payload.TargetDate
if !isValidOaDate(targetDate) {
targetDate = nextOaDate(date, 1)
}
if !isValidOaDate(targetDate) {
c.oasJsonErr(400, 400, "顺延日期无效")
return
}
var list []models.OaSchedule
_, err = models.Orm.QueryTable(new(models.OaSchedule)).
Filter("is_deleted", 0).
Filter("tid", claims.TenantId).
Filter("user_id", claims.UserID).
Filter("schedule_date", date).
Filter("status", 0).
OrderBy("start_time", "id").
All(&list)
if err != nil && err != orm.ErrNoRows {
c.oasJsonErr(500, 500, "查询失败")
return
}
success, skipped := 0, 0
lastDate := targetDate
for i := range list {
if _, msg := carryOaSchedule(&list[i], targetDate, claims, now); msg != "" {
skipped++
continue
}
success++
lastDate = targetDate
}
c.oasOk(map[string]interface{}{
"success": success,
"skipped": skipped,
"schedule_date": lastDate,
})
}
BIN
View File
Binary file not shown.
+2
View File
@@ -15,6 +15,8 @@ type BackendEmployeeFile struct {
IDCardFront string `orm:"column(id_card_front);size(500);default()" json:"id_card_front"`
IDCardBack string `orm:"column(id_card_back);size(500);default()" json:"id_card_back"`
EducationPhoto string `orm:"column(education_photo);size(500);default()" json:"education_photo"`
Education string `orm:"column(education);size(50);default()" json:"education"`
Nation string `orm:"column(nation);size(30);default()" json:"nation"`
PoliticalStatus string `orm:"column(political_status);size(30);default()" json:"political_status"`
MaritalStatus int8 `orm:"column(marital_status);default(0)" json:"marital_status"`
NativePlace string `orm:"column(native_place);size(100);default()" json:"native_place"`
+39 -1
View File
@@ -1,11 +1,19 @@
package models
import "time"
import (
"sync"
"time"
)
// OaSchedule OA日程(日历待办): yz_backend_oa_schedule
// 日程按租户 tid + 用户 user_id 双重隔离,供 backend 日程管理页面的
// 月历视图与待办列表读写。schedule_date/start_time/end_time 均以
// "YYYY-MM-DD"、"HH:mm" 字符串存取,前端无需再做时区换算。
//
// 顺延相关字段(source_id/carry_over/carry_count/carry_to_date):
// 当天做不完的日程可"顺延到下一天"——原日程标记完成并置 carry_over=1,
// 同时在目标日期生成一条待办,source_id 指向整条顺延链的源头日程,
// carry_count 记录累计顺延次数,便于追溯一个任务跨了多少天。
type OaSchedule struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Tid int `orm:"column(tid)" json:"tid"`
@@ -21,6 +29,10 @@ type OaSchedule struct {
Priority int8 `orm:"column(priority);default(0)" json:"priority"`
Status int8 `orm:"column(status);default(0)" json:"status"`
FinishTime *time.Time `orm:"column(finish_time);type(datetime);null" json:"finish_time"`
SourceID uint64 `orm:"column(source_id);default(0)" json:"source_id"`
CarryOver int8 `orm:"column(carry_over);default(0)" json:"carry_over"`
CarryCount int `orm:"column(carry_count);default(0)" json:"carry_count"`
CarryToDate string `orm:"column(carry_to_date);size(10);default()" json:"carry_to_date"`
IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime *time.Time `orm:"column(update_time);auto_now;type(datetime);null" json:"update_time"`
@@ -30,3 +42,29 @@ type OaSchedule struct {
func (m *OaSchedule) TableName() string {
return "yz_backend_oa_schedule"
}
var oaScheduleCarryColumnsOnce sync.Once
// EnsureOaScheduleCarryColumns 为日程表补齐"顺延到下一天"所需字段。
// 历史库可能早于该特性建表,这里在首次访问日程接口时自动补列,
// 字段已存在时 MySQL 会报 duplicate column,统一忽略,不阻断主流程。
func EnsureOaScheduleCarryColumns() error {
if Orm == nil {
return nil
}
var err error
oaScheduleCarryColumnsOnce.Do(func() {
alters := []string{
"ALTER TABLE yz_backend_oa_schedule ADD COLUMN source_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '顺延来源日程ID 0-原始日程'",
"ALTER TABLE yz_backend_oa_schedule ADD COLUMN carry_over tinyint NOT NULL DEFAULT 0 COMMENT '是否已顺延到下一天 0-否 1-是'",
"ALTER TABLE yz_backend_oa_schedule ADD COLUMN carry_count int NOT NULL DEFAULT 0 COMMENT '累计顺延次数'",
"ALTER TABLE yz_backend_oa_schedule ADD COLUMN carry_to_date varchar(10) NOT NULL DEFAULT '' COMMENT '顺延目标日期(YYYY-MM-DD)'",
}
for _, sql := range alters {
if _, e := Orm.Raw(sql).Exec(); e != nil {
err = e
}
}
})
return err
}
+2
View File
@@ -263,6 +263,8 @@ func RegisterAuthRoutes() {
beego.Router("/backend/oa/schedule/update/:id", &controllers.BackendOaScheduleController{}, "post:Update")
beego.Router("/backend/oa/schedule/delete/:id", &controllers.BackendOaScheduleController{}, "delete:Delete")
beego.Router("/backend/oa/schedule/finish/:id", &controllers.BackendOaScheduleController{}, "post:FinishSchedule")
beego.Router("/backend/oa/schedule/carry/:id", &controllers.BackendOaScheduleController{}, "post:CarrySchedule")
beego.Router("/backend/oa/schedule/carry-pending", &controllers.BackendOaScheduleController{}, "post:CarryPending")
}
// registerOrganizationRoutes 为指定模块前缀注册组织架构路由。