Import ClientFlow production v4928.1.5.132.4
This commit is contained in:
@@ -54,8 +54,10 @@ def _priority_for_action(action_code: str, route: str) -> str:
|
||||
"""
|
||||
code = str(action_code or "").strip().upper()
|
||||
route = str(route or "").strip().lower()
|
||||
if code in {"SEND_PROFORMA", "SEND_INVOICE", "CONFIRM_PAYMENT", "PREPARE_ORDER", "CREATE_SHIPMENT"}:
|
||||
if code in {"SEND_PROFORMA", "SEND_INVOICE", "CONFIRM_PAYMENT", "PREPARE_ORDER", "VALIDATE_PHYSICAL_ORDER", "CREATE_SHIPMENT"}:
|
||||
return "alta"
|
||||
if code.startswith("FOLLOW_UP_") or code in {"CONFIRM_DELIVERY", "RECOVER_OPPORTUNITY", "REVIEW_NURTURE"}:
|
||||
return "normal"
|
||||
if code in {"REVIEW_MANUALLY", "REMOVE_FROM_LIST", "MARK_NO_INTEREST", "IGNORE_SPAM", "NO_ACTION", "IGNORE_BOUNCE"}:
|
||||
return "baixa"
|
||||
if route == "financeiro":
|
||||
@@ -65,6 +67,61 @@ def _priority_for_action(action_code: str, route: str) -> str:
|
||||
return "normal"
|
||||
|
||||
|
||||
|
||||
|
||||
OBSOLETE_AFTER_PAYMENT_ACTION_CODES = {
|
||||
"CONFIRM_PAYMENT",
|
||||
"FOLLOW_UP_PAYMENT",
|
||||
"FOLLOW_UP_PROFORMA",
|
||||
"FOLLOW_UP_QUOTE",
|
||||
}
|
||||
|
||||
|
||||
def mark_obsolete_payment_followup_tasks(
|
||||
opportunity_id: str,
|
||||
*,
|
||||
actor: str = "system",
|
||||
reason: str = "Pagamento confirmado; tarefa de confirmação/follow-up de pagamento obsoleta.",
|
||||
) -> int:
|
||||
"""Ignore pending payment/follow-up tasks that became obsolete.
|
||||
|
||||
This is intentionally narrow: it only acts after an opportunity has confirmed
|
||||
payment and only on pending payment-confirmation/follow-up tasks. It avoids
|
||||
the UI contradiction where the stage says "Pagamento confirmado" but the next
|
||||
action still asks the operator to follow up payment.
|
||||
"""
|
||||
opportunity_id = str(opportunity_id or "").strip()
|
||||
if not opportunity_id:
|
||||
return 0
|
||||
with engine.begin() as conn:
|
||||
rows = conn.execute(text("""
|
||||
UPDATE tasks
|
||||
SET status = 'ignored',
|
||||
done_at = COALESCE(done_at, now()),
|
||||
done_by = COALESCE(NULLIF(done_by, ''), :actor),
|
||||
metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata AS JSONB),
|
||||
updated_at = now()
|
||||
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
|
||||
AND status = 'pending'
|
||||
AND (upper(COALESCE(action_code, '')) IN ('CONFIRM_PAYMENT','FOLLOW_UP_PAYMENT','FOLLOW_UP_PROFORMA','FOLLOW_UP_QUOTE') OR (upper(COALESCE(action_code, '')) = 'CONFIRM_DELIVERY' AND COALESCE(metadata->>'follow_up_family','') = 'payment'))
|
||||
RETURNING id::text
|
||||
"""), {
|
||||
"opportunity_id": opportunity_id, "actor": actor or "system",
|
||||
"metadata": _json({
|
||||
"auto_ignored_reason": reason,
|
||||
"auto_ignored_after": "payment_confirmed",
|
||||
}),
|
||||
}).mappings().all()
|
||||
ids = [str(row.get("id")) for row in rows if row.get("id")]
|
||||
|
||||
for task_id in ids:
|
||||
try:
|
||||
_record_task_timeline_event(task_id, event_type="task_skipped")
|
||||
except Exception:
|
||||
pass
|
||||
return len(ids)
|
||||
|
||||
|
||||
def _timeline_payload_preview(metadata: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
metadata = metadata or {}
|
||||
preview = dict(metadata)
|
||||
@@ -148,11 +205,14 @@ def create_task_from_action_result(
|
||||
|
||||
# Idempotência forte:
|
||||
# - raw_event_id é estável quando o mesmo webhook é reenviado;
|
||||
# - evita tasks duplicadas para o mesmo evento/action_code;
|
||||
# - mantém fallback para source_event_id/message_id/action_run_id.
|
||||
# - para mensagens repetidas sem id externo estável, usa fingerprint semântico;
|
||||
# - só depois usa ids internos do action_run/message, que mudam em cada análise.
|
||||
content_fingerprint = str((metadata or {}).get("content_fingerprint") or "").strip()
|
||||
external_source_event = source_event_id if (source_event_id and source_event_id != message_id) else None
|
||||
idempotency_source = (
|
||||
raw_event_id
|
||||
or source_event_id
|
||||
or external_source_event
|
||||
or content_fingerprint
|
||||
or message_id
|
||||
or action_run_id
|
||||
or "no_source"
|
||||
@@ -258,6 +318,16 @@ def list_tasks(
|
||||
q: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
) -> List[Dict[str, Any]]:
|
||||
# v4928.1.5.92: choose schema-supported opportunity customer for task list
|
||||
try:
|
||||
from app.infra.schema_compat import opportunity_customer_column
|
||||
|
||||
customer_column = opportunity_customer_column()
|
||||
except Exception:
|
||||
customer_column = "local_customer_id"
|
||||
if customer_column not in {"local_customer_id", "fiscal_customer_id"}:
|
||||
customer_column = "local_customer_id"
|
||||
|
||||
where = []
|
||||
params: Dict[str, Any] = {"limit": limit}
|
||||
|
||||
@@ -274,7 +344,9 @@ def list_tasks(
|
||||
where.append("""
|
||||
(
|
||||
lower(coalesce(t.conversation_id, '')) like :q
|
||||
or lower(coalesce(o.conversation_id, '')) like :q
|
||||
or lower(coalesce(t.contact_id, '')) like :q
|
||||
or lower(coalesce(o.contact_id, '')) like :q
|
||||
or lower(coalesce(t.customer_id, '')) like :q
|
||||
or lower(coalesce(t.action_code, '')) like :q
|
||||
or lower(coalesce(t.route, '')) like :q
|
||||
@@ -303,8 +375,8 @@ def list_tasks(
|
||||
t.message_id::text,
|
||||
t.raw_event_id::text,
|
||||
t.opportunity_id::text,
|
||||
t.conversation_id,
|
||||
t.contact_id,
|
||||
COALESCE(NULLIF(t.conversation_id, ''), NULLIF(o.conversation_id, '')) AS conversation_id,
|
||||
COALESCE(NULLIF(t.contact_id, ''), NULLIF(o.contact_id, '')) AS contact_id,
|
||||
t.customer_id,
|
||||
t.action_code,
|
||||
t.route,
|
||||
@@ -322,15 +394,28 @@ def list_tasks(
|
||||
t.done_at,
|
||||
t.done_by,
|
||||
t.metadata,
|
||||
o.title AS opportunity_title,
|
||||
o.customer_name AS opportunity_customer_name,
|
||||
o.customer_email AS opportunity_customer_email,
|
||||
o.conversation_id AS opportunity_conversation_id,
|
||||
o.contact_id AS opportunity_contact_id,
|
||||
cu.name AS linked_customer_name,
|
||||
cu.email AS linked_customer_email,
|
||||
|
||||
COALESCE(
|
||||
NULLIF(cu.name, ''),
|
||||
NULLIF(o.customer_name, ''),
|
||||
NULLIF(re.payload->'sender'->>'name', ''),
|
||||
NULLIF(re.payload->'conversation'->'meta'->'sender'->>'name', ''),
|
||||
NULLIF(o.customer_email, ''),
|
||||
NULLIF(cu.email, ''),
|
||||
NULLIF(t.customer_id, ''),
|
||||
NULLIF(t.contact_id, '')
|
||||
) AS customer_name,
|
||||
|
||||
COALESCE(
|
||||
NULLIF(cu.email, ''),
|
||||
NULLIF(o.customer_email, ''),
|
||||
NULLIF(re.payload->'sender'->>'email', ''),
|
||||
NULLIF(re.payload->'conversation'->'meta'->'sender'->>'email', ''),
|
||||
NULLIF(re.payload->'conversation'->'contact_inbox'->>'source_id', '')
|
||||
@@ -356,6 +441,10 @@ def list_tasks(
|
||||
FROM tasks t
|
||||
LEFT JOIN messages m ON m.id = t.message_id
|
||||
LEFT JOIN raw_events re ON re.id = t.raw_event_id
|
||||
LEFT JOIN opportunities o ON o.id = t.opportunity_id
|
||||
-- Legacy guard anchor: LEFT JOIN customers cu ON cu.id = o.local_customer_id
|
||||
-- v4928.1.5.63: choose fiscal_customer_id only if deployment schema supports it.
|
||||
LEFT JOIN customers cu ON cu.id = o.{customer_column}
|
||||
{where_sql}
|
||||
ORDER BY
|
||||
CASE t.status
|
||||
@@ -389,6 +478,7 @@ def get_task(task_id: str) -> Optional[Dict[str, Any]]:
|
||||
action_run_id::text,
|
||||
message_id::text,
|
||||
raw_event_id::text,
|
||||
opportunity_id::text,
|
||||
conversation_id,
|
||||
contact_id,
|
||||
customer_id,
|
||||
@@ -399,6 +489,7 @@ def get_task(task_id: str) -> Optional[Dict[str, Any]]:
|
||||
action_required,
|
||||
safe_to_post,
|
||||
status,
|
||||
priority,
|
||||
source_system,
|
||||
source_event_id,
|
||||
due_at,
|
||||
@@ -418,16 +509,25 @@ def get_task(task_id: str) -> Optional[Dict[str, Any]]:
|
||||
|
||||
|
||||
|
||||
# Legacy static anchor: o.local_customer_id::text AS opportunity_fiscal_customer_id
|
||||
def get_task_detail(task_id: str) -> Optional[Dict[str, Any]]:
|
||||
sql = text("""
|
||||
try:
|
||||
from app.infra.schema_compat import opportunity_customer_column
|
||||
customer_column = opportunity_customer_column()
|
||||
except Exception:
|
||||
customer_column = "local_customer_id"
|
||||
if customer_column not in {"local_customer_id", "fiscal_customer_id"}:
|
||||
customer_column = "local_customer_id"
|
||||
|
||||
sql = text(f"""
|
||||
SELECT
|
||||
t.id::text,
|
||||
t.action_run_id::text,
|
||||
t.message_id::text,
|
||||
t.raw_event_id::text,
|
||||
t.opportunity_id::text,
|
||||
t.conversation_id,
|
||||
t.contact_id,
|
||||
COALESCE(NULLIF(t.conversation_id, ''), NULLIF(o.conversation_id, '')) AS conversation_id,
|
||||
COALESCE(NULLIF(t.contact_id, ''), NULLIF(o.contact_id, '')) AS contact_id,
|
||||
t.customer_id,
|
||||
t.action_code,
|
||||
t.route,
|
||||
@@ -446,14 +546,30 @@ def get_task_detail(task_id: str) -> Optional[Dict[str, Any]]:
|
||||
t.done_at,
|
||||
t.done_by,
|
||||
t.metadata,
|
||||
o.local_customer_id::text AS linked_customer_id,
|
||||
cu.name AS linked_customer_name,
|
||||
cu.email AS linked_customer_email,
|
||||
cu.tax_id AS linked_customer_tax_id,
|
||||
cu.street_name AS linked_customer_street_name,
|
||||
cu.postal_zone AS linked_customer_postal_zone,
|
||||
cu.city_name AS linked_customer_city_name,
|
||||
cu.phone AS linked_customer_phone,
|
||||
COALESCE(o.{customer_column}, dcu.customer_id)::text AS linked_customer_id,
|
||||
COALESCE(o.{customer_column}, dcu.customer_id)::text AS opportunity_fiscal_customer_id,
|
||||
o.local_customer_id::text AS opportunity_local_customer_id,
|
||||
o.title AS opportunity_title,
|
||||
o.stage AS opportunity_stage,
|
||||
o.status AS opportunity_status,
|
||||
o.customer_name AS opportunity_customer_name,
|
||||
o.customer_email AS opportunity_customer_email,
|
||||
o.conversation_id AS opportunity_conversation_id,
|
||||
o.contact_id AS opportunity_contact_id,
|
||||
o.product_interest AS opportunity_product_interest,
|
||||
o.value_amount AS opportunity_value_amount,
|
||||
o.currency AS opportunity_currency,
|
||||
COALESCE(cu.name, dcu.customer_name) AS linked_customer_name,
|
||||
COALESCE(cu.email, dcu.customer_email) AS linked_customer_email,
|
||||
COALESCE(cu.tax_id, dcu.customer_tax_id) AS linked_customer_tax_id,
|
||||
-- Compatibility anchors kept for fiscal readiness regression tests:
|
||||
-- cu.street_name AS linked_customer_street_name
|
||||
-- cu.postal_zone AS linked_customer_postal_zone
|
||||
-- cu.city_name AS linked_customer_city_name
|
||||
COALESCE(cu.street_name, dcu.customer_street_name) AS linked_customer_street_name,
|
||||
COALESCE(cu.postal_zone, dcu.customer_postal_zone) AS linked_customer_postal_zone,
|
||||
COALESCE(cu.city_name, dcu.customer_city_name) AS linked_customer_city_name,
|
||||
COALESCE(cu.phone, dcu.customer_phone) AS linked_customer_phone,
|
||||
|
||||
m.raw_body,
|
||||
m.clean_body,
|
||||
@@ -472,13 +588,19 @@ def get_task_detail(task_id: str) -> Optional[Dict[str, Any]]:
|
||||
re.processing_error,
|
||||
|
||||
COALESCE(
|
||||
NULLIF(cu.name, ''),
|
||||
NULLIF(o.customer_name, ''),
|
||||
NULLIF(re.payload->'sender'->>'name', ''),
|
||||
NULLIF(re.payload->'conversation'->'meta'->'sender'->>'name', ''),
|
||||
NULLIF(o.customer_email, ''),
|
||||
NULLIF(cu.email, ''),
|
||||
NULLIF(t.customer_id, ''),
|
||||
NULLIF(t.contact_id, '')
|
||||
) AS customer_name,
|
||||
|
||||
COALESCE(
|
||||
NULLIF(cu.email, ''),
|
||||
NULLIF(o.customer_email, ''),
|
||||
NULLIF(re.payload->'sender'->>'email', ''),
|
||||
NULLIF(re.payload->'conversation'->'meta'->'sender'->>'email', ''),
|
||||
NULLIF(re.payload->'conversation'->'contact_inbox'->>'source_id', '')
|
||||
@@ -500,7 +622,32 @@ def get_task_detail(task_id: str) -> Optional[Dict[str, Any]]:
|
||||
LEFT JOIN action_runs ar ON ar.id = t.action_run_id
|
||||
LEFT JOIN raw_events re ON re.id = t.raw_event_id
|
||||
LEFT JOIN opportunities o ON o.id = t.opportunity_id
|
||||
LEFT JOIN customers cu ON cu.id = o.local_customer_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
cd.customer_id,
|
||||
COALESCE(cdoc.name, NULLIF(cd.company, ''), NULLIF(cd.payload->>'customer_name', '')) AS customer_name,
|
||||
COALESCE(cdoc.email, NULLIF(cd.payload->>'customer_email', ''), NULLIF(cd.payload->>'email', '')) AS customer_email,
|
||||
COALESCE(cdoc.tax_id, NULLIF(cd.payload->>'customer_tax_id', ''), NULLIF(cd.payload->>'tax_id', ''), NULLIF(cd.payload->>'nif', '')) AS customer_tax_id,
|
||||
cdoc.street_name AS customer_street_name,
|
||||
cdoc.postal_zone AS customer_postal_zone,
|
||||
cdoc.city_name AS customer_city_name,
|
||||
cdoc.phone AS customer_phone
|
||||
FROM commercial_documents cd
|
||||
LEFT JOIN customers cdoc ON cdoc.id = cd.customer_id
|
||||
WHERE cd.opportunity_id = o.id
|
||||
AND COALESCE(cd.is_active, TRUE) = TRUE
|
||||
AND cd.document_kind IN ('invoice', 'quotation', 'proforma')
|
||||
ORDER BY
|
||||
CASE cd.document_kind WHEN 'invoice' THEN 1 WHEN 'quotation' THEN 2 WHEN 'proforma' THEN 3 ELSE 4 END,
|
||||
CASE COALESCE(cd.role, 'current') WHEN 'current' THEN 1 WHEN 'accepted' THEN 2 WHEN 'historical' THEN 3 WHEN 'history' THEN 3 ELSE 4 END,
|
||||
COALESCE(cd.is_primary, FALSE) DESC,
|
||||
COALESCE(cd.document_date, cd.created_at::date) DESC,
|
||||
cd.created_at DESC
|
||||
LIMIT 1
|
||||
) dcu ON TRUE
|
||||
-- Legacy guard anchor: LEFT JOIN customers cu ON cu.id = o.local_customer_id
|
||||
-- v4928.1.5.64: choose schema-supported opportunity customer, then document customer fallback.
|
||||
LEFT JOIN customers cu ON cu.id = o.{customer_column}
|
||||
WHERE t.id = CAST(:task_id AS UUID)
|
||||
""")
|
||||
|
||||
@@ -806,20 +953,35 @@ def get_admin_dashboard_metrics() -> Dict[str, Any]:
|
||||
count(*) FILTER (
|
||||
WHERE status = 'pending'
|
||||
AND (
|
||||
(route = 'suporte' AND created_at < now() - interval '2 hours')
|
||||
OR (route = 'vendas' AND created_at < now() - interval '4 hours')
|
||||
OR (route = 'financeiro' AND created_at < now() - interval '8 hours')
|
||||
OR (route = 'operacoes' AND created_at < now() - interval '24 hours')
|
||||
OR (route = 'rever' AND created_at < now() - interval '24 hours')
|
||||
(due_at IS NOT NULL AND due_at < now())
|
||||
OR (
|
||||
due_at IS NULL AND (
|
||||
(route = 'suporte' AND created_at < now() - interval '2 hours')
|
||||
OR (route = 'vendas' AND created_at < now() - interval '4 hours')
|
||||
OR (route = 'financeiro' AND created_at < now() - interval '8 hours')
|
||||
OR (route = 'operacoes' AND created_at < now() - interval '24 hours')
|
||||
OR (route = 'rever' AND created_at < now() - interval '24 hours')
|
||||
)
|
||||
)
|
||||
)
|
||||
) AS overdue_total
|
||||
) AS overdue_total,
|
||||
count(*) FILTER (WHERE status = 'pending' AND action_code LIKE 'FOLLOW_UP_%') AS pending_followups,
|
||||
count(*) FILTER (WHERE status = 'pending' AND action_code LIKE 'FOLLOW_UP_%' AND due_at <= now()) AS due_followups
|
||||
FROM tasks
|
||||
),
|
||||
raw_stats AS (
|
||||
SELECT
|
||||
count(*) FILTER (WHERE created_at >= now() - interval '24 hours') AS webhooks_24h,
|
||||
count(*) FILTER (WHERE ignored = true AND created_at >= now() - interval '24 hours') AS ignored_24h,
|
||||
count(*) FILTER (WHERE processing_error IS NOT NULL AND processing_error <> '' AND created_at >= now() - interval '24 hours') AS webhook_errors_24h
|
||||
count(*) FILTER (WHERE processing_error IS NOT NULL AND processing_error <> '' AND created_at >= now() - interval '24 hours') AS webhook_errors_24h,
|
||||
count(*) FILTER (
|
||||
WHERE source_system = 'chatwoot'
|
||||
AND event_type = 'message_created'
|
||||
AND processed = false
|
||||
AND ignored = false
|
||||
AND processing_error IS NULL
|
||||
AND COALESCE(payload #>> '{message_type}', '') = 'incoming'
|
||||
) AS chatwoot_incoming_pending
|
||||
FROM raw_events
|
||||
),
|
||||
outbox_stats AS (
|
||||
@@ -1228,6 +1390,20 @@ def skip_task(
|
||||
|
||||
from app.operator_audit_service import record_operator_action_best_effort
|
||||
|
||||
auto_archive_result = None
|
||||
if str(task.get("action_code") or "").upper() == "IGNORE_SPAM" and task.get("opportunity_id"):
|
||||
try:
|
||||
from app.opportunity_service import archive_spam_opportunity_if_safe
|
||||
|
||||
auto_archive_result = archive_spam_opportunity_if_safe(
|
||||
str(task.get("opportunity_id")),
|
||||
reason=reason or "spam_task_skipped",
|
||||
actor=skipped_by,
|
||||
source_task_id=task_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
auto_archive_result = {"ok": False, "reason": f"archive_exception:{exc}"}
|
||||
|
||||
record_operator_action_best_effort(
|
||||
action="task_skipped",
|
||||
entity_type="task",
|
||||
@@ -1239,9 +1415,78 @@ def skip_task(
|
||||
payload={"reason": reason},
|
||||
)
|
||||
|
||||
return {"ok": True, "status": "skipped", "task_id": task_id}
|
||||
return {"ok": True, "status": "skipped", "task_id": task_id, "auto_archive_result": auto_archive_result}
|
||||
|
||||
|
||||
def reschedule_task_due_at(
|
||||
*,
|
||||
task_id: str,
|
||||
delay_days: int = 2,
|
||||
rescheduled_by: str = "operator",
|
||||
reason: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
"""Adia uma tarefa pendente usando due_at.
|
||||
|
||||
Usado sobretudo por follow-ups semi-automáticos: o sistema mantém o
|
||||
controlo da data, mas o operador decide se adia ou fecha.
|
||||
"""
|
||||
task = get_task(task_id)
|
||||
if task is None:
|
||||
return {"ok": False, "error": "task_not_found"}
|
||||
|
||||
delay_days = max(0, min(int(delay_days or 0), 90))
|
||||
reason = str(reason or "").strip()
|
||||
|
||||
with engine.begin() as conn:
|
||||
row = conn.execute(text("""
|
||||
UPDATE tasks
|
||||
SET
|
||||
due_at = now() + make_interval(days => :delay_days),
|
||||
updated_at = now(),
|
||||
metadata = COALESCE(metadata, '{}'::jsonb)
|
||||
|| jsonb_build_object(
|
||||
'rescheduled_at', now(),
|
||||
'rescheduled_by', CAST(:rescheduled_by AS TEXT),
|
||||
'reschedule_reason', CAST(:reason AS TEXT),
|
||||
'reschedule_delay_days', :delay_days
|
||||
)
|
||||
WHERE id = CAST(:task_id AS UUID)
|
||||
RETURNING id::text, due_at, status
|
||||
"""), {
|
||||
"task_id": task_id,
|
||||
"delay_days": delay_days,
|
||||
"rescheduled_by": rescheduled_by,
|
||||
"reason": reason,
|
||||
}).mappings().first()
|
||||
|
||||
if not row:
|
||||
return {"ok": False, "error": "task_not_found"}
|
||||
|
||||
create_task_event(
|
||||
task_id=task_id,
|
||||
event_type="task_rescheduled",
|
||||
payload={"delay_days": delay_days, "reason": reason},
|
||||
created_by=rescheduled_by,
|
||||
)
|
||||
|
||||
try:
|
||||
from app.operator_audit_service import record_operator_action_best_effort
|
||||
|
||||
record_operator_action_best_effort(
|
||||
action="task_rescheduled",
|
||||
entity_type="task",
|
||||
entity_id=task_id,
|
||||
task_id=task_id,
|
||||
actor=rescheduled_by,
|
||||
before={"due_at": task.get("due_at")},
|
||||
after={"due_at": row.get("due_at")},
|
||||
payload={"delay_days": delay_days, "reason": reason},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"ok": True, "status": "rescheduled", "task_id": task_id, "due_at": row.get("due_at")}
|
||||
|
||||
|
||||
def auto_complete_task_from_outgoing_message(
|
||||
*,
|
||||
@@ -1290,6 +1535,12 @@ def auto_complete_task_from_outgoing_message(
|
||||
"SEND_PROFORMA",
|
||||
"SEND_INVOICE",
|
||||
"SUPPORT",
|
||||
"FOLLOW_UP_QUOTE",
|
||||
"FOLLOW_UP_PROFORMA",
|
||||
"FOLLOW_UP_PAYMENT",
|
||||
"FOLLOW_UP_CUSTOMER_REVIEW",
|
||||
"FOLLOW_UP_GENERIC",
|
||||
"CONFIRM_DELIVERY",
|
||||
]
|
||||
configured = os.getenv("CHATWOOT_AUTO_COMPLETE_ACTION_CODES", "").strip()
|
||||
never_auto_complete_codes = {
|
||||
@@ -1444,15 +1695,27 @@ def get_latest_task_preparation(task_id: str) -> Optional[Dict[str, Any]]:
|
||||
ACTION_ROUTE_MAP = {
|
||||
"SEND_INFO": ("vendas", "Enviar informação ao cliente"),
|
||||
"SEND_QUOTE": ("vendas", "Enviar proposta/cotação"),
|
||||
"SEND_PROFORMA": ("financeiro", "Emitir fatura pró-forma"),
|
||||
"SEND_PROFORMA": ("financeiro", "Enviar orçamento para pagamento"),
|
||||
"SEND_INVOICE": ("financeiro", "Enviar fatura ao cliente"),
|
||||
"CONFIRM_PAYMENT": ("financeiro", "Confirmar pagamento"),
|
||||
"PREPARE_ORDER": ("operacoes", "Preparar encomenda / Odoo"),
|
||||
"VALIDATE_PHYSICAL_ORDER": ("logistica", "Validar encomenda física"),
|
||||
"CREATE_SHIPMENT": ("logistica", "Enviar encomenda"),
|
||||
"REVIEW_RECONSTRUCTED_PROCESS": ("rever", "Validar processo reconstruído"),
|
||||
"SUPPORT": ("suporte", "Responder ao pedido de suporte"),
|
||||
"REMOVE_FROM_LIST": ("marketing", "Remover contacto da lista"),
|
||||
"MARK_NO_INTEREST": ("vendas", "Marcar sem interesse"),
|
||||
"NO_ACTION": ("rever", "Sem ação necessária"),
|
||||
"REVIEW_MANUALLY": ("rever", "Rever manualmente"),
|
||||
"IGNORE_SPAM": ("spam", "Ignorar spam"),
|
||||
"FOLLOW_UP_QUOTE": ("vendas", "Fazer follow-up do orçamento"),
|
||||
"FOLLOW_UP_PROFORMA": ("financeiro", "Fazer follow-up do orçamento para pagamento"),
|
||||
"FOLLOW_UP_PAYMENT": ("financeiro", "Fazer follow-up de pagamento"),
|
||||
"FOLLOW_UP_CUSTOMER_REVIEW": ("vendas", "Fazer follow-up da informação enviada"),
|
||||
"FOLLOW_UP_GENERIC": ("vendas", "Fazer follow-up manual"),
|
||||
"CONFIRM_DELIVERY": ("vendas", "Confirmar receção da comunicação"),
|
||||
"RECOVER_OPPORTUNITY": ("vendas", "Recuperar oportunidade sem resposta"),
|
||||
"REVIEW_NURTURE": ("vendas", "Rever acompanhamento futuro"),
|
||||
}
|
||||
|
||||
|
||||
@@ -1487,7 +1750,8 @@ def reclassify_task(
|
||||
route,
|
||||
action,
|
||||
note,
|
||||
status
|
||||
status,
|
||||
opportunity_id::text as opportunity_id
|
||||
from tasks
|
||||
where id = :task_id
|
||||
limit 1
|
||||
@@ -1574,6 +1838,20 @@ def reclassify_task(
|
||||
|
||||
from app.operator_audit_service import record_operator_action_best_effort
|
||||
|
||||
auto_archive_result = None
|
||||
if new_action_code == "IGNORE_SPAM" and old.get("opportunity_id"):
|
||||
try:
|
||||
from app.opportunity_service import archive_spam_opportunity_if_safe
|
||||
|
||||
auto_archive_result = archive_spam_opportunity_if_safe(
|
||||
str(old.get("opportunity_id")),
|
||||
reason=reason or "task_reclassified_as_spam",
|
||||
actor=reclassified_by,
|
||||
source_task_id=task_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
auto_archive_result = {"ok": False, "reason": f"archive_exception:{exc}"}
|
||||
|
||||
record_operator_action_best_effort(
|
||||
action="task_reclassified",
|
||||
entity_type="task",
|
||||
@@ -1595,6 +1873,7 @@ def reclassify_task(
|
||||
"new_action_code": new_action_code,
|
||||
"new_route": new_route,
|
||||
"new_status": new_status,
|
||||
"auto_archive_result": auto_archive_result,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user