using MeterVault.Core.Analysis; using MeterVault.Core.Analysis.Costing; using MeterVault.Core.Analysis.Coverage; using MeterVault.Core.Analysis.Quantities; using MeterVault.Core.Analysis.Totals; using MeterVault.Core.Analysis.Virtual; using MeterVault.Core.Domain; using MeterVault.Core.Normalization; using MeterVault.Infrastructure.Analysis; using MeterVault.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; namespace MeterVault.Infrastructure.Costing; /// /// One cost request from catalog to priced result: which lines, standing charges and manual costs the scope holds, the /// quantities they need (read once, through the analysis reader), and their prices (the Core calculator). /// /// /// /// The order is fixed: catalog, tariffs, manual costs and categories are loaded once → the bill is classified with the /// tariff book (D-35) → the scope becomes pricing groups (a type's bill, a category, a meter) → the buckets are planned /// and cut into local months (D-36) → the quantities of every meter any group prices are read in one reader pass → each /// group is priced twice: per bucket, and over the period's own months for its total. /// /// /// A figure that is the sum of several groups (the portfolio: every type's bill plus the global standing charge and the /// meterless manual costs) is added up with , so it reconciles with its parts exactly. /// /// internal sealed class BillRun { private readonly MeterVaultDbContext _db; private readonly AnalysisReader _reader; private readonly CostAnalysisRequest _request; private readonly ResolvedPeriod _period; private readonly TimeZoneInfo _zone; private readonly string _currency; private readonly DateOnly _today; private readonly HashSet _notCosted = []; private readonly Dictionary _firstData = []; private readonly List _quantityProblems = []; /// A catalog the caller already loaded and wants this run to reuse (A-40); null to load one. private readonly AnalysisCatalog? _sharedCatalog; private AnalysisCatalog _catalog = null!; private TariffBook _book = null!; private TotalsClassification _full = null!; private List _manual = []; private List _categories = []; private Dictionary> _virtualSources = []; private BucketPlan _plan = null!; private AnalysisBucket _periodBucket = null!; private IReadOnlyList _displayParts = []; private IReadOnlyList _periodParts = []; private Dictionary> _displayValues = []; private Dictionary> _periodValues = []; private Dictionary> _displaySpans = []; private Dictionary> _periodSpans = []; private Dictionary _series = []; public BillRun( MeterVaultDbContext db, AnalysisReader reader, CostAnalysisRequest request, string currency, AnalysisCatalog? catalog = null) { _db = db; _reader = reader; _request = request; _period = request.Period; _zone = reader.Zone; _currency = currency; _today = PeriodResolver.LocalDate(_period.Now, _zone); _sharedCatalog = catalog; } private bool WantsCategories => _request.Scope.Kind == CostScopeKind.Category || (_request.Scope.Kind == CostScopeKind.Portfolio && _request.IncludeCategories); public async Task ExecuteAsync(CancellationToken cancellationToken) { await LoadAsync(WantsCategories, cancellationToken).ConfigureAwait(false); if (ScopeFor() is not { } scope) { return Refused(EmptyPlan(), CostRefusal.UnknownScope); } var plan = await PlanAsync(scope, cancellationToken).ConfigureAwait(false); if (plan.Refused) { return Refused(plan, CostRefusal.TooManyPoints); } _plan = plan; _periodBucket = PeriodBucket.Of(_period); _displayParts = CostCalculator.Parts(plan.Buckets); _periodParts = CostCalculator.Parts([_periodBucket]); await ReadQuantitiesAsync(scope, cancellationToken).ConfigureAwait(false); await LoadServiceAsync(cancellationToken).ConfigureAwait(false); foreach (var group in scope.AllGroups()) { Price(group); } return Compose(scope); } /// What a scope has data for (D-19): its priced meters' coverage and its manual costs, without pricing. public async Task AvailabilityAsync(CancellationToken cancellationToken) { await LoadAsync(WantsCategories, cancellationToken).ConfigureAwait(false); if (ScopeFor() is not { } scope) { return CostAvailability.None; } var metered = await _reader.QuantityAvailabilityAsync(_db, _catalog, scope.AvailabilityMeters, _period.Now, cancellationToken) .ConfigureAwait(false); return Availability(metered, scope.ManualInScope); } // ------------------------------------------------------------------------------------------------ loading private async Task LoadAsync(bool categories, CancellationToken cancellationToken) { // The catalog is a snapshot of meters, tanks, links and rollup states; a caller that reads several figures of // one page from it (the Overview's bill and its comparison) passes the one it already holds, so they answer // from the same snapshot and the load is not repeated (A-40). _catalog = _sharedCatalog ?? await AnalysisCatalog.LoadAsync(_db, _zone, cancellationToken).ConfigureAwait(false); // One tariff load per request; ordered so that the book's deterministic tie-break never depends on the plan. var tariffs = await _db.Tariffs.AsNoTracking().OrderBy(t => t.Id).ToListAsync(cancellationToken).ConfigureAwait(false); _book = TariffBook.Create(tariffs, _currency); _full = _catalog.Classify(_book.HasMeterScopedUnitPrice); _virtualSources = _catalog.TotalsMeters.Where(m => m.IsVirtual).ToDictionary(m => m.Id, m => m.VirtualSources); _manual = await _db.ManualCosts.AsNoTracking().OrderBy(c => c.Id).ToListAsync(cancellationToken).ConfigureAwait(false); if (categories) { _categories = await _db.CostCategories.AsNoTracking() .Include(c => c.Members) .OrderBy(c => c.Sort) .ThenBy(c => c.Id) .ToListAsync(cancellationToken) .ConfigureAwait(false); } } // ------------------------------------------------------------------------------------------------ scope → groups /// The scope as pricing groups, or null when its meter or category does not exist. private ScopePlan? ScopeFor() { var scope = _request.Scope; switch (scope.Kind) { case CostScopeKind.Portfolio: { var plan = new ScopePlan { AvailabilityMeters = [.. _full.BillItems.Order()], ManualInScope = [.. _manual] }; foreach (var type in TypesWithMeters()) { plan.Types.Add((type, TypeGroup(type))); } plan.Portfolio = new PricingGroup(); if (_book.HasAnyInScope(TariffComponent.BasePrice, TariffScope.Global, null)) { plan.Portfolio.Rows.Add(new StandingChargeKey(TariffScope.Global, null)); } plan.Portfolio.Manual.AddRange(_manual.Where(c => c.MeterId is not { } id || _catalog.Find(id) is null)); if (_request.IncludeCategories) { plan.Categories = PlanCategories(); } return plan; } case CostScopeKind.EnergyType: { var type = scope.Id!.Value; var plan = new ScopePlan { AvailabilityMeters = [.. _full.ForType(type).Billing.Items.Order()], ManualInScope = [.. _manual.Where(c => MeterType(c.MeterId) == type)], }; plan.Types.Add((type, TypeGroup(type))); return plan; } case CostScopeKind.Meter: { if (_catalog.Find(scope.Id!.Value) is not { } meter) { return null; } var (group, info) = MeterGroup(meter); return new ScopePlan { Single = group, Meter = info, AvailabilityMeters = [meter.Id], ManualInScope = [.. group.Manual], OwnSemantics = !meter.IsVirtual, }; } default: { if (_categories.Find(c => c.Id == scope.Id) is null) { return null; } var categories = PlanCategories(); var entry = categories.Entries[scope.Id!.Value]; return new ScopePlan { Single = entry.Group, CategoryEntry = entry, Categories = categories, AvailabilityMeters = entry.Cover.CoverMeterIds, ManualInScope = [.. entry.Group.Manual], }; } } } private IEnumerable TypesWithMeters() => _catalog.Meters.Values.Select(m => m.EnergyTypeId).Distinct().Order(); private int? MeterType(int? meterId) => meterId is { } id && _catalog.Find(id) is { } meter ? meter.EnergyTypeId : null; /// A type's bill (D-34, D-35): its lines, its own standing charge (D-40), the manual costs of its meters (D-41). private PricingGroup TypeGroup(int type) { var billing = _full.ForType(type).Billing; var group = new PricingGroup { EnergyTypeId = type, Basis = billing.Basis }; foreach (var line in billing.Lines) { group.Lines.AddRange(Expand(line, billing.SeparatelyBilled)); } if (_book.HasAnyInScope(TariffComponent.BasePrice, TariffScope.EnergyType, type)) { group.Rows.Add(new StandingChargeKey(TariffScope.EnergyType, type)); } group.Rows.AddRange(MeterFeeRows(type, group.Lines)); group.Manual.AddRange(_manual.Where(c => MeterType(c.MeterId) == type)); return group; } /// /// A-18: the meter-scoped standing charges of a type's physical meters that no line of /// accrues — a PV or house meter behind the billed grid meter — each as a row of its own on its meter, so a meter fee /// is charged once, on its meter (D-40), rather than dropped because its meter is not a bill line. /// private List MeterFeeRows(int type, IEnumerable lines) { var lined = lines.Select(l => l.MeterId).ToHashSet(); return [.. _catalog.Meters.Values .Where(m => m.EnergyTypeId == type && !m.IsVirtual && !lined.Contains(m.Id) && _book.HasAnyInScope(TariffComponent.BasePrice, TariffScope.Meter, m.Id)) .Select(m => m.Id) .Order() .Select(id => new StandingChargeKey(TariffScope.Meter, id))]; } /// /// A meter's own cost (D-39 for virtual meters): the line the bill prices it with, else a view at its unit price or /// its feed-in credit; a virtual meter by its cost rule. Its manual costs go with it (D-41). /// private (PricingGroup Group, MeterCostInfo Info) MeterGroup(AnalysisMeter meter) { var group = new PricingGroup(); group.Manual.AddRange(_manual.Where(c => c.MeterId == meter.Id)); var line = _full.LineOf(meter.Id); if (!meter.IsVirtual) { var own = OwnLines(meter); group.Lines.AddRange(own.Lines); // A meter the scope prices no quantity of (a generation meter) still carries its own fee (A-18). if (own.Lines.Count == 0 && _book.HasAnyInScope(TariffComponent.BasePrice, TariffScope.Meter, meter.Id)) { group.Rows.Add(new StandingChargeKey(TariffScope.Meter, meter.Id)); } return (group, new MeterCostInfo(meter.Id, own.Rule, own.OnBill, own.Reason, [])); } if (meter.Formula is null) { return (group, Info(MeterCostRule.None, line is not null, MeterNotCostedReason.NotEvaluable)); } var rule = meter.CostRule switch { VirtualCostRule.SourceCosts => MeterCostRule.SourceCosts, VirtualCostRule.OwnQuantity => MeterCostRule.OwnQuantity, _ => MeterCostRule.None, }; // A stored source-costs rule over a nested difference is taken as none (A-15), and a generation result is never // billed (D-34): say why rather than "no rule". var notCosted = rule != MeterCostRule.None ? MeterNotCostedReason.None : meter.Validation?.CostRuleProblem is not null ? MeterNotCostedReason.SourcesNotPureSum : meter.Quantity.Kind == QuantityKind.Generation ? MeterNotCostedReason.Generation : MeterNotCostedReason.NoCostRule; if (line is not null) { group.Lines.AddRange(Expand(line, SeparatelyBilledOf(meter.EnergyTypeId))); IReadOnlyList billedSources = rule == MeterCostRule.SourceCosts ? [.. group.Lines.Select(l => l.MeterId).Distinct().Order()] : []; return (group, new MeterCostInfo(meter.Id, rule, true, notCosted, billedSources)); } if (rule == MeterCostRule.SourceCosts) { if (CostSourcesOf(meter.Id) is not { } sourceIds) { return (group, Info(MeterCostRule.None, false, MeterNotCostedReason.SourcesNotPureSum)); } var lines = SourceLines(meter, [], []); if (lines.Count == 0) { // D-39 adds the sources' own costs; generation and runtime sources have none (D-34). return (group, Info(MeterCostRule.None, false, NotCostedReasonOf(sourceIds))); } group.Lines.AddRange(lines); return (group, new MeterCostInfo(meter.Id, rule, false, notCosted, [.. lines.Select(l => l.MeterId).Distinct().Order()])); } if (rule == MeterCostRule.OwnQuantity) { group.Lines.Add(new LineSpec(meter.Id, meter.EnergyTypeId, BillLineKind.UnitPrice, meter.Quantity.Unit, [], null)); } return (group, new MeterCostInfo(meter.Id, rule, false, notCosted, [])); MeterCostInfo Info(MeterCostRule costRule, bool onBill, MeterNotCostedReason reason = MeterNotCostedReason.None) => new(meter.Id, costRule, onBill, reason, []); } /// /// What a physical meter's own scope prices: its line on the bill; otherwise a view by what it measures — its /// consumption at its unit price, its export as a feed-in credit — or nothing, and why (generation and operating time /// are never billed, D-34). /// private OwnCost OwnLines(AnalysisMeter meter) { if (_full.LineOf(meter.Id) is { } line) { return new OwnCost([.. Expand(line, SeparatelyBilledOf(meter.EnergyTypeId))], MeterCostRule.BillLine, true, MeterNotCostedReason.None); } return meter.Quantity.Kind switch { QuantityKind.Consumption => new OwnCost( [new LineSpec(meter.Id, meter.EnergyTypeId, BillLineKind.UnitPrice, meter.Quantity.Unit, [], null)], MeterCostRule.UnitPriceView, false, MeterNotCostedReason.None), QuantityKind.Export => new OwnCost( [new LineSpec(meter.Id, meter.EnergyTypeId, BillLineKind.FeedIn, meter.Quantity.Unit, [], null)], MeterCostRule.FeedInView, false, MeterNotCostedReason.None), QuantityKind.Generation => new OwnCost([], MeterCostRule.None, false, MeterNotCostedReason.Generation), QuantityKind.Runtime => new OwnCost([], MeterCostRule.None, false, MeterNotCostedReason.Runtime), _ => new OwnCost([], MeterCostRule.None, false, MeterNotCostedReason.NoCostRule), }; } /// Why a sum whose sources price nothing has no cost: all generation, all operating time, or no rule at all. private MeterNotCostedReason NotCostedReasonOf(IReadOnlyList sourceIds) { var kinds = sourceIds.Select(id => _catalog.Meters[id].Quantity.Kind).Distinct().ToList(); return kinds switch { [QuantityKind.Generation] => MeterNotCostedReason.Generation, [QuantityKind.Runtime] => MeterNotCostedReason.Runtime, _ => MeterNotCostedReason.NoCostRule, }; } private IReadOnlyList SeparatelyBilledOf(int type) => _full.ForType(type).Billing.SeparatelyBilled; /// /// A bill line as priced lines. A physical meter is its own line. A virtual meter counted by an override (D-23) is /// priced by its cost rule (D-39): its own quantity, or its sources' metered costs — one line per source, each at /// its own price, never the sum repriced; with the rule none it is left out and reported. /// private IEnumerable Expand(BillLine line, IReadOnlyList separately, bool report = true) { var meter = _catalog.Meters[line.MeterId]; if (!meter.IsVirtual || meter.CostRule == VirtualCostRule.OwnQuantity) { return [new LineSpec(meter.Id, meter.EnergyTypeId, line.Kind, meter.Quantity.Unit, line.Deductions, null)]; } if (meter.CostRule == VirtualCostRule.SourceCosts) { return SourceLines(meter, line.Deductions, separately); } if (report) { _notCosted.Add(meter.Id); } return []; } /// /// D-39 sourceCosts: each physical source priced the way its own scope prices it (A-15) — a consumption source /// at its unit price (or its bill line), an export source as a feed-in credit, a generation or runtime source not at /// all — once per source the formula's weights name (). A separately billed subsection the /// bill takes out of the sum is taken out of the source it runs through. /// private List SourceLines(AnalysisMeter meter, IReadOnlyList deductions, IReadOnlyList separately) { var sources = CostSourcesOf(meter.Id) ?? []; var perSource = sources.Select(_ => new List()).ToList(); foreach (var deduction in deductions) { var through = separately.FirstOrDefault(s => s.MeterId == deduction.MeterId && s.SubtractFromMeterId == meter.Id)?.ThroughMeterId; var index = through is { } t ? Math.Max(0, sources.ToList().IndexOf(t)) : 0; if (perSource.Count == 0) { break; } var factor = UnitFactor(deduction.MeterId, sources[index]) ?? deduction.UnitFactor; perSource[index].Add(new BillDeduction(deduction.MeterId, factor)); } var lines = new List(); for (var i = 0; i < sources.Count; i++) { var own = OwnLines(_catalog.Meters[sources[i]]).Lines; for (var j = 0; j < own.Count; j++) { lines.Add(own[j] with { Deductions = j == 0 ? [.. own[j].Deductions, .. perSource[i]] : own[j].Deductions, ForMeterId = meter.Id }); } } return lines; } /// /// The physical sources whose metered costs a sourceCosts meter adds (D-39): the meters its formula's weights /// name — each once, so m1 + m1 - m1 + m2 is m1 and m2 — through nested pure sums. Null when a nested source /// is not a pure sum: its sources' costs would include a subtrahend's (A-15). /// private List? CostSourcesOf(int virtualId) { var result = new List(); var visiting = new HashSet(); return Collect(virtualId) ? result : null; bool Collect(int id) { if (_catalog.Find(id) is not { } meter) { return true; } if (!meter.IsVirtual) { result.Add(id); return true; } if (meter.Formula is not { IsPureSum: true, Coefficients: { } weights } || !visiting.Add(id)) { return false; } foreach (var source in weights.Keys.Order()) { if (!Collect(source)) { return false; } } visiting.Remove(id); return true; } } private IReadOnlyList SourcesOf(int virtualId) => [.. (_virtualSources.GetValueOrDefault(virtualId) ?? []).Where(id => _catalog.Find(id) is { IsVirtual: false })]; private double? UnitFactor(int from, int to) { var fromUnit = _catalog.Meters[from].Quantity.Unit; var toUnit = _catalog.Meters[to].Quantity.Unit; return Units.AreSame(fromUnit, toUnit) ? 1d : Units.ConversionFactor(fromUnit, toUnit); } // ------------------------------------------------------------------------------------------------ categories (D-42) /// /// Every category's cover, the parts of the bill each holds, which categories are slices of the bill and which are /// overlapping views, and the composition's groups. /// private CategoryPlan PlanCategories() { var plan = new CategoryPlan(); var billRows = BillRows(); var members = _categories.ToDictionary(c => c.Id, ExpandMembers); var covers = _categories.Select(c => CategoryCover.Compute(_full, c.Id, members[c.Id])).ToList(); var report = CategoryCover.CheckOverlap(_full, covers); var manualClaims = _manual.ToDictionary(c => c.Id, c => ClaimsOf(c, members)); var rowClaims = billRows.ToDictionary(r => r, r => _categories.Where(c => HoldsRow(c, r, members[c.Id])).Select(c => c.Id).ToList()); // A slice of the bill shares nothing with another slice: two line-disjoint categories claiming the same manual // cost or standing charge would add it twice, so both become views. var views = new HashSet(report.OverlappingViewIds); var candidates = _categories.Select(c => c.Id).Where(id => !views.Contains(id)).ToHashSet(); foreach (var claimants in manualClaims.Values.Concat(rowClaims.Values)) { var slices = claimants.Where(candidates.Contains).ToList(); if (slices.Count > 1) { views.UnionWith(slices); } } plan.Overlaps = Overlaps(report, manualClaims, rowClaims); var disjoint = _categories.Select(c => c.Id).Where(id => !views.Contains(id)).ToHashSet(); foreach (var (category, cover) in _categories.Zip(covers)) { var group = new PricingGroup(); var separately = cover.SeparatelyBilled; foreach (var line in cover.Lines) { group.Lines.AddRange(Expand(line, cover.LiesOutsideBill ? separately : SeparatelyBilledOf(_catalog.Meters[line.MeterId].EnergyTypeId))); } // A meter's own fee accrues on its line where the category prices one (A-18); only a meter without one needs its row. group.Rows.AddRange(rowClaims .Where(r => r.Value.Contains(category.Id)) .Select(r => r.Key) .Where(r => r.Scope != TariffScope.Meter || !group.Lines.Exists(l => l.MeterId == r.ScopeId))); group.Manual.AddRange(_manual.Where(c => manualClaims[c.Id].Contains(category.Id))); plan.Entries[category.Id] = new CategoryEntry(category, cover, group, views.Contains(category.Id)); } // Uncategorized: the bill lines and manual costs no slice holds; standing charges no slice holds are rows of their own. var categorized = disjoint.SelectMany(id => plan.Entries[id].Cover.CoverMeterIds).ToHashSet(); plan.UncategorizedMeterIds = [.. _full.BillItems.Where(id => !categorized.Contains(id)).Order()]; foreach (var id in plan.UncategorizedMeterIds) { plan.Uncategorized.Lines.AddRange(Expand(_full.LineOf(id)!, SeparatelyBilledOf(_catalog.Meters[id].EnergyTypeId))); } plan.Uncategorized.Manual.AddRange(_manual.Where(c => !manualClaims[c.Id].Exists(disjoint.Contains))); foreach (var row in billRows.Where(r => !rowClaims[r].Exists(disjoint.Contains))) { var group = new PricingGroup(); group.Rows.Add(row); plan.RowSlices.Add((row, group)); } plan.Disjoint = [.. _categories.Where(c => disjoint.Contains(c.Id)).Select(c => c.Id)]; return plan; } /// /// The standing-charge rows of the whole bill: each type with meters and a base price, the meter fees no bill line /// accrues (A-18, as adds them), and the global one. /// private List BillRows() { var rows = new List(); foreach (var type in TypesWithMeters()) { if (_book.HasAnyInScope(TariffComponent.BasePrice, TariffScope.EnergyType, type)) { rows.Add(new StandingChargeKey(TariffScope.EnergyType, type)); } var lines = _full.ForType(type).Billing.Lines.SelectMany(l => Expand(l, SeparatelyBilledOf(type), report: false)); rows.AddRange(MeterFeeRows(type, lines)); } if (_book.HasAnyInScope(TariffComponent.BasePrice, TariffScope.Global, null)) { rows.Add(new StandingChargeKey(TariffScope.Global, null)); } return rows; } /// A category's members with type members expanded to every meter of the type, known meters only, ascending. private IReadOnlyList ExpandMembers(CostCategory category) { var ids = new SortedSet(); foreach (var member in category.Members) { if (member.MeterId is { } meterId && _catalog.Find(meterId) is not null) { ids.Add(meterId); } if (member.EnergyTypeId is { } type) { ids.UnionWith(_catalog.Meters.Values.Where(m => m.EnergyTypeId == type).Select(m => m.Id)); } } return [.. ids]; } /// /// The categories a manual cost belongs to (D-41): its own category, else every category holding its meter. /// private List ClaimsOf(ManualCost cost, Dictionary> members) { if (cost.CategoryId is { } categoryId) { return members.ContainsKey(categoryId) ? [categoryId] : []; } return cost.MeterId is { } meterId ? [.. _categories.Where(c => members[c.Id].Contains(meterId)).Select(c => c.Id)] : []; } /// /// D-42: a type's standing charge joins a category that holds the whole type — the type itself, or every physical /// meter of it; the global one a category that holds every billed meter. /// private bool HoldsRow(CostCategory category, StandingChargeKey row, IReadOnlyList members) { var set = members.ToHashSet(); if (row.Scope == TariffScope.Global) { return _full.BillItems.Count > 0 && _full.BillItems.All(set.Contains); } if (row.Scope == TariffScope.Meter) { // A meter's own fee goes with its meter (D-40, D-42). return set.Contains(row.ScopeId!.Value); } var type = row.ScopeId!.Value; if (category.Members.Any(m => m.EnergyTypeId == type)) { return true; } var physical = _catalog.Meters.Values.Where(m => m.EnergyTypeId == type && !m.IsVirtual).Select(m => m.Id).ToList(); return physical.Count > 0 && physical.All(set.Contains); } private static List Overlaps( CategoryOverlapReport report, Dictionary> manualClaims, Dictionary> rowClaims) { var pairs = new SortedDictionary<(int, int), (SortedSet Meters, SortedSet Manual, List Rows)>(); (SortedSet Meters, SortedSet Manual, List Rows) Pair(int a, int b) { var key = a < b ? (a, b) : (b, a); if (!pairs.TryGetValue(key, out var pair)) { pairs[key] = pair = ([], [], []); } return pair; } foreach (var overlap in report.Overlaps) { Pair(overlap.CategoryId, overlap.OtherCategoryId).Meters.UnionWith(overlap.SharedMeterIds); } foreach (var (manualId, claimants) in manualClaims) { ForEachPair(claimants, (a, b) => Pair(a, b).Manual.Add(manualId)); } foreach (var (row, claimants) in rowClaims) { ForEachPair(claimants, (a, b) => Pair(a, b).Rows.Add(row)); } return [.. pairs.Select(p => new CostCategoryOverlap(p.Key.Item1, p.Key.Item2, [.. p.Value.Meters], [.. p.Value.Manual], p.Value.Rows))]; static void ForEachPair(List ids, Action action) { for (var i = 0; i < ids.Count; i++) { for (var j = i + 1; j < ids.Count; j++) { action(ids[i], ids[j]); } } } } // ------------------------------------------------------------------------------------------------ plan and quantities /// /// The buckets (D-05): the caller's plan, the size asked for, or auto from the coverage of the priced meters. A line /// without any tariff has no cost to chart, so its resolution (a tank's months-long dipstick intervals) does not /// coarsen the chart; only when nothing has a tariff do all lines decide. /// private async Task PlanAsync(ScopePlan scope, CancellationToken cancellationToken) { if (_request.Plan is { } given) { return given; } var lines = scope.AllGroups().SelectMany(g => g.Lines).ToList(); var withPrice = lines.Where(HasAnyPrice).ToList(); var priced = (withPrice.Count > 0 ? withPrice : lines).Select(l => l.MeterId).Distinct().Order().ToList(); if (_request.Bucket != BucketSize.Auto || priced.Count == 0) { return BucketPlanner.Plan(_period, _request.Bucket, maxPoints: _request.MaxPoints); } var request = new AnalysisRequest(AnalysisScope.ForMeters(priced), _period) { MaxPoints = _request.MaxPoints, MaxSeries = int.MaxValue, QuantitiesOnly = true, }; return await _reader.PlanAsync(_db, _catalog, request, cancellationToken).ConfigureAwait(false); } private bool HasAnyPrice(LineSpec line) => line.Kind switch { BillLineKind.FeedIn => _book.HasAny(TariffComponent.FeedIn, line.MeterId, line.EnergyTypeId), BillLineKind.OwnPrice => _book.HasAnyInScope(TariffComponent.UnitPrice, TariffScope.Meter, line.MeterId), _ => _book.HasAny(TariffComponent.UnitPrice, line.MeterId, line.EnergyTypeId), }; /// /// Reads every meter the groups price (with the subsections they deduct, and the meters the availability is taken /// from) per local-month part (D-36): once for the buckets' parts, and — only when they differ — once for the /// period's own months, which the total is priced from. /// private async Task ReadQuantitiesAsync(ScopePlan scope, CancellationToken cancellationToken) { var lines = scope.AllGroups().SelectMany(g => g.Lines).ToList(); List ids = [ .. lines.Select(l => l.MeterId) .Concat(lines.SelectMany(l => l.Deductions.Select(d => d.MeterId))) .Concat(lines.SelectMany(l => BasisCheckOf(l)?.UseMeterIds ?? [])) .Concat(scope.AvailabilityMeters) .Where(id => _catalog.Find(id) is not null) .Distinct() .Order(), ]; if (ids.Count == 0) { return; } var contributors = !scope.OwnSemantics; var display = await ReadPartsAsync(ids, _displayParts, _plan.Buckets, _plan.Size, contributors, cancellationToken).ConfigureAwait(false); _series = display; _displayValues = display.ToDictionary(p => p.Key, p => p.Value.Values); if (SameParts()) { _periodValues = _displayValues; } else { var period = await ReadPartsAsync(ids, _periodParts, [_periodBucket], _periodBucket.Size, contributors, cancellationToken).ConfigureAwait(false); _periodValues = period.ToDictionary(p => p.Key, p => p.Value.Values); } // A-16: where a priced line's part is unresolved inside a bucket of several months, read the bucket whole too. List spanned = [ .. lines.Where(HasAnyPrice) .SelectMany(l => l.Deductions.Select(d => d.MeterId).Prepend(l.MeterId)) .Where(id => _catalog.Find(id) is not null) .Distinct() .Order(), ]; if (NeedsSpans(_displayParts, _displayValues, spanned)) { _displaySpans = await ReadSpansAsync(spanned, _plan.Buckets, _plan.Size, contributors, cancellationToken).ConfigureAwait(false); } if (NeedsSpans(_periodParts, _periodValues, spanned)) { _periodSpans = await ReadSpansAsync(spanned, [_periodBucket], _periodBucket.Size, contributors, cancellationToken).ConfigureAwait(false); } } /// True when a meter's part is unresolved inside a bucket that has several parts (A-16). private static bool NeedsSpans(IReadOnlyList parts, Dictionary> values, IReadOnlyList ids) { var multi = parts.GroupBy(p => p.BucketIndex).Where(g => g.Count() > 1).Select(g => g.Key).ToHashSet(); if (multi.Count == 0) { return false; } return ids.Any(id => values.GetValueOrDefault(id) is { } series && parts.Select((part, index) => (part, index)).Any(p => multi.Contains(p.part.BucketIndex) && series[p.index].Status == BucketStatus.Unresolved)); } /// The meters' values over whole buckets, one per bucket (A-16). private async Task>> ReadSpansAsync( IReadOnlyList ids, IReadOnlyList buckets, BucketSize size, bool contributors, CancellationToken cancellationToken) { List whole = [.. buckets.Select((b, i) => new CostPart(i, b.FirstDay, b.EndDay))]; var read = await ReadPartsAsync(ids, whole, buckets, size, contributors, cancellationToken).ConfigureAwait(false); return read.ToDictionary(p => p.Key, p => p.Value.Values); } private async Task> ReadPartsAsync( IReadOnlyList ids, IReadOnlyList parts, IReadOnlyList buckets, BucketSize size, bool contributors, CancellationToken cancellationToken) { List partBuckets = [.. parts.Select(p => PartBucket(p, buckets[p.BucketIndex]))]; var request = new AnalysisRequest(AnalysisScope.ForMeters(ids), _period) { Plan = new BucketPlan(size, size, partBuckets, partBuckets.Count, Refused: false, Suggested: null), MaxSeries = int.MaxValue, AsContributors = contributors, QuantitiesOnly = true, }; var result = await _reader.ReadAsync(_db, _catalog, request, cancellationToken).ConfigureAwait(false); foreach (var problem in result.Problems) { if (!_quantityProblems.Exists(p => p.Kind == problem.Kind && p.MeterId == problem.MeterId && p.Virtual?.Kind == problem.Virtual?.Kind)) { _quantityProblems.Add(problem); } } return result.Series.Where(s => s.MeterId is not null).ToDictionary(s => s.MeterId!.Value); } /// /// A part as a bucket the reader sums: its local days, clipped to its bucket's instants (the last part of a to-date /// bucket ends at now), with its bucket's size, so it resolves exactly as its bucket would (D-14). /// private AnalysisBucket PartBucket(CostPart part, AnalysisBucket bucket) { var start = GapAttribution.LocalMidnight(part.FirstDay, _zone); var end = GapAttribution.LocalMidnight(part.EndDay, _zone); var from = start > bucket.From ? start : bucket.From; var to = end < bucket.To ? end : bucket.To; return new AnalysisBucket(part.FirstDay, part.EndDay, from, to < from ? from : to, bucket.Size); } /// /// True when the buckets' parts are the period's months, evaluated alike: the same days, and sizes that resolve /// them the same way (a month and a year both resolve by month). /// private bool SameParts() { if (_displayParts.Count != _periodParts.Count) { return false; } static bool Monthly(BucketSize size) => size is BucketSize.Month or BucketSize.Year; var sizesAgree = _plan.Size == _periodBucket.Size || (Monthly(_plan.Size) && Monthly(_periodBucket.Size)); return sizesAgree && _displayParts.Zip(_periodParts).All(p => p.First.FirstDay == p.Second.FirstDay && p.First.EndDay == p.Second.EndDay); } /// /// The first data day of every physical meter, for standing-charge service periods (D-40) — read only when there is /// a base price to accrue. /// private async Task LoadServiceAsync(CancellationToken cancellationToken) { if (!_book.Tariffs.Any(t => t.Component == TariffComponent.BasePrice)) { return; } var ids = _catalog.Meters.Values.Where(m => !m.IsVirtual).Select(m => m.Id).ToList(); if (ids.Count == 0) { return; } await _db.Database.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); var runs = await AnalysisQueries.CoverageAsync(_db.Database.GetDbConnection(), ids, cancellationToken).ConfigureAwait(false); foreach (var id in ids) { _firstData[id] = AvailableRange.OfRuns(runs.GetValueOrDefault(id) ?? [], _period.Now, _zone)?.FirstDay; } } private ServicePeriod? MeterService(int meterId) { if (_catalog.Find(meterId) is not { } meter) { return null; } return meter.IsVirtual ? ServicePeriod.Span(SourcesOf(meterId).Distinct().Select(MeterService)) : ServicePeriod.ForMeter(meter.Meter.InstalledAt, meter.Meter.RetiredAt, _firstData.GetValueOrDefault(meterId)); } private ServicePeriod? RowService(StandingChargeKey row) => row.Scope == TariffScope.Meter ? MeterService(row.ScopeId!.Value) : ServicePeriod.Span(_catalog.Meters.Values .Where(m => !m.IsVirtual && (row.Scope == TariffScope.Global || m.EnergyTypeId == row.ScopeId)) .Select(m => MeterService(m.Id))); // ------------------------------------------------------------------------------------------------ pricing /// Prices a group per bucket and over the period's own months. private void Price(PricingGroup group) { var rows = group.Rows .Select(r => new StandingChargeScope(r.Scope, r.ScopeId, RowService(r))) .ToList(); var bucketLines = group.Lines.Select(l => Line(l, _displayParts, _displayValues, _plan.Buckets, _displaySpans, group.BasisGaps)).ToList(); var totalLines = group.Lines.Select(l => Line(l, _periodParts, _periodValues, [_periodBucket], _periodSpans, group.BasisGaps)).ToList(); group.ByBucket = CostCalculator.Calculate(new CostRequest(_plan.Buckets, _today, _book, bucketLines, rows, group.Manual)); group.OverPeriod = CostCalculator.Calculate(new CostRequest([_periodBucket], _today, _book, totalLines, rows, group.Manual)); foreach (var line in group.ByBucket.Lines.Concat(group.OverPeriod.Lines).Where(l => l.MonthsWithPriceChangeInsideInterval.Count > 0)) { Months(group.PriceChanges, line.MeterId).UnionWith(line.MonthsWithPriceChangeInsideInterval); } for (var i = 0; i < group.Lines.Count; i++) { var spec = group.Lines[i]; var buckets = group.ByBucket.Lines[i]; var total = group.OverPeriod.Lines[i]; var perBucket = new double?[_plan.Buckets.Count]; for (var p = 0; p < _displayParts.Count; p++) { if (bucketLines[i].Quantities[p] is { IsKnown: true, Amount: { } amount }) { var index = _displayParts[p].BucketIndex; perBucket[index] = (perBucket[index] ?? 0) + amount; } } var known = totalLines[i].Quantities.Where(q => q.IsKnown).Select(q => q.Amount!.Value).ToList(); group.Figures.Add(new CostLineFigure( spec.MeterId, _catalog.Meters[spec.MeterId].Name, spec.EnergyTypeId, spec.Kind, spec.Unit, buckets.Buckets, total.Total, perBucket, known.Count > 0 ? known.Sum() : null) { Deductions = spec.Deductions, MonthsWithoutOwnPrice = total.MonthsWithoutOwnPrice, ForMeterId = spec.ForMeterId, }); } foreach (var (buckets, total) in group.ByBucket.StandingCharges.Zip(group.OverPeriod.StandingCharges)) { group.RowFigures.Add(new StandingChargeFigure( buckets.Scope, buckets.ScopeId, buckets.Buckets, total.Total, RowService(new StandingChargeKey(buckets.Scope, buckets.ScopeId)))); } } /// /// A line's net quantity per part (D-35): its meter's value less the subsections billed on their own that month. A /// month the type's billing basis has no meter in service for is unknown, not the grid meter's zero (A-17). Buckets /// read whole (A-16) give the line its spans. /// private CostLine Line( LineSpec spec, IReadOnlyList parts, Dictionary> values, IReadOnlyList buckets, Dictionary> spanValues, Dictionary> basisGaps) { var own = values.GetValueOrDefault(spec.MeterId); var basis = BasisCheckOf(spec); var quantities = new List(parts.Count); var gapBuckets = new HashSet(); for (var p = 0; p < parts.Count; p++) { var part = parts[p]; if (basis is not null && IsBasisGap(basis, part, p, values)) { quantities.Add(CostQuantity.Unknown(part, BucketStatus.Missing)); Months(basisGaps, spec.MeterId).Add(part.Month); gapBuckets.Add(part.BucketIndex); continue; } var value = own?[p] ?? BucketValue.Missing(); List<(BucketValue Value, double Factor)> deducted = []; foreach (var deduction in spec.Deductions) { if (_book.HasOwnUnitPrice(deduction.MeterId, part.Month)) { deducted.Add((values.GetValueOrDefault(deduction.MeterId)?[p] ?? BucketValue.Missing(), deduction.UnitFactor)); } } quantities.Add(Net(part, value, deducted)); } var service = _book.HasAnyInScope(TariffComponent.BasePrice, TariffScope.Meter, spec.MeterId) ? MeterService(spec.MeterId) : null; return new CostLine(spec.MeterId, spec.EnergyTypeId, spec.Kind, spec.Unit, quantities, service) { Spans = Spans(spec, parts, buckets, spanValues, gapBuckets), }; } /// /// A line's quantity over each bucket of several parts that was read whole (A-16), net of the subsections billed on /// their own — only where the same subsections are taken out in every month of the bucket — and never over a bucket /// holding a basis gap. /// private List? Spans( LineSpec spec, IReadOnlyList parts, IReadOnlyList buckets, Dictionary> spanValues, HashSet gapBuckets) { if (spanValues.GetValueOrDefault(spec.MeterId) is not { } own) { return null; } var spans = new List(); foreach (var bucket in parts.GroupBy(p => p.BucketIndex).Where(g => g.Count() > 1)) { var index = bucket.Key; if (gapBuckets.Contains(index) || index >= own.Count) { continue; } var applied = bucket .Select(part => spec.Deductions.Where(d => _book.HasOwnUnitPrice(d.MeterId, part.Month)).Select(d => d.MeterId).ToHashSet()) .ToList(); if (!applied.TrueForAll(set => set.SetEquals(applied[0]))) { continue; } var deducted = spec.Deductions .Where(d => applied[0].Contains(d.MeterId)) .Select(d => (spanValues.GetValueOrDefault(d.MeterId)?[index] ?? BucketValue.Missing(), d.UnitFactor)) .ToList(); var whole = new CostPart(index, buckets[index].FirstDay, buckets[index].EndDay); var net = Net(whole, own[index], deducted); spans.Add(new CostSpan(index, net.Amount, net.Availability)); } return spans.Count > 0 ? spans : null; } /// /// A-17: for a grid-import line of a type billed on its grid import, the grid meters whose service decides the basis, /// and the use meters whose data shows a month the basis misses. Null for any other line, and when no billed grid /// meter has an install or retire date (then it covers every month). /// private BasisCheck? BasisCheckOf(LineSpec spec) { if (spec.Kind != BillLineKind.UnitPrice || spec.ForMeterId is not null) { return null; } var totals = _full.ForType(spec.EnergyTypeId); var billing = totals.Billing; if (billing.Basis != BillingBasis.GridImport || !billing.BilledMeterIds.Contains(spec.MeterId)) { return null; } var grids = billing.BilledMeterIds.Select(id => _catalog.Meters[id].Meter).ToList(); var use = totals.MetersIn(TotalsMeasure.Use).Where(id => _catalog.Find(id) is { IsVirtual: false }).ToList(); if (use.Count == 0 || grids.TrueForAll(m => m.InstalledAt is null && m.RetiredAt is null)) { return null; } return new BasisCheck([.. grids.Select(m => (m.InstalledAt ?? DateOnly.MinValue, m.RetiredAt ?? DateOnly.MaxValue))], use); } /// /// True when some day of has no billed grid meter in service while a use meter in service /// that month measured something (A-17): the grid meter's known zero would pass the use off as free. /// private bool IsBasisGap(BasisCheck basis, CostPart part, int index, Dictionary> values) { var covered = true; for (var day = part.FirstDay; day < part.EndDay && covered; day = day.AddDays(1)) { covered = basis.GridService.Exists(w => w.First <= day && day <= w.Last); } if (covered) { return false; } return basis.UseMeterIds.Any(id => { var meter = _catalog.Meters[id].Meter; var inService = (meter.InstalledAt ?? DateOnly.MinValue) < part.EndDay && (meter.RetiredAt ?? DateOnly.MaxValue) >= part.FirstDay; var value = values.GetValueOrDefault(id)?[index]; return inService && value is not null && value.Status != BucketStatus.Missing && !(value.Status == BucketStatus.Available && value.Value is { } amount && Math.Abs(amount) <= ProvenanceRules.Epsilon); }); } private static SortedSet Months(Dictionary> map, int meterId) { if (!map.TryGetValue(meterId, out var months)) { map[meterId] = months = []; } return months; } /// /// A meter's value less deducted values: unknown as soon as any of them is (the strictest status wins, as for a /// virtual difference, D-27), partial when any is partial. /// internal static CostQuantity Net(CostPart part, BucketValue value, IReadOnlyList<(BucketValue Value, double Factor)> deducted) { if (deducted.Count == 0) { return value.Value is { } plain && value.Status is BucketStatus.Available or BucketStatus.Partial ? CostQuantity.Known(part, plain, value.Status) : CostQuantity.Unknown(part, UnknownStatus(value.Status)); } var all = deducted.Select(d => d.Value).Prepend(value).ToList(); foreach (var status in (ReadOnlySpan)[BucketStatus.Pending, BucketStatus.Invalid, BucketStatus.Unresolved]) { if (all.Exists(v => v.Status == status)) { return CostQuantity.Unknown(part, status); } } if (all.Exists(v => v.Value is null || v.Status is not (BucketStatus.Available or BucketStatus.Partial))) { return CostQuantity.Unknown(part, BucketStatus.Missing); } var net = value.Value!.Value - deducted.Sum(d => d.Value.Value!.Value * d.Factor); return CostQuantity.Known(part, net, all.Exists(v => v.Status == BucketStatus.Partial) ? BucketStatus.Partial : BucketStatus.Available); } private static BucketStatus UnknownStatus(BucketStatus status) => status is BucketStatus.Available or BucketStatus.Partial ? BucketStatus.Missing : status; // ------------------------------------------------------------------------------------------------ result private CostAnalysis Compose(ScopePlan scope) { var groups = scope.Groups().ToList(); var buckets = Enumerable.Range(0, _plan.Buckets.Count) .Select(b => CostAmount.Sum(groups.Select(g => g.ByBucket.Totals[b]))) .ToList(); var total = CostAmount.Sum(groups.Select(g => g.OverPeriod.Total)); var manual = new ManualCostFigure( [.. groups.SelectMany(g => g.ByBucket.ManualCosts.Bookings).OrderBy(b => b.Day).ThenBy(b => b.ManualCostId)], AfterToday(groups), [.. Enumerable.Range(0, _plan.Buckets.Count).Select(b => CostAmount.Sum(groups.Select(g => g.ByBucket.ManualCosts.Buckets[b])))], CostAmount.Sum(groups.Select(g => g.OverPeriod.ManualCosts.Total))); var warnings = Warnings(groups); var result = new CostAnalysis( _request, _plan, _book.Currency, buckets, total, [.. groups.SelectMany(g => g.Figures)], [.. groups.SelectMany(g => g.RowFigures)], manual, Availability(MeteredAvailability(scope), scope.ManualInScope), Attention(scope, total, manual, warnings)) { Warnings = warnings, QuantityProblems = _quantityProblems, EnergyTypes = [.. scope.Types.Select(t => new EnergyTypeCostFigure( t.EnergyTypeId, t.Group.Basis, t.Group.ByBucket.Totals, t.Group.OverPeriod.Total, [.. t.Group.Lines.Select(l => l.MeterId).Distinct()]))], Meter = scope.Meter, }; if (scope.Categories is { } categories) { if (scope.CategoryEntry is { } entry) { result = result with { Category = Figure(entry, categories) }; } else { result = result with { Composition = Composition(categories) }; } } return result; } private CategoryComposition Composition(CategoryPlan plan) { var slices = new List(); foreach (var id in plan.Disjoint) { var entry = plan.Entries[id]; slices.Add(new CompositionSlice(CompositionSliceKind.Category, id, null, entry.Group.ByBucket.Totals, entry.Group.OverPeriod.Total) { MeterIds = entry.Cover.CoverMeterIds, ManualCostIds = [.. entry.Group.Manual.Select(c => c.Id)], }); } slices.Add(new CompositionSlice(CompositionSliceKind.Uncategorized, null, null, plan.Uncategorized.ByBucket.Totals, plan.Uncategorized.OverPeriod.Total) { MeterIds = plan.UncategorizedMeterIds, ManualCostIds = [.. plan.Uncategorized.Manual.Select(c => c.Id)], }); foreach (var (row, group) in plan.RowSlices) { slices.Add(new CompositionSlice(CompositionSliceKind.StandingCharge, null, row, group.ByBucket.Totals, group.OverPeriod.Total)); } return new CategoryComposition( slices, [.. _categories.Select(c => Figure(plan.Entries[c.Id], plan))], plan.Overlaps, [.. Enumerable.Range(0, _plan.Buckets.Count).Select(b => CostAmount.Sum(slices.Select(s => s.Buckets[b])))], CostAmount.Sum(slices.Select(s => s.Total))); } private static CategoryCostFigure Figure(CategoryEntry entry, CategoryPlan plan) { var id = entry.Category.Id; return new CategoryCostFigure( id, entry.Category.Name, entry.Category.ColorHex, entry.Category.Sort, entry.Cover, entry.Group.ByBucket.Totals, entry.Group.OverPeriod.Total) { IsOverlappingView = entry.IsView, OverlapsWith = [.. plan.Overlaps .Where(o => o.CategoryId == id || o.OtherCategoryId == id) .Select(o => o.CategoryId == id ? o.OtherCategoryId : o.CategoryId) .Distinct() .Order()], ManualCostIds = [.. entry.Group.Manual.Select(c => c.Id)], StandingCharges = entry.Group.Rows, }; } /// /// The manual costs of the figure that start after today but inside the range the period names (D-41, compare /// D-04): the buckets stop at now, so they are in none, and are reported rather than silently left out. /// private List AfterToday(IEnumerable groups) { var last = _period.Preset == PeriodPreset.AllHistory ? DateOnly.MaxValue : _period.NominalLastDay(); return [ .. groups.SelectMany(g => g.Manual) .Where(c => c.PeriodStart > _today && c.PeriodStart >= _period.FirstDay && c.PeriodStart <= last) .Select(c => c.Id) .Concat(groups.SelectMany(g => g.OverPeriod.ManualCosts.AfterTodayIds)) .Distinct() .Order(), ]; } /// The coverage of the scope's priced meters, from the series the quantities were read with. private AvailableRange? MeteredAvailability(ScopePlan scope) => AvailableRange.Union(scope.AvailabilityMeters.Select(id => _series.GetValueOrDefault(id)?.Availability), _zone); /// D-19: the priced meters' coverage and the manual costs' start days up to today, and the latest month of either. private CostAvailability Availability(AvailableRange? metered, IReadOnlyList manual) { var days = manual.Where(c => c.PeriodStart <= _today).Select(c => c.PeriodStart).ToList(); AvailableRange? manualRange = null; if (days.Count > 0) { var from = GapAttribution.LocalMidnight(days.Min(), _zone); var to = GapAttribution.LocalMidnight(days.Max().AddDays(1), _zone); manualRange = AvailableRange.Of(from, to < _period.Now ? to : _period.Now, _zone) ?? AvailableRange.Of(from, to, _zone); } var range = AvailableRange.Union([metered, manualRange], _zone); LatestPeriod? latest = null; if (range is not null) { var month = range.LatestMonth; var byMeters = metered?.LatestMonth == month; var byManual = manualRange?.LatestMonth == month; latest = new LatestPeriod(month, byMeters && byManual ? LatestPeriodBasis.Both : byManual ? LatestPeriodBasis.Manual : LatestPeriodBasis.Meters); } return new CostAvailability(metered, manualRange, range, latest); } /// Tariffs applied with an unchecked unit, each once with the first month it priced. private static List Warnings(IEnumerable groups) => [.. groups .SelectMany(g => g.OverPeriod.Warnings) .GroupBy(w => (w.TariffId, w.Component, w.Issue, w.MeterId)) .Select(g => g.MinBy(w => w.FirstMonth)!) .OrderBy(w => w.FirstMonth) .ThenBy(w => w.TariffId) .ThenBy(w => w.MeterId ?? int.MaxValue)]; /// The cost attention items (D-53) of the result. private List Attention(ScopePlan scope, CostAmount total, ManualCostFigure manual, IReadOnlyList warnings) { var items = new List(); items.AddRange(total.MissingPrices.Select(m => new CostAttention(CostAttentionKind.MissingPrice, m.MeterId) { Price = m })); items.AddRange(warnings.Select(w => new CostAttention(CostAttentionKind.UnverifiedTariffUnit, w.MeterId) { Warning = w })); if (manual.AfterTodayIds.Count > 0) { items.Add(new CostAttention(CostAttentionKind.ManualCostAfterToday, null) { ManualCostIds = manual.AfterTodayIds }); } var foreign = manual.Bookings.Where(b => b.CurrencyMismatch).Select(b => b.ManualCostId).Distinct().Order().ToList(); if (foreign.Count > 0) { items.Add(new CostAttention(CostAttentionKind.ManualCostCurrency, null) { ManualCostIds = foreign }); } items.AddRange(_notCosted.Order().Select(id => new CostAttention(CostAttentionKind.VirtualNotCosted, id))); var groups = scope.Groups().ToList(); foreach (var (kind, map) in new[] { (CostAttentionKind.PriceChangeInsideInterval, Merge(groups.Select(g => g.PriceChanges))), (CostAttentionKind.BillingBasisGap, Merge(groups.Select(g => g.BasisGaps))), }) { items.AddRange(map.OrderBy(m => m.Key).Select(m => new CostAttention(kind, m.Key) { FirstMonth = m.Value.Min, LastMonth = m.Value.Max })); } items.AddRange(_full.Problems .Where(p => p.Kind is TotalsProblemKind.UnusedMeterPrice or TotalsProblemKind.SeparateBillingUnitMismatch && InScope(scope, p.MeterId)) .Select(p => new CostAttention(CostAttentionKind.BillingConfiguration, p.MeterId) { Totals = p })); // A category whose members give it no line — calculated views, generation, operating hours (D-39, D-42) — reads // empty; say why rather than let it pass for a category without data (A-22). IEnumerable entries = scope.CategoryEntry is { } own ? [own] : scope.Categories?.Entries.Values.OrderBy(e => e.Category.Sort).ThenBy(e => e.Category.Id) ?? Enumerable.Empty(); foreach (var entry in entries) { if (entry.Cover.CoverMeterIds.Count == 0 && entry.Cover.AnalysisOnlyMeterIds.Count > 0) { items.Add(new CostAttention(CostAttentionKind.CategoryPricesNothing, entry.Cover.AnalysisOnlyMeterIds[0]) { CategoryId = entry.Category.Id, MeterIds = entry.Cover.AnalysisOnlyMeterIds, }); } } return items; } private static Dictionary> Merge(IEnumerable>> maps) { var merged = new Dictionary>(); foreach (var (meter, months) in maps.SelectMany(m => m)) { Months(merged, meter).UnionWith(months); } return merged; } /// Whether a meter's billing problem belongs to the scope: its meter, its category's members, its type, or anything. private bool InScope(ScopePlan scope, int meterId) { if (scope.Meter is { } meter) { return meterId == meter.MeterId; } if (scope.CategoryEntry is { } entry) { return entry.Cover.MemberIds.Contains(meterId); } return scope.Portfolio is not null || scope.Types.Exists(t => t.EnergyTypeId == _catalog.Find(meterId)?.EnergyTypeId); } private CostAnalysis Refused(BucketPlan plan, CostRefusal refusal) => new( _request, plan, _currency, [], CostAmount.Empty, [], [], new ManualCostFigure([], [], [], CostAmount.Empty), CostAvailability.None, []) { Refusal = refusal, }; private BucketPlan EmptyPlan() { var size = _request.Bucket == BucketSize.Auto ? BucketSize.Day : _request.Bucket; return new BucketPlan(_request.Bucket, size, [], 0, Refused: false, Suggested: null); } // ------------------------------------------------------------------------------------------------ plans /// One priced line: whose quantity, in which unit, how, less what. private sealed record LineSpec( int MeterId, int EnergyTypeId, BillLineKind Kind, string Unit, IReadOnlyList Deductions, int? ForMeterId); /// What a physical meter's own scope prices, and how its cost is named. private sealed record OwnCost(List Lines, MeterCostRule Rule, bool OnBill, MeterNotCostedReason Reason); /// The service windows of a type's billed grid meters (inclusive local days) and its use meters (A-17). private sealed record BasisCheck(List<(DateOnly First, DateOnly Last)> GridService, IReadOnlyList UseMeterIds); /// Lines, standing-charge rows and manual costs priced together, and their priced figures. private sealed class PricingGroup { public int? EnergyTypeId { get; init; } public BillingBasis Basis { get; init; } public List Lines { get; } = []; public List Rows { get; } = []; public List Manual { get; } = []; public CostResult ByBucket { get; set; } = null!; public CostResult OverPeriod { get; set; } = null!; public List Figures { get; } = []; public List RowFigures { get; } = []; /// Per line meter: the months whose interval could not be priced because the price changes inside it (A-16). public Dictionary> PriceChanges { get; } = []; /// Per grid meter: the months its type's grid basis missed while use was measured (A-17). public Dictionary> BasisGaps { get; } = []; } private sealed record CategoryEntry(CostCategory Category, CategoryCoverResult Cover, PricingGroup Group, bool IsView); /// Every category's figure and the bill's composition by them. private sealed class CategoryPlan { public Dictionary Entries { get; } = []; public IReadOnlyList Disjoint { get; set; } = []; public PricingGroup Uncategorized { get; } = new(); public IReadOnlyList UncategorizedMeterIds { get; set; } = []; public List<(StandingChargeKey Row, PricingGroup Group)> RowSlices { get; } = []; public IReadOnlyList Overlaps { get; set; } = []; /// The groups the composition prices (every category, Uncategorized, the rows of their own). public IEnumerable Groups() => Entries.Values.Select(e => e.Group).Append(Uncategorized).Concat(RowSlices.Select(r => r.Group)); } /// A scope as pricing groups: the groups its figure adds up, and whatever else it prices alongside. private sealed class ScopePlan { public List<(int EnergyTypeId, PricingGroup Group)> Types { get; } = []; public PricingGroup? Portfolio { get; set; } public PricingGroup? Single { get; init; } public MeterCostInfo? Meter { get; init; } public CategoryEntry? CategoryEntry { get; init; } public CategoryPlan? Categories { get; set; } public IReadOnlyList AvailabilityMeters { get; init; } = []; public IReadOnlyList ManualInScope { get; init; } = []; /// A physical meter's own page: outside its service period it has no data, as its quantity says. public bool OwnSemantics { get; init; } /// The groups whose sum is the scope's figure. public IEnumerable Groups() { if (Single is not null) { yield return Single; yield break; } foreach (var (_, group) in Types) { yield return group; } if (Portfolio is not null) { yield return Portfolio; } } /// Everything the request prices: the scope's groups, and for the portfolio the composition's. public IEnumerable AllGroups() => Single is null && Categories is not null ? Groups().Concat(Categories.Groups()) : Groups(); } }