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
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:
104
app/tiny.py
104
app/tiny.py
@@ -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]}
|
||||
|
||||
Reference in New Issue
Block a user