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:
@@ -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:<mode></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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user