MO-Fuel-Tax-Back/lib/services/fuel_report.dart

171 lines
6.8 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import '../models/fuel_entry.dart';
import '../models/vehicle.dart';
/// One vehicle's totals — and, for the line-items/receipt-images report
/// sections, its individual entries — within a [FuelReport]'s date range.
/// [entries] is sorted oldest-first, the natural order for a ledger of
/// line items building up to a total.
class VehicleReportRow {
final Vehicle vehicle;
final List<FuelEntry> entries;
final double totalGallons;
final double totalCost;
VehicleReportRow({
required this.vehicle,
required this.entries,
required this.totalGallons,
required this.totalCost,
});
int get entryCount => entries.length;
}
/// One entry paired with the vehicle it belongs to — [FuelEntry] only knows
/// [FuelEntry.vehicleId], not the [Vehicle] itself, so anything that needs
/// to show both together (like [FuelReport.allEntries], for a combined
/// cross-vehicle list) needs this pairing.
class ReportEntry {
final FuelEntry entry;
final Vehicle vehicle;
ReportEntry({required this.entry, required this.vehicle});
}
/// Every qualifying entry in a single calendar month, across every vehicle
/// in the report — the "Gallons by month" breakdown is this, one per
/// month present in [FuelReport.monthlyTotals].
class MonthlyTotal {
/// The first of the month, e.g. `DateTime(2026, 8)` for August 2026.
final DateTime month;
final int fillCount;
final double totalGallons;
final double totalCost;
MonthlyTotal({
required this.month,
required this.fillCount,
required this.totalGallons,
required this.totalCost,
});
double get avgPricePerGallon => totalGallons == 0 ? 0 : totalCost / totalGallons;
}
/// Per-vehicle fuel totals for entries with a receipt, dated within
/// [startDate]–[endDate] inclusive. See [buildFuelReport].
class FuelReport {
final DateTime startDate;
final DateTime endDate;
final List<VehicleReportRow> rows;
FuelReport({required this.startDate, required this.endDate, required this.rows});
double get totalGallons => rows.fold(0.0, (sum, r) => sum + r.totalGallons);
double get totalCost => rows.fold(0.0, (sum, r) => sum + r.totalCost);
int get fillCount => rows.fold(0, (sum, r) => sum + r.entryCount);
double get avgPricePerGallon => totalGallons == 0 ? 0 : totalCost / totalGallons;
/// Every entry across every vehicle, oldest first — the natural order for
/// a combined "purchase detail" ledger that isn't grouped by vehicle.
List<ReportEntry> get allEntries {
final list = [
for (final row in rows)
for (final entry in row.entries) ReportEntry(entry: entry, vehicle: row.vehicle),
];
list.sort((a, b) => a.entry.date.compareTo(b.entry.date));
return list;
}
/// [allEntries] grouped by calendar month, in chronological order — the
/// "Purchase detail" section's grouping, and the source data for
/// [monthlyTotals].
Map<DateTime, List<ReportEntry>> get entriesByMonth {
final map = <DateTime, List<ReportEntry>>{};
for (final reportEntry in allEntries) {
final month = DateTime(reportEntry.entry.date.year, reportEntry.entry.date.month);
(map[month] ??= []).add(reportEntry);
}
return map;
}
/// One [MonthlyTotal] per calendar month with qualifying entries, oldest
/// first — the "Gallons by month" chart/table's data.
List<MonthlyTotal> get monthlyTotals {
final byMonth = entriesByMonth;
final months = byMonth.keys.toList()..sort();
return [
for (final month in months)
MonthlyTotal(
month: month,
fillCount: byMonth[month]!.length,
totalGallons: byMonth[month]!.fold(0.0, (sum, re) => sum + re.entry.gallons),
totalCost: byMonth[month]!.fold(0.0, (sum, re) => sum + re.entry.totalCost),
),
];
}
}
/// The date range the Reports tab defaults to on open: a year-long window
/// from July 1 through the following June 30, matching how Missouri's
/// fuel tax refund periods run. Which specific year that window falls in
/// rolls over on August 1: from August of year Y through July of year
/// Y+1, this stays July Y–June (Y+1) throughout — the most recently
/// completed (or currently running) period — rather than jumping to a
/// brand new, still-empty one the moment August 1 hits.
(DateTime start, DateTime end) defaultReportDateRange(DateTime now) {
final fiscalStartYear = now.month >= 8 ? now.year : now.year - 1;
return (DateTime(fiscalStartYear, 7, 1), DateTime(fiscalStartYear + 1, 6, 30));
}
/// Builds a per-vehicle fuel summary for [startDate]–[endDate] (inclusive,
/// judged by each entry's purchase date, in local time), restricted to
/// entries that actually have a receipt attached — a report meant to
/// substantiate a fuel tax refund claim is only as good as the
/// documentation behind each entry, so an entry logged without a photo
/// isn't something the report should be claiming on your behalf. "Has a
/// receipt" means a local copy or a cloud copy — [FuelEntry.receiptImagePath]
/// or [FuelEntry.receiptDriveFileId] — not specifically an already-synced
/// one, since sync status is a technical detail unrelated to whether the
/// purchase is documented.
///
/// Vehicles with no qualifying entries in range are omitted rather than
/// shown with an all-zero row. Rows are sorted by [Vehicle.displayLabel].
FuelReport buildFuelReport({
required List<Vehicle> vehicles,
required List<FuelEntry> fuelEntries,
required DateTime startDate,
required DateTime endDate,
}) {
final rangeStart = DateTime(startDate.year, startDate.month, startDate.day);
final rangeEndExclusive =
DateTime(endDate.year, endDate.month, endDate.day).add(const Duration(days: 1));
final vehiclesById = {for (final v in vehicles) v.id: v};
final entriesByVehicle = <String, List<FuelEntry>>{};
for (final entry in fuelEntries) {
final hasReceipt = entry.receiptImagePath != null || entry.receiptDriveFileId != null;
if (!hasReceipt) continue;
if (entry.date.isBefore(rangeStart) || !entry.date.isBefore(rangeEndExclusive)) continue;
(entriesByVehicle[entry.vehicleId] ??= []).add(entry);
}
final rows = <VehicleReportRow>[];
for (final entry in entriesByVehicle.entries) {
final vehicle = vehiclesById[entry.key];
// No matching (active) vehicle — e.g. deleted since — nothing sane to
// attribute these entries to.
if (vehicle == null) continue;
final sortedEntries = entry.value.toList()..sort((a, b) => a.date.compareTo(b.date));
rows.add(VehicleReportRow(
vehicle: vehicle,
entries: sortedEntries,
totalGallons: sortedEntries.fold(0.0, (sum, e) => sum + e.gallons),
totalCost: sortedEntries.fold(0.0, (sum, e) => sum + e.totalCost),
));
}
rows.sort((a, b) => a.vehicle.displayLabel.compareTo(b.vehicle.displayLabel));
return FuelReport(startDate: rangeStart, endDate: endDate, rows: rows);
}