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())
|
||||
|
||||
@@ -37,6 +37,9 @@ TINY_PRODUCT_TEXTIL_FOLHA=TBD
|
||||
TINY_PRODUCT_TEXTIL_AVULSA=TBD
|
||||
TINY_PRODUCT_UV_FOLHA=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
|
||||
STORAGE_QUOTA_BYTES=TBD
|
||||
OWNER_UPLOAD_QUOTA_BYTES=TBD
|
||||
|
||||
@@ -37,6 +37,13 @@ x-app-environment: &app-environment
|
||||
TINY_PRODUCT_TEXTIL_AVULSA: ${TINY_PRODUCT_TEXTIL_AVULSA:-}
|
||||
TINY_PRODUCT_UV_FOLHA: ${TINY_PRODUCT_UV_FOLHA:-}
|
||||
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
|
||||
STORAGE_ADAPTER: s3-r2
|
||||
PUBLIC_ORIGIN: https://${SITE_DOMAIN:?set SITE_DOMAIN}
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
**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
|
||||
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
|
||||
|
||||
@@ -204,6 +204,11 @@ def run():
|
||||
time.sleep(1)
|
||||
else:raise AssertionError('Mock outbox did not drain')
|
||||
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.")
|
||||
return oid
|
||||
|
||||
|
||||
@@ -23,7 +23,9 @@ PAID = {'order_id': 'b6f1c0de-0000-4000-8000-000000000001', 'number': 42, 'event
|
||||
'state': 'SP', 'postal_code': '14400000'},
|
||||
'total_cents': 8472}}
|
||||
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:
|
||||
@@ -32,12 +34,15 @@ class FakeAuth:
|
||||
|
||||
|
||||
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):
|
||||
self.contacts = []
|
||||
self.orders = []
|
||||
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}',
|
||||
'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])
|
||||
if request.method == 'GET' and path == '/produtos':
|
||||
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':
|
||||
return httpx.Response(200, json={'itens': [{'id': o['id']} for o in self.orders]})
|
||||
if request.method == 'GET' and path.startswith('/pedidos/'):
|
||||
wanted = int(path.rsplit('/', 1)[-1])
|
||||
return httpx.Response(200, json=next(o for o in self.orders if o['id'] == wanted))
|
||||
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))}
|
||||
self.orders.append(order)
|
||||
return httpx.Response(200, json={'id': order['id'], 'numeroPedido': order['numeroPedido']})
|
||||
@@ -124,7 +143,7 @@ class TinyTests(FakeTinyCase):
|
||||
from app.tiny import check
|
||||
results = check(auth=FakeAuth(), transport=httpx.MockTransport(self.handler))
|
||||
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.products['103']['situacao'] = 'I'
|
||||
with mock.patch.dict(os.environ, {'TINY_PRODUCT_UV_AVULSA': ''}):
|
||||
@@ -141,6 +160,86 @@ class TinyTests(FakeTinyCase):
|
||||
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)
|
||||
class TinyProbeTests(FakeTinyCase):
|
||||
"""The supervised console tool run against the fake Tiny."""
|
||||
@@ -158,6 +257,9 @@ class TinyProbeTests(FakeTinyCase):
|
||||
code, text = self.probe('conferir')
|
||||
self.assertEqual(code, 0)
|
||||
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))
|
||||
|
||||
def test_order_is_shown_and_not_created_without_confirmation(self):
|
||||
|
||||
@@ -614,8 +614,11 @@ function eventFilters(){
|
||||
function eventResult(e){
|
||||
const receipt=e.receipt||{};
|
||||
if(e.delivered_at){
|
||||
if(receipt.status==='created')return ['Criado no Tiny nº '+receipt.tiny_number,'good'];
|
||||
if(receipt.status==='already-created')return ['Já existia no Tiny nº '+receipt.tiny_number+' (reenvio)','good'];
|
||||
const situacao=receipt.situacao?' · '+receipt.situacao:'';
|
||||
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',''];
|
||||
return ['Registrado, sem envio',''];
|
||||
}
|
||||
@@ -645,7 +648,7 @@ function renderIntegrations(){
|
||||
const actions=[connect];
|
||||
if(t.connected){
|
||||
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',
|
||||
t.connected&&!t.problem?'ok':'warn',tinyDetail(t),actions));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user