Add user-defined invoice set structure dialog (ADR 0016)
Playwright Tests / test (push) Canceled after 0s

Lets a back-office user correct/define which invoice line belongs to
which "set" and how deep it is nested, instead of relying only on the
implicit "everything after a Set header until the next Set" grouping
fds__prepInvoice derives. Sets may contain sets; every change applies
immediately to the cached draft session and is reflected in the editor
and PDF (ADR 0006/0008).

Backend:
- InvoiceSetStructure: pure parent-link forest (assign, change level,
  create, place/move-sibling, depth-first linearise + level recompute).
- InvoiceDraftSetStructureDeltas: new inv/dpatch targets set.assign,
  set.level, set.create, set.move.
- InvoiceSetPricing.Build is now order-preserving and nesting-aware
  (governed by the nearest converted ancestor); the set-item switch
  sums a header's transitive descendants so nested conversions never
  double-count.
- FuchsPdf indents nested set members per level, mirroring the editor.

Frontend:
- New fis.inv_sets.js: "Set-Struktur bearbeiten" dialog — per-block
  table of all lines with drag & drop reorder (before/after/into),
  ▲▼ order and ◀▶ level buttons, fold/unfold per set (view-only), and
  a create bar for new Set/Text lines. Posts one delta per action; the
  dialog is a pure view of the server state (ADR 0006).
- _Layout.cshtml / fis_main.js: cache-bust the dynamically imported
  per-module bundles (web/fis.<mdl>.<locale>.js/.css) with an asset
  version derived from the newest bundle write time, so a gulp rebuild
  is picked up without a stale cached module.

