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

@@ -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 # even if a mistaken move is undone and made again. Each correction
# is a new request, so it keeps one message per movement. # is a new request, so it keeps one message per movement.
once = body.state in ('imp','fin') 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}' key = f'{uid}:{events[body.state]}:{provider}' if once else f'{uid}:{changed["version"]}:{provider}'
enqueue(c, key, provider, enqueue(c, key, provider, payload)
{'order_id':str(uid), 'number':row['number'], 'event':events[body.state], 'reason':body.reason,
'customer_path': f'/portal.html?order={uid}'})
return changed return changed
@router.post('/api/operator/orders/{uid}/print-files') @router.post('/api/operator/orders/{uid}/print-files')

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 carries numeroOrdemCompra = "DTF-<order number>", and before creating one the
customer's recent orders are searched for that number, so a second delivery customer's recent orders are searched for that number, so a second delivery
finds the first order instead of creating another. 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 os
import secrets import secrets
@@ -41,6 +48,20 @@ PRODUCTS = {'file': 'DTF Têxtil 57 cm · folha montada',
'uv': 'DTF UV 28,5 cm · artes avulsas'} 'uv': 'DTF UV 28,5 cm · artes avulsas'}
# How far back to look for an order a previous delivery may already have made. # How far back to look for an order a previous delivery may already have made.
SEARCH_DAYS = 7 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 STATE_MINUTES = 10
# Renewal runs about every four hours against a one-day refresh token, so # Renewal runs about every four hours against a one-day refresh token, so
# less than this left means renewals have been failing for hours. # 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()) 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 ------------------------------------------------------------------- # OAuth -------------------------------------------------------------------
class TinyAuth: class TinyAuth:
@@ -266,6 +291,9 @@ def order_payload(payload, contact_id, today=None):
'valorFrete': money(freight['total_cents']), 'valorFrete': money(freight['total_cents']),
'observacoes': f"Pedido DTF #{payload['number']}" + (' · retirada em Franca' if pickup else ''), 'observacoes': f"Pedido DTF #{payload['number']}" + (' · retirada em Franca' if pickup else ''),
'observacoesInternas': f"Site DTF · pago · {payload['order_id']}"} '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') destination = order.get('destination')
if destination: if destination:
pedido['enderecoEntrega'] = {**address(destination), 'enderecoNro': destination['number'], pedido['enderecoEntrega'] = {**address(destination), 'enderecoNro': destination['number'],
@@ -289,10 +317,18 @@ def configured_product(orders, mode):
return found, 'ok' 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): def check(auth=None, transport=None, orders=None):
"""Read-only proof that the connection and permissions work: one order and """Read-only proof that the connection and permissions work: one order and
one contact listed and each configured product found active, nothing one contact listed, each configured product found active and the pickup
created. Raises TinyNotConnected when there is no usable connection; forma de envio found, nothing created. Raises TinyNotConnected when there is no usable connection;
otherwise reports each read separately.""" otherwise reports each read separately."""
orders = orders or TinyOrders(auth=auth or TinyAuth(), transport=transport) 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 = [('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 += [(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 = {} results = {}
for name, read in reads: for name, read in reads:
try: try:
@@ -341,10 +378,10 @@ class TinyOrders:
return entry['id'] return entry['id']
return self.request('POST', '/contatos', json=contact_payload(order))['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.""" """An order a previous delivery already created, or None."""
wanted = purchase_order(payload['number']) 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'], found = self.request('GET', '/pedidos', params={'cpfCnpj': payload['order']['customer']['cnpj'],
'dataInicial': since, 'limit': 100}) 'dataInicial': since, 'limit': 100})
for entry in found.get('itens') or []: for entry in found.get('itens') or []:
@@ -353,16 +390,55 @@ class TinyOrders:
return detail return detail
return None 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): def deliver(self, event_key, payload):
if payload.get('event') != 'payment_approved': event = payload.get('event')
# Production progress is not written to Tiny; only the sale is. if event == 'payment_approved':
return {'provider': 'tiny', 'event_key': event_key, 'status': 'not-applicable', return self.sale(event_key, payload)
'event': payload.get('event')} 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) existing = self.find(payload)
if existing: if existing:
return {'provider': 'tiny', 'event_key': event_key, 'status': 'already-created', receipt = {'provider': 'tiny', 'event_key': event_key, 'status': 'already-created',
'tiny_id': str(existing.get('id')), 'tiny_number': str(existing.get('numeroPedido'))} 'tiny_id': str(existing.get('id')), 'tiny_number': str(existing.get('numeroPedido'))}
contact_id = self.contact(payload['order']) tiny_id, situacao = existing.get('id'), existing.get('situacao')
created = self.request('POST', '/pedidos', json=order_payload(payload, contact_id, self.today)) else:
return {'provider': 'tiny', 'event_key': event_key, 'status': 'created', contact_id = self.contact(payload['order'])
'tiny_id': str(created.get('id')), 'tiny_number': str(created.get('numeroPedido'))} 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]}

