1) 管理员颜色 - users 表新增 color;设置 → 管理员 每行一个颜色选择器,保存即生效 - 新建管理员自动从 8 色调色板分配,尽量不重复;支持 #RRGGBB 校验,非法值拒绝 2) 客户归属打手 - customers 表新增 assignee_id(可空,指向 users) - 新建/编辑客户时可选择「归属打手」,客户详情页也能直接改派 - 语义:一个客户一个打手,该客户之后的所有单子都归这个打手 - 停用的账户不出现在打手候选里;未指派显示灰色「未指派」 3) 日历 - 每个日期格左侧新增 5px 竖条,按当天涉及的打手颜色等分显示(几种颜色几段) - 竖条按“当天全部任务”去重计算,不受格内色签 3 条上限影响 - 未指派的任务用中性灰段落 4) 待办与大屏 - 「当日待完成」每条任务左侧 3px 打手色条 + 圆点色标 + 打手名字,未指派灰字 - 大屏:今日待打列表、热力格竖条、悬停浮层(新增 who / who_color 字段)统一使用同一套颜色 - 加急样式改为红底,避免与打手色左边框冲突 数据库兼容性: - 只做加法:users.color、customers.assignee_id 两列;老库打开时自动 ALTER,可重复迁移 - 原有表结构、字段、页面接口不变;未指派的存量数据照常工作 测试: - 新增单测:颜色自动分配/规范化/非法值拒绝、客户指派与取消、订阅联表带出打手、停用账户不进候选 - 旧库迁移测试扩展:老 users / customers 表自动补列并可写 - 功能验证 16 项(颜色保存、指派、详情展示、日历多色竖条分段、大屏列表与浮层接口)全部通过 - 原有 119 项验收回归全绿;go vet / go test ./... 通过;版本号 1.3.0
607 lines
18 KiB
Go
607 lines
18 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"daili/internal/domain"
|
|
)
|
|
|
|
// Subscription 服务订阅。
|
|
type Subscription struct {
|
|
ID int64
|
|
CustomerID int64
|
|
PackageID int64
|
|
Unit string
|
|
Status string
|
|
StartDate string
|
|
EndDate string
|
|
TotalCount int
|
|
UsedCount int
|
|
Note string
|
|
CreatedBy int64
|
|
CreatedAt string
|
|
UpdatedAt string
|
|
ClosedAt string
|
|
|
|
// 联表
|
|
PackageName string
|
|
PackageColor string
|
|
CustomerTail string
|
|
CustomerName string
|
|
CustomerContact string
|
|
|
|
// 客户归属的打手(管理员)
|
|
AssigneeID int64
|
|
AssigneeName string
|
|
AssigneeColor string
|
|
}
|
|
|
|
// Eval 计算展示状态。
|
|
func (s *Subscription) Eval(today time.Time, warnDays int) domain.Eval {
|
|
return domain.EvalSub(s.Unit, s.Status, s.StartDate, s.EndDate, s.TotalCount, s.UsedCount, today, warnDays)
|
|
}
|
|
|
|
// IsCount 是否按次套餐。
|
|
func (s *Subscription) IsCount() bool { return s.Unit == domain.UnitCount }
|
|
|
|
// Pause 暂停分段。
|
|
type Pause struct {
|
|
ID int64
|
|
SubID int64
|
|
StartAt string
|
|
EndAt string
|
|
StartDate string
|
|
EndDate string
|
|
}
|
|
|
|
// Adjustment 追加记录。
|
|
type Adjustment struct {
|
|
ID int64
|
|
SubID int64
|
|
Kind string
|
|
BaseDate string
|
|
OldEndDate string
|
|
NewEndDate string
|
|
DaysAdded int
|
|
CountAdded int
|
|
OldTotal int
|
|
NewTotal int
|
|
Note string
|
|
OperatorID int64
|
|
OperatorName string
|
|
CreatedAt string
|
|
|
|
// 联表
|
|
CustomerTail string
|
|
CustomerName string
|
|
PackageName string
|
|
}
|
|
|
|
// KindLabel 返回追加类型的中文名。
|
|
func (a *Adjustment) KindLabel() string {
|
|
switch a.Kind {
|
|
case "initial":
|
|
return "开通"
|
|
case "extend_days":
|
|
return "追加天数"
|
|
case "add_count":
|
|
return "追加次数"
|
|
case "set_end_date":
|
|
return "调整结束日"
|
|
}
|
|
return "调整"
|
|
}
|
|
|
|
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,''),
|
|
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),''),
|
|
IFNULL((SELECT u.color FROM users u WHERE u.id=c.assignee_id),'')
|
|
FROM subscriptions s
|
|
JOIN packages p ON p.id=s.package_id
|
|
JOIN customers c ON c.id=s.customer_id`
|
|
|
|
func scanSub(row interface{ Scan(dest ...any) error }) (*Subscription, error) {
|
|
var s Subscription
|
|
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,
|
|
&s.PackageName, &s.PackageColor, &s.CustomerTail, &s.CustomerName, &s.CustomerContact,
|
|
&s.AssigneeID, &s.AssigneeName, &s.AssigneeColor)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
return &s, nil
|
|
}
|
|
|
|
// Sub 取单个订阅。
|
|
func (db *DB) Sub(id int64) (*Subscription, error) {
|
|
return scanSub(db.sql.QueryRow(subSelect+` WHERE s.id=?`, id))
|
|
}
|
|
|
|
// SubsByCustomer 取某客户的订阅。
|
|
func (db *DB) SubsByCustomer(customerID int64) ([]Subscription, error) {
|
|
return db.querySubs(subSelect+` WHERE s.customer_id=? ORDER BY s.status, s.id`, customerID)
|
|
}
|
|
|
|
// SubsByCustomers 批量取订阅(客户列表聚合用)。
|
|
func (db *DB) SubsByCustomers(ids []int64) ([]Subscription, error) {
|
|
if len(ids) == 0 {
|
|
return nil, nil
|
|
}
|
|
ph := placeholders(len(ids))
|
|
args := make([]any, len(ids))
|
|
for i, id := range ids {
|
|
args[i] = id
|
|
}
|
|
return db.querySubs(subSelect+` WHERE s.customer_id IN (`+ph+`) ORDER BY s.id`, args...)
|
|
}
|
|
|
|
func (db *DB) querySubs(q string, args ...any) ([]Subscription, error) {
|
|
rows, err := db.sql.Query(q, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []Subscription{}
|
|
for rows.Next() {
|
|
s, err := scanSub(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, *s)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func placeholders(n int) string {
|
|
if n <= 0 {
|
|
return ""
|
|
}
|
|
return strings.TrimSuffix(strings.Repeat("?,", n), ",")
|
|
}
|
|
|
|
// SubFilter 订阅列表筛选。
|
|
type SubFilter struct {
|
|
Q string
|
|
State string
|
|
PackageID int64
|
|
Limit int
|
|
Offset int
|
|
Today time.Time
|
|
}
|
|
|
|
const expiredExpr = `((s.unit='day' AND s.end_date < ?) OR (s.unit='count' AND (s.used_count>=s.total_count OR (IFNULL(s.end_date,'')<>'' AND s.end_date < ?))))`
|
|
|
|
// ListSubs 分页查询订阅。
|
|
func (db *DB) ListSubs(f SubFilter) ([]Subscription, int, error) {
|
|
where := []string{"1=1"}
|
|
args := []any{}
|
|
today := domain.Date(f.Today)
|
|
if q := strings.TrimSpace(f.Q); q != "" {
|
|
like := "%" + q + "%"
|
|
where = append(where, "(c.tail LIKE ? OR c.name LIKE ? OR p.name LIKE ?)")
|
|
args = append(args, like, like, like)
|
|
}
|
|
if f.PackageID > 0 {
|
|
where = append(where, "s.package_id=?")
|
|
args = append(args, f.PackageID)
|
|
}
|
|
switch f.State {
|
|
case "active":
|
|
where = append(where, "s.status='active'", "NOT "+expiredExpr)
|
|
args = append(args, today, today)
|
|
case "paused":
|
|
where = append(where, "s.status='paused'")
|
|
case "closed":
|
|
where = append(where, "s.status='closed'")
|
|
case "expired":
|
|
where = append(where, "s.status<>'closed'", expiredExpr)
|
|
args = append(args, today, today)
|
|
case "due7":
|
|
where = append(where, "s.status<>'closed'", "s.end_date IS NOT NULL", "s.end_date BETWEEN ? AND ?")
|
|
args = append(args, today, domain.Date(domain.AddDays(f.Today, 6)))
|
|
case "due3":
|
|
where = append(where, "s.status<>'closed'", "s.end_date IS NOT NULL", "s.end_date BETWEEN ? AND ?")
|
|
args = append(args, today, domain.Date(domain.AddDays(f.Today, 2)))
|
|
case "due_today":
|
|
where = append(where, "s.status<>'closed'", "s.end_date = ?")
|
|
args = append(args, today)
|
|
}
|
|
w := strings.Join(where, " AND ")
|
|
var total int
|
|
if err := db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions s
|
|
JOIN packages p ON p.id=s.package_id JOIN customers c ON c.id=s.customer_id WHERE `+w, args...).Scan(&total); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
lim := f.Limit
|
|
if lim <= 0 {
|
|
lim = 30
|
|
}
|
|
q := subSelect + ` WHERE ` + w + ` ORDER BY s.status, IFNULL(s.end_date,'9999-12-31'), s.id DESC LIMIT ? OFFSET ?`
|
|
subs, err := db.querySubs(q, append(args, lim, f.Offset)...)
|
|
return subs, total, err
|
|
}
|
|
|
|
// CalendarSubs 取与 [from,to] 日期区间相交的订阅(用于日历)。
|
|
func (db *DB) CalendarSubs(from, to string) ([]Subscription, error) {
|
|
return db.querySubs(subSelect+` WHERE s.status<>'closed'
|
|
AND s.start_date <= ? AND IFNULL(s.end_date,'')<>'' AND s.end_date >= ?
|
|
ORDER BY s.id`, to, from)
|
|
}
|
|
|
|
// DueOn 某天到期的订阅。
|
|
func (db *DB) DueOn(date string) ([]Subscription, error) {
|
|
return db.querySubs(subSelect+` WHERE s.status<>'closed' AND s.end_date=? ORDER BY s.id`, date)
|
|
}
|
|
|
|
// CreateSubInput 新建订阅入参。
|
|
type CreateSubInput struct {
|
|
CustomerID int64
|
|
PackageID int64
|
|
StartDate string
|
|
EndDate string
|
|
Days int
|
|
TotalCount int
|
|
Note string
|
|
OperatorID int64
|
|
}
|
|
|
|
// CreateSub 新建订阅,并写入初始追加记录。
|
|
func (db *DB) CreateSub(in CreateSubInput) (*Subscription, error) {
|
|
pkg, err := db.Package(in.PackageID)
|
|
if err != nil {
|
|
return nil, errors.New("套餐不存在")
|
|
}
|
|
if pkg.Archived {
|
|
return nil, errors.New("该套餐已归档,不能新建订阅")
|
|
}
|
|
start, err := domain.ParseDate(in.StartDate)
|
|
if err != nil {
|
|
return nil, errors.New("开始日期格式不正确")
|
|
}
|
|
unit := pkg.Unit
|
|
endDate := ""
|
|
days := 0
|
|
total := 0
|
|
if unit == domain.UnitDay {
|
|
days = in.Days
|
|
if days <= 0 {
|
|
days = pkg.DefaultAmount
|
|
}
|
|
if days <= 0 {
|
|
days = 30
|
|
}
|
|
if in.EndDate != "" {
|
|
end, err := domain.ParseDate(in.EndDate)
|
|
if err != nil {
|
|
return nil, errors.New("结束日期格式不正确")
|
|
}
|
|
if domain.DayDiff(start, end) < 0 {
|
|
return nil, errors.New("结束日期不能早于开始日期")
|
|
}
|
|
endDate = domain.Date(end)
|
|
days = domain.DayDiff(start, end) + 1
|
|
} else {
|
|
endDate = domain.Date(domain.AddDays(start, days-1))
|
|
}
|
|
} else {
|
|
total = in.TotalCount
|
|
if total <= 0 {
|
|
total = pkg.DefaultAmount
|
|
}
|
|
if total <= 0 {
|
|
return nil, errors.New("总次数必须大于 0")
|
|
}
|
|
if in.EndDate != "" {
|
|
end, err := domain.ParseDate(in.EndDate)
|
|
if err != nil {
|
|
return nil, errors.New("有效期格式不正确")
|
|
}
|
|
if domain.DayDiff(start, end) < 0 {
|
|
return nil, errors.New("有效期不能早于开始日期")
|
|
}
|
|
endDate = domain.Date(end)
|
|
}
|
|
}
|
|
|
|
now := nowStamp()
|
|
var id int64
|
|
err = db.withTx(func(tx *sql.Tx) error {
|
|
var existing int64
|
|
err := tx.QueryRow(`SELECT id FROM subscriptions WHERE customer_id=? AND package_id=?`,
|
|
in.CustomerID, in.PackageID).Scan(&existing)
|
|
if err == nil {
|
|
return errors.New("该客户已订阅此套餐,请改用「追加时长」")
|
|
}
|
|
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)
|
|
if err != nil {
|
|
if isUnique(err) {
|
|
return errors.New("该客户已订阅此套餐,请改用「追加时长」")
|
|
}
|
|
return err
|
|
}
|
|
id, _ = res.LastInsertId()
|
|
_, err = tx.Exec(`INSERT INTO sub_adjustments(sub_id,kind,base_date,old_end_date,new_end_date,days_added,count_added,old_total,new_total,note,operator_id,created_at)
|
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
id, "initial", domain.Date(start), "", nullIfEmpty(endDate), days, 0, 0, total, "新建订阅", nullIfZero(in.OperatorID), now)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return db.Sub(id)
|
|
}
|
|
|
|
func nullIfEmpty(s string) any {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
return s
|
|
}
|
|
|
|
// ExtendDays 按日追加:手动指定新的结束日,自动换算计算起点与追加天数。
|
|
func (db *DB) ExtendDays(subID int64, newEndDate, note string, operatorID int64) (domain.ExtendPlan, error) {
|
|
var plan domain.ExtendPlan
|
|
today := domain.Today()
|
|
newEnd, err := domain.ParseDate(newEndDate)
|
|
if err != nil {
|
|
return plan, errors.New("请选择新的终止日期")
|
|
}
|
|
err = db.withTx(func(tx *sql.Tx) error {
|
|
s, err := scanSub(tx.QueryRow(subSelect+` WHERE s.id=?`, subID))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if s.Unit != domain.UnitDay {
|
|
return errors.New("该订阅按次计费,请使用「追加次数」")
|
|
}
|
|
if s.Status == domain.StatusClosed {
|
|
return errors.New("订阅已结束,请先「重新启用」")
|
|
}
|
|
oldEnd := domain.ParseDateOr(s.EndDate, today)
|
|
plan = domain.PlanExtend(oldEnd, newEnd, today)
|
|
if plan.Backwards {
|
|
return fmt.Errorf("所选日期早于计算起点 %s,不会增加时长", domain.Date(plan.Base))
|
|
}
|
|
status := s.Status
|
|
if status == domain.StatusPaused {
|
|
status = domain.StatusActive
|
|
}
|
|
if _, err := tx.Exec(`UPDATE subscriptions SET end_date=?, status=?, updated_at=? WHERE id=?`,
|
|
domain.Date(newEnd), status, nowStamp(), subID); err != nil {
|
|
return err
|
|
}
|
|
_, err = tx.Exec(`INSERT INTO sub_adjustments(sub_id,kind,base_date,old_end_date,new_end_date,days_added,count_added,old_total,new_total,note,operator_id,created_at)
|
|
VALUES(?,?,?,?,?,?,0,0,0,?,?,?)`,
|
|
subID, "extend_days", domain.Date(plan.Base), s.EndDate, domain.Date(newEnd), plan.Days, note, nullIfZero(operatorID), nowStamp())
|
|
return err
|
|
})
|
|
return plan, err
|
|
}
|
|
|
|
// AddCount 按次追加次数(可同时调整有效期)。
|
|
func (db *DB) AddCount(subID int64, add int, newEndDate, note string, operatorID int64) error {
|
|
if add <= 0 {
|
|
return errors.New("追加次数必须大于 0")
|
|
}
|
|
err := db.withTx(func(tx *sql.Tx) error {
|
|
s, err := scanSub(tx.QueryRow(subSelect+` WHERE s.id=?`, subID))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if s.Unit != domain.UnitCount {
|
|
return errors.New("该订阅按日计费,请使用「追加天数」")
|
|
}
|
|
if s.Status == domain.StatusClosed {
|
|
return errors.New("订阅已结束,请先「重新启用」")
|
|
}
|
|
endDate := s.EndDate
|
|
if newEndDate != "" {
|
|
end, err := domain.ParseDate(newEndDate)
|
|
if err != nil {
|
|
return errors.New("有效期格式不正确")
|
|
}
|
|
if end.Before(domain.ParseDateOr(s.StartDate, domain.Today())) {
|
|
return errors.New("有效期不能早于开始日期")
|
|
}
|
|
endDate = domain.Date(end)
|
|
}
|
|
status := s.Status
|
|
if status == domain.StatusPaused {
|
|
status = domain.StatusActive
|
|
}
|
|
newTotal := s.TotalCount + add
|
|
if _, err := tx.Exec(`UPDATE subscriptions SET total_count=?, end_date=?, status=?, updated_at=? WHERE id=?`,
|
|
newTotal, nullIfEmpty(endDate), status, nowStamp(), subID); err != nil {
|
|
return err
|
|
}
|
|
_, err = tx.Exec(`INSERT INTO sub_adjustments(sub_id,kind,old_end_date,new_end_date,days_added,count_added,old_total,new_total,note,operator_id,created_at)
|
|
VALUES(?,?,?,?,0,?,?,?,?,?,?)`,
|
|
subID, "add_count", s.EndDate, endDate, add, s.TotalCount, newTotal, note, nullIfZero(operatorID), nowStamp())
|
|
return err
|
|
})
|
|
return err
|
|
}
|
|
|
|
// PauseSub 暂停订阅。
|
|
func (db *DB) PauseSub(subID, operatorID int64) error {
|
|
return db.withTx(func(tx *sql.Tx) error {
|
|
s, err := scanSub(tx.QueryRow(subSelect+` WHERE s.id=?`, subID))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if s.Status == domain.StatusPaused {
|
|
return errors.New("订阅已处于暂停状态")
|
|
}
|
|
if s.Status == domain.StatusClosed {
|
|
return errors.New("订阅已结束,无法暂停")
|
|
}
|
|
now := nowStamp()
|
|
if _, err := tx.Exec(`INSERT INTO sub_pauses(sub_id,start_at) VALUES(?,?)`, subID, now); err != nil {
|
|
return err
|
|
}
|
|
_, err = tx.Exec(`UPDATE subscriptions SET status='paused', updated_at=? WHERE id=?`, now, subID)
|
|
return err
|
|
})
|
|
}
|
|
|
|
// ResumeSub 恢复订阅,按日套餐顺延结束日,返回顺延天数。
|
|
func (db *DB) ResumeSub(subID, operatorID int64) (int, error) {
|
|
var shift int
|
|
today := domain.Today()
|
|
err := db.withTx(func(tx *sql.Tx) error {
|
|
s, err := scanSub(tx.QueryRow(subSelect+` WHERE s.id=?`, subID))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if s.Status != domain.StatusPaused {
|
|
return errors.New("订阅未处于暂停状态")
|
|
}
|
|
var startAt string
|
|
err = tx.QueryRow(`SELECT start_at FROM sub_pauses WHERE sub_id=? AND end_at IS NULL ORDER BY id DESC LIMIT 1`, subID).Scan(&startAt)
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return err
|
|
}
|
|
if err == nil {
|
|
ps := domain.ParseDateOr(startAt[:10], today)
|
|
shift = domain.DayDiff(ps, today)
|
|
if shift < 0 {
|
|
shift = 0
|
|
}
|
|
}
|
|
now := nowStamp()
|
|
if _, err := tx.Exec(`UPDATE sub_pauses SET end_at=? WHERE sub_id=? AND end_at IS NULL`, now, subID); err != nil {
|
|
return err
|
|
}
|
|
if s.Unit == domain.UnitDay && shift > 0 {
|
|
oldEnd := domain.ParseDateOr(s.EndDate, today)
|
|
if _, err := tx.Exec(`UPDATE subscriptions SET end_date=? WHERE id=?`,
|
|
domain.Date(domain.AddDays(oldEnd, shift)), subID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
_, err = tx.Exec(`UPDATE subscriptions SET status='active', updated_at=? WHERE id=?`, now, subID)
|
|
return err
|
|
})
|
|
return shift, err
|
|
}
|
|
|
|
// CloseSub 结束订阅。
|
|
func (db *DB) CloseSub(subID int64) error {
|
|
now := nowStamp()
|
|
_, err := db.sql.Exec(`UPDATE subscriptions SET status='closed', closed_at=?, updated_at=? WHERE id=? AND status<>'closed'`,
|
|
now, now, subID)
|
|
return err
|
|
}
|
|
|
|
// ReopenSub 重新启用已结束的订阅。
|
|
func (db *DB) ReopenSub(subID int64) error {
|
|
_, err := db.sql.Exec(`UPDATE subscriptions SET status='active', closed_at=NULL, updated_at=? WHERE id=?`, nowStamp(), subID)
|
|
return err
|
|
}
|
|
|
|
// AdjustmentsBySub 取某订阅的追加记录(新→旧)。
|
|
func (db *DB) AdjustmentsBySub(subID int64) ([]Adjustment, error) {
|
|
return db.queryAdjustments(`SELECT a.id,a.sub_id,a.kind,IFNULL(a.base_date,''),IFNULL(a.old_end_date,''),IFNULL(a.new_end_date,''),
|
|
a.days_added,a.count_added,a.old_total,a.new_total,a.note,IFNULL(a.operator_id,0),IFNULL(u.display_name,''),a.created_at,
|
|
c.tail,c.name,p.name
|
|
FROM sub_adjustments a
|
|
LEFT JOIN users u ON u.id=a.operator_id
|
|
JOIN subscriptions s ON s.id=a.sub_id
|
|
JOIN customers c ON c.id=s.customer_id
|
|
JOIN packages p ON p.id=s.package_id
|
|
WHERE a.sub_id=? ORDER BY a.id DESC`, subID)
|
|
}
|
|
|
|
// RecentAdjustments 最近追加记录。
|
|
func (db *DB) RecentAdjustments(limit int) ([]Adjustment, error) {
|
|
return db.queryAdjustments(`SELECT a.id,a.sub_id,a.kind,IFNULL(a.base_date,''),IFNULL(a.old_end_date,''),IFNULL(a.new_end_date,''),
|
|
a.days_added,a.count_added,a.old_total,a.new_total,a.note,IFNULL(a.operator_id,0),IFNULL(u.display_name,''),a.created_at,
|
|
c.tail,c.name,p.name
|
|
FROM sub_adjustments a
|
|
LEFT JOIN users u ON u.id=a.operator_id
|
|
JOIN subscriptions s ON s.id=a.sub_id
|
|
JOIN customers c ON c.id=s.customer_id
|
|
JOIN packages p ON p.id=s.package_id
|
|
ORDER BY a.id DESC LIMIT ?`, limit)
|
|
}
|
|
|
|
func (db *DB) queryAdjustments(q string, args ...any) ([]Adjustment, error) {
|
|
rows, err := db.sql.Query(q, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []Adjustment{}
|
|
for rows.Next() {
|
|
var a Adjustment
|
|
if err := rows.Scan(&a.ID, &a.SubID, &a.Kind, &a.BaseDate, &a.OldEndDate, &a.NewEndDate,
|
|
&a.DaysAdded, &a.CountAdded, &a.OldTotal, &a.NewTotal, &a.Note, &a.OperatorID, &a.OperatorName, &a.CreatedAt,
|
|
&a.CustomerTail, &a.CustomerName, &a.PackageName); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, a)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// PausesForSubs 批量取暂停分段。
|
|
func (db *DB) PausesForSubs(ids []int64) (map[int64][]Pause, error) {
|
|
out := map[int64][]Pause{}
|
|
if len(ids) == 0 {
|
|
return out, nil
|
|
}
|
|
args := make([]any, len(ids))
|
|
for i, id := range ids {
|
|
args[i] = id
|
|
}
|
|
rows, err := db.sql.Query(`SELECT id,sub_id,start_at,IFNULL(end_at,'') FROM sub_pauses
|
|
WHERE sub_id IN (`+placeholders(len(ids))+`) ORDER BY id`, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var p Pause
|
|
if err := rows.Scan(&p.ID, &p.SubID, &p.StartAt, &p.EndAt); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(p.StartAt) >= 10 {
|
|
p.StartDate = p.StartAt[:10]
|
|
}
|
|
if len(p.EndAt) >= 10 {
|
|
p.EndDate = p.EndAt[:10]
|
|
}
|
|
out[p.SubID] = append(out[p.SubID], p)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// CountActiveSubs 活跃订阅数。
|
|
func (db *DB) CountActiveSubs(today string) (int, error) {
|
|
var n int
|
|
err := db.sql.QueryRow(`SELECT COUNT(*) FROM subscriptions s WHERE s.status='active' AND (
|
|
(s.unit='day' AND s.end_date >= ?) OR
|
|
(s.unit='count' AND (IFNULL(s.end_date,'')='' OR s.end_date >= ?) AND s.used_count < s.total_count)
|
|
)`, today, today).Scan(&n)
|
|
return n, err
|
|
}
|
|
|
|
// SubTitle 生成「1234 老张 · 包月日常」式标题。
|
|
func (s *Subscription) SubTitle() string {
|
|
return fmt.Sprintf("%s %s · %s", s.CustomerTail, s.CustomerName, s.PackageName)
|
|
}
|