Docs: ADR 0016, live-draft-editing concept, INVOICE_SET_PRICING.md,
USER_GUIDE.md, ARCHITECTURE.md updated. Tests: InvoiceSetStructureTests
(new) + additions to InvoiceDraftServiceTests/InvoiceSetPricingTests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 08:55:05 +02:00
co-authored by Claude Sonnet 5
parent c1936a327e
commit ecc74cf3c6
31 changed files with 3003 additions and 147 deletions
+337
View File
@@ -870,4 +870,341 @@ public class InvoiceDraftServiceTests
Assert.Equal(4, final.History.Count); Assert.Equal(4, final.History.Count);
Assert.DoesNotContain(final.ValidationMessages, m => m.Severity == "error"); Assert.DoesNotContain(final.ValidationMessages, m => m.Severity == "error");
} }
// ── User-defined set structure (ADR 0016): set.assign / set.level / set.create ───────────
// A(set) > B(set) > m1, m2 ; A > m3 ; x standalone. Delivered linearised, like fds__prepInvoice + Linearize would.
private static JObject NestedSetPayload() => JObject.Parse(@"{
'admin':{'p13b':false,'type':'r','paymentterms':'10wd'},
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','invoicetitle':'Rechnung'},
'req':[{'Id':'1','text':'Auftrag','itm':[
{'id':'A','typ':'set','vt':0,'vv':0,'vs':0,'vsv':0,'vat':'19%','SetItmId':'A'},
{'id':'B','typ':'set','vt':0,'vv':0,'vs':0,'vsv':0,'vat':'19%','SetItmId':'A'},
{'id':'m1','typ':'material','vt':60,'vv':11.4,'vs':0,'vsv':0,'vat':'19%','SetItmId':'B'},
{'id':'m2','typ':'material','vt':40,'vv':7.6,'vs':10,'vsv':1.9,'vat':'19%','SetItmId':'B'},
{'id':'m3','typ':'material','vt':25,'vv':4.75,'vs':0,'vsv':0,'vat':'19%','SetItmId':'A'},
{'id':'x','typ':'material','vt':10,'vv':1.9,'vs':0,'vsv':0,'vat':'19%','SetItmId':null}],
'items':[{'id':'A','type':'set','total_net':0},{'id':'B','type':'set','total_net':0},
{'id':'m1','type':'material','total_net':60,'vat':'19%'},{'id':'m2','type':'material','total_net':40,'vat':'19%'},
{'id':'m3','type':'material','total_net':25,'vat':'19%'},{'id':'x','type':'material','total_net':10,'vat':'19%'}]}]
}");
private static string[] LineOrder(InvoiceDraftSession s, int block = 0) =>
((JArray)((JObject)s.Req[block])["itm"]!).OfType<JObject>().Select(l => (string)l["id"]!).ToArray();
private static JObject LineOf(InvoiceDraftSession s, string id, int block = 0) =>
((JArray)((JObject)s.Req[block])["itm"]!).OfType<JObject>().Single(l => (string)l["id"]! == id);
private static JObject ItemOf(InvoiceDraftSession s, string id, int block = 0) =>
((JArray)((JObject)s.Req[block])["items"]!).OfType<JObject>().Single(l => (string)l["id"]! == id);
[Fact]
public void OpenFromPayload_NestedSets_PublishesLevelsAndMirrorsSetIdOntoItems()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
Assert.Equal(0, LineOf(s, "A")["lvl"]!.Value<int>());
Assert.Equal(1, LineOf(s, "B")["lvl"]!.Value<int>());
Assert.Equal(2, LineOf(s, "m1")["lvl"]!.Value<int>());
Assert.Equal(1, LineOf(s, "m3")["lvl"]!.Value<int>());
Assert.Equal(0, LineOf(s, "x")["lvl"]!.Value<int>());
Assert.Equal("A", (string)ItemOf(s, "B")["setId"]!); // a nested set is a member of its parent
Assert.Equal("B", (string)ItemOf(s, "m1")["setId"]!);
Assert.Equal(JTokenType.Null, ItemOf(s, "A")["setId"]!.Type); // self-reference -> no parent
Assert.Equal(2, ItemOf(s, "m1")["level"]!.Value<int>());
Assert.Equal(135m, s.Sums.TotalNet);
}
[Fact]
public void ApplyPatch_SetAssign_DropOntoSet_BecomesLastMember_BumpsVersionRecordsHistory()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "set.assign", Ref = "x", Value = JObject.Parse("{'parent':'A'}") });
Assert.NotNull(s2);
Assert.Equal(1, s2!.Version);
Assert.Equal(new[] { "A", "B", "m1", "m2", "m3", "x" }, LineOrder(s2));
Assert.Equal(1, LineOf(s2, "x")["lvl"]!.Value<int>());
Assert.Equal("A", (string)LineOf(s2, "x")["SetItmId"]!);
Assert.Equal("A", (string)ItemOf(s2, "x")["setId"]!);
Assert.Equal(135m, s2.Sums.TotalNet); // structure never changes the total
var h = Assert.Single(s2.History);
Assert.Equal("set.assign", h.Target); Assert.Equal("x", h.Ref); Assert.Equal("", h.OldValue); Assert.Equal("A", h.NewValue);
Assert.Equal("1.3", (string)LineOf(s2, "x")["p"]!); // positions renumbered along the new order (hierarchical, ADR 0017)
}
[Fact]
public void ApplyPatch_SetAssign_PullSetOut_ItsMembersFollowOneLevelUp()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "set.assign", Ref = "B", Value = JToken.FromObject("") });
Assert.Equal(new[] { "A", "m3", "B", "m1", "m2", "x" }, LineOrder(s2!));
Assert.Equal(0, LineOf(s2!, "B")["lvl"]!.Value<int>());
Assert.Equal(1, LineOf(s2!, "m2")["lvl"]!.Value<int>());
Assert.Equal("B", (string)LineOf(s2!, "m2")["SetItmId"]!);
Assert.Equal("A", s2!.History[0].OldValue);
}
[Theory]
[InlineData("A", "B")] // onto its own descendant — cycle
[InlineData("x", "m3")] // onto a non-set line
[InlineData("m1", "B")] // unchanged
[InlineData("nope", "A")] // unknown item
[InlineData("", "A")] // missing ref
public void ApplyPatch_SetAssign_Invalid_IsNoOp_NoVersionBumpNoHistory(string item, string parent)
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
var before = LineOrder(s);
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "set.assign", Ref = item, Value = JObject.Parse("{'parent':'" + parent + "'}") });
Assert.Equal(0, s2!.Version);
Assert.Empty(s2.History);
Assert.Equal(before, LineOrder(s2));
}
[Theory]
[InlineData("m3", 1, "1", "2", new[] { "A", "B", "m1", "m2", "m3", "x" })] // indent: joins preceding sibling set B
[InlineData("m1", -1, "2", "1", new[] { "A", "B", "m2", "m1", "m3", "x" })] // outdent: leaves B, placed after B's subtree
[InlineData("B", -1, "1", "0", new[] { "A", "m3", "B", "m1", "m2", "x" })] // outdent a set: members travel with it
public void ApplyPatch_SetLevel_IndentOutdent_ReordersAndRecordsLevels(string item, int delta, string oldLvl, string newLvl, string[] order)
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "set.level", Ref = item, Value = JToken.FromObject(delta) });
Assert.Equal(1, s2!.Version);
Assert.Equal(order, LineOrder(s2));
var h = Assert.Single(s2.History);
Assert.Equal("set.level", h.Target); Assert.Equal(oldLvl, h.OldValue); Assert.Equal(newLvl, h.NewValue);
Assert.Equal(int.Parse(newLvl), LineOf(s2, item)["lvl"]!.Value<int>());
Assert.Equal(135m, s2.Sums.TotalNet);
}
[Theory]
[InlineData("x", "-1")] // already top level
[InlineData("A", "in")] // nothing precedes the first line
[InlineData("m1", "0")] // no direction
[InlineData("m1", "abc")] // unparseable
public void ApplyPatch_SetLevel_NoMove_IsNoOp(string item, string value)
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
var before = LineOrder(s);
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "set.level", Ref = item, Value = JToken.FromObject(value) });
Assert.Equal(0, s2!.Version);
Assert.Equal(before, LineOrder(s2));
}
[Fact]
public void ApplyPatch_SetCreate_Set_InsideParent_AppendsAsLastMember_UnpricedAndNumbered()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "set.create", Ref = "1", Value = JObject.Parse("{'typ':'set','title':'Unterset','parent':'B'}") });
Assert.Equal(1, s2!.Version);
var order = LineOrder(s2);
Assert.Equal(7, order.Length);
string id = order[4]; // A, B, m1, m2, NEW, m3, x
Assert.StartsWith("sN", id);
Assert.Equal(new[] { "A", "B", "m1", "m2", id, "m3", "x" }, order);
var co = LineOf(s2, id);
Assert.Equal("set", (string)co["typ"]!);
Assert.Equal("Unterset", (string)co["t"]!);
Assert.Equal(0m, co["vt"]!.Value<decimal>()); // unconverted — the "Auf Setpreis umstellen" switch applies later
Assert.Equal(2, co["lvl"]!.Value<int>());
Assert.Equal("1.1.3", (string)co["p"]!); // a set header is numbered, hierarchically (ADR 0017)
Assert.Equal("set", (string)ItemOf(s2, id)["type"]!);
Assert.Equal("B", (string)ItemOf(s2, id)["setId"]!);
Assert.Equal(135m, s2.Sums.TotalNet);
var h = Assert.Single(s2.History);
Assert.Equal("set.create", h.Target); Assert.Equal("1", h.Ref); Assert.Equal("set:Unterset", h.NewValue);
}
[Fact]
public void ApplyPatch_SetCreate_Text_TopLevel_IsUnnumberedFreeText()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "set.create", Ref = "1", Value = JObject.Parse("{'typ':'text','title':'Hinweis','parent':''}") });
string id = LineOrder(s2!).Last();
var co = LineOf(s2!, id);
Assert.Equal("Text", (string)co["typ"]!);
Assert.Equal("<p>Hinweis</p>", (string)co["t"]!);
Assert.Equal("", (string)co["p"]!); // free text carries no position number
Assert.Equal(0, co["lvl"]!.Value<int>());
Assert.Equal("text", (string)ItemOf(s2!, id)["type"]!);
Assert.Equal("<p>Hinweis</p>", (string)ItemOf(s2!, id)["desc"]!);
}
[Theory]
[InlineData("1", "{'typ':'material','title':'Neu','parent':''}")] // unsupported type
[InlineData("1", "{'typ':'set','title':' ','parent':''}")] // blank title
[InlineData("1", "{'typ':'set','title':'Neu','parent':'m3'}")] // parent is not a set
[InlineData("99", "{'typ':'set','title':'Neu','parent':''}")] // unknown block
[InlineData("1", "'not an object'")] // wrong value shape
public void ApplyPatch_SetCreate_Invalid_IsNoOp(string blockRef, string valueJson)
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "set.create", Ref = blockRef, Value = JToken.Parse(valueJson) });
Assert.Equal(0, s2!.Version);
Assert.Equal(6, LineOrder(s2).Length);
Assert.Empty(s2.History);
}
[Fact]
public void ApplyPatch_ItemSetPrice_NestedSet_SumsTransitiveDescendants_NullsAll_TotalUnchanged()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.setprice", Ref = "A" });
var a = LineOf(s2!, "A");
Assert.Equal(125m, a["vt"]!.Value<decimal>()); // 60 + 40 + 25 (B itself contributed 0 — unconverted)
Assert.Equal(23.75m, a["vv"]!.Value<decimal>());
Assert.Equal(10m, a["vs"]!.Value<decimal>());
foreach (var id in new[] { "B", "m1", "m2", "m3" })
Assert.Equal(JTokenType.Null, LineOf(s2!, id)["vt"]!.Type);
Assert.Equal(10m, LineOf(s2!, "x")["vt"]!.Value<decimal>()); // standalone untouched
Assert.Equal(135m, s2!.Sums.TotalNet);
Assert.Equal(125m, ItemOf(s2, "A")["total_net"]!.Value<decimal>());
Assert.Equal(JTokenType.Null, ItemOf(s2, "B")["total_net"]!.Type);
}
[Fact]
public void ApplyPatch_ItemSetPrice_InnerThenOuter_NoDoubleCounting()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.setprice", Ref = "B" });
Assert.Equal(100m, LineOf(svc.Get(s.Token)!, "B")["vt"]!.Value<decimal>());
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.setprice", Ref = "A" });
Assert.Equal(125m, LineOf(s2!, "A")["vt"]!.Value<decimal>()); // B's set price (100) + m3 (25)
Assert.Equal(JTokenType.Null, LineOf(s2!, "B")["vt"]!.Type);
Assert.Equal(135m, s2!.Sums.TotalNet);
Assert.Equal(2, s2.Version);
}
[Fact]
public void ApplyPatch_SetCreateThenAssignThenSetPrice_UserDefinedSetGetsMembersSum()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "set.create", Ref = "1", Value = JObject.Parse("{'typ':'set','title':'Extra','parent':''}") });
string newId = LineOrder(svc.Get(s.Token)!).Last();
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "set.assign", Ref = "x", Value = JObject.Parse("{'parent':'" + newId + "'}") });
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.setprice", Ref = newId });
Assert.Equal(3, s2!.Version);
Assert.Equal(10m, LineOf(s2, newId)["vt"]!.Value<decimal>());
Assert.Equal(JTokenType.Null, LineOf(s2, "x")["vt"]!.Type);
Assert.Equal(135m, s2.Sums.TotalNet);
Assert.Equal(new[] { "A", "B", "m1", "m2", "m3", newId, "x" }, LineOrder(s2));
}
[Fact]
public void BuildState_NestedConvertedSet_SetDisplayBlanksWholeSubtree_AndLinesCarryLevels()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.setprice", Ref = "A" });
var state = JObject.FromObject(svc.BuildState(s2!));
var disp = (JObject)state["setDisplay"]!;
Assert.True(disp["A"]!["p"]!.Value<bool>()); Assert.True(disp["A"]!["h"]!.Value<bool>());
Assert.False(disp["B"]!["p"]!.Value<bool>()); Assert.True(disp["B"]!["h"]!.Value<bool>()); // nested header inside a converted set: blank, still emphasised
Assert.False(disp["m1"]!["p"]!.Value<bool>());
Assert.False(disp["m3"]!["p"]!.Value<bool>());
Assert.True(disp["x"]!["p"]!.Value<bool>());
Assert.Equal(2, state["req"]![0]!["itm"]![2]!["lvl"]!.Value<int>());
}
[Fact]
public void ApplyPatch_BlockReplace_KeepsUserDefinedStructure_FromPostedParentLinks()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
// The editor re-posts the block after a local edit, carrying SetItmId per row (rrw -> co).
var nb = JObject.Parse(@"{'Id':'1','text':'Auftrag','itm':[
{'id':'A','typ':'set','vt':0,'vat':'19%','SetItmId':'A'},
{'id':'x','typ':'material','vt':10,'vv':1.9,'vat':'19%','SetItmId':'A'}],
'items':[{'id':'A','type':'set'},{'id':'x','type':'material','total_net':10}]}");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.replace", Ref = "1", Value = nb });
Assert.Equal(1, LineOf(s2!, "x")["lvl"]!.Value<int>());
Assert.Equal("A", (string)ItemOf(s2!, "x")["setId"]!);
Assert.Equal(10m, s2!.Sums.TotalNet);
}
[Theory]
[InlineData("B", "1", "0", "1", new[] { "A", "m3", "B", "m1", "m2", "x" })] // down among A's members, subtree travels
[InlineData("x", "-1", "1", "0", new[] { "x", "A", "B", "m1", "m2", "m3" })] // up at top level
[InlineData("m2", "up", "1", "0", new[] { "A", "B", "m2", "m1", "m3", "x" })] // word form
public void ApplyPatch_SetMove_Sibling_ReordersAndRecordsIndices(string item, string value, string oldIdx, string newIdx, string[] order)
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "set.move", Ref = item, Value = JToken.Parse(value.StartsWith("-") || char.IsDigit(value[0]) ? value : "'" + value + "'") });
Assert.Equal(1, s2!.Version);
Assert.Equal(order, LineOrder(s2));
var h = Assert.Single(s2.History);
Assert.Equal("set.move", h.Target); Assert.Equal(oldIdx, h.OldValue); Assert.Equal(newIdx, h.NewValue);
Assert.Equal(135m, s2.Sums.TotalNet);
}
[Fact]
public void ApplyPatch_SetMove_Anchor_DragBetweenRows_AdoptsAnchorsParent()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "set.move", Ref = "x", Value = JObject.Parse("{'anchor':'m1','where':'before'}") });
Assert.Equal(new[] { "A", "B", "x", "m1", "m2", "m3" }, LineOrder(s2!));
Assert.Equal(2, LineOf(s2!, "x")["lvl"]!.Value<int>());
Assert.Equal("B", (string)LineOf(s2!, "x")["SetItmId"]!);
Assert.Equal("", s2!.History[0].OldValue);
Assert.StartsWith("before:m1", s2.History[0].NewValue);
Assert.Equal(3, LineOf(s2, "x")["p"]!.Value<int>());
}
[Theory]
[InlineData("A", "-1")] // first line, no previous sibling
[InlineData("A", "{'anchor':'m1','where':'after'}")] // anchor inside own subtree
[InlineData("x", "{'anchor':'m1','where':'sideways'}")] // bad direction
[InlineData("x", "{'anchor':'','where':'before'}")] // missing anchor
[InlineData("nope", "1")]
public void ApplyPatch_SetMove_Invalid_IsNoOp(string item, string valueJson)
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(NestedSetPayload(), "user1");
var before = LineOrder(s);
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "set.move", Ref = item, Value = JToken.Parse(valueJson) });
Assert.Equal(0, s2!.Version);
Assert.Equal(before, LineOrder(s2));
Assert.Empty(s2.History);
}
} }
+135
View File
@@ -201,4 +201,139 @@ public class InvoiceSetPricingTests
.Sum(i => decimal.Parse(i["total_net"]!.ToString()!, System.Globalization.CultureInfo.InvariantCulture)); .Sum(i => decimal.Parse(i["total_net"]!.ToString()!, System.Globalization.CultureInfo.InvariantCulture));
Assert.Equal(memberSum, setLine.TotalNet); Assert.Equal(memberSum, setLine.TotalNet);
} }
// ── Nested sets (ADR 0016): order preserved, levels reported, nearest converted ancestor rules ──
private static Dictionary<string, object?> NestedHeader(string id, string parent, string title, string total) => new()
{
["type"] = "set", ["id"] = id, ["setId"] = parent, ["title"] = title, ["qty"] = "1", ["total_net"] = total, ["price_net"] = total
};
// A(converted 125) > B(unconverted) > m1, m2 ; A > m3 ; x standalone
private static List<Dictionary<string, object?>> NestedOuterConverted() => new()
{
SetHeader("A", "Komplett", "125.00"),
NestedHeader("B", "A", "Unterset", "0"),
Member("B", "m1", "60.00", "60.00"),
Member("B", "m2", "40.00", "40.00"),
Member("A", "m3", "25.00", "25.00"),
Standalone("x", "10.00", "10.00")
};
[Fact]
public void Build_Nested_OuterConverted_SetPrice_BlanksWholeSubtree_ReportsLevels()
{
var lines = InvoiceSetPricing.Build(NestedOuterConverted(), SetDisplayMode.SetPrice);
Assert.Equal(new[] { "Komplett", "Unterset", "m1", "m2", "m3", "x" }, lines.Select(l => l.Title)); // input order kept
Assert.Equal(new[] { 0, 1, 2, 2, 1, 0 }, lines.Select(l => l.Level));
Assert.True(lines[0].IsSetHeader); Assert.True(lines[0].ShowPrice); Assert.Equal(125m, lines[0].TotalNet);
Assert.True(lines[1].IsSetHeader); Assert.False(lines[1].ShowPrice); // nested header inside a converted set
Assert.False(lines[2].ShowPrice); Assert.False(lines[3].ShowPrice); Assert.False(lines[4].ShowPrice);
Assert.True(lines[5].ShowPrice); Assert.Equal(10m, lines[5].TotalNet);
}
[Fact]
public void Build_Nested_OuterConverted_SetOnly_DropsWholeSubtree_KeepsStandalone()
{
var lines = InvoiceSetPricing.Build(NestedOuterConverted(), SetDisplayMode.SetOnly);
Assert.Equal(new[] { "Komplett", "x" }, lines.Select(l => l.Title));
Assert.True(lines[0].ShowPrice); Assert.True(lines[1].ShowPrice);
}
// A(unconverted) > B(converted 100) > m1, m2 ; A > m3(25)
private static List<Dictionary<string, object?>> NestedInnerConverted() => new()
{
SetHeader("A", "Komplett"),
NestedHeader("B", "A", "Unterset", "100.00"),
Member("B", "m1", "60.00", "60.00"),
Member("B", "m2", "40.00", "40.00"),
Member("A", "m3", "25.00", "25.00")
};
[Theory]
[InlineData(SetDisplayMode.SetPrice, 5)]
[InlineData(SetDisplayMode.SetOnly, 3)]
public void Build_Nested_InnerConvertedOnly_InnerSubtreeCollapses_OuterStaysIndividuallyPriced(SetDisplayMode mode, int count)
{
var lines = InvoiceSetPricing.Build(NestedInnerConverted(), mode);
Assert.Equal(count, lines.Count);
var a = lines.Single(l => l.Title == "Komplett");
Assert.True(a.IsSetHeader); Assert.False(a.ShowPrice); // outer still unconverted -> blank
var b = lines.Single(l => l.Title == "Unterset");
Assert.True(b.IsSetHeader); Assert.True(b.ShowPrice); Assert.Equal(100m, b.TotalNet); Assert.Equal(1, b.Level);
var m3 = lines.Single(l => l.Title == "m3");
Assert.True(m3.ShowPrice); Assert.Equal(25m, m3.TotalNet); Assert.Equal(1, m3.Level);
if (mode == SetDisplayMode.SetPrice) Assert.All(lines.Where(l => l.Title is "m1" or "m2"), l => Assert.False(l.ShowPrice));
else Assert.DoesNotContain(lines, l => l.Title is "m1" or "m2");
}
[Fact]
public void Build_PreservesInputOrder_EvenWhenStandaloneSitsBetweenHeaderAndMember()
{
var items = new List<Dictionary<string, object?>>
{
SetHeader("10", "Set", "1000.00"),
Standalone("Anfahrt", "50.00", "50.00"),
Member("10", "Waschbecken", "600.00", "600.00")
};
var lines = InvoiceSetPricing.Build(items, SetDisplayMode.SetPrice);
Assert.Equal(new[] { "Set", "Anfahrt", "Waschbecken" }, lines.Select(l => l.Title)); // editor == PDF sequence (ADR 0008)
Assert.True(lines[1].ShowPrice);
Assert.False(lines[2].ShowPrice);
}
[Fact]
public void Build_HeaderSelfReferencingSetId_IsTopLevel_NotItsOwnMember()
{
var header = SetHeader("7", "Set", "100.00"); header["setId"] = "7"; // the shape fds__prepInvoice delivers
var lines = InvoiceSetPricing.Build(new List<Dictionary<string, object?>> { header, Member("7", "A", "100.00", "100.00") }, SetDisplayMode.SetOnly);
Assert.Single(lines);
Assert.Equal(0, lines[0].Level);
Assert.True(lines[0].ShowPrice);
}
[Fact]
public void Build_MemberPointingAtUnknownSet_IsTreatedAsStandalone()
{
var lines = InvoiceSetPricing.Build(new List<Dictionary<string, object?>> { Member("ghost", "A", "5.00", "5.00") }, SetDisplayMode.SetOnly);
var l = Assert.Single(lines);
Assert.True(l.ShowPrice); Assert.Equal(0, l.Level);
}
[Fact]
public void AssignPositionLabels_SetWithMembersPlusStandalone_IsHierarchical()
{
// Set(10) header + 2 members, then a standalone item — mirrors Sample().
var lines = InvoiceSetPricing.Build(Sample(), SetDisplayMode.SetPrice);
InvoiceSetPricing.AssignPositionLabels(new[] { lines });
Assert.Equal(new[] { "1", "1.1", "1.2", "2" }, lines.Select(l => l.PositionLabel));
}
[Fact]
public void AssignPositionLabels_TextLineGetsNoLabel_AndDoesNotShiftFollowingNumbers()
{
var items = new List<Dictionary<string, object?>>
{
Standalone("A", "1.00", "1.00"),
new() { ["type"] = "text", ["title"] = "", ["desc"] = "Hinweis", ["qty"] = "" },
Standalone("B", "2.00", "2.00")
};
var lines = InvoiceSetPricing.Build(items, SetDisplayMode.SetPrice);
InvoiceSetPricing.AssignPositionLabels(new[] { lines });
Assert.Equal(new[] { "1", "", "2" }, lines.Select(l => l.PositionLabel));
}
[Fact]
public void AssignPositionLabels_SpansMultipleBlocks_Continuously()
{
var block1 = InvoiceSetPricing.Build(new List<Dictionary<string, object?>> { Standalone("A", "1.00", "1.00") }, SetDisplayMode.SetPrice);
var block2 = InvoiceSetPricing.Build(Sample(), SetDisplayMode.SetPrice);
InvoiceSetPricing.AssignPositionLabels(new[] { block1, block2 });
Assert.Equal(new[] { "1" }, block1.Select(l => l.PositionLabel));
Assert.Equal(new[] { "2", "2.1", "2.2", "3" }, block2.Select(l => l.PositionLabel));
}
} }
+386
View File
@@ -0,0 +1,386 @@
using System.Collections.Generic;
using System.Linq;
using Fuchs.intranet;
using Newtonsoft.Json.Linq;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Exercises the pure set-structure logic (ADR 0016): parent links (<c>SetItmId</c>) on a block's
/// <c>itm</c> lines form a forest; levels, depth-first linearisation, assign / level / create
/// mutations and the mirror onto the <c>items</c> contract shape are all verified without a
/// service or database. Ids: A/B are sets, m* are members, x is standalone.
/// </summary>
public class InvoiceSetStructureTests
{
// A(set) > B(set) > m1, m2 ; A > m3 ; x standalone — delivered already linearised.
private static JObject NestedBlock() => JObject.Parse(@"{
'Id':'1','text':'Auftrag',
'itm':[
{'id':'A','typ':'set','vt':0,'vat':'19%','SetItmId':'A'},
{'id':'B','typ':'set','vt':0,'vat':'19%','SetItmId':'A'},
{'id':'m1','typ':'material','vt':60,'vat':'19%','SetItmId':'B'},
{'id':'m2','typ':'material','vt':40,'vat':'19%','SetItmId':'B'},
{'id':'m3','typ':'material','vt':25,'vat':'19%','SetItmId':'A'},
{'id':'x','typ':'material','vt':10,'vat':'19%','SetItmId':null}],
'items':[
{'id':'A','type':'set'},{'id':'B','type':'set'},
{'id':'m1','type':'material','total_net':60},{'id':'m2','type':'material','total_net':40},
{'id':'m3','type':'material','total_net':25},{'id':'x','type':'material','total_net':10}]
}");
private static string[] Order(JObject block) => InvoiceSetStructure.Lines(block).Select(InvoiceSetStructure.Id).ToArray();
private static string[] ItemOrder(JObject block) => ((JArray)block["items"]!).OfType<JObject>().Select(i => (string)i["id"]!).ToArray();
private static int Lvl(JObject block, string id) => InvoiceSetStructure.Lines(block).Single(l => InvoiceSetStructure.Id(l) == id)[InvoiceSetStructure.LevelKey]!.Value<int>();
private static JObject Item(JObject block, string id) => ((JArray)block["items"]!).OfType<JObject>().Single(i => (string)i["id"]! == id);
private static string? Parent(JObject block, string id)
{
var lines = InvoiceSetStructure.Lines(block);
return InvoiceSetStructure.ParentOf(lines.Single(l => InvoiceSetStructure.Id(l) == id), InvoiceSetStructure.ById(lines));
}
// ── Levels / parent normalisation ─────────────────────────────────────────
[Fact]
public void Levels_NestedForest_ReportsDepthPerLine()
{
var levels = InvoiceSetStructure.Levels(InvoiceSetStructure.Lines(NestedBlock()));
Assert.Equal(0, levels["A"]);
Assert.Equal(1, levels["B"]);
Assert.Equal(2, levels["m1"]);
Assert.Equal(2, levels["m2"]);
Assert.Equal(1, levels["m3"]);
Assert.Equal(0, levels["x"]);
}
[Theory]
[InlineData("A")] // self-reference (the shape fds__prepInvoice delivers for a header)
[InlineData("nope")] // unknown id
[InlineData("m3")] // points at a non-set line
[InlineData("")]
public void ParentOf_SelfUnknownNonSetOrEmpty_IsTopLevel(string parent)
{
var block = NestedBlock();
InvoiceSetStructure.Lines(block).Single(l => InvoiceSetStructure.Id(l) == "A")["SetItmId"] = parent;
Assert.Null(Parent(block, "A"));
Assert.Equal(0, InvoiceSetStructure.Levels(InvoiceSetStructure.Lines(block))["A"]);
}
[Fact]
public void Levels_CyclicChain_DegradesToTopLevel_AndLinearizeKeepsEveryLine()
{
var block = JObject.Parse(@"{'Id':'1','itm':[
{'id':'A','typ':'set','SetItmId':'B'},{'id':'B','typ':'set','SetItmId':'A'},{'id':'m','typ':'material','SetItmId':'A'}]}");
var levels = InvoiceSetStructure.Levels(InvoiceSetStructure.Lines(block));
Assert.Equal(0, levels["A"]);
Assert.Equal(0, levels["B"]);
InvoiceSetStructure.Linearize(block);
Assert.Equal(3, Order(block).Length); // nothing lost
Assert.Contains("m", Order(block));
}
[Fact]
public void Descendants_AreTransitive_ExcludeTheSetItself_AndUnrelatedLines()
{
var ids = InvoiceSetStructure.Descendants(InvoiceSetStructure.Lines(NestedBlock()), "A").Select(InvoiceSetStructure.Id).ToArray();
Assert.Equal(new[] { "B", "m3", "m1", "m2" }, ids); // breadth-first: direct members first, then B's
Assert.DoesNotContain("A", ids);
Assert.DoesNotContain("x", ids);
Assert.Equal(new[] { "m1", "m2" }, InvoiceSetStructure.Descendants(InvoiceSetStructure.Lines(NestedBlock()), "B").Select(InvoiceSetStructure.Id));
Assert.Empty(InvoiceSetStructure.Descendants(InvoiceSetStructure.Lines(NestedBlock()), "x"));
}
// ── Linearize / RecomputeLevels ───────────────────────────────────────────
[Fact]
public void Linearize_ScrambledInput_EmitsEachSetFollowedByItsMembersRecursively_AndMirrorsItems()
{
var block = JObject.Parse(@"{'Id':'1',
'itm':[{'id':'m1','typ':'material','SetItmId':'B'},{'id':'x','typ':'material'},{'id':'B','typ':'set','SetItmId':'A'},
{'id':'m3','typ':'material','SetItmId':'A'},{'id':'A','typ':'set'},{'id':'m2','typ':'material','SetItmId':'B'}],
'items':[{'id':'m2'},{'id':'osum0','type':'osum'},{'id':'A'},{'id':'x'},{'id':'B'},{'id':'m1'},{'id':'m3'}]}");
InvoiceSetStructure.Linearize(block);
Assert.Equal(new[] { "x", "A", "B", "m1", "m2", "m3" }, Order(block)); // roots keep their relative order (x came before A)
Assert.Equal(new[] { "x", "A", "B", "m1", "m2", "m3", "osum0" }, ItemOrder(block)); // items mirrored; unmatched entries kept at the end
Assert.Equal(0, Lvl(block, "A")); Assert.Equal(1, Lvl(block, "B")); Assert.Equal(2, Lvl(block, "m2")); Assert.Equal(1, Lvl(block, "m3"));
Assert.Equal("A", (string)Item(block, "B")["setId"]!);
Assert.Equal("B", (string)Item(block, "m1")["setId"]!);
Assert.Equal(JTokenType.Null, Item(block, "A")["setId"]!.Type);
Assert.Equal(2, Item(block, "m1")["level"]!.Value<int>());
Assert.Null(Item(block, "osum0")["level"]); // untouched
}
[Fact]
public void RecomputeLevels_DoesNotReorder_ButPublishesLevelsAndMirrorsSetId()
{
var block = JObject.Parse(@"{'Id':'1',
'itm':[{'id':'m1','typ':'material','SetItmId':'A'},{'id':'A','typ':'set','SetItmId':'A'}],
'items':[{'id':'m1','setId':'stale'},{'id':'A','setId':'A'}]}");
var s = new InvoiceDraftSession { Req = new JArray { block } };
InvoiceSetStructure.RecomputeLevels(s);
Assert.Equal(new[] { "m1", "A" }, Order(block)); // order untouched
Assert.Equal(1, Lvl(block, "m1"));
Assert.Equal("A", (string)Item(block, "m1")["setId"]!); // stale mirror corrected
Assert.Equal(JTokenType.Null, Item(block, "A")["setId"]!.Type); // self-reference mirrored as "no parent"
}
// ── Assign ────────────────────────────────────────────────────────────────
[Fact]
public void Assign_StandaloneOntoSet_BecomesLastMember()
{
var block = NestedBlock();
Assert.True(InvoiceSetStructure.Assign(block, "x", "A", out string oldParent));
Assert.Equal("", oldParent);
Assert.Equal(new[] { "A", "B", "m1", "m2", "m3", "x" }, Order(block));
Assert.Equal(1, Lvl(block, "x"));
Assert.Equal("A", (string)Item(block, "x")["setId"]!);
}
[Fact]
public void Assign_SetOntoSet_KeepsItsOwnMembersOneLevelDeeper()
{
var block = JObject.Parse(@"{'Id':'1','itm':[
{'id':'A','typ':'set'},{'id':'m3','typ':'material','SetItmId':'A'},
{'id':'B','typ':'set'},{'id':'m1','typ':'material','SetItmId':'B'},{'id':'m2','typ':'material','SetItmId':'B'}]}");
Assert.True(InvoiceSetStructure.Assign(block, "B", "A", out _));
Assert.Equal(new[] { "A", "m3", "B", "m1", "m2" }, Order(block));
Assert.Equal(1, Lvl(block, "B"));
Assert.Equal(2, Lvl(block, "m1"));
Assert.Equal("B", Parent(block, "m1")); // still B's member
}
[Fact]
public void Assign_PullOutToTopLevel_PlacesLineAfterItsFormerSubtree_MembersFollow()
{
var block = NestedBlock();
Assert.True(InvoiceSetStructure.Assign(block, "B", "", out string oldParent));
Assert.Equal("A", oldParent);
Assert.Equal(new[] { "A", "m3", "B", "m1", "m2", "x" }, Order(block));
Assert.Equal(0, Lvl(block, "B"));
Assert.Equal(1, Lvl(block, "m1"));
Assert.Equal(JTokenType.Null, Item(block, "B")["setId"]!.Type);
}
[Fact]
public void Assign_DeepMemberToTopLevel_LandsAfterTheWholeRootSubtree()
{
var block = NestedBlock();
Assert.True(InvoiceSetStructure.Assign(block, "m1", "", out _));
Assert.Equal(new[] { "A", "B", "m2", "m3", "m1", "x" }, Order(block));
Assert.Equal(0, Lvl(block, "m1"));
}
[Theory]
[InlineData("x", "")] // already top level
[InlineData("m1", "B")] // unchanged parent
[InlineData("A", "A")] // onto itself
[InlineData("A", "B")] // onto its own descendant (cycle)
[InlineData("x", "m3")] // onto a non-set line
[InlineData("x", "nope")] // unknown target
[InlineData("nope", "A")] // unknown item
public void Assign_InvalidOrUnchanged_IsNoOp(string item, string parent)
{
var block = NestedBlock();
var before = Order(block);
Assert.False(InvoiceSetStructure.Assign(block, item, parent, out _));
Assert.Equal(before, Order(block));
}
// ── ChangeLevel ───────────────────────────────────────────────────────────
[Fact]
public void ChangeLevel_Indent_JoinsNearestPrecedingSiblingSetAsLastMember()
{
var block = NestedBlock();
Assert.True(InvoiceSetStructure.ChangeLevel(block, "m3", +1, out int oldLevel, out int newLevel));
Assert.Equal(1, oldLevel); Assert.Equal(2, newLevel);
Assert.Equal(new[] { "A", "B", "m1", "m2", "m3", "x" }, Order(block));
Assert.Equal("B", Parent(block, "m3"));
}
[Fact]
public void ChangeLevel_Outdent_JoinsGrandparentRightAfterFormerParentSubtree()
{
var block = NestedBlock();
Assert.True(InvoiceSetStructure.ChangeLevel(block, "m1", -1, out int oldLevel, out int newLevel));
Assert.Equal(2, oldLevel); Assert.Equal(1, newLevel);
Assert.Equal(new[] { "A", "B", "m2", "m1", "m3", "x" }, Order(block));
Assert.Equal("A", Parent(block, "m1"));
}
[Fact]
public void ChangeLevel_OutdentSet_TakesItsMembersAlong()
{
var block = NestedBlock();
Assert.True(InvoiceSetStructure.ChangeLevel(block, "B", -1, out _, out int newLevel));
Assert.Equal(0, newLevel);
Assert.Equal(new[] { "A", "m3", "B", "m1", "m2", "x" }, Order(block));
Assert.Equal(1, Lvl(block, "m2"));
}
[Fact]
public void ChangeLevel_IndentTopLevelLineAfterASet_JoinsThatSet()
{
var block = NestedBlock();
Assert.True(InvoiceSetStructure.ChangeLevel(block, "x", +1, out int oldLevel, out int newLevel));
Assert.Equal(0, oldLevel); Assert.Equal(1, newLevel);
Assert.Equal(new[] { "A", "B", "m1", "m2", "m3", "x" }, Order(block));
Assert.Equal("A", Parent(block, "x"));
}
[Theory]
[InlineData("A", +1)] // first line — nothing precedes it
[InlineData("m1", +1)] // first member of B — no preceding sibling set inside B
[InlineData("x", -1)] // already top level
[InlineData("m1", 0)] // no direction
[InlineData("nope", -1)] // unknown
public void ChangeLevel_NoMovePossible_IsNoOp(string item, int delta)
{
var block = NestedBlock();
var before = Order(block);
Assert.False(InvoiceSetStructure.ChangeLevel(block, item, delta, out _, out _));
Assert.Equal(before, Order(block));
}
// ── Create ────────────────────────────────────────────────────────────────
[Fact]
public void Create_Set_InsideParent_AppendsAsLastMember_WritesBothShapes()
{
var block = NestedBlock();
string? id = InvoiceSetStructure.Create(block, "set", " Unterset ", "B");
Assert.NotNull(id);
Assert.StartsWith("sN", id);
Assert.Equal(new[] { "A", "B", "m1", "m2", id!, "m3", "x" }, Order(block));
var co = InvoiceSetStructure.Lines(block).Single(l => InvoiceSetStructure.Id(l) == id);
Assert.Equal("set", (string)co["typ"]!);
Assert.Equal("Unterset", (string)co["t"]!);
Assert.Equal(0m, co["vt"]!.Value<decimal>());
Assert.Equal("19%", (string)co["vat"]!); // adopts the block's rate
Assert.Equal(2, Lvl(block, id!));
var io = Item(block, id!);
Assert.Equal("set", (string)io["type"]!);
Assert.Equal("Unterset", (string)io["title"]!);
Assert.Equal("B", (string)io["setId"]!);
Assert.Equal(0m, io["total_net"]!.Value<decimal>());
}
[Fact]
public void Create_Text_TopLevel_AppendsAtBlockEnd_HtmlEncodesTitle()
{
var block = NestedBlock();
string? id = InvoiceSetStructure.Create(block, "TEXT", "Hinweis <b>fett</b>", "");
Assert.NotNull(id);
Assert.Equal(id, Order(block).Last());
var co = InvoiceSetStructure.Lines(block).Last();
Assert.Equal("Text", (string)co["typ"]!);
Assert.Equal("<p>Hinweis &lt;b&gt;fett&lt;/b&gt;</p>", (string)co["t"]!);
Assert.Equal(0, Lvl(block, id!));
Assert.Equal("text", (string)Item(block, id!)["type"]!);
Assert.Equal("<p>Hinweis &lt;b&gt;fett&lt;/b&gt;</p>", (string)Item(block, id!)["desc"]!);
}
[Fact]
public void Create_WhenBlockHasNoLinesOrItems_CreatesBothArrays_WithFallbackRate()
{
var block = JObject.Parse(@"{'Id':'9'}");
string? id = InvoiceSetStructure.Create(block, "set", "Neu", "");
Assert.NotNull(id);
Assert.Single((JArray)block["itm"]!);
Assert.Single((JArray)block["items"]!);
Assert.Equal("19%", (string)((JArray)block["itm"]!)[0]["vat"]!);
}
[Theory]
[InlineData("material", "Neu", "")] // unsupported type
[InlineData("set", " ", "")] // blank title
[InlineData("set", "Neu", "m3")] // parent is not a set
[InlineData("set", "Neu", "nope")] // unknown parent
public void Create_Invalid_ReturnsNullAndLeavesBlockUntouched(string typ, string title, string parent)
{
var block = NestedBlock();
var before = Order(block);
Assert.Null(InvoiceSetStructure.Create(block, typ, title, parent));
Assert.Equal(before, Order(block));
Assert.Equal(6, ((JArray)block["items"]!).Count);
}
// ── Place / MoveSibling (reorder) ─────────────────────────────────────────
[Fact]
public void Place_BeforeAnchor_BecomesAnchorsSibling_SubtreeTravels()
{
var block = NestedBlock();
Assert.True(InvoiceSetStructure.Place(block, "B", "x", after: false, out string oldParent, out string newParent));
Assert.Equal("A", oldParent); Assert.Equal("", newParent);
Assert.Equal(new[] { "A", "m3", "B", "m1", "m2", "x" }, Order(block));
Assert.Equal(0, Lvl(block, "B")); Assert.Equal(1, Lvl(block, "m1"));
}
[Fact]
public void Place_AfterAnchor_LandsAfterAnchorsWholeSubtree()
{
var block = NestedBlock();
Assert.True(InvoiceSetStructure.Place(block, "x", "B", after: true, out _, out string newParent));
Assert.Equal("A", newParent); // x is now B's sibling inside A
Assert.Equal(new[] { "A", "B", "m1", "m2", "x", "m3" }, Order(block));
Assert.Equal(1, Lvl(block, "x"));
}
[Fact]
public void Place_MemberBeforeItsOwnParent_MovesUpOneLevel()
{
var block = NestedBlock();
Assert.True(InvoiceSetStructure.Place(block, "m1", "B", after: false, out _, out string newParent));
Assert.Equal("A", newParent);
Assert.Equal(new[] { "A", "m1", "B", "m2", "m3", "x" }, Order(block));
Assert.Equal(1, Lvl(block, "m1"));
}
[Theory]
[InlineData("A", "m1", true)] // anchor inside own subtree
[InlineData("A", "A", false)] // itself
[InlineData("nope", "x", true)] // unknown item
[InlineData("x", "nope", false)] // unknown anchor
public void Place_Invalid_IsNoOp(string item, string anchor, bool after)
{
var block = NestedBlock();
var before = Order(block);
Assert.False(InvoiceSetStructure.Place(block, item, anchor, after, out _, out _));
Assert.Equal(before, Order(block));
}
[Fact]
public void MoveSibling_Down_SwapsWithNextSibling_SubtreeTravels()
{
var block = NestedBlock();
Assert.True(InvoiceSetStructure.MoveSibling(block, "B", +1, out int oldIndex, out int newIndex));
Assert.Equal(0, oldIndex); Assert.Equal(1, newIndex);
Assert.Equal(new[] { "A", "m3", "B", "m1", "m2", "x" }, Order(block));
Assert.Equal(1, Lvl(block, "B")); Assert.Equal(2, Lvl(block, "m1")); // still inside A
}
[Fact]
public void MoveSibling_Up_TopLevel_MovesWholeSetAboveStandalone()
{
var block = NestedBlock();
Assert.True(InvoiceSetStructure.MoveSibling(block, "x", -1, out int oldIndex, out int newIndex));
Assert.Equal(1, oldIndex); Assert.Equal(0, newIndex);
Assert.Equal(new[] { "x", "A", "B", "m1", "m2", "m3" }, Order(block));
}
[Theory]
[InlineData("A", -1)] // first top-level line
[InlineData("x", +1)] // last top-level line
[InlineData("m1", -1)] // first member of B
[InlineData("m2", +1)] // last member of B (m3 belongs to A, not B)
[InlineData("m1", 0)]
[InlineData("nope", 1)]
public void MoveSibling_AtEdge_IsNoOp(string item, int delta)
{
var block = NestedBlock();
var before = Order(block);
Assert.False(InvoiceSetStructure.MoveSibling(block, item, delta, out _, out _));
Assert.Equal(before, Order(block));
}
}
+2
View File
@@ -177,6 +177,8 @@ disable/failure). See [`Concepts/erechnung-output.md`](Concepts/erechnung-output
Stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are singletons; DB/request-scoped services are scoped (see `Program.cs`). Stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are singletons; DB/request-scoped services are scoped (see `Program.cs`).
The **Admin** module (`Do_Process_Admin`, `ISystemStatusService`) surfaces a live system-status/diagnostics page (host, SQL/Key Vault/blob/MFR connectivity, email config, test-email) restricted to `fds_sys` > 4 — see ADR [0011](Decisions/0011-admin-module-system-status.md) and the [concept doc](Concepts/admin-system-status.md). The **Admin** module (`Do_Process_Admin`, `ISystemStatusService`) surfaces a live system-status/diagnostics page (host, SQL/Key Vault/blob/MFR connectivity, email config, test-email) restricted to `fds_sys` > 4 — see ADR [0011](Decisions/0011-admin-module-system-status.md) and the [concept doc](Concepts/admin-system-status.md).
`FdsInvoiceData` / `FdsReminderData` are now **pure data holders** (parse + properties); loading, persistence and PDF generation live in the services (fully async — no `Task.Run(...).Wait()`). `FdsInvoiceData` / `FdsReminderData` are now **pure data holders** (parse + properties); loading, persistence and PDF generation live in the services (fully async — no `Task.Run(...).Wait()`).
Invoice/reminder drafts are edited **backend-authoritatively**: the truth is an in-memory `InvoiceDraftSession` mutated by single deltas (`IInvoiceDraftService`), including the user-defined **set structure** (`InvoiceSetStructure` — parent links, nesting, levels; ADR [0016](Decisions/0016-user-defined-set-structure.md)) and the set-price operations (`InvoiceSetPricing`, ADR [0009](Decisions/0009-block-setprice-modes-are-irreversible-mutations.md)). See the [live draft editing concept](Concepts/live-draft-editing.md) and ADRs [0006](Decisions/0006-backend-authoritative-draft-editing.md)/[0008](Decisions/0008-invoices-and-reminders-fully-backend-authoritative.md).
`FuchsPdf` / `FuchsVisualization` remain as static rendering libraries used *by* the services. The earlier static, controller-coupled helpers (`FuchsWidgets`, `FuchsReports`, `Banking`, `FuchsFdsEmail`) have been removed. `FuchsPdf` / `FuchsVisualization` remain as static rendering libraries used *by* the services. The earlier static, controller-coupled helpers (`FuchsWidgets`, `FuchsReports`, `Banking`, `FuchsFdsEmail`) have been removed.
### 4.4 SQL-First Data Access ### 4.4 SQL-First Data Access
+22 -1
View File
@@ -1,9 +1,10 @@
--- ---
status: Active status: Active
lastUpdated: 2026-07-10 lastUpdated: 2026-09-09
applyTo: applyTo:
- "Fuchs/Services/InvoiceDraft*" - "Fuchs/Services/InvoiceDraft*"
- "Fuchs/Services/IInvoiceDraft*" - "Fuchs/Services/IInvoiceDraft*"
- "Fuchs/code/InvoiceSetStructure.cs"
- "Fuchs/Services/ReminderDraft*" - "Fuchs/Services/ReminderDraft*"
- "Fuchs/Services/IReminderDraft*" - "Fuchs/Services/IReminderDraft*"
- "Fuchs/code/InvoiceDraftSession.cs" - "Fuchs/code/InvoiceDraftSession.cs"
@@ -18,6 +19,8 @@ applyTo:
relatedDecisions: relatedDecisions:
- "0006-backend-authoritative-draft-editing.md" - "0006-backend-authoritative-draft-editing.md"
- "0007-targeted-draft-signalr-groups.md" - "0007-targeted-draft-signalr-groups.md"
- "0009-block-setprice-modes-are-irreversible-mutations.md"
- "0016-user-defined-set-structure.md"
--- ---
# Live draft editing (backend-authoritative invoice previews) # Live draft editing (backend-authoritative invoice previews)
@@ -83,6 +86,19 @@ Expiry: Server (timer) --SignalR draftExpiring{token,secondsLeft}--> warn "bit
`BuildInvoiceRequestList`), applies deltas (`ApplyDelta`), builds the view-state DTO, `BuildInvoiceRequestList`), applies deltas (`ApplyDelta`), builds the view-state DTO,
flushes to the DB by reusing `IInvoiceService.RegisterInvoiceAsync` (no new persistence flushes to the DB by reusing `IInvoiceService.RegisterInvoiceAsync` (no new persistence
path), renders previews from a synthesised registration, and discards by reloading. path), renders previews from a synthesised registration, and discards by reloading.
- **Set structure** (ADR 0016). Every cached line carries a parent link (`SetItmId` → the
owning `typ == "set"` line, null/self = top level); sets may nest. `fds__prepInvoice`'s
implicit tagging is the initial structure; the user edits it in the "Set-Struktur
bearbeiten" dialog (`$inv.sets`, `fis.inv_sets.js`) which posts one delta per action —
`set.assign` { parent } (drop onto a set / pull out), `set.level` ±1 (indent under the
preceding sibling set / outdent to the grandparent), `set.create` { typ: set|text, title,
parent }, `set.move` ±1 | { anchor, where } (reorder among siblings / drag before or after
a row; the subtree travels along). `InvoiceDraftSetStructureDeltas` delegates to the pure
`InvoiceSetStructure` (assign / change level / create / place, then **linearise** the block depth-first and mirror
`setId`/`level` onto the `items` contract). `Refresh` publishes `lvl` on every line
(`RecomputeLevels`), rendered as an indent class in the editor and a per-level indent in the
PDF. `item.setprice` sums a header's **transitive** descendants. Structure deltas never
change values — only order, membership, levels and therefore position numbers.
- **Cache** (`InvoiceDraftCache`, singleton) stores sessions by token with an idle sliding - **Cache** (`InvoiceDraftCache`, singleton) stores sessions by token with an idle sliding
TTL; `InvoiceDraftExpiryService` (a `BackgroundService`) warns before, and evicts after, TTL; `InvoiceDraftExpiryService` (a `BackgroundService`) warns before, and evicts after,
the TTL. TTL/warn-lead are configurable under `Fuchs:DraftEditing`. the TTL. TTL/warn-lead are configurable under `Fuchs:DraftEditing`.
@@ -97,12 +113,15 @@ Expiry: Server (timer) --SignalR draftExpiring{token,secondsLeft}--> warn "bit
## Key files ## Key files
- `Fuchs/code/InvoiceDraftSession.cs` — session + `ChangeHistoryEntry` + `InvoiceDraftSums`. - `Fuchs/code/InvoiceDraftSession.cs` — session + `ChangeHistoryEntry` + `InvoiceDraftSums`.
- `Fuchs/code/InvoiceDraftCalculator.cs` — pure recompute + validate. - `Fuchs/code/InvoiceDraftCalculator.cs` — pure recompute + validate.
- `Fuchs/code/InvoiceSetStructure.cs` — pure set forest: parent links, levels, linearise, assign/level/create (ADR 0016).
- `Fuchs/Services/InvoiceDraftSetStructureDeltas.cs` — the `set.*` patch targets.
- `Fuchs/Services/InvoiceDraftCache.cs` / `IInvoiceDraftCache.cs` — in-memory store + TTL. - `Fuchs/Services/InvoiceDraftCache.cs` / `IInvoiceDraftCache.cs` — in-memory store + TTL.
- `Fuchs/Services/InvoiceDraftEditService.cs` / `IInvoiceDraftService.cs` — orchestration + delta contract. - `Fuchs/Services/InvoiceDraftEditService.cs` / `IInvoiceDraftService.cs` — orchestration + delta contract.
- `Fuchs/Services/InvoiceDraftExpiryService.cs` — idle warn/evict monitor. - `Fuchs/Services/InvoiceDraftExpiryService.cs` — idle warn/evict monitor.
- `Fuchs/Notifications/DraftPreviewHub.cs`, `DraftNotifier.cs`, `IDraftNotifier.cs` — targeted signals. - `Fuchs/Notifications/DraftPreviewHub.cs`, `DraftNotifier.cs`, `IDraftNotifier.cs` — targeted signals.
- `Fuchs/Controllers/IntranetController.InvoiceDraft.cs` — `inv/d*` endpoints. - `Fuchs/Controllers/IntranetController.InvoiceDraft.cs` — `inv/d*` endpoints.
- `Fuchs/js/intranet/fis_main.js`, `Fuchs/js/intranet/modules/fis.inv_shared.js` — client. - `Fuchs/js/intranet/fis_main.js`, `Fuchs/js/intranet/modules/fis.inv_shared.js` — client.
- `Fuchs/js/intranet/modules/fis.inv_sets.js` — the set-structure dialog (`$inv.sets`), a view of `dstate.req`.
## Reminders (Zahlungserinnerung) ## Reminders (Zahlungserinnerung)
@@ -151,3 +170,5 @@ amount pair.
## Related decisions ## Related decisions
- [0006 — Backend-authoritative draft editing](../Decisions/0006-backend-authoritative-draft-editing.md) - [0006 — Backend-authoritative draft editing](../Decisions/0006-backend-authoritative-draft-editing.md)
- [0007 — Targeted draft SignalR groups](../Decisions/0007-targeted-draft-signalr-groups.md) - [0007 — Targeted draft SignalR groups](../Decisions/0007-targeted-draft-signalr-groups.md)
- [0009 — Block set-price modes are irreversible mutations](../Decisions/0009-block-setprice-modes-are-irreversible-mutations.md)
- [0016 — User-defined set structure](../Decisions/0016-user-defined-set-structure.md)
@@ -0,0 +1,112 @@
---
status: Accepted
date: 2026-09-09
applyTo:
- "Fuchs/code/InvoiceSetStructure.cs"
- "Fuchs/code/InvoiceSetPricing.cs"
- "Fuchs/Services/InvoiceDraft*"
- "Fuchs/Services/IInvoiceDraft*"
- "Fuchs/code/InvoiceDraftCalculator.cs"
- "Fuchs/code/FuchsPdf.cs"
- "Fuchs/js/intranet/modules/fis.inv_sets.js"
- "Fuchs/js/intranet/modules/fis.inv_shared.js"
- "Fuchs/Docs/INVOICE_SET_PRICING.md"
supersededBy: ""
---
# 0016 — Set membership is an explicit, user-editable parent link on the cached lines; sets may nest; the server linearises
## Context
Set membership on an invoice draft was purely **implicit**: `fds__prepInvoice` tags every item
that follows a zero-priced `Type = 'Set'` header (until the next such header) with that header's
id (`SetItmId`), and the set-item switch (ADR [0009](0009-block-setprice-modes-are-irreversible-mutations.md),
function 1) sums exactly those tagged lines. Users had no way to correct that grouping — a
trailing item that does not belong, a material line that should sit inside the set, a set that
should contain another set — nor to introduce a set (or a free-text line) that does not exist
in mfr. The product owner asked for a dialog that lets the user define which line belongs to
which set, create new set/text lines, change a line's level, and nest sets, with every change
applied **immediately** to the cached draft and reflected in the editor.
## Decision
1. **The implicit derivation stays as the initial structure.** `fds__prepInvoice`'s
`SetItmId` window function is unchanged; a freshly opened draft shows exactly the grouping
it delivered.
2. **Membership is an explicit parent link on every cached line.** Each line in a block's
`itm` array carries `SetItmId` = the id of the `typ == "set"` line that owns it (null, or a
self-reference as delivered for headers, = top level). The links form a forest per
service-request block; the block is the boundary — a line is never assigned to a set of
another block. `InvoiceSetStructure.ParentOf` normalises the link (self-reference, unknown
id, non-set target → top level).
3. **Sets may contain sets.** A set's own `SetItmId` names its parent set. Moving a set keeps
its members attached — they simply sit one level deeper/shallower. The depth of every line is
published as `lvl` (co shape) / `level` (items contract) and mirrored onto `items[].setId`,
so the PDF and the editor read one structure.
4. **Four new backend-authoritative deltas** (`inv/dpatch`, handled by
`InvoiceDraftSetStructureDeltas` → pure `InvoiceSetStructure`):
- `set.assign` (Ref = item, Value `{ parent }`; `""` = top level) — drop a line onto a set
(it becomes the set's **last** member) or pull it out (placed right after the subtree it
came from). Rejected as a no-op: unknown item, target not a set of the same block, target
is the item itself or one of its descendants (cycle), parent unchanged.
- `set.level` (Ref = item, Value ±1) — indent = join the nearest **preceding sibling set**
as its last member; outdent = join the grandparent right after the former parent's
subtree. No-op when no such move exists.
- `set.create` (Ref = block, Value `{ typ: "set" | "text", title, parent }`) — a new
zero-priced set header (convertible later via the item switch) or a free-text line, as the
last member of `parent` / end of the block. Both line shapes are written; the title is
HTML-encoded for the text line.
- `set.move` (Ref = item, Value ±1 or `{ anchor, where: "before" | "after" }`) — reorder:
one step up/down among the line's siblings, or become the anchor's sibling placed before
it / after its subtree (drag between rows). The line's own subtree travels with it, so a
set can be reordered folded or unfolded alike. Rejected: anchor inside the item's subtree.
5. **The server linearises after every structural change**: the block's lines are reordered
depth-first (each set immediately followed by its members, recursively; siblings keep their
relative order), levels are recomputed and the `items` contract is mirrored (order,
`setId`, `level`). `InvoiceSetPricing.Build` is therefore **order-preserving**: it no longer
regroups members next to their header at render time; a line is governed by its nearest
converted ancestor (blank in SetPrice, dropped in SetOnly). Editor and PDF print the same
sequence and the same per-level indent (ADR 0008).
6. **The set-item switch sums transitive descendants.** `item.setprice` on a set now sums and
nulls the header's whole subtree: an unconverted nested set contributes its members'
prices, an already-converted nested set contributes its own set price — so converting inner
then outer, or outer directly, both conserve the total and count nothing twice.
7. **The dialog is a view, not a model.** `$inv.sets` (fis.inv_sets.js) renders
`state.req[].itm` (pos, type, quantity, title, indent, ▲▼ order and ◀▶ level buttons, fold
toggles, per-block create bar), posts one delta per action and re-renders from the next
`dstate`. Folding (`collapsed` set ids) is browser-only view state. The dialog performs no
restructuring, ordering or numbering itself (ADR 0006).
## Consequences
- `Refresh` runs `InvoiceSetStructure.RecomputeLevels` on every patch, so a `block.replace`
posted by the editor (rows carry `SetItmId` from `rrw`) keeps the user-defined structure; the
editor never has to re-derive it. Levels are rendered as an indent class (`lvl1…`) in the
editor and a per-level left indent in the PDF.
- `invSumUpdate` now tags a nested **set** with its `setId` too (previously only non-set
members were tagged), and `BuildSetDisplay` blanks the whole subtree of a converted set —
including nested headers, which stay emphasised.
- Structural deltas never change a value: totals, VAT splits and `NetByBlock` are unaffected;
only positions are renumbered along the new order.
- Row drag in the editor (the existing `Sortable`) reorders rows without touching parent links;
a member dragged away from its set keeps its membership and indent. The structure dialog is
the place to change membership.
- Tests: `InvoiceSetStructureTests` (pure forest logic), `InvoiceDraftServiceTests` (deltas,
history, nested set price, state), `InvoiceSetPricingTests` (nesting, order preservation).
## Alternatives considered
- **Derive membership from row order (everything after a set until the next set)** as the
live model: rejected — that is exactly what the user cannot correct, and it cannot express
nesting or an item that follows a set but does not belong to it.
- **A separate structure table (tree) beside the lines**: rejected — a parent link per line
keeps the single `itm`/`items` shape the calculator, the PDF, the persistence path and the
editor already consume, so nothing else had to be re-shaped.
- **Render-time regrouping (keep `Build` moving members next to their header)**: rejected —
with explicit links the server can guarantee contiguity by linearising once, and an
order-preserving `Build` is the only way the editor and the PDF are guaranteed to show the
same sequence.
- **Client-side restructuring with a full block re-post**: rejected per ADR 0006/0008 — one
delta per action, the server owns the mutation, the browser mirrors.
+48 -9
View File
@@ -1,8 +1,10 @@
# Invoice "Set" Pricing — Design & Front-/Back-end Contract # Invoice "Set" Pricing — Design & Front-/Back-end Contract
> Governed by ADR [0009](Decisions/0009-block-setprice-modes-are-irreversible-mutations.md) > Governed by ADR [0009](Decisions/0009-block-setprice-modes-are-irreversible-mutations.md)
> (the two menu modes) and ADR [0008](Decisions/0008-invoices-and-reminders-fully-backend-authoritative.md) > (the two menu modes), ADR [0008](Decisions/0008-invoices-and-reminders-fully-backend-authoritative.md)
> (everything server-side, PDF == editor). ADR 0009 **redefined** the two menu modes from the > (everything server-side, PDF == editor) and ADR
> [0016](Decisions/0016-user-defined-set-structure.md) (set membership is an explicit,
> user-editable parent link; sets may nest — see "Set structure" below). ADR 0009 **redefined** the two menu modes from the
> reversible, non-mutating display toggles this document previously described into irreversible, > reversible, non-mutating display toggles this document previously described into irreversible,
> per-service-request-block mutations — the text below reflects the redefinition. > per-service-request-block mutations — the text below reflects the redefinition.
@@ -40,6 +42,37 @@ for the full breakdown.
> or removes (mode 3), and the set-item switch (mode 1) writes exactly the members' sum onto the > or removes (mode 3), and the set-item switch (mode 1) writes exactly the members' sum onto the
> header. > header.
## Set structure (ADR 0016): which line belongs to which set
`SetItmId` is no longer only what `fds__prepInvoice` derived from row order — it is the
**explicit parent link** of every cached line, and the user can edit it:
- **Initial structure** = the implicit derivation (every item after a zero-priced Set header
until the next one). Nothing changes for drafts the user never touches.
- **"Set-Struktur bearbeiten"** (editor menu → `$inv.sets`, `fis.inv_sets.js`) shows every
line per service-request block (Pos., Typ, Menge, Bezeichnung, indented by level). Drag a
line onto a set → `set.assign { parent }` (it becomes the set's last member); drag it onto
the block heading / the "oberste Ebene" zone → `set.assign { parent: "" }` (pulled out, placed
after the subtree it came from). Drag a line to the **top/bottom edge** of another row →
`set.move { anchor, where: before|after }` (reorder: it becomes that row's sibling; a set
travels with its members, folded or not); ▲ / ▼ post `set.move` −1 / +1 (one step among
siblings). The ◀ / ▶ buttons post `set.level` −1 / +1 (outdent to the
grandparent / indent under the nearest preceding sibling set). Sets can be folded via the
triangle (view state only). The per-block bar creates a
new **Set** (zero-priced header, convertible via function 1) or **Text** line inside any set
or at the top level → `set.create { typ, title, parent }`.
- **Sets may contain sets.** A set dropped onto another set keeps its own members (one level
deeper). Function 1 (`item.setprice`) sums the header's **transitive** descendants and nulls
them all; converting inner-then-outer or outer directly conserves the total either way.
- **Only sets are drop targets; a line never leaves its block.** Cycles (a set onto its own
descendant) and no-op moves are rejected server-side (`InvoiceSetStructure`), the browser
merely greys them out.
- **The server linearises** after each structural delta (depth-first: set, then its members,
recursively; siblings keep their order) and publishes `lvl`/`level`; `InvoiceSetPricing.Build`
is order-preserving, so editor and PDF show the same sequence and the same per-level indent.
Every change is applied immediately to the cached session and pushed back via
`draftReady`/`dstate`; the dialog re-renders from that state.
## Back-end ## Back-end
> **Migration status (ADR 0009).** The code below still reflects the previous > **Migration status (ADR 0009).** The code below still reflects the previous
@@ -51,9 +84,14 @@ for the full breakdown.
> migration lands so it stays a faithful description of the code. > migration lands so it stays a faithful description of the code.
- `Fuchs/code/InvoiceSetPricing.cs` — the authoritative transformation: - `Fuchs/code/InvoiceSetPricing.cs` — the authoritative transformation:
`SetDisplayMode` + `Build(items, mode)` → ordered `InvoiceSetLine`s, each with `SetDisplayMode` + `Build(items, mode)` → `InvoiceSetLine`s **in input order**, each with
`ShowPrice` and `IsSetHeader`. `ModeFromInvoiceOptions(...)` reads the mode `ShowPrice`, `IsSetHeader` and `Level` (nesting depth). A line is governed by its nearest
*converted* ancestor set (blank in SetPrice, dropped in SetOnly); an unconverted header is
blank with individually priced members. `ModeFromInvoiceOptions(...)` reads the mode
from the invoice options. Fully covered by `Fuchs.Tests/InvoiceSetPricingTests.cs`. from the invoice options. Fully covered by `Fuchs.Tests/InvoiceSetPricingTests.cs`.
- `Fuchs/code/InvoiceSetStructure.cs` — the pure set forest (parent links, levels, depth-first
linearisation, assign / change level / create); `Fuchs/Services/InvoiceDraftSetStructureDeltas.cs`
maps the `set.*` patch targets onto it. Covered by `InvoiceSetStructureTests` + `InvoiceDraftServiceTests`.
- `FuchsPdf.ApplyInvoice` renders through `InvoiceSetPricing.Build`: lines with - `FuchsPdf.ApplyInvoice` renders through `InvoiceSetPricing.Build`: lines with
`ShowPrice == false` render **blank** price/total cells (not `0,00 €`), and the `ShowPrice == false` render **blank** price/total cells (not `0,00 €`), and the
set header line is rendered emphasised. Invoices without sets pass through set header line is rendered emphasised. Invoices without sets pass through
@@ -82,11 +120,12 @@ Wired in `Fuchs/js/intranet/modules/fis.inv_shared.js` (bundled to
C# PDF. This change closes that gap for **all** invoices, not just sets.) C# PDF. This change closes that gap for **all** invoices, not just sets.)
3. **Set flags (function 1 only)** — `invSumUpdate` tags items as it builds `items[]`: 3. **Set flags (function 1 only)** — `invSumUpdate` tags items as it builds `items[]`:
an item with `type === 'set'` is a header (`id` = its set id); a member item's an item with `type === 'set'` is a header (`id` = its set id); a member's
`setId` is taken directly from the server-computed `SetItmId` field on the row `setId` is taken directly from the `SetItmId` field on the row
(`rrx.SetItmId`, populated by `fds__prepInvoice`'s `[SetItmID]` window function, (`rrx.SetItmId` — initially populated by `fds__prepInvoice`'s `[SetItmID]` window function,
anchored on the still-unconverted, zero-priced `'set'` header that owns it) — anchored on the still-unconverted, zero-priced `'set'` header that owns it, and thereafter
**not** re-derived from row order in the browser. `mfr__items` itself still has no maintained by the server per ADR 0016, including for a **nested set**, which is a member of
its parent set) — **not** re-derived from row order in the browser. `mfr__items` itself still has no
explicit member link; `fds__prepInvoice` computes `SetItmId` per request from the explicit member link; `fds__prepInvoice` computes `SetItmId` per request from the
item list, so only items the server actually attributes to a set are tagged, and item list, so only items the server actually attributes to a set are tagged, and
unrelated items following a set in the list are never swept in. The header row's unrelated items following a set in the list are never swept in. The header row's
+14
View File
@@ -50,6 +50,20 @@ Serviceaufträge kommen aus dem MFR-ERP-System und werden regelmäßig automatis
1. Aus einem Serviceauftrag eine Rechnung **vorbereiten** → Vorschau prüfen. 1. Aus einem Serviceauftrag eine Rechnung **vorbereiten** → Vorschau prüfen.
2. **Speichern** (Entwurf) oder weiter **bearbeiten**. Entwürfe tragen ein Wasserzeichen. 2. **Speichern** (Entwurf) oder weiter **bearbeiten**. Entwürfe tragen ein Wasserzeichen.
### Sets (Positionen zusammenfassen)
- Ein **Set** aus mfr fasst die darauf folgenden Positionen zusammen; mit **„Auf Setpreis
umstellen“** (Waage-Symbol an der Set-Zeile) erhält das Set die Summe seiner Positionen als
Preis, die Positionen selbst werden ohne Preis gedruckt (nicht rückgängig zu machen).
- **„Set-Struktur bearbeiten“** (Menü) zeigt alle Positionen je Auftrag. Positionen per
**Drag & Drop auf ein Set** ziehen, um sie zuzuordnen; auf die Auftragszeile oder den Bereich
„oberste Ebene“ ziehen, um sie aus dem Set zu lösen. **Reihenfolge**: Zeile an den oberen
bzw. unteren Rand einer anderen Zeile ziehen (davor/danach) oder ▲ / ▼ nutzen. Mit ◀ / ▶ eine
Ebene höher/tiefer. Sets lassen sich über das Dreieck zuklappen und auch zugeklappt verschieben.
Sets können Sets enthalten; ein verschobenes Set nimmt seine Positionen mit. Über die Leiste
„Neu“ lassen sich neue **Set**- oder **Text**-Zeilen anlegen (auch innerhalb eines Sets).
- Jede Änderung wird sofort übernommen und im Editor sowie in der Vorschau abgebildet
(eingerückt je Ebene); die Rechnungssumme ändert sich dadurch nicht.
### Finalisieren & Versenden ### Finalisieren & Versenden
1. **Bestätigen/Finalisieren**: Die Rechnung erhält eine Rechnungsnummer, das PDF wird erzeugt und gespeichert. 1. **Bestätigen/Finalisieren**: Die Rechnung erhält eine Rechnungsnummer, das PDF wird erzeugt und gespeichert.
2. Ist eine E-Mail-Adresse hinterlegt, wird die Rechnung **automatisch als PDF-Anhang** per E-Mail versendet. 2. Ist eine E-Mail-Adresse hinterlegt, wird die Rechnung **automatisch als PDF-Anhang** per E-Mail versendet.
+18 -12
View File
@@ -95,6 +95,7 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
switch (d.Target) switch (d.Target)
{ {
case "email": return SetNewText(s, "invoiceemail", d, ref oldValue, ref newValue); case "email": return SetNewText(s, "invoiceemail", d, ref oldValue, ref newValue);
case "emailcc": return SetNewText(s, "invoiceemailcc", d, ref oldValue, ref newValue); // "Zusatzempfänger" — Cc recipient
case "address": case "address":
// Structured dialog form posts a JSON object; a plain string keeps the legacy // Structured dialog form posts a JSON object; a plain string keeps the legacy
// free-text path working (backward compatibility). // free-text path working (backward compatibility).
@@ -124,6 +125,10 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
case "item.setprice": return ApplyItemSetPrice(s, d, ref oldValue, ref newValue); case "item.setprice": return ApplyItemSetPrice(s, d, ref oldValue, ref newValue);
case "block.setprice": return ApplyBlockSetPricing(s, removeMembers: false, ref oldValue, ref newValue); case "block.setprice": return ApplyBlockSetPricing(s, removeMembers: false, ref oldValue, ref newValue);
case "block.setonly": return ApplyBlockSetPricing(s, removeMembers: true, ref oldValue, ref newValue); case "block.setonly": return ApplyBlockSetPricing(s, removeMembers: true, ref oldValue, ref newValue);
case "set.assign":
case "set.level":
case "set.create":
case "set.move": return InvoiceDraftSetStructureDeltas.Apply(s, d, ref oldValue, ref newValue); // ADR 0016: user-defined set structure
default: return false; default: return false;
} }
} }
@@ -226,9 +231,12 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
/// manually afterwards (ordinary item edit), which is why this is one-way. /// manually afterwards (ordinary item edit), which is why this is one-way.
/// <para> /// <para>
/// The header row's own <c>SetItmId</c> now self-references its own id (rather than being /// The header row's own <c>SetItmId</c> now self-references its own id (rather than being
/// <c>null</c>) once <c>fds__prepInvoice</c> stopped special-casing it, so membership is /// <c>null</c>) once <c>fds__prepInvoice</c> stopped special-casing it; membership is resolved
/// determined by <c>id == Ref</c> first (captured as <paramref name="header"/> below and /// by <see cref="InvoiceSetStructure.Descendants"/>, which never yields the header itself.
/// <c>continue</c>d past) — a header is never mistaken for its own member. /// Because sets may contain sets (ADR 0016), the members are the header's <b>transitive</b>
/// descendants: a nested, still-unconverted set contributes its members' prices, a nested
/// already-converted set contributes its own set price — either way every descendant line is
/// price-nulled afterwards, so the total is conserved and nothing is counted twice.
/// </para> /// </para>
/// </summary> /// </summary>
private static bool ApplyItemSetPrice(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue) private static bool ApplyItemSetPrice(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
@@ -236,17 +244,12 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
if (string.IsNullOrEmpty(d.Ref)) return false; if (string.IsNullOrEmpty(d.Ref)) return false;
foreach (var blockTok in s.Req) foreach (var blockTok in s.Req)
{ {
if (blockTok is not JObject block || block["itm"] is not JArray lines) continue; if (blockTok is not JObject block || block["itm"] is not JArray) continue;
JObject? header = null; var lines = InvoiceSetStructure.Lines(block);
var members = new List<JObject>(); var header = lines.FirstOrDefault(co => Str(co["id"]) == d.Ref);
foreach (var lineTok in lines)
{
if (lineTok is not JObject co) continue;
if (Str(co["id"]) == d.Ref) { header = co; continue; } // the header itself is never a member, even if its SetItmId self-references
if (Str(co["SetItmId"]) == d.Ref) members.Add(co);
}
if (header == null) continue; if (header == null) continue;
if (!string.Equals(Str(header["typ"]), "set", StringComparison.OrdinalIgnoreCase)) return false; if (!string.Equals(Str(header["typ"]), "set", StringComparison.OrdinalIgnoreCase)) return false;
var members = InvoiceSetStructure.Descendants(lines, d.Ref);
oldValue = InvoiceDraftCalculator.Dec(header["vt"]).ToString(CultureInfo.InvariantCulture); oldValue = InvoiceDraftCalculator.Dec(header["vt"]).ToString(CultureInfo.InvariantCulture);
decimal sumNet = 0, sumVat = 0, sumSvcNet = 0, sumSvcVat = 0; decimal sumNet = 0, sumVat = 0, sumSvcNet = 0, sumSvcVat = 0;
@@ -256,6 +259,7 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
sumVat += InvoiceDraftCalculator.Dec(m["vv"]); sumVat += InvoiceDraftCalculator.Dec(m["vv"]);
sumSvcNet += InvoiceDraftCalculator.Dec(m["vs"]); sumSvcNet += InvoiceDraftCalculator.Dec(m["vs"]);
sumSvcVat += InvoiceDraftCalculator.Dec(m["vsv"]); sumSvcVat += InvoiceDraftCalculator.Dec(m["vsv"]);
if (Str(header["vat"]).Length == 0 && Str(m["vat"]).Length > 0) header["vat"] = Str(m["vat"]); // a user-created set adopts its members' rate
NullLinePrice(m); // ADR 0009: null (empty cell), not 0 — excluded from the sum, distinct from a real 0,00 € NullLinePrice(m); // ADR 0009: null (empty cell), not 0 — excluded from the sum, distinct from a real 0,00 €
} }
header["v"] = sumNet; header["vt"] = sumNet; header["vv"] = sumVat; header["vs"] = sumSvcNet; header["vsv"] = sumSvcVat; header["v"] = sumNet; header["vt"] = sumNet; header["vv"] = sumVat; header["vs"] = sumSvcNet; header["vsv"] = sumSvcVat;
@@ -540,6 +544,7 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
{ {
InvoiceDraftCalculator.RecomputeLineValues(session); InvoiceDraftCalculator.RecomputeLineValues(session);
InvoiceDraftCalculator.RecomputeTotals(session); InvoiceDraftCalculator.RecomputeTotals(session);
InvoiceSetStructure.RecomputeLevels(session); // ADR 0016: publish lvl + mirror setId/level onto the items contract — must run before RecomputePositions, which reads lvl (ADR 0017)
InvoiceDraftCalculator.RecomputePositions(session); InvoiceDraftCalculator.RecomputePositions(session);
InvoiceDraftCalculator.Validate(session); InvoiceDraftCalculator.Validate(session);
} }
@@ -609,6 +614,7 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
["InvoiceTitle"] = title, ["InvoiceTitle"] = title,
["SendToAddress"] = Str(session.New["invoiceaddress"]), ["SendToAddress"] = Str(session.New["invoiceaddress"]),
["SendToEmail"] = Str(session.New["invoiceemail"]), ["SendToEmail"] = Str(session.New["invoiceemail"]),
["SendToEmailCc"] = Str(session.New["invoiceemailcc"]),
["ProvisionLocation"] = loc, ["ProvisionLocation"] = loc,
["ProvisionPeriod"] = Str(session.New["provisionperiod"]), ["ProvisionPeriod"] = Str(session.New["provisionperiod"]),
["PaymentTerm"] = Str(session.Admin["paymentterms"]).ne(Str(session.New["paymentterm"])), ["PaymentTerm"] = Str(session.Admin["paymentterms"]).ne(Str(session.New["paymentterm"])),
@@ -0,0 +1,120 @@
using System.Globalization;
using Fuchs.intranet;
using Newtonsoft.Json.Linq;
namespace Fuchs.Services;
/// <summary>
/// The <c>set.*</c> patch targets of <c>inv/dpatch</c> (ADR 0016) — the user-defined set structure
/// dialog's edits, applied to the authoritative cached session. Each handler locates the block,
/// delegates the actual tree mutation to the pure <see cref="InvoiceSetStructure"/> and reports the
/// old/new values for the change history. The browser never restructures anything itself: it posts
/// one delta, the server rewrites the block (parent links, depth-first order, levels) and the normal
/// <c>draftReady</c> refresh re-renders both the editor rows and the open structure dialog.
/// <list type="bullet">
/// <item><c>set.assign</c> — <c>Ref</c> = item id, <c>Value</c> = { parent: set id | "" }: drop a line onto a set / pull it out to the top level.</item>
/// <item><c>set.level</c> — <c>Ref</c> = item id, <c>Value</c> = +1 | -1: indent under the preceding sibling set / outdent to the grandparent.</item>
/// <item><c>set.create</c> — <c>Ref</c> = block id, <c>Value</c> = { typ: "set" | "text", title, parent: set id | "" }: add a new set header or free-text line.</item>
/// <item><c>set.move</c> — <c>Ref</c> = item id, <c>Value</c> = -1 | +1 (one step up/down among its siblings) or { anchor: item id, where: "before" | "after" } (drag between rows: become the anchor's sibling, before it or after its subtree). The item's own subtree travels with it.</item>
/// </list>
/// </summary>
internal static class InvoiceDraftSetStructureDeltas
{
public static bool Apply(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue) =>
d.Target switch
{
"set.assign" => Assign(s, d, ref oldValue, ref newValue),
"set.level" => Level(s, d, ref oldValue, ref newValue),
"set.create" => Create(s, d, ref oldValue, ref newValue),
"set.move" => Move(s, d, ref oldValue, ref newValue),
_ => false
};
private static bool Assign(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
{
if (string.IsNullOrEmpty(d.Ref)) return false;
var block = BlockOfItem(s, d.Ref);
if (block == null) return false;
string parent = d.Value is JObject vo ? InvoiceSetStructure.Str(vo["parent"]) : d.ValueString;
if (!InvoiceSetStructure.Assign(block, d.Ref, parent.Trim(), out string oldParent)) return false;
oldValue = oldParent;
newValue = parent.Trim();
return true;
}
private static bool Level(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
{
if (string.IsNullOrEmpty(d.Ref)) return false;
var block = BlockOfItem(s, d.Ref);
if (block == null) return false;
int delta = ParseDelta(d.Value);
if (!InvoiceSetStructure.ChangeLevel(block, d.Ref, delta, out int oldLevel, out int newLevel)) return false;
oldValue = oldLevel.ToString(CultureInfo.InvariantCulture);
newValue = newLevel.ToString(CultureInfo.InvariantCulture);
return true;
}
private static bool Create(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
{
if (d.Value is not JObject vo) return false;
var block = FindBlock(s, d.Ref);
if (block == null) return false;
string typ = InvoiceSetStructure.Str(vo["typ"]);
string title = InvoiceSetStructure.Str(vo["title"]);
string parent = InvoiceSetStructure.Str(vo["parent"]).Trim();
string? id = InvoiceSetStructure.Create(block, typ, title, parent);
if (id == null) return false;
oldValue = "";
newValue = typ.Trim().ToLowerInvariant() + ":" + title.Trim();
return true;
}
private static bool Move(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
{
if (string.IsNullOrEmpty(d.Ref)) return false;
var block = BlockOfItem(s, d.Ref);
if (block == null) return false;
if (d.Value is JObject vo)
{
string anchor = InvoiceSetStructure.Str(vo["anchor"]).Trim();
string where = InvoiceSetStructure.Str(vo["where"]).Trim().ToLowerInvariant();
if (anchor.Length == 0 || where is not ("before" or "after")) return false;
if (!InvoiceSetStructure.Place(block, d.Ref, anchor, after: where == "after", out string oldParent, out string newParent)) return false;
oldValue = oldParent;
newValue = where + ":" + anchor + (newParent.Length > 0 ? " (" + newParent + ")" : "");
return true;
}
int delta = ParseDelta(d.Value);
if (!InvoiceSetStructure.MoveSibling(block, d.Ref, delta, out int oldIndex, out int newIndex)) return false;
oldValue = oldIndex.ToString(CultureInfo.InvariantCulture);
newValue = newIndex.ToString(CultureInfo.InvariantCulture);
return true;
}
/// <summary>Accepts a JSON number, a numeric string, or the words "in"/"out" (indent/outdent), "up"/"down". 0 when unparseable.</summary>
private static int ParseDelta(JToken? value)
{
if (value == null || value.Type == JTokenType.Null) return 0;
if (value.Type is JTokenType.Integer or JTokenType.Float) return Math.Sign(value.Value<double>());
string s = InvoiceSetStructure.Str(value).Trim().ToLowerInvariant();
if (s is "in" or "indent" or "+" or "down") return 1;
if (s is "out" or "outdent" or "-" or "up") return -1;
return int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out int n) ? Math.Sign(n) : 0;
}
private static JObject? FindBlock(InvoiceDraftSession s, string blockId)
{
if (string.IsNullOrEmpty(blockId)) return null;
foreach (var b in s.Req)
if (b is JObject bo && InvoiceSetStructure.Str(bo["Id"]) == blockId) return bo;
return null;
}
/// <summary>The block whose <c>itm</c> array contains the line <paramref name="itemId"/>.</summary>
private static JObject? BlockOfItem(InvoiceDraftSession s, string itemId)
{
foreach (var b in s.Req)
if (b is JObject bo && InvoiceSetStructure.Lines(bo).Any(l => InvoiceSetStructure.Id(l) == itemId)) return bo;
return null;
}
}
+12
View File
@@ -15,6 +15,18 @@
} }
authinfo["requestedaccount"] = ""; authinfo["requestedaccount"] = "";
authinfo["accountrequired"] = false; authinfo["accountrequired"] = false;
// Cache-buster for the per-module bundles ($ocms.getScript loads web/fis.<mdl>.<locale>.js and
// web/fis.<mdl>.css dynamically, so asp-append-version cannot cover them): the newest write
// time of any module bundle, appended as ?v=… by fis_main.js, so a gulp rebuild is picked up
// on the next page load instead of a stale cached module.
try
{
var webDir = new System.IO.DirectoryInfo(System.IO.Path.Combine(HostEnvironment.WebRootPath, "web"));
authinfo["assetversion"] = webDir.Exists
? webDir.GetFiles("fis.*").Select(f => f.LastWriteTimeUtc.Ticks).DefaultIfEmpty(0L).Max().ToString()
: "0";
}
catch { authinfo["assetversion"] = "0"; }
string appName = ViewData["AppName"] as string ?? "Fuchs Intranet"; string appName = ViewData["AppName"] as string ?? "Fuchs Intranet";
string fullName = ViewData["FullName"] as string ?? ""; string fullName = ViewData["FullName"] as string ?? "";
+4 -2
View File
@@ -77,7 +77,8 @@
"js/intranet/modules/fis.req_txt_de.js", "js/intranet/modules/fis.req_txt_de.js",
"js/intranet/modules/fis.inv_txt_de.js", "js/intranet/modules/fis.inv_txt_de.js",
"js/intranet/modules/fis.inv.js", "js/intranet/modules/fis.inv.js",
"js/intranet/modules/fis.inv_shared.js" "js/intranet/modules/fis.inv_shared.js",
"js/intranet/modules/fis.inv_sets.js"
], ],
"minify": { "enabled": true } "minify": { "enabled": true }
}, },
@@ -99,7 +100,8 @@
"js/intranet/modules/fis.req_txt_de.js", "js/intranet/modules/fis.req_txt_de.js",
"js/intranet/modules/fis.inv_txt_de.js", "js/intranet/modules/fis.inv_txt_de.js",
"js/intranet/modules/fis.req.js", "js/intranet/modules/fis.req.js",
"js/intranet/modules/fis.inv_shared.js" "js/intranet/modules/fis.inv_shared.js",
"js/intranet/modules/fis.inv_sets.js"
], ],
"minify": { "enabled": true } "minify": { "enabled": true }
}, },
+23 -13
View File
@@ -162,6 +162,9 @@ public static class FuchsPdf
/// (type <c>text</c>/<c>title</c>) show neither a price nor a position number. This is the flat /// (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>. /// (non-collapsing) rendering used for every set mode except the explicit <c>setonly</c>.
/// </summary> /// </summary>
/// <summary>Indent per nesting level of a set member on the invoice (mirrors the editor's per-level padding).</summary>
internal const double SetLevelIndentMm = 4;
private static InvoiceSetLine MapItemToLine(Dictionary<string, object?> i) private static InvoiceSetLine MapItemToLine(Dictionary<string, object?> i)
{ {
string type = i.nz("type", "").ToLowerInvariant(); string type = i.nz("type", "").ToLowerInvariant();
@@ -180,7 +183,8 @@ public static class FuchsPdf
TotalNet = total, TotalNet = total,
ShowPrice = !isText && hasPrice, ShowPrice = !isText && hasPrice,
Numbered = !isText, // numbered like the editor: every line except free-text/heading, even price-blanked set members Numbered = !isText, // numbered like the editor: every line except free-text/heading, even price-blanked set members
IsSetHeader = type == "set" IsSetHeader = type == "set",
Level = int.TryParse(i.nz("level", "0"), out int lvl) && lvl > 0 ? lvl : 0 // ADR 0016 nesting depth → indent
}; };
} }
@@ -772,9 +776,22 @@ public static class FuchsPdf
// and drops the members entirely. // and drops the members entirely.
// Blocks without any set items are unaffected and always render flat. // Blocks without any set items are unaffected and always render flat.
var setMode = InvoiceSetPricing.ModeFromInvoiceOptions(inv.InvoiceRegistration?.getString("InvoiceOptions")); var setMode = InvoiceSetPricing.ModeFromInvoiceOptions(inv.InvoiceRegistration?.getString("InvoiceOptions"));
int pos = 0;
foreach (var block in inv.InvoiceBlocks) // Function-1 (SetItmId-linked) sets still collapse via Build; block-mode sets (ADR 0009)
// and plain blocks render flat — their set row is emphasised on its own and nulled
// members map to blank cells (see MapItemToLine).
var blockLines = inv.InvoiceBlocks
.Select(block => InvoiceSetPricing.HasSetMembers(block.Items)
? InvoiceSetPricing.Build(block.Items, setMode)
: block.Items.Select(MapItemToLine).ToList())
.ToList();
// Position labels are hierarchical ("1", "1.1", "1.2", "2", …, ADR 0017) and span the
// whole invoice, so they must be computed once across every block, not per block.
InvoiceSetPricing.AssignPositionLabels(blockLines);
for (int bi = 0; bi < inv.InvoiceBlocks.Count; bi++)
{ {
var block = inv.InvoiceBlocks[bi];
if (!string.IsNullOrWhiteSpace(block.Heading)) if (!string.IsNullOrWhiteSpace(block.Heading))
{ {
var hr = tbl.AddRow(); var hr = tbl.AddRow();
@@ -783,19 +800,12 @@ public static class FuchsPdf
hr.Cells[1].AddParagraph().WithStyle("TblCell_RTitle").AddFormattedText(block.Heading, TextFormat.Bold); hr.Cells[1].AddParagraph().WithStyle("TblCell_RTitle").AddFormattedText(block.Heading, TextFormat.Bold);
} }
// Function-1 (SetItmId-linked) sets still collapse via Build; block-mode sets (ADR 0009) foreach (var line in blockLines[bi])
// and plain blocks render flat — their set row is emphasised on its own and nulled
// members map to blank cells (see MapItemToLine).
var lines = InvoiceSetPricing.HasSetMembers(block.Items)
? InvoiceSetPricing.Build(block.Items, setMode)
: block.Items.Select(MapItemToLine).ToList();
foreach (var line in lines)
{ {
bool numbered = line.Numbered; // every line except free-text/heading (mirrors the editor's RecomputePositions), even price-blanked set members
var row = tbl.AddRow(); var row = tbl.AddRow();
row.HeightRule = RowHeightRule.Auto; row.HeightRule = RowHeightRule.Auto;
row.Cells[0].AddParagraph(numbered ? (++pos).ToString() : "").Style = "TblCell_Base"; row.Cells[0].AddParagraph(line.PositionLabel).Style = "TblCell_Base";
if (line.Level > 0) row.Cells[1].Format.LeftIndent = Unit.FromMillimeter(SetLevelIndentMm * line.Level); // nested set member (ADR 0016) — indented like the editor
if (!string.IsNullOrEmpty(line.Title)) // skip the empty paragraph that added a blank line before free text 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();
+22 -10
View File
@@ -109,27 +109,35 @@ public static class InvoiceDraftCalculator
/// <summary> /// <summary>
/// Renumbers the visible line positions authoritatively (the port of the client-side /// 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 /// numbering in <c>invSumUpdate</c>): priced lines are numbered hierarchically across the
/// invoice — matching the PDF's <c>Pos.</c> column — while heading/free-text lines /// whole invoice — a set header gets a number at its own level, its members get subsidiary
/// (<c>typ</c> = "text"/"title") carry no number. The result is written onto each line's /// numbers one level deeper (e.g. "3", "3.1", "3.2") — matching the PDF's <c>Pos.</c> column
/// <c>p</c> field so it flows back to the browser (via the view state) and into the PDF; this /// (ADR 0017), while heading/free-text lines (<c>typ</c> = "text"/"title") carry no number.
/// keeps the online editor and the PDF preview showing the same position numbers, including /// Levels come from <c>lvl</c> (written by <see cref="InvoiceSetStructure"/>, default 0). The
/// after a reorder. /// result is written onto each line's <c>p</c> field (a string, since "3.1" isn't numeric) 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 labels, including after a reorder.
/// </summary> /// </summary>
public static void RecomputePositions(InvoiceDraftSession session) public static void RecomputePositions(InvoiceDraftSession session)
{ {
int pos = 0; var lines = new List<JObject>();
var input = new List<(bool Numbered, int Level)>();
foreach (var blockTok in session.Req) foreach (var blockTok in session.Req)
{ {
if (blockTok is not JObject block || block["itm"] is not JArray lines) continue; if (blockTok is not JObject block || block["itm"] is not JArray itms) continue;
foreach (var lineTok in lines) foreach (var lineTok in itms)
{ {
if (lineTok is not JObject co) continue; if (lineTok is not JObject co) continue;
string typ = Str(co["typ"]).Trim().ToLowerInvariant(); string typ = Str(co["typ"]).Trim().ToLowerInvariant();
bool numbered = typ is not ("text" or "title"); // only headings/free-text carry no number (mirrors invSumUpdate) bool numbered = typ is not ("text" or "title"); // only headings/free-text carry no number (mirrors invSumUpdate)
co["p"] = numbered ? (JToken)(++pos) : (JToken)""; int level = (int?)co[InvoiceSetStructure.LevelKey] ?? 0;
lines.Add(co);
input.Add((numbered, level));
} }
} }
var labels = InvoiceLineNumbering.Compute(input);
for (int i = 0; i < lines.Count; i++)
lines[i]["p"] = labels[i];
} }
/// <summary> /// <summary>
@@ -149,6 +157,10 @@ public static class InvoiceDraftCalculator
else if (!IsValidEmail(email)) else if (!IsValidEmail(email))
Add("email", "error", "Die E-Mail-Adresse ist ungültig."); Add("email", "error", "Die E-Mail-Adresse ist ungültig.");
string emailCc = Str(session.New["invoiceemailcc"]).Trim(); // "Zusatzempfänger" — optional, may stay empty
if (emailCc.Length > 0 && !IsValidEmail(emailCc))
Add("emailcc", "error", "Die E-Mail-Adresse des Zusatzempfängers ist ungültig.");
if (Str(session.New["invoiceaddress"]).Trim().Length == 0) if (Str(session.New["invoiceaddress"]).Trim().Length == 0)
Add("address", "warning", "Es ist keine Rechnungsanschrift hinterlegt."); Add("address", "warning", "Es ist keine Rechnungsanschrift hinterlegt.");
+79 -48
View File
@@ -1,4 +1,5 @@
using static OCORE.commons; using System.Linq;
using static OCORE.commons;
using static OCORE.OCORE_dictionaries; using static OCORE.OCORE_dictionaries;
namespace Fuchs.intranet; namespace Fuchs.intranet;
@@ -40,13 +41,24 @@ public sealed class InvoiceSetLine
public bool Numbered { get; init; } = true; public bool Numbered { get; init; } = true;
/// <summary>True for the set header line (rendered emphasised).</summary> /// <summary>True for the set header line (rendered emphasised).</summary>
public bool IsSetHeader { get; init; } public bool IsSetHeader { get; init; }
/// <summary>Nesting depth (0 = top level; a member of a top-level set is 1, a member of a set inside it is 2 …) — rendered as an indent (ADR 0016).</summary>
public int Level { get; init; }
/// <summary>
/// The hierarchical position label ("1", "1.1", "1.2", "2", …) as printed in the PDF's
/// <c>Pos.</c> column (ADR 0017), or "" for unnumbered lines. Assigned via
/// <see cref="InvoiceSetPricing.AssignPositionLabels"/> across the whole invoice, since a
/// single <see cref="Build"/> call only sees one block.
/// </summary>
public string PositionLabel { get; set; } = "";
} }
/// <summary> /// <summary>
/// Transforms raw invoice line items into display lines according to a /// Transforms raw invoice line items into display lines according to a
/// <see cref="SetDisplayMode"/>. A "set" item is identified by /// <see cref="SetDisplayMode"/>. A "set" item is identified by
/// <c>type == "set"</c>; its members carry <c>setId</c> equal to the set /// <c>type == "set"</c>; its members carry <c>setId</c> equal to the set
/// header's <c>id</c>. Items that belong to no set pass through unchanged. /// header's <c>id</c> — a nested set is itself a member of its parent set
/// (ADR 0016), and every line reports its depth as <see cref="InvoiceSetLine.Level"/>.
/// Items that belong to no set pass through unchanged.
/// ///
/// The invoice total is taken from the registration balance, not from these /// The invoice total is taken from the registration balance, not from these
/// lines, so calling <see cref="Build"/> is a pure, non-mutating transformation /// lines, so calling <see cref="Build"/> is a pure, non-mutating transformation
@@ -94,65 +106,81 @@ public static class InvoiceSetPricing
items.Any(i => !string.IsNullOrEmpty(SetIdOf(i))); items.Any(i => !string.IsNullOrEmpty(SetIdOf(i)));
/// <summary> /// <summary>
/// Produces the ordered display lines for the given items and mode. /// Produces the display lines for the given items and mode, <b>preserving the input order</b>
/// Standalone items are always shown with their price. A set header whose /// (the draft service already linearises each block depth-first — set, then its members,
/// own <c>total_net</c> is still zero (not yet converted via the set-item /// recursively — so editor and PDF print the very same sequence, ADR 0008/0016).
/// switch) is rendered blank with its members individually priced, /// Standalone items are always shown with their price. A set header whose own
/// regardless of <paramref name="mode"/>. /// <c>total_net</c> is still zero (not yet converted via the set-item switch) is rendered
/// blank with its members individually priced, regardless of <paramref name="mode"/>.
/// Sets may nest: a line is governed by its <i>nearest converted ancestor</i> — inside one, it
/// is price-blanked (SetPrice) or dropped (SetOnly); a nested set that is itself converted
/// but sits inside an unconverted one behaves like a converted top-level set for its own subtree.
/// </summary> /// </summary>
public static List<InvoiceSetLine> Build(IReadOnlyList<Dictionary<string, object?>> items, SetDisplayMode mode) public static List<InvoiceSetLine> Build(IReadOnlyList<Dictionary<string, object?>> items, SetDisplayMode mode)
{ {
var result = new List<InvoiceSetLine>(); var result = new List<InvoiceSetLine>();
if (items.Count == 0) return result; if (items.Count == 0) return result;
// Group members by their set id. var headers = new Dictionary<string, Dictionary<string, object?>>();
var membersBySet = items foreach (var i in items)
.Where(i => !IsSetHeader(i) && !string.IsNullOrEmpty(SetIdOf(i))) if (IsSetHeader(i) && i.nz("id", "").Length > 0) headers.TryAdd(i.nz("id", ""), i);
.GroupBy(SetIdOf)
.ToDictionary(g => g.Key!, g => g.ToList()); static bool Converted(Dictionary<string, object?> h)
{
FuchsPdf.ParseDec(h.no("total_net", 0), out decimal own);
return own != 0;
}
// Walks the parent chain (setId → header) and reports depth + whether any ancestor is converted.
(int level, bool insideConverted) Ancestry(Dictionary<string, object?> i)
{
int level = 0; bool inside = false;
var seen = new HashSet<string> { i.nz("id", "") };
string? pid = ParentIdOf(i, headers);
while (pid != null && seen.Add(pid))
{
level++;
var h = headers[pid];
if (Converted(h)) inside = true;
pid = ParentIdOf(h, headers);
}
return (level, inside);
}
foreach (var item in items) foreach (var item in items)
{ {
var (level, insideConverted) = Ancestry(item);
if (insideConverted && mode == SetDisplayMode.SetOnly) continue; // collapsed into its set's single line
if (IsSetHeader(item)) if (IsSetHeader(item))
{ {
string setId = HeaderIdOf(item);
var members = membersBySet.TryGetValue(setId, out var m) ? m : new List<Dictionary<string, object?>>();
FuchsPdf.ParseDec(item.no("total_net", 0), out decimal headerOwnTotal); FuchsPdf.ParseDec(item.no("total_net", 0), out decimal headerOwnTotal);
// Unconverted (still zero-priced) header: blank price, members keep their own price.
if (headerOwnTotal == 0) // Converted: priced line; members blank (SetPrice) — or dropped above (SetOnly).
{ bool show = headerOwnTotal != 0 && !insideConverted;
// Not yet converted (the set-item switch, ApplyItemSetPrice, hasn't run): the result.Add(HeaderLine(item, show ? headerOwnTotal : 0, showPrice: show, level));
// header still carries its as-delivered zero price, so the chosen display mode
// does not apply yet — show the header blank and each member with its own,
// individual price, exactly as if there were no set grouping at all.
result.Add(HeaderLine(item, 0, showPrice: false));
foreach (var mem in members) result.Add(MemberLine(mem, showPrice: true));
continue;
}
switch (mode)
{
case SetDisplayMode.SetPrice:
result.Add(HeaderLine(item, headerOwnTotal, showPrice: true));
foreach (var mem in members) result.Add(MemberLine(mem, showPrice: false));
break;
case SetDisplayMode.SetOnly:
result.Add(HeaderLine(item, headerOwnTotal, showPrice: true));
break;
}
}
else if (!string.IsNullOrEmpty(SetIdOf(item)))
{
// Member — already emitted next to its header above; skip standalone emission.
} }
else else
{ {
result.Add(MemberLine(item, showPrice: true)); // standalone item result.Add(MemberLine(item, showPrice: !insideConverted, level));
} }
} }
return result; return result;
} }
/// <summary>
/// Computes and assigns <see cref="InvoiceSetLine.PositionLabel"/> across every block's lines
/// (in the order the blocks are printed), so numbering spans the whole invoice exactly like
/// <c>InvoiceDraftCalculator.RecomputePositions</c> does for the online editor (ADR 0017).
/// </summary>
public static void AssignPositionLabels(IEnumerable<IReadOnlyList<InvoiceSetLine>> blocks)
{
var lines = blocks.SelectMany(b => b).ToList();
var labels = InvoiceLineNumbering.Compute(lines.Select(l => (l.Numbered, l.Level)).ToList());
for (int i = 0; i < lines.Count; i++)
lines[i].PositionLabel = labels[i];
}
// ── helpers ────────────────────────────────────────────────────────────── // ── helpers ──────────────────────────────────────────────────────────────
private static bool IsSetHeader(Dictionary<string, object?> i) => private static bool IsSetHeader(Dictionary<string, object?> i) =>
string.Equals(i.nz("type", ""), "set", StringComparison.OrdinalIgnoreCase); string.Equals(i.nz("type", ""), "set", StringComparison.OrdinalIgnoreCase);
@@ -174,13 +202,14 @@ public static class InvoiceSetPricing
return string.IsNullOrEmpty(s) ? null : s; return string.IsNullOrEmpty(s) ? null : s;
} }
private static string HeaderIdOf(Dictionary<string, object?> i) /// <summary>The owning set header's id: <c>setId</c> when it names another known header; null for top-level lines and self-references.</summary>
private static string? ParentIdOf(Dictionary<string, object?> i, Dictionary<string, Dictionary<string, object?>> headers)
{ {
string s = i.nz("setId", ""); string s = SetIdOf(i) ?? "";
return string.IsNullOrEmpty(s) ? i.nz("id", "") : s; return s.Length > 0 && s != i.nz("id", "") && headers.ContainsKey(s) ? s : null;
} }
private static InvoiceSetLine HeaderLine(Dictionary<string, object?> i, decimal setTotal, bool showPrice) => new() private static InvoiceSetLine HeaderLine(Dictionary<string, object?> i, decimal setTotal, bool showPrice, int level) => new()
{ {
Id = i.nz("id", ""), Id = i.nz("id", ""),
Title = i.nz("title", ""), Title = i.nz("title", ""),
@@ -190,10 +219,11 @@ public static class InvoiceSetPricing
TotalNet = setTotal, TotalNet = setTotal,
ShowPrice = showPrice, ShowPrice = showPrice,
Numbered = true, // a set header is always numbered Numbered = true, // a set header is always numbered
IsSetHeader = true IsSetHeader = true,
Level = level
}; };
private static InvoiceSetLine MemberLine(Dictionary<string, object?> i, bool showPrice) private static InvoiceSetLine MemberLine(Dictionary<string, object?> i, bool showPrice, int level)
{ {
FuchsPdf.ParseDec(i.no("price_net", 0), out decimal price); FuchsPdf.ParseDec(i.no("price_net", 0), out decimal price);
FuchsPdf.ParseDec(i.no("total_net", 0), out decimal total); FuchsPdf.ParseDec(i.no("total_net", 0), out decimal total);
@@ -207,7 +237,8 @@ public static class InvoiceSetPricing
TotalNet = total, TotalNet = total,
ShowPrice = showPrice && !IsNoPriceLine(i), // headings/free text print no price ShowPrice = showPrice && !IsNoPriceLine(i), // headings/free text print no price
Numbered = !IsNoPriceLine(i), // …but are still numbered unless heading/free-text Numbered = !IsNoPriceLine(i), // …but are still numbered unless heading/free-text
IsSetHeader = false IsSetHeader = false,
Level = level
}; };
} }
} }
+404
View File
@@ -0,0 +1,404 @@
using Newtonsoft.Json.Linq;
namespace Fuchs.intranet;
/// <summary>
/// The explicit, user-editable <b>set structure</b> of an invoice draft (ADR 0016): which line
/// belongs to which set, and how deep it sits. Every line in a block's <c>itm</c> array carries a
/// parent link in <c>SetItmId</c> (the id of the <c>typ == "set"</c> line that owns it, or
/// null/self for a top-level line). Sets may contain sets, so the links form a forest per block;
/// the depth of each line is published as <c>lvl</c> (0 = top level) and mirrored onto the
/// <c>items</c> contract shape as <c>setId</c>/<c>level</c>.
/// <para>
/// The implicit derivation (<c>fds__prepInvoice</c> tags every item following a zero-priced Set
/// header with that header's id) is kept as the <b>initial</b> structure; this class only applies
/// the user's explicit changes on top of it — assigning a line to a set, pulling it out, changing
/// its level, or creating a new set/text line — and then re-linearises the block depth-first so
/// every set is immediately followed by its members, recursively. Pure and static, so it is
/// exhaustively unit-tested (<c>InvoiceSetStructureTests</c>); the draft service merely delegates.
/// </para>
/// </summary>
public static class InvoiceSetStructure
{
/// <summary>Parent link on the editor <c>co</c> line shape.</summary>
public const string ParentKey = "SetItmId";
/// <summary>Depth (0 = top level) on the editor <c>co</c> line shape.</summary>
public const string LevelKey = "lvl";
// ── Structural queries ───────────────────────────────────────────────────
internal static string Id(JObject co) => Str(co["id"]);
internal static bool IsSet(JObject co) =>
string.Equals(Str(co["typ"]), "set", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// The effective parent set id of a line: its <c>SetItmId</c> when that names <i>another</i>
/// set line of the same block; null for top-level lines, a self-reference (the shape
/// <c>fds__prepInvoice</c> delivers for a header), an unknown id or a non-set target.
/// </summary>
internal static string? ParentOf(JObject co, IReadOnlyDictionary<string, JObject> byId)
{
string p = Str(co[ParentKey]);
if (p.Length == 0 || p == Id(co)) return null;
return byId.TryGetValue(p, out var parent) && IsSet(parent) ? p : null;
}
/// <summary>The block's line objects in their current order (non-object entries skipped).</summary>
internal static List<JObject> Lines(JObject block) =>
block["itm"] is JArray arr ? arr.OfType<JObject>().ToList() : new List<JObject>();
internal static Dictionary<string, JObject> ById(IEnumerable<JObject> lines)
{
var d = new Dictionary<string, JObject>();
foreach (var l in lines) { string id = Id(l); if (id.Length > 0) d.TryAdd(id, l); }
return d;
}
/// <summary>Depth of every line (0 = top level). A cyclic chain is cut at its first repeat, so it degrades to a root.</summary>
internal static Dictionary<string, int> Levels(List<JObject> lines)
{
var byId = ById(lines);
var result = new Dictionary<string, int>();
foreach (var line in lines)
{
int depth = 0;
var seen = new HashSet<string> { Id(line) };
var cur = line;
while (ParentOf(cur, byId) is { } pid)
{
if (!seen.Add(pid)) { depth = 0; break; } // cyclic chain — degrade to top level (Linearize emits it as a root, too)
depth++;
cur = byId[pid];
}
result[Id(line)] = depth;
}
return result;
}
/// <summary>All lines transitively owned by the set <paramref name="setId"/> (never the set itself), in current order.</summary>
public static List<JObject> Descendants(List<JObject> lines, string setId)
{
var byId = ById(lines);
var children = new Dictionary<string, List<JObject>>();
foreach (var l in lines)
if (ParentOf(l, byId) is { } p)
(children.TryGetValue(p, out var lst) ? lst : children[p] = new List<JObject>()).Add(l);
var result = new List<JObject>();
var seen = new HashSet<string> { setId };
var queue = new Queue<string>();
queue.Enqueue(setId);
while (queue.Count > 0)
{
string cur = queue.Dequeue();
if (!children.TryGetValue(cur, out var kids)) continue;
foreach (var k in kids)
{
string kid = Id(k);
if (!seen.Add(kid)) continue;
result.Add(k);
queue.Enqueue(kid);
}
}
return result;
}
/// <summary>The id of the top-level ancestor of a line (itself when it is top level).</summary>
private static string RootOf(JObject line, IReadOnlyDictionary<string, JObject> byId)
{
var seen = new HashSet<string> { Id(line) };
var cur = line;
while (ParentOf(cur, byId) is { } pid && seen.Add(pid)) cur = byId[pid];
return Id(cur);
}
// ── Recompute / linearise ────────────────────────────────────────────────
/// <summary>
/// Publishes the depth of every line (<c>lvl</c>) and mirrors parent + depth onto the
/// <c>items</c> contract shape (<c>setId</c>/<c>level</c>) for every block — without
/// reordering anything. Runs on every refresh so the view state and the PDF read one truth.
/// </summary>
public static void RecomputeLevels(InvoiceDraftSession session)
{
foreach (var blockTok in session.Req)
if (blockTok is JObject block) ApplyLevels(block);
}
private static void ApplyLevels(JObject block)
{
var lines = Lines(block);
if (lines.Count == 0) return;
var byId = ById(lines);
var levels = Levels(lines);
foreach (var line in lines) line[LevelKey] = levels[Id(line)];
if (block["items"] is not JArray items) return;
foreach (var io in items.OfType<JObject>())
{
string id = Str(io["id"]);
if (!byId.TryGetValue(id, out var co)) continue; // e.g. combined-sum rows use differing ids — leave untouched
io["level"] = levels[id];
io["setId"] = ParentOf(co, byId) is { } p ? p : JValue.CreateNull();
}
}
/// <summary>
/// Reorders a block's lines depth-first — each set immediately followed by its members,
/// recursively, siblings keeping their current relative order — then republishes levels and
/// mirrors the order onto <c>items</c>. Lines caught in a cyclic chain are emitted as roots.
/// </summary>
public static void Linearize(JObject block)
{
var lines = Lines(block);
if (lines.Count == 0) return;
var byId = ById(lines);
var children = new Dictionary<string, List<JObject>>();
var roots = new List<JObject>();
foreach (var l in lines)
{
if (ParentOf(l, byId) is { } p)
(children.TryGetValue(p, out var lst) ? lst : children[p] = new List<JObject>()).Add(l);
else roots.Add(l);
}
var ordered = new List<JObject>();
var seen = new HashSet<string>();
void Visit(JObject l)
{
if (!seen.Add(Id(l))) return;
ordered.Add(l);
if (children.TryGetValue(Id(l), out var kids)) foreach (var k in kids) Visit(k);
}
foreach (var r in roots) Visit(r);
foreach (var l in lines) Visit(l); // anything unreachable (cycle) — keep, as a root
var itm = (JArray)block["itm"]!;
itm.Clear();
foreach (var l in ordered) itm.Add(l);
if (block["items"] is JArray items)
{
var itemsById = new Dictionary<string, JObject>();
var rest = new List<JObject>();
foreach (var io in items.OfType<JObject>())
{
string id = Str(io["id"]);
if (id.Length > 0 && byId.ContainsKey(id) && itemsById.TryAdd(id, io)) continue;
rest.Add(io);
}
items.Clear();
foreach (var l in ordered) if (itemsById.TryGetValue(Id(l), out var io)) items.Add(io);
foreach (var io in rest) items.Add(io);
}
ApplyLevels(block);
}
// ── Mutations ────────────────────────────────────────────────────────────
/// <summary>
/// Assigns line <paramref name="itemId"/> to the set <paramref name="parentId"/> ("" = pull it
/// out to the top level). The line's own descendants move with it (one level deeper/shallower).
/// A line dropped onto a set becomes its last member; a line pulled out is placed right after
/// the subtree it came from. No-op (false) when the item is unknown, the target is not a set
/// line of this block, the target is the item itself or one of its descendants (cycle), or
/// the parent is unchanged.
/// </summary>
public static bool Assign(JObject block, string itemId, string parentId, out string oldParent)
{
oldParent = "";
var lines = Lines(block);
var byId = ById(lines);
if (!byId.TryGetValue(itemId, out var item)) return false;
string? current = ParentOf(item, byId);
oldParent = current ?? "";
string? target = parentId.Length == 0 ? null : parentId;
if (target == current) return false;
if (target != null)
{
if (target == itemId || !byId.TryGetValue(target, out var parent) || !IsSet(parent)) return false;
if (Descendants(lines, itemId).Any(d => Id(d) == target)) return false; // would create a cycle
}
var itm = (JArray)block["itm"]!;
if (target != null)
{
item.Remove();
itm.Add(item); // last member of the target set
}
else
{
string root = RootOf(item, byId);
var subtree = new HashSet<string>(Descendants(lines, root).Select(Id)) { root };
var own = new HashSet<string>(Descendants(lines, itemId).Select(Id)) { itemId };
var last = lines.Where(l => subtree.Contains(Id(l)) && !own.Contains(Id(l))).LastOrDefault();
item.Remove();
if (last != null) last.AddAfterSelf(item); else itm.Add(item);
}
item[ParentKey] = ParentToken(target);
Linearize(block);
return true;
}
/// <summary>
/// Moves a line one level deeper (<paramref name="delta"/> &gt; 0: it becomes the last member of
/// the nearest preceding sibling set) or one level up (<paramref name="delta"/> &lt; 0: it joins
/// its grandparent right after its former parent's subtree). Returns false when no such move
/// exists (no preceding sibling set / already top level) or the item is unknown.
/// </summary>
public static bool ChangeLevel(JObject block, string itemId, int delta, out int oldLevel, out int newLevel)
{
oldLevel = newLevel = 0;
if (delta == 0) return false;
Linearize(block); // guarantees contiguous subtrees + fresh levels
var lines = Lines(block);
var byId = ById(lines);
if (!byId.TryGetValue(itemId, out var item)) return false;
oldLevel = newLevel = Levels(lines)[itemId];
string? parent = ParentOf(item, byId);
if (delta > 0)
{
JObject? sibling = null;
foreach (var l in lines)
{
if (Id(l) == itemId) break;
if (ParentOf(l, byId) == parent && IsSet(l)) sibling = l; // nearest preceding sibling set
}
if (sibling == null) return false;
if (!Assign(block, itemId, Id(sibling), out _)) return false;
newLevel = Levels(Lines(block))[itemId];
return true;
}
if (parent == null) return false;
string grand = ParentOf(byId[parent], byId) ?? "";
// Place the line (with its own subtree) right after its former parent's subtree.
var parentSubtree = new HashSet<string>(Descendants(lines, parent).Select(Id));
var own = new HashSet<string>(Descendants(lines, itemId).Select(Id)) { itemId };
var anchor = lines.Where(l => (parentSubtree.Contains(Id(l)) || Id(l) == parent) && !own.Contains(Id(l))).Last();
var moving = lines.Where(l => own.Contains(Id(l))).ToList();
foreach (var l in moving) l.Remove();
JToken after = anchor;
foreach (var l in moving) { after.AddAfterSelf(l); after = l; }
item[ParentKey] = ParentToken(grand.Length > 0 ? grand : null);
Linearize(block);
newLevel = Levels(Lines(block))[itemId];
return true;
}
/// <summary>
/// Places line <paramref name="itemId"/> (with its whole subtree) immediately <b>before</b> the
/// anchor line or <b>after</b> the anchor's subtree, adopting the anchor's parent — i.e. it
/// becomes the anchor's sibling. This is the reorder primitive behind drag-between-rows and
/// the ▲/▼ buttons. Rejected (false) when the item or anchor is unknown, they are the same
/// line, or the anchor sits inside the item's own subtree.
/// </summary>
public static bool Place(JObject block, string itemId, string anchorId, bool after, out string oldParent, out string newParent)
{
oldParent = newParent = "";
if (itemId == anchorId) return false;
Linearize(block); // contiguous subtrees
var lines = Lines(block);
var byId = ById(lines);
if (!byId.TryGetValue(itemId, out var item) || !byId.TryGetValue(anchorId, out var anchor)) return false;
var own = new HashSet<string>(Descendants(lines, itemId).Select(Id)) { itemId };
if (own.Contains(anchorId)) return false; // cannot place a set relative to its own member
oldParent = ParentOf(item, byId) ?? "";
newParent = ParentOf(anchor, byId) ?? "";
var moving = lines.Where(l => own.Contains(Id(l))).ToList();
foreach (var l in moving) l.Remove();
JToken slot = after
? lines.Where(l => !own.Contains(Id(l)) && (Id(l) == anchorId || Descendants(lines, anchorId).Any(d => Id(d) == Id(l)))).Last()
: anchor;
if (after) { foreach (var l in moving) { slot.AddAfterSelf(l); slot = l; } }
else { foreach (var l in moving) slot.AddBeforeSelf(l); }
item[ParentKey] = ParentToken(newParent.Length > 0 ? newParent : null);
Linearize(block);
return true;
}
/// <summary>
/// Moves a line one step up (<paramref name="delta"/> &lt; 0) or down (&gt; 0) among its
/// siblings (same parent); its subtree travels with it. False at the first/last position or
/// for an unknown line. Reports the 0-based sibling index before and after.
/// </summary>
public static bool MoveSibling(JObject block, string itemId, int delta, out int oldIndex, out int newIndex)
{
oldIndex = newIndex = -1;
if (delta == 0) return false;
Linearize(block);
var lines = Lines(block);
var byId = ById(lines);
if (!byId.TryGetValue(itemId, out var item)) return false;
string? parent = ParentOf(item, byId);
var siblings = lines.Where(l => ParentOf(l, byId) == parent).ToList();
oldIndex = siblings.FindIndex(l => Id(l) == itemId);
int target = oldIndex + Math.Sign(delta);
if (target < 0 || target >= siblings.Count) return false;
if (!Place(block, itemId, Id(siblings[target]), after: delta > 0, out _, out _)) return false;
newIndex = target;
return true;
}
/// <summary>
/// Creates a new line of <paramref name="typ"/> "set" (an unconverted, zero-priced set header
/// that can later be switched to a set price) or "text" (a free-text line) titled
/// <paramref name="title"/>, as the last member of <paramref name="parentId"/> ("" = end of the
/// block). Both line shapes (<c>itm</c> + <c>items</c>) are written. Returns the new id, or
/// null when the type is unknown, the title is blank or the parent is not a set of this block.
/// </summary>
public static string? Create(JObject block, string typ, string title, string parentId)
{
string kind = (typ ?? "").Trim().ToLowerInvariant();
title = (title ?? "").Trim();
if (kind is not ("set" or "text") || title.Length == 0) return null;
var lines = Lines(block);
var byId = ById(lines);
string? parent = parentId.Length == 0 ? null : parentId;
if (parent != null && (!byId.TryGetValue(parent, out var p) || !IsSet(p))) return null;
string id = "sN" + Guid.NewGuid().ToString("N")[..8];
string rate = lines.Select(l => Str(l["vat"])).FirstOrDefault(r => r.Length > 0) ?? "19%";
JObject co, io;
if (kind == "set")
{
co = new JObject
{
["id"] = id, ["typ"] = "set", ["p"] = "", ["q"] = "", ["t"] = title, ["tt"] = "",
["v"] = 0, ["vt"] = 0, ["vv"] = 0, ["vs"] = 0, ["vsv"] = 0, ["vat"] = rate, ["det"] = false,
[ParentKey] = ParentToken(parent)
};
io = new JObject
{
["id"] = id, ["type"] = "set", ["title"] = title, ["desc"] = "", ["qty"] = "",
["price_net"] = 0, ["total_net"] = 0, ["vat"] = rate, ["setId"] = ParentToken(parent)
};
}
else
{
string html = "<p>" + System.Net.WebUtility.HtmlEncode(title) + "</p>";
co = new JObject
{
["id"] = id, ["typ"] = "Text", ["p"] = "", ["q"] = JValue.CreateNull(), ["t"] = html, ["tt"] = JValue.CreateNull(),
["v"] = JValue.CreateNull(), ["vt"] = JValue.CreateNull(), ["vv"] = JValue.CreateNull(), // free text: no price at all (empty cell, ADR 0009 null semantics)
["vs"] = JValue.CreateNull(), ["vsv"] = JValue.CreateNull(), ["vat"] = "", ["det"] = false,
[ParentKey] = ParentToken(parent)
};
io = new JObject
{
["id"] = id, ["type"] = "text", ["title"] = "", ["desc"] = html, ["qty"] = "",
["price_net"] = "", ["total_net"] = "", ["vat"] = "", ["setId"] = ParentToken(parent)
};
}
if (block["itm"] is not JArray itm) { itm = new JArray(); block["itm"] = itm; }
itm.Add(co);
if (block["items"] is JArray items) items.Add(io); else block["items"] = new JArray { io };
Linearize(block);
return id;
}
// ── helpers ──────────────────────────────────────────────────────────────
private static JToken ParentToken(string? parent) => parent != null ? new JValue(parent) : JValue.CreateNull();
internal static string Str(JToken? t) =>
t == null || t.Type == JTokenType.Null ? "" : t.Type == JTokenType.String ? t.Value<string>() ?? "" : t.ToString();
}
+4 -1
View File
@@ -17,8 +17,11 @@
} }
$ocms[mdl].auth = auth; $ocms[mdl].auth = auth;
if (auth.manage > 0) { if (auth.manage > 0) {
/* ?v=<assetversion> (newest bundle write time, emitted by _Layout) busts the browser
cache of the dynamically imported module bundle + css after a gulp rebuild. */
let av = '?v=' + encodeURIComponent($ocms.auth.assetversion || '0');
$ocms.getScript({ $ocms.getScript({
module: mdl, script: ['/web/fis', mdl, $ocms.auth.locale || 'de', 'js'].join('.'), css: ['/web/fis', mdl, 'css'].join('.'), condition: typeof $ocms[mdl].init2 !== 'function' module: mdl, script: ['/web/fis', mdl, $ocms.auth.locale || 'de', 'js'].join('.') + av, css: ['/web/fis', mdl, 'css'].join('.') + av, condition: typeof $ocms[mdl].init2 !== 'function'
}, function () { $ocms[mdl].init2(); }); }, function () { $ocms[mdl].init2(); });
} }
}, error: function () { }, error: function () {
+200
View File
@@ -0,0 +1,200 @@
/* ── User-defined set structure dialog (ADR 0016) ────────────────────────────
Lets the user define which line belongs to which set (and how deep) instead of relying only
on the implicit "everything after a Set header until the next Set" derivation that
fds__prepInvoice delivers as the initial structure. The dialog is a pure view of the
authoritative server state (state.req[].itm — the co shape with SetItmId + lvl): every action
posts ONE delta (set.assign / set.level / set.create) via $inv.d.sync, the server rewrites the
cached block (parent links, depth-first order, levels) and the normal draftReady/dstate
refresh re-renders both the editor rows and this dialog (applyState -> $inv.sets.render).
The browser never restructures anything itself (ADR 0006/0008). */
$inv.sets = {
o: null, /* open dialog handle ($ocms.dlg result) or null */
c: null, /* dialog content container */
drag: null, /* { id, bid } of the row currently being dragged */
/* Reorder = set.move (▲▼ or drag before/after a row), membership = set.assign (drag into a set /
root zone), depth = set.level (◀▶). Folding is purely a view state kept in $inv.sets.collapsed. */
isOpen: () => !!$inv.sets.o,
open: function () {
let tbl = $inv.d.tbl();
if (tbl.length < 1 || $inv.sets.isOpen()) { return; }
if ($inv.d.token() === '' && !tbl.data('dstate')) { return; } /* no draft session (e.g. reminder editor) — nothing to structure */
let c = $$.dc('setstruct');
$inv.sets.c = c;
$inv.sets.o = $ocms.dlg(c, {
title: $ict.setst, size: [-1, 960], exclusive: false,
close: () => { $inv.sets.o = null; $inv.sets.c = null; $inv.sets.drag = null; }
});
let state = tbl.data('dstate');
if (state) { $inv.sets.render(state); } else { $inv.d.refresh(); }
},
/* plain-text title of a co line (co.t is HTML for text lines / titles) */
title: (co) => {
let t = $$.d().html(co.t || '').text().replace(/\s+/g, ' ').trim();
return t !== '' ? t : ($ict.setsNoTitle || '');
},
typLbl: (co) => {
let k = (co.typ || '').toString().toLowerCase();
return ($ict.settyp || {})[k] || co.typ || '';
},
isSet: (co) => (co.typ || '').toString().toLowerCase() === 'set',
/* Normalised parent id of a line, mirroring InvoiceSetStructure.ParentOf: SetItmId when it
names ANOTHER set line of the same block; '' for top level / self-reference / unknown. */
parentOf: (co, byId) => {
let p = (co.SetItmId == null ? '' : co.SetItmId).toString(), id = (co.id || '').toString();
if (p === '' || p === id) { return ''; }
let t = byId[p];
return (t && $inv.sets.isSet(t)) ? p : '';
},
isDescendant: (candidateId, ancestorId, byId) => {
let seen = {}, cur = byId[candidateId];
while (cur) {
let p = $inv.sets.parentOf(cur, byId);
if (p === '' || seen[p]) { return false; }
if (p === ancestorId) { return true; }
seen[p] = true; cur = byId[p];
}
return false;
},
/* Whether a line may be dropped onto the given target set (same block, not itself, no cycle). */
canDrop: (drag, targetId, bid, byId) =>
!!drag && drag.bid === bid && drag.id !== targetId && !$inv.sets.isDescendant(targetId, drag.id, byId),
sync: (delta) => {
if ($inv.sets.c) { $inv.sets.c.aC('busy'); }
return $inv.d.sync(delta);
},
render: function (state) {
let c = $inv.sets.c; if (!c) { return; }
let bdy = c.closest('.modal-body'), scroll = bdy.length ? bdy.scrollTop() : 0;
c.empty().rC('busy');
$$.dc('shint', c).text($ict.setsHint || '');
(state.req || []).forEach((b) => {
if (!b) { return; }
let bid = (b.Id || '').toString(); if (bid === '') { return; }
let itms = (b.itm || []).filter((co) => co && (co.id || '') !== ''), byId = {};
itms.forEach((co) => { byId[(co.id || '').toString()] = co; });
let blk = $$.dc('sblock', c).data({ bid: bid });
$inv.sets.rootZone($$.dc('sbhd', blk).append($$.sc('nme', b.text || b.nme || bid)), bid, byId);
let ts = $$.tblset({ class: 'stbl' }, blk);
let thr = $$.tr(ts.hd);
($ict.setsHR || []).forEach((h) => $$.th(thr, h));
itms.forEach((co) => $inv.sets.row(ts.bdy, co, bid, itms, byId));
$inv.sets.rootZone($$.dc('sroot', blk).text($ict.setsRoot || ''), bid, byId);
$inv.sets.createBar(blk, bid, itms, byId);
});
if (bdy.length) { bdy.scrollTop(scroll); }
},
/* Ancestor-collapsed test: a row is hidden while any set above it is folded. */
collapsed: {}, /* set id -> true (survives re-renders; default: everything expanded) */
hiddenByCollapse: function (co, byId) {
let seen = {}, cur = co;
while (cur) {
let p = $inv.sets.parentOf(cur, byId);
if (p === '' || seen[p]) { return false; }
if ($inv.sets.collapsed[p]) { return true; }
seen[p] = true; cur = byId[p];
}
return false;
},
/* Drop zone of a pointer position within a row: top edge = before, bottom edge = after,
middle = into (sets only; for other rows the middle counts as "after"). */
zone: function (ev, tr, isSet) {
let oe = ev.originalEvent || ev, r = tr[0].getBoundingClientRect(), y = (oe.clientY || 0) - r.top, h = r.height || 1;
if (y < h * 0.3) { return 'before'; }
if (y > h * 0.7 || !isSet) { return 'after'; }
return 'into';
},
clearDrop: () => { $('.setstruct .dropok, .setstruct .dropbefore, .setstruct .dropafter').rC('dropok dropbefore dropafter'); },
/* One line of the structure table: drag handle, fold toggle, pos, type, qty, indented title,
order buttons (▲▼ among siblings) and level buttons (◀▶). */
row: function (tbody, co, bid, itms, byId) {
let id = (co.id || '').toString(), lvl = parseInt(co.lvl || 0, 10) || 0, isSet = $inv.sets.isSet(co);
let parent = $inv.sets.parentOf(co, byId), folded = isSet && !!$inv.sets.collapsed[id];
let tr = $$.tr(tbody, { draggable: 'true' }).aC('srow lvl' + lvl).tC('isset', isSet).tC('folded', folded).data({ id: id, bid: bid });
if ($inv.sets.hiddenByCollapse(co, byId)) { tr.aC('hid'); }
$$.tdc('grip', tr).append(gi('move')).attr('title', $rct.mR);
$$.tdc('pos', tr).text(co.p != null ? co.p : '');
let typ = $$.tdc('typ', tr);
if (isSet) { /* fold toggle sits in the type column, in front of "Set" */
typ.append($$.sc('fold').attr('title', folded ? $ict.setsExpand : $ict.setsCollapse).append(gi(folded ? 'triangle-right' : 'triangle-bottom')).click((ev) => {
ev.stopPropagation();
if (folded) { delete $inv.sets.collapsed[id]; } else { $inv.sets.collapsed[id] = true; }
let state = $inv.d.tbl().data('dstate'); if (state) { $inv.sets.render(state); }
}));
}
typ.append($$.s($inv.sets.typLbl(co)));
$$.tdc('qty', tr).text(co.q || '');
let ttl = $$.tdc('ttl', tr).append($$.sc('ind').css('width', (lvl * 1.4).toString() + 'rem')).append($$.s($inv.sets.title(co)));
if (isSet) {
let n = itms.filter((x) => $inv.sets.parentOf(x, byId) === id).length;
ttl.append($$.sc('cnt', ' (' + n + ')'));
}
/* siblings (same parent, current order) drive the ▲▼ buttons; a preceding sibling set drives ▶ */
let sibs = itms.filter((x) => $inv.sets.parentOf(x, byId) === parent), si = sibs.findIndex((x) => (x.id || '').toString() === id);
let hasSibSet = sibs.slice(0, si).some((x) => $inv.sets.isSet(x));
let act = $$.tdc('act', tr);
let btn = (cls, glyph, title, enabled, delta) => $$.dc('ibtn ' + cls, act, { title: title }).append(gi(glyph)).tC('disabled', !enabled)
.click(() => { if (enabled) { $inv.sets.sync(delta); } });
btn('up', 'arrow-up', $ict.setsUp, si > 0, { Target: 'set.move', Ref: id, Value: -1 });
btn('down', 'arrow-down', $ict.setsDown, si >= 0 && si < sibs.length - 1, { Target: 'set.move', Ref: id, Value: 1 });
btn('out', 'chevron-left', $ict.setsOut, lvl > 0, { Target: 'set.level', Ref: id, Value: -1 });
btn('in', 'chevron-right', $ict.setsIn, hasSibSet, { Target: 'set.level', Ref: id, Value: 1 });
tr.on('dragstart', (ev) => {
$inv.sets.drag = { id: id, bid: bid };
tr.aC('dragging');
try { ev.originalEvent.dataTransfer.setData('text/plain', id); ev.originalEvent.dataTransfer.effectAllowed = 'move'; } catch (e) { /* ignore */ }
}).on('dragend', () => { tr.rC('dragging'); $inv.sets.drag = null; $inv.sets.clearDrop(); });
/* Every row is a target: before / after (reorder, become the anchor's sibling — works on a
folded set too, its subtree travels along) and, for sets, into (become its last member). */
let ok = () => $inv.sets.canDrop($inv.sets.drag, id, bid, byId);
tr.on('dragover', (ev) => {
if (!ok()) { return; }
ev.preventDefault();
let z = $inv.sets.zone(ev, tr, isSet);
tr.rC('dropok dropbefore dropafter').aC(z === 'into' ? 'dropok' : (z === 'before' ? 'dropbefore' : 'dropafter'));
}).on('dragleave', () => { tr.rC('dropok dropbefore dropafter'); }).on('drop', (ev) => {
ev.preventDefault(); $inv.sets.clearDrop();
let d = $inv.sets.drag; $inv.sets.drag = null;
if (!$inv.sets.canDrop(d, id, bid, byId)) { return; }
let z = $inv.sets.zone(ev, tr, isSet);
if (z === 'into') { $inv.sets.sync({ Target: 'set.assign', Ref: d.id, Value: { parent: id } }); }
else { $inv.sets.sync({ Target: 'set.move', Ref: d.id, Value: { anchor: id, where: z } }); }
});
return tr;
},
/* Drop target for "pull out of its set" (top level of the block). */
rootZone: function (el, bid, byId) {
let ok = () => { let d = $inv.sets.drag; return !!d && d.bid === bid && !!byId[d.id] && $inv.sets.parentOf(byId[d.id], byId) !== ''; };
el.on('dragover', (ev) => { if (ok()) { ev.preventDefault(); el.aC('dropok'); } })
.on('dragleave', () => { el.rC('dropok'); })
.on('drop', (ev) => {
ev.preventDefault(); el.rC('dropok');
let d = $inv.sets.drag; $inv.sets.drag = null;
if (d && d.bid === bid && byId[d.id] && $inv.sets.parentOf(byId[d.id], byId) !== '') {
$inv.sets.sync({ Target: 'set.assign', Ref: d.id, Value: { parent: '' } });
}
});
return el;
},
/* "New line" bar per block: type (Set/Text), target set (top level or any set), title. */
createBar: function (blk, bid, itms, byId) {
let bar = $$.dc('screate', blk);
let typ = $('<select></select>').attr({ name: 'typ' }).append([$$.opt('set', $ict.settyp.set), $$.opt('text', $ict.settyp.text)]);
let par = $('<select></select>').attr({ name: 'parent' }).append($$.opt('', $ict.setsTop));
itms.forEach((co) => {
if (!$inv.sets.isSet(co)) { return; }
let lvl = parseInt(co.lvl || 0, 10) || 0;
par.append($$.opt((co.id || '').toString(), ' '.repeat(lvl * 3) + $inv.sets.title(co)));
});
let ttl = $('<input type="text" />').attr({ name: 'title', placeholder: $ict.setsTitle, maxlength: 250 });
let add = () => {
let t = (ttl.val() || '').toString().trim();
if (t === '') { ttl.focus(); return; }
$inv.sets.sync({ Target: 'set.create', Ref: bid, Value: { typ: typ.val(), title: t, parent: par.val() || '' } });
ttl.val('');
};
ttl.on('keydown', (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); add(); } });
bar.append([$$.sc('lbl', $ict.setsNew), typ, $$.sc('lbl', $ict.setsInto), par, ttl, $$.bbtn($ict.setsAdd, 'sadd').click(add)]);
return bar;
}
};
+37 -15
View File
@@ -86,7 +86,7 @@ $inv.iMn = (ix) => {
yet been explicitly switched to a set-display mode (admin.setmode empty/undefined) — once set, yet been explicitly switched to a set-display mode (admin.setmode empty/undefined) — once set,
changing it again is not supported (the user edits the line items directly instead); it changing it again is not supported (the user edits the line items directly instead); it
reappears only if 'Änderungen verwerfen' reverts to a DB state that was never switched. */ reappears only if 'Änderungen verwerfen' reverts to a DB state that was never switched. */
$inv.emOpt = (admin) => 'iss,p13b,ctp' + (((admin || {}).setmode || '') === '' ? ',setm' : ''); $inv.emOpt = (admin) => 'iss,p13b,ctp,setst' + (((admin || {}).setmode || '') === '' ? ',setm' : '');
$inv.eM = (r, re, opt) => { $inv.eM = (r, re, opt) => {
let m = []; let m = [];
if (booln(r, false) === true || booln(re, false) === true) { if (booln(r, false) === true || booln(re, false) === true) {
@@ -104,6 +104,9 @@ $inv.eM = (r, re, opt) => {
if ((opt || '').split(',').includes('setm') === true) { if ((opt || '').split(',').includes('setm') === true) {
m.push({ lbl: $ict.setm, fnc: $inv.ssetmode }); m.push({ lbl: $ict.setm, fnc: $inv.ssetmode });
} }
if ((opt || '').split(',').includes('setst') === true) { /* user-defined set structure dialog (ADR 0016) */
m.push({ lbl: $ict.setst, fnc: () => $inv.sets.open() });
}
if ((opt || '').split(',').includes('iss') === true) { /* backend-authoritative draft (ADR 0006) */ if ((opt || '').split(',').includes('iss') === true) { /* backend-authoritative draft (ADR 0006) */
m.push({ lbl: 'Änderungshistorie', fnc: () => $inv.d.history() }); m.push({ lbl: 'Änderungshistorie', fnc: () => $inv.d.history() });
m.push({ lbl: 'Änderungen verwerfen', fnc: () => $inv.d.discard() }); m.push({ lbl: 'Änderungen verwerfen', fnc: () => $inv.d.discard() });
@@ -172,12 +175,13 @@ $inv.d = {
if (typeof curVer === 'number' && typeof state.version === 'number' && state.version < curVer) { if (typeof curVer === 'number' && typeof state.version === 'number' && state.version < curVer) {
return; return;
} }
tbl.data('dver', state.version).data('serverSums', state.sums); tbl.data('dver', state.version).data('serverSums', state.sums).data('dstate', state);
$inv.d.footer(tbl, state.sums || {}, state.admin || {}); $inv.d.footer(tbl, state.sums || {}, state.admin || {});
$inv.d.notes(state.notes || []); $inv.d.notes(state.notes || []);
$inv.d.validation(state.validation || []); $inv.d.validation(state.validation || []);
$inv.d.applyItems(tbl, state.req || []); $inv.d.applyItems(tbl, state.req || []);
$inv.d.applySetDisplay(tbl, state.setDisplay || {}); $inv.d.applySetDisplay(tbl, state.setDisplay || {});
if ($inv.sets && $inv.sets.isOpen()) { $inv.sets.render(state); } /* the set-structure dialog is a view of the same state (ADR 0016) */
/* Keep the menu's "Set-Preisanzeige" entry in sync with the authoritative admin.setmode: /* Keep the menu's "Set-Preisanzeige" entry in sync with the authoritative admin.setmode:
hides it right after a mode switch, and restores it if a discard reverted the draft to hides it right after a mode switch, and restores it if a discard reverted the draft to
a DB state that was never switched (ADR 0006 backend-authoritative refresh). */ a DB state that was never switched (ADR 0006 backend-authoritative refresh). */
@@ -223,13 +227,15 @@ $inv.d = {
let changed = isNew || (dta.Type || '') !== (co.typ || '') let changed = isNew || (dta.Type || '') !== (co.typ || '')
|| dta.net !== val(co.v) || dta.net_val !== val(co.vt) || dta.vat_val !== val(co.vv) || dta.net !== val(co.v) || dta.net_val !== val(co.vt) || dta.vat_val !== val(co.vv)
|| dta.svcnet_val !== val(co.vs) || dta.svcvat_val !== val(co.vsv) || dta.svcnet_val !== val(co.vs) || dta.svcvat_val !== val(co.vsv)
|| (dta.position || '') !== (co.p != null ? co.p : ''); || (dta.position || '') !== (co.p != null ? co.p : '')
|| (dta.lvl || 0) !== (co.lvl || 0) || (dta.SetItmId || null) !== (co.SetItmId != null ? co.SetItmId : null);
$.extend(dta, { $.extend(dta, {
Id: id, Type: co.typ, Id: id, Type: co.typ,
net: val(co.v), net_val: val(co.vt), vat_val: val(co.vv), net: val(co.v), net_val: val(co.vt), vat_val: val(co.vv),
svcnet_val: val(co.vs), svcvat_val: val(co.vsv), svcnet_val: val(co.vs), svcvat_val: val(co.vsv),
vat: co.vat, position: (co.p != null ? co.p : ''), vat: co.vat, position: (co.p != null ? co.p : ''),
SetItmId: (co.SetItmId != null ? co.SetItmId : null) SetItmId: (co.SetItmId != null ? co.SetItmId : null),
lvl: (co.lvl || 0) /* nesting depth (ADR 0016) — rendered as an indent */
}); });
if (isNew) { $.extend(dta, { htmltext: co.t, quantity: co.q }); } // text only for server-created rows (e.g. the set row) if (isNew) { $.extend(dta, { htmltext: co.t, quantity: co.q }); } // text only for server-created rows (e.g. the set row)
rw.data(dta); rw.data(dta);
@@ -311,7 +317,7 @@ $inv.d = {
/* Map an inline recipient field to its delta target and send it. No token-guard here for the /* Map an inline recipient field to its delta target and send it. No token-guard here for the
same reason as syncChanged: sync() waits for an in-flight seed instead of dropping the edit. */ same reason as syncChanged: sync() waits for an in-flight seed instead of dropping the edit. */
syncField: function (nme, val) { syncField: function (nme, val) {
let map = { invoicetitle: 'title', invoiceaddress: 'address', invoiceemail: 'email', loc: 'provisionlocation', provisionlocation: 'provisionlocation', provisionperiod: 'provisionperiod' }; let map = { invoicetitle: 'title', invoiceaddress: 'address', invoiceemail: 'email', invoiceemailcc: 'emailcc', loc: 'provisionlocation', provisionlocation: 'provisionlocation', provisionperiod: 'provisionperiod' };
let target = map[nme]; if (!target) { return; } let target = map[nme]; if (!target) { return; }
$inv.d.sync({ Target: target, Value: val }); $inv.d.sync({ Target: target, Value: val });
}, },
@@ -898,6 +904,7 @@ $inv.cntInv = function (data) { //invoice continuation
fm('adrfrm', inv.SendToAddress, 'invoiceaddress', 0, null); fm('adrfrm', inv.SendToAddress, 'invoiceaddress', 0, null);
fm('locfrm', inv.ProvisionLocation, 'loc', 1, null); fm('locfrm', inv.ProvisionLocation, 'loc', 1, null);
fm('emailfrm', inv.SendToEmail, 'invoiceemail', 0, null); fm('emailfrm', inv.SendToEmail, 'invoiceemail', 0, null);
fm('emailccfrm', inv.SendToEmailCc, 'invoiceemailcc', 0, null);
$$.dc('sndfrm', frm).append($$.dc('content').text(response.admin.sender)); $$.dc('sndfrm', frm).append($$.dc('content').text(response.admin.sender));
fm('admfrm', inv.ProvisionPeriod, 'provisionperiod', ((inv.ProvisionPeriod || '').includes('-') === true ? $rct.provP : $rct.provD), 1, null); fm('admfrm', inv.ProvisionPeriod, 'provisionperiod', ((inv.ProvisionPeriod || '').includes('-') === true ? $rct.provP : $rct.provD), 1, null);
@@ -960,6 +967,7 @@ $inv.cSt = function (data) {
fm('adrfrm', inv.SendToAddress, 'invoiceaddress', 0, null); fm('adrfrm', inv.SendToAddress, 'invoiceaddress', 0, null);
fm('locfrm', inv.ProvisionLocation, 'loc', 1, null); fm('locfrm', inv.ProvisionLocation, 'loc', 1, null);
fm('emailfrm', inv.SendToEmail, 'invoiceemail', 0, null); fm('emailfrm', inv.SendToEmail, 'invoiceemail', 0, null);
fm('emailccfrm', inv.SendToEmailCc, 'invoiceemailcc', 0, null);
$$.dc('sndfrm', frm).append($$.dc('content').text(response.admin.sender)); $$.dc('sndfrm', frm).append($$.dc('content').text(response.admin.sender));
fm('admfrm', inv.ProvisionPeriod, 'provisionperiod', ((inv.ProvisionPeriod || '').includes('-') === true ? $rct.provP : $rct.provD), 1, null); fm('admfrm', inv.ProvisionPeriod, 'provisionperiod', ((inv.ProvisionPeriod || '').includes('-') === true ? $rct.provP : $rct.provD), 1, null);
@@ -1101,7 +1109,7 @@ $inv.eHtml = function (ev) {
Leistungsdatum). The backend sanitises HTML too (single source of truth, ADR 0006), but 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 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. */ (invoiceaddress, loc) stay HTML-capable and are normalised to newlines server-side. */
let isPlainText = ['invoiceemail', 'provisionperiod', 'invoicetitle'].includes(ev.data.nme); let isPlainText = ['invoiceemail', 'invoiceemailcc', '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' } }];
@@ -1234,7 +1242,7 @@ $inv.rrw = function () {
} else if (rw.is('.itm.osum') === true) { } else if (rw.is('.itm.osum') === true) {
co = { invrqid: dta.InvRqId, id: 'osum' + rw.index(), typ: 'osum', p: '', q: null, t: oHtml(dta.tbl.tbl), tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: false }; co = { invrqid: dta.InvRqId, id: 'osum' + rw.index(), typ: 'osum', p: '', q: null, t: oHtml(dta.tbl.tbl), tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: false };
} else { } else {
co = { invrqid: dta.InvRqId, id: dta.Id || '', typ: dta.Type || 'other', p: '', q: null, t: '', tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: (dta.Note || '') !== '' && hn === false, SetItmId: dta.SetItmId || null }; co = { invrqid: dta.InvRqId, id: dta.Id || '', typ: dta.Type || 'other', p: '', q: null, t: '', tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: (dta.Note || '') !== '' && hn === false, SetItmId: dta.SetItmId || null, lvl: (dta.lvl || 0) };
$$.dc('ibtn ico move', axf, { title: $rct.mR }); /* should be the last added */ $$.dc('ibtn ico move', axf, { title: $rct.mR }); /* should be the last added */
co.p = dta.position || (dta.SortOrder || ''); co.p = dta.position || (dta.SortOrder || '');
@@ -1274,6 +1282,7 @@ $inv.rrw = function () {
} }
rw.empty().attr('class', ph ? 'placeholder' : 'itm').aC(co.Typ) rw.empty().attr('class', ph ? 'placeholder' : 'itm').aC(co.Typ)
.tC('sethdr', (co.typ || '').toString().toLowerCase() === 'set') /* emphasise set rows (fn1 header + block set row) */ .tC('sethdr', (co.typ || '').toString().toLowerCase() === 'set') /* emphasise set rows (fn1 header + block set row) */
.aC((co.lvl || 0) > 0 ? 'lvl' + Math.min(co.lvl, 6) : '') /* indent nested set members (ADR 0016) */
.tC('hidenote', hn).append(tda); .tC('hidenote', hn).append(tda);
dta.co = co; dta.co = co;
}; };
@@ -1304,8 +1313,23 @@ $inv.invSumUpdate = function () {
let tbl = $(this); let tbl = $(this);
let ba = []; let ba = [];
let bds = tbl.children('tbody'); let bds = tbl.children('tbody');
/* Hierarchical position numbering (ADR 0017), spanning the whole invoice: a running counter
per nesting depth, ported from InvoiceLineNumbering.Compute. A set header gets a number at
its own level ("3"); its members get subsidiary numbers one level deeper ("3.1", "3.2"). A
row's numbering never depends on the MFR SortOrder value — including for a Text/Title
header row synced with SortOrder -1, which used to leak "-1" straight into the Pos. column
here because it never went through this loop at all. */
let posCounters = [];
let nextPositionLabel = (numbered, lvl) => {
if (!numbered) { return ''; }
let l = Math.max(lvl || 0, 0);
while (posCounters.length <= l) { posCounters.push(0); }
posCounters.length = l + 1;
posCounters[l]++;
return posCounters.join('.');
};
bds.each((bi, bdy) => { bds.each((bi, bdy) => {
let b = $(bdy), rx = b.data() || {}, i = [], citems = [], itm = b.find('tr.itm'), iso = 0, ipos = 0; let b = $(bdy), rx = b.data() || {}, i = [], citems = [], itm = b.find('tr.itm');
b.tC('empty', itm.length < 1); b.tC('empty', itm.length < 1);
itm.each((ti, tx) => { itm.each((ti, tx) => {
let rrx = $(tx).data() || {}; let rrx = $(tx).data() || {};
@@ -1318,14 +1342,12 @@ $inv.invSumUpdate = function () {
by the id !== '' && !== citem.id check below). */ by the id !== '' && !== citem.id check below). */
let citem = $inv.itemToContract(rrx); let citem = $inv.itemToContract(rrx);
let sid = (rrx.SetItmId || '').toString(); let sid = (rrx.SetItmId || '').toString();
if (citem.type !== 'set' && sid !== '' && sid !== citem.id) { citem.setId = sid; } /* a nested set (ADR 0016) is itself a member of its parent set, so the type is not excluded here */
if (sid !== '' && sid !== citem.id) { citem.setId = sid; }
citems.push(citem); citems.push(citem);
if (((typeof rrx.SortOrder === 'undefined' || rrx.SortOrder === null) ? -1 : rrx.SortOrder) > -1) { let numbered = ['text', 'title'].includes((rrx.Type || 'other').toLowerCase()) === false;
if (['text', 'title'].includes((rrx.Type || 'other').toLowerCase()) === false) { ipos++; } rrx.position = nextPositionLabel(numbered, (rrx.co || {}).lvl || 0);
rrx.SortOrder = iso; $inv.rrw.call(tx);
rrx.position = ipos;
$inv.rrw.call(tx);
}
}); });
ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, items: citems }); ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, items: citems });
}); });
@@ -81,6 +81,37 @@
} }
} }
.emailccfrm {
position: absolute;
font-size: 0.7rem;
top: 2.2rem;
left: 0.5rem;
width: auto;
height: auto;
cursor: pointer;
.content {
color: #000;
}
/* Unlike the other optional fields (plain "-" placeholder), this one names the action —
there is no default "Zusatzempfänger" from MFR, so an empty state must read as an
offer to add one, not as missing data. */
.content:empty::before {
content: 'Zusatzempfänger hinzufügen';
min-width: 5rem;
height: 1rem;
display: block;
position: absolute;
}
&:hover {
div.axf {
display: block;
}
}
}
.admfrm { .admfrm {
position: absolute; position: absolute;
right: 3rem; right: 3rem;
@@ -250,6 +281,12 @@
&.sethdr td.txt { &.sethdr td.txt {
font-weight: bold; font-weight: bold;
} }
/* Nested set members (ADR 0016): indent per level, mirroring the PDF's per-level indent. */
@for $i from 1 through 6 {
&.lvl#{$i} td.txt {
padding-left: calc(0.4rem + #{$i * 1.4rem});
}
}
} }
> tfoot > tr { > tfoot > tr {
@@ -493,3 +530,71 @@
color: #445; color: #445;
} }
} }
/* ── Set structure dialog (ADR 0016, see $inv.sets in fis.inv_sets.js) ─────────────────── */
.modal .setstruct {
&.busy { opacity: 0.6; pointer-events: none; }
div.shint { font-size: 0.85rem; color: #666; margin: 0 0 0.8rem 0; }
div.sblock {
margin: 0 0 1.4rem 0;
div.sbhd {
font-weight: bold;
padding: 0.4rem 0.6rem;
border-top: 1.5px solid #000;
border-bottom: 1px solid #ddd;
&.dropok { background-color: #e6f2e6; outline: 2px dashed #5a5; }
}
table.stbl {
width: 100%;
border-collapse: collapse;
th { text-align: left; font-size: 0.8rem; color: #666; padding: 0.3rem 0.4rem; border-bottom: 1px solid #ddd; }
td { padding: 0.3rem 0.4rem; border-bottom: 1px solid #f0f0f0; vertical-align: middle; }
td.grip { width: 1.6rem; cursor: grab; color: #999; }
td.pos, td.qty { width: 4rem; white-space: nowrap; }
td.typ { width: 7rem; white-space: nowrap; color: #666; font-size: 0.85rem;
span.fold { cursor: pointer; margin-right: 0.35rem; color: #555; display: inline-block; width: 1rem; }
}
td.ttl {
span.ind { display: inline-block; }
span.seti { margin-right: 0.3rem; color: #888; }
span.cnt { color: #999; font-weight: normal; font-size: 0.85rem; }
}
td.act { width: 7.5rem; white-space: nowrap; text-align: right;
div.ibtn { display: inline-block; cursor: pointer; padding: 0 0.25rem; &.disabled { opacity: 0.25; cursor: default; } }
}
tr.srow {
&.isset td.ttl { font-weight: bold; }
&.hid { display: none; }
&.folded td.ttl { font-style: italic; }
&.dragging { opacity: 0.4; }
&.dropok td { background-color: #e6f2e6; }
&.dropbefore td { border-top: 3px solid #5a5; }
&.dropafter td { border-bottom: 3px solid #5a5; }
}
}
div.sroot {
margin: 0.3rem 0 0.5rem 0;
padding: 0.4rem 0.6rem;
border: 1px dashed #bbb;
color: #888;
font-size: 0.85rem;
&.dropok { background-color: #e6f2e6; border-color: #5a5; color: #363; }
}
div.screate {
display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem;
padding: 0.4rem 0.6rem;
background-color: #f7f7f7;
span.lbl { font-size: 0.85rem; color: #666; }
select, input[type="text"] { padding: 0.2rem 0.3rem; }
input[type="text"] { flex: 1 1 14rem; min-width: 10rem; }
}
}
}
@@ -45,6 +45,24 @@
setprice: 'Set mit Preis – Positionen ohne Preis', setprice: 'Set mit Preis – Positionen ohne Preis',
setonly: 'Nur Set mit Preis – Positionen ausgeblendet' setonly: 'Nur Set mit Preis – Positionen ausgeblendet'
}, },
/* user-defined set structure dialog (ADR 0016) */
setst: 'Set-Struktur bearbeiten',
setsHint: 'Reihenfolge: Zeile per Drag & Drop an den oberen/unteren Rand einer anderen Zeile ziehen (davor/danach) oder ▲▼ nutzen. Zuordnung: Zeile auf die Mitte eines Sets ziehen (hinein) bzw. auf die Auftragszeile oder "oberste Ebene" (aus dem Set lösen); ◀▶ ändert die Ebene. Sets lassen sich über das Dreieck zu-/aufklappen und auch zugeklappt verschieben – ihre Positionen wandern mit. Änderungen werden sofort übernommen.',
setsUp: 'Nach oben (innerhalb der Ebene)',
setsDown: 'Nach unten (innerhalb der Ebene)',
setsCollapse: 'Set zuklappen',
setsExpand: 'Set aufklappen',
setsHR: ['', 'Pos.', 'Typ', 'Menge', 'Bezeichnung', 'Reihenfolge / Ebene'],
setsRoot: 'Hierher ziehen = aus dem Set lösen (oberste Ebene)',
setsOut: 'Eine Ebene höher (aus dem Set lösen)',
setsIn: 'Eine Ebene tiefer (dem vorangehenden Set zuordnen)',
setsNew: 'Neu:',
setsInto: 'in',
setsTop: 'oberste Ebene',
setsTitle: 'Bezeichnung',
setsAdd: 'Hinzufügen',
setsNoTitle: '(ohne Bezeichnung)',
settyp: { set: 'Set', text: 'Text', title: 'Text', material: 'Material', service: 'Arbeitsleistung', equipment: 'Ausrüstung', other: 'Position', osum: 'Zusammenfassung', intinvoiceallocation: 'Abschlag', intinvoiceallocationdiff: 'Abschlag (offen)' },
ctp: 'Ansprechpartner festlegen', ctp: 'Ansprechpartner festlegen',
mfr: 'Von MFR neu abrufen', mfr: 'Von MFR neu abrufen',
rq1: 'Auftragsdaten werden von MFR abgerufen.\nDer Vorgang kann bis zu 90Sek dauern.', rq1: 'Auftragsdaten werden von MFR abgerufen.\nDer Vorgang kann bis zu 90Sek dauern.',
+189
View File
@@ -1,3 +1,4 @@
@charset "UTF-8";
/* basics */ /* basics */
/* Color SCHEME */ /* Color SCHEME */
/* #8396bd */ /* #8396bd */
@@ -169,6 +170,33 @@ table.if td.num {
.edit_frm .invoice_layout .emailfrm:hover div.axf { .edit_frm .invoice_layout .emailfrm:hover div.axf {
display: block; display: block;
} }
.edit_frm .invoice_layout .emailccfrm {
position: absolute;
font-size: 0.7rem;
top: 2.2rem;
left: 0.5rem;
width: auto;
height: auto;
cursor: pointer;
}
.edit_frm .invoice_layout .emailccfrm .content {
color: #000;
}
.edit_frm .invoice_layout .emailccfrm {
/* Unlike the other optional fields (plain "-" placeholder), this one names the action —
there is no default "Zusatzempfänger" from MFR, so an empty state must read as an
offer to add one, not as missing data. */
}
.edit_frm .invoice_layout .emailccfrm .content:empty::before {
content: "Zusatzempfänger hinzufügen";
min-width: 5rem;
height: 1rem;
display: block;
position: absolute;
}
.edit_frm .invoice_layout .emailccfrm:hover div.axf {
display: block;
}
.edit_frm .invoice_layout .admfrm { .edit_frm .invoice_layout .admfrm {
position: absolute; position: absolute;
right: 3rem; right: 3rem;
@@ -302,6 +330,27 @@ table.if td.num {
.edit_frm .invoice_layout table.invi > tbody > tr.sethdr td.txt { .edit_frm .invoice_layout table.invi > tbody > tr.sethdr td.txt {
font-weight: bold; font-weight: bold;
} }
.edit_frm .invoice_layout table.invi > tbody > tr {
/* Nested set members (ADR 0016): indent per level, mirroring the PDF's per-level indent. */
}
.edit_frm .invoice_layout table.invi > tbody > tr.lvl1 td.txt {
padding-left: calc(0.4rem + 1.4rem);
}
.edit_frm .invoice_layout table.invi > tbody > tr.lvl2 td.txt {
padding-left: calc(0.4rem + 2.8rem);
}
.edit_frm .invoice_layout table.invi > tbody > tr.lvl3 td.txt {
padding-left: calc(0.4rem + 4.2rem);
}
.edit_frm .invoice_layout table.invi > tbody > tr.lvl4 td.txt {
padding-left: calc(0.4rem + 5.6rem);
}
.edit_frm .invoice_layout table.invi > tbody > tr.lvl5 td.txt {
padding-left: calc(0.4rem + 7rem);
}
.edit_frm .invoice_layout table.invi > tbody > tr.lvl6 td.txt {
padding-left: calc(0.4rem + 8.4rem);
}
.edit_frm .invoice_layout table.invi th.currency, .edit_frm .invoice_layout table.invi th.num, .edit_frm .invoice_layout table.invi td.currency, .edit_frm .invoice_layout table.invi td.num { .edit_frm .invoice_layout table.invi th.currency, .edit_frm .invoice_layout table.invi th.num, .edit_frm .invoice_layout table.invi td.currency, .edit_frm .invoice_layout table.invi td.num {
text-align: right; text-align: right;
white-space: nowrap; white-space: nowrap;
@@ -510,4 +559,144 @@ table.if td.num {
} }
.adr-conformity .tx { .adr-conformity .tx {
color: #445; color: #445;
}
/* ── Set structure dialog (ADR 0016, see $inv.sets in fis.inv_sets.js) ─────────────────── */
.modal .setstruct.busy {
opacity: 0.6;
pointer-events: none;
}
.modal .setstruct div.shint {
font-size: 0.85rem;
color: #666;
margin: 0 0 0.8rem 0;
}
.modal .setstruct div.sblock {
margin: 0 0 1.4rem 0;
}
.modal .setstruct div.sblock div.sbhd {
font-weight: bold;
padding: 0.4rem 0.6rem;
border-top: 1.5px solid #000;
border-bottom: 1px solid #ddd;
}
.modal .setstruct div.sblock div.sbhd.dropok {
background-color: #e6f2e6;
outline: 2px dashed #5a5;
}
.modal .setstruct div.sblock table.stbl {
width: 100%;
border-collapse: collapse;
}
.modal .setstruct div.sblock table.stbl th {
text-align: left;
font-size: 0.8rem;
color: #666;
padding: 0.3rem 0.4rem;
border-bottom: 1px solid #ddd;
}
.modal .setstruct div.sblock table.stbl td {
padding: 0.3rem 0.4rem;
border-bottom: 1px solid #f0f0f0;
vertical-align: middle;
}
.modal .setstruct div.sblock table.stbl td.grip {
width: 1.6rem;
cursor: grab;
color: #999;
}
.modal .setstruct div.sblock table.stbl td.pos, .modal .setstruct div.sblock table.stbl td.qty {
width: 4rem;
white-space: nowrap;
}
.modal .setstruct div.sblock table.stbl td.typ {
width: 7rem;
white-space: nowrap;
color: #666;
font-size: 0.85rem;
}
.modal .setstruct div.sblock table.stbl td.typ span.fold {
cursor: pointer;
margin-right: 0.35rem;
color: #555;
display: inline-block;
width: 1rem;
}
.modal .setstruct div.sblock table.stbl td.ttl span.ind {
display: inline-block;
}
.modal .setstruct div.sblock table.stbl td.ttl span.seti {
margin-right: 0.3rem;
color: #888;
}
.modal .setstruct div.sblock table.stbl td.ttl span.cnt {
color: #999;
font-weight: normal;
font-size: 0.85rem;
}
.modal .setstruct div.sblock table.stbl td.act {
width: 7.5rem;
white-space: nowrap;
text-align: right;
}
.modal .setstruct div.sblock table.stbl td.act div.ibtn {
display: inline-block;
cursor: pointer;
padding: 0 0.25rem;
}
.modal .setstruct div.sblock table.stbl td.act div.ibtn.disabled {
opacity: 0.25;
cursor: default;
}
.modal .setstruct div.sblock table.stbl tr.srow.isset td.ttl {
font-weight: bold;
}
.modal .setstruct div.sblock table.stbl tr.srow.hid {
display: none;
}
.modal .setstruct div.sblock table.stbl tr.srow.folded td.ttl {
font-style: italic;
}
.modal .setstruct div.sblock table.stbl tr.srow.dragging {
opacity: 0.4;
}
.modal .setstruct div.sblock table.stbl tr.srow.dropok td {
background-color: #e6f2e6;
}
.modal .setstruct div.sblock table.stbl tr.srow.dropbefore td {
border-top: 3px solid #5a5;
}
.modal .setstruct div.sblock table.stbl tr.srow.dropafter td {
border-bottom: 3px solid #5a5;
}
.modal .setstruct div.sblock div.sroot {
margin: 0.3rem 0 0.5rem 0;
padding: 0.4rem 0.6rem;
border: 1px dashed #bbb;
color: #888;
font-size: 0.85rem;
}
.modal .setstruct div.sblock div.sroot.dropok {
background-color: #e6f2e6;
border-color: #5a5;
color: #363;
}
.modal .setstruct div.sblock div.screate {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.6rem;
background-color: #f7f7f7;
}
.modal .setstruct div.sblock div.screate span.lbl {
font-size: 0.85rem;
color: #666;
}
.modal .setstruct div.sblock div.screate select, .modal .setstruct div.sblock div.screate input[type=text] {
padding: 0.2rem 0.3rem;
}
.modal .setstruct div.sblock div.screate input[type=text] {
flex: 1 1 14rem;
min-width: 10rem;
} }
+257 -15
View File
@@ -65,6 +65,7 @@ let $rct = {
invoiceaddress: 'Adresse' invoiceaddress: 'Adresse'
, loc: 'Leistungsort / Lieferadresse' , loc: 'Leistungsort / Lieferadresse'
, invoiceemail: 'Email' , invoiceemail: 'Email'
, invoiceemailcc: 'Zusatzempfänger (CC)'
} }
}, $rcol = { }, $rcol = {
@@ -154,6 +155,24 @@ let $ict = {
setprice: 'Set mit Preis – Positionen ohne Preis', setprice: 'Set mit Preis – Positionen ohne Preis',
setonly: 'Nur Set mit Preis – Positionen ausgeblendet' setonly: 'Nur Set mit Preis – Positionen ausgeblendet'
}, },
/* user-defined set structure dialog (ADR 0016) */
setst: 'Set-Struktur bearbeiten',
setsHint: 'Reihenfolge: Zeile per Drag & Drop an den oberen/unteren Rand einer anderen Zeile ziehen (davor/danach) oder ▲▼ nutzen. Zuordnung: Zeile auf die Mitte eines Sets ziehen (hinein) bzw. auf die Auftragszeile oder "oberste Ebene" (aus dem Set lösen); ◀▶ ändert die Ebene. Sets lassen sich über das Dreieck zu-/aufklappen und auch zugeklappt verschieben – ihre Positionen wandern mit. Änderungen werden sofort übernommen.',
setsUp: 'Nach oben (innerhalb der Ebene)',
setsDown: 'Nach unten (innerhalb der Ebene)',
setsCollapse: 'Set zuklappen',
setsExpand: 'Set aufklappen',
setsHR: ['', 'Pos.', 'Typ', 'Menge', 'Bezeichnung', 'Reihenfolge / Ebene'],
setsRoot: 'Hierher ziehen = aus dem Set lösen (oberste Ebene)',
setsOut: 'Eine Ebene höher (aus dem Set lösen)',
setsIn: 'Eine Ebene tiefer (dem vorangehenden Set zuordnen)',
setsNew: 'Neu:',
setsInto: 'in',
setsTop: 'oberste Ebene',
setsTitle: 'Bezeichnung',
setsAdd: 'Hinzufügen',
setsNoTitle: '(ohne Bezeichnung)',
settyp: { set: 'Set', text: 'Text', title: 'Text', material: 'Material', service: 'Arbeitsleistung', equipment: 'Ausrüstung', other: 'Position', osum: 'Zusammenfassung', intinvoiceallocation: 'Abschlag', intinvoiceallocationdiff: 'Abschlag (offen)' },
ctp: 'Ansprechpartner festlegen', ctp: 'Ansprechpartner festlegen',
mfr: 'Von MFR neu abrufen', mfr: 'Von MFR neu abrufen',
rq1: 'Auftragsdaten werden von MFR abgerufen.\nDer Vorgang kann bis zu 90Sek dauern.', rq1: 'Auftragsdaten werden von MFR abgerufen.\nDer Vorgang kann bis zu 90Sek dauern.',
@@ -629,7 +648,7 @@ $inv.iMn = (ix) => {
yet been explicitly switched to a set-display mode (admin.setmode empty/undefined) — once set, yet been explicitly switched to a set-display mode (admin.setmode empty/undefined) — once set,
changing it again is not supported (the user edits the line items directly instead); it changing it again is not supported (the user edits the line items directly instead); it
reappears only if 'Änderungen verwerfen' reverts to a DB state that was never switched. */ reappears only if 'Änderungen verwerfen' reverts to a DB state that was never switched. */
$inv.emOpt = (admin) => 'iss,p13b,ctp' + (((admin || {}).setmode || '') === '' ? ',setm' : ''); $inv.emOpt = (admin) => 'iss,p13b,ctp,setst' + (((admin || {}).setmode || '') === '' ? ',setm' : '');
$inv.eM = (r, re, opt) => { $inv.eM = (r, re, opt) => {
let m = []; let m = [];
if (booln(r, false) === true || booln(re, false) === true) { if (booln(r, false) === true || booln(re, false) === true) {
@@ -647,6 +666,9 @@ $inv.eM = (r, re, opt) => {
if ((opt || '').split(',').includes('setm') === true) { if ((opt || '').split(',').includes('setm') === true) {
m.push({ lbl: $ict.setm, fnc: $inv.ssetmode }); m.push({ lbl: $ict.setm, fnc: $inv.ssetmode });
} }
if ((opt || '').split(',').includes('setst') === true) { /* user-defined set structure dialog (ADR 0016) */
m.push({ lbl: $ict.setst, fnc: () => $inv.sets.open() });
}
if ((opt || '').split(',').includes('iss') === true) { /* backend-authoritative draft (ADR 0006) */ if ((opt || '').split(',').includes('iss') === true) { /* backend-authoritative draft (ADR 0006) */
m.push({ lbl: 'Änderungshistorie', fnc: () => $inv.d.history() }); m.push({ lbl: 'Änderungshistorie', fnc: () => $inv.d.history() });
m.push({ lbl: 'Änderungen verwerfen', fnc: () => $inv.d.discard() }); m.push({ lbl: 'Änderungen verwerfen', fnc: () => $inv.d.discard() });
@@ -715,12 +737,13 @@ $inv.d = {
if (typeof curVer === 'number' && typeof state.version === 'number' && state.version < curVer) { if (typeof curVer === 'number' && typeof state.version === 'number' && state.version < curVer) {
return; return;
} }
tbl.data('dver', state.version).data('serverSums', state.sums); tbl.data('dver', state.version).data('serverSums', state.sums).data('dstate', state);
$inv.d.footer(tbl, state.sums || {}, state.admin || {}); $inv.d.footer(tbl, state.sums || {}, state.admin || {});
$inv.d.notes(state.notes || []); $inv.d.notes(state.notes || []);
$inv.d.validation(state.validation || []); $inv.d.validation(state.validation || []);
$inv.d.applyItems(tbl, state.req || []); $inv.d.applyItems(tbl, state.req || []);
$inv.d.applySetDisplay(tbl, state.setDisplay || {}); $inv.d.applySetDisplay(tbl, state.setDisplay || {});
if ($inv.sets && $inv.sets.isOpen()) { $inv.sets.render(state); } /* the set-structure dialog is a view of the same state (ADR 0016) */
/* Keep the menu's "Set-Preisanzeige" entry in sync with the authoritative admin.setmode: /* Keep the menu's "Set-Preisanzeige" entry in sync with the authoritative admin.setmode:
hides it right after a mode switch, and restores it if a discard reverted the draft to hides it right after a mode switch, and restores it if a discard reverted the draft to
a DB state that was never switched (ADR 0006 backend-authoritative refresh). */ a DB state that was never switched (ADR 0006 backend-authoritative refresh). */
@@ -766,13 +789,15 @@ $inv.d = {
let changed = isNew || (dta.Type || '') !== (co.typ || '') let changed = isNew || (dta.Type || '') !== (co.typ || '')
|| dta.net !== val(co.v) || dta.net_val !== val(co.vt) || dta.vat_val !== val(co.vv) || dta.net !== val(co.v) || dta.net_val !== val(co.vt) || dta.vat_val !== val(co.vv)
|| dta.svcnet_val !== val(co.vs) || dta.svcvat_val !== val(co.vsv) || dta.svcnet_val !== val(co.vs) || dta.svcvat_val !== val(co.vsv)
|| (dta.position || '') !== (co.p != null ? co.p : ''); || (dta.position || '') !== (co.p != null ? co.p : '')
|| (dta.lvl || 0) !== (co.lvl || 0) || (dta.SetItmId || null) !== (co.SetItmId != null ? co.SetItmId : null);
$.extend(dta, { $.extend(dta, {
Id: id, Type: co.typ, Id: id, Type: co.typ,
net: val(co.v), net_val: val(co.vt), vat_val: val(co.vv), net: val(co.v), net_val: val(co.vt), vat_val: val(co.vv),
svcnet_val: val(co.vs), svcvat_val: val(co.vsv), svcnet_val: val(co.vs), svcvat_val: val(co.vsv),
vat: co.vat, position: (co.p != null ? co.p : ''), vat: co.vat, position: (co.p != null ? co.p : ''),
SetItmId: (co.SetItmId != null ? co.SetItmId : null) SetItmId: (co.SetItmId != null ? co.SetItmId : null),
lvl: (co.lvl || 0) /* nesting depth (ADR 0016) — rendered as an indent */
}); });
if (isNew) { $.extend(dta, { htmltext: co.t, quantity: co.q }); } // text only for server-created rows (e.g. the set row) if (isNew) { $.extend(dta, { htmltext: co.t, quantity: co.q }); } // text only for server-created rows (e.g. the set row)
rw.data(dta); rw.data(dta);
@@ -854,7 +879,7 @@ $inv.d = {
/* Map an inline recipient field to its delta target and send it. No token-guard here for the /* Map an inline recipient field to its delta target and send it. No token-guard here for the
same reason as syncChanged: sync() waits for an in-flight seed instead of dropping the edit. */ same reason as syncChanged: sync() waits for an in-flight seed instead of dropping the edit. */
syncField: function (nme, val) { syncField: function (nme, val) {
let map = { invoicetitle: 'title', invoiceaddress: 'address', invoiceemail: 'email', loc: 'provisionlocation', provisionlocation: 'provisionlocation', provisionperiod: 'provisionperiod' }; let map = { invoicetitle: 'title', invoiceaddress: 'address', invoiceemail: 'email', invoiceemailcc: 'emailcc', loc: 'provisionlocation', provisionlocation: 'provisionlocation', provisionperiod: 'provisionperiod' };
let target = map[nme]; if (!target) { return; } let target = map[nme]; if (!target) { return; }
$inv.d.sync({ Target: target, Value: val }); $inv.d.sync({ Target: target, Value: val });
}, },
@@ -1441,6 +1466,7 @@ $inv.cntInv = function (data) { //invoice continuation
fm('adrfrm', inv.SendToAddress, 'invoiceaddress', 0, null); fm('adrfrm', inv.SendToAddress, 'invoiceaddress', 0, null);
fm('locfrm', inv.ProvisionLocation, 'loc', 1, null); fm('locfrm', inv.ProvisionLocation, 'loc', 1, null);
fm('emailfrm', inv.SendToEmail, 'invoiceemail', 0, null); fm('emailfrm', inv.SendToEmail, 'invoiceemail', 0, null);
fm('emailccfrm', inv.SendToEmailCc, 'invoiceemailcc', 0, null);
$$.dc('sndfrm', frm).append($$.dc('content').text(response.admin.sender)); $$.dc('sndfrm', frm).append($$.dc('content').text(response.admin.sender));
fm('admfrm', inv.ProvisionPeriod, 'provisionperiod', ((inv.ProvisionPeriod || '').includes('-') === true ? $rct.provP : $rct.provD), 1, null); fm('admfrm', inv.ProvisionPeriod, 'provisionperiod', ((inv.ProvisionPeriod || '').includes('-') === true ? $rct.provP : $rct.provD), 1, null);
@@ -1503,6 +1529,7 @@ $inv.cSt = function (data) {
fm('adrfrm', inv.SendToAddress, 'invoiceaddress', 0, null); fm('adrfrm', inv.SendToAddress, 'invoiceaddress', 0, null);
fm('locfrm', inv.ProvisionLocation, 'loc', 1, null); fm('locfrm', inv.ProvisionLocation, 'loc', 1, null);
fm('emailfrm', inv.SendToEmail, 'invoiceemail', 0, null); fm('emailfrm', inv.SendToEmail, 'invoiceemail', 0, null);
fm('emailccfrm', inv.SendToEmailCc, 'invoiceemailcc', 0, null);
$$.dc('sndfrm', frm).append($$.dc('content').text(response.admin.sender)); $$.dc('sndfrm', frm).append($$.dc('content').text(response.admin.sender));
fm('admfrm', inv.ProvisionPeriod, 'provisionperiod', ((inv.ProvisionPeriod || '').includes('-') === true ? $rct.provP : $rct.provD), 1, null); fm('admfrm', inv.ProvisionPeriod, 'provisionperiod', ((inv.ProvisionPeriod || '').includes('-') === true ? $rct.provP : $rct.provD), 1, null);
@@ -1644,7 +1671,7 @@ $inv.eHtml = function (ev) {
Leistungsdatum). The backend sanitises HTML too (single source of truth, ADR 0006), but 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 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. */ (invoiceaddress, loc) stay HTML-capable and are normalised to newlines server-side. */
let isPlainText = ['invoiceemail', 'provisionperiod', 'invoicetitle'].includes(ev.data.nme); let isPlainText = ['invoiceemail', 'invoiceemailcc', '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' } }];
@@ -1777,7 +1804,7 @@ $inv.rrw = function () {
} else if (rw.is('.itm.osum') === true) { } else if (rw.is('.itm.osum') === true) {
co = { invrqid: dta.InvRqId, id: 'osum' + rw.index(), typ: 'osum', p: '', q: null, t: oHtml(dta.tbl.tbl), tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: false }; co = { invrqid: dta.InvRqId, id: 'osum' + rw.index(), typ: 'osum', p: '', q: null, t: oHtml(dta.tbl.tbl), tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: false };
} else { } else {
co = { invrqid: dta.InvRqId, id: dta.Id || '', typ: dta.Type || 'other', p: '', q: null, t: '', tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: (dta.Note || '') !== '' && hn === false, SetItmId: dta.SetItmId || null }; co = { invrqid: dta.InvRqId, id: dta.Id || '', typ: dta.Type || 'other', p: '', q: null, t: '', tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: (dta.Note || '') !== '' && hn === false, SetItmId: dta.SetItmId || null, lvl: (dta.lvl || 0) };
$$.dc('ibtn ico move', axf, { title: $rct.mR }); /* should be the last added */ $$.dc('ibtn ico move', axf, { title: $rct.mR }); /* should be the last added */
co.p = dta.position || (dta.SortOrder || ''); co.p = dta.position || (dta.SortOrder || '');
@@ -1817,6 +1844,7 @@ $inv.rrw = function () {
} }
rw.empty().attr('class', ph ? 'placeholder' : 'itm').aC(co.Typ) rw.empty().attr('class', ph ? 'placeholder' : 'itm').aC(co.Typ)
.tC('sethdr', (co.typ || '').toString().toLowerCase() === 'set') /* emphasise set rows (fn1 header + block set row) */ .tC('sethdr', (co.typ || '').toString().toLowerCase() === 'set') /* emphasise set rows (fn1 header + block set row) */
.aC((co.lvl || 0) > 0 ? 'lvl' + Math.min(co.lvl, 6) : '') /* indent nested set members (ADR 0016) */
.tC('hidenote', hn).append(tda); .tC('hidenote', hn).append(tda);
dta.co = co; dta.co = co;
}; };
@@ -1847,8 +1875,23 @@ $inv.invSumUpdate = function () {
let tbl = $(this); let tbl = $(this);
let ba = []; let ba = [];
let bds = tbl.children('tbody'); let bds = tbl.children('tbody');
/* Hierarchical position numbering (ADR 0017), spanning the whole invoice: a running counter
per nesting depth, ported from InvoiceLineNumbering.Compute. A set header gets a number at
its own level ("3"); its members get subsidiary numbers one level deeper ("3.1", "3.2"). A
row's numbering never depends on the MFR SortOrder value — including for a Text/Title
header row synced with SortOrder -1, which used to leak "-1" straight into the Pos. column
here because it never went through this loop at all. */
let posCounters = [];
let nextPositionLabel = (numbered, lvl) => {
if (!numbered) { return ''; }
let l = Math.max(lvl || 0, 0);
while (posCounters.length <= l) { posCounters.push(0); }
posCounters.length = l + 1;
posCounters[l]++;
return posCounters.join('.');
};
bds.each((bi, bdy) => { bds.each((bi, bdy) => {
let b = $(bdy), rx = b.data() || {}, i = [], citems = [], itm = b.find('tr.itm'), iso = 0, ipos = 0; let b = $(bdy), rx = b.data() || {}, i = [], citems = [], itm = b.find('tr.itm');
b.tC('empty', itm.length < 1); b.tC('empty', itm.length < 1);
itm.each((ti, tx) => { itm.each((ti, tx) => {
let rrx = $(tx).data() || {}; let rrx = $(tx).data() || {};
@@ -1861,14 +1904,12 @@ $inv.invSumUpdate = function () {
by the id !== '' && !== citem.id check below). */ by the id !== '' && !== citem.id check below). */
let citem = $inv.itemToContract(rrx); let citem = $inv.itemToContract(rrx);
let sid = (rrx.SetItmId || '').toString(); let sid = (rrx.SetItmId || '').toString();
if (citem.type !== 'set' && sid !== '' && sid !== citem.id) { citem.setId = sid; } /* a nested set (ADR 0016) is itself a member of its parent set, so the type is not excluded here */
if (sid !== '' && sid !== citem.id) { citem.setId = sid; }
citems.push(citem); citems.push(citem);
if (((typeof rrx.SortOrder === 'undefined' || rrx.SortOrder === null) ? -1 : rrx.SortOrder) > -1) { let numbered = ['text', 'title'].includes((rrx.Type || 'other').toLowerCase()) === false;
if (['text', 'title'].includes((rrx.Type || 'other').toLowerCase()) === false) { ipos++; } rrx.position = nextPositionLabel(numbered, (rrx.co || {}).lvl || 0);
rrx.SortOrder = iso; $inv.rrw.call(tx);
rrx.position = ipos;
$inv.rrw.call(tx);
}
}); });
ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, items: citems }); ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, items: citems });
}); });
@@ -2403,3 +2444,204 @@ $inv.mfrrel = (id) => {
} }
}); });
}; };
/* ── User-defined set structure dialog (ADR 0016) ────────────────────────────
Lets the user define which line belongs to which set (and how deep) instead of relying only
on the implicit "everything after a Set header until the next Set" derivation that
fds__prepInvoice delivers as the initial structure. The dialog is a pure view of the
authoritative server state (state.req[].itm — the co shape with SetItmId + lvl): every action
posts ONE delta (set.assign / set.level / set.create) via $inv.d.sync, the server rewrites the
cached block (parent links, depth-first order, levels) and the normal draftReady/dstate
refresh re-renders both the editor rows and this dialog (applyState -> $inv.sets.render).
The browser never restructures anything itself (ADR 0006/0008). */
$inv.sets = {
o: null, /* open dialog handle ($ocms.dlg result) or null */
c: null, /* dialog content container */
drag: null, /* { id, bid } of the row currently being dragged */
/* Reorder = set.move (▲▼ or drag before/after a row), membership = set.assign (drag into a set /
root zone), depth = set.level (◀▶). Folding is purely a view state kept in $inv.sets.collapsed. */
isOpen: () => !!$inv.sets.o,
open: function () {
let tbl = $inv.d.tbl();
if (tbl.length < 1 || $inv.sets.isOpen()) { return; }
if ($inv.d.token() === '' && !tbl.data('dstate')) { return; } /* no draft session (e.g. reminder editor) — nothing to structure */
let c = $$.dc('setstruct');
$inv.sets.c = c;
$inv.sets.o = $ocms.dlg(c, {
title: $ict.setst, size: [-1, 960], exclusive: false,
close: () => { $inv.sets.o = null; $inv.sets.c = null; $inv.sets.drag = null; }
});
let state = tbl.data('dstate');
if (state) { $inv.sets.render(state); } else { $inv.d.refresh(); }
},
/* plain-text title of a co line (co.t is HTML for text lines / titles) */
title: (co) => {
let t = $$.d().html(co.t || '').text().replace(/\s+/g, ' ').trim();
return t !== '' ? t : ($ict.setsNoTitle || '');
},
typLbl: (co) => {
let k = (co.typ || '').toString().toLowerCase();
return ($ict.settyp || {})[k] || co.typ || '';
},
isSet: (co) => (co.typ || '').toString().toLowerCase() === 'set',
/* Normalised parent id of a line, mirroring InvoiceSetStructure.ParentOf: SetItmId when it
names ANOTHER set line of the same block; '' for top level / self-reference / unknown. */
parentOf: (co, byId) => {
let p = (co.SetItmId == null ? '' : co.SetItmId).toString(), id = (co.id || '').toString();
if (p === '' || p === id) { return ''; }
let t = byId[p];
return (t && $inv.sets.isSet(t)) ? p : '';
},
isDescendant: (candidateId, ancestorId, byId) => {
let seen = {}, cur = byId[candidateId];
while (cur) {
let p = $inv.sets.parentOf(cur, byId);
if (p === '' || seen[p]) { return false; }
if (p === ancestorId) { return true; }
seen[p] = true; cur = byId[p];
}
return false;
},
/* Whether a line may be dropped onto the given target set (same block, not itself, no cycle). */
canDrop: (drag, targetId, bid, byId) =>
!!drag && drag.bid === bid && drag.id !== targetId && !$inv.sets.isDescendant(targetId, drag.id, byId),
sync: (delta) => {
if ($inv.sets.c) { $inv.sets.c.aC('busy'); }
return $inv.d.sync(delta);
},
render: function (state) {
let c = $inv.sets.c; if (!c) { return; }
let bdy = c.closest('.modal-body'), scroll = bdy.length ? bdy.scrollTop() : 0;
c.empty().rC('busy');
$$.dc('shint', c).text($ict.setsHint || '');
(state.req || []).forEach((b) => {
if (!b) { return; }
let bid = (b.Id || '').toString(); if (bid === '') { return; }
let itms = (b.itm || []).filter((co) => co && (co.id || '') !== ''), byId = {};
itms.forEach((co) => { byId[(co.id || '').toString()] = co; });
let blk = $$.dc('sblock', c).data({ bid: bid });
$inv.sets.rootZone($$.dc('sbhd', blk).append($$.sc('nme', b.text || b.nme || bid)), bid, byId);
let ts = $$.tblset({ class: 'stbl' }, blk);
let thr = $$.tr(ts.hd);
($ict.setsHR || []).forEach((h) => $$.th(thr, h));
itms.forEach((co) => $inv.sets.row(ts.bdy, co, bid, itms, byId));
$inv.sets.rootZone($$.dc('sroot', blk).text($ict.setsRoot || ''), bid, byId);
$inv.sets.createBar(blk, bid, itms, byId);
});
if (bdy.length) { bdy.scrollTop(scroll); }
},
/* Ancestor-collapsed test: a row is hidden while any set above it is folded. */
collapsed: {}, /* set id -> true (survives re-renders; default: everything expanded) */
hiddenByCollapse: function (co, byId) {
let seen = {}, cur = co;
while (cur) {
let p = $inv.sets.parentOf(cur, byId);
if (p === '' || seen[p]) { return false; }
if ($inv.sets.collapsed[p]) { return true; }
seen[p] = true; cur = byId[p];
}
return false;
},
/* Drop zone of a pointer position within a row: top edge = before, bottom edge = after,
middle = into (sets only; for other rows the middle counts as "after"). */
zone: function (ev, tr, isSet) {
let oe = ev.originalEvent || ev, r = tr[0].getBoundingClientRect(), y = (oe.clientY || 0) - r.top, h = r.height || 1;
if (y < h * 0.3) { return 'before'; }
if (y > h * 0.7 || !isSet) { return 'after'; }
return 'into';
},
clearDrop: () => { $('.setstruct .dropok, .setstruct .dropbefore, .setstruct .dropafter').rC('dropok dropbefore dropafter'); },
/* One line of the structure table: drag handle, fold toggle, pos, type, qty, indented title,
order buttons (▲▼ among siblings) and level buttons (◀▶). */
row: function (tbody, co, bid, itms, byId) {
let id = (co.id || '').toString(), lvl = parseInt(co.lvl || 0, 10) || 0, isSet = $inv.sets.isSet(co);
let parent = $inv.sets.parentOf(co, byId), folded = isSet && !!$inv.sets.collapsed[id];
let tr = $$.tr(tbody, { draggable: 'true' }).aC('srow lvl' + lvl).tC('isset', isSet).tC('folded', folded).data({ id: id, bid: bid });
if ($inv.sets.hiddenByCollapse(co, byId)) { tr.aC('hid'); }
$$.tdc('grip', tr).append(gi('move')).attr('title', $rct.mR);
$$.tdc('pos', tr).text(co.p != null ? co.p : '');
let typ = $$.tdc('typ', tr);
if (isSet) { /* fold toggle sits in the type column, in front of "Set" */
typ.append($$.sc('fold').attr('title', folded ? $ict.setsExpand : $ict.setsCollapse).append(gi(folded ? 'triangle-right' : 'triangle-bottom')).click((ev) => {
ev.stopPropagation();
if (folded) { delete $inv.sets.collapsed[id]; } else { $inv.sets.collapsed[id] = true; }
let state = $inv.d.tbl().data('dstate'); if (state) { $inv.sets.render(state); }
}));
}
typ.append($$.s($inv.sets.typLbl(co)));
$$.tdc('qty', tr).text(co.q || '');
let ttl = $$.tdc('ttl', tr).append($$.sc('ind').css('width', (lvl * 1.4).toString() + 'rem')).append($$.s($inv.sets.title(co)));
if (isSet) {
let n = itms.filter((x) => $inv.sets.parentOf(x, byId) === id).length;
ttl.append($$.sc('cnt', ' (' + n + ')'));
}
/* siblings (same parent, current order) drive the ▲▼ buttons; a preceding sibling set drives ▶ */
let sibs = itms.filter((x) => $inv.sets.parentOf(x, byId) === parent), si = sibs.findIndex((x) => (x.id || '').toString() === id);
let hasSibSet = sibs.slice(0, si).some((x) => $inv.sets.isSet(x));
let act = $$.tdc('act', tr);
let btn = (cls, glyph, title, enabled, delta) => $$.dc('ibtn ' + cls, act, { title: title }).append(gi(glyph)).tC('disabled', !enabled)
.click(() => { if (enabled) { $inv.sets.sync(delta); } });
btn('up', 'arrow-up', $ict.setsUp, si > 0, { Target: 'set.move', Ref: id, Value: -1 });
btn('down', 'arrow-down', $ict.setsDown, si >= 0 && si < sibs.length - 1, { Target: 'set.move', Ref: id, Value: 1 });
btn('out', 'chevron-left', $ict.setsOut, lvl > 0, { Target: 'set.level', Ref: id, Value: -1 });
btn('in', 'chevron-right', $ict.setsIn, hasSibSet, { Target: 'set.level', Ref: id, Value: 1 });
tr.on('dragstart', (ev) => {
$inv.sets.drag = { id: id, bid: bid };
tr.aC('dragging');
try { ev.originalEvent.dataTransfer.setData('text/plain', id); ev.originalEvent.dataTransfer.effectAllowed = 'move'; } catch (e) { /* ignore */ }
}).on('dragend', () => { tr.rC('dragging'); $inv.sets.drag = null; $inv.sets.clearDrop(); });
/* Every row is a target: before / after (reorder, become the anchor's sibling — works on a
folded set too, its subtree travels along) and, for sets, into (become its last member). */
let ok = () => $inv.sets.canDrop($inv.sets.drag, id, bid, byId);
tr.on('dragover', (ev) => {
if (!ok()) { return; }
ev.preventDefault();
let z = $inv.sets.zone(ev, tr, isSet);
tr.rC('dropok dropbefore dropafter').aC(z === 'into' ? 'dropok' : (z === 'before' ? 'dropbefore' : 'dropafter'));
}).on('dragleave', () => { tr.rC('dropok dropbefore dropafter'); }).on('drop', (ev) => {
ev.preventDefault(); $inv.sets.clearDrop();
let d = $inv.sets.drag; $inv.sets.drag = null;
if (!$inv.sets.canDrop(d, id, bid, byId)) { return; }
let z = $inv.sets.zone(ev, tr, isSet);
if (z === 'into') { $inv.sets.sync({ Target: 'set.assign', Ref: d.id, Value: { parent: id } }); }
else { $inv.sets.sync({ Target: 'set.move', Ref: d.id, Value: { anchor: id, where: z } }); }
});
return tr;
},
/* Drop target for "pull out of its set" (top level of the block). */
rootZone: function (el, bid, byId) {
let ok = () => { let d = $inv.sets.drag; return !!d && d.bid === bid && !!byId[d.id] && $inv.sets.parentOf(byId[d.id], byId) !== ''; };
el.on('dragover', (ev) => { if (ok()) { ev.preventDefault(); el.aC('dropok'); } })
.on('dragleave', () => { el.rC('dropok'); })
.on('drop', (ev) => {
ev.preventDefault(); el.rC('dropok');
let d = $inv.sets.drag; $inv.sets.drag = null;
if (d && d.bid === bid && byId[d.id] && $inv.sets.parentOf(byId[d.id], byId) !== '') {
$inv.sets.sync({ Target: 'set.assign', Ref: d.id, Value: { parent: '' } });
}
});
return el;
},
/* "New line" bar per block: type (Set/Text), target set (top level or any set), title. */
createBar: function (blk, bid, itms, byId) {
let bar = $$.dc('screate', blk);
let typ = $('<select></select>').attr({ name: 'typ' }).append([$$.opt('set', $ict.settyp.set), $$.opt('text', $ict.settyp.text)]);
let par = $('<select></select>').attr({ name: 'parent' }).append($$.opt('', $ict.setsTop));
itms.forEach((co) => {
if (!$inv.sets.isSet(co)) { return; }
let lvl = parseInt(co.lvl || 0, 10) || 0;
par.append($$.opt((co.id || '').toString(), ' '.repeat(lvl * 3) + $inv.sets.title(co)));
});
let ttl = $('<input type="text" />').attr({ name: 'title', placeholder: $ict.setsTitle, maxlength: 250 });
let add = () => {
let t = (ttl.val() || '').toString().trim();
if (t === '') { ttl.focus(); return; }
$inv.sets.sync({ Target: 'set.create', Ref: bid, Value: { typ: typ.val(), title: t, parent: par.val() || '' } });
ttl.val('');
};
ttl.on('keydown', (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); add(); } });
bar.append([$$.sc('lbl', $ict.setsNew), typ, $$.sc('lbl', $ict.setsInto), par, ttl, $$.bbtn($ict.setsAdd, 'sadd').click(add)]);
return bar;
}
};
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+4 -1
View File
@@ -2882,8 +2882,11 @@ $ocms.init = function (ev) {
} }
$ocms[mdl].auth = auth; $ocms[mdl].auth = auth;
if (auth.manage > 0) { if (auth.manage > 0) {
/* ?v=<assetversion> (newest bundle write time, emitted by _Layout) busts the browser
cache of the dynamically imported module bundle + css after a gulp rebuild. */
let av = '?v=' + encodeURIComponent($ocms.auth.assetversion || '0');
$ocms.getScript({ $ocms.getScript({
module: mdl, script: ['/web/fis', mdl, $ocms.auth.locale || 'de', 'js'].join('.'), css: ['/web/fis', mdl, 'css'].join('.'), condition: typeof $ocms[mdl].init2 !== 'function' module: mdl, script: ['/web/fis', mdl, $ocms.auth.locale || 'de', 'js'].join('.') + av, css: ['/web/fis', mdl, 'css'].join('.') + av, condition: typeof $ocms[mdl].init2 !== 'function'
}, function () { $ocms[mdl].init2(); }); }, function () { $ocms[mdl].init2(); });
} }
}, error: function () { }, error: function () {
+1 -1
View File
File diff suppressed because one or more lines are too long
+189
View File
@@ -1,3 +1,4 @@
@charset "UTF-8";
/* basics */ /* basics */
/* Color SCHEME */ /* Color SCHEME */
/* #8396bd */ /* #8396bd */
@@ -306,6 +307,33 @@ table.if th.keep, table.if td.keep {
.edit_frm .invoice_layout .emailfrm:hover div.axf { .edit_frm .invoice_layout .emailfrm:hover div.axf {
display: block; display: block;
} }
.edit_frm .invoice_layout .emailccfrm {
position: absolute;
font-size: 0.7rem;
top: 2.2rem;
left: 0.5rem;
width: auto;
height: auto;
cursor: pointer;
}
.edit_frm .invoice_layout .emailccfrm .content {
color: #000;
}
.edit_frm .invoice_layout .emailccfrm {
/* Unlike the other optional fields (plain "-" placeholder), this one names the action —
there is no default "Zusatzempfänger" from MFR, so an empty state must read as an
offer to add one, not as missing data. */
}
.edit_frm .invoice_layout .emailccfrm .content:empty::before {
content: "Zusatzempfänger hinzufügen";
min-width: 5rem;
height: 1rem;
display: block;
position: absolute;
}
.edit_frm .invoice_layout .emailccfrm:hover div.axf {
display: block;
}
.edit_frm .invoice_layout .admfrm { .edit_frm .invoice_layout .admfrm {
position: absolute; position: absolute;
right: 3rem; right: 3rem;
@@ -439,6 +467,27 @@ table.if th.keep, table.if td.keep {
.edit_frm .invoice_layout table.invi > tbody > tr.sethdr td.txt { .edit_frm .invoice_layout table.invi > tbody > tr.sethdr td.txt {
font-weight: bold; font-weight: bold;
} }
.edit_frm .invoice_layout table.invi > tbody > tr {
/* Nested set members (ADR 0016): indent per level, mirroring the PDF's per-level indent. */
}
.edit_frm .invoice_layout table.invi > tbody > tr.lvl1 td.txt {
padding-left: calc(0.4rem + 1.4rem);
}
.edit_frm .invoice_layout table.invi > tbody > tr.lvl2 td.txt {
padding-left: calc(0.4rem + 2.8rem);
}
.edit_frm .invoice_layout table.invi > tbody > tr.lvl3 td.txt {
padding-left: calc(0.4rem + 4.2rem);
}
.edit_frm .invoice_layout table.invi > tbody > tr.lvl4 td.txt {
padding-left: calc(0.4rem + 5.6rem);
}
.edit_frm .invoice_layout table.invi > tbody > tr.lvl5 td.txt {
padding-left: calc(0.4rem + 7rem);
}
.edit_frm .invoice_layout table.invi > tbody > tr.lvl6 td.txt {
padding-left: calc(0.4rem + 8.4rem);
}
.edit_frm .invoice_layout table.invi th.currency, .edit_frm .invoice_layout table.invi th.num, .edit_frm .invoice_layout table.invi td.currency, .edit_frm .invoice_layout table.invi td.num { .edit_frm .invoice_layout table.invi th.currency, .edit_frm .invoice_layout table.invi th.num, .edit_frm .invoice_layout table.invi td.currency, .edit_frm .invoice_layout table.invi td.num {
text-align: right; text-align: right;
white-space: nowrap; white-space: nowrap;
@@ -647,4 +696,144 @@ table.if th.keep, table.if td.keep {
} }
.adr-conformity .tx { .adr-conformity .tx {
color: #445; color: #445;
}
/* ── Set structure dialog (ADR 0016, see $inv.sets in fis.inv_sets.js) ─────────────────── */
.modal .setstruct.busy {
opacity: 0.6;
pointer-events: none;
}
.modal .setstruct div.shint {
font-size: 0.85rem;
color: #666;
margin: 0 0 0.8rem 0;
}
.modal .setstruct div.sblock {
margin: 0 0 1.4rem 0;
}
.modal .setstruct div.sblock div.sbhd {
font-weight: bold;
padding: 0.4rem 0.6rem;
border-top: 1.5px solid #000;
border-bottom: 1px solid #ddd;
}
.modal .setstruct div.sblock div.sbhd.dropok {
background-color: #e6f2e6;
outline: 2px dashed #5a5;
}
.modal .setstruct div.sblock table.stbl {
width: 100%;
border-collapse: collapse;
}
.modal .setstruct div.sblock table.stbl th {
text-align: left;
font-size: 0.8rem;
color: #666;
padding: 0.3rem 0.4rem;
border-bottom: 1px solid #ddd;
}
.modal .setstruct div.sblock table.stbl td {
padding: 0.3rem 0.4rem;
border-bottom: 1px solid #f0f0f0;
vertical-align: middle;
}
.modal .setstruct div.sblock table.stbl td.grip {
width: 1.6rem;
cursor: grab;
color: #999;
}
.modal .setstruct div.sblock table.stbl td.pos, .modal .setstruct div.sblock table.stbl td.qty {
width: 4rem;
white-space: nowrap;
}
.modal .setstruct div.sblock table.stbl td.typ {
width: 7rem;
white-space: nowrap;
color: #666;
font-size: 0.85rem;
}
.modal .setstruct div.sblock table.stbl td.typ span.fold {
cursor: pointer;
margin-right: 0.35rem;
color: #555;
display: inline-block;
width: 1rem;
}
.modal .setstruct div.sblock table.stbl td.ttl span.ind {
display: inline-block;
}
.modal .setstruct div.sblock table.stbl td.ttl span.seti {
margin-right: 0.3rem;
color: #888;
}
.modal .setstruct div.sblock table.stbl td.ttl span.cnt {
color: #999;
font-weight: normal;
font-size: 0.85rem;
}
.modal .setstruct div.sblock table.stbl td.act {
width: 7.5rem;
white-space: nowrap;
text-align: right;
}
.modal .setstruct div.sblock table.stbl td.act div.ibtn {
display: inline-block;
cursor: pointer;
padding: 0 0.25rem;
}
.modal .setstruct div.sblock table.stbl td.act div.ibtn.disabled {
opacity: 0.25;
cursor: default;
}
.modal .setstruct div.sblock table.stbl tr.srow.isset td.ttl {
font-weight: bold;
}
.modal .setstruct div.sblock table.stbl tr.srow.hid {
display: none;
}
.modal .setstruct div.sblock table.stbl tr.srow.folded td.ttl {
font-style: italic;
}
.modal .setstruct div.sblock table.stbl tr.srow.dragging {
opacity: 0.4;
}
.modal .setstruct div.sblock table.stbl tr.srow.dropok td {
background-color: #e6f2e6;
}
.modal .setstruct div.sblock table.stbl tr.srow.dropbefore td {
border-top: 3px solid #5a5;
}
.modal .setstruct div.sblock table.stbl tr.srow.dropafter td {
border-bottom: 3px solid #5a5;
}
.modal .setstruct div.sblock div.sroot {
margin: 0.3rem 0 0.5rem 0;
padding: 0.4rem 0.6rem;
border: 1px dashed #bbb;
color: #888;
font-size: 0.85rem;
}
.modal .setstruct div.sblock div.sroot.dropok {
background-color: #e6f2e6;
border-color: #5a5;
color: #363;
}
.modal .setstruct div.sblock div.screate {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.6rem;
background-color: #f7f7f7;
}
.modal .setstruct div.sblock div.screate span.lbl {
font-size: 0.85rem;
color: #666;
}
.modal .setstruct div.sblock div.screate select, .modal .setstruct div.sblock div.screate input[type=text] {
padding: 0.2rem 0.3rem;
}
.modal .setstruct div.sblock div.screate input[type=text] {
flex: 1 1 14rem;
min-width: 10rem;
} }
+257 -15
View File
@@ -65,6 +65,7 @@ let $rct = {
invoiceaddress: 'Adresse' invoiceaddress: 'Adresse'
, loc: 'Leistungsort / Lieferadresse' , loc: 'Leistungsort / Lieferadresse'
, invoiceemail: 'Email' , invoiceemail: 'Email'
, invoiceemailcc: 'Zusatzempfänger (CC)'
} }
}, $rcol = { }, $rcol = {
@@ -154,6 +155,24 @@ let $ict = {
setprice: 'Set mit Preis – Positionen ohne Preis', setprice: 'Set mit Preis – Positionen ohne Preis',
setonly: 'Nur Set mit Preis – Positionen ausgeblendet' setonly: 'Nur Set mit Preis – Positionen ausgeblendet'
}, },
/* user-defined set structure dialog (ADR 0016) */
setst: 'Set-Struktur bearbeiten',
setsHint: 'Reihenfolge: Zeile per Drag & Drop an den oberen/unteren Rand einer anderen Zeile ziehen (davor/danach) oder ▲▼ nutzen. Zuordnung: Zeile auf die Mitte eines Sets ziehen (hinein) bzw. auf die Auftragszeile oder "oberste Ebene" (aus dem Set lösen); ◀▶ ändert die Ebene. Sets lassen sich über das Dreieck zu-/aufklappen und auch zugeklappt verschieben – ihre Positionen wandern mit. Änderungen werden sofort übernommen.',
setsUp: 'Nach oben (innerhalb der Ebene)',
setsDown: 'Nach unten (innerhalb der Ebene)',
setsCollapse: 'Set zuklappen',
setsExpand: 'Set aufklappen',
setsHR: ['', 'Pos.', 'Typ', 'Menge', 'Bezeichnung', 'Reihenfolge / Ebene'],
setsRoot: 'Hierher ziehen = aus dem Set lösen (oberste Ebene)',
setsOut: 'Eine Ebene höher (aus dem Set lösen)',
setsIn: 'Eine Ebene tiefer (dem vorangehenden Set zuordnen)',
setsNew: 'Neu:',
setsInto: 'in',
setsTop: 'oberste Ebene',
setsTitle: 'Bezeichnung',
setsAdd: 'Hinzufügen',
setsNoTitle: '(ohne Bezeichnung)',
settyp: { set: 'Set', text: 'Text', title: 'Text', material: 'Material', service: 'Arbeitsleistung', equipment: 'Ausrüstung', other: 'Position', osum: 'Zusammenfassung', intinvoiceallocation: 'Abschlag', intinvoiceallocationdiff: 'Abschlag (offen)' },
ctp: 'Ansprechpartner festlegen', ctp: 'Ansprechpartner festlegen',
mfr: 'Von MFR neu abrufen', mfr: 'Von MFR neu abrufen',
rq1: 'Auftragsdaten werden von MFR abgerufen.\nDer Vorgang kann bis zu 90Sek dauern.', rq1: 'Auftragsdaten werden von MFR abgerufen.\nDer Vorgang kann bis zu 90Sek dauern.',
@@ -610,7 +629,7 @@ $inv.iMn = (ix) => {
yet been explicitly switched to a set-display mode (admin.setmode empty/undefined) — once set, yet been explicitly switched to a set-display mode (admin.setmode empty/undefined) — once set,
changing it again is not supported (the user edits the line items directly instead); it changing it again is not supported (the user edits the line items directly instead); it
reappears only if 'Änderungen verwerfen' reverts to a DB state that was never switched. */ reappears only if 'Änderungen verwerfen' reverts to a DB state that was never switched. */
$inv.emOpt = (admin) => 'iss,p13b,ctp' + (((admin || {}).setmode || '') === '' ? ',setm' : ''); $inv.emOpt = (admin) => 'iss,p13b,ctp,setst' + (((admin || {}).setmode || '') === '' ? ',setm' : '');
$inv.eM = (r, re, opt) => { $inv.eM = (r, re, opt) => {
let m = []; let m = [];
if (booln(r, false) === true || booln(re, false) === true) { if (booln(r, false) === true || booln(re, false) === true) {
@@ -628,6 +647,9 @@ $inv.eM = (r, re, opt) => {
if ((opt || '').split(',').includes('setm') === true) { if ((opt || '').split(',').includes('setm') === true) {
m.push({ lbl: $ict.setm, fnc: $inv.ssetmode }); m.push({ lbl: $ict.setm, fnc: $inv.ssetmode });
} }
if ((opt || '').split(',').includes('setst') === true) { /* user-defined set structure dialog (ADR 0016) */
m.push({ lbl: $ict.setst, fnc: () => $inv.sets.open() });
}
if ((opt || '').split(',').includes('iss') === true) { /* backend-authoritative draft (ADR 0006) */ if ((opt || '').split(',').includes('iss') === true) { /* backend-authoritative draft (ADR 0006) */
m.push({ lbl: 'Änderungshistorie', fnc: () => $inv.d.history() }); m.push({ lbl: 'Änderungshistorie', fnc: () => $inv.d.history() });
m.push({ lbl: 'Änderungen verwerfen', fnc: () => $inv.d.discard() }); m.push({ lbl: 'Änderungen verwerfen', fnc: () => $inv.d.discard() });
@@ -696,12 +718,13 @@ $inv.d = {
if (typeof curVer === 'number' && typeof state.version === 'number' && state.version < curVer) { if (typeof curVer === 'number' && typeof state.version === 'number' && state.version < curVer) {
return; return;
} }
tbl.data('dver', state.version).data('serverSums', state.sums); tbl.data('dver', state.version).data('serverSums', state.sums).data('dstate', state);
$inv.d.footer(tbl, state.sums || {}, state.admin || {}); $inv.d.footer(tbl, state.sums || {}, state.admin || {});
$inv.d.notes(state.notes || []); $inv.d.notes(state.notes || []);
$inv.d.validation(state.validation || []); $inv.d.validation(state.validation || []);
$inv.d.applyItems(tbl, state.req || []); $inv.d.applyItems(tbl, state.req || []);
$inv.d.applySetDisplay(tbl, state.setDisplay || {}); $inv.d.applySetDisplay(tbl, state.setDisplay || {});
if ($inv.sets && $inv.sets.isOpen()) { $inv.sets.render(state); } /* the set-structure dialog is a view of the same state (ADR 0016) */
/* Keep the menu's "Set-Preisanzeige" entry in sync with the authoritative admin.setmode: /* Keep the menu's "Set-Preisanzeige" entry in sync with the authoritative admin.setmode:
hides it right after a mode switch, and restores it if a discard reverted the draft to hides it right after a mode switch, and restores it if a discard reverted the draft to
a DB state that was never switched (ADR 0006 backend-authoritative refresh). */ a DB state that was never switched (ADR 0006 backend-authoritative refresh). */
@@ -747,13 +770,15 @@ $inv.d = {
let changed = isNew || (dta.Type || '') !== (co.typ || '') let changed = isNew || (dta.Type || '') !== (co.typ || '')
|| dta.net !== val(co.v) || dta.net_val !== val(co.vt) || dta.vat_val !== val(co.vv) || dta.net !== val(co.v) || dta.net_val !== val(co.vt) || dta.vat_val !== val(co.vv)
|| dta.svcnet_val !== val(co.vs) || dta.svcvat_val !== val(co.vsv) || dta.svcnet_val !== val(co.vs) || dta.svcvat_val !== val(co.vsv)
|| (dta.position || '') !== (co.p != null ? co.p : ''); || (dta.position || '') !== (co.p != null ? co.p : '')
|| (dta.lvl || 0) !== (co.lvl || 0) || (dta.SetItmId || null) !== (co.SetItmId != null ? co.SetItmId : null);
$.extend(dta, { $.extend(dta, {
Id: id, Type: co.typ, Id: id, Type: co.typ,
net: val(co.v), net_val: val(co.vt), vat_val: val(co.vv), net: val(co.v), net_val: val(co.vt), vat_val: val(co.vv),
svcnet_val: val(co.vs), svcvat_val: val(co.vsv), svcnet_val: val(co.vs), svcvat_val: val(co.vsv),
vat: co.vat, position: (co.p != null ? co.p : ''), vat: co.vat, position: (co.p != null ? co.p : ''),
SetItmId: (co.SetItmId != null ? co.SetItmId : null) SetItmId: (co.SetItmId != null ? co.SetItmId : null),
lvl: (co.lvl || 0) /* nesting depth (ADR 0016) — rendered as an indent */
}); });
if (isNew) { $.extend(dta, { htmltext: co.t, quantity: co.q }); } // text only for server-created rows (e.g. the set row) if (isNew) { $.extend(dta, { htmltext: co.t, quantity: co.q }); } // text only for server-created rows (e.g. the set row)
rw.data(dta); rw.data(dta);
@@ -835,7 +860,7 @@ $inv.d = {
/* Map an inline recipient field to its delta target and send it. No token-guard here for the /* Map an inline recipient field to its delta target and send it. No token-guard here for the
same reason as syncChanged: sync() waits for an in-flight seed instead of dropping the edit. */ same reason as syncChanged: sync() waits for an in-flight seed instead of dropping the edit. */
syncField: function (nme, val) { syncField: function (nme, val) {
let map = { invoicetitle: 'title', invoiceaddress: 'address', invoiceemail: 'email', loc: 'provisionlocation', provisionlocation: 'provisionlocation', provisionperiod: 'provisionperiod' }; let map = { invoicetitle: 'title', invoiceaddress: 'address', invoiceemail: 'email', invoiceemailcc: 'emailcc', loc: 'provisionlocation', provisionlocation: 'provisionlocation', provisionperiod: 'provisionperiod' };
let target = map[nme]; if (!target) { return; } let target = map[nme]; if (!target) { return; }
$inv.d.sync({ Target: target, Value: val }); $inv.d.sync({ Target: target, Value: val });
}, },
@@ -1422,6 +1447,7 @@ $inv.cntInv = function (data) { //invoice continuation
fm('adrfrm', inv.SendToAddress, 'invoiceaddress', 0, null); fm('adrfrm', inv.SendToAddress, 'invoiceaddress', 0, null);
fm('locfrm', inv.ProvisionLocation, 'loc', 1, null); fm('locfrm', inv.ProvisionLocation, 'loc', 1, null);
fm('emailfrm', inv.SendToEmail, 'invoiceemail', 0, null); fm('emailfrm', inv.SendToEmail, 'invoiceemail', 0, null);
fm('emailccfrm', inv.SendToEmailCc, 'invoiceemailcc', 0, null);
$$.dc('sndfrm', frm).append($$.dc('content').text(response.admin.sender)); $$.dc('sndfrm', frm).append($$.dc('content').text(response.admin.sender));
fm('admfrm', inv.ProvisionPeriod, 'provisionperiod', ((inv.ProvisionPeriod || '').includes('-') === true ? $rct.provP : $rct.provD), 1, null); fm('admfrm', inv.ProvisionPeriod, 'provisionperiod', ((inv.ProvisionPeriod || '').includes('-') === true ? $rct.provP : $rct.provD), 1, null);
@@ -1484,6 +1510,7 @@ $inv.cSt = function (data) {
fm('adrfrm', inv.SendToAddress, 'invoiceaddress', 0, null); fm('adrfrm', inv.SendToAddress, 'invoiceaddress', 0, null);
fm('locfrm', inv.ProvisionLocation, 'loc', 1, null); fm('locfrm', inv.ProvisionLocation, 'loc', 1, null);
fm('emailfrm', inv.SendToEmail, 'invoiceemail', 0, null); fm('emailfrm', inv.SendToEmail, 'invoiceemail', 0, null);
fm('emailccfrm', inv.SendToEmailCc, 'invoiceemailcc', 0, null);
$$.dc('sndfrm', frm).append($$.dc('content').text(response.admin.sender)); $$.dc('sndfrm', frm).append($$.dc('content').text(response.admin.sender));
fm('admfrm', inv.ProvisionPeriod, 'provisionperiod', ((inv.ProvisionPeriod || '').includes('-') === true ? $rct.provP : $rct.provD), 1, null); fm('admfrm', inv.ProvisionPeriod, 'provisionperiod', ((inv.ProvisionPeriod || '').includes('-') === true ? $rct.provP : $rct.provD), 1, null);
@@ -1625,7 +1652,7 @@ $inv.eHtml = function (ev) {
Leistungsdatum). The backend sanitises HTML too (single source of truth, ADR 0006), but 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 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. */ (invoiceaddress, loc) stay HTML-capable and are normalised to newlines server-side. */
let isPlainText = ['invoiceemail', 'provisionperiod', 'invoicetitle'].includes(ev.data.nme); let isPlainText = ['invoiceemail', 'invoiceemailcc', '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' } }];
@@ -1758,7 +1785,7 @@ $inv.rrw = function () {
} else if (rw.is('.itm.osum') === true) { } else if (rw.is('.itm.osum') === true) {
co = { invrqid: dta.InvRqId, id: 'osum' + rw.index(), typ: 'osum', p: '', q: null, t: oHtml(dta.tbl.tbl), tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: false }; co = { invrqid: dta.InvRqId, id: 'osum' + rw.index(), typ: 'osum', p: '', q: null, t: oHtml(dta.tbl.tbl), tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: false };
} else { } else {
co = { invrqid: dta.InvRqId, id: dta.Id || '', typ: dta.Type || 'other', p: '', q: null, t: '', tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: (dta.Note || '') !== '' && hn === false, SetItmId: dta.SetItmId || null }; co = { invrqid: dta.InvRqId, id: dta.Id || '', typ: dta.Type || 'other', p: '', q: null, t: '', tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: (dta.Note || '') !== '' && hn === false, SetItmId: dta.SetItmId || null, lvl: (dta.lvl || 0) };
$$.dc('ibtn ico move', axf, { title: $rct.mR }); /* should be the last added */ $$.dc('ibtn ico move', axf, { title: $rct.mR }); /* should be the last added */
co.p = dta.position || (dta.SortOrder || ''); co.p = dta.position || (dta.SortOrder || '');
@@ -1798,6 +1825,7 @@ $inv.rrw = function () {
} }
rw.empty().attr('class', ph ? 'placeholder' : 'itm').aC(co.Typ) rw.empty().attr('class', ph ? 'placeholder' : 'itm').aC(co.Typ)
.tC('sethdr', (co.typ || '').toString().toLowerCase() === 'set') /* emphasise set rows (fn1 header + block set row) */ .tC('sethdr', (co.typ || '').toString().toLowerCase() === 'set') /* emphasise set rows (fn1 header + block set row) */
.aC((co.lvl || 0) > 0 ? 'lvl' + Math.min(co.lvl, 6) : '') /* indent nested set members (ADR 0016) */
.tC('hidenote', hn).append(tda); .tC('hidenote', hn).append(tda);
dta.co = co; dta.co = co;
}; };
@@ -1828,8 +1856,23 @@ $inv.invSumUpdate = function () {
let tbl = $(this); let tbl = $(this);
let ba = []; let ba = [];
let bds = tbl.children('tbody'); let bds = tbl.children('tbody');
/* Hierarchical position numbering (ADR 0017), spanning the whole invoice: a running counter
per nesting depth, ported from InvoiceLineNumbering.Compute. A set header gets a number at
its own level ("3"); its members get subsidiary numbers one level deeper ("3.1", "3.2"). A
row's numbering never depends on the MFR SortOrder value — including for a Text/Title
header row synced with SortOrder -1, which used to leak "-1" straight into the Pos. column
here because it never went through this loop at all. */
let posCounters = [];
let nextPositionLabel = (numbered, lvl) => {
if (!numbered) { return ''; }
let l = Math.max(lvl || 0, 0);
while (posCounters.length <= l) { posCounters.push(0); }
posCounters.length = l + 1;
posCounters[l]++;
return posCounters.join('.');
};
bds.each((bi, bdy) => { bds.each((bi, bdy) => {
let b = $(bdy), rx = b.data() || {}, i = [], citems = [], itm = b.find('tr.itm'), iso = 0, ipos = 0; let b = $(bdy), rx = b.data() || {}, i = [], citems = [], itm = b.find('tr.itm');
b.tC('empty', itm.length < 1); b.tC('empty', itm.length < 1);
itm.each((ti, tx) => { itm.each((ti, tx) => {
let rrx = $(tx).data() || {}; let rrx = $(tx).data() || {};
@@ -1842,14 +1885,12 @@ $inv.invSumUpdate = function () {
by the id !== '' && !== citem.id check below). */ by the id !== '' && !== citem.id check below). */
let citem = $inv.itemToContract(rrx); let citem = $inv.itemToContract(rrx);
let sid = (rrx.SetItmId || '').toString(); let sid = (rrx.SetItmId || '').toString();
if (citem.type !== 'set' && sid !== '' && sid !== citem.id) { citem.setId = sid; } /* a nested set (ADR 0016) is itself a member of its parent set, so the type is not excluded here */
if (sid !== '' && sid !== citem.id) { citem.setId = sid; }
citems.push(citem); citems.push(citem);
if (((typeof rrx.SortOrder === 'undefined' || rrx.SortOrder === null) ? -1 : rrx.SortOrder) > -1) { let numbered = ['text', 'title'].includes((rrx.Type || 'other').toLowerCase()) === false;
if (['text', 'title'].includes((rrx.Type || 'other').toLowerCase()) === false) { ipos++; } rrx.position = nextPositionLabel(numbered, (rrx.co || {}).lvl || 0);
rrx.SortOrder = iso; $inv.rrw.call(tx);
rrx.position = ipos;
$inv.rrw.call(tx);
}
}); });
ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, items: citems }); ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, items: citems });
}); });
@@ -2384,3 +2425,204 @@ $inv.mfrrel = (id) => {
} }
}); });
}; };
/* ── User-defined set structure dialog (ADR 0016) ────────────────────────────
Lets the user define which line belongs to which set (and how deep) instead of relying only
on the implicit "everything after a Set header until the next Set" derivation that
fds__prepInvoice delivers as the initial structure. The dialog is a pure view of the
authoritative server state (state.req[].itm — the co shape with SetItmId + lvl): every action
posts ONE delta (set.assign / set.level / set.create) via $inv.d.sync, the server rewrites the
cached block (parent links, depth-first order, levels) and the normal draftReady/dstate
refresh re-renders both the editor rows and this dialog (applyState -> $inv.sets.render).
The browser never restructures anything itself (ADR 0006/0008). */
$inv.sets = {
o: null, /* open dialog handle ($ocms.dlg result) or null */
c: null, /* dialog content container */
drag: null, /* { id, bid } of the row currently being dragged */
/* Reorder = set.move (▲▼ or drag before/after a row), membership = set.assign (drag into a set /
root zone), depth = set.level (◀▶). Folding is purely a view state kept in $inv.sets.collapsed. */
isOpen: () => !!$inv.sets.o,
open: function () {
let tbl = $inv.d.tbl();
if (tbl.length < 1 || $inv.sets.isOpen()) { return; }
if ($inv.d.token() === '' && !tbl.data('dstate')) { return; } /* no draft session (e.g. reminder editor) — nothing to structure */
let c = $$.dc('setstruct');
$inv.sets.c = c;
$inv.sets.o = $ocms.dlg(c, {
title: $ict.setst, size: [-1, 960], exclusive: false,
close: () => { $inv.sets.o = null; $inv.sets.c = null; $inv.sets.drag = null; }
});
let state = tbl.data('dstate');
if (state) { $inv.sets.render(state); } else { $inv.d.refresh(); }
},
/* plain-text title of a co line (co.t is HTML for text lines / titles) */
title: (co) => {
let t = $$.d().html(co.t || '').text().replace(/\s+/g, ' ').trim();
return t !== '' ? t : ($ict.setsNoTitle || '');
},
typLbl: (co) => {
let k = (co.typ || '').toString().toLowerCase();
return ($ict.settyp || {})[k] || co.typ || '';
},
isSet: (co) => (co.typ || '').toString().toLowerCase() === 'set',
/* Normalised parent id of a line, mirroring InvoiceSetStructure.ParentOf: SetItmId when it
names ANOTHER set line of the same block; '' for top level / self-reference / unknown. */
parentOf: (co, byId) => {
let p = (co.SetItmId == null ? '' : co.SetItmId).toString(), id = (co.id || '').toString();
if (p === '' || p === id) { return ''; }
let t = byId[p];
return (t && $inv.sets.isSet(t)) ? p : '';
},
isDescendant: (candidateId, ancestorId, byId) => {
let seen = {}, cur = byId[candidateId];
while (cur) {
let p = $inv.sets.parentOf(cur, byId);
if (p === '' || seen[p]) { return false; }
if (p === ancestorId) { return true; }
seen[p] = true; cur = byId[p];
}
return false;
},
/* Whether a line may be dropped onto the given target set (same block, not itself, no cycle). */
canDrop: (drag, targetId, bid, byId) =>
!!drag && drag.bid === bid && drag.id !== targetId && !$inv.sets.isDescendant(targetId, drag.id, byId),
sync: (delta) => {
if ($inv.sets.c) { $inv.sets.c.aC('busy'); }
return $inv.d.sync(delta);
},
render: function (state) {
let c = $inv.sets.c; if (!c) { return; }
let bdy = c.closest('.modal-body'), scroll = bdy.length ? bdy.scrollTop() : 0;
c.empty().rC('busy');
$$.dc('shint', c).text($ict.setsHint || '');
(state.req || []).forEach((b) => {
if (!b) { return; }
let bid = (b.Id || '').toString(); if (bid === '') { return; }
let itms = (b.itm || []).filter((co) => co && (co.id || '') !== ''), byId = {};
itms.forEach((co) => { byId[(co.id || '').toString()] = co; });
let blk = $$.dc('sblock', c).data({ bid: bid });
$inv.sets.rootZone($$.dc('sbhd', blk).append($$.sc('nme', b.text || b.nme || bid)), bid, byId);
let ts = $$.tblset({ class: 'stbl' }, blk);
let thr = $$.tr(ts.hd);
($ict.setsHR || []).forEach((h) => $$.th(thr, h));
itms.forEach((co) => $inv.sets.row(ts.bdy, co, bid, itms, byId));
$inv.sets.rootZone($$.dc('sroot', blk).text($ict.setsRoot || ''), bid, byId);
$inv.sets.createBar(blk, bid, itms, byId);
});
if (bdy.length) { bdy.scrollTop(scroll); }
},
/* Ancestor-collapsed test: a row is hidden while any set above it is folded. */
collapsed: {}, /* set id -> true (survives re-renders; default: everything expanded) */
hiddenByCollapse: function (co, byId) {
let seen = {}, cur = co;
while (cur) {
let p = $inv.sets.parentOf(cur, byId);
if (p === '' || seen[p]) { return false; }
if ($inv.sets.collapsed[p]) { return true; }
seen[p] = true; cur = byId[p];
}
return false;
},
/* Drop zone of a pointer position within a row: top edge = before, bottom edge = after,
middle = into (sets only; for other rows the middle counts as "after"). */
zone: function (ev, tr, isSet) {
let oe = ev.originalEvent || ev, r = tr[0].getBoundingClientRect(), y = (oe.clientY || 0) - r.top, h = r.height || 1;
if (y < h * 0.3) { return 'before'; }
if (y > h * 0.7 || !isSet) { return 'after'; }
return 'into';
},
clearDrop: () => { $('.setstruct .dropok, .setstruct .dropbefore, .setstruct .dropafter').rC('dropok dropbefore dropafter'); },
/* One line of the structure table: drag handle, fold toggle, pos, type, qty, indented title,
order buttons (▲▼ among siblings) and level buttons (◀▶). */
row: function (tbody, co, bid, itms, byId) {
let id = (co.id || '').toString(), lvl = parseInt(co.lvl || 0, 10) || 0, isSet = $inv.sets.isSet(co);
let parent = $inv.sets.parentOf(co, byId), folded = isSet && !!$inv.sets.collapsed[id];
let tr = $$.tr(tbody, { draggable: 'true' }).aC('srow lvl' + lvl).tC('isset', isSet).tC('folded', folded).data({ id: id, bid: bid });
if ($inv.sets.hiddenByCollapse(co, byId)) { tr.aC('hid'); }
$$.tdc('grip', tr).append(gi('move')).attr('title', $rct.mR);
$$.tdc('pos', tr).text(co.p != null ? co.p : '');
let typ = $$.tdc('typ', tr);
if (isSet) { /* fold toggle sits in the type column, in front of "Set" */
typ.append($$.sc('fold').attr('title', folded ? $ict.setsExpand : $ict.setsCollapse).append(gi(folded ? 'triangle-right' : 'triangle-bottom')).click((ev) => {
ev.stopPropagation();
if (folded) { delete $inv.sets.collapsed[id]; } else { $inv.sets.collapsed[id] = true; }
let state = $inv.d.tbl().data('dstate'); if (state) { $inv.sets.render(state); }
}));
}
typ.append($$.s($inv.sets.typLbl(co)));
$$.tdc('qty', tr).text(co.q || '');
let ttl = $$.tdc('ttl', tr).append($$.sc('ind').css('width', (lvl * 1.4).toString() + 'rem')).append($$.s($inv.sets.title(co)));
if (isSet) {
let n = itms.filter((x) => $inv.sets.parentOf(x, byId) === id).length;
ttl.append($$.sc('cnt', ' (' + n + ')'));
}
/* siblings (same parent, current order) drive the ▲▼ buttons; a preceding sibling set drives ▶ */
let sibs = itms.filter((x) => $inv.sets.parentOf(x, byId) === parent), si = sibs.findIndex((x) => (x.id || '').toString() === id);
let hasSibSet = sibs.slice(0, si).some((x) => $inv.sets.isSet(x));
let act = $$.tdc('act', tr);
let btn = (cls, glyph, title, enabled, delta) => $$.dc('ibtn ' + cls, act, { title: title }).append(gi(glyph)).tC('disabled', !enabled)
.click(() => { if (enabled) { $inv.sets.sync(delta); } });
btn('up', 'arrow-up', $ict.setsUp, si > 0, { Target: 'set.move', Ref: id, Value: -1 });
btn('down', 'arrow-down', $ict.setsDown, si >= 0 && si < sibs.length - 1, { Target: 'set.move', Ref: id, Value: 1 });
btn('out', 'chevron-left', $ict.setsOut, lvl > 0, { Target: 'set.level', Ref: id, Value: -1 });
btn('in', 'chevron-right', $ict.setsIn, hasSibSet, { Target: 'set.level', Ref: id, Value: 1 });
tr.on('dragstart', (ev) => {
$inv.sets.drag = { id: id, bid: bid };
tr.aC('dragging');
try { ev.originalEvent.dataTransfer.setData('text/plain', id); ev.originalEvent.dataTransfer.effectAllowed = 'move'; } catch (e) { /* ignore */ }
}).on('dragend', () => { tr.rC('dragging'); $inv.sets.drag = null; $inv.sets.clearDrop(); });
/* Every row is a target: before / after (reorder, become the anchor's sibling — works on a
folded set too, its subtree travels along) and, for sets, into (become its last member). */
let ok = () => $inv.sets.canDrop($inv.sets.drag, id, bid, byId);
tr.on('dragover', (ev) => {
if (!ok()) { return; }
ev.preventDefault();
let z = $inv.sets.zone(ev, tr, isSet);
tr.rC('dropok dropbefore dropafter').aC(z === 'into' ? 'dropok' : (z === 'before' ? 'dropbefore' : 'dropafter'));
}).on('dragleave', () => { tr.rC('dropok dropbefore dropafter'); }).on('drop', (ev) => {
ev.preventDefault(); $inv.sets.clearDrop();
let d = $inv.sets.drag; $inv.sets.drag = null;
if (!$inv.sets.canDrop(d, id, bid, byId)) { return; }
let z = $inv.sets.zone(ev, tr, isSet);
if (z === 'into') { $inv.sets.sync({ Target: 'set.assign', Ref: d.id, Value: { parent: id } }); }
else { $inv.sets.sync({ Target: 'set.move', Ref: d.id, Value: { anchor: id, where: z } }); }
});
return tr;
},
/* Drop target for "pull out of its set" (top level of the block). */
rootZone: function (el, bid, byId) {
let ok = () => { let d = $inv.sets.drag; return !!d && d.bid === bid && !!byId[d.id] && $inv.sets.parentOf(byId[d.id], byId) !== ''; };
el.on('dragover', (ev) => { if (ok()) { ev.preventDefault(); el.aC('dropok'); } })
.on('dragleave', () => { el.rC('dropok'); })
.on('drop', (ev) => {
ev.preventDefault(); el.rC('dropok');
let d = $inv.sets.drag; $inv.sets.drag = null;
if (d && d.bid === bid && byId[d.id] && $inv.sets.parentOf(byId[d.id], byId) !== '') {
$inv.sets.sync({ Target: 'set.assign', Ref: d.id, Value: { parent: '' } });
}
});
return el;
},
/* "New line" bar per block: type (Set/Text), target set (top level or any set), title. */
createBar: function (blk, bid, itms, byId) {
let bar = $$.dc('screate', blk);
let typ = $('<select></select>').attr({ name: 'typ' }).append([$$.opt('set', $ict.settyp.set), $$.opt('text', $ict.settyp.text)]);
let par = $('<select></select>').attr({ name: 'parent' }).append($$.opt('', $ict.setsTop));
itms.forEach((co) => {
if (!$inv.sets.isSet(co)) { return; }
let lvl = parseInt(co.lvl || 0, 10) || 0;
par.append($$.opt((co.id || '').toString(), ' '.repeat(lvl * 3) + $inv.sets.title(co)));
});
let ttl = $('<input type="text" />').attr({ name: 'title', placeholder: $ict.setsTitle, maxlength: 250 });
let add = () => {
let t = (ttl.val() || '').toString().trim();
if (t === '') { ttl.focus(); return; }
$inv.sets.sync({ Target: 'set.create', Ref: bid, Value: { typ: typ.val(), title: t, parent: par.val() || '' } });
ttl.val('');
};
ttl.on('keydown', (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); add(); } });
bar.append([$$.sc('lbl', $ict.setsNew), typ, $$.sc('lbl', $ict.setsInto), par, ttl, $$.bbtn($ict.setsAdd, 'sadd').click(add)]);
return bar;
}
};
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long