Add unit tests for Fuchs_DataService and related components
- Introduced comprehensive unit tests for the Fuchs_DataService library, covering DATEV header formatting, CSV/XML generation, and FdsMfrClient construction. - Implemented tests for FdsMfr.UpdateNeed parsing and FdsShared utility helpers, ensuring correct functionality and stability. - Added tests for FdsConfig and FdsMfrClient to validate configuration resolution and client construction. Document decisions on backend-authoritative invoice and reminder handling - Created ADR 0008 to clarify that all invoice types and reminder stages are backend-authoritative during drafting and previewing. - Established that all calculations and settings must be processed server-side, ensuring consistency between online editor and PDF outputs. Define irreversible mutations for set-price modes in invoices - Documented ADR 0009 to specify that the "Set mit Preis" and "Nur Set mit Preis" operations are irreversible mutations affecting service request blocks. - Clarified that these operations are not display toggles but actual data changes, ensuring clear expectations for invoice handling. Transition MFR ERP sync to in-process execution within the web app - Created ADR 0010 to outline the migration of Fuchs_DataService from a standalone service to an in-process library within the Fuchs web application. - Updated configuration and logging management to be handled by the host application, streamlining the sync process. Add publish profile and periodic hosted service for job scheduling - Introduced a publish profile for deployment to a specified folder. - Implemented PeriodicHostedService to manage multiple independent jobs, including the MFR ERP sync, with configurable execution intervals. Add dotnet-tools.json for EF Core CLI tools - Included dotnet-tools.json to manage the version of dotnet-ef for Entity Framework Core migrations and commands.
This commit is contained in:
@@ -130,10 +130,13 @@ $inv.d = {
|
||||
return h;
|
||||
},
|
||||
/* Seed the authoritative server session from the assembled editor payload. Called
|
||||
once from invSumUpdate on open; thereafter the backend is the source of truth. */
|
||||
once from invSumUpdate on open; thereafter the backend is the source of truth.
|
||||
The in-flight ajax promise is stashed on 'dseedpromise' so a delta sent (e.g. via
|
||||
a context-menu action) before seeding has actually completed can wait for the token
|
||||
instead of silently no-op'ing (see $inv.d.sync). */
|
||||
seed: function (payload) {
|
||||
let l = $inv.d.layout(); l.aC('freeze');
|
||||
$ocms.postXT({
|
||||
let p = $ocms.postXT({
|
||||
url: $ocms.url('inv/dopen'), data: { payload: JSON.stringify(payload) }, success: (r) => {
|
||||
$inv.d.tbl().data('dtoken', r.token).data('dver', r.version).data('dhashes', $inv.d.hashes()).data('dorder', $inv.d.order());
|
||||
$fis.draft.bind(r.token, {
|
||||
@@ -142,8 +145,10 @@ $inv.d = {
|
||||
onClosed: (reason) => $inv.d.closed(reason)
|
||||
});
|
||||
$inv.d.refresh();
|
||||
}, error: () => { l.rC('freeze'); }, complete: () => { $inv.d.tbl().removeData('dseeding'); }
|
||||
}, error: () => { l.rC('freeze'); }, complete: () => { $inv.d.tbl().removeData('dseeding').removeData('dseedpromise'); }
|
||||
});
|
||||
$inv.d.tbl().data('dseedpromise', p);
|
||||
return p;
|
||||
},
|
||||
/* Re-fetch the authoritative state and render the totals footer + validation from it. */
|
||||
refresh: function (cb) {
|
||||
@@ -157,11 +162,21 @@ $inv.d = {
|
||||
},
|
||||
applyState: function (state) {
|
||||
let tbl = $inv.d.tbl(); if (tbl.length < 1) { return; }
|
||||
/* Concurrent refreshes (one per flushed block plus one for a follow-up delta such as
|
||||
item.setprice, and SignalR draftReady on top) can have their dstate responses arrive
|
||||
out of order. Applying a response older than the version already rendered would
|
||||
silently roll back a just-applied server-side mutation (e.g. a set-price conversion
|
||||
reverting to its pre-conversion 0/individually-priced state) — so stale responses are
|
||||
dropped here instead of rendered. */
|
||||
let curVer = tbl.data('dver');
|
||||
if (typeof curVer === 'number' && typeof state.version === 'number' && state.version < curVer) {
|
||||
return;
|
||||
}
|
||||
tbl.data('dver', state.version).data('serverSums', state.sums);
|
||||
$inv.d.footer(tbl, state.sums || {}, state.admin || {});
|
||||
$inv.d.notes(state.notes || []);
|
||||
$inv.d.validation(state.validation || []);
|
||||
$inv.d.applyPositions(tbl, state.req || []);
|
||||
$inv.d.applyItems(tbl, state.req || []);
|
||||
$inv.d.applySetDisplay(tbl, state.setDisplay || {});
|
||||
/* 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
|
||||
@@ -181,19 +196,60 @@ $inv.d = {
|
||||
/* Push the server's authoritative position numbers back onto the rendered rows so the online
|
||||
editor and the PDF preview always agree (the server numbers priced lines continuously; the
|
||||
browser must not keep its own numbering). Only the position cell is touched — no re-render. */
|
||||
applyPositions: function (tbl, req) {
|
||||
(req || []).forEach((b) => (b && b.itm || []).forEach((co) => {
|
||||
if (!co || (co.id || '') === '') { return; }
|
||||
let cell = tbl.find('#itm' + co.id + ' td.keep').first();
|
||||
if (cell.length) { cell.text(co.p != null ? co.p : ''); }
|
||||
}));
|
||||
/* 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
|
||||
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) {
|
||||
let val = (x) => (x === undefined ? null : x); // JSON null stays null; absent -> null
|
||||
(req || []).forEach((b) => {
|
||||
if (!b) { return; }
|
||||
let bid = (b.Id || '').toString(); if (bid === '') { return; }
|
||||
let bdy = tbl.children('tbody').filter((i, e) => (($(e).data() || {}).Id || '').toString() === bid).first();
|
||||
if (bdy.length < 1) { return; }
|
||||
let itms = b.itm || [], serverIds = {};
|
||||
let title = bdy.find('tr.title').first();
|
||||
let prev = title.length ? title : null;
|
||||
itms.forEach((co) => {
|
||||
let id = (co.id || '').toString(); if (id === '') { return; }
|
||||
serverIds[id] = true;
|
||||
let rw = bdy.find('#itm' + id).first(), isNew = rw.length < 1;
|
||||
if (isNew) { rw = $$.tr(bdy, { id: 'itm' + id, class: 'itm' }); }
|
||||
let dta = rw.data() || {};
|
||||
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 : '');
|
||||
$.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)
|
||||
});
|
||||
if (isNew) { $.extend(dta, { htmltext: co.t, quantity: co.q }); } // text only for server-created rows (e.g. the set row)
|
||||
rw.data(dta);
|
||||
if (prev) { if (rw.prev()[0] !== prev[0]) { rw.insertAfter(prev); } } // keep server order, but don't churn already-ordered rows
|
||||
else if (bdy.children().first()[0] !== rw[0]) { bdy.prepend(rw); }
|
||||
prev = rw;
|
||||
if (changed) { $inv.rrw.call(rw); }
|
||||
});
|
||||
bdy.find('tr.itm').each((i, tr) => {
|
||||
let id = (($(tr).data() || {}).Id || '').toString();
|
||||
if (id !== '' && !serverIds[id]) { $(tr).remove(); } // dropped server-side (block.setonly)
|
||||
});
|
||||
});
|
||||
},
|
||||
/* Applies the backend-authoritative set-pricing display flags (see BuildSetDisplay /
|
||||
InvoiceSetPricing) onto the rendered rows: blanks the price/total cells for lines the
|
||||
server says should show no price (set members in SetPrice mode, the set header in
|
||||
ItemPrices mode), and emphasises the set header line — so the online editor always
|
||||
shows exactly what the PDF will print, without duplicating the pricing rules client-side.
|
||||
Rows not present in the map (no set in that block, or standalone items) are left untouched. */
|
||||
server says should show no price (set members in SetPrice/SetOnly mode), and emphasises
|
||||
the set header line — so the online editor always shows exactly what the PDF will print,
|
||||
without duplicating the pricing rules client-side. Rows not present in the map (no set in
|
||||
that block, or standalone items) are left untouched. */
|
||||
applySetDisplay: function (tbl, setDisplay) {
|
||||
tbl.find('tr.itm').each((i, tr) => {
|
||||
let rw = $(tr), dta = rw.data() || {}, id = (dta.Id || '').toString();
|
||||
@@ -205,11 +261,23 @@ $inv.d = {
|
||||
}
|
||||
});
|
||||
},
|
||||
/* Send one change to the server; the draftReady signal and this success both refresh. */
|
||||
/* Send one change to the server; the draftReady signal and this success both refresh.
|
||||
Returns the underlying ajax promise so callers that must not race a follow-up delta
|
||||
against this one (e.g. $inv.toSetPrice after a flush) can chain on it.
|
||||
If the initial seeding (inv/dopen) is still in flight (no token yet, but a
|
||||
'dseedpromise' is pending), the delta previously vanished silently — the user saw no
|
||||
network activity at all when clicking a context-menu action right after opening the
|
||||
editor. Now the delta waits for the seed to finish and retries against the freshly
|
||||
issued token instead of being dropped. */
|
||||
sync: function (delta) {
|
||||
let t = $inv.d.token(); if (t === '') { return; }
|
||||
let t = $inv.d.token();
|
||||
if (t === '') {
|
||||
let seeding = $inv.d.tbl().data('dseedpromise');
|
||||
if (seeding) { return $.when(seeding).then(() => $inv.d.sync(delta)); }
|
||||
return $.when();
|
||||
}
|
||||
$inv.d.layout().aC('freeze');
|
||||
$ocms.postXT({
|
||||
return $ocms.postXT({
|
||||
url: $ocms.url('inv/dpatch'), data: { token: t, delta: JSON.stringify(delta) },
|
||||
success: () => { $inv.d.refresh(); },
|
||||
error: (xhr) => { $inv.d.layout().rC('freeze'); if (xhr && xhr.status === 410) { $inv.d.closed('expired'); } }
|
||||
@@ -220,22 +288,29 @@ $inv.d = {
|
||||
/* Diff the freshly-rebuilt blocks against the last-synced state and send only the
|
||||
changed/removed blocks as granular block.replace / block.remove deltas. A pure section
|
||||
reorder (same blocks, new sequence) changes no block hash, so it is sent separately as a
|
||||
block.order delta; the server reorders the cache, renumbers positions and pushes them back. */
|
||||
block.order delta; the server reorders the cache, renumbers positions and pushes them back.
|
||||
No early return on an empty token here: sync() itself waits for an in-flight seed
|
||||
(dseedpromise) instead of silently dropping the delta, so edits made in the brief window
|
||||
before seeding completes are not lost. */
|
||||
syncChanged: function (tbl) {
|
||||
if (($inv.d.token()) === '') { return; }
|
||||
let bai = tbl.data('bai') || [], prev = tbl.data('dhashes') || {}, next = {}, changed = [], removed = [];
|
||||
let bai = tbl.data('bai') || [], prev = tbl.data('dhashes') || {}, next = {}, changed = [], removed = [], pending = [];
|
||||
$.each(bai, (i, b) => { let id = (b.Id || '').toString(), h = JSON.stringify(b); next[id] = h; if (prev[id] !== h) { changed.push(b); } });
|
||||
$.each(prev, (id) => { if (next[id] === undefined) { removed.push(id); } });
|
||||
let order = $inv.d.order(), prevOrder = tbl.data('dorder') || [];
|
||||
tbl.data('dhashes', next).data('dorder', order);
|
||||
changed.forEach((b) => $inv.d.sync({ Target: 'block.replace', Ref: (b.Id || '').toString(), Value: b }));
|
||||
removed.forEach((id) => $inv.d.sync({ Target: 'block.remove', Ref: id }));
|
||||
changed.forEach((b) => pending.push($inv.d.sync({ Target: 'block.replace', Ref: (b.Id || '').toString(), Value: b })));
|
||||
removed.forEach((id) => pending.push($inv.d.sync({ Target: 'block.remove', Ref: id })));
|
||||
let sameSet = prevOrder.length === order.length && prevOrder.slice().sort().join(',') === order.slice().sort().join(',');
|
||||
if (sameSet && prevOrder.join(',') !== order.join(',')) { $inv.d.sync({ Target: 'block.order', Value: order }); }
|
||||
if (sameSet && prevOrder.join(',') !== order.join(',')) { pending.push($inv.d.sync({ Target: 'block.order', Value: order })); }
|
||||
/* $.when(...pending) so a caller (e.g. toSetPrice) can wait until every flushed block
|
||||
has actually reached the server before sending a further delta that depends on it —
|
||||
otherwise a delayed block.replace carrying pre-conversion values can land after and
|
||||
silently overwrite a just-applied server-side mutation (e.g. item.setprice). */
|
||||
return $.when.apply($, pending).promise();
|
||||
},
|
||||
/* Map an inline recipient field to its delta target and send it. */
|
||||
/* 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) {
|
||||
if ($inv.d.token() === '') { return; }
|
||||
let map = { invoicetitle: 'title', invoiceaddress: 'address', invoiceemail: 'email', loc: 'provisionlocation', provisionlocation: 'provisionlocation', provisionperiod: 'provisionperiod' };
|
||||
let target = map[nme]; if (!target) { return; }
|
||||
$inv.d.sync({ Target: target, Value: val });
|
||||
@@ -390,6 +465,12 @@ $inv.rd = {
|
||||
},
|
||||
applyState: function (state) {
|
||||
let tbl = $inv.rd.tbl(); if (tbl.length < 1) { return; }
|
||||
/* See $inv.d.applyState — drop stale/out-of-order dstate responses so an older refresh
|
||||
can never roll back a newer one's totals/validation. */
|
||||
let curVer = tbl.data('rdver');
|
||||
if (typeof curVer === 'number' && typeof state.version === 'number' && state.version < curVer) {
|
||||
return;
|
||||
}
|
||||
tbl.data('rdver', state.version).data('serverSums', state.sums).data('remid', state.remid || '');
|
||||
$inv.rd.footer(tbl, state.sums || {});
|
||||
$inv.rd.notes(state.notes || {});
|
||||
@@ -949,6 +1030,8 @@ $inv.eHtml = function (ev) {
|
||||
}
|
||||
$ocms.dlgform(flds, sets);
|
||||
};
|
||||
/* No client-side arithmetic (ADR 0006/0008): only the chosen VAT rate is stored on each row;
|
||||
the server (InvoiceDraftCalculator.RecomputeLineValues) recomputes vat_val/svcvat_val from it. */
|
||||
$inv.setVat = function (ev) {
|
||||
let t = $(this), thisrow = ev.data;
|
||||
let vat = prompt($rct.rqV);
|
||||
@@ -961,12 +1044,6 @@ $inv.setVat = function (ev) {
|
||||
thisrow.siblings('.itm').each(function () {
|
||||
let rwi = $(this), dta = rwi.data();
|
||||
dta.vat = fnum(vat, { style: 'percent' }).replace(' ', '');
|
||||
if ((dta.net_val || 0) > 0) {
|
||||
dta.vat_val = dta.net_val * vat;
|
||||
}
|
||||
if ((dta.svcnet_val || 0) > 0) {
|
||||
dta.svcvat_val = dta.svcnet_val * vat;
|
||||
}
|
||||
});
|
||||
$inv.t_fds_inv();
|
||||
}
|
||||
@@ -1026,12 +1103,14 @@ $inv.rrw = function () {
|
||||
if (ph === false) {
|
||||
bc.unshift($$.dc('ibtn edit', { title: $rct.cP }).append(gi('pencil')).click(rw, $inv.eRow));
|
||||
bc.push($$.dc('ibtn del', { title: $rct.dR }).append(gi('trash')).click(function (e) { if (confirm($rct.cD)) { rw.remove(); $inv.t_fds_inv(); } }));
|
||||
/* "Auf Setpreis umstellen": only offered on a Set header item that is not yet
|
||||
priced from its members (fds__prepInvoice's [SetItmId] is null exactly for a
|
||||
Set header — either already set-priced, or not yet converted). Irreversible:
|
||||
sets this row's price to the sum of its members (rows whose [SetItmId] equals
|
||||
this row's id) and clears each member's price. */
|
||||
if ((dta.Type || '').toLowerCase() === 'set' && (dta.SetItmId || '') === '') {
|
||||
/* "Auf Setpreis umstellen": offered on a Set header item that does not yet carry a
|
||||
price. fds__prepInvoice's [SetItmId] now anchors on the still-unconverted (price 0)
|
||||
header, so the header row's own SetItmId self-references its own id rather than being
|
||||
null — the real signal for showing this button is still the header's own price/net_val
|
||||
being 0 (once converted it carries a nonzero price and the button disappears).
|
||||
Irreversible: the actual sum/clear mutation happens server-side (backend-authoritative,
|
||||
see InvoiceDraftEditService.ApplyItemSetPrice) via the "item.setprice" patch target. */
|
||||
if ((dta.Type || '').toLowerCase() === 'set' && (dta.net_val || 0) === 0) {
|
||||
bc.push($$.dc('ibtn tosetp', { title: $rct.toSetP }).append(gi('scale')).click(rw, $inv.toSetPrice));
|
||||
}
|
||||
}
|
||||
@@ -1041,7 +1120,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 };
|
||||
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 };
|
||||
$$.dc('ibtn ico move', axf, { title: $rct.mR }); /* should be the last added */
|
||||
|
||||
co.p = dta.position || (dta.SortOrder || '');
|
||||
@@ -1051,7 +1130,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 || (fnum(dta.quantityhours) + ' ' + (dta.UnitString || ''));
|
||||
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.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
|
||||
@@ -1070,75 +1149,62 @@ $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 €. */
|
||||
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),
|
||||
$$.tdc('currency').text(fnum(co.v, $rct.cst)),
|
||||
$$.tdc('currency inetval').text(fnum(co.vt, $rct.cst)).attr('title', $rct.svcPart + ': ' + fnum(co.vs, $rct.cst))
|
||||
$$.tdc('currency').text(cur(co.v)),
|
||||
$$.tdc('currency inetval').text(cur(co.vt)).attr('title', $rct.svcPart + ': ' + cur(co.vs))
|
||||
]);
|
||||
}
|
||||
rw.empty().attr('class', ph ? 'placeholder' : 'itm').aC(co.Typ).tC('hidenote', hn).append(tda);
|
||||
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('hidenote', hn).append(tda);
|
||||
dta.co = co;
|
||||
};
|
||||
/* "Auf Setpreis umstellen" (see $inv.rrw): sets this Set-header row's price to the sum of
|
||||
its member rows' totals (rows in the same block whose [SetItmId] equals this row's id,
|
||||
the authoritative grouping from fds__prepInvoice), then clears each member's price so the
|
||||
member items show without a price — matching the SetPrice presentation, but as an actual,
|
||||
irreversible data change instead of a display-mode toggle (INVOICE_SET_PRICING.md's
|
||||
setmode remains display-only and is unaffected by this). */
|
||||
/* "Auf Setpreis umstellen" (see $inv.rrw): backend-authoritative (ADR 0006) — the browser only
|
||||
posts the target set-header item id; the server (InvoiceDraftEditService.ApplyItemSetPrice)
|
||||
sums the member rows (same block, [SetItmId] equal to this row's id), writes that sum onto
|
||||
the set header and clears each member's price, then the normal draftReady refresh re-renders
|
||||
both the header and its members from the authoritative session state. This mirrors the PDF's
|
||||
read of the same cached session, so the online editor and PDF can never drift apart. */
|
||||
$inv.toSetPrice = function (ev) {
|
||||
let rw = ev.data, dta = rw.data(), id = (dta.Id || '').toString();
|
||||
if (id === '') { return; }
|
||||
if (confirm($rct.toSetPc) === false) { return; }
|
||||
let bdy = rw.closest('tbody'), members = bdy.find('tr.itm').filter(function () {
|
||||
return (($(this).data('SetItmId') || '').toString()) === id;
|
||||
});
|
||||
let sum_net_val = 0, sum_vat_val = 0, sum_svcnet_val = 0, sum_svcvat_val = 0;
|
||||
members.each(function () {
|
||||
let m = $(this).data();
|
||||
sum_net_val += (m.net_val || 0);
|
||||
sum_vat_val += (m.vat_val || 0);
|
||||
sum_svcnet_val += (m.svcnet_val || 0);
|
||||
sum_svcvat_val += (m.svcvat_val || 0);
|
||||
$.extend(m, { net: 0, net_val: 0, vat_val: 0, svcnet_val: 0, svcvat_val: 0, Discount: 0 });
|
||||
$inv.rrw.call($(this));
|
||||
});
|
||||
$.extend(dta, {
|
||||
net: sum_net_val, quantityhours: 1, Discount: 0,
|
||||
net_val: sum_net_val, vat_val: sum_vat_val, svcnet_val: sum_svcnet_val, svcvat_val: sum_svcvat_val
|
||||
});
|
||||
$inv.rrw.call(rw);
|
||||
$inv.t_fds_inv();
|
||||
/* Wait for the flush of any pending local edits to actually reach the server before sending
|
||||
the conversion delta — sending both in parallel let a delayed block.replace (still carrying
|
||||
the pre-conversion, zero member/header values) land after item.setprice and silently
|
||||
overwrite the just-converted totals back to 0. */
|
||||
$.when($inv.t_fds_inv()).done(() => { $inv.d.sync({ Target: 'item.setprice', Ref: id }); });
|
||||
};
|
||||
/* No client-side totals/footer/tax computation (ADR 0006/0008): this only re-assembles each
|
||||
block's row contract array (needed to post the "req" shape to the server) and, on the very
|
||||
first pass, seeds the authoritative backend session. Footer, VAT breakdown, service-refund
|
||||
note figures and the per-block sum cell are never computed here — they are rendered exclusively
|
||||
from the server's `dstate` response by $inv.d.footer / $inv.d.applyState once the draft has a
|
||||
token. Before a token exists the footer/notes/isum cells are simply left empty; there is no
|
||||
local approximation to display. */
|
||||
$inv.invSumUpdate = function () {
|
||||
let tbl = $(this), ft = tbl.children('tfoot').empty(), p13b = bool((tbl.data().admin || {}).p13b || '', false);
|
||||
tbl.nextAll('.fnote').remove();
|
||||
let sms = { ttn: 0, ttb: 0, ttvat: 0, tscn: 0, tscvat: 0, vat: {}, itmnet: {} }, ba = [];
|
||||
let rwcy = (lbl, val, cls) => $$.tdc('currency', $$.tr(ft, { class: cls || 'tsum' }).append([$$.tdc('aux'), $$.td({ colspan: 4 }).text(lbl)]), fnum(val, $rct.cst)), fn = (t) => $$.dc('fnote').insertAfter(tbl).rwText(t);
|
||||
let csms = function (rrx, sms, sid) {
|
||||
sms.tscn += (rrx.svcnet_val || 0);
|
||||
sms.tscvat += (rrx.svcvat_val || 0);
|
||||
sms.ttn += (rrx.net_val || 0);
|
||||
sms.ttvat += (rrx.vat_val || 0);
|
||||
sms.ttb += ((rrx.net_val || 0) + (rrx.vat_val || 0));
|
||||
if ((rrx.vat || '') !== '') {
|
||||
sms.vat[rrx.vat] = (sms.vat[rrx.vat] || 0) + (rrx.vat_val || 0);
|
||||
}
|
||||
//sms.itmnet[sid] = (sms.itmnet[sid] || 0) + (rrx.net_val || 0);
|
||||
};
|
||||
let tbl = $(this);
|
||||
let ba = [];
|
||||
let bds = tbl.children('tbody');
|
||||
bds.each((bi, bdy) => {
|
||||
let b = $(bdy), rx = b.data() || {}, i = [], citems = [], cset = null, bnet = 0, itm = b.find('tr.itm'), iso = 0, ipos = 0;
|
||||
let b = $(bdy), rx = b.data() || {}, i = [], citems = [], 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);
|
||||
let rrx = $(tx).data() || {};
|
||||
i.push(rrx.co);
|
||||
/* 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). */
|
||||
Set grouping: fds__prepInvoice computes [SetItmId] per item, anchored on the still-unconverted
|
||||
(price 0) Set header that owns it — this is the authoritative membership signal (not row order),
|
||||
so items not tagged with a SetItmId by the server are never swept into a preceding set. The
|
||||
header row's own SetItmId self-references its own id, but it is never its own member (excluded
|
||||
by the id !== '' && !== citem.id check below). */
|
||||
let citem = $inv.itemToContract(rrx);
|
||||
if (citem.type === 'set' && citem.id !== '') { cset = citem.id; }
|
||||
else if (cset !== null && (citem.id || '') !== '') { citem.setId = cset; }
|
||||
let sid = (rrx.SetItmId || '').toString();
|
||||
if (citem.type !== 'set' && 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++; }
|
||||
@@ -1147,41 +1213,10 @@ $inv.invSumUpdate = function () {
|
||||
$inv.rrw.call(tx);
|
||||
}
|
||||
});
|
||||
//console.debug('%o', {
|
||||
// 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, items: citems, netval: bnet });
|
||||
ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, items: citems });
|
||||
});
|
||||
let nonempty = tbl.find('tbody:not(.empty)').length;
|
||||
bds.find('tr.isum').tC('hidden', nonempty < 2);
|
||||
//let fnet = $$.tdc('currency', $$.tr(ft, { class: 'tsum' }).append([$$.tdc('aux'), $$.td({ colspan: 4 }).text('Netto')]), fnum(sms.ttn, $rct.cst));
|
||||
rwcy('Netto', sms.ttn);
|
||||
if (p13b === false) {
|
||||
$.each(sms.vat, (vi, vx) => {
|
||||
//$$.tdc('currency vat', $$.tr(ft, { class: 'tvat' }).append([$$.tdc('aux'), $$.td({ colspan: 4 }).text('Umsatzsteuer ' + vi)]), fnum(vx, $rct.cst));
|
||||
rwcy($rct.vat + ' ' + vi, vx, 'tvat');
|
||||
});
|
||||
} else {
|
||||
sms.ttb = sms.ttn;
|
||||
}
|
||||
//let fsum = $$.tdc('currency', $$.tr(ft, { class: 'tsum' }).append([$$.tdc('aux'), $$.td({ colspan: 4 }).text('Summe')]), fnum(sms.ttb, $rct.cst));
|
||||
rwcy('Summe', sms.ttb);
|
||||
let itype = tbl.data().admin.type;
|
||||
if (itype === 'i') {
|
||||
fn($rct.note2);
|
||||
fn($rct.note4);
|
||||
} else if (itype === 'c') {
|
||||
fn($rct.note2);
|
||||
} else {
|
||||
fn(string($rct.note3, [fnum((sms.tscn + sms.tscvat) * (tbl.data().admin.tax_servicerefund || 0), $rct.cst)])).aC('ntax');
|
||||
fn($rct.note2);
|
||||
fn(string($rct.note1, [fnum(sms.tscn + sms.tscvat, $rct.cst), fnum(sms.tscn, $rct.cst), fnum(sms.tscvat, $rct.cst)]));
|
||||
}
|
||||
if (p13b === true) {
|
||||
fn($rct.note13b);
|
||||
}
|
||||
tbl.data('sms', sms);
|
||||
tbl.data('bai', ba);
|
||||
/* Backend-authoritative seeding (ADR 0006): on the first calculation of an invoice
|
||||
draft (invSumUpdate is only bound for invoices, never reminders), hand the assembled
|
||||
@@ -1281,8 +1316,16 @@ $inv.t_fds_inv = () => {
|
||||
let tbl = $('div.invoice_layout table.invi');
|
||||
tbl.trigger('fds.inv');
|
||||
/* After any local item mutation, push the changed block(s) to the authoritative
|
||||
server session as granular deltas (invoice drafts only — reminders have no token). */
|
||||
if ((tbl.data('dtoken') || '') !== '') { $inv.d.syncChanged(tbl); }
|
||||
server session as granular deltas (invoice drafts only — reminders have no token,
|
||||
nor a 'dseedpromise', so they correctly fall through to the no-op). While the initial
|
||||
seed (inv/dopen) is still in flight there is no token yet either, but a 'dseedpromise'
|
||||
is pending — wait for it instead of skipping the flush, otherwise an edit made in that
|
||||
brief window is silently never sent. Returns the flush promise so callers that must
|
||||
sequence a follow-up server delta after this flush (e.g. $inv.toSetPrice) can wait for
|
||||
it instead of racing it. */
|
||||
if ((tbl.data('dtoken') || '') !== '') { return $inv.d.syncChanged(tbl); }
|
||||
let seeding = tbl.data('dseedpromise');
|
||||
return seeding ? $.when(seeding).then(() => $inv.d.syncChanged(tbl)) : $.when();
|
||||
};
|
||||
$inv.sedit = () => {
|
||||
$inv.sprev(true);
|
||||
@@ -1349,7 +1392,10 @@ $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 || '' };
|
||||
/* 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. */
|
||||
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') {
|
||||
/* combined single-sum line — the on-screen "title" is an HTML sub-table; render it as desc */
|
||||
ci.desc = rrx.co.t || '';
|
||||
@@ -1370,30 +1416,35 @@ $inv.itemToContract = function (rrx) {
|
||||
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);
|
||||
ci.price_net = nz(rrx.net);
|
||||
ci.total_net = nz(rrx.net_val);
|
||||
}
|
||||
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. */
|
||||
/* 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. */
|
||||
$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')]);
|
||||
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). */
|
||||
$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.d.sync({ Target: 'setmode', Value: mode });
|
||||
if (confirm($rct.toSetMc || $rct.toSetPc) === false) { return; }
|
||||
let target = (mode === 'setonly') ? 'block.setonly' : 'block.setprice';
|
||||
$.when($inv.t_fds_inv()).done(() => { $inv.d.sync({ Target: target }); });
|
||||
};
|
||||
$inv.sctp = () => {
|
||||
let flds = $invcol.ctp;
|
||||
@@ -1416,24 +1467,21 @@ $inv.sctp = () => {
|
||||
});
|
||||
};
|
||||
/* 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);
|
||||
(FdsInvoiceData.BuildInvoiceParams) reads, then returns the `invc` payload. No totals/VAT
|
||||
are computed here (ADR 0006/0008: the online editor performs no arithmetic at all) — the
|
||||
backend session computes total_net/total_gross/vat itself (InvoiceDraftCalculator) once
|
||||
opened from this payload, and BuildFdsData/BuildInvoiceParams reads them from there:
|
||||
- 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. */
|
||||
let nw = $.extend({}, d.new), adm = $.extend({}, d.admin);
|
||||
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 };
|
||||
return { admin: adm, req: d.bai, new: nw };
|
||||
};
|
||||
/* Zwischenspeichern and preview now run against the backend-authoritative session
|
||||
(ADR 0006): no full-invoice re-upload — the cached draft is flushed / rendered by
|
||||
@@ -1447,20 +1495,11 @@ $inv.rReload = () => {
|
||||
$inv.cInv2({ id: s.search });
|
||||
} catch (e) { }
|
||||
};
|
||||
$inv.quantChange = function (i) {
|
||||
//console.debug({ t: this, i: i });
|
||||
let t = $(this), f = t.closest('form'), fi = {}, pf = (i) => parseFloat(i.toString().replace('%', '').replace(',', '.')), rtp = (num) => num.toFixed(2);
|
||||
f.find(':input').each((i, e) => { fi[$(e).attr('name')] = $(e); });
|
||||
let qv = parseInt(fi.quantityhours.val() || '0'), nv = pf(fi.net.val() || '0'), vat = pf(fi.vat.val()) * 0.01;
|
||||
if (qv > 0 && nv > 0) {
|
||||
fi.net_val.val(rtp(qv * nv));
|
||||
fi.vat_val.val(rtp(qv * nv * vat));
|
||||
if (['Service'].includes(fi.Type.val())) {
|
||||
fi.svcnet_val.val(rtp(qv * nv));
|
||||
fi.svcvat_val.val(rtp(qv * nv * vat));
|
||||
}
|
||||
}
|
||||
};
|
||||
/* No client-side arithmetic (ADR 0006/0008): quantity/price/VAT edits are posted as raw values
|
||||
and the server (InvoiceDraftCalculator.RecomputeLineValues) computes net_val/vat_val/
|
||||
svcnet_val/svcvat_val. The fields are left as entered here; the authoritative values are
|
||||
pushed back onto the row by $inv.d.applyItems once the change reaches the backend. */
|
||||
$inv.quantChange = function (i) { };
|
||||
$inv.storno = function (id, fds) {
|
||||
let o, fr = $$.dc('choicefrm').append([
|
||||
$$.dc('btn', 'Storno ohne Details').click({ id: id, mode: 'simple' }, (ev) => { o.c.trigger('modal_close'); $inv.cSt(ev.data); })
|
||||
|
||||
Reference in New Issue
Block a user