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

Moje firma#

Modul Company drží vlastní firmu provozovatele webu: její prezentaci, ceník, reference, objednávkový kalendář a evidenci zakázek napojenou na Fakturaci.

Company není Comcat a nesdílí s ním ani jednu tabulku

Katalog firem je adresář cizích firem, postavený na Advert s dynamickými parametry. Company má vlastní pevné entity, vlastní prefix cms_mod_company_* a s Comcatem si nepůjčuje nic — jen se s ním plete jméno. Kdo sem přenese vzor z Comcatu, přenese ho nadarmo.

Tři domény v jednom modulu#

25 Api entit se rozpadá na tři skupiny, které spolu skoro nesouvisí:

Doména Entity O čem to je
prezentace Company, CompanyText, CompanyAssetRel, Block, TeamMember, Highlight, Reference, Category, Service, ServiceGroup (+ *Text) co je o firmě na webu
agenda termínů Calendar, TimeSlot, Reservation rezervační kalendář
zakázky WorkList, WorkSheet, WorkItem, WorkItemComment práce, hodiny, faktura

Vrstvy jsou tři — app/UI/{Api,Admin,Front}/Company/ nad sdílenou základnou app/Core/Base/Shared/Company/.

Cron vrstvu modul nemá

app/UI/Cron/Company/ neexistuje. Žádná expirace, žádný přepočet počítadel, žádný reindex — modul nemá strom s počítadly ani vyhledávací zdroj. E-maily o rezervacích se posílají synchronně z Api akce, ne z fronty.

Ani obrazovku Nastavení

Modul nemá SettingPresenter ani index v tabulce nastavení. Všechno konfigurovatelné sedí na entitě Company. Kdo hledá companyPerPage nebo maxImageSize, hledá marně — limity galerie jsou v CompanyPresenter u konfigurace dropzone.

Právě jedna prezentovaná firma#

Firem může být víc, ale web se vykresluje z té s presented = 1. Exkluzivitu vynucuje Api, ne formulář:

// App\UI\Api\Company\Models\Managers\CompanyManager::updateColumns()
// → clearOtherPresented(): zapnutí presented vypne příznak u všech ostatních

Front si firmu nevybírá — všechny veřejné akce jdou přes CompanyManager::findPresented() a companyId se z prohlížeče nikdy neposílá.

Fail-closed: žádná prezentovaná firma = jako by byly rezervace vypnuté

findPresented() vrací null a front to vyhodnotí stejně jako booking_enabled = false. Nevypadne chyba, jen zmizí obsah — což se při ladění snadno svede na šablonu.

Tři kolekce, které se neukládají object-graphem#

Highlight, Block i TeamMember nesou skalární companyId, ne ManyToOne asociaci. Nejsou to tedy vlastněné kolekce a save() firmy je s sebou nevezme. CompanyFormTrait je ukládá třemi samostatnými SYNC průchody (saveHighlights(), saveBlocks(), saveTeamMembers()): najdi stávající → postav entity z formu → ulož → smaž odebrané.

Selhání SYNC průchodu nechá firmu uloženou a nahlásí chybu

saveEditForm() vrátí false a admin uvidí Uložení firmy se nezdařilo — ale firma i předchozí průchody uložené jsou. Kdo to čte jako transakci, opraví špatnou věc.

Tým musí jet klientským multiplierem

Highlights a bloky jsou server-side Multiplier s naja add/remove. Tým ne: jeho řádek nese <input type=file> a redraw snippetu by vybraný soubor zahodil. Proto addTeamFields() nepřidává addCreateButton()/addRemoveButton() a řádky klonuje JS ze <template data-multiplier-template>. Nové pole je proto potřeba přidat na dvě místa — do PHP i do té šablony; jinak ho nový řádek nemá.

Portrét člena týmu se musí přenést ručně

saveTeamMembers() staví entity členů znovu z formuláře a save() je náhrada, ne patch. Stávající portrét proto přenáší z hidden pole photoCurrentId (vykresluje ho komponenta ImageUpload); Api processPhoto() v afterSave dosazuje FK jen při novém obrázku. Kdo ten přenos vynechá, smaže portréty všech členů při každém uložení firmy — i když nikdo na záložku „Tým“ nesáhl.

Galerie: pořadí je datový kontrakt#

Galerie firmy i reference jede vzorem z Diskuze: admin posbírá uploaderItems[] do $meta['orderedAssets'], Api afterSave() zavolá AssetProcessingTrait::processOrderedAssets(), ta označí první položku jako main a přes hook getMainAssetColumn() zapíše její imageId do photo_image_id.

Šablony si pak sahají pro konkrétní indexy — Profile:hero bere položku 0, aboutMedia položky 1 a 2, qualityMedia položku 3, Gallery zbytek od 4. portrait bere také položku 0 — je pro téma, jehož hero fotku neukazuje, aby se na stránce neopakovala.

Pořadí galerie je veřejné rozhraní šablony, ne jen kosmetika

Přehození položek v administraci mění, co je na webu v hero sekci. A obráceně: kdo v šabloně sáhne pro galleryAssets[3], uzavírá s administrátorem smlouvu, kterou nikde nevidí. Nový index vždycky okomentujte v šabloně tak, jak to dělá AboutMedia.latte.

orderedAssets se posílá i prázdné

Kdyby se vynechávalo, vyprázdnění galerie by se nikdy neuložilo. Prázdné pole znamená „všechny rels removed = 1, photoImageId = null“.

Reference: public je master, zbytek filtruje front#

Reference má čtyři příznaky. Api úzká akce publicList vynucuje jen presented + public — to je bezpečnostní hranice. inList, featured a inLogos jsou prezentační podmnožiny a filtruje je front v PHP nad výsledkem publicList (ReferenceManager::findListReferences() a spol.). Každá sekce si publicList volá znovu, memo na požadavek není.

Příznak Kdo ho vynucuje Šablona komponenty
public server (publicList) —
inList front Grid, Filmstrip
featured front Featured
inLogos front Logos

inLogos se záměrně nefiltruje na vyplněné logo

Popisek pole v adminu slibuje Vyžaduje vyplněné logo reference, ale manager to nekontroluje. Je to úmysl: čerstvě založený partner by z pásu tiše zmizel a nebylo by poznat proč. Šablona bez loga vysází textovou značku z titulku. Popisek pole tedy neodpovídá kódu — kdo přidá filtr, „opraví“ chování, které je vědomé.

Kategorie referencí zatím nemají konzumenta

Category je nested set navázaný M:N přes cms_mod_company_reference_category_relations, ale front podle nich nefiltruje ani je nezobrazuje — Front\CategoryManager je prázdná CRUD kostra. Pole preferred, href a meta jsou zděděná ze vzoru Diskuze a nečte je nic.

Zakázka → pracovní list → práce#

Tři patra, ne dvě. WorkItem má FK work_sheet_id, ne work_list_id.

Entita Stavy Co umí
WorkList (zakázka) open, archived složka klienta; archivace zamyká, nefakturuje
WorkSheet (pracovní list) open, closing, closed, invoiced fakturační dávka; lockedStates() = poslední tři
WorkItem (práce) new, in_progress, waiting, done, cancelled řádek s hodinami a cenou

Název WorkList je historický a plete

WorkList není list prací — je to zakázka. List je WorkSheet. Celý životní cyklus uzávěrky a fakturace se z WorkList přestěhoval na WorkSheet (migrace sql/2026-08-29_01_company_work_sheets.sql); closing/closed byly z WorkListState odstraněny. Kdo si v hlavě nechá starý model, hledá tlačítko „Uzavřít“ na zakázce.

Cenu práce počítá výhradně server v beforeSave: manual_price ?? round(hourlyRate × actualHours, 2). price se z formuláře nikdy neposílá.

Sazba je snapshot, ne join

hourly_rate se opíše ze služby (jinak z firmy) při založení práce a dál se sama nemění. Ceníková změna staré práce nepřepočítá — a je to tak správně, protože už mohou být na faktuře.

Zámek prací a přesun mezi listy#

Práce zamčeného listu (WorkSheetState::lockedStates(): closing, closed, invoiced) i práce archivované zakázky jsou jen ke čtení. Rozhoduje WorkItemManager::guardSheetWritable(), který čte stav listu i zakázky syrovým dotazem — uzávěrka běží v jiném požadavku a identity map by mohla držet stav z doby před ní. Každá zápisová cesta obchází tu předchozí, proto guard sedí ve všech:

Cesta Co se kontroluje
save() → beforeSave() cílový list z payloadu; při úpravě i list, ve kterém práce v DB je teď; přesun jen v rámci téže zakázky
updateColumns() listy všech dotčených prací; s workSheetId navíc cílový list a táž zakázka
delete() → beforeDelete() list podle uloženého stavu práce, ne podle entity v ruce
deleteAll() override: najde práce podle filtru a prověří jejich listy (rodičovská verze hooky nespouští)

