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
+11
View File
@@ -135,6 +135,12 @@ public class AzureBlobStorageService : IBlobStorageService
Response<bool> response = await blobClient.ExistsAsync(cancellationToken);
return response.Value;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Caller cancelled (e.g. app shutdown during the startup backfill) — not a storage failure;
// reporting "not archived" here made the backfill go on to a doomed upload logged as an error.
throw;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Blob existence check failed for {Container}/{Blob} — treating as not archived.",
@@ -270,6 +276,11 @@ public class AzureBlobStorageService : IBlobStorageService
category, documentId, containerName, blobName);
return blobClient.Uri;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
_logger.LogDebug("Blob upload cancelled for {Category} {Id} (caller cancelled).", category, documentId);
throw;
}
catch (Exception ex)
{
FuchsTelemetry.BlobUploadsFailed.Add(1, new KeyValuePair<string, object?>("category", category));
+1 -7
View File
@@ -259,13 +259,7 @@ public static class ERechnungMapper
}
// ── helpers ────────────────────────────────────────────────────────────────
private static DateOnly IssueDate(GenericObjectDictionary? reg)
{
foreach (var key in new[] { "DateFinalized", "DateCreated" })
if (reg?.getString(key) is { Length: > 0 } s && DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dt))
return DateOnly.FromDateTime(dt);
return DateOnly.FromDateTime(DateTime.Today);
}
private static DateOnly IssueDate(GenericObjectDictionary? reg) => InvoiceDates.IssueDate(reg);
/// <summary>Normalises a raw country value (ISO alpha-2, or a German/English name) to a country code.</summary>
internal static CountryCode NormalizeCountry(string? raw)
+4 -2
View File
@@ -12,7 +12,8 @@ public interface IBlobStorageService
/// supplied (typically <c>FdsInvoiceData.InvoiceRegistration</c>), blob metadata is projected from
/// it using <see cref="AzureBlobStorageSettings.MetadataFields"/> — see <see cref="DocumentMetadataBuilder"/>.
/// Returns the blob URI, or <c>null</c> when storage is disabled/unconfigured
/// or the upload failed — failures never break the primary DB-storage flow.
/// or the upload failed — failures never break the primary DB-storage flow. Only a cancelled
/// <paramref name="cancellationToken"/> surfaces, as <see cref="OperationCanceledException"/>.
/// </summary>
Task<Uri?> UploadInvoicePdfAsync(string invoiceId, string fileName, byte[] content,
IReadOnlyDictionary<string, object?>? sourceRow = null, CancellationToken cancellationToken = default);
@@ -50,7 +51,8 @@ public interface IBlobStorageService
/// <summary>
/// Returns <c>true</c> if a blob already exists for the given container/document/filename
/// combination. Used by the startup backfill to skip documents that were already archived.
/// Returns <c>false</c> (never throws) when storage is disabled/unconfigured or the check fails.
/// Returns <c>false</c> when storage is disabled/unconfigured or the check fails; throws only
/// <see cref="OperationCanceledException"/> when <paramref name="cancellationToken"/> is cancelled.
/// </summary>
Task<bool> ExistsAsync(string containerName, string documentId, string fileName,
CancellationToken cancellationToken = default);
+2 -1
View File
@@ -616,7 +616,8 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
["SendToAddressJson"] = InvoiceRecipientAddress.FromCustomValues(Str(session.New["CustomValues"]))?
.ToJson().ToString(Newtonsoft.Json.Formatting.None) ?? "",
["InvoiceOptions"] = BuildInvoiceOptions(session),
["DateCreated"] = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
// UTC like the stored column — InvoiceDates.IssueDate converts it to the German day
["DateCreated"] = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
};
int idx = 0;
+15 -1
View File
@@ -205,6 +205,17 @@ public class InvoiceService : IInvoiceService
/// 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>
/// <summary>
/// Normalises an item's posted <c>ref</c> (the invoice a CanceledInvoice / IntInvoiceAllocation line
/// points at) for <c>fds__invoice_items.reference_InvId</c> (varchar(20)); null when absent or not an id.
/// </summary>
internal static string? ItemReferenceInvId(string? raw)
{
string s = (raw ?? "").Trim();
if (s.Length == 0 || s.Length > 20 || s.Equals("null", StringComparison.OrdinalIgnoreCase)) return null;
return s.All(char.IsAsciiLetterOrDigit) ? s : null;
}
private async Task PersistInvoiceLineItemsAsync(FdsInvoiceData invoice, string userAccountId, DatabaseSecurity dbSec)
{
string invoiceId = invoice.Id;
@@ -269,6 +280,7 @@ public class InvoiceService : IInvoiceService
float vs = 0;
if (TryCastSingle(itmDic.getItem("vs"), ref vs)) row["value_service"] = (decimal)vs;
row["det"] = itmDic.no("det", false) is true;
if (ItemReferenceInvId(itmDic.nz("ref")) is { } refInvId) row["reference_InvId"] = refInvId;
row["SortOrder"] = (byte)Math.Clamp(itemSort, 0, 255);
itemsTable.Rows.Add(row);
}
@@ -302,7 +314,9 @@ public class InvoiceService : IInvoiceService
: Array.Empty<string>(),
AdminUser = reg?.getString("UserNameFinalized") ?? "",
AdminUserEmail = reg?.getString("UserEmailFinalized") ?? "",
AdminDatumValue = reg?.getString("DateCreated") is { Length: > 0 } dc ? DateTime.Parse(dc) : DateTime.Now
// Rechnungsdatum = DateFinalized (incl. backdating), draft: DateCreated — same source as the
// e-invoice issue date (ERechnungMapper), see InvoiceDates / ADR 0032.
AdminDatumValue = InvoiceDates.IssueDate(reg).ToDateTime(TimeOnly.MinValue)
};
var inlineImages = await ResolveInlineImagesAsync(invoice);
var doc = await _pdf.WriteLetterAsync(tb, draft);
+2 -1
View File
@@ -271,7 +271,8 @@ public sealed class ReminderDraftEditService : IReminderDraftService
["invoices"] = invoices,
["CustomValues"] = Str(session.New["CustomValues"]),
["IsFinal"] = false,
["DateCreated"] = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
// UTC like the stored column — InvoiceDates.IssueDate converts it to the German day
["DateCreated"] = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
};
return new GenericObjectDictionary(d);
}
+2 -1
View File
@@ -111,7 +111,8 @@ public class ReminderService : IReminderService
Address = reminder.InvoiceAddress,
AdminUser = reminder.UserNameFinalized,
AdminUserEmail = reminder.UserEmailFinalized,
AdminDatumValue = reminder.DateCreated ?? DateTime.Now
// Reminder date = DateFinalized, draft: DateCreated — same rule as the invoice date (ADR 0035)
AdminDatumValue = InvoiceDates.IssueDate(reminder.ReminderRegistration).ToDateTime(TimeOnly.MinValue)
};
if (!string.IsNullOrEmpty(reminder.RawCustomValues))
{