982 lines
27 KiB
Go
982 lines
27 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rogeecn/wxapp-kouqiang-guahao/backend/internal/db"
|
|
"github.com/sirupsen/logrus"
|
|
)
|
|
|
|
type Service struct {
|
|
DB *sql.DB
|
|
Q *db.Queries
|
|
Log *logrus.Logger
|
|
}
|
|
|
|
type CategoryWithProjects struct {
|
|
db.ProjectCategory
|
|
Projects []db.Project `json:"projects"`
|
|
}
|
|
|
|
type TimeSlotView struct {
|
|
ID string `json:"id"`
|
|
StartTime string `json:"start_time"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
type ScheduleDayWithSlots struct {
|
|
Date string `json:"date"`
|
|
Status string `json:"status"`
|
|
Slots []TimeSlotView `json:"slots"`
|
|
}
|
|
|
|
type BookingDetail struct {
|
|
Booking db.Booking `json:"booking"`
|
|
Project db.Project `json:"project"`
|
|
Day db.ScheduleDay `json:"day"`
|
|
Slot TimeSlotView `json:"slot"`
|
|
}
|
|
|
|
type CreateBookingRequest struct {
|
|
OpenID string `json:"openid"`
|
|
ProjectID string `json:"project_id"`
|
|
SlotID string `json:"slot_id"`
|
|
Date string `json:"date"`
|
|
StartTime string `json:"start_time"`
|
|
Phone string `json:"phone"`
|
|
}
|
|
|
|
type CreatePriceInquiryRequest struct {
|
|
OpenID string `json:"openid"`
|
|
Province string `json:"province"`
|
|
City string `json:"city"`
|
|
District string `json:"district"`
|
|
ProjectName string `json:"project_name"`
|
|
}
|
|
|
|
type CreateTimeSlotRequest struct {
|
|
ScheduleDayID string `json:"schedule_day_id"`
|
|
StartTime string `json:"start_time"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
type UpdateTimeSlotRequest struct {
|
|
StartTime string `json:"start_time"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
type SessionResult struct {
|
|
User db.User `json:"user"`
|
|
SessionID string `json:"session_id"`
|
|
}
|
|
|
|
const (
|
|
defaultSlotLengthMinutes int64 = 60
|
|
priceInquiryShanghaiTimeMigrationKey = "migration.price_inquiries.asia_shanghai.v1"
|
|
)
|
|
|
|
var priceInquiryProjects = []string{
|
|
"牙齿矫正",
|
|
"牙齿种植",
|
|
"牙齿修复",
|
|
"牙齿治疗",
|
|
"全瓷牙种植",
|
|
"牙齿美白",
|
|
"正颌",
|
|
"瓷贴面",
|
|
"美容冠",
|
|
"牙齿清洁",
|
|
}
|
|
|
|
func New(database *sql.DB, logger *logrus.Logger) *Service {
|
|
return &Service{
|
|
DB: database,
|
|
Q: db.New(database),
|
|
Log: logger,
|
|
}
|
|
}
|
|
|
|
func ApplySchema(ctx context.Context, database *sql.DB, schemaPath string) error {
|
|
schema, err := os.ReadFile(schemaPath)
|
|
if err != nil {
|
|
return fmt.Errorf("read schema %s: %w", schemaPath, err)
|
|
}
|
|
if err := dedupeUserPhones(ctx, database); err != nil {
|
|
return fmt.Errorf("dedupe user phones: %w", err)
|
|
}
|
|
if err := ensurePriceInquiryColumns(ctx, database); err != nil {
|
|
return fmt.Errorf("ensure price inquiry columns: %w", err)
|
|
}
|
|
if err := dedupePriceInquiries(ctx, database); err != nil {
|
|
return fmt.Errorf("dedupe price inquiries: %w", err)
|
|
}
|
|
if _, err := database.ExecContext(ctx, string(schema)); err != nil {
|
|
return fmt.Errorf("apply schema: %w", err)
|
|
}
|
|
if err := ensureSchemaColumns(ctx, database); err != nil {
|
|
return fmt.Errorf("ensure schema columns: %w", err)
|
|
}
|
|
if err := migratePriceInquiryTimesToShanghai(ctx, database); err != nil {
|
|
return fmt.Errorf("migrate price inquiry times to Asia/Shanghai: %w", err)
|
|
}
|
|
if err := dropLegacyConfigColumns(ctx, database); err != nil {
|
|
return fmt.Errorf("drop legacy config columns: %w", err)
|
|
}
|
|
if err := normalizeScheduleStatuses(ctx, database); err != nil {
|
|
return fmt.Errorf("normalize schedule statuses: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func dedupePriceInquiries(ctx context.Context, database *sql.DB) error {
|
|
exists, err := columnExists(ctx, database, "price_inquiries", "phone")
|
|
if err != nil || !exists {
|
|
return err
|
|
}
|
|
|
|
_, err = database.ExecContext(ctx, `
|
|
DELETE FROM price_inquiries
|
|
WHERE id IN (
|
|
SELECT id
|
|
FROM (
|
|
SELECT id, ROW_NUMBER() OVER (
|
|
PARTITION BY phone
|
|
ORDER BY last_submitted_at DESC, updated_at DESC, created_at DESC, id DESC
|
|
) AS duplicate_rank
|
|
FROM price_inquiries
|
|
)
|
|
WHERE duplicate_rank > 1
|
|
)`)
|
|
return err
|
|
}
|
|
|
|
func dedupeUserPhones(ctx context.Context, database *sql.DB) error {
|
|
exists, err := columnExists(ctx, database, "users", "phone")
|
|
if err != nil || !exists {
|
|
return err
|
|
}
|
|
|
|
rows, err := database.QueryContext(ctx, `
|
|
SELECT id, phone
|
|
FROM users
|
|
WHERE phone IS NOT NULL AND TRIM(phone) <> ''
|
|
ORDER BY phone ASC, updated_at DESC, created_at DESC, id DESC`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
|
|
seen := make(map[string]struct{})
|
|
duplicateIDs := make([]string, 0)
|
|
for rows.Next() {
|
|
var (
|
|
id string
|
|
phone string
|
|
)
|
|
if err := rows.Scan(&id, &phone); err != nil {
|
|
return err
|
|
}
|
|
if _, ok := seen[phone]; ok {
|
|
duplicateIDs = append(duplicateIDs, id)
|
|
continue
|
|
}
|
|
seen[phone] = struct{}{}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return err
|
|
}
|
|
if len(duplicateIDs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
tx, err := database.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
for _, id := range duplicateIDs {
|
|
if _, err := tx.ExecContext(ctx, `
|
|
UPDATE users
|
|
SET phone = NULL, updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = ?`, id); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return tx.Commit()
|
|
}
|
|
|
|
func ensurePriceInquiryColumns(ctx context.Context, database *sql.DB) error {
|
|
exists, err := tableExists(ctx, database, "price_inquiries")
|
|
if err != nil || !exists {
|
|
return err
|
|
}
|
|
if err := addColumnIfMissing(ctx, database, "price_inquiries", "created_at", "created_at TEXT NOT NULL DEFAULT ''"); err != nil {
|
|
return err
|
|
}
|
|
if err := addColumnIfMissing(ctx, database, "price_inquiries", "updated_at", "updated_at TEXT NOT NULL DEFAULT ''"); err != nil {
|
|
return err
|
|
}
|
|
if err := addColumnIfMissing(ctx, database, "price_inquiries", "remark", "remark TEXT NOT NULL DEFAULT ''"); err != nil {
|
|
return err
|
|
}
|
|
if err := addColumnIfMissing(ctx, database, "price_inquiries", "last_submitted_at", "last_submitted_at TEXT NOT NULL DEFAULT ''"); err != nil {
|
|
return err
|
|
}
|
|
statements := []string{
|
|
"UPDATE price_inquiries SET created_at = CURRENT_TIMESTAMP WHERE TRIM(created_at) = ''",
|
|
"UPDATE price_inquiries SET updated_at = created_at WHERE TRIM(updated_at) = ''",
|
|
"UPDATE price_inquiries SET last_submitted_at = created_at WHERE TRIM(last_submitted_at) = ''",
|
|
}
|
|
for _, statement := range statements {
|
|
if _, err := database.ExecContext(ctx, statement); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func migratePriceInquiryTimesToShanghai(ctx context.Context, database *sql.DB) error {
|
|
tx, err := database.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
var applied bool
|
|
if err := tx.QueryRowContext(ctx, `
|
|
SELECT EXISTS(SELECT 1 FROM app_config WHERE config_key = ?)`, priceInquiryShanghaiTimeMigrationKey).Scan(&applied); err != nil {
|
|
return err
|
|
}
|
|
if applied {
|
|
return nil
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `
|
|
UPDATE price_inquiries
|
|
SET created_at = COALESCE(STRFTIME('%Y-%m-%d %H:%M:%f', created_at, '+8 hours'), created_at),
|
|
updated_at = COALESCE(STRFTIME('%Y-%m-%d %H:%M:%f', updated_at, '+8 hours'), updated_at),
|
|
last_submitted_at = COALESCE(STRFTIME('%Y-%m-%d %H:%M:%f', last_submitted_at, '+8 hours'), last_submitted_at)`); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `
|
|
INSERT INTO app_config (config_key, config_value) VALUES (?, 'applied')`, priceInquiryShanghaiTimeMigrationKey); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
func ensureSchemaColumns(ctx context.Context, database *sql.DB) error {
|
|
if err := addColumnIfMissing(ctx, database, "projects", "image", "image TEXT NOT NULL DEFAULT ''"); err != nil {
|
|
return err
|
|
}
|
|
return ensurePriceInquiryColumns(ctx, database)
|
|
}
|
|
|
|
func addColumnIfMissing(ctx context.Context, database *sql.DB, table, column, definition string) error {
|
|
exists, err := columnExists(ctx, database, table, column)
|
|
if err != nil || exists {
|
|
return err
|
|
}
|
|
_, err = database.ExecContext(ctx, "ALTER TABLE "+table+" ADD COLUMN "+definition)
|
|
return err
|
|
}
|
|
|
|
func tableExists(ctx context.Context, database *sql.DB, table string) (bool, error) {
|
|
var name string
|
|
err := database.QueryRowContext(ctx, `
|
|
SELECT name
|
|
FROM sqlite_master
|
|
WHERE type = 'table' AND name = ?`, table).Scan(&name)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return false, nil
|
|
}
|
|
return err == nil, err
|
|
}
|
|
|
|
func dropLegacyConfigColumns(ctx context.Context, database *sql.DB) error {
|
|
columns := []struct {
|
|
table string
|
|
column string
|
|
}{
|
|
{table: "project_categories", column: "icon"},
|
|
{table: "projects", column: "duration_minutes"},
|
|
{table: "projects", column: "notice"},
|
|
}
|
|
for _, item := range columns {
|
|
exists, err := columnExists(ctx, database, item.table, item.column)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !exists {
|
|
continue
|
|
}
|
|
if _, err := database.ExecContext(ctx, "ALTER TABLE "+item.table+" DROP COLUMN "+item.column); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func columnExists(ctx context.Context, database *sql.DB, table, column string) (bool, error) {
|
|
rows, err := database.QueryContext(ctx, "PRAGMA table_info("+table+")")
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var (
|
|
cid int64
|
|
name string
|
|
columnType string
|
|
notNull int64
|
|
defaultVal sql.NullString
|
|
pk int64
|
|
)
|
|
if err := rows.Scan(&cid, &name, &columnType, ¬Null, &defaultVal, &pk); err != nil {
|
|
return false, err
|
|
}
|
|
if name == column {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, rows.Err()
|
|
}
|
|
|
|
func normalizeScheduleStatuses(ctx context.Context, database *sql.DB) error {
|
|
statements := []string{
|
|
"UPDATE schedule_days SET status = 'available', updated_at = CURRENT_TIMESTAMP WHERE status <> 'available'",
|
|
"UPDATE time_slots SET status = 'available', updated_at = CURRENT_TIMESTAMP WHERE status <> 'available'",
|
|
}
|
|
for _, statement := range statements {
|
|
if _, err := database.ExecContext(ctx, statement); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func EnsureDatabaseDir(path string) error {
|
|
if strings.HasPrefix(path, "file:") || path == ":memory:" {
|
|
return nil
|
|
}
|
|
dir := filepath.Dir(path)
|
|
if dir == "." || dir == "" {
|
|
return nil
|
|
}
|
|
return os.MkdirAll(dir, 0o755)
|
|
}
|
|
|
|
func (s *Service) Seed(ctx context.Context) error {
|
|
count, err := s.Q.CountCategories(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if count > 0 {
|
|
return nil
|
|
}
|
|
tx, err := s.DB.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
q := s.Q.WithTx(tx)
|
|
|
|
categories := []db.CreateCategoryParams{
|
|
{ID: "cat_daily", Name: "日常护理", Sort: 10, Status: "active"},
|
|
{ID: "cat_clean", Name: "深层清洁", Sort: 20, Status: "active"},
|
|
{ID: "cat_white", Name: "美白亮齿", Sort: 30, Status: "active"},
|
|
{ID: "cat_breath", Name: "口气清新", Sort: 40, Status: "active"},
|
|
{ID: "cat_child", Name: "儿童护理", Sort: 50, Status: "active"},
|
|
}
|
|
for _, c := range categories {
|
|
if _, err := q.CreateCategory(ctx, c); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
projects := []db.CreateProjectParams{
|
|
defaultProject("project_fresh_clean", "cat_daily", "清新洁牙护理", "温和清洁 · 清新口气", "¥398", 10),
|
|
defaultProject("project_deep_clean", "cat_clean", "深层洁牙护理", "深层清洁 · 呵护牙龈", "¥598", 20),
|
|
defaultProject("project_light_care", "cat_daily", "舒缓护理", "舒缓敏感 · 轻柔呵护", "¥498", 30),
|
|
defaultProject("project_bright_white", "cat_white", "亮白护理", "提亮美白 · 光泽自信", "¥698", 40),
|
|
defaultProject("project_breath_fresh", "cat_breath", "口气清新护理", "清新口气 · 轻松交流", "到店咨询", 50),
|
|
defaultProject("project_child_care", "cat_child", "儿童口腔护理", "轻松陪伴 · 温和体验", "¥298", 60),
|
|
}
|
|
for _, p := range projects {
|
|
if _, err := q.CreateProject(ctx, p); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
base := time.Now().AddDate(0, 0, 1)
|
|
slotStarts := []string{"09:30", "10:30", "13:00", "14:00", "18:00", "19:00", "20:00"}
|
|
for _, project := range projects {
|
|
for i := 0; i < 21; i++ {
|
|
day := base.AddDate(0, 0, i)
|
|
date := day.Format("2006-01-02")
|
|
status := "available"
|
|
note := ""
|
|
dayID := staticScheduleDayID(project.ID, date)
|
|
createdDay, err := q.CreateScheduleDay(ctx, db.CreateScheduleDayParams{
|
|
ID: dayID, ProjectID: project.ID, Date: date, Status: status, Note: note,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, start := range slotStarts {
|
|
end := addMinutes(start, defaultSlotLengthMinutes)
|
|
slotStatus := status
|
|
_, err := q.CreateTimeSlot(ctx, db.CreateTimeSlotParams{
|
|
ID: staticSlotID(createdDay.ID, start),
|
|
ScheduleDayID: createdDay.ID,
|
|
StartTime: start,
|
|
EndTime: end,
|
|
Status: slotStatus,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
func addMinutes(hhmm string, minutes int64) string {
|
|
t, err := time.Parse("15:04", hhmm)
|
|
if err != nil {
|
|
return hhmm
|
|
}
|
|
return t.Add(time.Duration(minutes) * time.Minute).Format("15:04")
|
|
}
|
|
|
|
func defaultProject(id, categoryID, name, subtitle, priceText string, sort int64) db.CreateProjectParams {
|
|
return db.CreateProjectParams{
|
|
ID: id,
|
|
CategoryID: categoryID,
|
|
Name: name,
|
|
Subtitle: subtitle,
|
|
PriceText: priceText,
|
|
Status: "active",
|
|
Sort: sort,
|
|
}
|
|
}
|
|
|
|
func staticScheduleDayID(projectID, date string) string {
|
|
return "day_" + projectID + "_" + strings.ReplaceAll(date, "-", "")
|
|
}
|
|
|
|
func staticSlotID(scheduleDayID, startTime string) string {
|
|
return "slot_" + scheduleDayID + "_" + strings.ReplaceAll(startTime, ":", "")
|
|
}
|
|
|
|
func (s *Service) CategoriesWithProjects(ctx context.Context, admin bool) ([]CategoryWithProjects, error) {
|
|
var (
|
|
categories []db.ProjectCategory
|
|
projects []db.Project
|
|
err error
|
|
)
|
|
if admin {
|
|
categories, err = s.Q.ListCategories(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
projects, err = s.Q.ListProjects(ctx)
|
|
} else {
|
|
categories, err = s.Q.ListActiveCategories(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
projects, err = s.Q.ListActiveProjects(ctx)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
grouped := make(map[string][]db.Project)
|
|
for _, project := range projects {
|
|
grouped[project.CategoryID] = append(grouped[project.CategoryID], project)
|
|
}
|
|
result := make([]CategoryWithProjects, 0, len(categories))
|
|
for _, category := range categories {
|
|
result = append(result, CategoryWithProjects{
|
|
ProjectCategory: category,
|
|
Projects: grouped[category.ID],
|
|
})
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *Service) DeleteProject(ctx context.Context, projectID string) error {
|
|
projectID = strings.TrimSpace(projectID)
|
|
if projectID == "" {
|
|
return errors.New("项目 ID 不能为空")
|
|
}
|
|
tx, err := s.DB.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
qtx := s.Q.WithTx(tx)
|
|
if _, err := qtx.GetProject(ctx, projectID); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return errors.New("项目不存在,请刷新后重试")
|
|
}
|
|
return err
|
|
}
|
|
bookingCount, err := qtx.CountBookingsByProject(ctx, projectID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if bookingCount > 0 {
|
|
return errors.New("项目已有预约记录,请先下架项目以保留历史预约")
|
|
}
|
|
if err := qtx.DeleteTimeSlotsByProject(ctx, projectID); err != nil {
|
|
return err
|
|
}
|
|
if err := qtx.DeleteScheduleDaysByProject(ctx, projectID); err != nil {
|
|
return err
|
|
}
|
|
if err := qtx.DeleteProject(ctx, projectID); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
func (s *Service) ScheduleDays(ctx context.Context, projectID, month string) ([]ScheduleDayWithSlots, error) {
|
|
if strings.TrimSpace(projectID) == "" {
|
|
return nil, errors.New("projectId is required")
|
|
}
|
|
if month == "" {
|
|
month = time.Now().Format("2006-01")
|
|
}
|
|
days, err := s.Q.ListScheduleDaysByProjectMonth(ctx, db.ListScheduleDaysByProjectMonthParams{
|
|
ProjectID: projectID,
|
|
Month: sql.NullString{String: month, Valid: true},
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := make([]ScheduleDayWithSlots, 0, len(days))
|
|
for _, day := range days {
|
|
slots, err := s.Q.ListTimeSlotsByDay(ctx, day.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
slotViews := make([]TimeSlotView, 0, len(slots))
|
|
for _, slot := range slots {
|
|
slotViews = append(slotViews, toSlotView(slot))
|
|
}
|
|
result = append(result, ScheduleDayWithSlots{
|
|
Date: day.Date, Status: day.Status, Slots: slotViews,
|
|
})
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *Service) Login(ctx context.Context, code string) (SessionResult, error) {
|
|
code = strings.TrimSpace(code)
|
|
if code == "" {
|
|
code = "demo"
|
|
}
|
|
openid := "demo_openid_" + code
|
|
user, err := s.Q.GetUserByOpenID(ctx, openid)
|
|
if err == nil {
|
|
user, err = s.Q.TouchUserLogin(ctx, openid)
|
|
return SessionResult{User: user, SessionID: newID("session")}, err
|
|
}
|
|
if !errors.Is(err, sql.ErrNoRows) {
|
|
return SessionResult{}, err
|
|
}
|
|
user, err = s.Q.CreateUser(ctx, db.CreateUserParams{
|
|
ID: newID("user"), Openid: openid,
|
|
Unionid: sql.NullString{}, Phone: sql.NullString{},
|
|
})
|
|
return SessionResult{User: user, SessionID: newID("session")}, err
|
|
}
|
|
|
|
func (s *Service) BindPhone(ctx context.Context, openid, phone string) (db.User, error) {
|
|
phone = strings.TrimSpace(phone)
|
|
if phone == "" {
|
|
return db.User{}, errors.New("phone is required")
|
|
}
|
|
openid = strings.TrimSpace(openid)
|
|
if openid == "" {
|
|
openid = "demo_openid_demo"
|
|
}
|
|
|
|
tx, err := s.DB.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return db.User{}, err
|
|
}
|
|
defer tx.Rollback()
|
|
qtx := s.Q.WithTx(tx)
|
|
|
|
if _, err := tx.ExecContext(ctx, `
|
|
UPDATE users
|
|
SET phone = NULL, updated_at = CURRENT_TIMESTAMP
|
|
WHERE phone = ? AND openid <> ?`, phone, openid); err != nil {
|
|
return db.User{}, err
|
|
}
|
|
|
|
user, err := qtx.GetUserByOpenID(ctx, openid)
|
|
if err != nil {
|
|
if !errors.Is(err, sql.ErrNoRows) {
|
|
return db.User{}, err
|
|
}
|
|
user, err = qtx.CreateUser(ctx, db.CreateUserParams{
|
|
ID: newID("user"),
|
|
Openid: openid,
|
|
Unionid: sql.NullString{},
|
|
Phone: sql.NullString{String: phone, Valid: true},
|
|
})
|
|
if err != nil {
|
|
return db.User{}, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return db.User{}, err
|
|
}
|
|
return user, nil
|
|
}
|
|
user, err = qtx.BindUserPhone(ctx, db.BindUserPhoneParams{
|
|
ID: user.ID,
|
|
Phone: sql.NullString{String: phone, Valid: true},
|
|
})
|
|
if err != nil {
|
|
return db.User{}, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return db.User{}, err
|
|
}
|
|
return user, nil
|
|
}
|
|
|
|
func (s *Service) CreatePriceInquiry(ctx context.Context, req CreatePriceInquiryRequest) (db.PriceInquiry, error) {
|
|
province := strings.TrimSpace(req.Province)
|
|
city := strings.TrimSpace(req.City)
|
|
district := strings.TrimSpace(req.District)
|
|
projectName := strings.TrimSpace(req.ProjectName)
|
|
openid := strings.TrimSpace(req.OpenID)
|
|
if province == "" || city == "" || district == "" {
|
|
return db.PriceInquiry{}, errors.New("province, city and district are required")
|
|
}
|
|
if !isPriceInquiryProject(projectName) {
|
|
return db.PriceInquiry{}, errors.New("unsupported price inquiry project")
|
|
}
|
|
if openid == "" {
|
|
return db.PriceInquiry{}, errors.New("openid is required")
|
|
}
|
|
|
|
user, err := s.Q.GetUserByOpenID(ctx, openid)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return db.PriceInquiry{}, errors.New("phone authorization is required")
|
|
}
|
|
return db.PriceInquiry{}, err
|
|
}
|
|
phone := nullString(user.Phone)
|
|
if phone == "" {
|
|
return db.PriceInquiry{}, errors.New("phone authorization is required")
|
|
}
|
|
|
|
tx, err := s.DB.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return db.PriceInquiry{}, fmt.Errorf("begin price inquiry transaction: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
qtx := s.Q.WithTx(tx)
|
|
inquiry, err := qtx.UpsertPriceInquiry(ctx, db.UpsertPriceInquiryParams{
|
|
ID: newID("price_inquiry"),
|
|
UserID: sql.NullString{String: user.ID, Valid: true},
|
|
Phone: phone,
|
|
Province: province,
|
|
City: city,
|
|
District: district,
|
|
ProjectName: projectName,
|
|
Status: "pending",
|
|
})
|
|
if err != nil {
|
|
return db.PriceInquiry{}, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return db.PriceInquiry{}, fmt.Errorf("commit price inquiry transaction: %w", err)
|
|
}
|
|
return inquiry, nil
|
|
}
|
|
|
|
func isPriceInquiryProject(name string) bool {
|
|
for _, project := range priceInquiryProjects {
|
|
if name == project {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *Service) CreateBooking(ctx context.Context, req CreateBookingRequest) (BookingDetail, error) {
|
|
projectID := strings.TrimSpace(req.ProjectID)
|
|
phone := strings.TrimSpace(req.Phone)
|
|
if projectID == "" {
|
|
return BookingDetail{}, errors.New("projectId is required")
|
|
}
|
|
if phone == "" {
|
|
return BookingDetail{}, errors.New("phone is required")
|
|
}
|
|
req.ProjectID = projectID
|
|
req.Phone = phone
|
|
tx, err := s.DB.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return BookingDetail{}, err
|
|
}
|
|
defer tx.Rollback()
|
|
qtx := s.Q.WithTx(tx)
|
|
|
|
day, slot, err := ensureBookingSlot(ctx, qtx, req)
|
|
if err != nil {
|
|
return BookingDetail{}, err
|
|
}
|
|
userID := sql.NullString{}
|
|
if req.OpenID != "" {
|
|
if user, err := qtx.GetUserByOpenID(ctx, req.OpenID); err == nil {
|
|
userID = sql.NullString{String: user.ID, Valid: true}
|
|
}
|
|
}
|
|
booking, err := qtx.CreateBooking(ctx, db.CreateBookingParams{
|
|
ID: newID("booking"),
|
|
UserID: userID,
|
|
ProjectID: projectID,
|
|
SlotID: slot.ID,
|
|
Phone: phone,
|
|
Status: "pending",
|
|
Remark: "",
|
|
ConfirmToken: newID("confirm"),
|
|
})
|
|
if err != nil {
|
|
return BookingDetail{}, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return BookingDetail{}, err
|
|
}
|
|
project, err := s.Q.GetProject(ctx, projectID)
|
|
if err != nil {
|
|
return BookingDetail{}, err
|
|
}
|
|
return BookingDetail{
|
|
Booking: booking,
|
|
Project: project,
|
|
Day: day,
|
|
Slot: toSlotView(slot),
|
|
}, nil
|
|
}
|
|
|
|
func ensureBookingSlot(ctx context.Context, q *db.Queries, req CreateBookingRequest) (db.ScheduleDay, db.TimeSlot, error) {
|
|
projectID := strings.TrimSpace(req.ProjectID)
|
|
slotID := strings.TrimSpace(req.SlotID)
|
|
if slotID != "" {
|
|
slot, err := q.GetTimeSlot(ctx, slotID)
|
|
if err == nil {
|
|
day, err := q.GetScheduleDay(ctx, slot.ScheduleDayID)
|
|
if err != nil {
|
|
return db.ScheduleDay{}, db.TimeSlot{}, err
|
|
}
|
|
if day.ProjectID != projectID {
|
|
return db.ScheduleDay{}, db.TimeSlot{}, errors.New("slot does not belong to project")
|
|
}
|
|
return day, slot, nil
|
|
}
|
|
if !errors.Is(err, sql.ErrNoRows) {
|
|
return db.ScheduleDay{}, db.TimeSlot{}, err
|
|
}
|
|
}
|
|
|
|
date := strings.TrimSpace(req.Date)
|
|
startTime := strings.TrimSpace(req.StartTime)
|
|
if date == "" || startTime == "" {
|
|
return db.ScheduleDay{}, db.TimeSlot{}, errors.New("date and start_time are required")
|
|
}
|
|
|
|
dayID := staticScheduleDayID(projectID, date)
|
|
day, err := q.GetScheduleDay(ctx, dayID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
day, err = q.CreateScheduleDay(ctx, db.CreateScheduleDayParams{
|
|
ID: dayID,
|
|
ProjectID: projectID,
|
|
Date: date,
|
|
Status: "available",
|
|
Note: "",
|
|
})
|
|
}
|
|
if err != nil {
|
|
return db.ScheduleDay{}, db.TimeSlot{}, err
|
|
}
|
|
|
|
slotID = staticSlotID(day.ID, startTime)
|
|
slot, err := q.GetTimeSlot(ctx, slotID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
slot, err = q.CreateTimeSlot(ctx, db.CreateTimeSlotParams{
|
|
ID: slotID,
|
|
ScheduleDayID: day.ID,
|
|
StartTime: startTime,
|
|
EndTime: addMinutes(startTime, defaultSlotLengthMinutes),
|
|
Status: "available",
|
|
})
|
|
}
|
|
if err != nil {
|
|
return db.ScheduleDay{}, db.TimeSlot{}, err
|
|
}
|
|
return day, slot, nil
|
|
}
|
|
|
|
func (s *Service) BookingDetail(ctx context.Context, booking db.Booking) (BookingDetail, error) {
|
|
project, err := s.Q.GetProject(ctx, booking.ProjectID)
|
|
if err != nil {
|
|
return BookingDetail{}, err
|
|
}
|
|
slot, err := s.Q.GetTimeSlot(ctx, booking.SlotID)
|
|
if err != nil {
|
|
return BookingDetail{}, err
|
|
}
|
|
day, err := s.Q.GetScheduleDay(ctx, slot.ScheduleDayID)
|
|
if err != nil {
|
|
return BookingDetail{}, err
|
|
}
|
|
return BookingDetail{Booking: booking, Project: project, Day: day, Slot: toSlotView(slot)}, nil
|
|
}
|
|
|
|
func (s *Service) ListBookingDetailsByPhone(ctx context.Context, phone string) ([]BookingDetail, error) {
|
|
bookings, err := s.Q.ListBookingsByPhone(ctx, phone)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
details := make([]BookingDetail, 0, len(bookings))
|
|
for _, booking := range bookings {
|
|
detail, err := s.BookingDetail(ctx, booking)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
details = append(details, detail)
|
|
}
|
|
return details, nil
|
|
}
|
|
|
|
func (s *Service) CancelBooking(ctx context.Context, id, phone string) (BookingDetail, error) {
|
|
tx, err := s.DB.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return BookingDetail{}, err
|
|
}
|
|
defer tx.Rollback()
|
|
qtx := s.Q.WithTx(tx)
|
|
booking, err := qtx.CancelBooking(ctx, db.CancelBookingParams{ID: id, Phone: phone})
|
|
if err != nil {
|
|
return BookingDetail{}, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return BookingDetail{}, err
|
|
}
|
|
return s.BookingDetail(ctx, booking)
|
|
}
|
|
|
|
func (s *Service) AdminListBookingDetails(ctx context.Context, status string, limit, offset int64) ([]BookingDetail, error) {
|
|
if limit <= 0 || limit > 200 {
|
|
limit = 50
|
|
}
|
|
var (
|
|
bookings []db.Booking
|
|
err error
|
|
)
|
|
if status != "" {
|
|
bookings, err = s.Q.ListBookingsByStatus(ctx, db.ListBookingsByStatusParams{Status: status, Limit: limit, Offset: offset})
|
|
} else {
|
|
bookings, err = s.Q.ListBookings(ctx, db.ListBookingsParams{Limit: limit, Offset: offset})
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
details := make([]BookingDetail, 0, len(bookings))
|
|
for _, booking := range bookings {
|
|
detail, err := s.BookingDetail(ctx, booking)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
details = append(details, detail)
|
|
}
|
|
return details, nil
|
|
}
|
|
|
|
func (s *Service) CreateTimeSlot(ctx context.Context, req CreateTimeSlotRequest) (TimeSlotView, error) {
|
|
if strings.TrimSpace(req.ScheduleDayID) == "" || strings.TrimSpace(req.StartTime) == "" {
|
|
return TimeSlotView{}, errors.New("scheduleDayId and startTime are required")
|
|
}
|
|
if req.Status == "" {
|
|
req.Status = "available"
|
|
}
|
|
endTime, err := s.slotEndTime(ctx, req.ScheduleDayID, req.StartTime)
|
|
if err != nil {
|
|
return TimeSlotView{}, err
|
|
}
|
|
slotID := "slot_" + req.ScheduleDayID + "_" + strings.ReplaceAll(req.StartTime, ":", "")
|
|
slot, err := s.Q.CreateTimeSlot(ctx, db.CreateTimeSlotParams{
|
|
ID: slotID,
|
|
ScheduleDayID: req.ScheduleDayID,
|
|
StartTime: req.StartTime,
|
|
EndTime: endTime,
|
|
Status: req.Status,
|
|
})
|
|
if err != nil {
|
|
return TimeSlotView{}, err
|
|
}
|
|
return toSlotView(slot), nil
|
|
}
|
|
|
|
func (s *Service) UpdateTimeSlot(ctx context.Context, id string, req UpdateTimeSlotRequest) (TimeSlotView, error) {
|
|
if strings.TrimSpace(id) == "" {
|
|
return TimeSlotView{}, errors.New("slot id is required")
|
|
}
|
|
slot, err := s.Q.GetTimeSlot(ctx, id)
|
|
if err != nil {
|
|
return TimeSlotView{}, err
|
|
}
|
|
if req.StartTime == "" {
|
|
req.StartTime = slot.StartTime
|
|
}
|
|
if req.Status == "" {
|
|
req.Status = slot.Status
|
|
}
|
|
endTime, err := s.slotEndTime(ctx, slot.ScheduleDayID, req.StartTime)
|
|
if err != nil {
|
|
return TimeSlotView{}, err
|
|
}
|
|
updated, err := s.Q.UpdateTimeSlot(ctx, db.UpdateTimeSlotParams{
|
|
ID: id,
|
|
StartTime: req.StartTime,
|
|
EndTime: endTime,
|
|
Status: req.Status,
|
|
})
|
|
if err != nil {
|
|
return TimeSlotView{}, err
|
|
}
|
|
return toSlotView(updated), nil
|
|
}
|
|
|
|
func (s *Service) slotEndTime(ctx context.Context, scheduleDayID, startTime string) (string, error) {
|
|
if _, err := s.Q.GetScheduleDay(ctx, scheduleDayID); err != nil {
|
|
return "", err
|
|
}
|
|
return addMinutes(startTime, defaultSlotLengthMinutes), nil
|
|
}
|
|
|
|
func toSlotView(slot db.TimeSlot) TimeSlotView {
|
|
return TimeSlotView{
|
|
ID: slot.ID,
|
|
StartTime: slot.StartTime,
|
|
Status: slot.Status,
|
|
}
|
|
}
|