Add backend-authoritative invoice draft editing (ADR 0006/0007) #1
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 & 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 });
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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><p>…</p></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><p>18.06.2026</p></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><br></c> — while single-line fields collapse to one line.
|
||||||
|
/// Blank lines are removed so a stray <c><p></p></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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>(),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|||||||
+65
-22
@@ -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,30 +617,49 @@ 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)
|
||||||
{
|
{
|
||||||
var row = tbl.AddRow();
|
if (!string.IsNullOrWhiteSpace(block.Heading))
|
||||||
row.HeightRule = RowHeightRule.Auto;
|
{
|
||||||
row.Cells[0].AddParagraph(pos.ToString()).Style = "TblCell_Base";
|
var hr = tbl.AddRow();
|
||||||
var titleCell = row.Cells[1].AddParagraph();
|
hr.HeightRule = RowHeightRule.Auto;
|
||||||
titleCell.Style = "TblCell_RTitle";
|
hr.Cells[1].MergeRight = 3; // span Bezeichnung … Gesamtpreis
|
||||||
if (line.IsSetHeader) titleCell.AddFormattedText(line.Title, TextFormat.Bold);
|
hr.Cells[1].AddParagraph().WithStyle("TblCell_RTitle").AddFormattedText(block.Heading, TextFormat.Bold);
|
||||||
else titleCell.AddText(line.Title);
|
}
|
||||||
if (!string.IsNullOrEmpty(line.Desc)) row.Cells[1].AddHtml($"<div>{line.Desc}</div>");
|
|
||||||
row.Cells[2].AddParagraph(line.Qty).Style = "TblCell_Base";
|
var lines = setMode == SetDisplayMode.SetOnly && InvoiceSetPricing.ContainsSets(block.Items)
|
||||||
row.Cells[3].AddParagraph(line.ShowPrice ? Currency(line.PriceNet) : "").Style = "TblCell_Base";
|
? InvoiceSetPricing.Build(block.Items, SetDisplayMode.SetOnly) // only this mode collapses members
|
||||||
row.Cells[4].AddParagraph(line.ShowPrice ? Currency(line.TotalNet) : "").Style = "TblCell_RSum";
|
: block.Items.Select(MapItemToLine).ToList(); // flat: faithful mirror of the editor
|
||||||
row.Cells[2].Format.Alignment = ParagraphAlignment.Right;
|
|
||||||
row.Cells[3].Format.Alignment = ParagraphAlignment.Right;
|
foreach (var line in lines)
|
||||||
row.Cells[4].Format.Alignment = ParagraphAlignment.Right;
|
{
|
||||||
pos++;
|
bool numbered = line.IsSetHeader || line.ShowPrice; // free-text/heading lines carry no number
|
||||||
|
var row = tbl.AddRow();
|
||||||
|
row.HeightRule = RowHeightRule.Auto;
|
||||||
|
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();
|
||||||
|
titleCell.Style = "TblCell_RTitle";
|
||||||
|
if (line.IsSetHeader) titleCell.AddFormattedText(line.Title, TextFormat.Bold);
|
||||||
|
else titleCell.AddText(line.Title);
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrEmpty(line.Desc)) row.Cells[1].AddHtml($"<div>{line.Desc}</div>");
|
||||||
|
row.Cells[2].AddParagraph(line.Qty).Style = "TblCell_Base";
|
||||||
|
row.Cells[3].AddParagraph(line.ShowPrice ? Currency(line.PriceNet) : "").Style = "TblCell_Base";
|
||||||
|
row.Cells[4].AddParagraph(line.ShowPrice ? Currency(line.TotalNet) : "").Style = "TblCell_RSum";
|
||||||
|
row.Cells[2].Format.Alignment = ParagraphAlignment.Right;
|
||||||
|
row.Cells[3].Format.Alignment = ParagraphAlignment.Right;
|
||||||
|
row.Cells[4].Format.Alignment = ParagraphAlignment.Right;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Totals
|
// Totals
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -213,10 +214,17 @@ 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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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");
|
||||||
@@ -2683,10 +2684,17 @@ 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
|
||||||
|
|||||||
Vendored
+2
-2
File diff suppressed because one or more lines are too long
@@ -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();
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user