Enhance banking transaction data structure and validation
Playwright Tests / test (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
- Increased the length of the NameOfPayer field from NVARCHAR(60) to NVARCHAR(140) in the banking transactions function, table, temporary table, and user-defined type to accommodate longer names. - Expanded the SepaRemittanceInformation field from VARCHAR(150) to VARCHAR(200) in the banking transactions function for more detailed remittance information. - Modified the stored procedure fds__setBankingtransaction_done to return additional transaction data (ValueDate and Amount) along with the success flag for improved notification messaging. - Added validation in MFRClientConfig constructor to ensure the provided URL is not null or empty, enhancing error handling for configuration settings.
This commit is contained in:
@@ -190,11 +190,19 @@ public partial class IntranetController
|
||||
{
|
||||
if (!HasForm("taid")) return BadRequest400();
|
||||
var pl = StdParamlist(SQL_VarChar("@taID", Form("taid"), dbNull_IfEmpty: true));
|
||||
var res = await getSQLValue_async(
|
||||
var res = await getSQLDatatable_async(
|
||||
"EXECUTE [dbo].[fds__setBankingtransaction_done] @taID, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString, pl,
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
return res.Result is true
|
||||
var row = res.FirstRow;
|
||||
bool success = row["success"] is true;
|
||||
if (success)
|
||||
{
|
||||
DateTime? valueDate = row["ValueDate"] == DBNull.Value ? null : (DateTime)row["ValueDate"];
|
||||
decimal? amount = row["Amount"] == DBNull.Value ? null : (decimal)row["Amount"];
|
||||
await _events.BankingTransactionMarkedDoneAsync(Form("taid"), valueDate, amount, UserAccountID);
|
||||
}
|
||||
return success
|
||||
? await JSONAsync(new { ok = true })
|
||||
: StatusCode(500, new { error = "not successful" });
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ public enum DomainEventType
|
||||
ReminderSendFailed,
|
||||
BankingTransactionsImported,
|
||||
BankingImportFailed,
|
||||
BankingTransactionMarkedDone,
|
||||
UserIssue
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using Fuchs.intranet;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -130,6 +131,18 @@ public sealed class EventService : IEventService
|
||||
"Banking",
|
||||
new Dictionary<string, object?> { ["fileName"] = fileName, ["message"] = message }));
|
||||
|
||||
public Task BankingTransactionMarkedDoneAsync(string taId, DateTime? valueDate, decimal? amount, string userAccountId)
|
||||
=> PublishAsync(new DomainEvent(
|
||||
DomainEventType.BankingTransactionMarkedDone,
|
||||
userAccountId,
|
||||
"Banking",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["taId"] = taId,
|
||||
["valueDate"] = valueDate,
|
||||
["amount"] = amount
|
||||
}));
|
||||
|
||||
public Task UserIssueAsync(string title, string message, string userAccountId, IReadOnlyDictionary<string, object?>? context = null)
|
||||
{
|
||||
Dictionary<string, object?> ctx = context == null
|
||||
@@ -181,6 +194,8 @@ public sealed class EventService : IEventService
|
||||
BankingImportMessage(domainEvent),
|
||||
DomainEventType.BankingImportFailed =>
|
||||
Ctx(domainEvent, "message"),
|
||||
DomainEventType.BankingTransactionMarkedDone =>
|
||||
BankingTransactionMarkedDoneMessage(domainEvent),
|
||||
DomainEventType.UserIssue =>
|
||||
Ctx(domainEvent, "message"),
|
||||
_ => domainEvent.Title
|
||||
@@ -242,6 +257,29 @@ public sealed class EventService : IEventService
|
||||
return DateTime.TryParse(value.ToString(), out var parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
private static string BankingTransactionMarkedDoneMessage(DomainEvent domainEvent)
|
||||
{
|
||||
DateTime? valueDate = DateCtx(domainEvent, "valueDate");
|
||||
string amount = AmountCtx(domainEvent, "amount");
|
||||
string datePart = valueDate == null ? "" : $" vom {valueDate.Value:dd.MM.yy}";
|
||||
string amountPart = string.IsNullOrEmpty(amount) ? "" : $" über {amount}€";
|
||||
return $"Bank-Transaktion{datePart}{amountPart} wurde als erledigt markiert.";
|
||||
}
|
||||
|
||||
private static string AmountCtx(DomainEvent domainEvent, string key)
|
||||
{
|
||||
if (!domainEvent.Context.TryGetValue(key, out var value) || value == null) return "";
|
||||
decimal? amount = value switch
|
||||
{
|
||||
decimal d => d,
|
||||
double d => (decimal)d,
|
||||
_ => decimal.TryParse(value.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var parsed)
|
||||
? parsed
|
||||
: null
|
||||
};
|
||||
return amount?.ToString("0.00", Fuchs_intranet.DeCulture) ?? "";
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> InvoiceContext(FdsInvoiceData invoice)
|
||||
{
|
||||
string invoiceNumber = invoice.InvoiceId;
|
||||
|
||||
@@ -20,6 +20,7 @@ public interface IEventService
|
||||
|
||||
Task BankingTransactionsImportedAsync(DateTime? from, DateTime? to, int rows, string fileName, string userAccountId);
|
||||
Task BankingImportIssueAsync(string message, string fileName, string userAccountId);
|
||||
Task BankingTransactionMarkedDoneAsync(string taId, DateTime? valueDate, decimal? amount, string userAccountId);
|
||||
|
||||
Task UserIssueAsync(string title, string message, string userAccountId, IReadOnlyDictionary<string, object?>? context = null);
|
||||
}
|
||||
|
||||
@@ -144,6 +144,11 @@ public class Program
|
||||
|
||||
private static void ConfigureApp(WebApplication app)
|
||||
{
|
||||
// OCORE's internal logging helpers (e.g. DatatableWriterAsync's exception logging)
|
||||
// call the static OCORE.Logging.Logger via a null-conditional — without this, those
|
||||
// calls silently no-op instead of reaching Debug output / ErrorLog.txt.
|
||||
OCORE.Logging.Logger = app.Logger;
|
||||
|
||||
if (!app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseExceptionHandler("/error");
|
||||
|
||||
@@ -36,6 +36,7 @@ public class BankingService : IBankingService
|
||||
using var act = FuchsTelemetry.StartActivity("banking.parse");
|
||||
var sw = Stopwatch.StartNew();
|
||||
var tbl = schemaDatatable?.Clone() ?? BuildDefaultSchema();
|
||||
ApplyKnownColumnWidths(tbl);
|
||||
var diag = new ParseDiagnostics();
|
||||
|
||||
// Buffer once so we can sniff the format and (re)parse from the bytes.
|
||||
@@ -280,6 +281,53 @@ public class BankingService : IBankingService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The schema DataTable fetched from <c>[dbo].[fds__tt__bankingtransactions]</c> via
|
||||
/// <c>SELECT TOP(0) * FROM @tmp</c> comes back with <see cref="DataColumn.MaxLength"/> = -1
|
||||
/// for every string column — ADO.NET does not carry character-length facets through a
|
||||
/// table-variable-typed SELECT. Without a real MaxLength, <see cref="SetCell"/>'s truncation
|
||||
/// guard never engages, so an over-long real-world CAMT field (routine for rich SEPA
|
||||
/// remittance/reference data) sails through parsing and the temp-table bulk copy — which
|
||||
/// builds its columns from this same DataTable and creates them as NVARCHAR(MAX) — only to
|
||||
/// throw "String or binary data would be truncated" once the merge stored procedure inserts
|
||||
/// into the real, correctly-width-constrained target table. These widths mirror
|
||||
/// [dbo].[fds__tt__bankingtransactions] / [dbo].[fds__bankingtransactions] (kept identical by
|
||||
/// design) so truncation happens safely here instead of failing the whole import downstream.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, int> KnownColumnMaxLengths = new()
|
||||
{
|
||||
["AccountIdentification"] = 50,
|
||||
["FundsCode"] = 1,
|
||||
["AccountNumberOfPayer"] = 30,
|
||||
["BankCodeOfPayer"] = 11,
|
||||
["CompensationAmount"] = 50,
|
||||
["CreditorReference"] = 30,
|
||||
["CreditorsReferenceParty"] = 50,
|
||||
["CustomerReference"] = 50,
|
||||
["EndToEndReference"] = 50,
|
||||
["JournalNumber"] = 10,
|
||||
["MandateReference"] = 50,
|
||||
["NameOfPayer"] = 140, // ISO 20022 Max140Text — some banks put the full postal address in <Nm>, not just a name
|
||||
["OriginalAmount"] = 150,
|
||||
["OriginatorsIdentificationCode"] = 150,
|
||||
["PayersReferenceParty"] = 150,
|
||||
["PostingText"] = 30,
|
||||
["SepaRemittanceInformation"] = 200,
|
||||
["UnstructuredData"] = 390,
|
||||
["UnstructuredRemittanceInformation"] = 390,
|
||||
["DebitCreditMark"] = 2,
|
||||
["TransactionTypeIdCode"] = 3,
|
||||
};
|
||||
|
||||
private static void ApplyKnownColumnWidths(DataTable tbl)
|
||||
{
|
||||
foreach (var (columnName, maxLength) in KnownColumnMaxLengths)
|
||||
{
|
||||
if (tbl.Columns.Contains(columnName) && tbl.Columns[columnName]!.DataType == typeof(string))
|
||||
tbl.Columns[columnName]!.MaxLength = maxLength;
|
||||
}
|
||||
}
|
||||
|
||||
private static DataTable BuildDefaultSchema()
|
||||
{
|
||||
var t = new DataTable();
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Debug",
|
||||
"Microsoft.AspNetCore": "Information"
|
||||
"Default": "Warning",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"Fuchs": {
|
||||
@@ -22,5 +22,10 @@
|
||||
"InvoiceContainer": "dev-fuchs-invoices",
|
||||
"ReminderContainer": "dev-fuchs-reminders"
|
||||
}
|
||||
},
|
||||
"Fds": {
|
||||
"MFR_host": "portal.mobilefieldreport.com",
|
||||
"MFR_UserName": "system@sebastian-fuchs---bad-und-heizung-gmbh-und-co-kg.com",
|
||||
"MFR_Password": "0oT4G3H2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
"Fuchs--SMS-APIKey",
|
||||
"Fuchs--Mailer--Token",
|
||||
"Fuchs--fuchs-captcha-TOTP",
|
||||
"Fuchs--fuchs-intranet-TOTP"
|
||||
"Fuchs--fuchs-intranet-TOTP",
|
||||
"Fds--MFR-UserName",
|
||||
"Fds--MFR-Password"
|
||||
]
|
||||
},
|
||||
"Logging": {
|
||||
@@ -55,5 +57,10 @@
|
||||
"Enabled": true,
|
||||
"OtlpEndpoint": ""
|
||||
}
|
||||
},
|
||||
"Fds": {
|
||||
"MFR_host": "portal.mobilefieldreport.com",
|
||||
"MFR_UserName": "MANAGED_BY_KEYVAULT",
|
||||
"MFR_Password": "MANAGED_BY_KEYVAULT"
|
||||
}
|
||||
}
|
||||
@@ -30,57 +30,59 @@ main nav ul > li a[role=button] {
|
||||
}
|
||||
|
||||
#notification_frame {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 2000;
|
||||
width: min(24rem, calc(100vw - 2rem));
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.notification_item {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
border-left: 0.35rem solid $fuchs_blau;
|
||||
border-radius: 0.35rem;
|
||||
box-shadow: 0 0.25rem 1rem rgba(30, 35, 45, 0.25);
|
||||
color: #222;
|
||||
padding: 0.75rem 2.2rem 0.75rem 0.85rem;
|
||||
flex: 0 0 $oci_header;
|
||||
height: $oci_header;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
color: $fuchs_weiss;
|
||||
padding: 0 2.2rem 0 0.85rem;
|
||||
pointer-events: auto;
|
||||
|
||||
&.warn {
|
||||
border-left-color: #c78300;
|
||||
}
|
||||
|
||||
&.error {
|
||||
border-left-color: #b92525;
|
||||
&.warn, &.warning, &.error {
|
||||
color: $oci_yellow;
|
||||
}
|
||||
|
||||
.notification_title {
|
||||
font-weight: bold;
|
||||
line-height: 1.25;
|
||||
margin-bottom: 0.2rem;
|
||||
flex: 0 0 auto;
|
||||
|
||||
&::after {
|
||||
content: ":";
|
||||
margin-right: 0.35rem;
|
||||
}
|
||||
}
|
||||
|
||||
.notification_message {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.notification_close {
|
||||
position: absolute;
|
||||
top: 0.35rem;
|
||||
top: 0;
|
||||
right: 0.45rem;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #444;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
padding: 0.1rem 0.25rem;
|
||||
line-height: $oci_header;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -244,8 +244,22 @@ $fis.notifications = {
|
||||
this.connection.on('notification', (notification) => {
|
||||
this.push(notification);
|
||||
});
|
||||
this.connection.start().catch(() => {
|
||||
// withAutomaticReconnect() only retries a connection that was successfully
|
||||
// established and later dropped — it does not retry a failed initial start()
|
||||
// (e.g. the server restarting mid-debug). Without this, a single failed start()
|
||||
// silently blackholes every notification for the rest of the page's lifetime.
|
||||
this.connection.onclose(() => {
|
||||
console.warn('Notification connection closed; retrying in 5s.');
|
||||
this.connection = null;
|
||||
setTimeout(() => this.init(), 5000);
|
||||
});
|
||||
this.start();
|
||||
},
|
||||
start: function () {
|
||||
this.connection.start().catch((err) => {
|
||||
console.warn('Notification connection failed to start; retrying in 5s.', err);
|
||||
this.connection = null;
|
||||
setTimeout(() => this.init(), 5000);
|
||||
});
|
||||
},
|
||||
ensureFrame: function () {
|
||||
|
||||
+27
-25
@@ -2231,53 +2231,55 @@ main nav ul > li a[role=button]:hover::after, main nav ul > li a[role=button].fb
|
||||
}
|
||||
|
||||
#notification_frame {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 2000;
|
||||
width: min(24rem, 100vw - 2rem);
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.notification_item {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
border-left: 0.35rem solid rgb(27, 67, 121);
|
||||
border-radius: 0.35rem;
|
||||
box-shadow: 0 0.25rem 1rem rgba(30, 35, 45, 0.25);
|
||||
color: #222;
|
||||
padding: 0.75rem 2.2rem 0.75rem 0.85rem;
|
||||
flex: 0 0 2.3rem;
|
||||
height: 2.3rem;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
color: #FFF;
|
||||
padding: 0 2.2rem 0 0.85rem;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.notification_item.warn {
|
||||
border-left-color: #c78300;
|
||||
}
|
||||
.notification_item.error {
|
||||
border-left-color: #b92525;
|
||||
.notification_item.warn, .notification_item.warning, .notification_item.error {
|
||||
color: rgb(255, 200, 1);
|
||||
}
|
||||
.notification_item .notification_title {
|
||||
font-weight: bold;
|
||||
line-height: 1.25;
|
||||
margin-bottom: 0.2rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.notification_item .notification_title::after {
|
||||
content: ":";
|
||||
margin-right: 0.35rem;
|
||||
}
|
||||
.notification_item .notification_message {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.notification_item .notification_close {
|
||||
position: absolute;
|
||||
top: 0.35rem;
|
||||
top: 0;
|
||||
right: 0.45rem;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #444;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
padding: 0.1rem 0.25rem;
|
||||
line-height: 2.3rem;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.wdg_frame {
|
||||
|
||||
@@ -3088,8 +3088,22 @@ $fis.notifications = {
|
||||
this.connection.on('notification', (notification) => {
|
||||
this.push(notification);
|
||||
});
|
||||
this.connection.start().catch(() => {
|
||||
// withAutomaticReconnect() only retries a connection that was successfully
|
||||
// established and later dropped — it does not retry a failed initial start()
|
||||
// (e.g. the server restarting mid-debug). Without this, a single failed start()
|
||||
// silently blackholes every notification for the rest of the page's lifetime.
|
||||
this.connection.onclose(() => {
|
||||
console.warn('Notification connection closed; retrying in 5s.');
|
||||
this.connection = null;
|
||||
setTimeout(() => this.init(), 5000);
|
||||
});
|
||||
this.start();
|
||||
},
|
||||
start: function () {
|
||||
this.connection.start().catch((err) => {
|
||||
console.warn('Notification connection failed to start; retrying in 5s.', err);
|
||||
this.connection = null;
|
||||
setTimeout(() => this.init(), 5000);
|
||||
});
|
||||
},
|
||||
ensureFrame: function () {
|
||||
|
||||
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
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user