vehicle.go 31 KB

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