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
|
# 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')
|
||||||
|
|||||||
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
|
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]}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user