Moje firma#
Modul Company drží vlastní firmu provozovatele webu: její prezentaci, ceník,
reference, objednávkový kalendář a evidenci zakázek napojenou na
Fakturaci. Návrh a rozhodnutí D1–D19 jsou v
improvements/company-module.md.
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
(D2). 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 (D1). 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á.
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.
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 jedním bridge dotazem
(ReferenceManager::findListReferences() a spol.).
| 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 (etapa W2,
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.
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.
Stav closing je okno uzávěrky, ne fáze práce:
open/closed ──(guardy + složená, NEULOŽENÁ faktura)──▸ closing
closing ──(save OK → zápis 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
(etapa I1). Bez té migrace 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.
Komentáře práce mají vlastní tabulku#
Původní návrh (D8) počítal se sdíleným Base CommentThread v privátním režimu.
2026-08-30 se to zrušilo: 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í. Dva bezpečnostní nálezy z review 2026-08-29/30 byly přímým důsledkem
té větve.
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 (ReservationManager).
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.
Front#
Pevná id stránek (D15, 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ý |
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…). Dnes je používá jen template2 a template3.
Kdo hledá „stránku firmy“ v app/UI/Front/Company/Presenters/, nenajde ji — jsou tam
jen TimeSlot, 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.
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 |
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 |
| 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 |
| formulář firmy (3× SYNC + dropzone) | app/UI/Admin/Company/Presenters/Traits/Forms/CompanyFormTrait.php |
| mřížku prací | app/UI/Admin/Company/Components/WorkGridComponent/ |
| komponenty pro téma | app/UI/Front/Company/Components/ |
| návrh a rozhodnutí D1–D19 | improvements/company-module.md |
| etapu pracovních listů | improvements/company-worksheets-invoicing.md |
| odpojení komentářů od Base | improvements/company-work-item-comments-own-table.md |
| fakturační profil odběratele | improvements/company-customer-billing-profile.md |
Navazující kapitoly: Fakturace · Katalog firem · Autentizace a autorizace · CORS, rate limit a audit