package vehicle import ( "crypto/sha256" "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{} const temporaryRFIDPlatePrefix = "TMP" // temporaryRFIDPlateNumber 为没有识别到车牌的 RFID 临时车辆生成固定编号。 // 车牌字段限制为 20 位,使用 3 位前缀加 16 位摘要,既可追溯又不会占用真实车牌。 func temporaryRFIDPlateNumber(rfidTag string) string { digest := sha256.Sum256([]byte(strings.TrimSpace(rfidTag))) return fmt.Sprintf("%s%X", temporaryRFIDPlatePrefix, digest[:8]) } // 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) { plateNumberInput := strings.TrimSpace(req.PlateNumber) rfidTagInput := strings.TrimSpace(req.RFIDTag) req.PlateNumber = plateNumberInput req.RFIDTag = rfidTagInput // 黑名单检查 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("系统错误:临时车类型未配置") } temporaryPlateNumber := req.PlateNumber if temporaryPlateNumber == "" && req.RFIDTag != "" { temporaryPlateNumber = temporaryRFIDPlateNumber(req.RFIDTag) } createReq := request.VehicleCreate{ PlateNumber: temporaryPlateNumber, RFIDTag: req.RFIDTag, VehicleTypeID: tempType.ID, OwnerId: 0, } err = s.CreateVehicle(createReq) if err != nil { return request.VehicleEntryResponse{}, err } vehicle, _ = s.GetVehicleByPlateNumber(temporaryPlateNumber, 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 // RFID 读卡出场统一按免密结算,临时车也必须走同一规则。 // 人工入口仍禁止对有费用的临时车直接免密放行。 if fee > 0 && isTemp && !isWhite && req.PaymentMethod == paymentService.PaymentFree && req.TriggerSource != "rfid" { 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, req.TriggerSource == "rfid"); err != nil { return err } result = vehicleResponse.ExitConfirmResponse{ SessionID: record.ID, 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 }