feat: add party-size queueing and call modes
This commit is contained in:
@@ -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}}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user