import json import uuid from decimal import Decimal, InvalidOperation from typing import Any, Dict, List, Optional from sqlalchemy import text from app.db import engine _PRODUCT_SCHEMA_READY = False DEFAULT_PRODUCTS = [ { "sku": "EV-AC-7KW", "jasmin_sales_item": "CARREGADOR_MONO_7KW", "name": "Carregador EV 7.4kW monofásico", "category": "Carregadores", "description": "Carregador AC monofásico para moradias e pequenas instalações.", "default_unit_price": "590.00", "vat_rate": "23.00", }, { "sku": "EV-AC-22KW", "jasmin_sales_item": "CARREGADOR_TRIF_22KW", "name": "Carregador EV 22kW trifásico", "category": "Carregadores", "description": "Carregador AC trifásico para empresas, condomínios e instalações com maior potência.", "default_unit_price": "790.00", "vat_rate": "23.00", }, { "sku": "CAB-T2-5M", "jasmin_sales_item": "CABO_VE", "name": "Cabo Type 2 5m", "category": "Acessórios", "description": "Cabo Type 2 para carregamento de veículos elétricos.", "default_unit_price": "120.00", "vat_rate": "23.00", }, { "sku": "CAB-T2-7M", "jasmin_sales_item": "CABO_VE", "name": "Cabo Type 2 7m", "category": "Acessórios", "description": "Cabo Type 2 de 7 metros para carregamento de veículos elétricos.", "default_unit_price": "150.00", "vat_rate": "23.00", }, { "sku": "INST-BASIC", "jasmin_sales_item": "INSTALACAO", "name": "Instalação básica", "category": "Serviços", "description": "Serviço de instalação básica sujeito a validação técnica.", "default_unit_price": "350.00", "vat_rate": "23.00", }, { "sku": "SHIP-STD", "jasmin_sales_item": "TRANSPORTE", "name": "Transporte nacional", "category": "Serviços", "description": "Envio nacional standard.", "default_unit_price": "15.00", "vat_rate": "23.00", }, ] def _json(value: Any) -> str: return json.dumps(value or {}, ensure_ascii=False, default=str) def _uuid_or_none(value: Optional[str]) -> Optional[str]: value = str(value or "").strip() return value or None def _decimal(value: Any, default: str = "0") -> Decimal: try: if value is None or str(value).strip() == "": return Decimal(default) return Decimal(str(value).replace(",", ".").strip()) except (InvalidOperation, ValueError): return Decimal(default) def _money(value: Any) -> str: return f"{_decimal(value):.2f}" def _strict_decimal(value: Any, field_name: str) -> Decimal: try: if value is None or str(value).strip() == "": raise InvalidOperation() return Decimal(str(value).replace(",", ".").strip()) except (InvalidOperation, ValueError): raise ValueError(f"{field_name} inválido.") def _non_negative_decimal(value: Any, field_name: str, default: str = "0") -> Decimal: if value is None or str(value).strip() == "": value = default d = _strict_decimal(value, field_name) if d < 0: raise ValueError(f"{field_name} não pode ser negativo.") return d def _positive_decimal(value: Any, field_name: str, default: str = "1") -> Decimal: if value is None or str(value).strip() == "": value = default d = _strict_decimal(value, field_name) if d <= 0: raise ValueError(f"{field_name} deve ser superior a zero.") return d def _bool(value: Any) -> bool: return str(value or "").lower() in {"1", "true", "yes", "on", "sim", "ativo"} def ensure_product_schema(*, seed: bool = True) -> None: """Cria catálogo simples de produtos e tabelas de linhas. Sem variantes: cada produto/preço configurável fica numa linha em `products`. As linhas de oportunidade/encomenda copiam nome, SKU e preço no momento para preservar histórico quando o catálogo for alterado. """ global _PRODUCT_SCHEMA_READY if _PRODUCT_SCHEMA_READY: return with engine.begin() as conn: conn.execute(text(""" CREATE TABLE IF NOT EXISTS products ( id UUID PRIMARY KEY, sku TEXT UNIQUE NOT NULL, jasmin_sales_item TEXT, name TEXT NOT NULL, category TEXT NOT NULL DEFAULT 'Geral', description TEXT, default_unit_price NUMERIC(12,2) NOT NULL DEFAULT 0, vat_rate NUMERIC(5,2) NOT NULL DEFAULT 23, active BOOLEAN NOT NULL DEFAULT TRUE, metadata JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) """)) conn.execute(text(""" CREATE TABLE IF NOT EXISTS opportunity_items ( id UUID PRIMARY KEY, opportunity_id UUID NOT NULL REFERENCES opportunities(id) ON DELETE CASCADE, product_id UUID REFERENCES products(id) ON DELETE SET NULL, sku TEXT, jasmin_sales_item TEXT, product_name TEXT NOT NULL, description TEXT, quantity NUMERIC(12,2) NOT NULL DEFAULT 1, unit_price NUMERIC(12,2) NOT NULL DEFAULT 0, discount_amount NUMERIC(12,2) NOT NULL DEFAULT 0, total_price NUMERIC(12,2) NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'INTERESTED', metadata JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) """)) conn.execute(text(""" CREATE TABLE IF NOT EXISTS order_items ( id UUID PRIMARY KEY, order_id UUID, opportunity_id UUID REFERENCES opportunities(id) ON DELETE SET NULL, opportunity_item_id UUID REFERENCES opportunity_items(id) ON DELETE SET NULL, product_id UUID REFERENCES products(id) ON DELETE SET NULL, sku TEXT, jasmin_sales_item TEXT, product_name TEXT NOT NULL, description TEXT, quantity NUMERIC(12,2) NOT NULL DEFAULT 1, unit_price NUMERIC(12,2) NOT NULL DEFAULT 0, total_price NUMERIC(12,2) NOT NULL DEFAULT 0, fulfillment_status TEXT NOT NULL DEFAULT 'PENDING', metadata JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) """)) for stmt in [ "ALTER TABLE products ADD COLUMN IF NOT EXISTS jasmin_sales_item TEXT", "ALTER TABLE opportunity_items ADD COLUMN IF NOT EXISTS jasmin_sales_item TEXT", "ALTER TABLE order_items ADD COLUMN IF NOT EXISTS jasmin_sales_item TEXT", ]: conn.execute(text(stmt)) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_products_category ON products(category)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_products_active ON products(active)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_opportunity_items_opp ON opportunity_items(opportunity_id)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_order_items_opp ON order_items(opportunity_id)")) if seed: count = conn.execute(text("SELECT count(*) FROM products")).scalar() or 0 if int(count) == 0: for product in DEFAULT_PRODUCTS: conn.execute(text(""" INSERT INTO products ( id, sku, jasmin_sales_item, name, category, description, default_unit_price, vat_rate, active, metadata ) VALUES ( CAST(:id AS UUID), :sku, :jasmin_sales_item, :name, :category, :description, :default_unit_price, :vat_rate, TRUE, CAST(:metadata AS JSONB) ) ON CONFLICT (sku) DO NOTHING """), { "id": str(uuid.uuid4()), "sku": product["sku"], "jasmin_sales_item": product.get("jasmin_sales_item"), "name": product["name"], "category": product["category"], "description": product["description"], "default_unit_price": product["default_unit_price"], "vat_rate": product["vat_rate"], "metadata": _json({"seed": True}), }) _PRODUCT_SCHEMA_READY = True def list_product_categories() -> List[str]: ensure_product_schema() with engine.begin() as conn: rows = conn.execute(text(""" SELECT DISTINCT category FROM products WHERE COALESCE(category, '') <> '' ORDER BY category """)).all() return [str(row[0]) for row in rows] def list_products( *, q: Optional[str] = None, category: Optional[str] = None, active: Optional[str] = "true", limit: int = 300, ) -> List[Dict[str, Any]]: ensure_product_schema() filters = [] params: Dict[str, Any] = {"limit": int(limit)} if q: filters.append("(sku ILIKE :q OR COALESCE(jasmin_sales_item, '') ILIKE :q OR name ILIKE :q OR category ILIKE :q OR COALESCE(description, '') ILIKE :q)") params["q"] = f"%{str(q).strip()}%" if category and category != "all": filters.append("category = :category") params["category"] = category if active in {"true", "false"}: filters.append("active = :active") params["active"] = active == "true" where_sql = "WHERE " + " AND ".join(filters) if filters else "" sql = text(f""" SELECT id::text, sku, jasmin_sales_item, name, category, description, default_unit_price, vat_rate, active, created_at, updated_at FROM products {where_sql} ORDER BY active DESC, category ASC, name ASC LIMIT :limit """) with engine.begin() as conn: rows = conn.execute(sql, params).mappings().all() return [dict(row) for row in rows] def get_product(product_id: str) -> Optional[Dict[str, Any]]: ensure_product_schema() sql = text(""" SELECT id::text, sku, jasmin_sales_item, name, category, description, default_unit_price, vat_rate, active, metadata, created_at, updated_at FROM products WHERE id = CAST(:product_id AS UUID) LIMIT 1 """) with engine.begin() as conn: row = conn.execute(sql, {"product_id": product_id}).mappings().first() return dict(row) if row else None def create_product(data: Dict[str, Any]) -> str: ensure_product_schema() product_id = str(uuid.uuid4()) sku = str(data.get("sku") or "").strip().upper() name = str(data.get("name") or "").strip() if not sku: raise ValueError("SKU é obrigatório.") if not name: raise ValueError("Nome do produto é obrigatório.") default_unit_price = _non_negative_decimal(data.get("default_unit_price", data.get("price")), "Preço base") vat_rate = _non_negative_decimal(data.get("vat_rate", "23"), "IVA") with engine.begin() as conn: conn.execute(text(""" INSERT INTO products ( id, sku, jasmin_sales_item, name, category, description, default_unit_price, vat_rate, active, metadata ) VALUES ( CAST(:id AS UUID), :sku, :jasmin_sales_item, :name, :category, :description, :default_unit_price, :vat_rate, :active, CAST(:metadata AS JSONB) ) """), { "id": product_id, "sku": sku, "jasmin_sales_item": str(data.get("jasmin_sales_item") or "").strip().upper() or None, "name": name, "category": str(data.get("category") or "Geral").strip() or "Geral", "description": str(data.get("description") or "").strip(), "default_unit_price": _money(default_unit_price), "vat_rate": _money(vat_rate), "active": _bool(data.get("active", "true")), "metadata": _json({}), }) return product_id def update_product(product_id: str, data: Dict[str, Any]) -> bool: ensure_product_schema() sku = str(data.get("sku") or "").strip().upper() name = str(data.get("name") or "").strip() if not sku: raise ValueError("SKU é obrigatório.") if not name: raise ValueError("Nome do produto é obrigatório.") default_unit_price = _non_negative_decimal(data.get("default_unit_price", data.get("price")), "Preço base") vat_rate = _non_negative_decimal(data.get("vat_rate", "23"), "IVA") with engine.begin() as conn: result = conn.execute(text(""" UPDATE products SET sku = :sku, jasmin_sales_item = :jasmin_sales_item, name = :name, category = :category, description = :description, default_unit_price = :default_unit_price, vat_rate = :vat_rate, active = :active, updated_at = now() WHERE id = CAST(:product_id AS UUID) """), { "product_id": product_id, "sku": sku, "jasmin_sales_item": str(data.get("jasmin_sales_item") or "").strip().upper() or None, "name": name, "category": str(data.get("category") or "Geral").strip() or "Geral", "description": str(data.get("description") or "").strip(), "default_unit_price": _money(default_unit_price), "vat_rate": _money(vat_rate), "active": _bool(data.get("active")), }) return result.rowcount > 0 def set_product_active(product_id: str, active: bool) -> bool: ensure_product_schema() with engine.begin() as conn: result = conn.execute(text(""" UPDATE products SET active = :active, updated_at = now() WHERE id = CAST(:product_id AS UUID) """), {"product_id": product_id, "active": bool(active)}) return result.rowcount > 0 def list_opportunity_items(opportunity_id: str) -> List[Dict[str, Any]]: ensure_product_schema() sql = text(""" SELECT oi.id::text, oi.opportunity_id::text, oi.product_id::text, oi.sku, COALESCE(NULLIF(oi.jasmin_sales_item, ''), p.jasmin_sales_item) AS jasmin_sales_item, oi.product_name, oi.description, oi.quantity, oi.unit_price, oi.discount_amount, oi.total_price, oi.status, oi.metadata, oi.created_at, oi.updated_at, p.active AS product_active FROM opportunity_items oi LEFT JOIN products p ON p.id = oi.product_id WHERE oi.opportunity_id = CAST(:opportunity_id AS UUID) ORDER BY oi.created_at ASC """) with engine.begin() as conn: rows = conn.execute(sql, {"opportunity_id": opportunity_id}).mappings().all() return [dict(row) for row in rows] def _recalculate_opportunity_value(conn, opportunity_id: str) -> None: conn.execute(text(""" UPDATE opportunities SET value_amount = COALESCE(( SELECT SUM(total_price) FROM opportunity_items WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND status NOT IN ('REJECTED', 'CANCELLED') ), 0), updated_at = now() WHERE id = CAST(:opportunity_id AS UUID) """), {"opportunity_id": opportunity_id}) def add_opportunity_item( opportunity_id: str, *, product_id: Optional[str] = None, product_name: Optional[str] = None, sku: Optional[str] = None, jasmin_sales_item: Optional[str] = None, description: Optional[str] = None, quantity: Any = "1", unit_price: Any = None, discount_amount: Any = "0", status: str = "INTERESTED", ) -> str: ensure_product_schema() item_id = str(uuid.uuid4()) product: Optional[Dict[str, Any]] = get_product(product_id) if product_id else None name = str(product_name or (product or {}).get("name") or "").strip() if not name: raise ValueError("Produto é obrigatório.") q = _positive_decimal(quantity, "Quantidade", "1") price = _non_negative_decimal(unit_price if unit_price not in {None, ""} else (product or {}).get("default_unit_price"), "Preço unitário", "0") discount = _non_negative_decimal(discount_amount, "Desconto", "0") total = (q * price) - discount if total < 0: raise ValueError("Total da linha não pode ser negativo.") normalized_status = str(status or "INTERESTED").strip().upper() with engine.begin() as conn: conn.execute(text(""" INSERT INTO opportunity_items ( id, opportunity_id, product_id, sku, jasmin_sales_item, product_name, description, quantity, unit_price, discount_amount, total_price, status, metadata ) VALUES ( CAST(:id AS UUID), CAST(:opportunity_id AS UUID), CAST(:product_id AS UUID), :sku, :jasmin_sales_item, :product_name, :description, :quantity, :unit_price, :discount_amount, :total_price, :status, CAST(:metadata AS JSONB) ) """), { "id": item_id, "opportunity_id": opportunity_id, "product_id": _uuid_or_none(product_id), "sku": sku or (product or {}).get("sku") or "", "jasmin_sales_item": (jasmin_sales_item or (product or {}).get("jasmin_sales_item") or "").strip().upper() or None, "product_name": name, "description": description if description is not None else (product or {}).get("description") or "", "quantity": _money(q), "unit_price": _money(price), "discount_amount": _money(discount), "total_price": _money(total), "status": normalized_status, "metadata": _json({"source": "admin", "jasmin_sales_item": (jasmin_sales_item or (product or {}).get("jasmin_sales_item") or "").strip().upper()}), }) _recalculate_opportunity_value(conn, opportunity_id) conn.execute(text(""" INSERT INTO opportunity_events ( id, opportunity_id, event_type, note, payload, created_by ) VALUES ( CAST(:id AS UUID), CAST(:opportunity_id AS UUID), 'opportunity_item_added', :note, CAST(:payload AS JSONB), 'operator' ) """), { "id": str(uuid.uuid4()), "opportunity_id": opportunity_id, "note": f"Produto adicionado: {name} x {_money(q)}", "payload": _json({"item_id": item_id, "sku": sku or (product or {}).get("sku") or "", "jasmin_sales_item": (jasmin_sales_item or (product or {}).get("jasmin_sales_item") or "").strip().upper()}), }) return item_id def update_opportunity_item(item_id: str, data: Dict[str, Any]) -> Optional[str]: ensure_product_schema() q = _positive_decimal(data.get("quantity"), "Quantidade", "1") price = _non_negative_decimal(data.get("unit_price"), "Preço unitário", "0") discount = _non_negative_decimal(data.get("discount_amount"), "Desconto", "0") total = (q * price) - discount if total < 0: raise ValueError("Total da linha não pode ser negativo.") status = str(data.get("status") or "INTERESTED").strip().upper() with engine.begin() as conn: row = conn.execute(text(""" UPDATE opportunity_items SET jasmin_sales_item = COALESCE(NULLIF(:jasmin_sales_item, ''), jasmin_sales_item), quantity = :quantity, unit_price = :unit_price, discount_amount = :discount_amount, total_price = :total_price, status = :status, updated_at = now() WHERE id = CAST(:item_id AS UUID) RETURNING opportunity_id::text """), { "item_id": item_id, "jasmin_sales_item": str(data.get("jasmin_sales_item") or "").strip().upper(), "quantity": _money(q), "unit_price": _money(price), "discount_amount": _money(discount), "total_price": _money(total), "status": status, }).first() if row: _recalculate_opportunity_value(conn, str(row[0])) return str(row[0]) return None def delete_opportunity_item(item_id: str) -> Optional[str]: ensure_product_schema() with engine.begin() as conn: row = conn.execute(text(""" DELETE FROM opportunity_items WHERE id = CAST(:item_id AS UUID) RETURNING opportunity_id::text, product_name """), {"item_id": item_id}).first() if row: opportunity_id = str(row[0]) _recalculate_opportunity_value(conn, opportunity_id) conn.execute(text(""" INSERT INTO opportunity_events ( id, opportunity_id, event_type, note, payload, created_by ) VALUES ( CAST(:id AS UUID), CAST(:opportunity_id AS UUID), 'opportunity_item_deleted', :note, '{}'::jsonb, 'operator' ) """), { "id": str(uuid.uuid4()), "opportunity_id": opportunity_id, "note": f"Produto removido: {row[1] or 'produto'}", }) return opportunity_id return None