using System; using System.Linq; using AwesomeAssertions; using NUnit.Framework; using Action = System.Action; using Soneta.Business; using Soneta.CRM; using Soneta.Handel; using Soneta.Tools; using Soneta.Towary; using Soneta.Types; namespace Soneta.Skills.Test.Handel.DokumentyHandlowe; /// /// Rozdział 11 skilla „dokument-handlowy” — Operacje pomocnicze (przekrojowe) (W56–W61). /// /// Testy weryfikują wzorce „okołodokumentowe”: bezpieczne pozyskanie kontrahenta/towaru i obsługę /// kontrahenta incydentalnego (W56), przeliczanie jednostek miary towaru (W57), walidację przed /// zatwierdzeniem (W58), obsługę błędów/blokady optymistycznej (W59), odczyt metadanych audytowych /// ChangeInfos (W60) oraz pracę z definicjami i numeracją dokumentu (W61). /// /// /// W bazie Demo działa StanUjemnyVerifier (blokada stanu ujemnego): rozchód wymaga /// wcześniejszego zapisanego przyjęcia. Do prostych scenariuszy używamy przychodu (PW), który /// niczego nie blokuje. Magazyn księguje się dopiero po Session.Save(). Wzorzec testów: /// utwórz → SaveDispose() → odczyt na świeżej sesji po Guid (po Save() w środku /// testu okno edycji się zamyka — kolejna edycja rzuca AccessWriteDenied). /// /// Cała klasa operuje wyłącznie na publicznym kontrakcie platformy Soneta (jak dodatek zewnętrzny). /// [TestFixture] public class Rozdzial11_PomocniczeTest : DokumentHandlowyTestBase { // =================================================================================== // Pomocnik lokalny: zatwierdzony przychód (PW) z pozycją, zapisany trwale. // PW to przychód — nie podlega blokadzie stanu ujemnego, więc nadaje się do testów // numeracji, audytu i odczytu metadanych po zatwierdzeniu. // =================================================================================== private Guid UtworzZatwierdzonyPwIZapisz(double ilosc = 10, double cena = 5) { var dok = UtworzDokument(Definicje.PrzyjecieWewnetrzne, magazyn: Magazyn(Magazyn_.Firma)); InTransaction(() => DodajPozycje(dok, Towar(Towar_.Bikini), ilosc, cena)); InTransaction(() => dok.Stan = StanDokumentuHandlowego.Zatwierdzony); var guid = dok.Guid; // Dopiero Save() nadaje numer właściwy i księguje obroty/zasoby; SaveDispose zamyka sesję. SaveDispose(); return guid; } // =================================================================================== // W56 — Bezpieczne pobranie / utworzenie kontrahenta i towaru pozycji // =================================================================================== [Test] [Description("W56: WgKodu zwraca istniejący rekord dla znanego kodu, a null dla kodu spoza kartoteki " + "(klucz unikalny — pojedynczy rekord lub null).")] public void W56_LookupPoKodzie_ZwracaRekordLubNull() { // Istniejący kontrahent z bazy Demo — lookup po kluczu unikalnym zwraca jeden rekord. Kontrahent istniejacy = Kontrahent(Kontrahent_.Abc); istniejacy.Should().NotBeNull("kontrahent „Abc” istnieje w bazie Demo"); // Kod spoza kartoteki → null (nie wyjątek). To podstawa kontroli istnienia przed użyciem. Kontrahent brak = Kontrahent("NIE_ISTNIEJE_XYZ"); brak.Should().BeNull("WgKodu dla nieistniejącego kodu zwraca null"); // Analogicznie towar po kodzie. Towar towar = Towar(Towar_.Bikini); towar.Should().NotBeNull("towar „BIKINI” istnieje w bazie Demo"); Towar brakTowaru = Towar("NIE_MA_TAKIEGO"); brakTowaru.Should().BeNull("WgKodu dla nieistniejącego kodu towaru zwraca null"); } [Test] [Description("W56: kontrahent incydentalny — rekord systemowy pobierany po stałej Kontrahent.INCYDENTALNY " + "(indeksator GuidedTable po Guid); rekord ma JestIncydentalny == true.")] public void W56_KontrahentIncydentalny_PobranyPoGuidJestOznaczonyJakoIncydentalny() { // Sprzedaż jednorazowa (klient detaliczny bez kartoteki) — używamy systemowego rekordu // „incydentalnego” zamiast tworzyć nowego kontrahenta. Dostęp po stałej Guid. Soneta.CRM.Kontrahent incydentalny = Crm.Kontrahenci[Soneta.CRM.Kontrahent.INCYDENTALNY]; incydentalny.Should().NotBeNull("rekord incydentalny to systemowy rekord obecny w bazie"); // JestIncydentalny to pole KALKULOWANE (bool) — potwierdza, że to rekord systemowy. incydentalny.JestIncydentalny.Should().BeTrue("to systemowy kontrahent incydentalny"); // Zwykły kontrahent z kartoteki NIE jest incydentalny. Kontrahent(Kontrahent_.Abc).JestIncydentalny.Should() .BeFalse("kontrahent z kartoteki nie jest rekordem incydentalnym"); } [Test] [Description("W56: fallback przy braku rekordu — gdy WgKodu zwraca null, kontrola istnienia pozwala " + "sięgnąć po systemowy rekord incydentalny; jednak na fakturze (FV) NIE wolno go ustawiać — " + "setter rzuca ArgumentException (kontrahent incydentalny niedozwolony w dokumentach typu FV).")] public void W56_FallbackNaIncydentalnego_GdyBrakKontrahentaPoKodzie() { // Symulacja: kod nabywcy nie istnieje w kartotece — WgKodu zwraca null (kontrola istnienia). Kontrahent kontrahent = Kontrahent("DETAL_BEZ_KARTOTEKI"); if (kontrahent == null) kontrahent = Crm.Kontrahenci[Soneta.CRM.Kontrahent.INCYDENTALNY]; // świadomy fallback po stałej Guid // Fallback rzeczywiście znajduje systemowy rekord incydentalny (bez przypisywania go do dokumentu). kontrahent.Should().NotBeNull("systemowy rekord incydentalny istnieje w bazie Demo"); kontrahent.JestIncydentalny.Should().BeTrue("to systemowy kontrahent incydentalny"); // Reguła biznesowa: kontrahenta incydentalnego NIE wolno ustawiać na fakturze sprzedaży (FV). // Setter Kontrahent na FV zgłasza ArgumentException — dokumentujemy to jako twardą walidację platformy. var dok = UtworzDokument(Definicje.FakturaSprzedazy, magazyn: Magazyn(Magazyn_.Firma)); Action ustawIncydentalnego = () => InTransaction(() => dok.Kontrahent = kontrahent); ustawIncydentalnego.Should().Throw( "kontrahenta incydentalnego nie można ustawiać w dokumentach typu FV"); } // =================================================================================== // W57 — Przeliczanie jednostek miary towaru (Towar.PrzeliczJednostkę) // =================================================================================== [Test] [Description("W57: PrzeliczJednostkę w jednostce podstawowej towaru (przeliczenie tożsamościowe) " + "zwraca tę samą wartość i symbol — przelicznik 1:1 jest zawsze zdefiniowany.")] public void W57_PrzeliczJednostkeNaPodstawowa_ZwracaTeSamaIlosc() { var towar = Towar(Towar_.Bikini); towar.Should().NotBeNull(); // Ilość w jednostce PODSTAWOWEJ towaru. throwError: true — brak przelicznika zgłosiłby wyjątek, // ale dla jednostki podstawowej → podstawowa konwersja jest tożsamościowa (zawsze poprawna). var iloscPodst = new Quantity(7, towar.Jednostka.Kod); Quantity wynik = towar.PrzeliczJednostkę(towar.Jednostka, iloscPodst, throwError: true); // Przeliczenie 1:1 — wartość i jednostka bez zmian. wynik.Value.Should().Be(7); wynik.Symbol.Should().Be(towar.Jednostka.Kod); } [Test] [Description("W57: na pozycji dokumentu po ustawieniu Towaru symbol jednostki na Ilosc pochodzi z " + "jednostki podstawowej towaru — new Quantity(n, poz.Ilosc.Symbol) daje zgodny symbol.")] public void W57_SymbolJednostkiNaPozycji_PochodziZTowaru() { var towar = Towar(Towar_.Bikini); var dok = UtworzDokument(Definicje.PrzyjecieWewnetrzne, magazyn: Magazyn(Magazyn_.Firma)); PozycjaDokHandlowego poz = null; InTransaction(() => { // DodajPozycje ustawia Towar PIERWSZY (inicjuje jednostkę), potem Ilosc z symbolem pozycji. poz = DodajPozycje(dok, towar, ilosc: 4); }); // Symbol jednostki pozycji pokrywa się z jednostką podstawową towaru. poz.Ilosc.Symbol.Should().Be(towar.Jednostka.Kod, "ustawienie Towaru inicjuje symbol jednostki na Ilosc"); poz.Ilosc.Value.Should().Be(4); } // =================================================================================== // W58 — Walidacja przed zatwierdzeniem (kompletność, zasób) // =================================================================================== [Test] [Description("W58: własna walidacja kompletności przed zmianą stanu — dokument bez pozycji ma " + "Pozycje.IsEmpty == true (właściwość serwerowa), co pozwala zgłosić czytelny błąd.")] public void W58_WalidacjaKompletnosci_PustyDokumentMaPozycjeIsEmpty() { // FV bez pozycji — nabywca ustawiony, ale brak pozycji. var dok = UtworzDokument(Definicje.FakturaSprzedazy, kontrahent: Kontrahent(Kontrahent_.Abc), magazyn: Magazyn(Magazyn_.Firma)); // IsEmpty to WŁAŚCIWOŚĆ (serwerowy exists), nie metoda — używamy jej w walidacji własnej. dok.Pozycje.IsEmpty.Should().BeTrue("dokument nie ma jeszcze pozycji"); dok.Kontrahent.Should().NotBeNull("nabywca jest ustawiony"); // Po dodaniu pozycji walidacja kompletności przechodzi. InTransaction(() => DodajPozycje(dok, Towar(Towar_.Bikini), ilosc: 2, cena: 5)); dok.Pozycje.IsEmpty.Should().BeFalse("po dodaniu pozycji kolekcja nie jest pusta"); } [Test] [Description("W58: blokada stanu ujemnego — zatwierdzenie i zapis rozchodu (WZ) towaru bez wcześniej " + "zapisanego przyjęcia zgłasza wyjątek dopiero w Save() (StanUjemnyVerifier).")] public void W58_RozchodBezStanu_RzucaWyjatekWSave() { // WZ rozchodowy towaru BIKINI — w tym teście NIE robimy wcześniejszego przyjęcia, // więc stan jest niewystarczający. Magazyn księguje się dopiero w Save(). var wz = UtworzDokument(Definicje.WydanieZewnetrzne, kontrahent: Kontrahent(Kontrahent_.Abc), magazyn: Magazyn(Magazyn_.Firma)); InTransaction(() => DodajPozycje(wz, Towar(Towar_.Bikini), ilosc: 5, cena: 9)); InTransaction(() => wz.Stan = StanDokumentuHandlowego.Zatwierdzony); // Sam Commit NIE księguje zasobów — kontrola stanu ujemnego uruchamia się w Save(). Action zapis = () => SaveDispose(); zapis.Should().Throw("StanUjemnyVerifier blokuje rozchód bez zapisanego przyjęcia"); } // =================================================================================== // W59 — Obsługa błędów i blokada optymistyczna // =================================================================================== [Test] [Description("W59: wzorzec łapania wyjątku platformy — edycja na sesji z zamkniętym oknem edycji " + "(po SaveDispose) rzuca wyjątek; asercja typu/komunikatu zamiast „połykania”.")] public void W59_EdycjaPozaTransakcja_RzucaWyjatek() { // Tworzymy i zapisujemy dokument; po SaveDispose okno edycji bieżącej sesji jest zamknięte. var guid = UtworzZatwierdzonyPwIZapisz(); var dok = Get(guid); dok.Should().NotBeNull(); // Próba modyfikacji pola POZA transakcją edycyjną (bez Session.Logout(true)) jest niedozwolona. // Wzorzec safe-code: łapiemy konkretny wyjątek platformy, nie Exception ogólnie „po cichu”. // MemoText przyjmuje string przez konwersję niejawną (string -> MemoText). Action edycjaBezTransakcji = () => dok.Opis = "X"; edycjaBezTransakcji.Should().Throw( "modyfikacja rekordu wymaga otwartej transakcji edycyjnej (Session.Logout(true))"); } [Test] [Description("W59: walidacja własna rzucana jako RowException PRZED Commit — wyjątek niesie odwołanie " + "do wiersza i komunikat; asercja typu wyjątku i jego Row.")] public void W59_WalidacjaWlasna_RzucaRowException() { // Pokazujemy WZORZEC obsługi: walidacja własna zgłasza RowException(dok, komunikat) przed Commit. var dok = UtworzDokument(Definicje.FakturaSprzedazy, magazyn: Magazyn(Magazyn_.Firma)); // Symulacja walidacji „brak nabywcy” — w realnym kodzie poprzedza zmianę stanu. Action walidacja = () => { if (dok.Kontrahent == null) throw new RowException(dok, "Dokument nie ma nabywcy.".Translate()); }; // Asercja TYPU wyjątku (nie ogólne Exception) — tak rozróżnia się walidację biznesową. // RowException udostępnia wiersz przez właściwość IRow (RowException dziedziczy z BusException). walidacja.Should().Throw() .Which.IRow.Should().Be(dok, "RowException niesie odwołanie do wiersza, którego dotyczy"); } // =================================================================================== // W60 — Odczyt metadanych dokumentu (ChangeInfos) // =================================================================================== [Test] [Description("W60: po utworzeniu i zapisaniu dokumentu FirstChangeInfo (kto/kiedy założył) jest " + "wypełnione, gdy audyt jest włączony; gdy null (tryb testowy bez rejestracji) — pomijamy asercję.")] public void W60_FirstChangeInfo_PoZapisieNiepusteLubPominiete() { var guid = UtworzZatwierdzonyPwIZapisz(); var dok = Get(guid); dok.Should().NotBeNull(); // FirstChangeInfo jest KALKULOWANE (select top 1 ... from ChangeInfos) i może być null, // gdy historia rekordu nie była rejestrowana (np. import / audyt wyłączony). var zalozyl = dok.FirstChangeInfo; if (zalozyl == null) { // Audyt nie zarejestrował wpisu w tym trybie — SKIP asercji, sam odczyt nie rzuca. Assert.Ignore("Brak wpisu ChangeInfo dla rekordu w tym trybie (rejestracja audytu wyłączona) — " + "właściwość kalkulowana zwróciła null; odczyt jest dozwolony, asercję pomijamy."); } // Gdy audyt działa — wpis ma czas utworzenia i (zwykle) operatora. zalozyl.Time.Should().NotBe(default(DateTime), "wpis założenia niesie czas utworzenia"); } [Test] [Description("W60: LastChangeInfo (kto/kiedy ostatnio zmienił) po zapisie jest niepuste lub — w trybie " + "bez rejestracji audytu — null; odczyt nie rzuca, asercję czasu wykonujemy warunkowo.")] public void W60_LastChangeInfo_PoZapisieNiepusteLubPominiete() { var guid = UtworzZatwierdzonyPwIZapisz(); var dok = Get(guid); // Sam odczyt właściwości kalkulowanej nie może rzucać — zawsze sprawdzamy != null. var ostatnia = dok.LastChangeInfo; if (ostatnia == null) { Assert.Ignore("Brak wpisu LastChangeInfo w tym trybie (rejestracja audytu wyłączona) — " + "odczyt dozwolony, asercję pomijamy."); } ostatnia.Time.Should().NotBe(default(DateTime), "wpis ostatniej zmiany niesie czas"); } // =================================================================================== // W61 — Praca z definicjami i numeracją (seria, numer pełny, bufor) // =================================================================================== [Test] [Description("W61: dokument w buforze nie ma jeszcze numeru właściwego — BuforNumer == \"BUFOR\", " + "a po zatwierdzeniu i zapisie Numer.NumerPelny zawiera nadany numer (bez znacznika BUFOR).")] public void W61_NumerNadawanyPrzyZatwierdzeniu_BuforPotemNumerWlasciwy() { // Dokument w buforze (jeszcze niezatwierdzony). var dok = UtworzDokument(Definicje.PrzyjecieWewnetrzne, magazyn: Magazyn(Magazyn_.Firma)); InTransaction(() => DodajPozycje(dok, Towar(Towar_.Bikini), ilosc: 10, cena: 5)); // W buforze numer właściwy nie jest nadany — kalkulowane BuforNumer zwraca znacznik „BUFOR”. dok.Bufor.Should().BeTrue(); dok.BuforNumer.Should().Be("BUFOR", "w buforze numer właściwy nie jest jeszcze nadany"); // Zatwierdzenie + Save nadaje numer właściwy. var guid = dok.Guid; InTransaction(() => dok.Stan = StanDokumentuHandlowego.Zatwierdzony); SaveDispose(); // Odczyt na świeżej sesji — numer pełny czytamy przez Numer.NumerPelny (nie składamy ręcznie). var zapisany = Get(guid); zapisany.Zatwierdzony.Should().BeTrue(); string numer = zapisany.Numer.NumerPelny; numer.Should().NotBeNullOrEmpty("zatwierdzony dokument ma nadany numer pełny"); numer.Should().NotContain("BUFOR", "po zatwierdzeniu numer nie zawiera już znacznika bufora"); } [Test] [Description("W61: pobranie definicji po symbolu (WgSymbolu) oraz odczyt dozwolonych serii dokumentu " + "przez GetListSeria(); dodatkowo na zatwierdzonym i zapisanym PW Numer.NumerPelny jest niepusty.")] public void W61_DefinicjaISerie_OdczytPublicznegoKontraktu() { // Definicja dokumentu pobierana po symbolu z bazy Demo (klucz WgSymbolu). DefDokHandlowego def = Definicja(Definicje.FakturaSprzedazy); def.Should().NotBeNull("definicja FV istnieje w bazie Demo"); var dok = UtworzDokument(Definicje.FakturaSprzedazy, kontrahent: Kontrahent(Kontrahent_.Abc), magazyn: Magazyn(Magazyn_.Firma)); // Dokument ma przypisaną definicję (ustawioną jako pierwszą przez helper bazy). dok.Definicja.Should().Be(def); // GetListSeria() zwraca dozwolone serie lub null, gdy numeracja nie ma komponentu Seria. // Kontrakt testu: sam ODCZYT nie może rzucać; null jest dopuszczalny (brak komponentu Seria). string[] serie = null; Action odczytSerii = () => serie = dok.GetListSeria(); odczytSerii.Should().NotThrow("odczyt dozwolonych serii nie może rzucać"); // Serię ustawiamy TYLKO gdy numeracja na to pozwala — w przeciwnym razie setter rzuciłby RowException. if (serie != null && serie.Length > 0) { InTransaction(() => dok.Seria = serie[0]); dok.Seria.Should().Be(serie[0], "ustawiona seria została zapamiętana"); } // Numerację potwierdzamy na bezpiecznym dokumencie przychodowym (PW), który można zatwierdzić // i zapisać (FV w bazie Demo rzuca NRE w ewidencji VAT przy zatwierdzeniu — §3 faktów). var pwGuid = UtworzZatwierdzonyPwIZapisz(); var pw = Get(pwGuid); // świeża sesja po SaveDispose (§8) pw.Should().NotBeNull(); pw.Numer.NumerPelny.Should().NotBeNullOrEmpty("zatwierdzony i zapisany dokument ma nadany numer pełny"); } // =================================================================================== // SCENARIUSZE POMINIĘTE (SKIP) — uzasadnienie zgodne z treścią rozdziału // =================================================================================== [Test] [Ignore("W56 — utworzenie NOWEGO kontrahenta/towaru „w locie” (new Kontrahent()/new Towar() + AddRow) " + "to dane kartotekowe (KONFIGURACYJNE), a nie zachowanie dokumentu handlowego. Rozdział wprost " + "odradza tworzenie towaru przy wystawianiu (brak towaru = błąd danych → BusException). Tworzenie " + "kartoteki kontrahenta jest pokryte w testach CRM (kontrahent.md, W3). SKIP: poza zakresem rozdziału.")] [Description("W56: tworzenie nowego rekordu kartotekowego w locie — pominięte (zakres CRM/konfiguracja).")] public void W56_TworzenieNowegoRekordu_Skip() { } [Test] [Ignore("W57 — przeliczenie z jednostki POMOCNICZEJ na podstawową (PrzeliczJednostkę z realnym " + "przelicznikiem ≠ 1:1) wymaga towaru z ZDEFINIOWANYM przelicznikiem jednostki pomocniczej/" + "uzupełniającej. Przeliczniki to dane konfiguracyjne towaru; baza Demo nie gwarantuje towaru " + "z jednoznacznym przelicznikiem pomocniczym (TRANSPORT ma jednostkę km, ale konfiguracja " + "przeliczników nie jest częścią kontraktu testu). Z throwError: true brak przelicznika rzuciłby " + "wyjątek — test byłby kruchy. Pokrywamy konwersję tożsamościową (1:1) i symbol jednostki pozycji. " + "SKIP: realny przelicznik pomocniczy = konfiguracja towaru poza zakresem.")] [Description("W57: przeliczenie z jednostki pomocniczej (przelicznik ≠ 1:1) — pominięte (konfiguracja towaru).")] public void W57_PrzeliczniePomocniczej_Skip() { } [Test] [Ignore("W59 — realny konflikt optymistyczny (RowConflictException) i retry wymagają RÓWNOLEGŁEGO zapisu " + "tego samego rekordu z DRUGIEJ sesji/wątku. TestBase udostępnia pojedynczą sesję operacyjną i wycofuje " + "zmiany w transakcji bazodanowej; symulacja drugiej, zapisującej sesji wykracza poza ten model testowy " + "(ryzyko zakleszczeń i niestabilności). Wzorzec łapania wyjątku platformy pokrywamy w " + "W59_EdycjaPozaTransakcja i W59_WalidacjaWlasna (asercja typu wyjątku). SKIP: realny wyścig zapisu " + "poza modelem TestBase.")] [Description("W59: faktyczny konflikt optymistyczny i retry między sesjami — pominięte (model TestBase).")] public void W59_KonfliktOptymistyczny_Skip() { } }