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
This commit is contained in:
@@ -139,6 +139,31 @@ func (db *DB) Migrate() error {
|
||||
"ALTER TABLE packages ADD COLUMN tag TEXT NOT NULL DEFAULT ''"); err != nil {
|
||||
return err
|
||||
}
|
||||
// v1.8:包赛季套餐 + 按次默认有效期
|
||||
if err := db.ensureColumn("packages", "season",
|
||||
"ALTER TABLE packages ADD COLUMN season INTEGER NOT NULL DEFAULT 0"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.ensureColumn("packages", "default_validity_days",
|
||||
"ALTER TABLE packages ADD COLUMN default_validity_days INTEGER NOT NULL DEFAULT 0"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.ensureColumn("packages", "season_start",
|
||||
"ALTER TABLE packages ADD COLUMN season_start TEXT NOT NULL DEFAULT ''"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.ensureColumn("packages", "season_end",
|
||||
"ALTER TABLE packages ADD COLUMN season_end TEXT NOT NULL DEFAULT ''"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.ensureColumn("subscriptions", "season",
|
||||
"ALTER TABLE subscriptions ADD COLUMN season INTEGER NOT NULL DEFAULT 0"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.ensureColumn("subscriptions", "season_done",
|
||||
"ALTER TABLE subscriptions ADD COLUMN season_done TEXT NOT NULL DEFAULT ''"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -202,6 +227,10 @@ var schemaStatements = []string{
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
calendar_separate INTEGER NOT NULL DEFAULT 0,
|
||||
tag TEXT NOT NULL DEFAULT '',
|
||||
season INTEGER NOT NULL DEFAULT 0,
|
||||
default_validity_days INTEGER NOT NULL DEFAULT 0,
|
||||
season_start TEXT NOT NULL DEFAULT '',
|
||||
season_end TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`,
|
||||
@@ -231,6 +260,8 @@ var schemaStatements = []string{
|
||||
end_date TEXT,
|
||||
total_count INTEGER NOT NULL DEFAULT 0,
|
||||
used_count INTEGER NOT NULL DEFAULT 0,
|
||||
season INTEGER NOT NULL DEFAULT 0,
|
||||
season_done TEXT NOT NULL DEFAULT '',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
created_at TEXT NOT NULL,
|
||||
|
||||
+34
-12
@@ -23,7 +23,14 @@ type Package struct {
|
||||
// CalendarSeparate 勾选后在工作台/大屏日历中单独成页
|
||||
CalendarSeparate bool
|
||||
// Tag 套餐标签:相同标签的套餐在日历里合并显示
|
||||
Tag string
|
||||
Tag string
|
||||
// Season 包赛季模式:开始~结束之间任意一天可整段完成
|
||||
Season bool
|
||||
// DefaultValidityDays 按次套餐默认有效期(天),0 不限
|
||||
DefaultValidityDays int
|
||||
// 包赛季的固定区间(套餐级)
|
||||
SeasonStart string
|
||||
SeasonEnd string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
SubCount int
|
||||
@@ -32,6 +39,9 @@ type Package struct {
|
||||
|
||||
// UnitLabel 返回类型中文名。
|
||||
func (p *Package) UnitLabel() string {
|
||||
if p.Season {
|
||||
return "包赛季"
|
||||
}
|
||||
if p.Unit == domain.UnitCount {
|
||||
return "按次"
|
||||
}
|
||||
@@ -41,9 +51,9 @@ func (p *Package) UnitLabel() string {
|
||||
func scanPackage(row interface{ Scan(dest ...any) error }) (*Package, error) {
|
||||
var p Package
|
||||
var price sql.NullInt64
|
||||
var archived, separate int
|
||||
var archived, separate, season, validity int
|
||||
err := row.Scan(&p.ID, &p.Name, &p.Unit, &p.DefaultAmount, &price, &p.Color, &p.Note,
|
||||
&archived, &p.SortOrder, &separate, &p.Tag, &p.CreatedAt, &p.UpdatedAt)
|
||||
&archived, &p.SortOrder, &separate, &p.Tag, &season, &validity, &p.SeasonStart, &p.SeasonEnd, &p.CreatedAt, &p.UpdatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
@@ -52,6 +62,8 @@ func scanPackage(row interface{ Scan(dest ...any) error }) (*Package, error) {
|
||||
}
|
||||
p.Archived = archived == 1
|
||||
p.CalendarSeparate = separate == 1
|
||||
p.Season = season == 1
|
||||
p.DefaultValidityDays = validity
|
||||
if price.Valid {
|
||||
p.PriceCents = int(price.Int64)
|
||||
p.HasPrice = true
|
||||
@@ -61,7 +73,7 @@ func scanPackage(row interface{ Scan(dest ...any) error }) (*Package, error) {
|
||||
|
||||
// ListPackages 列出套餐(含订阅计数)。
|
||||
func (db *DB) ListPackages(includeArchived bool) ([]Package, error) {
|
||||
q := `SELECT p.id,p.name,p.unit,p.default_amount,p.price_cents,p.color,p.note,p.archived,p.sort_order,p.calendar_separate,p.tag,p.created_at,p.updated_at,
|
||||
q := `SELECT p.id,p.name,p.unit,p.default_amount,p.price_cents,p.color,p.note,p.archived,p.sort_order,p.calendar_separate,p.tag,p.season,p.default_validity_days,p.season_start,p.season_end,p.created_at,p.updated_at,
|
||||
(SELECT COUNT(*) FROM subscriptions s WHERE s.package_id=p.id),
|
||||
(SELECT COUNT(*) FROM subscriptions s WHERE s.package_id=p.id AND s.status<>'closed')
|
||||
FROM packages p`
|
||||
@@ -78,13 +90,15 @@ func (db *DB) ListPackages(includeArchived bool) ([]Package, error) {
|
||||
for rows.Next() {
|
||||
var p Package
|
||||
var price sql.NullInt64
|
||||
var archived, separate int
|
||||
var archived, separate, season, validity int
|
||||
if err := rows.Scan(&p.ID, &p.Name, &p.Unit, &p.DefaultAmount, &price, &p.Color, &p.Note,
|
||||
&archived, &p.SortOrder, &separate, &p.Tag, &p.CreatedAt, &p.UpdatedAt, &p.SubCount, &p.ActiveCount); err != nil {
|
||||
&archived, &p.SortOrder, &separate, &p.Tag, &season, &validity, &p.SeasonStart, &p.SeasonEnd, &p.CreatedAt, &p.UpdatedAt, &p.SubCount, &p.ActiveCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Archived = archived == 1
|
||||
p.CalendarSeparate = separate == 1
|
||||
p.Season = season == 1
|
||||
p.DefaultValidityDays = validity
|
||||
if price.Valid {
|
||||
p.PriceCents = int(price.Int64)
|
||||
p.HasPrice = true
|
||||
@@ -96,7 +110,7 @@ func (db *DB) ListPackages(includeArchived bool) ([]Package, error) {
|
||||
|
||||
// Package 取单个套餐。
|
||||
func (db *DB) Package(id int64) (*Package, error) {
|
||||
return scanPackage(db.sql.QueryRow(`SELECT id,name,unit,default_amount,price_cents,color,note,archived,sort_order,calendar_separate,tag,created_at,updated_at
|
||||
return scanPackage(db.sql.QueryRow(`SELECT id,name,unit,default_amount,price_cents,color,note,archived,sort_order,calendar_separate,tag,season,default_validity_days,season_start,season_end,created_at,updated_at
|
||||
FROM packages WHERE id=?`, id))
|
||||
}
|
||||
|
||||
@@ -114,9 +128,9 @@ func (db *DB) CreatePackage(p *Package) (int64, error) {
|
||||
if p.HasPrice {
|
||||
price = p.PriceCents
|
||||
}
|
||||
res, err := db.sql.Exec(`INSERT INTO packages(name,unit,default_amount,price_cents,color,note,archived,sort_order,calendar_separate,tag,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,0,?,?,?,?,?)`,
|
||||
p.Name, p.Unit, p.DefaultAmount, price, p.Color, p.Note, p.SortOrder, b2i(p.CalendarSeparate), strings.TrimSpace(p.Tag), now, now)
|
||||
res, err := db.sql.Exec(`INSERT INTO packages(name,unit,default_amount,price_cents,color,note,archived,sort_order,calendar_separate,tag,season,default_validity_days,season_start,season_end,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,0,?,?,?,?,?,?,?,?,?)`,
|
||||
p.Name, p.Unit, p.DefaultAmount, price, p.Color, p.Note, p.SortOrder, b2i(p.CalendarSeparate), strings.TrimSpace(p.Tag), b2i(p.Season), p.DefaultValidityDays, p.SeasonStart, p.SeasonEnd, now, now)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -136,8 +150,8 @@ func (db *DB) UpdatePackage(p *Package) error {
|
||||
if p.HasPrice {
|
||||
price = p.PriceCents
|
||||
}
|
||||
_, err := db.sql.Exec(`UPDATE packages SET name=?,unit=?,default_amount=?,price_cents=?,color=?,note=?,sort_order=?,calendar_separate=?,tag=?,updated_at=?
|
||||
WHERE id=?`, p.Name, p.Unit, p.DefaultAmount, price, p.Color, p.Note, p.SortOrder, b2i(p.CalendarSeparate), strings.TrimSpace(p.Tag), nowStamp(), p.ID)
|
||||
_, err := db.sql.Exec(`UPDATE packages SET name=?,unit=?,default_amount=?,price_cents=?,color=?,note=?,sort_order=?,calendar_separate=?,tag=?,season=?,default_validity_days=?,season_start=?,season_end=?,updated_at=?
|
||||
WHERE id=?`, p.Name, p.Unit, p.DefaultAmount, price, p.Color, p.Note, p.SortOrder, b2i(p.CalendarSeparate), strings.TrimSpace(p.Tag), b2i(p.Season), p.DefaultValidityDays, p.SeasonStart, p.SeasonEnd, nowStamp(), p.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -194,3 +208,11 @@ func (db *DB) PackageTags() ([]string, error) {
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SeasonExpired 判断包赛季套餐在 today 是否已结束。
|
||||
func (p Package) SeasonExpired(today string) bool {
|
||||
if !p.Season || p.SeasonEnd == "" {
|
||||
return false
|
||||
}
|
||||
return today > p.SeasonEnd
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ type Subscription struct {
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
ClosedAt string
|
||||
// Season 包赛季;SeasonDone 标记整段完成的日期(空=未完成)
|
||||
Season bool
|
||||
SeasonDone string
|
||||
|
||||
// 联表
|
||||
PackageName string
|
||||
@@ -98,6 +101,7 @@ func (a *Adjustment) KindLabel() string {
|
||||
|
||||
const subSelect = `SELECT s.id,s.customer_id,s.package_id,s.unit,s.status,s.start_date,IFNULL(s.end_date,''),
|
||||
s.total_count,s.used_count,s.note,IFNULL(s.created_by,0),s.created_at,s.updated_at,IFNULL(s.closed_at,''),
|
||||
IFNULL(s.season,0),IFNULL(s.season_done,''),
|
||||
p.name,p.color,c.tail,c.name,IFNULL(c.contact,''),
|
||||
IFNULL(c.assignee_id,0),
|
||||
IFNULL((SELECT u.display_name FROM users u WHERE u.id=c.assignee_id),''),
|
||||
@@ -108,8 +112,10 @@ const subSelect = `SELECT s.id,s.customer_id,s.package_id,s.unit,s.status,s.star
|
||||
|
||||
func scanSub(row interface{ Scan(dest ...any) error }) (*Subscription, error) {
|
||||
var s Subscription
|
||||
var seasonInt int
|
||||
err := row.Scan(&s.ID, &s.CustomerID, &s.PackageID, &s.Unit, &s.Status, &s.StartDate, &s.EndDate,
|
||||
&s.TotalCount, &s.UsedCount, &s.Note, &s.CreatedBy, &s.CreatedAt, &s.UpdatedAt, &s.ClosedAt,
|
||||
&seasonInt, &s.SeasonDone,
|
||||
&s.PackageName, &s.PackageColor, &s.CustomerTail, &s.CustomerName, &s.CustomerContact,
|
||||
&s.AssigneeID, &s.AssigneeName, &s.AssigneeColor)
|
||||
if err != nil {
|
||||
@@ -118,6 +124,7 @@ func scanSub(row interface{ Scan(dest ...any) error }) (*Subscription, error) {
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
s.Season = seasonInt == 1
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
@@ -263,6 +270,9 @@ func (db *DB) CreateSub(in CreateSubInput) (*Subscription, error) {
|
||||
if pkg.Archived {
|
||||
return nil, errors.New("该套餐已归档,不能新建订阅")
|
||||
}
|
||||
if pkg.Season && strings.TrimSpace(in.StartDate) == "" {
|
||||
in.StartDate = pkg.SeasonStart
|
||||
}
|
||||
start, err := domain.ParseDate(in.StartDate)
|
||||
if err != nil {
|
||||
return nil, errors.New("开始日期格式不正确")
|
||||
@@ -309,8 +319,28 @@ func (db *DB) CreateSub(in CreateSubInput) (*Subscription, error) {
|
||||
return nil, errors.New("有效期不能早于开始日期")
|
||||
}
|
||||
endDate = domain.Date(end)
|
||||
} else if pkg.DefaultValidityDays > 0 {
|
||||
endDate = domain.Date(domain.AddDays(start, pkg.DefaultValidityDays-1))
|
||||
}
|
||||
}
|
||||
if pkg.Season {
|
||||
if pkg.SeasonStart == "" || pkg.SeasonEnd == "" {
|
||||
return nil, errors.New("该赛季套餐未设置起止日期")
|
||||
}
|
||||
end, err := domain.ParseDate(pkg.SeasonEnd)
|
||||
if err != nil {
|
||||
return nil, errors.New("赛季结束日期不正确")
|
||||
}
|
||||
if domain.DayDiff(domain.Today(), end) < 0 {
|
||||
return nil, errors.New("该赛季已结束,不能开通")
|
||||
}
|
||||
if domain.DayDiff(start, end) < 0 {
|
||||
return nil, errors.New("赛季结束日期早于开始日期")
|
||||
}
|
||||
endDate = domain.Date(end)
|
||||
unit = domain.UnitDay
|
||||
days = domain.DayDiff(start, end) + 1
|
||||
}
|
||||
|
||||
now := nowStamp()
|
||||
var id int64
|
||||
@@ -324,9 +354,9 @@ func (db *DB) CreateSub(in CreateSubInput) (*Subscription, error) {
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
res, err := tx.Exec(`INSERT INTO subscriptions(customer_id,package_id,unit,status,start_date,end_date,total_count,used_count,note,created_by,created_at,updated_at)
|
||||
VALUES(?,?,?,'active',?,?,?,0,?,?,?,?)`,
|
||||
in.CustomerID, in.PackageID, unit, domain.Date(start), nullIfEmpty(endDate), total, in.Note, nullIfZero(in.OperatorID), now, now)
|
||||
res, err := tx.Exec(`INSERT INTO subscriptions(customer_id,package_id,unit,status,start_date,end_date,total_count,used_count,note,created_by,created_at,updated_at,season)
|
||||
VALUES(?,?,?,'active',?,?,?,0,?,?,?,?,?)`,
|
||||
in.CustomerID, in.PackageID, unit, domain.Date(start), nullIfEmpty(endDate), total, in.Note, nullIfZero(in.OperatorID), now, now, b2i(pkg.Season))
|
||||
if err != nil {
|
||||
if isUnique(err) {
|
||||
return errors.New("该客户已订阅此套餐,请改用「追加时长」")
|
||||
@@ -605,3 +635,29 @@ func (db *DB) CountActiveSubs(today string) (int, error) {
|
||||
func (s *Subscription) SubTitle() string {
|
||||
return fmt.Sprintf("%s %s · %s", s.CustomerTail, s.CustomerName, s.PackageName)
|
||||
}
|
||||
|
||||
// SeasonComplete 把包赛季订阅整段标记为已完成。
|
||||
func (db *DB) SeasonComplete(subID int64, date string) error {
|
||||
sub, err := db.Sub(subID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !sub.Season {
|
||||
return errors.New("不是包赛季订阅")
|
||||
}
|
||||
_, err = db.sql.Exec(`UPDATE subscriptions SET season_done=?, updated_at=? WHERE id=?`,
|
||||
date, nowStamp(), subID)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteSubscription 彻底删除订阅;打卡、暂停、追加、加急随外键级联删除。
|
||||
func (db *DB) DeleteSubscription(subID int64) error {
|
||||
res, err := db.sql.Exec(`DELETE FROM subscriptions WHERE id=?`, subID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user