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, tabele 1:1 mapate prin compoziție), pauzele calendaristice exacte, ciclul lunar (perioade cu snapshot-uri) și linia de cost canonică — ancora viitoare pentru facturile de la furnizori.
Harta tabelelor
Același shape DB deja folosit la provider_resource + copiii lui: un tabel părinte cu un selector de tip și tabele-copil 1:1 pentru partea variabilă. În Doctrine se mapează prin compoziție, nu prin inheritance.
Nume compus, fără text liber: asset — grup — departament [— supplier].
Clientul se derivă din asset, grupul din departament. supplier_key normalizează
supplierul la '' sau UUID; open_supplier_key îl oglindește doar când
end_date IS NULL. Cheia unică
(tenant, asset, department, open_supplier_key) împiedică două servicii fără termen.
Intervalele bounded sunt verificate inclusiv de aplicație sub lock-ul Digital Asset-ului.
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_* 1:1 · 8 tabele-copil
Doar parametrii fixați la setup. PK = service_id. În cod, entitățile sunt rânduri subțiri: mapping Doctrine + constraints Symfony; regulile de use-case stau în servicii.
Suspendarea comercială exactă: paused_from și paused_until
sunt inclusive și opresc livrarea plus facturarea. NULL înseamnă pauză deschisă.
Statusul scheduled/paused/completed se derivă pentru data curentă; intervalele active
nu se pot suprapune sau atinge direct.
Rândul generat care explică o lună fără nicio zi facturabilă. O pauză parțială
păstrează service_billing_period și reduce billable_days;
numai acoperirea integrală produce acest skip.
UNIQ (service_id, period) face pauza idempotentă; FK-ul spre service este
ON DELETE RESTRICT, fiindcă pauza este istoric de billing.
Vocabularul canonic §1.12.1. Engine-ul scrie cele 7 primitive, inclusiv
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).
calendar_days/billable_days explică exact proratarea și
îngheață cu cele opt inputuri de calcul ale facturabilelor. Freeze-ul aparține
rândului lunar, nu Service-ului sau formularului TimeEntry: pontajele întârziate pot
actualiza labour-ul până la invoicing fără să rescrie factura ready. CHECK-uri:
period = ziua 1 a lunii,
day basis valid, inputuri ≥ 0. FK-ul spre
service este ON DELETE RESTRICT: perioadele sunt istoric.
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. materialized_key impune maximum o linie engine-made
pentru C/D/E/F2/F3/Platform per perioadă; F1 are deliverable și poate avea
mai multe linii. 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. Calendarul intersectează luna cu invoice_start_date, data finală inclusivă și pauzele exacte. O pauză parțială păstrează perioada; zero zile facturabile produce service_billing_pause. A1/C/D proratează componentele fixe; A2/B folosesc doar ore eligibile fără să prorateze ratele sau cap-ul. Generatorul pornește din luna invoice_start_date; acțiunea manuală Generate periods este ascunsă și respinsă înainte de acea lună.
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 4 tabele operaționale
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 · type selector
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 | Selectorul de tip — 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. |
| effective status | derivat | — | app | UI-ul derivă active, paused, scheduled to end sau closed pentru o dată exactă. Nu există service.status. |
| 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 = fără termen. Valoarea este ultima zi activă, inclusivă; o dată viitoare este Scheduled to end, iar Closed începe a doua zi. ServiceEndScheduler permite editarea inclusiv în ultima zi activă, dar nu reînvie un Service deja Closed. Minimul de la finalul ultimei luni facturate este impus server-side chiar dacă o pauză maschează schimbarea de coverage. O lună cu coverage schimbat se reconciliază normal. Dacă finalul este chiar ultima zi a lunii și coverage-ul nu se schimbă, luna finală se materializează numai când nu avea înainte nici ServiceBillingPeriod, nici ServiceBillingPause; o reprezentare existentă rămâne neatinsă. Vechea lună finală viitoare, dacă nu era reprezentată, nu se creează dincolo de orizontul de referință. Orice conflict produce rollback complet. |
| invoice_start_date | date | — | user · setup | Prima zi eligibilă pentru facturare — separat de start_date. Luna parțială rămâne perioadă și folosește day basis exact. |
| 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) · materialized | — | app · Service | Normalizează NULL → ''; altfel conține UUID-ul supplierului. CHK_SERVICE_SUPPLIER_KEY ține coloana sincronizată cu supplier_id și oferă cheia identității pentru verificarea intervalelor. |
| open_supplier_key | char(36) · materialized | NULL | app · Service | Oglindește supplier_key numai pentru end_date IS NULL; altfel este NULL. Unique-ul (tenant, asset, department, open_supplier_key) este safety net pentru două servicii fără termen. ServiceRepository::findOverlappingIdentity() verifică toate intervalele inclusive sub lock și exclude serviciul editat. |
| created_at · updated_at | datetime | — | app | Audit standard. |
service_pause — intervalul auditat de pauză decizie umană
Un rând = un interval exact de suspendare comercială. Ambele capete sunt inclusive; NULL la final înseamnă pauză deschisă. Toate scrierile derivate din calendar — setup, pricing cu recalculare, pause/resume/cancel, service end, generator/reconciler și recompute/reopen/close/override de perioadă — folosesc un coordinator comun. Limita exterioară deține o singură tranzacție și refuză o tranzacție DBAL externă deja activă; blochează pesimist Digital Asset-ul comun și reîncarcă o singură dată Service-ul și rândul financiar direct. Operațiile inner verifică runtime lock-ul și scope-ul. Sub MariaDB REPEATABLE READ, batch-ul capturează candidații înaintea tranzacției, deduplică și sortează asset-urile după UUID și le blochează pe toate înainte de callback, refresh sau orice consistent read. Fiecare limită exterioară îngheață setul declarat imediat după lock-uri și înainte de primul refresh, read sau callback; operațiile imbricate pot refolosi doar asset-uri din set, iar un asset omis este respins înainte de un lock, refresh sau work suplimentar care ar inversa ordinea. Batch-ul outermost îngheață setul complet sortat și deduplicat înainte de work; un batch gol îngheață un set gol și respinge asset work imbricat. Setul se elimină la orice ieșire din limita exterioară. Astfel, un snapshot stale nu poate ascunde un pause/pricing commit, iar batch-urile rămân one-flush. Emiterea facturii și fluxurile financiare F1 vor adopta același protocol când vor fi implementate.
| câmp | tip | null | cine scrie | ce face / când e NULL |
|---|---|---|---|---|
| id | char(36) | — | app | PK, UUID. |
| tenant_id | char(36) · FK | — | app | Tenantul serviciului; cascade la ștergerea tenantului. |
| service_id | char(36) · FK | — | app | Serviciul suspendat. UNIQ_SERVICE_PAUSE_ACTIVE_FROM permite audit anulat, iar aplicația respinge intervalele active suprapuse sau adiacente. |
| paused_from | date | — | user | Prima zi suspendată, inclusivă. |
| paused_until | date | NULL | user | Ultima zi suspendată, inclusivă. NULL = pauză deschisă; Resume on 20 august salvează 19 august. |
| active_paused_from | date · generated | NULL | db | Egal cu paused_from doar pentru intervale neanulate. |
| paused_by_id · resumed_by_id | char(36) · FK | parțial | user | Actorii operaționali. paused_by_id este obligatoriu. Un paused_until planificat fără resumed_by_id rămâne modificabil; după auditul reluării, o a doua reluare este interzisă. |
| cancelled_by_id · cancelled_at · cancel_reason | char(36) · datetime · longtext | NULL | user | Audit imuabil pentru o pauză greșită. Resume on aceeași dată cu paused_from înseamnă interval de zero zile și folosește anularea; o pauză anulată nu mai poate fi reluată. |
| pause_reason · resume_reason | longtext | NULL | user | Context opțional pentru audit. |
| created_at · updated_at | datetime | — | app | Audit standard. |
service_billing_pause — luna complet suspendată skip auditat
Un rând = un serviciu × o lună cu zero zile facturabile din cauza pauzei. O pauză parțială păstrează perioada.
| câmp | tip | null | cine scrie | ce face / când e NULL |
|---|---|---|---|---|
| id | char(36) | — | engine | PK, UUID. |
| tenant_id | char(36) · FK | — | engine | Denormalizat de pe serviciu, pentru scoping și audit fără join suplimentar. |
| service_id | char(36) · FK | — | engine | Serviciul pus pe pauză. FK-ul este ON DELETE RESTRICT, deci un serviciu cu pauze are istoric și se închide, nu se șterge. |
| service_pause_id | char(36) · FK | NULL | engine | Intervalul service_pause care a produs acest skip lunar. NULL rămâne permis pentru date istorice/importuri, dar rândurile noi trebuie să-l seteze. Dacă intervalul este anulat ca greșeală înainte de lock financiar, rândurile generate din el se pot elimina. |
| period | date | — | engine | Luna pauzei, ca prima zi a lunii. UNIQ (service_id, period) face generatorul idempotent; CHK cere ziua 1. |
| reason | longtext | NULL | engine | Snapshot al motivului din intervalul de pauză; lipsa lui nu schimbă regula de billing. |
| 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 RESTRICT). Împreună cu period: o singură perioadă per serviciu per lună. Un serviciu cu perioade este istoric și se închide, nu se șterge. |
| 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. |
| calendar_days · billable_days | smallint | NULL legacy | engine | Baza exactă a proratării, de exemplu 17/31. Îngheață cu sumele facturabile; NULL/NULL este permis numai pentru perioade istorice calculate înaintea regulii. |
| inputurile de pricing ale calculului — snapshot deținut de perioadă | ||||
| snapshot_pricing_fixed_amount | dec(12,2) | NULL | engine | A1: suma fixă folosită în ecuația perioadei. |
| snapshot_pricing_supplier_cost · snapshot_pricing_fee | dec(12,2) | NULL | engine | C/D: costul fix de supplier și, numai la D, fee-ul fix folosite la proratare. |
| snapshot_pricing_billed_rate | dec(10,2) | NULL | engine | A2: rata contractuală aplicată orelor eligibile. |
| snapshot_pricing_cap_hours · snapshot_pricing_base_rate · snapshot_pricing_over_rate | dec(7,2) · dec(10,2) | NULL | engine | B: cap-ul și cele două rate marginale. Inputurile se rescriu la un calcul writable sau reopen explicit, îngheață la ready și nu sunt atinse de refresh-ul labour. Lipsa lor pe un rând legacy facturat produce Historical calculation, nu o formulă din default-uri curente. |
| snapshot_billed_hours | dec(7,2) | NULL | engine | A2/B: orele care au produs client amount-ul. Îngheață cu billables la ready; redeschiderea explicită le poate recalcula. Sunt distincte de snapshot_labour_hours, care continuă să urmărească perechea cost-side până la invoiced. |
| 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. După ready poate diferi de snapshot_billed_hours; UI-ul păstrează ecuația din billed hours și arată separat ultimul total eligibil. |
| 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. |
| ajustare manuală de client — excepție pe o singură perioadă | ||||
| manual_client_amount_override | dec(12,2) | NULL | finance/ops | Suma clientului pentru o excepție punctuală pe o perioadă ready, dar nefacturată. Nu schimbă service_pricing_fixed_fee.fixed_amount; engine-ul o folosește ca input doar pentru perioada respectivă și scrie rezultatul în snapshot_client_amount. Tranzacția blochează pesimist Digital Asset-ul comun, reîncarcă Service-ul și perioada, reverifică starea financiară și folosește coverage-ul canonic al pauzelor înainte de commit. |
| manual_override_reason | longtext | NULL | finance/ops | Motivul obligatoriu pentru suma manuală. Trebuie să fie NULL când nu există override și completat când există. |
| manual_override_at | datetime | NULL | app | Timestamp-ul auditabil al ajustării manuale. Împreună cu suma și motivul formează perechea de explicație pentru snapshot-ul diferit de setup. |
| 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: engine-ul rotunjește o singură dată la persistența snapshot-urilor și a sumelor liniilor; AR copiază valorile persistate, iar TVA se rotunjește la Issue | ||||
| 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. |
| materialized_key | char(36) | NULL | app | billing_period_id pentru liniile materializate de engine; NULL pentru deliverable. Nu există trigger DB: entitatea setează cheia, iar CHECK + UNIQUE permit multe linii F1, dar maximum o linie materializată pentru C/D/E/F2/F3/Platform per perioadă. |
| 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. În aplicație, crearea pornește cu un selector de billing type și continuă cu un formular dedicat tipului ales; câmpurile celorlalte tipuri nu sunt randate și fluxul funcționează fără JavaScript.
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 | ready |
| Article — Native | Contentbox | 2 | 280 | 70 | 700 | ready |
| Static design | Contentbox | 6 | 120 | — | — | awaiting_fee |
| Video | Webify | 1 | — | — | — | cost_entry |
| Preview subtotal (linii ready) | 2.460 | |||||
cost_entry,
finance → awaiting_fee → ready, cu „send back" posibil. Editarea costului resetează linia.
Subtotalul ready este doar preview: perioada intră în factura clientului numai când
toate liniile rămase sunt ready, apoi toate devin invoiced împreună. 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 alege entitatea de pricing 1:1; Service rămâne o entitate concretă, mapată prin compoziție.#[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 metode de domeniu.// service.billing_type = 'supplier_hourly' → F2 $service = $services->find($id); // Service concret $pricing = $pricingResolver->for($service); // pricing entity matching billing_type $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 { … }