feat: Implement mfr truncation fix and enhance invoice handling
Playwright Tests / test (push) Canceled after 0s

- Added a new SQL script to address the "String or binary data would be truncated" error in mfr sync by widening the Note fields in service objects and companies to NVARCHAR(MAX).
- Updated the table types and associated stored procedures to accommodate the changes, ensuring compatibility with existing data.
- Queued a re-sync for previously rejected rows due to truncation issues.
- Created application logins for fuchs_fds and fuchs_web with placeholder passwords for initial setup.
- Introduced functions for invoice backdating and invoice date retrieval to streamline invoice processing.
- Developed a password generation function with customizable parameters for length and character types.
- Added a random number generation function to support password creation.
This commit is contained in:
Stefan
2026-09-30 16:56:08 +02:00
parent 7357f60120
commit c741535aec
79 changed files with 2739 additions and 143 deletions
@@ -3,6 +3,7 @@ using Fuchs.Services;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using static OCORE.SQL.sql;
using static OCORE.web.mvc_helper_async;
namespace Fuchs.Controllers;
@@ -14,6 +15,20 @@ namespace Fuchs.Controllers;
// only signals (ADR 0007).
public partial class IntranetController
{
/// <summary>
/// The date the calling user may backdate an invoice to (last working day of the previous month), or
/// null when not allowed — fds_inv &gt; 2 and no invoice finalized after that date yet. The SQL function
/// is the single source; fds__setInvoiceFinal re-checks it when @backdate = 1 (ADR 0032).
/// </summary>
private async Task<DateOnly?> GetInvoiceBackdateAsync(string fn, string id, string code)
{
var val = await getSQLValue_async<string>(
"SELECT CONVERT(varchar(10), [dbo].[fds__fn_invoiceBackdate](@authuser), 23);",
_intranet.Intranet__SQLConnectionString, "",
StdParamlist(), Security: DbSec, options: SqlOpt(fn, id, code));
return InvoiceDates.ParseBackdate(val.Result);
}
/// <summary>Standard 410 when a session token is unknown/expired — the client re-opens the draft.</summary>
private IActionResult DraftGone() => StatusCode(410, new { error = "expired" });
@@ -71,7 +86,8 @@ public partial class IntranetController
return await JSONAsync(new { ok = true, version = session.Version });
}
// POST inv/dpreview — { token } → { img[], total } (rendered straight from the cache)
// POST inv/dpreview — { token } → { img[], total, backdate } (rendered straight from the cache).
// backdate ("yyyy-MM-dd" or null) drives the admin-only "Rechnungsdatum Vormonat" checkbox (ADR 0032).
private async Task<IActionResult> HandleDraftPreview(string fn, string id, string code)
{
if (!HasForm("token")) return BadRequest400();
@@ -81,7 +97,8 @@ public partial class IntranetController
var doc = await _invoiceDrafts.RenderPreviewAsync(token);
if (doc == null) return DraftGone();
var imgcol = await _pdf.DocToImageCollectionAsync(doc);
return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages });
DateOnly? backdate = await GetInvoiceBackdateAsync(fn, id, code);
return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages, backdate = backdate?.ToString("yyyy-MM-dd") });
});
}
@@ -16,10 +16,22 @@ namespace Fuchs.Controllers;
// Partial class: Invoice sub-handlers
public partial class IntranetController
{
/// <summary>True when <paramref name="raw"/> is an mfr invoice id (positive integer); Intranet-created
/// invoices use alphanumeric ids and have no mfr entity to reset.</summary>
internal static bool TryGetMfrInvoiceId(string? raw, out long mfrId)
=> long.TryParse(raw?.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out mfrId) && mfrId > 0;
private async Task<IActionResult> HandleInvoicePget(string fn, string id, string code)
{
if (!HasForm("id")) { _logger.LogWarning("HandleInvoicePget: missing 'id' form field user={User}", UserAccountID); return BadRequest400(); }
if (!long.TryParse(Form("id"), out long tgtid)) { _logger.LogWarning("HandleInvoicePget: invalid 'id' value='{Value}' user={User}", Form("id"), UserAccountID); return BadRequest400(); }
if (!TryGetMfrInvoiceId(Form("id"), out long tgtid))
{
// Intranet-created invoices carry an alphanumeric Id (fds__fn_invoice_id) and have no mfr
// entity to reset. Both storno paths call pget first, so rejecting them here (400) left the
// storno window empty. Legacy parity: skip the mfr reset and report success.
_logger.LogDebug("HandleInvoicePget: non-mfr invoice id='{Value}', nothing to reset user={User}", Form("id"), UserAccountID);
return await JSONAsync(new { ok = true });
}
_logger.LogDebug("HandleInvoicePget tgtid={TgtId} user={User}", tgtid, UserAccountID);
using (var mfr = _mfrFactory.Create())
@@ -263,6 +275,16 @@ public partial class IntranetController
return await JSONAsync(new { payments = sqldt.Tables("items").toArrayofObjectDictionaries() });
}
/// <summary>
/// DATEV overview (<c>inv/datev</c>). Parameters are bound by name: the proc's signature is
/// (@tgtdate, @mode, @files, @authuser) and the former positional call put @authuser into
/// @files and never supplied @authuser, so every call failed. @files = 0 — the overview needs
/// no PDF bytes. Result sets in proc order: header, invoice documents, bookings, debtors.
/// </summary>
internal const string DatevOverviewSql =
"EXECUTE [dbo].[fds__getDatevExports] @tgtdate = @tgtdate, @mode = @mode, @files = 0, @authuser = @authuser;";
internal static readonly string[] DatevOverviewTables = { "admin", "files", "invoices", "debits" };
private async Task<IActionResult> HandleDatev(string fn, string id, string code)
{
if (!DateTime.TryParseExact(Form("tgt"), "yy-MM-dd",
@@ -274,10 +296,10 @@ public partial class IntranetController
string mode = Form("mode").ne("m");
_logger.LogDebug("HandleDatev tgtdate={TgtDate} mode={Mode} user={User}", tgtdate.ToString("yy-MM-dd"), mode, UserAccountID);
var dset = await getSQLDataSet_async(
"EXECUTE [dbo].[fds__getDatevExports] @tgtdate, @mode, @authuser;",
DatevOverviewSql,
_intranet.Intranet__SQLConnectionString,
StdParamlist(SQL_Date("@tgtdate", tgtdate), SQL_VarChar("@mode", mode)),
tablenames: new[] { "files", "invoices", "debits" },
tablenames: DatevOverviewTables,
Security: DbSec, options: SqlOpt(fn, id, code));
_logger.LogDebug("HandleDatev tgtdate={TgtDate} files={FileCount} invoices={InvCount} user={User}",
tgtdate.ToString("yy-MM-dd"), dset.Tables("files").Rows.Count, dset.Tables("invoices").Rows.Count, UserAccountID);
@@ -465,7 +487,10 @@ public partial class IntranetController
["NameOrNumber"] = "",
["htmltext"] = d["Text"],
["position"] = d["Position"],
["SortOrder"] = d["SortOrder"]
["SortOrder"] = d["SortOrder"],
// Round-trips the referenced invoice (CanceledInvoice / IntInvoiceAllocation) so a
// re-saved draft keeps it — fds__setInvoiceFinal needs it for Replaces/allocations.
["reference_InvId"] = d.nz("reference_InvId") is { Length: > 0 } refId ? refId : null
});
}
sdic["items"] = itm;
@@ -1,5 +1,6 @@
using System.Globalization;
using Fuchs.intranet;
using Fuchs.Observability;
using Fuchs.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.SqlClient;
@@ -302,14 +303,41 @@ public partial class IntranetController
private async Task<IActionResult> HandleRequestSconf(string fn, string id, string code)
{
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);
// Admin option: date the invoice to the last working day of the previous month (ADR 0032).
// fds__setInvoiceFinal re-validates (fds_inv > 2, no later invoice) and throws otherwise.
bool backdate = InvoiceDates.IsBackdateRequested(Form("backdate"));
_logger.LogInformation("HandleRequestSconf: finalizing invoice {InvId} backdate={Backdate} user={User}", Form("id"), backdate, UserAccountID);
var dt = await getSQLDatatable_async(
"EXECUTE [dbo].[fds__setInvoiceFinal] @Id, @authuser;",
"EXECUTE [dbo].[fds__setInvoiceFinal] @Id, @authuser, @backdate;",
_intranet.Intranet__SQLConnectionString,
StdParamlist(SQL_VarChar("@Id", Form("id"))),
StdParamlist(SQL_VarChar("@Id", Form("id")), SQL_Bit("@backdate", backdate)),
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);
_logger.LogError("HandleRequestSconf: SQL error {SqlErrorNumber} finalizing invoice {InvId}: {SqlError}, user={User}", dt.ExceptionCode, Form("id"), dt.Exception, UserAccountID);
// Only SQL error 60001 is a backdate rejection. A 0-row result means the invoice is already
// final/sent (double submit) — it must not be reported as "create it again without backdating".
var outcome = InvoiceDates.ClassifyFinalizeResult(dt.Exception, dt.ExceptionCode, dt.Count);
if (backdate)
{
FuchsTelemetry.InvoicesBackdated.Add(1, new KeyValuePair<string, object?>("outcome", InvoiceDates.OutcomeTag(outcome)));
if (outcome == InvoiceFinalizeOutcome.Finalized)
_logger.LogInformation(new EventId(0, "InvoiceBackdated"),
"Invoice {InvId} finalized with backdated invoice date {DateFinalized} user={User}",
Form("id"), dt.FirstRow.toObjectDictionary().nz("DateFinalized"), UserAccountID);
}
switch (outcome)
{
case InvoiceFinalizeOutcome.BackdateNotAllowed:
return await InvoiceIssueResult(
"Die Rechnung wurde nicht erstellt: Das Rechnungsdatum kann nicht mehr auf den Vormonat gesetzt werden (es wurde bereits eine Rechnung im neuen Monat erstellt). Bitte ohne Rückdatierung erneut erstellen.",
Form("id"));
case InvoiceFinalizeOutcome.AlreadyFinal:
return await InvoiceIssueResult(
"Die Rechnung ist bereits abgeschlossen oder versendet und wurde nicht erneut erstellt.",
Form("id"));
case InvoiceFinalizeOutcome.Error:
return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht erstellt werden.", Form("id"));
}
var frdic = dt.FirstRow.toObjectDictionary();
if (frdic.TryGetValue("IsFinal", out var isFinal) && isFinal is true)
{