Compare commits
2
Commits
c741535aec
...
d0002ccdad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0002ccdad | ||
|
|
0a8384ab33 |
@@ -0,0 +1,165 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Fuchs.intranet;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Invoice amounts reach the procs as exact, cent-rounded decimals, independent of the server
|
||||
/// culture. Regression for the Stornorechnung save failure: the VAT amount was formatted
|
||||
/// invariantly ("-615.3757999999999", unrounded double from a reloaded Storno line) and re-parsed
|
||||
/// under de-DE as -6153757999999999 → "Error converting data type float to numeric".
|
||||
/// </summary>
|
||||
public class InvoiceAmountParamTests : IDisposable
|
||||
{
|
||||
private readonly CultureInfo _culture = CultureInfo.CurrentCulture;
|
||||
public void Dispose() => CultureInfo.CurrentCulture = _culture;
|
||||
|
||||
private static readonly string[] AmountParams =
|
||||
{ "@InvoiceBalance", "@InvoiceBalance_net", "@InvoiceVAT_net1", "@InvoiceService_net", "@InvoiceService_VAT" };
|
||||
|
||||
/// <summary>The exact shape InvoiceDraftEditService.BuildFdsData produces: decimal sums, VAT map by rate.</summary>
|
||||
private static FdsInvoiceData StornoDraft(object vatAmount) => new(new JObject
|
||||
{
|
||||
["admin"] = new JObject { ["type"] = "c", ["customerid"] = "7" },
|
||||
["new"] = new JObject { ["title"] = "Stornorechnung", ["total_net"] = -3238.82m, ["total_gross"] = -3854.20m },
|
||||
["sms"] = new JObject
|
||||
{
|
||||
["tscn"] = -1234.56m, ["tscvat"] = -234.57m,
|
||||
["vat"] = new JObject { ["19"] = JToken.FromObject(vatAmount) }
|
||||
}
|
||||
});
|
||||
|
||||
private static decimal Val(List<SqlParameter> pl, string name)
|
||||
{
|
||||
var p = pl.Single(x => x.ParameterName == name);
|
||||
Assert.Equal(SqlDbType.Decimal, p.SqlDbType);
|
||||
return Assert.IsType<decimal>(p.Value);
|
||||
}
|
||||
|
||||
public static TheoryData<string, object> CulturesAndVat => new()
|
||||
{
|
||||
{ "de-DE", -615.38m },
|
||||
{ "en-US", -615.38m },
|
||||
{ "de-DE", -615.3757999999999 }, // unrounded double from a reloaded Storno line (net * vat * 0.01)
|
||||
{ "en-US", -615.3757999999999 },
|
||||
{ "de-DE", -615.3800000000001 },
|
||||
{ "", -615.38m }, // invariant
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(CulturesAndVat))]
|
||||
public void BuildInvoiceParams_StornoDraft_AmountsExactInEveryCulture(string culture, object vatAmount)
|
||||
{
|
||||
CultureInfo.CurrentCulture = new CultureInfo(culture);
|
||||
|
||||
var pl = StornoDraft(vatAmount).BuildInvoiceParams(change: true, invId: "abc");
|
||||
|
||||
Assert.Equal(-3854.20m, Val(pl, "@InvoiceBalance"));
|
||||
Assert.Equal(-3238.82m, Val(pl, "@InvoiceBalance_net"));
|
||||
Assert.Equal(-615.38m, Val(pl, "@InvoiceVAT_net1"));
|
||||
Assert.Equal(-1234.56m, Val(pl, "@InvoiceService_net"));
|
||||
Assert.Equal(-234.57m, Val(pl, "@InvoiceService_VAT"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildInvoiceParams_AmountParams_MatchProcPrecisionAndScale()
|
||||
{
|
||||
var pl = StornoDraft(-615.38m).BuildInvoiceParams(change: false, invId: "");
|
||||
|
||||
foreach (var name in AmountParams)
|
||||
{
|
||||
var p = pl.Single(x => x.ParameterName == name);
|
||||
Assert.Equal(10, p.Precision);
|
||||
// fds__setInvoice/fds__createInvoice: numeric(10,3); fds__createInvoice_Details: numeric(10,2).
|
||||
Assert.Equal(name.StartsWith("@InvoiceService_") ? 2 : 3, p.Scale);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-10_000_000.00)]
|
||||
[InlineData(10_000_000.00)]
|
||||
[InlineData(-6153757999999999.0)]
|
||||
public void BuildInvoiceParams_AmountOutOfProcRange_Throws(double vat)
|
||||
{
|
||||
var ex = Assert.Throws<InvoiceAmountOutOfRangeException>(() =>
|
||||
StornoDraft(vat).BuildInvoiceParams(change: true, invId: "abc"));
|
||||
|
||||
Assert.Equal("@InvoiceVAT_net1", ex.Parameter);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(9_999_999.99)]
|
||||
[InlineData(-9_999_999.99)]
|
||||
[InlineData(0.0)]
|
||||
public void BuildInvoiceParams_AmountAtProcRangeBoundary_Accepted(double vat)
|
||||
{
|
||||
var pl = StornoDraft(vat).BuildInvoiceParams(change: true, invId: "abc");
|
||||
|
||||
Assert.Equal((decimal)vat, Val(pl, "@InvoiceVAT_net1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildInvoiceParams_MissingAmounts_KeepPreviousNullAndZeroSemantics()
|
||||
{
|
||||
var noSms = new FdsInvoiceData(new JObject { ["admin"] = new JObject(), ["new"] = new JObject() })
|
||||
.BuildInvoiceParams(change: false, invId: "");
|
||||
Assert.Equal(DBNull.Value, noSms.Single(p => p.ParameterName == "@InvoiceBalance").Value);
|
||||
Assert.Equal(0m, noSms.Single(p => p.ParameterName == "@InvoiceService_net").Value); // no sms block → 0
|
||||
Assert.Equal(0m, noSms.Single(p => p.ParameterName == "@InvoiceVAT_net1").Value); // HighestVat → "0"
|
||||
|
||||
var emptySms = new FdsInvoiceData(new JObject { ["new"] = new JObject(), ["sms"] = new JObject() })
|
||||
.BuildInvoiceParams(change: false, invId: "");
|
||||
Assert.Equal(DBNull.Value, emptySms.Single(p => p.ParameterName == "@InvoiceService_net").Value); // key missing → NULL
|
||||
|
||||
// No "new" block at all: the legacy SQL_Float(nz(...) ?? "0") sent 0, not NULL.
|
||||
var noNew = new FdsInvoiceData(new JObject { ["admin"] = new JObject(), ["sms"] = new JObject() })
|
||||
.BuildInvoiceParams(change: false, invId: "");
|
||||
Assert.Equal(0m, noNew.Single(p => p.ParameterName == "@InvoiceBalance").Value);
|
||||
Assert.Equal(0m, noNew.Single(p => p.ParameterName == "@InvoiceBalance_net").Value);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("-19.95", "-19.95")]
|
||||
[InlineData("-19,95", "-19.95")]
|
||||
[InlineData("-1234.5600000000002", "-1234.56")]
|
||||
[InlineData("-615.3757999999999", "-615.38")]
|
||||
[InlineData("1234,565", "1234.57")] // rounded half away from zero
|
||||
[InlineData("-1234,565", "-1234.57")]
|
||||
[InlineData("-1.5E-05", "0")]
|
||||
[InlineData(" 42 ", "42")]
|
||||
public void ParseAmount_Strings_ParsedCultureIndependentlyAndRoundedToCents(string raw, string expected)
|
||||
{
|
||||
foreach (var c in new[] { "de-DE", "en-US" })
|
||||
{
|
||||
CultureInfo.CurrentCulture = new CultureInfo(c);
|
||||
Assert.Equal(decimal.Parse(expected, CultureInfo.InvariantCulture), FdsInvoiceData.ParseAmount(raw));
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("NaN")]
|
||||
[InlineData("abc")]
|
||||
[InlineData(double.NaN)]
|
||||
[InlineData(double.PositiveInfinity)]
|
||||
public void ParseAmount_EmptyOrInvalid_IsNull(object? raw) =>
|
||||
Assert.Null(FdsInvoiceData.ParseAmount(raw));
|
||||
|
||||
[Fact]
|
||||
public void ParseAmount_NumbersAndJValues_TakenAsIs()
|
||||
{
|
||||
Assert.Equal(-3238.82m, FdsInvoiceData.ParseAmount(-3238.82m));
|
||||
Assert.Equal(-3238.82m, FdsInvoiceData.ParseAmount(-3238.82));
|
||||
Assert.Equal(-12m, FdsInvoiceData.ParseAmount(-12));
|
||||
Assert.Equal(-3238.82m, FdsInvoiceData.ParseAmount(new JValue(-3238.82m)));
|
||||
Assert.Null(FdsInvoiceData.ParseAmount(JValue.CreateNull()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System;
|
||||
using Fuchs.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The invoice header save batch must stop as soon as fds__createInvoice / fds__setInvoice fails:
|
||||
/// previously a parameter conversion error left @Id NULL and the batch went on to
|
||||
/// fds__createInvoice_Details, whose "Cannot insert NULL into InvId" buried the real error.
|
||||
/// </summary>
|
||||
public class InvoiceRegisterSqlTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(false, "[dbo].[fds__createInvoice] ", "fds__createInvoice did not return")]
|
||||
[InlineData(true, "[dbo].[fds__setInvoice] ", "fds__setInvoice did not return")]
|
||||
public void BuildRegisterInvoiceSql_GuardSitsBetweenHeaderProcAndDetails(bool change, string headerProc, string guardText)
|
||||
{
|
||||
string sql = InvoiceService.BuildRegisterInvoiceSql(change, hasProvisionLocation: true);
|
||||
|
||||
int header = sql.IndexOf(headerProc, StringComparison.Ordinal);
|
||||
int guard = sql.IndexOf("IF @Id IS NULL THROW", StringComparison.Ordinal);
|
||||
int details = sql.IndexOf("[dbo].[fds__createInvoice_Details]", StringComparison.Ordinal);
|
||||
int update = sql.IndexOf("UPDATE [dbo].[fds__invoices]", StringComparison.Ordinal);
|
||||
|
||||
Assert.True(header >= 0, "header proc missing");
|
||||
Assert.True(header < guard && guard < details && details < update, sql);
|
||||
Assert.Contains(guardText, sql);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void BuildRegisterInvoiceSql_ExactlyOneHeaderProcPerMode(bool change)
|
||||
{
|
||||
string sql = InvoiceService.BuildRegisterInvoiceSql(change, hasProvisionLocation: false);
|
||||
|
||||
Assert.Equal(change, sql.Contains("[dbo].[fds__setInvoice] @InvId,"));
|
||||
Assert.Equal(!change, sql.Contains("[dbo].[fds__createInvoice] @InvoiceType,"));
|
||||
Assert.Equal(change, sql.Contains("@InvId"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, false)]
|
||||
[InlineData(true, true)]
|
||||
public void BuildRegisterInvoiceSql_ProvisionLocationUpdateOnlyWhenPresent(bool hasProvisionLocation, bool expected)
|
||||
{
|
||||
string sql = InvoiceService.BuildRegisterInvoiceSql(change: true, hasProvisionLocation);
|
||||
|
||||
Assert.Equal(expected, sql.Contains("@ProvisionLocation"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRegisterInvoiceSql_EveryStatementIsSemicolonTerminated()
|
||||
{
|
||||
// THROW requires the preceding statement to be terminated with ';'.
|
||||
string sql = InvoiceService.BuildRegisterInvoiceSql(change: true, hasProvisionLocation: true);
|
||||
|
||||
foreach (var line in sql.Split('\n'))
|
||||
Assert.EndsWith(";", line.TrimEnd());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Fuchs.intranet;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
using static OCORE.SQL.sql;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for the OCORE string → number SQL parameter helpers. Saving a Stornorechnung
|
||||
/// failed in production with "Error converting data type float to numeric": the negative totals
|
||||
/// did not parse in the first (sign-less) attempt and fell back to the server's de-DE culture,
|
||||
/// where "." is a group separator — "-19.95" became -1995 and JS float noise
|
||||
/// ("-1234.5600000000002") overflowed numeric(10,3), so fds__setInvoice never ran.
|
||||
/// Every test runs under de-DE, the production server culture.
|
||||
/// </summary>
|
||||
public class SqlNumberParameterTests : IDisposable
|
||||
{
|
||||
private readonly CultureInfo _culture = CultureInfo.CurrentCulture;
|
||||
|
||||
public SqlNumberParameterTests() => CultureInfo.CurrentCulture = new CultureInfo("de-DE");
|
||||
|
||||
public void Dispose() => CultureInfo.CurrentCulture = _culture;
|
||||
|
||||
[Theory]
|
||||
[InlineData("-19.95", -19.95)]
|
||||
[InlineData("-1234.56", -1234.56)]
|
||||
[InlineData("-1234.5600000000002", -1234.56)]
|
||||
[InlineData("-0.5", -0.5)]
|
||||
[InlineData("1234.56", 1234.56)]
|
||||
[InlineData("1234.5600000000002", 1234.56)]
|
||||
[InlineData("-1,5", -1.5)]
|
||||
[InlineData("-1.234,56", -1234.56)]
|
||||
[InlineData("1.234,56", 1234.56)]
|
||||
[InlineData("1,234.56", 1234.56)]
|
||||
[InlineData(" -42 ", -42)]
|
||||
[InlineData("-1.5E-05", -0.000015)]
|
||||
[InlineData("0", 0)]
|
||||
public void SQL_Float_StringValue_ParsesSignedAndCultureFormattedNumbers(string input, double expected)
|
||||
{
|
||||
var p = SQL_Float("@v", stringvalue: input);
|
||||
|
||||
var value = Assert.IsType<float>(p.Value);
|
||||
Assert.Equal(expected, value, 0.01);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("-19.95", -19.95)]
|
||||
[InlineData("-1234.56", -1234.56)]
|
||||
[InlineData("-1.234,56", -1234.56)]
|
||||
[InlineData("1.234,56", 1234.56)]
|
||||
[InlineData("-1,5", -1.5)]
|
||||
public void SQL_Decimal_StringValue_ParsesSignedAndCultureFormattedNumbers(string input, double expected)
|
||||
{
|
||||
var p = SQL_Decimal("@v", input, precision: 10, scale: 3);
|
||||
|
||||
var value = Assert.IsType<decimal>(p.Value);
|
||||
Assert.Equal((decimal)expected, value);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("NaN")]
|
||||
[InlineData("abc")]
|
||||
[InlineData("--5")]
|
||||
public void SQL_Float_StringValue_InvalidOrEmpty_IsDbNull(string input)
|
||||
{
|
||||
var p = SQL_Float("@v", stringvalue: input);
|
||||
|
||||
Assert.Equal(DBNull.Value, p.Value);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("NaN")]
|
||||
[InlineData("abc")]
|
||||
public void SQL_Decimal_StringValue_InvalidOrEmpty_IsDbNull(string input)
|
||||
{
|
||||
var p = SQL_Decimal("@v", input);
|
||||
|
||||
Assert.Equal(DBNull.Value, p.Value);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -475,7 +475,9 @@ public partial class IntranetController
|
||||
{
|
||||
["Id"] = d["Id"],
|
||||
["net_val"] = net,
|
||||
["vat_val"] = net * vat * 0.01,
|
||||
// Cent-rounded in decimal like InvoiceDraftCalculator; the former double product
|
||||
// (e.g. -615.3757999999999) carried float noise into the draft's VAT sums.
|
||||
["vat_val"] = Math.Round((decimal)net * (decimal)vat / 100m, 2, MidpointRounding.AwayFromZero),
|
||||
["vat"] = vat == 0 ? "" : vat.ToString("0.00", FuchsPdf.DeCulture) + "%",
|
||||
["svcnet_val"] = d.no("value_service", 0),
|
||||
["net"] = d.no("value", 0),
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="4.1.2" />
|
||||
<PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.12" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="10.0.12" />
|
||||
<PackageReference Include="Azure.Storage.Blobs" Version="12.29.2" />
|
||||
<PackageReference Include="Azure.Storage.Blobs" Version="12.30.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Folder Include="App_Data\cache\" />
|
||||
|
||||
@@ -70,6 +70,8 @@ public class InvoiceService : IInvoiceService
|
||||
|
||||
private string Conn => _intranet.Intranet__SQLConnectionString;
|
||||
|
||||
private static readonly EventId InvoiceAmountRejected = new(0, "InvoiceAmountRejected");
|
||||
|
||||
public async Task<FdsInvoiceData> LoadInvoiceAsync(string id, string userAccountId, DatabaseSecurity dbSec)
|
||||
{
|
||||
_logger.LogDebug("LoadInvoiceAsync id={Id} user={User}", id, userAccountId);
|
||||
@@ -151,6 +153,27 @@ public class InvoiceService : IInvoiceService
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The header save batch for <see cref="RegisterInvoiceAsync"/>. If the create/set proc fails
|
||||
/// (e.g. a parameter conversion error) the batch would otherwise carry on with a NULL
|
||||
/// <c>@Id</c> and fail again in <c>fds__createInvoice_Details</c> ("Cannot insert NULL into
|
||||
/// InvId"), burying the real error; the guard stops the batch right after the first failure.
|
||||
/// </summary>
|
||||
internal static string BuildRegisterInvoiceSql(bool change, bool hasProvisionLocation)
|
||||
{
|
||||
var sqlParts = new List<string> { "DECLARE @Id varchar(10);" };
|
||||
sqlParts.Add(change
|
||||
? "EXECUTE [dbo].[fds__setInvoice] @InvId, @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @SendToEmailCc, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT, @SendToAddressJson;"
|
||||
: "EXECUTE [dbo].[fds__createInvoice] @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @SendToEmailCc, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT, @SendToAddressJson;");
|
||||
sqlParts.Add(change
|
||||
? "IF @Id IS NULL THROW 50001, N'fds__setInvoice did not return an invoice id - invoice header not saved.', 1;"
|
||||
: "IF @Id IS NULL THROW 50001, N'fds__createInvoice did not return an invoice id - invoice header not saved.', 1;");
|
||||
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice_Details] @Id, @InvoiceService_net, @InvoiceService_VAT, @InvoiceOptions, @authuser;");
|
||||
if (hasProvisionLocation)
|
||||
sqlParts.Add("UPDATE [dbo].[fds__invoices] SET [ProvisionLocation] = LEFT(@ProvisionLocation,1000) WHERE [Id] = @Id AND [isFinal] = 0;");
|
||||
return string.Join("\n", sqlParts);
|
||||
}
|
||||
|
||||
public async Task<FdsInvoiceData> RegisterInvoiceAsync(FdsInvoiceData invoice, bool change, string invId,
|
||||
string userAccountId, DatabaseSecurity dbSec)
|
||||
{
|
||||
@@ -158,28 +181,26 @@ public class InvoiceService : IInvoiceService
|
||||
if (invoice.NewValues == null) { _logger.LogWarning("RegisterInvoiceAsync: no form values supplied (user={User})", userAccountId); return invoice; }
|
||||
|
||||
var pl = new List<SqlParameter> { SQL_VarChar("@authuser", userAccountId) };
|
||||
try
|
||||
{
|
||||
pl.AddRange(invoice.BuildInvoiceParams(change, invId));
|
||||
}
|
||||
catch (InvoiceAmountOutOfRangeException ex)
|
||||
{
|
||||
// Refuse rather than store a wrong amount; an empty registration makes the caller report the save as failed.
|
||||
_logger.LogError(InvoiceAmountRejected, ex,
|
||||
"RegisterInvoiceAsync: refused invoice {InvId} - amount {Parameter} = {Value} is out of range (user={User})",
|
||||
invId, ex.Parameter, ex.Value, userAccountId);
|
||||
return invoice;
|
||||
}
|
||||
|
||||
var sqlParts = new List<string> { "DECLARE @Id varchar(10);" };
|
||||
if (!change)
|
||||
{
|
||||
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice] @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @SendToEmailCc, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT, @SendToAddressJson;");
|
||||
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice_Details] @Id, @InvoiceService_net, @InvoiceService_VAT, @InvoiceOptions, @authuser;");
|
||||
}
|
||||
else
|
||||
{
|
||||
pl.Add(SQL_VarChar("@InvId", invId));
|
||||
sqlParts.Add("EXECUTE [dbo].[fds__setInvoice] @InvId, @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @SendToEmailCc, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT, @SendToAddressJson;");
|
||||
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice_Details] @Id, @InvoiceService_net, @InvoiceService_VAT, @InvoiceOptions, @authuser;");
|
||||
}
|
||||
if (invoice.RawProvisionLocation.Length > 0)
|
||||
{
|
||||
if (change) pl.Add(SQL_VarChar("@InvId", invId));
|
||||
bool hasProvisionLocation = invoice.RawProvisionLocation.Length > 0;
|
||||
if (hasProvisionLocation)
|
||||
pl.Add(SQL_NVarChar("@ProvisionLocation",
|
||||
string.Join("\n", invoice.RawProvisionLocation).LeftToFirst("<!--", emptyIfNotFound: false)));
|
||||
sqlParts.Add("UPDATE [dbo].[fds__invoices] SET [ProvisionLocation] = LEFT(@ProvisionLocation,1000) WHERE [Id] = @Id AND [isFinal] = 0;");
|
||||
}
|
||||
|
||||
var invdset = await getSQLDataSet_async(string.Join("\n", sqlParts),
|
||||
var invdset = await getSQLDataSet_async(BuildRegisterInvoiceSql(change, hasProvisionLocation),
|
||||
Conn, pl, tablenames: new[] { "inv", "det" },
|
||||
Security: dbSec, options: new FIS_SQLOptions());
|
||||
if (!string.IsNullOrEmpty(invdset.Exception))
|
||||
|
||||
@@ -173,9 +173,9 @@ public class FdsInvoiceData
|
||||
{
|
||||
SQL_VarChar("@InvoiceType", Admin?.nz("type") ?? ""),
|
||||
SQL_NVarChar("@InvoiceTitle", NewValues?.nz("title") ?? ""),
|
||||
SQL_Float("@InvoiceBalance", stringvalue: NewValues?.nz("total_gross") ?? "0"),
|
||||
SQL_Float("@InvoiceBalance_net", stringvalue: NewValues?.nz("total_net") ?? "0"),
|
||||
SQL_Float("@InvoiceVAT_net1", stringvalue: vatNet),
|
||||
AmountParam("@InvoiceBalance", NewValues is null ? 0m : NewValues.getItem("total_gross"), scale: 3),
|
||||
AmountParam("@InvoiceBalance_net", NewValues is null ? 0m : NewValues.getItem("total_net"), scale: 3),
|
||||
AmountParam("@InvoiceVAT_net1", vatNet, scale: 3),
|
||||
SQL_VarChar("@InvoiceVAT_1", vatRate),
|
||||
SQL_VarChar("@PaymentTerm", NewValues?.nz("paymentterm") ?? "", dbNull_IfEmpty: true),
|
||||
SQL_BigInt("@CustomerId", Admin?.nz("customerid") ?? ""),
|
||||
@@ -185,12 +185,63 @@ public class FdsInvoiceData
|
||||
SQL_NVarChar("@ProvisionPeriod", RawProvisionPeriod, dbNull_IfEmpty: true),
|
||||
SQL_NVarChar("@CustomValues", RawCustomValues, dbNull_IfEmpty: true),
|
||||
SQL_NVarChar("@SendToAddressJson", SendToAddressJsonForPersist(), dbNull_IfEmpty: true),
|
||||
SQL_Float("@InvoiceService_net", stringvalue: Sms?.nz("tscn") ?? "0"),
|
||||
SQL_Float("@InvoiceService_VAT", stringvalue: Sms?.nz("tscvat") ?? "0"),
|
||||
AmountParam("@InvoiceService_net", Sms is null ? 0m : Sms.getItem("tscn"), scale: 2),
|
||||
AmountParam("@InvoiceService_VAT", Sms is null ? 0m : Sms.getItem("tscvat"), scale: 2),
|
||||
SQL_VarChar("@InvoiceOptions", BuildInvoiceOptions(), dbNull_IfEmpty: true)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Largest magnitude the <c>numeric(10,x)</c> amount parameters of the invoice procs accept.</summary>
|
||||
internal const decimal MaxAmount = 9_999_999.99m;
|
||||
|
||||
/// <summary>
|
||||
/// Invoice amounts go to SQL as exact <c>decimal</c> parameters rounded to cents — never through
|
||||
/// a culture-dependent string → float round trip. That round trip once parsed negative amounts
|
||||
/// with the server's de-DE culture ("-615.3758" → -6153758, with float noise beyond numeric(10,3)),
|
||||
/// so Stornorechnungen could not be saved. An amount outside the procs' range is refused here
|
||||
/// (<see cref="InvoiceAmountOutOfRangeException"/>) instead of being stored wrongly or failing in SQL.
|
||||
/// </summary>
|
||||
internal static SqlParameter AmountParam(string name, object? raw, byte scale)
|
||||
{
|
||||
decimal? value = ParseAmount(raw);
|
||||
if (value is { } v && Math.Abs(v) > MaxAmount)
|
||||
throw new InvoiceAmountOutOfRangeException(name, v);
|
||||
return new SqlParameter(name, System.Data.SqlDbType.Decimal)
|
||||
{
|
||||
Precision = 10,
|
||||
Scale = scale,
|
||||
Value = value.HasValue ? value.Value : DBNull.Value
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads an amount the draft session or editor supplied: numbers are taken as-is; strings may be
|
||||
/// German ("-1234,56") or invariant ("-1234.56", "-1.5E-05") — a comma marks German formatting.
|
||||
/// The result is rounded to cents. Empty / "NaN" / unparseable → null (stored as NULL).
|
||||
/// </summary>
|
||||
internal static decimal? ParseAmount(object? raw)
|
||||
{
|
||||
if (raw is JValue jv) raw = jv.Value;
|
||||
decimal d;
|
||||
switch (raw)
|
||||
{
|
||||
case null: return null;
|
||||
case decimal m: d = m; break;
|
||||
case double dbl when double.IsFinite(dbl): d = (decimal)dbl; break;
|
||||
case float flt when float.IsFinite(flt): d = (decimal)flt; break;
|
||||
case int or long or short or byte: d = Convert.ToDecimal(raw, CultureInfo.InvariantCulture); break;
|
||||
case double or float: return null;
|
||||
default:
|
||||
string s = (Convert.ToString(raw, CultureInfo.InvariantCulture) ?? "").Trim();
|
||||
var culture = s.Contains(',') ? GermanCulture : CultureInfo.InvariantCulture;
|
||||
if (!decimal.TryParse(s, NumberStyles.Float, culture, out d)) return null;
|
||||
break;
|
||||
}
|
||||
return Math.Round(d, 2, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
private static readonly CultureInfo GermanCulture = new("de-DE");
|
||||
|
||||
/// <summary>
|
||||
/// Builds the InvoiceOptions CSV from the posted admin flags.
|
||||
/// <c>§13b</c> is the existing reverse-charge flag; <c>setmode:<mode></c>
|
||||
@@ -253,6 +304,14 @@ public class FdsInvoiceData
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>An invoice amount exceeds what the invoice procs' <c>numeric(10,x)</c> parameters can store.</summary>
|
||||
public sealed class InvoiceAmountOutOfRangeException(string parameter, decimal value)
|
||||
: Exception($"Invoice amount {parameter} = {value.ToString(CultureInfo.InvariantCulture)} is outside ±{FdsInvoiceData.MaxAmount.ToString(CultureInfo.InvariantCulture)}.")
|
||||
{
|
||||
public string Parameter { get; } = parameter;
|
||||
public decimal Value { get; } = value;
|
||||
}
|
||||
|
||||
/// <summary>A service-request group as it renders on the invoice: a heading plus its line items.</summary>
|
||||
public sealed class InvoiceBlock
|
||||
{
|
||||
|
||||
+1
-1
Submodule OCORE updated: def034e155...728ab395c7
+1
-1
Submodule OCORE_web updated: edf50f1e91...bc4e534e1b
@@ -0,0 +1,22 @@
|
||||
-- Read-only check for amounts mangled by the SQL_Float culture bug (fixed 2026-10-01).
|
||||
-- No corrections are to be made from this: issued invoices are not changed retrospectively.
|
||||
--
|
||||
-- What was at risk: only the VAT amount of a negative invoice (Storno / Gutschrift / Korrektur).
|
||||
-- FdsInvoiceData.HighestVat formatted it invariantly ("-615.38"), and the old OCORE SQL_Float parsed a
|
||||
-- negative value with the server's de-DE culture ("." = thousands separator) -> -61538. When the VAT
|
||||
-- carried float noise ("-615.3757999999999") the result overflowed numeric(10,3) and the save failed
|
||||
-- (the reported error); without noise it would have been stored scaled by 10/100/1000.
|
||||
-- The header totals (InvoiceBalance / _net) and service totals were formatted and parsed in the same
|
||||
-- culture and were never affected. Negative amounts themselves are legitimate.
|
||||
--
|
||||
-- Bug signature: InvoiceVAT_net1 = (InvoiceBalance - InvoiceBalance_net) * 10 / 100 / 1000.
|
||||
SELECT i.[Id], i.[InvoiceId], i.[InvoiceType], i.[IsFinal], i.[DateCreated], i.[DateModified],
|
||||
i.[InvoiceBalance], i.[InvoiceBalance_net], i.[InvoiceVAT_net1],
|
||||
vat_from_totals = i.[InvoiceBalance] - i.[InvoiceBalance_net],
|
||||
vat_scaled_by_bug = CASE WHEN EXISTS (SELECT 1 FROM (VALUES (10), (100), (1000)) f(n)
|
||||
WHERE i.[InvoiceBalance] - i.[InvoiceBalance_net] <> 0
|
||||
AND ABS(i.[InvoiceVAT_net1] - (i.[InvoiceBalance] - i.[InvoiceBalance_net]) * f.n) <= 0.05 * f.n)
|
||||
THEN 1 ELSE 0 END
|
||||
FROM [dbo].[fds__invoices] i
|
||||
WHERE i.[InvoiceBalance] < 0 OR i.[InvoiceBalance_net] < 0 OR i.[InvoiceVAT_net1] < 0
|
||||
ORDER BY vat_scaled_by_bug DESC, i.[DateModified] DESC;
|
||||
Reference in New Issue
Block a user