service.go 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  1. package service
  2. import (
  3. "crypto/rand"
  4. "encoding/hex"
  5. "encoding/json"
  6. "errors"
  7. "fmt"
  8. "gorm.io/gorm"
  9. "time"
  10. "wails-app/internal/dao"
  11. "wails-app/internal/global"
  12. "wails-app/internal/modules/digital-ticket/repository"
  13. )
  14. const (
  15. StateCreated = "created"
  16. StatePendingPayment = "pending_payment"
  17. StatePaid = "paid"
  18. StateExited = "exited"
  19. StateExpired = "expired"
  20. )
  21. type TicketService struct {
  22. repo *repository.TicketRepository
  23. }
  24. func NewTicketService() *TicketService {
  25. return &TicketService{repo: &repository.TicketRepository{}}
  26. }
  27. func genTicketNo() string {
  28. b := make([]byte, 16)
  29. rand.Read(b)
  30. return hex.EncodeToString(b)
  31. }
  32. func appendEvent(log string, event map[string]interface{}) string {
  33. var events []map[string]interface{}
  34. json.Unmarshal([]byte(log), &events)
  35. event["at"] = time.Now().Format(time.RFC3339)
  36. events = append(events, event)
  37. data, _ := json.Marshal(events)
  38. return string(data)
  39. }
  40. // Create 创建数字票(所有进场方式统一入口)
  41. func (s *TicketService) Create(plateNumber, triggerMode string, recordID uint) (*dao.DigitalTicket, error) {
  42. return s.CreateTx(global.GVA_DB, plateNumber, triggerMode, recordID)
  43. }
  44. func (s *TicketService) CreateTx(db *gorm.DB, plateNumber, triggerMode string, recordID uint) (*dao.DigitalTicket, error) {
  45. log := appendEvent("[]", map[string]interface{}{"event": "created", "trigger": triggerMode})
  46. ticket := &dao.DigitalTicket{
  47. TicketNo: genTicketNo(), PlateNumber: plateNumber, TriggerMode: triggerMode,
  48. State: StatePendingPayment, VehicleRecordID: recordID, EntryTime: time.Now(), EventLog: log,
  49. }
  50. if err := s.repo.CreateTx(db, ticket); err != nil {
  51. return nil, err
  52. }
  53. return ticket, nil
  54. }
  55. // Transition 状态转换(校验合法性,原子更新)
  56. func (s *TicketService) Transition(id uint, toState string, extra map[string]interface{}) error {
  57. return s.TransitionTx(global.GVA_DB, id, toState, extra)
  58. }
  59. func (s *TicketService) TransitionTx(db *gorm.DB, id uint, toState string, extra map[string]interface{}) error {
  60. ticket, err := s.repo.GetByIDTx(db, id)
  61. if err != nil {
  62. return errors.New("票不存在")
  63. }
  64. validTransitions := map[string][]string{
  65. StateCreated: {StatePendingPayment},
  66. StatePendingPayment: {StatePaid, StateExpired},
  67. StatePaid: {StateExited, StateExpired},
  68. }
  69. allowed := false
  70. for _, s := range validTransitions[ticket.State] {
  71. if s == toState {
  72. allowed = true
  73. break
  74. }
  75. }
  76. if !allowed {
  77. return fmt.Errorf("状态转换不合法: %s → %s", ticket.State, toState)
  78. }
  79. updates := map[string]interface{}{}
  80. if toState == StatePaid {
  81. now := time.Now()
  82. updates["paid_at"] = now
  83. }
  84. if toState == StateExited {
  85. now := time.Now()
  86. updates["exit_time"] = now
  87. }
  88. // 只允许表中存在的字段写入数据库,其余仅记录到事件日志
  89. dbFields := map[string]bool{"payment_entry": true, "payment_method": true, "payment_order_no": true, "paid_amount": true, "fee": true}
  90. for k, v := range extra {
  91. if dbFields[k] {
  92. updates[k] = v
  93. }
  94. }
  95. event := map[string]interface{}{"event": toState}
  96. for k, v := range extra {
  97. event[k] = v
  98. }
  99. updates["event_log"] = appendEvent(ticket.EventLog, event)
  100. return s.repo.UpdateStateTx(db, id, toState, updates)
  101. }
  102. // MarkPaidAndExitedTx updates the ticket lifecycle in the caller's
  103. // transaction. Payment entry and method are stored as payment metadata only;
  104. // they do not define the ticket itself.
  105. func (s *TicketService) MarkPaidAndExitedTx(db *gorm.DB, recordID uint, paymentEntry, paymentMethod, paymentOrderNo string, paidAmount, fee float64) error {
  106. ticket, err := s.repo.GetByVehicleRecordIDTx(db, recordID)
  107. if err != nil {
  108. return errors.New("车辆对应的数字票不存在")
  109. }
  110. if ticket.State == StatePendingPayment {
  111. extra := map[string]interface{}{
  112. "payment_entry": paymentEntry,
  113. "payment_method": paymentMethod,
  114. "payment_order_no": paymentOrderNo,
  115. "paid_amount": paidAmount,
  116. "fee": fee,
  117. }
  118. if err := s.TransitionTx(db, ticket.ID, StatePaid, extra); err != nil {
  119. return err
  120. }
  121. ticket.State = StatePaid
  122. }
  123. if ticket.State == StatePaid {
  124. return s.TransitionTx(db, ticket.ID, StateExited, nil)
  125. }
  126. if ticket.State == StateExited {
  127. return nil
  128. }
  129. return fmt.Errorf("数字票状态不允许出场: %s", ticket.State)
  130. }
  131. func (s *TicketService) GetByTicketNo(no string) (*dao.DigitalTicket, error) {
  132. ticket, err := s.repo.GetByTicketNo(no)
  133. if err != nil {
  134. return nil, err
  135. }
  136. s.enrichTicket(ticket)
  137. return ticket, nil
  138. }
  139. func (s *TicketService) List(q repository.TicketListQuery) ([]dao.DigitalTicket, int64, error) {
  140. list, total, err := s.repo.List(q)
  141. if err != nil {
  142. return nil, 0, err
  143. }
  144. for i := range list {
  145. s.enrichTicket(&list[i])
  146. }
  147. return list, total, nil
  148. }
  149. func (s *TicketService) Summary(q repository.TicketListQuery) (repository.TicketSummary, error) {
  150. return s.repo.Summary(q)
  151. }
  152. // enrichTicket keeps the ticket view useful before payment. A ticket is
  153. // created at vehicle entry, while the definitive fee is calculated at payment
  154. // or exit time. Pending tickets therefore need a live fee preview derived from
  155. // their vehicle record and current parking duration.
  156. func (s *TicketService) enrichTicket(ticket *dao.DigitalTicket) {
  157. if ticket == nil || ticket.VehicleRecordID == 0 || global.GVA_DB == nil {
  158. return
  159. }
  160. var record dao.VehicleRecord
  161. if err := global.GVA_DB.First(&record, ticket.VehicleRecordID).Error; err != nil {
  162. return
  163. }
  164. if record.PlateNumber != "" {
  165. ticket.PlateNumber = record.PlateNumber
  166. }
  167. var vehicle dao.Vehicle
  168. vehicleQuery := global.GVA_DB
  169. vehicleFound := false
  170. if record.PlateNumber != "" {
  171. vehicleQuery = vehicleQuery.Where("plate_number = ?", record.PlateNumber)
  172. vehicleFound = vehicleQuery.First(&vehicle).Error == nil
  173. } else if record.RFIDTag != "" {
  174. vehicleQuery = vehicleQuery.Where("rfid_tag = ?", record.RFIDTag)
  175. vehicleFound = vehicleQuery.First(&vehicle).Error == nil
  176. }
  177. if ticket.PlateNumber == "" && vehicleFound {
  178. ticket.PlateNumber = vehicle.PlateNumber
  179. }
  180. if ticket.State == StateCreated || ticket.State == StatePendingPayment {
  181. if !vehicleFound {
  182. return
  183. }
  184. stayTime := int64(time.Since(record.EntryTime).Minutes())
  185. if stayTime < 0 {
  186. stayTime = 0
  187. }
  188. ticket.Fee = CalculateFee(&vehicle, stayTime)
  189. return
  190. }
  191. // Older rows may have a zero digital-ticket fee even though the vehicle
  192. // record was already settled. Keep the historical record's fee visible.
  193. if ticket.Fee == 0 && record.Fee != 0 {
  194. ticket.Fee = record.Fee
  195. }
  196. }