feat: add party-size queueing and call modes

This commit is contained in:
wangxuming
2026-07-16 11:40:34 +08:00
parent 7f751bebae
commit 66951b4dc3
53 changed files with 3348 additions and 533 deletions

View File

@@ -70,10 +70,14 @@ func (s *Server) createProject(w http.ResponseWriter, r *http.Request) {
project := model.Project{
ID: uuid.NewString(), Name: input.Name, Code: input.Code, Timezone: input.Timezone, TicketPrefix: input.TicketPrefix,
Status: model.ProjectNotOpen, CallBatchSize: 1, GracePeriodMinutes: 5, ETAMode: model.ETAFixedBatch,
CallMode: model.CallModeBoth, MaxCallTicketCount: 100,
DefaultCallPeopleCount: 1, MaxCallPeopleCount: 100,
MinPartySize: 1, MaxPartySize: 10,
AverageBatchIntervalSeconds: 300, ContinuousRatePerMinute: 1, ETABufferMinutes: 0,
ETAIntervalSeconds: 60,
VisitorNotice: model.DefaultVisitorNotice,
DeviceSimulationMode: "DISABLED", CreatedAt: now, UpdatedAt: now,
ETAIntervalSeconds: 60,
ExperiencedPeopleStart: 0,
VisitorNotice: model.DefaultVisitorNotice,
DeviceSimulationMode: "DISABLED", CreatedAt: now, UpdatedAt: now,
}
err = s.db.WithContext(r.Context()).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&project).Error; err != nil {
@@ -153,6 +157,9 @@ func (s *Server) adminUsers(w http.ResponseWriter, r *http.Request) {
}
views := make([]map[string]any, 0, len(users))
for _, user := range users {
if user.Username == model.PublicVisitorUsername {
continue
}
views = append(views, adminUserView(user, projects[user.ID]))
}
writeJSON(w, http.StatusOK, map[string]any{"users": views})
@@ -256,6 +263,9 @@ func (s *Server) updateAdminUser(w http.ResponseWriter, r *http.Request) {
if user.Username == model.SuperAdminUsername {
return &apiError{Status: 403, Code: "PROTECTED_SUPER_ADMIN", Message: "超级管理员账号不能编辑"}
}
if user.Username == model.PublicVisitorUsername {
return &apiError{Status: 403, Code: "PROTECTED_SYSTEM_USER", Message: "系统游客账号不能编辑"}
}
nextRole := user.Role
if input.Role != "" {
nextRole = input.Role
@@ -318,16 +328,20 @@ func (s *Server) adminOverview(w http.ResponseWriter, r *http.Request) {
type totalRow struct {
Projects int64
ActiveSessions int64
Waiting int64
Called int64
WaitingTickets int64
WaitingPeople int64
CalledTickets int64
CalledPeople int64
}
var totals totalRow
if err := s.db.WithContext(r.Context()).Raw(`
SELECT
(SELECT count(*) FROM projects) AS projects,
(SELECT count(*) FROM queue_sessions WHERE status IN ('RUNNING','PAUSED')) AS active_sessions,
(SELECT count(*) FROM queue_tickets WHERE status = 'WAITING') AS waiting,
(SELECT count(*) FROM queue_tickets WHERE status IN ('CALLED','ARRIVED')) AS called
(SELECT count(*) FROM queue_tickets WHERE status = 'WAITING') AS waiting_tickets,
(SELECT COALESCE(sum(party_size), 0) FROM queue_tickets WHERE status = 'WAITING') AS waiting_people,
(SELECT count(*) FROM queue_tickets WHERE status IN ('CALLED','ARRIVED')) AS called_tickets,
(SELECT COALESCE(sum(party_size), 0) FROM queue_tickets WHERE status IN ('CALLED','ARRIVED')) AS called_people
`).Scan(&totals).Error; err != nil {
writeError(w, err)
return
@@ -363,15 +377,16 @@ func (s *Server) adminOverview(w http.ResponseWriter, r *http.Request) {
activeTicketViews = append(activeTicketViews, view)
}
projectProjections := make([]map[string]any, 0, len(projects))
var runningProjects, anomalyProjects, offlineDevices, projectedWaiting int64
var runningProjects, anomalyProjects, offlineDevices, projectedWaitingTickets, projectedWaitingPeople int64
for _, project := range projects {
projection, anomaly, offline, waiting, err := s.adminProjectProjection(r.Context(), project)
projection, anomaly, offline, waitingTickets, waitingPeople, err := s.adminProjectProjection(r.Context(), project)
if err != nil {
writeError(w, err)
return
}
projectProjections = append(projectProjections, projection)
projectedWaiting += waiting
projectedWaitingTickets += waitingTickets
projectedWaitingPeople += waitingPeople
if project.Status == model.ProjectRunning {
runningProjects++
}
@@ -384,12 +399,15 @@ func (s *Server) adminOverview(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, map[string]any{
"summary": map[string]int64{
"running_projects": runningProjects, "waiting_count": projectedWaiting,
"running_projects": runningProjects, "waiting_count": projectedWaitingTickets,
"waiting_ticket_count": projectedWaitingTickets, "waiting_people_count": projectedWaitingPeople,
"anomaly_projects": anomalyProjects, "offline_devices": offlineDevices,
},
"totals": map[string]int64{
"projects": totals.Projects, "active_sessions": totals.ActiveSessions,
"waiting": totals.Waiting, "called": totals.Called,
"waiting": totals.WaitingTickets, "called": totals.CalledTickets,
"waiting_ticket_count": totals.WaitingTickets, "waiting_people_count": totals.WaitingPeople,
"called_ticket_count": totals.CalledTickets, "called_people_count": totals.CalledPeople,
},
"projects": projectProjections, "active_tickets": activeTicketViews,
"recent_device_simulations": simulations, "server_time": s.now(),
@@ -404,11 +422,11 @@ func (s *Server) adminActiveTicketView(ticket model.QueueTicket, projectName str
return map[string]any{
"id": ticket.ID, "project_id": ticket.ProjectID, "project_name": projectName,
"ticket_number": ticket.DisplayNumber, "phone": phone, "last_name": lastName,
"honorific": ticket.Honorific, "status": ticket.Status, "created_at": ticket.CreatedAt,
"honorific": ticket.Honorific, "party_size": ticket.PartySize, "status": ticket.Status, "created_at": ticket.CreatedAt,
}, nil
}
func (s *Server) adminProjectProjection(ctx context.Context, project model.Project) (map[string]any, bool, bool, int64, error) {
func (s *Server) adminProjectProjection(ctx context.Context, project model.Project) (map[string]any, bool, bool, int64, int64, error) {
view := projectView(project)
lastUpdated := project.UpdatedAt
deviceStatus := map[string]any{
@@ -432,7 +450,7 @@ func (s *Server) adminProjectProjection(ctx context.Context, project model.Proje
anomaly = true
}
} else if !errors.Is(simulationErr, gorm.ErrRecordNotFound) {
return nil, false, false, 0, simulationErr
return nil, false, false, 0, 0, simulationErr
}
if project.DeviceSimulationMode == "DISABLED" {
deviceStatus = map[string]any{"mode": "DISABLED", "status": "DISABLED", "label": "设备模拟器已停用"}
@@ -442,48 +460,91 @@ func (s *Server) adminProjectProjection(ctx context.Context, project model.Proje
sessionErr := s.db.WithContext(ctx).Where("project_id = ? AND status IN ?", project.ID, []string{"RUNNING", "PAUSED"}).
Order("business_date DESC").First(&session).Error
if errors.Is(sessionErr, gorm.ErrRecordNotFound) {
experiencedPeople, err := s.displayedExperiencedPeople(ctx, project, nil)
if err != nil {
return nil, false, false, 0, 0, err
}
view["waiting_count"] = int64(0)
view["waiting_ticket_count"] = int64(0)
view["waiting_people_count"] = int64(0)
view["issued_ticket_count"] = int64(0)
view["latest_ticket_number"] = nil
view["experienced_people"] = experiencedPeople
view["current_batch"] = nil
view["estimated_wait"] = domain.ETAResult{Available: false, Reason: "queue_not_running"}
view["last_updated_at"] = lastUpdated
view["device_status"] = deviceStatus
return view, anomaly, offline, 0, nil
return view, anomaly, offline, 0, 0, nil
}
if sessionErr != nil {
return nil, false, false, 0, sessionErr
return nil, false, false, 0, 0, sessionErr
}
if session.UpdatedAt.After(lastUpdated) {
lastUpdated = session.UpdatedAt
}
var waitingCount int64
if err := s.db.WithContext(ctx).Model(&model.QueueTicket{}).
Where("project_id = ? AND queue_session_id = ? AND status = ?", project.ID, session.ID, model.TicketWaiting).
Count(&waitingCount).Error; err != nil {
return nil, false, false, 0, err
waiting, err := queueTotals(s.db.WithContext(ctx), project.ID, session.ID, model.TicketWaiting)
if err != nil {
return nil, false, false, 0, 0, err
}
currentBatch, err := s.currentDisplayBatch(ctx, project.ID, session.ID)
if err != nil {
return nil, false, false, 0, err
return nil, false, false, 0, 0, err
}
var lastWaiting model.QueueTicket
lastWaitingErr := s.db.WithContext(ctx).Select("id", "party_size").
Where("project_id = ? AND queue_session_id = ? AND status = ?", project.ID, session.ID, model.TicketWaiting).
Order("ticket_number DESC").First(&lastWaiting).Error
if lastWaitingErr != nil && !errors.Is(lastWaitingErr, gorm.ErrRecordNotFound) {
return nil, false, false, 0, 0, lastWaitingErr
}
peopleAhead := int(waiting.PeopleCount)
if lastWaitingErr == nil {
peopleAhead = max(0, peopleAhead-lastWaiting.PartySize)
}
estimatedWait, err := domain.CalculateETA(domain.ETAInput{
PeopleAhead: max(0, int(waitingCount)-1), IntervalPerNumber: time.Duration(project.ETAIntervalSeconds) * time.Second,
Running: project.Status == model.ProjectRunning && session.Status == "RUNNING" && waitingCount > 0,
PeopleAhead: peopleAhead, IntervalPerPerson: time.Duration(project.ETAIntervalSeconds) * time.Second,
Running: project.Status == model.ProjectRunning && session.Status == "RUNNING" && waiting.TicketCount > 0,
})
if err != nil {
return nil, false, false, 0, err
return nil, false, false, 0, 0, err
}
view["waiting_count"] = waitingCount
experiencedPeople, err := s.displayedExperiencedPeople(ctx, project, &session)
if err != nil {
return nil, false, false, 0, 0, err
}
issuedTicketCount := max(0, session.NextTicketNumber-1)
var latestTicketNumber any
if issuedTicketCount > 0 {
latestTicketNumber, err = domain.DisplayNumber("", issuedTicketCount)
if err != nil {
return nil, false, false, 0, 0, err
}
}
view["waiting_count"] = waiting.TicketCount
view["waiting_ticket_count"] = waiting.TicketCount
view["waiting_people_count"] = waiting.PeopleCount
view["issued_ticket_count"] = issuedTicketCount
view["latest_ticket_number"] = latestTicketNumber
view["experienced_people"] = experiencedPeople
view["current_batch"] = currentBatch
view["estimated_wait"] = estimatedWait
view["last_updated_at"] = lastUpdated
view["device_status"] = deviceStatus
return view, anomaly, offline, waitingCount, nil
return view, anomaly, offline, waiting.TicketCount, waiting.PeopleCount, nil
}
type updateProjectSettingsRequest struct {
Status *string `json:"status"`
CallBatchSize *int `json:"call_batch_size"`
DefaultCallTicketCount *int `json:"default_call_ticket_count"`
MaxCallTicketCount *int `json:"max_call_ticket_count"`
DefaultCallPeopleCount *int `json:"default_call_people_count"`
MaxCallPeopleCount *int `json:"max_call_people_count"`
CallMode *string `json:"call_mode"`
MinPartySize *int `json:"min_party_size"`
MaxPartySize *int `json:"max_party_size"`
GracePeriodMinutes *int `json:"grace_period_minutes"`
ExperiencedPeopleStart *int `json:"experienced_people_start"`
ETAMode *string `json:"eta_mode"`
AverageBatchIntervalSeconds *int `json:"average_batch_interval_seconds"`
ContinuousRatePerMinute *float64 `json:"continuous_rate_per_minute"`
@@ -516,6 +577,9 @@ func (s *Server) updateProjectSettings(w http.ResponseWriter, r *http.Request) {
return mapNotFound(err, "PROJECT_NOT_FOUND", "项目不存在")
}
before := projectView(project)
if err := validateProjectSettingsCombination(project, updates); err != nil {
return err
}
updates["updated_at"] = s.now()
if err := tx.Model(&model.Project{}).Where("id = ?", projectID).Updates(updates).Error; err != nil {
return err
@@ -542,11 +606,54 @@ func validateProjectSettings(input updateProjectSettingsRequest) (map[string]any
}
updates["status"] = value
}
if input.CallBatchSize != nil {
if *input.CallBatchSize < 1 || *input.CallBatchSize > 100 {
return nil, invalidSetting("call_batch_size")
ticketDefault := input.DefaultCallTicketCount
if ticketDefault == nil {
ticketDefault = input.CallBatchSize
} else if input.CallBatchSize != nil && *input.CallBatchSize != *ticketDefault {
return nil, invalidSetting("default_call_ticket_count")
}
if ticketDefault != nil {
if *ticketDefault < 1 || *ticketDefault > 10000 {
return nil, invalidSetting("default_call_ticket_count")
}
updates["call_batch_size"] = *input.CallBatchSize
updates["call_batch_size"] = *ticketDefault
}
if input.MaxCallTicketCount != nil {
if *input.MaxCallTicketCount < 1 || *input.MaxCallTicketCount > 10000 {
return nil, invalidSetting("max_call_ticket_count")
}
updates["max_call_ticket_count"] = *input.MaxCallTicketCount
}
if input.DefaultCallPeopleCount != nil {
if *input.DefaultCallPeopleCount < 1 || *input.DefaultCallPeopleCount > 10000 {
return nil, invalidSetting("default_call_people_count")
}
updates["default_call_people_count"] = *input.DefaultCallPeopleCount
}
if input.MaxCallPeopleCount != nil {
if *input.MaxCallPeopleCount < 1 || *input.MaxCallPeopleCount > 10000 {
return nil, invalidSetting("max_call_people_count")
}
updates["max_call_people_count"] = *input.MaxCallPeopleCount
}
if input.CallMode != nil {
value := strings.ToUpper(strings.TrimSpace(*input.CallMode))
if value != model.CallModeTicket && value != model.CallModePeople && value != model.CallModeBoth {
return nil, invalidSetting("call_mode")
}
updates["call_mode"] = value
}
if input.MinPartySize != nil {
if *input.MinPartySize < 1 || *input.MinPartySize > 10000 {
return nil, invalidSetting("min_party_size")
}
updates["min_party_size"] = *input.MinPartySize
}
if input.MaxPartySize != nil {
if *input.MaxPartySize < 1 || *input.MaxPartySize > 10000 {
return nil, invalidSetting("max_party_size")
}
updates["max_party_size"] = *input.MaxPartySize
}
if input.GracePeriodMinutes != nil {
if *input.GracePeriodMinutes < 0 || *input.GracePeriodMinutes > 120 {
@@ -554,6 +661,12 @@ func validateProjectSettings(input updateProjectSettingsRequest) (map[string]any
}
updates["grace_period_minutes"] = *input.GracePeriodMinutes
}
if input.ExperiencedPeopleStart != nil {
if *input.ExperiencedPeopleStart < 0 || *input.ExperiencedPeopleStart > 1000000000 {
return nil, invalidSetting("experienced_people_start")
}
updates["experienced_people_start"] = *input.ExperiencedPeopleStart
}
if input.ETAMode != nil {
value := strings.ToUpper(strings.TrimSpace(*input.ETAMode))
if value != model.ETAFixedBatch && value != model.ETAContinuous {
@@ -605,6 +718,43 @@ func validateProjectSettings(input updateProjectSettingsRequest) (map[string]any
return updates, nil
}
func validateProjectSettingsCombination(project model.Project, updates map[string]any) error {
ticketDefault := project.CallBatchSize
maxTicketCount := project.MaxCallTicketCount
peopleDefault := project.DefaultCallPeopleCount
maxPeopleCount := project.MaxCallPeopleCount
minPartySize := project.MinPartySize
maxPartySize := project.MaxPartySize
if value, ok := updates["call_batch_size"].(int); ok {
ticketDefault = value
}
if value, ok := updates["max_call_ticket_count"].(int); ok {
maxTicketCount = value
}
if value, ok := updates["default_call_people_count"].(int); ok {
peopleDefault = value
}
if value, ok := updates["max_call_people_count"].(int); ok {
maxPeopleCount = value
}
if value, ok := updates["min_party_size"].(int); ok {
minPartySize = value
}
if value, ok := updates["max_party_size"].(int); ok {
maxPartySize = value
}
if ticketDefault > maxTicketCount {
return invalidSetting("default_call_ticket_count")
}
if peopleDefault > maxPeopleCount {
return invalidSetting("default_call_people_count")
}
if minPartySize > maxPartySize {
return invalidSetting("min_party_size")
}
return nil
}
func invalidSetting(field string) error {
return &apiError{Status: http.StatusUnprocessableEntity, Code: "INVALID_SETTING", Message: "项目设置值不正确", Details: map[string]string{"field": field}}
}