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:
@@ -129,4 +129,51 @@ public class BankingParseToDatatableTests
|
|||||||
var ex = Record.Exception(() => Svc.ParseToDatatable(stream));
|
var ex = Record.Exception(() => Svc.ParseToDatatable(stream));
|
||||||
Assert.Null(ex);
|
Assert.Null(ex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("AccountIdentification", 50)]
|
||||||
|
[InlineData("NameOfPayer", 140)]
|
||||||
|
[InlineData("SepaRemittanceInformation", 200)]
|
||||||
|
[InlineData("DebitCreditMark", 2)]
|
||||||
|
[InlineData("UnstructuredData", 390)]
|
||||||
|
public void ParseToDatatable_DefaultSchema_EnforcesKnownColumnWidth(string column, int expectedMaxLength)
|
||||||
|
{
|
||||||
|
using var stream = ToStream(MinimalMT940);
|
||||||
|
var table = Svc.ParseToDatatable(stream);
|
||||||
|
Assert.Equal(expectedMaxLength, table.Columns[column]!.MaxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseToDatatable_SchemaWithoutMaxLength_StillEnforcesKnownWidths()
|
||||||
|
{
|
||||||
|
// Mirrors the real bug: SELECT TOP(0) * FROM @tmp (a table-type variable) comes back
|
||||||
|
// from ADO.NET with MaxLength = -1 for every string column, so a schema built purely
|
||||||
|
// from that fetch cannot truncate on its own. ApplyKnownColumnWidths must patch it up
|
||||||
|
// regardless of what the caller-supplied schema carries.
|
||||||
|
var schema = new DataTable();
|
||||||
|
schema.Columns.Add("AccountIdentification", typeof(string));
|
||||||
|
schema.Columns.Add("Amount", typeof(decimal));
|
||||||
|
schema.Columns.Add("DebitCreditMark", typeof(string));
|
||||||
|
Assert.Equal(-1, schema.Columns["AccountIdentification"]!.MaxLength);
|
||||||
|
|
||||||
|
using var stream = ToStream(MinimalMT940);
|
||||||
|
var table = Svc.ParseToDatatable(stream, schemaDatatable: schema);
|
||||||
|
|
||||||
|
Assert.Equal(50, table.Columns["AccountIdentification"]!.MaxLength);
|
||||||
|
Assert.Equal(2, table.Columns["DebitCreditMark"]!.MaxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseToDatatable_OverlongAccountIdentification_TruncatesInsteadOfThrowing()
|
||||||
|
{
|
||||||
|
string longAccount = "DE" + new string('9', 60); // 62 chars, over the 50-char column width
|
||||||
|
string mt940 = MinimalMT940.Replace("DE12345678901234567890", longAccount);
|
||||||
|
|
||||||
|
using var stream = ToStream(mt940);
|
||||||
|
var table = Svc.ParseToDatatable(stream);
|
||||||
|
|
||||||
|
Assert.Equal(1, table.Rows.Count);
|
||||||
|
Assert.Equal(50, ((string)table.Rows[0]["AccountIdentification"]).Length);
|
||||||
|
Assert.Equal(longAccount[..50], table.Rows[0]["AccountIdentification"]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -391,7 +391,7 @@ public class BankingDualFormatTests
|
|||||||
t.Columns.Add("Amount", typeof(decimal));
|
t.Columns.Add("Amount", typeof(decimal));
|
||||||
t.Columns.Add("ValueDate", typeof(DateTime));
|
t.Columns.Add("ValueDate", typeof(DateTime));
|
||||||
t.Columns.Add("FundsCode", typeof(string)).MaxLength = 1; // VARCHAR(1) — currency "EUR" must not land here
|
t.Columns.Add("FundsCode", typeof(string)).MaxLength = 1; // VARCHAR(1) — currency "EUR" must not land here
|
||||||
t.Columns.Add("NameOfPayer", typeof(string)).MaxLength = 60;
|
t.Columns.Add("NameOfPayer", typeof(string)).MaxLength = 140;
|
||||||
t.Columns.Add("PostingText", typeof(string)).MaxLength = 30;
|
t.Columns.Add("PostingText", typeof(string)).MaxLength = 30;
|
||||||
t.Columns.Add("TransactionTypeIdCode", typeof(string)).MaxLength = 3;
|
t.Columns.Add("TransactionTypeIdCode", typeof(string)).MaxLength = 3;
|
||||||
t.Columns.Add("SepaRemittanceInformation", typeof(string)).MaxLength = 200;
|
t.Columns.Add("SepaRemittanceInformation", typeof(string)).MaxLength = 200;
|
||||||
@@ -418,7 +418,9 @@ public class BankingDualFormatTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void ParseToDatatable_Camt_OverlongFields_AreTruncatedNotDropped()
|
public void ParseToDatatable_Camt_OverlongFields_AreTruncatedNotDropped()
|
||||||
{
|
{
|
||||||
const string longName = "Ein sehr langer Zahlungspflichtiger Name der weit ueber sechzig Zeichen hinausgeht GmbH Co KG";
|
// Some banks put the full postal address into <Nm>, not just a name — real-world case
|
||||||
|
// that exceeds even the ISO 20022 Max140Text width this column is now sized to.
|
||||||
|
const string longName = "Ein sehr langer Zahlungspflichtiger Name der weit ueber hundertvierzig Zeichen hinausgeht GmbH Co KG, Musterstrasse 123, 12345 Musterstadt, Deutschland";
|
||||||
string camt = """
|
string camt = """
|
||||||
<?xml version="1.0"?>
|
<?xml version="1.0"?>
|
||||||
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
|
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
|
||||||
@@ -440,7 +442,7 @@ public class BankingDualFormatTests
|
|||||||
var t = Svc.ParseToDatatable(s, schemaDatatable: DbLikeSchema());
|
var t = Svc.ParseToDatatable(s, schemaDatatable: DbLikeSchema());
|
||||||
|
|
||||||
Assert.Equal(1, t.Rows.Count);
|
Assert.Equal(1, t.Rows.Count);
|
||||||
Assert.Equal(60, ((string)t.Rows[0]["NameOfPayer"]).Length); // truncated to column width, row kept
|
Assert.Equal(140, ((string)t.Rows[0]["NameOfPayer"]).Length); // truncated to column width, row kept
|
||||||
Assert.Equal(longName[..60], t.Rows[0]["NameOfPayer"]);
|
Assert.Equal(longName[..140], t.Rows[0]["NameOfPayer"]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,18 @@ public class MFRClientConfigTests
|
|||||||
Assert.Equal("", config.LogoutAddress);
|
Assert.Equal("", config.LogoutAddress);
|
||||||
Assert.Equal("", config.TokenCookieName);
|
Assert.Equal("", config.TokenCookieName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Regression: an empty/missing host used to be silently turned into "https:///odata/"
|
||||||
|
// (empty authority), which only failed much later inside RestSharp with a cryptic
|
||||||
|
// "Invalid URI: The hostname could not be parsed." Fail fast at construction instead.
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
public void Constructor_WithMissingHost_ThrowsArgumentException(string? url)
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentException>(() => new MFRClientConfig(url!));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class MFRClientCredentialsTests
|
public class MFRClientCredentialsTests
|
||||||
|
|||||||
@@ -190,11 +190,19 @@ public partial class IntranetController
|
|||||||
{
|
{
|
||||||
if (!HasForm("taid")) return BadRequest400();
|
if (!HasForm("taid")) return BadRequest400();
|
||||||
var pl = StdParamlist(SQL_VarChar("@taID", Form("taid"), dbNull_IfEmpty: true));
|
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;",
|
"EXECUTE [dbo].[fds__setBankingtransaction_done] @taID, @authuser;",
|
||||||
_intranet.Intranet__SQLConnectionString, pl,
|
_intranet.Intranet__SQLConnectionString, pl,
|
||||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
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 })
|
? await JSONAsync(new { ok = true })
|
||||||
: StatusCode(500, new { error = "not successful" });
|
: StatusCode(500, new { error = "not successful" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ public enum DomainEventType
|
|||||||
ReminderSendFailed,
|
ReminderSendFailed,
|
||||||
BankingTransactionsImported,
|
BankingTransactionsImported,
|
||||||
BankingImportFailed,
|
BankingImportFailed,
|
||||||
|
BankingTransactionMarkedDone,
|
||||||
UserIssue
|
UserIssue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Globalization;
|
||||||
using Fuchs.intranet;
|
using Fuchs.intranet;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@@ -130,6 +131,18 @@ public sealed class EventService : IEventService
|
|||||||
"Banking",
|
"Banking",
|
||||||
new Dictionary<string, object?> { ["fileName"] = fileName, ["message"] = message }));
|
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)
|
public Task UserIssueAsync(string title, string message, string userAccountId, IReadOnlyDictionary<string, object?>? context = null)
|
||||||
{
|
{
|
||||||
Dictionary<string, object?> ctx = context == null
|
Dictionary<string, object?> ctx = context == null
|
||||||
@@ -181,6 +194,8 @@ public sealed class EventService : IEventService
|
|||||||
BankingImportMessage(domainEvent),
|
BankingImportMessage(domainEvent),
|
||||||
DomainEventType.BankingImportFailed =>
|
DomainEventType.BankingImportFailed =>
|
||||||
Ctx(domainEvent, "message"),
|
Ctx(domainEvent, "message"),
|
||||||
|
DomainEventType.BankingTransactionMarkedDone =>
|
||||||
|
BankingTransactionMarkedDoneMessage(domainEvent),
|
||||||
DomainEventType.UserIssue =>
|
DomainEventType.UserIssue =>
|
||||||
Ctx(domainEvent, "message"),
|
Ctx(domainEvent, "message"),
|
||||||
_ => domainEvent.Title
|
_ => domainEvent.Title
|
||||||
@@ -242,6 +257,29 @@ public sealed class EventService : IEventService
|
|||||||
return DateTime.TryParse(value.ToString(), out var parsed) ? parsed : null;
|
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)
|
private static Dictionary<string, object?> InvoiceContext(FdsInvoiceData invoice)
|
||||||
{
|
{
|
||||||
string invoiceNumber = invoice.InvoiceId;
|
string invoiceNumber = invoice.InvoiceId;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ public interface IEventService
|
|||||||
|
|
||||||
Task BankingTransactionsImportedAsync(DateTime? from, DateTime? to, int rows, string fileName, string userAccountId);
|
Task BankingTransactionsImportedAsync(DateTime? from, DateTime? to, int rows, string fileName, string userAccountId);
|
||||||
Task BankingImportIssueAsync(string message, 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);
|
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)
|
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())
|
if (!app.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
app.UseExceptionHandler("/error");
|
app.UseExceptionHandler("/error");
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ public class BankingService : IBankingService
|
|||||||
using var act = FuchsTelemetry.StartActivity("banking.parse");
|
using var act = FuchsTelemetry.StartActivity("banking.parse");
|
||||||
var sw = Stopwatch.StartNew();
|
var sw = Stopwatch.StartNew();
|
||||||
var tbl = schemaDatatable?.Clone() ?? BuildDefaultSchema();
|
var tbl = schemaDatatable?.Clone() ?? BuildDefaultSchema();
|
||||||
|
ApplyKnownColumnWidths(tbl);
|
||||||
var diag = new ParseDiagnostics();
|
var diag = new ParseDiagnostics();
|
||||||
|
|
||||||
// Buffer once so we can sniff the format and (re)parse from the bytes.
|
// 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()
|
private static DataTable BuildDefaultSchema()
|
||||||
{
|
{
|
||||||
var t = new DataTable();
|
var t = new DataTable();
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
},
|
},
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Debug",
|
"Default": "Warning",
|
||||||
"Microsoft.AspNetCore": "Information"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Fuchs": {
|
"Fuchs": {
|
||||||
@@ -22,5 +22,10 @@
|
|||||||
"InvoiceContainer": "dev-fuchs-invoices",
|
"InvoiceContainer": "dev-fuchs-invoices",
|
||||||
"ReminderContainer": "dev-fuchs-reminders"
|
"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--SMS-APIKey",
|
||||||
"Fuchs--Mailer--Token",
|
"Fuchs--Mailer--Token",
|
||||||
"Fuchs--fuchs-captcha-TOTP",
|
"Fuchs--fuchs-captcha-TOTP",
|
||||||
"Fuchs--fuchs-intranet-TOTP"
|
"Fuchs--fuchs-intranet-TOTP",
|
||||||
|
"Fds--MFR-UserName",
|
||||||
|
"Fds--MFR-Password"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"Logging": {
|
"Logging": {
|
||||||
@@ -55,5 +57,10 @@
|
|||||||
"Enabled": true,
|
"Enabled": true,
|
||||||
"OtlpEndpoint": ""
|
"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 {
|
#notification_frame {
|
||||||
position: fixed;
|
height: 100%;
|
||||||
bottom: 1rem;
|
overflow: hidden;
|
||||||
right: 1rem;
|
|
||||||
z-index: 2000;
|
|
||||||
width: min(24rem, calc(100vw - 2rem));
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification_item {
|
.notification_item {
|
||||||
position: relative;
|
position: relative;
|
||||||
background: #fff;
|
flex: 0 0 $oci_header;
|
||||||
border-left: 0.35rem solid $fuchs_blau;
|
height: $oci_header;
|
||||||
border-radius: 0.35rem;
|
box-sizing: border-box;
|
||||||
box-shadow: 0 0.25rem 1rem rgba(30, 35, 45, 0.25);
|
display: flex;
|
||||||
color: #222;
|
align-items: center;
|
||||||
padding: 0.75rem 2.2rem 0.75rem 0.85rem;
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
color: $fuchs_weiss;
|
||||||
|
padding: 0 2.2rem 0 0.85rem;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
|
|
||||||
&.warn {
|
&.warn, &.warning, &.error {
|
||||||
border-left-color: #c78300;
|
color: $oci_yellow;
|
||||||
}
|
|
||||||
|
|
||||||
&.error {
|
|
||||||
border-left-color: #b92525;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification_title {
|
.notification_title {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
line-height: 1.25;
|
flex: 0 0 auto;
|
||||||
margin-bottom: 0.2rem;
|
|
||||||
|
&::after {
|
||||||
|
content: ":";
|
||||||
|
margin-right: 0.35rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification_message {
|
.notification_message {
|
||||||
font-size: 0.9rem;
|
overflow: hidden;
|
||||||
line-height: 1.3;
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification_close {
|
.notification_close {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0.35rem;
|
top: 0;
|
||||||
right: 0.45rem;
|
right: 0.45rem;
|
||||||
|
height: 100%;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #444;
|
color: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
line-height: 1;
|
line-height: $oci_header;
|
||||||
padding: 0.1rem 0.25rem;
|
padding: 0 0.25rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -244,8 +244,22 @@ $fis.notifications = {
|
|||||||
this.connection.on('notification', (notification) => {
|
this.connection.on('notification', (notification) => {
|
||||||
this.push(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;
|
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 () {
|
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 {
|
#notification_frame {
|
||||||
position: fixed;
|
height: 100%;
|
||||||
bottom: 1rem;
|
overflow: hidden;
|
||||||
right: 1rem;
|
|
||||||
z-index: 2000;
|
|
||||||
width: min(24rem, 100vw - 2rem);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification_item {
|
.notification_item {
|
||||||
position: relative;
|
position: relative;
|
||||||
background: #fff;
|
flex: 0 0 2.3rem;
|
||||||
border-left: 0.35rem solid rgb(27, 67, 121);
|
height: 2.3rem;
|
||||||
border-radius: 0.35rem;
|
box-sizing: border-box;
|
||||||
box-shadow: 0 0.25rem 1rem rgba(30, 35, 45, 0.25);
|
display: flex;
|
||||||
color: #222;
|
align-items: center;
|
||||||
padding: 0.75rem 2.2rem 0.75rem 0.85rem;
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #FFF;
|
||||||
|
padding: 0 2.2rem 0 0.85rem;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
.notification_item.warn {
|
.notification_item.warn, .notification_item.warning, .notification_item.error {
|
||||||
border-left-color: #c78300;
|
color: rgb(255, 200, 1);
|
||||||
}
|
|
||||||
.notification_item.error {
|
|
||||||
border-left-color: #b92525;
|
|
||||||
}
|
}
|
||||||
.notification_item .notification_title {
|
.notification_item .notification_title {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
line-height: 1.25;
|
flex: 0 0 auto;
|
||||||
margin-bottom: 0.2rem;
|
}
|
||||||
|
.notification_item .notification_title::after {
|
||||||
|
content: ":";
|
||||||
|
margin-right: 0.35rem;
|
||||||
}
|
}
|
||||||
.notification_item .notification_message {
|
.notification_item .notification_message {
|
||||||
font-size: 0.9rem;
|
overflow: hidden;
|
||||||
line-height: 1.3;
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.notification_item .notification_close {
|
.notification_item .notification_close {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0.35rem;
|
top: 0;
|
||||||
right: 0.45rem;
|
right: 0.45rem;
|
||||||
|
height: 100%;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #444;
|
color: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
line-height: 1;
|
line-height: 2.3rem;
|
||||||
padding: 0.1rem 0.25rem;
|
padding: 0 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wdg_frame {
|
.wdg_frame {
|
||||||
|
|||||||
@@ -3088,8 +3088,22 @@ $fis.notifications = {
|
|||||||
this.connection.on('notification', (notification) => {
|
this.connection.on('notification', (notification) => {
|
||||||
this.push(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;
|
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 () {
|
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
@@ -18,8 +18,8 @@ RETURNS @bankingtransactions TABLE
|
|||||||
,[ValueDate] date
|
,[ValueDate] date
|
||||||
,[Amount] numeric(9,2)
|
,[Amount] numeric(9,2)
|
||||||
,[AccountNumberOfPayer] varchar(30)
|
,[AccountNumberOfPayer] varchar(30)
|
||||||
,[NameOfPayer] nvarchar(60)
|
,[NameOfPayer] nvarchar(140)
|
||||||
,[SepaRemittanceInformation] varchar(150)
|
,[SepaRemittanceInformation] varchar(200)
|
||||||
,[EndToEndReference] varchar(50)
|
,[EndToEndReference] varchar(50)
|
||||||
,[manu] bit
|
,[manu] bit
|
||||||
,[InvId] varchar(15)
|
,[InvId] varchar(15)
|
||||||
|
|||||||
@@ -34,7 +34,12 @@ BEGIN
|
|||||||
;
|
;
|
||||||
|
|
||||||
|
|
||||||
--output to confirm as boolean
|
--output to confirm as boolean, plus the transaction data needed for the notification message
|
||||||
SELECT CAST( (CASE WHEN ISNULL((SELECT TOP(1) [done_manually] FROM @out), '') <> '' THEN 1 ELSE 0 END) as bit);
|
SELECT
|
||||||
|
CAST( (CASE WHEN ISNULL((SELECT TOP(1) [done_manually] FROM @out), '') <> '' THEN 1 ELSE 0 END) as bit) as [success]
|
||||||
|
,b.[ValueDate]
|
||||||
|
,b.[Amount]
|
||||||
|
FROM (SELECT 1 as [dummy]) as d
|
||||||
|
LEFT JOIN [dbo].[fds__bankingtransactions] as b ON b.[taID] = @taID;
|
||||||
|
|
||||||
END
|
END
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
[EndToEndReference] VARCHAR (50) NULL,
|
[EndToEndReference] VARCHAR (50) NULL,
|
||||||
[JournalNumber] VARCHAR (10) NULL,
|
[JournalNumber] VARCHAR (10) NULL,
|
||||||
[MandateReference] VARCHAR (50) NULL,
|
[MandateReference] VARCHAR (50) NULL,
|
||||||
[NameOfPayer] NVARCHAR (60) NULL,
|
[NameOfPayer] NVARCHAR (140) NULL,
|
||||||
[OriginalAmount] VARCHAR (150) NULL,
|
[OriginalAmount] VARCHAR (150) NULL,
|
||||||
[OriginatorsIdentificationCode] VARCHAR (150) NULL,
|
[OriginatorsIdentificationCode] VARCHAR (150) NULL,
|
||||||
[PayersReferenceParty] VARCHAR (150) NULL,
|
[PayersReferenceParty] VARCHAR (150) NULL,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
[EndToEndReference] VARCHAR (50) NULL,
|
[EndToEndReference] VARCHAR (50) NULL,
|
||||||
[JournalNumber] VARCHAR (10) NULL,
|
[JournalNumber] VARCHAR (10) NULL,
|
||||||
[MandateReference] VARCHAR (50) NULL,
|
[MandateReference] VARCHAR (50) NULL,
|
||||||
[NameOfPayer] NVARCHAR (60) NULL,
|
[NameOfPayer] NVARCHAR (140) NULL,
|
||||||
[OriginalAmount] VARCHAR (150) NULL,
|
[OriginalAmount] VARCHAR (150) NULL,
|
||||||
[OriginatorsIdentificationCode] VARCHAR (150) NULL,
|
[OriginatorsIdentificationCode] VARCHAR (150) NULL,
|
||||||
[PayersReferenceParty] VARCHAR (150) NULL,
|
[PayersReferenceParty] VARCHAR (150) NULL,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
[EndToEndReference] VARCHAR (50) NULL,
|
[EndToEndReference] VARCHAR (50) NULL,
|
||||||
[JournalNumber] VARCHAR (10) NULL,
|
[JournalNumber] VARCHAR (10) NULL,
|
||||||
[MandateReference] VARCHAR (50) NULL,
|
[MandateReference] VARCHAR (50) NULL,
|
||||||
[NameOfPayer] NVARCHAR (60) NULL,
|
[NameOfPayer] NVARCHAR (140) NULL,
|
||||||
[OriginalAmount] VARCHAR (150) NULL,
|
[OriginalAmount] VARCHAR (150) NULL,
|
||||||
[OriginatorsIdentificationCode] VARCHAR (150) NULL,
|
[OriginatorsIdentificationCode] VARCHAR (150) NULL,
|
||||||
[PayersReferenceParty] VARCHAR (150) NULL,
|
[PayersReferenceParty] VARCHAR (150) NULL,
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ public class MFRClientConfig
|
|||||||
|
|
||||||
public MFRClientConfig(string url)
|
public MFRClientConfig(string url)
|
||||||
{
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(url))
|
||||||
|
throw new ArgumentException("MFR host must not be null or empty (check the 'Fds:MFR_host' configuration entry).", nameof(url));
|
||||||
|
|
||||||
BaseUrl = url.StartsWith("http") ? url : $"https://{url.Trim()}/odata/";
|
BaseUrl = url.StartsWith("http") ? url : $"https://{url.Trim()}/odata/";
|
||||||
if (!BaseUrl.EndsWith("/")) BaseUrl += "/";
|
if (!BaseUrl.EndsWith("/")) BaseUrl += "/";
|
||||||
// Derive the REST root (/mfr/) from the same scheme+host as the OData root.
|
// Derive the REST root (/mfr/) from the same scheme+host as the OData root.
|
||||||
|
|||||||
Reference in New Issue
Block a user