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:
@@ -255,11 +255,16 @@ def move(uid: UUID, body: Move, user=Depends(operator)):
|
||||
# even if a mistaken move is undone and made again. Each correction
|
||||
# is a new request, so it keeps one message per movement.
|
||||
once = body.state in ('imp','fin')
|
||||
for provider in ('tiny','whatsapp'):
|
||||
event = {'order_id':str(uid), 'number':row['number'], 'event':events[body.state], 'reason':body.reason,
|
||||
'customer_path': f'/portal.html?order={uid}'}
|
||||
# Tiny needs the order (customer, pickup or delivery) and, once the
|
||||
# sale reached it, its id, to set the situação without a search.
|
||||
sale = c.execute("SELECT receipt FROM dtf_local.outbox WHERE event_key=%s AND delivered_at IS NOT NULL",
|
||||
(f'{uid}:paid:tiny',)).fetchone()
|
||||
tiny = {**event, 'order': row['snapshot'], 'tiny_id': ((sale or {}).get('receipt') or {}).get('tiny_id')}
|
||||
for provider, payload in (('tiny', tiny), ('whatsapp', event)):
|
||||
key = f'{uid}:{events[body.state]}:{provider}' if once else f'{uid}:{changed["version"]}:{provider}'
|
||||
enqueue(c, key, provider,
|
||||
{'order_id':str(uid), 'number':row['number'], 'event':events[body.state], 'reason':body.reason,
|
||||
'customer_path': f'/portal.html?order={uid}'})
|
||||
enqueue(c, key, provider, payload)
|
||||
return changed
|
||||
|
||||
@router.post('/api/operator/orders/{uid}/print-files')
|
||||
|
||||
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]}
|
||||
|
||||
@@ -3,7 +3,8 @@ container console (docker exec, or Portainer > Containers > worker > Console).
|
||||
Tiny has no sandbox, so this is how the adapter is proven on the real account.
|
||||
|
||||
python -m app.tiny_probe produtos [termo] list active products (read-only)
|
||||
python -m app.tiny_probe conferir connection and the four product ids (read-only)
|
||||
python -m app.tiny_probe formas-envio [termo] list formas de envio, to find pickup (read-only)
|
||||
python -m app.tiny_probe conferir connection, the four product ids and pickup (read-only)
|
||||
python -m app.tiny_probe pedido --cnpj ... --email ... --celular ... [--modo file]
|
||||
show the test order; --confirmar creates it
|
||||
|
||||
@@ -49,6 +50,17 @@ def produtos(orders, termo, out):
|
||||
out(f'{len(found)} produto(s) ativo(s)' + (f' com "{termo}"' if termo else '') + '.')
|
||||
|
||||
|
||||
def formas_envio(orders, termo, out):
|
||||
params = {'limit': 100}
|
||||
if termo:
|
||||
params['nome'] = termo
|
||||
found = orders.request('GET', '/formas-envio', params=params).get('itens') or []
|
||||
for item in found:
|
||||
out(f"{item.get('id')}\t{item.get('tipo')}\t{item.get('nome')}")
|
||||
out(f'{len(found)} forma(s) de envio' + (f' com "{termo}"' if termo else '') +
|
||||
'. A retirada é a do tipo 6 (Customizado) que a equipe usa para retirar pessoalmente.')
|
||||
|
||||
|
||||
def conferir(orders, out):
|
||||
ok = True
|
||||
for mode, setting in tiny.PRODUCT_SETTINGS.items():
|
||||
@@ -56,6 +68,10 @@ def conferir(orders, out):
|
||||
ok &= result == 'ok'
|
||||
described = f"{product.get('sku') or '-'} · {product.get('descricao')}" if product else ''
|
||||
out(f'{setting} ({tiny.PRODUCTS[mode]}): {result} {described}'.rstrip())
|
||||
method, result = tiny.configured_pickup(orders)
|
||||
ok &= result == 'ok'
|
||||
out(f"{tiny.PICKUP_SETTING} (retirada): {result} {method.get('nome') if method else ''}".rstrip())
|
||||
out(f"{tiny.STATUS_UPDATES_SETTING}: {'ligado' if tiny.status_updates() else 'desligado'}")
|
||||
for name, result in tiny.check(orders=orders).items():
|
||||
if name in ('pedidos', 'contatos'):
|
||||
ok &= result == 'ok'
|
||||
@@ -100,6 +116,8 @@ def main(argv=None, orders=None, out=print):
|
||||
commands = parser.add_subparsers(dest='command', required=True)
|
||||
listing = commands.add_parser('produtos')
|
||||
listing.add_argument('termo', nargs='?', default='')
|
||||
methods = commands.add_parser('formas-envio')
|
||||
methods.add_argument('termo', nargs='?', default='')
|
||||
commands.add_parser('conferir')
|
||||
order = commands.add_parser('pedido')
|
||||
order.add_argument('--cnpj', required=True)
|
||||
@@ -115,6 +133,9 @@ def main(argv=None, orders=None, out=print):
|
||||
if args.command == 'produtos':
|
||||
produtos(orders, args.termo, out)
|
||||
return 0
|
||||
if args.command == 'formas-envio':
|
||||
formas_envio(orders, args.termo, out)
|
||||
return 0
|
||||
if args.command == 'conferir':
|
||||
return 0 if conferir(orders, out) else 1
|
||||
cnpj = ''.join(ch for ch in args.cnpj if ch.isdigit())
|
||||
|
||||
Reference in New Issue
Block a user