Restyle UI to match Mago4 and add invoicing, warehouse, scadenzario

Rework the demo into a faithful Mago4 look-and-feel and expand it with
core ERP modules modelled on the real Mago4 database schema.

UI (ispirata a Mago4):
- Blue header with logo/search/operation date, desktop-style window tabs
- White sidebar with coloured module icons (Anagrafiche, Vendite, Acquisti,
  Amministrazione, Logistica)
- Card-based module landing pages with breadcrumb + tab strip
- Document detail forms with left tabs, line grid and totals box

Fatturazione (InvoiceMng / MA_SaleDoc):
- Document types (Fattura Immediata, DDT, Nota di Credito, ProForma)
- Lines with discount/IVA, automatic taxable/tax/total computation
- Document states (draft -> printed -> issued / posted_to_inventory)

Magazzino (Inventory / MA_InventoryEntries + MA_InventoryReasons):
- Inventory movements with load/unload reasons (DebitCreditSign)
- Posting a movement actually updates item balances
- Causali and stock valuation pages

Amministrazione / Scadenzario (AP_AR / MA_PyblsRcvbls):
- Open items (receivables/payables) with due date, method, state
- Settle action closes the item and recomputes the cash forecast

Dashboard riprogettata (focus su scadenze e to-do):
- Overdue alert banner, KPI for overdue payables/receivables
- Upcoming deadlines table, prioritised "Cose da fare" action list
- 30-day cash-flow forecast and customer credit-limit check

