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:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user