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