View File

@@ -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. 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 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] python -m app.tiny_probe pedido --cnpj ... --email ... --celular ... [--modo file]
show the test order; --confirmar creates it 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 '') + '.') 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): def conferir(orders, out):
ok = True ok = True
for mode, setting in tiny.PRODUCT_SETTINGS.items(): for mode, setting in tiny.PRODUCT_SETTINGS.items():
@@ -56,6 +68,10 @@ def conferir(orders, out):
ok &= result == 'ok' ok &= result == 'ok'
described = f"{product.get('sku') or '-'} · {product.get('descricao')}" if product else '' described = f"{product.get('sku') or '-'} · {product.get('descricao')}" if product else ''
out(f'{setting} ({tiny.PRODUCTS[mode]}): {result} {described}'.rstrip()) 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(): for name, result in tiny.check(orders=orders).items():
if name in ('pedidos', 'contatos'): if name in ('pedidos', 'contatos'):
ok &= result == 'ok' ok &= result == 'ok'
@@ -100,6 +116,8 @@ def main(argv=None, orders=None, out=print):
commands = parser.add_subparsers(dest='command', required=True) commands = parser.add_subparsers(dest='command', required=True)
listing = commands.add_parser('produtos') listing = commands.add_parser('produtos')
listing.add_argument('termo', nargs='?', default='') listing.add_argument('termo', nargs='?', default='')
methods = commands.add_parser('formas-envio')
methods.add_argument('termo', nargs='?', default='')
commands.add_parser('conferir') commands.add_parser('conferir')
order = commands.add_parser('pedido') order = commands.add_parser('pedido')
order.add_argument('--cnpj', required=True) order.add_argument('--cnpj', required=True)
@@ -115,6 +133,9 @@ def main(argv=None, orders=None, out=print):
if args.command == 'produtos': if args.command == 'produtos':
produtos(orders, args.termo, out) produtos(orders, args.termo, out)
return 0 return 0
if args.command == 'formas-envio':
formas_envio(orders, args.termo, out)
return 0
if args.command == 'conferir': if args.command == 'conferir':
return 0 if conferir(orders, out) else 1 return 0 if conferir(orders, out) else 1
cnpj = ''.join(ch for ch in args.cnpj if ch.isdigit()) cnpj = ''.join(ch for ch in args.cnpj if ch.isdigit())

View File

@@ -37,6 +37,9 @@ TINY_PRODUCT_TEXTIL_FOLHA=TBD
TINY_PRODUCT_TEXTIL_AVULSA=TBD TINY_PRODUCT_TEXTIL_AVULSA=TBD
TINY_PRODUCT_UV_FOLHA=TBD TINY_PRODUCT_UV_FOLHA=TBD
TINY_PRODUCT_UV_AVULSA=TBD TINY_PRODUCT_UV_AVULSA=TBD
TINY_FORMA_ENVIO_RETIRADA=TBD
# true only at go-live, after the n8n DTFIMP designer branch is removed.
TINY_STATUS_UPDATES=false
WHATSAPP_ADAPTER=TBD WHATSAPP_ADAPTER=TBD
STORAGE_QUOTA_BYTES=TBD STORAGE_QUOTA_BYTES=TBD
OWNER_UPLOAD_QUOTA_BYTES=TBD OWNER_UPLOAD_QUOTA_BYTES=TBD

