Add user-defined invoice set structure dialog (ADR 0016)
Playwright Tests / test (push) Canceled after 0s

Lets a back-office user correct/define which invoice line belongs to
which "set" and how deep it is nested, instead of relying only on the
implicit "everything after a Set header until the next Set" grouping
fds__prepInvoice derives. Sets may contain sets; every change applies
immediately to the cached draft session and is reflected in the editor
and PDF (ADR 0006/0008).

Backend:
- InvoiceSetStructure: pure parent-link forest (assign, change level,
  create, place/move-sibling, depth-first linearise + level recompute).
- InvoiceDraftSetStructureDeltas: new inv/dpatch targets set.assign,
  set.level, set.create, set.move.
- InvoiceSetPricing.Build is now order-preserving and nesting-aware
  (governed by the nearest converted ancestor); the set-item switch
  sums a header's transitive descendants so nested conversions never
  double-count.
- FuchsPdf indents nested set members per level, mirroring the editor.

Frontend:
- New fis.inv_sets.js: "Set-Struktur bearbeiten" dialog — per-block
  table of all lines with drag & drop reorder (before/after/into),
  ▲▼ order and ◀▶ level buttons, fold/unfold per set (view-only), and
  a create bar for new Set/Text lines. Posts one delta per action; the
  dialog is a pure view of the server state (ADR 0006).
- _Layout.cshtml / fis_main.js: cache-bust the dynamically imported
  per-module bundles (web/fis.<mdl>.<locale>.js/.css) with an asset
  version derived from the newest bundle write time, so a gulp rebuild
  is picked up without a stale cached module.

