942 lines
38 KiB
Go
942 lines
38 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/csv"
|
|
"fmt"
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"calllinesystem/server/internal/model"
|
|
"calllinesystem/server/internal/security"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const historyDateLayout = "2006-01-02"
|
|
|
|
var historyTicketStatuses = map[string]bool{
|
|
model.TicketWaiting: true, model.TicketCalled: true, model.TicketArrived: true,
|
|
model.TicketCompleted: true, model.TicketMissed: true, model.TicketCanceled: true,
|
|
}
|
|
|
|
var historyBatchStatuses = map[string]bool{"CALLED": true, "COMPLETED": true}
|
|
|
|
type historyFilter struct {
|
|
ProjectID string
|
|
From string
|
|
To string
|
|
Status string
|
|
Query string
|
|
Page int
|
|
PageSize int
|
|
}
|
|
|
|
type historyTicketRow struct {
|
|
ID string `gorm:"column:id"`
|
|
ProjectID string `gorm:"column:project_id"`
|
|
ProjectName string `gorm:"column:project_name"`
|
|
ProjectCode string `gorm:"column:project_code"`
|
|
QueueSessionID string `gorm:"column:queue_session_id"`
|
|
BusinessDate time.Time `gorm:"column:business_date"`
|
|
TicketNumber int `gorm:"column:ticket_number"`
|
|
DisplayNumber string `gorm:"column:display_number"`
|
|
PartySize int `gorm:"column:party_size"`
|
|
Status string `gorm:"column:status"`
|
|
JoinedAt time.Time `gorm:"column:joined_at"`
|
|
CalledAt *time.Time `gorm:"column:called_at"`
|
|
ArrivedAt *time.Time `gorm:"column:arrived_at"`
|
|
CompletedAt *time.Time `gorm:"column:completed_at"`
|
|
MissedAt *time.Time `gorm:"column:missed_at"`
|
|
UpdatedAt *time.Time `gorm:"column:updated_at"`
|
|
PersonalDataPurgedAt *time.Time `gorm:"column:personal_data_purged_at"`
|
|
HistoryAnonymizedAt *time.Time `gorm:"column:history_anonymized_at"`
|
|
PhoneCiphertext []byte `gorm:"column:phone_ciphertext"`
|
|
PhoneNonce []byte `gorm:"column:phone_nonce"`
|
|
PhoneHMAC *string `gorm:"column:phone_hmac"`
|
|
LastNameCiphertext []byte `gorm:"column:last_name_ciphertext"`
|
|
LastNameNonce []byte `gorm:"column:last_name_nonce"`
|
|
Honorific string `gorm:"column:honorific"`
|
|
CreatedBy string `gorm:"column:created_by"`
|
|
CreatedByName string `gorm:"column:created_by_name"`
|
|
BatchID *string `gorm:"column:batch_id"`
|
|
BatchSequence *int `gorm:"column:batch_sequence"`
|
|
BatchPosition *int `gorm:"column:batch_position"`
|
|
}
|
|
|
|
type historyBatchRow struct {
|
|
ID string `gorm:"column:id"`
|
|
ProjectID string `gorm:"column:project_id"`
|
|
ProjectName string `gorm:"column:project_name"`
|
|
ProjectCode string `gorm:"column:project_code"`
|
|
QueueSessionID string `gorm:"column:queue_session_id"`
|
|
BusinessDate time.Time `gorm:"column:business_date"`
|
|
BatchSequence int `gorm:"column:batch_sequence"`
|
|
Revision int64 `gorm:"column:revision"`
|
|
Status string `gorm:"column:status"`
|
|
CallMode string `gorm:"column:call_mode"`
|
|
RequestedCount int `gorm:"column:requested_count"`
|
|
TicketCount int `gorm:"column:ticket_count"`
|
|
PeopleCount int `gorm:"column:people_count"`
|
|
RequestedBy string `gorm:"column:requested_by"`
|
|
RequestedByName string `gorm:"column:requested_by_name"`
|
|
CalledAt time.Time `gorm:"column:called_at"`
|
|
CompletedAt *time.Time `gorm:"column:completed_at"`
|
|
}
|
|
|
|
type historyBatchTicketLabel struct {
|
|
BatchID string `gorm:"column:batch_id"`
|
|
DisplayNumber string `gorm:"column:display_number"`
|
|
Position int `gorm:"column:position"`
|
|
}
|
|
|
|
type historySummaryRow struct {
|
|
TotalTickets int64 `gorm:"column:total_tickets"`
|
|
TotalPeople int64 `gorm:"column:total_people"`
|
|
CalledTickets int64 `gorm:"column:called_tickets"`
|
|
CalledPeople int64 `gorm:"column:called_people"`
|
|
CompletedTickets int64 `gorm:"column:completed_tickets"`
|
|
CompletedPeople int64 `gorm:"column:completed_people"`
|
|
MissedTickets int64 `gorm:"column:missed_tickets"`
|
|
CanceledTickets int64 `gorm:"column:canceled_tickets"`
|
|
AverageWait *float64 `gorm:"column:average_wait"`
|
|
MaxWait *float64 `gorm:"column:max_wait"`
|
|
}
|
|
|
|
type historyHourlyRow struct {
|
|
Hour int64 `gorm:"column:hour"`
|
|
IssuedTickets int64 `gorm:"column:issued_tickets"`
|
|
IssuedPeople int64 `gorm:"column:issued_people"`
|
|
CalledTickets int64 `gorm:"column:called_tickets"`
|
|
CalledPeople int64 `gorm:"column:called_people"`
|
|
}
|
|
|
|
type historyDailyRow struct {
|
|
BusinessDate time.Time `gorm:"column:business_date"`
|
|
TotalTickets int64 `gorm:"column:total_tickets"`
|
|
TotalPeople int64 `gorm:"column:total_people"`
|
|
CalledTickets int64 `gorm:"column:called_tickets"`
|
|
CompletedTickets int64 `gorm:"column:completed_tickets"`
|
|
MissedTickets int64 `gorm:"column:missed_tickets"`
|
|
CanceledTickets int64 `gorm:"column:canceled_tickets"`
|
|
AverageWait *float64 `gorm:"column:average_wait"`
|
|
MaxWait *float64 `gorm:"column:max_wait"`
|
|
}
|
|
|
|
type historyProjectRow struct {
|
|
ProjectID string `gorm:"column:project_id"`
|
|
ProjectName string `gorm:"column:project_name"`
|
|
ProjectCode string `gorm:"column:project_code"`
|
|
TotalTickets int64 `gorm:"column:total_tickets"`
|
|
TotalPeople int64 `gorm:"column:total_people"`
|
|
CalledTickets int64 `gorm:"column:called_tickets"`
|
|
CompletedTickets int64 `gorm:"column:completed_tickets"`
|
|
MissedTickets int64 `gorm:"column:missed_tickets"`
|
|
CanceledTickets int64 `gorm:"column:canceled_tickets"`
|
|
AverageWait *float64 `gorm:"column:average_wait"`
|
|
MaxWait *float64 `gorm:"column:max_wait"`
|
|
}
|
|
|
|
func (s *Server) adminHistoryTickets(w http.ResponseWriter, r *http.Request) {
|
|
filter, err := s.parseHistoryFilter(r, historyTicketStatuses)
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
if err := s.auditHistoryPhoneSearch(r, filter); err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
|
|
base := s.historyTicketQuery(r.Context())
|
|
base = applyHistoryTicketFilter(base, filter, s)
|
|
var total int64
|
|
if err := base.Select("COUNT(ticket.id)").Scan(&total).Error; err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
|
|
var rows []historyTicketRow
|
|
query := s.historyTicketQuery(r.Context())
|
|
query = applyHistoryTicketFilter(query, filter, s)
|
|
if err := query.Select(historyTicketSelect()).Order("session.business_date DESC, ticket.ticket_number DESC").
|
|
Offset((filter.Page - 1) * filter.PageSize).Limit(filter.PageSize).Scan(&rows).Error; err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
items, err := s.historyTicketViews(rows, false)
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"items": items, "page": filter.Page, "page_size": filter.PageSize, "total": total,
|
|
"from": filter.From, "to": filter.To,
|
|
})
|
|
}
|
|
|
|
func (s *Server) adminHistoryTicket(w http.ResponseWriter, r *http.Request) {
|
|
ticketID := r.PathValue("id")
|
|
if err := validateUUID(ticketID); err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
var row historyTicketRow
|
|
query := s.historyTicketQuery(r.Context()).Where("ticket.id = ?", ticketID)
|
|
if err := query.Select(historyTicketSelect()).First(&row).Error; err != nil {
|
|
writeError(w, mapNotFound(err, "TICKET_NOT_FOUND", "历史号码不存在"))
|
|
return
|
|
}
|
|
if err := s.authorizeProject(r.Context(), row.ProjectID); err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
reveal := parseBoolQuery(r.URL.Query().Get("reveal"))
|
|
if err := s.auditHistoryAccess(r, "HISTORY_TICKET_VIEW", "QUEUE_TICKET", &row.ID, &row.ProjectID, map[string]any{"reveal_personal": reveal}); err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
view, err := s.historyTicketView(row, reveal)
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"ticket": view})
|
|
}
|
|
|
|
func (s *Server) adminHistoryBatches(w http.ResponseWriter, r *http.Request) {
|
|
filter, err := s.parseHistoryFilter(r, historyBatchStatuses)
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
base := s.historyBatchQuery(r.Context())
|
|
base = applyHistoryBatchFilter(base, filter)
|
|
var total int64
|
|
if err := base.Select("COUNT(batch.id)").Scan(&total).Error; err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
var rows []historyBatchRow
|
|
query := s.historyBatchQuery(r.Context())
|
|
query = applyHistoryBatchFilter(query, filter)
|
|
if err := query.Select(historyBatchSelect()).Order("session.business_date DESC, batch.batch_sequence DESC").
|
|
Offset((filter.Page - 1) * filter.PageSize).Limit(filter.PageSize).Scan(&rows).Error; err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
labels, err := s.historyBatchLabels(r.Context(), rows)
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
items := make([]map[string]any, 0, len(rows))
|
|
for _, row := range rows {
|
|
items = append(items, historyBatchView(row, labels[row.ID]))
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"items": items, "page": filter.Page, "page_size": filter.PageSize, "total": total,
|
|
"from": filter.From, "to": filter.To,
|
|
})
|
|
}
|
|
|
|
func (s *Server) adminHistoryBatch(w http.ResponseWriter, r *http.Request) {
|
|
batchID := r.PathValue("id")
|
|
if err := validateUUID(batchID); err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
var batch historyBatchRow
|
|
if err := s.historyBatchQuery(r.Context()).Where("batch.id = ?", batchID).Select(historyBatchSelect()).First(&batch).Error; err != nil {
|
|
writeError(w, mapNotFound(err, "BATCH_NOT_FOUND", "历史叫号记录不存在"))
|
|
return
|
|
}
|
|
if err := s.authorizeProject(r.Context(), batch.ProjectID); err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
var rows []historyTicketRow
|
|
query := s.historyTicketQuery(r.Context()).
|
|
Where("link.call_batch_id = ?", batchID).
|
|
Select(historyTicketSelect()).
|
|
Order("link.position ASC")
|
|
if err := query.Scan(&rows).Error; err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
items, err := s.historyTicketViews(rows, false)
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
if err := s.auditHistoryAccess(r, "HISTORY_BATCH_VIEW", "CALL_BATCH", &batch.ID, &batch.ProjectID, nil); err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
view := historyBatchView(batch, nil)
|
|
view["tickets"] = items
|
|
writeJSON(w, http.StatusOK, map[string]any{"batch": view})
|
|
}
|
|
|
|
func (s *Server) adminHistorySummary(w http.ResponseWriter, r *http.Request) {
|
|
filter, err := s.parseHistoryFilter(r, nil)
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
base := s.historyTicketQuery(r.Context())
|
|
base = applyHistoryTicketFilter(base, filter, s)
|
|
var summary historySummaryRow
|
|
if err := base.Select(`
|
|
COUNT(ticket.id) AS total_tickets,
|
|
COALESCE(SUM(ticket.party_size), 0) AS total_people,
|
|
COUNT(ticket.id) FILTER (WHERE ticket.status IN ('CALLED', 'ARRIVED', 'COMPLETED', 'MISSED')) AS called_tickets,
|
|
COALESCE(SUM(ticket.party_size) FILTER (WHERE ticket.status IN ('CALLED', 'ARRIVED', 'COMPLETED', 'MISSED')), 0) AS called_people,
|
|
COUNT(ticket.id) FILTER (WHERE ticket.status = 'COMPLETED') AS completed_tickets,
|
|
COALESCE(SUM(ticket.party_size) FILTER (WHERE ticket.status = 'COMPLETED'), 0) AS completed_people,
|
|
COUNT(ticket.id) FILTER (WHERE ticket.status = 'MISSED') AS missed_tickets,
|
|
COUNT(ticket.id) FILTER (WHERE ticket.status = 'CANCELED') AS canceled_tickets,
|
|
AVG(EXTRACT(EPOCH FROM (ticket.called_at - ticket.joined_at))) FILTER (WHERE ticket.called_at IS NOT NULL) AS average_wait,
|
|
MAX(EXTRACT(EPOCH FROM (ticket.called_at - ticket.joined_at))) FILTER (WHERE ticket.called_at IS NOT NULL) AS max_wait`).Scan(&summary).Error; err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
hourly, err := s.historyHourlySummary(r.Context(), filter)
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
daily, err := s.historyDailySummary(r.Context(), filter)
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
projects, err := s.historyProjectSummary(r.Context(), filter)
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
completionRate, missedRate, canceledRate := 0.0, 0.0, 0.0
|
|
if summary.TotalTickets > 0 {
|
|
completionRate = float64(summary.CompletedTickets) / float64(summary.TotalTickets)
|
|
missedRate = float64(summary.MissedTickets) / float64(summary.TotalTickets)
|
|
canceledRate = float64(summary.CanceledTickets) / float64(summary.TotalTickets)
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"from": filter.From, "to": filter.To,
|
|
"summary": map[string]any{
|
|
"total_tickets": summary.TotalTickets, "total_people": summary.TotalPeople,
|
|
"called_tickets": summary.CalledTickets, "called_people": summary.CalledPeople,
|
|
"completed_tickets": summary.CompletedTickets, "completed_people": summary.CompletedPeople,
|
|
"missed_tickets": summary.MissedTickets, "canceled_tickets": summary.CanceledTickets,
|
|
"completion_rate": completionRate, "missed_rate": missedRate, "canceled_rate": canceledRate,
|
|
"average_wait_seconds": nullableRoundedSeconds(summary.AverageWait),
|
|
"max_wait_seconds": nullableRoundedSeconds(summary.MaxWait),
|
|
},
|
|
"hourly": historyHourlyViews(hourly),
|
|
"peak_hours": historyPeakHourViews(hourly),
|
|
"daily": historyDailyViews(daily),
|
|
"projects": historyProjectViews(projects),
|
|
"alerts": historySummaryAlerts(projects),
|
|
})
|
|
}
|
|
|
|
func (s *Server) adminHistoryExport(w http.ResponseWriter, r *http.Request) {
|
|
filter, err := s.parseHistoryFilter(r, historyTicketStatuses)
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
includePersonal := parseBoolQuery(r.URL.Query().Get("include_personal"))
|
|
if includePersonal && !parseBoolQuery(r.URL.Query().Get("confirm")) {
|
|
writeError(w, &apiError{Status: http.StatusUnprocessableEntity, Code: "EXPORT_CONFIRMATION_REQUIRED", Message: "导出个人信息前需要二次确认"})
|
|
return
|
|
}
|
|
base := s.historyTicketQuery(r.Context())
|
|
base = applyHistoryTicketFilter(base, filter, s)
|
|
var total int64
|
|
if err := base.Select("COUNT(ticket.id)").Scan(&total).Error; err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
if total > 10000 {
|
|
writeError(w, &apiError{Status: http.StatusUnprocessableEntity, Code: "EXPORT_TOO_LARGE", Message: "导出结果超过 10000 行,请缩小日期范围或增加筛选条件", Details: map[string]any{"total": total, "max_rows": 10000}})
|
|
return
|
|
}
|
|
var rows []historyTicketRow
|
|
query := s.historyTicketQuery(r.Context())
|
|
query = applyHistoryTicketFilter(query, filter, s)
|
|
if err := query.Select(historyTicketSelect()).Order("session.business_date ASC, ticket.ticket_number ASC").Limit(10000).Scan(&rows).Error; err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
if err := s.auditHistoryAccess(r, "HISTORY_EXPORT", "HISTORY_QUERY", nil, historyProjectIDPointer(filter.ProjectID), map[string]any{
|
|
"from": filter.From, "to": filter.To, "status": filter.Status, "query_kind": historyQueryKind(filter.Query), "rows": len(rows), "include_personal": includePersonal,
|
|
}); err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=history-tickets-%s-%s.csv", filter.From, filter.To))
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte{0xEF, 0xBB, 0xBF})
|
|
csvWriter := csv.NewWriter(w)
|
|
_ = csvWriter.Write([]string{"项目", "营业日", "号码", "同行人数", "状态", "取号时间", "叫号时间", "终态时间", "等待时长(秒)", "手机号", "姓氏", "称谓", "数据状态"})
|
|
for _, row := range rows {
|
|
view, err := s.historyTicketView(row, includePersonal)
|
|
if err != nil {
|
|
return
|
|
}
|
|
_ = csvWriter.Write([]string{
|
|
stringValue(view["project_name"]), stringValue(view["business_date"]), stringValue(view["ticket_number"]),
|
|
stringValue(view["party_size"]), stringValue(view["status"]), stringValue(view["joined_at"]),
|
|
stringValue(view["called_at"]), stringValue(view["terminal_at"]), stringValue(view["wait_seconds"]),
|
|
stringValue(view["phone"]), stringValue(view["last_name"]), stringValue(view["honorific"]), stringValue(view["personal_data_status"]),
|
|
})
|
|
}
|
|
csvWriter.Flush()
|
|
}
|
|
|
|
func (s *Server) parseHistoryFilter(r *http.Request, allowedStatuses map[string]bool) (historyFilter, error) {
|
|
query := r.URL.Query()
|
|
filter := historyFilter{From: query.Get("from"), To: query.Get("to"), Status: strings.ToUpper(strings.TrimSpace(query.Get("status"))), Query: strings.TrimSpace(query.Get("query")), Page: 1, PageSize: 20}
|
|
if filter.ProjectID = strings.TrimSpace(query.Get("project_id")); filter.ProjectID != "" {
|
|
if err := validateUUID(filter.ProjectID); err != nil {
|
|
return historyFilter{}, err
|
|
}
|
|
if err := s.authorizeProject(r.Context(), filter.ProjectID); err != nil {
|
|
return historyFilter{}, err
|
|
}
|
|
}
|
|
if filter.From == "" || filter.To == "" {
|
|
today := s.now().UTC().Truncate(24 * time.Hour)
|
|
filter.To = today.Format(historyDateLayout)
|
|
filter.From = today.AddDate(0, 0, -6).Format(historyDateLayout)
|
|
}
|
|
from, fromErr := time.Parse(historyDateLayout, filter.From)
|
|
to, toErr := time.Parse(historyDateLayout, filter.To)
|
|
if fromErr != nil || toErr != nil || to.Before(from) || to.Sub(from) > 30*24*time.Hour {
|
|
return historyFilter{}, &apiError{Status: http.StatusBadRequest, Code: "INVALID_HISTORY_DATE_RANGE", Message: "历史数据日期范围必须有效且不超过 31 天"}
|
|
}
|
|
if filter.Status != "" && allowedStatuses != nil && !allowedStatuses[filter.Status] {
|
|
return historyFilter{}, &apiError{Status: http.StatusBadRequest, Code: "INVALID_HISTORY_STATUS", Message: "历史数据状态筛选条件不正确"}
|
|
}
|
|
if len([]rune(filter.Query)) > 80 {
|
|
return historyFilter{}, &apiError{Status: http.StatusBadRequest, Code: "HISTORY_QUERY_TOO_LONG", Message: "搜索条件不能超过 80 个字符"}
|
|
}
|
|
if raw := query.Get("page"); raw != "" {
|
|
value, err := strconv.Atoi(raw)
|
|
if err != nil || value < 1 || value > 100000 {
|
|
return historyFilter{}, &apiError{Status: http.StatusBadRequest, Code: "INVALID_PAGE", Message: "页码不正确"}
|
|
}
|
|
filter.Page = value
|
|
}
|
|
if raw := query.Get("page_size"); raw != "" {
|
|
value, err := strconv.Atoi(raw)
|
|
if err != nil || value < 1 || value > 100 {
|
|
return historyFilter{}, &apiError{Status: http.StatusBadRequest, Code: "INVALID_PAGE_SIZE", Message: "每页数量必须在 1 到 100 之间"}
|
|
}
|
|
filter.PageSize = value
|
|
}
|
|
return filter, nil
|
|
}
|
|
|
|
func (s *Server) historyTicketQuery(ctx context.Context) *gorm.DB {
|
|
return s.db.WithContext(ctx).Table("queue_tickets AS ticket").
|
|
Joins("JOIN queue_sessions AS session ON session.id = ticket.queue_session_id AND session.project_id = ticket.project_id").
|
|
Joins("JOIN projects AS project ON project.id = ticket.project_id").
|
|
Joins("LEFT JOIN users AS creator ON creator.id = ticket.created_by").
|
|
Joins("LEFT JOIN call_batch_tickets AS link ON link.ticket_id = ticket.id AND link.project_id = ticket.project_id").
|
|
Joins("LEFT JOIN call_batches AS batch ON batch.id = link.call_batch_id AND batch.project_id = link.project_id")
|
|
}
|
|
|
|
func historyTicketSelect() string {
|
|
return `ticket.id, ticket.project_id, project.name AS project_name, project.code AS project_code,
|
|
ticket.queue_session_id, session.business_date, ticket.ticket_number, ticket.display_number, ticket.party_size,
|
|
ticket.status, ticket.joined_at, ticket.called_at, ticket.arrived_at, ticket.completed_at, ticket.missed_at,
|
|
ticket.updated_at,
|
|
ticket.personal_data_purged_at, ticket.phone_ciphertext, ticket.phone_nonce, ticket.phone_hmac,
|
|
ticket.history_anonymized_at, ticket.last_name_ciphertext, ticket.last_name_nonce, ticket.honorific, ticket.created_by,
|
|
creator.username AS created_by_name, batch.id AS batch_id, batch.batch_sequence, link.position AS batch_position`
|
|
}
|
|
|
|
func applyHistoryTicketFilter(db *gorm.DB, filter historyFilter, server *Server) *gorm.DB {
|
|
db = db.Where("session.business_date BETWEEN ? AND ?", filter.From, filter.To)
|
|
if filter.ProjectID != "" {
|
|
db = db.Where("ticket.project_id = ?", filter.ProjectID)
|
|
}
|
|
if filter.Status != "" {
|
|
db = db.Where("ticket.status = ?", filter.Status)
|
|
}
|
|
if filter.Query != "" {
|
|
pattern := "%" + escapeHistoryLike(filter.Query) + "%"
|
|
phoneDigest := ""
|
|
if normalized, err := security.NormalizePhone(filter.Query); err == nil {
|
|
phoneDigest = server.cipher.Digest(normalized)
|
|
}
|
|
if phoneDigest != "" {
|
|
db = db.Where("(ticket.display_number ILIKE ? ESCAPE '\\' OR ticket.ticket_number::text ILIKE ? ESCAPE '\\' OR ticket.phone_hmac = ?)", pattern, pattern, phoneDigest)
|
|
} else {
|
|
db = db.Where("(ticket.display_number ILIKE ? ESCAPE '\\' OR ticket.ticket_number::text ILIKE ? ESCAPE '\\')", pattern, pattern)
|
|
}
|
|
}
|
|
return db
|
|
}
|
|
|
|
func (s *Server) historyBatchQuery(ctx context.Context) *gorm.DB {
|
|
return s.db.WithContext(ctx).Table("call_batches AS batch").
|
|
Joins("JOIN queue_sessions AS session ON session.id = batch.queue_session_id AND session.project_id = batch.project_id").
|
|
Joins("JOIN projects AS project ON project.id = batch.project_id").
|
|
Joins("LEFT JOIN users AS requester ON requester.id = batch.requested_by")
|
|
}
|
|
|
|
func historyBatchSelect() string {
|
|
return `batch.id, batch.project_id, project.name AS project_name, project.code AS project_code,
|
|
batch.queue_session_id, session.business_date, batch.batch_sequence, batch.revision, batch.status,
|
|
batch.call_mode, batch.requested_count, batch.ticket_count, batch.people_count, batch.requested_by,
|
|
requester.username AS requested_by_name, batch.called_at, batch.completed_at`
|
|
}
|
|
|
|
func applyHistoryBatchFilter(db *gorm.DB, filter historyFilter) *gorm.DB {
|
|
db = db.Where("session.business_date BETWEEN ? AND ?", filter.From, filter.To)
|
|
if filter.ProjectID != "" {
|
|
db = db.Where("batch.project_id = ?", filter.ProjectID)
|
|
}
|
|
if filter.Status != "" {
|
|
db = db.Where("batch.status = ?", filter.Status)
|
|
}
|
|
if filter.Query != "" {
|
|
pattern := "%" + escapeHistoryLike(filter.Query) + "%"
|
|
db = db.Where(`(
|
|
batch.batch_sequence::text ILIKE ? ESCAPE '\\'
|
|
OR EXISTS (
|
|
SELECT 1 FROM call_batch_tickets AS search_link
|
|
JOIN queue_tickets AS search_ticket ON search_ticket.id = search_link.ticket_id AND search_ticket.project_id = search_link.project_id
|
|
WHERE search_link.call_batch_id = batch.id AND search_ticket.display_number ILIKE ? ESCAPE '\\'
|
|
)`, pattern, pattern)
|
|
}
|
|
return db
|
|
}
|
|
|
|
func (s *Server) historyBatchLabels(ctx context.Context, rows []historyBatchRow) (map[string][]string, error) {
|
|
labels := make(map[string][]string)
|
|
if len(rows) == 0 {
|
|
return labels, nil
|
|
}
|
|
ids := make([]string, 0, len(rows))
|
|
for _, row := range rows {
|
|
ids = append(ids, row.ID)
|
|
}
|
|
var values []historyBatchTicketLabel
|
|
err := s.db.WithContext(ctx).Table("call_batch_tickets AS link").
|
|
Select("link.call_batch_id AS batch_id, ticket.display_number, link.position").
|
|
Joins("JOIN queue_tickets AS ticket ON ticket.id = link.ticket_id AND ticket.project_id = link.project_id").
|
|
Where("link.call_batch_id IN ?", ids).Order("link.call_batch_id, link.position").Scan(&values).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, value := range values {
|
|
labels[value.BatchID] = append(labels[value.BatchID], value.DisplayNumber)
|
|
}
|
|
return labels, nil
|
|
}
|
|
|
|
func (s *Server) historyHourlySummary(ctx context.Context, filter historyFilter) ([]historyHourlyRow, error) {
|
|
issuedBase := s.historyTicketQuery(ctx)
|
|
issuedBase = applyHistoryTicketFilter(issuedBase, filter, s)
|
|
var issuedRows []historyHourlyRow
|
|
if err := issuedBase.Select(`
|
|
EXTRACT(HOUR FROM ticket.joined_at AT TIME ZONE 'Asia/Shanghai')::bigint AS hour,
|
|
COUNT(ticket.id) AS issued_tickets,
|
|
COALESCE(SUM(ticket.party_size), 0) AS issued_people`).
|
|
Group("EXTRACT(HOUR FROM ticket.joined_at AT TIME ZONE 'Asia/Shanghai')").Scan(&issuedRows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
calledBase := s.historyTicketQuery(ctx)
|
|
calledBase = applyHistoryTicketFilter(calledBase, filter, s).Where("ticket.called_at IS NOT NULL")
|
|
var calledRows []historyHourlyRow
|
|
if err := calledBase.Select(`
|
|
EXTRACT(HOUR FROM ticket.called_at AT TIME ZONE 'Asia/Shanghai')::bigint AS hour,
|
|
COUNT(ticket.id) AS called_tickets,
|
|
COALESCE(SUM(ticket.party_size), 0) AS called_people`).
|
|
Group("EXTRACT(HOUR FROM ticket.called_at AT TIME ZONE 'Asia/Shanghai')").Scan(&calledRows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
byHour := make(map[int64]historyHourlyRow, len(issuedRows)+len(calledRows))
|
|
for _, row := range issuedRows {
|
|
byHour[row.Hour] = row
|
|
}
|
|
for _, row := range calledRows {
|
|
merged := byHour[row.Hour]
|
|
merged.Hour = row.Hour
|
|
merged.CalledTickets = row.CalledTickets
|
|
merged.CalledPeople = row.CalledPeople
|
|
byHour[row.Hour] = merged
|
|
}
|
|
rows := make([]historyHourlyRow, 0, len(byHour))
|
|
for _, row := range byHour {
|
|
rows = append(rows, row)
|
|
}
|
|
sort.Slice(rows, func(i, j int) bool { return rows[i].Hour < rows[j].Hour })
|
|
return rows, nil
|
|
}
|
|
|
|
func (s *Server) historyDailySummary(ctx context.Context, filter historyFilter) ([]historyDailyRow, error) {
|
|
base := s.historyTicketQuery(ctx)
|
|
base = applyHistoryTicketFilter(base, filter, s)
|
|
var rows []historyDailyRow
|
|
err := base.Select(`
|
|
session.business_date AS business_date,
|
|
COUNT(ticket.id) AS total_tickets,
|
|
COALESCE(SUM(ticket.party_size), 0) AS total_people,
|
|
COUNT(ticket.id) FILTER (WHERE ticket.called_at IS NOT NULL) AS called_tickets,
|
|
COUNT(ticket.id) FILTER (WHERE ticket.status = 'COMPLETED') AS completed_tickets,
|
|
COUNT(ticket.id) FILTER (WHERE ticket.status = 'MISSED') AS missed_tickets,
|
|
COUNT(ticket.id) FILTER (WHERE ticket.status = 'CANCELED') AS canceled_tickets,
|
|
AVG(EXTRACT(EPOCH FROM (ticket.called_at - ticket.joined_at))) FILTER (WHERE ticket.called_at IS NOT NULL) AS average_wait,
|
|
MAX(EXTRACT(EPOCH FROM (ticket.called_at - ticket.joined_at))) FILTER (WHERE ticket.called_at IS NOT NULL) AS max_wait`).
|
|
Group("session.business_date").Order("session.business_date ASC").Scan(&rows).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
func (s *Server) historyProjectSummary(ctx context.Context, filter historyFilter) ([]historyProjectRow, error) {
|
|
base := s.historyTicketQuery(ctx)
|
|
base = applyHistoryTicketFilter(base, filter, s)
|
|
var rows []historyProjectRow
|
|
err := base.Select(`
|
|
project.id AS project_id, project.name AS project_name, project.code AS project_code,
|
|
COUNT(ticket.id) AS total_tickets,
|
|
COALESCE(SUM(ticket.party_size), 0) AS total_people,
|
|
COUNT(ticket.id) FILTER (WHERE ticket.called_at IS NOT NULL) AS called_tickets,
|
|
COUNT(ticket.id) FILTER (WHERE ticket.status = 'COMPLETED') AS completed_tickets,
|
|
COUNT(ticket.id) FILTER (WHERE ticket.status = 'MISSED') AS missed_tickets,
|
|
COUNT(ticket.id) FILTER (WHERE ticket.status = 'CANCELED') AS canceled_tickets,
|
|
AVG(EXTRACT(EPOCH FROM (ticket.called_at - ticket.joined_at))) FILTER (WHERE ticket.called_at IS NOT NULL) AS average_wait,
|
|
MAX(EXTRACT(EPOCH FROM (ticket.called_at - ticket.joined_at))) FILTER (WHERE ticket.called_at IS NOT NULL) AS max_wait`).
|
|
Group("project.id, project.name, project.code").Order("total_tickets DESC, project.name ASC").Scan(&rows).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
func historyHourlyViews(rows []historyHourlyRow) []map[string]any {
|
|
result := make([]map[string]any, 0, len(rows))
|
|
for _, row := range rows {
|
|
result = append(result, map[string]any{"hour": row.Hour, "issued_tickets": row.IssuedTickets, "issued_people": row.IssuedPeople, "called_tickets": row.CalledTickets, "called_people": row.CalledPeople})
|
|
}
|
|
return result
|
|
}
|
|
|
|
func historyPeakHourViews(rows []historyHourlyRow) []map[string]any {
|
|
peaks := append([]historyHourlyRow(nil), rows...)
|
|
sort.Slice(peaks, func(i, j int) bool {
|
|
if peaks[i].IssuedTickets != peaks[j].IssuedTickets {
|
|
return peaks[i].IssuedTickets > peaks[j].IssuedTickets
|
|
}
|
|
if peaks[i].CalledTickets != peaks[j].CalledTickets {
|
|
return peaks[i].CalledTickets > peaks[j].CalledTickets
|
|
}
|
|
return peaks[i].Hour < peaks[j].Hour
|
|
})
|
|
if len(peaks) > 3 {
|
|
peaks = peaks[:3]
|
|
}
|
|
result := make([]map[string]any, 0, len(peaks))
|
|
for index, row := range peaks {
|
|
if row.IssuedTickets == 0 {
|
|
continue
|
|
}
|
|
result = append(result, map[string]any{"rank": index + 1, "hour": row.Hour, "issued_tickets": row.IssuedTickets, "issued_people": row.IssuedPeople, "called_tickets": row.CalledTickets, "called_people": row.CalledPeople})
|
|
}
|
|
return result
|
|
}
|
|
|
|
func historyDailyViews(rows []historyDailyRow) []map[string]any {
|
|
result := make([]map[string]any, 0, len(rows))
|
|
for _, row := range rows {
|
|
result = append(result, map[string]any{
|
|
"business_date": row.BusinessDate.Format(historyDateLayout), "total_tickets": row.TotalTickets, "total_people": row.TotalPeople,
|
|
"called_tickets": row.CalledTickets, "completed_tickets": row.CompletedTickets, "missed_tickets": row.MissedTickets, "canceled_tickets": row.CanceledTickets,
|
|
"completion_rate": historyRate(row.CompletedTickets, row.TotalTickets), "missed_rate": historyRate(row.MissedTickets, row.TotalTickets), "canceled_rate": historyRate(row.CanceledTickets, row.TotalTickets),
|
|
"average_wait_seconds": nullableRoundedSeconds(row.AverageWait), "max_wait_seconds": nullableRoundedSeconds(row.MaxWait),
|
|
})
|
|
}
|
|
return result
|
|
}
|
|
|
|
func historyProjectViews(rows []historyProjectRow) []map[string]any {
|
|
result := make([]map[string]any, 0, len(rows))
|
|
for _, row := range rows {
|
|
result = append(result, map[string]any{
|
|
"project_id": row.ProjectID, "project_name": row.ProjectName, "project_code": row.ProjectCode,
|
|
"total_tickets": row.TotalTickets, "total_people": row.TotalPeople, "called_tickets": row.CalledTickets, "completed_tickets": row.CompletedTickets,
|
|
"missed_tickets": row.MissedTickets, "canceled_tickets": row.CanceledTickets,
|
|
"completion_rate": historyRate(row.CompletedTickets, row.TotalTickets), "missed_rate": historyRate(row.MissedTickets, row.TotalTickets), "canceled_rate": historyRate(row.CanceledTickets, row.TotalTickets),
|
|
"average_wait_seconds": nullableRoundedSeconds(row.AverageWait), "max_wait_seconds": nullableRoundedSeconds(row.MaxWait),
|
|
})
|
|
}
|
|
return result
|
|
}
|
|
|
|
func historySummaryAlerts(rows []historyProjectRow) []map[string]any {
|
|
alerts := make([]map[string]any, 0)
|
|
for _, row := range rows {
|
|
if row.TotalTickets < 5 {
|
|
continue
|
|
}
|
|
missedRate := historyRate(row.MissedTickets, row.TotalTickets)
|
|
canceledRate := historyRate(row.CanceledTickets, row.TotalTickets)
|
|
completionRate := historyRate(row.CompletedTickets, row.TotalTickets)
|
|
var alert map[string]any
|
|
switch {
|
|
case missedRate >= 0.20:
|
|
alert = map[string]any{"severity": 3, "type": "MISSED_RATE", "title": row.ProjectName + "过号率偏高", "detail": "过号率达到 " + formatHistoryRate(missedRate) + ",建议复盘叫号触达和现场提醒。"}
|
|
case row.AverageWait != nil && *row.AverageWait >= 900:
|
|
alert = map[string]any{"severity": 3, "type": "WAIT_TIME", "title": row.ProjectName + "等待时间偏长", "detail": "平均等待约 " + formatHistoryDuration(*row.AverageWait) + ",建议检查高峰时段的叫号吞吐。"}
|
|
case completionRate < 0.70:
|
|
alert = map[string]any{"severity": 2, "type": "COMPLETION_RATE", "title": row.ProjectName + "完成率偏低", "detail": "完成率为 " + formatHistoryRate(completionRate) + ",建议结合过号和取消明细进一步下钻。"}
|
|
case canceledRate >= 0.10:
|
|
alert = map[string]any{"severity": 2, "type": "CANCELED_RATE", "title": row.ProjectName + "取消率偏高", "detail": "取消率达到 " + formatHistoryRate(canceledRate) + ",建议确认营业安排或取号规则是否需要调整。"}
|
|
}
|
|
if alert != nil {
|
|
alert["project_id"] = row.ProjectID
|
|
alert["project_name"] = row.ProjectName
|
|
alert["total_tickets"] = row.TotalTickets
|
|
alerts = append(alerts, alert)
|
|
}
|
|
}
|
|
sort.Slice(alerts, func(i, j int) bool {
|
|
left, _ := alerts[i]["severity"].(int)
|
|
right, _ := alerts[j]["severity"].(int)
|
|
return left > right
|
|
})
|
|
if len(alerts) > 6 {
|
|
alerts = alerts[:6]
|
|
}
|
|
return alerts
|
|
}
|
|
|
|
func historyRate(numerator, denominator int64) float64 {
|
|
if denominator == 0 {
|
|
return 0
|
|
}
|
|
return float64(numerator) / float64(denominator)
|
|
}
|
|
|
|
func formatHistoryRate(value float64) string {
|
|
return strconv.FormatFloat(value*100, 'f', 1, 64) + "%"
|
|
}
|
|
|
|
func formatHistoryDuration(seconds float64) string {
|
|
minutes := int64(seconds) / 60
|
|
remaining := int64(seconds) % 60
|
|
if minutes == 0 {
|
|
return strconv.FormatInt(remaining, 10) + " 秒"
|
|
}
|
|
return strconv.FormatInt(minutes, 10) + " 分钟"
|
|
}
|
|
|
|
func (s *Server) historyTicketViews(rows []historyTicketRow, reveal bool) ([]map[string]any, error) {
|
|
views := make([]map[string]any, 0, len(rows))
|
|
for _, row := range rows {
|
|
view, err := s.historyTicketView(row, reveal)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
views = append(views, view)
|
|
}
|
|
return views, nil
|
|
}
|
|
|
|
func (s *Server) historyTicketView(row historyTicketRow, reveal bool) (map[string]any, error) {
|
|
ticket := model.QueueTicket{
|
|
ID: row.ID, ProjectID: row.ProjectID, DisplayNumber: row.DisplayNumber, PartySize: row.PartySize,
|
|
Status: row.Status, JoinedAt: row.JoinedAt, CalledAt: row.CalledAt, ArrivedAt: row.ArrivedAt,
|
|
CompletedAt: row.CompletedAt, MissedAt: row.MissedAt, PersonalDataPurgedAt: row.PersonalDataPurgedAt,
|
|
HistoryAnonymizedAt: row.HistoryAnonymizedAt,
|
|
UpdatedAt: func() time.Time {
|
|
if row.UpdatedAt != nil {
|
|
return *row.UpdatedAt
|
|
}
|
|
return row.JoinedAt
|
|
}(),
|
|
PhoneCiphertext: row.PhoneCiphertext, PhoneNonce: row.PhoneNonce, PhoneHMAC: row.PhoneHMAC,
|
|
LastNameCiphertext: row.LastNameCiphertext, LastNameNonce: row.LastNameNonce, Honorific: row.Honorific,
|
|
}
|
|
personal, err := s.historyPersonalView(ticket, reveal)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
view := map[string]any{
|
|
"id": row.ID, "project_id": row.ProjectID, "project_name": row.ProjectName, "project_code": row.ProjectCode,
|
|
"queue_session_id": row.QueueSessionID, "business_date": row.BusinessDate.Format(historyDateLayout),
|
|
"ticket_number": row.DisplayNumber, "ticket_sequence": row.TicketNumber, "party_size": row.PartySize, "status": row.Status,
|
|
"joined_at": row.JoinedAt, "called_at": row.CalledAt, "arrived_at": row.ArrivedAt,
|
|
"completed_at": row.CompletedAt, "missed_at": row.MissedAt, "terminal_at": historyTerminalAt(ticket),
|
|
"wait_seconds": historyWaitSeconds(ticket), "created_by": row.CreatedByName, "personal_data_purged_at": row.PersonalDataPurgedAt,
|
|
"history_anonymized_at": row.HistoryAnonymizedAt,
|
|
"batch_id": row.BatchID, "batch_sequence": row.BatchSequence, "batch_position": row.BatchPosition,
|
|
}
|
|
for key, value := range personal {
|
|
view[key] = value
|
|
}
|
|
return view, nil
|
|
}
|
|
|
|
func (s *Server) historyPersonalView(ticket model.QueueTicket, reveal bool) (map[string]any, error) {
|
|
view := map[string]any{"phone": nil, "phone_last4": nil, "phone_masked": nil, "last_name": nil, "honorific": nil, "personal_data_status": "ANONYMIZED"}
|
|
if ticket.HistoryAnonymizedAt != nil || ticket.PhoneHMAC == nil || len(ticket.PhoneCiphertext) == 0 || len(ticket.PhoneNonce) == 0 {
|
|
return view, nil
|
|
}
|
|
last4, err := s.ticketPhoneLast4(ticket)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
view["phone_last4"] = last4
|
|
view["phone_masked"] = "****" + last4
|
|
view["personal_data_status"] = "AVAILABLE"
|
|
if !reveal {
|
|
return view, nil
|
|
}
|
|
phone, lastName, err := s.decryptTicketPersonal(ticket)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
view["phone"] = phone
|
|
view["last_name"] = lastName
|
|
view["honorific"] = ticket.Honorific
|
|
return view, nil
|
|
}
|
|
|
|
func historyBatchView(row historyBatchRow, ticketNumbers []string) map[string]any {
|
|
view := map[string]any{
|
|
"id": row.ID, "project_id": row.ProjectID, "project_name": row.ProjectName, "project_code": row.ProjectCode,
|
|
"queue_session_id": row.QueueSessionID, "business_date": row.BusinessDate.Format(historyDateLayout),
|
|
"batch_sequence": row.BatchSequence, "status": row.Status, "call_mode": row.CallMode,
|
|
"requested_count": row.RequestedCount, "ticket_count": row.TicketCount, "people_count": row.PeopleCount,
|
|
"requested_by": row.RequestedByName, "called_at": row.CalledAt, "completed_at": row.CompletedAt, "revision": row.Revision,
|
|
}
|
|
if ticketNumbers != nil {
|
|
view["ticket_numbers"] = ticketNumbers
|
|
}
|
|
return view
|
|
}
|
|
|
|
func (s *Server) auditHistoryPhoneSearch(r *http.Request, filter historyFilter) error {
|
|
if filter.Query == "" {
|
|
return nil
|
|
}
|
|
if _, err := security.NormalizePhone(filter.Query); err != nil {
|
|
return nil
|
|
}
|
|
return s.auditHistoryAccess(r, "HISTORY_PHONE_SEARCH", "HISTORY_QUERY", nil, historyProjectIDPointer(filter.ProjectID), map[string]any{
|
|
"from": filter.From, "to": filter.To, "query_kind": "phone",
|
|
})
|
|
}
|
|
|
|
func (s *Server) auditHistoryAccess(r *http.Request, action, entityType string, entityID, projectID *string, details map[string]any) error {
|
|
user := currentPrincipal(r.Context()).User
|
|
return s.db.WithContext(r.Context()).Transaction(func(tx *gorm.DB) error {
|
|
return s.addAudit(tx, r, projectID, &user.ID, action, entityType, entityID, details)
|
|
})
|
|
}
|
|
|
|
func historyTerminalAt(ticket model.QueueTicket) *time.Time {
|
|
switch ticket.Status {
|
|
case model.TicketCompleted:
|
|
return ticket.CompletedAt
|
|
case model.TicketMissed:
|
|
return ticket.MissedAt
|
|
case model.TicketCanceled:
|
|
return &ticket.UpdatedAt
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func historyWaitSeconds(ticket model.QueueTicket) any {
|
|
if ticket.CalledAt == nil {
|
|
return nil
|
|
}
|
|
return maxInt64(0, int64(ticket.CalledAt.Sub(ticket.JoinedAt).Seconds()))
|
|
}
|
|
|
|
func nullableRoundedSeconds(value *float64) any {
|
|
if value == nil {
|
|
return nil
|
|
}
|
|
return maxInt64(0, int64(*value+0.5))
|
|
}
|
|
|
|
func maxInt64(value, floor int64) int64 {
|
|
if value < floor {
|
|
return floor
|
|
}
|
|
return value
|
|
}
|
|
|
|
func parseBoolQuery(value string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
case "1", "true", "yes":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func historyProjectIDPointer(value string) *string {
|
|
if value == "" {
|
|
return nil
|
|
}
|
|
return &value
|
|
}
|
|
|
|
func historyQueryKind(value string) string {
|
|
if _, err := security.NormalizePhone(value); err == nil {
|
|
return "phone"
|
|
}
|
|
if value == "" {
|
|
return "none"
|
|
}
|
|
return "ticket_or_text"
|
|
}
|
|
|
|
func escapeHistoryLike(value string) string {
|
|
return strings.NewReplacer("\\", "\\\\", "%", "\\%", "_", "\\_").Replace(value)
|
|
}
|
|
|
|
func stringValue(value any) string {
|
|
if value == nil {
|
|
return ""
|
|
}
|
|
switch typed := value.(type) {
|
|
case string:
|
|
return typed
|
|
case *time.Time:
|
|
if typed == nil {
|
|
return ""
|
|
}
|
|
return typed.Format(time.RFC3339)
|
|
case time.Time:
|
|
return typed.Format(time.RFC3339)
|
|
case int64:
|
|
return strconv.FormatInt(typed, 10)
|
|
case int:
|
|
return strconv.Itoa(typed)
|
|
case float64:
|
|
return strconv.FormatFloat(typed, 'f', 0, 64)
|
|
default:
|
|
return fmt.Sprint(typed)
|
|
}
|
|
}
|