Compare commits

...
3 Commits
Author SHA1 Message Date
StefanandClaude Sonnet 5 311134727c Refresh glyphicons webfont assets (gulp copy)
Playwright Tests / test (push) Canceled after 0s
Picks up the currently installed glyphicons font from node_modules,
which now includes the arrow-up/arrow-down/triangle-right/
triangle-bottom glyphs used by the set-structure dialog's reorder and
fold controls (ADR 0016) — the previously copied font predates those
icons. Also adds an empty scratch file the user had open in
Fuchs/tmp/.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 09:09:54 +02:00
StefanandClaude Sonnet 5 d16d168eee Add hierarchical invoice position numbering (ADR 0017)
Adds the InvoiceLineNumbering class that InvoiceDraftCalculator.
RecomputePositions, InvoiceSetPricing.AssignPositionLabels and their
callers already depend on (this dependency was pushed in ecc74cf
without the class itself, breaking the build) — position labels are
now hierarchical ("1", "1.1", "1.2", …) matching a line's ADR 0016
nesting level, computed by one pure, shared algorithm for both the
online editor and the PDF so they can never disagree (ADR 0008).

Also fixes an InvoiceDraftServiceTests assertion that still expected
the old flat integer position field; it is a string label now.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 09:09:43 +02:00
StefanandClaude Sonnet 5 94aa7cbfe4 Add Cc (Zusatzempfänger) recipient support for invoice emails
Lets an invoice carry an additional Cc recipient alongside the primary
SendToEmail, sent via the ProcessWeb mailer API. Honors the
Fuchs:Email:OverrideRecipient dev safety net: when the override is
active, Cc is dropped entirely (only the single override inbox is
ever reached), never leaked to it.

- IComService.SendEmailAsync / ProcessWebComService: new optional
  `cc` parameter, included in the mailer payload only when non-empty.
- FdsInvoiceData.RawInvoiceEmailCc + BuildInvoiceParams: reads
  `new.invoiceemailcc`, posts @SendToEmailCc.
- IntranetController.Requests: reads SendToEmailCc from the loaded
  invoice row and passes it through on send/resend.
- Fuchs_Database: SendToEmailCc column on fds__invoices, threaded
  through fds__createInvoice/fds__setInvoice/fds__getInvoice and the
  fds__tt__invoice_base/_core table types.
- fis.req_txt_de.js: "Zusatzempfänger (CC)" field label.
- Tests: ProcessWebComServiceTests (Cc included/omitted/dropped under
  override); SystemStatusServiceTests mocks updated for the new
  IComService.SendEmailAsync parameter.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 09:09:29 +02:00
23 changed files with 248 additions and 19 deletions
+1 -1
View File
@@ -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]
+71
View File
@@ -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));
}
}
+40
View File
@@ -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
+6 -6
View File
@@ -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.
+2 -1
View File
@@ -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.
+2 -2
View File
@@ -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)
+8 -5
View File
@@ -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)
+3
View File
@@ -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),
+28
View File
@@ -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 = {
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -14,7 +14,8 @@ CREATE PROCEDURE [dbo].[fds__createInvoice]
@CustomerId bigint, @CustomerId bigint,
@SendToAddress nvarchar(1000), @SendToAddress nvarchar(1000),
@SendToEmail nvarchar(255), @SendToEmail nvarchar(255),
@ProvisionPeriod varchar(50), @SendToEmailCc nvarchar(255) = NULL,
@ProvisionPeriod varchar(50),
@CustomValues nvarchar(max), @CustomValues nvarchar(max),
@authuser varchar(25), @authuser varchar(25),
@Id varchar(10) OUT, @Id varchar(10) OUT,
@@ -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,7 +15,8 @@ CREATE PROCEDURE [dbo].[fds__setInvoice]
@CustomerId bigint, @CustomerId bigint,
@SendToAddress nvarchar(1000), @SendToAddress nvarchar(1000),
@SendToEmail nvarchar(255), @SendToEmail nvarchar(255),
@ProvisionPeriod varchar(50), @SendToEmailCc nvarchar(255) = NULL,
@ProvisionPeriod varchar(50),
@CustomValues nvarchar(max), @CustomValues nvarchar(max),
@authuser varchar(25), @authuser varchar(25),
@Id varchar(10) OUT, @Id varchar(10) OUT,
@@ -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,