package service import ( "errors" "strconv" "strings" "time" "github.com/sundynix/pets-be/internal/model" "gorm.io/gorm" ) // 套用养护方案:把模板里的重复规则展开成某个月的计划节点。 // // 为什么要展开成一条条实体行、而不是「存规则、读时展开」: // 勾选状态是按天的。存规则的话得再开一张表记「哪天打过勾」, // 而且用户改一条(比如把「每周二梳毛」这周挪到周三)就要引入例外记录。 // 一次展开成行,增删改和打勾全都免费。 // // 代价是套用之后模板和计划就断开了:后台改模板不会影响已套用的宠物。 // 这是有意的——用户已经在自己的计划上勾了几十个勾,后台一改就被覆盖才是灾难。 // TemplateOption 建档/计划页上给用户挑的一套方案 type TemplateOption struct { ID string `json:"id"` Name string `json:"name"` Summary string `json:"summary"` // 按重复方式分开报数。只给一个总数会骗人:10 条明细里有 4 条是「每天」, // 套用一整月会展开成 130 条节点,用户看到「10 条」再看到 130 条会以为出 bug 了 Daily int `json:"daily"` // 每天做的 Weekly int `json:"weekly"` // 每周做的 Once int `json:"once"` // 一次性的 Monthly int `json:"monthly"` // 每月做的 Reminds int `json:"reminders"` // 会建的提醒 } // ListTemplateOptions 某物种阶段下可选的方案。空列表意味着后台没配, // 前端要显示「暂无方案,可以自己排」而不是转圈 func (s *Service) ListTemplateOptions(species, stage string) []TemplateOption { var tpls []model.CareTemplate s.db.Preload("Items"). Where("species = ? AND stage = ? AND enabled = ?", species, stage, true). Order("sort asc, id asc").Find(&tpls) out := make([]TemplateOption, 0, len(tpls)) for _, t := range tpls { o := TemplateOption{ID: t.ID, Name: t.Name, Summary: t.Summary} if o.Name == "" { o.Name = t.Stage + "养护方案" // 老数据没名字,兜一个别显示空白 } for _, it := range t.Items { switch { case it.Kind == model.CareKindReminder: o.Reminds++ case it.Repeat == model.RepeatDaily: o.Daily++ case it.Repeat == model.RepeatWeekly: o.Weekly++ case it.Repeat == model.RepeatMonthly: o.Monthly++ default: o.Once++ } } out = append(out, o) } return out } // weekdaysOf 解析 "1,3,5" → {1:true,3:true,5:true},1=周一 func weekdaysOf(s string) map[int]bool { out := map[int]bool{} for _, p := range strings.Split(s, ",") { n, err := strconv.Atoi(strings.TrimSpace(p)) if err == nil && n >= 1 && n <= 7 { out[n] = true } } return out } // isoWeekday 把 Go 的 Sunday=0 换成 1=周一 … 7=周日 func isoWeekday(t time.Time) int { d := int(t.Weekday()) if d == 0 { return 7 } return d } // expandItem 把一条明细展开成若干个日期。 // from/to 是套用窗口(通常是「今天」到「本月最后一天」) func expandItem(it model.CareTemplateItem, from, to time.Time) []time.Time { var out []time.Time switch it.Repeat { case model.RepeatDaily: for d := from; !d.After(to); d = d.AddDate(0, 0, 1) { out = append(out, d) } case model.RepeatWeekly: wd := weekdaysOf(it.Weekdays) if len(wd) == 0 { return nil // 配了每周却没选星期几,展开不出东西,跳过比排错天好 } for d := from; !d.After(to); d = d.AddDate(0, 0, 1) { if wd[isoWeekday(d)] { out = append(out, d) } } case model.RepeatMonthly: md := it.MonthDay if md < 1 { md = 1 } // 逐月走,因为窗口可能跨月 for cur := time.Date(from.Year(), from.Month(), 1, 0, 0, 0, 0, from.Location()); !cur.After(to); cur = cur.AddDate(0, 1, 0) { last := cur.AddDate(0, 1, -1).Day() day := md if day > last { day = last // 「每月 31 号」在 2 月按月末算,不然这个月直接没有 } d := time.Date(cur.Year(), cur.Month(), day, 0, 0, 0, 0, from.Location()) if !d.Before(from) && !d.After(to) { out = append(out, d) } } default: // once:从窗口起点偏移 N 天 d := from.AddDate(0, 0, it.Day) // 偏移出窗口的直接丢掉。硬塞到窗口末尾会让「到家第 14 天驱虫」 // 变成「月底驱虫」,日期错了比没有更糟 if !d.Before(from) && !d.After(to) { out = append(out, d) } } return out } // ApplyTemplateResult 套用结果,给前端一句反馈用 type ApplyTemplateResult struct { Plans int `json:"plans"` // 排进了几条计划 Reminders int `json:"reminders"` // 建了几条提醒 From string `json:"from"` To string `json:"to"` } // ApplyTemplate 把一套方案套用到某只宠物的某个月。 // monthOffset: 0 本月,1 下月。本月只从今天算起——给用户排一堆昨天的事没意义 func (s *Service) ApplyTemplate(userID, petID, templateID string, monthOffset int) (*ApplyTemplateResult, error) { pet, err := s.ownedPet(userID, petID) if err != nil { return nil, err } var tpl model.CareTemplate if e := s.db.Preload("Items", func(db *gorm.DB) *gorm.DB { return db.Order("sort, id") }). First(&tpl, "id = ?", templateID).Error; e != nil { return nil, ErrNotFound } first, last := monthBounds(monthOffset) from := first if monthOffset == 0 { today := time.Now() t := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, time.Local) if t.After(from) { from = t } } if from.After(last) { return nil, errors.New("这个月已经过完了") } plan, err := s.planOf(userID, petID) if err != nil { return nil, err } start := time.Date(plan.StartDate.Year(), plan.StartDate.Month(), plan.StartDate.Day(), 0, 0, 0, 0, time.Local) // 已经有的节点按「日期+标题」记下来,套用不产生重复。 // 用户可能先自己排了「每天喂药」,再套一套含同样一条的方案 var exist []model.PlanTask s.db.Where("plan_id = ?", plan.ID).Find(&exist) seen := map[string]bool{} for _, t := range exist { d := start.AddDate(0, 0, t.Day) seen[d.Format("2006-01-02")+"|"+t.Title] = true } var rows []model.PlanTask for _, it := range tpl.Items { if it.Kind == model.CareKindReminder { continue } for _, d := range expandItem(it, from, last) { key := d.Format("2006-01-02") + "|" + it.Title if seen[key] { continue } seen[key] = true day := int(d.Sub(start).Hours() / 24) if day < 0 { continue // 早于计划开始日的排不进去 } rows = append(rows, model.PlanTask{ PlanID: plan.ID, Day: day, DayLabel: dayLabelFor(day), Title: it.Title, Description: it.Description, SheetType: it.SheetType, }) } } res := &ApplyTemplateResult{From: from.Format("2006-01-02"), To: last.Format("2006-01-02")} if len(rows) > 0 { if e := s.db.Create(&rows).Error; e != nil { return nil, e } res.Plans = len(rows) } // 提醒只在第一次套用时建:它是「到期提醒」,按物种阶段来就一份, // 套两次方案不该出现两条疫苗提醒 res.Reminders = s.applyTemplateReminders(pet, tpl.Items) return res, nil } // applyTemplateReminders 建模板里的提醒,已存在同标题的跳过 func (s *Service) applyTemplateReminders(pet *model.Pet, items []model.CareTemplateItem) int { var exist []model.Reminder s.db.Where("pet_id = ?", pet.ID).Find(&exist) seen := map[string]bool{} for _, r := range exist { seen[r.Title] = true } goals := petGoals(pet) var rows []model.Reminder for _, it := range items { if it.Kind != model.CareKindReminder || seen[it.Title] { continue } if !goalWantsReminder(goals, it.ReminderType) { continue } seen[it.Title] = true r := model.Reminder{ PetID: pet.ID, UserID: pet.UserID, Type: it.ReminderType, Title: it.Title, Frequency: it.Frequency, } if it.OffsetDays > 0 { // dueDaysFor 会按宠物月龄调整(幼猫的「下一针」不该按成年猫的周期算) due := time.Now().AddDate(0, 0, dueDaysFor(it, pet, time.Now())) r.NextDueDate = &due } rows = append(rows, r) } if len(rows) == 0 { return 0 } if err := s.db.Create(&rows).Error; err != nil { return 0 } return len(rows) }