Fakturace#
Modul Invoicer vystavuje daňové doklady — ručně z administrace, z objednávky,
automaticky z platby a podle rozvrhu. Kód: app/UI/Api/Invoicer/, administrace
app/UI/Admin/Invoicer/.
Z čeho se modul skládá#
| Skupina | Entity | K čemu |
|---|---|---|
| doklad | Invoice + řádky InvoiceItem, InvoiceShipper, InvoicePaymentMethod, InvoiceVoucher, InvoiceGift |
samotná faktura; každý druh řádku má vlastní tabulku, ale stejnou sadu peněžních polí |
| šablona | InvoiceTemplate + zrcadlová sada InvoiceTemplate* |
předloha pro opakované doklady |
| strany | Seller, SellerContact, SellerBankAccount, Customer, CustomerContact |
prodejce a odběratel |
| číselníky | InvoiceType, BillingLine, PaymentMethod, ProductType |
typ dokladu, číselná řada, způsob platby, druh plnění |
| automatika | Schedule, PaymentStatusRule |
rozvrh opakování, pravidlo „stav platby → typ dokladu" |
K tomu tři generátory a jeden počítadlo-free helper:
| Třída | Co dělá | Kde se volá |
|---|---|---|
InvoiceCalculator |
přepočte všechna peněžní pole | InvoiceManager::beforeSave(), před flush |
InvoiceNumberGenerator |
přidělí order, periodKey a number |
tamtéž, jen při vložení |
InvoiceGenerator |
postaví celý doklad z platby (nebo dobropis ze zdroje) | cron cron/invoicer/payment/generate |
InvoicePdfGenerator |
vyrobí PDF a QR platbu | InvoiceManager::completedSave(), po commitu |
CreditNoteLedger |
dopočítá, kolik z faktury už je dobropisováno | kontrola stropu dílčích dobropisů |
Doklad je otisk, ne odkaz#
Faktura si při vystavení opíše adresu prodejce i odběratele, bankovní spojení, formát čísel i sazby. Není to výpadek normalizace — daňový doklad musí navždy vypadat tak, jak byl vystaven.
Změna karty prodejce nezmění už vystavené doklady — a naopak
Oprava jen na jednom místě je nejčastější chyba v tomhle modulu. Když opravíte IČO na kartě prodejce, staré faktury dál nesou to původní; když opravíte kartu faktury, nová faktura zase vyjde se starým údajem. Ptejte se pokaždé, jestli opravujete budoucí doklady, nebo jeden konkrétní.
Zámek vystaveného dokladu#
Doklad, který zákazník viděl, se zamyká, a to nevratně. Stav nesou sloupce locked_datetime
a lock_reason; oba jsou ve defaultSkipFields, takže je běžné uložení objektového grafu
nepřepíše. Zamyká se třemi cestami:
| Kdy | Kudy | lock_reason |
|---|---|---|
zápis emailIsSent = true |
InvoiceManager::updateColumns(), zámek jde do stejného UPDATE jako příznak |
email (Invoice::LOCK_REASON_EMAIL) |
| uložení dobropisu nebo storna | afterSave() dobropisu zamkne zdroj (relatedInvoiceId) v téže transakci; nepovedený zámek vrátí i dobropis |
creditNote |
| ruční akce v administraci | InvoiceManager::lock(), Api invoicer/invoice/lock |
manual |
Zamyká se podmíněným UPDATE … WHERE locked_datetime IS NULL: první zámek vyhrává a už
zamčený doklad si nechá původní čas i důvod.
Plné uložení zamčeného dokladu odmítne InvoiceManager::beforeSave() kódem INVOICE_LOCKED
(errorCode invoiceLocked) na každé cestě: administrace, Api i generátory. Stav čte
z databáze (lockedAt(), raw DBAL), ne z entity, protože lockedDatetime v payloadu chybí.
Co uživatel na zamčeném dokladu měnit smí (stav úhrady, poznámka, údaje pro e-mail), zapisuje
InvoiceFormTrait::saveLockedInvoice() přímo přes updateColumns(), který hooky nespouští:
nic se nepřepočítá a PDF zůstane původní. Co vidí uživatel, popisuje
Faktury v administraci.
Postup: měním už vystavený doklad z kódu
- Zjisti stav přes
InvoiceManager::lockedAt($id). Čte přímo z databáze (raw DBAL) bez hydratace, takže ho jde volat i uprostřed hooků. - Na zamčeném dokladu zapisuj jen sloupce mimo částky, řádky a odběratele, a to
přes
updateColumns(). - Cokoli dalšího oprav dobropisem nebo stornem.
Příznak odeslání zapisuj jen přes InvoiceManager::updateColumns()
Jen tahle metoda k emailIsSent = true přidá zámek. Když příznak nastaví plné uložení
nebo SQL mimo manager, doklad bude „odeslaný“, ale odemčený, a půjde dál měnit, přestože ho
zákazník už má.
Doklad s číslem se nemaže
beforeDelete() ho odmítne kódem INVOICE_NUMBERED_DELETE_BLOCKED. Řada přiděluje
MAX(order) + 1, takže smazaný poslední doklad by své číslo předal dalšímu (dvě různé
faktury pod jedním číslem u zákazníka a v účetnictví) a jakýkoli jiný by v řadě nechal díru.
Smazat jde jen doklad bez čísla, třeba nedokončený import. Hromadné deleteAll() hooky
nespouští, proto ho na doklady nevolej.
Jméno kontaktu odběratele#
Kontakt odběratele (CustomerContact) má strukturovanou trojici companyName, firstname,
lastname a vedle ní zobrazovaná pole name a name2, která se opisují na doklady.
Dopočítává je jediné místo, CustomerManager::beforeSave(), a jen do prázdných polí:
| Vyplněná trojice | name |
name2 |
|---|---|---|
| firma i osoba | název firmy | jméno a příjmení |
| jen firma | název firmy | beze změny |
| jen osoba | jméno a příjmení | beze změny |
| nic (starý kontakt) | beze změny | beze změny |
Hook pokrývá všechny zápisy, protože kontakty se ukládají výhradně jako vlastněná kolekce
odběratele přes CustomerManager (administrace i OrderCustomerManager e-shopu);
CustomerContactManager se používá jen ke čtení.
Změna názvu firmy jméno kontaktu nepřepíše
Jednou vyplněné name a name2 hook nikdy nepřepíše ani nevynuluje, aby se neztratila
ruční úprava. Když se v kontaktu změní companyName nebo příjmení, nové doklady dál
ponesou staré jméno. Má-li se přepočítat, vyprázdni name / name2 ve stejném uložení.
Postup: vystavuji doklad z kódu#
- Sestav
Invoice— strany, měnu, datum vystavení a řádky. - Peněžní pole neplň. Cokoli tam dáte,
InvoiceCalculatorvbeforeSave()přepíše. Vstupy jsou jen cena, množství, sazba DPH, sleva a přepínačeuseVat/useDiscount. - Číslo nastavuj jen při importu. Běžně nechte pole
numberprázdné a naplňtebillingLineId— číslo přidělí řada. - Ulož přes
InvoiceManager::save(). Přepočet, číslování i PDF se navěsí samy. - Ověř
isSuccess()— neúspěch nese konkrétní kód (chybějící řada, duplicitní číslo, porušené XOR).
Ruční číslo a řada se vylučují (XOR)
Poslat obojí naráz skončí chybou VALIDATION_FAILED. Doklad s ručním číslem nese
order = 0 a prázdný periodKey; druhý takový ve stejné řadě by porušil unikátní
index uq_invoice_sequence. Ruční číslo je vyhrazené pro import a migraci,
admin formulář ho nezadává vůbec.
Doklad z cizího modulu#
Modul, který není platba ani objednávka (například Moje firma při fakturaci
pracovních listů), si doklad nechá složit InvoiceComposer::compose(). Vstupem je
InvoiceComposerRequest, výstupem Result s neuloženou fakturou v datech.
| Věc | Jak to je |
|---|---|
| zápis | žádný: composer nepersistuje, nebere zámek ani číslo; číslo, přepočet a PDF vzniknou až v InvoiceManager::save() |
| idempotence | žádná: dvě volání jsou dvě faktury a jejich uložení spálí dvě čísla; duplicitu hlídá volající ve své doméně pod vlastním zámkem |
| prodejce | parametrem sellerId; kontakt a účet volitelně, jinak 7/invoice.default.*, pak první v kolekci prodejce (cizí id kontaktu nikdy nepřimíchá adresu jiného prodejce) |
| odběratel | InvoiceComposerCustomer::fromDirectory($customerId, $contactId) z kartotéky, nebo fromSnapshot($pole); pole se na doklad kopírují vždy, customerId je jen odkaz |
| řádky | InvoiceComposerLine::create(...) jen s primárními vstupy: název, množství, jednotková cena bez DPH, sazba, sleva, druh plnění |
| DPH | plátcovství z SellerManager::resolveUseVatOn() k DUZP; sazby, kód zacházení a doložku zapíše InvoiceTaxManager, má-li některý řádek druh plnění nebo je zadán taxOverride |
| neumí | dopravu, platební metodu, vouchery, dárky, dobropisy ani localizationId (částky formátuje podle měny) |
DTO mají privátní konstruktor a staví se jen továrnami (create(), fromDirectory(),
fromSnapshot()). Neinstanciovatelná třída z DI search vypadne sama, takže složka Dto/
nepotřebuje výjimku v configu.
Chyby jsou trvalé (vada vstupu nebo nastavení, opakování nepomůže). Kód je v datech
výsledku pod klíčem errorCode:
errorCode |
Kdy |
|---|---|
sellerMissing |
sellerId ≤ 0 nebo prodejce v adresáři není |
currencyMissing |
currencyId ≤ 0 |
invoiceTypeMissing |
typ dokladu chybí nebo neexistuje |
numberSourceMissing |
není řada (požadavek, typ dokladu, 7/invoice.default.billingLineId) ani ruční číslo |
numberSourceConflict |
ruční číslo a zároveň kladné billingLineId (0 se bere jako „bez řady“) |
noLines |
prázdný seznam řádků |
invalidLine |
řádek bez názvu; množství nečíselné, ≤ 0 nebo nad 999999.99; cena nečíselná nebo mimo ±9999999.999; sazba nebo sleva mimo 0–100 % |
customerMissing |
fromDirectory() na zákazníka, který v kartotéce není (i customerId ≤ 0) |
paymentRegionMissing |
region nedá požadavek, 7/invoice.default.paymentRegionId ani výchozí region systému |
sellerVatModeMissing |
prodejce nemá daňový status k DUZP (historie začíná až později) |
Postup: vystavuji doklad z cizího modulu
- Pod vlastním zámkem ověř, že doklad pro tenhle záznam ještě nevznikl.
- Sestav řádky přes
InvoiceComposerLine::create()s pojmenovanými argumenty; cena je jednotková a bez DPH, kalkulátor ji násobí množstvím. - Odběratele předej přes
fromDirectory(), nebo přesfromSnapshot(), když v kartotéce není nebo má doklad nést údaje platné k jinému okamžiku. - Zavolej
compose(). Při neúspěchu vraťerrorCodea nic neukládej. - Fakturu ulož přes
InvoiceManager::save()a ověřisSuccess(). - Id faktury si zapiš na své straně.
Invoice.orderIdk tomu nepoužívej, už teď míchá id objednávek e-shopu a starých plateb.
Neznámý klíč snapshotu odběratele se tiše zahodí
fromSnapshot() bere pole bez prefixu customer (name, street, countryCode, vatId,
userTypeId, …; dodací dlv* jen s useDelivery: true). Překlep v klíči nespadne, údaj
jen na dokladu chybí. Zemi odběratele čte daňový engine z countryCode (ISO2), ne z názvu
v country. Bez něj engine zemi odběratele nezná a zacházení s DPH určí bez ní.
useVat a vatPercents v požadavku jsou nouzové vstupy
Ruční zacházení s DPH patří do taxOverride (InvoiceTaxOverride::create(): kód, sazba,
povinný důvod). Pro neplatný vstup vrací null; volající to musí vrátit jako chybu
validace, jinak doklad odejde v jiném režimu, než o jaký se žádalo. useVat se uplatní
až po enginu, ale jen bez taxOverride; s ručním přepisem se ignoruje a hlavičkové
use_vat určuje přepis (InvoiceComposer::resolveUseVatOverride()).
Číslování#
Řada (BillingLine) nese masku, periodu resetu a počet číslic. Generátor
z ní odvodí:
| Pole | Jak vznikne |
|---|---|
periodKey |
klíč období podle billing_period řady (rok, měsíc, …) |
order |
MAX(order) + 1 v rámci dvojice (řada, období) |
number |
maska s náhradami %Y, %m, %d (z data vystavení) a %number (doplněné nulami na number_length) |
variableSymbol |
jen číslice z čísla, pokud nebyl zadaný ručně |
Kritická sekce je serializovaná SELECT … FOR UPDATE na řádku řady a drží se do
commitu; pojistkou na úrovni schématu je UNIQUE(billing_line_id, order, period_key).
Maska bez %number shodí druhou fakturu v období
První doklad se vystaví, druhý složí totéž číslo. Generátor zkusí zvednout
pořadí, ale bez %number se výsledek nezmění — smyčku ukončí a backstop v manageru
vrátí DB_DUPLICATE_ENTRY. Vypadá to jako chyba ukládání, ale je to chybně
nastavená řada.
Datum vystavení řídí číslo, takže zpětné datování vyrobí jinou řadu čísel
%Y/%m/%d se berou z data vystavení a podle něj se počítá i klíč období.
Změna data u nevystaveného dokladu tedy mění, do které sekvence spadne — a to není
vidět, dokud se doklad neuloží.
V sekvenci vznikají díry a je to správně
Ručně importovaná faktura má číslo, ale nezvedá MAX(order) v řadě (nemá řadu).
Generátor kolizi obejde posunem pořadí — obsazený slot zůstane jako mezera. Kdo tu
díru „opraví“, vyrobí duplicitu.
Novou vazbu na řadu bez cizího klíče přidej do kontroly mazání
BillingLineManager::beforeDelete() odmítne smazat řadu, na kterou odkazují doklady,
šablony, typy dokladu nebo firmy (invoicer_billing_line_id). Typy dokladu a firmy nemají
cizí klíč, proto se kontroluje raw DBAL podle seznamu tabulek v té metodě. Tabulka, která na
řadu odkazuje a v seznamu chybí, nechá řadu smazat a vystavení dokladu pak selže na
„Billing line not found“.
Přepočet částek#
Server je autorita: invoice-calc.js tutéž matematiku jen zrcadlí kvůli UX. Všechny
tři druhy řádků (položka, doprava, platební metoda) sdílejí identickou sadu polí.
per řádek
vatPercent = useVat ? (vat ?? 0) : 0
discountPercent = useDiscount ? (discount ?? 0) : 0
priceAfterDiscount = price − price · discountPercent/100
totalPrice = priceAfterDiscount · quantity
totalPriceWithVat = priceAfterDiscount · (1 + vatPercent/100) · quantity
vatInMoney = totalPriceWithVat − totalPrice
hlavička
base = Σ řádek.totalPrice (položky + doprava + platby)
baseVat = Σ řádek.totalPriceWithVat
totalPrice = base − Σ sleva voucheru
totalPriceWithVat = baseVat − Σ sleva voucheru s DPH
Sazba DPH je řetězec, ne číslo
Sazba nemusí být celé číslo (5,5 %, 2,1 %, 13,5 %), proto se v PHP nese jako
numeric-string ('5.50'), v databázi je DECIMAL(5,2) a počítá se přes bcmath.
Parsování vstupu (čárka i tečka, 0–100), klíč skupiny a zobrazení drží jediná třída
App\Core\Utils\Helpers\VatRate: normalize() → '5.50', key() → '5.5'
(identita skupiny — sazby 5 a 5,5 se nesmí slít; nikdy $byRate[$float], float klíč
PHP ořízne na int), format() / formatFor() → „5,5“ / „5.5“ podle locale. V Latte je
filtr {$rate|vatRate}. Formuláře berou sazbu přes Form::addVatRate(). Rekapitulace
DPH na PDF drží u každé skupiny rateKey (kanonický klíč) a rate (text pro šablonu).
Množství položky je taky řetězec
InvoiceItem a InvoiceTemplateItem mají quantity jako DECIMAL(8,2) (hodiny, metry,
kilogramy) přes DecimalQuantityTrait. Getter vrací numeric-string v pevném tvaru
'2.50', setter bere int|float|string, čárku i tisícové mezery (Decimal::clean()),
zaokrouhlí half-up na 2 místa a nečíselný vstup převede na '0.00'. Kdo s množstvím
počítá, castuje sám ((float) $line->getQuantity()): abs() nebo intdiv() nad
řetězcem spadnou pod strict_types=1 na TypeError. Rozsah (max 999999.99) setter
neořezává, hlídá ho InvoiceComposer. Doprava a platební metoda mají množství dál
celočíselné. Pro zobrazení je v Latte filtr {$qty|quantity}: celé číslo bez desetin
(„5“ z '5.000'), jinak bez koncových nul („1,5“, „0,25“) s oddělovačem podle lokalizace
(lokalizace dokladu má přednost před požadavkem, jako u vatRate); |quantity:true dá
strojový tvar s tečkou pro value a data atributy. V PHP totéž Quantity::format();
formuláře a PDF fakturace jdou přes obal dokladu QuantityFormat::quantity()
(InvoicePdfGenerator::formatQuantity()), který nechá chybějící řádek jako null.
Formulář dokladu je přísnější než setter množství
Pole množství v administraci (faktura i šablona) hlídá maska QUANTITY_PATTERN:
nepovinné mínus, 1–6 celých míst, 0–2 desetinná za čárkou nebo tečkou. „1 000“ s tisícovou
mezerou neprojde a admin dostane hlášku u pole. Bez masky by takový vstup tiše skončil jako
jiné množství, než jaké admin napsal. Mínus maska bere kvůli záporným množstvím ze starých
dokladů a importů, jinak by takový doklad nešel znovu uložit. Dobropisy záporné množství
nevyrábějí: zrcadlí kladné quantity a obracejí znaménko ceny (mirrorLine()).
Hlavičkové useVat dominuje nad sazbami řádků
Když je vypnuté, sazba každého řádku se počítá jako nula — v PDF nebude rozpis DPH, i když hodnoty v datech jsou. Neplátce DPH tak dostane správný doklad, ale kdo přepínač přehlédne, vidí „chybí DPH“ a hledá to v šabloně.
Klientským číslům se nevěří
item.totalPrice i header.totalPrice poslané formulářem se neukládají —
přepíše je dopočet. Nemá smysl je v Api volání posílat a nemá smysl ladit, proč
„se neuložila“ hodnota, kterou počítá server.
Zaokrouhlení je zatím vypnuté
roundedTotal* = total* a rounding* = 0. Pole existují a PDF šablona je čte,
ale celokorunové zaokrouhlení se zatím nedělá.
Dobropis#
Dobropis není příznak na faktuře. Je to typ dokladu s příznakem
isCreditNote(); výsledný doklad je zrcadlo zdroje se zápornými částkami a odkazem
na něj (relatedInvoiceId).
Strop dílčích dobropisů hlídá CreditNoteLedger a drží jedinou invariantu:
pro každý řádek zdrojové faktury platí Σ dobropisovaného množství ≤ fakturovaného množství
| Věc | Jak se řeší |
|---|---|
| zůstatek | dopočítává se z existujících dobropisů, nedrží se jako čítač |
| klíč | id řádku zdrojové faktury, nikdy order_item_id (ten není unikátní) |
| voucher | neklíčuje se kusy, ale částkou — pevná sleva se rozpouští po částech |
| souběh | ledger sám zámek nemá; kontrola i zápis musí proběhnout uvnitř zámku zdroje |
Ruční dobropis k vystavenému dokladu z administrace (InvoiceCreditNoteFormTrait) si načte
podklad přes api/invoicer/invoice/credit-note-preview a vystaví ho přes credit-note
(InvoiceCreditNoteManager). Strop kontroluje až issue() pod zámkem dokladu
cms_invoicer_invoice_{id}; max u množství ve formuláři je jen pohodlí. Pevná sleva se
ve formuláři nevybírá, u dílčího dobropisu se rozpustí poměrně sama.
Čítač zůstatku by se rozešel při prvním smazaném dokladu
Proto se dopočítává. Kdo to „zoptimalizuje“ na uložené číslo, dostane doklad, který jde dobropisovat víc než jednou.
Dobropisový řádek bez vazby na zdroj se nesmí ignorovat
Starý doklad, který se nepodařilo spárovat, znamená, že nevíme, který zdrojový řádek spotřeboval. Stropu u takové faktury nelze věřit — volající to musí odmítnout, ne přeskočit.
Jedna platba smí mít víc dokladů
Řádnou fakturu i dobropis. Na Invoice.paymentId proto není unikátní index
a idempotenci drží kontrola existujícího dokladu daného typu plus posun
Payment.invoicedStatus.
Množství v ledgeru je řetězec na 3 desetinná místa
Položky faktury mají quantity jako decimal(10,3) (stejné měřítko jako sklad), doprava
a platba celé kusy (int). CreditNoteLedger vrací invoiced, credited a remaining
jako numerické řetězce ('1.500') a sčítá i porovnává je přes bcmath, nikdy přes float
nebo (int) — ořezání by strop podčítalo, float by u 0,5 + 0,7 nesečetl přesně 1,2.
Vstup volajícího převeď přes CreditNoteLedger::normalizeQuantity() (desetinná čárka
i tečka, zaokrouhlení half-up na 3 místa jako u cen). Desetinné množství u dopravy nebo
platby violations() odmítne. Pevná sleva se u dílčího dobropisu rozpouští poměrem
množství a zaokrouhluje half-up, nikdy nad svůj zůstatek.
PDF a QR platba#
Vznikají v completedSave() — tedy po commitu, mimo save transakci. Sloupce pdf
a qrCode jsou ve defaultSkipFields, takže je běžné uložení objektového grafu
vynechá; jediný jejich zapisovatel je přímý updateColumns() po commitu.
Generátor je best-effort: nikdy nevyhazuje výjimku, dílčí selhání jen zaloguje
a vrátí null.
Variantu PDF šablony vybírá typ dokladu (InvoiceType) jednou ze dvou matic
(InvoicePdfGenerator::resolveVariant()); varianta už uložená na dokladu
(pdfTemplateVariantId) má vždy přednost:
| Doklad | Matice | Klíč |
|---|---|---|
má tax_treatment (kód zacházení s DPH) |
pdfVariantTreatmentRels |
kód zacházení, jinak řádek „Výchozí (*)“ |
| nemá ho (starší doklady) | pdfTemplateVariantRels |
druh plnění × platební region × typ odběratele × režim prodejce (useVat dokladu, 1 plátce / 0 neplátce) |
Obě matice edituje formulář typu dokladu (InvoiceTypeFormTrait) a ukládá je nahrazením
celé kolekce v jednom uložení. Starší matici vykresluje dvakrát, na záložce plátce
a neplátce (PdfVariantMatrixTrait::pdfVariantSellerModes()).
Obě poloviny matice musí být ve formuláři vždy vykreslené
Nevykreslené pole Nette při odeslání vyhodnotí jako prázdné a nahrazení kolekce tu polovinu matice smaže. Kdo záložku podmíní nebo líně načte, přijde při prvním uložení o všechny varianty neplátce (nebo plátce).
Bez PDF varianty se soubor vůbec nevytvoří a nikdo se to nedozví
Když pro kombinaci v matici nic není, faktura se uloží v pořádku, jen bez souboru.
Generátor zapíše jen řádek do logu pdf, administrace ani Api chybu nehlásí. Přijde se
na to, až si někdo řekne o PDF.
Přegenerování nejdřív promaže složku dokladu
Ručně přidané soubory ve složce faktury zmizí. Proto se do pdf/qrCode zapisuje
i null — stará cesta by po neúspěšné regeneraci ukazovala na smazaný soubor.
Šablona PDF: varianta, layout a Latte#
Soubor skládá sdílený App\UI\Api\System\Models\Generators\PdfGenerator (mPDF) ze dvou
částí. Varianta (PdfTemplateVariant) nese význam a název (meta_title z variant_texts),
layout (PdfTemplateLayout) vzhled: css a per jazyk header, html a footer
(PdfTemplateVariantLayoutText). Css dokumentu je css layoutu + volitelné css varianty.
| Krok | Co se vezme |
|---|---|
| layout | 7/invoice.pdf.layoutId, jinak výchozí layout (isDefault) |
| obsah | text varianty pro zvolený layout a jazyk dokladu, jinak první text téhož layoutu; nemá-li ho zvolený layout vůbec, výchozí layout (zapíše se do logu pdf) |
| render | samostatný Latte engine (StringLoader) s PriceLatteExtension a datovými filtry; {#BODY#} v obálce email.latte nahradí obsah |
V hlavičce a patičce se značky mPDF píšou #.PAGENO.# a #.nbpg.#. Latte by {PAGENO}
považovalo za svou značku; generátor po vykreslení #. a .# vrátí na { a }. Výchozí
layout se přepíná jen přepínačem ve sloupci isDefault gridu layoutů
(:Admin:System:PdfTemplateLayout); PdfTemplateLayoutManager::updateColumns() při zapnutí vypne všechny ostatní.
Jediný výchozí layout nejde vypnout ani smazat
Bez výchozího layoutu by resolveContent() vrátilo null i tehdy, když nastavení layout
určuje, a každý doklad by se uložil bez PDF. PdfTemplateLayoutManager proto vypnutí
i smazání posledního výchozího layoutu odmítne kódem PDF_LAYOUT_DEFAULT_REQUIRED a grid
ukáže důvod. Jiný layout sám nevybere, aby se vzhled dokladů nezměnil bez vědomí správce.
Výchozí měň zapnutím jiného layoutu.
Šablona je Latte bez sandboxu
Obsah layoutu a varianty se vykonává jako Latte kód, který smí volat libovolné PHP funkce. Kdo smí PDF šablony upravovat, musí být důvěryhodný jako vývojář; oprávnění k nim nedávej běžné roli.
Filtry z DI rozšíření v šabloně nejsou
Engine je samostatný, ne z LatteFactory. Nový filtr pro PDF zaregistruj v
PdfGenerator::createEngine(), jinak vykreslení spadne a doklad zůstane bez PDF. Částky
formátuj filtry z PriceLatteExtension: jen ta zná lokalizaci a účetní režim dokladu.
Automatika: rozvrhy a platby#
| Úloha | Endpoint | Jak často | Vypínač |
|---|---|---|---|
| rozvrhy | /cron/invoicer/schedule/generate |
denně | — |
| doklady z plateb | /cron/invoicer/payment/generate |
často (např. à 15 min) | 7/invoice.payment.auto |
Obě jsou dávkové (7/schedule.batchSize, 7/payment.batchSize, výchozí 10).
Rozvrh generuje ze šablony nebo klonem faktury a datum dalšího spuštění posune až po úspěšném uložení — proto je opakované spuštění bezpečné. Faktura a posun data jsou v jedné transakci (pád mezi nimi nenechá vystavenou fakturu s neposunutým rozvrhem), e-mail odchází až po commitu.
Pořadí uvnitř transakce (ScheduleManager::issueAtomically()):
- Zabrání období (
claimPeriod()): podmíněnýUPDATErozvrhu, jehožWHEREdržíactive = 1, načtenérenewal_datea všechny sloupce, ze kterých cron fakturu sestavil (perioda, interval, konec, zdroj, šablona, faktura). Řádek tím zůstane zamčený do commitu. Jednorázový rozvrh (periodanevernebo interval ≤ 0) se místo posunu deaktivuje. - Uložení faktury přes
InvoiceManager::save(). Transakci otevřela přímo DBAL spojení (inAtomicTransaction()), takžeTransactionManageruložení otevře jako savepoint pod ní. Rollback vrátí všechno včetně čísla z řady, takže v číslování nevznikne díra ani duplicita.
Nula změněných řádků v kroku 1 znamená, že období mezitím posunul souběžný běh nebo admin
změnil rozvrh. Rozvrh se přeskočí s INVALID_STATE bez odstupu a příští běh ho vezme už
v nové podobě, takže faktura nikdy nevyjde ze staré konfigurace. Ostatní selhání (chybí zdroj
nebo řada, faktura se neuložila, výjimka) dostanou rostoucí odstup
ve frontě invoicer.schedule.generate; doklady z plateb mají frontu invoicer.payment.invoice.
PDF vzniká ještě uvnitř transakce rozvrhu
completedSave() faktury běží uvnitř vnější transakce, protože vnořený commit nic
nezapíše. Selže-li až závěrečný commit, zůstane na disku PDF bez řádku faktury. Nic na něj
neukazuje, takže neškodí; posun rozvrhu jde proto v transakci první.
Doklady z plateb jdou podle pravidel PaymentStatusRule (stav platby → jeden typ
dokladu). Generace je záměrně odpojená od platebního callbacku, aby nezdržovala
odpověď bráně. Chyby se dělí na dva druhy:
| Druh chyby | Co se stane | Proč |
|---|---|---|
| přechodná (konfigurace, selhání uložení) | invoicedStatus se neposune, platba se zkusí příště |
příště to může vyjít |
trvalá (DB_NOT_FOUND — osiřelé pravidlo, chybí zdroj dobropisu) |
platba se odbaví bez dokladu a zapíše se do chyb | retry by nikdy neuspěl a platba by trvale žrala dávku |
Trvalá chyba znamená platbu bez faktury — a systém ji pustí dál
Je to schválně: jedna nevyřešitelná platba by jinak vyhladověla celou dávku a nevystavily by se ani ty ostatní. Důsledek ale je, že přehled chyb se musí kontrolovat — jinak zůstane zaplacená objednávka bez dokladu a nikdo se to nedozví.
Měna s vypnutým vystavováním se odbaví bez dokladu a bez chyby
Kredity a podobné interní měny doklad nedostávají. Platba se označí za odbavenou, aby se nevracela do fronty každý běh — v přehledu chyb proto nebude.
Rozvrh bez řady se tiše přeskočí
Doplněním řady se to samo spraví, ale zmeškaný termín se nevrátí.
Doklad z platby může mít řádek „Haléřové vyrovnání“
Platba je zaokrouhlená na přesnost měny, řádky dokladu jsou třídesetinné základy ceníku
a hlavička se zaokrouhluje na 3 místa. Rozdíl doúčtuje InvoiceGenerator řádkem
Haléřové vyrovnání se sazbou 0, aby total_price_with_vat seděl přesně na zaplacenou
částku. Je-li rozdíl větší než 0.010 (PER_LINE_ROUNDING_LIMIT), rozpad po tarifech se
zahodí a základ se dopočte zpětně ze zaplacené částky (fallbackBase(), případně opět
s vyrovnávacím řádkem). Kdo řádek odstraní, rozejde doklad s platbou o haléř.
Odeslání dokladu e-mailem#
Doručení e-mailu a zápis emailIsSent do faktury nejdou spojit atomicky: e-mail je vnější
efekt mimo transakci. Všechna tři místa, která doklad odesílají (Api InvoicePresenter pro
ruční odeslání z administrace, InvoiceGenerator u dokladů z plateb, ScheduleManager
u rozvrhů), proto jdou přes InvoiceEmailAudit::sendAndRecord(). Audit zapíše jen po
úspěšném odeslání a vrátí jeden ze tří stavů InvoiceEmailState:
| Stav | Co se stalo | Jak to hlásit |
|---|---|---|
NotSent |
odeslání nebo zařazení do fronty selhalo, audit se nezapisoval | chyba, odeslat znovu jde |
Sent |
odesláno i zapsáno | úspěch |
SentNotRecorded |
odesláno, ale zápis auditu selhal; faktura dál ukazuje emailIsSent = false |
ani úspěch, ani chyba: „neodesílat znovu“ |
Ruční odeslání vrací u třetího stavu error: 'sentButNotRecorded' s emailSent: true, rozvrh
ho zapíše do chyb běhu a generátor z plateb jen do logu invoicer.sendEmail.
Postup: přidávám další místo, které doklad odesílá
- Odeslání zabal do closure, která vrací
Result(např.EmailManager::sendEmailFromText()). - Zápis auditu dej do druhé closure (
InvoiceManager::updateColumns($id, ['emailIsSent' => true, …])). - Obě předej
InvoiceEmailAudit::sendAndRecord()spolu s closure, která zapíše do logu. - Stav
SentNotRecordedohlas zvlášť, ne jako úspěch ani jako neodesláno.
Zahozený Result zápisu auditu vede k druhému e-mailu
Když se po odeslání zavolá updateColumns() a jeho výsledek se nekontroluje, selhaný
UPDATE nechá e-mail venku a fakturu ve stavu „neodesláno“. Admin dostane úspěch a doklad
pošle podruhé.
DPH dokladu a status prodejce#
Zda prodejce DPH účtuje, rozhoduje historie daňového statusu prodejce
(cms_mod_invoicer_seller_tax_statuses: status, platí od), ne typ uživatele ani plochý
příznak. Admin ji edituje ve fieldsetu „Daňový status“ formuláře prodejce spolu se zemí
usazení. Dotaz „je plátce k datu“ má jediné místo, SellerManager::resolveUseVatOn():
| Historie | Výsledek |
|---|---|
| status k datu je plátce | true |
| status k datu je identifikovaná osoba nebo neplátce | false |
| prodejce nemá žádný řádek historie | plochý sellers.use_vat |
| historie začíná až po datu | null, volající rozhodne (composer selže, generátory zalogují a vezmou plochý příznak) |
Sazbu, kód zacházení a doložku ručně vystaveného dokladu (formulář, šablona, rozvrh,
InvoiceComposer) určuje InvoiceTaxManager z druhu plnění řádků a údajů odběratele
a zapíše je do řádků a do hlavičky (invoices.tax_*). Doklad z platby kód zacházení
nepočítá, opíše ho z platby. Doklad bez řádku s druhem plnění a bez ručního přepisu se
počítá postaru se sazbami, které poslal volající.
Plochý sellers.use_vat je jen zrcadlo ke dni uložení
Formulář prodejce ho při každém uložení přepíše statusem „plátce k dnešku“. Status
s datem v budoucnu se do něj promítne až dalším uložením. Kdo čte příznak přímo místo
resolveUseVatOn(), vystaví doklad podle stavu ke dni posledního uložení prodejce.
Admin smí zacházení na dokladu ručně přepsat (panel „DPH“ ve formuláři faktury). Přepis
nahradí kód a sazbu všech řádků s druhem plnění, doklad dostane příznak MANUAL_OVERRIDE
a důvod v tax_override_reason. Kód DOMESTIC_REVERSE_CHARGE (tuzemské přenesení daňové
povinnosti) engine nikdy nevrátí, vzniká jen takhle.
Přepis bez důvodu se neuloží
Povinnost visí na poli taxOverrideReason (addConditionOn), ne na formuláři. Chyby
celého formuláře admin šablony nevykreslují, takže pravidlo na formuláři by uložení
odmítlo bez viditelné hlášky.
Výchozí hodnoty dokladu v nastavení#
Výchozí údaje dokladu jsou v nastavení modulu 7 pod klíči invoice.default.* (prodejce,
kontakt, účet, řada, druh plnění, typ uživatele, jazyk, jednotka, splatnost, sazby DPH).
Pro doklady z objednávek je e-shop (modul 4) může přebít klíčem se stejnou příponou:
4/invoicer.<přípona> (invoicer.sellerId, billingLineId, userTypeId,
paymentRegionId). Řetěz skládá OrderInvoiceGenerator (settingIntOrNull(),
settingInt(), klíč modulu 4 odvodí eshopInvoicerKey()):
| Údaj dokladu z objednávky | Pořadí |
|---|---|
| prodejce a jeho kontakt a účet | snapshot prodejce v objednávce → 4/invoicer.* → 7/invoice.default.* |
| řada | řada typu dokladu (InvoiceType::getBillingLineId()) → 4 → 7 |
| platební region | region objednávky → 4 → 7 → výchozí region lokalizace |
| ostatní | 4 → 7 |
U id se hodnota modulu 4, která není kladné číslo (prázdný řetězec, 0), bere jako
chybějící. Číselné nastavení (duePeriod) bere z modulu 4 i nulu.
Postup: přidávám výchozí hodnotu dokladu
- Přidej klíč
invoice.default.<přípona>doTEXT_KEYSvapp/UI/Admin/Invoicer/Presenters/SettingPresenter.php. - V generátoru ho čti přes
settingIntOrNull()nebosettingInt()s celým klíčem modulu 7. - Má-li ho e-shop přebíjet, přidej pole
invoicer.<přípona>do nastavení e-shopu (app/UI/Admin/Eshop/Presenters/SettingPresenter.php).
Klíč mimo invoice.default.* přebití z e-shopu nemá
eshopInvoicerKey() jiný prefix nepřevede, takže pole invoicer.* přidané do nastavení
e-shopu k takovému klíči se nikdy nepřečte.
Uložení kolekcí je nahrazení#
Pole, která formulář nevykresluje, se musí přenést explicitně
Uložení objektového grafu chybějící řádky maže — viz Hydrátory. U faktury to má navíc peněžní důsledek: zmizelé řádky posunou strop dílčích dobropisů, takže se pak dá dobropisovat víc, než bylo fakturováno.
Kam sáhnout#
| Chci | Kde |
|---|---|
| přepočet částek | app/UI/Api/Invoicer/Models/Helpers/InvoiceCalculator.php |
| číslování | app/UI/Api/Invoicer/Models/Generators/InvoiceNumberGenerator.php |
| stavbu dokladu z platby | app/UI/Api/Invoicer/Models/Generators/InvoiceGenerator.php |
| doklad z cizího modulu | app/UI/Api/Invoicer/Models/Generators/InvoiceComposer.php, vstupy v Generators/Dto/ |
| odeslání e-mailem | app/UI/Api/Invoicer/Models/Email/InvoiceEmailAudit.php |
| PDF a QR | app/UI/Api/Invoicer/Models/Generators/InvoicePdfGenerator.php |
| strop dobropisů | app/UI/Api/Invoicer/Models/Managers/CreditNoteLedger.php |
| hooky uložení | app/UI/Api/Invoicer/Models/Managers/InvoiceManager.php |
| cron úlohy | app/UI/Cron/Invoicer/Presenters/ |
| DPH dokladu a status prodejce | app/UI/Api/Invoicer/Models/Managers/InvoiceTaxManager.php, SellerManager::resolveUseVatOn() |
| řetěz výchozích hodnot z e-shopu | app/UI/Api/Eshop/Models/Generators/OrderInvoiceGenerator.php |
| administraci | app/UI/Admin/Invoicer/ |
| zámek a mazání dokladu | InvoiceManager::beforeSave(), updateColumns(), lock(), beforeDelete() |
| jméno kontaktu odběratele | app/UI/Api/Invoicer/Models/Managers/CustomerManager.php |
| atomický běh rozvrhu | app/UI/Api/Invoicer/Models/Managers/ScheduleManager.php (issueAtomically(), claimPeriod()) |
Navazující kapitoly: Životní cyklus manageru · Hydrátory · E-shop · Naplánované úlohy · Fakturace v administraci