feat(backend): PDF deviz + portal client + SMS + invoice service

- PDF generation with WeasyPrint: deviz and factura templates (A4, branding)
- GET /orders/{id}/pdf/deviz returns PDF with order lines and totals
- Client portal (public, no auth): GET /p/{token}, POST /p/{token}/accept|reject
- SMS service (SMSAPI.ro) - skips in dev when no token configured
- Invoice service: create from validated order, auto-number (F-YYYY-NNNN)
- GET /invoices/{id}/pdf returns factura PDF
- Order status_client field for client accept/reject tracking
- Alembic migration for status_client
- 19 passing tests (auth + sync + orders + pdf + portal + invoices)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 17:34:36 +02:00
parent efc9545ae6
commit 3bdafad22a
17 changed files with 786 additions and 0 deletions

View File

@@ -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 ###

View File

View File

@@ -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}

View File

@@ -17,3 +17,4 @@ class Order(Base, UUIDMixin, TenantMixin, TimestampMixin):
total_materiale: Mapped[float] = mapped_column(Float, default=0) total_materiale: Mapped[float] = mapped_column(Float, default=0)
total_general: 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)) token_client: Mapped[str | None] = mapped_column(String(36))
status_client: Mapped[str | None] = mapped_column(String(20))

View File

View File

@@ -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"'
},
)

View File

@@ -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

View File

@@ -4,9 +4,11 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.auth.router import router as auth_router 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.config import settings
from app.db.base import Base from app.db.base import Base
from app.db.session import engine 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.orders.router import router as orders_router
from app.sync.router import router as sync_router from app.sync.router import router as sync_router
from app.vehicles.router import router as vehicles_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(sync_router, prefix="/api/sync")
app.include_router(orders_router, prefix="/api/orders") app.include_router(orders_router, prefix="/api/orders")
app.include_router(vehicles_router, prefix="/api/vehicles") 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") @app.get("/api/health")

View File

@@ -1,9 +1,16 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession 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.db.session import get_db
from app.deps import get_tenant_id from app.deps import get_tenant_id
from app.orders import schemas, service from app.orders import schemas, service
from app.pdf.service import generate_deviz
router = APIRouter() router = APIRouter()
@@ -81,3 +88,70 @@ async def validate_order(
return {"status": order.status} return {"status": order.status}
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=422, detail=str(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"'
},
)

View File

View File

@@ -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()

View File

