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.

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

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

service părinte · discriminator 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)discriminator
statusvarchar(40)
currencyvarchar(3)
start_datedate
end_datedateNULL
invoice_start_datedate
noteslongtextNULL
supplier_keygenerated · storedUNIQ
created_at · updated_atdatetime

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.

1 : 1 · setup
1 : N · lunar

service_pricing_* CTI · 8 tabele-copil

Doar parametrii fixați la setup. PK = service_id. Un tip nou = un tabel nou, nimic de modificat.

…_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_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)
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
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 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.

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
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. 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. Pentru lunile în care serviciul e paused nu se generează rând deloc — absența rândului = nefacturabil.

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 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â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 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.
statusvarchar(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).
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 = serviciu fără termen (rulează până e oprit). Când e setat: CHK_SERVICE_DATES cere end ≥ start.
invoice_start_datedateuser · setup Prima lună care se facturează — separat de start_date, ca începutul contractual și începutul facturării să poată diferi.
noteslongtextNULLuser Singurul text liber de pe serviciu — numele fiind compus din FK-uri, aici e locul pentru context uman.
tehnic
supplier_keychar(36) · generated storedDB 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_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 CASCADE). Împreună cu period: o singură perioadă per serviciu per lună.
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.
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.
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.
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 (se rotunjește o singură dată, la facturare)
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.
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.

Fixed fee

A1 · labor

Made by society RO — Website · DMK — DMK

client_invoice = fixed_amount · net_profit = fixed_amount − labour_cost

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 — factura e constanta din setup, iar 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. → 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.760invoiced
Article — NativeContentbox228070700ready
Static designContentbox6120awaiting_fee
VideoWebify1cost_entry
Invoiceable (ready + invoiced)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. Î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

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
Doctrine hidratează
CTI citește billing_type și instanțiază subclasa — Service vine gata cu copilul de pricing atașat.
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 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 { … }