Agency Flow · Propunere de schemă
Serviciile & motorul de billing
Un serviciu = o unitate de pricing cu exact un billing type. Schema separă identitatea (comună), parametrii de setup (per tip, class-table inheritance), ciclul lunar (perioade cu snapshot-uri) și linia de cost canonică — ancora viitoare pentru facturile de la furnizori.
Harta tabelelor
Același pattern deja folosit în schema existentă la provider_resource + copiii lui: un tabel părinte cu discriminator, tabele-copil 1:1 pentru partea variabilă.
Nume compus, fără text liber: asset — grup — departament [— supplier].
Clientul se derivă din asset, grupul din departament. Cheia unică
(tenant, asset, department, supplier_key) folosește coloana generată
COALESCE(supplier_id,'') — altfel NULL-urile ar permite duplicate.
supplier_id e completat doar pentru C/D/E/F2/F3/Platform; A1/A2/B și F1 îl lasă NULL —
regulă impusă chiar în DB prin CHK_SERVICE_SUPPLIER_BY_TYPE (cele 6 tipuri
single-supplier trebuie să-l aibă, restul nu au voie), plus CHK_SERVICE_DATES
pe intervalul start/end. Fiecare câmp, explicat rând cu rând → secțiunea 02.
service_pricing_* CTI · 8 tabele-copil
Doar parametrii fixați la setup. PK = service_id. Un tip nou = un tabel nou, nimic de modificat.
Vocabularul canonic §1.12.1. Engine-ul scrie doar cele 6 primitive (plus
snapshot_labour_hours, perechea în ore a lui labour_cost);
gross / net / recovery_net sunt coloane
generate de DB — imposibil de desincronizat. Înghețarea e ancorată în
ready_at (sumele facturabile) și invoiced_at (labour_cost
și labour_hours, împreună; time entries sosesc cu întârziere). NULL pe un
snapshot = „încă necalculat", distinct de 0 (pass-through legitim la C/E).
CHECK-uri: period = ziua 1 a lunii, inputuri ≥ 0.
Aici va pointa supplier_invoice_line.service_cost_line_id (§1.14) —
un singur FK, indiferent de billing type. NULL-urile de aici sunt de lifecycle,
nu de tip: unit_cost NULL = linia e în cost_entry,
unit_fee NULL = în awaiting_fee; totul devine obligatoriu la
ready. CHECK-uri: quantity > 0, unit-uri ≥ 0.
snapshot_supplier_cost = Σ supplier_cost_amount.
Doar F1 trece prin awaiting_fee (fee per linie, lunar). E/F2/F3/Platform sar direct la ready. A1 se naște direct ready — suma e constantă; A2/B se nasc cost_entry (orele se adună din pontaje) și devin ready la închiderea lunii, când orele sunt finale. Editarea unui cost resetează linia la cost_entry; o linie invoiced se corectează doar prin storno. Pentru lunile în care serviciul e paused nu se generează rând deloc — absența rândului = nefacturabil.
budget − Σ snapshot_client_amount
e derivată, nu stocată. Propus — ajunge în SQL la modulul Billing (M6); azi brand încă nu are aceste coloane.
Dicționarul câmpurilor — cele 3 tabele mari
Toate coloanele, rând cu rând: cine scrie câmpul, la ce billing types e folosit și ce înseamnă acolo NULL. Convenția întregii scheme: NULL = „necalculat / nu se aplică (încă)", iar 0 = „calculat, și rezultatul e zero" — nu sunt interschimbabile (profitul 0 la C/E e legitim, nu lipsă de date).
service — identitatea și contractul părinte · discriminator
Un rând = o unitate de pricing cu exact un billing type. Numele NU e un câmp — e compus din FK-uri: asset — grup — departament [— supplier].
| câmp | tip | null | cine scrie | ce face / când e NULL |
|---|---|---|---|---|
| identitate & legături | ||||
| id | char(36) | — | app | PK, UUID — ca peste tot în schemă. |
| tenant_id | char(36) · FK | — | app | Izolarea multi-tenant. Prezent și pe perioade și pe linii, ca filtrarea să nu ceară join-uri. |
| digital_asset_id | char(36) · FK | — | user · setup | Asset-ul pe care rulează serviciul. Clientul (brand) nu se stochează — se derivă de aici (digital_asset.brand_id). |
| department_id | char(36) · FK | — | user · setup | Departamentul care prestează. Grupul de departamente se derivă (department.department_group_id) — nici el nu se stochează. |
| supplier_id | char(36) · FK | NULL | user · setup | Completat DOAR la C/D/E/F2/F3/Platform (cele 6 tipuri single-supplier). NULL obligatoriu la A1/A2/B (labor — nu există furnizor) și la F1 (furnizorul stă pe fiecare linie de cost, nu pe serviciu). NULL-ul aici e regulă, nu accident — impus în DB de CHK_SERVICE_SUPPLIER_BY_TYPE. |
| contract | ||||
| billing_type | varchar(40) | — | user · setup | Discriminatorul CTI — alege tabelul de pricing și strategia de calcul din cod. 10 valori: fixed_fee (A1) · hourly_flat (A2) · hourly_tiered (B) · supplier_fixed (C) · supplier_fixed_plus_fee (D) · supplier_variable (E) · per_deliverable (F1) · supplier_hourly (F2) · supplier_percentage (F3) · platform_pct (Platform). Un serviciu = exact un billing type. |
| status | varchar(40) | — | user | Lifecycle-ul serviciului. Efect direct pe billing: pentru lunile în care serviciul e paused nu se generează perioadă deloc — absența rândului = nefacturabil (fără rânduri cu zerouri). |
| currency | varchar(3) | — | user · setup | RON sau EUR. O factură are o singură monedă — serviciile facturate împreună trebuie s-o aibă pe a facturii. La clienții cu buget (cazul G), serviciile moștenesc budget_currency (regula A). |
| start_date | date | — | user · setup | Începutul contractual al serviciului. |
| end_date | date | NULL | user | NULL = serviciu fără termen (rulează până e oprit). Când e setat: CHK_SERVICE_DATES cere end ≥ start. |
| invoice_start_date | date | — | user · setup | Prima lună care se facturează — separat de start_date, ca începutul contractual și începutul facturării să poată diferi. |
| notes | longtext | NULL | user | Singurul text liber de pe serviciu — numele fiind compus din FK-uri, aici e locul pentru context uman. |
| tehnic | ||||
| supplier_key | char(36) · generated stored | — | DB | COALESCE(supplier_id,'') — normalizează NULL → '' pentru cheia unică compusă (tenant, asset, department, supplier_key). Fără ea, NULL-urile nu se compară în unique index și serviciile fără furnizor s-ar putea duplica. Nu se scrie niciodată din aplicație. |
| created_at · updated_at | datetime | — | app | Audit standard. |
service_billing_period — rândul lunar canonic 1 : N · lunar
Un rând = un serviciu × o lună (UNIQ (service_id, period)). Aici se sursează linia de factură client și raportul de profitabilitate — pentru toate tipurile, inclusiv A1/A2/B.
| câmp | tip | null | cine scrie | ce face / când e NULL |
|---|---|---|---|---|
| identitate | ||||
| id | char(36) | — | engine | PK, UUID. |
| tenant_id | char(36) · FK | — | engine | Denormalizat de pe serviciu; susține indexul (tenant_id, state) — cozile de lucru („ce perioade așteaptă input?") fără join. |
| service_id | char(36) · FK | — | engine | Serviciul părinte (ON DELETE CASCADE). Împreună cu period: o singură perioadă per serviciu per lună. |
| period | date | — | engine | Luna, ca prima zi a lunii — CHK: DAYOFMONTH(period) = 1. E identificatorul lunii, nu o dată calendaristică oarecare. |
| state | varchar(40) | — | engine | Lifecycle: cost_entry → awaiting_fee → ready → invoiced. Per tip: A1 se naște direct ready (suma e constantă); A2/B se nasc cost_entry și devin ready la închiderea lunii, când orele sunt finale; E/F2/F3/Platform: cost_entry → ready (sar peste awaiting_fee); F1: starea perioadei e derivată din liniile ei — o recalculează exclusiv engine-ul la fiecare tranziție de linie. |
| inputul lunar — exact unul per tip, restul rămân NULL | ||||
| input_supplier_cost | dec(12,2) | NULL | specialist | Folosit doar la E și F3: costul lunar real al furnizorului. La orice alt tip rămâne NULL — whitelist de engine: scrierea unui input care nu aparține tipului e eroare, nu ignorare silențioasă. CHK ≥ 0. |
| input_supplier_hours | dec(7,2) | NULL | specialist | Folosit doar la F2: orele lucrate de furnizor în lună (ratele vin din setup). Restul tipurilor: NULL. |
| input_amount_spend | dec(12,2) | NULL | specialist | Folosit doar la Platform: spend-ul lunii pe platformă. Atenție la vocabular (§1.12.1): spend-ul nu e cost și nu e venit — doar trece prin factură. C/D, F1 și A1/A2/B nu au niciun input aici: la C/D sumele cad din setup, la F1 inputul stă pe linii, la A1/A2/B orele vin din time entries. |
| snapshot-urile primitive — le scrie engine-ul · NULL = necalculat, 0 = calculat și e zero | ||||
| snapshot_client_amount | dec(12,2) | NULL | engine | Ce facturăm clientului pe luna asta — singura cifră pe care o vede el; de aici se copiază linia de factură (nimic nu se recalculează la facturare). Îngheață la ready_at. |
| snapshot_supplier_cost | dec(12,2) | NULL | engine | Banii care ies real spre furnizori. 0 explicit (nu NULL!) la A1/A2/B. Invariant verificabil: = Σ supplier_cost_amount al liniilor perioadei. La Platform: spend − rebate. Îngheață la ready_at. |
| snapshot_labour_cost | dec(12,2) | NULL | engine | Σ ore × cost/h per angajat (din payroll, cu rata efectivă la data pontajului). 0 la tipurile fără tasks (C–F3). Se recalculează până la invoiced_at — time entries sosesc cu întârziere — și abia atunci îngheață. |
| snapshot_labour_hours | dec(7,2) | NULL | engine | Perechea în ore a lui labour_cost: orele agregate care l-au produs. Îngheață împreună cu el, la invoiced_at. |
| snapshot_direct_profit | dec(12,2) | NULL | engine | Bucket §1.12.1: rebate-ul de platformă (platform_pct × spend) — profit care nu se erodează cu orele. 0 la toate tipurile fără rebate. |
| snapshot_fee_profit | dec(12,2) | NULL | engine | Bucket: fee-urile D/F1/F2/F3 — marjă curată, fără muncă în spate. 0 la restul. |
| snapshot_labour_recovery | dec(12,2) | NULL | engine | Bucket: fee-ul de Platform către client (client_pct × spend) — banii meniți să acopere munca echipei. 0 în afara Platform. |
| derivatele — coloane GENERATE de DB, imposibil de desincronizat | ||||
| snapshot_gross_profit | generated · stored | DB | DB | client_amount − supplier_cost — spread-ul de cash, înainte de muncă. Nu se scrie niciodată din cod; urmează automat înghețarea coloanelor de bază. |
| snapshot_net_profit | generated · stored | DB | DB | client − supplier − labour — cifra-titlu a profitabilității. (Repetă expresia lui gross fiindcă MariaDB nu lasă o coloană generată să refere alta generată.) |
| snapshot_recovery_net | generated · stored | DB | DB | labour_recovery − labour_cost — „a ajuns fee-ul de Platform să acopere echipa?" KPI-ul de renegociere a lui client_pct. |
| ancorele de înghețare — NULL = încă editabil | ||||
| ready_at | datetime | NULL | engine | NULL = sumele facturabile sunt încă recalculabile (editarea setup-ului le împrospătează). Setat = client_amount, supplier_cost și bucket-urile sunt înghețate — editarea setup-ului nu mai atinge luna; redeschidere doar explicită. |
| invoiced_at | datetime | NULL | engine | Setat la emiterea facturii: labour_cost + labour_hours îngheață abia acum (nu la ready — pontajele sosesc târziu). După: corecții doar prin storno. |
| created_at · updated_at | datetime | — | app | Audit standard. |
service_cost_line — linia de cost canonică ancora AP
Un rând = o linie de cost de furnizor într-o lună. A1/A2/B nu au niciun rând aici. F1: N linii/lună introduse de oameni; C/D/E/F2/F3/Platform: exact 1 linie/lună, materializată de engine (nu se editează direct — starea oglindește perioada).
| câmp | tip | null | cine scrie | ce face / când e NULL |
|---|---|---|---|---|
| identitate & legături | ||||
| id | char(36) | — | app / engine | PK, UUID. Aici va pointa supplier_invoice_line.service_cost_line_id (§1.14) — un singur FK indiferent de billing type, fără legături polimorfice. |
| tenant_id | char(36) · FK | — | app / engine | Denormalizat; susține indexul (tenant_id, state) — coada finance („ce linii așteaptă fee?") și picker-ul din supplier invoice. |
| billing_period_id | char(36) · FK | — | app / engine | Luna căreia îi aparține linia (ON DELETE CASCADE). Prin period → service afli serviciul și billing type-ul. |
| supplier_id | char(36) · FK | — | specialist (F1) / engine | NOT NULL — spre deosebire de service.supplier_id: orice linie de cost are furnizor. La F1 fiecare linie își are propriul supplier (un serviciu poate traversa mai mulți); la C/D/E/F2/F3/Platform engine-ul îl copiază de pe serviciu. |
| conținutul liniei | ||||
| line_type | varchar(40) | — | app / engine | 6 valori, câte una per familie: fixed_supplier (C, D) · variable_supplier (E) · deliverable (F1) · supplier_hours (F2) · supplier_percentage (F3) · platform_spend (Platform). Denormalizare deliberată pentru picker-ul din supplier invoice — tipul e derivabil și prin period → service.billing_type. |
| description | varchar(255) | — | specialist (F1) / engine | Eticheta liniei; la F1 e numele livrabilului („Article — Outreach"). Un deliverable_type ca FK e omis deliberat (YAGNI — item 25 în spec): labelul liber stă aici, lookup-ul cu prețuri se adaugă mai târziu fără migrare de date. |
| quantity | dec(10,2) · default 1 | — | specialist (F1) / engine | F1: bucăți de livrabil · F2: orele lunii · restul tipurilor: 1 (linia = suma lunii). CHK: quantity > 0. |
| unit_cost | dec(12,2) | NULL | specialist (F1) / engine | Costul per unitate. NULL-ul e de lifecycle, nu de tip: NULL = linia e în cost_entry (specialistul n-a terminat introducerea). Obligatoriu completat până la ready — regulă de engine. CHK ≥ 0. |
| unit_fee | dec(12,2) | NULL | finance (F1) / engine | Fee-ul per unitate. La F1: NULL = linia e în awaiting_fee (finance n-a pus fee-ul). La D/F2 vine din setup. Excepția documentată: la F3 rămâne NULL și după ready — fee_amount se derivă din procentul de setup, nu din unit_fee. CHK ≥ 0. |
| sumele — NULL = încă necalculabile; fără rotunjiri intermediare (se rotunjește o singură dată, la facturare) | ||||
| supplier_cost_amount | dec(12,2) | NULL | engine | = qty × unit_cost — banii care ies spre furnizor pe linia asta. La Platform: spend − rebate (netul plătit platformei). Ținta reconcilierii AP: supplier_invoice_line.amount ↔ acest câmp (diferența = discuție cu furnizorul, nu rescriere de istorie). |
| fee_amount | dec(12,2) | NULL | engine | = qty × unit_fee (la F3: fee_pct × cost) — marja liniei, sursa bucket-ului fee_profit. |
| client_amount | dec(12,2) | NULL | engine | = supplier_cost_amount + fee_amount — ce vede clientul pentru linia asta: o singură sumă, split-ul cost/fee nu apare niciodată pe factură. |
| lifecycle | ||||
| state | varchar(40) | — | engine | La F1 e mașina de stări reală: cost_entry → awaiting_fee → ready → invoiced, cu „send back" posibil; editarea unui cost resetează linia la cost_entry. La restul tipurilor linia doar oglindește starea perioadei — nu se editează direct. invoiced = Locked; corecții doar prin storno. |
| position | int | — | app | Ordinea liniilor în cadrul perioadei — relevantă la F1, unde-s N linii. |
| created_at · updated_at | datetime | — | app | Audit standard. |
input_amount_spend la un serviciu E);
(2) de lifecycle — valoarea nu există încă (ex. unit_fee înainte de awaiting_fee, snapshot-urile înainte de calcul);
(3) de domeniu — „fără termen" (end_date), „fără furnizor pe serviciu" (supplier_id la F1).
Iar 0 nu e niciodată NULL: 0 = calculat și e zero (profitul la C/E), NULL = necalculat.
Cum arată datele, per billing type
Cifrele sunt cele din prototipuri. Fiecare exemplu arată ce stă în setup, ce se introduce lunar și ce îngheață pe perioadă. Fiecare tip are și un flux narativ pas-cu-pas — linkul e la finalul fiecărui panou.
Fixed fee
A1 · laborMade by society RO — Website · DMK — DMK
Setup · service_pricing_fixed_fee
Lunar · service_billing_period
| period | snapshot_client | snapshot_labour | snapshot_net | state |
|---|---|---|---|---|
| 2024-07-01 | 3.500 | 2.140 | 1.360 | invoiced |
| 2024-08-01 | 3.500 | se adună… | — | ready |
hours × cost/h angajat) până la invoiced.
→ fluxul complet A1
Hourly — flat rate
A2 · laborMade by society RO — Website · SEO — Linkbuilding
Setup · service_pricing_hourly_flat
Lunar · ore din tasks → service_billing_period
| period | ore (time entries) | snapshot_client | snapshot_labour | snapshot_net | state |
|---|---|---|---|---|---|
| 2024-07-01 | 38,0 | 5.700 | 2.960 | 2.740 | invoiced |
| 2024-08-01 | 42,0 | 6.300 | se adună… | — | cost_entry |
Hourly — tiered
B · laborMade by society RO — Website · SEO — SEO Tehnic
Setup · service_pricing_hourly_tiered
Lunar · ore din tasks → service_billing_period
| period | ore | calcul | snapshot_client | state |
|---|---|---|---|---|
| 2024-07-01 | 28,0 | 28 × 140 | 3.920 | invoiced |
| 2024-08-01 | 36,0 | 30 × 140 + 6 × 170 | 5.220 | cost_entry |
Supplier — fixed cost
C · pass-throughNorthwind Retail — Magento · SEO — Webmastering — Genezio
Setup · service_pricing_supplier_fixed
Lunar · linie materializată de engine · service_cost_line
| period | supplier_cost | client_amount | profit | state |
|---|---|---|---|---|
| 2024-07-01 | 2.000 | 2.000 | 0 | invoiced |
| 2024-08-01 | 2.000 | 2.000 | 0 | ready |
Supplier — fixed cost + fee
DNorthwind Retail — Magento · Data — Analytics — Webify
Setup · service_pricing_supplier_fixed_plus_fee
Lunar · linie materializată · service_cost_line
| period | supplier_cost | fee | client_amount | state |
|---|---|---|---|---|
| 2024-07-01 | 2.000 | 400 | 2.400 | invoiced |
| 2024-08-01 | 2.000 | 400 | 2.400 | ready |
Supplier — variable cost
E · pass-throughMade by society PL — Website · SEO — Webmastering — Genezio
Setup
Fără parametri — nu există tabel de pricing. Doar supplierul pe serviciu (Genezio).
Lunar · specialistul introduce costul · input_supplier_cost
| period | input_supplier_cost | client_amount | state |
|---|---|---|---|
| 2024-06-01 | 1.520 | 1.520 | invoiced |
| 2024-07-01 | 1.890 | 1.890 | invoiced |
| 2024-08-01 | 1.740 | 1.740 | ready |
awaiting_fee: cost_entry → ready.
→ fluxul complet E
Supplier — per deliverable + fee
F1 · două roluriMade by society PL — Website · SEO — Linkbuilding · fără supplier pe serviciu — unul per linie
Setup
Fără parametri. Specialistul introduce liniile (supplier, qty, cost), finance pune fee-ul — lunar, per linie.
Lunar · linii introduse de utilizatori · service_cost_line · August 2024
| description | supplier | qty | cost/u | fee/u | client | state |
|---|---|---|---|---|---|---|
| Article — Outreach | Outreach Media | 4 | 350 | 90 | 1.760 | invoiced |
| Article — Native | Contentbox | 2 | 280 | 70 | 700 | ready |
| Static design | Contentbox | 6 | 120 | — | — | awaiting_fee |
| Video | Webify | 1 | — | — | — | cost_entry |
| Invoiceable (ready + invoiced) | 2.460 | |||||
cost_entry,
finance → awaiting_fee → ready, cu „send back" posibil. Editarea costului resetează linia.
În factura clientului intră doar liniile ready + invoiced. Un serviciu F1 poate traversa mai mulți supplieri.
→ fluxul complet F1
Supplier — cost/h + fee/h
F2Northwind Retail — Magento · SEO — Webmastering — Webify
Setup · service_pricing_supplier_hourly
Lunar · specialistul introduce orele · input_supplier_hours
| period | ore | cost | fee | client_amount | state |
|---|---|---|---|---|---|
| 2024-07-01 | 31,0 | 3.100 | 1.550 | 4.650 | invoiced |
| 2024-08-01 | 26,0 | 2.600 | 1.300 | 3.900 | ready |
awaiting_fee.
Linia de cost a lunii (qty = ore, unit_cost/unit_fee din setup) e materializată de engine.
→ fluxul complet F2
Supplier — % of cost
F3 · markupAurora Cosmetics — Website · PMK — Adina — Outreach Media
Setup · service_pricing_supplier_percentage
Lunar · specialistul introduce costul · input_supplier_cost
| period | supplier_cost | markup 15% | client_amount | state |
|---|---|---|---|---|
| 2024-07-01 | 3.100 | 465 | 3.565 | invoiced |
| 2024-08-01 | 2.600 | 390 | 2.990 | ready |
Platform — % of spend
hibrid · rebate + feeAurora Cosmetics — Website · DMK — DMK — Google Ads
Setup · service_pricing_platform
Lunar · specialistul introduce spend-ul · input_amount_spend
| period | spend | rebate 10% | supplier_cost | fee client 3% | client_invoice | state |
|---|---|---|---|---|---|---|
| 2024-08-01 | 10.000 | 1.000 | 9.000 | 300 | 10.300 | ready |
Din rândul de DB, în strategie
Schema doar păstrează datele — calculul stă în cod, câte o strategie per billing type. Fluxul, de la hidratare la snapshot:
billing_type și instanțiază subclasa — Service vine gata cu copilul de pricing atașat.#[AutowireLocator] peste serviciile tăgate — un tip nou de billing = o clasă nouă, zero modificări aici.BillingBreakdown afară. Fără persistență, fără side effects — testabilă pe exemplele din spec.service_cost_line, mișcă stările prin workflow.// service.billing_type = 'supplier_hourly' → F2 $service = $services->find($id); // Doctrine CTI: Service + pricing-ul lui $strategy = $this->strategies->get($service->billingType->value); $breakdown = $strategy->calculate($ctx); // pur: (setup, inputuri, labour) → sume $engine->apply($period, $breakdown); // snapshot + cost lines + tranziții de stare // strategiile se înregistrează singure: #[AsTaggedItem(index: 'supplier_hourly')] final class SupplierHourlyStrategy implements BillingStrategy { … }