package service import ( "context" "strings" "time" "github.com/kennethcheng/fuellog-api/internal/dto" "github.com/kennethcheng/fuellog-api/internal/model" "github.com/kennethcheng/fuellog-api/internal/repository" "github.com/kennethcheng/fuellog-api/pkg/apperror" money "github.com/kennethcheng/fuellog-api/pkg/decimal" ) // RefuelRecordService 加油记录业务逻辑。 type RefuelRecordService interface { Create(ctx context.Context, in *dto.RefuelCreate) (*model.RefuelRecord, error) Get(ctx context.Context, id uint64) (*model.RefuelRecord, error) Update(ctx context.Context, id uint64, in *dto.RefuelUpdate) (*model.RefuelRecord, error) Delete(ctx context.Context, id uint64) error ListByVehicle(ctx context.Context, vehicleID uint64, start, end *time.Time) ([]model.RefuelRecord, error) // Statistics 返回单车的关键统计指标(供 Dashboard 使用)。 Statistics(ctx context.Context, vehicleID uint64) (*RefuelStatistics, error) } func NewRefuelRecordService( recRepo repository.RefuelRecordRepository, vehicleRepo repository.VehicleRepository, ) RefuelRecordService { return &refuelService{recRepo: recRepo, vehicleRepo: vehicleRepo} } type refuelService struct { recRepo repository.RefuelRecordRepository vehicleRepo repository.VehicleRepository } // RefuelStatistics 单车统计指标。 type RefuelStatistics struct { VehicleID uint64 `json:"vehicle_id"` TotalRecords int `json:"total_records"` TotalLiters float64 `json:"total_liters"` TotalAmount float64 `json:"total_amount"` AvgPrice float64 `json:"avg_price"` // 实际平均油价 AvgConsumption float64 `json:"avg_consumption"` // 标准百公里油耗(仅 is_full=true 序列) CostPerKm float64 `json:"cost_per_km"` // 每公里成本 LastRefuel *model.RefuelRecord `json:"last_refuel,omitempty"` } func (s *refuelService) Create(ctx context.Context, in *dto.RefuelCreate) (*model.RefuelRecord, error) { // 校验车辆存在 if _, err := s.vehicleRepo.Get(ctx, in.VehicleID); err != nil { return nil, err } if in.Liters <= 0 { return nil, apperror.New(apperror.CodeInvalidArg, "加油量必须大于 0") } if in.TotalAmount < 0 { return nil, apperror.New(apperror.CodeInvalidArg, "总金额不能小于 0") } // 时区规整:统一转 UTC 存库 t := in.RefuelTime.UTC() if t.IsZero() { return nil, apperror.New(apperror.CodeInvalidArg, "加油时间不能为空") } rec := &model.RefuelRecord{ VehicleID: in.VehicleID, RefuelTime: t, Odometer: in.Odometer, FuelType: strings.TrimSpace(in.FuelType), Liters: in.Liters, PricePerLiter: money.PricePerLiter(in.TotalAmount, in.Liters), TotalAmount: in.TotalAmount, StationName: strings.TrimSpace(in.StationName), IsFull: in.IsFull, Remark: in.Remark, } if err := s.recRepo.Create(ctx, rec); err != nil { return nil, err } return rec, nil } func (s *refuelService) Get(ctx context.Context, id uint64) (*model.RefuelRecord, error) { return s.recRepo.Get(ctx, id) } func (s *refuelService) Update(ctx context.Context, id uint64, in *dto.RefuelUpdate) (*model.RefuelRecord, error) { rec, err := s.recRepo.Get(ctx, id) if err != nil { return nil, err } if in.RefuelTime != nil { rec.RefuelTime = in.RefuelTime.UTC() } if in.Odometer != nil { rec.Odometer = *in.Odometer } if in.FuelType != nil { rec.FuelType = strings.TrimSpace(*in.FuelType) } if in.Liters != nil { if *in.Liters <= 0 { return nil, apperror.New(apperror.CodeInvalidArg, "加油量必须大于 0") } rec.Liters = *in.Liters } if in.TotalAmount != nil { if *in.TotalAmount < 0 { return nil, apperror.New(apperror.CodeInvalidArg, "总金额不能小于 0") } rec.TotalAmount = *in.TotalAmount } if in.StationName != nil { rec.StationName = strings.TrimSpace(*in.StationName) } if in.IsFull != nil { rec.IsFull = *in.IsFull } if in.Remark != nil { rec.Remark = *in.Remark } // 实际油价随 liters / total_amount 变化 rec.PricePerLiter = money.PricePerLiter(rec.TotalAmount, rec.Liters) if err := s.recRepo.Update(ctx, rec); err != nil { return nil, err } return rec, nil } func (s *refuelService) Delete(ctx context.Context, id uint64) error { return s.recRepo.Delete(ctx, id) } func (s *refuelService) ListByVehicle(ctx context.Context, vehicleID uint64, start, end *time.Time) ([]model.RefuelRecord, error) { if _, err := s.vehicleRepo.Get(ctx, vehicleID); err != nil { return nil, err } return s.recRepo.ListByVehicle(ctx, vehicleID, start, end) } func (s *refuelService) Statistics(ctx context.Context, vehicleID uint64) (*RefuelStatistics, error) { if _, err := s.vehicleRepo.Get(ctx, vehicleID); err != nil { return nil, err } records, err := s.recRepo.ListByVehicle(ctx, vehicleID, nil, nil) if err != nil { return nil, err } stats := &RefuelStatistics{VehicleID: vehicleID, TotalRecords: len(records)} if len(records) == 0 { return stats, nil } var ( totalLiters float64 totalAmount float64 prices []float64 consumptions []float64 costPerKmSample []float64 ) // ListByVehicle 默认按 refuel_time DESC;遍历一次累加,同时算油耗序列 // 标准油耗只在"连续两次 is_full=true"之间计算 var prevFull *model.RefuelRecord for i := range records { r := records[i] totalLiters += r.Liters totalAmount += r.TotalAmount prices = append(prices, r.PricePerLiter) if r.IsFull { if prevFull != nil { mileage := money.Mileage(r.Odometer, prevFull.Odometer) cons := money.ConsumptionL100km(r.Liters, mileage) cost := money.CostPerKm(r.TotalAmount, mileage) if cons > 0 { consumptions = append(consumptions, cons) } if cost > 0 { costPerKmSample = append(costPerKmSample, cost) } } // 浅拷贝保留 prev cp := r prevFull = &cp } } stats.TotalLiters = round2(totalLiters) stats.TotalAmount = round2(totalAmount) stats.AvgPrice = money.AvgFloat64(prices) stats.AvgConsumption = money.AvgFloat64(consumptions) stats.CostPerKm = money.AvgFloat64(costPerKmSample) // 最近一次加油:列表已按时间倒序,第一条即最近 last := records[0] stats.LastRefuel = &last return stats, nil } func round2(x float64) float64 { // 简单两位四舍五入,避免 import math 而本文件只用一次 // 实际更推荐用 shopspring/decimal.Decimal,这里精度损失可接受 return float64(int64(x*100+0.5)) / 100 }