Přesun do listu jiné zakázky se odmítne i u téhož klienta (guardSheetOfSameList()): peníze by přešly mezi dva fakturační kontexty. Ze zamčeného listu práci nevyvede žádná cesta.

Inline změna v mřížce přepočítá cenu mimo beforeSave()

updateColumns() hooky nespouští, takže override po úspěchu zavolá recomputeStoredPrices(): týž vzorec, zápis jen u řádků, kde se cena změnila. Spouští ho jen pole z PRICE_INPUT_FIELDS (actualHours, hourlyRate, manualPrice). Nové pole, ze kterého se cena počítá, tam přidejte — jinak zůstane v DB stará cena a na fakturu půjde ta.

findForSheet() a findForSheets() ownership neověřují

Vrátí práce kteréhokoli listu. Klientská sekce smí jít jen přes findOwnedItemRow() a findForOwnedList(): ownership i výběr jsou v jednom dotazu přes práci → list → zakázku → odběratele uživatele, s deleted = 0 na všech třech koncích. Cizí, neexistující i smazaná zakázka vrátí totéž prázdno; rozlišená odpověď by z akce udělala orákulum na existenci cizích zakázek.

Zámky zápisu listu a zakázky#

Stejné čtyři cesty jako u prací hlídají i WorkSheetManager a WorkListManager; šedé tlačítko v administraci je jen zrcadlo. Override deleteAll() není formalita: rodičovská verze maže podle filtru bez beforeDelete, a přitom je to běžná Api akce s vlastním RBAC klíčem — guard by stačilo obejít filtrem místo id. Stav se i tady čte syrovým dotazem, ne z entity, která přišla z payloadu.

Situace Co se odmítne errorCode
zakázka archived jakýkoli zápis do zakázky, jejích listů a prací workListArchived
list closing / closed / invoiced úprava a mazání listu (práce viz výše) workSheetClosing / workSheetClosed / workSheetInvoiced
stav listu nebo zakázky mimo enum (tryFrom() vrátí null) totéž co zamčený stav unknownState
jiné workListId u existujícího listu beforeSave() i updateColumns() workSheetListImmutable

List se mezi zakázkami nestěhuje: klient, odběratel i klientský účet sedí na zakázce, takže jedno pole formuláře by předalo všechny práce listu i jejich konverzace jinému klientovi.

Přechody stavů guardy neprocházejí

closeSheet(), reopenSheet(), invoiceSheets(), archive() a unarchive() zapisují jen privátními podmíněnými UPDATEy (WHERE state = …), které jsou zároveň serializační branou. Pole, která mění, jsou v $defaultSkipFields (u listu state, stateBeforeClosing, closedDatetime, invoicerInvoiceId). Nový přechod pište stejně — meta příznak, kterým by save() guard přeskočil, může poslat kdokoli. bill ve skip listu záměrně není: je to běžné zaškrtávátko, chrání ho #[ExternalReadonly] a guardy zamčeného listu.

Zakázka se maže jen prázdná, nikdy kaskádou

Zakázku s nesmazanými listy beforeDelete() odmítne (WORK_LIST_NOT_EMPTY), se zamčeným nebo kdysi vyfakturovaným listem (closing, invoiced nebo vyplněné invoicer_invoice_id) také (WORK_LIST_HAS_INVOICED_SHEETS) — takový list smazat nejde, jediný konec zakázky je archivace. Archivovaná zakázka vrátí WORK_LIST_ARCHIVED. Grid tyto kódy přeloží z domény results (explainDeleteFailures() ve WorkListPresenter), takže admin vidí důvod. Kaskádu v PHP nepřidávejte: mazání je tvrdé, klient vidí práce i komentáře na frontu a ON DELETE RESTRICT na listech i pracích je záměr.

WorkItemManager se injektuje přes továrnu

WorkListManager i WorkSheetManager berou manager prací přes WorkItemManagerFactory (líné Container::getByType()). Manager listu se ptá na počty prací a manager prací nezná žádný Work* manager — firmu listu si čte jedním JOINem (resolveCompanyIdOfSheet()). Oboustrannou konstruktorovou injektáž by kontejner nesestavil. Pravidla „co je fakturovatelné“ a „co se počítá“ žijí jen ve WorkItemManager; neopisujte je syrovým dotazem do volajícího.

Uzávěrka listu → faktura#

WorkListManager::invoiceSheets() bere N listů → JEDNU fakturu. Řádek faktury je jedna práce (CompanyInvoiceGenerator::buildLine()):

Typ práce quantity unit price
hodinová actual_hours (DECIMAL string z DB) hod hodinová sazba
manual_price 1 ks fixní cena

Výběr položek: billable = 1 AND deleted = 0 AND price != 0. Součty se neposílají — autoritativní je kalkulátor Invoiceru.

Generátor faktury jen skládá#

CompanyInvoiceGenerator::generate() je čistý sestavovač: nic nezapisuje, nebere zámek ani číslo z řady a vrací neuloženou fakturu. Ukládá ji invoiceSheets() pod zámkem zakázky až po přechodu listů do closing — každá chyba sestavení tedy padne ještě nad otevřenými listy. Idempotenci generátor neřeší: dvě volání = dvě faktury.

Vstupem je N listů jedné zakázky ve tvaru [['sheet' => …, 'items' => […]], …]. Ke kterému listu řádek patří, nese pole description řádku (název listu); name je název práce a code zůstává prázdné. Plátcovství (useVat) se neposílá — patří prodejci a composer si ho vezme z něj.

errorCode Kdy
customerMissing zakázka nemá odběratele
sellerMissing firma nemá invoicer_seller_id
invoiceTypeMissing typ dokladu chybí na zakázce, u odběratele i u firmy
currencyMissing měna chybí na zakázce, u odběratele i u firmy
noLines prázdný seznam prací (invoiceSheets() ho hlásí jako nothingToInvoice)
invalidWorkItem práce nemá z čeho spočítat cenu (chybí sazba nebo hodiny)
+ kódy InvoiceComposer::compose() např. numberSourceMissing

Všechny kódy jsou trvalé — vada konfigurace nebo dat, opakování nepomůže. Hláška jmenuje, co doplnit; u typu a měny všechna tři místa řetězu.

Výběr a pořadí prací dělá volající, ne generátor

Generátor práce nefiltruje ani nepřerovnává — řádky jdou na doklad přesně v pořadí vstupu (work_sheets.order, pak work_items.order z WorkItemManager::findBillableForSheets()). Modál faktury ukazuje týž seznam a admin ho potvrzuje. Kdo do generátoru přidá vlastní filtr nebo usort(), pošle zákazníkovi jiný doklad, než jaký člověk schválil — a nikdo si toho nevšimne.

Hodiny se nezaokrouhlují

Desetinné hodiny bez migrace quantity na DECIMAL(8,2) odmítne guard v InvoiceManager::beforeSave(). Generátor ho záměrně neobchází zaokrouhlením: 2,5 h → 3 h by tiše změnilo částku faktury.

Stav closing je okno uzávěrky, ne fáze práce:

open/closed ──(guardy + složená, NEULOŽENÁ faktura)──▸ closing
closing ──(save OK → JEDEN UPDATE invoice_id + state)─▸ invoiced
closing ──(save selhal, doklad NEVZNIKL)─────────────▸ původní stav (state_before_closing)

Idempotence stojí na closing + invoicer_invoice_id

Opakované volání nad listem, který je v closing a už má invoice_id, uzávěrku dokončí a druhou fakturu nevystaví. Jediný stav, který potřebuje člověka, je closing BEZ invoice_id. Kdo tuhle dvojici v novém kódu obejde, vyrobí duplicitní doklady.

hodiny do quantity jdou jako DECIMAL string, ne float

Vyžaduje to MODIFY … DECIMAL(8,2) na cms_mod_invoicer_invoice_items.quantity (migrace sql/2026-08-23_07_invoicer_quantity_decimal.sql). Bez ní uzávěrka desetinné hodiny odmítne — hlasitě, ne tiše.

Mazání listu má dvě nezávislé podmínky

Nesmí být zamčený a nesmí mít nesmazané práce. FK je ON DELETE RESTRICT; u měkkého smazání by RESTRICT nepomohl a vznikly by osiřelé práce započtené v součtech.

Zámek zakázky a pořadí kroků#

invoiceSheets() běží celé pod advisory zámkem GET_LOCK(WorkListManager::lockName($id), 10) (company_work_list_{id}). Týž zámek berou archive(), unarchive() a WorkSheetManager::closeSheet() / reopenSheet(): fakturace rozhoduje podle stavů N listů najednou, takže zámek per list by nic nechránil. Kdo zámek nedostane do 10 s, dostane locked. Zámek i všechny zápisy jedou po téže DBAL connection — GET_LOCK chrání jen session, která ho drží.

