feat: send order situações to Tiny for the client's WhatsApp notices
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 2m5s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images (push) Successful in 1m45s

The client already sends WhatsApp notices from Tiny's order situação
(Tiny webhook -> middleware -> n8n). With TINY_STATUS_UPDATES on, a paid
order is set to "Aprovada" once and a finished pickup order to "Pronto
para envio"; pickup orders carry the client's pickup forma de envio
(TINY_FORMA_ENVIO_RETIRADA). The ready event now carries the order and
the Tiny id from the sale's receipt. Off by default until go-live, when
n8n stops sending the DTFIMP designer message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cauê Faleiros
2026-09-25 14:17:00 -03:00
parent 8b42e684ca
commit aec5b1d054
9 changed files with 272 additions and 26 deletions

View File

@@ -19,6 +19,13 @@ Idempotency: the outbox may deliver the same event more than once. Every order
carries numeroOrdemCompra = "DTF-<order number>", and before creating one the
customer's recent orders are searched for that number, so a second delivery
finds the first order instead of creating another.
Customer notices: the client already sends WhatsApp messages from Tiny's
order situação (Tiny webhook -> their middleware -> n8n). With
TINY_STATUS_UPDATES on, a paid order is set to "Aprovada" and a finished
pickup order to "Pronto para envio", so those notices reach Site customers
from the same number and templates; the system's own WhatsApp sender stays
off. Pickup orders carry the client's "retirar pessoalmente" forma de envio.
"""
import os
import secrets
@@ -41,6 +48,20 @@ PRODUCTS = {'file': 'DTF Têxtil 57 cm · folha montada',
'uv': 'DTF UV 28,5 cm · artes avulsas'}
# How far back to look for an order a previous delivery may already have made.
SEARCH_DAYS = 7
# A "ready" event can come after corrections; it normally knows the Tiny id.
READY_SEARCH_DAYS = 45
# Tiny v3 order situações (PUT /pedidos/{id}/situacao).
ABERTA, APROVADA, PRONTO_ENVIO, ENVIADA, ENTREGUE, CANCELADA, NAO_ENTREGUE = 0, 3, 7, 5, 6, 2, 9
SITUACOES = {0: 'Aberta', 3: 'Aprovada', 4: 'Preparando envio', 1: 'Faturada', 7: 'Pronto para envio',
5: 'Enviada', 6: 'Entregue', 2: 'Cancelada', 8: 'Dados incompletos', 9: 'Não entregue'}
# The client's customer notices (Tiny webhook -> middleware -> n8n -> WhatsApp)
# react to these situações. Off until go-live: while n8n still sends the
# designer message for DTFIMP products on "Aprovado", setting it would reach
# Site customers.
STATUS_UPDATES_SETTING = 'TINY_STATUS_UPDATES'
# The id of the client's custom "retirar pessoalmente" forma de envio (v2 code
# X), which the pickup notice is keyed on. Find it with the console tool.
PICKUP_SETTING = 'TINY_FORMA_ENVIO_RETIRADA'
STATE_MINUTES = 10
# Renewal runs about every four hours against a one-day refresh token, so
# less than this left means renewals have been failing for hours.
@@ -74,6 +95,10 @@ def required_settings():
return ['TINY_CLIENT_ID', 'TINY_CLIENT_SECRET', 'TINY_REDIRECT_URI'] + list(PRODUCT_SETTINGS.values())
def status_updates():
return os.environ.get(STATUS_UPDATES_SETTING, '').lower() == 'true'
# OAuth -------------------------------------------------------------------
class TinyAuth:
@@ -266,6 +291,9 @@ def order_payload(payload, contact_id, today=None):
'valorFrete': money(freight['total_cents']),
'observacoes': f"Pedido DTF #{payload['number']}" + (' · retirada em Franca' if pickup else ''),
'observacoesInternas': f"Site DTF · pago · {payload['order_id']}"}
pickup_method = os.environ.get(PICKUP_SETTING, '')
if pickup and pickup_method.isdigit():
pedido['transportador'] = {'formaEnvio': {'id': int(pickup_method)}}
destination = order.get('destination')
if destination:
pedido['enderecoEntrega'] = {**address(destination), 'enderecoNro': destination['number'],
@@ -289,10 +317,18 @@ def configured_product(orders, mode):
return found, 'ok'
def configured_pickup(orders):
"""The forma de envio the pickup setting points at, or why it cannot be used."""
value = os.environ.get(PICKUP_SETTING, '')
if not value.isdigit():
return None, f'{PICKUP_SETTING} sem id'
return orders.request('GET', f'/formas-envio/{value}'), 'ok'
def check(auth=None, transport=None, orders=None):
"""Read-only proof that the connection and permissions work: one order and
one contact listed and each configured product found active, nothing
created. Raises TinyNotConnected when there is no usable connection;
one contact listed, each configured product found active and the pickup
forma de envio found, nothing created. Raises TinyNotConnected when there is no usable connection;
otherwise reports each read separately."""
orders = orders or TinyOrders(auth=auth or TinyAuth(), transport=transport)
@@ -302,6 +338,7 @@ def check(auth=None, transport=None, orders=None):
reads = [('pedidos', lambda: listed('/pedidos')), ('contatos', lambda: listed('/contatos'))]
reads += [(PRODUCTS[mode], lambda mode=mode: configured_product(orders, mode)[1]) for mode in PRODUCT_SETTINGS]
reads.append(('forma de envio de retirada', lambda: configured_pickup(orders)[1]))
results = {}
for name, read in reads:
try:
@@ -341,10 +378,10 @@ class TinyOrders:
return entry['id']
return self.request('POST', '/contatos', json=contact_payload(order))['id']
def find(self, payload):
def find(self, payload, days=SEARCH_DAYS):
"""An order a previous delivery already created, or None."""
wanted = purchase_order(payload['number'])
since = ((self.today or local_today()) - timedelta(days=SEARCH_DAYS)).isoformat()
since = ((self.today or local_today()) - timedelta(days=days)).isoformat()
found = self.request('GET', '/pedidos', params={'cpfCnpj': payload['order']['customer']['cnpj'],
'dataInicial': since, 'limit': 100})
for entry in found.get('itens') or []:
@@ -353,16 +390,55 @@ class TinyOrders:
return detail
return None
def set_situacao(self, tiny_id, situacao):
self.request('PUT', f'/pedidos/{tiny_id}/situacao', json={'situacao': situacao})
def deliver(self, event_key, payload):
if payload.get('event') != 'payment_approved':
# Production progress is not written to Tiny; only the sale is.
return {'provider': 'tiny', 'event_key': event_key, 'status': 'not-applicable',
'event': payload.get('event')}
event = payload.get('event')
if event == 'payment_approved':
return self.sale(event_key, payload)
if event == 'ready' and status_updates():
return self.ready(event_key, payload)
# Other production events have no Tiny situação and are not written.
return {'provider': 'tiny', 'event_key': event_key, 'status': 'not-applicable', 'event': event}
def sale(self, event_key, payload):
"""Create the order once; with status updates on, approve it once.
An order is created "Aberta" and then set to "Aprovada", so a retry
after a failure between the two finds it still open and finishes the
job, and an order someone has already moved on is left alone."""
existing = self.find(payload)
if existing:
return {'provider': 'tiny', 'event_key': event_key, 'status': 'already-created',
'tiny_id': str(existing.get('id')), 'tiny_number': str(existing.get('numeroPedido'))}
contact_id = self.contact(payload['order'])
created = self.request('POST', '/pedidos', json=order_payload(payload, contact_id, self.today))
return {'provider': 'tiny', 'event_key': event_key, 'status': 'created',
'tiny_id': str(created.get('id')), 'tiny_number': str(created.get('numeroPedido'))}
receipt = {'provider': 'tiny', 'event_key': event_key, 'status': 'already-created',
'tiny_id': str(existing.get('id')), 'tiny_number': str(existing.get('numeroPedido'))}
tiny_id, situacao = existing.get('id'), existing.get('situacao')
else:
contact_id = self.contact(payload['order'])
created = self.request('POST', '/pedidos', json=order_payload(payload, contact_id, self.today))
receipt = {'provider': 'tiny', 'event_key': event_key, 'status': 'created',
'tiny_id': str(created.get('id')), 'tiny_number': str(created.get('numeroPedido'))}
tiny_id, situacao = created.get('id'), ABERTA
if status_updates() and situacao == ABERTA:
self.set_situacao(tiny_id, APROVADA)
receipt['situacao'] = SITUACOES[APROVADA]
return receipt
def ready(self, event_key, payload):
"""A pickup order became ready: "Pronto para envio", which the client's
notices turn into the pickup message. Shipped orders get "Enviada"
once freight exists (1.2)."""
base = {'provider': 'tiny', 'event_key': event_key}
order = payload.get('order') or {}
if (order.get('freight') or {}).get('service') != 'pickup':
return {**base, 'status': 'not-applicable', 'event': 'ready'}
tiny_id = payload.get('tiny_id')
detail = self.request('GET', f'/pedidos/{tiny_id}') if tiny_id else self.find(payload, READY_SEARCH_DAYS)
if not detail:
raise TinyError(f"{purchase_order(payload['number'])} is not in Tiny yet; the outbox will retry")
base.update(tiny_id=str(detail.get('id')), tiny_number=str(detail.get('numeroPedido')))
situacao = detail.get('situacao')
if situacao in (PRONTO_ENVIO, ENVIADA, ENTREGUE, CANCELADA, NAO_ENTREGUE):
return {**base, 'status': 'status-unchanged', 'situacao': SITUACOES.get(situacao, str(situacao))}
self.set_situacao(detail['id'], PRONTO_ENVIO)
return {**base, 'status': 'status-updated', 'situacao': SITUACOES[PRONTO_ENVIO]}