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.

12 tabele 10 billing types MariaDB · uuid char(36) Symfony 8 · Doctrine composition spec §1.11a · §1.12 · §1.14
01

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.

service părinte · type selector identitate + nume compus
idchar(36)PK
tenant_idchar(36)FK tenant
digital_asset_idchar(36)FK digital_asset
department_idchar(36)FK department
supplier_idchar(36)FK supplierNULL
billing_typevarchar(40)type
currencyvarchar(3)
start_datedate
end_datedateNULL
invoice_start_datedate
noteslongtextNULL
supplier_keychar(36) · materialized
open_supplier_keychar(36) · materializedNULLUNIQ
created_at · updated_atdatetime

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.

1 : 1 · setup
1 : N · lunar

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.

…_fixed_feeA1
fixed_amountdec 12,2
planning_hoursdec 7,2 · null
…_hourly_flatA2
billed_ratedec 10,2
planning_hoursdec 7,2 · null
…_hourly_tieredB
cap_hoursdec 7,2
base_ratedec 10,2
over_ratedec 10,2
planning_hoursdec 7,2 · null
…_supplier_fixedC
supplier_costdec 12,2
…_supplier_fixed_plus_feeD
supplier_costdec 12,2
feedec 12,2
…_supplier_hourlyF2
cost_per_hourdec 10,2
fee_per_hourdec 10,2
…_supplier_percentageF3
fee_pctdec 6,3
…_platformPlatform
platform_pctdec 6,3
client_pctdec 6,3
E · supplier_variableE
fără setup — costul intră lunar
F1 · per_deliverableF1
fără setup — totul stă pe linii
service_pause interval auditat · date exacte
idchar(36)PK
tenant_idchar(36)FK tenant
service_idchar(36)FK service
paused_fromdate · inclusiv
paused_untildate · inclusivNULL
active_paused_fromdate · generatedUNIQ activeNULL
paused_by_idchar(36)FK user
resumed_by_idchar(36)FK userNULL
cancelled_by_id · cancelled_atFK user · datetimeNULL
pause_reason · resume_reason · cancel_reasonlongtextNULL
created_at · updated_atdatetime

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.

service_billing_pause lună complet suspendată · skip auditat
idchar(36)PK
tenant_idchar(36)FK tenant
service_idchar(36)FK serviceUNIQ+period
service_pause_idchar(36)FK pauseNULL
perioddate · ziua 1 a luniiUNIQ
reasonlongtextNULL
created_at · updated_atdatetime

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.

skip lunar
service_billing_period rândul lunar canonic
idchar(36)PK
tenant_idchar(36)FK tenant
service_idchar(36)FK serviceUNIQ+period
perioddate · ziua 1 a luniiUNIQ
statevarchar(40)
calendar_dayssmallint · 28..31NULL legacy
billable_dayssmallint · 1..calendarNULL legacy
snapshot_pricing_fixed_amountdec 12,2 · A1NULL
snapshot_pricing_supplier_costdec 12,2 · C/DNULL
snapshot_pricing_feedec 12,2 · DNULL
snapshot_pricing_billed_ratedec 10,2 · A2NULL
snapshot_pricing_cap_hoursdec 7,2 · BNULL
snapshot_pricing_base_ratedec 10,2 · BNULL
snapshot_pricing_over_ratedec 10,2 · BNULL
snapshot_billed_hoursdec 7,2 · A2/BNULL
input_supplier_costdec 12,2 · doar E · F3NULL
input_supplier_hoursdec 7,2 · doar F2NULL
input_amount_spenddec 12,2 · doar PlatformNULL
snapshot_client_amountdec 12,2 · primitivăNULL
snapshot_supplier_costdec 12,2 · primitivăNULL
snapshot_labour_costdec 12,2 · primitivăNULL
snapshot_labour_hoursdec 7,2 · perechea în ore a lui labour_costNULL
snapshot_direct_profitdec 12,2 · bucketNULL
snapshot_fee_profitdec 12,2 · bucketNULL
snapshot_labour_recoverydec 12,2 · bucketNULL
manual_client_amount_overridedec 12,2 · excepție pe lunăNULL
manual_override_reasonlongtext · motivNULL
manual_override_atdatetime · auditNULL
snapshot_gross_profitclient − supplierDB
snapshot_net_profitclient − supplier − labourDB
snapshot_recovery_netrecovery − labourDB
ready_atdatetime · îngheață facturabileleNULL
invoiced_atdatetime · îngheață labour-ulNULL
created_at · updated_atdatetime

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.