Kroky v runInvoiceSheets():

Krok Co Zapisuje
1 zámek zakázky —
2 syrové čtení zakázky a listů, guardy; list v closing ⇒ recovery; brána „zakázka bez klienta“ —
3 findBillableForSheets() —
4 složení faktury generátorem —
5 open/closed → closing jedním podmíněným UPDATE, musí projít všechny listy listy
6 znovu findBillableForSheets(); jiný výběr ⇒ revert + billableChanged listy
7 log záměru do kanálu company, InvoiceManager::save() doklad
8 save selhal ⇒ sonda invoiceRowExists() ⇒ revert, nebo closing zůstane listy
9 jeden UPDATE: invoicer_invoice_id, state = invoiced, bill = 0, closed_datetime listy
10 volitelně navazující prázdný list list

Postup: změna fakturace

  1. Kontrolu, která může selhat kvůli datům, dejte do kroků 2–4. Selže nad listy, které jsou pořád otevřené, a admin po opravě klikne znovu.
  2. Vstupní guard přidejte do guardInvoiceEntry() nebo guardSelectedSheets(). Sdílí je fakturace i náhled previewInvoiceSheets() — jinak modál slíbí fakturu, kterou fakturace odmítne.
  3. Pravidlo výběru prací měňte jen ve WorkItemManager::findBillableForSheets(); čte ho modál, krok 3 i kontrola v kroku 6.
  4. Každé selhání za krokem 5 musí listy vrátit revertClosing(), jinak zůstanou zamčené v closing bez faktury.

Pořadí v SET je závazné

Krok 5 je SET state_before_closing = state, invoicer_invoice_id = NULL, state = 'closing'. MySQL/MariaDB vyhodnocují přiřazení zleva doprava a pravá strana už vidí dřívější přiřazení téže klauzule. Prohozené pořadí uloží do state_before_closing hodnotu closing a revert pak ručně uzavřený list otevře. Revert (state = state_before_closing, state_before_closing = NULL) má pořadí opačné ze stejného důvodu.

Žádná obalová transakce

InvoiceManager::save() si transakci otevírá sám a PDF s QR vyrábí až po commitu. Ve vnořené transakci by rollback nechal na disku PDF neexistující faktury a zámek řádku číselné řady by se držel po celé generování PDF, takže by se vystavování dokladů v celém systému seřadilo za sebe. Okno mezi uložením faktury a krokem 9 proto zůstává a ošetřuje ho recovery.

Navazující list fakturaci neshodí

V kroku 10 je faktura vystavená a listy invoiced. createFollowUpSheet() proto chytá i výjimky (zavřený EntityManager, výpadek DBAL) a vrací je v datech jako followUpError; admin dostane úspěch s varováním, ne chybu 500 nad požadavkem, který už spálil číslo z ostré řady.

Znovuotevření a nová fakturace listu#

reopenSheet() (closed/invoiced → open) nenuluje invoicer_invoice_id: doklad je vystavený a u klienta, list je pořád „ten, ze kterého vznikla faktura #X“. bill zůstává 0 a closing se ručně neotevírá — to je běžící nebo přerušená fakturace. Doplatek se vystaví další fakturou. Krok 5 starou vazbu odpojí ve stejném statementu jako přechod do closing a předtím ji zaloguje do kanálu company.

Revert starou vazbu nevrací

Když nová fakturace znovuotevřeného listu selže, list se vrátí do původního stavu s invoicer_invoice_id = NULL. Historická vazba je jen v logu. Bez odpojení by krok 9 (WHERE invoicer_invoice_id IS NULL OR = :invoiceId) list odmítl až po uložení nové faktury — vznikl by osiřelý doklad se spáleným číslem.

Přerušená fakturace#

Pád mezi kroky Stav v DB Další invoiceSheets() nad týmž výběrem
5 a 7 closing, bez invoicer_invoice_id revert na state_before_closing + closingWithoutInvoice
v kroku 8 původní stav (revert s důkazem sondou) vyfakturuje normálně
7 a 9 closing, bez invoicer_invoice_id totéž co první řádek — ⚠️ faktura ale mohla vzniknout
po ruční opravě vazby closing s vyplněným invoicer_invoice_id dokončí krokem 9, recovered: true, nová faktura nevznikne
9 a 10 invoiced alreadyInvoiced; navazující list admin založí ručně

Recovery (recoverInterrupted()) dokončí výběr, jen když všechny jeho closing listy nesou tutéž fakturu a zbytek výběru je invoiced s toutéž fakturou (krok 9 prošel jen na části listů). Cokoli jiného je closingMixedState. Faktura navázaná i na list mimo výběr vrátí closingInvoiceTaken, vazba na neexistující doklad closingInvoiceMissing. Recovery nezakládá navazující list a nevyžaduje klienta na zakázce — faktura už existuje.

Postup: listy po pádu skončily v closing bez faktury

  1. Najděte v logu kanálu company záznam záměru z kroku 7 a případně číslo uložené faktury.
  2. Ověřte v Invoiceru, jestli doklad vznikl.
  3. Nevznikl: akci zopakujte (první pokus listy už vrátil, nebo je vrátí).
  4. Vznikl: akci neopakujte. V DB nastavte všem dotčeným listům invoicer_invoice_id = <id> a state = 'closing' a teprve pak akci spusťte — dokončí je bez nové faktury. Administrace ani Api tato pole nezmění.

Opakování po pádu mezi kroky 7 a 9 vystaví duplicitu

Server tenhle pád nerozezná od pádu před uložením, protože vazba na doklad ještě neexistuje a Invoice.order_id se k tomu použít nesmí. Hláška closingWithoutInvoice proto žádá ověření v Invoiceru. Duplicitní faktura spálí číslo z ostré řady, odejde zákazníkovi a vrací se jen ručním stornem.

Editace zakázky v administraci#

Obrazovka /admin/company/work-list → edit je jeden {form editForm} se dvěma taby: „Zakázka“ (WorkListFormTrait) a „Pracovní listy“ (WorkListSheetsFormTrait). Každý list má vlastní instanci mřížky WorkGrid přes Multiplier; všechny jsou v DOM naráz a JS jen přepíná viditelnost, takže přepnutí listu nezahodí rozepsané řádky jiného.

Jedno odeslání uloží všechno, v tomhle pořadí (saveWorkListForm()):

  1. Uloží se zakázka (WorkListManager::save()). Když selže, nic dalšího neproběhne.
  2. persistSheetsAndItems() projde POST pole sheets[<id>] — surový POST, žádné Nette kontroly. U archivované zakázky se přeskočí celé.
  3. Každý list cíleně: updateColumns() jen se změněnými name, bill, order.
  4. Každá práce cíleně: existující [id] → updateColumns(), [deleted]=1 → delete(), nový řádek s vyplněným názvem → save(), prázdný nový řádek se tiše přeskočí.
  5. Chyby jednotlivých listů a řádků se posbírají a vypíšou jako samostatné varovné hlášky; zakázka a všechno, co prošlo, zůstává uložené.
Pole Odkud se bere
price práce nikdy z POSTu — počítá ho server
[deleted] nehydratuje se, překládá se na delete()
state, closedDatetime zakázky formulář na ně nesahá; mění je jen modál archivace
řádek cizího listu ignoruje se (id nepatří k tomu listu)
řádek zamčeného listu prohlížeč posílá jen id/order/deleted=0; cokoli dalšího se nahlásí jako pokus o změnu

Čísla v mřížce hlídá jen itemNumberError()

Mřížka nemá validaci po buňkách, takže hodiny, sazbu a ruční cenu kontroluje proti mezím sloupců (ColumnLimits) jen tahle brána. Vadná hodnota odmítne celý řádek, ne tichou nulou. Bez ní by DB ve striktním sql_mode shodila uložení obecnou chybou databáze a v nestriktním by hodiny ořízla, zatímco cena by se spočítala z neoříznuté hodnoty — faktura by pak neprošla kontrolou množství × sazba = cena. Nové číselné pole mřížky proto přidejte i do itemNumberError().

is_numeric() na vstup z mřížky nestačí

Bere i exponent: 1e1000 projde, (float) z něj udělá INF a do DB jde řetězec inf. parseDecimal() proto ověřuje tvar regulárním výrazem a zaokrouhluje v bcmath na přesnost sloupce. Sazbu DPH čte parseVatPercents(), ne intOrNull() — ten by 0 % (přenesená daňová povinnost) převedl na NULL.

Odběratel a fakturační údaje#

