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:
@@ -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 ###
|
||||
0
backend/app/client_portal/__init__.py
Normal file
0
backend/app/client_portal/__init__.py
Normal file
94
backend/app/client_portal/router.py
Normal file
94
backend/app/client_portal/router.py
Normal 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}
|
||||
@@ -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))
|
||||
|
||||
0
backend/app/invoices/__init__.py
Normal file
0
backend/app/invoices/__init__.py
Normal file
111
backend/app/invoices/router.py
Normal file
111
backend/app/invoices/router.py
Normal 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"'
|
||||
},
|
||||
)
|
||||
49
backend/app/invoices/service.py
Normal file
49
backend/app/invoices/service.py
Normal 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
|
||||
@@ -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")
|
||||
|
||||
@@ -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"'
|
||||
},
|
||||
)
|
||||
|
||||
0
backend/app/pdf/__init__.py
Normal file
0
backend/app/pdf/__init__.py
Normal file
30
backend/app/pdf/service.py
Normal file
30
backend/app/pdf/service.py
Normal 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()
|
||||
67
backend/app/pdf/templates/deviz.html
Normal file
67
backend/app/pdf/templates/deviz.html
Normal 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>
|
||||
65
backend/app/pdf/templates/factura.html
Normal file
65
backend/app/pdf/templates/factura.html
Normal 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>
|
||||
0
backend/app/sms/__init__.py
Normal file
0
backend/app/sms/__init__.py
Normal file
18
backend/app/sms/service.py
Normal file
18
backend/app/sms/service.py
Normal 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
142
backend/tests/test_pdf.py
Normal 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"
|
||||
101
backend/tests/test_portal.py
Normal file
101
backend/tests/test_portal.py
Normal 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
|
||||
Reference in New Issue
Block a user