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>
324 lines
13 KiB
Python
324 lines
13 KiB
Python
from fastapi import FastAPI, HTTPException
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.templating import Jinja2Templates
|
|
from fastapi.responses import HTMLResponse
|
|
from starlette.requests import Request
|
|
from jinja2 import Environment, FileSystemLoader
|
|
from datetime import datetime
|
|
import os
|
|
|
|
from models import (
|
|
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 — Python FastAPI", version="2.0.0")
|
|
|
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
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)
|
|
_jinja_env = Environment(
|
|
loader=FileSystemLoader(os.path.join(BASE_DIR, "templates")),
|
|
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)
|
|
|
|
|
|
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()
|
|
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)
|
|
|
|
|
|
# ============================================================
|
|
# 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 render(request, "customers.html", "anagrafiche", customers=db.get_customers())
|
|
|
|
|
|
@app.get("/suppliers", response_class=HTMLResponse)
|
|
async def suppliers_page(request: Request):
|
|
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 render(request, "products.html", "anagrafiche", products=products,
|
|
low_stock=[p for p in products if p.quantity_in_stock <= p.reorder_level])
|
|
|
|
|
|
# ============================================================
|
|
# 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 render(request, "sales_orders.html", "vendite", orders=orders,
|
|
pending=[o for o in orders if o.status != OrderStatus.RECEIVED])
|
|
|
|
|
|
@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("/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)
|
|
|
|
|
|
# ============================================================
|
|
# 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 render(request, "purchase_orders.html", "acquisti", orders=orders,
|
|
pending=[o for o in orders if o.status != OrderStatus.RECEIVED])
|
|
|
|
|
|
# ============================================================
|
|
# 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 render(request, "warehouse.html", "magazzino", products=db.get_products(),
|
|
low_stock=stats["low_stock_items"], warehouse_value=stats["warehouse_value"])
|
|
|
|
|
|
@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():
|
|
return {"name": "Mago4 Demo", "version": "2.0.0", "status": "active",
|
|
"database_type": "in-memory", "statistics": db.get_dashboard_stats()}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
port = int(os.environ.get("PORT", 8000))
|
|
uvicorn.run(app, host="127.0.0.1", port=port)
|