Klient (invoicerCustomerId, picker nad kartotékou Invoiceru) není povinný pro uložení, jen pro fakturaci: hlídá to Api (WorkListManager, kód customerMissing) a fakturační box zakázky na to upozorní předem. Starší zakázky bez klienta tak jdou dál upravovat. Klientský účet portálu na zakázce není — bere se z odběratele (Customer.cms_system_users_user_id).

Typ dokladu a měna jsou na zakázce volitelné selecty. Po výběru klienta je refresh (applyCustomerRefresh()) přepíše hodnotami z profilu odběratele a uloží se kopie, ne odkaz — pozdější úprava profilu existující zakázku nezmění. Prázdná hodnota znamená „další článek řetězu“:

Údaj Řetěz (první vyplněný vyhrává) Kde se vyhodnocuje
typ dokladu, měna zakázka → odběratel → firma CompanyInvoiceGenerator
měna tabu „Pracovní listy“ zakázka → odběratel → firma → první měna systému resolveCurrency()
výchozí sazba DPH odběratel → firma (zakázka pole DPH nemá) generátor; v mřížce jen placeholder

Select kontaktu musí mít volby i při odeslání

Volby invoicerCustomerContactId plní refresh a výchozí hodnoty editace. Kdyby je addClientFields() nenaplnil i na POSTu podle odeslaného klienta, Nette by hodnotu mimo (prázdné) volby tiše zahodilo a kontakt by se uložil jako NULL.

setRequired() na klientovi by hlášku schoval

invoicerCustomerId je hidden pole a HiddenField::addError() posílá chybu vždy na úroveň formuláře — {inputError invoicerCustomerId} nic nevypíše. Kdo povinnost vrátí, musí počítat s tím, že hlášku ukáže jen flashFormOwnErrors().

Souhrny a vystavení faktury#

Cena pod názvem listu, fakturační box i „nevyfakturováno“ sčítají jen práce s billable = 1 (summarizeItemsRaw()); počet a hodiny se nefiltrují. Nefakturovatelná práce má cenu spočítanou dál — „zdarma“ vyjadřuje příznak, ne nulová cena.

Modál „Vystavit fakturu“ (WorkListInvoiceFormTrait) má dva kroky:

  1. invoicePreview! (AJAX) dostane zaškrtnuté listy jako sheetIds[] a naplní modál read-only náhledem. Při selhání se nevykreslí žádná tabulka, jen chyba — rozhoduje isSuccess(), ne obsah sheets.
  2. Potvrzení je skutečný formulář invoiceConfirmForm s plnou navigací (bez ajax), protože vystavení mění skoro celou stránku. Vybrané listy jedou jako prosté <input type="hidden" name="sheetIds[]"> a čtou se z POSTu.
  3. Když vystavení selže, presenter přesměruje zpátky na náhled nad stejným výběrem.

Oprávnění se kontroluje v obou krocích

:Admin:Company:WorkList:invoice ověřuje handleInvoicePreview() i processInvoiceConfirm(). Potvrzení je samostatný request — kdo kontrolu z něj odstraní s tím, že „modál už je chráněný“, otevře vystavení faktury každému, kdo pošle POST.

Komentáře práce mají vlastní tabulku#

Původní návrh počítal se sdíleným Base CommentThread v privátním režimu. Od toho se upustilo: vlákno vznikalo ke každé práci i bez komentáře (naměřeno 9 vláken / 0 komentářů) a z Base se nevyužívalo nic — žádné vnořování, moderace ani jazyk. Vzniklo cms_mod_company_work_item_comments.

Privátní větev Base je SMAZANÁ, nevracejte ji

Z ThreadType zmizel case CompanyWorkItem a s ním isPrivate(), PublicThreadsOnlyFilter, PrivateThreadVisibilityTrait i části filterReadPayload(). Všechna vlákna v tom enumu jsou dnes veřejná a čtecí cesty na to spoléhají. Právě ta větev byla příčinou dvou bezpečnostních chyb ve čtení vláken.

Nepřečtené řeší dvojice by_client + read_datetime, jeden mechanismus pro obě strany (CommentSide::Company / Client). Značí se v okamžiku zobrazení, ne odkliknutím; countUnreadForCompany() je jeden dotaz pro celý grid, ne N+1.

„Firma“ je jeden subjekt

Přečtení jedním adminem platí pro všechny. Per-uživatelské sledování by chtělo další rostoucí tabulku — přesně to, kvůli čemu se komentáře od Base odpojily.

Ownership klienta hlídá jen WorkItemPresenter

Řetěz práce → list → zakázka → klient sedí v akcích myComments/myCommentAdd. WorkItemCommentPresenter je bridge-only pro admin — kdyby klient chodil tam, kontrolovalo by se ownership na dvou místech a jednou by se rozešla.

Rezervace#

TimeSlotType má tři případy — slot (okno přepisu), blocked (blokace uvnitř oken) a closed_day. Den je proto ve třech režimech: výchozí (bez záznamu, platí pracovní doba kalendáře), denní přepis (existuje aspoň jedno slot) a zavřeno.

volno = okna − rezervace − blokace

První slot dne vypne výchozí pracovní dobu pro ten den

Den v režimu přepisu se řídí výhradně svými okny. Admin akce „Otevřít okno“ proto na dosud výchozím dni nejdřív uloží stávající pracovní dobu jako okna — aby se nabídka nezmenšila. Nový kód, který zapisuje slot mimo tuhle cestu, tu ochranu obejde a den tiše osekne.

E-maily nesou prefix CMP_: CMP_RESERVATION_RECEIVED, _REQUESTED, _CONFIRMED, _CANCELLED, _CHANGED (ReservationManager).

Upozornění ve zvonku administrace vzniká jen u webové rezervace

Notifikaci typu COMPANY_RESERVATION_NEW zakládá ReservationPresenter::internalActionBook() po úspěšném book(), ne manager — bookByAdmin() tudy nejde, takže ruční zápis správcem zvonek nerozsvítí. Je best-effort: chyba se jen zaloguje do kanálu company a rezervace platí. receiverUserId = null znamená, že ji uvidí každý admin; chybějící nebo neaktivní typ v číselníku se tiše přeskočí.

Intervalový model a nosič rezervace#

book() přijme libovolný rozsah timeFrom < timeTo (konec výlučný), který se celý vejde do jednoho volného intervalu dne. slot_length_minutes je jen krok nabídky v UI, server na mřížku nezarovnává ani nehlídá minimální délku. Rezervace pak založí nosič — řádek slot s public = 0 a přesným rozsahem. Okno 9:00–17:00 s rezervací 10:00–10:45 tak dá volno 9:00–10:00 a 10:45–17:00 bez dělení řádků.

Nosič se hledá a zakládá v resolveCarrierSlot() třemi cestami:

  1. Na začátku rozsahu žádný řádek není → založí se nosič.
  2. Existuje volný slot se stejným rozsahem (okno přepisu nebo nosič po zrušené rezervaci) → použije se.
  3. Na začátku sedí řádek s jiným rozsahem → nosič se založí na klíč posunutý o sekundy (13:00:01), protože unikátní klíč (calendar_id, date, time_from) druhý řádek téže minuty nepustí. Všechna aritmetika běží v minutách, takže posun není vidět.

Nosič musí mít public = 0

S public = 1 by první rezervace překlopila den do režimu přepisu a ostatní okna by z kalendáře zmizela.

Časy přijímají jen nulové sekundy

parseMinutes() bere H:i a H:i:s jen se sekundami 00; 24:00 je platný konec dne. 10:00:30 se odmítne — oříznutí na minutu by posunulo hranici rezervace proti tomu, co klient poslal.

Souběh: denní zámek kalendáře#

Všechny zápisy do jednoho dne jednoho kalendáře (klientská rezervace, správcovský zápis, TimeSlotManager::save(), zkrácení, rozdělení, slučování) jdou pod advisory zámek TimeSlotManager::dayLockName() = company_calendar_slot_{calendarId}_{date}, čekání DAY_LOCK_TIMEOUT = 5 s. Klíč je den, ne začátek okna: rozsahy 10:00–11:00 a 10:30–11:30 mají různý začátek a zámek podle začátku by je neserializoval. Různé dny a různé kalendáře na sebe nečekají.

Postup zápisu pod zámkem:

  1. Předběžná kontrola volna přes Doctrine (mimo zámek, jen rychlé odmítnutí).
  2. acquireSourceLock(); nepovede se → DB_LOCKED s hláškou „zkuste znovu“.
  3. Den se znovu přečte syrovým DBAL (TimeSlotManager::findDayRowsRaw()) a volno se přepočítá — identity map může držet zastaralý řádek.
  4. Zápis nosiče a rezervace po téže DBAL connection, na které je zámek.
  5. Uvolnění zámku ve finally, teprve pak e-maily (best-effort, selhání rezervaci nezruší).
