Invoice set pricing: wire editor + align editor/backend contract
Front-end (fis.inv_shared.js / fis.inv_txt_de.js, rebuilt bundles): - 3-way set display switch (setprice/itemprices/setonly) via admin.setmode, emitted into InvoiceOptions by FdsInvoiceData.BuildInvoiceOptions. - Each request block now posts items[] in the backend contract shape (title/desc/qty/price_net/total_net + set type/setId tags) via itemToContract. - invcPayload normalises the editor model to the field names BuildInvoiceParams reads (sms totals -> new.total_net/total_gross, invoicetitle->title, loc->provisionlocation, admin.paymentterms->new.paymentterm, CustomerId->customerid). Back-end: - BuildInvoiceOptions adds the setmode token alongside §13b. - VAT rate+amount now taken from sms.vat (HighestVat) instead of the broken items 'is List<object>' detection that pinned the rate to 19. - InvoiceSetPricing blanks price/total cells for text/title heading lines. Tests: set-pricing text-line blanking, HighestVat selection/parsing, updated the VAT param test to the sms.vat contract. 180 passing. Note: the second VAT slot (InvoiceVAT_2) stays unused by design; mixed-rate invoices store only the highest rate (pre-existing, accepted). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -146,6 +146,12 @@ let $ict = {
|
||||
eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.',
|
||||
iss: 'Zwischenstand speichern.',
|
||||
p13b: 'USt -> §13b',
|
||||
setm: 'Set-Preisanzeige',
|
||||
setmo: {
|
||||
setprice: 'Set mit Preis – Positionen ohne Preis',
|
||||
itemprices: 'Positionen mit Preis – Set als Überschrift',
|
||||
setonly: 'Nur Set mit Preis – Positionen ausgeblendet'
|
||||
},
|
||||
ctp: 'Ansprechpartner festlegen',
|
||||
mfr: 'Von MFR neu abrufen',
|
||||
rq1: 'Auftragsdaten werden von MFR abgerufen.\nDer Vorgang kann bis zu 90Sek dauern.',
|
||||
@@ -618,6 +624,9 @@ $inv.eM = (r, re, opt) => {
|
||||
if ((opt || '').split(',').includes('p13b') === true) {
|
||||
m.push({ lbl: $ict.p13b, fnc: $inv.sp13b });
|
||||
}
|
||||
if ((opt || '').split(',').includes('setm') === true) {
|
||||
m.push({ lbl: $ict.setm, fnc: $inv.ssetmode });
|
||||
}
|
||||
if (booln(r, false) === true) {
|
||||
m.push({ lbl: $ict.rel, fnc: $inv.rReload });
|
||||
}
|
||||
@@ -780,7 +789,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, 'iss,p13b,ctp');
|
||||
$inv.eM(false, true, 'iss,p13b,setm,ctp');
|
||||
}, complete: () => {
|
||||
o.c.trigger('modal_close');
|
||||
}
|
||||
@@ -925,7 +934,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, 'iss,p13b,ctp');
|
||||
$inv.eM(false, true, 'iss,p13b,setm,ctp');
|
||||
}, complete: () => {
|
||||
o.c.trigger('modal_close');
|
||||
}
|
||||
@@ -1168,12 +1177,19 @@ $inv.invSumUpdate = function () {
|
||||
};
|
||||
let bds = tbl.children('tbody');
|
||||
bds.each((bi, bdy) => {
|
||||
let b = $(bdy), rx = b.data() || {}, i = [], bnet = 0, itm = b.find('tr.itm'), iso = 0, ipos = 0;
|
||||
let b = $(bdy), rx = b.data() || {}, i = [], citems = [], cset = null, bnet = 0, itm = b.find('tr.itm'), iso = 0, ipos = 0;
|
||||
b.tC('empty', itm.length < 1);
|
||||
itm.each((ti, tx) => {
|
||||
|
||||
|
||||
let rrx = $(tx).data() || {}; csms(rrx, sms, rx.Id); bnet += (rrx.net_val || 0); i.push(rrx.co);
|
||||
//console.debug('rrx %o', rrx);
|
||||
/* backend item contract (title/desc/qty/price_net/total_net + set flags), see InvoiceSetPricing.
|
||||
Set grouping: an item of Type 'set' is a header that claims the following items in this
|
||||
block as its members until the next set header (mfr__items has no explicit member link). */
|
||||
let citem = $inv.itemToContract(rrx);
|
||||
if (citem.type === 'set' && citem.id !== '') { cset = citem.id; }
|
||||
else if (cset !== null && (citem.id || '') !== '') { citem.setId = cset; }
|
||||
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;
|
||||
@@ -1185,7 +1201,7 @@ $inv.invSumUpdate = function () {
|
||||
// f: b.find('tr.isum > td.isumval'), t: fnum(bnet, $rct.cst), n: bnet
|
||||
//});
|
||||
b.find('tr.isum > td.isumval').text(fnum(bnet, $rct.cst));
|
||||
ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, netval: bnet });
|
||||
ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, items: citems, netval: bnet });
|
||||
});
|
||||
let nonempty = tbl.find('tbody:not(.empty)').length;
|
||||
bds.find('tr.isum').tC('hidden', nonempty < 2);
|
||||
@@ -1360,6 +1376,53 @@ $inv.sp13b = () => {
|
||||
}
|
||||
tbl.trigger('fds.inv');
|
||||
};
|
||||
/* Maps an item row's data to the backend item contract consumed by InvoiceSetPricing
|
||||
/ FuchsPdf.ApplyInvoice: { id, type, title (plain), desc (html), qty, price_net,
|
||||
total_net, vat }. Set membership (type:'set' header + setId on members) is added by
|
||||
the caller. The invoice total comes from the registration balance, so per-item
|
||||
totals here are purely presentational. */
|
||||
$inv.itemToContract = function (rrx) {
|
||||
rrx = rrx || {};
|
||||
let oHtml = (e) => $$.d().append(e).html();
|
||||
let type = (rrx.Type || '').toString().toLowerCase();
|
||||
let ci = { id: (rrx.Id || '').toString(), type: type, title: '', desc: '', qty: '', price_net: '', total_net: (rrx.net_val || 0), vat: rrx.vat || '' };
|
||||
if (rrx.co && rrx.co.typ === 'osum') {
|
||||
/* combined single-sum line — the on-screen "title" is an HTML sub-table; render it as desc */
|
||||
ci.desc = rrx.co.t || '';
|
||||
ci.total_net = (rrx.net_val || 0);
|
||||
} else if (['text', 'title'].includes(type) && (rrx.net_val || 0) === 0) {
|
||||
/* heading / free-text line, no price */
|
||||
ci.desc = rrx.htmltext || ((((rrx.NameOrNumber || '').substr(0, 1) !== '#') ? oHtml($$[0]('p').text(rrx.NameOrNumber || '')) : '') + (rrx.Note || ''));
|
||||
ci.total_net = '';
|
||||
} else {
|
||||
/* normal priced item (incl. set headers, which carry their own set price or 0) */
|
||||
ci.title = rrx.NameOrNumber || '';
|
||||
ci.desc = rrx.Note || '';
|
||||
ci.qty = rrx.quantity || ((rrx.quantityhours || 0) !== 0 ? (fnum(rrx.quantityhours) + (rrx.UnitString ? ' ' + rrx.UnitString : '')) : '');
|
||||
ci.price_net = (rrx.net || 0);
|
||||
ci.total_net = (rrx.net_val || 0);
|
||||
}
|
||||
return ci;
|
||||
};
|
||||
/* 3-way set-pricing display switch. Mirrors §13b: writes the choice onto admin.setmode,
|
||||
which BuildInvoiceParams turns into the "setmode:<mode>" InvoiceOptions token the PDF reads. */
|
||||
$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 fr = $$.dc('choicefrm').append([btn('setprice'), btn('itemprices'), btn('setonly')]);
|
||||
o = $ocms.dlg(fr, { width: 800 });
|
||||
};
|
||||
$inv.setSetmode = (mode) => {
|
||||
let l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data();
|
||||
d.admin = d.admin || {};
|
||||
d.admin.setmode = mode; /* posted in admin -> BuildInvoiceParams writes setmode: into InvoiceOptions */
|
||||
d.inv = d.inv || {}; /* keep a local InvoiceOptions reflection in sync (cosmetic) */
|
||||
let opts = (d.inv.InvoiceOptions || '').split(',').filter(x => x !== '' && x.indexOf('setmode:') !== 0);
|
||||
if (mode && mode !== 'setprice') { opts.push('setmode:' + mode); }
|
||||
d.inv.InvoiceOptions = opts.join(',');
|
||||
};
|
||||
$inv.sctp = () => {
|
||||
let flds = $invcol.ctp;
|
||||
$ocms.dlgform(flds, {
|
||||
@@ -1379,12 +1442,32 @@ $inv.sctp = () => {
|
||||
}, typedvalues: true
|
||||
});
|
||||
};
|
||||
/* Normalises the editor's working model into the exact field names the C# backend
|
||||
(FdsInvoiceData.BuildInvoiceParams) reads, then returns the `invc` payload:
|
||||
- balances/service sums come from `sms` (ttn/ttb), exposed on `new` as total_net/total_gross;
|
||||
- every VAT rate's net amount is exposed as new.vat_<rate>_net (the backend reads the highest);
|
||||
- new.invoicetitle -> new.title, new.loc -> new.provisionlocation, admin.paymentterms ->
|
||||
new.paymentterm, admin.CustomerId -> admin.customerid.
|
||||
Originals are kept alongside; the source objects are not mutated. */
|
||||
$inv.invcPayload = function (d) {
|
||||
d = d || {};
|
||||
let sms = d.sms || {}, nw = $.extend({}, d.new), adm = $.extend({}, d.admin);
|
||||
nw.total_net = sms.ttn || 0;
|
||||
nw.total_gross = sms.ttb || 0;
|
||||
/* VAT (rate + amount) is taken by the backend straight from the posted sms.vat map
|
||||
(FdsInvoiceData.HighestVat), so no per-rate new.vat_* keys are needed here. */
|
||||
nw.title = (nw.invoicetitle != null ? nw.invoicetitle : (nw.title || ''));
|
||||
nw.provisionlocation = (nw.loc != null ? nw.loc : (nw.provisionlocation || ''));
|
||||
nw.paymentterm = (adm.paymentterms != null ? adm.paymentterms : (nw.paymentterm || ''));
|
||||
adm.customerid = (adm.customerid != null ? adm.customerid : adm.CustomerId);
|
||||
return { admin: adm, req: d.bai, sms: d.sms, new: nw };
|
||||
};
|
||||
$inv.ssave = () => {
|
||||
var l = $('div.invoice_layout'), d = l.find('table.invi').data();
|
||||
$inv.t_fds_inv();
|
||||
l.aC('freeze');
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('req/save'), data: { invc: JSON.stringify({ admin: d.admin, req: d.bai, sms: d.sms, new: d.new }), id: d.invid || '' }, success: (response) => {
|
||||
url: $ocms.url('req/save'), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid || '' }, success: (response) => {
|
||||
$inv.cntInv({ id: response.id });
|
||||
}, error: () => {
|
||||
alert($ict.eis);
|
||||
@@ -1406,7 +1489,7 @@ $inv.sprev = (change) => {
|
||||
}
|
||||
}
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('req/' + (change === true ?'sedit':'sprep')), data: { invc: JSON.stringify({ admin: d.admin, req: d.bai, sms: d.sms, new: d.new }), id: d.invid ||'' }, success: (response) => {
|
||||
url: $ocms.url('req/' + (change === true ?'sedit':'sprep')), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid ||'' }, success: (response) => {
|
||||
l.rC('freeze');
|
||||
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id, invtp = response.total;
|
||||
if (invtp > 10) {
|
||||
|
||||
Reference in New Issue
Block a user