Files
sundynix-pets/pets-be/internal/service/plan.go
T
Blizzard 2f2757267e fix(be): 任务完成统计与留痕改用 PlanTask(DailyTask 已空)
- TogglePlanTask:完成无 sheet_type 的任务时补一条 note 记录,
  首页/计划页两条路径都统一留痕(ToggleTask 委托它);去掉前端重复建记录
- 报告页「完成任务」原来统计空的 DailyTask 恒为 0,改成经 plan 关联
  统计 PlanTask 本周完成数;用户汇总的 tasksDone 同改
- 首页今日完成度 todayCompletionPct 同样从 DailyTask 改成今日 PlanTask

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 14:29:04 +08:00

516 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"errors"
"math"
"strconv"
"strings"
"time"
"gorm.io/datatypes"
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/model"
)
// TogglePlanTask 切换计划明细完成状态(校验归属)
func (s *Service) TogglePlanTask(userID, taskID string) error {
var pt model.PlanTask
if err := s.db.First(&pt, taskID).Error; err != nil {
return ErrNotFound
}
var plan model.Plan
if err := s.db.Where("id = ? AND user_id = ?", pt.PlanID, userID).First(&plan).Error; err != nil {
return ErrNotFound
}
newDone := !pt.Done
if err := s.db.Model(&model.PlanTask{}).Where("id = ?", taskID).Update("done", newDone).Error; err != nil {
return err
}
// 勾选完成、且没关联记录类型的任务(自定义/无 sheet_type),补一条 note 记录,
// 让最近记录里留痕。有 sheet_type 的任务是走记录表单单独落记录的,这里不重复。
if newDone && pt.SheetType == "" {
_, _ = s.CreateRecord(userID, plan.PetID, RecordInput{Type: "note", Title: "完成:" + pt.Title})
}
return nil
}
// newStagePlan 按宠物当前阶段的养护模板生成一份 30 天计划(未保存)
// newStagePlan 只建一个空计划壳子(记住开始日,PlanTask.Day 是相对它算的)。
//
// 原来它顺手把整套模板铺成节点。那是第二条「自动铺」的路径——我把
// seedPetDefaults 改成不自动铺之后,建档后首页仍然躺着 5 条任务,就是这里干的。
// 惰性生成尤其难查:不是建档时发生的,是第一次读计划才发生的。
//
// 现在铺不铺由用户决定:建档最后一步挑方案,或者之后在计划页套用。
func (s *Service) newStagePlan(pet *model.Pet, start time.Time) model.Plan {
end := start.AddDate(0, 0, 30)
return model.Plan{
PetID: pet.ID, UserID: pet.UserID, Kind: model.PlanThirtyDay, Stage: pet.Stage,
StartDate: &start, EndDate: &end, CompletionPct: 0, Status: "active",
}
}
// ensurePlan 计划到期自动续期:active 计划过期则归档并按当前阶段生成新一轮;无 active 则补建
func (s *Service) ensurePlan(pet *model.Pet) {
now := time.Now()
var plan model.Plan
err := s.db.Where("pet_id = ? AND kind = ? AND status = ?", pet.ID, model.PlanThirtyDay, "active").
Order("id desc").First(&plan).Error
if err == gorm.ErrRecordNotFound {
p := s.newStagePlan(pet, now)
s.db.Create(&p)
return
}
if err == nil && plan.EndDate != nil && plan.EndDate.Before(now) {
s.db.Model(&model.Plan{}).Where("id = ?", plan.ID).Update("status", "archived")
p := s.newStagePlan(pet, now)
s.db.Create(&p)
}
}
// GetPlan 取宠物的 30 天计划(含明细);过期自动续期
func (s *Service) GetPlan(userID, petID string) (*model.Plan, error) {
pet, err := s.ownedPet(userID, petID)
if err != nil {
return nil, err
}
s.ensurePlan(pet)
var plan model.Plan
err = s.db.Preload("Tasks").
Where("pet_id = ? AND kind = ?", petID, model.PlanThirtyDay).
Where("status = ?", "active").
Order("id desc").First(&plan).Error
if err == gorm.ErrRecordNotFound {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
// 真实完成度:已完成明细 / 总明细
if n := len(plan.Tasks); n > 0 {
done := 0
for _, t := range plan.Tasks {
if t.Done {
done++
}
}
plan.CompletionPct = int(math.Round(float64(done) / float64(n) * 100))
} else {
plan.CompletionPct = 0
}
// 计算每个路线图节点的真实日期(开始日 + Day),供前端展示并与日历对齐
if plan.StartDate != nil {
for i := range plan.Tasks {
plan.Tasks[i].Date = plan.StartDate.AddDate(0, 0, plan.Tasks[i].Day).Format("2006-01-02")
}
}
return &plan, nil
}
// planNodeDates 30 天计划路线图节点在 [start,end) 内的真实日期(开始日 + Day)
func (s *Service) planNodeDates(petID string, start, end time.Time) []time.Time {
var plan model.Plan
if err := s.db.Preload("Tasks").
Where("pet_id = ? AND kind = ?", petID, model.PlanThirtyDay).
Order("id desc").First(&plan).Error; err != nil || plan.StartDate == nil {
return nil
}
var out []time.Time
for _, t := range plan.Tasks {
d := plan.StartDate.AddDate(0, 0, t.Day)
dd := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, d.Location())
if !dd.Before(start) && dd.Before(end) {
out = append(out, dd)
}
}
return out
}
// CalendarResult 日历视图
type CalendarResult struct {
Year int `json:"year"`
Month int `json:"month"`
TaskedDays []int `json:"tasked_days"`
Today int `json:"today"`
}
// Calendar 某月有任务/提醒的日期
func (s *Service) Calendar(userID, petID string, year, month int) (*CalendarResult, error) {
if _, err := s.ownedPet(userID, petID); err != nil {
return nil, err
}
loc := time.Now().Location()
start := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, loc)
end := start.AddDate(0, 1, 0)
// 与首页周历同源:任务/记录/提醒/计划路线图节点
set := s.datesWithActivity(petID, start, end)
days := make([]int, 0, len(set))
for k := range set {
if t, err := time.ParseInLocation("2006-01-02", k, time.Local); err == nil {
days = append(days, t.Day())
}
}
today := 0
now := time.Now()
if now.Year() == year && int(now.Month()) == month {
today = now.Day()
}
return &CalendarResult{Year: year, Month: month, TaskedDays: days, Today: today}, nil
}
// DayPlan 某一天的安排:计划路线图节点 + 今日任务 + 到期提醒
func (s *Service) DayPlan(userID, petID, dateStr string) (map[string]any, error) {
if _, err := s.ownedPet(userID, petID); err != nil {
return nil, err
}
day, err := time.ParseInLocation("2006-01-02", dateStr, time.Local)
if err != nil {
return nil, ErrNotFound
}
next := day.AddDate(0, 0, 1)
nodes := []model.PlanTask{}
var plan model.Plan
if e := s.db.Preload("Tasks").
Where("pet_id = ? AND kind = ?", petID, model.PlanThirtyDay).
Order("id desc").First(&plan).Error; e == nil && plan.StartDate != nil {
for _, t := range plan.Tasks {
d := plan.StartDate.AddDate(0, 0, t.Day)
if d.Year() == day.Year() && d.YearDay() == day.YearDay() {
t.Date = d.Format("2006-01-02")
nodes = append(nodes, t)
}
}
}
var tasks []model.DailyTask
s.db.Where("pet_id = ? AND task_date >= ? AND task_date < ?", petID, day, next).Find(&tasks)
var reminders []model.Reminder
s.db.Where("pet_id = ? AND next_due_date >= ? AND next_due_date < ?", petID, day, next).Find(&reminders)
return map[string]any{
"date": dateStr,
"plan_nodes": nodes,
"tasks": tasks,
"reminders": reminders,
}, nil
}
// CreateAIPlan 基于用户描述做规则化提取,生成待确认的 AI 计划
func (s *Service) CreateAIPlan(userID, petID string, input string) (*model.Plan, error) {
if err := s.consumeAIQuota(userID, aiKindPlan); err != nil {
return nil, err
}
pet, err := s.ownedPet(userID, petID)
if err != nil {
return nil, err
}
// 默认(规则化)提取与任务
var extracted any = extractAIInfo(input, pet.Stage)
tasks := []model.PlanTask{
{DayLabel: "Day 1-2", Title: "观察食欲和便便", Description: "每日记录饮食、便便、精神状态。"},
{DayLabel: "Day 3-5", Title: "记录换粮比例", Description: "逐步提高新粮比例,留意软便。"},
{DayLabel: "Day 6-7", Title: "生成复盘建议", Description: "汇总一周状态,输出复盘。"},
}
// 启用模型则用真实提取覆盖
if ex, ok := s.aiPlanExtract(pet, input); ok {
extracted = map[string]string{
"stage": ex.Stage, "risk": ex.Risk, "priority": ex.Priority, "reminder": ex.Reminder,
}
tasks = tasks[:0]
for _, t := range ex.Tasks {
tasks = append(tasks, model.PlanTask{DayLabel: t.DayLabel, Title: t.Title, Description: t.Description})
}
}
b, _ := jsonMarshalAny(extracted)
plan := model.Plan{
PetID: petID, UserID: userID, Kind: model.PlanAI, Stage: pet.Stage,
UserInput: input, Extracted: datatypes.JSON(b), Status: "draft", Tasks: tasks,
}
if err := s.db.Create(&plan).Error; err != nil {
return nil, err
}
return &plan, nil
}
// ApplyAIPlan 确认并应用 AI 计划
func (s *Service) ApplyAIPlan(userID, planID string) (*model.Plan, error) {
var plan model.Plan
if err := s.db.Where("id = ? AND user_id = ?", planID, userID).First(&plan).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, ErrNotFound
}
return nil, err
}
now := time.Now()
end := now.AddDate(0, 0, 7)
if err := s.db.Model(&plan).Updates(map[string]any{
"status": "active", "start_date": now, "end_date": end,
}).Error; err != nil {
return nil, err
}
if err := s.db.Preload("Tasks").First(&plan, plan.ID).Error; err != nil {
return nil, err
}
return &plan, nil
}
// extractAIInfo 规则化提取(后续可替换为大模型)
func extractAIInfo(input, stage string) map[string]string {
risk := "暂无明显风险"
priority := "日常观察"
reminder := "常规提醒"
if strings.Contains(input, "换粮") || strings.Contains(input, "软便") {
risk = "换粮软便"
priority = "排便观察"
}
if strings.Contains(input, "疫苗") {
reminder = "第二针疫苗"
}
if stage == "" {
stage = "幼年期"
}
return map[string]string{
"stage": stage + " / 疫苗期",
"risk": risk,
"priority": priority,
"reminder": reminder,
}
}
// PlanTaskInput 用户自己加/改的计划节点
type PlanTaskInput struct {
Date string // "2026-08-05",落到 Day = 这天 - 计划开始日
Title string
Description string
SheetType string // 关联的记录类型 code,可空
// Daily 应用到每天:从 Date 起到窗口末尾,一天建一条。
// 不做「一条 + repeat 字段读时展开」是因为勾选状态是按天的——
// 那样得再开一张表记「哪天打过勾」,而直接建 N 条天然就有这个能力。
Daily bool
}
// planOf 取这只宠物当前的 30 天计划,顺带做归属校验
func (s *Service) planOf(userID, petID string) (*model.Plan, error) {
pet, err := s.ownedPet(userID, petID)
if err != nil {
return nil, err
}
s.ensurePlan(pet)
var plan model.Plan
if e := s.db.Where("pet_id = ? AND kind = ? AND status = ?",
petID, model.PlanThirtyDay, "active").Order("id desc").First(&plan).Error; e != nil {
return nil, ErrNotFound
}
return &plan, nil
}
// 计划以「自然月」为单位管理。
//
// 不用「从计划开始日起 30 天」那种滚动窗口:计划可能是月中建的,那样窗口会
// 跨两个月,用户看着一个 7月26日—8月25日 的日历,既不像本月也不像下月,
// 「这个月还有什么没做」根本对不上。按自然月切,日历就是一眼能认的月历。
//
// 允许安排到下月末,否则「做下月计划」这件事本身做不了。
// monthBounds 某个月的首尾。off=0 本月,off=1 下月
func monthBounds(off int) (time.Time, time.Time) {
n := time.Now()
first := time.Date(n.Year(), n.Month(), 1, 0, 0, 0, 0, time.Local).AddDate(0, off, 0)
return first, first.AddDate(0, 1, -1)
}
// dayOffset 把日期换算成 PlanTask.Day(相对计划开始日)。
//
// 校验按**日期**而不是 Day 上限:Day 是相对计划开始日的,月份边界是相对今天的,
// 两者会错开。按 Day<=30 卡的话,计划建了几天之后下月末就换算成 Day=40 多,
// 用户在日历上明明看得到那格、点进去却存不了。
func dayOffset(plan *model.Plan, dateStr string) (int, error) {
if plan.StartDate == nil {
return 0, errors.New("计划还没有开始日期")
}
d, err := time.ParseInLocation("2006-01-02", dateStr, time.Local)
if err != nil {
return 0, ErrInvalidParam
}
st := time.Date(plan.StartDate.Year(), plan.StartDate.Month(), plan.StartDate.Day(),
0, 0, 0, 0, time.Local)
// 下界放到计划开始日而不是本月 1 号:模板生成的历史节点可能在上个月,
// 那些要允许原地改(改标题时日期会原样传回来)
if d.Before(st) {
return 0, errors.New("这天在计划开始之前,排不了")
}
_, nextEnd := monthBounds(1)
if d.After(nextEnd) {
return 0, errors.New("最远只能排到下个月月底(" + nextEnd.Format("1月2日") + "")
}
return int(d.Sub(st).Hours() / 24), nil
}
// AddPlanTask 用户自己往计划里加。Daily=true 时从这天起到窗口末尾每天一条
func (s *Service) AddPlanTask(userID, petID string, in PlanTaskInput) ([]model.PlanTask, error) {
title := strings.TrimSpace(in.Title)
if title == "" {
return nil, ErrInvalidParam
}
plan, err := s.planOf(userID, petID)
if err != nil {
return nil, err
}
day, err := dayOffset(plan, in.Date)
if err != nil {
return nil, err
}
// 要建几天。非 daily 就一天;daily 从这天排到窗口末尾
// 要建几天。daily 从这天排到**这一天所在那个月**的月底:
// 「应用到每天」说的是这个月每天,跨到下月去就变成用户没要求的事了
count := 1
if in.Daily {
from, e := time.ParseInLocation("2006-01-02", in.Date, time.Local)
if e != nil {
return nil, ErrInvalidParam
}
monthEnd := time.Date(from.Year(), from.Month(), 1, 0, 0, 0, 0, time.Local).
AddDate(0, 1, -1)
count = int(monthEnd.Sub(from).Hours()/24) + 1
if count < 1 {
count = 1
}
if count > 31 {
count = 31 // 兜一层,别让脏日期算出个巨大的数
}
}
out := make([]model.PlanTask, 0, count)
for i := 0; i < count; i++ {
d := day + i
out = append(out, model.PlanTask{
PlanID: plan.ID, Day: d, DayLabel: dayLabelFor(d),
Title: title, Description: strings.TrimSpace(in.Description),
SheetType: in.SheetType,
})
}
if err := s.db.Create(&out).Error; err != nil {
return nil, err
}
for i := range out {
out[i].Date = plan.StartDate.AddDate(0, 0, out[i].Day).Format("2006-01-02")
}
return out, nil
}
// UpdatePlanTask 改标题/描述/日期
func (s *Service) UpdatePlanTask(userID, taskID string, in PlanTaskInput) error {
plan, t, err := s.ownedPlanTask(userID, taskID)
if err != nil {
return err
}
fields := map[string]any{}
if strings.TrimSpace(in.Title) != "" {
fields["title"] = strings.TrimSpace(in.Title)
}
fields["description"] = strings.TrimSpace(in.Description)
if in.Date != "" {
day, err := dayOffset(plan, in.Date)
if err != nil {
return err
}
fields["day"] = day
fields["day_label"] = dayLabelFor(day)
}
return s.db.Model(&model.PlanTask{}).Where("id = ?", t.ID).Updates(fields).Error
}
// DeletePlanTask 删掉一条
func (s *Service) DeletePlanTask(userID, taskID string) error {
_, t, err := s.ownedPlanTask(userID, taskID)
if err != nil {
return err
}
return s.db.Delete(&model.PlanTask{}, "id = ?", t.ID).Error
}
// ownedPlanTask 校验这条节点属于当前用户的宠物。
// 不校验的话,拿到别人的 task id 就能改删别人的计划
func (s *Service) ownedPlanTask(userID, taskID string) (*model.Plan, *model.PlanTask, error) {
var t model.PlanTask
if err := s.db.First(&t, "id = ?", taskID).Error; err != nil {
return nil, nil, ErrNotFound
}
var plan model.Plan
if err := s.db.First(&plan, "id = ?", t.PlanID).Error; err != nil {
return nil, nil, ErrNotFound
}
if _, err := s.ownedPet(userID, plan.PetID); err != nil {
return nil, nil, ErrForbidden
}
return &plan, &t, nil
}
// dayLabelFor 第 N 天的展示标签,和内置模板生成的那批保持一致
func dayLabelFor(day int) string {
switch {
case day == 0:
return "今天"
case day == 1:
return "明天"
default:
return "第 " + strconv.Itoa(day+1) + " 天"
}
}
// PlanMonthStatus 首页那个提醒角标要的东西
type PlanMonthStatus struct {
Month string `json:"month"` // "2026-07"
ThisMonth int `json:"this_month"` // 本月有几件
ThisUndone int `json:"this_undone"` // 本月还剩几件
NextMonth int `json:"next_month"` // 下月有几件
// NeedNext 该提醒用户排下月计划了。
// 只在月末最后 7 天提醒:月初就催「排下个月」太早,用户会当成噪音关掉。
NeedNext bool `json:"need_next"`
NextLabel string `json:"next_label"` // "8 月"
}
// PlanMonthStatus 统计本月/下月的计划件数
func (s *Service) PlanMonthStatus(userID, petID string) (*PlanMonthStatus, error) {
plan, err := s.planOf(userID, petID)
if err != nil {
return nil, err
}
thisFirst, thisEnd := monthBounds(0)
nextFirst, nextEnd := monthBounds(1)
var tasks []model.PlanTask
s.db.Where("plan_id = ?", plan.ID).Find(&tasks)
out := &PlanMonthStatus{
Month: thisFirst.Format("2006-01"),
NextLabel: nextFirst.Format("1 月"),
}
for _, t := range tasks {
d := plan.StartDate.AddDate(0, 0, t.Day)
switch {
case !d.Before(thisFirst) && !d.After(thisEnd):
out.ThisMonth++
if !t.Done {
out.ThisUndone++
}
case !d.Before(nextFirst) && !d.After(nextEnd):
out.NextMonth++
}
}
// 距月底还剩几天。用 24h 取整会在夏令时地区差一天,这里按日期差算
left := int(thisEnd.Sub(time.Date(time.Now().Year(), time.Now().Month(), time.Now().Day(),
0, 0, 0, 0, time.Local)).Hours()/24)
out.NeedNext = out.NextMonth == 0 && left <= 7
return out, nil
}