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

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

View File

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

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