From aec5b1d054bc9f8333362faae6381625d9f7fc30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Fri, 25 Sep 2026 14:17:00 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20send=20order=20situa=C3=A7=C3=B5es=20to?= =?UTF-8?q?=20Tiny=20for=20the=20client's=20WhatsApp=20notices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/api/operator.py | 13 +++-- app/tiny.py | 104 ++++++++++++++++++++++++++++----- app/tiny_probe.py | 23 +++++++- deploy/portainer.env.example | 3 + docker-compose.yml | 7 +++ docs/ROADMAP.md | 24 ++++++++ tests/smoke_test.py | 5 ++ tests/test_tiny.py | 110 +++++++++++++++++++++++++++++++++-- web/kanban.js | 9 ++- 9 files changed, 272 insertions(+), 26 deletions(-) diff --git a/app/api/operator.py b/app/api/operator.py index eef71b8..481a8a8 100644 --- a/app/api/operator.py +++ b/app/api/operator.py @@ -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') diff --git a/app/tiny.py b/app/tiny.py index 19e09fd..042c9f9 100644 --- a/app/tiny.py +++ b/app/tiny.py @@ -19,6 +19,13 @@ Idempotency: the outbox may deliver the same event more than once. Every order carries numeroOrdemCompra = "DTF-", 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]} diff --git a/app/tiny_probe.py b/app/tiny_probe.py index b920106..90f806e 100644 --- a/app/tiny_probe.py +++ b/app/tiny_probe.py @@ -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()) diff --git a/deploy/portainer.env.example b/deploy/portainer.env.example index 4d10408..b1d0453 100644 --- a/deploy/portainer.env.example +++ b/deploy/portainer.env.example @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index bc842e9..ce4b609 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0c034ec..92999aa 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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-`. **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 diff --git a/tests/smoke_test.py b/tests/smoke_test.py index a3c841e..92510ed 100644 --- a/tests/smoke_test.py +++ b/tests/smoke_test.py @@ -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 diff --git a/tests/test_tiny.py b/tests/test_tiny.py index 3676583..67ff19f 100644 --- a/tests/test_tiny.py +++ b/tests/test_tiny.py @@ -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): diff --git a/web/kanban.js b/web/kanban.js index a1decf7..bb9236e 100644 --- a/web/kanban.js +++ b/web/kanban.js @@ -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)); }