View File

@@ -37,6 +37,13 @@ x-app-environment: &app-environment
TINY_PRODUCT_TEXTIL_AVULSA: ${TINY_PRODUCT_TEXTIL_AVULSA:-} TINY_PRODUCT_TEXTIL_AVULSA: ${TINY_PRODUCT_TEXTIL_AVULSA:-}
TINY_PRODUCT_UV_FOLHA: ${TINY_PRODUCT_UV_FOLHA:-} TINY_PRODUCT_UV_FOLHA: ${TINY_PRODUCT_UV_FOLHA:-}
TINY_PRODUCT_UV_AVULSA: ${TINY_PRODUCT_UV_AVULSA:-} TINY_PRODUCT_UV_AVULSA: ${TINY_PRODUCT_UV_AVULSA:-}
# The client's "retirar pessoalmente" forma de envio id, on pickup orders.
TINY_FORMA_ENVIO_RETIRADA: ${TINY_FORMA_ENVIO_RETIRADA:-}
# Sets "Aprovada" on paid orders and "Pronto para envio" on finished pickup
# orders, which the client's Tiny -> n8n notices send to customers. Turn on
# only together with TINY_ADAPTER=tiny and after n8n stops sending the
# designer message for DTFIMP products.
TINY_STATUS_UPDATES: ${TINY_STATUS_UPDATES:-false}
WHATSAPP_ADAPTER: fake WHATSAPP_ADAPTER: fake
STORAGE_ADAPTER: s3-r2 STORAGE_ADAPTER: s3-r2
PUBLIC_ORIGIN: https://${SITE_DOMAIN:?set SITE_DOMAIN} PUBLIC_ORIGIN: https://${SITE_DOMAIN:?set SITE_DOMAIN}

View File

@@ -308,6 +308,30 @@ From the report already sent. These are dated promises, not backlog.
the two Têxtil modes; UV needs a product either way. The item note already the two Têxtil modes; UV needs a product either way. The item note already
carries the Site mode and grade. No product ids are set in production yet; carries the Site mode and grade. No product ids are set in production yet;
both questions go to the client with the Mercado Pago credentials request. both questions go to the client with the Mercado Pago credentials request.
**Customer notices through Tiny (2026-09-25, Week 3 head start):** the client
already sends WhatsApp notices from Tiny's order situação (Tiny webhook ->
the `api-tiny-n8n` middleware, which reads the order through API v2 -> n8n
-> WhatsApp templates): Aprovado, Pronto para envio with forma de envio `X`
(v2 "Customizada", their pickup), Enviado, Entregue. So the system's own
WhatsApp sender stays off and Tiny drives the notices. With
`TINY_STATUS_UPDATES=true` a paid order is created "Aberta" and set to
"Aprovada" (a retry finishes a half-done approval; an order already moved on
is left alone), and moving a pickup order to Finalizado sets "Pronto para
envio" by the Tiny id from the sale's receipt, searching only when it is
missing and retrying while the sale has not reached Tiny. Pickup orders
carry `TINY_FORMA_ENVIO_RETIRADA` (`tiny_probe formas-envio` lists the ids;
"Testar conexão" now checks it). Delivery orders get "Enviada" with 1.2.
Off by default: while n8n's `isDTFIMP` branch exists, "Aprovado" on a
`DTFIMP` product sends the designer message, and the Site's Têxtil product
code starts with `DTFIMP`. **Go-live together:** `TINY_ADAPTER=tiny`,
`TINY_STATUS_UPDATES=true`, n8n's `isDTFIMP`/"DTF Aprovado - Designer"
removed with "Mapear Whatsapp do Vendedor" connected to `If6`, and the
middleware's `numero_ecommerce` falling back to the purchase order so the
message shows `DTF-<n>`. **Unverified on the account:** that an API status
change fires Tiny's webhook, that Tiny accepts Aprovada -> Pronto para envio
without Faturada, and the v2 field name for the purchase order. Correção
necessária and Produção iniciada have no Tiny situação; client to decide
whether they need messages.
- `[~]` 1.4 — Final print-file generation (see 3.2 and 3.6: production instructions - `[~]` 1.4 — Final print-file generation (see 3.2 and 3.6: production instructions
must survive checkout before an output engine can reproduce the approved job). must survive checkout before an output engine can reproduce the approved job).
**Built (2026-09-24):** each paid item gets a PDF the width of the film and **Built (2026-09-24):** each paid item gets a PDF the width of the film and

