| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872 |
- package vehicle
- import (
- "errors"
- "fmt"
- "math"
- "strings"
- "time"
- "wails-app/internal/dao"
- "wails-app/internal/global"
- "wails-app/internal/model/vehicle/request"
- vehicleResponse "wails-app/internal/model/vehicle/response"
- ticketService "wails-app/internal/modules/digital-ticket/service"
- sessionRequest "wails-app/internal/modules/parking-session/model/request"
- sessionService "wails-app/internal/modules/parking-session/service"
- paymentService "wails-app/internal/modules/payment/service"
- "gorm.io/gorm"
- )
- type VehicleService struct{}
- // buildEntryAccessProfileTx 计算车辆在停车场满位时使用的准入身份。
- func buildEntryAccessProfileTx(tx *gorm.DB, vehicle *dao.Vehicle, at time.Time) (sessionRequest.EntryAccessProfile, error) {
- profile := sessionRequest.EntryAccessProfile{}
- if vehicle == nil || vehicle.ID == 0 {
- profile.IsTemporary = true
- return profile, nil
- }
- if vehicle.VehicleType != nil {
- profile.IsTemporary = vehicle.VehicleType.IsSystem
- } else {
- var vehicleType dao.VehicleType
- if err := tx.First(&vehicleType, vehicle.VehicleTypeID).Error; err != nil {
- return profile, err
- }
- profile.IsTemporary = vehicleType.IsSystem
- }
- if vehicle.OwnerId != nil {
- var owner dao.Owner
- if err := tx.First(&owner, *vehicle.OwnerId).Error; err == nil {
- profile.IsVIP = owner.IsVip && !owner.VipExpireTime.IsZero() && owner.VipExpireTime.After(at)
- } else if !errors.Is(err, gorm.ErrRecordNotFound) {
- return profile, err
- }
- }
- var monthlyCount int64
- if err := tx.Model(&dao.MonthlyCard{}).
- Where("vehicle_id = ? AND payment_status = ? AND start_date <= ? AND end_date > ?", vehicle.ID, "paid", at, at).
- Count(&monthlyCount).Error; err != nil {
- return profile, err
- }
- profile.HasValidMonthlyCard = monthlyCount > 0
- return profile, nil
- }
- // buildPlateOrRfidQuery 按车牌或RFID查询,RFID为空时仅用车牌(避免 rfid_tag=” 匹配所有无标签车辆)
- func buildPlateOrRfidQuery(db *gorm.DB, plateNumber, rfidTag string) *gorm.DB {
- if rfidTag != "" {
- return db.Where("plate_number = ? OR rfid_tag = ?", plateNumber, rfidTag)
- }
- return db.Where("plate_number = ?", plateNumber)
- }
- // getVehicleForSessionTx 根据停车会话中保存的车辆身份定位车辆档案。
- // 优先同时匹配车牌和 RFID;若历史会话未完整保存 RFID,则依次按 RFID、车牌降级查询。
- func getVehicleForSessionTx(db *gorm.DB, session *dao.VehicleRecord) (dao.Vehicle, error) {
- if db == nil || session == nil {
- return dao.Vehicle{}, errors.New("车辆会话不存在")
- }
- var vehicle dao.Vehicle
- if session.PlateNumber != "" && session.RFIDTag != "" {
- result := db.Preload("VehicleType").Preload("Owner").
- Where("plate_number = ? AND rfid_tag = ?", session.PlateNumber, session.RFIDTag).
- First(&vehicle)
- if result.Error == nil {
- return vehicle, nil
- }
- if !errors.Is(result.Error, gorm.ErrRecordNotFound) {
- return dao.Vehicle{}, result.Error
- }
- }
- if session.RFIDTag != "" {
- result := db.Preload("VehicleType").Preload("Owner").Where("rfid_tag = ?", session.RFIDTag).First(&vehicle)
- if result.Error == nil {
- return vehicle, nil
- }
- if !errors.Is(result.Error, gorm.ErrRecordNotFound) {
- return dao.Vehicle{}, result.Error
- }
- }
- if session.PlateNumber != "" {
- result := db.Preload("VehicleType").Preload("Owner").Where("plate_number = ?", session.PlateNumber).First(&vehicle)
- if result.Error == nil {
- return vehicle, nil
- }
- if !errors.Is(result.Error, gorm.ErrRecordNotFound) {
- return dao.Vehicle{}, result.Error
- }
- }
- return dao.Vehicle{}, errors.New("车辆不存在")
- }
- // CreateVehicle 创建车辆信息
- func (s *VehicleService) CreateVehicle(req request.VehicleCreate) error {
- // 检查车牌号是否已存在
- var existingVehicle dao.Vehicle
- result := buildPlateOrRfidQuery(global.GVA_DB, req.PlateNumber, req.RFIDTag).First(&existingVehicle)
- if result.RowsAffected > 0 {
- return errors.New("车牌号已存在")
- }
- // 检查车辆类型是否存在
- var vehicleType dao.VehicleType
- result = global.GVA_DB.First(&vehicleType, req.VehicleTypeID)
- if result.RowsAffected == 0 {
- return errors.New("车辆类型不存在")
- }
- // 处理临时车辆的情况
- var ownerId *uint
- if req.OwnerId > 0 {
- ownerId = &req.OwnerId
- }
- vehicle := dao.Vehicle{
- PlateNumber: req.PlateNumber,
- RFIDTag: req.RFIDTag,
- VehicleTypeID: req.VehicleTypeID,
- VehicleBrand: req.VehicleBrand,
- VehicleColor: req.VehicleColor,
- OwnerId: ownerId,
- }
- return global.GVA_DB.Create(&vehicle).Error
- }
- // UpdateVehicle 更新车辆信息
- func (s *VehicleService) UpdateVehicle(req request.VehicleUpdate) error {
- var vehicle dao.Vehicle
- result := global.GVA_DB.First(&vehicle, req.ID)
- if result.RowsAffected == 0 {
- return errors.New("车辆不存在")
- }
- // 检查车牌号是否被其他车辆使用
- var existingVehicle dao.Vehicle
- result = buildPlateOrRfidQuery(global.GVA_DB, req.PlateNumber, req.RFIDTag).Where("id != ?", req.ID).First(&existingVehicle)
- fmt.Println("result", result.RowsAffected)
- if result.RowsAffected > 0 {
- return errors.New("车牌号已被其他车辆使用")
- }
- // 检查车辆类型是否存在
- var vehicleType dao.VehicleType
- result = global.GVA_DB.First(&vehicleType, req.VehicleTypeID)
- if result.RowsAffected == 0 {
- return errors.New("车辆类型不存在")
- }
- // 处理临时车辆的情况
- var ownerId *uint
- if req.OwnerId > 0 {
- ownerId = &req.OwnerId
- } else {
- ownerId = nil
- }
- err := global.GVA_DB.Model(&vehicle).Updates(map[string]interface{}{
- "plate_number": req.PlateNumber,
- "rfid_tag": req.RFIDTag,
- "vehicle_type_id": req.VehicleTypeID,
- "vehicle_brand": req.VehicleBrand,
- "vehicle_color": req.VehicleColor,
- "owner_id": ownerId,
- }).Error
- return err
- }
- // GetVehicleByID 根据ID获取车辆信息
- func (s *VehicleService) GetVehicleByID(id uint) (dao.Vehicle, error) {
- var vehicle dao.Vehicle
- result := global.GVA_DB.First(&vehicle, id)
- if result.RowsAffected == 0 {
- return dao.Vehicle{}, errors.New("车辆不存在")
- }
- return vehicle, nil
- }
- // GetVehicleByPlateNumber 根据车牌号或RFID获取车辆信息
- func (s *VehicleService) GetVehicleByPlateNumber(plateNumber, RFIDTag string) (dao.Vehicle, error) {
- var vehicle dao.Vehicle
- if plateNumber != "" && RFIDTag != "" {
- exactQuery := global.GVA_DB.Preload("VehicleType").Preload("Owner").Where("plate_number = ? AND rfid_tag = ?", plateNumber, RFIDTag)
- if result := exactQuery.First(&vehicle); result.Error == nil {
- return vehicle, nil
- }
- }
- query := global.GVA_DB.Preload("VehicleType").Preload("Owner")
- if RFIDTag != "" {
- query = query.Where("plate_number = ? OR rfid_tag = ?", plateNumber, RFIDTag)
- } else {
- query = query.Where("plate_number = ?", plateNumber)
- }
- result := query.First(&vehicle)
- if result.RowsAffected == 0 {
- return dao.Vehicle{}, errors.New("车辆不存在")
- }
- return vehicle, nil
- }
- func (s *VehicleService) QueryAllVehicles() ([]dao.Vehicle, error) {
- var vehicles []dao.Vehicle
- err := global.GVA_DB.Find(&vehicles).Error
- if err != nil {
- return vehicles, err
- }
- return vehicles, nil
- }
- // ListVehicles 获取车辆列表
- func (s *VehicleService) ListVehicles(req request.VehicleQuery) (int64, []dao.Vehicle, error) {
- var vehicles []dao.Vehicle
- var total int64
- query := global.GVA_DB.Model(&dao.Vehicle{})
- if req.PlateNumber != "" {
- query = query.Where("plate_number LIKE ?", "%"+req.PlateNumber+"%")
- }
- if req.VehicleTypeID > 0 {
- query = query.Where("vehicle_type_id = ?", req.VehicleTypeID)
- }
- if req.OwnerId > 0 {
- query = query.Where("owner_id = ?", req.OwnerId)
- }
- // 计算总数
- query.Count(&total)
- // 分页查询
- offset := (req.Page - 1) * req.PageSize
- result := query.Offset(offset).Limit(req.PageSize).Preload("Owner").Preload("VehicleType").Find(&vehicles)
- if result.Error != nil {
- return 0, nil, result.Error
- }
- return total, vehicles, nil
- }
- // DeleteVehicle 删除车辆
- func (s *VehicleService) DeleteVehicle(id uint) error {
- result := global.GVA_DB.Delete(&dao.Vehicle{}, id)
- if result.RowsAffected == 0 {
- return errors.New("车辆不存在")
- }
- return result.Error
- }
- // VehicleEntry 车辆入场
- func (s *VehicleService) VehicleEntry(req request.VehicleEntry) (request.VehicleEntryResponse, error) {
- // 黑名单检查
- var shortlistSvc ShortlistService
- if isBlack, _ := shortlistSvc.CheckVehicleShortlist(req.PlateNumber, req.RFIDTag); isBlack {
- return request.VehicleEntryResponse{}, errors.New("车辆在黑名单中,禁止入场")
- }
- // 检查车辆是否存在
- vehicle, err := s.GetVehicleByPlateNumber(req.PlateNumber, req.RFIDTag)
- if err != nil {
- // 如果车辆不存在,创建新车辆,自动归类为"临时车"
- var tempType dao.VehicleType
- if global.GVA_DB.Where("is_system = ?", true).First(&tempType).Error != nil {
- return request.VehicleEntryResponse{}, errors.New("系统错误:临时车类型未配置")
- }
- createReq := request.VehicleCreate{
- PlateNumber: req.PlateNumber,
- RFIDTag: req.RFIDTag,
- VehicleTypeID: tempType.ID,
- OwnerId: 0,
- }
- err = s.CreateVehicle(createReq)
- if err != nil {
- return request.VehicleEntryResponse{}, err
- }
- vehicle, _ = s.GetVehicleByPlateNumber(req.PlateNumber, req.RFIDTag)
- }
- // 在同一事务中创建停车会话和数字票,避免出现只有会话或只有数字票的不完整入场数据。
- entryTime := time.Now()
- plateNumber := vehicle.PlateNumber
- if plateNumber == "" {
- plateNumber = req.PlateNumber
- }
- rfidTag := req.RFIDTag
- if rfidTag == "" {
- rfidTag = vehicle.RFIDTag
- }
- var record *dao.VehicleRecord
- var ticket *dao.DigitalTicket
- ticketSvc := ticketService.NewTicketService()
- sessionSvc := sessionService.NewParkingSessionService()
- triggerMode := strings.TrimSpace(req.TriggerMode)
- if triggerMode == "" {
- triggerMode = "manual"
- }
- if req.RFIDTag != "" && req.TriggerMode == "" {
- triggerMode = "rfid"
- }
- err = global.GVA_DB.Transaction(func(tx *gorm.DB) error {
- accessProfile, profileErr := buildEntryAccessProfileTx(tx, &vehicle, entryTime)
- if profileErr != nil {
- return fmt.Errorf("识别车辆满位准入身份失败: %w", profileErr)
- }
- var err error
- record, err = sessionSvc.OpenSessionTx(tx, sessionRequest.OpenSessionRequest{
- PlateNumber: plateNumber,
- RFIDTag: rfidTag,
- ParkingLotID: req.ParkingLotID,
- ParkingSpaceID: req.ParkingSpaceID,
- EntryChannelID: req.EntryChannelID,
- EntryChannelCode: req.EntryChannelCode,
- EntryChannelName: req.EntryChannelName,
- EntryDeviceCode: req.EntryDeviceCode,
- EntryDeviceName: req.EntryDeviceName,
- EntryImage: req.EntryImage,
- EntryTime: entryTime,
- AccessProfile: accessProfile,
- })
- if err != nil {
- return err
- }
- ticket, err = ticketSvc.CreateTx(tx, plateNumber, triggerMode, record.ID)
- if err != nil {
- return fmt.Errorf("创建数字票失败: %w", err)
- }
- return tx.Model(&dao.Vehicle{}).Where("id = ?", vehicle.ID).
- Update("last_entry_time", entryTime).Error
- })
- if err != nil {
- return request.VehicleEntryResponse{}, err
- }
- response := request.VehicleEntryResponse{
- Success: true,
- Message: "车辆入场成功",
- }
- response.Data.SessionID = record.ID
- response.Data.TicketID = ticket.ID
- response.Data.TicketNo = ticket.TicketNo
- response.Data.PlateNumber = plateNumber
- response.Data.EntryTime = record.EntryTime.Unix()
- response.Data.ParkingLotID = req.ParkingLotID
- response.Data.ParkingSpaceID = req.ParkingSpaceID
- response.Data.EntryChannelID = record.EntryChannelID
- response.Data.EntryChannelCode = record.EntryChannelCode
- response.Data.EntryChannelName = record.EntryChannelName
- response.Data.EntryDeviceCode = record.EntryDeviceCode
- response.Data.EntryDeviceName = record.EntryDeviceName
- return response, nil
- }
- func (s *VehicleService) buildExitPreview(record *dao.VehicleRecord) (vehicleResponse.ExitPreviewResponse, error) {
- if record == nil {
- return vehicleResponse.ExitPreviewResponse{}, errors.New("停车会话不存在")
- }
- ticketSvc := ticketService.NewTicketService()
- ticket, err := ticketSvc.GetByVehicleRecordIDTx(global.GVA_DB, record.ID)
- if err != nil {
- return vehicleResponse.ExitPreviewResponse{}, errors.New("停车会话对应的数字票不存在")
- }
- vehicle, err := getVehicleForSessionTx(global.GVA_DB, record)
- if err != nil {
- return vehicleResponse.ExitPreviewResponse{}, err
- }
- stayTime := int64(math.Ceil(time.Since(record.EntryTime).Minutes()))
- if stayTime < 0 {
- stayTime = 0
- }
- var shortlistSvc ShortlistService
- _, isWhite := shortlistSvc.CheckVehicleShortlist(record.PlateNumber, record.RFIDTag)
- fee := 0.0
- var overtime bool
- var overtimeFee, overtimeDiff float64
- if ticket.State == ticketService.StatePaid {
- fee = ticket.Fee
- // 缴费后免费离场时限:超时按当前时长重算,供前端提示补缴
- overtime, overtimeFee, overtimeDiff, err = s.paidTicketOvertime(&vehicle, ticket, stayTime)
- if err != nil {
- return vehicleResponse.ExitPreviewResponse{}, err
- }
- if overtime {
- fee = overtimeFee
- }
- } else if !isWhite {
- fee, err = s.calculateFee(&vehicle, stayTime)
- if err != nil {
- return vehicleResponse.ExitPreviewResponse{}, err
- }
- }
- typeName := ""
- isTemp := false
- if vehicle.VehicleType != nil {
- typeName = vehicle.VehicleType.Name
- isTemp = vehicle.VehicleType.IsSystem
- }
- plateNumber := record.PlateNumber
- if plateNumber == "" {
- plateNumber = vehicle.PlateNumber
- }
- return vehicleResponse.ExitPreviewResponse{
- SessionID: record.ID,
- TicketNo: ticket.TicketNo,
- TicketState: ticket.State,
- PlateNumber: plateNumber,
- RFIDTag: record.RFIDTag,
- EntryTime: record.EntryTime.Unix(),
- StayTime: stayTime,
- Fee: fee,
- PaidAmount: ticket.PaidAmount,
- PaymentStatus: record.PaymentStatus,
- PaymentEntry: ticket.PaymentEntry,
- PaymentMethod: ticket.PaymentMethod,
- ParkingLotID: record.ParkingLotID,
- EntryImage: record.EntryImage,
- EntryChannelID: record.EntryChannelID,
- EntryChannelCode: record.EntryChannelCode,
- EntryChannelName: record.EntryChannelName,
- EntryDeviceCode: record.EntryDeviceCode,
- EntryDeviceName: record.EntryDeviceName,
- VehicleType: typeName,
- IsTemp: isTemp,
- Overtime: overtime,
- OvertimeFee: overtimeFee,
- OvertimeDiff: overtimeDiff,
- }, nil
- }
- // paidTicketOvertime 判定已支付票是否超过"缴费后免费离场时限":
- // 未超时返回 (false, 0, 0, nil);超时返回 (true, 按当前时长重算的总费用, 需补缴差额, nil)。
- // FreeExitMinutes<=0(不限)或 PaidAt 为空(历史票)视为未超时。
- func (s *VehicleService) paidTicketOvertime(vehicle *dao.Vehicle, ticket *dao.DigitalTicket, stayTime int64) (bool, float64, float64, error) {
- var config dao.FeeConfig
- if err := global.GVA_DB.Where("vehicle_type_id = ?", vehicle.VehicleTypeID).First(&config).Error; err != nil {
- return false, 0, 0, err
- }
- if config.FreeExitMinutes <= 0 || ticket.PaidAt == nil {
- return false, 0, 0, nil
- }
- if time.Since(*ticket.PaidAt) <= time.Duration(config.FreeExitMinutes)*time.Minute {
- return false, 0, 0, nil
- }
- newFee, err := s.calculateFee(vehicle, stayTime)
- if err != nil {
- return false, 0, 0, err
- }
- diff := newFee - ticket.PaidAmount
- if diff < 0 {
- diff = 0
- }
- return true, newFee, diff, nil
- }
- // GetOperationContext 根据车辆身份判断下一步应执行进场还是出场。
- func (s *VehicleService) GetOperationContext(req request.VehicleIdentityRequest) (vehicleResponse.OperationContextResponse, error) {
- sessionSvc := sessionService.NewParkingSessionService()
- record, err := sessionSvc.ResolveActiveSession(sessionRequest.ResolveActiveSessionRequest{
- PlateNumber: req.PlateNumber,
- RFIDTag: req.RFIDTag,
- })
- if err == nil {
- preview, previewErr := s.buildExitPreview(record)
- if previewErr != nil {
- return vehicleResponse.OperationContextResponse{}, previewErr
- }
- vehicle, vehicleErr := getVehicleForSessionTx(global.GVA_DB, record)
- if vehicleErr != nil {
- return vehicleResponse.OperationContextResponse{}, vehicleErr
- }
- return vehicleResponse.OperationContextResponse{
- Action: "exit",
- VehicleFound: true,
- Vehicle: &vehicle,
- Exit: &preview,
- }, nil
- }
- if !errors.Is(err, sessionService.ErrSessionNotFound) {
- return vehicleResponse.OperationContextResponse{}, err
- }
- vehicle, vehicleErr := s.GetVehicleByPlateNumber(req.PlateNumber, req.RFIDTag)
- if vehicleErr != nil {
- return vehicleResponse.OperationContextResponse{Action: "entry", VehicleFound: false}, nil
- }
- return vehicleResponse.OperationContextResponse{
- Action: "entry",
- VehicleFound: true,
- Vehicle: &vehicle,
- }, nil
- }
- // ExitPreview 费用预览(不修改数据库,仅查询和计算费用)。
- func (s *VehicleService) ExitPreview(req request.ExitPreviewRequest) (vehicleResponse.ExitPreviewResponse, error) {
- sessionSvc := sessionService.NewParkingSessionService()
- record, err := sessionSvc.ResolveActiveSession(sessionRequest.ResolveActiveSessionRequest{
- SessionID: req.SessionID,
- PlateNumber: req.PlateNumber,
- RFIDTag: req.RFIDTag,
- })
- if err != nil {
- return vehicleResponse.ExitPreviewResponse{}, errors.New("车辆未入场或已出场")
- }
- return s.buildExitPreview(record)
- }
- // PayDigitalTicket settles a digital ticket and its vehicle record atomically.
- // A successful ticket payment is therefore visible to exit processing,
- // payment reports, and shift reconciliation immediately.
- func (s *VehicleService) PayDigitalTicket(ticketNo, paymentEntry, paymentMethod, paymentOrderNo string, paidAmount float64, operatorID uint) error {
- if ticketNo == "" {
- return errors.New("数字票号不能为空")
- }
- if paymentEntry == "" {
- return errors.New("数字票支付必须指定支付入口")
- }
- ticketSvc := ticketService.NewTicketService()
- paymentSvc := paymentService.NewPaymentService()
- sessionSvc := sessionService.NewParkingSessionService()
- return global.GVA_DB.Transaction(func(tx *gorm.DB) error {
- ticket, err := ticketSvc.GetByTicketNoTx(tx, ticketNo)
- if err != nil {
- return errors.New("票不存在")
- }
- if ticket.State != ticketService.StatePendingPayment {
- return fmt.Errorf("数字票当前状态不允许支付: %s", ticket.State)
- }
- record, err := sessionSvc.ResolveActiveSessionTx(tx, sessionRequest.ResolveActiveSessionRequest{
- SessionID: ticket.VehicleRecordID,
- })
- if err != nil {
- return errors.New("车辆未入场或已出场")
- }
- vehicle, err := getVehicleForSessionTx(tx, record)
- if err != nil {
- return err
- }
- stayTime := int64(math.Ceil(time.Since(record.EntryTime).Minutes()))
- if stayTime < 0 {
- stayTime = 0
- }
- var shortlistSvc ShortlistService
- _, isWhite := shortlistSvc.CheckVehicleShortlist(record.PlateNumber, record.RFIDTag)
- fee := 0.0
- if !isWhite {
- fee, err = s.calculateFee(&vehicle, stayTime)
- if err != nil {
- return err
- }
- }
- payment, err := paymentSvc.ProcessPaymentTxAtEntry(tx, record.ID, paymentEntry, paymentMethod, fee, paidAmount, operatorID)
- if err != nil {
- return err
- }
- extra := map[string]interface{}{
- "payment_entry": paymentEntry, "payment_method": paymentMethod, "payment_order_no": paymentOrderNo,
- "paid_amount": payment.PaidAmount, "fee": fee,
- }
- return ticketSvc.TransitionTx(tx, ticket.ID, ticketService.StatePaid, extra)
- })
- }
- func resolveExitRecordTx(tx *gorm.DB, ticketSvc *ticketService.TicketService, req request.ExitConfirmRequest) (*dao.VehicleRecord, *dao.DigitalTicket, error) {
- sessionSvc := sessionService.NewParkingSessionService()
- var record *dao.VehicleRecord
- var ticket *dao.DigitalTicket
- var err error
- if req.SessionID != 0 {
- record, err = sessionSvc.ResolveActiveSessionTx(tx, sessionRequest.ResolveActiveSessionRequest{SessionID: req.SessionID})
- if err == nil {
- ticket, err = ticketSvc.GetByVehicleRecordIDTx(tx, record.ID)
- }
- if err == nil && req.TicketNo != "" && ticket.TicketNo != req.TicketNo {
- return nil, nil, errors.New("停车会话与数字票不匹配")
- }
- } else if req.TicketNo != "" {
- ticket, err = ticketSvc.GetByTicketNoTx(tx, req.TicketNo)
- if err != nil {
- return nil, nil, errors.New("票不存在")
- }
- record, err = sessionSvc.ResolveActiveSessionTx(tx, sessionRequest.ResolveActiveSessionRequest{
- SessionID: ticket.VehicleRecordID,
- })
- } else {
- record, err = sessionSvc.ResolveActiveSessionTx(tx, sessionRequest.ResolveActiveSessionRequest{
- PlateNumber: req.PlateNumber,
- RFIDTag: req.RFIDTag,
- })
- if err == nil {
- ticket, err = ticketSvc.GetByVehicleRecordIDTx(tx, record.ID)
- }
- }
- if err != nil {
- return nil, nil, errors.New("车辆未入场、已出场或数字票不存在")
- }
- return record, ticket, nil
- }
- func ensurePrepaidPaymentTx(tx *gorm.DB, ticket *dao.DigitalTicket, recordID uint) (*dao.PaymentRecord, error) {
- var payment dao.PaymentRecord
- err := tx.Where("record_id = ?", recordID).Order("id DESC").First(&payment).Error
- if err == nil {
- return &payment, nil
- }
- if !errors.Is(err, gorm.ErrRecordNotFound) {
- return nil, err
- }
- if ticket.PaymentEntry == "" || ticket.PaymentMethod == "" {
- return nil, errors.New("已支付数字票缺少支付明细")
- }
- paidAt := time.Now()
- if ticket.PaidAt != nil {
- paidAt = *ticket.PaidAt
- }
- changeAmount := ticket.PaidAmount - ticket.Fee
- if changeAmount < 0 {
- changeAmount = 0
- }
- payment = dao.PaymentRecord{
- RecordID: recordID, PaymentEntry: ticket.PaymentEntry, PaymentMethod: ticket.PaymentMethod,
- Amount: ticket.Fee, PaidAmount: ticket.PaidAmount, ChangeAmount: changeAmount,
- PaidAt: paidAt, Remark: "由历史数字票支付记录补建",
- }
- if err := tx.Create(&payment).Error; err != nil {
- return nil, err
- }
- return &payment, nil
- }
- // ExitConfirm is the only exit settlement path. It either creates a new
- // payment or consumes an existing paid digital ticket, then updates the vehicle
- // record, vehicle statistics, and ticket state in one transaction.
- func (s *VehicleService) ExitConfirm(req request.ExitConfirmRequest, operatorID uint) (vehicleResponse.ExitConfirmResponse, error) {
- paymentEntry := req.PaymentEntry
- if paymentEntry == "" {
- paymentEntry = paymentService.EntryCounter
- }
- var result vehicleResponse.ExitConfirmResponse
- err := global.GVA_DB.Transaction(func(tx *gorm.DB) error {
- ticketSvc := ticketService.NewTicketService()
- sessionSvc := sessionService.NewParkingSessionService()
- record, ticket, err := resolveExitRecordTx(tx, ticketSvc, req)
- if err != nil {
- return err
- }
- var shortlistSvc ShortlistService
- isBlack, isWhite := shortlistSvc.CheckVehicleShortlist(record.PlateNumber, record.RFIDTag)
- if isBlack {
- return errors.New("车辆在黑名单中,禁止出场")
- }
- vehicle, err := getVehicleForSessionTx(tx, record)
- if err != nil {
- return err
- }
- exitTime := time.Now()
- stayTime := int64(math.Ceil(exitTime.Sub(record.EntryTime).Minutes()))
- if stayTime < 0 {
- stayTime = 0
- }
- fee := 0.0
- if !isWhite {
- fee, err = s.calculateFee(&vehicle, stayTime)
- if err != nil {
- return err
- }
- }
- actualEntry := paymentEntry
- actualMethod := req.PaymentMethod
- paidAmount := req.PaidAmount
- changeAmount := 0.0
- if ticket.State == ticketService.StatePaid {
- payment, err := ensurePrepaidPaymentTx(tx, ticket, record.ID)
- if err != nil {
- return err
- }
- fee = payment.Amount
- paidAmount = payment.PaidAmount
- changeAmount = payment.ChangeAmount
- actualEntry = payment.PaymentEntry
- actualMethod = payment.PaymentMethod
- // 缴费后免费离场时限:超时按实际时长重算费用,差额需现场补缴
- overtime, overtimeFee, overtimeDiff, err := s.paidTicketOvertime(&vehicle, ticket, stayTime)
- if err != nil {
- return err
- }
- if overtime {
- fee = overtimeFee
- if overtimeDiff > 0 {
- if req.PaymentMethod == paymentService.PaymentFree {
- return errors.New("缴费已超时,补缴差额不能使用免密放行")
- }
- if req.PaidAmount <= 0 {
- return fmt.Errorf("缴费已超时,需补缴差额 %.2f 元", overtimeDiff)
- }
- paymentSvc := paymentService.NewPaymentService()
- // 补缴校验走统一金额/方式校验(会话已 paid,不走 ProcessPayment 的会话状态守卫)
- if _, actualPaid, verr := paymentSvc.ValidatePayment(tx, req.PaymentMethod, paymentEntry, overtimeDiff, req.PaidAmount); verr != nil {
- return verr
- } else {
- now := time.Now()
- topUpChange := actualPaid - overtimeDiff
- if topUpChange < 0 {
- topUpChange = 0
- }
- topUp := dao.PaymentRecord{
- RecordID: record.ID, PaymentEntry: paymentEntry, PaymentMethod: req.PaymentMethod,
- Amount: overtimeDiff, PaidAmount: actualPaid, ChangeAmount: topUpChange,
- OperatorID: operatorID, PaidAt: now,
- Remark: fmt.Sprintf("超时补缴(已付%.2f,总费%.2f)", payment.PaidAmount, overtimeFee),
- }
- if err := tx.Create(&topUp).Error; err != nil {
- return fmt.Errorf("写入补缴流水失败: %w", err)
- }
- paidAmount += actualPaid
- changeAmount += topUpChange
- actualMethod = req.PaymentMethod
- // 票面累计并刷新支付时间(重新获得免费离场窗口)
- if err := tx.Model(&ticket).Updates(map[string]interface{}{
- "fee": overtimeFee,
- "paid_amount": ticket.PaidAmount + actualPaid,
- "paid_at": now,
- }).Error; err != nil {
- return fmt.Errorf("更新数字票补缴信息失败: %w", err)
- }
- }
- }
- // overtimeDiff == 0:费用未涨,无需补缴,直接放行
- }
- } else if ticket.State == ticketService.StatePendingPayment {
- isTemp := vehicle.VehicleType != nil && vehicle.VehicleType.IsSystem
- if fee > 0 && isTemp && !isWhite && req.PaymentMethod == paymentService.PaymentFree {
- return errors.New("临时车辆不能使用免密放行")
- }
- paymentSvc := paymentService.NewPaymentService()
- payment, err := paymentSvc.ProcessPaymentTxAtEntry(tx, record.ID, paymentEntry, req.PaymentMethod, fee, req.PaidAmount, operatorID)
- if err != nil {
- return err
- }
- paidAmount = payment.PaidAmount
- changeAmount = payment.ChangeAmount
- } else {
- return fmt.Errorf("数字票状态不允许出场: %s", ticket.State)
- }
- record, err = sessionSvc.CloseSessionTx(tx, sessionRequest.CloseSessionRequest{
- SessionID: record.ID,
- ExitTime: exitTime,
- StayTime: stayTime,
- Fee: fee,
- PaymentStatus: sessionService.PaymentStatusPaid,
- PaymentEntry: actualEntry,
- PaymentMethod: actualMethod,
- ExitChannelID: req.ExitChannelID,
- ExitChannelCode: req.ExitChannelCode,
- ExitChannelName: req.ExitChannelName,
- ExitDeviceCode: req.ExitDeviceCode,
- ExitDeviceName: req.ExitDeviceName,
- })
- if err != nil {
- return err
- }
- vehicle.LastExitTime = &exitTime
- vehicle.TotalStayTime += stayTime
- vehicle.TotalFee += fee
- if err := tx.Save(&vehicle).Error; err != nil {
- return err
- }
- if err := ticketSvc.MarkPaidAndExitedTx(tx, record.ID, actualEntry, actualMethod, "", paidAmount, fee); err != nil {
- return err
- }
- result = vehicleResponse.ExitConfirmResponse{
- PlateNumber: vehicle.PlateNumber, EntryTime: record.EntryTime.Unix(), ExitTime: exitTime.Unix(),
- StayTime: stayTime, Fee: fee, ChangeAmount: changeAmount, PaymentEntry: actualEntry, PaymentMethod: actualMethod,
- EntryChannelID: record.EntryChannelID, EntryChannelCode: record.EntryChannelCode,
- EntryChannelName: record.EntryChannelName, EntryDeviceCode: record.EntryDeviceCode,
- EntryDeviceName: record.EntryDeviceName, ExitChannelID: record.ExitChannelID,
- ExitChannelCode: record.ExitChannelCode, ExitChannelName: record.ExitChannelName,
- ExitDeviceCode: record.ExitDeviceCode, ExitDeviceName: record.ExitDeviceName,
- }
- return nil
- })
- if err != nil {
- return vehicleResponse.ExitConfirmResponse{}, err
- }
- return result, nil
- }
- // calculateFee 计算停车费用
- func (s *VehicleService) calculateFee(vehicle *dao.Vehicle, stayTime int64) (float64, error) {
- return ticketService.CalculateFee(vehicle, stayTime)
- }
- // InParkVehicle 在场车辆信息
- type InParkVehicle struct {
- RecordID uint `json:"record_id"`
- PlateNumber string `json:"plate_number"`
- VehicleType string `json:"vehicle_type"`
- OwnerName string `json:"owner_name"`
- OwnerPhone string `json:"owner_phone"`
- EntryTime string `json:"entry_time"`
- StayMinutes int64 `json:"stay_minutes"`
- ParkingLot string `json:"parking_lot"`
- ParkingSpace string `json:"parking_space"`
- }
- // GetInParkVehicles 获取所有在场车辆
- func (s *VehicleService) GetInParkVehicles(lotID uint, plateNumber string) ([]InParkVehicle, error) {
- var results []InParkVehicle
- now := time.Now()
- query := global.GVA_DB.Table("vehicle_record").
- Select(`vehicle_record.id as record_id, vehicle_record.plate_number, vehicle_type.name as vehicle_type,
- owner.owner_name, owner.owner_phone, vehicle_record.entry_time,
- parking_lot.lot_name as parking_lot`).
- Joins("LEFT JOIN vehicle ON vehicle.plate_number = vehicle_record.plate_number").
- Joins("LEFT JOIN vehicle_type ON vehicle_type.id = vehicle.vehicle_type_id").
- Joins("LEFT JOIN owner ON owner.id = vehicle.owner_id").
- Joins("LEFT JOIN parking_lot ON parking_lot.id = vehicle_record.parking_lot_id").
- Where("vehicle_record.exit_time IS NULL")
- if lotID > 0 {
- query = query.Where("vehicle_record.parking_lot_id = ?", lotID)
- }
- if plateNumber != "" {
- query = query.Where("vehicle_record.plate_number LIKE ?", "%"+plateNumber+"%")
- }
- err := query.Order("vehicle_record.entry_time ASC").Scan(&results).Error
- if err != nil {
- return nil, err
- }
- for i := range results {
- // Parse entry time and calculate stay duration
- t, parseErr := time.Parse("2006-01-02T15:04:05-07:00", results[i].EntryTime)
- if parseErr != nil {
- t, _ = time.Parse("2006-01-02 15:04:05", results[i].EntryTime)
- }
- results[i].StayMinutes = int64(now.Sub(t).Minutes())
- if results[i].StayMinutes < 0 {
- results[i].StayMinutes = 0
- }
- }
- return results, nil
- }
|