Přeskočit obsah
V
Pro vývojáře
Architektura, konvence, jádro systému a bezpečnost
Moduly / Fakturace

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

  1. 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ů.
  2. Na zamčeném dokladu zapisuj jen sloupce mimo částky, řádky a odběratele, a to přes updateColumns().
  3. 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#

  1. Sestav Invoice — strany, měnu, datum vystavení a řádky.
  2. Peněžní pole neplň. Cokoli tam dáte, InvoiceCalculator v beforeSave() přepíše. Vstupy jsou jen cena, množství, sazba DPH, sleva a přepínače useVat / useDiscount.
  3. Číslo nastavuj jen při importu. Běžně nechte pole number prázdné a naplňte billingLineId — číslo přidělí řada.
  4. Ulož přes InvoiceManager::save(). Přepočet, číslování i PDF se navěsí samy.
  5. 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

  1. Pod vlastním zámkem ověř, že doklad pro tenhle záznam ještě nevznikl.
  2. Sestav řádky přes InvoiceComposerLine::create() s pojmenovanými argumenty; cena je jednotková a bez DPH, kalkulátor ji násobí množstvím.
  3. Odběratele předej přes fromDirectory(), nebo přes fromSnapshot(), když v kartotéce není nebo má doklad nést údaje platné k jinému okamžiku.
  4. Zavolej compose(). Při neúspěchu vrať errorCode a nic neukládej.
  5. Fakturu ulož přes InvoiceManager::save() a ověř isSuccess().
  6. Id faktury si zapiš na své straně. Invoice.orderId k 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()):

  1. Zabrání období (claimPeriod()): podmíněný UPDATE rozvrhu, jehož WHERE drží active = 1, načtené renewal_date a 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 (perioda never nebo interval ≤ 0) se místo posunu deaktivuje.
  2. Uložení faktury přes InvoiceManager::save(). Transakci otevřela přímo DBAL spojení (inAtomicTransaction()), takže TransactionManager ulož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á

  1. Odeslání zabal do closure, která vrací Result (např. EmailManager::sendEmailFromText()).
  2. Zápis auditu dej do druhé closure (InvoiceManager::updateColumns($id, ['emailIsSent' => true, …])).
  3. Obě předej InvoiceEmailAudit::sendAndRecord() spolu s closure, která zapíše do logu.
  4. Stav SentNotRecorded ohlas 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

  1. Přidej klíč invoice.default.<přípona> do TEXT_KEYS v app/UI/Admin/Invoicer/Presenters/SettingPresenter.php.
  2. V generátoru ho čti přes settingIntOrNull() nebo settingInt() s celým klíčem modulu 7.
  3. 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

Nastavení cookies