diff --git a/Elwig/Helpers/Billing/BillingData.cs b/Elwig/Helpers/Billing/BillingData.cs index 94d678f..91e1ada 100644 --- a/Elwig/Helpers/Billing/BillingData.cs +++ b/Elwig/Helpers/Billing/BillingData.cs @@ -1,6 +1,9 @@ using Newtonsoft.Json; using NJsonSchema; using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; using System.Reflection; using System.Text.Json.Nodes; using System.Threading.Tasks; @@ -8,6 +11,11 @@ using System.Threading.Tasks; namespace Elwig.Helpers.Billing { public class BillingData { + public enum CalculationMode { Elwig, WgMaster } + public enum CurveMode { Oe, Kmw } + + public record struct Curve(CurveMode Mode, Dictionary Normal, Dictionary? Gebunden); + public static JsonSchema? Schema { get; private set; } public static async Task Init() { @@ -15,11 +23,21 @@ namespace Elwig.Helpers.Billing { Schema = await JsonSchema.FromJsonAsync(stream ?? throw new ArgumentException("JSON schema not found")); } - private JsonObject Data; + private readonly CalculationMode Mode; + private readonly JsonObject Data; + private readonly Dictionary Curves; + private readonly Dictionary PaymentData; + private readonly Dictionary QualityData; - public BillingData(JsonObject data) { + public BillingData(JsonObject data, IEnumerable attributeVariants) { + if (attributeVariants.Any(e => e.Any(c => c < 'A' || c > 'Z'))) + throw new ArgumentException("Invalid attributeVariants"); Data = data; - + var mode = Data["mode"]?.GetValue(); + Mode = (mode == "elwig") ? CalculationMode.Elwig : CalculationMode.WgMaster; + Curves = GetCurves(data); + PaymentData = GetPaymentData(attributeVariants); + QualityData = GetQualityData(attributeVariants); } public static JsonObject ParseJson(string json) { @@ -33,12 +51,170 @@ namespace Elwig.Helpers.Billing { } } - public static BillingData FromJson(string json) { - return new(ParseJson(json)); + public static BillingData FromJson(string json, IEnumerable attributeVariants) { + return new(ParseJson(json), attributeVariants); } - public decimal CalculatePrice(string sortId, string? attrid, string qualId, bool gebunden, double oe, double kmw, bool minQuw) { - return 0m; + private static Dictionary GetCurveData(JsonObject data, CurveMode mode) { + var dict = new Dictionary(); + foreach (var (index, price) in data) { + double idx; + bool? gtlt = index.StartsWith('>') ? true : index.StartsWith('<') ? false : null; + if (index.EndsWith("kmw")) { + idx = double.Parse(index[(gtlt != null ? 1 : 0)..^3], CultureInfo.InvariantCulture); + if (mode == CurveMode.Oe) { + idx = Utils.KmwToOe(idx); + } + } else if (index.EndsWith("oe")) { + idx = double.Parse(index[(gtlt != null ? 1 : 0)..^2], CultureInfo.InvariantCulture); + if (mode == CurveMode.Kmw) { + idx = Utils.OeToKmw(idx); + } + } else { + throw new InvalidOperationException(); + } + if (gtlt == true) { + idx = Math.BitIncrement(idx); + } else if (gtlt == false) { + idx = Math.BitDecrement(idx); + } + dict[idx] = price?.AsValue().GetValue() ?? throw new InvalidOperationException(); + } + return dict; + } + + public static Dictionary GetCurves(JsonObject data) { + var dict = new Dictionary(); + var curves = data["curves"]?.AsArray() ?? throw new InvalidOperationException(); + foreach (var c in curves) { + var obj = c?.AsObject() ?? throw new InvalidOperationException(); + var id = obj["id"]?.GetValue() ?? throw new InvalidOperationException(); + var cMode = (obj["mode"]?.GetValue() == "kmw") ? CurveMode.Kmw : CurveMode.Oe; + + Dictionary c1; + Dictionary? c2 = null; + var norm = obj["data"]; + if (norm is JsonObject) { + c1 = GetCurveData(norm.AsObject(), cMode); + } else if (norm?.AsValue().TryGetValue(out decimal v) == true) { + c1 = new() { { cMode == CurveMode.Oe ? 73 : 15, v } }; + } else { + throw new InvalidOperationException(); + } + var geb = obj["geb"]; + if (geb is JsonObject) { + c2 = GetCurveData(geb.AsObject(), cMode); + } else if (geb?.AsValue().TryGetValue(out decimal v) == true) { + c2 = c1.ToDictionary(e => e.Key, e => e.Value + v); + } + dict.Add(id, new(cMode, c1, c2)); + } + return dict; + } + + private Dictionary GetData(JsonObject data, IEnumerable attributeVariants) { + Dictionary dict; + if (data["default"] is JsonValue def) { + var c = LookupCurve(def); + dict = attributeVariants.ToDictionary(e => e, _ => c); + } else { + dict = []; + } + + var variants = data.Where(p => !p.Key.StartsWith('/') && p.Key.Length == 2); + var attributes = data.Where(p => p.Key.StartsWith('/')); + var others = data.Where(p => !p.Key.StartsWith('/') && p.Key.Length > 2); + foreach (var (idx, v) in variants) { + var curve = LookupCurve(v?.AsValue() ?? throw new InvalidOperationException()); + foreach (var i in attributeVariants.Where(e => e.StartsWith(idx[..^1]))) { + dict[i] = curve; + } + } + foreach (var (idx, v) in attributes) { + var curve = LookupCurve(v?.AsValue() ?? throw new InvalidOperationException()); + foreach (var i in attributeVariants.Where(e => e[2..] == idx[1..])) { + dict[i] = curve; + } + } + foreach (var (idx, v) in others) { + var curve = LookupCurve(v?.AsValue() ?? throw new InvalidOperationException()); + dict[idx.Replace("/", "")] = curve; + } + + return dict; + } + + public Dictionary GetPaymentData(IEnumerable attributeVariants) { + // TODO parse wgmaster + var p = Data["payment"]; + if (p is JsonValue val) { + var c = LookupCurve(val); + return attributeVariants.ToDictionary(e => e, _ => c); + } + return GetData(p?.AsObject() ?? throw new InvalidOperationException(), attributeVariants); + } + + public Dictionary GetQualityData(IEnumerable attributeVariants) { + // TODO parse wgmaster + var q = Data["quality"]?.AsObject(); + Dictionary dict = []; + if (q == null) return dict; + + foreach (var (qualid, data) in q) { + Dictionary qualDict; + if (data is JsonValue val) { + var c = LookupCurve(val); + qualDict = attributeVariants.ToDictionary(e => e, _ => c); + } else { + qualDict = GetData(data?.AsObject() ?? throw new InvalidOperationException(), attributeVariants); + } + foreach (var (idx, d) in qualDict) { + dict[$"{qualid}/{idx}"] = d; + } + } + + return dict; + } + + public decimal CalculatePrice(string sortid, string? attrid, string qualid, bool gebunden, double oe, double kmw) { + var curve = GetQualityCurve(qualid, sortid, attrid) ?? GetCurve(sortid, attrid); + var d = (gebunden ? curve.Gebunden : null) ?? curve.Normal; + if (d.Count == 1) return d.First().Value; + + var r = curve.Mode == CurveMode.Oe ? oe : kmw; + var lt = d.Keys.Where(v => v <= r); + var gt = d.Keys.Where(v => v >= r); + if (!lt.Any()) { + return d[gt.Min()]; + } else if (!gt.Any()) { + return d[lt.Max()]; + } + + var max = lt.Max(); + var min = gt.Min(); + if (max == min) return d[r]; + + var p1 = ((decimal)r - (decimal)min) / ((decimal)max - (decimal)min); + var p2 = 1 - p1; + return d[min] * p2 + d[max] * p1; + } + + private Curve LookupCurve(JsonValue val) { + if (val.TryGetValue(out string? curve)) { + var curveId = int.Parse(curve.Split(":")[1]); + return Curves[curveId]; + } else if (val.TryGetValue(out decimal value)) { + return new(CurveMode.Oe, new() { { 73, value } }, null); + } + throw new InvalidOperationException(); + } + + public Curve GetCurve(string sortid, string? attrid) { + return PaymentData[$"{sortid}{attrid ?? ""}"]; + } + + public Curve? GetQualityCurve(string qualid, string sortid, string? attrid) { + return QualityData.TryGetValue($"{qualid}/{sortid}{attrid ?? ""}", out var curve) ? curve : null; } } } diff --git a/Elwig/Resources/Schemas/PaymentVariantData.json b/Elwig/Resources/Schemas/PaymentVariantData.json index af7e86b..08eecfb 100644 --- a/Elwig/Resources/Schemas/PaymentVariantData.json +++ b/Elwig/Resources/Schemas/PaymentVariantData.json @@ -19,7 +19,7 @@ } }, "patternProperties": { - "^[A-Z]+$": { + "^([A-Z]{2})?(\/[A-Z]*)?$": { "type": ["number", "string"], "pattern": "^curve:[0-9]+$" } @@ -40,7 +40,7 @@ } }, "patternProperties": { - "^[A-Z]+$": { + "^([A-Z]{2})?(\/[A-Z]*)?$": { "type": ["number", "string"], "pattern": "^curve:[0-9]+$" } @@ -128,7 +128,7 @@ "additionalProperties": false, "minProperties": 1, "patternProperties": { - "^([0-9]+(\\.[0-9]+)?)(oe|kmw)$": {"type": "number"} + "^[<>]?([0-9]+(\\.[0-9]+)?)(oe|kmw)$": {"type": "number"} } } } diff --git a/Tests/Helpers/BillingDataTest.cs b/Tests/Helpers/BillingDataTest.cs new file mode 100644 index 0000000..f8a317d --- /dev/null +++ b/Tests/Helpers/BillingDataTest.cs @@ -0,0 +1,305 @@ +using Elwig.Helpers; +using Elwig.Helpers.Billing; + +namespace Tests.Helpers { + [TestFixture] + public class BillingDataTest { + + private static readonly string[] AttributeVariants = ["GV", "GVD", "GVK", "GVS", "GVZ", "WR", "WRS", "ZW", "ZWS", "ZWZ"]; + + [OneTimeSetUp] + public async Task SetupBilling() { + await BillingData.Init(); + } + + private static (string, string?) GetSortIdAttrId(string bucket) { + return (bucket[..2], bucket.Length > 2 ? bucket[2..] : null); + } + + private static string GetQualId(double kmw) { + return kmw switch { + >= 17.0 => "KAB", + >= 15.0 => "QUW", + >= 14.0 => "LDW", + >= 10.6 => "RSW", + _ => "WEI", + }; + } + + private static void TestCalcOe(BillingData data, string bucket, double oe, decimal expected, string? qualid = null, bool geb = false) { + var (sortid, attrid) = GetSortIdAttrId(bucket); + var kmw = Utils.OeToKmw(oe); + var v = data.CalculatePrice(sortid, attrid, qualid ?? GetQualId(kmw), geb, oe, kmw); + Assert.That(Math.Round(v, 6), Is.EqualTo(expected)); + } + + private static void TestCalcKmw(BillingData data, string bucket, double kmw, decimal expected, string? qualid = null, bool geb = false) { + var (sortid, attrid) = GetSortIdAttrId(bucket); + var oe = Utils.KmwToOe(kmw); + var v = data.CalculatePrice(sortid, attrid, qualid ?? GetQualId(kmw), geb, oe, kmw); + Assert.That(Math.Round(v, 6), Is.EqualTo(expected)); + } + + [Test] + public void Test_01_Flatrate() { + var data = BillingData.FromJson(""" + { + "mode": "elwig", + "version": 1, + "payment": 0.5, + "curves": [] + } + """, AttributeVariants); + Assert.Multiple(() => { + TestCalcOe(data, "GV", 73, 0.5m); + TestCalcOe(data, "WRS", 74, 0.5m); + }); + } + + [Test] + public void Test_02_Simple() { + var data = BillingData.FromJson(""" + { + "mode": "elwig", + "version": 1, + "payment": "curve:1", + "curves": [{ + "id": 1, + "mode": "oe", + "data": { + "72.0oe": 0.25, + "15.0kmw": 0.5, + "83oe": 1 + }, + "geb": 0.10 + }] + } + """, AttributeVariants); + Assert.Multiple(() => { + TestCalcOe(data, "GV", 70, 0.25m); + TestCalcOe(data, "GV", 72, 0.25m); + TestCalcOe(data, "GV", 73, 0.50m); + TestCalcOe(data, "GV", 74, 0.55m); + TestCalcOe(data, "GV", 80, 0.85m); + TestCalcOe(data, "GV", 83, 1.00m); + TestCalcOe(data, "GV", 90, 1.00m); + TestCalcOe(data, "GV", 73, 0.60m, geb: true); + TestCalcOe(data, "GV", 74, 0.65m, geb: true); + TestCalcOe(data, "GV", 80, 0.95m, geb: true); + TestCalcOe(data, "GV", 83, 1.10m, geb: true); + TestCalcOe(data, "GV", 90, 1.10m, geb: true); + }); + } + + [Test] + public void Test_03_GreaterThanAndLessThan() { + var data = BillingData.FromJson(""" + { + "mode": "elwig", + "version": 1, + "payment": "curve:1", + "curves": [{ + "id": 1, + "mode": "kmw", + "data": { + "<14kmw": 0.1, + "14kmw": 0.2, + "<15kmw": 0.25, + "15kmw": 0.5, + "17kmw": 1, + ">17kmw": 1.25 + } + }] + } + """, AttributeVariants); + Assert.Multiple(() => { + TestCalcKmw(data, "GV", 13.00, 0.10m); + TestCalcKmw(data, "GV", 13.50, 0.10m); + TestCalcKmw(data, "GV", 13.99, 0.10m); + TestCalcKmw(data, "GV", 14.00, 0.20m); + TestCalcKmw(data, "GV", 14.50, 0.225m); + TestCalcKmw(data, "GV", 15.00, 0.50m); + TestCalcKmw(data, "GV", 15.50, 0.625m); + TestCalcKmw(data, "GV", 16.00, 0.75m); + TestCalcKmw(data, "GV", 16.50, 0.875m); + TestCalcKmw(data, "GV", 17.00, 1.00m); + TestCalcKmw(data, "GV", 17.01, 1.25m); + TestCalcKmw(data, "GV", 17.50, 1.25m); + TestCalcKmw(data, "GV", 18.00, 1.25m); + TestCalcKmw(data, "GV", 18.50, 1.25m); + }); + } + + [Test] + public void Test_04_VariantsAndAttributes() { + var data = BillingData.FromJson(""" + { + "mode": "elwig", + "version": 1, + "payment": { + "default": 0.10, + "GV/": 0.20, + "ZW": 0.25, + "/S": 0.15, + "GV/K": 0.30 + }, + "curves": [] + } + """, AttributeVariants); + Assert.Multiple(() => { + TestCalcOe(data, "WR", 73, 0.10m); + TestCalcOe(data, "WRS", 73, 0.15m); + TestCalcOe(data, "GV", 73, 0.20m); + TestCalcOe(data, "GVD", 73, 0.10m); + TestCalcOe(data, "GVK", 73, 0.30m); + TestCalcOe(data, "GVS", 73, 0.15m); + TestCalcOe(data, "GVZ", 73, 0.10m); + TestCalcOe(data, "ZW", 73, 0.25m); + TestCalcOe(data, "ZWS", 73, 0.15m); + TestCalcOe(data, "ZWZ", 73, 0.25m); + }); + } + + [Test] + public void Test_05_QualityLevel() { + var data = BillingData.FromJson(""" + { + "mode": "elwig", + "version": 1, + "payment": 0.5, + "quality": { + "WEI": { + "default": 0.25, + "GV": 0.3, + "/S": 0.2 + } + }, + "curves": [] + } + """, AttributeVariants); + Assert.Multiple(() => { + TestCalcOe(data, "GV", 75, 0.30m, qualid: "WEI"); + TestCalcOe(data, "ZW", 76, 0.25m, qualid: "WEI"); + TestCalcOe(data, "GVS", 75, 0.20m, qualid: "WEI"); + TestCalcOe(data, "GVK", 74, 0.30m, qualid: "WEI"); + TestCalcOe(data, "ZWS", 73, 0.20m, qualid: "WEI"); + TestCalcOe(data, "GV", 70, 0.5m); + TestCalcOe(data, "GV", 72, 0.5m); + TestCalcOe(data, "GV", 73, 0.5m); + TestCalcOe(data, "ZWS", 74, 0.5m); + TestCalcOe(data, "GVK", 80, 0.5m); + }); + } + + [Test] + public void Test_06_ModeOeAndKmw() { + var data = BillingData.FromJson(""" + { + "mode": "elwig", + "version": 1, + "payment": { + "default": 1.0, + "GV": "curve:1", + "ZW": "curve:2" + }, + "curves": [{ + "id": 1, + "mode": "oe", + "data": { + "73oe": 2.0, + "17kmw": 3.0 + } + }, { + "id": 2, + "mode": "kmw", + "data": { + "73oe": 2.0, + "17kmw": 3.0 + } + }] + } + """, AttributeVariants); + Assert.Multiple(() => { + TestCalcKmw(data, "GV", 15.0, 2.0m); + TestCalcKmw(data, "GV", 15.5, 2.272727m); + TestCalcKmw(data, "GV", 16.0, 2.454545m); + TestCalcKmw(data, "GV", 16.5, 2.727273m); + TestCalcKmw(data, "GV", 17.0, 3.0m); + TestCalcKmw(data, "ZW", 15.0, 2.0m); + TestCalcKmw(data, "ZW", 15.5, 2.25m); + TestCalcKmw(data, "ZW", 16.0, 2.50m); + TestCalcKmw(data, "ZW", 16.5, 2.75m); + TestCalcKmw(data, "ZW", 17.0, 3.0m); + }); + } + + [Test] + public void Test_07_MultipleCurves() { + var data = BillingData.FromJson(""" + { + "mode": "elwig", + "version": 1, + "payment": { + "default": 0.25, + "/S": "curve:1", + "GV/": 0.75, + "GV/K": "curve:2", + "WR/S": "curve:3" + }, + "curves": [{ + "id": 3, + "mode": "kmw", + "data": { + "73oe": 0.7, + "17kmw": 0.8 + }, + "geb": { + "15kmw": 0.8, + "17kmw": 0.95 + } + }, { + "id": 1, + "mode": "kmw", + "data": { + "73oe": 0.5, + "17kmw": 0.6 + }, + "geb": 0.1 + }, { + "id": 2, + "mode": "kmw", + "data": { + "15kmw": 0.6, + "17kmw": 0.7 + }, + "geb": { + "73oe": 0.65, + "17kmw": 0.80 + } + }] + } + """, AttributeVariants); + Assert.Multiple(() => { + TestCalcKmw(data, "GV", 15.0, 0.75m); + TestCalcKmw(data, "GVS", 15.0, 0.50m); + TestCalcKmw(data, "GVS", 16.0, 0.55m); + TestCalcKmw(data, "GVS", 17.0, 0.60m); + TestCalcKmw(data, "GVS", 15.0, 0.60m, geb: true); + TestCalcKmw(data, "GVS", 16.0, 0.65m, geb: true); + TestCalcKmw(data, "GVS", 17.0, 0.70m, geb: true); + TestCalcKmw(data, "GVK", 15.0, 0.60m); + TestCalcKmw(data, "GVK", 16.0, 0.65m); + TestCalcKmw(data, "GVK", 17.0, 0.70m); + TestCalcKmw(data, "GVK", 15.0, 0.65m, geb: true); + TestCalcKmw(data, "GVK", 16.0, 0.725m, geb: true); + TestCalcKmw(data, "GVK", 17.0, 0.80m, geb: true); + TestCalcKmw(data, "WRS", 15.0, 0.70m); + TestCalcKmw(data, "WRS", 16.0, 0.75m); + TestCalcKmw(data, "WRS", 17.0, 0.80m); + TestCalcKmw(data, "WRS", 15.0, 0.80m, geb: true); + TestCalcKmw(data, "WRS", 16.0, 0.875m, geb: true); + TestCalcKmw(data, "WRS", 17.0, 0.95m, geb: true); + }); + } + } +}