first commit
Some checks failed
Validate, publish and deploy / validate (push) Successful in 2m2s
Validate, publish and deploy / publish-and-deploy (push) Failing after 8s

This commit is contained in:
Cauê Faleiros
2026-09-15 16:42:34 -03:00
commit 98c951d374
170 changed files with 95988 additions and 0 deletions

16
.dockerignore Normal file
View File

@@ -0,0 +1,16 @@
.git
.agents
.codex
.env
.venv
**/__pycache__
node_modules
output
tmp
*.pdf
agente
backups
staging/staging.env
deploy/portainer.env
portal
kanban

32
.env.example Normal file
View File

@@ -0,0 +1,32 @@
# LOCAL DEVELOPMENT ONLY. These are public disposable development values.
COMPOSE_PROJECT_NAME=dtf-cloud
SITE_PORT=8080
KANBAN_PORT=8081
API_PORT=8000
POSTGRES_DB=dtf_local
POSTGRES_USER=dtf_local
POSTGRES_PASSWORD=local-database-only
APP_DB_USER=dtf_app
APP_DB_PASSWORD=local-app-database-only
MINIO_ROOT_USER=dtf_local
MINIO_ROOT_PASSWORD=local-storage-only
S3_APP_USER=dtf_app
S3_APP_PASSWORD=local-app-storage-only
S3_BUCKET=dtf-local-artwork
S3_PUBLIC_ENDPOINT=http://localhost:9000
OPERATOR_USER=operator
OPERATOR_PASSWORD=local-operator-only
APP_ENV=local
PAYMENT_ADAPTER=fake
FREIGHT_ADAPTER=fake
TINY_ADAPTER=fake
WHATSAPP_ADAPTER=fake
STORAGE_ADAPTER=s3-local
MOCK_FREIGHT_CENTS=1500
MAX_UPLOAD_BYTES=5368709120
UPLOAD_PART_BYTES=8388608
STORAGE_QUOTA_BYTES=53687091200
OWNER_UPLOAD_QUOTA_BYTES=10737418240
MAX_PENDING_UPLOADS=10
# Files above 128 MiB remain blocked (ClamAV config must agree with this limit).
SCAN_MAX_BYTES=134217728

27
.env.exemplo Normal file
View File

@@ -0,0 +1,27 @@
# HISTORICAL PROTOTYPE ONLY. Do not use for the local stack; use .env.example.
# ---- portal (nuvem) ----
DATABASE_URL=postgresql+psycopg://dtf:senha@localhost/dtf
S3_ENDPOINT=https://<conta>.r2.cloudflarestorage.com
S3_BUCKET=dtf-artes
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
BASE_PORTAL=https://arte.dropstaratacado.com.br
BASE_KANBAN=http://servidor:8080
WEBHOOK_TOKEN=troque-isto
AGENTE_TOKEN=troque-isto-tambem
AGENTE_WEBHOOK=http://ip-da-fabrica:8080/api/agente/acordar
# ---- Tiny ----
TINY_CLIENT_ID=
TINY_CLIENT_SECRET=
TINY_TOKEN_URL=
TINY_BASE=https://api.tiny.com.br/public-api/v3
# ---- WhatsApp ----
WHATS_PROVEDOR=meta
WHATS_TOKEN=
WHATS_NUMERO_ID=
# ---- fábrica ----
PASTA_DTF=\\servidor\DTF
KANBAN_DB=C:\dtf\kanban.db

109
.gitea/workflows/deploy.yml Normal file
View File

@@ -0,0 +1,109 @@
name: Validate, publish and deploy
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
jobs:
validate:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- name: Validate source and deployment definitions
run: |
python3 -m py_compile local/*.py deploy/*.py
python3 -m unittest local.test_dependency_lock local.test_staging_readiness deploy.test_production_preflight local.test_pricing -v
sh -n local/lock_dependencies.sh
docker compose config --quiet
docker compose -f compose.staging.yaml config --quiet
set -a
. deploy/portainer.env.example
set +a
docker stack config --compose-file deploy/stack.yaml >/dev/null
publish-and-deploy:
needs: validate
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 75
env:
COMPOSE_PROJECT_NAME: dtf-release-${{ gitea.run_number }}
REGISTRY_HOST: ${{ vars.REGISTRY_HOST }}
REGISTRY_OWNER: ${{ vars.REGISTRY_OWNER }}
PYTHON_BASE_IMAGE: ${{ vars.PYTHON_BASE_IMAGE }}
NGINX_BASE_IMAGE: ${{ vars.NGINX_BASE_IMAGE }}
TRIVY_IMAGE: ${{ vars.TRIVY_IMAGE }}
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- name: Require production-capable application source
run: python3 deploy/production_preflight.py --source-only
- name: Validate immutable build inputs
run: |
for value in "$PYTHON_BASE_IMAGE" "$NGINX_BASE_IMAGE" "$TRIVY_IMAGE"; do
echo "$value" | grep -Eq '^[a-z0-9][a-z0-9._:/-]*@sha256:[0-9a-f]{64}$'
echo "$value" | grep -Ev '@sha256:0{64}$' >/dev/null
done
case "$REGISTRY_HOST/$REGISTRY_OWNER" in *[A-Z]*|*' '*|'/'*) exit 2;; esac
- name: Run complete isolated regression suite
run: |
docker compose up --build -d --wait
python3 -m local.security_test
python3 -m local.scanning_test
python3 -m local.smoke_test
python3 -m local.workflow_test
docker compose exec -T api python -m local.runtime_security_test
docker compose exec -T api python -m local.retention_test
node local/browser_test.mjs
python3 -m local.backup create-and-verify
- name: Build production images
run: |
api_sha="$REGISTRY_HOST/$REGISTRY_OWNER/dtf-api:${{ gitea.sha }}"
api_latest="$REGISTRY_HOST/$REGISTRY_OWNER/dtf-api:latest"
web_sha="$REGISTRY_HOST/$REGISTRY_OWNER/dtf-web:${{ gitea.sha }}"
web_latest="$REGISTRY_HOST/$REGISTRY_OWNER/dtf-web:latest"
docker build --pull --file deploy/Dockerfile.api \
--build-arg PYTHON_BASE_IMAGE="$PYTHON_BASE_IMAGE" \
--build-arg VCS_REF="${{ gitea.sha }}" \
--tag "$api_sha" --tag "$api_latest" .
docker build --pull --file deploy/Dockerfile.web \
--build-arg PYTHON_BASE_IMAGE="$PYTHON_BASE_IMAGE" \
--build-arg NGINX_BASE_IMAGE="$NGINX_BASE_IMAGE" \
--build-arg VCS_REF="${{ gitea.sha }}" \
--tag "$web_sha" --tag "$web_latest" .
- name: Block secret, configuration and image findings
run: |
api="$REGISTRY_HOST/$REGISTRY_OWNER/dtf-api:${{ gitea.sha }}"
web="$REGISTRY_HOST/$REGISTRY_OWNER/dtf-web:${{ gitea.sha }}"
docker run --rm --volume "$PWD:/src:ro" --workdir /src "$TRIVY_IMAGE" \
fs --scanners secret --exit-code 1 --severity HIGH,CRITICAL .
docker run --rm --volume "$PWD:/src:ro" --workdir /src "$TRIVY_IMAGE" \
config --exit-code 1 --severity HIGH,CRITICAL deploy
docker run --rm --volume /var/run/docker.sock:/var/run/docker.sock:ro "$TRIVY_IMAGE" \
image --exit-code 1 --scanners vuln --severity HIGH,CRITICAL "$api"
docker run --rm --volume /var/run/docker.sock:/var/run/docker.sock:ro "$TRIVY_IMAGE" \
image --exit-code 1 --scanners vuln --severity HIGH,CRITICAL "$web"
- name: Publish latest and rollback tags
env:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
trap 'docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true' EXIT
echo "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" --username "$REGISTRY_USERNAME" --password-stdin
docker push "$REGISTRY_HOST/$REGISTRY_OWNER/dtf-api:${{ gitea.sha }}"
docker push "$REGISTRY_HOST/$REGISTRY_OWNER/dtf-api:latest"
docker push "$REGISTRY_HOST/$REGISTRY_OWNER/dtf-web:${{ gitea.sha }}"
docker push "$REGISTRY_HOST/$REGISTRY_OWNER/dtf-web:latest"
- name: Trigger the Portainer stack webhook
env:
PORTAINER_WEBHOOK: ${{ secrets.PORTAINER_WEBHOOK }}
run: |
test -n "$PORTAINER_WEBHOOK"
curl --fail --silent --show-error --max-time 30 --request POST "$PORTAINER_WEBHOOK"
- name: Stop isolated test stack
if: always()
run: docker compose down --volumes --remove-orphans

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
.env
.venv/
__pycache__/
*.pyc
node_modules/
test-results/
playwright-report/
backups/
staging/staging.env
deploy/portainer.env

328
API.md Normal file
View File

@@ -0,0 +1,328 @@
# API — contratos
> Historical prototype contracts only. The active local API is documented at
> [localhost:8000/docs](http://localhost:8000/docs) and in [LOCAL_SETUP.md](LOCAL_SETUP.md).
> `CONTEXT.md` overrides the legacy endpoints and workflows below.
> Todos os endpoints do módulo DTF, com requisição e resposta.
> Base: `portal/main.py` e `kanban/main.py`.
---
## Autenticação
| Quem chama | Como se autentica |
|---|---|
| Tiny (webhook) | header `x-token` com `WEBHOOK_TOKEN` |
| Cliente | token no path da URL — sem login |
| Agente | header `x-token` com `AGENTE_TOKEN` |
| Kanban (aba do PCP) | sessão do PCP — **rede interna, sem porta para fora** |
---
# PORTAL · na nuvem
## `POST /webhook/tiny`
Chamado quando nasce um pedido de DTF. **É o gatilho de tudo.**
```json
{ "numero": "48213", "cliente": "Estamparia Vitória",
"telefone": "5516999998888", "metros": 2.4, "cliente_novo": false }
```
**200**
```json
{ "ok": true, "link": "https://arte.dropstaratacado.com.br/arte/a7f3k9..." }
```
Cria o pedido, gera token de 7 dias e enfileira o WhatsApp com o link.
Se o número já existir, devolve `{"ok":true,"ja_existia":true}` sem duplicar.
**Latência importa.** O link só sai depois que o pedido existe no Tiny — se a
integração VNDA → Tiny demorar, o cliente espera. Medir antes de subir.
---
## `GET /arte/{token}`
Página de upload. HTML. O token já sabe o pedido — **o cliente não digita nada.**
## `GET /api/arte/{token}`
```json
{ "pedido": "48213", "cliente": "Estamparia Vitória", "metros": 2.4,
"area_util_cm": 97, "largura_max_cm": 57,
"limite_arquivo_m": 20, "max_gb": 5, "max_arquivos": 10 }
```
**410** se o token expirou.
## `POST /api/arte/{token}/url`
```json
{ "nome": "estampa.png", "bytes": 84000000 }
```
```json
{ "url": "https://r2.../48213/ab12_estampa.png?X-Amz-...",
"chave": "48213/ab12_estampa.png", "expira_em": 3600 }
```
**Upload direto para o storage.** Arquivo de 5 GB passando pelo VPS derrubaria
o processo. Em partes, para arquivos grandes.
## `POST /api/arte/{token}/pronto`
```json
{ "chave": "48213/ab12_estampa.png", "repeticoes": 20 }
```
```json
{ "arte_id": 991, "status": "processando" }
```
Dispara o pré-flight em background. O cliente acompanha por polling em
`GET /api/arte/{token}/status`.
## `GET /api/arte/{token}/status`
**Aprovada:**
```json
{ "status": "aprovada", "dpi_efetivo": 300, "qualidade_pct": 100,
"metros_totais": 48.0, "minutos_maquina": 144,
"partes": [{"ordem":1,"metros":20,"nome":"48213_p1.png"},
{"ordem":2,"metros":20,"nome":"48213_p2.png"},
{"ordem":3,"metros":8,"nome":"48213_p3_carimbado.png"}],
"avisos": [], "previsao_saida": "2026-09-02T18:00:00-03:00" }
```
**Recusada:**
```json
{ "status": "recusada",
"motivo": "A resolução real da arte é de 72 DPI no tamanho que você comprou. Precisamos de pelo menos 150 DPI — o ideal é 300. Reenvie em maior resolução." }
```
**O relógio do prazo só começa quando `status = aprovada`.** Arquivo ruim
enviado às 17h não faz as horas correrem contra a casa.
---
## `GET /api/artes` · o agente busca
Header `x-token`. Devolve o que está aprovado, com vírus liberado e ainda não baixado.
```json
[{ "arte_id": 991, "pedido": "48213", "cliente": "Estamparia Vitória",
"metros": 48.0, "minutos_maquina": 144, "conferir_manual": false,
"partes": [{"ordem":1,"metros":20,"nome":"48213_p1.png",
"url":"https://r2.../...","sha256":"a3f9..."}] }]
```
## `POST /api/artes/{id}/baixada` · confirma o download
## `POST /api/agente/heartbeat` · a cada 5 min
**Sem sinal por 15 minutos, alertar o TI.** Serviço silencioso parado é pior que
erro barulhento: ninguém percebe até o cliente cobrar.
---
## `GET /cliente/{token_cliente}` · minhas artes
Link permanente do cliente, não do pedido. Lista as artes tratadas dos últimos
12 meses.
## `POST /cliente/{token_cliente}/reimprimir`
```json
{ "arte_id": 812, "metros": 20 }
```
Abre pedido novo no Tiny com a arte já pronta. **Recompra sem atrito.**
---
# KANBAN · rede interna, aba do PCP
## `GET /api/quadro`
```json
{ "colunas": [{"id":"rec","nome":"Arte recebida"}, ...],
"cards": {
"fil": [{ "arte_id": 991, "pedido": "48213", "cliente": "Estamparia Vitória",
"metros": 48.0, "minutos_maquina": 144, "partes": 3,
"maquina": null, "desde": "2026-09-02T14:02:00",
"parado_seg": 4320, "conferir_manual": false }]
},
"maquinas": [{ "n": 1, "ocupada": true, "pedido": "48190",
"metros": 9.0, "rodando_seg": 720 },
{ "n": 3, "ocupada": false }] }
```
`maquinas` é o que pinta o **círculo vermelho**. Com seis máquinas vendo o mesmo
quadro, é o que evita dois operadores no mesmo arquivo.
---
## `POST /api/puxar`
```json
{ "maquina": 4, "usuario": "alexandre" }
```
```json
{ "maquina": "Maq 4", "pedido": "48197", "metros": 18.0,
"minutos": 60, "reserva_expira_em": 3 }
```
**Um pedido por vez** e **reserva de 3 minutos** — ajustes pedidos pela sala.
Pega sempre o mais antigo da fila. Se não entrar em `imp` em 3 min, volta.
**409** se a máquina já estiver ocupada. **404** se a fila estiver vazia.
## `POST /api/devolver`
```json
{ "arte_id": 991, "usuario": "thales", "motivo": "travou no meio" }
```
Volta ao **topo** da fila, não ao fim — o pedido já esperou uma vez.
## `POST /api/mover`
```json
{ "arte_id": 991, "para": "fin", "usuario": "poliana", "maquina": 4 }
```
Grava o movimento **antes** de qualquer integração externa. Se o Tiny estiver
fora, o job fica na fila e tenta de novo em 1, 5 e 30 min — **mas a medição de
tempo já está salva.**
Move o arquivo entre as pastas e enfileira marcador e WhatsApp quando a coluna
pedir. Ao ir para `apc`, pergunta o motivo da prova de cor.
## `PATCH /api/card/{arte_id}`
```json
{ "minutos_maquina": 90 }
```
Ajuste da estimativa. **O sistema sugere por `metros/20*60`; quem trata a arte
corrige só quando foge do normal.** É esse número que soma a fila contra a
capacidade do dia.
## `GET /p/{pedido}`
O QR do carimbo aponta para cá. Redireciona para o card. **A revisão bipa e cai
na tela do pedido** em vez de procurar na lista.
---
## Retrabalho
### `POST /api/retrabalho`
```json
{ "pedido_origem": "48184", "metros": 2.0, "causa": "impressao",
"aberto_por": "mayana", "evidencia": "print-whatsapp.jpg" }
```
```json
{ "id": 44, "alcada": "sala", "vez": 1, "conta_na_meta": true }
```
**A Mayana abre e classifica** — conhece o cliente e a reclamação.
**A causa fica travada.** Quem discorda contesta, não altera.
### `POST /api/retrabalho/{id}/autorizar`
```json
{ "usuario": "thales" }
```
**Thales ou Alexandre autorizam**, porque o retrabalho da casa entra na meta
deles. Eles confirmaram achar justo.
**403** se a alçada exigir financeiro ou diretoria.
### `POST /api/retrabalho/{id}/contestar`
```json
{ "usuario": "alexandre", "texto": "não foi impressão, a arte veio em CMYK" }
```
Registra a discordância sem mudar a causa. Fica com quem pediu e quem decidiu.
---
## Relatórios
### `GET /api/relatorio/etapas?dias=7`
```json
[{ "coluna": "tra", "nome": "Arte tratada", "movimentos": 84,
"media_min": 94, "pior_min": 380 }]
```
**Responde a pergunta que decide o terceiro turno:** quanto do tempo é fila e
quanto é trabalho. Se a arte demora 6h e a máquina imprime em 40 min, rodar 24h
só faz a fila esperar de madrugada.
### `GET /api/relatorio/impressao?dias=7`
```json
[{ "maquina": "Maq 1", "pedidos": 42, "media_min": 34,
"metros": 310.5, "m_por_hora": 20.4 }]
```
**`m_por_hora` valida ou derruba os 20 m/h.** Todo o cálculo de capacidade —
60.480 metros/mês, R$ 148 mil de ganho — depende desse número. Se for 14, a
conta muda inteira.
### `GET /api/relatorio/aproveitamento?dias=30`
```json
{ "metros_faturados": 11605, "metros_de_filme": 12693,
"aproveitamento_pct": 91.4, "valor_do_ponto_mes": 980,
"fonte": "transferências para o depósito Sala DTF no Tiny" }
```
**Não exige apontamento novo.** O consumo já é registrado quando o insumo é
transferido para o depósito de impressão — a Altus faz isso porque também
revende insumo.
### `GET /api/relatorio/retrabalho?dias=30`
```json
{ "por_causa": [{ "causa": "impressao", "descricao": "Falha de impressão",
"responsavel": "Thales e Alexandre", "pedidos": 9,
"metros": 24.5, "pct": 1.1, "meta": 0.4, "bate": false }],
"total_casa_pct": 2.9, "meta_casa_pct": 1.6 }
```
**⚠ A meta ainda não está decidida.** A proposta era 0,4% por causa; a sala
respondeu que "meta geral seria melhor". `META_POR_CAUSA` está isolado no código
para trocar sem mexer no resto.
---
## Códigos de erro
| Código | Quando |
|---|---|
| 400 | payload inválido, coluna inexistente, mais de 10 arquivos |
| 401 | token do webhook ou do agente errado |
| 403 | alçada insuficiente para autorizar retrabalho |
| 404 | token não existe, card não encontrado, fila vazia |
| 409 | máquina já ocupada, pedido já reservado |
| 410 | token do link expirou |
| 413 | arquivo acima de 5 GB |
| 429 | rate limit — 20 req/min por token |
---
## O que testar antes de dar por pronto
- [ ] Upload de arquivo de 5 GB sem derrubar o VPS
- [ ] Dois operadores clicando `puxar` ao mesmo tempo — só um pega
- [ ] Reserva expirando: puxar e não mover em 3 min devolve à fila
- [ ] Tiny fora do ar: o movimento grava e o job fica na fila
- [ ] Agente sem internet: para, e ao voltar baixa o acumulado
- [ ] Arquivo baixado pela metade não aparece no kanban
- [ ] Token expirado devolve 410 com mensagem clara ao cliente

392
CONTEXT.md Normal file
View File

@@ -0,0 +1,392 @@
# DTF System - Project Context for AI Agents
## Purpose of this document
Read this document before changing code, documentation, architecture, or scope.
It is the current English source of truth for the project. It supersedes older
decisions in `README.md`, `ESPECIFICACAO.md`, `schema.sql`, and the existing
prototype code whenever they conflict.
Update this file whenever the team makes a material product, process, or
architecture decision.
## Project goal
Build a DTF ordering and production-flow system for Dropstar/Altus.
The current process is slow and manual: customers send artwork through
WhatsApp, staff forward files, designers discover problems late, and production
status is not visible outside the factory. The business has substantially more
machine capacity than it currently uses. The goal is to let customers place DTF
orders online, pay, send artwork, and follow production without making
WhatsApp, shared folders, or manual handoffs the bottleneck.
The customer-facing value is speed and clarity. The operations value is a
traceable queue and fewer lost or manually handled orders.
## Current MVP scope
The MVP must be delivered in **at most three weeks**. The target is a working
online system, not factory-machine automation.
### Included
- Dedicated DTF site/subdomain.
- Existing Site DTF commercial rules and product experience.
- Direct, resumable multipart file upload to Cloudflare R2.
- Mercado Pago payment integration with signed and idempotent webhooks.
- Freight quotation and charging at checkout.
- Idempotent Tiny/Olist order integration and order traceability.
- Online Kanban for production status and secure file download.
- WhatsApp status notifications.
- Private object storage, 30-day file retention, backups, and basic security.
- Operator guidance for the factory team.
### Explicitly outside the three-week delivery
- Automatic pre-flight validation. It will be validated after delivery using
real customer files and real printed output, then refined as support work.
- Direct FlexiPRINT integration.
- Factory-side agent, hot folder, internal file server automation, VPN, and
heartbeat monitoring.
- Production-room dashboard, printer/machine telemetry, and advanced reports.
- Automated shipping-label purchase, shipping-label printing, and dispatch
automation. The MVP freight scope is quote selection and charging only.
- Personalized cart behaviour based on past orders, day, or time.
After delivery, the team provides support and evaluates requested changes. Do
not turn post-delivery support into a new committed delivery phase without an
explicit decision.
## Agreed production model
```text
Customer browser
-> Site DTF / API on VPS
-> direct multipart upload to private Cloudflare R2
-> Mercado Pago payment + freight selected at checkout
-> Tiny/Olist order record
-> online Kanban
-> factory operator downloads final file and imports it manually into FlexiPRINT
```
WhatsApp is a notification channel, not the artwork-upload channel. It is used
for payment confirmation, correction requests, production status, and order
completion.
The operator works through the browser. The factory does not need an installed
agent or an internal server for the MVP.
If the factory internet connection fails, only the factory team temporarily
loses access to the Kanban and secure downloads. The public portal, payments,
uploads, R2 objects, queue, and cloud services remain online. Factory work
resumes when local access returns.
## Infrastructure and deployment
- **VPS:** runs the website, API, worker, PostgreSQL, ClamAV, Kanban, and
third-party integrations.
- **Cloudflare R2:** private S3-compatible object storage for original uploads
and final files. It does not run the application, database, worker, or
antivirus.
- **Upload path:** the browser uploads directly to R2 using short-lived,
server-issued presigned multipart URLs. Large files must never be proxied
through the VPS.
- **Deployment:** one Portainer-owned `dtf-cloud` Docker Swarm stack. One Gitea
Actions workflow tests/scans, publishes `latest` plus commit-SHA application
images, and calls the Portainer webhook. Activation remains blocked pending
the production work listed below.
- **Recommended VPS baseline:** 4 vCPU, 16 GB RAM, and 200 GB NVMe. Existing
VPS capacity may be used if it safely meets or exceeds this baseline.
- **Backups:** PostgreSQL backups go to R2. Application secrets are never
committed to Git.
## File retention
The business does not want an unlimited artwork library.
| Artifact | Retention |
|---|---:|
| Incomplete multipart upload | 1 day |
| Rejected or infected upload | 3 days |
| Customer original after approval | Up to 7 days |
| Final print artwork | Maximum 30 days from the first upload |
| Order history and metrics | Kept in the database without keeping the image |
The 30-day retention decision overrides older references to 90 days or
12-month artwork reordering.
## R2 planning cost reference
R2 cost is driven primarily by the average number of gigabytes stored during a
month; read/write operations are expected to be a much smaller cost at the
project's scale. There is no egress charge in the current planning model.
The client-facing roadmap uses the following planning examples, based on
standard R2 storage pricing, the included storage allowance, and an exchange
rate reference of USD 1 = BRL 5.13:
| Average storage | Approx. R2/month | Approx. BRL/month |
|---|---:|---:|
| 100 GB | USD 1.35 | BRL 7 |
| 500 GB | USD 7.35 | BRL 38 |
| 1 TB | USD 14.85 | BRL 76 |
| 3 TB | USD 44.85 | BRL 230 |
These are planning estimates only. Confirm Cloudflare pricing, exchange rates,
taxes, and payment-provider fees before presenting a commercial quote.
## Commercial rules
The existing rules in `dtf-site.html` are approved as the current source of
truth. Do **not** redesign, simplify, or change prices, discounts, minimums,
rounding, or product modes without explicit approval.
The current site contains four product modes, quality-based price tiers,
artwork-ready discounts, separate assembly charges for loose artwork, a
one-metre minimum, and ten-centimetre billing rounding.
The browser may display the calculation, but production payment creation must
recalculate and validate all price, quantity, freight, and discount values on
the server. Client-provided totals are never authoritative.
## Freight
The original visual freight flow in `dtf-site.html` was a stub with only pickup
functional. The localhost bridge now supports pickup and backend fake freight
quotes; there is still no real carrier quotation or production checkout.
The customer already uses Correios and other shipping platforms. Before freight
can be completed, obtain:
- The platform(s) that should be used as the source of truth.
- API credentials or delegated access for the selected platform.
- Origin postal code/address.
- Available services and carriers to offer.
- Packaging weight and dimensions by DTF length/package.
- Freight business policy: exact pass-through, subsidy, free-shipping rules,
pickup, or other exceptions.
At checkout, the backend must quote freight from the selected source, store the
chosen service and quoted amount, include the amount in the Mercado Pago
payment, and persist it in the order. Do not create a payment before the freight
amount is final.
## Integrations
### Mercado Pago
Required before production payment integration, not for the local mock milestone:
- Production credentials.
- Webhook configuration/access.
Webhook processing must verify authenticity and be idempotent. A duplicate or
late delivery must not create a duplicate payment, order, message, or queue
card.
### Tiny/Olist
Tiny/Olist is already available. The implementation must create/update orders
idempotently and use the order number for traceability. Confirm the actual API
endpoints, marker/tag behaviour, and rate limits before production use.
### WhatsApp
WhatsApp is already available, but the technical provider still needs to be
confirmed (official Meta Cloud API, Z-API, or another provider).
Implement only the required customer events:
1. Payment approved.
2. Artwork correction needed, with a secure link back to the site.
3. Order entered production.
4. Order ready for collection or shipping.
Messages initiated by the business may require approved templates depending on
the provider and conversation state. Use an outbox/job mechanism with retries,
delivery status handling, and idempotency. Do not send artwork through
WhatsApp.
## Factory responsibilities after delivery
The client/factory team is responsible for:
- Opening the Kanban, downloading final files, and importing them manually into
the current FlexiPRINT setup.
- Performing real print tests and reporting mismatches in the final file or
printed result.
- Maintaining PCs, printers, FlexiPRINT licences, printing profiles, internal
folders, and the local factory network.
- Informing the development team about changes to prices, freight, operational
rules, or external platforms that need system changes.
The development team delivers the online system, provides usage guidance, and
supports subsequent corrections or scoped enhancements.
## Three-week roadmap
| Week | Delivery | Result |
|---|---|---|
| 1 | Infrastructure and upload | VPS, domain, database, R2, multipart upload, antivirus, and service foundation. |
| 2 | Payment, freight, and order | Mercado Pago, freight quotation, idempotent Tiny/Olist order creation, final file generation, and main Kanban states. |
| 3 | Kanban and handover | Online Kanban, secure download, WhatsApp status messages, 30-day retention, and operational handover. |
## Known implementation status
### Active localhost milestone (2026-09-15)
The current implementation target is localhost before any production connection.
Use `compose.yaml`, `.env.example`, and `LOCAL_SETUP.md`. The runtime is in
`local/`; historical `portal/`, `kanban/`, `agente/`, and `schema.sql` are preserved
as references and are not imported or started by Compose.
- One local `dtf-cloud` Compose project runs seven long-lived services: Site and
Kanban web gateways, FastAPI Portal/API, PostgreSQL, MinIO, ClamAV, and a
PostgreSQL outbox/scanning worker. Separate database and storage initialization
jobs provision restricted runtime identities, for nine Compose services total.
- MinIO implements the S3 storage boundary with direct multipart browser uploads,
resumable parts, private objects, and short-lived signed operator downloads.
- Payment, freight, Tiny/Olist, and WhatsApp use fake adapters only. No production
credentials or integrations are configured. The API and worker have no external
network route; only local web/storage gateways publish loopback ports.
- The original Site commercial calculation and appearance remain the source of
truth. Its existing browser artwork analysis is retained as prototype advice;
no new automatic pre-flight, server artwork analysis, or print-file generation
is implemented or invoked.
- Since client length and grade could be tampered with and automatic pre-flight
is deferred, a local operator manually confirms those commercial inputs before
checkout. This is a development trust-boundary decision, not a new production
promise. The backend calculates all tiers, discounts, assembly-inclusive rates,
one-metre minimum, ten-centimetre rounding, freight, and final totals. Immutable
approved quotes expire after 24 hours. A customer confirms the server total
on the Site to create one idempotent local paid order.
- Paid orders use `rec`, `tra`, `fil`, `imp`, `cor`, `fin`, with allowed transitions,
operator authentication, concurrency checks, and durable movement history.
Local mock integration receipts are visible in the Kanban.
- The customer portal at `/portal.html` supports local registration/login,
stronger scrypt password hashing with legacy-hash upgrade, revocable HttpOnly
sessions, owned order history/status, secure active
file downloads, and correction uploads. Registration/login can claim only the
current guest session's records, never orders found by email or CNPJ. Email
verification and password recovery are not implemented.
- Unfinished carts (including File blobs) recover from IndexedDB for 24 hours in
the same browser, scoped to the guest/account identity. Recovered items can be
removed/replaced or joined by new items; in-place reconstruction of the artwork
editor and cross-device cart synchronization are not implemented. Browser
storage capacity limits apply. Payment clears the saved cart. Logout revokes
the server session and clears local checkout metadata and saved File blobs
across open Site tabs.
- Operators manually upload and approve a complete final-file set, with one or
more files per order item. Queue entry requires active final files for every
item. Corrections invalidate the old set; customers submit corrections through
their portal, and operators reapprove final files. No artwork transformation,
automatic pre-flight, or machine control is performed.
- Every completed upload is quarantined pending a local ClamAV scan. Only `clean`
files can be quoted, commercially approved, paid, downloaded, attached as final
files, or admitted to the print queue. Rejected/error files remain blocked and
expire within three days. The isolated scanner uses signatures bundled in its
pinned image and has no external network route. The transport accepts files up
to 5 GiB, but the local scan/release limit is 128 MiB; larger files remain
blocked. This malware gate is not print pre-flight or artwork validation.
- A retention worker removes expired object bytes but keeps order/file metadata:
incomplete uploads after one day, originals within seven days of manual final
artwork approval, and attached final/correction files within 30 days of the
order's first upload. Storage lifecycle is also a 30-day backstop.
- Structured security events are written to logs and PostgreSQL. The local
`security_status` command summarizes authentication, rate-limit, scan, and
scanner/signature alerts without exposing secrets. Security regression tests,
exact-runtime Python dependency auditing, and Trivy image reports live under
`local/` and `output/security/`; open findings are documented in
`SECURITY_REPORT.md` and are not a production-readiness claim.
- All direct and transitive Python packages are pinned with artifact hashes in
`local/requirements.lock`; the API image build requires those hashes. The lock
is regenerated in a disposable Python 3.12 container by
`local/lock_dependencies.sh`. A fresh exact-runtime audit found no known Python
advisories on 2026-09-15; this does not cover OS/container findings.
- `compose.staging.yaml` is a separate, network-disabled readiness gate only. It
validates non-secret staging decisions and rejects placeholders, local endpoints,
fake providers, embedded secret settings, and unsafe secret sources. It does not
deploy the application, contain credentials, or contact any real provider.
- A separate production delivery package now exists under `deploy/`, with
non-root API/web image definitions, a single Portainer Docker Swarm stack,
external secret/volume contracts, health-monitored rolling updates,
commit-SHA rollback images, and one protected Gitea workflow under
`.gitea/workflows/`. The package never contains provider credentials and has
not been deployed. Its fail-closed preflight intentionally rejects the current
source until production adapters, Docker-secret file loading, approved inputs,
restore rehearsal, image scans, and human security approval are complete.
- `python3 -m local.backup create-and-verify` creates a private, Git-ignored bundle
containing a PostgreSQL dump plus every complete, unexpired object already marked
`clean`. SHA-256 manifests protect both parts. Verification restores the database
under a UUID name and the object bytes under a UUID MinIO prefix, hashes the
restored bytes, then removes only those temporary targets. Active data is never
overwritten; pending, rejected, errored, expired, and purged objects are excluded.
Run this local snapshot while uploads and retention are idle. Scheduling, offsite
copies, and production restore operations remain unfinished.
- Real providers, production authentication hardening, email verification/recovery,
automatic final-file generation, production backup operations, and production
activation remain incomplete. Deployment plumbing exists but cannot pass its
release gate yet. No factory automation or pre-flight is added.
See `IMPLEMENTATION_REPORT.md` for verification, `PRODUCTION_INPUTS.md` for the
decisions and evidence required before staging or production connection, and
`PORTAINER.md` for the production delivery contract.
The client roadmap promises are unchanged, so its PDF source is not regenerated
for this local implementation checkpoint.
### Existing assets
- `dtf-site.html`: rich static front-end prototype. It includes the current
commercial rules, client-side artwork analysis, current-session cart, and
delivery UI. The localhost checkout bridge connects it to the new local API.
- `portal/`: early FastAPI prototype for the original Tiny-first, token-link
upload flow.
- `kanban/`: early FastAPI/SQLite Kanban prototype.
- `agente/`: factory-side agent prototype; out of current MVP scope.
- `tmp/pdfs/generate_dtf_report.py`: generator for the current client-facing
roadmap PDF.
- `output/pdf/dtf-plano-producao-e-roadmap.pdf`: current generated roadmap.
- `Reunião iniciada às 2026_09_09 09_39 GMT-03_00 - Anotações do Gemini.pdf`:
meeting notes that established the business direction. Treat it as context;
the latest decisions in this document define the active scope.
### Important gaps and stale assumptions
- The original Site had no backend payment, freight quote, order persistence,
authentication, purchase history, or real cart persistence. Local order
persistence, reviewed quotes, mock freight and fake payment now work through
`local/static/checkout.js`. The local customer portal now provides accounts and
order history; cart recovery is browser-local. Production account verification,
recovery, and cross-device cart editing are still absent.
- Without the local bridge, the original freight fallback remains a stub.
Real freight quotation remains unimplemented in all runtimes.
- The existing `portal/whats.py` and `kanban/whats.py` are duplicated and send
direct text messages. They are not production-ready notification modules.
- Older documentation and code describe a Tiny webhook creating an upload link,
a factory agent, local Kanban, FlexiPRINT automation, 90-day retention, and
12-month artwork reuse. Those are not the current MVP model.
## Documentation synchronization
`context.md` is the compact operating context for agents. The current
client-facing narrative, diagrams, and formatting live in
`output/pdf/dtf-plano-producao-e-roadmap.pdf`, generated from
`tmp/pdfs/generate_dtf_report.py`.
When a decision changes, update both the relevant implementation context here
and the roadmap source when it changes what the client-facing plan promises.
## Change-control rules for agents
- Preserve the current Site DTF commercial rules unless explicitly asked to
change them.
- Do not add factory-side automation, a local agent, hot folders, FlexiPRINT
control, machine dashboards, or a new post-MVP delivery commitment by
inference.
- Do not claim that R2 hosts or executes application services.
- Keep all customer-facing and internal documentation in English only when this
file is the requested artifact; otherwise follow the user's requested
language.
- When a requirement is unclear, distinguish between the MVP, a future
enhancement, and an existing prototype assumption before changing code.

307
ESPECIFICACAO.md Normal file
View File

@@ -0,0 +1,307 @@
# Módulo DTF no PCP — especificação de implementação
> Para o Wagner. Consolida o desenho depois das reuniões com os designers e com
> a sala de impressão em 02/09/2026.
> Complementa o `README.md` (arquitetura) e o código em `portal/`, `agente/` e
> `kanban/`.
---
## O que mudou depois das reuniões
Sete decisões vieram da equipe e **já estão refletidas no código**. Se algo no
código parecer estranho, provavelmente é uma delas.
| Item | Era | Ficou | Quem definiu |
|---|---|---|---|
| Reserva ao puxar | 15 min | **3 min** | sala |
| Puxar trabalho | até 30 min | **um pedido por vez** | sala |
| Marcadores de duração | seriam apagados | **viram campo `minutos_maquina`** | sala |
| Hot folder em rede | a confirmar | **aceita** | sala |
| Production Manager | a confirmar | **centraliza, mas o PC central precisa de mais capacidade** | sala |
| Licenças do Flexi | a confirmar | **por PC** | sala |
| Meta de retrabalho | 0,4% por causa | **rever: a sala prefere meta geral** | sala |
---
## Números que a equipe deu e que entram no cálculo
| Dado | Valor | De onde veio |
|---|---|---|
| Arquivos que chegam usáveis | **30%** | designers |
| Tempo para tratar arquivo bom | **3 minutos** | designers |
| Tempo para tratar arquivo ruim | **1h30** | designers |
| Formatos que mais chegam | **PNG e PDF** | designers |
| PDF que chega | **vetorial** | designers |
| SPOT | **sempre igual** | designers |
| Cores que sempre dão problema | azul, vermelho, castanho, laranja, verde — **secundárias** | sala |
| Prova de cor hoje | **não mandam para ninguém** | sala |
| Como o operador sabe o próximo | **notinha do pedido** | sala |
| Tempos de setup | **"não temos informação"** | sala |
**O de 30% é o mais consequente.** Sete de cada dez arquivos passam pelo
designer. Se o pré-flight barrar metade dos ruins na entrada, são ~3,5 arquivos
em 10 que deixam de consumir tempo de arte.
---
## Fases de implementação
Cada fase entrega valor sozinha. **Não espere a fase 4 para colocar algo no ar.**
### Fase 1 · Limpeza do Tiny — pode começar hoje
Não depende de nada nem de ninguém.
- [ ] Criar os marcadores `DTF-PRODUCAO`, `DTF-PRONTO` e `DTF-PROVA-COR`
- [ ] Tirar da **lista de sugestão** as variações digitadas à mão: `inicio 14:45`,
`inicio13:34`, `1hrs`, `3h`, `+30min de correcao` e as demais
- [ ] **Não apagar do histórico dos pedidos** — só da lista de opções
- [ ] **Preservar** `30 min`, `1 hora` e `3 horas`: são estimativa de máquina e
viram o campo `minutos_maquina`
### Fase 2 · Portal e robô — recebe 24h sozinho
Entrega valor sem o kanban existir.
- [ ] Contratar VPS, storage S3 e o subdomínio (ver README)
- [ ] Webhook do Tiny → cria token → dispara link no WhatsApp
- [ ] Página de upload com campo de repetição e prévia da montagem
- [ ] URL pré-assinada · upload em partes até **5 GB**
- [ ] Pré-flight (`preflight.py` pronto)
- [ ] Normalização: vetor → PDF, raster → TIF, CMYK → RGB
- [ ] Montagem empilhada · fatiamento em 20 m · carimbo com QR de 20 mm
- [ ] ClamAV em segundo plano
- [ ] Retenção de 30 dias · artes para recompra, 12 meses
### Fase 3 · Agente — o arquivo cai na pasta sozinho
- [ ] Serviço no servidor da fábrica (NSSM ou systemd)
- [ ] Webhook + polling de 60 s
- [ ] Download com verificação de hash
- [ ] Heartbeat a cada 5 min · alerta se sumir por 15
### Fase 4 · Kanban como aba do PCP
- [ ] Quadro com as 7 colunas e cronômetro por card
- [ ] Painel das 6 máquinas com **indicador vermelho de ocupada**
- [ ] `puxar próximo` — reserva de 3 min, um pedido por vez
- [ ] `devolver à fila` — volta ao topo
- [ ] Campo `minutos_maquina` no card, com sugestão automática
- [ ] Retrabalho: abertura, classificação de causa, autorização
- [ ] Painel com filtros de período
- [ ] Endpoints de relatório: etapas, impressão, aproveitamento, retrabalho
### Fase 5 · Duas semanas medindo
**Não pule.** É o que decide se vale o terceiro turno.
O que sai depois de duas semanas rodando:
- tempo real de cada etapa
- metros/hora reais por máquina
- quanto do tempo do designer é retrabalho de arquivo ruim
- retrabalho medido contra o que a sala achava que era
---
## Funcionalidades, uma a uma
### Portal do cliente
**Link com token.** Um por pedido, UUID v4, 7 dias de validade. O token já
carrega o número do pedido no Tiny — o cliente não digita nada.
**Upload direto para o storage.** URL pré-assinada, em partes, até 5 GB. O
arquivo nunca passa pelo VPS.
**Campo de repetição.** O cliente sobe uma arte e informa quantas vezes repetir.
O robô empilha. É o trabalho braçal que hoje o designer faz à mão.
**Prévia da montagem.** Mostra como a folha ficou antes de fechar o pedido.
Montagem **empilhada**, sem encaixe lado a lado — decisão do Marcus.
**Gabarito 57 × 97 cm** para download. Os 30 mm do rodapé são do carimbo.
**Resposta na hora.** Aprovado ou recusado com o motivo em português. **O relógio
do prazo só começa quando a arte é aprovada.**
### Robô de pré-flight
Ordem: **normalizar → validar → repetir → fatiar → carimbar.**
| Verificação | Limite | Ação |
|---|---|---|
| Canal de transparência | obrigatório | recusa |
| DPI efetivo na medida comprada | < 150 | recusa |
| Largura | > 57 cm | recusa |
| Formato | JPEG | recusa |
| DPI efetivo | 150 a 300 | aceita com aviso |
| Alfa parcial nas bordas | > 15% | aceita com aviso |
| CDR | — | **entra sem validar**, etiqueta "conferir" |
**DPI efetivo = pixels ÷ (cm comprados ÷ 2,54).** O metadado do arquivo mente.
**Normalização por natureza:** vetor sai PDF mantendo vetor, raster sai TIF com
alfa. CMYK vira RGB. **O original é sempre preservado** — pedido dos designers.
### Kanban
**Sete colunas:** arte recebida, arte tratada, aprovação de cor, fila,
imprimindo, correção, finalizado. **Não há coluna de aplicação** — a sala só
imprime o filme.
**Fila por ordem de chegada, sempre.**
**Puxar próximo.** Um botão por máquina. Reserva por 3 minutos; se não entrar em
Imprimindo nesse prazo, volta para a fila. **Um pedido por vez.**
**Indicador de máquina ocupada.** Círculo vermelho e botão desabilitado. Com seis
máquinas vendo o mesmo quadro, é o que evita dois operadores no mesmo arquivo.
**Devolver à fila.** Volta ao **topo**, não ao fim — o pedido já esperou uma vez.
**Cronômetro por card.** Tempo parado na coluna atual, em tempo real. Verde até
1h, amarelo até 3h, vermelho acima.
**Trilha por pedido.** Tempo em cada etapa e quanto do total foi só esperando.
### Estimativa de máquina
Campo `minutos_maquina` no card. **Herda os marcadores `30 min`, `1 hora` e
`3 horas` do Tiny**, que a sala confirmou serem estimativa.
- O sistema sugere pelo tamanho: `metros ÷ 20 × 60`
- Quem trata a arte ajusta **só quando foge do normal**
- É esse número que soma a fila contra a capacidade do dia
### Aprovação de cor
A sala confirmou que **hoje não manda prova para ninguém**, e que as cores
problemáticas são as secundárias: azul, vermelho, castanho, laranja e verde.
São cores fora do gamut CMYK — o monitor do cliente mostra o que a máquina não
reproduz.
Dispara sozinho em três casos: arquivo acima de 10 m, primeiro pedido do
cliente, ou cor detectada fora do gamut.
**Custo:** ~30 cm de filme, R$ 1,50. Uma reposição de 20 m custa R$ 99.
**Sugestão de implantação:** começar só com **cliente novo**. Os outros dois
gatilhos entram depois, senão metade dos pedidos vai esperar resposta e o prazo
morre.
### Retrabalho
SKU `CRRMP.TX.100CM`. **Mayana abre e classifica a causa; Thales ou Alexandre
autorizam** — eles confirmaram que acham justo, já que entra na meta deles.
**A causa fica travada depois de aberta.** Com o indicador atrelado a bônus,
haveria incentivo para reclassificar falha de máquina como culpa do cliente.
Quem discorda contesta, e a contestação fica registrada.
**⚠ A meta precisa ser redefinida.** A proposta era 0,4% por causa, somando 1,6%.
A sala respondeu que **"meta geral seria melhor"**. Antes de codificar, decidir
com o Marcus: meta única sobre o total da casa, ou por causa. O código tem
`META_POR_CAUSA` isolado justamente para isso.
### Integração com o Tiny
**Três marcadores, não sete.** O kanban é a fonte de verdade; o Tiny mostra o
estágio grosso para quem não abre o kanban.
| Movimento | Marcador |
|---|---|
| Imprimindo | `DTF-PRODUCAO` |
| Correção | `CORRECAO DTF` |
| Finalizado | `DTF-PRONTO` |
640 chamadas de API por dia em vez de 1.500.
### Mensagens ao cliente
| Quando | Mensagem |
|---|---|
| 1 hora sem arte | lembrete com o link · o pedido aparece como *faltando arquivo* |
| Arte recusada | motivo em português · o prazo não começou |
| Arte aprovada | qualidade em **% e DPI**, metragem, tempo estimado, horário previsto |
| Entrou em produção | aviso simples |
| Correção | motivo · **única que pede resposta** |
| Finalizado | pronto para retirada ou envio |
---
## O que ainda não pode ser codificado
### 1 · O SPOT automático
**Hipótese, não fato.** Os designers disseram que o SPOT é sempre igual; a sala
disse que "é possível criar um perfil só para isso". A documentação do Flexi 22
cita *transparency mask*, que gera o branco a partir da transparência de PNG e
TIF.
**Mas ninguém configurou ainda**, e a edição MiniTX é OEM.
O roteiro de teste está em `dtf-teste-branco-automatico.pdf`. Sete passos, uma
máquina, meia hora.
- **Se funcionar:** o arquivo vai do portal direto para a hot folder e o PC
central atende só exceções. A madrugada roda sem ninguém.
- **Se não funcionar:** o SPOT segue manual e todo pedido passa pelo PC central.
**A conta das 24 horas muda.**
### 2 · A capacidade do PC central
A sala confirmou que o Production Manager centraliza várias impressoras, **mas
que o PC central precisa de mais capacidade**. E as licenças do Flexi são **por
PC** — então centralizar o RIP exige licença lá.
Levantar antes de decidir: configuração atual do PC central, custo de uma
licença adicional, e quanto de memória o RIP consome num trabalho de 15 m.
### 3 · As regras de choke
Os designers **não responderam** os valores de choke nem a espessura mínima de
traço. Sem isso, a regra proposta — 2 px acima de 2 mm, 1 px de 1 a 2 mm,
nenhum abaixo de 1 mm — é chute informado.
**Só entra em código depois de validada com um arquivo real.**
### 4 · Metros/hora reais
Todo o cálculo de capacidade assume **20 m/h por máquina**. A sala não respondeu
o valor real, e disse que não tem informação sobre tempos.
Se o real for 14, a capacidade cai de 60.480 para 42.336 metros/mês e o plano
das 24 horas precisa ser refeito.
---
## Ressalvas honestas sobre o código
**`tiny.py` é chute informado.** Estrutura padrão de OAuth2 e endpoints v3, não
validado contra a documentação real. Está isolado de propósito: se o endpoint
for diferente, muda só ali.
**`carimbar()` precisa de teste visual.** A lógica está certa, mas posicionamento
de texto com pyvips sempre pede ajuste olhando o resultado impresso.
**O front do kanban usa dados fixos.** Precisa trocar por chamadas a
`/api/quadro` e `/api/mover`. O protótipo `dtf-kanban-treino.html` tem o layout e
o comportamento final.
**Nada instalado nos PCs da sala.** Decisão do Marcus. O navegador não abre
arquivo no FlexiPRINT — o desenho é miniatura no card mais botão que copia o
caminho, e File System Access API para mover arquivo pelo navegador.
---
## O que precisa de resposta antes da fase 4
1. Servidor da fábrica é Windows ou Linux?
2. A conta do Tiny já tem aplicação na API v3?
3. Confirmar na documentação do Tiny o endpoint de marcadores e o limite de
requisições por minuto
4. O número do WhatsApp migra para a API oficial da Meta?
5. Qual a latência da integração VNDA → Tiny? Define quando o link sai
6. Meta de retrabalho: geral ou por causa?
7. Configuração e licença do PC central, se for virar servidor de RIP

219
IMPLEMENTATION_REPORT.md Normal file
View File

@@ -0,0 +1,219 @@
# Local milestone implementation report
## Implemented
- A single `dtf-cloud` Compose project: Site, Kanban, FastAPI Portal/API,
PostgreSQL 17, private MinIO S3 storage, isolated ClamAV, and a mock integration
outbox/scanning worker. Restricted database and MinIO runtime identities are
provisioned by separate one-shot initialization jobs.
- Existing Site product UI and commercial functions retained, with a separate
local checkout bridge. Server pricing mirrors the four price ladders, all
grade discounts, assembly-inclusive rates, minimum, and rounding.
- Direct multipart browser uploads, part resumption, owned upload completion,
size verification, private five-minute operator downloads, persistent volumes,
and 30-day object/one-day incomplete-upload lifecycle rules.
- Authenticated manual quote review supplies trusted commercial quantities and
grades without adding pre-flight. Immutable server quotes include mock freight;
the customer confirms the total and creates an idempotent local paid order.
- Shared PostgreSQL orders appear in Kanban. Validated transitions, correction
reasons, version checks and movement history persist. Tiny/WhatsApp events use
a transactional outbox, retry scheduling, unique event keys, and fake receipts.
- Loopback-only published ports; API/worker/database on an isolated network;
guards reject production mode, real adapters and nonlocal storage endpoints.
All long-running services have health checks. `.env.example` contains disposable local
defaults only. No new production credentials or provider endpoints.
- Local customer registration/login, scrypt password hashes, revocable database
sessions and attempt throttling. Customers see owned orders, timelines, final
files and correction requests, and can upload corrections through the portal.
- Browser-local unfinished-cart recovery for 24 hours, including file blobs,
with storage errors surfaced. Recovered rows can be removed/replaced and new
items added. Customer navigation now points to local pages instead of the old
external account/cart destinations.
- Manual final-file sets cover every order item, support multiple parts, and
are required before queue entry. Corrections invalidate old final approvals.
Customer and operator file views distinguish originals, corrections and finals.
- Worker retention cleanup: one-day unfinished uploads, seven-day originals
after final artwork approval, and final/correction files no later than 30 days
from the first upload. Combined database/clean-object backup plus isolated,
hash-checked restore verification.
- Upload extension/size/count quotas, exact signed multipart `Content-Length`,
Host/cross-origin rejection, CSP and security headers, escaped filenames, and
logout cleanup of browser cart blobs and checkout metadata.
- Expiring, revocable HttpOnly operator sessions replace browser-stored Basic
credentials. Customer passwords use stronger scrypt parameters, with legacy
verification and upgrade on login.
- Completed artwork is quarantined until local ClamAV marks it `clean`. Quote,
payment, download, final-file approval, queue, and printing gates fail closed.
Scanner errors/rejections remain blocked and expire within three days. This is
malware scanning only, not print pre-flight.
- Structured redacted security logging, a 30-day `security_events` table, live
scanner/signature alert summaries, security regressions, exact-runtime Python
auditing, and JSON image-scan reports under `output/security/`.
- Reproducible Python 3.12 dependency resolution: all 26 direct/transitive runtime
packages are pinned with artifact hashes, and image builds enforce those hashes.
- A separate network-disabled staging-readiness gate validates non-secret inputs;
it is deliberately not an application deployment or provider connection.
- A separate production delivery package defines non-root, hash-locked API/web
images, one external-secret Portainer Docker Swarm stack, health-monitored
rolling updates, commit-SHA rollback, and one Gitea test/scan/publish/webhook
workflow matching the established Graphs/ComporHUB operating model. Its
preflight deliberately blocks the current local-only source and placeholder
inputs; no registry push, Swarm deployment, or real provider call occurred.
## URLs
| Component | URL |
|---|---|
| Site | http://localhost:8080 |
| Customer portal | http://localhost:8080/portal.html |
| Kanban | http://localhost:8081 |
| API health | http://localhost:8000/health |
| Local object API and console | http://localhost:9000 · http://localhost:9001 |
Kanban local login: `operator` / `local-operator-only`.
Setup and the complete browser test are in `LOCAL_SETUP.md`.
Interactive `/docs` and `/redoc` are disabled.
## Verification completed
- `docker compose up --build -d --wait`: all seven long-running services healthy;
both initialization jobs exited successfully and published ports are loopback-only.
- `python3 -m unittest local.test_pricing -v`: all tests passed, including
4,444 comparisons with the actual Site JavaScript calculator.
- `python3 -m local.smoke_test`: passed multipart resume/incomplete completion,
download byte identity, private bucket and session ownership, input/tamper
rejection, four-mode pricing, reviewed corrections, mock freight, concurrent
payment idempotency, valid/invalid transitions, history and eight mock receipts.
- `node local/browser_test.mjs`: passed actual browser upload, manual review,
local payment, cart recovery, final-file approval, customer registration,
customer tracking, Kanban state changes and persisted board reload, with no
JavaScript exceptions. Screenshots inspected in `output/local/`.
- Local test orders are retained in **Finalizado** for inspection, including
the browser fixture at **R$21.89** and the four-mode smoke order at **R$537.25**.
- Restarts of all long-running containers preserved order snapshots, states, mock
receipts and original file bytes; all services returned to healthy.
- Explicit guard checks rejected production mode, every real integration adapter,
R2 selection and a nonlocal S3 endpoint without contacting external services.
- `python3 -m local.workflow_test`: passed account/session isolation, guest
migration, revoked-cookie rejection, customer corrections, final revision
invalidation, secure file downloads and final-file gating.
- `python3 -m local.backup create-and-verify`: the refreshed 2026-09-15 bundle
archived 61 clean MinIO objects (58,723,323 bytes) alongside the local database,
verified SHA-256 manifests,
restored and rehashed every object under an isolated MinIO prefix, restored the
database with 16 orders and 107 upload records, then removed all temporary restore
targets. The prior database-only backup also passed the legacy verifier.
- `docker compose exec -T api python -m local.retention_test`: verified expired
object deletion, preservation of unexpired bytes, and retention of upload
metadata. Only synthetic retention-test object bytes were cleaned up.
- `python3 -m local.security_test`: passed CSP/frame/Host/origin defenses,
rejection of Basic credentials, HttpOnly operator-session revocation, extension
and multipart-size signing, upload quotas, and login throttling.
- `python3 -m local.scanning_test`: a real harmless EICAR fixture was rejected by
ClamAV and could not be downloaded or quoted; a clean control was released.
- `docker compose exec -T api python -m local.runtime_security_test`: passed
database/MinIO least privilege, legacy/current password hashes, quarantine
states, live scanner commands, scan-size rejection, and fail-closed offline behavior.
- The hash-enforced rebuild completed successfully; `pip check`, four staging-gate
regressions, security, smoke, and runtime-security tests passed. A fresh
exact-runtime `pip-audit` found no known Python advisories on 2026-09-15.
- `node local/browser_test.mjs`: additionally passed filename XSS probes with CSP
bypassed, absence of stored operator credentials, and logout removal of File blobs.
- The final rebuilt stack has seven healthy long-running services; Site/API routing and a fresh
guest portal session were checked after the rebuild.
- Production-package validation passed Python/unit and shell syntax checks, Gitea
workflow YAML parsing, and `docker stack config` rendering. The API production
image built from an immutable Python base. The web production image built from
immutable Python/Nginx bases and ran as UID 101 with a read-only filesystem,
returning 200 for its configured Host and 400 for an unexpected Host.
- The pinned ClamAV validation image started as UID 100:101 with a read-only
filesystem, all capabilities dropped, and `no-new-privileges`, then returned
`PONG` through the production health command; those restrictions are encoded in
the Swarm stack.
- `python3 deploy/production_preflight.py --source-only` returned the expected
blocked result for local-only adapters/hosts/fake providers and missing Docker
secret-file loading. This is verified fail-closed behavior, not production
acceptance.
The final closeout smoke test retained local order #14 in **Finalizado** with
eight durable fake receipts. The final workflow test again passed identity,
correction, download, invalidation, and final-file gates after the PostgreSQL 17
image refresh. Existing database and MinIO named volumes were preserved.
## Reuse and replacement decisions
| Existing asset | Decision |
|---|---|
| `dtf-site.html` | Reused directly. Preserved layout, modes, pricing, client previews, minimums and rounding; added file references and hooks for local checkout/freight. |
| `portal/main.py` | Inspected and preserved. Reused FastAPI and direct signed S3 upload design; replaced runtime with `local/app.py` because the prototype starts from Tiny and invokes pre-flight/agent delivery. |
| `portal/preflight.py` | Inspected, untouched, never imported by local runtime. |
| `kanban/main.py` | Inspected and preserved. Reused workflow state names, movement-history concept and outbox approach; replaced SQLite/folder/machine runtime with PostgreSQL endpoints. |
| `kanban/static/kanban.html` | Inspected and preserved. Reused dark palette, cards, columns and drag/drop pattern in `local/static/kanban.html`. Legacy machine controls and conflicting local/server handlers are not loaded. |
| Duplicated Tiny/WhatsApp modules | Preserved but excluded from build/runtime. New explicit fake adapters cannot invoke them. |
| `agente/` | Inspected, untouched, excluded from build and Compose. Factory automation remains out of scope. |
| Root `schema.sql`, `requirements.txt`, `.env.exemplo` | Historical only; local runtime uses a separate `dtf_local` PostgreSQL schema, dependencies, and `.env.example`. No migration of prototype data. |
| README/API docs | Legacy content preserved under explicit notices pointing to active local instructions. |
## Deferred and practical limits
This is a working local development milestone, not the completed three-week production
MVP. Real payments/webhooks, freight providers, Tiny/Olist, WhatsApp, production
R2, production account verification/recovery/security, automatic final print-file
generation, scheduled/offsite backups and a production restore runbook, and production
activation remain undone. Deployment definitions and automation now exist, but
their gate correctly prevents use with the local-only application. No factory agent, hot folders, FlexiPRINT, VPN,
machine dashboards, label automation, or new automatic pre-flight were added.
Browser artwork analysis already in the Site is retained as advisory prototype
behavior. The backend never adopts its prices, lengths or grades as approved.
The temporary manual quote step resolves that trust boundary locally; its role
in production needs an explicit product decision. Final files are manually
prepared and approved by operators; the test `.cdr` fixture is text, not printable
artwork. Cart recovery is browser-local, subject to quota, and does not reconstruct
the active artwork editor in place. Fake delivery receipts
mean only that the local adapter recorded an event, never that a person received
a message or an ERP order was created.
The multipart transport supports up to 5 GiB, but this local scanner can release
only files up to 128 MiB. Larger uploads remain blocked. The scanner has no
external network route and uses signatures bundled in its pinned image; rebuild
or replace that image before signatures exceed the seven-day alert threshold.
The 2026-09-15 exact-runtime Python audit reported no known dependency findings.
All 26 installed packages are transitively pinned with artifact hashes; scheduled
lock refresh and audit automation are still pending.
Trivy HIGH/CRITICAL image reports are retained in `output/security/`: API 46
(44 Debian records without fixes plus two third-party-SBOM package records absent
from the runtime), Site 5, refreshed PostgreSQL 31, pinned MinIO 108, and pinned
ClamAV 0. Counts are scanner observations, not exploitability determinations.
MinIO was not upgraded over the preserved object volume; its old pinned release
is a material localhost-only limitation. See `SECURITY_REPORT.md`.
The Site retains its pre-existing external fonts/logo/PDF.js 3.11.174 references.
The known eval advisory is mitigated by the existing `isEvalSupported:false` call;
the old CDN dependency still needs a planned upgrade or vendored/pinned
replacement. No new production service references were added. The `.git` directory is unavailable
as a working Git repository in this workspace, so changes are delivered as local
files without a commit or Git diff.
## Exact next step
The localhost security closeout is complete; continue local product work without
treating these controls as production approval. Before any staging or production
connection, complete `PRODUCTION_INPUTS.md` and pass the network-disabled readiness
gate, including the acceptance flow,
production authority for length/grade, and freight platform, origin, packaging and
policy. The verified local database/clean-object bundle addresses the local
recovery gap but is neither scheduled nor offsite and should be created while local
writes are idle. Remaining foundations include production backup/restore design,
container-image remediation/upgrade decisions, and account verification/recovery
design. The current `compose.staging.yaml` validates inputs only and cannot deploy
the application or contact providers.
Use the checked-in `deploy/` package as the target deployment contract rather
than creating another production stack. First add the actual separate staging application runtime, beginning with S3
adapter contract tests for direct multipart upload, private downloads, CORS and
retention using injected staging credentials. Add real provider adapters only
after their contracts are confirmed; payment activation requires signed,
idempotent webhooks. Implement Docker-secret file loading, resolve or formally
accept image findings, rehearse production restore, and make the release preflight
pass without weakening it. The local runtime intentionally refuses production values.

366
LOCAL_SETUP.md Normal file
View File

@@ -0,0 +1,366 @@
# DTF local development
Read `CONTEXT.md` first. The current runtime lives in `local/`; older portal,
Kanban, agent, requirements, and SQL files are historical prototypes.
## Start
Install Docker Engine/Desktop with Compose v2 or later. Docker must be running
and your account must have permission to use it. The initial build downloads
public container images and Python packages; running integrations are local.
From this repository directory:
```bash
docker compose up --build
```
No `.env` is required: Compose has the same disposable defaults as `.env.example`.
To customize, copy `.env.example` to `.env` and edit it. Never use real credentials.
For a detached start with readiness verification:
```bash
docker compose up --build -d --wait
docker compose ps
```
| Component | Local URL / port | Purpose |
|---|---|---|
| Site DTF | http://localhost:8080 | Existing Site with local checkout bridge |
| Customer portal | http://localhost:8080/portal.html | Local account, order history, corrections and files |
| Kanban | http://localhost:8081 | Quote review, orders, movement, original downloads |
| API health | http://localhost:8000/health | Checks PostgreSQL and MinIO |
| MinIO S3 | http://localhost:9000 | Direct signed multipart uploads and downloads |
| MinIO console | http://localhost:9001 | Inspect private local storage |
| PostgreSQL | `db:5432`, internal only | Shared persistent data |
| ClamAV | `scanner:3310`, internal only | Isolated malware scanner with bundled signatures |
| Worker | `worker:8002/health`, internal only | Mock outbox delivery, scanning and combined health |
Use `localhost` consistently; mixing it with `127.0.0.1` creates a different
browser session. Published ports bind only to `127.0.0.1`.
Kanban default login: `operator` / `local-operator-only`.
MinIO default login: `dtf_local` / `local-storage-only`.
These are public, disposable development values, not real credentials.
Interactive API documentation is disabled; `/docs` and `/redoc` are not local
operator interfaces.
## Browser test order
1. Open the Site. Choose **Arquivo por metro** and the manual/table-price path
(CDR/AI/PSD/TIFF). Select `local/fixtures/local-test.cdr`. This is deliberately
harmless text for upload testing, not a printable CDR file.
2. Enter **1.01 metres**. The original calculation bills **1.10 m × R$19.90 =
R$21.89**, with grade 0 and pickup. You can also use your own non-sensitive
artwork with the original product controls, repetitions, and mixed cart.
3. Use test CNPJ `11.222.333/0001-81`, phone `11999999999`, email
`local-test@example.test`, and pickup. For simulated freight, select delivery,
enter an eight-digit CEP, and click quote. The default mock price is R$15.00.
4. Click the Site checkout button. Files upload directly to MinIO, remain
quarantined until ClamAV returns `clean`, and then become eligible for a quote.
The local banner displays a quote ID awaiting commercial review.
5. Open Kanban and log in. In **Cotações**, download the original if needed,
confirm/correct total metres and grade, tick the manual confirmation, and
click **Aprovar cotação**. For the fixture keep 1.01 m and grade 0.
6. Return to the Site, click **Atualizar pedido local**, inspect the authoritative
server total, then click **Criar pedido pago local**. No real payment occurs.
7. Refresh Kanban. The paid order starts in **Arte recebida**. Move it using
the buttons or drag and drop to **Arte tratada**. Open **Arquivos de produção**,
select the manually prepared final files for every item, enter a review note,
tick the confirmation and click **Aprovar arquivos finais**. Use the harmless
fixture again only for this local test; no printable file is generated.
Continue through **Fila de impressão →
Imprimindo → Finalizado**. A move to **Correção** requires a reason; it can
return to **Arte recebida** or **Arte tratada**. Finalizado is terminal locally.
8. Inspect **Histórico** and **Eventos locais de integração**. Worker receipts
should say recorded locally. Download links expire after five minutes;
request another link from Kanban when needed.
The manual quote step is necessary to enforce the backend trust boundary while
automatic pre-flight is deferred. Browser measurements and grade are proposals.
Only an authenticated operator can approve them; the server derives prices and
freight. Payment accepts a quote ID only. Approved quotes are immutable for
24 hours, and repeated/concurrent payment requests return the same order.
This local review step does not settle the future automated production checkout.
Malware scanning is a separate safety gate and does not inspect dimensions,
resolution, colors, printability, or any other pre-flight property.
The existing client-side previews/analysis remain unchanged and advisory. No new
automatic print pre-flight or artwork validation runs on upload; malware scanning
is the separate gate described above. Original downloads and manually approved
final files are separately labeled. Never print the local text fixture.
## Customer accounts, corrections, and cart recovery
Open **Minha conta** on the Site, or `/portal.html`. Create a local account with
a test CNPJ, phone, email and password of at least 12 characters. Passwords use
the current stronger scrypt format; legacy local hashes are verified and upgraded
on successful login. Sessions are stored in PostgreSQL, expire after
seven days, and are revoked on logout. Login/registration attempts are limited.
No email service is used: email verification and password recovery remain absent.
Registration associates only the current guest session's uploads, quotes and
orders with the new account. Signing in from another browser restores access to
that account's orders. Matching an email or CNPJ never grants access to an order.
Guests can still order and track within their current session.
In the portal, **Ver detalhes e arquivos** displays status history, correction
reasons, files and expiry dates. When Kanban requests **Correção**, upload corrected
files against the relevant item and add a note. The order stays in correction
until the operator reviews it. The old final set is invalidated; the operator
must upload/approve another complete set before re-entering the queue. Concurrent
or repeated submissions with a stale order version are rejected.
The browser saves unfinished cart items and their File blobs in IndexedDB for
24 hours. Reloading restores them as cart rows; remove/replace a row to alter its
artwork settings, or add more products. Existing calculations are preserved and
delivery must be requoted. Payment clears the saved cart. Cart recovery is local
to the browser, not a cross-device artwork library; very large files can exceed
browser storage capacity, which is reported visibly. Expired browser entries are
removed the next time the Site opens. Server retention does not erase downloaded
files or copies stored by the browser/operating system.
Logout also clears local checkout keys and IndexedDB File blobs across open Site
tabs. Operator authentication uses an expiring, revocable HttpOnly session;
browser storage never retains the operator password or a Basic-auth credential.
## Automated checks
Pricing parity requires Python 3.10+ and Node 22+ on the host, no package install:
```bash
python3 -m unittest local.test_pricing -v
```
This executes the real pricing constants/functions extracted from `dtf-site.html`
and compares all four modes, 101 grades, and 11 lengths (4,444 cases) to backend
pricing, plus assembly and invalid-input checks.
With the stack healthy, run the integration test (Python standard library only):
```bash
python3 -m local.smoke_test
python3 -m local.workflow_test
```
It checks direct two-part upload/resume, missing parts, session isolation,
private downloads and byte identity, customer validation, tampered totals,
operator corrections, all four products, freight, immutable quotes, concurrent
payment retries, state rules, history, and durable fake integration receipts.
Each run leaves one clearly labeled test order and an approximately 8 MiB object.
The workflow test also checks guest-to-account migration, cross-session login,
revoked sessions, ownership isolation, correction submissions, final revision
invalidation, secure downloads, and mandatory final-file approval before queueing.
Security and malware regressions are separate so an authorized harmless EICAR
test is unmistakable:
```bash
python3 -m local.security_test
python3 -m local.scanning_test
docker compose exec -T api python -m local.runtime_security_test
docker compose exec -T api python -m local.retention_test
```
`local.scanning_test` stores an EICAR fixture as `SECURITY-EICAR.cdr`; ClamAV
must reject it, and quote/download gates must remain closed. The rejected fixture
is retained for at most three days, so it temporarily appears in alert summaries.
For an automated real-browser walkthrough, install Chrome and use Node 22+:
```bash
node local/browser_test.mjs
```
Set `CHROME_BIN` if Chrome is not at `/usr/bin/google-chrome-stable`. The test
uses a separate temporary browser profile, walks through the actual Site and
Kanban controls, and saves screenshots to `output/local/`. It leaves its local
test order for inspection. Both integration scripts read `.env` automatically.
## Configuration and storage
`.env.example` lists local ports, database/MinIO values, operator login, adapter
selection, mock freight amount, maximum file size (5 GiB), and multipart size
(8 MiB by default). The malware scanner releases only files up to 128 MiB by
default (`SCAN_MAX_BYTES`); larger uploads remain blocked even though the
multipart transport supports 5 GiB. `S3_ENDPOINT=http://storage:9000`, database
hostname `db`,
and the internal service ports are fixed Compose wiring. The public S3 endpoint
must resolve from the browser; keep `http://localhost:9000` for this stack.
Parts use 15-minute presigned URLs and uploads must finish within one day.
Only fake integration adapters and `s3-local` storage are accepted. Startup fails
if a production adapter/environment or nonlocal S3 endpoint is selected.
The API, worker, and database run on an internal Docker network; web gateways
and MinIO also join a network that permits loopback port publishing.
Objects are private, use UUID keys rather than filenames, and persist in a named
volume. MinIO lifecycle rules expire objects after 30 days and abandon incomplete
multipart uploads after one day; the API also blocks expired downloads. Order
history remains in PostgreSQL. Completed files start in `pending`; unknown,
scanner-error, over-limit, encrypted/unsafe, and malware results fail closed.
Only `clean` files can cross quote, payment, download, final-approval, and queue
gates. Rejected/error objects expire within three days. The worker deletes
original bytes within seven days of
manual final artwork approval, and final/correction bytes by 30 days from the
order's first upload. Expired files remain visible as metadata but cannot be
downloaded. Retention runs once a minute; MinIO lifecycle is a backstop.
Upload retries resume completed parts. The saved cart retains files when browser
storage permits; otherwise reselect them. Saved quote IDs also survive reloads.
Clearing cookies loses a guest session, while registered customers can sign in
again. The operator can still inspect order records.
## Local backup and restore check
```bash
python3 -m local.backup create-and-verify
```
This writes a private four-file bundle in `backups/` (ignored by Git and Docker
builds): a PostgreSQL custom-format dump, a gzip object archive, a JSON manifest,
and the manifest's SHA-256 sidecar. The object archive includes only complete,
unexpired files that ClamAV has marked `clean`; pending, rejected, errored, expired,
and purged objects remain excluded. Run the command while uploads and retention are
idle so the database dump and subsequent object selection describe the same local
state.
Verification restores the dump into a newly created UUID-named database and the
object bytes under a UUID-named `originals/restore-verification/` MinIO prefix. It
checks database counts, bundle hashes, every archived object's hash, and the bytes
downloaded after restore, then removes only the temporary database and objects. It
never restores over active data. Keep every bundle file private: it contains
customer data, password hashes, and customer artwork. To verify it again, run
`python3 -m local.backup verify` followed by the printed
`backups/...manifest.json` path. Legacy database-only `.dump` backups remain
verifiable. Scheduling, offsite copies, and a production restore runbook remain
unfinished.
After building the current image, test retention with synthetic files:
```bash
docker compose exec -T api python -m local.retention_test
```
This checks that expired bytes are removed while unexpired files survive. It
cleans up its own synthetic object bytes and retains their metadata.
## Operations and troubleshooting
```bash
docker compose logs --tail=100 api worker scanner
docker compose restart api worker
docker compose ps
docker compose down
```
`down` stops the stack and preserves named database/storage volumes. Restart
with `docker compose up -d --wait`. Do not add `--volumes` unless you intend to
permanently erase all local orders and artwork. No reset is required for tests.
Seven long-running services have Docker health checks; the database and storage
initialization jobs must exit successfully. API health queries PostgreSQL and
MinIO; worker health fails if its database-processing loop, scanning thread, or
live ClamAV PING is unavailable. Both web gateways expose `/health`. MinIO
exposes `/minio/health/ready`.
Run the redacted local alert summary inside the API network namespace:
```bash
docker compose exec -T api python -m local.security_status
```
Exit status 1 means attention is required. Review blocked artwork, rate limits,
failed logins, scanner availability, and signature age. Immediately after the
security tests, expected synthetic login failures/rate limits and
`SECURITY-EICAR.cdr` rejections will trigger the alert; confirm names and test
timing before classifying them as expected. Unexpected failures, scanner errors,
stale signatures (more than seven days), or non-test rejected files require
investigation. Events are retained for 30 days. See `SECURITY_REPORT.md`.
If a host port is occupied, change `SITE_PORT`, `KANBAN_PORT`, or `API_PORT` in
`.env` and recreate the stack. MinIO currently reserves 9000/9001. If Docker says
permission denied, fix Docker access or use your OS-approved Docker workflow.
If image/package download fails, check development internet access. A warning
about missing Buildx can fall back to Docker's classic builder; install your
platform's Buildx plugin for the supported modern build path.
If API is unhealthy, inspect its logs and check local adapter settings. Database
and MinIO credentials initialize persistent volumes only once; changing values
later also requires updating the existing database/storage account deliberately.
If an upload fails, keep the session and click checkout again. If parts expired,
reselect the file to start another upload. For a stale Kanban move, refresh;
the backend rejects stale versions instead of overwriting another operator.
The Site retains its existing remote visual assets (fonts/logo) and optional
PDF.js 3.11.174 CDN dependency. Its known eval-based advisory is mitigated in
the current call with `isEvalSupported:false`; the old CDN dependency is still
an upgrade/vendor-pinning limitation, not a claim that it is current. The local
API, manual fixture flow, storage, payment,
freight, Kanban, and mock integrations work without these external services.
Offline PDF previews may fall back to the prototype's manual/table-price path.
## Dependency lock
The API, worker, and database initializer install every Python dependency from
`local/requirements.lock` with `--require-hashes`. `local/requirements.txt`
remains the human-maintained direct dependency list. After deliberately changing
a direct pin, regenerate the lock in the same Python 3.12 environment and rebuild:
```bash
./local/lock_dependencies.sh
docker compose up --build -d --wait
```
The generator downloads public package metadata in a disposable container and
does not modify the host Python environment. Review the resolved versions and run
the exact-runtime audit plus regression suite before accepting an updated lock.
## Staging readiness gate
`compose.staging.yaml` is intentionally not a staging deployment. It runs only a
network-disabled validator for non-secret decisions. After completing
`PRODUCTION_INPUTS.md`, copy `staging/staging.env.example` to the ignored
`staging/staging.env`, enter non-secret metadata, and run:
```bash
docker compose -f compose.staging.yaml run --rm readiness
```
A pass does not authorize deployment or prove provider access. The real staging
composition and external secret injection still require approved provider
contracts and owners. See `staging/README.md`.
## Next production connection step
Keep this stack local. Before connecting real services, confirm the production
checkout trust workflow, complete `PRODUCTION_INPUTS.md`, and pass the isolated
staging-readiness gate. Then implement the actual staging composition using the adapter contracts in
`local/adapters.py`: start with private S3 staging storage, CORS, signed multipart
contract tests and scoped credentials injected outside Git. Add provider sandbox
adapters one at a time. Mercado Pago requires authenticated, signed, idempotent
webhook handling before real payment is allowed; Tiny/Olist and WhatsApp need
confirmed contracts and provider idempotency/delivery handling. Do not simply
change the local fake flags or point this Compose file at production.
## Production delivery package
The repository now includes a separate Docker Swarm and Gitea Actions delivery
package in `deploy/` and `.gitea/workflows/`. It is not used by this localhost
Compose stack and does not alter its volumes. Production images run as
unprivileged users and install the hash-locked Python runtime. Gitea publishes
`latest` for the normal Portainer webhook plus the full commit SHA for rollback.
The stack expects pre-provisioned external Swarm secrets and an external
PostgreSQL volume. Only Site and Kanban ports are published for the existing TLS
reverse proxy; API, database, worker, and scanner remain private.
Run the source-only gate without credentials:
```bash
python3 deploy/production_preflight.py --source-only
```
It must remain blocked while `local/` supports only local fake adapters and does
not load Docker secret `*_FILE` settings. Do not bypass or delete this check.
After approved production implementations and inputs exist, follow
`PORTAINER.md` and `deploy/PRODUCTION_CHECKLIST.md`; release and deployment
still require the Gitea scan/test gates and explicit approvals.

121
PORTAINER.md Normal file
View File

@@ -0,0 +1,121 @@
# Portainer deployment
DTF follows the same operating model as Graphs and ComporHUB: Gitea builds
prebuilt images, pushes them to the Gitea registry, and calls one Portainer
webhook. Portainer owns and redeploys one Docker Swarm stack named `dtf-cloud`.
The production stack is `deploy/stack.yaml`. It contains Site, Kanban, API,
worker, PostgreSQL, ClamAV, and a one-time database initializer. Production uses
Cloudflare R2, so MinIO is not part of this stack.
## 1. Gitea configuration
The single workflow is `.gitea/workflows/deploy.yml`. Pull requests run static
validation. A push to `main` runs the full isolated test suite, builds and scans
the production images, publishes both `latest` and the full commit SHA, then
calls Portainer.
Repository variables:
- `REGISTRY_HOST` — normally `gitea.blyzer.com.br`.
- `REGISTRY_OWNER` — normally `blyzer`.
- `PYTHON_BASE_IMAGE`, `NGINX_BASE_IMAGE`, `TRIVY_IMAGE` — approved immutable
`@sha256:` image references.
Repository secrets:
- `REGISTRY_USERNAME` and `REGISTRY_TOKEN` — package write credentials.
- `PORTAINER_WEBHOOK` — webhook generated by the `dtf-cloud` Portainer stack.
The webhook is called only after tests and HIGH/CRITICAL secret,
misconfiguration, and image gates pass. The current local-only application
fails the source preflight intentionally, so it cannot publish yet.
## 2. One-time Portainer resources
Use a Docker Swarm environment. Create a dedicated production PostgreSQL volume
and set its name as `POSTGRES_VOLUME`. Label its Swarm node
`dtf_database=true`. Do not reuse the localhost Compose volume.
Create each application credential under **Secrets**. Use versioned names such
as `dtf_prod_database_url_v1`, then place only those names in the corresponding
`*_SECRET` stack variables. The stack expects distinct secrets for:
- runtime and administrator database URLs;
- database administrator and application-role passwords;
- R2 access key and secret key;
- operator password;
- Mercado Pago token and webhook secret;
- Tiny/Olist token;
- WhatsApp token.
Credential values must never be entered into Git, `portainer.env`, or Gitea
workflow variables.
## 3. Create the stack once
In Portainer select **Stacks → Add stack → Git repository**:
- Name: `dtf-cloud`
- Repository: this Gitea repository
- Reference: `main`
- Compose path: `deploy/stack.yaml`
- Registry: the private `gitea.blyzer.com.br` registry
- Re-pull image: enabled
- Automatic update: webhook
Load a completed copy of `deploy/portainer.env.example` as the stack's
non-secret environment variables. Do not load the example unchanged: `TBD` and
zero digests are deliberate blockers. Keep `IMAGE_TAG=latest` for normal
webhook deployments.
Before entering those values in Portainer, validate the completed ignored file:
```bash
set -a
. deploy/portainer.env
set +a
python3 deploy/production_preflight.py
```
The public reverse proxy should send the Site hostname to `SITE_PORT` and the
Kanban hostname to `KANBAN_PORT`. `/api/` and `/portal.html` are served through
the Site gateway. Preserve the original Host header. Do not expose PostgreSQL,
API, worker, or ClamAV. Restrict the
two published web ports at the host firewall so only the reverse proxy can use
them, and terminate HTTPS at the proxy.
The `db-init` service completing and stopping is expected. The other six
services must be healthy. A failed `db-init` task or an unhealthy service blocks
acceptance.
## 4. Normal deployment
Push to `main`. Gitea validates, tests, scans, publishes these images, and calls
the webhook:
```text
gitea.blyzer.com.br/blyzer/dtf-api:latest
gitea.blyzer.com.br/blyzer/dtf-api:<full-commit-sha>
gitea.blyzer.com.br/blyzer/dtf-web:latest
gitea.blyzer.com.br/blyzer/dtf-web:<full-commit-sha>
```
In Portainer, verify the new image digests, service health, `db-init` result, and
the public `/health` endpoints. Back up PostgreSQL and R2 before changes that
affect stored data or retention.
## 5. Rollback
In the Portainer stack variables, change `IMAGE_TAG` from `latest` to the full
SHA of the last known-good Gitea commit and redeploy. This rolls back Site, API,
Kanban, and worker code; it does not undo database migrations or restore data.
After recovery and a corrected release, return `IMAGE_TAG` to `latest`.
## Current blocker
This is the final deployment shape, but it is not authorized for production
today. Production adapters, Docker-secret file loading, provider sandbox tests,
current clean base images, approved inputs, and a production restore rehearsal
are still required. Do not bypass `deploy/production_preflight.py` or the Gitea
scan gates to make a deployment run.

62
PRODUCTION_INPUTS.md Normal file
View File

@@ -0,0 +1,62 @@
# DTF staging and production input checklist
Status: input worksheet only. Nothing in this document authorizes a real
integration, production deployment, or use of production credentials.
Do not put passwords, tokens, webhook secrets, private keys, customer data, or
provider recovery codes in this repository. Record only the credential owner and
the approved secret-injection location. Test every provider in an isolated staging
or sandbox environment before production activation.
## Decisions required before staging implementation
| Area | Required decision or evidence | Owner | Status/date |
|---|---|---|---|
| Artwork acceptance | Decide whether customer-entered length and quality grade are accepted directly, manually approved, or verified by another defined process. Name the commercial authority and rejection/correction flow. | | |
| Checkout | Approve the exact transition from quote to payment, including quote expiry, customer confirmation, cancellation, refund, and correction rules. | | |
| Infrastructure | Confirm VPS capacity, operating system, domain/subdomain, DNS owner, TLS termination, Portainer access, Gitea Actions path, and deployment/rollback owner. | | |
| Cloudflare R2 | Provide a staging bucket and endpoint, CORS policy, retention/lifecycle rules, narrowly scoped credential owner, storage budget, and restore/rollback acceptance test. | | |
| PostgreSQL | Define the managed/self-hosted choice, encryption and access policy, backup destination, schedule, retention, restore objective, and named restore-test owner. | | |
| Malware signatures | Approve how ClamAV signatures are refreshed without unrestricted scanner egress, plus stale-signature and scanner-outage response. | | |
| Freight | Select the source-of-truth platform, staging access, origin address/postal code, services/carriers, packaging weight/dimensions by DTF length, pickup rules, and pass-through/subsidy/free-shipping policy. | | |
| Mercado Pago | Provide sandbox account ownership, webhook administration, approved event/status mapping, refund/cancellation policy, reconciliation owner, and secret-injection location. | | |
| Tiny/Olist | Confirm API version/endpoints, staging access, order/product/customer mappings, tag/marker behavior, idempotency key, rate limits, retry rules, and reconciliation owner. | | |
| WhatsApp | Select the provider, sending number owner, opt-in/legal basis, approved templates for the four agreed events, staging access, retry/delivery-failure policy, and support owner. | | |
| Customer accounts | Select email verification and password-recovery provider/flow, session policy, privacy/contact requirements, and customer-support ownership. | | |
| Operators | Define named operator provisioning/removal, roles, MFA expectation, emergency access, and periodic access review. Shared production credentials are not acceptable. | | |
| Monitoring | Name alert recipients and escalation windows for authentication abuse, malware detections, stale signatures, queue failures, provider failures, backup failures, capacity, and downtime. | | |
| Security findings | Record remediation or explicit risk acceptance for the current MinIO, PostgreSQL, Nginx, Debian, and PDF.js findings before a production-readiness review. | | |
## Evidence required before production activation
- A separate staging composition/deployment with no production secrets and no
route from local fake integrations to real provider accounts.
- Server-authoritative pricing regression results, including all four product
modes, discounts, assembly charges, minimum length, rounding, and freight.
- Direct multipart R2 tests for resume, exact part sizes, private access, CORS,
expiry, abort, malware quarantine, secure download, and retention.
- Signed and idempotent Mercado Pago webhook tests, including duplicate, delayed,
invalid, cancelled, and refunded events. No payment may be created before freight
is final.
- Idempotent Tiny/Olist and WhatsApp outbox tests covering retry, duplicate delivery,
rate limiting, permanent failure, and operator reconciliation.
- Account verification/recovery, operator access, TLS, cookie, Host/origin, audit,
alert, and incident-response checks in the target architecture.
- A scheduled, encrypted, offsite PostgreSQL/object backup and a documented restore
rehearsal. The verified localhost bundle is useful evidence, not this production
control.
- A recorded go/no-go review signed by the business owner, operations owner, and
technical owner, with rollback contacts and a support window.
## Safe implementation order after inputs are approved
1. Complete the non-secret metadata and pass the network-disabled readiness gate.
2. Create the actual separate staging composition and external secret-injection path.
3. Validate private R2 multipart storage, malware release gates, downloads,
retention, backup, and restore.
4. Add freight quotation because its final value is required before payment.
5. Add Mercado Pago sandbox checkout and authenticated idempotent webhooks.
6. Add Tiny/Olist through the existing outbox/idempotency boundary.
7. Add only the four agreed WhatsApp notification events.
8. Run the complete functional, security, dependency, image, recovery, and manual
acceptance suite in staging before any production activation.

662
README.md Normal file
View File

@@ -0,0 +1,662 @@
# Sistema DTF 24h — Altus Group
> **Active local milestone (2026-09-11):** Read [CONTEXT.md](CONTEXT.md) first.
> Start with `docker compose up --build`, then open the [Site](http://localhost:8080)
> and [Kanban](http://localhost:8081). Optional configuration: copy `.env.example`
> to `.env`. Follow [LOCAL_SETUP.md](LOCAL_SETUP.md) for the complete test flow,
> local login, health checks, and troubleshooting. See
> [IMPLEMENTATION_REPORT.md](IMPLEMENTATION_REPORT.md) for scope and reuse decisions.
> Production delivery uses one Portainer stack; see [PORTAINER.md](PORTAINER.md).
> Everything below is preserved historical prototype documentation, not the active
> setup or delivery specification. Do not run its production integrations or agent.
> Documentação para o TI. Reúne o contexto do projeto, as decisões já tomadas,
> a arquitetura, o código e o que ainda depende de resposta.
> Base: conversas de 29 e 30/08/2026 com Marcus.
---
## Por que este projeto existe
A sala de DTF tem 6 máquinas a 20 m/h. Em dois turnos, das 5h às 20h, isso dá
**37.800 metros/mês de capacidade**. A produção real de maio a agosto de 2026
ficou em **11.605 metros/mês** — 31% de ocupação. O pico foi junho, com 12.373 m.
**Não falta máquina. Falta a porta ficar aberta.**
O que limita a venda é o horário em que conseguimos receber e responder. O
pedido que chega às 17h30 espera até as 5h da manhã, porque não há ninguém para
receber o arquivo, tratar e mandar imprimir. O cliente de DTF compra por
rapidez, e a rapidez tem duas metades: receber e produzir. Hoje as duas param
juntas às 20h.
### O que muda com o projeto
| | Hoje · 18k m | Com 24h · 36k m |
|---|---|---|
| Custo por metro | R$ 9,43 | R$ 7,46 |
| Receita | R$ 268.200 | R$ 536.400 |
| **Resultado** | R$ 77.011 | **R$ 225.032** |
| Margem | 28,7% | 42,0% |
**Ganho de R$ 148 mil/mês, R$ 1,78 milhão/ano**, com a folha subindo 20%
(R$ 6.333). Cada metro acima de 18 mil rende R$ 8,76 de margem de contribuição —
insumo e manutenção custam R$ 4,94 e não mudam; folha, fixo e administrativo já
estão pagos.
Contra isso, a infraestrutura do projeto custa **R$ 160 a 450 por mês**.
### Os quatro furos do fluxo atual
1. **A arte entra por WhatsApp** — canal que não registra nada auditável
2. **O número do pedido é digitado à mão** no nome do arquivo; se errar, a arte
some no servidor e ninguém descobre até o cliente cobrar
3. **O status vive numa pasta de rede** — o Tiny sabe do pedido no começo e no
fim, no meio ninguém sabe
4. **Ninguém mede o tempo de espera** entre etapas
O item 4 é o que mais importa. Sem ele, não dá para saber se o gargalo é máquina
ou designer — e essa resposta decide se vale montar o terceiro turno.
---
Três componentes que fazem a sala de DTF receber arte 24 horas e medir o próprio tempo.
> **Comece pelo `TAREFAS.md`** — ordem de execução com critério de aceite.
> Depois `ESPECIFICACAO.md` — ele traz as fases de implementação, as
> funcionalidades uma a uma, e o que mudou depois das reuniões com os designers e
> a sala em 02/09/2026. Este README cobre a arquitetura.
```
TAREFAS.md o que fazer, em ordem, com critério de aceite
ESPECIFICACAO.md as funcionalidades uma a uma e o que mudou nas reuniões
API.md contratos de todos os endpoints
schema.sql banco completo, nuvem e local
README.md arquitetura e decisões (este arquivo)
portal/ FastAPI na nuvem · recebe a arte, valida, repete, fatia, carimba
agente/ script na fábrica · traz o arquivo aprovado para o servidor
kanban/ FastAPI local · a sala move o card, o Tiny recebe o marcador
static/kanban.html front já conectado à API
```
---
## O que cada um faz
**portal** — aberto na internet, roda em VPS. O Tiny avisa por webhook que
nasceu um pedido; o portal cria um link com token e manda no WhatsApp. O cliente
sobe o arquivo direto para o storage, sem passar pelo servidor. O robô valida,
empilha as repetições, fatia em partes de no máximo 15 m e carimba a última.
**agente** — roda como serviço no servidor da fábrica. Busca o que foi aprovado
e grava na pasta `00_ARTE_RECEBIDA`. Se a internet cair, ele para e o portal
continua recebendo; quando voltar, baixa o acumulado.
**kanban** — tela da sala, só na rede interna. Mover o card grava o movimento,
move o arquivo entre as pastas e enfileira o marcador no Tiny e o WhatsApp.
---
## Regras do processo, em código
| Regra | Onde está |
|---|---|
| Área útil 57 × 97 cm (30 mm de rodapé) | `preflight.AREA_UTIL_CM` |
| DPI efetivo mínimo 150, ideal 300 | `preflight.validar()` |
| Largura máxima 57 cm | `preflight.validar()` |
| Repetição da arte | `preflight.empilhar()` |
| Montagem empilhada de vários arquivos | `preflight.montar_folha()` |
| Normalização por natureza do arquivo | `preflight.normalizar()` |
| CDR entra sem validar, etiqueta "conferir" | `preflight.FORMATOS_SEM_VALIDACAO` |
| Máximo 20 m por arquivo | `preflight.fatiar()` |
| Carimbo: QR 20 mm + texto, só preto | `preflight.carimbar()` |
| Um carimbo por pedido, na última parte | `preflight.processar()` |
**A regra que mais pega arquivo ruim é o DPI efetivo.** O valor gravado no
cabeçalho mente com frequência — o cliente escala a imagem e o programa mantém
"300". O que vale é `pixels ÷ (centímetros comprados ÷ 2,54)`.
---
## Decisões já tomadas
Não precisam ser rediscutidas. Estão implementadas no código.
| Assunto | Decisão | Por quê |
|---|---|---|
| Chave do pedido | **número do Tiny** | é o que a empresa inteira já usa |
| Gatilho do link | **webhook do Tiny**, não polling | o link sai no segundo em que o pedido nasce |
| Hospedagem do portal | **fora da fábrica** | se a internet cair às 3h, o cliente sobe do mesmo jeito |
| Hospedagem do kanban | **rede interna** | é tela de operação, funciona sem internet |
| Upload | **direto para o storage**, URL pré-assinada | 200 MB passando pelo VPS derrubaria o processo |
| Limite do site | VNDA não aceita 200 MB | por isso o portal é externo |
| Faixa do rodapé | **30 mm** → área útil 57 × 97 cm | cabe o QR de 20 mm com folga |
| QR | **20 mm**, leitura por **celular** | não haverá coletor; celular lê com folga |
| Conteúdo do QR | **URL do card no kanban** | a revisão bipa e cai na tela do pedido |
| Carimbo | **um por pedido**, na última parte | é ela que fecha o pedido |
| Cor do carimbo | **só canal preto** | sem branco de apoio: economiza a tinta mais cara |
| Repetição | **campo no portal**, robô empilha | tira do designer o trabalho mais braçal |
| Limite por arquivo | **20 metros** | 100 m viram 5 arquivos |
| Tamanho do upload | **5 GB** | é o que os PCs da sala aguentam abrir |
| Retenção | **30 dias** | depois apaga do storage |
| Montagem | **empilhada**, sem encaixe lado a lado | são artes de 1 m em sequência |
| CDR | entra com etiqueta **conferir** | a licença do Corel é de estação, não de servidor |
| Movimento | **no kanban**, pasta espelha | sem watchdog: uma peça a menos para manter |
| Qualidade na mensagem | **% e DPI** juntos | um para leigo, outro para quem é do ramo |
| Marcadores no Tiny | **3, não 7** | o kanban é a fonte de verdade |
| Avisos ao cliente | **3 + correção** | sete viraria spam e ele para de ler |
| Designer de madrugada | **não haverá** | a noite imprime o que já estava tratado |
### O que o robô NÃO faz
Tratar arte continua sendo trabalho do designer: remover fundo quando o cliente
não removeu, corrigir cor para o perfil da impressora, e julgar o que não presta
mesmo passando na validação técnica — degradê que vira mancha branca, traço fino
que some na aplicação.
O robô filtra o que **nem deveria chegar** ao designer: DPI baixo, fundo não
removido, largura acima de 57 cm, arquivo corrompido. Isso volta ao cliente
automaticamente e não consome minuto de arte nenhum.
**Isso gera o número que decide o terceiro turno:** quanto do tempo do designer é
retrabalho de arquivo ruim, e quanto é trabalho de verdade. O kanban mede isso na
primeira semana, olhando o tempo entre `Arte recebida` e `Arte tratada`.
### Referência de mercado
O **dtflexprint.com.br**, de Salvador, já opera este modelo: upload no próprio
produto, pré-flight automático, sem receber arquivo por WhatsApp. **Cobram
R$ 55,00 pela mesma unidade de 57 × 100 cm que vendemos a R$ 14,90.**
A guerra de preço é local; o mercado não é. Depois de resolver a dinâmica de
horário, o passo seguinte é logística nacional.
---
## Fase 1 — limpeza dos marcadores do Tiny
Primeira entrega do projeto. Não depende de nada e pode começar hoje.
### Regra que não pode ser violada
**Limpar a lista de sugestão, não o histórico dos pedidos.** Apagar marcador de
pedido já fechado destrói o pouco de dado que existe sobre o passado. O que
atrapalha é a lista de opções aparecer poluída na hora de marcar um pedido novo.
### O que sai da lista
Todas as variações de horário e duração digitadas à mão. Existem dezenas, quase
todas com um ou dois pedidos:
```
inicio 14:45 inicio13:34 inicio 12:38hr inicio 930 INICIO 15:09
11:10 15:30hr 1hrs 3h 3hr
2hrs 1h30min +20m +30min de correcao
+1hr (correcao)
```
Alguém na sala está tentando registrar tempo de máquina há meses, à mão, sem
padrão nenhum. **O kanban passa a fazer isso sozinho e melhor**, com carimbo
automático de entrada e saída em cada etapa. Esse trabalho manual deixa de
existir — e é bom dizer isso à sala, porque é esforço real que estava sendo
desperdiçado.
### O que fica
| Marcador | Pedidos | Para quê |
|---|---|---|
| `fila de impressao` | 2.004 | coluna do kanban |
| `CORRECAO DTF` | 116 | coluna do kanban |
| `Maq 1` … `Maq 6` | 3.406 | histórico de máquina |
| `multiempresa`, `VALE`, `Troca`, `BOLETO NEXSTAR SICCOB` | — | outra natureza, não mexer |
### O que nasce
`DTF-PRODUCAO` e `DTF-PRONTO`. Só dois — ver a seção de marcadores acima.
### Atenção: os marcadores de duração NÃO são poluição
```
30 min → 2.430 pedidos
1 hora → 833 pedidos
3 horas → 40 pedidos
```
Estes são padronizados e parecem ser a **estimativa de minutos de máquina do
pedido** — exatamente o dado que o kanban precisa para calcular fila contra
capacidade.
Hoje o código estima esse tempo pelos metros, a 20 m/h
(`kanban/main.py`, campo `minutos_maquina`). Mas arte complexa pode demorar mais
que arte simples do mesmo tamanho.
**Confirmar com o Thales antes de apagar:** esses marcadores são estimativa de
tempo de máquina ou outra coisa? Se forem estimativa e refletirem algo que a
sala sabe e o metro não captura, viram **campo no card**, não marcador — e o
cálculo de capacidade fica mais preciso que a conta por metragem.
---
## Como o arquivo chega na máquina
**Desenho novo, decidido em 30/08/2026.** O PC central deixa de ser passagem
obrigatória e vira posto de validação e exceção.
### Antes
```
PC central → abre o arquivo, aplica o SPOT à mão, salva de novo na pasta
↓
PC da máquina → alguém pega o arquivo e toca no software
```
Todos os arquivos passavam por uma máquina e uma pessoa. Se ela saía, a fila
parava. Às 3h da manhã, não havia ninguém.
### Agora
```
portal → robô (valida · monta · SPOT · carimba) → fila única
↓
operador clica "puxar próximo" na máquina
↓
arquivo cai na hot folder 30_MAQ1 … 30_MAQ6
↓
FlexiPRINT processa sozinho de lá
```
O operador abre no Flexi, roda, e arrasta o card para Finalizado. **Um clique no
começo, um arraste no fim.** Nunca escolhe pedido, nunca procura arquivo, nunca
decide ordem.
### Por que puxada e não empurro
Empurrar exigiria adivinhar qual máquina estará livre daqui a duas horas. Se uma
travar, a fila dela para enquanto outra fica ociosa, e alguém remaneja na mão.
**Por puxada nunca desbalanceia.**
Três regras que sustentam isso:
| Regra | Por quê |
|---|---|
| Reserva expira em **3 min** | ajustado pela sala: 15 era demais |
| Devolver põe no **topo**, não no fim | o pedido já esperou uma vez |
| **Um pedido por vez** | a sala preferiu assim a puxar lote de trabalho |
Ver `/api/puxar` e `/api/devolver` em `kanban/main.py`.
### O que sobra para o PC central
Validação, exceções e o SPOT manual dos casos que a regra automática não cobre.
Deixa de estar no caminho crítico de todo pedido.
---
## A validar com os designers e a sala — SPOT
O canal branco passa a ser gerado pelo robô. A regra base é medível e cabe em
poucas linhas, mas existem variações que **precisam ser confirmadas antes de
codificar**.
### O que já é regra, não julgamento
**Choke** (contração do branco) evita que o branco apareça na borda quando o
registro sai levemente fora. O problema é que em traço fino ele come a linha:
um filete de 0,4 mm a 300 DPI tem ~5 px; tirando 2 de cada lado sobra 1, e o
branco quase some.
Isso é medível. O robô mede a espessura de cada elemento e aplica choke
proporcional — **na mesma arte, texto grosso encolhe e filete fino não**:
| Espessura do elemento | Choke |
|---|---|
| acima de 2 mm | 2 px |
| 1 a 2 mm | 1 px |
| abaixo de 1 mm | nenhum |
### O que precisa ser validado
| Ponto | Pergunta para a sala |
|---|---|
| **Valores do choke** | 2 px acima de 2 mm está certo, ou vocês usam outro número? |
| **Limite do traço fino** | abaixo de quanto vocês nunca aplicam contração? |
| **Branco em degradê** | onde a arte tem transparência parcial, o branco fica meio transparente. Vocês ajustam? Como? |
| **Densidade do branco** | arte escura pede mais branco que arte clara? Isso é ajustado hoje? |
| **Tipo de tecido** | o branco muda conforme o cliente vai aplicar em algodão ou poliéster? |
| **Casos que nunca dão certo no automático** | quais? |
### Como conduzir a conversa
Não perguntar "o que vocês fazem". Pedir **cinco arquivos**:
- dois que sempre saem bem no automático
- dois que sempre precisam de ajuste
- um que já deu errado
Com esses cinco dá para descobrir se a regra cabe em três linhas de código ou se
há julgamento de verdade envolvido.
**Aposta:** 80% dos casos cabem na regra de espessura. Os 20% restantes viram
uma etiqueta `spot manual` no card e vão para o PC central como **exceção, não
como padrão**.
**Isso decide a madrugada:** se o SPOT manual for exceção, o turno da noite roda
no automático e deixa as exceções para o dia. Não precisa de designer de plantão.
---
## Insumos: tudo que passa pelo depósito já está medido
O aproveitamento do filme sai das transferências para o depósito de impressão no
Tiny. O mesmo caminho serve para todo o resto:
| Insumo | Consumo teórico por 100 m | O que o desvio revela |
|---|---|---|
| Filme | 100 m comprados → 90 úteis | acerto, prova de cor, refile, ponta |
| Tinta | 1,5 L | purga excessiva, vazamento, apontamento errado |
| Poliamida | 2,5 kg | idem |
| Peças de máquina | — | custo por metro, sem teórico |
O sistema sabe quantos metros imprimiu, então calcula o teórico e compara com o
transferido. Tinta a 1,8 L por 100 m significa 20% de desvio — hoje ninguém
saberia.
---
## FlexiPRINT · hot folder confirmado, arquitetura a definir
A documentação oficial da SAi confirma: **o Flexi tem hot folder**, uma pasta
por dispositivo de saída, monitorada continuamente. Arquivo copiado ou movido
para dentro entra na fila automaticamente. Por padrão fica em
`C:\Program Files\[Software]\Jobs`.
A versão 21 traz dois recursos relevantes:
- **"RIP only" ao receber na hot folder** — processa o arquivo sem esperar a
máquina ficar livre
- **Opções melhoradas de branco para DTF** e ferramenta de fundo transparente
### O problema de memória
Relato da sala: **o PC que roda a máquina não aguenta preparar outro arquivo ao
mesmo tempo** — o RIP consome memória demais. É por isso que existe o PC
central hoje.
Duas arquiteturas possíveis, e a escolha depende do que a licença de vocês
permite:
**A · Production Manager centralizado.** Se o Flexi permitir gerenciar a fila de
várias impressoras a partir de um PC só, o **PC central vira servidor de RIP** e
os PCs das máquinas ficam leves — só transmitem dados para a impressora.
Resolve o problema sem trocar computador.
**B · RIP distribuído.** Cada PC ripa o próprio trabalho. Aí o "RIP only" ajuda,
porque o arquivo é processado enquanto a máquina ainda roda o anterior — mas o
PC precisa aguentar as duas coisas.
**Levantamento pendente com Thales e Alexandre** (ver `dtf-roteiro-reuniao.md`,
bloco E2):
- edição e versão do Flexi **em cada máquina** — se forem diferentes, o
comportamento da hot folder varia entre elas
- a hot folder aceita pasta de rede ou só local?
- existe "RIP only" na versão instalada?
- o Production Manager centraliza várias impressoras?
- o Flexi gera o canal branco a partir da transparência?
- as licenças são por PC ou flutuantes?
**Enquanto isso não fecha, não programe a fase 4.** O desenho da hot folder
depende de a pasta de rede funcionar.
---
## Instalação
### Portal (VPS Ubuntu)
```bash
apt install -y python3.12-venv libvips-tools postgresql nginx certbot
python3 -m venv /opt/dtf/venv && source /opt/dtf/venv/bin/activate
pip install -r requirements.txt
cp .env.exemplo .env # preencher
cd portal && uvicorn main:app --host 0.0.0.0 --port 8000
```
Nginx faz proxy para a 8000 e o certbot cuida do TLS em
`arte.dropstaratacado.com.br`.
### Agente (servidor da fábrica)
Windows, como serviço:
```
nssm install DtfAgente "C:\Python312\python.exe" "C:\dtf\agente.py"
nssm set DtfAgente AppEnvironmentExtra BASE_PORTAL=... AGENTE_TOKEN=...
nssm start DtfAgente
```
Linux: usar `agente/dtf-agente.service`.
### Kanban (servidor da fábrica)
```bash
cd kanban && uvicorn main:app --host 0.0.0.0 --port 8080
```
O front é o protótipo `dtf-portal-kanban.html`, apontando para `/api/quadro`
e `/api/mover`. Colocar em `kanban/static/kanban.html`.
---
## Estrutura de pastas no servidor
```
\\servidor\DTF\
00_ARTE_RECEBIDA 10_ARTE_TRATADA 20_FILA_IMPRESSAO
30_IMPRIMINDO 40_CORRECAO 50_APLICACAO
60_FINALIZADO _ERRO _log
```
O banco é a fonte de verdade e a pasta é espelho. Se divergirem, o banco ganha.
---
## Tabelas
| Tabela | Onde | Para quê |
|---|---|---|
| `pedido` | nuvem | número do Tiny, cliente, token e validade |
| `arte` | nuvem | arquivo, DPI, status, motivo da recusa |
| `parte` | nuvem + local | cada arquivo gerado pelo fatiamento |
| `card` | local | posição atual no kanban |
| **`movimento`** | local | **de onde saem todos os números** |
| `job` | local | fila de marcador e WhatsApp, com retentativa |
### Normalização: o cliente manda o que quiser
Não existe "formato certo" único — normalizar tudo para um só jogaria fora o
melhor de cada tipo. A regra é por **natureza do conteúdo**:
| Chega como | Sai como | Por quê |
|---|---|---|
| PDF, AI, SVG, EPS | **PDF**, mantendo vetor | rasterizar aqui é perder qualidade |
| PNG, TIF, PSD, PSB | **TIF** com alfa, LZW | é o que a sala já usa |
| CDR | não converte | sem Corel no servidor |
**O original é sempre preservado.** Pedido dos designers: se a conversão sair
ruim num caso, eles voltam ao arquivo do cliente.
**Resolve o limite dos 5 metros.** Os designers relatam abrir PDF no Corel para
não rasterizar, exportar, abrir no Photoshop — e esbarrar em 5 metros. O limite
não é do Photoshop: é do formato **PSD**, que trava em 30.000 px por dimensão,
o que a 150 DPI dá 5,08 m. PSB vai a 300.000 px, ou 50 metros. Em PDF vetorial
não há esse limite.
**Melhor ainda:** PDF vetorial pode ir do portal direto para a hot folder. O
FlexiPRINT rasteriza na resolução exata da máquina, na hora de imprimir —
qualidade melhor que qualquer caminho manual, e uma etapa a menos.
**CMYK vira RGB na normalização.** Arquivo em CMYK é uma das causas de cor
errada no DTF: o cliente monta em CMYK, a máquina trabalha em RGB, e a cor muda.
### Retrabalho: meta por causa, não meta única
SKU `CRRMP.TX.100CM`. A Mayana abre e classifica a causa; Thales ou Alexandre
autorizam, porque o retrabalho da casa entra na meta e na remuneração deles.
**A causa fica travada depois de aberta** — com indicador atrelado a bônus,
haveria incentivo para reclassificar falha de máquina como culpa do cliente.
Quem discorda contesta, e a contestação fica registrada.
| Causa | Responsável | Hoje | Meta |
|---|---|---|---|
| Falha de impressão | Thales e Alexandre | 1,8% | 0,4% |
| Perfil de cor | Thales e Alexandre | — | 0,4% |
| Erro de tratamento | designers | 1,1% | 0,4% |
| Erro de pedido | comercial | — | 0,4% |
| Arte do cliente | — | 0,9% | fora da meta |
| **Total da casa** | | **2,9%** | **1,6%** |
**Perfil de cor virou causa própria.** Cor errada é o retrabalho mais comum de
DTF e quase nunca é defeito de máquina. Separando de "falha de impressão", dá
para saber quanto é arte em CMYK, quanto é monitor do cliente e quanto é perfil
da impressora desatualizado — só o último é da casa.
**Coluna nova: aprovação de cor.** Antes de imprimir o pedido inteiro, sai uma
amostra, foto pelo WhatsApp e o cliente aprova. Dispara sozinho em arquivo acima
de 10 m, primeiro pedido do cliente, ou cor fora do gamut CMYK. Custa
centímetros de filme e evita metros de reposição.
**Não existe coluna de aplicação.** A sala só imprime o filme — quem aplica na
peça é o cliente.
Meta única de 1,5% penalizaria o designer por falha de máquina e exigiria
derrubar 64% do retrabalho de uma vez. Meta inatingível empurra para
reclassificar causa, não para reduzir defeito. Ver `META_POR_CAUSA` e
`/api/relatorio/retrabalho`.
### Os marcadores de duração são estimativa de máquina
Confirmado pela sala em 02/09/2026. `30 min` (2.430 pedidos), `1 hora` (833) e
`3 horas` (40) **não são lixo de cadastro** — são a estimativa de quanto tempo o
trabalho leva na máquina.
**Não somem: viram o campo `minutos_maquina` no card.** É esse número que o
kanban usa para calcular fila contra capacidade do dia. Sem ele, a estimativa
sai dos metros a 20 m/h — mas arte complexa demora mais que arte simples do
mesmo tamanho, e a sala sabe qual é qual.
**Quem preenche:** o sistema sugere pelo tamanho; quem trata a arte ajusta só
quando foge do normal.
| Marcador | Destino |
|---|---|
| `inicio 14:45` e variações | **some** — o sistema carimba a hora do movimento real |
| `Maq 1` … `Maq 6` | vira etiqueta no card, sai do Tiny |
| `fila de impressao` | vira coluna do kanban |
| `CORRECAO DTF` | continua no Tiny — o comercial precisa ver |
| **`30 min` · `1 hora` · `3 horas`** | **vira campo `minutos_maquina`** |
### Marcadores no Tiny: três, não sete
O kanban é a fonte de verdade da operação. O Tiny recebe só o estágio grosso,
para quem não abre o kanban — comercial no telefone, expedição, celular:
| Movimento no kanban | Marcador no Tiny |
|---|---|
| recebida · tratada · fila · aplicação | nenhum |
| entra em **Imprimindo** | `DTF-PRODUCAO` |
| vai para **Correção** | `CORRECAO DTF` |
| **Finalizado** | `DTF-PRONTO` |
Três chamadas por pedido em vez de sete. A 213 pedidos/dia, 640 requisições
em vez de 1.500 — folga no limite da API e menos job para monitorar.
A máquina que imprimiu fica só no kanban.
### Artes guardadas para recompra
Prefixo separado no storage: `artes-cliente/{numero_cliente}/`, retenção de
**12 meses** — contra os 30 dias do arquivo de trabalho. Guarda-se só a **arte
tratada**, não o original: é menor e é a que vai para a máquina.
O cliente acessa por um link permanente dele (token de cliente, não de pedido),
numa página "minhas artes", e pede reimpressão com um clique — que abre pedido
novo no Tiny. Resolve o caso de quem quer a mesma arte de dois meses atrás.
### Abrir o arquivo pelo kanban · sem instalar nada
**Decisão de 30/08/2026: nada instalado nos PCs da sala. Só a tela.**
Navegador não lança programa externo — é bloqueio de segurança, sem contorno.
Então o desenho é:
1. **Miniatura no card** — o operador confere a arte sem sair da tela
2. **Botão copia o caminho** — cola no FlexiPRINT
3. **File System Access API** — Chrome ou Edge pede permissão à pasta de rede
uma vez; a partir daí o kanban lê e move arquivo direto do navegador
O item 3 tem uma consequência boa: **a movimentação de arquivo sai do backend.**
O `mover_arquivos()` em `kanban/main.py` vira opcional — quem move é a própria
tela. Menos código no servidor e menos chance de o estado divergir.
O que continua manual: colar o caminho no Flexi. Uma tecla a mais que o ideal.
Handler local resolveria, mas exige instalação em cada máquina — descartado.
### Aproveitamento do filme: sai do Tiny, sem apontamento novo
A Altus transfere o insumo para um depósito de impressão antes de rodar, porque
também **revende insumo** — o depósito separa consumo interno de revenda.
Isso significa que o consumo de filme **já é registrado hoje**. O painel só lê a
movimentação desse depósito e divide os metros faturados pelos metros
transferidos. Nenhum gesto novo para a sala.
Ver `DEPOSITO_IMPRESSAO` e `tiny.transferido_para_deposito()`.
`movimento` é a mais importante. Dela saem tempo por etapa, fila por máquina,
taxa de retrabalho e ocupação real — os números que hoje não existem em lugar
nenhum. Ver `/api/relatorio/etapas`.
---
## Decisões que dependem de você, Wagner
1. **Servidor da fábrica é Windows ou Linux?** Muda como o agente vira serviço.
2. **A conta do Tiny já tem aplicação na API v3?** Precisamos de client id e secret.
3. **Confirmar na documentação do Tiny** o endpoint de marcadores e o limite de
requisições por minuto. `tiny.py` isola isso — se mudar, muda só ali.
4. **O número do WhatsApp do DTF migra para a API oficial da Meta**, ou fica com
provedor tipo Z-API? A oficial exige template aprovado para mensagem iniciada
por nós, mas não corre risco de bloqueio.
5. **Latência da integração VNDA → Tiny.** É ela que define quanto tempo passa
entre a compra e o link chegar no cliente. Se for alta, o argumento de
rapidez do projeto perde força.
6. **Retenção dos arquivos: 90 dias.** Configurar regra de ciclo de vida no
bucket. A arte é propriedade do cliente.
---
## Segurança
- Token do link: UUID v4, 7 dias, um por pedido
- HTTPS obrigatório no portal, Let's Encrypt com renovação automática
- Kanban **sem porta aberta para fora** — se precisar de acesso remoto, VPN
- ClamAV no arquivo recebido antes de liberar para o agente
- Dump diário do Postgres para o storage
- Heartbeat do agente a cada 5 min; sem sinal por 15 min, alertar o TI
Serviço silencioso parado é pior que erro barulhento — ninguém percebe até o
cliente cobrar.
---
## Ordem de entrega
| Fase | Entrega | Já dá valor sozinha? |
|---|---|---|
| 1 | Limpar marcadores — ver seção própria | **sim**: destrava o kanban e some com trabalho manual da sala |
| 2 | Portal + robô + storage no ar | **sim**: recebimento 24h e recusa automática |
| 3 | Agente rodando como serviço | **sim**: arquivo cai na pasta sozinho |
| 4 | Kanban + Tiny + WhatsApp | é o que transforma movimento em número |
As fases 2 e 3 entregam sozinhas. A fase 4 é onde a medição começa.

175
SECURITY_REPORT.md Normal file
View File

@@ -0,0 +1,175 @@
# Local security closeout
Date: 2026-09-15
## Scope and conclusion
This report covers the localhost-only Site, Portal/API, Kanban, PostgreSQL,
MinIO, ClamAV, and fake integration worker. It does not approve production use
or real Mercado Pago, R2, Tiny/Olist, WhatsApp, freight, or other providers.
Malware scanning is a quarantine control; it is not artwork or print pre-flight.
The local stack is healthy and its security regressions pass. Existing orders
and the named PostgreSQL/MinIO volumes were preserved. Open dependency and image
findings remain and are recorded below; there is no claim of zero vulnerabilities,
complete OWASP compliance, or production readiness.
## Implemented controls
- Loopback-only published ports, local-only adapter guards, internal API/worker/
database/scanner network, Host checks, cross-origin write rejection, CSP,
frame denial, MIME sniffing protection, and no interactive API documentation.
- Escaped untrusted filenames and browser regression coverage with CSP bypassed.
- Expiring, revocable, hashed HttpOnly operator sessions. No Basic credentials or
operator password are retained in browser storage.
- Seven-day revocable customer sessions, stronger scrypt password hashes, legacy
verification and upgrade, comparable missing-account password work, and login
throttling.
- Upload extension/size/count quotas, storage quotas, private UUID object keys,
exact signed multipart `Content-Length`, completion/ownership checks, and
five-minute signed downloads.
- Files start `pending`. Only `clean` files may be quoted, approved, paid,
downloaded, attached as final files, or moved into queue/printing states.
Unknown, scanner error, unsafe/encrypted, over-limit, and malware outcomes fail
closed. Rejected/error files expire within three days.
- Restricted PostgreSQL and MinIO runtime identities provisioned separately from
administrator credentials. Containers use read-only filesystems/capability
drops where compatible.
- Structured redacted security logs plus a 30-day PostgreSQL event table. Worker
health includes outbox progress, scan-thread liveness, and a live ClamAV PING.
The alert summary also reports signature age.
- Logout revokes server sessions and clears checkout keys and IndexedDB File blobs
across open Site tabs.
- Production delivery definitions use unprivileged application/web users,
read-only filesystems, dropped capabilities, external Swarm secrets, private
service networking, immutable base images, commit-SHA rollback tags,
health-monitored rolling updates, and automatic application rollback. A
fail-closed source/configuration gate prevents the current local-only runtime
from being released as production.
## Verified checks
All results below were observed against the final application source. Smoke and
workflow were repeated after the refreshed PostgreSQL 17 image was activated.
| Check | Result |
|---|---|
| Pricing parity against Site JavaScript | Pass: all 4,444 comparisons plus invalid inputs |
| Security regression | Pass: headers, Host/origin, sessions, quotas, multipart signing, throttling |
| Real ClamAV EICAR regression | Pass: rejected and blocked from download/quote; clean control released |
| Smoke workflow | Pass: multipart, ownership, all modes, pricing/freight, payment idempotency, states/history |
| Customer/final-file workflow | Pass: identity, revocation, corrections, secure downloads, invalidation and queue gate |
| Runtime security | Pass: PostgreSQL/MinIO least privilege, hashes, quarantine and scanner failure behavior |
| Retention | Pass: expired bytes removed, live bytes preserved, metadata retained |
| Browser workflow | Pass: end-to-end flow, filename XSS probe, no stored operator password, logout blob cleanup |
| Dependency lock | Pass: 26 exact packages, SHA-256 hashes on every entry, direct-pin parity, hash-enforced image build, `pip check` |
| Staging readiness gate | Pass with synthetic non-secret metadata in a network-disabled container; unsafe/incomplete regression cases rejected |
| Production delivery definitions | Pass: unit/syntax/YAML checks, Swarm render, immutable-base image builds, non-root read-only web runtime and Host rejection |
| Production source preflight | Expected block: local-only/fake adapters and Docker secret-file loading are not production implementations |
| Production ClamAV runtime | Pass: UID 100:101, read-only filesystem, no capabilities/no-new-privileges, live PONG |
| Repository secret scan | Pass: no HIGH/CRITICAL Trivy secret findings; release workflow enforces the same gate |
## Malware-scanner operations
The scanner image is digest-pinned and has no external network route. It uses
the signatures bundled into that image. On closeout it reported ClamAV
`1.5.4/28122/Sun Sep 13 06:26:25 2026`; the summary calculated 31.8 hours of age,
below the seven-day alert threshold.
The multipart transport supports uploads up to 5 GiB, but `SCAN_MAX_BYTES` and
ClamAV stream limits release at most 128 MiB by default. Larger files remain
blocked. Supporting larger files requires a deliberate resource/timeout design,
not simply increasing the upload limit.
Run:
```bash
docker compose exec -T api python -m local.security_status
```
An exit status of 1 requires review. At closeout, attention was expected because
the regression suite produced two rate-limit alerts, 20 synthetic failed operator
logins, and two rejected `SECURITY-EICAR.cdr` fixtures. Both rejected fixtures
expire on 2026-09-17. ClamAV was available and its signatures were not stale.
Do not automatically dismiss later alerts: verify filename, timestamp, test run,
scanner availability, and signature age. Treat non-test rejected files, scanner
errors, unexplained authentication bursts, or stale signatures as incidents.
## Dependency and image audit
`pip-audit` inspected the exact packages installed in the hash-enforced rebuilt API
and found no known Python advisories on 2026-09-15. All 26 direct and transitive
runtime packages are pinned with artifact hashes in `local/requirements.lock`.
`local/lock_dependencies.sh` regenerates it in a disposable Python 3.12 container.
This is a point-in-time package-database result, not proof that the dependencies
or image are vulnerability-free. Scheduled lock refresh and audit automation remain
unfinished.
Trivy JSON reports are in `output/security/`. HIGH/CRITICAL results after the
available custom-image package upgrades were:
| Image | Findings | Qualification |
|---|---:|---|
| API | 46 HIGH | 44 Debian records had no fix; Trivy's two Python records named `msgpack` and `setuptools`, which `pip list` confirmed are absent from the runtime. Trivy warned that the third-party SBOM may be inaccurate. |
| Site | 5 HIGH | Nginx package records with fixes listed by Trivy, but the current official `nginx:1.28-alpine` image/repository did not supply them. |
| PostgreSQL 17 | 30 HIGH, 1 CRITICAL | Nine Alpine library records and 22 records in `/usr/local/bin/gosu`; presence is confirmed, reachability through this local stack is not. |
| MinIO | 102 HIGH, 6 CRITICAL | Findings span the MinIO and `mc` binaries and two OS packages. A verified local clean-object archive now exists, but the old pinned release was not changed without a tested version-migration and rollback plan. |
| ClamAV | 0 HIGH/CRITICAL | This scan result is not a guarantee that no vulnerability exists. |
| Production API validation image | 55 HIGH, 3 CRITICAL | 44 records had no fix. Fourteen listed fixes, but the `msgpack` and `setuptools` records came from a third-party SBOM and both packages were confirmed absent with `pip list`; the remaining fixed records are Debian packages. |
| Production web validation image | 52 HIGH, 2 CRITICAL | All 54 records listed fixed Alpine versions. A current approved Nginx base digest must replace the locally available validation base before release. |
Scanner presence is evidence, while exploitability/reachability requires separate
analysis. MinIO and the remaining base-image findings block any production-readiness
claim even though ports are loopback-only here. The production validation reports
are `production-api-container-audit.json` and
`production-web-container-audit.json`; both make the release workflow's
HIGH/CRITICAL gate fail. Counts reflect the 2026-09-15 Trivy database and can
change when the advisory database or selected base digest changes.
## Production delivery security boundary
The files in `deploy/` and `.gitea/workflows/` are a guarded delivery mechanism,
not an approval to operate the current application on the public internet. The
single Gitea workflow requires a protected Docker runner, exact commit checkout,
digest-pinned base/scanner images, regressions, and HIGH/CRITICAL Trivy gates.
It publishes both `latest` and the full commit SHA, then calls the Portainer
webhook. Application/provider secrets are created directly as versioned external
Swarm secrets and never cross the workflow. Rollback selects the prior commit SHA
in Portainer and does not roll back the database.
The gate currently identifies real blockers: production adapters and authenticated
payment webhooks are absent, the runtime intentionally rejects production/R2,
Docker secret-file configuration is not implemented, approvals are unset, and
the existing image findings are unresolved. These checks must be satisfied by
implementation and review, not by replacing the checks with permissive values.
## PDF.js review
The Site loads version 3.11.174 from a versioned CDN URL. That version is affected
by [GHSA-wgrm-67xf-hhpq](https://github.com/mozilla/pdf.js/security/advisories/GHSA-wgrm-67xf-hhpq),
whose documented workaround is `isEvalSupported:false`; the existing Site call
already sets that value, so the known eval path is mitigated and is not reported
as unmitigated. The newer
[GHSA-hq66-cqwq-w95j](https://github.com/mozilla/pdf.js/security/advisories/GHSA-hq66-cqwq-w95j)
affects versions from 5.6.83 up to the patched 6.2.108 and therefore does not
include 3.11.174.
Version 3.11.174 is nevertheless old, remotely loaded, and not a satisfactory
long-term dependency posture. Plan a compatibility-tested upgrade and preferably
vendor or integrity-pin the asset. Keep script execution disabled and do not
weaken CSP merely to support a preview.
## Before any staging or production work
- Resolve or formally accept current MinIO, PostgreSQL, Nginx, and Debian image
findings. Use the verified local clean-object bundle to rehearse a MinIO
migration and rollback; it is not a scheduled, offsite, or production backup.
- Schedule controlled dependency-lock refreshes and exact-runtime audits; review
and test every resulting version change.
- Establish a signature update/rebuild process that works without giving the
scanner an unrestricted external network route.
- Replace disposable local secrets; add TLS, production session/cookie settings,
account verification/recovery, centralized monitoring, and complete backup/
restore procedures.
- Re-run every security, malware, workflow, browser, retention, dependency, and
image check in the target staging architecture.

212
TAREFAS.md Normal file
View File

@@ -0,0 +1,212 @@
# Tarefas — em ordem de execução
> Cada tarefa tem **critério de aceite**: o que precisa acontecer para ela ser
> considerada pronta. Sem isso, "terminei" vira discussão.
>
> A ordem importa: as três primeiras não dependem de decisão de ninguém.
---
## Bloco A · Pode começar hoje
Nada aqui depende de resposta pendente.
### A1 · Limpar os marcadores do Tiny
**Aceite:** a lista de sugestão de marcadores só mostra os padrões. Os pedidos
antigos mantêm o histórico intacto.
- Criar `DTF-PRODUCAO`, `DTF-PRONTO`, `DTF-PROVA-COR`
- Remover da lista de sugestão: `inicio 14:45`, `inicio13:34`, `1hrs`, `3h`,
`+30min de correcao` e as demais variações digitadas à mão
- **Não apagar do histórico dos pedidos** — só da lista de opções
- **Preservar** `30 min`, `1 hora`, `3 horas`: são estimativa de máquina
### A2 · Contratar a infraestrutura
**Aceite:** `https://arte.dropstaratacado.com.br` responde com certificado
válido, e um arquivo de teste sobe e desce do storage.
- VPS Linux 2 vCPU / 4 GB · Hetzner, Contabo ou Hostinger
- Storage S3 compatível · Cloudflare R2 ou Backblaze B2
- Subdomínio + Let's Encrypt
- Bucket `dtf-artes` com ciclo de vida de 30 dias
- Prefixo `artes-cliente/` com 12 meses
- CORS liberado só para o domínio do portal
### A3 · Criar o banco
**Aceite:** `schema.sql` roda sem erro no Postgres e no SQLite.
- Postgres na nuvem · SQLite na fábrica
- Dump diário do Postgres para o storage
### A4 · Provisionar acessos
**Aceite:** um token do Tiny obtido por código, e um WhatsApp de teste enviado.
- Aplicação na API v3 do Tiny · client id e secret
- **Confirmar na documentação** o endpoint de marcadores e o limite de req/min
- WhatsApp: definir Meta oficial ou provedor
---
## Bloco B · Portal — entrega sozinho
Ao fim deste bloco a Altus **já recebe arte 24 horas**, mesmo sem kanban.
### B1 · Webhook e link
**Aceite:** um pedido criado no Tiny gera link e chega no WhatsApp em menos de
1 minuto.
- `POST /webhook/tiny` → cria pedido, token de 7 dias, dispara WhatsApp
- Idempotente: pedido repetido não duplica
- **Medir a latência VNDA → Tiny** e registrar
### B2 · Página de upload
**Aceite:** um arquivo de 5 GB sobe sem derrubar o VPS.
- URL pré-assinada, upload em partes
- Campo de repetição com prévia da conta
- Gabarito 57 × 97 cm para download
- Rate limit de 20 req/min por token
### B3 · Pré-flight
**Aceite:** dos cinco arquivos que os designers separarem, o robô decide igual
ao que eles decidiriam.
- `preflight.py` está pronto — integrar e testar
- Normalização: vetor → PDF, raster → TIF, CMYK → RGB, **original preservado**
- Repetição, fatiamento em 20 m, carimbo com QR de 20 mm
- CDR entra sem validar, etiqueta "conferir"
- **`carimbar()` precisa de teste visual impresso** antes de valer
### B4 · Mensagens
**Aceite:** as seis mensagens chegam com o texto certo, e a de correção pede
resposta.
- Lembrete de 1 hora sem arte
- Recusa com motivo em português
- Aprovação com **% e DPI**, metragem, tempo e horário previsto
- Em produção, correção, finalizado
### B5 · Antivírus
**Aceite:** um EICAR de teste é barrado e não chega ao agente.
- ClamAV em segundo plano, sem travar a fila
---
## Bloco C · Agente
### C1 · Serviço na fábrica
**Aceite:** derrubar a internet por 10 minutos e religar — o agente baixa o
acumulado sozinho, sem duplicar nada.
- Windows via NSSM ou Linux via systemd
- Webhook + polling de 60 s
- Download em arquivo temporário, renomeia só quando completa
- Verificação de SHA-256
- Idempotente pelo `arte_id`
### C2 · Monitoramento
**Aceite:** matar o processo dispara alerta em até 15 minutos.
- Heartbeat a cada 5 min
- Alerta ao TI se sumir
---
## Bloco D · Kanban como aba do PCP
### D1 · Quadro
**Aceite:** dois operadores clicando `puxar` ao mesmo tempo — só um pega.
- 7 colunas · **sem coluna de aplicação**
- Cronômetro por card, em tempo real
- Front pronto em `kanban/static/kanban.html`
- `?maq=4` abre filtrado na máquina
### D2 · Puxar e devolver
**Aceite:** puxar e não mover em 3 minutos devolve o pedido à fila sozinho.
- Reserva de **3 min** · **um pedido por vez**
- Devolver volta ao **topo**
- Círculo vermelho na máquina ocupada, botão desabilitado
### D3 · Movimento e integração
**Aceite:** desligar o Tiny, mover cards, religar — os marcadores entram sozinhos
e nenhum movimento se perde.
- Gravar o movimento **antes** de qualquer chamada externa
- Fila de jobs com retentativa em 1, 5 e 30 min
- Três marcadores no Tiny, não sete
- Arquivo acompanha o card entre as pastas
### D4 · Estimativa de máquina
**Aceite:** o card sugere os minutos e aceita ajuste manual.
- Campo `minutos_maquina`, sugerido por `metros/20*60`
- Importar os marcadores `30 min`, `1 hora`, `3 horas` dos pedidos existentes
### D5 · Retrabalho
**Aceite:** a causa não pode ser alterada depois de aberta, nem pelo autorizador.
- Abertura com causa obrigatória e evidência
- Alçada automática · reincidência sobe nível
- Contestação registrada sem alterar a causa
- **Meta ainda não definida** — `META_POR_CAUSA` isolado
### D6 · Painel e relatórios
**Aceite:** os quatro relatórios respondem com dados reais depois de uma semana.
- Filtros de período
- `/api/relatorio/etapas`, `/impressao`, `/aproveitamento`, `/retrabalho`
- Aproveitamento lendo as transferências do depósito no Tiny
---
## Bloco E · Só depois de medir
**Duas semanas com o kanban rodando antes de tocar nisto.**
### E1 · Decidir o terceiro turno
Depende de: metros/hora reais, tempo entre etapas, e quanto do tempo do designer
é retrabalho de arquivo ruim.
### E2 · SPOT automático
Depende do teste em `dtf-teste-branco-automatico.pdf`.
### E3 · PC central como servidor de RIP
Depende de: configuração atual, custo da licença (é **por PC**), e memória que o
RIP consome.
---
## Bloqueios · o que trava qual tarefa
| Tarefa | Espera por | De quem |
|---|---|---|
| B3 · pré-flight | os cinco arquivos de exemplo | designers |
| B3 · regras de choke | valor do choke e espessura mínima | designers |
| D5 · meta | meta geral ou por causa? | Marcus |
| E1 · terceiro turno | metros/hora reais | sala |
| E2 · SPOT | resultado do teste | sala |
| E3 · RIP central | config e licença do PC central | Wagner |
**Nada no bloco A, B1, B2, C e D1 a D4 está bloqueado.** Dá para chegar até o
kanban funcionando sem nenhuma dessas respostas.
---
## Ordem sugerida de trabalho
```
semana 1 A1 · A2 · A3 · A4
semana 2 B1 · B2
semana 3 B3 · B4 · B5 ← portal no ar, recebendo 24h
semana 4 C1 · C2 ← arquivo caindo na pasta sozinho
semana 5 D1 · D2
semana 6 D3 · D4
semana 7 D5 · D6 ← kanban completo
semana 8+ medir · depois E
```
**Ao fim da semana 3 a Altus já recebe arte de madrugada e recusa arquivo ruim
automaticamente**, sem nada ter mudado na sala. É o primeiro ganho real.

141
agente/agente.py Normal file
View File

@@ -0,0 +1,141 @@
"""
Agente da fábrica — traz a arte aprovada do portal para o servidor local.
Roda como serviço:
Windows: nssm install DtfAgente "C:\\Python312\\python.exe" "C:\\dtf\\agente.py"
Linux: systemd (ver dtf-agente.service no repositório)
Duas fontes de trabalho:
- webhook do portal (chega na hora)
- polling a cada 60 s (rede de segurança se o webhook falhar)
Se a internet cair, o agente para e o portal continua recebendo.
Quando voltar, ele baixa o acumulado. Nada se perde.
"""
from __future__ import annotations
import hashlib
import logging
import os
import sqlite3
import time
from datetime import datetime
from pathlib import Path
import httpx
PORTAL = os.environ["BASE_PORTAL"]
TOKEN = os.environ["AGENTE_TOKEN"]
RAIZ = Path(os.environ.get("PASTA_DTF", r"\\servidor\DTF"))
BANCO = Path(os.environ.get("KANBAN_DB", r"C:\dtf\kanban.db"))
INTERVALO = 60
HEARTBEAT = 300
PASTA_ENTRADA = RAIZ / "00_ARTE_RECEBIDA"
logging.basicConfig(
filename=RAIZ / "_log" / "agente.log",
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
log = logging.getLogger("agente")
def sha256(caminho: Path) -> str:
h = hashlib.sha256()
with open(caminho, "rb") as f:
for bloco in iter(lambda: f.read(1 << 20), b""):
h.update(bloco)
return h.hexdigest()
def registrar_no_kanban(arte: dict, arquivos: list[Path]) -> None:
"""Insere o card. Idempotente: a chave é o arte_id, não o nome do arquivo."""
con = sqlite3.connect(BANCO)
try:
con.execute("""
CREATE TABLE IF NOT EXISTS card(
arte_id INTEGER PRIMARY KEY,
pedido TEXT, cliente TEXT, metros REAL,
coluna TEXT DEFAULT 'rec', desde TEXT,
maquina TEXT, partes INTEGER
)""")
con.execute("""
INSERT OR IGNORE INTO card(arte_id,pedido,cliente,metros,desde,partes)
VALUES (?,?,?,?,?,?)""",
(arte["arte_id"], arte["pedido"], arte["cliente"],
arte["metros"], datetime.now().isoformat(timespec="seconds"),
len(arquivos)))
con.commit()
finally:
con.close()
def baixar(cliente: httpx.Client, arte: dict) -> bool:
PASTA_ENTRADA.mkdir(parents=True, exist_ok=True)
salvos: list[Path] = []
for parte in arte["partes"]:
destino = PASTA_ENTRADA / f"{arte['pedido']}_{parte['nome']}"
if destino.exists():
salvos.append(destino)
continue
tmp = destino.with_suffix(".parcial")
with cliente.stream("GET", parte["url"]) as r:
r.raise_for_status()
with open(tmp, "wb") as f:
for bloco in r.iter_bytes(1 << 20):
f.write(bloco)
tmp.rename(destino) # só aparece na pasta quando está completo
salvos.append(destino)
log.info("baixado %s (%s) sha=%s", destino.name,
f"{parte['metros']:.2f} m", sha256(destino)[:12])
registrar_no_kanban(arte, salvos)
cliente.post(f"{PORTAL}/api/artes/{arte['arte_id']}/baixada",
headers={"x-token": TOKEN}, timeout=15)
return True
def ciclo(cliente: httpx.Client) -> int:
r = cliente.get(f"{PORTAL}/api/artes", headers={"x-token": TOKEN}, timeout=30)
r.raise_for_status()
artes = r.json()
for arte in artes:
try:
baixar(cliente, arte)
except Exception as e:
log.error("falha na arte %s: %s", arte["arte_id"], e)
return len(artes)
def main() -> None:
log.info("agente iniciado · portal=%s · pasta=%s", PORTAL, RAIZ)
ultimo_hb = 0.0
with httpx.Client(follow_redirects=True) as cliente:
while True:
try:
n = ciclo(cliente)
if n:
log.info("%d arte(s) processada(s)", n)
except httpx.HTTPError as e:
# internet caiu: não é erro fatal, o portal segue recebendo
log.warning("sem conexão com o portal: %s", e)
except Exception as e:
log.exception("erro inesperado: %s", e)
agora = time.time()
if agora - ultimo_hb > HEARTBEAT:
try:
cliente.post(f"{PORTAL}/api/agente/heartbeat",
headers={"x-token": TOKEN}, timeout=10)
ultimo_hb = agora
except Exception:
pass # sem sinal por 15 min, o portal alerta o TI
time.sleep(INTERVALO)
if __name__ == "__main__":
main()

14
agente/dtf-agente.service Normal file
View File

@@ -0,0 +1,14 @@
[Unit]
Description=Agente DTF - baixa artes do portal
After=network-online.target
[Service]
Type=simple
User=dtf
EnvironmentFile=/etc/dtf/.env
ExecStart=/usr/bin/python3 /opt/dtf/agente.py
Restart=always
RestartSec=15
[Install]
WantedBy=multi-user.target

20
compose.staging.yaml Normal file
View File

@@ -0,0 +1,20 @@
name: dtf-staging-readiness
# This composition root is only a network-disabled readiness gate. It does not
# deploy the application or connect to R2, Mercado Pago, freight, Tiny/Olist,
# WhatsApp, a production database, or any other external service.
services:
readiness:
build:
context: .
dockerfile: local/Dockerfile
command: python -m local.staging_readiness /config/staging.env
volumes:
- ./staging/staging.env:/config/staging.env:ro
network_mode: none
read_only: true
tmpfs: [/tmp]
init: true
pids_limit: 64
cap_drop: [ALL]
security_opt: [no-new-privileges:true]

168
compose.yaml Normal file
View File

@@ -0,0 +1,168 @@
name: ${COMPOSE_PROJECT_NAME:-dtf-cloud}
x-app: &app
build:
context: .
dockerfile: local/Dockerfile
environment: &environment
APP_ENV: ${APP_ENV:-local}
DATABASE_URL: postgresql://${APP_DB_USER:-dtf_app}:${APP_DB_PASSWORD:-local-app-database-only}@db:5432/${POSTGRES_DB:-dtf_local}
S3_ENDPOINT: http://storage:9000
S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:-http://localhost:9000}
S3_BUCKET: ${S3_BUCKET:-dtf-local-artwork}
AWS_ACCESS_KEY_ID: ${S3_APP_USER:-dtf_app}
AWS_SECRET_ACCESS_KEY: ${S3_APP_PASSWORD:-local-app-storage-only}
AWS_DEFAULT_REGION: us-east-1
OPERATOR_USER: ${OPERATOR_USER:-operator}
OPERATOR_PASSWORD: ${OPERATOR_PASSWORD:-local-operator-only}
PAYMENT_ADAPTER: ${PAYMENT_ADAPTER:-fake}
FREIGHT_ADAPTER: ${FREIGHT_ADAPTER:-fake}
TINY_ADAPTER: ${TINY_ADAPTER:-fake}
WHATSAPP_ADAPTER: ${WHATSAPP_ADAPTER:-fake}
STORAGE_ADAPTER: ${STORAGE_ADAPTER:-s3-local}
MOCK_FREIGHT_CENTS: ${MOCK_FREIGHT_CENTS:-1500}
MAX_UPLOAD_BYTES: ${MAX_UPLOAD_BYTES:-5368709120}
UPLOAD_PART_BYTES: ${UPLOAD_PART_BYTES:-8388608}
STORAGE_QUOTA_BYTES: ${STORAGE_QUOTA_BYTES:-53687091200}
OWNER_UPLOAD_QUOTA_BYTES: ${OWNER_UPLOAD_QUOTA_BYTES:-10737418240}
MAX_PENDING_UPLOADS: ${MAX_PENDING_UPLOADS:-10}
SCAN_MAX_BYTES: ${SCAN_MAX_BYTES:-134217728}
networks: [local]
init: true
security_opt: [no-new-privileges:true]
cap_drop: [ALL]
read_only: true
tmpfs: [/tmp]
pids_limit: 128
logging:
driver: json-file
options: {max-size: "10m", max-file: "3"}
services:
scanner:
image: clamav/clamav@sha256:9cb27d7660bdf66e9878c832cb433dd8aa152cfbe16f3c2c0084c80b04ae22b4
entrypoint: [clamd, --foreground=true, --config-file=/etc/clamav/clamd.conf]
volumes: [./local/clamd.conf:/etc/clamav/clamd.conf:ro]
networks: [local]
mem_limit: 3g
pids_limit: 128
security_opt: [no-new-privileges:true]
healthcheck:
test: [CMD, clamdscan, --config-file=/etc/clamav/clamd.conf, --ping, "3"]
start_period: 60s
interval: 10s
timeout: 5s
retries: 30
db-init:
build:
context: .
dockerfile: local/Dockerfile
command: python -m local.bootstrap
environment:
DATABASE_ADMIN_URL: postgresql://${POSTGRES_USER:-dtf_local}:${POSTGRES_PASSWORD:-local-database-only}@db:5432/${POSTGRES_DB:-dtf_local}
APP_DB_USER: ${APP_DB_USER:-dtf_app}
APP_DB_PASSWORD: ${APP_DB_PASSWORD:-local-app-database-only}
networks: [local]
depends_on:
db: {condition: service_healthy}
storage-init:
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
entrypoint: [/bin/sh, /init.sh]
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-dtf_local}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-local-storage-only}
S3_APP_USER: ${S3_APP_USER:-dtf_app}
S3_APP_PASSWORD: ${S3_APP_PASSWORD:-local-app-storage-only}
S3_BUCKET: ${S3_BUCKET:-dtf-local-artwork}
volumes:
- ./local/storage-init.sh:/init.sh:ro
- ./local/storage-policy.json:/policy.json:ro
- ./local/storage-lifecycle.json:/lifecycle.json:ro
networks: [local]
depends_on:
storage: {condition: service_healthy}
db:
image: postgres:17-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-dtf_local}
POSTGRES_USER: ${POSTGRES_USER:-dtf_local}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-local-database-only}
volumes: [postgres-data:/var/lib/postgresql/data]
networks: [local]
healthcheck:
test: [CMD-SHELL, 'pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB"']
interval: 5s
timeout: 3s
retries: 30
storage:
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
command: server /data --console-address :9001
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-dtf_local}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-local-storage-only}
ports: ["127.0.0.1:9000:9000", "127.0.0.1:9001:9001"]
volumes: [storage-data:/data]
networks: [local, edge]
healthcheck:
test: [CMD, curl, -f, http://localhost:9000/minio/health/ready]
interval: 5s
timeout: 3s
retries: 30
api:
<<: *app
command: uvicorn local.app:app --host 0.0.0.0 --port 8000 --no-access-log
depends_on:
db-init: {condition: service_completed_successfully}
storage-init: {condition: service_completed_successfully}
healthcheck:
test: [CMD, python, -c, "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 5s
timeout: 3s
retries: 30
worker:
<<: *app
command: python -m local.worker
depends_on:
api: {condition: service_healthy}
scanner: {condition: service_healthy}
healthcheck:
test: [CMD, python, -c, "import urllib.request; urllib.request.urlopen('http://localhost:8002/health')"]
interval: 5s
timeout: 3s
retries: 12
site:
build:
context: .
dockerfile: local/Dockerfile.web
environment:
S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:-http://localhost:9000}
ports: ["127.0.0.1:${SITE_PORT:-8080}:80", "127.0.0.1:${API_PORT:-8000}:81"]
networks: [local, edge]
depends_on:
api: {condition: service_healthy}
healthcheck:
test: [CMD, wget, -q, -O, /dev/null, http://127.0.0.1/health]
interval: 5s
timeout: 3s
retries: 12
kanban:
build:
context: .
dockerfile: local/Dockerfile.web
environment:
WEB_INDEX: kanban.html
S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:-http://localhost:9000}
ports: ["127.0.0.1:${KANBAN_PORT:-8081}:80"]
networks: [local, edge]
depends_on:
api: {condition: service_healthy}
healthcheck:
test: [CMD, wget, -q, -O, /dev/null, http://127.0.0.1/health]
interval: 5s
timeout: 3s
retries: 12
volumes:
postgres-data:
storage-data:
networks:
local:
internal: true
edge:

18
deploy/Dockerfile.api Normal file
View File

@@ -0,0 +1,18 @@
# syntax=docker/dockerfile:1
ARG PYTHON_BASE_IMAGE
FROM ${PYTHON_BASE_IMAGE}
ARG VCS_REF=unknown
LABEL org.opencontainers.image.title="DTF Portal/API" \
org.opencontainers.image.revision="$VCS_REF" \
org.opencontainers.image.source="DTF System repository"
WORKDIR /app
COPY local/requirements.txt local/requirements.lock /app/local/
RUN python -m pip install --no-cache-dir --require-hashes -r local/requirements.lock
COPY local /app/local
RUN useradd --uid 10001 --create-home --shell /usr/sbin/nologin dtf
USER 10001:10001
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PYTHONPATH=/app
EXPOSE 8000
CMD ["uvicorn", "local.app:app", "--host", "0.0.0.0", "--port", "8000", "--no-access-log"]

22
deploy/Dockerfile.web Normal file
View File

@@ -0,0 +1,22 @@
# syntax=docker/dockerfile:1
ARG PYTHON_BASE_IMAGE
ARG NGINX_BASE_IMAGE
FROM ${PYTHON_BASE_IMAGE} AS policy
WORKDIR /build
COPY dtf-site.html /build/dtf-site.html
COPY local /build/local
COPY deploy /build/deploy
ENV NGINX_TEMPLATE=/build/deploy/nginx.conf.template
RUN python local/compile_web.py
FROM ${NGINX_BASE_IMAGE}
ARG VCS_REF=unknown
LABEL org.opencontainers.image.title="DTF Site and Kanban" \
org.opencontainers.image.revision="$VCS_REF" \
org.opencontainers.image.source="DTF System repository"
ENV WEB_INDEX=index.html PUBLIC_HOST=invalid.example S3_PUBLIC_ENDPOINT=https://invalid.example
COPY --from=policy /build/default.conf.template /etc/nginx/templates/default.conf.template
COPY dtf-site.html /usr/share/nginx/html/index.html
COPY local/static/ /usr/share/nginx/html/
USER 101:101
EXPOSE 8080

View File

@@ -0,0 +1,46 @@
# Production release checklist
Every item is required. A checked box records reviewed evidence; it is not a
substitute for `production_preflight.py`, CI, staging acceptance, or change approval.
## Application and external contracts
- [ ] `PRODUCTION_INPUTS.md` has owners, decisions, and evidence for every item.
- [ ] Production R2, freight, Mercado Pago, Tiny/Olist, and WhatsApp adapters have
sandbox contract tests and least-privilege credentials.
- [ ] Payment webhook authenticity, replay handling, and idempotency are tested.
- [ ] Docker secret `*_FILE` loading is implemented and tested without logging values.
- [ ] Production account verification/recovery and the length/grade authority flow
are approved.
- [ ] No factory automation or automatic print pre-flight was added by inference.
## Platform and recovery
- [ ] DNS/TLS proxy routes and firewall rules are approved; only Site and Kanban
have published web ports.
- [ ] External, versioned Swarm secrets exist and match the configured names.
- [ ] The PostgreSQL volume is encrypted/backed up and pinned to the labeled node.
- [ ] Scheduled offsite database/object backups and an isolated restore rehearsal pass.
- [ ] ClamAV signatures are current and have a controlled update/rebuild process.
- [ ] Central alerts, logs, clocks, capacity, and on-call ownership are verified.
## Release and deploy
- [ ] Protected Gitea runner, `main` branch, registry permissions, and variables are reviewed.
- [ ] Base, database, and scanner images use approved immutable digests; application
SHA tags remain pullable and the digest resolved by each deployment is recorded.
- [ ] Full regressions and production preflight pass on the exact release commit.
- [ ] HIGH/CRITICAL Trivy findings are fixed or formally risk-accepted with evidence.
- [ ] Staging acceptance passes with provider sandboxes and production-like topology.
- [ ] Four production approval variables equal `approved` only after their reviews.
- [ ] The generated `docker stack config` contains no secret values or placeholders.
- [ ] Health probes, monitoring, rollback, and a backup restore are observed in staging.
- [ ] The Portainer webhook redeploys the one `dtf-cloud` stack and post-deploy
HTTPS health probes pass.
## Rollback
- [ ] Previous application image digests remain pullable.
- [ ] Portainer rollback by full commit `IMAGE_TAG` has been rehearsed; it does
not roll back the database.
- [ ] Database migration forward/restore ownership and maintenance procedure are approved.

16
deploy/README.md Normal file
View File

@@ -0,0 +1,16 @@
# Production deployment files
The DTF application is one Portainer-owned Docker Swarm stack. Gitea builds,
tests, scans, and publishes the two application images, then calls the stack's
Portainer webhook. Start with the short operator guide in `../PORTAINER.md`.
- `stack.yaml` — the single Portainer stack.
- `Dockerfile.api` and `Dockerfile.web` — prebuilt registry images.
- `portainer.env.example` — non-secret Portainer variables.
- `production_preflight.py` — fail-closed application/configuration validator.
- `PRODUCTION_CHECKLIST.md` — production evidence checklist.
The current application remains deliberately blocked from production because
real adapters and Docker-secret file loading are absent and current validation
base images have unresolved HIGH/CRITICAL findings. No real provider or
production service has been configured or contacted.

1
deploy/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Production deployment validation package."""

View File

@@ -0,0 +1,31 @@
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=20r/s;
server {
listen 8080;
server_name ${PUBLIC_HOST};
if ($host != ${PUBLIC_HOST}) { return 400; }
root /usr/share/nginx/html;
index ${WEB_INDEX};
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy no-referrer always;
add_header X-Frame-Options DENY always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' @SCRIPT_HASHES@ https://cdnjs.cloudflare.com; script-src-attr 'none'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://cdn.vnda.com.br; connect-src 'self' ${S3_PUBLIC_ENDPOINT} https://cdn.vnda.com.br https://cdnjs.cloudflare.com; worker-src 'self' blob: https://cdnjs.cloudflare.com; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" always;
location = /health { access_log off; return 200 'ok'; }
location /api/ {
limit_req zone=api_limit burst=100 nodelay;
limit_req_status 429;
proxy_pass http://api:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 5s;
proxy_read_timeout 30s;
client_max_body_size 2m;
}
location / { try_files $uri $uri/ =404; }
}

View File

@@ -0,0 +1,51 @@
# Non-secret Portainer stack variables. Never put credential values in this file.
PRODUCTION_DEPLOY_ENABLED=TBD
PRODUCTION_INPUTS_APPROVED=TBD
PRODUCTION_SECURITY_REVIEW_APPROVED=TBD
PRODUCTION_RESTORE_REHEARSED=TBD
API_IMAGE=gitea.blyzer.com.br/blyzer/dtf-api
WEB_IMAGE=gitea.blyzer.com.br/blyzer/dtf-web
IMAGE_TAG=latest
POSTGRES_IMAGE=postgres@sha256:0000000000000000000000000000000000000000000000000000000000000000
CLAMAV_IMAGE=clamav/clamav@sha256:0000000000000000000000000000000000000000000000000000000000000000
PUBLIC_ORIGIN=https://dtf.example.invalid
PUBLIC_HOST=dtf.example.invalid
KANBAN_HOST=kanban-dtf.example.invalid
SITE_PORT=8080
KANBAN_PORT=8081
R2_ENDPOINT=TBD
R2_PUBLIC_ENDPOINT=TBD
R2_BUCKET=TBD
POSTGRES_DB=dtf
POSTGRES_USER=dtf_admin
APP_DB_USER=dtf_app
POSTGRES_VOLUME=TBD
OPERATOR_USER=TBD
PAYMENT_ADAPTER=TBD
FREIGHT_ADAPTER=TBD
TINY_ADAPTER=TBD
WHATSAPP_ADAPTER=TBD
STORAGE_QUOTA_BYTES=TBD
OWNER_UPLOAD_QUOTA_BYTES=TBD
MAX_PENDING_UPLOADS=10
MAX_UPLOAD_BYTES=5368709120
UPLOAD_PART_BYTES=8388608
SCAN_MAX_BYTES=134217728
# Names of external Portainer/Docker Swarm secrets, never their values.
DATABASE_URL_SECRET=TBD
DATABASE_ADMIN_URL_SECRET=TBD
DB_ADMIN_PASSWORD_SECRET=TBD
APP_DB_PASSWORD_SECRET=TBD
R2_ACCESS_KEY_ID_SECRET=TBD
R2_SECRET_ACCESS_KEY_SECRET=TBD
OPERATOR_PASSWORD_SECRET=TBD
PAYMENT_TOKEN_SECRET=TBD
PAYMENT_WEBHOOK_SECRET=TBD
TINY_TOKEN_SECRET=TBD
WHATSAPP_TOKEN_SECRET=TBD

View File

@@ -0,0 +1,159 @@
"""Fail closed until the application and non-secret production inputs are ready."""
import os
from pathlib import Path
import re
import sys
from urllib.parse import urlparse
ROOT = Path(__file__).resolve().parent.parent
APPROVALS = (
'PRODUCTION_DEPLOY_ENABLED',
'PRODUCTION_INPUTS_APPROVED',
'PRODUCTION_SECURITY_REVIEW_APPROVED',
'PRODUCTION_RESTORE_REHEARSED',
)
REQUIRED = (
'API_IMAGE', 'WEB_IMAGE', 'POSTGRES_IMAGE', 'CLAMAV_IMAGE',
'IMAGE_TAG', 'PUBLIC_ORIGIN', 'PUBLIC_HOST', 'KANBAN_HOST',
'SITE_PORT', 'KANBAN_PORT',
'R2_ENDPOINT', 'R2_PUBLIC_ENDPOINT', 'R2_BUCKET',
'POSTGRES_DB', 'POSTGRES_USER', 'APP_DB_USER', 'POSTGRES_VOLUME', 'OPERATOR_USER',
'STORAGE_QUOTA_BYTES', 'OWNER_UPLOAD_QUOTA_BYTES', 'MAX_UPLOAD_BYTES',
'UPLOAD_PART_BYTES', 'MAX_PENDING_UPLOADS', 'SCAN_MAX_BYTES',
'PAYMENT_ADAPTER', 'FREIGHT_ADAPTER', 'TINY_ADAPTER', 'WHATSAPP_ADAPTER',
'DATABASE_URL_SECRET', 'DATABASE_ADMIN_URL_SECRET',
'DB_ADMIN_PASSWORD_SECRET', 'APP_DB_PASSWORD_SECRET',
'R2_ACCESS_KEY_ID_SECRET', 'R2_SECRET_ACCESS_KEY_SECRET',
'OPERATOR_PASSWORD_SECRET', 'PAYMENT_TOKEN_SECRET',
'PAYMENT_WEBHOOK_SECRET', 'TINY_TOKEN_SECRET', 'WHATSAPP_TOKEN_SECRET',
)
IMMUTABLE_IMAGE = re.compile(r'^[a-z0-9][a-z0-9._:/-]*@sha256:([0-9a-f]{64})$')
IMAGE_REPOSITORY = re.compile(
r'^[a-z0-9][a-z0-9.-]*(?::[0-9]{1,5})?(?:/[a-z0-9][a-z0-9._-]*)+$')
DNS = re.compile(r'^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$')
NAME = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9_.-]{2,127}$')
SOURCE_BLOCKERS = {
'local/adapters.py': (
'This runtime only supports APP_ENV=local',
'Only local S3 storage is supported',
),
'local/app.py': (
"allowed_hosts=['localhost', '127.0.0.1']",
"'environment': 'local'",
'payment = FakePayment()',
),
'local/worker.py': (
"adapters = {'tiny': FakeTiny(), 'whatsapp': FakeWhatsApp()}",
),
}
def source_errors(root=ROOT):
errors = []
for relative, markers in SOURCE_BLOCKERS.items():
text = (root / relative).read_text()
for marker in markers:
if marker in text:
errors.append(f'{relative} remains local-only: {marker}')
secrets_module = root / 'local' / 'secrets.py'
if not secrets_module.exists() or 'DATABASE_URL_FILE' not in secrets_module.read_text():
errors.append('local runtime does not load the production Docker secret *_FILE settings')
return errors
def config_errors(values):
errors = []
for name in APPROVALS:
if values.get(name) != 'approved':
errors.append(f'{name} must equal approved')
for name in REQUIRED:
value = values.get(name, '')
if not value or value.lower() in {'tbd', 'todo', 'replace-me', 'unconfirmed'}:
errors.append(f'{name} is missing or unresolved')
for name in ('API_IMAGE', 'WEB_IMAGE'):
if not IMAGE_REPOSITORY.fullmatch(values.get(name, '')):
errors.append(f'{name} must be a lowercase registry repository without a tag')
for name in ('POSTGRES_IMAGE', 'CLAMAV_IMAGE'):
match = IMMUTABLE_IMAGE.fullmatch(values.get(name, ''))
if not match or match.group(1) == '0' * 64:
errors.append(f'{name} must be an immutable non-placeholder digest reference')
image_tag = values.get('IMAGE_TAG', '')
if image_tag != 'latest' and not re.fullmatch(r'(?:[0-9a-f]{40}|[0-9a-f]{64})', image_tag):
errors.append('IMAGE_TAG must be latest or a full commit SHA published by Gitea')
origin = urlparse(values.get('PUBLIC_ORIGIN', ''))
if (origin.scheme != 'https' or not origin.hostname or origin.path not in ('', '/')
or origin.params or origin.query or origin.fragment):
errors.append('PUBLIC_ORIGIN must be an HTTPS origin without a path')
for name in ('PUBLIC_HOST', 'KANBAN_HOST'):
if not DNS.fullmatch(values.get(name, '')):
errors.append(f'{name} must be a valid lowercase DNS hostname')
if origin.hostname and values.get('PUBLIC_HOST') != origin.hostname:
errors.append('PUBLIC_ORIGIN hostname must equal PUBLIC_HOST')
for name in ('R2_ENDPOINT', 'R2_PUBLIC_ENDPOINT'):
endpoint = urlparse(values.get(name, ''))
host = endpoint.hostname or ''
if (endpoint.scheme != 'https' or not host.endswith('.r2.cloudflarestorage.com')
or endpoint.path not in ('', '/') or endpoint.query or endpoint.fragment):
errors.append(f'{name} must be an HTTPS Cloudflare R2 S3 API endpoint')
bucket = values.get('R2_BUCKET', '')
if bucket and not re.fullmatch(r'[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]', bucket):
errors.append('R2_BUCKET must be a valid S3 bucket name')
for name in ('PAYMENT_ADAPTER', 'FREIGHT_ADAPTER', 'TINY_ADAPTER', 'WHATSAPP_ADAPTER'):
if values.get(name, '').lower() in {'', 'fake', 'mock', 'local'}:
errors.append(f'{name} must select an approved non-fake implementation')
for name in REQUIRED:
if name.endswith('_SECRET') and values.get(name) and not NAME.fullmatch(values[name]):
errors.append(f'{name} must name a pre-provisioned external Swarm secret')
secret_names = [values.get(name, '') for name in REQUIRED if name.endswith('_SECRET')]
populated_secret_names = [name for name in secret_names if name]
if len(populated_secret_names) != len(set(populated_secret_names)):
errors.append('Every production secret setting must use a distinct external Swarm secret')
if values.get('POSTGRES_USER') == values.get('APP_DB_USER'):
errors.append('Database administrator and runtime user must differ')
if values.get('PUBLIC_HOST') == values.get('KANBAN_HOST'):
errors.append('Site and Kanban hosts must differ')
numeric = {}
for name in (
'STORAGE_QUOTA_BYTES', 'OWNER_UPLOAD_QUOTA_BYTES', 'MAX_UPLOAD_BYTES',
'UPLOAD_PART_BYTES', 'MAX_PENDING_UPLOADS', 'SCAN_MAX_BYTES'):
try:
numeric[name] = int(values.get(name, '0'))
if numeric[name] <= 0:
raise ValueError
except ValueError:
errors.append(f'{name} must be a positive integer')
if numeric.get('OWNER_UPLOAD_QUOTA_BYTES', 0) > numeric.get('STORAGE_QUOTA_BYTES', 0):
errors.append('OWNER_UPLOAD_QUOTA_BYTES cannot exceed STORAGE_QUOTA_BYTES')
if numeric.get('SCAN_MAX_BYTES', 0) > numeric.get('MAX_UPLOAD_BYTES', 0):
errors.append('SCAN_MAX_BYTES cannot exceed MAX_UPLOAD_BYTES')
ports = {}
for name in ('SITE_PORT', 'KANBAN_PORT'):
try:
ports[name] = int(values.get(name, '0'))
if not 1024 <= ports[name] <= 65535:
raise ValueError
except ValueError:
errors.append(f'{name} must be an unprivileged TCP port from 1024 to 65535')
if ports.get('SITE_PORT') == ports.get('KANBAN_PORT'):
errors.append('SITE_PORT and KANBAN_PORT must differ')
return errors
def main(source_only=False):
errors = source_errors()
if not source_only:
errors.extend(config_errors(os.environ))
if errors:
print('BLOCKED: production preflight failed:')
for error in errors:
print(f'- {error}')
return 2
print('PASS: production source and non-secret deployment metadata passed preflight.')
print('This does not replace staging acceptance, image scanning, or human approval.')
return 0
if __name__ == '__main__':
if len(sys.argv) > 2 or (len(sys.argv) == 2 and sys.argv[1] != '--source-only'):
raise SystemExit('usage: python deploy/production_preflight.py [--source-only]')
raise SystemExit(main(len(sys.argv) == 2))

309
deploy/stack.yaml Normal file
View File

@@ -0,0 +1,309 @@
version: "3.8"
x-app-environment: &app-environment
APP_ENV: production
DATABASE_URL_FILE: /run/secrets/database_url
S3_ENDPOINT: ${R2_ENDPOINT:?set R2_ENDPOINT}
S3_PUBLIC_ENDPOINT: ${R2_PUBLIC_ENDPOINT:?set R2_PUBLIC_ENDPOINT}
S3_BUCKET: ${R2_BUCKET:?set R2_BUCKET}
AWS_ACCESS_KEY_ID_FILE: /run/secrets/r2_access_key_id
AWS_SECRET_ACCESS_KEY_FILE: /run/secrets/r2_secret_access_key
AWS_DEFAULT_REGION: auto
OPERATOR_USER: ${OPERATOR_USER:?set OPERATOR_USER}
OPERATOR_PASSWORD_FILE: /run/secrets/operator_password
PAYMENT_ADAPTER: ${PAYMENT_ADAPTER:?set PAYMENT_ADAPTER}
FREIGHT_ADAPTER: ${FREIGHT_ADAPTER:?set FREIGHT_ADAPTER}
TINY_ADAPTER: ${TINY_ADAPTER:?set TINY_ADAPTER}
WHATSAPP_ADAPTER: ${WHATSAPP_ADAPTER:?set WHATSAPP_ADAPTER}
STORAGE_ADAPTER: s3-r2
PAYMENT_TOKEN_FILE: /run/secrets/payment_token
PAYMENT_WEBHOOK_SECRET_FILE: /run/secrets/payment_webhook_secret
TINY_TOKEN_FILE: /run/secrets/tiny_token
WHATSAPP_TOKEN_FILE: /run/secrets/whatsapp_token
PUBLIC_ORIGIN: ${PUBLIC_ORIGIN:?set PUBLIC_ORIGIN}
PUBLIC_HOST: ${PUBLIC_HOST:?set PUBLIC_HOST}
ALLOWED_HOSTS: ${PUBLIC_HOST:?set PUBLIC_HOST},${KANBAN_HOST:?set KANBAN_HOST}
ALLOWED_ORIGINS: ${PUBLIC_ORIGIN:?set PUBLIC_ORIGIN},https://${KANBAN_HOST:?set KANBAN_HOST}
COOKIE_SECURE: "true"
MAX_UPLOAD_BYTES: ${MAX_UPLOAD_BYTES:-5368709120}
UPLOAD_PART_BYTES: ${UPLOAD_PART_BYTES:-8388608}
STORAGE_QUOTA_BYTES: ${STORAGE_QUOTA_BYTES:?set STORAGE_QUOTA_BYTES}
OWNER_UPLOAD_QUOTA_BYTES: ${OWNER_UPLOAD_QUOTA_BYTES:?set OWNER_UPLOAD_QUOTA_BYTES}
MAX_PENDING_UPLOADS: ${MAX_PENDING_UPLOADS:-10}
SCAN_MAX_BYTES: ${SCAN_MAX_BYTES:-134217728}
x-app-secrets: &app-secrets
- database_url
- r2_access_key_id
- r2_secret_access_key
- operator_password
- payment_token
- payment_webhook_secret
- tiny_token
- whatsapp_token
x-rolling: &rolling
update_config:
parallelism: 1
delay: 10s
order: start-first
failure_action: rollback
monitor: 45s
rollback_config:
parallelism: 1
delay: 5s
order: start-first
failure_action: pause
monitor: 45s
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 5
window: 60s
services:
db:
image: ${POSTGRES_IMAGE:?set POSTGRES_IMAGE}
environment:
POSTGRES_DB: ${POSTGRES_DB:?set POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER:?set POSTGRES_USER}
POSTGRES_PASSWORD_FILE: /run/secrets/db_admin_password
secrets: [db_admin_password]
volumes:
- postgres-data:/var/lib/postgresql/data
networks: [backend]
healthcheck:
test: [CMD-SHELL, 'pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB"']
interval: 10s
timeout: 5s
retries: 12
start_period: 20s
stop_grace_period: 60s
deploy:
replicas: 1
placement:
constraints: [node.labels.dtf_database == true]
update_config:
parallelism: 1
order: stop-first
failure_action: rollback
monitor: 60s
rollback_config:
parallelism: 1
order: stop-first
failure_action: pause
monitor: 60s
restart_policy:
condition: on-failure
delay: 10s
max_attempts: 5
window: 120s
resources:
limits: {cpus: "2.0", memory: 4G}
reservations: {cpus: "0.5", memory: 1G}
db-init:
image: ${API_IMAGE:?set API_IMAGE}:${IMAGE_TAG:-latest}
command: python -m local.bootstrap
environment:
APP_ENV: production
DATABASE_ADMIN_URL_FILE: /run/secrets/database_admin_url
APP_DB_USER: ${APP_DB_USER:?set APP_DB_USER}
APP_DB_PASSWORD_FILE: /run/secrets/app_db_password
secrets: [database_admin_url, app_db_password]
networks: [backend]
deploy:
replicas: 1
restart_policy: {condition: none}
placement:
constraints: [node.platform.os == linux]
resources:
limits: {cpus: "0.5", memory: 512M}
scanner:
image: ${CLAMAV_IMAGE:?set CLAMAV_IMAGE}
user: "100:101"
entrypoint: [clamd, --foreground=true, --config-file=/etc/clamav/clamd.conf]
configs:
- source: clamd_config
target: /etc/clamav/clamd.conf
mode: 0444
networks: [backend]
read_only: true
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
tmpfs:
- /tmp:uid=100,gid=101,mode=0750
- /run/clamav:uid=100,gid=101,mode=0750
- /var/log/clamav:uid=100,gid=101,mode=0750
healthcheck:
test: [CMD, clamdscan, --config-file=/etc/clamav/clamd.conf, --ping, "3"]
interval: 15s
timeout: 5s
retries: 20
start_period: 90s
deploy:
replicas: 1
restart_policy: {condition: on-failure, delay: 10s}
resources:
limits: {cpus: "2.0", memory: 3G}
reservations: {cpus: "0.5", memory: 1G}
api:
image: ${API_IMAGE:?set API_IMAGE}:${IMAGE_TAG:-latest}
environment: *app-environment
secrets: *app-secrets
networks: [backend, egress]
read_only: true
tmpfs: [/tmp]
init: true
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
healthcheck:
test:
- CMD-SHELL
- >-
python -c "import os,urllib.request; r=urllib.request.Request('http://localhost:8000/health',headers={'Host':os.environ['PUBLIC_HOST']}); urllib.request.urlopen(r,timeout=3)"
interval: 10s
timeout: 5s
retries: 12
start_period: 30s
stop_grace_period: 30s
deploy:
<<: *rolling
replicas: 2
resources:
limits: {cpus: "1.0", memory: 1G}
reservations: {cpus: "0.25", memory: 256M}
worker:
image: ${API_IMAGE:?set API_IMAGE}:${IMAGE_TAG:-latest}
command: python -m local.worker
environment:
<<: *app-environment
CLAMD_HOST: scanner
secrets: *app-secrets
networks: [backend, egress]
read_only: true
tmpfs: [/tmp]
init: true
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
healthcheck:
test: [CMD, python, -c, "import urllib.request; urllib.request.urlopen('http://localhost:8002/health',timeout=3)"]
interval: 15s
timeout: 5s
retries: 12
start_period: 90s
stop_grace_period: 60s
deploy:
<<: *rolling
replicas: 1
update_config:
parallelism: 1
order: stop-first
failure_action: rollback
monitor: 60s
rollback_config:
parallelism: 1
order: stop-first
failure_action: pause
monitor: 60s
resources:
limits: {cpus: "1.5", memory: 2G}
reservations: {cpus: "0.25", memory: 512M}
site:
image: ${WEB_IMAGE:?set WEB_IMAGE}:${IMAGE_TAG:-latest}
environment:
WEB_INDEX: index.html
PUBLIC_HOST: ${PUBLIC_HOST:?set PUBLIC_HOST}
S3_PUBLIC_ENDPOINT: ${R2_PUBLIC_ENDPOINT:?set R2_PUBLIC_ENDPOINT}
networks: [backend]
ports:
- target: 8080
published: ${SITE_PORT:-8080}
protocol: tcp
mode: ingress
read_only: true
tmpfs:
- /tmp:uid=101,gid=101,mode=0750
- /var/cache/nginx:uid=101,gid=101,mode=0750
- /var/run:uid=101,gid=101,mode=0750
- /etc/nginx/conf.d:uid=101,gid=101,mode=0750
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
healthcheck:
test: [CMD-SHELL, 'wget -q --header="Host: $$PUBLIC_HOST" -O /dev/null http://127.0.0.1:8080/health']
interval: 10s
timeout: 5s
retries: 12
start_period: 15s
deploy:
<<: *rolling
replicas: 2
resources:
limits: {cpus: "0.5", memory: 256M}
reservations: {cpus: "0.1", memory: 64M}
kanban:
image: ${WEB_IMAGE:?set WEB_IMAGE}:${IMAGE_TAG:-latest}
environment:
WEB_INDEX: kanban.html
PUBLIC_HOST: ${KANBAN_HOST:?set KANBAN_HOST}
S3_PUBLIC_ENDPOINT: ${R2_PUBLIC_ENDPOINT:?set R2_PUBLIC_ENDPOINT}
networks: [backend]
ports:
- target: 8080
published: ${KANBAN_PORT:-8081}
protocol: tcp
mode: ingress
read_only: true
tmpfs:
- /tmp:uid=101,gid=101,mode=0750
- /var/cache/nginx:uid=101,gid=101,mode=0750
- /var/run:uid=101,gid=101,mode=0750
- /etc/nginx/conf.d:uid=101,gid=101,mode=0750
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
healthcheck:
test: [CMD-SHELL, 'wget -q --header="Host: $$PUBLIC_HOST" -O /dev/null http://127.0.0.1:8080/health']
interval: 10s
timeout: 5s
retries: 12
start_period: 15s
deploy:
<<: *rolling
replicas: 1
resources:
limits: {cpus: "0.5", memory: 256M}
reservations: {cpus: "0.1", memory: 64M}
configs:
clamd_config:
file: ../local/clamd.conf
secrets:
database_url: {external: true, name: "${DATABASE_URL_SECRET:?set DATABASE_URL_SECRET}"}
database_admin_url: {external: true, name: "${DATABASE_ADMIN_URL_SECRET:?set DATABASE_ADMIN_URL_SECRET}"}
db_admin_password: {external: true, name: "${DB_ADMIN_PASSWORD_SECRET:?set DB_ADMIN_PASSWORD_SECRET}"}
app_db_password: {external: true, name: "${APP_DB_PASSWORD_SECRET:?set APP_DB_PASSWORD_SECRET}"}
r2_access_key_id: {external: true, name: "${R2_ACCESS_KEY_ID_SECRET:?set R2_ACCESS_KEY_ID_SECRET}"}
r2_secret_access_key: {external: true, name: "${R2_SECRET_ACCESS_KEY_SECRET:?set R2_SECRET_ACCESS_KEY_SECRET}"}
operator_password: {external: true, name: "${OPERATOR_PASSWORD_SECRET:?set OPERATOR_PASSWORD_SECRET}"}
payment_token: {external: true, name: "${PAYMENT_TOKEN_SECRET:?set PAYMENT_TOKEN_SECRET}"}
payment_webhook_secret: {external: true, name: "${PAYMENT_WEBHOOK_SECRET:?set PAYMENT_WEBHOOK_SECRET}"}
tiny_token: {external: true, name: "${TINY_TOKEN_SECRET:?set TINY_TOKEN_SECRET}"}
whatsapp_token: {external: true, name: "${WHATSAPP_TOKEN_SECRET:?set WHATSAPP_TOKEN_SECRET}"}
volumes:
postgres-data:
external: true
name: ${POSTGRES_VOLUME:?set POSTGRES_VOLUME}
networks:
backend:
driver: overlay
internal: true
egress:
driver: overlay

View File

@@ -0,0 +1,98 @@
import unittest
from .production_preflight import config_errors, source_errors
def valid_config():
digest = '1' * 64
values = {
'PRODUCTION_DEPLOY_ENABLED': 'approved',
'PRODUCTION_INPUTS_APPROVED': 'approved',
'PRODUCTION_SECURITY_REVIEW_APPROVED': 'approved',
'PRODUCTION_RESTORE_REHEARSED': 'approved',
'API_IMAGE': 'registry.example.com/dropstar/dtf-api',
'WEB_IMAGE': 'registry.example.com/dropstar/dtf-web',
'IMAGE_TAG': 'latest',
'POSTGRES_IMAGE': f'postgres@sha256:{digest}',
'CLAMAV_IMAGE': f'clamav/clamav@sha256:{digest}',
'PUBLIC_ORIGIN': 'https://dtf.example.com',
'PUBLIC_HOST': 'dtf.example.com',
'KANBAN_HOST': 'kanban-dtf.example.com',
'SITE_PORT': '8080',
'KANBAN_PORT': '8081',
'R2_ENDPOINT': 'https://account.r2.cloudflarestorage.com',
'R2_PUBLIC_ENDPOINT': 'https://account.r2.cloudflarestorage.com',
'R2_BUCKET': 'dtf-production-artwork',
'POSTGRES_DB': 'dtf',
'POSTGRES_USER': 'dtf_admin',
'APP_DB_USER': 'dtf_app',
'POSTGRES_VOLUME': 'dtf-postgres-data',
'OPERATOR_USER': 'dtf-operator',
'STORAGE_QUOTA_BYTES': '53687091200',
'OWNER_UPLOAD_QUOTA_BYTES': '10737418240',
'MAX_UPLOAD_BYTES': '5368709120',
'UPLOAD_PART_BYTES': '8388608',
'MAX_PENDING_UPLOADS': '10',
'SCAN_MAX_BYTES': '134217728',
'PAYMENT_ADAPTER': 'mercado-pago',
'FREIGHT_ADAPTER': 'approved-freight',
'TINY_ADAPTER': 'tiny-olist',
'WHATSAPP_ADAPTER': 'approved-whatsapp',
}
for name in (
'DATABASE_URL_SECRET', 'DATABASE_ADMIN_URL_SECRET',
'DB_ADMIN_PASSWORD_SECRET', 'APP_DB_PASSWORD_SECRET',
'R2_ACCESS_KEY_ID_SECRET', 'R2_SECRET_ACCESS_KEY_SECRET',
'OPERATOR_PASSWORD_SECRET', 'PAYMENT_TOKEN_SECRET',
'PAYMENT_WEBHOOK_SECRET', 'TINY_TOKEN_SECRET', 'WHATSAPP_TOKEN_SECRET',
):
values[name] = 'dtf_prod_' + name.lower()
return values
class ProductionPreflightTests(unittest.TestCase):
def test_structurally_complete_metadata_passes(self):
self.assertEqual(config_errors(valid_config()), [])
def test_mutable_images_and_fake_adapters_fail(self):
values = valid_config()
values['API_IMAGE'] = 'registry.example.com/dropstar/dtf-api:latest'
values['PAYMENT_ADAPTER'] = 'fake'
errors = config_errors(values)
self.assertTrue(any('API_IMAGE' in error for error in errors))
self.assertTrue(any('PAYMENT_ADAPTER' in error for error in errors))
def test_image_tag_and_public_ports_are_validated(self):
values = valid_config()
values['IMAGE_TAG'] = 'main'
values['KANBAN_PORT'] = values['SITE_PORT']
errors = config_errors(values)
self.assertTrue(any('IMAGE_TAG' in error for error in errors))
self.assertTrue(any('must differ' in error for error in errors))
def test_approval_and_secret_name_are_enforced(self):
values = valid_config()
values['PRODUCTION_DEPLOY_ENABLED'] = 'yes'
values['DATABASE_URL_SECRET'] = '../unsafe'
errors = config_errors(values)
self.assertTrue(any('PRODUCTION_DEPLOY_ENABLED' in error for error in errors))
self.assertTrue(any('DATABASE_URL_SECRET' in error for error in errors))
def test_current_application_is_explicitly_blocked(self):
errors = source_errors()
self.assertTrue(any('local/adapters.py remains local-only' in error for error in errors))
self.assertTrue(any('local/app.py remains local-only' in error for error in errors))
def test_secret_reuse_and_incoherent_limits_are_rejected(self):
values = valid_config()
values['PAYMENT_TOKEN_SECRET'] = values['TINY_TOKEN_SECRET']
values['OWNER_UPLOAD_QUOTA_BYTES'] = str(int(values['STORAGE_QUOTA_BYTES']) + 1)
values['SCAN_MAX_BYTES'] = str(int(values['MAX_UPLOAD_BYTES']) + 1)
errors = config_errors(values)
self.assertTrue(any('distinct external Swarm secret' in error for error in errors))
self.assertIn('OWNER_UPLOAD_QUOTA_BYTES cannot exceed STORAGE_QUOTA_BYTES', errors)
self.assertIn('SCAN_MAX_BYTES cannot exceed MAX_UPLOAD_BYTES', errors)
if __name__ == '__main__':
unittest.main()

BIN
dtf-organograma-sistema.pdf Normal file

Binary file not shown.

205
dtf-organograma-ti.html Normal file
View File

@@ -0,0 +1,205 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DTF — arquitetura para o TI</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root{--bg:#0E1013;--card:#191C21;--card2:#1F232A;--linha:#2C3039;--tx:#E9E7E2;--fraco:#8A8F98;
--ciano:#00A6C4;--roxo:#7C6BD1;--magenta:#C8256B;--verde:#3E9B62;--alerta:#C9531F;--amarelo:#D9A400}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--tx);font-family:"IBM Plex Mono",ui-monospace,monospace;font-size:14px;line-height:1.55;-webkit-font-smoothing:antialiased}
h1,h2,h3{font-family:"Space Grotesk",system-ui,sans-serif;letter-spacing:-.02em}
.faixa{height:5px;background:linear-gradient(90deg,var(--ciano) 0 34%,var(--roxo) 34% 67%,var(--magenta) 67% 100%)}
.wrap{max-width:1180px;margin:0 auto;padding:0 20px 70px}
header{padding:34px 0 18px}
.eb{font-size:10.5px;letter-spacing:.26em;text-transform:uppercase;color:var(--ciano);margin-bottom:10px}
h1{font-weight:700;font-size:clamp(26px,4.6vw,44px);line-height:1.03}
h1 span{color:var(--fraco)}
.lead{color:var(--fraco);max-width:70ch;margin-top:10px}
.mapa{margin:24px 0 0;overflow-x:auto}
svg{width:100%;min-width:960px;height:auto}
.zona{fill:#14161A;stroke:var(--linha);stroke-dasharray:4 3}
.zt{font-family:"IBM Plex Mono",monospace;font-size:10px;fill:var(--fraco);letter-spacing:.2em;text-transform:uppercase}
.no rect{fill:var(--card);stroke:var(--linha);cursor:pointer;transition:stroke .15s,fill .15s}
.no:hover rect{stroke:var(--ciano)}
.no[aria-current="true"] rect{fill:var(--card2);stroke-width:2}
.no text{pointer-events:none}
.nn{font-family:"Space Grotesk",sans-serif;font-size:13.5px;font-weight:700;fill:var(--tx)}
.nq{font-family:"IBM Plex Mono",monospace;font-size:9.5px;fill:var(--fraco)}
.lig{stroke:var(--linha);fill:none;marker-end:url(#s)}
.lb{font-family:"IBM Plex Mono",monospace;font-size:9.5px;fill:var(--fraco)}
.det{margin-top:26px;background:var(--card);border:1px solid var(--linha);border-left:3px solid var(--acc,var(--ciano));border-radius:9px;padding:22px 24px}
.det h2{font-size:22px;margin-bottom:2px}
.det .sub{color:var(--acc,var(--ciano));font-size:12.5px;margin-bottom:16px}
.det p{margin-bottom:12px;max-width:74ch}
pre{background:var(--card2);border:1px solid var(--linha);border-radius:7px;padding:13px 15px;overflow-x:auto;font-size:12.5px;line-height:1.6;margin:12px 0;color:#C9CDD4}
pre b{color:var(--ciano);font-weight:500} pre i{color:var(--fraco);font-style:normal} pre u{color:var(--amarelo);text-decoration:none}
.blocos{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:12px;margin-top:14px}
.b{background:var(--card2);border:1px solid var(--linha);border-radius:7px;padding:13px 15px}
.b h3{font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--fraco);margin-bottom:6px;font-family:"IBM Plex Mono",monospace}
.b ul{list-style:none} .b li{padding:3px 0 3px 15px;position:relative;font-size:13px}
.b li::before{content:"·";position:absolute;left:4px;color:var(--acc,var(--ciano));font-weight:700}
.aten{margin-top:14px;border-left:2px solid var(--alerta);background:#22150F;padding:12px 16px;border-radius:0 6px 6px 0;font-size:13.5px}
.aten b{color:var(--alerta);display:block;margin-bottom:3px;font-family:"Space Grotesk",sans-serif}
code{background:var(--card2);border:1px solid var(--linha);border-radius:4px;padding:1px 6px;font-size:12.5px;color:var(--ciano)}
.dica{color:var(--fraco);font-size:12px;margin-top:10px}
@media(prefers-reduced-motion:reduce){*{transition:none!important}}
</style>
</head>
<body>
<div class="faixa"></div>
<div class="wrap">
<header>
<div class="eb">Documento técnico · Wagner · agosto 2026</div>
<h1>Arquitetura do DTF 24h<br><span>três zonas, oito componentes</span></h1>
<p class="lead">Clique em qualquer caixa para ver o que ela faz, em que linguagem, o que acontece se cair e o que ainda depende de resposta. O código completo está em <code>dtf-sistema.zip</code>.</p>
</header>
<div class="mapa">
<svg viewBox="0 0 1120 380" role="img" aria-label="Arquitetura do sistema DTF">
<defs><marker id="s" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5" markerHeight="5" orient="auto">
<path d="M1 1L9 5L1 9" fill="none" stroke="#2C3039" stroke-width="1.6"/></marker></defs>
<rect class="zona" x="10" y="26" width="330" height="120" rx="8"/>
<text class="zt" x="26" y="46">Terceiros</text>
<rect class="zona" x="356" y="26" width="380" height="230" rx="8"/>
<text class="zt" x="372" y="46">Nuvem · VPS + storage</text>
<rect class="zona" x="752" y="26" width="358" height="330" rx="8"/>
<text class="zt" x="768" y="46">Fábrica · rede interna</text>
<path class="lig" d="M200 84 H424"/><text class="lb" x="250" y="78">webhook</text>
<path class="lig" d="M584 110 V140"/>
<path class="lig" d="M584 196 V226"/>
<path class="lig" d="M700 168 H850"/><text class="lb" x="730" y="162">HTTPS</text>
<path class="lig" d="M916 200 V232"/>
<path class="lig" d="M916 288 V318"/>
<path class="lig" d="M850 262 H620 M620 262 V88 M620 88 H460" style="stroke-dasharray:3 3"/>
<text class="lb" x="636" y="180">marcador</text>
<path class="lig" d="M424 116 H200" style="stroke-dasharray:3 3"/><text class="lb" x="252" y="132">WhatsApp</text>
<g class="no" data-i="0" tabindex="0"><rect x="26" y="58" width="174" height="52" rx="8"/>
<text class="nn" x="42" y="82">Tiny · Olist</text><text class="nq" x="42" y="98">pedido nasce aqui</text></g>
<g class="no" data-i="1" tabindex="0"><rect x="26" y="116" width="174" height="24" rx="6"/>
<text class="nn" x="42" y="133" style="font-size:12px">WhatsApp API</text></g>
<g class="no" data-i="2" tabindex="0"><rect x="424" y="58" width="260" height="52" rx="8" style="stroke:#00A6C4"/>
<text class="nn" x="442" y="82">Portal · FastAPI</text><text class="nq" x="442" y="98">token, link, pré-assinada</text></g>
<g class="no" data-i="3" tabindex="0"><rect x="424" y="140" width="260" height="56" rx="8" style="stroke:#00A6C4"/>
<text class="nn" x="442" y="164">Robô · pyvips</text><text class="nq" x="442" y="182">valida, repete, fatia, carimba</text></g>
<g class="no" data-i="4" tabindex="0"><rect x="424" y="226" width="124" height="24" rx="6"/>
<text class="nn" x="440" y="243" style="font-size:12px">PostgreSQL</text></g>
<g class="no" data-i="5" tabindex="0"><rect x="560" y="226" width="124" height="24" rx="6"/>
<text class="nn" x="576" y="243" style="font-size:12px">Storage S3</text></g>
<g class="no" data-i="6" tabindex="0"><rect x="850" y="142" width="200" height="52" rx="8" style="stroke:#7C6BD1"/>
<text class="nn" x="866" y="166">Agente</text><text class="nq" x="866" y="182">baixa e grava na pasta</text></g>
<g class="no" data-i="7" tabindex="0"><rect x="850" y="232" width="200" height="52" rx="8"/>
<text class="nn" x="866" y="256">Pastas do servidor</text><text class="nq" x="866" y="272">00 … 60 · espelho</text></g>
<g class="no" data-i="8" tabindex="0"><rect x="850" y="318" width="200" height="52" rx="8" style="stroke:#C8256B"/>
<text class="nn" x="866" y="342">Kanban · FastAPI</text><text class="nq" x="866" y="358">SQLite · fonte de verdade</text></g>
</svg>
</div>
<div class="det" id="det"></div>
<p class="dica">Setas do teclado navegam entre os componentes.</p>
</div>
<script>
const C=[
{t:"Tiny · Olist",s:"Sistema que já existe · fonte do número do pedido",acc:"var(--fraco)",
p:["O número do Tiny é a chave de tudo. O portal só cria o link depois que o pedido existe lá.","<b>Gatilho é webhook, não polling.</b> O link sai no segundo em que o pedido nasce."],
code:`<b>POST</b> /webhook/tiny <i>header: x-token</i>
{ "numero":"48213", "cliente":"...", "telefone":"...", "metros":2.4 }`,
blocos:[["Marcadores que o sistema escreve",["DTF-PRODUCAO — entrou na máquina","CORRECAO DTF — voltou","DTF-PRONTO — finalizado"]],
["Marcadores que ele preserva",["multiempresa, VALE, Troca","BOLETO NEXSTAR SICCOB","Maq 1 a 6 (histórico)"]]],
aten:["Confirmar antes de subir","Endpoint exato de marcadores na API v3, limite de requisições por minuto e se o refresh token expira. Tudo isolado em <code>tiny.py</code> — se mudar, muda só ali."]},
{t:"WhatsApp API",s:"Meta oficial ou provedor · três avisos ao cliente",acc:"var(--fraco)",
p:["Aprovada, em produção e finalizada. Mais correção, que é a única que pede resposta. Sete avisos viraria spam e o cliente pararia de ler."],
blocos:[["Meta oficial",["Sem risco de bloqueio do número","Exige template aprovado","R$ 100 a 300/mês"]],
["Z-API ou Evolution",["Sobe rápido, sem template","Risco de bloqueio","Mais barato"]]],
aten:["Decisão pendente","O número do DTF migra para a API oficial ou fica com provedor? Muda o custo e o risco."]},
{t:"Portal · FastAPI",s:"Python 3.12 · VPS Linux · aberto na internet",acc:"var(--ciano)",
p:["Cria o token do link, entrega a URL pré-assinada e recebe o aviso de que o upload terminou.","<b>Fica fora da fábrica de propósito.</b> Se a internet da Altus cair às 3h da manhã, o cliente sobe do mesmo jeito e o agente baixa quando voltar."],
code:`<b>GET</b> /arte/{token} <i>página</i>
<b>POST</b> /api/arte/{token}/url <i>URL pré-assinada</i>
<b>POST</b> /api/arte/{token}/pronto <i>dispara o robô</i>
<b>GET</b> /api/artes <i>o agente busca aqui</i>`,
blocos:[["Por que pré-assinada",["200 MB pelo VPS derrubaria o processo","O navegador fala direto com o storage","Validade de 1 hora"]],
["Token do link",["UUID v4, 7 dias","Um por pedido, nunca reaproveitado","Rate limit de 20 req/min"]]]},
{t:"Robô · pyvips",s:"Pré-flight, repetição, fatiamento e carimbo",acc:"var(--ciano)",
p:["pyvips lê PNG e TIFF de 200 MB em streaming, sem carregar na memória. É o que permite rodar num VPS de 4 GB.","<b>A regra que mais pega arquivo ruim é o DPI efetivo.</b> O valor gravado no cabeçalho mente — o cliente escala a imagem e o programa mantém 300."],
code:`dpi = img.height / (cm_uteis / 2.54) <i># não confie no metadado</i>
<b>se</b> dpi &lt; 150: recusa
<b>se</b> repeticoes &gt; 1: empilhar()
partes = fatiar(limite_m=<u>15</u>)
carimbar(partes[-1]) <i># só na última</i>`,
blocos:[["Recusa",["Sem canal alfa","DPI efetivo abaixo de 150","Largura acima de 57 cm","JPEG ou arquivo corrompido"]],
["Aceita com aviso",["DPI entre 150 e 300","Traço abaixo de 0,4 mm","Bordas com alfa parcial"]]],
aten:["carimbar() precisa de teste visual","A lógica está certa, mas posicionamento de texto com pyvips sempre pede ajuste olhando o resultado impresso."]},
{t:"PostgreSQL",s:"Na nuvem · pedidos, artes, partes e jobs",acc:"var(--ciano)",
p:["Quatro tabelas: <code>pedido</code>, <code>arte</code>, <code>parte</code> e <code>job</code>.","<b>A fila de jobs é tabela, não broker.</b> <code>SELECT … FOR UPDATE SKIP LOCKED</code> resolve. Redis e Celery são complexidade sem retorno neste volume."],
blocos:[["Retentativa",["1 min, 5 min, 30 min","Depois desiste e alerta","O log local grava independente"]],
["Backup",["Dump diário para o storage","SQLite local no backup do servidor"]]]},
{t:"Storage S3",s:"Cloudflare R2 ou Backblaze B2",acc:"var(--ciano)",
p:["Escolhido por não cobrar saída de dados — o agente baixa tudo que sobe, e isso sairia caro em provedor que tarifa egress.","<b>Retenção de 90 dias.</b> A arte é propriedade do cliente. Regra de ciclo de vida no bucket faz a limpeza sozinha."],
aten:["Antivírus antes de liberar","ClamAV no arquivo recebido antes de o agente baixar. É arquivo de terceiro entrando na rede."]},
{t:"Agente",s:"Python · serviço no servidor da fábrica",acc:"var(--roxo)",
p:["Busca o que foi aprovado e grava em <code>00_ARTE_RECEBIDA</code>. Baixa em arquivo temporário e só renomeia quando completa — o kanban nunca vê arquivo pela metade.","Trata queda de internet como situação normal, não como erro. Para, e volta quando a conexão voltar."],
code:`webhook do portal + polling de 60 s <i>(rede de segurança)</i>
baixa → confere SHA-256 → insere no SQLite → marca baixada
heartbeat a cada 5 min`,
blocos:[["Windows",["nssm install DtfAgente","Variáveis via AppEnvironmentExtra"]],
["Linux",["dtf-agente.service","Restart=always"]]],
aten:["Serviço silencioso parado é pior que erro barulhento","Sem heartbeat por 15 minutos, o portal precisa alertar o TI. Ninguém percebe um agente morto até o cliente cobrar."]},
{t:"Pastas do servidor",s:"Espelho, não fonte de verdade",acc:"var(--fraco)",
p:["Nove pastas numeradas para ordenar sozinhas no explorador. O arquivo acompanha o card.","<b>Se a pasta e o banco divergirem, o banco ganha.</b> Um watchdog opcional captura quem move arquivo direto pelo Explorer e corrige o banco."],
code:`00_ARTE_RECEBIDA 10_ARTE_TRATADA 20_FILA_IMPRESSAO
30_IMPRIMINDO 40_CORRECAO 50_APLICACAO
60_FINALIZADO _ERRO _log`},
{t:"Kanban · FastAPI",s:"SQLite local · rede interna, sem porta para fora",acc:"var(--magenta)",
p:["Mover o card grava o movimento, move o arquivo e enfileira o marcador e o WhatsApp. Ninguém abre o Tiny para atualizar nada.","<b>O movimento é gravado antes de qualquer integração externa.</b> Se o Tiny estiver fora do ar, a medição de tempo já está salva."],
code:`<b>POST</b> /api/mover {arte_id, para, usuario, maquina}
<b>GET</b> /api/quadro <i>cards com tempo parado em tempo real</i>
<b>GET</b> /p/{pedido} <i>o QR do carimbo cai aqui</i>
<b>GET</b> /api/relatorio/etapas`,
blocos:[["Tabela movimento",["de, para, entrou, saiu, segundos","usuário e máquina","é dela que saem todos os números"]],
["O que ela responde",["Tempo médio por etapa","Fila por máquina","Taxa de retrabalho","Se o gargalo é máquina ou designer"]]],
aten:["O front ainda não está conectado","O protótipo usa dados fixos. Precisa trocar por chamadas a /api/quadro e /api/mover. Meia hora de trabalho."]}
];
const nos=[...document.querySelectorAll('.no')], det=document.getElementById('det');
function ver(i){
const c=C[i];
det.style.setProperty('--acc',c.acc);
let h=`<h2>${c.t}</h2><div class="sub">${c.s}</div>`+c.p.map(p=>`<p>${p}</p>`).join('');
if(c.code) h+=`<pre>${c.code}</pre>`;
if(c.blocos) h+='<div class="blocos">'+c.blocos.map(b=>
`<div class="b"><h3>${b[0]}</h3><ul>${b[1].map(x=>`<li>${x}</li>`).join('')}</ul></div>`).join('')+'</div>';
if(c.aten) h+=`<div class="aten"><b>${c.aten[0]}</b>${c.aten[1]}</div>`;
det.innerHTML=h;
nos.forEach((n,j)=>n.setAttribute('aria-current',j===i?'true':'false'));
}
nos.forEach(n=>{
n.addEventListener('click',()=>ver(+n.dataset.i));
n.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){ver(+n.dataset.i);e.preventDefault();}});
});
document.addEventListener('keydown',e=>{
const at=nos.findIndex(n=>n.getAttribute('aria-current')==='true');
if(e.key==='ArrowRight'&&at<nos.length-1){nos[at+1].focus();ver(at+1);e.preventDefault();}
if(e.key==='ArrowLeft'&&at>0){nos[at-1].focus();ver(at-1);e.preventDefault();}
});
ver(0);
</script>
</body>
</html>

2819
dtf-site.html Normal file

File diff suppressed because it is too large Load Diff

557
kanban/main.py Normal file
View File

@@ -0,0 +1,557 @@
"""
Kanban da sala de DTF — roda no servidor da fábrica, rede interna.
Princípio: o KANBAN é a única interface. O operador move o card aqui e as
pastas do servidor seguem sozinhas — ninguém abre o Explorer.
Decidido em 30/08/2026: não existe watchdog de pastas. Movimento feito fora do
kanban não é suportado.
A fila é sempre por HORÁRIO DE CHEGADA: quem chegou primeiro fica em cima.
Mover o card grava o movimento, move o arquivo, enfileira o marcador no Tiny
e o WhatsApp ao cliente. Ninguém abre o Tiny para atualizar nada.
O log de movimentos é gravado SEMPRE, mesmo se o Tiny estiver fora do ar.
A medição de tempo por etapa nunca depende de API de terceiro.
Rodar:
uvicorn main:app --host 0.0.0.0 --port 8080
"""
from __future__ import annotations
import json
import os
import shutil
import sqlite3
import threading
import time
from datetime import datetime, timedelta
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, RedirectResponse
from pydantic import BaseModel
import tiny
import whats
BANCO = Path(os.environ.get("KANBAN_DB", r"C:\dtf\kanban.db"))
RAIZ = Path(os.environ.get("PASTA_DTF", r"\\servidor\DTF"))
# O KANBAN é a fonte de verdade da operação. O marcador no Tiny existe só para
# quem NÃO abre o kanban: comercial atendendo cliente no telefone, expedição
# conferindo se o DTF saiu, e o Marcus olhando pelo celular fora da fábrica.
#
# Por isso o Tiny recebe o estágio grosso, não o detalhe fino:
# está sendo feito · travou · ficou pronto
#
# Três chamadas por pedido em vez de sete. A 213 pedidos/dia, são 640 requisições
# em vez de 1.500 — folga no limite da API e menos job para o TI monitorar.
COLUNAS = [
("aut", "Aguardando autorização", "05_AUTORIZACAO", None, False), # só retrabalho
("rec", "Arte recebida", "00_ARTE_RECEBIDA", None, False),
("tra", "Arte tratada", "10_ARTE_TRATADA", None, False),
("apc", "Aprovação de cor", "15_APROVACAO_COR", "DTF-PROVA-COR",False),
("fil", "Fila de impressão", "20_FILA_IMPRESSAO", None, False),
("imp", "Imprimindo", "30_IMPRIMINDO", "DTF-PRODUCAO", False),
("cor", "Correção", "40_CORRECAO", "CORRECAO DTF", False),
("fin", "Finalizado", "60_FINALIZADO", "DTF-PRONTO", False),
]
# A sala só IMPRIME o filme — quem aplica na peça é o cliente.
# Por isso não existe coluna de aplicação nem causa de retrabalho por aplicação.
IDS = [c[0] for c in COLUNAS]
INFO = {c[0]: c for c in COLUNAS}
AVISA_CLIENTE = {"apc": "prova_cor", "imp": "producao",
"cor": "correcao", "fin": "concluido"}
app = FastAPI(title="Kanban DTF")
# ---------------------------------------------------------------------------
# Distribuição entre as máquinas — POR PUXADA, não por empurro
# ---------------------------------------------------------------------------
# O sistema NÃO decide qual máquina roda qual pedido. A fila é única, por
# horário de chegada, e a máquina que termina puxa o próximo.
#
# Empurrar exigiria adivinhar qual máquina estará livre daqui a duas horas. Se
# uma travar, a fila dela para enquanto outra fica ociosa, e alguém remaneja na
# mão. Por puxada nunca desbalanceia.
#
# Para o operador: um botão "puxar próximo". O arquivo já vem com o spot pronto
# do robô — ele só abre no Flexi e roda.
# Ajustado em 02/09/2026 pelo Thales e Alexandre:
# "reserva de 15 min é demais, no máx 3 min"
# "pedido por vez" — não puxar lote de trabalho
RESERVA_MIN = 3 # sem entrar em Imprimindo nesse prazo, volta para a fila
PUXAR_ATE_MIN = 0 # 0 = um pedido por vez, como a sala pediu
class Puxar(BaseModel):
maquina: int
usuario: str
minutos: int = PUXAR_ATE_MIN
@app.post("/api/puxar")
def puxar(p: Puxar):
"""
A máquina puxa o próximo da fila. Reserva por 15 min e move o arquivo para
a hot folder daquela máquina — o FlexiPRINT processa sozinho de lá.
"""
agora = datetime.now()
limite = (agora - timedelta(minutes=RESERVA_MIN)).isoformat(timespec="seconds")
pegos, minutos = [], 0
with con() as c:
# solta reservas vencidas antes de distribuir
c.execute("UPDATE card SET reservado_por=NULL, reservado_em=NULL "
"WHERE reservado_em IS NOT NULL AND reservado_em < ?", (limite,))
fila = c.execute(
"SELECT * FROM card WHERE coluna='fil' AND reservado_por IS NULL "
"ORDER BY desde").fetchall() # sempre por horário de chegada
for card in fila:
# estimativa de máquina: o campo do card manda; sem ele, calcula pelos metros
mm = card["minutos_maquina"] or round(card["metros"] / 20 * 60)
if pegos: # um pedido por vez
break
c.execute("UPDATE card SET reservado_por=?, reservado_em=?, maquina=? "
"WHERE arte_id=?",
(f"Maq {p.maquina}", agora.isoformat(timespec="seconds"),
f"Maq {p.maquina}", card["arte_id"]))
mover_arquivos(card["pedido"], "20_FILA_IMPRESSAO", f"30_MAQ{p.maquina}")
pegos.append({"pedido": card["pedido"], "metros": card["metros"],
"minutos": mm})
minutos += mm
return {"maquina": f"Maq {p.maquina}", "pedidos": pegos,
"minutos_total": minutos,
"reserva_expira_em": RESERVA_MIN}
@app.post("/api/devolver")
def devolver(arte_id: int, usuario: str):
"""
Máquina travou no meio. O pedido volta ao TOPO da fila, não ao fim —
ele já esperou uma vez.
"""
with con() as c:
card = c.execute("SELECT * FROM card WHERE arte_id=?", (arte_id,)).fetchone()
if not card:
raise HTTPException(404)
maq = (card["maquina"] or "Maq 1").replace("Maq ", "")
mover_arquivos(card["pedido"], f"30_MAQ{maq}", "20_FILA_IMPRESSAO")
# desde antigo = volta para o topo da ordem por horário de chegada
c.execute("UPDATE card SET coluna='fil', reservado_por=NULL, "
"reservado_em=NULL, maquina=NULL WHERE arte_id=?", (arte_id,))
c.execute("""INSERT INTO movimento
(arte_id,pedido,de,para,entrou_em,saiu_em,segundos,usuario,maquina)
VALUES (?,?,?,?,?,?,?,?,?)""",
(arte_id, card["pedido"], "imp", "fil", card["desde"],
datetime.now().isoformat(timespec="seconds"), 0, usuario, card["maquina"]))
return {"ok": True, "voltou_ao_topo": True}
# ---------------------------------------------------------------------------
# Aprovação de cor — evita o retrabalho em vez de repor depois
# ---------------------------------------------------------------------------
# Imprime uma amostra pequena, fotografa e manda ao cliente. Ele aprova, e só
# então o pedido vai para a fila. Custa centímetros de filme e evita metros.
#
# Dispara sozinho em três situações:
PROVA_METROS = 10.0 # arquivo grande: erro de cor custa caro
PROVA_CLIENTE_NOVO = True # primeiro pedido do cliente
# cores fora do gamut CMYK, que quase nunca saem como o cliente vê na tela
PROVA_CORES_TENSAS = {
"vermelho_saturado", "laranja", "verde_vivo", "azul_royal",
"roxo", "tom_de_pele", "cinza_neutro",
}
def precisa_prova_cor(metros: float, cliente_novo: bool,
cores_detectadas: set[str]) -> tuple[bool, str]:
if metros >= PROVA_METROS:
return True, f"arquivo de {metros:.1f} m"
if cliente_novo and PROVA_CLIENTE_NOVO:
return True, "primeiro pedido do cliente"
tensas = cores_detectadas & PROVA_CORES_TENSAS
if tensas:
return True, "cor fora do gamut: " + ", ".join(sorted(tensas))
return False, ""
# ---------------------------------------------------------------------------
# Retrabalho — SKU CRRMP.TX.100CM
# ---------------------------------------------------------------------------
# A causa é obrigatória e define quem absorve o custo. Sem ela o card não anda.
CAUSAS = {
"arte_cliente": ("Arte ruim aprovada pelo cliente", "cliente"),
"tratamento": ("Erro de tratamento", "casa"),
"impressao": ("Falha de impressão", "casa"),
"perfil_cor": ("Perfil de cor", "casa"),
"pedido": ("Erro de pedido", "casa"),
}
# Quem abre e quem autoriza são pessoas diferentes, de propósito.
#
# MAYANA abre e CLASSIFICA a causa. Ela conhece o cliente e sabe se a
# reclamação procede. A causa fica TRAVADA depois de aberta.
#
# THALES ou ALEXANDRE autorizam. O retrabalho da casa entra na meta e na
# remuneração deles — então têm incentivo real para questionar o que não
# procede. É controle natural, não burocracia.
#
# Por que a causa não pode ser mudada por quem autoriza: com o indicador
# atrelado a bônus, existiria incentivo para reclassificar falha de máquina
# como "arte do cliente". Quem discorda CONTESTA, e a contestação fica
# registrada com quem pediu e quem decidiu.
ABRE = "mayana"
AUTORIZA = ("thales", "alexandre")
# Alçada por metragem. Cada metro custa R$ 4,94 de insumo mais tempo de máquina.
ALCADA = [(15.0, "sala"), (float("inf"), "financeiro")]
# Meta por causa, não meta única. Cada um responde pelo que controla.
# 0,4% em cada uma das quatro causas da casa = 1,6% no total.
#
# Perfil de cor é causa PRÓPRIA, separada de falha de impressão. Cor errada é o
# retrabalho mais comum de DTF e quase nunca é defeito de máquina: ou a arte
# veio em CMYK, ou o monitor do cliente não é calibrado, ou o perfil da
# impressora está desatualizado. Só o terceiro é da casa — e separando dá para
# saber quanto é cada coisa.
META_POR_CAUSA = {
"impressao": (0.4, "Thales e Alexandre"),
"perfil_cor": (0.4, "Thales e Alexandre"),
"tratamento": (0.4, "designers"),
"pedido": (0.4, "comercial"),
"arte_cliente": (None, None), # reposição comercial: fora da meta
}
# soma das metas da casa = 1,6%
TETO_MES_PCT = 6.0 # acima disso tudo sobe uma alçada
TETO_CLIENTE_PCT = 10.0 # cliente acima disso fica sinalizado no painel
def quem_autoriza(metros: float, vez: int, pct_mes: float) -> str:
"""Reincidência e teto do mês sobem a alçada automaticamente."""
if vez >= 3:
return "diretoria"
nivel = next(n for lim, n in ALCADA if metros <= lim)
if vez == 2:
nivel = {"sala": "financeiro"}.get(nivel, "diretoria")
if pct_mes > TETO_MES_PCT:
nivel = {"sala": "financeiro", "financeiro": "diretoria"}.get(nivel, "diretoria")
return nivel
def conta_na_meta(causa: str) -> bool:
"""Só o retrabalho da casa entra na meta. O do cliente, não."""
return CAUSAS[causa][1] == "casa"
@app.get("/api/relatorio/retrabalho")
def retrabalho(dias: int = 30):
"""
Retrabalho por causa, com responsável e meta.
Base para a bonificação: cada equipe é medida só pelo que controla.
"""
with con() as c:
total = c.execute(
"SELECT COUNT(*) n FROM card WHERE desde >= date('now', ?)",
(f"-{dias} days",)).fetchone()["n"] or 1
linhas = c.execute("""
SELECT causa, COUNT(*) n, SUM(metros) m
FROM retrabalho WHERE aberto_em >= date('now', ?)
GROUP BY causa""", (f"-{dias} days",)).fetchall()
saida, casa = [], 0.0
for r in linhas:
pct = r["n"] / total * 100
meta, quem = META_POR_CAUSA.get(r["causa"], (None, None))
if conta_na_meta(r["causa"]):
casa += pct
saida.append({
"causa": r["causa"], "descricao": CAUSAS[r["causa"]][0],
"responsavel": quem, "pedidos": r["n"], "metros": r["m"],
"pct": round(pct, 2), "meta": meta,
"bate": None if meta is None else pct <= meta,
})
return {"por_causa": saida, "total_casa_pct": round(casa, 2),
"meta_casa_pct": round(sum(m for m, _ in META_POR_CAUSA.values()
if m is not None), 2)}
# --------------------------------------------------------------------------
def con():
c = sqlite3.connect(BANCO, timeout=10)
c.row_factory = sqlite3.Row
return c
def criar_tabelas() -> None:
with con() as c:
c.executescript("""
CREATE TABLE IF NOT EXISTS card(
arte_id INTEGER PRIMARY KEY, pedido TEXT, cliente TEXT, metros REAL,
coluna TEXT DEFAULT 'rec', desde TEXT, maquina TEXT, partes INTEGER,
reservado_por TEXT, reservado_em TEXT,
minutos_maquina INTEGER); -- estimativa; herda os marcadores 30min/1h/3h
CREATE TABLE IF NOT EXISTS movimento(
id INTEGER PRIMARY KEY AUTOINCREMENT,
arte_id INTEGER, pedido TEXT, de TEXT, para TEXT,
entrou_em TEXT, saiu_em TEXT, segundos INTEGER,
usuario TEXT, maquina TEXT);
CREATE TABLE IF NOT EXISTS job(
id INTEGER PRIMARY KEY AUTOINCREMENT,
tipo TEXT, payload TEXT, tentativas INTEGER DEFAULT 0,
proxima_em REAL, erro TEXT, feito INTEGER DEFAULT 0);
CREATE INDEX IF NOT EXISTS ix_mov_arte ON movimento(arte_id);
CREATE INDEX IF NOT EXISTS ix_job_fila ON job(feito, proxima_em);
""")
criar_tabelas()
# --------------------------------------------------------------------------
class Mover(BaseModel):
arte_id: int
para: str
usuario: str
maquina: str | None = None
@app.get("/api/quadro")
def quadro():
agora = datetime.now()
with con() as c:
cards = c.execute("SELECT * FROM card ORDER BY desde").fetchall()
saida = {i: [] for i in IDS}
for r in cards:
desde = datetime.fromisoformat(r["desde"])
saida[r["coluna"]].append({
"arte_id": r["arte_id"], "pedido": r["pedido"], "cliente": r["cliente"],
"metros": r["metros"], "maquina": r["maquina"], "partes": r["partes"],
"desde": r["desde"],
"parado_seg": int((agora - desde).total_seconds()),
"minutos_maquina": round(r["metros"] / 20 * 60), # 20 m/h por máquina
})
return {"colunas": [{"id": c[0], "nome": c[1]} for c in COLUNAS], "cards": saida}
@app.post("/api/mover")
def mover(m: Mover):
if m.para not in IDS:
raise HTTPException(400, "coluna inválida")
agora = datetime.now()
with con() as c:
card = c.execute("SELECT * FROM card WHERE arte_id=?", (m.arte_id,)).fetchone()
if not card:
raise HTTPException(404, "card não encontrado")
if card["coluna"] == m.para:
return {"ok": True, "sem_mudanca": True}
de = card["coluna"]
desde = datetime.fromisoformat(card["desde"])
# 1. o movimento é gravado ANTES de qualquer integração externa
c.execute("""INSERT INTO movimento
(arte_id,pedido,de,para,entrou_em,saiu_em,segundos,usuario,maquina)
VALUES (?,?,?,?,?,?,?,?,?)""",
(m.arte_id, card["pedido"], de, m.para, card["desde"],
agora.isoformat(timespec="seconds"),
int((agora - desde).total_seconds()), m.usuario,
m.maquina or card["maquina"]))
c.execute("UPDATE card SET coluna=?, desde=?, maquina=COALESCE(?,maquina) "
"WHERE arte_id=?",
(m.para, agora.isoformat(timespec="seconds"), m.maquina, m.arte_id))
# 2. arquivo acompanha o card
mover_arquivos(card["pedido"], INFO[de][2], INFO[m.para][2])
# 3. jobs: marcador no Tiny e WhatsApp
# a máquina fica só no kanban — no Tiny basta saber que entrou em produção
marcador = INFO[m.para][3]
if marcador:
enfileirar(c, "tiny_marcador",
{"pedido": card["pedido"], "marcador": marcador})
if m.para in AVISA_CLIENTE:
enfileirar(c, "whats",
{"pedido": card["pedido"], "tipo": AVISA_CLIENTE[m.para]})
return {"ok": True, "de": de, "para": m.para}
def mover_arquivos(pedido: str, pasta_de: str, pasta_para: str) -> None:
origem, destino = RAIZ / pasta_de, RAIZ / pasta_para
destino.mkdir(parents=True, exist_ok=True)
for f in origem.glob(f"{pedido}_*"):
shutil.move(str(f), str(destino / f.name))
# --------------------------------------------------------------------------
# Fila de jobs — tabela simples, sem Redis nem Celery
# --------------------------------------------------------------------------
def enfileirar(c, tipo: str, payload: dict) -> None:
c.execute("INSERT INTO job(tipo,payload,proxima_em) VALUES (?,?,?)",
(tipo, json.dumps(payload), time.time()))
ESPERA = [60, 300, 1800] # 1 min, 5 min, 30 min
def worker() -> None:
while True:
try:
with con() as c:
job = c.execute(
"SELECT * FROM job WHERE feito=0 AND proxima_em<=? "
"ORDER BY id LIMIT 1", (time.time(),)).fetchone()
if not job:
time.sleep(3)
continue
p = json.loads(job["payload"])
try:
if job["tipo"] == "tiny_marcador":
tiny.marcador(p["pedido"], p["marcador"])
elif job["tipo"] == "whats":
whats.avisar(p["pedido"], p["tipo"])
c.execute("UPDATE job SET feito=1 WHERE id=?", (job["id"],))
except Exception as e:
n = job["tentativas"] + 1
if n > len(ESPERA):
c.execute("UPDATE job SET feito=1, erro=? WHERE id=?",
(f"desistiu: {e}", job["id"]))
# TODO: alertar o TI
else:
c.execute(
"UPDATE job SET tentativas=?, proxima_em=?, erro=? WHERE id=?",
(n, time.time() + ESPERA[n - 1], str(e), job["id"]))
except Exception:
time.sleep(5)
threading.Thread(target=worker, daemon=True).start()
# --------------------------------------------------------------------------
# QR do carimbo aponta para cá
# --------------------------------------------------------------------------
@app.get("/p/{pedido}")
def abrir_card(pedido: str):
"""A revisão bipa o QR do filme e cai direto no card."""
with con() as c:
card = c.execute("SELECT arte_id FROM card WHERE pedido=?", (pedido,)).fetchone()
if not card:
raise HTTPException(404, "pedido não encontrado no kanban")
return RedirectResponse(f"/?card={card['arte_id']}")
@app.get("/api/pedido/{pedido}")
def trilha(pedido: str):
"""Tempo em cada etapa e quanto do total foi só esperando."""
PARADO = {"rec", "tra", "fil", "cor"}
with con() as c:
movs = c.execute(
"SELECT * FROM movimento WHERE pedido=? ORDER BY id", (pedido,)).fetchall()
card = c.execute("SELECT * FROM card WHERE pedido=?", (pedido,)).fetchone()
if not card:
raise HTTPException(404)
agora = int((datetime.now() - datetime.fromisoformat(card["desde"])).total_seconds())
etapas = [{"coluna": m["de"], "nome": INFO[m["de"]][1], "segundos": m["segundos"]}
for m in movs]
etapas.append({"coluna": card["coluna"], "nome": INFO[card["coluna"]][1],
"segundos": agora, "atual": True})
total = sum(e["segundos"] for e in etapas)
esperando = sum(e["segundos"] for e in etapas if e["coluna"] in PARADO)
return {"pedido": pedido, "cliente": card["cliente"], "metros": card["metros"],
"etapas": etapas, "total_seg": total, "esperando_seg": esperando}
PERIODOS = {"hoje": 1, "7": 7, "30": 30, "mes": 30, "ant": 60}
@app.get("/api/relatorio/impressao")
def tempo_impressao(dias: int = 7):
"""
Tempo médio de impressão e velocidade real por máquina.
Sai da coluna 'imp' da tabela movimento, cruzada com os metros do card.
É este número que diz se 20 m/h é a velocidade real ou só a de catálogo.
"""
with con() as c:
linhas = c.execute("""
SELECT m.maquina, COUNT(*) n,
AVG(m.segundos) media_seg,
SUM(c.metros) metros,
SUM(m.segundos) total_seg
FROM movimento m JOIN card c ON c.arte_id = m.arte_id
WHERE m.de = 'imp' AND m.saiu_em >= date('now', ?)
GROUP BY m.maquina""", (f"-{dias} days",)).fetchall()
saida = []
for r in linhas:
horas = (r["total_seg"] or 0) / 3600
saida.append({
"maquina": r["maquina"], "pedidos": r["n"],
"media_min": round((r["media_seg"] or 0) / 60),
"metros": round(r["metros"] or 0, 1),
"m_por_hora": round((r["metros"] or 0) / horas, 1) if horas else None,
})
return saida
# Depósito do Tiny para onde o insumo é transferido antes de imprimir.
# A Altus também VENDE insumo, então esse depósito separa o que é consumo
# interno do que é revenda. Confirmar o nome exato no cadastro do Tiny.
DEPOSITO_IMPRESSAO = "Sala DTF"
SKU_FILME = "DTF.FILME.57"
@app.get("/api/relatorio/aproveitamento")
def aproveitamento(dias: int = 30):
"""
Quanto do filme transferido virou metro faturado.
Compra-se rolo de 100 m e imprime-se cerca de 90, por acerto de máquina,
prova de cor, refile e sobra de ponta. Cada ponto percentual vale cerca de
R$ 980/mês no volume atual.
NÃO exige apontamento novo na sala: o consumo já é registrado hoje, quando
o insumo é transferido para o depósito de impressão no Tiny. Este endpoint
só lê a movimentação desse depósito.
"""
fat = 0.0
with con() as c:
fat = c.execute(
"SELECT SUM(metros) m FROM card WHERE coluna='fin' AND desde >= date('now', ?)",
(f"-{dias} days",)).fetchone()["m"] or 0
cons = tiny.transferido_para_deposito(
sku=SKU_FILME, deposito=DEPOSITO_IMPRESSAO, dias=dias)
pct = fat / cons * 100 if cons else None
return {"metros_faturados": round(fat, 1),
"metros_de_filme": round(cons, 1),
"fonte": f"transferências para o depósito {DEPOSITO_IMPRESSAO} no Tiny",
"aproveitamento_pct": round(pct, 1) if pct else None,
"valor_do_ponto_mes": 980}
@app.get("/api/relatorio/etapas")
def relatorio(dias: int = 7):
"""Tempo médio por etapa. É o número que hoje não existe em lugar nenhum."""
with con() as c:
linhas = c.execute("""
SELECT de, COUNT(*) n, AVG(segundos) media, MAX(segundos) pior
FROM movimento
WHERE saiu_em >= date('now', ?)
GROUP BY de""", (f"-{dias} days",)).fetchall()
return [{"coluna": r["de"], "nome": INFO[r["de"]][1], "movimentos": r["n"],
"media_min": round(r["media"] / 60), "pior_min": round(r["pior"] / 60)}
for r in linhas]
@app.get("/")
def index():
return FileResponse("static/kanban.html")

271
kanban/static/kanban.html Normal file
View File

@@ -0,0 +1,271 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DTF · sala de impressão</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root{--bg:#0B0D10;--card:#15181D;--card2:#1C2026;--linha:#282C34;--tx:#EDEBE6;--fraco:#7C828C;
--ciano:#00B8DA;--magenta:#E0357C;--amarelo:#EFB700;--branco:#EDEBE6;--verde:#48B072;
--alerta:#E0642A;--roxo:#8E7CE8}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--tx);font-family:"IBM Plex Mono",ui-monospace,monospace;
font-size:14px;line-height:1.45;-webkit-font-smoothing:antialiased;padding:16px 14px 34px}
h1,h2{font-family:"Space Grotesk",system-ui,sans-serif;letter-spacing:-.02em}
.topo{display:flex;align-items:baseline;gap:14px;flex-wrap:wrap;margin-bottom:6px}
h1{font-size:23px;font-weight:700}
.sub{color:var(--fraco);font-size:12px}
.rel{margin-left:auto;font-family:"Space Grotesk",sans-serif;font-size:19px;font-weight:700;color:var(--ciano)}
.aviso{background:#152026;border:1px solid #24404A;border-left:3px solid var(--ciano);
border-radius:0 8px 8px 0;padding:10px 14px;font-size:12.5px;color:#B9CBD2;margin:10px 0 14px}
.aviso b{color:var(--ciano);font-family:"Space Grotesk",sans-serif}
/* máquinas */
.maqs{display:grid;grid-template-columns:repeat(6,1fr);gap:8px;margin-bottom:14px}
@media(max-width:900px){.maqs{grid-template-columns:repeat(3,1fr)}}
.mq{background:var(--card);border:1px solid var(--linha);border-radius:9px;padding:11px 12px;position:relative}
.mq.ocup{border-color:var(--alerta)}
.mq .luz{width:11px;height:11px;border-radius:50%;position:absolute;top:11px;right:11px;background:var(--verde)}
.mq.ocup .luz{background:var(--alerta);box-shadow:0 0 0 3px rgba(224,100,42,.2)}
.mq b{font-family:"Space Grotesk",sans-serif;font-size:13px;display:block;margin-bottom:4px}
.mq .st{font-size:10.5px;color:var(--fraco);line-height:1.4;min-height:28px}
.mq button{margin-top:8px;width:100%;font-family:inherit;font-size:11px;padding:6px;border-radius:6px;
border:1px solid var(--linha);background:var(--card2);color:var(--fraco);cursor:pointer}
.mq.livre button{background:var(--ciano);border-color:var(--ciano);color:#03181D;font-weight:600}
.mq.livre button:hover{filter:brightness(1.1)}
.mq.ocup button{opacity:.4;cursor:default}
/* kpis */
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:9px;margin-bottom:14px}
.k{background:var(--card);border:1px solid var(--linha);border-radius:9px;padding:11px 14px;border-left:3px solid var(--c,var(--linha))}
.k dt{font-size:10px;letter-spacing:.15em;text-transform:uppercase;color:var(--fraco);margin-bottom:3px}
.k dd{font-family:"Space Grotesk",sans-serif;font-size:23px;font-weight:700}
.k dd small{font-size:12px;color:var(--fraco);font-weight:400;font-family:"IBM Plex Mono",monospace}
/* kanban */
.kan{display:grid;grid-template-columns:repeat(7,minmax(140px,1fr));gap:8px;overflow-x:auto;padding-bottom:16px}
@media(max-width:1200px){.kan{grid-template-columns:repeat(7,168px)}}
.col{background:var(--card);border:1px solid var(--linha);border-radius:9px;display:flex;flex-direction:column;min-height:250px}
.col.alvo{border-color:var(--ciano);background:#101820}
.ch{padding:10px 11px 8px;border-bottom:1px solid var(--linha)}
.ch b{font-family:"Space Grotesk",sans-serif;font-size:12px;display:block}
.ch b .p{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:6px;background:var(--cc)}
.ch small{font-size:10px;color:var(--fraco)}
.cb{padding:8px;display:flex;flex-direction:column;gap:6px;flex:1}
.vazio{color:var(--fraco);font-size:10.5px;text-align:center;padding:14px 4px}
.cd{background:var(--card2);border:1px solid var(--linha);border-left:3px solid var(--cc);
border-radius:5px;padding:8px 9px;cursor:grab;font-size:11px}
.cd:active{cursor:grabbing}
.cd.arrasta{opacity:.35}
.cd b{display:block;font-family:"Space Grotesk",sans-serif;font-size:12.5px}
.cd .cli{color:var(--fraco);font-size:10px;margin:1px 0 5px}
.cd .l{display:flex;justify-content:space-between;font-size:10px}
.cd .l i{font-style:normal;color:var(--fraco)}
.cd .mq2{display:inline-block;font-size:9px;padding:1px 5px;border-radius:3px;background:var(--linha);margin-top:4px}
.cd.q .l i{color:var(--amarelo)}
.cd.r{border-left-color:var(--alerta)}
.cd.r .l i{color:var(--alerta)}
.rod{margin-top:14px;display:flex;gap:9px;align-items:center;flex-wrap:wrap;color:var(--fraco);font-size:11.5px}
.rod button{font-family:inherit;font-size:11.5px;padding:7px 12px;border-radius:7px;
border:1px solid var(--linha);background:none;color:var(--fraco);cursor:pointer}
.rod button:hover{border-color:var(--ciano);color:var(--ciano)}
@media(prefers-reduced-motion:reduce){*{transition:none!important}}
</style>
</head>
<body>
<div class="topo">
<h1>Kanban da sala de DTF</h1>
<span class="sub" id="ctx">carregando…</span>
<span class="rel" id="hora">—</span>
</div>
<div class="aviso" id="aviso" style="display:none"></div>
<div class="maqs" id="maqs"></div>
<dl class="kpis" id="kpis"></dl>
<div class="kan" id="kan"></div>
<div class="rod">
<button id="atualizar">Atualizar</button>
<span id="msg"></span>
</div>
<script>
const COLS=[
{id:'rec', nome:'Arte recebida', cor:'var(--ciano)'},
{id:'tra', nome:'Arte tratada', cor:'var(--magenta)'},
{id:'cor2',nome:'Prova de cor', cor:'var(--roxo)'},
{id:'fil', nome:'Fila', cor:'var(--amarelo)'},
{id:'imp', nome:'Imprimindo', cor:'var(--branco)'},
{id:'cor', nome:'Correção', cor:'var(--alerta)'},
{id:'fin', nome:'Finalizado', cor:'var(--verde)'}
];
const API=''; // mesma origem do PCP
const USUARIO=window.PCP_USUARIO || 'operador';
const MAQUINA=new URLSearchParams(location.search).get('maq'); // ?maq=4
const PARADO=['rec','tra','cor2','fil','cor'];
const H=36e5, M=6e4;
let cards=[], maquinas=[], erro=false;
async function api(rota,opt){
const r=await fetch(API+rota,{headers:{'Content-Type':'application/json'},...opt});
if(!r.ok){
let m='erro '+r.status;
try{ m=(await r.json()).detail || m; }catch(e){}
throw new Error(m);
}
return r.status===204? null : r.json();
}
async function carregar(){
try{
const d=await api('/api/quadro');
cards=[]; maquinas=d.maquinas||[];
Object.entries(d.cards||{}).forEach(([col,lista])=>
lista.forEach(c=>cards.push({
id:String(c.arte_id), pedido:c.pedido, cli:c.cliente,
m:c.metros, min:c.minutos_maquina || Math.round(c.metros/20*60),
col, desde:Date.parse(c.desde), maq:c.maquina? +String(c.maquina).replace(/\D/g,''):null
})));
erro=false; esconderAviso();
}catch(e){
erro=true; mostrarAviso('Sem conexão com o servidor — '+e.message+'. A tela não está atualizando.');
}
pinta();
}
function mostrarAviso(t){
const a=document.getElementById('aviso');
a.style.display='block'; a.innerHTML='<b>Atenção</b> '+t;
}
function esconderAviso(){ document.getElementById('aviso').style.display='none'; }
function salvar(){ /* estado vive no servidor */ }
async function puxar(n){
try{
const r=await api('/api/puxar',{method:'POST',
body:JSON.stringify({maquina:n, usuario:USUARIO})});
msg('Maq '+n+' puxou o #'+r.pedido+' · '+r.minutos+' min · reserva de '+r.reserva_expira_em+' min');
}catch(e){ msg(e.message); }
carregar();
}
async function mover(arte_id,para,maq){
try{
await api('/api/mover',{method:'POST',
body:JSON.stringify({arte_id:+arte_id, para, usuario:USUARIO, maquina:maq||null})});
}catch(e){ msg(e.message); }
carregar();
}
async function devolver(arte_id){
try{
await api('/api/devolver',{method:'POST',
body:JSON.stringify({arte_id:+arte_id, usuario:USUARIO})});
msg('devolvido ao topo da fila');
}catch(e){ msg(e.message); }
carregar();
}
function pintaMaqs(){
const el=document.getElementById('maqs'); el.innerHTML='';
for(let n=1;n<=6;n++){
const info=maquinas.find(x=>x.n===n) || {};
const job=cards.find(c=>c.col==='imp' && c.maq===n) ||
(info.ocupada? {pedido:info.pedido,m:info.metros,desde:Date.now()-(info.rodando_seg||0)*1000}:null);
if(MAQUINA && String(n)!==MAQUINA) continue;
const d=document.createElement('div');
d.className='mq '+(job?'ocup':'livre');
d.innerHTML='<span class="luz"></span><b>Maq '+n+'</b>'+
'<div class="st">'+(job
? '#'+job.pedido+' · '+job.m.toFixed(1).replace('.',',')+' m<br>rodando há '+dur(Date.now()-job.desde)
: 'livre<br>&nbsp;')+'</div>'+
'<button '+(job?'disabled':'')+'>'+(job?'ocupada':'puxar próximo')+'</button>';
if(!job) d.querySelector('button').addEventListener('click',()=>puxar(n));
el.appendChild(d);
}
}
function puxar(n){
const fila=cards.filter(c=>c.col==='fil').sort((a,b)=>a.desde-b.desde);
if(!fila.length) return msg('a fila está vazia');
const c=fila[0];
c.col='imp'; c.maq=n; c.desde=Date.now();
salvar(); pinta();
msg('Maq '+n+' puxou o #'+c.pedido+' — o mais antigo da fila');
}
function pintaKpis(){
const fila=cards.filter(c=>['rec','tra','cor2','fil'].includes(c.col));
const mf=fila.reduce((s,c)=>s+c.m,0), mn=fila.reduce((s,c)=>s+c.min,0);
const feito=cards.filter(c=>c.col==='fin').reduce((s,c)=>s+c.m,0);
const livres=[1,2,3,4,5,6].filter(n=>!cards.some(c=>c.col==='imp'&&c.maq===n)).length;
const cap=20*6*4.5; // 6 máquinas, 20 m/h, 4h30 restantes
const risco=cards.filter(c=>PARADO.includes(c.col)&&Date.now()-c.desde>3*H).length;
const k=[
['Na fila', mf.toFixed(1).replace('.',',')+' <small>m</small>','var(--amarelo)'],
['Minutos de máquina', mn+' <small>min</small>','var(--amarelo)'],
['Capacidade até 20h', Math.round(cap)+' <small>m</small>', mf>cap?'var(--alerta)':'var(--verde)'],
['Máquinas livres',livres+' <small>de 6</small>','var(--ciano)'],
['Impresso hoje', feito.toFixed(1).replace('.',',')+' <small>m</small>','var(--verde)'],
['Parados +3h', risco+' <small>pedido'+(risco===1?'':'s')+'</small>', risco?'var(--alerta)':'var(--verde)']
];
document.getElementById('kpis').innerHTML=k.map(x=>
'<div class="k" style="--c:'+x[2]+'"><dt>'+x[0]+'</dt><dd>'+x[1]+'</dd></div>').join('');
}
function pinta(){
pintaMaqs(); pintaKpis();
const agora=Date.now(), el=document.getElementById('kan'); el.innerHTML='';
COLS.forEach(col=>{
const its=cards.filter(c=>c.col===col.id).sort((a,b)=>a.desde-b.desde);
const mt=its.reduce((s,c)=>s+c.m,0);
const d=document.createElement('div');
d.className='col'; d.dataset.col=col.id; d.style.setProperty('--cc',col.cor);
d.innerHTML='<div class="ch"><b><span class="p"></span>'+col.nome+'</b>'+
'<small>'+its.length+' · '+mt.toFixed(1).replace('.',',')+' m</small></div><div class="cb"></div>';
const cb=d.querySelector('.cb');
if(!its.length) cb.innerHTML='<div class="vazio">—</div>';
its.forEach(c=>{
const p=agora-c.desde, fin=col.id==='fin';
const cl=fin?'':(p>3*H?' r':(p>1*H?' q':''));
const e=document.createElement('div');
e.className='cd'+cl; e.draggable=true; e.dataset.id=c.id;
e.innerHTML='<b>#'+c.pedido+'</b><div class="cli">'+c.cli+'</div>'+
'<div class="l"><i>'+(fin?'concluído':'há '+dur(p))+'</i>'+
'<span>'+c.m.toFixed(1).replace('.',',')+' m · '+c.min+' min</span></div>'+
(c.maq?'<span class="mq2">Maq '+c.maq+'</span>':'');
e.addEventListener('dragstart',ev=>{ev.dataTransfer.setData('text/plain',c.id);e.classList.add('arrasta')});
e.addEventListener('dragend',()=>e.classList.remove('arrasta'));
cb.appendChild(e);
});
d.addEventListener('dragover',ev=>{ev.preventDefault();d.classList.add('alvo')});
d.addEventListener('dragleave',()=>d.classList.remove('alvo'));
d.addEventListener('drop',ev=>{
ev.preventDefault(); d.classList.remove('alvo');
const c=cards.find(x=>x.id===ev.dataTransfer.getData('text/plain'));
if(!c||c.col===col.id) return;
if(c.col==='imp'&&col.id==='fil') return devolver(c.id);
let maq=null;
if(col.id==='imp'){
maq=[1,2,3,4,5,6].find(n=>!cards.some(x=>x.col==='imp'&&x.maq===n));
if(!maq) return msg('todas as máquinas estão ocupadas');
}
mover(c.id,col.id,maq);
});
el.appendChild(d);
});
}
document.getElementById('atualizar').addEventListener('click',carregar);
document.getElementById('ctx').textContent =
MAQUINA ? 'Maq '+MAQUINA+' · '+USUARIO : 'visão geral · '+USUARIO;
function tic(){
document.getElementById('hora').textContent=
new Date().toLocaleTimeString('pt-BR',{hour:'2-digit',minute:'2-digit'});
}
tic(); setInterval(tic,30000);
setInterval(pinta,30000); // relógio dos cards
setInterval(carregar,15000); // busca o estado no servidor
carregar();
</script>
</body>
</html>

89
kanban/tiny.py Normal file
View File

@@ -0,0 +1,89 @@
"""
Cliente da API do Tiny (Olist) — v3, OAuth2 client credentials.
ATENÇÃO Wagner: confirmar na documentação oficial antes de subir:
- o endpoint exato de marcadores do pedido
- o limite de requisições por minuto (dimensiona o worker)
- se o refresh token expira e com que frequência
Este módulo isola a API: se o endpoint mudar, muda só aqui.
"""
from __future__ import annotations
import os
import time
import httpx
BASE = os.environ.get("TINY_BASE", "https://api.tiny.com.br/public-api/v3")
CLIENT_ID = os.environ["TINY_CLIENT_ID"]
CLIENT_SECRET = os.environ["TINY_CLIENT_SECRET"]
TOKEN_URL = os.environ["TINY_TOKEN_URL"]
_token: dict = {"valor": None, "expira": 0.0}
def _acesso() -> str:
if _token["valor"] and time.time() < _token["expira"] - 60:
return _token["valor"]
r = httpx.post(TOKEN_URL, data={
"grant_type": "client_credentials",
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
}, timeout=20)
r.raise_for_status()
d = r.json()
_token["valor"] = d["access_token"]
_token["expira"] = time.time() + d.get("expires_in", 3600)
return _token["valor"]
def _headers() -> dict:
return {"Authorization": f"Bearer {_acesso()}", "Content-Type": "application/json"}
def buscar_pedido(numero: str) -> dict:
r = httpx.get(f"{BASE}/pedidos", params={"numero": numero},
headers=_headers(), timeout=20)
r.raise_for_status()
itens = r.json().get("itens") or []
if not itens:
raise LookupError(f"pedido {numero} não encontrado no Tiny")
return itens[0]
def transferido_para_deposito(sku: str, deposito: str, dias: int) -> float:
"""
Soma o que foi transferido para o depósito de impressão no período.
A Altus já faz essa transferência hoje, porque também revende insumo — o
depósito separa consumo interno de revenda. Por isso o aproveitamento sai
de graça: nenhum apontamento novo na sala.
ATENÇÃO Wagner: confirmar o endpoint de movimentações de estoque por
depósito na API v3 e o nome exato do depósito no cadastro.
"""
r = httpx.get(
f"{BASE}/estoque/movimentacoes",
params={"codigo": sku, "deposito": deposito, "dias": dias, "tipo": "E"},
headers=_headers(), timeout=30,
)
r.raise_for_status()
return sum(float(m.get("quantidade", 0)) for m in r.json().get("itens", []))
def marcador(numero: str, marcador: str) -> None:
"""
Troca o marcador do pedido. Substitui os marcadores de etapa do DTF,
preservando marcadores de outra natureza (multiempresa, VALE, Troca...).
"""
pedido = buscar_pedido(numero)
atuais = [m["descricao"] for m in pedido.get("marcadores", [])]
# só três marcadores de etapa vivem no Tiny; o resto do fluxo é do kanban
ETAPAS = {"DTF-RECEBIDA", "DTF-PRODUCAO", "CORRECAO DTF", "DTF-PRONTO"}
mantem = [m for m in atuais if m not in ETAPAS]
novos = mantem + [marcador]
r = httpx.put(f"{BASE}/pedidos/{pedido['id']}/marcadores",
json={"marcadores": [{"descricao": m} for m in novos]},
headers=_headers(), timeout=20)
r.raise_for_status()

68
kanban/whats.py Normal file
View File

@@ -0,0 +1,68 @@
"""
Envio de WhatsApp. Três avisos ao cliente, não sete:
aprovada · em produção · finalizada (+ correção, a única que pede resposta)
Provedor definido por WHATS_PROVEDOR = meta | zapi
A API oficial da Meta exige template aprovado para mensagem iniciada por nós.
"""
from __future__ import annotations
import os
import httpx
PROVEDOR = os.environ.get("WHATS_PROVEDOR", "meta")
TOKEN = os.environ.get("WHATS_TOKEN", "")
NUMERO_ID = os.environ.get("WHATS_NUMERO_ID", "")
ZAPI_URL = os.environ.get("ZAPI_URL", "")
TEXTOS = {
"prova_cor": ("Antes de imprimir o pedido {pedido} inteiro, fizemos uma amostra "
"de cor. Confira a foto e responda APROVO para seguirmos. A cor no "
"filme pode variar em relação ao seu monitor."),
"producao": "Seu pedido {pedido} entrou em produção. Avisamos quando ficar pronto.",
"concluido": "Pedido {pedido} finalizado e pronto para retirada ou envio.",
"correcao": "Precisamos de um ajuste na arte do pedido {pedido}: {motivo}",
}
def _enviar(telefone: str, texto: str) -> None:
if PROVEDOR == "zapi":
httpx.post(ZAPI_URL, json={"phone": telefone, "message": texto}, timeout=20)
return
httpx.post(
f"https://graph.facebook.com/v20.0/{NUMERO_ID}/messages",
headers={"Authorization": f"Bearer {TOKEN}"},
json={"messaging_product": "whatsapp", "to": telefone,
"type": "text", "text": {"body": texto}},
timeout=20,
).raise_for_status()
def enviar_link(telefone: str, pedido: str, link: str) -> None:
_enviar(telefone,
f"Pedido {pedido} confirmado. Envie sua arte por aqui: {link}\n"
"O link vale por 7 dias. Gabarito 57 × 97 cm, PNG 300 DPI com fundo "
"transparente.")
def enviar_aprovacao(pedido, r) -> None:
n = len(r.partes)
_enviar(pedido.telefone if hasattr(pedido, "telefone") else "",
f"Arte do pedido {pedido.numero_tiny} aprovada. "
f"{r.metros_totais:.2f} m em {n} arquivo{'s' if n > 1 else ''}. "
"Entrou na fila de impressão.".replace(".", ",", 1))
def enviar_recusa(pedido, motivo: str) -> None:
_enviar(getattr(pedido, "telefone", ""),
f"A arte do pedido {pedido.numero_tiny} precisa de ajuste: {motivo}\n"
"O prazo só começa a contar depois que a arte for aprovada.")
def avisar(pedido: str, tipo: str, motivo: str = "") -> None:
# o telefone vem do cadastro do pedido no Tiny
import tiny
p = tiny.buscar_pedido(pedido)
telefone = (p.get("cliente") or {}).get("fone", "")
_enviar(telefone, TEXTOS[tipo].format(pedido=pedido, motivo=motivo))

11
local/Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
FROM python:3.12-slim
RUN apt-get update \
&& apt-get upgrade -y \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY local/requirements.txt local/requirements.lock /app/local/
RUN pip install --no-cache-dir --require-hashes -r local/requirements.lock
COPY local /app/local
RUN useradd --uid 10001 --create-home dtf
USER dtf
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1

13
local/Dockerfile.web Normal file
View File

@@ -0,0 +1,13 @@
FROM python:3.12-slim AS policy
WORKDIR /build
COPY dtf-site.html /build/dtf-site.html
COPY local /build/local
RUN python local/compile_web.py
FROM nginx:1.28-alpine
RUN apk upgrade --no-cache
ENV WEB_INDEX=index.html
COPY --from=policy /build/default.conf.template /etc/nginx/templates/default.conf.template
ENV S3_PUBLIC_ENDPOINT=http://localhost:9000
COPY dtf-site.html /usr/share/nginx/html/index.html
COPY local/static/ /usr/share/nginx/html/

1
local/__init__.py Normal file
View File

@@ -0,0 +1 @@

127
local/adapters.py Normal file
View File

@@ -0,0 +1,127 @@
"""Local-only composition root. No production provider implementations/imports."""
import os
from typing import Protocol
from urllib.parse import urlparse
import boto3
from botocore.config import Config
from botocore.exceptions import ClientError
def require_local():
if os.environ.get('APP_ENV') != 'local':
raise RuntimeError('This runtime only supports APP_ENV=local')
for name in ('PAYMENT', 'FREIGHT', 'TINY', 'WHATSAPP'):
if os.environ.get(f'{name}_ADAPTER') != 'fake':
raise RuntimeError(f'{name} must use the fake adapter')
if os.environ.get('STORAGE_ADAPTER') != 's3-local':
raise RuntimeError('Only local S3 storage is supported')
for name in ('S3_ENDPOINT', 'S3_PUBLIC_ENDPOINT'):
endpoint = urlparse(os.environ[name])
if endpoint.scheme != 'http' or endpoint.hostname not in ('storage', 'localhost', '127.0.0.1'):
raise RuntimeError(f'{name} must point to local MinIO')
class PaymentAdapter(Protocol):
def pay(self, quote_id: str, total_cents: int) -> dict: ...
class FakePayment:
def pay(self, quote_id: str, total_cents: int) -> dict:
return {'provider': 'fake', 'id': f'local-{quote_id}',
'status': 'paid', 'total_cents': total_cents}
class FreightAdapter(Protocol):
def quote(self, service: str, postal_code: str) -> dict: ...
class FakeFreight:
def quote(self, service: str, postal_code: str) -> dict:
if service == 'pickup':
return {'provider': 'fake', 'service': 'pickup', 'total_cents': 0, 'postal_code': ''}
if service != 'mock-standard' or len(postal_code) != 8 or not postal_code.isascii() or not postal_code.isdigit():
raise ValueError('Select pickup or a mock quote with an eight-digit CEP')
cents = int(os.environ.get('MOCK_FREIGHT_CENTS', '1500'))
if cents < 0:
raise ValueError('Invalid mock freight configuration')
return {'provider': 'fake', 'service': service, 'postal_code': postal_code,
'total_cents': cents, 'description': 'Local simulated freight'}
class EventAdapter(Protocol):
def deliver(self, event_key: str, payload: dict) -> dict: ...
class FakeTiny:
def deliver(self, event_key: str, payload: dict) -> dict:
return {'provider': 'fake-tiny', 'reference': f"LOCAL-{payload['number']}",
'event_key': event_key, 'status': 'recorded-locally'}
class FakeWhatsApp:
def deliver(self, event_key: str, payload: dict) -> dict:
return {'provider': 'fake-whatsapp', 'event_key': event_key,
'event': payload['event'], 'status': 'recorded-locally'}
class ObjectStorage(Protocol):
def begin(self, key: str) -> str: ...
def parts(self, key: str, upload_id: str) -> list: ...
def part_url(self, key: str, upload_id: str, part: int, size: int) -> str: ...
def complete(self, key: str, upload_id: str, parts: list): ...
def size(self, key: str) -> int: ...
def download(self, key: str, name: str) -> str: ...
def health(self): ...
def discard(self, key: str, upload_id: str, complete: bool): ...
class LocalS3Storage:
def __init__(self):
config = Config(signature_version='s3v4', s3={'addressing_style': 'path'},
connect_timeout=3, read_timeout=10, retries={'max_attempts': 2})
self.client = boto3.client('s3', endpoint_url=os.environ['S3_ENDPOINT'], config=config)
self.public = boto3.client('s3', endpoint_url=os.environ['S3_PUBLIC_ENDPOINT'], config=config)
self.bucket = os.environ['S3_BUCKET']
def initialize(self):
try:
self.client.head_bucket(Bucket=self.bucket)
except ClientError as exc:
if exc.response['ResponseMetadata']['HTTPStatusCode'] != 404:
raise
self.client.create_bucket(Bucket=self.bucket)
self.client.put_bucket_lifecycle_configuration(Bucket=self.bucket, LifecycleConfiguration={
'Rules': [{'ID': 'local-artwork-retention', 'Status': 'Enabled', 'Filter': {'Prefix': ''},
'Expiration': {'Days': 30}, 'AbortIncompleteMultipartUpload': {'DaysAfterInitiation': 1}}]})
def health(self):
self.client.head_bucket(Bucket=self.bucket)
def begin(self, key):
return self.client.create_multipart_upload(Bucket=self.bucket, Key=key,
ContentType='application/octet-stream')['UploadId']
def parts(self, key, upload_id):
result = []
for page in self.client.get_paginator('list_parts').paginate(
Bucket=self.bucket, Key=key, UploadId=upload_id):
result.extend(page.get('Parts', []))
return result
def part_url(self, key, upload_id, part, size):
return self.public.generate_presigned_url('upload_part', Params={
'Bucket': self.bucket, 'Key': key, 'UploadId': upload_id, 'PartNumber': part,
'ContentLength': size}, ExpiresIn=900)
def complete(self, key, upload_id, parts):
self.client.complete_multipart_upload(Bucket=self.bucket, Key=key, UploadId=upload_id,
MultipartUpload={'Parts': [{'PartNumber': p['PartNumber'], 'ETag': p['ETag']} for p in parts]})
def size(self, key):
return self.client.head_object(Bucket=self.bucket, Key=key)['ContentLength']
def download(self, key, name):
from urllib.parse import quote
return self.public.generate_presigned_url('get_object', Params={
'Bucket': self.bucket, 'Key': key,
'ResponseContentDisposition': "attachment; filename*=UTF-8''" + quote(name, safe=''),
'ResponseContentType': 'application/octet-stream'}, ExpiresIn=300)
def discard(self, key, upload_id, complete):
if not complete:
try:
self.client.abort_multipart_upload(Bucket=self.bucket, Key=key, UploadId=upload_id)
except ClientError as exc:
if exc.response['ResponseMetadata']['HTTPStatusCode'] != 404:
raise
self.client.delete_object(Bucket=self.bucket, Key=key)

341
local/app.py Normal file
View File

@@ -0,0 +1,341 @@
import hashlib
import json
import math
import os
import secrets
from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone
from uuid import UUID, uuid4
from botocore.exceptions import ClientError
from fastapi import Depends, FastAPI, HTTPException, Request, Response
from fastapi.responses import JSONResponse
from starlette.middleware.trustedhost import TrustedHostMiddleware
from psycopg.types.json import Jsonb
from . import db
from .adapters import FakeFreight, FakePayment, LocalS3Storage, require_local
from .models import Freight, Move, Pay, QuoteRequest, Review, UploadStart, OperatorLogin
from .pricing import price
from .auth import owner, session_row, new_session, operator, throttle, audit, rate_limit
from .scanning import require_clean
require_local()
storage = LocalS3Storage()
payment = FakePayment()
freight = FakeFreight()
PART_BYTES = int(os.environ.get('UPLOAD_PART_BYTES', '8388608'))
if not 5242880 <= PART_BYTES <= 67108864:
raise RuntimeError('UPLOAD_PART_BYTES must be between 5 and 64 MiB')
STATES = {'rec': 'Arte recebida', 'tra': 'Arte tratada', 'fil': 'Fila de impressão',
'imp': 'Imprimindo', 'cor': 'Correção', 'fin': 'Finalizado'}
TRANSITIONS = {'rec': ['tra','cor'], 'tra': ['fil','cor'], 'fil': ['imp','cor'],
'imp': ['fin','cor'], 'cor': ['rec','tra'], 'fin': []}
@asynccontextmanager
async def lifespan(app):
with db.connect() as c:
c.execute('SELECT 1 FROM dtf_local.operator_sessions LIMIT 1')
storage.health()
yield
app = FastAPI(title='DTF Local Portal/API', lifespan=lifespan, docs_url=None, redoc_url=None)
app.add_middleware(TrustedHostMiddleware, allowed_hosts=['localhost', '127.0.0.1'])
@app.post('/api/operator/login')
def operator_login(body: OperatorLogin, request: Request, response: Response):
throttle('operator:'+body.username, request)
valid_user = secrets.compare_digest(body.username.encode(), os.environ['OPERATOR_USER'].encode())
valid_password = secrets.compare_digest(body.password.encode(), os.environ['OPERATOR_PASSWORD'].encode())
if not (valid_user and valid_password):
audit('operator_login_failed')
raise HTTPException(401, 'Invalid local operator login')
token = secrets.token_urlsafe(32)
with db.connect() as c:
previous = hashlib.sha256(request.cookies.get('dtf_operator','').encode()).hexdigest()
c.execute('DELETE FROM dtf_local.operator_sessions WHERE token_hash=%s', (previous,))
c.execute('INSERT INTO dtf_local.operator_sessions(token_hash,username) VALUES(%s,%s)',
(hashlib.sha256(token.encode()).hexdigest(), body.username))
response.set_cookie('dtf_operator', token, httponly=True, samesite='strict', path='/api/operator', max_age=28800)
audit('operator_login_success', operator=body.username)
return {'ok': True}
@app.post('/api/operator/logout')
def operator_logout(request: Request, response: Response):
with db.connect() as c:
digest = hashlib.sha256(request.cookies.get('dtf_operator','').encode()).hexdigest()
c.execute('DELETE FROM dtf_local.operator_sessions WHERE token_hash=%s', (digest,))
response.delete_cookie('dtf_operator', path='/api/operator', httponly=True, samesite='strict')
audit('operator_logout')
return {'ok': True}
@app.middleware('http')
async def safe_headers(request, call_next):
if request.method not in ('GET','HEAD','OPTIONS'):
origin = request.headers.get('origin')
if request.headers.get('sec-fetch-site') == 'cross-site' or (origin and origin != 'http://'+request.headers.get('host','')):
audit('cross_origin_rejected')
return JSONResponse({'detail':'Cross-origin request rejected'}, status_code=403)
response = await call_next(request)
if response.status_code in (401,403,429) or response.status_code>=500:
audit('http_security_event', method=request.method, status=response.status_code)
response.headers['Cache-Control'] = 'no-store'
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['Referrer-Policy'] = 'no-referrer'
return response
@app.get('/health')
@app.get('/api/health')
def health():
try:
with db.connect() as c:
c.execute('SELECT 1')
storage.health()
except Exception:
raise HTTPException(503, 'Database or storage unavailable')
return {'status': 'ok', 'environment': 'local', 'storage': 'minio', 'integrations': 'fake'}
@app.get('/api/session')
def session(request: Request, response: Response):
try:
session_id = owner(request)
except HTTPException:
rate_limit('guest-sessions', 'local-stack', 120, 900)
with db.connect() as c:
session_id = new_session(c, response)
return {'environment': 'local', 'cart_scope': str(session_id), 'part_bytes': PART_BYTES,
'max_upload_bytes': int(os.environ.get('MAX_UPLOAD_BYTES', '5368709120'))}
@app.post('/api/freight')
def quote_freight(body: Freight):
try:
return freight.quote(body.service, body.postal_code)
except ValueError as exc:
raise HTTPException(422, str(exc))
def upload_row(c, upload_id, session_id, lock=False):
row = c.execute('SELECT * FROM dtf_local.uploads WHERE id=%s AND owner=%s' +
(' FOR UPDATE' if lock else ''), (upload_id, session_id)).fetchone()
if not row:
raise HTTPException(404, 'Upload not found')
days = 30 if row['complete'] else 1
if row['purged_at'] or row['expires_at'] <= datetime.now(timezone.utc) or row['created_at'] < datetime.now(timezone.utc) - timedelta(days=days):
raise HTTPException(410, 'Upload expired; select the file again')
return row
@app.post('/api/uploads')
def begin_upload(body: UploadStart, session_id=Depends(owner)):
if body.size > int(os.environ.get('MAX_UPLOAD_BYTES', '5368709120')):
raise HTTPException(413, 'File exceeds the local upload limit')
uid = uuid4()
key = f'originals/{uid}'
rate_limit('upload-start', str(session_id), 60, 900)
with db.connect() as c:
# Serialize reservations across API processes, including changing guest IDs.
c.execute('SELECT pg_advisory_xact_lock(804208)')
usage = c.execute('''SELECT COALESCE(sum(size),0) AS total,
COALESCE(sum(size) FILTER(WHERE owner=%s),0) AS owned,
count(*) FILTER(WHERE owner=%s AND NOT complete) AS pending
FROM dtf_local.uploads WHERE purged_at IS NULL''', (session_id,session_id)).fetchone()
if (usage['total']+body.size > int(os.environ.get('STORAGE_QUOTA_BYTES','53687091200')) or
usage['owned']+body.size > int(os.environ.get('OWNER_UPLOAD_QUOTA_BYTES','10737418240')) or
usage['pending'] >= int(os.environ.get('MAX_PENDING_UPLOADS','10'))):
audit('upload_quota_rejected')
raise HTTPException(429, 'Local storage quota or pending upload limit reached')
multipart = storage.begin(key)
c.execute('INSERT INTO dtf_local.uploads(id,owner,name,size,object_key,multipart_id) VALUES(%s,%s,%s,%s,%s,%s)',
(uid, session_id, body.name, body.size, key, multipart))
return {'id': uid, 'part_bytes': PART_BYTES}
@app.get('/api/uploads/{uid}')
def upload_status(uid: UUID, session_id=Depends(owner)):
with db.connect() as c:
row = upload_row(c, uid, session_id)
parts = [] if row['complete'] else storage.parts(row['object_key'], row['multipart_id'])
return {'id': uid, 'complete': row['complete'], 'part_bytes': PART_BYTES,
'scan_state':row['scan_state'], 'scan_reason':row['scan_reason'],
'parts': [p['PartNumber'] for p in parts]}
@app.post('/api/uploads/{uid}/parts/{part}')
def part_url(uid: UUID, part: int, session_id=Depends(owner)):
with db.connect() as c:
row = upload_row(c, uid, session_id)
if row['complete'] or not 1 <= part <= math.ceil(row['size'] / PART_BYTES):
raise HTTPException(409, 'Invalid part or completed upload')
size = min(PART_BYTES, row['size']-(part-1)*PART_BYTES)
return {'url': storage.part_url(row['object_key'], row['multipart_id'], part, size)}
@app.post('/api/uploads/{uid}/complete')
def complete_upload(uid: UUID, session_id=Depends(owner)):
with db.connect() as c:
row = upload_row(c, uid, session_id, lock=True)
if row['complete']:
return {'id': uid, 'complete': True}
try:
existing_size = storage.size(row['object_key'])
except ClientError as exc:
if exc.response['ResponseMetadata']['HTTPStatusCode'] != 404:
raise
existing_size = None
if existing_size is None:
parts = storage.parts(row['object_key'], row['multipart_id'])
expected = math.ceil(row['size'] / PART_BYTES)
if [p['PartNumber'] for p in parts] != list(range(1, expected+1)) or any(
p['Size'] != min(PART_BYTES, row['size'] - i*PART_BYTES) for i,p in enumerate(parts)):
raise HTTPException(409, 'Parts are missing or their sizes do not match')
storage.complete(row['object_key'], row['multipart_id'], parts)
existing_size = storage.size(row['object_key'])
if existing_size != row['size']:
raise HTTPException(409, 'Stored size differs from declared size')
c.execute('UPDATE dtf_local.uploads SET complete=true WHERE id=%s', (uid,))
return {'id': uid, 'complete': True}
@app.post('/api/quotes')
def create_quote(body: QuoteRequest, session_id=Depends(owner)):
draft = body.model_dump(mode='json', exclude={'request_key'})
digest = hashlib.sha256(json.dumps(draft, sort_keys=True).encode()).hexdigest()
try:
freight.quote(body.freight.service, body.freight.postal_code)
except ValueError as exc:
raise HTTPException(422, str(exc))
with db.connect() as c:
for item in body.items:
for uid in item.uploads:
row = upload_row(c, uid, session_id)
if not row['complete']:
raise HTTPException(409, 'Complete every upload before requesting a quote')
require_clean(row)
uid = uuid4()
c.execute('INSERT INTO dtf_local.quotes(id,owner,request_key,request_hash,draft) VALUES(%s,%s,%s,%s,%s) ON CONFLICT(owner,request_key) DO NOTHING',
(uid, session_id, body.request_key, digest, Jsonb(draft)))
row = c.execute('SELECT * FROM dtf_local.quotes WHERE owner=%s AND request_key=%s', (session_id, body.request_key)).fetchone()
if row['request_hash'] != digest:
raise HTTPException(409, 'Request key already used for a different cart')
return {'id': row['id'], 'status': 'pending_review'}
def quote_view(c, row):
order = c.execute('SELECT id,number,state FROM dtf_local.orders WHERE quote_id=%s', (row['id'],)).fetchone()
expired = row['approved_at'] and row['approved_at'] < datetime.now(timezone.utc)-timedelta(hours=24)
return {'id': row['id'], 'draft': row['draft'], 'approved': row['approved'],
'status': 'paid' if order else 'expired' if expired else 'approved' if row['approved'] else 'pending_review',
'order': order}
@app.get('/api/quotes/{uid}')
def get_quote(uid: UUID, session_id=Depends(owner)):
with db.connect() as c:
row = c.execute('SELECT * FROM dtf_local.quotes WHERE id=%s AND owner=%s', (uid,session_id)).fetchone()
if not row:
raise HTTPException(404, 'Quote not found')
return quote_view(c, row)
def enqueue(c, event_key, provider, payload):
c.execute('INSERT INTO dtf_local.outbox(event_key,provider,payload) VALUES(%s,%s,%s) ON CONFLICT(event_key) DO NOTHING',
(event_key, provider, Jsonb(payload)))
@app.post('/api/orders/dev-paid')
def dev_paid(body: Pay, session_id=Depends(owner)):
with db.connect() as c:
row = c.execute('SELECT * FROM dtf_local.quotes WHERE id=%s AND owner=%s FOR UPDATE', (body.quote_id,session_id)).fetchone()
if not row:
raise HTTPException(404, 'Quote not found')
existing = c.execute('SELECT * FROM dtf_local.orders WHERE quote_id=%s', (body.quote_id,)).fetchone()
if existing:
return existing
if not row['approved']:
raise HTTPException(409, 'An operator must verify length and grade first')
if row['approved_at'] < datetime.now(timezone.utc)-timedelta(hours=24):
raise HTTPException(409, 'Quote expired; request a new quote')
approved = row['approved']
for item in approved['items']:
for upload_id in item['uploads']:
require_clean(upload_row(c, UUID(upload_id), session_id))
paid = payment.pay(str(body.quote_id), approved['total_cents'])
result = c.execute('INSERT INTO dtf_local.orders(id,quote_id,owner,snapshot,payment) VALUES(%s,%s,%s,%s,%s) RETURNING *',
(uuid4(),body.quote_id,session_id,Jsonb(approved),Jsonb(paid))).fetchone()
for provider in ('tiny','whatsapp'):
enqueue(c, f"{result['id']}:paid:{provider}", provider,
{'order_id': str(result['id']), 'number': result['number'], 'event': 'payment_approved', 'order': approved})
return result
@app.get('/api/operator/board')
def board(user=Depends(operator)):
with db.connect() as c:
orders = c.execute('SELECT * FROM dtf_local.orders ORDER BY created_at').fetchall()
quotes = c.execute('SELECT q.* FROM dtf_local.quotes q LEFT JOIN dtf_local.orders o ON o.quote_id=q.id WHERE o.id IS NULL ORDER BY q.created_at').fetchall()
return {'states': STATES, 'transitions': TRANSITIONS, 'orders': orders,
'quotes': [quote_view(c, q) for q in quotes],
'events': c.execute('SELECT * FROM dtf_local.outbox ORDER BY id DESC LIMIT 100').fetchall()}
@app.post('/api/operator/quotes/{uid}/approve')
def approve(uid: UUID, body: Review, user=Depends(operator)):
with db.connect() as c:
row = c.execute('SELECT * FROM dtf_local.quotes WHERE id=%s FOR UPDATE', (uid,)).fetchone()
if not row:
raise HTTPException(404, 'Quote not found')
if row['approved']:
raise HTTPException(409, 'Approved quotes are immutable; request a new quote')
draft = row['draft']
if len(body.items) != len(draft['items']):
raise HTTPException(422, 'Review must cover every item')
items = []
for item, original in zip(body.items, draft['items']):
if item.mode != original['mode'] or list(map(str,item.uploads)) != original['uploads']:
raise HTTPException(422, 'Product mode and attached files cannot change during review')
for upload_id in item.uploads:
require_clean(upload_row(c, upload_id, row['owner']))
items.append({**price(item.mode, str(item.metres), item.grade), 'uploads': original['uploads']})
quoted_freight = freight.quote(**draft['freight'])
approved = {'customer': draft['customer'], 'items': items, 'freight': quoted_freight,
'total_cents': sum(i['total_cents'] for i in items)+quoted_freight['total_cents']}
c.execute('UPDATE dtf_local.quotes SET approved=%s, reviewed_by=%s, approved_at=now() WHERE id=%s', (Jsonb(approved),user,uid))
return approved
@app.post('/api/operator/orders/{uid}/move')
def move(uid: UUID, body: Move, user=Depends(operator)):
with db.connect() as c:
row = c.execute('SELECT * FROM dtf_local.orders WHERE id=%s FOR UPDATE', (uid,)).fetchone()
if not row:
raise HTTPException(404, 'Order not found')
if body.version != row['version']:
raise HTTPException(409, 'Order changed; refresh the board')
if body.state == row['state']:
return row
if body.state not in TRANSITIONS[row['state']]:
raise HTTPException(409, 'Move is not allowed from this state')
if body.state == 'cor' and not body.reason.strip():
raise HTTPException(422, 'Correction requires a reason')
if body.state in ('fil','imp'):
coverage = c.execute('SELECT DISTINCT f.item_index FROM dtf_local.order_files f JOIN dtf_local.uploads u ON u.id=f.upload_id WHERE f.order_id=%s AND f.kind=\'final\' AND f.active AND u.expires_at>now() AND u.purged_at IS NULL AND u.scan_state=\'clean\'', (uid,)).fetchall()
if {r['item_index'] for r in coverage} != set(range(len(row['snapshot']['items']))):
raise HTTPException(409, 'Approve a complete final-file set for every item before queueing')
if body.state == 'cor':
c.execute("UPDATE dtf_local.order_files SET active=false WHERE order_id=%s AND kind='final'", (uid,))
c.execute('INSERT INTO dtf_local.movements(order_id,from_state,to_state,operator,reason) VALUES(%s,%s,%s,%s,%s)',
(uid,row['state'],body.state,user,body.reason))
changed = c.execute('UPDATE dtf_local.orders SET state=%s, version=version+1, updated_at=now() WHERE id=%s RETURNING *', (body.state,uid)).fetchone()
events = {'imp':'production_started','cor':'correction_needed','fin':'ready'}
if body.state in events:
for provider in ('tiny','whatsapp'):
enqueue(c, f'{uid}:{changed["version"]}:{provider}', provider,
{'order_id':str(uid), 'number':row['number'], 'event':events[body.state], 'reason':body.reason,
'customer_path': f'/portal.html?order={uid}'})
return changed
@app.get('/api/operator/orders/{uid}/history')
def history(uid: UUID, user=Depends(operator)):
with db.connect() as c:
return c.execute('SELECT * FROM dtf_local.movements WHERE order_id=%s ORDER BY id', (uid,)).fetchall()
@app.get('/api/operator/uploads/{uid}/download')
def download(uid: UUID, user=Depends(operator)):
with db.connect() as c:
row = c.execute('SELECT * FROM dtf_local.uploads WHERE id=%s AND complete', (uid,)).fetchone()
if not row:
raise HTTPException(404, 'Completed upload not found')
if row['expires_at'] <= datetime.now(timezone.utc):
raise HTTPException(410, 'Artwork retention expired')
require_clean(row)
return {'name':row['name'], 'url':storage.download(row['object_key'],row['name']), 'expires_in':300}
from .customer import install_routes
install_routes(app, operator, storage, begin_upload, upload_status, part_url, complete_upload, upload_row, STATES)

93
local/auth.py Normal file
View File

@@ -0,0 +1,93 @@
"""Local customer passwords and revocable database sessions. No email service."""
import hashlib
import secrets
import logging
import json
from uuid import UUID, uuid4
from fastapi import HTTPException, Request
from .db import connect
def password_hash(password, salt=None):
salt = salt or secrets.token_hex(16)
digest = hashlib.scrypt(password.encode(), salt=bytes.fromhex(salt), n=16384, r=8, p=5).hex()
return f'scrypt-v2${salt}${digest}'
def password_matches(password, stored):
try:
version, salt, digest = stored.split('$')
if version not in ('scrypt', 'scrypt-v2'): return False
actual = hashlib.scrypt(password.encode(), salt=bytes.fromhex(salt), n=16384, r=8,
p=1 if version == 'scrypt' else 5).hex()
return secrets.compare_digest(actual, digest)
except (ValueError, TypeError):
return False
DUMMY_PASSWORD_HASH = password_hash('invalid-account-password', '00'*16)
def session_row(request):
try:
sid = UUID(request.cookies.get('dtf_session', ''))
except ValueError:
raise HTTPException(401, 'Start a local session first')
with connect() as c:
row = c.execute('SELECT * FROM dtf_local.sessions WHERE id=%s AND expires_at>now()', (sid,)).fetchone()
if not row:
raise HTTPException(401, 'Session expired; sign in or start a new session')
return row
def owner(request: Request):
return session_row(request)['owner']
def new_session(c, response, identity=None):
sid = uuid4()
identity = identity or uuid4()
c.execute('INSERT INTO dtf_local.sessions(id,owner) VALUES(%s,%s)', (sid,identity))
response.set_cookie('dtf_session', str(sid), httponly=True, samesite='strict', max_age=86400*7)
return identity
def transfer_guest(c, previous, identity):
# Successful authentication can claim only the current guest browser's records.
if c.execute('SELECT id FROM dtf_local.accounts WHERE id=%s', (previous['owner'],)).fetchone():
return
for table in ('uploads','quotes','orders'):
c.execute(f'UPDATE dtf_local.{table} SET owner=%s WHERE owner=%s', (identity,previous['owner']))
c.execute('DELETE FROM dtf_local.sessions WHERE owner=%s', (previous['owner'],))
def audit(event, **fields):
# Only explicit metadata: never cookies, passwords, signed URLs or request bodies.
logging.getLogger('dtf.security').warning(json.dumps({'event':event, **fields}, sort_keys=True))
try:
from psycopg.types.json import Jsonb
with connect() as c:
c.execute('INSERT INTO dtf_local.security_events(event,details) VALUES(%s,%s)',(event,Jsonb(fields)))
except Exception:
# Logging must still work during a DB outage without recursively auditing itself.
logging.getLogger('dtf.security').error('Security event persistence unavailable')
def rate_limit(scope, identity, limit, seconds=900):
key = hashlib.sha256((scope+'|'+identity).encode()).hexdigest()
with connect() as c:
row = c.execute("""INSERT INTO dtf_local.login_attempts(key,attempts) VALUES(%s,1)
ON CONFLICT(key) DO UPDATE SET
attempts=CASE WHEN dtf_local.login_attempts.started_at < now()-%s*interval '1 second' THEN 1 ELSE LEAST(dtf_local.login_attempts.attempts+1,1000000) END,
started_at=CASE WHEN dtf_local.login_attempts.started_at < now()-%s*interval '1 second' THEN now() ELSE dtf_local.login_attempts.started_at END
RETURNING attempts""", (key,seconds,seconds)).fetchone()
if row['attempts'] > limit:
audit('rate_limit', scope=scope)
raise HTTPException(429, 'Too many requests. Try again later.', headers={'Retry-After':str(seconds)})
def throttle(email, request):
# Independent account and source buckets prevent bypass by rotating emails.
rate_limit('auth-source', request.client.host if request.client else 'local', 60)
rate_limit('auth-account', email, 10)
def operator(request: Request):
token = request.cookies.get('dtf_operator', '')
if not token or len(token)>128:
raise HTTPException(401, 'Sign in to the local Kanban')
digest = hashlib.sha256(token.encode()).hexdigest()
with connect() as c:
row = c.execute('SELECT username FROM dtf_local.operator_sessions WHERE token_hash=%s AND expires_at>now()', (digest,)).fetchone()
if not row:
raise HTTPException(401, 'Operator session expired')
return row['username']

128
local/backup.py Normal file
View File

@@ -0,0 +1,128 @@
"""Local PostgreSQL and clean-object backup with isolated restore verification."""
import argparse
from datetime import datetime, timezone
import hashlib
import json
from pathlib import Path
import subprocess
from uuid import uuid4
ROOT = Path(__file__).resolve().parent.parent
BACKUPS = ROOT / 'backups'
def docker(script, *args, **kwargs):
return subprocess.run(['docker','compose','exec','-T','db','sh','-c',script,'sh',*args],
cwd=ROOT,check=True,**kwargs)
def checksum(path):
digest=hashlib.sha256()
with path.open('rb') as stream:
for block in iter(lambda:stream.read(1048576),b''):digest.update(block)
return digest.hexdigest()
def compose_exec(service, *command, **kwargs):
return subprocess.run(['docker','compose','exec','-T',service,*command],
cwd=ROOT,check=True,**kwargs)
def create():
BACKUPS.mkdir(mode=0o700,exist_ok=True)
prefix='dtf-'+datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')+'-'+uuid4().hex[:8]
database=BACKUPS/(prefix+'.dump')
objects=BACKUPS/(prefix+'.objects.tar.gz')
manifest_path=BACKUPS/(prefix+'.manifest.json')
sidecar=BACKUPS/(prefix+'.manifest.sha256')
created=[]
try:
with database.open('xb') as stream:
created.append(database);database.chmod(0o600)
docker('pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --format=custom',stdout=stream)
with objects.open('xb') as stream:
created.append(objects);objects.chmod(0o600)
result=compose_exec('api','python','-m','local.storage_backup','export',
stdout=stream,stderr=subprocess.PIPE)
summaries=[line.removeprefix('DTF_BACKUP_SUMMARY ') for line in
result.stderr.decode().splitlines() if line.startswith('DTF_BACKUP_SUMMARY ')]
if len(summaries)!=1:
raise RuntimeError('Object backup did not return a valid summary')
manifest={'format':'dtf-local-backup-v2',
'created_at':datetime.now(timezone.utc).isoformat(),
'database':{'file':database.name,'sha256':checksum(database)},
'objects':{'file':objects.name,'sha256':checksum(objects),
**json.loads(summaries[0])}}
manifest_path.write_text(json.dumps(manifest,indent=2,sort_keys=True)+'\n')
created.append(manifest_path);manifest_path.chmod(0o600)
sidecar.write_text(checksum(manifest_path)+'\n')
created.append(sidecar);sidecar.chmod(0o600)
except Exception:
for path in reversed(created):path.unlink(missing_ok=True)
raise
print(manifest_path.relative_to(ROOT))
return manifest_path
def verify_database(path):
if checksum(path)!=path.with_suffix('.sha256').read_text().strip():
raise ValueError('Backup checksum mismatch')
database='dtf_verify_'+uuid4().hex
docker('createdb -U "$POSTGRES_USER" "$1"',database)
try:
with path.open('rb') as stream:
docker('pg_restore -U "$POSTGRES_USER" -d "$1" --exit-on-error --no-owner --no-privileges',database,stdin=stream)
result=docker('psql -U "$POSTGRES_USER" -d "$1" -At -v ON_ERROR_STOP=1 -c "SELECT json_build_object(\'orders\',(SELECT count(*) FROM dtf_local.orders),\'uploads\',(SELECT count(*) FROM dtf_local.uploads),\'accounts\',(SELECT count(*) FROM dtf_local.accounts),\'history\',(SELECT count(*) FROM dtf_local.movements));"',database,capture_output=True,text=True)
print('PASS: backup restored to isolated database; restored counts '+result.stdout.strip())
finally:
# Only the freshly created UUID-named verification database is removed.
docker('dropdb -U "$POSTGRES_USER" "$1"',database)
print('Removed temporary verification database. Active database was untouched.')
def bundle_file(manifest_path, name):
if not isinstance(name,str) or Path(name).name!=name:
raise ValueError('Backup manifest contains an invalid filename')
path=(manifest_path.parent/name).resolve()
if path.parent!=BACKUPS.resolve():
raise ValueError('Backup manifest references a file outside backups/')
return path
def verify(path):
path=path.resolve()
if path.parent!=BACKUPS.resolve():
raise ValueError('Choose a backup generated in this repository backups/ directory')
if path.suffix=='.dump':
return verify_database(path)
if not path.name.endswith('.manifest.json'):
raise ValueError('Choose a v2 .manifest.json or legacy .dump backup')
sidecar=path.with_name(path.name.removesuffix('.json')+'.sha256')
if checksum(path)!=sidecar.read_text().strip():
raise ValueError('Backup manifest checksum mismatch')
manifest=json.loads(path.read_text())
if manifest.get('format')!='dtf-local-backup-v2':
raise ValueError('Unsupported backup format')
database=bundle_file(path,manifest.get('database',{}).get('file'))
objects=bundle_file(path,manifest.get('objects',{}).get('file'))
if checksum(database)!=manifest['database'].get('sha256') or checksum(objects)!=manifest['objects'].get('sha256'):
raise ValueError('Backup bundle checksum mismatch')
# Reuse the legacy database verifier with a temporary matching sidecar.
legacy_sidecar=database.with_suffix('.sha256')
had_legacy=legacy_sidecar.exists()
previous=legacy_sidecar.read_bytes() if had_legacy else None
legacy_sidecar.write_text(manifest['database']['sha256']+'\n');legacy_sidecar.chmod(0o600)
try:
verify_database(database)
finally:
if had_legacy:legacy_sidecar.write_bytes(previous)
else:legacy_sidecar.unlink(missing_ok=True)
with objects.open('rb') as stream:
compose_exec('api','python','-m','local.storage_backup','verify',stdin=stream)
print('PASS: combined database and clean-object backup verified. Active data was untouched.')
if __name__=='__main__':
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('command',choices=['create','verify','create-and-verify'])
parser.add_argument('path',nargs='?',type=Path,
help='v2 manifest printed by create, or a legacy .dump')
args=parser.parse_args()
if args.command=='verify':
if not args.path:parser.error('verify requires the path printed by create')
verify(args.path)
else:
path=create()
if args.command=='create-and-verify':verify(path)

25
local/bootstrap.py Normal file
View File

@@ -0,0 +1,25 @@
"""One-shot schema/role setup. Only this job receives database admin credentials."""
import os
from pathlib import Path
import psycopg
from psycopg import sql
def main():
role = os.environ['APP_DB_USER']
with psycopg.connect(os.environ['DATABASE_ADMIN_URL']) as c:
admin, database = c.execute('SELECT current_user,current_database()').fetchone()
if role == admin:
raise RuntimeError('Application and database administrator must differ')
if not c.execute('SELECT 1 FROM pg_roles WHERE rolname=%s', (role,)).fetchone():
c.execute(sql.SQL('CREATE ROLE {} LOGIN').format(sql.Identifier(role)))
c.execute(sql.SQL('ALTER ROLE {} NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS PASSWORD {}').format(
sql.Identifier(role), sql.Literal(os.environ['APP_DB_PASSWORD'])))
c.execute(Path(__file__).with_name('schema.sql').read_text())
c.execute(sql.SQL('REVOKE CREATE ON SCHEMA public FROM PUBLIC'))
c.execute(sql.SQL('GRANT CONNECT ON DATABASE {} TO {}').format(sql.Identifier(database),sql.Identifier(role)))
c.execute(sql.SQL('GRANT USAGE ON SCHEMA dtf_local TO {}').format(sql.Identifier(role)))
c.execute(sql.SQL('GRANT SELECT,INSERT,UPDATE,DELETE ON ALL TABLES IN SCHEMA dtf_local TO {}').format(sql.Identifier(role)))
c.execute(sql.SQL('GRANT USAGE,SELECT ON ALL SEQUENCES IN SCHEMA dtf_local TO {}').format(sql.Identifier(role)))
print('Local schema migrated; runtime role has DML only.')
if __name__ == '__main__': main()

140
local/browser_test.mjs Normal file
View File

@@ -0,0 +1,140 @@
// Dependency-free Chrome DevTools smoke test. Node 22+ and Chrome required.
import {spawn} from 'node:child_process';
import {mkdtemp,readFile,writeFile,mkdir} from 'node:fs/promises';
import {tmpdir} from 'node:os';
import {resolve} from 'node:path';
import assert from 'node:assert/strict';
try {
for(const line of (await readFile('.env','utf8')).split('\n')) {
if(!line.startsWith('#') && line.includes('=')) {
const i=line.indexOf('=');process.env[line.slice(0,i)]??=line.slice(i+1);
}
}
}catch(e){if(e.code!=='ENOENT')throw e;}
const profile=await mkdtemp(tmpdir()+'/dtf-browser-');
const chrome=spawn(process.env.CHROME_BIN||'/usr/bin/google-chrome-stable',[
'--headless=new','--disable-gpu','--no-first-run','--no-default-browser-check',
'--remote-debugging-port=0','--user-data-dir='+profile,'about:blank'
],{stdio:['ignore','ignore','pipe']});
const pause=ms=>new Promise(r=>setTimeout(r,ms));
let stderr='';chrome.stderr.on('data',data=>stderr+=data.toString());
const clients=[];
async function waitFor(fn,description,timeout=30000){const end=Date.now()+timeout;while(Date.now()<end){if(await fn())return;await pause(200);}throw new Error('Timeout: '+description);}
class Page {
constructor(ws){this.ws=ws;this.next=0;this.pending=new Map();this.errors=[];
ws.onmessage=event=>{const d=JSON.parse(event.data);if(d.id){const p=this.pending.get(d.id);if(p){this.pending.delete(d.id);d.error?p.reject(d.error):p.resolve(d.result);}}else if(d.method==='Runtime.exceptionThrown')this.errors.push(d.params.exceptionDetails);};
}
call(method,params={}){return new Promise((resolve,reject)=>{const id=++this.next;this.pending.set(id,{resolve,reject});this.ws.send(JSON.stringify({id,method,params}));});}
async eval(expression){const result=await this.call('Runtime.evaluate',{expression,awaitPromise:true,returnByValue:true});if(result.exceptionDetails)throw new Error(JSON.stringify(result.exceptionDetails));return result.result.value;}
async click(selector){await this.eval(`document.querySelector(${JSON.stringify(selector)}).click()`);}
async fill(selector,value,event='input'){await this.eval(`(()=>{const el=document.querySelector(${JSON.stringify(selector)});el.value=${JSON.stringify(value)};el.dispatchEvent(new Event(${JSON.stringify(event)},{bubbles:true}));})()`);}
async text(){return this.eval('document.body.innerText');}
async screenshot(path){const result=await this.call('Page.captureScreenshot',{format:'png',captureBeyondViewport:false});await writeFile(path,Buffer.from(result.data,'base64'));}
}
try{
let port;
await waitFor(async()=>{try{port=Number((await readFile(profile+'/DevToolsActivePort','utf8')).split('\n')[0]);return true;}catch{return false;}},'Chrome startup');
async function page(url){
const tab=await (await fetch('http://localhost:'+port+'/json/new?'+encodeURIComponent(url),{method:'PUT'})).json();
const ws=new WebSocket(tab.webSocketDebuggerUrl);await new Promise((r,j)=>{ws.onopen=r;ws.onerror=j;});
const p=new Page(ws);clients.push(p);await p.call('Runtime.enable');await p.call('Page.enable');
await p.call('Emulation.setDeviceMetricsOverride',{width:1440,height:1000,deviceScaleFactor:1,mobile:false});
await waitFor(()=>p.eval('document.readyState === "complete"'),'page load');return p;
}
const site=await page('http://localhost:'+(process.env.SITE_PORT||8080));
await waitFor(()=>site.eval('typeof window.dtfCheckout === "function"'),'checkout bridge');
// Prove escaping itself, independently of the CSP's second line of defense.
await site.call('Page.setBypassCSP',{enabled:true});
await site.eval(`(()=>{window.xssProbe=0;abrir('file');sel([new File(['test'],'<img src=x onerror=window.xssProbe=1>.cdr')]);})()`);
await pause(500);
assert.equal(await site.eval('window.xssProbe'),0);
assert.equal(await site.eval('document.querySelectorAll("#lista img[onerror]").length'),0);
assert.ok((await site.text()).includes('<img src=x onerror=window.xssProbe=1>.cdr'));
await site.eval(`(()=>{recusa([new File(['test'],'bad.<img src=x onerror=window.xssProbe=1>')]);})()`);
await pause(200);assert.equal(await site.eval('window.xssProbe'),0);
await site.call('Page.setBypassCSP',{enabled:false});
await site.call('Page.reload');
await waitFor(()=>site.eval('typeof window.dtfCheckout === "function"'),'reload after security probe');
await site.click('[data-modo="file"]');
await site.click('[data-cam="tabela"]');
const root=await site.call('DOM.getDocument');
const input=await site.call('DOM.querySelector',{nodeId:root.root.nodeId,selector:'#inp'});
await site.call('DOM.setFileInputFiles',{nodeId:input.nodeId,files:[resolve('local/fixtures/local-test.cdr')]});
await waitFor(()=>site.eval('!!document.querySelector("[data-comp]")'),'manual length field');
await site.fill('[data-comp]','1.01','change');
await waitFor(()=>site.eval('!!itemAtual'),'cart calculation');
assert.equal(await site.eval('itemAtual.total'),21.89);
await site.fill('#fCnpj','11222333000181');
await site.fill('#fZap','11999999999');
await site.fill('#fMail','local-browser@example.test');
await pause(800);
await site.call('Page.reload');
await waitFor(()=>site.eval('typeof pedido!=="undefined" && pedido.length===1'),'persistent cart recovery');
assert.equal(await site.eval('pedido[0].localFiles[0].name'),'local-test.cdr');
assert.equal(await site.eval('pedido[0].total'),21.89);
assert.equal(await site.eval('document.getElementById("bPagar").disabled'),false);
await site.click('#bPagar');
await waitFor(async()=> (await site.text()).includes('Arquivos enviados.'),'browser upload and quote',45000);
const qid=await site.eval('localStorage.getItem("dtf-quote")');
const kanban=await page('http://localhost:'+(process.env.KANBAN_PORT||8081));
await kanban.fill('#user',process.env.OPERATOR_USER||'operator');
await kanban.fill('#password',process.env.OPERATOR_PASSWORD||'local-operator-only');
await kanban.eval('document.getElementById("login").requestSubmit()');
await waitFor(async()=> (await kanban.text()).includes(qid.slice(0,8)),'quote on Kanban');
assert.equal(await kanban.eval('sessionStorage.getItem("dtf-operator")'),null);
assert.equal(await kanban.eval('document.getElementById("password").value'),'');
await kanban.eval(`(()=>{const card=[...document.querySelectorAll('.review')].find(x=>x.textContent.includes(${JSON.stringify(qid.slice(0,8))}));card.querySelector('[type=checkbox]').click();card.querySelector('form').requestSubmit();})()`);
await waitFor(async()=> (await kanban.text()).includes('Aprovada:'),'quote approval');
await site.eval('[...document.querySelectorAll("button")].find(x=>x.textContent==="Atualizar pedido local").click()');
await waitFor(async()=> (await site.text()).includes('Total validado no servidor:'),'approved quote displayed');
await site.eval('[...document.querySelectorAll("button")].find(x=>x.textContent==="Criar pedido pago local").click()');
await waitFor(async()=> (await site.text()).includes('Nenhuma cobrança real.'),'local payment');
await kanban.click('#refresh');
await waitFor(()=>kanban.eval(`board.orders.some(o=>o.quote_id===${JSON.stringify(qid)})`),'paid card');
const oid=await kanban.eval(`board.orders.find(o=>o.quote_id===${JSON.stringify(qid)}).id`);
// Click real transition buttons, including rerender after each move.
for(const [title,state] of [['Arte tratada','tra'],['Fila de impressão','fil'],['Imprimindo','imp'],['Finalizado','fin']]){
if(state==='fil'){
await kanban.eval(`(()=>{const card=document.querySelector('[data-order="${oid}"]');[...card.querySelectorAll('button')].find(x=>x.textContent==='Arquivos de produção').click();})()`);
await waitFor(()=>kanban.eval(`!!document.querySelector('[data-order="${oid}"] [data-final-item]')`),'final upload controls');
const doc=await kanban.call('DOM.getDocument');
const input=await kanban.call('DOM.querySelector',{nodeId:doc.root.nodeId,selector:`[data-order="${oid}"] [data-final-item]`});
await kanban.call('DOM.setFileInputFiles',{nodeId:input.nodeId,files:[resolve('local/fixtures/local-test.cdr')]});
await kanban.fill(`[data-order="${oid}"] input[placeholder="Nota da revisão"]`,'Browser test final file');
await kanban.eval(`(()=>{const form=document.querySelector('[data-order="${oid}"] form');form.querySelector('[type=checkbox]').click();form.requestSubmit();})()`);
await waitFor(()=>kanban.eval(`board.orders.find(o=>o.id==='${oid}').version===2`),'final file approval');
}
await kanban.eval(`(()=>{const card=document.querySelector('[data-order="${oid}"]');[...card.querySelectorAll('button')].find(x=>x.textContent===${JSON.stringify('→ '+title)}).click();})()`);
await waitFor(()=>kanban.eval(`board.orders.find(o=>o.id==='${oid}').state==='${state}'`),'transition '+state);
}
// Reload proves the board is persisted on the backend.
await kanban.call('Page.reload');
await waitFor(()=>kanban.eval(`typeof board!=='undefined' && !!board && board.orders.some(o=>o.id==='${oid}'&&o.state==='fin')`),'persisted board');
await mkdir('output/local',{recursive:true});
await site.eval('window.scrollTo({top:0,behavior:"instant"})');
await waitFor(()=>site.eval('window.scrollY===0'),'screenshot scroll position');
await site.screenshot('output/local/site.png');
await kanban.screenshot('output/local/kanban.png');
const portal=await page('http://localhost:'+(process.env.SITE_PORT||8080)+'/portal.html?order='+oid);
await waitFor(async()=> (await portal.text()).includes('Finalizado'),'customer order tracking');
await portal.fill('#cnpj','11222333000181');await portal.fill('#phone','11999999999');
await portal.fill('#register-email','browser-'+Date.now()+'@example.test');
await portal.fill('#register-password','local-browser-password-123');
await portal.eval('document.getElementById("register").requestSubmit()');
await waitFor(()=>portal.eval('document.getElementById("auth").hidden'),'customer registration');
assert.ok((await portal.text()).includes('Finalizado'));
await portal.screenshot('output/local/portal.png');
// A logout must clear draft file blobs and metadata, including other open Site tabs.
await portal.eval(`(async()=>{const db=await new Promise((resolve,reject)=>{const r=indexedDB.open('dtf-local-cart',1);r.onsuccess=()=>resolve(r.result);r.onerror=reject;});await new Promise((resolve,reject)=>{const tx=db.transaction('cart','readwrite');tx.objectStore('cart').put({items:[new File(['private'],'private.cdr')],expires:Date.now()+86400000},'security-fixture');tx.oncomplete=resolve;tx.onerror=reject;});db.close();})()`);
await portal.click('#logout');
await waitFor(()=>portal.eval('document.getElementById("logout").hidden'),'customer logout');
let stored;
await waitFor(async()=>{stored=await portal.eval(`(async()=>{try{const db=await new Promise((resolve,reject)=>{const r=indexedDB.open('dtf-local-cart',1);r.onupgradeneeded=()=>r.result.createObjectStore('cart');r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(r.error);});const n=await new Promise((resolve,reject)=>{const r=db.transaction('cart').objectStore('cart').count();r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(r.error);});db.close();return n;}catch{return -1;}})()`);return stored>=0;},'IndexedDB available after Clear-Site-Data');
assert.equal(stored,0);
assert.deepEqual(portal.errors,[]);
assert.deepEqual(site.errors,[]);assert.deepEqual(kanban.errors,[]);
console.log('PASS: browser Site upload → operator quote → local paid order → all main Kanban states → reload persistence. Order '+oid);
console.log('Screenshots: output/local/site.png and output/local/kanban.png');
console.log('PASS: filename XSS escaping with CSP bypassed, no stored operator password, logout clears browser file blobs.');
}catch(error){console.error(error);if(stderr)console.error(stderr.slice(-1500));process.exitCode=1;}
finally{for(const p of clients)p.ws.close();chrome.kill();}

15
local/clamd.conf Normal file
View File

@@ -0,0 +1,15 @@
Foreground yes
LogTime yes
DatabaseDirectory /var/lib/clamav
TCPSocket 3310
TCPAddr 0.0.0.0
MaxThreads 2
MaxQueue 8
StreamMaxLength 128M
MaxFileSize 128M
MaxScanSize 256M
MaxScanTime 120000
AlertExceedsMax yes
AlertEncrypted yes
ScanPDF yes
ScanArchive yes

15
local/compile_web.py Normal file
View File

@@ -0,0 +1,15 @@
"""Hash only trusted source inline scripts at image build time for strict CSP."""
import base64
import hashlib
import os
from pathlib import Path
import re
root=Path('/build')
hashes=[]
for html in [root/'dtf-site.html',*(root/'local/static').glob('*.html')]:
for attributes,body in re.findall(r'<script\b([^>]*)>(.*?)</script>',html.read_text(),re.S|re.I):
if not re.search(r'\bsrc\s*=',attributes,re.I):
hashes.append("'sha256-"+base64.b64encode(hashlib.sha256(body.encode()).digest()).decode()+"'")
template=Path(os.environ.get('NGINX_TEMPLATE', root/'local/nginx.conf.template')).read_text()
(root/'default.conf.template').write_text(template.replace('@SCRIPT_HASHES@',' '.join(hashes)))

191
local/customer.py Normal file
View File

@@ -0,0 +1,191 @@
"""Customer portal and manual artwork handoff, composed into the local API."""
from datetime import datetime, timedelta, timezone
from uuid import UUID, uuid4, uuid5, NAMESPACE_URL
from fastapi import Depends, HTTPException, Request, Response
from psycopg.errors import UniqueViolation
from psycopg.types.json import Jsonb
from . import db
from .auth import owner, session_row, new_session, password_hash, password_matches, transfer_guest, throttle, DUMMY_PASSWORD_HASH, audit
from .models import Register, Login, UploadStart, ArtworkSubmission
from .scanning import require_clean
def install_routes(app, operator, storage, begin, status, part, complete, upload_row, states):
def current(request):
try: return session_row(request)
except HTTPException: return None
@app.post('/api/account/register')
def register(body: Register, request: Request, response: Response):
email = body.customer.mail.strip().lower()
throttle(email, request)
previous = current(request)
encoded = password_hash(body.password)
identity = uuid4()
profile = body.customer.model_dump()
profile['mail'] = email
try:
with db.connect() as c:
if previous and c.execute('SELECT id FROM dtf_local.accounts WHERE id=%s', (previous['owner'],)).fetchone():
raise HTTPException(409, 'Sign out before registering another account')
c.execute('INSERT INTO dtf_local.accounts(id,email,password_hash,profile) VALUES(%s,%s,%s,%s)', (identity,email,encoded,Jsonb(profile)))
if previous: transfer_guest(c, previous, identity)
new_session(c, response, identity)
except UniqueViolation:
raise HTTPException(409, 'An account already exists; sign in')
audit('account_registered', account=str(identity))
return {'customer': profile}
@app.post('/api/account/login')
def login(body: Login, request: Request, response: Response):
email = body.email.strip().lower()
throttle(email, request)
with db.connect() as c:
account = c.execute('SELECT * FROM dtf_local.accounts WHERE email=%s', (email,)).fetchone()
# Comparable password work even when the email is absent.
stored = account['password_hash'] if account else DUMMY_PASSWORD_HASH
matches = password_matches(body.password, stored)
if not account or not matches:
audit('customer_login_failed')
raise HTTPException(401, 'Invalid email or password')
previous = current(request)
with db.connect() as c:
if not stored.startswith('scrypt-v2$'):
c.execute('UPDATE dtf_local.accounts SET password_hash=%s WHERE id=%s', (password_hash(body.password),account['id']))
if previous:
transfer_guest(c, previous, account['id'])
c.execute('DELETE FROM dtf_local.sessions WHERE id=%s', (previous['id'],))
new_session(c, response, account['id'])
audit('customer_login_success', account=str(account['id']))
return {'customer': account['profile']}
@app.post('/api/account/logout')
def logout(request: Request, response: Response):
previous = current(request)
if previous:
with db.connect() as c:
c.execute('DELETE FROM dtf_local.sessions WHERE id=%s', (previous['id'],))
response.delete_cookie('dtf_session', httponly=True, samesite='strict')
response.headers['Clear-Site-Data'] = '"storage"'
audit('customer_logout')
return {'ok': True}
@app.get('/api/account/me')
def me(identity=Depends(owner)):
with db.connect() as c:
row = c.execute('SELECT profile FROM dtf_local.accounts WHERE id=%s', (identity,)).fetchone()
return {'customer': row['profile'] if row else None}
def owned_order(c, oid, identity, lock=False):
row = c.execute('SELECT * FROM dtf_local.orders WHERE id=%s AND owner=%s'+(' FOR UPDATE' if lock else ''), (oid,identity)).fetchone()
if not row: raise HTTPException(404, 'Order not found')
return row
def file_rows(c, oid):
return c.execute('''SELECT f.id,f.upload_id,f.item_index,f.kind,f.active,f.note,f.created_at,
u.name,u.size,u.expires_at,(u.expires_at<=now()) AS expired
FROM dtf_local.order_files f JOIN dtf_local.uploads u ON u.id=f.upload_id
WHERE order_id=%s ORDER BY f.created_at''', (oid,)).fetchall()
@app.get('/api/customer/orders')
def orders(identity=Depends(owner)):
with db.connect() as c:
rows = c.execute('SELECT id,number,state,version,snapshot,created_at,updated_at FROM dtf_local.orders WHERE owner=%s ORDER BY created_at DESC', (identity,)).fetchall()
quotes = c.execute('''SELECT q.id,q.approved,q.approved_at,q.created_at FROM dtf_local.quotes q
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id WHERE q.owner=%s AND o.id IS NULL ORDER BY q.created_at DESC''', (identity,)).fetchall()
return {'orders': rows, 'quotes': quotes, 'states': states}
@app.get('/api/customer/orders/{oid}')
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()
return {'id': row['id'], 'number': row['number'], 'state': row['state'], 'version': row['version'],
'snapshot': row['snapshot'], 'history': history, 'files': file_rows(c,oid)}
def submit_files(c, order, body, identity, kind, actor):
if order['version'] != body.version:
raise HTTPException(409, 'Order changed. Refresh before submitting files.')
if kind == 'final' and order['state'] not in ('rec','tra','cor'):
raise HTTPException(409, 'Final files can only change during artwork review')
if kind == 'correction' and order['state'] != 'cor':
raise HTTPException(409, 'This order is not awaiting artwork correction')
if len({f.upload_id for f in body.files}) != len(body.files):
raise HTTPException(422, 'Each uploaded file must appear once')
count = len(order['snapshot']['items'])
if any(f.item_index >= count for f in body.files):
raise HTTPException(422, 'Invalid order item')
if kind == 'final' and {f.item_index for f in body.files} != set(range(count)):
raise HTTPException(422, 'Final-file set must cover every order item')
original_ids = [UUID(uid) for item in order['snapshot']['items'] for uid in item['uploads']]
first = c.execute('SELECT min(created_at) AS first FROM dtf_local.uploads WHERE id=ANY(%s)', (original_ids,)).fetchone()['first']
expiry = first + timedelta(days=30)
if expiry <= datetime.now(timezone.utc):
raise HTTPException(410, 'Order artwork retention has expired')
for ref in body.files:
upload = upload_row(c, ref.upload_id, identity, lock=True)
if not upload['complete']:
raise HTTPException(409, 'Complete all uploads first')
require_clean(upload)
if c.execute('SELECT id FROM dtf_local.order_files WHERE upload_id=%s', (ref.upload_id,)).fetchone():
raise HTTPException(409, 'File is already attached. Upload a new revision.')
c.execute('UPDATE dtf_local.order_files SET active=false WHERE order_id=%s AND kind=%s', (order['id'],kind))
for ref in body.files:
c.execute('INSERT INTO dtf_local.order_files(id,order_id,upload_id,item_index,kind,note,created_by) VALUES(%s,%s,%s,%s,%s,%s,%s)',
(uuid4(),order['id'],ref.upload_id,ref.item_index,kind,body.note,actor))
c.execute('UPDATE dtf_local.uploads SET expires_at=LEAST(expires_at,%s) WHERE id=%s', (expiry,ref.upload_id))
c.execute('UPDATE dtf_local.orders SET version=version+1,updated_at=now() WHERE id=%s', (order['id'],))
if kind == 'final':
# Artwork approval, not commercial quote approval, starts original cleanup.
c.execute("UPDATE dtf_local.uploads SET expires_at=LEAST(expires_at,now()+interval '7 days') WHERE id=ANY(%s)", (original_ids,))
return {'ok': True, 'version': order['version']+1, 'expires_at': expiry}
@app.post('/api/customer/orders/{oid}/corrections')
def correction(oid: UUID, body: ArtworkSubmission, identity=Depends(owner)):
with db.connect() as c:
order = owned_order(c, oid, identity, lock=True)
return submit_files(c,order,body,identity,'correction','customer')
@app.get('/api/customer/orders/{oid}/files/{fid}/download')
def customer_download(oid: UUID, fid: UUID, identity=Depends(owner)):
with db.connect() as c:
owned_order(c,oid,identity)
row = c.execute('SELECT u.* FROM dtf_local.order_files f JOIN dtf_local.uploads u ON u.id=f.upload_id WHERE f.id=%s AND f.order_id=%s AND f.active', (fid,oid)).fetchone()
if not row: raise HTTPException(404, 'Active file not found')
if row['expires_at'] <= datetime.now(timezone.utc): raise HTTPException(410, 'File expired')
require_clean(row)
return {'url':storage.download(row['object_key'],row['name']), 'name':row['name']}
def operator_identity(user):
return uuid5(NAMESPACE_URL, 'dtf-local-operator:'+user)
@app.post('/api/operator/orders/{oid}/uploads')
def begin_final(oid: UUID, body: UploadStart, user=Depends(operator)):
with db.connect() as c:
row = c.execute('SELECT state FROM dtf_local.orders WHERE id=%s', (oid,)).fetchone()
if not row: raise HTTPException(404, 'Order not found')
if row['state'] not in ('rec','tra','cor'): raise HTTPException(409, 'Order is not in artwork review')
return begin(body, session_id=operator_identity(user))
@app.get('/api/operator/uploads/{uid}')
def final_status(uid: UUID, user=Depends(operator)):
return status(uid,session_id=operator_identity(user))
@app.post('/api/operator/uploads/{uid}/parts/{number}')
def final_part(uid: UUID, number: int, user=Depends(operator)):
return part(uid,number,session_id=operator_identity(user))
@app.post('/api/operator/uploads/{uid}/complete')
def final_complete(uid: UUID, user=Depends(operator)):
return complete(uid,session_id=operator_identity(user))
@app.get('/api/operator/orders/{oid}/files')
def operator_files(oid: UUID, user=Depends(operator)):
with db.connect() as c:
return file_rows(c,oid)
@app.post('/api/operator/orders/{oid}/final-files')
def final_files(oid: UUID, body: ArtworkSubmission, user=Depends(operator)):
with db.connect() as c:
order = c.execute('SELECT * FROM dtf_local.orders WHERE id=%s FOR UPDATE', (oid,)).fetchone()
if not order: raise HTTPException(404, 'Order not found')
return submit_files(c,order,body,operator_identity(user),'final',user)

12
local/db.py Normal file
View File

@@ -0,0 +1,12 @@
import os
from pathlib import Path
import psycopg
from psycopg.rows import dict_row
def connect():
return psycopg.connect(os.environ['DATABASE_URL'], row_factory=dict_row)
def initialize():
with connect() as c:
c.execute(Path(__file__).with_name('schema.sql').read_text())

24
local/dependency_audit.py Normal file
View File

@@ -0,0 +1,24 @@
"""Audit exact installed API packages, not a host-dependent re-resolution.
Install pip-audit in an isolated environment; pass its executable as argv[1].
Only package names/versions are sent to the public vulnerability database.
"""
import json
from pathlib import Path
import subprocess
import sys
if __name__=='__main__':
packages=json.loads(subprocess.check_output(['docker','compose','exec','-T','api','pip','list','--format=json'],text=True))
requirements='\n'.join(p['name']+'=='+p['version'] for p in packages)+'\n'
destination=Path('output/security');destination.mkdir(parents=True,exist_ok=True)
(destination/'runtime-requirements.txt').write_text(requirements)
result=subprocess.run([sys.argv[1] if len(sys.argv)>1 else 'pip-audit','--no-deps','--disable-pip',
'--progress-spinner','off','-r',str(destination/'runtime-requirements.txt'),
'-f','json','-o',str(destination/'python-audit.json')])
if (destination/'python-audit.json').exists():
data=json.loads((destination/'python-audit.json').read_text())
for package in data['dependencies']:
for vulnerability in package.get('vulns',[]):
print(package['name'],package['version'],vulnerability['id'],'fix:',','.join(vulnerability['fix_versions']))
sys.exit(result.returncode)

View File

@@ -0,0 +1,4 @@
DTF LOCAL UPLOAD TEST ONLY
This is a harmless text fixture with a .cdr extension, not printable artwork.
Use the manual-length path: 1.01 metres, grade 0, pickup = BRL 21.89.
It intentionally requires no artwork parsing or automatic pre-flight.

13
local/lock_dependencies.sh Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/sh
set -eu
root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
docker run --rm \
--user "$(id -u):$(id -g)" \
--env HOME=/tmp \
--env CUSTOM_COMPILE_COMMAND=./local/lock_dependencies.sh \
--volume "$root:/src" \
--workdir /src \
python:3.12-slim \
sh -c 'python -m pip install --no-cache-dir --target /tmp/piptools pip==25.3 pip-tools==7.5.2 && PYTHONPATH=/tmp/piptools python -m piptools compile --generate-hashes --allow-unsafe --strip-extras --no-emit-index-url --output-file local/requirements.lock local/requirements.txt'

101
local/models.py Normal file
View File

@@ -0,0 +1,101 @@
import re
from decimal import Decimal
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, field_validator
class StrictModel(BaseModel):
model_config = ConfigDict(extra='forbid', allow_inf_nan=False)
class Customer(StrictModel):
cnpj: str
zap: str
mail: str = Field(max_length=254)
@field_validator('cnpj')
@classmethod
def cnpj_valid(cls, value):
digits = re.sub(r'\D', '', value)
if len(digits) != 14 or len(set(digits)) == 1 or not digits.isascii():
raise ValueError('Invalid CNPJ')
def check(base, weights):
rem = sum(int(n) * w for n,w in zip(base, weights)) % 11
return 0 if rem < 2 else 11-rem
if check(digits[:12], [5,4,3,2,9,8,7,6,5,4,3,2]) != int(digits[12]) or check(digits[:13], [6,5,4,3,2,9,8,7,6,5,4,3,2]) != int(digits[13]):
raise ValueError('Invalid CNPJ')
return digits
@field_validator('zap')
@classmethod
def phone_valid(cls, value):
digits = re.sub(r'\D', '', value)
if len(digits) not in (10,11) or not digits.isascii():
raise ValueError('Invalid phone')
return digits
@field_validator('mail')
@classmethod
def email_valid(cls, value):
if not re.fullmatch(r'[^\s@]+@[^\s@]+\.[a-zA-Z]{2,}', value.strip()):
raise ValueError('Invalid email')
return value.strip()
class Freight(StrictModel):
service: Literal['pickup','mock-standard'] = 'pickup'
postal_code: str = Field(default='', max_length=8)
class UploadStart(StrictModel):
name: str = Field(min_length=1, max_length=200, pattern=r'^[^/\\\x00-\x1f]+$')
size: int = Field(gt=0, strict=True)
@field_validator('name')
@classmethod
def artwork_extension(cls, value):
# Union of existing Site product formats; this is NOT malware/pre-flight validation.
if not re.search(r'\.(png|jpe?g|webp|tiff?|pdf|psd|psb|ai|cdr)$', value, re.I):
raise ValueError('Unsupported artwork file extension')
return value
class Item(StrictModel):
mode: Literal['file','avulsa','uvfile','uv']
metres: Decimal = Field(gt=0, le=12000)
grade: int = Field(ge=0, le=100, strict=True)
uploads: list[UUID] = Field(min_length=1, max_length=20)
class QuoteRequest(StrictModel):
request_key: UUID
customer: Customer
items: list[Item] = Field(min_length=1, max_length=30)
freight: Freight
class Review(StrictModel):
items: list[Item] = Field(min_length=1, max_length=30)
class Pay(StrictModel):
quote_id: UUID
class Move(StrictModel):
state: Literal['rec','tra','fil','imp','cor','fin']
version: int = Field(ge=0)
reason: str = Field(default='', max_length=1000)
class Register(StrictModel):
customer: Customer
password: str = Field(min_length=12, max_length=128)
class Login(StrictModel):
email: str = Field(min_length=3, max_length=254)
password: str = Field(min_length=1, max_length=128)
class OperatorLogin(StrictModel):
username: str = Field(min_length=1, max_length=100)
password: str = Field(min_length=1, max_length=128)
class FileReference(StrictModel):
upload_id: UUID
item_index: int = Field(ge=0, strict=True)
class ArtworkSubmission(StrictModel):
version: int = Field(ge=0, strict=True)
files: list[FileReference] = Field(min_length=1, max_length=60)
note: str = Field(min_length=1, max_length=1000)

34
local/nginx.conf.template Normal file
View File

@@ -0,0 +1,34 @@
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=20r/s;
server {
listen 80;
server_name localhost;
if ($host !~ ^(localhost|127\.0\.0\.1)$) { return 400; }
root /usr/share/nginx/html;
index ${WEB_INDEX};
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy no-referrer always;
add_header X-Frame-Options DENY always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' @SCRIPT_HASHES@ https://cdnjs.cloudflare.com; script-src-attr 'none'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://cdn.vnda.com.br; connect-src 'self' ${S3_PUBLIC_ENDPOINT} https://cdn.vnda.com.br https://cdnjs.cloudflare.com; worker-src 'self' blob: https://cdnjs.cloudflare.com; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" always;
location = /health { access_log off; return 200 'ok'; }
location /api/ {
limit_req zone=api_limit burst=100 nodelay;
limit_req_status 429;
proxy_pass http://api:8000;
proxy_set_header Host $http_host;
client_max_body_size 2m;
}
location / { try_files $uri $uri/ =404; }
}
server {
listen 81;
server_name localhost;
if ($host !~ ^(localhost|127\.0\.0\.1)$) { return 400; }
client_max_body_size 2m;
location / {
limit_req zone=api_limit burst=100 nodelay;
limit_req_status 429;
proxy_pass http://api:8000;
proxy_set_header Host $http_host;
}
}

27
local/pricing.py Normal file
View File

@@ -0,0 +1,27 @@
"""Exact approved Site DTF price ladders; integer BRL cents at the boundary.
Quantity/grade must be supplied by an authenticated commercial reviewer.
This module does not inspect artwork or perform pre-flight.
"""
from decimal import Decimal, ROUND_CEILING
TIERS = {
'file': [(90,1490),(75,1620),(60,1750),(40,1870),(0,1990)],
'avulsa': [(90,2490),(75,2620),(60,2750),(40,2870),(0,2990)],
'uvfile': [(90,6990),(75,7390),(60,7790),(40,8190),(0,8590)],
'uv': [(90,8390),(75,8790),(60,9190),(40,9590),(0,9990)],
}
def price(mode: str, metres: str, grade: int) -> dict:
length = Decimal(str(metres))
if mode not in TIERS or not length.is_finite() or not 0 < length <= 12000:
raise ValueError('Invalid product or length')
if isinstance(grade, bool) or not isinstance(grade, int) or not 0 <= grade <= 100:
raise ValueError('Grade must be an integer between 0 and 100')
billed = max(Decimal('1'), (length * 10).to_integral_value(rounding=ROUND_CEILING) / 10)
unit = next(cents for minimum, cents in TIERS[mode] if grade >= minimum)
return {'mode': mode, 'metres': str(length), 'grade': grade,
'billed_metres': str(billed), 'unit_cents': unit,
'total_cents': int(billed * unit),
'discount_cents': int(billed * (TIERS[mode][-1][1] - unit))}

316
local/requirements.lock Normal file
View File

@@ -0,0 +1,316 @@
#
# This file is autogenerated by pip-compile with Python 3.12
# by the following command:
#
# ./local/lock_dependencies.sh
#
annotated-doc==0.0.5 \
--hash=sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101 \
--hash=sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb
# via fastapi
annotated-types==0.8.0 \
--hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7 \
--hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0
# via pydantic
anyio==4.15.1 \
--hash=sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101 \
--hash=sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94
# via
# httpx
# starlette
boto3==1.38.23 \
--hash=sha256:70ab8364f1f6f0a7e0eaf97f62fbdacf9c1e4cc1de330faf1c146ef9ab01e7d0 \
--hash=sha256:bcf73aca469add09e165b8793be18e7578db8d2604d82505ab13dc2495bad982
# via -r local/requirements.txt
botocore==1.38.46 \
--hash=sha256:8798e5a418c27cf93195b077153644aea44cb171fcd56edc1ecebaa1e49e226e \
--hash=sha256:89ca782ffbf2e8769ca9c89234cfa5ca577f1987d07d913ee3c68c4776b1eb5b
# via
# boto3
# s3transfer
certifi==2026.7.22 \
--hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \
--hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55
# via
# httpcore
# httpx
click==8.5.0 \
--hash=sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360 \
--hash=sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34
# via uvicorn
fastapi==0.141.1 \
--hash=sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3 \
--hash=sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1
# via -r local/requirements.txt
h11==0.16.0 \
--hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \
--hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86
# via
# httpcore
# uvicorn
httpcore==1.0.9 \
--hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \
--hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8
# via httpx
httpx==0.28.1 \
--hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \
--hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad
# via -r local/requirements.txt
idna==3.19 \
--hash=sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15 \
--hash=sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4
# via
# anyio
# httpx
jmespath==1.1.0 \
--hash=sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d \
--hash=sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64
# via
# boto3
# botocore
psycopg==3.2.9 \
--hash=sha256:01a8dadccdaac2123c916208c96e06631641c0566b22005493f09663c7a8d3b6 \
--hash=sha256:2fbb46fcd17bc81f993f28c47f1ebea38d66ae97cc2dbc3cad73b37cefbff700
# via -r local/requirements.txt
psycopg-binary==3.2.9 \
--hash=sha256:001e986656f7e06c273dd4104e27f4b4e0614092e544d950c7c938d822b1a894 \
--hash=sha256:08bf9d5eabba160dd4f6ad247cf12f229cc19d2458511cab2eb9647f42fa6795 \
--hash=sha256:093a0c079dd6228a7f3c3d82b906b41964eaa062a9a8c19f45ab4984bf4e872b \
--hash=sha256:0e8aeefebe752f46e3c4b769e53f1d4ad71208fe1150975ef7662c22cca80fab \
--hash=sha256:14f64d1ac6942ff089fc7e926440f7a5ced062e2ed0949d7d2d680dc5c00e2d4 \
--hash=sha256:166acc57af5d2ff0c0c342aed02e69a0cd5ff216cae8820c1059a6f3b7cf5f78 \
--hash=sha256:18ac08475c9b971237fcc395b0a6ee4e8580bb5cf6247bc9b8461644bef5d9f4 \
--hash=sha256:1b2cf018168cad87580e67bdde38ff5e51511112f1ce6ce9a8336871f465c19a \
--hash=sha256:1ed2bab85b505d13e66a914d0f8cdfa9475c16d3491cf81394e0748b77729af2 \
--hash=sha256:1f1736d5b21f69feefeef8a75e8d3bf1f0a1e17c165a7488c3111af9d6936e91 \
--hash=sha256:2290bc146a1b6a9730350f695e8b670e1d1feb8446597bed0bbe7c3c30e0abcb \
--hash=sha256:24ddb03c1ccfe12d000d950c9aba93a7297993c4e3905d9f2c9795bb0764d523 \
--hash=sha256:2504e9fd94eabe545d20cddcc2ff0da86ee55d76329e1ab92ecfcc6c0a8156c4 \
--hash=sha256:25ab464bfba8c401f5536d5aa95f0ca1dd8257b5202eede04019b4415f491351 \
--hash=sha256:354dea21137a316b6868ee41c2ae7cce001e104760cf4eab3ec85627aed9b6cd \
--hash=sha256:387c87b51d72442708e7a853e7e7642717e704d59571da2f3b29e748be58c78a \
--hash=sha256:39a127e0cf9b55bd4734a8008adf3e01d1fd1cb36339c6a9e2b2cbb6007c50ee \
--hash=sha256:3db3ba3c470801e94836ad78bf11fd5fab22e71b0c77343a1ee95d693879937a \
--hash=sha256:413f9e46259fe26d99461af8e1a2b4795a4e27cc8ac6f7919ec19bcee8945074 \
--hash=sha256:418f52b77b715b42e8ec43ee61ca74abc6765a20db11e8576e7f6586488a266f \
--hash=sha256:4bfec4a73e8447d8fe8854886ffa78df2b1c279a7592241c2eb393d4499a17e2 \
--hash=sha256:4c1ab25e3134774f1e476d4bb9050cdec25f10802e63e92153906ae934578734 \
--hash=sha256:4df22ec17390ec5ccb38d211fb251d138d37a43344492858cea24de8efa15003 \
--hash=sha256:528239bbf55728ba0eacbd20632342867590273a9bacedac7538ebff890f1093 \
--hash=sha256:52e239cd66c4158e412318fbe028cd94b0ef21b0707f56dcb4bdc250ee58fd40 \
--hash=sha256:587a3f19954d687a14e0c8202628844db692dbf00bba0e6d006659bf1ca91cbe \
--hash=sha256:5918c0fab50df764812f3ca287f0d716c5c10bedde93d4da2cefc9d40d03f3aa \
--hash=sha256:5be8292d07a3ab828dc95b5ee6b69ca0a5b2e579a577b39671f4f5b47116dfd2 \
--hash=sha256:5d2c9fe14fe42b3575a0b4e09b081713e83b762c8dc38a3771dd3265f8f110e7 \
--hash=sha256:61d0a6ceed8f08c75a395bc28cb648a81cf8dee75ba4650093ad1a24a51c8724 \
--hash=sha256:6a76b4722a529390683c0304501f238b365a46b1e5fb6b7249dbc0ad6fea51a0 \
--hash=sha256:6afb3e62f2a3456f2180a4eef6b03177788df7ce938036ff7f09b696d418d186 \
--hash=sha256:72691a1615ebb42da8b636c5ca9f2b71f266be9e172f66209a361c175b7842c5 \
--hash=sha256:72fdbda5b4c2a6a72320857ef503a6589f56d46821592d4377c8c8604810342b \
--hash=sha256:76eddaf7fef1d0994e3d536ad48aa75034663d3a07f6f7e3e601105ae73aeff6 \
--hash=sha256:778588ca9897b6c6bab39b0d3034efff4c5438f5e3bd52fda3914175498202f9 \
--hash=sha256:791759138380df21d356ff991265fde7fe5997b0c924a502847a9f9141e68786 \
--hash=sha256:799fa1179ab8a58d1557a95df28b492874c8f4135101b55133ec9c55fc9ae9d7 \
--hash=sha256:7a838852e5afb6b4126f93eb409516a8c02a49b788f4df8b6469a40c2157fa21 \
--hash=sha256:7b617b81f08ad8def5edd110de44fd6d326f969240cc940c6f6b3ef21fe9c59f \
--hash=sha256:7e4660fad2807612bb200de7262c88773c3483e85d981324b3c647176e41fdc8 \
--hash=sha256:7fc2915949e5c1ea27a851f7a472a7da7d0a40d679f0a31e42f1022f3c562e87 \
--hash=sha256:95315b8c8ddfa2fdcb7fe3ddea8a595c1364524f512160c604e3be368be9dd07 \
--hash=sha256:96a551e4683f1c307cfc3d9a05fec62c00a7264f320c9962a67a543e3ce0d8ff \
--hash=sha256:98bbe35b5ad24a782c7bf267596638d78aa0e87abc7837bdac5b2a2ab954179e \
--hash=sha256:a1fa38a4687b14f517f049477178093c39c2a10fdcced21116f47c017516498f \
--hash=sha256:a3e0f89fe35cb03ff1646ab663dabf496477bab2a072315192dbaa6928862891 \
--hash=sha256:a4d76e28df27ce25dc19583407f5c6c6c2ba33b443329331ab29b6ef94c8736d \
--hash=sha256:ac2c04b6345e215e65ca6aef5c05cc689a960b16674eaa1f90a8f86dfaee8c04 \
--hash=sha256:ad280bbd409bf598683dda82232f5215cfc5f2b1bf0854e409b4d0c44a113b1d \
--hash=sha256:b2d7a6646d41228e9049978be1f3f838b557a1bde500b919906d54c4390f5086 \
--hash=sha256:b7e4e4dd177a8665c9ce86bc9caae2ab3aa9360b7ce7ec01827ea1baea9ff748 \
--hash=sha256:bb37ac3955d19e4996c3534abfa4f23181333974963826db9e0f00731274b695 \
--hash=sha256:bc75f63653ce4ec764c8f8c8b0ad9423e23021e1c34a84eb5f4ecac8538a4a4a \
--hash=sha256:be7d650a434921a6b1ebe3fff324dbc2364393eb29d7672e638ce3e21076974e \
--hash=sha256:cc19ed5c7afca3f6b298bfc35a6baa27adb2019670d15c32d0bb8f780f7d560d \
--hash=sha256:cf789be42aea5752ee396d58de0538d5fcb76795c85fb03ab23620293fb81b6f \
--hash=sha256:d9ac10a2ebe93a102a326415b330fff7512f01a9401406896e78a81d75d6eddc \
--hash=sha256:e0f05b9dafa5670a7503abc715af081dbbb176a8e6770de77bccaeb9024206c5 \
--hash=sha256:e4978c01ca4c208c9d6376bd585e2c0771986b76ff7ea518f6d2b51faece75e8 \
--hash=sha256:eac3a6e926421e976c1c2653624e1294f162dc67ac55f9addbe8f7b8d08ce603 \
--hash=sha256:f0d5b3af045a187aedbd7ed5fc513bd933a97aaff78e61c3745b330792c4345b \
--hash=sha256:f34e88940833d46108f949fdc1fcfb74d6b5ae076550cd67ab59ef47555dba95 \
--hash=sha256:fa5c80d8b4cbf23f338db88a7251cef8bb4b68e0f91cf8b6ddfa93884fdbb0c1 \
--hash=sha256:fb7599e436b586e265bea956751453ad32eb98be6a6e694252f4691c31b16edb
# via psycopg
pydantic==2.13.5 \
--hash=sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73 \
--hash=sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08
# via fastapi
pydantic-core==2.46.5 \
--hash=sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942 \
--hash=sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821 \
--hash=sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5 \
--hash=sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c \
--hash=sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184 \
--hash=sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2 \
--hash=sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc \
--hash=sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5 \
--hash=sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0 \
--hash=sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931 \
--hash=sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e \
--hash=sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25 \
--hash=sha256:1e5aad1220a1192c42341c8fd4a8686657e73ab2a920c970bdc4de334fe3193d \
--hash=sha256:200aa3dc9f8d54f0754f43247c0bad0999fdcfbfd2488384dd44f37279271fe6 \
--hash=sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47 \
--hash=sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038 \
--hash=sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8 \
--hash=sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b \
--hash=sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a \
--hash=sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e \
--hash=sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074 \
--hash=sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0 \
--hash=sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433 \
--hash=sha256:356c8368cbc321050b169595683a2e1d63413b1e0e2868b330af9fc14c616d3f \
--hash=sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034 \
--hash=sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21 \
--hash=sha256:3a3e26b6a8274211bddee2d0e4d0d42778f17a34510f49d2ec44b58abfc41736 \
--hash=sha256:3aa166e99c4f2985407fb8714aebede877ecb5455cf321b606adca926d30d5a0 \
--hash=sha256:3d2652072b2d774947ba5cf78a9e59644ac62ee572daf6dd2e1dfe905e15b2b7 \
--hash=sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c \
--hash=sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4 \
--hash=sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c \
--hash=sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c \
--hash=sha256:4d44cf99ddebf875f9b68cc267aa684c99b7b44fe63ee1cac4ec163807290069 \
--hash=sha256:4dedce55295becb61921e386b99d4f2706045306e7fa52249a33004c837379fb \
--hash=sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061 \
--hash=sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29 \
--hash=sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3 \
--hash=sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa \
--hash=sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e \
--hash=sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38 \
--hash=sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f \
--hash=sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b \
--hash=sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8 \
--hash=sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e \
--hash=sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c \
--hash=sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be \
--hash=sha256:657b40d6240c0a7b6a64b30f22d1e3aa631c7e846c621b0c0f6d1d75e2e15ea6 \
--hash=sha256:6d30e1a4f138b8951063e9a394752a9179b51da288ffa507b1e659222f4c1793 \
--hash=sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1 \
--hash=sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b \
--hash=sha256:771cf63ae0b1b50dd22e5f3e3549fab5f3f4ff1635d352a9e1a97fe01c7b2e64 \
--hash=sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f \
--hash=sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761 \
--hash=sha256:7b0fc826b16c55e561e5d2a0c5c77b051ba1d92808118c4e4b5390f5e0cf191d \
--hash=sha256:7c6be839a5a8312626b32029a415644a0846b420bc8b52b95b28cd92da162168 \
--hash=sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869 \
--hash=sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111 \
--hash=sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5 \
--hash=sha256:850a08d167dde16db8702c274f320c7be9d7da6f6dff2b58b18f9e815bd94f5b \
--hash=sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7 \
--hash=sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129 \
--hash=sha256:895395f8918627b04efb1ad2a4cf605387143300ba03304cd1dfa6d03f5e095e \
--hash=sha256:8b10e3e8fd7ddc2bd915848a2768e44c15b22936f1cc54c462ad1164deb02655 \
--hash=sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c \
--hash=sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec \
--hash=sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689 \
--hash=sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f \
--hash=sha256:978e7b97d4824b5be09c69fb70507cbde3b0323fc147332ca40a94d9a6a0ebbf \
--hash=sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea \
--hash=sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9 \
--hash=sha256:9b68938dd5b0c783d88ff8e2dcc69451b5eb936fe212d516b21b9d5567f6d464 \
--hash=sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519 \
--hash=sha256:9f47b8a949e60f027f0aa0a6f6c7b7e9c55cbf4380d10b344e282fa4e7ab1e1b \
--hash=sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f \
--hash=sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee \
--hash=sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a \
--hash=sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e \
--hash=sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6 \
--hash=sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2 \
--hash=sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f \
--hash=sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a \
--hash=sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f \
--hash=sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a \
--hash=sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47 \
--hash=sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda \
--hash=sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0 \
--hash=sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4 \
--hash=sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d \
--hash=sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3 \
--hash=sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2 \
--hash=sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355 \
--hash=sha256:c3471e5c4a949c26ec00a77f01df59096aa9495877de76fd60a980f8ee6be461 \
--hash=sha256:c583b927a8838dab890706a6fa7573fbb8b70e24000ef9f7238e2d6f6435a5ed \
--hash=sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f \
--hash=sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5 \
--hash=sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2 \
--hash=sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829 \
--hash=sha256:cdc8b74ecc48c0cb1e9607a05ec4e9e88db60a19ffcc9a1d5f9088ede40c8dc0 \
--hash=sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266 \
--hash=sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575 \
--hash=sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290 \
--hash=sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f \
--hash=sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62 \
--hash=sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f \
--hash=sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a \
--hash=sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7 \
--hash=sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed \
--hash=sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f \
--hash=sha256:eb7d8d0e5886a89a55d2eef490e272fa965a9d57c6b29a5b5088a7997ec2cad1 \
--hash=sha256:ecb42011e12ee19cafbc312887cbf3546959fe02fbad44f272d4be5baa997615 \
--hash=sha256:ef3fbbf161dc9351a2fe0422e51b129f9e97e42385bd0320b309c15f7d287dd8 \
--hash=sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3 \
--hash=sha256:f077d0b97ab11fa7dcc633fca53515f290bca8a8a633e966d5b6d1879d9ed01a \
--hash=sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff \
--hash=sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084 \
--hash=sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0 \
--hash=sha256:fc5d783bd4a2387e97b8a2d5ec781cfb92b3d893bf82370548e99db5915935d3 \
--hash=sha256:fc8515076c11f3cfdf4fb142dcca0fe384b1230a3b5415458ac84f3e0903ec13 \
--hash=sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9
# via pydantic
python-dateutil==2.9.0.post0 \
--hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \
--hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427
# via botocore
s3transfer==0.13.1 \
--hash=sha256:a981aa7429be23fe6dfc13e80e4020057cbab622b08c0315288758d67cabc724 \
--hash=sha256:c3fdba22ba1bd367922f27ec8032d6a1cf5f10c934fb5d68cf60fd5a23d936cf
# via boto3
six==1.17.0 \
--hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \
--hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81
# via python-dateutil
starlette==1.6.0 \
--hash=sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c \
--hash=sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b
# via
# -r local/requirements.txt
# fastapi
typing-extensions==4.16.0 \
--hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \
--hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5
# via
# anyio
# fastapi
# psycopg
# pydantic
# pydantic-core
# starlette
# typing-inspection
typing-inspection==0.4.4 \
--hash=sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47 \
--hash=sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147
# via
# fastapi
# pydantic
urllib3==2.7.0 \
--hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \
--hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897
# via botocore
uvicorn==0.34.2 \
--hash=sha256:0e929828f6186353a80b58ea719861d2629d766293b6d19baf086ba31d4f3328 \
--hash=sha256:deb49af569084536d269fe0a6d67e3754f104cf03aba7c11c40f01aadf33c403
# via -r local/requirements.txt
# The following packages are considered to be unsafe in a requirements file:
pip==26.2.1 \
--hash=sha256:71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e \
--hash=sha256:f6ad667e89a1fe78046c8f13232b247200f5258d7828f3f7883d660878e0813f
# via -r local/requirements.txt

7
local/requirements.txt Normal file
View File

@@ -0,0 +1,7 @@
fastapi==0.141.1
starlette==1.6.0
pip==26.2.1
uvicorn==0.34.2
psycopg[binary]==3.2.9
boto3==1.38.23
httpx==0.28.1

34
local/retention_test.py Normal file
View File

@@ -0,0 +1,34 @@
"""Run inside the API container. Creates only synthetic retention fixtures."""
from datetime import datetime, timedelta, timezone
from uuid import uuid4
from botocore.exceptions import ClientError
from .adapters import LocalS3Storage
from .db import connect
from .worker import cleanup
def run():
storage=LocalS3Storage()
ids=[]
for expired in (True,False):
uid=uuid4();ids.append(uid);key=f'originals/{uid}'
upload=storage.begin(key)
result=storage.client.upload_part(Bucket=storage.bucket,Key=key,UploadId=upload,PartNumber=1,Body=b'LOCAL RETENTION TEST')
storage.complete(key,upload,[{'PartNumber':1,'ETag':result['ETag']}])
expiry=datetime.now(timezone.utc)+timedelta(days=-1 if expired else 1)
with connect() as c:
c.execute('INSERT INTO dtf_local.uploads(id,owner,name,size,object_key,multipart_id,complete,expires_at) VALUES(%s,%s,%s,%s,%s,%s,true,%s)',
(uid,uuid4(),'LOCAL-RETENTION-TEST.txt',len(b'LOCAL RETENTION TEST'),key,upload,expiry))
cleanup()
with connect() as c:
assert c.execute('SELECT purged_at FROM dtf_local.uploads WHERE id=%s',(ids[0],)).fetchone()['purged_at']
assert not c.execute('SELECT purged_at FROM dtf_local.uploads WHERE id=%s',(ids[1],)).fetchone()['purged_at']
try:storage.size(f'originals/{ids[0]}');raise AssertionError('Expired bytes still exist')
except ClientError as exc:assert exc.response['ResponseMetadata']['HTTPStatusCode']==404
assert storage.size(f'originals/{ids[1]}')==len(b'LOCAL RETENTION TEST')
# Clean only the other fixture just created above, leaving metadata intact.
with connect() as c:
c.execute("UPDATE dtf_local.uploads SET expires_at=now()-interval '1 second' WHERE id=%s",(ids[1],))
cleanup()
print('PASS: expired bytes removed, unexpired bytes preserved, upload metadata retained. Synthetic fixture bytes cleaned up.')
if __name__=='__main__':run()

View File

@@ -0,0 +1,42 @@
"""Run inside API container: permissions, hashing and fail-closed scanner unit checks."""
import hashlib
from unittest.mock import patch
from botocore.exceptions import ClientError
from .db import connect
from .adapters import LocalS3Storage
from .auth import password_hash, password_matches
from .scanning import ClamAV, require_clean
from fastapi import HTTPException
def run():
with connect() as c:
role=c.execute('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user').fetchone()
assert not any(role.values()),role
assert not c.execute("SELECT has_schema_privilege(current_user,'dtf_local','CREATE') AS allowed").fetchone()['allowed']
storage=LocalS3Storage()
storage.health()
visible={bucket['Name'] for bucket in storage.client.list_buckets()['Buckets']}
assert visible=={storage.bucket},visible
for call in (lambda:storage.client.get_bucket_policy(Bucket=storage.bucket),
lambda:storage.client.get_object(Bucket=storage.bucket,Key='outside-runtime-prefix/test.cdr')):
try:call();raise AssertionError('Runtime storage credentials have excessive privilege')
except ClientError as error:assert error.response['ResponseMetadata']['HTTPStatusCode']==403
password='test-password-for-hash'
salt='00'*16
old='scrypt$'+salt+'$'+hashlib.scrypt(password.encode(),salt=bytes.fromhex(salt),n=16384,r=8,p=1).hex()
assert password_matches(password,old)
new=password_hash(password)
assert new.startswith('scrypt-v2$') and password_matches(password,new)
assert not password_matches('wrong',new)
for state in ('pending','error','rejected'):
try:require_clean({'complete':True,'scan_state':state});raise AssertionError('Unscanned file released')
except HTTPException as error:assert error.status_code==409
require_clean({'complete':True,'scan_state':'clean'})
assert ClamAV().ping() and ClamAV().version().startswith('ClamAV ')
assert ClamAV().scan(None,134217729)[0]=='rejected'
with patch('local.scanning.socket.create_connection',side_effect=OSError('offline')):
try:ClamAV().scan(None,1);raise AssertionError('Offline scanner returned success')
except OSError:pass
print('PASS: runtime DB/S3 least privilege, legacy/current password hashes, quarantine states and scanner size/offline behavior')
if __name__=='__main__':run()

81
local/scanning.py Normal file
View File

@@ -0,0 +1,81 @@
"""Local ClamAV boundary. Unknown/error/over-limit results NEVER release artwork."""
import os
import socket
import struct
import time
from fastapi import HTTPException
from .auth import audit
from .db import connect
def require_clean(row):
if not row['complete'] or row['scan_state'] != 'clean':
raise HTTPException(409, 'Artwork is quarantined until the malware scan succeeds')
class ClamAV:
def command(self, command, timeout=2):
with socket.create_connection(('scanner',3310),timeout=timeout) as sock:
sock.settimeout(timeout)
sock.sendall(b'z'+command+b'\0')
reply=b''
while b'\0' not in reply and len(reply)<4096:
block=sock.recv(4096)
if not block:break
reply+=block
return reply.rstrip(b'\0\n')
def ping(self):
return self.command(b'PING') == b'PONG'
def version(self):
return self.command(b'VERSION').decode('utf-8','replace')
def scan(self, stream, size):
if size > min(134217728, int(os.environ.get('SCAN_MAX_BYTES','134217728'))):
return 'rejected', 'File exceeds the local malware scan limit'
with socket.create_connection(('scanner',3310),timeout=10) as sock:
sock.settimeout(150)
sock.sendall(b'zINSTREAM\0')
sent=0
for chunk in stream.iter_chunks(chunk_size=65536):
sent+=len(chunk)
if sent>size: return 'rejected','Stored file size changed'
sock.sendall(struct.pack('!I',len(chunk))+chunk)
if sent!=size:return 'rejected','Stored file size changed'
sock.sendall(b'\0\0\0\0')
reply=b''
while b'\0' not in reply and len(reply)<4096:
block=sock.recv(4096)
if not block:break
reply+=block
result=reply.rstrip(b'\0\n')
if result==b'stream: OK':return 'clean',None
if result.endswith(b' FOUND'):return 'rejected','Malware or unsafe scan condition detected'
return 'error','Scanner could not verify this file'
def scan_one(storage, scanner=None):
scanner=scanner or ClamAV()
with connect() as c:
row=c.execute('''SELECT * FROM dtf_local.uploads WHERE complete AND purged_at IS NULL
AND expires_at>now() AND scan_state IN ('pending','error') AND scan_after<=now()
ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1''').fetchone()
if not row:return False
try:
stream=storage.client.get_object(Bucket=storage.bucket,Key=row['object_key'])['Body']
try:state,reason=scanner.scan(stream,row['size'])
finally:stream.close()
except Exception:
state,reason='error','Local malware scanner unavailable; file remains blocked'
c.execute("""UPDATE dtf_local.uploads SET scan_state=%s,scan_reason=%s,scanned_at=now(),
scan_after=now()+interval '1 minute',
expires_at=CASE WHEN %s IN ('rejected','error') THEN LEAST(expires_at,now()+interval '3 days') ELSE expires_at END
WHERE id=%s""",(state,reason,state,row['id']))
audit('artwork_scan', upload=str(row['id']), result=state)
return True
def scan_loop(storage):
while True:
try:
if scan_one(storage):continue
except Exception:
audit('scanner_worker_error')
time.sleep(1)

17
local/scanning_test.py Normal file
View File

@@ -0,0 +1,17 @@
"""Harmless EICAR anti-malware test and blocked download/quote regressions."""
from uuid import uuid4
from .smoke_test import Client, upload_bytes
def run():
customer=Client();customer.call('/session')
# Standard antivirus test string, not executable malware.
marker=b'X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*'
uid=upload_bytes(customer,marker,name='SECURITY-EICAR.cdr',expected_scan='rejected')
customer.call('/operator/uploads/'+uid+'/download',operator=True,expected=409)
customer.call('/quotes',{'request_key':str(uuid4()),'customer':{'cnpj':'11222333000181','zap':'11999999999','mail':'security@example.test'},
'items':[{'mode':'file','metres':'1','grade':0,'uploads':[uid]}],'freight':{'service':'pickup'}},expected=409)
clean=upload_bytes(customer,b'Harmless local artwork fixture',name='SECURITY-CLEAN.cdr')
customer.call('/operator/uploads/'+clean+'/download',operator=True)
print('PASS: real ClamAV detects EICAR; rejected artwork cannot be downloaded or quoted; clean artwork is released.')
if __name__=='__main__':run()

81
local/schema.sql Normal file
View File

@@ -0,0 +1,81 @@
-- Separate schema: never imports/migrates the historical schema.sql or SQLite.
CREATE SCHEMA IF NOT EXISTS dtf_local;
CREATE TABLE IF NOT EXISTS dtf_local.uploads (
id uuid PRIMARY KEY, owner uuid NOT NULL, name text NOT NULL,
size bigint NOT NULL, object_key text UNIQUE NOT NULL, multipart_id text NOT NULL,
complete boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS dtf_local.quotes (
id uuid PRIMARY KEY, owner uuid NOT NULL, request_key uuid NOT NULL,
request_hash text NOT NULL, draft jsonb NOT NULL, approved jsonb,
reviewed_by text, approved_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(), UNIQUE(owner, request_key)
);
CREATE TABLE IF NOT EXISTS dtf_local.orders (
id uuid PRIMARY KEY, number bigint GENERATED ALWAYS AS IDENTITY UNIQUE,
quote_id uuid NOT NULL UNIQUE REFERENCES dtf_local.quotes(id), owner uuid NOT NULL,
snapshot jsonb NOT NULL, payment jsonb NOT NULL, state text NOT NULL DEFAULT 'rec',
version integer NOT NULL DEFAULT 0, created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS dtf_local.movements (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id uuid NOT NULL REFERENCES dtf_local.orders(id),
from_state text NOT NULL, to_state text NOT NULL, operator text NOT NULL,
reason text NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS dtf_local.outbox (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, event_key text NOT NULL UNIQUE,
provider text NOT NULL, payload jsonb NOT NULL, attempts integer NOT NULL DEFAULT 0,
available_at timestamptz NOT NULL DEFAULT now(), delivered_at timestamptz,
last_error text, receipt jsonb
);
CREATE TABLE IF NOT EXISTS dtf_local.accounts (
id uuid PRIMARY KEY, email text UNIQUE NOT NULL, password_hash text NOT NULL,
profile jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS dtf_local.sessions (
id uuid PRIMARY KEY, owner uuid NOT NULL,
expires_at timestamptz NOT NULL DEFAULT now() + interval '7 days'
);
CREATE TABLE IF NOT EXISTS dtf_local.migrations (name text PRIMARY KEY);
-- Preserve pre-account guest sessions once, without resurrecting logged-out sessions.
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM dtf_local.migrations WHERE name='customer-sessions-v1') THEN
INSERT INTO dtf_local.sessions(id,owner)
SELECT owner,owner FROM (
SELECT owner FROM dtf_local.orders UNION SELECT owner FROM dtf_local.quotes
UNION SELECT owner FROM dtf_local.uploads
) legacy ON CONFLICT DO NOTHING;
INSERT INTO dtf_local.migrations VALUES('customer-sessions-v1');
END IF;
END $$;
CREATE TABLE IF NOT EXISTS dtf_local.login_attempts (
key text PRIMARY KEY, attempts integer NOT NULL DEFAULT 0,
started_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS dtf_local.operator_sessions (
token_hash text PRIMARY KEY, username text NOT NULL,
expires_at timestamptz NOT NULL DEFAULT now() + interval '8 hours'
);
CREATE TABLE IF NOT EXISTS dtf_local.security_events (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
event text NOT NULL, details jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS uploads_owner ON dtf_local.uploads(owner);
ALTER TABLE dtf_local.uploads ADD COLUMN IF NOT EXISTS expires_at timestamptz;
ALTER TABLE dtf_local.uploads ADD COLUMN IF NOT EXISTS purged_at timestamptz;
ALTER TABLE dtf_local.uploads ADD COLUMN IF NOT EXISTS scan_state text NOT NULL DEFAULT 'pending';
ALTER TABLE dtf_local.uploads ADD COLUMN IF NOT EXISTS scan_reason text;
ALTER TABLE dtf_local.uploads ADD COLUMN IF NOT EXISTS scanned_at timestamptz;
ALTER TABLE dtf_local.uploads ADD COLUMN IF NOT EXISTS scan_after timestamptz NOT NULL DEFAULT now();
UPDATE dtf_local.uploads SET expires_at=created_at + interval '30 days' WHERE expires_at IS NULL;
ALTER TABLE dtf_local.uploads ALTER COLUMN expires_at SET DEFAULT now() + interval '30 days';
CREATE TABLE IF NOT EXISTS dtf_local.order_files (
id uuid PRIMARY KEY, order_id uuid NOT NULL REFERENCES dtf_local.orders(id),
upload_id uuid NOT NULL REFERENCES dtf_local.uploads(id), item_index integer NOT NULL,
kind text NOT NULL CHECK(kind IN ('final','correction')), active boolean NOT NULL DEFAULT true,
note text NOT NULL, created_by text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE(order_id,upload_id,kind)
);

31
local/security_status.py Normal file
View File

@@ -0,0 +1,31 @@
"""Local alert triage: prints redacted counts; exits 1 when attention is required."""
import json
from datetime import datetime, timezone
from .db import connect
from .scanning import ClamAV
def scanner_status():
try:
version=ClamAV().version()
signature_text=version.split('/',2)[2]
signature_at=datetime.strptime(signature_text,'%a %b %d %H:%M:%S %Y').replace(tzinfo=timezone.utc)
age_hours=max(0,(datetime.now(timezone.utc)-signature_at).total_seconds()/3600)
return {'available':True,'version':version,'signature_age_hours':round(age_hours,1),
'signatures_stale':age_hours>24*7}
except Exception:
return {'available':False,'version':None,'signature_age_hours':None,'signatures_stale':True}
def status():
with connect() as c:
rows=c.execute("""SELECT event,count(*) AS count FROM dtf_local.security_events
WHERE created_at>now()-interval '24 hours' GROUP BY event ORDER BY event""").fetchall()
blocked=c.execute("SELECT scan_state,count(*) AS count FROM dtf_local.uploads WHERE purged_at IS NULL AND scan_state IN ('error','rejected') GROUP BY scan_state").fetchall()
counts={r['event']:r['count'] for r in rows}
scanner=scanner_status()
attention=bool(blocked or not scanner['available'] or scanner['signatures_stale'] or
counts.get('rate_limit',0) or counts.get('scanner_worker_error',0) or
counts.get('operator_login_failed',0)+counts.get('customer_login_failed',0)>=10)
return {'attention_required':attention,'scanner':scanner,'last_24h':counts,'blocked_artwork':blocked}
if __name__=='__main__':
result=status();print(json.dumps(result,indent=2));raise SystemExit(1 if result['attention_required'] else 0)

66
local/security_test.py Normal file
View File

@@ -0,0 +1,66 @@
"""Non-destructive localhost security regressions. Leaves tiny test upload reservations."""
import base64
import os
from urllib.error import HTTPError
from urllib.request import Request, urlopen
from urllib.parse import urlparse, parse_qs
from uuid import uuid4
from .smoke_test import Client, BASE
def raw(path, expected, headers=None, body=None):
request=Request(BASE+path, data=body, headers=headers or {})
try:
with urlopen(request,timeout=10) as response:
assert response.status==expected
return response.headers
except HTTPError as error:
assert error.code==expected,(path,error.code,expected)
return error.headers
def run():
headers=raw('/',200)
policy=headers['Content-Security-Policy']
assert "script-src-attr 'none'" in policy and "frame-ancestors 'none'" in policy
assert "'sha256-" in policy and "object-src 'none'" in policy
raw('/api/health',400,{'Host':'attacker.invalid'})
raw('/api/account/logout',403,{'Content-Type':'application/json','Origin':'https://attacker.invalid'},b'{}')
raw('/api/account/logout',403,{'Content-Type':'application/json','Origin':'http://localhost:9999'},b'{}')
print('PASS: CSP, frame protection, Host and cross-origin rejection')
operator=Client()
credentials={'username':os.environ.get('OPERATOR_USER','operator'),'password':os.environ.get('OPERATOR_PASSWORD','local-operator-only')}
encoded=base64.b64encode((credentials['username']+':'+credentials['password']).encode()).decode()
raw('/api/operator/board',401,{'Authorization':'Basic '+encoded})
operator.call('/operator/login',credentials)
token=next(c for c in operator.jar if c.name=='dtf_operator')
assert token.has_nonstandard_attr('HttpOnly') and token.get_nonstandard_attr('SameSite')=='strict'
assert token.path=='/api/operator'
operator.call('/operator/board')
replay=Client();replay.jar.set_cookie(token)
operator.call('/operator/logout',{})
replay.call('/operator/board',expected=401)
print('PASS: Basic rejected; HttpOnly scoped operator session; server-side logout revocation')
client=Client();client.call('/session')
client.call('/uploads',{'name':'payload.html','size':1},expected=422)
uid=client.call('/uploads',{'name':'SECURITY-PART.cdr','size':3})['id']
url=client.call('/uploads/'+uid+'/parts/1',{})['url']
assert 'content-length' in parse_qs(urlparse(url).query)['X-Amz-SignedHeaders'][0]
try:
urlopen(Request(url,data=b'toolong',method='PUT'),timeout=10)
raise AssertionError('Signed part accepted wrong length')
except HTTPError as error:assert error.code==403,error.code
with urlopen(Request(url,data=b'abc',method='PUT'),timeout=10) as response:assert response.status==200
client.call('/uploads/'+uid+'/complete',{})
count=int(os.environ.get('MAX_PENDING_UPLOADS','10'))
for i in range(count):client.call('/uploads',{'name':'SECURITY-PENDING.cdr','size':1})
client.call('/uploads',{'name':'SECURITY-OVER-LIMIT.cdr','size':1},expected=429)
print('PASS: extension allowlist, exact multipart Content-Length signature, pending upload quota')
# Unique identity avoids locking out the real local operator.
attacker=Client();username='test-'+uuid4().hex
for _ in range(10):attacker.call('/operator/login',{'username':username,'password':'invalid'},expected=401)
attacker.call('/operator/login',{'username':username,'password':'invalid'},expected=429)
print('PASS: operator login throttling (only synthetic account bucket exhausted)')
if __name__=='__main__':run()

147
local/smoke_test.py Normal file
View File

@@ -0,0 +1,147 @@
"""Local stack integration checks; creates and retains clearly named test orders.
Run: python3 -m local.smoke_test. Standard library only. Honors .env/environment.
"""
from concurrent.futures import ThreadPoolExecutor
import hashlib
import http.cookiejar
import json
import os
from pathlib import Path
import time
from urllib.error import HTTPError
from urllib.request import build_opener, HTTPCookieProcessor, Request, urlopen
from uuid import uuid4
if Path('.env').exists():
for line in Path('.env').read_text().splitlines():
if line.strip() and not line.startswith('#') and '=' in line:
key,value=line.split('=',1)
os.environ.setdefault(key,value)
BASE='http://localhost:'+os.environ.get('SITE_PORT','8080')
class Client:
def __init__(self):
self.jar=http.cookiejar.CookieJar()
self.opener=build_opener(HTTPCookieProcessor(self.jar))
self.operator_client=None
def call(self,path,body=None,operator=False,expected=200):
headers={'Content-Type':'application/json'}
if operator:
if self.operator_client is None:
self.operator_client=Client()
self.operator_client.call('/operator/login',{'username':os.environ.get('OPERATOR_USER','operator'),'password':os.environ.get('OPERATOR_PASSWORD','local-operator-only')})
return self.operator_client.call(path,body,expected=expected)
request=Request(BASE+'/api'+path,data=None if body is None else json.dumps(body).encode(),headers=headers)
try:
with self.opener.open(request,timeout=30) as response:
assert response.status==expected,(path,response.status,expected)
return json.load(response)
except HTTPError as exc:
if exc.code!=expected:raise AssertionError((path,exc.code,exc.read().decode())) from exc
return json.load(exc)
def wait_scan(client, uid, operator=False, expected='clean'):
prefix='/operator/uploads' if operator else '/uploads'
deadline=time.monotonic()+150
while time.monotonic()<deadline:
state=client.call(prefix+'/'+uid,operator=operator)
if state['scan_state'] in ('clean','rejected','error'):
assert state['scan_state']==expected,state
return state
time.sleep(0.5)
raise AssertionError('Malware scan did not finish')
def upload_bytes(client, content, name='LOCAL-TEST.cdr', order_id=None, expected_scan='clean'):
operator=order_id is not None
prefix='/operator/uploads' if operator else '/uploads'
start='/operator/orders/'+order_id+'/uploads' if operator else prefix
data=client.call(start,{'name':name,'size':len(content)},operator=operator)
uid=data['id'];size=data['part_bytes']
for offset in range(0,len(content),size):
url=client.call(prefix+'/'+uid+'/parts/'+str(offset//size+1),{},operator=operator)['url']
with urlopen(Request(url,data=content[offset:offset+size],method='PUT'),timeout=30) as response:
assert response.status==200
client.call(prefix+'/'+uid+'/complete',{},operator=operator)
wait_scan(client,uid,operator,expected_scan)
return uid
def run():
client=Client();other=Client()
config=client.call('/session');other.call('/session')
assert client.call('/health')['integrations']=='fake'
client.call('/operator/board',expected=401)
block=config['part_bytes'];content=b'DTF local multipart test\n'+b'x'*block
uid=client.call('/uploads',{'name':'LOCAL-SMOKE-ONLY.cdr','size':len(content)})['id']
other.call('/uploads/'+uid,expected=404)
other.call('/uploads/'+uid+'/parts/1',{},expected=404)
signed=client.call('/uploads/'+uid+'/parts/1',{})['url']
with urlopen(Request(signed,data=content[:block],method='PUT'),timeout=30) as response:assert response.status==200
assert client.call('/uploads/'+uid)['parts']==[1]
client.call('/uploads/'+uid+'/complete',{},expected=409)
signed=client.call('/uploads/'+uid+'/parts/2',{})['url']
with urlopen(Request(signed,data=content[block:],method='PUT'),timeout=30) as response:assert response.status==200
client.call('/uploads/'+uid+'/complete',{})
client.call('/uploads/'+uid+'/complete',{})
wait_scan(client,uid)
client.call('/uploads/'+uid+'/parts/1',{},expected=409)
download=client.call('/operator/uploads/'+uid+'/download',operator=True)
with urlopen(download['url'],timeout=30) as response:assert hashlib.sha256(response.read()).digest()==hashlib.sha256(content).digest()
unsigned=download['url'].split('?')[0]
try:urlopen(unsigned,timeout=10);raise AssertionError('Bucket must be private')
except HTTPError as exc:assert exc.code==403
print('PASS: multipart resume, incomplete rejection, immutable completion, ownership, private/downloaded bytes')
items=[{'mode':m,'metres':'2.75','grade':90,'uploads':[uid]} for m in ('file','avulsa','uvfile','uv')]
draft={'request_key':str(uuid4()),'customer':{'cnpj':'11222333000181','zap':'11999999999','mail':'local-smoke@example.test'},
'items':items,'freight':{'service':'mock-standard','postal_code':'14400000'}}
client.call('/quotes',{**draft,'total_cents':1},expected=422)
client.call('/quotes',{**draft,'customer':{**draft['customer'],'cnpj':'11111111111111'}},expected=422)
quote=client.call('/quotes',draft)
assert client.call('/quotes',draft)['id']==quote['id']
client.call('/quotes',{**draft,'freight':{'service':'pickup'}},expected=409)
qid=quote['id']
other.call('/quotes/'+qid,expected=404)
client.call('/orders/dev-paid',{'quote_id':qid},expected=409)
client.call('/operator/quotes/'+qid+'/approve',{'items':items},expected=401)
# Reviewer corrects a browser-supplied grade and length. Browser values are proposals.
corrected=[{**items[0],'metres':'1.01','grade':0},*items[1:]]
approved=client.call('/operator/quotes/'+qid+'/approve',{'items':corrected},operator=True)
assert approved['items'][0]['total_cents']==2189
assert approved['total_cents']==2189+6972+19572+23492+int(os.environ.get('MOCK_FREIGHT_CENTS','1500'))
client.call('/operator/quotes/'+qid+'/approve',{'items':items},operator=True,expected=409)
client.call('/orders/dev-paid',{'quote_id':qid,'total_cents':1},expected=422)
other.call('/orders/dev-paid',{'quote_id':qid},expected=404)
# Concurrent retries must produce precisely one payment/order/outbox pair.
with ThreadPoolExecutor(max_workers=4) as executor:
paid=list(executor.map(lambda _:client.call('/orders/dev-paid',{'quote_id':qid}),range(4)))
assert len({p['id'] for p in paid})==1
order=paid[0];oid=order['id']
assert order['payment']['status']=='paid' and order['snapshot']==approved
board=client.call('/operator/board',operator=True)
assert len([o for o in board['orders'] if o['quote_id']==qid])==1
client.call('/operator/orders/'+oid+'/move',{'state':'fin','version':0},operator=True,expected=409)
client.call('/operator/orders/'+oid+'/move',{'state':'cor','version':0},operator=True,expected=422)
version=0
for state in ('cor','rec','tra','fil','imp','fin'):
if state=='fil':
client.call('/operator/orders/'+oid+'/move',{'state':'fil','version':version},operator=True,expected=409)
files=[{'item_index':i,'upload_id':upload_bytes(client,b'LOCAL FINAL FIXTURE '+str(i).encode(),order_id=oid)} for i in range(4)]
result=client.call('/operator/orders/'+oid+'/final-files',{'version':version,'files':files,'note':'Local test manual final-file approval'},operator=True)
version=result['version']
moved=client.call('/operator/orders/'+oid+'/move',{'state':state,'version':version,'reason':'Local test correction' if state=='cor' else ''},operator=True)
version+=1;assert moved['version']==version
client.call('/operator/orders/'+oid+'/move',{'state':'rec','version':0},operator=True,expected=409)
assert len(client.call('/operator/orders/'+oid+'/history',operator=True))==6
print('PASS: all modes, authoritative review/prices/freight, tamper rejection, concurrent payment idempotency, transitions and history')
deadline=time.monotonic()+30
while time.monotonic()<deadline:
events=[e for e in client.call('/operator/board',operator=True)['events'] if e['payload']['order_id']==oid]
if len(events)==8 and all(e['delivered_at'] and e['receipt'] for e in events):break
time.sleep(1)
else:raise AssertionError('Mock outbox did not drain')
assert len({e['event_key'] for e in events})==8
print(f"PASS: 8 durable fake receipts. Local test order #{order['number']} retained in Finalizado.")
return oid
if __name__=='__main__':run()

100
local/staging_readiness.py Normal file
View File

@@ -0,0 +1,100 @@
"""Validate non-secret staging decisions without contacting external services."""
from pathlib import Path
import re
import sys
from urllib.parse import urlparse
REQUIRED = (
'APP_ENV',
'STAGING_APPROVED_BY',
'STAGING_PUBLIC_ORIGIN',
'STAGING_S3_ENDPOINT',
'STAGING_S3_BUCKET',
'STAGING_DATABASE_MODE',
'STAGING_SECRET_SOURCE',
'STAGING_BACKUP_DESTINATION',
'STAGING_FREIGHT_PROVIDER',
'STAGING_PAYMENT_PROVIDER',
'STAGING_ERP_PROVIDER',
'STAGING_WHATSAPP_PROVIDER',
'STAGING_ALERT_OWNER',
'STAGING_ROLLBACK_OWNER',
)
FORBIDDEN_NAME = re.compile(
r'(PASSWORD|TOKEN|SECRET|ACCESS_KEY|PRIVATE_KEY|CREDENTIAL)', re.IGNORECASE)
PLACEHOLDERS = {'', 'todo', 'tbd', 'replace-me', 'changeme', 'unconfirmed'}
LOCAL_HOSTS = {'localhost', '127.0.0.1', '::1', 'storage', 'db'}
def read_config(path: Path) -> dict[str, str]:
values = {}
for number, raw in enumerate(path.read_text().splitlines(), 1):
line = raw.strip()
if not line or line.startswith('#'):
continue
if '=' not in line:
raise ValueError(f'{path}:{number}: expected NAME=value')
name, value = line.split('=', 1)
name = name.strip()
if not re.fullmatch(r'[A-Z][A-Z0-9_]*', name):
raise ValueError(f'{path}:{number}: invalid setting name')
if name in values:
raise ValueError(f'{path}:{number}: duplicate setting {name}')
if name != 'STAGING_SECRET_SOURCE' and FORBIDDEN_NAME.search(name):
raise ValueError(f'{path}:{number}: secrets must not be stored in this file ({name})')
values[name] = value.strip()
return values
def validate(values: dict[str, str]) -> list[str]:
errors = []
for name in REQUIRED:
if values.get(name, '').lower() in PLACEHOLDERS:
errors.append(f'{name} is not decided')
if values.get('APP_ENV') != 'staging':
errors.append('APP_ENV must be staging')
for name in ('STAGING_PUBLIC_ORIGIN', 'STAGING_S3_ENDPOINT'):
endpoint = urlparse(values.get(name, ''))
if endpoint.scheme != 'https' or not endpoint.hostname:
errors.append(f'{name} must be an absolute HTTPS URL')
elif endpoint.hostname.lower() in LOCAL_HOSTS:
errors.append(f'{name} must not point to localhost or a Compose service')
if endpoint.path not in ('', '/') or endpoint.params or endpoint.query or endpoint.fragment:
errors.append(f'{name} must not include a path, query, or fragment')
storage_host = urlparse(values.get('STAGING_S3_ENDPOINT', '')).hostname or ''
if storage_host and not storage_host.endswith('.r2.cloudflarestorage.com'):
errors.append('STAGING_S3_ENDPOINT must be a Cloudflare R2 S3 API endpoint')
bucket = values.get('STAGING_S3_BUCKET', '')
if bucket and not re.fullmatch(r'[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]', bucket):
errors.append('STAGING_S3_BUCKET is not a valid S3 bucket name')
for name in ('STAGING_FREIGHT_PROVIDER', 'STAGING_PAYMENT_PROVIDER',
'STAGING_ERP_PROVIDER', 'STAGING_WHATSAPP_PROVIDER'):
if values.get(name, '').lower() in {'fake', 'local', 'mock'}:
errors.append(f'{name} cannot select a local fake provider')
if values.get('STAGING_DATABASE_MODE') not in {'managed', 'dedicated-container'}:
errors.append('STAGING_DATABASE_MODE must be managed or dedicated-container')
if values.get('STAGING_SECRET_SOURCE') in {'env-file', 'repository', '.env'}:
errors.append('STAGING_SECRET_SOURCE must be an external secret-injection mechanism')
return errors
def main(path: Path) -> int:
try:
errors = validate(read_config(path))
except (OSError, ValueError) as exc:
print(f'BLOCKED: {exc}')
return 2
if errors:
print('BLOCKED: staging inputs are incomplete or unsafe:')
for error in errors:
print(f'- {error}')
return 2
print('PASS: non-secret staging inputs are complete and structurally safe.')
print('No provider was contacted. This check does not authorize deployment.')
return 0
if __name__ == '__main__':
if len(sys.argv) != 2:
raise SystemExit('usage: python -m local.staging_readiness PATH')
raise SystemExit(main(Path(sys.argv[1])))

47
local/static/cart.js Normal file
View File

@@ -0,0 +1,47 @@
/* Browser-local cart recovery. Files are stored only for an unfinished cart, for 24h. */
(() => {
let database,scope,timer,restoring=true;
let signedOut=false;
window.addEventListener('dtf-private-data-cleared',()=>{signedOut=true;clearTimeout(timer);pedido=[];itemAtual=null;folhas=[];artes=[];});
const notice=document.createElement('p');notice.style.cssText='font:13px system-ui;color:#56616d;padding:8px 20px';
document.getElementById('carr').prepend(notice);
function transaction(mode,fn){return new Promise((resolve,reject)=>{const tx=database.transaction('cart',mode);const request=fn(tx.objectStore('cart'));let result;request.onsuccess=()=>result=request.result;tx.oncomplete=()=>resolve(result);tx.onerror=()=>reject(tx.error);tx.onabort=()=>reject(tx.error);});}
async function save(){
if(signedOut||restoring||!database||!scope)return;
const items=[...pedido,...(itemAtual?[itemAtual]:[])];
try{
if(!items.length){await transaction('readwrite',store=>store.delete(scope));return;}
await transaction('readwrite',store=>store.put({items,customer:{...cliente},delivery:{...entrega},expires:Date.now()+86400000},scope));
notice.textContent='Carrinho salvo neste navegador por 24 horas. Você pode remover itens e adicionar outros após recarregar.';
}catch(error){notice.textContent='Não foi possível salvar o carrinho neste navegador. Mantenha esta página aberta até enviar o pedido.';}
}
window.dtfClearCart=async()=>{clearTimeout(timer);if(database&&scope)await transaction('readwrite',store=>store.delete(scope));notice.textContent='';};
window.addEventListener('dtf-cart-changed',()=>{clearTimeout(timer);timer=setTimeout(save,400);});
window.addEventListener('pagehide',()=>{clearTimeout(timer);save();});
setInterval(async()=>{
if(!database||signedOut)return;
try{const keys=await transaction('readonly',s=>s.getAllKeys());for(const key of keys){const value=await transaction('readonly',s=>s.get(key));if(value&&value.expires<Date.now())await transaction('readwrite',s=>s.delete(key));}}catch{/* Browser may close storage during navigation. */}
},60000);
(async()=>{
try{
scope=(await window.dtfSessionReady).cart_scope;
database=await new Promise((resolve,reject)=>{const req=indexedDB.open('dtf-local-cart',1);req.onupgradeneeded=()=>req.result.createObjectStore('cart');req.onsuccess=()=>resolve(req.result);req.onerror=()=>reject(req.error);});
const records=await transaction('readonly',store=>store.getAllKeys());
for(const key of records){const value=await transaction('readonly',store=>store.get(key));if(value.expires<Date.now())await transaction('readwrite',store=>store.delete(key));}
const saved=await transaction('readonly',store=>store.get(scope));
if(saved && !pedido.length && !itemAtual){
pedido=saved.items;cliente=saved.customer;entrega=saved.delivery;
// Freight must be quoted again; restored browser values are never final.
if(entrega.tipo==='frete'){entrega.cotado=false;entrega.valor=0;}
for(const [id,key] of [['fCnpj','cnpj'],['fZap','zap'],['fMail','mail']])$(id).value=cliente[key];
$('cepIn').value=entrega.cep;
$('atual').style.display='none';pintaEntrega();
notice.textContent='Carrinho recuperado. Remova e adicione novamente um item se precisar alterar sua montagem.';
}else{
const account=await window.dtfApi('/account/me');
if(account.customer && !cliente.mail){cliente={...account.customer};for(const [id,key] of [['fCnpj','cnpj'],['fZap','zap'],['fMail','mail']])$(id).value=cliente[key];pintaEntrega();}
}
}catch(error){notice.textContent='Recuperação de carrinho indisponível; o pedido ainda pode ser feito nesta sessão.';}
finally{restoring=false;}
})();
})();

126
local/static/checkout.js Normal file
View File

@@ -0,0 +1,126 @@
/* Checkout bridge only: approved commercial functions in dtf-site.html stay intact. */
(() => {
const box = document.createElement('section');
box.style.cssText = 'position:relative;z-index:20;background:#152026;color:#e6f4f7;padding:12px 24px;font:14px system-ui;border-bottom:2px solid #00b8da';
box.innerHTML = '<b>Ambiente local · pagamento simulado</b> <span id="local-status" role="status"></span><div id="local-actions"></div>';
document.body.prepend(box);
const status = document.getElementById('local-status');
const actions = document.getElementById('local-actions');
let busy = false;
let draftId = localStorage.getItem('dtf-quote');
let requestKey = localStorage.getItem('dtf-request-key');
let requestBody = localStorage.getItem('dtf-request-body');
const api = async (path, body) => {
const response = await fetch('/api'+path, {
credentials: 'same-origin', headers: {'Content-Type':'application/json'},
...(body === undefined ? {} : {method:'POST', body:JSON.stringify(body)})
});
const data = await response.json();
if (!response.ok) { const error=new Error(typeof data.detail === 'string' ? data.detail : 'Confira os dados do pedido ('+response.status+').');error.status=response.status;throw error; }
return data;
};
const ready = api('/session');
window.dtfSessionReady=ready;
window.dtfApi=api;
ready.catch(error => { status.textContent = error.message; });
function message(text) { status.textContent = ' · '+text; }
function button(label, handler) {
const el = document.createElement('button');
el.textContent = label;
el.style.cssText = 'margin:8px 8px 0 0;padding:8px 14px;cursor:pointer';
el.onclick = handler;
actions.append(el);
return el;
}
async function upload(file) {
const session=await ready;
return window.dtfUpload(file,{api,progress:message,scope:session.cart_scope});
}
window.dtfFreight = async () => {
const cep = entrega.cep;
try {
const result = await api('/freight',{service:'mock-standard',postal_code:cep});
if (entrega.cep !== cep || entrega.tipo !== 'frete') return;
entrega.valor = result.total_cents/100;
entrega.cotado = true;
$('cepMsg').textContent = 'Frete simulado local: '+rs(entrega.valor)+'. Nenhuma transportadora foi consultada.';
pintaEntrega();
} catch(error) { $('cepMsg').textContent = error.message; }
};
window.dtfCheckout = async () => {
if (busy) return;
if (draftId) { await refresh(); box.scrollIntoView(); return; }
if (!clienteOk() || !entrega.cotado) return;
const cart = [...pedido,...(itemAtual?[itemAtual]:[])];
if (!cart.length) return message('Adicione um item ao pedido.');
busy = true;
$('bPagar').disabled = true;
try {
await ready;
if((await api('/session')).cart_scope !== (await ready).cart_scope) throw new Error('Sua conta ou sessão mudou. Recarregue a página antes de enviar o carrinho.');
const items=[];
for (const item of cart) {
if (!item.localFiles?.length) throw new Error('Selecione novamente os arquivos deste item.');
const uploads=[];
for (const file of item.localFiles) uploads.push(await upload(file));
items.push({mode:item.modo, metres:String(item.metros), grade:item.nota, uploads});
}
const content = {customer:{...cliente},items,freight:{service:entrega.tipo==='retira'?'pickup':'mock-standard',postal_code:entrega.tipo==='retira'?'':entrega.cep}};
const serialized = JSON.stringify(content);
if (!requestKey || serialized !== requestBody) {
requestKey = crypto.randomUUID(); requestBody = serialized;
localStorage.setItem('dtf-request-key',requestKey);
localStorage.setItem('dtf-request-body',requestBody);
}
const quote = await api('/quotes',{request_key:requestKey,...content});
draftId=quote.id; localStorage.setItem('dtf-quote',draftId);
await refresh();
box.scrollIntoView({behavior:'smooth'});
} catch(error) { message(error.message); }
finally { busy=false; pintaEntrega(); }
};
async function refresh() {
if (!draftId) return;
try {
await ready;
const quote=await api('/quotes/'+draftId);
actions.replaceChildren();
if (quote.status==='pending_review') {
message('Arquivos enviados. No Kanban, confira metragem e nota e aprove a cotação '+draftId.slice(0,8)+'.');
} else if (quote.status==='approved') {
message('Total validado no servidor: '+rs(quote.approved.total_cents/100)+' · inclui frete. Cotação válida por 24 horas.');
button('Criar pedido pago local',async event=>{
event.target.disabled=true;
try {
const order=await api('/orders/dev-paid',{quote_id:draftId});
pedido=[]; itemAtual=null; limpaPaineis();
await window.dtfClearCart?.();
message('Pedido local #'+order.number+' pago e disponível no Kanban.');
await refresh();
} catch(error) { message(error.message); event.target.disabled=false; }
});
} else if (quote.status==='paid') {
message('Pedido local #'+quote.order.number+' pago · etapa: '+quote.order.state+'. Nenhuma cobrança real.');
button('Novo pedido local',()=>{localStorage.removeItem('dtf-quote');localStorage.removeItem('dtf-request-key');localStorage.removeItem('dtf-request-body');location.reload();});
} else {
message('Cotação expirada. Envie o carrinho para uma nova revisão.');
button('Nova cotação',()=>{draftId=null;requestKey=null;localStorage.removeItem('dtf-quote');localStorage.removeItem('dtf-request-key');actions.replaceChildren();});
}
} catch(error) {
message(error.message);
actions.replaceChildren();
button('Limpar referência local e tentar de novo',()=>{draftId=null;requestKey=null;localStorage.removeItem('dtf-quote');localStorage.removeItem('dtf-request-key');actions.replaceChildren();});
}
}
// Explicit refresh avoids replacing focused payment controls during interaction.
const refreshButton = document.createElement('button');
refreshButton.textContent='Atualizar pedido local';
refreshButton.style.cssText='margin-left:12px;padding:6px;cursor:pointer';
refreshButton.onclick=refresh;
box.append(refreshButton);
const portalLink=document.createElement('a');portalLink.href='/portal.html';portalLink.textContent='Minha conta e pedidos';
portalLink.style.cssText='color:#e6f4f7;margin-left:16px;text-decoration:underline';box.append(portalLink);
const quoteFromPortal=new URLSearchParams(location.search).get('quote');
if(quoteFromPortal && /^[0-9a-f-]{36}$/.test(quoteFromPortal)){draftId=quoteFromPortal;localStorage.setItem('dtf-quote',draftId);}
refresh();
})();

21
local/static/kanban.html Normal file
View File

@@ -0,0 +1,21 @@
<!doctype html>
<html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>DTF · Kanban local</title>
<style>
:root{--bg:#0B0D10;--card:#15181D;--card2:#1C2026;--linha:#282C34;--tx:#EDEBE6;--fraco:#a1a6af;--ciano:#00B8DA}
*{box-sizing:border-box}body{background:var(--bg);color:var(--tx);font:14px/1.5 system-ui,sans-serif;padding:20px;margin:0}
h1{font-size:24px;margin:0}h2{font-size:18px}header{display:flex;align-items:center;gap:18px;flex-wrap:wrap;margin-bottom:20px}
.aviso{background:#152026;border-left:3px solid var(--ciano);padding:12px;color:#b9cbd2;margin:16px 0}
button,input,select{font:inherit;border:1px solid #56616d;border-radius:5px;padding:8px;background:var(--card2);color:var(--tx)}
button{cursor:pointer}button:hover{border-color:var(--ciano)}button:disabled{opacity:.5}input[type=number]{width:100px}
label{display:inline-flex;gap:8px;align-items:center;margin:5px}#kan{display:grid;grid-template-columns:repeat(6,minmax(220px,1fr));gap:10px;overflow-x:auto;padding-bottom:16px}
.col{background:var(--card);border:1px solid var(--linha);border-radius:9px;min-height:250px;padding:10px}.col h2{font-size:14px;border-bottom:2px solid var(--cc);padding-bottom:10px}.col.alvo{border-color:var(--ciano)}
.cd,.review{background:var(--card2);border:1px solid var(--linha);border-left:3px solid var(--cc,var(--ciano));border-radius:6px;padding:12px;margin-bottom:10px}.cd{cursor:grab}.cd p{overflow-wrap:anywhere}.meta{color:var(--fraco);font-size:12px}.actions{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}.error{color:#ffad83}#status{min-height:24px}details{margin:16px 0}pre{white-space:pre-wrap;overflow-wrap:anywhere}a{color:var(--ciano)}
</style></head><body>
<header><h1>Kanban DTF</h1><span class="meta">Desenvolvimento local</span><button id="refresh">Atualizar</button><button id="logout">Sair</button></header>
<div class="aviso">Pagamentos, Tiny/Olist, WhatsApp e frete são simulados. Confira a cotação manualmente. Em Arquivos de produção, envie e aprove os arquivos finais de todos os itens antes de colocar o pedido na fila. Não há validação automática de arte.</div>
<form id="login"><label>Usuário <input id="user" autocomplete="username" required></label><label>Senha local <input id="password" type="password" autocomplete="current-password" required></label><button>Entrar</button></form>
<p id="status" role="status"></p>
<section id="reviews"></section><div id="kan"></div>
<details><summary>Eventos locais de integração</summary><pre id="events"></pre></details>
<script src="/upload.js"></script><script src="/kanban.js"></script></body></html>

107
local/static/kanban.js Normal file
View File

@@ -0,0 +1,107 @@
/* Reuses the prototype palette, column names and drag/drop interaction; no machine controls. */
const $ = id=>document.getElementById(id);
// Remove credentials saved by older local builds. Only HttpOnly sessions now.
sessionStorage.removeItem('dtf-operator');
let board;
const money=cents=>(cents/100).toLocaleString('pt-BR',{style:'currency',currency:'BRL'});
function node(tag,text,className){const e=document.createElement(tag);if(text!==undefined)e.textContent=text;if(className)e.className=className;return e;}
function action(text,fn){const b=node('button',text);b.onclick=async()=>{b.disabled=true;try{await fn();}catch(e){$('status').textContent=e.message;}finally{b.disabled=false;}};return b;}
async function api(path,body){
const r=await fetch('/api/operator'+path,{headers:{'Content-Type':'application/json'},...(body===undefined?{}:{method:'POST',body:JSON.stringify(body)})});
const d=await r.json();if(!r.ok){const error=new Error(typeof d.detail==='string'?d.detail:'Confira os campos ('+r.status+').');error.status=r.status;throw error;}return d;
}
function files(container,items){
for(const uid of [...new Set(items.flatMap(i=>i.uploads))])container.append(action('Baixar original '+uid.slice(0,6),async()=>{
const result=await api('/uploads/'+uid+'/download');
const link=node('a');link.href=result.url;link.referrerPolicy='no-referrer';link.download=result.name;link.click();
}));
}
async function load(){
try{
board=await api('/board');$('login').hidden=true;
$('status').textContent='Atualizado às '+new Date().toLocaleTimeString();
render();
}catch(e){$('status').textContent=e.message;$('login').hidden=false;}
}
function render(){
$('reviews').replaceChildren();
if(board.quotes.length)$('reviews').append(node('h2','Cotações · conferência comercial'));
for(const quote of board.quotes){
const card=node('article',undefined,'review');
card.append(node('b',quote.id.slice(0,8)+' · '+quote.draft.customer.mail));
if(quote.status!=='pending_review'){
card.append(node('p',quote.status==='expired'?'Cotação expirada. O cliente precisa solicitar outra.':'Aprovada: '+money(quote.approved.total_cents)+'. Volte ao Site, atualize o pedido e confirme o pagamento local.'));
}else{
const form=node('form');
const edits=quote.draft.items.map((item,index)=>{
const row=node('div');row.append(node('strong',(index+1)+'. '+item.mode+' '));
const metres=node('input');metres.type='number';metres.min='0.001';metres.max='12000';metres.step='any';metres.value=item.metres;metres.required=true;
const grade=node('input');grade.type='number';grade.min='0';grade.max='100';grade.step='1';grade.value=item.grade;grade.required=true;
const ml=node('label','Metros conferidos');ml.append(metres);const gl=node('label','Nota conferida');gl.append(grade);row.append(ml,gl);form.append(row);
return ()=>({...item,metres:metres.value,grade:Number(grade.value)});
});
const check=node('input');check.type='checkbox';check.required=true;const label=node('label','Conferi os arquivos, a metragem total (incluindo repetições/montagem) e a nota.');label.prepend(check);form.append(label);
const submit=node('button','Aprovar cotação');submit.type='submit';form.append(submit);
form.onsubmit=async e=>{e.preventDefault();submit.disabled=true;try{await api('/quotes/'+quote.id+'/approve',{items:edits.map(fn=>fn())});await load();}catch(error){$('status').textContent=error.message;submit.disabled=false;}};
card.append(form);
}
const downloads=node('div',undefined,'actions');files(downloads,quote.draft.items);card.append(downloads);$('reviews').append(card);
}
$('kan').replaceChildren();
const colors=['#00b8da','#e0357c','#efb700','#edebe6','#e0642a','#48b072'];
Object.entries(board.states).forEach(([state,title],index)=>{
const column=node('section',undefined,'col');column.dataset.state=state;column.style.setProperty('--cc',colors[index]);
const orders=board.orders.filter(o=>o.state===state);column.append(node('h2',title+' · '+orders.length));
for(const order of orders){
const card=node('article',undefined,'cd');card.draggable=true;card.dataset.order=order.id;
card.append(node('b','#'+order.number+' · Pago local'),node('p',order.snapshot.customer.mail,'meta'),node('p',money(order.snapshot.total_cents)));
for(const item of order.snapshot.items)card.append(node('p',item.mode+' · '+item.billed_metres+' m · nota '+item.grade,'meta'));
const actions=node('div',undefined,'actions');files(actions,order.snapshot.items);
const artwork=node('div');
actions.append(action('Arquivos de produção',()=>artworkPanel(order,artwork)));
actions.append(action('Histórico',async()=>{const rows=await api('/orders/'+order.id+'/history');alert(rows.map(r=>board.states[r.from_state]+' → '+board.states[r.to_state]+' · '+r.operator+(r.reason?' · '+r.reason:'')).join('\n')||'Pedido recebido.');}));
for(const next of board.transitions[state])actions.append(action('→ '+board.states[next],()=>move(order,next)));
card.append(actions,artwork);card.ondragstart=e=>e.dataTransfer.setData('text/plain',order.id);column.append(card);
}
column.ondragover=e=>{e.preventDefault();column.classList.add('alvo');};column.ondragleave=()=>column.classList.remove('alvo');
column.ondrop=async e=>{e.preventDefault();column.classList.remove('alvo');const order=board.orders.find(o=>o.id===e.dataTransfer.getData('text/plain'));if(order)try{await move(order,state);}catch(error){$('status').textContent=error.message;}};
$('kan').append(column);
});
$('events').textContent=board.events.map(e=>e.provider+' · '+e.payload.event+' · pedido #'+e.payload.number+' · '+(e.delivered_at?'registrado localmente':'pendente')+' · tentativas '+e.attempts).join('\n')||'Nenhum evento.';
}
async function move(order,state){
let reason='';if(state==='cor'){reason=prompt('Motivo da correção:');if(!reason)return;}
await api('/orders/'+order.id+'/move',{state,version:order.version,reason});await load();
}
$('login').onsubmit=async e=>{e.preventDefault();try{await api('/login',{username:$('user').value,password:$('password').value});await load();}catch(error){$('status').textContent=error.message;}finally{$('password').value='';}};
$('logout').onclick=async()=>{await api('/logout',{});for(const key of Object.keys(localStorage))if(key.startsWith('dtf-'))localStorage.removeItem(key);location.reload();};
$('refresh').onclick=load;
load();
async function artworkPanel(order,container){
container.replaceChildren();
const revisions=await api('/orders/'+order.id+'/files');
for(const file of revisions){
const row=node('p',(file.kind==='final'?'Final':'Correção do cliente')+' · item '+(file.item_index+1)+' · '+file.name+' · '+(file.expired?'expirado':file.active?'atual':'substituído'),'meta');
row.append(node('p',file.note));
if(!file.expired)row.append(action('Baixar '+file.name,async()=>{const result=await api('/uploads/'+file.upload_id+'/download');const link=node('a');link.href=result.url;link.download=result.name;link.referrerPolicy='no-referrer';link.click();}));
container.append(row);
}
if(!['rec','tra','cor'].includes(order.state))return;
const form=node('form');
form.append(node('p','Enviar um conjunto final completo. Pode haver várias partes por item. Um novo conjunto substitui o anterior.'));
const inputs=order.snapshot.items.map((item,index)=>{const label=node('label','Item '+(index+1)+' · '+item.mode);label.style.display='block';const input=node('input');input.type='file';input.multiple=true;input.required=true;input.dataset.finalItem=index;input.style.width='100%';label.append(input);form.append(label);return input;});
const note=node('input');note.placeholder='Nota da revisão';note.required=true;note.maxLength=1000;note.style.width='100%';form.append(note);
const check=node('input');check.type='checkbox';check.required=true;const label=node('label','Conferi estes arquivos finais para impressão manual.');label.prepend(check);form.append(label);
const submit=node('button','Aprovar arquivos finais');submit.type='submit';form.append(submit);
form.onsubmit=async event=>{event.preventDefault();submit.disabled=true;try{
const refs=[];
for(const [index,input] of inputs.entries())for(const file of input.files){
const uid=await dtfUpload(file,{api,startPath:'/orders/'+order.id+'/uploads',scope:'operator:'+order.id+':'+order.version,progress:text=>$('status').textContent=text});
refs.push({item_index:index,upload_id:uid});
}
await api('/orders/'+order.id+'/final-files',{version:order.version,files:refs,note:note.value});
await load();
}catch(error){$('status').textContent=error.message;submit.disabled=false;}};
container.append(form);
}

11
local/static/portal.html Normal file
View File

@@ -0,0 +1,11 @@
<!doctype html><html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Dropstar · Meus pedidos DTF</title>
<style>
*{box-sizing:border-box}body{margin:0;background:#f6f7f9;color:#20252d;font:15px/1.5 system-ui,sans-serif}header{background:white;border-bottom:1px solid #ddd;padding:20px max(20px,calc((100% - 1100px)/2));display:flex;gap:24px;align-items:center;flex-wrap:wrap}.brand{color:#ef8500;font-size:24px;font-style:italic;font-weight:900}a{color:#8a4e00}main{max-width:1100px;margin:24px auto;padding:0 20px}h1{font-size:28px}h2{font-size:21px}h3{font-size:17px}.notice{background:#fff1d4;border-left:4px solid #ffa900;padding:12px 16px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:20px}.card{background:white;border:1px solid #ddd;border-radius:10px;padding:20px;margin:16px 0}.card p{overflow-wrap:anywhere}.muted{color:#596471}label{display:block;margin:10px 0}input,textarea,button{font:inherit;border:1px solid #9aa0a8;border-radius:6px;padding:10px}input:not([type=file]),textarea{width:100%}button{background:#ffa900;border-color:#ffa900;cursor:pointer}button:disabled{opacity:.6}form{margin:10px 0}.actions{display:flex;gap:10px;flex-wrap:wrap;align-items:center}#message{min-height:24px;color:#9b3800}li{margin:8px 0}.tag{background:#eef2f6;padding:5px 10px;border-radius:6px}details{margin:12px 0}[hidden]{display:none!important}
</style></head><body>
<header><a class="brand" href="/">DROPSTAR</a><a href="/">Enviar arte</a><span>Minha conta · DTF</span><button id="logout" hidden>Sair</button></header>
<main><h1>Meus pedidos DTF</h1><p class="notice">Ambiente de desenvolvimento local. Pagamentos e notificações são simulados. Use apenas dados de teste.</p><p id="message" role="status"></p>
<div id="account"></div><section id="auth" class="grid">
<form id="login" class="card"><h2>Entrar</h2><label>E-mail <input id="email" type="email" autocomplete="username" required></label><label>Senha <input id="password" type="password" autocomplete="current-password" required></label><button>Entrar</button></form>
<form id="register" class="card"><h2>Criar conta local</h2><label>CNPJ <input id="cnpj" required></label><label>WhatsApp <input id="phone" required></label><label>E-mail <input id="register-email" type="email" autocomplete="email" required></label><label>Senha (12 caracteres ou mais) <input id="register-password" type="password" minlength="12" maxlength="128" autocomplete="new-password" required></label><button>Criar conta</button><p class="muted">Pedidos desta sessão serão associados à sua conta. Não há envio de e-mail nem recuperação de senha nesta versão local.</p></form>
</section><div class="actions"><h2>Acompanhamento</h2><button id="refresh">Atualizar pedidos</button></div><p id="guest" class="muted"></p><section id="quotes"></section><section id="orders"></section></main>
<script src="/privacy.js"></script><script src="/upload.js"></script><script src="/portal.js"></script></body></html>

76
local/static/portal.js Normal file
View File

@@ -0,0 +1,76 @@
const $=id=>document.getElementById(id);
const node=(tag,text)=>{const el=document.createElement(tag);if(text!==undefined)el.textContent=text;return el;};
const money=c=>(c/100).toLocaleString('pt-BR',{style:'currency',currency:'BRL'});
let scope,states={};
async function api(path,body){const r=await fetch('/api'+path,{headers:{'Content-Type':'application/json'},...(body===undefined?{}:{method:'POST',body:JSON.stringify(body)})});const d=await r.json();if(!r.ok){const error=new Error(typeof d.detail==='string'?d.detail:'Confira os campos informados.');error.status=r.status;throw error;}return d;}
function button(text,fn){const b=node('button',text);b.onclick=async()=>{b.disabled=true;try{await fn();}catch(e){$('message').textContent=e.message;}finally{b.disabled=false;}};return b;}
function forgetCheckout(){for(const k of ['dtf-quote','dtf-request-key','dtf-request-body'])localStorage.removeItem(k);}
async function load(){
try{
scope=(await api('/session')).cart_scope;
const account=await api('/account/me');
$('auth').hidden=!!account.customer;$('logout').hidden=!account.customer;
$('account').textContent=account.customer?'Conta: '+account.customer.mail:'';
$('guest').textContent=account.customer?'':'Você está vendo apenas os pedidos desta sessão. Crie uma conta para acessar seus pedidos em outro navegador.';
const data=await api('/customer/orders');states=data.states;
$('quotes').replaceChildren();
for(const quote of data.quotes){
const card=node('article');card.className='card';card.append(node('h3','Cotação '+quote.id.slice(0,8)));
card.append(node('p',quote.approved?'Total aprovado: '+money(quote.approved.total_cents):'Aguardando conferência de metragem e nota.'));
const link=node('a','Abrir cotação no Site');link.href='/?quote='+quote.id;card.append(link);$('quotes').append(card);
}
$('orders').replaceChildren();
if(!data.orders.length)$('orders').append(node('p','Nenhum pedido pago nesta conta ou sessão.'));
for(const order of data.orders){
const card=node('article');card.className='card';card.id='order-'+order.id;
card.append(node('h3','Pedido #'+order.number),node('p',states[order.state]+' · '+money(order.snapshot.total_cents)+' · pagamento local'));
card.append(node('p',new Date(order.created_at).toLocaleString('pt-BR')));
const content=node('div');
card.append(button('Ver detalhes e arquivos',()=>details(order.id,content)),content);$('orders').append(card);
if(new URLSearchParams(location.search).get('order')===order.id)await details(order.id,content);
}
}catch(error){$('message').textContent=error.message;}
}
async function details(id,container){
const order=await api('/customer/orders/'+id);container.replaceChildren();
for(const [i,item] of order.snapshot.items.entries())container.append(node('p',(i+1)+'. '+item.mode+' · '+item.billed_metres+' m · nota '+item.grade+' · '+money(item.total_cents)));
const history=node('ol');
for(const h of order.history)history.append(node('li',new Date(h.created_at).toLocaleString('pt-BR')+' · '+states[h.to_state]+(h.reason?' — '+h.reason:'')));
container.append(history);
for(const file of order.files){
const row=node('p',(file.kind==='final'?'Arquivo final':'Correção')+' · item '+(file.item_index+1)+' · '+file.name+' · '+(file.expired?'expirado':!file.active?'substituído':'disponível até '+new Date(file.expires_at).toLocaleDateString('pt-BR')));
if(file.active&&!file.expired)row.append(button('Baixar',async()=>{const d=await api('/customer/orders/'+id+'/files/'+file.id+'/download');const link=node('a');link.href=d.url;link.download=d.name;link.referrerPolicy='no-referrer';link.click();}));
container.append(row);
}
if(order.state==='cor'){
const form=node('form');form.append(node('h3','Enviar arte corrigida'));
const inputs=order.snapshot.items.map((item,index)=>{const label=node('label','Item '+(index+1)+' · '+item.mode);const input=node('input');input.type='file';input.multiple=true;input.dataset.item=index;label.append(input);form.append(label);return input;});
const note=node('textarea');note.placeholder='Descreva a correção';note.required=true;note.maxLength=1000;form.append(note);
const submit=node('button','Enviar correção');submit.type='submit';form.append(submit);
form.onsubmit=async event=>{event.preventDefault();submit.disabled=true;try{
const files=[];for(const [index,input] of inputs.entries())for(const file of input.files)files.push({item_index:index,upload_id:await dtfUpload(file,{api,scope:scope+':correction:'+id+':'+order.version,progress:text=>$('message').textContent=text})});
if(!files.length)throw new Error('Selecione pelo menos um arquivo corrigido.');
await api('/customer/orders/'+id+'/corrections',{version:order.version,files,note:note.value});
$('message').textContent='Correção enviada. Aguarde a conferência da equipe.';await details(id,container);
}catch(error){$('message').textContent=error.message;}finally{submit.disabled=false;}};
container.append(form);
}
}
async function migrateGuestCart(previous,next){
if(previous===next)return;
const database=await new Promise((resolve,reject)=>{const r=indexedDB.open('dtf-local-cart',1);r.onupgradeneeded=()=>r.result.createObjectStore('cart');r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(r.error);});
await new Promise((resolve,reject)=>{const tx=database.transaction('cart','readwrite'),store=tx.objectStore('cart'),req=store.get(previous);req.onsuccess=()=>{if(req.result){store.put(req.result,next);store.delete(previous);}};tx.oncomplete=resolve;tx.onerror=()=>reject(tx.error);});
database.close();
}
async function authenticate(path,body){
const previous=scope,guest=!(await api('/account/me')).customer;
await api(path,body);forgetCheckout();$('message').textContent='Acesso confirmado.';
const next=(await api('/session')).cart_scope;
if(guest)try{await migrateGuestCart(previous,next);}catch{$('message').textContent='Acesso confirmado. Não foi possível transferir o carrinho salvo neste navegador.';}
await load();$('password').value='';$('register-password').value='';
}
$('login').onsubmit=async event=>{event.preventDefault();try{await authenticate('/account/login',{email:$('email').value,password:$('password').value});}catch(e){$('message').textContent=e.message;}};
$('register').onsubmit=async event=>{event.preventDefault();try{await authenticate('/account/register',{customer:{cnpj:$('cnpj').value,zap:$('phone').value,mail:$('register-email').value},password:$('register-password').value});}catch(e){$('message').textContent=e.message;}};
$('logout').onclick=async()=>{try{await window.dtfForgetPrivateData();await api('/account/logout',{});location.reload();}catch(error){$('message').textContent='Não foi possível concluir a limpeza local. Feche as abas do Site e limpe os dados deste site no navegador.';}};
$('refresh').onclick=load;
load();

15
local/static/privacy.js Normal file
View File

@@ -0,0 +1,15 @@
/* Clear draft files and metadata across Site tabs when signing out. */
(() => {
const signal='dtf-privacy-reset';
function notify(){window.dispatchEvent(new Event('dtf-private-data-cleared'));}
window.dtfForgetPrivateData=async()=>{
notify();
for(const key of Object.keys(localStorage))if(key.startsWith('dtf-'))localStorage.removeItem(key);
localStorage.setItem(signal,crypto.randomUUID());
const db=await new Promise((resolve,reject)=>{const r=indexedDB.open('dtf-local-cart',1);r.onupgradeneeded=()=>r.result.createObjectStore('cart');r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(r.error);});
try{await new Promise((resolve,reject)=>{const tx=db.transaction('cart','readwrite');tx.objectStore('cart').clear();tx.oncomplete=resolve;tx.onerror=()=>reject(tx.error);});}finally{db.close();}
};
window.addEventListener('storage',event=>{
if(event.key===signal && event.newValue){notify();location.reload();}
});
})();

30
local/static/upload.js Normal file
View File

@@ -0,0 +1,30 @@
/* Shared direct multipart transport for customer originals/corrections and operator finals. */
window.dtfUpload = async (file, {api, prefix='/uploads', startPath=prefix, progress=()=>{}, scope='guest', resume=true}) => {
const samples=new Blob([file.slice(0,65536),file.slice(Math.max(0,file.size-65536))]);
const hash=Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',await samples.arrayBuffer())),b=>b.toString(16).padStart(2,'0')).join('');
const key='dtf-upload:'+JSON.stringify([scope,file.name,file.size,file.lastModified,hash]);
let id=resume?localStorage.getItem(key):null,state;
if(id){try{state=await api(prefix+'/'+id);}catch(error){if(![404,410].includes(error.status))throw error;id=null;}}
if(!id){state=await api(startPath,{name:file.name,size:file.size});id=state.id;if(resume)localStorage.setItem(key,id);}
async function waitForScan(){
for(let attempt=0;attempt<150;attempt++){
const checked=await api(prefix+'/'+id);
if(checked.scan_state==='clean')return id;
if(['rejected','error'].includes(checked.scan_state))throw new Error(checked.scan_reason||'Arquivo bloqueado pela verificação de segurança.');
progress('Verificando segurança de '+file.name+'…');
await new Promise(resolve=>setTimeout(resolve,1000));
}
throw new Error('Verificação de segurança pendente. Tente novamente para consultar o resultado.');
}
if(state.complete)return waitForScan();
const done=new Set(state.parts||[]),size=state.part_bytes;
for(let offset=0,part=1;offset<file.size;offset+=size,part++){
if(done.has(part))continue;
progress('Enviando '+file.name+' · parte '+part+'/'+Math.ceil(file.size/size));
const signed=await api(prefix+'/'+id+'/parts/'+part,{});
const response=await fetch(signed.url,{method:'PUT',body:file.slice(offset,offset+size)});
if(!response.ok)throw new Error('Upload interrompido. Tente novamente para retomar.');
}
await api(prefix+'/'+id+'/complete',{});
return waitForScan();
};

24
local/storage-init.sh Normal file
View File

@@ -0,0 +1,24 @@
#!/bin/sh
set -eu
case "$S3_BUCKET" in *[!a-z0-9.-]*|'') echo 'Invalid local bucket name' >&2; exit 1;; esac
if [ "$MINIO_ROOT_USER" = "$S3_APP_USER" ]; then echo 'Runtime storage user must not be root' >&2; exit 1; fi
# Disposable local secrets are passed via environment, never traced/logged.
mc alias set local http://storage:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null
mc mb --ignore-existing "local/$S3_BUCKET" >/dev/null
mc anonymous set none "local/$S3_BUCKET" >/dev/null
mc admin user add local "$S3_APP_USER" "$S3_APP_PASSWORD" >/dev/null
# Use shell builtins only: the minimal MinIO image does not ship sed/cat.
policy=''
while IFS= read -r line || [ -n "$line" ]; do
while [ "${line#*dtf-local-artwork}" != "$line" ]; do
policy="$policy${line%%dtf-local-artwork*}$S3_BUCKET"
line=${line#*dtf-local-artwork}
done
policy="$policy$line
"
done < /policy.json
printf '%s' "$policy" > /tmp/policy.json
mc admin policy create local dtf-artwork /tmp/policy.json >/dev/null
mc admin policy attach local dtf-artwork --user "$S3_APP_USER" >/dev/null
mc ilm import "local/$S3_BUCKET" < /lifecycle.json
echo 'Local storage runtime account provisioned.'

View File

@@ -0,0 +1 @@
{"Rules":[{"ID":"local-artwork-retention","Status":"Enabled","Filter":{"Prefix":""},"Expiration":{"Days":30},"AbortIncompleteMultipartUpload":{"DaysAfterInitiation":1}}]}

View File

@@ -0,0 +1,7 @@
{
"Version": "2012-10-17",
"Statement": [
{"Effect":"Allow","Action":["s3:ListBucket","s3:ListBucketMultipartUploads","s3:GetBucketLocation"],"Resource":["arn:aws:s3:::dtf-local-artwork"]},
{"Effect":"Allow","Action":["s3:GetObject","s3:PutObject","s3:DeleteObject","s3:AbortMultipartUpload","s3:ListMultipartUploadParts"],"Resource":["arn:aws:s3:::dtf-local-artwork/originals/*"]}
]
}

192
local/storage_backup.py Normal file
View File

@@ -0,0 +1,192 @@
"""Stream clean MinIO artwork to an archive and verify it via an isolated prefix."""
import hashlib
import io
import json
import os
import sys
import tarfile
from datetime import datetime, timezone
from uuid import uuid4
from .adapters import LocalS3Storage, require_local
from .db import connect
CHUNK = 8 * 1024 * 1024
MAX_OBJECT = min(128 * 1024 * 1024, int(os.environ.get('SCAN_MAX_BYTES', '134217728')))
MAX_TOTAL = int(os.environ.get('STORAGE_QUOTA_BYTES', '53687091200'))
MAX_OBJECTS = 100000
class DigestReader:
def __init__(self, stream):
self.stream = stream
self.digest = hashlib.sha256()
self.size = 0
def read(self, size=-1):
block = self.stream.read(size)
if block:
self.digest.update(block)
self.size += len(block)
return block
def add_bytes(archive, name, content):
info = tarfile.TarInfo(name)
info.size = len(content)
info.mtime = int(datetime.now(timezone.utc).timestamp())
info.mode = 0o600
archive.addfile(info, io.BytesIO(content))
def export_archive():
require_local()
storage = LocalS3Storage()
with connect() as c:
rows = c.execute("""SELECT id,object_key,size,created_at,expires_at
FROM dtf_local.uploads WHERE complete AND purged_at IS NULL
AND expires_at>now() AND scan_state='clean' ORDER BY object_key""").fetchall()
if len(rows) > MAX_OBJECTS:
raise RuntimeError('Object count exceeds the backup safety limit')
expected_total = sum(row['size'] for row in rows)
if expected_total > MAX_TOTAL:
raise RuntimeError('Object bytes exceed the backup safety limit')
objects = []
with tarfile.open(fileobj=sys.stdout.buffer, mode='w|gz', compresslevel=6) as archive:
for index, row in enumerate(rows):
if row['size'] <= 0 or row['size'] > MAX_OBJECT:
raise RuntimeError('A clean object has an invalid backup size')
response = storage.client.get_object(Bucket=storage.bucket, Key=row['object_key'])
if response['ContentLength'] != row['size']:
response['Body'].close()
raise RuntimeError('Stored object size differs from database metadata')
reader = DigestReader(response['Body'])
name = f'objects/{index:08d}'
info = tarfile.TarInfo(name)
info.size = row['size']
info.mtime = int(row['created_at'].timestamp())
info.mode = 0o600
try:
archive.addfile(info, reader)
finally:
response['Body'].close()
if reader.size != row['size']:
raise RuntimeError('Object changed while the backup was streaming')
objects.append({'archive_name': name, 'upload_id': str(row['id']),
'object_key': row['object_key'], 'size': row['size'],
'sha256': reader.digest.hexdigest(),
'expires_at': row['expires_at'].isoformat()})
manifest = {'format': 'dtf-object-backup-v1',
'created_at': datetime.now(timezone.utc).isoformat(),
'source_bucket': storage.bucket, 'objects': objects,
'object_count': len(objects), 'total_bytes': expected_total}
add_bytes(archive, 'manifest.json', json.dumps(manifest, sort_keys=True).encode())
print('DTF_BACKUP_SUMMARY '+json.dumps({'object_count': len(objects),
'total_bytes': expected_total}), file=sys.stderr)
def upload_member(storage, source, size, key):
upload = storage.client.create_multipart_upload(
Bucket=storage.bucket, Key=key, ContentType='application/octet-stream')['UploadId']
parts = []
digest = hashlib.sha256()
remaining = size
try:
number = 1
while remaining:
block = source.read(min(CHUNK, remaining))
if not block:
raise ValueError('Archive object ended before its declared size')
digest.update(block)
result = storage.client.upload_part(Bucket=storage.bucket, Key=key,
UploadId=upload, PartNumber=number, Body=block, ContentLength=len(block))
parts.append({'PartNumber': number, 'ETag': result['ETag']})
remaining -= len(block)
number += 1
storage.client.complete_multipart_upload(Bucket=storage.bucket, Key=key,
UploadId=upload, MultipartUpload={'Parts': parts})
except Exception:
try:
storage.client.abort_multipart_upload(
Bucket=storage.bucket, Key=key, UploadId=upload)
except Exception:
# Preserve the original upload/read error; the verification-prefix
# cleanup below still removes any completed temporary object.
pass
raise
return digest.hexdigest()
def object_digest(storage, key):
response = storage.client.get_object(Bucket=storage.bucket, Key=key)
digest = hashlib.sha256()
size = 0
try:
for block in response['Body'].iter_chunks(chunk_size=1024 * 1024):
digest.update(block)
size += len(block)
finally:
response['Body'].close()
return size, digest.hexdigest()
def verify_archive():
require_local()
storage = LocalS3Storage()
verification = uuid4().hex
restored = []
observed = []
manifest = None
total = 0
try:
with tarfile.open(fileobj=sys.stdin.buffer, mode='r|gz') as archive:
for member in archive:
if not member.isfile():
raise ValueError('Backup archive contains a non-file member')
source = archive.extractfile(member)
if source is None:
raise ValueError('Backup archive member cannot be read')
if member.name == 'manifest.json':
if manifest is not None or member.size > 1024 * 1024:
raise ValueError('Invalid object-backup manifest')
manifest = json.loads(source.read().decode())
continue
expected_name = f'objects/{len(observed):08d}'
if member.name != expected_name or len(observed) >= MAX_OBJECTS:
raise ValueError('Unexpected object-backup member')
if member.size <= 0 or member.size > MAX_OBJECT:
raise ValueError('Object-backup member exceeds safety limits')
total += member.size
if total > MAX_TOTAL:
raise ValueError('Object-backup total exceeds safety limits')
key = f'originals/restore-verification/{verification}/{len(observed):08d}'
digest = upload_member(storage, source, member.size, key)
restored.append(key)
observed.append({'archive_name': member.name, 'size': member.size,
'sha256': digest})
if not manifest or manifest.get('format') != 'dtf-object-backup-v1':
raise ValueError('Object-backup manifest is missing or unsupported')
expected = manifest.get('objects')
if manifest.get('object_count') != len(observed) or manifest.get('total_bytes') != total:
raise ValueError('Object-backup summary does not match archive contents')
if not isinstance(expected, list) or len(expected) != len(observed):
raise ValueError('Object-backup manifest count does not match')
for actual, recorded, key in zip(observed, expected, restored):
for field in ('archive_name', 'size', 'sha256'):
if actual[field] != recorded.get(field):
raise ValueError('Object-backup checksum manifest does not match')
restored_size, restored_hash = object_digest(storage, key)
if restored_size != actual['size'] or restored_hash != actual['sha256']:
raise ValueError('Restored verification object differs from archive')
print(f'PASS: {len(observed)} clean objects ({total} bytes) restored and hashed in an isolated prefix.')
finally:
for key in restored:
storage.client.delete_object(Bucket=storage.bucket, Key=key)
print('Removed temporary verification objects. Active artwork was untouched.')
if __name__ == '__main__':
if len(sys.argv) != 2 or sys.argv[1] not in ('export', 'verify'):
raise SystemExit('usage: python -m local.storage_backup export|verify')
export_archive() if sys.argv[1] == 'export' else verify_archive()

View File

@@ -0,0 +1,46 @@
import re
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
NAME_VERSION = re.compile(r'^([A-Za-z0-9_.-]+)==([^\s\\]+)', re.MULTILINE)
def normalized(name):
return re.sub(r'[-_.]+', '-', name).lower()
class DependencyLockTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.direct_text = (ROOT / 'local/requirements.txt').read_text()
cls.lock_text = (ROOT / 'local/requirements.lock').read_text()
def test_every_direct_pin_matches_lock(self):
direct = {normalized(name): version for name, version in
NAME_VERSION.findall(self.direct_text)}
locked = {normalized(name): version for name, version in
NAME_VERSION.findall(self.lock_text)}
self.assertTrue(direct)
self.assertEqual({name: locked.get(name) for name in direct}, direct)
def test_every_locked_package_has_sha256_hash(self):
matches = list(NAME_VERSION.finditer(self.lock_text))
self.assertTrue(matches)
for index, match in enumerate(matches):
end = matches[index + 1].start() if index + 1 < len(matches) else len(self.lock_text)
block = self.lock_text[match.start():end]
hashes = re.findall(r'--hash=sha256:([0-9a-f]{64})(?:\s|\\)', block)
self.assertTrue(hashes, f'{match.group(1)} has no SHA-256 artifact hash')
def test_image_build_requires_the_lock_and_hashes(self):
dockerfile = (ROOT / 'local/Dockerfile').read_text()
self.assertIn('requirements.lock', dockerfile)
self.assertIn('--require-hashes -r local/requirements.lock', dockerfile)
def test_reproducible_generator_is_recorded(self):
self.assertIn('./local/lock_dependencies.sh', self.lock_text[:300])
if __name__ == '__main__':
unittest.main()

40
local/test_pricing.py Normal file
View File

@@ -0,0 +1,40 @@
"""Run from repository root: python3 -m unittest local.test_pricing -v."""
import json
from pathlib import Path
import subprocess
import unittest
from .pricing import price, TIERS
class PricingTests(unittest.TestCase):
def test_every_grade_against_actual_site_javascript(self):
source = Path('dtf-site.html').read_text()
tiers = source.split('const FAIXAS=')[1].split('\n};',1)[0]+'\n}'
calculator = source.split('const cobrar=')[1].split(';',1)[0]
js = f'const FAIXAS={tiers};const MINIMO_M=1;const cobrar={calculator};'
js += '''const results=[];for(const mode of Object.keys(FAIXAS))for(let grade=0;grade<=100;grade++)
for(const metres of [0.01,0.99,1,1.01,1.1,2.75,2.8,2.8000000000000003,19.99,60,12000]){
const unit=FAIXAS[mode].find(x=>grade>=x[0])[1];
results.push([mode,String(metres),grade,cobrar(metres),Math.round(unit*100),Math.round(cobrar(metres)*unit*100)]);
}process.stdout.write(JSON.stringify(results));'''
cases = json.loads(subprocess.check_output(['node','-e',js],text=True))
for mode,metres,grade,billed,unit,total in cases:
with self.subTest(mode=mode,metres=metres,grade=grade):
result=price(mode,metres,grade)
self.assertEqual(float(result['billed_metres']),billed)
self.assertEqual(result['unit_cents'],unit)
self.assertEqual(result['total_cents'],total)
def test_assembly_is_included_at_every_tier(self):
for grade in range(101):
self.assertEqual(price('avulsa','1',grade)['total_cents']-price('file','1',grade)['total_cents'],1000)
self.assertEqual(price('uv','1',grade)['total_cents']-price('uvfile','1',grade)['total_cents'],1400)
def test_invalid_inputs(self):
for value in ('0','-1','NaN','Infinity','12001'):
with self.assertRaises(ValueError):price('file',value,90)
for grade in (-1,101,True,89.5):
with self.assertRaises(ValueError):price('file','1',grade)
with self.assertRaises(ValueError):price('other','1',90)
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,56 @@
import tempfile
import unittest
from pathlib import Path
from .staging_readiness import read_config, validate
VALID = '''
APP_ENV=staging
STAGING_APPROVED_BY=business-and-technical-owners
STAGING_PUBLIC_ORIGIN=https://staging.example.invalid
STAGING_S3_ENDPOINT=https://example.r2.cloudflarestorage.com
STAGING_S3_BUCKET=dtf-staging-artwork
STAGING_DATABASE_MODE=dedicated-container
STAGING_SECRET_SOURCE=portainer-secrets
STAGING_BACKUP_DESTINATION=separate-encrypted-r2-bucket
STAGING_FREIGHT_PROVIDER=provider-sandbox
STAGING_PAYMENT_PROVIDER=mercado-pago-sandbox
STAGING_ERP_PROVIDER=tiny-olist-sandbox
STAGING_WHATSAPP_PROVIDER=provider-sandbox
STAGING_ALERT_OWNER=operations-team
STAGING_ROLLBACK_OWNER=technical-team
'''
class StagingReadinessTests(unittest.TestCase):
def parse(self, text):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'staging.env'
path.write_text(text)
return read_config(path)
def test_complete_non_secret_metadata_passes(self):
self.assertEqual(validate(self.parse(VALID)), [])
def test_local_endpoint_and_fake_provider_are_blocked(self):
values = self.parse(VALID.replace(
'https://example.r2.cloudflarestorage.com', 'http://localhost:9000'
).replace('provider-sandbox', 'fake', 1))
errors = validate(values)
self.assertTrue(any('STAGING_S3_ENDPOINT' in error for error in errors))
self.assertTrue(any('STAGING_FREIGHT_PROVIDER' in error for error in errors))
def test_secret_named_setting_is_rejected(self):
with self.assertRaisesRegex(ValueError, 'secrets must not be stored'):
self.parse(VALID + 'MERCADO_PAGO_ACCESS_TOKEN=do-not-store-this\n')
def test_undecided_values_are_blocked(self):
errors = validate(self.parse(VALID.replace(
'STAGING_APPROVED_BY=business-and-technical-owners',
'STAGING_APPROVED_BY=TBD')))
self.assertIn('STAGING_APPROVED_BY is not decided', errors)
if __name__ == '__main__':
unittest.main()

77
local/worker.py Normal file
View File

@@ -0,0 +1,77 @@
"""Transactional outbox worker. Fake receipts persist; no messages leave the stack."""
import json
import logging
import threading
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
from psycopg.types.json import Jsonb
from .adapters import FakeTiny, FakeWhatsApp, LocalS3Storage, require_local
from .db import connect
from .scanning import ClamAV, scan_loop
require_local()
adapters = {'tiny': FakeTiny(), 'whatsapp': FakeWhatsApp()}
last_tick = 0.0
last_cleanup = 0.0
storage = LocalS3Storage()
scan_thread = None
def cleanup():
"""Delete only expired object bytes; retain order/file metadata and history."""
with connect() as c:
rows = c.execute("""SELECT * FROM dtf_local.uploads WHERE purged_at IS NULL
AND (expires_at<=now() OR (NOT complete AND created_at<now()-interval '1 day'))
ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 50""").fetchall()
for row in rows:
storage.discard(row['object_key'],row['multipart_id'],row['complete'])
c.execute('UPDATE dtf_local.uploads SET purged_at=now() WHERE id=%s', (row['id'],))
c.execute('DELETE FROM dtf_local.sessions WHERE expires_at<=now()')
c.execute('DELETE FROM dtf_local.operator_sessions WHERE expires_at<=now()')
c.execute("DELETE FROM dtf_local.login_attempts WHERE started_at<now()-interval '1 day'")
c.execute("DELETE FROM dtf_local.security_events WHERE created_at<now()-interval '30 days'")
def tick():
global last_tick
with connect() as c:
job = c.execute('SELECT * FROM dtf_local.outbox WHERE delivered_at IS NULL AND available_at <= now() ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 1').fetchone()
if job:
try:
receipt = adapters[job['provider']].deliver(job['event_key'], job['payload'])
c.execute('UPDATE dtf_local.outbox SET delivered_at=now(), receipt=%s, attempts=attempts+1, last_error=NULL WHERE id=%s', (Jsonb(receipt),job['id']))
except Exception as exc:
delay = min(1800, 2**min(job['attempts']+1, 10))
c.execute("UPDATE dtf_local.outbox SET attempts=attempts+1, last_error=%s, available_at=now() + %s * interval '1 second' WHERE id=%s", (str(exc),delay,job['id']))
last_tick = time.monotonic()
def loop():
global last_cleanup
while True:
try:
tick()
if time.monotonic()-last_cleanup > 60:
cleanup()
last_cleanup = time.monotonic()
except Exception:
logging.exception('Local worker tick failed')
time.sleep(1)
class Health(BaseHTTPRequestHandler):
def do_GET(self):
scanner = False
try:
scanner = bool(scan_thread and scan_thread.is_alive() and ClamAV().ping())
except Exception:
pass
healthy = time.monotonic()-last_tick < 15 and scanner
self.send_response(200 if self.path == '/health' and healthy else 503)
self.end_headers()
self.wfile.write(json.dumps({'worker': 'ok' if healthy else 'unavailable',
'scanner': 'ok' if scanner else 'unavailable'}).encode())
def log_message(self, *args):
pass
if __name__ == '__main__':
scan_thread = threading.Thread(target=scan_loop,args=(storage,),daemon=True)
scan_thread.start()
threading.Thread(target=loop, daemon=True).start()
HTTPServer(('0.0.0.0',8002),Health).serve_forever()

73
local/workflow_test.py Normal file
View File

@@ -0,0 +1,73 @@
"""Customer identity, correction and final-file trust boundaries against local stack."""
from uuid import uuid4
from urllib.request import urlopen
from .smoke_test import Client, upload_bytes
def run():
customer=Client();other=Client();customer.call('/session');other.call('/session')
uid=upload_bytes(customer,b'LOCAL ORIGINAL ONLY')
item={'mode':'file','metres':'1.01','grade':0,'uploads':[uid]}
profile={'cnpj':'11222333000181','zap':'11999999999','mail':'workflow-'+uuid4().hex[:8]+'@example.test'}
q=customer.call('/quotes',{'request_key':str(uuid4()),'customer':profile,'items':[item],'freight':{'service':'pickup'}})
customer.call('/operator/quotes/'+q['id']+'/approve',{'items':[item]},operator=True)
order=customer.call('/orders/dev-paid',{'quote_id':q['id']});oid=order['id']
before=list(customer.jar)[0].value
password='local-test-password-'+uuid4().hex
customer.call('/account/register',{'customer':profile,'password':password})
assert customer.call('/account/me')['customer']['mail']==profile['mail']
assert customer.call('/customer/orders')['orders'][0]['id']==oid
# Email/CNPJ do not grant ownership; only current guest session is migrated.
other.call('/customer/orders/'+oid,expected=404)
other.call('/account/login',{'email':profile['mail'],'password':'wrong-password'},expected=401)
revoked=Client()
import http.cookiejar
cookie=http.cookiejar.Cookie(0,'dtf_session',before,None,False,'localhost.local',False,False,'/',True,False,None,True,None,None,{},False)
revoked.jar.set_cookie(cookie)
revoked.call('/customer/orders',expected=401)
account_scope=customer.call('/session')['cart_scope']
cookie.value=account_scope;revoked.jar.set_cookie(cookie)
revoked.call('/customer/orders',expected=401)
other.call('/account/login',{'email':profile['mail'],'password':password})
assert other.call('/customer/orders/'+oid)['id']==oid
print('PASS: registration claims only current guest records, cross-session account login, revoked sessions, owner UUID is not a credential')
def move(state,version):
return customer.call('/operator/orders/'+oid+'/move',{'state':state,'version':version,'reason':'Please replace the artwork' if state=='cor' else ''},operator=True)['version']
version=move('tra',0)
customer.call('/operator/orders/'+oid+'/move',{'state':'fil','version':version},operator=True,expected=409)
final_id=upload_bytes(customer,b'LOCAL FINAL VERSION ONE',order_id=oid)
customer.call('/uploads/'+final_id,expected=404)
body={'version':version,'files':[{'item_index':0,'upload_id':final_id}],'note':'Manually checked final'}
customer.call('/operator/orders/'+oid+'/final-files',body,expected=401)
version=customer.call('/operator/orders/'+oid+'/final-files',body,operator=True)['version']
detail=customer.call('/customer/orders/'+oid)
final=detail['files'][0]
link=customer.call('/customer/orders/'+oid+'/files/'+final['id']+'/download')
assert urlopen(link['url']).read()==b'LOCAL FINAL VERSION ONE'
guest=Client();guest.call('/session');guest.call('/customer/orders/'+oid+'/files/'+final['id']+'/download',expected=404)
version=move('fil',version);version=move('imp',version);version=move('cor',version)
customer.call('/customer/orders/'+oid+'/files/'+final['id']+'/download',expected=404)
correction_id=upload_bytes(customer,b'LOCAL CORRECTED ORIGINAL')
payload={'version':version,'files':[{'item_index':0,'upload_id':correction_id}],'note':'Replaced the artwork as requested'}
guest.call('/customer/orders/'+oid+'/corrections',payload,expected=404)
customer.call('/customer/orders/'+oid+'/corrections',{**payload,'version':0},expected=409)
version=customer.call('/customer/orders/'+oid+'/corrections',payload)['version']
files=customer.call('/operator/orders/'+oid+'/files',operator=True)
assert any(f['kind']=='correction' and f['active'] and f['upload_id']==correction_id for f in files)
version=move('tra',version)
customer.call('/operator/orders/'+oid+'/move',{'state':'fil','version':version},operator=True,expected=409)
new_final=upload_bytes(customer,b'LOCAL FINAL VERSION TWO',order_id=oid)
version=customer.call('/operator/orders/'+oid+'/final-files',{'version':version,'files':[{'item_index':0,'upload_id':new_final}],'note':'Checked corrected final'},operator=True)['version']
for state in ('fil','imp','fin'):version=move(state,version)
detail=other.call('/customer/orders/'+oid)
assert detail['state']=='fin'
assert sum(f['active'] and f['kind']=='final' for f in detail['files'])==1
assert any(h['reason']=='Please replace the artwork' for h in detail['history'])
print('PASS: final-file gate, final revisions, secure customer downloads, correction history/uploads, old final invalidation and reapproval')
old_cookie=list(other.jar)[0].value
other.call('/account/logout',{})
cookie.value=old_cookie;revoked.jar.set_cookie(cookie);revoked.call('/customer/orders',expected=401)
other.call('/session');assert other.call('/customer/orders')['orders']==[]
print('PASS: logout revokes server session and signed-out visitors cannot see account orders')
if __name__=='__main__':run()

BIN
output/local/kanban.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
output/local/portal.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

BIN
output/local/site.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
{"dependencies": [{"name": "annotated-doc", "version": "0.0.5", "vulns": []}, {"name": "annotated-types", "version": "0.8.0", "vulns": []}, {"name": "anyio", "version": "4.15.1", "vulns": []}, {"name": "boto3", "version": "1.38.23", "vulns": []}, {"name": "botocore", "version": "1.38.46", "vulns": []}, {"name": "certifi", "version": "2026.7.22", "vulns": []}, {"name": "click", "version": "8.5.0", "vulns": []}, {"name": "fastapi", "version": "0.141.1", "vulns": []}, {"name": "h11", "version": "0.16.0", "vulns": []}, {"name": "httpcore", "version": "1.0.9", "vulns": []}, {"name": "httpx", "version": "0.28.1", "vulns": []}, {"name": "idna", "version": "3.19", "vulns": []}, {"name": "jmespath", "version": "1.1.0", "vulns": []}, {"name": "pip", "version": "26.2.1", "vulns": []}, {"name": "psycopg", "version": "3.2.9", "vulns": []}, {"name": "psycopg-binary", "version": "3.2.9", "vulns": []}, {"name": "pydantic", "version": "2.13.5", "vulns": []}, {"name": "pydantic-core", "version": "2.46.5", "vulns": []}, {"name": "python-dateutil", "version": "2.9.0.post0", "vulns": []}, {"name": "s3transfer", "version": "0.13.1", "vulns": []}, {"name": "six", "version": "1.17.0", "vulns": []}, {"name": "starlette", "version": "1.6.0", "vulns": []}, {"name": "typing-extensions", "version": "4.16.0", "vulns": []}, {"name": "typing-inspection", "version": "0.4.4", "vulns": []}, {"name": "urllib3", "version": "2.7.0", "vulns": []}, {"name": "uvicorn", "version": "0.34.2", "vulns": []}], "fixes": []}

View File

@@ -0,0 +1,26 @@
annotated-doc==0.0.5
annotated-types==0.8.0
anyio==4.15.1
boto3==1.38.23
botocore==1.38.46
certifi==2026.7.22
click==8.5.0
fastapi==0.141.1
h11==0.16.0
httpcore==1.0.9
httpx==0.28.1
idna==3.19
jmespath==1.1.0
pip==26.2.1
psycopg==3.2.9
psycopg-binary==3.2.9
pydantic==2.13.5
pydantic_core==2.46.5
python-dateutil==2.9.0.post0
s3transfer==0.13.1
six==1.17.0
starlette==1.6.0
typing_extensions==4.16.0
typing-inspection==0.4.4
urllib3==2.7.0
uvicorn==0.34.2

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

322
portal/main.py Normal file
View File

@@ -0,0 +1,322 @@
"""
Portal de recebimento de arte — roda na nuvem, aberto 24 horas.
Fluxo:
0. Tiny avisa por webhook que nasceu um pedido de DTF
1. portal cria token e manda o link no WhatsApp
2. cliente abre o link e pede uma URL pré-assinada
3. o navegador envia o arquivo DIRETO para o storage (não passa por aqui)
4. cliente avisa que terminou -> robô valida, repete, fatia e carimba
5. o agente da fábrica busca o que foi aprovado
Rodar:
uvicorn main:app --host 0.0.0.0 --port 8000
"""
from __future__ import annotations
import os
import uuid
from datetime import datetime, timedelta, timezone
import boto3
from fastapi import BackgroundTasks, Depends, FastAPI, Header, HTTPException
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from sqlmodel import Field, Session, SQLModel, create_engine, select
import preflight
import tiny
import whats
# --------------------------------------------------------------------------
DB = os.environ["DATABASE_URL"]
BUCKET = os.environ["S3_BUCKET"]
BASE_PORTAL = os.environ["BASE_PORTAL"] # https://arte.dropstaratacado.com.br
BASE_KANBAN = os.environ["BASE_KANBAN"] # http://servidor:8080
TOKEN_TINY = os.environ["WEBHOOK_TOKEN"] # segredo combinado com o Tiny
TOKEN_AGENTE = os.environ["AGENTE_TOKEN"]
VALIDADE_TOKEN_DIAS = 7
MAX_GB = 5 # limite do que os PCs da sala aguentam abrir
MAX_ARQUIVOS = 10
engine = create_engine(DB)
s3 = boto3.client("s3", endpoint_url=os.environ["S3_ENDPOINT"])
app = FastAPI(title="Portal de arte DTF")
# --------------------------------------------------------------------------
# Tabelas
# --------------------------------------------------------------------------
class Pedido(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
numero_tiny: str = Field(index=True, unique=True)
cliente: str
metros: float # metros comprados por arte
token: str = Field(index=True, unique=True)
token_expira: datetime
criado_em: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class Arte(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
pedido_id: int = Field(foreign_key="pedido.id", index=True)
arquivo: str
bytes: int = 0
repeticoes: int = 1
status: str = "recebida" # recebida | aprovada | recusada
motivo: str = ""
avisos: str = ""
dpi_efetivo: float = 0
largura_cm: float = 0
metros_totais: float = 0
baixada: bool = False
criado_em: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class Parte(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
arte_id: int = Field(foreign_key="arte.id", index=True)
ordem: int
metros: float
arquivo: str
carimbada: bool = False
SQLModel.metadata.create_all(engine)
def sessao():
with Session(engine) as s:
yield s
# --------------------------------------------------------------------------
# 0. Webhook do Tiny — pedido novo
# --------------------------------------------------------------------------
class PedidoTiny(BaseModel):
numero: str
cliente: str
telefone: str
metros: float
@app.post("/webhook/tiny")
def pedido_novo(
p: PedidoTiny,
background: BackgroundTasks,
x_token: str = Header(default=""),
s: Session = Depends(sessao),
):
if x_token != TOKEN_TINY:
raise HTTPException(401, "token inválido")
existente = s.exec(select(Pedido).where(Pedido.numero_tiny == p.numero)).first()
if existente:
return {"ok": True, "ja_existia": True}
pedido = Pedido(
numero_tiny=p.numero,
cliente=p.cliente,
metros=p.metros,
token=uuid.uuid4().hex,
token_expira=datetime.now(timezone.utc) + timedelta(days=VALIDADE_TOKEN_DIAS),
)
s.add(pedido)
s.commit()
s.refresh(pedido)
link = f"{BASE_PORTAL}/arte/{pedido.token}"
background.add_task(whats.enviar_link, p.telefone, p.numero, link)
return {"ok": True, "link": link}
# --------------------------------------------------------------------------
# 1 e 2. Página e URL pré-assinada
# --------------------------------------------------------------------------
def _pedido_por_token(token: str, s: Session) -> Pedido:
p = s.exec(select(Pedido).where(Pedido.token == token)).first()
if not p:
raise HTTPException(404, "link inválido")
if p.token_expira < datetime.now(timezone.utc):
raise HTTPException(410, "link expirado — fale com o atendimento")
return p
@app.get("/arte/{token}", response_class=HTMLResponse)
def pagina(token: str, s: Session = Depends(sessao)):
_pedido_por_token(token, s)
return open("static/portal.html", encoding="utf-8").read()
@app.get("/api/arte/{token}")
def dados(token: str, s: Session = Depends(sessao)):
p = _pedido_por_token(token, s)
return {
"pedido": p.numero_tiny,
"cliente": p.cliente,
"metros": p.metros,
"area_util_cm": preflight.AREA_UTIL_CM,
"limite_arquivo_m": preflight.LIMITE_ARQUIVO_M,
"max_gb": MAX_GB,
}
@app.post("/api/arte/{token}/url")
def url_upload(token: str, nome: str, s: Session = Depends(sessao)):
p = _pedido_por_token(token, s)
n = len(s.exec(select(Arte).where(Arte.pedido_id == p.id)).all())
if n >= MAX_ARQUIVOS:
raise HTTPException(400, f"máximo de {MAX_ARQUIVOS} arquivos por pedido")
chave = f"{p.numero_tiny}/{uuid.uuid4().hex}_{nome}"
url = s3.generate_presigned_url(
"put_object",
Params={"Bucket": BUCKET, "Key": chave},
ExpiresIn=3600,
)
return {"url": url, "chave": chave}
# --------------------------------------------------------------------------
# 4. Terminou o upload -> roda o pré-flight
# --------------------------------------------------------------------------
class Pronto(BaseModel):
chave: str
repeticoes: int = 1
@app.post("/api/arte/{token}/pronto")
def pronto(
token: str,
body: Pronto,
background: BackgroundTasks,
s: Session = Depends(sessao),
):
p = _pedido_por_token(token, s)
arte = Arte(pedido_id=p.id, arquivo=body.chave, repeticoes=max(1, body.repeticoes))
s.add(arte)
s.commit()
s.refresh(arte)
background.add_task(processar, arte.id)
return {"arte_id": arte.id, "status": "processando"}
def processar(arte_id: int):
"""Baixa do storage, valida, repete, fatia, carimba e devolve as partes."""
with Session(engine) as s:
arte = s.get(Arte, arte_id)
pedido = s.get(Pedido, arte.pedido_id)
local = f"/tmp/{arte.id}.png"
s3.download_file(BUCKET, arte.arquivo, local)
arte.bytes = os.path.getsize(local)
r = preflight.processar(
caminho=local,
cm_comprados=pedido.metros * 100,
repeticoes=arte.repeticoes,
pedido=pedido.numero_tiny,
base_kanban=BASE_KANBAN,
pasta_saida="/tmp",
)
arte.dpi_efetivo = r.dpi_efetivo
arte.largura_cm = r.largura_cm
arte.metros_totais = r.metros_totais
arte.avisos = " | ".join(r.avisos)
if not r.aprovado:
arte.status, arte.motivo = "recusada", r.motivo
s.add(arte)
s.commit()
whats.enviar_recusa(pedido, arte.motivo)
return
for i, caminho in enumerate(r.partes, start=1):
chave = f"{pedido.numero_tiny}/partes/{os.path.basename(caminho)}"
s3.upload_file(caminho, BUCKET, chave)
s.add(Parte(
arte_id=arte.id,
ordem=i,
metros=min(preflight.LIMITE_ARQUIVO_M,
r.metros_totais - preflight.LIMITE_ARQUIVO_M * (i - 1)),
arquivo=chave,
carimbada=(i == len(r.partes)),
))
arte.status = "aprovada"
s.add(arte)
s.commit()
tiny.marcador(pedido.numero_tiny, "DTF-RECEBIDA")
whats.enviar_aprovacao(pedido, r)
avisar_agente()
# --------------------------------------------------------------------------
# 5. O agente busca o que foi aprovado
# --------------------------------------------------------------------------
@app.get("/api/artes")
def para_baixar(x_token: str = Header(default=""), s: Session = Depends(sessao)):
if x_token != TOKEN_AGENTE:
raise HTTPException(401, "token inválido")
artes = s.exec(
select(Arte).where(Arte.status == "aprovada", Arte.baixada == False) # noqa: E712
).all()
saida = []
for a in artes:
p = s.get(Pedido, a.pedido_id)
partes = s.exec(select(Parte).where(Parte.arte_id == a.id)).all()
saida.append({
"arte_id": a.id,
"pedido": p.numero_tiny,
"cliente": p.cliente,
"metros": a.metros_totais,
"partes": [
{
"ordem": pt.ordem,
"metros": pt.metros,
"url": s3.generate_presigned_url(
"get_object",
Params={"Bucket": BUCKET, "Key": pt.arquivo},
ExpiresIn=3600,
),
"nome": os.path.basename(pt.arquivo),
}
for pt in sorted(partes, key=lambda x: x.ordem)
],
})
return saida
@app.post("/api/artes/{arte_id}/baixada")
def marcar_baixada(
arte_id: int, x_token: str = Header(default=""), s: Session = Depends(sessao)
):
if x_token != TOKEN_AGENTE:
raise HTTPException(401, "token inválido")
a = s.get(Arte, arte_id)
a.baixada = True
s.add(a)
s.commit()
return {"ok": True}
def avisar_agente():
"""Webhook para o agente na fábrica. O polling de 60s é a rede de segurança."""
import httpx
url = os.environ.get("AGENTE_WEBHOOK")
if not url:
return
try:
httpx.post(url, timeout=5, headers={"x-token": TOKEN_AGENTE})
except Exception:
pass # o polling pega
@app.get("/saude")
def saude():
return {"ok": True, "em": datetime.now(timezone.utc).isoformat()}

415
portal/preflight.py Normal file
View File

@@ -0,0 +1,415 @@
"""
Pré-flight do DTF: valida a arte, repete, fatia e carimba.
Regras acordadas com Marcus em 29/08/2026:
- unidade de venda: 57 x 100 cm
- faixa reservada no rodapé: 30 mm -> área útil 57 x 97 cm
- carimbo: QR de 20 mm + texto na mesma altura, só canal preto
- limite por arquivo gerado: 15 metros
- o carimbo vai apenas na ÚLTIMA parte do pedido
Dependências: pyvips, qrcode, pillow
apt install libvips-tools
pip install pyvips qrcode pillow
"""
from __future__ import annotations
import math
import os
import shutil
import subprocess
from dataclasses import dataclass, field
from datetime import datetime
import pyvips
import qrcode
# --------------------------------------------------------------------------
# Constantes do processo
# --------------------------------------------------------------------------
LARGURA_MAX_CM = 57.0
FAIXA_RODAPE_MM = 30.0 # reservada para o carimbo
ALTURA_UNIDADE_CM = 100.0
AREA_UTIL_CM = ALTURA_UNIDADE_CM - FAIXA_RODAPE_MM / 10 # 97 cm
DPI_MINIMO = 150 # abaixo disso recusa
DPI_IDEAL = 300 # entre 150 e 300 aceita com aviso
LIMITE_ARQUIVO_M = 20.0 # nenhum arquivo gerado passa disso
QR_MM = 20.0 # leitura por celular
ZONA_LIMPA_MM = 3.0 # margem branca ao redor do QR
FORMATOS_OK = {".png", ".tif", ".tiff", ".psd", ".psb", ".pdf", ".ai", ".svg", ".eps"}
# --------------------------------------------------------------------------
# Normalização — o cliente manda o que quiser, o designer recebe padronizado
# --------------------------------------------------------------------------
# Não existe "formato certo" único: normalizar tudo para um só jogaria fora o
# melhor de cada tipo. A regra é por NATUREZA do conteúdo.
#
# vetor -> PDF, mantendo vetor (rasterizar aqui é perder qualidade)
# pixel -> TIF com transparência (é o que a sala já usa)
#
# Isso resolve o vai-e-volta que os designers relatam hoje: abrir PDF no Corel
# para não rasterizar, exportar, abrir no Photoshop, e esbarrar no limite de
# 5 metros do PSD.
#
# O limite de 5 m NÃO é do Photoshop: é do formato PSD, que trava em 30.000 px
# por dimensão — a 150 DPI dá 5,08 m. PSB vai a 300.000 px, ou 50 metros.
# Em PDF vetorial não há esse limite; em TIF o teto é 4 GB por arquivo.
VETORIAIS = {".pdf", ".ai", ".svg", ".eps"}
RASTER = {".png", ".tif", ".tiff", ".psd", ".psb"}
FORMATOS_RECUSA = {".jpg", ".jpeg", ".gif", ".bmp", ".webp"}
# CDR entra com etiqueta "conferir": pula o pré-flight e vai direto ao designer.
# A licença do CorelDRAW é de estação, não cobre servidor processando arquivo.
FORMATOS_SEM_VALIDACAO = {".cdr"}
CM_POR_POLEGADA = 2.54
# --------------------------------------------------------------------------
# Resultado
# --------------------------------------------------------------------------
@dataclass
class Resultado:
aprovado: bool
motivo: str = "" # mensagem ao cliente quando recusado
avisos: list[str] = field(default_factory=list)
dpi_efetivo: float = 0.0
largura_cm: float = 0.0
altura_cm: float = 0.0
metros_totais: float = 0.0
conferir_manual: bool = False # CDR: entra sem validar
natureza: str = "" # vetor | raster | conferir
partes: list[str] = field(default_factory=list) # caminhos gerados
# --------------------------------------------------------------------------
# 1. Validação
# --------------------------------------------------------------------------
def validar(caminho: str, cm_comprados: float) -> Resultado:
"""
cm_comprados = altura em centímetros que o cliente pagou por UMA arte.
Ex.: comprou 1 metro -> 100.
"""
r = Resultado(aprovado=False)
ext = os.path.splitext(caminho)[1].lower()
if ext in FORMATOS_SEM_VALIDACAO:
r.aprovado = True
r.conferir_manual = True
r.avisos.append("CDR não passa pelo pré-flight — vai direto para o designer.")
return r
if ext in FORMATOS_RECUSA:
r.motivo = (
f"Arquivo {ext.upper().lstrip('.')} não guarda transparência. "
"Envie PNG ou TIFF com fundo transparente."
)
return r
if ext not in FORMATOS_OK:
r.motivo = "Formato não aceito. Envie PNG ou TIFF com fundo transparente."
return r
try:
# access sequential: lê em streaming, não carrega 200 MB na memória
img = pyvips.Image.new_from_file(caminho, access="sequential")
except Exception:
r.motivo = "Não conseguimos abrir o arquivo. Ele pode estar corrompido."
return r
# --- canal alfa ---
if img.bands < 4 or not img.hasalpha():
r.motivo = (
"A arte está sem fundo transparente. Todo fundo não removido "
"sai impresso em branco na estampa."
)
return r
# --- DPI efetivo: a regra que mais pega arquivo ruim ---
# O DPI gravado no cabeçalho mente com frequência (o cliente escala a
# imagem e o programa mantém "300"). O que vale é pixel dividido pela
# medida que ele comprou.
area_util_cm = cm_comprados - FAIXA_RODAPE_MM / 10
r.dpi_efetivo = img.height / (area_util_cm / CM_POR_POLEGADA)
r.altura_cm = area_util_cm
r.largura_cm = img.width / r.dpi_efetivo * CM_POR_POLEGADA
if r.dpi_efetivo < DPI_MINIMO:
r.motivo = (
f"A resolução real da arte é de {r.dpi_efetivo:.0f} DPI no tamanho "
f"que você comprou. Precisamos de pelo menos {DPI_MINIMO} DPI — "
"o ideal é 300. Reenvie em maior resolução."
)
return r
if r.dpi_efetivo < DPI_IDEAL:
r.avisos.append(
f"Resolução de {r.dpi_efetivo:.0f} DPI. Funciona, mas detalhes "
"finos podem perder definição."
)
# --- largura física ---
if r.largura_cm > LARGURA_MAX_CM + 0.2: # 2 mm de tolerância
r.motivo = (
f"A arte tem {r.largura_cm:.1f} cm de largura. O máximo é "
f"{LARGURA_MAX_CM:.0f} cm. Use o gabarito 57 × 97 cm."
)
return r
# --- bordas com alfa parcial (sombras suaves) ---
alfa = img[img.bands - 1]
parcial = ((alfa > 10) & (alfa < 245)).avg() / 255.0
if parcial > 0.15:
r.avisos.append(
"A arte tem muitas bordas suaves. O branco de apoio pode sair "
"irregular nessas áreas."
)
r.aprovado = True
return r
# --------------------------------------------------------------------------
# 1b. Normalização
# --------------------------------------------------------------------------
def normalizar(caminho: str, pasta_saida: str, pedido: str) -> tuple[str, str]:
"""
Converte o que o cliente mandou para o formato de trabalho da sala.
Devolve (caminho_normalizado, natureza).
O ORIGINAL É SEMPRE PRESERVADO. Se a conversão sair ruim num caso
específico, o designer volta ao arquivo do cliente — pedido dos próprios
designers, e é barato perto de refazer o trabalho.
"""
ext = os.path.splitext(caminho)[1].lower()
if ext in FORMATOS_SEM_VALIDACAO: # CDR: sem Corel no servidor
return caminho, "conferir"
if ext in VETORIAIS:
# mantém vetor: nada de rasterizar. O FlexiPRINT rasteriza na resolução
# da máquina, na hora de imprimir — melhor que qualquer caminho manual.
destino = os.path.join(pasta_saida, f"{pedido}_norm.pdf")
if ext == ".pdf":
shutil.copy(caminho, destino)
else:
# AI, SVG e EPS convertem para PDF preservando o vetor
subprocess.run(
["inkscape", caminho, "--export-type=pdf",
f"--export-filename={destino}", "--export-text-to-path"],
check=True, capture_output=True,
)
return destino, "vetor"
# raster: TIF com alfa, sem compressão com perda
destino = os.path.join(pasta_saida, f"{pedido}_norm.tif")
img = pyvips.Image.new_from_file(caminho, access="sequential")
if not img.hasalpha():
img = img.bandjoin(255)
img = _para_rgb(img)
img.tiffsave(destino, compression="lzw", predictor="horizontal")
return destino, "raster"
def _para_rgb(img: pyvips.Image) -> pyvips.Image:
"""
CMYK vira RGB. Arquivo em CMYK é uma das causas de cor errada no DTF —
o cliente monta em CMYK, a máquina trabalha em RGB, e a cor muda.
"""
if img.interpretation in ("cmyk", "b-w"):
return img.colourspace("srgb")
return img
# --------------------------------------------------------------------------
# 2. Repetição
# --------------------------------------------------------------------------
def empilhar(caminho: str, repeticoes: int, destino: str) -> str:
"""Concatena N cópias da arte na vertical, sem espaço entre elas."""
if repeticoes <= 1:
return caminho
img = pyvips.Image.new_from_file(caminho, access="sequential")
empilhado = pyvips.Image.arrayjoin([img] * repeticoes, across=1)
empilhado.write_to_file(destino)
return destino
def montar_folha(caminhos: list[str], destino: str) -> str:
"""
Junta vários arquivos numa folha só, EMPILHADOS na ordem que subiram.
Decisão de 30/08/2026: empilhamento simples, sem encaixe lado a lado.
São artes de 1 metro em sequência — não é arte única e não há otimização
de largura. O cliente vê a prévia antes de fechar o pedido.
"""
if len(caminhos) == 1:
return caminhos[0]
imgs = [pyvips.Image.new_from_file(c, access="sequential") for c in caminhos]
largura = max(i.width for i in imgs)
# centraliza as artes mais estreitas, mantendo a largura da folha
ajustadas = [
i if i.width == largura
else i.embed((largura - i.width) // 2, 0, largura, i.height)
for i in imgs
]
pyvips.Image.arrayjoin(ajustadas, across=1).write_to_file(destino)
return destino
# --------------------------------------------------------------------------
# 3. Fatiamento
# --------------------------------------------------------------------------
def quantas_partes(metros_totais: float) -> int:
return max(1, math.ceil(metros_totais / LIMITE_ARQUIVO_M))
def fatiar(caminho: str, metros_totais: float, prefixo: str) -> list[str]:
"""
Corta em partes de no máximo 15 metros.
1 m x 20 -> 15 + 5 (2 arquivos)
100 m -> 15 x 6 + 10 (7 arquivos)
"""
n = quantas_partes(metros_totais)
if n == 1:
return [caminho]
img = pyvips.Image.new_from_file(caminho, access="random")
px_por_metro = img.height / metros_totais
partes, topo, restante = [], 0, metros_totais
for i in range(n):
m = min(LIMITE_ARQUIVO_M, restante)
altura = int(round(m * px_por_metro))
altura = min(altura, img.height - topo)
saida = f"{prefixo}_p{i + 1}.png"
img.crop(0, topo, img.width, altura).write_to_file(saida)
partes.append(saida)
topo += altura
restante -= m
return partes
# --------------------------------------------------------------------------
# 4. Carimbo
# --------------------------------------------------------------------------
def _qr_vips(conteudo: str, lado_px: int) -> pyvips.Image:
qr = qrcode.QRCode(
version=None,
error_correction=qrcode.constants.ERROR_CORRECT_M, # aguenta 15% de dano
box_size=1,
border=0,
)
qr.add_data(conteudo)
qr.make(fit=True)
m = qr.get_matrix()
n = len(m)
# matriz -> imagem 1 bit -> escala para o tamanho pedido
dados = bytes(0 if v else 255 for linha in m for v in linha)
img = pyvips.Image.new_from_memory(dados, n, n, 1, "uchar")
return img.resize(lado_px / n, kernel="nearest")
def carimbar(
caminho: str,
pedido: str,
metros: float,
url_kanban: str,
dpi: float = 300.0,
quando: datetime | None = None,
) -> str:
"""
Escreve o carimbo na faixa de 30 mm do rodapé.
QR de 20 mm com a URL do card + número, metragem e data ao lado.
Só canal preto — sem branco de apoio, para não gastar a tinta mais cara.
"""
quando = quando or datetime.now()
img = pyvips.Image.new_from_file(caminho, access="random")
px_mm = dpi / CM_POR_POLEGADA / 10
qr_px = int(QR_MM * px_mm)
margem = int(ZONA_LIMPA_MM * px_mm)
faixa_px = int(FAIXA_RODAPE_MM * px_mm)
# fundo branco só sob o QR, para o celular ler com contraste
fundo = pyvips.Image.black(qr_px + margem * 2, qr_px + margem * 2) + 255
qr = _qr_vips(url_kanban, qr_px)
bloco = fundo.insert(qr, margem, margem)
# texto alinhado à altura do QR
texto = (
f"<span size='{int(qr_px*0.28)*1000}' weight='bold'>{pedido}</span>\n"
f"<span size='{int(qr_px*0.19)*1000}'>{metros:.2f} m</span>\n"
f"<span size='{int(qr_px*0.15)*1000}' foreground='#555555'>"
f"{quando:%d/%m · %H:%M}</span>"
).replace(".", ",")
txt = pyvips.Image.text(texto, dpi=int(dpi), align="low")
# base da faixa: transparente, o carimbo entra em preto
base = pyvips.Image.black(img.width, faixa_px, bands=4)
y_qr = max(0, (faixa_px - bloco.height) // 2)
base = base.insert(bloco.bandjoin(255), margem, y_qr, expand=False)
base = base.insert(
txt.bandjoin(255) if txt.bands < 4 else txt,
margem * 2 + bloco.width,
max(0, (faixa_px - txt.height) // 2),
expand=False,
)
final = pyvips.Image.arrayjoin([img, base], across=1)
saida = caminho.replace(".png", "_carimbado.png")
final.write_to_file(saida)
return saida
# --------------------------------------------------------------------------
# 5. Orquestração — é isso que o portal chama
# --------------------------------------------------------------------------
def processar(
caminho: str,
cm_comprados: float,
repeticoes: int,
pedido: str,
base_kanban: str,
pasta_saida: str,
) -> Resultado:
# 0. normaliza antes de tudo: o designer nunca mais converte à mão
caminho, natureza = normalizar(caminho, pasta_saida, pedido)
if natureza == "conferir":
r = Resultado(aprovado=True, conferir_manual=True)
r.avisos.append("CDR: sem validação automática, vai direto ao designer.")
r.partes = [caminho]
return r
r = validar(caminho, cm_comprados)
r.natureza = natureza
if not r.aprovado:
return r
r.metros_totais = cm_comprados / 100 * repeticoes
prefixo = os.path.join(pasta_saida, pedido)
empilhado = empilhar(caminho, repeticoes, f"{prefixo}_full.png")
partes = fatiar(empilhado, r.metros_totais, prefixo)
# o carimbo vai só na última parte: é ela que fecha o pedido
partes[-1] = carimbar(
partes[-1],
pedido=pedido,
metros=r.metros_totais,
url_kanban=f"{base_kanban}/p/{pedido}",
dpi=r.dpi_efetivo,
)
r.partes = partes
return r
if __name__ == "__main__":
# conferência rápida da regra de fatiamento
for m in (20, 15, 16, 45, 100):
print(f"{m:>4} m -> {quantas_partes(m)} arquivo(s)")

89
portal/tiny.py Normal file
View File

@@ -0,0 +1,89 @@
"""
Cliente da API do Tiny (Olist) — v3, OAuth2 client credentials.
ATENÇÃO Wagner: confirmar na documentação oficial antes de subir:
- o endpoint exato de marcadores do pedido
- o limite de requisições por minuto (dimensiona o worker)
- se o refresh token expira e com que frequência
Este módulo isola a API: se o endpoint mudar, muda só aqui.
"""
from __future__ import annotations
import os
import time
import httpx
BASE = os.environ.get("TINY_BASE", "https://api.tiny.com.br/public-api/v3")
CLIENT_ID = os.environ["TINY_CLIENT_ID"]
CLIENT_SECRET = os.environ["TINY_CLIENT_SECRET"]
TOKEN_URL = os.environ["TINY_TOKEN_URL"]
_token: dict = {"valor": None, "expira": 0.0}
def _acesso() -> str:
if _token["valor"] and time.time() < _token["expira"] - 60:
return _token["valor"]
r = httpx.post(TOKEN_URL, data={
"grant_type": "client_credentials",
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
}, timeout=20)
r.raise_for_status()
d = r.json()
_token["valor"] = d["access_token"]
_token["expira"] = time.time() + d.get("expires_in", 3600)
return _token["valor"]
def _headers() -> dict:
return {"Authorization": f"Bearer {_acesso()}", "Content-Type": "application/json"}
def buscar_pedido(numero: str) -> dict:
r = httpx.get(f"{BASE}/pedidos", params={"numero": numero},
headers=_headers(), timeout=20)
r.raise_for_status()
itens = r.json().get("itens") or []
if not itens:
raise LookupError(f"pedido {numero} não encontrado no Tiny")
return itens[0]
def transferido_para_deposito(sku: str, deposito: str, dias: int) -> float:
"""
Soma o que foi transferido para o depósito de impressão no período.
A Altus já faz essa transferência hoje, porque também revende insumo — o
depósito separa consumo interno de revenda. Por isso o aproveitamento sai
de graça: nenhum apontamento novo na sala.
ATENÇÃO Wagner: confirmar o endpoint de movimentações de estoque por
depósito na API v3 e o nome exato do depósito no cadastro.
"""
r = httpx.get(
f"{BASE}/estoque/movimentacoes",
params={"codigo": sku, "deposito": deposito, "dias": dias, "tipo": "E"},
headers=_headers(), timeout=30,
)
r.raise_for_status()
return sum(float(m.get("quantidade", 0)) for m in r.json().get("itens", []))
def marcador(numero: str, marcador: str) -> None:
"""
Troca o marcador do pedido. Substitui os marcadores de etapa do DTF,
preservando marcadores de outra natureza (multiempresa, VALE, Troca...).
"""
pedido = buscar_pedido(numero)
atuais = [m["descricao"] for m in pedido.get("marcadores", [])]
# só três marcadores de etapa vivem no Tiny; o resto do fluxo é do kanban
ETAPAS = {"DTF-RECEBIDA", "DTF-PRODUCAO", "CORRECAO DTF", "DTF-PRONTO"}
mantem = [m for m in atuais if m not in ETAPAS]
novos = mantem + [marcador]
r = httpx.put(f"{BASE}/pedidos/{pedido['id']}/marcadores",
json={"marcadores": [{"descricao": m} for m in novos]},
headers=_headers(), timeout=20)
r.raise_for_status()

Some files were not shown because too many files have changed in this diff Show More