Invoice set pricing: wire editor + align editor/backend contract

Front-end (fis.inv_shared.js / fis.inv_txt_de.js, rebuilt bundles):
- 3-way set display switch (setprice/itemprices/setonly) via admin.setmode,
  emitted into InvoiceOptions by FdsInvoiceData.BuildInvoiceOptions.
- Each request block now posts items[] in the backend contract shape
  (title/desc/qty/price_net/total_net + set type/setId tags) via itemToContract.
- invcPayload normalises the editor model to the field names BuildInvoiceParams
  reads (sms totals -> new.total_net/total_gross, invoicetitle->title,
  loc->provisionlocation, admin.paymentterms->new.paymentterm, CustomerId->customerid).

Back-end:
- BuildInvoiceOptions adds the setmode token alongside §13b.
- VAT rate+amount now taken from sms.vat (HighestVat) instead of the broken
  items 'is List<object>' detection that pinned the rate to 19.
- InvoiceSetPricing blanks price/total cells for text/title heading lines.

Tests: set-pricing text-line blanking, HighestVat selection/parsing, updated
the VAT param test to the sms.vat contract. 180 passing.

Note: the second VAT slot (InvoiceVAT_2) stays unused by design; mixed-rate
invoices store only the highest rate (pre-existing, accepted).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 10:48:14 +02:00
parent ebdb92713a
commit bfc695ed6a
12 changed files with 552 additions and 67 deletions
+65 -21
View File
@@ -1,3 +1,4 @@
using System.Globalization;
using Microsoft.Data.SqlClient;
using Newtonsoft.Json.Linq;
using static OCORE.OCORE_dictionaries;
@@ -113,23 +114,10 @@ public class FdsInvoiceData
internal List<SqlParameter> BuildInvoiceParams(bool change, string invId)
{
_ = change; _ = invId;
var vatsDic = new Dictionary<string, string>();
if (Req != null)
{
foreach (var rq in Req)
{
if (rq.TryGetValue("items", out var itmsObj) && itmsObj is List<object> itms)
{
foreach (var itm in itms.OfType<Dictionary<string, object?>>())
{
string vatKey = itm.nz("vat", "").Replace("%", "").Trim();
if (!string.IsNullOrEmpty(vatKey) && vatKey != "0")
vatsDic.TryAdd(vatKey, vatsDic.TryGetValue(vatKey, out var ve) ? ve : "0");
}
}
}
}
string vathigh = vatsDic.Keys.OrderByDescending(k => double.TryParse(k, out var d) ? d : 0).FirstOrDefault() ?? "19";
// VAT rate + amount come from the editor's computed sms.vat map (rate → amount),
// matching the legacy contract. Item-level VAT strings are German-formatted and
// single-rate procs only store one rate, so the highest rate wins.
var (vatRate, vatNet) = HighestVat(Sms);
return new List<SqlParameter>
{
@@ -137,8 +125,8 @@ public class FdsInvoiceData
SQL_NVarChar("@InvoiceTitle", NewValues?.nz("title") ?? ""),
SQL_Float("@InvoiceBalance", stringvalue: NewValues?.nz("total_gross") ?? "0"),
SQL_Float("@InvoiceBalance_net", stringvalue: NewValues?.nz("total_net") ?? "0"),
SQL_Float("@InvoiceVAT_net1", stringvalue: NewValues?.nz($"vat_{vathigh}_net") ?? "0"),
SQL_VarChar("@InvoiceVAT_1", vathigh),
SQL_Float("@InvoiceVAT_net1", stringvalue: vatNet),
SQL_VarChar("@InvoiceVAT_1", vatRate),
SQL_VarChar("@PaymentTerm", NewValues?.nz("paymentterm") ?? "", dbNull_IfEmpty: true),
SQL_BigInt("@CustomerId", Admin?.nz("customerid") ?? ""),
SQL_VarChar("@SendToAddress", RawInvoiceAddress),
@@ -147,8 +135,64 @@ public class FdsInvoiceData
SQL_NVarChar("@CustomValues", RawCustomValues, dbNull_IfEmpty: true),
SQL_Float("@InvoiceService_net", stringvalue: Sms?.nz("tscn") ?? "0"),
SQL_Float("@InvoiceService_VAT", stringvalue: Sms?.nz("tscvat") ?? "0"),
SQL_VarChar("@InvoiceOptions",
Admin?.no("p13b", false) is true ? "§13b" : "", dbNull_IfEmpty: true)
SQL_VarChar("@InvoiceOptions", BuildInvoiceOptions(), dbNull_IfEmpty: true)
};
}
/// <summary>
/// Builds the InvoiceOptions CSV from the posted admin flags.
/// <c>§13b</c> is the existing reverse-charge flag; <c>setmode:&lt;mode&gt;</c>
/// selects the set-pricing display mode (the default <c>setprice</c> is omitted).
/// Read back by <see cref="InvoiceSetPricing.ModeFromInvoiceOptions"/> (set mode)
/// and the §13b PDF note. Both ride the <c>admin</c> object the editor posts.
/// </summary>
internal string BuildInvoiceOptions()
{
var tokens = new List<string>();
if (Admin?.no("p13b", false) is true) tokens.Add("§13b");
string setmode = (Admin?.nz("setmode") ?? "").Trim().ToLowerInvariant();
if (setmode is "itemprices" or "setonly") tokens.Add("setmode:" + setmode);
return string.Join(",", tokens);
}
/// <summary>
/// Determines the highest VAT rate and its net VAT amount from the editor's
/// computed <c>sms.vat</c> map (rate string → amount), e.g. {"19,0%": 123.45}.
/// Mirrors the legacy contract (VAT comes from the totals block, not line items).
/// Returns invariant numeric strings — rate ("19") and amount ("123.45") — ready
/// for the <c>numeric</c> proc params. Empty/absent map → ("0","0").
/// </summary>
internal static (string rate, string netAmount) HighestVat(GenericObjectDictionary? sms)
{
if (sms == null || !sms.TryGetValue("vat", out var vobj) || vobj is null)
return ("0", "0");
bool found = false; double bestRate = 0, bestAmount = 0;
void Consider(string key, double amount)
{
double r = ParseRate(key);
if (!found || r > bestRate) { found = true; bestRate = r; bestAmount = amount; }
}
if (vobj is JObject jo)
foreach (var p in jo)
Consider(p.Key, p.Value is { } t && t.Type is JTokenType.Float or JTokenType.Integer ? t.Value<double>() : 0);
else if (vobj is IDictionary<string, object?> dict)
foreach (var kv in dict)
Consider(kv.Key, double.TryParse(Convert.ToString(kv.Value, CultureInfo.InvariantCulture),
NumberStyles.Any, CultureInfo.InvariantCulture, out var a) ? a : 0);
if (!found) return ("0", "0");
string rate = bestRate == Math.Floor(bestRate)
? ((long)bestRate).ToString(CultureInfo.InvariantCulture)
: bestRate.ToString(CultureInfo.InvariantCulture);
return (rate, bestAmount.ToString(CultureInfo.InvariantCulture));
}
/// <summary>Parses a VAT rate string ("19,0%", "7%", "19") to a number (German or invariant).</summary>
private static double ParseRate(string? key)
{
string s = (key ?? "").Replace("%", "").Trim().Replace(',', '.');
return double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var d) ? d : 0;
}
}
+12 -1
View File
@@ -122,6 +122,17 @@ public static class InvoiceSetPricing
private static bool IsSetHeader(Dictionary<string, object?> i) =>
string.Equals(i.nz("type", ""), "set", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Text/Title lines are headings/free text with no price — their price/total
/// cells render blank (matching the editor and the legacy invoice), regardless
/// of the chosen mode.
/// </summary>
private static bool IsNoPriceLine(Dictionary<string, object?> i)
{
string t = i.nz("type", "").ToLowerInvariant();
return t is "text" or "title";
}
private static string? SetIdOf(Dictionary<string, object?> i)
{
string s = i.nz("setId", "");
@@ -165,7 +176,7 @@ public static class InvoiceSetPricing
Qty = i.nz("qty", "1"),
PriceNet = price,
TotalNet = total,
ShowPrice = showPrice,
ShowPrice = showPrice && !IsNoPriceLine(i), // headings/free text print no price
IsSetHeader = false
};
}