Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1e6002c3b | ||
|
|
fce83c528f |
@@ -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"));
|
||||
if (doc == null) return DraftGone();
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(doc);
|
||||
return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||
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);
|
||||
}
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(_reminders.GenerateReminderPdf(fdRem, fdRem.IsDraft));
|
||||
return await JSONAsync(new { id = fdRem.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||
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"));
|
||||
if (doc == null) return DraftGone();
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(doc);
|
||||
return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||
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");
|
||||
}
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(await _invoices.GenerateInvoicePdfAsync(fdInv, fdInv.IsDraft));
|
||||
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||
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)
|
||||
|
||||
@@ -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`.
|
||||
@@ -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 =
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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 = () => {
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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();
|
||||
|
||||
@@ -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
+1
-1
Submodule OCORE_web updated: 5531d95924...d5021033fe
Reference in New Issue
Block a user