Files
sundynix-pets/pets-be/internal/service/report.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

231 lines
8.0 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 (
"fmt"
"strings"
"time"
"github.com/sundynix/pets-be/internal/model"
)
// WeeklyReport 周报聚合
type WeeklyReport struct {
Summary string `json:"summary"`
TasksCompleted int64 `json:"tasks_completed"`
WeightGain float64 `json:"weight_gain"`
HighRiskCount int64 `json:"high_risk_count"`
HealthStatus string `json:"health_status"`
NextWeekFocus string `json:"next_week_focus"`
}
// GetWeeklyReport 计算最近 7 天周报
func (s *Service) GetWeeklyReport(userID, petID string) (*WeeklyReport, error) {
pet, err := s.ownedPet(userID, petID)
if err != nil {
return nil, err
}
weekAgo := time.Now().AddDate(0, 0, -7)
// 任务已并成 PlanTaskDailyTask 表已空),要经 plan 关联到宠物来统计本周完成数
var tasksCompleted int64
s.db.Model(&model.PlanTask{}).
Where("plan_id IN (SELECT id FROM sundynix_plans WHERE pet_id = ?) AND done = ? AND updated_at >= ?", petID, true, weekAgo).
Count(&tasksCompleted)
// 体重增长:最近 7 天最新 - 最早
var latest, earliest model.HealthRecord
gain := 0.0
if err := s.db.Where("pet_id = ? AND type = ? AND occurred_at >= ?", petID, model.RecordWeight, weekAgo).
Order("occurred_at desc").First(&latest).Error; err == nil {
if err := s.db.Where("pet_id = ? AND type = ? AND occurred_at >= ?", petID, model.RecordWeight, weekAgo).
Order("occurred_at asc").First(&earliest).Error; err == nil {
gain = latest.NumValue - earliest.NumValue
}
}
var highRisk int64
s.db.Model(&model.HealthRecord{}).
Where("pet_id = ? AND type = ? AND category = ? AND occurred_at >= ?", petID, model.RecordSymptom, "高", weekAgo).
Count(&highRisk)
status := "稳定成长"
risk := "无高风险异常记录"
if highRisk > 0 {
status = "需要关注"
// 原来这句写死成「无高风险异常记录」,和上面刚算出来的 highRisk 自相矛盾:
// 记了 3 条高风险,摘要还说没有
risk = fmt.Sprintf("有 %d 条高风险异常记录", highRisk)
}
return &WeeklyReport{
Summary: fmt.Sprintf("本周 %s 完成 %d 项任务,体重 %+.1fkg%s。",
pet.Name, tasksCompleted, gain, risk),
TasksCompleted: tasksCompleted,
WeightGain: gain,
HighRiskCount: highRisk,
HealthStatus: status,
NextWeekFocus: s.nextWeekFocus(petID, highRisk, gain),
}, nil
}
// nextWeekFocus 下周重点。原来是一句写死的「第 2 针疫苗提醒、继续观察体重趋势、
// 避免频繁更换食物」——不管谁的宠物、多大年纪、有没有异常,看到的都是这句,
// 而且「第 2 针疫苗」对成年猫狗根本不适用。改成按真实数据拼。
func (s *Service) nextWeekFocus(petID string, highRisk int64, gain float64) string {
var parts []string
// 未来 7 天到期的提醒,是最具体的「下周要做什么」
now := time.Now()
var rems []model.Reminder
s.db.Where("pet_id = ? AND next_due_date >= ? AND next_due_date <= ?",
petID, now, now.AddDate(0, 0, 7)).Order("next_due_date asc").Limit(2).Find(&rems)
for _, r := range rems {
parts = append(parts, r.Title)
}
if highRisk > 0 {
parts = append(parts, "继续观察上周记录的异常,必要时就医")
}
// 一周没称过体重(gain 恰好为 0 也可能是真没变,所以查条数而不是看 gain)
var weighed int64
s.db.Model(&model.HealthRecord{}).
Where("pet_id = ? AND type = ? AND occurred_at >= ?", petID, model.RecordWeight, now.AddDate(0, 0, -7)).
Count(&weighed)
if weighed == 0 {
parts = append(parts, "本周还没称体重,补记一次")
}
if len(parts) == 0 {
return "各项都稳定,保持现在的记录节奏就好。"
}
return strings.Join(parts, "") + "。"
}
// BillCategory 账单分类项
type BillCategory struct {
Category string `json:"category"`
Amount float64 `json:"amount"`
Percent int `json:"percent"`
}
// Bill 账单聚合
type Bill struct {
Period string `json:"period"`
Total float64 `json:"total"`
MaxSingle float64 `json:"max_single"`
Categories []BillCategory `json:"categories"`
}
// GetBill 账单(period=month 取本月)
func (s *Service) GetBill(userID, petID string, period string) (*Bill, error) {
if _, err := s.ownedPet(userID, petID); err != nil {
return nil, err
}
now := time.Now()
var start time.Time
if period == "year" {
start = time.Date(now.Year(), 1, 1, 0, 0, 0, 0, now.Location())
} else {
period = "month"
start = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
}
type row struct {
Category string
Amount float64
}
var rows []row
s.db.Model(&model.HealthRecord{}).
Select("category, sum(num_value) as amount").
Where("pet_id = ? AND type = ? AND occurred_at >= ?", petID, model.RecordCost, start).
Group("category").Scan(&rows)
var total, maxSingle float64
s.db.Model(&model.HealthRecord{}).
Select("coalesce(sum(num_value),0)").
Where("pet_id = ? AND type = ? AND occurred_at >= ?", petID, model.RecordCost, start).
Scan(&total)
s.db.Model(&model.HealthRecord{}).
Select("coalesce(max(num_value),0)").
Where("pet_id = ? AND type = ? AND occurred_at >= ?", petID, model.RecordCost, start).
Scan(&maxSingle)
cats := make([]BillCategory, 0, len(rows))
for _, r := range rows {
pct := 0
if total > 0 {
pct = int(r.Amount / total * 100)
}
cats = append(cats, BillCategory{Category: r.Category, Amount: r.Amount, Percent: pct})
}
return &Bill{Period: period, Total: total, MaxSingle: maxSingle, Categories: cats}, nil
}
// HealthSummary 健康摘要
type HealthSummary struct {
VaccineProgress string `json:"vaccine_progress"`
DewormStatus string `json:"deworm_status"`
WeightTrend string `json:"weight_trend"`
AnomalyCount int64 `json:"anomaly_count"`
}
// GetHealthSummary 健康摘要聚合
func (s *Service) GetHealthSummary(userID, petID string) (*HealthSummary, error) {
if _, err := s.ownedPet(userID, petID); err != nil {
return nil, err
}
var vaccineDone int64
s.db.Model(&model.HealthRecord{}).Where("pet_id = ? AND type = ?", petID, model.RecordVaccine).Count(&vaccineDone)
dewormStatus := "暂无计划"
var dewormReminder model.Reminder
if err := s.db.Where("pet_id = ? AND type = ?", petID, model.ReminderDeworm).Order("next_due_date asc").First(&dewormReminder).Error; err == nil && dewormReminder.NextDueDate != nil {
dewormStatus = "下次 " + dewormReminder.NextDueDate.Format("1月2日")
}
var anomalies int64
s.db.Model(&model.HealthRecord{}).Where("pet_id = ? AND type = ?", petID, model.RecordSymptom).Count(&anomalies)
return &HealthSummary{
VaccineProgress: fmt.Sprintf("%d/3,即将到期", vaccineDone),
DewormStatus: dewormStatus,
WeightTrend: "稳定增长",
AnomalyCount: anomalies,
}, nil
}
// Poster 成长海报数据
type Poster struct {
PetName string `json:"pet_name"`
PetEmoji string `json:"pet_emoji"`
Age string `json:"age"`
Weight string `json:"weight"`
Stage string `json:"stage"`
TasksCompleted int64 `json:"tasks_completed"`
WeightRecords int64 `json:"weight_records"`
VaccineRecords int64 `json:"vaccine_records"`
HighRiskCount int64 `json:"high_risk_count"`
Headline string `json:"headline"`
}
// GetPoster 生成海报聚合数据
func (s *Service) GetPoster(userID, petID string) (*Poster, error) {
pet, err := s.ownedPet(userID, petID)
if err != nil {
return nil, err
}
var tasksDone, weightRecs, vaccineRecs int64
s.db.Model(&model.PlanTask{}).
Where("plan_id IN (SELECT id FROM sundynix_plans WHERE pet_id = ?) AND done = ?", petID, true).
Count(&tasksDone)
s.db.Model(&model.HealthRecord{}).Where("pet_id = ? AND type = ?", petID, model.RecordWeight).Count(&weightRecs)
s.db.Model(&model.HealthRecord{}).Where("pet_id = ? AND type = ?", petID, model.RecordVaccine).Count(&vaccineRecs)
return &Poster{
PetName: pet.Name, PetEmoji: pet.Emoji, Age: pet.Age, Weight: pet.Weight, Stage: pet.Stage,
TasksCompleted: tasksDone, WeightRecords: weightRecs, VaccineRecords: vaccineRecs,
HighRiskCount: 0, Headline: "稳定成长",
}, nil
}