1 : N
service_cost_line ancora AP (Accounts Payable)
idchar(36)PK
tenant_idchar(36)FK tenant
billing_period_idchar(36)FK period
supplier_idchar(36) · NOT NULLFK supplier
line_typevarchar(40) · 6 valori
descriptionvarchar(255)
quantitydec 10,2 · default 1
unit_costdec 12,2NULL
unit_feedec 12,2NULL
supplier_cost_amountqty × unit_costNULL
fee_amountqty × unit_feeNULL
client_amountcost + feeNULL
statevarchar(40)
positionint
materialized_keyapp-owned keyUNIQ
created_at · updated_atdatetime

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.

Regula de ownership — evită dubla sursă de adevăr: pentru F1 liniile sunt introduse de utilizatori (specialist: supplier, qty, cost/unit · finance: fee/unit), iar starea perioadei se derivă din linii. Pentru C/D/E/F2/F3/Platform inputul stă pe perioadă, iar engine-ul materializează unica linie a lunii — nu se editează direct. A1/A2/B nu au linii de cost; orele vin din tasks/time entries. Invariant: 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ă.

Overlay la nivel de client (cazul G · §1.11b)nu e o tabelă de servicii, ci trei coloane pe brand (= clientul): billing_mode ENUM('per_service','budget') DEFAULT 'per_service' + budget DECIMAL · NULL + budget_currency VARCHAR(3) · NULL (parametrii modului; serviciile clientului-buget moștenesc moneda — regula A). billing_mode alege strategia de asamblare a facturii: per_service → Σ linii servicii · budget → o linie fixă = budget. E un nivel diferit de billing_type (ăla e per-serviciu): serviciile clientului rămân neatinse, cu tipurile lor reale, și calculează ca actuals. Variația budget − Σ snapshot_client_amount e derivată, nu stocată. Propus — ajunge în SQL la modulul Billing (M6); azi brand încă nu are aceste coloane.
02

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âmptipnullcine scriece face / când e NULL
identitate & legături
idchar(36)app PK, UUID — ca peste tot în schemă.
tenant_idchar(36) · FKapp Izolarea multi-tenant. Prezent și pe perioade și pe linii, ca filtrarea să nu ceară join-uri.
digital_asset_idchar(36) · FKuser · setup Asset-ul pe care rulează serviciul. Clientul (brand) nu se stochează — se derivă de aici (digital_asset.brand_id).
department_idchar(36) · FKuser · setup Departamentul care prestează. Grupul de departamente se derivă (department.department_group_id) — nici el nu se stochează.
supplier_idchar(36) · FKNULLuser · 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_typevarchar(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 statusderivatapp UI-ul derivă active, paused, scheduled to end sau closed pentru o dată exactă. Nu există service.status.
currencyvarchar(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_datedateuser · setup Începutul contractual al serviciului.
end_datedateNULLuser 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_datedateuser · setup Prima zi eligibilă pentru facturare — separat de start_date. Luna parțială rămâne perioadă și folosește day basis exact.
noteslongtextNULLuser Singurul text liber de pe serviciu — numele fiind compus din FK-uri, aici e locul pentru context uman.
tehnic
supplier_keychar(36) · materializedapp · 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_keychar(36) · materializedNULLapp · 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_atdatetimeapp 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âmptipnullcine scriece face / când e NULL
idchar(36)app PK, UUID.
tenant_idchar(36) · FKapp Tenantul serviciului; cascade la ștergerea tenantului.
service_idchar(36) · FKapp Serviciul suspendat. UNIQ_SERVICE_PAUSE_ACTIVE_FROM permite audit anulat, iar aplicația respinge intervalele active suprapuse sau adiacente.
paused_fromdateuser Prima zi suspendată, inclusivă.
paused_untildateNULLuser Ultima zi suspendată, inclusivă. NULL = pauză deschisă; Resume on 20 august salvează 19 august.
active_paused_fromdate · generatedNULLdb Egal cu paused_from doar pentru intervale neanulate.
paused_by_id · resumed_by_idchar(36) · FKparțialuser 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_reasonchar(36) · datetime · longtextNULLuser 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_reasonlongtextNULLuser Context opțional pentru audit.
created_at · updated_atdatetimeapp 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âmptipnullcine scriece face / când e NULL
idchar(36)engine PK, UUID.
tenant_idchar(36) · FKengine Denormalizat de pe serviciu, pentru scoping și audit fără join suplimentar.
service_idchar(36) · FKengine 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_idchar(36) · FKNULLengine 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.
perioddateengine Luna pauzei, ca prima zi a lunii. UNIQ (service_id, period) face generatorul idempotent; CHK cere ziua 1.
reasonlongtextNULLengine Snapshot al motivului din intervalul de pauză; lipsa lui nu schimbă regula de billing.
created_at · updated_atdatetimeapp 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âmptipnullcine scriece face / când e NULL
identitate
idchar(36)engine PK, UUID.
tenant_idchar(36) · FKengine Denormalizat de pe serviciu; susține indexul (tenant_id, state) — cozile de lucru („ce perioade așteaptă input?") fără join.
service_idchar(36) · FKengine 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.
perioddateengine Luna, ca prima zi a luniiCHK: DAYOFMONTH(period) = 1. E identificatorul lunii, nu o dată calendaristică oarecare.
statevarchar(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_dayssmallintNULL legacyengine 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_amountdec(12,2)NULLengine A1: suma fixă folosită în ecuația perioadei.
snapshot_pricing_supplier_cost · snapshot_pricing_feedec(12,2)NULLengine C/D: costul fix de supplier și, numai la D, fee-ul fix folosite la proratare.
snapshot_pricing_billed_ratedec(10,2)NULLengine A2: rata contractuală aplicată orelor eligibile.
snapshot_pricing_cap_hours · snapshot_pricing_base_rate · snapshot_pricing_over_ratedec(7,2) · dec(10,2)NULLengine 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_hoursdec(7,2)NULLengine 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_costdec(12,2)NULLspecialist 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_hoursdec(7,2)NULLspecialist Folosit doar la F2: orele lucrate de furnizor în lună (ratele vin din setup). Restul tipurilor: NULL.
input_amount_spenddec(12,2)NULLspecialist 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_amountdec(12,2)NULLengine 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_costdec(12,2)NULLengine 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_costdec(12,2)NULLengine Σ 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_hoursdec(7,2)NULLengine 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_profitdec(12,2)NULLengine 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_profitdec(12,2)NULLengine Bucket: fee-urile D/F1/F2/F3 — marjă curată, fără muncă în spate. 0 la restul.
snapshot_labour_recoverydec(12,2)NULLengine 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_overridedec(12,2)NULLfinance/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_reasonlongtextNULLfinance/ops Motivul obligatoriu pentru suma manuală. Trebuie să fie NULL când nu există override și completat când există.
manual_override_atdatetimeNULLapp 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_profitgenerated · storedDBDB 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_profitgenerated · storedDBDB client − supplier − labourcifra-titlu a profitabilității. (Repetă expresia lui gross fiindcă MariaDB nu lasă o coloană generată să refere alta generată.)
snapshot_recovery_netgenerated · storedDBDB 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_atdatetimeNULLengine 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_atdatetimeNULLengine 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_atdatetimeapp 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âmptipnullcine scriece face / când e NULL
identitate & legături
idchar(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_idchar(36) · FKapp / engine Denormalizat; susține indexul (tenant_id, state) — coada finance („ce linii așteaptă fee?") și picker-ul din supplier invoice.
billing_period_idchar(36) · FKapp / engine Luna căreia îi aparține linia (ON DELETE CASCADE). Prin period → service afli serviciul și billing type-ul.
supplier_idchar(36) · FKspecialist (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_typevarchar(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.
descriptionvarchar(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.
quantitydec(10,2) · default 1specialist (F1) / engine F1: bucăți de livrabil · F2: orele lunii · restul tipurilor: 1 (linia = suma lunii). CHK: quantity > 0.
unit_costdec(12,2)NULLspecialist (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_feedec(12,2)NULLfinance (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_amountdec(12,2)NULLengine = 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_amountdec(12,2)NULLengine = qty × unit_fee (la F3: fee_pct × cost) — marja liniei, sursa bucket-ului fee_profit.
client_amountdec(12,2)NULLengine = supplier_cost_amount + fee_amount — ce vede clientul pentru linia asta: o singură sumă, split-ul cost/fee nu apare niciodată pe factură.
lifecycle
statevarchar(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.
positionintapp Ordinea liniilor în cadrul perioadei — relevantă la F1, unde-s N linii.
materialized_keychar(36)NULLapp 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_atdatetimeapp Audit standard.
De ce atâtea NULL-uri? Trei motive diferite, niciodată amestecate: (1) de tip — câmpul nu aparține billing type-ului (ex. 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.
03

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 · labor

Made by society RO — Website · DMK — DMK

client_invoice = fixed_amount × billable_days / calendar_days · net = invoice − labour

Setup · service_pricing_fixed_fee

fixed_amount3.500 RON
planning_hours24 h

Lunar · service_billing_period

periodsnapshot_clientsnapshot_laboursnapshot_netstate
2024-07-013.5002.1401.360invoiced
2024-08-013.500se adună…ready
Fără input lunar și fără cost lines — luna completă folosește suma din setup, iar lunile parțiale o proratează după zile calendaristice. labour_cost se adună din time entries (hours × cost/h angajat) până la invoiced. → fluxul complet A1

Hourly — flat rate

A2 · labor

Made by society RO — Website · SEO — Linkbuilding

client_invoice = billed_rate × Σ ore (toți angajații) · net_profit = invoice − labour_cost

Setup · service_pricing_hourly_flat

billed_rate150 RON/h
planning_hours40 h

Lunar · ore din tasks → service_billing_period

periodore (time entries)snapshot_clientsnapshot_laboursnapshot_netstate
2024-07-0138,05.7002.9602.740invoiced
2024-08-0142,06.300se adună…cost_entry
O singură rată pe serviciu (nu per persoană / per task). Orele nu se introduc pe perioadă — se agregă din time entries; costul intern rămâne per angajat, din payroll. → fluxul complet A2

Hourly — tiered

B · labor

Made by society RO — Website · SEO — SEO Tehnic

client_invoice = min(ore, cap)×base + max(ore−cap, 0)×over — marginal, exact 2 benzi

Setup · service_pricing_hourly_tiered

cap_hours30 h
base_rate140 RON/h
over_rate170 RON/h
planning_hours30 h

Lunar · ore din tasks → service_billing_period

periodorecalculsnapshot_clientstate
2024-07-0128,028 × 1403.920invoiced
2024-08-0136,030 × 140 + 6 × 1705.220cost_entry
Banda a doua se aplică doar orelor de peste cap — niciodată retroactiv pe toate orele. Capul e prag de tarifare, nu limită de lucru și nu se proratează în lunile parțiale. → fluxul complet B

Supplier — fixed cost

C · pass-through

Northwind Retail — Magento · SEO — Webmastering — Genezio

client_invoice = supplier_cost · gross_profit = 0 (pass-through pur)

Setup · service_pricing_supplier_fixed

supplierGenezio
supplier_cost2.000 RON

Lunar · linie materializată de engine · service_cost_line

periodsupplier_costclient_amountprofitstate
2024-07-012.0002.0000invoiced
2024-08-012.0002.0000ready
Nimic de introdus lunar — rândul se generează. Profit 0 e legitim aici; linia de cost există totuși, ca factura furnizorului să aibă de ce se lega. → fluxul complet C

Supplier — fixed cost + fee

D

Northwind Retail — Magento · Data — Analytics — Webify

client_invoice = supplier_cost + fee (ambele fixe) · gross_profit = fee

Setup · service_pricing_supplier_fixed_plus_fee

supplierWebify
supplier_cost2.000 RON
fee400 RON

Lunar · linie materializată · service_cost_line

periodsupplier_costfeeclient_amountstate
2024-07-012.0004002.400invoiced
2024-08-012.0004002.400ready
Clientul vede o singură sumă combinată (2.400) — niciodată split-ul cost/fee. Factura e derivată, nu introdusă. → fluxul complet D

Supplier — variable cost

E · pass-through

Made by society PL — Website · SEO — Webmastering — Genezio

client_invoice = input_supplier_cost (introdus lunar) · gross_profit = 0

Setup

Fără parametri — nu există tabel de pricing. Doar supplierul pe serviciu (Genezio).

Lunar · specialistul introduce costul · input_supplier_cost

periodinput_supplier_costclient_amountstate
2024-06-011.5201.520invoiced
2024-07-011.8901.890invoiced
2024-08-011.7401.740ready
Singura diferență față de C: costul variază și cineva trebuie să-l introducă. Fluxul de stare sare peste awaiting_fee: cost_entry → ready. → fluxul complet E

Supplier — per deliverable + fee

F1 · două roluri

Made by society PL — Website · SEO — Linkbuilding  ·  fără supplier pe serviciu — unul per linie

client_invoice = Σ qty × (cost/unit + fee/unit) · gross_profit = Σ qty × fee/unit

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

descriptionsupplierqtycost/ufee/uclientstate
Article — OutreachOutreach Media4350901.760ready
Article — NativeContentbox228070700ready
Static designContentbox6120awaiting_fee
VideoWebify1cost_entry
Preview subtotal (linii ready)2.460
Singurul tip unde starea per linie e mașina reală: specialist → 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

F2

Northwind Retail — Magento · SEO — Webmastering — Webify

client_invoice = (cost/h + fee/h) × input_supplier_hours · gross_profit = fee/h × ore

Setup · service_pricing_supplier_hourly

supplierWebify
cost_per_hour100 RON
fee_per_hour50 RON

Lunar · specialistul introduce orele · input_supplier_hours

periodorecostfeeclient_amountstate
2024-07-0131,03.1001.5504.650invoiced
2024-08-0126,02.6001.3003.900ready
Ratele sunt fixate la setup, deci fluxul sare peste 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 · markup

Aurora Cosmetics — Website · PMK — Adina — Outreach Media

client_invoice = input_supplier_cost × (1 + fee_pct) · gross_profit = fee_pct × cost

Setup · service_pricing_supplier_percentage

supplierOutreach Media
fee_pct15 %

Lunar · specialistul introduce costul · input_supplier_cost

periodsupplier_costmarkup 15%client_amountstate
2024-07-013.1004653.565invoiced
2024-08-012.6003902.990ready
Tot fee-ul e marjă curată (fee_profit) — spre deosebire de Platform, unde fee-ul clientului e etichetat labour-recovery și se netează cu orele echipei. → fluxul complet F3

Platform — % of spend

hibrid · rebate + fee

Aurora Cosmetics — Website · DMK — DMK — Google Ads

client_invoice = spend × (1 + client_pct) · rebate = platform_pct × spend (profit direct, nu e cash)

Setup · service_pricing_platform

supplierGoogle Ads
platform_pct10 %
client_pct3 %

Lunar · specialistul introduce spend-ul · input_amount_spend

periodspendrebate 10%supplier_costfee client 3%client_invoicestate
2024-08-0110.0001.0009.00030010.300ready
direct_profit
1.000
rebate — nu se erodează
labour_recovery
300
fee-ul care plătește echipa
labour_cost
250
din tasks / time entries
recovery_net
+50
e client_pct dimensionat bine?
net_profit
1.050
= 1.000 + 50
Singurul hibrid — financiar de supplier plus tasks. Clientul vede spend-ul ca o singură cifră + fee-ul de 3%; rebate-ul nu e mișcare de cash (plătești net platformei, facturezi gross clientului — doar spread). → fluxul complet Platform
04

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:

PAS 1
Aplicația încarcă pricing-ul
billing_type alege entitatea de pricing 1:1; Service rămâne o entitate concretă, mapată prin compoziție.
PAS 2
Locatorul alege
#[AutowireLocator] peste serviciile tăgate — un tip nou de billing = o clasă nouă, zero modificări aici.
PAS 3
Strategia calculează
Pură: context în → BillingBreakdown afară. Fără persistență, fără side effects — testabilă pe exemplele din spec.
PAS 4
Engine-ul persistă
Scrie snapshot-urile pe perioadă, materializează 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 { … }