Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
311134727c | ||
|
|
d16d168eee | ||
|
|
94aa7cbfe4 |
@@ -1186,7 +1186,7 @@ public class InvoiceDraftServiceTests
|
|||||||
Assert.Equal("B", (string)LineOf(s2!, "x")["SetItmId"]!);
|
Assert.Equal("B", (string)LineOf(s2!, "x")["SetItmId"]!);
|
||||||
Assert.Equal("", s2!.History[0].OldValue);
|
Assert.Equal("", s2!.History[0].OldValue);
|
||||||
Assert.StartsWith("before:m1", s2.History[0].NewValue);
|
Assert.StartsWith("before:m1", s2.History[0].NewValue);
|
||||||
Assert.Equal(3, LineOf(s2, "x")["p"]!.Value<int>());
|
Assert.Equal("1.1.1", (string)LineOf(s2, "x")["p"]!); // hierarchical label (ADR 0017): lvl2 under B (1.1)
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using Fuchs.intranet;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Fuchs.Tests;
|
||||||
|
|
||||||
|
/// <summary>Pure unit tests for the hierarchical position-numbering algorithm (ADR 0017).</summary>
|
||||||
|
public class InvoiceLineNumberingTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Compute_EmptyInput_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
Assert.Empty(InvoiceLineNumbering.Compute(new List<(bool, int)>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Compute_FlatSequence_NumbersSequentially()
|
||||||
|
{
|
||||||
|
var input = new List<(bool Numbered, int Level)> { (true, 0), (true, 0), (true, 0) };
|
||||||
|
Assert.Equal(new[] { "1", "2", "3" }, InvoiceLineNumbering.Compute(input));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Compute_AllUnnumbered_ReturnsAllBlank()
|
||||||
|
{
|
||||||
|
var input = new List<(bool Numbered, int Level)> { (false, 0), (false, 0) };
|
||||||
|
Assert.Equal(new[] { "", "" }, InvoiceLineNumbering.Compute(input));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Compute_OneLevelOfNesting_ProducesSubsidiaryNumbers()
|
||||||
|
{
|
||||||
|
// header, member, member, top-level item
|
||||||
|
var input = new List<(bool Numbered, int Level)> { (true, 0), (true, 1), (true, 1), (true, 0) };
|
||||||
|
Assert.Equal(new[] { "1", "1.1", "1.2", "2" }, InvoiceLineNumbering.Compute(input));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Compute_TwoLevelsOfNesting_ProducesDeepLabels()
|
||||||
|
{
|
||||||
|
// A(0), B(1, nested set), m1(2), m2(2), m3(1), x(0)
|
||||||
|
var input = new List<(bool Numbered, int Level)>
|
||||||
|
{
|
||||||
|
(true, 0), (true, 1), (true, 2), (true, 2), (true, 1), (true, 0)
|
||||||
|
};
|
||||||
|
Assert.Equal(new[] { "1", "1.1", "1.1.1", "1.1.2", "1.2", "2" }, InvoiceLineNumbering.Compute(input));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Compute_TextLinesInterleaved_GetNoLabelAndDoNotConsumeACounterSlot()
|
||||||
|
{
|
||||||
|
var input = new List<(bool Numbered, int Level)> { (true, 0), (false, 0), (true, 1), (false, 1), (true, 0) };
|
||||||
|
Assert.Equal(new[] { "1", "", "1.1", "", "2" }, InvoiceLineNumbering.Compute(input));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Compute_LevelJumpWithoutIntermediateLevelPresent_StillProducesAStableLabel()
|
||||||
|
{
|
||||||
|
// A level-2 line appears with no level-1 ancestor ever seen (defensive — shouldn't happen
|
||||||
|
// given InvoiceSetStructure's guarantees, but the algorithm must not throw or misbehave).
|
||||||
|
var input = new List<(bool Numbered, int Level)> { (true, 0), (true, 2) };
|
||||||
|
Assert.Equal(new[] { "1", "1.0.1" }, InvoiceLineNumbering.Compute(input));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Compute_ReturningToAShallowerLevel_TruncatesDeeperCounters()
|
||||||
|
{
|
||||||
|
var input = new List<(bool Numbered, int Level)> { (true, 0), (true, 1), (true, 2), (true, 1) };
|
||||||
|
Assert.Equal(new[] { "1", "1.1", "1.1.1", "1.2" }, InvoiceLineNumbering.Compute(input));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -322,6 +322,46 @@ public class ProcessWebComServiceTests
|
|||||||
Assert.Equal("realcustomer@example.de", json["communication"]!["to"]!.ToString());
|
Assert.Equal("realcustomer@example.de", json["communication"]!["to"]!.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Cc recipient ("Zusatzempfänger") ─────────────────────────────────────────
|
||||||
|
[Fact]
|
||||||
|
public async Task SendEmailAsync_CcProvided_IsIncludedInPayload()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(HttpStatusCode.OK);
|
||||||
|
var svc = CreateService(handler);
|
||||||
|
|
||||||
|
await svc.SendEmailAsync("inv_cc1", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde",
|
||||||
|
cc: "zusatz@example.de");
|
||||||
|
|
||||||
|
var json = JObject.Parse(handler.LastRequestBody!);
|
||||||
|
Assert.Equal("zusatz@example.de", json["communication"]!["cc"]!.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendEmailAsync_CcOmitted_IsNullInPayload()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(HttpStatusCode.OK);
|
||||||
|
var svc = CreateService(handler);
|
||||||
|
|
||||||
|
await svc.SendEmailAsync("inv_cc2", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde");
|
||||||
|
|
||||||
|
var json = JObject.Parse(handler.LastRequestBody!);
|
||||||
|
Assert.Equal(JTokenType.Null, json["communication"]!["cc"]!.Type);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendEmailAsync_OverrideRecipientSet_DropsCcEntirely()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(HttpStatusCode.OK);
|
||||||
|
var svc = CreateService(handler, overrideRecipient: "dev-inbox@example.test");
|
||||||
|
|
||||||
|
await svc.SendEmailAsync("inv_cc3", "Subject", "<p>hi</p>", "realcustomer@example.de", "Kunde",
|
||||||
|
cc: "zusatz@example.de");
|
||||||
|
|
||||||
|
var json = JObject.Parse(handler.LastRequestBody!);
|
||||||
|
Assert.Equal("dev-inbox@example.test", json["communication"]!["to"]!.ToString());
|
||||||
|
Assert.Equal(JTokenType.Null, json["communication"]!["cc"]!.Type);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Override enforcement across every real appsettings*.json environment ───
|
// ── Override enforcement across every real appsettings*.json environment ───
|
||||||
// Loads the ACTUAL Fuchs/appsettings*.json files the same way ASP.NET Core
|
// Loads the ACTUAL Fuchs/appsettings*.json files the same way ASP.NET Core
|
||||||
// layers them (base file + optional environment-specific overlay). These files
|
// layers them (base file + optional environment-specific overlay). These files
|
||||||
|
|||||||
@@ -404,7 +404,7 @@ public class SystemStatusServiceTests
|
|||||||
|
|
||||||
Assert.False(r.Sent);
|
Assert.False(r.Sent);
|
||||||
com.Verify(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
com.Verify(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>>()), Times.Never);
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>>(), It.IsAny<string?>()), Times.Never);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -413,9 +413,9 @@ public class SystemStatusServiceTests
|
|||||||
string? capturedHtml = null, capturedTo = null, capturedSubject = null;
|
string? capturedHtml = null, capturedTo = null, capturedSubject = null;
|
||||||
var com = new Mock<IComService>();
|
var com = new Mock<IComService>();
|
||||||
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>()))
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>(), It.IsAny<string?>()))
|
||||||
.Callback<string, string, string, string, string, Dictionary<string, byte[]>?>(
|
.Callback<string, string, string, string, string, Dictionary<string, byte[]>?, string?>(
|
||||||
(_, subj, html, to, _, _) => { capturedSubject = subj; capturedHtml = html; capturedTo = to; })
|
(_, subj, html, to, _, _, _) => { capturedSubject = subj; capturedHtml = html; capturedTo = to; })
|
||||||
.ReturnsAsync(true);
|
.ReturnsAsync(true);
|
||||||
var svc = Build(com: com);
|
var svc = Build(com: com);
|
||||||
|
|
||||||
@@ -436,7 +436,7 @@ public class SystemStatusServiceTests
|
|||||||
{
|
{
|
||||||
var com = new Mock<IComService>();
|
var com = new Mock<IComService>();
|
||||||
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>()))
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>(), It.IsAny<string?>()))
|
||||||
.ReturnsAsync(true);
|
.ReturnsAsync(true);
|
||||||
var svc = Build(com: com, email: new FuchsEmailSettings { OverrideRecipient = "safety@example.test" });
|
var svc = Build(com: com, email: new FuchsEmailSettings { OverrideRecipient = "safety@example.test" });
|
||||||
|
|
||||||
@@ -453,7 +453,7 @@ public class SystemStatusServiceTests
|
|||||||
{
|
{
|
||||||
var com = new Mock<IComService>();
|
var com = new Mock<IComService>();
|
||||||
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>()))
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>(), It.IsAny<string?>()))
|
||||||
.ReturnsAsync(false);
|
.ReturnsAsync(false);
|
||||||
var svc = Build(com: com);
|
var svc = Build(com: com);
|
||||||
|
|
||||||
|
|||||||
@@ -324,6 +324,7 @@ public partial class IntranetController
|
|||||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||||
frdic = dtset.Table("inv").FirstRow.toObjectDictionary();
|
frdic = dtset.Table("inv").FirstRow.toObjectDictionary();
|
||||||
string email = frdic.nz("SendToEmail", "");
|
string email = frdic.nz("SendToEmail", "");
|
||||||
|
string emailCc = frdic.nz("SendToEmailCc", "");
|
||||||
if (!string.IsNullOrEmpty(email) && filebyte.Length > 0)
|
if (!string.IsNullOrEmpty(email) && filebyte.Length > 0)
|
||||||
{
|
{
|
||||||
var inv = new Dictionary<string, byte[]> { [frdic.nz("DocumentName")] = filebyte };
|
var inv = new Dictionary<string, byte[]> { [frdic.nz("DocumentName")] = filebyte };
|
||||||
@@ -334,7 +335,7 @@ public partial class IntranetController
|
|||||||
invId, email.Trim(), UserAccountID);
|
invId, email.Trim(), UserAccountID);
|
||||||
bool sent = await _comService.SendEmailAsync(
|
bool sent = await _comService.SendEmailAsync(
|
||||||
$"inv_{invId}", $"Sanit\u00e4rFuchs - {frdic.nz("DocumentName")}",
|
$"inv_{invId}", $"Sanit\u00e4rFuchs - {frdic.nz("DocumentName")}",
|
||||||
body, email.Trim(), "", inv);
|
body, email.Trim(), "", inv, string.IsNullOrEmpty(emailCc) ? null : emailCc.Trim());
|
||||||
if (sent)
|
if (sent)
|
||||||
{
|
{
|
||||||
await _events.InvoiceSentToCustomerAsync(fdInv, email.Trim(), UserAccountID);
|
await _events.InvoiceSentToCustomerAsync(fdInv, email.Trim(), UserAccountID);
|
||||||
@@ -425,6 +426,7 @@ public partial class IntranetController
|
|||||||
var fdInv = await _invoices.LoadInvoiceAsync(invId, UserAccountID, DbSec);
|
var fdInv = await _invoices.LoadInvoiceAsync(invId, UserAccountID, DbSec);
|
||||||
byte[] filebyte = await _invoices.RenderInvoicePdfBytesAsync(fdInv, fdInv.IsDraft);
|
byte[] filebyte = await _invoices.RenderInvoicePdfBytesAsync(fdInv, fdInv.IsDraft);
|
||||||
string email = frdic.nz("SendToEmail", "");
|
string email = frdic.nz("SendToEmail", "");
|
||||||
|
string emailCc = frdic.nz("SendToEmailCc", "");
|
||||||
if (!string.IsNullOrEmpty(email) && filebyte.Length > 0)
|
if (!string.IsNullOrEmpty(email) && filebyte.Length > 0)
|
||||||
{
|
{
|
||||||
double bal = Convert.ToDouble(frdic.no("InvoiceBalance", 0));
|
double bal = Convert.ToDouble(frdic.no("InvoiceBalance", 0));
|
||||||
@@ -432,7 +434,8 @@ public partial class IntranetController
|
|||||||
bool sent = await _comService.SendEmailAsync(
|
bool sent = await _comService.SendEmailAsync(
|
||||||
$"inv_{invId}", $"Sanit\u00e4rFuchs - {frdic.nz("DocumentName")}",
|
$"inv_{invId}", $"Sanit\u00e4rFuchs - {frdic.nz("DocumentName")}",
|
||||||
BuildInvoiceBody(bal, terms), email.Trim(), "",
|
BuildInvoiceBody(bal, terms), email.Trim(), "",
|
||||||
new Dictionary<string, byte[]> { [frdic.nz("DocumentName")] = filebyte });
|
new Dictionary<string, byte[]> { [frdic.nz("DocumentName")] = filebyte },
|
||||||
|
string.IsNullOrEmpty(emailCc) ? null : emailCc.Trim());
|
||||||
if (sent)
|
if (sent)
|
||||||
await _events.InvoiceSentToCustomerAsync(fdInv, email.Trim(), UserAccountID, resent: true);
|
await _events.InvoiceSentToCustomerAsync(fdInv, email.Trim(), UserAccountID, resent: true);
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
---
|
||||||
|
status: Accepted
|
||||||
|
date: 2026-09-10
|
||||||
|
applyTo:
|
||||||
|
- "Fuchs/code/InvoiceLineNumbering.cs"
|
||||||
|
- "Fuchs/code/InvoiceDraftCalculator.cs"
|
||||||
|
- "Fuchs/code/InvoiceSetPricing.cs"
|
||||||
|
- "Fuchs/code/FuchsPdf.cs"
|
||||||
|
- "Fuchs/js/intranet/modules/fis.inv_shared.js"
|
||||||
|
supersededBy: ""
|
||||||
|
---
|
||||||
|
|
||||||
|
# 0017 — Invoice position numbers are hierarchical labels ("1", "1.1", "1.2", …), computed by one shared algorithm
|
||||||
|
|
||||||
|
## Context
|
||||||
|
ADR [0016](0016-user-defined-set-structure.md) made set nesting explicit and gave every line a
|
||||||
|
depth (`lvl`/`level`). With a set able to contain a set able to contain plain items, a flat,
|
||||||
|
sequential position number ("1", "2", "3", …) across the whole invoice no longer communicates the
|
||||||
|
structure a set-price customer sees: nothing in the printed `Pos.` column distinguished a set's
|
||||||
|
own line from its members, nor showed how deep a member sat.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
1. **Position labels are hierarchical**, matching the line's nesting level: a top-level line gets
|
||||||
|
"1", "2", …; a member of set "3" gets "3.1", "3.2", …; a member nested two sets deep gets
|
||||||
|
"3.1.1", "3.1.2", … Text/free-text lines are unnumbered (`""`) and consume no slot at any level,
|
||||||
|
exactly as before.
|
||||||
|
2. **One pure algorithm, two call sites.** `InvoiceLineNumbering.Compute` takes an ordered
|
||||||
|
sequence of `(Numbered, Level)` pairs and returns the label per line — it knows nothing about
|
||||||
|
invoices, sets or JSON. It is called from:
|
||||||
|
- `InvoiceDraftCalculator.RecomputePositions` (the online editor / cached draft session) —
|
||||||
|
labels are written onto each line's `p` field, which is now a **string**, not an int.
|
||||||
|
- `InvoiceSetPricing.AssignPositionLabels` (the PDF) — called once across **every** block's
|
||||||
|
`Build` output (`InvoiceSetLine.PositionLabel`), so numbering spans the whole document the
|
||||||
|
same way the editor's `RecomputePositions` does across the whole session, not per block.
|
||||||
|
3. **Levels come from the ADR 0016 structure**, not re-derived: the editor reads each line's
|
||||||
|
`lvl` (written by `InvoiceSetStructure`), the PDF reads each line's `Level` (written by
|
||||||
|
`InvoiceSetPricing.Build`). Both are already order-preserving/depth-first, so a straight
|
||||||
|
left-to-right pass produces correct labels without knowing about sets at all.
|
||||||
|
4. **The algorithm is defensive**, not invoice-specific: a level jump with no shallower ancestor
|
||||||
|
ever seen still produces a stable label (padding with "0" — e.g. `1.0.1`) instead of throwing,
|
||||||
|
and returning to a shallower level truncates the deeper counters. This keeps it a pure,
|
||||||
|
independently testable utility (`InvoiceLineNumberingTests`) with no invoice-shaped
|
||||||
|
preconditions to violate.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- Everywhere a line's position was read as a number (persistence params, sort keys, display),
|
||||||
|
it must be treated as an opaque string label instead. The editor (`fis.inv_shared.js`) renders
|
||||||
|
`co.p`/`ix.position` as text; nothing sorts or does arithmetic on it.
|
||||||
|
- The PDF and the online editor compute labels via the **same underlying algorithm** but two
|
||||||
|
separate call sites (one owns the whole draft session, the other owns the whole rendered
|
||||||
|
document) — keeping them in one algorithm is what guarantees they can never drift apart
|
||||||
|
(ADR 0008).
|
||||||
|
- `InvoiceSetPricing.Build` no longer assigns its own line numbers per block; a caller that wants
|
||||||
|
correct labels must call `AssignPositionLabels` across the full ordered set of blocks.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
- **Keep flat sequential numbers, show nesting only via indentation**: rejected — indentation
|
||||||
|
alone does not survive being read out loud or referenced ("Position 3.1") the way a printed
|
||||||
|
invoice is used in practice, and does not withstand copy/paste into an email or a phone call.
|
||||||
|
- **Number only top-level lines, leave members entirely unnumbered**: rejected — a "Set mit
|
||||||
|
Preis"-adjacent workflow (ADR 0009) still wants every priced member individually referenceable
|
||||||
|
before/after a set-price conversion.
|
||||||
|
- **Re-derive nesting from `SetItmId` at numbering time instead of reading the already-published
|
||||||
|
`lvl`/`Level`**: rejected — `InvoiceSetStructure`/`InvoiceSetPricing.Build` already guarantee a
|
||||||
|
depth-first, level-tagged order; recomputing it a second time would duplicate that logic and
|
||||||
|
risk disagreeing with it.
|
||||||
@@ -15,9 +15,10 @@ public interface IComService
|
|||||||
/// <param name="email">Recipient email address.</param>
|
/// <param name="email">Recipient email address.</param>
|
||||||
/// <param name="name">Recipient display name.</param>
|
/// <param name="name">Recipient display name.</param>
|
||||||
/// <param name="attachments">Optional file attachments (filename → content).</param>
|
/// <param name="attachments">Optional file attachments (filename → content).</param>
|
||||||
|
/// <param name="cc">Optional Cc recipient (e.g. a customer's "Zusatzempfänger").</param>
|
||||||
/// <returns><c>true</c> when the email was accepted successfully.</returns>
|
/// <returns><c>true</c> when the email was accepted successfully.</returns>
|
||||||
Task<bool> SendEmailAsync(string reference, string subject, string html,
|
Task<bool> SendEmailAsync(string reference, string subject, string html,
|
||||||
string email, string name, Dictionary<string, byte[]>? attachments = null);
|
string email, string name, Dictionary<string, byte[]>? attachments = null, string? cc = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sends an SMS message.
|
/// Sends an SMS message.
|
||||||
|
|||||||
@@ -135,13 +135,13 @@ public class InvoiceService : IInvoiceService
|
|||||||
var sqlParts = new List<string> { "DECLARE @Id varchar(10);" };
|
var sqlParts = new List<string> { "DECLARE @Id varchar(10);" };
|
||||||
if (!change)
|
if (!change)
|
||||||
{
|
{
|
||||||
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice] @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT, @SendToAddressJson;");
|
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice] @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @SendToEmailCc, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT, @SendToAddressJson;");
|
||||||
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice_Details] @Id, @InvoiceService_net, @InvoiceService_VAT, @InvoiceOptions, @authuser;");
|
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice_Details] @Id, @InvoiceService_net, @InvoiceService_VAT, @InvoiceOptions, @authuser;");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
pl.Add(SQL_VarChar("@InvId", invId));
|
pl.Add(SQL_VarChar("@InvId", invId));
|
||||||
sqlParts.Add("EXECUTE [dbo].[fds__setInvoice] @InvId, @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT, @SendToAddressJson;");
|
sqlParts.Add("EXECUTE [dbo].[fds__setInvoice] @InvId, @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @SendToEmailCc, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT, @SendToAddressJson;");
|
||||||
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice_Details] @Id, @InvoiceService_net, @InvoiceService_VAT, @InvoiceOptions, @authuser;");
|
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice_Details] @Id, @InvoiceService_net, @InvoiceService_VAT, @InvoiceOptions, @authuser;");
|
||||||
}
|
}
|
||||||
if (invoice.RawProvisionLocation.Length > 0)
|
if (invoice.RawProvisionLocation.Length > 0)
|
||||||
|
|||||||
@@ -46,10 +46,11 @@ public class ProcessWebComService : IComService
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> SendEmailAsync(string reference, string subject, string html,
|
public async Task<bool> SendEmailAsync(string reference, string subject, string html,
|
||||||
string email, string name, Dictionary<string, byte[]>? attachments = null)
|
string email, string name, Dictionary<string, byte[]>? attachments = null, string? cc = null)
|
||||||
{
|
{
|
||||||
using var act = FuchsTelemetry.StartActivity("email.send");
|
using var act = FuchsTelemetry.StartActivity("email.send");
|
||||||
act?.SetTag("fuchs.email.ref", reference);
|
act?.SetTag("fuchs.email.ref", reference);
|
||||||
|
cc = string.IsNullOrWhiteSpace(cc) ? null : cc.Trim();
|
||||||
|
|
||||||
string overrideRecipient = _emailSettings.OverrideRecipient ?? "";
|
string overrideRecipient = _emailSettings.OverrideRecipient ?? "";
|
||||||
if (!string.IsNullOrWhiteSpace(overrideRecipient))
|
if (!string.IsNullOrWhiteSpace(overrideRecipient))
|
||||||
@@ -58,12 +59,13 @@ public class ProcessWebComService : IComService
|
|||||||
// redirect every outbound email to a single controlled inbox, so a locally
|
// redirect every outbound email to a single controlled inbox, so a locally
|
||||||
// enabled mailer can never reach a real tenant-owner or end-customer.
|
// enabled mailer can never reach a real tenant-owner or end-customer.
|
||||||
_logger.LogWarning(
|
_logger.LogWarning(
|
||||||
"SendEmailAsync: recipient override active for ref {Reference} – redirecting from '{OriginalEmail}' to '{OverrideRecipient}'",
|
"SendEmailAsync: recipient override active for ref {Reference} – redirecting from '{OriginalEmail}' (cc='{OriginalCc}') to '{OverrideRecipient}'",
|
||||||
reference, email, overrideRecipient);
|
reference, email, cc ?? "", overrideRecipient);
|
||||||
act?.SetTag("fuchs.email.overridden", true);
|
act?.SetTag("fuchs.email.overridden", true);
|
||||||
act?.SetTag("fuchs.email.original_recipient", email);
|
act?.SetTag("fuchs.email.original_recipient", email);
|
||||||
subject = $"[DEV \u2192 {email}] {subject}";
|
subject = $"[DEV \u2192 {email}] {subject}";
|
||||||
email = overrideRecipient;
|
email = overrideRecipient;
|
||||||
|
cc = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!IsValidEmail(email))
|
if (!IsValidEmail(email))
|
||||||
@@ -111,13 +113,14 @@ public class ProcessWebComService : IComService
|
|||||||
var communication = new
|
var communication = new
|
||||||
{
|
{
|
||||||
to = email,
|
to = email,
|
||||||
|
cc,
|
||||||
subject,
|
subject,
|
||||||
body,
|
body,
|
||||||
attachments = attachmentPayload
|
attachments = attachmentPayload
|
||||||
};
|
};
|
||||||
|
|
||||||
_logger.LogDebug("SendEmailAsync ref={Reference} to={Email} attachments={Count}",
|
_logger.LogDebug("SendEmailAsync ref={Reference} to={Email} cc={Cc} attachments={Count}",
|
||||||
reference, email, attachmentPayload.Length);
|
reference, email, cc ?? "", attachmentPayload.Length);
|
||||||
|
|
||||||
var (ok, status, responseBody) = await PostToApiAsync(reference, "e", communication);
|
var (ok, status, responseBody) = await PostToApiAsync(reference, "e", communication);
|
||||||
if (ok)
|
if (ok)
|
||||||
|
|||||||
@@ -133,6 +133,8 @@ public class FdsInvoiceData
|
|||||||
// -- Raw form properties (used by parameter mapping) -----------------------
|
// -- Raw form properties (used by parameter mapping) -----------------------
|
||||||
internal string RawInvoiceAddress => NewValues?.nz("invoiceaddress") ?? "";
|
internal string RawInvoiceAddress => NewValues?.nz("invoiceaddress") ?? "";
|
||||||
internal string RawInvoiceEmail => NewValues?.nz("invoiceemail").Trim() ?? "";
|
internal string RawInvoiceEmail => NewValues?.nz("invoiceemail").Trim() ?? "";
|
||||||
|
/// <summary>Additional recipient ("Zusatzempfänger") sent as Cc alongside the primary invoice email.</summary>
|
||||||
|
internal string RawInvoiceEmailCc => NewValues?.nz("invoiceemailcc").Trim() ?? "";
|
||||||
internal string RawCustomValues => NewValues?.nz("CustomValues").Trim() ?? "";
|
internal string RawCustomValues => NewValues?.nz("CustomValues").Trim() ?? "";
|
||||||
internal string RawProvisionPeriod => NewValues?.nz("provisionperiod") ?? "";
|
internal string RawProvisionPeriod => NewValues?.nz("provisionperiod") ?? "";
|
||||||
internal string[] RawProvisionLocation =>
|
internal string[] RawProvisionLocation =>
|
||||||
@@ -179,6 +181,7 @@ public class FdsInvoiceData
|
|||||||
SQL_BigInt("@CustomerId", Admin?.nz("customerid") ?? ""),
|
SQL_BigInt("@CustomerId", Admin?.nz("customerid") ?? ""),
|
||||||
SQL_VarChar("@SendToAddress", RawInvoiceAddress),
|
SQL_VarChar("@SendToAddress", RawInvoiceAddress),
|
||||||
SQL_NVarChar("@SendToEmail", RawInvoiceEmail),
|
SQL_NVarChar("@SendToEmail", RawInvoiceEmail),
|
||||||
|
SQL_NVarChar("@SendToEmailCc", RawInvoiceEmailCc, dbNull_IfEmpty: true),
|
||||||
SQL_NVarChar("@ProvisionPeriod", RawProvisionPeriod, dbNull_IfEmpty: true),
|
SQL_NVarChar("@ProvisionPeriod", RawProvisionPeriod, dbNull_IfEmpty: true),
|
||||||
SQL_NVarChar("@CustomValues", RawCustomValues, dbNull_IfEmpty: true),
|
SQL_NVarChar("@CustomValues", RawCustomValues, dbNull_IfEmpty: true),
|
||||||
SQL_NVarChar("@SendToAddressJson", SendToAddressJsonForPersist(), dbNull_IfEmpty: true),
|
SQL_NVarChar("@SendToAddressJson", SendToAddressJsonForPersist(), dbNull_IfEmpty: true),
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
namespace Fuchs.intranet;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Computes hierarchical invoice position labels ("1", "1.1", "1.2", "2", …) from a
|
||||||
|
/// depth-first-ordered sequence of lines (ADR 0017). A set header gets a number at its own
|
||||||
|
/// level; its members get subsidiary numbers one level deeper. Text/Title lines get no label
|
||||||
|
/// and do not consume a counter slot at any level. Callers must supply lines already in final
|
||||||
|
/// document order — <see cref="InvoiceSetStructure.Linearize"/> and
|
||||||
|
/// <see cref="InvoiceSetPricing.Build"/> both already guarantee that.
|
||||||
|
/// </summary>
|
||||||
|
public static class InvoiceLineNumbering
|
||||||
|
{
|
||||||
|
public static IReadOnlyList<string> Compute(IReadOnlyList<(bool Numbered, int Level)> lines)
|
||||||
|
{
|
||||||
|
var result = new List<string>(lines.Count);
|
||||||
|
var counters = new List<int>();
|
||||||
|
foreach (var (numbered, level) in lines)
|
||||||
|
{
|
||||||
|
if (!numbered) { result.Add(""); continue; }
|
||||||
|
int lvl = Math.Max(level, 0);
|
||||||
|
while (counters.Count <= lvl) counters.Add(0);
|
||||||
|
if (counters.Count > lvl + 1) counters.RemoveRange(lvl + 1, counters.Count - lvl - 1);
|
||||||
|
counters[lvl]++;
|
||||||
|
result.Add(string.Join(".", counters));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,6 +65,7 @@
|
|||||||
invoiceaddress: 'Adresse'
|
invoiceaddress: 'Adresse'
|
||||||
, loc: 'Leistungsort / Lieferadresse'
|
, loc: 'Leistungsort / Lieferadresse'
|
||||||
, invoiceemail: 'Email'
|
, invoiceemail: 'Email'
|
||||||
|
, invoiceemailcc: 'Zusatzempfänger (CC)'
|
||||||
|
|
||||||
}
|
}
|
||||||
}, $rcol = {
|
}, $rcol = {
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -14,6 +14,7 @@ CREATE PROCEDURE [dbo].[fds__createInvoice]
|
|||||||
@CustomerId bigint,
|
@CustomerId bigint,
|
||||||
@SendToAddress nvarchar(1000),
|
@SendToAddress nvarchar(1000),
|
||||||
@SendToEmail nvarchar(255),
|
@SendToEmail nvarchar(255),
|
||||||
|
@SendToEmailCc nvarchar(255) = NULL,
|
||||||
@ProvisionPeriod varchar(50),
|
@ProvisionPeriod varchar(50),
|
||||||
@CustomValues nvarchar(max),
|
@CustomValues nvarchar(max),
|
||||||
@authuser varchar(25),
|
@authuser varchar(25),
|
||||||
@@ -56,6 +57,7 @@ BEGIN
|
|||||||
[CustomerId] [bigint] NULL,
|
[CustomerId] [bigint] NULL,
|
||||||
[SendToAddress] [nvarchar](1000) NULL,
|
[SendToAddress] [nvarchar](1000) NULL,
|
||||||
[SendToEmail] [nvarchar](255) NULL,
|
[SendToEmail] [nvarchar](255) NULL,
|
||||||
|
[SendToEmailCc] [nvarchar](255) NULL,
|
||||||
[ProvisionPeriod] [varchar](50) NULL,
|
[ProvisionPeriod] [varchar](50) NULL,
|
||||||
[ProvisionLocation] [nvarchar](1000) NULL,
|
[ProvisionLocation] [nvarchar](1000) NULL,
|
||||||
[PaymentStatus] [char](1) NULL,
|
[PaymentStatus] [char](1) NULL,
|
||||||
@@ -100,6 +102,7 @@ BEGIN
|
|||||||
,[CustomerId]
|
,[CustomerId]
|
||||||
,[SendToAddress]
|
,[SendToAddress]
|
||||||
,[SendToEmail]
|
,[SendToEmail]
|
||||||
|
,[SendToEmailCc]
|
||||||
,[ProvisionPeriod]
|
,[ProvisionPeriod]
|
||||||
,[ProvisionLocation]
|
,[ProvisionLocation]
|
||||||
,[PaymentStatus]
|
,[PaymentStatus]
|
||||||
@@ -140,6 +143,7 @@ BEGIN
|
|||||||
,@CustomerId
|
,@CustomerId
|
||||||
,@SendToAddress
|
,@SendToAddress
|
||||||
,@SendToEmail
|
,@SendToEmail
|
||||||
|
,@SendToEmailCc
|
||||||
,@ProvisionPeriod
|
,@ProvisionPeriod
|
||||||
,NULL --[ProvisionLocation]
|
,NULL --[ProvisionLocation]
|
||||||
,NULL --[PaymentStatus]
|
,NULL --[PaymentStatus]
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ BEGIN
|
|||||||
,[CustomerId]
|
,[CustomerId]
|
||||||
,[SendToAddress]
|
,[SendToAddress]
|
||||||
,[SendToEmail]
|
,[SendToEmail]
|
||||||
|
,[SendToEmailCc]
|
||||||
,[ProvisionPeriod]
|
,[ProvisionPeriod]
|
||||||
,[ProvisionLocation]
|
,[ProvisionLocation]
|
||||||
,[PaymentStatus]
|
,[PaymentStatus]
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ CREATE PROCEDURE [dbo].[fds__setInvoice]
|
|||||||
@CustomerId bigint,
|
@CustomerId bigint,
|
||||||
@SendToAddress nvarchar(1000),
|
@SendToAddress nvarchar(1000),
|
||||||
@SendToEmail nvarchar(255),
|
@SendToEmail nvarchar(255),
|
||||||
|
@SendToEmailCc nvarchar(255) = NULL,
|
||||||
@ProvisionPeriod varchar(50),
|
@ProvisionPeriod varchar(50),
|
||||||
@CustomValues nvarchar(max),
|
@CustomValues nvarchar(max),
|
||||||
@authuser varchar(25),
|
@authuser varchar(25),
|
||||||
@@ -57,6 +58,7 @@ BEGIN
|
|||||||
[CustomerId] [bigint] NULL,
|
[CustomerId] [bigint] NULL,
|
||||||
[SendToAddress] [nvarchar](1000) NULL,
|
[SendToAddress] [nvarchar](1000) NULL,
|
||||||
[SendToEmail] [nvarchar](255) NULL,
|
[SendToEmail] [nvarchar](255) NULL,
|
||||||
|
[SendToEmailCc] [nvarchar](255) NULL,
|
||||||
[ProvisionPeriod] [varchar](50) NULL,
|
[ProvisionPeriod] [varchar](50) NULL,
|
||||||
[ProvisionLocation] [nvarchar](1000) NULL,
|
[ProvisionLocation] [nvarchar](1000) NULL,
|
||||||
[PaymentStatus] [char](1) NULL,
|
[PaymentStatus] [char](1) NULL,
|
||||||
@@ -98,6 +100,7 @@ BEGIN
|
|||||||
,@CustomerId
|
,@CustomerId
|
||||||
,@SendToAddress
|
,@SendToAddress
|
||||||
,@SendToEmail
|
,@SendToEmail
|
||||||
|
,@SendToEmailCc
|
||||||
,@ProvisionPeriod
|
,@ProvisionPeriod
|
||||||
,NULL --[ProvisionLocation]
|
,NULL --[ProvisionLocation]
|
||||||
,NULL --[PaymentStatus]
|
,NULL --[PaymentStatus]
|
||||||
@@ -130,6 +133,7 @@ BEGIN
|
|||||||
,[CustomerId]
|
,[CustomerId]
|
||||||
,[SendToAddress]
|
,[SendToAddress]
|
||||||
,[SendToEmail]
|
,[SendToEmail]
|
||||||
|
,[SendToEmailCc]
|
||||||
,[ProvisionPeriod]
|
,[ProvisionPeriod]
|
||||||
,[ProvisionLocation]
|
,[ProvisionLocation]
|
||||||
,[PaymentStatus]
|
,[PaymentStatus]
|
||||||
@@ -164,6 +168,7 @@ BEGIN
|
|||||||
,[CustomerId] = SOURCE.[CustomerId]
|
,[CustomerId] = SOURCE.[CustomerId]
|
||||||
,[SendToAddress] = SOURCE.[SendToAddress]
|
,[SendToAddress] = SOURCE.[SendToAddress]
|
||||||
,[SendToEmail] = SOURCE.[SendToEmail]
|
,[SendToEmail] = SOURCE.[SendToEmail]
|
||||||
|
,[SendToEmailCc] = SOURCE.[SendToEmailCc]
|
||||||
,[ProvisionPeriod] = SOURCE.[ProvisionPeriod]
|
,[ProvisionPeriod] = SOURCE.[ProvisionPeriod]
|
||||||
,[ProvisionLocation] = SOURCE.[ProvisionLocation]
|
,[ProvisionLocation] = SOURCE.[ProvisionLocation]
|
||||||
,[CustomValues] = SOURCE.[CustomValues]
|
,[CustomValues] = SOURCE.[CustomValues]
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
[CustomerId] BIGINT NULL,
|
[CustomerId] BIGINT NULL,
|
||||||
[SendToAddress] NVARCHAR (1000) NULL,
|
[SendToAddress] NVARCHAR (1000) NULL,
|
||||||
[SendToEmail] NVARCHAR (255) NULL,
|
[SendToEmail] NVARCHAR (255) NULL,
|
||||||
|
[SendToEmailCc] NVARCHAR (255) NULL,
|
||||||
[ProvisionPeriod] VARCHAR (50) NULL,
|
[ProvisionPeriod] VARCHAR (50) NULL,
|
||||||
[ProvisionLocation] NVARCHAR (1000) NULL,
|
[ProvisionLocation] NVARCHAR (1000) NULL,
|
||||||
[PaymentStatus] CHAR (1) NULL,
|
[PaymentStatus] CHAR (1) NULL,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
[CustomerId] BIGINT NULL,
|
[CustomerId] BIGINT NULL,
|
||||||
[SendToAddress] NVARCHAR (1000) NULL,
|
[SendToAddress] NVARCHAR (1000) NULL,
|
||||||
[SendToEmail] NVARCHAR (255) NULL,
|
[SendToEmail] NVARCHAR (255) NULL,
|
||||||
|
[SendToEmailCc] NVARCHAR (255) NULL,
|
||||||
[ProvisionPeriod] VARCHAR (50) NULL,
|
[ProvisionPeriod] VARCHAR (50) NULL,
|
||||||
[ProvisionLocation] NVARCHAR (1000) NULL,
|
[ProvisionLocation] NVARCHAR (1000) NULL,
|
||||||
[PaymentStatus] CHAR (1) NULL,
|
[PaymentStatus] CHAR (1) NULL,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
[CustomerId] BIGINT NULL,
|
[CustomerId] BIGINT NULL,
|
||||||
[SendToAddress] NVARCHAR (1000) NULL,
|
[SendToAddress] NVARCHAR (1000) NULL,
|
||||||
[SendToEmail] NVARCHAR (255) NULL,
|
[SendToEmail] NVARCHAR (255) NULL,
|
||||||
|
[SendToEmailCc] NVARCHAR (255) NULL,
|
||||||
[ProvisionPeriod] VARCHAR (50) NULL,
|
[ProvisionPeriod] VARCHAR (50) NULL,
|
||||||
[ProvisionLocation] NVARCHAR (1000) NULL,
|
[ProvisionLocation] NVARCHAR (1000) NULL,
|
||||||
[PaymentStatus] CHAR (1) NULL,
|
[PaymentStatus] CHAR (1) NULL,
|
||||||
|
|||||||
Reference in New Issue
Block a user