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
- 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.
- Vstupní guard přidejte do
guardInvoiceEntry()neboguardSelectedSheets(). Sdílí je fakturace i náhledpreviewInvoiceSheets()— jinak modál slíbí fakturu, kterou fakturace odmítne. - Pravidlo výběru prací měňte jen ve
WorkItemManager::findBillableForSheets(); čte ho modál, krok 3 i kontrola v kroku 6. - Každé selhání za krokem 5 musí listy vrátit
revertClosing(), jinak zůstanou zamčené vclosingbez 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
- Najděte v logu kanálu
companyzáznam záměru z kroku 7 a případně číslo uložené faktury. - Ověřte v Invoiceru, jestli doklad vznikl.
- Nevznikl: akci zopakujte (první pokus listy už vrátil, nebo je vrátí).
- Vznikl: akci neopakujte. V DB nastavte všem dotčeným listům
invoicer_invoice_id = <id>astate = '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()):
- Uloží se zakázka (
WorkListManager::save()). Když selže, nic dalšího neproběhne. persistSheetsAndItems()projde POST polesheets[<id>]— surový POST, žádné Nette kontroly. U archivované zakázky se přeskočí celé.- Každý list cíleně:
updateColumns()jen se změněnýminame,bill,order. - 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čí. - 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:
invoicePreview!(AJAX) dostane zaškrtnuté listy jakosheetIds[]a naplní modál read-only náhledem. Při selhání se nevykreslí žádná tabulka, jen chyba — rozhodujeisSuccess(), ne obsahsheets.- Potvrzení je skutečný formulář
invoiceConfirmForms plnou navigací (bezajax), protože vystavení mění skoro celou stránku. Vybrané listy jedou jako prosté<input type="hidden" name="sheetIds[]">a čtou se z POSTu. - 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.
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:
- Na začátku rozsahu žádný řádek není → založí se nosič.
- Existuje volný
slotse stejným rozsahem (okno přepisu nebo nosič po zrušené rezervaci) → použije se. - 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:
- Předběžná kontrola volna přes Doctrine (mimo zámek, jen rychlé odmítnutí).
acquireSourceLock(); nepovede se →DB_LOCKEDs hláškou „zkuste znovu“.- 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. - Zápis nosiče a rezervace po téže DBAL connection, na které je zámek.
- 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ů.
Modál termínu#
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/datestránky (Nette prefixuje argumenty jménem komponenty). Modál je dostává parametry signálu, drží je v hidden polích aSchedulePresentersi je bere zpět přesSlotModal::getViewContext(). Bez tohorenderDay()po kliku překreslí jiný den. - Žádné
setRequired(). Formulář má víc tlačítek; HTML5requiredna 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.
slotFormse sestaví líně v módufilluž při zpracování POSTu; přepnutí módu potom formulář nezmění a šablona sáhne po neexistujícímtimeSlotId→ 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“:
- Vítězí nejdřív začínající aktivní rezervace; ostatní se zruší bez e-mailu
(
cancelInternal()) a jejich řádky pohltíTimeSlotManager::absorbRow(). - 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. - Jména a poznámky se spojí přes
' + ', e-mail a telefon zůstanou vítěze. - Stav je
confirmed, jen když byly potvrzené všechny sloučené rezervace, jinakpending. - 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
- Přidejte konstantu hodnoty do přepínače (
fillActionnebotimeAction) a pole doadd*Fields()příslušného módu — bezsetRequired(). - Zápis veďte přes Admin manager → bridge → Api úzkou akci, která si bere denní zámek.
- V handleru zavolejte
requirePrivilege(),hydrateContext()a ověření, že slot a kalendář patří firmě (requireSlotInCompany()/requireCalendarInCompany()). - Po zápisu znovu načtěte stav a skončete
finish()nebofailAndReopen(), neredirect().
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
- Zapisujte přes
TimeSlotManager::save()/delete(), nebo přidejte úzkou metodu, která práci obalíwithDayLocks([self::dayLockName(…)]). - Jméno zámku skládejte z uloženého řádku (
findRowRaw(), z cizího managerufindLockContext()), ne z parametrů. Pod zámkem řádek přečtěte znovu a ověřte, že je pořád na zamčeném dni (vzorguardLockedDay()). - Mění-li zápis den z výchozího režimu na přepis, nejdřív zhmotněte výchozí okna
(
materializeDefaultWindows(), vzoropenWindow()). - Po syrovém UPDATE zavolejte
bumpCacheTagsForClass(TimeSlot::class)— obecné shození cache zeSaveTraittuhle cestu nepokrývá. - 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. calendarIdz URL se nevěří. Platné jsou jen hodnoty z klíčecalendarsv odpovědiavailability(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
leadTimeHoursse počítá v PHP, šablona dostane hotový příznakbookable. Klepnutý termín(from, to)se ověří protistartsspočítaným na serveru; neplatná dvojice modál neotevře. - Kreslí se jen
starts. Veřejná dostupnost nenese identitu cizích rezervací, blokybusy/confirmed/pendingdo š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řívcreateComponent*()arender()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
slotLengthMinutesaž domaxTo; dvojici(from, to)ověřuje znovubook()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řesnormalizeResultEnvelope(), ne přesfailSoftRead()—success: false(obsazeno, limit) je smysl volání. Formulář překládácodemapouERROR_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ýsledkumyLista práce bere zmyItems. 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řesbcadd, ne šablona. Práce bez listu v_referencesse 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řesdata-grid-confirm, newindow.confirm.
Postup: nová komponenta klientské sekce
- Data čtěte jen přes akci
my*(identita z JWT) a neposílejte žádnéuserId. - Cizí, neexistující i prázdný výsledek vykreslete stejně; nerozlišujte je ani hláškou.
- Data, která potřebuje formulář, memoizujte v komponentě a čtěte z
createComponent*()i z přípravy šablony. - Zápis veďte POST formulářem s
addProtection(); výsledek čtěte z ploché obálky a hlášku stavte zcode.
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
- Rozhodněte, jestli ho klient smí vidět. Pokud ne, přidejte ho do strip konstanty presenteru z tabulky.
- Pole, které entita zatím nemá, označte v docblocku konstanty
FUTURE-SAFEGUARD: `pole`.tests/Manual/Company/c7_harness.phpkontroluje, že každé pole konstanty je ORM pole nebo takhle označená pojistka; jinak ho nahlásí jako překlep. - Potřebuje-li klient místo interního id čitelnou hodnotu, dejte ji do vlastního klíče
(vzor
invoiceNumbermístoinvoicerInvoiceId,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