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
289 lines
8.1 KiB
Go
289 lines
8.1 KiB
Go
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)
|
||
}
|