Files
subtrack/internal/web/handlers_packages.go
T
subtrack a3fd817edc feat(v1.8): 包赛季模式、按次默认有效期、订阅删除
1) 包赛季(第三种计费方式)
- 套餐表单新增「包赛季」,套餐上直接填赛季开始日/结束日(必填,结束日不能早于开始日)
- 客户开通时自动带出赛季起止,不用再手填日期
- 日历上用横向色带标出整段赛季;赛季内任意一天点「完成」即整段完成
- 完成后不再进日历/待打,客户详情显示「赛季已完成 + 完成日期」
- 已过赛季结束日的套餐在客户开通下拉里置灰标「赛季已结束」,服务端同样拒绝开通

2) 按次默认有效期
- 套餐新增「按次默认有效期(天)」,开单时自动算出到期日(开始日 + 天数 − 1),仍可改

3) 订阅删除
- 已结束的订阅卡片在「重新启用」旁新增「删除」,二次确认后彻底删除该订阅及其打卡/追加/加急记录

4) 客户月历、日历与大屏同步识别赛季(色带、完成状态)

数据库兼容性:
- 仅新增列:packages.season / default_validity_days / season_start / season_end,subscriptions.season / season_done
- 没有改动原有 unit 的 CHECK 约束、没有重建表;老库启动自动 ALTER,可重复迁移

测试:
- 功能验证 13 项:赛季套餐创建、过期套餐不可选、开通带出日期、日历色带、完成整段后隐藏、按次自动到期、删除订阅
- 119 项验收回归全绿;go test ./... 通过;无头 Chrome 实拍 3 张(日历色带 / 客户赛季卡片 / 套餐表单)
- 版本号 1.7.0 → 1.8.0
2026-10-19 19:26:47 +08:00

