Compare commits

...

4 Commits

Author SHA1 Message Date
Cauê Faleiros
9bea187ea4 feat: add a read-only Jadlog price probe
All checks were successful
Build and deploy / Validate source (push) Successful in 1m20s
Build and deploy / Integration suite on a real stack (push) Successful in 2m49s
Build and deploy / Secret scan and release gate (push) Successful in 10s
Build and deploy / Publish images (push) Successful in 1m51s
app/jadlog.py prices one package through Jadlog's Simulador de Frete as the
API manual v2.3 describes it; app.jadlog_probe prices test weights to six
regions on the client's account to confirm token, account and contract.
Tested against a fake transport. The roadmap records the Jadlog data and the
Mercado Pago account setup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:41:18 -03:00
Cauê Faleiros
bd56170248 feat: let production select Mercado Pago and acknowledge simulated notifications
The production compose hard-coded the fake payment adapter; it now takes
PAYMENT_ADAPTER and the MP_* settings from the stack's environment, so the
sandbox can run with test credentials. A signed notification about a payment
Mercado Pago does not have, such as the panel's "Simular notificação", is
acknowledged instead of answering 500 and being retried; any other lookup
failure still raises.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:41:18 -03:00
Cauê Faleiros
60b7336b37 docs: commit the operator guide's source and record the Week-2 report
The operator guide is now built from docs/guias/operador/ by
docs/guias/imprimir.sh, with the corrections on Tiny and the WhatsApp
notices. The roadmap records the guide, the history fix found while
writing it, and the Week-2 report as sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:07:58 -03:00
Cauê Faleiros
04e4cbc953 fix: hide operators' internal back-move reasons from the customer's order history
A move back undoes an operator's mistake and its reason is internal. The
customer's history now omits back moves and shows a reason only for a
correction; the smoke test checks both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:07:58 -03:00
25 changed files with 664 additions and 8 deletions

View File

@@ -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

View File

@@ -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: |

View File

@@ -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
View 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
View 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())

View File

@@ -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', '')))

View File

@@ -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

View File

@@ -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

View File

@@ -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.

Binary file not shown.

8
docs/guias/imprimir.sh Executable file
View 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"

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

View File

@@ -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
View 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()

View File

@@ -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},