vehicle.go 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872
  1. package vehicle
  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/model/vehicle/request"
  11. vehicleResponse "wails-app/internal/model/vehicle/response"
  12. ticketService "wails-app/internal/modules/digital-ticket/service"
  13. sessionRequest "wails-app/internal/modules/parking-session/model/request"
  14. sessionService "wails-app/internal/modules/parking-session/service"
  15. paymentService "wails-app/internal/modules/payment/service"
  16. "gorm.io/gorm"
  17. )
  18. type VehicleService struct{}
  19. // buildEntryAccessProfileTx 计算车辆在停车场满位时使用的准入身份。
  20. func buildEntryAccessProfileTx(tx *gorm.DB, vehicle *dao.Vehicle, at time.Time) (sessionRequest.EntryAccessProfile, error) {
  21. profile := sessionRequest.EntryAccessProfile{}
  22. if vehicle == nil || vehicle.ID == 0 {
  23. profile.IsTemporary = true
  24. return profile, nil
  25. }
  26. if vehicle.VehicleType != nil {
  27. profile.IsTemporary = vehicle.VehicleType.IsSystem
  28. } else {
  29. var vehicleType dao.VehicleType
  30. if err := tx.First(&vehicleType, vehicle.VehicleTypeID).Error; err != nil {
  31. return profile, err
  32. }
  33. profile.IsTemporary = vehicleType.IsSystem
  34. }
  35. if vehicle.OwnerId != nil {
  36. var owner dao.Owner
  37. if err := tx.First(&owner, *vehicle.OwnerId).Error; err == nil {
  38. profile.IsVIP = owner.IsVip && !owner.VipExpireTime.IsZero() && owner.VipExpireTime.After(at)
  39. } else if !errors.Is(err, gorm.ErrRecordNotFound) {
  40. return profile, err
  41. }
  42. }
  43. var monthlyCount int64
  44. if err := tx.Model(&dao.MonthlyCard{}).
  45. Where("vehicle_id = ? AND payment_status = ? AND start_date <= ? AND end_date > ?", vehicle.ID, "paid", at, at).
  46. Count(&monthlyCount).Error; err != nil {
  47. return profile, err
  48. }
  49. profile.HasValidMonthlyCard = monthlyCount > 0
  50. return profile, nil
  51. }
  52. // buildPlateOrRfidQuery 按车牌或RFID查询,RFID为空时仅用车牌(避免 rfid_tag=” 匹配所有无标签车辆)
  53. func buildPlateOrRfidQuery(db *gorm.DB, plateNumber, rfidTag string) *gorm.DB {
  54. if rfidTag != "" {
  55. return db.Where("plate_number = ? OR rfid_tag = ?", plateNumber, rfidTag)
  56. }
  57. return db.Where("plate_number = ?", plateNumber)
  58. }
  59. // getVehicleForSessionTx 根据停车会话中保存的车辆身份定位车辆档案。
  60. // 优先同时匹配车牌和 RFID;若历史会话未完整保存 RFID,则依次按 RFID、车牌降级查询。
  61. func getVehicleForSessionTx(db *gorm.DB, session *dao.VehicleRecord) (dao.Vehicle, error) {
  62. if db == nil || session == nil {
  63. return dao.Vehicle{}, errors.New("车辆会话不存在")
  64. }
  65. var vehicle dao.Vehicle
  66. if session.PlateNumber != "" && session.RFIDTag != "" {
  67. result := db.Preload("VehicleType").Preload("Owner").
  68. Where("plate_number = ? AND rfid_tag = ?", session.PlateNumber, session.RFIDTag).
  69. First(&vehicle)
  70. if result.Error == nil {
  71. return vehicle, nil
  72. }
  73. if !errors.Is(result.Error, gorm.ErrRecordNotFound) {
  74. return dao.Vehicle{}, result.Error
  75. }
  76. }
  77. if session.RFIDTag != "" {
  78. result := db.Preload("VehicleType").Preload("Owner").Where("rfid_tag = ?", session.RFIDTag).First(&vehicle)
  79. if result.Error == nil {
  80. return vehicle, nil
  81. }
  82. if !errors.Is(result.Error, gorm.ErrRecordNotFound) {
  83. return dao.Vehicle{}, result.Error
  84. }
  85. }
  86. if session.PlateNumber != "" {
  87. result := db.Preload("VehicleType").Preload("Owner").Where("plate_number = ?", session.PlateNumber).First(&vehicle)
  88. if result.Error == nil {
  89. return vehicle, nil
  90. }
  91. if !errors.Is(result.Error, gorm.ErrRecordNotFound) {
  92. return dao.Vehicle{}, result.Error
  93. }
  94. }
  95. return dao.Vehicle{}, errors.New("车辆不存在")
  96. }
  97. // CreateVehicle 创建车辆信息
  98. func (s *VehicleService) CreateVehicle(req request.VehicleCreate) error {
  99. // 检查车牌号是否已存在
  100. var existingVehicle dao.Vehicle
  101. result := buildPlateOrRfidQuery(global.GVA_DB, req.PlateNumber, req.RFIDTag).First(&existingVehicle)
  102. if result.RowsAffected > 0 {
  103. return errors.New("车牌号已存在")
  104. }
  105. // 检查车辆类型是否存在
  106. var vehicleType dao.VehicleType
  107. result = global.GVA_DB.First(&vehicleType, req.VehicleTypeID)
  108. if result.RowsAffected == 0 {
  109. return errors.New("车辆类型不存在")
  110. }
  111. // 处理临时车辆的情况
  112. var ownerId *uint
  113. if req.OwnerId > 0 {
  114. ownerId = &req.OwnerId
  115. }
  116. vehicle := dao.Vehicle{
  117. PlateNumber: req.PlateNumber,
  118. RFIDTag: req.RFIDTag,
  119. VehicleTypeID: req.VehicleTypeID,
  120. VehicleBrand: req.VehicleBrand,
  121. VehicleColor: req.VehicleColor,
  122. OwnerId: ownerId,
  123. }
  124. return global.GVA_DB.Create(&vehicle).Error
  125. }
  126. // UpdateVehicle 更新车辆信息
  127. func (s *VehicleService) UpdateVehicle(req request.VehicleUpdate) error {
  128. var vehicle dao.Vehicle
  129. result := global.GVA_DB.First(&vehicle, req.ID)
  130. if result.RowsAffected == 0 {
  131. return errors.New("车辆不存在")
  132. }
  133. // 检查车牌号是否被其他车辆使用
  134. var existingVehicle dao.Vehicle
  135. result = buildPlateOrRfidQuery(global.GVA_DB, req.PlateNumber, req.RFIDTag).Where("id != ?", req.ID).First(&existingVehicle)
  136. fmt.Println("result", result.RowsAffected)
  137. if result.RowsAffected > 0 {
  138. return errors.New("车牌号已被其他车辆使用")
  139. }
  140. // 检查车辆类型是否存在
  141. var vehicleType dao.VehicleType
  142. result = global.GVA_DB.First(&vehicleType, req.VehicleTypeID)
  143. if result.RowsAffected == 0 {
  144. return errors.New("车辆类型不存在")
  145. }
  146. // 处理临时车辆的情况
  147. var ownerId *uint
  148. if req.OwnerId > 0 {
  149. ownerId = &req.OwnerId
  150. } else {
  151. ownerId = nil
  152. }
  153. err := global.GVA_DB.Model(&vehicle).Updates(map[string]interface{}{
  154. "plate_number": req.PlateNumber,
  155. "rfid_tag": req.RFIDTag,
  156. "vehicle_type_id": req.VehicleTypeID,
  157. "vehicle_brand": req.VehicleBrand,
  158. "vehicle_color": req.VehicleColor,
  159. "owner_id": ownerId,
  160. }).Error
  161. return err
  162. }
  163. // GetVehicleByID 根据ID获取车辆信息
  164. func (s *VehicleService) GetVehicleByID(id uint) (dao.Vehicle, error) {
  165. var vehicle dao.Vehicle
  166. result := global.GVA_DB.First(&vehicle, id)
  167. if result.RowsAffected == 0 {
  168. return dao.Vehicle{}, errors.New("车辆不存在")
  169. }
  170. return vehicle, nil
  171. }
  172. // GetVehicleByPlateNumber 根据车牌号或RFID获取车辆信息
  173. func (s *VehicleService) GetVehicleByPlateNumber(plateNumber, RFIDTag string) (dao.Vehicle, error) {
  174. var vehicle dao.Vehicle
  175. if plateNumber != "" && RFIDTag != "" {
  176. exactQuery := global.GVA_DB.Preload("VehicleType").Preload("Owner").Where("plate_number = ? AND rfid_tag = ?", plateNumber, RFIDTag)
  177. if result := exactQuery.First(&vehicle); result.Error == nil {
  178. return vehicle, nil
  179. }
  180. }
  181. query := global.GVA_DB.Preload("VehicleType").Preload("Owner")
  182. if RFIDTag != "" {
  183. query = query.Where("plate_number = ? OR rfid_tag = ?", plateNumber, RFIDTag)
  184. } else {
  185. query = query.Where("plate_number = ?", plateNumber)
  186. }
  187. result := query.First(&vehicle)
  188. if result.RowsAffected == 0 {
  189. return dao.Vehicle{}, errors.New("车辆不存在")
  190. }
  191. return vehicle, nil
  192. }
  193. func (s *VehicleService) QueryAllVehicles() ([]dao.Vehicle, error) {
  194. var vehicles []dao.Vehicle
  195. err := global.GVA_DB.Find(&vehicles).Error
  196. if err != nil {
  197. return vehicles, err
  198. }
  199. return vehicles, nil
  200. }
  201. // ListVehicles 获取车辆列表
  202. func (s *VehicleService) ListVehicles(req request.VehicleQuery) (int64, []dao.Vehicle, error) {
  203. var vehicles []dao.Vehicle
  204. var total int64
  205. query := global.GVA_DB.Model(&dao.Vehicle{})
  206. if req.PlateNumber != "" {
  207. query = query.Where("plate_number LIKE ?", "%"+req.PlateNumber+"%")
  208. }
  209. if req.VehicleTypeID > 0 {
  210. query = query.Where("vehicle_type_id = ?", req.VehicleTypeID)
  211. }
  212. if req.OwnerId > 0 {
  213. query = query.Where("owner_id = ?", req.OwnerId)
  214. }
  215. // 计算总数
  216. query.Count(&total)
  217. // 分页查询
  218. offset := (req.Page - 1) * req.PageSize
  219. result := query.Offset(offset).Limit(req.PageSize).Preload("Owner").Preload("VehicleType").Find(&vehicles)
  220. if result.Error != nil {
  221. return 0, nil, result.Error
  222. }
  223. return total, vehicles, nil
  224. }
  225. // DeleteVehicle 删除车辆
  226. func (s *VehicleService) DeleteVehicle(id uint) error {
  227. result := global.GVA_DB.Delete(&dao.Vehicle{}, id)
  228. if result.RowsAffected == 0 {
  229. return errors.New("车辆不存在")
  230. }
  231. return result.Error
  232. }
  233. // VehicleEntry 车辆入场
  234. func (s *VehicleService) VehicleEntry(req request.VehicleEntry) (request.VehicleEntryResponse, error) {
  235. // 黑名单检查
  236. var shortlistSvc ShortlistService
  237. if isBlack, _ := shortlistSvc.CheckVehicleShortlist(req.PlateNumber, req.RFIDTag); isBlack {
  238. return request.VehicleEntryResponse{}, errors.New("车辆在黑名单中,禁止入场")
  239. }
  240. // 检查车辆是否存在
  241. vehicle, err := s.GetVehicleByPlateNumber(req.PlateNumber, req.RFIDTag)
  242. if err != nil {
  243. // 如果车辆不存在,创建新车辆,自动归类为"临时车"
  244. var tempType dao.VehicleType
  245. if global.GVA_DB.Where("is_system = ?", true).First(&tempType).Error != nil {
  246. return request.VehicleEntryResponse{}, errors.New("系统错误:临时车类型未配置")
  247. }
  248. createReq := request.VehicleCreate{
  249. PlateNumber: req.PlateNumber,
  250. RFIDTag: req.RFIDTag,
  251. VehicleTypeID: tempType.ID,
  252. OwnerId: 0,
  253. }
  254. err = s.CreateVehicle(createReq)
  255. if err != nil {
  256. return request.VehicleEntryResponse{}, err
  257. }
  258. vehicle, _ = s.GetVehicleByPlateNumber(req.PlateNumber, req.RFIDTag)
  259. }
  260. // 在同一事务中创建停车会话和数字票,避免出现只有会话或只有数字票的不完整入场数据。
  261. entryTime := time.Now()
  262. plateNumber := vehicle.PlateNumber
  263. if plateNumber == "" {
  264. plateNumber = req.PlateNumber
  265. }
  266. rfidTag := req.RFIDTag
  267. if rfidTag == "" {
  268. rfidTag = vehicle.RFIDTag
  269. }
  270. var record *dao.VehicleRecord
  271. var ticket *dao.DigitalTicket
  272. ticketSvc := ticketService.NewTicketService()
  273. sessionSvc := sessionService.NewParkingSessionService()
  274. triggerMode := strings.TrimSpace(req.TriggerMode)
  275. if triggerMode == "" {
  276. triggerMode = "manual"
  277. }
  278. if req.RFIDTag != "" && req.TriggerMode == "" {
  279. triggerMode = "rfid"
  280. }
  281. err = global.GVA_DB.Transaction(func(tx *gorm.DB) error {
  282. accessProfile, profileErr := buildEntryAccessProfileTx(tx, &vehicle, entryTime)
  283. if profileErr != nil {
  284. return fmt.Errorf("识别车辆满位准入身份失败: %w", profileErr)
  285. }
  286. var err error
  287. record, err = sessionSvc.OpenSessionTx(tx, sessionRequest.OpenSessionRequest{
  288. PlateNumber: plateNumber,
  289. RFIDTag: rfidTag,
  290. ParkingLotID: req.ParkingLotID,
  291. ParkingSpaceID: req.ParkingSpaceID,
  292. EntryChannelID: req.EntryChannelID,
  293. EntryChannelCode: req.EntryChannelCode,
  294. EntryChannelName: req.EntryChannelName,
  295. EntryDeviceCode: req.EntryDeviceCode,
  296. EntryDeviceName: req.EntryDeviceName,
  297. EntryImage: req.EntryImage,
  298. EntryTime: entryTime,
  299. AccessProfile: accessProfile,
  300. })
  301. if err != nil {
  302. return err
  303. }
  304. ticket, err = ticketSvc.CreateTx(tx, plateNumber, triggerMode, record.ID)
  305. if err != nil {
  306. return fmt.Errorf("创建数字票失败: %w", err)
  307. }
  308. return tx.Model(&dao.Vehicle{}).Where("id = ?", vehicle.ID).
  309. Update("last_entry_time", entryTime).Error
  310. })
  311. if err != nil {
  312. return request.VehicleEntryResponse{}, err
  313. }
  314. response := request.VehicleEntryResponse{
  315. Success: true,
  316. Message: "车辆入场成功",
  317. }
  318. response.Data.SessionID = record.ID
  319. response.Data.TicketID = ticket.ID
  320. response.Data.TicketNo = ticket.TicketNo
  321. response.Data.PlateNumber = plateNumber
  322. response.Data.EntryTime = record.EntryTime.Unix()
  323. response.Data.ParkingLotID = req.ParkingLotID
  324. response.Data.ParkingSpaceID = req.ParkingSpaceID
  325. response.Data.EntryChannelID = record.EntryChannelID
  326. response.Data.EntryChannelCode = record.EntryChannelCode
  327. response.Data.EntryChannelName = record.EntryChannelName
  328. response.Data.EntryDeviceCode = record.EntryDeviceCode
  329. response.Data.EntryDeviceName = record.EntryDeviceName
  330. return response, nil
  331. }
  332. func (s *VehicleService) buildExitPreview(record *dao.VehicleRecord) (vehicleResponse.ExitPreviewResponse, error) {
  333. if record == nil {
  334. return vehicleResponse.ExitPreviewResponse{}, errors.New("停车会话不存在")
  335. }
  336. ticketSvc := ticketService.NewTicketService()
  337. ticket, err := ticketSvc.GetByVehicleRecordIDTx(global.GVA_DB, record.ID)
  338. if err != nil {
  339. return vehicleResponse.ExitPreviewResponse{}, errors.New("停车会话对应的数字票不存在")
  340. }
  341. vehicle, err := getVehicleForSessionTx(global.GVA_DB, record)
  342. if err != nil {
  343. return vehicleResponse.ExitPreviewResponse{}, err
  344. }
  345. stayTime := int64(math.Ceil(time.Since(record.EntryTime).Minutes()))
  346. if stayTime < 0 {
  347. stayTime = 0
  348. }
  349. var shortlistSvc ShortlistService
  350. _, isWhite := shortlistSvc.CheckVehicleShortlist(record.PlateNumber, record.RFIDTag)
  351. fee := 0.0
  352. var overtime bool
  353. var overtimeFee, overtimeDiff float64
  354. if ticket.State == ticketService.StatePaid {
  355. fee = ticket.Fee
  356. // 缴费后免费离场时限:超时按当前时长重算,供前端提示补缴
  357. overtime, overtimeFee, overtimeDiff, err = s.paidTicketOvertime(&vehicle, ticket, stayTime)
  358. if err != nil {
  359. return vehicleResponse.ExitPreviewResponse{}, err
  360. }
  361. if overtime {
  362. fee = overtimeFee
  363. }
  364. } else if !isWhite {
  365. fee, err = s.calculateFee(&vehicle, stayTime)
  366. if err != nil {
  367. return vehicleResponse.ExitPreviewResponse{}, err
  368. }
  369. }
  370. typeName := ""
  371. isTemp := false
  372. if vehicle.VehicleType != nil {
  373. typeName = vehicle.VehicleType.Name
  374. isTemp = vehicle.VehicleType.IsSystem
  375. }
  376. plateNumber := record.PlateNumber
  377. if plateNumber == "" {
  378. plateNumber = vehicle.PlateNumber
  379. }
  380. return vehicleResponse.ExitPreviewResponse{
  381. SessionID: record.ID,
  382. TicketNo: ticket.TicketNo,
  383. TicketState: ticket.State,
  384. PlateNumber: plateNumber,
  385. RFIDTag: record.RFIDTag,
  386. EntryTime: record.EntryTime.Unix(),
  387. StayTime: stayTime,
  388. Fee: fee,
  389. PaidAmount: ticket.PaidAmount,
  390. PaymentStatus: record.PaymentStatus,
  391. PaymentEntry: ticket.PaymentEntry,
  392. PaymentMethod: ticket.PaymentMethod,
  393. ParkingLotID: record.ParkingLotID,
  394. EntryImage: record.EntryImage,
  395. EntryChannelID: record.EntryChannelID,
  396. EntryChannelCode: record.EntryChannelCode,
  397. EntryChannelName: record.EntryChannelName,
  398. EntryDeviceCode: record.EntryDeviceCode,
  399. EntryDeviceName: record.EntryDeviceName,
  400. VehicleType: typeName,
  401. IsTemp: isTemp,
  402. Overtime: overtime,
  403. OvertimeFee: overtimeFee,
  404. OvertimeDiff: overtimeDiff,
  405. }, nil
  406. }
  407. // paidTicketOvertime 判定已支付票是否超过"缴费后免费离场时限":
  408. // 未超时返回 (false, 0, 0, nil);超时返回 (true, 按当前时长重算的总费用, 需补缴差额, nil)。
  409. // FreeExitMinutes<=0(不限)或 PaidAt 为空(历史票)视为未超时。
  410. func (s *VehicleService) paidTicketOvertime(vehicle *dao.Vehicle, ticket *dao.DigitalTicket, stayTime int64) (bool, float64, float64, error) {
  411. var config dao.FeeConfig
  412. if err := global.GVA_DB.Where("vehicle_type_id = ?", vehicle.VehicleTypeID).First(&config).Error; err != nil {
  413. return false, 0, 0, err
  414. }
  415. if config.FreeExitMinutes <= 0 || ticket.PaidAt == nil {
  416. return false, 0, 0, nil
  417. }
  418. if time.Since(*ticket.PaidAt) <= time.Duration(config.FreeExitMinutes)*time.Minute {
  419. return false, 0, 0, nil
  420. }
  421. newFee, err := s.calculateFee(vehicle, stayTime)
  422. if err != nil {
  423. return false, 0, 0, err
  424. }
  425. diff := newFee - ticket.PaidAmount
  426. if diff < 0 {
  427. diff = 0
  428. }
  429. return true, newFee, diff, nil
  430. }
  431. // GetOperationContext 根据车辆身份判断下一步应执行进场还是出场。
  432. func (s *VehicleService) GetOperationContext(req request.VehicleIdentityRequest) (vehicleResponse.OperationContextResponse, error) {
  433. sessionSvc := sessionService.NewParkingSessionService()
  434. record, err := sessionSvc.ResolveActiveSession(sessionRequest.ResolveActiveSessionRequest{
  435. PlateNumber: req.PlateNumber,
  436. RFIDTag: req.RFIDTag,
  437. })
  438. if err == nil {
  439. preview, previewErr := s.buildExitPreview(record)
  440. if previewErr != nil {
  441. return vehicleResponse.OperationContextResponse{}, previewErr
  442. }
  443. vehicle, vehicleErr := getVehicleForSessionTx(global.GVA_DB, record)
  444. if vehicleErr != nil {
  445. return vehicleResponse.OperationContextResponse{}, vehicleErr
  446. }
  447. return vehicleResponse.OperationContextResponse{
  448. Action: "exit",
  449. VehicleFound: true,
  450. Vehicle: &vehicle,
  451. Exit: &preview,
  452. }, nil
  453. }
  454. if !errors.Is(err, sessionService.ErrSessionNotFound) {
  455. return vehicleResponse.OperationContextResponse{}, err
  456. }
  457. vehicle, vehicleErr := s.GetVehicleByPlateNumber(req.PlateNumber, req.RFIDTag)
  458. if vehicleErr != nil {
  459. return vehicleResponse.OperationContextResponse{Action: "entry", VehicleFound: false}, nil
  460. }
  461. return vehicleResponse.OperationContextResponse{
  462. Action: "entry",
  463. VehicleFound: true,
  464. Vehicle: &vehicle,
  465. }, nil
  466. }
  467. // ExitPreview 费用预览(不修改数据库,仅查询和计算费用)。
  468. func (s *VehicleService) ExitPreview(req request.ExitPreviewRequest) (vehicleResponse.ExitPreviewResponse, error) {
  469. sessionSvc := sessionService.NewParkingSessionService()
  470. record, err := sessionSvc.ResolveActiveSession(sessionRequest.ResolveActiveSessionRequest{
  471. SessionID: req.SessionID,
  472. PlateNumber: req.PlateNumber,
  473. RFIDTag: req.RFIDTag,
  474. })
  475. if err != nil {
  476. return vehicleResponse.ExitPreviewResponse{}, errors.New("车辆未入场或已出场")
  477. }
  478. return s.buildExitPreview(record)
  479. }
  480. // PayDigitalTicket settles a digital ticket and its vehicle record atomically.
  481. // A successful ticket payment is therefore visible to exit processing,
  482. // payment reports, and shift reconciliation immediately.
  483. func (s *VehicleService) PayDigitalTicket(ticketNo, paymentEntry, paymentMethod, paymentOrderNo string, paidAmount float64, operatorID uint) error {
  484. if ticketNo == "" {
  485. return errors.New("数字票号不能为空")
  486. }
  487. if paymentEntry == "" {
  488. return errors.New("数字票支付必须指定支付入口")
  489. }
  490. ticketSvc := ticketService.NewTicketService()
  491. paymentSvc := paymentService.NewPaymentService()
  492. sessionSvc := sessionService.NewParkingSessionService()
  493. return global.GVA_DB.Transaction(func(tx *gorm.DB) error {
  494. ticket, err := ticketSvc.GetByTicketNoTx(tx, ticketNo)
  495. if err != nil {
  496. return errors.New("票不存在")
  497. }
  498. if ticket.State != ticketService.StatePendingPayment {
  499. return fmt.Errorf("数字票当前状态不允许支付: %s", ticket.State)
  500. }
  501. record, err := sessionSvc.ResolveActiveSessionTx(tx, sessionRequest.ResolveActiveSessionRequest{
  502. SessionID: ticket.VehicleRecordID,
  503. })
  504. if err != nil {
  505. return errors.New("车辆未入场或已出场")
  506. }
  507. vehicle, err := getVehicleForSessionTx(tx, record)
  508. if err != nil {
  509. return err
  510. }
  511. stayTime := int64(math.Ceil(time.Since(record.EntryTime).Minutes()))
  512. if stayTime < 0 {
  513. stayTime = 0
  514. }
  515. var shortlistSvc ShortlistService
  516. _, isWhite := shortlistSvc.CheckVehicleShortlist(record.PlateNumber, record.RFIDTag)
  517. fee := 0.0
  518. if !isWhite {
  519. fee, err = s.calculateFee(&vehicle, stayTime)
  520. if err != nil {
  521. return err
  522. }
  523. }
  524. payment, err := paymentSvc.ProcessPaymentTxAtEntry(tx, record.ID, paymentEntry, paymentMethod, fee, paidAmount, operatorID)
  525. if err != nil {
  526. return err
  527. }
  528. extra := map[string]interface{}{
  529. "payment_entry": paymentEntry, "payment_method": paymentMethod, "payment_order_no": paymentOrderNo,
  530. "paid_amount": payment.PaidAmount, "fee": fee,
  531. }
  532. return ticketSvc.TransitionTx(tx, ticket.ID, ticketService.StatePaid, extra)
  533. })
  534. }
  535. func resolveExitRecordTx(tx *gorm.DB, ticketSvc *ticketService.TicketService, req request.ExitConfirmRequest) (*dao.VehicleRecord, *dao.DigitalTicket, error) {
  536. sessionSvc := sessionService.NewParkingSessionService()
  537. var record *dao.VehicleRecord
  538. var ticket *dao.DigitalTicket
  539. var err error
  540. if req.SessionID != 0 {
  541. record, err = sessionSvc.ResolveActiveSessionTx(tx, sessionRequest.ResolveActiveSessionRequest{SessionID: req.SessionID})
  542. if err == nil {
  543. ticket, err = ticketSvc.GetByVehicleRecordIDTx(tx, record.ID)
  544. }
  545. if err == nil && req.TicketNo != "" && ticket.TicketNo != req.TicketNo {
  546. return nil, nil, errors.New("停车会话与数字票不匹配")
  547. }
  548. } else if req.TicketNo != "" {
  549. ticket, err = ticketSvc.GetByTicketNoTx(tx, req.TicketNo)
  550. if err != nil {
  551. return nil, nil, errors.New("票不存在")
  552. }
  553. record, err = sessionSvc.ResolveActiveSessionTx(tx, sessionRequest.ResolveActiveSessionRequest{
  554. SessionID: ticket.VehicleRecordID,
  555. })
  556. } else {
  557. record, err = sessionSvc.ResolveActiveSessionTx(tx, sessionRequest.ResolveActiveSessionRequest{
  558. PlateNumber: req.PlateNumber,
  559. RFIDTag: req.RFIDTag,
  560. })
  561. if err == nil {
  562. ticket, err = ticketSvc.GetByVehicleRecordIDTx(tx, record.ID)
  563. }
  564. }
  565. if err != nil {
  566. return nil, nil, errors.New("车辆未入场、已出场或数字票不存在")
  567. }
  568. return record, ticket, nil
  569. }
  570. func ensurePrepaidPaymentTx(tx *gorm.DB, ticket *dao.DigitalTicket, recordID uint) (*dao.PaymentRecord, error) {
  571. var payment dao.PaymentRecord
  572. err := tx.Where("record_id = ?", recordID).Order("id DESC").First(&payment).Error
  573. if err == nil {
  574. return &payment, nil
  575. }
  576. if !errors.Is(err, gorm.ErrRecordNotFound) {
  577. return nil, err
  578. }
  579. if ticket.PaymentEntry == "" || ticket.PaymentMethod == "" {
  580. return nil, errors.New("已支付数字票缺少支付明细")
  581. }
  582. paidAt := time.Now()
  583. if ticket.PaidAt != nil {
  584. paidAt = *ticket.PaidAt
  585. }
  586. changeAmount := ticket.PaidAmount - ticket.Fee
  587. if changeAmount < 0 {
  588. changeAmount = 0
  589. }
  590. payment = dao.PaymentRecord{
  591. RecordID: recordID, PaymentEntry: ticket.PaymentEntry, PaymentMethod: ticket.PaymentMethod,
  592. Amount: ticket.Fee, PaidAmount: ticket.PaidAmount, ChangeAmount: changeAmount,
  593. PaidAt: paidAt, Remark: "由历史数字票支付记录补建",
  594. }
  595. if err := tx.Create(&payment).Error; err != nil {
  596. return nil, err
  597. }
  598. return &payment, nil
  599. }
  600. // ExitConfirm is the only exit settlement path. It either creates a new
  601. // payment or consumes an existing paid digital ticket, then updates the vehicle
  602. // record, vehicle statistics, and ticket state in one transaction.
  603. func (s *VehicleService) ExitConfirm(req request.ExitConfirmRequest, operatorID uint) (vehicleResponse.ExitConfirmResponse, error) {
  604. paymentEntry := req.PaymentEntry
  605. if paymentEntry == "" {
  606. paymentEntry = paymentService.EntryCounter
  607. }
  608. var result vehicleResponse.ExitConfirmResponse
  609. err := global.GVA_DB.Transaction(func(tx *gorm.DB) error {
  610. ticketSvc := ticketService.NewTicketService()
  611. sessionSvc := sessionService.NewParkingSessionService()
  612. record, ticket, err := resolveExitRecordTx(tx, ticketSvc, req)
  613. if err != nil {
  614. return err
  615. }
  616. var shortlistSvc ShortlistService
  617. isBlack, isWhite := shortlistSvc.CheckVehicleShortlist(record.PlateNumber, record.RFIDTag)
  618. if isBlack {
  619. return errors.New("车辆在黑名单中,禁止出场")
  620. }
  621. vehicle, err := getVehicleForSessionTx(tx, record)
  622. if err != nil {
  623. return err
  624. }
  625. exitTime := time.Now()
  626. stayTime := int64(math.Ceil(exitTime.Sub(record.EntryTime).Minutes()))
  627. if stayTime < 0 {
  628. stayTime = 0
  629. }
  630. fee := 0.0
  631. if !isWhite {
  632. fee, err = s.calculateFee(&vehicle, stayTime)
  633. if err != nil {
  634. return err
  635. }
  636. }
  637. actualEntry := paymentEntry
  638. actualMethod := req.PaymentMethod
  639. paidAmount := req.PaidAmount
  640. changeAmount := 0.0
  641. if ticket.State == ticketService.StatePaid {
  642. payment, err := ensurePrepaidPaymentTx(tx, ticket, record.ID)
  643. if err != nil {
  644. return err
  645. }
  646. fee = payment.Amount
  647. paidAmount = payment.PaidAmount
  648. changeAmount = payment.ChangeAmount
  649. actualEntry = payment.PaymentEntry
  650. actualMethod = payment.PaymentMethod
  651. // 缴费后免费离场时限:超时按实际时长重算费用,差额需现场补缴
  652. overtime, overtimeFee, overtimeDiff, err := s.paidTicketOvertime(&vehicle, ticket, stayTime)
  653. if err != nil {
  654. return err
  655. }
  656. if overtime {
  657. fee = overtimeFee
  658. if overtimeDiff > 0 {
  659. if req.PaymentMethod == paymentService.PaymentFree {
  660. return errors.New("缴费已超时,补缴差额不能使用免密放行")
  661. }
  662. if req.PaidAmount <= 0 {
  663. return fmt.Errorf("缴费已超时,需补缴差额 %.2f 元", overtimeDiff)
  664. }
  665. paymentSvc := paymentService.NewPaymentService()
  666. // 补缴校验走统一金额/方式校验(会话已 paid,不走 ProcessPayment 的会话状态守卫)
  667. if _, actualPaid, verr := paymentSvc.ValidatePayment(tx, req.PaymentMethod, paymentEntry, overtimeDiff, req.PaidAmount); verr != nil {
  668. return verr
  669. } else {
  670. now := time.Now()
  671. topUpChange := actualPaid - overtimeDiff
  672. if topUpChange < 0 {
  673. topUpChange = 0
  674. }
  675. topUp := dao.PaymentRecord{
  676. RecordID: record.ID, PaymentEntry: paymentEntry, PaymentMethod: req.PaymentMethod,
  677. Amount: overtimeDiff, PaidAmount: actualPaid, ChangeAmount: topUpChange,
  678. OperatorID: operatorID, PaidAt: now,
  679. Remark: fmt.Sprintf("超时补缴(已付%.2f,总费%.2f)", payment.PaidAmount, overtimeFee),
  680. }
  681. if err := tx.Create(&topUp).Error; err != nil {
  682. return fmt.Errorf("写入补缴流水失败: %w", err)
  683. }
  684. paidAmount += actualPaid
  685. changeAmount += topUpChange
  686. actualMethod = req.PaymentMethod
  687. // 票面累计并刷新支付时间(重新获得免费离场窗口)
  688. if err := tx.Model(&ticket).Updates(map[string]interface{}{
  689. "fee": overtimeFee,
  690. "paid_amount": ticket.PaidAmount + actualPaid,
  691. "paid_at": now,
  692. }).Error; err != nil {
  693. return fmt.Errorf("更新数字票补缴信息失败: %w", err)
  694. }
  695. }
  696. }
  697. // overtimeDiff == 0:费用未涨,无需补缴,直接放行
  698. }
  699. } else if ticket.State == ticketService.StatePendingPayment {
  700. isTemp := vehicle.VehicleType != nil && vehicle.VehicleType.IsSystem
  701. if fee > 0 && isTemp && !isWhite && req.PaymentMethod == paymentService.PaymentFree {
  702. return errors.New("临时车辆不能使用免密放行")
  703. }
  704. paymentSvc := paymentService.NewPaymentService()
  705. payment, err := paymentSvc.ProcessPaymentTxAtEntry(tx, record.ID, paymentEntry, req.PaymentMethod, fee, req.PaidAmount, operatorID)
  706. if err != nil {
  707. return err
  708. }
  709. paidAmount = payment.PaidAmount
  710. changeAmount = payment.ChangeAmount
  711. } else {
  712. return fmt.Errorf("数字票状态不允许出场: %s", ticket.State)
  713. }
  714. record, err = sessionSvc.CloseSessionTx(tx, sessionRequest.CloseSessionRequest{
  715. SessionID: record.ID,
  716. ExitTime: exitTime,
  717. StayTime: stayTime,
  718. Fee: fee,
  719. PaymentStatus: sessionService.PaymentStatusPaid,
  720. PaymentEntry: actualEntry,
  721. PaymentMethod: actualMethod,
  722. ExitChannelID: req.ExitChannelID,
  723. ExitChannelCode: req.ExitChannelCode,
  724. ExitChannelName: req.ExitChannelName,
  725. ExitDeviceCode: req.ExitDeviceCode,
  726. ExitDeviceName: req.ExitDeviceName,
  727. })
  728. if err != nil {
  729. return err
  730. }
  731. vehicle.LastExitTime = &exitTime
  732. vehicle.TotalStayTime += stayTime
  733. vehicle.TotalFee += fee
  734. if err := tx.Save(&vehicle).Error; err != nil {
  735. return err
  736. }
  737. if err := ticketSvc.MarkPaidAndExitedTx(tx, record.ID, actualEntry, actualMethod, "", paidAmount, fee); err != nil {
  738. return err
  739. }
  740. result = vehicleResponse.ExitConfirmResponse{
  741. PlateNumber: vehicle.PlateNumber, EntryTime: record.EntryTime.Unix(), ExitTime: exitTime.Unix(),
  742. StayTime: stayTime, Fee: fee, ChangeAmount: changeAmount, PaymentEntry: actualEntry, PaymentMethod: actualMethod,
  743. EntryChannelID: record.EntryChannelID, EntryChannelCode: record.EntryChannelCode,
  744. EntryChannelName: record.EntryChannelName, EntryDeviceCode: record.EntryDeviceCode,
  745. EntryDeviceName: record.EntryDeviceName, ExitChannelID: record.ExitChannelID,
  746. ExitChannelCode: record.ExitChannelCode, ExitChannelName: record.ExitChannelName,
  747. ExitDeviceCode: record.ExitDeviceCode, ExitDeviceName: record.ExitDeviceName,
  748. }
  749. return nil
  750. })
  751. if err != nil {
  752. return vehicleResponse.ExitConfirmResponse{}, err
  753. }
  754. return result, nil
  755. }
  756. // calculateFee 计算停车费用
  757. func (s *VehicleService) calculateFee(vehicle *dao.Vehicle, stayTime int64) (float64, error) {
  758. return ticketService.CalculateFee(vehicle, stayTime)
  759. }
  760. // InParkVehicle 在场车辆信息
  761. type InParkVehicle struct {
  762. RecordID uint `json:"record_id"`
  763. PlateNumber string `json:"plate_number"`
  764. VehicleType string `json:"vehicle_type"`
  765. OwnerName string `json:"owner_name"`
  766. OwnerPhone string `json:"owner_phone"`
  767. EntryTime string `json:"entry_time"`
  768. StayMinutes int64 `json:"stay_minutes"`
  769. ParkingLot string `json:"parking_lot"`
  770. ParkingSpace string `json:"parking_space"`
  771. }
  772. // GetInParkVehicles 获取所有在场车辆
  773. func (s *VehicleService) GetInParkVehicles(lotID uint, plateNumber string) ([]InParkVehicle, error) {
  774. var results []InParkVehicle
  775. now := time.Now()
  776. query := global.GVA_DB.Table("vehicle_record").
  777. Select(`vehicle_record.id as record_id, vehicle_record.plate_number, vehicle_type.name as vehicle_type,
  778. owner.owner_name, owner.owner_phone, vehicle_record.entry_time,
  779. parking_lot.lot_name as parking_lot`).
  780. Joins("LEFT JOIN vehicle ON vehicle.plate_number = vehicle_record.plate_number").
  781. Joins("LEFT JOIN vehicle_type ON vehicle_type.id = vehicle.vehicle_type_id").
  782. Joins("LEFT JOIN owner ON owner.id = vehicle.owner_id").
  783. Joins("LEFT JOIN parking_lot ON parking_lot.id = vehicle_record.parking_lot_id").
  784. Where("vehicle_record.exit_time IS NULL")
  785. if lotID > 0 {
  786. query = query.Where("vehicle_record.parking_lot_id = ?", lotID)
  787. }
  788. if plateNumber != "" {
  789. query = query.Where("vehicle_record.plate_number LIKE ?", "%"+plateNumber+"%")
  790. }
  791. err := query.Order("vehicle_record.entry_time ASC").Scan(&results).Error
  792. if err != nil {
  793. return nil, err
  794. }
  795. for i := range results {
  796. // Parse entry time and calculate stay duration
  797. t, parseErr := time.Parse("2006-01-02T15:04:05-07:00", results[i].EntryTime)
  798. if parseErr != nil {
  799. t, _ = time.Parse("2006-01-02 15:04:05", results[i].EntryTime)
  800. }
  801. results[i].StayMinutes = int64(now.Sub(t).Minutes())
  802. if results[i].StayMinutes < 0 {
  803. results[i].StayMinutes = 0
  804. }
  805. }
  806. return results, nil
  807. }