Key the set-price menu modes on the set instead of the service-request block (ADR 0019)
"Set mit Preis" / "Nur Set mit Preis" collapsed every block into an inserted "Gesamtumfang pauschal" row, nulled/removed all lines and cleared the user-defined set structure. Both modes now convert every still-unconverted top-level set: the set line takes the sum of its subtree, its members render without a price (setprice) or are removed from itm + items (setonly); lines outside a set stay individually priced and several sets stay separate lines. Nested sets are priced once via their top-level set. One conversion primitive (ConvertSetHeader) now backs all three triggers; item.setprice is a no-op on an already-converted set (a repeated delta used to reset the set price to 0). The header's price_net is mirrored too (flat PDF path unit price), and Build treats a null total_net as "no price" like the flat path. The editor checks for a set before offering the modes and the menu entry stays available for sets added later. ADR 0009 is superseded by 0019; INVOICE_SET_PRICING.md rewritten accordingly. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -123,8 +123,8 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
||||
case "block.remove": return RemoveBlock(s, d, ref oldValue, ref newValue);
|
||||
case "block.order": return ReorderBlocks(s, d, ref oldValue, ref newValue);
|
||||
case "item.setprice": return ApplyItemSetPrice(s, d, ref oldValue, ref newValue);
|
||||
case "block.setprice": return ApplyBlockSetPricing(s, removeMembers: false, ref oldValue, ref newValue);
|
||||
case "block.setonly": return ApplyBlockSetPricing(s, removeMembers: true, ref oldValue, ref newValue);
|
||||
case "block.setprice": return ApplySetPricing(s, removeMembers: false, ref oldValue, ref newValue); // ADR 0019: keyed on the set, not the block
|
||||
case "block.setonly": return ApplySetPricing(s, removeMembers: true, ref oldValue, ref newValue);
|
||||
case "set.assign":
|
||||
case "set.level":
|
||||
case "set.create":
|
||||
@@ -221,23 +221,10 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
||||
/// <summary>
|
||||
/// "Auf Setpreis umstellen" (backend-authoritative, ADR 0006): irreversibly converts a Set
|
||||
/// header item (<c>Ref</c> = the header's item id) from individually-priced members to a
|
||||
/// single set price. Sums the <c>net</c>/<c>net_val</c>/<c>vat_val</c>/<c>svcnet_val</c>/
|
||||
/// <c>svcvat_val</c> of every member line in the same block whose <c>SetItmId</c> equals the
|
||||
/// header id (the grouping <c>fds__prepInvoice</c> computes, anchored on the still-unconverted,
|
||||
/// zero-priced Set header — see the <c>[SetItmID]</c> window function there), writes that sum
|
||||
/// onto the header line and zeroes each member's price fields — matching the visual
|
||||
/// <c>SetPrice</c> display mode (<see cref="InvoiceSetPricing"/>), but as an actual, irreversible
|
||||
/// data change rather than a display toggle. The user can still change the resulting header price
|
||||
/// manually afterwards (ordinary item edit), which is why this is one-way.
|
||||
/// <para>
|
||||
/// The header row's own <c>SetItmId</c> now self-references its own id (rather than being
|
||||
/// <c>null</c>) once <c>fds__prepInvoice</c> stopped special-casing it; membership is resolved
|
||||
/// by <see cref="InvoiceSetStructure.Descendants"/>, which never yields the header itself.
|
||||
/// Because sets may contain sets (ADR 0016), the members are the header's <b>transitive</b>
|
||||
/// descendants: a nested, still-unconverted set contributes its members' prices, a nested
|
||||
/// already-converted set contributes its own set price — either way every descendant line is
|
||||
/// price-nulled afterwards, so the total is conserved and nothing is counted twice.
|
||||
/// </para>
|
||||
/// single set price, by delegating to <see cref="ConvertSetHeader"/> — the one conversion
|
||||
/// primitive shared with the two menu modes (<see cref="ApplySetPricing"/>, ADR 0019).
|
||||
/// The user can still change the resulting header price manually afterwards (ordinary item
|
||||
/// edit), which is why this is one-way.
|
||||
/// </summary>
|
||||
private static bool ApplyItemSetPrice(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
|
||||
{
|
||||
@@ -248,137 +235,141 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
||||
var lines = InvoiceSetStructure.Lines(block);
|
||||
var header = lines.FirstOrDefault(co => Str(co["id"]) == d.Ref);
|
||||
if (header == null) continue;
|
||||
if (!string.Equals(Str(header["typ"]), "set", StringComparison.OrdinalIgnoreCase)) return false;
|
||||
var members = InvoiceSetStructure.Descendants(lines, d.Ref);
|
||||
if (!InvoiceSetStructure.IsSet(header)) return false;
|
||||
// Already converted (own price non-zero): one-way, so this is a no-op. Without the
|
||||
// guard a second delta (double click, replayed patch) would re-sum the members that
|
||||
// the first conversion already nulled and reset the set price to 0.
|
||||
if (InvoiceDraftCalculator.Dec(header["vt"]) != 0) return false;
|
||||
|
||||
oldValue = InvoiceDraftCalculator.Dec(header["vt"]).ToString(CultureInfo.InvariantCulture);
|
||||
decimal sumNet = 0, sumVat = 0, sumSvcNet = 0, sumSvcVat = 0;
|
||||
foreach (var m in members)
|
||||
{
|
||||
sumNet += InvoiceDraftCalculator.Dec(m["vt"]);
|
||||
sumVat += InvoiceDraftCalculator.Dec(m["vv"]);
|
||||
sumSvcNet += InvoiceDraftCalculator.Dec(m["vs"]);
|
||||
sumSvcVat += InvoiceDraftCalculator.Dec(m["vsv"]);
|
||||
if (Str(header["vat"]).Length == 0 && Str(m["vat"]).Length > 0) header["vat"] = Str(m["vat"]); // a user-created set adopts its members' rate
|
||||
NullLinePrice(m); // ADR 0009: null (empty cell), not 0 — excluded from the sum, distinct from a real 0,00 €
|
||||
}
|
||||
header["v"] = sumNet; header["vt"] = sumNet; header["vv"] = sumVat; header["vs"] = sumSvcNet; header["vsv"] = sumSvcVat;
|
||||
decimal sumNet = ConvertSetHeader(block, lines, header);
|
||||
newValue = sumNet.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
// Mirror the conversion onto the "items" contract shape (InvoiceSetPricing/BuildSetDisplay
|
||||
// read total_net from here, not from "itm") so the online editor's set-display immediately
|
||||
// reflects that the set is now converted, instead of waiting for a reload.
|
||||
if (block["items"] is JArray items)
|
||||
{
|
||||
foreach (var itemTok in items)
|
||||
{
|
||||
if (itemTok is not JObject io) continue;
|
||||
string id = Str(io["id"]);
|
||||
if (id == d.Ref) { io["total_net"] = sumNet; }
|
||||
else if (members.Any(m => Str(m["id"]) == id)) { NullItemPrice(io); }
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The two menu set-price modes (ADR 0009), grouped by service-request <b>block</b>
|
||||
/// (not <c>SetItmId</c>) and applied server-side as an <b>irreversible mutation</b> of the
|
||||
/// cached session — never a display flag. For every block that carries any priced line:
|
||||
/// a dedicated, emphasised set row (<c>typ == "set"</c>) is inserted at the top carrying the
|
||||
/// block's aggregated value (net + VAT + service-net/-VAT splits); then the block's original
|
||||
/// items are either price-<b>nulled</b> (<paramref name="removeMembers"/> = false → "Set mit
|
||||
/// Preis": empty price cells, kept) or <b>removed</b> (<paramref name="removeMembers"/> = true →
|
||||
/// "Nur Set mit Preis"). <c>null</c> (not <c>0</c>) marks "no price" — an empty cell excluded
|
||||
/// from the sum, distinct from a genuine 0,00 €. Any pre-existing set membership is neutralised
|
||||
/// (existing set rows demoted, <c>setId</c>/<c>SetItmId</c> cleared) so the block becomes one
|
||||
/// flat set and the two groupings (this vs. the <c>SetItmId</c>-based item switch) never collide.
|
||||
/// The invoice total is conserved: the set row's value equals the sum of the members it blanks
|
||||
/// or removes.
|
||||
/// The single set-conversion primitive: sums the <c>vt</c>/<c>vv</c>/<c>vs</c>/<c>vsv</c> of the
|
||||
/// header's <b>transitive</b> descendants (<see cref="InvoiceSetStructure.Descendants"/>, ADR 0016
|
||||
/// — a nested unconverted set contributes its members' prices, a nested already-converted set its
|
||||
/// own set price, so nothing is counted twice), writes that sum onto the header and nulls every
|
||||
/// descendant's price. <c>null</c> (not <c>0</c>) marks "no price": an empty cell excluded from the
|
||||
/// sum, distinct from a genuine 0,00 € (ADR 0009). The conversion is mirrored onto the <c>items</c>
|
||||
/// contract shape so <see cref="InvoiceSetPricing"/>/<c>BuildSetDisplay</c> — which read
|
||||
/// <c>total_net</c> from there — see the set as converted immediately, without a reload.
|
||||
/// Returns the set's net total. The invoice total is conserved.
|
||||
/// </summary>
|
||||
private static bool ApplyBlockSetPricing(InvoiceDraftSession s, bool removeMembers, ref string oldValue, ref string newValue)
|
||||
private static decimal ConvertSetHeader(JObject block, List<JObject> lines, JObject header)
|
||||
{
|
||||
string headerId = Str(header["id"]);
|
||||
var members = InvoiceSetStructure.Descendants(lines, headerId);
|
||||
|
||||
decimal sumNet = 0, sumVat = 0, sumSvcNet = 0, sumSvcVat = 0;
|
||||
foreach (var m in members)
|
||||
{
|
||||
sumNet += InvoiceDraftCalculator.Dec(m["vt"]);
|
||||
sumVat += InvoiceDraftCalculator.Dec(m["vv"]);
|
||||
sumSvcNet += InvoiceDraftCalculator.Dec(m["vs"]);
|
||||
sumSvcVat += InvoiceDraftCalculator.Dec(m["vsv"]);
|
||||
if (Str(header["vat"]).Length == 0 && Str(m["vat"]).Length > 0) header["vat"] = Str(m["vat"]); // a user-created set adopts its members' rate
|
||||
NullLinePrice(m);
|
||||
}
|
||||
header["v"] = sumNet; header["vt"] = sumNet; header["vv"] = sumVat; header["vs"] = sumSvcNet; header["vsv"] = sumSvcVat;
|
||||
|
||||
if (block["items"] is JArray items)
|
||||
{
|
||||
var memberIds = members.Select(m => Str(m["id"])).Where(id => id.Length > 0).ToHashSet(StringComparer.Ordinal);
|
||||
foreach (var itemTok in items)
|
||||
{
|
||||
if (itemTok is not JObject io) continue;
|
||||
string id = Str(io["id"]);
|
||||
// Both price fields: the flat PDF path (MapItemToLine, taken once the members are
|
||||
// gone under "Nur Set mit Preis") prints price_net as the unit price — mirroring only
|
||||
// total_net would print the stale 0 next to the correct total.
|
||||
if (id == headerId) { io["price_net"] = sumNet; io["total_net"] = sumNet; }
|
||||
else if (memberIds.Contains(id)) NullItemPrice(io);
|
||||
}
|
||||
}
|
||||
return sumNet;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The two menu set-price modes (ADR 0019, superseding the per-block collapse of ADR 0009):
|
||||
/// applied server-side as an <b>irreversible mutation</b> of the cached session, keyed on the
|
||||
/// user-defined <b>set structure</b> (ADR 0016) — never on the service-request block and never a
|
||||
/// display flag. Every still-unconverted <b>top-level</b> set of every block is converted via
|
||||
/// <see cref="ConvertSetHeader"/>, so each set carries one price and its members render with an
|
||||
/// empty price cell ("Set mit Preis", <paramref name="removeMembers"/> = false) or are removed from
|
||||
/// the dataset entirely ("Nur Set mit Preis", <paramref name="removeMembers"/> = true). Nested sets
|
||||
/// are part of their top-level set's subtree, so they are blanked/removed with it rather than
|
||||
/// priced twice.
|
||||
/// <para>
|
||||
/// Lines that belong to <b>no</b> set are left completely untouched: they keep their individual
|
||||
/// price and are listed as ordinary positions. Sets and standalone lines together make up the
|
||||
/// invoice total, which is conserved either way — a blanked member contributes nothing
|
||||
/// (<c>null</c> = no price) and a removed one is gone, in both cases already accounted for by the
|
||||
/// set price. An invoice without any set is a no-op.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static bool ApplySetPricing(InvoiceDraftSession s, bool removeMembers, ref string oldValue, ref string newValue)
|
||||
{
|
||||
bool mutated = false;
|
||||
int converted = 0;
|
||||
int converted = 0, removed = 0;
|
||||
foreach (var blockTok in s.Req)
|
||||
{
|
||||
if (blockTok is not JObject block || block["itm"] is not JArray lines || lines.Count == 0) continue;
|
||||
if (blockTok is not JObject block || block["itm"] is not JArray lineArr) continue;
|
||||
var lines = InvoiceSetStructure.Lines(block);
|
||||
var byId = InvoiceSetStructure.ById(lines);
|
||||
var dropIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
// Aggregate the block from its current line values (nulled/absent = 0 contribution).
|
||||
decimal net = 0, vat = 0, svcNet = 0, svcVat = 0;
|
||||
string rate = "";
|
||||
foreach (var lineTok in lines)
|
||||
// Top-level sets only: a nested set is a member of its parent's subtree and is
|
||||
// blanked/removed with it, so converting both would count its value twice.
|
||||
foreach (var header in lines.Where(co => InvoiceSetStructure.IsSet(co)
|
||||
&& InvoiceSetStructure.ParentOf(co, byId) == null).ToList())
|
||||
{
|
||||
if (lineTok is not JObject co) continue;
|
||||
net += InvoiceDraftCalculator.Dec(co["vt"]);
|
||||
vat += InvoiceDraftCalculator.Dec(co["vv"]);
|
||||
svcNet += InvoiceDraftCalculator.Dec(co["vs"]);
|
||||
svcVat += InvoiceDraftCalculator.Dec(co["vsv"]);
|
||||
string r = InvoiceDraftCalculator.NormalizeRate(Str(co["vat"]));
|
||||
if (r.Length > 0) rate = r; // collapsed set line carries the block's (highest/last) rate
|
||||
}
|
||||
if (net == 0 && vat == 0 && svcNet == 0 && svcVat == 0) continue; // nothing priced -> skip block
|
||||
string headerId = Str(header["id"]);
|
||||
if (headerId.Length == 0) continue;
|
||||
var members = InvoiceSetStructure.Descendants(lines, headerId);
|
||||
if (members.Count == 0) continue; // an empty set has nothing to collapse
|
||||
|
||||
string blockId = Str(block["Id"]);
|
||||
string setId = "bset_" + (blockId.Length > 0 ? blockId : Guid.NewGuid().ToString("N")[..8]);
|
||||
const string title = "Gesamtumfang pauschal"; // neutral label for the block set row (ADR 0009) — not the service-request title
|
||||
|
||||
// The dedicated, emphasised set row, in both line shapes (itm = editor co; items = contract).
|
||||
var setItm = new JObject
|
||||
{
|
||||
["id"] = setId, ["typ"] = "set", ["p"] = "", ["q"] = "", ["t"] = title, ["tt"] = "",
|
||||
["v"] = JValue.CreateNull(), ["vt"] = net, ["vv"] = vat, ["vs"] = svcNet, ["vsv"] = svcVat,
|
||||
["vat"] = rate, ["det"] = false
|
||||
};
|
||||
var setItem = new JObject
|
||||
{
|
||||
["id"] = setId, ["type"] = "set", ["title"] = title, ["desc"] = "", ["qty"] = "",
|
||||
["price_net"] = net, ["total_net"] = net, ["vat"] = rate, ["setId"] = JValue.CreateNull()
|
||||
};
|
||||
|
||||
// Mutate the itm array in place (avoids Newtonsoft re-parenting): drop members (setonly)
|
||||
// or null their prices + neutralise set membership (setprice), then prepend the set row.
|
||||
if (removeMembers) lines.Clear();
|
||||
else
|
||||
foreach (var lineTok in lines)
|
||||
// Already converted (own price non-zero, e.g. via the inline item switch): keep its
|
||||
// price as it stands — but "Nur Set mit Preis" still drops the members below.
|
||||
if (InvoiceDraftCalculator.Dec(header["vt"]) == 0)
|
||||
{
|
||||
if (lineTok is not JObject co) continue;
|
||||
DemoteSet(co, "typ"); co["SetItmId"] = JValue.CreateNull();
|
||||
NullLinePrice(co);
|
||||
ConvertSetHeader(block, lines, header);
|
||||
mutated = true; converted++;
|
||||
}
|
||||
lines.Insert(0, setItm);
|
||||
|
||||
if (block["items"] is JArray items)
|
||||
{
|
||||
if (removeMembers) items.Clear();
|
||||
else
|
||||
foreach (var itemTok in items)
|
||||
if (removeMembers)
|
||||
foreach (var m in members)
|
||||
{
|
||||
if (itemTok is not JObject io) continue;
|
||||
DemoteSet(io, "type"); io["setId"] = JValue.CreateNull();
|
||||
NullItemPrice(io);
|
||||
string mid = Str(m["id"]);
|
||||
if (mid.Length > 0) dropIds.Add(mid);
|
||||
}
|
||||
items.Insert(0, setItem);
|
||||
}
|
||||
else
|
||||
{
|
||||
block["items"] = new JArray { setItem };
|
||||
}
|
||||
|
||||
mutated = true; converted++;
|
||||
if (dropIds.Count > 0)
|
||||
{
|
||||
int gone = RemoveById(lineArr, dropIds);
|
||||
if (block["items"] is JArray itemsArr) RemoveById(itemsArr, dropIds);
|
||||
if (gone > 0) { mutated = true; removed += gone; }
|
||||
}
|
||||
}
|
||||
oldValue = "";
|
||||
newValue = (removeMembers ? "setonly" : "setprice") + ":" + converted;
|
||||
newValue = (removeMembers ? "setonly" : "setprice") + ":" + converted + (removeMembers ? "/" + removed : "");
|
||||
return mutated;
|
||||
}
|
||||
|
||||
/// <summary>Demotes a pre-existing set row to a plain line so only the freshly inserted block set row stays "set".</summary>
|
||||
private static void DemoteSet(JObject line, string typeKey)
|
||||
/// <summary>Removes every entry of the array whose <c>id</c> is in <paramref name="ids"/>; returns how many went.</summary>
|
||||
private static int RemoveById(JArray arr, HashSet<string> ids)
|
||||
{
|
||||
if (line[typeKey] is { } t && string.Equals(Str(t), "set", StringComparison.OrdinalIgnoreCase))
|
||||
line[typeKey] = "other";
|
||||
int n = 0;
|
||||
for (int i = arr.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (arr[i] is not JObject o || !ids.Contains(Str(o["id"]))) continue;
|
||||
arr[i].Remove(); n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/// <summary>Sets the editor <c>co</c> price fields to JSON null (empty cell, excluded from sums — ADR 0009).</summary>
|
||||
@@ -478,8 +469,9 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
||||
{
|
||||
if (blockTok is not JObject block || block["items"] is not JArray itemsArr) continue;
|
||||
List<Dictionary<string, object?>>? items = itemsArr.ToObject<List<Dictionary<string, object?>>>();
|
||||
// Only function-1 (SetItmId-linked) sets get display flags; block-mode sets (ADR 0009)
|
||||
// clear setId and render flat, so BuildSetDisplay must not sweep them into Build here.
|
||||
// Only blocks that actually carry a set need flags; a block of plain, individually
|
||||
// priced lines renders flat (and since ADR 0019 keeps non-set lines untouched, that is
|
||||
// exactly what the two menu modes leave behind for them).
|
||||
if (items == null || !InvoiceSetPricing.HasSetMembers(items)) continue;
|
||||
|
||||
foreach (var line in InvoiceSetPricing.Build(items, mode))
|
||||
|
||||
Reference in New Issue
Block a user