diff --git a/backend/alembic/versions/eec3c13599e7_add_order_status_client.py b/backend/alembic/versions/eec3c13599e7_add_order_status_client.py
new file mode 100644
index 0000000..435b879
--- /dev/null
+++ b/backend/alembic/versions/eec3c13599e7_add_order_status_client.py
@@ -0,0 +1,30 @@
+"""add_order_status_client
+
+Revision ID: eec3c13599e7
+Revises: fbbfad4cd8f3
+Create Date: 2026-03-13 17:34:26.366597
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = 'eec3c13599e7'
+down_revision: Union[str, None] = 'fbbfad4cd8f3'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('orders', sa.Column('status_client', sa.String(length=20), nullable=True))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('orders', 'status_client')
+ # ### end Alembic commands ###
diff --git a/backend/app/client_portal/__init__.py b/backend/app/client_portal/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/client_portal/router.py b/backend/app/client_portal/router.py
new file mode 100644
index 0000000..94f291d
--- /dev/null
+++ b/backend/app/client_portal/router.py
@@ -0,0 +1,94 @@
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import select, text
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.db.models.order import Order
+from app.db.models.order_line import OrderLine
+from app.db.models.tenant import Tenant
+from app.db.models.vehicle import Vehicle
+from app.db.session import get_db
+
+router = APIRouter()
+
+
+@router.get("/p/{token}")
+async def get_deviz_public(token: str, db: AsyncSession = Depends(get_db)):
+ r = await db.execute(select(Order).where(Order.token_client == token))
+ order = r.scalar_one_or_none()
+ if not order:
+ raise HTTPException(status_code=404, detail="Deviz not found")
+
+ r = await db.execute(select(Tenant).where(Tenant.id == order.tenant_id))
+ tenant = r.scalar_one()
+
+ r = await db.execute(select(Vehicle).where(Vehicle.id == order.vehicle_id))
+ vehicle = r.scalar_one_or_none()
+
+ r = await db.execute(
+ select(OrderLine).where(OrderLine.order_id == order.id)
+ )
+ lines = r.scalars().all()
+
+ return {
+ "order": {
+ "id": order.id,
+ "status": order.status,
+ "data_comanda": order.data_comanda,
+ "total_manopera": order.total_manopera,
+ "total_materiale": order.total_materiale,
+ "total_general": order.total_general,
+ "nr_auto": vehicle.nr_inmatriculare if vehicle else "",
+ "observatii": order.observatii,
+ },
+ "tenant": {
+ "nume": tenant.nume,
+ "telefon": tenant.telefon,
+ },
+ "lines": [
+ {
+ "tip": l.tip,
+ "descriere": l.descriere,
+ "ore": l.ore,
+ "pret_ora": l.pret_ora,
+ "cantitate": l.cantitate,
+ "pret_unitar": l.pret_unitar,
+ "um": l.um,
+ "total": l.total,
+ }
+ for l in lines
+ ],
+ }
+
+
+@router.post("/p/{token}/accept")
+async def accept_deviz(token: str, db: AsyncSession = Depends(get_db)):
+ r = await db.execute(select(Order).where(Order.token_client == token))
+ order = r.scalar_one_or_none()
+ if not order:
+ raise HTTPException(status_code=404, detail="Deviz not found")
+ await db.execute(
+ text(
+ "UPDATE orders SET status_client='ACCEPTAT', updated_at=datetime('now') "
+ "WHERE token_client=:t"
+ ),
+ {"t": token},
+ )
+ await db.commit()
+ return {"ok": True}
+
+
+@router.post("/p/{token}/reject")
+async def reject_deviz(token: str, db: AsyncSession = Depends(get_db)):
+ r = await db.execute(select(Order).where(Order.token_client == token))
+ order = r.scalar_one_or_none()
+ if not order:
+ raise HTTPException(status_code=404, detail="Deviz not found")
+ await db.execute(
+ text(
+ "UPDATE orders SET status_client='RESPINS', updated_at=datetime('now') "
+ "WHERE token_client=:t"
+ ),
+ {"t": token},
+ )
+ await db.commit()
+ return {"ok": True}
diff --git a/backend/app/db/models/order.py b/backend/app/db/models/order.py
index 79ac23b..c88f534 100644
--- a/backend/app/db/models/order.py
+++ b/backend/app/db/models/order.py
@@ -17,3 +17,4 @@ class Order(Base, UUIDMixin, TenantMixin, TimestampMixin):
total_materiale: Mapped[float] = mapped_column(Float, default=0)
total_general: Mapped[float] = mapped_column(Float, default=0)
token_client: Mapped[str | None] = mapped_column(String(36))
+ status_client: Mapped[str | None] = mapped_column(String(20))
diff --git a/backend/app/invoices/__init__.py b/backend/app/invoices/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/invoices/router.py b/backend/app/invoices/router.py
new file mode 100644
index 0000000..447e505
--- /dev/null
+++ b/backend/app/invoices/router.py
@@ -0,0 +1,111 @@
+from fastapi import APIRouter, Depends, HTTPException
+from fastapi.responses import Response
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.db.models.invoice import Invoice
+from app.db.models.order import Order
+from app.db.models.order_line import OrderLine
+from app.db.models.tenant import Tenant
+from app.db.models.vehicle import Vehicle
+from app.db.session import get_db
+from app.deps import get_tenant_id
+from app.invoices import service
+from app.pdf.service import generate_factura
+
+router = APIRouter()
+
+
+@router.post("")
+async def create_invoice(
+ data: dict,
+ tenant_id: str = Depends(get_tenant_id),
+ db: AsyncSession = Depends(get_db),
+):
+ order_id = data.get("order_id")
+ if not order_id:
+ raise HTTPException(status_code=422, detail="order_id required")
+ try:
+ invoice = await service.create_invoice(db, tenant_id, order_id)
+ return {"id": invoice.id, "nr_factura": invoice.nr_factura}
+ except ValueError as e:
+ raise HTTPException(status_code=422, detail=str(e))
+
+
+@router.get("/{invoice_id}/pdf")
+async def get_invoice_pdf(
+ invoice_id: str,
+ tenant_id: str = Depends(get_tenant_id),
+ db: AsyncSession = Depends(get_db),
+):
+ r = await db.execute(
+ select(Invoice).where(
+ Invoice.id == invoice_id, Invoice.tenant_id == tenant_id
+ )
+ )
+ invoice = r.scalar_one_or_none()
+ if not invoice:
+ raise HTTPException(status_code=404, detail="Invoice not found")
+
+ r = await db.execute(select(Order).where(Order.id == invoice.order_id))
+ order = r.scalar_one()
+
+ r = await db.execute(select(Vehicle).where(Vehicle.id == order.vehicle_id))
+ vehicle = r.scalar_one_or_none()
+
+ r = await db.execute(select(Tenant).where(Tenant.id == tenant_id))
+ tenant = r.scalar_one()
+
+ r = await db.execute(
+ select(OrderLine).where(OrderLine.order_id == order.id)
+ )
+ lines = r.scalars().all()
+
+ order_data = {
+ "id": order.id,
+ "nr_auto": vehicle.nr_inmatriculare if vehicle else "",
+ "client_nume": vehicle.client_nume if vehicle else "",
+ "client_cui": vehicle.client_cui if vehicle else "",
+ "client_adresa": vehicle.client_adresa if vehicle else "",
+ "total_manopera": order.total_manopera,
+ "total_materiale": order.total_materiale,
+ "total_general": order.total_general,
+ }
+
+ invoice_data = {
+ "nr_factura": invoice.nr_factura,
+ "data_factura": invoice.data_factura,
+ "total": invoice.total,
+ }
+
+ tenant_data = {
+ "nume": tenant.nume,
+ "cui": tenant.cui,
+ "reg_com": tenant.reg_com,
+ "adresa": tenant.adresa,
+ "iban": tenant.iban,
+ "banca": tenant.banca,
+ }
+
+ lines_data = [
+ {
+ "tip": l.tip,
+ "descriere": l.descriere,
+ "ore": l.ore,
+ "pret_ora": l.pret_ora,
+ "cantitate": l.cantitate,
+ "pret_unitar": l.pret_unitar,
+ "um": l.um,
+ "total": l.total,
+ }
+ for l in lines
+ ]
+
+ pdf_bytes = generate_factura(invoice_data, order_data, lines_data, tenant_data)
+ return Response(
+ content=pdf_bytes,
+ media_type="application/pdf",
+ headers={
+ "Content-Disposition": f'inline; filename="factura-{invoice.nr_factura}.pdf"'
+ },
+ )
diff --git a/backend/app/invoices/service.py b/backend/app/invoices/service.py
new file mode 100644
index 0000000..57ac2ad
--- /dev/null
+++ b/backend/app/invoices/service.py
@@ -0,0 +1,49 @@
+from datetime import UTC, datetime
+
+from sqlalchemy import select, text
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.db.base import uuid7
+from app.db.models.invoice import Invoice
+from app.db.models.order import Order
+
+
+async def create_invoice(
+ db: AsyncSession, tenant_id: str, order_id: str
+) -> Invoice:
+ r = await db.execute(
+ select(Order).where(Order.id == order_id, Order.tenant_id == tenant_id)
+ )
+ order = r.scalar_one_or_none()
+ if not order:
+ raise ValueError("Order not found")
+ if order.status != "VALIDAT":
+ raise ValueError("Order must be VALIDAT to create invoice")
+
+ # Generate next invoice number for this tenant
+ r = await db.execute(
+ text(
+ "SELECT COUNT(*) as cnt FROM invoices WHERE tenant_id = :tid"
+ ),
+ {"tid": tenant_id},
+ )
+ count = r.scalar() + 1
+ now = datetime.now(UTC)
+ nr_factura = f"F-{now.year}-{count:04d}"
+
+ invoice = Invoice(
+ id=uuid7(),
+ tenant_id=tenant_id,
+ order_id=order_id,
+ nr_factura=nr_factura,
+ data_factura=now.isoformat().split("T")[0],
+ total=order.total_general,
+ )
+ db.add(invoice)
+
+ # Update order status to FACTURAT
+ order.status = "FACTURAT"
+ order.updated_at = now.isoformat()
+ await db.commit()
+ await db.refresh(invoice)
+ return invoice
diff --git a/backend/app/main.py b/backend/app/main.py
index 35b9f7a..4bb5275 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -4,9 +4,11 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.auth.router import router as auth_router
+from app.client_portal.router import router as portal_router
from app.config import settings
from app.db.base import Base
from app.db.session import engine
+from app.invoices.router import router as invoices_router
from app.orders.router import router as orders_router
from app.sync.router import router as sync_router
from app.vehicles.router import router as vehicles_router
@@ -34,6 +36,8 @@ app.include_router(auth_router, prefix="/api/auth")
app.include_router(sync_router, prefix="/api/sync")
app.include_router(orders_router, prefix="/api/orders")
app.include_router(vehicles_router, prefix="/api/vehicles")
+app.include_router(invoices_router, prefix="/api/invoices")
+app.include_router(portal_router, prefix="/api")
@app.get("/api/health")
diff --git a/backend/app/orders/router.py b/backend/app/orders/router.py
index 156d53d..0763a20 100644
--- a/backend/app/orders/router.py
+++ b/backend/app/orders/router.py
@@ -1,9 +1,16 @@
from fastapi import APIRouter, Depends, HTTPException
+from fastapi.responses import Response
+from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
+from app.db.models.order import Order
+from app.db.models.order_line import OrderLine
+from app.db.models.tenant import Tenant
+from app.db.models.vehicle import Vehicle
from app.db.session import get_db
from app.deps import get_tenant_id
from app.orders import schemas, service
+from app.pdf.service import generate_deviz
router = APIRouter()
@@ -81,3 +88,70 @@ async def validate_order(
return {"status": order.status}
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
+
+
+@router.get("/{order_id}/pdf/deviz")
+async def get_deviz_pdf(
+ order_id: str,
+ tenant_id: str = Depends(get_tenant_id),
+ db: AsyncSession = Depends(get_db),
+):
+ r = await db.execute(
+ select(Order).where(Order.id == order_id, Order.tenant_id == tenant_id)
+ )
+ order = r.scalar_one_or_none()
+ if not order:
+ raise HTTPException(status_code=404, detail="Order not found")
+
+ r = await db.execute(select(Vehicle).where(Vehicle.id == order.vehicle_id))
+ vehicle = r.scalar_one_or_none()
+
+ r = await db.execute(select(Tenant).where(Tenant.id == tenant_id))
+ tenant = r.scalar_one()
+
+ r = await db.execute(
+ select(OrderLine).where(OrderLine.order_id == order.id)
+ )
+ lines = r.scalars().all()
+
+ order_data = {
+ "id": order.id,
+ "data_comanda": order.data_comanda,
+ "nr_auto": vehicle.nr_inmatriculare if vehicle else "",
+ "client_nume": vehicle.client_nume if vehicle else "",
+ "marca_denumire": "",
+ "model_denumire": "",
+ "total_manopera": order.total_manopera,
+ "total_materiale": order.total_materiale,
+ "total_general": order.total_general,
+ }
+
+ tenant_data = {
+ "nume": tenant.nume,
+ "cui": tenant.cui,
+ "adresa": tenant.adresa,
+ "telefon": tenant.telefon,
+ }
+
+ lines_data = [
+ {
+ "tip": l.tip,
+ "descriere": l.descriere,
+ "ore": l.ore,
+ "pret_ora": l.pret_ora,
+ "cantitate": l.cantitate,
+ "pret_unitar": l.pret_unitar,
+ "um": l.um,
+ "total": l.total,
+ }
+ for l in lines
+ ]
+
+ pdf_bytes = generate_deviz(order_data, lines_data, tenant_data)
+ return Response(
+ content=pdf_bytes,
+ media_type="application/pdf",
+ headers={
+ "Content-Disposition": f'inline; filename="deviz-{order.id[:8]}.pdf"'
+ },
+ )
diff --git a/backend/app/pdf/__init__.py b/backend/app/pdf/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/pdf/service.py b/backend/app/pdf/service.py
new file mode 100644
index 0000000..927eaab
--- /dev/null
+++ b/backend/app/pdf/service.py
@@ -0,0 +1,30 @@
+from pathlib import Path
+
+from jinja2 import Environment, FileSystemLoader
+from weasyprint import HTML
+
+TEMPLATES = Path(__file__).parent / "templates"
+
+
+def generate_deviz(order: dict, lines: list, tenant: dict) -> bytes:
+ env = Environment(loader=FileSystemLoader(str(TEMPLATES)))
+ html = env.get_template("deviz.html").render(
+ order=order,
+ tenant=tenant,
+ manopera=[l for l in lines if l.get("tip") == "manopera"],
+ materiale=[l for l in lines if l.get("tip") == "material"],
+ )
+ return HTML(string=html).write_pdf()
+
+
+def generate_factura(
+ invoice: dict, order: dict, lines: list, tenant: dict
+) -> bytes:
+ env = Environment(loader=FileSystemLoader(str(TEMPLATES)))
+ html = env.get_template("factura.html").render(
+ invoice=invoice,
+ order=order,
+ tenant=tenant,
+ lines=lines,
+ )
+ return HTML(string=html).write_pdf()
diff --git a/backend/app/pdf/templates/deviz.html b/backend/app/pdf/templates/deviz.html
new file mode 100644
index 0000000..132850b
--- /dev/null
+++ b/backend/app/pdf/templates/deviz.html
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+{% if manopera %}
+Operatii manopera
+
+ | Descriere | Ore | Pret/ora (RON) | Total (RON) |
+ {% for l in manopera %}
+
+ | {{ l.descriere }} | {{ l.ore }} |
+ {{ "%.2f"|format(l.pret_ora or 0) }} |
+ {{ "%.2f"|format(l.total or 0) }} |
+
+ {% endfor %}
+
+{% endif %}
+
+{% if materiale %}
+Materiale
+
+ | Descriere | UM | Cant. | Pret unit. (RON) | Total (RON) |
+ {% for l in materiale %}
+
+ | {{ l.descriere }} | {{ l.um }} | {{ l.cantitate }} |
+ {{ "%.2f"|format(l.pret_unitar or 0) }} |
+ {{ "%.2f"|format(l.total or 0) }} |
+
+ {% endfor %}
+
+{% endif %}
+
+
+
Manopera: {{ "%.2f"|format(order.total_manopera or 0) }} RON
+
Materiale: {{ "%.2f"|format(order.total_materiale or 0) }} RON
+
TOTAL: {{ "%.2f"|format(order.total_general or 0) }} RON
+
+
diff --git a/backend/app/pdf/templates/factura.html b/backend/app/pdf/templates/factura.html
new file mode 100644
index 0000000..c722326
--- /dev/null
+++ b/backend/app/pdf/templates/factura.html
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+ CLIENT
+ {{ order.client_nume or 'N/A' }}
+ {% if order.client_cui %}CUI: {{ order.client_cui }}
{% endif %}
+ {% if order.client_adresa %}{{ order.client_adresa }}{% endif %}
+
+
+
+ Auto: {{ order.nr_auto }} | Deviz: {{ order.id[:8]|upper }}
+
+
+
+ | # | Descriere | UM | Cant. | Pret unit. (RON) | Total (RON) |
+ {% for l in lines %}
+
+ | {{ loop.index }} |
+ {{ l.descriere }} |
+ {{ l.um or ('ore' if l.tip == 'manopera' else 'buc') }} |
+ {{ l.ore if l.tip == 'manopera' else l.cantitate }} |
+ {{ "%.2f"|format(l.pret_ora if l.tip == 'manopera' else l.pret_unitar) }} |
+ {{ "%.2f"|format(l.total or 0) }} |
+
+ {% endfor %}
+
+
+
+
Manopera: {{ "%.2f"|format(order.total_manopera or 0) }} RON
+
Materiale: {{ "%.2f"|format(order.total_materiale or 0) }} RON
+
TOTAL: {{ "%.2f"|format(invoice.total or 0) }} RON
+
+
diff --git a/backend/app/sms/__init__.py b/backend/app/sms/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/sms/service.py b/backend/app/sms/service.py
new file mode 100644
index 0000000..d93237e
--- /dev/null
+++ b/backend/app/sms/service.py
@@ -0,0 +1,18 @@
+import httpx
+
+from app.config import settings
+
+
+async def send_deviz_sms(
+ telefon: str, token_client: str, tenant_name: str, base_url: str
+):
+ if not settings.SMSAPI_TOKEN:
+ return # skip in dev/test
+ url = f"{base_url}/p/{token_client}"
+ msg = f"{tenant_name}: Devizul tau e gata. Vizualizeaza: {url}"
+ async with httpx.AsyncClient() as c:
+ await c.post(
+ "https://api.smsapi.ro/sms.do",
+ headers={"Authorization": f"Bearer {settings.SMSAPI_TOKEN}"},
+ data={"to": telefon, "message": msg, "from": "ROAAUTO"},
+ )
diff --git a/backend/tests/test_pdf.py b/backend/tests/test_pdf.py
new file mode 100644
index 0000000..cfb64d4
--- /dev/null
+++ b/backend/tests/test_pdf.py
@@ -0,0 +1,142 @@
+import uuid
+from datetime import UTC, datetime
+
+import pytest
+from httpx import ASGITransport, AsyncClient
+
+from app.main import app
+
+
+@pytest.mark.asyncio
+async def test_pdf_deviz_returns_pdf(client, auth_headers):
+ me = await client.get("/api/auth/me", headers=auth_headers)
+ tenant_id = me.json()["tenant_id"]
+
+ vid = str(uuid.uuid4())
+ now = datetime.now(UTC).isoformat()
+ await client.post(
+ "/api/sync/push",
+ headers=auth_headers,
+ json={
+ "operations": [
+ {
+ "table": "vehicles",
+ "id": vid,
+ "operation": "INSERT",
+ "data": {
+ "id": vid,
+ "tenant_id": tenant_id,
+ "nr_inmatriculare": "CT99PDF",
+ "client_nume": "PDF Test",
+ "created_at": now,
+ "updated_at": now,
+ },
+ "timestamp": now,
+ }
+ ]
+ },
+ )
+
+ r = await client.post(
+ "/api/orders",
+ headers=auth_headers,
+ json={"vehicle_id": vid},
+ )
+ order_id = r.json()["id"]
+
+ await client.post(
+ f"/api/orders/{order_id}/lines",
+ headers=auth_headers,
+ json={
+ "tip": "manopera",
+ "descriere": "Diagnosticare",
+ "ore": 0.5,
+ "pret_ora": 100,
+ },
+ )
+
+ r = await client.get(
+ f"/api/orders/{order_id}/pdf/deviz",
+ headers=auth_headers,
+ )
+ assert r.status_code == 200
+ assert r.headers["content-type"] == "application/pdf"
+ assert r.content[:4] == b"%PDF"
+
+
+@pytest.mark.asyncio
+async def test_invoice_workflow(client, auth_headers):
+ me = await client.get("/api/auth/me", headers=auth_headers)
+ tenant_id = me.json()["tenant_id"]
+
+ vid = str(uuid.uuid4())
+ now = datetime.now(UTC).isoformat()
+ await client.post(
+ "/api/sync/push",
+ headers=auth_headers,
+ json={
+ "operations": [
+ {
+ "table": "vehicles",
+ "id": vid,
+ "operation": "INSERT",
+ "data": {
+ "id": vid,
+ "tenant_id": tenant_id,
+ "nr_inmatriculare": "B01INV",
+ "client_nume": "Factura Test",
+ "created_at": now,
+ "updated_at": now,
+ },
+ "timestamp": now,
+ }
+ ]
+ },
+ )
+
+ # Create order + line + validate
+ r = await client.post(
+ "/api/orders",
+ headers=auth_headers,
+ json={"vehicle_id": vid},
+ )
+ order_id = r.json()["id"]
+
+ await client.post(
+ f"/api/orders/{order_id}/lines",
+ headers=auth_headers,
+ json={
+ "tip": "material",
+ "descriere": "Filtru aer",
+ "cantitate": 1,
+ "pret_unitar": 80,
+ "um": "buc",
+ },
+ )
+
+ await client.post(
+ f"/api/orders/{order_id}/validate",
+ headers=auth_headers,
+ )
+
+ # Create invoice
+ r = await client.post(
+ "/api/invoices",
+ headers=auth_headers,
+ json={"order_id": order_id},
+ )
+ assert r.status_code == 200
+ data = r.json()
+ assert "id" in data
+ assert "nr_factura" in data
+ assert data["nr_factura"].startswith("F-")
+
+ # Get invoice PDF
+ invoice_id = data["id"]
+ r = await client.get(
+ f"/api/invoices/{invoice_id}/pdf",
+ headers=auth_headers,
+ )
+ assert r.status_code == 200
+ assert r.headers["content-type"] == "application/pdf"
+ assert r.content[:4] == b"%PDF"
diff --git a/backend/tests/test_portal.py b/backend/tests/test_portal.py
new file mode 100644
index 0000000..d627c33
--- /dev/null
+++ b/backend/tests/test_portal.py
@@ -0,0 +1,101 @@
+import uuid
+from datetime import UTC, datetime
+
+import pytest
+from httpx import ASGITransport, AsyncClient
+
+from app.main import app
+
+
+async def _setup_order_with_lines(client, auth_headers):
+ """Create vehicle + order + lines, return order details."""
+ me = await client.get("/api/auth/me", headers=auth_headers)
+ tenant_id = me.json()["tenant_id"]
+
+ vid = str(uuid.uuid4())
+ now = datetime.now(UTC).isoformat()
+ await client.post(
+ "/api/sync/push",
+ headers=auth_headers,
+ json={
+ "operations": [
+ {
+ "table": "vehicles",
+ "id": vid,
+ "operation": "INSERT",
+ "data": {
+ "id": vid,
+ "tenant_id": tenant_id,
+ "nr_inmatriculare": "B99PDF",
+ "client_nume": "Ion Popescu",
+ "created_at": now,
+ "updated_at": now,
+ },
+ "timestamp": now,
+ }
+ ]
+ },
+ )
+
+ r = await client.post(
+ "/api/orders",
+ headers=auth_headers,
+ json={"vehicle_id": vid},
+ )
+ order_id = r.json()["id"]
+
+ await client.post(
+ f"/api/orders/{order_id}/lines",
+ headers=auth_headers,
+ json={
+ "tip": "manopera",
+ "descriere": "Schimb ulei",
+ "ore": 1,
+ "pret_ora": 150,
+ },
+ )
+
+ # Get order to find token_client
+ r = await client.get(f"/api/orders/{order_id}", headers=auth_headers)
+ return r.json()
+
+
+@pytest.mark.asyncio
+async def test_portal_get_deviz(client, auth_headers):
+ order = await _setup_order_with_lines(client, auth_headers)
+ token = order["token_client"]
+
+ # Portal access is public (no auth headers)
+ r = await client.get(f"/api/p/{token}")
+ assert r.status_code == 200
+ data = r.json()
+ assert "order" in data
+ assert "tenant" in data
+ assert "lines" in data
+ assert data["order"]["nr_auto"] == "B99PDF"
+
+
+@pytest.mark.asyncio
+async def test_portal_accept(client, auth_headers):
+ order = await _setup_order_with_lines(client, auth_headers)
+ token = order["token_client"]
+
+ r = await client.post(f"/api/p/{token}/accept")
+ assert r.status_code == 200
+ assert r.json()["ok"] is True
+
+
+@pytest.mark.asyncio
+async def test_portal_reject(client, auth_headers):
+ order = await _setup_order_with_lines(client, auth_headers)
+ token = order["token_client"]
+
+ r = await client.post(f"/api/p/{token}/reject")
+ assert r.status_code == 200
+ assert r.json()["ok"] is True
+
+
+@pytest.mark.asyncio
+async def test_portal_invalid_token(client):
+ r = await client.get("/api/p/invalid-token")
+ assert r.status_code == 404