| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414 |
- package service
- import (
- "errors"
- "fmt"
- "math"
- "strings"
- "time"
- "wails-app/internal/dao"
- "wails-app/internal/global"
- "wails-app/internal/modules/parking-session/model/request"
- "wails-app/internal/modules/parking-session/repository"
- "gorm.io/gorm"
- )
- const (
- PaymentStatusUnpaid = "unpaid"
- PaymentStatusPaid = "paid"
- )
- var (
- ErrSessionNotFound = errors.New("未找到在场停车会话")
- ErrSessionAlreadyOpen = errors.New("车辆已有在场停车会话")
- ErrSessionAlreadyPaid = errors.New("停车会话已完成支付")
- ErrSessionAlreadyClosed = errors.New("停车会话已关闭")
- ErrSessionIdentityConflict = errors.New("车牌与RFID对应不同的在场停车会话")
- ErrSessionAmbiguous = errors.New("存在多条匹配的在场停车会话")
- ErrSessionIdentifierEmpty = errors.New("停车会话必须提供车牌、RFID或会话编号")
- ErrParkingLotNotFound = errors.New("停车场不存在")
- ErrParkingLotFull = errors.New("停车场车位已满")
- )
- // ParkingSessionService owns the parking-stay lifecycle. It deliberately
- // persists to vehicle_record so existing reports and historical data remain
- // compatible while callers stop treating the record as an unstructured table.
- type ParkingSessionService struct {
- repo *repository.ParkingSessionRepository
- }
- func NewParkingSessionService() *ParkingSessionService {
- return &ParkingSessionService{repo: &repository.ParkingSessionRepository{}}
- }
- // FindActiveSession is the domain-facing name used by adapters that do not
- // need to know whether the lookup is backed by a transaction.
- func (s *ParkingSessionService) FindActiveSession(req request.ResolveActiveSessionRequest) (*dao.VehicleRecord, error) {
- return s.ResolveActiveSession(req)
- }
- func (s *ParkingSessionService) OpenSession(req request.OpenSessionRequest) (*dao.VehicleRecord, error) {
- if global.GVA_DB == nil {
- return nil, errors.New("数据库未初始化")
- }
- var session *dao.VehicleRecord
- err := global.GVA_DB.Transaction(func(tx *gorm.DB) error {
- var err error
- session, err = s.OpenSessionTx(tx, req)
- return err
- })
- return session, err
- }
- func (s *ParkingSessionService) MarkPaid(sessionID uint, paymentEntry, paymentMethod string, fee float64) (*dao.VehicleRecord, error) {
- if global.GVA_DB == nil {
- return nil, errors.New("数据库未初始化")
- }
- var session *dao.VehicleRecord
- err := global.GVA_DB.Transaction(func(tx *gorm.DB) error {
- var err error
- session, err = s.MarkPaidTx(tx, sessionID, paymentEntry, paymentMethod, fee)
- return err
- })
- return session, err
- }
- func (s *ParkingSessionService) CloseSession(req request.CloseSessionRequest) (*dao.VehicleRecord, error) {
- if global.GVA_DB == nil {
- return nil, errors.New("数据库未初始化")
- }
- var session *dao.VehicleRecord
- err := global.GVA_DB.Transaction(func(tx *gorm.DB) error {
- var err error
- session, err = s.CloseSessionTx(tx, req)
- return err
- })
- return session, err
- }
- func (s *ParkingSessionService) ResolveActiveSession(req request.ResolveActiveSessionRequest) (*dao.VehicleRecord, error) {
- return s.ResolveActiveSessionTx(global.GVA_DB, req)
- }
- // ResolveActiveSessionTx resolves one active parking session deterministically.
- // When both identities are supplied, it refuses to pick an arbitrary session
- // if the plate and RFID point to different active records.
- func (s *ParkingSessionService) ResolveActiveSessionTx(db *gorm.DB, req request.ResolveActiveSessionRequest) (*dao.VehicleRecord, error) {
- if db == nil {
- return nil, errors.New("数据库未初始化")
- }
- if req.SessionID != 0 {
- session, err := s.repo.FindActiveByIDTx(db, req.SessionID)
- if err == nil {
- return session, nil
- }
- return nil, sessionLookupError(db, req.SessionID, err)
- }
- plateNumber := strings.TrimSpace(req.PlateNumber)
- rfidTag := strings.TrimSpace(req.RFIDTag)
- if plateNumber == "" && rfidTag == "" {
- return nil, ErrSessionIdentifierEmpty
- }
- if plateNumber != "" && rfidTag != "" {
- if session, err := s.repo.FindActiveByExactIdentityTx(db, plateNumber, rfidTag); err == nil {
- return session, nil
- } else if !errors.Is(err, gorm.ErrRecordNotFound) {
- return nil, err
- }
- }
- var plateSessions, rfidSessions []dao.VehicleRecord
- var err error
- if plateNumber != "" {
- plateSessions, err = s.repo.FindActiveByPlateTx(db, plateNumber)
- if err != nil {
- return nil, err
- }
- }
- if rfidTag != "" {
- rfidSessions, err = s.repo.FindActiveByRFIDTx(db, rfidTag)
- if err != nil {
- return nil, err
- }
- }
- if len(plateSessions) > 1 || len(rfidSessions) > 1 {
- return nil, ErrSessionAmbiguous
- }
- if len(plateSessions) == 1 && len(rfidSessions) == 1 && plateSessions[0].ID != rfidSessions[0].ID {
- return nil, ErrSessionIdentityConflict
- }
- if len(plateSessions) == 1 {
- return &plateSessions[0], nil
- }
- if len(rfidSessions) == 1 {
- return &rfidSessions[0], nil
- }
- return nil, ErrSessionNotFound
- }
- func sessionLookupError(db *gorm.DB, id uint, err error) error {
- if !errors.Is(err, gorm.ErrRecordNotFound) {
- return err
- }
- var session dao.VehicleRecord
- if lookupErr := db.Unscoped().First(&session, id).Error; lookupErr == nil {
- if session.ExitTime != nil {
- return ErrSessionAlreadyClosed
- }
- if session.PaymentStatus == PaymentStatusPaid {
- return ErrSessionAlreadyPaid
- }
- return ErrSessionNotFound
- } else if !errors.Is(lookupErr, gorm.ErrRecordNotFound) {
- return lookupErr
- }
- return ErrSessionNotFound
- }
- // OpenSessionTx 创建持久化停车会话。
- // 在调用方事务中再次查询在场会话,确保校验和写入属于同一个原子操作。
- func (s *ParkingSessionService) OpenSessionTx(db *gorm.DB, req request.OpenSessionRequest) (*dao.VehicleRecord, error) {
- if db == nil {
- return nil, errors.New("数据库未初始化")
- }
- req.PlateNumber = strings.TrimSpace(req.PlateNumber)
- req.RFIDTag = strings.TrimSpace(req.RFIDTag)
- if req.PlateNumber == "" && req.RFIDTag == "" {
- return nil, ErrSessionIdentifierEmpty
- }
- _, err := s.ResolveActiveSessionTx(db, request.ResolveActiveSessionRequest{
- PlateNumber: req.PlateNumber,
- RFIDTag: req.RFIDTag,
- })
- if err == nil {
- return nil, ErrSessionAlreadyOpen
- }
- if !errors.Is(err, ErrSessionNotFound) {
- return nil, err
- }
- if err := reserveParkingSpaceTx(db, req.ParkingLotID, req.AccessProfile); err != nil {
- return nil, err
- }
- entryTime := req.EntryTime
- if entryTime.IsZero() {
- entryTime = time.Now()
- }
- session := &dao.VehicleRecord{
- PlateNumber: req.PlateNumber,
- RFIDTag: req.RFIDTag,
- EntryTime: entryTime,
- ParkingLotID: req.ParkingLotID,
- ParkingSpaceID: req.ParkingSpaceID,
- EntryChannelID: req.EntryChannelID,
- EntryChannelCode: req.EntryChannelCode,
- EntryChannelName: req.EntryChannelName,
- EntryDeviceCode: req.EntryDeviceCode,
- EntryDeviceName: req.EntryDeviceName,
- EntryImage: req.EntryImage,
- PaymentStatus: PaymentStatusUnpaid,
- }
- if err := s.repo.CreateTx(db, session); err != nil {
- return nil, err
- }
- return session, nil
- }
- // reserveParkingSpaceTx 在创建会话前原子增加停车场占用数。
- // 满位时只有停车场明确允许的车辆身份可以继续入场。
- func reserveParkingSpaceTx(db *gorm.DB, parkingLotID uint, profile request.EntryAccessProfile) error {
- if parkingLotID == 0 {
- return nil
- }
- result := db.Model(&dao.ParkingLot{}).
- Where(`id = ? AND (
- occupied < capacity OR
- (? AND allow_temporary_when_full) OR
- (? AND allow_monthly_when_full) OR
- (? AND allow_vip_when_full)
- )`, parkingLotID, profile.IsTemporary, profile.HasValidMonthlyCard, profile.IsVIP).
- UpdateColumns(map[string]interface{}{
- "occupied": gorm.Expr("occupied + 1"),
- "available": gorm.Expr("CASE WHEN capacity > occupied + 1 THEN capacity - occupied - 1 ELSE 0 END"),
- })
- if result.Error != nil {
- return fmt.Errorf("更新停车场占用数失败: %w", result.Error)
- }
- if result.RowsAffected == 1 {
- return nil
- }
- var lot dao.ParkingLot
- if err := db.First(&lot, parkingLotID).Error; err != nil {
- if errors.Is(err, gorm.ErrRecordNotFound) {
- return ErrParkingLotNotFound
- }
- return err
- }
- if lot.Occupied < lot.Capacity {
- return errors.New("停车场车位占用状态更新失败")
- }
- switch {
- case profile.IsVIP && !lot.AllowVIPWhenFull:
- return fmt.Errorf("%w,当前停车场不允许 VIP 车满位入场", ErrParkingLotFull)
- case profile.HasValidMonthlyCard && !lot.AllowMonthlyWhenFull:
- return fmt.Errorf("%w,当前停车场不允许月租车满位入场", ErrParkingLotFull)
- case profile.IsTemporary && !lot.AllowTemporaryWhenFull:
- return fmt.Errorf("%w,当前停车场不允许临时车满位入场", ErrParkingLotFull)
- default:
- return ErrParkingLotFull
- }
- }
- // releaseParkingSpaceTx 在会话关闭后释放占用,并根据实际占用数重新计算余位。
- func releaseParkingSpaceTx(db *gorm.DB, parkingLotID uint) error {
- if parkingLotID == 0 {
- return nil
- }
- result := db.Model(&dao.ParkingLot{}).Where("id = ?", parkingLotID).
- UpdateColumn("occupied", gorm.Expr("CASE WHEN occupied > 0 THEN occupied - 1 ELSE 0 END"))
- if result.Error != nil {
- return fmt.Errorf("释放停车场占用数失败: %w", result.Error)
- }
- if result.RowsAffected != 1 {
- return ErrParkingLotNotFound
- }
- if err := db.Model(&dao.ParkingLot{}).Where("id = ?", parkingLotID).
- UpdateColumn("available", gorm.Expr("CASE WHEN capacity > occupied THEN capacity - occupied ELSE 0 END")).Error; err != nil {
- return fmt.Errorf("更新停车场余位失败: %w", err)
- }
- return nil
- }
- // MarkPaidTx 更新在场会话的当前结算摘要。
- // 实际资金流水仍以支付模块创建的 payment_record 为准。
- func (s *ParkingSessionService) MarkPaidTx(db *gorm.DB, sessionID uint, paymentEntry, paymentMethod string, fee float64) (*dao.VehicleRecord, error) {
- if db == nil {
- return nil, errors.New("数据库未初始化")
- }
- if sessionID == 0 {
- return nil, ErrSessionNotFound
- }
- if math.IsNaN(fee) || math.IsInf(fee, 0) || fee < 0 {
- return nil, errors.New("停车费用必须是有限的非负数")
- }
- session, err := s.ResolveActiveSessionTx(db, request.ResolveActiveSessionRequest{SessionID: sessionID})
- if err != nil {
- return nil, err
- }
- if session.PaymentStatus == PaymentStatusPaid {
- return nil, ErrSessionAlreadyPaid
- }
- rows, err := s.repo.UpdateUnpaidActiveTx(db, sessionID, map[string]interface{}{
- "payment_status": PaymentStatusPaid,
- "payment_entry": paymentEntry,
- "payment_method": paymentMethod,
- "fee": fee,
- })
- if err != nil {
- return nil, fmt.Errorf("更新停车会话支付状态失败: %w", err)
- }
- if rows != 1 {
- return nil, sessionLookupError(db, sessionID, gorm.ErrRecordNotFound)
- }
- session.PaymentStatus = PaymentStatusPaid
- session.PaymentEntry = paymentEntry
- session.PaymentMethod = paymentMethod
- session.Fee = fee
- return session, nil
- }
- // CloseSessionTx 只允许将一个已支付的停车会话结算出场一次。
- func (s *ParkingSessionService) CloseSessionTx(db *gorm.DB, req request.CloseSessionRequest) (*dao.VehicleRecord, error) {
- if db == nil {
- return nil, errors.New("数据库未初始化")
- }
- if req.SessionID == 0 {
- return nil, ErrSessionNotFound
- }
- if req.StayTime < 0 || math.IsNaN(req.Fee) || math.IsInf(req.Fee, 0) || req.Fee < 0 {
- return nil, errors.New("停车会话结算数据无效")
- }
- session, err := s.ResolveActiveSessionTx(db, request.ResolveActiveSessionRequest{SessionID: req.SessionID})
- if err != nil {
- return nil, err
- }
- exitTime := req.ExitTime
- if exitTime.IsZero() {
- exitTime = time.Now()
- }
- paymentStatus := req.PaymentStatus
- if paymentStatus == "" {
- paymentStatus = PaymentStatusPaid
- }
- updates := map[string]interface{}{
- "exit_time": exitTime,
- "stay_time": req.StayTime,
- "fee": req.Fee,
- "payment_status": paymentStatus,
- "payment_entry": req.PaymentEntry,
- "payment_method": req.PaymentMethod,
- }
- if req.ExitImage != "" {
- updates["exit_image"] = req.ExitImage
- }
- if req.ExitChannelID != 0 {
- updates["exit_channel_id"] = req.ExitChannelID
- }
- if req.ExitChannelCode != "" {
- updates["exit_channel_code"] = req.ExitChannelCode
- }
- if req.ExitChannelName != "" {
- updates["exit_channel_name"] = req.ExitChannelName
- }
- if req.ExitDeviceCode != "" {
- updates["exit_device_code"] = req.ExitDeviceCode
- }
- if req.ExitDeviceName != "" {
- updates["exit_device_name"] = req.ExitDeviceName
- }
- rows, err := s.repo.UpdateActiveTx(db, req.SessionID, updates)
- if err != nil {
- return nil, fmt.Errorf("关闭停车会话失败: %w", err)
- }
- if rows != 1 {
- return nil, sessionLookupError(db, req.SessionID, gorm.ErrRecordNotFound)
- }
- if err := releaseParkingSpaceTx(db, session.ParkingLotID); err != nil {
- return nil, err
- }
- session.ExitTime = &exitTime
- session.StayTime = req.StayTime
- session.Fee = req.Fee
- session.PaymentStatus = paymentStatus
- session.PaymentEntry = req.PaymentEntry
- session.PaymentMethod = req.PaymentMethod
- if req.ExitImage != "" {
- session.ExitImage = req.ExitImage
- }
- if req.ExitChannelID != 0 {
- session.ExitChannelID = req.ExitChannelID
- }
- if req.ExitChannelCode != "" {
- session.ExitChannelCode = req.ExitChannelCode
- }
- if req.ExitChannelName != "" {
- session.ExitChannelName = req.ExitChannelName
- }
- if req.ExitDeviceCode != "" {
- session.ExitDeviceCode = req.ExitDeviceCode
- }
- if req.ExitDeviceName != "" {
- session.ExitDeviceName = req.ExitDeviceName
- }
- return session, nil
- }
|