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:
@@ -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: "");
|
||||
|
||||
@@ -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" })));
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user