feat: implement set-based invoice payment calculations and enhance email logging configuration
Playwright Tests / test (push) Canceled after 0s
Playwright Tests / test (push) Canceled after 0s
This commit is contained in:
+74
@@ -0,0 +1,74 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-09-16
|
||||
applyTo:
|
||||
- "Fuchs/Services/FuchsWidgetService.cs"
|
||||
- "Fuchs/Services/ProcessWebComService.cs"
|
||||
- "Fuchs_Database/dbo/Functions/fds__fn_unpaidInvoices.sql"
|
||||
- "Fuchs_Database/dbo/Functions/fds__fn_InvoicePaymentAmount*.sql"
|
||||
- "Fuchs_Database/dbo/Functions/fds_wgd_*.sql"
|
||||
- "Fuchs_Database/dbo/Functions/fds_wdg_*.sql"
|
||||
- "Fuchs_Database/dbo/Stored Procedures/fds__logEmail.sql"
|
||||
- "Fuchs_Database/Scripts/**"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0026 — Dashboard widget SQL is set-based (no per-row scalar UDFs); `fds__emaillog.config` is a JSON contract
|
||||
|
||||
## Context
|
||||
Two dashboard widgets were failing in the DB log (`fds__debuglog`, 2026-09-16):
|
||||
|
||||
1. **`wdg_invoices_overdue_sum`** timed out after the widget command timeout of 90 s. Its SQL called
|
||||
`[dbo].[fds__fn_InvoicePaymentAmount](Id)` once per row of `fds__fn_unpaidInvoices()`. That scalar
|
||||
UDF declares a table variable, so SQL Server 2019 cannot inline it (`sys.sql_modules.is_inlineable = 0`)
|
||||
and re-executes the multi-statement TVF `fds__fn_bankingtransactions` — a full pass over
|
||||
~11 k banking rows plus a `STRING_SPLIT` cross-apply over the settings heap — for **every** invoice:
|
||||
≈0.5 s × 187 unpaid invoices ≈ 100 s measured. `wdg_unbez_akonto` had the same pattern (≈5 s,
|
||||
hidden only by `daycache = 1`). The same total computed set-based (one TVF pass, `GROUP BY InvId`,
|
||||
joined to the invoices) takes < 0.5 s and returns the identical figure.
|
||||
2. **`wgd_lastEmails`** raised *JSON text is not properly formatted*. `fds_wgd_lastEmails` parses
|
||||
`fds__emaillog.config` with `OPENJSON`/`JSON_QUERY`, but `ProcessWebComService` — the replacement
|
||||
for the legacy mailer — wrote `config = ''` on every path. One such row was enough to error the
|
||||
whole widget, and the audit trail had lost recipient/subject/attachment names for every email sent
|
||||
through the new service.
|
||||
|
||||
Widget definitions are **data** (`fis_widgets.sql`), not schema, so there is no compile-time check
|
||||
that a widget's SQL is cheap or that the columns it parses exist.
|
||||
|
||||
## Decision
|
||||
- **Widget SQL and the functions backing it are set-based.** Per-invoice payment totals come from the
|
||||
inline TVF `[dbo].[fds__fn_InvoicePaymentAmounts]()` (one banking pass, grouped by `InvId`) or from
|
||||
the `[PaymentAmount]`/`[OpenAmount]` columns `fds__fn_unpaidInvoices()` now exposes. The scalar
|
||||
`fds__fn_InvoicePaymentAmount(_full)` remains for single-invoice callers (invoice/reminder
|
||||
preparation, per-row `paymentstatus = 'p'` cells in the invoice lists) but must not be called
|
||||
per row from a widget or any other query that iterates a whole invoice set.
|
||||
- **`fds__emaillog.config` is a JSON contract**, written by `ProcessWebComService.BuildAuditConfig`
|
||||
in the legacy mailer's shape — `{subject, from, to[], cc[], bcc[], attachments[]}` with addresses
|
||||
as `"Name" <email>` and attachments as file names only (never the body, never attachment bytes) —
|
||||
on **every** audit path (success, API error, exception, invalid address, service disabled). SQL
|
||||
consumers of the column must still tolerate non-JSON text (`IIF(ISJSON(x)=1, x, N'{}')` before
|
||||
parsing), because `config` is `NOT NULL` and older rows/other writers may hold `''`.
|
||||
- **Widget-data migrations live in `Fuchs_Database/Scripts/`** as idempotent, dated T-SQL files
|
||||
(`<None Include>` in the sqlproj, not part of the schema build) and are run once per environment
|
||||
after the schema deploy they depend on.
|
||||
|
||||
## Consequences
|
||||
- `wdg_invoices_overdue_sum` 101.9 s → 0.45 s, `wdg_unbez_akonto` 4.7 s → 0.4 s, `wgd_lastEmails`
|
||||
error → 15 ms (dev, 2026-09-16). The widget command timeout stays at 90 s; it is a guard, not a
|
||||
budget.
|
||||
- Adding a scalar UDF call inside a widget's `SELECT`/`WHERE` over `fds__invoices` or the
|
||||
`fds__fn_unpaidInvoices()` set is a regression of this decision. Prefer exposing a set-based column
|
||||
on the TVF, as done for `PaymentAmount`.
|
||||
- `ProcessWebComServiceTests` assert the config contract on every audit path; changing the shape
|
||||
requires updating `fds_wgd_lastEmails` in the same change.
|
||||
- The `Scripts/2026-09-16_fis_widgets_set_based_payment_amounts.sql` migration must be run on
|
||||
Production after the schema deploy (it is a no-op once applied).
|
||||
|
||||
## Alternatives considered
|
||||
- **Raise the widget `CommandTimeout`** — masks an O(N) re-scan; the cost grows with every unpaid
|
||||
invoice and every banking upload, and it would still block a dashboard load for minutes.
|
||||
- **Convert `fds__fn_InvoicePaymentAmount` into an inlineable scalar UDF** — impossible while it
|
||||
needs a TVP (`fds__tt__idlist_vchar`) to call the banking TVF; rewriting the banking TVF as an
|
||||
inline TVF is a larger change to a function shared with the banking-assignment UI.
|
||||
- **Fix the bad `fds__emaillog` row and leave the widget function strict** — the next `''` row (any
|
||||
writer, any environment) would take the widget down again; tolerance belongs in the reader.
|
||||
@@ -74,8 +74,8 @@ public class ProcessWebComService : IComService
|
||||
FuchsTelemetry.EmailsFailed.Add(1, new KeyValuePair<string, object?>("reason", "invalid-email"));
|
||||
act?.SetStatus(ActivityStatusCode.Error, "invalid-email");
|
||||
// Every attempt is audit-logged, including this early rejection (previously it returned unlogged).
|
||||
await WriteAuditLogAsync(reference, "", "", default, false,
|
||||
[$"Invalid recipient email address: '{email}'"]);
|
||||
await WriteAuditLogAsync(reference, "", BuildAuditConfig(subject, email, name, cc, attachments?.Keys),
|
||||
default, false, [$"Invalid recipient email address: '{email}'"]);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -86,7 +86,8 @@ public class ProcessWebComService : IComService
|
||||
_logger.LogInformation(
|
||||
"[ComService DISABLED] Would send email ref={Reference} subject='{Subject}' to={Email}",
|
||||
reference, subject, email);
|
||||
await WriteAuditLogAsync(reference, "", "", default, false, ["Service disabled – email not sent"]);
|
||||
await WriteAuditLogAsync(reference, "", BuildAuditConfig(subject, email, name, cc, attachments?.Keys),
|
||||
default, false, ["Service disabled – email not sent"]);
|
||||
FuchsTelemetry.EmailsFailed.Add(1, new KeyValuePair<string, object?>("reason", "disabled"));
|
||||
return false;
|
||||
}
|
||||
@@ -153,7 +154,8 @@ public class ProcessWebComService : IComService
|
||||
_logger.LogInformation("SendEmailAsync ref={Reference} success={Success} in {Ms} ms",
|
||||
reference, success, sw.ElapsedMilliseconds);
|
||||
|
||||
await WriteAuditLogAsync(reference, messageId, "", success ? DateTime.UtcNow : default, success, errors);
|
||||
await WriteAuditLogAsync(reference, messageId, BuildAuditConfig(subject, email, name, cc, attachments?.Keys),
|
||||
success ? DateTime.UtcNow : default, success, errors);
|
||||
return success;
|
||||
}
|
||||
|
||||
@@ -308,6 +310,30 @@ public class ProcessWebComService : IComService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serialises the audit <c>config</c> stored in <c>fds__emaillog.config</c>: who the mail went
|
||||
/// to, its subject and the attachment <em>names</em> — never the body or attachment bytes.
|
||||
/// The shape (<c>subject</c>/<c>from</c>/<c>to[]</c>/<c>cc[]</c>/<c>bcc[]</c>/<c>attachments[]</c>,
|
||||
/// addresses as <c>"Name" <email></c>) is the one the legacy mailer wrote and that
|
||||
/// <c>[dbo].[fds_wgd_lastEmails]</c> reads back with <c>OPENJSON</c>; an empty string here
|
||||
/// made that dashboard widget fail for every row the new service logged.
|
||||
/// Pure so the contract can be unit-tested without a database.
|
||||
/// </summary>
|
||||
internal static string BuildAuditConfig(string subject, string email, string? name, string? cc,
|
||||
IEnumerable<string>? attachmentNames) =>
|
||||
JsonConvert.SerializeObject(new
|
||||
{
|
||||
subject = subject ?? "",
|
||||
from = "",
|
||||
to = new[] { FormatAddress(email, name) },
|
||||
cc = string.IsNullOrWhiteSpace(cc) ? Array.Empty<string>() : new[] { cc.Trim() },
|
||||
bcc = Array.Empty<string>(),
|
||||
attachments = (attachmentNames ?? []).ToArray()
|
||||
});
|
||||
|
||||
private static string FormatAddress(string email, string? name) =>
|
||||
string.IsNullOrWhiteSpace(name) ? (email ?? "") : $"\"{name.Trim()}\" <{email}>";
|
||||
|
||||
/// <summary>
|
||||
/// Builds the parameter list for <c>[dbo].[fds__logEmail]</c>. <c>fds__emaillog</c> declares
|
||||
/// <c>config</c> and <c>DateSent</c> as NOT NULL, so neither may be sent as NULL:
|
||||
|
||||
Reference in New Issue
Block a user