Refactor code structure for improved readability and maintainability
Playwright Tests / test (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
This commit is contained in:
Vendored
+2
-1
@@ -3,6 +3,7 @@
|
||||
"dotnet run": true,
|
||||
"dotnet test": true,
|
||||
"dotnet build": true,
|
||||
"npx gulp": true
|
||||
"npx gulp": true,
|
||||
"ForEach-Object": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Fuchs.Notifications;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="EventService"/>, the single point every server-side
|
||||
/// operation goes through to notify the user. Covers the two failure methods the
|
||||
/// exception safety nets rely on (<c>UserIssueAsync</c> from
|
||||
/// <c>IntranetController.Do</c>'s catch-all, and <c>InvoiceIssueAsync</c> from
|
||||
/// <c>HandleInvoiceGet</c>) rendering as <c>"error"</c> notifications, plus a
|
||||
/// contrasting success path rendering as <c>"info"</c> — see ADR 0003.
|
||||
/// </summary>
|
||||
public class EventServiceTests
|
||||
{
|
||||
// ── Test doubles: capture the GuiNotification pushed to Clients.All ─────────
|
||||
private sealed class CapturingClientProxy : IClientProxy
|
||||
{
|
||||
public string? Method { get; private set; }
|
||||
public object?[]? Args { get; private set; }
|
||||
|
||||
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Method = method;
|
||||
Args = args;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubHubClients : IHubClients
|
||||
{
|
||||
private readonly IClientProxy _all;
|
||||
public StubHubClients(IClientProxy all) => _all = all;
|
||||
public IClientProxy All => _all;
|
||||
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => throw new System.NotImplementedException();
|
||||
public IClientProxy Client(string connectionId) => throw new System.NotImplementedException();
|
||||
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => throw new System.NotImplementedException();
|
||||
public IClientProxy Group(string groupName) => throw new System.NotImplementedException();
|
||||
public IClientProxy Groups(IReadOnlyList<string> groupNames) => throw new System.NotImplementedException();
|
||||
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => throw new System.NotImplementedException();
|
||||
public IClientProxy User(string userId) => throw new System.NotImplementedException();
|
||||
public IClientProxy Users(IReadOnlyList<string> userIds) => throw new System.NotImplementedException();
|
||||
}
|
||||
|
||||
private sealed class StubHubContext : IHubContext<NotificationHub>
|
||||
{
|
||||
public StubHubContext(IHubClients clients) => Clients = clients;
|
||||
public IHubClients Clients { get; }
|
||||
public IGroupManager Groups => throw new System.NotImplementedException();
|
||||
}
|
||||
|
||||
private static (EventService svc, CapturingClientProxy proxy) CreateService()
|
||||
{
|
||||
var proxy = new CapturingClientProxy();
|
||||
var hub = new StubHubContext(new StubHubClients(proxy));
|
||||
return (new EventService(hub, NullLogger<EventService>.Instance), proxy);
|
||||
}
|
||||
|
||||
private static GuiNotification Captured(CapturingClientProxy proxy)
|
||||
{
|
||||
Assert.Equal("notification", proxy.Method);
|
||||
Assert.NotNull(proxy.Args);
|
||||
var arg = Assert.Single(proxy.Args!);
|
||||
return Assert.IsType<GuiNotification>(arg);
|
||||
}
|
||||
|
||||
// ── Failure paths the exception safety nets use ─────────────────────────────
|
||||
[Fact]
|
||||
public async Task UserIssueAsync_PublishesErrorNotificationWithMessage()
|
||||
{
|
||||
var (svc, proxy) = CreateService();
|
||||
|
||||
await svc.UserIssueAsync(
|
||||
"Aktion fehlgeschlagen",
|
||||
"Die Aktion konnte aufgrund eines unerwarteten Fehlers nicht abgeschlossen werden. Bitte versuchen Sie es erneut.",
|
||||
"user-42",
|
||||
new Dictionary<string, object?> { ["fn"] = "inv" });
|
||||
|
||||
var n = Captured(proxy);
|
||||
Assert.Equal("error", n.Severity);
|
||||
Assert.Equal("Aktion fehlgeschlagen", n.Title);
|
||||
Assert.Equal(DomainEventType.UserIssue.ToString(), n.Type);
|
||||
Assert.Contains("nicht abgeschlossen werden", n.Message);
|
||||
Assert.Equal("inv", n.Context["fn"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvoiceIssueAsync_PublishesErrorNotificationCarryingInvoiceId()
|
||||
{
|
||||
var (svc, proxy) = CreateService();
|
||||
|
||||
await svc.InvoiceIssueAsync(
|
||||
"Die Rechnung konnte aufgrund eines Fehlers nicht geladen werden.",
|
||||
"user-42",
|
||||
"INV-1001");
|
||||
|
||||
var n = Captured(proxy);
|
||||
Assert.Equal("error", n.Severity);
|
||||
Assert.Equal(DomainEventType.InvoiceCreationFailed.ToString(), n.Type);
|
||||
Assert.Equal("Die Rechnung konnte aufgrund eines Fehlers nicht geladen werden.", n.Message);
|
||||
Assert.Equal("INV-1001", n.Context["id"]);
|
||||
}
|
||||
|
||||
// ── Contrasting success path renders as info, not error ─────────────────────
|
||||
[Fact]
|
||||
public async Task InvoiceMarkedSentAsync_PublishesInfoNotification()
|
||||
{
|
||||
var (svc, proxy) = CreateService();
|
||||
|
||||
await svc.InvoiceMarkedSentAsync("INV-1001", "R2026-0001", "user-42");
|
||||
|
||||
var n = Captured(proxy);
|
||||
Assert.Equal("info", n.Severity);
|
||||
Assert.Contains("R2026-0001", n.Message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using System.Collections.Generic;
|
||||
using Fuchs.Controllers;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers <see cref="RequestValueHelper.Resolve"/>, which backs IntranetController's
|
||||
/// Form()/HasForm() helpers. Endpoints in _allowedGet (e.g. req/idoc, rem/idoc) are invoked via
|
||||
/// a plain GET (window.open with '?id=...'), so this must resolve from the query string without
|
||||
/// ever touching an IFormCollection built from a non-form request (that would previously throw
|
||||
/// InvalidOperationException in production - see Do() unhandled-exception log for fn=req id=idoc).
|
||||
/// </summary>
|
||||
public class RequestValueHelperTests
|
||||
{
|
||||
private static IFormCollection Form(params (string Key, string Value)[] pairs)
|
||||
{
|
||||
var dict = new Dictionary<string, StringValues>();
|
||||
foreach (var (key, value) in pairs) dict[key] = value;
|
||||
return new FormCollection(dict);
|
||||
}
|
||||
|
||||
private static IQueryCollection Query(params (string Key, string Value)[] pairs)
|
||||
{
|
||||
var dict = new Dictionary<string, StringValues>();
|
||||
foreach (var (key, value) in pairs) dict[key] = value;
|
||||
return new QueryCollection(dict);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_FormContentTypeWithKey_ReturnsFormValue()
|
||||
{
|
||||
string? result = RequestValueHelper.Resolve(
|
||||
hasFormContentType: true,
|
||||
form: Form(("id", "abc123")),
|
||||
query: Query(("id", "from-query")),
|
||||
key: "id");
|
||||
|
||||
Assert.Equal("abc123", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_NoFormContentType_FallsBackToQuery()
|
||||
{
|
||||
// Simulates a GET request opened via window.open('?id=...'): no Content-Type header,
|
||||
// so the (empty) form collection must not be consulted - only the query string.
|
||||
string? result = RequestValueHelper.Resolve(
|
||||
hasFormContentType: false,
|
||||
form: FormCollection.Empty,
|
||||
query: Query(("id", "7O32P")),
|
||||
key: "id");
|
||||
|
||||
Assert.Equal("7O32P", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_FormContentTypeButKeyMissingFromForm_FallsBackToQuery()
|
||||
{
|
||||
string? result = RequestValueHelper.Resolve(
|
||||
hasFormContentType: true,
|
||||
form: Form(("other", "value")),
|
||||
query: Query(("id", "7O32P")),
|
||||
key: "id");
|
||||
|
||||
Assert.Equal("7O32P", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_KeyMissingFromBoth_ReturnsNull()
|
||||
{
|
||||
string? result = RequestValueHelper.Resolve(
|
||||
hasFormContentType: true,
|
||||
form: Form(("other", "value")),
|
||||
query: Query(("other", "value")),
|
||||
key: "id");
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_NoFormContentTypeAndQueryEmpty_ReturnsNull()
|
||||
{
|
||||
string? result = RequestValueHelper.Resolve(
|
||||
hasFormContentType: false,
|
||||
form: FormCollection.Empty,
|
||||
query: QueryCollection.Empty,
|
||||
key: "id");
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
}
|
||||
@@ -240,11 +240,22 @@ public partial class IntranetController
|
||||
}
|
||||
|
||||
// ── Form helpers ─────────────────────────────────────────────────────────
|
||||
// Reads from the posted form when available, falling back to the query string. This
|
||||
// supports endpoints in _allowedGet (e.g. req/idoc, rem/idoc) that are invoked via a
|
||||
// plain GET (window.open with '?id=...'), where Request.Form has no Content-Type and
|
||||
// would otherwise throw InvalidOperationException.
|
||||
protected bool HasForm(params string[] keys) =>
|
||||
keys.All(k => Request.Form.ContainsKey(k) && !string.IsNullOrWhiteSpace(Request.Form[k]));
|
||||
keys.All(k => !string.IsNullOrWhiteSpace(FormValue(k)));
|
||||
|
||||
protected string Form(string key, string fallback = "") =>
|
||||
Request.Form.TryGetValue(key, out var v) ? v.ToString() : fallback;
|
||||
FormValue(key) ?? fallback;
|
||||
|
||||
private string? FormValue(string key) =>
|
||||
RequestValueHelper.Resolve(
|
||||
Request.HasFormContentType,
|
||||
Request.HasFormContentType ? Request.Form : Microsoft.AspNetCore.Http.FormCollection.Empty,
|
||||
Request.Query,
|
||||
key);
|
||||
|
||||
private static (DateTime? From, DateTime? To) BankingDateRange(System.Data.DataTable tbl)
|
||||
{
|
||||
|
||||
@@ -87,7 +87,13 @@ public partial class IntranetController
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "HandleInvoiceGet failed for id={InvoiceId} user={User}", Form("id"), UserAccountID);
|
||||
string invoiceId = Form("id");
|
||||
_logger.LogError(ex, "HandleInvoiceGet failed for id={InvoiceId} user={User}", invoiceId, UserAccountID);
|
||||
// This handler has its own catch (returns 500) and so never reaches the Do safety net;
|
||||
// notify the user here so a failed invoice load is not silently swallowed.
|
||||
await _events.InvoiceIssueAsync(
|
||||
"Die Rechnung konnte aufgrund eines Fehlers nicht geladen werden.",
|
||||
UserAccountID, invoiceId);
|
||||
return StatusCode(500);
|
||||
}
|
||||
}
|
||||
@@ -390,6 +396,20 @@ public partial class IntranetController
|
||||
var d = sitm.toObjectDictionary();
|
||||
double net = Convert.ToDouble(d.no("value_total", 0));
|
||||
double vat = Convert.ToDouble(d.no("vat", 0));
|
||||
double value = Convert.ToDouble(d.no("value", 0));
|
||||
string quantityStr = d.nz("Quantity");
|
||||
// quantityhours/UnitString reconstruct the hour-based quantity editor's raw
|
||||
// fields (e.g. "5 Std" -> quantityhours=5, UnitString="Std") from the persisted
|
||||
// "Quantity" string, mirroring the legacy fds__invoice_data ndic mapping — without
|
||||
// this, re-editing a reloaded hour-based item showed an empty quantity field.
|
||||
object quantityHours = "";
|
||||
if (value != 0 && !string.IsNullOrEmpty(quantityStr))
|
||||
{
|
||||
long qh = (long)(net / value);
|
||||
if (quantityStr.StartsWith(qh.ToString(CultureInfo.InvariantCulture) + " ", StringComparison.Ordinal))
|
||||
quantityHours = qh;
|
||||
}
|
||||
string unitString = !string.IsNullOrEmpty(quantityStr) ? quantityStr.RightFromFirst(" ") : "";
|
||||
itm.Add(new Dictionary<string, object?>
|
||||
{
|
||||
["Id"] = d["Id"],
|
||||
@@ -399,8 +419,11 @@ public partial class IntranetController
|
||||
["svcnet_val"] = d.no("value_service", 0),
|
||||
["net"] = d.no("value", 0),
|
||||
["quantity"] = d["Quantity"],
|
||||
["quantityhours"] = quantityHours,
|
||||
["UnitString"] = unitString,
|
||||
["Type"] = d["Type"],
|
||||
["Note"] = null,
|
||||
["NameOrNumber"] = "",
|
||||
["htmltext"] = d["Text"],
|
||||
["position"] = d["Position"],
|
||||
["SortOrder"] = d["SortOrder"]
|
||||
|
||||
@@ -22,7 +22,9 @@ public partial class IntranetController
|
||||
{
|
||||
case "get":
|
||||
{
|
||||
if (!HasForm("id")) return BadRequest400();
|
||||
if (!HasForm("id")) { _logger.LogWarning("Reminder get: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
|
||||
_logger.LogDebug("Reminder get: preparing reminder for invoice {InvId} type={Type} level={Level} user={User}",
|
||||
Form("id"), Form("type"), Form("level"), UserAccountID);
|
||||
var pl = StdParamlist(
|
||||
SQL_VarChar("@InvId", Form("id")),
|
||||
SQL_VarChar("@type", Form("type")),
|
||||
@@ -32,17 +34,21 @@ public partial class IntranetController
|
||||
_intranet.Intranet__SQLConnectionString, pl,
|
||||
tablenames: new[] { "rem" },
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
if (!string.IsNullOrEmpty(dset.Exception))
|
||||
_logger.LogError("Reminder get: SQL error for invoice {InvId}: {SqlError}, user={User}", Form("id"), dset.Exception, UserAccountID);
|
||||
return await JSONAsync(new { rm = dset.Table("rem").FirstRow.toObjectDictionary() });
|
||||
}
|
||||
|
||||
case "prep":
|
||||
{
|
||||
if (!HasForm("remc")) return BadRequest400();
|
||||
if (!HasForm("remc")) { _logger.LogWarning("Reminder prep: missing form field 'remc', user={User}", UserAccountID); return BadRequest400(); }
|
||||
_logger.LogInformation("Reminder prep: creating draft reminder, user={User}", UserAccountID);
|
||||
var ctd = JsonConvert.DeserializeObject(Form("remc"))!;
|
||||
var fdRem = await _reminders.RegisterReminderAsync(
|
||||
new FdsReminderData(ctd), change: false, remId: "", UserAccountID, DbSec);
|
||||
if (!string.IsNullOrEmpty(fdRem.Id))
|
||||
{
|
||||
_logger.LogInformation("Reminder prep: draft reminder {RemId} created, user={User}", fdRem.Id, UserAccountID);
|
||||
await _events.ReminderDraftCreatedAsync(fdRem, UserAccountID);
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(_reminders.GenerateReminderPdf(fdRem, fdRem.IsDraft));
|
||||
return await JSONAsync(new { id = fdRem.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||
@@ -54,7 +60,8 @@ public partial class IntranetController
|
||||
|
||||
case "srs":
|
||||
{
|
||||
if (!HasForm("id")) return BadRequest400();
|
||||
if (!HasForm("id")) { _logger.LogWarning("Reminder srs: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
|
||||
_logger.LogInformation("Reminder srs: marking reminder {RemId} as sent, user={User}", Form("id"), UserAccountID);
|
||||
var pl = StdParamlist(SQL_VarChar("@Id", Form("id")), SQL_Bit("@auto", false));
|
||||
var dt2 = await getSQLDataSet_async(
|
||||
"EXECUTE [dbo].[fds__setReminderSent] @Id, @auto, @authuser;",
|
||||
@@ -63,17 +70,25 @@ public partial class IntranetController
|
||||
if (string.IsNullOrEmpty(dt2.Exception))
|
||||
await _events.ReminderMarkedSentAsync(Form("id"), Form("id"), UserAccountID);
|
||||
else
|
||||
{
|
||||
_logger.LogError("Reminder srs: SQL error marking reminder {RemId} sent: {SqlError}, user={User}", Form("id"), dt2.Exception, UserAccountID);
|
||||
await _events.ReminderIssueAsync(
|
||||
$"Mahnung {Form("id")} konnte nicht als versandt markiert werden.",
|
||||
UserAccountID, Form("id"));
|
||||
}
|
||||
return string.IsNullOrEmpty(dt2.Exception) ? await JSONAsync(new { ok = true }) : StatusCode(500);
|
||||
}
|
||||
|
||||
case "rdoc":
|
||||
{
|
||||
if (!HasForm("id")) return BadRequest400();
|
||||
if (!HasForm("id")) { _logger.LogWarning("Reminder rdoc: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
|
||||
_logger.LogDebug("Reminder rdoc: fetching stored reminder document {RemId} typ={Typ} user={User}", Form("id"), Form("typ"), UserAccountID);
|
||||
var (file, fc) = await _reminders.GetStoredFileAsync(Form("id"), UserAccountID, DbSec);
|
||||
if (file == null || fc == null) return StatusCode(404, new { error = "Dokument wurde nicht gefunden" });
|
||||
if (file == null || fc == null)
|
||||
{
|
||||
_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) });
|
||||
@@ -84,13 +99,16 @@ public partial class IntranetController
|
||||
|
||||
case "lrem":
|
||||
{
|
||||
if (!HasForm("id")) return BadRequest400();
|
||||
if (!HasForm("id")) { _logger.LogWarning("Reminder lrem: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
|
||||
_logger.LogDebug("Reminder lrem: listing reminders for invoice {InvId} user={User}", Form("id"), UserAccountID);
|
||||
var dset = await getSQLDataSet_async(
|
||||
"EXECUTE [dbo].[fds__lookupReminders] @InvId, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
StdParamlist(SQL_VarChar("@InvId", Form("id"))),
|
||||
tablenames: new[] { "ov", "rem" },
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
if (!string.IsNullOrEmpty(dset.Exception))
|
||||
_logger.LogError("Reminder lrem: SQL error for invoice {InvId}: {SqlError}, user={User}", Form("id"), dset.Exception, UserAccountID);
|
||||
return await JSONAsync(new
|
||||
{
|
||||
ov = dset.Table("ov").FirstRow.toStringDictionary(),
|
||||
@@ -98,18 +116,23 @@ public partial class IntranetController
|
||||
});
|
||||
}
|
||||
|
||||
default: return await JSONAsync(new { ok = true });
|
||||
default:
|
||||
_logger.LogWarning("Do_Process_Reminder: unhandled action id={Id}, user={User}", id, UserAccountID);
|
||||
return await JSONAsync(new { ok = true });
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IActionResult> HandleReminderConf(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("id")) return BadRequest400();
|
||||
if (!HasForm("id")) { _logger.LogWarning("HandleReminderConf: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
|
||||
_logger.LogInformation("HandleReminderConf: finalizing reminder {RemId} user={User}", Form("id"), UserAccountID);
|
||||
var dt = await getSQLDatatable_async(
|
||||
"EXECUTE [dbo].[fds__setReminderFinal] @Id, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
StdParamlist(SQL_VarChar("@Id", Form("id"))),
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
if (!string.IsNullOrEmpty(dt.Exception))
|
||||
_logger.LogError("HandleReminderConf: SQL error finalizing reminder {RemId}: {SqlError}, user={User}", Form("id"), dt.Exception, UserAccountID);
|
||||
var frdic = dt.FirstRow.toObjectDictionary();
|
||||
if (frdic.TryGetValue("IsFinal", out var isFinal) && isFinal is true)
|
||||
{
|
||||
@@ -167,9 +190,10 @@ public partial class IntranetController
|
||||
|
||||
private async Task<IActionResult> HandleReminderIdoc(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("id") || string.IsNullOrEmpty(Form("id"))) return StatusCode(404);
|
||||
if (!HasForm("id") || string.IsNullOrEmpty(Form("id"))) { _logger.LogWarning("HandleReminderIdoc: missing/empty form field 'id', user={User}", UserAccountID); return StatusCode(404); }
|
||||
_logger.LogDebug("HandleReminderIdoc: reminderId={RemId} typ={Typ} create={Create} user={User}", Form("id"), Form("typ"), Form("create", "0"), UserAccountID);
|
||||
var fdRem = await _reminders.LoadReminderAsync(Form("id"), UserAccountID, DbSec);
|
||||
if (string.IsNullOrEmpty(fdRem.Id)) return StatusCode(404, new { error = "Erinnerung wurde nicht gefunden" });
|
||||
if (string.IsNullOrEmpty(fdRem.Id)) { _logger.LogWarning("HandleReminderIdoc: reminder not found id={RemId} user={User}", Form("id"), UserAccountID); return StatusCode(404, new { error = "Erinnerung wurde nicht gefunden" }); }
|
||||
string filename = fdRem.ReminderRegistration!.nz("DocumentName").ne($"Zahlungserinnerung_{fdRem.Id}.pdf");
|
||||
if (Form("typ") != "img")
|
||||
{
|
||||
@@ -184,7 +208,8 @@ public partial class IntranetController
|
||||
|
||||
private async Task<IActionResult> HandleReminderResend(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("id") || string.IsNullOrEmpty(Form("id"))) return StatusCode(404);
|
||||
if (!HasForm("id") || string.IsNullOrEmpty(Form("id"))) { _logger.LogWarning("HandleReminderResend: missing/empty form field 'id', user={User}", UserAccountID); return StatusCode(404); }
|
||||
_logger.LogInformation("HandleReminderResend: resending reminder {RemId} user={User}", Form("id"), UserAccountID);
|
||||
var pl = StdParamlist(SQL_VarChar("@Id", Form("id")), new SqlParameter("@includefile", true));
|
||||
var dset = await getSQLDataSet_async(
|
||||
"EXECUTE [dbo].[fds__getReminder] @Id, @includefile, @authuser;",
|
||||
|
||||
@@ -27,13 +27,16 @@ public partial class IntranetController
|
||||
|
||||
case "rthd":
|
||||
{
|
||||
if (!HasForm("id")) return BadRequest400();
|
||||
if (!HasForm("id")) { _logger.LogWarning("Requests rthd: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
|
||||
_logger.LogInformation("Requests rthd: toggling hidden state for request {ReqId}, user={User}", Form("id"), UserAccountID);
|
||||
var sqldt = await getSQLDatatable_async(
|
||||
"EXECUTE [dbo].[fds__toggleRequestHidden] @Id, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
StdParamlist(SQL_BigInt("@Id", Form("id"))),
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
if (sqldt.Count == 0) return StatusCode(404, new { error = "not found" });
|
||||
if (!string.IsNullOrEmpty(sqldt.Exception))
|
||||
_logger.LogError("Requests rthd: SQL error for request {ReqId}: {SqlError}, user={User}", Form("id"), sqldt.Exception, UserAccountID);
|
||||
if (sqldt.Count == 0) { _logger.LogWarning("Requests rthd: request {ReqId} not found, user={User}", Form("id"), UserAccountID); return StatusCode(404, new { error = "not found" }); }
|
||||
var dic = sqldt.FirstRow.toObjectDictionary();
|
||||
return await JSONAsync(new { id = dic["EntityId"], visible = dic.no("hidden", false) is not true });
|
||||
}
|
||||
@@ -45,12 +48,17 @@ public partial class IntranetController
|
||||
|
||||
case "save":
|
||||
{
|
||||
if (!HasForm("invc")) return BadRequest400();
|
||||
if (!HasForm("invc")) { _logger.LogWarning("Requests save: missing form field 'invc', user={User}", UserAccountID); return BadRequest400(); }
|
||||
bool saveChange = !string.IsNullOrEmpty(Form("id"));
|
||||
_logger.LogInformation("Requests save: saving invoice draft change={Change} invId={InvId} user={User}", saveChange, Form("id"), UserAccountID);
|
||||
var fdInv = await _invoices.RegisterInvoiceAsync(
|
||||
new FdsInvoiceData(JsonConvert.DeserializeObject(Form("invc"))!),
|
||||
change: !string.IsNullOrEmpty(Form("id")), invId: Form("id"), UserAccountID, DbSec);
|
||||
change: saveChange, invId: Form("id"), UserAccountID, DbSec);
|
||||
if (!string.IsNullOrEmpty(fdInv.Id))
|
||||
await _events.InvoiceDraftRegisteredAsync(fdInv, !string.IsNullOrEmpty(Form("id")), UserAccountID);
|
||||
{
|
||||
_logger.LogInformation("Requests save: invoice draft {InvId} saved, user={User}", fdInv.Id, UserAccountID);
|
||||
await _events.InvoiceDraftRegisteredAsync(fdInv, saveChange, UserAccountID);
|
||||
}
|
||||
return !string.IsNullOrEmpty(fdInv.Id)
|
||||
? await JSONAsync(new { id = fdInv.Id })
|
||||
: await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht gespeichert werden.");
|
||||
@@ -58,12 +66,14 @@ public partial class IntranetController
|
||||
|
||||
case "sprep":
|
||||
{
|
||||
if (!HasForm("invc")) return BadRequest400();
|
||||
if (!HasForm("invc")) { _logger.LogWarning("Requests sprep: missing form field 'invc', user={User}", UserAccountID); return BadRequest400(); }
|
||||
_logger.LogInformation("Requests sprep: preparing new invoice draft, user={User}", UserAccountID);
|
||||
var fdInv = await _invoices.RegisterInvoiceAsync(
|
||||
new FdsInvoiceData(JsonConvert.DeserializeObject(Form("invc"))!),
|
||||
change: false, invId: "", UserAccountID, DbSec);
|
||||
if (!string.IsNullOrEmpty(fdInv.Id))
|
||||
{
|
||||
_logger.LogInformation("Requests sprep: invoice draft {InvId} created, user={User}", fdInv.Id, UserAccountID);
|
||||
await _events.InvoiceDraftRegisteredAsync(fdInv, changed: false, userAccountId: UserAccountID);
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
|
||||
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||
@@ -73,12 +83,14 @@ public partial class IntranetController
|
||||
|
||||
case "sedit":
|
||||
{
|
||||
if (!HasForm("id", "invc")) return BadRequest400();
|
||||
if (!HasForm("id", "invc")) { _logger.LogWarning("Requests sedit: missing form field 'id'/'invc', user={User}", UserAccountID); return BadRequest400(); }
|
||||
_logger.LogInformation("Requests sedit: updating invoice draft {InvId}, user={User}", Form("id"), UserAccountID);
|
||||
var fdInv = await _invoices.RegisterInvoiceAsync(
|
||||
new FdsInvoiceData(JsonConvert.DeserializeObject(Form("invc"))!),
|
||||
change: true, invId: Form("id"), UserAccountID, DbSec);
|
||||
if (!string.IsNullOrEmpty(fdInv.Id))
|
||||
{
|
||||
_logger.LogInformation("Requests sedit: invoice draft {InvId} updated, user={User}", fdInv.Id, UserAccountID);
|
||||
await _events.InvoiceDraftRegisteredAsync(fdInv, changed: true, userAccountId: UserAccountID);
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
|
||||
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||
@@ -87,25 +99,36 @@ public partial class IntranetController
|
||||
}
|
||||
|
||||
case "sdel":
|
||||
if (!HasForm("id")) return BadRequest400();
|
||||
await setSQLValue_async("EXECUTE [dbo].[fds__remInvoice] @Id, @authuser;",
|
||||
{
|
||||
if (!HasForm("id")) { _logger.LogWarning("Requests sdel: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
|
||||
_logger.LogInformation("Requests sdel: deleting invoice draft {InvId}, user={User}", Form("id"), UserAccountID);
|
||||
var ok = await setSQLValue_async("EXECUTE [dbo].[fds__remInvoice] @Id, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
StdParamlist(SQL_VarChar("@Id", Form("id"))),
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
if (!ok)
|
||||
{
|
||||
_logger.LogError("Requests sdel: SQL failed deleting invoice draft {InvId}, user={User}", Form("id"), UserAccountID);
|
||||
return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht gelöscht werden.", Form("id"));
|
||||
}
|
||||
return await JSONAsync(new { ok = true });
|
||||
}
|
||||
|
||||
case "sconf": return await HandleRequestSconf(fn, id, code);
|
||||
case "idoc": return await HandleRequestIdoc(fn, id, code);
|
||||
case "resend": return await HandleRequestResend(fn, id, code);
|
||||
|
||||
default: return await JSONAsync(new { ok = true });
|
||||
default:
|
||||
_logger.LogWarning("Do_Process_Requests: unhandled action id={Id}, user={User}", id, UserAccountID);
|
||||
return await JSONAsync(new { ok = true });
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IActionResult> HandleRequestList(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("mode")) return BadRequest400();
|
||||
if (!HasForm("mode")) { _logger.LogWarning("HandleRequestList: missing form field 'mode', user={User}", UserAccountID); return BadRequest400(); }
|
||||
string mode = Form("mode").ToLower();
|
||||
_logger.LogDebug("HandleRequestList mode={Mode} tgt={Tgt} user={User}", mode, Form("tgt"), UserAccountID);
|
||||
if (mode == "s" && Form("tgt").Contains(':'))
|
||||
{
|
||||
var pl = StdParamlist(
|
||||
@@ -126,7 +149,10 @@ public partial class IntranetController
|
||||
}
|
||||
if (!DateTime.TryParseExact(Form("tgt"), "yy-MM-dd",
|
||||
CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out var tgtdate))
|
||||
{
|
||||
_logger.LogWarning("HandleRequestList: invalid date format tgt='{Tgt}' user={User}", Form("tgt"), UserAccountID);
|
||||
return BadRequest400();
|
||||
}
|
||||
{
|
||||
var pl = StdParamlist(
|
||||
SQL_Date("@tgtdate", tgtdate),
|
||||
@@ -204,7 +230,8 @@ public partial class IntranetController
|
||||
|
||||
private async Task<IActionResult> HandleRequestGet(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("id")) return BadRequest400();
|
||||
if (!HasForm("id")) { _logger.LogWarning("HandleRequestGet: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
|
||||
_logger.LogDebug("HandleRequestGet requestId={ReqId} mode={Mode} user={User}", Form("id"), Form("mode"), UserAccountID);
|
||||
string modeVal = Form("mode").ne("ov");
|
||||
string[] tn = modeVal switch
|
||||
{
|
||||
@@ -231,7 +258,8 @@ public partial class IntranetController
|
||||
|
||||
private async Task<IActionResult> HandleRequestIget(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("id", "typ")) return BadRequest400();
|
||||
if (!HasForm("id", "typ")) { _logger.LogWarning("HandleRequestIget: missing form field 'id'/'typ', user={User}", UserAccountID); return BadRequest400(); }
|
||||
_logger.LogDebug("HandleRequestIget requestId={ReqId} typ={Typ} mode={Mode} user={User}", Form("id"), Form("typ"), Form("mode"), UserAccountID);
|
||||
var pl = StdParamlist(
|
||||
SQL_BigInt("@servicerequestid", Form("id")),
|
||||
SQL_VarChar("@mode", Form("mode").ne("ov")),
|
||||
@@ -273,12 +301,15 @@ public partial class IntranetController
|
||||
|
||||
private async Task<IActionResult> HandleRequestSconf(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("id")) return BadRequest400();
|
||||
if (!HasForm("id")) { _logger.LogWarning("HandleRequestSconf: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
|
||||
_logger.LogInformation("HandleRequestSconf: finalizing invoice {InvId} user={User}", Form("id"), UserAccountID);
|
||||
var dt = await getSQLDatatable_async(
|
||||
"EXECUTE [dbo].[fds__setInvoiceFinal] @Id, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
StdParamlist(SQL_VarChar("@Id", Form("id"))),
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
if (!string.IsNullOrEmpty(dt.Exception))
|
||||
_logger.LogError("HandleRequestSconf: SQL error finalizing invoice {InvId}: {SqlError}, user={User}", Form("id"), dt.Exception, UserAccountID);
|
||||
var frdic = dt.FirstRow.toObjectDictionary();
|
||||
if (frdic.TryGetValue("IsFinal", out var isFinal) && isFinal is true)
|
||||
{
|
||||
@@ -331,16 +362,19 @@ public partial class IntranetController
|
||||
$"Die Rechnungs-PDF {frdic.nz("DocumentName").ne($"Rechnung_{invId}.pdf")} konnte nicht erstellt werden.",
|
||||
UserAccountID, invId);
|
||||
}
|
||||
return await JSONAsync(new { ok = true });
|
||||
// hasFile tells the frontend whether the PDF was actually stored, so it only opens the
|
||||
// idoc preview popup when there is something to show (never on a failed render/store).
|
||||
return await JSONAsync(new { ok = true, hasFile = filebyte.Length > 0 });
|
||||
}
|
||||
return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht erstellt werden.");
|
||||
}
|
||||
|
||||
private async Task<IActionResult> HandleRequestIdoc(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("id") || string.IsNullOrEmpty(Form("id"))) return StatusCode(404);
|
||||
if (!HasForm("id") || string.IsNullOrEmpty(Form("id"))) { _logger.LogWarning("HandleRequestIdoc: missing/empty form field 'id', user={User}", UserAccountID); return StatusCode(404); }
|
||||
_logger.LogDebug("HandleRequestIdoc: invoiceId={InvId} typ={Typ} create={Create} user={User}", Form("id"), Form("typ"), Form("create", "0"), UserAccountID);
|
||||
var fdInv = await _invoices.LoadInvoiceAsync(Form("id"), UserAccountID, DbSec);
|
||||
if (string.IsNullOrEmpty(fdInv.Id)) return StatusCode(404, new { error = "Rechnung wurde nicht gefunden" });
|
||||
if (string.IsNullOrEmpty(fdInv.Id)) { _logger.LogWarning("HandleRequestIdoc: invoice not found id={InvId} user={User}", Form("id"), UserAccountID); return StatusCode(404, new { error = "Rechnung wurde nicht gefunden" }); }
|
||||
string filename = fdInv.InvoiceRegistration!.nz("DocumentName").ne($"Rechnung_{fdInv.Id}.pdf");
|
||||
if (Form("typ") != "img")
|
||||
{
|
||||
@@ -357,7 +391,8 @@ public partial class IntranetController
|
||||
|
||||
private async Task<IActionResult> HandleRequestResend(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("id") || string.IsNullOrEmpty(Form("id"))) return StatusCode(404);
|
||||
if (!HasForm("id") || string.IsNullOrEmpty(Form("id"))) { _logger.LogWarning("HandleRequestResend: missing/empty form field 'id', user={User}", UserAccountID); return StatusCode(404); }
|
||||
_logger.LogInformation("HandleRequestResend: resending invoice {InvId} user={User}", Form("id"), UserAccountID);
|
||||
var dtset = await getSQLDataSet_async(
|
||||
"EXECUTE [dbo].[fds__getInvoice] @Id, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
|
||||
@@ -174,6 +174,19 @@ 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);
|
||||
// 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)
|
||||
// issue event. Pre-auth flows (login/logout, unauthenticated GETs) are skipped — there
|
||||
// is no user session to notify and the HTTP status already conveys the failure.
|
||||
if (UserIdent.IsAuthenticated)
|
||||
{
|
||||
await _events.UserIssueAsync(
|
||||
"Aktion fehlgeschlagen",
|
||||
"Die Aktion konnte aufgrund eines unerwarteten Fehlers nicht abgeschlossen werden. Bitte versuchen Sie es erneut.",
|
||||
UserAccountID,
|
||||
new Dictionary<string, object?> { ["fn"] = fn });
|
||||
}
|
||||
return ServerError();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Fuchs.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a request value from the posted form, falling back to the query string.
|
||||
/// Extracted as pure/testable logic: endpoints in <c>_allowedGet</c> (e.g. req/idoc, rem/idoc)
|
||||
/// are invoked via a plain GET (window.open with '?id=...'), where <see cref="HttpRequest.Form"/>
|
||||
/// has no Content-Type and throws <see cref="InvalidOperationException"/> if read directly.
|
||||
/// </summary>
|
||||
internal static class RequestValueHelper
|
||||
{
|
||||
internal static string? Resolve(bool hasFormContentType, IFormCollection form, IQueryCollection query, string key)
|
||||
{
|
||||
if (hasFormContentType && form.TryGetValue(key, out var formValue))
|
||||
return formValue.ToString();
|
||||
if (query.TryGetValue(key, out var queryValue))
|
||||
return queryValue.ToString();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-07-08
|
||||
applyTo:
|
||||
- "Fuchs/Controllers/**"
|
||||
- "Fuchs/Services/**"
|
||||
- "Fuchs/Notifications/**"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0003 — Any exception that interrupts a user action notifies the user (not just the log)
|
||||
|
||||
## Context
|
||||
[0001](0001-domain-events-and-notification-triggers.md) requires the *modeled*
|
||||
failure paths (invoice/reminder/banking create, send, import) to publish a
|
||||
`*IssueAsync`/`*Failed` event. But an action can also fail through an
|
||||
**unexpected/unmodeled** exception — a bug, a transient dependency error, an
|
||||
edge case nobody wrote a specific failure event for. Those were only landing in
|
||||
the log (`_logger.LogError` + an HTTP 500), so the user saw the action stop with
|
||||
no explanation and no notification. The user asked that *whenever* an exception
|
||||
interrupts a process they initiated, they be told via the notification system.
|
||||
|
||||
## Decision
|
||||
Every exception that **interrupts a user-initiated action** must surface to the
|
||||
user through `IEventService`, in addition to being logged. Concretely:
|
||||
|
||||
- **Catch-all safety net at the dispatcher.** `IntranetController.Do`'s
|
||||
top-level `catch` publishes a generic `UserIssueAsync("Aktion fehlgeschlagen",
|
||||
…)` for any `Do_Process_*` action that throws without having already published
|
||||
its own (more specific) issue event. It is guarded by
|
||||
`UserIdent.IsAuthenticated` — pre-auth flows (login/logout, anonymous GETs)
|
||||
have no session to notify and the HTTP status already conveys the failure.
|
||||
- **Handlers with their own `catch` must notify locally.** A handler that
|
||||
swallows its exception (returns a 500/error result instead of rethrowing)
|
||||
never reaches the `Do` net, so it must call the matching issue event itself —
|
||||
e.g. `HandleInvoiceGet` calls `InvoiceIssueAsync` before returning 500. Prefer
|
||||
the domain-specific method (`InvoiceIssueAsync`/`ReminderIssueAsync`/
|
||||
`BankingImportIssueAsync`); fall back to `UserIssueAsync` when none fits.
|
||||
- **Message stays user-readable and broadcast-safe.** Per
|
||||
[0002](0002-gui-notification-delivery-signalr.md) notifications are broadcast
|
||||
to every logged-in session, so the German `Message`/`Context` must never carry
|
||||
the raw exception text or anything sensitive — diagnostics go to the log; the
|
||||
user gets a plain "could not be completed" message.
|
||||
|
||||
This deliberately **excludes** operations that do not interrupt a discrete user
|
||||
action: background/best-effort work (blob archiving, startup self-tests,
|
||||
per-entry parse skips) stays log-only, and auto-refreshing read views (dashboard
|
||||
widgets, report reloads) return their error status without a toast, because
|
||||
notifying on every poll cycle would spam the user rather than inform them.
|
||||
|
||||
## Consequences
|
||||
- New `catch` blocks on a request-handling path must be classified: does the
|
||||
exception interrupt a user action? If yes → publish an issue event (specific
|
||||
if one exists, else `UserIssueAsync`). If it is background/best-effort or an
|
||||
auto-poll read → log only, and say so in a comment.
|
||||
- The `Do` net is a backstop, not a replacement for specific events: modeled
|
||||
failures should still publish their contextful `*IssueAsync` at the point of
|
||||
failure so the message names the invoice/reminder/file involved.
|
||||
- Because the net only fires on *unhandled* exceptions (handled flows return
|
||||
rather than rethrow), it does not double-notify the flows that already report
|
||||
their own failures.
|
||||
|
||||
## Alternatives considered
|
||||
- **Rely solely on 0001's per-flow issue events**: rejected — it leaves every
|
||||
unmodeled/unexpected exception silent, which is exactly the gap the user
|
||||
reported.
|
||||
- **Notify on every failing read/poll too (widgets, reports)**: rejected —
|
||||
auto-refresh would turn a transient backend hiccup into a stream of toasts;
|
||||
those paths surface failure via HTTP status instead.
|
||||
- **Surface the raw exception message to the GUI**: rejected for the same
|
||||
reason as 0001 — not user-understandable, leaks internals, and (per 0002) is
|
||||
visible to every logged-in session.
|
||||
@@ -6,6 +6,7 @@ using Fuchs.Observability;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MigraDoc.DocumentObjectModel;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using OCORE.security;
|
||||
using OCORE.SQL;
|
||||
using static OCORE.commons;
|
||||
@@ -48,11 +49,13 @@ public class InvoiceService : IInvoiceService
|
||||
var pl = new List<SqlParameter>
|
||||
{
|
||||
SQL_VarChar("@authuser", userAccountId),
|
||||
SQL_VarChar("@Id", id),
|
||||
SQL_Bit("@includefile", false)
|
||||
SQL_VarChar("@Id", id)
|
||||
};
|
||||
// fds__getInvoice only takes @Id and @authuser (no @includefile) - passing an extra
|
||||
// parameter makes SQL Server throw "too many arguments", which previously left
|
||||
// InvoiceRegistration empty (Id "") and silently broke downstream file storage/emailing.
|
||||
var dset = await getSQLDataSet_async(
|
||||
"EXECUTE [dbo].[fds__getInvoice] @Id, @includefile, @authuser;",
|
||||
"EXECUTE [dbo].[fds__getInvoice] @Id, @authuser;",
|
||||
Conn, pl, tablenames: new[] { "admin", "inv", "req", "itm" },
|
||||
Security: dbSec, options: new FIS_SQLOptions());
|
||||
if (!string.IsNullOrEmpty(dset.Exception))
|
||||
@@ -93,16 +96,113 @@ public class InvoiceService : IInvoiceService
|
||||
}
|
||||
|
||||
var invdset = await getSQLDataSet_async(string.Join("\n", sqlParts),
|
||||
Conn, pl, tablenames: new[] { "inv", "det", "req", "itm" },
|
||||
Conn, pl, tablenames: new[] { "inv", "det" },
|
||||
Security: dbSec, options: new FIS_SQLOptions());
|
||||
if (!string.IsNullOrEmpty(invdset.Exception))
|
||||
_logger.LogError("RegisterInvoiceAsync sql exception: {Ex}", invdset.Exception);
|
||||
|
||||
invoice.InvoiceRegistration = new GenericObjectDictionary(invdset.Table("inv").FirstRow.toObjectDictionary());
|
||||
_logger.LogInformation("RegisterInvoiceAsync registered id={Id} (change={Change})", invoice.Id, change);
|
||||
|
||||
if (!string.IsNullOrEmpty(invoice.Id) && invoice.Req != null)
|
||||
await PersistInvoiceLineItemsAsync(invoice, userAccountId, dbSec);
|
||||
|
||||
return invoice;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists the invoice's service-request groupings and their line items
|
||||
/// (<c>fds__invoice_servicerequests</c> / <c>fds__invoice_items</c>). These are what
|
||||
/// <c>fds__getInvoice</c> reads back when a draft is reloaded (e.g. after "Zwischenstand
|
||||
/// speichern" or reopening a draft from the invoice list) — the preview/PDF itself renders
|
||||
/// straight from the posted JSON, so without this the preview looked fine right after
|
||||
/// posting but a reloaded draft showed no steps/positions at all (nothing had ever been
|
||||
/// written for the service requests/items, only the invoice header row).
|
||||
/// The whole set is cleared and rewritten on every call — simpler and safer than diffing —
|
||||
/// mirroring the legacy <c>fds__invoice_data.RegisterInvoice_V1</c> (VB) behaviour.
|
||||
/// </summary>
|
||||
private async Task PersistInvoiceLineItemsAsync(FdsInvoiceData invoice, string userAccountId, DatabaseSecurity dbSec)
|
||||
{
|
||||
string invoiceId = invoice.Id;
|
||||
var clearReqPl = new List<SqlParameter> { SQL_VarChar("@authuser", userAccountId), SQL_VarChar("@Id", invoiceId) };
|
||||
await setSQLValue_async("EXECUTE [dbo].[fds__remInvoice_ServiceRequests] @Id, @authuser;",
|
||||
Conn, clearReqPl, Security: dbSec, options: new FIS_SQLOptions());
|
||||
|
||||
var itemsTable = (await getSQLDatatable_async(
|
||||
"SELECT TOP(0) * FROM [dbo].[fds__invoice_items];",
|
||||
Conn, Security: dbSec, options: new FIS_SQLOptions())).DataTable;
|
||||
|
||||
int sortOrder = -1;
|
||||
foreach (var reqEntry in invoice.Req!)
|
||||
{
|
||||
sortOrder++; // 0-based, matching legacy fds__invoice_data.RegisterInvoice_V1 (VB)'s "ri" loop index
|
||||
var rdic = new GenericObjectDictionary(reqEntry);
|
||||
|
||||
float vnet = 0;
|
||||
var valueNetParam = TryCastSingle(rdic.getItem("netval"), ref vnet)
|
||||
? SQL_Float("@value_net", vnet)
|
||||
: SQL_Float("@value_net", stringvalue: rdic.nz("netval"));
|
||||
var reqPl = new List<SqlParameter>
|
||||
{
|
||||
SQL_VarChar("@authuser", userAccountId),
|
||||
SQL_VarChar("@InvId", invoiceId),
|
||||
SQL_BigInt("@mfr__servicerequest", rdic.nz("Id")),
|
||||
SQL_NVarChar("@title", rdic.nz("text").ne(rdic.nz("nme"))),
|
||||
valueNetParam,
|
||||
SQL_Int("@SortOrder", sortOrder)
|
||||
};
|
||||
var reqDt = await getSQLDatatable_async(
|
||||
"EXECUTE [dbo].[fds__createInvoice_ServiceRequest] @InvId, @mfr__servicerequest, @title, @value_net, @SortOrder;",
|
||||
Conn, reqPl, Security: dbSec, options: new FIS_SQLOptions());
|
||||
if (!string.IsNullOrEmpty(reqDt.Exception))
|
||||
_logger.LogError("PersistInvoiceLineItemsAsync: fds__createInvoice_ServiceRequest failed for invoice {Id}: {Ex}", invoiceId, reqDt.Exception);
|
||||
string invRqId = reqDt.FirstRow.nz("Id");
|
||||
if (string.IsNullOrEmpty(invRqId)) continue;
|
||||
|
||||
if (!rdic.TryGetValue("itm", out var itmObj) || itmObj is not JArray itmArray) continue;
|
||||
int itemSort = 0;
|
||||
foreach (var tok in itmArray)
|
||||
{
|
||||
itemSort++;
|
||||
if (tok is not JObject itmJson) continue;
|
||||
var itmDic = new GenericObjectDictionary(itmJson.ToObject<Dictionary<string, object>>()!);
|
||||
if ((itmDic.nz("id") ?? "") == "" && (itmDic.nz("typ") ?? "") == "" && (itmDic.nz("t") ?? "") == "") continue;
|
||||
|
||||
var row = itemsTable.NewRow();
|
||||
row["InvId"] = invoiceId;
|
||||
row["InvRqId"] = invRqId;
|
||||
if (long.TryParse(itmDic.nz("id"), out long mfrItem)) row["mfr__item"] = mfrItem;
|
||||
row["Type"] = itmDic.nz("typ");
|
||||
row["Position"] = itmDic.nz("p");
|
||||
row["Quantity"] = itmDic.nz("q");
|
||||
row["Text"] = itmDic.nz("t");
|
||||
float v = 0;
|
||||
if (TryCastSingle(itmDic.getItem("v"), ref v)) row["value"] = (decimal)v;
|
||||
float vt = 0;
|
||||
if (TryCastSingle(itmDic.getItem("vt"), ref vt)) row["value_total"] = (decimal)vt;
|
||||
float vat = 0;
|
||||
if (TryCastSingle(itmDic.nz("vat").Replace("%", ""), ref vat)) row["vat"] = (decimal)vat;
|
||||
float vs = 0;
|
||||
if (TryCastSingle(itmDic.getItem("vs"), ref vs)) row["value_service"] = (decimal)vs;
|
||||
row["det"] = itmDic.no("det", false) is true;
|
||||
row["SortOrder"] = (byte)Math.Clamp(itemSort, 0, 255);
|
||||
itemsTable.Rows.Add(row);
|
||||
}
|
||||
}
|
||||
|
||||
await setSQLValue_async("EXECUTE [dbo].[fds__remInvoice_Items] @Id, @authuser;",
|
||||
Conn, new List<SqlParameter> { SQL_VarChar("@authuser", userAccountId), SQL_VarChar("@Id", invoiceId) },
|
||||
Security: dbSec, options: new FIS_SQLOptions());
|
||||
|
||||
if (itemsTable.Rows.Count > 0)
|
||||
{
|
||||
var dtw = new DatatableWriterAsync(itemsTable, Conn, "[dbo].[fds__invoice_items]");
|
||||
dtw.DoSubmit();
|
||||
if (dtw.SubmitException != null)
|
||||
_logger.LogError(dtw.SubmitException, "PersistInvoiceLineItemsAsync: bulk-copy of invoice items failed for invoice {Id}", invoiceId);
|
||||
}
|
||||
}
|
||||
|
||||
public Document GenerateInvoicePdf(FdsInvoiceData invoice, bool draft)
|
||||
{
|
||||
using var act = FuchsTelemetry.StartActivity("invoice.render");
|
||||
|
||||
@@ -253,7 +253,8 @@ public class StartupSelfTestService : BackgroundService
|
||||
|
||||
using var check = new Spire.Pdf.PdfDocument();
|
||||
check.LoadFromStream(ms);
|
||||
string text = check.Pages[0].ExtractText();
|
||||
var extractor = new Spire.Pdf.Texts.PdfTextExtractor(check.Pages[0]);
|
||||
string text = extractor.ExtractText(new Spire.Pdf.Texts.PdfTextExtractOptions());
|
||||
|
||||
return !text.Contains("Evaluation Warning", StringComparison.OrdinalIgnoreCase)
|
||||
&& !text.Contains("created with Spire.PDF", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
@@ -6,9 +6,11 @@
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Debug",
|
||||
"Microsoft.AspNetCore": "Debug",
|
||||
"Microsoft.Hosting.Lifetime": "Debug",
|
||||
"Default": "Information",
|
||||
"Microsoft": "Warning",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.Hosting.Lifetime": "Information",
|
||||
"System": "Warning",
|
||||
"fds": "Debug",
|
||||
"Fuchs.Controllers": "Debug"
|
||||
}
|
||||
|
||||
@@ -480,14 +480,22 @@ $inv.cSt = function (data) {
|
||||
});
|
||||
};
|
||||
$inv.eHtml = function (ev) {
|
||||
let t = $(this), frmct = ev.data instanceof jQuery ? ev.data : ev.data.t, flds = [
|
||||
{ name: 'txt', label: 'Text', type: 'html', value: frmct.html(), tinymce: true, attr: { style: 'height: 300px' } }
|
||||
];
|
||||
let t = $(this), frmct = ev.data instanceof jQuery ? ev.data : ev.data.t;
|
||||
/* invoiceemail must stay plain text — using the TinyMCE/html editor here used to wrap the
|
||||
address in <p> tags, which then got posted and persisted verbatim into SendToEmail. */
|
||||
let isPlainText = ev.data.nme === 'invoiceemail';
|
||||
let flds = isPlainText
|
||||
? [{ name: 'txt', label: 'Text', type: 'text', value: frmct.text() }]
|
||||
: [{ name: 'txt', label: 'Text', type: 'html', value: frmct.html(), tinymce: true, attr: { style: 'height: 300px' } }];
|
||||
let change = ev.data.change || null;
|
||||
let sets = {
|
||||
title: t.data('dialog') || '',
|
||||
success: function (response) {
|
||||
if (isPlainText) {
|
||||
frmct.text(response.txt || '');
|
||||
} else {
|
||||
frmct.html(response.txt);
|
||||
}
|
||||
if (typeof change === 'function') {
|
||||
change(response.txt);
|
||||
}
|
||||
@@ -962,7 +970,6 @@ $inv.sprev = (change) => {
|
||||
}
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('req/' + (change === true ?'sedit':'sprep')), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid ||'' }, success: (response) => {
|
||||
l.rC('freeze');
|
||||
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id, invtp = response.total;
|
||||
if (invtp > 10) {
|
||||
$$.dc('note warn', c).text($ict.tpe);
|
||||
@@ -976,15 +983,20 @@ $inv.sprev = (change) => {
|
||||
$ocms.dlg(c, {
|
||||
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false, button: $rct.crI, confirm: function (e) {
|
||||
let ct = $(this);
|
||||
l.aC('freeze'); /* spinner while the invoice is finalized/emailed on the backend */
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('req/sconf'), data: { id: invid }, success: () => {
|
||||
url: $ocms.url('req/sconf'), data: { id: invid }, success: (cresp) => {
|
||||
ct.trigger('modal_close');
|
||||
window.open($ocms.url('req/idoc') + '?id=' + invid, '_blank'); /* open pdf in new tab */
|
||||
if (cresp.hasFile === true) {
|
||||
window.open($ocms.url('req/idoc') + '?id=' + invid, '_blank'); /* open pdf in new tab, only if a file was actually created */
|
||||
}
|
||||
$ocms.init('req'); /* go back to request list */
|
||||
$inv.rReload();
|
||||
}, error: () => {
|
||||
alert($t.f1);
|
||||
ct.trigger('modal_close');
|
||||
}, complete: () => {
|
||||
l.rC('freeze');
|
||||
}
|
||||
});
|
||||
}, cancel: function (e) {
|
||||
@@ -999,6 +1011,10 @@ $inv.sprev = (change) => {
|
||||
$inv.rReload();
|
||||
}
|
||||
});
|
||||
}, error: () => {
|
||||
alert($ict.eis);
|
||||
}, complete: () => {
|
||||
l.rC('freeze');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
|
||||
@keyframes fis_spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.edit_frm {
|
||||
|
||||
@@ -376,6 +380,22 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.freeze::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
margin: -1.5rem 0 0 -1.5rem;
|
||||
border: 0.4rem solid rgba(255, 255, 255, .35);
|
||||
border-top-color: #FFF;
|
||||
border-radius: 50%;
|
||||
z-index: 11;
|
||||
pointer-events: none;
|
||||
animation: fis_spin 0.8s linear infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -96,6 +96,14 @@ table.if td.num {
|
||||
margin: 0.2rem 0;
|
||||
}
|
||||
|
||||
@keyframes fis_spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.edit_frm .invoice_layout {
|
||||
margin: 2rem 2rem 2rem auto; /* align right */
|
||||
width: 50rem;
|
||||
@@ -381,6 +389,21 @@ table.if td.num {
|
||||
.edit_frm .invoice_layout.freeze::before tr:hover > td.aux > .axf {
|
||||
display: inline-flex;
|
||||
}
|
||||
.edit_frm .invoice_layout.freeze::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
margin: -1.5rem 0 0 -1.5rem;
|
||||
border: 0.4rem solid rgba(255, 255, 255, 0.35);
|
||||
border-top-color: #FFF;
|
||||
border-radius: 50%;
|
||||
z-index: 11;
|
||||
pointer-events: none;
|
||||
animation: fis_spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.modal-body .lstfrm {
|
||||
display: block;
|
||||
|
||||
@@ -1509,7 +1509,6 @@ $inv.sprev = (change) => {
|
||||
}
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('req/' + (change === true ?'sedit':'sprep')), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid ||'' }, success: (response) => {
|
||||
l.rC('freeze');
|
||||
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id, invtp = response.total;
|
||||
if (invtp > 10) {
|
||||
$$.dc('note warn', c).text($ict.tpe);
|
||||
@@ -1523,15 +1522,20 @@ $inv.sprev = (change) => {
|
||||
$ocms.dlg(c, {
|
||||
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false, button: $rct.crI, confirm: function (e) {
|
||||
let ct = $(this);
|
||||
l.aC('freeze'); /* spinner while the invoice is finalized/emailed on the backend */
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('req/sconf'), data: { id: invid }, success: () => {
|
||||
url: $ocms.url('req/sconf'), data: { id: invid }, success: (cresp) => {
|
||||
ct.trigger('modal_close');
|
||||
window.open($ocms.url('req/idoc') + '?id=' + invid, '_blank'); /* open pdf in new tab */
|
||||
if (cresp.hasFile === true) {
|
||||
window.open($ocms.url('req/idoc') + '?id=' + invid, '_blank'); /* open pdf in new tab, only if a file was actually created */
|
||||
}
|
||||
$ocms.init('req'); /* go back to request list */
|
||||
$inv.rReload();
|
||||
}, error: () => {
|
||||
alert($t.f1);
|
||||
ct.trigger('modal_close');
|
||||
}, complete: () => {
|
||||
l.rC('freeze');
|
||||
}
|
||||
});
|
||||
}, cancel: function (e) {
|
||||
@@ -1546,6 +1550,10 @@ $inv.sprev = (change) => {
|
||||
$inv.rReload();
|
||||
}
|
||||
});
|
||||
}, error: () => {
|
||||
alert($ict.eis);
|
||||
}, complete: () => {
|
||||
l.rC('freeze');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -233,6 +233,14 @@ table.if th.keep, table.if td.keep {
|
||||
content: "\e067";
|
||||
}
|
||||
|
||||
@keyframes fis_spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.edit_frm .invoice_layout {
|
||||
margin: 2rem 2rem 2rem auto; /* align right */
|
||||
width: 50rem;
|
||||
@@ -518,6 +526,21 @@ table.if th.keep, table.if td.keep {
|
||||
.edit_frm .invoice_layout.freeze::before tr:hover > td.aux > .axf {
|
||||
display: inline-flex;
|
||||
}
|
||||
.edit_frm .invoice_layout.freeze::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
margin: -1.5rem 0 0 -1.5rem;
|
||||
border: 0.4rem solid rgba(255, 255, 255, 0.35);
|
||||
border-top-color: #FFF;
|
||||
border-radius: 50%;
|
||||
z-index: 11;
|
||||
pointer-events: none;
|
||||
animation: fis_spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.modal-body .lstfrm {
|
||||
display: block;
|
||||
|
||||
@@ -1490,7 +1490,6 @@ $inv.sprev = (change) => {
|
||||
}
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('req/' + (change === true ?'sedit':'sprep')), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid ||'' }, success: (response) => {
|
||||
l.rC('freeze');
|
||||
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id, invtp = response.total;
|
||||
if (invtp > 10) {
|
||||
$$.dc('note warn', c).text($ict.tpe);
|
||||
@@ -1504,15 +1503,20 @@ $inv.sprev = (change) => {
|
||||
$ocms.dlg(c, {
|
||||
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false, button: $rct.crI, confirm: function (e) {
|
||||
let ct = $(this);
|
||||
l.aC('freeze'); /* spinner while the invoice is finalized/emailed on the backend */
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('req/sconf'), data: { id: invid }, success: () => {
|
||||
url: $ocms.url('req/sconf'), data: { id: invid }, success: (cresp) => {
|
||||
ct.trigger('modal_close');
|
||||
window.open($ocms.url('req/idoc') + '?id=' + invid, '_blank'); /* open pdf in new tab */
|
||||
if (cresp.hasFile === true) {
|
||||
window.open($ocms.url('req/idoc') + '?id=' + invid, '_blank'); /* open pdf in new tab, only if a file was actually created */
|
||||
}
|
||||
$ocms.init('req'); /* go back to request list */
|
||||
$inv.rReload();
|
||||
}, error: () => {
|
||||
alert($t.f1);
|
||||
ct.trigger('modal_close');
|
||||
}, complete: () => {
|
||||
l.rC('freeze');
|
||||
}
|
||||
});
|
||||
}, cancel: function (e) {
|
||||
@@ -1527,6 +1531,10 @@ $inv.sprev = (change) => {
|
||||
$inv.rReload();
|
||||
}
|
||||
});
|
||||
}, error: () => {
|
||||
alert($ict.eis);
|
||||
}, complete: () => {
|
||||
l.rC('freeze');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user