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:
@@ -70,6 +70,7 @@ public class ProcessWebComServiceTests
|
||||
private sealed class CapturingComService : ProcessWebComService
|
||||
{
|
||||
public readonly List<(bool success, List<string> log)> Entries = new();
|
||||
public readonly List<string> Configs = new();
|
||||
|
||||
public CapturingComService(
|
||||
IOptions<ProcessWebComSettings> settings,
|
||||
@@ -83,6 +84,7 @@ public class ProcessWebComServiceTests
|
||||
DateTime sent, bool success, IEnumerable<string> errors)
|
||||
{
|
||||
Entries.Add((success, errors.ToList()));
|
||||
Configs.Add(config);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -267,6 +269,90 @@ public class ProcessWebComServiceTests
|
||||
Assert.Equal(when, pl.Single(p => p.ParameterName == "@DateSent").Value);
|
||||
}
|
||||
|
||||
// ── Audit-log config: fds__emaillog.config must be the JSON fds_wgd_lastEmails parses ─
|
||||
// Regression: the service logged config = "" on every path; OPENJSON('') throws, which took
|
||||
// the "last emails" dashboard widget down and lost who/what/attachments from the audit trail.
|
||||
[Fact]
|
||||
public void BuildAuditConfig_FullInput_ProducesLegacyShapeWithoutBody()
|
||||
{
|
||||
var files = new Dictionary<string, byte[]> { ["Rechnung R2026-0119.pdf"] = [1, 2], ["x.xml"] = [3] };
|
||||
|
||||
string json = ProcessWebComService.BuildAuditConfig(
|
||||
"SanitärFuchs - Rechnung", "buchhaltung@example.de", "Buchhaltung Erne", "chef@example.de", files.Keys);
|
||||
|
||||
var o = JObject.Parse(json);
|
||||
Assert.Equal("SanitärFuchs - Rechnung", (string?)o["subject"]);
|
||||
Assert.Equal("", (string?)o["from"]);
|
||||
Assert.Equal(new[] { "\"Buchhaltung Erne\" <buchhaltung@example.de>" }, o["to"]!.Select(t => (string?)t));
|
||||
Assert.Equal(new[] { "chef@example.de" }, o["cc"]!.Select(t => (string?)t));
|
||||
Assert.Empty(o["bcc"]!);
|
||||
Assert.Equal(new[] { "Rechnung R2026-0119.pdf", "x.xml" }, o["attachments"]!.Select(t => (string?)t));
|
||||
Assert.Null(o["body"]); // never the HTML body
|
||||
Assert.DoesNotContain("AQI=", json); // never attachment bytes (base64 of 1,2)
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "a@b.de", "a@b.de")] // no name → bare address
|
||||
[InlineData("", "a@b.de", "a@b.de")]
|
||||
[InlineData(" ", "a@b.de", "a@b.de")]
|
||||
[InlineData("Ann", "a@b.de", "\"Ann\" <a@b.de>")] // name → quoted display form the widget unwraps
|
||||
[InlineData(" Ann ", "a@b.de", "\"Ann\" <a@b.de>")]
|
||||
public void BuildAuditConfig_RecipientName_FormatsToArrayEntry(string? name, string email, string expected)
|
||||
{
|
||||
var o = JObject.Parse(ProcessWebComService.BuildAuditConfig("S", email, name, null, null));
|
||||
|
||||
Assert.Equal(new[] { expected }, o["to"]!.Select(t => (string?)t));
|
||||
Assert.Empty(o["cc"]!);
|
||||
Assert.Empty(o["attachments"]!);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
public void BuildAuditConfig_NullOrEmptySubject_StillValidJson(string? subject)
|
||||
{
|
||||
var o = JObject.Parse(ProcessWebComService.BuildAuditConfig(subject!, "a@b.de", null, null, null));
|
||||
Assert.Equal("", (string?)o["subject"]);
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> AllAuditPaths() =>
|
||||
[
|
||||
[ "success", HttpStatusCode.OK, true, "kunde@example.de" ],
|
||||
[ "api-error", HttpStatusCode.InternalServerError, true, "kunde@example.de" ],
|
||||
[ "disabled", HttpStatusCode.OK, false, "kunde@example.de" ],
|
||||
[ "invalid-email", HttpStatusCode.OK, true, "not-an-email" ],
|
||||
];
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(AllAuditPaths))]
|
||||
public async Task SendEmailAsync_EveryAuditPath_LogsParsableConfigWithRecipientSubjectAndAttachments(
|
||||
string path, HttpStatusCode status, bool enabled, string email)
|
||||
{
|
||||
var svc = CreateCapturing(new StubHandler(status), enabled: enabled);
|
||||
var att = new Dictionary<string, byte[]> { ["Mahnung.pdf"] = [9, 9] };
|
||||
|
||||
await svc.SendEmailAsync("inv_cfg_" + path, "Subj " + path, "<p>x</p>", email, "Kunde", att);
|
||||
|
||||
string config = Assert.Single(svc.Configs);
|
||||
var o = JObject.Parse(config); // was "" → OPENJSON error in fds_wgd_lastEmails
|
||||
Assert.Equal("Subj " + path, (string?)o["subject"]);
|
||||
Assert.Equal(new[] { $"\"Kunde\" <{email}>" }, o["to"]!.Select(t => (string?)t));
|
||||
Assert.Equal(new[] { "Mahnung.pdf" }, o["attachments"]!.Select(t => (string?)t));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_OverrideRecipientSet_ConfigRecordsActualRecipientAndPrefixedSubject()
|
||||
{
|
||||
var svc = CreateCapturing(new StubHandler(HttpStatusCode.OK), overrideRecipient: "dev-inbox@example.test");
|
||||
|
||||
await svc.SendEmailAsync("inv_cfg_ovr", "Subject", "<p>x</p>", "kunde@example.de", "Kunde", null, cc: "cc@example.de");
|
||||
|
||||
var o = JObject.Parse(Assert.Single(svc.Configs));
|
||||
Assert.Equal(new[] { "\"Kunde\" <dev-inbox@example.test>" }, o["to"]!.Select(t => (string?)t)); // where it really went
|
||||
Assert.Empty(o["cc"]!); // cc is dropped by the override
|
||||
Assert.StartsWith("[DEV → kunde@example.de] ", (string?)o["subject"]); // original recipient stays traceable
|
||||
}
|
||||
|
||||
// ── Dev/test recipient override safety net ─────────────────────────────────
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_OverrideRecipientSet_RedirectsToOverrideAddress()
|
||||
|
||||
+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:
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
<Folder Include="dbo\Tables\" />
|
||||
<Folder Include="dbo\Views\" />
|
||||
<Folder Include="Security\" />
|
||||
<Folder Include="Scripts\" />
|
||||
<Folder Include="dbo\Functions\" />
|
||||
<Folder Include="dbo\Stored Procedures\" />
|
||||
<Folder Include="dbo\User Defined Types\" />
|
||||
@@ -199,6 +200,7 @@
|
||||
<Build Include="dbo\Functions\fds__fn_invoice-srq_id.sql" />
|
||||
<Build Include="dbo\Functions\fds__fn_InvoicePaymentAmount_full.sql" />
|
||||
<Build Include="dbo\Functions\fds__fn_InvoicePaymentAmount.sql" />
|
||||
<Build Include="dbo\Functions\fds__fn_InvoicePaymentAmounts.sql" />
|
||||
<Build Include="dbo\Functions\fds__fn_InvoiceIDs_mfr.sql" />
|
||||
<Build Include="dbo\Functions\fds__fn_InvoiceIdByName.sql" />
|
||||
<Build Include="dbo\Functions\fds__fn_invoice_id.sql" />
|
||||
@@ -405,4 +407,7 @@
|
||||
<Build Include="dbo\Functions\fds_wgd_lastEmails.sql" />
|
||||
<Build Include="dbo\Functions\fds_wdg_MFRSync.sql" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="Scripts\2026-09-16_fis_widgets_set_based_payment_amounts.sql" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,31 @@
|
||||
-- =============================================
|
||||
-- One-off data migration (fis_widgets rows are data, not schema — run once per environment
|
||||
-- after deploying the schema that adds [dbo].[fds__fn_InvoicePaymentAmounts] and the
|
||||
-- [PaymentAmount]/[OpenAmount] columns of [dbo].[fds__fn_unpaidInvoices]).
|
||||
--
|
||||
-- Both widgets called the scalar [fds__fn_InvoicePaymentAmount](Id) once per invoice row. That
|
||||
-- function declares a table variable, so SQL Server cannot inline it and re-runs the whole
|
||||
-- banking TVF per row (~0.5 s each): wdg_invoices_overdue_sum took ~100 s over 187 unpaid
|
||||
-- invoices and timed out the 90 s widget command; wdg_unbez_akonto took ~5 s (hidden by daycache).
|
||||
-- Set-based replacements give identical totals in < 0.5 s.
|
||||
-- Idempotent: each UPDATE only rewrites the old text and is a no-op once applied.
|
||||
-- =============================================
|
||||
SET NOCOUNT ON;
|
||||
|
||||
UPDATE [dbo].[fis_widgets]
|
||||
SET [sql] = REPLACE([sql], N'[invoicebalance] - [dbo].[fds__fn_InvoicePaymentAmount](Id)', N'[OpenAmount]')
|
||||
WHERE [short_name] = 'wdg_invoices_overdue_sum'
|
||||
AND [sql] LIKE N'%[[]dbo].[[]fds__fn_InvoicePaymentAmount](Id)%';
|
||||
PRINT CONCAT('wdg_invoices_overdue_sum rows updated: ', @@ROWCOUNT);
|
||||
|
||||
UPDATE [dbo].[fis_widgets]
|
||||
SET [sql] = REPLACE(REPLACE([sql]
|
||||
, N'[dbo].[fds__fn_InvoicePaymentAmount]([id])', N'ISNULL(pay.[Amount], 0.0)')
|
||||
, N'FROM [dbo].[fds__invoices] as i where', N'FROM [dbo].[fds__invoices] as i LEFT JOIN [dbo].[fds__fn_InvoicePaymentAmounts]() as pay ON pay.[InvId] = i.[id] where')
|
||||
WHERE [short_name] = 'wdg_unbez_akonto'
|
||||
AND [sql] LIKE N'%[[]dbo].[[]fds__fn_InvoicePaymentAmount]([[]id])%'
|
||||
AND [sql] LIKE N'%FROM [[]dbo].[[]fds__invoices] as i where%';
|
||||
PRINT CONCAT('wdg_unbez_akonto rows updated: ', @@ROWCOUNT);
|
||||
|
||||
-- Verify: nothing on the dashboard still calls the per-row scalar function.
|
||||
SELECT [short_name], [sql] FROM [dbo].[fis_widgets] WHERE [sql] LIKE N'%fds__fn_InvoicePaymentAmount](%';
|
||||
@@ -0,0 +1,23 @@
|
||||
-- =============================================
|
||||
-- Description: Set-based counterpart of the scalar [fds__fn_InvoicePaymentAmount]:
|
||||
-- returns the summed (non-storno) banking payments for EVERY invoice in one pass
|
||||
-- over [fds__fn_bankingtransactions]. Join it instead of calling the scalar
|
||||
-- function per row — the scalar one declares a table variable, so SQL Server
|
||||
-- cannot inline it and re-runs the whole banking TVF for each invoice
|
||||
-- (~0.5 s × N rows; the overdue-sum widget timed out at 90 s with 187 rows).
|
||||
-- Same semantics as the scalar version with an empty id list: all invoices,
|
||||
-- no date filter, storno credits NOT included (see *_full for those).
|
||||
-- =============================================
|
||||
CREATE FUNCTION [dbo].[fds__fn_InvoicePaymentAmounts]
|
||||
(
|
||||
)
|
||||
RETURNS TABLE
|
||||
AS
|
||||
RETURN
|
||||
(
|
||||
SELECT [InvId] = bs.[InvId]
|
||||
,[Amount] = CAST(SUM(ISNULL(bs.[Amount], 0.0)) as numeric(9,2))
|
||||
FROM [dbo].[fds__fn_bankingtransactions](NULL, NULL, NULL, NULL, DEFAULT) as bs
|
||||
WHERE bs.[InvId] IS NOT NULL
|
||||
GROUP BY bs.[InvId]
|
||||
)
|
||||
@@ -1,7 +1,9 @@
|
||||
-- =============================================
|
||||
-- Author: <Author,,Name>
|
||||
-- Create date: <Create Date,,>
|
||||
-- Description: <Description,,>
|
||||
-- Description: Finalized, not-paid, not-canceled, not-storno'd invoices with a balance > 0.
|
||||
-- [PaymentAmount] / [OpenAmount] come from one set-based pass over the banking
|
||||
-- transactions ([fds__fn_InvoicePaymentAmounts]) — use them instead of calling the
|
||||
-- scalar [fds__fn_InvoicePaymentAmount](Id) per row, which re-runs the whole
|
||||
-- banking TVF for every invoice and timed out the dashboard widgets.
|
||||
-- =============================================
|
||||
CREATE FUNCTION [dbo].[fds__fn_unpaidInvoices]
|
||||
(
|
||||
@@ -28,6 +30,10 @@ RETURN
|
||||
and InvoiceBalance > 0
|
||||
AND NOT exists (SELECT 0 FROM [dbo].[fds__invoice_details] as d where d.[StornoTo_InvId] = iv.[id])
|
||||
)
|
||||
SELECT * /*[Rechnung] = [invoiceId], [Name] = [InvoiceTitle] + CHAR(10) + ' (' + (SELECT TOP(1) c.[name] FROM [dbo].[mfr__companies] as c WHERE inv.[customerid] = c.[id]) + ')', [Betrag (net)] = FORMAT([invoicebalance], '#0.00€', 'de'), [Datum] = FORMAT([DateFinalized], 'dd.MM.yy' , 'de'), [Zahlungen] = FORMAT([dbo].[fds__fn_InvoicePaymentAmount]([id]), '#0.00€', 'de'), [order] = ROW_NUMBER() OVER (ORDER BY [DateFinalized], [DateSent]) */
|
||||
SELECT inv.*
|
||||
,[PaymentAmount] = ISNULL(pay.[Amount], 0.0)
|
||||
,[OpenAmount] = CAST(inv.[InvoiceBalance] - ISNULL(pay.[Amount], 0.0) as numeric(10,2))
|
||||
/*[Rechnung] = [invoiceId], [Name] = [InvoiceTitle] + CHAR(10) + ' (' + (SELECT TOP(1) c.[name] FROM [dbo].[mfr__companies] as c WHERE inv.[customerid] = c.[id]) + ')', [Betrag (net)] = FORMAT([invoicebalance], '#0.00€', 'de'), [Datum] = FORMAT([DateFinalized], 'dd.MM.yy' , 'de'), [Zahlungen] = FORMAT([dbo].[fds__fn_InvoicePaymentAmount]([id]), '#0.00€', 'de'), [order] = ROW_NUMBER() OVER (ORDER BY [DateFinalized], [DateSent]) */
|
||||
FROM inv
|
||||
LEFT JOIN [dbo].[fds__fn_InvoicePaymentAmounts]() as pay ON pay.[InvId] = inv.[Id]
|
||||
)␍
|
||||
@@ -2,6 +2,10 @@
|
||||
returns table
|
||||
as return
|
||||
|
||||
-- [config] is NOT NULL but a logging path may have stored '' (or otherwise non-JSON text).
|
||||
-- OPENJSON/JSON_QUERY raise on that and took the whole widget down, so the JSON is normalised
|
||||
-- once (invalid -> '{}') before anything parses it; a WHERE ISJSON() alone is not guaranteed to
|
||||
-- be evaluated ahead of the CROSS APPLY.
|
||||
SELECT TOP (@count)
|
||||
[Betreff] = oj.[subject],
|
||||
[Empfänger] = (SELECT STRING_AGG(
|
||||
@@ -9,9 +13,10 @@ SELECT TOP (@count)
|
||||
THEN SUBSTRING([value], CHARINDEX('<', [value]) + 1, CHARINDEX('>', [value]) - CHARINDEX('<', [value]) - 1)
|
||||
ELSE [value]
|
||||
END, ', ')
|
||||
FROM OPENJSON(JSON_QUERY(el.[config], '$.to'))),
|
||||
[attachments] = (SELECT STRING_AGG([value], ', ') FROM OPENJSON(JSON_QUERY(el.[config], '$.attachments'))),
|
||||
FROM OPENJSON(JSON_QUERY(j.[cfg], '$.to'))),
|
||||
[attachments] = (SELECT STRING_AGG([value], ', ') FROM OPENJSON(JSON_QUERY(j.[cfg], '$.attachments'))),
|
||||
[gesendet] = FORMAT(el.DateSent, 'dd.MM.yy', 'de-DE')
|
||||
FROM [dbo].[fds__emaillog] as el
|
||||
CROSS APPLY OPENJSON(el.[config], '$') WITH ( [subject] nvarchar(255) '$.subject' ) as oj
|
||||
CROSS APPLY (SELECT [cfg] = IIF(ISJSON(el.[config]) = 1, el.[config], N'{}')) as j
|
||||
CROSS APPLY OPENJSON(j.[cfg], '$') WITH ( [subject] nvarchar(255) '$.subject' ) as oj
|
||||
order by [DateSent] desc
|
||||
Reference in New Issue
Block a user