Pojistka Co chytí
uq_company_reservation_active (time_slot_id, active_flag) dvě aktivní rezervace na jednom nosiči
uq_company_calendar_time_slot (calendar_id, date, time_from) jen shodný začátek
denní zámek + přepočet pod ním překryv rozsahů s různým začátkem — nic jiného ho nechytí

Jméno zámku skládejte jen přes dayLockName()

Vlastní kopie formátu by nad týmž dnem vytvořila druhý zámek — a serializace by zmizela přesně tam, kde na ní stojí ochrana proti překryvu.

Booking nevolejte uvnitř cizí transakce

Rezervace záměrně nejede v obalové transakci: jediné mezistadium (nosič bez rezervace) je neškodné, příští rezervace nosič použije. Uvnitř cizí transakce by rezervace byla trvalá až s cizím commitem, zatímco e-maily už odešly.

Po UniqueConstraintViolationException už nic přes ORM

Výjimka zavře EntityManager. Kód, který ji chytí, smí jen vrátit chybu.

Klientská rezervace × zápis správcem#

book() (web) a bookByAdmin() (modál termínu) sdílejí jádro createReservation() včetně zámku a přepočtu volna. Liší se jen tímto:

book() bookByAdmin()
kalendář nepovinný, jinak CalendarManager::resolveDefault() povinný; firma se z něj odvodí
kalendář patří firmě kontroluje se firma se bere z kalendáře
souhlas GDPR povinný, zapíše gdpr_consent_datetime nevyžaduje se, razítko zůstane NULL
minulost a lead time ořezávají volno neořezávají
rozsah mimo nabízená okna odmítne nejdřív okno otevře (TimeSlotManager::openWindow()); překryv s rezervací či blokací a closed_day odmítne
presented + booking_enabled vyžadované nekontrolují se (firma musí být aktivní a nesmazaná)
výchozí stav pending, nebo confirmed při auto_confirm kalendáře volí správce (pending/confirmed)
e-maily vždy jen se zaškrtnutým notifyClient (výchozí vypnuto)

auto_confirm je vlastnost kalendáře, ne firmy. Mění, který pár e-mailů odejde: při automatickém potvrzení dostane klient _CONFIRMED a firma nic (text _REQUESTED vyzývá k potvrzení, které už proběhlo); jinak klient _RECEIVED a firma _REQUESTED.

Razítko souhlasu je doklad

gdpr_consent_datetime u ručního zápisu nevyplňujte. Správce žádný souhlas přes web nesbíral a vyplněné razítko by byl nepravdivý doklad.

Zrušení a přechody stavů#

cancelByToken() (odkaz z e-mailu), cancelOwn() (přihlášený klient), confirm(), cancel() i updateByAdmin() jsou jeden podmíněný UPDATE, jehož WHERE nese povolené stavy a u klienta i token nebo user_id. Kontrola a zápis tak nemají mezi sebou okno: dva souběžné kliky skončí affectedRows 1 a 0 a e-mail odejde jednou.

Odpověď klientovi neprozrazuje existenci rezervace

„Neexistuje“, „není moje“ i „už zrušená“ vracejí stejnou hlášku. cancelOwn() načítá rezervaci až po úspěšném UPDATE — dřív by měl kód v ruce cizí osobní údaje. Kdo přidá rozlišenou hlášku nebo načtení předem, udělá z akce orákulum na cizí rezervace.

Podmíněné UPDATE jdou syrovým DBAL, takže je nezachytí obecné shození cache ze SaveTrait; bumpStateCacheTags() shazuje ručně tagy Reservation i TimeSlot. Nový přechod stavu ho musí volat taky — jinak by kalendář po zapnutí query cache ukazoval zrušený termín jako obsazený až do vypršení TTL.

Odkaz na zrušení potřebuje site.baseUrl mimo HTTP

buildCancelLink() bere nastavení site.baseUrl (SiteBaseUrl), jinak adresu požadavku. V cronu nebo CLI je adresa požadavku http:/, odkaz se nesestaví a e-mail odejde bez něj — jen se záznamem v logu kanálu company.

Kalendář: firma se nemění, maže se jen prázdný#

