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:
2026-09-10 22:19:01 +02:00
co-authored by Claude Fable 5.1
parent 22d2ef9c1a
commit 5fa7a2b50f
16 changed files with 678 additions and 447 deletions
+45 -36
View File
@@ -82,11 +82,11 @@ $inv.iMn = (ix) => {
}
return $('#topbar').ocmsmenu(m);
}
/* Menu option CSV builder: 'setm' (Set-Preisanzeige) is only offered while the invoice has not
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,setst' + (((admin || {}).setmode || '') === '' ? ',setm' : '');
/* Menu option CSV builder. 'setm' (Set-Preisanzeige) is always offered: since ADR 0019 the two
modes convert every still-unconverted SET (not the whole invoice), so running them again after
the user has added or re-assigned a set converts just that one — and is a plain no-op when there
is nothing left to convert. The legacy admin.setmode flag no longer gates the entry. */
$inv.emOpt = () => 'iss,p13b,ctp,setst,setm';
$inv.eM = (r, re, opt) => {
let m = [];
if (booln(r, false) === true || booln(re, false) === true) {
@@ -182,10 +182,7 @@ $inv.d = {
$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). */
$inv.eM(false, true, $inv.emOpt(state.admin || {}));
$inv.eM(false, true, $inv.emOpt());
},
/* Renders the backend-authoritative notice paragraphs (ADR: notice texts are
backend-authoritative) exactly as FuchsPdf.BuildInvoiceNotes emits them for the PDF,
@@ -202,10 +199,10 @@ $inv.d = {
browser must not keep its own numbering). Only the position cell is touched — no re-render. */
/* Reconciles each block's rendered rows against the authoritative server line list
(`req[].itm`, the co shape). The server may not only change a line's values but also
ADD lines (the block set row inserted by "Set mit Preis"/block.setprice) and REMOVE lines
("Nur Set mit Preis"/block.setonly), and reorder them — the browser never computes any of
that (ADR 0006/0008/0009), it just mirrors the server. For each server line: patch/insert
the matching row, preserving null price fields verbatim (ADR 0009: null = empty cell, kept
ADD lines (e.g. a set/text line created in the structure dialog) and REMOVE lines (a set's
members under "Nur Set mit Preis"/block.setonly), and reorder them — the browser never
computes any of that (ADR 0006/0008/0019), it just mirrors the server. For each server line:
patch/insert the matching row, preserving null price fields verbatim (null = empty cell, kept
distinct from 0). Rows the server no longer lists are removed; order follows the server.
Position numbers, net/VAT and the set-row emphasis all come from rrw off the mirrored data. */
applyItems: function (tbl, req) {
@@ -768,7 +765,7 @@ $inv.ccInv = function (ev) { //normale rechnung
rif.tbl.children('tbody').each($inv.bdysort);
rif.tbl.trigger('fds.inv'); /* trigger calculations */
$inv.eM(false, true, $inv.emOpt(admin));
$inv.eM(false, true, $inv.emOpt());
}, complete: () => {
o.c.trigger('modal_close');
}
@@ -913,7 +910,7 @@ $inv.cntInv = function (data) { //invoice continuation
rif.tbl.children('tbody').each($inv.bdysort);
rif.tbl.trigger('fds.inv'); /* trigger calculations */
$inv.eM(false, true, $inv.emOpt(response.admin));
$inv.eM(false, true, $inv.emOpt());
}, complete: () => {
o.c.trigger('modal_close');
}
@@ -1311,7 +1308,7 @@ $inv.rrw = function () {
co.t = dta.htmltext || (((dta.NameOrNumber || '').substr(0, 1) !== '#' ? oHtml($$[0]('p').text(dta.NameOrNumber)) : '') + (dta.Note || ''));
} else {
co.tt = co.det ? '' : $$.s(dta.Note || '').text();
co.q = dta.quantity || ((dta.quantityhours || 0) !== 0 ? (fnum(dta.quantityhours) + ' ' + (dta.UnitString || '')) : ''); /* guard: no quantityhours -> blank, not fnum(undefined)="NaN" (e.g. the block set row) */
co.q = dta.quantity || ((dta.quantityhours || 0) !== 0 ? (fnum(dta.quantityhours) + ' ' + (dta.UnitString || '')) : ''); /* guard: no quantityhours -> blank, not fnum(undefined)="NaN" (e.g. a set line) */
co.t = dta.htmltext ||(co.det ? (oHtml($$.s(dta.NameOrNumber || '')) + oHtml($$.dc('desc').html(dta.Note))) : oHtml($$.s(dta.NameOrNumber || '')));
co.v = dta.net
co.vt = dta.net_val
@@ -1330,8 +1327,8 @@ $inv.rrw = function () {
tda.push($$.td(rw, { colspan: 4 }).append(co.t));
} else {
Array.prototype.push.apply(tda, co.q ? [$$.tdc('keep').text(co.q)] : []);
/* ADR 0009: a null/undefined price renders an EMPTY cell (block-mode "Set mit Preis"
members, and the set row's own unit-price), distinct from a real 0,00 €. */
/* ADR 0009/0019: a null/undefined price renders an EMPTY cell (the members of a set
converted to a set price, and a set line's own unit price), distinct from a real 0,00 €. */
let cur = (x) => (x == null ? '' : fnum(x, $rct.cst));
Array.prototype.push.apply(tda, [
$$.tdc('txt', { colspan: !co.q ? 2 : 1, title: co.tt }).append(co.t),
@@ -1340,7 +1337,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) */
.tC('sethdr', (co.typ || '').toString().toLowerCase() === 'set') /* emphasise set rows */
.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;
@@ -1588,7 +1585,7 @@ $inv.itemToContract = function (rrx) {
let oHtml = (e) => $$.d().append(e).html();
let type = (rrx.Type || '').toString().toLowerCase();
/* ADR 0009: preserve null prices verbatim (null = empty cell, kept distinct from a real 0) so
block-mode nulled members and the set row round-trip unchanged when the block is re-posted. */
nulled set members and the set line round-trip unchanged when the block is re-posted. */
let nz = (x) => (x == null ? null : x);
let ci = { id: (rrx.Id || '').toString(), type: type, title: '', desc: '', qty: '', price_net: '', total_net: nz(rrx.net_val), vat: rrx.vat || '' };
if (rrx.co && rrx.co.typ === 'osum') {
@@ -1616,26 +1613,38 @@ $inv.itemToContract = function (rrx) {
}
return ci;
};
/* 2-way set-pricing display switch (SetPrice / SetOnly). Mirrors §13b: writes the choice onto
admin.setmode, which BuildInvoiceParams turns into the "setmode:<mode>" InvoiceOptions token
the PDF reads. ItemPrices ("Positionen mit Preis – Set als Überschrift") was removed: it was
always just the implicit pre-conversion default and is not a state the user can switch back to
once a set has been converted via the "Auf Setpreis umstellen" item switch (see $inv.toSetPrice /
InvoiceDraftEditService.ApplyItemSetPrice) — that conversion is one-way. */
/* Whether the authoritative state carries at least one SET line that owns members — the two
set-price modes only ever touch sets (ADR 0019), so without one there is nothing to convert. */
$inv.hasSets = (state) => (((state || {}).req) || []).some((b) => {
let itms = ((b || {}).itm || []).filter((co) => co && (co.id || '') !== ''), byId = {};
itms.forEach((co) => { byId[(co.id || '').toString()] = co; });
let isSet = (co) => ((co.typ || '').toString().toLowerCase() === 'set');
return itms.some((co) => {
let p = (co.SetItmId == null ? '' : co.SetItmId).toString();
return p !== '' && p !== (co.id || '').toString() && byId[p] && isSet(byId[p]);
});
});
/* 2-way set-pricing switch (SetPrice / SetOnly), ADR 0019: both modes give EVERY set the sum of
its positions as one set price; "Set mit Preis" keeps those positions without a price, "Nur Set
mit Preis" removes them. Positions outside any set stay individually priced either way, and
several sets stay several separate lines. ItemPrices ("Positionen mit Preis – Set als
Überschrift") was removed: it is just the pre-conversion default and not a state one can switch
back to, since every conversion is one-way (see $inv.toSetPrice /
InvoiceDraftEditService.ApplyItemSetPrice). */
$inv.ssetmode = () => {
let l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data();
d.admin = d.admin || {};
let cur = (d.admin.setmode || 'setprice'), o;
let btn = (mode) => $$.dc('btn', $ict.setmo[mode]).tC('selected', cur === mode).click(() => { o.c.trigger('modal_close'); $inv.setSetmode(mode); });
let l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data(), o;
if (d.dstate && $inv.hasSets(d.dstate) === false) { alert($rct.toSetMn); return; } /* no state yet -> let the server decide (no-op) */
let btn = (mode) => $$.dc('btn', $ict.setmo[mode]).click(() => { o.c.trigger('modal_close'); $inv.setSetmode(mode); });
let fr = $$.dc('choicefrm').append([btn('setprice'), btn('setonly')]);
o = $ocms.dlg(fr, { width: 800 });
};
/* "Set mit Preis" / "Nur Set mit Preis" (ADR 0009): NOT a display toggle — an irreversible,
backend-authoritative mutation grouped by service-request block. The server inserts a dedicated
set row per block (block sum) and either nulls each member's price (setprice) or removes the
members (setonly); the normal draftReady refresh re-renders both via $inv.d.applyItems. The
browser only posts the chosen mode. Flush pending local edits first so a delayed block.replace
can't land after the conversion and overwrite it (same race guard as $inv.toSetPrice). */
/* "Set mit Preis" / "Nur Set mit Preis" (ADR 0019, superseding the per-block collapse of ADR 0009):
NOT a display toggle — an irreversible, backend-authoritative mutation grouped by SET. The server
sums each top-level set's positions onto the set line and either nulls those positions' prices
(setprice) or removes them (setonly); lines outside a set are untouched. The normal draftReady
refresh re-renders both via $inv.d.applyItems. The browser only posts the chosen mode. Flush
pending local edits first so a delayed block.replace can't land after the conversion and
overwrite it (same race guard as $inv.toSetPrice). */
$inv.setSetmode = (mode) => {
if (confirm($rct.toSetMc || $rct.toSetPc) === false) { return; }
let target = (mode === 'setonly') ? 'block.setonly' : 'block.setprice';