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_FOLHA=...
|
||||||
# TINY_PRODUCT_UV_AVULSA=...
|
# TINY_PRODUCT_UV_AVULSA=...
|
||||||
# TINY_ADAPTER=tiny # only once connected and tested: creates real orders
|
# 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
|
WHATSAPP_ADAPTER=fake
|
||||||
STORAGE_ADAPTER=s3-local
|
STORAGE_ADAPTER=s3-local
|
||||||
MOCK_FREIGHT_CENTS=1500
|
MOCK_FREIGHT_CENTS=1500
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ jobs:
|
|||||||
# the generator's geometry. The provider suites use a fake transport: they
|
# the generator's geometry. The provider suites use a fake transport: they
|
||||||
# prove the documented contract, not the integration.
|
# prove the documented contract, not the integration.
|
||||||
- name: Print-file geometry and provider adapters
|
- 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
|
- name: Runtime and retention regressions
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -98,7 +98,10 @@ def orders(identity=Depends(owner)):
|
|||||||
def detail(oid: UUID, identity=Depends(owner)):
|
def detail(oid: UUID, identity=Depends(owner)):
|
||||||
with db.connect() as c:
|
with db.connect() as c:
|
||||||
row = owned_order(c, oid, identity)
|
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'],
|
return {'id': row['id'], 'number': row['number'], 'state': row['state'], 'version': row['version'],
|
||||||
'snapshot': row['snapshot'], 'history': history, 'files': file_rows(c,oid)}
|
'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 '')
|
payment_id = str((query or {}).get('data.id') or (data.get('data') or {}).get('id') or '')
|
||||||
if not payment_id.isdigit():
|
if not payment_id.isdigit():
|
||||||
return None
|
return None
|
||||||
|
try:
|
||||||
payment = self.lookup(payment_id)
|
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', '')))
|
return event_from_payment(payment, notification_id=str(data.get('id', '')))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ POSTGRES_VOLUME=TBD
|
|||||||
OPERATOR_EMAIL=TBD
|
OPERATOR_EMAIL=TBD
|
||||||
|
|
||||||
PAYMENT_ADAPTER=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
|
FREIGHT_ADAPTER=TBD
|
||||||
TINY_ADAPTER=TBD
|
TINY_ADAPTER=TBD
|
||||||
# Tiny API v3 application (Configurações > Geral > Aplicativos in Tiny). The
|
# Tiny API v3 application (Configurações > Geral > Aplicativos in Tiny). The
|
||||||
|
|||||||
@@ -19,10 +19,20 @@ x-app-environment: &app-environment
|
|||||||
# this value is configured.
|
# this value is configured.
|
||||||
OPERATOR_EMAIL: ${OPERATOR_EMAIL:-}
|
OPERATOR_EMAIL: ${OPERATOR_EMAIL:-}
|
||||||
OPERATOR_PASSWORD: ${OPERATOR_PASSWORD:?set OPERATOR_PASSWORD}
|
OPERATOR_PASSWORD: ${OPERATOR_PASSWORD:?set OPERATOR_PASSWORD}
|
||||||
PAYMENT_ADAPTER: fake
|
# fake until Mercado Pago is configured; mercadopago requires MP_ACCESS_TOKEN
|
||||||
# Optional: without it the webhook verifies nothing and therefore accepts
|
# and MP_WEBHOOK_SECRET or the API and worker refuse to start. Test
|
||||||
# nothing, which is the correct state until a provider is connected. Set it
|
# credentials (TEST-...) until the sandbox flows have passed. The card form
|
||||||
# when the provider is configured, never to a value anyone could guess.
|
# 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:-}
|
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
|
# 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
|
quote cannot be charged twice. The Site CSP gains the Mercado Pago origins
|
||||||
only through `PAYMENT_CSP_SOURCES`, empty by default. Not yet rendered
|
only through `PAYMENT_CSP_SOURCES`, empty by default. Not yet rendered
|
||||||
against a real public key; sandbox run and refund policy remain.
|
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
|
- `[ ]` 1.2 — Real freight quotation. **Blocked on client inputs** (see
|
||||||
`PRODUCTION_INPUTS.md`): source platform, credentials, origin CEP, services,
|
`PRODUCTION_INPUTS.md`): source platform, credentials, origin CEP, services,
|
||||||
packaging weight/dimensions per length, subsidy policy.
|
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.
|
- `[~]` 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), API v3 by decision:** OAuth2 against Tiny's
|
**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
|
and fictional orders. The payment step has no screenshot until Mercado Pago
|
||||||
is configured, and the guides name the provisional Kanban domain; regenerate
|
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".
|
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
|
- `[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"
|
remove the inaccurate "Arquivo por metro permanece separado, com seleção explícita"
|
||||||
claim and the internal commit reference.
|
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
|
client.call('/operator/orders/'+oid+'/move',{'state':'fin','version':version},operator=True);version+=1
|
||||||
history=client.call('/operator/orders/'+oid+'/history',operator=True)
|
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']
|
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: 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')
|
print('PASS: all modes, authoritative review/prices/freight, tamper rejection, concurrent payment idempotency, transitions and history')
|
||||||
deadline=time.monotonic()+30
|
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)
|
self.requests.append(request)
|
||||||
if request.method == 'GET':
|
if request.method == 'GET':
|
||||||
payment_id = request.url.path.rsplit('/', 1)[-1]
|
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])
|
return httpx.Response(200, json=self.payments[payment_id])
|
||||||
body = json.loads(request.content)
|
body = json.loads(request.content)
|
||||||
payment = {'id': 555, 'status': 'pending', 'status_detail': 'pending_waiting_transfer',
|
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.assertEqual(self.requests[-1].headers['authorization'], 'Bearer TEST-token')
|
||||||
self.assertIsNone(self.mp.parse(json.dumps({'type': 'merchant_order', 'data': {'id': '1'}}).encode()))
|
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):
|
def test_amounts_outside_brl_centavos_are_not_trusted(self):
|
||||||
for payment in ({'currency_id': 'USD', 'transaction_amount': 10},
|
for payment in ({'currency_id': 'USD', 'transaction_amount': 10},
|
||||||
{'currency_id': 'BRL', 'transaction_amount': 10.001},
|
{'currency_id': 'BRL', 'transaction_amount': 10.001},
|
||||||
|
|||||||