@@ -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 i s 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 memb ers (rows whose [SetItmId] equ als
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 doe s 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 head er' s own price/net_v al
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 ) ; } )