Add unit tests for Fuchs_DataService and related components
- Introduced comprehensive unit tests for the Fuchs_DataService library, covering DATEV header formatting, CSV/XML generation, and FdsMfrClient construction. - Implemented tests for FdsMfr.UpdateNeed parsing and FdsShared utility helpers, ensuring correct functionality and stability. - Added tests for FdsConfig and FdsMfrClient to validate configuration resolution and client construction. Document decisions on backend-authoritative invoice and reminder handling - Created ADR 0008 to clarify that all invoice types and reminder stages are backend-authoritative during drafting and previewing. - Established that all calculations and settings must be processed server-side, ensuring consistency between online editor and PDF outputs. Define irreversible mutations for set-price modes in invoices - Documented ADR 0009 to specify that the "Set mit Preis" and "Nur Set mit Preis" operations are irreversible mutations affecting service request blocks. - Clarified that these operations are not display toggles but actual data changes, ensuring clear expectations for invoice handling. Transition MFR ERP sync to in-process execution within the web app - Created ADR 0010 to outline the migration of Fuchs_DataService from a standalone service to an in-process library within the Fuchs web application. - Updated configuration and logging management to be handled by the host application, streamlining the sync process. Add publish profile and periodic hosted service for job scheduling - Introduced a publish profile for deployment to a specified folder. - Implemented PeriodicHostedService to manage multiple independent jobs, including the MFR ERP sync, with configurable execution intervals. Add dotnet-tools.json for EF Core CLI tools - Included dotnet-tools.json to manage the version of dotnet-ef for Entity Framework Core migrations and commands.
This commit is contained in:
@@ -0,0 +1,446 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Xml;
|
||||
using fds;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Tests for the Fuchs_DataService library (MFR ERP sync).
|
||||
//
|
||||
// Since Topshelf/own-config were removed and the library is hosted in-process by
|
||||
// Fuchs, these tests exercise the parts that are pure/deterministic and do not
|
||||
// require a live SQL Server or MFR endpoint: DATEV header/CSV/XML formatting,
|
||||
// UpdateNeed parsing, the FdsShared utility helpers, config resolution, and the
|
||||
// (network-free) FdsMfrClient construction.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>DATEV header line formatting — pure string assembly, no I/O.</summary>
|
||||
public class DatevHeaderTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToHeaderString_Defaults_ProducesThirtySemicolonFields()
|
||||
{
|
||||
var header = new DatevHeader();
|
||||
var parts = header.ToHeaderString().Split(';');
|
||||
|
||||
Assert.Equal(30, parts.Length);
|
||||
Assert.Equal("\"EXTF\"", parts[0]); // Kennzeichen quoted
|
||||
Assert.Equal("700", parts[1]); // Versionsnummer
|
||||
Assert.Equal("1", parts[20]); // Festschreibung true → "1"
|
||||
Assert.Equal("\"EUR\"", parts[21]); // WKZ quoted
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToHeaderString_MapsNumericAndDateFields()
|
||||
{
|
||||
var header = new DatevHeader
|
||||
{
|
||||
Beraternummer = 12345,
|
||||
Mandantennummer = 678,
|
||||
Sachkontenlänge = 4,
|
||||
WJBeginn = new DateTime(2026, 1, 1),
|
||||
DatumVon = new DateTime(2026, 7, 1),
|
||||
DatumBis = new DateTime(2026, 7, 31),
|
||||
Bezeichnung = "fds_m260731",
|
||||
};
|
||||
var parts = header.ToHeaderString().Split(';');
|
||||
|
||||
Assert.Equal("12345", parts[10]);
|
||||
Assert.Equal("678", parts[11]);
|
||||
Assert.Equal("20260101", parts[12]);
|
||||
Assert.Equal("4", parts[13]);
|
||||
Assert.Equal("20260701", parts[14]);
|
||||
Assert.Equal("20260731", parts[15]);
|
||||
Assert.Equal("\"fds_m260731\"", parts[16]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, "1")]
|
||||
[InlineData(false, "0")]
|
||||
public void ToHeaderString_Festschreibung_MapsToFlag(bool festschreibung, string expected)
|
||||
{
|
||||
var header = new DatevHeader { Festschreibung = festschreibung };
|
||||
Assert.Equal(expected, header.ToHeaderString().Split(';')[20]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToHeaderString_DtvfKennzeichen_IsHonoured()
|
||||
{
|
||||
var header = new DatevHeader { Kennzeichen = DatevKennzeichen.DTVF };
|
||||
Assert.Equal("\"DTVF\"", header.ToHeaderString().Split(';')[0]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DatevFormatkategorie.Debitoren__Kreditoren, "Debitoren/Kreditoren")]
|
||||
[InlineData(DatevFormatkategorie.Buchungsstapel, "Buchungsstapel")]
|
||||
[InlineData(DatevFormatkategorie.Wiederkehrende_Buchungen, "Wiederkehrende Buchungen")]
|
||||
public void Formatname_TranslatesEnumUnderscores(DatevFormatkategorie kat, string expected)
|
||||
{
|
||||
var header = new DatevHeader { Formatkategorie = kat };
|
||||
Assert.Equal(expected, header.Formatname);
|
||||
// Field 3 embeds the same, quoted.
|
||||
Assert.Equal($"\"{expected}\"", header.ToHeaderString().Split(';')[3]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>DATEV CSV + document XML generation on FdsMfr (no DB / no MFR).</summary>
|
||||
public class FdsMfrDatevTests
|
||||
{
|
||||
private static FdsMfr NewMfr() =>
|
||||
new(NullLogger<FdsMfr>.Instance, NullLoggerFactory.Instance);
|
||||
|
||||
[Fact]
|
||||
public void DATEV_PrependsHeaderLine_AndSemicolonCsvWithColumnHeaders()
|
||||
{
|
||||
var header = new DatevHeader { Bezeichnung = "test" };
|
||||
var tbl = new DataTable();
|
||||
tbl.Columns.Add("Konto", typeof(string));
|
||||
tbl.Columns.Add("Betrag", typeof(decimal));
|
||||
tbl.Rows.Add("1200", 119.50m);
|
||||
|
||||
var result = NewMfr().DATEV(header, tbl);
|
||||
var lines = result.Split("\r\n");
|
||||
|
||||
Assert.Equal(header.ToHeaderString(), lines[0]); // header line first
|
||||
Assert.Equal("Konto;Betrag", lines[1]); // column headers, semicolon-delimited, unquoted
|
||||
Assert.Contains("1200", lines[2]);
|
||||
// de-DE culture → decimal comma
|
||||
Assert.Contains("119,5", lines[2]);
|
||||
}
|
||||
|
||||
// Note: only the root <archive> is in the DATEV namespace; the generator emits child
|
||||
// elements in the empty namespace (existing production behavior). Child lookups therefore
|
||||
// use namespace-agnostic local-name() XPath rather than the datev namespace prefix.
|
||||
|
||||
[Fact]
|
||||
public void CreateDatevDocumentXml_BuildsArchiveWithNamespaceAndOneDocumentPerInput()
|
||||
{
|
||||
var docs = new List<DatevDocument>
|
||||
{
|
||||
new("guid-1", "invoice1.pdf", "RgNr: 100", "ProcessWeb_Belege", "2026/07"),
|
||||
new("guid-2", "invoice2.pdf", "", "ProcessWeb_Belege", "2026/07"),
|
||||
};
|
||||
|
||||
var xml = NewMfr().CreateDatevDocumentXml(docs);
|
||||
var doc = new XmlDocument();
|
||||
doc.LoadXml(xml);
|
||||
|
||||
Assert.Equal("archive", doc.DocumentElement!.LocalName);
|
||||
Assert.Equal("http://xml.datev.de/bedi/tps/document/v04.0", doc.DocumentElement.NamespaceURI);
|
||||
Assert.Equal("ProcessWeb", doc.DocumentElement.GetAttribute("generatingSystem"));
|
||||
Assert.Equal("4.0", doc.DocumentElement.GetAttribute("version"));
|
||||
|
||||
Assert.NotNull(doc.SelectSingleNode("//*[local-name()='header']/*[local-name()='date']"));
|
||||
Assert.NotNull(doc.SelectSingleNode("//*[local-name()='header']/*[local-name()='description']"));
|
||||
|
||||
var documentNodes = doc.SelectNodes("//*[local-name()='content']/*[local-name()='document']");
|
||||
Assert.Equal(2, documentNodes!.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateDatevDocumentXml_OmitsKeywordsWhenEmpty_IncludesWhenPresent()
|
||||
{
|
||||
var docs = new List<DatevDocument>
|
||||
{
|
||||
new("g-withkw", "a.pdf", "keyword-here", "ProcessWeb_Belege", "2026/07"),
|
||||
new("g-nokw", "b.pdf", "", "ProcessWeb_Belege", "2026/07"),
|
||||
};
|
||||
|
||||
var doc = new XmlDocument();
|
||||
doc.LoadXml(NewMfr().CreateDatevDocumentXml(docs));
|
||||
|
||||
var withKw = doc.SelectSingleNode("//*[local-name()='document'][@guid='g-withkw']")!;
|
||||
var noKw = doc.SelectSingleNode("//*[local-name()='document'][@guid='g-nokw']")!;
|
||||
|
||||
var withKwNode = withKw.SelectSingleNode("*[local-name()='keywords']");
|
||||
Assert.NotNull(withKwNode);
|
||||
Assert.Equal("keyword-here", withKwNode.InnerText);
|
||||
Assert.Null(noKw.SelectSingleNode("*[local-name()='keywords']"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateDatevDocumentXml_EmitsThreeRepositoryLevels()
|
||||
{
|
||||
var docs = new List<DatevDocument>
|
||||
{
|
||||
new("g1", "a.pdf", "", "ProcessWeb_Belege", "2026/07_w03"),
|
||||
};
|
||||
var doc = new XmlDocument();
|
||||
doc.LoadXml(NewMfr().CreateDatevDocumentXml(docs));
|
||||
|
||||
var levels = doc.SelectNodes(
|
||||
"//*[local-name()='document']/*[local-name()='repository']/*[local-name()='level']")!;
|
||||
Assert.Equal(3, levels.Count);
|
||||
Assert.Equal("ProcessWeb", ((XmlElement)levels[0]!).GetAttribute("name"));
|
||||
Assert.Equal("ProcessWeb_Belege", ((XmlElement)levels[1]!).GetAttribute("name"));
|
||||
Assert.Equal("2026/07_w03", ((XmlElement)levels[2]!).GetAttribute("name"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>FdsMfr.UpdateNeed parsing + enum contract.</summary>
|
||||
public class FdsMfrUpdateNeedTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("Reset", FdsMfr.UpdateNeed.Reset)]
|
||||
[InlineData("Full", FdsMfr.UpdateNeed.Full)]
|
||||
[InlineData("Short", FdsMfr.UpdateNeed.Short)]
|
||||
[InlineData("None", FdsMfr.UpdateNeed.None)]
|
||||
public void UpdateNeedValue_ValidName_Parses(string name, FdsMfr.UpdateNeed expected)
|
||||
{
|
||||
Assert.Equal(expected, FdsMfr.UpdateNeedValue(name));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateNeedValue_UnknownName_Throws()
|
||||
{
|
||||
Assert.ThrowsAny<Exception>(() => FdsMfr.UpdateNeedValue("Bogus"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateNeed_NumericValues_AreStable()
|
||||
{
|
||||
// These map to SQL updateneed codes — must not drift.
|
||||
Assert.Equal(5, (int)FdsMfr.UpdateNeed.Reset);
|
||||
Assert.Equal(2, (int)FdsMfr.UpdateNeed.Full);
|
||||
Assert.Equal(1, (int)FdsMfr.UpdateNeed.Short);
|
||||
Assert.Equal(0, (int)FdsMfr.UpdateNeed.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>FdsShared utility helpers — pure formatting + local file I/O.</summary>
|
||||
public class FdsSharedTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData((byte)0)]
|
||||
[InlineData((byte)1)]
|
||||
[InlineData((byte)16)]
|
||||
[InlineData((byte)32)]
|
||||
public void RandomString_ReturnsRequestedLength_AlphanumericOnly(byte length)
|
||||
{
|
||||
var s = FdsShared.RandomString(length);
|
||||
Assert.Equal(length, s.Length);
|
||||
Assert.All(s, c => Assert.True(char.IsLetterOrDigit(c), $"unexpected char '{c}'"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToCsv_DataRow_QuotesAndEscapesStringsWhenRequested()
|
||||
{
|
||||
var tbl = new DataTable();
|
||||
tbl.Columns.Add("Name", typeof(string));
|
||||
var row = tbl.NewRow();
|
||||
row["Name"] = "Say \"hi\"";
|
||||
tbl.Rows.Add(row);
|
||||
|
||||
Assert.Equal("\"Say \"\"hi\"\"\"", row.ToCsv(quoteStrings: true, CultureInfo.InvariantCulture));
|
||||
Assert.Equal("Say \"hi\"", row.ToCsv(quoteStrings: false, CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToCsv_DataRow_NullAndDbNull_RenderAsEmpty()
|
||||
{
|
||||
var tbl = new DataTable();
|
||||
tbl.Columns.Add("A", typeof(string));
|
||||
tbl.Columns.Add("B", typeof(string));
|
||||
var row = tbl.NewRow();
|
||||
row["A"] = DBNull.Value;
|
||||
row["B"] = "x";
|
||||
|
||||
Assert.Equal(";x", row.ToCsv(quoteStrings: false, CultureInfo.InvariantCulture, delimiter: ";"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToCsv_DataRow_FormatsDecimalWithSuppliedCulture()
|
||||
{
|
||||
var tbl = new DataTable();
|
||||
tbl.Columns.Add("V", typeof(decimal));
|
||||
var row = tbl.NewRow();
|
||||
row["V"] = 1234.5m;
|
||||
tbl.Rows.Add(row);
|
||||
|
||||
Assert.Equal("1234.5", row.ToCsv(quoteStrings: false, CultureInfo.InvariantCulture));
|
||||
Assert.Equal("1234,5", row.ToCsv(quoteStrings: false, new CultureInfo("de-DE")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToCsv_DataTable_IncludesHeaderRow_WhenRequested()
|
||||
{
|
||||
var tbl = new DataTable();
|
||||
tbl.Columns.Add("Konto", typeof(string));
|
||||
tbl.Columns.Add("Betrag", typeof(decimal));
|
||||
tbl.Rows.Add("1200", 5m);
|
||||
tbl.Rows.Add("1400", 6m);
|
||||
|
||||
var csv = tbl.ToCsv(includeHeaders: true, quoteStrings: false, fieldDelimiter: ";");
|
||||
var lines = csv.Split("\r\n");
|
||||
|
||||
Assert.Equal("Konto;Betrag", lines[0]);
|
||||
Assert.Equal("1200;5", lines[1]);
|
||||
Assert.Equal("1400;6", lines[2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToCsv_DataTable_CanOmitHeaders()
|
||||
{
|
||||
var tbl = new DataTable();
|
||||
tbl.Columns.Add("X", typeof(string));
|
||||
tbl.Rows.Add("a");
|
||||
|
||||
var csv = tbl.ToCsv(includeHeaders: false, quoteStrings: false);
|
||||
Assert.Equal("a", csv);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("archive.zip", "archive")]
|
||||
[InlineData("archive.tar.gz", "archive.tar")]
|
||||
[InlineData("noext", "noext")]
|
||||
public void NameBase_StripsFinalExtension(string fileName, string expected)
|
||||
{
|
||||
var fi = new FileInfo(Path.Combine(Path.GetTempPath(), fileName));
|
||||
Assert.Equal(expected, fi.NameBase());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Hello, DATEV!")]
|
||||
[InlineData("Ümläute & Straße")]
|
||||
public void ToByteArray_Utf8_RoundTrips(string input)
|
||||
{
|
||||
// Default encoding is Encoding.UTF8, so the StreamWriter emits a leading BOM.
|
||||
var bytes = input.ToByteArray();
|
||||
var decoded = Encoding.UTF8.GetString(bytes).TrimStart('');
|
||||
Assert.Equal(input, decoded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToByteArray_Iso8859_1_RoundTrips()
|
||||
{
|
||||
const string input = "Grüße";
|
||||
var latin1 = Encoding.GetEncoding("ISO-8859-1");
|
||||
var bytes = input.ToByteArray(latin1);
|
||||
Assert.Equal(input, latin1.GetString(bytes));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadWriteStream_CopiesFullContent()
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes("stream-copy-payload-0123456789");
|
||||
using var src = new MemoryStream(payload);
|
||||
using var dst = new MemoryStream();
|
||||
|
||||
Assert.True(FdsShared.ReadWriteStream(src, dst, closeWriteStream: false));
|
||||
Assert.Equal(payload, dst.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteStreamToDisk_PersistsStreamToFile()
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes("disk-payload");
|
||||
var path = Path.Combine(Path.GetTempPath(), $"fds_test_{Guid.NewGuid():N}.bin");
|
||||
try
|
||||
{
|
||||
using var src = new MemoryStream(payload);
|
||||
Assert.True(FdsShared.WriteStreamToDisk(src, path));
|
||||
Assert.True(File.Exists(path));
|
||||
Assert.Equal(payload, File.ReadAllBytes(path));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FdsConfig + FdsMfrClient share process-global config state (FdsConfig._config),
|
||||
/// so their tests are grouped into one collection to run sequentially and never
|
||||
/// clobber each other's <see cref="FdsConfig.Initialize"/> call.
|
||||
/// </summary>
|
||||
[CollectionDefinition("FdsConfig")]
|
||||
public class FdsConfigCollection { }
|
||||
|
||||
[Collection("FdsConfig")]
|
||||
public class FdsConfigTests
|
||||
{
|
||||
private static IConfiguration BuildConfig(Dictionary<string, string?> values) =>
|
||||
new ConfigurationBuilder().AddInMemoryCollection(values).Build();
|
||||
|
||||
[Fact]
|
||||
public void FDSConnectionString_ReturnsConfiguredValue()
|
||||
{
|
||||
FdsConfig.Initialize(BuildConfig(new()
|
||||
{
|
||||
["ConnectionStrings:fuchs_fds_ConnectionString"] = "Server=.;Database=fds;",
|
||||
}));
|
||||
Assert.Equal("Server=.;Database=fds;", FdsConfig.FDSConnectionString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FDSConnectionString_Missing_Throws()
|
||||
{
|
||||
FdsConfig.Initialize(BuildConfig(new()));
|
||||
Assert.Throws<InvalidOperationException>(() => FdsConfig.FDSConnectionString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MfrSettings_ResolveFromFdsSection()
|
||||
{
|
||||
FdsConfig.Initialize(BuildConfig(new()
|
||||
{
|
||||
["Fds:MFR_UserName"] = "system@example.com",
|
||||
["Fds:MFR_Password"] = "secret",
|
||||
["Fds:MFR_host"] = "portal.mobilefieldreport.com",
|
||||
}));
|
||||
|
||||
Assert.Equal("system@example.com", FdsConfig.MFR_UserName);
|
||||
Assert.Equal("secret", FdsConfig.MFR_Password);
|
||||
Assert.Equal("portal.mobilefieldreport.com", FdsConfig.MFR_host);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MfrSettings_Absent_FallBackToEmptyString()
|
||||
{
|
||||
FdsConfig.Initialize(BuildConfig(new()));
|
||||
Assert.Equal("", FdsConfig.MFR_UserName);
|
||||
Assert.Equal("", FdsConfig.MFR_Password);
|
||||
Assert.Equal("", FdsConfig.MFR_host);
|
||||
}
|
||||
}
|
||||
|
||||
[Collection("FdsConfig")]
|
||||
public class FdsMfrClientTests
|
||||
{
|
||||
private static void InitConfig(string host) =>
|
||||
FdsConfig.Initialize(new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Fds:MFR_host"] = host,
|
||||
["Fds:MFR_UserName"] = "u",
|
||||
["Fds:MFR_Password"] = "p",
|
||||
}).Build());
|
||||
|
||||
[Fact]
|
||||
public void Construction_BuildsClientConfig_FromHost()
|
||||
{
|
||||
InitConfig("portal.mobilefieldreport.com");
|
||||
using var client = new FdsMfrClient();
|
||||
Assert.Contains("portal.mobilefieldreport.com", client.ClientConfig.BaseUrl);
|
||||
Assert.EndsWith("/", client.ClientConfig.BaseUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsReadonly_DefaultsTrue_AndIsSettable()
|
||||
{
|
||||
InitConfig("portal.mobilefieldreport.com");
|
||||
using var client = new FdsMfrClient();
|
||||
Assert.True(client.IsReadonly);
|
||||
client.IsReadonly = false;
|
||||
Assert.False(client.IsReadonly);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
||||
<PackageReference Include="xunit.v3" Version="3.2.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.intranet;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
@@ -22,6 +22,88 @@ public class InvoiceDraftCalculatorTests
|
||||
return s;
|
||||
}
|
||||
|
||||
// ── RecomputeLineValues ────────────────────────────────────────────────────
|
||||
private static JObject Line(InvoiceDraftSession s, int block, int line) =>
|
||||
(JObject)((JArray)((JObject)s.Req[block])["itm"]!)[line];
|
||||
|
||||
[Fact]
|
||||
public void RecomputeLineValues_ComputesNetAndVatFromRawQtyPriceAndRate()
|
||||
{
|
||||
var s = SessionWith(@"[
|
||||
{ 'Id':'10','itm':[ {'id':'a','typ':'material','qn':2,'v':50,'vat':'19%'} ] }
|
||||
]");
|
||||
|
||||
InvoiceDraftCalculator.RecomputeLineValues(s);
|
||||
|
||||
var l = Line(s, 0, 0);
|
||||
Assert.Equal(100m, l["vt"]!.Value<decimal>());
|
||||
Assert.Equal(19m, l["vv"]!.Value<decimal>());
|
||||
Assert.Equal(0m, l["vs"]!.Value<decimal>());
|
||||
Assert.Equal(0m, l["vsv"]!.Value<decimal>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecomputeLineValues_ServiceType_AlsoFillsServiceNetAndVat()
|
||||
{
|
||||
var s = SessionWith(@"[
|
||||
{ 'Id':'10','itm':[ {'id':'a','typ':'Service','qn':3,'v':10,'vat':'19%'} ] }
|
||||
]");
|
||||
|
||||
InvoiceDraftCalculator.RecomputeLineValues(s);
|
||||
|
||||
var l = Line(s, 0, 0);
|
||||
Assert.Equal(30m, l["vt"]!.Value<decimal>());
|
||||
Assert.Equal(5.7m, l["vv"]!.Value<decimal>());
|
||||
Assert.Equal(30m, l["vs"]!.Value<decimal>());
|
||||
Assert.Equal(5.7m, l["vsv"]!.Value<decimal>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 50)] // no quantity posted -> leave value untouched
|
||||
[InlineData(2, 0)] // no price posted -> leave value untouched
|
||||
public void RecomputeLineValues_MissingQtyOrPrice_LeavesExistingValueUntouched(decimal qty, decimal price)
|
||||
{
|
||||
var s = SessionWith($@"[
|
||||
{{ 'Id':'10','itm':[ {{'id':'a','typ':'material','qn':{qty},'v':{price},'vat':'19%','vt':777,'vv':111}} ] }}
|
||||
]");
|
||||
|
||||
InvoiceDraftCalculator.RecomputeLineValues(s);
|
||||
|
||||
var l = Line(s, 0, 0);
|
||||
Assert.Equal(777m, l["vt"]!.Value<decimal>()); // untouched — mirrors quantChange's own guard
|
||||
Assert.Equal(111m, l["vv"]!.Value<decimal>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecomputeLineValues_ZeroVatRate_ComputesNetWithNoVat()
|
||||
{
|
||||
var s = SessionWith(@"[
|
||||
{ 'Id':'10','itm':[ {'id':'a','typ':'material','qn':4,'v':25,'vat':'0%'} ] }
|
||||
]");
|
||||
|
||||
InvoiceDraftCalculator.RecomputeLineValues(s);
|
||||
|
||||
var l = Line(s, 0, 0);
|
||||
Assert.Equal(100m, l["vt"]!.Value<decimal>());
|
||||
Assert.Equal(0m, l["vv"]!.Value<decimal>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecomputeLineValues_SetHeaderConvertedSum_IsNotClobberedByRecompute()
|
||||
{
|
||||
// A converted set header carries a synthesised sum (no raw qty/price of its own) —
|
||||
// RecomputeLineValues must never overwrite it, mirroring quantChange's guard.
|
||||
var s = SessionWith(@"[
|
||||
{ 'Id':'10','itm':[ {'id':'h','typ':'set','vt':1000,'vv':190,'vat':'19%'} ] }
|
||||
]");
|
||||
|
||||
InvoiceDraftCalculator.RecomputeLineValues(s);
|
||||
|
||||
var l = Line(s, 0, 0);
|
||||
Assert.Equal(1000m, l["vt"]!.Value<decimal>());
|
||||
Assert.Equal(190m, l["vv"]!.Value<decimal>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecomputeTotals_SumsNetVatServiceAndPerBlock()
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Linq;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Services;
|
||||
@@ -205,9 +205,9 @@ public class InvoiceDraftServiceTests
|
||||
var (svc, _, _) = NewService();
|
||||
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||
|
||||
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "setmode", Value = JToken.FromObject("itemprices") });
|
||||
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "setmode", Value = JToken.FromObject("setonly") });
|
||||
|
||||
Assert.Equal("itemprices", s2!.Admin["setmode"]!.Value<string>());
|
||||
Assert.Equal("setonly", s2!.Admin["setmode"]!.Value<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -240,6 +240,249 @@ public class InvoiceDraftServiceTests
|
||||
Assert.Equal(30m, s2.Sums.NetByBlock["2"]);
|
||||
}
|
||||
|
||||
// fds__prepInvoice's [SetItmID] window function anchors on the still-unconverted (price 0)
|
||||
// Set header: the header row's own SetItmId self-references its own id ('1'), never null —
|
||||
// ApplyItemSetPrice must still recognize id-equality first and never treat the header as its
|
||||
// own member (see ApplyItemSetPrice's id == Ref check).
|
||||
private static JObject SetPayload() => JObject.Parse(@"{
|
||||
'admin':{'p13b':false,'type':'r','paymentterms':'10wd'},
|
||||
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','invoicetitle':'Rechnung'},
|
||||
'req':[{'Id':'1','text':'Auftrag','itm':[
|
||||
{'id':'1','typ':'set','vt':0,'vv':0,'vs':0,'vsv':0,'vat':'19%','SetItmId':'1'},
|
||||
{'id':'2','typ':'material','vt':60,'vv':11.4,'vs':0,'vsv':0,'vat':'19%','SetItmId':'1'},
|
||||
{'id':'3','typ':'material','vt':40,'vv':7.6,'vs':10,'vsv':1.9,'vat':'19%','SetItmId':'1'}],
|
||||
'items':[{'id':'1','type':'set'},{'id':'2','type':'material','setId':'1','total_net':60,'vat':'19%'},
|
||||
{'id':'3','type':'material','setId':'1','total_net':40,'vat':'19%'}]}]
|
||||
}");
|
||||
|
||||
// A fourth, unrelated item ('4') follows the set's members in the same block but was never
|
||||
// attributed a SetItmId by fds__prepInvoice (it isn't part of the set) — it must stay untouched
|
||||
// by the conversion, proving membership is driven purely by SetItmId, not row order/position.
|
||||
private static JObject SetPayloadWithTrailingUnrelatedItem() => JObject.Parse(@"{
|
||||
'admin':{'p13b':false,'type':'r','paymentterms':'10wd'},
|
||||
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','invoicetitle':'Rechnung'},
|
||||
'req':[{'Id':'1','text':'Auftrag','itm':[
|
||||
{'id':'1','typ':'set','vt':0,'vv':0,'vs':0,'vsv':0,'vat':'19%','SetItmId':'1'},
|
||||
{'id':'2','typ':'material','vt':60,'vv':11.4,'vs':0,'vsv':0,'vat':'19%','SetItmId':'1'},
|
||||
{'id':'3','typ':'material','vt':40,'vv':7.6,'vs':10,'vsv':1.9,'vat':'19%','SetItmId':'1'},
|
||||
{'id':'4','typ':'material','vt':25,'vv':4.75,'vs':0,'vsv':0,'vat':'19%','SetItmId':null}],
|
||||
'items':[{'id':'1','type':'set'},{'id':'2','type':'material','setId':'1','total_net':60,'vat':'19%'},
|
||||
{'id':'3','type':'material','setId':'1','total_net':40,'vat':'19%'},
|
||||
{'id':'4','type':'material','total_net':25,'vat':'19%'}]}]
|
||||
}");
|
||||
|
||||
[Fact]
|
||||
public void ApplyPatch_ItemSetPrice_SumsMembersOntoHeaderAndNullsMembers()
|
||||
{
|
||||
var (svc, _, _) = NewService();
|
||||
var s = svc.OpenFromPayload(SetPayload(), "user1");
|
||||
|
||||
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.setprice", Ref = "1" });
|
||||
|
||||
Assert.NotNull(s2);
|
||||
Assert.Equal(1, s2!.Version);
|
||||
var block = (JObject)s2.Req[0];
|
||||
var lines = (JArray)block["itm"]!;
|
||||
var header = lines.OfType<JObject>().Single(l => (string)l["id"]! == "1");
|
||||
var m2 = lines.OfType<JObject>().Single(l => (string)l["id"]! == "2");
|
||||
var m3 = lines.OfType<JObject>().Single(l => (string)l["id"]! == "3");
|
||||
|
||||
Assert.Equal(100m, header["vt"]!.Value<decimal>()); // 60 + 40
|
||||
Assert.Equal(19m, header["vv"]!.Value<decimal>()); // 11.4 + 7.6
|
||||
Assert.Equal(10m, header["vs"]!.Value<decimal>());
|
||||
Assert.Equal(1.9m, header["vsv"]!.Value<decimal>());
|
||||
Assert.Equal(JTokenType.Null, m2["vt"]!.Type); // ADR 0009: members nulled (empty cell), not 0
|
||||
Assert.Equal(JTokenType.Null, m2["vv"]!.Type);
|
||||
Assert.Equal(JTokenType.Null, m3["vt"]!.Type);
|
||||
Assert.Equal(JTokenType.Null, m3["vv"]!.Type);
|
||||
|
||||
// Total invoice sum is unchanged by the conversion (set price == sum of members).
|
||||
Assert.Equal(100m, s2.Sums.TotalNet);
|
||||
Assert.Equal(19m, s2.Sums.TotalVat);
|
||||
|
||||
var h = Assert.Single(s2.History);
|
||||
Assert.Equal("item.setprice", h.Target);
|
||||
Assert.Equal("1", h.Ref);
|
||||
Assert.Equal("0", h.OldValue);
|
||||
Assert.Equal("100", h.NewValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyPatch_ItemSetPrice_UnknownRef_IsNoOp()
|
||||
{
|
||||
var (svc, _, _) = NewService();
|
||||
var s = svc.OpenFromPayload(SetPayload(), "user1");
|
||||
|
||||
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.setprice", Ref = "999" });
|
||||
|
||||
Assert.NotNull(s2);
|
||||
Assert.Equal(0, s2!.Version);
|
||||
Assert.Empty(s2.History);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyPatch_ItemSetPrice_RefNotASetHeader_IsNoOp()
|
||||
{
|
||||
var (svc, _, _) = NewService();
|
||||
var s = svc.OpenFromPayload(SetPayload(), "user1");
|
||||
|
||||
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.setprice", Ref = "2" });
|
||||
|
||||
Assert.NotNull(s2);
|
||||
Assert.Equal(0, s2!.Version);
|
||||
Assert.Empty(s2.History);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyPatch_ItemSetPrice_HeaderSelfReferencingSetItmId_NeverCountsHeaderAsOwnMember()
|
||||
{
|
||||
// Regression test: fds__prepInvoice no longer nulls out the header's own SetItmId (it
|
||||
// self-references its own id). ApplyItemSetPrice must still sum exactly the two real
|
||||
// members (100 net), not 3x by also including the header as if it were a member of itself.
|
||||
var (svc, _, _) = NewService();
|
||||
var s = svc.OpenFromPayload(SetPayload(), "user1");
|
||||
|
||||
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.setprice", Ref = "1" });
|
||||
|
||||
Assert.NotNull(s2);
|
||||
var block = (JObject)s2!.Req[0];
|
||||
var lines = (JArray)block["itm"]!;
|
||||
var header = lines.OfType<JObject>().Single(l => (string)l["id"]! == "1");
|
||||
|
||||
Assert.Equal(100m, header["vt"]!.Value<decimal>()); // 60 + 40, not tripled by self-inclusion
|
||||
Assert.Equal(100m, s2.Sums.TotalNet);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyPatch_ItemSetPrice_UnrelatedItemAfterMembers_IsNeverSweptIntoSet()
|
||||
{
|
||||
// Regression test for the reported bug: an item after the set's real members in the same
|
||||
// block, but with no SetItmId of its own, must stay fully priced and untouched — only
|
||||
// items the server actually tagged with SetItmId == the header id are members.
|
||||
var (svc, _, _) = NewService();
|
||||
var s = svc.OpenFromPayload(SetPayloadWithTrailingUnrelatedItem(), "user1");
|
||||
|
||||
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.setprice", Ref = "1" });
|
||||
|
||||
Assert.NotNull(s2);
|
||||
var block = (JObject)s2!.Req[0];
|
||||
var lines = (JArray)block["itm"]!;
|
||||
var header = lines.OfType<JObject>().Single(l => (string)l["id"]! == "1");
|
||||
var m2 = lines.OfType<JObject>().Single(l => (string)l["id"]! == "2");
|
||||
var m3 = lines.OfType<JObject>().Single(l => (string)l["id"]! == "3");
|
||||
var other = lines.OfType<JObject>().Single(l => (string)l["id"]! == "4");
|
||||
|
||||
Assert.Equal(100m, header["vt"]!.Value<decimal>()); // only the real members (60 + 40)
|
||||
Assert.Equal(JTokenType.Null, m2["vt"]!.Type); // ADR 0009: nulled, not 0
|
||||
Assert.Equal(JTokenType.Null, m3["vt"]!.Type);
|
||||
Assert.Equal(25m, other["vt"]!.Value<decimal>()); // untouched — never part of the set
|
||||
Assert.Equal(4.75m, other["vv"]!.Value<decimal>());
|
||||
|
||||
// Total invoice sum unaffected: 100 (set) + 25 (unrelated item) = 125.
|
||||
Assert.Equal(125m, s2.Sums.TotalNet);
|
||||
}
|
||||
|
||||
// ── Block set-price menu modes (ADR 0009): per-service-request-block, irreversible ──
|
||||
private static JObject BlocksPayload() => JObject.Parse(@"{
|
||||
'admin':{'p13b':false,'type':'r'},
|
||||
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','invoicetitle':'Rechnung'},
|
||||
'req':[
|
||||
{'Id':'1','text':'Auftrag A','itm':[
|
||||
{'id':'11','typ':'material','vt':60,'vv':11.4,'vs':0,'vsv':0,'vat':'19%'},
|
||||
{'id':'12','typ':'service','vt':40,'vv':7.6,'vs':40,'vsv':7.6,'vat':'19%'}],
|
||||
'items':[{'id':'11','type':'material','total_net':60,'vat':'19%'},
|
||||
{'id':'12','type':'service','total_net':40,'vat':'19%'}]},
|
||||
{'Id':'2','text':'Auftrag B','itm':[
|
||||
{'id':'21','typ':'material','vt':30,'vv':5.7,'vs':0,'vsv':0,'vat':'19%'}],
|
||||
'items':[{'id':'21','type':'material','total_net':30,'vat':'19%'}]}]
|
||||
}");
|
||||
|
||||
[Fact]
|
||||
public void ApplyPatch_BlockSetPrice_InsertsSetRowPerBlock_NullsMembers_TotalUnchanged()
|
||||
{
|
||||
var (svc, _, _) = NewService();
|
||||
var s = svc.OpenFromPayload(BlocksPayload(), "user1");
|
||||
Assert.Equal(130m, s.Sums.TotalNet); // 100 (A) + 30 (B)
|
||||
|
||||
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setprice" });
|
||||
|
||||
Assert.NotNull(s2);
|
||||
Assert.Equal(1, s2!.Version);
|
||||
|
||||
var linesA = (JArray)((JObject)s2.Req[0])["itm"]!;
|
||||
Assert.Equal(3, linesA.Count); // set row + the two (nulled) members, kept
|
||||
var setA = (JObject)linesA[0];
|
||||
Assert.Equal("set", (string)setA["typ"]!);
|
||||
Assert.Equal("bset_1", (string)setA["id"]!);
|
||||
Assert.Equal(100m, setA["vt"]!.Value<decimal>()); // block sum
|
||||
Assert.Equal(19m, setA["vv"]!.Value<decimal>());
|
||||
Assert.Equal(40m, setA["vs"]!.Value<decimal>()); // service-net split preserved
|
||||
Assert.Equal(7.6m, setA["vsv"]!.Value<decimal>());
|
||||
var m11 = linesA.OfType<JObject>().Single(l => (string)l["id"]! == "11");
|
||||
Assert.Equal(JTokenType.Null, m11["vt"]!.Type); // ADR 0009: null (empty cell), not 0
|
||||
Assert.Equal(JTokenType.Null, m11["vv"]!.Type);
|
||||
|
||||
// items contract mirrors it (PDF reads total_net from here).
|
||||
var itemsA = (JArray)((JObject)s2.Req[0])["items"]!;
|
||||
Assert.Equal("set", (string)((JObject)itemsA[0])["type"]!);
|
||||
Assert.Equal(100m, ((JObject)itemsA[0])["total_net"]!.Value<decimal>());
|
||||
Assert.Equal(JTokenType.Null, itemsA.OfType<JObject>().Single(i => (string)i["id"]! == "11")["total_net"]!.Type);
|
||||
|
||||
// second block converted too; total conserved.
|
||||
Assert.Equal(30m, ((JObject)((JArray)((JObject)s2.Req[1])["itm"]!)[0])["vt"]!.Value<decimal>());
|
||||
Assert.Equal(130m, s2.Sums.TotalNet);
|
||||
Assert.Equal(24.7m, s2.Sums.TotalVat);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyPatch_BlockSetOnly_InsertsSetRowPerBlock_RemovesMembers_TotalUnchanged()
|
||||
{
|
||||
var (svc, _, _) = NewService();
|
||||
var s = svc.OpenFromPayload(BlocksPayload(), "user1");
|
||||
|
||||
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setonly" });
|
||||
|
||||
Assert.NotNull(s2);
|
||||
var linesA = (JArray)((JObject)s2!.Req[0])["itm"]!;
|
||||
Assert.Single(linesA); // members removed, only the set row remains
|
||||
Assert.Equal("set", (string)((JObject)linesA[0])["typ"]!);
|
||||
Assert.Equal(100m, ((JObject)linesA[0])["vt"]!.Value<decimal>());
|
||||
Assert.Single((JArray)((JObject)s2.Req[0])["items"]!);
|
||||
Assert.Equal(130m, s2.Sums.TotalNet); // total unchanged
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyPatch_BlockSetPrice_SetRowNotSweptIntoBuildSetDisplay()
|
||||
{
|
||||
// The block set row has no setId members -> HasSetMembers is false -> it renders flat
|
||||
// (emphasised on its own, members null to blank), so BuildSetDisplay must not emit flags for it.
|
||||
var (svc, _, _) = NewService();
|
||||
var s = svc.OpenFromPayload(BlocksPayload(), "user1");
|
||||
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setprice" });
|
||||
|
||||
var state = JObject.FromObject(svc.BuildState(svc.Get(s.Token)!));
|
||||
var setDisplay = (JObject)state["setDisplay"]!;
|
||||
Assert.False(setDisplay.ContainsKey("bset_1"));
|
||||
Assert.Empty(setDisplay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyPatch_BlockSetPrice_EmptyOrUnpricedBlocks_IsNoOp()
|
||||
{
|
||||
var (svc, _, _) = NewService();
|
||||
var s = svc.OpenFromPayload(JObject.Parse(@"{
|
||||
'admin':{'p13b':false,'type':'r'},
|
||||
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','invoicetitle':'Rechnung'},
|
||||
'req':[{'Id':'1','text':'Leer','itm':[],'items':[]}]
|
||||
}"), "user1");
|
||||
|
||||
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setprice" });
|
||||
|
||||
Assert.NotNull(s2);
|
||||
Assert.Equal(0, s2!.Version); // nothing priced -> no-op
|
||||
Assert.Empty(s2.History);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyPatch_UnknownTarget_IsNoOp_NoVersionBumpNoHistory()
|
||||
{
|
||||
@@ -306,7 +549,7 @@ public class InvoiceDraftServiceTests
|
||||
var (svc, inv, _) = NewService();
|
||||
var payload = Payload();
|
||||
payload["admin"]!["p13b"] = true;
|
||||
payload["admin"]!["setmode"] = "itemprices";
|
||||
payload["admin"]!["setmode"] = "setonly";
|
||||
var s = svc.OpenFromPayload(payload, "user1");
|
||||
|
||||
await svc.FlushToDbAsync(s.Token, "user1", null!);
|
||||
@@ -314,7 +557,7 @@ public class InvoiceDraftServiceTests
|
||||
var options = inv.Registered!.BuildInvoiceParams(change: false, invId: "")
|
||||
.First(p => p.ParameterName == "@InvoiceOptions").Value?.ToString() ?? "";
|
||||
Assert.Contains("§13b", options);
|
||||
Assert.Contains("setmode:itemprices", options);
|
||||
Assert.Contains("setmode:setonly", options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -476,4 +719,124 @@ public class InvoiceDraftServiceTests
|
||||
|
||||
Assert.Equal(new[] { "2", "1" }, s2!.Req.Select(b => b["Id"]!.Value<string>()).ToArray());
|
||||
}
|
||||
|
||||
// ── Multiple VAT rates aggregate independently (ADR 0008: server owns every tax sum) ──
|
||||
private static JObject MultiRatePayload() => JObject.Parse(@"{
|
||||
'admin':{'p13b':false,'type':'r'},
|
||||
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1'},
|
||||
'req':[
|
||||
{'Id':'1','text':'A','itm':[
|
||||
{'id':'900','typ':'material','vt':100,'vv':19,'vat':'19%'},
|
||||
{'id':'901','typ':'material','vt':200,'vv':14,'vat':'7%'},
|
||||
{'id':'902','typ':'material','vt':50,'vv':0,'vat':'0%'}],
|
||||
'items':[{'id':'900','type':'material','total_net':100,'vat':'19%'},
|
||||
{'id':'901','type':'material','total_net':200,'vat':'7%'},
|
||||
{'id':'902','type':'material','total_net':50,'vat':'0%'}]}
|
||||
]}");
|
||||
|
||||
[Fact]
|
||||
public void OpenFromPayload_MultipleVatRates_GroupsSumsPerRate()
|
||||
{
|
||||
var (svc, _, _) = NewService();
|
||||
var s = svc.OpenFromPayload(MultiRatePayload(), "user1");
|
||||
|
||||
Assert.Equal(350m, s.Sums.TotalNet);
|
||||
Assert.Equal(33m, s.Sums.TotalVat);
|
||||
Assert.Equal(383m, s.Sums.TotalGross);
|
||||
Assert.Equal(19m, s.Sums.VatByRate["19"]);
|
||||
Assert.Equal(14m, s.Sums.VatByRate["7"]);
|
||||
Assert.False(s.Sums.VatByRate.ContainsKey("0")); // zero-rate contributes no VAT key (mirrors calculator)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyPatch_BlockReplace_MultiRate_RecomputesEachRateIndependently()
|
||||
{
|
||||
var (svc, _, _) = NewService();
|
||||
var s = svc.OpenFromPayload(MultiRatePayload(), "user1");
|
||||
|
||||
// Halve the 7%-rate line's net/VAT via a block replace; the 19% bucket must stay untouched.
|
||||
var newBlock = JObject.Parse(@"{'Id':'1','text':'A','itm':[
|
||||
{'id':'900','typ':'material','vt':100,'vv':19,'vat':'19%'},
|
||||
{'id':'901','typ':'material','vt':100,'vv':7,'vat':'7%'},
|
||||
{'id':'902','typ':'material','vt':50,'vv':0,'vat':'0%'}],
|
||||
'items':[]}");
|
||||
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.replace", Ref = "1", Value = newBlock });
|
||||
|
||||
Assert.Equal(19m, s2!.Sums.VatByRate["19"]); // unchanged
|
||||
Assert.Equal(7m, s2.Sums.VatByRate["7"]); // recomputed from new line
|
||||
Assert.Equal(250m, s2.Sums.TotalNet);
|
||||
Assert.Equal(26m, s2.Sums.TotalVat);
|
||||
}
|
||||
|
||||
// ── BuildSetDisplay reflects both set-pricing modes (mirrors the PDF's InvoiceSetPricing) ──
|
||||
// SetPayload()'s header item is still unconverted (own total_net == 0, as delivered by
|
||||
// fds__prepInvoice) — until the set-item switch (ApplyItemSetPrice) actually gives it its own
|
||||
// price, InvoiceSetPricing.Build must not apply the chosen setmode yet: header stays blank and
|
||||
// each member keeps showing its own individual price.
|
||||
[Theory]
|
||||
[InlineData("setprice")]
|
||||
[InlineData("setonly")]
|
||||
public void BuildState_SetDisplay_UnconvertedSet_HeaderBlankMembersIndividuallyPriced(string setmode)
|
||||
{
|
||||
var (svc, _, _) = NewService();
|
||||
var payload = SetPayload();
|
||||
payload["admin"]!["setmode"] = setmode;
|
||||
var s = svc.OpenFromPayload(payload, "user1");
|
||||
|
||||
var state = JObject.FromObject(svc.BuildState(s));
|
||||
var setDisplay = (JObject)state["setDisplay"]!;
|
||||
|
||||
Assert.False(setDisplay["1"]!["p"]!.Value<bool>()); // header not priced yet
|
||||
Assert.True(setDisplay["2"]!["p"]!.Value<bool>()); // member 2 keeps its own price
|
||||
Assert.True(setDisplay["3"]!["p"]!.Value<bool>()); // member 3 keeps its own price
|
||||
}
|
||||
|
||||
// Once converted (ApplyItemSetPrice has given the header its own price), the chosen setmode
|
||||
// takes effect: SetPrice blanks the members (still present in the map); SetOnly drops them
|
||||
// from the map entirely.
|
||||
[Theory]
|
||||
[InlineData("setprice", true, false)] // header priced, member 2 blank
|
||||
[InlineData("setonly", true, false)] // header priced; member 2 dropped entirely (absent from map)
|
||||
public void BuildState_SetDisplay_ConvertedSet_ReflectsSetmodeForHeaderAndMember(string setmode, bool headerShown, bool memberShown)
|
||||
{
|
||||
var (svc, _, _) = NewService();
|
||||
var payload = SetPayload();
|
||||
payload["admin"]!["setmode"] = setmode;
|
||||
var s = svc.OpenFromPayload(payload, "user1");
|
||||
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.setprice", Ref = "1" }); // convert the set first
|
||||
|
||||
var state = JObject.FromObject(svc.BuildState(s));
|
||||
var setDisplay = (JObject)state["setDisplay"]!;
|
||||
|
||||
Assert.Equal(headerShown, setDisplay["1"]!["p"]!.Value<bool>());
|
||||
if (setmode == "setonly")
|
||||
Assert.False(setDisplay.ContainsKey("2")); // member removed from the display entirely
|
||||
else
|
||||
Assert.Equal(memberShown, setDisplay["2"]!["p"]!.Value<bool>());
|
||||
}
|
||||
|
||||
// ── Full lifecycle recalculation: several edits of different kinds land in one consistent recompute ──
|
||||
[Fact]
|
||||
public void ApplyPatch_FullEditSequence_TextReorderSetPriceAndTaxToggle_EndsConsistent()
|
||||
{
|
||||
var (svc, _, _) = NewService();
|
||||
var s = svc.OpenFromPayload(TwoBlockPayload(), "user1");
|
||||
|
||||
// 1) text change
|
||||
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "title", Value = JToken.FromObject("Endabrechnung") });
|
||||
// 2) reorder sections
|
||||
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.order", Value = JArray.Parse("['2','1']") });
|
||||
// 3) toggle reverse-charge on, then off again (settings roundtrip)
|
||||
svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "p13b", Value = JToken.FromObject(true) });
|
||||
var final = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "p13b", Value = JToken.FromObject(false) });
|
||||
|
||||
Assert.NotNull(final);
|
||||
Assert.Equal("Endabrechnung", final!.New["invoicetitle"]!.Value<string>());
|
||||
Assert.Equal(new[] { "2", "1" }, final.Req.Select(b => b["Id"]!.Value<string>()).ToArray());
|
||||
Assert.Equal("1", ((JObject)((JArray)((JObject)final.Req[0])["itm"]!)[0])["p"]!.ToString()); // renumbered after reorder
|
||||
Assert.Equal(130m, final.Sums.TotalNet); // totals stable across the whole sequence
|
||||
Assert.Equal(24.7m, final.Sums.TotalVat); // VAT restored after the toggle roundtrip
|
||||
Assert.Equal(4, final.History.Count);
|
||||
Assert.DoesNotContain(final.ValidationMessages, m => m.Severity == "error");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,12 +38,18 @@ public class InvoiceOptionsTests
|
||||
=> Assert.Equal("setmode:setprice", InvoiceOptionsFor(new { type = "r", setmode = "setprice" }));
|
||||
|
||||
[Theory]
|
||||
[InlineData("itemprices", "setmode:itemprices")]
|
||||
[InlineData("setonly", "setmode:setonly")]
|
||||
[InlineData("ITEMPRICES", "setmode:itemprices")] // case-insensitive
|
||||
[InlineData("SETONLY", "setmode:setonly")] // case-insensitive
|
||||
public void SetMode_EmitsToken(string mode, string expected)
|
||||
=> Assert.Equal(expected, InvoiceOptionsFor(new { type = "r", setmode = mode }));
|
||||
|
||||
[Fact]
|
||||
public void RemovedItemPricesMode_OmitsToken()
|
||||
// "itemprices" was a valid mode before the button/mode was removed. Any invoice options
|
||||
// still carrying the stale value (or a client re-posting it) must not be persisted as a
|
||||
// recognized token — it is treated the same as an unknown/garbage mode.
|
||||
=> Assert.Equal("", InvoiceOptionsFor(new { type = "r", setmode = "itemprices" }));
|
||||
|
||||
[Fact]
|
||||
public void UnknownSetMode_OmitsToken()
|
||||
=> Assert.Equal("", InvoiceOptionsFor(new { type = "r", setmode = "garbage" }));
|
||||
@@ -106,9 +112,11 @@ public class InvoiceOptionsTests
|
||||
public void ModeFromInvoiceOptions_RoundTripsBackendEmission()
|
||||
{
|
||||
// The token this side emits must parse back to the same mode on the PDF side.
|
||||
Assert.Equal(SetDisplayMode.ItemPrices,
|
||||
InvoiceSetPricing.ModeFromInvoiceOptions(InvoiceOptionsFor(new { type = "r", setmode = "itemprices" })));
|
||||
Assert.Equal(SetDisplayMode.SetOnly,
|
||||
InvoiceSetPricing.ModeFromInvoiceOptions(InvoiceOptionsFor(new { type = "r", p13b = true, setmode = "setonly" })));
|
||||
// A removed mode never round-trips as itself — it is never persisted, so reading it back
|
||||
// always yields the default SetPrice.
|
||||
Assert.Equal(SetDisplayMode.SetPrice,
|
||||
InvoiceSetPricing.ModeFromInvoiceOptions(InvoiceOptionsFor(new { type = "r", setmode = "itemprices" })));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Fuchs.intranet;
|
||||
using Xunit;
|
||||
@@ -40,11 +40,11 @@ public class InvoiceSetPricingTests
|
||||
|
||||
// ── Mode parsing ────────────────────────────────────────────────────────
|
||||
[Theory]
|
||||
[InlineData("itemprices", SetDisplayMode.ItemPrices)]
|
||||
[InlineData("items", SetDisplayMode.ItemPrices)]
|
||||
[InlineData("setonly", SetDisplayMode.SetOnly)]
|
||||
[InlineData("set_only", SetDisplayMode.SetOnly)]
|
||||
[InlineData("setprice", SetDisplayMode.SetPrice)]
|
||||
[InlineData("", SetDisplayMode.SetPrice)]
|
||||
[InlineData("itemprices", SetDisplayMode.SetPrice)] // removed mode — falls back to default, never throws
|
||||
[InlineData("garbage", SetDisplayMode.SetPrice)]
|
||||
public void ParseMode_Works(string raw, SetDisplayMode expected)
|
||||
=> Assert.Equal(expected, InvoiceSetPricing.ParseMode(raw));
|
||||
@@ -52,10 +52,12 @@ public class InvoiceSetPricingTests
|
||||
[Fact]
|
||||
public void ModeFromInvoiceOptions_ReadsToken()
|
||||
{
|
||||
Assert.Equal(SetDisplayMode.ItemPrices, InvoiceSetPricing.ModeFromInvoiceOptions("§13b,setmode:itemprices"));
|
||||
Assert.Equal(SetDisplayMode.SetOnly, InvoiceSetPricing.ModeFromInvoiceOptions("setmode:setonly"));
|
||||
Assert.Equal(SetDisplayMode.SetPrice, InvoiceSetPricing.ModeFromInvoiceOptions("§13b")); // default
|
||||
Assert.Equal(SetDisplayMode.SetPrice, InvoiceSetPricing.ModeFromInvoiceOptions(null));
|
||||
Assert.Equal(SetDisplayMode.SetOnly, InvoiceSetPricing.ModeFromInvoiceOptions("§13b,setmode:setonly"));
|
||||
Assert.Equal(SetDisplayMode.SetPrice, InvoiceSetPricing.ModeFromInvoiceOptions("§13b")); // default
|
||||
Assert.Equal(SetDisplayMode.SetPrice, InvoiceSetPricing.ModeFromInvoiceOptions(null));
|
||||
// A stale/removed "itemprices" token (e.g. from an invoice created before the mode was
|
||||
// dropped) must not throw — it degrades gracefully to the default SetPrice mode.
|
||||
Assert.Equal(SetDisplayMode.SetPrice, InvoiceSetPricing.ModeFromInvoiceOptions("setmode:itemprices"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -85,22 +87,6 @@ public class InvoiceSetPricingTests
|
||||
Assert.Equal(50.00m, lines[3].TotalNet);
|
||||
}
|
||||
|
||||
// ── ItemPrices: members priced, set header blank ──────────────────────────
|
||||
[Fact]
|
||||
public void Build_ItemPrices_MembersPricedHeaderBlank()
|
||||
{
|
||||
var lines = InvoiceSetPricing.Build(Sample(), SetDisplayMode.ItemPrices);
|
||||
|
||||
Assert.Equal(4, lines.Count);
|
||||
Assert.True(lines[0].IsSetHeader);
|
||||
Assert.False(lines[0].ShowPrice); // set header is just a title now
|
||||
Assert.True(lines[1].ShowPrice);
|
||||
Assert.Equal(600.00m, lines[1].TotalNet);
|
||||
Assert.True(lines[2].ShowPrice);
|
||||
Assert.Equal(400.00m, lines[2].TotalNet);
|
||||
Assert.True(lines[3].ShowPrice); // standalone
|
||||
}
|
||||
|
||||
// ── SetOnly: members removed ──────────────────────────────────────────────
|
||||
[Fact]
|
||||
public void Build_SetOnly_RemovesMembers()
|
||||
@@ -115,18 +101,45 @@ public class InvoiceSetPricingTests
|
||||
Assert.Equal("Anfahrt", lines[1].Title);
|
||||
}
|
||||
|
||||
// ── Set price falls back to sum of members when header total is 0 ─────────
|
||||
// ── Unconverted set (header total still 0): shown blank, members individually priced ──
|
||||
[Fact]
|
||||
public void Build_HeaderTotalZero_UsesSumOfMembers()
|
||||
public void Build_HeaderTotalZero_UnconvertedSet_HeaderBlankMembersPriced()
|
||||
{
|
||||
var items = new List<Dictionary<string, object?>>
|
||||
{
|
||||
SetHeader("7", "Set ohne Preis"), // total 0
|
||||
SetHeader("7", "Set ohne Preis"), // total 0 — not yet converted
|
||||
Member("7", "A", "120.00", "120.00"),
|
||||
Member("7", "B", "80.00", "80.00")
|
||||
};
|
||||
var lines = InvoiceSetPricing.Build(items, SetDisplayMode.SetPrice);
|
||||
Assert.Equal(200.00m, lines[0].TotalNet); // 120 + 80
|
||||
|
||||
Assert.Equal(3, lines.Count);
|
||||
Assert.True(lines[0].IsSetHeader);
|
||||
Assert.False(lines[0].ShowPrice); // header not priced yet
|
||||
Assert.Equal(0m, lines[0].TotalNet);
|
||||
|
||||
Assert.True(lines[1].ShowPrice); // members keep their own price
|
||||
Assert.Equal(120.00m, lines[1].TotalNet);
|
||||
Assert.True(lines[2].ShowPrice);
|
||||
Assert.Equal(80.00m, lines[2].TotalNet);
|
||||
}
|
||||
|
||||
// ── SetOnly on an unconverted set also just passes items through unchanged ──
|
||||
[Fact]
|
||||
public void Build_HeaderTotalZero_UnconvertedSet_SetOnlyModeStillPassesThrough()
|
||||
{
|
||||
var items = new List<Dictionary<string, object?>>
|
||||
{
|
||||
SetHeader("7", "Set ohne Preis"),
|
||||
Member("7", "A", "120.00", "120.00"),
|
||||
Member("7", "B", "80.00", "80.00")
|
||||
};
|
||||
var lines = InvoiceSetPricing.Build(items, SetDisplayMode.SetOnly);
|
||||
|
||||
Assert.Equal(3, lines.Count); // members not dropped before conversion
|
||||
Assert.False(lines[0].ShowPrice);
|
||||
Assert.True(lines[1].ShowPrice);
|
||||
Assert.True(lines[2].ShowPrice);
|
||||
}
|
||||
|
||||
// ── No sets: pass-through unchanged ───────────────────────────────────────
|
||||
@@ -164,7 +177,7 @@ public class InvoiceSetPricingTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_TextLine_AsSetMember_NoPriceEvenInItemPrices()
|
||||
public void Build_TextLine_AsSetMember_NoPriceInSetPriceMode()
|
||||
{
|
||||
var items = new List<Dictionary<string, object?>>
|
||||
{
|
||||
@@ -172,10 +185,10 @@ public class InvoiceSetPricingTests
|
||||
new() { ["type"] = "title", ["setId"] = "10", ["title"] = "Hinweis", ["total_net"] = "" },
|
||||
Member("10", "Waschbecken", "600.00", "600.00")
|
||||
};
|
||||
var lines = InvoiceSetPricing.Build(items, SetDisplayMode.ItemPrices);
|
||||
var lines = InvoiceSetPricing.Build(items, SetDisplayMode.SetPrice);
|
||||
var note = lines.First(l => l.Title == "Hinweis");
|
||||
Assert.False(note.ShowPrice); // text member stays blank
|
||||
Assert.True(lines.First(l => l.Title == "Waschbecken").ShowPrice);
|
||||
Assert.False(note.ShowPrice); // text member stays blank (already blank in SetPrice mode)
|
||||
Assert.False(lines.First(l => l.Title == "Waschbecken").ShowPrice); // members blank in SetPrice mode too
|
||||
}
|
||||
|
||||
// ── Set price equals sum of member prices across modes (no double counting) ─
|
||||
|
||||
Reference in New Issue
Block a user