Files
soneta-erp-skills/Soneta.Skills.Test/KadryPlace/Pracownik/RozdzialCrest_PotraceniaTest.cs
T
2026-06-06 22:33:15 +02:00

520 lines
27 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Linq;
using AwesomeAssertions;
using NUnit.Framework;
using Soneta.Business;
using Soneta.Kadry;
using Soneta.Place;
using Soneta.Przeszeregowania;
using Soneta.Types;
using Prac = Soneta.Kadry.Pracownik;
namespace Soneta.Skills.Test.KadryPlace.Pracownik;
/// <summary>
/// Rozdział C (część „potrąceniowa") — receptury C2C7 z dokumentu skilla <c>pracownik.md</c>:
/// <list type="bullet">
/// <item><b>C2</b> — potrącenia: w modelu płacowym potrącenie NIE ma osobnej klasy; to
/// <c>Soneta.Kadry.Dodatek</c> z definicją elementu o <c>Algorytm.Potracenie == true</c>;</item>
/// <item><b>C3</b> — akordy: <c>Soneta.Kadry.Akord</c> bez publicznego konstruktora — dodawane przez
/// worker <c>Pracownik.DodajAkordWorker</c>; zakończenie przez <c>ZakończAkordWorker</c>;</item>
/// <item><b>C4</b> — zajęcia komornicze: <c>new ZajęcieKomornicze(pracownik)</c>; anulowanie/przywracanie
/// przez workery <c>AnulujWorker</c>/<c>PrzywrócWorker</c>;</item>
/// <item><b>C5</b> — operacje seryjne na dodatkach (moduł <c>Soneta.Przeszeregowania</c>): worker
/// <c>NowyDodatekWorker</c> oraz dokument <c>Przeszeregowanie</c>;</item>
/// <item><b>C6</b> — świadczenia socjalne (ZFŚS): <c>new SwiadczSocjalne(pracownik)</c> + subrow
/// <c>Rozliczenie</c>;</item>
/// <item><b>C7</b> — pożyczki (KZP/ZFM): trzystopniowo <c>FundPozyczkowy(pracownik, definicja)</c> →
/// <c>Pozyczka(fundusz)</c> → harmonogram rat przez <c>UzgodnijRatyWorker</c>.</item>
/// </list>
/// <para>
/// Faktyczne kwoty/spłaty (<c>Splacono</c>, <c>Pozostało</c>, <c>Rozliczone</c>, stany rat) wyliczają się
/// dopiero przy NALICZENIU WYPŁATY (rozdział H). Te testy weryfikują UTWORZENIE i PARAMETRYZACJĘ obiektów
/// oraz publiczny model — skutki finansowe są poza zakresem (asercje na model albo <c>[Ignore]</c>).
/// </para>
/// <para>
/// Wszystko działa na bazie Demo (GoldStandard) z automatycznym rollbackiem po teście. Operujemy wyłącznie
/// na <b>publicznym kontrakcie</b> — jak dodatek programisty zewnętrznego bez dostępu do kodu źródłowego.
/// Definicje (DefElementow, DefinicjeAkordow, DefSwiadczSocjal, DefFundPozycz) pobieramy DYNAMICZNIE;
/// brak wpisu w Demo kończy test przez <c>Assert.Ignore</c>, nie przez błąd.
/// </para>
/// </summary>
[TestFixture]
public class RozdzialCrest_PotraceniaTest : PracownikTestBase
{
// Helper: świeży pracownik etatowy (Etat.Okres odblokowuje warunki; Wydzial+Stanowisko wymagane przy Save).
private Prac NowyPracownikEtatowy(string prefix, out Guid guid)
{
var pracownik = Session.AddRow(new PracownikFirmy());
pracownik.Kod = prefix + "_" + Guid.NewGuid().ToString("N").Substring(0, 6);
pracownik.Last.Nazwisko = "Testowy";
pracownik.Last.Imie = "Jan";
var etat = pracownik.Last.Etat;
etat.Okres = new FromTo(new Date(2026, 1, 1), Date.MaxValue); // PIERWSZE — odblokowuje Etat
etat.Wydzial = Kadry.Wydzialy.Firma;
etat.Stanowisko = "Specjalista";
guid = pracownik.Guid;
return pracownik;
}
// Helper: pierwsza definicja potrącenia możliwa do podpięcia pod Dodatek.
// WAŻNE: znacznik Algorytm.Potracenie nie wystarcza — element podpinany pod Dodatek MUSI mieć też
// RodzajZrodla == Dodatek (DodHistoria.Element odrzuca definicje o innym rodzaju źródła, np. "Alimenty"
// jako RodzajZrodla == ZajęcieKomornicze).
private DefinicjaElementu PierwszaDefinicjaPotraceniaJakoDodatek() =>
Place.DefElementow.Cast<DefinicjaElementu>()
.FirstOrDefault(d => d.RodzajZrodla == RodzajŹródłaWypłaty.Dodatek
&& d.Algorytm != null && d.Algorytm.Potracenie);
// Helper: pierwsza definicja elementu zajęcia komorniczego (RodzajZrodla == ZajęcieKomornicze).
private DefinicjaElementu PierwszaDefinicjaZajecia() =>
Place.DefElementow.Cast<DefinicjaElementu>()
.FirstOrDefault(d => d.RodzajZrodla == RodzajŹródłaWypłaty.ZajęcieKomornicze);
// ============================== C2 — Potrącenia (stałe / jednorazowe) ==============================
[Test]
[Description("C2: potrącenie NIE jest osobną klasą — to Dodatek z definicją elementu, w której " +
"Algorytm.Potracenie == true. Tworzymy przez new Dodatek(pracownik) + Kadry.Dodatki.AddRow. " +
"UWAGA (zweryfikowane): aby definicję podpiąć pod Dodatek, musi ona mieć RodzajZrodla == Dodatek " +
"ORAZ Algorytm.Potracenie == true — sam znacznik Algorytm.Potracenie nie wystarcza " +
"(DodHistoria.Element odrzuca definicje o innym rodzaju źródła, np. \"Alimenty\").")]
public void C2_Potracenie_ToDodatekZDefinicjaPotracajaca()
{
var defPotracenia = PierwszaDefinicjaPotraceniaJakoDodatek();
if (defPotracenia == null)
Assert.Ignore("Baza Demo nie zawiera definicji Dodatku o Algorytm.Potracenie == true.");
// Potrącenie-Dodatek: charakter minusowy daje algorytm, ale rodzaj źródła musi być Dodatek.
defPotracenia.Algorytm.Potracenie.Should().BeTrue("to definicja o charakterze potrącenia");
defPotracenia.RodzajZrodla.Should().Be(RodzajŹródłaWypłaty.Dodatek,
"potrącenie podpinane pod Dodatek musi mieć RodzajZrodla == Dodatek");
Guid guid = Guid.Empty;
var okres = new FromTo(new Date(2026, 1, 1), Date.MaxValue); // stałe
InTransaction(() =>
{
var pracownik = NowyPracownikEtatowy("C2", out guid);
// Mechanizm identyczny jak C1 (Dodatek + DodHistoria) — różni tylko dobór definicji.
var potracenie = new Dodatek(pracownik);
Kadry.Dodatki.AddRow(potracenie); // tworzy pierwszy zapis DodHistoria (Last)
var h = potracenie.Last;
h.Should().NotBeNull("AddRow tworzy pierwszy zapis DodHistoria");
h.Element = defPotracenia; // definicja o Algorytm.Potracenie == true (wymagana)
h.Okres = okres; // stałe potrącenie — okres otwarty
});
SaveDispose();
var pracownik2 = Get<Prac>(guid);
var dodatki = pracownik2.Dodatki.Cast<Dodatek>().ToList();
dodatki.Should().ContainSingle("dodaliśmy jedno potrącenie (Dodatek) do świeżego pracownika");
dodatki[0].Last.Element.Should().NotBeNull("Element (definicja potrącenia) jest wymagany");
dodatki[0].Last.Element.Algorytm.Potracenie.Should().BeTrue(
"trwale zapisana definicja zachowuje charakter potrącenia");
}
[Test]
[Description("C2 (jednorazowe): potrącenie jednorazowe to Dodatek z OKRESEM zawężonym do jednego " +
"miesiąca rozliczeniowego — naliczy się tylko w wypłatach z tego miesiąca. " +
"Okres ustawiamy przez FromTo.Month(YearMonth).")]
public void C2_PotracenieJednorazowe_OkresZawezonyDoMiesiaca()
{
var defPotracenia = PierwszaDefinicjaPotraceniaJakoDodatek();
if (defPotracenia == null)
Assert.Ignore("Baza Demo nie zawiera definicji Dodatku o Algorytm.Potracenie == true.");
Guid guid = Guid.Empty;
var okresMiesiaca = FromTo.Month(2026, 3); // jeden miesiąc rozliczeniowy (marzec 2026)
InTransaction(() =>
{
var pracownik = NowyPracownikEtatowy("C2j", out guid);
var potracenie = new Dodatek(pracownik);
Kadry.Dodatki.AddRow(potracenie);
potracenie.Last.Element = defPotracenia;
potracenie.Last.Okres = okresMiesiaca; // jednorazowe — tylko marzec 2026
});
SaveDispose();
var h = Get<Prac>(guid).Dodatki.Cast<Dodatek>().Single().Last;
// Okres zawężony do jednego miesiąca — granice pokrywają się z miesiącem rozliczeniowym.
h.Okres.From.Should().Be(okresMiesiaca.From, "potrącenie jednorazowe obejmuje tylko jeden miesiąc");
h.Okres.To.Should().Be(okresMiesiaca.To);
}
// ============================== C3 — Akordy ==============================
[Test]
[Description("C3: Akord NIE ma publicznego konstruktora — kanoniczną ścieżką dodania jest worker " +
"Pracownik.DodajAkordWorker (parametryzowany przez Params(context) + Pracownicy[]). " +
"Definicję akordu pobieramy ze słownika DefinicjeAkordow (klucz WgNazwa). Odczyt z pracownik.Akordy.")]
public void C3_Akord_DodawanyWorkerem_ZDefinicjiSlownika()
{
var defAkordu = Kadry.DefinicjeAkordow.Cast<DefinicjaAkordu>().FirstOrDefault();
if (defAkordu == null)
Assert.Ignore("Baza Demo nie zawiera żadnej definicji akordu (DefinicjeAkordow).");
// Akord NIE ma publicznego ctora — potwierdzenie kanonicznej ścieżki (worker zamiast `new`).
typeof(Akord).GetConstructors()
.Should().NotContain(c => c.GetParameters().Length == 1
&& c.GetParameters()[0].ParameterType == typeof(Prac),
"Akord nie ma publicznego ctora new Akord(pracownik) — dodajemy go workerem");
Guid guid = Guid.Empty;
InTransaction(() => NowyPracownikEtatowy("C3", out guid));
SaveDispose();
// Worker akordu działa „jak z UI" (Params wymaga Context) — używamy InUITransaction + CommitUI.
bool dodano = false;
InUITransaction(() =>
{
var pracownik = Get<Prac>(guid);
var context = Login.CreateEmptyContext().Clone(Session);
var par = new Prac.DodajAkordWorker.Params(context)
{
Definicja = defAkordu,
OdDnia = new Date(2026, 1, 1),
DoDnia = new Date(2026, 12, 31),
};
// Worker akordu ma ctor (Session); parametry przez property Pars/Pracownicy.
var worker = new Prac.DodajAkordWorker(Session) { Pars = par, Pracownicy = new[] { pracownik } };
worker.DodajAkord();
dodano = true;
});
if (!dodano)
Assert.Ignore("DodajAkordWorker nie wykonał się w headless host (zależność od kontekstu UI).");
SaveDispose();
// Odczyt akordów pracownika (child SubTable). Akord jest historyczny — bieżący zapis przez Last.
var akordy = Get<Prac>(guid).Akordy.Cast<Akord>().ToList();
akordy.Should().ContainSingle("worker dodał jeden akord");
akordy[0].Definicja.Should().NotBeNull("akord wiąże definicję ze słownika DefinicjeAkordow");
akordy[0].Last.Should().NotBeNull("akord ma bieżący zapis historii AkordHistoria");
}
// ============================== C4 — Zajęcia wynagrodzenia (komornicze/alimentacyjne) ==============================
[Test]
[Description("C4: zajęcie komornicze to JEDNA klasa ZajęcieKomornicze (alimentacyjne vs niealimentacyjne " +
"rozstrzyga definicja elementu i parametry zapisu historii, nie osobny typ ani pole Priorytet — " +
"którego na ZajęcieKomornicze NIE ma). Ctor publiczny new ZajęcieKomornicze(pracownik) + " +
"Kadry.ZajKomornicze.AddRow. Element (potrącenie zajęcia) jest wymagany. " +
"Rodzaj to enum RodzajeZajęciaWynagrodzenia { Kwota, KwotaMiesięczna }.")]
public void C4_ZajecieKomornicze_TworzoneZParametrami()
{
// Element zajęcia — definicja o RodzajZrodla == ZajęcieKomornicze (dedykowany rodzaj źródła).
var elementZajecia = PierwszaDefinicjaZajecia();
if (elementZajecia == null)
Assert.Ignore("Baza Demo nie zawiera definicji elementu o RodzajZrodla == ZajęcieKomornicze.");
Guid guid = Guid.Empty;
InTransaction(() =>
{
var pracownik = NowyPracownikEtatowy("C4", out guid);
var zajecie = new ZajęcieKomornicze(pracownik); // ctor PUBLICZNY
Kadry.ZajKomornicze.AddRow(zajecie);
zajecie.Rodzaj = RodzajeZajęciaWynagrodzenia.KwotaMiesięczna;
zajecie.Element = elementZajecia; // element płacowy potrącenia (wymagany)
zajecie.NumerSprawy = "KM 123/2026";
zajecie.Data = new Date(2026, 1, 1);
});
SaveDispose();
var zaj = Get<Prac>(guid).ZajęciaKomornicze.Cast<ZajęcieKomornicze>().Single();
zaj.NumerSprawy.Should().Be("KM 123/2026");
zaj.Rodzaj.Should().Be(RodzajeZajęciaWynagrodzenia.KwotaMiesięczna);
zaj.Element.Should().NotBeNull("Element (definicja potrącenia zajęcia) jest wymagany");
// Skutki finansowe (Splacono/Pozostało) wyliczają się przy naliczeniu wypłaty — po samym dodaniu
// pozostają niewyliczone (puste). Nie asercjonujemy na nie tu (zakres: utworzenie/parametryzacja).
zaj.Anulowane.Should().BeFalse("nowo dodane zajęcie nie jest anulowane");
zaj.SplataZakonczona.Should().BeFalse("nowo dodane zajęcie nie jest spłacone");
}
[Test]
[Description("C4 (anulowanie): zajęcie anuluje się WORKEREM ZajęcieKomornicze.AnulujWorker (nie ręcznym " +
"ustawianiem flagi Anulowane) — worker dba o storna i spójność rozliczenia. Tu weryfikujemy " +
"tylko publiczny model anulowania (utworzenie + uruchomienie workera).")]
public void C4_ZajecieKomornicze_AnulujWorker()
{
var elementZajecia = PierwszaDefinicjaZajecia();
if (elementZajecia == null)
Assert.Ignore("Baza Demo nie zawiera definicji elementu o RodzajZrodla == ZajęcieKomornicze.");
Guid guid = Guid.Empty;
InTransaction(() =>
{
var pracownik = NowyPracownikEtatowy("C4a", out guid);
var zajecie = new ZajęcieKomornicze(pracownik);
Kadry.ZajKomornicze.AddRow(zajecie);
zajecie.Element = elementZajecia;
zajecie.NumerSprawy = "KM 999/2026";
zajecie.Data = new Date(2026, 1, 1);
});
SaveDispose();
bool anulowano = false;
InUITransaction(() =>
{
var zaj = Get<Prac>(guid).ZajęciaKomornicze.Cast<ZajęcieKomornicze>().Single();
// Worker przez parameterless ctor + property setter (Zajęcie), nie przez ctor parametryczny.
var worker = new ZajęcieKomornicze.AnulujWorker { Zajęcie = zaj };
worker.Anuluj();
anulowano = true;
});
if (!anulowano)
Assert.Ignore("AnulujWorker nie wykonał się w headless host (zależność od kontekstu UI).");
SaveDispose();
Get<Prac>(guid).ZajęciaKomornicze.Cast<ZajęcieKomornicze>().Single()
.Anulowane.Should().BeTrue("worker AnulujWorker oznacza zajęcie jako anulowane");
}
// ============================== C5 — Operacje seryjne na dodatkach (moduł Przeszeregowania) ==============================
[Test]
[Description("C5: seryjne nadanie dodatku grupie realizuje moduł Soneta.Przeszeregowania — worker " +
"NowyDodatekWorker (Params(context) { Definicja, Podstawa, Procent } + Pracownicy[]). " +
"Worker przyjmuje TABLICĘ pracowników, więc nadaje się do operacji grupowej. " +
"Tu weryfikujemy utworzenie/parametryzację — efekt to nowy Dodatek u pracownika.")]
[Ignore("NowyDodatekWorker (moduł Przeszeregowania) rzuca NullReferenceException w headless host " +
"testowym (Przeszeregowania/NowyDodatek.cs:94) — operacja seryjna zależy od stanu operatora/" +
"kontekstu UI nieobecnego w bazie Demo. Test dokumentuje publiczny model workera seryjnego.")]
public void C5_OperacjaSeryjna_NowyDodatekWorker_GrupaPracownikow()
{
// Definicja dodatku (RodzajZrodla == Dodatek) — np. Premia z Demo.
var def = Place.DefElementow.WgNazwy["Premia"] as DefinicjaElementu;
if (def == null)
Assert.Ignore("Baza Demo nie zawiera definicji dodatku \"Premia\".");
Guid g1 = Guid.Empty, g2 = Guid.Empty;
InTransaction(() =>
{
NowyPracownikEtatowy("C5a", out g1);
NowyPracownikEtatowy("C5b", out g2);
});
SaveDispose();
bool wykonano = false;
InUITransaction(() =>
{
var grupa = new[] { Get<Prac>(g1), Get<Prac>(g2) };
var context = Login.CreateEmptyContext().Clone(Session);
var par = new NowyDodatekWorker.Params(context)
{
Definicja = def,
Podstawa = (Currency)300m,
};
var worker = new NowyDodatekWorker { Pars = par, Pracownicy = grupa };
worker.NowyDodatek();
wykonano = true;
});
if (!wykonano)
Assert.Ignore("NowyDodatekWorker (moduł Przeszeregowania) nie wykonał się w headless host.");
SaveDispose();
// Po wykonaniu operacji seryjnej każdy pracownik z grupy ma nowy dodatek z tej definicji.
// Materializujemy do listy i sprawdzamy LINQ Any (poza drzewem wyrażeń — można użyć ?. i funkcji).
static bool MaPremie(Dodatek d) => d.Last?.Element?.Nazwa == "Premia";
Get<Prac>(g1).Dodatki.Cast<Dodatek>().Any(MaPremie).Should().BeTrue(
"operacja seryjna nadała dodatek pierwszemu pracownikowi");
Get<Prac>(g2).Dodatki.Cast<Dodatek>().Any(MaPremie).Should().BeTrue(
"operacja seryjna nadała dodatek drugiemu pracownikowi");
}
[Test]
[Description("C5 (dokument Przeszeregowanie): dokument zbiorczy Soneta.Przeszeregowania.Przeszeregowanie " +
"ma publiczny ctor + AddRow (kolekcja nie ma AddNew). Jest PLANEM — NIE zmienia danych dopóki " +
"nie zostanie wykonany (WykonajWorker). Tu weryfikujemy utworzenie i parametryzację nagłówka " +
"(Data, Nazwa). Kolekcja Pracownicy jest zarządzana przez przepływ workera, nie prostym Add.")]
public void C5_DokumentPrzeszeregowania_JestPlanemDoWykonania()
{
Guid guid = Guid.Empty;
InTransaction(() =>
{
// Dokument tworzymy przez new + AddRow (kolekcja nie ma AddNew — to standardowy GuidedRow root).
var doc = new Przeszeregowanie();
Session.GetPrzeszeregowania().Przeszeregowania.AddRow(doc);
doc.Data = new Date(2026, 4, 1);
doc.Nazwa = "Przeszeregowanie testowe";
// Dokument to PLAN — pozycje (Elementy) i materializacja danych następują dopiero przy WykonajWorker.
doc.Nazwa.Should().Be("Przeszeregowanie testowe");
doc.Data.Should().Be(new Date(2026, 4, 1));
});
// Bez Save — to wyłącznie weryfikacja utworzenia/parametryzacji planu (rollback po teście).
}
// ============================== C6 — Świadczenia socjalne (ZFŚS) ==============================
[Test]
[Description("C6: świadczenie socjalne to Soneta.Kadry.SwiadczSocjalne (ctor publiczny new SwiadczSocjalne" +
"(pracownik) + Kadry.SwiadczeniaSoc.AddRow). Definicję pobieramy ze słownika DefSwiadczSocjal " +
"(klucz WgNazwy); dane rozliczeniowe (Element, Kwota, Okres) ustawiamy na subrowie Rozliczenie. " +
"Faktyczne rozliczenie (Rozliczone == true) następuje przy naliczeniu wypłaty.")]
public void C6_SwiadczenieSocjalne_TworzoneZRozliczeniem()
{
var defSwiadcz = Kadry.DefSwiadczSocjal.Cast<DefinicjaŚwiadczeniaSocjalnego>().FirstOrDefault();
if (defSwiadcz == null)
Assert.Ignore("Baza Demo nie zawiera definicji świadczenia socjalnego (DefSwiadczSocjal).");
// Element rozliczenia — preferuj domyślny z definicji, w razie braku dowolny element płacowy.
var element = defSwiadcz.Element
?? Place.DefElementow.Cast<DefinicjaElementu>().FirstOrDefault();
if (element == null)
Assert.Ignore("Brak elementu płacowego do rozliczenia świadczenia socjalnego.");
Guid guid = Guid.Empty;
var okres = FromTo.Month(2026, 6);
InTransaction(() =>
{
var pracownik = NowyPracownikEtatowy("C6", out guid);
var sw = new SwiadczSocjalne(pracownik); // ctor PUBLICZNY
Kadry.SwiadczeniaSoc.AddRow(sw);
sw.Definicja = defSwiadcz;
sw.Data = new Date(2026, 6, 1);
// Dane rozliczeniowe — na SUBROWIE Rozliczenie (nadpisują domyślne z definicji).
sw.Rozliczenie.Element = element;
sw.Rozliczenie.Kwota = (Currency)1000m;
sw.Rozliczenie.Okres = okres;
});
SaveDispose();
var s = Get<Prac>(guid).Swiadczenia.Cast<SwiadczSocjalne>().Single();
s.Definicja.Should().NotBeNull("świadczenie wiąże definicję ze słownika DefSwiadczSocjal");
s.Rozliczenie.Kwota.Should().Be((Currency)1000m, "kwota świadczenia z subrowa Rozliczenie");
s.Rozliczenie.Element.Should().NotBeNull("element płacowy rozliczenia");
s.Rozliczenie.Rozliczone.Should().BeFalse("rozliczenie następuje dopiero przy naliczeniu wypłaty");
}
// ============================== C7 — Pożyczki (KZP / ZFM) ==============================
[Test]
[Description("C7: ścieżka trzystopniowa FundPozyczkowy(pracownik, definicja) → Pozyczka(fundusz) → " +
"harmonogram rat. Pożyczki NIE da się utworzyć bez funduszu (ctor wymaga FundPozyczkowy). " +
"Definicję funduszu pobieramy ze słownika DefFundPozycz (WgNazwy). Element (wypłata) i " +
"ElementRaty (potrącenie raty) to RÓŻNE definicje. Harmonogram generuje worker UzgodnijRatyWorker.")]
public void C7_Pozyczka_FunduszPozyczkaHarmonogram()
{
var defFunduszu = Kadry.DefFundPozycz.Cast<DefinicjaFunduszuPozyczkowego>().FirstOrDefault();
if (defFunduszu == null)
Assert.Ignore("Baza Demo nie zawiera definicji funduszu pożyczkowego (DefFundPozycz).");
// Element wypłaty i element raty — dwie różne definicje płacowe (dowolne dostępne).
var elementy = Place.DefElementow.Cast<DefinicjaElementu>().Take(2).ToList();
if (elementy.Count < 2)
Assert.Ignore("Baza Demo nie zawiera co najmniej dwóch definicji elementów (wypłata + rata).");
var elWyplata = elementy[0];
var elRata = elementy[1];
Guid guidPrac = Guid.Empty, guidFundusz = Guid.Empty, guidPozyczka = Guid.Empty;
InTransaction(() =>
{
var pracownik = NowyPracownikEtatowy("C7", out guidPrac);
// 1) Członkostwo w funduszu — ctor wymaga (pracownik, definicja).
var fundusz = new FundPozyczkowy(pracownik, defFunduszu);
Kadry.FundPozyczkowe.AddRow(fundusz);
fundusz.Okres = new FromTo(new Date(2026, 1, 1), Date.MaxValue);
guidFundusz = fundusz.Guid;
// 2) Pożyczka w ramach funduszu — ctor wymaga FundPozyczkowy.
var pozyczka = new Pozyczka(fundusz);
Kadry.Pozyczki.AddRow(pozyczka);
pozyczka.Data = new Date(2026, 1, 10);
pozyczka.Kwota = (Currency)12000m;
pozyczka.Element = elWyplata; // element WYPŁATY pożyczki
pozyczka.ElementRaty = elRata; // element POTRĄCENIA raty (inny niż wypłata)
pozyczka.IloscRat = 12;
pozyczka.SplatyOd = new YearMonth(2026, 2);
guidPozyczka = pozyczka.Guid;
});
SaveDispose();
var pozyczka2 = Get<Pozyczka>(guidPozyczka);
pozyczka2.Should().NotBeNull("pożyczka utrwalona w tabeli Pozyczki");
pozyczka2.Fundusz.Should().NotBeNull("pożyczka należy do funduszu (ctor wymaga FundPozyczkowy)");
pozyczka2.Kwota.Should().Be((Currency)12000m);
pozyczka2.IloscRat.Should().Be(12);
pozyczka2.Element.Should().NotBeNull("element wypłaty pożyczki");
pozyczka2.ElementRaty.Should().NotBeNull("element potrącenia raty");
// Fundusz widoczny przez child pracownika.
Get<Prac>(guidPrac).FunduszePozyczkowe.Cast<FundPozyczkowy>()
.Should().ContainSingle("pracownik jest członkiem jednego funduszu");
}
[Test]
[Description("C7 (harmonogram): harmonogram rat generuje worker Pozyczka.UzgodnijRatyWorker " +
"(Params(context){ UzgodnijRaty, PrzeliczRaty }, property Pożyczka) albo metoda " +
"pozyczka.UpdatePozyczka() — NIE ręczne dodawanie RataPozyczki. Worker rozkłada kapitał/odsetki. " +
"Faktyczne potrącenia rat (Stan/Splacono) aktualizują się dopiero przy naliczeniu wypłaty.")]
public void C7_Pozyczka_HarmonogramRatPrzezWorker()
{
var defFunduszu = Kadry.DefFundPozycz.Cast<DefinicjaFunduszuPozyczkowego>().FirstOrDefault();
if (defFunduszu == null)
Assert.Ignore("Baza Demo nie zawiera definicji funduszu pożyczkowego (DefFundPozycz).");
var elementy = Place.DefElementow.Cast<DefinicjaElementu>().Take(2).ToList();
if (elementy.Count < 2)
Assert.Ignore("Baza Demo nie zawiera co najmniej dwóch definicji elementów.");
Guid guidPozyczka = Guid.Empty;
InTransaction(() =>
{
var pracownik = NowyPracownikEtatowy("C7h", out _);
var fundusz = new FundPozyczkowy(pracownik, defFunduszu);
Kadry.FundPozyczkowe.AddRow(fundusz);
fundusz.Okres = new FromTo(new Date(2026, 1, 1), Date.MaxValue);
var pozyczka = new Pozyczka(fundusz);
Kadry.Pozyczki.AddRow(pozyczka);
pozyczka.Data = new Date(2026, 1, 10);
pozyczka.Kwota = (Currency)12000m;
pozyczka.Element = elementy[0];
pozyczka.ElementRaty = elementy[1];
pozyczka.IloscRat = 12;
pozyczka.SplatyOd = new YearMonth(2026, 2);
guidPozyczka = pozyczka.Guid;
});
SaveDispose();
bool uzgodniono = false;
InUITransaction(() =>
{
var pozyczka = Get<Pozyczka>(guidPozyczka);
var context = Login.CreateEmptyContext().Clone(Session);
// PrzeliczRaty jest tylko-do-odczytu (ustawiane wewnętrznie) — parametryzujemy tylko UzgodnijRaty.
var par = new Pozyczka.UzgodnijRatyWorker.Params(context)
{
UzgodnijRaty = true,
};
var worker = new Pozyczka.UzgodnijRatyWorker { Pars = par, Pożyczka = pozyczka };
worker.UzgodnijRaty();
uzgodniono = true;
});
if (!uzgodniono)
Assert.Ignore("UzgodnijRatyWorker nie wykonał się w headless host (zależność od kontekstu UI).");
SaveDispose();
// Po uzgodnieniu harmonogram rat istnieje (worker rozłożył kapitał/odsetki wg IloscRat/SplatyOd).
var raty = Get<Pozyczka>(guidPozyczka).Raty.Cast<RataPozyczki>().ToList();
raty.Should().NotBeEmpty("UzgodnijRatyWorker buduje harmonogram rat");
raty.Should().OnlyContain(r => r.Stan == StanSpłat.NieSpłacona,
"świeżo wygenerowane raty są niespłacone — spłata nalicza się przy wypłacie");
}
}