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
+3 -3
View File
@@ -42,11 +42,11 @@ public class FdsDataTests
[Fact]
public void FdsInvoiceData_BuildInvoiceParams_PicksHighestVatAndMapsFields()
{
// two line items with different VAT rates → highest (19) selected
// VAT comes from the sms.vat map (rate → amount); highest rate (19) selected.
var jo = JObject.Parse(
"{ \"admin\": { \"type\": \"R\", \"customerid\": \"7\", \"p13b\": false }, " +
" \"new\": { \"title\": \"Bad\", \"total_gross\": \"119\", \"total_net\": \"100\", \"vat_19_net\": \"100\", \"paymentterm\": \"10d\" }, " +
" \"req\": [ { \"items\": [ { \"vat\": \"7%\" }, { \"vat\": \"19%\" } ] } ] }");
" \"new\": { \"title\": \"Bad\", \"total_gross\": \"119\", \"total_net\": \"100\", \"paymentterm\": \"10d\" }, " +
" \"sms\": { \"vat\": { \"7,0%\": 7.0, \"19,0%\": 19.0 } } }");
var inv = new FdsInvoiceData(jo);
var pl = inv.BuildInvoiceParams(change: false, invId: "");
+111
View File
@@ -0,0 +1,111 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Fuchs.intranet;
using Newtonsoft.Json.Linq;
using Xunit;
using static OCORE.OCORE_dictionaries;
namespace Fuchs.Tests;
/// <summary>
/// Verifies that the posted <c>admin</c> flags map to the <c>@InvoiceOptions</c>
/// CSV the backend persists — the channel both §13b and the set-pricing
/// <c>setmode</c> token ride. The PDF reads the mode back via
/// <see cref="InvoiceSetPricing.ModeFromInvoiceOptions"/>.
/// </summary>
public class InvoiceOptionsTests
{
private static string InvoiceOptionsFor(object? admin)
{
var root = new JObject();
if (admin != null) root["admin"] = JObject.FromObject(admin);
var data = new FdsInvoiceData(root);
var p = data.BuildInvoiceParams(change: false, invId: "")
.First(x => x.ParameterName == "@InvoiceOptions");
return p.Value is DBNull or null ? "" : p.Value!.ToString()!;
}
[Fact]
public void NoFlags_EmptyOptions()
=> Assert.Equal("", InvoiceOptionsFor(new { type = "r" }));
[Fact]
public void DefaultSetPrice_OmitsToken()
=> Assert.Equal("", InvoiceOptionsFor(new { type = "r", setmode = "setprice" }));
[Theory]
[InlineData("itemprices", "setmode:itemprices")]
[InlineData("setonly", "setmode:setonly")]
[InlineData("ITEMPRICES", "setmode:itemprices")] // case-insensitive
public void SetMode_EmitsToken(string mode, string expected)
=> Assert.Equal(expected, InvoiceOptionsFor(new { type = "r", setmode = mode }));
[Fact]
public void UnknownSetMode_OmitsToken()
=> Assert.Equal("", InvoiceOptionsFor(new { type = "r", setmode = "garbage" }));
[Fact]
public void P13bOnly_EmitsLegacyToken()
=> Assert.Equal("§13b", InvoiceOptionsFor(new { type = "r", p13b = true }));
[Fact]
public void P13bAndSetMode_EmitsBoth()
=> Assert.Equal("§13b,setmode:setonly", InvoiceOptionsFor(new { type = "r", p13b = true, setmode = "setonly" }));
// ── VAT: highest rate + amount taken from the sms.vat map ─────────────────
private static GenericObjectDictionary SmsWithVat(params (string rate, double amount)[] vat)
{
var map = new Dictionary<string, double>();
foreach (var (r, a) in vat) map[r] = a;
return new GenericObjectDictionary(new Dictionary<string, object> { ["vat"] = JObject.FromObject(map) });
}
[Fact]
public void HighestVat_PicksHighestRate_GermanFormatted()
{
var (rate, amt) = FdsInvoiceData.HighestVat(SmsWithVat(("19,0%", 38.0), ("7,0%", 7.0)));
Assert.Equal("19", rate); // integer, invariant
Assert.Equal("38", amt);
}
[Fact]
public void HighestVat_NonIntegerRate_Preserved()
{
var (rate, amt) = FdsInvoiceData.HighestVat(SmsWithVat(("10,5%", 5.25)));
Assert.Equal("10.5", rate); // invariant decimal point — safe for numeric(5,2)
Assert.Equal("5.25", amt);
}
[Fact]
public void HighestVat_EmptyOrMissing_ReturnsZero()
{
Assert.Equal(("0", "0"), FdsInvoiceData.HighestVat(null));
Assert.Equal(("0", "0"), FdsInvoiceData.HighestVat(new GenericObjectDictionary(new Dictionary<string, object>())));
}
[Fact]
public void HighestVat_FlowsIntoParams()
{
var root = new JObject
{
["admin"] = JObject.FromObject(new { type = "r" }),
["sms"] = JObject.FromObject(new Dictionary<string, object>
{
["vat"] = new Dictionary<string, double> { ["19,0%"] = 38.0 }
})
};
var pars = new FdsInvoiceData(root).BuildInvoiceParams(false, "");
Assert.Equal("19", pars.First(p => p.ParameterName == "@InvoiceVAT_1").Value);
}
[Fact]
public void ModeFromInvoiceOptions_RoundTripsBackendEmission()
{
// The token this side emits must parse back to the same mode on the PDF side.
Assert.Equal(SetDisplayMode.ItemPrices,
InvoiceSetPricing.ModeFromInvoiceOptions(InvoiceOptionsFor(new { type = "r", setmode = "itemprices" })));
Assert.Equal(SetDisplayMode.SetOnly,
InvoiceSetPricing.ModeFromInvoiceOptions(InvoiceOptionsFor(new { type = "r", p13b = true, setmode = "setonly" })));
}
}
+34
View File
@@ -144,6 +144,40 @@ public class InvoiceSetPricingTests
Assert.All(lines, l => Assert.False(l.IsSetHeader));
}
// ── Text/Title lines never show a price (any mode) ────────────────────────
private static Dictionary<string, object?> TextLine(string title) => new()
{
["type"] = "title", ["title"] = title, ["qty"] = "", ["price_net"] = "", ["total_net"] = ""
};
[Fact]
public void Build_TextLine_Standalone_NoPrice()
{
var items = new List<Dictionary<string, object?>>
{
TextLine("Leistungszeitraum 2026"),
Standalone("Anfahrt", "50.00", "50.00")
};
var lines = InvoiceSetPricing.Build(items, SetDisplayMode.SetPrice);
Assert.False(lines[0].ShowPrice); // heading — blank price
Assert.True(lines[1].ShowPrice); // real item — priced
}
[Fact]
public void Build_TextLine_AsSetMember_NoPriceEvenInItemPrices()
{
var items = new List<Dictionary<string, object?>>
{
SetHeader("10", "Bad-Komplettset", "1000.00"),
new() { ["type"] = "title", ["setId"] = "10", ["title"] = "Hinweis", ["total_net"] = "" },
Member("10", "Waschbecken", "600.00", "600.00")
};
var lines = InvoiceSetPricing.Build(items, SetDisplayMode.ItemPrices);
var note = lines.First(l => l.Title == "Hinweis");
Assert.False(note.ShowPrice); // text member stays blank
Assert.True(lines.First(l => l.Title == "Waschbecken").ShowPrice);
}
// ── Set price equals sum of member prices across modes (no double counting) ─
[Fact]
public void SetPrice_EqualsSumOfMembers()