View File

@@ -204,6 +204,11 @@ def run():
time.sleep(1) time.sleep(1)
else:raise AssertionError('Mock outbox did not drain') else:raise AssertionError('Mock outbox did not drain')
assert len({e['event_key'] for e in events})==8 assert len({e['event_key'] for e in events})==8
# Tiny sets the pickup situação from the order it is sent; the fake sale
# has no Tiny id, so the real adapter would search for it.
ready={e['provider']:e['payload'] for e in events if e['payload']['event']=='ready'}
assert ready['tiny']['order']['freight']==order['snapshot']['freight'] and 'tiny_id' in ready['tiny']
assert 'order' not in ready['whatsapp']
print(f"PASS: 8 durable fake receipts. Local test order #{order['number']} retained in Finalizado.") print(f"PASS: 8 durable fake receipts. Local test order #{order['number']} retained in Finalizado.")
return oid return oid

View File

@@ -23,7 +23,9 @@ PAID = {'order_id': 'b6f1c0de-0000-4000-8000-000000000001', 'number': 42, 'event
'state': 'SP', 'postal_code': '14400000'}, 'state': 'SP', 'postal_code': '14400000'},
'total_cents': 8472}} 'total_cents': 8472}}
PRODUCTS = {'TINY_PRODUCT_TEXTIL_FOLHA': '101', 'TINY_PRODUCT_TEXTIL_AVULSA': '102', PRODUCTS = {'TINY_PRODUCT_TEXTIL_FOLHA': '101', 'TINY_PRODUCT_TEXTIL_AVULSA': '102',
'TINY_PRODUCT_UV_FOLHA': '103', 'TINY_PRODUCT_UV_AVULSA': '104'} 'TINY_PRODUCT_UV_FOLHA': '103', 'TINY_PRODUCT_UV_AVULSA': '104', 'TINY_FORMA_ENVIO_RETIRADA': '77'}
PICKUP = {**PAID, 'order': {**PAID['order'], 'destination': None, 'freight': {'service': 'pickup', 'total_cents': 0}}}
READY = {'order_id': PAID['order_id'], 'number': 42, 'event': 'ready', 'reason': '', 'order': PICKUP['order']}
class FakeAuth: class FakeAuth:
@@ -32,12 +34,15 @@ class FakeAuth:
class FakeTinyCase(unittest.TestCase): class FakeTinyCase(unittest.TestCase):
"""A Tiny account in memory: contacts, orders and the four products.""" """A Tiny account in memory: contacts, orders, products and formas de envio."""
def setUp(self): def setUp(self):
self.contacts = [] self.contacts = []
self.orders = [] self.orders = []
self.calls = [] self.calls = []
self.refuse_status = 0
self.methods = {'77': {'id': 77, 'nome': 'Retirar pessoalmente', 'tipo': '6'},
'78': {'id': 78, 'nome': 'Correios', 'tipo': '1'}}
self.products = {value: {'id': int(value), 'sku': f'DTF-{value}', 'descricao': f'Produto {value}', self.products = {value: {'id': int(value), 'sku': f'DTF-{value}', 'descricao': f'Produto {value}',
'situacao': 'A', 'precos': {'preco': 14.9}} for value in PRODUCTS.values()} 'situacao': 'A', 'precos': {'preco': 14.9}} for value in PRODUCTS.values()}
@@ -59,13 +64,27 @@ class FakeTinyCase(unittest.TestCase):
return httpx.Response(200, json=self.products[wanted]) return httpx.Response(200, json=self.products[wanted])
if request.method == 'GET' and path == '/produtos': if request.method == 'GET' and path == '/produtos':
return httpx.Response(200, json={'itens': list(self.products.values())}) return httpx.Response(200, json={'itens': list(self.products.values())})
if request.method == 'GET' and path.startswith('/formas-envio/'):
wanted = path.rsplit('/', 1)[-1]
if wanted not in self.methods:
return httpx.Response(404, json={'mensagem': 'não encontrado'})
return httpx.Response(200, json=self.methods[wanted])
if request.method == 'GET' and path == '/formas-envio':
return httpx.Response(200, json={'itens': list(self.methods.values())})
if request.method == 'PUT' and path.startswith('/pedidos/') and path.endswith('/situacao'):
if self.refuse_status:
self.refuse_status -= 1
return httpx.Response(503, text='indisponível')
wanted = int(path.split('/')[2])
next(o for o in self.orders if o['id'] == wanted)['situacao'] = json.loads(request.content)['situacao']
return httpx.Response(204)
if request.method == 'GET' and path == '/pedidos': if request.method == 'GET' and path == '/pedidos':
return httpx.Response(200, json={'itens': [{'id': o['id']} for o in self.orders]}) return httpx.Response(200, json={'itens': [{'id': o['id']} for o in self.orders]})
if request.method == 'GET' and path.startswith('/pedidos/'): if request.method == 'GET' and path.startswith('/pedidos/'):
wanted = int(path.rsplit('/', 1)[-1]) wanted = int(path.rsplit('/', 1)[-1])
return httpx.Response(200, json=next(o for o in self.orders if o['id'] == wanted)) return httpx.Response(200, json=next(o for o in self.orders if o['id'] == wanted))
if request.method == 'POST' and path == '/pedidos': if request.method == 'POST' and path == '/pedidos':
order = {**json.loads(request.content), 'id': 9000 + len(self.orders), order = {'situacao': 0, **json.loads(request.content), 'id': 9000 + len(self.orders),
'numeroPedido': str(100 + len(self.orders))} 'numeroPedido': str(100 + len(self.orders))}
self.orders.append(order) self.orders.append(order)
return httpx.Response(200, json={'id': order['id'], 'numeroPedido': order['numeroPedido']}) return httpx.Response(200, json={'id': order['id'], 'numeroPedido': order['numeroPedido']})
@@ -124,7 +143,7 @@ class TinyTests(FakeTinyCase):
from app.tiny import check from app.tiny import check
results = check(auth=FakeAuth(), transport=httpx.MockTransport(self.handler)) results = check(auth=FakeAuth(), transport=httpx.MockTransport(self.handler))
self.assertEqual(set(results.values()), {'ok'}) self.assertEqual(set(results.values()), {'ok'})
self.assertEqual(len(results), 6) self.assertEqual(len(results), 7)
self.assertTrue(all(method == 'GET' for method, _ in self.calls)) self.assertTrue(all(method == 'GET' for method, _ in self.calls))
self.products['103']['situacao'] = 'I' self.products['103']['situacao'] = 'I'
with mock.patch.dict(os.environ, {'TINY_PRODUCT_UV_AVULSA': ''}): with mock.patch.dict(os.environ, {'TINY_PRODUCT_UV_AVULSA': ''}):
@@ -141,6 +160,86 @@ class TinyTests(FakeTinyCase):
tiny.deliver('k4', PAID) tiny.deliver('k4', PAID)
@mock.patch.dict(os.environ, PRODUCTS)
class TinyStatusTests(FakeTinyCase):
"""Situações that the client's Tiny -> n8n notices turn into WhatsApp messages."""
def puts(self):
return [call for call in self.calls if call[0] == 'PUT']
def test_nothing_changes_while_status_updates_are_off(self):
self.assertEqual(self.tiny.deliver('k1', PICKUP)['status'], 'created')
self.assertEqual(self.orders[0]['situacao'], 0)
self.assertEqual(self.tiny.deliver('k2', READY)['status'], 'not-applicable')
self.assertEqual(self.puts(), [])
@mock.patch.dict(os.environ, {'TINY_STATUS_UPDATES': 'true'})
def test_paid_order_is_approved_once(self):
first = self.tiny.deliver('k1', PICKUP)
self.assertEqual((first['status'], first['situacao']), ('created', 'Aprovada'))
self.assertEqual(self.orders[0]['situacao'], 3)
second = self.tiny.deliver('k1', PICKUP)
self.assertEqual(second['status'], 'already-created')
self.assertNotIn('situacao', second)
self.assertEqual(len(self.puts()), 1)
@mock.patch.dict(os.environ, {'TINY_STATUS_UPDATES': 'true'})
def test_retry_after_a_failed_approval_finishes_it(self):
self.refuse_status = 1
with self.assertRaises(TinyError):
self.tiny.deliver('k1', PICKUP)
self.assertEqual((len(self.orders), self.orders[0]['situacao']), (1, 0))
retry = self.tiny.deliver('k1', PICKUP)
self.assertEqual((retry['status'], retry['situacao']), ('already-created', 'Aprovada'))
self.assertEqual(self.calls.count(('POST', '/pedidos')), 1)
@mock.patch.dict(os.environ, {'TINY_STATUS_UPDATES': 'true'})
def test_an_order_already_moved_on_is_not_approved_again(self):
self.tiny.deliver('k1', PICKUP)
self.orders[0]['situacao'] = 7
self.tiny.deliver('k1', PICKUP)
self.assertEqual(self.orders[0]['situacao'], 7)
@mock.patch.dict(os.environ, {'TINY_STATUS_UPDATES': 'true'})
def test_ready_pickup_order_is_set_ready_once_by_its_known_id(self):
sale = self.tiny.deliver('k1', PICKUP)
self.calls.clear()
ready = self.tiny.deliver('k2', {**READY, 'tiny_id': sale['tiny_id']})
self.assertEqual((ready['status'], ready['situacao'], ready['tiny_number']),
('status-updated', 'Pronto para envio', sale['tiny_number']))
self.assertEqual(self.orders[0]['situacao'], 7)
self.assertNotIn(('GET', '/pedidos'), self.calls, 'a known id needs no search')
again = self.tiny.deliver('k2', {**READY, 'tiny_id': sale['tiny_id']})
self.assertEqual(again['status'], 'status-unchanged')
self.assertEqual(len(self.puts()), 1)
@mock.patch.dict(os.environ, {'TINY_STATUS_UPDATES': 'true'})
def test_ready_without_an_id_finds_the_order(self):
self.tiny.deliver('k1', PICKUP)
self.assertEqual(self.tiny.deliver('k2', READY)['status'], 'status-updated')
self.assertEqual(self.orders[0]['situacao'], 7)
@mock.patch.dict(os.environ, {'TINY_STATUS_UPDATES': 'true'})
def test_ready_before_the_sale_reached_tiny_is_retried(self):
with self.assertRaisesRegex(TinyError, 'not in Tiny yet'):
self.tiny.deliver('k2', READY)
self.assertEqual(self.puts(), [])
@mock.patch.dict(os.environ, {'TINY_STATUS_UPDATES': 'true'})
def test_ready_delivery_order_waits_for_freight(self):
self.tiny.deliver('k1', PAID)
self.calls.clear()
ready = self.tiny.deliver('k2', {**READY, 'order': PAID['order']})
self.assertEqual(ready['status'], 'not-applicable')
self.assertEqual(self.calls, [])
def test_pickup_orders_carry_the_pickup_forma_de_envio(self):
self.assertEqual(order_payload(PICKUP, 1)['transportador'], {'formaEnvio': {'id': 77}})
self.assertNotIn('transportador', order_payload(PAID, 1))
with mock.patch.dict(os.environ, {'TINY_FORMA_ENVIO_RETIRADA': ''}):
self.assertNotIn('transportador', order_payload(PICKUP, 1))
@mock.patch.dict(os.environ, PRODUCTS) @mock.patch.dict(os.environ, PRODUCTS)
class TinyProbeTests(FakeTinyCase): class TinyProbeTests(FakeTinyCase):
"""The supervised console tool run against the fake Tiny.""" """The supervised console tool run against the fake Tiny."""
@@ -158,6 +257,9 @@ class TinyProbeTests(FakeTinyCase):
code, text = self.probe('conferir') code, text = self.probe('conferir')
self.assertEqual(code, 0) self.assertEqual(code, 0)
self.assertIn('TINY_PRODUCT_UV_AVULSA (DTF UV 28,5 cm · artes avulsas): ok DTF-104', text) self.assertIn('TINY_PRODUCT_UV_AVULSA (DTF UV 28,5 cm · artes avulsas): ok DTF-104', text)
self.assertIn('TINY_FORMA_ENVIO_RETIRADA (retirada): ok Retirar pessoalmente', text)
code, text = self.probe('formas-envio')
self.assertIn('77\t6\tRetirar pessoalmente', text)
self.assertTrue(all(method == 'GET' for method, _ in self.calls)) self.assertTrue(all(method == 'GET' for method, _ in self.calls))
def test_order_is_shown_and_not_created_without_confirmation(self): def test_order_is_shown_and_not_created_without_confirmation(self):