@@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="ro"><head>
<meta charset="UTF-8">
<style>
@page { size: A4; margin: 2cm; }
body { font-family: DejaVu Sans, sans-serif; font-size: 11pt; color: #111; }
.header { display: flex; justify-content: space-between; margin-bottom: 24px; }
h2 { margin: 0; font-size: 16pt; }
h3 { font-size: 11pt; margin: 16px 0 6px; color: #374151; }
table { width: 100%; border-collapse: collapse; }
th { background: #f3f4f6; padding: 6px 8px; text-align: left; font-size: 10pt; }
td { padding: 5px 8px; border-bottom: 1px solid #e5e7eb; font-size: 10pt; }
.totals { margin-top: 20px; text-align: right; }
.totals div { margin-bottom: 4px; }
.total-final { font-weight: bold; font-size: 13pt; border-top: 2px solid #111; padding-top: 6px; }
</style>
</head><body>
<div class="header">
<div>
<strong>{{ tenant.nume }}</strong><br>
{% if tenant.cui %}CUI: {{ tenant.cui }}<br>{% endif %}
{% if tenant.adresa %}{{ tenant.adresa }}<br>{% endif %}
{% if tenant.telefon %}Tel: {{ tenant.telefon }}{% endif %}
</div>
<div style="text-align:right">
<h2>DEVIZ Nr. {{ order.nr_comanda or order.id[:8]|upper }}</h2>
<div>Data: {{ order.data_comanda }}</div>
<div>Auto: <strong>{{ order.nr_auto }}</strong></div>
<div>{{ order.marca_denumire or '' }} {{ order.model_denumire or '' }}</div>
<div>Client: {{ order.client_nume or '' }}</div>
</div>
</div>
{% if manopera %}
<h3>Operatii manopera</h3>
<table>
<tr><th>Descriere</th><th>Ore</th><th>Pret/ora (RON)</th><th>Total (RON)</th></tr>
{% for l in manopera %}
<tr>
<td>{{ l.descriere }}</td><td>{{ l.ore }}</td>
<td>{{ "%.2f"|format(l.pret_ora or 0) }}</td>
<td>{{ "%.2f"|format(l.total or 0) }}</td>
</tr>
{% endfor %}
</table>
{% endif %}
{% if materiale %}
<h3>Materiale</h3>
<table>
<tr><th>Descriere</th><th>UM</th><th>Cant.</th><th>Pret unit. (RON)</th><th>Total (RON)</th></tr>
{% for l in materiale %}
<tr>
<td>{{ l.descriere }}</td><td>{{ l.um }}</td><td>{{ l.cantitate }}</td>
<td>{{ "%.2f"|format(l.pret_unitar or 0) }}</td>
<td>{{ "%.2f"|format(l.total or 0) }}</td>
</tr>
{% endfor %}
</table>
{% endif %}
<div class="totals">
<div>Manopera: {{ "%.2f"|format(order.total_manopera or 0) }} RON</div>
<div>Materiale: {{ "%.2f"|format(order.total_materiale or 0) }} RON</div>
<div class="total-final">TOTAL: {{ "%.2f"|format(order.total_general or 0) }} RON</div>
</div>
</body></html>

View File

@@ -0,0 +1,65 @@
<!DOCTYPE html>
<html lang="ro"><head>
<meta charset="UTF-8">
<style>
@page { size: A4; margin: 2cm; }
body { font-family: DejaVu Sans, sans-serif; font-size: 11pt; color: #111; }
.header { display: flex; justify-content: space-between; margin-bottom: 24px; }
h2 { margin: 0; font-size: 16pt; }
h3 { font-size: 11pt; margin: 16px 0 6px; color: #374151; }
table { width: 100%; border-collapse: collapse; }
th { background: #f3f4f6; padding: 6px 8px; text-align: left; font-size: 10pt; }
td { padding: 5px 8px; border-bottom: 1px solid #e5e7eb; font-size: 10pt; }
.totals { margin-top: 20px; text-align: right; }
.totals div { margin-bottom: 4px; }
.total-final { font-weight: bold; font-size: 13pt; border-top: 2px solid #111; padding-top: 6px; }
.info-box { border: 1px solid #d1d5db; padding: 12px; margin-bottom: 16px; }
</style>
</head><body>
<div class="header">
<div>
<strong>FURNIZOR</strong><br>
<strong>{{ tenant.nume }}</strong><br>
{% if tenant.cui %}CUI: {{ tenant.cui }}<br>{% endif %}
{% if tenant.reg_com %}Reg. Com.: {{ tenant.reg_com }}<br>{% endif %}
{% if tenant.adresa %}{{ tenant.adresa }}<br>{% endif %}
{% if tenant.iban %}IBAN: {{ tenant.iban }}<br>{% endif %}
{% if tenant.banca %}Banca: {{ tenant.banca }}{% endif %}
</div>
<div style="text-align:right">
<h2>FACTURA Nr. {{ invoice.nr_factura }}</h2>
<div>Data: {{ invoice.data_factura }}</div>
</div>
</div>
<div class="info-box">
<strong>CLIENT</strong><br>
{{ order.client_nume or 'N/A' }}<br>
{% if order.client_cui %}CUI: {{ order.client_cui }}<br>{% endif %}
{% if order.client_adresa %}{{ order.client_adresa }}{% endif %}
</div>
<div style="margin-bottom: 8px;">
Auto: <strong>{{ order.nr_auto }}</strong> | Deviz: {{ order.id[:8]|upper }}
</div>
<table>
<tr><th>#</th><th>Descriere</th><th>UM</th><th>Cant.</th><th>Pret unit. (RON)</th><th>Total (RON)</th></tr>
{% for l in lines %}
<tr>
<td>{{ loop.index }}</td>
<td>{{ l.descriere }}</td>
<td>{{ l.um or ('ore' if l.tip == 'manopera' else 'buc') }}</td>
<td>{{ l.ore if l.tip == 'manopera' else l.cantitate }}</td>
<td>{{ "%.2f"|format(l.pret_ora if l.tip == 'manopera' else l.pret_unitar) }}</td>
<td>{{ "%.2f"|format(l.total or 0) }}</td>
</tr>
{% endfor %}
</table>
<div class="totals">
<div>Manopera: {{ "%.2f"|format(order.total_manopera or 0) }} RON</div>
<div>Materiale: {{ "%.2f"|format(order.total_materiale or 0) }} RON</div>
<div class="total-final">TOTAL: {{ "%.2f"|format(invoice.total or 0) }} RON</div>
</div>
</body></html>

View File

View File

@@ -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"},
)

142
backend/tests/test_pdf.py Normal file
View File

@@ -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"

View File

@@ -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