Add backend-authoritative invoice draft editing (ADR 0006/0007) #1

Merged
Stefan merged 12 commits from feature/backend-authoritative-draft-editing into main 2026-07-16 19:46:55 +02:00
18 changed files with 696 additions and 86 deletions
Showing only changes of commit 83d1c28b29 - Show all commits
+52
View File
@@ -0,0 +1,52 @@
using System.Linq;
using Fuchs.intranet;
using Newtonsoft.Json.Linq;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Verifies the block projection that feeds the PDF item table: each service-request
/// group exposes its heading (the section title the editor shows) and its line items,
/// so the PDF can print a heading row per block and the flat item list still works.
/// </summary>
public class FdsInvoiceDataBlocksTests
{
private static FdsInvoiceData FromReq(string reqJson) =>
new(JObject.Parse(@"{'admin':{'type':'r'},'new':{},'sms':{},'req':" + reqJson + "}"));
[Fact]
public void InvoiceBlocks_ExposesHeadingFromTextThenNme_AndItems()
{
var inv = FromReq(@"[
{'Id':'1','text':'Sektion A','items':[{'id':'a','type':'material','title':'X','price_net':10,'total_net':10}]},
{'Id':'2','nme':'Sektion B','items':[{'id':'b','type':'material','title':'Y','price_net':20,'total_net':20}]}
]");
var blocks = inv.InvoiceBlocks;
Assert.Equal(2, blocks.Count);
Assert.Equal("Sektion A", blocks[0].Heading);
Assert.Equal("Sektion B", blocks[1].Heading); // falls back to nme
Assert.Single(blocks[0].Items);
Assert.Equal("X", blocks[0].Items[0]["title"]);
}
[Fact]
public void InvoiceBlocks_MissingHeading_IsEmpty()
{
var inv = FromReq(@"[{'Id':'1','items':[{'id':'a','type':'material','total_net':5}]}]");
Assert.Equal("", Assert.Single(inv.InvoiceBlocks).Heading);
}
[Fact]
public void InvoiceItems_StillFlattensAcrossBlocks()
{
var inv = FromReq(@"[
{'Id':'1','text':'A','items':[{'id':'a','type':'material','total_net':10}]},
{'Id':'2','text':'B','items':[{'id':'b','type':'material','total_net':20},{'id':'c','type':'material','total_net':30}]}
]");
Assert.Equal(new[] { "a", "b", "c" }, inv.InvoiceItems.Select(i => i["id"]!.ToString()).ToArray());
}
}
@@ -136,4 +136,77 @@ public class InvoiceDraftCalculatorTests
InvoiceDraftCalculator.Validate(s); InvoiceDraftCalculator.Validate(s);
Assert.Contains(s.ValidationMessages, m => m.Field == "total" && m.Severity == "warning"); Assert.Contains(s.ValidationMessages, m => m.Field == "total" && m.Severity == "warning");
} }
// ── RecomputePositions ────────────────────────────────────────────────────
private static string Pos(InvoiceDraftSession s, int block, int line) =>
((JObject)((JArray)((JObject)s.Req[block])["itm"]!)[line])["p"]!.ToString();
[Fact]
public void RecomputePositions_NumbersPricedLinesContinuouslyAcrossBlocks()
{
var s = SessionWith(@"[
{ 'Id':'10','itm':[ {'id':'a','typ':'material','vt':1}, {'id':'b','typ':'service','vt':2} ] },
{ 'Id':'11','itm':[ {'id':'c','typ':'material','vt':3} ] }
]");
InvoiceDraftCalculator.RecomputePositions(s);
Assert.Equal("1", Pos(s, 0, 0));
Assert.Equal("2", Pos(s, 0, 1));
Assert.Equal("3", Pos(s, 1, 0)); // continuous, not restarting per block
}
[Fact]
public void RecomputePositions_SkipsHeadingAndFreeTextLines()
{
var s = SessionWith(@"[
{ 'Id':'10','itm':[
{'id':'t','typ':'Title','vt':0},
{'id':'a','typ':'material','vt':1},
{'id':'x','typ':'Text','vt':0},
{'id':'b','typ':'material','vt':2} ] }
]");
InvoiceDraftCalculator.RecomputePositions(s);
Assert.Equal("", Pos(s, 0, 0)); // title carries no number
Assert.Equal("1", Pos(s, 0, 1));
Assert.Equal("", Pos(s, 0, 2)); // free text carries no number
Assert.Equal("2", Pos(s, 0, 3));
}
[Fact]
public void RecomputePositions_NumbersSetHeaderLikeAnyItem()
{
// A set header is numbered just like the editor numbers it — only text/title lines are skipped.
var s = SessionWith(@"[
{ 'Id':'10','itm':[
{'id':'h','typ':'set','vt':1000},
{'id':'a','typ':'material','vt':600},
{'id':'b','typ':'material','vt':400} ] }
]");
InvoiceDraftCalculator.RecomputePositions(s);
Assert.Equal("1", Pos(s, 0, 0)); // set header keeps position 1 (matches the editor)
Assert.Equal("2", Pos(s, 0, 1));
Assert.Equal("3", Pos(s, 0, 2));
}
[Fact]
public void RecomputePositions_AfterBlockOrderChange_RenumbersToNewSequence()
{
var s = SessionWith(@"[
{ 'Id':'10','itm':[ {'id':'a','typ':'material','vt':1} ] },
{ 'Id':'11','itm':[ {'id':'b','typ':'material','vt':2} ] }
]");
// Simulate a section reorder: swap the two blocks.
var b0 = s.Req[0]; var b1 = s.Req[1];
s.Req = new JArray(b1.DeepClone(), b0.DeepClone());
InvoiceDraftCalculator.RecomputePositions(s);
Assert.Equal("1", Pos(s, 0, 0)); // formerly block 11's item is now position 1
Assert.Equal("2", Pos(s, 1, 0));
}
} }
+124
View File
@@ -352,4 +352,128 @@ public class InvoiceDraftServiceTests
Assert.Null(svc.Get(s.Token)); Assert.Null(svc.Get(s.Token));
Assert.False(svc.Close(s.Token)); Assert.False(svc.Close(s.Token));
} }
// ── HTML sanitisation (values must never reach the DB/PDF wrapped in tags) ─
[Theory]
[InlineData("provisionperiod", "provisionperiod")]
[InlineData("title", "invoicetitle")]
[InlineData("email", "invoiceemail")]
public void ApplyPatch_ScalarField_StripsHtmlWrapper(string target, string newKey)
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = target, Value = JToken.FromObject("<p>18.06.2026</p>") });
Assert.Equal("18.06.2026", s2!.New[newKey]!.Value<string>()); // no <p> tags stored
Assert.Equal("18.06.2026", Assert.Single(s2.History).NewValue);
}
[Fact]
public void ApplyPatch_Address_MultilineHtml_KeepsLineBreaks()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta
{
Target = "address",
Value = JToken.FromObject("<p>Firma AG</p><p>Weg 1<br>5080 Laufenburg</p>")
});
Assert.Equal("Firma AG\nWeg 1\n5080 Laufenburg", s2!.New["invoiceaddress"]!.Value<string>());
}
[Fact]
public void ApplyPatch_ScalarField_DecodesEntities()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "title", Value = JToken.FromObject("Tom &amp; Jerry") });
Assert.Equal("Tom & Jerry", s2!.New["invoicetitle"]!.Value<string>());
}
[Fact]
public void ApplyPatch_ProvisionLocation_SanitisesAndMirrorsLoc()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "provisionlocation", Value = JToken.FromObject("<p>Baustelle 7</p>") });
Assert.Equal("Baustelle 7", s2!.New["provisionlocation"]!.Value<string>());
Assert.Equal("Baustelle 7", s2.New["loc"]!.Value<string>());
}
// ── Change history records the changed field, not the whole block JSON ─────
[Fact]
public void ApplyPatch_BlockReplace_HistoryNewValueIsSectionText_NotJson()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var newBlock = JObject.Parse(@"{'Id':'1','text':'<p>Neue Überschrift</p>',
'itm':[{'id':'900','typ':'material','vt':100,'vv':19,'vat':'19%'}],
'items':[{'id':'900','type':'material','total_net':100,'vat':'19%'}]}");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.replace", Ref = "1", Value = newBlock });
var h = Assert.Single(s2!.History);
Assert.Equal("Neue Überschrift", h.NewValue); // the heading, sanitised — never the block JSON
Assert.DoesNotContain("{", h.NewValue);
Assert.Equal("Auftrag", h.OldValue);
// and the cached block text is stored clean too
Assert.Equal("Neue Überschrift", ((JObject)s2.Req[0])["text"]!.Value<string>());
}
// ── Section reorder ───────────────────────────────────────────────────────
private static JObject TwoBlockPayload() => JObject.Parse(@"{
'admin':{'p13b':false,'type':'r'},
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1'},
'req':[
{'Id':'1','text':'A','itm':[{'id':'900','typ':'material','vt':100,'vv':19,'vat':'19%'}],'items':[{'id':'900','type':'material','total_net':100,'vat':'19%'}]},
{'Id':'2','text':'B','itm':[{'id':'950','typ':'material','vt':30,'vv':5.7,'vat':'19%'}],'items':[{'id':'950','type':'material','total_net':30,'vat':'19%'}]}
]}");
[Fact]
public void ApplyPatch_BlockOrder_ReordersReqAndRenumbersPositions()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(TwoBlockPayload(), "user1");
Assert.Equal(new[] { "1", "2" }, s.Req.Select(b => b["Id"]!.Value<string>()).ToArray());
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.order", Value = JArray.Parse("['2','1']") });
Assert.Equal(new[] { "2", "1" }, s2!.Req.Select(b => b["Id"]!.Value<string>()).ToArray());
Assert.Equal("1", ((JObject)((JArray)((JObject)s2.Req[0])["itm"]!)[0])["p"]!.ToString()); // block 2's item now position 1
Assert.Equal(130m, s2.Sums.TotalNet); // totals unaffected by reorder
var h = Assert.Single(s2.History);
Assert.Equal("1,2", h.OldValue);
Assert.Equal("2,1", h.NewValue);
}
[Fact]
public void ApplyPatch_BlockOrder_UnchangedSequence_IsNoOp()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(TwoBlockPayload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.order", Value = JArray.Parse("['1','2']") });
Assert.Equal(0, s2!.Version); // no-op: no version bump, no history
Assert.Empty(s2.History);
}
[Fact]
public void ApplyPatch_BlockOrder_UnknownIds_KeepMentionedFirstThenRest()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(TwoBlockPayload(), "user1");
// Only name block 2; block 1 is unmentioned and must be kept (appended after).
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.order", Value = JArray.Parse("['2','ghost']") });
Assert.Equal(new[] { "2", "1" }, s2!.Req.Select(b => b["Id"]!.Value<string>()).ToArray());
}
} }
@@ -369,6 +369,21 @@ public partial class IntranetController
return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht erstellt werden."); return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht erstellt werden.");
} }
/// <summary>
/// Serves the PDF inline (browser shows it) while advertising the real download filename —
/// both a quoted ASCII form and RFC 5987 <c>filename*</c> for spaces/non-ASCII. Works around
/// the OCORE FileContentResult helper, whose classic-MVC <c>ExecuteResult(ControllerContext)</c>
/// never runs under ASP.NET Core, so the filename was dropped and downloads used the "idoc"
/// endpoint segment.
/// </summary>
private void SetInlinePdfFilename(string filename)
{
string safe = (filename ?? "").Replace("\"", "").Replace("\r", " ").Replace("\n", " ").Trim();
if (safe.Length == 0) return;
Response.Headers["Content-Disposition"] =
$"inline; filename=\"{safe}\"; filename*=UTF-8''{Uri.EscapeDataString(safe)}";
}
private async Task<IActionResult> HandleRequestIdoc(string fn, string id, string code) private async Task<IActionResult> HandleRequestIdoc(string fn, string id, string code)
{ {
if (!HasForm("id") || string.IsNullOrEmpty(Form("id"))) { _logger.LogWarning("HandleRequestIdoc: missing/empty form field 'id', user={User}", UserAccountID); return StatusCode(404); } if (!HasForm("id") || string.IsNullOrEmpty(Form("id"))) { _logger.LogWarning("HandleRequestIdoc: missing/empty form field 'id', user={User}", UserAccountID); return StatusCode(404); }
@@ -381,9 +396,13 @@ public partial class IntranetController
byte[]? ct = Form("create", "0") != "1" byte[]? ct = Form("create", "0") != "1"
? await _invoices.GetInvoiceFileAsync(fdInv, fdInv.IsDraft, _mfr) is { Length: > 0 } f1 ? f1 : await _invoices.StoreInvoiceDocumentFileAsync(fdInv, fdInv.IsDraft, UserAccountID, DbSec) ? await _invoices.GetInvoiceFileAsync(fdInv, fdInv.IsDraft, _mfr) is { Length: > 0 } f1 ? f1 : await _invoices.StoreInvoiceDocumentFileAsync(fdInv, fdInv.IsDraft, UserAccountID, DbSec)
: _pdf.DocToPdfBytes(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft)); : _pdf.DocToPdfBytes(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
return ct != null if (ct == null)
? await FileContentResultAsync(ct, "application/pdf", filename, inline: true) return await InvoiceIssueResult("Die Rechnungs-PDF konnte aufgrund eines Fehlers nicht erstellt werden.", fdInv.Id);
: await InvoiceIssueResult("Die Rechnungs-PDF konnte aufgrund eines Fehlers nicht erstellt werden.", fdInv.Id); // Serve inline for the in-browser viewer, but carry the real DocumentName so the browser's
// "download" uses "Rechnung R2026-0121.pdf" instead of the "idoc" endpoint segment. (The
// OCORE FileContentResult helper drops the filename under ASP.NET Core, so set it here.)
SetInlinePdfFilename(filename);
return File(ct, "application/pdf");
} }
var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft)); var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages }); return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
+16 -3
View File
@@ -51,9 +51,22 @@ Expiry: Server (timer) --SignalR draftExpiring{token,secondsLeft}--> warn "bit
`Sums`, `ValidationMessages`, `History`, `Version`, `Token`, `InvId`, `LastAccessUtc`. `Sums`, `ValidationMessages`, `History`, `Version`, `Token`, `InvId`, `LastAccessUtc`.
- **Calculation** (`InvoiceDraftCalculator`, static/pure) ports the former client math: - **Calculation** (`InvoiceDraftCalculator`, static/pure) ports the former client math:
`RecomputeItem` (quantity × price × VAT, the `quantChange` port), `RecomputeTotals` `RecomputeItem` (quantity × price × VAT, the `quantChange` port), `RecomputeTotals`
(the `invSumUpdate`/`csms` aggregation + §13b reverse-charge), and `Validate` (the `invSumUpdate`/`csms` aggregation + §13b reverse-charge), `RecomputePositions`
(email/address/items/VAT-rate/negative-total checks). Being pure, it is exhaustively (numbers every line except heading/free-text lines continuously across the whole invoice —
unit-tested. mirroring the editor's `invSumUpdate`, so the editor and the PDF show identical `Pos.` numbers,
including after a reorder),
and `Validate` (email/address/items/VAT-rate/negative-total checks). Being pure, it is
exhaustively unit-tested.
- **Sanitisation & reorder.** Scalar text deltas (`title`/`email`/`address`/`provisionperiod`/
`provisionlocation`) and the section heading (`block.replace`) are stripped of the editor's
TinyMCE HTML (`<p>…</p>`, `<br>`) to plain text in `ApplyDelta` (`HtmlToPlain`) — the backend
is the single source of truth, so no HTML reaches the DB, the PDF or a reloaded draft. Section
drags post a `block.order` delta (`["id",…]`) that reorders `Req`; positions are then
renumbered and pushed back via the view state (`applyState`/`applyPositions`). The change
history records the **changed field** (e.g. the new heading text), never the whole block JSON.
The PDF (`FuchsPdf`) renders a heading row per block (`FdsInvoiceData.InvoiceBlocks`) and shows
every position's price (set members are priced like standalone lines; only `setonly` collapses
them), so the PDF preview mirrors the online editor.
- **Orchestration** (`InvoiceDraftEditService`, scoped) opens sessions (from a fresh - **Orchestration** (`InvoiceDraftEditService`, scoped) opens sessions (from a fresh
payload or by reloading a DB draft via `fds__getInvoice`, reshaped like payload or by reloading a DB draft via `fds__getInvoice`, reshaped like
`BuildInvoiceRequestList`), applies deltas (`ApplyDelta`), builds the view-state DTO, `BuildInvoiceRequestList`), applies deltas (`ApplyDelta`), builds the view-state DTO,
+112 -25
View File
@@ -1,4 +1,5 @@
using System.Globalization; using System.Globalization;
using System.Text.RegularExpressions;
using Fuchs.intranet; using Fuchs.intranet;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using MigraDoc.DocumentObjectModel; using MigraDoc.DocumentObjectModel;
@@ -59,8 +60,8 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
var session = _cache.Get(token); var session = _cache.Get(token);
if (session == null) return null; if (session == null) return null;
string oldValue = ""; string oldValue = "", newValue = "";
bool mutated = ApplyDelta(session, delta, ref oldValue); bool mutated = ApplyDelta(session, delta, ref oldValue, ref newValue);
if (!mutated) if (!mutated)
{ {
_logger.LogDebug("Draft {Token}: no-op patch target={Target} ref={Ref}", token, delta.Target, delta.Ref); _logger.LogDebug("Draft {Token}: no-op patch target={Target} ref={Ref}", token, delta.Target, delta.Ref);
@@ -75,71 +76,89 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
Target = delta.Target, Target = delta.Target,
Ref = delta.Ref, Ref = delta.Ref,
OldValue = oldValue, OldValue = oldValue,
NewValue = delta.ValueString, NewValue = newValue,
Version = session.Version Version = session.Version
}); });
_cache.Set(session); _cache.Set(session);
return session; return session;
} }
/// <summary>Applies one delta to the payload; returns whether anything changed and captures the prior value.</summary> /// <summary>
private static bool ApplyDelta(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue) /// Applies one delta to the payload; returns whether anything changed and captures the prior
/// and new value for the change history. Scalar text fields and the section heading are
/// sanitised from the editor's HTML (TinyMCE wraps inline edits in <c>&lt;p&gt;…&lt;/p&gt;</c>)
/// to plain text here — the backend is the single source of truth (ADR 0006), so no HTML ever
/// reaches the DB, the PDF or a reloaded draft, regardless of which UI path produced it.
/// </summary>
private static bool ApplyDelta(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
{ {
switch (d.Target) switch (d.Target)
{ {
case "email": return SetNew(s, "invoiceemail", d, ref oldValue); case "email": return SetNewText(s, "invoiceemail", d, ref oldValue, ref newValue);
case "address": return SetNew(s, "invoiceaddress", d, ref oldValue); case "address": return SetNewText(s, "invoiceaddress", d, ref oldValue, ref newValue);
case "title": return SetNew(s, "invoicetitle", d, ref oldValue); case "title": return SetNewText(s, "invoicetitle", d, ref oldValue, ref newValue);
case "provisionperiod": return SetNew(s, "provisionperiod", d, ref oldValue); case "provisionperiod": return SetNewText(s, "provisionperiod", d, ref oldValue, ref newValue);
case "provisionlocation": case "provisionlocation":
oldValue = Str(s.New["provisionlocation"]); oldValue = Str(s.New["provisionlocation"]);
s.New["provisionlocation"] = d.ValueString; newValue = HtmlToPlain(d.ValueString);
s.New["loc"] = d.ValueString; // editor mirrors both s.New["provisionlocation"] = newValue;
s.New["loc"] = newValue; // editor mirrors both
return true; return true;
case "contact": return SetContact(s, d, ref oldValue); case "contact": return SetContact(s, d, ref oldValue, ref newValue);
case "setmode": return SetAdmin(s, "setmode", d, ref oldValue); case "setmode": return SetAdmin(s, "setmode", d, ref oldValue, ref newValue);
case "p13b": case "p13b":
oldValue = Str(s.Admin["p13b"]); oldValue = Str(s.Admin["p13b"]);
s.Admin["p13b"] = d.Value != null && d.Value.Type != JTokenType.Null bool p13b = d.Value != null && d.Value.Type != JTokenType.Null
? AsBool(d.Value) : !AsBool(s.Admin["p13b"]); // toggle when no explicit value ? AsBool(d.Value) : !AsBool(s.Admin["p13b"]); // toggle when no explicit value
s.Admin["p13b"] = p13b;
newValue = p13b ? "§13b" : "";
return true; return true;
case "block.replace": return ReplaceBlock(s, d, ref oldValue); case "block.replace": return ReplaceBlock(s, d, ref oldValue, ref newValue);
case "block.remove": return RemoveBlock(s, d, ref oldValue); case "block.remove": return RemoveBlock(s, d, ref oldValue, ref newValue);
case "block.order": return ReorderBlocks(s, d, ref oldValue, ref newValue);
default: return false; default: return false;
} }
} }
private static bool SetNew(InvoiceDraftSession s, string key, InvoiceDraftDelta d, ref string oldValue) private static bool SetNewText(InvoiceDraftSession s, string key, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
{ {
oldValue = Str(s.New[key]); oldValue = Str(s.New[key]);
s.New[key] = d.ValueString; newValue = HtmlToPlain(d.ValueString);
s.New[key] = newValue;
return true; return true;
} }
private static bool SetAdmin(InvoiceDraftSession s, string key, InvoiceDraftDelta d, ref string oldValue) private static bool SetAdmin(InvoiceDraftSession s, string key, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
{ {
oldValue = Str(s.Admin[key]); oldValue = Str(s.Admin[key]);
s.Admin[key] = d.ValueString; newValue = d.ValueString;
s.Admin[key] = newValue;
return true; return true;
} }
private static bool SetContact(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue) private static bool SetContact(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
{ {
oldValue = Str(s.New["CustomValues"]); JObject prev = TryParseObject(Str(s.New["CustomValues"]));
JObject cvo = TryParseObject(oldValue); oldValue = ContactLabel(Str(prev["contactName"]), Str(prev["contactEmail"]));
JObject cvo = (JObject)prev.DeepClone();
if (d.Value is JObject vo) if (d.Value is JObject vo)
{ {
cvo["contactName"] = vo["name"] ?? vo["contactName"] ?? ""; cvo["contactName"] = vo["name"] ?? vo["contactName"] ?? "";
cvo["contactEmail"] = vo["email"] ?? vo["contactEmail"] ?? ""; cvo["contactEmail"] = vo["email"] ?? vo["contactEmail"] ?? "";
} }
s.New["CustomValues"] = cvo.ToString(Newtonsoft.Json.Formatting.None); s.New["CustomValues"] = cvo.ToString(Newtonsoft.Json.Formatting.None);
newValue = ContactLabel(Str(cvo["contactName"]), Str(cvo["contactEmail"]));
return true; return true;
} }
private static string ContactLabel(string name, string email) =>
string.IsNullOrEmpty(name) ? email : string.IsNullOrEmpty(email) ? name : $"{name} <{email}>";
/// <summary>Replaces (or inserts) a whole block — the editor re-emits an edited block's line arrays as one delta.</summary> /// <summary>Replaces (or inserts) a whole block — the editor re-emits an edited block's line arrays as one delta.</summary>
private static bool ReplaceBlock(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue) private static bool ReplaceBlock(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
{ {
if (d.Value is not JObject nb) return false; if (d.Value is not JObject nb) return false;
SanitizeBlockText(nb);
string bid = !string.IsNullOrEmpty(d.Ref) ? d.Ref : Str(nb["Id"]); string bid = !string.IsNullOrEmpty(d.Ref) ? d.Ref : Str(nb["Id"]);
var existing = FindBlock(s, bid); var existing = FindBlock(s, bid);
if (existing != null) if (existing != null)
@@ -152,18 +171,58 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
oldValue = ""; oldValue = "";
s.Req.Add(nb); s.Req.Add(nb);
} }
newValue = Str(nb["text"]); // the section heading — never the whole block JSON
return true; return true;
} }
private static bool RemoveBlock(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue) private static bool RemoveBlock(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
{ {
var block = FindBlock(s, d.Ref); var block = FindBlock(s, d.Ref);
if (block == null) return false; if (block == null) return false;
oldValue = Str(block["text"]); oldValue = Str(block["text"]);
newValue = "";
block.Remove(); block.Remove();
return true; return true;
} }
/// <summary>
/// Reorders the service-request blocks to the id sequence the editor posts after a section
/// drag (<c>Value</c> = ["id",…]). Named ids move into the given order; any not named are kept
/// in their current relative order at the end. Totals are unaffected; item position numbers
/// are renumbered by <see cref="Refresh"/> and pushed back to the browser via the view state.
/// </summary>
private static bool ReorderBlocks(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
{
if (d.Value is not JArray order) return false;
var current = s.Req.OfType<JObject>().ToList();
oldValue = string.Join(",", current.Select(b => Str(b["Id"])));
var byId = current.ToDictionary(b => Str(b["Id"]), b => b);
var ordered = new List<JObject>();
var seen = new HashSet<string>();
foreach (var idTok in order)
{
string id = Str(idTok);
if (byId.TryGetValue(id, out var blk) && seen.Add(id)) ordered.Add(blk);
}
foreach (var b in current) // append blocks the order list didn't mention, in place
if (seen.Add(Str(b["Id"]))) ordered.Add(b);
newValue = string.Join(",", ordered.Select(b => Str(b["Id"])));
if (oldValue == newValue) return false; // no-op reorder
s.Req.Clear();
foreach (var b in ordered) s.Req.Add(b);
return true;
}
/// <summary>Strips the editor's HTML from a block's heading (<c>text</c>/<c>nme</c>) before it is cached.</summary>
private static void SanitizeBlockText(JObject block)
{
if (block["text"] != null) block["text"] = HtmlToPlain(Str(block["text"]));
if (block["nme"] != null) block["nme"] = HtmlToPlain(Str(block["nme"]));
}
// ── View state / history ──────────────────────────────────────────────── // ── View state / history ────────────────────────────────────────────────
public object BuildState(InvoiceDraftSession session) public object BuildState(InvoiceDraftSession session)
{ {
@@ -230,6 +289,7 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
private static void Refresh(InvoiceDraftSession session) private static void Refresh(InvoiceDraftSession session)
{ {
InvoiceDraftCalculator.RecomputeTotals(session); InvoiceDraftCalculator.RecomputeTotals(session);
InvoiceDraftCalculator.RecomputePositions(session);
InvoiceDraftCalculator.Validate(session); InvoiceDraftCalculator.Validate(session);
} }
@@ -349,4 +409,31 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
} }
return new JObject(); return new JObject();
} }
/// <summary>
/// Converts the editor's HTML (TinyMCE-wrapped inline edits, e.g. <c>&lt;p&gt;18.06.2026&lt;/p&gt;</c>)
/// to plain text: line-break-producing tags become newlines, remaining tags are stripped and
/// entities decoded. Multi-line fields (address, Leistungsort) keep their line breaks — the PDF
/// splits those on <c>\n</c>/<c>&lt;br&gt;</c> — while single-line fields collapse to one line.
/// Blank lines are removed so a stray <c>&lt;p&gt;&lt;/p&gt;</c> never becomes an empty row.
/// </summary>
internal static string HtmlToPlain(string? raw)
{
if (string.IsNullOrEmpty(raw)) return "";
if (raw.IndexOf('<') < 0 && raw.IndexOf('&') < 0) return raw.Trim();
// Turn line-break / block-close tags into newlines before stripping the rest.
string s = Regex.Replace(raw, @"<\s*br\s*/?\s*>", "\n", RegexOptions.IgnoreCase);
s = Regex.Replace(s, @"</\s*(p|div|li|tr|h[1-6])\s*>", "\n", RegexOptions.IgnoreCase);
var doc = new HtmlAgilityPack.HtmlDocument();
doc.LoadHtml(s);
string text = System.Net.WebUtility.HtmlDecode(doc.DocumentNode.InnerText);
var lines = text.Replace("\r\n", "\n").Replace('\r', '\n')
.Split('\n')
.Select(l => l.Trim())
.Where(l => l.Length > 0);
return string.Join("\n", lines).Trim();
}
} }
+56 -2
View File
@@ -63,10 +63,64 @@ public class InvoiceService : IInvoiceService
inv.InvoiceRegistration = new GenericObjectDictionary(dset.Table("inv").FirstRow.toObjectDictionary()); inv.InvoiceRegistration = new GenericObjectDictionary(dset.Table("inv").FirstRow.toObjectDictionary());
inv.IsDraft = inv.InvoiceRegistration.getItem("IsFinal", false) is not true; inv.IsDraft = inv.InvoiceRegistration.getItem("IsFinal", false) is not true;
_logger.LogDebug("LoadInvoiceAsync loaded id={Id} draft={Draft}", inv.Id, inv.IsDraft); // Reconstruct the service-request blocks + line items so the PDF renders the positions.
// Without this the reloaded/finalized invoice showed an empty item table (only the header
// + totals came from InvoiceRegistration), i.e. it did not match the cached preview.
inv.Req = BuildPdfRequestBlocks(dset);
_logger.LogDebug("LoadInvoiceAsync loaded id={Id} draft={Draft} blocks={Blocks}", inv.Id, inv.IsDraft, inv.Req?.Count ?? 0);
return inv; return inv;
} }
/// <summary>
/// Rebuilds the invoice's service-request groups and line items from the persisted
/// <c>req</c>/<c>itm</c> tables (<c>fds__getInvoice</c>) into the exact block shape the PDF
/// consumes (<see cref="FdsInvoiceData.InvoiceBlocks"/> → item contract
/// <c>type/title/desc/qty/price_net/total_net</c>). Item order follows the persisted
/// <c>SortOrder</c>, so a reordered draft renders in its saved order — making the finalized
/// PDF and the re-downloaded (<c>idoc</c>) document identical to the cached preview.
/// </summary>
private static List<Dictionary<string, object>> BuildPdfRequestBlocks(SQLDataSet dset)
{
var blocks = new List<Dictionary<string, object>>();
if (!dset.Contains("req")) return blocks;
var reqTable = dset.Tables("req");
string reqSort = reqTable.Columns.Contains("order") ? "order" : "";
foreach (DataRow rq in reqTable.Select("", reqSort))
{
var rdic = rq.toObjectDictionary();
var items = new List<Dictionary<string, object?>>();
if (dset.Contains("itm"))
{
var itmTable = dset.Tables("itm");
string itmSort = itmTable.Columns.Contains("order") ? "order" : "";
foreach (DataRow it in itmTable.Select($"[InvRqId] = '{rdic.nz("Id")}'", itmSort))
{
var d = it.toObjectDictionary();
// The persisted "Text" holds the item's rendered HTML (the editor's co.t); it is
// the full title+description, so it maps to the contract's desc (title stays empty).
items.Add(new Dictionary<string, object?>
{
["id"] = d.nz("mfr__item"),
["type"] = d.nz("Type"),
["title"] = "",
["desc"] = d.nz("Text"),
["qty"] = d.nz("Quantity"),
["price_net"] = d.no("value", 0),
["total_net"] = d.no("value_total", 0)
});
}
}
blocks.Add(new Dictionary<string, object>
{
["Id"] = rdic.nz("mfr__servicerequest"),
["text"] = System.Net.WebUtility.HtmlDecode(rdic.nz("title")),
["items"] = items
});
}
return blocks;
}
public async Task<FdsInvoiceData> RegisterInvoiceAsync(FdsInvoiceData invoice, bool change, string invId, public async Task<FdsInvoiceData> RegisterInvoiceAsync(FdsInvoiceData invoice, bool change, string invId,
string userAccountId, DatabaseSecurity dbSec) string userAccountId, DatabaseSecurity dbSec)
{ {
@@ -212,7 +266,7 @@ public class InvoiceService : IInvoiceService
var reg = invoice.InvoiceRegistration; var reg = invoice.InvoiceRegistration;
var tb = new FuchsPdf.FdsTextBlocks var tb = new FuchsPdf.FdsTextBlocks
{ {
AdminRef = reg?.getString("Id") ?? "", AdminRef = (reg?.getString("InvoiceId") ?? "").ne(reg?.getString("Id") ?? ""),
Address = reg?.getString("SendToAddress") is { Length: > 0 } sa Address = reg?.getString("SendToAddress") is { Length: > 0 } sa
? sa.Replace("<br>", "\n").Replace("<br/>", "\n").Split('\n').Select(t => t.Trim()).ToArray() ? sa.Replace("<br>", "\n").Replace("<br/>", "\n").Split('\n').Select(t => t.Trim()).ToArray()
: Array.Empty<string>(), : Array.Empty<string>(),
+31 -2
View File
@@ -19,7 +19,7 @@ public class FdsInvoiceData
public GenericObjectDictionary? Admin { get; private set; } public GenericObjectDictionary? Admin { get; private set; }
public GenericObjectDictionary? NewValues { get; private set; } public GenericObjectDictionary? NewValues { get; private set; }
public GenericObjectDictionary? Sms { get; private set; } public GenericObjectDictionary? Sms { get; private set; }
public List<Dictionary<string, object>>? Req { get; private set; } public List<Dictionary<string, object>>? Req { get; internal set; }
public GenericObjectDictionary? InvoiceRegistration { get; internal set; } public GenericObjectDictionary? InvoiceRegistration { get; internal set; }
public bool IsDraft { get; internal set; } = true; public bool IsDraft { get; internal set; } = true;
@@ -46,9 +46,25 @@ public class FdsInvoiceData
get get
{ {
var result = new List<Dictionary<string, object?>>(); var result = new List<Dictionary<string, object?>>();
foreach (var block in InvoiceBlocks) result.AddRange(block.Items);
return result;
}
}
/// <summary>
/// The service-request groups as they should render on the invoice: each block carries its
/// heading (the section title the editor shows) and its line items. The PDF renders a heading
/// row per block followed by that block's items, so the online editor and the PDF stay in sync.
/// </summary>
public List<InvoiceBlock> InvoiceBlocks
{
get
{
var result = new List<InvoiceBlock>();
if (Req == null) return result; if (Req == null) return result;
foreach (var req in Req) foreach (var req in Req)
{ {
var items = new List<Dictionary<string, object?>>();
if (req.TryGetValue("items", out var itmsObj)) if (req.TryGetValue("items", out var itmsObj))
{ {
IEnumerable<Dictionary<string, object?>>? itms = IEnumerable<Dictionary<string, object?>>? itms =
@@ -56,8 +72,12 @@ public class FdsInvoiceData
?? (itmsObj is JArray ja ?? (itmsObj is JArray ja
? ja.ToObject<List<Dictionary<string, object?>>>() ? ja.ToObject<List<Dictionary<string, object?>>>()
: null); : null);
if (itms != null) result.AddRange(itms); if (itms != null) items.AddRange(itms);
} }
string heading = "";
if (req.TryGetValue("text", out var th) && th != null) heading = th.ToString() ?? "";
if (heading.Length == 0 && req.TryGetValue("nme", out var nh) && nh != null) heading = nh.ToString() ?? "";
result.Add(new InvoiceBlock { Heading = heading, Items = items });
} }
return result; return result;
} }
@@ -196,3 +216,12 @@ public class FdsInvoiceData
return double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var d) ? d : 0; return double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var d) ? d : 0;
} }
} }
/// <summary>A service-request group as it renders on the invoice: a heading plus its line items.</summary>
public sealed class InvoiceBlock
{
/// <summary>The section heading (editor's <c>text</c>/<c>nme</c>); empty when the group has none.</summary>
public string Heading { get; init; } = "";
/// <summary>The group's line items (the editor's <c>items</c> contract).</summary>
public List<Dictionary<string, object?>> Items { get; init; } = new();
}
+52 -9
View File
@@ -137,6 +137,30 @@ public static class FuchsPdf
public static string TranslatePaymentTerm(string pt) => public static string TranslatePaymentTerm(string pt) =>
pt.Replace("wd", " Werktagen").Replace("d", " Tagen").Replace("wk", " Wochen").ne("10 Tagen"); pt.Replace("wd", " Werktagen").Replace("d", " Tagen").Replace("wk", " Wochen").ne("10 Tagen");
/// <summary>
/// Maps one editor item-contract entry to a display line, mirroring the online editor: the
/// item's own price/total is shown, a set header is emphasised, and free-text/heading lines
/// (type <c>text</c>/<c>title</c>) show neither a price nor a position number. This is the flat
/// (non-collapsing) rendering used for every set mode except the explicit <c>setonly</c>.
/// </summary>
private static InvoiceSetLine MapItemToLine(Dictionary<string, object?> i)
{
string type = i.nz("type", "").ToLowerInvariant();
bool isText = type is "text" or "title";
ParseDec(i.no("price_net", 0), out decimal price);
ParseDec(i.no("total_net", 0), out decimal total);
return new InvoiceSetLine
{
Title = i.nz("title", ""),
Desc = i.nz("desc", ""),
Qty = i.nz("qty", ""),
PriceNet = price,
TotalNet = total,
ShowPrice = !isText,
IsSetHeader = type == "set"
};
}
/// <summary> /// <summary>
/// Parses a numeric value coming from JSON deserialization (long/double), SQL /// Parses a numeric value coming from JSON deserialization (long/double), SQL
/// (decimal), or an already-invariant numeric string. Numeric CLR types are /// (decimal), or an already-invariant numeric string. Numeric CLR types are
@@ -593,22 +617,41 @@ public static class FuchsPdf
hRow.Cells[i].Format.Alignment = i >= 2 ? ParagraphAlignment.Right : ParagraphAlignment.Left; hRow.Cells[i].Format.Alignment = i >= 2 ? ParagraphAlignment.Right : ParagraphAlignment.Left;
} }
// Data rows — resolved through the set-display mode (see InvoiceSetPricing). // Data rows — grouped by service-request block (see FdsInvoiceData.InvoiceBlocks).
// For invoices without sets this passes items through unchanged; for sets it // Per the product decision the PDF must mirror the online editor exactly: each section
// emits set header + members per the chosen mode, blanking price cells where // prints its heading, every position shows its own price, and positions are numbered the
// a line should show no price. Totals come from the registration balance, so // same way the editor numbers them (every line except free-text/heading lines, including a
// the mode is purely presentational. // set header). Set-display collapsing is honoured only for the explicit SetOnly mode; every
// other mode renders the items flat, so nothing is silently blanked or renumbered.
var setMode = InvoiceSetPricing.ModeFromInvoiceOptions(inv.InvoiceRegistration?.getString("InvoiceOptions")); var setMode = InvoiceSetPricing.ModeFromInvoiceOptions(inv.InvoiceRegistration?.getString("InvoiceOptions"));
int pos = 1; int pos = 0;
foreach (var line in InvoiceSetPricing.Build(inv.InvoiceItems, setMode)) foreach (var block in inv.InvoiceBlocks)
{ {
if (!string.IsNullOrWhiteSpace(block.Heading))
{
var hr = tbl.AddRow();
hr.HeightRule = RowHeightRule.Auto;
hr.Cells[1].MergeRight = 3; // span Bezeichnung … Gesamtpreis
hr.Cells[1].AddParagraph().WithStyle("TblCell_RTitle").AddFormattedText(block.Heading, TextFormat.Bold);
}
var lines = setMode == SetDisplayMode.SetOnly && InvoiceSetPricing.ContainsSets(block.Items)
? InvoiceSetPricing.Build(block.Items, SetDisplayMode.SetOnly) // only this mode collapses members
: block.Items.Select(MapItemToLine).ToList(); // flat: faithful mirror of the editor
foreach (var line in lines)
{
bool numbered = line.IsSetHeader || line.ShowPrice; // free-text/heading lines carry no number
var row = tbl.AddRow(); var row = tbl.AddRow();
row.HeightRule = RowHeightRule.Auto; row.HeightRule = RowHeightRule.Auto;
row.Cells[0].AddParagraph(pos.ToString()).Style = "TblCell_Base"; row.Cells[0].AddParagraph(numbered ? (++pos).ToString() : "").Style = "TblCell_Base";
if (!string.IsNullOrEmpty(line.Title)) // skip the empty paragraph that added a blank line before free text
{
var titleCell = row.Cells[1].AddParagraph(); var titleCell = row.Cells[1].AddParagraph();
titleCell.Style = "TblCell_RTitle"; titleCell.Style = "TblCell_RTitle";
if (line.IsSetHeader) titleCell.AddFormattedText(line.Title, TextFormat.Bold); if (line.IsSetHeader) titleCell.AddFormattedText(line.Title, TextFormat.Bold);
else titleCell.AddText(line.Title); else titleCell.AddText(line.Title);
}
if (!string.IsNullOrEmpty(line.Desc)) row.Cells[1].AddHtml($"<div>{line.Desc}</div>"); if (!string.IsNullOrEmpty(line.Desc)) row.Cells[1].AddHtml($"<div>{line.Desc}</div>");
row.Cells[2].AddParagraph(line.Qty).Style = "TblCell_Base"; row.Cells[2].AddParagraph(line.Qty).Style = "TblCell_Base";
row.Cells[3].AddParagraph(line.ShowPrice ? Currency(line.PriceNet) : "").Style = "TblCell_Base"; row.Cells[3].AddParagraph(line.ShowPrice ? Currency(line.PriceNet) : "").Style = "TblCell_Base";
@@ -616,7 +659,7 @@ public static class FuchsPdf
row.Cells[2].Format.Alignment = ParagraphAlignment.Right; row.Cells[2].Format.Alignment = ParagraphAlignment.Right;
row.Cells[3].Format.Alignment = ParagraphAlignment.Right; row.Cells[3].Format.Alignment = ParagraphAlignment.Right;
row.Cells[4].Format.Alignment = ParagraphAlignment.Right; row.Cells[4].Format.Alignment = ParagraphAlignment.Right;
pos++; }
} }
// Totals // Totals
+25
View File
@@ -68,6 +68,31 @@ public static class InvoiceDraftCalculator
session.Sums = sums; session.Sums = sums;
} }
/// <summary>
/// Renumbers the visible line positions authoritatively (the port of the client-side
/// numbering in <c>invSumUpdate</c>): priced lines are numbered sequentially across the whole
/// invoice — matching the PDF's <c>Pos.</c> column — while heading/free-text lines
/// (<c>typ</c> = "text"/"title") carry no number. The result is written onto each line's
/// <c>p</c> field so it flows back to the browser (via the view state) and into the PDF; this
/// keeps the online editor and the PDF preview showing the same position numbers, including
/// after a reorder.
/// </summary>
public static void RecomputePositions(InvoiceDraftSession session)
{
int pos = 0;
foreach (var blockTok in session.Req)
{
if (blockTok is not JObject block || block["itm"] is not JArray lines) continue;
foreach (var lineTok in lines)
{
if (lineTok is not JObject co) continue;
string typ = Str(co["typ"]).Trim().ToLowerInvariant();
bool numbered = typ is not ("text" or "title"); // only headings/free-text carry no number (mirrors invSumUpdate)
co["p"] = numbered ? (JToken)(++pos) : (JToken)"";
}
}
}
/// <summary> /// <summary>
/// Refreshes the draft's plausibility / consistency findings. "error" severity marks /// Refreshes the draft's plausibility / consistency findings. "error" severity marks
/// issues that should block a clean finalise; "warning" is advisory. Kept in German, /// issues that should block a clean finalise; "warning" is advisory. Kept in German,
+32 -7
View File
@@ -130,7 +130,7 @@ $inv.d = {
let l = $inv.d.layout(); l.aC('freeze'); let l = $inv.d.layout(); l.aC('freeze');
$ocms.postXT({ $ocms.postXT({
url: $ocms.url('inv/dopen'), data: { payload: JSON.stringify(payload) }, success: (r) => { 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()); $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, { $fis.draft.bind(r.token, {
onReady: () => $inv.d.refresh(), onReady: () => $inv.d.refresh(),
onExpiring: (s) => $inv.d.warnExpiry(s), onExpiring: (s) => $inv.d.warnExpiry(s),
@@ -155,6 +155,17 @@ $inv.d = {
tbl.data('dver', state.version).data('serverSums', state.sums); tbl.data('dver', state.version).data('serverSums', state.sums);
$inv.d.footer(tbl, state.sums || {}, state.admin || {}); $inv.d.footer(tbl, state.sums || {}, state.admin || {});
$inv.d.validation(state.validation || []); $inv.d.validation(state.validation || []);
$inv.d.applyPositions(tbl, state.req || []);
},
/* 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 : ''); }
}));
}, },
/* 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. */
sync: function (delta) { sync: function (delta) {
@@ -166,16 +177,23 @@ $inv.d = {
error: (xhr) => { $inv.d.layout().rC('freeze'); if (xhr && xhr.status === 410) { $inv.d.closed('expired'); } } error: (xhr) => { $inv.d.layout().rC('freeze'); if (xhr && xhr.status === 410) { $inv.d.closed('expired'); } }
}); });
}, },
/* The current section id sequence (used to detect a reorder that changes no block content). */
order: function () { return (($inv.d.tbl().data('bai')) || []).map((b) => (b.Id || '').toString()); },
/* Diff the freshly-rebuilt blocks against the last-synced state and send only the /* Diff the freshly-rebuilt blocks against the last-synced state and send only the
changed/removed blocks as granular block.replace / block.remove deltas. */ 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. */
syncChanged: function (tbl) { syncChanged: function (tbl) {
if (($inv.d.token()) === '') { return; } 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 = [];
$.each(bai, (i, b) => { let id = (b.Id || '').toString(), h = JSON.stringify(b); next[id] = h; if (prev[id] !== h) { changed.push(b); } }); $.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); } }); $.each(prev, (id) => { if (next[id] === undefined) { removed.push(id); } });
tbl.data('dhashes', next); 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 })); 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 })); removed.forEach((id) => $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 }); }
}, },
/* Map an inline recipient field to its delta target and send it. */ /* Map an inline recipient field to its delta target and send it. */
syncField: function (nme, val) { syncField: function (nme, val) {
@@ -672,9 +690,12 @@ $inv.cSt = function (data) {
}; };
$inv.eHtml = function (ev) { $inv.eHtml = function (ev) {
let t = $(this), frmct = ev.data instanceof jQuery ? ev.data : ev.data.t; let t = $(this), frmct = ev.data instanceof jQuery ? ev.data : ev.data.t;
/* invoiceemail must stay plain text — using the TinyMCE/html editor here used to wrap the /* Single-line fields must stay plain text — the TinyMCE/html editor wraps the value in <p>
address in <p> tags, which then got posted and persisted verbatim into SendToEmail. */ tags, which used to get posted and persisted verbatim (e.g. <p>18.06.2026</p> in the
let isPlainText = ev.data.nme === 'invoiceemail'; Leistungsdatum). The backend sanitises HTML too (single source of truth, ADR 0006), but
keeping these plain here avoids the UI briefly holding the wrapped value. Multi-line fields
(invoiceaddress, loc) stay HTML-capable and are normalised to newlines server-side. */
let isPlainText = ['invoiceemail', 'provisionperiod', 'invoicetitle'].includes(ev.data.nme);
let flds = isPlainText let flds = isPlainText
? [{ name: 'txt', label: 'Text', type: 'text', value: frmct.text() }] ? [{ name: 'txt', label: 'Text', type: 'text', value: frmct.text() }]
: [{ name: 'txt', label: 'Text', type: 'html', value: frmct.html(), tinymce: true, attr: { style: 'height: 300px' } }]; : [{ name: 'txt', label: 'Text', type: 'html', value: frmct.html(), tinymce: true, attr: { style: 'height: 300px' } }];
@@ -777,7 +798,11 @@ $inv.eRw = function(row, dta, flds) {
}, typedvalues: true }, typedvalues: true
}); });
}; };
$inv.bdysort = (i, e) => { $(e).Sortable({ dragItem: false, dragHandleClass: 'ico', parentident: 'tr', swapdone: (p1, p2, i1, i2) => { $inv.t_fds_inv(); } }) } /* Reorder items via drag. The DOM swap happens inside the Sortable during the drag; we commit
the new order once, reliably, on drop (onend) — that recomputes positions/totals and pushes the
changed block(s) to the backend session (t_fds_inv -> syncChanged). Committing on drop (rather
than on every mid-drag hover-swap) avoids rebuilding the row that is currently being dragged. */
$inv.bdysort = (i, e) => { $(e).Sortable({ dragItem: false, dragHandleClass: 'ico', parentident: 'tr', onend: () => { $inv.t_fds_inv(); } }) }
$inv.rrw = function () { $inv.rrw = function () {
let rw = $(this), dta = rw.data(), co = {}, ph = rw.is('.placeholder'), hn = rw.is('.hidenote'); let rw = $(this), dta = rw.data(), co = {}, ph = rw.is('.placeholder'), hn = rw.is('.hidenote');
let oHtml = (e) => $$.d().append(e).html(); let oHtml = (e) => $$.d().append(e).html();
+9 -1
View File
@@ -63,7 +63,8 @@ if (!Element.prototype.closest) {
this._dragging = false; this._dragging = false;
this._dragHandleClass = this._options.dragHandleClass || ''; this._dragHandleClass = this._options.dragHandleClass || '';
this._parentident = this._options.parentident || ''; this._parentident = this._options.parentident || '';
this._swapdone = typeof this._options.swapdone === "function" ? this._options._swapdone : null; this._swapdone = typeof this._options.swapdone === "function" ? this._options.swapdone : null;
this._onend = typeof this._options.onend === "function" ? this._options.onend : null;
this._container.setAttribute("data-is-sortable", 1); this._container.setAttribute("data-is-sortable", 1);
this._container.classList.add("sortable"); this._container.classList.add("sortable");
@@ -215,8 +216,15 @@ if (!Element.prototype.closest) {
// on item release/drop // on item release/drop
_onRelease: function (e) { _onRelease: function (e) {
// Was THIS list mid-drag? (mouseup fires on every instance's window listener.)
var wasDragging = this._dragging === true && this._clickItem !== null;
this._dragging = false; this._dragging = false;
this._trashDragItem(); this._trashDragItem();
// Fire a single "drag finished" callback so callers can commit the new order once,
// reliably, on drop — rather than relying on the per-hover _swapdone during the drag.
if (wasDragging && typeof this._onend === 'function') {
this._onend();
}
}, },
// on item drag/move // on item drag/move
+32 -7
View File
@@ -677,7 +677,7 @@ $inv.d = {
let l = $inv.d.layout(); l.aC('freeze'); let l = $inv.d.layout(); l.aC('freeze');
$ocms.postXT({ $ocms.postXT({
url: $ocms.url('inv/dopen'), data: { payload: JSON.stringify(payload) }, success: (r) => { 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()); $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, { $fis.draft.bind(r.token, {
onReady: () => $inv.d.refresh(), onReady: () => $inv.d.refresh(),
onExpiring: (s) => $inv.d.warnExpiry(s), onExpiring: (s) => $inv.d.warnExpiry(s),
@@ -702,6 +702,17 @@ $inv.d = {
tbl.data('dver', state.version).data('serverSums', state.sums); tbl.data('dver', state.version).data('serverSums', state.sums);
$inv.d.footer(tbl, state.sums || {}, state.admin || {}); $inv.d.footer(tbl, state.sums || {}, state.admin || {});
$inv.d.validation(state.validation || []); $inv.d.validation(state.validation || []);
$inv.d.applyPositions(tbl, state.req || []);
},
/* 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 : ''); }
}));
}, },
/* 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. */
sync: function (delta) { sync: function (delta) {
@@ -713,16 +724,23 @@ $inv.d = {
error: (xhr) => { $inv.d.layout().rC('freeze'); if (xhr && xhr.status === 410) { $inv.d.closed('expired'); } } error: (xhr) => { $inv.d.layout().rC('freeze'); if (xhr && xhr.status === 410) { $inv.d.closed('expired'); } }
}); });
}, },
/* The current section id sequence (used to detect a reorder that changes no block content). */
order: function () { return (($inv.d.tbl().data('bai')) || []).map((b) => (b.Id || '').toString()); },
/* Diff the freshly-rebuilt blocks against the last-synced state and send only the /* Diff the freshly-rebuilt blocks against the last-synced state and send only the
changed/removed blocks as granular block.replace / block.remove deltas. */ 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. */
syncChanged: function (tbl) { syncChanged: function (tbl) {
if (($inv.d.token()) === '') { return; } 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 = [];
$.each(bai, (i, b) => { let id = (b.Id || '').toString(), h = JSON.stringify(b); next[id] = h; if (prev[id] !== h) { changed.push(b); } }); $.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); } }); $.each(prev, (id) => { if (next[id] === undefined) { removed.push(id); } });
tbl.data('dhashes', next); 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 })); 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 })); removed.forEach((id) => $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 }); }
}, },
/* Map an inline recipient field to its delta target and send it. */ /* Map an inline recipient field to its delta target and send it. */
syncField: function (nme, val) { syncField: function (nme, val) {
@@ -1219,9 +1237,12 @@ $inv.cSt = function (data) {
}; };
$inv.eHtml = function (ev) { $inv.eHtml = function (ev) {
let t = $(this), frmct = ev.data instanceof jQuery ? ev.data : ev.data.t; let t = $(this), frmct = ev.data instanceof jQuery ? ev.data : ev.data.t;
/* invoiceemail must stay plain text — using the TinyMCE/html editor here used to wrap the /* Single-line fields must stay plain text — the TinyMCE/html editor wraps the value in <p>
address in <p> tags, which then got posted and persisted verbatim into SendToEmail. */ tags, which used to get posted and persisted verbatim (e.g. <p>18.06.2026</p> in the
let isPlainText = ev.data.nme === 'invoiceemail'; Leistungsdatum). The backend sanitises HTML too (single source of truth, ADR 0006), but
keeping these plain here avoids the UI briefly holding the wrapped value. Multi-line fields
(invoiceaddress, loc) stay HTML-capable and are normalised to newlines server-side. */
let isPlainText = ['invoiceemail', 'provisionperiod', 'invoicetitle'].includes(ev.data.nme);
let flds = isPlainText let flds = isPlainText
? [{ name: 'txt', label: 'Text', type: 'text', value: frmct.text() }] ? [{ name: 'txt', label: 'Text', type: 'text', value: frmct.text() }]
: [{ name: 'txt', label: 'Text', type: 'html', value: frmct.html(), tinymce: true, attr: { style: 'height: 300px' } }]; : [{ name: 'txt', label: 'Text', type: 'html', value: frmct.html(), tinymce: true, attr: { style: 'height: 300px' } }];
@@ -1324,7 +1345,11 @@ $inv.eRw = function(row, dta, flds) {
}, typedvalues: true }, typedvalues: true
}); });
}; };
$inv.bdysort = (i, e) => { $(e).Sortable({ dragItem: false, dragHandleClass: 'ico', parentident: 'tr', swapdone: (p1, p2, i1, i2) => { $inv.t_fds_inv(); } }) } /* Reorder items via drag. The DOM swap happens inside the Sortable during the drag; we commit
the new order once, reliably, on drop (onend) — that recomputes positions/totals and pushes the
changed block(s) to the backend session (t_fds_inv -> syncChanged). Committing on drop (rather
than on every mid-drag hover-swap) avoids rebuilding the row that is currently being dragged. */
$inv.bdysort = (i, e) => { $(e).Sortable({ dragItem: false, dragHandleClass: 'ico', parentident: 'tr', onend: () => { $inv.t_fds_inv(); } }) }
$inv.rrw = function () { $inv.rrw = function () {
let rw = $(this), dta = rw.data(), co = {}, ph = rw.is('.placeholder'), hn = rw.is('.hidenote'); let rw = $(this), dta = rw.data(), co = {}, ph = rw.is('.placeholder'), hn = rw.is('.hidenote');
let oHtml = (e) => $$.d().append(e).html(); let oHtml = (e) => $$.d().append(e).html();
File diff suppressed because one or more lines are too long
+9 -1
View File
@@ -2533,7 +2533,8 @@ if (!Element.prototype.closest) {
this._dragging = false; this._dragging = false;
this._dragHandleClass = this._options.dragHandleClass || ''; this._dragHandleClass = this._options.dragHandleClass || '';
this._parentident = this._options.parentident || ''; this._parentident = this._options.parentident || '';
this._swapdone = typeof this._options.swapdone === "function" ? this._options._swapdone : null; this._swapdone = typeof this._options.swapdone === "function" ? this._options.swapdone : null;
this._onend = typeof this._options.onend === "function" ? this._options.onend : null;
this._container.setAttribute("data-is-sortable", 1); this._container.setAttribute("data-is-sortable", 1);
this._container.classList.add("sortable"); this._container.classList.add("sortable");
@@ -2685,8 +2686,15 @@ if (!Element.prototype.closest) {
// on item release/drop // on item release/drop
_onRelease: function (e) { _onRelease: function (e) {
// Was THIS list mid-drag? (mouseup fires on every instance's window listener.)
var wasDragging = this._dragging === true && this._clickItem !== null;
this._dragging = false; this._dragging = false;
this._trashDragItem(); this._trashDragItem();
// Fire a single "drag finished" callback so callers can commit the new order once,
// reliably, on drop — rather than relying on the per-hover _swapdone during the drag.
if (wasDragging && typeof this._onend === 'function') {
this._onend();
}
}, },
// on item drag/move // on item drag/move
+2 -2
View File
File diff suppressed because one or more lines are too long
+32 -7
View File
@@ -658,7 +658,7 @@ $inv.d = {
let l = $inv.d.layout(); l.aC('freeze'); let l = $inv.d.layout(); l.aC('freeze');
$ocms.postXT({ $ocms.postXT({
url: $ocms.url('inv/dopen'), data: { payload: JSON.stringify(payload) }, success: (r) => { 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()); $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, { $fis.draft.bind(r.token, {
onReady: () => $inv.d.refresh(), onReady: () => $inv.d.refresh(),
onExpiring: (s) => $inv.d.warnExpiry(s), onExpiring: (s) => $inv.d.warnExpiry(s),
@@ -683,6 +683,17 @@ $inv.d = {
tbl.data('dver', state.version).data('serverSums', state.sums); tbl.data('dver', state.version).data('serverSums', state.sums);
$inv.d.footer(tbl, state.sums || {}, state.admin || {}); $inv.d.footer(tbl, state.sums || {}, state.admin || {});
$inv.d.validation(state.validation || []); $inv.d.validation(state.validation || []);
$inv.d.applyPositions(tbl, state.req || []);
},
/* 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 : ''); }
}));
}, },
/* 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. */
sync: function (delta) { sync: function (delta) {
@@ -694,16 +705,23 @@ $inv.d = {
error: (xhr) => { $inv.d.layout().rC('freeze'); if (xhr && xhr.status === 410) { $inv.d.closed('expired'); } } error: (xhr) => { $inv.d.layout().rC('freeze'); if (xhr && xhr.status === 410) { $inv.d.closed('expired'); } }
}); });
}, },
/* The current section id sequence (used to detect a reorder that changes no block content). */
order: function () { return (($inv.d.tbl().data('bai')) || []).map((b) => (b.Id || '').toString()); },
/* Diff the freshly-rebuilt blocks against the last-synced state and send only the /* Diff the freshly-rebuilt blocks against the last-synced state and send only the
changed/removed blocks as granular block.replace / block.remove deltas. */ 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. */
syncChanged: function (tbl) { syncChanged: function (tbl) {
if (($inv.d.token()) === '') { return; } 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 = [];
$.each(bai, (i, b) => { let id = (b.Id || '').toString(), h = JSON.stringify(b); next[id] = h; if (prev[id] !== h) { changed.push(b); } }); $.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); } }); $.each(prev, (id) => { if (next[id] === undefined) { removed.push(id); } });
tbl.data('dhashes', next); 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 })); 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 })); removed.forEach((id) => $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 }); }
}, },
/* Map an inline recipient field to its delta target and send it. */ /* Map an inline recipient field to its delta target and send it. */
syncField: function (nme, val) { syncField: function (nme, val) {
@@ -1200,9 +1218,12 @@ $inv.cSt = function (data) {
}; };
$inv.eHtml = function (ev) { $inv.eHtml = function (ev) {
let t = $(this), frmct = ev.data instanceof jQuery ? ev.data : ev.data.t; let t = $(this), frmct = ev.data instanceof jQuery ? ev.data : ev.data.t;
/* invoiceemail must stay plain text using the TinyMCE/html editor here used to wrap the /* Single-line fields must stay plain text the TinyMCE/html editor wraps the value in <p>
address in <p> tags, which then got posted and persisted verbatim into SendToEmail. */ tags, which used to get posted and persisted verbatim (e.g. <p>18.06.2026</p> in the
let isPlainText = ev.data.nme === 'invoiceemail'; Leistungsdatum). The backend sanitises HTML too (single source of truth, ADR 0006), but
keeping these plain here avoids the UI briefly holding the wrapped value. Multi-line fields
(invoiceaddress, loc) stay HTML-capable and are normalised to newlines server-side. */
let isPlainText = ['invoiceemail', 'provisionperiod', 'invoicetitle'].includes(ev.data.nme);
let flds = isPlainText let flds = isPlainText
? [{ name: 'txt', label: 'Text', type: 'text', value: frmct.text() }] ? [{ name: 'txt', label: 'Text', type: 'text', value: frmct.text() }]
: [{ name: 'txt', label: 'Text', type: 'html', value: frmct.html(), tinymce: true, attr: { style: 'height: 300px' } }]; : [{ name: 'txt', label: 'Text', type: 'html', value: frmct.html(), tinymce: true, attr: { style: 'height: 300px' } }];
@@ -1305,7 +1326,11 @@ $inv.eRw = function(row, dta, flds) {
}, typedvalues: true }, typedvalues: true
}); });
}; };
$inv.bdysort = (i, e) => { $(e).Sortable({ dragItem: false, dragHandleClass: 'ico', parentident: 'tr', swapdone: (p1, p2, i1, i2) => { $inv.t_fds_inv(); } }) } /* Reorder items via drag. The DOM swap happens inside the Sortable during the drag; we commit
the new order once, reliably, on drop (onend) that recomputes positions/totals and pushes the
changed block(s) to the backend session (t_fds_inv -> syncChanged). Committing on drop (rather
than on every mid-drag hover-swap) avoids rebuilding the row that is currently being dragged. */
$inv.bdysort = (i, e) => { $(e).Sortable({ dragItem: false, dragHandleClass: 'ico', parentident: 'tr', onend: () => { $inv.t_fds_inv(); } }) }
$inv.rrw = function () { $inv.rrw = function () {
let rw = $(this), dta = rw.data(), co = {}, ph = rw.is('.placeholder'), hn = rw.is('.hidenote'); let rw = $(this), dta = rw.data(), co = {}, ph = rw.is('.placeholder'), hn = rw.is('.hidenote');
let oHtml = (e) => $$.d().append(e).html(); let oHtml = (e) => $$.d().append(e).html();
File diff suppressed because one or more lines are too long