Add backend-authoritative invoice draft editing (ADR 0006/0007)
Move the invoice draft editor to a backend single source of truth: an in-memory InvoiceDraftSession (per-token, cached) holds the editable payload, server-computed sums/VAT and validation, plus an automatic change history. The browser posts single edits; the server recomputes and signals the editing session over a dedicated SignalR hub (DraftPreviewHub) to re-fetch. This reverses the previously-documented stateless editor (EVAL_live_invoice_editing, INVOICE_LIFECYCLE §10), by explicit product decision — captured in ADR 0006 and 0007 plus the live-draft-editing concept doc. Backend (this milestone): - InvoiceDraftSession + ChangeHistoryEntry data holders - InvoiceDraftCalculator: pure port of quantChange/invSumUpdate (§13b, VAT-by-rate) and consistency checks — fully unit-tested - IInvoiceDraftCache/InvoiceDraftCache: in-memory store with idle sliding TTL - IInvoiceDraftService/InvoiceDraftEditService: open (payload or DB reload), patch, build state, flush via existing RegisterInvoiceAsync (no new persistence), preview from cache, discard (DB reload), history - InvoiceDraftExpiryService: pre-expiry warning + eviction-with-reason - DraftPreviewHub + IDraftNotifier/DraftNotifier: targeted draftReady/draftExpiring/ draftClosed signals per draft token - inv/dopen|dstate|dpatch|dpreview|dsave|dhistory|ddiscard|dclose endpoints; save reports success/failure via the existing EventService - DI + hub mapping in Program.cs Frontend (additive foundation): $fis.draft SignalR client for /draftpreview. The editor DOM inversion (routing deltas, rendering from server state) is the next, separately-verified step; existing endpoints are unaffected. Tests: 30 new (calculator, cache, expiry, patch/history, flush); 306 total passing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
e53d8962ad
commit
af445c015e
@@ -3129,6 +3129,68 @@ $fis.notifications = {
|
||||
}
|
||||
};
|
||||
|
||||
/* Live draft-editing client (ADR 0006/0007). Separate SignalR connection to the
|
||||
dedicated /draftpreview hub; the server signals the *one* browser editing a draft
|
||||
(group = session token) to re-fetch (draftReady), warns before idle expiry
|
||||
(draftExpiring), and tells it to close on eviction (draftClosed). The editor
|
||||
(fis.inv_shared.js) registers the open draft via $fis.draft.bind(token, {...}). */
|
||||
$fis.draft = {
|
||||
connection: null,
|
||||
active: null, /* { token, onReady(version), onExpiring(secondsLeft), onClosed(reason) } */
|
||||
init: function () {
|
||||
if (typeof signalR === 'undefined' || this.connection !== null || !$ocms.auth.useraccount_id) {
|
||||
return;
|
||||
}
|
||||
this.connection = new signalR.HubConnectionBuilder()
|
||||
.withUrl('/draftpreview')
|
||||
.withAutomaticReconnect()
|
||||
.build();
|
||||
this.connection.on('draftReady', (p) => this._dispatch('onReady', p, (p) => p.version));
|
||||
this.connection.on('draftExpiring', (p) => this._dispatch('onExpiring', p, (p) => p.secondsLeft));
|
||||
this.connection.on('draftClosed', (p) => this._dispatch('onClosed', p, (p) => p.reason));
|
||||
/* Re-join the active draft's group after a (re)connect — group membership is
|
||||
per-connection and is lost when the socket drops. */
|
||||
this.connection.onreconnected(() => { if (this.active) { this._invoke('JoinDraft', this.active.token); } });
|
||||
this.connection.onclose(() => {
|
||||
console.warn('Draft connection closed; retrying in 5s.');
|
||||
this.connection = null;
|
||||
setTimeout(() => { this.init(); if (this.active) { this.bind(this.active.token, this.active); } }, 5000);
|
||||
});
|
||||
this.start();
|
||||
},
|
||||
start: function () {
|
||||
this.connection.start()
|
||||
.then(() => { if (this.active) { this._invoke('JoinDraft', this.active.token); } })
|
||||
.catch((err) => {
|
||||
console.warn('Draft connection failed to start; retrying in 5s.', err);
|
||||
this.connection = null;
|
||||
setTimeout(() => this.init(), 5000);
|
||||
});
|
||||
},
|
||||
/* Registers the currently open draft and joins its signal group. handlers:
|
||||
{ onReady, onExpiring, onClosed }. */
|
||||
bind: function (token, handlers) {
|
||||
if (!token) { return; }
|
||||
this.active = $.extend({ token: token }, handlers || {});
|
||||
if (this.connection === null) { this.init(); }
|
||||
this._invoke('JoinDraft', token);
|
||||
},
|
||||
/* Unregisters + leaves the group (editor closed). */
|
||||
release: function (token) {
|
||||
if (this.active && (!token || this.active.token === token)) { this.active = null; }
|
||||
this._invoke('LeaveDraft', token);
|
||||
},
|
||||
_invoke: function (method, token) {
|
||||
if (!token || !this.connection || this.connection.state !== 'Connected') { return; }
|
||||
this.connection.invoke(method, token).catch((err) => console.warn('Draft ' + method + ' failed', err));
|
||||
},
|
||||
_dispatch: function (handler, payload, argOf) {
|
||||
payload = payload || {};
|
||||
if (!this.active || this.active.token !== payload.token) { return; }
|
||||
if (typeof this.active[handler] === 'function') { this.active[handler](argOf(payload)); }
|
||||
}
|
||||
};
|
||||
|
||||
(function () {
|
||||
Array.prototype.push.apply($ocms.ocmsmenu,[
|
||||
{ lbl: $t.m_inv, id: 'm_inv', fnc: 'init:inv', ico: 'glyphicon glyphicon-list-alt'}
|
||||
@@ -3143,5 +3205,6 @@ $fis.notifications = {
|
||||
})();
|
||||
$(document).ready(function () {
|
||||
$fis.notifications.init();
|
||||
$fis.draft.init();
|
||||
$fis.ov();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user