Release v4928.1.4.2 stable
This commit is contained in:
533
app/product_service.py
Normal file
533
app/product_service.py
Normal file
@@ -0,0 +1,533 @@
|
||||
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 _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.")
|
||||
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(data.get("default_unit_price")),
|
||||
"vat_rate": _money(data.get("vat_rate", "23")),
|
||||
"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.")
|
||||
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(data.get("default_unit_price")),
|
||||
"vat_rate": _money(data.get("vat_rate", "23")),
|
||||
"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.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 = _decimal(quantity, "1")
|
||||
price = _decimal(unit_price if unit_price not in {None, ""} else (product or {}).get("default_unit_price"), "0")
|
||||
discount = _decimal(discount_amount, "0")
|
||||
total = max(Decimal("0"), (q * price) - discount)
|
||||
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 = _decimal(data.get("quantity"), "1")
|
||||
price = _decimal(data.get("unit_price"), "0")
|
||||
discount = _decimal(data.get("discount_amount"), "0")
|
||||
total = max(Decimal("0"), (q * price) - discount)
|
||||
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
|
||||
Reference in New Issue
Block a user