Compare commits

...
2 Commits
Author SHA1 Message Date
Stefan a1e6002c3b chore: Update OCORE and OCORE_web submodules to latest commits
Playwright Tests / test (push) Canceled after 0s
2026-09-16 16:35:35 +02:00
Stefan fce83c528f feat: Enhance error logging for PDF preview failures and SQL timeouts
- Implemented a new method `ShapeTableNames` to gather entity and navigation table names, ensuring no blanks or duplicates are included.
- Updated `FdsMfrClient` to handle cases where schema queries return no tables, logging appropriate warnings and preventing misleading errors.
- Introduced `PdfPreviewAsync` in `IntranetController` to manage PDF preview rendering, capturing detailed logs for failures and notifying users with readable error messages.
- Added comprehensive unit tests for error logging and table name shaping to ensure robustness and correctness.
- Documented decision to persist user action failures to the `fds__admin_logdebug` table, enhancing support diagnostics.
2026-09-16 16:35:04 +02:00
21 changed files with 611 additions and 58 deletions
+209
View File
@@ -0,0 +1,209 @@
using System;
using System.Collections.Generic;
using System.Data;
using Fuchs.Controllers;
using Fuchs.intranet;
using Microsoft.Extensions.Logging;
using Moq;
using Newtonsoft.Json;
using Xunit;
using static fds.FdsMfrClient;
namespace Fuchs.Tests;
/// <summary>
/// Covers the error-attribution fixes made after the 2026-09-16 production log review: every
/// "SQL Error in getSQLDataSet" row in fds__admin_logdebug carried Data=NULL (the failing SQL
/// was dropped), unhandled Do() exceptions and PDF-preview failures never reached the DB log at
/// all, and the browser alert always had an empty description. The DB write itself (debug_log →
/// fds__admin_logdebug) is SQL-bound and not unit-tested; these tests pin what gets handed to it.
/// </summary>
public class ErrorLoggingTests
{
// ── FIS_SQLOptions ─────────────────────────────────────────────────────────
[Fact]
public void FisSqlOptions_OnError_ForwardsSqlStatementAndContextToDbLog()
{
string? codeRef = null; Exception? loggedEx = null; object? loggedData = null;
var logger = new Mock<ILogger>();
var context = new Dictionary<string, object> { ["fn"] = "inv", ["id"] = "invl", ["code"] = "" };
var opt = new FIS_SQLOptions(context, logger.Object, (p, ex, data) => { codeRef = p; loggedEx = ex; loggedData = data; });
var ex = new TimeoutException("Das Ausführungstimeout ist abgelaufen.");
opt.OnError!("getSQLDataSet", ex, new { sql = "EXECUTE [dbo].[fds__getInvoiceList] @authuser;" });
Assert.Equal("SQL Error in getSQLDataSet", codeRef);
Assert.Same(ex, loggedEx);
// The DB column is the JSON of this object — it must carry the SQL, which was the missing piece.
string json = JsonConvert.SerializeObject(loggedData);
Assert.Contains("fds__getInvoiceList", json);
Assert.Contains("\"fn\":\"inv\"", json);
Assert.Contains("\"id\":\"invl\"", json);
logger.Verify(l => l.Log(LogLevel.Error, It.IsAny<EventId>(), It.IsAny<It.IsAnyType>(), ex,
It.IsAny<Func<It.IsAnyType, Exception?, string>>()), Times.Once);
}
[Fact]
public void FisSqlOptions_OnError_DbLogThrows_DoesNotPropagate()
{
var opt = new FIS_SQLOptions(null, null, (_, _, _) => throw new InvalidOperationException("no db"));
var recorded = Record.Exception(() => opt.OnError!("setSQLValue_async", new Exception("x"), new { sql = "SELECT 1;" }));
Assert.Null(recorded);
}
[Fact]
public void FisSqlOptions_BuildLogData_UnwrapsOcoreSqlPayload()
{
var d = FIS_SQLOptions.BuildLogData(null, new { sql = "SELECT 1;" });
Assert.Equal("SELECT 1;", d["sql"]);
Assert.False(d.ContainsKey("context"));
}
[Fact]
public void FisSqlOptions_BuildLogData_KeepsForeignPayloadVerbatim()
{
var payload = new { statement = "SELECT 2;" };
var d = FIS_SQLOptions.BuildLogData(null, payload);
Assert.Same(payload, d["sql"]);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void FisSqlOptions_BuildLogData_NullOrEmptyContextIsOmitted(bool empty)
{
var ctx = empty ? new Dictionary<string, object>() : null;
var d = FIS_SQLOptions.BuildLogData(ctx, new { sql = "SELECT 1;" });
Assert.False(d.ContainsKey("context"));
}
[Fact]
public void FisSqlOptions_BuildLogData_NullData_ProducesEmptyOrContextOnly()
{
Assert.Empty(FIS_SQLOptions.BuildLogData(null, null));
var ctx = new Dictionary<string, object> { ["fn"] = "rem" };
var d = FIS_SQLOptions.BuildLogData(ctx, null);
Assert.Single(d);
Assert.Same(ctx, d["context"]);
}
// ── Do() catch-all + response description header ──────────────────────────
[Fact]
public void BuildDoErrorLogData_ContainsRouteUserAndFormKeys_ButNoFormValues()
{
var d = IntranetController.BuildDoErrorLogData("rem", "dpreview", "", "user-1", new[] { "token" });
string json = JsonConvert.SerializeObject(d);
Assert.Contains("\"fn\":\"rem\"", json);
Assert.Contains("\"id\":\"dpreview\"", json);
Assert.Contains("\"user\":\"user-1\"", json);
Assert.Contains("\"formKeys\":[\"token\"]", json);
}
[Theory]
[InlineData("Die PDF-Vorschau konnte nicht erstellt werden.", "Die%20PDF-Vorschau%20konnte%20nicht%20erstellt%20werden.")]
[InlineData("a+b", "a%2Bb")] // '+' must not survive: the client maps '+' to a space
[InlineData("Zeile1\r\nZeile2", "Zeile1%20%20Zeile2")]
[InlineData(" trimmed ", "trimmed")]
public void EncodeResponseDescription_ProducesAsciiHeaderValue_ClientDecodesToOriginal(string msg, string expected)
{
string encoded = IntranetController.EncodeResponseDescription(msg);
Assert.Equal(expected, encoded);
Assert.All(encoded, c => Assert.True(c < 128, "header must be ASCII"));
// Mirror of the client: decodeURIComponent(v.replace(/\+/g, '%20'))
Assert.Equal(msg.Replace("\r", " ").Replace("\n", " ").Trim(), Uri.UnescapeDataString(encoded.Replace("+", "%20")));
}
[Fact]
public void EncodeResponseDescription_UmlautsSurviveRoundTrip()
{
const string msg = "Zeitüberschreitung – bitte erneut versuchen";
string encoded = IntranetController.EncodeResponseDescription(msg);
Assert.All(encoded, c => Assert.True(c < 128));
Assert.Equal(msg, Uri.UnescapeDataString(encoded));
}
// ── PDF preview failure payloads ──────────────────────────────────────────
[Fact]
public void PdfPreviewFailureMessage_UsesInnermostExceptionText()
{
var ex = new InvalidOperationException("outer",
new AggregateException("agg", new ApplicationException("Font 'Courier New' cannot be resolved")));
string msg = IntranetController.PdfPreviewFailureMessage(ex);
Assert.StartsWith("Die PDF-Vorschau konnte nicht erstellt werden: ", msg);
Assert.EndsWith("Font 'Courier New' cannot be resolved", msg);
Assert.DoesNotContain("outer", msg);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public void PdfPreviewFailureMessage_EmptyExceptionText_FallsBackToGenericText(string text)
{
string msg = IntranetController.PdfPreviewFailureMessage(new Exception(text));
Assert.Equal("Die PDF-Vorschau konnte nicht erstellt werden.", msg);
}
[Fact]
public void BuildPdfPreviewLogData_CarriesEndpointRefUserAndDuration()
{
var d = IntranetController.BuildPdfPreviewLogData("rem/dpreview", "tok-123", "user-1", 4321);
string json = JsonConvert.SerializeObject(d);
Assert.Contains("\"what\":\"rem/dpreview\"", json);
Assert.Contains("\"ref\":\"tok-123\"", json);
Assert.Contains("\"user\":\"user-1\"", json);
Assert.Contains("\"ms\":4321", json);
}
// ── Data service: schema failure must not cascade into "FROM [dbo].[]" ────
private static DataRowCollection NavTables(params string[] names)
{
var t = new DataTable();
t.Columns.Add("tablename", typeof(string));
foreach (var n in names) t.Rows.Add(n);
return t.Rows;
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public void ShapeTableNames_NoEntityTable_ReturnsEmpty_EvenWithNavTables(string entityTable)
{
var names = DatabaseSchema.ShapeTableNames(entityTable, NavTables("mfr_invoice_items"));
Assert.Empty(names);
}
[Fact]
public void ShapeTableNames_EntityTableFirst_ThenNavTables_SkippingBlanksAndDuplicates()
{
var names = DatabaseSchema.ShapeTableNames("mfr_invoice", NavTables("mfr_invoice_items", "", "mfr_invoice_items", "mfr_invoice_files"));
Assert.Equal(new[] { "mfr_invoice", "mfr_invoice_items", "mfr_invoice_files" }, names);
}
[Fact]
public void ShapeTableNames_NoNavTables_ReturnsEntityOnly()
{
var names = DatabaseSchema.ShapeTableNames("mfr_invoice", NavTables());
Assert.Equal(new[] { "mfr_invoice" }, names);
}
}
@@ -75,10 +75,14 @@ public partial class IntranetController
private async Task<IActionResult> HandleDraftPreview(string fn, string id, string code)
{
if (!HasForm("token")) return BadRequest400();
var doc = await _invoiceDrafts.RenderPreviewAsync(Form("token"));
string token = Form("token");
return await PdfPreviewAsync("inv/dpreview", token, async () =>
{
var doc = await _invoiceDrafts.RenderPreviewAsync(token);
if (doc == null) return DraftGone();
var imgcol = await _pdf.DocToImageCollectionAsync(doc);
return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages });
});
}
// POST inv/dsave — { token } → { ok, invid }; flush cache→DB + business event + draftReady
@@ -300,9 +300,10 @@ public partial class IntranetController
}
_logger.LogDebug("HandleReportDoc found reportid={ReportId} fileName={FileName} mimeType={MimeType} size={Size} user={User}",
reportid, file.Name, file.MimeType(), content?.Length ?? 0, UserAccountID);
return Form("typ") != "img"
? await FileContentResultAsync(content!, file.MimeType(), file.Name)
: await JSONAsync(new { id = reportid, img = await BuildPdfImageArray(content!) });
if (Form("typ") != "img")
return await FileContentResultAsync(content!, file.MimeType(), file.Name);
return await PdfPreviewAsync("inv/rdoc", reportid, async () =>
await JSONAsync(new { id = reportid, img = await BuildPdfImageArray(content!) }));
}
private async Task<IActionResult> HandleReportDocByName(string fn, string id, string code)
@@ -0,0 +1,88 @@
using System.Diagnostics;
using Fuchs.Observability;
using Microsoft.AspNetCore.Mvc;
namespace Fuchs.Controllers;
// Partial class: shared guard for every "PDF-Vorschau" endpoint (inv/dpreview, rem/dpreview,
// req/idoc+img, rem/idoc+img, inv/rdoc+img, rem/rdoc+img).
//
// A preview render can fail deep inside MigraDoc / Spire (fonts, licence, malformed HTML in an
// item text, an oversized inline image, ...). Before this guard such a failure only surfaced as
// the generic catch-all in Do(): an ErrorLog.txt line on the web server, nothing in the
// fds__admin_logdebug table support actually reads, and an empty "Der Server hat einen Fehler
// gemeldet:" alert in the browser. Every preview now goes through PdfPreviewAsync so the failure
// is (1) written to the DB log with what/id/user/duration, (2) counted, (3) toasted to the user
// and (4) returned with a readable description header.
public partial class IntranetController
{
internal const string PdfPreviewLogReference = "PDF preview failed";
/// <summary>
/// Runs <paramref name="render"/> (the endpoint body that produces the preview response) and
/// turns any exception into a fully logged, user-readable 500.
/// </summary>
/// <param name="what">Endpoint tag, e.g. <c>inv/dpreview</c>.</param>
/// <param name="refId">Draft token / invoice / reminder / report id the preview is for.</param>
private async Task<IActionResult> PdfPreviewAsync(string what, string refId, Func<Task<IActionResult>> render)
{
var sw = Stopwatch.StartNew();
using var act = FuchsTelemetry.StartActivity("pdf.preview");
act?.SetTag("fuchs.pdf.preview.what", what);
act?.SetTag("fuchs.pdf.preview.ref", refId);
_logger.LogDebug("PDF preview {What} ref={RefId} user={User} started", what, refId, UserAccountID);
try
{
var result = await render();
sw.Stop();
FuchsTelemetry.PdfPreviews.Add(1,
new KeyValuePair<string, object?>("what", what),
new KeyValuePair<string, object?>("outcome", "success"));
_logger.LogInformation("PDF preview {What} ref={RefId} user={User} done in {Ms} ms",
what, refId, UserAccountID, sw.ElapsedMilliseconds);
return result;
}
catch (Exception ex)
{
sw.Stop();
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
FuchsTelemetry.PdfPreviews.Add(1,
new KeyValuePair<string, object?>("what", what),
new KeyValuePair<string, object?>("outcome", "failure"));
_logger.LogError(ex, "PDF preview {What} ref={RefId} user={User} failed after {Ms} ms",
what, refId, UserAccountID, sw.ElapsedMilliseconds);
_intranet.debug_log($"{PdfPreviewLogReference} ({what})", ex, UserAccountID,
BuildPdfPreviewLogData(what, refId, UserAccountID, sw.ElapsedMilliseconds));
await _events.UserIssueAsync(
"PDF-Vorschau fehlgeschlagen",
"Die PDF-Vorschau konnte nicht erstellt werden. Der Fehler wurde protokolliert.",
UserAccountID,
new Dictionary<string, object?> { ["what"] = what, ["ref"] = refId });
return ServerError(PdfPreviewFailureMessage(ex));
}
}
/// <summary>Serialisable payload for the DB log row of a failed preview.</summary>
internal static Dictionary<string, object?> BuildPdfPreviewLogData(string what, string refId, string user, long elapsedMs) =>
new()
{
["what"] = what,
["ref"] = refId,
["user"] = user,
["ms"] = elapsedMs
};
/// <summary>
/// User-facing message: the innermost exception text is included so the customer can quote
/// it to support (this is an internal intranet; the message never carries secrets).
/// </summary>
internal static string PdfPreviewFailureMessage(Exception ex)
{
var root = ex;
while (root.InnerException != null) root = root.InnerException;
string detail = (root.Message ?? "").Trim();
return detail.Length == 0
? "Die PDF-Vorschau konnte nicht erstellt werden."
: $"Die PDF-Vorschau konnte nicht erstellt werden: {detail}";
}
}
@@ -89,9 +89,11 @@ public partial class IntranetController
_logger.LogWarning("Reminder rdoc: document not found for reminder {RemId} user={User}", Form("id"), UserAccountID);
return StatusCode(404, new { error = "Dokument wurde nicht gefunden" });
}
return Form("typ") != "img"
? await FileContentResultAsync(fc, file.MimeType(), file.Name)
: await JSONAsync(new { id = Form("id"), img = await BuildPdfImageArray(fc) });
if (Form("typ") != "img")
return await FileContentResultAsync(fc, file.MimeType(), file.Name);
string remId = Form("id");
return await PdfPreviewAsync("rem/rdoc", remId, async () =>
await JSONAsync(new { id = remId, img = await BuildPdfImageArray(fc) }));
}
case "idoc": return await HandleReminderIdoc(fn, id, code);
@@ -211,8 +213,11 @@ public partial class IntranetController
: _pdf.DocToPdfBytes(_reminders.GenerateReminderPdf(fdRem, fdRem.IsDraft));
return await FileContentResultAsync(ct, "application/pdf", filename, inline: true);
}
return await PdfPreviewAsync("rem/idoc", fdRem.Id, async () =>
{
var imgcol = await _pdf.DocToImageCollectionAsync(_reminders.GenerateReminderPdf(fdRem, fdRem.IsDraft));
return await JSONAsync(new { id = fdRem.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
});
}
private async Task<IActionResult> HandleReminderResend(string fn, string id, string code)
@@ -67,10 +67,14 @@ public partial class IntranetController
private async Task<IActionResult> HandleReminderDraftPreview(string fn, string id, string code)
{
if (!HasForm("token")) return BadRequest400();
var doc = _reminderDrafts.RenderPreview(Form("token"));
string token = Form("token");
return await PdfPreviewAsync("rem/dpreview", token, async () =>
{
var doc = _reminderDrafts.RenderPreview(token);
if (doc == null) return DraftGone();
var imgcol = await _pdf.DocToImageCollectionAsync(doc);
return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages });
});
}
// POST rem/dsave — { token } → { ok, remid }; flush cache→DB + business event + draftReady
@@ -405,8 +405,11 @@ public partial class IntranetController
SetInlinePdfFilename(filename);
return File(ct, "application/pdf");
}
return await PdfPreviewAsync("req/idoc", fdInv.Id, async () =>
{
var imgcol = await _pdf.DocToImageCollectionAsync(await _invoices.GenerateInvoicePdfAsync(fdInv, fdInv.IsDraft));
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
});
}
private async Task<IActionResult> HandleRequestResend(string fn, string id, string code)
+41 -2
View File
@@ -123,7 +123,25 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
// ── Action helpers ────────────────────────────────────────────────────────
protected IActionResult Unauthorized401() => StatusCode(401);
protected IActionResult BadRequest400() => BadRequest();
protected IActionResult ServerError(string msg = "") => StatusCode(500, new { error = msg });
/// <summary>
/// 500 with the message both in the JSON body and in the <c>x-ocms-desc</c> header, which is
/// the only place the frontend's default failure handler (<c>$ocms.failure</c>) reads a
/// description from — without it the user only ever saw an empty "Der Server hat einen Fehler
/// gemeldet:" alert.
/// </summary>
protected IActionResult ServerError(string msg = "")
{
if (!string.IsNullOrEmpty(msg))
Response.Headers["x-ocms-desc"] = EncodeResponseDescription(msg);
return StatusCode(500, new { error = msg });
}
/// <summary>
/// Header-safe (ASCII, percent-encoded) form of a user message; the client decodes it with
/// <c>decodeURIComponent</c> after mapping <c>+</c> to a space, so spaces must be <c>%20</c>.
/// </summary>
internal static string EncodeResponseDescription(string msg) =>
Uri.EscapeDataString((msg ?? "").Replace("\r", " ").Replace("\n", " ").Trim());
// ── Index (GET /) ─────────────────────────────────────────────────────────
[AllowAnonymous]
@@ -189,6 +207,11 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
{
_logger.LogError(ex, "Unhandled exception in Do fn={Fn} id={Id} code={Code} user={User}",
fn, id, code, UserAccountID);
// Also persist to fds__admin_logdebug: the ILogger sink is a file on the web server,
// while the DB log is what support actually pulls from the customer's database — an
// unhandled handler exception used to be invisible there.
_intranet.debug_log($"Unhandled exception in Do {fn}/{id}", ex, UserAccountID,
BuildDoErrorLogData(fn, id, code, UserAccountID, RequestFormKeys()));
// Standing rule: an exception that interrupts a user-initiated action must reach the
// user as a notification, not only the log. This is the catch-all safety net for any
// Do_Process_* action that throws without first publishing its own (more specific)
@@ -202,10 +225,26 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
UserAccountID,
new Dictionary<string, object?> { ["fn"] = fn });
}
return ServerError();
return ServerError("Die Aktion konnte aufgrund eines unerwarteten Fehlers nicht abgeschlossen werden.");
}
}
/// <summary>Form field names of the current request (never values — they may carry PII/tokens).</summary>
private string[] RequestFormKeys() =>
Request.HasFormContentType ? Request.Form.Keys.ToArray() : Array.Empty<string>();
/// <summary>Serialisable context for the DB log row of an unhandled <see cref="Do"/> exception.</summary>
internal static Dictionary<string, object?> BuildDoErrorLogData(
string fn, string id, string code, string user, IReadOnlyCollection<string> formKeys) =>
new()
{
["fn"] = fn,
["id"] = id,
["code"] = code,
["user"] = user,
["formKeys"] = formKeys
};
// ── Auth helper ───────────────────────────────────────────────────────────
private async Task<IActionResult> HandleAuth(string fn, string id, string code)
{
@@ -0,0 +1,64 @@
---
status: Accepted
date: 2026-09-16
applyTo:
- "Fuchs/Controllers/**"
- "Fuchs/code/FuchsIntranet.cs"
- "Fuchs/js/intranet/**"
supersededBy: ""
---
# 0023 — Failures of user actions are persisted to `fds__admin_logdebug`, with the SQL and a readable description
## Context
Support diagnoses production problems from a query over the `fds__admin_logdebug` table
(`EXECUTE [dbo].[fds__admin_logdebug]` via `Fuchs_intranet.debug_log`) — not from the
web server's `logs/ErrorLog.txt` ([0003](0003-structured-diagnostic-logging.md)), which is
rarely reachable. Reviewing the 2026-09-16 production extract of that table showed three gaps:
- Every "SQL Error in getSQLDataSet" row (twelve execution timeouts in one morning) had
`Data = NULL`. `FIS_SQLOptions.OnError` logged its constructor *context* (the fn/id/code,
usually `null`) instead of the *data* OCORE hands over (`new { sql = … }`), so nobody could
tell which statement timed out.
- Unhandled exceptions from `IntranetController.Do` went to `ILogger` only. A customer's
failing "PDF-Vorschau" left no trace in the table at all.
- The browser's generic failure alert reads its description from the `x-ocms-desc` response
header, which the backend never set — so users always saw an empty
"Der Server hat einen Fehler gemeldet:".
## Decision
- **The DB log row must carry what is needed to attribute the failure.** `FIS_SQLOptions`
merges the OCORE payload (the SQL statement) with the caller context into the `Data`
column (`{ sql, context }`). New `OnError` hooks must never drop the statement.
- **Every failure that interrupts a user action is written to `fds__admin_logdebug` too**,
in addition to `ILogger` and the notification required by
[0003](0003-interrupting-exceptions-notify-the-user.md): the `Do` catch-all writes
`Unhandled exception in Do {fn}/{id}` with route, user and the form *field names* (never
values — they can carry tokens/PII); the PDF-preview endpoints go through a shared guard
(`IntranetController.PdfPreviewAsync`) that logs `PDF preview failed (endpoint)` with
ref/user/duration, counts `fuchs.pdf.previews{outcome}` and toasts the user.
- **A 500 carries its message in the `x-ocms-desc` header** (`ServerError(msg)` →
`EncodeResponseDescription`: percent-encoded ASCII, spaces as `%20` because the client
maps `+` to space before `decodeURIComponent`). Handlers that return a 500 should pass a
user-readable German message; the frontend shows it verbatim.
- **Preview calls in the frontend never fail silently.** They use a 120 s timeout
(`$inv.pdfPreviewTimeout`), one shared error handler (`$inv.pdfPreviewFailed`) and a
`complete` handler that unfreezes the editor and reports a timeout — `postXT` returns early
on a timeout without calling `error`, so `complete` is the only place it is observable.
## Consequences
- Support can attribute a SQL timeout to its statement straight from the table; a failing
preview is visible as one row with endpoint, reference id, user and duration.
- `debug_log` is a synchronous DB round-trip and is only used on error paths; it must not be
added to hot/success paths (see [0003](0003-structured-diagnostic-logging.md) on the cost
of DB logging).
- `x-ocms-desc` is user-facing: messages must be German, short, and free of secrets. The
innermost exception text is acceptable for an internal intranet audience.
## Alternatives considered
- **Enable `FuchsLoggerProvider.DatabaseLoggingEnabled`** so every `ILogger` error lands in
the DB. Rejected: it would write every `LogError` (including expected/handled ones and
background-service noise) with a DB round-trip each, and the provider has no access to the
request context (user, route) that makes a row actionable.
- **Return the message only in the JSON body.** The generic client failure path never parses
the body of a non-2xx response; the header is the existing contract in `oci_basic.js`.
+3
View File
@@ -57,6 +57,9 @@ public static class FuchsTelemetry
public static readonly Counter<long> InvoiceInlineImageFetches =
Meter.CreateCounter<long>("fuchs.invoice.inlineimages.fetches", "{fetch}",
"Invoice-text image fetches for inline PDF rendering (ADR 0018), tagged by outcome.");
public static readonly Counter<long> PdfPreviews =
Meter.CreateCounter<long>("fuchs.pdf.previews", "{preview}",
"PDF preview requests (draft/invoice/reminder/report image previews), tagged by endpoint and outcome.");
// ── Performance histograms (durations in milliseconds) ───────────────────
public static readonly Histogram<double> PdfRenderDuration =
+32 -3
View File
@@ -229,13 +229,42 @@ public class FuchsUserIdentity
public class FIS_SQLOptions : sqloptions
{
public FIS_SQLOptions(Dictionary<string, object>? context = null, ILogger? logger = null)
: this(context, logger, dbLog: (procedure, ex, data) => FuchsOcmsIntranet.Instance.debug_log(procedure, ex, data: data))
{
}
/// <summary>
/// Test seam: <paramref name="dbLog"/> receives what would otherwise go to
/// <see cref="Fuchs_intranet.debug_log"/> (code reference, exception, serialisable data).
/// </summary>
internal FIS_SQLOptions(Dictionary<string, object>? context, ILogger? logger,
Action<string, Exception?, object?> dbLog)
{
OnError = (procedure, ex, data) =>
{
logger?.LogError(ex, "SQL error in {Procedure}: {Message} — context={@Context}",
procedure, ex.Message, context);
try { FuchsOcmsIntranet.Instance.debug_log($"SQL Error in {procedure}", ex, data: context); }
// OCORE hands the failing statement in `data` (new { sql = ... }); `context` is the
// caller's fn/id/code. Both are needed to attribute a timeout in the DB log — logging
// only the (usually null) context left every "SQL Error in getSQLDataSet" row without
// the SQL that timed out.
var logData = BuildLogData(context, data);
logger?.LogError(ex, "SQL error in {Procedure}: {Message} — sql={@Sql} context={@Context}",
procedure, ex.Message, data, context);
try { dbLog($"SQL Error in {procedure}", ex, logData); }
catch { }
};
}
/// <summary>Merges the OCORE error payload (the SQL) with the caller context into one JSON-able object.</summary>
internal static Dictionary<string, object?> BuildLogData(IReadOnlyDictionary<string, object>? context, object? data)
{
var d = new Dictionary<string, object?>();
if (data != null)
{
// OCORE passes new { sql = "..." }; unwrap so the log column reads as the plain statement.
var sqlProp = data.GetType().GetProperty("sql");
d["sql"] = sqlProp?.GetValue(data) ?? data;
}
if (context is { Count: > 0 }) d["context"] = context;
return d;
}
}
+31 -8
View File
@@ -116,6 +116,29 @@ $inv.eM = (r, re, opt) => {
}
return $('#topbar').ocmsmenu(m); /* empty Array => empty menu */
};
/* ── PDF preview failure handling (shared by every image-preview call) ────────
A preview render can legitimately take longer than the 30s postXT default (Spire
rasterises every page), and postXT swallows a timeout silently — the user would
sit in front of a frozen editor with no message. So previews get a longer timeout
and one handler that always tells the user something: the server's description
header when it sent one (the backend logs the failure and puts the reason there),
a timeout hint when the request timed out, the generic text otherwise. */
$inv.pdfPreviewTimeout = 120; /* seconds */
$inv.pdfPreviewFailed = function (jqXHR, textStatus) {
if ((jqXHR || {}).internalCode === 11110) { $ocms.login.dlg(); return; }
let desc = ((jqXHR || {}).internalText || '').trim();
if (desc === '' && textStatus === 'timeout') { desc = $ict.pvto; }
if (desc === '' && (jqXHR || {}).status === 410) { desc = $ict.pvgone; }
alert(($ict.pvf || $t.f1) + (desc !== '' ? '\n' + desc : ''));
};
/* postXT never calls `error` for a timeout (it returns early), so the timeout is only
observable from `complete`; this builds the complete handler for a preview call. */
$inv.pdfPreviewComplete = function (layout) {
return (jqXHR, textStatus) => {
if (layout) { layout.rC('freeze'); }
if (textStatus === 'timeout') { $inv.pdfPreviewFailed(jqXHR, textStatus); }
};
};
/* ── Backend-authoritative draft editing controller (ADR 0006/0007) ───────────
The server holds the truth for an invoice draft in an in-memory session; this
object seeds it, sends single edits as deltas, and renders the totals footer +
@@ -352,7 +375,7 @@ $inv.d = {
if ($fis.ValidateEmail(email) === false) { if (bool(confirm($ict.ivE + $ict.ivEc), false) === false) { return; } }
l.aC('freeze');
$ocms.postXT({
url: $ocms.url('inv/dpreview'), data: { token: t }, success: (response) => {
url: $ocms.url('inv/dpreview'), data: { token: t }, timeout: $inv.pdfPreviewTimeout, success: (response) => {
l.rC('freeze');
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), total = response.total;
if (total > 10) { $$.dc('note warn', c).text($ict.tpe); }
@@ -377,7 +400,7 @@ $inv.d = {
},
cancel: function (e) { if (confirm($ict.cdI)) { $inv.d.close(); $inv.rReload(); } }
});
}, error: () => { l.rC('freeze'); alert($ict.eis); }
}, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete(l)
});
},
/* Zwischenspeichern: flush the cache to the DB (no re-upload); stay in the editor. */
@@ -540,7 +563,7 @@ $inv.rd = {
if ($fis.ValidateEmail(email) === false) { if (bool(confirm($ict.ivE + $ict.ivEc), false) === false) { return; } }
l.aC('freeze');
$ocms.postXT({
url: $ocms.url('rem/dpreview'), data: { token: t }, success: (response) => {
url: $ocms.url('rem/dpreview'), data: { token: t }, timeout: $inv.pdfPreviewTimeout, success: (response) => {
l.rC('freeze');
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88);
$.each(response.img || [], (ii, img) => { $$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px')); });
@@ -563,7 +586,7 @@ $inv.rd = {
},
cancel: function (e) { if (confirm($ict.cdI)) { $inv.rd.close(); $inv.rReload(); } }
});
}, error: () => { l.rC('freeze'); alert($t.f1); }
}, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete(l)
});
},
/* Zwischenspeichern: flush the cache to the DB (no re-upload); stay in the editor. */
@@ -1540,7 +1563,7 @@ $inv.disp = (id, typ) => {
}
if (u !== '') {
$ocms.postXT({
url: $ocms.url(u), data: { id: id || '', typ: 'img' }, success: (response) => {
url: $ocms.url(u), data: { id: id || '', typ: 'img' }, timeout: $inv.pdfPreviewTimeout, success: (response) => {
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id;
$.each(response.img || [], function (ii, img) {
$$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px'));
@@ -1548,13 +1571,13 @@ $inv.disp = (id, typ) => {
$ocms.dlg(c, {
size: [vhr, Math.round(vw() * 0.88)], zindex_min: 50, form: false, exclusive:false
});
}
}, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete()
});
}
};
$inv.jdbn = function (ev) {
$ocms.postXT({
url: $ocms.url('inv/rdocn'), data: { name: ev.data.id || '', typ: 'img' }, success: (response) => {
url: $ocms.url('inv/rdocn'), data: { name: ev.data.id || '', typ: 'img' }, timeout: $inv.pdfPreviewTimeout, success: (response) => {
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id;
$.each(response.img || [], function (ii, img) {
$$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px'));
@@ -1562,7 +1585,7 @@ $inv.jdbn = function (ev) {
$ocms.dlg(c, {
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false
});
}
}, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete()
});
};
$inv.sp13b = () => {
@@ -38,6 +38,9 @@
pna: 'Diese Seite ist in der Vorschau nicht verfügbar',
tpe: 'Die Anzahl von {0} Seiten wird aktuell nicht unterstützt',
eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.',
pvf: 'Die PDF-Vorschau konnte nicht erstellt werden.',
pvto: 'Der Server hat nicht rechtzeitig geantwortet (Zeitüberschreitung). Bitte versuchen Sie es erneut.',
pvgone: 'Der Entwurf ist auf dem Server nicht mehr vorhanden (Sitzung abgelaufen). Bitte öffnen Sie den Entwurf erneut.',
iss: 'Zwischenstand speichern.',
p13b: 'USt -> §13b',
setm: 'Set-Preisanzeige',
+34 -8
View File
@@ -162,6 +162,9 @@ let $ict = {
pna: 'Diese Seite ist in der Vorschau nicht verfügbar',
tpe: 'Die Anzahl von {0} Seiten wird aktuell nicht unterstützt',
eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.',
pvf: 'Die PDF-Vorschau konnte nicht erstellt werden.',
pvto: 'Der Server hat nicht rechtzeitig geantwortet (Zeitüberschreitung). Bitte versuchen Sie es erneut.',
pvgone: 'Der Entwurf ist auf dem Server nicht mehr vorhanden (Sitzung abgelaufen). Bitte öffnen Sie den Entwurf erneut.',
iss: 'Zwischenstand speichern.',
p13b: 'USt -> §13b',
setm: 'Set-Preisanzeige',
@@ -692,6 +695,29 @@ $inv.eM = (r, re, opt) => {
}
return $('#topbar').ocmsmenu(m); /* empty Array => empty menu */
};
/* ── PDF preview failure handling (shared by every image-preview call) ────────
A preview render can legitimately take longer than the 30s postXT default (Spire
rasterises every page), and postXT swallows a timeout silently — the user would
sit in front of a frozen editor with no message. So previews get a longer timeout
and one handler that always tells the user something: the server's description
header when it sent one (the backend logs the failure and puts the reason there),
a timeout hint when the request timed out, the generic text otherwise. */
$inv.pdfPreviewTimeout = 120; /* seconds */
$inv.pdfPreviewFailed = function (jqXHR, textStatus) {
if ((jqXHR || {}).internalCode === 11110) { $ocms.login.dlg(); return; }
let desc = ((jqXHR || {}).internalText || '').trim();
if (desc === '' && textStatus === 'timeout') { desc = $ict.pvto; }
if (desc === '' && (jqXHR || {}).status === 410) { desc = $ict.pvgone; }
alert(($ict.pvf || $t.f1) + (desc !== '' ? '\n' + desc : ''));
};
/* postXT never calls `error` for a timeout (it returns early), so the timeout is only
observable from `complete`; this builds the complete handler for a preview call. */
$inv.pdfPreviewComplete = function (layout) {
return (jqXHR, textStatus) => {
if (layout) { layout.rC('freeze'); }
if (textStatus === 'timeout') { $inv.pdfPreviewFailed(jqXHR, textStatus); }
};
};
/* ── Backend-authoritative draft editing controller (ADR 0006/0007) ───────────
The server holds the truth for an invoice draft in an in-memory session; this
object seeds it, sends single edits as deltas, and renders the totals footer +
@@ -928,7 +954,7 @@ $inv.d = {
if ($fis.ValidateEmail(email) === false) { if (bool(confirm($ict.ivE + $ict.ivEc), false) === false) { return; } }
l.aC('freeze');
$ocms.postXT({
url: $ocms.url('inv/dpreview'), data: { token: t }, success: (response) => {
url: $ocms.url('inv/dpreview'), data: { token: t }, timeout: $inv.pdfPreviewTimeout, success: (response) => {
l.rC('freeze');
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), total = response.total;
if (total > 10) { $$.dc('note warn', c).text($ict.tpe); }
@@ -953,7 +979,7 @@ $inv.d = {
},
cancel: function (e) { if (confirm($ict.cdI)) { $inv.d.close(); $inv.rReload(); } }
});
}, error: () => { l.rC('freeze'); alert($ict.eis); }
}, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete(l)
});
},
/* Zwischenspeichern: flush the cache to the DB (no re-upload); stay in the editor. */
@@ -1116,7 +1142,7 @@ $inv.rd = {
if ($fis.ValidateEmail(email) === false) { if (bool(confirm($ict.ivE + $ict.ivEc), false) === false) { return; } }
l.aC('freeze');
$ocms.postXT({
url: $ocms.url('rem/dpreview'), data: { token: t }, success: (response) => {
url: $ocms.url('rem/dpreview'), data: { token: t }, timeout: $inv.pdfPreviewTimeout, success: (response) => {
l.rC('freeze');
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88);
$.each(response.img || [], (ii, img) => { $$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px')); });
@@ -1139,7 +1165,7 @@ $inv.rd = {
},
cancel: function (e) { if (confirm($ict.cdI)) { $inv.rd.close(); $inv.rReload(); } }
});
}, error: () => { l.rC('freeze'); alert($t.f1); }
}, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete(l)
});
},
/* Zwischenspeichern: flush the cache to the DB (no re-upload); stay in the editor. */
@@ -2116,7 +2142,7 @@ $inv.disp = (id, typ) => {
}
if (u !== '') {
$ocms.postXT({
url: $ocms.url(u), data: { id: id || '', typ: 'img' }, success: (response) => {
url: $ocms.url(u), data: { id: id || '', typ: 'img' }, timeout: $inv.pdfPreviewTimeout, success: (response) => {
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id;
$.each(response.img || [], function (ii, img) {
$$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px'));
@@ -2124,13 +2150,13 @@ $inv.disp = (id, typ) => {
$ocms.dlg(c, {
size: [vhr, Math.round(vw() * 0.88)], zindex_min: 50, form: false, exclusive:false
});
}
}, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete()
});
}
};
$inv.jdbn = function (ev) {
$ocms.postXT({
url: $ocms.url('inv/rdocn'), data: { name: ev.data.id || '', typ: 'img' }, success: (response) => {
url: $ocms.url('inv/rdocn'), data: { name: ev.data.id || '', typ: 'img' }, timeout: $inv.pdfPreviewTimeout, success: (response) => {
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id;
$.each(response.img || [], function (ii, img) {
$$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px'));
@@ -2138,7 +2164,7 @@ $inv.jdbn = function (ev) {
$ocms.dlg(c, {
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false
});
}
}, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete()
});
};
$inv.sp13b = () => {
File diff suppressed because one or more lines are too long
+34 -8
View File
@@ -162,6 +162,9 @@ let $ict = {
pna: 'Diese Seite ist in der Vorschau nicht verfügbar',
tpe: 'Die Anzahl von {0} Seiten wird aktuell nicht unterstützt',
eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.',
pvf: 'Die PDF-Vorschau konnte nicht erstellt werden.',
pvto: 'Der Server hat nicht rechtzeitig geantwortet (Zeitüberschreitung). Bitte versuchen Sie es erneut.',
pvgone: 'Der Entwurf ist auf dem Server nicht mehr vorhanden (Sitzung abgelaufen). Bitte öffnen Sie den Entwurf erneut.',
iss: 'Zwischenstand speichern.',
p13b: 'USt -> §13b',
setm: 'Set-Preisanzeige',
@@ -673,6 +676,29 @@ $inv.eM = (r, re, opt) => {
}
return $('#topbar').ocmsmenu(m); /* empty Array => empty menu */
};
/* ── PDF preview failure handling (shared by every image-preview call) ────────
A preview render can legitimately take longer than the 30s postXT default (Spire
rasterises every page), and postXT swallows a timeout silently — the user would
sit in front of a frozen editor with no message. So previews get a longer timeout
and one handler that always tells the user something: the server's description
header when it sent one (the backend logs the failure and puts the reason there),
a timeout hint when the request timed out, the generic text otherwise. */
$inv.pdfPreviewTimeout = 120; /* seconds */
$inv.pdfPreviewFailed = function (jqXHR, textStatus) {
if ((jqXHR || {}).internalCode === 11110) { $ocms.login.dlg(); return; }
let desc = ((jqXHR || {}).internalText || '').trim();
if (desc === '' && textStatus === 'timeout') { desc = $ict.pvto; }
if (desc === '' && (jqXHR || {}).status === 410) { desc = $ict.pvgone; }
alert(($ict.pvf || $t.f1) + (desc !== '' ? '\n' + desc : ''));
};
/* postXT never calls `error` for a timeout (it returns early), so the timeout is only
observable from `complete`; this builds the complete handler for a preview call. */
$inv.pdfPreviewComplete = function (layout) {
return (jqXHR, textStatus) => {
if (layout) { layout.rC('freeze'); }
if (textStatus === 'timeout') { $inv.pdfPreviewFailed(jqXHR, textStatus); }
};
};
/* ── Backend-authoritative draft editing controller (ADR 0006/0007) ───────────
The server holds the truth for an invoice draft in an in-memory session; this
object seeds it, sends single edits as deltas, and renders the totals footer +
@@ -909,7 +935,7 @@ $inv.d = {
if ($fis.ValidateEmail(email) === false) { if (bool(confirm($ict.ivE + $ict.ivEc), false) === false) { return; } }
l.aC('freeze');
$ocms.postXT({
url: $ocms.url('inv/dpreview'), data: { token: t }, success: (response) => {
url: $ocms.url('inv/dpreview'), data: { token: t }, timeout: $inv.pdfPreviewTimeout, success: (response) => {
l.rC('freeze');
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), total = response.total;
if (total > 10) { $$.dc('note warn', c).text($ict.tpe); }
@@ -934,7 +960,7 @@ $inv.d = {
},
cancel: function (e) { if (confirm($ict.cdI)) { $inv.d.close(); $inv.rReload(); } }
});
}, error: () => { l.rC('freeze'); alert($ict.eis); }
}, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete(l)
});
},
/* Zwischenspeichern: flush the cache to the DB (no re-upload); stay in the editor. */
@@ -1097,7 +1123,7 @@ $inv.rd = {
if ($fis.ValidateEmail(email) === false) { if (bool(confirm($ict.ivE + $ict.ivEc), false) === false) { return; } }
l.aC('freeze');
$ocms.postXT({
url: $ocms.url('rem/dpreview'), data: { token: t }, success: (response) => {
url: $ocms.url('rem/dpreview'), data: { token: t }, timeout: $inv.pdfPreviewTimeout, success: (response) => {
l.rC('freeze');
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88);
$.each(response.img || [], (ii, img) => { $$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px')); });
@@ -1120,7 +1146,7 @@ $inv.rd = {
},
cancel: function (e) { if (confirm($ict.cdI)) { $inv.rd.close(); $inv.rReload(); } }
});
}, error: () => { l.rC('freeze'); alert($t.f1); }
}, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete(l)
});
},
/* Zwischenspeichern: flush the cache to the DB (no re-upload); stay in the editor. */
@@ -2097,7 +2123,7 @@ $inv.disp = (id, typ) => {
}
if (u !== '') {
$ocms.postXT({
url: $ocms.url(u), data: { id: id || '', typ: 'img' }, success: (response) => {
url: $ocms.url(u), data: { id: id || '', typ: 'img' }, timeout: $inv.pdfPreviewTimeout, success: (response) => {
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id;
$.each(response.img || [], function (ii, img) {
$$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px'));
@@ -2105,13 +2131,13 @@ $inv.disp = (id, typ) => {
$ocms.dlg(c, {
size: [vhr, Math.round(vw() * 0.88)], zindex_min: 50, form: false, exclusive:false
});
}
}, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete()
});
}
};
$inv.jdbn = function (ev) {
$ocms.postXT({
url: $ocms.url('inv/rdocn'), data: { name: ev.data.id || '', typ: 'img' }, success: (response) => {
url: $ocms.url('inv/rdocn'), data: { name: ev.data.id || '', typ: 'img' }, timeout: $inv.pdfPreviewTimeout, success: (response) => {
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id;
$.each(response.img || [], function (ii, img) {
$$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px'));
@@ -2119,7 +2145,7 @@ $inv.jdbn = function (ev) {
$ocms.dlg(c, {
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false
});
}
}, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete()
});
};
$inv.sp13b = () => {
File diff suppressed because one or more lines are too long
+27 -7
View File
@@ -92,21 +92,41 @@ public class FdsMfrClient : IDisposable
foreach (DataRow nprw in dset.Tables("navigation_properties").Rows)
NavProperties[nprw.nz("name").ToLower()] = nprw.toStringDictionary();
var sqlList = new List<string> { NewDatatableSql(EntityTableName) };
var tableNames = new List<string> { EntityTableName };
foreach (DataRow ttrw in dset.Tables("tables").Rows)
// When mfr__getSchema itself failed (deadlock victim / timeout) there is no table name
// to build the TOP(0) shape queries from. Running them anyway produced a second, misleading
// "FROM [dbo].[]" error per sync and hid the real cause; the caller sees IsValid=false.
var shapeTables = ShapeTableNames(EntityTableName, dset.Tables("tables").Rows);
if (shapeTables.Length == 0)
{
sqlList.Add(NewDatatableSql(ttrw.nz("tablename")));
tableNames.Add(ttrw.nz("tablename"));
logger?.LogWarning("DatabaseSchema for {Entity}: schema query returned no entity table (exception={Exception}); skipping table-shape load",
ThisEntityName, dset.Exception);
_tableSet = new DataSet();
return;
}
_tableSet = Task.Run(async () => await getSQLDataSet_async(
string.Join(Environment.NewLine, sqlList),
string.Join(Environment.NewLine, shapeTables.Select(NewDatatableSql)),
FdsShared.FDSConnectionString(),
tablenames: tableNames.ToArray(),
tablenames: shapeTables,
options: new FdsSqlOptions(logger: logger))).Result.DataSet;
}
/// <summary>
/// Entity table plus its navigation tables, without blanks: the list of tables whose
/// (empty) shape is loaded. Empty when the entity table itself is unknown.
/// </summary>
internal static string[] ShapeTableNames(string entityTableName, DataRowCollection navTables)
{
if (string.IsNullOrWhiteSpace(entityTableName)) return Array.Empty<string>();
var names = new List<string> { entityTableName };
foreach (DataRow row in navTables)
{
string tbl = row.nz("tablename");
if (!string.IsNullOrWhiteSpace(tbl) && !names.Contains(tbl)) names.Add(tbl);
}
return names.ToArray();
}
public DataSet TgtDataset(string setId)
{
var dset = new DataSet();
+6
View File
@@ -16,6 +16,12 @@
<member name="F:fds.FdsMfr.InvoiceFileDownloadConcurrency">
<summary>Max parallel invoice-file downloads (independent per file).</summary>
</member>
<member name="M:fds.FdsMfrClient.DatabaseSchema.ShapeTableNames(System.String,System.Data.DataRowCollection)">
<summary>
Entity table plus its navigation tables, without blanks: the list of tables whose
(empty) shape is loaded. Empty when the entity table itself is unknown.
</summary>
</member>
<member name="T:fds.FdsConfig">
<summary>
Holds the application <see cref="T:Microsoft.Extensions.Configuration.IConfiguration"/> supplied by the host (the Fuchs web app).
+1 -1
Submodule OCORE updated: 9d0e088ab3...d249bb3b2a