Compare commits
4 Commits
aec5b1d054
...
9bea187ea4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bea187ea4 | ||
|
|
bd56170248 | ||
|
|
60b7336b37 | ||
|
|
04e4cbc953 |
@@ -37,6 +37,11 @@ TINY_ADAPTER=fake
|
||||
# TINY_PRODUCT_UV_FOLHA=...
|
||||
# TINY_PRODUCT_UV_AVULSA=...
|
||||
# TINY_ADAPTER=tiny # only once connected and tested: creates real orders
|
||||
# Jadlog price quotes go in jadlog.env, not here (see app/jadlog_probe.py):
|
||||
# JADLOG_TOKEN=...
|
||||
# JADLOG_CNPJ=... # the "Usuário" Jadlog issued, the CNPJ that contracts freight
|
||||
# JADLOG_CONTA=... # conta corrente
|
||||
# JADLOG_CONTRATO= # only if Jadlog issued a contract number
|
||||
WHATSAPP_ADAPTER=fake
|
||||
STORAGE_ADAPTER=s3-local
|
||||
MOCK_FREIGHT_CENTS=1500
|
||||
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
# the generator's geometry. The provider suites use a fake transport: they
|
||||
# prove the documented contract, not the integration.
|
||||
- name: Print-file geometry and provider adapters
|
||||
run: $COMPOSE exec -T api python -m unittest tests.test_printfile tests.test_mercadopago tests.test_tiny -v
|
||||
run: $COMPOSE exec -T api python -m unittest tests.test_printfile tests.test_mercadopago tests.test_tiny tests.test_jadlog -v
|
||||
|
||||
- name: Runtime and retention regressions
|
||||
run: |
|
||||
|
||||
@@ -98,7 +98,10 @@ def orders(identity=Depends(owner)):
|
||||
def detail(oid: UUID, identity=Depends(owner)):
|
||||
with db.connect() as c:
|
||||
row = owned_order(c, oid, identity)
|
||||
history = c.execute('SELECT from_state,to_state,reason,created_at FROM dtf_local.movements WHERE order_id=%s ORDER BY id', (oid,)).fetchall()
|
||||
# A move back undoes an operator's mistake and its reason is internal;
|
||||
# only a correction's reason is written for the customer.
|
||||
history = c.execute('''SELECT from_state,to_state,CASE WHEN to_state='cor' THEN reason ELSE '' END AS reason,
|
||||
created_at FROM dtf_local.movements WHERE order_id=%s AND NOT back ORDER BY id''', (oid,)).fetchall()
|
||||
return {'id': row['id'], 'number': row['number'], 'state': row['state'], 'version': row['version'],
|
||||
'snapshot': row['snapshot'], 'history': history, 'files': file_rows(c,oid)}
|
||||
|
||||
|
||||
74
app/jadlog.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Jadlog freight quotes (Embarcador API, "Simulador de Frete").
|
||||
|
||||
Written from Jadlog's API manual v2.3 (August 2025) and exercised only
|
||||
against a fake HTTP transport until app.jadlog_probe has run on the client's
|
||||
account. A quote is read-only: it creates no shipment and costs nothing, so
|
||||
unlike Tiny it can be tried on the real account as often as needed.
|
||||
|
||||
The client's contract: origin CEP 14402-310, service .PACKAGE (modalidade 3),
|
||||
home delivery. Jadlog prices by weight in kg and expects the larger of the
|
||||
real and the cubed weight; the weight per metre of film is still to come from
|
||||
the client, so nothing here decides it.
|
||||
"""
|
||||
import os
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
import httpx
|
||||
|
||||
QUOTE_URL = 'https://www.jadlog.com.br/embarcador/api/frete/valor'
|
||||
PACKAGE = 3
|
||||
ORIGIN = '14402310'
|
||||
|
||||
|
||||
class JadlogError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def digits(value):
|
||||
return ''.join(ch for ch in str(value or '') if ch.isdigit())
|
||||
|
||||
|
||||
class JadlogQuotes:
|
||||
def __init__(self, token=None, cnpj=None, conta=None, contrato=None, origin=None,
|
||||
modalidade=None, transport=None):
|
||||
self.token = (token or os.environ.get('JADLOG_TOKEN', '')).strip()
|
||||
self.cnpj = digits(cnpj or os.environ.get('JADLOG_CNPJ'))
|
||||
self.conta = digits(conta if conta is not None else os.environ.get('JADLOG_CONTA'))
|
||||
# Only when Jadlog issued one; the manual says to send null otherwise.
|
||||
self.contrato = (contrato if contrato is not None else os.environ.get('JADLOG_CONTRATO', '')).strip() or None
|
||||
self.origin = digits(origin or os.environ.get('JADLOG_ORIGEM_CEP') or ORIGIN)
|
||||
self.modalidade = int(modalidade or os.environ.get('JADLOG_MODALIDADE') or PACKAGE)
|
||||
if not self.token or len(self.cnpj) != 14:
|
||||
raise RuntimeError('Jadlog needs JADLOG_TOKEN and a 14-digit JADLOG_CNPJ')
|
||||
self.http = httpx.Client(timeout=20, transport=transport)
|
||||
|
||||
def item(self, postal_code, weight_kg, declared_cents):
|
||||
return {'cepori': self.origin, 'cepdes': digits(postal_code), 'frap': 'N',
|
||||
'peso': float(weight_kg), 'cnpj': self.cnpj, 'conta': self.conta,
|
||||
'contrato': self.contrato, 'modalidade': self.modalidade,
|
||||
'tpentrega': 'D', 'tpseguro': 'N',
|
||||
'vldeclarado': declared_cents / 100, 'vlcoleta': 0}
|
||||
|
||||
def quote(self, postal_code, weight_kg, declared_cents):
|
||||
"""Price in centavos and delivery days for one package to one CEP."""
|
||||
response = self.http.post(QUOTE_URL, json={'frete': [self.item(postal_code, weight_kg, declared_cents)]},
|
||||
headers={'Authorization': self.token})
|
||||
if response.status_code == 401:
|
||||
raise JadlogError('Jadlog refused the token (HTTP 401)')
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError:
|
||||
raise JadlogError(f'HTTP {response.status_code}: the response is not JSON') from None
|
||||
items = data.get('frete') or []
|
||||
# The manual names the group "erro" in its tables and "error" in its
|
||||
# examples, at the top level and per item.
|
||||
problem = data.get('error') or data.get('erro')
|
||||
if not problem and items:
|
||||
problem = items[0].get('erro') or items[0].get('error')
|
||||
if problem:
|
||||
raise JadlogError(problem.get('descricao') or str(problem) if isinstance(problem, dict) else str(problem))
|
||||
if response.status_code >= 400 or not items or items[0].get('vltotal') is None:
|
||||
raise JadlogError(f'HTTP {response.status_code}: no freight value in the response')
|
||||
total = Decimal(str(items[0]['vltotal']))
|
||||
return {'total_cents': int((total * 100).quantize(Decimal('1'), ROUND_HALF_UP)),
|
||||
'days': items[0].get('prazo'), 'raw': items[0]}
|
||||
75
app/jadlog_probe.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""Read-only price check against the client's real Jadlog account, run by hand.
|
||||
A quote creates no shipment and costs nothing.
|
||||
|
||||
python -m app.jadlog_probe [--cep 01310100 ...] [--peso 0.5 ...] [--valor 100]
|
||||
|
||||
Without --cep and --peso it prices a few test weights to a few regions. It
|
||||
answers whether the token, CNPJ and account are accepted, whether a contract
|
||||
number is required (Jadlog names it in the error), and what the contract
|
||||
prices and delivery times are. The first failure stops the run: an account
|
||||
problem would repeat on every line.
|
||||
|
||||
Locally, with the credentials in jadlog.env (ignored by git):
|
||||
|
||||
docker compose -f compose.local.yaml -f compose.providers.yaml run --rm --no-deps --build \\
|
||||
--env-from-file jadlog.env worker python -m app.jadlog_probe
|
||||
"""
|
||||
import argparse
|
||||
import sys
|
||||
from decimal import Decimal
|
||||
|
||||
from .core.secrets import load as load_secret_files
|
||||
from .jadlog import JadlogError, JadlogQuotes
|
||||
|
||||
DESTINATIONS = {'14402310': 'Franca (origem)', '01310100': 'São Paulo', '20040002': 'Rio de Janeiro',
|
||||
'80010000': 'Curitiba', '50030230': 'Recife', '69005010': 'Manaus'}
|
||||
WEIGHTS = ['0.3', '0.5', '1', '2', '5']
|
||||
|
||||
|
||||
def money(cents):
|
||||
return f'R$ {cents / 100:.2f}'.replace('.', ',')
|
||||
|
||||
|
||||
def run(quotes, ceps, weights, declared_cents, out):
|
||||
header = quotes.token
|
||||
for weight in weights:
|
||||
for cep in ceps:
|
||||
try:
|
||||
result = quotes.quote(cep, Decimal(weight), declared_cents)
|
||||
except JadlogError as error:
|
||||
# The manual shows the header as the bare token; some accounts
|
||||
# are issued one that expects the Bearer scheme.
|
||||
if '401' in str(error) and not header.lower().startswith('bearer '):
|
||||
quotes.token = 'Bearer ' + header
|
||||
header = quotes.token
|
||||
try:
|
||||
result = quotes.quote(cep, Decimal(weight), declared_cents)
|
||||
except JadlogError as retry:
|
||||
out(f'ERRO {cep} {weight} kg: {retry} (also with "Bearer ")')
|
||||
return 1
|
||||
out('Note: the token only works with the "Bearer " prefix.')
|
||||
else:
|
||||
out(f'ERRO {cep} {weight} kg: {error}')
|
||||
return 1
|
||||
days = result['days']
|
||||
out(f"{weight} kg\t{cep}\t{DESTINATIONS.get(cep, '')}\t{money(result['total_cents'])}\t"
|
||||
f"{days if days is not None else '?'} dia(s)")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(prog='python -m app.jadlog_probe')
|
||||
parser.add_argument('--cep', action='append', help='destination CEP (repeatable)')
|
||||
parser.add_argument('--peso', action='append', help='weight in kg (repeatable)')
|
||||
parser.add_argument('--valor', default='100', help='declared value in reais (default 100)')
|
||||
args = parser.parse_args(argv)
|
||||
load_secret_files()
|
||||
quotes = JadlogQuotes()
|
||||
declared = int(Decimal(args.valor) * 100)
|
||||
print(f'Origem {quotes.origin}, modalidade {quotes.modalidade}, contrato {quotes.contrato or "-"}, '
|
||||
f'valor declarado {money(declared)}')
|
||||
return run(quotes, args.cep or list(DESTINATIONS), args.peso or WEIGHTS, declared, print)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -134,7 +134,15 @@ class MercadoPagoPayment:
|
||||
payment_id = str((query or {}).get('data.id') or (data.get('data') or {}).get('id') or '')
|
||||
if not payment_id.isdigit():
|
||||
return None
|
||||
payment = self.lookup(payment_id)
|
||||
try:
|
||||
payment = self.lookup(payment_id)
|
||||
except httpx.HTTPStatusError as error:
|
||||
# Signed by Mercado Pago but about no payment of ours, such as the
|
||||
# panel's "Simular notificação". Anything else is raised so that a
|
||||
# real notification is retried.
|
||||
if error.response.status_code == 404:
|
||||
return None
|
||||
raise
|
||||
return event_from_payment(payment, notification_id=str(data.get('id', '')))
|
||||
|
||||
|
||||
|
||||
@@ -27,6 +27,10 @@ POSTGRES_VOLUME=TBD
|
||||
OPERATOR_EMAIL=TBD
|
||||
|
||||
PAYMENT_ADAPTER=TBD
|
||||
# With PAYMENT_ADAPTER=mercadopago. MP_ACCESS_TOKEN and MP_WEBHOOK_SECRET are
|
||||
# secrets: enter them in Portainer only, never in this file.
|
||||
MP_NOTIFICATION_URL=https://<SITE_DOMAIN>/api/payments/webhook
|
||||
MP_PUBLIC_KEY=
|
||||
FREIGHT_ADAPTER=TBD
|
||||
TINY_ADAPTER=TBD
|
||||
# Tiny API v3 application (Configurações > Geral > Aplicativos in Tiny). The
|
||||
|
||||
@@ -19,10 +19,20 @@ x-app-environment: &app-environment
|
||||
# this value is configured.
|
||||
OPERATOR_EMAIL: ${OPERATOR_EMAIL:-}
|
||||
OPERATOR_PASSWORD: ${OPERATOR_PASSWORD:?set OPERATOR_PASSWORD}
|
||||
PAYMENT_ADAPTER: fake
|
||||
# Optional: without it the webhook verifies nothing and therefore accepts
|
||||
# nothing, which is the correct state until a provider is connected. Set it
|
||||
# when the provider is configured, never to a value anyone could guess.
|
||||
# fake until Mercado Pago is configured; mercadopago requires MP_ACCESS_TOKEN
|
||||
# and MP_WEBHOOK_SECRET or the API and worker refuse to start. Test
|
||||
# credentials (TEST-...) until the sandbox flows have passed. The card form
|
||||
# appears only with MP_PUBLIC_KEY, and then needs PAYMENT_CSP_SOURCES too.
|
||||
PAYMENT_ADAPTER: ${PAYMENT_ADAPTER:-fake}
|
||||
MP_ACCESS_TOKEN: ${MP_ACCESS_TOKEN:-}
|
||||
# The "assinatura secreta" from the webhook settings in Mercado Pago.
|
||||
MP_WEBHOOK_SECRET: ${MP_WEBHOOK_SECRET:-}
|
||||
# https://<SITE_DOMAIN>/api/payments/webhook, sent with every payment.
|
||||
MP_NOTIFICATION_URL: ${MP_NOTIFICATION_URL:-}
|
||||
MP_PUBLIC_KEY: ${MP_PUBLIC_KEY:-}
|
||||
# The fake adapter's secret. Optional: without it the webhook verifies
|
||||
# nothing and therefore accepts nothing, which is the correct state until a
|
||||
# provider is connected. Never set it to a value anyone could guess.
|
||||
PAYMENT_WEBHOOK_SECRET: ${PAYMENT_WEBHOOK_SECRET:-}
|
||||
FREIGHT_ADAPTER: fake
|
||||
# Order creation in Tiny stays off until it has been tested against the
|
||||
|
||||
@@ -247,9 +247,24 @@ From the report already sent. These are dated promises, not backlog.
|
||||
quote cannot be charged twice. The Site CSP gains the Mercado Pago origins
|
||||
only through `PAYMENT_CSP_SOURCES`, empty by default. Not yet rendered
|
||||
against a real public key; sandbox run and refund policy remain.
|
||||
**Account setup (2026-09-28):** the client's application is Checkout
|
||||
Transparente on the Payments API, webhook event "Pagamentos (legacy)" only,
|
||||
URL `https://dtf.agenciacompor.com.br/api/payments/webhook`. The production
|
||||
compose now takes `PAYMENT_ADAPTER` and `MP_*` from Portainer (it hard-coded
|
||||
the fake adapter), so the sandbox runs on production with test credentials;
|
||||
the Site has no real customers yet. A verified notification whose payment
|
||||
does not exist (the panel's "Simular notificação") is acknowledged instead
|
||||
of answering 500, which would have made Mercado Pago retry it.
|
||||
- `[ ]` 1.2 — Real freight quotation. **Blocked on client inputs** (see
|
||||
`PRODUCTION_INPUTS.md`): source platform, credentials, origin CEP, services,
|
||||
packaging weight/dimensions per length, subsidy policy.
|
||||
**Received (2026-09-25):** Jadlog, API access (user/CNPJ, client code,
|
||||
token, account), origin CEP 14402-310, service .PACKAGE (modalidade 3),
|
||||
home delivery, billed by contract. Still missing: packaging weight and
|
||||
dimensions per length and how freight is charged to the customer (asked
|
||||
2026-09-28). `app/jadlog.py` prices one package from the manual (v2.3);
|
||||
`python -m app.jadlog_probe` prices test weights to six regions, read-only,
|
||||
to confirm token, account and contract on the client's account. Not yet run.
|
||||
- `[~]` 1.3 — Idempotent Tiny/Olist order creation with order-number traceability.
|
||||
Confirm endpoints, tag behaviour and rate limits first.
|
||||
**Groundwork (2026-09-24), API v3 by decision:** OAuth2 against Tiny's
|
||||
@@ -934,7 +949,23 @@ print-file evidence still need correction before this item can close.
|
||||
and fictional orders. The payment step has no screenshot until Mercado Pago
|
||||
is configured, and the guides name the provisional Kanban domain; regenerate
|
||||
them when either changes. Early work toward Week 3's "orientação à operação".
|
||||
- `[ ]` Week-2 client report, due end of day 2026-09-25.
|
||||
- `[x]` Operator guide rebuilt from a committed source (2026-09-25):
|
||||
`docs/guias/operador/` (HTML and the original screenshots, cropped as
|
||||
before), printed by `docs/guias/imprimir.sh`. The source of the 09-24 PDF
|
||||
was never committed. Corrected: stage moves do not update Tiny (only
|
||||
"Aprovada" and, for pickup, "Pronto para envio", once enabled); WhatsApp
|
||||
notices go through the client's Tiny -> n8n flow; the correction notice is
|
||||
not automatic, the reason is in Minha conta; the Tiny card's renewal,
|
||||
"Verificar" and "Testar conexão" checks. Found while writing it: the
|
||||
customer's order history showed operators' internal reasons for moving an
|
||||
order back; it now omits back moves and shows reasons only for corrections.
|
||||
The Site guide's source is also not in the repository.
|
||||
- `[x]` Week-2 client report (`Relatorio-Semana-2-DTF.docx`), written 2026-09-25,
|
||||
sent 2026-09-28. Before sending, the FlexiPRINT import was taken out of
|
||||
"O que falta" (it is done this week) and the print-file paragraph no longer
|
||||
names PSD, AI, CDR or multi-page PDFs as hand-preparation cases: generating
|
||||
those is Week 3 work (1.4). Freight is reported as Jadlog data received,
|
||||
waiting on packaging weight and dimensions and the charging policy.
|
||||
- `[x]` Week-1 client report (`Relatorio-Semana-1-DTF.docx`), corrected 2026-09-18 to
|
||||
remove the inaccurate "Arquivo por metro permanece separado, com seleção explícita"
|
||||
claim and the internal commit reference.
|
||||
|
||||
8
docs/guias/imprimir.sh
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/bin/sh
|
||||
# Print the guide sources in docs/guias/ to the PDFs in docs/ with headless
|
||||
# Chrome (A4, no browser header or footer). Needs Chrome and the DejaVu fonts.
|
||||
set -eu
|
||||
cd "$(dirname "$0")"
|
||||
CHROME=${CHROME:-$(command -v google-chrome-stable || command -v google-chrome || command -v chromium)}
|
||||
"$CHROME" --headless --disable-gpu --no-pdf-header-footer --run-all-compositor-stages-before-draw \
|
||||
--print-to-pdf="$PWD/../guia-operador-kanban.pdf" "file://$PWD/operador/guia-operador-kanban.html"
|
||||
320
docs/guias/operador/guia-operador-kanban.html
Normal file
@@ -0,0 +1,320 @@
|
||||
<!DOCTYPE html>
|
||||
<!-- Source of docs/guia-operador-kanban.pdf. Print with docs/guias/imprimir.sh.
|
||||
Screenshots in img/ come from a throwaway stack with fictional orders. -->
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Kanban DTF · Guia do operador</title>
|
||||
<style>
|
||||
@page { size: A4; margin: 0; }
|
||||
:root {
|
||||
--navy: #0B1320; --orange: #F08A24; --ink: #1B2331; --muted: #6B7280;
|
||||
--line: #D6DCE4; --head: #EAF0F8; --note: #FFF5E8; --note-line: #F4C58E;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body { font-family: 'DejaVu Sans', sans-serif; color: var(--ink); font-size: 9.7pt; line-height: 1.55;
|
||||
-webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
.page { width: 210mm; height: 297mm; position: relative; padding: 26mm 18mm 20mm; overflow: hidden;
|
||||
page-break-after: always; break-after: page; }
|
||||
.page:last-child { page-break-after: auto; break-after: auto; }
|
||||
.running { position: absolute; top: 9mm; left: 18mm; right: 18mm; font-size: 7.4pt; color: var(--muted);
|
||||
border-bottom: 0.6pt solid #E3E7ED; padding-bottom: 2.4mm; }
|
||||
.folio { position: absolute; bottom: 9mm; right: 18mm; font-size: 7.4pt; color: var(--muted); }
|
||||
.kicker { color: var(--orange); font-weight: bold; font-size: 7.6pt; letter-spacing: 0.04em; margin: 0 0 1mm; }
|
||||
h1 { font-size: 20.5pt; line-height: 1.2; margin: 0 0 2mm; color: #101826; }
|
||||
h1::after { content: ""; display: block; width: 18mm; height: 1.4mm; background: var(--orange); margin-top: 3mm; }
|
||||
h2 { font-size: 11.5pt; margin: 7mm 0 2.5mm; color: #101826; }
|
||||
p { margin: 0 0 2.6mm; }
|
||||
b { font-weight: bold; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 2mm 0 3mm; font-size: 8.6pt; line-height: 1.45; }
|
||||
th, td { border: 0.6pt solid var(--line); padding: 2mm 2.4mm; vertical-align: top; text-align: left; }
|
||||
th { background: var(--head); font-weight: bold; }
|
||||
td.n { color: var(--orange); font-weight: bold; font-size: 11pt; width: 7mm; text-align: center; }
|
||||
td.k { font-weight: bold; }
|
||||
.note { background: var(--note); border: 0.6pt solid var(--note-line); border-radius: 2mm; padding: 3mm 3.6mm;
|
||||
font-size: 8.6pt; margin: 3mm 0; }
|
||||
ul { margin: 0 0 3mm; padding-left: 4.5mm; }
|
||||
ul li { margin-bottom: 1.1mm; }
|
||||
ul li::marker { color: var(--orange); }
|
||||
ol { margin: 0 0 3mm; padding-left: 5mm; }
|
||||
ol li { margin-bottom: 1.3mm; }
|
||||
ol li::marker { color: var(--orange); font-weight: bold; }
|
||||
.chip { display: inline-block; border: 0.6pt solid #C9D0DA; background: #F4F6F9; border-radius: 1.2mm;
|
||||
padding: 0 1.6mm; font-size: 7.6pt; line-height: 1.6; white-space: nowrap; }
|
||||
.shot { display: block; width: 100%; border-radius: 1.6mm; margin: 2mm 0 1mm; }
|
||||
.caption { font-size: 7.2pt; color: var(--muted); margin: 0 0 3mm; }
|
||||
.split { display: flex; gap: 6mm; align-items: flex-start; }
|
||||
.split > div { flex: 1; }
|
||||
.split > .side { flex: 0 0 79.5mm; }
|
||||
.quote { font-style: normal; }
|
||||
|
||||
/* Cover */
|
||||
.cover { background: var(--navy); color: #F3F5F8; padding: 0 18mm; }
|
||||
.cover .block { position: absolute; left: 18mm; right: 18mm; top: 158mm; }
|
||||
.cover .kicker { margin-bottom: 4mm; }
|
||||
.cover .title { font-size: 27pt; font-weight: bold; line-height: 1.25; margin: 0 0 4mm; color: #fff; }
|
||||
.cover .lead { font-size: 9.6pt; color: #C8CFDA; margin: 0 0 8mm; }
|
||||
.cover table { font-size: 8pt; margin: 0 0 8mm; }
|
||||
.cover th, .cover td { border-color: #2A3547; background: transparent; color: #E6EAF0; padding: 3mm 3.6mm; }
|
||||
.cover th { color: var(--orange); }
|
||||
.cover .version { font-size: 8pt; color: #A9B2C0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- 1 · Capa -->
|
||||
<section class="page cover">
|
||||
<div class="block">
|
||||
<p class="kicker">GUIA DE OPERAÇÃO</p>
|
||||
<p class="title">Kanban DTF<br>Guia do operador</p>
|
||||
<p class="lead">Como conferir, produzir e entregar os pedidos que chegam pelo Site DTF.</p>
|
||||
<table>
|
||||
<tr><th style="width:50%">Acesso</th><th>Quem usa</th></tr>
|
||||
<tr><td>dtf.kanban.agenciacompor.com.br<br>e-mail e senha do operador</td>
|
||||
<td>Equipe da sala de DTF e atendimento</td></tr>
|
||||
</table>
|
||||
<p class="version">Versão de 25 de setembro de 2026</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 2 · Visão geral -->
|
||||
<section class="page">
|
||||
<div class="running">DTF Dropstar - guia do operador</div>
|
||||
<p class="kicker">VISÃO GERAL</p>
|
||||
<h1>O caminho de um pedido</h1>
|
||||
<p>O cliente monta a folha e envia as artes pelo Site DTF. Daí em diante, tudo acontece no Kanban: a equipe
|
||||
confere a cotação, o cliente paga, e o pedido percorre as etapas de produção até ficar pronto para retirada.</p>
|
||||
<table>
|
||||
<tr><td class="n">1</td><td class="k" style="width:31mm">Cliente envia</td>
|
||||
<td>Monta a folha no Site, vê a nota e o preço e envia o pedido para conferência.</td></tr>
|
||||
<tr><td class="n">2</td><td class="k">Equipe confere a cotação</td>
|
||||
<td>Na aba <b>Cotações</b>: confere arquivos, metragem e nota e aprova.</td></tr>
|
||||
<tr><td class="n">3</td><td class="k">Cliente paga</td>
|
||||
<td>O total aprovado aparece no Site. O pagamento é pelo Mercado Pago (PIX ou cartão).</td></tr>
|
||||
<tr><td class="n">4</td><td class="k">Pedido entra na produção</td>
|
||||
<td>Com o pagamento aprovado, o pedido aparece em <b>Arte recebida</b>, com o PDF de impressão já gerado.</td></tr>
|
||||
<tr><td class="n">5</td><td class="k">Equipe produz</td>
|
||||
<td>Aprova o arquivo final, baixa, importa no FlexiPRINT e move o card a cada etapa.</td></tr>
|
||||
<tr><td class="n">6</td><td class="k">Pedido pronto</td>
|
||||
<td>Em <b>Finalizado</b>, o pedido fica pronto para retirada e o cliente é avisado (página 7).</td></tr>
|
||||
</table>
|
||||
<h2>As quatro abas</h2>
|
||||
<table>
|
||||
<tr><th style="width:31mm">Aba</th><th>Para que serve</th></tr>
|
||||
<tr><td class="k">Produção</td><td>O quadro com os pedidos pagos, uma coluna por etapa.</td></tr>
|
||||
<tr><td class="k">Cotações</td><td>Pedidos enviados pelo Site esperando conferência. O número ao lado indica quantos faltam revisar.</td></tr>
|
||||
<tr><td class="k">Pagamentos</td><td>Pagamentos que o sistema não conseguiu ligar a um pedido. Normalmente fica vazia.</td></tr>
|
||||
<tr><td class="k">Integrações</td><td>Situação do Tiny, Mercado Pago, frete e WhatsApp, e o registro de tudo o que foi enviado a eles.</td></tr>
|
||||
</table>
|
||||
<div class="note">O quadro não se atualiza sozinho. Clique em <b>Atualizar</b>, no canto superior direito, para ver pedidos e cotações novos.</div>
|
||||
<div class="folio">Página 2</div>
|
||||
</section>
|
||||
|
||||
<!-- 3 · Entrar e ler o quadro -->
|
||||
<section class="page">
|
||||
<div class="running">DTF Dropstar - guia do operador</div>
|
||||
<p class="kicker">PRODUÇÃO</p>
|
||||
<h1>Entrar e ler o quadro</h1>
|
||||
<div class="split">
|
||||
<div>
|
||||
<p>Acesse <b>dtf.kanban.agenciacompor.com.br</b> e entre com seu e-mail e senha. Cada operador tem o próprio
|
||||
acesso: é o nome dele que fica registrado em cada movimento do pedido.</p>
|
||||
<p>Depois de várias senhas erradas seguidas, o acesso fica bloqueado por 15 minutos.</p>
|
||||
</div>
|
||||
<div class="side"><img class="shot" src="img/login.png" alt="Tela de entrada do Kanban"></div>
|
||||
</div>
|
||||
<img class="shot" src="img/quadro.png" alt="Quadro de produção">
|
||||
<p class="caption">Quadro de produção com três pedidos em etapas diferentes.</p>
|
||||
<h2>O que cada card mostra</h2>
|
||||
<ul>
|
||||
<li><b>#número</b> do pedido e há quanto tempo ele está parado na etapa.</li>
|
||||
<li>E-mail do cliente, produto e metragem cobrada (ex.: <span class="chip">Têxtil avulsa · 1,00 m</span>).</li>
|
||||
<li>Situação do arquivo: <span class="chip">PDF pronto</span> (gerado, falta aprovar), <span class="chip">Final aprovado</span>
|
||||
(pode ir para a fila) ou <span class="chip">Preparar à mão</span> (o sistema não conseguiu gerar; veja a página 6).</li>
|
||||
<li><span class="chip">Retirada</span> ou <span class="chip">Entrega · UF</span>.</li>
|
||||
<li>Um aviso <b>Parado há mais de 4 h</b> aparece quando o pedido não anda.</li>
|
||||
</ul>
|
||||
<p>Os filtros <b>Têxtil</b>, <b>UV</b> e <b>Preparar à mão</b> mostram só esses pedidos. A busca encontra pedido, cliente ou CNPJ.</p>
|
||||
<div class="folio">Página 3</div>
|
||||
</section>
|
||||
|
||||
<!-- 4 · Cotações -->
|
||||
<section class="page">
|
||||
<div class="running">DTF Dropstar - guia do operador</div>
|
||||
<p class="kicker">COTAÇÕES</p>
|
||||
<h1>Conferir e aprovar uma cotação</h1>
|
||||
<img class="shot" src="img/cotacoes.png" alt="Aba Cotações">
|
||||
<p class="caption">À esquerda, as cotações a revisar; à direita, a cotação selecionada.</p>
|
||||
<ol>
|
||||
<li>Abra a aba <b>Cotações</b> e clique na cotação em <b>A revisar</b>.</li>
|
||||
<li>Confira a montagem na miniatura. Se precisar, abra os arquivos em <b>Original</b> ou a lista de peças em <b>Manifesto</b>.</li>
|
||||
<li>Confira <b>Metros conferidos</b> e <b>Nota conferida</b>. Os valores do cliente já vêm preenchidos; mude só se
|
||||
estiverem errados. A nota define o preço do metro.</li>
|
||||
<li>Marque <b>Arquivos, metragem e nota conferidos</b> e clique em <b>Aprovar cotação</b>.</li>
|
||||
</ol>
|
||||
<p>O cliente vê o total aprovado no Site e tem <b>24 horas</b> para pagar. As aprovadas ficam em
|
||||
<b>Aprovadas, aguardando pagamento</b> até o pagamento chegar.</p>
|
||||
<div class="note"><b>Ressalva de resolução aceita pelo cliente</b>: alguma arte tem menos de 300 DPI e o cliente confirmou
|
||||
que quer imprimir assim. <b>Montagem antiga</b>: a cotação foi feita numa versão anterior do Site; peça ao cliente
|
||||
para enviar de novo.</div>
|
||||
<div class="folio">Página 4</div>
|
||||
</section>
|
||||
|
||||
<!-- 5 · Abrir um pedido -->
|
||||
<section class="page">
|
||||
<div class="running">DTF Dropstar - guia do operador</div>
|
||||
<p class="kicker">PRODUÇÃO</p>
|
||||
<h1>Abrir um pedido</h1>
|
||||
<img class="shot" src="img/painel.png" alt="Painel do pedido">
|
||||
<p class="caption">Clique em qualquer card para abrir o painel do pedido.</p>
|
||||
<table>
|
||||
<tr><th style="width:36mm">Parte do painel</th><th>O que tem</th></tr>
|
||||
<tr><td class="k">Topo</td><td>Número, etapa atual, data do pagamento e os botões para mover o pedido.</td></tr>
|
||||
<tr><td class="k">Cliente e Entrega</td><td>E-mail, CNPJ e WhatsApp do cliente; endereço ou <b>Retirada em Franca</b>.</td></tr>
|
||||
<tr><td class="k">Itens e arquivos de impressão</td><td>Montagem de cada item, peças, tamanho da folha e nota. <b>Baixar PDF</b>
|
||||
baixa o arquivo pronto para o FlexiPRINT; <b>Original</b> baixa o arquivo que o cliente enviou; <b>Manifesto</b>
|
||||
lista as peças e suas posições.</td></tr>
|
||||
<tr><td class="k">Arquivos finais</td><td>O arquivo que vai ser impresso, aprovado por um operador (próxima página).</td></tr>
|
||||
<tr><td class="k">Histórico</td><td>Cada movimento do pedido, com hora, operador e motivo.</td></tr>
|
||||
</table>
|
||||
<div class="folio">Página 5</div>
|
||||
</section>
|
||||
|
||||
<!-- 6 · Arquivo final -->
|
||||
<section class="page">
|
||||
<div class="running">DTF Dropstar - guia do operador</div>
|
||||
<p class="kicker">PRODUÇÃO</p>
|
||||
<h1>Aprovar o arquivo final</h1>
|
||||
<div class="split">
|
||||
<div>
|
||||
<p>Antes de ir para a <b>Fila de impressão</b>, todo item precisa de um arquivo final aprovado. O sistema já gera
|
||||
um PDF com a montagem que o cliente viu e pagou.</p>
|
||||
<ol>
|
||||
<li>Baixe o PDF em <b>Baixar PDF</b> e confira.</li>
|
||||
<li>Deixe marcado <b>Usar o PDF gerado</b>. Se preferir usar outro arquivo, desmarque e envie o seu.</li>
|
||||
<li>Escreva uma <b>Nota da revisão</b> (ex.: “Conferido, pronto para imprimir”).</li>
|
||||
<li>Marque <b>Arquivos conferidos</b> e clique em <b>Aprovar arquivos finais</b>.</li>
|
||||
</ol>
|
||||
<p>O card passa a mostrar <span class="chip">Final aprovado</span>.</p>
|
||||
</div>
|
||||
<div class="side"><img class="shot" src="img/arquivo-final.png" alt="Aprovação do arquivo final"></div>
|
||||
</div>
|
||||
<h2>Quando o PDF não é gerado</h2>
|
||||
<table>
|
||||
<tr><th style="width:40mm">No card ou no item</th><th>O que fazer</th></tr>
|
||||
<tr><td><span class="chip">Na fila para gerar</span></td><td>Aguarde alguns segundos e clique em <b>Atualizar</b>.</td></tr>
|
||||
<tr><td><span class="chip">Preparar à mão</span></td><td>O cliente enviou um formato que o gerador não lê (CDR, AI, PSD, TIFF)
|
||||
ou pediu correção. Baixe o <b>Original</b>, prepare o arquivo no seu programa e envie-o em <b>Arquivos finais</b>.</td></tr>
|
||||
<tr><td><span class="chip">Falhou ao gerar</span></td><td>Clique em <b>Gerar novamente</b>. Se falhar de novo, prepare à mão.</td></tr>
|
||||
</table>
|
||||
<div class="folio">Página 6</div>
|
||||
</section>
|
||||
|
||||
<!-- 7 · Mover pelas etapas -->
|
||||
<section class="page">
|
||||
<div class="running">DTF Dropstar - guia do operador</div>
|
||||
<p class="kicker">PRODUÇÃO</p>
|
||||
<h1>Mover o pedido pelas etapas</h1>
|
||||
<p>Use o botão laranja <b>Mover para…</b> no topo do painel ou arraste o card para a coluna seguinte. Ao arrastar,
|
||||
só as colunas permitidas ficam destacadas.</p>
|
||||
<table>
|
||||
<tr><th style="width:27mm">Etapa</th><th>Significa</th><th style="width:43mm">Aviso ao cliente</th></tr>
|
||||
<tr><td class="k">Arte recebida</td><td>Pedido pago, arquivo gerado. Ainda não conferido.</td><td>Pedido aprovado (automático)</td></tr>
|
||||
<tr><td class="k">Arte tratada</td><td>Arquivo conferido e ajustado, se preciso.</td><td>—</td></tr>
|
||||
<tr><td class="k">Fila de impressão</td><td>Pronto para imprimir. Exige o arquivo final aprovado.</td><td>—</td></tr>
|
||||
<tr><td class="k">Imprimindo</td><td>Na máquina.</td><td>—</td></tr>
|
||||
<tr><td class="k">Finalizado</td><td>Impresso e pronto para retirada ou envio.</td><td>Pedido pronto para retirada</td></tr>
|
||||
<tr><td class="k">Correção</td><td>Algo no arquivo precisa ser resolvido pelo cliente.</td><td>O motivo aparece em Minha conta, no Site (página 8)</td></tr>
|
||||
</table>
|
||||
<div class="note">Os avisos por WhatsApp saem pelo número e pelas mensagens que a Dropstar já usa, a partir da situação
|
||||
do pedido no Tiny: o pedido pago fica <b>Aprovado</b> e, em <b>Finalizado</b>, o pedido de retirada fica
|
||||
<b>Pronto para envio</b>. Cada aviso sai <b>uma vez só</b>, mesmo que o pedido volte uma etapa e avance de novo.
|
||||
As outras etapas não mudam o pedido no Tiny. Enquanto essa integração não estiver ativa, avise o cliente como hoje.</div>
|
||||
<h2>Moveu errado? Volte uma etapa</h2>
|
||||
<img class="shot" src="img/voltar-etapa.png" alt="Botão Voltar para">
|
||||
<p class="caption">Botão <b>Voltar para…</b>: pede um motivo interno e não avisa o cliente.</p>
|
||||
<p>Clique em <b>Voltar para [etapa anterior]</b>, escreva o motivo (ex.: “movi por engano”) e confirme. O retorno fica
|
||||
no histórico como <b>Voltou para…</b>. O cliente não vê esse retorno nem o motivo.</p>
|
||||
<div class="folio">Página 7</div>
|
||||
</section>
|
||||
|
||||
<!-- 8 · Correção -->
|
||||
<section class="page">
|
||||
<div class="running">DTF Dropstar - guia do operador</div>
|
||||
<p class="kicker">PRODUÇÃO</p>
|
||||
<h1>Pedir correção ao cliente</h1>
|
||||
<img class="shot" src="img/pedir-correcao.png" alt="Botão Pedir correção" style="width:78%">
|
||||
<p class="caption">Botão <b>Pedir correção</b>: o motivo vai para o cliente.</p>
|
||||
<ol>
|
||||
<li>No painel do pedido, clique em <b>Pedir correção</b>.</li>
|
||||
<li>Escreva o motivo de forma clara para o cliente (ex.: “A arte escudo.png está com fundo branco. Envie com fundo transparente.”).</li>
|
||||
<li>Clique em <b>Enviar para correção</b>. O pedido vai para a coluna <b>Correção</b>.</li>
|
||||
</ol>
|
||||
<p>O cliente vê o motivo em <b>Minha conta</b>, no Site, e envia por lá o arquivo corrigido. Esse aviso ainda não sai
|
||||
pelo WhatsApp: avise o cliente de que há uma correção pendente. O arquivo corrigido aparece em <b>Arquivos finais</b>
|
||||
como <b>Correção do cliente</b>. Confira, mova o pedido para <b>Arte recebida</b> ou <b>Arte tratada</b> e siga o fluxo normal.</p>
|
||||
<div class="note">Correção é para problemas que só o cliente resolve (resolução, fundo, arte errada). Ajustes que a equipe
|
||||
faz sozinha não precisam de correção: trate o arquivo em <b>Arte tratada</b>.</div>
|
||||
<h2>Histórico</h2>
|
||||
<img class="shot" src="img/historico.png" alt="Histórico do pedido" style="width:78%">
|
||||
<p class="caption">Cada movimento fica registrado com data, hora e operador.</p>
|
||||
<div class="folio">Página 8</div>
|
||||
</section>
|
||||
|
||||
<!-- 9 · Pagamentos e integrações -->
|
||||
<section class="page">
|
||||
<div class="running">DTF Dropstar - guia do operador</div>
|
||||
<p class="kicker">PAGAMENTOS E INTEGRAÇÕES</p>
|
||||
<h1>Pagamentos com problema e integrações</h1>
|
||||
<p>A aba <b>Pagamentos</b> lista os pagamentos que chegaram mas não puderam virar pedido sozinhos. Exemplos:</p>
|
||||
<ul>
|
||||
<li>valor pago diferente do total da cotação;</li>
|
||||
<li>cotação vencida antes do pagamento (passou das 24 h);</li>
|
||||
<li>pagamento estornado ou cancelado depois que o pedido já existia.</li>
|
||||
</ul>
|
||||
<p>Resolva com o cliente ou no Mercado Pago e clique em <b>Registrar resolução</b>, descrevendo o que foi feito.
|
||||
O item passa para <b>Resolvidos</b>.</p>
|
||||
<img class="shot" src="img/pagamentos.png" alt="Aba Pagamentos">
|
||||
<h2>Integrações</h2>
|
||||
<img class="shot" src="img/integracoes.png" alt="Aba Integrações">
|
||||
<p class="caption">Imagem de um ambiente sem integrações ativas. Em produção, cada cartão mostra se a integração está conectada.</p>
|
||||
<ul>
|
||||
<li><b>Tiny</b>: o cartão mostra quem conectou, quando a conexão foi renovada e até quando vale; o sistema renova
|
||||
sozinho. Se aparecer <b>Não conectado</b> ou <b>Verificar</b>, clique em <b>Conectar Tiny</b> ou <b>Reconectar</b> e
|
||||
entre com o usuário do Tiny indicado pela Dropstar. <b>Testar conexão</b> confere pedidos, contatos, os produtos
|
||||
e a forma de envio de retirada, sem criar nada.</li>
|
||||
<li><b>Registro de envios</b>: tudo o que o sistema mandou ao Tiny e ao WhatsApp, com filtros por destino, situação,
|
||||
evento e pedido. <b>Com erro</b> indica um envio que falhou; o sistema tenta de novo sozinho, mas o erro precisa de atenção.</li>
|
||||
</ul>
|
||||
<div class="folio">Página 9</div>
|
||||
</section>
|
||||
|
||||
<!-- 10 · Dúvidas -->
|
||||
<section class="page">
|
||||
<div class="running">DTF Dropstar - guia do operador</div>
|
||||
<p class="kicker">DÚVIDAS FREQUENTES</p>
|
||||
<h1>Mensagens e o que fazer</h1>
|
||||
<table>
|
||||
<tr><th style="width:62mm">Mensagem ou situação</th><th>O que fazer</th></tr>
|
||||
<tr><td>“O pedido mudou. Clique em Atualizar.”</td><td>Outra pessoa mexeu no pedido ao mesmo tempo. Clique em <b>Atualizar</b> e repita a ação.</td></tr>
|
||||
<tr><td>“Aprove os arquivos finais de todos os itens antes de colocar na fila.”</td><td>Aprove o arquivo final de cada item (página 6) e tente de novo.</td></tr>
|
||||
<tr><td>“Informe o motivo da correção.” / “Informe por que o pedido está voltando de etapa.”</td><td>Preencha o motivo antes de confirmar.</td></tr>
|
||||
<tr><td>Cotação com <b>Montagem antiga</b></td><td>Peça ao cliente para enviar o pedido de novo pelo Site.</td></tr>
|
||||
<tr><td>Card com <b>Parado há mais de 4 h</b></td><td>Veja se o pedido está esperando alguém ou se esqueceram de movê-lo.</td></tr>
|
||||
<tr><td>Arquivo aparece como <b>expirado</b></td><td>Os arquivos ficam guardados por 30 dias. Depois disso, o cliente precisa enviar de novo.</td></tr>
|
||||
<tr><td>No topo, <b>Tiny: verificar conexão</b> ou <b>Tiny não conectado</b></td><td>Abra <b>Integrações</b> e siga a mensagem do cartão do Tiny. Se pedir, clique em <b>Reconectar</b>.</td></tr>
|
||||
<tr><td>A internet da fábrica caiu</td><td>O Site continua recebendo pedidos e pagamentos. Quando a conexão voltar, clique em <b>Atualizar</b>.</td></tr>
|
||||
</table>
|
||||
<h2>Boas práticas</h2>
|
||||
<ul>
|
||||
<li>Mova o card assim que a etapa mudar: é o que avisa o cliente e mede o tempo de produção.</li>
|
||||
<li>Escreva motivos que outra pessoa entenda sem perguntar.</li>
|
||||
<li>Use sempre o seu acesso: o histórico mostra quem fez cada movimento.</li>
|
||||
<li>Clique em <b>Sair</b> ao deixar o computador.</li>
|
||||
</ul>
|
||||
<div class="folio">Página 10</div>
|
||||
</section>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
BIN
docs/guias/operador/img/arquivo-final.png
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
docs/guias/operador/img/cotacoes.png
Normal file
|
After Width: | Height: | Size: 191 KiB |
BIN
docs/guias/operador/img/historico.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
docs/guias/operador/img/integracoes.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
docs/guias/operador/img/login.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
docs/guias/operador/img/pagamentos.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
docs/guias/operador/img/painel.png
Normal file
|
After Width: | Height: | Size: 288 KiB |
BIN
docs/guias/operador/img/pedir-correcao.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
docs/guias/operador/img/quadro.png
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
docs/guias/operador/img/voltar-etapa.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
@@ -195,6 +195,11 @@ def run():
|
||||
client.call('/operator/orders/'+oid+'/move',{'state':'fin','version':version},operator=True);version+=1
|
||||
history=client.call('/operator/orders/'+oid+'/history',operator=True)
|
||||
assert len(history)==8 and history[-2]['back'] and history[-2]['reason']=='Movido por engano' and not history[-1]['back']
|
||||
# The customer sees the stages and the correction's reason, never the
|
||||
# internal reason for going back.
|
||||
seen=client.call('/customer/orders/'+oid)['history']
|
||||
assert len(seen)==7 and all(h['reason']=='' or h['to_state']=='cor' for h in seen)
|
||||
assert any(h['to_state']=='cor' and h['reason']=='Local test correction' for h in seen)
|
||||
print('PASS: a mistaken move is undone one stage back with a reason, without messaging the customer again')
|
||||
print('PASS: all modes, authoritative review/prices/freight, tamper rejection, concurrent payment idempotency, transitions and history')
|
||||
deadline=time.monotonic()+30
|
||||
|
||||
100
tests/test_jadlog.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""The Jadlog quote client against a fake HTTP transport: payload and errors.
|
||||
|
||||
This proves the manual's contract only; app.jadlog_probe must still confirm
|
||||
it on the client's account. Runs where httpx is installed.
|
||||
"""
|
||||
import json
|
||||
import unittest
|
||||
|
||||
import httpx
|
||||
|
||||
from app.jadlog import QUOTE_URL, JadlogError, JadlogQuotes
|
||||
from app.jadlog_probe import run
|
||||
|
||||
CNPJ = '11.222.333/0001-81'
|
||||
|
||||
|
||||
def client(handler, **settings):
|
||||
return JadlogQuotes(token=settings.pop('token', 'tok-1'), cnpj=CNPJ, conta='123456',
|
||||
transport=httpx.MockTransport(handler), **settings)
|
||||
|
||||
|
||||
class JadlogQuoteTest(unittest.TestCase):
|
||||
def test_payload_follows_the_manual_and_price_becomes_centavos(self):
|
||||
seen = []
|
||||
|
||||
def handler(request):
|
||||
seen.append(request)
|
||||
item = json.loads(request.content)['frete'][0]
|
||||
return httpx.Response(200, json={'frete': [{**item, 'vltotal': 23.455, 'prazo': 4}]})
|
||||
|
||||
result = client(handler).quote('01310-100', 1.5, 12990)
|
||||
request = seen[0]
|
||||
self.assertEqual(str(request.url), QUOTE_URL)
|
||||
self.assertEqual(request.headers['authorization'], 'tok-1')
|
||||
item = json.loads(request.content)['frete'][0]
|
||||
self.assertEqual(item, {'cepori': '14402310', 'cepdes': '01310100', 'frap': 'N', 'peso': 1.5,
|
||||
'cnpj': '11222333000181', 'conta': '123456', 'contrato': None,
|
||||
'modalidade': 3, 'tpentrega': 'D', 'tpseguro': 'N',
|
||||
'vldeclarado': 129.9, 'vlcoleta': 0})
|
||||
self.assertEqual((result['total_cents'], result['days']), (2346, 4))
|
||||
|
||||
def test_contract_is_sent_only_when_configured(self):
|
||||
def handler(request):
|
||||
item = json.loads(request.content)['frete'][0]
|
||||
self.assertEqual(item['contrato'], '042')
|
||||
return httpx.Response(200, json={'frete': [{'vltotal': 10, 'prazo': 2}]})
|
||||
|
||||
client(handler, contrato='042').quote('01310100', 1, 100)
|
||||
|
||||
def test_account_and_item_errors_are_raised_with_jadlog_text(self):
|
||||
replies = [
|
||||
httpx.Response(200, json={'frete': [{}], 'error': {'id': -1, 'descricao': 'frete[0].contrato Numero de contrato invalido'}}),
|
||||
httpx.Response(200, json={'frete': [{'erro': {'id': 2, 'descricao': 'CEP destino invalido'}}]}),
|
||||
httpx.Response(200, json={'frete': [{'prazo': 3}]}),
|
||||
httpx.Response(502, text='<html>bad gateway</html>'),
|
||||
]
|
||||
messages = ['contrato invalido', 'CEP destino invalido', 'no freight value', 'not JSON']
|
||||
for reply, message in zip(replies, messages):
|
||||
with self.subTest(message=message), self.assertRaisesRegex(JadlogError, message):
|
||||
client(lambda request, reply=reply: reply).quote('01310100', 1, 100)
|
||||
|
||||
def test_missing_credentials_refuse_to_start(self):
|
||||
with self.assertRaises(RuntimeError):
|
||||
JadlogQuotes(token='', cnpj=CNPJ)
|
||||
with self.assertRaises(RuntimeError):
|
||||
JadlogQuotes(token='tok', cnpj='123')
|
||||
|
||||
|
||||
class JadlogProbeTest(unittest.TestCase):
|
||||
def test_bearer_is_tried_once_after_a_401_and_kept(self):
|
||||
headers = []
|
||||
|
||||
def handler(request):
|
||||
headers.append(request.headers['authorization'])
|
||||
if not request.headers['authorization'].startswith('Bearer '):
|
||||
return httpx.Response(401, json={'descricao': 'TOKEN INVALIDO', 'id': 1})
|
||||
return httpx.Response(200, json={'frete': [{'vltotal': 12.5, 'prazo': 3}]})
|
||||
|
||||
lines = []
|
||||
status = run(client(handler), ['01310100', '20040002'], ['1'], 10000, lines.append)
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(headers, ['tok-1', 'Bearer tok-1', 'Bearer tok-1'])
|
||||
self.assertIn('Bearer', lines[0])
|
||||
self.assertIn('R$ 12,50', lines[1])
|
||||
|
||||
def test_first_failure_stops_the_run(self):
|
||||
calls = []
|
||||
|
||||
def handler(request):
|
||||
calls.append(request)
|
||||
return httpx.Response(200, json={'frete': [{}], 'error': {'id': -1, 'descricao': 'CNPJ invalido'}})
|
||||
|
||||
lines = []
|
||||
self.assertEqual(run(client(handler), ['01310100', '20040002'], ['1', '2'], 10000, lines.append), 1)
|
||||
self.assertEqual(len(calls), 1)
|
||||
self.assertIn('CNPJ invalido', lines[0])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -36,6 +36,10 @@ class MercadoPagoTests(unittest.TestCase):
|
||||
self.requests.append(request)
|
||||
if request.method == 'GET':
|
||||
payment_id = request.url.path.rsplit('/', 1)[-1]
|
||||
if payment_id == '500':
|
||||
return httpx.Response(500, json={'message': 'internal_error'})
|
||||
if payment_id not in self.payments:
|
||||
return httpx.Response(404, json={'message': 'Payment not found'})
|
||||
return httpx.Response(200, json=self.payments[payment_id])
|
||||
body = json.loads(request.content)
|
||||
payment = {'id': 555, 'status': 'pending', 'status_detail': 'pending_waiting_transfer',
|
||||
@@ -78,6 +82,15 @@ class MercadoPagoTests(unittest.TestCase):
|
||||
self.assertEqual(self.requests[-1].headers['authorization'], 'Bearer TEST-token')
|
||||
self.assertIsNone(self.mp.parse(json.dumps({'type': 'merchant_order', 'data': {'id': '1'}}).encode()))
|
||||
|
||||
def test_notification_for_an_unknown_payment_is_acknowledged(self):
|
||||
# The panel's "Simular notificação" sends a payment id that does not
|
||||
# exist. Raising would answer 500 and Mercado Pago would retry for ever.
|
||||
body = json.dumps({'id': 43, 'type': 'payment', 'data': {'id': '123456'}}).encode()
|
||||
self.assertIsNone(self.mp.parse(body, {'data.id': '123456', 'type': 'payment'}))
|
||||
# Any other failure still raises, so a real notification is retried.
|
||||
with self.assertRaises(httpx.HTTPStatusError):
|
||||
self.mp.parse(json.dumps({'type': 'payment', 'data': {'id': '500'}}).encode(), {'data.id': '500'})
|
||||
|
||||
def test_amounts_outside_brl_centavos_are_not_trusted(self):
|
||||
for payment in ({'currency_id': 'USD', 'transaction_amount': 10},
|
||||
{'currency_id': 'BRL', 'transaction_amount': 10.001},
|
||||
|
||||