Invoice set pricing: 3 display modes (backend contract + PDF + tests)
Sets (mfr__items Type='set') can be shown three ways on the invoice:
- SetPrice (default): set line priced, member items shown without price
- ItemPrices: member items priced, set line as a heading without price
- SetOnly: only the set line (priced), members removed
- InvoiceSetPricing (new): the authoritative, unit-tested transformation
(SetDisplayMode + Build) that both sides agree on; set price always equals the
sum of members. Mode is read from InvoiceOptions ("setmode:<mode>").
- FuchsPdf.ApplyInvoice renders through it: lines flagged ShowPrice=false print
blank price/total cells; set headers are emphasised. Invoices without sets are
unchanged. Totals come from the registration balance, so modes are purely
presentational and never change the sum.
- InvoiceSetPricingTests (+14): all three modes, set-price = member sum, header
total fallback, no-set pass-through, option parsing.
- Docs/INVOICE_SET_PRICING.md documents the front-end contract (the editor sets
the mode token + tags set header/member items); the back-end does the rest.
Front-end editor wiring is specified in the doc but intentionally not shipped
blind (cannot validate the running editor here).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Fuchs.intranet;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Contract tests for the set-pricing display modes — the agreed interplay
|
||||
/// between the front-end (which picks the mode and groups items) and the
|
||||
/// back-end PDF renderer (which honours ShowPrice / IsSetHeader). Set items use
|
||||
/// type="set"; members carry setId == the set header's id.
|
||||
/// </summary>
|
||||
public class InvoiceSetPricingTests
|
||||
{
|
||||
private static Dictionary<string, object?> SetHeader(string id, string title, string? total = null) => new()
|
||||
{
|
||||
["type"] = "set", ["id"] = id, ["title"] = title, ["qty"] = "1",
|
||||
["total_net"] = total ?? "0", ["price_net"] = total ?? "0"
|
||||
};
|
||||
|
||||
private static Dictionary<string, object?> Member(string setId, string title, string price, string total) => new()
|
||||
{
|
||||
["setId"] = setId, ["title"] = title, ["qty"] = "1", ["price_net"] = price, ["total_net"] = total
|
||||
};
|
||||
|
||||
private static Dictionary<string, object?> Standalone(string title, string price, string total) => new()
|
||||
{
|
||||
["title"] = title, ["qty"] = "1", ["price_net"] = price, ["total_net"] = total
|
||||
};
|
||||
|
||||
// A set (sum 1000) with two members, plus one standalone item (50).
|
||||
private static List<Dictionary<string, object?>> Sample() => new()
|
||||
{
|
||||
SetHeader("10", "Bad-Komplettset", "1000.00"),
|
||||
Member("10", "Waschbecken", "600.00", "600.00"),
|
||||
Member("10", "Armatur", "400.00", "400.00"),
|
||||
Standalone("Anfahrt", "50.00", "50.00")
|
||||
};
|
||||
|
||||
// ── Mode parsing ────────────────────────────────────────────────────────
|
||||
[Theory]
|
||||
[InlineData("itemprices", SetDisplayMode.ItemPrices)]
|
||||
[InlineData("items", SetDisplayMode.ItemPrices)]
|
||||
[InlineData("setonly", SetDisplayMode.SetOnly)]
|
||||
[InlineData("setprice", SetDisplayMode.SetPrice)]
|
||||
[InlineData("", SetDisplayMode.SetPrice)]
|
||||
[InlineData("garbage", SetDisplayMode.SetPrice)]
|
||||
public void ParseMode_Works(string raw, SetDisplayMode expected)
|
||||
=> Assert.Equal(expected, InvoiceSetPricing.ParseMode(raw));
|
||||
|
||||
[Fact]
|
||||
public void ModeFromInvoiceOptions_ReadsToken()
|
||||
{
|
||||
Assert.Equal(SetDisplayMode.ItemPrices, InvoiceSetPricing.ModeFromInvoiceOptions("§13b,setmode:itemprices"));
|
||||
Assert.Equal(SetDisplayMode.SetOnly, InvoiceSetPricing.ModeFromInvoiceOptions("setmode:setonly"));
|
||||
Assert.Equal(SetDisplayMode.SetPrice, InvoiceSetPricing.ModeFromInvoiceOptions("§13b")); // default
|
||||
Assert.Equal(SetDisplayMode.SetPrice, InvoiceSetPricing.ModeFromInvoiceOptions(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContainsSets_DetectsHeaderOrMember()
|
||||
{
|
||||
Assert.True(InvoiceSetPricing.ContainsSets(Sample()));
|
||||
Assert.False(InvoiceSetPricing.ContainsSets(new List<Dictionary<string, object?>> { Standalone("X", "1", "1") }));
|
||||
}
|
||||
|
||||
// ── SetPrice (default): set priced, members blank ─────────────────────────
|
||||
[Fact]
|
||||
public void Build_SetPrice_SetLinePricedMembersBlank()
|
||||
{
|
||||
var lines = InvoiceSetPricing.Build(Sample(), SetDisplayMode.SetPrice);
|
||||
|
||||
Assert.Equal(4, lines.Count); // header + 2 members + standalone
|
||||
var header = lines[0];
|
||||
Assert.True(header.IsSetHeader);
|
||||
Assert.True(header.ShowPrice);
|
||||
Assert.Equal(1000.00m, header.TotalNet);
|
||||
|
||||
Assert.False(lines[1].ShowPrice); // Waschbecken — no price
|
||||
Assert.False(lines[2].ShowPrice); // Armatur — no price
|
||||
Assert.Equal("Waschbecken", lines[1].Title);
|
||||
|
||||
Assert.True(lines[3].ShowPrice); // standalone keeps price
|
||||
Assert.Equal(50.00m, lines[3].TotalNet);
|
||||
}
|
||||
|
||||
// ── ItemPrices: members priced, set header blank ──────────────────────────
|
||||
[Fact]
|
||||
public void Build_ItemPrices_MembersPricedHeaderBlank()
|
||||
{
|
||||
var lines = InvoiceSetPricing.Build(Sample(), SetDisplayMode.ItemPrices);
|
||||
|
||||
Assert.Equal(4, lines.Count);
|
||||
Assert.True(lines[0].IsSetHeader);
|
||||
Assert.False(lines[0].ShowPrice); // set header is just a title now
|
||||
Assert.True(lines[1].ShowPrice);
|
||||
Assert.Equal(600.00m, lines[1].TotalNet);
|
||||
Assert.True(lines[2].ShowPrice);
|
||||
Assert.Equal(400.00m, lines[2].TotalNet);
|
||||
Assert.True(lines[3].ShowPrice); // standalone
|
||||
}
|
||||
|
||||
// ── SetOnly: members removed ──────────────────────────────────────────────
|
||||
[Fact]
|
||||
public void Build_SetOnly_RemovesMembers()
|
||||
{
|
||||
var lines = InvoiceSetPricing.Build(Sample(), SetDisplayMode.SetOnly);
|
||||
|
||||
Assert.Equal(2, lines.Count); // set header + standalone only
|
||||
Assert.True(lines[0].IsSetHeader);
|
||||
Assert.True(lines[0].ShowPrice);
|
||||
Assert.Equal(1000.00m, lines[0].TotalNet);
|
||||
Assert.False(lines[1].IsSetHeader);
|
||||
Assert.Equal("Anfahrt", lines[1].Title);
|
||||
}
|
||||
|
||||
// ── Set price falls back to sum of members when header total is 0 ─────────
|
||||
[Fact]
|
||||
public void Build_HeaderTotalZero_UsesSumOfMembers()
|
||||
{
|
||||
var items = new List<Dictionary<string, object?>>
|
||||
{
|
||||
SetHeader("7", "Set ohne Preis"), // total 0
|
||||
Member("7", "A", "120.00", "120.00"),
|
||||
Member("7", "B", "80.00", "80.00")
|
||||
};
|
||||
var lines = InvoiceSetPricing.Build(items, SetDisplayMode.SetPrice);
|
||||
Assert.Equal(200.00m, lines[0].TotalNet); // 120 + 80
|
||||
}
|
||||
|
||||
// ── No sets: pass-through unchanged ───────────────────────────────────────
|
||||
[Fact]
|
||||
public void Build_NoSets_PassThroughAllPriced()
|
||||
{
|
||||
var items = new List<Dictionary<string, object?>>
|
||||
{
|
||||
Standalone("A", "10.00", "10.00"),
|
||||
Standalone("B", "20.00", "20.00")
|
||||
};
|
||||
var lines = InvoiceSetPricing.Build(items, SetDisplayMode.SetPrice);
|
||||
Assert.Equal(2, lines.Count);
|
||||
Assert.All(lines, l => Assert.True(l.ShowPrice));
|
||||
Assert.All(lines, l => Assert.False(l.IsSetHeader));
|
||||
}
|
||||
|
||||
// ── Set price equals sum of member prices across modes (no double counting) ─
|
||||
[Fact]
|
||||
public void SetPrice_EqualsSumOfMembers()
|
||||
{
|
||||
var setLine = InvoiceSetPricing.Build(Sample(), SetDisplayMode.SetPrice).First(l => l.IsSetHeader);
|
||||
decimal memberSum = Sample()
|
||||
.Where(i => i.TryGetValue("setId", out var s) && s?.ToString() == "10")
|
||||
.Sum(i => decimal.Parse(i["total_net"]!.ToString()!, System.Globalization.CultureInfo.InvariantCulture));
|
||||
Assert.Equal(memberSum, setLine.TotalNet);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user