using System; using Centron.BusinessLogic; using Centron.BusinessLogic.Administration.Settings; using Centron.BusinessLogic.EmployeeArea; using Centron.BusinessLogic.Sales.Receipts.DataAndResults; using Centron.BusinessLogic.WebServices.Sales.Receipts; using Centron.Data.WebServices.Sales.Receipts.Invoices; using Centron.Interfaces; using Centron.Interfaces.Administration.Environment; using Centron.Interfaces.BL; using Centron.Tests.EndToEnd.Infrastructure; using Centron.Tests.EndToEnd.Infrastructure.Verifier; using CentronSoftware.Centron.WebServices.Entities.Sales.CustomerAssets.TimerBilling; using Xunit.Abstractions; namespace Centron.Tests.EndToEnd.Tests.TimerBilling { /// /// Covers the "transfer ticket times into a billing partlist" feature and every related setting: /// the two transfer modes (TransferAllTimesToPartsList / TransferTimesWithSamePriceToPartsList), /// ReplacePartListArticles, ReplacePartListText, SortPartListByTicket, and the special-article /// handling introduced by Ticket 161840 (TransferSpecialArticlesToPartsList). It also includes the /// control case where partlist transfer is OFF (nothing is transferred). /// /// Uses the Ticket87016 seed data: helpdesk timer 10509 (service article 94, 30 EUR) carrying two /// travel/Anfahrt special articles - 56 "Fahrtkosten" (fixed 35) and 1184 "Fahrtkosten" (x44). /// The billing partlist header is article 16 (a parts-list article). /// /// SortPartListByTicket is exercised with timers from two different tickets (10509 on ticket 12475 /// and 227 on ticket 12184 - same customer/address) so the per-ticket grouping is actually visible /// when compared against the combined variant. /// public class TimerBillingPartListTransferTest : EndToEndTest { private static readonly int HelpdeskTimerI3D = 10509; // ticket 12475, with Remote/Anfahrt special articles private static readonly int SecondHelpdeskTimerI3D = 227; // ticket 12184 (same customer), service article 94, no contract private static readonly int PartListHeaderArticleI3D = 16; public TimerBillingPartListTransferTest(ITestOutputHelper testOutputHelper) : base(testOutputHelper) { } public override void Execute() { this.PrepareDatabase(); // Control: partlist transfer not enabled -> times and special articles are billed as normal positions. this.CreateInvoice("NotTransferred", _ => { }); // Mode 1: all times into a single billing partlist; special articles out (default) vs in (Ticket 161840). this.CreateInvoice("AllTimes_SpecialArticlesExcluded", s => s.TransferAllTimesToPartsList = true); this.CreateInvoice("AllTimes_SpecialArticlesIncluded", s => { s.TransferAllTimesToPartsList = true; s.TransferSpecialArticlesToPartsList = true; }); // Mode 2: times grouped by equal price into billing partlists; special articles out vs in. this.CreateInvoice("SamePrice_SpecialArticlesExcluded", s => s.TransferTimesWithSamePriceToPartsList = true); this.CreateInvoice("SamePrice_SpecialArticlesIncluded", s => { s.TransferTimesWithSamePriceToPartsList = true; s.TransferSpecialArticlesToPartsList = true; }); // ReplacePartListArticles = false -> the header article keeps its own components instead of // being replaced by the transferred timer items. this.CreateInvoice("AllTimes_KeepExistingPartListArticles", s => { s.TransferAllTimesToPartsList = true; s.ReplacePartListArticles = false; }); // ReplacePartListText = true -> the partlist head text is replaced by the joined item texts. this.CreateInvoice("AllTimes_ReplacePartListText", s => { s.TransferAllTimesToPartsList = true; s.ReplacePartListText = true; }); // SortPartListByTicket: two timers from two different tickets, combined into one partlist... this.CreateInvoice("MultiTicket_AllTimes_Combined", s => s.TransferAllTimesToPartsList = true, HelpdeskTimerI3D, SecondHelpdeskTimerI3D); // ...vs. a separate partlist per ticket. this.CreateInvoice("MultiTicket_AllTimes_SortedByTicket", s => { s.TransferAllTimesToPartsList = true; s.SortPartListByTicket = true; }, HelpdeskTimerI3D, SecondHelpdeskTimerI3D); // Saved last, because saving allocates a receipt number and writes rows the other scenarios must // not see. This is the path the wizard takes with "Vorschau anzeigen" switched off. this.SaveInvoiceAndVerifyPositions("AllTimes_SpecialArticlesIncluded_Saved", s => { s.TransferAllTimesToPartsList = true; s.TransferSpecialArticlesToPartsList = true; }); } private void PrepareDatabase() { this.Sql($"UPDATE dbo.Stammdat SET WertText = 'g' WHERE I3D = {(int)AppSettingsConst.HelpdeskTimerStartTimeFormat}"); this.Sql($"UPDATE dbo.Stammdat SET WertText = 'g' WHERE I3D = {(int)AppSettingsConst.HelpdeskTimerEndTimeFormat}"); } private void CreateInvoice(string name, Action configure, params int[] timerI3Ds) { using (var session = new BLSession()) { var user = session.GetBL().GetAppUser(f => f.I3D == 11); var timers = timerI3Ds.Length > 0 ? timerI3Ds : new[] { HelpdeskTimerI3D }; var invoiceResult = session.GetBL() .CreateNewReceiptForHelpdekTimers(timers, CreateSettings(configure), user); var verifierSettings = new CentronVerifierSettings(); verifierSettings.IgnoreProperty(f => f.BalanceID); this.Verifier.Verify(name, invoiceResult, verifierSettings); } } /// /// Creates and saves the invoice the way the timer-billing wizard does when "Vorschau anzeigen" is /// switched off: the backend result is saved directly, without passing through the WPF position grid. /// Verifies the persisted positions, because reports and the ZUGFeRD export read the partlist head /// quantity and price from the database - QS found Stk = 1 on the head while the user interface /// displayed the summed quantity (Ticket 163721). /// private void SaveInvoiceAndVerifyPositions(string name, Action configure, params int[] timerI3Ds) { int positionCutoffI3D = this.Sql("SELECT ISNULL(MAX(I3D), 0) FROM dbo.RechPos"); using (var session = new BLSession()) { var user = session.GetBL().GetAppUser(f => f.I3D == 11); var timers = timerI3Ds.Length > 0 ? timerI3Ds : new[] { HelpdeskTimerI3D }; var invoiceResult = session.GetBL() .CreateNewReceiptForHelpdekTimers(timers, CreateSettings(configure), user); if (invoiceResult.Status == ResultStatus.Error) throw new InvalidOperationException($"{name}: creating the invoice failed - {invoiceResult.Message}"); var saveResult = session.GetBL().SaveReceipt( invoiceResult.Data, user, new SaveReceiptData { IgnoreCallbacks = true }, false, CreatedThroughApplication.CentronNet, null); if (saveResult.Status == ResultStatus.Error) throw new InvalidOperationException($"{name}: saving the invoice failed - {saveResult.Message}"); } // The billing partlist head replaces its children on the document - the children are hidden // (Visible 0) while the collapsed head (Expanded 0) carries the quantity and the unit price. // CalculatedNetPriceTotalFCComplete is the amount reports and the ZUGFeRD export read, so the head // amount and the sum of its children are both visible here. this.Verifier.VerifySql($"{name}_DbPositions", $@" SELECT Pos, ArtikelI3D, Indent, Expanded, Visible, ISNULL(CAST(IsBillingPartList AS int), 0) AS IsBillingPartList, Stk, VKBasisWert, EK, ISNULL(Rabatt, 0) AS Rabatt, CalculatedNetPriceTotalFCComplete FROM dbo.RechPos WHERE I3D > {positionCutoffI3D} ORDER BY Pos"); // The collapsed head stands in for its hidden children on the document, so both amounts have to // agree. They can differ by cents when the head unit price (total / quantity) does not divide // evenly at the head article's number of decimals - the difference is asserted, not assumed. this.Verifier.VerifySql($"{name}_HeadAmountVersusChildren", $@" SELECT Head.HeadAmount, Children.ChildAmount, Head.HeadAmount - Children.ChildAmount AS Difference FROM (SELECT ISNULL(SUM(CalculatedNetPriceTotalFCComplete), 0) AS HeadAmount FROM dbo.RechPos WHERE I3D > {positionCutoffI3D} AND ISNULL(CAST(IsBillingPartList AS int), 0) = 1) Head CROSS JOIN (SELECT ISNULL(SUM(CalculatedNetPriceTotalFCComplete), 0) AS ChildAmount FROM dbo.RechPos WHERE I3D > {positionCutoffI3D} AND Indent > 0 AND Expanded IS NULL) Children"); } private static TimerBillingSettingsDTO CreateSettings(Action configure) { var settings = new TimerBillingSettingsDTO { InsertHelpdeskDescription = true, InsertHelpdeskShortDescription = true, InsertTimerText = true, InsertTimerTime = true, InsertTheFollowingAreTimersFromHelpdeskText = true, Sorting = Interfaces.Sales.Receipts.TimerBilling.TimerSorting.ChronologicalUp, ReceiptKind = CentronObjectKindNumeric.InvoiceClass, // Partlist defaults; individual scenarios override what they test. DefaultPartListHeaderI3D = PartListHeaderArticleI3D, ReplacePartListArticles = true, }; configure(settings); return settings; } } }