495 lines
18 KiB
Python
495 lines
18 KiB
Python
import json
|
|
import logging
|
|
import os
|
|
import uuid
|
|
from datetime import datetime
|
|
from decimal import Decimal, ROUND_HALF_UP
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from fastapi import HTTPException
|
|
|
|
from crm import nextcloud
|
|
from crm.quotation_models import (
|
|
QuotationCreate,
|
|
QuotationInDB,
|
|
QuotationItemCreate,
|
|
QuotationItemInDB,
|
|
QuotationListItem,
|
|
QuotationUpdate,
|
|
)
|
|
from crm.service import get_customer
|
|
from mqtt import database as mqtt_db
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Path to Jinja2 templates directory (relative to this file)
|
|
_TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
|
|
|
|
|
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
def _d(value) -> Decimal:
|
|
"""Convert to Decimal safely."""
|
|
return Decimal(str(value if value is not None else 0))
|
|
|
|
|
|
def _float(d: Decimal) -> float:
|
|
"""Round Decimal to 2dp and return as float for storage."""
|
|
return float(d.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
|
|
|
|
|
|
def _calculate_totals(
|
|
items: list,
|
|
global_discount_percent: float,
|
|
shipping_cost: float,
|
|
shipping_cost_discount: float,
|
|
install_cost: float,
|
|
install_cost_discount: float,
|
|
extras_cost: float,
|
|
) -> dict:
|
|
"""
|
|
Calculate all monetary totals using Decimal arithmetic (ROUND_HALF_UP).
|
|
VAT is computed per-item from each item's vat_percent field.
|
|
Shipping and install costs carry 0% VAT.
|
|
Returns a dict of floats ready for DB storage.
|
|
"""
|
|
# Per-line totals and per-item VAT
|
|
item_totals = []
|
|
item_vat = Decimal(0)
|
|
for item in items:
|
|
cost = _d(item.get("unit_cost", 0))
|
|
qty = _d(item.get("quantity", 1))
|
|
disc = _d(item.get("discount_percent", 0))
|
|
net = cost * qty * (1 - disc / 100)
|
|
item_totals.append(net)
|
|
vat_pct = _d(item.get("vat_percent", 24))
|
|
item_vat += net * (vat_pct / 100)
|
|
|
|
# Shipping net (VAT = 0%)
|
|
ship_gross = _d(shipping_cost)
|
|
ship_disc = _d(shipping_cost_discount)
|
|
ship_net = ship_gross * (1 - ship_disc / 100)
|
|
|
|
# Install net (VAT = 0%)
|
|
install_gross = _d(install_cost)
|
|
install_disc = _d(install_cost_discount)
|
|
install_net = install_gross * (1 - install_disc / 100)
|
|
|
|
subtotal = sum(item_totals, Decimal(0)) + ship_net + install_net
|
|
|
|
global_disc_pct = _d(global_discount_percent)
|
|
global_disc_amount = subtotal * (global_disc_pct / 100)
|
|
new_subtotal = subtotal - global_disc_amount
|
|
|
|
# Global discount proportionally reduces VAT too
|
|
if subtotal > 0:
|
|
disc_ratio = new_subtotal / subtotal
|
|
vat_amount = item_vat * disc_ratio
|
|
else:
|
|
vat_amount = Decimal(0)
|
|
|
|
extras = _d(extras_cost)
|
|
final_total = new_subtotal + vat_amount + extras
|
|
|
|
return {
|
|
"subtotal_before_discount": _float(subtotal),
|
|
"global_discount_amount": _float(global_disc_amount),
|
|
"new_subtotal": _float(new_subtotal),
|
|
"vat_amount": _float(vat_amount),
|
|
"final_total": _float(final_total),
|
|
}
|
|
|
|
|
|
def _calc_line_total(item) -> float:
|
|
cost = _d(item.get("unit_cost", 0))
|
|
qty = _d(item.get("quantity", 1))
|
|
disc = _d(item.get("discount_percent", 0))
|
|
return _float(cost * qty * (1 - disc / 100))
|
|
|
|
|
|
async def _generate_quotation_number(db) -> str:
|
|
year = datetime.utcnow().year
|
|
prefix = f"QT-{year}-"
|
|
rows = await db.execute_fetchall(
|
|
"SELECT quotation_number FROM crm_quotations WHERE quotation_number LIKE ? ORDER BY quotation_number DESC LIMIT 1",
|
|
(f"{prefix}%",),
|
|
)
|
|
if rows:
|
|
last_num = rows[0][0] # e.g. "QT-2026-012"
|
|
try:
|
|
seq = int(last_num[len(prefix):]) + 1
|
|
except ValueError:
|
|
seq = 1
|
|
else:
|
|
seq = 1
|
|
return f"{prefix}{seq:03d}"
|
|
|
|
|
|
def _row_to_quotation(row: dict, items: list[dict]) -> QuotationInDB:
|
|
row = dict(row)
|
|
row["comments"] = json.loads(row.get("comments") or "[]")
|
|
row["quick_notes"] = json.loads(row.get("quick_notes") or "{}")
|
|
item_models = [QuotationItemInDB(**{k: v for k, v in i.items() if k in QuotationItemInDB.model_fields}) for i in items]
|
|
return QuotationInDB(**{k: v for k, v in row.items() if k in QuotationInDB.model_fields}, items=item_models)
|
|
|
|
|
|
def _row_to_list_item(row: dict) -> QuotationListItem:
|
|
return QuotationListItem(**{k: v for k, v in dict(row).items() if k in QuotationListItem.model_fields})
|
|
|
|
|
|
async def _fetch_items(db, quotation_id: str) -> list[dict]:
|
|
rows = await db.execute_fetchall(
|
|
"SELECT * FROM crm_quotation_items WHERE quotation_id = ? ORDER BY sort_order ASC",
|
|
(quotation_id,),
|
|
)
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
# ── Public API ────────────────────────────────────────────────────────────────
|
|
|
|
async def get_next_number() -> str:
|
|
db = await mqtt_db.get_db()
|
|
return await _generate_quotation_number(db)
|
|
|
|
|
|
async def list_quotations(customer_id: str) -> list[QuotationListItem]:
|
|
db = await mqtt_db.get_db()
|
|
rows = await db.execute_fetchall(
|
|
"SELECT id, quotation_number, title, customer_id, status, final_total, created_at, updated_at, nextcloud_pdf_url "
|
|
"FROM crm_quotations WHERE customer_id = ? ORDER BY created_at DESC",
|
|
(customer_id,),
|
|
)
|
|
return [_row_to_list_item(dict(r)) for r in rows]
|
|
|
|
|
|
async def get_quotation(quotation_id: str) -> QuotationInDB:
|
|
db = await mqtt_db.get_db()
|
|
rows = await db.execute_fetchall(
|
|
"SELECT * FROM crm_quotations WHERE id = ?", (quotation_id,)
|
|
)
|
|
if not rows:
|
|
raise HTTPException(status_code=404, detail="Quotation not found")
|
|
items = await _fetch_items(db, quotation_id)
|
|
return _row_to_quotation(dict(rows[0]), items)
|
|
|
|
|
|
async def create_quotation(data: QuotationCreate, generate_pdf: bool = False) -> QuotationInDB:
|
|
db = await mqtt_db.get_db()
|
|
now = datetime.utcnow().isoformat()
|
|
qid = str(uuid.uuid4())
|
|
quotation_number = await _generate_quotation_number(db)
|
|
|
|
# Build items list for calculation
|
|
items_raw = [item.model_dump() for item in data.items]
|
|
|
|
# Calculate per-item line totals
|
|
for item in items_raw:
|
|
item["line_total"] = _calc_line_total(item)
|
|
|
|
totals = _calculate_totals(
|
|
items_raw,
|
|
data.global_discount_percent,
|
|
data.shipping_cost,
|
|
data.shipping_cost_discount,
|
|
data.install_cost,
|
|
data.install_cost_discount,
|
|
data.extras_cost,
|
|
)
|
|
|
|
comments_json = json.dumps(data.comments)
|
|
quick_notes_json = json.dumps(data.quick_notes or {})
|
|
|
|
await db.execute(
|
|
"""INSERT INTO crm_quotations (
|
|
id, quotation_number, title, subtitle, customer_id,
|
|
language, status, order_type, shipping_method, estimated_shipping_date,
|
|
global_discount_label, global_discount_percent,
|
|
shipping_cost, shipping_cost_discount, install_cost, install_cost_discount,
|
|
extras_label, extras_cost, comments, quick_notes,
|
|
subtotal_before_discount, global_discount_amount, new_subtotal, vat_amount, final_total,
|
|
nextcloud_pdf_path, nextcloud_pdf_url,
|
|
client_org, client_name, client_location, client_phone, client_email,
|
|
created_at, updated_at
|
|
) VALUES (
|
|
?, ?, ?, ?, ?,
|
|
?, 'draft', ?, ?, ?,
|
|
?, ?,
|
|
?, ?, ?, ?,
|
|
?, ?, ?, ?,
|
|
?, ?, ?, ?, ?,
|
|
NULL, NULL,
|
|
?, ?, ?, ?, ?,
|
|
?, ?
|
|
)""",
|
|
(
|
|
qid, quotation_number, data.title, data.subtitle, data.customer_id,
|
|
data.language, data.order_type, data.shipping_method, data.estimated_shipping_date,
|
|
data.global_discount_label, data.global_discount_percent,
|
|
data.shipping_cost, data.shipping_cost_discount, data.install_cost, data.install_cost_discount,
|
|
data.extras_label, data.extras_cost, comments_json, quick_notes_json,
|
|
totals["subtotal_before_discount"], totals["global_discount_amount"],
|
|
totals["new_subtotal"], totals["vat_amount"], totals["final_total"],
|
|
data.client_org, data.client_name, data.client_location, data.client_phone, data.client_email,
|
|
now, now,
|
|
),
|
|
)
|
|
|
|
# Insert items
|
|
for i, item in enumerate(items_raw):
|
|
item_id = str(uuid.uuid4())
|
|
await db.execute(
|
|
"""INSERT INTO crm_quotation_items
|
|
(id, quotation_id, product_id, description, unit_type, unit_cost,
|
|
discount_percent, quantity, vat_percent, line_total, sort_order)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(
|
|
item_id, qid, item.get("product_id"), item.get("description"),
|
|
item.get("unit_type", "pcs"), item.get("unit_cost", 0),
|
|
item.get("discount_percent", 0), item.get("quantity", 1),
|
|
item.get("vat_percent", 24), item["line_total"], item.get("sort_order", i),
|
|
),
|
|
)
|
|
|
|
await db.commit()
|
|
|
|
quotation = await get_quotation(qid)
|
|
|
|
if generate_pdf:
|
|
quotation = await _do_generate_and_upload_pdf(quotation)
|
|
|
|
return quotation
|
|
|
|
|
|
async def update_quotation(quotation_id: str, data: QuotationUpdate, generate_pdf: bool = False) -> QuotationInDB:
|
|
db = await mqtt_db.get_db()
|
|
rows = await db.execute_fetchall(
|
|
"SELECT * FROM crm_quotations WHERE id = ?", (quotation_id,)
|
|
)
|
|
if not rows:
|
|
raise HTTPException(status_code=404, detail="Quotation not found")
|
|
|
|
existing = dict(rows[0])
|
|
now = datetime.utcnow().isoformat()
|
|
|
|
# Merge update into existing values
|
|
update_fields = data.model_dump(exclude_none=True)
|
|
|
|
# Build SET clause — handle comments JSON separately
|
|
set_parts = []
|
|
params = []
|
|
|
|
scalar_fields = [
|
|
"title", "subtitle", "language", "status", "order_type", "shipping_method",
|
|
"estimated_shipping_date", "global_discount_label", "global_discount_percent",
|
|
"shipping_cost", "shipping_cost_discount", "install_cost",
|
|
"install_cost_discount", "extras_label", "extras_cost",
|
|
"client_org", "client_name", "client_location", "client_phone", "client_email",
|
|
]
|
|
|
|
for field in scalar_fields:
|
|
if field in update_fields:
|
|
set_parts.append(f"{field} = ?")
|
|
params.append(update_fields[field])
|
|
|
|
if "comments" in update_fields:
|
|
set_parts.append("comments = ?")
|
|
params.append(json.dumps(update_fields["comments"]))
|
|
|
|
if "quick_notes" in update_fields:
|
|
set_parts.append("quick_notes = ?")
|
|
params.append(json.dumps(update_fields["quick_notes"] or {}))
|
|
|
|
# Recalculate totals using merged values
|
|
merged = {**existing, **{k: update_fields.get(k, existing.get(k)) for k in scalar_fields}}
|
|
|
|
# If items are being updated, recalculate with new items; otherwise use existing items
|
|
if "items" in update_fields:
|
|
items_raw = [item.model_dump() for item in data.items]
|
|
for item in items_raw:
|
|
item["line_total"] = _calc_line_total(item)
|
|
else:
|
|
existing_items = await _fetch_items(db, quotation_id)
|
|
items_raw = existing_items
|
|
|
|
totals = _calculate_totals(
|
|
items_raw,
|
|
float(merged.get("global_discount_percent", 0)),
|
|
float(merged.get("shipping_cost", 0)),
|
|
float(merged.get("shipping_cost_discount", 0)),
|
|
float(merged.get("install_cost", 0)),
|
|
float(merged.get("install_cost_discount", 0)),
|
|
float(merged.get("extras_cost", 0)),
|
|
)
|
|
|
|
for field, val in totals.items():
|
|
set_parts.append(f"{field} = ?")
|
|
params.append(val)
|
|
|
|
set_parts.append("updated_at = ?")
|
|
params.append(now)
|
|
params.append(quotation_id)
|
|
|
|
if set_parts:
|
|
await db.execute(
|
|
f"UPDATE crm_quotations SET {', '.join(set_parts)} WHERE id = ?",
|
|
params,
|
|
)
|
|
|
|
# Replace items if provided
|
|
if "items" in update_fields:
|
|
await db.execute("DELETE FROM crm_quotation_items WHERE quotation_id = ?", (quotation_id,))
|
|
for i, item in enumerate(items_raw):
|
|
item_id = str(uuid.uuid4())
|
|
await db.execute(
|
|
"""INSERT INTO crm_quotation_items
|
|
(id, quotation_id, product_id, description, unit_type, unit_cost,
|
|
discount_percent, quantity, vat_percent, line_total, sort_order)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(
|
|
item_id, quotation_id, item.get("product_id"), item.get("description"),
|
|
item.get("unit_type", "pcs"), item.get("unit_cost", 0),
|
|
item.get("discount_percent", 0), item.get("quantity", 1),
|
|
item.get("vat_percent", 24), item["line_total"], item.get("sort_order", i),
|
|
),
|
|
)
|
|
|
|
await db.commit()
|
|
|
|
quotation = await get_quotation(quotation_id)
|
|
|
|
if generate_pdf:
|
|
quotation = await _do_generate_and_upload_pdf(quotation)
|
|
|
|
return quotation
|
|
|
|
|
|
async def delete_quotation(quotation_id: str) -> None:
|
|
db = await mqtt_db.get_db()
|
|
rows = await db.execute_fetchall(
|
|
"SELECT nextcloud_pdf_path FROM crm_quotations WHERE id = ?", (quotation_id,)
|
|
)
|
|
if not rows:
|
|
raise HTTPException(status_code=404, detail="Quotation not found")
|
|
|
|
pdf_path = dict(rows[0]).get("nextcloud_pdf_path")
|
|
|
|
await db.execute("DELETE FROM crm_quotation_items WHERE quotation_id = ?", (quotation_id,))
|
|
await db.execute("DELETE FROM crm_quotations WHERE id = ?", (quotation_id,))
|
|
await db.commit()
|
|
|
|
# Remove PDF from Nextcloud (best-effort)
|
|
if pdf_path:
|
|
try:
|
|
await nextcloud.delete_file(pdf_path)
|
|
except Exception as e:
|
|
logger.warning("Failed to delete PDF from Nextcloud (%s): %s", pdf_path, e)
|
|
|
|
|
|
# ── PDF Generation ─────────────────────────────────────────────────────────────
|
|
|
|
async def _do_generate_and_upload_pdf(quotation: QuotationInDB) -> QuotationInDB:
|
|
"""Generate PDF, upload to Nextcloud, update DB record. Returns updated quotation."""
|
|
try:
|
|
customer = get_customer(quotation.customer_id)
|
|
except Exception as e:
|
|
logger.error("Cannot generate PDF — customer not found: %s", e)
|
|
return quotation
|
|
|
|
try:
|
|
pdf_bytes = await _generate_pdf_bytes(quotation, customer)
|
|
except Exception as e:
|
|
logger.error("PDF generation failed for quotation %s: %s", quotation.id, e)
|
|
return quotation
|
|
|
|
# Delete old PDF if present
|
|
if quotation.nextcloud_pdf_path:
|
|
try:
|
|
await nextcloud.delete_file(quotation.nextcloud_pdf_path)
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
pdf_path, pdf_url = await _upload_pdf(customer, quotation, pdf_bytes)
|
|
except Exception as e:
|
|
logger.error("PDF upload failed for quotation %s: %s", quotation.id, e)
|
|
return quotation
|
|
|
|
# Persist paths
|
|
db = await mqtt_db.get_db()
|
|
await db.execute(
|
|
"UPDATE crm_quotations SET nextcloud_pdf_path = ?, nextcloud_pdf_url = ? WHERE id = ?",
|
|
(pdf_path, pdf_url, quotation.id),
|
|
)
|
|
await db.commit()
|
|
|
|
return await get_quotation(quotation.id)
|
|
|
|
|
|
async def _generate_pdf_bytes(quotation: QuotationInDB, customer) -> bytes:
|
|
"""Render Jinja2 template and convert to PDF via WeasyPrint."""
|
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
|
import weasyprint
|
|
|
|
env = Environment(
|
|
loader=FileSystemLoader(str(_TEMPLATES_DIR)),
|
|
autoescape=select_autoescape(["html"]),
|
|
)
|
|
|
|
def format_money(value):
|
|
try:
|
|
f = float(value)
|
|
# Greek-style: dot thousands separator, comma decimal
|
|
formatted = f"{f:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
|
|
return f"{formatted} €"
|
|
except (TypeError, ValueError):
|
|
return "0,00 €"
|
|
|
|
env.filters["format_money"] = format_money
|
|
|
|
template = env.get_template("quotation.html")
|
|
|
|
html_str = template.render(
|
|
quotation=quotation,
|
|
customer=customer,
|
|
lang=quotation.language,
|
|
)
|
|
|
|
pdf = weasyprint.HTML(string=html_str, base_url=str(_TEMPLATES_DIR)).write_pdf()
|
|
return pdf
|
|
|
|
|
|
async def _upload_pdf(customer, quotation: QuotationInDB, pdf_bytes: bytes) -> tuple[str, str]:
|
|
"""Upload PDF to Nextcloud, return (relative_path, public_url)."""
|
|
from crm.service import get_customer_nc_path
|
|
from config import settings
|
|
|
|
nc_folder = get_customer_nc_path(customer)
|
|
date_str = datetime.utcnow().strftime("%Y-%m-%d")
|
|
filename = f"Quotation-{quotation.quotation_number}-{date_str}.pdf"
|
|
rel_path = f"customers/{nc_folder}/quotations/{filename}"
|
|
|
|
await nextcloud.ensure_folder(f"customers/{nc_folder}/quotations")
|
|
await nextcloud.upload_file(rel_path, pdf_bytes, "application/pdf")
|
|
|
|
# Construct a direct WebDAV download URL
|
|
from crm.nextcloud import _full_url
|
|
pdf_url = _full_url(rel_path)
|
|
|
|
return rel_path, pdf_url
|
|
|
|
|
|
async def regenerate_pdf(quotation_id: str) -> QuotationInDB:
|
|
quotation = await get_quotation(quotation_id)
|
|
return await _do_generate_and_upload_pdf(quotation)
|
|
|
|
|
|
async def get_quotation_pdf_bytes(quotation_id: str) -> bytes:
|
|
"""Download the PDF for a quotation from Nextcloud and return raw bytes."""
|
|
from fastapi import HTTPException
|
|
quotation = await get_quotation(quotation_id)
|
|
if not quotation.nextcloud_pdf_path:
|
|
raise HTTPException(status_code=404, detail="No PDF generated for this quotation")
|
|
pdf_bytes, _ = await nextcloud.download_file(quotation.nextcloud_pdf_path)
|
|
return pdf_bytes
|