service.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414
  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. ErrParkingLotNotFound = errors.New("停车场不存在")
  27. ErrParkingLotFull = errors.New("停车场车位已满")
  28. )
  29. // ParkingSessionService owns the parking-stay lifecycle. It deliberately
  30. // persists to vehicle_record so existing reports and historical data remain
  31. // compatible while callers stop treating the record as an unstructured table.
  32. type ParkingSessionService struct {
  33. repo *repository.ParkingSessionRepository
  34. }
  35. func NewParkingSessionService() *ParkingSessionService {
  36. return &ParkingSessionService{repo: &repository.ParkingSessionRepository{}}
  37. }
  38. // FindActiveSession is the domain-facing name used by adapters that do not
  39. // need to know whether the lookup is backed by a transaction.
  40. func (s *ParkingSessionService) FindActiveSession(req request.ResolveActiveSessionRequest) (*dao.VehicleRecord, error) {
  41. return s.ResolveActiveSession(req)
  42. }
  43. func (s *ParkingSessionService) OpenSession(req request.OpenSessionRequest) (*dao.VehicleRecord, error) {
  44. if global.GVA_DB == nil {
  45. return nil, errors.New("数据库未初始化")
  46. }
  47. var session *dao.VehicleRecord
  48. err := global.GVA_DB.Transaction(func(tx *gorm.DB) error {
  49. var err error
  50. session, err = s.OpenSessionTx(tx, req)
  51. return err
  52. })
  53. return session, err
  54. }
  55. func (s *ParkingSessionService) MarkPaid(sessionID uint, paymentEntry, paymentMethod string, fee float64) (*dao.VehicleRecord, error) {
  56. if global.GVA_DB == nil {
  57. return nil, errors.New("数据库未初始化")
  58. }
  59. var session *dao.VehicleRecord
  60. err := global.GVA_DB.Transaction(func(tx *gorm.DB) error {
  61. var err error
  62. session, err = s.MarkPaidTx(tx, sessionID, paymentEntry, paymentMethod, fee)
  63. return err
  64. })
  65. return session, err
  66. }
  67. func (s *ParkingSessionService) CloseSession(req request.CloseSessionRequest) (*dao.VehicleRecord, error) {
  68. if global.GVA_DB == nil {
  69. return nil, errors.New("数据库未初始化")
  70. }
  71. var session *dao.VehicleRecord
  72. err := global.GVA_DB.Transaction(func(tx *gorm.DB) error {
  73. var err error
  74. session, err = s.CloseSessionTx(tx, req)
  75. return err
  76. })
  77. return session, err
  78. }
  79. func (s *ParkingSessionService) ResolveActiveSession(req request.ResolveActiveSessionRequest) (*dao.VehicleRecord, error) {
  80. return s.ResolveActiveSessionTx(global.GVA_DB, req)
  81. }
  82. // ResolveActiveSessionTx resolves one active parking session deterministically.
  83. // When both identities are supplied, it refuses to pick an arbitrary session
  84. // if the plate and RFID point to different active records.
  85. func (s *ParkingSessionService) ResolveActiveSessionTx(db *gorm.DB, req request.ResolveActiveSessionRequest) (*dao.VehicleRecord, error) {
  86. if db == nil {
  87. return nil, errors.New("数据库未初始化")
  88. }
  89. if req.SessionID != 0 {
  90. session, err := s.repo.FindActiveByIDTx(db, req.SessionID)
  91. if err == nil {
  92. return session, nil
  93. }
  94. return nil, sessionLookupError(db, req.SessionID, err)
  95. }
  96. plateNumber := strings.TrimSpace(req.PlateNumber)
  97. rfidTag := strings.TrimSpace(req.RFIDTag)
  98. if plateNumber == "" && rfidTag == "" {
  99. return nil, ErrSessionIdentifierEmpty
  100. }
  101. if plateNumber != "" && rfidTag != "" {
  102. if session, err := s.repo.FindActiveByExactIdentityTx(db, plateNumber, rfidTag); err == nil {
  103. return session, nil
  104. } else if !errors.Is(err, gorm.ErrRecordNotFound) {
  105. return nil, err
  106. }
  107. }
  108. var plateSessions, rfidSessions []dao.VehicleRecord
  109. var err error
  110. if plateNumber != "" {
  111. plateSessions, err = s.repo.FindActiveByPlateTx(db, plateNumber)
  112. if err != nil {
  113. return nil, err
  114. }
  115. }
  116. if rfidTag != "" {
  117. rfidSessions, err = s.repo.FindActiveByRFIDTx(db, rfidTag)
  118. if err != nil {
  119. return nil, err
  120. }
  121. }
  122. if len(plateSessions) > 1 || len(rfidSessions) > 1 {
  123. return nil, ErrSessionAmbiguous
  124. }
  125. if len(plateSessions) == 1 && len(rfidSessions) == 1 && plateSessions[0].ID != rfidSessions[0].ID {
  126. return nil, ErrSessionIdentityConflict
  127. }
  128. if len(plateSessions) == 1 {
  129. return &plateSessions[0], nil
  130. }
  131. if len(rfidSessions) == 1 {
  132. return &rfidSessions[0], nil
  133. }
  134. return nil, ErrSessionNotFound
  135. }
  136. func sessionLookupError(db *gorm.DB, id uint, err error) error {
  137. if !errors.Is(err, gorm.ErrRecordNotFound) {
  138. return err
  139. }
  140. var session dao.VehicleRecord
  141. if lookupErr := db.Unscoped().First(&session, id).Error; lookupErr == nil {
  142. if session.ExitTime != nil {
  143. return ErrSessionAlreadyClosed
  144. }
  145. if session.PaymentStatus == PaymentStatusPaid {
  146. return ErrSessionAlreadyPaid
  147. }
  148. return ErrSessionNotFound
  149. } else if !errors.Is(lookupErr, gorm.ErrRecordNotFound) {
  150. return lookupErr
  151. }
  152. return ErrSessionNotFound
  153. }
  154. // OpenSessionTx 创建持久化停车会话。
  155. // 在调用方事务中再次查询在场会话,确保校验和写入属于同一个原子操作。
  156. func (s *ParkingSessionService) OpenSessionTx(db *gorm.DB, req request.OpenSessionRequest) (*dao.VehicleRecord, error) {
  157. if db == nil {
  158. return nil, errors.New("数据库未初始化")
  159. }
  160. req.PlateNumber = strings.TrimSpace(req.PlateNumber)
  161. req.RFIDTag = strings.TrimSpace(req.RFIDTag)
  162. if req.PlateNumber == "" && req.RFIDTag == "" {
  163. return nil, ErrSessionIdentifierEmpty
  164. }
  165. _, err := s.ResolveActiveSessionTx(db, request.ResolveActiveSessionRequest{
  166. PlateNumber: req.PlateNumber,
  167. RFIDTag: req.RFIDTag,
  168. })
  169. if err == nil {
  170. return nil, ErrSessionAlreadyOpen
  171. }
  172. if !errors.Is(err, ErrSessionNotFound) {
  173. return nil, err
  174. }
  175. if err := reserveParkingSpaceTx(db, req.ParkingLotID, req.AccessProfile); err != nil {
  176. return nil, err
  177. }
  178. entryTime := req.EntryTime
  179. if entryTime.IsZero() {
  180. entryTime = time.Now()
  181. }
  182. session := &dao.VehicleRecord{
  183. PlateNumber: req.PlateNumber,
  184. RFIDTag: req.RFIDTag,
  185. EntryTime: entryTime,
  186. ParkingLotID: req.ParkingLotID,
  187. ParkingSpaceID: req.ParkingSpaceID,
  188. EntryChannelID: req.EntryChannelID,
  189. EntryChannelCode: req.EntryChannelCode,
  190. EntryChannelName: req.EntryChannelName,
  191. EntryDeviceCode: req.EntryDeviceCode,
  192. EntryDeviceName: req.EntryDeviceName,
  193. EntryImage: req.EntryImage,
  194. PaymentStatus: PaymentStatusUnpaid,
  195. }
  196. if err := s.repo.CreateTx(db, session); err != nil {
  197. return nil, err
  198. }
  199. return session, nil
  200. }
  201. // reserveParkingSpaceTx 在创建会话前原子增加停车场占用数。
  202. // 满位时只有停车场明确允许的车辆身份可以继续入场。
  203. func reserveParkingSpaceTx(db *gorm.DB, parkingLotID uint, profile request.EntryAccessProfile) error {
  204. if parkingLotID == 0 {
  205. return nil
  206. }
  207. result := db.Model(&dao.ParkingLot{}).
  208. Where(`id = ? AND (
  209. occupied < capacity OR
  210. (? AND allow_temporary_when_full) OR
  211. (? AND allow_monthly_when_full) OR
  212. (? AND allow_vip_when_full)
  213. )`, parkingLotID, profile.IsTemporary, profile.HasValidMonthlyCard, profile.IsVIP).
  214. UpdateColumns(map[string]interface{}{
  215. "occupied": gorm.Expr("occupied + 1"),
  216. "available": gorm.Expr("CASE WHEN capacity > occupied + 1 THEN capacity - occupied - 1 ELSE 0 END"),
  217. })
  218. if result.Error != nil {
  219. return fmt.Errorf("更新停车场占用数失败: %w", result.Error)
  220. }
  221. if result.RowsAffected == 1 {
  222. return nil
  223. }
  224. var lot dao.ParkingLot
  225. if err := db.First(&lot, parkingLotID).Error; err != nil {
  226. if errors.Is(err, gorm.ErrRecordNotFound) {
  227. return ErrParkingLotNotFound
  228. }
  229. return err
  230. }
  231. if lot.Occupied < lot.Capacity {
  232. return errors.New("停车场车位占用状态更新失败")
  233. }
  234. switch {
  235. case profile.IsVIP && !lot.AllowVIPWhenFull:
  236. return fmt.Errorf("%w,当前停车场不允许 VIP 车满位入场", ErrParkingLotFull)
  237. case profile.HasValidMonthlyCard && !lot.AllowMonthlyWhenFull:
  238. return fmt.Errorf("%w,当前停车场不允许月租车满位入场", ErrParkingLotFull)
  239. case profile.IsTemporary && !lot.AllowTemporaryWhenFull:
  240. return fmt.Errorf("%w,当前停车场不允许临时车满位入场", ErrParkingLotFull)
  241. default:
  242. return ErrParkingLotFull
  243. }
  244. }
  245. // releaseParkingSpaceTx 在会话关闭后释放占用,并根据实际占用数重新计算余位。
  246. func releaseParkingSpaceTx(db *gorm.DB, parkingLotID uint) error {
  247. if parkingLotID == 0 {
  248. return nil
  249. }
  250. result := db.Model(&dao.ParkingLot{}).Where("id = ?", parkingLotID).
  251. UpdateColumn("occupied", gorm.Expr("CASE WHEN occupied > 0 THEN occupied - 1 ELSE 0 END"))
  252. if result.Error != nil {
  253. return fmt.Errorf("释放停车场占用数失败: %w", result.Error)
  254. }
  255. if result.RowsAffected != 1 {
  256. return ErrParkingLotNotFound
  257. }
  258. if err := db.Model(&dao.ParkingLot{}).Where("id = ?", parkingLotID).
  259. UpdateColumn("available", gorm.Expr("CASE WHEN capacity > occupied THEN capacity - occupied ELSE 0 END")).Error; err != nil {
  260. return fmt.Errorf("更新停车场余位失败: %w", err)
  261. }
  262. return nil
  263. }
  264. // MarkPaidTx 更新在场会话的当前结算摘要。
  265. // 实际资金流水仍以支付模块创建的 payment_record 为准。
  266. func (s *ParkingSessionService) MarkPaidTx(db *gorm.DB, sessionID uint, paymentEntry, paymentMethod string, fee float64) (*dao.VehicleRecord, error) {
  267. if db == nil {
  268. return nil, errors.New("数据库未初始化")
  269. }
  270. if sessionID == 0 {
  271. return nil, ErrSessionNotFound
  272. }
  273. if math.IsNaN(fee) || math.IsInf(fee, 0) || fee < 0 {
  274. return nil, errors.New("停车费用必须是有限的非负数")
  275. }
  276. session, err := s.ResolveActiveSessionTx(db, request.ResolveActiveSessionRequest{SessionID: sessionID})
  277. if err != nil {
  278. return nil, err
  279. }
  280. if session.PaymentStatus == PaymentStatusPaid {
  281. return nil, ErrSessionAlreadyPaid
  282. }
  283. rows, err := s.repo.UpdateUnpaidActiveTx(db, sessionID, map[string]interface{}{
  284. "payment_status": PaymentStatusPaid,
  285. "payment_entry": paymentEntry,
  286. "payment_method": paymentMethod,
  287. "fee": fee,
  288. })
  289. if err != nil {
  290. return nil, fmt.Errorf("更新停车会话支付状态失败: %w", err)
  291. }
  292. if rows != 1 {
  293. return nil, sessionLookupError(db, sessionID, gorm.ErrRecordNotFound)
  294. }
  295. session.PaymentStatus = PaymentStatusPaid
  296. session.PaymentEntry = paymentEntry
  297. session.PaymentMethod = paymentMethod
  298. session.Fee = fee
  299. return session, nil
  300. }
  301. // CloseSessionTx 只允许将一个已支付的停车会话结算出场一次。
  302. func (s *ParkingSessionService) CloseSessionTx(db *gorm.DB, req request.CloseSessionRequest) (*dao.VehicleRecord, error) {
  303. if db == nil {
  304. return nil, errors.New("数据库未初始化")
  305. }
  306. if req.SessionID == 0 {
  307. return nil, ErrSessionNotFound
  308. }
  309. if req.StayTime < 0 || math.IsNaN(req.Fee) || math.IsInf(req.Fee, 0) || req.Fee < 0 {
  310. return nil, errors.New("停车会话结算数据无效")
  311. }
  312. session, err := s.ResolveActiveSessionTx(db, request.ResolveActiveSessionRequest{SessionID: req.SessionID})
  313. if err != nil {
  314. return nil, err
  315. }
  316. exitTime := req.ExitTime
  317. if exitTime.IsZero() {
  318. exitTime = time.Now()
  319. }
  320. paymentStatus := req.PaymentStatus
  321. if paymentStatus == "" {
  322. paymentStatus = PaymentStatusPaid
  323. }
  324. updates := map[string]interface{}{
  325. "exit_time": exitTime,
  326. "stay_time": req.StayTime,
  327. "fee": req.Fee,
  328. "payment_status": paymentStatus,
  329. "payment_entry": req.PaymentEntry,
  330. "payment_method": req.PaymentMethod,
  331. }
  332. if req.ExitImage != "" {
  333. updates["exit_image"] = req.ExitImage
  334. }
  335. if req.ExitChannelID != 0 {
  336. updates["exit_channel_id"] = req.ExitChannelID
  337. }
  338. if req.ExitChannelCode != "" {
  339. updates["exit_channel_code"] = req.ExitChannelCode
  340. }
  341. if req.ExitChannelName != "" {
  342. updates["exit_channel_name"] = req.ExitChannelName
  343. }
  344. if req.ExitDeviceCode != "" {
  345. updates["exit_device_code"] = req.ExitDeviceCode
  346. }
  347. if req.ExitDeviceName != "" {
  348. updates["exit_device_name"] = req.ExitDeviceName
  349. }
  350. rows, err := s.repo.UpdateActiveTx(db, req.SessionID, updates)
  351. if err != nil {
  352. return nil, fmt.Errorf("关闭停车会话失败: %w", err)
  353. }
  354. if rows != 1 {
  355. return nil, sessionLookupError(db, req.SessionID, gorm.ErrRecordNotFound)
  356. }
  357. if err := releaseParkingSpaceTx(db, session.ParkingLotID); err != nil {
  358. return nil, err
  359. }
  360. session.ExitTime = &exitTime
  361. session.StayTime = req.StayTime
  362. session.Fee = req.Fee
  363. session.PaymentStatus = paymentStatus
  364. session.PaymentEntry = req.PaymentEntry
  365. session.PaymentMethod = req.PaymentMethod
  366. if req.ExitImage != "" {
  367. session.ExitImage = req.ExitImage
  368. }
  369. if req.ExitChannelID != 0 {
  370. session.ExitChannelID = req.ExitChannelID
  371. }
  372. if req.ExitChannelCode != "" {
  373. session.ExitChannelCode = req.ExitChannelCode
  374. }
  375. if req.ExitChannelName != "" {
  376. session.ExitChannelName = req.ExitChannelName
  377. }
  378. if req.ExitDeviceCode != "" {
  379. session.ExitDeviceCode = req.ExitDeviceCode
  380. }
  381. if req.ExitDeviceName != "" {
  382. session.ExitDeviceName = req.ExitDeviceName
  383. }
  384. return session, nil
  385. }