feat: connect Tiny through its v3 API with OAuth
All checks were successful
Build and deploy / Validate source (push) Successful in 9s
Build and deploy / Integration suite on a real stack (push) Successful in 2m49s
Build and deploy / Secret scan and release gate (push) Successful in 9s
Build and deploy / Publish images and notify Portainer (push) Has been skipped
All checks were successful
Build and deploy / Validate source (push) Successful in 9s
Build and deploy / Integration suite on a real stack (push) Successful in 2m49s
Build and deploy / Secret scan and release gate (push) Successful in 9s
Build and deploy / Publish images and notify Portainer (push) Has been skipped
Tiny v3 replaces the v2 token adapter. An operator connects Tiny once from the Kanban; the callback is authorised by a single-use state, because Tiny's cross-site redirect does not carry the SameSite=Strict operator cookie. Tokens are kept in provider_tokens, the refresh token rotates under a row lock, and the worker keeps the connection alive while order creation is off. Orders find or create the customer's contact by CNPJ, then POST /pedidos with product ids from TINY_PRODUCT_TEXTIL_FOLHA, _TEXTIL_AVULSA, _UV_FOLHA and _UV_AVULSA and numeroOrdemCompra DTF-<number>; a retry searches the customer's recent orders for that number first. The product settings avoid a _FILE suffix, which the secrets loader reads as a secret file path. Production passes the application credentials through but keeps TINY_ADAPTER fake: Tiny has no sandbox, so creating real orders waits for a supervised test. compose.providers.yaml gives the local API and worker an internet route for provider testing; the default local stack still has none. Verified with the full CI integration sequence locally, including the new tiny_oauth_test against the real database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
15
.env.example
15
.env.example
@@ -25,9 +25,18 @@ TINY_ADAPTER=fake
|
|||||||
# MP_ACCESS_TOKEN=TEST-...
|
# MP_ACCESS_TOKEN=TEST-...
|
||||||
# MP_WEBHOOK_SECRET=...
|
# MP_WEBHOOK_SECRET=...
|
||||||
# MP_NOTIFICATION_URL=https://<public tunnel>/api/payments/webhook
|
# MP_NOTIFICATION_URL=https://<public tunnel>/api/payments/webhook
|
||||||
# TINY_ADAPTER=tiny
|
# Tiny API v3: client ID/secret come from the "Aplicativo" created in Tiny
|
||||||
# TINY_TOKEN=...
|
# (Configurações > Geral > Aplicativos); the redirect URI registered there
|
||||||
# TINY_TAG=Site DTF
|
# must be exactly TINY_REDIRECT_URI. Product ids are the Tiny products each
|
||||||
|
# Site product becomes. Tiny has no sandbox: orders created are real.
|
||||||
|
# TINY_CLIENT_ID=...
|
||||||
|
# TINY_CLIENT_SECRET=...
|
||||||
|
# TINY_REDIRECT_URI=http://localhost:8081/api/operator/tiny/callback
|
||||||
|
# TINY_PRODUCT_TEXTIL_FOLHA=...
|
||||||
|
# TINY_PRODUCT_TEXTIL_AVULSA=...
|
||||||
|
# TINY_PRODUCT_UV_FOLHA=...
|
||||||
|
# TINY_PRODUCT_UV_AVULSA=...
|
||||||
|
# TINY_ADAPTER=tiny # only once connected and tested: creates real orders
|
||||||
WHATSAPP_ADAPTER=fake
|
WHATSAPP_ADAPTER=fake
|
||||||
STORAGE_ADAPTER=s3-local
|
STORAGE_ADAPTER=s3-local
|
||||||
MOCK_FREIGHT_CENTS=1500
|
MOCK_FREIGHT_CENTS=1500
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
$COMPOSE exec -T api python -m tests.retention_test
|
$COMPOSE exec -T api python -m tests.retention_test
|
||||||
$COMPOSE exec -T api python -m tests.runtime_security_test
|
$COMPOSE exec -T api python -m tests.runtime_security_test
|
||||||
|
$COMPOSE exec -T api python -m tests.tiny_oauth_test
|
||||||
|
|
||||||
# Run Chrome on the Compose network. It must resolve the same storage:9000
|
# Run Chrome on the Compose network. It must resolve the same storage:9000
|
||||||
# hostname used in presigned URLs, and absence of Chrome must fail CI.
|
# hostname used in presigned URLs, and absence of Chrome must fail CI.
|
||||||
|
|||||||
@@ -22,8 +22,10 @@ def require_runtime():
|
|||||||
raise RuntimeError(f'{name} must use the currently supported fake adapter')
|
raise RuntimeError(f'{name} must use the currently supported fake adapter')
|
||||||
tiny = os.environ.get('TINY_ADAPTER')
|
tiny = os.environ.get('TINY_ADAPTER')
|
||||||
if tiny == 'tiny':
|
if tiny == 'tiny':
|
||||||
if not os.environ.get('TINY_TOKEN'):
|
from .tiny import required_settings
|
||||||
raise RuntimeError('TINY_TOKEN is required for the Tiny adapter')
|
for name in required_settings():
|
||||||
|
if not os.environ.get(name):
|
||||||
|
raise RuntimeError(f'{name} is required for the Tiny adapter')
|
||||||
elif tiny != 'fake':
|
elif tiny != 'fake':
|
||||||
raise RuntimeError('TINY must use the fake or tiny adapter')
|
raise RuntimeError('TINY must use the fake or tiny adapter')
|
||||||
# Mercado Pago is selectable only with its credentials present; it has not
|
# Mercado Pago is selectable only with its credentials present; it has not
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from typing import Literal
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
from psycopg.types.json import Jsonb
|
from psycopg.types.json import Jsonb
|
||||||
|
|
||||||
from ..core import db
|
from ..core import db
|
||||||
@@ -15,6 +16,7 @@ from ..core.auth import (COOKIE_SECURE, DUMMY_PASSWORD_HASH, audit, client_ip, o
|
|||||||
from ..core.models import Move, OperatorLogin, Resolution, Review
|
from ..core.models import Move, OperatorLogin, Resolution, Review
|
||||||
from ..core.pricing import price
|
from ..core.pricing import price
|
||||||
from ..printjobs import queue as queue_print_files
|
from ..printjobs import queue as queue_print_files
|
||||||
|
from .. import tiny
|
||||||
from ..runtime import (BOARD_FINISHED_LIMIT, BOARD_QUOTE_LIMIT, STATES, TRANSITIONS,
|
from ..runtime import (BOARD_FINISHED_LIMIT, BOARD_QUOTE_LIMIT, STATES, TRANSITIONS,
|
||||||
enqueue, freight, quote_view, storage, upload_row)
|
enqueue, freight, quote_view, storage, upload_row)
|
||||||
from ..scanning import require_clean
|
from ..scanning import require_clean
|
||||||
@@ -94,7 +96,7 @@ def board(user=Depends(operator)):
|
|||||||
FROM dtf_local.payment_events WHERE (outcome LIKE 'refused%' OR outcome LIKE 'attention%') AND resolved_at IS NULL
|
FROM dtf_local.payment_events WHERE (outcome LIKE 'refused%' OR outcome LIKE 'attention%') AND resolved_at IS NULL
|
||||||
ORDER BY received_at LIMIT 100''').fetchall()
|
ORDER BY received_at LIMIT 100''').fetchall()
|
||||||
return {'states': STATES, 'transitions': TRANSITIONS,
|
return {'states': STATES, 'transitions': TRANSITIONS,
|
||||||
'orders': orders, 'payment_issues': refused,
|
'orders': orders, 'payment_issues': refused, 'tiny': tiny_status(),
|
||||||
'finished_shown': len(finished), 'finished_total': finished_total,
|
'finished_shown': len(finished), 'finished_total': finished_total,
|
||||||
'quotes': [quote_view(c, q) for q in pending + approved],
|
'quotes': [quote_view(c, q) for q in pending + approved],
|
||||||
'pending_total': pending_total, 'approved_total': approved_total,
|
'pending_total': pending_total, 'approved_total': approved_total,
|
||||||
@@ -207,6 +209,34 @@ def resolve_payment(uid: UUID, body: Resolution, user=Depends(operator)):
|
|||||||
audit('payment_issue_resolved', payment_event=str(uid), operator=user)
|
audit('payment_issue_resolved', payment_event=str(uid), operator=user)
|
||||||
return {'ok': True}
|
return {'ok': True}
|
||||||
|
|
||||||
|
def tiny_status():
|
||||||
|
if not tiny.configured():
|
||||||
|
return {'configured': False}
|
||||||
|
return {'configured': True, 'orders_enabled': os.environ.get('TINY_ADAPTER') == 'tiny',
|
||||||
|
**tiny.TinyAuth().status()}
|
||||||
|
|
||||||
|
@router.post('/api/operator/tiny/connect')
|
||||||
|
def tiny_connect(user=Depends(operator)):
|
||||||
|
"""Start the one-time authorisation of this system in the client's Tiny."""
|
||||||
|
if not tiny.configured():
|
||||||
|
raise HTTPException(503, 'Tiny application is not configured')
|
||||||
|
audit('tiny_connect_started', operator=user)
|
||||||
|
return {'url': tiny.TinyAuth().authorize_url(user)}
|
||||||
|
|
||||||
|
@router.get('/api/operator/tiny/callback')
|
||||||
|
def tiny_callback(code: str = Query(max_length=4096), state: str = Query(max_length=128)):
|
||||||
|
"""Tiny's redirect back. Cross-site, so the operator cookie is absent: the
|
||||||
|
single-use state an operator created is what authorises it."""
|
||||||
|
if not tiny.configured():
|
||||||
|
raise HTTPException(503, 'Tiny application is not configured')
|
||||||
|
try:
|
||||||
|
who = tiny.TinyAuth().complete(code, state)
|
||||||
|
except tiny.TinyError:
|
||||||
|
audit('tiny_connect_failed')
|
||||||
|
return RedirectResponse('/?tiny=failed', status_code=303)
|
||||||
|
audit('tiny_connected', operator=who)
|
||||||
|
return RedirectResponse('/?tiny=connected', status_code=303)
|
||||||
|
|
||||||
@router.get('/api/operator/orders/{uid}/history')
|
@router.get('/api/operator/orders/{uid}/history')
|
||||||
def history(uid: UUID, user=Depends(operator)):
|
def history(uid: UUID, user=Depends(operator)):
|
||||||
with db.connect() as c:
|
with db.connect() as c:
|
||||||
|
|||||||
@@ -99,6 +99,20 @@ CREATE TABLE IF NOT EXISTS dtf_local.payment_intents (
|
|||||||
created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(),
|
created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
UNIQUE(provider, provider_payment_id)
|
UNIQUE(provider, provider_payment_id)
|
||||||
);
|
);
|
||||||
|
-- OAuth connections to providers (Tiny). One row per provider; the refresh
|
||||||
|
-- token rotates on use, so it lives here, never in configuration.
|
||||||
|
CREATE TABLE IF NOT EXISTS dtf_local.provider_tokens (
|
||||||
|
provider text PRIMARY KEY, access_token text NOT NULL, refresh_token text NOT NULL,
|
||||||
|
access_expires_at timestamptz NOT NULL, refresh_expires_at timestamptz,
|
||||||
|
connected_by text NOT NULL, connected_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
-- Single-use states for an operator-started OAuth connection. They protect the
|
||||||
|
-- callback, which arrives cross-site without the operator's cookie.
|
||||||
|
CREATE TABLE IF NOT EXISTS dtf_local.oauth_states (
|
||||||
|
state text PRIMARY KEY, provider text NOT NULL, operator text NOT NULL,
|
||||||
|
expires_at timestamptz NOT NULL
|
||||||
|
);
|
||||||
-- The print file generated from each paid item's approved layout. One row per
|
-- The print file generated from each paid item's approved layout. One row per
|
||||||
-- item: the worker claims it, renders, and records either the file or why the
|
-- item: the worker claims it, renders, and records either the file or why the
|
||||||
-- item has to be prepared by hand. Regenerating replaces the row's result.
|
-- item has to be prepared by hand. Regenerating replaces the row's result.
|
||||||
|
|||||||
298
app/tiny.py
298
app/tiny.py
@@ -1,104 +1,264 @@
|
|||||||
"""Tiny/Olist (API 2.0): create the sales order once a payment is approved.
|
"""Tiny/Olist ERP (API v3): create the sales order once a payment is approved.
|
||||||
|
|
||||||
Written from the public API documentation and exercised only against a fake
|
Written from the public API documentation and exercised only against a fake
|
||||||
HTTP transport. Endpoints, product codes, tags and rate limits still have to
|
HTTP transport. Tiny has no sandbox: the first real test creates a real order
|
||||||
be confirmed against the client's account before this is a real integration.
|
in the client's ERP, so test with a marked order and cancel it afterwards.
|
||||||
|
|
||||||
Idempotency: the outbox may deliver the same event more than once (a timeout
|
Authentication is OAuth2 on Tiny's Keycloak. The client creates an
|
||||||
after Tiny accepted it, a worker restart). Every order is created with
|
"Aplicativo" in Tiny (Configurações > Geral > Aplicativos), which yields a
|
||||||
numero_pedido_ecommerce = "DTF-<order number>", and Tiny is searched for that
|
client ID and secret and registers our callback URL. An operator then clicks
|
||||||
number first, so a second delivery finds the first order instead of creating
|
"Conectar Tiny" on the Kanban once; the tokens are kept in the database and
|
||||||
another one.
|
the refresh token is rotated on every refresh, under a row lock so two
|
||||||
|
workers never spend the same one.
|
||||||
|
|
||||||
Tiny answers HTTP 200 for most failures and reports them in retorno.status,
|
Orders are created by contact and product id: the customer's contact is found
|
||||||
so the body decides success. Anything unexpected raises, and the outbox
|
by CNPJ or created, and each product mode maps to a product that must already
|
||||||
retries with backoff; nothing is marked delivered unless Tiny confirmed it.
|
exist in Tiny (PRODUCT_SETTINGS).
|
||||||
|
|
||||||
|
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.
|
||||||
"""
|
"""
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
API = 'https://api.tiny.com.br/api2'
|
API = 'https://api.tiny.com.br/public-api/v3'
|
||||||
|
AUTH = 'https://accounts.tiny.com.br/realms/tiny/protocol/openid-connect'
|
||||||
|
# Not TINY_PRODUCT_<MODE>: app/core/secrets.py reads any variable ending in
|
||||||
|
# _FILE as a path to a secret file, and one product mode is called "file".
|
||||||
|
PRODUCT_SETTINGS = {'file': 'TINY_PRODUCT_TEXTIL_FOLHA', 'avulsa': 'TINY_PRODUCT_TEXTIL_AVULSA',
|
||||||
|
'uvfile': 'TINY_PRODUCT_UV_FOLHA', 'uv': 'TINY_PRODUCT_UV_AVULSA'}
|
||||||
PRODUCTS = {'file': 'DTF Têxtil 57 cm · folha montada',
|
PRODUCTS = {'file': 'DTF Têxtil 57 cm · folha montada',
|
||||||
'avulsa': 'DTF Têxtil 57 cm · artes avulsas',
|
'avulsa': 'DTF Têxtil 57 cm · artes avulsas',
|
||||||
'uvfile': 'DTF UV 28,5 cm · folha montada',
|
'uvfile': 'DTF UV 28,5 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.
|
||||||
|
SEARCH_DAYS = 7
|
||||||
|
STATE_MINUTES = 10
|
||||||
|
# Brazil has had no daylight saving since 2019; the order date is the local day.
|
||||||
|
BRASILIA = timezone(timedelta(hours=-3))
|
||||||
|
|
||||||
|
|
||||||
|
def local_today():
|
||||||
|
return datetime.now(BRASILIA).date()
|
||||||
|
|
||||||
|
|
||||||
class TinyError(Exception):
|
class TinyError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def ecommerce_number(number):
|
class TinyNotConnected(TinyError):
|
||||||
|
"""No authorised connection yet: an operator must click "Conectar Tiny"."""
|
||||||
|
|
||||||
|
|
||||||
|
def purchase_order(number):
|
||||||
return f'DTF-{number}'
|
return f'DTF-{number}'
|
||||||
|
|
||||||
|
|
||||||
|
def configured():
|
||||||
|
"""Whether the OAuth application is configured (orders may still be fake)."""
|
||||||
|
return all(os.environ.get(name) for name in ('TINY_CLIENT_ID', 'TINY_CLIENT_SECRET', 'TINY_REDIRECT_URI'))
|
||||||
|
|
||||||
|
|
||||||
|
def required_settings():
|
||||||
|
return ['TINY_CLIENT_ID', 'TINY_CLIENT_SECRET', 'TINY_REDIRECT_URI'] + list(PRODUCT_SETTINGS.values())
|
||||||
|
|
||||||
|
|
||||||
|
# OAuth -------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TinyAuth:
|
||||||
|
"""The OAuth application and the stored connection."""
|
||||||
|
|
||||||
|
def __init__(self, connect=None, transport=None, clock=time.time):
|
||||||
|
self.client_id = os.environ.get('TINY_CLIENT_ID', '')
|
||||||
|
self.client_secret = os.environ.get('TINY_CLIENT_SECRET', '')
|
||||||
|
self.redirect_uri = os.environ.get('TINY_REDIRECT_URI', '')
|
||||||
|
if connect is None:
|
||||||
|
from .core.db import connect
|
||||||
|
self.connect = connect
|
||||||
|
self.http = httpx.Client(timeout=20, transport=transport)
|
||||||
|
self.clock = clock
|
||||||
|
|
||||||
|
def authorize_url(self, operator):
|
||||||
|
"""Start a connection. The state is single-use, short-lived, and only an
|
||||||
|
authenticated operator can create one, which is what protects the
|
||||||
|
callback: Tiny's redirect back is cross-site, so the operator's
|
||||||
|
SameSite=Strict cookie does not travel with it."""
|
||||||
|
state = secrets.token_urlsafe(32)
|
||||||
|
with self.connect() as c:
|
||||||
|
c.execute("DELETE FROM dtf_local.oauth_states WHERE expires_at<now()")
|
||||||
|
c.execute('''INSERT INTO dtf_local.oauth_states(state,provider,operator,expires_at)
|
||||||
|
VALUES(%s,'tiny',%s,now()+%s)''', (state, operator, timedelta(minutes=STATE_MINUTES)))
|
||||||
|
return f'{AUTH}/auth?' + urlencode({'response_type': 'code', 'client_id': self.client_id,
|
||||||
|
'redirect_uri': self.redirect_uri, 'scope': 'openid',
|
||||||
|
'state': state})
|
||||||
|
|
||||||
|
def complete(self, code, state):
|
||||||
|
"""Exchange the authorisation code; returns the operator who started it."""
|
||||||
|
with self.connect() as c:
|
||||||
|
row = c.execute('''DELETE FROM dtf_local.oauth_states WHERE state=%s AND provider='tiny'
|
||||||
|
AND expires_at>now() RETURNING operator''', (state,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise TinyError('Unknown or expired authorisation state')
|
||||||
|
tokens = self._token({'grant_type': 'authorization_code', 'code': code,
|
||||||
|
'redirect_uri': self.redirect_uri})
|
||||||
|
self._store(c, tokens, row['operator'])
|
||||||
|
return row['operator']
|
||||||
|
|
||||||
|
def status(self):
|
||||||
|
with self.connect() as c:
|
||||||
|
row = c.execute('''SELECT connected_by,connected_at,refresh_expires_at
|
||||||
|
FROM dtf_local.provider_tokens WHERE provider='tiny' ''').fetchone()
|
||||||
|
if not row:
|
||||||
|
return {'connected': False}
|
||||||
|
expired = row['refresh_expires_at'] and row['refresh_expires_at'] <= datetime.now(timezone.utc)
|
||||||
|
return {'connected': not expired, 'connected_by': row['connected_by'],
|
||||||
|
'connected_at': row['connected_at'], 'expires_at': row['refresh_expires_at']}
|
||||||
|
|
||||||
|
def access_token(self):
|
||||||
|
"""A valid access token, refreshing (and rotating) under a row lock."""
|
||||||
|
with self.connect() as c:
|
||||||
|
row = c.execute('''SELECT * FROM dtf_local.provider_tokens WHERE provider='tiny'
|
||||||
|
FOR UPDATE''').fetchone()
|
||||||
|
if not row:
|
||||||
|
raise TinyNotConnected('Tiny is not connected; use "Conectar Tiny" on the Kanban')
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
if row['access_expires_at'] > now + timedelta(seconds=60):
|
||||||
|
return row['access_token']
|
||||||
|
if row['refresh_expires_at'] and row['refresh_expires_at'] <= now:
|
||||||
|
raise TinyNotConnected('The Tiny connection expired; connect again on the Kanban')
|
||||||
|
tokens = self._token({'grant_type': 'refresh_token', 'refresh_token': row['refresh_token']})
|
||||||
|
self._store(c, tokens, row['connected_by'], refreshed=True)
|
||||||
|
return tokens['access_token']
|
||||||
|
|
||||||
|
def _token(self, form):
|
||||||
|
response = self.http.post(f'{AUTH}/token', data={**form, 'client_id': self.client_id,
|
||||||
|
'client_secret': self.client_secret})
|
||||||
|
if response.status_code == 400 and form['grant_type'] == 'refresh_token':
|
||||||
|
raise TinyNotConnected('Tiny refused the stored refresh token; connect again on the Kanban')
|
||||||
|
response.raise_for_status()
|
||||||
|
tokens = response.json()
|
||||||
|
if not tokens.get('access_token') or not tokens.get('refresh_token'):
|
||||||
|
raise TinyError('Tiny token response is missing tokens')
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
def _store(self, c, tokens, operator, refreshed=False):
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
access_expires = now + timedelta(seconds=int(tokens.get('expires_in', 300)))
|
||||||
|
refresh_in = tokens.get('refresh_expires_in')
|
||||||
|
refresh_expires = now + timedelta(seconds=int(refresh_in)) if refresh_in else None
|
||||||
|
c.execute('''INSERT INTO dtf_local.provider_tokens(provider,access_token,refresh_token,
|
||||||
|
access_expires_at,refresh_expires_at,connected_by,connected_at,updated_at)
|
||||||
|
VALUES('tiny',%s,%s,%s,%s,%s,now(),now())
|
||||||
|
ON CONFLICT(provider) DO UPDATE SET access_token=EXCLUDED.access_token,
|
||||||
|
refresh_token=EXCLUDED.refresh_token, access_expires_at=EXCLUDED.access_expires_at,
|
||||||
|
refresh_expires_at=EXCLUDED.refresh_expires_at, updated_at=now(),
|
||||||
|
connected_by=CASE WHEN %s THEN dtf_local.provider_tokens.connected_by ELSE EXCLUDED.connected_by END,
|
||||||
|
connected_at=CASE WHEN %s THEN dtf_local.provider_tokens.connected_at ELSE now() END''',
|
||||||
|
(tokens['access_token'], tokens['refresh_token'], access_expires, refresh_expires,
|
||||||
|
operator, refreshed, refreshed))
|
||||||
|
|
||||||
|
|
||||||
|
# Orders ------------------------------------------------------------------
|
||||||
|
|
||||||
def money(cents):
|
def money(cents):
|
||||||
return f'{Decimal(cents) / 100:.2f}'
|
return float(Decimal(cents) / 100)
|
||||||
|
|
||||||
|
|
||||||
def order_payload(payload):
|
def contact_payload(order):
|
||||||
"""The Tiny 'pedido' for a paid order's approved snapshot."""
|
|
||||||
order = payload['order']
|
|
||||||
customer = order['customer']
|
customer = order['customer']
|
||||||
destination = order.get('destination')
|
destination = order.get('destination')
|
||||||
client = {'nome': (destination or {}).get('recipient') or customer['mail'],
|
contact = {'nome': (destination or {}).get('recipient') or customer['mail'],
|
||||||
'tipo_pessoa': 'J', 'cpf_cnpj': customer['cnpj'],
|
'tipoPessoa': 'J', 'cpfCnpj': customer['cnpj'], 'email': customer['mail'],
|
||||||
'fone': customer['zap'], 'email': customer['mail']}
|
'celular': customer['zap'], 'situacao': 'A'}
|
||||||
if destination:
|
if destination:
|
||||||
client.update(endereco=destination['street'], numero=destination['number'],
|
contact['endereco'] = address(destination)
|
||||||
complemento=destination.get('complement', ''), bairro=destination['district'],
|
return contact
|
||||||
cep=destination['postal_code'], cidade=destination['city'], uf=destination['state'])
|
|
||||||
|
|
||||||
|
def address(destination):
|
||||||
|
return {'endereco': destination['street'], 'numero': destination['number'],
|
||||||
|
'complemento': destination.get('complement', ''), 'bairro': destination['district'],
|
||||||
|
'municipio': destination['city'], 'cep': destination['postal_code'],
|
||||||
|
'uf': destination['state'], 'pais': 'Brasil'}
|
||||||
|
|
||||||
|
|
||||||
|
def order_payload(payload, contact_id, today=None):
|
||||||
|
"""The v3 'pedido' for a paid order's approved snapshot."""
|
||||||
|
order = payload['order']
|
||||||
items = []
|
items = []
|
||||||
for item in order['items']:
|
for item in order['items']:
|
||||||
code = os.environ.get(f"TINY_SKU_{item['mode'].upper()}", '')
|
setting = PRODUCT_SETTINGS[item['mode']]
|
||||||
entry = {'descricao': PRODUCTS[item['mode']] + f" · nota {item['grade']}",
|
product = os.environ.get(setting, '')
|
||||||
'unidade': 'M', 'quantidade': item['billed_metres'],
|
if not product.isdigit():
|
||||||
'valor_unitario': money(item['unit_cents'])}
|
raise TinyError(f'{setting} must be the Tiny product id')
|
||||||
if code:
|
items.append({'produto': {'id': int(product)},
|
||||||
entry['codigo'] = code
|
'quantidade': float(Decimal(item['billed_metres'])),
|
||||||
items.append({'item': entry})
|
'valorUnitario': money(item['unit_cents']),
|
||||||
|
'infoAdicional': PRODUCTS[item['mode']] + f" · nota {item['grade']}"})
|
||||||
freight = order['freight']
|
freight = order['freight']
|
||||||
pedido = {'numero_pedido_ecommerce': ecommerce_number(payload['number']),
|
pickup = freight.get('service') == 'pickup'
|
||||||
'cliente': client, 'itens': items,
|
pedido = {'data': (today or local_today()).isoformat(),
|
||||||
'valor_frete': money(freight['total_cents']),
|
'idContato': contact_id,
|
||||||
'obs': f"Pedido DTF #{payload['number']} · pago · {payload['order_id']}"}
|
'numeroOrdemCompra': purchase_order(payload['number']),
|
||||||
if freight.get('service') == 'pickup':
|
'itens': items,
|
||||||
# What dispatch already receives for pickups today (see web/site-config.js).
|
'valorFrete': money(freight['total_cents']),
|
||||||
pedido.update(forma_envio='X', nome_transportador='DropStar', frete_por_conta='R')
|
'observacoes': f"Pedido DTF #{payload['number']}" + (' · retirada em Franca' if pickup else ''),
|
||||||
tag = os.environ.get('TINY_TAG', '')
|
'observacoesInternas': f"Site DTF · pago · {payload['order_id']}"}
|
||||||
if tag:
|
destination = order.get('destination')
|
||||||
pedido['marcadores'] = [{'marcador': {'descricao': tag}}]
|
if destination:
|
||||||
return {'pedido': pedido}
|
pedido['enderecoEntrega'] = {**address(destination), 'enderecoNro': destination['number'],
|
||||||
|
'nomeDestinatario': destination['recipient']}
|
||||||
|
del pedido['enderecoEntrega']['numero']
|
||||||
|
ecommerce = os.environ.get('TINY_ECOMMERCE_ID', '')
|
||||||
|
if ecommerce.isdigit():
|
||||||
|
pedido['ecommerce'] = {'id': int(ecommerce), 'numeroPedidoEcommerce': purchase_order(payload['number'])}
|
||||||
|
return pedido
|
||||||
|
|
||||||
|
|
||||||
class TinyOrders:
|
class TinyOrders:
|
||||||
def __init__(self, token=None, transport=None):
|
def __init__(self, auth=None, transport=None, today=None):
|
||||||
self.token = token or os.environ.get('TINY_TOKEN', '')
|
missing = [name for name in required_settings() if not os.environ.get(name)]
|
||||||
if not self.token:
|
if auth is None and missing:
|
||||||
raise RuntimeError('Tiny needs TINY_TOKEN')
|
raise RuntimeError('Tiny needs ' + ', '.join(missing))
|
||||||
|
self.auth = auth or TinyAuth()
|
||||||
self.http = httpx.Client(base_url=API, transport=transport, timeout=30)
|
self.http = httpx.Client(base_url=API, transport=transport, timeout=30)
|
||||||
|
self.today = today
|
||||||
|
|
||||||
def call(self, method, **params):
|
def request(self, method, path, **kwargs):
|
||||||
response = self.http.post(f'/{method}.php', data={'token': self.token, 'formato': 'json', **params})
|
headers = {'Authorization': f'Bearer {self.auth.access_token()}'}
|
||||||
response.raise_for_status()
|
response = self.http.request(method, path, headers=headers, **kwargs)
|
||||||
body = response.json().get('retorno', {})
|
if response.status_code == 429:
|
||||||
if body.get('status') != 'OK':
|
raise TinyError('Tiny rate limit reached; the outbox will retry')
|
||||||
errors = body.get('erros') or body.get('registros') or []
|
if response.status_code >= 400:
|
||||||
# "No records" is how the search reports an empty result.
|
raise TinyError(f'{method} {path}: {response.status_code} {response.text[:300]}')
|
||||||
if str(body.get('codigo_erro')) == '20':
|
return response.json() if response.content else {}
|
||||||
return {'pedidos': []}
|
|
||||||
raise TinyError(f"{method}: {body.get('codigo_erro')} {json.dumps(errors, ensure_ascii=False)[:300]}")
|
|
||||||
return body
|
|
||||||
|
|
||||||
def find(self, number):
|
def contact(self, order):
|
||||||
found = self.call('pedidos.pesquisa', numeroEcommerce=ecommerce_number(number))
|
cnpj = order['customer']['cnpj']
|
||||||
for entry in found.get('pedidos') or []:
|
found = self.request('GET', '/contatos', params={'cpfCnpj': cnpj, 'limit': 5})
|
||||||
pedido = entry.get('pedido', entry)
|
for entry in found.get('itens') or []:
|
||||||
if str(pedido.get('numero_ecommerce')) == ecommerce_number(number):
|
if ''.join(ch for ch in str(entry.get('cpfCnpj') or '') if ch.isdigit()) == cnpj:
|
||||||
return pedido
|
return entry['id']
|
||||||
|
return self.request('POST', '/contatos', json=contact_payload(order))['id']
|
||||||
|
|
||||||
|
def find(self, payload):
|
||||||
|
"""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()
|
||||||
|
found = self.request('GET', '/pedidos', params={'cpfCnpj': payload['order']['customer']['cnpj'],
|
||||||
|
'dataInicial': since, 'limit': 100})
|
||||||
|
for entry in found.get('itens') or []:
|
||||||
|
detail = self.request('GET', f"/pedidos/{entry['id']}")
|
||||||
|
if detail.get('numeroOrdemCompra') == wanted:
|
||||||
|
return detail
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def deliver(self, event_key, payload):
|
def deliver(self, event_key, payload):
|
||||||
@@ -106,13 +266,11 @@ class TinyOrders:
|
|||||||
# Production progress is not written to Tiny; only the sale is.
|
# Production progress is not written to Tiny; only the sale is.
|
||||||
return {'provider': 'tiny', 'event_key': event_key, 'status': 'not-applicable',
|
return {'provider': 'tiny', 'event_key': event_key, 'status': 'not-applicable',
|
||||||
'event': payload.get('event')}
|
'event': payload.get('event')}
|
||||||
existing = self.find(payload['number'])
|
existing = self.find(payload)
|
||||||
if existing:
|
if existing:
|
||||||
return {'provider': 'tiny', 'event_key': event_key, 'status': 'already-created',
|
return {'provider': 'tiny', 'event_key': event_key, 'status': 'already-created',
|
||||||
'tiny_id': str(existing.get('id')), 'tiny_number': str(existing.get('numero'))}
|
'tiny_id': str(existing.get('id')), 'tiny_number': str(existing.get('numeroPedido'))}
|
||||||
created = self.call('pedido.incluir', pedido=json.dumps(order_payload(payload), ensure_ascii=False))
|
contact_id = self.contact(payload['order'])
|
||||||
record = (created.get('registros') or [{}])[0].get('registro', {})
|
created = self.request('POST', '/pedidos', json=order_payload(payload, contact_id, self.today))
|
||||||
if record.get('status') != 'OK':
|
|
||||||
raise TinyError(f"pedido.incluir: {json.dumps(record, ensure_ascii=False)[:300]}")
|
|
||||||
return {'provider': 'tiny', 'event_key': event_key, 'status': 'created',
|
return {'provider': 'tiny', 'event_key': event_key, 'status': 'created',
|
||||||
'tiny_id': str(record.get('id')), 'tiny_number': str(record.get('numero'))}
|
'tiny_id': str(created.get('id')), 'tiny_number': str(created.get('numeroPedido'))}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ if os.environ.get('TINY_ADAPTER') == 'tiny':
|
|||||||
adapters['tiny'] = TinyOrders()
|
adapters['tiny'] = TinyOrders()
|
||||||
last_tick = 0.0
|
last_tick = 0.0
|
||||||
last_cleanup = 0.0
|
last_cleanup = 0.0
|
||||||
|
last_tiny_keepalive = 0.0
|
||||||
storage = LocalS3Storage()
|
storage = LocalS3Storage()
|
||||||
scan_thread = None
|
scan_thread = None
|
||||||
render_thread = None
|
render_thread = None
|
||||||
@@ -52,6 +53,27 @@ def tick():
|
|||||||
c.execute("UPDATE dtf_local.outbox SET attempts=attempts+1, last_error=%s, available_at=now() + %s * interval '1 second' WHERE id=%s", (str(exc),delay,job['id']))
|
c.execute("UPDATE dtf_local.outbox SET attempts=attempts+1, last_error=%s, available_at=now() + %s * interval '1 second' WHERE id=%s", (str(exc),delay,job['id']))
|
||||||
last_tick = time.monotonic()
|
last_tick = time.monotonic()
|
||||||
|
|
||||||
|
def tiny_keepalive():
|
||||||
|
"""Keep a connected Tiny authorised even while no orders are sent.
|
||||||
|
|
||||||
|
The refresh token expires unless it is used; access_token() refreshes (and
|
||||||
|
rotates) only when the access token is about to expire, so asking every few
|
||||||
|
minutes renews the connection roughly once per access-token lifetime.
|
||||||
|
"""
|
||||||
|
global last_tiny_keepalive
|
||||||
|
if time.monotonic()-last_tiny_keepalive < 600:
|
||||||
|
return
|
||||||
|
last_tiny_keepalive = time.monotonic()
|
||||||
|
from . import tiny
|
||||||
|
if not tiny.configured():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
tiny.TinyAuth().access_token()
|
||||||
|
except tiny.TinyNotConnected:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
logging.exception('Tiny connection refresh failed')
|
||||||
|
|
||||||
def loop():
|
def loop():
|
||||||
global last_cleanup
|
global last_cleanup
|
||||||
while True:
|
while True:
|
||||||
@@ -60,6 +82,7 @@ def loop():
|
|||||||
if time.monotonic()-last_cleanup > 60:
|
if time.monotonic()-last_cleanup > 60:
|
||||||
cleanup()
|
cleanup()
|
||||||
last_cleanup = time.monotonic()
|
last_cleanup = time.monotonic()
|
||||||
|
tiny_keepalive()
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.exception('Local worker tick failed')
|
logging.exception('Local worker tick failed')
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
|
|||||||
@@ -37,8 +37,14 @@ x-app: &app
|
|||||||
MP_NOTIFICATION_URL: ${MP_NOTIFICATION_URL:-}
|
MP_NOTIFICATION_URL: ${MP_NOTIFICATION_URL:-}
|
||||||
FREIGHT_ADAPTER: fake
|
FREIGHT_ADAPTER: fake
|
||||||
TINY_ADAPTER: ${TINY_ADAPTER:-fake}
|
TINY_ADAPTER: ${TINY_ADAPTER:-fake}
|
||||||
TINY_TOKEN: ${TINY_TOKEN:-}
|
TINY_CLIENT_ID: ${TINY_CLIENT_ID:-}
|
||||||
TINY_TAG: ${TINY_TAG:-}
|
TINY_CLIENT_SECRET: ${TINY_CLIENT_SECRET:-}
|
||||||
|
TINY_REDIRECT_URI: ${TINY_REDIRECT_URI:-}
|
||||||
|
TINY_PRODUCT_TEXTIL_FOLHA: ${TINY_PRODUCT_TEXTIL_FOLHA:-}
|
||||||
|
TINY_PRODUCT_TEXTIL_AVULSA: ${TINY_PRODUCT_TEXTIL_AVULSA:-}
|
||||||
|
TINY_PRODUCT_UV_FOLHA: ${TINY_PRODUCT_UV_FOLHA:-}
|
||||||
|
TINY_PRODUCT_UV_AVULSA: ${TINY_PRODUCT_UV_AVULSA:-}
|
||||||
|
TINY_ECOMMERCE_ID: ${TINY_ECOMMERCE_ID:-}
|
||||||
WHATSAPP_ADAPTER: fake
|
WHATSAPP_ADAPTER: fake
|
||||||
STORAGE_ADAPTER: s3-local
|
STORAGE_ADAPTER: s3-local
|
||||||
MOCK_FREIGHT_CENTS: ${MOCK_FREIGHT_CENTS:-1500}
|
MOCK_FREIGHT_CENTS: ${MOCK_FREIGHT_CENTS:-1500}
|
||||||
|
|||||||
16
compose.providers.yaml
Normal file
16
compose.providers.yaml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Provider sandbox testing only: gives the API and worker a route to the
|
||||||
|
# internet so they can reach Mercado Pago and Tiny. The default local stack
|
||||||
|
# keeps them on an internal network with no external route, which is what
|
||||||
|
# every other local run should keep doing.
|
||||||
|
#
|
||||||
|
# docker compose -f compose.local.yaml -f compose.providers.yaml up -d --wait
|
||||||
|
#
|
||||||
|
# Credentials go in .env (see .env.example); never production credentials.
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
networks: [local, provider-egress]
|
||||||
|
worker:
|
||||||
|
networks: [local, provider-egress]
|
||||||
|
|
||||||
|
networks:
|
||||||
|
provider-egress: {}
|
||||||
@@ -29,6 +29,14 @@ OPERATOR_EMAIL=TBD
|
|||||||
PAYMENT_ADAPTER=TBD
|
PAYMENT_ADAPTER=TBD
|
||||||
FREIGHT_ADAPTER=TBD
|
FREIGHT_ADAPTER=TBD
|
||||||
TINY_ADAPTER=TBD
|
TINY_ADAPTER=TBD
|
||||||
|
# Tiny API v3 application (Configurações > Geral > Aplicativos in Tiny). The
|
||||||
|
# redirect URL registered there must equal TINY_REDIRECT_URI.
|
||||||
|
TINY_CLIENT_ID=TBD
|
||||||
|
TINY_REDIRECT_URI=https://<KANBAN_DOMAIN>/api/operator/tiny/callback
|
||||||
|
TINY_PRODUCT_TEXTIL_FOLHA=TBD
|
||||||
|
TINY_PRODUCT_TEXTIL_AVULSA=TBD
|
||||||
|
TINY_PRODUCT_UV_FOLHA=TBD
|
||||||
|
TINY_PRODUCT_UV_AVULSA=TBD
|
||||||
WHATSAPP_ADAPTER=TBD
|
WHATSAPP_ADAPTER=TBD
|
||||||
STORAGE_QUOTA_BYTES=TBD
|
STORAGE_QUOTA_BYTES=TBD
|
||||||
OWNER_UPLOAD_QUOTA_BYTES=TBD
|
OWNER_UPLOAD_QUOTA_BYTES=TBD
|
||||||
|
|||||||
@@ -25,7 +25,18 @@ x-app-environment: &app-environment
|
|||||||
# when the provider is configured, never to a value anyone could guess.
|
# when the provider is configured, never to a value anyone could guess.
|
||||||
PAYMENT_WEBHOOK_SECRET: ${PAYMENT_WEBHOOK_SECRET:-}
|
PAYMENT_WEBHOOK_SECRET: ${PAYMENT_WEBHOOK_SECRET:-}
|
||||||
FREIGHT_ADAPTER: fake
|
FREIGHT_ADAPTER: fake
|
||||||
|
# Order creation in Tiny stays off until it has been tested against the
|
||||||
|
# client's account (Tiny has no sandbox). The application credentials can be
|
||||||
|
# set now: they let an operator connect Tiny from the Kanban, and the worker
|
||||||
|
# keeps that connection alive. Callback: https://<KANBAN_DOMAIN>/api/operator/tiny/callback
|
||||||
TINY_ADAPTER: fake
|
TINY_ADAPTER: fake
|
||||||
|
TINY_CLIENT_ID: ${TINY_CLIENT_ID:-}
|
||||||
|
TINY_CLIENT_SECRET: ${TINY_CLIENT_SECRET:-}
|
||||||
|
TINY_REDIRECT_URI: ${TINY_REDIRECT_URI:-}
|
||||||
|
TINY_PRODUCT_TEXTIL_FOLHA: ${TINY_PRODUCT_TEXTIL_FOLHA:-}
|
||||||
|
TINY_PRODUCT_TEXTIL_AVULSA: ${TINY_PRODUCT_TEXTIL_AVULSA:-}
|
||||||
|
TINY_PRODUCT_UV_FOLHA: ${TINY_PRODUCT_UV_FOLHA:-}
|
||||||
|
TINY_PRODUCT_UV_AVULSA: ${TINY_PRODUCT_UV_AVULSA:-}
|
||||||
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}
|
||||||
|
|||||||
@@ -255,7 +255,15 @@ draws each page and checks where every quadrant of the artwork lands.
|
|||||||
|
|
||||||
`app/mercadopago.py` and `app/tiny.py` follow the providers' public API
|
`app/mercadopago.py` and `app/tiny.py` follow the providers' public API
|
||||||
documentation and pass their unit suites against a fake transport. They are not
|
documentation and pass their unit suites against a fake transport. They are not
|
||||||
verified integrations until they pass with the client's sandbox accounts.
|
verified integrations until they pass with the client's accounts.
|
||||||
|
|
||||||
|
The default local stack gives the API and worker no route to the internet, so
|
||||||
|
provider testing adds `compose.providers.yaml`, which does:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f compose.local.yaml -f compose.providers.yaml up -d --wait
|
||||||
|
```
|
||||||
|
|
||||||
Put only **test** credentials in `.env`:
|
Put only **test** credentials in `.env`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -263,11 +271,38 @@ PAYMENT_ADAPTER=mercadopago
|
|||||||
MP_ACCESS_TOKEN=TEST-...
|
MP_ACCESS_TOKEN=TEST-...
|
||||||
MP_WEBHOOK_SECRET=... # "Assinatura secreta" in the webhook settings
|
MP_WEBHOOK_SECRET=... # "Assinatura secreta" in the webhook settings
|
||||||
MP_NOTIFICATION_URL=https://<public tunnel>/api/payments/webhook
|
MP_NOTIFICATION_URL=https://<public tunnel>/api/payments/webhook
|
||||||
TINY_ADAPTER=tiny
|
|
||||||
TINY_TOKEN=...
|
|
||||||
TINY_TAG=Site DTF # optional marker on created orders
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Tiny (API v3)
|
||||||
|
|
||||||
|
Tiny uses OAuth2. In the client's Tiny (Construa plan or above, with the
|
||||||
|
"Gestão de Aplicativos" extension): **Configurações → Geral → Aplicativos →
|
||||||
|
+ novo aplicativo**, with the redirect URL set to this system's callback. That
|
||||||
|
gives a client ID and secret:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TINY_CLIENT_ID=...
|
||||||
|
TINY_CLIENT_SECRET=...
|
||||||
|
TINY_REDIRECT_URI=http://localhost:8081/api/operator/tiny/callback # exactly as registered in Tiny
|
||||||
|
TINY_PRODUCT_TEXTIL_FOLHA=... # Tiny product id for each Site product
|
||||||
|
TINY_PRODUCT_TEXTIL_AVULSA=...
|
||||||
|
TINY_PRODUCT_UV_FOLHA=...
|
||||||
|
TINY_PRODUCT_UV_AVULSA=...
|
||||||
|
```
|
||||||
|
|
||||||
|
With the client ID and secret set, the Kanban header shows **Conectar Tiny**.
|
||||||
|
Someone with a Tiny login approves access once; the tokens are stored in the
|
||||||
|
database (the refresh token rotates on every use) and the worker keeps the
|
||||||
|
connection alive. Only then set `TINY_ADAPTER=tiny`, which starts creating an
|
||||||
|
order in Tiny for every paid order: find or create the customer's contact by
|
||||||
|
CNPJ, then `POST /pedidos` with `numeroOrdemCompra = DTF-<order number>`. A
|
||||||
|
retry searches the customer's recent orders for that number first, so it does
|
||||||
|
not create a second one. **Tiny has no sandbox**: every test order is real, so
|
||||||
|
agree the test with the client and cancel the test orders afterwards.
|
||||||
|
|
||||||
|
`tests.tiny_oauth_test` checks the connection flow against the real database
|
||||||
|
with a fake token server; it saves and restores any existing connection.
|
||||||
|
|
||||||
With Mercado Pago selected, an approved quote shows **Pagar com PIX** on the
|
With Mercado Pago selected, an approved quote shows **Pagar com PIX** on the
|
||||||
Site instead of the local test button. The order is created only by the signed
|
Site instead of the local test button. The order is created only by the signed
|
||||||
notification, after the payment is fetched from the Mercado Pago API and its
|
notification, after the payment is fetched from the Mercado Pago API and its
|
||||||
|
|||||||
@@ -237,13 +237,23 @@ From the report already sent. These are dated promises, not backlog.
|
|||||||
packaging weight/dimensions per length, subsidy policy.
|
packaging weight/dimensions per length, subsidy policy.
|
||||||
- `[~]` 1.3 — Idempotent Tiny/Olist order creation with order-number traceability.
|
- `[~]` 1.3 — Idempotent Tiny/Olist order creation with order-number traceability.
|
||||||
Confirm endpoints, tag behaviour and rate limits first.
|
Confirm endpoints, tag behaviour and rate limits first.
|
||||||
**Groundwork (2026-09-24):** `app/tiny.py` (API 2.0) maps the approved
|
**Groundwork (2026-09-24), API v3 by decision:** OAuth2 against Tiny's
|
||||||
snapshot to `pedido.incluir` with `numero_pedido_ecommerce = DTF-<number>`,
|
Keycloak. An operator starts the connection from the Kanban; the callback is
|
||||||
searches for that number before creating, and treats Tiny's in-body errors
|
authorised by a single-use state (the operator cookie is SameSite=Strict and
|
||||||
as failures so the outbox retries. Pickup keeps the existing `forma_envio X`
|
does not survive Tiny's cross-site redirect). Tokens live in
|
||||||
/ DropStar convention. Selected with `TINY_ADAPTER=tiny`; tested against a
|
`provider_tokens`; the refresh token rotates under a row lock and the worker
|
||||||
fake transport only. Product codes (`TINY_SKU_<MODE>`), the tag, API version
|
keeps the connection alive. Orders: contact found by CNPJ or created, then
|
||||||
(2.0 vs 3.0) and rate limits must be confirmed on the client's account.
|
`POST /pedidos` with product ids from `TINY_PRODUCT_TEXTIL_FOLHA` / `_TEXTIL_AVULSA` / `_UV_FOLHA`
|
||||||
|
/ `_UV_AVULSA` (not `_<MODE>`: a name ending in `_FILE` is read as a secret
|
||||||
|
file path by `app/core/secrets.py`) and
|
||||||
|
`numeroOrdemCompra = DTF-<number>`; a retry searches the customer's last
|
||||||
|
seven days of orders for that number first. Production passes the app
|
||||||
|
credentials through but keeps `TINY_ADAPTER: fake`. Tested against fake
|
||||||
|
transports (`tests.test_tiny`) and, for OAuth, the real database
|
||||||
|
(`tests.tiny_oauth_test`). Tiny has no sandbox: the first real test creates
|
||||||
|
real orders. Still to confirm on the client's account: plan (Construa+),
|
||||||
|
product ids, token lifetimes, whether pickup needs a transportador, and
|
||||||
|
rate limits.
|
||||||
- `[~]` 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
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
"""The Tiny adapter against a fake HTTP transport: payload and idempotency.
|
"""The Tiny v3 adapter against a fake HTTP transport: payload and idempotency.
|
||||||
|
|
||||||
This proves the documented contract only; the client's account must still
|
This proves the documented contract only; the client's account must still
|
||||||
confirm endpoints, product codes and tags. Runs where httpx is installed.
|
confirm products, contacts and order fields. Runs where httpx is installed.
|
||||||
|
The OAuth connection against the real database is tests/tiny_oauth_test.py.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import unittest
|
import unittest
|
||||||
from urllib.parse import parse_qs
|
from datetime import date
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from app.tiny import TinyError, TinyOrders, order_payload
|
from app.tiny import TinyError, TinyOrders, contact_payload, order_payload
|
||||||
|
|
||||||
PAID = {'order_id': 'b6f1c0de-0000-4000-8000-000000000001', 'number': 42, 'event': 'payment_approved',
|
PAID = {'order_id': 'b6f1c0de-0000-4000-8000-000000000001', 'number': 42, 'event': 'payment_approved',
|
||||||
'order': {'customer': {'cnpj': '11222333000181', 'zap': '16999999999', 'mail': 'loja@example.test'},
|
'order': {'customer': {'cnpj': '11222333000181', 'zap': '16999999999', 'mail': 'loja@example.test'},
|
||||||
@@ -19,60 +22,94 @@ PAID = {'order_id': 'b6f1c0de-0000-4000-8000-000000000001', 'number': 42, 'event
|
|||||||
'complement': '', 'district': 'Centro', 'city': 'Franca',
|
'complement': '', 'district': 'Centro', 'city': 'Franca',
|
||||||
'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',
|
||||||
|
'TINY_PRODUCT_UV_FOLHA': '103', 'TINY_PRODUCT_UV_AVULSA': '104'}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeAuth:
|
||||||
|
def access_token(self):
|
||||||
|
return 'access-1'
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PRODUCTS)
|
||||||
class TinyTests(unittest.TestCase):
|
class TinyTests(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.orders = {}
|
self.contacts = []
|
||||||
|
self.orders = []
|
||||||
self.calls = []
|
self.calls = []
|
||||||
|
|
||||||
def handler(request):
|
def handler(request):
|
||||||
form = {k: v[0] for k, v in parse_qs(request.content.decode()).items()}
|
path = request.url.path.removeprefix('/public-api/v3')
|
||||||
method = request.url.path.rsplit('/', 1)[-1].removesuffix('.php')
|
self.calls.append((request.method, path))
|
||||||
self.calls.append((method, form))
|
assert request.headers['authorization'] == 'Bearer access-1'
|
||||||
if method == 'pedidos.pesquisa':
|
if request.method == 'GET' and path == '/contatos':
|
||||||
found = self.orders.get(form['numeroEcommerce'])
|
cnpj = request.url.params['cpfCnpj']
|
||||||
if not found:
|
return httpx.Response(200, json={'itens': [c for c in self.contacts if c['cpfCnpj'] == cnpj]})
|
||||||
return httpx.Response(200, json={'retorno': {'status': 'Erro', 'codigo_erro': 20}})
|
if request.method == 'POST' and path == '/contatos':
|
||||||
return httpx.Response(200, json={'retorno': {'status': 'OK', 'pedidos': [{'pedido': found}]}})
|
contact = {**json.loads(request.content), 'id': 500 + len(self.contacts)}
|
||||||
pedido = json.loads(form['pedido'])['pedido']
|
self.contacts.append(contact)
|
||||||
record = {'id': 9000 + len(self.orders), 'numero': 100 + len(self.orders), 'status': 'OK'}
|
return httpx.Response(200, json={'id': contact['id']})
|
||||||
self.orders[pedido['numero_pedido_ecommerce']] = {**record, 'numero_ecommerce': pedido['numero_pedido_ecommerce']}
|
if request.method == 'GET' and path == '/pedidos':
|
||||||
return httpx.Response(200, json={'retorno': {'status': 'OK', 'registros': [{'registro': {'sequencia': 1, **record}}]}})
|
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),
|
||||||
|
'numeroPedido': str(100 + len(self.orders))}
|
||||||
|
self.orders.append(order)
|
||||||
|
return httpx.Response(200, json={'id': order['id'], 'numeroPedido': order['numeroPedido']})
|
||||||
|
return httpx.Response(404)
|
||||||
|
|
||||||
self.tiny = TinyOrders('test-token', transport=httpx.MockTransport(handler))
|
self.tiny = TinyOrders(auth=FakeAuth(), transport=httpx.MockTransport(handler),
|
||||||
|
today=date(2026, 9, 24))
|
||||||
|
|
||||||
def test_payload_carries_customer_address_items_and_freight(self):
|
def test_payload_carries_contact_products_address_and_freight(self):
|
||||||
pedido = order_payload(PAID)['pedido']
|
pedido = order_payload(PAID, 777, date(2026, 9, 24))
|
||||||
self.assertEqual(pedido['numero_pedido_ecommerce'], 'DTF-42')
|
self.assertEqual((pedido['idContato'], pedido['numeroOrdemCompra'], pedido['data']),
|
||||||
self.assertEqual((pedido['cliente']['cpf_cnpj'], pedido['cliente']['cep'], pedido['cliente']['uf']),
|
(777, 'DTF-42', '2026-09-24'))
|
||||||
('11222333000181', '14400000', 'SP'))
|
item = pedido['itens'][0]
|
||||||
item = pedido['itens'][0]['item']
|
self.assertEqual((item['produto'], item['quantidade'], item['valorUnitario']),
|
||||||
self.assertEqual((item['quantidade'], item['valor_unitario'], item['unidade']), ('2.8', '24.90', 'M'))
|
({'id': 102}, 2.8, 24.9))
|
||||||
self.assertEqual(pedido['valor_frete'], '15.00')
|
self.assertEqual(pedido['valorFrete'], 15.0)
|
||||||
|
self.assertEqual((pedido['enderecoEntrega']['cep'], pedido['enderecoEntrega']['enderecoNro'],
|
||||||
|
pedido['enderecoEntrega']['nomeDestinatario']), ('14400000', '10', 'Loja Teste'))
|
||||||
|
contact = contact_payload(PAID['order'])
|
||||||
|
self.assertEqual((contact['tipoPessoa'], contact['cpfCnpj'], contact['endereco']['uf']),
|
||||||
|
('J', '11222333000181', 'SP'))
|
||||||
pickup = order_payload({**PAID, 'order': {**PAID['order'], 'destination': None,
|
pickup = order_payload({**PAID, 'order': {**PAID['order'], 'destination': None,
|
||||||
'freight': {'service': 'pickup', 'total_cents': 0}}})['pedido']
|
'freight': {'service': 'pickup', 'total_cents': 0}}}, 1)
|
||||||
self.assertEqual((pickup['forma_envio'], pickup['frete_por_conta']), ('X', 'R'))
|
self.assertNotIn('enderecoEntrega', pickup)
|
||||||
self.assertNotIn('endereco', pickup['cliente'])
|
self.assertIn('retirada', pickup['observacoes'])
|
||||||
|
|
||||||
def test_second_delivery_finds_the_first_order(self):
|
def test_second_delivery_finds_the_first_order(self):
|
||||||
first = self.tiny.deliver('k1', PAID)
|
first = self.tiny.deliver('k1', PAID)
|
||||||
second = self.tiny.deliver('k1', PAID)
|
second = self.tiny.deliver('k1', PAID)
|
||||||
self.assertEqual(first['status'], 'created')
|
self.assertEqual((first['status'], second['status']), ('created', 'already-created'))
|
||||||
self.assertEqual(second['status'], 'already-created')
|
|
||||||
self.assertEqual(first['tiny_id'], second['tiny_id'])
|
self.assertEqual(first['tiny_id'], second['tiny_id'])
|
||||||
self.assertEqual([m for m, _ in self.calls].count('pedido.incluir'), 1)
|
self.assertEqual(self.calls.count(('POST', '/pedidos')), 1)
|
||||||
self.assertEqual(self.calls[0][1]['token'], 'test-token')
|
self.assertEqual(self.calls.count(('POST', '/contatos')), 1)
|
||||||
|
|
||||||
|
def test_existing_contact_is_reused(self):
|
||||||
|
self.contacts.append({'id': 321, 'cpfCnpj': '11222333000181'})
|
||||||
|
self.tiny.deliver('k1', PAID)
|
||||||
|
self.assertNotIn(('POST', '/contatos'), self.calls)
|
||||||
|
self.assertEqual(self.orders[0]['idContato'], 321)
|
||||||
|
|
||||||
def test_production_events_are_not_sent(self):
|
def test_production_events_are_not_sent(self):
|
||||||
self.assertEqual(self.tiny.deliver('k2', {**PAID, 'event': 'ready'})['status'], 'not-applicable')
|
self.assertEqual(self.tiny.deliver('k2', {**PAID, 'event': 'ready'})['status'], 'not-applicable')
|
||||||
self.assertEqual(self.calls, [])
|
self.assertEqual(self.calls, [])
|
||||||
|
|
||||||
def test_errors_reported_in_the_body_are_failures(self):
|
def test_missing_product_mapping_fails_rather_than_guessing(self):
|
||||||
tiny = TinyOrders('t', transport=httpx.MockTransport(lambda r: httpx.Response(
|
with mock.patch.dict(os.environ, {'TINY_PRODUCT_TEXTIL_AVULSA': ''}):
|
||||||
200, json={'retorno': {'status': 'Erro', 'codigo_erro': 6, 'erros': [{'erro': 'API Bloqueada'}]}})))
|
with self.assertRaises(TinyError):
|
||||||
with self.assertRaises(TinyError):
|
self.tiny.deliver('k3', PAID)
|
||||||
tiny.deliver('k3', PAID)
|
self.assertNotIn(('POST', '/pedidos'), self.calls)
|
||||||
|
|
||||||
|
def test_rate_limit_is_a_retryable_failure(self):
|
||||||
|
tiny = TinyOrders(auth=FakeAuth(), transport=httpx.MockTransport(lambda r: httpx.Response(429)))
|
||||||
|
with self.assertRaisesRegex(TinyError, 'rate limit'):
|
||||||
|
tiny.deliver('k4', PAID)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
105
tests/tiny_oauth_test.py
Normal file
105
tests/tiny_oauth_test.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
"""The Tiny OAuth connection against the real database, with a fake token server.
|
||||||
|
|
||||||
|
Run inside the API container: python -m tests.tiny_oauth_test
|
||||||
|
It saves any existing Tiny connection first and restores it afterwards.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.core.db import connect
|
||||||
|
from app.tiny import TinyAuth, TinyError, TinyNotConnected
|
||||||
|
|
||||||
|
os.environ.update(TINY_CLIENT_ID='test-client', TINY_CLIENT_SECRET='test-secret',
|
||||||
|
TINY_REDIRECT_URI='http://localhost:8081/api/operator/tiny/callback')
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
with connect() as c:
|
||||||
|
saved = c.execute("SELECT * FROM dtf_local.provider_tokens WHERE provider='tiny'").fetchone()
|
||||||
|
c.execute("DELETE FROM dtf_local.provider_tokens WHERE provider='tiny'")
|
||||||
|
try:
|
||||||
|
exercise()
|
||||||
|
finally:
|
||||||
|
with connect() as c:
|
||||||
|
c.execute("DELETE FROM dtf_local.provider_tokens WHERE provider='tiny'")
|
||||||
|
if saved:
|
||||||
|
columns = ','.join(saved)
|
||||||
|
c.execute(f'INSERT INTO dtf_local.provider_tokens({columns}) VALUES({",".join(["%s"] * len(saved))})',
|
||||||
|
tuple(saved.values()))
|
||||||
|
|
||||||
|
|
||||||
|
def exercise():
|
||||||
|
issued = []
|
||||||
|
|
||||||
|
def token_server(request):
|
||||||
|
form = {k: v[0] for k, v in parse_qs(request.content.decode()).items()}
|
||||||
|
assert (form['client_id'], form['client_secret']) == ('test-client', 'test-secret')
|
||||||
|
if form['grant_type'] == 'authorization_code':
|
||||||
|
assert form['code'] == 'good-code' and form['redirect_uri'].endswith('/tiny/callback')
|
||||||
|
elif form['grant_type'] == 'refresh_token':
|
||||||
|
if form['refresh_token'] != issued[-1]:
|
||||||
|
return httpx.Response(400, json={'error': 'invalid_grant'})
|
||||||
|
n = len(issued) + 1
|
||||||
|
issued.append(f'refresh-{n}')
|
||||||
|
return httpx.Response(200, json={'access_token': f'access-{n}', 'expires_in': 14400,
|
||||||
|
'refresh_token': f'refresh-{n}', 'refresh_expires_in': 86400})
|
||||||
|
|
||||||
|
auth = TinyAuth(transport=httpx.MockTransport(token_server))
|
||||||
|
assert auth.status() == {'connected': False}
|
||||||
|
try:
|
||||||
|
auth.access_token()
|
||||||
|
raise AssertionError('an unconnected Tiny must not yield a token')
|
||||||
|
except TinyNotConnected:
|
||||||
|
pass
|
||||||
|
|
||||||
|
url = urlparse(auth.authorize_url('operator@example.test'))
|
||||||
|
query = {k: v[0] for k, v in parse_qs(url.query).items()}
|
||||||
|
assert url.netloc == 'accounts.tiny.com.br' and query['client_id'] == 'test-client'
|
||||||
|
assert query['redirect_uri'].endswith('/api/operator/tiny/callback') and len(query['state']) > 30
|
||||||
|
try:
|
||||||
|
auth.complete('good-code', 'forged-state')
|
||||||
|
raise AssertionError('a state no operator created must be refused')
|
||||||
|
except TinyError:
|
||||||
|
pass
|
||||||
|
assert auth.complete('good-code', query['state']) == 'operator@example.test'
|
||||||
|
try:
|
||||||
|
auth.complete('good-code', query['state'])
|
||||||
|
raise AssertionError('a state must be single-use')
|
||||||
|
except TinyError:
|
||||||
|
pass
|
||||||
|
status = auth.status()
|
||||||
|
assert status['connected'] and status['connected_by'] == 'operator@example.test'
|
||||||
|
assert auth.access_token() == 'access-1'
|
||||||
|
print('PASS: operator-started state is required, single-use, and stores the connection')
|
||||||
|
|
||||||
|
# An access token about to expire is refreshed, and the refresh token rotates.
|
||||||
|
with connect() as c:
|
||||||
|
c.execute("UPDATE dtf_local.provider_tokens SET access_expires_at=now() WHERE provider='tiny'")
|
||||||
|
assert auth.access_token() == 'access-2'
|
||||||
|
with connect() as c:
|
||||||
|
row = c.execute("SELECT refresh_token,connected_by FROM dtf_local.provider_tokens WHERE provider='tiny'").fetchone()
|
||||||
|
assert row == {'refresh_token': 'refresh-2', 'connected_by': 'operator@example.test'}
|
||||||
|
assert auth.access_token() == 'access-2'
|
||||||
|
print('PASS: expiring access token refreshed once, refresh token rotated and stored')
|
||||||
|
|
||||||
|
# A refused refresh (revoked in Tiny) asks for a new connection, not a retry loop.
|
||||||
|
with connect() as c:
|
||||||
|
c.execute("""UPDATE dtf_local.provider_tokens SET access_expires_at=now(), refresh_token='revoked'
|
||||||
|
WHERE provider='tiny'""")
|
||||||
|
try:
|
||||||
|
auth.access_token()
|
||||||
|
raise AssertionError('a refused refresh must report the connection as lost')
|
||||||
|
except TinyNotConnected:
|
||||||
|
pass
|
||||||
|
with connect() as c:
|
||||||
|
c.execute("UPDATE dtf_local.provider_tokens SET refresh_expires_at=%s WHERE provider='tiny'",
|
||||||
|
(datetime.now(timezone.utc) - timedelta(seconds=1),))
|
||||||
|
assert not auth.status()['connected']
|
||||||
|
print('PASS: revoked or expired connections report that Tiny must be connected again')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
run()
|
||||||
@@ -10,12 +10,12 @@ button,input,select{font:inherit;border:1px solid #56616d;border-radius:5px;padd
|
|||||||
button{cursor:pointer}button:hover{border-color:var(--ciano)}button:disabled{opacity:.5}input[type=number]{width:100px}
|
button{cursor:pointer}button:hover{border-color:var(--ciano)}button:disabled{opacity:.5}input[type=number]{width:100px}
|
||||||
label{display:inline-flex;gap:8px;align-items:center;margin:5px}#kan{display:grid;grid-template-columns:repeat(6,minmax(220px,1fr));gap:10px;overflow-x:auto;padding-bottom:16px}
|
label{display:inline-flex;gap:8px;align-items:center;margin:5px}#kan{display:grid;grid-template-columns:repeat(6,minmax(220px,1fr));gap:10px;overflow-x:auto;padding-bottom:16px}
|
||||||
.col{background:var(--card);border:1px solid var(--linha);border-radius:9px;min-height:250px;padding:10px}.col h2{font-size:14px;border-bottom:2px solid var(--cc);padding-bottom:10px}.col.alvo{border-color:var(--ciano)}
|
.col{background:var(--card);border:1px solid var(--linha);border-radius:9px;min-height:250px;padding:10px}.col h2{font-size:14px;border-bottom:2px solid var(--cc);padding-bottom:10px}.col.alvo{border-color:var(--ciano)}
|
||||||
.cd,.review{background:var(--card2);border:1px solid var(--linha);border-left:3px solid var(--cc,var(--ciano));border-radius:6px;padding:12px;margin-bottom:10px}.cd{cursor:grab}.cd p{overflow-wrap:anywhere}.meta{color:var(--fraco);font-size:12px}.actions{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}.error{color:#ffad83}.print{margin-top:8px;padding-top:8px;border-top:1px solid var(--linha)}.print button{margin-left:6px}#status{min-height:24px}details{margin:16px 0}pre{white-space:pre-wrap;overflow-wrap:anywhere}a{color:var(--ciano)}
|
.cd,.review{background:var(--card2);border:1px solid var(--linha);border-left:3px solid var(--cc,var(--ciano));border-radius:6px;padding:12px;margin-bottom:10px}.cd{cursor:grab}.cd p{overflow-wrap:anywhere}.meta{color:var(--fraco);font-size:12px}.actions{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}.error{color:#ffad83}.print{margin-top:8px;padding-top:8px;border-top:1px solid var(--linha)}.print button{margin-left:6px}#tiny{font-size:12px;color:var(--fraco);margin-right:8px}#tiny button{margin-left:6px}#status{min-height:24px}details{margin:16px 0}pre{white-space:pre-wrap;overflow-wrap:anywhere}a{color:var(--ciano)}
|
||||||
</style></head><body>
|
</style></head><body>
|
||||||
<header><h1>Kanban DTF</h1><button id="refresh">Atualizar</button><button id="logout">Sair</button></header>
|
<header><h1>Kanban DTF</h1><span id="tiny"></span><button id="refresh">Atualizar</button><button id="logout">Sair</button></header>
|
||||||
<div class="aviso">Confira a cotação manualmente. Em Arquivos de produção, envie e aprove os arquivos finais de todos os itens antes de colocar o pedido na fila. Não há validação automática de arte.</div>
|
<div class="aviso">Confira a cotação manualmente. Em Arquivos de produção, envie e aprove os arquivos finais de todos os itens antes de colocar o pedido na fila. Não há validação automática de arte.</div>
|
||||||
<form id="login"><label>E-mail <input id="email" type="email" autocomplete="username" required></label><label>Senha <input id="password" type="password" autocomplete="current-password" required></label><button>Entrar</button></form>
|
<form id="login"><label>E-mail <input id="email" type="email" autocomplete="username" required></label><label>Senha <input id="password" type="password" autocomplete="current-password" required></label><button>Entrar</button></form>
|
||||||
<p id="status" role="status"></p>
|
<p id="status" role="status"></p>
|
||||||
<section id="payments"></section><section id="reviews"></section><div id="kan"></div>
|
<section id="payments"></section><section id="reviews"></section><div id="kan"></div>
|
||||||
<details><summary>Eventos locais de integração</summary><pre id="events"></pre></details>
|
<details><summary>Eventos locais de integração</summary><pre id="events"></pre></details>
|
||||||
<script src="/upload.js"></script><script src="/kanban.js?v=print-files-1"></script></body></html>
|
<script src="/upload.js"></script><script src="/kanban.js?v=tiny-v3-1"></script></body></html>
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ async function load(){
|
|||||||
moreQuotes={pending:board.pending_total>board.quotes.filter(q=>!q.approved).length,
|
moreQuotes={pending:board.pending_total>board.quotes.filter(q=>!q.approved).length,
|
||||||
approved:board.approved_total>board.quotes.filter(q=>!!q.approved).length};
|
approved:board.approved_total>board.quotes.filter(q=>!!q.approved).length};
|
||||||
$('login').hidden=true;
|
$('login').hidden=true;
|
||||||
$('status').textContent='Atualizado às '+new Date().toLocaleTimeString();
|
$('status').textContent='Atualizado às '+new Date().toLocaleTimeString()+tinyNotice;
|
||||||
render();
|
render();
|
||||||
}catch(e){$('status').textContent=e.message;$('login').hidden=false;}
|
}catch(e){$('status').textContent=e.message;$('login').hidden=false;}
|
||||||
}
|
}
|
||||||
@@ -108,7 +108,24 @@ async function loadMoreQuotes(kind){
|
|||||||
moreQuotes[kind]=page.has_more;
|
moreQuotes[kind]=page.has_more;
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
// The one-time authorisation of this system in the client's Tiny. Shown only
|
||||||
|
// when the Tiny application is configured on the server.
|
||||||
|
function tinyStatus(){
|
||||||
|
const box=$('tiny');box.replaceChildren();
|
||||||
|
const tiny=board.tiny||{};
|
||||||
|
if(!tiny.configured)return;
|
||||||
|
box.append(node('span',tiny.connected
|
||||||
|
? 'Tiny conectado'+(tiny.orders_enabled?'':' · envio de pedidos desligado')
|
||||||
|
: 'Tiny não conectado'));
|
||||||
|
box.append(action(tiny.connected?'Reconectar Tiny':'Conectar Tiny',async()=>{
|
||||||
|
const result=await api('/tiny/connect',{});location.href=result.url;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
const tinyResult=new URLSearchParams(location.search).get('tiny');
|
||||||
|
const tinyNotice=tinyResult?(tinyResult==='connected'?' · Tiny conectado.':' · Não foi possível conectar o Tiny. Tente de novo.'):'';
|
||||||
|
if(tinyResult)history.replaceState(null,'',location.pathname);
|
||||||
function render(){
|
function render(){
|
||||||
|
tinyStatus();
|
||||||
paymentIssues();
|
paymentIssues();
|
||||||
$('reviews').replaceChildren();
|
$('reviews').replaceChildren();
|
||||||
if(board.pending_total || board.approved_total)
|
if(board.pending_total || board.approved_total)
|
||||||
|
|||||||
Reference in New Issue
Block a user