service.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338
  1. package service
  2. import (
  3. "errors"
  4. "fmt"
  5. "math"
  6. "strings"
  7. "time"
  8. "wails-app/internal/dao"
  9. "wails-app/internal/global"
  10. "wails-app/internal/modules/parking-session/model/request"
  11. "wails-app/internal/modules/parking-session/repository"
  12. "gorm.io/gorm"
  13. )
  14. const (
  15. PaymentStatusUnpaid = "unpaid"
  16. PaymentStatusPaid = "paid"
  17. )
  18. var (
  19. ErrSessionNotFound = errors.New("未找到在场停车会话")
  20. ErrSessionAlreadyOpen = errors.New("车辆已有在场停车会话")
  21. ErrSessionAlreadyPaid = errors.New("停车会话已完成支付")
  22. ErrSessionAlreadyClosed = errors.New("停车会话已关闭")
  23. ErrSessionIdentityConflict = errors.New("车牌与RFID对应不同的在场停车会话")
  24. ErrSessionAmbiguous = errors.New("存在多条匹配的在场停车会话")
  25. ErrSessionIdentifierEmpty = errors.New("停车会话必须提供车牌、RFID或会话编号")
  26. )
  27. // ParkingSessionService owns the parking-stay lifecycle. It deliberately
  28. // persists to vehicle_record so existing reports and historical data remain
  29. // compatible while callers stop treating the record as an unstructured table.
  30. type ParkingSessionService struct {
  31. repo *repository.ParkingSessionRepository
  32. }
  33. func NewParkingSessionService() *ParkingSessionService {
  34. return &ParkingSessionService{repo: &repository.ParkingSessionRepository{}}
  35. }
  36. // FindActiveSession is the domain-facing name used by adapters that do not
  37. // need to know whether the lookup is backed by a transaction.
  38. func (s *ParkingSessionService) FindActiveSession(req request.ResolveActiveSessionRequest) (*dao.VehicleRecord, error) {
  39. return s.ResolveActiveSession(req)
  40. }
  41. func (s *ParkingSessionService) OpenSession(req request.OpenSessionRequest) (*dao.VehicleRecord, error) {
  42. if global.GVA_DB == nil {
  43. return nil, errors.New("数据库未初始化")
  44. }
  45. var session *dao.VehicleRecord
  46. err := global.GVA_DB.Transaction(func(tx *gorm.DB) error {
  47. var err error
  48. session, err = s.OpenSessionTx(tx, req)
  49. return err
  50. })
  51. return session, err
  52. }
  53. func (s *ParkingSessionService) MarkPaid(sessionID uint, paymentEntry, paymentMethod string, fee float64) (*dao.VehicleRecord, error) {
  54. if global.GVA_DB == nil {
  55. return nil, errors.New("数据库未初始化")
  56. }
  57. var session *dao.VehicleRecord
  58. err := global.GVA_DB.Transaction(func(tx *gorm.DB) error {
  59. var err error
  60. session, err = s.MarkPaidTx(tx, sessionID, paymentEntry, paymentMethod, fee)
  61. return err
  62. })
  63. return session, err
  64. }
  65. func (s *ParkingSessionService) CloseSession(req request.CloseSessionRequest) (*dao.VehicleRecord, error) {
  66. if global.GVA_DB == nil {
  67. return nil, errors.New("数据库未初始化")
  68. }
  69. var session *dao.VehicleRecord
  70. err := global.GVA_DB.Transaction(func(tx *gorm.DB) error {
  71. var err error
  72. session, err = s.CloseSessionTx(tx, req)
  73. return err
  74. })
  75. return session, err
  76. }
  77. func (s *ParkingSessionService) ResolveActiveSession(req request.ResolveActiveSessionRequest) (*dao.VehicleRecord, error) {
  78. return s.ResolveActiveSessionTx(global.GVA_DB, req)
  79. }
  80. // ResolveActiveSessionTx resolves one active parking session deterministically.
  81. // When both identities are supplied, it refuses to pick an arbitrary session
  82. // if the plate and RFID point to different active records.
  83. func (s *ParkingSessionService) ResolveActiveSessionTx(db *gorm.DB, req request.ResolveActiveSessionRequest) (*dao.VehicleRecord, error) {
  84. if db == nil {
  85. return nil, errors.New("数据库未初始化")
  86. }
  87. if req.SessionID != 0 {
  88. session, err := s.repo.FindActiveByIDTx(db, req.SessionID)
  89. if err == nil {
  90. return session, nil
  91. }
  92. return nil, sessionLookupError(db, req.SessionID, err)
  93. }
  94. plateNumber := strings.TrimSpace(req.PlateNumber)
  95. rfidTag := strings.TrimSpace(req.RFIDTag)
  96. if plateNumber == "" && rfidTag == "" {
  97. return nil, ErrSessionIdentifierEmpty
  98. }
  99. if plateNumber != "" && rfidTag != "" {
  100. if session, err := s.repo.FindActiveByExactIdentityTx(db, plateNumber, rfidTag); err == nil {
  101. return session, nil
  102. } else if !errors.Is(err, gorm.ErrRecordNotFound) {
  103. return nil, err
  104. }
  105. }
  106. var plateSessions, rfidSessions []dao.VehicleRecord
  107. var err error
  108. if plateNumber != "" {
  109. plateSessions, err = s.repo.FindActiveByPlateTx(db, plateNumber)
  110. if err != nil {
  111. return nil, err
  112. }
  113. }
  114. if rfidTag != "" {
  115. rfidSessions, err = s.repo.FindActiveByRFIDTx(db, rfidTag)
  116. if err != nil {
  117. return nil, err
  118. }
  119. }
  120. if len(plateSessions) > 1 || len(rfidSessions) > 1 {
  121. return nil, ErrSessionAmbiguous
  122. }
  123. if len(plateSessions) == 1 && len(rfidSessions) == 1 && plateSessions[0].ID != rfidSessions[0].ID {
  124. return nil, ErrSessionIdentityConflict
  125. }
  126. if len(plateSessions) == 1 {
  127. return &plateSessions[0], nil
  128. }
  129. if len(rfidSessions) == 1 {
  130. return &rfidSessions[0], nil
  131. }
  132. return nil, ErrSessionNotFound
  133. }
  134. func sessionLookupError(db *gorm.DB, id uint, err error) error {
  135. if !errors.Is(err, gorm.ErrRecordNotFound) {
  136. return err
  137. }
  138. var session dao.VehicleRecord
  139. if lookupErr := db.Unscoped().First(&session, id).Error; lookupErr == nil {
  140. if session.ExitTime != nil {
  141. return ErrSessionAlreadyClosed
  142. }
  143. if session.PaymentStatus == PaymentStatusPaid {
  144. return ErrSessionAlreadyPaid
  145. }
  146. return ErrSessionNotFound
  147. } else if !errors.Is(lookupErr, gorm.ErrRecordNotFound) {
  148. return lookupErr
  149. }
  150. return ErrSessionNotFound
  151. }
  152. // OpenSessionTx 创建持久化停车会话。
  153. // 在调用方事务中再次查询在场会话,确保校验和写入属于同一个原子操作。
  154. func (s *ParkingSessionService) OpenSessionTx(db *gorm.DB, req request.OpenSessionRequest) (*dao.VehicleRecord, error) {
  155. if db == nil {
  156. return nil, errors.New("数据库未初始化")
  157. }
  158. req.PlateNumber = strings.TrimSpace(req.PlateNumber)
  159. req.RFIDTag = strings.TrimSpace(req.RFIDTag)
  160. if req.PlateNumber == "" && req.RFIDTag == "" {
  161. return nil, ErrSessionIdentifierEmpty
  162. }
  163. _, err := s.ResolveActiveSessionTx(db, request.ResolveActiveSessionRequest{
  164. PlateNumber: req.PlateNumber,
  165. RFIDTag: req.RFIDTag,
  166. })
  167. if err == nil {
  168. return nil, ErrSessionAlreadyOpen
  169. }
  170. if !errors.Is(err, ErrSessionNotFound) {
  171. return nil, err
  172. }
  173. entryTime := req.EntryTime
  174. if entryTime.IsZero() {
  175. entryTime = time.Now()
  176. }
  177. session := &dao.VehicleRecord{
  178. PlateNumber: req.PlateNumber,
  179. RFIDTag: req.RFIDTag,
  180. EntryTime: entryTime,
  181. ParkingLotID: req.ParkingLotID,
  182. ParkingSpaceID: req.ParkingSpaceID,
  183. EntryChannelID: req.EntryChannelID,
  184. EntryChannelCode: req.EntryChannelCode,
  185. EntryChannelName: req.EntryChannelName,
  186. EntryDeviceCode: req.EntryDeviceCode,
  187. EntryDeviceName: req.EntryDeviceName,
  188. EntryImage: req.EntryImage,
  189. PaymentStatus: PaymentStatusUnpaid,
  190. }
  191. if err := s.repo.CreateTx(db, session); err != nil {
  192. return nil, err
  193. }
  194. return session, nil
  195. }
  196. // MarkPaidTx 更新在场会话的当前结算摘要。
  197. // 实际资金流水仍以支付模块创建的 payment_record 为准。
  198. func (s *ParkingSessionService) MarkPaidTx(db *gorm.DB, sessionID uint, paymentEntry, paymentMethod string, fee float64) (*dao.VehicleRecord, error) {
  199. if db == nil {
  200. return nil, errors.New("数据库未初始化")
  201. }
  202. if sessionID == 0 {
  203. return nil, ErrSessionNotFound
  204. }
  205. if math.IsNaN(fee) || math.IsInf(fee, 0) || fee < 0 {
  206. return nil, errors.New("停车费用必须是有限的非负数")
  207. }
  208. session, err := s.ResolveActiveSessionTx(db, request.ResolveActiveSessionRequest{SessionID: sessionID})
  209. if err != nil {
  210. return nil, err
  211. }
  212. if session.PaymentStatus == PaymentStatusPaid {
  213. return nil, ErrSessionAlreadyPaid
  214. }
  215. rows, err := s.repo.UpdateUnpaidActiveTx(db, sessionID, map[string]interface{}{
  216. "payment_status": PaymentStatusPaid,
  217. "payment_entry": paymentEntry,
  218. "payment_method": paymentMethod,
  219. "fee": fee,
  220. })
  221. if err != nil {
  222. return nil, fmt.Errorf("更新停车会话支付状态失败: %w", err)
  223. }
  224. if rows != 1 {
  225. return nil, sessionLookupError(db, sessionID, gorm.ErrRecordNotFound)
  226. }
  227. session.PaymentStatus = PaymentStatusPaid
  228. session.PaymentEntry = paymentEntry
  229. session.PaymentMethod = paymentMethod
  230. session.Fee = fee
  231. return session, nil
  232. }
  233. // CloseSessionTx 只允许将一个已支付的停车会话结算出场一次。
  234. func (s *ParkingSessionService) CloseSessionTx(db *gorm.DB, req request.CloseSessionRequest) (*dao.VehicleRecord, error) {
  235. if db == nil {
  236. return nil, errors.New("数据库未初始化")
  237. }
  238. if req.SessionID == 0 {
  239. return nil, ErrSessionNotFound
  240. }
  241. if req.StayTime < 0 || math.IsNaN(req.Fee) || math.IsInf(req.Fee, 0) || req.Fee < 0 {
  242. return nil, errors.New("停车会话结算数据无效")
  243. }
  244. session, err := s.ResolveActiveSessionTx(db, request.ResolveActiveSessionRequest{SessionID: req.SessionID})
  245. if err != nil {
  246. return nil, err
  247. }
  248. exitTime := req.ExitTime
  249. if exitTime.IsZero() {
  250. exitTime = time.Now()
  251. }
  252. paymentStatus := req.PaymentStatus
  253. if paymentStatus == "" {
  254. paymentStatus = PaymentStatusPaid
  255. }
  256. updates := map[string]interface{}{
  257. "exit_time": exitTime,
  258. "stay_time": req.StayTime,
  259. "fee": req.Fee,
  260. "payment_status": paymentStatus,
  261. "payment_entry": req.PaymentEntry,
  262. "payment_method": req.PaymentMethod,
  263. }
  264. if req.ExitImage != "" {
  265. updates["exit_image"] = req.ExitImage
  266. }
  267. if req.ExitChannelID != 0 {
  268. updates["exit_channel_id"] = req.ExitChannelID
  269. }
  270. if req.ExitChannelCode != "" {
  271. updates["exit_channel_code"] = req.ExitChannelCode
  272. }
  273. if req.ExitChannelName != "" {
  274. updates["exit_channel_name"] = req.ExitChannelName
  275. }
  276. if req.ExitDeviceCode != "" {
  277. updates["exit_device_code"] = req.ExitDeviceCode
  278. }
  279. if req.ExitDeviceName != "" {
  280. updates["exit_device_name"] = req.ExitDeviceName
  281. }
  282. rows, err := s.repo.UpdateActiveTx(db, req.SessionID, updates)
  283. if err != nil {
  284. return nil, fmt.Errorf("关闭停车会话失败: %w", err)
  285. }
  286. if rows != 1 {
  287. return nil, sessionLookupError(db, req.SessionID, gorm.ErrRecordNotFound)
  288. }
  289. session.ExitTime = &exitTime
  290. session.StayTime = req.StayTime
  291. session.Fee = req.Fee
  292. session.PaymentStatus = paymentStatus
  293. session.PaymentEntry = req.PaymentEntry
  294. session.PaymentMethod = req.PaymentMethod
  295. if req.ExitImage != "" {
  296. session.ExitImage = req.ExitImage
  297. }
  298. if req.ExitChannelID != 0 {
  299. session.ExitChannelID = req.ExitChannelID
  300. }
  301. if req.ExitChannelCode != "" {
  302. session.ExitChannelCode = req.ExitChannelCode
  303. }
  304. if req.ExitChannelName != "" {
  305. session.ExitChannelName = req.ExitChannelName
  306. }
  307. if req.ExitDeviceCode != "" {
  308. session.ExitDeviceCode = req.ExitDeviceCode
  309. }
  310. if req.ExitDeviceName != "" {
  311. session.ExitDeviceName = req.ExitDeviceName
  312. }
  313. return session, nil
  314. }