289 lines
8.1 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 web
import (
"fmt"
"net/http"
"strconv"
"strings"
"daili/internal/domain"
"daili/internal/store"
)
type packagesData struct {
List []store.Package
}
func (s *Server) handlePackages(w http.ResponseWriter, r *http.Request) {
list, err := s.db.ListPackages(true)
if err != nil {
http.Error(w, "读取套餐失败", http.StatusInternalServerError)
return
}
p := s.newPage(r, "套餐", "packages")
p.D = &packagesData{List: list}
s.render(w, r, "packages", p)
}
type packageFormData struct {
Package *store.Package
IsNew bool
Tags []string
}
func (s *Server) handlePackageNewPage(w http.ResponseWriter, r *http.Request) {
p := s.newPage(r, "新增套餐", "packages")
tags, _ := s.db.PackageTags()
p.D = &packageFormData{
Package: &store.Package{Unit: domain.UnitDay, DefaultAmount: 30, Color: "#2F5D50"},
IsNew: true,
Tags: tags,
}
s.render(w, r, "package_form", p)
}
func readPackageForm(r *http.Request) *store.Package {
p := &store.Package{
Name: strings.TrimSpace(r.PostFormValue("name")),
Unit: strings.TrimSpace(r.PostFormValue("unit")),
DefaultAmount: 0,
Color: strings.TrimSpace(r.PostFormValue("color")),
Note: strings.TrimSpace(r.PostFormValue("note")),
SortOrder: 0,
}
if v, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue("default_amount"))); err == nil {
p.DefaultAmount = v
}
if v, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue("sort_order"))); err == nil {
p.SortOrder = v
}
if price := strings.TrimSpace(r.PostFormValue("price")); price != "" {
if f, err := strconv.ParseFloat(price, 64); err == nil {
p.PriceCents = int(f*100 + 0.5)
p.HasPrice = true
}
}
if p.Unit == "" {
p.Unit = domain.UnitDay
}
p.CalendarSeparate = r.PostFormValue("calendar_separate") != ""
p.Tag = strings.TrimSpace(r.PostFormValue("tag"))
if p.Unit == "season" {
p.Season = true
p.Unit = domain.UnitDay
}
if v, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue("default_validity_days"))); err == nil {
p.DefaultValidityDays = v
}
p.SeasonStart = strings.TrimSpace(r.PostFormValue("season_start"))
p.SeasonEnd = strings.TrimSpace(r.PostFormValue("season_end"))
return p
}
func validatePackage(p *store.Package) string {
if p.Season {
if p.SeasonStart == "" || p.SeasonEnd == "" {
return "包赛季套餐必须填写赛季开始日和结束日"
}
if p.SeasonEnd < p.SeasonStart {
return "赛季结束日不能早于开始日"
}
}
if p.Name == "" {
return "套餐名称不能为空"
}
if len([]rune(p.Name)) > 32 {
return "套餐名称最长 32 字"
}
if p.Unit != domain.UnitDay && p.Unit != domain.UnitCount {
return "套餐类型不正确"
}
if p.DefaultAmount <= 0 {
return "默认天数/次数必须大于 0"
}
if p.Color == "" || !strings.HasPrefix(p.Color, "#") {
return "请选择套餐颜色"
}
return ""
}
// packageDetailData 套餐详情页数据。
type packageDetailData struct {
Package *store.Package
Customers []store.Customer
Subscribed map[int64]bool
Q string
Tags []string
Today string
}
// handlePackageDetail 套餐详情:查看信息 + 勾选客户批量开通。
func (s *Server) handlePackageDetail(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
pkg, err := s.db.Package(id)
if err != nil {
http.NotFound(w, r)
return
}
today := domain.Today()
warn := s.warnDays()
q := strings.TrimSpace(r.URL.Query().Get("q"))
list, _, err := s.db.ListCustomers(q, 300, 0, domain.Date(today), warn, false)
if err != nil {
http.Error(w, "读取客户失败", http.StatusInternalServerError)
return
}
subscribed, err := s.db.SubscribedCustomerIDs(id)
if err != nil {
http.Error(w, "读取订阅失败", http.StatusInternalServerError)
return
}
tags, _ := s.db.PackageTags()
p := s.newPage(r, "套餐详情", "packages")
p.D = &packageDetailData{Package: pkg, Customers: list, Subscribed: subscribed, Q: q, Tags: tags, Today: domain.Date(today)}
s.render(w, r, "package_detail", p)
}
// handlePackageBatchSubscribe 给勾选的客户批量开通该套餐。
func (s *Server) handlePackageBatchSubscribe(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
pkg, err := s.db.Package(id)
if err != nil {
http.NotFound(w, r)
return
}
_ = r.ParseForm()
back := "/packages/" + itoa(id)
raw := r.PostForm["customer_ids"]
if len(raw) == 0 {
s.redirect(w, r, back, "没有选中任何客户")
return
}
start := strings.TrimSpace(r.PostFormValue("start_date"))
if start == "" {
start = domain.Date(domain.Today())
}
if _, err := domain.ParseDate(start); err != nil {
s.redirect(w, r, back, "开始日期格式不正确")
return
}
end := strings.TrimSpace(r.PostFormValue("end_date"))
days := s.formInt(r, "days", 0)
count := s.formInt(r, "total_count", 0)
u := s.currentUser(r)
var op int64
if u != nil {
op = u.ID
}
done, skipped, failed := 0, 0, 0
var lastErr string
for _, v := range raw {
cid, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
if err != nil {
failed++
continue
}
_, err = s.db.CreateSub(store.CreateSubInput{
CustomerID: cid, PackageID: id, StartDate: start, EndDate: end,
Days: days, TotalCount: count, OperatorID: op,
})
if err != nil {
if strings.Contains(err.Error(), "已订阅") {
skipped++
continue
}
failed++
lastErr = err.Error()
continue
}
done++
}
s.logOp(r, "批量开通", "package", id,
fmt.Sprintf("%s:成功 %d、跳过 %d、失败 %d", pkg.Name, done, skipped, failed))
msg := fmt.Sprintf("已为 %d 位客户开通「%s」", done, pkg.Name)
if skipped > 0 {
msg += fmt.Sprintf(",%d 位已有该套餐已跳过", skipped)
}
if failed > 0 {
msg += fmt.Sprintf(",%d 位失败(%s)", failed, lastErr)
}
s.redirect(w, r, back, msg)
}
func (s *Server) handlePackageCreate(w http.ResponseWriter, r *http.Request) {
pkg := readPackageForm(r)
p := s.newPage(r, "新增套餐", "packages")
if msg := validatePackage(pkg); msg != "" {
tags, _ := s.db.PackageTags()
p.D = &packageFormData{Package: pkg, IsNew: true, Tags: tags}
s.fail(w, r, "package_form", msg, p)
return
}
id, err := s.db.CreatePackage(pkg)
if err != nil {
tags, _ := s.db.PackageTags()
p.D = &packageFormData{Package: pkg, IsNew: true, Tags: tags}
s.fail(w, r, "package_form", "保存失败:"+err.Error(), p)
return
}
s.logOp(r, "新增套餐", "package", id, "新增套餐 "+pkg.Name)
s.redirect(w, r, "/packages", "套餐已创建,现在可以给客户开通了")
}
func (s *Server) handlePackageEditPage(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
pkg, err := s.db.Package(id)
if err != nil {
http.NotFound(w, r)
return
}
p := s.newPage(r, "编辑套餐", "packages")
tags, _ := s.db.PackageTags()
p.D = &packageFormData{Package: pkg, IsNew: false, Tags: tags}
s.render(w, r, "package_form", p)
}
func (s *Server) handlePackageUpdate(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
pkg := readPackageForm(r)
pkg.ID = id
p := s.newPage(r, "编辑套餐", "packages")
if msg := validatePackage(pkg); msg != "" {
tags, _ := s.db.PackageTags()
p.D = &packageFormData{Package: pkg, IsNew: false, Tags: tags}
s.fail(w, r, "package_form", msg, p)
return
}
if err := s.db.UpdatePackage(pkg); err != nil {
tags, _ := s.db.PackageTags()
p.D = &packageFormData{Package: pkg, IsNew: false, Tags: tags}
s.fail(w, r, "package_form", "保存失败:"+err.Error(), p)
return
}
s.logOp(r, "编辑套餐", "package", id, "修改套餐 "+pkg.Name)
s.redirect(w, r, "/packages", "套餐已保存")
}
func (s *Server) handlePackageArchive(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
pkg, err := s.db.Package(id)
if err != nil {
http.NotFound(w, r)
return
}
archived := !pkg.Archived
if err := s.db.SetPackageArchived(id, archived); err != nil {
s.redirect(w, r, "/packages", "操作失败:"+err.Error())
return
}
action := "归档套餐"
msg := "套餐已归档,不再允许新订阅(已有订阅不受影响)"
if !archived {
action = "取消归档"
msg = "套餐已恢复"
}
s.logOp(r, action, "package", id, action+" "+pkg.Name)
s.redirect(w, r, "/packages", msg)
}