Adds REST endpoints for invoices, movements, schedules and dashboard data.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 11:47:32 +02:00
co-authored by Claude Sonnet 4.6
parent 8a9fb5085a
commit b6b6759b9a
26 changed files with 2900 additions and 2125 deletions
+244 -225
View File
@@ -8,24 +8,16 @@ from datetime import datetime
import os
from models import (
Customer, Supplier, Product, SalesOrder, PurchaseOrder,
OrderLine, OrderStatus, Dashboard, DashboardWidget
Customer, Supplier, Product, SalesOrder, PurchaseOrder, OrderStatus,
Invoice, InventoryMovement, DocumentType, DocumentStatus,
PaymentSchedule, PaymentScheduleType,
DOCUMENT_TYPE_LABELS, DOCUMENT_STATUS_LABELS, PAYMENT_SCHEDULE_TYPE_LABELS, MovementSign,
)
from database import db
app = FastAPI(
title="Mago4 Demo",
description="Demo gestionale ERP con Python FastAPI",
version="1.0.0"
)
app = FastAPI(title="Mago4 Demo", description="Demo gestionale ERP — Python FastAPI", version="2.0.0")
# Setup static e template files
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
os.makedirs(os.path.join(BASE_DIR, "static"), exist_ok=True)
os.makedirs(os.path.join(BASE_DIR, "static", "css"), exist_ok=True)
os.makedirs(os.path.join(BASE_DIR, "static", "js"), exist_ok=True)
os.makedirs(os.path.join(BASE_DIR, "templates"), exist_ok=True)
app.mount("/static", StaticFiles(directory=os.path.join(BASE_DIR, "static")), name="static")
# Workaround Python 3.14 + Jinja2: disable template cache (globals dict not hashable as cache key)
@@ -34,268 +26,295 @@ _jinja_env = Environment(
autoescape=True,
cache_size=0,
)
# Helper esposti ai template
_jinja_env.globals["doc_type_label"] = lambda t: DOCUMENT_TYPE_LABELS.get(t, str(t))
_jinja_env.globals["doc_status_label"] = lambda s: DOCUMENT_STATUS_LABELS.get(s, str(s))
_jinja_env.globals["sched_type_label"] = lambda t: PAYMENT_SCHEDULE_TYPE_LABELS.get(t, str(t))
_jinja_env.globals["sched_status"] = db.schedule_status
_jinja_env.globals["sched_days"] = db.schedule_days_to_due
templates = Jinja2Templates(env=_jinja_env)
# ==================== DASHBOARD ====================
def ctx(request: Request, active_module: str, **extra):
base = {
"request": request,
"active_module": active_module,
"operation_date": datetime.now().strftime("%d/%m/%Y"),
}
base.update(extra)
return base
def render(request: Request, name: str, active_module: str, **extra):
return templates.TemplateResponse(request, name, ctx(request, active_module, **extra))
# ============================================================
# DASHBOARD
# ============================================================
@app.get("/", response_class=HTMLResponse)
async def dashboard(request: Request):
stats = db.get_dashboard_stats()
widgets = [
[
DashboardWidget(
title="Ordini Vendita da Evadere",
style="stats",
size="small",
icon="shopping_cart",
color="blue",
value=str(stats["sales_to_fulfill"])
),
DashboardWidget(
title="Ordini Acquisto da Ricevere",
style="stats",
size="small",
icon="local_shipping",
color="orange",
value=str(stats["purchase_to_receive"])
),
DashboardWidget(
title="Crediti Clienti",
style="stats",
size="small",
icon="euro_symbol",
color="green",
value=f"{stats['total_receivable']:,.2f}",
format="money"
),
DashboardWidget(
title="Articoli Sotto Soglia",
style="stats",
size="small",
icon="warning",
color="red",
value=str(stats["low_stock_count"])
),
],
[
DashboardWidget(
title="Ultimi Ordini Vendita",
subtitle="per data ordine",
style="grid",
size="medium",
data={"orders": [
{
"order_number": o.order_number,
"order_date": o.order_date.strftime("%d/%m/%Y"),
"customer_name": o.customer_name,
"total_amount": f"{o.total_amount:,.2f}",
"status": o.status.value
}
for o in sorted(db.get_sales_orders(), key=lambda x: x.order_date, reverse=True)[:5]
]}
),
DashboardWidget(
title="Ultimi Ordini Acquisto",
subtitle="per data ordine",
style="grid",
size="medium",
data={"orders": [
{
"order_number": o.order_number,
"order_date": o.order_date.strftime("%d/%m/%Y"),
"supplier_name": o.supplier_name,
"total_amount": f"{o.total_amount:,.2f}",
"status": o.status.value
}
for o in sorted(db.get_purchase_orders(), key=lambda x: x.order_date, reverse=True)[:5]
]}
),
]
]
return templates.TemplateResponse(request, "dashboard.html", {
"widgets": widgets,
"stats": stats
})
upcoming = db.get_upcoming_schedules(30)
todos = db.get_todo_items()
cashflow = db.get_cashflow_forecast(30)
credit_exceeded = db.get_credit_exceeded_customers()
last_invoices = sorted(db.get_invoices(), key=lambda x: x.doc_date, reverse=True)[:5]
return render(request, "dashboard.html", "dashboard",
stats=stats, upcoming=upcoming, todos=todos, cashflow=cashflow,
credit_exceeded=credit_exceeded, last_invoices=last_invoices,
RECEIVABLE=PaymentScheduleType.RECEIVABLE, PAYABLE=PaymentScheduleType.PAYABLE)
# ==================== CUSTOMERS ====================
@app.get("/api/customers", tags=["Customers"])
async def list_customers():
return db.get_customers()
@app.get("/api/customers/{customer_id}", tags=["Customers"])
async def get_customer(customer_id: int):
customer = db.get_customer(customer_id)
if not customer:
raise HTTPException(status_code=404, detail="Cliente non trovato")
return customer
@app.post("/api/customers", tags=["Customers"])
async def create_customer(customer: Customer):
return db.create_customer(customer)
@app.put("/api/customers/{customer_id}", tags=["Customers"])
async def update_customer(customer_id: int, customer_data: dict):
result = db.update_customer(customer_id, customer_data)
if not result:
raise HTTPException(status_code=404, detail="Cliente non trovato")
return result
# ============================================================
# ANAGRAFICHE
# ============================================================
@app.get("/anagrafiche", response_class=HTMLResponse)
async def anagrafiche(request: Request):
return render(request, "anagrafiche.html", "anagrafiche")
@app.get("/customers", response_class=HTMLResponse)
async def customers_page(request: Request):
return templates.TemplateResponse(request, "customers.html", {
"customers": db.get_customers()
})
# ==================== SUPPLIERS ====================
@app.get("/api/suppliers", tags=["Suppliers"])
async def list_suppliers():
return db.get_suppliers()
@app.get("/api/suppliers/{supplier_id}", tags=["Suppliers"])
async def get_supplier(supplier_id: int):
supplier = db.get_supplier(supplier_id)
if not supplier:
raise HTTPException(status_code=404, detail="Fornitore non trovato")
return supplier
@app.post("/api/suppliers", tags=["Suppliers"])
async def create_supplier(supplier: Supplier):
return db.create_supplier(supplier)
return render(request, "customers.html", "anagrafiche", customers=db.get_customers())
@app.get("/suppliers", response_class=HTMLResponse)
async def suppliers_page(request: Request):
return templates.TemplateResponse(request, "suppliers.html", {
"suppliers": db.get_suppliers()
})
# ==================== PRODUCTS ====================
@app.get("/api/products", tags=["Products"])
async def list_products():
return db.get_products()
@app.get("/api/products/{product_id}", tags=["Products"])
async def get_product(product_id: int):
product = db.get_product(product_id)
if not product:
raise HTTPException(status_code=404, detail="Articolo non trovato")
return product
@app.post("/api/products", tags=["Products"])
async def create_product(product: Product):
return db.create_product(product)
return render(request, "suppliers.html", "anagrafiche", suppliers=db.get_suppliers())
@app.get("/products", response_class=HTMLResponse)
async def products_page(request: Request):
products = db.get_products()
return templates.TemplateResponse(request, "products.html", {
"products": products,
"low_stock": [p for p in products if p.quantity_in_stock <= p.reorder_level]
})
return render(request, "products.html", "anagrafiche", products=products,
low_stock=[p for p in products if p.quantity_in_stock <= p.reorder_level])
# ==================== SALES ORDERS ====================
@app.get("/api/sales-orders", tags=["Sales Orders"])
async def list_sales_orders():
return db.get_sales_orders()
@app.get("/api/sales-orders/{order_id}", tags=["Sales Orders"])
async def get_sales_order(order_id: int):
order = db.get_sales_order(order_id)
if not order:
raise HTTPException(status_code=404, detail="Ordine vendita non trovato")
return order
@app.post("/api/sales-orders", tags=["Sales Orders"])
async def create_sales_order(order: SalesOrder):
return db.create_sales_order(order)
# ============================================================
# VENDITE
# ============================================================
@app.get("/vendite", response_class=HTMLResponse)
async def vendite(request: Request):
return render(request, "vendite.html", "vendite")
@app.get("/sales-orders", response_class=HTMLResponse)
async def sales_orders_page(request: Request):
orders = db.get_sales_orders()
return templates.TemplateResponse(request, "sales_orders.html", {
"orders": orders,
"pending": [o for o in orders if o.status != OrderStatus.RECEIVED]
})
return render(request, "sales_orders.html", "vendite", orders=orders,
pending=[o for o in orders if o.status != OrderStatus.RECEIVED])
# ==================== PURCHASE ORDERS ====================
@app.get("/api/purchase-orders", tags=["Purchase Orders"])
async def list_purchase_orders():
return db.get_purchase_orders()
@app.get("/invoices", response_class=HTMLResponse)
async def invoices_page(request: Request, type: str = "all"):
invoices = db.get_invoices()
selected = "all"
if type != "all":
try:
dt = DocumentType(type)
invoices = [i for i in invoices if i.doc_type == dt]
selected = type
except ValueError:
pass
invoices = sorted(invoices, key=lambda x: x.doc_date, reverse=True)
return render(request, "invoices.html", "vendite", invoices=invoices,
selected_type=selected, doc_types=list(DocumentType))
@app.get("/api/purchase-orders/{order_id}", tags=["Purchase Orders"])
async def get_purchase_order(order_id: int):
order = db.get_purchase_order(order_id)
if not order:
raise HTTPException(status_code=404, detail="Ordine acquisto non trovato")
return order
@app.get("/invoices/{invoice_id}", response_class=HTMLResponse)
async def invoice_detail_page(request: Request, invoice_id: int):
invoice = db.get_invoice(invoice_id)
if not invoice:
raise HTTPException(status_code=404, detail="Documento non trovato")
return render(request, "invoice_detail.html", "vendite", invoice=invoice)
@app.post("/api/purchase-orders", tags=["Purchase Orders"])
async def create_purchase_order(order: PurchaseOrder):
return db.create_purchase_order(order)
# ============================================================
# ACQUISTI
# ============================================================
@app.get("/acquisti", response_class=HTMLResponse)
async def acquisti(request: Request):
return render(request, "acquisti.html", "acquisti")
@app.get("/purchase-orders", response_class=HTMLResponse)
async def purchase_orders_page(request: Request):
orders = db.get_purchase_orders()
return templates.TemplateResponse(request, "purchase_orders.html", {
"orders": orders,
"pending": [o for o in orders if o.status != OrderStatus.RECEIVED]
})
return render(request, "purchase_orders.html", "acquisti", orders=orders,
pending=[o for o in orders if o.status != OrderStatus.RECEIVED])
# ==================== WAREHOUSE ====================
@app.get("/api/warehouse/stats", tags=["Warehouse"])
async def warehouse_stats():
stats = db.get_dashboard_stats()
return {
"total_products": stats["total_products"],
"low_stock_count": stats["low_stock_count"],
"low_stock_items": stats["low_stock_items"],
"all_products": db.get_products()
}
# ============================================================
# AMMINISTRAZIONE / SCADENZARIO (AP_AR)
# ============================================================
@app.get("/amministrazione", response_class=HTMLResponse)
async def amministrazione(request: Request):
return render(request, "amministrazione.html", "amministrazione")
@app.get("/scadenzario", response_class=HTMLResponse)
async def scadenzario_page(request: Request, type: str = "all"):
schedules = db.get_payment_schedules()
selected = "all"
if type in ("receivable", "payable"):
st = PaymentScheduleType(type)
schedules = [s for s in schedules if s.type == st]
selected = type
# ordina: prima le aperte per data scadenza, poi le chiuse
schedules = sorted(schedules, key=lambda s: (s.closed, s.due_date))
return render(request, "scadenzario.html", "amministrazione",
schedules=schedules, selected_type=selected,
RECEIVABLE=PaymentScheduleType.RECEIVABLE, PAYABLE=PaymentScheduleType.PAYABLE)
@app.post("/scadenzario/{schedule_id}/settle")
async def settle_schedule_action(schedule_id: int):
s = db.settle_schedule(schedule_id)
if not s:
raise HTTPException(status_code=404, detail="Partita non trovata o già saldata")
return {"status": "ok", "schedule_id": schedule_id, "closed": s.closed}
# ============================================================
# MAGAZZINO / LOGISTICA
# ============================================================
@app.get("/magazzino", response_class=HTMLResponse)
async def magazzino(request: Request):
return render(request, "magazzino.html", "magazzino")
@app.get("/warehouse", response_class=HTMLResponse)
async def warehouse_page(request: Request):
stats = db.get_dashboard_stats()
return templates.TemplateResponse(request, "warehouse.html", {
"products": db.get_products(),
"low_stock": stats["low_stock_items"]
})
return render(request, "warehouse.html", "magazzino", products=db.get_products(),
low_stock=stats["low_stock_items"], warehouse_value=stats["warehouse_value"])
# ==================== INFO ====================
@app.get("/inventory-movements", response_class=HTMLResponse)
async def inventory_movements_page(request: Request):
movements = sorted(db.get_inventory_movements(), key=lambda m: m.posting_date, reverse=True)
return render(request, "inventory_movements.html", "magazzino", movements=movements)
@app.get("/inventory-movements/{movement_id}", response_class=HTMLResponse)
async def movement_detail_page(request: Request, movement_id: int):
movement = db.get_inventory_movement(movement_id)
if not movement:
raise HTTPException(status_code=404, detail="Movimento non trovato")
return render(request, "movement_detail.html", "magazzino", movement=movement)
@app.post("/inventory-movements/{movement_id}/post")
async def post_movement_action(movement_id: int):
m = db.post_movement(movement_id)
if not m:
raise HTTPException(status_code=404, detail="Movimento non trovato o già confermato")
return {"status": "ok", "movement_id": movement_id, "posted": m.posted}
@app.get("/inventory-reasons", response_class=HTMLResponse)
async def inventory_reasons_page(request: Request):
return render(request, "inventory_reasons.html", "magazzino", reasons=db.get_inventory_reasons())
# ============================================================
# API REST
# ============================================================
@app.get("/api/customers", tags=["Customers"])
async def api_customers(): return db.get_customers()
@app.get("/api/customers/{cid}", tags=["Customers"])
async def api_customer(cid: int):
c = db.get_customer(cid)
if not c: raise HTTPException(404, "Cliente non trovato")
return c
@app.post("/api/customers", tags=["Customers"])
async def api_create_customer(customer: Customer): return db.create_customer(customer)
@app.get("/api/suppliers", tags=["Suppliers"])
async def api_suppliers(): return db.get_suppliers()
@app.get("/api/suppliers/{sid}", tags=["Suppliers"])
async def api_supplier(sid: int):
s = db.get_supplier(sid)
if not s: raise HTTPException(404, "Fornitore non trovato")
return s
@app.get("/api/products", tags=["Products"])
async def api_products(): return db.get_products()
@app.get("/api/products/{pid}", tags=["Products"])
async def api_product(pid: int):
p = db.get_product(pid)
if not p: raise HTTPException(404, "Articolo non trovato")
return p
@app.get("/api/sales-orders", tags=["Sales Orders"])
async def api_sales_orders(): return db.get_sales_orders()
@app.get("/api/sales-orders/{oid}", tags=["Sales Orders"])
async def api_sales_order(oid: int):
o = db.get_sales_order(oid)
if not o: raise HTTPException(404, "Ordine non trovato")
return o
@app.get("/api/purchase-orders", tags=["Purchase Orders"])
async def api_purchase_orders(): return db.get_purchase_orders()
@app.get("/api/purchase-orders/{oid}", tags=["Purchase Orders"])
async def api_purchase_order(oid: int):
o = db.get_purchase_order(oid)
if not o: raise HTTPException(404, "Ordine non trovato")
return o
@app.get("/api/invoices", tags=["Invoices"])
async def api_invoices(): return db.get_invoices()
@app.get("/api/invoices/{iid}", tags=["Invoices"])
async def api_invoice(iid: int):
i = db.get_invoice(iid)
if not i: raise HTTPException(404, "Documento non trovato")
return i
@app.post("/api/invoices", tags=["Invoices"])
async def api_create_invoice(invoice: Invoice): return db.create_invoice(invoice)
@app.get("/api/inventory-movements", tags=["Warehouse"])
async def api_movements(): return db.get_inventory_movements()
@app.get("/api/inventory-movements/{mid}", tags=["Warehouse"])
async def api_movement(mid: int):
m = db.get_inventory_movement(mid)
if not m: raise HTTPException(404, "Movimento non trovato")
return m
@app.post("/api/inventory-movements", tags=["Warehouse"])
async def api_create_movement(movement: InventoryMovement): return db.create_inventory_movement(movement)
@app.get("/api/inventory-reasons", tags=["Warehouse"])
async def api_reasons(): return db.get_inventory_reasons()
@app.get("/api/payment-schedules", tags=["Scadenzario"])
async def api_schedules(): return db.get_payment_schedules()
@app.get("/api/payment-schedules/{sid}", tags=["Scadenzario"])
async def api_schedule(sid: int):
s = db.get_payment_schedule(sid)
if not s: raise HTTPException(404, "Partita non trovata")
return s
@app.get("/api/dashboard/todos", tags=["Dashboard"])
async def api_todos(): return db.get_todo_items()
@app.get("/api/dashboard/cashflow", tags=["Dashboard"])
async def api_cashflow(days: int = 30): return db.get_cashflow_forecast(days)
@app.get("/api/info", tags=["Info"])
async def api_info():
stats = db.get_dashboard_stats()
return {
"name": "Mago4 Demo",
"version": "1.0.0",
"status": "active",
"database_type": "in-memory",
"statistics": stats
}
return {"name": "Mago4 Demo", "version": "2.0.0", "status": "active",
"database_type": "in-memory", "statistics": db.get_dashboard_stats()}
if __name__ == "__main__":