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';
|
||||
|
||||
@@ -39,7 +39,8 @@
|
||||
combP: 'Positionen zusammenfassen',
|
||||
toSetP: 'Auf Setpreis umstellen (Positionen ohne Preis)',
|
||||
toSetPc: 'Der Set-Preis wird auf die Summe seiner Positionen gesetzt und die Positionen selbst verlieren ihren Preis. Das ist nicht rückgängig zu machen (der Betrag kann danach nur noch manuell geändert werden). Fortfahren?',
|
||||
toSetMc: 'Jeder Leistungsauftrag wird zu einer Set-Position mit der Summe als Preis zusammengefasst. Das ist nicht rückgängig zu machen (die Set-Preise können danach nur noch manuell geändert werden). Fortfahren?',
|
||||
toSetMc: 'Jedes Set erhält die Summe seiner Positionen als Set-Preis; die Positionen des Sets werden ohne Preis dargestellt bzw. ausgeblendet. Positionen ausserhalb eines Sets bleiben unverändert einzeln aufgeführt. Das ist nicht rückgängig zu machen (die Set-Preise können danach nur noch manuell geändert werden). Fortfahren?',
|
||||
toSetMn: 'Diese Rechnung enthält kein Set. Legen Sie zuerst über "Set-Struktur bearbeiten" ein Set an bzw. ordnen Sie Positionen einem Set zu.',
|
||||
iSum: 'Zwischensumme',
|
||||
dtRel: 'Freigegeben am: ',
|
||||
dtCr: 'Erstellt am: ',
|
||||
|
||||
Reference in New Issue
Block a user