View File

@@ -614,8 +614,11 @@ function eventFilters(){
function eventResult(e){ function eventResult(e){
const receipt=e.receipt||{}; const receipt=e.receipt||{};
if(e.delivered_at){ if(e.delivered_at){
if(receipt.status==='created')return ['Criado no Tiny nº '+receipt.tiny_number,'good']; const situacao=receipt.situacao?' · '+receipt.situacao:'';
if(receipt.status==='already-created')return ['Já existia no Tiny nº '+receipt.tiny_number+' (reenvio)','good']; if(receipt.status==='created')return ['Criado no Tiny nº '+receipt.tiny_number+situacao,'good'];
if(receipt.status==='already-created')return ['Já existia no Tiny nº '+receipt.tiny_number+' (reenvio)'+situacao,'good'];
if(receipt.status==='status-updated')return ['Tiny nº '+receipt.tiny_number+situacao,'good'];
if(receipt.status==='status-unchanged')return ['Tiny nº '+receipt.tiny_number+' já estava'+situacao.replace(' · ',' em '),''];
if(receipt.status==='not-applicable')return ['Não se aplica ao Tiny','']; if(receipt.status==='not-applicable')return ['Não se aplica ao Tiny',''];
return ['Registrado, sem envio','']; return ['Registrado, sem envio',''];
} }
@@ -645,7 +648,7 @@ function renderIntegrations(){
const actions=[connect]; const actions=[connect];
if(t.connected){ if(t.connected){
actions.unshift(button('Testar conexão',async()=>{const r=await api('/tiny/test',{}); actions.unshift(button('Testar conexão',async()=>{const r=await api('/tiny/test',{});
say(r.ok?'Conexão com o Tiny ok: pedidos, contatos e os 4 produtos conferidos.':'Tiny: '+Object.entries(r.results).filter(([,v])=>v!=='ok').map(([k,v])=>k+': '+v).join(' · '),!r.ok);}));} say(r.ok?'Conexão com o Tiny ok: pedidos, contatos, os 4 produtos e a retirada conferidos.':'Tiny: '+Object.entries(r.results).filter(([,v])=>v!=='ok').map(([k,v])=>k+': '+v).join(' · '),!r.ok);}));}
cards.push(integration('Tiny',!t.connected?'Não conectado':t.problem?'Verificar':'Conectado', cards.push(integration('Tiny',!t.connected?'Não conectado':t.problem?'Verificar':'Conectado',
t.connected&&!t.problem?'ok':'warn',tinyDetail(t),actions)); t.connected&&!t.problem?'ok':'warn',tinyDetail(t),actions));
} }