8b1b4cde02
## 三个问题,一起解决 你问「模版要不要做成可选」,摸下来发现底下有三个更硬的: **① 根本没有多套模版可选。** 模板表唯一索引是 (species, stage) —— 同一个「猫 × 幼年期」只能有一套。「让用户选模版」在数据模型上做不到。 去掉唯一索引,加 Name / Summary / Sort / Enabled。 **② 今日任务和计划节点是两套,而且是我上一轮改出来的。** DailyTask 模板 kind=task 生成,ensureDayTasks 每天把昨天那份复制到今天 PlanTask 模板 kind=plan 生成,Day 偏移 计划改成月度管理 + 加了「应用到每天」之后,两者做的是同一件事, 但首页读 DailyTask、计划页管 PlanTask —— 用户在计划页加一条「每天喂药」, 首页今日任务里看不到。 DailyTask 退役。task.go 重写成 PlanTask 的一层「按日期取」的门面, /pets/:id/tasks 路径不变,小程序端不用为这次合并改调用。 PlanTask 加 Priority(首页那份要能标出哪条重要)。 顺手删掉重复的 CreateTask/UpdateTask/DeleteTask —— /plan-tasks 那套已经 覆盖了,留着两套会出现「首页管理弹层加的任务」和「计划页加的节点」进不同的表。 **③ 模板的 Day 偏移和月度计划对不上。** 月底建档的话 day=14/21/30 全落到 下个月,用户建完档打开计划页发现本月是空的。 改成重复规则:once(偏移 N 天,疫苗序列要用)/ daily / weekly(星期几) / monthly(几号)。套用 = 展开成目标月份的具体日期。 「每月 31 号」在 2 月按月末算,不然那个月直接没有。 列名用 repeat_rule 不用 repeat —— repeat 是 MySQL 保留字(和 group_key 同理)。 ## 展开成实体行,不是「存规则读时展开」 勾选状态是按天的。存规则得再开一张表记「哪天打过勾」,而且用户把 「每周二梳毛」这周挪到周三就要引入例外记录。一次展开成行,增删改和打勾全免费。 代价是套用后模板和计划断开:后台改模板不影响已套用的宠物。这是有意的—— 用户已经在自己的计划上勾了几十个勾,后台一改就被覆盖才是灾难。 ## 建档不再自动铺 seedPetDefaults 只建必要提醒(疫苗、驱虫)。漏针是真会出事的,而且它们是 「到期提醒」不占计划页的格子。其余交给用户挑方案。 自动铺的问题是:用户还没看懂这个 App 是干什么的,首页已经躺着十几条他没 答应过的任务,第一反应是「这些是什么」而不是「我该做什么」。 **改完发现还有第二条自动铺的路径**:newStagePlan 在建计划壳子时顺手把模板 铺成节点。所以我改完 seedPetDefaults 之后,建档后首页仍然躺着 5 条任务。 惰性生成尤其难查——不是建档时发生的,是第一次读计划才发生的。 ## 方案摘要按重复方式分开报数 一开始只报一个总数,「10 条」套用一整月展开成 130 条,用户会以为出 bug。 现在分成 每天 4 · 每周 0 · 每月 0 · 一次性 6 · 提醒 4。 ## 数据迁移(预生产库,已执行) 模板明细 kind=task → plan + repeat=daily 28 条 模板明细 kind=plan 补 repeat=once 44 条 8 套方案补名字和摘要 DailyTask → PlanTask 34 条,跳过 0 DailyTask 表的行**没删**,先留着当备份。 迁移第一版又踩了「列还没 AutoMigrate 出来就跑」那个坑:Count 静默返回 0, 我以为「没有需要补的」。第二版加了 HasColumn 前置检查才发现 44 条没转。 和上次 record_types.fields 回填是同一个错。 ## 验证(预生产库实跑) 今日任务/计划页今天 4 条 vs 4 条,一致 建档后 0 任务 / 0 计划节点 / 4 条必要提醒 套用本月 从今天到月末,4 条(7/30-7/31 两天) 重复套用 0 条新增(按「日期+标题」去重) 套用下月 130 条覆盖 31 天,每天 4.2 条 全部完成 5 → 0 未做 ## 还没做(下一步) 前端:建档第 4 步选方案、计划页空月份的套用入口、首页「管理」改跳计划页、 弹层删 manageTasks;后台模板页要支持多套 + 重复规则编辑。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
261 lines
8.1 KiB
Go
261 lines
8.1 KiB
Go
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)
|
||
}
|