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. 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.

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 (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