CalendarManager::beforeSave() odmítne přeřazení kalendáře k jiné firmě i z interní cesty (#[ExternalReadonly] blokuje jen externí HTTP). Sloty nesou denormalizované company_id, které se srovnává jen při zápisu slotu — po přeřazení by se trvale rozešly. Kdo chce kalendář jinde, založí nový.

beforeDelete() odmítne smazání kalendáře, který má jakýkoli slot (i minulý nebo neaktivní), kódem CALENDAR_DELETE_BLOCKED. Bez toho by RESTRICT na fk_company_time_slot_calendar skončil syrovou chybou DB a zavřeným EntityManagerem.

ReservationManagerFactory láme kruh v DI

TimeSlotManager potřebuje ReservationManager (obsazenost, guardy) a naopak. TimeSlotManager proto drží továrnu a manager si vyzvedne až při volání; opačný směr je běžná konstruktorová závislost. Nový manager, který potřebuje oba, injektujte stejně.

Omezení počtu požadavků (config/Shared/ratelimit.neon):

Klíč Limit Podle čeho
companyReservation 5 / hodinu, 20 / den; zrušení 10 / hodinu IP (kanál je otevřený nepřihlášenému)
companyWorkComment 30 / 10 minut hash id uživatele

Komentář se limituje per uživatel, ne per IP

Firemní klienti sedí za jedním NATem a navzájem by si kvótu brali. Čísla jsou doslova messageReply ze soukromých zpráv — je to týž typ akce.

Kdy se pokus započítá, se liší podle akce:

Akce Započtení Proč
book obě okna se nejdřív jen čtou (check()), hit() až po výsledku; DB_LOCKED se nepočítá DB_LOCKED znamená cizí rezervaci pod denním zámkem, ne pokus klienta
cancelByToken hit() před zrušením token se nesmí dát zkoušet zadarmo
myCommentAdd hit() před voláním manageru, i neúspěšné pokusy jinak by šlo zadarmo zkoušet cizí workItemId

U book se nepočítá jen DB_LOCKED, ne „jen úspěch“

Validační selhání se účtují dál — jinak by robot donekonečna zkoušel okna s neplatnými daty. Kdo podmínku !== ResultCode::DB_LOCKED přepíše na „jen úspěch“, otevře tohle zkoušení; kdo ji smaže, vyřadí poctivého klienta po pár souběžích na hodinu.

Administrace kalendáře#

Kalendář má v adminu dvě obrazovky s rozdělenou odpovědností:

Obrazovka Presenter Co dělá
Rozvrhy (/admin/company/schedule) SchedulePresenter vizuální rok / měsíc / týden / den, modál termínu SlotModal, slučování
Termíny (/admin/company/time-slot) TimeSlotPresenter DataGrid přepisů a blokací, formulář add/edit
Rezervace (/admin/company/reservation) ReservationPresenter výpis, detail, Potvrdit / Zrušit

Měsíční přehled čte Api úzkou akci availability s calendarId (TimeSlotManager::availabilityForCalendar()), ne entity. Dny bez vlastního řádku (source = 'default') by holý grid nad TimeSlot neukázal a rozlišení closed_day → přepis → výchozí mřížka zná jen Api — v adminu ho neopakujte. Roční přehled naopak Api nevolá vůbec: dvanáct měsíčních payloadů by bylo dvanáct bridge požadavků, takže jede dvěma dotazy nad Admin managery a ukazuje jen počty rezervací, blokací a zavřených dnů.

SlotModal se otevírá jen klikem na blok denní timeline nebo bublinu týdne a typ bloku určuje mód:

Blok Mód Co jde
volno / zavřeno fill nová rezervace (bookByAdmin), blokace, nebo „otevřít okno“ (openWindow)
čeká / potvrzeno reservation údaje klienta a stav (updateByAdmin), Zkrátit / Rozdělit, zrušení
blokováno blocked jen zrušení blokace

Čas obsazeného řádku mění jen TimeSlotManager::shorten()/split(); pole rozsahu je v módu reservation statický text. Komponenta nic nezamyká ani nevaliduje — všechno rozhoduje Api pod denním zámkem kalendáře (TimeSlotManager::dayLockName()).

Uložit + Zkrátit nejsou jedna transakce

Tlačítko „Uložit“ volá updateByAdmin() a podle přepínače timeAction navíc shorten() nebo split(). Každé si bere a pouští denní zámek samo. Když druhý krok selže (nový rozsah koliduje), údaje klienta zůstanou uložené a čas ne — modál se znovu otevře nad skutečným, napůl uloženým stavem.

Pasti, které modál obchází záměrně — při úpravě je nezaveďte zpátky:

  • Kontext jde explicitně. Signálový odkaz komponenty nenese companyId/calendarId/ date stránky (Nette prefixuje argumenty jménem komponenty). Modál je dostává parametry signálu, drží je v hidden polích a SchedulePresenter si je bere zpět přes SlotModal::getViewContext(). Bez toho renderDay() po kliku překreslí jiný den.
  • Žádné setRequired(). Formulář má víc tlačítek; HTML5 required na poli jiného tlačítka tiše zablokuje odeslání v prohlížeči. Validace je celá v Api managerech.
  • Po úspěšném založení se modál nepřepíná do editace. slotForm se sestaví líně v módu fill už při zpracování POSTu; přepnutí módu potom formulář nezmění a šablona sáhne po neexistujícím timeSlotId → 500. Modál proto končí v terminálním stavu ($done), který {form} nevykresluje.
  • Hlášky si kreslí sám ($presenter->flashMessage() je v šabloně komponenty neviditelné), neredirectuje (zavřel by se) a po zápisu čte stav znovu — mutace přes bridge nezmění už zhydratované Admin objekty.

Slučování termínů#

SchedulePresenter::handleMergeSlots() je prostý POST signál s PRG, logika je v Api ReservationManager::mergeSlots().

Vstupem je rozsah, ne seznam id: co v něm leží, si server přečte znovu pod denním zámkem. Výsledný typ určuje priorita rezervace > blokace > volno > zavřeno — např. volno + zavřeno = volno, blokace + rezervace = rezervace. Samé zavřené kusy se odmítnou („není co slučovat“).

Při výsledku „rezervace“:

  1. Vítězí nejdřív začínající aktivní rezervace; ostatní se zruší bez e-mailu (cancelInternal()) a jejich řádky pohltí TimeSlotManager::absorbRow().
  2. Teprve potom se nosič vítěze roste přes celý rozsah (growSlot()) a stane se oknem (public = 1). Opačné pořadí by neodklizené kusy vyhodnotilo jako kolizi.
  3. Jména a poznámky se spojí přes ' + ', e-mail a telefon zůstanou vítěze.
  4. Stav je confirmed, jen když byly potvrzené všechny sloučené rezervace, jinak pending.
  5. Po uvolnění zámku dostane klient vítěze _CHANGED, pokud se jeho rozsah změnil.

Slot s historií rezervace nejde smazat

fk_company_reservation_slot brání DELETE slotu s jakoukoli rezervací, i zrušenou. Proto se vítěz rozšiřuje a poražené řádky se pohlcují, místo aby se mazaly a zakládaly znovu.

Cílovou URL slučování skládejte explicitně

companyId/calendarId/date nejsou persistentní parametry pohledu day, takže redirect('this') by je tiše zahodil. A timeFrom/timeTo musí mít výchozí hodnotu: šablona generuje odkaz bez nich (doplní je merge-slots.js) a chybějící povinný parametr shodí už vykreslení šablony na InvalidLinkException.

Termíny a rezervace v tabulce#

Formulář termínu (TimeSlotFormTrait) obslouží tři režimy jedním přepínačem: přepis (slot), blokaci (blocked) a zavřený den (closed_day, čas se ignoruje). buildTimeSlotEntity() nastavuje public = true natvrdo; hidden pole public v POSTu není zdroj pravdy. Prázdný kalendář ve formuláři nechá Api dosadit výchozí kalendář firmy (CalendarManager::resolveDefault()).

Nosič rezervace (public = 0) se needituje

TimeSlotPresenter::actionEdit() ho odmítne. Uložení by nastavilo public = 1 a den by se překlopil do režimu přepisu — ostatní implicitní okna toho dne by zmizela.

Rezervace se ručně needitují: state, cancelToken a spol. jsou v Api #[ExternalReadonly] + defaultSkipFields. Zapisují jen přechody confirm/cancel (úzké akce — updateColumns by obešel guard přechodu i e-mail zákazníkovi) a jediné pole subject přes generické save() (ReservationFormTrait).

subject nepřidávejte do updateByAdmin()

updateByAdmin() volá i modál termínu, který o subject neví. Rozšířený podpis by předmět při každém uložení z modálu tiše vynuloval.

Postup: přidání akce do modálu termínu

  1. Přidejte konstantu hodnoty do přepínače (fillAction nebo timeAction) a pole do add*Fields() příslušného módu — bez setRequired().
  2. Zápis veďte přes Admin manager → bridge → Api úzkou akci, která si bere denní zámek.
  3. V handleru zavolejte requirePrivilege(), hydrateContext() a ověření, že slot a kalendář patří firmě (requireSlotInCompany()/requireCalendarInCompany()).
  4. Po zápisu znovu načtěte stav a skončete finish() nebo failAndReopen(), ne redirect().

Zápis termínu v Api: cesty a guardy#

Řádek kalendáře patří kalendáři; company_id na něm je jen odvozená kopie. TimeSlotManager::save() proto dvojici nejdřív srovná (reconcileCalendarCompany()):

Vstup Výsledek
calendarId > 0 companyId se přepíše firmou kalendáře — kalendář vyhrává
jen companyId (formulář termínu, starší volající) dosadí se CalendarManager::resolveDefault()
firma bez aktivního kalendáře VALIDATION_FAILED s výzvou kalendář založit

Teprve ze srovnaného calendarId se počítá jméno denního zámku. Při přesunu řádku na jiné datum nebo do jiného kalendáře se zamykají oba dny — na jednom se čas uvolňuje, na druhém obsazuje. withDayLocks() jména před zabráním seřadí, takže dva správci nad stejnými dvěma dny se nezaklesnou.

Cesta Co hlídá
save() zámek; řádek s aktivní rezervací smí měnit jen poznámku a public (guardActiveReservationRow()); closed_day ne na den s aktivní rezervací; blocked jen celý ve volném čase, bez ořezu lead time; kolize začátku se řeší sekundovým posunem klíče
updateColumns() jen note a public; datum, časy, typ, kalendář či firma → VALIDATION_FAILED s errorCode timeSlotStructuralUpdateColumns
delete() zámek dnů všech mazaných řádků; nesmaže řádek s aktivní rezervací ani s historií rezervací
deleteAll() nic — nespustí beforeDelete() ani nevezme zámek
shorten(), split(), growSlot() vlastní guardy pod zámkem, syrový UPDATE jen časových sloupců

reconcileCalendarCompany() nepřesouvejte do beforeSave()

beforeSave() běží až uvnitř parent::save(), tedy už pod zámkem. Starší zápis s pouhým companyId by se pak zamkl na kalendáři 0 a neserializoval by nic — blokace nebo zavření dne by mohly přeběhnout právě vzniklou rezervaci.

Termíny mažte jen přes delete()

deleteAll() podle filtru obejde guardy i zámek a slot s historií rezervací pak zastaví až FK fk_company_reservation_slot (ON DELETE RESTRICT). Správce místo hlášky dostane syrovou chybu databáze a zavřený EntityManager.

bookingEnabled = false dostupnost nevyprázdní: availabilityForCalendar() dny spočítá vždy a příznak jen přiloží. Prázdná odpověď by nešla odlišit od „tento měsíc zavřeno“ a admin přehled jede touž cestou. Obsazenost mimo nabízená okna (rezervace z doby před zúžením pracovní doby, blokace na zavřeném dni) jde v klíči busyOutside; veřejné busy je oříznuté na okna. Přehled, který čte jen busy, takový termín tiše schová.

Zkrácení, rozdělení, rozšíření#

Obecný save() čas obsazeného řádku nezmění. Jedinou výjimkou jsou tři úzké akce:

Akce Směr Na čem Co dál
shorten() jen dovnitř původního rozsahu slot s aktivní rezervací u okna přepisu (public = 1) se zkrátí i nabízené okno
split() dělicí čas uvnitř rozsahu, ne na hranici slot s aktivní rezervací rezervace zůstane na [from, cut); řádek pro [cut, to) vznikne jen u public = 1
growSlot() oběma směry slot nesmí kolidovat s rezervací ani blokací; s makePublic nejdřív zhmotní výchozí okna dne

U nosiče (public = 0) se zbytek po rozdělení uvolní sám — volno je okna minus obsazené. Nový řádek s public = 1 by na dni bez přepisu přepnul den do režimu přepisu a ostatní okna by zmizela. E-mail klientovi o změně posílají obaly ReservationManager::shortenTerm() a splitTerm(), ne TimeSlotManager.

Nepovedené rozdělení se nevrací

Když se po zkrácení nepodaří založit uvolněné okno, zkrácení zůstane a split() vrátí úspěch s freedSlotCreated = false; důvod je v logu kanálu company. Návrat by rezervaci znovu roztáhl přes čas, který správce vědomě uvolnil. Kdo slibuje „zbytek je dál v nabídce“, musí freedSlotCreated číst.

DB_LOCKED po přesunu řádku

Jméno zámku se skládá z řádku přečteného před zámkem. Přesunul-li ho mezitím jiný zápis na jiný den nebo kalendář, akce skončí DB_LOCKED a nic nezapíše; opakování už zamkne správný den.

absorbRow() a absorbWindowsWithin() si zámek neberou

Volejte je jen uvnitř withDayLocks() ve stejné MySQL session, jinak běží souběžně s book() nad týmž dnem. absorbWindowsWithin() navíc až poté, co výsledné okno existuje: den bez přepisu by jinak spadl zpět na pracovní dobu a nabídka by se tiše rozšířila.

Postup: nový správcovský zápis do kalendáře

  1. Zapisujte přes TimeSlotManager::save()/delete(), nebo přidejte úzkou metodu, která práci obalí withDayLocks([self::dayLockName(…)]).
  2. Jméno zámku skládejte z uloženého řádku (findRowRaw(), z cizího manageru findLockContext()), ne z parametrů. Pod zámkem řádek přečtěte znovu a ověřte, že je pořád na zamčeném dni (vzor guardLockedDay()).
  3. Mění-li zápis den z výchozího režimu na přepis, nejdřív zhmotněte výchozí okna (materializeDefaultWindows(), vzor openWindow()).
  4. Po syrovém UPDATE zavolejte bumpCacheTagsForClass(TimeSlot::class) — obecné shození cache ze SaveTrait tuhle cestu nepokrývá.
  5. Akci dejte vlastní RBAC klíč (vzor :Api:Company:TimeSlot:shorten) a naseedujte ho.

Front#

Pevná id stránek (rozsah 300–349):

Id Slug Přístup
300 terminy veřejná
301 moje-zakazky přihlášený
302 moje-zakazka přihlášený
303 moje-terminy přihlášený
304 rezervace-terminu veřejná

Api povrch pro front jsou úzké akce, ne obecné CRUD: presented, */public-list, time-slot/availability, reservation/book, cancel-by-token pro hosta; my* s ownership guardem pro přihlášeného. Obecné getAll/save zůstávají adminovi.

Prezentační profil firmy nemá vlastní presenter

Veřejný profil není stránka modulu — je to sada komponent, které si volá téma ({control frontCompanyProfile:hero}, frontCompanyBlockList:single, 'about', frontCompanyServiceList:carousel…). Která témata je volají, ukáže grep -rl frontCompany www/themes/frontend. Kdo hledá „stránku firmy“ v app/UI/Front/Company/Presenters/, nenajde ji — jsou tam jen TimeSlot, Booking, MyWorkList a MyReservation.

Komponenta Profile se na stránce vykresluje víckrát

Topbar, úvod i recenze jsou tatáž instance v různých stylech. Šablony proto nesmí sázet holé {$uniqueId} — id by se ztrojilo. Každý styl si k němu přidává vlastní příponu.

Komponenty pro téma#

Téma volá komponenty jménem frontCompany<Komponenta> a styl vybírá metodou render<Styl>(), např. {control frontCompanyReferenceList:logos}. Všechny čtou přes úzké akce s filtrem presented na serveru a companyId neposílají. Prázdný výsledek vykreslí šablonu Empty.latte nebo nic, nikdy chybu.

Komponenta Styly Co ukazuje
Profile hero, topbar, aboutMedia, qualityMedia, portrait, gallery, footerBrand, contact, copyright, testimonials údaje o firmě, fotky galerie podle indexu, recenze (jen se zapnutým ratingEnabled a existujícím vláknem)
BlockList sections, single volné HTML bloky; single vybere jeden podle kotvy identifier ({control frontCompanyBlockList:single, 'about'}); bez kotvy vezme první veřejný blok podle order, neznámá kotva dá Empty.latte
ReferenceList grid, featured, logos, filmstrip reference podle příznaků (tabulka v kapitole o referencích)
ServiceList list, cards, carousel, links ceník služeb
TeamList cards, carousel členové týmu
HighlightList jen výchozí render() (Bar) přednosti firmy
OpeningHours jen výchozí render() (Table) pracovní doba celého týdne, dnešek zvýrazněný

Profile:topbar ukazuje jen dnešní pracovní dobu. Souhrn typu „Po–Pá 7:00–17:00“ by se z libovolného rozvrhu dal složit jen odhadem (výjimka ve středu), týden ukazuje OpeningHours.

Obsah bloku jde ven bez escapování

content bloku je hotové HTML z administrace a šablona ho sází přes |noescape. Escapování by ukázalo syrové značky; obsah proto smí plnit jen administrátor. Kdo pustí do bloků text z jiného zdroje, otevře XSS na hlavní straně webu.

Rezervace na webu: dvě stránky, dvě komponenty#

Stránka Presenter Komponenta Co umí
300 terminy TimeSlotPresenter BookingCalendar měsíc a pod ním zvolený den, rozsah ze dvou selectů, zrušení přes ?cancel=<token>
304 rezervace-terminu BookingPresenter BookingWizard den nahrazuje měsíc, přepínač veřejných kalendářů, formulář v modálu
303 moje-terminy MyReservationPresenter BookingWizard:button + MyReservationList tlačítko otevře celého průvodce v modálu

Starší kalendář na stránce 300 zůstává vedle průvodce beze změny; obě komponenty sdílejí jen _booking_calendar.scss. Zrušení přes token z e-mailu umí jen stránka 300 a funguje i s booking_enabled = false — odkaz z dřívějšího e-mailu musí jít použít, i když správce nové rezervace mezitím vypnul. Bez prezentované firmy nebo s vypnutými rezervacemi obě stránky místo kalendáře vypíšou hlášku „rezervace vypnuty“.

Pravidla průvodce (BookingWizard):

  • Stav nese URL, ne presenter. Měsíc, den, kalendář, klepnutý termín i režim (inModal) jsou persistentní parametry; render() nebere argumenty a presenter komponentě nic nepředává. Kontext předaný argumentem by AJAX překreslení snippetu ztratilo a vykreslilo prázdno.
  • calendarId z URL se nevěří. Platné jsou jen hodnoty z klíče calendars v odpovědi availability (veřejné kalendáře prezentované firmy, InitTrait::resolveCalendarId()); jiné id spadne na první veřejný kalendář. Firma bez veřejného kalendáře nedostane mřížku žádného jiného — výchozí kalendář může být interní („Dovolená“, „Test“).
  • Přepínač kalendářů se vykreslí jen při víc než jednom veřejném kalendáři.
  • Klikatelnost dne a ořez dneška o leadTimeHours se počítá v PHP, šablona dostane hotový příznak bookable. Klepnutý termín (from, to) se ověří proti starts spočítaným na serveru; neplatná dvojice modál neotevře.
  • Kreslí se jen starts. Veřejná dostupnost nenese identitu cizích rezervací, bloky busy/confirmed/pending do šablony nepatří.

Na frontu je jeden modál, ne dva nad sebou

Front modál jede přes ModalRegistry a ModalWindowEvents, ne jako admin modál. ModalWindows.latte má data-naja-snippet-append na vnitřním #modals-container, Naja ale příznak hledá na samotném snippetu, takže se kontejner celý nahrazuje. Druhý modál by překreslil i první a třídu show by dostal jen ten nový — první by zmizel. Formulář proto nahrazuje obsah modálu s kalendářem (ModalRegistry::update() + ModalWindowEvents::UPDATE) a zpět vede šipka backToCalendar!. Otevření přes ADDED nad už otevřeným oknem by zahodilo jeho bootstrapovou instanci.

Společné oběma formulářům:

  • Formulář vzniká dřív než render(). POST signálu formuláře volá nejdřív createComponent*() a render() při přesměrování nebo chybě vůbec neproběhne. Dostupnost, ze které formulář staví nabídku časů, se proto čte memoizovaně z obou míst (fetchAvailability()), ne jen v přípravě šablony. Stejně to dělá detail zakázky.
  • Nabídka časů není kontrola. Konce se nabízejí po slotLengthMinutes až do maxTo; dvojici (from, to) ověřuje znovu book() na serveru.
  • Honeypot website: vyplněný se tváří jako úspěch a Api se nevolá.
  • Výsledek book() se propaguje. Repository vrací plochou obálku (success/code/reservationId/…) přes normalizeResultEnvelope(), ne přes failSoftRead() — success: false (obsazeno, limit) je smysl volání. Formulář překládá code mapou ERROR_MESSAGE_KEYS, neznámý kód dá obecnou hlášku.

Jméno a e-mail přihlášeného ber z identity, ne z UserManager

Front UserManager::find() míří na :Api:System:User:get, který má grant jen admin — v režimu enforce vrátí prázdno. findPublicProfile() dává maskovaný e-mail. Formulář proto předvyplňuje first_name, last_name a email z dat JWT identity (AuthStorageTrait::createIdentity()) bez dalšího dotazu.

Klientská sekce na webu#

Stránky 301–303 nemají vlastní přesměrování nepřihlášeného: bez identity bridge nic nevydá, komponenta ukáže prázdný stav a přístup hlídá layout @logged.latte. Cizí a neexistující záznam dávají tentýž prázdný stav.

  • Detail zakázky (MyWorkDetail) hledá hlavičku zakázky ve vlastním výsledku myList a práce bere z myItems. Seskupuje je podle pracovního listu v pořadí prvního výskytu a znovu je neřadí: Api je vrací v pořadí faktury (work_sheets.order, work_items.order, id). Součty hodin a cen skupiny počítá PHP přes bcadd, ne šablona. Práce bez listu v _references se ukáže bez hlavičky listu, nezahodí se.
  • Zrušení vlastního termínu (MyReservationList) je skutečný POST formulář s CSRF, ne GET signál — odkaz nebo prefetch by jinak termín zrušil. Potvrzení jde stylovaným <dialog> přes data-grid-confirm, ne window.confirm.

Postup: nová komponenta klientské sekce

  1. Data čtěte jen přes akci my* (identita z JWT) a neposílejte žádné userId.
  2. Cizí, neexistující i prázdný výsledek vykreslete stejně; nerozlišujte je ani hláškou.
  3. Data, která potřebuje formulář, memoizujte v komponentě a čtěte z createComponent*() i z přípravy šablony.
  4. Zápis veďte POST formulářem s addProtection(); výsledek čtěte z ploché obálky a hlášku stavte z code.

Klientské akce a ořez odpovědí#

Přihlášený klient chodí jen přes úzké akce my*. Všechny berou identitu výhradně z JWT, nemají parametr userId ani filters a bez identity vrátí prázdno bez dotazu do DB — záznam bez účtu (user_id IS NULL, zakázka bez odběratele) tak nikdy nepadne pod „uživatel 0“. Cizí, neexistující i smazaný záznam dávají tutéž odpověď jako prázdný.

Akce Vstup Ownership Klíče navíc
WorkList:myList offset, limit, computeTotalCount WorkListManager::findForUser(): uživatel → jeho odběratelé → zakázky workItemCounts (chybějící klíč = 0)
WorkItem:myItems workListId, offset, limit findForOwnedList() listy v _references (+ invoiceNumber), unreadComments
WorkItem:myDetail workItemId findOwnedItemRow(), teprve pak find() —
WorkItem:myComments workItemId, offset, limit findClientComments(); zároveň označí komentáře firmy za přečtené —
WorkItem:myCommentAdd workItemId, text addClientComment() commentId, approved (při úspěchu vždy true), retryAfter
Reservation:myList offset, limit, computeTotalCount ReservationManager::findForUser() podle user_id slots (datum a čas podle timeSlotId)
Reservation:myCancel id user_id ve WHERE podmíněného UPDATE (cancelOwn()) —

myItems vrací práce celé zakázky, ne jednoho listu. Stránkuje se až v PHP nad ověřeným a seřazeným výběrem (pořadí jako na faktuře) a totalCount je počet před ořezem.

Nové pole entity jde klientovi samo

extractAll() i extractWithReferences() vydají všechna ORM pole a sdílené entity modulu nemají #[FieldAccess]. Interní pole se proto ořezávají až v hotovém payloadu konstantou v presenteru. Kdo přidá do entity interní sloupec a konstantu nedoplní, ukáže ho klientovi na webu.

Kde Ořezaná pole
WorkListPresenter::MY_LIST_STRIP_FIELDS note, invoicerCustomerId, invoicerCustomerContactId, invoicerInvoiceTypeId
WorkItemPresenter::MY_DETAIL_STRIP_FIELDS note (pojistka, sloupec zatím neexistuje)
WorkItemPresenter::MY_SHEET_STRIP_FIELDS bill, invoicerInvoiceId, stateBeforeClosing
WorkItemPresenter::MY_COMMENT_STRIP_FIELDS ip
ReservationPresenter::MY_LIST_STRIP_FIELDS cancelToken, insertIp, gdprConsentDatetime
CompanyPresenter::PRESENTED_STRIP_FIELDS invoicerSellerId, invoicerBillingLineId, invoicerInvoiceTypeId, defaultHourlyRate, defaultVatPercents
ServicePresenter::internalActionPublicList() hourlyRate (přímo v metodě)

Záměrně se neořezává hourlyRate práce (klient vidí hodiny i cenu a sazba je jejich podíl) a userId komentáře (klientské vlákno podle něj pozná vlastní zprávy).

Postup: nové pole entity, kterou vydává klientská akce

  1. Rozhodněte, jestli ho klient smí vidět. Pokud ne, přidejte ho do strip konstanty presenteru z tabulky.
  2. Pole, které entita zatím nemá, označte v docblocku konstanty FUTURE-SAFEGUARD: `pole`. tests/Manual/Company/c7_harness.php kontroluje, že každé pole konstanty je ORM pole nebo takhle označená pojistka; jinak ho nahlásí jako překlep.
  3. Potřebuje-li klient místo interního id čitelnou hodnotu, dejte ji do vlastního klíče (vzor invoiceNumber místo invoicerInvoiceId, WorkItemPresenter::withInvoiceNumbers()).

cancelToken je schopnost, ne údaj

Kdo token zná, zruší rezervaci bez přihlášení (cancelByToken má grant pro hosta). Na stránce „Moje termíny“ by skončil v historii prohlížeče, v Referer i v cache; přihlášený klient ruší přes myCancel.

RBAC#

Celý modul má jednu skupinu privilegií Company:Company:show (vzor soukromých zpráv). Nad běžným CRUD stojí zvlášť seedované akce:

Klíč Co brání
:Admin:Company:WorkList:invoice vystavení faktury (dvě brány — modál i POST)
:Admin:Company:WorkList:archive archivaci zakázky
:Admin:Company:WorkSheet:reopen znovuotevření uzavřeného či vyfakturovaného listu
:Api:Company:WorkSheet:getAll čtení listů; bez něj se místo gridu vypíše deniedState

Obecné CRUD akce Api (get, getAll, getBy, save, updateColumns, delete, deleteAll) mají grant jen role admin. Role registrated má z modulu jen akce my*, host navíc jen veřejné čtecí akce (presented, *:publicList, availability) a book s cancelByToken. Manager metody bez ownershipu (WorkItemManager::findForSheet(), findForSheets()) jsou proto bezpečné jen na admin cestě — z klientské akce je nevolejte.

Chybějící klíč nespadne, jen vrátí prázdno

RBAC v2 je enforce pro externí i interní cestu. Nová Api akce bez naseedovaného klíče znamená prázdný grid bez jediné hlášky. Každý nový presenter tedy potřebuje i SQL seed — vzor sql/2026-08-23_03_company_rbac.sql.

Kam sáhnout#

Chci Kde
logiku firmy, galerii, hodnoticí vlákno app/UI/Api/Company/Models/Managers/CompanyManager.php
uzávěrku a fakturaci listů app/UI/Api/Company/Models/Managers/WorkListManager.php
zámky zápisu listů, ruční uzavření a otevření listu app/UI/Api/Company/Models/Managers/WorkSheetManager.php
skládání faktury app/UI/Api/Company/Models/Generators/CompanyInvoiceGenerator.php
dostupnost termínů a rezervace app/UI/Api/Company/Models/Managers/ReservationManager.php
zápisy řádků kalendáře, guardy a denní zámek app/UI/Api/Company/Models/Managers/TimeSlotManager.php
zámek prací a klientský ownership prací app/UI/Api/Company/Models/Managers/WorkItemManager.php
formulář firmy (3× SYNC + dropzone) app/UI/Admin/Company/Presenters/Traits/Forms/CompanyFormTrait.php
modál termínu a kalendářové pohledy app/UI/Admin/Company/Components/SlotModalComponent/SlotModal.php, app/UI/Admin/Company/Presenters/SchedulePresenter.php
mřížku prací app/UI/Admin/Company/Components/WorkGridComponent/
uložení zakázky, listů a prací z adminu app/UI/Admin/Company/Presenters/Traits/Forms/WorkListFormTrait.php, WorkListSheetsFormTrait.php
modál vystavení faktury app/UI/Admin/Company/Presenters/Traits/Forms/WorkListInvoiceFormTrait.php
klientské akce my* a ořez polí v odpovědích app/UI/Api/Company/Presenters/WorkItemPresenter.php, WorkListPresenter.php, ReservationPresenter.php
komponenty pro téma app/UI/Front/Company/Components/
rezervační průvodce a starší kalendář app/UI/Front/Company/Components/BookingWizardComponent/, BookingCalendarComponent/

Navazující kapitoly: Fakturace · Katalog firem · Autentizace a autorizace · CORS, rate limit a audit

Nastavení cookies