Agency Flow · Prezentare de flux · Referință istorică
A1 · Fixed fee — de la fixed_amount la factură
Cel mai simplu billing type, urmărit cap-coadă pe un serviciu real: ce completează omul, ce rânduri se inserează, ce calculează engine-ul, ce calculează baza de date singură — și în ce moment îngheață fiecare cifră.
/t/{tenantId}/digital-assets/{digitalAssetId}/services/fixed-fee/new,
FixedFeeServiceCreator și generarea perioadelor/cost lines la save nu mai
reprezintă UI-ul curent. Setup-ul actual folosește selectorul
/t/{tenantId}/digital-assets/{digitalAssetId}/services/new, apoi ruta generică
/t/{tenantId}/digital-assets/{digitalAssetId}/services/new/{billingType}, cu forme
dedicate pentru toate cele 10 billing types;
setup-ul nu generează perioade sau cost lines. Pentru implementarea curentă, consultă
planul de implementare și fluxul generic de setup.
O singură dată · la vânzarea serviciului
Setup: drawerul de serviciu
Account managerÎn fluxul A1 istoric, se alegeau assetul (clientul se deriva din el, nu se alegea separat), departamentul (grupul se deriva din el) și billing type-ul Fixed fee (A1). Singurul parametru de preț era suma lunară fixă. Numele serviciului nu se scria — se compunea.
Tracer bullet-ul istoric folosea ruta contextuală
/t/{tenantId}/digital-assets/{digitalAssetId}/services/fixed-fee/new:
un formular minim A1, fără supplier, care persista prin FixedFeeServiceCreator.
După redirect, Digital Asset afișa un panou minim Services cu departamentul,
tipul, suma fixă și planning-ul. Rândurile erau ordonate după statusul efectiv:
întâi active, apoi pe pauză, apoi închise; departamentul și data creării rămâneau
departajarea în interiorul fiecărui grup. Fiecare rând ducea la pagina read-only
/t/{tenantId}/services/{serviceId}, unde se verificau identitatea serviciului,
pricing-ul A1, orele planificate și faptul că serviciul labor apărea ca No supplier.
Aceeași pagină păstra pricing-ul de setup ca default și îl putea edita separat
prin Edit defaults (fixed_amount și planning_hours).
Excepțiile lunare stăteau pe rândul perioadei: o lună ready, dar nefacturată,
putea fi ajustată dintr-un drawer de period cu sumă manuală și motiv obligatoriu.
Programarea finalului folosește Schedule end / Change end date și formularul
Last active day, care poate seta, schimba sau șterge end_date ca
ultima zi activă, inclusiv; nu există status persistat
closed. O dată viitoare afișează Scheduled to end, serviciul rămâne
activ până la sfârșitul acelei zile și devine Closed în ziua următoare. Ultima
lună nefacturată se recalculează după zilele eligibile, perioadele ulterioare se elimină,
iar orice conflict cu date facturate sau cu input uman blochează salvarea. După închiderea
efectivă, setup-ul, pricing-ul și perioadele rămân istoric read-only.
Pauza temporară folosește Pause service / Resume service și salvează în
service_pause date calendaristice exacte: paused_from și
paused_until, ambele inclusive. Formularele folosesc Paused from,
Last paused day și prima zi activă Resume on. Pauza oprește simultan livrarea și billing-ul.
O lună complet suspendată primește service_billing_pause și nicio perioadă;
o pauză parțială păstrează perioada cu baza auditabilă
billable_days / calendar_days. „Resume on 20 August” înseamnă că ultima zi
suspendată salvată este 19 August. Reluarea în chiar prima zi programată anulează pauza
prin traseul auditat de corecție. O pauză planificată cu final, dar fără actor de reluare,
poate fi încă schimbată; o pauză anulată sau deja reluată rămâne audit imuabil. Toate
Dashboard-ul păstrează un tabel de audit cu intervalele scheduled/current/completed/cancelled,
Resume on, actorii și motivele. Perioadele arată intervalul calendaristic, baza
billable_days / calendar_days și ecuația din
snapshot_pricing_fixed_amount. Suma fixă folosită este persistată pe perioadă,
deci schimbarea default-ului nu rescrie auditul; rândurile legacy facturate fără baza sau
inputul original afișează
Historical calculation. Toate scrierile derivate din calendar folosesc același coordinator: setup, editarea pricing-ului
cu recalcularea lunilor deschise, pause/resume/cancel, service end, generator/reconciler și
recompute/reopen/close/override de perioadă. Operația exterioară deschide o singură
tranzacție proprie și refuză să adopte o tranzacție DBAL externă deja activă. Blochează
pesimist rândul Digital Asset comun și reîncarcă o singură dată Service-ul și rândul
financiar direct înainte de citirea stării mutabile. Operațiile imbricate pe același
coordinator refolosesc lock-ul și refresh-ul și lasă flush/commit limitei exterioare. Astfel,
un writer cu o perioadă stale nu poate suprascrie o reconciliere de calendar deja comisă,
iar batch-urile își păstrează un singur flush. Query-urile canonice reîncarcă rândurile
existente fără să piardă rândurile încă programate în UnitOfWork. Metodele engine-ului care
primesc coverage și metodele mutante ale reconcilerului sunt inner-only și verifică runtime
că Digital Asset-ul Service-ului este blocat în scope. Sub MariaDB REPEATABLE READ,
batch-ul capturează candidații înaintea tranzacției, deduplică și sortează Digital Asset-urile
după UUID, apoi le blochează pe toate înainte de callback, refresh sau orice consistent read;
altfel primul SELECT ar fixa un snapshot care poate ascunde un pause/pricing commit ulterior.
Fiecare limită exterioară îngheață setul declarat de Digital Asset-uri imediat după lock-uri și
înainte de primul refresh, read sau callback. Operațiile imbricate pot refolosi numai asset-uri
din setul înghețat; un asset omis este respins înainte de orice lock, refresh sau work suplimentar
care ar putea inversa ordinea lock-urilor. Batch-ul este outermost și î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 ieșirea din limita exterioară, inclusiv după excepții.
Comenzile per-Service precolectează perechea de ID Service/Digital Asset, deschid tranzacția,
obțin o referință Digital Asset fără SELECT, o blochează și abia apoi încarcă Service-ul fresh.
Emiterea facturii și
fluxurile financiare F1 vor adopta același protocol când vor fi implementate. Schimbările de calendar reconciliază doar lunile
nefacturate și nu șterg niciodată în tăcere pontaje, inputuri, linii sau override-uri.
Reconcilierea post-migrare verifică înainte de orice mutație UnitOfWork: coverage zero nu
poate șterge un override manual sau o linie deliverable user-owned, iar coverage zero/parțial
nu poate exclude pontaje datate.
La save, FixedFeeServiceCreator pornea generatorul pentru lunile eligibile,
așa că un A1 cu invoice_start_date în luna curentă se năștea deja cu perioada
ready. Din aceeași pagină se putea reporni manual generatorul idempotent
doar după ce luna curentă ajungea la invoice_start_date; înainte de acea
lună acțiunea nu apărea, iar POST-ul direct era respins fără rând lunar sau skip.
Se vedeau rândurile lunare cu state, suma clientului, labour și net profit snapshot.
Drawerul complet cu toate tipurile rămânea pasul următor.
CHK_SERVICE_SUPPLIER_BY_TYPE ✓ — A1 e labor, supplier-ul trebuie să fie NULL.
UNIQ_SERVICE_OPEN_ASSET_DEPARTMENT_SUPPLIER_KEY ✓ — un singur serviciu deschis DMK–DMK pe acest asset.
service într-o singură tranzacție.service și
service_pricing_fixed_fee. În implementarea tracer bullet, același use-case
pornește imediat generatorul pentru lunile eligibile; dacă există deja o lună de
facturat, apare și service_billing_period. service_cost_line rămâne
zero la A1.
1 august · automat
Luna se deschide: engine-ul creează perioada
Engine
Un generator lunar calculează mai întâi acoperirea calendaristică exactă. Creează
service_billing_period dacă rămâne cel puțin o zi facturabilă; numai când o
pauză elimină toate zilele eligibile creează service_billing_pause.
Intervalele de pauză anulate sunt ignorate.
Entry point-ul operațional este app:billing:generate-periods, cu
--tenant=<uuid> pentru rulare pe un tenant și --month=YYYY-MM
pentru generare până la luna respectivă. Comanda este idempotentă: rerun-ul nu dublează
perioadele și nu emite facturi. Acțiunea manuală de pe Service detail urmează aceeași
regulă de eligibilitate și apare numai din luna invoice_start_date.
După migrarea calendarului exact, app:billing:reconcile-calendar completează
doar lunile A1 deja reprezentate; nu generează istoric nou și nu recalculează perioade
invoiced. Migrarea rulează prima, reconcilierea a doua, apoi generatorul normal.
Pentru A1 nu există nimic de introdus și suma nu depinde de ore, dar componenta
fixă se proratează cu billable_days / calendar_days într-o lună parțială,
așa că rândul se naște direct ready: strategia rulează la creare și sumele
facturabile îngheață pe loc. (A1 e singurul cu privilegiul ăsta — la A2/B factura
depinde de orele lunii, deci perioada lor se naște cost_entry.)
FixedFeeStrategy::calculate(ctx) → BillingBreakdown {
clientInvoice: ctx.coverage.prorate(3.500), supplierCost: 0, labourCost: ctx.labour.cost,
directProfit: 0, feeProfit: 0, labourRecovery: 0
}
input_* rămân NULL — la A1 nu participă (whitelist per tip, regulă de engine).ready_at e setat → cele 3.500 nu mai sunt modificate automat.
Dacă august trebuie facturat la 4.000, dar default-ul serviciului rămâne 3.500,
finance/ops ajustează doar perioada august din drawerul rândului. Periodul păstrează
suma manuală + motivul, snapshot_client_amount devine 4.000, iar lunile
următoare continuă să pornească din fixed_amount.
Pe parcursul lui august
Echipa lucrează: pontajele curg
Echipa task & time_entry — pasul următor de implementare
Factura A1 e fixă, dar profitul nu e — depinde de cât timp consumă echipa.
Orele se loghează pe task-urile serviciului; costul intern e per angajat, din payroll
(cost/h efectiv la data pontajului). După fiecare pontaj, engine-ul actualizează
snapshot_labour_cost, iar DB-ul recalculează singur netul.
| angajat | task | ore | cost/h intern | cost |
|---|---|---|---|---|
| Ana — Senior DMK | Campanie always-on | 15,0 | 100 | 1.500 |
| Radu — DMK Specialist | Rapoarte + optimizări | 8,0 | 80 | 640 |
| total august (planificat: 24 h) | 23,0 | 2.140 | ||
ready_at îngheață doar facturabilele; labour-ul curge liber
până la invoiced_at — pontajele sosesc mereu cu întârziere, iar factura clientului
nu depinde de ele la A1.
1 septembrie · batch lunar
Facturarea: generate → review → issue
Finance modulul Billing — urmează
Fluxul ghidat de facturare (clienți → assets → servicii → generate) adună toate
perioadele ready din august ale clientului și produce o factură per client,
cu câte o linie per serviciu. Linia noastră vine direct din snapshot — nimic nu se recalculează.
| linie | qty | preț unitar | TVA 21% | total |
|---|---|---|---|---|
| Made by society RO — DMK — DMK · aug 2024 | 1 | 3.500,00 | 735,00 | 4.235,00 |
| total factură (1 serviciu la acest client) | 4.235,00 | |||
invoiced_at setat → și labour-ul îngheață. Un pontaj întârziat pe august e respins — se corectează doar explicit, prin storno.Oricând după aceea
Raportarea: doar citiri, zero calcule
Engine
Profitabilitatea pe serviciu / client / agenție e un SUM peste rândurile de
perioadă — cifrele au fost înghețate la momentul potrivit, deci raportul e stabil istoric
și nu re-rulează nicio strategie.
service_billing_period — ce se scrie și ce se actualizează
A1 se naște direct ready — suma facturabilă e constantă din setup. Singurul lucru care se mai mișcă lunar e labour-ul (din pontaje), până la invoiced.
gross / net / recovery_net sunt coloane generate — DB-ul le derivă singur, nimeni nu le scrie.client_amount NU se schimbă (fix); doar labour_cost / labour_hours / net se mișcă.invoiced, pontajele pe luna asta se resping; editarea lui fixed_amount în setup NU atinge automat lunile cu ready_at setat (A1 e ready din naștere). Aplicarea default-ului curent unei luni ready, nefacturate, cere reopen + recompute explicit și autorizat. Separat, finance/ops poate ajusta doar suma acelei perioade: engine-ul păstrează suma manuală ca input, recalculează snapshot-ul și pune A1 înapoi ready. Pentru o lună deja invoiced, corecția merge prin storno/adjustment.service_cost_line — niciuna la acest tip
A1/A2/B sunt labor pur (supplier_id NULL, impus de CHECK) — nu există cost de furnizor, deci zero linii în service_cost_line. Rândul lunar din service_billing_period e singura evidență: factura clientului + profitabilitatea din pontaje.
Simulare · factură fixă, profit variabil
interactiv · doar vizual
Esența A1: clientul plătește suma fixă indiferent de ore, dar profitul depinde de cât
lucrează echipa. Echipa adaugă pontaje (oră × cost/h) care ating doar
labour_cost și net_profit; Finance emite factura →
invoiced și îngheață labour-ul. Schimbă orele și vezi cum un serviciu profitabil
la puține ore devine neprofitabil dacă orele cresc. Nimic nu se salvează — e o schiță.
| angajat | ore | cost/h | cost linie | acțiune |
|---|---|---|---|---|
| total labour | — | 0 |
labour_cost și net_profit, până la invoiced_at
(înghețare în două faze: ready îngheață factura, invoiced îngheață labour-ul).
Editarea default-ului de setup afectează perioada ready numai după acțiunea explicită
reopen + recompute.
Un serviciu profitabil la puține ore devine neprofitabil dacă orele cresc — de-aia se pontează
chiar dacă factura e fixă.
Recapitulare: cine scrie ce
Fiecare coloană din schemă are exact un stăpân — de aici vine predictibilitatea fluxului.
Omul
- setup-ul serviciului:
service+service_pricing_fixed_fee - pontajele (task / time_entry)
- acțiunea de facturare (generate → issue)
Engine-ul
- creează perioada lunară, direct
ready - rulează
FixedFeeStrategy→ scrie cele 6 primitive - actualizează
labour_costla fiecare pontaj - mișcă stările + setează
ready_at/invoiced_at
Baza de date
gross / net / recovery_net— coloane generate- numele compus unic pentru servicii deschise (
open_supplier_key) - CHECK-uri: supplier NULL la A1, period = ziua 1, sume ≥ 0
input_* (rămân NULL) și
service_cost_line (zero rânduri) — pentru că A1 n-are furnizor și n-are nimic de
introdus lunar. Exact în golurile astea intră celelalte tipuri: E își pune costul în
input_supplier_cost, F2 orele, Platform spend-ul, iar F1 își aduce liniile de
deliverable. Aceleași tabele, alt actor pe aceleași locuri — ăsta e tot strategy pattern-ul.