Docs: ADR 0016, live-draft-editing concept, INVOICE_SET_PRICING.md,
USER_GUIDE.md, ARCHITECTURE.md updated. Tests: InvoiceSetStructureTests
(new) + additions to InvoiceDraftServiceTests/InvoiceSetPricingTests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 08:55:05 +02:00
co-authored by Claude Sonnet 5
parent c1936a327e
commit ecc74cf3c6
31 changed files with 3003 additions and 147 deletions
+37 -15
View File
@@ -86,7 +86,7 @@ $inv.iMn = (ix) => {
yet been explicitly switched to a set-display mode (admin.setmode empty/undefined) — once set,
changing it again is not supported (the user edits the line items directly instead); it
reappears only if 'Änderungen verwerfen' reverts to a DB state that was never switched. */
$inv.emOpt = (admin) => 'iss,p13b,ctp' + (((admin || {}).setmode || '') === '' ? ',setm' : '');
$inv.emOpt = (admin) => 'iss,p13b,ctp,setst' + (((admin || {}).setmode || '') === '' ? ',setm' : '');
$inv.eM = (r, re, opt) => {
let m = [];
if (booln(r, false) === true || booln(re, false) === true) {
@@ -104,6 +104,9 @@ $inv.eM = (r, re, opt) => {
if ((opt || '').split(',').includes('setm') === true) {
m.push({ lbl: $ict.setm, fnc: $inv.ssetmode });
}
if ((opt || '').split(',').includes('setst') === true) { /* user-defined set structure dialog (ADR 0016) */
m.push({ lbl: $ict.setst, fnc: () => $inv.sets.open() });
}
if ((opt || '').split(',').includes('iss') === true) { /* backend-authoritative draft (ADR 0006) */
m.push({ lbl: 'Änderungshistorie', fnc: () => $inv.d.history() });
m.push({ lbl: 'Änderungen verwerfen', fnc: () => $inv.d.discard() });
@@ -172,12 +175,13 @@ $inv.d = {
if (typeof curVer === 'number' && typeof state.version === 'number' && state.version < curVer) {
return;
}
tbl.data('dver', state.version).data('serverSums', state.sums);
tbl.data('dver', state.version).data('serverSums', state.sums).data('dstate', state);
$inv.d.footer(tbl, state.sums || {}, state.admin || {});
$inv.d.notes(state.notes || []);
$inv.d.validation(state.validation || []);
$inv.d.applyItems(tbl, state.req || []);
$inv.d.applySetDisplay(tbl, state.setDisplay || {});
if ($inv.sets && $inv.sets.isOpen()) { $inv.sets.render(state); } /* the set-structure dialog is a view of the same state (ADR 0016) */
/* Keep the menu's "Set-Preisanzeige" entry in sync with the authoritative admin.setmode:
hides it right after a mode switch, and restores it if a discard reverted the draft to
a DB state that was never switched (ADR 0006 backend-authoritative refresh). */
@@ -223,13 +227,15 @@ $inv.d = {
let changed = isNew || (dta.Type || '') !== (co.typ || '')
|| dta.net !== val(co.v) || dta.net_val !== val(co.vt) || dta.vat_val !== val(co.vv)
|| dta.svcnet_val !== val(co.vs) || dta.svcvat_val !== val(co.vsv)
|| (dta.position || '') !== (co.p != null ? co.p : '');
|| (dta.position || '') !== (co.p != null ? co.p : '')
|| (dta.lvl || 0) !== (co.lvl || 0) || (dta.SetItmId || null) !== (co.SetItmId != null ? co.SetItmId : null);
$.extend(dta, {
Id: id, Type: co.typ,
net: val(co.v), net_val: val(co.vt), vat_val: val(co.vv),
svcnet_val: val(co.vs), svcvat_val: val(co.vsv),
vat: co.vat, position: (co.p != null ? co.p : ''),
SetItmId: (co.SetItmId != null ? co.SetItmId : null)
SetItmId: (co.SetItmId != null ? co.SetItmId : null),
lvl: (co.lvl || 0) /* nesting depth (ADR 0016) — rendered as an indent */
});
if (isNew) { $.extend(dta, { htmltext: co.t, quantity: co.q }); } // text only for server-created rows (e.g. the set row)
rw.data(dta);
@@ -311,7 +317,7 @@ $inv.d = {
/* Map an inline recipient field to its delta target and send it. No token-guard here for the
same reason as syncChanged: sync() waits for an in-flight seed instead of dropping the edit. */
syncField: function (nme, val) {
let map = { invoicetitle: 'title', invoiceaddress: 'address', invoiceemail: 'email', loc: 'provisionlocation', provisionlocation: 'provisionlocation', provisionperiod: 'provisionperiod' };
let map = { invoicetitle: 'title', invoiceaddress: 'address', invoiceemail: 'email', invoiceemailcc: 'emailcc', loc: 'provisionlocation', provisionlocation: 'provisionlocation', provisionperiod: 'provisionperiod' };
let target = map[nme]; if (!target) { return; }
$inv.d.sync({ Target: target, Value: val });
},
@@ -898,6 +904,7 @@ $inv.cntInv = function (data) { //invoice continuation
fm('adrfrm', inv.SendToAddress, 'invoiceaddress', 0, null);
fm('locfrm', inv.ProvisionLocation, 'loc', 1, null);
fm('emailfrm', inv.SendToEmail, 'invoiceemail', 0, null);
fm('emailccfrm', inv.SendToEmailCc, 'invoiceemailcc', 0, null);
$$.dc('sndfrm', frm).append($$.dc('content').text(response.admin.sender));
fm('admfrm', inv.ProvisionPeriod, 'provisionperiod', ((inv.ProvisionPeriod || '').includes('-') === true ? $rct.provP : $rct.provD), 1, null);
@@ -960,6 +967,7 @@ $inv.cSt = function (data) {
fm('adrfrm', inv.SendToAddress, 'invoiceaddress', 0, null);
fm('locfrm', inv.ProvisionLocation, 'loc', 1, null);
fm('emailfrm', inv.SendToEmail, 'invoiceemail', 0, null);
fm('emailccfrm', inv.SendToEmailCc, 'invoiceemailcc', 0, null);
$$.dc('sndfrm', frm).append($$.dc('content').text(response.admin.sender));
fm('admfrm', inv.ProvisionPeriod, 'provisionperiod', ((inv.ProvisionPeriod || '').includes('-') === true ? $rct.provP : $rct.provD), 1, null);
@@ -1101,7 +1109,7 @@ $inv.eHtml = function (ev) {
Leistungsdatum). The backend sanitises HTML too (single source of truth, ADR 0006), but
keeping these plain here avoids the UI briefly holding the wrapped value. Multi-line fields
(invoiceaddress, loc) stay HTML-capable and are normalised to newlines server-side. */
let isPlainText = ['invoiceemail', 'provisionperiod', 'invoicetitle'].includes(ev.data.nme);
let isPlainText = ['invoiceemail', 'invoiceemailcc', 'provisionperiod', 'invoicetitle'].includes(ev.data.nme);
let flds = isPlainText
? [{ name: 'txt', label: 'Text', type: 'text', value: frmct.text() }]
: [{ name: 'txt', label: 'Text', type: 'html', value: frmct.html(), tinymce: true, attr: { style: 'height: 300px' } }];
@@ -1234,7 +1242,7 @@ $inv.rrw = function () {
} else if (rw.is('.itm.osum') === true) {
co = { invrqid: dta.InvRqId, id: 'osum' + rw.index(), typ: 'osum', p: '', q: null, t: oHtml(dta.tbl.tbl), tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: false };
} else {
co = { invrqid: dta.InvRqId, id: dta.Id || '', typ: dta.Type || 'other', p: '', q: null, t: '', tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: (dta.Note || '') !== '' && hn === false, SetItmId: dta.SetItmId || null };
co = { invrqid: dta.InvRqId, id: dta.Id || '', typ: dta.Type || 'other', p: '', q: null, t: '', tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: (dta.Note || '') !== '' && hn === false, SetItmId: dta.SetItmId || null, lvl: (dta.lvl || 0) };
$$.dc('ibtn ico move', axf, { title: $rct.mR }); /* should be the last added */
co.p = dta.position || (dta.SortOrder || '');
@@ -1274,6 +1282,7 @@ $inv.rrw = function () {
}
rw.empty().attr('class', ph ? 'placeholder' : 'itm').aC(co.Typ)
.tC('sethdr', (co.typ || '').toString().toLowerCase() === 'set') /* emphasise set rows (fn1 header + block set row) */
.aC((co.lvl || 0) > 0 ? 'lvl' + Math.min(co.lvl, 6) : '') /* indent nested set members (ADR 0016) */
.tC('hidenote', hn).append(tda);
dta.co = co;
};
@@ -1304,8 +1313,23 @@ $inv.invSumUpdate = function () {
let tbl = $(this);
let ba = [];
let bds = tbl.children('tbody');
/* Hierarchical position numbering (ADR 0017), spanning the whole invoice: a running counter
per nesting depth, ported from InvoiceLineNumbering.Compute. A set header gets a number at
its own level ("3"); its members get subsidiary numbers one level deeper ("3.1", "3.2"). A
row's numbering never depends on the MFR SortOrder value — including for a Text/Title
header row synced with SortOrder -1, which used to leak "-1" straight into the Pos. column
here because it never went through this loop at all. */
let posCounters = [];
let nextPositionLabel = (numbered, lvl) => {
if (!numbered) { return ''; }
let l = Math.max(lvl || 0, 0);
while (posCounters.length <= l) { posCounters.push(0); }
posCounters.length = l + 1;
posCounters[l]++;
return posCounters.join('.');
};
bds.each((bi, bdy) => {
let b = $(bdy), rx = b.data() || {}, i = [], citems = [], itm = b.find('tr.itm'), iso = 0, ipos = 0;
let b = $(bdy), rx = b.data() || {}, i = [], citems = [], itm = b.find('tr.itm');
b.tC('empty', itm.length < 1);
itm.each((ti, tx) => {
let rrx = $(tx).data() || {};
@@ -1318,14 +1342,12 @@ $inv.invSumUpdate = function () {
by the id !== '' && !== citem.id check below). */
let citem = $inv.itemToContract(rrx);
let sid = (rrx.SetItmId || '').toString();
if (citem.type !== 'set' && sid !== '' && sid !== citem.id) { citem.setId = sid; }
/* a nested set (ADR 0016) is itself a member of its parent set, so the type is not excluded here */
if (sid !== '' && sid !== citem.id) { citem.setId = sid; }
citems.push(citem);
if (((typeof rrx.SortOrder === 'undefined' || rrx.SortOrder === null) ? -1 : rrx.SortOrder) > -1) {
if (['text', 'title'].includes((rrx.Type || 'other').toLowerCase()) === false) { ipos++; }
rrx.SortOrder = iso;
rrx.position = ipos;
$inv.rrw.call(tx);
}
let numbered = ['text', 'title'].includes((rrx.Type || 'other').toLowerCase()) === false;
rrx.position = nextPositionLabel(numbered, (rrx.co || {}).lvl || 0);
$inv.rrw.call(tx);
});
ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, items: citems });
});