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)
|
private async Task<IActionResult> HandleDraftPreview(string fn, string id, string code)
|
||||||
{
|
{
|
||||||
if (!HasForm("token")) return BadRequest400();
|
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();
|
if (doc == null) return DraftGone();
|
||||||
var imgcol = await _pdf.DocToImageCollectionAsync(doc);
|
var imgcol = await _pdf.DocToImageCollectionAsync(doc);
|
||||||
return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST inv/dsave — { token } → { ok, invid }; flush cache→DB + business event + draftReady
|
// 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}",
|
_logger.LogDebug("HandleReportDoc found reportid={ReportId} fileName={FileName} mimeType={MimeType} size={Size} user={User}",
|
||||||
reportid, file.Name, file.MimeType(), content?.Length ?? 0, UserAccountID);
|
reportid, file.Name, file.MimeType(), content?.Length ?? 0, UserAccountID);
|
||||||
return Form("typ") != "img"
|
if (Form("typ") != "img")
|
||||||
? await FileContentResultAsync(content!, file.MimeType(), file.Name)
|
return await FileContentResultAsync(content!, file.MimeType(), file.Name);
|
||||||
: await JSONAsync(new { id = reportid, img = await BuildPdfImageArray(content!) });
|
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)
|
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);
|
_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 StatusCode(404, new { error = "Dokument wurde nicht gefunden" });
|
||||||
}
|
}
|
||||||
return Form("typ") != "img"
|
if (Form("typ") != "img")
|
||||||
? await FileContentResultAsync(fc, file.MimeType(), file.Name)
|
return await FileContentResultAsync(fc, file.MimeType(), file.Name);
|
||||||
: await JSONAsync(new { id = Form("id"), img = await BuildPdfImageArray(fc) });
|
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);
|
case "idoc": return await HandleReminderIdoc(fn, id, code);
|
||||||
@@ -211,8 +213,11 @@ public partial class IntranetController
|
|||||||
: _pdf.DocToPdfBytes(_reminders.GenerateReminderPdf(fdRem, fdRem.IsDraft));
|
: _pdf.DocToPdfBytes(_reminders.GenerateReminderPdf(fdRem, fdRem.IsDraft));
|
||||||
return await FileContentResultAsync(ct, "application/pdf", filename, inline: true);
|
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));
|
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 JSONAsync(new { id = fdRem.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<IActionResult> HandleReminderResend(string fn, string id, string code)
|
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)
|
private async Task<IActionResult> HandleReminderDraftPreview(string fn, string id, string code)
|
||||||
{
|
{
|
||||||
if (!HasForm("token")) return BadRequest400();
|
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();
|
if (doc == null) return DraftGone();
|
||||||
var imgcol = await _pdf.DocToImageCollectionAsync(doc);
|
var imgcol = await _pdf.DocToImageCollectionAsync(doc);
|
||||||
return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST rem/dsave — { token } → { ok, remid }; flush cache→DB + business event + draftReady
|
// POST rem/dsave — { token } → { ok, remid }; flush cache→DB + business event + draftReady
|
||||||
|
|||||||
@@ -405,8 +405,11 @@ public partial class IntranetController
|
|||||||
SetInlinePdfFilename(filename);
|
SetInlinePdfFilename(filename);
|
||||||
return File(ct, "application/pdf");
|
return File(ct, "application/pdf");
|
||||||
}
|
}
|
||||||
|
return await PdfPreviewAsync("req/idoc", fdInv.Id, async () =>
|
||||||
|
{
|
||||||
var imgcol = await _pdf.DocToImageCollectionAsync(await _invoices.GenerateInvoicePdfAsync(fdInv, fdInv.IsDraft));
|
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 JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<IActionResult> HandleRequestResend(string fn, string id, string code)
|
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 ────────────────────────────────────────────────────────
|
// ── Action helpers ────────────────────────────────────────────────────────
|
||||||
protected IActionResult Unauthorized401() => StatusCode(401);
|
protected IActionResult Unauthorized401() => StatusCode(401);
|
||||||
protected IActionResult BadRequest400() => BadRequest();
|
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 /) ─────────────────────────────────────────────────────────
|
// ── Index (GET /) ─────────────────────────────────────────────────────────
|
||||||
[AllowAnonymous]
|
[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}",
|
_logger.LogError(ex, "Unhandled exception in Do fn={Fn} id={Id} code={Code} user={User}",
|
||||||
fn, id, code, UserAccountID);
|
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
|
// 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
|
// 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)
|
// 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,
|
UserAccountID,
|
||||||
new Dictionary<string, object?> { ["fn"] = fn });
|
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 ───────────────────────────────────────────────────────────
|
// ── Auth helper ───────────────────────────────────────────────────────────
|
||||||
private async Task<IActionResult> HandleAuth(string fn, string id, string code)
|
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 =
|
public static readonly Counter<long> InvoiceInlineImageFetches =
|
||||||
Meter.CreateCounter<long>("fuchs.invoice.inlineimages.fetches", "{fetch}",
|
Meter.CreateCounter<long>("fuchs.invoice.inlineimages.fetches", "{fetch}",
|
||||||
"Invoice-text image fetches for inline PDF rendering (ADR 0018), tagged by outcome.");
|
"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) ───────────────────
|
// ── Performance histograms (durations in milliseconds) ───────────────────
|
||||||
public static readonly Histogram<double> PdfRenderDuration =
|
public static readonly Histogram<double> PdfRenderDuration =
|
||||||
|
|||||||
@@ -229,13 +229,42 @@ public class FuchsUserIdentity
|
|||||||
public class FIS_SQLOptions : sqloptions
|
public class FIS_SQLOptions : sqloptions
|
||||||
{
|
{
|
||||||
public FIS_SQLOptions(Dictionary<string, object>? context = null, ILogger? logger = null)
|
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) =>
|
OnError = (procedure, ex, data) =>
|
||||||
{
|
{
|
||||||
logger?.LogError(ex, "SQL error in {Procedure}: {Message} — context={@Context}",
|
// OCORE hands the failing statement in `data` (new { sql = ... }); `context` is the
|
||||||
procedure, ex.Message, context);
|
// caller's fn/id/code. Both are needed to attribute a timeout in the DB log — logging
|
||||||
try { FuchsOcmsIntranet.Instance.debug_log($"SQL Error in {procedure}", ex, data: context); }
|
// 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 { }
|
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 */
|
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) ───────────
|
/* ── Backend-authoritative draft editing controller (ADR 0006/0007) ───────────
|
||||||
The server holds the truth for an invoice draft in an in-memory session; this
|
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 +
|
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; } }
|
if ($fis.ValidateEmail(email) === false) { if (bool(confirm($ict.ivE + $ict.ivEc), false) === false) { return; } }
|
||||||
l.aC('freeze');
|
l.aC('freeze');
|
||||||
$ocms.postXT({
|
$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');
|
l.rC('freeze');
|
||||||
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), total = response.total;
|
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), total = response.total;
|
||||||
if (total > 10) { $$.dc('note warn', c).text($ict.tpe); }
|
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(); } }
|
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. */
|
/* 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; } }
|
if ($fis.ValidateEmail(email) === false) { if (bool(confirm($ict.ivE + $ict.ivEc), false) === false) { return; } }
|
||||||
l.aC('freeze');
|
l.aC('freeze');
|
||||||
$ocms.postXT({
|
$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');
|
l.rC('freeze');
|
||||||
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88);
|
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')); });
|
$.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(); } }
|
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. */
|
/* Zwischenspeichern: flush the cache to the DB (no re-upload); stay in the editor. */
|
||||||
@@ -1540,7 +1563,7 @@ $inv.disp = (id, typ) => {
|
|||||||
}
|
}
|
||||||
if (u !== '') {
|
if (u !== '') {
|
||||||
$ocms.postXT({
|
$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;
|
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id;
|
||||||
$.each(response.img || [], function (ii, img) {
|
$.each(response.img || [], function (ii, img) {
|
||||||
$$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px'));
|
$$.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, {
|
$ocms.dlg(c, {
|
||||||
size: [vhr, Math.round(vw() * 0.88)], zindex_min: 50, form: false, exclusive:false
|
size: [vhr, Math.round(vw() * 0.88)], zindex_min: 50, form: false, exclusive:false
|
||||||
});
|
});
|
||||||
}
|
}, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
$inv.jdbn = function (ev) {
|
$inv.jdbn = function (ev) {
|
||||||
$ocms.postXT({
|
$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;
|
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id;
|
||||||
$.each(response.img || [], function (ii, img) {
|
$.each(response.img || [], function (ii, img) {
|
||||||
$$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px'));
|
$$.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, {
|
$ocms.dlg(c, {
|
||||||
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false
|
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false
|
||||||
});
|
});
|
||||||
}
|
}, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete()
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
$inv.sp13b = () => {
|
$inv.sp13b = () => {
|
||||||
|
|||||||
@@ -38,6 +38,9 @@
|
|||||||
pna: 'Diese Seite ist in der Vorschau nicht verfügbar',
|
pna: 'Diese Seite ist in der Vorschau nicht verfügbar',
|
||||||
tpe: 'Die Anzahl von {0} Seiten wird aktuell nicht unterstützt',
|
tpe: 'Die Anzahl von {0} Seiten wird aktuell nicht unterstützt',
|
||||||
eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.',
|
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.',
|
iss: 'Zwischenstand speichern.',
|
||||||
p13b: 'USt -> §13b',
|
p13b: 'USt -> §13b',
|
||||||
setm: 'Set-Preisanzeige',
|
setm: 'Set-Preisanzeige',
|
||||||
|
|||||||
@@ -162,6 +162,9 @@ let $ict = {
|
|||||||
pna: 'Diese Seite ist in der Vorschau nicht verfügbar',
|
pna: 'Diese Seite ist in der Vorschau nicht verfügbar',
|
||||||
tpe: 'Die Anzahl von {0} Seiten wird aktuell nicht unterstützt',
|
tpe: 'Die Anzahl von {0} Seiten wird aktuell nicht unterstützt',
|
||||||
eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.',
|
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.',
|
iss: 'Zwischenstand speichern.',
|
||||||
p13b: 'USt -> §13b',
|
p13b: 'USt -> §13b',
|
||||||
setm: 'Set-Preisanzeige',
|
setm: 'Set-Preisanzeige',
|
||||||
@@ -692,6 +695,29 @@ $inv.eM = (r, re, opt) => {
|
|||||||
}
|
}
|
||||||
return $('#topbar').ocmsmenu(m); /* empty Array => empty menu */
|
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) ───────────
|
/* ── Backend-authoritative draft editing controller (ADR 0006/0007) ───────────
|
||||||
The server holds the truth for an invoice draft in an in-memory session; this
|
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 +
|
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; } }
|
if ($fis.ValidateEmail(email) === false) { if (bool(confirm($ict.ivE + $ict.ivEc), false) === false) { return; } }
|
||||||
l.aC('freeze');
|
l.aC('freeze');
|
||||||
$ocms.postXT({
|
$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');
|
l.rC('freeze');
|
||||||
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), total = response.total;
|
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), total = response.total;
|
||||||
if (total > 10) { $$.dc('note warn', c).text($ict.tpe); }
|
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(); } }
|
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. */
|
/* 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; } }
|
if ($fis.ValidateEmail(email) === false) { if (bool(confirm($ict.ivE + $ict.ivEc), false) === false) { return; } }
|
||||||
l.aC('freeze');
|
l.aC('freeze');
|
||||||
$ocms.postXT({
|
$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');
|
l.rC('freeze');
|
||||||
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88);
|
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')); });
|
$.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(); } }
|
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. */
|
/* Zwischenspeichern: flush the cache to the DB (no re-upload); stay in the editor. */
|
||||||
@@ -2116,7 +2142,7 @@ $inv.disp = (id, typ) => {
|
|||||||
}
|
}
|
||||||
if (u !== '') {
|
if (u !== '') {
|
||||||
$ocms.postXT({
|
$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;
|
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id;
|
||||||
$.each(response.img || [], function (ii, img) {
|
$.each(response.img || [], function (ii, img) {
|
||||||
$$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px'));
|
$$.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, {
|
$ocms.dlg(c, {
|
||||||
size: [vhr, Math.round(vw() * 0.88)], zindex_min: 50, form: false, exclusive:false
|
size: [vhr, Math.round(vw() * 0.88)], zindex_min: 50, form: false, exclusive:false
|
||||||
});
|
});
|
||||||
}
|
}, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
$inv.jdbn = function (ev) {
|
$inv.jdbn = function (ev) {
|
||||||
$ocms.postXT({
|
$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;
|
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id;
|
||||||
$.each(response.img || [], function (ii, img) {
|
$.each(response.img || [], function (ii, img) {
|
||||||
$$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px'));
|
$$.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, {
|
$ocms.dlg(c, {
|
||||||
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false
|
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false
|
||||||
});
|
});
|
||||||
}
|
}, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete()
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
$inv.sp13b = () => {
|
$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',
|
pna: 'Diese Seite ist in der Vorschau nicht verfügbar',
|
||||||
tpe: 'Die Anzahl von {0} Seiten wird aktuell nicht unterstützt',
|
tpe: 'Die Anzahl von {0} Seiten wird aktuell nicht unterstützt',
|
||||||
eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.',
|
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.',
|
iss: 'Zwischenstand speichern.',
|
||||||
p13b: 'USt -> §13b',
|
p13b: 'USt -> §13b',
|
||||||
setm: 'Set-Preisanzeige',
|
setm: 'Set-Preisanzeige',
|
||||||
@@ -673,6 +676,29 @@ $inv.eM = (r, re, opt) => {
|
|||||||
}
|
}
|
||||||
return $('#topbar').ocmsmenu(m); /* empty Array => empty menu */
|
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) ───────────
|
/* ── Backend-authoritative draft editing controller (ADR 0006/0007) ───────────
|
||||||
The server holds the truth for an invoice draft in an in-memory session; this
|
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 +
|
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; } }
|
if ($fis.ValidateEmail(email) === false) { if (bool(confirm($ict.ivE + $ict.ivEc), false) === false) { return; } }
|
||||||
l.aC('freeze');
|
l.aC('freeze');
|
||||||
$ocms.postXT({
|
$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');
|
l.rC('freeze');
|
||||||
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), total = response.total;
|
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), total = response.total;
|
||||||
if (total > 10) { $$.dc('note warn', c).text($ict.tpe); }
|
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(); } }
|
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. */
|
/* 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; } }
|
if ($fis.ValidateEmail(email) === false) { if (bool(confirm($ict.ivE + $ict.ivEc), false) === false) { return; } }
|
||||||
l.aC('freeze');
|
l.aC('freeze');
|
||||||
$ocms.postXT({
|
$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');
|
l.rC('freeze');
|
||||||
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88);
|
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')); });
|
$.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(); } }
|
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. */
|
/* Zwischenspeichern: flush the cache to the DB (no re-upload); stay in the editor. */
|
||||||
@@ -2097,7 +2123,7 @@ $inv.disp = (id, typ) => {
|
|||||||
}
|
}
|
||||||
if (u !== '') {
|
if (u !== '') {
|
||||||
$ocms.postXT({
|
$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;
|
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id;
|
||||||
$.each(response.img || [], function (ii, img) {
|
$.each(response.img || [], function (ii, img) {
|
||||||
$$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px'));
|
$$.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, {
|
$ocms.dlg(c, {
|
||||||
size: [vhr, Math.round(vw() * 0.88)], zindex_min: 50, form: false, exclusive:false
|
size: [vhr, Math.round(vw() * 0.88)], zindex_min: 50, form: false, exclusive:false
|
||||||
});
|
});
|
||||||
}
|
}, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
$inv.jdbn = function (ev) {
|
$inv.jdbn = function (ev) {
|
||||||
$ocms.postXT({
|
$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;
|
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id;
|
||||||
$.each(response.img || [], function (ii, img) {
|
$.each(response.img || [], function (ii, img) {
|
||||||
$$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px'));
|
$$.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, {
|
$ocms.dlg(c, {
|
||||||
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false
|
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false
|
||||||
});
|
});
|
||||||
}
|
}, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete()
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
$inv.sp13b = () => {
|
$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)
|
foreach (DataRow nprw in dset.Tables("navigation_properties").Rows)
|
||||||
NavProperties[nprw.nz("name").ToLower()] = nprw.toStringDictionary();
|
NavProperties[nprw.nz("name").ToLower()] = nprw.toStringDictionary();
|
||||||
|
|
||||||
var sqlList = new List<string> { NewDatatableSql(EntityTableName) };
|
// When mfr__getSchema itself failed (deadlock victim / timeout) there is no table name
|
||||||
var tableNames = new List<string> { EntityTableName };
|
// to build the TOP(0) shape queries from. Running them anyway produced a second, misleading
|
||||||
foreach (DataRow ttrw in dset.Tables("tables").Rows)
|
// "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")));
|
logger?.LogWarning("DatabaseSchema for {Entity}: schema query returned no entity table (exception={Exception}); skipping table-shape load",
|
||||||
tableNames.Add(ttrw.nz("tablename"));
|
ThisEntityName, dset.Exception);
|
||||||
|
_tableSet = new DataSet();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_tableSet = Task.Run(async () => await getSQLDataSet_async(
|
_tableSet = Task.Run(async () => await getSQLDataSet_async(
|
||||||
string.Join(Environment.NewLine, sqlList),
|
string.Join(Environment.NewLine, shapeTables.Select(NewDatatableSql)),
|
||||||
FdsShared.FDSConnectionString(),
|
FdsShared.FDSConnectionString(),
|
||||||
tablenames: tableNames.ToArray(),
|
tablenames: shapeTables,
|
||||||
options: new FdsSqlOptions(logger: logger))).Result.DataSet;
|
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)
|
public DataSet TgtDataset(string setId)
|
||||||
{
|
{
|
||||||
var dset = new DataSet();
|
var dset = new DataSet();
|
||||||
|
|||||||
@@ -16,6 +16,12 @@
|
|||||||
<member name="F:fds.FdsMfr.InvoiceFileDownloadConcurrency">
|
<member name="F:fds.FdsMfr.InvoiceFileDownloadConcurrency">
|
||||||
<summary>Max parallel invoice-file downloads (independent per file).</summary>
|
<summary>Max parallel invoice-file downloads (independent per file).</summary>
|
||||||
</member>
|
</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">
|
<member name="T:fds.FdsConfig">
|
||||||
<summary>
|
<summary>
|
||||||
Holds the application <see cref="T:Microsoft.Extensions.Configuration.IConfiguration"/> supplied by the host (the Fuchs web app).
|
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