Compare commits

...

79 Commits

Author SHA1 Message Date
Cauê Faleiros
ef95dcc327 fix: the page's centred blocks keep their full width in the column layout
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 2m44s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images (push) Successful in 1m2s
Making the body a column to keep the footer at the bottom shrank every
block centred with auto margins (the account area, and two of the Site's
sections) to the width of its content. They take the full width again, up
to their max-width, exactly as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:34:09 -03:00
Cauê Faleiros
0b86fe79fc fix: the footer stays at the bottom of the screen on short pages
All checks were successful
Build and deploy / Validate source (push) Successful in 7s
Build and deploy / Integration suite on a real stack (push) Successful in 2m51s
Build and deploy / Secret scan and release gate (push) Successful in 9s
Build and deploy / Publish images (push) Successful in 56s
On a short page (sign-in, an empty cart) the footer followed the content
and the rest of the screen was left blank below it, more so with the Site
drawn at 80%. The body is now a column at least the screen's height with
the footer pushed to its end; under the 80% zoom that height is 125vh,
because the zoom scales viewport units too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:12:55 -03:00
Cauê Faleiros
24ee4e9eab feat: Minha conta with sign-in, overview, orders and account details
All checks were successful
Build and deploy / Validate source (push) Successful in 6s
Build and deploy / Integration suite on a real stack (push) Successful in 3m7s
Build and deploy / Secret scan and release gate (push) Successful in 10s
Build and deploy / Publish images (push) Successful in 1m6s
The order page becomes the customer's area, one page at four addresses:

- /conta/entrar: sign in or create an account, side by side. "Esqueci
  minha senha" points to the Dropstar WhatsApp until e-mail can be sent.
- /conta: the counts of orders waiting for payment, in production, in
  correction and finished, and the latest one.
- /conta/pedidos: every order and the cart waiting for payment in one list,
  newest first, filtered by group, order number and period, ten per page.
  The cart shows "Aguardando pagamento" and its "Pagar" goes to the PIX
  page when a PIX code is open. An order opens in place with its progress,
  items, delivery, history, files and the correction form. A guest sees
  the orders paid in this browser.
- /conta/dados: WhatsApp and a saved delivery address (the CNPJ is locked),
  e-mail and password changes, both confirmed with the current password;
  a password change signs the other devices out.

The cart fills in the account's details and saved address. New API routes
for the details, and the order list takes filters and pages and returns
the counts; only the newest unpaid quote whose files still exist is listed.
The Site's "Minha conta" and "Ver meus pedidos" point to the new addresses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:57:37 -03:00
Cauê Faleiros
223854e392 feat: the order page in the Site's look, and no search box
All checks were successful
Build and deploy / Validate source (push) Successful in 4s
Build and deploy / Integration suite on a real stack (push) Successful in 2m37s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images (push) Successful in 59s
"Meus pedidos" was still the prototype's plain page. It now has the Site's
stripe, header and footer, and its type, colours and cards: each order shows
its status, a progress line through production (or the correction the team
asked for), the items with metres and price, where it goes, and on request
the history, the files to download and the correction form. Carts waiting
for payment are listed with a button to pay. Sign-in and account creation
are side by side.

The header, stripe, menu, footer and tokens moved from index.html's inline
style to site-shell.css, which both pages load. The header's search box,
which did nothing, is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:13:35 -03:00
Cauê Faleiros
0b55ebce7f style: the Site at 80% on computers, and seconds instead of "quase pronto"
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 2m43s
Build and deploy / Secret scan and release gate (push) Successful in 4s
Build and deploy / Publish images (push) Successful in 58s
The text sizes were chosen looking at the Site with the browser at 80%; at
100% everything felt too big. On screens 1100 px and wider the Site is now
drawn at 80% (CSS zoom); phones and tablets keep their sizes. The time left
while reading a large file shows the seconds under 10 s ("faltam 7 s")
instead of "quase pronto".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:39:50 -03:00
Cauê Faleiros
bee7f8f245 feat: the preview of a large file says how long is left, not a percentage
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 3m2s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images (push) Successful in 1m0s
Reading a large PNG in the browser for its preview showed "gerando a
prévia… 87%". It now shows the time left, worked out from how long the
reading has taken so far: "faltam cerca de 25 s", "faltam cerca de 3 min",
"quase pronto". The bar still fills as it goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:18:21 -03:00
Cauê Faleiros
c5db1375de test: reload only after the cart is saved, instead of after 800 ms
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 2m50s
Build and deploy / Secret scan and release gate (push) Successful in 9s
Build and deploy / Publish images (push) Successful in 1m2s
The browser suite reloaded the page a fixed 800 ms after the last field
was typed. The cart save waits for typing to stop and writes the files to
the browser's storage, so under load the reload came first and the cart was
not recovered ("persistent cart recovery", and once "Inspected target
navigated or closed"). It now waits until the cart is in storage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:55:19 -03:00
Cauê Faleiros
3d8bca3af7 feat: the upload bar says how long is left, not a notice before it
All checks were successful
Build and deploy / Validate source (push) Successful in 4s
Build and deploy / Integration suite on a real stack (push) Successful in 2m42s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images (push) Successful in 1m2s
The product page's notice about the upload time is gone. The cart's upload
bar now reads "Enviando seus arquivos · faltam cerca de 6 min", measured
from the upload's own speed (a range from the size for the first seconds),
with a line saying the time depends on the internet and to keep the page
open. The header and the pay button show the same time instead of a
percentage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:43:30 -03:00
Cauê Faleiros
f3fc733352 fix: the upload-time notice leaves with the files, and says it in one line
All checks were successful
Build and deploy / Integration suite on a real stack (push) Successful in 2m37s
Build and deploy / Validate source (push) Successful in 6s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images (push) Successful in 56s
The notice stayed on the product page after the item went to the cart,
showing the last file's size on the next order. It is now redrawn wherever
the files are cleared and hidden when no product is open. The text is one
line: "Envio: até 8 min (501,2 MB), conforme sua internet. Mantenha a
página aberta."

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:25:50 -03:00
Cauê Faleiros
4ce44feb70 fix: mark the required fields instead of a message about the address
Some checks failed
Build and deploy / Validate source (push) Successful in 6s
Build and deploy / Integration suite on a real stack (push) Successful in 2m46s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images (push) Has been cancelled
Removes "Complete o endereço de entrega para ir ao pagamento." The required
fields (CNPJ, WhatsApp, e-mail, and the delivery address except the
complement) carry a red asterisk, and aria-required for screen readers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:22:13 -03:00
Cauê Faleiros
01b94da36d feat: choose which cart items to pay, with a thumbnail of each
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 3m22s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images (push) Successful in 1m13s
Each cart item has a checkbox and a thumbnail of its first file (or its
extension when there is none). Only checked items are priced, quoted,
uploaded and paid; an unchecked item stays in the cart, dimmed, for a later
order, and stays there after the others are paid. The header count is the
whole cart; the summary counts what is being paid.

Also removes the "A conta nasce aqui…" and "Carrinho salvo neste
navegador…" texts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:57:16 -03:00
Cauê Faleiros
25fa890e92 style: one more step up for the text and icons
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 2m36s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images (push) Successful in 1m6s
Every Site text size up to 18 px grows another pixel, the Kanban's up to
14 px half a pixel, and the icons one pixel. The pay button grows to two
lines when "Pagar os N itens de uma vez" no longer fits on one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:37:20 -03:00
Cauê Faleiros
4386fc0737 style: larger text across the Site and the Kanban, and icons to match
All checks were successful
Build and deploy / Validate source (push) Successful in 6s
Build and deploy / Integration suite on a real stack (push) Successful in 2m47s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images (push) Successful in 1m5s
Most of the text was 11 to 13 px and hard to read. Every size up to 16 px
grows: by 1.5 px up to 12.5, 1 px up to 14 and 0.5 px up to 16 on the Site,
and a little less on the denser Kanban. Headings and prices keep their size.
Icons grow by 2 px so they stay in proportion with the text beside them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:26:00 -03:00
Cauê Faleiros
70a76feb23 feat: icons on the buttons, delivery, payment, status and Kanban
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 2m40s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images (push) Successful in 53s
- Buttons: add to cart (bag with plus), go to payment (lock), remove from
  the cart (bin).
- Delivery: a pin for pickup in Franca, a truck for home delivery.
- Payment: card and QR code on the methods, copy on the PIX code, a clock on
  its countdown, a check on the confirmation.
- Status: a check when the files are sent, check, alert, redo and cross in
  the quality list instead of Unicode symbols, and a check on finished steps.
- Kanban: a truck or pin for where an order goes, a speech bubble on the
  WhatsApp link, a download arrow on the file buttons.

All Lucide shapes drawn inline, as in the header.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:13:45 -03:00
Cauê Faleiros
20982a945f feat: icons in the header and on the Montagem controls
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 2m50s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images (push) Successful in 1m18s
The header's search, account and cart get icons, with the item count on the
cart instead of "Carrinho (N)"; on phones only the icons show. The upload
zone, remove, rotate and mirror buttons used Unicode symbols that look
different, or are missing, depending on the system's fonts; they are now
Lucide icons (ISC licence) drawn inline, so nothing new is loaded and the
CSP is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:56:01 -03:00
Cauê Faleiros
37715ef223 feat: the server checks the grade against the files before approving
All checks were successful
Build and deploy / Validate source (push) Successful in 1m23s
Build and deploy / Integration suite on a real stack (push) Successful in 3m13s
Build and deploy / Secret scan and release gate (push) Successful in 10s
Build and deploy / Publish images (push) Successful in 1m25s
The price depends on the grade, which the browser worked out and the API
took on trust. Before a cart is approved at checkout the API now recomputes
it from the uploaded files by the Site's own rules: the pixel size in a
PNG, JPG or WebP header across the printed width (rotation included), and
the area-weighted DPI of the images placed in a PDF of up to 150 MB, 300
for vectors. Sheets take the worst grade, artworks the average. A claim more
than 2 points above the file's grade, or a discount on a file the server
cannot grade, waits for an operator, with the reason on the Kanban.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:24:37 -03:00
Cauê Faleiros
b7e2ea12d0 feat: accept large JPG and WebP artworks instead of refusing them
Above 150 MB the browser cannot read JPG or WebP in parts, so a large
artwork was refused. It is now measured from its header, graded and priced
with the discount like any other, and placed on the sheet as a full box,
which is exact for JPG. The card says there is no preview and that the team
checks the art before printing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:14:04 -03:00
Cauê Faleiros
933bd30cbd feat: an unpaid cart's files are kept 2 days, a paid order's 30
Files are uploaded before payment so the price and the security check use
the file itself, but an abandoned cart kept them for 30 days. Now a finished
upload is held 2 days, a quote waiting for review 7, an approved quote 2 more
to be paid, and the paid order keeps its originals for 30 days from upload.
A payment never starts for files that are gone; one under way holds them a
day. Files attached to an order take the order's window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:09:02 -03:00
Cauê Faleiros
eb254da501 feat: tell the customer how long the upload takes before it starts
When files are picked, the product page shows the range the upload takes
between a slow (10 Mbps) and a fast (150 Mbps) connection, and asks to keep
the page open. The cart shows the same range until the real speed is
measured, then the measured time left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:09:02 -03:00
Cauê Faleiros
8786a33c8d fix: the cart keeps only its items, never the customer's details
The browser put the CNPJ, WhatsApp, e-mail, CEP and address back after a
reload without telling the page, which then showed them but could not pay
until they were typed again. The fields are now emptied on load and marked
autocomplete="off", and the saved cart holds only the items: the details and
address are typed again after a reload or a closed tab. The payment page
already takes them from the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 09:23:18 -03:00
Cauê Faleiros
20403c5132 feat: daily encrypted database backup to a bucket of its own
All checks were successful
Build and deploy / Validate source (push) Successful in 8s
Build and deploy / Integration suite on a real stack (push) Successful in 3m38s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images (push) Successful in 1m2s
A backup service runs pg_dump every day at 03:00 Brasília, checks the archive,
encrypts it with age to a public key and uploads it with a token for that
bucket only. The server cannot read or delete backups: the private key stays
with the owner, the bucket's lifecycle rule expires copies and its lock stops
early deletion. Each run is recorded and shown on the Kanban's Integrations
tab. tests/backup_test.py backs up, restores into a scratch database and
compares the rows in CI. Setup and restore: docs/BACKUP.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 18:49:21 -03:00
Cauê Faleiros
1b57313496 feat: give Artes avulsas the file card of Folha já montada
All checks were successful
Build and deploy / Validate source (push) Successful in 10s
Build and deploy / Integration suite on a real stack (push) Successful in 2m49s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images (push) Successful in 54s
Each artwork gets the same card as a finished sheet: "Suas artes" with the
count of artworks and copies, a thumbnail, the size in cm with pixels,
format and weight, a DPI chip and how many fit per row, the resolution or
width hint, then width in cm, a copies stepper, rotate and mirror, and the
usual sizes. Packing, pricing and the hints are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:49:22 -03:00
Cauê Faleiros
a02711ef2b fix: keep every Dúvidas link in its row, one answer open at a time
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 2m24s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images (push) Successful in 52s
Opening a question moved its link out of the row into the answer, and only
that heading closed it again. The four questions are now a row of links
that always stays: the open one is highlighted, its answer shows below,
another link switches to its answer and the same link closes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:55:25 -03:00
Cauê Faleiros
8dddf0fa25 feat: redesign the Montagem page, with thumbnails, check chips and a DPI price scale
All checks were successful
Build and deploy / Validate source (push) Successful in 6s
Build and deploy / Integration suite on a real stack (push) Successful in 2m31s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images (push) Successful in 1m0s
The product page is rebuilt around mockup 1. The two ways to send are
cards under the title; the PNG/CDR route choice is gone (it never changed
the price, and the picker now takes every allowed format). Each file card
has a thumbnail, its size, check chips with a plain sentence for any
problem, a "Ver detalhes da conferência" link and a copies stepper; a CDR
file says "Conferência manual · preço cheio" and asks for the length. The
buy box shows the price scale in DPI with the customer's step, the metre
and 10 cm length, the total and what the resolution saved, and "Sai em N
partes" only above 20 m. Folha já montada gets "Ampliar" instead of zoom.
Pricing, grade and checks are unchanged.

Fixes on top: the card thumbnail is a small image made once, not the full
preview re-parsed on every repaint; sizes from 1 GB up read in GB; a failed
upload is shown with "Tentar de novo" instead of retrying silently on every
cart change. The browser suites wait for the product and the reloaded page
instead of racing them, and the artwork suite delays imagemDaArte, which
the packing now uses, fixing the CI failure of f4aacb5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:36:41 -03:00
Cauê Faleiros
f4aacb5776 feat: pack large artworks in Artes avulsas from a streamed copy
Some checks failed
Build and deploy / Validate source (push) Successful in 6s
Build and deploy / Integration suite on a real stack (push) Failing after 2m36s
Build and deploy / Secret scan and release gate (push) Successful in 8s
Build and deploy / Publish images (push) Has been skipped
Artes avulsas decoded every file whole, and the packing decoded each one
again on every repaint, so a large artwork failed to load. A PNG over 150
MB is now measured from its header (pixel size, so DPI and grade stay
exact) and packed from a 1200-pixel copy read as a stream, with progress on
its card; every artwork's image is loaded once and reused by the packing.
A large JPG or WebP asks for a PNG, and the grade card no longer calls a
file unreadable while it is still being read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:02:35 -03:00
Cauê Faleiros
64e47ee481 feat: skeletons where the page waits, no flicker while a sheet is read, repeated copies in the preview
All checks were successful
Build and deploy / Validate source (push) Successful in 6s
Build and deploy / Integration suite on a real stack (push) Successful in 2m30s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images (push) Successful in 57s
Reading a large sheet repainted the list and "Sua folha" on every progress
step, so the preview flickered; progress now moves the bar in place and
"Sua folha" shows a skeleton until the preview is ready. A sheet printed
N times is shown N times (up to six, then a count). The payment page shows
skeletons while the order loads and until the card form is ready.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 15:52:58 -03:00
Cauê Faleiros
2b313a1cc8 feat: show upload progress with a bar, time left and a header badge
All checks were successful
Build and deploy / Validate source (push) Successful in 9s
Build and deploy / Integration suite on a real stack (push) Successful in 2m27s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images (push) Successful in 45s
Uploads of several GB ran behind one grey line in the cart. The order
summary now has a progress bar with the percentage, size and an estimate of
the time left from the recent speed; the header shows "enviando 45%" on
every page while it runs; the payment button says what it is waiting for;
and leaving the page mid-upload asks first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 15:34:57 -03:00
Cauê Faleiros
7116ebe50a feat: preview and check large PNG sheets by reading them as a stream
All checks were successful
Build and deploy / Validate source (push) Successful in 6s
Build and deploy / Integration suite on a real stack (push) Successful in 2m33s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images (push) Successful in 1m0s
A large sheet had no preview and no automatic check of pieces, residue,
gaps and background, because decoding it whole would crash the browser.
A PNG over 150 MB is now inflated as a stream (DecompressionStream) and
unfiltered row by row, keeping every n-th pixel: a 600-pixel-wide copy in
about 100 MB of memory whatever the file's size (6.6 s for 500 MB, 22.7 s
for 2.4 GB of random pixels, identical to Pillow's output for RGB, RGBA,
grey, grey+alpha, palette and 16-bit). The copy is the preview and what
the sheet check reads; the grade still comes at once from the header.
The preview note promising a server-side re-nesting that does not exist
is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 15:25:13 -03:00
Cauê Faleiros
9e69c48d2d feat: let operators create test orders from approved quotes
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 2m28s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images (push) Successful in 47s
Without an approved card payment there was no way to try the board, the
files and the print flow in production. "Criar pedido de teste" on an
approved quote creates the order through the same path as a paid one, marked
TESTE on its card and panel and audited; neither it nor its stage moves
queue anything for Tiny or WhatsApp.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:50:40 -03:00
Cauê Faleiros
64c1260a9f feat: report Mercado Pago's notifications in the account check
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 2m16s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images (push) Successful in 45s
Without access to the Mercado Pago panel, "Verificar conta" now also says
how many payment notifications arrived and passed the signature in the last
24 hours, how many were refused by it, and the last one received: the PIX
payments created in test mode notify the webhook, so this shows whether
Mercado Pago reaches the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:35:47 -03:00
Cauê Faleiros
b5bb03cbb4 feat: check the Mercado Pago account from the Kanban
All checks were successful
Build and deploy / Validate source (push) Successful in 4s
Build and deploy / Integration suite on a real stack (push) Successful in 2m18s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images (push) Successful in 44s
"Verificar conta" on the Mercado Pago card of the Integrations tab runs the
read-only account check (whether the token is a test user's, the card
methods, how the test card is classified) without a container console.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:18:45 -03:00
Cauê Faleiros
2495edf188 fix: keep the payment page's styles off Mercado Pago's card form
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 2m22s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images (push) Successful in 47s
The rule sizing the PIX code field applied to every input in the payment
area, turning the card form's instalment radios into tall boxes; the
heading rule likewise reached the form's own headings. Both now apply only
to the page's own elements.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:14:06 -03:00
Cauê Faleiros
f5fed013ab fix: ask 3-D Secure of debit cards only, and send the cardholder as the card payer
All checks were successful
Build and deploy / Validate source (push) Successful in 4s
Build and deploy / Integration suite on a real stack (push) Successful in 2m13s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images (push) Successful in 1m9s
A credit card payment with the test credentials was refused with 10113
("the payment method is excluded by a rule"). Every card was sent with
three_d_secure_mode, which only debit needs, and with the order's CNPJ as
payer instead of the cardholder's document from the card form. Debit
methods keep 3-D Secure, and the card form's document is the payer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:57:19 -03:00
Cauê Faleiros
593ddf82c8 feat: add a read-only Mercado Pago account probe
All checks were successful
Build and deploy / Validate source (push) Successful in 4s
Build and deploy / Integration suite on a real stack (push) Successful in 2m3s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images (push) Successful in 45s
Card payments with the test credentials are refused with 10111 and 10113,
which depend on the account behind the token. python -m
app.mercadopago_probe shows that account (and whether it is a test user),
the card methods it accepts, and how Mercado Pago classifies a card's first
digits: type, issuer and instalments. It creates and charges nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:47:39 -03:00
Cauê Faleiros
8b5aafa299 fix: let Mercado Pago take the card issuer from the card number
All checks were successful
Build and deploy / Validate source (push) Successful in 4s
Build and deploy / Integration suite on a real stack (push) Successful in 2m9s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images (push) Successful in 50s
Paying with Mercado Pago's own test card failed with 10111 ("the issuer
does not have the BIN configured"): the issuer_id suggested by the card
form did not match the card. issuer_id is optional, and without it Mercado
Pago resolves the issuer from the BIN.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:37:16 -03:00
Cauê Faleiros
e47f88a6d6 test: expect the 5 GB limit in the browser suite, and grade a large sheet from its header
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 2m16s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images (push) Successful in 51s
The artwork suite still asserted the old 128 MB refusal. It now refuses a
file above 5 GB and checks that a 3 GB sheet is graded from the pixel size
in its PNG header (300 DPI, 3 m) without being decoded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:31:21 -03:00
Cauê Faleiros
5f2be7ea20 feat: accept sheets of up to 5 GB end to end
Some checks failed
Build and deploy / Validate source (push) Successful in 12s
Build and deploy / Integration suite on a real stack (push) Failing after 2m36s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images (push) Has been skipped
Sheets of several GB are the normal order. The upload limit is now 5 GB.
ClamAV scans files up to 2 GB; a larger file is released only when its
first bytes match the format its name claims, and a disguised file is
refused. The Site grades a sheet over 150 MB from the pixel size in its
PNG, JPEG or WebP header without decoding it, and reads large PDFs in
ranges. The worker never opens a source over 300 MB: a finished sheet
placed whole becomes its own print file, which the Kanban offers to approve
as the final, and anything else goes to hand preparation. Files start
uploading as they enter the cart, with progress in the summary, and each
part renews the reservation so slow uploads do not expire. Quotas grow to
50 GB per customer and 500 GB in total; the Swarm config for ClamAV is
renamed because a deployed config cannot change in place.

Verified locally with a 386 MB and a 1.8 GB PNG (scanned, paid, original
as print file), a 2.3 GB PNG (format check) and a disguised 2.3 GB file
(refused).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:18:18 -03:00
Cauê Faleiros
4437232d27 fix: show why a card payment failed instead of leaving the form spinning
All checks were successful
Build and deploy / Validate source (push) Successful in 7s
Build and deploy / Integration suite on a real stack (push) Successful in 3m0s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images (push) Successful in 56s
When creating the card payment failed, the form's onSubmit rejected with no
message and Mercado Pago's button kept spinning. The page now shows the
reason above the form. A payment Mercado Pago refuses is logged with its
status, message and cause codes (payment_intent_refused) and answered 422
with that reason; other failures log their type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 11:58:56 -03:00
Cauê Faleiros
8bc57195e8 feat: open the customer's WhatsApp from the order panel
All checks were successful
Build and deploy / Validate source (push) Successful in 4s
Build and deploy / Integration suite on a real stack (push) Successful in 2m13s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images (push) Successful in 43s
The number in the Kanban's order panel is now a formatted wa.me link, so a
correction or a question about the artwork can be sent by hand in one
click.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 11:51:15 -03:00
Cauê Faleiros
0a575a3be7 fix: drop the repeated total from the PIX instructions
All checks were successful
Build and deploy / Validate source (push) Successful in 7s
Build and deploy / Integration suite on a real stack (push) Successful in 2m19s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images (push) Successful in 50s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 11:21:57 -03:00
Cauê Faleiros
15abd589a8 ci: download the vulnerability database once, and tell a failed scan from a finding
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 2m22s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images (push) Successful in 55s
The CRITICAL scan of dtf-api failed on a 404 from the database mirror and
was reported as a CRITICAL vulnerability. The image step now downloads the
database once into a cache volume, with three attempts, scans all four
times from it, and exits 5 only on findings: a scan that does not run fails
with its own message, and nothing is published unscanned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 11:10:58 -03:00
Cauê Faleiros
32c060e1b0 fix: hide the card form's own title on the payment page
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 2m39s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images (push) Successful in 1m50s
Mercado Pago's form titled both card options "Cartão de crédito ou débito";
the option above it already names the card.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 11:04:37 -03:00
Cauê Faleiros
6f5d183365 fix: show the real freight status on the Kanban
Some checks failed
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 2m16s
Build and deploy / Secret scan and release gate (push) Successful in 18s
Build and deploy / Publish images (push) Failing after 1m25s
The Integrations tab always said freight was not configured. It now reads
the server's adapter and, with Jadlog on, shows the package weight rule and
the production days the Site prices with.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 10:57:34 -03:00
Cauê Faleiros
2215da8d5e fix: centre the arrow in the upload box; record that CDR/AI/PSD stay manual
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 2m24s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images (push) Successful in 1m43s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 10:49:11 -03:00
Cauê Faleiros
88e65290e1 fix: quote freight on its own, and drop the Calcular button
All checks were successful
Build and deploy / Validate source (push) Successful in 6s
Build and deploy / Integration suite on a real stack (push) Successful in 2m20s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images (push) Successful in 1m46s
Going back to home delivery with a CEP already typed, or restoring a saved
cart, now quotes the freight again by itself; before, only pressing Calcular
did. With every case covered, the button is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 10:40:55 -03:00
Cauê Faleiros
690b15ece1 feat: fill the delivery address from the CEP and quote freight as it is typed
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 2m21s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images (push) Successful in 1m47s
A complete CEP now looks up its address on the server (ViaCEP, rate
limited, keeping the Site's CSP to its own origin) and fills street,
district, city and state, moving the cursor to the first field left, and
quotes the freight without pressing Calcular. The CEP keeps its mask when
the cart is restored. Freight and its delivery time appear in the order
summary instead of a line under the CEP, the delivery option says it ships
with Jadlog, and the address reminder is a hint rather than an error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 10:31:41 -03:00
Cauê Faleiros
e20c8673bb fix: send the Jadlog account exactly as configured
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 2m19s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images (push) Successful in 1m33s
Jadlog issues the account as 000000-0 and documents a six-character field;
stripping the dash sent seven digits. The value now goes as typed, so the
accepted form can be found on the account without a new release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 10:00:44 -03:00
Cauê Faleiros
4de2151e9a feat: price home delivery with Jadlog
All checks were successful
Build and deploy / Validate source (push) Successful in 1m18s
Build and deploy / Integration suite on a real stack (push) Successful in 2m33s
Build and deploy / Secret scan and release gate (push) Successful in 9s
Build and deploy / Publish images (push) Successful in 1m42s
FREIGHT_ADAPTER=jadlog prices "Receber em casa" through Jadlog's Simulador
de Frete from the order's billed metres and value, adding production days
to Jadlog's delivery time. The package weight is a base plus a weight per
metre from the client, with no default: the adapter refuses to start
without it and without the credentials. The cart re-quotes when the package
changes, and approval quotes again from the server-priced items. The
production stack takes the Jadlog settings, so the read-only probe runs
from the worker's console.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 09:34:01 -03:00
Cauê Faleiros
641aafc87d feat: PIX codes expire after 30 minutes, with a countdown
All checks were successful
Build and deploy / Validate source (push) Successful in 11s
Build and deploy / Integration suite on a real stack (push) Successful in 2m50s
Build and deploy / Secret scan and release gate (push) Successful in 8s
Build and deploy / Publish images (push) Successful in 1m54s
A PIX is created with a 30-minute date_of_expiration, and the PIX page
counts down to it. When it runs out the page says the code expired and
offers a new one. The API keeps one open code per quote: the same code
until it expires, then exactly one new attempt (serialised per quote, with
its own idempotency key), the old one marked expired.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 15:40:26 -03:00
Cauê Faleiros
875a7ef9c7 fix: drop the change-method button from the PIX page
All checks were successful
Build and deploy / Validate source (push) Successful in 6s
Build and deploy / Integration suite on a real stack (push) Successful in 2m10s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images (push) Successful in 1m37s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:18:18 -03:00
Cauê Faleiros
c436936fed fix: drop the confirmation-time promise from the PIX note
Some checks failed
Build and deploy / Validate source (push) Successful in 6s
Build and deploy / Integration suite on a real stack (push) Successful in 2m3s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:15:08 -03:00
Cauê Faleiros
0ed6de4bd5 fix: shorten the PIX note on the payment page
Some checks failed
Build and deploy / Validate source (push) Successful in 8s
Build and deploy / Publish images (push) Has been cancelled
Build and deploy / Secret scan and release gate (push) Has been cancelled
Build and deploy / Integration suite on a real stack (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:13:20 -03:00
Cauê Faleiros
eae3dad306 feat: offer credit card, debit card and PIX, with the bank's 3-D Secure step
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 2m3s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images (push) Successful in 1m31s
The payment page lists credit card (preselected), debit card and PIX. Each
card option limits Mercado Pago's form to its kind; debit is paid at once.
Card payments ask for 3-D Secure when the issuer requires it, and a
challenge opens the bank's page in a frame, which needs
PAYMENT_CHALLENGE_SOURCES=https: (frames and form posts only). A card left
waiting for that confirmation stops blocking a new attempt after ten
minutes, and a refusal reported by the notification returns the customer to
the payment choice. Written from the documentation; not yet run with a real
debit card.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:24:06 -03:00
Cauê Faleiros
7b6675d4d4 feat: two-column payment page with card by default and PIX on its own page
All checks were successful
Build and deploy / Validate source (push) Successful in 7s
Build and deploy / Integration suite on a real stack (push) Successful in 2m19s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images (push) Successful in 1m38s
The payment page puts paying on the left and the order summary on the
right (above it on phones). Card is preselected and paid on the page with
Mercado Pago's form, in the Site's colours and with the order's e-mail;
choosing PIX shows a Pagar button that opens /pagamento/pix with the QR code
and copy-and-paste code, waiting there for the confirmation. A confirmed
payment shows "Pagamento confirmado" with the order number and a button to
the customer's orders. The payment buttons no longer restyle every button
inside the form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:10:05 -03:00
Cauê Faleiros
4df74221d2 fix: drop the validated-total line from the payment page
All checks were successful
Build and deploy / Validate source (push) Successful in 8s
Build and deploy / Integration suite on a real stack (push) Successful in 2m10s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images (push) Successful in 1m44s
The summary above already shows the total; the line stays only beside the
local stack's simulated payment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:15:00 -03:00
Cauê Faleiros
43043c361c feat: give payment its own page
All checks were successful
Build and deploy / Validate source (push) Successful in 6s
Build and deploy / Integration suite on a real stack (push) Successful in 2m12s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images (push) Successful in 1m35s
The PIX and card choices appeared under the cart, on the same page as the
customer's details. "Ir para o pagamento" now sends the order and opens
/pagamento, step 3 of the progress bar: the server's order summary, then PIX
or card, each opening below. The cart keeps only the sending progress and its
errors; a changed cart is sent again instead of offering the old quote.
Portal links open the payment page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:45:44 -03:00
Cauê Faleiros
536510b148 fix: version the Site's scripts by content so a release never meets a cached old one
All checks were successful
Build and deploy / Validate source (push) Successful in 7s
Build and deploy / Integration suite on a real stack (push) Successful in 2m9s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images (push) Successful in 1m44s
The proxy in front of production caches .js and .css for hours. After the
last release the Site got the new index.html with the old site-flow.js,
which wrote to an element the new page no longer has; the error left
"Adicionar ao carrinho" disabled. The web build now addresses every local
script and stylesheet by a hash of its content, replacing the hand-kept
?v= markers, so a new release always loads its own files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:27:55 -03:00
Cauê Faleiros
a1877bdf0a fix: remove the pickup and invoice notes from checkout; document R2 CORS
All checks were successful
Build and deploy / Validate source (push) Successful in 6s
Build and deploy / Integration suite on a real stack (push) Successful in 2m15s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images (push) Successful in 1m41s
The pickup notice under the delivery options and the invoice and retention
note under the purchase summary are gone. PORTAINER.md records the R2 CORS
policy the browser's direct uploads need: without it the preflight is
refused and checkout fails with a NetworkError before payment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:10:52 -03:00
Cauê Faleiros
275ebf72c4 feat: approve priced carts at checkout so customers can pay at once
All checks were successful
Build and deploy / Validate source (push) Successful in 8s
Build and deploy / Integration suite on a real stack (push) Successful in 2m22s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images (push) Successful in 1m34s
Every quote waited for an operator before it could be paid, so an order
placed at night waited for the morning. A cart the Site priced is now
approved when the quote is created, through the same server pricing the
operator's approval uses (app/quote_review.py). Orders above
QUOTE_AUTO_MAX_METRES (50 m) and items claiming a discount on art the Site
could not analyse still wait for review; the Kanban shows which quotes were
approved automatically and why the others wait.

The grade is still computed in the browser (roadmap 3.2, 3.9), so the
discount remains a customer-supplied value until the server computes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:02:56 -03:00
Cauê Faleiros
9bea187ea4 feat: add a read-only Jadlog price probe
All checks were successful
Build and deploy / Validate source (push) Successful in 1m20s
Build and deploy / Integration suite on a real stack (push) Successful in 2m49s
Build and deploy / Secret scan and release gate (push) Successful in 10s
Build and deploy / Publish images (push) Successful in 1m51s
app/jadlog.py prices one package through Jadlog's Simulador de Frete as the
API manual v2.3 describes it; app.jadlog_probe prices test weights to six
regions on the client's account to confirm token, account and contract.
Tested against a fake transport. The roadmap records the Jadlog data and the
Mercado Pago account setup.

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:07:58 -03:00
Cauê Faleiros
aec5b1d054 feat: send order situações to Tiny for the client's WhatsApp notices
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 2m5s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images (push) Successful in 1m45s
The client already sends WhatsApp notices from Tiny's order situação
(Tiny webhook -> middleware -> n8n). With TINY_STATUS_UPDATES on, a paid
order is set to "Aprovada" once and a finished pickup order to "Pronto
para envio"; pickup orders carry the client's pickup forma de envio
(TINY_FORMA_ENVIO_RETIRADA). The ready event now carries the order and
the Tiny id from the sale's receipt. Off by default until go-live, when
n8n stops sending the DTFIMP designer message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:17:00 -03:00
Cauê Faleiros
8b42e684ca docs: record the Tiny account and product findings
All checks were successful
Build and deploy / Validate source (push) Successful in 10s
Build and deploy / Integration suite on a real stack (push) Successful in 2m46s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images (push) Successful in 1m46s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:40:28 -03:00
Cauê Faleiros
e768dcb489 feat: keep the Tiny connection alive and show when it is not
All checks were successful
Build and deploy / Validate source (push) Successful in 8s
Build and deploy / Integration suite on a real stack (push) Successful in 2m29s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images (push) Successful in 1m49s
Connecting now asks for offline_access, retrying once without it if Tiny
refuses the scope. Renewal failures are stored: a refused refresh token
marks the connection lost and is not sent again (the Kanban previously
still said "conectado"), a transient failure shows as a warning until the
next renewal, and a session grant with under 12 hours left is flagged.
Tiny errors on the callback return to the Kanban instead of a 422.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:04:23 -03:00
Cauê Faleiros
122c645f72 fix: stop Site timers from running after a product is closed
The grade check and the layout preview run on short timers. When the
item went to the cart inside that window, no product was open and both
threw in the customer's browser, which also failed the browser tests
intermittently. Each now returns when no product is open. The cart test
waits for the empty state, which is painted on the next animation frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:04:23 -03:00
Cauê Faleiros
988b252f9d feat: check Tiny products and add a supervised order test
All checks were successful
Build and deploy / Validate source (push) Successful in 11s
Build and deploy / Integration suite on a real stack (push) Successful in 2m39s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images (push) Successful in 1m43s
"Testar conexão" now also reads the four configured Tiny products and
requires each to be active. app/tiny_probe.py runs from the worker console
to list products, confirm the configured ids, and create one marked test
order through the worker's own delivery path, proving the duplicate guard
by search before a second delivery. Nothing is sent without --confirmar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 10:23:07 -03:00
Cauê Faleiros
56021d8451 docs: add the operator and Site guides and bring the roadmap up to date
All checks were successful
Build and deploy / Validate source (push) Successful in 1m39s
Build and deploy / Integration suite on a real stack (push) Successful in 2m32s
Build and deploy / Secret scan and release gate (push) Successful in 10s
Build and deploy / Publish images (push) Successful in 1m58s
The operator guide covers the Kanban flow from quote review to finished
order; the Site guide walks through the customer journey, prices and the
current state of each integration. The roadmap records the Kanban and Site
redesigns, the guides, and what is left for the last day of Week 2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 09:07:50 -03:00
Cauê Faleiros
b6a90411f1 feat: let customers remove items and empty the cart, with undo
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 2m2s
Build and deploy / Secret scan and release gate (push) Successful in 4s
Build and deploy / Publish images (push) Successful in 1m33s
Each cart item has a visible "Remover" button instead of a faint ×, and
carts with two or more items get "Esvaziar carrinho". Both show a
"Desfazer" notice for 8 seconds, so a wrong click costs nothing. The
browser test covers remove and undo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 18:18:10 -03:00
Cauê Faleiros
cbbbdb351a feat: rebuild the Site product page around a buy box
All checks were successful
Build and deploy / Validate source (push) Successful in 10s
Build and deploy / Integration suite on a real stack (push) Successful in 2m46s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images (push) Successful in 1m46s
Artworks on the left; on the right a box that stays in view with the live
sheet, the grade, the price per metre, the metres charged, the total, the
resolution note and "Adicionar ao carrinho". The separate quality and
preview panels, the second sheet preview, the per-row mini sheets, the
summary box and the repeated findings list are gone: each piece of
information now appears once.

Each artwork row carries at most one hint (resolution first, otherwise a
width that saves film), the ready-sheet/loose-artwork choice is a toggle
beside the title, the upload area is one bar and the tips are collapsed.
The box only shows the item the page already priced, so it always matches
the cart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 18:02:45 -03:00
Cauê Faleiros
b81ff8d03d feat: give each Site product and the cart its own page
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 2m28s
Build and deploy / Secret scan and release gate (push) Successful in 8s
Build and deploy / Publish images (push) Successful in 1m52s
The home, each product's Montagem and the cart now have their own
addresses (/artes-avulsas, /arquivo-por-metro, /uv-artes-avulsas,
/uv-arquivo-por-metro, /carrinho) and show only their own content, with
Back, Forward, reload and direct links working as in any store. They stay
one document so uploaded artworks survive moving between pages; nginx
serves index.html for these addresses.

"Adicionar ao carrinho" puts the item in the cart and opens it, and an
empty cart says so. Portal quote links open in the cart. Also fixes the
"57 cm" line break on the ready-sheet option, returns "Novo pedido" to
the home, and says PDF depends on the product.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 16:56:58 -03:00
Cauê Faleiros
177882e77b feat: redesign the Site order flow and fix the cart layout
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 2m23s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images (push) Successful in 1m48s
New landing (hero with a sheet preview, four steps, product cards priced
from the checkout table, price table and benefits), a progress bar that
follows the order through Montagem, Dados e entrega and Pagamento, the
live sheet beside the artworks, and a running total bar on phones.

The cart's saved-in-browser note no longer takes a grid column, which had
pushed the order into a narrow strip and the summary below it. The
previous look is kept in tag ui-v1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 16:33:06 -03:00
Cauê Faleiros
2e03b0362b feat: undo mistaken moves, numbered pagination, and quieter Kanban messages
All checks were successful
Build and deploy / Validate source (push) Successful in 6s
Build and deploy / Integration suite on a real stack (push) Successful in 2m33s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images (push) Successful in 1m48s
Moves: an order can go back one stage (BACK in app/runtime.py) with an
internal reason, flagged in the history as movements.back. The customer is
not notified and approved finals stay; "production started" and "ready" are
now enqueued once per order, so undoing and redoing a move sends nothing
twice. Dragging only goes forward and highlights the allowed column. Move
errors are in Portuguese.

Lists: the send log, payments (open, resolved as history, all) and quotes
are paged on the server with a total, 20 rows by default (10/20/50/100),
first/previous/page/next/last. The send log filters by destination, status,
event and order. Older finished orders load on demand. The board no longer
carries the send log or payment rows, only the open-payment count.

Kanban: Pagamentos and Integrações are separate tabs; messages are brief,
bottom notifications that clear themselves; wording is shorter.

Full CI integration sequence passes locally, with new checks for undo, paging
and filters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 16:01:31 -03:00
Cauê Faleiros
99a558ddd9 feat: redesign the Kanban and keep test wording out of production
All checks were successful
Build and deploy / Validate source (push) Successful in 11s
Build and deploy / Integration suite on a real stack (push) Successful in 3m3s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images (push) Successful in 1m51s
Kanban: tabs for production, quote review and payments/integrations; compact
cards with products, metres, print-file status, delivery and time in stage;
an order panel with stage progress, one main action, a correction reason in
place, items with a preview drawn from the approved layout, final-file
approval and the history as a timeline. Quote review gets a list and a pane;
payment issues resolve in place; integrations show their real state, Tiny's
connection with a read-only "Testar conexão", and a readable send log. The
previous Kanban is kept in git tag ui-v1 and is no longer served.

Production wording: the customer portal no longer says it is a local test
environment outside the local stack; the checkout no longer tells customers
to use the Kanban or shows internal stage codes; sign-in, session, quota and
print-file messages are Portuguese and never say "local". A simulated freight
price is refused outside the local stack until a real freight provider
exists, so production only offers pickup.

The browser suite drives the new tabs and panel and still checks the whole
upload, quote, payment and production journey. Full CI sequence passes locally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:28:04 -03:00
Cauê Faleiros
641dc6053b ci: publish images from every green push to main
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 2m5s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images (push) Successful in 1m54s
The release job enforced the production source preflight, which blocks while
the payment and messaging adapters are fake. They still are, by design, so
since 2026-09-23 no release could succeed and production kept running older
images while main moved on.

Pushes to main that pass validation, the integration suite and the scans now
build, scan and publish the images. Nothing is deployed automatically:
production changes when the stack is pulled and redeployed in Portainer. A
manual run also calls the Portainer webhook when one is configured. The
preflight stays in the scan job, advisory unless ENFORCE_PRODUCTION_PREFLIGHT
is true, in which case a blocked preflight stops publishing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 14:30:43 -03:00
Cauê Faleiros
4c01e932c3 feat: place PDF artwork in print files, add card payment, count only failed logins
All checks were successful
Build and deploy / Validate source (push) Successful in 6s
Build and deploy / Integration suite on a real stack (push) Successful in 2m23s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images and notify Portainer (push) Has been skipped
PDF artwork: a single-page PDF source is placed in the print file as a
vector form through pikepdf, never rasterised, using the CropBox and
inherited /Rotate the Site measured with pdf.js. Multi-page and protected
PDFs go to hand preparation. PyMuPDF was not used because of its AGPL
licence. Raster tests cover crop, page rotation, placement rotation and
mirroring, and fail when the rotation or crop handling is broken.

Card payment: Mercado Pago's Card Payment Brick on the Site when
MP_PUBLIC_KEY is set; the card becomes a one-time token in Mercado Pago's
secure fields. Each card attempt has its own idempotency key, and the intent
route refuses new attempts once a payment is approved or a card is in
review, so a quote cannot be charged twice. The Site CSP admits Mercado
Pago's origins only through PAYMENT_CSP_SOURCES, empty by default.

Logins: every attempt counts against the source address, only failures
against the account. Counting successful sign-ins let ordinary use lock an
operator out and made CI's final browser sign-in fail.

No new required settings; production behaviour is unchanged until the
provider credentials are configured. Verified with the full CI integration
sequence locally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 13:14:56 -03:00
Cauê Faleiros
e3d5558198 feat: connect Tiny through its v3 API with OAuth
All checks were successful
Build and deploy / Validate source (push) Successful in 9s
Build and deploy / Integration suite on a real stack (push) Successful in 2m49s
Build and deploy / Secret scan and release gate (push) Successful in 9s
Build and deploy / Publish images and notify Portainer (push) Has been skipped
Tiny v3 replaces the v2 token adapter. An operator connects Tiny once from
the Kanban; the callback is authorised by a single-use state, because Tiny's
cross-site redirect does not carry the SameSite=Strict operator cookie.
Tokens are kept in provider_tokens, the refresh token rotates under a row
lock, and the worker keeps the connection alive while order creation is off.

Orders find or create the customer's contact by CNPJ, then POST /pedidos
with product ids from TINY_PRODUCT_TEXTIL_FOLHA, _TEXTIL_AVULSA, _UV_FOLHA
and _UV_AVULSA and numeroOrdemCompra DTF-<number>; a retry searches the
customer's recent orders for that number first. The product settings avoid a
_FILE suffix, which the secrets loader reads as a secret file path.

Production passes the application credentials through but keeps
TINY_ADAPTER fake: Tiny has no sandbox, so creating real orders waits for a
supervised test. compose.providers.yaml gives the local API and worker an
internet route for provider testing; the default local stack still has none.

Verified with the full CI integration sequence locally, including the new
tiny_oauth_test against the real database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:46:09 -03:00
Cauê Faleiros
c18b9e5b87 feat: generate print files, collect delivery addresses, add provider adapters
All checks were successful
Build and deploy / Validate source (push) Successful in 1m45s
Build and deploy / Integration suite on a real stack (push) Successful in 4m48s
Build and deploy / Secret scan and release gate (push) Successful in 11s
Build and deploy / Publish images and notify Portainer (push) Has been skipped
Week 2 work that did not need client inputs.

Print files (1.4): each paid item gets a PDF the width of the film and the
length of the approved layout, with every copy at its reviewed position,
rotation and mirror. Sources are embedded once at original resolution; JPEG
bytes pass through and PNG alpha becomes a soft mask. Artwork the generator
cannot reproduce goes to hand preparation with the reason. The worker renders
outside any transaction, and the operator approves the generated file as the
final one through the existing review.

Delivery address (3.8): required for any non-pickup quote, bound to the
quoted CEP, carried into the order snapshot, the Kanban card and Tiny.

Kanban (1.5): print-file status per item, and a panel of payment events that
need a person (money without an order, refunds after an order) until an
operator records the resolution.

Mercado Pago and Tiny (1.1, 1.3): adapters written from the public API
documentation and tested against fake transports only. Selectable for
sandbox testing with their credentials; the production preflight still
blocks release. Adds payment intents and a PIX step on the Site.

MinIO: Docker Hub and quay.io now refuse anonymous pulls, so local and CI
storage use Chainguard's MinIO build, pinned by digest.

Verified with the full CI integration sequence on a fresh local build,
including the new print_file_test and both browser suites.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 11:56:46 -03:00
104 changed files with 10687 additions and 1442 deletions

View File

@@ -20,6 +20,28 @@ APP_ENV=local
PAYMENT_ADAPTER=fake PAYMENT_ADAPTER=fake
FREIGHT_ADAPTER=fake FREIGHT_ADAPTER=fake
TINY_ADAPTER=fake TINY_ADAPTER=fake
# Sandbox only (see docs/LOCAL_SETUP.md, "Provider sandboxes"):
# PAYMENT_ADAPTER=mercadopago
# MP_ACCESS_TOKEN=TEST-...
# MP_WEBHOOK_SECRET=...
# MP_NOTIFICATION_URL=https://<public tunnel>/api/payments/webhook
# Tiny API v3: client ID/secret come from the "Aplicativo" created in Tiny
# (Configurações > Geral > Aplicativos); the redirect URI registered there
# must be exactly TINY_REDIRECT_URI. Product ids are the Tiny products each
# Site product becomes. Tiny has no sandbox: orders created are real.
# TINY_CLIENT_ID=...
# TINY_CLIENT_SECRET=...
# TINY_REDIRECT_URI=http://localhost:8081/api/operator/tiny/callback
# TINY_PRODUCT_TEXTIL_FOLHA=...
# TINY_PRODUCT_TEXTIL_AVULSA=...
# TINY_PRODUCT_UV_FOLHA=...
# TINY_PRODUCT_UV_AVULSA=...
# TINY_ADAPTER=tiny # only once connected and tested: creates real orders
# Jadlog price quotes go in jadlog.env, not here (see app/jadlog_probe.py):
# JADLOG_TOKEN=...
# JADLOG_CNPJ=... # the "Usuário" Jadlog issued, the CNPJ that contracts freight
# JADLOG_CONTA=... # conta corrente
# JADLOG_CONTRATO= # only if Jadlog issued a contract number
WHATSAPP_ADAPTER=fake WHATSAPP_ADAPTER=fake
STORAGE_ADAPTER=s3-local STORAGE_ADAPTER=s3-local
MOCK_FREIGHT_CENTS=1500 MOCK_FREIGHT_CENTS=1500

View File

@@ -73,7 +73,7 @@ jobs:
# one a developer exercises on localhost. # one a developer exercises on localhost.
- name: API and workflow regressions - name: API and workflow regressions
run: | run: |
for suite in smoke_test workflow_test security_test scanning_test payment_test quote_pagination_test; do for suite in smoke_test workflow_test security_test scanning_test payment_test quote_pagination_test print_file_test; do
echo "--- $suite" echo "--- $suite"
$COMPOSE exec -T \ $COMPOSE exec -T \
-e SITE_BASE_URL=http://site \ -e SITE_BASE_URL=http://site \
@@ -81,10 +81,19 @@ jobs:
api python -m "tests.$suite" api python -m "tests.$suite"
done done
# Need Pillow and httpx, which only the application image has. The raster
# check needs PyMuPDF as well and skips here; run it locally when changing
# the generator's geometry. The provider suites use a fake transport: they
# prove the documented contract, not the integration.
- name: Print-file geometry and provider adapters
run: $COMPOSE exec -T api python -m unittest tests.test_printfile tests.test_mercadopago tests.test_tiny tests.test_jadlog tests.test_quote_review tests.test_large_files tests.test_grade_check -v
- name: Runtime and retention regressions - name: Runtime and retention regressions
run: | run: |
$COMPOSE exec -T api python -m tests.retention_test $COMPOSE exec -T api python -m tests.retention_test
$COMPOSE exec -T api python -m tests.runtime_security_test $COMPOSE exec -T api python -m tests.runtime_security_test
$COMPOSE exec -T api python -m tests.tiny_oauth_test
$COMPOSE exec -T backup python -m tests.backup_test
# Run Chrome on the Compose network. It must resolve the same storage:9000 # Run Chrome on the Compose network. It must resolve the same storage:9000
# hostname used in presigned URLs, and absence of Chrome must fail CI. # hostname used in presigned URLs, and absence of Chrome must fail CI.
@@ -98,7 +107,7 @@ jobs:
if: failure() if: failure()
run: | run: |
$COMPOSE ps || true $COMPOSE ps || true
$COMPOSE logs --tail 200 api worker site kanban || true $COMPOSE logs --tail 200 api worker backup site kanban || true
- name: Tear down - name: Tear down
if: always() if: always()
@@ -126,9 +135,9 @@ jobs:
fs --scanners secret --exit-code 1 --severity HIGH,CRITICAL \ fs --scanners secret --exit-code 1 --severity HIGH,CRITICAL \
--no-progress /src --no-progress /src
# Keep push feedback advisory while the provider adapters are fake. # Advisory while the provider adapters are fake. This is the only copy of
# The manual release job enforces the source preflight unconditionally. # the gate: set ENFORCE_PRODUCTION_PREFLIGHT=true and a blocked preflight
# ENFORCE_PRODUCTION_PREFLIGHT can make push checks fail on blockers too. # fails this job, which stops images from being published.
- name: Production source preflight - name: Production source preflight
run: | run: |
set +e set +e
@@ -145,10 +154,14 @@ jobs:
fi fi
echo "::warning::Source preflight reports blockers (advisory; set ENFORCE_PRODUCTION_PREFLIGHT=true to gate)." echo "::warning::Source preflight reports blockers (advisory; set ENFORCE_PRODUCTION_PREFLIGHT=true to gate)."
# Every push to main that passes validation, the integration suite and the
# scans publishes images. Production changes only when someone pulls and
# redeploys the stack in Portainer; a manual run of this workflow also calls
# the Portainer webhook when one is configured.
publish-and-deploy: publish-and-deploy:
name: Publish images and notify Portainer name: Publish images
needs: [validate, integration, scan] needs: [validate, integration, scan]
if: gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/main' if: gitea.ref == 'refs/heads/main' && (gitea.event_name == 'push' || gitea.event_name == 'workflow_dispatch')
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 45 timeout-minutes: 45
env: env:
@@ -158,12 +171,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- name: Require production readiness
env:
PORTAINER_WEBHOOK: ${{ secrets.PORTAINER_WEBHOOK }}
run: |
python3 deploy/production_preflight.py --source-only
test -n "$PORTAINER_WEBHOOK"
- name: Sign in to the Gitea Container Registry - name: Sign in to the Gitea Container Registry
env: env:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
@@ -200,24 +207,47 @@ jobs:
- name: Image vulnerabilities - name: Image vulnerabilities
run: | run: |
image="${TRIVY_IMAGE:-aquasec/trivy:0.58.1}" image="${TRIVY_IMAGE:-aquasec/trivy:0.58.1}"
failed=0 # One database download for the four scans, kept in a volume between
# runs and retried: a failed download from the mirror used to fail
# the gate as if a CRITICAL vulnerability had been found.
trivy() { docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
-v dtf-trivy-cache:/root/.cache/trivy "$image" "$@"; }
for attempt in 1 2 3; do
trivy image --download-db-only --no-progress && break
if [ "$attempt" -eq 3 ]; then
echo "::error::The vulnerability database could not be downloaded; the release images were not scanned."
exit 1
fi
echo "Database download failed (attempt $attempt); retrying in 30 s."
sleep 30
done
# Findings exit 5; any other failure means the scan did not run.
found=0; broken=0
for target in \ for target in \
"gitea.blyzer.com.br/blyzer/dtf-api:${{ gitea.sha }}" \ "gitea.blyzer.com.br/blyzer/dtf-api:${{ gitea.sha }}" \
"gitea.blyzer.com.br/blyzer/dtf-web:${{ gitea.sha }}"; do "gitea.blyzer.com.br/blyzer/dtf-web:${{ gitea.sha }}"; do
echo "--- $target (HIGH, reported)" echo "--- $target (HIGH, reported)"
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock "$image" \ trivy image --skip-db-update --image-src docker --scanners vuln --severity HIGH --no-progress \
image --image-src docker --scanners vuln --severity HIGH --no-progress \
--format table --exit-code 0 "$target" || --format table --exit-code 0 "$target" ||
echo "::warning::Could not scan $target for HIGH findings" echo "::warning::Could not scan $target for HIGH findings"
echo "--- $target (CRITICAL, blocking)" echo "--- $target (CRITICAL, blocking)"
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock "$image" \ set +e
image --image-src docker --scanners vuln --severity CRITICAL --no-progress \ trivy image --skip-db-update --image-src docker --scanners vuln --severity CRITICAL --no-progress \
--format table --exit-code 1 "$target" || failed=1 --format table --exit-code 5 "$target"
verdict=$?
set -e
if [ "$verdict" -eq 5 ]; then found=1
elif [ "$verdict" -ne 0 ]; then broken=1; echo "::error::The CRITICAL scan of $target did not run (exit $verdict)."
fi
done done
if [ "$failed" -ne 0 ]; then if [ "$found" -ne 0 ]; then
echo "::error::A CRITICAL vulnerability was found in a release image." echo "::error::A CRITICAL vulnerability was found in a release image."
exit 1 exit 1
fi fi
if [ "$broken" -ne 0 ]; then
echo "::error::A release image could not be scanned; nothing is published unscanned."
exit 1
fi
- name: Publish validated images - name: Publish validated images
run: | run: |
@@ -228,7 +258,12 @@ jobs:
done done
- name: Trigger Portainer redeployment - name: Trigger Portainer redeployment
if: gitea.event_name == 'workflow_dispatch'
env: env:
PORTAINER_WEBHOOK: ${{ secrets.PORTAINER_WEBHOOK }} PORTAINER_WEBHOOK: ${{ secrets.PORTAINER_WEBHOOK }}
run: | run: |
if [ -z "$PORTAINER_WEBHOOK" ]; then
echo "No PORTAINER_WEBHOOK configured; redeploy the stack in Portainer."
exit 0
fi
curl --fail --silent --show-error --max-time 30 --request POST "$PORTAINER_WEBHOOK" curl --fail --silent --show-error --max-time 30 --request POST "$PORTAINER_WEBHOOK"

View File

@@ -17,9 +17,33 @@ def require_runtime():
checkout until their audited implementations are added. checkout until their audited implementations are added.
""" """
environment = os.environ.get('APP_ENV', 'local') environment = os.environ.get('APP_ENV', 'local')
for name in ('PAYMENT', 'FREIGHT', 'TINY', 'WHATSAPP'): if os.environ.get('WHATSAPP_ADAPTER') != 'fake':
if os.environ.get(f'{name}_ADAPTER') != 'fake': raise RuntimeError('WHATSAPP must use the currently supported fake adapter')
raise RuntimeError(f'{name} must use the currently supported fake adapter') freight = os.environ.get('FREIGHT_ADAPTER')
if freight == 'jadlog':
from .jadlog import required_settings as jadlog_settings
for name in jadlog_settings():
if not os.environ.get(name):
raise RuntimeError(f'{name} is required for the Jadlog adapter')
elif freight != 'fake':
raise RuntimeError('FREIGHT must use the fake or jadlog adapter')
tiny = os.environ.get('TINY_ADAPTER')
if tiny == 'tiny':
from .tiny import required_settings
for name in required_settings():
if not os.environ.get(name):
raise RuntimeError(f'{name} is required for the Tiny adapter')
elif tiny != 'fake':
raise RuntimeError('TINY must use the fake or tiny adapter')
# Mercado Pago is selectable only with its credentials present; it has not
# yet passed the sandbox flows, so production preflight still blocks it.
payment = os.environ.get('PAYMENT_ADAPTER')
if payment == 'mercadopago':
for name in ('MP_ACCESS_TOKEN', 'MP_WEBHOOK_SECRET'):
if not os.environ.get(name):
raise RuntimeError(f'{name} is required for the Mercado Pago adapter')
elif payment != 'fake':
raise RuntimeError('PAYMENT must use the fake or mercadopago adapter')
if environment == 'local': if environment == 'local':
if os.environ.get('STORAGE_ADAPTER') != 's3-local': if os.environ.get('STORAGE_ADAPTER') != 's3-local':
raise RuntimeError('Local runtime requires local S3 storage') raise RuntimeError('Local runtime requires local S3 storage')
@@ -60,14 +84,14 @@ class PaymentEvent(NamedTuple):
class PaymentAdapter(Protocol): class PaymentAdapter(Protocol):
def create(self, quote_id: str, total_cents: int, customer: dict) -> dict: def create(self, quote_id: str, total_cents: int, customer: dict, method: dict | None = None) -> dict:
"""Start a payment. Must be idempotent on quote_id: a retry after a """Start a payment. Must be idempotent on quote_id: a retry after a
timeout has to return the existing payment, never charge twice.""" timeout has to return the existing payment, never charge twice."""
def verify(self, headers: Mapping[str, str], body: bytes) -> bool: def verify(self, headers: Mapping[str, str], body: bytes, query: Mapping[str, str] | None = None) -> bool:
"""Whether this delivery genuinely came from the provider.""" """Whether this delivery genuinely came from the provider."""
def parse(self, body: bytes) -> PaymentEvent | None: def parse(self, body: bytes, query: Mapping[str, str] | None = None) -> PaymentEvent | None:
"""Normalise a verified delivery, or None if it is not about a payment.""" """Normalise a verified delivery, or None if it is not about a payment."""
@@ -80,14 +104,21 @@ class FakePayment:
the service changes. the service changes.
""" """
name = 'fake'
header = 'x-payment-signature' header = 'x-payment-signature'
def _secret(self) -> bytes | None: def _secret(self) -> bytes | None:
secret = os.environ.get('PAYMENT_WEBHOOK_SECRET', '') secret = os.environ.get('PAYMENT_WEBHOOK_SECRET', '')
return secret.encode() if secret else None return secret.encode() if secret else None
def create(self, quote_id: str, total_cents: int, customer: dict) -> dict: def create(self, quote_id: str, total_cents: int, customer: dict, method: dict | None = None) -> dict:
return {'provider': 'fake', 'id': f'local-{quote_id}', kind = (method or {}).get('type', 'pix')
if kind == 'pix':
from datetime import datetime, timedelta, timezone
expires = (datetime.now(timezone.utc) + timedelta(minutes=30)).isoformat(timespec='milliseconds')
return {'provider': 'fake', 'id': f"local-{quote_id}-pix-{(method or {}).get('attempt', 1)}",
'status': 'pending', 'total_cents': total_cents, 'expires_at': expires}
return {'provider': 'fake', 'id': f'local-{quote_id}-{kind}',
'status': 'pending', 'total_cents': total_cents} 'status': 'pending', 'total_cents': total_cents}
def sign(self, body: bytes) -> str: def sign(self, body: bytes) -> str:
@@ -96,7 +127,7 @@ class FakePayment:
raise RuntimeError('PAYMENT_WEBHOOK_SECRET is not configured') raise RuntimeError('PAYMENT_WEBHOOK_SECRET is not configured')
return hmac.new(secret, body, hashlib.sha256).hexdigest() return hmac.new(secret, body, hashlib.sha256).hexdigest()
def verify(self, headers, body: bytes) -> bool: def verify(self, headers, body: bytes, query=None) -> bool:
# No configured secret means nothing can be verified, so nothing is # No configured secret means nothing can be verified, so nothing is
# accepted. A guessable default would let anyone forge an approval and # accepted. A guessable default would let anyone forge an approval and
# create an order that was never paid for. # create an order that was never paid for.
@@ -105,7 +136,7 @@ class FakePayment:
supplied = headers.get(self.header) or headers.get(self.header.title()) or '' supplied = headers.get(self.header) or headers.get(self.header.title()) or ''
return hmac.compare_digest(supplied, self.sign(body)) return hmac.compare_digest(supplied, self.sign(body))
def parse(self, body: bytes): def parse(self, body: bytes, query=None):
try: try:
data = json.loads(body) data = json.loads(body)
except ValueError: except ValueError:
@@ -124,10 +155,12 @@ class FakePayment:
'status': 'paid', 'total_cents': total_cents} 'status': 'paid', 'total_cents': total_cents}
class FreightAdapter(Protocol): class FreightAdapter(Protocol):
def quote(self, service: str, postal_code: str) -> dict: ... def quote(self, service: str, postal_code: str, metres=None, declared_cents: int = 0) -> dict: ...
class FakeFreight: class FakeFreight:
def quote(self, service: str, postal_code: str) -> dict: name = 'fake'
def quote(self, service: str, postal_code: str, metres=None, declared_cents: int = 0) -> dict:
if service == 'pickup': if service == 'pickup':
return {'provider': 'fake', 'service': 'pickup', 'total_cents': 0, 'postal_code': ''} 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(): if service != 'mock-standard' or len(postal_code) != 8 or not postal_code.isascii() or not postal_code.isdigit():
@@ -158,6 +191,8 @@ class ObjectStorage(Protocol):
def complete(self, key: str, upload_id: str, parts: list): ... def complete(self, key: str, upload_id: str, parts: list): ...
def size(self, key: str) -> int: ... def size(self, key: str) -> int: ...
def download(self, key: str, name: str) -> str: ... def download(self, key: str, name: str) -> str: ...
def fetch(self, key: str, path: str): ...
def store(self, key: str, path: str, content_type: str): ...
def health(self): ... def health(self): ...
def discard(self, key: str, upload_id: str, complete: bool): ... def discard(self, key: str, upload_id: str, complete: bool): ...
@@ -206,6 +241,14 @@ class LocalS3Storage:
def size(self, key): def size(self, key):
return self.client.head_object(Bucket=self.bucket, Key=key)['ContentLength'] return self.client.head_object(Bucket=self.bucket, Key=key)['ContentLength']
def fetch(self, key, path):
"""Copy a stored object to a local file (the worker's scratch space)."""
self.client.download_file(self.bucket, key, path)
def store(self, key, path, content_type):
"""Upload a file the service generated itself, such as a print file."""
self.client.upload_file(path, self.bucket, key, ExtraArgs={'ContentType': content_type})
def download(self, key, name): def download(self, key, name):
from urllib.parse import quote from urllib.parse import quote
return self.public.generate_presigned_url('get_object', Params={ return self.public.generate_presigned_url('get_object', Params={

View File

@@ -2,15 +2,17 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException, Request, Response from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from psycopg.errors import UniqueViolation from psycopg.errors import UniqueViolation
from psycopg.types.json import Jsonb from psycopg.types.json import Jsonb
from ..core import db from ..core import db
from ..artwork import submit_files from ..artwork import submit_files
from ..core.auth import (DUMMY_PASSWORD_HASH, audit, client_ip, new_session, owner, from ..core.auth import (DUMMY_PASSWORD_HASH, audit, client_ip, new_session, owner,
password_hash, password_matches, session_row, throttle, transfer_guest) login_failed, password_hash, password_matches, session_row, throttle, transfer_guest)
from ..core.models import ArtworkSubmission, Login, Register from ..core.models import ArtworkSubmission, EmailChange, Login, PasswordChange, ProfileUpdate, Register
from ..runtime import STATES, file_rows, owned_order, storage from ..runtime import STATES, file_rows, owned_order, storage
from ..scanning import require_clean from ..scanning import require_clean
@@ -26,6 +28,8 @@ def current(request):
def register(body: Register, request: Request, response: Response): def register(body: Register, request: Request, response: Response):
email = body.customer.mail.strip().lower() email = body.customer.mail.strip().lower()
throttle(email, request) throttle(email, request)
# Registration attempts keep counting against the email, as before.
login_failed(email)
previous = current(request) previous = current(request)
encoded = password_hash(body.password) encoded = password_hash(body.password)
identity = uuid4() identity = uuid4()
@@ -53,8 +57,9 @@ def login(body: Login, request: Request, response: Response):
stored = account['password_hash'] if account else DUMMY_PASSWORD_HASH stored = account['password_hash'] if account else DUMMY_PASSWORD_HASH
matches = password_matches(body.password, stored) matches = password_matches(body.password, stored)
if not account or not matches: if not account or not matches:
login_failed(email)
audit('customer_login_failed', ip=client_ip(request)) audit('customer_login_failed', ip=client_ip(request))
raise HTTPException(401, 'Invalid email or password') raise HTTPException(401, 'E-mail ou senha inválidos.')
previous = current(request) previous = current(request)
with db.connect() as c: with db.connect() as c:
if not stored.startswith('scrypt-v2$'): if not stored.startswith('scrypt-v2$'):
@@ -83,21 +88,124 @@ def me(identity=Depends(owner)):
row = c.execute('SELECT profile FROM dtf_local.accounts WHERE id=%s', (identity,)).fetchone() row = c.execute('SELECT profile FROM dtf_local.accounts WHERE id=%s', (identity,)).fetchone()
return {'customer': row['profile'] if row else None} return {'customer': row['profile'] if row else None}
@router.get('/api/customer/orders') def account_row(c, identity, lock=False):
def orders(identity=Depends(owner)): row = c.execute('SELECT * FROM dtf_local.accounts WHERE id=%s' + (' FOR UPDATE' if lock else ''), (identity,)).fetchone()
if not row:
raise HTTPException(401, 'Entre na sua conta.')
return row
@router.post('/api/account/profile')
def update_profile(body: ProfileUpdate, identity=Depends(owner)):
"""WhatsApp and the saved delivery address. The CNPJ is the account's and
does not change; the e-mail is the login and changes on its own route."""
with db.connect() as c: 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() row = account_row(c, identity, lock=True)
quotes = c.execute('''SELECT q.id,q.approved,q.approved_at,q.created_at FROM dtf_local.quotes q profile = {**row['profile'], 'zap': body.zap,
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() 'address': body.address.model_dump() if body.address else None}
return {'orders': rows, 'quotes': quotes, 'states': STATES} c.execute('UPDATE dtf_local.accounts SET profile=%s WHERE id=%s', (Jsonb(profile), identity))
audit('account_profile_updated', account=str(identity))
return {'customer': profile}
@router.post('/api/account/email')
def change_email(body: EmailChange, request: Request, identity=Depends(owner)):
with db.connect() as c:
row = account_row(c, identity)
throttle(row['email'], request)
if not password_matches(body.password, row['password_hash']):
login_failed(row['email'])
raise HTTPException(401, 'Senha incorreta.')
profile = {**row['profile'], 'mail': body.email}
try:
with db.connect() as c:
c.execute('UPDATE dtf_local.accounts SET email=%s, profile=%s WHERE id=%s', (body.email, Jsonb(profile), identity))
except UniqueViolation:
raise HTTPException(409, 'Este e-mail já é de outra conta.')
audit('account_email_changed', account=str(identity))
return {'customer': profile}
@router.post('/api/account/password')
def change_password(body: PasswordChange, request: Request, identity=Depends(owner)):
with db.connect() as c:
row = account_row(c, identity)
throttle(row['email'], request)
if not password_matches(body.current, row['password_hash']):
login_failed(row['email'])
raise HTTPException(401, 'Senha atual incorreta.')
session = current(request)
with db.connect() as c:
c.execute('UPDATE dtf_local.accounts SET password_hash=%s WHERE id=%s', (password_hash(body.new), identity))
# Other devices signed in with the old password are signed out.
c.execute('DELETE FROM dtf_local.sessions WHERE owner=%s AND id<>%s', (identity, session['id']))
audit('account_password_changed', account=str(identity))
return {'ok': True}
# The order list's groups, as the customer filters them.
GROUPS = {'pay': 'Aguardando pagamento', 'prod': 'Em produção', 'cor': 'Correção', 'fin': 'Finalizados'}
ENTRIES = '''WITH live_quote AS (
-- The cart waiting for payment: the newest unpaid quote whose files still
-- exist. An older one was replaced when the cart changed, and one whose
-- files are gone can no longer be paid.
SELECT q.id, q.created_at, q.approved FROM dtf_local.quotes q
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id
WHERE q.owner=%(me)s AND o.id IS NULL
AND NOT EXISTS (SELECT 1 FROM jsonb_array_elements(q.draft->'items') i
CROSS JOIN LATERAL jsonb_array_elements_text(i->'uploads') u(uid)
JOIN dtf_local.uploads up ON up.id=u.uid::uuid
WHERE up.purged_at IS NOT NULL OR up.expires_at<=now())
ORDER BY q.created_at DESC LIMIT 1
), entries AS (
SELECT 'order' AS kind, o.id, o.number, o.state,
CASE WHEN o.state='fin' THEN 'fin' WHEN o.state='cor' THEN 'cor' ELSE 'prod' END AS grp,
(o.snapshot->>'total_cents')::bigint AS total_cents, o.created_at, o.snapshot->'items' AS items
FROM dtf_local.orders o WHERE o.owner=%(me)s
UNION ALL
SELECT 'quote', q.id, NULL, CASE WHEN q.approved IS NULL THEN 'review' ELSE 'pay' END, 'pay',
(q.approved->>'total_cents')::bigint, q.created_at, COALESCE(q.approved->'items','[]'::jsonb)
FROM live_quote q
)'''
@router.get('/api/customer/orders')
def orders(identity=Depends(owner), status: Literal['all','pay','prod','cor','fin'] = 'all',
number: int | None = Query(None, ge=1), days: int = Query(0, ge=0, le=3650),
page: int = Query(1, ge=1, le=10000), size: int = Query(10, ge=1, le=50)):
"""The customer's orders and the cart waiting for payment, newest first,
filtered and a page at a time, with how many there are in each group."""
params = {'me': identity, 'grp': None if status == 'all' else status, 'number': number,
'since': datetime.now(timezone.utc) - timedelta(days=days) if days else None,
'size': size, 'offset': (page - 1) * size}
where = '''WHERE (%(grp)s::text IS NULL OR grp=%(grp)s) AND (%(number)s::int IS NULL OR number=%(number)s)
AND (%(since)s::timestamptz IS NULL OR created_at>=%(since)s)'''
with db.connect() as c:
counts = {r['grp']: r['n'] for r in c.execute(ENTRIES + ' SELECT grp, count(*) AS n FROM entries GROUP BY grp', params).fetchall()}
total = c.execute(ENTRIES + ' SELECT count(*) AS n FROM entries ' + where, params).fetchone()['n']
rows = c.execute(ENTRIES + ' SELECT * FROM entries ' + where +
' ORDER BY created_at DESC LIMIT %(size)s OFFSET %(offset)s', params).fetchall()
items = []
for r in rows:
entry = {'kind': r['kind'], 'id': r['id'], 'number': r['number'], 'state': r['state'],
'group': r['grp'], 'total_cents': r['total_cents'], 'created_at': r['created_at'],
'items': [{'mode': i['mode'], 'billed_metres': i.get('billed_metres')} for i in r['items']]}
if r['kind'] == 'quote':
# A PIX code still open is paid on its own page.
pix = c.execute('''SELECT 1 FROM dtf_local.payment_intents WHERE quote_id=%s AND method='pix'
AND status='pending' AND COALESCE((response->>'expires_at')::timestamptz > now(), false)''',
(r['id'],)).fetchone()
entry['pay_with'] = 'pix' if pix else 'page'
items.append(entry)
return {'items': items, 'total': total, 'page': page, 'size': size,
'counts': {g: counts.get(g, 0) for g in GROUPS}, 'groups': GROUPS, 'states': STATES}
@router.get('/api/customer/orders/{oid}') @router.get('/api/customer/orders/{oid}')
def detail(oid: UUID, identity=Depends(owner)): def detail(oid: UUID, identity=Depends(owner)):
with db.connect() as c: with db.connect() as c:
row = owned_order(c, oid, identity) row = owned_order(c, oid, identity)
history = c.execute('SELECT from_state,to_state,reason,created_at FROM dtf_local.movements WHERE order_id=%s ORDER BY id', (oid,)).fetchall() # A move back undoes an operator's mistake and its reason is internal;
# only a correction's reason is written for the customer.
history = c.execute('''SELECT from_state,to_state,CASE WHEN to_state='cor' THEN reason ELSE '' END AS reason,
created_at FROM dtf_local.movements WHERE order_id=%s AND NOT back ORDER BY id''', (oid,)).fetchall()
return {'id': row['id'], 'number': row['number'], 'state': row['state'], 'version': row['version'], return {'id': row['id'], 'number': row['number'], 'state': row['state'], 'version': row['version'],
'snapshot': row['snapshot'], 'history': history, 'files': file_rows(c,oid)} 'created_at': row['created_at'], 'snapshot': row['snapshot'], 'history': history,
'files': file_rows(c,oid)}
@router.post('/api/customer/orders/{oid}/corrections') @router.post('/api/customer/orders/{oid}/corrections')
def correction(oid: UUID, body: ArtworkSubmission, identity=Depends(owner)): def correction(oid: UUID, body: ArtworkSubmission, identity=Depends(owner)):

View File

@@ -1,11 +1,16 @@
"""Health, session bootstrap and freight quoting.""" """Health, session bootstrap, freight quoting and the address of a CEP."""
import os
import re
import httpx
from fastapi import APIRouter, HTTPException, Request, Response from fastapi import APIRouter, HTTPException, Request, Response
from ..core import db from ..core import db
from ..core.auth import client_ip, owner, new_session, rate_limit from ..core.auth import client_ip, owner, new_session, rate_limit
from ..core.limits import upload_limit_bytes from ..core.limits import upload_limit_bytes
from ..core.models import Freight from ..core.models import FreightEstimate
from ..runtime import ENVIRONMENT, GUEST_SESSION_LIMIT, PART_BYTES, freight, storage from ..runtime import (ENVIRONMENT, GUEST_SESSION_LIMIT, PART_BYTES, freight, payment,
require_delivery_available, storage)
router = APIRouter() router = APIRouter()
@@ -32,11 +37,36 @@ def session(request: Request, response: Response):
with db.connect() as c: with db.connect() as c:
session_id = new_session(c, response) session_id = new_session(c, response)
return {'environment': ENVIRONMENT, 'cart_scope': str(session_id), 'part_bytes': PART_BYTES, return {'environment': ENVIRONMENT, 'cart_scope': str(session_id), 'part_bytes': PART_BYTES,
'max_upload_bytes': upload_limit_bytes()} 'max_upload_bytes': upload_limit_bytes(), 'payment_provider': payment.name,
# Public by design: Mercado Pago's card form needs it in the browser.
'payment_public_key': os.environ.get('MP_PUBLIC_KEY', '') if payment.name == 'mercadopago' else '',
# The delivery service the cart quotes, or none: pickup only.
'freight_service': 'jadlog' if freight.name == 'jadlog' else 'mock-standard' if ENVIRONMENT == 'local' else None}
@router.post('/api/freight') @router.post('/api/freight')
def quote_freight(body: Freight): def quote_freight(body: FreightEstimate):
require_delivery_available(body.service)
try: try:
return freight.quote(body.service, body.postal_code) return freight.quote(body.service, body.postal_code, body.metres, body.declared_cents)
except ValueError as exc: except ValueError as exc:
raise HTTPException(422, str(exc)) raise HTTPException(422, str(exc))
# The address of a CEP, so the cart fills it in. Looked up here rather than in
# the browser, which keeps the Site's CSP to its own origin.
VIACEP = 'https://viacep.com.br/ws/{}/json/'
CEP_LIMIT = 120
@router.get('/api/cep/{cep}')
def cep_address(cep: str, request: Request):
if not re.fullmatch(r'[0-9]{8}', cep):
raise HTTPException(422, 'CEP must have eight digits')
rate_limit('cep-lookup', client_ip(request), CEP_LIMIT, 900)
try:
response = httpx.get(VIACEP.format(cep), timeout=5)
data = response.json() if response.status_code == 200 else {}
except (httpx.HTTPError, ValueError):
raise HTTPException(503, 'Consulta de CEP indisponível')
if not data or data.get('erro'):
raise HTTPException(404, 'CEP não encontrado')
return {'street': data.get('logradouro') or '', 'district': data.get('bairro') or '',
'city': data.get('localidade') or '', 'state': data.get('uf') or ''}

View File

@@ -7,15 +7,17 @@ from typing import Literal
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from psycopg.types.json import Jsonb from fastapi.responses import RedirectResponse
from ..core import db from ..core import db
from ..core.auth import (COOKIE_SECURE, DUMMY_PASSWORD_HASH, audit, client_ip, operator, from ..core.auth import (COOKIE_SECURE, DUMMY_PASSWORD_HASH, audit, client_ip, operator,
password_matches, throttle) login_failed, password_matches, throttle)
from ..core.models import Move, OperatorLogin, Review from ..core.models import Move, OperatorLogin, Resolution, Review
from ..core.pricing import price from ..printjobs import queue as queue_print_files
from ..runtime import (BOARD_FINISHED_LIMIT, BOARD_QUOTE_LIMIT, STATES, TRANSITIONS, from .. import payments, quote_review
enqueue, freight, quote_view, storage, upload_row) from .. import tiny
from ..runtime import (BACK, BOARD_FINISHED_LIMIT, BOARD_QUOTE_LIMIT, ENVIRONMENT, STATES, TRANSITIONS, payment,
enqueue, freight, quote_view, storage)
from ..scanning import require_clean from ..scanning import require_clean
router = APIRouter() router = APIRouter()
@@ -27,13 +29,14 @@ def operator_login(body: OperatorLogin, request: Request, response: Response):
with db.connect() as c: with db.connect() as c:
account = c.execute('SELECT * FROM dtf_local.operators WHERE email=%s', (email,)).fetchone() account = c.execute('SELECT * FROM dtf_local.operators WHERE email=%s', (email,)).fetchone()
if not c.execute('SELECT 1 FROM dtf_local.operators WHERE active LIMIT 1').fetchone(): if not c.execute('SELECT 1 FROM dtf_local.operators WHERE active LIMIT 1').fetchone():
raise HTTPException(503, 'No Kanban operator account is configured') raise HTTPException(503, 'Nenhuma conta de operador configurada.')
# Comparable password work whether or not the account exists or is active. # Comparable password work whether or not the account exists or is active.
stored = account['password_hash'] if account else DUMMY_PASSWORD_HASH stored = account['password_hash'] if account else DUMMY_PASSWORD_HASH
matches = password_matches(body.password, stored) matches = password_matches(body.password, stored)
if not account or not account['active'] or not matches: if not account or not account['active'] or not matches:
login_failed('operator:'+email)
audit('operator_login_failed', ip=client_ip(request), operator=email) audit('operator_login_failed', ip=client_ip(request), operator=email)
raise HTTPException(401, 'Invalid operator login') raise HTTPException(401, 'E-mail ou senha inválidos.')
token = secrets.token_urlsafe(32) token = secrets.token_urlsafe(32)
with db.connect() as c: with db.connect() as c:
previous = hashlib.sha256(request.cookies.get('dtf_operator','').encode()).hexdigest() previous = hashlib.sha256(request.cookies.get('dtf_operator','').encode()).hexdigest()
@@ -56,6 +59,24 @@ def operator_logout(request: Request, response: Response):
audit('operator_logout') audit('operator_logout')
return {'ok': True} return {'ok': True}
def freight_status():
"""What delivery the Site offers, and the package rule it prices with."""
if freight.name != 'jadlog':
return {'provider': freight.name}
return {'provider': 'jadlog', 'base_kg': str(freight.base_kg), 'per_metre_kg': str(freight.per_metre_kg),
'production_days': freight.production_days}
def backup_status(c):
"""The latest database backup and the latest run, which may have failed."""
ok = c.execute('''SELECT finished_at,bytes FROM dtf_local.backups WHERE status='ok'
ORDER BY finished_at DESC LIMIT 1''').fetchone()
last = c.execute('SELECT finished_at,status,detail FROM dtf_local.backups ORDER BY finished_at DESC LIMIT 1').fetchone()
return {'last_ok': ok['finished_at'].isoformat() if ok else None, 'bytes': ok['bytes'] if ok else None,
'failed': last['detail'] if last and last['status'] == 'failed' else None,
'failed_at': last['finished_at'].isoformat() if last and last['status'] == 'failed' else None}
@router.get('/api/operator/board') @router.get('/api/operator/board')
def board(user=Depends(operator)): def board(user=Depends(operator)):
with db.connect() as c: with db.connect() as c:
@@ -80,29 +101,113 @@ def board(user=Depends(operator)):
approved_total = c.execute('''SELECT count(*) AS n FROM dtf_local.quotes q approved_total = c.execute('''SELECT count(*) AS n FROM dtf_local.quotes q
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id LEFT JOIN dtf_local.orders o ON o.quote_id=q.id
WHERE o.id IS NULL AND q.approved IS NOT NULL''').fetchone()['n'] WHERE o.id IS NULL AND q.approved IS NOT NULL''').fetchone()['n']
return {'states': STATES, 'transitions': TRANSITIONS, orders = with_print_files(c, active + list(reversed(finished)))
'orders': active + list(reversed(finished)), # A paid notification that did not become an order is money received
# for nothing the factory will make. The board carries the count; the
# Pagamentos tab pages through them until someone records a resolution.
issues_total = c.execute('''SELECT count(*) AS n FROM dtf_local.payment_events
WHERE (outcome LIKE 'refused%' OR outcome LIKE 'attention%') AND resolved_at IS NULL''').fetchone()['n']
backup = backup_status(c)
return {'states': STATES, 'transitions': TRANSITIONS, 'back': BACK,
'orders': orders, 'payment_issues_total': issues_total, 'tiny': tiny_status(), 'operator': user,
'providers': {'payment': payment.name, 'freight': freight_status(), 'backup': backup}, 'environment': ENVIRONMENT,
'finished_shown': len(finished), 'finished_total': finished_total, 'finished_shown': len(finished), 'finished_total': finished_total,
'quotes': [quote_view(c, q) for q in pending + approved], 'quotes': [quote_view(c, q) for q in pending + approved],
'pending_total': pending_total, 'approved_total': approved_total, 'pending_total': pending_total, 'approved_total': approved_total}
'events': c.execute('SELECT * FROM dtf_local.outbox ORDER BY id DESC LIMIT 100').fetchall()}
def with_print_files(c, orders):
generated = c.execute('''SELECT p.order_id,p.item_index,p.status,p.upload_id,p.detail,u.name
FROM dtf_local.print_files p LEFT JOIN dtf_local.uploads u ON u.id=p.upload_id
WHERE p.order_id=ANY(%s) ORDER BY p.item_index''', ([o['id'] for o in orders],)).fetchall()
for order in orders:
order['print_files'] = [row for row in generated if row['order_id'] == order['id']]
return orders
def cursor_pair(first, second):
if (first is None) != (second is None):
raise HTTPException(422, 'Both cursor fields are required')
return first is not None
@router.get('/api/operator/orders/finished')
def finished_page(before_created_at: datetime | None = None, before_id: UUID | None = None,
limit: int = Query(default=50, ge=1, le=100), user=Depends(operator)):
"""Older finished orders, newest first, beyond the board's recent window."""
paged = cursor_pair(before_created_at, before_id)
with db.connect() as c:
rows = c.execute('''SELECT * FROM dtf_local.orders WHERE state='fin'
''' + ('AND (created_at,id)<(%s,%s) ' if paged else '') + '''
ORDER BY created_at DESC,id DESC LIMIT %s''',
((before_created_at, before_id) if paged else ()) + (limit + 1,)).fetchall()
return {'orders': with_print_files(c, rows[:limit]), 'has_more': len(rows) > limit}
@router.get('/api/operator/payment-events')
def payment_events(state: Literal['open','resolved','all'] = 'open',
offset: int = Query(default=0, ge=0),
limit: int = Query(default=20, ge=1, le=100), user=Depends(operator)):
"""Payments that needed a person, newest first: open ones to act on, resolved ones as history."""
where = {'open': 'AND resolved_at IS NULL', 'resolved': 'AND resolved_at IS NOT NULL', 'all': ''}[state]
with db.connect() as c:
rows = c.execute('''SELECT id,provider,event_id,reference,status,amount_cents,received_at,outcome,
resolved_at,resolved_by,resolution
FROM dtf_local.payment_events WHERE (outcome LIKE 'refused%%' OR outcome LIKE 'attention%%') ''' + where + '''
ORDER BY received_at DESC,id DESC LIMIT %s OFFSET %s''', (limit, offset)).fetchall()
total = c.execute('''SELECT count(*) AS n FROM dtf_local.payment_events
WHERE (outcome LIKE 'refused%' OR outcome LIKE 'attention%') ''' + where).fetchone()['n']
return {'issues': rows, 'total': total}
@router.get('/api/operator/events')
def events(provider: Literal['tiny','whatsapp'] | None = None,
status: Literal['delivered','queued','failing'] | None = None,
event: Literal['payment_approved','production_started','correction_needed','ready'] | None = None,
order: int | None = Query(default=None, ge=1), offset: int = Query(default=0, ge=0),
limit: int = Query(default=20, ge=1, le=100), user=Depends(operator)):
"""The integration send log, newest first, filtered, by page with a total."""
clauses, params = [], []
if provider:
clauses.append('provider=%s'); params.append(provider)
if status == 'delivered':
clauses.append('delivered_at IS NOT NULL')
elif status == 'queued':
clauses.append('delivered_at IS NULL AND last_error IS NULL')
elif status == 'failing':
clauses.append('delivered_at IS NULL AND last_error IS NOT NULL')
if event:
clauses.append("payload->>'event'=%s"); params.append(event)
if order:
clauses.append("payload->>'number'=%s"); params.append(str(order))
where = ('WHERE ' + ' AND '.join(clauses)) if clauses else ''
with db.connect() as c:
rows = c.execute(f'SELECT * FROM dtf_local.outbox {where} ORDER BY id DESC LIMIT %s OFFSET %s',
(*params, limit, offset)).fetchall()
total = c.execute(f'SELECT count(*) AS n FROM dtf_local.outbox {where}', params).fetchone()['n']
return {'events': rows, 'total': total}
@router.get('/api/operator/quotes') @router.get('/api/operator/quotes')
def quote_page(kind: Literal['pending','approved'], before_created_at: datetime | None = None, def quote_page(kind: Literal['pending','approved'], before_created_at: datetime | None = None,
before_id: UUID | None = None, limit: int = Query(default=50, ge=1, le=100), before_id: UUID | None = None, offset: int = Query(default=0, ge=0),
user=Depends(operator)): limit: int = Query(default=50, ge=1, le=100), user=Depends(operator)):
"""Unpaid quotes, newest first: by cursor, or by page (offset) with a total."""
if (before_created_at is None) != (before_id is None): if (before_created_at is None) != (before_id is None):
raise HTTPException(422, 'Both quote cursor fields are required') raise HTTPException(422, 'Both quote cursor fields are required')
approved_filter = 'q.approved IS NULL' if kind == 'pending' else 'q.approved IS NOT NULL' approved_filter = 'q.approved IS NULL' if kind == 'pending' else 'q.approved IS NOT NULL'
cursor = 'AND (q.created_at,q.id)<(%s,%s)' if before_created_at else '' cursor = 'AND (q.created_at,q.id)<(%s,%s)' if before_created_at else ''
params = ((before_created_at,before_id) if before_created_at else ()) + (limit+1,) skip = 0 if before_created_at else offset
params = ((before_created_at,before_id) if before_created_at else ()) + (limit+1, skip)
with db.connect() as c: with db.connect() as c:
rows = c.execute(f'''SELECT q.* FROM dtf_local.quotes q rows = c.execute(f'''SELECT q.* FROM dtf_local.quotes q
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id LEFT JOIN dtf_local.orders o ON o.quote_id=q.id
WHERE o.id IS NULL AND {approved_filter} {cursor} WHERE o.id IS NULL AND {approved_filter} {cursor}
ORDER BY q.created_at DESC,q.id DESC LIMIT %s''', params).fetchall() ORDER BY q.created_at DESC,q.id DESC LIMIT %s OFFSET %s''', params).fetchall()
total = c.execute(f'''SELECT count(*) AS n FROM dtf_local.quotes q
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id
WHERE o.id IS NULL AND {approved_filter}''').fetchone()['n']
return {'quotes':[quote_view(c,row) for row in rows[:limit]], return {'quotes':[quote_view(c,row) for row in rows[:limit]],
'has_more':len(rows)>limit} 'has_more':len(rows)>limit, 'total': total}
@router.post('/api/operator/quotes/{uid}/approve') @router.post('/api/operator/quotes/{uid}/approve')
def approve(uid: UUID, body: Review, user=Depends(operator)): def approve(uid: UUID, body: Review, user=Depends(operator)):
@@ -110,30 +215,25 @@ def approve(uid: UUID, body: Review, user=Depends(operator)):
row = c.execute('SELECT * FROM dtf_local.quotes WHERE id=%s FOR UPDATE', (uid,)).fetchone() row = c.execute('SELECT * FROM dtf_local.quotes WHERE id=%s FOR UPDATE', (uid,)).fetchone()
if not row: if not row:
raise HTTPException(404, 'Quote not found') raise HTTPException(404, 'Quote not found')
if row['approved']: return quote_review.approve(c, row, body.items, user)
raise HTTPException(409, 'Approved quotes are immutable; request a new quote')
draft = row['draft'] @router.post('/api/operator/quotes/{uid}/test-order')
if any(item.get('production', {}).get('version') != 2 for item in draft['items']): def test_order(uid: UUID, user=Depends(operator)):
raise HTTPException(409, 'Quote uses an obsolete production layout; customer must request a new quote') """An order for an approved quote without payment, to try the Kanban,
if len(body.items) != len(draft['items']): files and print flow in production. Marked TEST on the board, never sent
raise HTTPException(422, 'Review must cover every item') to Tiny or WhatsApp, and audited."""
items = [] with db.connect() as c:
for item, original in zip(body.items, draft['items']): try:
if item.mode != original['mode'] or list(map(str,item.uploads)) != original['uploads']: quote = payments.approved_quote(c, uid)
raise HTTPException(422, 'Product mode and attached files cannot change during review') except payments.PaymentRefused as refusal:
if item.production.model_dump(mode='json') != original['production'] or item.quality_status != original['quality_status'] or item.quality_acknowledged != original['quality_acknowledged']: raise HTTPException(404 if 'not found' in str(refusal) else 409, str(refusal))
raise HTTPException(422, 'Production instructions and customer acknowledgement cannot change during commercial review') receipt = {'provider': payments.TEST_PROVIDER, 'id': f'teste-{uid}', 'status': 'paid',
for upload_id in item.uploads: 'total_cents': quote['approved']['total_cents'], 'operator': user}
require_clean(upload_row(c, upload_id, row['owner'])) order, created = payments.create_order(c, quote, receipt)
items.append({**price(item.mode, str(item.metres), item.grade), if created:
'uploads': original['uploads'], 'production': original['production'], audit('test_order_created', order=str(order['id']), operator=user)
'quality_status': original['quality_status'], return order
'quality_acknowledged': original['quality_acknowledged']})
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
@router.post('/api/operator/orders/{uid}/move') @router.post('/api/operator/orders/{uid}/move')
def move(uid: UUID, body: Move, user=Depends(operator)): def move(uid: UUID, body: Move, user=Depends(operator)):
@@ -142,30 +242,140 @@ def move(uid: UUID, body: Move, user=Depends(operator)):
if not row: if not row:
raise HTTPException(404, 'Order not found') raise HTTPException(404, 'Order not found')
if body.version != row['version']: if body.version != row['version']:
raise HTTPException(409, 'Order changed; refresh the board') raise HTTPException(409, 'O pedido mudou. Clique em Atualizar.')
if body.state == row['state']: if body.state == row['state']:
return row return row
if body.state not in TRANSITIONS[row['state']]: back = BACK.get(row['state']) == body.state
raise HTTPException(409, 'Move is not allowed from this state') if body.state not in TRANSITIONS[row['state']] and not back:
raise HTTPException(409, f"Não dá para ir de {STATES[row['state']]} para {STATES[body.state]}.")
if body.state == 'cor' and not body.reason.strip(): if body.state == 'cor' and not body.reason.strip():
raise HTTPException(422, 'Correction requires a reason') raise HTTPException(422, 'Informe o motivo da correção.')
if back and not body.reason.strip():
raise HTTPException(422, 'Informe por que o pedido está voltando de etapa.')
if body.state in ('fil','imp'): 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() 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']))): 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') raise HTTPException(409, 'Aprove os arquivos finais de todos os itens antes de colocar na fila.')
if body.state == 'cor': if body.state == 'cor':
c.execute("UPDATE dtf_local.order_files SET active=false WHERE order_id=%s AND kind='final'", (uid,)) 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)', c.execute('INSERT INTO dtf_local.movements(order_id,from_state,to_state,operator,reason,back) VALUES(%s,%s,%s,%s,%s,%s)',
(uid,row['state'],body.state,user,body.reason)) (uid,row['state'],body.state,user,body.reason,back))
changed = c.execute('UPDATE dtf_local.orders SET state=%s, version=version+1, updated_at=now() WHERE id=%s RETURNING *', (body.state,uid)).fetchone() 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'} events = {'imp':'production_started','cor':'correction_needed','fin':'ready'}
if body.state in events: if body.state in events and not back and not payments.is_test(row):
for provider in ('tiny','whatsapp'): # "Production started" and "ready" reach the customer once per order,
enqueue(c, f'{uid}:{changed["version"]}:{provider}', provider, # even if a mistaken move is undone and made again. Each correction
{'order_id':str(uid), 'number':row['number'], 'event':events[body.state], 'reason':body.reason, # is a new request, so it keeps one message per movement.
'customer_path': f'/portal.html?order={uid}'}) once = body.state in ('imp','fin')
event = {'order_id':str(uid), 'number':row['number'], 'event':events[body.state], 'reason':body.reason,
'customer_path': f'/portal.html?order={uid}'}
# Tiny needs the order (customer, pickup or delivery) and, once the
# sale reached it, its id, to set the situação without a search.
sale = c.execute("SELECT receipt FROM dtf_local.outbox WHERE event_key=%s AND delivered_at IS NOT NULL",
(f'{uid}:paid:tiny',)).fetchone()
tiny = {**event, 'order': row['snapshot'], 'tiny_id': ((sale or {}).get('receipt') or {}).get('tiny_id')}
for provider, payload in (('tiny', tiny), ('whatsapp', event)):
key = f'{uid}:{events[body.state]}:{provider}' if once else f'{uid}:{changed["version"]}:{provider}'
enqueue(c, key, provider, payload)
return changed return changed
@router.post('/api/operator/orders/{uid}/print-files')
def regenerate(uid: UUID, user=Depends(operator)):
"""Queue generation again for items that failed or went to manual preparation,
and for orders paid before the generator existed."""
with db.connect() as c:
row = c.execute('SELECT id,snapshot,state FROM dtf_local.orders WHERE id=%s FOR UPDATE', (uid,)).fetchone()
if not row:
raise HTTPException(404, 'Order not found')
if row['state'] not in ('rec','tra'):
raise HTTPException(409, 'Print files are generated only before the order is queued')
if c.execute("SELECT 1 FROM dtf_local.order_files WHERE order_id=%s AND kind='correction' LIMIT 1", (uid,)).fetchone():
raise HTTPException(409, 'A customer correction replaced the original artwork; prepare the final file by hand')
queue_print_files(c, uid, len(row['snapshot']['items']), only_missing=True)
audit('print_file_requeued', order=str(uid), operator=user)
return c.execute('SELECT * FROM dtf_local.print_files WHERE order_id=%s ORDER BY item_index', (uid,)).fetchall()
@router.post('/api/operator/payment-events/{uid}/resolve')
def resolve_payment(uid: UUID, body: Resolution, user=Depends(operator)):
with db.connect() as c:
row = c.execute('''UPDATE dtf_local.payment_events SET resolved_at=now(), resolved_by=%s, resolution=%s
WHERE id=%s AND (outcome LIKE 'refused%%' OR outcome LIKE 'attention%%') AND resolved_at IS NULL RETURNING id''',
(user, body.note, uid)).fetchone()
if not row:
raise HTTPException(404, 'No open payment issue with this id')
audit('payment_issue_resolved', payment_event=str(uid), operator=user)
return {'ok': True}
def tiny_status():
if not tiny.configured():
return {'configured': False}
return {'configured': True, 'orders_enabled': os.environ.get('TINY_ADAPTER') == 'tiny',
**tiny.TinyAuth().status()}
@router.post('/api/operator/tiny/connect')
def tiny_connect(user=Depends(operator)):
"""Start the one-time authorisation of this system in the client's Tiny."""
if not tiny.configured():
raise HTTPException(503, 'Tiny application is not configured')
audit('tiny_connect_started', operator=user)
return {'url': tiny.TinyAuth().authorize_url(user)}
@router.post('/api/operator/tiny/test')
def tiny_test(user=Depends(operator)):
"""Read one order and one contact from Tiny. Creates nothing."""
if not tiny.configured():
raise HTTPException(503, 'Tiny application is not configured')
try:
results = tiny.check()
except tiny.TinyNotConnected as exc:
raise HTTPException(409, str(exc))
audit('tiny_tested', operator=user, ok=all(v == 'ok' for v in results.values()))
return {'ok': all(v == 'ok' for v in results.values()), 'results': results}
@router.post('/api/operator/mercadopago/check')
def mercadopago_check(user=Depends(operator)):
"""The Mercado Pago account behind the configured token; reads only."""
if payment.name != 'mercadopago':
raise HTTPException(409, 'Mercado Pago não está configurado')
from ..mercadopago_probe import check
audit('mercadopago_check', operator=user)
result = check(payment.access_token)
# Whether Mercado Pago's notifications reach this server and pass the
# signature: every accepted one is recorded, a refused one is audited.
with db.connect() as c:
received = c.execute('''SELECT count(*) AS n, max(received_at) AS last FROM dtf_local.payment_events
WHERE provider='mercadopago' AND received_at > now()-interval '24 hours' ''').fetchone()
last = c.execute('''SELECT received_at,status,outcome FROM dtf_local.payment_events
WHERE provider='mercadopago' ORDER BY received_at DESC LIMIT 1''').fetchone()
refused = c.execute('''SELECT count(*) AS n FROM dtf_local.security_events
WHERE event='payment_webhook_rejected' AND created_at > now()-interval '24 hours' ''').fetchone()
result['webhooks'] = {'accepted_24h': received['n'], 'refused_24h': refused['n'], 'last': last}
return result
@router.get('/api/operator/tiny/callback')
def tiny_callback(code: str = Query('', max_length=4096), state: str = Query('', max_length=128),
error: str = Query('', max_length=128)):
"""Tiny's redirect back. Cross-site, so the operator cookie is absent: the
single-use state an operator created is what authorises it. Tiny reports a
refusal (the operator declined, or offline access is not allowed for this
application) with `error` instead of a code."""
if not tiny.configured():
raise HTTPException(503, 'Tiny application is not configured')
try:
if error == 'invalid_scope':
retry = tiny.TinyAuth().without_offline(state)
audit('tiny_offline_refused')
return RedirectResponse(retry, status_code=303)
if error or not code:
raise tiny.TinyError(f'Tiny returned {error or "no code"}')
who = tiny.TinyAuth().complete(code, state)
except tiny.TinyError:
audit('tiny_connect_failed')
return RedirectResponse('/?tiny=failed', status_code=303)
audit('tiny_connected', operator=who)
return RedirectResponse('/?tiny=connected', status_code=303)
@router.get('/api/operator/orders/{uid}/history') @router.get('/api/operator/orders/{uid}/history')
def history(uid: UUID, user=Depends(operator)): def history(uid: UUID, user=Depends(operator)):
with db.connect() as c: with db.connect() as c:

View File

@@ -5,11 +5,16 @@ signature is the only thing standing between this endpoint and an attacker
creating orders. It is verified before the body is parsed, let alone acted on, creating orders. It is verified before the body is parsed, let alone acted on,
and an unverified delivery is recorded and refused rather than retried. and an unverified delivery is recorded and refused rather than retried.
""" """
from fastapi import APIRouter, HTTPException, Request from uuid import uuid4
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request
from psycopg.types.json import Jsonb
from .. import payments from .. import payments
from ..core import db from ..core import db
from ..core.auth import audit, client_ip, rate_limit from ..core.auth import audit, client_ip, owner, rate_limit
from ..core.models import PaymentIntent
from ..runtime import payment from ..runtime import payment
router = APIRouter() router = APIRouter()
@@ -17,6 +22,8 @@ router = APIRouter()
# Generous: a provider legitimately retries, and a signature check is cheap. # Generous: a provider legitimately retries, and a signature check is cheap.
# This exists so an unsigned flood cannot keep the database busy. # This exists so an unsigned flood cannot keep the database busy.
WEBHOOK_LIMIT = 600 WEBHOOK_LIMIT = 600
# How long a card waiting for the bank's confirmation holds off a new attempt.
CHALLENGE_MINUTES = 10
@router.post('/api/payments/webhook') @router.post('/api/payments/webhook')
@@ -24,11 +31,12 @@ async def webhook(request: Request):
rate_limit('payment-webhook', client_ip(request), WEBHOOK_LIMIT, 900) rate_limit('payment-webhook', client_ip(request), WEBHOOK_LIMIT, 900)
body = await request.body() body = await request.body()
if not payment.verify(request.headers, body): query = dict(request.query_params)
if not payment.verify(request.headers, body, query):
audit('payment_webhook_rejected', ip=client_ip(request), reason='signature') audit('payment_webhook_rejected', ip=client_ip(request), reason='signature')
raise HTTPException(403, 'Invalid signature') raise HTTPException(403, 'Invalid signature')
event = payment.parse(body) event = payment.parse(body, query)
if event is None: if event is None:
# Verified, so genuinely from the provider, but not about a payment. # Verified, so genuinely from the provider, but not about a payment.
# Acknowledge it: refusing would make the provider retry for ever. # Acknowledge it: refusing would make the provider retry for ever.
@@ -50,4 +58,90 @@ async def webhook(request: Request):
def event_provider(): def event_provider():
return getattr(payment, 'name', payment.__class__.__name__.replace('Payment', '').lower() or 'fake') return payment.name
def provider_reason(response):
"""A short, loggable reason from a refused provider call."""
try:
data = response.json()
except ValueError:
return f'HTTP {response.status_code}'
causes = '; '.join(f"{c.get('code')}: {c.get('description')}" for c in data.get('cause') or []
if isinstance(c, dict))
return (causes or data.get('message') or data.get('error') or f'HTTP {response.status_code}')[:300]
@router.post('/api/payments/intent')
def intent(body: PaymentIntent, session_id=Depends(owner)):
"""Start paying an approved quote: a PIX code, or a card token from the
provider's own form. Asking twice for the same method returns the same
payment; a quote already paid returns 409."""
rate_limit('payment-intent', str(session_id), 30, 900)
with db.connect() as c:
try:
quote = payments.approved_quote(c, body.quote_id, session_id)
except payments.PaymentRefused as refusal:
raise HTTPException(404 if 'not found' in str(refusal) else 409, str(refusal))
if c.execute('SELECT 1 FROM dtf_local.orders WHERE quote_id=%s', (body.quote_id,)).fetchone():
raise HTTPException(409, 'Quote is already paid')
# Never charge for files that are gone: an unpaid cart's files are
# removed after a while. A payment under way keeps them a day longer,
# time enough for the provider's notice to become the order.
uploads = payments.quote_uploads(quote['approved'])
live = c.execute('''SELECT count(*) AS n FROM dtf_local.uploads WHERE id=ANY(%s)
AND purged_at IS NULL AND expires_at>now()''', (uploads,)).fetchone()['n']
if live != len(set(uploads)):
raise HTTPException(410, 'Os arquivos deste pedido expiraram porque ele não foi pago a tempo. '
'Monte o pedido de novo para pagar.')
payments.hold_uploads(c, uploads, '1 day')
# Never a second charge: an approved payment is waiting for its
# notification to become the order, and a card in review may still be.
# A card waiting for the bank's confirmation (3-D Secure) blocks only
# for CHALLENGE_MINUTES: a customer who gave up on it must still be able
# to pay, and an unanswered challenge is not charged.
if c.execute('''SELECT 1 FROM dtf_local.payment_intents WHERE quote_id=%s
AND (status='approved' OR (method='card' AND status='pending'
AND NOT (jsonb_typeof(response->'challenge')='object'
AND created_at < now() - make_interval(mins => %s))))''',
(body.quote_id, CHALLENGE_MINUTES)).fetchone():
raise HTTPException(409, 'A payment for this quote is already approved or in review')
method = body.method.model_dump()
if body.method.type == 'pix':
# One open PIX per quote: the same code until it expires, and a new
# one only after that, when the old code can no longer be paid.
# Serialised per quote, so two clicks never open two codes.
c.execute('SELECT pg_advisory_xact_lock(hashtext(%s))', ('pix:'+str(body.quote_id),))
existing = c.execute('''SELECT *, COALESCE((response->>'expires_at')::timestamptz <= now(), false) AS expired
FROM dtf_local.payment_intents WHERE quote_id=%s AND method='pix'
AND status='pending' ORDER BY created_at DESC LIMIT 1''', (body.quote_id,)).fetchone()
if existing and not existing['expired']:
return existing['response']
if existing:
c.execute("UPDATE dtf_local.payment_intents SET status='expired', updated_at=now() WHERE id=%s",
(existing['id'],))
method['attempt'] = c.execute('''SELECT count(*) AS n FROM dtf_local.payment_intents
WHERE quote_id=%s AND method='pix' ''', (body.quote_id,)).fetchone()['n'] + 1
try:
created = payment.create(str(body.quote_id), quote['approved']['total_cents'],
quote['approved']['customer'], method)
except ValueError as exc:
raise HTTPException(422, str(exc))
except httpx.HTTPStatusError as exc:
# Mercado Pago's own reason (status, message and cause codes) goes to
# the log; it never contains card data, only what was refused.
reason = provider_reason(exc.response)
audit('payment_intent_refused', quote=str(body.quote_id), method=body.method.type,
status=exc.response.status_code, reason=reason)
if exc.response.status_code < 500:
raise HTTPException(422, f'O Mercado Pago recusou o pagamento: {reason}')
raise HTTPException(502, 'Payment provider unavailable; try again')
except Exception as exc:
audit('payment_intent_failed', quote=str(body.quote_id), error=type(exc).__name__)
raise HTTPException(502, 'Payment provider unavailable; try again')
c.execute('''INSERT INTO dtf_local.payment_intents(id,quote_id,provider,provider_payment_id,method,
status,amount_cents,response) VALUES(%s,%s,%s,%s,%s,%s,%s,%s)
ON CONFLICT(provider,provider_payment_id) DO NOTHING''',
(uuid4(), body.quote_id, payment.name, created['id'], body.method.type,
created['status'], quote['approved']['total_cents'], Jsonb(created)))
return created

View File

@@ -1,4 +1,4 @@
"""Quotes: the customer's cart, and the operator-reviewed version of it.""" """Quotes: the customer's cart, approved at once when it can be (app/quote_review.py)."""
import hashlib import hashlib
import json import json
from decimal import Decimal from decimal import Decimal
@@ -7,10 +7,14 @@ from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from psycopg.types.json import Jsonb from psycopg.types.json import Jsonb
from .. import payments
from ..core import db from ..core import db
from ..core.auth import owner from ..core.auth import owner
from ..core.models import QuoteRequest from ..core.models import QuoteRequest
from ..runtime import freight, quote_view, upload_row from ..core.pricing import price
from .. import quote_review
from ..grade_check import Files, mismatch
from ..runtime import freight, quote_view, require_delivery_available, storage, upload_row
from ..scanning import require_clean from ..scanning import require_clean
router = APIRouter() router = APIRouter()
@@ -20,26 +24,44 @@ def create_quote(body: QuoteRequest, session_id=Depends(owner)):
for item in body.items: for item in body.items:
if abs(item.metres*100-item.production.height_cm) > Decimal('0.02'): if abs(item.metres*100-item.production.height_cm) > Decimal('0.02'):
raise HTTPException(422, 'Quoted metres do not match the submitted layout height') raise HTTPException(422, 'Quoted metres do not match the submitted layout height')
require_delivery_available(body.freight.service)
draft = body.model_dump(mode='json', exclude={'request_key'}) draft = body.model_dump(mode='json', exclude={'request_key'})
digest = hashlib.sha256(json.dumps(draft, sort_keys=True).encode()).hexdigest() digest = hashlib.sha256(json.dumps(draft, sort_keys=True).encode()).hexdigest()
reason = quote_review.review_reason(draft)
if reason is not None:
# Waits for review: check the delivery now, priced at approval.
try: try:
freight.quote(body.freight.service, body.freight.postal_code) priced = [price(item.mode, str(item.metres), item.grade) for item in body.items]
freight.quote(body.freight.service, body.freight.postal_code,
sum(Decimal(i['billed_metres']) for i in priced), sum(i['total_cents'] for i in priced))
except ValueError as exc: except ValueError as exc:
raise HTTPException(422, str(exc)) raise HTTPException(422, str(exc))
with db.connect() as c: with db.connect() as c:
rows = {}
for item in body.items: for item in body.items:
for uid in item.uploads: for uid in item.uploads:
row = upload_row(c, uid, session_id) row = upload_row(c, uid, session_id)
if not row['complete']: if not row['complete']:
raise HTTPException(409, 'Complete every upload before requesting a quote') raise HTTPException(409, 'Complete every upload before requesting a quote')
require_clean(row) require_clean(row)
rows[str(uid)] = row
# The grade sets the price and came from the browser: before a cart is
# approved at checkout, the server works it out from the files.
note = mismatch(Files(storage), draft['items'], rows) if reason is None else None
reason = reason or note
uid = uuid4() 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', c.execute('INSERT INTO dtf_local.quotes(id,owner,request_key,request_hash,draft,review_note) VALUES(%s,%s,%s,%s,%s,%s) ON CONFLICT(owner,request_key) DO NOTHING',
(uid, session_id, body.request_key, digest, Jsonb(draft))) (uid, session_id, body.request_key, digest, Jsonb(draft), note))
row = c.execute('SELECT * FROM dtf_local.quotes WHERE owner=%s AND request_key=%s', (session_id, body.request_key)).fetchone() row = c.execute('SELECT * FROM dtf_local.quotes WHERE owner=%s AND request_key=%s FOR UPDATE', (session_id, body.request_key)).fetchone()
if row['request_hash'] != digest: if row['request_hash'] != digest:
raise HTTPException(409, 'Request key already used for a different cart') raise HTTPException(409, 'Request key already used for a different cart')
return {'id': row['id'], 'status': 'pending_review'} if row['id'] == uid and reason is None:
quote_review.approve(c, row, body.items, quote_review.AUTO)
return {'id': row['id'], 'status': 'approved'}
if row['id'] == uid:
# An operator reviews it first; the files wait for that review.
payments.hold_uploads(c, payments.quote_uploads(draft), payments.REVIEW_HOLD)
return {'id': row['id'], 'status': 'approved' if row['approved'] else 'pending_review'}
@router.get('/api/quotes/{uid}') @router.get('/api/quotes/{uid}')
def get_quote(uid: UUID, session_id=Depends(owner)): def get_quote(uid: UUID, session_id=Depends(owner)):

View File

@@ -14,6 +14,7 @@ from ..core import db
from ..core.auth import audit, owner, rate_limit from ..core.auth import audit, owner, rate_limit
from ..core.limits import upload_limit_bytes from ..core.limits import upload_limit_bytes
from ..core.models import UploadStart from ..core.models import UploadStart
from ..payments import UNPAID_HOLD
from ..runtime import PART_BYTES, storage, upload_row from ..runtime import PART_BYTES, storage, upload_row
router = APIRouter() router = APIRouter()
@@ -37,7 +38,7 @@ def begin_upload(body: UploadStart, session_id=Depends(owner)):
usage['owned']+body.size > int(os.environ.get('OWNER_UPLOAD_QUOTA_BYTES','10737418240')) 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'))): usage['pending'] >= int(os.environ.get('MAX_PENDING_UPLOADS','10'))):
audit('upload_quota_rejected') audit('upload_quota_rejected')
raise HTTPException(429, 'Local storage quota or pending upload limit reached') raise HTTPException(429, 'Limite de armazenamento ou de envios pendentes atingido. Tente de novo mais tarde.')
multipart = storage.begin(key) multipart = storage.begin(key)
c.execute('''INSERT INTO dtf_local.uploads(id,owner,name,size,object_key,multipart_id,expires_at) c.execute('''INSERT INTO dtf_local.uploads(id,owner,name,size,object_key,multipart_id,expires_at)
VALUES(%s,%s,%s,%s,%s,%s,now()+interval '1 hour')''', VALUES(%s,%s,%s,%s,%s,%s,now()+interval '1 hour')''',
@@ -59,6 +60,9 @@ def part_url(uid: UUID, part: int, session_id=Depends(owner)):
row = upload_row(c, uid, session_id) row = upload_row(c, uid, session_id)
if row['complete'] or not 1 <= part <= math.ceil(row['size'] / PART_BYTES): if row['complete'] or not 1 <= part <= math.ceil(row['size'] / PART_BYTES):
raise HTTPException(409, 'Invalid part or completed upload') raise HTTPException(409, 'Invalid part or completed upload')
# The reservation lease is an hour; a multi-GB upload on a slow line
# takes longer, so each part it asks for keeps it alive.
c.execute("UPDATE dtf_local.uploads SET expires_at=GREATEST(expires_at,now()+interval '1 hour') WHERE id=%s", (uid,))
size = min(PART_BYTES, row['size']-(part-1)*PART_BYTES) size = min(PART_BYTES, row['size']-(part-1)*PART_BYTES)
return {'url': storage.part_url(row['object_key'], row['multipart_id'], part, size)} return {'url': storage.part_url(row['object_key'], row['multipart_id'], part, size)}
@@ -84,7 +88,10 @@ def complete_upload(uid: UUID, session_id=Depends(owner)):
existing_size = storage.size(row['object_key']) existing_size = storage.size(row['object_key'])
if existing_size != row['size']: if existing_size != row['size']:
raise HTTPException(409, 'Stored size differs from declared size') raise HTTPException(409, 'Stored size differs from declared size')
c.execute("UPDATE dtf_local.uploads SET complete=true,expires_at=now()+interval '30 days' WHERE id=%s", (uid,)) # Held while the cart is unpaid: an abandoned cart's files go after
# UNPAID_HOLD; a paid order keeps them for its 30 days (app/payments.py).
c.execute('UPDATE dtf_local.uploads SET complete=true,expires_at=now()+%s::interval WHERE id=%s',
(UNPAID_HOLD, uid))
return {'id': uid, 'complete': True} return {'id': uid, 'complete': True}
@router.delete('/api/uploads/{uid}') @router.delete('/api/uploads/{uid}')

View File

@@ -12,6 +12,26 @@ from fastapi import HTTPException
from .runtime import upload_row from .runtime import upload_row
from .scanning import require_clean from .scanning import require_clean
def generated_owner(c, order, ref, kind):
"""The owner to look a generated print file up under, or None if it is not one.
A generated file may be approved as the final for the item it was made
from, and only while that item still has its original artwork: after a
customer correction it reproduces a layout nobody wants printed.
"""
generated = c.execute('''SELECT item_index FROM dtf_local.print_files
WHERE order_id=%s AND upload_id=%s AND status='ready' ''', (order['id'], ref.upload_id)).fetchone()
if not generated:
return None
if kind != 'final' or generated['item_index'] != ref.item_index:
raise HTTPException(422, 'A generated print file can only be the final file of its own item')
if c.execute("SELECT 1 FROM dtf_local.order_files WHERE order_id=%s AND kind='correction' LIMIT 1",
(order['id'],)).fetchone():
raise HTTPException(409, 'A customer correction replaced the artwork this file was generated from')
# A large sheet's print file is its original, owned by the customer.
return c.execute('SELECT owner FROM dtf_local.uploads WHERE id=%s', (ref.upload_id,)).fetchone()['owner']
def submit_files(c, order, body, identity, kind, actor): def submit_files(c, order, body, identity, kind, actor):
if order['version'] != body.version: if order['version'] != body.version:
raise HTTPException(409, 'Order changed. Refresh before submitting files.') raise HTTPException(409, 'Order changed. Refresh before submitting files.')
@@ -32,7 +52,7 @@ def submit_files(c, order, body, identity, kind, actor):
if expiry <= datetime.now(timezone.utc): if expiry <= datetime.now(timezone.utc):
raise HTTPException(410, 'Order artwork retention has expired') raise HTTPException(410, 'Order artwork retention has expired')
for ref in body.files: for ref in body.files:
upload = upload_row(c, ref.upload_id, identity, lock=True) upload = upload_row(c, ref.upload_id, generated_owner(c, order, ref, kind) or identity, lock=True)
if not upload['complete']: if not upload['complete']:
raise HTTPException(409, 'Complete all uploads first') raise HTTPException(409, 'Complete all uploads first')
require_clean(upload) require_clean(upload)
@@ -47,9 +67,13 @@ def submit_files(c, order, body, identity, kind, actor):
for ref in body.files: 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)', 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)) (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)) # The order's window, whatever the upload's own hold was.
c.execute('UPDATE dtf_local.uploads SET 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'],)) c.execute('UPDATE dtf_local.orders SET version=version+1,updated_at=now() WHERE id=%s', (order['id'],))
if kind == 'final': if kind == 'final':
# Artwork approval, not commercial quote approval, starts original cleanup. # 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,)) # An original approved as its own print file is kept as the final.
finals = [ref.upload_id for ref in body.files]
c.execute("UPDATE dtf_local.uploads SET expires_at=LEAST(expires_at,now()+interval '7 days') WHERE id=ANY(%s) AND NOT id=ANY(%s)",
(original_ids, finals))
return {'ok': True, 'version': order['version']+1, 'expires_at': expiry} return {'ok': True, 'version': order['version']+1, 'expires_at': expiry}

View File

@@ -45,7 +45,7 @@ def session_row(request):
try: try:
sid = UUID(request.cookies.get('dtf_session', '')) sid = UUID(request.cookies.get('dtf_session', ''))
except ValueError: except ValueError:
raise HTTPException(401, 'Start a local session first') raise HTTPException(401, 'Sessão não iniciada. Recarregue a página.')
with connect() as c: with connect() as c:
row = c.execute('SELECT * FROM dtf_local.sessions WHERE id=%s AND expires_at>now()', (sid,)).fetchone() row = c.execute('SELECT * FROM dtf_local.sessions WHERE id=%s AND expires_at>now()', (sid,)).fetchone()
if not row: if not row:
@@ -92,20 +92,35 @@ def rate_limit(scope, identity, limit, seconds=900):
RETURNING attempts""", (key,seconds,seconds)).fetchone() RETURNING attempts""", (key,seconds,seconds)).fetchone()
if row['attempts'] > limit: if row['attempts'] > limit:
audit('rate_limit', scope=scope) audit('rate_limit', scope=scope)
raise HTTPException(429, 'Too many requests. Try again later.', headers={'Retry-After':str(seconds)}) raise HTTPException(429, 'Muitas tentativas. Tente de novo mais tarde.', headers={'Retry-After':str(seconds)})
ACCOUNT_FAILURES = 10
def throttle(email, request): def throttle(email, request):
# Independent account and source buckets prevent bypass by rotating emails. # Independent account and source buckets prevent bypass by rotating emails.
# Every attempt counts against the source. Only failures count against the
# account: guessing a password is what the account bucket stops, and
# counting successful sign-ins too let ordinary use lock an operator out.
rate_limit('auth-source', client_ip(request), 60) rate_limit('auth-source', client_ip(request), 60)
rate_limit('auth-account', email, 10) key = hashlib.sha256(('auth-account|'+email).encode()).hexdigest()
with connect() as c:
row = c.execute("""SELECT attempts FROM dtf_local.login_attempts
WHERE key=%s AND started_at >= now()-interval '900 seconds'""", (key,)).fetchone()
if row and row['attempts'] >= ACCOUNT_FAILURES:
audit('rate_limit', scope='auth-account')
raise HTTPException(429, 'Muitas tentativas. Tente de novo mais tarde.', headers={'Retry-After': '900'})
def login_failed(email):
"""Count a failed sign-in (or registration attempt) against the account."""
rate_limit('auth-account', email, 1_000_000)
def operator(request: Request): def operator(request: Request):
token = request.cookies.get('dtf_operator', '') token = request.cookies.get('dtf_operator', '')
if not token or len(token)>128: if not token or len(token)>128:
raise HTTPException(401, 'Sign in to the local Kanban') raise HTTPException(401, 'Entre no Kanban.')
digest = hashlib.sha256(token.encode()).hexdigest() digest = hashlib.sha256(token.encode()).hexdigest()
with connect() as c: with connect() as c:
row = c.execute('SELECT username FROM dtf_local.operator_sessions WHERE token_hash=%s AND expires_at>now()', (digest,)).fetchone() row = c.execute('SELECT username FROM dtf_local.operator_sessions WHERE token_hash=%s AND expires_at>now()', (digest,)).fetchone()
if not row: if not row:
raise HTTPException(401, 'Operator session expired') raise HTTPException(401, 'Sua sessão expirou. Entre de novo.')
return row['username'] return row['username']

View File

@@ -1,13 +1,13 @@
"""Limits shared by upload admission and the malware scanner.""" """Limits shared by upload admission and the malware scanner."""
import os import os
CLAMAV_STREAM_MAX_BYTES = 128 * 1024 * 1024 # infra/clamd.conf CLAMAV_STREAM_MAX_BYTES = 2000 * 1024 * 1024 # infra/clamd.conf StreamMaxLength
def scan_limit_bytes(): def scan_limit_bytes():
return min(CLAMAV_STREAM_MAX_BYTES, int(os.environ.get('SCAN_MAX_BYTES', '134217728'))) """The largest file ClamAV scans; above it the format check releases it."""
return min(CLAMAV_STREAM_MAX_BYTES, int(os.environ.get('SCAN_MAX_BYTES', str(CLAMAV_STREAM_MAX_BYTES))))
def upload_limit_bytes(): def upload_limit_bytes():
transport = int(os.environ.get('MAX_UPLOAD_BYTES', '5368709120')) return int(os.environ.get('MAX_UPLOAD_BYTES', '5368709120'))
return min(transport, scan_limit_bytes())

View File

@@ -41,9 +41,38 @@ class Customer(StrictModel):
return value.strip() return value.strip()
class Freight(StrictModel): class Freight(StrictModel):
service: Literal['pickup','mock-standard'] = 'pickup' service: Literal['pickup','mock-standard','jadlog'] = 'pickup'
postal_code: str = Field(default='', max_length=8) postal_code: str = Field(default='', max_length=8)
class FreightEstimate(Freight):
"""The cart's estimate. The charged freight is quoted again by the server
from the approved items, never from these numbers."""
metres: Decimal | None = Field(default=None, gt=0, le=12000)
declared_cents: int = Field(default=0, ge=0, le=100_000_000, strict=True)
UF = Literal['AC','AL','AP','AM','BA','CE','DF','ES','GO','MA','MT','MS','MG','PA','PB',
'PR','PE','PI','RJ','RN','RS','RO','RR','SC','SP','SE','TO']
class Destination(StrictModel):
"""Where a shipped order goes. A CEP alone quotes freight; it does not deliver."""
recipient: str = Field(min_length=2, max_length=120)
street: str = Field(min_length=2, max_length=160)
number: str = Field(min_length=1, max_length=20)
complement: str = Field(default='', max_length=80)
district: str = Field(min_length=2, max_length=80)
city: str = Field(min_length=2, max_length=80)
state: UF
postal_code: str = Field(pattern=r'^[0-9]{8}$')
@field_validator('recipient', 'street', 'number', 'complement', 'district', 'city', mode='before')
@classmethod
def trimmed(cls, value):
if isinstance(value, str):
value = ' '.join(value.split())
if any(ord(ch) < 32 for ch in value):
raise ValueError('Invalid characters')
return value
class UploadStart(StrictModel): class UploadStart(StrictModel):
name: str = Field(min_length=1, max_length=200, pattern=r'^[^/\\\x00-\x1f]+$') name: str = Field(min_length=1, max_length=200, pattern=r'^[^/\\\x00-\x1f]+$')
size: int = Field(gt=0, strict=True) size: int = Field(gt=0, strict=True)
@@ -136,6 +165,18 @@ class QuoteRequest(StrictModel):
customer: Customer customer: Customer
items: list[Item] = Field(min_length=1, max_length=30) items: list[Item] = Field(min_length=1, max_length=30)
freight: Freight freight: Freight
destination: Destination | None = None
@model_validator(mode='after')
def destination_matches_freight(self):
if self.freight.service == 'pickup':
if self.destination is not None:
raise ValueError('Pickup orders do not take a delivery address')
elif self.destination is None:
raise ValueError('Delivery requires the full address')
elif self.destination.postal_code != self.freight.postal_code:
raise ValueError('The delivery address CEP must be the CEP freight was quoted for')
return self
class Review(StrictModel): class Review(StrictModel):
items: list[Item] = Field(min_length=1, max_length=30) items: list[Item] = Field(min_length=1, max_length=30)
@@ -143,15 +184,70 @@ class Review(StrictModel):
class Pay(StrictModel): class Pay(StrictModel):
quote_id: UUID quote_id: UUID
class PaymentMethod(StrictModel):
type: Literal['pix', 'card']
# Card fields come from the provider's own form, which tokenises the card
# in the browser; the number never reaches this server.
token: str | None = Field(default=None, max_length=200)
payment_method_id: str | None = Field(default=None, max_length=40, pattern=r'^[a-z_]+$')
installments: int = Field(default=1, ge=1, le=12, strict=True)
issuer_id: str | None = Field(default=None, max_length=40)
# The cardholder's document from the card form: for a card, the payer is
# the cardholder, not necessarily the company on the invoice.
payer_document_type: Literal['CPF', 'CNPJ'] | None = None
payer_document: str | None = Field(default=None, pattern=r'^[0-9]{11,14}$')
@model_validator(mode='after')
def card_needs_token(self):
if self.type == 'card' and not (self.token and self.payment_method_id):
raise ValueError('Card payment requires the provider token and method')
return self
class PaymentIntent(StrictModel):
quote_id: UUID
method: PaymentMethod
class Move(StrictModel): class Move(StrictModel):
state: Literal['rec','tra','fil','imp','cor','fin'] state: Literal['rec','tra','fil','imp','cor','fin']
version: int = Field(ge=0) version: int = Field(ge=0)
reason: str = Field(default='', max_length=1000) reason: str = Field(default='', max_length=1000)
class Resolution(StrictModel):
note: str = Field(min_length=3, max_length=1000)
class Register(StrictModel): class Register(StrictModel):
customer: Customer customer: Customer
password: str = Field(min_length=12, max_length=128) password: str = Field(min_length=12, max_length=128)
class ProfileUpdate(StrictModel):
"""What a customer may change about their account; the CNPJ is not among it."""
zap: str
address: Destination | None = None
@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
class EmailChange(StrictModel):
email: str = Field(max_length=254)
password: str = Field(min_length=1, max_length=128)
@field_validator('email')
@classmethod
def email_valid(cls, value):
value = value.strip().lower()
if not re.fullmatch(r'[^\s@]+@[^\s@]+\.[a-zA-Z]{2,}', value):
raise ValueError('Invalid email')
return value
class PasswordChange(StrictModel):
current: str = Field(min_length=1, max_length=128)
new: str = Field(min_length=12, max_length=128)
class Login(StrictModel): class Login(StrictModel):
email: str = Field(min_length=3, max_length=254) email: str = Field(min_length=3, max_length=254)
password: str = Field(min_length=1, max_length=128) password: str = Field(min_length=1, max_length=128)

189
app/grade_check.py Normal file
View File

@@ -0,0 +1,189 @@
"""The grade (the resolution discount) recomputed from the uploaded files.
The Site grades the art in the customer's browser and sends the grade with the
cart, and the price depends on it. A customer who edits the page could claim a
better grade, so before a cart is approved at checkout the server reads the
files it already has and works the grade out again, by the Site's own rules:
- a finished sheet: its pixel width over the sheet width is its DPI, and the
item's grade comes from the worst sheet (web/site-quality.js);
- artworks: each one's DPI along the width it is printed at, rotation
included; the item's grade is the average of the artworks' grades;
- a PDF: the area-weighted DPI of the images drawn on its page, 300 when it
is only vectors (web/site-pdf.js, dpiDasImagens and resumoDpi).
Images are measured from their first bytes, never decoded. A PDF is opened
only up to PDF_MAX_BYTES, the size the Site itself analyses. A grade that
cannot be recomputed is None; the caller decides what that means.
"""
import math
import os
import struct
import tempfile
DPI_IDEAL = 300
# The Site analyses PDFs up to 150 MB (GRANDE_BYTES); above that it does not
# grade them, so neither does this.
PDF_MAX_BYTES = 150 * 1048576
HEAD_BYTES = 1048576
# Rounding in the browser and here can differ by a point on the same file.
TOLERANCE = 2
def js_round(value):
"""Math.round, which rounds halves up; Python's round() does not."""
return math.floor(value + 0.5)
def grade_of(dpi):
return max(6, min(100, js_round(dpi / DPI_IDEAL * 100)))
def image_size(head):
"""(width, height) in pixels from a PNG, JPEG or WebP's first bytes."""
if head[:8] == b'\x89PNG\r\n\x1a\n' and head[12:16] == b'IHDR':
return struct.unpack('>II', head[16:24])
if head[:2] == b'\xff\xd8':
i = 2
while i + 9 < len(head):
if head[i] != 0xFF:
i += 1
continue
marker = head[i + 1]
if marker in (0xD8, 0x01) or 0xD0 <= marker <= 0xD7 or marker == 0xFF:
i += 1 if marker == 0xFF else 2
continue
length = struct.unpack('>H', head[i + 2:i + 4])[0]
if 0xC0 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC):
h, w = struct.unpack('>HH', head[i + 5:i + 9])
return w, h
i += 2 + length
return None
if head[:4] == b'RIFF' and head[8:12] == b'WEBP':
chunk = head[12:16]
if chunk == b'VP8X':
return (int.from_bytes(head[24:27], 'little') + 1, int.from_bytes(head[27:30], 'little') + 1)
if chunk == b'VP8L' and head[20] == 0x2F:
bits = int.from_bytes(head[21:25], 'little')
return (bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1
if chunk == b'VP8 ' and head[23:26] == b'\x9d\x01\x2a':
w, h = struct.unpack('<HH', head[26:30])
return w & 0x3FFF, h & 0x3FFF
return None
def _multiply(a, b):
return [a[0] * b[0] + a[2] * b[1], a[1] * b[0] + a[3] * b[1],
a[0] * b[2] + a[2] * b[3], a[1] * b[2] + a[3] * b[3],
a[0] * b[4] + a[2] * b[5] + a[4], a[1] * b[4] + a[3] * b[5] + a[5]]
def pdf_dpi(path):
"""The page's area-weighted image DPI, 300 for vectors only, None if the
file is not a one-page PDF this can read."""
import pikepdf
try:
with pikepdf.open(path) as pdf:
if len(pdf.pages) != 1:
return None
page = pdf.pages[0]
unit = float(page.obj.get('/UserUnit', 1))
found = []
def walk(resources, instructions, ctm, depth):
stack = []
xobjects = resources.get('/XObject', {}) if resources is not None else {}
for operands, operator in instructions:
op = str(operator)
if op == 'q':
stack.append(ctm)
elif op == 'Q':
ctm = stack.pop() if stack else [1, 0, 0, 1, 0, 0]
elif op == 'cm':
ctm = _multiply(ctm, [float(v) for v in operands])
elif op == 'Do' and operands:
xobject = xobjects.get(str(operands[0]))
if xobject is None:
continue
subtype = xobject.get('/Subtype')
if subtype == '/Image':
width_pt = math.hypot(ctm[0], ctm[1])
if width_pt >= 1:
found.append((int(xobject.get('/Width', 0)) / (width_pt * unit / 72),
abs(ctm[0] * ctm[3] - ctm[1] * ctm[2])))
elif subtype == '/Form' and depth < 8:
matrix = [float(v) for v in xobject.get('/Matrix', [1, 0, 0, 1, 0, 0])]
walk(xobject.get('/Resources', resources),
pikepdf.parse_content_stream(xobject), _multiply(ctm, matrix), depth + 1)
walk(page.obj.get('/Resources'), pikepdf.parse_content_stream(page), [1, 0, 0, 1, 0, 0], 0)
except Exception:
return None
found = [(dpi, area) for dpi, area in found if dpi > 0]
if not found:
return DPI_IDEAL
total = sum(area for _, area in found) or 1
return js_round(sum(dpi * area for dpi, area in found) / total)
class Files:
"""The uploaded files, read from storage as little as needed."""
def __init__(self, storage):
self.storage = storage
def head(self, row):
return self.storage.client.get_object(Bucket=self.storage.bucket, Key=row['object_key'],
Range=f'bytes=0-{HEAD_BYTES - 1}')['Body'].read()
def pdf_dpi(self, row):
if row['size'] > PDF_MAX_BYTES:
return None
with tempfile.NamedTemporaryFile(suffix='.pdf') as handle:
self.storage.client.download_fileobj(self.storage.bucket, row['object_key'], handle)
handle.flush()
return pdf_dpi(handle.name)
def source_dpi(files, row, source):
"""One source's DPI across the width it is printed at, or None."""
name = row['name'].lower()
width_in = float(source['width_cm']) / 2.54
if name.endswith('.pdf'):
return files.pdf_dpi(row) if source['kind'] == 'sheet' else None
if not name.endswith(('.png', '.jpg', '.jpeg', '.webp')):
return None
size = image_size(files.head(row))
if not size or not all(size):
return None
across = size[1] if source['rotation_degrees'] in (90, 270) else size[0]
return across / width_in
def item_grade(files, item, rows):
"""The grade the Site gives this item, recomputed; None if it cannot be."""
sources = item['production']['sources']
dpis = []
for source in sources:
row = rows.get(str(source['upload_id']))
dpi = source_dpi(files, row, source) if row else None
if dpi is None:
return None
dpis.append(dpi)
if all(source['kind'] == 'sheet' for source in sources):
# A sheet's DPI is shown and compared as a whole number.
return grade_of(min(js_round(d) for d in dpis))
return js_round(sum(grade_of(d) for d in dpis) / len(dpis))
def mismatch(files, items, rows):
"""Why a cart's grades cannot be approved at checkout, or None."""
for item in items:
if item['grade'] == 0:
continue # full price: nothing to verify
verified = item_grade(files, item, rows)
if verified is None:
return 'Nota não conferida no servidor'
if item['grade'] > verified + TOLERANCE:
return f"Nota {item['grade']} maior que a do arquivo ({verified})"
return None

119
app/jadlog.py Normal file
View File

@@ -0,0 +1,119 @@
"""Jadlog freight quotes (Embarcador API, "Simulador de Frete").
Written from Jadlog's API manual v2.3 (August 2025) and exercised only
against a fake HTTP transport until app.jadlog_probe has run on the client's
account. A quote is read-only: it creates no shipment and costs nothing, so
unlike Tiny it can be tried on the real account as often as needed.
The client's contract: origin CEP 14402-310, service .PACKAGE (modalidade 3),
home delivery. Jadlog prices by weight in kg and expects the larger of the
real and the cubed weight. The package weight comes from the client (a base
plus a weight per billed metre) and has no default: FREIGHT_ADAPTER=jadlog
refuses to start without it, so a guessed weight never prices a customer's
freight.
"""
import os
from decimal import ROUND_HALF_UP, Decimal
import httpx
QUOTE_URL = 'https://www.jadlog.com.br/embarcador/api/frete/valor'
PACKAGE = 3
ORIGIN = '14402310'
SERVICE = 'jadlog'
WEIGHT_SETTINGS = ('JADLOG_PESO_BASE_KG', 'JADLOG_PESO_POR_METRO_KG')
class JadlogError(Exception):
pass
def digits(value):
return ''.join(ch for ch in str(value or '') if ch.isdigit())
class JadlogQuotes:
def __init__(self, token=None, cnpj=None, conta=None, contrato=None, origin=None,
modalidade=None, transport=None):
self.token = (token or os.environ.get('JADLOG_TOKEN', '')).strip()
self.cnpj = digits(cnpj or os.environ.get('JADLOG_CNPJ'))
# Sent as configured: Jadlog issues it as 000000-0 and documents six
# characters, so which form it accepts is only known on the account.
self.conta = (conta if conta is not None else os.environ.get('JADLOG_CONTA', '')).strip()
# Only when Jadlog issued one; the manual says to send null otherwise.
self.contrato = (contrato if contrato is not None else os.environ.get('JADLOG_CONTRATO', '')).strip() or None
self.origin = digits(origin or os.environ.get('JADLOG_ORIGEM_CEP') or ORIGIN)
self.modalidade = int(modalidade or os.environ.get('JADLOG_MODALIDADE') or PACKAGE)
if not self.token or len(self.cnpj) != 14:
raise RuntimeError('Jadlog needs JADLOG_TOKEN and a 14-digit JADLOG_CNPJ')
self.http = httpx.Client(timeout=20, transport=transport)
def item(self, postal_code, weight_kg, declared_cents):
return {'cepori': self.origin, 'cepdes': digits(postal_code), 'frap': 'N',
'peso': float(weight_kg), 'cnpj': self.cnpj, 'conta': self.conta,
'contrato': self.contrato, 'modalidade': self.modalidade,
'tpentrega': 'D', 'tpseguro': 'N',
'vldeclarado': declared_cents / 100, 'vlcoleta': 0}
def quote(self, postal_code, weight_kg, declared_cents):
"""Price in centavos and delivery days for one package to one CEP."""
response = self.http.post(QUOTE_URL, json={'frete': [self.item(postal_code, weight_kg, declared_cents)]},
headers={'Authorization': self.token})
if response.status_code == 401:
raise JadlogError('Jadlog refused the token (HTTP 401)')
try:
data = response.json()
except ValueError:
raise JadlogError(f'HTTP {response.status_code}: the response is not JSON') from None
items = data.get('frete') or []
# The manual names the group "erro" in its tables and "error" in its
# examples, at the top level and per item.
problem = data.get('error') or data.get('erro')
if not problem and items:
problem = items[0].get('erro') or items[0].get('error')
if problem:
raise JadlogError(problem.get('descricao') or str(problem) if isinstance(problem, dict) else str(problem))
if response.status_code >= 400 or not items or items[0].get('vltotal') is None:
raise JadlogError(f'HTTP {response.status_code}: no freight value in the response')
total = Decimal(str(items[0]['vltotal']))
return {'total_cents': int((total * 100).quantize(Decimal('1'), ROUND_HALF_UP)),
'days': items[0].get('prazo'), 'raw': items[0]}
def required_settings():
return ('JADLOG_TOKEN', 'JADLOG_CNPJ') + WEIGHT_SETTINGS
class JadlogFreight:
"""The Site's delivery option: Jadlog's price for the order's package."""
name = 'jadlog'
def __init__(self, quotes=None):
self.quotes = quotes or JadlogQuotes()
self.base_kg = Decimal(os.environ['JADLOG_PESO_BASE_KG'])
self.per_metre_kg = Decimal(os.environ['JADLOG_PESO_POR_METRO_KG'])
# Jadlog's time counts from collection; the order is printed first.
self.production_days = int(os.environ.get('FREIGHT_PRODUCTION_DAYS') or 0)
if self.base_kg < 0 or self.per_metre_kg <= 0 or self.production_days < 0:
raise RuntimeError('Jadlog package weight and production days must be positive')
def weight_kg(self, metres):
return (self.base_kg + self.per_metre_kg * Decimal(str(metres))).quantize(Decimal('0.001'))
def quote(self, service, postal_code, metres=None, declared_cents=0):
if service == 'pickup':
return {'provider': self.name, 'service': 'pickup', 'total_cents': 0, 'postal_code': ''}
if service != SERVICE or len(postal_code) != 8 or not postal_code.isascii() or not postal_code.isdigit():
raise ValueError('Select pickup or Jadlog delivery with an eight-digit CEP')
if metres is None or Decimal(str(metres)) <= 0:
raise ValueError('The order length is required to quote freight')
weight = self.weight_kg(metres)
try:
result = self.quotes.quote(postal_code, weight, int(declared_cents))
except (JadlogError, httpx.HTTPError) as error:
raise ValueError(f'Não foi possível cotar o frete na Jadlog agora: {error}') from None
days = result['days']
return {'provider': self.name, 'service': SERVICE, 'postal_code': postal_code,
'total_cents': result['total_cents'], 'weight_kg': str(weight),
'days': None if days is None else int(days) + self.production_days,
'description': 'Jadlog .PACKAGE'}

80
app/jadlog_probe.py Normal file
View File

@@ -0,0 +1,80 @@
"""Read-only price check against the client's real Jadlog account, run by hand.
A quote creates no shipment and costs nothing.
python -m app.jadlog_probe [--cep 01310100 ...] [--peso 0.5 ...] [--valor 100]
Without --cep and --peso it prices a few test weights to a few regions. It
answers whether the token, CNPJ and account are accepted, whether a contract
number is required (Jadlog names it in the error), and what the contract
prices and delivery times are. The first failure stops the run: an account
problem would repeat on every line.
In production, from the worker's console (Portainer > Containers > worker >
Console), with JADLOG_TOKEN, JADLOG_CNPJ and JADLOG_CONTA in the stack:
python -m app.jadlog_probe
Locally, with the credentials in jadlog.env (ignored by git):
docker compose -f compose.local.yaml -f compose.providers.yaml run --rm --no-deps --build \\
--env-from-file jadlog.env worker python -m app.jadlog_probe
"""
import argparse
import sys
from decimal import Decimal
from .core.secrets import load as load_secret_files
from .jadlog import JadlogError, JadlogQuotes
DESTINATIONS = {'14402310': 'Franca (origem)', '01310100': 'São Paulo', '20040002': 'Rio de Janeiro',
'80010000': 'Curitiba', '50030230': 'Recife', '69005010': 'Manaus'}
WEIGHTS = ['0.3', '0.5', '1', '2', '5']
def money(cents):
return f'R$ {cents / 100:.2f}'.replace('.', ',')
def run(quotes, ceps, weights, declared_cents, out):
header = quotes.token
for weight in weights:
for cep in ceps:
try:
result = quotes.quote(cep, Decimal(weight), declared_cents)
except JadlogError as error:
# The manual shows the header as the bare token; some accounts
# are issued one that expects the Bearer scheme.
if '401' in str(error) and not header.lower().startswith('bearer '):
quotes.token = 'Bearer ' + header
header = quotes.token
try:
result = quotes.quote(cep, Decimal(weight), declared_cents)
except JadlogError as retry:
out(f'ERRO {cep} {weight} kg: {retry} (also with "Bearer ")')
return 1
out('Note: the token only works with the "Bearer " prefix.')
else:
out(f'ERRO {cep} {weight} kg: {error}')
return 1
days = result['days']
out(f"{weight} kg\t{cep}\t{DESTINATIONS.get(cep, '')}\t{money(result['total_cents'])}\t"
f"{days if days is not None else '?'} dia(s)")
return 0
def main(argv=None):
parser = argparse.ArgumentParser(prog='python -m app.jadlog_probe')
parser.add_argument('--cep', action='append', help='destination CEP (repeatable)')
parser.add_argument('--peso', action='append', help='weight in kg (repeatable)')
parser.add_argument('--valor', default='100', help='declared value in reais (default 100)')
args = parser.parse_args(argv)
load_secret_files()
quotes = JadlogQuotes()
declared = int(Decimal(args.valor) * 100)
print(f'Origem {quotes.origin}, modalidade {quotes.modalidade}, contrato {quotes.contrato or "-"}, '
f'valor declarado {money(declared)}')
return run(quotes, args.cep or list(DESTINATIONS), args.peso or WEIGHTS, declared, print)
if __name__ == '__main__':
sys.exit(main())

196
app/mercadopago.py Normal file
View File

@@ -0,0 +1,196 @@
"""Mercado Pago: payment creation, webhook verification and status lookup.
Written from the public API documentation and exercised only against a fake
HTTP transport. It is not a verified integration until it has passed the
sandbox flows in docs/PRODUCTION_INPUTS.md with the client's own account;
until then the runtime refuses to select it without explicit credentials.
The notification is only a pointer. Its body says "payment 123 changed" and
nothing about amount or status, so nothing in it is trusted beyond the id:
the payment is fetched from the API with our access token, and that response
is what the order service compares against the approved quote.
Signature (x-signature: "ts=<unix>,v1=<hex>"): HMAC-SHA256, keyed with the
webhook secret from the integration panel, over the manifest
"id:<data.id>;request-id:<x-request-id>;ts:<ts>;", where data.id comes from
the notification URL's query string (lower-cased when alphanumeric). A part
whose value is absent from the notification is left out of the manifest.
"""
import hashlib
import hmac
import json
import os
import time
from datetime import datetime, timedelta, timezone
from decimal import Decimal, InvalidOperation
from typing import Mapping
import httpx
from .adapters import PaymentEvent
API = 'https://api.mercadopago.com'
# How old a signed timestamp may be. Mercado Pago retries a failed delivery
# every 15 minutes, re-signing each attempt, so a replayed old one is refused.
MAX_SIGNATURE_AGE = 30 * 60
STATUSES = {'approved': 'approved', 'pending': 'pending', 'in_process': 'pending',
'authorized': 'pending', 'in_mediation': 'pending', 'rejected': 'rejected',
'cancelled': 'cancelled', 'refunded': 'refunded', 'charged_back': 'refunded'}
# A PIX code stops working after this; Mercado Pago then cancels the payment.
PIX_MINUTES = 30
BRASILIA = timezone(timedelta(hours=-3))
class MercadoPagoPayment:
name = 'mercadopago'
def __init__(self, access_token=None, webhook_secret=None, notification_url=None,
transport=None, clock=time.time):
self.access_token = access_token or os.environ.get('MP_ACCESS_TOKEN', '')
self.webhook_secret = (webhook_secret or os.environ.get('MP_WEBHOOK_SECRET', '')).encode()
self.notification_url = notification_url or os.environ.get('MP_NOTIFICATION_URL', '')
if not self.access_token or not self.webhook_secret:
raise RuntimeError('Mercado Pago needs MP_ACCESS_TOKEN and MP_WEBHOOK_SECRET')
self.http = httpx.Client(base_url=API, transport=transport, timeout=15,
headers={'Authorization': f'Bearer {self.access_token}'})
self.clock = clock
# Payments ------------------------------------------------------------
def create(self, quote_id: str, total_cents: int, customer: dict, method: dict | None = None) -> dict:
"""Create a payment for an approved quote.
The quote id is the idempotency key, so a retry after a timeout returns
the payment already created rather than charging again. `method` is
{'type': 'pix'} or {'type': 'card', 'token', 'payment_method_id',
'installments', 'issuer_id'} from Mercado Pago's card form: card data is
tokenised in the customer's browser and never reaches this server.
"""
method = method or {'type': 'pix'}
body = {'transaction_amount': float(Decimal(total_cents) / 100),
'description': f'DTF - cotação {quote_id[:8]}',
'external_reference': quote_id,
'payer': {'email': customer['mail'],
'identification': {'type': 'CNPJ', 'number': customer['cnpj']}}}
if self.notification_url:
body['notification_url'] = self.notification_url
expires_at = None
if method['type'] == 'pix':
body['payment_method_id'] = 'pix'
expires_at = (datetime.now(BRASILIA) + timedelta(minutes=PIX_MINUTES)).isoformat(timespec='milliseconds')
body['date_of_expiration'] = expires_at
elif method['type'] == 'card':
body.update(token=method['token'], payment_method_id=method['payment_method_id'],
installments=int(method.get('installments', 1)))
# 3-D Secure (the bank's confirmation) only for debit, which needs
# it; asking it of every card was refused as a rule (10113).
if method['payment_method_id'].startswith('deb'):
body['three_d_secure_mode'] = 'optional'
if method.get('payer_document_type') and method.get('payer_document'):
body['payer']['identification'] = {'type': method['payer_document_type'],
'number': method['payer_document']}
# No issuer_id: Mercado Pago takes the issuer from the card number.
# The form's suggestion was refused for its own test cards
# (10111, "the issuer does not have the BIN configured").
else:
raise ValueError('Unsupported payment method')
# A PIX retry must return the same code; a new one, after the last
# expired, is the next attempt. A card retry after a decline is a new
# attempt with a new token, so the token is part of the key; the intent
# route refuses new attempts once one is approved or in review.
key = f"dtf-quote-{quote_id}-pix-{int(method.get('attempt', 1))}" if method['type'] == 'pix' else \
f"dtf-quote-{quote_id}-card-{hashlib.sha256(method['token'].encode()).hexdigest()[:24]}"
response = self.http.post('/v1/payments', json=body, headers={'X-Idempotency-Key': key})
response.raise_for_status()
payment = response.json()
transaction = (payment.get('point_of_interaction') or {}).get('transaction_data') or {}
# A payment waiting for 3-D Secure carries the bank's challenge page,
# which the Site shows in a frame by posting `creq` to that address.
three_ds = payment.get('three_ds_info') or {}
challenge = ({'url': three_ds['external_resource_url'], 'creq': three_ds['creq']}
if payment.get('status_detail') == 'pending_challenge'
and three_ds.get('external_resource_url') and three_ds.get('creq') else None)
return {'provider': self.name, 'id': str(payment['id']),
'status': STATUSES.get(payment.get('status'), 'pending'),
'status_detail': payment.get('status_detail'),
'total_cents': total_cents,
'pix_qr_code': transaction.get('qr_code'),
'pix_qr_code_base64': transaction.get('qr_code_base64'),
'ticket_url': transaction.get('ticket_url'),
'expires_at': payment.get('date_of_expiration') or expires_at,
'challenge': challenge}
def lookup(self, payment_id: str) -> dict:
response = self.http.get(f'/v1/payments/{payment_id}')
response.raise_for_status()
return response.json()
# Webhooks ------------------------------------------------------------
def verify(self, headers: Mapping[str, str], body: bytes, query: Mapping[str, str] | None = None) -> bool:
signature = headers.get('x-signature') or ''
parts = dict(part.strip().split('=', 1) for part in signature.split(',') if '=' in part)
ts, supplied = parts.get('ts', ''), parts.get('v1', '')
if not ts.isdigit() or not supplied:
return False
if abs(self.clock() - int(ts[:10])) > MAX_SIGNATURE_AGE:
return False
data_id = (query or {}).get('data.id', '')
if data_id.isalnum():
data_id = data_id.lower()
manifest = ''
if data_id:
manifest += f'id:{data_id};'
request_id = headers.get('x-request-id') or ''
if request_id:
manifest += f'request-id:{request_id};'
manifest += f'ts:{ts};'
expected = hmac.new(self.webhook_secret, manifest.encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, supplied.lower())
def parse(self, body: bytes, query: Mapping[str, str] | None = None) -> PaymentEvent | None:
try:
data = json.loads(body)
except ValueError:
return None
if not isinstance(data, dict) or data.get('type') != 'payment':
return None
payment_id = str((query or {}).get('data.id') or (data.get('data') or {}).get('id') or '')
if not payment_id.isdigit():
return None
try:
payment = self.lookup(payment_id)
except httpx.HTTPStatusError as error:
# Signed by Mercado Pago but about no payment of ours, such as the
# panel's "Simular notificação". Anything else is raised so that a
# real notification is retried.
if error.response.status_code == 404:
return None
raise
return event_from_payment(payment, notification_id=str(data.get('id', '')))
def event_from_payment(payment: dict, notification_id: str = '') -> PaymentEvent:
"""Normalise a payment fetched from the API. Amount is None unless it is BRL
and a whole number of centavos, so a foreign or malformed amount is refused."""
amount = None
if payment.get('currency_id') == 'BRL':
try:
cents = Decimal(str(payment.get('transaction_amount'))) * 100
if cents == cents.to_integral_value():
amount = int(cents)
except (InvalidOperation, TypeError, ValueError):
amount = None
status = STATUSES.get(payment.get('status'), 'pending')
# One event per payment state: a notification id alone would let the same
# approval be applied twice under two notifications, and would collapse a
# later refund into the earlier approval.
event_id = f"{payment.get('id')}:{payment.get('status')}"
return PaymentEvent(event_id=event_id, reference=str(payment.get('external_reference') or ''),
status=status, amount_cents=amount,
raw={'provider': 'mercadopago', 'payment_id': str(payment.get('id')),
'status': payment.get('status'), 'status_detail': payment.get('status_detail'),
'currency_id': payment.get('currency_id'),
'transaction_amount': payment.get('transaction_amount'),
'date_approved': payment.get('date_approved'),
'notification_id': notification_id})

65
app/mercadopago_probe.py Normal file
View File

@@ -0,0 +1,65 @@
"""Read-only look at the Mercado Pago account behind MP_ACCESS_TOKEN.
python -m app.mercadopago_probe [--bin 503143] [--valor 50]
Run from the api container's console (Portainer > Containers > api >
Console), or with "Verificar conta" on the Kanban's Integrations tab. It creates nothing and charges nothing: it asks Mercado Pago which
account the token belongs to, which card methods the account accepts, and how
it classifies a card number's first digits (type, issuer, instalments). That
is what payment errors such as 10111 (issuer) and 10113 (method excluded by a
rule) depend on.
"""
import argparse
import os
import sys
import httpx
from .core.secrets import load as load_secret_files
API = 'https://api.mercadopago.com'
def check(token, bin_='548083', amount='50', transport=None):
"""The account, its card methods and how a card's first digits are read."""
http = httpx.Client(base_url=API, timeout=15, transport=transport,
headers={'Authorization': f'Bearer {token}'})
result = {'token': 'test' if token.startswith('TEST-') else 'production'}
me = http.get('/users/me')
if me.status_code == 200:
user = me.json()
tags = user.get('tags') or []
result['account'] = {'id': user.get('id'), 'nickname': user.get('nickname'),
'site': user.get('site_id'), 'test_user': 'test_user' in tags, 'tags': tags}
else:
result['account'] = {'error': f'HTTP {me.status_code}'}
methods = http.get('/v1/payment_methods')
result['cards'] = ([{'id': m.get('id'), 'type': m.get('payment_type_id'), 'status': m.get('status')}
for m in methods.json() if m.get('payment_type_id') in ('credit_card', 'debit_card')]
if methods.status_code == 200 else {'error': f'HTTP {methods.status_code}'})
options = http.get('/v1/payment_methods/installments', params={'bin': bin_, 'amount': amount})
result['bin'] = ([{'method': o.get('payment_method_id'), 'type': o.get('payment_type_id'),
'issuer': (o.get('issuer') or {}).get('name'),
'installments': [c.get('installments') for c in o.get('payer_costs') or []]}
for o in options.json()]
if options.status_code == 200 else {'error': f'HTTP {options.status_code} {options.text[:200]}'})
return result
def main(argv=None):
parser = argparse.ArgumentParser(prog='python -m app.mercadopago_probe')
parser.add_argument('--bin', default='548083', help="the card's first six digits")
parser.add_argument('--valor', default='50', help='amount in reais for the instalment lookup')
args = parser.parse_args(argv)
load_secret_files()
token = os.environ.get('MP_ACCESS_TOKEN', '')
if not token:
print('MP_ACCESS_TOKEN is not set in this container.')
return 1
import json
print(json.dumps(check(token, args.bin, args.valor), indent=2, ensure_ascii=False))
return 0
if __name__ == '__main__':
sys.exit(main())

View File

@@ -15,6 +15,7 @@ from uuid import UUID, uuid4
from psycopg.types.json import Jsonb from psycopg.types.json import Jsonb
from .core.auth import audit from .core.auth import audit
from .printjobs import queue as queue_print_files
from .runtime import enqueue, upload_row from .runtime import enqueue, upload_row
from .scanning import require_clean from .scanning import require_clean
@@ -40,6 +41,34 @@ def approved_quote(c, quote_id, owner=None):
return row return row
TEST_PROVIDER = 'teste'
def is_test(order):
"""An operator's test order: it goes through production and notifies no one."""
return (order.get('payment') or {}).get('provider') == TEST_PROVIDER
# How long a file is kept while nothing has been paid for it. A cart's files
# are uploaded before payment, so the price and the security check are done on
# the file itself; a cart that is never paid must not keep them for 30 days.
UNPAID_HOLD = '2 days'
# A quote waiting for an operator's review keeps its files this long.
REVIEW_HOLD = '7 days'
# Once paid, the order keeps its originals for this long from the upload.
ORDER_RETENTION = '30 days'
def quote_uploads(quote_or_draft):
return [uid for item in quote_or_draft['items'] for uid in item['uploads']]
def hold_uploads(c, upload_ids, interval):
"""Keep these files at least `interval` from now; never shortens a hold."""
c.execute('''UPDATE dtf_local.uploads SET expires_at=GREATEST(expires_at, now()+%s::interval)
WHERE id=ANY(%s) AND purged_at IS NULL''', (interval, [str(u) for u in upload_ids]))
def create_order(c, quote, payment): def create_order(c, quote, payment):
"""Create the order for a reviewed quote, or return the one already there. """Create the order for a reviewed quote, or return the one already there.
@@ -59,6 +88,12 @@ def create_order(c, quote, payment):
order = c.execute( order = c.execute(
'INSERT INTO dtf_local.orders(id,quote_id,owner,snapshot,payment) VALUES(%s,%s,%s,%s,%s) RETURNING *', 'INSERT INTO dtf_local.orders(id,quote_id,owner,snapshot,payment) VALUES(%s,%s,%s,%s,%s) RETURNING *',
(uuid4(), quote['id'], quote['owner'], Jsonb(approved), Jsonb(payment))).fetchone() (uuid4(), quote['id'], quote['owner'], Jsonb(approved), Jsonb(payment))).fetchone()
# Paid: the files are kept for the order's retention, counted from upload.
c.execute('''UPDATE dtf_local.uploads SET expires_at=GREATEST(expires_at, created_at+%s::interval)
WHERE id=ANY(%s) AND purged_at IS NULL''', (ORDER_RETENTION, quote_uploads(approved)))
queue_print_files(c, order['id'], len(approved['items']))
if is_test(order):
return order, True
for provider in ('tiny', 'whatsapp'): for provider in ('tiny', 'whatsapp'):
enqueue(c, f"{order['id']}:paid:{provider}", provider, enqueue(c, f"{order['id']}:paid:{provider}", provider,
{'order_id': str(order['id']), 'number': order['number'], {'order_id': str(order['id']), 'number': order['number'],
@@ -77,7 +112,25 @@ def record(c, provider, event):
def apply(c, event): def apply(c, event):
"""Act on a payment notification. Returns the outcome recorded against it.""" """Act on a payment notification. Returns the outcome recorded against it.
Outcomes starting 'refused' (money arrived, no order) or 'attention' (an
order exists but its payment was reversed) stay on the Kanban until an
operator records a resolution.
"""
provider_id = event.raw.get('payment_id')
if provider_id:
c.execute('''UPDATE dtf_local.payment_intents SET status=%s, updated_at=now()
WHERE provider_payment_id=%s''', (event.status, provider_id))
if event.status in ('refunded', 'cancelled'):
try:
order = c.execute('SELECT number FROM dtf_local.orders WHERE quote_id=%s',
(UUID(event.reference),)).fetchone()
except (ValueError, AttributeError):
order = None
if order:
audit('payment_reversed', order=order['number'], status=event.status)
return f"attention: payment {event.status} for order {order['number']}"
if event.status != 'approved': if event.status != 'approved':
return f'ignored: {event.status}' return f'ignored: {event.status}'
@@ -101,5 +154,5 @@ def apply(c, event):
expected_cents=expected, paid_cents=event.amount_cents) expected_cents=expected, paid_cents=event.amount_cents)
return f'refused: paid {event.amount_cents} but quote total is {expected}' return f'refused: paid {event.amount_cents} but quote total is {expected}'
order, created = create_order(c, quote, {'provider': 'webhook', **event.raw}) order, created = create_order(c, quote, {'provider': event.raw.get('provider', 'webhook'), **event.raw})
return f"order {order['number']}" + ('' if created else ' (already existed)') return f"order {order['number']}" + ('' if created else ' (already existed)')

489
app/printfile.py Normal file
View File

@@ -0,0 +1,489 @@
"""The print file: the reviewed layout, reproduced exactly, at the film's size.
The customer is quoted on a layout the Site computes: each copy of each artwork
at a width, rotation, mirror and position on the film. Production spec v2 keeps
that layout through the approved order. This module turns it into one PDF per
order item, with a page exactly as wide as the film and as long as the layout,
so the operator imports what the customer approved instead of rebuilding it.
Each source image is embedded once, at its original resolution, and every copy
is a placement of it. Nothing is resampled: a 300 DPI artwork is still 300 DPI
in the file, a JPEG keeps its original bytes, and transparency survives as a
soft mask. The output is therefore about the size of the artwork, not of a
57 cm x 20 m raster, and it never needs that raster in memory.
Raster sources are JPEG, PNG, WebP and TIFF. A single-page PDF is placed as a
vector form, never rasterised. Anything else (PSD, AI, CDR, multi-page or
protected PDFs), or a file whose proportions do not match the size it was
quoted at, is refused with a reason, and the operator prepares that item by
hand exactly as before.
"""
import math
import os
import tempfile
import zlib
from decimal import Decimal
from PIL import Image, ImageOps
PT_PER_CM = 72 / 2.54
# Acrobat's page limit. Longer layouts scale user space with /UserUnit (PDF 1.6)
# rather than cutting the film into pages a RIP might print with gaps.
MAX_PAGE_PT = 14400
# How far a file's proportions may drift from the quoted size before the item
# is refused: rounding in the Site keeps real files well inside this.
ASPECT_TOLERANCE = 0.01
# The quote may round up (10 cm steps, 1 m minimum) but never down.
HEIGHT_TOLERANCE_CM = Decimal('0.05')
SUPPORTED_FORMATS = {'JPEG', 'PNG', 'WEBP', 'TIFF'}
STRIP_ROWS = 256
# Decoding holds the whole image in memory (about 4 bytes a pixel). 57 cm x 3 m
# at 300 DPI is 239 Mpx; above this the item goes to the operator instead.
MAX_DECODED_PIXELS = int(os.environ.get('PRINT_MAX_PIXELS', '250000000'))
# Pillow's own bomb guard would refuse a genuine long sheet before we can
# decide; the explicit limit above is the one that applies.
Image.MAX_IMAGE_PIXELS = None
class Unsupported(Exception):
"""This item cannot be generated automatically. The reason is shown to the
operator on the Kanban, so it is written in Portuguese."""
class Name(str):
pass
class Ref(int):
pass
def serialize(value):
if isinstance(value, Ref):
return b'%d 0 R' % value
if isinstance(value, Name):
return b'/' + value.encode('ascii')
if isinstance(value, bool):
return b'true' if value else b'false'
if isinstance(value, int):
return b'%d' % value
if isinstance(value, (float, Decimal)):
text = f'{float(value):.4f}'.rstrip('0').rstrip('.')
return (text if text not in ('', '-0') else '0').encode('ascii')
if isinstance(value, dict):
return b'<<' + b''.join(b'/' + k.encode('ascii') + b' ' + serialize(v)
for k, v in value.items()) + b'>>'
if isinstance(value, (list, tuple)):
return b'[' + b' '.join(serialize(v) for v in value) + b']'
if isinstance(value, str):
escaped = value.replace('\\', '\\\\').replace('(', '\\(').replace(')', '\\)')
return b'(' + escaped.encode('latin-1', 'replace') + b')'
raise TypeError(f'Cannot serialize {type(value).__name__}')
class PdfWriter:
"""A sequential PDF writer: objects go straight to the file, streams included.
Stream lengths are indirect objects written after the data, so an image is
compressed strip by strip into the output without being held in memory.
"""
def __init__(self, fp):
self.fp = fp
self.offsets = {}
self.next_id = 1
self.write(b'%PDF-1.6\n%\xe2\xe3\xcf\xd3\n')
def write(self, data):
self.fp.write(data)
def tell(self):
return self.fp.tell()
def alloc(self):
ref = Ref(self.next_id)
self.next_id += 1
return ref
def obj(self, value, ref=None):
ref = ref or self.alloc()
self.offsets[ref] = self.tell()
self.write(b'%d 0 obj\n' % ref + serialize(value) + b'\nendobj\n')
return ref
def stream(self, dictionary, chunks, ref=None):
"""Write a stream from an iterable of already-encoded byte chunks."""
ref = ref or self.alloc()
length = self.alloc()
self.offsets[ref] = self.tell()
self.write(b'%d 0 obj\n' % ref + serialize({**dictionary, 'Length': length}) + b'\nstream\n')
start = self.tell()
for chunk in chunks:
self.write(chunk)
size = self.tell() - start
self.write(b'\nendstream\nendobj\n')
self.obj(size, length)
return ref
def finish(self, root, info):
xref = self.tell()
count = self.next_id
self.write(b'xref\n0 %d\n0000000000 65535 f \n' % count)
for ref in range(1, count):
self.write(b'%010d 00000 n \n' % self.offsets[ref])
self.write(b'trailer\n' + serialize({'Size': count, 'Root': root, 'Info': info}) +
b'\nstartxref\n%d\n%%%%EOF\n' % xref)
def deflate(pieces):
compressor = zlib.compressobj(6)
for piece in pieces:
out = compressor.compress(piece)
if out:
yield out
yield compressor.flush()
class SourceImage:
"""One customer file, opened for reading pixels and measured as the browser sees it."""
def __init__(self, path, name):
self.path = path
self.name = name
try:
self.image = Image.open(path)
self.format = self.image.format
except Image.DecompressionBombError as exc:
raise Unsupported(f'"{name}" é grande demais para gerar automaticamente') from exc
except Exception as exc:
raise Unsupported(f'"{name}" não é uma imagem que o gerador consiga ler') from exc
if self.format not in SUPPORTED_FORMATS:
raise Unsupported(f'"{name}" está em {self.format or "formato desconhecido"}; '
'só JPEG, PNG, WebP, TIFF e PDF são gerados automaticamente')
if getattr(self.image, 'n_frames', 1) > 1 and self.format != 'TIFF':
raise Unsupported(f'"{name}" é animado ou tem vários quadros')
# Browsers draw a photo upright according to its EXIF orientation, and
# the Site measured it that way, so the print must too.
try:
self.orientation = self.image.getexif().get(0x0112, 1)
except Exception:
self.orientation = 1
width, height = self.image.size
self.size = (height, width) if self.orientation in (5, 6, 7, 8) else (width, height)
def passthrough(self):
"""Whether the original JPEG bytes can go into the PDF unchanged."""
return (self.format == 'JPEG' and self.orientation == 1 and
self.image.mode in ('L', 'RGB', 'CMYK'))
def embed(self, pdf):
"""Write this image (and its alpha) as XObjects; returns the image reference."""
if self.passthrough():
return self._embed_jpeg(pdf)
width, height = self.image.size
if width * height > MAX_DECODED_PIXELS:
raise Unsupported(f'"{self.name}" tem {width} × {height} px, mais do que o '
'gerador processa; prepare este item à mão')
return self._embed_pixels(pdf)
def _colorspace(self, pdf, mode):
components = {'L': 1, 'RGB': 3, 'CMYK': 4}[mode]
device = Name({'L': 'DeviceGray', 'RGB': 'DeviceRGB', 'CMYK': 'DeviceCMYK'}[mode])
profile = self.image.info.get('icc_profile')
if not profile:
return device
icc = pdf.stream({'N': components, 'Alternate': device, 'Filter': Name('FlateDecode')},
deflate([profile]))
return [Name('ICCBased'), icc]
def _embed_jpeg(self, pdf):
image = self.image
extra = {}
if image.mode == 'CMYK' and 'adobe' in image.info:
# Adobe writes CMYK JPEGs inverted; PDF readers expect the Decode flip.
extra['Decode'] = [1, 0, 1, 0, 1, 0, 1, 0]
def chunks():
with open(self.path, 'rb') as source:
while block := source.read(1 << 20):
yield block
return pdf.stream({'Type': Name('XObject'), 'Subtype': Name('Image'),
'Width': image.width, 'Height': image.height,
'ColorSpace': self._colorspace(pdf, image.mode),
'BitsPerComponent': 8, 'Filter': Name('DCTDecode'), **extra},
chunks())
def _upright(self):
image = self.image
image.load()
if self.orientation != 1:
image = ImageOps.exif_transpose(image)
return image
def _embed_pixels(self, pdf):
image = self._upright()
mode = image.mode
has_alpha = mode in ('RGBA', 'LA', 'PA', 'RGBa', 'La') or (
mode == 'P' and 'transparency' in image.info) or (
mode in ('L', 'RGB') and 'transparency' in image.info)
if mode in ('I;16', 'I;16B', 'I;16L', 'I'):
image = image.convert('I').point(lambda value: value * (1 / 257)).convert('L')
mode = 'L'
if mode == 'CMYK':
color_mode = 'CMYK'
elif mode in ('1', 'L', 'LA', 'La'):
color_mode = 'L'
elif mode in ('P', 'PA', 'RGB', 'RGBA', 'RGBa'):
color_mode = 'RGB'
else:
raise Unsupported(f'"{self.name}" usa o modo de cor {mode}, que não é gerado automaticamente')
if has_alpha:
image = image.convert('RGBA' if color_mode == 'RGB' else 'LA')
width, height = image.size
def strips(convert):
for top in range(0, height, STRIP_ROWS):
yield convert(image.crop((0, top, width, min(height, top + STRIP_ROWS)))).tobytes()
smask = None
if has_alpha:
smask = pdf.stream({'Type': Name('XObject'), 'Subtype': Name('Image'),
'Width': width, 'Height': height,
'ColorSpace': Name('DeviceGray'), 'BitsPerComponent': 8,
'Filter': Name('FlateDecode')},
deflate(strips(lambda strip: strip.getchannel('A'))))
colorspace = self._colorspace(pdf, color_mode)
dictionary = {'Type': Name('XObject'), 'Subtype': Name('Image'),
'Width': width, 'Height': height, 'ColorSpace': colorspace,
'BitsPerComponent': 8, 'Filter': Name('FlateDecode')}
if smask:
dictionary['SMask'] = smask
return pdf.stream(dictionary, deflate(strips(lambda strip: strip.convert(color_mode))))
def close(self):
self.image.close()
def placement_matrix(placement, page_height_pt):
"""Map the image's unit square onto its box on the film.
Matches the Site's canvas: the artwork is mirrored first, then turned
clockwise about the centre of its box, and the turned image fills the box.
"""
x = float(placement['x_cm']) * PT_PER_CM
top = page_height_pt - float(placement['y_cm']) * PT_PER_CM
w = float(placement['width_cm']) * PT_PER_CM
h = float(placement['length_cm']) * PT_PER_CM
rotation = placement['rotation_degrees'] % 360
mirrored = placement['mirrored']
def to_page(u, v):
# Unit square (v up) -> image frame (s right, t down).
s, t = u, 1 - v
if mirrored:
s = 1 - s
s, t = {0: (s, t), 90: (1 - t, s), 180: (1 - s, 1 - t), 270: (t, 1 - s)}[rotation]
return x + s * w, top - t * h
ox, oy = to_page(0, 0)
ax, ay = to_page(1, 0)
cx, cy = to_page(0, 1)
return [ax - ox, ay - oy, cx - ox, cy - oy, ox, oy]
class PdfPage:
"""One page of a customer PDF, placed as a vector form: nothing is rasterised.
The box and orientation are the ones the Site measured with pdf.js: the
CropBox (which pikepdf uses for the form's BBox), turned by the page's
/Rotate, inherited or not.
"""
def __init__(self, path, name):
import pikepdf
self.name = name
try:
self.pdf = pikepdf.open(path)
except pikepdf.PasswordError as exc:
raise Unsupported(f'"{name}" está protegido por senha') from exc
except Exception as exc:
raise Unsupported(f'"{name}" não é um PDF que o gerador consiga ler') from exc
try:
pages = len(self.pdf.pages)
if pages != 1:
raise Unsupported(f'"{name}" tem {pages} páginas; só PDFs de uma página são gerados automaticamente')
self.page = self.pdf.pages[0]
self.rotation = int(self.page.rotation) % 360
if self.rotation not in (0, 90, 180, 270):
raise Unsupported(f'"{name}" tem uma rotação de página não suportada')
box = [float(v) for v in self.page.cropbox]
except Unsupported:
self.pdf.close()
raise
except Exception as exc:
self.pdf.close()
raise Unsupported(f'"{name}" tem uma página que o gerador não consegue ler') from exc
self.x0, self.y0 = min(box[0], box[2]), min(box[1], box[3])
self.w, self.h = abs(box[2] - box[0]), abs(box[3] - box[1])
if self.w <= 0 or self.h <= 0:
self.pdf.close()
raise Unsupported(f'"{name}" tem uma página vazia')
# As displayed, which is what the proportions are checked against.
self.size = (self.h, self.w) if self.rotation in (90, 270) else (self.w, self.h)
def normalise(self):
"""Matrix from the page's box, as displayed, onto the unit square."""
a, d = 1 / self.w, 1 / self.h
scale = [a, 0, 0, d, -self.x0 * a, -self.y0 * d]
turn = {0: [1, 0, 0, 1, 0, 0], 90: [0, -1, 1, 0, 0, 1],
180: [-1, 0, 0, -1, 1, 1], 270: [0, 1, -1, 0, 1, 0]}[self.rotation]
return multiply(scale, turn)
def form(self, target):
"""The page as a form XObject copied into the target document."""
form = self.page.as_form_xobject(handle_transformations=False)
group = self.page.obj.get('/Group')
if group is not None and '/Group' not in form:
form.Group = group
return target.copy_foreign(form)
def close(self):
self.pdf.close()
def multiply(first, then):
"""PDF matrices: a point transformed by `first`, then by `then`."""
a1, b1, c1, d1, e1, f1 = first
a2, b2, c2, d2, e2, f2 = then
return [a1 * a2 + b1 * c2, a1 * b2 + b1 * d2, c1 * a2 + d1 * c2, c1 * b2 + d1 * d2,
e1 * a2 + f1 * c2 + e2, e1 * b2 + f1 * d2 + f2]
def open_source(path, name):
with open(path, 'rb') as handle:
head = handle.read(1024)
if b'%PDF-' in head:
return PdfPage(path, name)
return SourceImage(path, name)
def check_layout(item, sizes, vector=()):
"""Refuse a layout the file cannot reproduce faithfully. Returns the lowest
DPI of the raster sources (vector pages have none)."""
production = item['production']
height = Decimal(str(production['height_cm']))
billed = Decimal(str(item['billed_metres'])) * 100
if height > billed + HEIGHT_TOLERANCE_CM:
raise Unsupported(f'a montagem tem {height} cm, mas só {billed} cm foram cobrados')
lowest = None
for placement in production['placements']:
width_px, height_px = sizes[placement['source_index']]
if placement['rotation_degrees'] % 180 == 90:
width_px, height_px = height_px, width_px
width_cm = float(placement['width_cm'])
length_cm = float(placement['length_cm'])
drift = abs((width_px / height_px) / (width_cm / length_cm) - 1)
if drift > ASPECT_TOLERANCE:
source = production['sources'][placement['source_index']]
raise Unsupported(f'o arquivo {placement["source_index"] + 1} tem proporções diferentes '
f'das cotadas ({source["width_cm"]} × {source["length_cm"]} cm)')
if placement['source_index'] in vector:
continue
dpi = width_px / (width_cm / 2.54)
lowest = dpi if lowest is None else min(lowest, dpi)
return lowest
def render(item, files, out, title):
"""Write the PDF for one approved order item.
`files` maps each source index to (local path, original name). Returns the
evidence the Kanban shows: page size, what was billed, and the lowest DPI.
Raster sources are written by the streaming writer above. PDF sources are
then added as vector forms by a second pass through pikepdf, so a sheet
exported as PDF keeps its vectors, fonts and transparency.
"""
production = item['production']
if production.get('version') != 2:
raise Unsupported('o item usa uma montagem antiga')
sources = []
try:
for index in range(len(production['sources'])):
path, name = files[index]
sources.append(open_source(path, name))
vector = {index for index, source in enumerate(sources) if isinstance(source, PdfPage)}
lowest_dpi = check_layout(item, [source.size for source in sources], vector)
width_pt = float(production['film_width_cm']) * PT_PER_CM
height_pt = float(production['height_cm']) * PT_PER_CM
unit = max(1, math.ceil(max(width_pt, height_pt) / MAX_PAGE_PT))
first = tempfile.TemporaryFile() if vector else out
try:
write_rasters(production, sources, first, title, width_pt, height_pt, unit)
if vector:
first.seek(0)
add_vector_pages(production, sources, first, out, height_pt)
finally:
if vector:
first.close()
finally:
for source in sources:
source.close()
return {'film_width_cm': str(production['film_width_cm']),
'height_cm': str(production['height_cm']),
'billed_metres': str(item['billed_metres']),
'placements': len(production['placements']),
'sources': len(sources),
'vector_sources': len(vector),
'min_dpi': round(lowest_dpi) if lowest_dpi else None,
'user_unit': unit}
def write_rasters(production, sources, out, title, width_pt, height_pt, unit):
pdf = PdfWriter(out)
images = {index: source.embed(pdf) for index, source in enumerate(sources)
if isinstance(source, SourceImage)}
# The user-space scale is not wrapped in q/Q, so it also applies to the
# vector placements appended by the second pass.
commands = [b'%s 0 0 %s 0 0 cm\n' % (serialize(1 / unit), serialize(1 / unit))] if unit > 1 else []
for placement in production['placements']:
if placement['source_index'] not in images:
continue
matrix = placement_matrix(placement, height_pt)
commands.append(b'q ' + b' '.join(serialize(v) for v in matrix) +
b' cm /Im%d Do Q\n' % placement['source_index'])
content = pdf.stream({'Filter': Name('FlateDecode')}, deflate(commands))
pages = pdf.alloc()
page_box = [0, 0, width_pt / unit, height_pt / unit]
page = {'Type': Name('Page'), 'Parent': pages, 'MediaBox': page_box, 'TrimBox': page_box,
'Resources': {'XObject': {f'Im{index}': ref for index, ref in images.items()}},
'Contents': content}
if unit > 1:
page['UserUnit'] = unit
page_ref = pdf.obj(page)
pdf.obj({'Type': Name('Pages'), 'Kids': [page_ref], 'Count': 1}, pages)
root = pdf.obj({'Type': Name('Catalog'), 'Pages': pages})
info = pdf.obj({'Title': title, 'Producer': 'DTF System print-file generator'})
pdf.finish(root, info)
def add_vector_pages(production, sources, first, out, height_pt):
import pikepdf
with pikepdf.open(first) as document:
page = document.pages[0]
xobjects = page.obj.Resources.XObject
for index, source in enumerate(sources):
if isinstance(source, PdfPage):
xobjects[f'/Pdf{index}'] = source.form(document)
commands = []
for placement in production['placements']:
source = sources[placement['source_index']]
if not isinstance(source, PdfPage):
continue
matrix = multiply(source.normalise(), placement_matrix(placement, height_pt))
commands.append(b'q ' + b' '.join(serialize(v) for v in matrix) +
b' cm /Pdf%d Do Q\n' % placement['source_index'])
page.contents_add(pikepdf.Stream(document, b''.join(commands)), prepend=False)
document.save(out, min_version='1.6')

180
app/printjobs.py Normal file
View File

@@ -0,0 +1,180 @@
"""Generating print files in the background, one order item at a time.
A paid order queues one job per item. The worker claims a job, commits the
claim, and renders outside any transaction, so a long render never holds a row
lock or a database connection. A claim older than CLAIM_TIMEOUT is assumed to
belong to a worker that died and is taken again.
The result is an ordinary upload row owned by an identity derived from the
order, already marked clean: its only inputs are artwork that passed the
malware scan, and the bytes are written here. The operator still decides
whether it becomes the final file; generation never approves anything.
Sheets of several GB are the normal order, and decoding one would take more
memory than the worker has. A source above LARGE_SOURCE_BYTES is never
opened: a finished sheet placed whole on the film is already its own print
file, so the original becomes the print file; any other layout is prepared
by hand from the original.
"""
import logging
import os
import tempfile
import time
from datetime import timedelta
from uuid import NAMESPACE_URL, UUID, uuid4, uuid5
from psycopg.types.json import Jsonb
from .core.auth import audit
from .core.db import connect
from .printfile import Unsupported, render
CLAIM_TIMEOUT = timedelta(minutes=15)
MAX_ATTEMPTS = 3
LARGE_SOURCE_BYTES = int(os.environ.get('PRINT_DECODE_MAX_BYTES', str(300 * 1024 * 1024)))
# Formats the operator can import as they are.
PRINTABLE_ORIGINAL = ('.png', '.jpg', '.jpeg', '.tif', '.tiff', '.pdf')
def generated_identity(order_id):
return uuid5(NAMESPACE_URL, f'dtf-print-file:{order_id}')
def queue(c, order_id, items, only_missing=False):
"""Queue (or re-queue) generation for every item of an order."""
for index in range(items):
if only_missing:
c.execute('''INSERT INTO dtf_local.print_files(id,order_id,item_index) VALUES(%s,%s,%s)
ON CONFLICT(order_id,item_index) DO UPDATE SET status='pending', attempts=0,
claimed_at=NULL, finished_at=NULL, detail='{}'
WHERE dtf_local.print_files.status IN ('failed','manual')''',
(uuid4(), order_id, index))
else:
c.execute('''INSERT INTO dtf_local.print_files(id,order_id,item_index) VALUES(%s,%s,%s)
ON CONFLICT(order_id,item_index) DO NOTHING''', (uuid4(), order_id, index))
def claim(c):
return c.execute('''SELECT p.*, o.snapshot, o.number FROM dtf_local.print_files p
JOIN dtf_local.orders o ON o.id=p.order_id
WHERE p.status='pending' OR (p.status='rendering' AND p.claimed_at < now()-%s)
ORDER BY p.created_at FOR UPDATE OF p SKIP LOCKED LIMIT 1''', (CLAIM_TIMEOUT,)).fetchone()
def render_one(storage):
with connect() as c:
job = claim(c)
if not job:
return False
c.execute('''UPDATE dtf_local.print_files SET status='rendering', claimed_at=now(),
attempts=attempts+1 WHERE id=%s''', (job['id'],))
item = job['snapshot']['items'][job['item_index']]
uploads = c.execute('''SELECT id,name,size,object_key,scan_state,purged_at,expires_at,
(expires_at<=now()) AS expired FROM dtf_local.uploads WHERE id=ANY(%s)''',
([UUID(u) for u in item['uploads']],)).fetchall()
# Every generated file shares its order's artwork retention deadline.
expiry = c.execute('SELECT min(created_at)+interval \'30 days\' AS e FROM dtf_local.uploads WHERE id=ANY(%s)',
([UUID(u) for u in item['uploads']],)).fetchone()['e']
by_id = {str(row['id']): row for row in uploads}
if any(row['size'] > LARGE_SOURCE_BYTES for row in uploads):
original = whole_sheet(item, by_id)
if original:
with connect() as c:
c.execute('''UPDATE dtf_local.print_files SET status='ready', upload_id=%s, detail=%s,
finished_at=now(), claimed_at=NULL WHERE id=%s''',
(original['id'], Jsonb({'source': 'original', 'name': original['name']}), job['id']))
audit('print_file_original', order=str(job['order_id']), item=job['item_index'])
else:
finish(job, 'manual', {'reason': f'arquivo acima de {LARGE_SOURCE_BYTES // 1048576} MB: '
'monte a folha a partir do original'})
audit('print_file_manual', order=str(job['order_id']), item=job['item_index'])
return True
try:
result = produce(storage, job, item, by_id)
except Unsupported as reason:
finish(job, 'manual', {'reason': str(reason)})
audit('print_file_manual', order=str(job['order_id']), item=job['item_index'])
return True
except Exception as exc:
logging.exception('Print file generation failed')
final = job['attempts'] + 1 >= MAX_ATTEMPTS
finish(job, 'failed' if final else 'pending', {'error': type(exc).__name__})
return True
path, name, size, detail = result
try:
uid = uuid4()
key = f'originals/{uid}'
storage.store(key, path, 'application/pdf')
finally:
os.unlink(path)
with connect() as c:
c.execute('''INSERT INTO dtf_local.uploads(id,owner,name,size,object_key,multipart_id,complete,
expires_at,scan_state,scan_reason,scanned_at)
VALUES(%s,%s,%s,%s,%s,'',true,%s,'clean','generated from scanned artwork',now())''',
(uid, generated_identity(job['order_id']), name, size, key, expiry))
c.execute('''UPDATE dtf_local.print_files SET status='ready', upload_id=%s, detail=%s,
finished_at=now() WHERE id=%s''', (uid, Jsonb(detail), job['id']))
audit('print_file_ready', order=str(job['order_id']), item=job['item_index'])
return True
def whole_sheet(item, uploads):
"""The original, when the item is one finished sheet placed whole, once,
unrotated and unmirrored, across the film: then it is the print file."""
spec = item.get('production') or {}
sources, placements = spec.get('sources') or [], spec.get('placements') or []
if len(item['uploads']) != 1 or len(sources) != 1 or len(placements) != 1:
return None
source, place = sources[0], placements[0]
row = uploads.get(item['uploads'][0])
if (not row or row['scan_state'] != 'clean' or row['purged_at'] or row['expired']
or source.get('kind') != 'sheet' or int(source.get('copies', 1)) != 1
or not row['name'].lower().endswith(PRINTABLE_ORIGINAL)):
return None
if (float(place['x_cm']) != 0 or float(place['y_cm']) != 0 or int(place['rotation_degrees']) != 0
or place['mirrored'] or abs(float(source['width_cm']) - float(spec['film_width_cm'])) > 0.5):
return None
return row
def produce(storage, job, item, uploads):
"""Fetch the item's artwork and render it. Returns (pdf path, name, size, evidence)."""
for upload_id in item['uploads']:
row = uploads.get(upload_id)
if not row or row['scan_state'] != 'clean':
raise Unsupported('um arquivo original está ausente ou não foi liberado pelo antivírus')
if row['purged_at'] or row['expired']:
raise Unsupported('um arquivo original passou do prazo de guarda')
number = job['number']
name = f'pedido-{number}-item-{job["item_index"] + 1}.pdf'
with tempfile.TemporaryDirectory(prefix='print-') as scratch:
files = {}
for index, upload_id in enumerate(item['uploads']):
local = os.path.join(scratch, f'source-{index}')
storage.fetch(uploads[upload_id]['object_key'], local)
files[index] = (local, uploads[upload_id]['name'])
handle, output = tempfile.mkstemp(prefix='print-', suffix='.pdf')
try:
with os.fdopen(handle, 'wb') as out:
detail = render(item, files, out, f'Pedido {number} - item {job["item_index"] + 1}')
except BaseException:
os.unlink(output)
raise
return output, name, os.path.getsize(output), detail
def finish(job, status, detail):
with connect() as c:
c.execute('''UPDATE dtf_local.print_files SET status=%s, detail=%s,
finished_at=CASE WHEN %s='pending' THEN NULL ELSE now() END,
claimed_at=NULL WHERE id=%s''', (status, Jsonb(detail), status, job['id']))
def render_loop(storage):
while True:
try:
if render_one(storage):
continue
except Exception:
logging.exception('Print render worker tick failed')
time.sleep(2)

89
app/quote_review.py Normal file
View File

@@ -0,0 +1,89 @@
"""Quote approval: automatic at checkout, by an operator for the exceptions.
A quote the customer can pay is priced here from the server's own ladders,
whether an operator approved it on the Kanban or the Site approved it the
moment it was created. The Site is a shop: a customer who can price the order
should be able to pay for it straight away, at any hour, so only orders a
person must look at before charging wait for the Kanban (review_reason).
The grade (and so the discount) is worked out in the customer's browser and
checked against the files before an automatic approval (app/grade_check.py);
a grade the files do not support waits for review. The layout is still the
browser's (roadmap 3.9): the API checks its geometry. Operators see every
order's grade and artwork at Arte recebida.
"""
import os
from decimal import Decimal
from fastapi import HTTPException
from psycopg.types.json import Jsonb
from . import payments
from .core.pricing import price
from .runtime import freight, upload_row
from .scanning import require_clean
AUTO = 'auto'
def auto_approve_enabled():
return os.environ.get('QUOTE_AUTO_APPROVE', 'true').lower() == 'true'
def max_auto_metres():
return Decimal(os.environ.get('QUOTE_AUTO_MAX_METRES', '50'))
def review_reason(draft):
"""Why this quote needs a person before it can be paid, or None."""
if not auto_approve_enabled():
return 'Aprovação automática desligada'
total = sum(Decimal(str(item['metres'])) for item in draft['items'])
if total > max_auto_metres():
return f'Pedido acima de {max_auto_metres():g} m'
for item in draft['items']:
if item.get('production', {}).get('version') != 2:
return 'Montagem antiga'
# The Site grades only the art it could analyse; anything else is
# priced at the full rate. A discount on it did not come from the Site.
if item['quality_status'] == 'unverified' and item['grade'] != 0:
return 'Nota informada sem análise da arte'
return None
def approve(c, row, items, reviewer):
"""Price the reviewed items and bind them to the quote; returns the approval.
`row` must be locked by the caller."""
draft = row['draft']
if row['approved']:
raise HTTPException(409, 'Approved quotes are immutable; request a new quote')
if any(item.get('production', {}).get('version') != 2 for item in draft['items']):
raise HTTPException(409, 'Quote uses an obsolete production layout; customer must request a new quote')
if len(items) != len(draft['items']):
raise HTTPException(422, 'Review must cover every item')
priced = []
for item, original in zip(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')
if item.production.model_dump(mode='json') != original['production'] or item.quality_status != original['quality_status'] or item.quality_acknowledged != original['quality_acknowledged']:
raise HTTPException(422, 'Production instructions and customer acknowledgement cannot change during commercial review')
for upload_id in item.uploads:
require_clean(upload_row(c, upload_id, row['owner']))
priced.append({**price(item.mode, str(item.metres), item.grade),
'uploads': original['uploads'], 'production': original['production'],
'quality_status': original['quality_status'],
'quality_acknowledged': original['quality_acknowledged']})
metres = sum(Decimal(i['billed_metres']) for i in priced)
try:
quoted_freight = freight.quote(draft['freight']['service'], draft['freight'].get('postal_code', ''),
metres, sum(i['total_cents'] for i in priced))
except ValueError as exc:
raise HTTPException(422, str(exc))
approved = {'customer': draft['customer'], 'items': priced, 'freight': quoted_freight,
'destination': draft.get('destination'),
'total_cents': sum(i['total_cents'] for i in priced) + quoted_freight['total_cents']}
c.execute('UPDATE dtf_local.quotes SET approved=%s, reviewed_by=%s, approved_at=now() WHERE id=%s',
(Jsonb(approved), reviewer, row['id']))
# Approved and payable now: the files wait for the payment, not for ever.
payments.hold_uploads(c, payments.quote_uploads(approved), payments.UNPAID_HOLD)
return approved

View File

@@ -20,8 +20,16 @@ load_secret_files()
require_runtime() require_runtime()
storage = LocalS3Storage() storage = LocalS3Storage()
payment = FakePayment() if os.environ.get('PAYMENT_ADAPTER') == 'mercadopago':
freight = FakeFreight() from .mercadopago import MercadoPagoPayment
payment = MercadoPagoPayment()
else:
payment = FakePayment()
if os.environ.get('FREIGHT_ADAPTER') == 'jadlog':
from .jadlog import JadlogFreight
freight = JadlogFreight()
else:
freight = FakeFreight()
ENVIRONMENT = os.environ.get('APP_ENV', 'local') ENVIRONMENT = os.environ.get('APP_ENV', 'local')
PUBLIC_ORIGIN = os.environ.get('PUBLIC_ORIGIN', 'http://localhost') PUBLIC_ORIGIN = os.environ.get('PUBLIC_ORIGIN', 'http://localhost')
ALLOWED_HOSTS = [host for host in os.environ.get('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',') if host] ALLOWED_HOSTS = [host for host in os.environ.get('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',') if host]
@@ -42,6 +50,16 @@ STATES = {'rec': 'Arte recebida', 'tra': 'Arte tratada', 'fil': 'Fila de impress
'imp': 'Imprimindo', 'cor': 'Correção', 'fin': 'Finalizado'} 'imp': 'Imprimindo', 'cor': 'Correção', 'fin': 'Finalizado'}
TRANSITIONS = {'rec': ['tra','cor'], 'tra': ['fil','cor'], 'fil': ['imp','cor'], TRANSITIONS = {'rec': ['tra','cor'], 'tra': ['fil','cor'], 'fil': ['imp','cor'],
'imp': ['fin','cor'], 'cor': ['rec','tra'], 'fin': []} 'imp': ['fin','cor'], 'cor': ['rec','tra'], 'fin': []}
# Undoing a move made by mistake: one stage back, with an internal reason. It
# is not a correction: the customer is not told and approved finals stay.
BACK = {'tra': 'rec', 'fil': 'tra', 'imp': 'fil', 'fin': 'imp'}
def require_delivery_available(service):
"""Outside the local stack, a simulated freight price must never reach a
customer: until a real freight provider exists, only pickup is offered."""
if service != 'pickup' and ENVIRONMENT != 'local' and getattr(freight, 'name', '') == 'fake':
raise HTTPException(503, 'A entrega ainda não está disponível. Escolha a retirada em Franca.')
def upload_row(c, upload_id, session_id, lock=False): def upload_row(c, upload_id, session_id, lock=False):
@@ -56,11 +74,19 @@ def upload_row(c, upload_id, session_id, lock=False):
def quote_view(c, row): def quote_view(c, row):
from .quote_review import review_reason # it imports this module
order = c.execute('SELECT id,number,state FROM dtf_local.orders WHERE quote_id=%s', (row['id'],)).fetchone() order = c.execute('SELECT id,number,state FROM dtf_local.orders WHERE quote_id=%s', (row['id'],)).fetchone()
# The latest payment attempt, so the payment page can tell a refused card
# (the notification updates it) from one still waiting.
attempt = c.execute('''SELECT method,status,response->>'status_detail' AS status_detail
FROM dtf_local.payment_intents WHERE quote_id=%s ORDER BY created_at DESC LIMIT 1''', (row['id'],)).fetchone()
expired = row['approved_at'] and row['approved_at'] < datetime.now(timezone.utc)-timedelta(hours=24) expired = row['approved_at'] and row['approved_at'] < datetime.now(timezone.utc)-timedelta(hours=24)
return {'id': row['id'], 'created_at': row['created_at'], return {'id': row['id'], 'created_at': row['created_at'],
'draft': row['draft'], 'approved': row['approved'], 'draft': row['draft'], 'approved': row['approved'],
'status': 'paid' if order else 'expired' if expired else 'approved' if row['approved'] else 'pending_review', 'status': 'paid' if order else 'expired' if expired else 'approved' if row['approved'] else 'pending_review',
'auto_approved': row.get('reviewed_by') == 'auto',
'review_reason': None if row['approved'] else (row.get('review_note') or review_reason(row['draft'])),
'payment': attempt,
'order': order} 'order': order}

View File

@@ -1,4 +1,13 @@
"""Local ClamAV boundary. Unknown/error/over-limit results NEVER release artwork.""" """Releasing artwork: ClamAV up to its size limit, a format check above it.
Unknown or error results NEVER release artwork. ClamAV scans files up to
scan_limit_bytes() (2 GB). Sheets of several GB are the normal order and
ClamAV cannot take them, so a larger file is released only if its first bytes
are those of the format its name claims (a PNG that really is a PNG, not a
program renamed .png). That is the check the client chose for large files; it
does not look for malware inside a valid file.
"""
import re
import socket import socket
import struct import struct
import time import time
@@ -30,10 +39,9 @@ class ClamAV:
return self.command(b'VERSION').decode('utf-8','replace') return self.command(b'VERSION').decode('utf-8','replace')
def scan(self, stream, size): def scan(self, stream, size):
if size > scan_limit_bytes():
return 'rejected', 'File exceeds the malware scan limit'
with socket.create_connection(('scanner',3310),timeout=10) as sock: with socket.create_connection(('scanner',3310),timeout=10) as sock:
sock.settimeout(150) # A 2 GB file takes minutes to stream and scan.
sock.settimeout(900)
sock.sendall(b'zINSTREAM\0') sock.sendall(b'zINSTREAM\0')
sent=0 sent=0
for chunk in stream.iter_chunks(chunk_size=65536): for chunk in stream.iter_chunks(chunk_size=65536):
@@ -52,6 +60,37 @@ class ClamAV:
if result.endswith(b' FOUND'):return 'rejected','Malware or unsafe scan condition detected' if result.endswith(b' FOUND'):return 'rejected','Malware or unsafe scan condition detected'
return 'error','Scanner could not verify this file' return 'error','Scanner could not verify this file'
# What each accepted extension must start with. AI files are PDF or PostScript;
# CDR and WebP are RIFF containers with their own form type.
TIFF = (b'II*\x00', b'MM\x00*', b'II+\x00', b'MM\x00+')
SIGNATURES = {
'png': (b'\x89PNG\r\n\x1a\n',),
'jpg': (b'\xff\xd8\xff',), 'jpeg': (b'\xff\xd8\xff',),
'tif': TIFF, 'tiff': TIFF,
'pdf': (b'%PDF-',), 'ai': (b'%PDF-', b'%!PS'),
'psd': (b'8BPS',), 'psb': (b'8BPS',),
}
RIFF_FORMS = {'cdr': re.compile(rb'^RIFF....CDR', re.S), 'webp': re.compile(rb'^RIFF....WEBP', re.S)}
HEAD_BYTES = 64
def format_matches(name, head):
"""Whether a file's first bytes are those of the format its name claims."""
ext = name.rsplit('.', 1)[-1].lower() if '.' in name else ''
if ext in RIFF_FORMS:
return bool(RIFF_FORMS[ext].match(head))
return any(head.startswith(sig) for sig in SIGNATURES.get(ext, ()))
def check_large(storage, row):
"""Release decision for a file above the antivirus limit."""
head = storage.client.get_object(Bucket=storage.bucket, Key=row['object_key'],
Range=f'bytes=0-{HEAD_BYTES - 1}')['Body'].read()
if format_matches(row['name'], head):
return 'clean', 'Acima do limite do antivírus; formato do arquivo conferido'
return 'rejected', 'O conteúdo do arquivo não corresponde ao formato do nome'
def scan_one(storage, scanner=None): def scan_one(storage, scanner=None):
scanner=scanner or ClamAV() scanner=scanner or ClamAV()
with connect() as c: with connect() as c:
@@ -60,6 +99,9 @@ def scan_one(storage, scanner=None):
ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1''').fetchone() ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1''').fetchone()
if not row:return False if not row:return False
try: try:
if row['size'] > scan_limit_bytes():
state,reason=check_large(storage,row)
else:
stream=storage.client.get_object(Bucket=storage.bucket,Key=row['object_key'])['Body'] stream=storage.client.get_object(Bucket=storage.bucket,Key=row['object_key'])['Body']
try:state,reason=scanner.scan(stream,row['size']) try:state,reason=scanner.scan(stream,row['size'])
finally:stream.close() finally:stream.close()

View File

@@ -90,6 +90,61 @@ CREATE TABLE IF NOT EXISTS dtf_local.order_files (
note text NOT NULL, created_by text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), note text NOT NULL, created_by text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE(order_id,upload_id,kind) UNIQUE(order_id,upload_id,kind)
); );
-- A payment started at the provider for an approved quote: which provider
-- payment belongs to which quote, and its latest known status.
CREATE TABLE IF NOT EXISTS dtf_local.payment_intents (
id uuid PRIMARY KEY, quote_id uuid NOT NULL REFERENCES dtf_local.quotes(id),
provider text NOT NULL, provider_payment_id text NOT NULL, method text NOT NULL,
status text NOT NULL, amount_cents bigint NOT NULL, response jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE(provider, provider_payment_id)
);
-- OAuth connections to providers (Tiny). One row per provider; the refresh
-- token rotates on use, so it lives here, never in configuration.
CREATE TABLE IF NOT EXISTS dtf_local.provider_tokens (
provider text PRIMARY KEY, access_token text NOT NULL, refresh_token text NOT NULL,
access_expires_at timestamptz NOT NULL, refresh_expires_at timestamptz,
connected_by text NOT NULL, connected_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Renewal health, cleared by the next successful renewal or connection.
-- refused_at: the provider rejected the refresh token, so only a new
-- connection helps. offline: the grant is not bound to a login session.
ALTER TABLE dtf_local.provider_tokens ADD COLUMN IF NOT EXISTS refresh_failed_at timestamptz;
ALTER TABLE dtf_local.provider_tokens ADD COLUMN IF NOT EXISTS refresh_error text;
ALTER TABLE dtf_local.provider_tokens ADD COLUMN IF NOT EXISTS refused_at timestamptz;
ALTER TABLE dtf_local.provider_tokens ADD COLUMN IF NOT EXISTS offline boolean NOT NULL DEFAULT false;
-- Single-use states for an operator-started OAuth connection. They protect the
-- callback, which arrives cross-site without the operator's cookie.
CREATE TABLE IF NOT EXISTS dtf_local.oauth_states (
state text PRIMARY KEY, provider text NOT NULL, operator text NOT NULL,
expires_at timestamptz NOT NULL
);
-- The print file generated from each paid item's approved layout. One row per
-- item: the worker claims it, renders, and records either the file or why the
-- item has to be prepared by hand. Regenerating replaces the row's result.
CREATE TABLE IF NOT EXISTS dtf_local.print_files (
id uuid PRIMARY KEY, order_id uuid NOT NULL REFERENCES dtf_local.orders(id),
item_index integer NOT NULL,
status text NOT NULL DEFAULT 'pending'
CHECK(status IN ('pending','rendering','ready','manual','failed')),
upload_id uuid REFERENCES dtf_local.uploads(id), detail jsonb NOT NULL DEFAULT '{}',
attempts integer NOT NULL DEFAULT 0, claimed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(), finished_at timestamptz,
UNIQUE(order_id,item_index)
);
-- Each run of the off-server database backup (ops/db_backup.py), which the
-- Kanban shows so a backup that stopped working does not go unnoticed.
CREATE TABLE IF NOT EXISTS dtf_local.backups (
id uuid PRIMARY KEY, started_at timestamptz NOT NULL, finished_at timestamptz NOT NULL,
status text NOT NULL CHECK(status IN ('ok','failed')), object_key text, bytes bigint, detail text
);
CREATE INDEX IF NOT EXISTS backups_finished ON dtf_local.backups(finished_at DESC);
-- Why a quote waits for review, when the reason came from its files (the grade
-- the server recomputed) and cannot be worked out again from the draft alone.
ALTER TABLE dtf_local.quotes ADD COLUMN IF NOT EXISTS review_note text;
CREATE INDEX IF NOT EXISTS uploads_owner ON dtf_local.uploads(owner); CREATE INDEX IF NOT EXISTS uploads_owner ON dtf_local.uploads(owner);
@@ -131,3 +186,22 @@ CREATE INDEX IF NOT EXISTS security_events_created ON dtf_local.security_events(
-- its own index, and it stays the size of the backlog. -- its own index, and it stays the size of the backlog.
CREATE INDEX IF NOT EXISTS payment_events_unprocessed ON dtf_local.payment_events(received_at) CREATE INDEX IF NOT EXISTS payment_events_unprocessed ON dtf_local.payment_events(received_at)
WHERE processed_at IS NULL; WHERE processed_at IS NULL;
-- The render worker polls for unclaimed or abandoned jobs; the order view reads
-- by order through the unique constraint.
CREATE INDEX IF NOT EXISTS print_files_open ON dtf_local.print_files(created_at)
WHERE status IN ('pending','rendering');
-- A movement that undid an earlier one (a mistaken move), shown as such.
ALTER TABLE dtf_local.movements ADD COLUMN IF NOT EXISTS back boolean NOT NULL DEFAULT false;
-- The Kanban lists payment events a person must act on (money without an
-- order, or a reversed payment on an existing order) until resolved.
ALTER TABLE dtf_local.payment_events ADD COLUMN IF NOT EXISTS resolved_at timestamptz;
ALTER TABLE dtf_local.payment_events ADD COLUMN IF NOT EXISTS resolved_by text;
ALTER TABLE dtf_local.payment_events ADD COLUMN IF NOT EXISTS resolution text;
CREATE INDEX IF NOT EXISTS payment_events_open_issues ON dtf_local.payment_events(received_at)
WHERE (outcome LIKE 'refused%' OR outcome LIKE 'attention%') AND resolved_at IS NULL;
-- Payment intents look up by quote (customer retry) and by provider id (webhook).
CREATE INDEX IF NOT EXISTS payment_intents_quote ON dtf_local.payment_intents(quote_id, created_at DESC);

444
app/tiny.py Normal file
View File

@@ -0,0 +1,444 @@
"""Tiny/Olist ERP (API v3): create the sales order once a payment is approved.
Written from the public API documentation and exercised only against a fake
HTTP transport. Tiny has no sandbox: the first real test creates a real order
in the client's ERP, so test with a marked order and cancel it afterwards.
Authentication is OAuth2 on Tiny's Keycloak. The client creates an
"Aplicativo" in Tiny (Configurações > Geral > Aplicativos), which yields a
client ID and secret and registers our callback URL. An operator then clicks
"Conectar Tiny" on the Kanban once; the tokens are kept in the database and
the refresh token is rotated on every refresh, under a row lock so two
workers never spend the same one.
Orders are created by contact and product id: the customer's contact is found
by CNPJ or created, and each product mode maps to a product that must already
exist in Tiny (PRODUCT_SETTINGS).
Idempotency: the outbox may deliver the same event more than once. Every order
carries numeroOrdemCompra = "DTF-<order number>", and before creating one the
customer's recent orders are searched for that number, so a second delivery
finds the first order instead of creating another.
Customer notices: the client already sends WhatsApp messages from Tiny's
order situação (Tiny webhook -> their middleware -> n8n). With
TINY_STATUS_UPDATES on, a paid order is set to "Aprovada" and a finished
pickup order to "Pronto para envio", so those notices reach Site customers
from the same number and templates; the system's own WhatsApp sender stays
off. Pickup orders carry the client's "retirar pessoalmente" forma de envio.
"""
import os
import secrets
import time
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from urllib.parse import urlencode
import httpx
API = 'https://api.tiny.com.br/public-api/v3'
AUTH = 'https://accounts.tiny.com.br/realms/tiny/protocol/openid-connect'
# Not TINY_PRODUCT_<MODE>: app/core/secrets.py reads any variable ending in
# _FILE as a path to a secret file, and one product mode is called "file".
PRODUCT_SETTINGS = {'file': 'TINY_PRODUCT_TEXTIL_FOLHA', 'avulsa': 'TINY_PRODUCT_TEXTIL_AVULSA',
'uvfile': 'TINY_PRODUCT_UV_FOLHA', 'uv': 'TINY_PRODUCT_UV_AVULSA'}
PRODUCTS = {'file': 'DTF Têxtil 57 cm · folha montada',
'avulsa': 'DTF Têxtil 57 cm · artes avulsas',
'uvfile': 'DTF UV 28,5 cm · folha montada',
'uv': 'DTF UV 28,5 cm · artes avulsas'}
# How far back to look for an order a previous delivery may already have made.
SEARCH_DAYS = 7
# A "ready" event can come after corrections; it normally knows the Tiny id.
READY_SEARCH_DAYS = 45
# Tiny v3 order situações (PUT /pedidos/{id}/situacao).
ABERTA, APROVADA, PRONTO_ENVIO, ENVIADA, ENTREGUE, CANCELADA, NAO_ENTREGUE = 0, 3, 7, 5, 6, 2, 9
SITUACOES = {0: 'Aberta', 3: 'Aprovada', 4: 'Preparando envio', 1: 'Faturada', 7: 'Pronto para envio',
5: 'Enviada', 6: 'Entregue', 2: 'Cancelada', 8: 'Dados incompletos', 9: 'Não entregue'}
# The client's customer notices (Tiny webhook -> middleware -> n8n -> WhatsApp)
# react to these situações. Off until go-live: while n8n still sends the
# designer message for DTFIMP products on "Aprovado", setting it would reach
# Site customers.
STATUS_UPDATES_SETTING = 'TINY_STATUS_UPDATES'
# The id of the client's custom "retirar pessoalmente" forma de envio (v2 code
# X), which the pickup notice is keyed on. Find it with the console tool.
PICKUP_SETTING = 'TINY_FORMA_ENVIO_RETIRADA'
STATE_MINUTES = 10
# Renewal runs about every four hours against a one-day refresh token, so
# less than this left means renewals have been failing for hours.
EXPIRY_WARNING = timedelta(hours=12)
# Brazil has had no daylight saving since 2019; the order date is the local day.
BRASILIA = timezone(timedelta(hours=-3))
def local_today():
return datetime.now(BRASILIA).date()
class TinyError(Exception):
pass
class TinyNotConnected(TinyError):
"""No authorised connection yet: an operator must click "Conectar Tiny"."""
def purchase_order(number):
return f'DTF-{number}'
def configured():
"""Whether the OAuth application is configured (orders may still be fake)."""
return all(os.environ.get(name) for name in ('TINY_CLIENT_ID', 'TINY_CLIENT_SECRET', 'TINY_REDIRECT_URI'))
def required_settings():
return ['TINY_CLIENT_ID', 'TINY_CLIENT_SECRET', 'TINY_REDIRECT_URI'] + list(PRODUCT_SETTINGS.values())
def status_updates():
return os.environ.get(STATUS_UPDATES_SETTING, '').lower() == 'true'
# OAuth -------------------------------------------------------------------
class TinyAuth:
"""The OAuth application and the stored connection."""
def __init__(self, connect=None, transport=None, clock=time.time):
self.client_id = os.environ.get('TINY_CLIENT_ID', '')
self.client_secret = os.environ.get('TINY_CLIENT_SECRET', '')
self.redirect_uri = os.environ.get('TINY_REDIRECT_URI', '')
if connect is None:
from .core.db import connect
self.connect = connect
self.http = httpx.Client(timeout=20, transport=transport)
self.clock = clock
def authorize_url(self, operator, offline=True):
"""Start a connection. The state is single-use, short-lived, and only an
authenticated operator can create one, which is what protects the
callback: Tiny's redirect back is cross-site, so the operator's
SameSite=Strict cookie does not travel with it.
offline_access asks for a grant that is not bound to a login session,
so the connection lasts as long as it keeps being renewed. Tiny's
documented refresh token otherwise lasts one day."""
state = secrets.token_urlsafe(32)
with self.connect() as c:
c.execute("DELETE FROM dtf_local.oauth_states WHERE expires_at<now()")
c.execute('''INSERT INTO dtf_local.oauth_states(state,provider,operator,expires_at)
VALUES(%s,'tiny',%s,now()+%s)''', (state, operator, timedelta(minutes=STATE_MINUTES)))
return f'{AUTH}/auth?' + urlencode({'response_type': 'code', 'client_id': self.client_id,
'redirect_uri': self.redirect_uri,
'scope': 'openid offline_access' if offline else 'openid',
'state': state})
def _claim(self, c, state):
row = c.execute('''DELETE FROM dtf_local.oauth_states WHERE state=%s AND provider='tiny'
AND expires_at>now() RETURNING operator''', (state,)).fetchone()
if not row:
raise TinyError('Unknown or expired authorisation state')
return row['operator']
def complete(self, code, state):
"""Exchange the authorisation code; returns the operator who started it."""
with self.connect() as c:
operator = self._claim(c, state)
tokens = self._token({'grant_type': 'authorization_code', 'code': code,
'redirect_uri': self.redirect_uri})
self._store(c, tokens, operator)
return operator
def without_offline(self, state):
"""Tiny refused the offline_access scope for this application: spend the
operator's state and start again with a session-bound grant."""
with self.connect() as c:
operator = self._claim(c, state)
return self.authorize_url(operator, offline=False)
def status(self):
with self.connect() as c:
row = c.execute('''SELECT connected_by,connected_at,refresh_expires_at,updated_at,offline,
refresh_failed_at,refused_at FROM dtf_local.provider_tokens WHERE provider='tiny' ''').fetchone()
if not row:
return {'connected': False}
now = datetime.now(timezone.utc)
expires = row['refresh_expires_at']
expired = bool(expires and expires <= now)
if row['refused_at']:
problem = 'refused'
elif expired:
problem = 'expired'
elif row['refresh_failed_at']:
problem = 'renewal-failing'
elif expires and expires - now < EXPIRY_WARNING:
problem = 'expiring'
else:
problem = None
return {'connected': not expired and not row['refused_at'], 'problem': problem,
'connected_by': row['connected_by'], 'connected_at': row['connected_at'],
'renewed_at': row['updated_at'], 'expires_at': expires, 'offline': row['offline'],
'failed_at': row['refresh_failed_at']}
def access_token(self):
"""A valid access token, refreshing (and rotating) under a row lock.
A failed renewal is recorded after the lock is released, so the Kanban
can show it. A refused refresh token is never tried again: only a new
connection helps, and retrying it would only repeat the refusal."""
with self.connect() as c:
row = c.execute('''SELECT * FROM dtf_local.provider_tokens WHERE provider='tiny'
FOR UPDATE''').fetchone()
if not row:
raise TinyNotConnected('Tiny is not connected; use "Conectar Tiny" on the Kanban')
now = datetime.now(timezone.utc)
if row['access_expires_at'] > now + timedelta(seconds=60):
return row['access_token']
if row['refused_at']:
raise TinyNotConnected('Tiny refused the stored refresh token; connect again on the Kanban')
if row['refresh_expires_at'] and row['refresh_expires_at'] <= now:
raise TinyNotConnected('The Tiny connection expired; connect again on the Kanban')
try:
tokens = self._token({'grant_type': 'refresh_token', 'refresh_token': row['refresh_token']})
except (TinyError, httpx.HTTPError, ValueError) as exc:
failure = exc
else:
self._store(c, tokens, row['connected_by'], refreshed=True)
return tokens['access_token']
# Only against the token that failed: another worker may have renewed since.
with self.connect() as c:
c.execute('''UPDATE dtf_local.provider_tokens SET refresh_failed_at=now(), refresh_error=%s,
refused_at=CASE WHEN %s THEN now() ELSE refused_at END
WHERE provider='tiny' AND refresh_token=%s''',
(str(failure)[:300] or type(failure).__name__, isinstance(failure, TinyNotConnected),
row['refresh_token']))
raise failure
def _token(self, form):
response = self.http.post(f'{AUTH}/token', data={**form, 'client_id': self.client_id,
'client_secret': self.client_secret})
if response.status_code == 400 and form['grant_type'] == 'refresh_token':
raise TinyNotConnected('Tiny refused the stored refresh token; connect again on the Kanban')
response.raise_for_status()
tokens = response.json()
if not tokens.get('access_token') or not tokens.get('refresh_token'):
raise TinyError('Tiny token response is missing tokens')
return tokens
def _store(self, c, tokens, operator, refreshed=False):
now = datetime.now(timezone.utc)
access_expires = now + timedelta(seconds=int(tokens.get('expires_in', 300)))
# An offline grant reports refresh_expires_in 0: no fixed end.
refresh_in = tokens.get('refresh_expires_in')
refresh_expires = now + timedelta(seconds=int(refresh_in)) if refresh_in else None
offline = 'offline_access' in str(tokens.get('scope') or '').split()
c.execute('''INSERT INTO dtf_local.provider_tokens(provider,access_token,refresh_token,
access_expires_at,refresh_expires_at,connected_by,connected_at,updated_at,offline)
VALUES('tiny',%s,%s,%s,%s,%s,now(),now(),%s)
ON CONFLICT(provider) DO UPDATE SET access_token=EXCLUDED.access_token,
refresh_token=EXCLUDED.refresh_token, access_expires_at=EXCLUDED.access_expires_at,
refresh_expires_at=EXCLUDED.refresh_expires_at, updated_at=now(), offline=EXCLUDED.offline,
refresh_failed_at=NULL, refresh_error=NULL, refused_at=NULL,
connected_by=CASE WHEN %s THEN dtf_local.provider_tokens.connected_by ELSE EXCLUDED.connected_by END,
connected_at=CASE WHEN %s THEN dtf_local.provider_tokens.connected_at ELSE now() END''',
(tokens['access_token'], tokens['refresh_token'], access_expires, refresh_expires,
operator, offline, refreshed, refreshed))
# Orders ------------------------------------------------------------------
def money(cents):
return float(Decimal(cents) / 100)
def contact_payload(order):
customer = order['customer']
destination = order.get('destination')
contact = {'nome': (destination or {}).get('recipient') or customer['mail'],
'tipoPessoa': 'J', 'cpfCnpj': customer['cnpj'], 'email': customer['mail'],
'celular': customer['zap'], 'situacao': 'A'}
if destination:
contact['endereco'] = address(destination)
return contact
def address(destination):
return {'endereco': destination['street'], 'numero': destination['number'],
'complemento': destination.get('complement', ''), 'bairro': destination['district'],
'municipio': destination['city'], 'cep': destination['postal_code'],
'uf': destination['state'], 'pais': 'Brasil'}
def order_payload(payload, contact_id, today=None):
"""The v3 'pedido' for a paid order's approved snapshot."""
order = payload['order']
items = []
for item in order['items']:
setting = PRODUCT_SETTINGS[item['mode']]
product = os.environ.get(setting, '')
if not product.isdigit():
raise TinyError(f'{setting} must be the Tiny product id')
items.append({'produto': {'id': int(product)},
'quantidade': float(Decimal(item['billed_metres'])),
'valorUnitario': money(item['unit_cents']),
'infoAdicional': PRODUCTS[item['mode']] + f" · nota {item['grade']}"})
freight = order['freight']
pickup = freight.get('service') == 'pickup'
pedido = {'data': (today or local_today()).isoformat(),
'idContato': contact_id,
'numeroOrdemCompra': purchase_order(payload['number']),
'itens': items,
'valorFrete': money(freight['total_cents']),
'observacoes': f"Pedido DTF #{payload['number']}" + (' · retirada em Franca' if pickup else ''),
'observacoesInternas': f"Site DTF · pago · {payload['order_id']}"}
pickup_method = os.environ.get(PICKUP_SETTING, '')
if pickup and pickup_method.isdigit():
pedido['transportador'] = {'formaEnvio': {'id': int(pickup_method)}}
destination = order.get('destination')
if destination:
pedido['enderecoEntrega'] = {**address(destination), 'enderecoNro': destination['number'],
'nomeDestinatario': destination['recipient']}
del pedido['enderecoEntrega']['numero']
ecommerce = os.environ.get('TINY_ECOMMERCE_ID', '')
if ecommerce.isdigit():
pedido['ecommerce'] = {'id': int(ecommerce), 'numeroPedidoEcommerce': purchase_order(payload['number'])}
return pedido
def configured_product(orders, mode):
"""The Tiny product a mode's setting points at, or a reason it cannot be used."""
setting = PRODUCT_SETTINGS[mode]
value = os.environ.get(setting, '')
if not value.isdigit():
return None, f'{setting} sem id'
found = orders.request('GET', f'/produtos/{value}')
if found.get('situacao') != 'A':
return found, f"produto {value} não está ativo no Tiny ({found.get('situacao')})"
return found, 'ok'
def configured_pickup(orders):
"""The forma de envio the pickup setting points at, or why it cannot be used."""
value = os.environ.get(PICKUP_SETTING, '')
if not value.isdigit():
return None, f'{PICKUP_SETTING} sem id'
return orders.request('GET', f'/formas-envio/{value}'), 'ok'
def check(auth=None, transport=None, orders=None):
"""Read-only proof that the connection and permissions work: one order and
one contact listed, each configured product found active and the pickup
forma de envio found, nothing created. Raises TinyNotConnected when there is no usable connection;
otherwise reports each read separately."""
orders = orders or TinyOrders(auth=auth or TinyAuth(), transport=transport)
def listed(path):
orders.request('GET', path, params={'limit': 1})
return 'ok'
reads = [('pedidos', lambda: listed('/pedidos')), ('contatos', lambda: listed('/contatos'))]
reads += [(PRODUCTS[mode], lambda mode=mode: configured_product(orders, mode)[1]) for mode in PRODUCT_SETTINGS]
reads.append(('forma de envio de retirada', lambda: configured_pickup(orders)[1]))
results = {}
for name, read in reads:
try:
results[name] = read()
except TinyNotConnected:
raise
except TinyError as exc:
results[name] = str(exc)[:200]
except httpx.HTTPError:
results[name] = 'sem resposta do Tiny'
return results
class TinyOrders:
def __init__(self, auth=None, transport=None, today=None):
missing = [name for name in required_settings() if not os.environ.get(name)]
if auth is None and missing:
raise RuntimeError('Tiny needs ' + ', '.join(missing))
self.auth = auth or TinyAuth()
self.http = httpx.Client(base_url=API, transport=transport, timeout=30)
self.today = today
def request(self, method, path, **kwargs):
headers = {'Authorization': f'Bearer {self.auth.access_token()}'}
response = self.http.request(method, path, headers=headers, **kwargs)
if response.status_code == 429:
raise TinyError('Tiny rate limit reached; the outbox will retry')
if response.status_code >= 400:
raise TinyError(f'{method} {path}: {response.status_code} {response.text[:300]}')
return response.json() if response.content else {}
def contact(self, order):
cnpj = order['customer']['cnpj']
found = self.request('GET', '/contatos', params={'cpfCnpj': cnpj, 'limit': 5})
for entry in found.get('itens') or []:
if ''.join(ch for ch in str(entry.get('cpfCnpj') or '') if ch.isdigit()) == cnpj:
return entry['id']
return self.request('POST', '/contatos', json=contact_payload(order))['id']
def find(self, payload, days=SEARCH_DAYS):
"""An order a previous delivery already created, or None."""
wanted = purchase_order(payload['number'])
since = ((self.today or local_today()) - timedelta(days=days)).isoformat()
found = self.request('GET', '/pedidos', params={'cpfCnpj': payload['order']['customer']['cnpj'],
'dataInicial': since, 'limit': 100})
for entry in found.get('itens') or []:
detail = self.request('GET', f"/pedidos/{entry['id']}")
if detail.get('numeroOrdemCompra') == wanted:
return detail
return None
def set_situacao(self, tiny_id, situacao):
self.request('PUT', f'/pedidos/{tiny_id}/situacao', json={'situacao': situacao})
def deliver(self, event_key, payload):
event = payload.get('event')
if event == 'payment_approved':
return self.sale(event_key, payload)
if event == 'ready' and status_updates():
return self.ready(event_key, payload)
# Other production events have no Tiny situação and are not written.
return {'provider': 'tiny', 'event_key': event_key, 'status': 'not-applicable', 'event': event}
def sale(self, event_key, payload):
"""Create the order once; with status updates on, approve it once.
An order is created "Aberta" and then set to "Aprovada", so a retry
after a failure between the two finds it still open and finishes the
job, and an order someone has already moved on is left alone."""
existing = self.find(payload)
if existing:
receipt = {'provider': 'tiny', 'event_key': event_key, 'status': 'already-created',
'tiny_id': str(existing.get('id')), 'tiny_number': str(existing.get('numeroPedido'))}
tiny_id, situacao = existing.get('id'), existing.get('situacao')
else:
contact_id = self.contact(payload['order'])
created = self.request('POST', '/pedidos', json=order_payload(payload, contact_id, self.today))
receipt = {'provider': 'tiny', 'event_key': event_key, 'status': 'created',
'tiny_id': str(created.get('id')), 'tiny_number': str(created.get('numeroPedido'))}
tiny_id, situacao = created.get('id'), ABERTA
if status_updates() and situacao == ABERTA:
self.set_situacao(tiny_id, APROVADA)
receipt['situacao'] = SITUACOES[APROVADA]
return receipt
def ready(self, event_key, payload):
"""A pickup order became ready: "Pronto para envio", which the client's
notices turn into the pickup message. Shipped orders get "Enviada"
once freight exists (1.2)."""
base = {'provider': 'tiny', 'event_key': event_key}
order = payload.get('order') or {}
if (order.get('freight') or {}).get('service') != 'pickup':
return {**base, 'status': 'not-applicable', 'event': 'ready'}
tiny_id = payload.get('tiny_id')
detail = self.request('GET', f'/pedidos/{tiny_id}') if tiny_id else self.find(payload, READY_SEARCH_DAYS)
if not detail:
raise TinyError(f"{purchase_order(payload['number'])} is not in Tiny yet; the outbox will retry")
base.update(tiny_id=str(detail.get('id')), tiny_number=str(detail.get('numeroPedido')))
situacao = detail.get('situacao')
if situacao in (PRONTO_ENVIO, ENVIADA, ENTREGUE, CANCELADA, NAO_ENTREGUE):
return {**base, 'status': 'status-unchanged', 'situacao': SITUACOES.get(situacao, str(situacao))}
self.set_situacao(detail['id'], PRONTO_ENVIO)
return {**base, 'status': 'status-updated', 'situacao': SITUACOES[PRONTO_ENVIO]}

153
app/tiny_probe.py Normal file
View File

@@ -0,0 +1,153 @@
"""Supervised checks against the client's real Tiny, run by hand from a
container console (docker exec, or Portainer > Containers > worker > Console).
Tiny has no sandbox, so this is how the adapter is proven on the real account.
python -m app.tiny_probe produtos [termo] list active products (read-only)
python -m app.tiny_probe formas-envio [termo] list formas de envio, to find pickup (read-only)
python -m app.tiny_probe conferir connection, the four product ids and pickup (read-only)
python -m app.tiny_probe pedido --cnpj ... --email ... --celular ... [--modo file]
show the test order; --confirmar creates it
The test order goes through TinyOrders.deliver, exactly as the worker sends a
paid order. The duplicate guard is then proven read-only first (the order must
be found by its purchase-order number) and only then by a second delivery,
which must return the existing order. If the search cannot find the order, the
second delivery is not attempted: it would create a duplicate. Cancel the test
order in Olist afterwards.
"""
import argparse
import json
import sys
import time
from datetime import datetime
from .core.pricing import TIERS
from .core.secrets import load as load_secret_files
from . import tiny
FIND_ATTEMPTS = 4
FIND_WAIT_SECONDS = 5
def test_order(cnpj, email, celular, mode, now=None):
number = 'TESTE-' + (now or datetime.now(tiny.BRASILIA)).strftime('%Y%m%d%H%M')
unit = TIERS[mode][0][1]
return {'order_id': 'teste-integracao', 'number': number, 'event': 'payment_approved',
'order': {'customer': {'cnpj': cnpj, 'mail': email, 'zap': celular},
'items': [{'mode': mode, 'grade': 100, 'billed_metres': '1', 'unit_cents': unit}],
'freight': {'service': 'pickup', 'total_cents': 0},
'destination': None, 'total_cents': unit}}
def produtos(orders, termo, out):
params = {'situacao': 'A', 'limit': 100}
if termo:
params['nome'] = termo
found = orders.request('GET', '/produtos', params=params).get('itens') or []
for item in found:
preco = (item.get('precos') or {}).get('preco')
out(f"{item['id']}\t{item.get('sku') or '-'}\t{item.get('descricao')}\tR$ {preco}")
out(f'{len(found)} produto(s) ativo(s)' + (f' com "{termo}"' if termo else '') + '.')
def formas_envio(orders, termo, out):
params = {'limit': 100}
if termo:
params['nome'] = termo
found = orders.request('GET', '/formas-envio', params=params).get('itens') or []
for item in found:
out(f"{item.get('id')}\t{item.get('tipo')}\t{item.get('nome')}")
out(f'{len(found)} forma(s) de envio' + (f' com "{termo}"' if termo else '') +
'. A retirada é a do tipo 6 (Customizado) que a equipe usa para retirar pessoalmente.')
def conferir(orders, out):
ok = True
for mode, setting in tiny.PRODUCT_SETTINGS.items():
product, result = tiny.configured_product(orders, mode)
ok &= result == 'ok'
described = f"{product.get('sku') or '-'} · {product.get('descricao')}" if product else ''
out(f'{setting} ({tiny.PRODUCTS[mode]}): {result} {described}'.rstrip())
method, result = tiny.configured_pickup(orders)
ok &= result == 'ok'
out(f"{tiny.PICKUP_SETTING} (retirada): {result} {method.get('nome') if method else ''}".rstrip())
out(f"{tiny.STATUS_UPDATES_SETTING}: {'ligado' if tiny.status_updates() else 'desligado'}")
for name, result in tiny.check(orders=orders).items():
if name in ('pedidos', 'contatos'):
ok &= result == 'ok'
out(f'{name}: {result}')
out('Tudo conferido.' if ok else 'Há pendências acima.')
return ok
def pedido(orders, payload, confirm, out, wait=time.sleep):
cnpj = payload['order']['customer']['cnpj']
contacts = orders.request('GET', '/contatos', params={'cpfCnpj': cnpj, 'limit': 5}).get('itens') or []
known = [c for c in contacts if ''.join(ch for ch in str(c.get('cpfCnpj') or '') if ch.isdigit()) == cnpj]
out(f"Contato {cnpj}: " + (f"já existe no Tiny (id {known[0]['id']})" if known
else 'não existe; será criado com o e-mail como nome'))
out('Pedido que será enviado:')
out(json.dumps(tiny.order_payload(payload, known[0]['id'] if known else 0), ensure_ascii=False, indent=2))
if not confirm:
out('Nada foi criado. Repita com --confirmar para criar este pedido no Tiny.')
return None
first = orders.deliver('teste-integracao', payload)
out(f"1º envio: {first['status']} · Tiny id {first['tiny_id']} · nº {first['tiny_number']}")
for attempt in range(FIND_ATTEMPTS):
found = orders.find(payload)
if found:
break
if attempt < FIND_ATTEMPTS - 1:
wait(FIND_WAIT_SECONDS)
else:
out(f'FALHA: a busca por {tiny.purchase_order(payload["number"])} não encontrou o pedido. '
'Um reenvio criaria um duplicado; o 2º envio não foi feito. Cancele o pedido no Olist.')
return False
second = orders.deliver('teste-integracao', payload)
out(f"2º envio: {second['status']} · Tiny id {second['tiny_id']}")
passed = second['status'] == 'already-created' and second['tiny_id'] == first['tiny_id']
out(('OK: o reenvio encontrou o mesmo pedido.' if passed else 'FALHA: o reenvio não devolveu o mesmo pedido.')
+ f" Cancele o pedido nº {first['tiny_number']} no Olist.")
return passed
def main(argv=None, orders=None, out=print):
parser = argparse.ArgumentParser(prog='python -m app.tiny_probe')
commands = parser.add_subparsers(dest='command', required=True)
listing = commands.add_parser('produtos')
listing.add_argument('termo', nargs='?', default='')
methods = commands.add_parser('formas-envio')
methods.add_argument('termo', nargs='?', default='')
commands.add_parser('conferir')
order = commands.add_parser('pedido')
order.add_argument('--cnpj', required=True)
order.add_argument('--email', required=True)
order.add_argument('--celular', required=True)
order.add_argument('--modo', choices=sorted(tiny.PRODUCT_SETTINGS), default='file')
order.add_argument('--confirmar', action='store_true')
args = parser.parse_args(argv)
if orders is None:
load_secret_files()
orders = tiny.TinyOrders(auth=tiny.TinyAuth())
try:
if args.command == 'produtos':
produtos(orders, args.termo, out)
return 0
if args.command == 'formas-envio':
formas_envio(orders, args.termo, out)
return 0
if args.command == 'conferir':
return 0 if conferir(orders, out) else 1
cnpj = ''.join(ch for ch in args.cnpj if ch.isdigit())
if len(cnpj) != 14:
out('Informe um CNPJ com 14 dígitos.')
return 2
payload = test_order(cnpj, args.email, args.celular, args.modo)
return 0 if pedido(orders, payload, args.confirmar, out) is not False else 1
except tiny.TinyError as exc:
out(f'Tiny: {exc}')
return 1
if __name__ == '__main__':
sys.exit(main())

View File

@@ -1,6 +1,7 @@
"""Transactional outbox worker. Fake receipts persist; no messages leave the stack.""" """Transactional outbox worker. Fake receipts persist; no messages leave the stack."""
import json import json
import logging import logging
import os
import threading import threading
import time import time
from http.server import BaseHTTPRequestHandler, HTTPServer from http.server import BaseHTTPRequestHandler, HTTPServer
@@ -8,15 +9,22 @@ from psycopg.types.json import Jsonb
from .adapters import FakeTiny, FakeWhatsApp, LocalS3Storage, require_runtime from .adapters import FakeTiny, FakeWhatsApp, LocalS3Storage, require_runtime
from .core.secrets import load as load_secret_files from .core.secrets import load as load_secret_files
from .core.db import connect from .core.db import connect
from .printjobs import render_loop
from .scanning import ClamAV, scan_loop from .scanning import ClamAV, scan_loop
load_secret_files() load_secret_files()
require_runtime() require_runtime()
adapters = {'tiny': FakeTiny(), 'whatsapp': FakeWhatsApp()} adapters = {'tiny': FakeTiny(), 'whatsapp': FakeWhatsApp()}
# Not yet confirmed against the client's account; see app/tiny.py.
if os.environ.get('TINY_ADAPTER') == 'tiny':
from .tiny import TinyOrders
adapters['tiny'] = TinyOrders()
last_tick = 0.0 last_tick = 0.0
last_cleanup = 0.0 last_cleanup = 0.0
last_tiny_keepalive = 0.0
storage = LocalS3Storage() storage = LocalS3Storage()
scan_thread = None scan_thread = None
render_thread = None
def cleanup(): def cleanup():
"""Delete only expired object bytes; retain order/file metadata and history.""" """Delete only expired object bytes; retain order/file metadata and history."""
@@ -45,6 +53,27 @@ def tick():
c.execute("UPDATE dtf_local.outbox SET attempts=attempts+1, last_error=%s, available_at=now() + %s * interval '1 second' WHERE id=%s", (str(exc),delay,job['id'])) c.execute("UPDATE dtf_local.outbox SET attempts=attempts+1, last_error=%s, available_at=now() + %s * interval '1 second' WHERE id=%s", (str(exc),delay,job['id']))
last_tick = time.monotonic() last_tick = time.monotonic()
def tiny_keepalive():
"""Keep a connected Tiny authorised even while no orders are sent.
The refresh token expires unless it is used; access_token() refreshes (and
rotates) only when the access token is about to expire, so asking every few
minutes renews the connection roughly once per access-token lifetime.
"""
global last_tiny_keepalive
if time.monotonic()-last_tiny_keepalive < 600:
return
last_tiny_keepalive = time.monotonic()
from . import tiny
if not tiny.configured():
return
try:
tiny.TinyAuth().access_token()
except tiny.TinyNotConnected:
pass
except Exception:
logging.exception('Tiny connection refresh failed')
def loop(): def loop():
global last_cleanup global last_cleanup
while True: while True:
@@ -53,6 +82,7 @@ def loop():
if time.monotonic()-last_cleanup > 60: if time.monotonic()-last_cleanup > 60:
cleanup() cleanup()
last_cleanup = time.monotonic() last_cleanup = time.monotonic()
tiny_keepalive()
except Exception: except Exception:
logging.exception('Local worker tick failed') logging.exception('Local worker tick failed')
time.sleep(1) time.sleep(1)
@@ -64,16 +94,20 @@ class Health(BaseHTTPRequestHandler):
scanner = bool(scan_thread and scan_thread.is_alive() and ClamAV().ping()) scanner = bool(scan_thread and scan_thread.is_alive() and ClamAV().ping())
except Exception: except Exception:
pass pass
healthy = time.monotonic()-last_tick < 15 and scanner renderer = bool(render_thread and render_thread.is_alive())
healthy = time.monotonic()-last_tick < 15 and scanner and renderer
self.send_response(200 if self.path == '/health' and healthy else 503) self.send_response(200 if self.path == '/health' and healthy else 503)
self.end_headers() self.end_headers()
self.wfile.write(json.dumps({'worker': 'ok' if healthy else 'unavailable', self.wfile.write(json.dumps({'worker': 'ok' if healthy else 'unavailable',
'scanner': 'ok' if scanner else 'unavailable'}).encode()) 'scanner': 'ok' if scanner else 'unavailable',
'print_files': 'ok' if renderer else 'unavailable'}).encode())
def log_message(self, *args): def log_message(self, *args):
pass pass
if __name__ == '__main__': if __name__ == '__main__':
scan_thread = threading.Thread(target=scan_loop,args=(storage,),daemon=True) scan_thread = threading.Thread(target=scan_loop,args=(storage,),daemon=True)
scan_thread.start() scan_thread.start()
render_thread = threading.Thread(target=render_loop,args=(storage,),daemon=True)
render_thread.start()
threading.Thread(target=loop, daemon=True).start() threading.Thread(target=loop, daemon=True).start()
HTTPServer(('0.0.0.0',8002),Health).serve_forever() HTTPServer(('0.0.0.0',8002),Health).serve_forever()

View File

@@ -27,10 +27,35 @@ x-app: &app
ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1} ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1}
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost:${SITE_PORT:-8080},http://localhost:${KANBAN_PORT:-8081},http://127.0.0.1:${SITE_PORT:-8080},http://127.0.0.1:${KANBAN_PORT:-8081}} ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost:${SITE_PORT:-8080},http://localhost:${KANBAN_PORT:-8081},http://127.0.0.1:${SITE_PORT:-8080},http://127.0.0.1:${KANBAN_PORT:-8081}}
COOKIE_SECURE: "false" COOKIE_SECURE: "false"
PAYMENT_ADAPTER: fake # Sandbox testing only: set PAYMENT_ADAPTER=mercadopago with the MP_* test
# credentials, or TINY_ADAPTER=tiny with a TINY_TOKEN, in .env. Never real
# production credentials on a developer machine.
PAYMENT_ADAPTER: ${PAYMENT_ADAPTER:-fake}
PAYMENT_WEBHOOK_SECRET: ${PAYMENT_WEBHOOK_SECRET:-local-webhook-secret} PAYMENT_WEBHOOK_SECRET: ${PAYMENT_WEBHOOK_SECRET:-local-webhook-secret}
FREIGHT_ADAPTER: fake MP_ACCESS_TOKEN: ${MP_ACCESS_TOKEN:-}
TINY_ADAPTER: fake MP_WEBHOOK_SECRET: ${MP_WEBHOOK_SECRET:-}
MP_NOTIFICATION_URL: ${MP_NOTIFICATION_URL:-}
MP_PUBLIC_KEY: ${MP_PUBLIC_KEY:-}
FREIGHT_ADAPTER: ${FREIGHT_ADAPTER:-fake}
JADLOG_TOKEN: ${JADLOG_TOKEN:-}
JADLOG_CNPJ: ${JADLOG_CNPJ:-}
JADLOG_CONTA: ${JADLOG_CONTA:-}
JADLOG_CONTRATO: ${JADLOG_CONTRATO:-}
JADLOG_PESO_BASE_KG: ${JADLOG_PESO_BASE_KG:-}
JADLOG_PESO_POR_METRO_KG: ${JADLOG_PESO_POR_METRO_KG:-}
FREIGHT_PRODUCTION_DAYS: ${FREIGHT_PRODUCTION_DAYS:-0}
# Carts the Site priced are approved at checkout; larger ones wait for review.
QUOTE_AUTO_APPROVE: ${QUOTE_AUTO_APPROVE:-true}
QUOTE_AUTO_MAX_METRES: ${QUOTE_AUTO_MAX_METRES:-50}
TINY_ADAPTER: ${TINY_ADAPTER:-fake}
TINY_CLIENT_ID: ${TINY_CLIENT_ID:-}
TINY_CLIENT_SECRET: ${TINY_CLIENT_SECRET:-}
TINY_REDIRECT_URI: ${TINY_REDIRECT_URI:-}
TINY_PRODUCT_TEXTIL_FOLHA: ${TINY_PRODUCT_TEXTIL_FOLHA:-}
TINY_PRODUCT_TEXTIL_AVULSA: ${TINY_PRODUCT_TEXTIL_AVULSA:-}
TINY_PRODUCT_UV_FOLHA: ${TINY_PRODUCT_UV_FOLHA:-}
TINY_PRODUCT_UV_AVULSA: ${TINY_PRODUCT_UV_AVULSA:-}
TINY_ECOMMERCE_ID: ${TINY_ECOMMERCE_ID:-}
WHATSAPP_ADAPTER: fake WHATSAPP_ADAPTER: fake
STORAGE_ADAPTER: s3-local STORAGE_ADAPTER: s3-local
MOCK_FREIGHT_CENTS: ${MOCK_FREIGHT_CENTS:-1500} MOCK_FREIGHT_CENTS: ${MOCK_FREIGHT_CENTS:-1500}
@@ -39,7 +64,7 @@ x-app: &app
STORAGE_QUOTA_BYTES: ${STORAGE_QUOTA_BYTES:-53687091200} STORAGE_QUOTA_BYTES: ${STORAGE_QUOTA_BYTES:-53687091200}
OWNER_UPLOAD_QUOTA_BYTES: ${OWNER_UPLOAD_QUOTA_BYTES:-10737418240} OWNER_UPLOAD_QUOTA_BYTES: ${OWNER_UPLOAD_QUOTA_BYTES:-10737418240}
MAX_PENDING_UPLOADS: ${MAX_PENDING_UPLOADS:-10} MAX_PENDING_UPLOADS: ${MAX_PENDING_UPLOADS:-10}
SCAN_MAX_BYTES: ${SCAN_MAX_BYTES:-134217728} SCAN_MAX_BYTES: ${SCAN_MAX_BYTES:-2097152000}
networks: [local] networks: [local]
init: true init: true
security_opt: [no-new-privileges:true] security_opt: [no-new-privileges:true]
@@ -66,11 +91,14 @@ services:
retries: 30 retries: 30
storage: storage:
# quay.io, not Docker Hub: minio/minio there now answers anonymous pulls # MinIO stopped publishing public images: since September 2026 both
# with 401 authentication required, which breaks any runner that is not # Docker Hub (minio/minio) and quay.io answer anonymous pulls with 401,
# logged in. Same image — identical image ID. Override MINIO_IMAGE to use # which breaks any machine or runner without a cached copy. Chainguard's
# a mirror of your own. # build still pulls anonymously, ships sh and mc (the healthcheck and
image: ${MINIO_IMAGE:-quay.io/minio/minio:RELEASE.2025-04-22T22-12-26Z} # storage-init need both) and runs as a non-root user. Pinned by digest
# because Chainguard's free tier only publishes :latest. Override
# MINIO_IMAGE to use a mirror of your own.
image: ${MINIO_IMAGE:-cgr.dev/chainguard/minio@sha256:bd014394a80898e68c149f2311fdf8d5a2c2f3bb2c33b9327ae6d02b4b065ae1}
command: server /data --console-address :9001 command: server /data --console-address :9001
environment: environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-dtf_local} MINIO_ROOT_USER: ${MINIO_ROOT_USER:-dtf_local}
@@ -112,7 +140,7 @@ services:
context: . context: .
dockerfile: infra/Dockerfile.storage-init dockerfile: infra/Dockerfile.storage-init
args: args:
MINIO_IMAGE: ${MINIO_IMAGE:-quay.io/minio/minio:RELEASE.2025-04-22T22-12-26Z} MINIO_IMAGE: ${MINIO_IMAGE:-cgr.dev/chainguard/minio@sha256:bd014394a80898e68c149f2311fdf8d5a2c2f3bb2c33b9327ae6d02b4b065ae1}
entrypoint: [/bin/sh, /init.sh] entrypoint: [/bin/sh, /init.sh]
environment: environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-dtf_local} MINIO_ROOT_USER: ${MINIO_ROOT_USER:-dtf_local}
@@ -120,6 +148,9 @@ services:
S3_APP_USER: ${S3_APP_USER:-dtf_app} S3_APP_USER: ${S3_APP_USER:-dtf_app}
S3_APP_PASSWORD: ${S3_APP_PASSWORD:-local-app-storage-only} S3_APP_PASSWORD: ${S3_APP_PASSWORD:-local-app-storage-only}
S3_BUCKET: ${S3_BUCKET:-dtf-local-artwork} S3_BUCKET: ${S3_BUCKET:-dtf-local-artwork}
S3_BACKUP_BUCKET: dtf-local-backups
S3_BACKUP_USER: dtf_backup
S3_BACKUP_PASSWORD: local-backup-storage-only
networks: [local] networks: [local]
depends_on: depends_on:
storage: {condition: service_healthy} storage: {condition: service_healthy}
@@ -169,12 +200,37 @@ services:
timeout: 3s timeout: 3s
retries: 12 retries: 12
# The daily database backup, against the local backup bucket. Idle until
# BACKUP_AGE_RECIPIENT is set; tests/backup_test.py runs it with a throwaway key.
backup:
build:
context: .
dockerfile: infra/Dockerfile
command: python -m ops.db_backup serve
environment:
DATABASE_ADMIN_URL: postgresql://${POSTGRES_USER:-dtf_local}:${POSTGRES_PASSWORD:-local-database-only}@db:5432/${POSTGRES_DB:-dtf_local}
BACKUP_S3_ENDPOINT: http://storage:9000
BACKUP_BUCKET: dtf-local-backups
BACKUP_ACCESS_KEY_ID: dtf_backup
BACKUP_SECRET_ACCESS_KEY: local-backup-storage-only
BACKUP_REGION: us-east-1
BACKUP_AGE_RECIPIENT: ${BACKUP_AGE_RECIPIENT:-}
networks: [local]
read_only: true
tmpfs: [/tmp]
depends_on:
db-init: {condition: service_completed_successfully}
storage-init: {condition: service_completed_successfully}
site: site:
build: build:
context: . context: .
dockerfile: infra/Dockerfile.web dockerfile: infra/Dockerfile.web
environment: environment:
S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:-http://localhost:${STORAGE_PORT:-9000}} S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:-http://localhost:${STORAGE_PORT:-9000}}
# Empty unless testing Mercado Pago's card form; see docs/LOCAL_SETUP.md.
PAYMENT_CSP_SOURCES: ${PAYMENT_CSP_SOURCES:-}
PAYMENT_CHALLENGE_SOURCES: ${PAYMENT_CHALLENGE_SOURCES:-}
ports: ports:
# Published ports are host-wide even bound to loopback, so on a shared # Published ports are host-wide even bound to loopback, so on a shared
# machine any of them can collide with something unrelated. CI overrides # machine any of them can collide with something unrelated. CI overrides
@@ -198,6 +254,8 @@ services:
environment: environment:
WEB_INDEX: kanban.html WEB_INDEX: kanban.html
S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:-http://localhost:${STORAGE_PORT:-9000}} S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:-http://localhost:${STORAGE_PORT:-9000}}
PAYMENT_CSP_SOURCES: ""
PAYMENT_CHALLENGE_SOURCES: ""
ports: ["127.0.0.1:${KANBAN_PORT:-8081}:80"] ports: ["127.0.0.1:${KANBAN_PORT:-8081}:80"]
networks: [local, edge] networks: [local, edge]
depends_on: depends_on:

16
compose.providers.yaml Normal file
View File

@@ -0,0 +1,16 @@
# Provider sandbox testing only: gives the API and worker a route to the
# internet so they can reach Mercado Pago and Tiny. The default local stack
# keeps them on an internal network with no external route, which is what
# every other local run should keep doing.
#
# docker compose -f compose.local.yaml -f compose.providers.yaml up -d --wait
#
# Credentials go in .env (see .env.example); never production credentials.
services:
api:
networks: [local, provider-egress]
worker:
networks: [local, provider-egress]
networks:
provider-egress: {}

View File

@@ -13,8 +13,11 @@ LABEL org.opencontainers.image.title="DTF Portal/API" \
# The base is pinned, so its OS packages are frozen at the digest's build date. # The base is pinned, so its OS packages are frozen at the digest's build date.
# Upgrade them here or the image ships known-fixed Debian vulnerabilities, which # Upgrade them here or the image ships known-fixed Debian vulnerabilities, which
# is what the production image was doing while the local one already did this. # is what the production image was doing while the local one already did this.
# pg_dump and age are for the database backup (ops/db_backup.py). Debian 13
# ships PostgreSQL 17, the server's major version, which pg_dump must match.
RUN apt-get update \ RUN apt-get update \
&& apt-get upgrade -y \ && apt-get upgrade -y \
&& apt-get install -y --no-install-recommends postgresql-client-17 age \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app

View File

@@ -22,7 +22,8 @@ LABEL org.opencontainers.image.title="DTF Site and Kanban" \
org.opencontainers.image.source="DTF System repository" org.opencontainers.image.source="DTF System repository"
ENV WEB_INDEX=index.html PUBLIC_HOST=invalid.example S3_PUBLIC_ENDPOINT=https://invalid.example 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 --from=policy /build/default.conf.template /etc/nginx/templates/default.conf.template
COPY web/ /usr/share/nginx/html/ # The HTML from the policy stage, with every asset address versioned.
COPY --from=policy /build/web/ /usr/share/nginx/html/
# The official entrypoint renders the server configuration at startup and Nginx # The official entrypoint renders the server configuration at startup and Nginx
# writes its PID/cache files. Keep the service non-root while granting it # writes its PID/cache files. Keep the service non-root while granting it

View File

@@ -27,7 +27,7 @@ server {
add_header Referrer-Policy no-referrer always; add_header Referrer-Policy no-referrer always;
add_header X-Frame-Options DENY always; add_header X-Frame-Options DENY always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' @SCRIPT_HASHES@; 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; worker-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self' @SCRIPT_HASHES@ ${PAYMENT_CSP_SOURCES}; 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 ${PAYMENT_CSP_SOURCES}; connect-src 'self' ${S3_PUBLIC_ENDPOINT} https://cdn.vnda.com.br ${PAYMENT_CSP_SOURCES}; frame-src 'self' ${PAYMENT_CSP_SOURCES} ${PAYMENT_CHALLENGE_SOURCES}; worker-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self' ${PAYMENT_CHALLENGE_SOURCES}" always;
location = /health { access_log off; return 200 'ok'; } location = /health { access_log off; return 200 'ok'; }
location /api/ { location /api/ {
@@ -48,6 +48,16 @@ server {
} }
# Always revalidate HTML/JS/CSS after a deployment. Without this, a browser # Always revalidate HTML/JS/CSS after a deployment. Without this, a browser
# can pair a new Kanban page with a cached older script after a rollout. # can pair a new Kanban page with a cached older script after a rollout.
# The Site's product pages and cart are addresses of the same page (web/site-pages.js).
location ~ ^/(arquivo-por-metro|artes-avulsas|uv-arquivo-por-metro|uv-artes-avulsas|carrinho|pagamento|pagamento/pix)/?$ {
expires -1;
try_files /index.html =404;
}
# The customer's area: one page, which shows the right part for each address.
location ~ ^/conta(/(entrar|pedidos|dados))?/?$ {
expires -1;
try_files /portal.html =404;
}
location / { location / {
expires -1; expires -1;
try_files $uri $uri/ =404; try_files $uri $uri/ =404;

View File

@@ -27,8 +27,23 @@ POSTGRES_VOLUME=TBD
OPERATOR_EMAIL=TBD OPERATOR_EMAIL=TBD
PAYMENT_ADAPTER=TBD PAYMENT_ADAPTER=TBD
# With PAYMENT_ADAPTER=mercadopago. MP_ACCESS_TOKEN and MP_WEBHOOK_SECRET are
# secrets: enter them in Portainer only, never in this file.
MP_NOTIFICATION_URL=https://<SITE_DOMAIN>/api/payments/webhook
MP_PUBLIC_KEY=
FREIGHT_ADAPTER=TBD FREIGHT_ADAPTER=TBD
TINY_ADAPTER=TBD TINY_ADAPTER=TBD
# Tiny API v3 application (Configurações > Geral > Aplicativos in Tiny). The
# redirect URL registered there must equal TINY_REDIRECT_URI.
TINY_CLIENT_ID=TBD
TINY_REDIRECT_URI=https://<KANBAN_DOMAIN>/api/operator/tiny/callback
TINY_PRODUCT_TEXTIL_FOLHA=TBD
TINY_PRODUCT_TEXTIL_AVULSA=TBD
TINY_PRODUCT_UV_FOLHA=TBD
TINY_PRODUCT_UV_AVULSA=TBD
TINY_FORMA_ENVIO_RETIRADA=TBD
# true only at go-live, after the n8n DTFIMP designer branch is removed.
TINY_STATUS_UPDATES=false
WHATSAPP_ADAPTER=TBD WHATSAPP_ADAPTER=TBD
STORAGE_QUOTA_BYTES=TBD STORAGE_QUOTA_BYTES=TBD
OWNER_UPLOAD_QUOTA_BYTES=TBD OWNER_UPLOAD_QUOTA_BYTES=TBD
@@ -36,6 +51,13 @@ MAX_PENDING_UPLOADS=10
MAX_UPLOAD_BYTES=5368709120 MAX_UPLOAD_BYTES=5368709120
UPLOAD_PART_BYTES=8388608 UPLOAD_PART_BYTES=8388608
SCAN_MAX_BYTES=134217728 SCAN_MAX_BYTES=134217728
# Daily encrypted database backup to its own bucket (docs/BACKUP.md).
BACKUP_S3_ENDPOINT=https://<account>.r2.cloudflarestorage.com
BACKUP_BUCKET=dtf-backups
BACKUP_ACCESS_KEY_ID=TBD
BACKUP_SECRET_ACCESS_KEY=TBD
BACKUP_AGE_RECIPIENT=age1...
BACKUP_HOUR=3
# Names of external Portainer/Docker Swarm secrets, never their values. # Names of external Portainer/Docker Swarm secrets, never their values.
DATABASE_URL_SECRET=TBD DATABASE_URL_SECRET=TBD

View File

@@ -5,21 +5,24 @@ from .production_preflight import config_errors, source_errors
class ReleaseWorkflowTests(unittest.TestCase): class ReleaseWorkflowTests(unittest.TestCase):
def test_main_push_cannot_publish_and_manual_release_is_gated(self): def test_only_checked_main_publishes_and_pushes_never_deploy(self):
workflow = (Path(__file__).resolve().parents[1] / workflow = (Path(__file__).resolve().parents[1] /
'.gitea/workflows/deploy.yml').read_text() '.gitea/workflows/deploy.yml').read_text()
release = workflow.split(' publish-and-deploy:\n', 1)[1] checks, release = workflow.split(' publish-and-deploy:\n', 1)
self.assertIn("if: gitea.event_name == 'workflow_dispatch' && " # Publishing waits for every check, and only ever happens from main.
"gitea.ref == 'refs/heads/main'", release) self.assertIn('needs: [validate, integration, scan]', release)
preflight = release.index('python3 deploy/production_preflight.py --source-only') self.assertIn("if: gitea.ref == 'refs/heads/main' && ", release)
webhook = release.index('test -n "$PORTAINER_WEBHOOK"') # The source preflight lives in the scan job and can be made blocking.
self.assertIn('python3 deploy/production_preflight.py --source-only', checks)
self.assertIn('ENFORCE_PRODUCTION_PREFLIGHT', checks)
# Images are scanned before they are pushed, and a push to main only
# publishes: redeployment is Portainer's (or a manual run's) decision.
scan = release.index('- name: Image vulnerabilities') scan = release.index('- name: Image vulnerabilities')
publish = release.index('- name: Publish validated images') publish = release.index('- name: Publish validated images')
redeploy = release.index('- name: Trigger Portainer redeployment') redeploy = release.index('- name: Trigger Portainer redeployment')
self.assertLess(preflight, scan)
self.assertLess(webhook, scan)
self.assertLess(scan, publish) self.assertLess(scan, publish)
self.assertLess(publish, redeploy) self.assertLess(publish, redeploy)
self.assertIn("if: gitea.event_name == 'workflow_dispatch'", release[redeploy:])
def valid_config(): def valid_config():

View File

@@ -19,13 +19,60 @@ x-app-environment: &app-environment
# this value is configured. # this value is configured.
OPERATOR_EMAIL: ${OPERATOR_EMAIL:-} OPERATOR_EMAIL: ${OPERATOR_EMAIL:-}
OPERATOR_PASSWORD: ${OPERATOR_PASSWORD:?set OPERATOR_PASSWORD} OPERATOR_PASSWORD: ${OPERATOR_PASSWORD:?set OPERATOR_PASSWORD}
PAYMENT_ADAPTER: fake # fake until Mercado Pago is configured; mercadopago requires MP_ACCESS_TOKEN
# Optional: without it the webhook verifies nothing and therefore accepts # and MP_WEBHOOK_SECRET or the API and worker refuse to start. Test
# nothing, which is the correct state until a provider is connected. Set it # credentials (TEST-...) until the sandbox flows have passed. The card form
# when the provider is configured, never to a value anyone could guess. # appears only with MP_PUBLIC_KEY, and then needs PAYMENT_CSP_SOURCES too.
PAYMENT_ADAPTER: ${PAYMENT_ADAPTER:-fake}
MP_ACCESS_TOKEN: ${MP_ACCESS_TOKEN:-}
# The "assinatura secreta" from the webhook settings in Mercado Pago.
MP_WEBHOOK_SECRET: ${MP_WEBHOOK_SECRET:-}
# https://<SITE_DOMAIN>/api/payments/webhook, sent with every payment.
MP_NOTIFICATION_URL: ${MP_NOTIFICATION_URL:-}
MP_PUBLIC_KEY: ${MP_PUBLIC_KEY:-}
# The fake adapter's secret. Optional: without it the webhook verifies
# nothing and therefore accepts nothing, which is the correct state until a
# provider is connected. Never set it to a value anyone could guess.
PAYMENT_WEBHOOK_SECRET: ${PAYMENT_WEBHOOK_SECRET:-} PAYMENT_WEBHOOK_SECRET: ${PAYMENT_WEBHOOK_SECRET:-}
FREIGHT_ADAPTER: fake # fake offers pickup only. jadlog prices delivery with Jadlog and needs the
# credentials below and the package weight from the client; it refuses to
# start without them. The credentials alone are enough for the console
# check, python -m app.jadlog_probe, on the worker.
FREIGHT_ADAPTER: ${FREIGHT_ADAPTER:-fake}
JADLOG_TOKEN: ${JADLOG_TOKEN:-}
# The "Usuário" Jadlog issued: the CNPJ that contracts the freight.
JADLOG_CNPJ: ${JADLOG_CNPJ:-}
JADLOG_CONTA: ${JADLOG_CONTA:-}
JADLOG_CONTRATO: ${JADLOG_CONTRATO:-}
# Package weight in kg: a base plus each billed metre of film.
JADLOG_PESO_BASE_KG: ${JADLOG_PESO_BASE_KG:-}
JADLOG_PESO_POR_METRO_KG: ${JADLOG_PESO_POR_METRO_KG:-}
# Working days of production added to Jadlog's delivery time.
FREIGHT_PRODUCTION_DAYS: ${FREIGHT_PRODUCTION_DAYS:-0}
# A cart the Site priced is approved at checkout and can be paid at once;
# orders above QUOTE_AUTO_MAX_METRES, or with a grade the Site did not
# compute, wait for an operator on the Kanban (app/quote_review.py).
QUOTE_AUTO_APPROVE: ${QUOTE_AUTO_APPROVE:-true}
QUOTE_AUTO_MAX_METRES: ${QUOTE_AUTO_MAX_METRES:-50}
# Order creation in Tiny stays off until it has been tested against the
# client's account (Tiny has no sandbox). The application credentials can be
# set now: they let an operator connect Tiny from the Kanban, and the worker
# keeps that connection alive. Callback: https://<KANBAN_DOMAIN>/api/operator/tiny/callback
TINY_ADAPTER: fake TINY_ADAPTER: fake
TINY_CLIENT_ID: ${TINY_CLIENT_ID:-}
TINY_CLIENT_SECRET: ${TINY_CLIENT_SECRET:-}
TINY_REDIRECT_URI: ${TINY_REDIRECT_URI:-}
TINY_PRODUCT_TEXTIL_FOLHA: ${TINY_PRODUCT_TEXTIL_FOLHA:-}
TINY_PRODUCT_TEXTIL_AVULSA: ${TINY_PRODUCT_TEXTIL_AVULSA:-}
TINY_PRODUCT_UV_FOLHA: ${TINY_PRODUCT_UV_FOLHA:-}
TINY_PRODUCT_UV_AVULSA: ${TINY_PRODUCT_UV_AVULSA:-}
# The client's "retirar pessoalmente" forma de envio id, on pickup orders.
TINY_FORMA_ENVIO_RETIRADA: ${TINY_FORMA_ENVIO_RETIRADA:-}
# Sets "Aprovada" on paid orders and "Pronto para envio" on finished pickup
# orders, which the client's Tiny -> n8n notices send to customers. Turn on
# only together with TINY_ADAPTER=tiny and after n8n stops sending the
# designer message for DTFIMP products.
TINY_STATUS_UPDATES: ${TINY_STATUS_UPDATES:-false}
WHATSAPP_ADAPTER: fake WHATSAPP_ADAPTER: fake
STORAGE_ADAPTER: s3-r2 STORAGE_ADAPTER: s3-r2
PUBLIC_ORIGIN: https://${SITE_DOMAIN:?set SITE_DOMAIN} PUBLIC_ORIGIN: https://${SITE_DOMAIN:?set SITE_DOMAIN}
@@ -35,10 +82,13 @@ x-app-environment: &app-environment
COOKIE_SECURE: "true" COOKIE_SECURE: "true"
MAX_UPLOAD_BYTES: "5368709120" MAX_UPLOAD_BYTES: "5368709120"
UPLOAD_PART_BYTES: "8388608" UPLOAD_PART_BYTES: "8388608"
STORAGE_QUOTA_BYTES: "53687091200" # Sheets of several GB are the normal order, so room for many of them.
OWNER_UPLOAD_QUOTA_BYTES: "10737418240" STORAGE_QUOTA_BYTES: "${STORAGE_QUOTA_BYTES:-536870912000}"
OWNER_UPLOAD_QUOTA_BYTES: "${OWNER_UPLOAD_QUOTA_BYTES:-53687091200}"
MAX_PENDING_UPLOADS: "10" MAX_PENDING_UPLOADS: "10"
SCAN_MAX_BYTES: "134217728" # ClamAV scans up to this; larger files (up to MAX_UPLOAD_BYTES) are released
# after a file-format check instead (app/scanning.py).
SCAN_MAX_BYTES: "2097152000"
services: services:
db: db:
@@ -84,7 +134,7 @@ services:
image: clamav/clamav@sha256:9cb27d7660bdf66e9878c832cb433dd8aa152cfbe16f3c2c0084c80b04ae22b4 image: clamav/clamav@sha256:9cb27d7660bdf66e9878c832cb433dd8aa152cfbe16f3c2c0084c80b04ae22b4
entrypoint: [clamd, --foreground=true, --config-file=/etc/clamav/clamd.conf] entrypoint: [clamd, --foreground=true, --config-file=/etc/clamav/clamd.conf]
configs: configs:
- source: clamd_config - source: clamd_config_2gb
target: /etc/clamav/clamd.conf target: /etc/clamav/clamd.conf
mode: 0444 mode: 0444
networks: [backend] networks: [backend]
@@ -133,12 +183,47 @@ services:
replicas: 1 replicas: 1
restart_policy: {condition: on-failure, delay: 5s} restart_policy: {condition: on-failure, delay: 5s}
# Daily encrypted copy of the database to a bucket of its own, off this
# server (ops/db_backup.py). Idle until the BACKUP_* settings are set. The
# bucket's lifecycle rule removes old copies; this credential never deletes.
backup:
image: ${API_IMAGE:-gitea.blyzer.com.br/blyzer/dtf-api}:${IMAGE_TAG:-latest}
command: python -m ops.db_backup serve
environment:
DATABASE_ADMIN_HOST: db
DATABASE_ADMIN_NAME: dtf
DATABASE_ADMIN_USER: dtf_admin
DATABASE_ADMIN_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
# The R2 account endpoint (the same as R2_ENDPOINT) and a bucket and API
# token for backups only, never the artwork bucket's.
BACKUP_S3_ENDPOINT: ${BACKUP_S3_ENDPOINT:-}
BACKUP_BUCKET: ${BACKUP_BUCKET:-}
BACKUP_ACCESS_KEY_ID: ${BACKUP_ACCESS_KEY_ID:-}
BACKUP_SECRET_ACCESS_KEY: ${BACKUP_SECRET_ACCESS_KEY:-}
# The public key (age1...). Its private key stays off this server.
BACKUP_AGE_RECIPIENT: ${BACKUP_AGE_RECIPIENT:-}
# Hour of day in Brasília.
BACKUP_HOUR: ${BACKUP_HOUR:-3}
networks: [backend, egress]
read_only: true
tmpfs: [/tmp]
deploy:
replicas: 1
restart_policy: {condition: on-failure, delay: 30s}
site: site:
image: ${WEB_IMAGE:-gitea.blyzer.com.br/blyzer/dtf-web}:${IMAGE_TAG:-latest} image: ${WEB_IMAGE:-gitea.blyzer.com.br/blyzer/dtf-web}:${IMAGE_TAG:-latest}
environment: environment:
WEB_INDEX: index.html WEB_INDEX: index.html
PUBLIC_HOST: ${SITE_DOMAIN:?set SITE_DOMAIN} PUBLIC_HOST: ${SITE_DOMAIN:?set SITE_DOMAIN}
S3_PUBLIC_ENDPOINT: ${R2_ENDPOINT:?set R2_ENDPOINT} S3_PUBLIC_ENDPOINT: ${R2_ENDPOINT:?set R2_ENDPOINT}
# Mercado Pago's card form loads from these origins; empty keeps the
# Site at script-src 'self'. Set together with the Mercado Pago adapter.
PAYMENT_CSP_SOURCES: ${PAYMENT_CSP_SOURCES:-}
# The bank's confirmation page for debit and other 3-D Secure cards is
# on the issuer's own domain, so it cannot be listed: "https:" lets
# frames and form posts reach it (never scripts). Empty turns it off.
PAYMENT_CHALLENGE_SOURCES: ${PAYMENT_CHALLENGE_SOURCES:-}
networks: [backend] networks: [backend]
ports: ports:
- target: 8080 - target: 8080
@@ -161,6 +246,8 @@ services:
WEB_INDEX: kanban.html WEB_INDEX: kanban.html
PUBLIC_HOST: ${KANBAN_DOMAIN:?set KANBAN_DOMAIN} PUBLIC_HOST: ${KANBAN_DOMAIN:?set KANBAN_DOMAIN}
S3_PUBLIC_ENDPOINT: ${R2_ENDPOINT:?set R2_ENDPOINT} S3_PUBLIC_ENDPOINT: ${R2_ENDPOINT:?set R2_ENDPOINT}
PAYMENT_CSP_SOURCES: ""
PAYMENT_CHALLENGE_SOURCES: ""
networks: [backend] networks: [backend]
ports: ports:
- target: 8080 - target: 8080
@@ -178,7 +265,9 @@ services:
restart_policy: {condition: on-failure, delay: 5s} restart_policy: {condition: on-failure, delay: 5s}
configs: configs:
clamd_config: # Renamed whenever infra/clamd.conf changes: Swarm cannot update a deployed
# config in place, and a redeploy with new content under the old name fails.
clamd_config_2gb:
file: ./infra/clamd.conf file: ./infra/clamd.conf
volumes: volumes:

87
docs/BACKUP.md Normal file
View File

@@ -0,0 +1,87 @@
# Database backup
The `backup` service copies the database off the server once a day
(`ops/db_backup.py`). It runs `pg_dump`, checks the archive, encrypts it with
[age](https://age-encryption.org) and uploads it to an R2 bucket used for
nothing else. Each run is recorded, and the Kanban shows the latest one under
Integrações → "Backup do banco" ("Em dia", "Verificar" or "Não configurado").
The artwork is not backed up. Originals and print files are temporary (7 to 30
days) and already stored on R2. The database holds what cannot be recreated:
orders, customers, quotes, payments, the Tiny connection and the operators.
## Why the backup cannot be read or deleted from the server
- **The server only encrypts.** It holds the public key (`age1…`). The private
key, which is the only way to open a backup, stays with the owner.
- **Its own bucket and token.** The backup token reaches the backup bucket
only, and the artwork token cannot reach it.
- **Nothing is deleted by the job.** The bucket's lifecycle rule removes old
copies. The bucket lock keeps anyone, including someone holding the token,
from deleting or overwriting a copy before its time.
## Setup (once)
1. **Key pair.** Generate it on your own computer, not on the server:
```bash
docker run --rm gitea.blyzer.com.br/blyzer/dtf-api:latest age-keygen
```
Or, with age installed (`sudo pacman -S age`, `apt install age`), just
run `age-keygen`.
Save the whole output (the `AGE-SECRET-KEY-1…` line is the private key) in
the password manager. Only the `public key: age1…` value goes to Portainer.
Without the private key, no backup can ever be restored.
2. **Cloudflare R2 → Create bucket.** Use a name such as `dtf-backups`.
3. **Settings on that bucket:**
- Object lifecycle rules: delete objects after 35 days.
- Bucket lock rules: retain for 30 days, prefix `db/`.
4. **R2 → Manage API tokens → Create API token.**
- Permission: Object Read & Write.
- Scope: the `dtf-backups` bucket only.
5. **Portainer.** Add these to the stack's environment, then redeploy:
| Variable | Value |
|---|---|
| `BACKUP_S3_ENDPOINT` | same as `R2_ENDPOINT` |
| `BACKUP_BUCKET` | `dtf-backups` |
| `BACKUP_ACCESS_KEY_ID` | the new token's Access Key ID |
| `BACKUP_SECRET_ACCESS_KEY` | the new token's Secret Access Key |
| `BACKUP_AGE_RECIPIENT` | the public key, `age1…` |
| `BACKUP_HOUR` | optional, default `3` (03:00 Brasília) |
The first backup runs as soon as the service starts. After that it runs daily,
and a failed run is retried an hour later.
## Restore test
Do this after setup, and then once a month. Open the console of the `backup`
container in Portainer (Containers → `…_backup…` → Console) and run:
```bash
python -m ops.db_backup list
python -m ops.db_backup verify --identity -
```
`--identity -` asks for the private key to be pasted. It is kept only in the
container's memory while the command runs. `verify` restores the latest backup
into a scratch database, prints its row counts and drops it. The live database
is not touched.
## Restoring for real
From the `backup` container's console:
```bash
python -m ops.db_backup restore db/2026/10/dtf-20261001T060000Z.dump.age --into dtf_restored --identity -
```
This restores into a new database (or an empty one), never over the running
one. Then do one of these:
- **Check the restored data**, then point the stack at it.
- **On a new server**, deploy only the `db` service first. Restore into `dtf`
while it is still empty, then deploy the rest. `db-init` then applies the
schema and grants on top.

View File

@@ -62,23 +62,35 @@ operator interfaces.
4. Click the Site checkout button. Files upload directly to MinIO, remain 4. Click the Site checkout button. Files upload directly to MinIO, remain
quarantined until ClamAV returns `clean`, and then become eligible for a quote. quarantined until ClamAV returns `clean`, and then become eligible for a quote.
The local banner displays a quote ID awaiting commercial review. The local banner displays a quote ID awaiting commercial review.
5. Open Kanban and log in. In **Cotações**, download the original if needed, 5. Open Kanban and log in. Open the **Cotações** tab, pick the quote, download
confirm/correct total metres and grade, tick the manual confirmation, and the original if needed, confirm/correct metres and grade, tick the
click **Aprovar cotação**. For the fixture keep 1.01 m and grade 0. confirmation at the bottom and click **Aprovar cotação**. For the fixture
keep 1.01 m and grade 0.
6. Return to the Site and click its checkout action again. Inspect the 6. Return to the Site and click its checkout action again. Inspect the
authoritative server total, then click **Criar pedido de teste**. No real authoritative server total, then click **Criar pedido de teste**. No real
payment occurs. payment occurs.
7. Refresh Kanban. The paid order starts in **Arte recebida**. Move it using 7. Click **Atualizar** on the Kanban. The paid order appears in **Arte
the buttons or drag and drop to **Arte tratada**. Open **Arquivos de produção**, recebida** on the **Produção** tab. Click its card to open the order panel,
select the manually prepared final files for every item, enter a review note, then **Mover para Arte tratada** (dragging the card to a column also works).
tick the confirmation and click **Aprovar arquivos finais**. Use the harmless In the panel's **Arquivos finais**, select the manually prepared final file
fixture again only for this local test; no printable file is generated. for every item, enter a review note, tick the confirmation and click
Continue through **Fila de impressão → **Aprovar arquivos finais**. Use the harmless fixture again only for this
Imprimindo → Finalizado**. A move to **Correção** requires a reason; it can local test. The text fixture is not an image, so its print file shows
return to **Arte recebida** or **Arte tratada**. Finalizado is terminal locally. **Preparar à mão**; with a PNG, JPEG or PDF artwork the generated file is
8. Inspect **Histórico** and **Eventos locais de integração**. Worker receipts preselected instead (see "Print files"). Continue with the panel's main
should say recorded locally. Download links expire after five minutes; button through **Fila de impressão → Imprimindo → Finalizado**. **Pedir
request another link from Kanban when needed. correção** asks for the reason the customer will see; from **Correção** the
order returns to **Arte recebida** or **Arte tratada**. A move made by
mistake is undone with **Voltar para …**: one stage back, with an internal
reason recorded in the history. The customer is not notified, approved
finals stay, and "produção iniciada"/"pedido pronto" are sent only once per
order even if the stage is entered again. Dragging a card only goes forward;
the columns it can be dropped on are highlighted.
8. The panel's **Histórico** lists every move. The **Pagamentos** tab lists
payments that need a person; **Integrações** shows the Tiny connection and
the send log; with the fake adapters every send reads "Registrado
(simulado)". Download links expire after five minutes; click again for a new
one. The previous Kanban is kept in git tag `ui-v1`.
The manual quote step is necessary to enforce the backend trust boundary while The manual quote step is necessary to enforce the backend trust boundary while
automatic pre-flight is deferred. Browser measurements and grade are proposals. automatic pre-flight is deferred. Browser measurements and grade are proposals.
@@ -193,8 +205,10 @@ must resolve from the browser; keep `http://localhost:9000` for this stack.
Parts use 15-minute presigned URLs and unfinished reservations expire after Parts use 15-minute presigned URLs and unfinished reservations expire after
one hour. Clients can cancel a reservation through the upload DELETE endpoint. one hour. Clients can cancel a reservation through the upload DELETE endpoint.
Only fake integration adapters and `s3-local` storage are accepted. Startup fails Fake integration adapters and `s3-local` storage are the default. Mercado Pago
if a production adapter/environment or nonlocal S3 endpoint is selected. and Tiny can be selected for sandbox testing only (see "Provider sandboxes");
startup fails if their credentials are missing, if any other adapter is
selected, or if a production environment or nonlocal S3 endpoint is used.
The API, worker, and database run on an internal Docker network; web gateways The API, worker, and database run on an internal Docker network; web gateways
and MinIO also join a network that permits loopback port publishing. and MinIO also join a network that permits loopback port publishing.
@@ -216,6 +230,119 @@ storage permits; otherwise reselect them. Saved quote IDs also survive reloads.
Clearing cookies loses a guest session, while registered customers can sign in Clearing cookies loses a guest session, while registered customers can sign in
again. The operator can still inspect order records. again. The operator can still inspect order records.
## Print files
Every paid order queues one print-file job per item. The worker renders a PDF
exactly as wide as the film and as long as the approved layout, placing each
copy at the position, rotation and mirror the customer reviewed. Each source
image is embedded once at its original resolution (JPEG bytes unchanged, PNG
transparency kept as a soft mask), so nothing is resampled. The Kanban card
shows the status per item, the page size and the lowest DPI, and offers
**Baixar PDF**. In **Arquivos de produção** the generated file is preselected
as the final file; untick it to upload one by hand instead.
JPEG, PNG, WebP and TIFF are embedded as images. A single-page PDF is placed
as a vector form (never rasterised), using the page's CropBox and `/Rotate`
exactly as the Site measured it with pdf.js; the card then shows **PDF
vetorial**. PSD, AI and CDR artwork, multi-page or password-protected PDFs, a
file whose proportions do not match the quoted size, a layout longer than was
billed, or an image above `PRINT_MAX_PIXELS` (250 Mpx by default) goes to
**preparar à mão** with the reason, and the operator prepares it as before.
**Gerar arquivos de impressão** retries those items and generates files for
orders paid before the generator existed. After a customer correction the
generated file is no longer offered: it reproduces the replaced artwork.
Layouts longer than about 5 m use the PDF `UserUnit` page scale instead of
being split into pages. Confirm on the factory's FlexiPRINT that such a file
imports at full length before relying on it for long orders.
```bash
docker compose -f compose.local.yaml exec -T api python -m unittest tests.test_printfile -v
docker compose -f compose.local.yaml exec -T api python -m tests.print_file_test
```
With PyMuPDF installed locally (`pip install pymupdf`), the unit suite also
draws each page and checks where every quadrant of the artwork lands.
## Provider sandboxes
`app/mercadopago.py` and `app/tiny.py` follow the providers' public API
documentation and pass their unit suites against a fake transport. They are not
verified integrations until they pass with the client's accounts.
The default local stack gives the API and worker no route to the internet, so
provider testing adds `compose.providers.yaml`, which does:
```bash
docker compose -f compose.local.yaml -f compose.providers.yaml up -d --wait
```
Put only **test** credentials in `.env`:
```bash
PAYMENT_ADAPTER=mercadopago
MP_ACCESS_TOKEN=TEST-...
MP_WEBHOOK_SECRET=... # "Assinatura secreta" in the webhook settings
MP_NOTIFICATION_URL=https://<public tunnel>/api/payments/webhook
```
### Tiny (API v3)
Tiny uses OAuth2. In the client's Tiny (Construa plan or above, with the
"Gestão de Aplicativos" extension): **Configurações → Geral → Aplicativos →
+ novo aplicativo**, with the redirect URL set to this system's callback. That
gives a client ID and secret:
```bash
TINY_CLIENT_ID=...
TINY_CLIENT_SECRET=...
TINY_REDIRECT_URI=http://localhost:8081/api/operator/tiny/callback # exactly as registered in Tiny
TINY_PRODUCT_TEXTIL_FOLHA=... # Tiny product id for each Site product
TINY_PRODUCT_TEXTIL_AVULSA=...
TINY_PRODUCT_UV_FOLHA=...
TINY_PRODUCT_UV_AVULSA=...
```
With the client ID and secret set, the Kanban header shows **Conectar Tiny**.
Someone with a Tiny login approves access once; the tokens are stored in the
database (the refresh token rotates on every use) and the worker keeps the
connection alive. Only then set `TINY_ADAPTER=tiny`, which starts creating an
order in Tiny for every paid order: find or create the customer's contact by
CNPJ, then `POST /pedidos` with `numeroOrdemCompra = DTF-<order number>`. A
retry searches the customer's recent orders for that number first, so it does
not create a second one. **Tiny has no sandbox**: every test order is real, so
agree the test with the client and cancel the test orders afterwards.
`tests.tiny_oauth_test` checks the connection flow against the real database
with a fake token server; it saves and restores any existing connection.
With Mercado Pago selected, an approved quote shows **Pagar com PIX** on the
Site instead of the local test button, and **Pagar com cartão** when
`MP_PUBLIC_KEY` is set. The card form is Mercado Pago's Card Payment Brick: the
card is typed into Mercado Pago's secure fields and only a one-time token
reaches the API. It loads from Mercado Pago, so the Site's CSP must allow it:
```bash
MP_PUBLIC_KEY=TEST-...
PAYMENT_CSP_SOURCES=https://sdk.mercadopago.com https://*.mercadopago.com https://*.mlstatic.com https://*.mercadolibre.com
```
`PAYMENT_CSP_SOURCES` is empty by default, which keeps the Site at
`script-src 'self'`. The payment page offers credit card (the default), debit
card and PIX. Card payments ask for 3-D Secure when the issuer requires it,
which debit cards usually do: the bank's confirmation page opens in a frame on
the issuer's own domain, so it needs `PAYMENT_CHALLENGE_SOURCES=https:` (frames
and form posts only, never scripts; empty by default). Once a payment for a
quote is approved, or a card payment is in review, the API refuses any further
attempt for that quote; a card left waiting for the bank's confirmation stops
blocking after ten minutes. The order is created only by the signed
notification, after the payment is fetched from the Mercado Pago API and its
BRL amount matches the approved total. Mercado Pago must be able to reach the
webhook, so a local run needs a public HTTPS tunnel to the Site port. A paid
notification that cannot become an order, or a refund on an existing order,
appears under **Pagamentos que precisam de atenção** on the Kanban until an
operator records the resolution.
## Local backup and restore check ## Local backup and restore check
```bash ```bash

View File

@@ -17,9 +17,11 @@ Cloudflare R2, so MinIO is not part of this stack.
The single workflow is `.gitea/workflows/deploy.yml`. Every push and pull request The single workflow is `.gitea/workflows/deploy.yml`. Every push and pull request
runs static validation, the integration suite against a real stack, and a secret runs static validation, the integration suite against a real stack, and a secret
scan. A push to `main` does not publish or deploy. A manual workflow run on scan. When all of them pass on a push to `main`, the images are built, scanned
`main` repeats those checks, builds and scans the images, then publishes both and published as both `latest` and the full commit SHA. Nothing is deployed:
`latest` and the full commit SHA and calls Portainer. production changes when someone pulls and redeploys the stack in Portainer. A
manual workflow run on `main` does the same and also calls the Portainer
webhook, if `PORTAINER_WEBHOOK` is configured.
What actually gates a deployment: What actually gates a deployment:
@@ -29,16 +31,18 @@ What actually gates a deployment:
| Integration suite on a live stack (smoke, workflow, security, scanning, retention, runtime) | yes | | Integration suite on a live stack (smoke, workflow, security, scanning, retention, runtime) | yes |
| Browser suites | yes; Chrome runs in the Compose test container | | Browser suites | yes; Chrome runs in the Compose test container |
| Trivy secret scan (HIGH/CRITICAL) | yes | | Trivy secret scan (HIGH/CRITICAL) | yes |
| Source preflight (`deploy/production_preflight.py --source-only`) | yes for manual release; advisory on pushes unless `ENFORCE_PRODUCTION_PREFLIGHT` is `true` | | Source preflight (`deploy/production_preflight.py --source-only`) | advisory unless `ENFORCE_PRODUCTION_PREFLIGHT` is `true`, which blocks publishing |
| Trivy image vulnerabilities, CRITICAL | yes | | Trivy image vulnerabilities, CRITICAL | yes |
| Trivy image vulnerabilities, HIGH | no — reported before publication | | Trivy image vulnerabilities, HIGH | no — reported before publication |
| Configured Portainer webhook | yes for manual release | | Configured Portainer webhook | no — called on manual runs when present; otherwise redeploy in Portainer |
The source preflight refuses a release while the payment and messaging adapters The source preflight reports while the payment and messaging adapters are
are fake. It remains advisory on push checks so development can continue, but a fake. From 2026-09-23 it was enforced on every manual release, and since the
manual release is blocked until those adapters are replaced. Set the repository adapters are still fake no release could succeed: production kept running
variable `ENFORCE_PRODUCTION_PREFLIGHT` to `true` when all pushes should also older images while `main` moved on. It is now advisory everywhere. Set the
fail on those blockers. Before a manual release, run the full configuration repository variable `ENFORCE_PRODUCTION_PREFLIGHT` to `true` once the real
adapters are in place, and a blocked preflight then stops images from being
published. Before a manual release, run the full configuration
preflight below against the actual Portainer values; CI checks source only. preflight below against the actual Portainer values; CI checks source only.
CRITICAL image findings block. Both images carry none: the bases are pinned by CRITICAL image findings block. Both images carry none: the bases are pinned by
@@ -89,6 +93,24 @@ as `dtf_prod_database_url_v1`, then place only those names in the corresponding
Credential values must never be entered into Git, `portainer.env`, or Gitea Credential values must never be entered into Git, `portainer.env`, or Gitea
workflow variables. workflow variables.
**R2 CORS.** The Site uploads artwork straight from the browser to the bucket
with presigned `PUT` requests, so the bucket must allow the Site's origin.
Without it the preflight is refused (403) and the Site shows "NetworkError
when attempting to fetch resource" at checkout (found 2026-09-28). In the
Cloudflare dashboard, R2 → the bucket → Settings → CORS policy:
```json
[
{
"AllowedOrigins": ["https://<SITE_DOMAIN>", "https://<KANBAN_DOMAIN>"],
"AllowedMethods": ["PUT", "GET", "HEAD"],
"AllowedHeaders": ["content-type"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}
]
```
## 3. Create the stack once ## 3. Create the stack once
In Portainer select **Stacks → Add stack → Git repository**: In Portainer select **Stacks → Add stack → Git repository**:

View File

@@ -7,7 +7,26 @@
> Update the **Current step** line and the item status every time something moves. > Update the **Current step** line and the item status every time something moves.
> Add new findings at the bottom of the relevant block rather than rewriting history. > Add new findings at the bottom of the relevant block rather than rewriting history.
**Current step (2026-09-23, Week 2):** Payment safety fixes 2.13 and 2.14 and **Current step (2026-09-25, last day of Week 2):** Still waiting on the client
for Mercado Pago credentials and webhook access (1.1) and freight data (1.2).
Tiny is connected in production and reads orders, but the connected user
cannot read contacts (403) and the client's catalogue has no per-metre UV
product; both wait on the client (1.3). Built and deployed without them: server-side print-file
generation (1.4), the delivery address (3.8), the Kanban's payment-issue and
print-file views and its redesign (1.5), the Site redesign (5.17), and Mercado
Pago and Tiny adapters written from the public API documentation and tested
against fake transports. None of the provider work is a verified integration.
Every change passed the full integration sequence locally (Docker Engine in
WSL) and on the Gitea runner, which publishes images on green pushes to `main`;
the user verified each release in production after redeploying in Portainer.
Operator and Site guides are in `docs/` (see Reporting).
**Left for today:** the Week 2 client report. Waiting on the client: the
Contatos permission and the product decision, then product ids and one
supervised real order (1.3); Mercado Pago credentials for the sandbox PIX and
card payments (1.1); freight data (1.2, the one Week 2 item that slips, on
client inputs).
**Previous step (2026-09-23):** Payment safety fixes 2.13 and 2.14 and
the local order-correctness work in 3.6/3.9 have passed integration checks. the local order-correctness work in 3.6/3.9 have passed integration checks.
Production specification v2 now records each copy's film coordinates and is Production specification v2 now records each copy's film coordinates and is
kept through the approved order; 4.6 now pages pending and approved unpaid kept through the approved order; 4.6 now pages pending and approved unpaid
@@ -211,14 +230,169 @@ From the report already sent. These are dated promises, not backlog.
A refused paid event must be visible for operator resolution rather than silently A refused paid event must be visible for operator resolution rather than silently
treated as finished. See 2.14 and 3.7. Requires sandbox access, webhook treated as finished. See 2.14 and 3.7. Requires sandbox access, webhook
administration, event mapping and an approved refund policy. administration, event mapping and an approved refund policy.
**Groundwork (2026-09-24):** `app/mercadopago.py` creates PIX or card-token
payments with the quote as idempotency key, verifies `x-signature` as
documented (HMAC-SHA256 over `id;request-id;ts`, 30-minute replay window),
and treats the notification as a pointer: the payment is fetched from the
API and only a BRL amount in whole centavos is compared. `payment_intents`
binds each provider payment to its quote; `/api/payments/intent` starts a
PIX and the Site shows its QR code. Refused paid events and refunds on
existing orders now stay on the Kanban until an operator records a
resolution. Unit-tested against a fake transport only.
**Card form (2026-09-24):** Mercado Pago's Card Payment Brick on the Site,
shown when `MP_PUBLIC_KEY` is set; the card becomes a one-time token in
Mercado Pago's secure fields. Each card attempt has its own idempotency key
(a decline can be retried with another card) and the intent route refuses
any new attempt once a payment is approved or a card is in review, so a
quote cannot be charged twice. The Site CSP gains the Mercado Pago origins
only through `PAYMENT_CSP_SOURCES`, empty by default. Not yet rendered
against a real public key; sandbox run and refund policy remain.
**Account setup (2026-09-28):** the client's application is Checkout
Transparente on the Payments API, webhook event "Pagamentos (legacy)" only,
URL `https://dtf.agenciacompor.com.br/api/payments/webhook`. The production
compose now takes `PAYMENT_ADAPTER` and `MP_*` from Portainer (it hard-coded
the fake adapter), so the sandbox runs on production with test credentials;
the Site has no real customers yet. A verified notification whose payment
does not exist (the panel's "Simular notificação") is acknowledged instead
of answering 500, which would have made Mercado Pago retry it.
- `[ ]` 1.2 — Real freight quotation. **Blocked on client inputs** (see - `[ ]` 1.2 — Real freight quotation. **Blocked on client inputs** (see
`PRODUCTION_INPUTS.md`): source platform, credentials, origin CEP, services, `PRODUCTION_INPUTS.md`): source platform, credentials, origin CEP, services,
packaging weight/dimensions per length, subsidy policy. packaging weight/dimensions per length, subsidy policy.
- `[ ]` 1.3 — Idempotent Tiny/Olist order creation with order-number traceability. **Received (2026-09-25):** Jadlog, API access (user/CNPJ, client code,
token, account), origin CEP 14402-310, service .PACKAGE (modalidade 3),
home delivery, billed by contract. Still missing: packaging weight and
dimensions per length and how freight is charged to the customer (asked
2026-09-28). `app/jadlog.py` prices one package from the manual (v2.3);
`python -m app.jadlog_probe` prices test weights to six regions, read-only,
to confirm token, account and contract on the client's account. Not yet run.
**Adapter (2026-09-29):** `FREIGHT_ADAPTER=jadlog` prices "Receber em casa"
with Jadlog from the order's billed metres (`JADLOG_PESO_BASE_KG` plus
`JADLOG_PESO_POR_METRO_KG` per metre) and value, adds
`FREIGHT_PRODUCTION_DAYS` to Jadlog's time, re-quotes the cart when it
changes, and quotes again at approval from the priced items. It refuses to
start without the credentials and the weights, which have no default. The
production stack now takes the Jadlog settings, so the probe runs from the
worker's console. Cubed weight is not computed: the client's box sizes will
tell whether it is needed.
- `[~]` 1.3 — Idempotent Tiny/Olist order creation with order-number traceability.
Confirm endpoints, tag behaviour and rate limits first. Confirm endpoints, tag behaviour and rate limits first.
- `[ ]` 1.4 — Final print-file generation (see 3.2 and 3.6: production instructions **Groundwork (2026-09-24), API v3 by decision:** OAuth2 against Tiny's
Keycloak. An operator starts the connection from the Kanban; the callback is
authorised by a single-use state (the operator cookie is SameSite=Strict and
does not survive Tiny's cross-site redirect). Tokens live in
`provider_tokens`; the refresh token rotates under a row lock and the worker
keeps the connection alive. Orders: contact found by CNPJ or created, then
`POST /pedidos` with product ids from `TINY_PRODUCT_TEXTIL_FOLHA` / `_TEXTIL_AVULSA` / `_UV_FOLHA`
/ `_UV_AVULSA` (not `_<MODE>`: a name ending in `_FILE` is read as a secret
file path by `app/core/secrets.py`) and
`numeroOrdemCompra = DTF-<number>`; a retry searches the customer's last
seven days of orders for that number first. Production passes the app
credentials through but keeps `TINY_ADAPTER: fake`. Tested against fake
transports (`tests.test_tiny`) and, for OAuth, the real database
(`tests.tiny_oauth_test`). Tiny has no sandbox: the first real test creates
real orders. Still to confirm on the client's account: plan (Construa+),
product ids, token lifetimes, whether pickup needs a transportador, and
rate limits.
**Supervised run (2026-09-25):** the payload and query parameters were
checked against Tiny's published v3 OpenAPI spec (`GET /pedidos` accepts
`cpfCnpj` and `dataInicial`; `GET /pedidos/{id}` returns
`numeroOrdemCompra`). "Testar conexão" on the Kanban now also reads the four
configured products and requires each to be active. `python -m
app.tiny_probe` runs from the worker console: `produtos` and `conferir` are
read-only; `pedido` shows the test order and creates it only with
`--confirmar`, through the worker's own `deliver`, then proves the duplicate
guard by search first and only then by a second delivery. Not yet run
against the client's account.
**Staying connected (2026-09-25):** Tiny documents a 4-hour access token and
a 1-day refresh token; the worker renews about every 4 hours. The connection
now asks for `offline_access` (listed by Tiny's Keycloak; if refused for this
application the callback retries once without it). Renewal failures are
stored: a refused refresh token marks the connection lost and is not retried,
a transient failure shows as a warning until the next success, and a
session grant with under 12 hours left is flagged. Previously a refused
refresh still showed "Tiny conectado". Whether Tiny grants offline access,
and whether a session grant has an undocumented maximum, is only known on
the client's account. There is no alert channel yet (no e-mail; WhatsApp
is fake): problems show on the Kanban only.
**Client account (2026-09-25):** connected in production with the
developer user the client provided. "Testar conexão": `pedidos` ok,
`contatos` 403, unchanged after reconnecting. The application's permissions
are correct (Contatos, Pedidos: read and include/edit; Produtos: read);
Olist documents that v3 calls also depend on the logged-in user's module
permissions, and that user's Cadastros menu shows only Produtos. Orders
need Contatos (search by CNPJ, create when new; delete never). **Client
to decide:** grant that user Clientes e Fornecedores, or reconnect with a
user that has it (a dedicated integration user is recommended).
**Products (2026-09-25),** from the client's product export: none of the
36 "DTF" products matches the four Site products. `951438842` "IMPRESSÃO
DTF PERSONALIZADO 57X100 (1 METRO)" (MT, R$ 19,90, active) fits Têxtil;
there is no per-metre UV product (the UV one is 27 cm, per 10 cm), and no
separate "artes avulsas" product. **Client to decide:** create four per-metre
products (recommended, copying `951438842`), or share `951438842` between
the two Têxtil modes; UV needs a product either way. The item note already
carries the Site mode and grade. No product ids are set in production yet;
both questions go to the client with the Mercado Pago credentials request.
**Customer notices through Tiny (2026-09-25, Week 3 head start):** the client
already sends WhatsApp notices from Tiny's order situação (Tiny webhook ->
the `api-tiny-n8n` middleware, which reads the order through API v2 -> n8n
-> WhatsApp templates): Aprovado, Pronto para envio with forma de envio `X`
(v2 "Customizada", their pickup), Enviado, Entregue. So the system's own
WhatsApp sender stays off and Tiny drives the notices. With
`TINY_STATUS_UPDATES=true` a paid order is created "Aberta" and set to
"Aprovada" (a retry finishes a half-done approval; an order already moved on
is left alone), and moving a pickup order to Finalizado sets "Pronto para
envio" by the Tiny id from the sale's receipt, searching only when it is
missing and retrying while the sale has not reached Tiny. Pickup orders
carry `TINY_FORMA_ENVIO_RETIRADA` (`tiny_probe formas-envio` lists the ids;
"Testar conexão" now checks it). Delivery orders get "Enviada" with 1.2.
Off by default: while n8n's `isDTFIMP` branch exists, "Aprovado" on a
`DTFIMP` product sends the designer message, and the Site's Têxtil product
code starts with `DTFIMP`. **Go-live together:** `TINY_ADAPTER=tiny`,
`TINY_STATUS_UPDATES=true`, n8n's `isDTFIMP`/"DTF Aprovado - Designer"
removed with "Mapear Whatsapp do Vendedor" connected to `If6`, and the
middleware's `numero_ecommerce` falling back to the purchase order so the
message shows `DTF-<n>`. **Unverified on the account:** that an API status
change fires Tiny's webhook, that Tiny accepts Aprovada -> Pronto para envio
without Faturada, and the v2 field name for the purchase order. Correção
necessária and Produção iniciada have no Tiny situação; client to decide
whether they need messages.
- `[~]` 1.4 — Final print-file generation (see 3.2 and 3.6: production instructions
must survive checkout before an output engine can reproduce the approved job). must survive checkout before an output engine can reproduce the approved job).
- `[ ]` 1.5 — Main Kanban production states consolidated. **Built (2026-09-24):** each paid item gets a PDF the width of the film and
the length of the approved layout, with every copy at its reviewed position,
rotation and mirror (`app/printfile.py`, rendered by the worker from
`app/printjobs.py`). Sources are embedded once at original resolution; JPEG
bytes pass through, PNG alpha becomes a soft mask, EXIF orientation is
honoured. A file whose proportions differ from the quote, a layout longer than
billed, or PDF/PSD/AI/CDR artwork goes to hand preparation with the reason.
The operator approves the generated PDF as the final file through the
existing review. Unit tests include a raster check of every rotation and
mirror, and `tests.print_file_test` passes on the running stack (generate,
download, approve as final, queue; hand-preparation routing and retry).
**Still open:** a FlexiPRINT import of real
generated files (including one longer than 5 m, which uses `UserUnit`).
**PDF artwork (2026-09-24):** a single-page PDF source is placed as a vector
form through pikepdf (MPL-2.0; PyMuPDF was rejected for its AGPL licence),
using the CropBox and inherited `/Rotate` the Site measured with pdf.js.
Raster tests cover crop, page rotation, placement rotation and mirroring,
and were shown to fail when the rotation or crop handling is broken.
Multi-page and protected PDFs go to hand preparation.
- `[~]` 1.5 — Main Kanban production states consolidated. The six states and
their transitions are unchanged; cards now show the delivery address, the
print-file status per item, and a panel lists payments that need a person
(money without an order, refunds after an order) until resolved. Confirm
with the operation that these are the main states before closing.
**2026-09-24:** a mistaken move can be undone one stage back (`BACK` in
`app/runtime.py`) with an internal reason, flagged in the history
(`movements.back`), without notifying the customer; "production started" and
"ready" are enqueued once per order. Previously the only way back was
Correção, which messages the customer and invalidates approved finals.
**Redesign (2026-09-24):** tabs for Produção, Cotações, Pagamentos and
Integrações; an order panel with stages, items, print files, final files and
history; numbered pagination (20 per page) on quotes, payment issues and the
integration log, which also has filters; messages dismiss themselves. The
operator guide (`docs/guia-operador-kanban.pdf`) documents the flow for the
operation to confirm.
- `[x]` 1.6 — **Block 0.2 + 0.3** were completed and verified on 2026-09-18. - `[x]` 1.6 — **Block 0.2 + 0.3** were completed and verified on 2026-09-18.
`[!]` The production compose currently blocks `dev_paid` (`ENVIRONMENT != 'local'`) `[!]` The production compose currently blocks `dev_paid` (`ENVIRONMENT != 'local'`)
@@ -414,7 +588,19 @@ The production topology has not been verified by the repository review.
## Block 3 · Architecture — needs a decision before code ## Block 3 · Architecture — needs a decision before code
### `[?]` 3.1 — Manual quote approval contradicts the 24h business case `(F16)` ### `[~]` 3.1 — Manual quote approval contradicts the 24h business case `(F16)`
**Decided 2026-09-28 (the user: "we are an e-commerce"):** a cart the Site
priced is approved when the quote is created and can be paid at once
(`app/quote_review.py`, the same pricing the operator's approval uses). A
person reviews only orders above `QUOTE_AUTO_MAX_METRES` (50 m) and items
that claim a grade the Site could not have computed (unanalysed art with a
discount). The Kanban lists automatic approvals and the reason for each
manual one. **Still open:** the grade and layout are the browser's (3.2,
3.9), so a customer who edits the page can claim a better grade, up to the
top tier's discount; the server must compute the grade before this closes.
Previously:
Payment requires `quotes.approved`, set only by an authenticated operator. The Payment requires `quotes.approved`, set only by an authenticated operator. The
meeting's premise was that the 17h30 order waiting until 5am is what costs the meeting's premise was that the 17h30 order waiting until 5am is what costs the
@@ -441,7 +627,14 @@ the site already did.
generator, with the browser as preview only. This is the single largest gap between generator, with the browser as preview only. This is the single largest gap between
what was promised in the meeting and what exists. what was promised in the meeting and what exists.
### `[?]` 3.3 — The 5 GB problem is unsolved `(F19)` **2026-09-30:** the grade (the resolution discount) is no longer taken on trust.
Before an automatic approval the API recomputes it from the uploaded files by the
Site's rules (`app/grade_check.py`): image headers for PNG/JPG/WebP, the placed
images of a PDF up to 150 MB. A claim more than 2 points above the file's grade,
or a discount on a file the server cannot grade, waits for review with the reason
on the Kanban. The metres (the packing) are still the browser's.
### `[~]` 3.3 — The 5 GB problem is unsolved `(F19)`
Transport accepts 5 GiB; `SCAN_MAX_BYTES` / ClamAV `StreamMaxLength` release only Transport accepts 5 GiB; `SCAN_MAX_BYTES` / ClamAV `StreamMaxLength` release only
≤ 128 MiB. As of 2026-09-23, customer selection and API reservation reject files ≤ 128 MiB. As of 2026-09-23, customer selection and API reservation reject files
@@ -452,6 +645,23 @@ This is exactly the risk Jorge raised in the meeting.
**Decide:** raise the scan ceiling with a resource/timeout design, or define an **Decide:** raise the scan ceiling with a resource/timeout design, or define an
explicit large-file path (staged scan, sampled scan, operator override with audit). explicit large-file path (staged scan, sampled scan, operator override with audit).
**Built 2026-09-29 (sheets of several GB are the normal order, not the
exception):** files up to 5 GB. ClamAV scans up to 2 GB (`StreamMaxLength
2000M`); above that a file is released only when its first bytes match the
format its name claims (option A, the user's choice). The Site grades a sheet
over 150 MB from the pixel size in the PNG/JPEG/WebP header without decoding
it, and measures large PDFs through ranged reads; the worker never opens a
source over 300 MB: a single finished sheet placed whole becomes its own print
file, anything else goes to hand preparation. Uploads start as items enter the
cart and the lease renews with each part. Verified on the local stack: 386 MB
(ClamAV 78 s), 1.8 GB (ClamAV 6 min 18 s, scanner under 430 MB of memory, the
original as print file), 2.3 GB PNG released by the format check and a
disguised 2.3 GB file refused, and the header grade of a 200 MB file in 0.5 s.
**Open:** large PDFs get no automatic grade (the DPI of images inside is not
read without rendering); the scanner takes one file at a time, so several
multi-GB uploads queue; pieces, residue and background of a large sheet are
not checked automatically.
### `[ ]` 3.4 — Upload throughput `(F20)` ### `[ ]` 3.4 — Upload throughput `(F20)`
8 MiB parts, strictly sequential in `local/static/upload.js:21`, one presign 8 MiB parts, strictly sequential in `local/static/upload.js:21`, one presign
@@ -497,12 +707,20 @@ overpayment and provider success followed by database failure. Record refused
paid events for resolution. Decide who reconciles them and when production must paid events for resolution. Decide who reconciles them and when production must
stop or refund. Implement with 1.1 after the checkout/refund policy is approved. stop or refund. Implement with 1.1 after the checkout/refund policy is approved.
### `[ ]` 3.8 — Collect a deliverable destination before charging freight ### `[~]` 3.8 — Collect a deliverable destination before charging freight
The quote has a shipping service and CEP but no recipient, street, number, The quote has a shipping service and CEP but no recipient, street, number,
city/state or delivery snapshot. Add and validate these fields with 1.2, then city/state or delivery snapshot. Add and validate these fields with 1.2, then
bind the chosen service and final freight amount to the payment intent. bind the chosen service and final freight amount to the payment intent.
**Local progress 2026-09-24:** the Site collects recipient, street, number,
complement, district, city and UF for delivery; the API requires them for any
non-pickup quote, requires the CEP to be the one freight was quoted for, and
refuses an address on pickup. The address is part of the reviewed quote, the
order snapshot, the Kanban card and the Tiny payload. Changing it after a quote
invalidates that quote in the browser like any other cart change. Binding the
chosen freight service to the payment intent waits on 1.2.
### `[~]` 3.9 — Make artwork quality and geometry evidence explicit ### `[~]` 3.9 — Make artwork quality and geometry evidence explicit
Reject or route for review when PDF page count/geometry, image decoding or DPI Reject or route for review when PDF page count/geometry, image decoding or DPI
@@ -622,11 +840,48 @@ print-file evidence still need correction before this item can close.
data, a production API image import, local security status, and a local data, a production API image import, local security status, and a local
backup/restore of the database plus 78 clean objects. Production offsite backup/restore of the database plus 78 clean objects. Production offsite
recovery and signature freshness remain separate open items. recovery and signature freshness remain separate open items.
- `[x]` 5.15 — MinIO stopped publishing public images: by 2026-09-24 both
Docker Hub and quay.io answered anonymous pulls with 401, so a runner or
machine without a cached image could not start the stack. The local/CI
storage and storage-init now use Chainguard's MinIO build (ships `sh` and
`mc`, non-root), pinned by digest. Verified with a fresh local build and the
full integration sequence. Production uses R2 and is unaffected.
- `[x]` 5.16 — The operator login limit (10 per account per 15 minutes) counted
successful logins too, so ordinary use could lock an operator out, and one
extra test login made CI's final browser sign-in fail with 429. Now every
attempt counts against the source address and only failures count against
the account; registration still counts every attempt. The security suite's
lockout check (ten failures, then 429) is unchanged and passes.
- `[x]` 5.17 — Site redesign (2026-09-24), Dropstar brand kept; the previous
look is tag `ui-v1`. The home, one page per product (`/arquivo-por-metro`,
`/artes-avulsas`, `/uv-arquivo-por-metro`, `/uv-artes-avulsas`) and
`/carrinho` have their own addresses but stay one document, so artwork held in
the browser survives moving between them (`web/site-pages.js`,
`web/site-steps.js`; nginx serves `index.html` for those paths). The product
page is built around a buy box (`web/site-compra.js`) that shows the item the
flow already priced (grade, price per metre, metres charged, total,
resolution acknowledgement and "Adicionar ao carrinho"); the duplicated
quality and preview panels are hidden. The cart has "Remover" per item,
"Esvaziar carrinho" and an 8-second undo. Fixed on the way, all already in
production before: the saved-cart note took a grid column and pushed the
order into a narrow strip, the panels outside `.w` ran edge to edge, and
"57 cm" wrapped on the ready-sheet option. The browser suite covers product
and cart addresses, reload on a product page, and remove/undo; the security
suite checks the new addresses carry the same CSP.
- `[ ]` 5.13 — Define production recovery: scheduled encrypted offsite database - `[ ]` 5.13 — Define production recovery: scheduled encrypted offsite database
and object backups, a consistent snapshot boundary, Swarm data placement and and object backups, a consistent snapshot boundary, Swarm data placement and
a restore rehearsal that opens every required live order file. a restore rehearsal that opens every required live order file.
- `[ ]` 5.14 — Promote and verify one immutable release. Normal `main` pushes **2026-09-29:** database part built. The `backup` service dumps daily,
now run checks only; manual dispatch requires source preflight and a configured encrypts with age (the server holds only the public key) and uploads to a
bucket of its own, whose lifecycle rule expires copies and whose lock keeps
them from being deleted early. The Kanban shows the latest run, and
`tests/backup_test.py` restores a copy in CI. Setup and restore:
docs/BACKUP.md. Artwork is not copied: it is temporary and already on R2.
- `[ ]` 5.14 — Promote and verify one immutable release. **2026-09-24:** green
pushes to `main` now publish images and Portainer's pull-and-redeploy is the
release gate; the source preflight is advisory unless enforced by variable,
because enforcing it while the adapters are fake made every release fail.
Previously: normal `main` pushes ran checks only; manual dispatch required source preflight and a configured
webhook, and scans images before publishing. Still make the full preflight webhook, and scans images before publishing. Still make the full preflight
validate the active stack, deploy the tested immutable image references, test validate the active stack, deploy the tested immutable image references, test
clean install and upgrade, check application readiness after Portainer clean install and upgrade, check application readiness after Portainer
@@ -740,6 +995,30 @@ print-file evidence still need correction before this item can close.
### Reporting ### Reporting
- `[x]` Operator guide (`docs/guia-operador-kanban.pdf`) and Site guide
(`docs/guia-site-dtf.pdf`), 2026-09-24, with screenshots of a throwaway stack
and fictional orders. The payment step has no screenshot until Mercado Pago
is configured, and the guides name the provisional Kanban domain; regenerate
them when either changes. Early work toward Week 3's "orientação à operação".
- `[x]` Operator guide rebuilt from a committed source (2026-09-25):
`docs/guias/operador/` (HTML and the original screenshots, cropped as
before), printed by `docs/guias/imprimir.sh`. The source of the 09-24 PDF
was never committed. Corrected: stage moves do not update Tiny (only
"Aprovada" and, for pickup, "Pronto para envio", once enabled); WhatsApp
notices go through the client's Tiny -> n8n flow; the correction notice is
not automatic, the reason is in Minha conta; the Tiny card's renewal,
"Verificar" and "Testar conexão" checks. Found while writing it: the
customer's order history showed operators' internal reasons for moving an
order back; it now omits back moves and shows reasons only for corrections.
The Site guide's source is also not in the repository.
- `[x]` Week-2 client report (`Relatorio-Semana-2-DTF.docx`), written 2026-09-25,
sent 2026-09-28. Before sending, the FlexiPRINT import was taken out of
"O que falta" (it is done this week) and the print-file paragraph no longer
names PSD, AI, CDR or multi-page PDFs as hand-preparation cases. **Reversed
2026-09-29:** those formats stay as built: no automatic check, the full rate
per metre, and the operator prepares them by hand. Automatic generation is
not planned; the sent report omits them from its list of exceptions. Freight is reported as Jadlog data received,
waiting on packaging weight and dimensions and the charging policy.
- `[x]` Week-1 client report (`Relatorio-Semana-1-DTF.docx`), corrected 2026-09-18 to - `[x]` Week-1 client report (`Relatorio-Semana-1-DTF.docx`), corrected 2026-09-18 to
remove the inaccurate "Arquivo por metro permanece separado, com seleção explícita" remove the inaccurate "Arquivo por metro permanece separado, com seleção explícita"
claim and the internal commit reference. claim and the internal commit reference.

Binary file not shown.

BIN
docs/guia-site-dtf.pdf Normal file

Binary file not shown.

8
docs/guias/imprimir.sh Executable file
View File

@@ -0,0 +1,8 @@
#!/bin/sh
# Print the guide sources in docs/guias/ to the PDFs in docs/ with headless
# Chrome (A4, no browser header or footer). Needs Chrome and the DejaVu fonts.
set -eu
cd "$(dirname "$0")"
CHROME=${CHROME:-$(command -v google-chrome-stable || command -v google-chrome || command -v chromium)}
"$CHROME" --headless --disable-gpu --no-pdf-header-footer --run-all-compositor-stages-before-draw \
--print-to-pdf="$PWD/../guia-operador-kanban.pdf" "file://$PWD/operador/guia-operador-kanban.html"

View File

@@ -0,0 +1,320 @@
<!DOCTYPE html>
<!-- Source of docs/guia-operador-kanban.pdf. Print with docs/guias/imprimir.sh.
Screenshots in img/ come from a throwaway stack with fictional orders. -->
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<title>Kanban DTF · Guia do operador</title>
<style>
@page { size: A4; margin: 0; }
:root {
--navy: #0B1320; --orange: #F08A24; --ink: #1B2331; --muted: #6B7280;
--line: #D6DCE4; --head: #EAF0F8; --note: #FFF5E8; --note-line: #F4C58E;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body { font-family: 'DejaVu Sans', sans-serif; color: var(--ink); font-size: 9.7pt; line-height: 1.55;
-webkit-print-color-adjust: exact; print-color-adjust: exact; }
.page { width: 210mm; height: 297mm; position: relative; padding: 26mm 18mm 20mm; overflow: hidden;
page-break-after: always; break-after: page; }
.page:last-child { page-break-after: auto; break-after: auto; }
.running { position: absolute; top: 9mm; left: 18mm; right: 18mm; font-size: 7.4pt; color: var(--muted);
border-bottom: 0.6pt solid #E3E7ED; padding-bottom: 2.4mm; }
.folio { position: absolute; bottom: 9mm; right: 18mm; font-size: 7.4pt; color: var(--muted); }
.kicker { color: var(--orange); font-weight: bold; font-size: 7.6pt; letter-spacing: 0.04em; margin: 0 0 1mm; }
h1 { font-size: 20.5pt; line-height: 1.2; margin: 0 0 2mm; color: #101826; }
h1::after { content: ""; display: block; width: 18mm; height: 1.4mm; background: var(--orange); margin-top: 3mm; }
h2 { font-size: 11.5pt; margin: 7mm 0 2.5mm; color: #101826; }
p { margin: 0 0 2.6mm; }
b { font-weight: bold; }
table { width: 100%; border-collapse: collapse; margin: 2mm 0 3mm; font-size: 8.6pt; line-height: 1.45; }
th, td { border: 0.6pt solid var(--line); padding: 2mm 2.4mm; vertical-align: top; text-align: left; }
th { background: var(--head); font-weight: bold; }
td.n { color: var(--orange); font-weight: bold; font-size: 11pt; width: 7mm; text-align: center; }
td.k { font-weight: bold; }
.note { background: var(--note); border: 0.6pt solid var(--note-line); border-radius: 2mm; padding: 3mm 3.6mm;
font-size: 8.6pt; margin: 3mm 0; }
ul { margin: 0 0 3mm; padding-left: 4.5mm; }
ul li { margin-bottom: 1.1mm; }
ul li::marker { color: var(--orange); }
ol { margin: 0 0 3mm; padding-left: 5mm; }
ol li { margin-bottom: 1.3mm; }
ol li::marker { color: var(--orange); font-weight: bold; }
.chip { display: inline-block; border: 0.6pt solid #C9D0DA; background: #F4F6F9; border-radius: 1.2mm;
padding: 0 1.6mm; font-size: 7.6pt; line-height: 1.6; white-space: nowrap; }
.shot { display: block; width: 100%; border-radius: 1.6mm; margin: 2mm 0 1mm; }
.caption { font-size: 7.2pt; color: var(--muted); margin: 0 0 3mm; }
.split { display: flex; gap: 6mm; align-items: flex-start; }
.split > div { flex: 1; }
.split > .side { flex: 0 0 79.5mm; }
.quote { font-style: normal; }
/* Cover */
.cover { background: var(--navy); color: #F3F5F8; padding: 0 18mm; }
.cover .block { position: absolute; left: 18mm; right: 18mm; top: 158mm; }
.cover .kicker { margin-bottom: 4mm; }
.cover .title { font-size: 27pt; font-weight: bold; line-height: 1.25; margin: 0 0 4mm; color: #fff; }
.cover .lead { font-size: 9.6pt; color: #C8CFDA; margin: 0 0 8mm; }
.cover table { font-size: 8pt; margin: 0 0 8mm; }
.cover th, .cover td { border-color: #2A3547; background: transparent; color: #E6EAF0; padding: 3mm 3.6mm; }
.cover th { color: var(--orange); }
.cover .version { font-size: 8pt; color: #A9B2C0; }
</style>
</head>
<body>
<!-- 1 · Capa -->
<section class="page cover">
<div class="block">
<p class="kicker">GUIA DE OPERAÇÃO</p>
<p class="title">Kanban DTF<br>Guia do operador</p>
<p class="lead">Como conferir, produzir e entregar os pedidos que chegam pelo Site DTF.</p>
<table>
<tr><th style="width:50%">Acesso</th><th>Quem usa</th></tr>
<tr><td>dtf.kanban.agenciacompor.com.br<br>e-mail e senha do operador</td>
<td>Equipe da sala de DTF e atendimento</td></tr>
</table>
<p class="version">Versão de 25 de setembro de 2026</p>
</div>
</section>
<!-- 2 · Visão geral -->
<section class="page">
<div class="running">DTF Dropstar - guia do operador</div>
<p class="kicker">VISÃO GERAL</p>
<h1>O caminho de um pedido</h1>
<p>O cliente monta a folha e envia as artes pelo Site DTF. Daí em diante, tudo acontece no Kanban: a equipe
confere a cotação, o cliente paga, e o pedido percorre as etapas de produção até ficar pronto para retirada.</p>
<table>
<tr><td class="n">1</td><td class="k" style="width:31mm">Cliente envia</td>
<td>Monta a folha no Site, vê a nota e o preço e envia o pedido para conferência.</td></tr>
<tr><td class="n">2</td><td class="k">Equipe confere a cotação</td>
<td>Na aba <b>Cotações</b>: confere arquivos, metragem e nota e aprova.</td></tr>
<tr><td class="n">3</td><td class="k">Cliente paga</td>
<td>O total aprovado aparece no Site. O pagamento é pelo Mercado Pago (PIX ou cartão).</td></tr>
<tr><td class="n">4</td><td class="k">Pedido entra na produção</td>
<td>Com o pagamento aprovado, o pedido aparece em <b>Arte recebida</b>, com o PDF de impressão já gerado.</td></tr>
<tr><td class="n">5</td><td class="k">Equipe produz</td>
<td>Aprova o arquivo final, baixa, importa no FlexiPRINT e move o card a cada etapa.</td></tr>
<tr><td class="n">6</td><td class="k">Pedido pronto</td>
<td>Em <b>Finalizado</b>, o pedido fica pronto para retirada e o cliente é avisado (página 7).</td></tr>
</table>
<h2>As quatro abas</h2>
<table>
<tr><th style="width:31mm">Aba</th><th>Para que serve</th></tr>
<tr><td class="k">Produção</td><td>O quadro com os pedidos pagos, uma coluna por etapa.</td></tr>
<tr><td class="k">Cotações</td><td>Pedidos enviados pelo Site esperando conferência. O número ao lado indica quantos faltam revisar.</td></tr>
<tr><td class="k">Pagamentos</td><td>Pagamentos que o sistema não conseguiu ligar a um pedido. Normalmente fica vazia.</td></tr>
<tr><td class="k">Integrações</td><td>Situação do Tiny, Mercado Pago, frete e WhatsApp, e o registro de tudo o que foi enviado a eles.</td></tr>
</table>
<div class="note">O quadro não se atualiza sozinho. Clique em <b>Atualizar</b>, no canto superior direito, para ver pedidos e cotações novos.</div>
<div class="folio">Página 2</div>
</section>
<!-- 3 · Entrar e ler o quadro -->
<section class="page">
<div class="running">DTF Dropstar - guia do operador</div>
<p class="kicker">PRODUÇÃO</p>
<h1>Entrar e ler o quadro</h1>
<div class="split">
<div>
<p>Acesse <b>dtf.kanban.agenciacompor.com.br</b> e entre com seu e-mail e senha. Cada operador tem o próprio
acesso: é o nome dele que fica registrado em cada movimento do pedido.</p>
<p>Depois de várias senhas erradas seguidas, o acesso fica bloqueado por 15 minutos.</p>
</div>
<div class="side"><img class="shot" src="img/login.png" alt="Tela de entrada do Kanban"></div>
</div>
<img class="shot" src="img/quadro.png" alt="Quadro de produção">
<p class="caption">Quadro de produção com três pedidos em etapas diferentes.</p>
<h2>O que cada card mostra</h2>
<ul>
<li><b>#número</b> do pedido e há quanto tempo ele está parado na etapa.</li>
<li>E-mail do cliente, produto e metragem cobrada (ex.: <span class="chip">Têxtil avulsa · 1,00 m</span>).</li>
<li>Situação do arquivo: <span class="chip">PDF pronto</span> (gerado, falta aprovar), <span class="chip">Final aprovado</span>
(pode ir para a fila) ou <span class="chip">Preparar à mão</span> (o sistema não conseguiu gerar; veja a página 6).</li>
<li><span class="chip">Retirada</span> ou <span class="chip">Entrega · UF</span>.</li>
<li>Um aviso <b>Parado há mais de 4 h</b> aparece quando o pedido não anda.</li>
</ul>
<p>Os filtros <b>Têxtil</b>, <b>UV</b> e <b>Preparar à mão</b> mostram só esses pedidos. A busca encontra pedido, cliente ou CNPJ.</p>
<div class="folio">Página 3</div>
</section>
<!-- 4 · Cotações -->
<section class="page">
<div class="running">DTF Dropstar - guia do operador</div>
<p class="kicker">COTAÇÕES</p>
<h1>Conferir e aprovar uma cotação</h1>
<img class="shot" src="img/cotacoes.png" alt="Aba Cotações">
<p class="caption">À esquerda, as cotações a revisar; à direita, a cotação selecionada.</p>
<ol>
<li>Abra a aba <b>Cotações</b> e clique na cotação em <b>A revisar</b>.</li>
<li>Confira a montagem na miniatura. Se precisar, abra os arquivos em <b>Original</b> ou a lista de peças em <b>Manifesto</b>.</li>
<li>Confira <b>Metros conferidos</b> e <b>Nota conferida</b>. Os valores do cliente já vêm preenchidos; mude só se
estiverem errados. A nota define o preço do metro.</li>
<li>Marque <b>Arquivos, metragem e nota conferidos</b> e clique em <b>Aprovar cotação</b>.</li>
</ol>
<p>O cliente vê o total aprovado no Site e tem <b>24 horas</b> para pagar. As aprovadas ficam em
<b>Aprovadas, aguardando pagamento</b> até o pagamento chegar.</p>
<div class="note"><b>Ressalva de resolução aceita pelo cliente</b>: alguma arte tem menos de 300 DPI e o cliente confirmou
que quer imprimir assim. <b>Montagem antiga</b>: a cotação foi feita numa versão anterior do Site; peça ao cliente
para enviar de novo.</div>
<div class="folio">Página 4</div>
</section>
<!-- 5 · Abrir um pedido -->
<section class="page">
<div class="running">DTF Dropstar - guia do operador</div>
<p class="kicker">PRODUÇÃO</p>
<h1>Abrir um pedido</h1>
<img class="shot" src="img/painel.png" alt="Painel do pedido">
<p class="caption">Clique em qualquer card para abrir o painel do pedido.</p>
<table>
<tr><th style="width:36mm">Parte do painel</th><th>O que tem</th></tr>
<tr><td class="k">Topo</td><td>Número, etapa atual, data do pagamento e os botões para mover o pedido.</td></tr>
<tr><td class="k">Cliente e Entrega</td><td>E-mail, CNPJ e WhatsApp do cliente; endereço ou <b>Retirada em Franca</b>.</td></tr>
<tr><td class="k">Itens e arquivos de impressão</td><td>Montagem de cada item, peças, tamanho da folha e nota. <b>Baixar PDF</b>
baixa o arquivo pronto para o FlexiPRINT; <b>Original</b> baixa o arquivo que o cliente enviou; <b>Manifesto</b>
lista as peças e suas posições.</td></tr>
<tr><td class="k">Arquivos finais</td><td>O arquivo que vai ser impresso, aprovado por um operador (próxima página).</td></tr>
<tr><td class="k">Histórico</td><td>Cada movimento do pedido, com hora, operador e motivo.</td></tr>
</table>
<div class="folio">Página 5</div>
</section>
<!-- 6 · Arquivo final -->
<section class="page">
<div class="running">DTF Dropstar - guia do operador</div>
<p class="kicker">PRODUÇÃO</p>
<h1>Aprovar o arquivo final</h1>
<div class="split">
<div>
<p>Antes de ir para a <b>Fila de impressão</b>, todo item precisa de um arquivo final aprovado. O sistema já gera
um PDF com a montagem que o cliente viu e pagou.</p>
<ol>
<li>Baixe o PDF em <b>Baixar PDF</b> e confira.</li>
<li>Deixe marcado <b>Usar o PDF gerado</b>. Se preferir usar outro arquivo, desmarque e envie o seu.</li>
<li>Escreva uma <b>Nota da revisão</b> (ex.: “Conferido, pronto para imprimir”).</li>
<li>Marque <b>Arquivos conferidos</b> e clique em <b>Aprovar arquivos finais</b>.</li>
</ol>
<p>O card passa a mostrar <span class="chip">Final aprovado</span>.</p>
</div>
<div class="side"><img class="shot" src="img/arquivo-final.png" alt="Aprovação do arquivo final"></div>
</div>
<h2>Quando o PDF não é gerado</h2>
<table>
<tr><th style="width:40mm">No card ou no item</th><th>O que fazer</th></tr>
<tr><td><span class="chip">Na fila para gerar</span></td><td>Aguarde alguns segundos e clique em <b>Atualizar</b>.</td></tr>
<tr><td><span class="chip">Preparar à mão</span></td><td>O cliente enviou um formato que o gerador não lê (CDR, AI, PSD, TIFF)
ou pediu correção. Baixe o <b>Original</b>, prepare o arquivo no seu programa e envie-o em <b>Arquivos finais</b>.</td></tr>
<tr><td><span class="chip">Falhou ao gerar</span></td><td>Clique em <b>Gerar novamente</b>. Se falhar de novo, prepare à mão.</td></tr>
</table>
<div class="folio">Página 6</div>
</section>
<!-- 7 · Mover pelas etapas -->
<section class="page">
<div class="running">DTF Dropstar - guia do operador</div>
<p class="kicker">PRODUÇÃO</p>
<h1>Mover o pedido pelas etapas</h1>
<p>Use o botão laranja <b>Mover para…</b> no topo do painel ou arraste o card para a coluna seguinte. Ao arrastar,
só as colunas permitidas ficam destacadas.</p>
<table>
<tr><th style="width:27mm">Etapa</th><th>Significa</th><th style="width:43mm">Aviso ao cliente</th></tr>
<tr><td class="k">Arte recebida</td><td>Pedido pago, arquivo gerado. Ainda não conferido.</td><td>Pedido aprovado (automático)</td></tr>
<tr><td class="k">Arte tratada</td><td>Arquivo conferido e ajustado, se preciso.</td><td>—</td></tr>
<tr><td class="k">Fila de impressão</td><td>Pronto para imprimir. Exige o arquivo final aprovado.</td><td>—</td></tr>
<tr><td class="k">Imprimindo</td><td>Na máquina.</td><td>—</td></tr>
<tr><td class="k">Finalizado</td><td>Impresso e pronto para retirada ou envio.</td><td>Pedido pronto para retirada</td></tr>
<tr><td class="k">Correção</td><td>Algo no arquivo precisa ser resolvido pelo cliente.</td><td>O motivo aparece em Minha conta, no Site (página 8)</td></tr>
</table>
<div class="note">Os avisos por WhatsApp saem pelo número e pelas mensagens que a Dropstar já usa, a partir da situação
do pedido no Tiny: o pedido pago fica <b>Aprovado</b> e, em <b>Finalizado</b>, o pedido de retirada fica
<b>Pronto para envio</b>. Cada aviso sai <b>uma vez só</b>, mesmo que o pedido volte uma etapa e avance de novo.
As outras etapas não mudam o pedido no Tiny. Enquanto essa integração não estiver ativa, avise o cliente como hoje.</div>
<h2>Moveu errado? Volte uma etapa</h2>
<img class="shot" src="img/voltar-etapa.png" alt="Botão Voltar para">
<p class="caption">Botão <b>Voltar para…</b>: pede um motivo interno e não avisa o cliente.</p>
<p>Clique em <b>Voltar para [etapa anterior]</b>, escreva o motivo (ex.: “movi por engano”) e confirme. O retorno fica
no histórico como <b>Voltou para…</b>. O cliente não vê esse retorno nem o motivo.</p>
<div class="folio">Página 7</div>
</section>
<!-- 8 · Correção -->
<section class="page">
<div class="running">DTF Dropstar - guia do operador</div>
<p class="kicker">PRODUÇÃO</p>
<h1>Pedir correção ao cliente</h1>
<img class="shot" src="img/pedir-correcao.png" alt="Botão Pedir correção" style="width:78%">
<p class="caption">Botão <b>Pedir correção</b>: o motivo vai para o cliente.</p>
<ol>
<li>No painel do pedido, clique em <b>Pedir correção</b>.</li>
<li>Escreva o motivo de forma clara para o cliente (ex.: “A arte escudo.png está com fundo branco. Envie com fundo transparente.”).</li>
<li>Clique em <b>Enviar para correção</b>. O pedido vai para a coluna <b>Correção</b>.</li>
</ol>
<p>O cliente vê o motivo em <b>Minha conta</b>, no Site, e envia por lá o arquivo corrigido. Esse aviso ainda não sai
pelo WhatsApp: avise o cliente de que há uma correção pendente. O arquivo corrigido aparece em <b>Arquivos finais</b>
como <b>Correção do cliente</b>. Confira, mova o pedido para <b>Arte recebida</b> ou <b>Arte tratada</b> e siga o fluxo normal.</p>
<div class="note">Correção é para problemas que só o cliente resolve (resolução, fundo, arte errada). Ajustes que a equipe
faz sozinha não precisam de correção: trate o arquivo em <b>Arte tratada</b>.</div>
<h2>Histórico</h2>
<img class="shot" src="img/historico.png" alt="Histórico do pedido" style="width:78%">
<p class="caption">Cada movimento fica registrado com data, hora e operador.</p>
<div class="folio">Página 8</div>
</section>
<!-- 9 · Pagamentos e integrações -->
<section class="page">
<div class="running">DTF Dropstar - guia do operador</div>
<p class="kicker">PAGAMENTOS E INTEGRAÇÕES</p>
<h1>Pagamentos com problema e integrações</h1>
<p>A aba <b>Pagamentos</b> lista os pagamentos que chegaram mas não puderam virar pedido sozinhos. Exemplos:</p>
<ul>
<li>valor pago diferente do total da cotação;</li>
<li>cotação vencida antes do pagamento (passou das 24 h);</li>
<li>pagamento estornado ou cancelado depois que o pedido já existia.</li>
</ul>
<p>Resolva com o cliente ou no Mercado Pago e clique em <b>Registrar resolução</b>, descrevendo o que foi feito.
O item passa para <b>Resolvidos</b>.</p>
<img class="shot" src="img/pagamentos.png" alt="Aba Pagamentos">
<h2>Integrações</h2>
<img class="shot" src="img/integracoes.png" alt="Aba Integrações">
<p class="caption">Imagem de um ambiente sem integrações ativas. Em produção, cada cartão mostra se a integração está conectada.</p>
<ul>
<li><b>Tiny</b>: o cartão mostra quem conectou, quando a conexão foi renovada e até quando vale; o sistema renova
sozinho. Se aparecer <b>Não conectado</b> ou <b>Verificar</b>, clique em <b>Conectar Tiny</b> ou <b>Reconectar</b> e
entre com o usuário do Tiny indicado pela Dropstar. <b>Testar conexão</b> confere pedidos, contatos, os produtos
e a forma de envio de retirada, sem criar nada.</li>
<li><b>Registro de envios</b>: tudo o que o sistema mandou ao Tiny e ao WhatsApp, com filtros por destino, situação,
evento e pedido. <b>Com erro</b> indica um envio que falhou; o sistema tenta de novo sozinho, mas o erro precisa de atenção.</li>
</ul>
<div class="folio">Página 9</div>
</section>
<!-- 10 · Dúvidas -->
<section class="page">
<div class="running">DTF Dropstar - guia do operador</div>
<p class="kicker">DÚVIDAS FREQUENTES</p>
<h1>Mensagens e o que fazer</h1>
<table>
<tr><th style="width:62mm">Mensagem ou situação</th><th>O que fazer</th></tr>
<tr><td>“O pedido mudou. Clique em Atualizar.”</td><td>Outra pessoa mexeu no pedido ao mesmo tempo. Clique em <b>Atualizar</b> e repita a ação.</td></tr>
<tr><td>“Aprove os arquivos finais de todos os itens antes de colocar na fila.”</td><td>Aprove o arquivo final de cada item (página 6) e tente de novo.</td></tr>
<tr><td>“Informe o motivo da correção.” / “Informe por que o pedido está voltando de etapa.”</td><td>Preencha o motivo antes de confirmar.</td></tr>
<tr><td>Cotação com <b>Montagem antiga</b></td><td>Peça ao cliente para enviar o pedido de novo pelo Site.</td></tr>
<tr><td>Card com <b>Parado há mais de 4 h</b></td><td>Veja se o pedido está esperando alguém ou se esqueceram de movê-lo.</td></tr>
<tr><td>Arquivo aparece como <b>expirado</b></td><td>Os arquivos ficam guardados por 30 dias. Depois disso, o cliente precisa enviar de novo.</td></tr>
<tr><td>No topo, <b>Tiny: verificar conexão</b> ou <b>Tiny não conectado</b></td><td>Abra <b>Integrações</b> e siga a mensagem do cartão do Tiny. Se pedir, clique em <b>Reconectar</b>.</td></tr>
<tr><td>A internet da fábrica caiu</td><td>O Site continua recebendo pedidos e pagamentos. Quando a conexão voltar, clique em <b>Atualizar</b>.</td></tr>
</table>
<h2>Boas práticas</h2>
<ul>
<li>Mova o card assim que a etapa mudar: é o que avisa o cliente e mede o tempo de produção.</li>
<li>Escreva motivos que outra pessoa entenda sem perguntar.</li>
<li>Use sempre o seu acesso: o histórico mostra quem fez cada movimento.</li>
<li>Clique em <b>Sair</b> ao deixar o computador.</li>
</ul>
<div class="folio">Página 10</div>
</section>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

View File

@@ -1,8 +1,11 @@
# Same pinned base as deploy/Dockerfile.api, so the integration suite exercises # Same pinned base as deploy/Dockerfile.api, so the integration suite exercises
# the image that ships rather than a different one. # the image that ships rather than a different one.
FROM python:3.12-slim@sha256:2f17fc044b579bab302c2e8054d3a686e2cb9a83de48e70534b94cd8ebbe06a9 FROM python:3.12-slim@sha256:2f17fc044b579bab302c2e8054d3a686e2cb9a83de48e70534b94cd8ebbe06a9
# pg_dump and age are for the database backup (ops/db_backup.py). Debian 13
# ships PostgreSQL 17, the server's major version, which pg_dump must match.
RUN apt-get update \ RUN apt-get update \
&& apt-get upgrade -y \ && apt-get upgrade -y \
&& apt-get install -y --no-install-recommends postgresql-client-17 age \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
COPY infra/requirements.txt infra/requirements.lock /app/infra/ COPY infra/requirements.txt infra/requirements.lock /app/infra/

View File

@@ -1,5 +1,5 @@
# Provisioning script and policies baked in, for the same reason as the scanner. # Provisioning script and policies baked in, for the same reason as the scanner.
ARG MINIO_IMAGE=quay.io/minio/minio:RELEASE.2025-04-22T22-12-26Z ARG MINIO_IMAGE=cgr.dev/chainguard/minio@sha256:bd014394a80898e68c149f2311fdf8d5a2c2f3bb2c33b9327ae6d02b4b065ae1
FROM ${MINIO_IMAGE} FROM ${MINIO_IMAGE}
COPY infra/storage-init.sh /init.sh COPY infra/storage-init.sh /init.sh
COPY infra/storage-policy.json /policy.json COPY infra/storage-policy.json /policy.json

View File

@@ -10,5 +10,6 @@ RUN apk upgrade --no-cache
ENV WEB_INDEX=index.html ENV WEB_INDEX=index.html
COPY --from=policy /build/default.conf.template /etc/nginx/templates/default.conf.template COPY --from=policy /build/default.conf.template /etc/nginx/templates/default.conf.template
ENV S3_PUBLIC_ENDPOINT=http://localhost:9000 ENV S3_PUBLIC_ENDPOINT=http://localhost:9000
COPY web/ /usr/share/nginx/html/ # The HTML from the policy stage, with every asset address versioned.
COPY --from=policy /build/web/ /usr/share/nginx/html/

View File

@@ -5,10 +5,12 @@ TCPSocket 3310
TCPAddr 0.0.0.0 TCPAddr 0.0.0.0
MaxThreads 2 MaxThreads 2
MaxQueue 8 MaxQueue 8
StreamMaxLength 128M StreamMaxLength 2000M
MaxFileSize 128M MaxFileSize 2000M
MaxScanSize 256M MaxScanSize 4000M
MaxScanTime 120000 MaxScanTime 900000
ReadTimeout 900
CommandReadTimeout 900
AlertExceedsMax yes AlertExceedsMax yes
AlertEncrypted yes AlertEncrypted yes
ScanPDF yes ScanPDF yes

View File

@@ -1,4 +1,7 @@
"""Compile the gateway configuration: hash any trusted inline script for the CSP. """Compile the gateway configuration and version the Site's assets.
Inline scripts are hashed for the CSP; local scripts and stylesheets get a
content hash in their address.
The Site's behaviour now lives in separate files, so normally there is nothing to The Site's behaviour now lives in separate files, so normally there is nothing to
hash and the policy is simply script-src 'self' — no allowlist to get wrong. The hash and the policy is simply script-src 'self' — no allowlist to get wrong. The
@@ -12,6 +15,20 @@ from pathlib import Path
import re import re
root = Path('/build') root = Path('/build')
# Every local script and stylesheet is addressed by its content, so a release
# can never pair new HTML with an old cached file: the proxy in front of the
# stack caches assets for hours, and a new index.html calling an old script
# broke the Site (2026-09-28). The HTML itself is served no-cache.
def versioned(match):
attribute, path = match.group(1), match.group(2)
digest = hashlib.sha256((root / 'web' / path.lstrip('/')).read_bytes()).hexdigest()[:12]
return f'{attribute}="{path}?v={digest}"'
for html in (root / 'web').glob('*.html'):
text = re.sub(r'\b(src|href)="(/[\w./-]+\.(?:js|css))(?:\?[^"]*)?"', versioned, html.read_text())
html.write_text(text)
hashes = [] hashes = []
for html in (root / 'web').glob('*.html'): for html in (root / 'web').glob('*.html'):
for attributes, script in re.findall(r'<script\b([^>]*)>(.*?)</script>', html.read_text(), re.S | re.I): for attributes, script in re.findall(r'<script\b([^>]*)>(.*?)</script>', html.read_text(), re.S | re.I):

View File

@@ -9,7 +9,7 @@ server {
add_header Referrer-Policy no-referrer always; add_header Referrer-Policy no-referrer always;
add_header X-Frame-Options DENY always; add_header X-Frame-Options DENY always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' @SCRIPT_HASHES@; 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; worker-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self' @SCRIPT_HASHES@ ${PAYMENT_CSP_SOURCES}; 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 ${PAYMENT_CSP_SOURCES}; connect-src 'self' ${S3_PUBLIC_ENDPOINT} https://cdn.vnda.com.br ${PAYMENT_CSP_SOURCES}; frame-src 'self' ${PAYMENT_CSP_SOURCES} ${PAYMENT_CHALLENGE_SOURCES}; worker-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self' ${PAYMENT_CHALLENGE_SOURCES}" always;
location = /health { access_log off; return 200 'ok'; } location = /health { access_log off; return 200 'ok'; }
location /api/ { location /api/ {
limit_req zone=api_limit burst=100 nodelay; limit_req zone=api_limit burst=100 nodelay;
@@ -19,6 +19,14 @@ server {
proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-For $remote_addr;
client_max_body_size 2m; client_max_body_size 2m;
} }
# The Site's product pages and cart are addresses of the same page (web/site-pages.js).
location ~ ^/(arquivo-por-metro|artes-avulsas|uv-arquivo-por-metro|uv-artes-avulsas|carrinho|pagamento|pagamento/pix)/?$ {
try_files /index.html =404;
}
# The customer's area: one page, which shows the right part for each address.
location ~ ^/conta(/(entrar|pedidos|dados))?/?$ {
try_files /portal.html =404;
}
location / { try_files $uri $uri/ =404; } location / { try_files $uri $uri/ =404; }
} }
server { server {

View File

@@ -21,7 +21,7 @@ anyio==4.15.1 \
boto3==1.38.23 \ boto3==1.38.23 \
--hash=sha256:70ab8364f1f6f0a7e0eaf97f62fbdacf9c1e4cc1de330faf1c146ef9ab01e7d0 \ --hash=sha256:70ab8364f1f6f0a7e0eaf97f62fbdacf9c1e4cc1de330faf1c146ef9ab01e7d0 \
--hash=sha256:bcf73aca469add09e165b8793be18e7578db8d2604d82505ab13dc2495bad982 --hash=sha256:bcf73aca469add09e165b8793be18e7578db8d2604d82505ab13dc2495bad982
# via -r local/requirements.txt # via -r infra/requirements.txt
botocore==1.38.46 \ botocore==1.38.46 \
--hash=sha256:8798e5a418c27cf93195b077153644aea44cb171fcd56edc1ecebaa1e49e226e \ --hash=sha256:8798e5a418c27cf93195b077153644aea44cb171fcd56edc1ecebaa1e49e226e \
--hash=sha256:89ca782ffbf2e8769ca9c89234cfa5ca577f1987d07d913ee3c68c4776b1eb5b --hash=sha256:89ca782ffbf2e8769ca9c89234cfa5ca577f1987d07d913ee3c68c4776b1eb5b
@@ -41,7 +41,7 @@ click==8.5.0 \
fastapi==0.141.1 \ fastapi==0.141.1 \
--hash=sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3 \ --hash=sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3 \
--hash=sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1 --hash=sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1
# via -r local/requirements.txt # via -r infra/requirements.txt
h11==0.16.0 \ h11==0.16.0 \
--hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \
--hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86
@@ -55,7 +55,7 @@ httpcore==1.0.9 \
httpx==0.28.1 \ httpx==0.28.1 \
--hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \ --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \
--hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad
# via -r local/requirements.txt # via -r infra/requirements.txt
idna==3.19 \ idna==3.19 \
--hash=sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15 \ --hash=sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15 \
--hash=sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4 --hash=sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4
@@ -68,10 +68,328 @@ jmespath==1.1.0 \
# via # via
# boto3 # boto3
# botocore # botocore
lxml==6.1.3 \
--hash=sha256:032a0a97eed428bd143c75a11118238546424ceb2fa311cca5f073aa44658dc4 \
--hash=sha256:05f5bce9af14fd1506997594bd81cee6d9c6b58ea80a39c058327aa6371ed9e9 \
--hash=sha256:0794e04ba343852c6d78e996c58ef4b8e579b4ecc72f8df0d4058bf843b4c96e \
--hash=sha256:0ab2467e405e748d93495fb5568e74044802b8d3ff2b2a1607c3f78c6e982de5 \
--hash=sha256:0bf5a3e397df2ec4258eb5eea4c1ac6cf013ca1abd04a176903bff20a70021fe \
--hash=sha256:0c0710ac085a157b593c38fbcacd950f15c4afa8e2057527185875ab302752bc \
--hash=sha256:0dee106e9aa97fb00541b1ed7827070564d0549c3d3fba8920e6b20fd980f748 \
--hash=sha256:0f17d83c48ee9dfd96abae3ac3e2108c76d2fc86ce96355e37b8da9f7f4ecc08 \
--hash=sha256:0feebef8d0521188d0157f758356072e840173aa61ca45b8b3f87959ac283dd5 \
--hash=sha256:13a620a3fcc20023f9e6ed5c383e00e826f1c2d5db554df2f67240760f9118e8 \
--hash=sha256:13d22c0d57355366b393936acf6b98a5e0edeadddd3fccbc6a846c50a76b8741 \
--hash=sha256:160fcf381f76c3aeac28a756bec44f48942a8f7245a87aa28e3a523b4d90cd87 \
--hash=sha256:16148acd77ed1d8836a56db883af2f5eed720f9723088110b16a0d08582130a6 \
--hash=sha256:170773d8a3cdc76259065523ddd978c44f9806e28605f08812e8f86783e44ac6 \
--hash=sha256:18293f8a8d8b6a8e71ef37706b659e3846a4261232158167b1ddf35f6994f633 \
--hash=sha256:18a4db52b5a7b53a3540b0b0f4123319334621ee8083d496de314d0bf06ff59a \
--hash=sha256:1a635e837b50a1819bebfedaac5916498ea024120969da8790500148fb0a894d \
--hash=sha256:1aeca87830c4fe649dcf93fe2b059525b71c72587f21be4ae4af7103082a79fa \
--hash=sha256:1b7c37339d7e75cab9a123a04248e243cefefb302ad6db566ea0c77cbcde421e \
--hash=sha256:1beb0f9909b26cee938df9ba56b15252a84429b1fc30ce6fca161390b9789a70 \
--hash=sha256:20384c2bbcbf87180c8c61eb60869699c1ec0cd09b62cfd13804022d860b0867 \
--hash=sha256:20428910dae17a1a93152a3ff2c0441d2f4932992c0797d65651dd0561f1792f \
--hash=sha256:207dfc3d47cf0e575e643bbc140dacc8863b39abaa1e5307cd64c7f2365b8a12 \
--hash=sha256:209c3ccbfe35a04ac6d24f0611f9d1cbf8025d49991b14acd935236234d6c156 \
--hash=sha256:2123e5aa075ac20d23c7af489255efd129cbfe190dbe88fd42598cc9df3199b6 \
--hash=sha256:21402998e4b78e7cce237d2788841aaa21ac9a4d1574d04dc2d12ee41ae807b5 \
--hash=sha256:2221e88679d1351e9a40aaee54bc65679b9795bbd0160bc3d5e36b163344eb75 \
--hash=sha256:22eec57e26c418cde02c051ce9914a365e52a7f135a565c6f0480242aeebab48 \
--hash=sha256:23c366231259cd75ad06495174701afb3fcb36a92917fa47de2d1f1bd9d95739 \
--hash=sha256:25f4118c438f96bb466e83108506d03d5c31b1bd2387e83e5b070bda6ded9c37 \
--hash=sha256:28a23fefdb345b2d4d0ff2860571b5ff9a89a28b6a120f720e8fb0324d346626 \
--hash=sha256:290f66b97ede0e552e1cb44a0fd8a74f9753ee635b50830a0b122fb72788d015 \
--hash=sha256:2b9b1325ca1c2a9a2dbb6eb913ae563313f2082ae60b03210f7e83ee80712274 \
--hash=sha256:2bec13085dc8ef48a3fe62f7dfcacfeda2c785cdf19cc8eeda2bb9ed081da165 \
--hash=sha256:2cae5d5c90a62d9139c512a0cb1aad1d182b022b5740daea2617eb5bf7fc658e \
--hash=sha256:2e01125896585139453cab8cb235893644d8815d7509520da95ae3ee8d1c1f79 \
--hash=sha256:2e62c569ec7531b679b184cbfe335c501c1d13c4b363560013019962eb630e6d \
--hash=sha256:2f5b2a2b9811b853b39bfa41367c6d78747b8e3e80e07fc5a24aae295c1a4d7d \
--hash=sha256:302f72413251c03f671e063c9414bed5dc8c927069e5abb69245521e51a4e81b \
--hash=sha256:32a409be3190b088f960ac92bfedfbef2f86c49ff940765e1548177592d20026 \
--hash=sha256:33cadd956b667997e4de1635fce9541f2e8ede2038fcde8cf55aa14d571d1bad \
--hash=sha256:379f8a75cf6eb7eef0af074b55f49ab73b868388a98de14646abcdfa4564bb11 \
--hash=sha256:3847e71a78cbbc1aff955dbbbaf2fff12153f611d3162c5beaa3395636cbc2f9 \
--hash=sha256:38fc4e4e4e084e0bd491949482527d406788045c546d4f8789e93fc527b91385 \
--hash=sha256:3a27ac6c780c8b8a1cd231b58407634cafc1c4cc28cd6c7141362df0f36351e7 \
--hash=sha256:3a48093cdb058a93af842ede9703520e810b05dcd0fc6d7190a06376c3bfb6bd \
--hash=sha256:3e42265103fb385d8642a78672edf376c6f7e1d3598a7a4f9cb1278f2f6b5f6f \
--hash=sha256:3e9a00d1c2c30936f7add097c41afc5da6556c580909104aafd382cac92a855c \
--hash=sha256:40983eabefd13da003e68170928c7acc011f0d095eefce5871a3c71c9385fb9a \
--hash=sha256:40bcbd9f94166ffe925811e730607385cec959f42fb1bb7dad83748680465221 \
--hash=sha256:41096ec0740a58dad03d3ae0c7486d306d20becefb13ceb1649835ab3eb64167 \
--hash=sha256:415e3a115c0d510e329020012834d1c0aa1c581ee53a218603e38abbc1dea70a \
--hash=sha256:41e2d428110b408e963b6fb18f9bbf1f5c027b56bd4b498d54556476c0aeb1c3 \
--hash=sha256:424aa5657141d306ba9ad1baab4b2c0a0719040075ee6c66aee9bb2dea2b5054 \
--hash=sha256:42632b4024ab24a6b488f559ac851312509888b6b80ae2aa11cf29a646a0d245 \
--hash=sha256:45222d94ddd511536f3b2f7d9deae3b2339b4ce0f075f1ca25703b07cad9dd21 \
--hash=sha256:4736e6c87e603146d8949d8501da621ad20c31015060d3fcf95ace2859f3e3e6 \
--hash=sha256:48542c9acba9ff9450bd18d871d2c2c8787fdb283572b623d206f1b927cd7d9e \
--hash=sha256:49fbc2682a9306135b7ec49e93f97f9c26689b9b7f96ed2742d8d6497e994d13 \
--hash=sha256:4a579dfb9c835f8ab47f4b8ed33440cbc75b806b73297208e6ec2a33e903740b \
--hash=sha256:4b061064b4a2fe8598a466d723d43dbcd5a610a5d5cfe02fb6226f5c17349f75 \
--hash=sha256:4e11e885e0704be185867fcf71b904d8f65d7d6877bc121f69870b0d0479ba7b \
--hash=sha256:4f4db7c7e954d289d71878938348b3d91b904a3e8210a11939359fb758a58e7d \
--hash=sha256:527195c188d7d0af748cd48d220ab8cdc5cb99be3d49ac4d9be7324d8abf9bc0 \
--hash=sha256:53258656846f5c48996b882fb4b135885e088a3ad3d96b4bc0530f95124d1f69 \
--hash=sha256:545ccc14fb05485f48b4439ec35beb16d5b5280eb6c81c658bd4707a2a119414 \
--hash=sha256:5609efdb0d3c95499c00046bc53648b3482ec2175b5503d6e611b3f0555dc71d \
--hash=sha256:5929d9df5e7e3379183be0e21f7d559618a5b61cb63280df6164019242e337ed \
--hash=sha256:5a143e6207579de8baeded4eaac9134413200359f1969d636f0bfb98ee8c3c8f \
--hash=sha256:5a721a98c649855963811b59b55755b30566e7f7fc40bdc9803d66dee9f811cf \
--hash=sha256:5cffe18571ccc51d742cd08cbb3f8b756de9311d18c7ea98f5d92f37b8fb60c2 \
--hash=sha256:5d12669a2c419b0e8dc423d23dea24bb82f6f9cb829f32e04674b0ba40322a7c \
--hash=sha256:5d582042c69857c364e8153de6e18e0da9b7b515a6a8113caf69a6ec8e0520f2 \
--hash=sha256:61116cec57ed69aebc70f37a545eec095339bb829efbdabcfb97c51e9536e158 \
--hash=sha256:611a51e61c92f62345a50b0035df6fc0d678f9299f33728826d831598862f59d \
--hash=sha256:623c8799c17128753c65699f1c3aa32402657393a9ad6db09ed8b98ddf76611d \
--hash=sha256:6374e9e382e5a98c9c5e66d41b357b470da1c54bce30f17f9dc4bcc58436cc1c \
--hash=sha256:66299564c046bc7e0cc5de5106601eae907e9fa5904cd68a323380a8502f7861 \
--hash=sha256:69cafd61aea04ebb3502c93c2aaa568b12931ca0802231e0b5de76bf8b6e74bd \
--hash=sha256:6a406d0b3cb207b0fa460ed4dc93e866f44f105da0169361cb18ff998a44c7f0 \
--hash=sha256:6ba4fe5bfbef6811a8e49b3719cde373ad399006c0c1ac184b7297116ecbba5d \
--hash=sha256:6cd11e7550d89e551a87dcec30f04b1fca32e86b68708aa01a4daa455d8605e5 \
--hash=sha256:6e1eb8a4cbffd5553680ad96be6680e364710656eced73d1dc90ec489df599a3 \
--hash=sha256:6ea2f13dce778ca072ccee598bca46a092ce192e8fd907b6c1f0e52c800529a0 \
--hash=sha256:71532ebf30be0048a45559b4fab15333fbaaf9042f658e878d918ecd0cf09805 \
--hash=sha256:73fc05988ed20809450474ba760a87c8ad4e455fc09783c02195e56ec634b41a \
--hash=sha256:75cc6569e86be5785b6188ef1642670c6adbc984e81ec35e224842ecd9eefcc8 \
--hash=sha256:773062aec2f2e56b2b22d37054123f0de8a22a4688a0c3376c3fe42685f975cf \
--hash=sha256:7ae4949f212a53b007dbc355884fda122545c5764a54256c9217e419a62a6559 \
--hash=sha256:7b2bb7d703bed7ac893bf7f40d97b5d9279d35d2ce460624ca28929eab0d5a3d \
--hash=sha256:7d0f5976aa2701996f759b30172925829867547bb073af0ae67d1307a0f0262c \
--hash=sha256:7d5a748d12dd9b535e0a130f60dae9ddf0adafbabe61e7864f55c7436c84547a \
--hash=sha256:7dd624c1eaa629ad44b59a1a0145fdf2d67895592dce94c9358b938b3d075e65 \
--hash=sha256:7f75b9b9fec2a9c6b18095c81865580e795b1441c429e42d22fcc82a77f40039 \
--hash=sha256:83e3a51e7933db700a0da0db31849db3a24022d9970da9bb73001e1d0326fd92 \
--hash=sha256:8499d464de86fab0f102313cce32a9bed9ab1f06ec813cf025cb790964fbb765 \
--hash=sha256:869dfcd4d381cb0ea87085cc4f011b9171b494ef21e76ad8665f6d5e2d1dc8a1 \
--hash=sha256:8753b8d51dbc86fd335ee31fcf7f3658e9f5c016d4edfb23f76ad295f4b8c9d0 \
--hash=sha256:887c021d9a977cff89cb273047c1352997b772a8908a25c21836861f69b92be1 \
--hash=sha256:88e719b9437f148f7e1465df845c758dd1598618cbea3a2fd1e61a715542f2b2 \
--hash=sha256:8a330c0ee5fa318c7b5cbbaad882baeca3f570357e7eb25ab34bf31008150758 \
--hash=sha256:8db38ff3fb7aee7d6a82ae4da2eef1178656fe1216841fbd24870062a9d60473 \
--hash=sha256:8e49a646acfab83c68974f4aa1d0a2acca9e88d7d627ae0fc13201b14b76d310 \
--hash=sha256:909f4e927bb051f7740d6367285fc60cdcfdaf0258c2dba4ff5ba7eadadc250c \
--hash=sha256:90f709b9accab6b2e4d14f5c8718203877a0486bcb3afd74d8b539ecd1e961d4 \
--hash=sha256:92d96586376fb79a33474797186bf993250152ee5c32650b67db78d54b92e6f3 \
--hash=sha256:93476b6514b373fc6ca67d26c442784f7807c86f00635bfe79f935c3eab2af17 \
--hash=sha256:97acecb11cbc411473f15b8d780df06d7a9f3a2aad9aca78364f56640c8fb70e \
--hash=sha256:97ce49699d87ebf8aad631b55d65b33219a4f1bfefbbf5bff19dc9af160aeaf9 \
--hash=sha256:9bde9ae026a55b9a192078dfa6e27dd0ca4a050171ab6272e92f97b757dfdf48 \
--hash=sha256:9e67324961ac9bbe616cce5100514d2e34d88665aeb07071e8b16eac55d06d94 \
--hash=sha256:9efe56a68179f3adc4de41861c9358931db03837c48dd5e1c78077b84dd07f3a \
--hash=sha256:a1932d7ce78a561367512c594fe66eac2b2ec9b9264cfd9b5f950622f4a116e2 \
--hash=sha256:a1cec0f99b9b914d39176347a93b7610dc09324491aee1cbc57cd291a41a1d55 \
--hash=sha256:a2e3f70673a1d5b82f38255f777d26cd855bf2092b1436c4867464a7892f9238 \
--hash=sha256:a43b3bdf11e477dc7770609d3477316f974354dfc8425d596f64f471cc8daf6e \
--hash=sha256:a5c18810318303ce9afb3f95e2ddb54834f96fa699a8600433fd5a93dcf44c56 \
--hash=sha256:a7eb78ba28b187e1e9203a55c60fcf70df2d22cb205fe6d51b9383d6097419f0 \
--hash=sha256:aa633613ff907ea91b9b0489a1f0da1b8725d8c6ccec6b77e8a1c9c235044bb0 \
--hash=sha256:aa9fd1ee2a5dacfc41039ed49ffeeacfa75bafbd255b69f3b578e11897a0e623 \
--hash=sha256:ace1d2c83b2bd24db5940600541140e87a325e119cb32d5fa9ad720d7e76648e \
--hash=sha256:b1cc980905221a5d8b3c476330730b3adb40ff80add71ffbdb6215ba055656f1 \
--hash=sha256:b37772102d44bb6628186accca3a121b1fa3a6b3d97518a8c29a5229ca4c0d0a \
--hash=sha256:b3ff39654f0ce6ebd4db154211136dbe7e8157bcc3bed2344c87f32c7c6ecb6c \
--hash=sha256:b477912f42c5c33405a10c759d22f80cf5af043ae02d95b9d8e5e5bc555739ed \
--hash=sha256:b49638355ea3bebba70da783ccbc630fd72afa16bc46c54474bfa1f9a915bbc6 \
--hash=sha256:b4fc6b03b9d9d90557274f571ab30e7fbbfc527955536935d96f98b6817a86e4 \
--hash=sha256:b50343241eb69fd85f7791cf8bcc7b1c4729826b7d59ba2f6b27db29638fa745 \
--hash=sha256:bc8dd3d9c93e70c3df974a201ac2958b6d77b465d813c51d1f15fa8e645763ae \
--hash=sha256:be5346653c0b0e34be96869ff9dbeba23860156f89a2896a64c64fb419260cb6 \
--hash=sha256:c00e26288784460885fe76e4d4b293573e0f791f52e6d60e27b42edf005922eb \
--hash=sha256:c1b50797ac246bb2942a04b6c0f69af0667aba7cf7535f39bbb1b3208fd5d128 \
--hash=sha256:c34ca1dc41bd86d9ff830d5bdf4e4a752bba6c54f7d2707027ce0eabd36084c9 \
--hash=sha256:c55e71a9b1db1f107efb60da49c093689b74c5c31a708e5379e2fd9439d4fbb5 \
--hash=sha256:c581b1d68b3845fb86c6b2983e755b29bf001461c59fa411d2c26a911b6559a9 \
--hash=sha256:c59e4265608da6a041f54646ecc0c9ecdbb19aaf14c4c684bb6c2114998cc415 \
--hash=sha256:c5e7ce578aa8a80910a72a8ca0bbea3baae10100827249001999726a788456d8 \
--hash=sha256:c66f858b82497173f73366795fc6ee8171620e75a338506d6b2e7bc16f5fca11 \
--hash=sha256:c6c0c13128a32eb04a51357e56a094e13aa8e6d3d1884de2e9ae923f6915e1a8 \
--hash=sha256:c9389b3784b56c58d933b5e0aecdf28f901b073ff385358d8a7d40907f6e14b2 \
--hash=sha256:ca0ec532ad2f5ba1e5ec120ac157769c57f01855b3d8bf37213f5d88abd9ba0a \
--hash=sha256:cad7617727a96d189bd6f979d0fadf765198c7934e85f4edaba9bf3ad919a300 \
--hash=sha256:cae82b5ca24b0c2beedb269f6e2a96f466acd926879ab00ae19f1a65cbf9ffb0 \
--hash=sha256:cc669256d28736f7f3a149df5c380c50ace2692ba3e62203d10656fade4a2145 \
--hash=sha256:ce1f220114959941170e22b8ad44279f6dee2dcef7591814d01ae805dc058889 \
--hash=sha256:cfb398886a7eb4c719161c3efcff2a1248febc53a4d8e5072d2d8a87fed84ac9 \
--hash=sha256:d077f21f4b16f0471353883748f126f62038760397c107bb9fad2ca94dc0dfb7 \
--hash=sha256:d0c5c362bc94f1929dc7e96e715bbe7bd17037f802e6d8f0d1545df9133c0559 \
--hash=sha256:d2765c18ce303149ee804b1f3dad11232726dd0a702d73a15cf19179ac8cc962 \
--hash=sha256:d44442effeb8781f392340c5dc8c6716fba41dbeacb82fd4c0f09026fb5ff682 \
--hash=sha256:d85dfab42dd672f87a7f76e9de7172962aee69fa12044f0d6e1a23cbd53fb80e \
--hash=sha256:d97c5227621af74b111882a290b10f371780a38eef9d9e730408fba2259b52fb \
--hash=sha256:d9a0d12846d6ce434fb3857918eef4315ec9b4769deb020c75828798614bfcfd \
--hash=sha256:d9b3e7d71bf6acff341233417abbdface29c647e3113892d9aaedc02eb4aa2bc \
--hash=sha256:da707f14ea3c35ee463d50acd596d6488e4b2b4ae7cf77a5bf93f55c023d63e8 \
--hash=sha256:da85db328e507da922d586c3c7416ec360ec22e9cd9e0700691afacde0c81f53 \
--hash=sha256:dc205732d593118cf701d986f40e9de7801bb2e371cb189ddbda9b7348f4d97e \
--hash=sha256:dc3a44689eea43eab836e5c98a8ab015dc2419987d1ea6eafc7c590cdff86bed \
--hash=sha256:dd5e90f34cffcfed97f36cf066325773d2b6021c60c29942e53a18b028501b1d \
--hash=sha256:ddcf547bea2aee967d6a77779376a45e77e610e8465147a1f3d7e20d539d6e32 \
--hash=sha256:e477aca0bc0d19f3b4ae9e4f2a1cfd687c31bf772d78734910658186b40b2477 \
--hash=sha256:e8b17e23df3e827a69d25af70990ca2420e92668aaffaeeb3cd2351d7916a023 \
--hash=sha256:e99e09ab7741f1281e2677f4c0058c7f5267d182530b09c87e4f6aa26adf3887 \
--hash=sha256:ea2c01cdb16dc12156e455007c406dfaaece0c89aa4ba0e3b47586779f951d41 \
--hash=sha256:ea6b1e9105b4b24a34c722432d9fb578f9ed83af21fa1abda639011e0f22bbb6 \
--hash=sha256:ebd054ad1737a68fb7c5c073d405cef2b88bb824e294de3b4a4e995b47f0e376 \
--hash=sha256:ec295280f4b37769256da025acf5890370355ac589c27e89caae0b5e9eedc702 \
--hash=sha256:f6449672f9c93316deb5e2839e18931f468670e44d5bd9b1301a5a9655d45c07 \
--hash=sha256:f683dc6300317700025e41d89a43e0276692ded16113a3c43eab704d605c58e5 \
--hash=sha256:f6b9d2aad499c769ee8287609ab0e6de99d8bcea99c6e6c2e64945259fd52fb2 \
--hash=sha256:f8b9c8ceebae6387d0dc77f7f4dbbfbfc962dba2efbfe6877486075a480726b4 \
--hash=sha256:fad67b12ffe0f71e02b4932b04883cbc76a9072bbd30731409d3523cf058b011 \
--hash=sha256:fbfb70ba01355251faf6b293171df49f73a88a1b6494db109ffea85442574458 \
--hash=sha256:fe91993149523aa59941b9e3c90e2eb45f57ad014697aef6c8b13339a59c019e \
--hash=sha256:febd35ef45f603c2d74b74655efdbf45e14f55fc0aef4ac82b663ca829b283e0 \
--hash=sha256:ff88a92cafde90888511242d1c54afcc1a8adbb6dc0a88fa7f87e29e92400d4a
# via pikepdf
packaging==26.3 \
--hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \
--hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c
# via pikepdf
pikepdf==10.13.0.post1 \
--hash=sha256:01f80ca046d984752cf6f08093debc193884bee91c01c104aa0236767711a20f \
--hash=sha256:092a9bf15739e931ecab15ec3baee5d9629dad90ea4e42b779f6b439d2d1e462 \
--hash=sha256:0efb4faed9cbb59c1486f668af326096dac1ff0ca058eb48ec485742a9a655af \
--hash=sha256:1f76fcbe5d86f2ae6f231ba542cd04793d4c89e75bd5f62526b7412926ff2100 \
--hash=sha256:20c76343128ec41d5be58337b23c6f9f620fa7b8256a2d942460a48c07bbfe7b \
--hash=sha256:2c6e83f8a1828ec79cdec4df8cc07209eaf10ed7e4f5a90a7356b254bacc07d5 \
--hash=sha256:2db9a18074ba112e7c517e8c21dfd8894cc13b8a37ccf49a5841192170fb68eb \
--hash=sha256:365b94f2be7e2857c6cb5445b56dc52dc7417ba9f06c8a4282d9f521cb2d0fb8 \
--hash=sha256:3e18d5a009bbe5f3ab18f916fb9e28f0c5b0d920736e3a85cc10c627ec633596 \
--hash=sha256:414f42c83e5e6029870de1a988625dafc95781ebff10e15d82caeb5e69a83c9c \
--hash=sha256:43d70f244a4a1120a11cfe2227f8c03249fac6a7e3789a3116173102a3b9fe37 \
--hash=sha256:4b73f926ebae81f04bf14527af330bd00bb268be767e0f189f7c4c3e4ad7ae0a \
--hash=sha256:4bb5fe2090d246ad4b325d17f33a186f60f6763bba3d4ac3c4b863c8890e913a \
--hash=sha256:51fae4a4a3c6549aa4c405896ff7010f3e43e0c4f407c0bcee071ef13d271202 \
--hash=sha256:544f1be1b1e5630a79cd182a8663c439504099eb9eae0d342a50173d9825bdf3 \
--hash=sha256:55e53b4d8a4b1700f686f76e3a68411e421e962a4c8b1b90d00aab3f3e494a55 \
--hash=sha256:571efcd1d54e0dd817973c76c253feb6fb758c93bb0c16a893cc68f2a178d404 \
--hash=sha256:6b038cd5bcbb6c1952bcc271695eaf24c4199d72e45606ee5e467f837760820e \
--hash=sha256:7ba09ef5a5f26e38ee558d2a08223fee08a5ef1868962ae2d8590d4de3c8c92f \
--hash=sha256:87141ada970386ff6640db54f0bda734d3bde7960d3ba04a76768b48e25028ca \
--hash=sha256:8cb976331cb8b03ec3465e06d9e7a3eadbadb7e622be888e70f918ac732a105e \
--hash=sha256:8fb8f82dc43056a4b4f891e78ee1db4e3ced75ba3e87b836f8e28c8771228928 \
--hash=sha256:90f17cb174db88e08075c5bfa3c619df00dd84abbad34bfa1edf84863f0b01a7 \
--hash=sha256:94e04ed92fe42b8bcebf8c40462868dca4eb92581dcc2be1f0c4b8ee23347386 \
--hash=sha256:9613505f5b22203465d4224a3fd8cf69876ce8278442c6478ac6849f54724a30 \
--hash=sha256:98a7305e330f797da02b543d3ad57a134c4a14c6ec6f8d86d91aa9dd130c425b \
--hash=sha256:996a714a47cc725e3c48fe3901691d3353f3c2eeb9e0571aa2b16164abc40ff9 \
--hash=sha256:99f6afccd6119233e7133bd4c2ade48461de3ddd4269cc28a4f90cdf7c1372f5 \
--hash=sha256:a3e9104db5ea5b5a7c5fde417147900966a687de39ef91a5ea103fd4b773aee1 \
--hash=sha256:b63577c44fedf7ed6b971076f7c7ed0ff95a8bac627ac93b79e8b542214861a7 \
--hash=sha256:b69b89577b50617248185b6ad73cda0e4f15c57da11f7da8cc4032bf0d7d9ebe \
--hash=sha256:b7b0cbb135de32ec3f41651a08ab294e3c18ae9fec32516a48d27e64a53a47f0 \
--hash=sha256:b948e11f7dd3710f939194f00b4d75b0df87a30d53b31414128768ac25da77d8 \
--hash=sha256:c0b5127df90b0164dd846bddd0ed542326b2f69bd11f627afe48762cf16c2904 \
--hash=sha256:d3b58ccb30b93ba400e6a6a83315b4830eea49f6f19e18d78241fe6b1c49fec2 \
--hash=sha256:e19bab4320e4e8771b7816f7319ba37530fd28a40372840b75cab394ebf868e4 \
--hash=sha256:ef4ed47d40aa44deb063feb4a88e8bcf1c8fa0183ce526dc4295f7cbdb1292f8 \
--hash=sha256:f0eb89f06cad9231b9db54d81a22592b03b63924824a6b850febd2b75daa6546 \
--hash=sha256:f2463f650efab46905b9e279f5c776faf96c65bb45c1acf9f2de0e8a6eec5fb7 \
--hash=sha256:f3dedd02795626f17ee42d5c02ec4ec94e28aa47046ef430d4478454a8fbd07f \
--hash=sha256:f515a31c76cce043bbb7b781e77a343a4e26fa8f520ba337d30ddea0f7a0ce50 \
--hash=sha256:f7962a75cf22d0d683b49ab19b8966e94dc7014d9aa6806f3c0d2b37fb9ae607 \
--hash=sha256:fb05fb7b42d85754219b55111aa61beff4e48da56069e971de1e5aca380c0ac1
# via -r infra/requirements.txt
pillow==12.3.0 \
--hash=sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756 \
--hash=sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a \
--hash=sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59 \
--hash=sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45 \
--hash=sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3 \
--hash=sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df \
--hash=sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139 \
--hash=sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b \
--hash=sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39 \
--hash=sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e \
--hash=sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8 \
--hash=sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1 \
--hash=sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8 \
--hash=sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89 \
--hash=sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5 \
--hash=sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130 \
--hash=sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd \
--hash=sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d \
--hash=sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b \
--hash=sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed \
--hash=sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace \
--hash=sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb \
--hash=sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931 \
--hash=sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510 \
--hash=sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6 \
--hash=sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1 \
--hash=sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce \
--hash=sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385 \
--hash=sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e \
--hash=sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c \
--hash=sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7 \
--hash=sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace \
--hash=sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c \
--hash=sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f \
--hash=sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64 \
--hash=sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f \
--hash=sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a \
--hash=sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827 \
--hash=sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17 \
--hash=sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4 \
--hash=sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a \
--hash=sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701 \
--hash=sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e \
--hash=sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91 \
--hash=sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66 \
--hash=sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468 \
--hash=sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217 \
--hash=sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658 \
--hash=sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418 \
--hash=sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a \
--hash=sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c \
--hash=sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330 \
--hash=sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402 \
--hash=sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09 \
--hash=sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930 \
--hash=sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f \
--hash=sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec \
--hash=sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a \
--hash=sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94 \
--hash=sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468 \
--hash=sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b \
--hash=sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965 \
--hash=sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8 \
--hash=sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd \
--hash=sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7 \
--hash=sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c \
--hash=sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777 \
--hash=sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35 \
--hash=sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9 \
--hash=sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f \
--hash=sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f \
--hash=sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0 \
--hash=sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c \
--hash=sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71 \
--hash=sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3 \
--hash=sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838 \
--hash=sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf \
--hash=sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321 \
--hash=sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26 \
--hash=sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec \
--hash=sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9 \
--hash=sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65 \
--hash=sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5 \
--hash=sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e \
--hash=sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d \
--hash=sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198 \
--hash=sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7
# via
# -r infra/requirements.txt
# pikepdf
psycopg==3.2.9 \ psycopg==3.2.9 \
--hash=sha256:01a8dadccdaac2123c916208c96e06631641c0566b22005493f09663c7a8d3b6 \ --hash=sha256:01a8dadccdaac2123c916208c96e06631641c0566b22005493f09663c7a8d3b6 \
--hash=sha256:2fbb46fcd17bc81f993f28c47f1ebea38d66ae97cc2dbc3cad73b37cefbff700 --hash=sha256:2fbb46fcd17bc81f993f28c47f1ebea38d66ae97cc2dbc3cad73b37cefbff700
# via -r local/requirements.txt # via -r infra/requirements.txt
psycopg-binary==3.2.9 \ psycopg-binary==3.2.9 \
--hash=sha256:001e986656f7e06c273dd4104e27f4b4e0614092e544d950c7c938d822b1a894 \ --hash=sha256:001e986656f7e06c273dd4104e27f4b4e0614092e544d950c7c938d822b1a894 \
--hash=sha256:08bf9d5eabba160dd4f6ad247cf12f229cc19d2458511cab2eb9647f42fa6795 \ --hash=sha256:08bf9d5eabba160dd4f6ad247cf12f229cc19d2458511cab2eb9647f42fa6795 \
@@ -281,7 +599,7 @@ starlette==1.6.0 \
--hash=sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c \ --hash=sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c \
--hash=sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b --hash=sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b
# via # via
# -r local/requirements.txt # -r infra/requirements.txt
# fastapi # fastapi
typing-extensions==4.16.0 \ typing-extensions==4.16.0 \
--hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \
@@ -307,6 +625,4 @@ urllib3==2.7.0 \
uvicorn==0.34.2 \ uvicorn==0.34.2 \
--hash=sha256:0e929828f6186353a80b58ea719861d2629d766293b6d19baf086ba31d4f3328 \ --hash=sha256:0e929828f6186353a80b58ea719861d2629d766293b6d19baf086ba31d4f3328 \
--hash=sha256:deb49af569084536d269fe0a6d67e3754f104cf03aba7c11c40f01aadf33c403 --hash=sha256:deb49af569084536d269fe0a6d67e3754f104cf03aba7c11c40f01aadf33c403
# via -r local/requirements.txt # via -r infra/requirements.txt
# The following packages are considered to be unsafe in a requirements file:

View File

@@ -4,3 +4,5 @@ uvicorn==0.34.2
psycopg[binary]==3.2.9 psycopg[binary]==3.2.9
boto3==1.38.23 boto3==1.38.23
httpx==0.28.1 httpx==0.28.1
pillow==12.3.0
pikepdf==10.13.0.post1

View File

@@ -21,4 +21,13 @@ printf '%s' "$policy" > /tmp/policy.json
mc admin policy create local dtf-artwork /tmp/policy.json >/dev/null 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 admin policy attach local dtf-artwork --user "$S3_APP_USER" >/dev/null
mc ilm import "local/$S3_BUCKET" < /lifecycle.json mc ilm import "local/$S3_BUCKET" < /lifecycle.json
# The backup bucket and its own account, which can write and read backups but
# not delete them: the same separation as the backup token on R2.
case "$S3_BACKUP_BUCKET" in *[!a-z0-9.-]*|'') echo 'Invalid local backup bucket name' >&2; exit 1;; esac
mc mb --ignore-existing "local/$S3_BACKUP_BUCKET" >/dev/null
mc anonymous set none "local/$S3_BACKUP_BUCKET" >/dev/null
mc admin user add local "$S3_BACKUP_USER" "$S3_BACKUP_PASSWORD" >/dev/null
printf '%s' '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:ListBucket","s3:GetBucketLocation"],"Resource":["arn:aws:s3:::'"$S3_BACKUP_BUCKET"'"]},{"Effect":"Allow","Action":["s3:GetObject","s3:PutObject","s3:AbortMultipartUpload","s3:ListMultipartUploadParts"],"Resource":["arn:aws:s3:::'"$S3_BACKUP_BUCKET"'/*"]}]}' > /tmp/backup-policy.json
mc admin policy create local dtf-backup /tmp/backup-policy.json >/dev/null
mc admin policy attach local dtf-backup --user "$S3_BACKUP_USER" >/dev/null
echo 'Local storage runtime account provisioned.' echo 'Local storage runtime account provisioned.'

289
ops/db_backup.py Normal file
View File

@@ -0,0 +1,289 @@
"""Daily off-server backup of the database to its own bucket.
python -m ops.db_backup serve # the stack's backup service
python -m ops.db_backup once # one backup now
python -m ops.db_backup list # what the bucket holds
python -m ops.db_backup verify --identity F # restore the latest into a scratch database, then drop it
python -m ops.db_backup restore KEY --identity F --into NAME
Each backup is `pg_dump --format=custom`, checked with `pg_restore --list`,
encrypted with age to BACKUP_AGE_RECIPIENT (a public key) and uploaded to
BACKUP_BUCKET with credentials of its own. The server holds only the public
key: it can write backups but not read them. The private key (the identity)
stays with the owner, off the server, and is needed only to restore.
Old backups are removed by the bucket's lifecycle rule, not by this job, so
its credential never needs to delete; a bucket lock keeps a stolen one from
deleting either. Every run is recorded in dtf_local.backups, which the Kanban
shows on its Integrations tab.
"""
import argparse
import hashlib
import os
import subprocess
import sys
import tempfile
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
from uuid import uuid4
import boto3
import psycopg
from botocore.config import Config
from psycopg import sql
from psycopg.conninfo import conninfo_to_dict
from app.bootstrap import admin_connect
from app.core.secrets import load as load_secret_files
BRASILIA = timezone(timedelta(hours=-3))
PREFIX = 'db/'
# After a failure the next attempt comes this much later, not a day later.
RETRY = timedelta(hours=1)
# A backup older than this means the daily run has stopped working.
STALE = timedelta(hours=26)
TABLES = ('orders', 'quotes', 'uploads', 'accounts', 'movements', 'payment_intents')
def configured():
return all(os.environ.get(k) for k in
('BACKUP_S3_ENDPOINT', 'BACKUP_BUCKET', 'BACKUP_ACCESS_KEY_ID',
'BACKUP_SECRET_ACCESS_KEY', 'BACKUP_AGE_RECIPIENT'))
def bucket():
client = boto3.client('s3', endpoint_url=os.environ['BACKUP_S3_ENDPOINT'],
aws_access_key_id=os.environ['BACKUP_ACCESS_KEY_ID'],
aws_secret_access_key=os.environ['BACKUP_SECRET_ACCESS_KEY'],
region_name=os.environ.get('BACKUP_REGION', 'auto'),
config=Config(signature_version='s3v4', s3={'addressing_style': 'path'},
retries={'max_attempts': 5, 'mode': 'standard'}))
return client, os.environ['BACKUP_BUCKET']
def admin_params(database=None):
"""The administrator's connection, optionally to another database."""
if os.environ.get('DATABASE_ADMIN_HOST'):
params = {'host': os.environ['DATABASE_ADMIN_HOST'], 'user': os.environ['DATABASE_ADMIN_USER'],
'password': os.environ['DATABASE_ADMIN_PASSWORD'], 'dbname': os.environ['DATABASE_ADMIN_NAME']}
else:
params = conninfo_to_dict(os.environ['DATABASE_ADMIN_URL'])
if database:
params['dbname'] = database
return params
def libpq_env(database=None):
"""pg_dump and pg_restore read the credentials from the environment, so the
password never appears in a process list."""
names = {'host': 'PGHOST', 'port': 'PGPORT', 'user': 'PGUSER', 'password': 'PGPASSWORD', 'dbname': 'PGDATABASE'}
return {**os.environ, **{names[k]: str(v) for k, v in admin_params(database).items() if k in names}}
def run(command, **kwargs):
result = subprocess.run(command, capture_output=True, **kwargs)
if result.returncode:
detail = result.stderr.decode(errors='replace').strip().splitlines()
raise RuntimeError(f'{command[0]} failed: ' + (detail[-1] if detail else f'exit {result.returncode}'))
return result
def sha256(path):
digest = hashlib.sha256()
with open(path, 'rb') as stream:
for block in iter(lambda: stream.read(1 << 20), b''):
digest.update(block)
return digest.hexdigest()
def record(started, status, key=None, size=None, detail=None):
with admin_connect() as c:
c.execute('''INSERT INTO dtf_local.backups(id,started_at,finished_at,status,object_key,bytes,detail)
VALUES(%s,%s,now(),%s,%s,%s,%s)''',
(uuid4(), started, status, key, size, detail))
def run_once():
"""Dump, check, encrypt and upload one backup. Returns its object key."""
started = datetime.now(timezone.utc)
key = PREFIX + started.strftime('%Y/%m/dtf-%Y%m%dT%H%M%SZ') + '.dump.age'
try:
client, name = bucket()
with tempfile.TemporaryDirectory(dir=os.environ.get('BACKUP_TMP')) as work:
dump, sealed = Path(work, 'dtf.dump'), Path(work, 'dtf.dump.age')
run(['pg_dump', '--format=custom', '--compress=6', '--file', str(dump)], env=libpq_env())
# A truncated or damaged archive fails here, before it is kept.
run(['pg_restore', '--list', str(dump)])
run(['age', '--encrypt', '--recipient', os.environ['BACKUP_AGE_RECIPIENT'],
'--output', str(sealed), str(dump)])
size = sealed.stat().st_size
client.upload_file(str(sealed), name, key, ExtraArgs={'Metadata': {
'sha256': sha256(sealed), 'dump-sha256': sha256(dump), 'format': 'pg-custom+age'}})
record(started, 'ok', key, size)
print(f'Backup {key} uploaded ({size} bytes).', flush=True)
return key
except Exception as error:
# The message names a command or a provider error, never a credential.
detail = str(error)[:500]
try:
record(started, 'failed', key, None, detail)
finally:
print(f'Backup failed: {detail}', file=sys.stderr, flush=True)
raise
def last_ok():
with admin_connect() as c:
row = c.execute("SELECT max(finished_at) FROM dtf_local.backups WHERE status='ok'").fetchone()
return row[0]
def next_run(now, hour):
"""The next `hour` o'clock in Brasília after `now`."""
local = now.astimezone(BRASILIA)
at = local.replace(hour=hour, minute=0, second=0, microsecond=0)
if at <= local:
at += timedelta(days=1)
return at.astimezone(timezone.utc)
def serve():
if not configured():
print('Backup not configured: set BACKUP_S3_ENDPOINT, BACKUP_BUCKET, BACKUP_ACCESS_KEY_ID, '
'BACKUP_SECRET_ACCESS_KEY and BACKUP_AGE_RECIPIENT. Idle.', flush=True)
while True:
time.sleep(3600)
hour = int(os.environ.get('BACKUP_HOUR', '3'))
# Wait for the schema (db-init) before the first query.
for _ in range(60):
try:
previous = last_ok()
break
except psycopg.Error:
time.sleep(10)
else:
previous = last_ok()
now = datetime.now(timezone.utc)
# A fresh deployment, or one that missed a day, backs up straight away.
due = now if previous is None or now - previous > STALE else next_run(now, hour)
while True:
time.sleep(max(0, (due - datetime.now(timezone.utc)).total_seconds()))
try:
run_once()
due = next_run(datetime.now(timezone.utc), hour)
except Exception:
due = datetime.now(timezone.utc) + RETRY
def listing():
client, name = bucket()
keys = []
for page in client.get_paginator('list_objects_v2').paginate(Bucket=name, Prefix=PREFIX):
keys += [(o['Key'], o['Size'], o['LastModified']) for o in page.get('Contents', [])]
return sorted(keys)
def counts(database):
with psycopg.connect(**admin_params(database)) as c:
return {t: c.execute(sql.SQL('SELECT count(*) FROM dtf_local.{}').format(sql.Identifier(t))).fetchone()[0]
for t in TABLES}
def restore(key, identity, into):
"""Restore one backup into a database that is new or holds no DTF data."""
client, name = bucket()
with admin_connect() as c:
c.autocommit = True
if into == c.execute('SELECT current_database()').fetchone()[0]:
raise SystemExit('Refusing to restore over the database the stack is using.')
exists = c.execute('SELECT 1 FROM pg_database WHERE datname=%s', (into,)).fetchone()
if not exists:
c.execute(sql.SQL('CREATE DATABASE {}').format(sql.Identifier(into)))
if exists:
with psycopg.connect(**admin_params(into)) as c:
if c.execute("SELECT 1 FROM pg_namespace WHERE nspname='dtf_local'").fetchone():
raise SystemExit(f'{into} already holds DTF data; choose an empty or new database.')
with tempfile.TemporaryDirectory(dir=os.environ.get('BACKUP_TMP')) as work:
sealed, dump = Path(work, 'dtf.dump.age'), Path(work, 'dtf.dump')
client.download_file(name, key, str(sealed))
expected = client.head_object(Bucket=name, Key=key)['Metadata'].get('sha256')
if expected and sha256(sealed) != expected:
raise SystemExit('The downloaded backup does not match its checksum.')
run(['age', '--decrypt', '--identity', identity, '--output', str(dump), str(sealed)])
run(['pg_restore', '--exit-on-error', '--no-owner', '--no-privileges', '--dbname', into, str(dump)],
env=libpq_env(into))
return counts(into)
def drop(database):
with admin_connect() as c:
c.autocommit = True
c.execute(sql.SQL('DROP DATABASE IF EXISTS {} WITH (FORCE)').format(sql.Identifier(database)))
def verify(identity, key=None):
"""The restore test: the latest backup into a scratch database, counted, dropped."""
key = key or listing()[-1][0]
scratch = 'dtf_verify_' + uuid4().hex[:12]
try:
restored = restore(key, identity, scratch)
finally:
drop(scratch)
print(f'PASS: {key} decrypted and restored into a scratch database (now dropped). Rows: ' +
', '.join(f'{t} {n}' for t, n in restored.items()))
return restored
def identity_file(value):
"""The identity as a path, or '-' to paste it: nothing is written to disk
except a private temporary file removed when the command ends."""
if value != '-':
return value, None
print('Paste the private key (AGE-SECRET-KEY-...) and press Enter:', file=sys.stderr)
line = sys.stdin.readline().strip()
handle = tempfile.NamedTemporaryFile('w', prefix='identity-', dir=os.environ.get('BACKUP_TMP'), delete=False)
os.chmod(handle.name, 0o600)
handle.write(line + '\n')
handle.close()
return handle.name, handle.name
def main(argv=None):
load_secret_files()
parser = argparse.ArgumentParser(prog='python -m ops.db_backup')
sub = parser.add_subparsers(dest='command', required=True)
sub.add_parser('serve')
sub.add_parser('once')
sub.add_parser('list')
for name in ('verify', 'restore'):
p = sub.add_parser(name)
if name == 'restore':
p.add_argument('key')
p.add_argument('--into', required=True, help='a new or empty database')
else:
p.add_argument('key', nargs='?', help='default: the latest backup')
p.add_argument('--identity', required=True, help="the private key file, or '-' to paste it")
args = parser.parse_args(argv)
if args.command == 'serve':
serve()
elif args.command == 'once':
run_once()
elif args.command == 'list':
for key, size, modified in listing():
print(f'{modified:%Y-%m-%d %H:%M} UTC {size:>12} {key}')
else:
path, temporary = identity_file(args.identity)
try:
if args.command == 'verify':
verify(path, args.key)
else:
rows = restore(args.key, path, args.into)
print(f'Restored {args.key} into {args.into}. Rows: ' + ', '.join(f'{t} {n}' for t, n in rows.items()))
finally:
if temporary:
os.unlink(temporary)
if __name__ == '__main__':
main()

View File

@@ -115,7 +115,7 @@ try{
await call('Runtime.enable');await call('Page.enable'); await call('Runtime.enable');await call('Page.enable');
await call('Emulation.setDeviceMetricsOverride',{width:1440,height:1100,deviceScaleFactor:1,mobile:false}); await call('Emulation.setDeviceMetricsOverride',{width:1440,height:1100,deviceScaleFactor:1,mobile:false});
await call('Page.navigate',{url:`http://127.0.0.1:${server.address().port}/`}); await call('Page.navigate',{url:`http://127.0.0.1:${server.address().port}/`});
await waitFor(()=>evaluate(`typeof sel==='function' && typeof AUTO!=='undefined'`),'Site scripts'); await waitFor(()=>evaluate(`typeof sel==='function' && typeof pintaEntrega==='function'`),'Site scripts');
// Observe the actual engine, without replacing its placement or rendering. // Observe the actual engine, without replacing its placement or rendering.
await evaluate(`(()=>{ await evaluate(`(()=>{
const original=encaixar; const original=encaixar;
@@ -149,15 +149,38 @@ try{
// Navigation links must reach the chooser, never preselect a product. // Navigation links must reach the chooser, never preselect a product.
assert.equal(await evaluate(`document.querySelectorAll('[data-modo-cta]').length`),0); assert.equal(await evaluate(`document.querySelectorAll('[data-modo-cta]').length`),0);
await click('[data-modo="file"]'); await click('[data-modo="file"]');
// Opening a product paints on the next frame; on a busy runner the check
// must wait for it rather than race it.
await waitFor(()=>evaluate(`!$('tipoEnvio').hidden`),'by-metre product open');
// By-metre opens declaring a mounted sheet; switching is explicit and priced. // By-metre opens declaring a mounted sheet; switching is explicit and priced.
assert.deepEqual(await evaluate(`({shown:!$('tipoEnvio').hidden, assert.deepEqual(await evaluate(`({shown:!$('tipoEnvio').hidden,
on:[...document.querySelectorAll('#tipoEnvio .cam.on')].map(b=>b.dataset.tipo)})`), on:[...document.querySelectorAll('#tipoEnvio .cam.on')].map(b=>b.dataset.tipo)})`),
{shown:true,on:['folha']}); {shown:true,on:['folha']});
await evaluate(`(()=>{const f=new File(['x'],'oversize.cdr');Object.defineProperty(f,'size',{value:128*1048576+1});sel([f]);})()`); await evaluate(`(()=>{const f=new File(['x'],'oversize.cdr');Object.defineProperty(f,'size',{value:5*1073741824+1});sel([f]);})()`);
assert.equal(await evaluate(`folhas.length===0 && $('recusa').textContent.includes('128 MB')`),true, assert.equal(await evaluate(`folhas.length===0 && $('recusa').textContent.includes('5 GB')`),true,
'files beyond the scanner limit are rejected before browser analysis'); 'files beyond the 5 GB upload limit are rejected before browser analysis');
// A multi-GB sheet is the normal order: graded from the pixel size in its
// header, never decoded. 6732 x 35433 px across 57 cm is 3 m at 300 DPI.
await evaluate(`(()=>{const h=new Uint8Array(33);h.set([0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A,0,0,0,13,0x49,0x48,0x44,0x52]);
const v=new DataView(h.buffer);v.setUint32(16,6732);v.setUint32(20,35433);h.set([8,6,0,0,0],24);
const f=new File([h],'folha-grande.png');Object.defineProperty(f,'size',{value:3*1073741824});sel([f]);})()`);
await waitFor(()=>evaluate(`folhas.length===1 && !!folhas[0].an`),'large sheet graded from its header');
assert.deepEqual(await evaluate(`({grande:folhas[0].an.grande,dpi:folhas[0].an.dpi,alt:folhas[0].med.alt,preview:!!folhas[0].previewSrc})`),
{grande:true,dpi:300,alt:300,preview:false});
await evaluate(`folhas=[];pintaFolha()`);
await evaluate(`pickTipo('avulsa')`); await evaluate(`pickTipo('avulsa')`);
assert.equal(await evaluate('modo'),'avulsa'); assert.equal(await evaluate('modo'),'avulsa');
// A large JPG artwork cannot be read in parts: it is measured from its
// header and placed as a full box, with the discount, instead of refused.
await evaluate(`(()=>{const h=new Uint8Array(20);h.set([0xFF,0xD8,0xFF,0xC0,0,17,8,0x09,0x3A,0x09,0x3A,3]);
const f=new File([h],'arte-grande.jpg');Object.defineProperty(f,'size',{value:400*1048576});sel([f]);})()`);
await waitFor(()=>evaluate(`artes.length===1 && artes[0].semPrevia===true`),'large JPG artwork measured from its header');
await fill('[data-cm]',20);
await waitFor(()=>evaluate(`!!itemAtual?.production?.sources?.length`),'large JPG artwork packed');
assert.deepEqual(await evaluate(`({dpi:Math.round(dpiDe(artes[0])),src:!!artes[0].src,
measurement:itemAtual.production.sources[0].measurement,note:$('lista').textContent.includes('Sem prévia')})`),
{dpi:300,src:false,measurement:'file',note:true});
await evaluate(`artes=[];pintaArtes()`);
await evaluate('sendImage()'); await evaluate('sendImage()');
await waitFor(()=>evaluate(`artes.length===1 && !!artes[0].src`),'JPG artwork model'); await waitFor(()=>evaluate(`artes.length===1 && !!artes[0].src`),'JPG artwork model');
assert.deepEqual(await evaluate(`({mode:modo,sheets:folhas.length,width:artes[0].cm,quantity:artes[0].q})`),{mode:'avulsa',sheets:0,width:0,quantity:1}); assert.deepEqual(await evaluate(`({mode:modo,sheets:folhas.length,width:artes[0].cm,quantity:artes[0].q})`),{mode:'avulsa',sheets:0,width:0,quantity:1});
@@ -284,13 +307,14 @@ try{
await fill('[data-cm]',10);await fill('[data-q]',4);await packed(4); await fill('[data-cm]',10);await fill('[data-q]',4);await packed(4);
} }
// An older image load must not overwrite a newer edit, even before debounce. // An older image load must not overwrite a newer edit, even before debounce.
await evaluate(`(()=>{window.realLoad=carregarImagem;window.delayed=[]; // The packing loads each artwork's image through imagemDaArte.
carregarImagem=f=>new Promise(resolve=>delayed.push(()=>realLoad(f).then(resolve))); await evaluate(`(()=>{window.realLoad=imagemDaArte;window.delayed=[];
imagemDaArte=a=>new Promise(resolve=>delayed.push(()=>realLoad(a).then(resolve)));
})()`); })()`);
await fill('[data-q]',5); await fill('[data-q]',5);
await waitFor(()=>evaluate('delayed.length===1'),'delayed preview'); await waitFor(()=>evaluate('delayed.length===1'),'delayed preview');
await fill('[data-q]',7); await fill('[data-q]',7);
await evaluate(`carregarImagem=realLoad;delayed[0]()`); await evaluate(`imagemDaArte=realLoad;delayed[0]()`);
await packed(7); await packed(7);
const timeoutCleanup=await evaluate(`(async()=>{ const timeoutCleanup=await evaluate(`(async()=>{
const realLoader=carregarPdfJs, realTimer=setTimeout, realWorker=temWorker; const realLoader=carregarPdfJs, realTimer=setTimeout, realWorker=temWorker;

79
tests/backup_test.py Normal file
View File

@@ -0,0 +1,79 @@
"""The database backup against the local stack: dump, encrypt, upload, restore.
docker compose -f compose.local.yaml exec -T backup python -m tests.backup_test
Uses a throwaway age key made here, so nothing secret is kept in the repository.
"""
import os
import subprocess
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from botocore.exceptions import ClientError
from app.bootstrap import admin_connect
from ops import db_backup
def check(condition, message):
if not condition:
raise AssertionError(message)
print('PASS:', message)
def main():
with tempfile.TemporaryDirectory() as work:
identity = Path(work, 'identity.txt')
subprocess.run(['age-keygen', '-o', str(identity)], check=True, capture_output=True)
public = next(line.split(': ', 1)[1] for line in identity.read_text().splitlines()
if line.startswith('# public key: '))
os.environ['BACKUP_AGE_RECIPIENT'] = public
check(db_backup.configured(), 'the local backup service is configured once a recipient is set')
live = db_backup.counts(None)
key = db_backup.run_once()
with admin_connect() as c:
row = c.execute('SELECT status,bytes FROM dtf_local.backups WHERE object_key=%s', (key,)).fetchone()
check(row is not None and row[0] == 'ok' and row[1] > 0, 'the run is recorded for the Kanban')
client, bucket = db_backup.bucket()
head = client.get_object(Bucket=bucket, Key=key, Range='bytes=0-20')['Body'].read()
check(head.startswith(b'age-encryption.org/v1'), 'what leaves the server is encrypted')
check(key in [k for k, _, _ in db_backup.listing()], 'the backup is listed in its bucket')
try:
client.delete_object(Bucket=bucket, Key=key)
deleted = True
except ClientError:
deleted = False
check(not deleted and key in [k for k, _, _ in db_backup.listing()],
'the backup credential cannot delete backups')
restored = db_backup.verify(str(identity), key)
check(restored == live, f'the restore has the same rows as the live database ({restored})')
live_name = db_backup.admin_params()['dbname']
try:
db_backup.restore(key, str(identity), live_name)
refused = False
except SystemExit:
refused = True
check(refused, 'a restore over the live database is refused')
other = Path(work, 'other.txt')
subprocess.run(['age-keygen', '-o', str(other)], check=True, capture_output=True)
try:
db_backup.verify(str(other), key)
opened = True
except RuntimeError:
opened = False
check(not opened, 'another key cannot open the backup')
brt = lambda h, m=0: datetime(2026, 9, 29, h + 3, m, tzinfo=timezone.utc)
check(db_backup.next_run(brt(2), 3) == brt(3), 'before 03:00 in Brasília the run is that day')
check(db_backup.next_run(brt(3), 3) == datetime(2026, 9, 30, 6, tzinfo=timezone.utc),
'at or after 03:00 the run is the next day')
if __name__ == '__main__':
main()

View File

@@ -62,8 +62,13 @@ try{
await site.call('Page.setBypassCSP',{enabled:false}); await site.call('Page.setBypassCSP',{enabled:false});
await site.call('Page.reload'); await site.call('Page.reload');
await waitFor(()=>site.eval('typeof window.dtfCheckout === "function"'),'reload after security probe'); await waitFor(()=>site.eval('typeof window.dtfCheckout === "function"'),'reload after security probe');
// The check above can still pass on the page being replaced; wait for the new one's cards.
await waitFor(()=>site.eval(`!!document.querySelector('[data-modo="file"]') && document.readyState==='complete'`),'product cards after reload');
await site.click('[data-modo="file"]'); await site.click('[data-modo="file"]');
await site.click('[data-cam="tabela"]'); // Each product has its own page; the home's parts are not on it.
assert.deepEqual(await site.eval(`({path:location.pathname,page:document.documentElement.dataset.rota,
cards:getComputedStyle($('cards')).display,cart:getComputedStyle($('carr')).display})`),
{path:'/arquivo-por-metro',page:'produto',cards:'none',cart:'none'});
const root=await site.call('DOM.getDocument'); const root=await site.call('DOM.getDocument');
const input=await site.call('DOM.querySelector',{nodeId:root.root.nodeId,selector:'#inp'}); const input=await site.call('DOM.querySelector',{nodeId:root.root.nodeId,selector:'#inp'});
await site.call('DOM.setFileInputFiles',{nodeId:input.nodeId,files:[resolve('tests/fixtures/local-test.cdr')]}); await site.call('DOM.setFileInputFiles',{nodeId:input.nodeId,files:[resolve('tests/fixtures/local-test.cdr')]});
@@ -74,15 +79,48 @@ try{
await site.fill('#fCnpj','11222333000181'); await site.fill('#fCnpj','11222333000181');
await site.fill('#fZap','11999999999'); await site.fill('#fZap','11999999999');
await site.fill('#fMail','local-browser@example.test'); await site.fill('#fMail','local-browser@example.test');
await pause(800); // Reload only once the cart is in the browser's storage: the save waits for
// typing to stop and writes the files, so a fixed pause raced it.
await waitFor(()=>site.eval(`new Promise(done=>{const open=indexedDB.open('dtf-local-cart',1);
open.onerror=()=>done(false);
open.onsuccess=()=>{const all=open.result.transaction('cart').objectStore('cart').getAll();
all.onsuccess=()=>done(all.result.some(v=>v.items?.length===1));all.onerror=()=>done(false);};})`),'cart saved in the browser');
await site.call('Page.reload'); await site.call('Page.reload');
await waitFor(()=>site.eval('typeof pedido!=="undefined" && pedido.length===1'),'persistent cart recovery'); await waitFor(()=>site.eval('typeof pedido!=="undefined" && pedido.length===1'),'persistent cart recovery');
// Reloading a product page reopens that product; the cart is its own page.
assert.equal(await site.eval('modo'),'file');
await site.click('#cartLink');
await waitFor(()=>site.eval(`location.pathname==='/carrinho' && getComputedStyle($('carr')).display!=='none' && getComputedStyle($('foco')).display==='none'`),'cart page');
// Removing an item is one click and can be undone.
await site.click('[data-rmi="0"]');
// The empty state is painted on the next animation frame (site-steps.js).
await waitFor(()=>site.eval(`!$('carrVazio').hidden||document.documentElement.hasAttribute('data-sem-itens')`),'empty cart state');
assert.deepEqual(await site.eval(`({items:pedido.length,empty:!$('carrVazio').hidden||document.documentElement.hasAttribute('data-sem-itens'),undo:!$('desfazer').hidden})`),{items:0,empty:true,undo:true});
await site.click('#desfazerBtn');
assert.equal(await site.eval('pedido.length===1 && pedido[0].total===21.89 && $("desfazer").hidden'),true);
// The items come back after a reload; the customer's details do not.
assert.deepEqual(await site.eval(`['fCnpj','fZap','fMail'].map(id=>$(id).value)`),['','','']);
assert.equal(await site.eval('document.getElementById("bPagar").disabled'),true);
await site.fill('#fCnpj','11222333000181');
await site.fill('#fZap','11999999999');
await site.fill('#fMail','local-browser@example.test');
assert.equal(await site.eval('pedido[0].localFiles[0].name'),'local-test.cdr'); assert.equal(await site.eval('pedido[0].localFiles[0].name'),'local-test.cdr');
// An unchecked item stays in the cart but is not paid for now.
await site.click('[data-sel="0"]');
assert.deepEqual(await site.eval(`({marcado:pedido[0].marcado,pagar:itensAPagar().length,zero:$('carrTot').textContent.includes('0,00'),row:document.querySelector('.item').classList.contains('fora')})`),
{marcado:false,pagar:0,zero:true,row:true});
await site.click('[data-sel="0"]');
assert.equal(await site.eval('pedido[0].marcado===true && itensAPagar().length===1'),true);
assert.equal(await site.eval('pedido[0].total'),21.89); assert.equal(await site.eval('pedido[0].total'),21.89);
assert.equal(await site.eval('document.getElementById("bPagar").disabled'),false); assert.equal(await site.eval('document.getElementById("bPagar").disabled'),false);
await site.click('#bPagar'); await site.click('#bPagar');
try{ try{
await waitFor(async()=> (await site.text()).includes('Arquivos enviados.'),'browser upload and quote',45000); // A cart the Site can price is approved at once: the customer pays now.
await waitFor(async()=> (await site.text()).includes('Total validado no servidor:'),'browser upload and automatic approval',45000);
// Payment is its own page: the cart form is gone and the order summary is shown.
assert.equal(await site.eval('location.pathname'),'/pagamento');
assert.equal(await site.eval('getComputedStyle(document.getElementById("carr")).display'),'none');
assert.equal(await site.eval('!document.getElementById("pagResumo").hidden && document.getElementById("pagResumo").textContent.includes("Total")'),true);
}catch(error){ }catch(error){
console.error('Checkout status:',await site.eval('document.getElementById("checkoutStatus")?.textContent')); console.error('Checkout status:',await site.eval('document.getElementById("checkoutStatus")?.textContent'));
throw error; throw error;
@@ -92,11 +130,15 @@ try{
await kanban.fill('#email',process.env.OPERATOR_EMAIL||'operator@example.test'); await kanban.fill('#email',process.env.OPERATOR_EMAIL||'operator@example.test');
await kanban.fill('#password',process.env.OPERATOR_PASSWORD||'local-operator-only'); await kanban.fill('#password',process.env.OPERATOR_PASSWORD||'local-operator-only');
await kanban.eval('document.getElementById("login").requestSubmit()'); await kanban.eval('document.getElementById("login").requestSubmit()');
await waitFor(async()=> (await kanban.text()).includes(qid.slice(0,8)),'quote on Kanban'); // Quotes live in their own tab; an automatic approval is listed as such.
await waitFor(()=>kanban.eval('!document.getElementById("app").hidden'),'Kanban sign-in');
assert.equal(await kanban.eval('sessionStorage.getItem("dtf-operator")'),null); assert.equal(await kanban.eval('sessionStorage.getItem("dtf-operator")'),null);
assert.equal(await kanban.eval('document.getElementById("password").value'),''); 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 kanban.click('[data-tab="quotes"]');
await waitFor(async()=> (await kanban.text()).includes('Aprovada:'),'quote approval'); await waitFor(()=>kanban.eval('document.querySelectorAll("#quote-filters button").length===2'),'quote filters');
await kanban.eval('document.querySelectorAll("#quote-filters button")[1].click()');
await waitFor(()=>kanban.eval(`!!document.querySelector('[data-quote-pick="${qid}"]')`),'approved quote listed on Kanban');
assert.equal(await kanban.eval(`document.querySelector('[data-quote-pick="${qid}"]').textContent.includes('Aprovada automaticamente')`),true);
assert.equal(await site.eval('pedido[0].production.sources[0].copies'),1); assert.equal(await site.eval('pedido[0].production.sources[0].copies'),1);
await site.eval('pedido[0].production.sources[0].copies=2;pintaPedido()'); await site.eval('pedido[0].production.sources[0].copies=2;pintaPedido()');
await waitFor(async()=> (await site.text()).includes('O carrinho mudou'),'same-price production edit invalidates quote'); await waitFor(async()=> (await site.text()).includes('O carrinho mudou'),'same-price production edit invalidates quote');
@@ -114,21 +156,34 @@ try{
const oid=await kanban.eval(`board.orders.find(o=>o.quote_id===${JSON.stringify(qid)}).id`); const oid=await kanban.eval(`board.orders.find(o=>o.quote_id===${JSON.stringify(qid)}).id`);
assert.deepEqual(await kanban.eval(`(()=>{const spec=board.orders.find(o=>o.id===${JSON.stringify(oid)}).snapshot.items[0].production;const source=spec.sources[0];return {kind:source.kind,copies:source.copies,length:Number(source.length_cm),height:Number(spec.height_cm),placed:spec.placements.length}})()`), assert.deepEqual(await kanban.eval(`(()=>{const spec=board.orders.find(o=>o.id===${JSON.stringify(oid)}).snapshot.items[0].production;const source=spec.sources[0];return {kind:source.kind,copies:source.copies,length:Number(source.length_cm),height:Number(spec.height_cm),placed:spec.placements.length}})()`),
{kind:'sheet',copies:1,length:101,height:101,placed:1}); {kind:'sheet',copies:1,length:101,height:101,placed:1});
// Click real transition buttons, including rerender after each move. // Open the order's panel from its card, then click the real transition
// buttons there, including the rerender after each move.
await kanban.click('[data-tab="board"]');
await kanban.click(`[data-card="${oid}"]`);
await waitFor(()=>kanban.eval(`!!document.querySelector('[data-order="${oid}"] [data-move]')`),'order panel');
for(const [title,state] of [['Arte tratada','tra'],['Fila de impressão','fil'],['Imprimindo','imp'],['Finalizado','fin']]){ for(const [title,state] of [['Arte tratada','tra'],['Fila de impressão','fil'],['Imprimindo','imp'],['Finalizado','fin']]){
if(state==='fil'){ 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'); await waitFor(()=>kanban.eval(`!!document.querySelector('[data-order="${oid}"] [data-final-item]')`),'final upload controls');
const doc=await kanban.call('DOM.getDocument'); 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]`}); 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('tests/fixtures/local-test.cdr')]}); await kanban.call('DOM.setFileInputFiles',{nodeId:input.nodeId,files:[resolve('tests/fixtures/local-test.cdr')]});
await kanban.fill(`[data-order="${oid}"] input[placeholder="Nota da revisão"]`,'Browser test final file'); 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 kanban.eval(`(()=>{const check=document.querySelector('[data-order="${oid}"] [data-confirm]');check.click();check.closest('form').requestSubmit();})()`);
await waitFor(()=>kanban.eval(`board.orders.find(o=>o.id==='${oid}').version===2`),'final file approval'); 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(`!!document.querySelector('[data-order="${oid}"] [data-move="${state}"]')`),'move to '+title);
await kanban.click(`[data-order="${oid}"] [data-move="${state}"]`);
await waitFor(()=>kanban.eval(`board.orders.find(o=>o.id==='${oid}').state==='${state}'`),'transition '+state); await waitFor(()=>kanban.eval(`board.orders.find(o=>o.id==='${oid}').state==='${state}'`),'transition '+state);
} }
// Undo a mistaken move from the panel: one stage back with an internal reason.
await waitFor(()=>kanban.eval(`!!document.querySelector('[data-order="${oid}"] [data-back="imp"]')`),'back button');
await kanban.click(`[data-order="${oid}"] [data-back="imp"]`);
await kanban.fill(`[data-order="${oid}"] form.reason input`,'Movido por engano (teste)');
await kanban.eval(`document.querySelector('[data-order="${oid}"] form.reason input').closest('form').requestSubmit()`);
await waitFor(()=>kanban.eval(`board.orders.find(o=>o.id==='${oid}').state==='imp'`),'undo to Imprimindo');
await waitFor(()=>kanban.eval(`!!document.querySelector('[data-order="${oid}"] [data-move="fin"]')`),'move to Finalizado again');
await kanban.click(`[data-order="${oid}"] [data-move="fin"]`);
await waitFor(()=>kanban.eval(`board.orders.find(o=>o.id==='${oid}').state==='fin'`),'finished again');
// Reload proves the board is persisted on the backend. // Reload proves the board is persisted on the backend.
await kanban.call('Page.reload'); 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 waitFor(()=>kanban.eval(`typeof board!=='undefined' && !!board && board.orders.some(o=>o.id==='${oid}'&&o.state==='fin')`),'persisted board');
@@ -137,14 +192,20 @@ try{
await waitFor(()=>site.eval('window.scrollY===0'),'screenshot scroll position'); await waitFor(()=>site.eval('window.scrollY===0'),'screenshot scroll position');
await site.screenshot('output/local/site.png'); await site.screenshot('output/local/site.png');
await kanban.screenshot('output/local/kanban.png'); await kanban.screenshot('output/local/kanban.png');
const portal=await page(siteOrigin+'/portal.html?order='+oid); // A guest sees the order paid in this browser; creating an account keeps it.
const portal=await page(siteOrigin+'/conta/pedidos?order='+oid);
await waitFor(async()=> (await portal.text()).includes('Finalizado'),'customer order tracking'); await waitFor(async()=> (await portal.text()).includes('Finalizado'),'customer order tracking');
await waitFor(()=>portal.eval('document.querySelector("#order-'+oid+' .detalhe")?.hidden===false'),'order from the link opened');
await portal.click('#navEntrar');
await waitFor(()=>portal.eval('location.pathname==="/conta/entrar" && !document.getElementById("auth").hidden'),'sign-in page');
await portal.fill('#cnpj','11222333000181');await portal.fill('#phone','11999999999'); await portal.fill('#cnpj','11222333000181');await portal.fill('#phone','11999999999');
await portal.fill('#register-email','browser-'+Date.now()+'@example.test'); await portal.fill('#register-email','browser-'+Date.now()+'@example.test');
await portal.fill('#register-password','local-browser-password-123'); await portal.fill('#register-password','local-browser-password-123');
await portal.eval('document.getElementById("register").requestSubmit()'); await portal.eval('document.getElementById("register").requestSubmit()');
await waitFor(()=>portal.eval('document.getElementById("auth").hidden'),'customer registration'); await waitFor(()=>portal.eval('document.getElementById("auth").hidden && location.pathname==="/conta"'),'customer registration');
assert.ok((await portal.text()).includes('Finalizado')); await waitFor(async()=> (await portal.text()).includes('Finalizado'),'overview shows the last order');
await portal.click('[data-view="dados"]');
await waitFor(()=>portal.eval('location.pathname==="/conta/dados" && document.getElementById("dCnpj").value==="11.222.333/0001-81"'),'account details');
await portal.screenshot('output/local/portal.png'); await portal.screenshot('output/local/portal.png');
// A logout must clear draft file blobs and metadata, including other open Site tabs. // 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.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();})()`);
@@ -155,7 +216,7 @@ try{
assert.equal(stored,0); assert.equal(stored,0);
assert.deepEqual(portal.errors,[]); assert.deepEqual(portal.errors,[]);
assert.deepEqual(site.errors,[]);assert.deepEqual(kanban.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('PASS: browser Site upload → automatic approval → 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('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.'); 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;} }catch(error){console.error(error);if(stderr)console.error(stderr.slice(-1500));process.exitCode=1;}

View File

@@ -12,7 +12,8 @@ from urllib.error import HTTPError
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
from uuid import uuid4 from uuid import uuid4
from tests.smoke_test import BASE, Client, upload_bytes, item_spec, with_host from app.core import db
from tests.smoke_test import BASE, Client, approved_quote, upload_bytes, item_spec, with_host
SECRET = os.environ.get('PAYMENT_WEBHOOK_SECRET', 'local-webhook-secret').encode() SECRET = os.environ.get('PAYMENT_WEBHOOK_SECRET', 'local-webhook-secret').encode()
@@ -42,8 +43,7 @@ def reviewed_quote():
'mail': 'payment-' + uuid4().hex[:8] + '@example.test'} 'mail': 'payment-' + uuid4().hex[:8] + '@example.test'}
quote = customer.call('/quotes', {'request_key': str(uuid4()), 'customer': profile, quote = customer.call('/quotes', {'request_key': str(uuid4()), 'customer': profile,
'items': [item], 'freight': {'service': 'pickup'}}) 'items': [item], 'freight': {'service': 'pickup'}})
approved = customer.call('/operator/quotes/' + quote['id'] + '/approve', approved = approved_quote(customer, quote, [item])
{'items': [item]}, operator=True)
return customer, quote['id'], approved['total_cents'] return customer, quote['id'], approved['total_cents']
@@ -58,6 +58,35 @@ def run():
assert not customer.call('/quotes/' + quote_id)['order'], 'unsigned delivery created an order' assert not customer.call('/quotes/' + quote_id)['order'], 'unsigned delivery created an order'
print('PASS: unsigned and tampered deliveries are refused and create nothing') print('PASS: unsigned and tampered deliveries are refused and create nothing')
# Starting a PIX twice returns the same one. A card in review blocks every
# further attempt, so one quote can never be charged twice.
pix = customer.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'pix'}})
assert pix['expires_at']
assert customer.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'pix'}})['id'] == pix['id']
# Once the code has expired, asking again opens a new one, and only one.
with db.connect() as c:
c.execute('''UPDATE dtf_local.payment_intents
SET response=jsonb_set(response,'{expires_at}',to_jsonb((now()-interval '1 minute')::text))
WHERE provider_payment_id=%s''', (pix['id'],))
renewed = customer.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'pix'}})
assert renewed['id'] != pix['id'], 'an expired PIX was offered again'
assert customer.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'pix'}})['id'] == renewed['id']
with db.connect() as c:
statuses = {r['provider_payment_id']: r['status'] for r in c.execute(
"SELECT provider_payment_id,status FROM dtf_local.payment_intents WHERE quote_id=%s AND method='pix'",
(quote_id,)).fetchall()}
assert statuses == {pix['id']: 'expired', renewed['id']: 'pending'}, statuses
print('PASS: a PIX code expires after 30 minutes and is replaced by exactly one new code')
customer.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'card'}}, expected=422)
card = {'type': 'card', 'token': 'tok-1', 'payment_method_id': 'visa', 'installments': 1}
customer.call('/payments/intent', {'quote_id': quote_id, 'method': card})
customer.call('/payments/intent', {'quote_id': quote_id, 'method': {**card, 'token': 'tok-2'}}, expected=409)
customer.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'pix'}}, expected=409)
stranger = Client()
stranger.call('/session')
stranger.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'pix'}}, expected=404)
print('PASS: payment start is idempotent for PIX and refuses a second charge')
# An approved payment for the wrong amount must not become an order. # An approved payment for the wrong amount must not become an order.
deliver({'event_id': 'short-' + uuid4().hex, 'reference': quote_id, deliver({'event_id': 'short-' + uuid4().hex, 'reference': quote_id,
'status': 'approved', 'amount_cents': total - 100}) 'status': 'approved', 'amount_cents': total - 100})
@@ -95,7 +124,7 @@ def run():
# The customer is told once, not once per delivery. # The customer is told once, not once per delivery.
board = Client() board = Client()
events = board.call('/operator/board', operator=True)['events'] events = board.call('/operator/events?order=' + str(order['number']), operator=True)['events']
paid = [e for e in events if e['payload'].get('order_id') == order['id'] paid = [e for e in events if e['payload'].get('order_id') == order['id']
and e['payload'].get('event') == 'payment_approved'] and e['payload'].get('event') == 'payment_approved']
assert len(paid) == 2, f'expected one tiny and one whatsapp event, got {len(paid)}' assert len(paid) == 2, f'expected one tiny and one whatsapp event, got {len(paid)}'
@@ -111,6 +140,45 @@ def run():
'status': 'pending', 'amount_cents': total}) 'status': 'pending', 'amount_cents': total})
print('PASS: unknown references and non-approved statuses are recorded without acting') print('PASS: unknown references and non-approved statuses are recorded without acting')
# An operator's test order runs the production flow and notifies no one.
tester, test_quote, _ = reviewed_quote()
order = tester.call('/operator/quotes/' + test_quote + '/test-order', {}, operator=True)
assert order['payment']['provider'] == 'teste' and order['state'] == 'rec', order
assert tester.call('/operator/quotes/' + test_quote + '/test-order', {}, operator=True)['id'] == order['id']
version = order['version']
for state in ('tra',):
moved = tester.call('/operator/orders/' + order['id'] + '/move', {'state': state, 'version': version}, operator=True)
version = moved['version']
with db.connect() as c:
queued = c.execute("SELECT count(*) AS n FROM dtf_local.outbox WHERE event_key LIKE %s", (order['id'] + ':%',)).fetchone()['n']
jobs = c.execute('SELECT count(*) AS n FROM dtf_local.print_files WHERE order_id=%s', (order['id'],)).fetchone()['n']
assert queued == 0 and jobs == 1, (queued, jobs)
print('PASS: an operator test order reaches the board and the print queue, never Tiny or WhatsApp')
# Files are uploaded before payment. An unpaid cart keeps them briefly; a
# paid order keeps them for its 30 days; a payment never starts for files
# that are gone.
def files_of(qid):
with db.connect() as c:
q = c.execute('SELECT approved,draft FROM dtf_local.quotes WHERE id=%s', (qid,)).fetchone()
return [u for item in (q['approved'] or q['draft'])['items'] for u in item['uploads']]
def days(ids, since='now()'):
with db.connect() as c:
return [float(r['d']) for r in c.execute(
f'SELECT extract(epoch FROM expires_at-{since})/86400 AS d FROM dtf_local.uploads WHERE id=ANY(%s)',
(ids,)).fetchall()]
fresh = upload_bytes(customer, b'UNPAID HOLD TEST')
assert all(1.99 < d <= 2.0 for d in days([fresh])), days([fresh])
assert all(abs(d - 30) < 0.01 for d in days(files_of(quote_id), 'created_at')), days(files_of(quote_id), 'created_at')
late, late_quote, _ = reviewed_quote()
assert all(d > 1.99 for d in days(files_of(late_quote))), days(files_of(late_quote))
with db.connect() as c:
c.execute("UPDATE dtf_local.uploads SET expires_at=now()-interval '1 second' WHERE id=ANY(%s)",
(files_of(late_quote),))
late.call('/payments/intent', {'quote_id': late_quote, 'method': {'type': 'pix'}}, expected=410)
print('PASS: unpaid files are held 2 days, paid ones 30 days, and expired files are never charged for')
if __name__ == '__main__': if __name__ == '__main__':
run() run()

184
tests/print_file_test.py Normal file
View File

@@ -0,0 +1,184 @@
"""Print files and payment issues, against a running stack.
A paid order must get a print file generated from its approved layout, which
the operator can download and approve as the final file without re-uploading
anything. Artwork the generator cannot read goes to hand preparation with a
reason, and a paid notification that did not become an order stays on the
Kanban until someone records what was done.
Run inside the API container (it needs Pillow): python -m tests.print_file_test
"""
import io
import re
import time
from urllib.request import urlopen
from uuid import uuid4
from PIL import Image
from tests.payment_test import deliver
from tests.smoke_test import Client, approved_quote as approval, upload_bytes
PT_PER_CM = 72 / 2.54
CUSTOMER = {'cnpj': '11222333000181', 'zap': '11999999999', 'mail': 'print-test@example.test'}
def artwork(kind='PNG'):
image = Image.new('RGBA', (600, 300), (0, 0, 0, 0))
for x in range(40, 560):
for y in range(40, 260):
image.putpixel((x, y), (220, 30, 60, 255))
out = io.BytesIO()
if kind == 'PDF':
image.convert('RGB').save(out, 'PDF', resolution=72)
else:
image.save(out, kind)
return out.getvalue()
def loose_item(uid, copies=2):
"""Two copies of a 20 x 10 cm artwork side by side on 57 cm film."""
placements = [{'source_index': 0, 'copy_index': i, 'x_cm': 20 * i, 'y_cm': 0,
'width_cm': 20, 'length_cm': 10, 'rotation_degrees': 0, 'mirrored': False}
for i in range(copies)]
return {'mode': 'avulsa', 'metres': '0.1', 'grade': 90, 'uploads': [uid],
'production': {'version': 2, 'film_width_cm': 57, 'height_cm': 10,
'sources': [{'upload_id': uid, 'kind': 'artwork', 'width_cm': 20,
'length_cm': 10, 'copies': copies, 'rotation_degrees': 0,
'mirrored': False, 'measurement': 'file'}],
'placements': placements},
'quality_status': 'ok', 'quality_acknowledged': False}
def approved_quote(client, item):
quote = client.call('/quotes', {'request_key': str(uuid4()), 'customer': CUSTOMER,
'items': [item], 'freight': {'service': 'pickup'}})
approved = approval(client, quote, [item])
return quote['id'], approved['total_cents']
def paid_order(client, item):
quote_id, _ = approved_quote(client, item)
return client.call('/orders/dev-paid', {'quote_id': quote_id})
def wait_print(client, oid, wanted):
deadline = time.monotonic() + 90
while time.monotonic() < deadline:
order = next(o for o in client.call('/operator/board', operator=True)['orders'] if o['id'] == oid)
rows = order['print_files']
if rows and rows[0]['status'] in ('ready', 'manual', 'failed'):
assert rows[0]['status'] == wanted, rows
return order, rows[0]
time.sleep(1)
raise AssertionError('Print file was not generated in time')
def run():
client = Client()
client.call('/session')
uid = upload_bytes(client, artwork(), name='LOCAL-PRINT-TEST.png')
order = paid_order(client, loose_item(uid))
order, row = wait_print(client, order['id'], 'ready')
assert row['detail']['placements'] == 2 and row['detail']['min_dpi'] == round(600 / (20 / 2.54))
link = client.call('/operator/uploads/' + str(row['upload_id']) + '/download', operator=True)
with urlopen(link['url'], timeout=30) as response:
pdf = response.read()
assert pdf.startswith(b'%PDF-') and pdf.rstrip().endswith(b'%%EOF')
width, height = map(float, re.search(rb'/MediaBox \[0 0 ([\d.]+) ([\d.]+)\]', pdf).groups())
assert abs(width - 57 * PT_PER_CM) < 0.01 and abs(height - 10 * PT_PER_CM) < 0.01, (width, height)
print('PASS: paid order generated a 57 x 10 cm print file with both copies')
# The operator approves the generated file as the final one, with no upload,
# and the order can then enter the print queue.
oid = order['id']
result = client.call('/operator/orders/' + oid + '/final-files',
{'version': order['version'], 'files': [{'item_index': 0, 'upload_id': str(row['upload_id'])}],
'note': 'Generated print file checked'}, operator=True)
version = result['version']
for state in ('tra', 'fil'):
client.call('/operator/orders/' + oid + '/move', {'state': state, 'version': version}, operator=True)
version += 1
# Once queued for printing, the final set can no longer change.
client.call('/operator/orders/' + oid + '/final-files',
{'version': version, 'files': [{'item_index': 0, 'upload_id': str(row['upload_id'])}],
'note': 'again'}, operator=True, expected=409)
print('PASS: generated file approved as final without re-uploading; order queued for printing')
# A single-page PDF is placed as a vector form, not rasterised.
pdf_upload = upload_bytes(client, artwork('PDF'), name='LOCAL-PRINT-TEST.pdf')
pdf_order = paid_order(client, loose_item(pdf_upload))
_, pdf_row = wait_print(client, pdf_order['id'], 'ready')
assert pdf_row['detail']['vector_sources'] == 1 and pdf_row['detail']['min_dpi'] is None, pdf_row
link = client.call('/operator/uploads/' + str(pdf_row['upload_id']) + '/download', operator=True)
with urlopen(link['url'], timeout=30) as response:
generated = response.read()
assert b'/Subtype /Form' in generated or b'/Subtype/Form' in generated
print('PASS: PDF artwork generated as a vector print file')
# A customer cannot see or reuse another order's generated file.
other = Client()
other.call('/session')
other.call('/uploads/' + str(row['upload_id']), expected=404)
# Artwork the generator cannot read goes to hand preparation, with a reason.
manual = upload_bytes(client, b'LOCAL PRINT TEST - NOT AN IMAGE', name='LOCAL-PRINT-TEST.cdr')
order = paid_order(client, loose_item(manual, copies=1))
order, row = wait_print(client, order['id'], 'manual')
assert 'não é uma imagem' in row['detail']['reason'], row
rows = client.call('/operator/orders/' + order['id'] + '/print-files', {}, operator=True)
assert rows[0]['status'] == 'pending'
wait_print(client, order['id'], 'manual')
print('PASS: unreadable artwork is routed to hand preparation and can be retried')
# A signed, paid notification for the wrong amount does not become an order;
# it waits on the Kanban until an operator records the resolution.
# Same client, so the same operator session: operator logins share a
# 10-per-15-minutes account limit with every other suite in the run.
quote_id, total = approved_quote(client, loose_item(uid, copies=1))
event_id = 'print-test-underpaid-' + uuid4().hex
outcome = deliver({'event_id': event_id, 'reference': quote_id,
'status': 'approved', 'amount_cents': total - 1})
assert outcome['outcome'].startswith('refused'), outcome
issues = client.call('/operator/payment-events?state=open&limit=100', operator=True)['issues']
issue = next(i for i in issues if i['event_id'] == event_id)
assert client.call('/operator/board', operator=True)['payment_issues_total'] >= 1
client.call('/operator/payment-events/' + issue['id'] + '/resolve', {'note': 'no'},
operator=True, expected=422)
client.call('/operator/payment-events/' + issue['id'] + '/resolve',
{'note': 'Local test: refunded the underpayment'}, operator=True)
client.call('/operator/payment-events/' + issue['id'] + '/resolve',
{'note': 'Local test: second resolution'}, operator=True, expected=404)
issues = client.call('/operator/payment-events?state=open&limit=100', operator=True)['issues']
assert not any(i['event_id'] == event_id for i in issues)
resolved_page = client.call('/operator/payment-events?state=resolved&limit=100', operator=True)
assert resolved_page['total'] >= 1
resolved = resolved_page['issues']
done = next(i for i in resolved if i['event_id'] == event_id)
assert done['resolution'] == 'Local test: refunded the underpayment' and done['resolved_by']
print('PASS: refused paid notification is listed until an operator resolves it, then kept as history')
# The send log pages by id and filters by destination, status, event and order.
page = client.call('/operator/events?limit=2', operator=True)
assert len(page['events']) == 2 and page['total'] > 2
second = client.call('/operator/events?limit=2&offset=2', operator=True)
assert all(e['id'] < page['events'][-1]['id'] for e in second['events'])
assert second['total'] == page['total']
tiny = client.call('/operator/events?provider=tiny&event=payment_approved&status=delivered&limit=100', operator=True)
assert tiny['events'] and all(e['provider'] == 'tiny' and e['payload']['event'] == 'payment_approved'
and e['delivered_at'] for e in tiny['events'])
client.call('/operator/events?provider=email', operator=True, expected=422)
client.call('/operator/events?limit=500', operator=True, expected=422)
quotes = client.call('/operator/quotes?kind=approved&limit=1&offset=0', operator=True)
assert 'total' in quotes and len(quotes['quotes']) <= 1
finished = client.call('/operator/orders/finished?limit=1', operator=True)
if finished['orders']:
last = finished['orders'][-1]
client.call('/operator/orders/finished?before_created_at=' + last['created_at'].replace('+', '%2B')
+ '&before_id=' + last['id'], operator=True)
print('PASS: send log, payment history and finished orders page and filter')
if __name__ == '__main__':
run()

View File

@@ -5,7 +5,7 @@ from botocore.exceptions import ClientError
from app.core.db import connect from app.core.db import connect
from app.adapters import LocalS3Storage from app.adapters import LocalS3Storage
from app.core.auth import client_ip, password_hash, password_matches from app.core.auth import client_ip, password_hash, password_matches
from app.scanning import ClamAV, require_clean from app.scanning import ClamAV, format_matches, require_clean
from fastapi import HTTPException from fastapi import HTTPException
class FakeRequest: class FakeRequest:
@@ -78,7 +78,13 @@ def run():
except HTTPException as error:assert error.status_code==409 except HTTPException as error:assert error.status_code==409
require_clean({'complete':True,'scan_state':'clean'}) require_clean({'complete':True,'scan_state':'clean'})
assert ClamAV().ping() and ClamAV().version().startswith('ClamAV ') assert ClamAV().ping() and ClamAV().version().startswith('ClamAV ')
assert ClamAV().scan(None,134217729)[0]=='rejected' # Above the antivirus limit only a file whose bytes match its name is released.
for name,head,ok in (('folha.png',b'\x89PNG\r\n\x1a\n\x00',True),('folha.png',b'MZ\x90\x00',False),
('folha.jpg',b'\xff\xd8\xff\xe0',True),('folha.pdf',b'%PDF-1.7',True),
('folha.pdf',b'#!/bin/sh',False),('folha.tif',b'II*\x00',True),('folha.psd',b'8BPS',True),
('folha.ai',b'%!PS-Adobe',True),('folha.cdr',b'RIFF\x10\x00\x00\x00CDRv',True),
('folha.cdr',b'RIFF\x10\x00\x00\x00WEBP',False),('folha.exe',b'MZ',False)):
assert format_matches(name,head)==ok,(name,head)
with patch('app.scanning.socket.create_connection',side_effect=OSError('offline')): with patch('app.scanning.socket.create_connection',side_effect=OSError('offline')):
try:ClamAV().scan(None,1);raise AssertionError('Offline scanner returned success') try:ClamAV().scan(None,1);raise AssertionError('Offline scanner returned success')
except OSError:pass except OSError:pass

View File

@@ -29,6 +29,10 @@ def run():
assert script_src == "script-src 'self'", script_src assert script_src == "script-src 'self'", script_src
assert "object-src 'none'" in policy assert "object-src 'none'" in policy
assert 'cdnjs' not in policy, 'pdf.js is vendored; no CDN belongs in the policy' assert 'cdnjs' not in policy, 'pdf.js is vendored; no CDN belongs in the policy'
# Product pages and the cart are addresses of the Site's page, under the same policy.
for page in ('/arquivo-por-metro','/artes-avulsas','/uv-arquivo-por-metro','/uv-artes-avulsas','/carrinho'):
assert raw(page,200)['Content-Security-Policy']==policy,page
raw('/carrinho/outra-coisa',404)
raw('/api/health',400,{'Host':'attacker.invalid'}) 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':'https://attacker.invalid'},b'{}')
raw('/api/account/logout',403,{'Content-Type':'application/json','Origin':'http://localhost:9999'},b'{}') raw('/api/account/logout',403,{'Content-Type':'application/json','Origin':'http://localhost:9999'},b'{}')

View File

@@ -94,11 +94,18 @@ def item_spec(mode, metres, grade, uid):
'quality_status':'unverified' if grade==0 else 'ok', 'quality_status':'unverified' if grade==0 else 'ok',
'quality_acknowledged':False} 'quality_acknowledged':False}
def approved_quote(client, quote, items):
"""The approval a customer pays against: automatic, or by the operator."""
if quote['status']=='approved':
return client.call('/quotes/'+quote['id'])['approved']
return client.call('/operator/quotes/'+quote['id']+'/approve',{'items':items},operator=True)
def run(): def run():
client=Client();other=Client() client=Client();other=Client()
config=client.call('/session');other.call('/session') config=client.call('/session');other.call('/session')
assert client.call('/health')['integrations']=='fake' assert client.call('/health')['integrations']=='fake'
assert 0 < config['max_upload_bytes'] <= 128 * 1024 * 1024 # Sheets of several GB are the normal order: 5 GB per file.
assert config['max_upload_bytes'] == 5 * 1024 ** 3
client.call('/uploads',{'name':'too-large.cdr', client.call('/uploads',{'name':'too-large.cdr',
'size':config['max_upload_bytes']+1},expected=413) 'size':config['max_upload_bytes']+1},expected=413)
cancelled=client.call('/uploads',{'name':'CANCELLED-PART.cdr','size':3})['id'] cancelled=client.call('/uploads',{'name':'CANCELLED-PART.cdr','size':3})['id']
@@ -127,10 +134,19 @@ def run():
except HTTPError as exc:assert exc.code==403 except HTTPError as exc:assert exc.code==403
print('PASS: multipart resume, incomplete rejection, immutable completion, ownership, private/downloaded bytes') print('PASS: multipart resume, incomplete rejection, immutable completion, ownership, private/downloaded bytes')
items=[item_spec(m,'2.75',90,uid) for m in ('file','avulsa','uvfile','uv')] # Above QUOTE_AUTO_MAX_METRES (50 m by default), so a person reviews it.
items=[item_spec(m,'13',90,uid) for m in ('file','avulsa','uvfile','uv')]
draft={'request_key':str(uuid4()),'customer':{'cnpj':'11222333000181','zap':'11999999999','mail':'local-smoke@example.test'}, draft={'request_key':str(uuid4()),'customer':{'cnpj':'11222333000181','zap':'11999999999','mail':'local-smoke@example.test'},
'items':items,'freight':{'service':'mock-standard','postal_code':'14400000'}} 'items':items,'freight':{'service':'mock-standard','postal_code':'14400000'},
'destination':{'recipient':'Local Smoke Ltda','street':'Rua de Teste','number':'100',
'district':'Centro','city':'Franca','state':'SP','postal_code':'14400000'}}
client.call('/quotes',{**draft,'total_cents':1},expected=422) client.call('/quotes',{**draft,'total_cents':1},expected=422)
# Freight quoted by CEP alone cannot ship: the address is required, must be
# the quoted CEP, and a pickup order takes none.
client.call('/quotes',{**draft,'destination':None},expected=422)
client.call('/quotes',{**draft,'destination':{**draft['destination'],'postal_code':'01001000'}},expected=422)
client.call('/quotes',{**draft,'destination':{**draft['destination'],'state':'XX'}},expected=422)
client.call('/quotes',{**draft,'freight':{'service':'pickup'}},expected=422)
client.call('/quotes',{**draft,'items':[{k:v for k,v in items[0].items() if k!='production'}]},expected=422) client.call('/quotes',{**draft,'items':[{k:v for k,v in items[0].items() if k!='production'}]},expected=422)
outside={**items[0],'production':{**items[0]['production'], outside={**items[0],'production':{**items[0]['production'],
'placements':[{**items[0]['production']['placements'][0],'x_cm':1}]}} 'placements':[{**items[0]['production']['placements'][0],'x_cm':1}]}}
@@ -138,8 +154,10 @@ def run():
client.call('/quotes',{**draft,'items':[{**items[0],'metres':'1.00'}]},expected=422) client.call('/quotes',{**draft,'items':[{**items[0],'metres':'1.00'}]},expected=422)
client.call('/quotes',{**draft,'customer':{**draft['customer'],'cnpj':'11111111111111'}},expected=422) client.call('/quotes',{**draft,'customer':{**draft['customer'],'cnpj':'11111111111111'}},expected=422)
quote=client.call('/quotes',draft) quote=client.call('/quotes',draft)
assert quote['status']=='pending_review'
assert client.call('/quotes/'+quote['id'])['review_reason']=='Pedido acima de 50 m'
assert client.call('/quotes',draft)['id']==quote['id'] assert client.call('/quotes',draft)['id']==quote['id']
client.call('/quotes',{**draft,'freight':{'service':'pickup'}},expected=409) client.call('/quotes',{**draft,'freight':{'service':'pickup'},'destination':None},expected=409)
qid=quote['id'] qid=quote['id']
other.call('/quotes/'+qid,expected=404) other.call('/quotes/'+qid,expected=404)
client.call('/orders/dev-paid',{'quote_id':qid},expected=409) client.call('/orders/dev-paid',{'quote_id':qid},expected=409)
@@ -151,8 +169,36 @@ def run():
corrected=[{**items[0],'metres':'1.01','grade':0},*items[1:]] corrected=[{**items[0],'metres':'1.01','grade':0},*items[1:]]
approved=client.call('/operator/quotes/'+qid+'/approve',{'items':corrected},operator=True) approved=client.call('/operator/quotes/'+qid+'/approve',{'items':corrected},operator=True)
assert approved['items'][0]['total_cents']==2189 assert approved['items'][0]['total_cents']==2189
assert approved['total_cents']==2189+6972+19572+23492+int(os.environ.get('MOCK_FREIGHT_CENTS','1500')) assert approved['total_cents']==2189+32370+90870+109070+int(os.environ.get('MOCK_FREIGHT_CENTS','1500'))
assert approved['destination']=={**draft['destination'],'complement':''}
client.call('/operator/quotes/'+qid+'/approve',{'items':items},operator=True,expected=409) client.call('/operator/quotes/'+qid+'/approve',{'items':items},operator=True,expected=409)
assert not client.call('/quotes/'+qid)['auto_approved']
# A cart the Site priced is approved at once and can be paid straight away,
# at the server's own prices; no operator can then change it.
# The server works the grade out from the file: this PNG is 6059 px across
# 57 cm, 270 DPI, which is grade 90.
png=b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR'+(6059).to_bytes(4,'big')+(2000).to_bytes(4,'big')+b'\x08\x06\x00\x00\x00'+b'\x00'*4
graded=upload_bytes(client,png,name='arte-270dpi.png')
small={**draft,'request_key':str(uuid4()),'items':[item_spec('avulsa','2.75',90,graded)]}
auto=client.call('/quotes',small)
assert auto['status']=='approved'
seen=client.call('/quotes/'+auto['id'])
assert seen['auto_approved'] and seen['review_reason'] is None
assert seen['approved']['total_cents']==6972+int(os.environ.get('MOCK_FREIGHT_CENTS','1500'))
assert client.call('/quotes',small)['status']=='approved'
client.call('/operator/quotes/'+auto['id']+'/approve',{'items':small['items']},operator=True,expected=409)
# The Site grades only art it analysed; a discount on unanalysed art waits for a person.
claimed={**item_spec('avulsa','2.75',0,uid),'grade':90}
held=client.call('/quotes',{**small,'request_key':str(uuid4()),'items':[claimed]})
assert held['status']=='pending_review'
assert client.call('/quotes/'+held['id'])['review_reason']=='Nota informada sem análise da arte'
# A better grade than the file supports, or one the server cannot check, waits too.
inflated=client.call('/quotes',{**small,'request_key':str(uuid4()),'items':[item_spec('avulsa','2.75',100,graded)]})
assert inflated['status']=='pending_review'
assert client.call('/quotes/'+inflated['id'])['review_reason']=='Nota 100 maior que a do arquivo (90)'
unchecked=client.call('/quotes',{**small,'request_key':str(uuid4()),'items':[item_spec('avulsa','2.75',90,uid)]})
assert client.call('/quotes/'+unchecked['id'])['review_reason']=='Nota não conferida no servidor'
print('PASS: priced carts are approved at checkout; large or inconsistent ones wait for review')
client.call('/orders/dev-paid',{'quote_id':qid,'total_cents':1},expected=422) client.call('/orders/dev-paid',{'quote_id':qid,'total_cents':1},expected=422)
other.call('/orders/dev-paid',{'quote_id':qid},expected=404) other.call('/orders/dev-paid',{'quote_id':qid},expected=404)
# Concurrent retries must produce precisely one payment/order/outbox pair. # Concurrent retries must produce precisely one payment/order/outbox pair.
@@ -176,14 +222,35 @@ def run():
version+=1;assert moved['version']==version version+=1;assert moved['version']==version
client.call('/operator/orders/'+oid+'/move',{'state':'rec','version':0},operator=True,expected=409) 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 assert len(client.call('/operator/orders/'+oid+'/history',operator=True))==6
# A mistaken move can be undone one stage at a time, with an internal
# reason. The customer is not told again: going back and forward once more
# adds no messages (the outbox count below stays 8).
client.call('/operator/orders/'+oid+'/move',{'state':'imp','version':version},operator=True,expected=422)
client.call('/operator/orders/'+oid+'/move',{'state':'tra','version':version},operator=True,expected=409)
back=client.call('/operator/orders/'+oid+'/move',{'state':'imp','version':version,'reason':'Movido por engano'},operator=True)
assert back['state']=='imp';version+=1
client.call('/operator/orders/'+oid+'/move',{'state':'fin','version':version},operator=True);version+=1
history=client.call('/operator/orders/'+oid+'/history',operator=True)
assert len(history)==8 and history[-2]['back'] and history[-2]['reason']=='Movido por engano' and not history[-1]['back']
# The customer sees the stages and the correction's reason, never the
# internal reason for going back.
seen=client.call('/customer/orders/'+oid)['history']
assert len(seen)==7 and all(h['reason']=='' or h['to_state']=='cor' for h in seen)
assert any(h['to_state']=='cor' and h['reason']=='Local test correction' for h in seen)
print('PASS: a mistaken move is undone one stage back with a reason, without messaging the customer again')
print('PASS: all modes, authoritative review/prices/freight, tamper rejection, concurrent payment idempotency, transitions and history') print('PASS: all modes, authoritative review/prices/freight, tamper rejection, concurrent payment idempotency, transitions and history')
deadline=time.monotonic()+30 deadline=time.monotonic()+30
while time.monotonic()<deadline: while time.monotonic()<deadline:
events=[e for e in client.call('/operator/board',operator=True)['events'] if e['payload']['order_id']==oid] events=[e for e in client.call('/operator/events?order='+str(order['number']),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 if len(events)==8 and all(e['delivered_at'] and e['receipt'] for e in events):break
time.sleep(1) time.sleep(1)
else:raise AssertionError('Mock outbox did not drain') else:raise AssertionError('Mock outbox did not drain')
assert len({e['event_key'] for e in events})==8 assert len({e['event_key'] for e in events})==8
# Tiny sets the pickup situação from the order it is sent; the fake sale
# has no Tiny id, so the real adapter would search for it.
ready={e['provider']:e['payload'] for e in events if e['payload']['event']=='ready'}
assert ready['tiny']['order']['freight']==order['snapshot']['freight'] and 'tiny_id' in ready['tiny']
assert 'order' not in ready['whatsapp']
print(f"PASS: 8 durable fake receipts. Local test order #{order['number']} retained in Finalizado.") print(f"PASS: 8 durable fake receipts. Local test order #{order['number']} retained in Finalizado.")
return oid return oid

86
tests/test_grade_check.py Normal file
View File

@@ -0,0 +1,86 @@
"""The server's grade against real image and PDF files.
Runs where Pillow and pikepdf are installed (the API image).
"""
import io
import tempfile
import unittest
import pikepdf
from PIL import Image
from app import grade_check
class FakeFiles:
def __init__(self, blobs):
self.blobs = blobs
def head(self, row):
return self.blobs[row['name']][:grade_check.HEAD_BYTES]
def pdf_dpi(self, row):
with tempfile.NamedTemporaryFile(suffix='.pdf') as handle:
handle.write(self.blobs[row['name']])
handle.flush()
return grade_check.pdf_dpi(handle.name)
def encoded(fmt, size, **options):
out = io.BytesIO()
Image.new('RGBA' if fmt in ('PNG', 'WEBP') else 'RGB', size, (200, 30, 30)).save(out, fmt, **options)
return out.getvalue()
def item(kind, grade, sources):
return {'grade': grade, 'production': {'sources': [
{'upload_id': name, 'kind': kind, 'width_cm': width, 'rotation_degrees': rotation}
for name, width, rotation in sources]}}
class GradeCheckTests(unittest.TestCase):
def test_image_sizes_from_the_first_bytes(self):
for fmt, options in (('PNG', {}), ('JPEG', {'quality': 80}), ('JPEG', {'progressive': True}),
('WEBP', {'lossless': True}), ('WEBP', {'quality': 80})):
self.assertEqual(grade_check.image_size(encoded(fmt, (1234, 567), **options)), (1234, 567), (fmt, options))
# A JPEG with a large metadata block before the frame header.
exif = Image.Exif()
exif[0x010E] = 'x' * 60000
self.assertEqual(grade_check.image_size(encoded('JPEG', (800, 600), exif=exif)), (800, 600))
self.assertIsNone(grade_check.image_size(b'not an image at all'))
def test_pdf_dpi_is_the_images_area_weighted(self):
out = io.BytesIO()
Image.new('RGB', (1500, 750), 'white').save(out, 'PDF', resolution=150)
with tempfile.NamedTemporaryFile(suffix='.pdf') as handle:
handle.write(out.getvalue()); handle.flush()
self.assertEqual(grade_check.pdf_dpi(handle.name), 150)
vector = pikepdf.new()
vector.add_blank_page(page_size=(1615, 850))
with tempfile.NamedTemporaryFile(suffix='.pdf') as handle:
vector.save(handle.name)
self.assertEqual(grade_check.pdf_dpi(handle.name), 300)
def test_grades_follow_the_site(self):
# 57 cm is 22.44 in: 6059 px is 270 DPI, grade 90; 3366 px is 150 DPI, grade 50.
files = FakeFiles({'a.png': encoded('PNG', (6059, 100)), 'b.jpg': encoded('JPEG', (3366, 100)),
'tall.png': encoded('PNG', (100, 2362)), 'x.cdr': b'CDR'})
rows = {n: {'name': n, 'size': 1} for n in files.blobs}
# A sheet takes the worst sheet's grade.
self.assertEqual(grade_check.item_grade(files, item('sheet', 0, [('a.png', 57, 0)]), rows), 90)
self.assertEqual(grade_check.item_grade(files, item('sheet', 0, [('a.png', 57, 0), ('b.jpg', 57, 0)]), rows), 50)
# Artworks average; a rotated one is measured along its height.
self.assertEqual(grade_check.item_grade(files, item('artwork', 0, [('a.png', 57, 0), ('b.jpg', 57, 0)]), rows), 70)
self.assertEqual(grade_check.item_grade(files, item('artwork', 0, [('tall.png', 20, 90)]), rows), 100)
self.assertIsNone(grade_check.item_grade(files, item('sheet', 0, [('x.cdr', 57, 0)]), rows))
# What the cart claims is checked, with a point or two of rounding.
claim = lambda g, src: grade_check.mismatch(files, [item('sheet', g, src)], rows)
self.assertIsNone(claim(90, [('a.png', 57, 0)]))
self.assertIsNone(claim(92, [('a.png', 57, 0)]))
self.assertEqual(claim(100, [('a.png', 57, 0)]), 'Nota 100 maior que a do arquivo (90)')
self.assertEqual(claim(40, [('x.cdr', 57, 0)]), 'Nota não conferida no servidor')
self.assertIsNone(claim(0, [('x.cdr', 57, 0)]))
if __name__ == '__main__':
unittest.main()

147
tests/test_jadlog.py Normal file
View File

@@ -0,0 +1,147 @@
"""The Jadlog quote client against a fake HTTP transport: payload and errors.
This proves the manual's contract only; app.jadlog_probe must still confirm
it on the client's account. Runs where httpx is installed.
"""
import json
import os
import unittest
from unittest import mock
import httpx
from app.jadlog import QUOTE_URL, JadlogError, JadlogFreight, JadlogQuotes
from app.jadlog_probe import run
CNPJ = '11.222.333/0001-81'
def client(handler, **settings):
return JadlogQuotes(token=settings.pop('token', 'tok-1'), cnpj=CNPJ, conta='123456',
transport=httpx.MockTransport(handler), **settings)
class JadlogQuoteTest(unittest.TestCase):
def test_payload_follows_the_manual_and_price_becomes_centavos(self):
seen = []
def handler(request):
seen.append(request)
item = json.loads(request.content)['frete'][0]
return httpx.Response(200, json={'frete': [{**item, 'vltotal': 23.455, 'prazo': 4}]})
result = client(handler).quote('01310-100', 1.5, 12990)
request = seen[0]
self.assertEqual(str(request.url), QUOTE_URL)
self.assertEqual(request.headers['authorization'], 'tok-1')
item = json.loads(request.content)['frete'][0]
self.assertEqual(item, {'cepori': '14402310', 'cepdes': '01310100', 'frap': 'N', 'peso': 1.5,
'cnpj': '11222333000181', 'conta': '123456', 'contrato': None,
'modalidade': 3, 'tpentrega': 'D', 'tpseguro': 'N',
'vldeclarado': 129.9, 'vlcoleta': 0})
self.assertEqual((result['total_cents'], result['days']), (2346, 4))
def test_contract_is_sent_only_when_configured(self):
def handler(request):
item = json.loads(request.content)['frete'][0]
self.assertEqual(item['contrato'], '042')
return httpx.Response(200, json={'frete': [{'vltotal': 10, 'prazo': 2}]})
client(handler, contrato='042').quote('01310100', 1, 100)
def test_account_is_sent_as_configured(self):
def handler(request):
self.assertEqual(json.loads(request.content)['frete'][0]['conta'], '123456-7')
return httpx.Response(200, json={'frete': [{'vltotal': 10, 'prazo': 2}]})
JadlogQuotes(token='tok', cnpj=CNPJ, conta=' 123456-7 ', transport=httpx.MockTransport(handler)).quote('01310100', 1, 100)
def test_account_and_item_errors_are_raised_with_jadlog_text(self):
replies = [
httpx.Response(200, json={'frete': [{}], 'error': {'id': -1, 'descricao': 'frete[0].contrato Numero de contrato invalido'}}),
httpx.Response(200, json={'frete': [{'erro': {'id': 2, 'descricao': 'CEP destino invalido'}}]}),
httpx.Response(200, json={'frete': [{'prazo': 3}]}),
httpx.Response(502, text='<html>bad gateway</html>'),
]
messages = ['contrato invalido', 'CEP destino invalido', 'no freight value', 'not JSON']
for reply, message in zip(replies, messages):
with self.subTest(message=message), self.assertRaisesRegex(JadlogError, message):
client(lambda request, reply=reply: reply).quote('01310100', 1, 100)
def test_missing_credentials_refuse_to_start(self):
with self.assertRaises(RuntimeError):
JadlogQuotes(token='', cnpj=CNPJ)
with self.assertRaises(RuntimeError):
JadlogQuotes(token='tok', cnpj='123')
class JadlogFreightTest(unittest.TestCase):
WEIGHTS = {'JADLOG_PESO_BASE_KG': '0.2', 'JADLOG_PESO_POR_METRO_KG': '0.15', 'FREIGHT_PRODUCTION_DAYS': '2'}
def freight(self, handler, **env):
with mock.patch.dict(os.environ, {**self.WEIGHTS, **env}):
return JadlogFreight(client(handler))
def test_package_weight_value_and_days(self):
seen = []
def handler(request):
seen.append(json.loads(request.content)['frete'][0])
return httpx.Response(200, json={'frete': [{'vltotal': 18.4, 'prazo': 3}]})
quote = self.freight(handler).quote('jadlog', '01310100', '2.8', 6972)
# 0.2 kg of packaging plus 0.15 kg for each of the 2.8 billed metres.
self.assertEqual((seen[0]['peso'], seen[0]['vldeclarado'], seen[0]['cepdes']), (0.62, 69.72, '01310100'))
self.assertEqual((quote['total_cents'], quote['days'], quote['service']), (1840, 5, 'jadlog'))
def test_pickup_is_free_and_bad_requests_are_refused(self):
freight = self.freight(lambda request: httpx.Response(500))
self.assertEqual(freight.quote('pickup', '')['total_cents'], 0)
for args in (('mock-standard', '01310100', '1'), ('jadlog', '0131', '1'), ('jadlog', '01310100', None)):
with self.subTest(args=args), self.assertRaises(ValueError):
freight.quote(*args)
def test_a_jadlog_failure_is_a_clear_refusal(self):
freight = self.freight(lambda request: httpx.Response(200, json={'frete': [{}], 'error': {'id': -1, 'descricao': 'CEP destino invalido'}}))
with self.assertRaisesRegex(ValueError, 'CEP destino invalido'):
freight.quote('jadlog', '01310100', '1', 100)
def test_the_weight_has_no_default(self):
with mock.patch.dict(os.environ, {}, clear=True), self.assertRaises(KeyError):
JadlogFreight(client(lambda request: httpx.Response(200)))
with self.assertRaises(RuntimeError):
self.freight(lambda request: httpx.Response(200), JADLOG_PESO_POR_METRO_KG='0')
class JadlogProbeTest(unittest.TestCase):
def test_bearer_is_tried_once_after_a_401_and_kept(self):
headers = []
def handler(request):
headers.append(request.headers['authorization'])
if not request.headers['authorization'].startswith('Bearer '):
return httpx.Response(401, json={'descricao': 'TOKEN INVALIDO', 'id': 1})
return httpx.Response(200, json={'frete': [{'vltotal': 12.5, 'prazo': 3}]})
lines = []
status = run(client(handler), ['01310100', '20040002'], ['1'], 10000, lines.append)
self.assertEqual(status, 0)
self.assertEqual(headers, ['tok-1', 'Bearer tok-1', 'Bearer tok-1'])
self.assertIn('Bearer', lines[0])
self.assertIn('R$ 12,50', lines[1])
def test_first_failure_stops_the_run(self):
calls = []
def handler(request):
calls.append(request)
return httpx.Response(200, json={'frete': [{}], 'error': {'id': -1, 'descricao': 'CNPJ invalido'}})
lines = []
self.assertEqual(run(client(handler), ['01310100', '20040002'], ['1', '2'], 10000, lines.append), 1)
self.assertEqual(len(calls), 1)
self.assertIn('CNPJ invalido', lines[0])
if __name__ == '__main__':
unittest.main()

42
tests/test_large_files.py Normal file
View File

@@ -0,0 +1,42 @@
"""Large sheets: when the original is its own print file, and the format check."""
import unittest
from app.printjobs import whole_sheet
from app.scanning import format_matches
ROW = {'id': 'u1', 'name': 'folha.png', 'size': 3 * 1024 ** 3, 'scan_state': 'clean',
'purged_at': None, 'expired': False}
def sheet(**changes):
source = {'kind': 'sheet', 'width_cm': 57, 'length_cm': 500, 'copies': 1}
place = {'x_cm': 0, 'y_cm': 0, 'rotation_degrees': 0, 'mirrored': False}
for key, value in changes.items():
(source if key in source else place)[key] = value
return {'uploads': ['u1'], 'production': {'film_width_cm': 57, 'sources': [source], 'placements': [place]}}
class WholeSheetTest(unittest.TestCase):
def test_a_finished_sheet_placed_whole_is_its_own_print_file(self):
self.assertIs(whole_sheet(sheet(), {'u1': ROW}), ROW)
def test_anything_else_is_prepared_by_hand(self):
for changes in ({'copies': 2}, {'kind': 'artwork'}, {'rotation_degrees': 90}, {'mirrored': True},
{'x_cm': 1}, {'width_cm': 50}):
with self.subTest(changes=changes):
self.assertIsNone(whole_sheet(sheet(**changes), {'u1': ROW}))
self.assertIsNone(whole_sheet(sheet(), {'u1': {**ROW, 'name': 'folha.cdr'}}))
self.assertIsNone(whole_sheet(sheet(), {'u1': {**ROW, 'scan_state': 'pending'}}))
self.assertIsNone(whole_sheet(sheet(), {'u1': {**ROW, 'expired': True}}))
class FormatTest(unittest.TestCase):
def test_bytes_must_match_the_name(self):
self.assertTrue(format_matches('A.PNG', b'\x89PNG\r\n\x1a\n'))
self.assertTrue(format_matches('a.tiff', b'MM\x00*'))
self.assertFalse(format_matches('a.png', b'%PDF-1.4'))
self.assertFalse(format_matches('semextensao', b'\x89PNG\r\n\x1a\n'))
if __name__ == '__main__':
unittest.main()

170
tests/test_mercadopago.py Normal file
View File

@@ -0,0 +1,170 @@
"""The Mercado Pago adapter against a fake HTTP transport.
This proves the adapter follows the documented contract. It does not prove the
integration: that needs the sandbox flows with the client's own account.
Runs where httpx is installed (the API image, or a local virtualenv).
"""
import hashlib
import hmac
import json
import unittest
from datetime import datetime, timezone
import httpx
from app.mercadopago import MercadoPagoPayment, event_from_payment
SECRET = 'test-webhook-secret'
NOW = 1_790_000_000
def signature(data_id, request_id, ts, secret=SECRET):
manifest = ''
if data_id:
manifest += f'id:{data_id};'
if request_id:
manifest += f'request-id:{request_id};'
manifest += f'ts:{ts};'
return f'ts={ts},v1=' + hmac.new(secret.encode(), manifest.encode(), hashlib.sha256).hexdigest()
class MercadoPagoTests(unittest.TestCase):
def setUp(self):
self.requests = []
self.payments = {}
def handler(request):
self.requests.append(request)
if request.method == 'GET':
payment_id = request.url.path.rsplit('/', 1)[-1]
if payment_id == '500':
return httpx.Response(500, json={'message': 'internal_error'})
if payment_id not in self.payments:
return httpx.Response(404, json={'message': 'Payment not found'})
return httpx.Response(200, json=self.payments[payment_id])
body = json.loads(request.content)
payment = {'id': 555, 'status': 'pending', 'status_detail': 'pending_waiting_transfer',
'point_of_interaction': {'transaction_data': {
'qr_code': '000201PIX', 'qr_code_base64': 'aW1n', 'ticket_url': 'https://mp/t'}},
**{k: body[k] for k in ('transaction_amount', 'external_reference')}}
return httpx.Response(201, json=payment)
self.mp = MercadoPagoPayment('TEST-token', SECRET, 'https://dtf.example/api/payments/webhook',
transport=httpx.MockTransport(handler), clock=lambda: NOW)
def test_signature_follows_the_documented_manifest(self):
headers = {'x-signature': signature('123456', 'req-1', NOW), 'x-request-id': 'req-1'}
self.assertTrue(self.mp.verify(headers, b'{}', {'data.id': '123456'}))
# Any change to the signed values breaks it.
self.assertFalse(self.mp.verify(headers, b'{}', {'data.id': '123457'}))
self.assertFalse(self.mp.verify({**headers, 'x-request-id': 'req-2'}, b'{}', {'data.id': '123456'}))
self.assertFalse(self.mp.verify({'x-signature': signature('123456', 'req-1', NOW, 'other'),
'x-request-id': 'req-1'}, b'{}', {'data.id': '123456'}))
self.assertFalse(self.mp.verify({}, b'{}', {'data.id': '123456'}))
def test_absent_values_are_left_out_and_alphanumeric_ids_lowercased(self):
self.assertTrue(self.mp.verify({'x-signature': signature('abc123', None, NOW)}, b'{}',
{'data.id': 'ABC123'}))
def test_old_signatures_are_refused(self):
old = NOW - 3600
self.assertFalse(self.mp.verify({'x-signature': signature('1', 'r', old), 'x-request-id': 'r'},
b'{}', {'data.id': '1'}))
def test_notification_is_only_a_pointer(self):
# The body claims nothing about amount or status; the API is asked.
self.payments['999'] = {'id': 999, 'status': 'approved', 'currency_id': 'BRL',
'transaction_amount': 123.45, 'external_reference': 'quote-1'}
body = json.dumps({'id': 42, 'type': 'payment', 'action': 'payment.updated',
'data': {'id': '999'}}).encode()
event = self.mp.parse(body, {'data.id': '999', 'type': 'payment'})
self.assertEqual((event.status, event.amount_cents, event.reference), ('approved', 12345, 'quote-1'))
self.assertEqual(event.event_id, '999:approved')
self.assertEqual(self.requests[-1].headers['authorization'], 'Bearer TEST-token')
self.assertIsNone(self.mp.parse(json.dumps({'type': 'merchant_order', 'data': {'id': '1'}}).encode()))
def test_notification_for_an_unknown_payment_is_acknowledged(self):
# The panel's "Simular notificação" sends a payment id that does not
# exist. Raising would answer 500 and Mercado Pago would retry for ever.
body = json.dumps({'id': 43, 'type': 'payment', 'data': {'id': '123456'}}).encode()
self.assertIsNone(self.mp.parse(body, {'data.id': '123456', 'type': 'payment'}))
# Any other failure still raises, so a real notification is retried.
with self.assertRaises(httpx.HTTPStatusError):
self.mp.parse(json.dumps({'type': 'payment', 'data': {'id': '500'}}).encode(), {'data.id': '500'})
def test_amounts_outside_brl_centavos_are_not_trusted(self):
for payment in ({'currency_id': 'USD', 'transaction_amount': 10},
{'currency_id': 'BRL', 'transaction_amount': 10.001},
{'currency_id': 'BRL', 'transaction_amount': None}):
self.assertIsNone(event_from_payment({'id': 1, 'status': 'approved', **payment}).amount_cents)
self.assertEqual(event_from_payment({'id': 1, 'status': 'approved', 'currency_id': 'BRL',
'transaction_amount': 0.1}).amount_cents, 10)
def test_statuses_map_to_the_service_vocabulary(self):
for provider, ours in (('in_process', 'pending'), ('charged_back', 'refunded'),
('cancelled', 'cancelled'), ('rejected', 'rejected')):
self.assertEqual(event_from_payment({'id': 1, 'status': provider}).status, ours)
def test_pix_payment_is_idempotent_on_the_quote(self):
created = self.mp.create('11111111-2222-3333-4444-555555555555', 12345,
{'mail': 'a@example.test', 'cnpj': '11222333000181'})
request = self.requests[-1]
body = json.loads(request.content)
self.assertEqual(request.headers['x-idempotency-key'],
'dtf-quote-11111111-2222-3333-4444-555555555555-pix-1')
self.assertEqual((body['payment_method_id'], body['transaction_amount']), ('pix', 123.45))
self.assertEqual(body['external_reference'], '11111111-2222-3333-4444-555555555555')
self.assertEqual(body['notification_url'], 'https://dtf.example/api/payments/webhook')
self.assertEqual((created['pix_qr_code'], created['status']), ('000201PIX', 'pending'))
# The code expires in 30 minutes, in the format Mercado Pago documents.
expires = datetime.fromisoformat(body['date_of_expiration'])
self.assertRegex(body['date_of_expiration'], r'^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}-03:00$')
self.assertAlmostEqual((expires - datetime.now(timezone.utc)).total_seconds(), 1800, delta=60)
self.assertEqual(created['expires_at'], body['date_of_expiration'])
# A new code after the last expired is the next attempt, not the same payment.
self.mp.create('11111111-2222-3333-4444-555555555555', 12345,
{'mail': 'a@example.test', 'cnpj': '11222333000181'}, {'type': 'pix', 'attempt': 2})
self.assertTrue(self.requests[-1].headers['x-idempotency-key'].endswith('-pix-2'))
def test_card_payment_uses_the_browser_token_only(self):
self.mp.create('q', 1000, {'mail': 'a@example.test', 'cnpj': '11222333000181'},
{'type': 'card', 'token': 'tok_abc', 'payment_method_id': 'visa', 'installments': 3})
request = self.requests[-1]
body = json.loads(request.content)
self.assertEqual((body['token'], body['payment_method_id'], body['installments']), ('tok_abc', 'visa', 3))
self.assertNotIn('card_number', json.dumps(body))
# A new card attempt after a decline must not collide with the first.
first_key = request.headers['x-idempotency-key']
self.mp.create('q', 1000, {'mail': 'a@example.test', 'cnpj': '11222333000181'},
{'type': 'card', 'token': 'tok_def', 'payment_method_id': 'visa'})
self.assertNotEqual(self.requests[-1].headers['x-idempotency-key'], first_key)
self.assertTrue(first_key.startswith('dtf-quote-q-card-'))
# 3-D Secure is asked of debit only; the cardholder is the payer.
self.assertNotIn('three_d_secure_mode', body)
self.mp.create('q', 1000, {'mail': 'a@example.test', 'cnpj': '11222333000181'},
{'type': 'card', 'token': 'tok_deb', 'payment_method_id': 'debmaster',
'payer_document_type': 'CPF', 'payer_document': '12345678909'})
debit = json.loads(self.requests[-1].content)
self.assertEqual(debit['three_d_secure_mode'], 'optional')
self.assertEqual(debit['payer']['identification'], {'type': 'CPF', 'number': '12345678909'})
self.assertEqual(body['payer']['identification'], {'type': 'CNPJ', 'number': '11222333000181'})
self.mp.create('q', 1000, {'mail': 'a@example.test', 'cnpj': '11222333000181'},
{'type': 'card', 'token': 'tok_iss', 'payment_method_id': 'master', 'issuer_id': '24'})
self.assertNotIn('issuer_id', json.loads(self.requests[-1].content))
self.assertIsNone(self.mp.create('q', 1000, {'mail': 'a@example.test', 'cnpj': '11222333000181'},
{'type': 'card', 'token': 'tok_ghi', 'payment_method_id': 'visa'})['challenge'])
def test_a_card_the_bank_must_confirm_returns_its_challenge(self):
def handler(request):
return httpx.Response(201, json={'id': 777, 'status': 'pending', 'status_detail': 'pending_challenge',
'three_ds_info': {'external_resource_url': 'https://acs.bank.example/challenge',
'creq': 'eyJjcmVxIjoiMSJ9'}})
mp = MercadoPagoPayment('TEST-token', SECRET, transport=httpx.MockTransport(handler), clock=lambda: NOW)
created = mp.create('q', 1000, {'mail': 'a@example.test', 'cnpj': '11222333000181'},
{'type': 'card', 'token': 'tok_debit', 'payment_method_id': 'debvisa'})
self.assertEqual((created['status'], created['status_detail']), ('pending', 'pending_challenge'))
self.assertEqual(created['challenge'], {'url': 'https://acs.bank.example/challenge', 'creq': 'eyJjcmVxIjoiMSJ9'})
if __name__ == '__main__':
unittest.main()

297
tests/test_printfile.py Normal file
View File

@@ -0,0 +1,297 @@
"""The print file must reproduce the reviewed layout: size, place, turn and mirror.
Runs where Pillow is installed (the API image, or a local virtualenv). The
raster checks additionally need PyMuPDF to draw the page, and skip without it;
the structural checks do not.
"""
import io
import os
import re
import tempfile
import unittest
import zlib
from PIL import Image
from app.printfile import PT_PER_CM, Unsupported, placement_matrix, render
try:
import fitz # PyMuPDF, a development-only rasteriser
except ImportError:
fitz = None
RED, GREEN, BLUE, YELLOW = (255, 0, 0), (0, 160, 0), (0, 0, 255), (255, 220, 0)
def quadrants(width=80, height=40, alpha=False):
"""Top-left red, top-right green, bottom-left blue, bottom-right yellow."""
image = Image.new('RGBA' if alpha else 'RGB', (width, height))
for x in range(width):
for y in range(height):
left, top = x < width // 2, y < height // 2
color = RED if left and top else GREEN if top else BLUE if left else YELLOW
image.putpixel((x, y), color + ((255,) if alpha else ()))
return image
def item(placements, sources, height_cm, billed='1.0', film=57):
return {'mode': 'avulsa', 'billed_metres': billed,
'production': {'version': 2, 'film_width_cm': film, 'height_cm': height_cm,
'sources': sources, 'placements': placements}}
def source(width_cm, length_cm, copies=1, rotation=0, mirrored=False):
return {'upload_id': '00000000-0000-0000-0000-000000000000', 'kind': 'artwork',
'width_cm': width_cm, 'length_cm': length_cm, 'copies': copies,
'rotation_degrees': rotation, 'mirrored': mirrored, 'measurement': 'file'}
def placement(x, y, width, length, rotation=0, mirrored=False, index=0, copy=0):
return {'source_index': index, 'copy_index': copy, 'x_cm': x, 'y_cm': y,
'width_cm': width, 'length_cm': length,
'rotation_degrees': rotation, 'mirrored': mirrored}
class PrintFileTests(unittest.TestCase):
def setUp(self):
self.dir = tempfile.TemporaryDirectory()
self.addCleanup(self.dir.cleanup)
def save(self, image, name, **options):
path = os.path.join(self.dir.name, name)
image.save(path, **options)
return path
def render(self, spec, paths):
out = io.BytesIO()
detail = render(spec, {i: (p, os.path.basename(p)) for i, p in enumerate(paths)}, out, 'test')
return out.getvalue(), detail
def test_page_is_the_film_and_the_layout_length(self):
path = self.save(quadrants(), 'a.png')
pdf, detail = self.render(item([placement(0, 0, 20, 10), placement(20, 0, 20, 10, copy=1)],
[source(20, 10, copies=2)], 10), [path])
box = re.search(rb'/MediaBox \[0 0 ([\d.]+) ([\d.]+)\]', pdf)
self.assertAlmostEqual(float(box.group(1)), 57 * PT_PER_CM, places=2)
self.assertAlmostEqual(float(box.group(2)), 10 * PT_PER_CM, places=2)
# One embedded image, drawn once per copy.
self.assertEqual(pdf.count(b'/Subtype /Image'), 1)
content = self.content(pdf)
self.assertEqual(content.count(b' Do '), 2)
self.assertEqual(detail['placements'], 2)
self.assertEqual(detail['min_dpi'], round(80 / (20 / 2.54)))
self.assertTrue(pdf.startswith(b'%PDF-1.6') and pdf.rstrip().endswith(b'%%EOF'))
def content(self, pdf):
# The page content is the last Flate stream before the page object.
streams = re.findall(rb'/Filter /FlateDecode/Length \d+ 0 R>>\nstream\n(.*?)\nendstream', pdf, re.S)
return zlib.decompress(streams[-1])
def test_jpeg_bytes_are_embedded_unchanged(self):
path = self.save(quadrants(), 'a.jpg', quality=90)
pdf, _ = self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [path])
with open(path, 'rb') as original:
self.assertIn(original.read(), pdf)
self.assertIn(b'/DCTDecode', pdf)
def test_transparency_survives_as_a_soft_mask(self):
image = quadrants(alpha=True)
image.putpixel((0, 0), (0, 0, 0, 0))
path = self.save(image, 'a.png')
pdf, _ = self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [path])
self.assertIn(b'/SMask', pdf)
self.assertEqual(pdf.count(b'/Subtype /Image'), 2)
def test_exif_orientation_is_honoured_like_a_browser(self):
# Stored landscape, tagged "rotate 90": browsers show it portrait.
image = quadrants(80, 40)
exif = Image.Exif()
exif[0x0112] = 6
path = self.save(image, 'a.jpg', exif=exif)
pdf, _ = self.render(item([placement(0, 0, 10, 20)], [source(10, 20)], 20), [path])
self.assertNotIn(b'/DCTDecode', pdf)
with self.assertRaises(Unsupported):
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [path])
def test_refuses_what_it_cannot_reproduce(self):
png = self.save(quadrants(), 'a.png')
with self.assertRaisesRegex(Unsupported, 'proporções'):
self.render(item([placement(0, 0, 20, 20)], [source(20, 20)], 20), [png])
with self.assertRaisesRegex(Unsupported, 'cobrados'):
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 150, billed='1.0'), [png])
fake_pdf = os.path.join(self.dir.name, 'art.pdf')
with open(fake_pdf, 'wb') as handle:
handle.write(b'%PDF-1.4\n%%EOF\n')
with self.assertRaisesRegex(Unsupported, 'não é um PDF'):
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [fake_pdf])
cdr = os.path.join(self.dir.name, 'art.cdr')
with open(cdr, 'wb') as handle:
handle.write(b'not artwork')
with self.assertRaisesRegex(Unsupported, 'não é uma imagem'):
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [cdr])
def test_long_layouts_scale_user_space_instead_of_splitting(self):
# 6 m is longer than a PDF page may be (about 5.08 m).
path = self.save(quadrants(40, 800), 'a.png')
pdf, detail = self.render(item([placement(0, 0, 30, 600)], [source(30, 600)], 600,
billed='6.0'), [path])
self.assertEqual(detail['user_unit'], 2)
self.assertIn(b'/UserUnit 2', pdf)
def test_matrix_maps_corners_like_the_canvas(self):
# Box 20 x 10 cm at the page's top-left; which image corner lands where.
page = 10 * PT_PER_CM
def corner(matrix, u, v):
a, b, c, d, e, f = matrix
x, y = a * u + c * v + e, b * u + d * v + f
return round(x / PT_PER_CM, 6), round((page - y) / PT_PER_CM, 6)
# Image top-left is unit (0, 1).
cases = {(0, False): (0, 0), (90, False): (20, 0), (180, False): (20, 10),
(270, False): (0, 10), (0, True): (20, 0), (90, True): (20, 10)}
for (rotation, mirrored), expected in cases.items():
matrix = placement_matrix(placement(0, 0, 20, 10, rotation, mirrored), page)
self.assertEqual(corner(matrix, 0, 1), expected, (rotation, mirrored))
@unittest.skipIf(fitz is None, 'PyMuPDF is not installed')
def test_drawn_page_matches_the_layout(self):
# Each case: the colour expected in the box's TL, TR, BL, BR quadrant.
cases = {
(0, False): (RED, GREEN, BLUE, YELLOW),
(90, False): (BLUE, RED, YELLOW, GREEN),
(180, False): (YELLOW, BLUE, GREEN, RED),
(0, True): (GREEN, RED, YELLOW, BLUE),
(90, True): (YELLOW, GREEN, BLUE, RED),
}
image = quadrants(80, 40)
for (rotation, mirrored), expected in cases.items():
turned = rotation % 180 == 90
width, length = (10, 20) if turned else (20, 10)
path = self.save(image, f'q{rotation}{mirrored}.png')
# Sources are described after the customer's turn, like the box.
spec = item([placement(12, 5, width, length, rotation, mirrored)],
[source(width, length, rotation=rotation % 180, mirrored=mirrored)], 30)
pdf, _ = self.render(spec, [path])
page = fitz.open(stream=pdf, filetype='pdf')[0]
dpi = 40
pixmap = page.get_pixmap(dpi=dpi, alpha=False)
def sample(x_cm, y_cm):
px = int(x_cm / 2.54 * dpi)
py = int(y_cm / 2.54 * dpi)
return pixmap.pixel(px, py)
got = (sample(12 + width * .25, 5 + length * .25), sample(12 + width * .75, 5 + length * .25),
sample(12 + width * .25, 5 + length * .75), sample(12 + width * .75, 5 + length * .75))
for actual, wanted in zip(got, expected):
self.assertTrue(all(abs(a - w) < 40 for a, w in zip(actual, wanted)),
f'rotation {rotation} mirrored {mirrored}: {got} != {expected}')
# Outside the box the film stays empty.
self.assertEqual(sample(2, 2), (255, 255, 255))
class PdfSourceTests(unittest.TestCase):
"""A customer PDF is placed as a vector form, sized and turned like an image."""
def setUp(self):
self.dir = tempfile.TemporaryDirectory()
self.addCleanup(self.dir.cleanup)
def pdf(self, name, image=None, rotate=None, crop=None, pages=1, password=None):
import pikepdf
path = os.path.join(self.dir.name, name)
image = image or quadrants(80, 40)
image.save(path, 'PDF', resolution=72, save_all=pages > 1,
append_images=[image] * (pages - 1))
if rotate is not None or crop or password:
with pikepdf.open(path, allow_overwriting_input=True) as document:
if rotate is not None:
document.Root.Pages.Rotate = rotate # inherited, not on the page
if crop:
document.pages[0].obj.CropBox = pikepdf.Array(crop)
encryption = pikepdf.Encryption(owner=password, user=password) if password else None
document.save(path, encryption=encryption or False)
return path
def render(self, spec, paths):
out = io.BytesIO()
detail = render(spec, {i: (p, os.path.basename(p)) for i, p in enumerate(paths)}, out, 'test')
return out.getvalue(), detail
def test_pdf_page_is_a_vector_form_placed_per_copy(self):
path = self.pdf('sheet.pdf')
pdf, detail = self.render(item([placement(0, 0, 20, 10), placement(20, 0, 20, 10, copy=1)],
[source(20, 10, copies=2)], 10), [path])
self.assertTrue(pdf.startswith(b'%PDF-1.6'))
self.assertEqual(detail['vector_sources'], 1)
self.assertIsNone(detail['min_dpi'])
import pikepdf
with pikepdf.open(io.BytesIO(pdf)) as document:
page = document.pages[0]
forms = [x for x in page.Resources.XObject.values() if x.Subtype == '/Form']
self.assertEqual(len(forms), 1)
self.assertAlmostEqual(float(page.mediabox[2]), 57 * PT_PER_CM, places=2)
content = b''.join(s.read_bytes() for s in page.obj.Contents) if isinstance(page.obj.Contents, pikepdf.Array) else page.obj.Contents.read_bytes()
self.assertEqual(content.count(b'/Pdf0 Do'), 2)
def test_mixed_raster_and_pdf_sources(self):
png = os.path.join(self.dir.name, 'a.png')
quadrants(80, 40).save(png)
pdf_path = self.pdf('b.pdf')
spec = item([placement(0, 0, 20, 10), placement(0, 0, 20, 10, index=1)],
[source(20, 10), source(20, 10)], 10)
spec['production']['placements'][1]['x_cm'] = 25
pdf, detail = self.render(spec, [png, pdf_path])
self.assertEqual((detail['sources'], detail['vector_sources']), (2, 1))
self.assertEqual(detail['min_dpi'], round(80 / (20 / 2.54)))
def test_refuses_pdfs_it_cannot_place(self):
with self.assertRaisesRegex(Unsupported, '2 páginas'):
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [self.pdf('two.pdf', pages=2)])
with self.assertRaisesRegex(Unsupported, 'senha'):
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10),
[self.pdf('locked.pdf', password='secret')])
with self.assertRaisesRegex(Unsupported, 'proporções'):
self.render(item([placement(0, 0, 20, 20)], [source(20, 20)], 20), [self.pdf('square.pdf')])
@unittest.skipIf(fitz is None, 'PyMuPDF is not installed')
def test_drawn_pdf_matches_what_the_site_measured(self):
framed = Image.new('RGB', (100, 60), (255, 255, 255))
framed.paste(quadrants(80, 40), (10, 10))
cases = {
# (page /Rotate, placement rotation, mirrored) -> TL, TR, BL, BR as drawn
'plain': (self.pdf('plain.pdf'), 0, False, (RED, GREEN, BLUE, YELLOW)),
# CropBox trims the white frame, exactly as pdf.js shows the page.
'cropped': (self.pdf('crop.pdf', image=framed, crop=[10, 10, 90, 50]), 0, False,
(RED, GREEN, BLUE, YELLOW)),
# An inherited /Rotate 90 is displayed turned clockwise.
'rotated page': (self.pdf('rot.pdf', rotate=90), 0, False, (BLUE, RED, YELLOW, GREEN)),
'placement turn': (self.pdf('turn.pdf'), 90, False, (BLUE, RED, YELLOW, GREEN)),
'mirrored': (self.pdf('mirror.pdf'), 0, True, (GREEN, RED, YELLOW, BLUE)),
}
for label, (path, turn, mirrored, expected) in cases.items():
displayed_portrait = label == 'rotated page'
portrait = displayed_portrait != (turn == 90)
width, length = (10, 20) if portrait else (20, 10)
src = (10, 20) if displayed_portrait else (20, 10)
spec = item([placement(12, 5, width, length, turn, mirrored)],
[source(*src, rotation=0, mirrored=mirrored)], 30)
pdf, _ = self.render(spec, [path])
page = fitz.open(stream=pdf, filetype='pdf')[0]
dpi = 40
pixmap = page.get_pixmap(dpi=dpi, alpha=False)
def sample(x_cm, y_cm):
return pixmap.pixel(int(x_cm / 2.54 * dpi), int(y_cm / 2.54 * dpi))
got = (sample(12 + width * .25, 5 + length * .25), sample(12 + width * .75, 5 + length * .25),
sample(12 + width * .25, 5 + length * .75), sample(12 + width * .75, 5 + length * .75))
for actual, wanted in zip(got, expected):
self.assertTrue(all(abs(a - w) < 40 for a, w in zip(actual, wanted)),
f'{label}: {got} != {expected}')
self.assertEqual(sample(2, 2), (255, 255, 255), label)
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,38 @@
"""Which quotes the Site approves at checkout and which wait for a person."""
import os
import unittest
from unittest import mock
from app.quote_review import review_reason
def item(mode='avulsa', metres='2', grade=90, quality='ok', version=2):
return {'mode': mode, 'metres': metres, 'grade': grade, 'quality_status': quality,
'quality_acknowledged': quality == 'warning', 'production': {'version': version}}
class ReviewReasonTest(unittest.TestCase):
def reason(self, *items, **env):
with mock.patch.dict(os.environ, env):
return review_reason({'items': list(items)})
def test_a_priced_cart_is_approved(self):
self.assertIsNone(self.reason(item()))
# Accepted resolution warnings and unanalysed art at the full rate too.
self.assertIsNone(self.reason(item(quality='warning'), item(grade=0, quality='unverified')))
def test_large_orders_wait_for_review(self):
self.assertIsNone(self.reason(item(metres='30'), item(metres='20')))
self.assertEqual(self.reason(item(metres='30'), item(metres='20.1')), 'Pedido acima de 50 m')
self.assertEqual(self.reason(item(metres='6'), QUOTE_AUTO_MAX_METRES='5'), 'Pedido acima de 5 m')
def test_a_discount_the_site_could_not_have_given_waits(self):
self.assertEqual(self.reason(item(grade=90, quality='unverified')), 'Nota informada sem análise da arte')
def test_old_layouts_and_switching_it_off(self):
self.assertEqual(self.reason(item(version=1)), 'Montagem antiga')
self.assertEqual(self.reason(item(), QUOTE_AUTO_APPROVE='false'), 'Aprovação automática desligada')
if __name__ == '__main__':
unittest.main()

296
tests/test_tiny.py Normal file
View File

@@ -0,0 +1,296 @@
"""The Tiny v3 adapter against a fake HTTP transport: payload and idempotency.
This proves the documented contract only; the client's account must still
confirm products, contacts and order fields. Runs where httpx is installed.
The OAuth connection against the real database is tests/tiny_oauth_test.py.
"""
import json
import os
import unittest
from datetime import date
from unittest import mock
import httpx
from app.tiny import TinyError, TinyOrders, contact_payload, order_payload
PAID = {'order_id': 'b6f1c0de-0000-4000-8000-000000000001', 'number': 42, 'event': 'payment_approved',
'order': {'customer': {'cnpj': '11222333000181', 'zap': '16999999999', 'mail': 'loja@example.test'},
'items': [{'mode': 'avulsa', 'grade': 90, 'billed_metres': '2.8', 'unit_cents': 2490}],
'freight': {'service': 'mock-standard', 'total_cents': 1500},
'destination': {'recipient': 'Loja Teste', 'street': 'Rua A', 'number': '10',
'complement': '', 'district': 'Centro', 'city': 'Franca',
'state': 'SP', 'postal_code': '14400000'},
'total_cents': 8472}}
PRODUCTS = {'TINY_PRODUCT_TEXTIL_FOLHA': '101', 'TINY_PRODUCT_TEXTIL_AVULSA': '102',
'TINY_PRODUCT_UV_FOLHA': '103', 'TINY_PRODUCT_UV_AVULSA': '104', 'TINY_FORMA_ENVIO_RETIRADA': '77'}
PICKUP = {**PAID, 'order': {**PAID['order'], 'destination': None, 'freight': {'service': 'pickup', 'total_cents': 0}}}
READY = {'order_id': PAID['order_id'], 'number': 42, 'event': 'ready', 'reason': '', 'order': PICKUP['order']}
class FakeAuth:
def access_token(self):
return 'access-1'
class FakeTinyCase(unittest.TestCase):
"""A Tiny account in memory: contacts, orders, products and formas de envio."""
def setUp(self):
self.contacts = []
self.orders = []
self.calls = []
self.refuse_status = 0
self.methods = {'77': {'id': 77, 'nome': 'Retirar pessoalmente', 'tipo': '6'},
'78': {'id': 78, 'nome': 'Correios', 'tipo': '1'}}
self.products = {value: {'id': int(value), 'sku': f'DTF-{value}', 'descricao': f'Produto {value}',
'situacao': 'A', 'precos': {'preco': 14.9}} for value in PRODUCTS.values()}
def handler(request):
path = request.url.path.removeprefix('/public-api/v3')
self.calls.append((request.method, path))
assert request.headers['authorization'] == 'Bearer access-1'
if request.method == 'GET' and path == '/contatos':
cnpj = request.url.params.get('cpfCnpj')
return httpx.Response(200, json={'itens': [c for c in self.contacts if cnpj in (None, c['cpfCnpj'])]})
if request.method == 'POST' and path == '/contatos':
contact = {**json.loads(request.content), 'id': 500 + len(self.contacts)}
self.contacts.append(contact)
return httpx.Response(200, json={'id': contact['id']})
if request.method == 'GET' and path.startswith('/produtos/'):
wanted = path.rsplit('/', 1)[-1]
if wanted not in self.products:
return httpx.Response(404, json={'mensagem': 'não encontrado'})
return httpx.Response(200, json=self.products[wanted])
if request.method == 'GET' and path == '/produtos':
return httpx.Response(200, json={'itens': list(self.products.values())})
if request.method == 'GET' and path.startswith('/formas-envio/'):
wanted = path.rsplit('/', 1)[-1]
if wanted not in self.methods:
return httpx.Response(404, json={'mensagem': 'não encontrado'})
return httpx.Response(200, json=self.methods[wanted])
if request.method == 'GET' and path == '/formas-envio':
return httpx.Response(200, json={'itens': list(self.methods.values())})
if request.method == 'PUT' and path.startswith('/pedidos/') and path.endswith('/situacao'):
if self.refuse_status:
self.refuse_status -= 1
return httpx.Response(503, text='indisponível')
wanted = int(path.split('/')[2])
next(o for o in self.orders if o['id'] == wanted)['situacao'] = json.loads(request.content)['situacao']
return httpx.Response(204)
if request.method == 'GET' and path == '/pedidos':
return httpx.Response(200, json={'itens': [{'id': o['id']} for o in self.orders]})
if request.method == 'GET' and path.startswith('/pedidos/'):
wanted = int(path.rsplit('/', 1)[-1])
return httpx.Response(200, json=next(o for o in self.orders if o['id'] == wanted))
if request.method == 'POST' and path == '/pedidos':
order = {'situacao': 0, **json.loads(request.content), 'id': 9000 + len(self.orders),
'numeroPedido': str(100 + len(self.orders))}
self.orders.append(order)
return httpx.Response(200, json={'id': order['id'], 'numeroPedido': order['numeroPedido']})
return httpx.Response(404)
self.handler = handler
self.tiny = TinyOrders(auth=FakeAuth(), transport=httpx.MockTransport(handler),
today=date(2026, 9, 24))
@mock.patch.dict(os.environ, PRODUCTS)
class TinyTests(FakeTinyCase):
def test_payload_carries_contact_products_address_and_freight(self):
pedido = order_payload(PAID, 777, date(2026, 9, 24))
self.assertEqual((pedido['idContato'], pedido['numeroOrdemCompra'], pedido['data']),
(777, 'DTF-42', '2026-09-24'))
item = pedido['itens'][0]
self.assertEqual((item['produto'], item['quantidade'], item['valorUnitario']),
({'id': 102}, 2.8, 24.9))
self.assertEqual(pedido['valorFrete'], 15.0)
self.assertEqual((pedido['enderecoEntrega']['cep'], pedido['enderecoEntrega']['enderecoNro'],
pedido['enderecoEntrega']['nomeDestinatario']), ('14400000', '10', 'Loja Teste'))
contact = contact_payload(PAID['order'])
self.assertEqual((contact['tipoPessoa'], contact['cpfCnpj'], contact['endereco']['uf']),
('J', '11222333000181', 'SP'))
pickup = order_payload({**PAID, 'order': {**PAID['order'], 'destination': None,
'freight': {'service': 'pickup', 'total_cents': 0}}}, 1)
self.assertNotIn('enderecoEntrega', pickup)
self.assertIn('retirada', pickup['observacoes'])
def test_second_delivery_finds_the_first_order(self):
first = self.tiny.deliver('k1', PAID)
second = self.tiny.deliver('k1', PAID)
self.assertEqual((first['status'], second['status']), ('created', 'already-created'))
self.assertEqual(first['tiny_id'], second['tiny_id'])
self.assertEqual(self.calls.count(('POST', '/pedidos')), 1)
self.assertEqual(self.calls.count(('POST', '/contatos')), 1)
def test_existing_contact_is_reused(self):
self.contacts.append({'id': 321, 'cpfCnpj': '11222333000181'})
self.tiny.deliver('k1', PAID)
self.assertNotIn(('POST', '/contatos'), self.calls)
self.assertEqual(self.orders[0]['idContato'], 321)
def test_production_events_are_not_sent(self):
self.assertEqual(self.tiny.deliver('k2', {**PAID, 'event': 'ready'})['status'], 'not-applicable')
self.assertEqual(self.calls, [])
def test_missing_product_mapping_fails_rather_than_guessing(self):
with mock.patch.dict(os.environ, {'TINY_PRODUCT_TEXTIL_AVULSA': ''}):
with self.assertRaises(TinyError):
self.tiny.deliver('k3', PAID)
self.assertNotIn(('POST', '/pedidos'), self.calls)
def test_connection_check_only_reads(self):
from app.tiny import check
results = check(auth=FakeAuth(), transport=httpx.MockTransport(self.handler))
self.assertEqual(set(results.values()), {'ok'})
self.assertEqual(len(results), 7)
self.assertTrue(all(method == 'GET' for method, _ in self.calls))
self.products['103']['situacao'] = 'I'
with mock.patch.dict(os.environ, {'TINY_PRODUCT_UV_AVULSA': ''}):
results = check(auth=FakeAuth(), transport=httpx.MockTransport(self.handler))
self.assertIn('não está ativo', results['DTF UV 28,5 cm · folha montada'])
self.assertEqual(results['DTF UV 28,5 cm · artes avulsas'], 'TINY_PRODUCT_UV_AVULSA sem id')
self.assertEqual(results['pedidos'], 'ok')
denied = check(auth=FakeAuth(), transport=httpx.MockTransport(lambda r: httpx.Response(403, text='forbidden')))
self.assertTrue(denied['pedidos'].startswith('GET /pedidos: 403'))
def test_rate_limit_is_a_retryable_failure(self):
tiny = TinyOrders(auth=FakeAuth(), transport=httpx.MockTransport(lambda r: httpx.Response(429)))
with self.assertRaisesRegex(TinyError, 'rate limit'):
tiny.deliver('k4', PAID)
@mock.patch.dict(os.environ, PRODUCTS)
class TinyStatusTests(FakeTinyCase):
"""Situações that the client's Tiny -> n8n notices turn into WhatsApp messages."""
def puts(self):
return [call for call in self.calls if call[0] == 'PUT']
def test_nothing_changes_while_status_updates_are_off(self):
self.assertEqual(self.tiny.deliver('k1', PICKUP)['status'], 'created')
self.assertEqual(self.orders[0]['situacao'], 0)
self.assertEqual(self.tiny.deliver('k2', READY)['status'], 'not-applicable')
self.assertEqual(self.puts(), [])
@mock.patch.dict(os.environ, {'TINY_STATUS_UPDATES': 'true'})
def test_paid_order_is_approved_once(self):
first = self.tiny.deliver('k1', PICKUP)
self.assertEqual((first['status'], first['situacao']), ('created', 'Aprovada'))
self.assertEqual(self.orders[0]['situacao'], 3)
second = self.tiny.deliver('k1', PICKUP)
self.assertEqual(second['status'], 'already-created')
self.assertNotIn('situacao', second)
self.assertEqual(len(self.puts()), 1)
@mock.patch.dict(os.environ, {'TINY_STATUS_UPDATES': 'true'})
def test_retry_after_a_failed_approval_finishes_it(self):
self.refuse_status = 1
with self.assertRaises(TinyError):
self.tiny.deliver('k1', PICKUP)
self.assertEqual((len(self.orders), self.orders[0]['situacao']), (1, 0))
retry = self.tiny.deliver('k1', PICKUP)
self.assertEqual((retry['status'], retry['situacao']), ('already-created', 'Aprovada'))
self.assertEqual(self.calls.count(('POST', '/pedidos')), 1)
@mock.patch.dict(os.environ, {'TINY_STATUS_UPDATES': 'true'})
def test_an_order_already_moved_on_is_not_approved_again(self):
self.tiny.deliver('k1', PICKUP)
self.orders[0]['situacao'] = 7
self.tiny.deliver('k1', PICKUP)
self.assertEqual(self.orders[0]['situacao'], 7)
@mock.patch.dict(os.environ, {'TINY_STATUS_UPDATES': 'true'})
def test_ready_pickup_order_is_set_ready_once_by_its_known_id(self):
sale = self.tiny.deliver('k1', PICKUP)
self.calls.clear()
ready = self.tiny.deliver('k2', {**READY, 'tiny_id': sale['tiny_id']})
self.assertEqual((ready['status'], ready['situacao'], ready['tiny_number']),
('status-updated', 'Pronto para envio', sale['tiny_number']))
self.assertEqual(self.orders[0]['situacao'], 7)
self.assertNotIn(('GET', '/pedidos'), self.calls, 'a known id needs no search')
again = self.tiny.deliver('k2', {**READY, 'tiny_id': sale['tiny_id']})
self.assertEqual(again['status'], 'status-unchanged')
self.assertEqual(len(self.puts()), 1)
@mock.patch.dict(os.environ, {'TINY_STATUS_UPDATES': 'true'})
def test_ready_without_an_id_finds_the_order(self):
self.tiny.deliver('k1', PICKUP)
self.assertEqual(self.tiny.deliver('k2', READY)['status'], 'status-updated')
self.assertEqual(self.orders[0]['situacao'], 7)
@mock.patch.dict(os.environ, {'TINY_STATUS_UPDATES': 'true'})
def test_ready_before_the_sale_reached_tiny_is_retried(self):
with self.assertRaisesRegex(TinyError, 'not in Tiny yet'):
self.tiny.deliver('k2', READY)
self.assertEqual(self.puts(), [])
@mock.patch.dict(os.environ, {'TINY_STATUS_UPDATES': 'true'})
def test_ready_delivery_order_waits_for_freight(self):
self.tiny.deliver('k1', PAID)
self.calls.clear()
ready = self.tiny.deliver('k2', {**READY, 'order': PAID['order']})
self.assertEqual(ready['status'], 'not-applicable')
self.assertEqual(self.calls, [])
def test_pickup_orders_carry_the_pickup_forma_de_envio(self):
self.assertEqual(order_payload(PICKUP, 1)['transportador'], {'formaEnvio': {'id': 77}})
self.assertNotIn('transportador', order_payload(PAID, 1))
with mock.patch.dict(os.environ, {'TINY_FORMA_ENVIO_RETIRADA': ''}):
self.assertNotIn('transportador', order_payload(PICKUP, 1))
@mock.patch.dict(os.environ, PRODUCTS)
class TinyProbeTests(FakeTinyCase):
"""The supervised console tool run against the fake Tiny."""
def probe(self, *argv):
from app import tiny_probe
lines = []
code = tiny_probe.main(list(argv), orders=self.tiny, out=lines.append)
return code, '\n'.join(lines)
def test_products_and_settings_are_listed_without_writing(self):
code, text = self.probe('produtos', 'DTF')
self.assertEqual(code, 0)
self.assertIn('101\tDTF-101\tProduto 101', text)
code, text = self.probe('conferir')
self.assertEqual(code, 0)
self.assertIn('TINY_PRODUCT_UV_AVULSA (DTF UV 28,5 cm · artes avulsas): ok DTF-104', text)
self.assertIn('TINY_FORMA_ENVIO_RETIRADA (retirada): ok Retirar pessoalmente', text)
code, text = self.probe('formas-envio')
self.assertIn('77\t6\tRetirar pessoalmente', text)
self.assertTrue(all(method == 'GET' for method, _ in self.calls))
def test_order_is_shown_and_not_created_without_confirmation(self):
code, text = self.probe('pedido', '--cnpj', '11.222.333/0001-81', '--email', 'a@example.test',
'--celular', '16999999999')
self.assertEqual(code, 0)
self.assertIn('"numeroOrdemCompra": "DTF-TESTE-', text)
self.assertIn('Nada foi criado', text)
self.assertEqual(self.orders, [])
self.assertTrue(all(method == 'GET' for method, _ in self.calls))
def test_confirmed_order_is_created_once_and_found_on_resend(self):
code, text = self.probe('pedido', '--cnpj', '11222333000181', '--email', 'a@example.test',
'--celular', '16999999999', '--modo', 'uv', '--confirmar')
self.assertEqual(code, 0, text)
self.assertEqual(len(self.orders), 1)
self.assertEqual(self.orders[0]['itens'][0]['produto'], {'id': 104})
self.assertIn('2º envio: already-created', text)
def test_resend_is_skipped_when_the_search_cannot_find_the_order(self):
from app import tiny_probe
blind = lambda request: (httpx.Response(200, json={'itens': []})
if request.method == 'GET' and request.url.path.endswith('/pedidos')
else self.handler(request))
self.tiny = TinyOrders(auth=FakeAuth(), transport=httpx.MockTransport(blind), today=date(2026, 9, 24))
payload = tiny_probe.test_order('11222333000181', 'a@example.test', '16999999999', 'file')
lines = []
self.assertFalse(tiny_probe.pedido(self.tiny, payload, True, lines.append, wait=lambda s: None))
self.assertEqual(len(self.orders), 1)
self.assertIn('o 2º envio não foi feito', lines[-1])
if __name__ == '__main__':
unittest.main()

184
tests/tiny_oauth_test.py Normal file
View File

@@ -0,0 +1,184 @@
"""The Tiny OAuth connection against the real database, with a fake token server.
Run inside the API container: python -m tests.tiny_oauth_test
It saves any existing Tiny connection first and restores it afterwards.
"""
import os
from datetime import datetime, timedelta, timezone
from urllib.parse import parse_qs, urlparse
import httpx
from app.core.db import connect
from app.tiny import TinyAuth, TinyError, TinyNotConnected
os.environ.update(TINY_CLIENT_ID='test-client', TINY_CLIENT_SECRET='test-secret',
TINY_REDIRECT_URI='http://localhost:8081/api/operator/tiny/callback')
def run():
with connect() as c:
saved = c.execute("SELECT * FROM dtf_local.provider_tokens WHERE provider='tiny'").fetchone()
c.execute("DELETE FROM dtf_local.provider_tokens WHERE provider='tiny'")
try:
exercise()
finally:
with connect() as c:
c.execute("DELETE FROM dtf_local.provider_tokens WHERE provider='tiny'")
if saved:
columns = ','.join(saved)
c.execute(f'INSERT INTO dtf_local.provider_tokens({columns}) VALUES({",".join(["%s"] * len(saved))})',
tuple(saved.values()))
def exercise():
issued = []
server = {'mode': 'session', 'calls': 0}
def token_server(request):
server['calls'] += 1
if server['mode'] == 'down':
return httpx.Response(503, text='unavailable')
form = {k: v[0] for k, v in parse_qs(request.content.decode()).items()}
assert (form['client_id'], form['client_secret']) == ('test-client', 'test-secret')
if form['grant_type'] == 'authorization_code':
assert form['code'] == 'good-code' and form['redirect_uri'].endswith('/tiny/callback')
elif form['grant_type'] == 'refresh_token':
if form['refresh_token'] != issued[-1]:
return httpx.Response(400, json={'error': 'invalid_grant'})
n = len(issued) + 1
issued.append(f'refresh-{n}')
if server['mode'] == 'offline':
return httpx.Response(200, json={'access_token': f'access-{n}', 'expires_in': 14400,
'refresh_token': f'refresh-{n}', 'refresh_expires_in': 0,
'scope': 'openid offline_access profile'})
return httpx.Response(200, json={'access_token': f'access-{n}', 'expires_in': 14400,
'refresh_token': f'refresh-{n}', 'refresh_expires_in': 86400,
'scope': 'openid profile'})
auth = TinyAuth(transport=httpx.MockTransport(token_server))
assert auth.status() == {'connected': False}
try:
auth.access_token()
raise AssertionError('an unconnected Tiny must not yield a token')
except TinyNotConnected:
pass
url = urlparse(auth.authorize_url('operator@example.test'))
query = {k: v[0] for k, v in parse_qs(url.query).items()}
assert url.netloc == 'accounts.tiny.com.br' and query['client_id'] == 'test-client'
assert query['redirect_uri'].endswith('/api/operator/tiny/callback') and len(query['state']) > 30
assert query['scope'] == 'openid offline_access'
try:
auth.complete('good-code', 'forged-state')
raise AssertionError('a state no operator created must be refused')
except TinyError:
pass
assert auth.complete('good-code', query['state']) == 'operator@example.test'
try:
auth.complete('good-code', query['state'])
raise AssertionError('a state must be single-use')
except TinyError:
pass
status = auth.status()
assert status['connected'] and status['connected_by'] == 'operator@example.test'
assert status['problem'] is None and not status['offline'] and status['expires_at']
assert auth.access_token() == 'access-1'
print('PASS: operator-started state is required, single-use, and stores the connection')
# An access token about to expire is refreshed, and the refresh token rotates.
with connect() as c:
c.execute("UPDATE dtf_local.provider_tokens SET access_expires_at=now() WHERE provider='tiny'")
assert auth.access_token() == 'access-2'
with connect() as c:
row = c.execute("SELECT refresh_token,connected_by FROM dtf_local.provider_tokens WHERE provider='tiny'").fetchone()
assert row == {'refresh_token': 'refresh-2', 'connected_by': 'operator@example.test'}
assert auth.access_token() == 'access-2'
print('PASS: expiring access token refreshed once, refresh token rotated and stored')
# Tiny unreachable: the failure is shown, the connection kept, and the next
# renewal clears it.
with connect() as c:
c.execute("UPDATE dtf_local.provider_tokens SET access_expires_at=now() WHERE provider='tiny'")
server['mode'] = 'down'
try:
auth.access_token()
raise AssertionError('an unreachable token server must fail the renewal')
except httpx.HTTPError:
pass
status = auth.status()
assert status['connected'] and status['problem'] == 'renewal-failing' and status['failed_at']
server['mode'] = 'session'
assert auth.access_token() == 'access-3'
assert auth.status()['problem'] is None
print('PASS: a failed renewal is shown and cleared by the next successful one')
# A refused refresh (revoked in Tiny) asks for a new connection, not a retry loop.
with connect() as c:
c.execute("""UPDATE dtf_local.provider_tokens SET access_expires_at=now(), refresh_token='revoked'
WHERE provider='tiny'""")
try:
auth.access_token()
raise AssertionError('a refused refresh must report the connection as lost')
except TinyNotConnected:
pass
status = auth.status()
assert not status['connected'] and status['problem'] == 'refused'
calls = server['calls']
try:
auth.access_token()
raise AssertionError('a refused connection must stay refused')
except TinyNotConnected:
pass
assert server['calls'] == calls, 'a refused refresh token must not be sent again'
with connect() as c:
c.execute("UPDATE dtf_local.provider_tokens SET refresh_expires_at=%s WHERE provider='tiny'",
(datetime.now(timezone.utc) - timedelta(seconds=1),))
assert not auth.status()['connected']
print('PASS: revoked or expired connections report that Tiny must be connected again')
# Reconnecting with an offline grant: no daily end, and the refusal is cleared.
server['mode'] = 'offline'
state = parse_qs(urlparse(auth.authorize_url('operator@example.test')).query)['state'][0]
auth.complete('good-code', state)
status = auth.status()
assert status['connected'] and status['offline'] and status['expires_at'] is None
assert status['problem'] is None
with connect() as c:
c.execute("UPDATE dtf_local.provider_tokens SET access_expires_at=now() WHERE provider='tiny'")
auth.access_token()
assert auth.status()['offline'] and auth.status()['expires_at'] is None
print('PASS: an offline grant is stored without a daily expiry and survives renewal')
# Tiny refusing offline_access restarts the authorisation without it, once.
state = parse_qs(urlparse(auth.authorize_url('operator@example.test')).query)['state'][0]
retry = parse_qs(urlparse(auth.without_offline(state)).query)
assert retry['scope'] == ['openid'] and retry['state'][0] != state
try:
auth.without_offline(state)
raise AssertionError('the refused state must be spent')
except TinyError:
pass
# A session grant close to its end is flagged while renewals are not happening.
server['mode'] = 'session'
auth.complete('good-code', retry['state'][0])
with connect() as c:
c.execute("UPDATE dtf_local.provider_tokens SET refresh_expires_at=now()+interval '2 hours' WHERE provider='tiny'")
status = auth.status()
assert status['connected'] and status['problem'] == 'expiring' and not status['offline']
print('PASS: a refused offline scope falls back once; a connection near its end is flagged')
# Tiny's redirect back with an error instead of a code.
from app.api.operator import tiny_callback
declined = parse_qs(urlparse(auth.authorize_url('operator@example.test')).query)['state'][0]
assert tiny_callback('', declined, 'access_denied').headers['location'] == '/?tiny=failed'
assert tiny_callback('', '', '').headers['location'] == '/?tiny=failed'
scoped = parse_qs(urlparse(auth.authorize_url('operator@example.test')).query)['state'][0]
location = urlparse(tiny_callback('', scoped, 'invalid_scope').headers['location'])
assert location.netloc == 'accounts.tiny.com.br' and parse_qs(location.query)['scope'] == ['openid']
assert tiny_callback('', scoped, 'invalid_scope').headers['location'] == '/?tiny=failed'
print('PASS: a declined or malformed callback returns to the Kanban; a refused scope retries without it')
if __name__ == '__main__':
run()

View File

@@ -1,7 +1,7 @@
"""Customer identity, correction and final-file trust boundaries against local stack.""" """Customer identity, correction and final-file trust boundaries against local stack."""
from uuid import uuid4 from uuid import uuid4
from urllib.request import urlopen from urllib.request import urlopen
from tests.smoke_test import Client, upload_bytes, item_spec from tests.smoke_test import Client, approved_quote, upload_bytes, item_spec
def run(): def run():
customer=Client();other=Client();customer.call('/session');other.call('/session') customer=Client();other=Client();customer.call('/session');other.call('/session')
@@ -9,13 +9,29 @@ def run():
item=item_spec('file','1.01',0,uid) item=item_spec('file','1.01',0,uid)
profile={'cnpj':'11222333000181','zap':'11999999999','mail':'workflow-'+uuid4().hex[:8]+'@example.test'} 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'}}) 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) approved_quote(customer,q,[item])
order=customer.call('/orders/dev-paid',{'quote_id':q['id']});oid=order['id'] order=customer.call('/orders/dev-paid',{'quote_id':q['id']});oid=order['id']
before=list(customer.jar)[0].value before=list(customer.jar)[0].value
password='local-test-password-'+uuid4().hex password='local-test-password-'+uuid4().hex
customer.call('/account/register',{'customer':profile,'password':password}) customer.call('/account/register',{'customer':profile,'password':password})
assert customer.call('/account/me')['customer']['mail']==profile['mail'] assert customer.call('/account/me')['customer']['mail']==profile['mail']
assert customer.call('/customer/orders')['orders'][0]['id']==oid assert [e['id'] for e in customer.call('/customer/orders')['items'] if e['kind']=='order'][0]==oid
# The list filters, pages and counts by group.
listing=customer.call('/customer/orders?status=prod&page=1&size=1')
assert listing['counts']['prod']>=1 and listing['total']==listing['counts']['prod'] and len(listing['items'])==1, listing
assert customer.call('/customer/orders?status=fin')['items']==[]
assert customer.call('/customer/orders?number='+str(order['number']))['items'][0]['id']==oid
# The account's details change; the CNPJ does not, and e-mail and password need the password.
address={'recipient':'Workflow Ltda','street':'Rua de Teste','number':'10','complement':'',
'district':'Centro','city':'Franca','state':'SP','postal_code':'14400000'}
saved=customer.call('/account/profile',{'zap':'(16) 98888-7777','address':address})['customer']
assert saved['zap']=='16988887777' and saved['cnpj']==profile['cnpj'] and saved['address']['city']=='Franca'
assert customer.call('/account/me')['customer']['address']['postal_code']=='14400000'
customer.call('/account/profile',{'zap':'16988887777','cnpj':'00000000000000'},expected=422)
customer.call('/account/email',{'email':'x-'+profile['mail'],'password':'wrong-password'},expected=401)
customer.call('/account/password',{'current':'wrong-password','new':'another-password-123'},expected=401)
other.call('/account/profile',{'zap':'16988887777'},expected=401)
print('PASS: customer order list filters and pages; account details change, CNPJ does not')
# Email/CNPJ do not grant ownership; only current guest session is migrated. # Email/CNPJ do not grant ownership; only current guest session is migrated.
other.call('/customer/orders/'+oid,expected=404) other.call('/customer/orders/'+oid,expected=404)
other.call('/account/login',{'email':profile['mail'],'password':'wrong-password'},expected=401) other.call('/account/login',{'email':profile['mail'],'password':'wrong-password'},expected=401)
@@ -72,7 +88,7 @@ def run():
old_cookie=list(other.jar)[0].value old_cookie=list(other.jar)[0].value
other.call('/account/logout',{}) other.call('/account/logout',{})
cookie.value=old_cookie;revoked.jar.set_cookie(cookie);revoked.call('/customer/orders',expected=401) cookie.value=old_cookie;revoked.jar.set_cookie(cookie);revoked.call('/customer/orders',expected=401)
other.call('/session');assert other.call('/customer/orders')['orders']==[] other.call('/session');assert other.call('/customer/orders')['items']==[]
print('PASS: logout revokes server session and signed-out visitors cannot see account orders') print('PASS: logout revokes server session and signed-out visitors cannot see account orders')
if __name__=='__main__':run() if __name__=='__main__':run()

View File

@@ -1,9 +1,15 @@
/* Browser-local cart recovery. Files are stored only for an unfinished cart, for 24h. */ /* Browser-local cart recovery. Files are stored only for an unfinished cart, for 24h.
Only the items are kept: the customer's details and address are typed again
after a reload or a closed tab, so nothing personal stays in the browser. */
(() => { (() => {
// A browser may put back what was typed before the reload without telling
// the page, which then shows values it does not know and cannot pay with.
const CAMPOS=['fCnpj','fZap','fMail','cepIn','eNome','eRua','eNum','eComp','eBairro','eCidade','eUf'];
for(const id of CAMPOS)if($(id))$(id).value='';
let database,scope,timer,restoring=true; let database,scope,timer,restoring=true;
let signedOut=false; let signedOut=false;
window.addEventListener('dtf-private-data-cleared',()=>{signedOut=true;clearTimeout(timer);pedido=[];itemAtual=null;folhas=[];artes=[];}); 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'; const notice=document.createElement('p');notice.className='carrAviso';
document.getElementById('carr').prepend(notice); 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);});} 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(){ async function save(){
@@ -11,8 +17,8 @@
const items=[...pedido,...(itemAtual?[itemAtual]:[])]; const items=[...pedido,...(itemAtual?[itemAtual]:[])];
try{ try{
if(!items.length){await transaction('readwrite',store=>store.delete(scope));return;} 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)); await transaction('readwrite',store=>store.put({items,expires:Date.now()+86400000},scope));
notice.textContent='Carrinho salvo neste navegador por 24 horas. Você pode remover itens e adicionar outros após recarregar.'; notice.textContent='';
}catch(error){notice.textContent='Não foi possível salvar o carrinho neste navegador. Mantenha esta página aberta até enviar o pedido.';} }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.dtfClearCart=async()=>{clearTimeout(timer);if(database&&scope)await transaction('readwrite',store=>store.delete(scope));notice.textContent='';};
@@ -30,16 +36,26 @@
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));} 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)); const saved=await transaction('readonly',store=>store.get(scope));
if(saved && !pedido.length && !itemAtual){ if(saved && !pedido.length && !itemAtual){
pedido=saved.items;cliente=saved.customer;entrega=saved.delivery; pedido=saved.items;
// 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(); $('atual').style.display='none';pintaEntrega();
notice.textContent='Carrinho recuperado. Remova e adicione novamente um item se precisar alterar sua montagem.'; notice.textContent='Carrinho recuperado. Remova e adicione novamente um item se precisar alterar sua montagem.';
}else{ }
// A signed-in customer's details come from the account, with the saved
// delivery address, whether or not a cart was recovered.
const account=await window.dtfApi('/account/me'); 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();} const c=account.customer;
if(c && !cliente.mail){
cliente={cnpj:c.cnpj,zap:c.zap,mail:c.mail};
// The fields' own input handlers format them, as if typed.
for(const [id,key] of [['fCnpj','cnpj'],['fZap','zap'],['fMail','mail']]){$(id).value=cliente[key];$(id).dispatchEvent(new Event('input'));}
const a=c.address;
if(a && !entrega.cep){
entrega.cep=a.postal_code;
entrega.end={nome:a.recipient,rua:a.street,num:a.number,comp:a.complement||'',bairro:a.district,cidade:a.city,uf:a.state};
$('cepIn').value=a.postal_code.replace(/^(\d{5})(\d{3})$/,'$1-$2');
for(const [id,key] of [['eNome','nome'],['eRua','rua'],['eNum','num'],['eComp','comp'],['eBairro','bairro'],['eCidade','cidade'],['eUf','uf']])$(id).value=entrega.end[key];
}
pintaEntrega();
} }
}catch(error){notice.textContent='Recuperação de carrinho indisponível; o pedido ainda pode ser feito nesta sessão.';} }catch(error){notice.textContent='Recuperação de carrinho indisponível; o pedido ainda pode ser feito nesta sessão.';}
finally{restoring=false;} finally{restoring=false;}

View File

@@ -1,7 +1,13 @@
/* Checkout bridge only: approved commercial functions in web/index.html stay intact. */ /* Checkout bridge only: approved commercial functions in web/index.html stay intact.
The cart sends the order; the quote is paid on its own page (/pagamento). */
(() => { (() => {
const status = document.getElementById('checkoutStatus'); const status = document.getElementById('checkoutStatus');
const actions = document.getElementById('checkoutActions'); const actions = document.getElementById('checkoutActions');
const resumo = document.getElementById('pagResumo');
const naPagina = () => ['pagamento','pix'].includes(document.documentElement.dataset.rota);
const naPix = () => document.documentElement.dataset.rota === 'pix';
let shownCart = null;
let pixClock = null;
let busy = false; let busy = false;
let draftId = localStorage.getItem('dtf-quote'); let draftId = localStorage.getItem('dtf-quote');
let requestKey = localStorage.getItem('dtf-request-key'); let requestKey = localStorage.getItem('dtf-request-key');
@@ -9,7 +15,7 @@
let quotedCart = localStorage.getItem('dtf-quote-cart'); let quotedCart = localStorage.getItem('dtf-quote-cart');
let refreshVersion = 0; let refreshVersion = 0;
function cartSnapshot() { function cartSnapshot() {
const items=[...pedido,...(itemAtual?[itemAtual]:[])]; const items=itensAPagar();
return JSON.stringify({customer:cliente,delivery:entrega,items:items.map(item=>({ return JSON.stringify({customer:cliente,delivery:entrega,items:items.map(item=>({
mode:item.modo,metres:item.metros,grade:item.nota,production:item.production, mode:item.modo,metres:item.metros,grade:item.nota,production:item.production,
quality:item.qualityStatus, quality:item.qualityStatus,
@@ -39,42 +45,214 @@
ready.then(session=>{ ready.then(session=>{
window.dtfUploadMaxBytes=session.max_upload_bytes; window.dtfUploadMaxBytes=session.max_upload_bytes;
const limit=document.getElementById('zLimite'); const limit=document.getElementById('zLimite');
if(limit)limit.textContent='Até '+(session.max_upload_bytes/1048576).toFixed(0)+ if(limit)limit.textContent='Até '+(session.max_upload_bytes/1073741824).toFixed(0)+' GB por arquivo.';
' MB por arquivo enquanto a verificação de segurança para arquivos grandes é preparada.';
}).catch(()=>{}); }).catch(()=>{});
window.dtfSessionReady=ready; window.dtfSessionReady=ready;
window.dtfApi=api; window.dtfApi=api;
ready.catch(error => { status.textContent = error.message; }); ready.catch(error => { status.textContent = error.message; });
function message(text) { status.textContent = text; } function message(text) { status.textContent = text; }
function button(label, handler) { function button(label, handler, into = actions, className = 'pagBtn') {
const el = document.createElement('button'); const el = document.createElement('button');
el.type = 'button';
el.className = className;
el.textContent = label; el.textContent = label;
el.style.cssText = 'margin:8px 8px 0 0;padding:8px 14px;cursor:pointer';
el.onclick = handler; el.onclick = handler;
actions.append(el); into.append(el);
return el; return el;
} }
async function upload(file) { function node(tag, text, className) {
const session=await ready; const el=document.createElement(tag);
return window.dtfUpload(file,{api,progress:message,scope:session.cart_scope}); if (text!=null) el.textContent=text;
if (className) el.className=className;
return el;
} }
// What the customer is paying for, from the server's approval.
function pintaResumo(quote) {
const approved = quote ? quote.approved : null;
resumo.hidden = !approved;
if (!approved) { resumo.replaceChildren(); return; }
const linha = (label, value, className='l') => {
const row=document.createElement('div'); row.className=className;
const name=document.createElement('span'); name.textContent=label;
const amount=document.createElement('b'); amount.textContent=value;
row.append(name,amount); return row;
};
const rows = approved.items.map(item => linha(
(MODOS[item.mode]?.tit || item.mode)+' · '+fmtM(Number(item.billed_metres))+' m', rs(item.total_cents/100)));
const pickup = approved.freight.service === 'pickup';
const prazo = approved.freight.days ? ' · até '+approved.freight.days+' dias úteis' : '';
rows.push(linha(pickup ? 'Retirada em Franca' : 'Frete'+prazo,
approved.freight.total_cents ? rs(approved.freight.total_cents/100) : 'Grátis'));
rows.push(linha('Total', rs(approved.total_cents/100), 'tot'));
resumo.replaceChildren(node('h4','Resumo do pedido'), ...rows);
}
function esqueletoPagamento() {
const linhas=['skel skelLinha','skel skelLinha','skel skelLinha curta'].map(c=>node('div',null,c));
resumo.hidden=false; resumo.replaceChildren(node('h4','Resumo do pedido'),...linhas);
actions.replaceChildren(node('div',null,'skel skelLinha curta'),node('div',null,'skel skelBloco'));
}
function semPedido() {
pintaResumo(null);
actions.replaceChildren();
message('Nenhum pedido aguardando pagamento.');
button('Ir para o carrinho', () => vaiPara(CARRINHO));
}
// Files start uploading as soon as they are in the cart, so a sheet of
// several GB is on its way while the customer fills in the order. The
// checkout waits for whatever is still going.
const envios=new Map();
const chaveArquivo=f=>[f.name,f.size,f.lastModified].join('|');
function enviar(file) {
const k=chaveArquivo(file);
let e=envios.get(k);
if (!e) {
e={file, sent:0, done:false, failed:null};
e.promise=(async()=>{
const session=await ready;
return window.dtfUpload(file,{api,scope:session.cart_scope,progress:()=>{},
onBytes:n=>{e.sent=n;pintaEnvio();}});
})();
e.promise.then(()=>{e.done=true;falhas.delete(k);pintaEnvio();},
error=>{envios.delete(k);falhas.set(k,{file,error});pintaEnvio();});
envios.set(k,e);
}
return e.promise;
}
// A failed upload is shown and waits for the customer, never retried behind
// their back: a file the check refused would fail again on every change.
const falhas=new Map();
function tentaDeNovo() {
const arquivos=[...falhas.values()].map(f=>f.file);
falhas.clear();
arquivos.forEach(f=>enviar(f).catch(()=>{}));
pintaEnvio();
}
const arquivosDoCarrinho=()=>[...pedido.filter(it=>it.marcado!==false),...(busy&&itemAtual?[itemAtual]:[])].flatMap(it=>it.localFiles||[]);
const gb=n=>(n/1073741824).toLocaleString('pt-BR',{maximumFractionDigits:1})+' GB';
const mb=n=>n>=1073741824 ? gb(n) : Math.round(n/1048576)+' MB';
// Where the uploads stand, and roughly how long is left from the recent speed.
const amostras=[];
function estadoEnvio() {
const files=arquivosDoCarrinho(); if(!files.length) return null;
let total=0, sent=0, pendentes=0;
for (const f of files) {
const e=envios.get(chaveArquivo(f));
total+=f.size; sent+=e ? Math.min(e.sent,f.size) : 0;
if (!e || !e.done) pendentes++;
}
const agora=Date.now();
amostras.push([agora,sent]); while(amostras.length>2 && agora-amostras[0][0]>15000) amostras.shift();
const [t0,s0]=amostras[0], taxa=agora>t0 ? (sent-s0)/((agora-t0)/1000) : 0;
const fase=!pendentes ? 'pronto' : sent>=total ? 'verificando' : 'enviando';
return {total, sent, fase, pct:Math.floor(sent/total*100),
restante: fase==='enviando' && taxa>0 ? (total-sent)/taxa : null};
}
const falta=s=>s<60 ? 'falta menos de 1 min' : 'faltam cerca de '+duracao(s);
// The time left, measured from the upload's own speed; until there is a
// measure, a range from the size.
const restante=e=>e.restante!=null ? falta(e.restante) : 'estimativa '+faixaEnvio(e.total-e.sent);
function textoEnvio(e) {
if (!e) return '';
if (e.fase==='pronto') return 'Arquivos enviados';
if (e.fase==='verificando') return 'Arquivos enviados · verificando a segurança…';
return 'Enviando seus arquivos · '+restante(e);
}
function pintaEnvio() {
const e=estadoEnvio(), texto=textoEnvio(e);
const el=document.getElementById('envioArq');
const falhou=arquivosDoCarrinho().map(f=>falhas.get(chaveArquivo(f))).filter(Boolean);
const topo=document.getElementById('cartEnvio');
if (falhou.length) {
if (el) {
el.replaceChildren(document.createTextNode('Não foi possível enviar '+falhou[0].file.name+': '+
(falhou[0].error?.message||'erro no envio').replace(/\.$/,'')+(falhou.length>1?' (e mais '+(falhou.length-1)+')':'')+'. '));
const b=document.createElement('button'); b.type='button'; b.className='envioDeNovo'; b.textContent='Tentar de novo';
b.onclick=tentaDeNovo; el.append(b);
el.classList.remove('ok'); el.classList.add('erro');
}
if (topo) topo.textContent='· falha no envio';
return;
}
if (el) el.classList.remove('erro');
if (el) {
el.replaceChildren();
if (e && e.fase!=='pronto') {
const barra=document.createElement('div'); barra.className='envioBarra';
const i=document.createElement('i'); i.style.width=(e.fase==='verificando'?100:e.pct)+'%'; barra.append(i);
el.append(barra);
}
if (e?.fase==='pronto') el.insertAdjacentHTML('beforeend', icone('okCirculo'));
if (texto) el.append(document.createTextNode(texto));
if (e?.fase==='enviando') {
const nota=document.createElement('small'); nota.className='envioNota';
nota.textContent='O tempo depende da velocidade da sua internet. Mantenha a página aberta.';
el.append(nota);
}
el.classList.toggle('ok', e?.fase==='pronto');
}
// Visible on every page while it runs.
if (topo) topo.textContent = !e || e.fase==='pronto' ? '' : e.fase==='verificando' ? '· verificando' : e.restante!=null ? '· '+duracao(e.restante) : '· enviando';
if (busy && texto) {
message(texto);
$('bPagarTx').textContent = e && e.fase!=='pronto' ? (e.fase==='verificando' ? 'Verificando os arquivos…' : 'Enviando arquivos… '+restante(e)) : $('bPagarTx').textContent;
}
}
// Leaving the page pauses an upload; it resumes, but the customer is warned.
window.addEventListener('beforeunload',event=>{
const e=estadoEnvio();
if (e && e.fase==='enviando') { event.preventDefault(); event.returnValue=''; }
});
// Only what is in the cart: the item on the product page may still change.
window.addEventListener('dtf-cart-changed',()=>{
arquivosDoCarrinho().forEach(f=>{ if(!falhas.has(chaveArquivo(f))) enviar(f).catch(()=>{}); });
pintaEnvio();
});
async function upload(file) {
return enviar(file);
}
// The cart's package: billed metres and value. The charged freight is
// quoted again by the server from the approved items.
const pacote = () => {
const items=itensAPagar();
return {metres:items.reduce((t,it)=>t+(it.cob||0),0), cents:Math.round(items.reduce((t,it)=>t+(it.total||0),0)*100)};
};
let freightQuoted = null;
window.dtfFreight = async () => { window.dtfFreight = async () => {
const cep = entrega.cep; const cep = entrega.cep;
const service = (await ready).freight_service;
if (!service) {
$('cepMsg').textContent = 'A entrega ainda não está disponível. Escolha a retirada em Franca.';
return;
}
const {metres, cents} = pacote();
if (!metres) { $('cepMsg').textContent = 'Adicione um item ao pedido para cotar o frete.'; return; }
$('cepMsg').textContent = 'Cotando o frete…';
try { try {
const result = await api('/freight',{service:'mock-standard',postal_code:cep}); const result = await api('/freight',{service,postal_code:cep,metres:metres.toFixed(2),declared_cents:cents});
if (entrega.cep !== cep || entrega.tipo !== 'frete') return; if (entrega.cep !== cep || entrega.tipo !== 'frete') return;
entrega.valor = result.total_cents/100; entrega.valor = result.total_cents/100;
entrega.dias = result.days || null;
entrega.cotado = true; entrega.cotado = true;
$('cepMsg').textContent = 'Frete estimado: '+rs(entrega.valor)+'.'; freightQuoted = JSON.stringify(pacote());
$('cepMsg').textContent = '';
pintaEntrega(); pintaEntrega();
} catch(error) { $('cepMsg').textContent = error.message; } } catch(error) { $('cepMsg').textContent = error.message; }
}; };
// A different package is a different freight: quote it again.
window.addEventListener('dtf-cart-changed',()=>{
if (entrega.tipo==='frete' && entrega.cotado && freightQuoted && freightQuoted!==JSON.stringify(pacote())) {
entrega.cotado=false; freightQuoted=null; window.dtfFreight();
}
});
window.dtfCheckout = async () => { window.dtfCheckout = async () => {
if (busy) return; if (busy) return;
if (draftId) { await refresh(); status.scrollIntoView({behavior:'smooth',block:'nearest'}); return; } // The cart that was sent goes straight to its payment; a changed cart is sent again.
if (draftId && quotedCart === cartSnapshot()) { await refresh(true); return; }
if (draftId) clearDraft();
if (!clienteOk() || !entrega.cotado) return; if (!clienteOk() || !entrega.cotado) return;
if (!enderecoOk()) return message('Preencha o endereço de entrega.');
if (!cartPodeEnviar()) return message('Revise a qualidade e confirme a ressalva de cada item antes de enviar o pedido.'); if (!cartPodeEnviar()) return message('Revise a qualidade e confirme a ressalva de cada item antes de enviar o pedido.');
const cart = [...pedido,...(itemAtual?[itemAtual]:[])]; const cart = itensAPagar();
if (!cart.length) return message('Adicione um item ao pedido.'); if (!cart.length) return message('Adicione um item ao pedido.');
const initialCart=cartSnapshot(); const initialCart=cartSnapshot();
busy = true; busy = true;
@@ -83,6 +261,7 @@
await ready; 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.'); 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=[]; const items=[];
pintaEnvio();
for (const item of cart) { for (const item of cart) {
if (!item.localFiles?.length) throw new Error('Selecione novamente os arquivos deste item.'); if (!item.localFiles?.length) throw new Error('Selecione novamente os arquivos deste item.');
const uploads=[]; const uploads=[];
@@ -94,7 +273,11 @@
upload_id:uploads[index],...source}))}, upload_id:uploads[index],...source}))},
quality_status:item.qualityStatus,quality_acknowledged:item.qualityAcknowledged}); quality_status:item.qualityStatus,quality_acknowledged:item.qualityAcknowledged});
} }
const content = {customer:{...cliente},items,freight:{service:entrega.tipo==='retira'?'pickup':'mock-standard',postal_code:entrega.tipo==='retira'?'':entrega.cep}}; const service = entrega.tipo==='retira' ? 'pickup' : (await ready).freight_service;
if (!service) throw new Error('A entrega ainda não está disponível. Escolha a retirada em Franca.');
const content = {customer:{...cliente},items,freight:{service,postal_code:entrega.tipo==='retira'?'':entrega.cep}};
const destination = destinoApi();
if (destination) content.destination = destination;
if (cartSnapshot()!==initialCart) throw new Error('O carrinho mudou durante o envio. Confira os itens e envie de novo.'); if (cartSnapshot()!==initialCart) throw new Error('O carrinho mudou durante o envio. Confira os itens e envie de novo.');
const serialized = JSON.stringify(content); const serialized = JSON.stringify(content);
if (!requestKey || serialized !== requestBody) { if (!requestKey || serialized !== requestBody) {
@@ -105,49 +288,64 @@
const quote = await api('/quotes',{request_key:requestKey,...content}); const quote = await api('/quotes',{request_key:requestKey,...content});
quotedCart=initialCart;localStorage.setItem('dtf-quote-cart',quotedCart); quotedCart=initialCart;localStorage.setItem('dtf-quote-cart',quotedCart);
draftId=quote.id; localStorage.setItem('dtf-quote',draftId); draftId=quote.id; localStorage.setItem('dtf-quote',draftId);
await refresh(); await refresh(true);
status.scrollIntoView({behavior:'smooth',block:'nearest'});
} catch(error) { message(error.message); } } catch(error) { message(error.message); }
finally { busy=false; pintaEntrega(); } finally { busy=false; pintaEntrega(); }
}; };
async function refresh() { // `go`: the customer asked to pay, so the cart page moves to the payment page.
if (!draftId) return; async function refresh(go) {
if (!naPagina()) { if (!go || !draftId) return; }
else if (!draftId) { semPedido(); return; }
const version=++refreshVersion, shownId=draftId; const version=++refreshVersion, shownId=draftId;
if (naPagina() && !actions.children.length) esqueletoPagamento();
try { try {
await ready; await ready;
const quote=await api('/quotes/'+shownId); const quote=await api('/quotes/'+shownId);
if(version!==refreshVersion || draftId!==shownId) return; if(version!==refreshVersion || draftId!==shownId) return;
if (!naPagina()) { message(''); vaiPara(PAGAMENTO); return; }
shownCart=cartSnapshot();
clearInterval(pixClock);
unmountCard();
actions.replaceChildren(); actions.replaceChildren();
if (quote.status!=='paid' && (!quotedCart || quotedCart!==cartSnapshot())) { message('');
pintaResumo(quote);
document.querySelector('.pagVolta').hidden = quote.status==='paid';
if (quote.status==='paid') { confirmado(quote); return; }
if (naPix() && quote.status!=='approved') { vaiPara(PAGAMENTO); return; }
if (!quotedCart || quotedCart!==cartSnapshot()) {
message('O carrinho mudou ou não está disponível neste navegador. A cotação anterior continua separada; envie o carrinho atual para uma nova revisão.'); message('O carrinho mudou ou não está disponível neste navegador. A cotação anterior continua separada; envie o carrinho atual para uma nova revisão.');
button('Enviar carrinho atual',()=>{clearDraft();window.dtfCheckout();}); button('Enviar carrinho atual',()=>{clearDraft();window.dtfCheckout();});
return; return;
} }
if (quote.status==='pending_review') { if (quote.status==='pending_review') {
message('Arquivos enviados. No Kanban, confira metragem e nota e aprove a cotação '+draftId.slice(0,8)+'.'); message((await ready).environment==='local'
? 'Arquivos enviados. No Kanban, confira metragem e nota e aprove a cotação '+draftId.slice(0,8)+'.'
: 'Arquivos enviados. Nossa equipe está conferindo a cotação '+draftId.slice(0,8)+'; o valor final aparece aqui em seguida.');
} else if (quote.status==='approved') { } 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.'); if ((await ready).payment_provider === 'mercadopago') {
if (naPix()) await paginaPix(quote, version);
else await escolhaPagamento(quote, version);
return;
}
if ((await ready).environment !== 'local') { if ((await ready).environment !== 'local') {
message('Cotação revisada. O pagamento online ainda não está disponível.'); message('Cotação revisada. O pagamento online ainda não está disponível.');
return; return;
} }
// Local stack only: the simulated payment.
message('Total validado no servidor: '+rs(quote.approved.total_cents/100)+' · inclui frete. Cotação válida por 24 horas.');
button('Criar pedido de teste',async event=>{ button('Criar pedido de teste',async event=>{
if (!quotedCart || quotedCart!==cartSnapshot()) { await refresh(); return; } if (!quotedCart || quotedCart!==cartSnapshot()) { await refresh(); return; }
event.target.disabled=true; event.target.disabled=true;
try { try {
const order=await api('/orders/dev-paid',{quote_id:draftId}); const order=await api('/orders/dev-paid',{quote_id:draftId});
pedido=[]; itemAtual=null; limpaPaineis(); await pagos();
await window.dtfClearCart?.();
message('Pedido #'+order.number+' criado e disponível no Kanban.'); message('Pedido #'+order.number+' criado e disponível no Kanban.');
await refresh(); await refresh();
} catch(error) { message(error.message); event.target.disabled=false; } } catch(error) { message(error.message); event.target.disabled=false; }
}); });
} else if (quote.status==='paid') {
message('Pedido #'+quote.order.number+' · etapa: '+quote.order.state+'.');
button('Novo pedido',()=>{clearDraft();location.reload();});
} else { } else {
message('Cotação expirada. Envie o carrinho para uma nova revisão.'); message('Cotação expirada. Envie o carrinho de novo para pagar.');
button('Nova cotação',clearDraft); button('Voltar ao carrinho',()=>{clearDraft();vaiPara(CARRINHO);});
} }
} catch(error) { } catch(error) {
if(version!==refreshVersion || draftId!==shownId) return; if(version!==refreshVersion || draftId!==shownId) return;
@@ -156,11 +354,242 @@
button('Limpar referência e tentar de novo',clearDraft); button('Limpar referência e tentar de novo',clearDraft);
} }
} }
// The order is created by Mercado Pago's notification, never by this page,
// so after paying the page only waits for the order to exist.
let waitTimer=null;
function waitForOrder() {
clearInterval(waitTimer);
waitTimer=setInterval(async()=>{
try {
const quote=await api('/quotes/'+draftId);
if (quote.status==='paid') {
clearInterval(waitTimer);
await pagos();
await refresh();
} else if (quote.payment?.status==='rejected') {
// Refused after the bank's confirmation or the provider's review.
clearInterval(waitTimer);
await refresh();
message('Pagamento recusado. Tente de novo ou escolha outra forma de pagamento.');
}
} catch(_) {}
},3000);
}
// 3-D Secure: the bank's own page, opened by posting its request in a frame.
function showChallenge(challenge, into) {
unmountCard();
const frame=document.createElement('iframe');
frame.name='confirmacaoBanco'; frame.title='Confirmação do banco'; frame.className='desafio';
const form=document.createElement('form');
form.method='POST'; form.action=challenge.url; form.target=frame.name;
const input=document.createElement('input');
input.type='hidden'; input.name='creq'; input.value=challenge.creq;
form.append(input);
into.replaceChildren(node('p','Confirme o pagamento na página do seu banco, abaixo.','pagNota'),frame,form);
form.submit();
form.remove();
}
function confirmado(quote) {
clearInterval(waitTimer);
const box=node('div',null,'confirmado');
const titulo=node('h4','Pagamento confirmado');
titulo.insertAdjacentHTML('afterbegin', icone('okCirculo'));
box.append(titulo,
node('p','Pedido #'+quote.order.number+' recebido e enviado para a produção. Você acompanha cada etapa em Meus pedidos.'));
button('Ver meus pedidos',()=>location.assign('/conta/pedidos?order='+quote.order.id),box);
actions.append(box);
}
const cartOk=()=>!!quotedCart && quotedCart===cartSnapshot();
// What was paid leaves the cart; unchecked items stay for another order.
async function pagos() {
pedido=pedido.filter(it=>it.marcado===false); itemAtual=null; limpaPaineis();
if (pedido.length) pintaPedido(); else await window.dtfClearCart?.();
}
// Paying: card (the default, paid on this page) or PIX (paid on its own page).
async function escolhaPagamento(quote, version) {
const session=await ready;
const opcoes=[...(session.payment_public_key?[['credito','Cartão de crédito'],['debito','Cartão de débito']]:[]),['pix','PIX']];
let escolhido=opcoes[0][0];
const grupo=node('div',null,'metodos');
grupo.setAttribute('role','radiogroup'); grupo.setAttribute('aria-label','Forma de pagamento');
const area=node('div',null,'area');
const radios=opcoes.map(([key,label])=>{
const el=button(label,()=>{ if(escolhido!==key){ escolhido=key; pinta(); } },grupo,'metodo');
el.setAttribute('role','radio'); el.dataset.metodo=key;
el.insertAdjacentHTML('afterbegin', icone(key==='pix'?'qrcode':'cartao'));
return el;
});
actions.append(node('h4','Forma de pagamento'),grupo,area);
async function pinta() {
radios.forEach(el=>el.setAttribute('aria-checked',String(el.dataset.metodo===escolhido)));
unmountCard(); area.replaceChildren(); message('');
if (escolhido!=='pix') { await showCard(quote.approved, area, version, escolhido); return; }
area.append(node('p','Clique em Pagar para gerar o QR code do PIX.','pagNota'));
button('Pagar',async event=>{
if (!cartOk()) { await refresh(); return; }
event.target.disabled=true;
try {
await api('/payments/intent',{quote_id:draftId,method:{type:'pix'}});
vaiPara(PAGAMENTO_PIX);
} catch(error) { message(error.message); event.target.disabled=false; }
},area);
}
await pinta();
}
// The PIX page. The intent is idempotent on the quote, so reopening this
// page shows the same QR code instead of creating another charge.
async function paginaPix(quote, version) {
let intent;
try { intent=await api('/payments/intent',{quote_id:draftId,method:{type:'pix'}}); }
catch(error) {
if (version!==refreshVersion) return;
message(error.message);
button('Escolher outra forma de pagamento',()=>vaiPara(PAGAMENTO),actions,'pagBtn sec');
return;
}
if (version!==refreshVersion) return;
if (!intent.pix_qr_code) { message('Não foi possível gerar o PIX. Tente de novo em instantes.'); return; }
const box=node('div',null,'pixBox');
box.append(node('h4','Pague com PIX'),
node('p','Abra o app do seu banco, escolha pagar com PIX e leia o QR code ou cole o código abaixo.','pagNota'));
if (intent.pix_qr_code_base64) {
const img=document.createElement('img');
img.src='data:image/png;base64,'+intent.pix_qr_code_base64;
img.alt='QR code do PIX';img.width=240;img.height=240;
box.append(img);
}
const linha=node('div',null,'codigo');
const code=document.createElement('input');
code.readOnly=true;code.value=intent.pix_qr_code;code.setAttribute('aria-label','Código PIX copia e cola');
linha.append(code);
const copiar=button('Copiar código',async()=>{
try{ await navigator.clipboard.writeText(intent.pix_qr_code);
copiar.replaceChildren(); copiar.insertAdjacentHTML('beforeend', icone('ok')+'<span>Código copiado</span>'); }
catch(_){ code.select(); }
},linha);
copiar.insertAdjacentHTML('afterbegin', icone('copiar'));
const prazo=node('p',null,'prazo');
prazo.insertAdjacentHTML('afterbegin', icone('relogio'));
const prazoTx=node('span'); prazo.append(prazoTx);
box.append(linha,prazo,node('p','Aguardando a confirmação do pagamento…','aguarda'));
actions.append(box);
waitForOrder();
// The code stops working when it expires; a new one is a click away.
const fim=Date.parse(intent.expires_at);
if (!Number.isFinite(fim)) { prazo.remove(); return; }
const tick=()=>{
const s=Math.max(0,Math.ceil((fim-Date.now())/1000));
prazoTx.textContent='Pague em '+String(Math.floor(s/60)).padStart(2,'0')+':'+String(s%60).padStart(2,'0');
if (s>0) return;
clearInterval(pixClock);
const fimBox=node('div',null,'pixBox');
fimBox.append(node('h4','O código PIX expirou'),
node('p','Gere um novo código para pagar. O anterior não pode mais ser pago.','pagNota'));
button('Gerar novo PIX',()=>refresh(),fimBox);
actions.replaceChildren(fimBox);
};
tick();
pixClock=setInterval(tick,1000);
}
// Card: Mercado Pago's own form (Card Payment Brick). The card is typed into
// Mercado Pago's secure fields and becomes a one-time token; the number never
// reaches this page's code or our server.
let sdkLoading=null;
function loadMercadoPago() {
if (window.MercadoPago) return Promise.resolve();
sdkLoading = sdkLoading || new Promise((resolve,reject)=>{
const script=document.createElement('script');
script.src='https://sdk.mercadopago.com/js/v2';
script.onload=resolve;
script.onerror=()=>{sdkLoading=null;reject(new Error('Não foi possível carregar o formulário do Mercado Pago.'));};
document.head.append(script);
});
return sdkLoading;
}
// The cardholder's document as the card form collected it.
function cardholder(id) {
const number=String(id?.number||'').replace(/\D/g,'');
const type=String(id?.type||'').toUpperCase();
return ['CPF','CNPJ'].includes(type) && /^[0-9]{11,14}$/.test(number)
? {payer_document_type:type, payer_document:number} : {};
}
let cardBrick=null;
function unmountCard() {
if (cardBrick) { try { cardBrick.unmount(); } catch(_) {} cardBrick=null; }
}
async function showCard(approved, into, version, tipo) {
const holder=node('div');
holder.id='cardPaymentBrick';
const espera=node('div',null,'skel skelBloco');
into.append(espera,holder);
try { await loadMercadoPago(); }
catch(error) { espera.remove(); message(error.message); return; }
const session=await ready;
// The customer may have switched to PIX, or the page re-rendered, meanwhile.
if (!holder.isConnected || version!==refreshVersion) return;
unmountCard();
const mp=new window.MercadoPago(session.payment_public_key,{locale:'pt-BR'});
cardBrick=await mp.bricks().create('cardPayment','cardPaymentBrick',{
initialization:{amount:approved.total_cents/100, payer:{email:approved.customer.mail}},
// Each option takes only its kind of card; debit is paid at once.
customization:{paymentMethods:tipo==='debito'
? {maxInstallments:1,types:{excluded:['credit_card']}}
: {maxInstallments:12,types:{excluded:['debit_card']}},
// The option above already names the card; the form's own title
// ("crédito ou débito") would contradict it.
visual:{hideFormTitle:true,
style:{customVariables:{baseColor:'#FFA81A',buttonTextColor:'#03060B'}}}},
callbacks:{
onReady:()=>{ espera.remove(); },
onError:error=>{ console.error(error); message('Erro no formulário do cartão. Confira os dados e tente de novo.'); },
onSubmit:async data=>{
if (!cartOk()) { await refresh(); throw new Error('cart changed'); }
message('');
let result;
try {
result=await api('/payments/intent',{quote_id:draftId,method:{
type:'card', token:data.token, payment_method_id:data.payment_method_id,
installments:Number(data.installments)||1,
issuer_id:data.issuer_id==null?null:String(data.issuer_id),
...cardholder(data.payer?.identification)}});
} catch(error) {
// Rejecting stops the form's spinner; the reason is shown above it.
message(error.message || 'Não foi possível concluir o pagamento. Tente de novo.');
status.scrollIntoView({behavior:'smooth',block:'center'});
throw error;
}
if (result.challenge) { showChallenge(result.challenge, into); waitForOrder(); return; }
if (result.status==='approved' || result.status==='pending') {
unmountCard();
into.replaceChildren(node('p',result.status==='approved'
? 'Pagamento aprovado. Confirmando seu pedido…'
: 'Pagamento em análise pelo Mercado Pago. Esta página atualiza assim que ele for confirmado.','pagNota'));
waitForOrder();
} else {
message('Pagamento recusado pelo Mercado Pago ('+(result.status_detail||result.status)+'). '+
'Confira os dados ou use outro cartão.');
throw new Error('rejected');
}
}
}
});
if (!holder.isConnected || version!==refreshVersion) unmountCard();
}
const quoteFromPortal=new URLSearchParams(location.search).get('quote'); const quoteFromPortal=new URLSearchParams(location.search).get('quote');
if(quoteFromPortal && /^[0-9a-f-]{36}$/.test(quoteFromPortal)){ if(quoteFromPortal && /^[0-9a-f-]{36}$/.test(quoteFromPortal)){
if(draftId!==quoteFromPortal){quotedCart=null;localStorage.removeItem('dtf-quote-cart');} if(draftId!==quoteFromPortal){quotedCart=null;localStorage.removeItem('dtf-quote-cart');}
draftId=quoteFromPortal;localStorage.setItem('dtf-quote',draftId); draftId=quoteFromPortal;localStorage.setItem('dtf-quote',draftId);
} }
window.addEventListener('dtf-cart-changed',()=>{if(draftId) refresh();}); // Re-render the payment page only when the cart really changed: re-rendering
// would remount the card form under the customer's typing.
window.addEventListener('dtf-cart-changed',()=>{if(draftId && naPagina() && shownCart!==cartSnapshot()) refresh();});
// The cart page keeps only the sending progress and errors, never a payment.
window.addEventListener('dtf-page-changed',()=>{
if (naPagina()) refresh();
else if (!busy) { refreshVersion++; clearInterval(pixClock); message(''); actions.replaceChildren(); pintaResumo(null); }
});
refresh(); refresh();
})(); })();

File diff suppressed because it is too large Load Diff

209
web/kanban.css Normal file
View File

@@ -0,0 +1,209 @@
/* Kanban DTF. The previous look is kept in git tag ui-v1. */
:root{
--bg:#0F1113;--bar:#13161A;--col:#13161A;--card:#1A1D21;--card2:#23282D;--panel:#15181B;
--line:#262A2F;--line2:#2A2F35;--line3:#3A4047;
--tx:#ECE9E4;--tx2:#D5D2CC;--muted:#A3A9B0;--faint:#8B929A;
--accent:#FF8B2B;--accent-tx:#FFB575;--ok:#6FD19C;--warn:#F2B33D;--bad:#F4968B;--bad-line:#5A3530;
--rec:#2BB6D9;--tra:#E0508F;--fil:#E9C23A;--imp:#D9D6D0;--cor:#F06A5B;--fin:#3FB97A;
--mono:Manrope,system-ui,sans-serif;
}
*{box-sizing:border-box}
html,body{margin:0;background:var(--bg);color:var(--tx)}
body{font:14px/1.45 Manrope,system-ui,sans-serif;font-variant-numeric:tabular-nums}
button,input,textarea{font:inherit;color:inherit}
button{cursor:pointer}
button:disabled{opacity:.5;cursor:default}
a{color:var(--accent-tx)}
[hidden]{display:none!important}
.mono{font-family:var(--mono)}
.muted{color:var(--muted)}
.faint{color:var(--faint)}
.btn{height:36px;padding:0 12px;border-radius:8px;border:1px solid var(--line2);background:var(--card2);color:var(--tx);font-size:14px;font-weight:600;white-space:nowrap}
.btn:hover{border-color:var(--line3)}
.btn.ghost{background:transparent;color:var(--muted)}
.btn.primary{height:44px;border:0;background:var(--accent);color:#0F1113;font-weight:800;font-size:14.5px;padding:0 18px}
.btn.primary:hover{filter:brightness(1.08)}
.btn.danger{height:44px;background:transparent;border-color:var(--bad-line);color:var(--bad);font-weight:700;font-size:14.5px}
.btn.warn{border:0;background:var(--warn);color:#0F1113;font-weight:800}
.chip{height:34px;padding:0 12px;border-radius:8px;border:1px solid var(--line2);background:transparent;color:var(--muted);font-size:14px;font-weight:600}
.chip[aria-pressed=true]{border-color:var(--accent);background:rgba(255,139,43,.12);color:var(--accent-tx)}
.badge{font-size:13.5px;font-weight:700;border-radius:999px;padding:1px 8px;background:var(--accent);color:#0F1113}
.badge.warn{background:var(--warn);color:#0F1113}
.badge:empty{display:none}
.eyebrow{font-size:13.5px;color:var(--faint);font-weight:700;text-transform:uppercase;letter-spacing:.06em;margin:0 0 8px}
input[type=text],input[type=email],input[type=password],input[type=number],input:not([type]),textarea{height:40px;padding:0 12px;border-radius:8px;border:1px solid var(--line3);background:var(--bg);color:var(--tx)}
textarea{height:auto;padding:10px 12px;min-height:72px;width:100%;resize:vertical}
input[type=checkbox]{width:18px;height:18px;accent-color:var(--accent)}
input[type=file]{font-size:14px;color:var(--muted);max-width:100%}
.check{display:flex;align-items:center;gap:10px;font-size:14px;color:var(--tx2)}
.ok{color:var(--ok)}.warn{color:var(--warn)}.bad{color:var(--bad)}
/* Sign-in */
.login{max-width:380px;margin:12vh auto 0;padding:32px;border:1px solid var(--line2);border-radius:14px;background:var(--panel);display:flex;flex-direction:column;gap:14px}
.login label{display:flex;flex-direction:column;gap:6px;font-size:14px;font-weight:600;color:var(--muted)}
.login .hint{font-size:13.5px;color:var(--faint);margin:0}
/* Top bar */
.top{height:64px;padding:0 24px;display:flex;align-items:center;gap:22px;border-bottom:1px solid var(--line);background:var(--bar);position:sticky;top:0;z-index:5}
.brand{display:flex;align-items:center;gap:10px;font-weight:800;font-size:16px;letter-spacing:-.01em;white-space:nowrap}
.brand i{width:28px;height:28px;border-radius:7px;background:var(--accent);color:#0F1113;font-style:normal;font-weight:800;font-size:15px;display:flex;align-items:center;justify-content:center}
.tabs{display:flex;gap:4px;align-self:stretch}
.tabs button{display:flex;align-items:center;gap:8px;padding:0 14px;border:0;border-bottom:2px solid transparent;background:none;color:var(--muted);font-weight:600;font-size:14.5px}
.tabs button[aria-selected=true]{color:var(--tx);font-weight:700;border-bottom-color:var(--accent)}
.tabs .count{font-family:var(--mono);font-size:13.5px;color:var(--muted)}
.search{display:flex;align-items:center;gap:8px;width:300px;height:38px;padding:0 12px;border:1px solid var(--line2);border-radius:9px;background:var(--card);color:var(--faint);margin-left:auto}
.search input{flex:1;min-width:0;border:0;background:transparent;height:auto;padding:0;outline:none}
.tinydot{display:flex;align-items:center;gap:8px;font-size:14px;color:var(--muted);white-space:nowrap}
.tinydot::before{content:"";width:8px;height:8px;border-radius:999px;background:var(--dot,#5B6168)}
.tinydot:empty{display:none}
.who{font-size:14px;color:var(--muted);white-space:nowrap;max-width:220px;overflow:hidden;text-overflow:ellipsis}
#status{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:20;margin:0;max-width:min(640px,calc(100vw - 32px));padding:10px 16px;border-radius:10px;border:1px solid var(--line3);background:#1E2226;color:var(--tx);font-size:14px;box-shadow:0 10px 30px rgba(0,0,0,.45)}
#status:empty,#login-status:empty{display:none}
#status.error{border-color:var(--bad-line);color:var(--bad)}
#login-status.error{color:var(--bad)}
/* Board */
main{padding:0 24px 24px}
.bar{display:flex;align-items:center;gap:16px;flex-wrap:wrap;padding:18px 0 12px}
.bar h1,.split h1,.page h1{margin:0;font-size:22px;font-weight:800;letter-spacing:-.02em}
.chips{display:flex;gap:8px;flex-wrap:wrap}
.bar .chips{margin-left:auto}
.kan{display:grid;grid-template-columns:repeat(6,minmax(220px,1fr));gap:12px;overflow-x:auto;padding-bottom:8px;align-items:start}
.col{display:flex;flex-direction:column;gap:10px;background:var(--col);border:1px solid #22262B;border-radius:12px;padding:12px;min-height:320px}
.col.allowed{border-color:rgba(255,139,43,.45);background:#17140F}
.col.target{border-color:var(--accent)}
.col-head{display:flex;flex-direction:column;gap:6px;padding:2px 2px 8px;border-bottom:1px solid #22262B}
.col-head div{display:flex;align-items:center;gap:8px}
.col-head h2{margin:0;font-size:14.5px;font-weight:700}
.col-head .swatch{width:10px;height:10px;border-radius:3px;background:var(--c)}
.col-head .n{margin-left:auto;font-family:var(--mono);font-size:13.5px;color:var(--muted)}
.col-head small{font-size:13.5px;color:var(--faint)}
.col .empty{font-size:13.5px;color:var(--faint);padding:8px 2px}
.card{text-align:left;display:flex;flex-direction:column;gap:8px;padding:12px;border-radius:10px;border:1px solid var(--line2);background:var(--card);width:100%}
.card:hover,.card.open{border-color:var(--line3);background:#1E2226}
.card .row{display:flex;align-items:baseline;gap:8px;width:100%}
.card .num{font-size:14.5px;font-weight:800}
.card .age{margin-left:auto;font-size:13.5px;color:var(--faint)}
.card .cust{font-size:14px;font-weight:600;color:var(--tx);width:100%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.tags{display:flex;flex-wrap:wrap;gap:6px}
.tag{font-size:13.5px;padding:3px 8px;border-radius:6px;background:var(--card2);color:var(--tx2)}
.tag-teste{font-size:12.5px;font-weight:800;letter-spacing:.04em;padding:2px 7px;border-radius:6px;background:var(--warn);color:#1a1300}
.card .foot{display:flex;align-items:center;gap:6px;font-size:13.5px}
.card .foot .where{margin-left:auto;color:var(--muted)}
.late{font-size:13.5px;color:#F06A5B;font-weight:600}
/* Order panel */
.drawer{position:fixed;inset:0;z-index:10;display:flex;justify-content:flex-end}
.scrim{position:absolute;inset:0;background:rgba(8,9,11,.6)}
.panel{position:relative;width:min(600px,100vw);height:100%;background:var(--panel);border-left:1px solid var(--line2);display:flex;flex-direction:column}
.panel-head{padding:20px 24px;border-bottom:1px solid var(--line);display:flex;flex-direction:column;gap:14px}
.panel-head .row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
.panel-head .num{font-size:22px;font-weight:800}
.stage{font-size:13.5px;font-weight:700;padding:4px 10px;border-radius:999px;background:color-mix(in srgb,var(--c) 18%,transparent);color:var(--c)}
.close{margin-left:auto;width:40px;height:40px;border-radius:8px;border:1px solid var(--line2);background:transparent;color:var(--muted);display:flex;align-items:center;justify-content:center}
.steps{margin:0;padding:0;list-style:none;display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:6px}
.steps li{display:flex;flex-direction:column;gap:6px;font-size:12.5px;color:var(--faint)}
.steps li::before{content:"";height:4px;border-radius:4px;background:var(--line2)}
.steps li.done{color:var(--muted)}.steps li.done::before{background:var(--c)}
.steps li.now{color:var(--tx);font-weight:700}.steps li.now::before{background:var(--c)}
.moves{display:flex;gap:10px;flex-wrap:wrap}
.moves .primary{flex:1}
.reason{display:flex;flex-direction:column;gap:8px}
.panel-body{flex:1;overflow:auto;padding:20px 24px 32px;display:flex;flex-direction:column;gap:24px}
.facts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}
.facts b{display:block;font-size:14.5px}
.facts span{font-size:14px;color:var(--muted);overflow-wrap:anywhere}
.item{display:flex;gap:14px;padding:14px;border:1px solid var(--line2);border-radius:12px;background:var(--card)}
.item.attention{border-color:#4A3A1E;background:#1C1A15}
.item .preview{width:96px;flex-shrink:0;border-radius:8px;background:var(--card2);display:flex;align-items:flex-start;justify-content:center;padding:6px}
.item .preview svg{width:100%;height:auto;display:block}
.item .body{flex:1;min-width:0;display:flex;flex-direction:column;gap:8px}
.item .title{display:flex;align-items:baseline;gap:8px}
.item .title b{font-size:14.5px}
.item .title .mono{margin-left:auto;font-size:14px}
.item .line{font-size:13.5px;color:var(--muted)}
.item .file{display:flex;align-items:center;gap:8px;font-size:14px}
.buttons{display:flex;gap:8px;flex-wrap:wrap}
.finals{display:flex;flex-direction:column;gap:12px}
.finals .rev{font-size:14px;display:flex;flex-direction:column;gap:4px;padding:10px 12px;border:1px solid var(--line2);border-radius:10px}
.finals form{display:flex;flex-direction:column;gap:12px;padding:14px;border:1px solid var(--line2);border-radius:12px}
.finals .slot{display:flex;flex-direction:column;gap:6px;font-size:14px;font-weight:600}
.timeline{margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:10px}
.timeline li{display:flex;gap:12px;font-size:14px}
.timeline li::before{content:"";width:8px;height:8px;margin-top:6px;border-radius:999px;background:var(--c,var(--muted));flex-shrink:0}
.timeline small{display:block;color:var(--faint);font-size:13.5px}
/* Quotes */
#tab-quotes{padding-top:18px}
.split{display:flex;gap:0;border:1px solid var(--line);border-radius:12px;overflow:hidden;min-height:calc(100vh - 140px)}
.split .list{width:460px;flex-shrink:0;border-right:1px solid var(--line);padding:16px;display:flex;flex-direction:column;gap:12px;background:#111316}
.qitem{text-align:left;display:flex;justify-content:space-between;gap:12px;padding:14px;border-radius:10px;border:1px solid var(--line2);background:var(--card);width:100%}
.qitem[aria-current=true]{border-color:var(--accent);background:#1F1A15}
.qitem b{display:block;font-size:14.5px;overflow-wrap:anywhere}
.qitem span{font-size:13.5px;color:var(--muted)}
.qitem .right{text-align:right;flex-shrink:0}
.qitem .flag{display:block;color:var(--warn);font-weight:600;margin-top:4px}
.pane{flex:1;min-width:0;display:flex;flex-direction:column}
.pane .head{padding:18px 24px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:14px;flex-wrap:wrap}
.pane .head b{font-size:18px;overflow-wrap:anywhere}
.pane .content{flex:1;padding:20px 24px;display:flex;flex-direction:column;gap:16px}
.pane .fields{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}
.pane .fields label{display:flex;flex-direction:column;gap:6px;font-size:13.5px;font-weight:600;color:var(--muted)}
.pane .fields input[readonly]{color:var(--faint);background:var(--panel);border-color:var(--line2)}
.pane .foot{padding:16px 24px;border-top:1px solid var(--line);background:var(--bar);display:flex;align-items:center;gap:16px;flex-wrap:wrap}
.pane .foot .total{margin-left:auto;font-family:var(--mono);color:var(--muted)}
.pane .foot .total b{color:var(--tx)}
.pane .note{padding:24px;color:var(--muted)}
/* Payments and integrations */
.page{display:flex;flex-direction:column;gap:22px;padding-top:18px}
.page h2{margin:0 0 10px;font-size:15px;font-weight:800}
.issue{display:flex;align-items:center;gap:16px;flex-wrap:wrap;padding:16px 18px;border-radius:12px;border:1px solid #4A3A1E;background:#1C1A15;margin-bottom:8px}
.issue .text{flex:1;min-width:260px;display:flex;flex-direction:column;gap:4px}
.issue .text b{font-size:14.5px}
.issue .text span{font-size:14px;color:var(--muted);overflow-wrap:anywhere}
.issue form{display:flex;gap:8px;align-items:center}
.issue.done{border-color:var(--line2);background:var(--card)}
.pager{display:flex;align-items:center;gap:16px;flex-wrap:wrap;padding:14px 4px;border-top:1px solid var(--line);margin-top:8px;font-size:14px;color:var(--muted)}
.pager-size{display:flex;align-items:center;gap:8px}
.pager-size select{height:34px;padding:0 8px;border-radius:8px;border:1px solid var(--line3);background:var(--bg);color:var(--tx)}
.pager-range{margin-left:auto}
.pager-nav{display:flex;align-items:center;gap:6px}
.pager .pg{width:34px;height:34px;border-radius:8px;border:1px solid var(--line2);background:transparent;color:var(--tx);font-size:16px;line-height:1}
.pager .pg:disabled{color:var(--faint);opacity:.45}
.pager-page{display:flex;align-items:center;gap:6px;margin:0 4px}
.pager-page input{width:56px;height:34px;text-align:center;padding:0 4px}
.pager.compact{gap:10px;padding:12px 0 0}
.pager.compact .pager-range{margin-left:0}
.pager.compact .pager-nav{margin-left:auto}
.filters{display:flex;gap:12px;flex-wrap:wrap;align-items:flex-end;margin-bottom:12px}
.field{display:flex;flex-direction:column;gap:6px;font-size:13.5px;font-weight:600;color:var(--muted)}
.field select,.field input{height:38px;min-width:160px;padding:0 10px;border-radius:8px;border:1px solid var(--line3);background:var(--bg);color:var(--tx)}
.field input{min-width:110px;width:110px}
.issue input{width:320px}
.grid4{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px}
.integration{display:flex;flex-direction:column;gap:10px;padding:16px;border-radius:12px;border:1px solid var(--line2);background:var(--card)}
.integration .head{display:flex;align-items:center;gap:8px}
.integration .head b{font-size:15px}
.integration .head .dot{width:9px;height:9px;border-radius:999px;background:var(--dot,#5B6168)}
.integration .head em{margin-left:auto;font-style:normal;font-size:13.5px;font-weight:700;color:var(--dotx,var(--muted))}
.integration p{margin:0;font-size:14px;color:var(--muted)}
.integration .buttons{margin-top:auto}
table.log{width:100%;border-collapse:collapse;font-size:14px}
table.log th{text-align:left;color:var(--faint);font-weight:600;padding:8px 12px;border-bottom:1px solid var(--line)}
table.log td{padding:10px 12px;border-bottom:1px solid #1E2226;vertical-align:top}
.good{color:var(--ok);font-weight:600}.retry{color:var(--warn);font-weight:600}
@media (max-width:900px){
.top{flex-wrap:wrap;height:auto;padding:10px 16px;gap:10px}
.search{order:5;width:100%;margin-left:0}
main{padding:0 16px 16px}
.split{flex-direction:column}
.split .list{width:auto;border-right:0;border-bottom:1px solid var(--line)}
.facts,.pane .fields{grid-template-columns:1fr}
}
/* Icons beside a label: the download buttons, where a card ships, the WhatsApp link. */
.btn.with-icon{display:inline-flex;align-items:center;gap:6px}
.card .foot .where{display:inline-flex;align-items:center;gap:4px}
a.zap{display:inline-flex;align-items:center;gap:4px;vertical-align:bottom}

View File

@@ -1,21 +1,70 @@
<!doctype html> <!doctype html>
<html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> <html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>DTF · Kanban local</title> <title>Kanban DTF</title>
<style> <link rel="preconnect" href="https://fonts.googleapis.com">
:root{--bg:#0B0D10;--card:#15181D;--card2:#1C2026;--linha:#282C34;--tx:#EDEBE6;--fraco:#a1a6af;--ciano:#00B8DA} <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&amp;display=swap">
*{box-sizing:border-box}body{background:var(--bg);color:var(--tx);font:14px/1.5 system-ui,sans-serif;padding:20px;margin:0} <link rel="stylesheet" href="/kanban.css">
h1{font-size:24px;margin:0}h2{font-size:18px}header{display:flex;align-items:center;gap:18px;flex-wrap:wrap;margin-bottom:20px} </head><body>
.aviso{background:#152026;border-left:3px solid var(--ciano);padding:12px;color:#b9cbd2;margin:16px 0} <form id="login" class="login" hidden>
button,input,select{font:inherit;border:1px solid #56616d;border-radius:5px;padding:8px;background:var(--card2);color:var(--tx)} <div class="brand"><i aria-hidden="true">D</i>Kanban DTF</div>
button{cursor:pointer}button:hover{border-color:var(--ciano)}button:disabled{opacity:.5}input[type=number]{width:100px} <label>E-mail <input id="email" type="email" autocomplete="username" required></label>
label{display:inline-flex;gap:8px;align-items:center;margin:5px}#kan{display:grid;grid-template-columns:repeat(6,minmax(220px,1fr));gap:10px;overflow-x:auto;padding-bottom:16px} <label>Senha <input id="password" type="password" autocomplete="current-password" required></label>
.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)} <button class="btn primary">Entrar</button>
.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)} <p id="login-status" class="hint" role="alert"></p>
</style></head><body> </form>
<header><h1>Kanban DTF</h1><button id="refresh">Atualizar</button><button id="logout">Sair</button></header>
<div class="aviso">Confira a cotação manualmente. Em Arquivos de produção, envie e aprove os arquivos finais de todos os itens antes de colocar o pedido na fila. Não há validação automática de arte.</div> <div id="app" hidden>
<form id="login"><label>E-mail <input id="email" type="email" autocomplete="username" required></label><label>Senha <input id="password" type="password" autocomplete="current-password" required></label><button>Entrar</button></form> <header class="top">
<p id="status" role="status"></p> <div class="brand"><i aria-hidden="true">D</i>Kanban DTF</div>
<section id="reviews"></section><div id="kan"></div> <nav class="tabs" aria-label="Seções">
<details><summary>Eventos locais de integração</summary><pre id="events"></pre></details> <button type="button" data-tab="board" aria-selected="true">Produção <span class="count" id="count-board"></span></button>
<script src="/upload.js"></script><script src="/kanban.js?v=operator-email-1"></script></body></html> <button type="button" data-tab="quotes" aria-selected="false">Cotações <span class="badge" id="badge-quotes"></span></button>
<button type="button" data-tab="payments" aria-selected="false">Pagamentos <span class="badge warn" id="badge-payments"></span></button>
<button type="button" data-tab="integrations" aria-selected="false">Integrações</button>
</nav>
<label class="search">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="11" cy="11" r="7"></circle><path d="M20 20l-3.5-3.5"></path></svg>
<input id="search" type="search" placeholder="Buscar pedido, cliente ou CNPJ" aria-label="Buscar pedido, cliente ou CNPJ">
</label>
<span id="tiny" class="tinydot"></span>
<span id="who" class="who"></span>
<button class="btn ghost" id="refresh" type="button">Atualizar</button>
<button class="btn ghost" id="logout" type="button">Sair</button>
</header>
<p id="status" role="status"></p>
<main>
<section id="tab-board">
<div class="bar"><h1>Produção</h1><span class="muted" id="board-summary"></span><div class="chips" id="filters"></div></div>
<div class="kan" id="kan"></div>
</section>
<section id="tab-quotes" hidden>
<div class="split">
<div class="list">
<h1>Cotações</h1>
<div class="chips" id="quote-filters"></div>
<div id="quote-list" style="display:flex;flex-direction:column;gap:8px"></div>
</div>
<div class="pane" id="reviews"></div>
</div>
</section>
<section id="tab-payments" class="page" hidden>
<h1>Pagamentos</h1>
<section id="payments"></section>
</section>
<section id="tab-integrations" class="page" hidden>
<h1>Integrações</h1>
<section><div class="grid4" id="integrations"></div></section>
<section><h2>Registro de envios</h2>
<div class="filters" id="event-filters"></div>
<table class="log"><thead><tr><th scope="col">Quando</th><th scope="col">Pedido</th><th scope="col">Destino</th><th scope="col">Evento</th><th scope="col">Resultado</th></tr></thead><tbody id="events"></tbody></table>
<div id="events-more"></div>
</section>
</section>
</main>
</div>
<div class="drawer" id="drawer" hidden>
<div class="scrim" id="scrim"></div>
<aside class="panel" id="panel" aria-label="Pedido"></aside>
</div>
<script src="/upload.js"></script><script src="/kanban.js"></script></body></html>

View File

@@ -1,163 +1,736 @@
/* Reuses the prototype palette, column names and drag/drop interaction; no machine controls. */ /* Kanban DTF: production board, quote review, payments and integrations.
Everything is built with textContent; nothing from an order or a customer is
ever parsed as HTML. The previous screen is in git tag ui-v1. */
const $ = id=>document.getElementById(id); const $ = id=>document.getElementById(id);
// Remove credentials saved by older local builds. Only HttpOnly sessions now. // Remove credentials saved by older local builds. Only HttpOnly sessions now.
sessionStorage.removeItem('dtf-operator'); sessionStorage.removeItem('dtf-operator');
let board;
let extraQuotes={pending:[],approved:[]}; const STAGE_COLORS={rec:'var(--rec)',tra:'var(--tra)',fil:'var(--fil)',imp:'var(--imp)',cor:'var(--cor)',fin:'var(--fin)'};
let moreQuotes={pending:false,approved:false}; const FLOW=['rec','tra','fil','imp','fin'];
const PRODUCT={file:'DTF Têxtil · folha montada',avulsa:'DTF Têxtil · artes avulsas',uvfile:'DTF UV · folha montada',uv:'DTF UV · artes avulsas'};
const SHORT={file:'Têxtil folha',avulsa:'Têxtil avulsa',uvfile:'UV folha',uv:'UV avulsa'};
const EVENTS={payment_approved:'Pagamento aprovado',production_started:'Produção iniciada',correction_needed:'Correção necessária',ready:'Pedido pronto'};
const LATE_HOURS=4;
// Payment outcomes are recorded by the API in English; operators read Portuguese.
function outcomeText(outcome){
const rules=[
[/^refused: paid (\S+) but quote total is (\d+)/,m=>'valor pago ('+(m[1]==='None'?'não informado':money(Number(m[1])))+') diferente do total da cotação ('+money(Number(m[2]))+')'],
[/^refused: quote expired/,()=>'a cotação venceu antes do pagamento'],
[/^refused: quote not found/,()=>'cotação não encontrada'],
[/^refused: reference is not a quote id/,()=>'a referência do pagamento não é uma cotação'],
[/^refused: quote was never reviewed/,()=>'a cotação ainda não tinha sido revisada'],
[/^refused: quote uses an obsolete/,()=>'a cotação usa uma montagem antiga'],
[/^attention: payment (\w+) for order (\d+)/,m=>'pagamento '+(m[1]==='refunded'?'estornado':'cancelado')+' depois do pedido #'+m[2]],
];
for(const [pattern,text] of rules){const m=outcome.match(pattern);if(m)return text(m);}
return outcome;
}
let board=null;
let tab='board', filter='all', search='', openOrder=null, openToken=0;
let quoteKind='pending', pickedQuote=null;
let olderFinished=[], finishedMore=null;
const money=cents=>(cents/100).toLocaleString('pt-BR',{style:'currency',currency:'BRL'}); 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;} const metres=value=>Number(value).toLocaleString('pt-BR',{minimumFractionDigits:2,maximumFractionDigits:2})+' m';
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;} const when=value=>new Date(value).toLocaleString('pt-BR',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'});
function ago(value){
const minutes=Math.max(0,Math.round((Date.now()-new Date(value))/60000));
if(minutes<60)return 'há '+minutes+' min';
const hours=Math.round(minutes/60);
return hours<48?'há '+hours+' h':'há '+Math.round(hours/24)+' dias';
}
// The customer's WhatsApp opens the conversation, for messages the system does
// not send (a correction, a question about the artwork).
function whatsappLink(zap){
const digits=String(zap||'').replace(/\D/g,'');
if(digits.length<10)return null;
return 'https://wa.me/'+(digits.length<=11?'55':'')+digits;
}
function contato(customer){
const line=node('span','CNPJ '+customer.cnpj+' · ');
const href=whatsappLink(customer.zap);
if(!href){line.append(customer.zap||'');return line;}
const d=String(customer.zap).replace(/\D/g,'').replace(/^55(?=\d{10,11}$)/,'');
const shown=d.length===11?'('+d.slice(0,2)+') '+d.slice(2,7)+'-'+d.slice(7):d.length===10?'('+d.slice(0,2)+') '+d.slice(2,6)+'-'+d.slice(6):customer.zap;
const a=node('a',shown,'zap');a.href=href;a.target='_blank';a.rel='noopener noreferrer';
a.title='Abrir conversa no WhatsApp';a.prepend(icon(ICON_CHAT));
line.append(a);return line;
}
function node(tag,text,className){const e=document.createElement(tag);if(text!==undefined&&text!==null)e.textContent=text;if(className)e.className=className;return e;}
function button(text,fn,className='btn'){
const b=node('button',text,className);b.type='button';
b.onclick=async event=>{event.stopPropagation();b.disabled=true;try{await fn();}catch(e){say(e.message,true);}finally{b.disabled=false;}};
return b;
}
// Messages are notifications, not state: they clear themselves. Errors stay
// longer so they can be read; a caller can hold one for as long as it needs.
let sayTimer=null;
function say(text,error=false,ms){
clearTimeout(sayTimer);
for(const id of ['status','login-status']){$(id).textContent=text||'';$(id).classList.toggle('error',!!error);}
if(text)sayTimer=setTimeout(()=>say(''),ms??(error?8000:4000));
}
function icon(path){
const ns='http://www.w3.org/2000/svg';const svg=document.createElementNS(ns,'svg');
svg.setAttribute('width','14');svg.setAttribute('height','14');svg.setAttribute('viewBox','0 0 24 24');
svg.setAttribute('fill','none');svg.setAttribute('stroke','currentColor');svg.setAttribute('stroke-width','2.2');svg.setAttribute('aria-hidden','true');
for(const d of path){const p=document.createElementNS(ns,d.startsWith('circle')?'circle':'path');
if(d.startsWith('circle')){const [cx,cy,r]=d.split(' ').slice(1);p.setAttribute('cx',cx);p.setAttribute('cy',cy);p.setAttribute('r',r);}else p.setAttribute('d',d);svg.append(p);}
return svg;
}
const ICON_OK=['M5 12l4 4 10-10'], ICON_WARN=['M12 8v5M12 17h.01','circle 12 12 9'];
// Lucide shapes (ISC licence), in the same stroke as the two above.
const ICON_TRUCK=['M14 18V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v11a1 1 0 0 0 1 1h2','M15 18H9',
'M19 18h2a1 1 0 0 0 1-1v-3.65a1 1 0 0 0-.22-.62l-3.48-4.35A1 1 0 0 0 17.52 8H14','circle 17 18 2','circle 7 18 2'];
const ICON_PIN=['M20 10c0 4.99-5.54 10.19-7.4 11.8a1 1 0 0 1-1.2 0C9.54 20.19 4 14.99 4 10a8 8 0 0 1 16 0','circle 12 10 3'];
const ICON_DOWNLOAD=['M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4','m7 10 5 5 5-5','M12 15V3'];
const ICON_CHAT=['M7.9 20A9 9 0 1 0 4 16.1L2 22Z'];
// A download button: the arrow, then its label.
function downloadButton(text,fn,className){const b=button(text,fn,className);b.prepend(icon(ICON_DOWNLOAD));b.classList.add('with-icon');return b;}
async function api(path,body){ 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 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; 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){ function download(uid){return async()=>{
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 result=await api('/uploads/'+uid+'/download');
const link=node('a');link.href=result.url;link.referrerPolicy='no-referrer';link.download=result.name;link.click(); const link=node('a');link.href=result.url;link.referrerPolicy='no-referrer';link.download=result.name;link.click();
})); };}
} function manifest(production){
function productionLines(container,item){ const url=URL.createObjectURL(new Blob([JSON.stringify(production,null,2)],{type:'application/json'}));
if(!item.production){container.append(node('p','Instruções de produção ausentes; não produzir este item.','meta'));return;} const link=node('a');link.href=url;link.download='dtf-layout-'+production.sources[0].upload_id.slice(0,8)+'.json';
container.append(node('p','Especificação v'+item.production.version+' · qualidade '+item.quality_status+
(item.quality_status==='warning'?' · ressalva '+(item.quality_acknowledged?'aceita':'não aceita'):''),'meta'));
if(item.production.placements){
container.append(node('p',item.production.placements.length+' peças posicionadas em '+
item.production.film_width_cm+' × '+item.production.height_cm+' cm de filme','meta'));
const download=node('button','Baixar manifesto da montagem');
download.type='button';
download.onclick=()=>{
const url=URL.createObjectURL(new Blob([JSON.stringify(item.production,null,2)],{type:'application/json'}));
const link=node('a');link.href=url;
link.download='dtf-layout-'+item.production.sources[0].upload_id.slice(0,8)+'.json';
link.click();setTimeout(()=>URL.revokeObjectURL(url),1000); link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);
};
container.append(download);
}
item.production.sources.forEach((source,index)=>container.append(node('p',
(index+1)+'. '+source.kind+' · '+source.copies+' × '+source.width_cm+' × '+source.length_cm+
' cm · giro '+source.rotation_degrees+'°'+(source.mirrored?' · espelhada':'')+
' · medida '+source.measurement+' · arquivo '+source.upload_id.slice(0,8),'meta')));
} }
// ---- Loading and tabs ------------------------------------------------------
async function load(){ async function load(){
try{ try{
board=await api('/board'); board=await api('/board');
extraQuotes={pending:[],approved:[]}; const known=new Set(board.orders.map(o=>o.id));
moreQuotes={pending:board.pending_total>board.quotes.filter(q=>!q.approved).length, board.orders.push(...olderFinished.filter(o=>!known.has(o.id)));
approved:board.approved_total>board.quotes.filter(q=>!!q.approved).length}; if(finishedMore===null)finishedMore=board.finished_total>board.finished_shown;
$('login').hidden=true; $('login').hidden=true;$('app').hidden=false;
$('status').textContent='Atualizado às '+new Date().toLocaleTimeString(); say('Atualizado às '+new Date().toLocaleTimeString('pt-BR')+tinyNotice,false,tinyNotice?6000:2500);
render(); render();
}catch(e){$('status').textContent=e.message;$('login').hidden=false;} }catch(e){
if(e.status===401||e.status===503){$('app').hidden=true;$('login').hidden=false;$('drawer').hidden=true;}
// Not being signed in yet is the normal start, not an error.
if(e.status===401){say('');return;}
say(e.message,true);
}
} }
async function loadMoreQuotes(kind){ function showTab(name){
const shown=[...board.quotes.filter(q=>kind==='pending'?!q.approved:!!q.approved),...extraQuotes[kind]]; tab=name;
const last=shown.at(-1); for(const b of document.querySelectorAll('[data-tab]'))b.setAttribute('aria-selected',String(b.dataset.tab===name));
const params=new URLSearchParams({kind,limit:'50'}); for(const id of ['board','quotes','payments','integrations'])$('tab-'+id).hidden=name!==id;
if(last){params.set('before_created_at',last.created_at);params.set('before_id',last.id);} const lists={quotes:loadQuotes,payments:loadIssues,integrations:loadEvents};
const page=await api('/quotes?'+params); if(board&&lists[name])lists[name]().catch(e=>say(e.message,true));
const known=new Set(shown.map(q=>q.id));
extraQuotes[kind].push(...page.quotes.filter(q=>!known.has(q.id)));
moreQuotes[kind]=page.has_more;
render();
} }
function render(){ function render(){
$('reviews').replaceChildren(); $('who').textContent=board.operator||'';
if(board.pending_total || board.approved_total) $('count-board').textContent=String(board.orders.filter(o=>o.state!=='fin').length);
$('reviews').append(node('h2','Cotações · '+board.pending_total+' pendentes · '+ $('badge-quotes').textContent=board.pending_total?String(board.pending_total):'';
board.approved_total+' aprovadas sem pedido')); $('badge-payments').textContent=board.payment_issues_total?String(board.payment_issues_total):'';
for(const quote of [...board.quotes,...extraQuotes.pending,...extraQuotes.approved]){ tinyHeader();renderBoard();renderIntegrations();
const card=node('article',undefined,'review'); // Lists live on their own pages of the API; refresh the one being looked at.
card.append(node('b',quote.id.slice(0,8)+' · '+quote.draft.customer.mail)); const lists={quotes:loadQuotes,payments:loadIssues,integrations:loadEvents};
if(quote.status==='pending_review' && quote.draft.items.some(item=>item.production?.version!==2)){ if(lists[tab])lists[tab]().catch(e=>say(e.message,true));
card.append(node('p','Cotação com montagem antiga. Peça ao cliente para enviar uma nova cotação.')); if(openOrder){const order=board.orders.find(o=>o.id===openOrder);if(order)renderPanel(order);else closePanel();}
}else 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+' '));
productionLines(row,item);
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);
}
for(const [kind,label] of [['pending','Carregar cotações pendentes mais antigas'],
['approved','Carregar cotações aprovadas mais antigas']]){
if(moreQuotes[kind])$('reviews').append(action(label,()=>loadMoreQuotes(kind)));
}
$('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);
// Finished orders are a recent window, not the whole history: say so rather
// than let the count read as an all-time total.
const count=state==='fin'&&board.finished_total>orders.length
? orders.length+' de '+board.finished_total : String(orders.length);
column.append(node('h2',title+' · '+count));
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'));
productionLines(card,item);
}
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',{email:$('email').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){ // ---- Production board ----------------------------------------------------
container.replaceChildren();
const revisions=await api('/orders/'+order.id+'/files'); function printState(order){
for(const file of revisions){ if(order.state==='fil'||order.state==='imp')return {text:'Final aprovado',tone:'ok'};
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'); if(order.state==='fin')return {text:'Concluído',tone:'ok'};
row.append(node('p',file.note)); if(order.state==='cor')return {text:'Aguardando o cliente',tone:''};
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();})); const rows=order.print_files||[], items=order.snapshot.items.length;
container.append(row); const ready=rows.filter(r=>r.status==='ready').length;
if(rows.some(r=>r.status==='pending'||r.status==='rendering'))return {text:'Gerando arquivo…',tone:''};
if(ready===items)return {text:'PDF pronto',tone:'ok'};
if(rows.some(r=>r.status==='manual'||r.status==='failed'))
return {text:items>1&&ready?ready+' de '+items+' pronto':'Preparar à mão',tone:'warn'};
return {text:'Sem arquivo gerado',tone:'warn'};
}
function matches(order){
const modes=order.snapshot.items.map(i=>i.mode);
if(filter==='textil'&&!modes.some(m=>m==='file'||m==='avulsa'))return false;
if(filter==='uv'&&!modes.some(m=>m==='uvfile'||m==='uv'))return false;
if(filter==='manual'&&!(order.print_files||[]).some(r=>r.status==='manual'||r.status==='failed'))return false;
if(!search)return true;
const c=order.snapshot.customer, d=order.snapshot.destination||{};
const hay=[String(order.number),c.mail,c.cnpj,d.recipient,d.city].join(' ').toLowerCase();
const digits=search.replace(/\D/g,'');
return hay.includes(search)||(digits.length>=3&&hay.replace(/\D/g,'').includes(digits));
}
function renderBoard(){
const filters=[['all','Todos'],['textil','Têxtil'],['uv','UV'],['manual','Preparar à mão']];
$('filters').replaceChildren(...filters.map(([key,label])=>{
const b=node('button',label,'chip');b.type='button';b.setAttribute('aria-pressed',String(filter===key));
b.onclick=()=>{filter=key;renderBoard();};return b;}));
const active=board.orders.filter(o=>['rec','tra','fil','imp'].includes(o.state));
const queued=active.reduce((s,o)=>s+o.snapshot.items.reduce((t,i)=>t+Number(i.billed_metres),0),0);
$('board-summary').textContent=metres(queued)+' em produção';
$('kan').replaceChildren(...Object.entries(board.states).map(([state,title])=>{
const column=node('section',undefined,'col');column.dataset.state=state;
const orders=board.orders.filter(o=>o.state===state&&matches(o));
const total=orders.reduce((s,o)=>s+o.snapshot.items.reduce((t,i)=>t+Number(i.billed_metres),0),0);
const head=node('div',undefined,'col-head');const line=node('div');
const swatch=node('span',undefined,'swatch');swatch.style.setProperty('--c',STAGE_COLORS[state]);
// Finished orders are a recent window, not the whole history: say so.
const count=state==='fin'&&board.finished_total>orders.length?orders.length+' de '+board.finished_total:String(orders.length);
line.append(swatch,node('h2',title),node('span',count,'n'));
head.append(line,node('small',orders.length?metres(total)+' no total':'vazio'));
column.append(head);
for(const order of orders)column.append(card(order));
if(state==='fin'&&finishedMore&&!search&&filter==='all')moreButton(column,true,loadOlderFinished);
// Dragging only goes forward to the next stage. Going back or asking for a
// correction needs a reason, so those happen in the order panel.
column.ondragover=e=>{if(!column.classList.contains('allowed'))return;e.preventDefault();column.classList.add('target');};
column.ondragleave=()=>column.classList.remove('target');
column.ondrop=async e=>{e.preventDefault();column.classList.remove('target');
const order=board.orders.find(o=>o.id===e.dataTransfer.getData('text/plain'));
if(order&&order.state!==state)try{await move(order,state);}catch(error){say(error.message,true);}};
return column;
}));
}
// An operator's test order: no payment, no Tiny, no WhatsApp.
const isTest=order=>order.payment?.provider==='teste';
function card(order){
const c=node('button',undefined,'card');c.type='button';c.dataset.card=order.id;c.draggable=true;
if(order.id===openOrder)c.classList.add('open');
const top=node('div',undefined,'row');
top.append(node('span','#'+order.number,'num'),...(isTest(order)?[node('span','TESTE','tag-teste')]:[]),node('span',ago(order.updated_at),'age'));
const tags=node('div',undefined,'tags');
for(const item of order.snapshot.items)tags.append(node('span',SHORT[item.mode]+' · '+metres(item.billed_metres),'tag'));
const foot=node('div',undefined,'foot');const p=printState(order);
const status=node('span',p.text,p.tone);status.style.display='flex';status.style.alignItems='center';status.style.gap='5px';
if(p.tone==='ok')status.prepend(icon(ICON_OK));if(p.tone==='warn')status.prepend(icon(ICON_WARN));
const dest=order.snapshot.destination;
const where=node('span',dest?'Entrega · '+dest.state:'Retirada','where');where.prepend(icon(dest?ICON_TRUCK:ICON_PIN));
foot.append(status,where);
c.append(top,node('div',order.snapshot.customer.mail,'cust'),tags,foot);
if(['rec','tra','fil','imp'].includes(order.state)&&Date.now()-new Date(order.updated_at)>LATE_HOURS*3600000)
c.append(node('div','Parado há mais de '+LATE_HOURS+' h','late'));
c.onclick=()=>openPanel(order.id);
c.ondragstart=e=>{
e.dataTransfer.setData('text/plain',order.id);
const forward=board.transitions[order.state].filter(s=>s!=='cor');
for(const col of document.querySelectorAll('.col[data-state]'))col.classList.toggle('allowed',forward.includes(col.dataset.state));
say(forward.length?'Mover para '+forward.map(s=>board.states[s]).join(' ou '):'Pedido finalizado',false,60000);
};
c.ondragend=()=>{for(const col of document.querySelectorAll('.col'))col.classList.remove('allowed','target');say('');};
return c;
}
async function loadOlderFinished(){
const shown=board.orders.filter(o=>o.state==='fin').sort((a,b)=>a.created_at<b.created_at?-1:1);
const oldest=shown[0];const params=new URLSearchParams({limit:'50'});
if(oldest){params.set('before_created_at',oldest.created_at);params.set('before_id',oldest.id);}
const page=await api('/orders/finished?'+params);
const known=new Set(board.orders.map(o=>o.id));
const fresh=page.orders.filter(o=>!known.has(o.id));
olderFinished.push(...fresh);board.orders.push(...fresh);finishedMore=page.has_more;renderBoard();
}
async function move(order,state,reason=''){
await api('/orders/'+order.id+'/move',{state,version:order.version,reason});
await load();
}
// ---- Order panel ---------------------------------------------------------
function openPanel(id){openOrder=id;const order=board.orders.find(o=>o.id===id);$('drawer').hidden=false;renderPanel(order);renderBoard();}
function closePanel(){openOrder=null;$('drawer').hidden=true;$('panel').replaceChildren();if(board)renderBoard();}
$('scrim').onclick=closePanel;
document.addEventListener('keydown',e=>{if(e.key==='Escape'&&openOrder)closePanel();});
function layoutPreview(production){
const ns='http://www.w3.org/2000/svg', w=Number(production.film_width_cm), h=Math.max(1,Number(production.height_cm));
const svg=document.createElementNS(ns,'svg');svg.setAttribute('viewBox','0 0 '+w+' '+h);svg.setAttribute('role','img');
svg.setAttribute('aria-label','Montagem: '+production.placements.length+' peças em '+w+' × '+h+' cm');
const film=document.createElementNS(ns,'rect');film.setAttribute('width',w);film.setAttribute('height',h);film.setAttribute('fill','#0F1113');svg.append(film);
const colors=['#2BB6D9','#E0508F','#E9C23A','#6FD19C','#FF8B2B','#A78BFA'];
for(const p of production.placements){const r=document.createElementNS(ns,'rect');
r.setAttribute('x',p.x_cm);r.setAttribute('y',p.y_cm);r.setAttribute('width',p.width_cm);r.setAttribute('height',p.length_cm);
r.setAttribute('fill',colors[p.source_index%colors.length]);r.setAttribute('fill-opacity','.55');svg.append(r);}
return svg;
}
function renderPanel(order){
const token=++openToken, panel=$('panel');panel.dataset.order=order.id;
const color=STAGE_COLORS[order.state];
const head=node('div',undefined,'panel-head');
const row=node('div',undefined,'row');
const stage=node('span',board.states[order.state],'stage');stage.style.setProperty('--c',color);
const close=node('button',undefined,'close');close.type='button';close.setAttribute('aria-label','Fechar');close.append(icon(['M6 6l12 12M18 6L6 18']));close.onclick=closePanel;
row.append(node('span','#'+order.number,'num'),stage,
isTest(order)?node('span','pedido de teste · sem pagamento, não vai ao Tiny nem ao WhatsApp','tag-teste'):node('span','pago '+when(order.created_at),'faint'),close);
const steps=node('ol',undefined,'steps');steps.setAttribute('aria-label','Etapas');
const at=FLOW.indexOf(order.state);
for(const [i,s] of FLOW.entries()){const li=node('li',board.states[s]);li.style.setProperty('--c',STAGE_COLORS[s]);
if(at>=0&&i<at)li.classList.add('done');if(i===at)li.classList.add('now');steps.append(li);}
head.append(row,steps,moves(order));
const body=node('div',undefined,'panel-body');
const s=order.snapshot, dest=s.destination;
const facts=node('section',undefined,'facts');
const who=node('div');who.append(node('p','Cliente','eyebrow'),node('b',s.customer.mail),contato(s.customer));
const where=node('div');where.append(node('p','Entrega','eyebrow'),node('b',dest?dest.recipient:'Retirada em Franca'),
node('span',dest?dest.street+', '+dest.number+(dest.complement?' '+dest.complement:'')+' · '+dest.district+' · '+dest.city+'/'+dest.state+' · CEP '+dest.postal_code:'Cliente retira na fábrica'));
facts.append(who,where);
const items=node('section');items.append(node('p','Itens e arquivos de impressão','eyebrow'));
const itemsList=node('div');itemsList.style.display='flex';itemsList.style.flexDirection='column';itemsList.style.gap='10px';
s.items.forEach((item,index)=>itemsList.append(itemCard(order,item,index)));
items.append(itemsList);
const total=node('p',undefined,'muted');total.append('Total pago: ',node('b',money(s.total_cents)));items.append(total);
const finals=node('section',undefined,'finals');finals.append(node('p','Arquivos finais','eyebrow'),node('p','Carregando…','faint'));
const history=node('section');history.append(node('p','Histórico','eyebrow'),node('p','Carregando…','faint'));
body.append(facts,items,finals,history);
panel.replaceChildren(head,body);
api('/orders/'+order.id+'/files').then(rows=>{if(token===openToken)finalsSection(order,rows,finals);}).catch(e=>say(e.message,true));
api('/orders/'+order.id+'/history').then(rows=>{if(token===openToken)historySection(order,rows,history);}).catch(e=>say(e.message,true));
}
function moves(order){
const box=node('div');box.style.display='flex';box.style.flexDirection='column';box.style.gap='10px';
const row=node('div',undefined,'moves');
const reason=node('form',undefined,'reason');reason.hidden=true;
for(const next of board.transitions[order.state]){
if(next==='cor'){
const b=node('button','Pedir correção','btn danger');b.type='button';b.dataset.move='cor';
b.onclick=()=>{reason.hidden=!reason.hidden;backForm.hidden=true;if(!reason.hidden)reason.querySelector('textarea').focus();};row.append(b);
}else{
const b=button('Mover para '+board.states[next],()=>move(order,next),'btn primary');b.dataset.move=next;row.append(b);
} }
if(!['rec','tra','cor'].includes(order.state))return; }
// Undo a mistaken move: one stage back, internal reason, no customer message.
const previous=board.back?.[order.state];
const backForm=node('form',undefined,'reason');backForm.hidden=true;
if(previous){
const b=node('button','Voltar para '+board.states[previous],'btn ghost');b.type='button';b.dataset.back=previous;
b.onclick=()=>{backForm.hidden=!backForm.hidden;reason.hidden=true;if(!backForm.hidden)backForm.querySelector('input').focus();};
row.append(b);
const why=node('input');why.placeholder='Motivo';why.setAttribute('aria-label','Motivo do retorno');why.required=true;why.maxLength=1000;
const go=node('button','Voltar para '+board.states[previous],'btn');
backForm.append(why,go);
backForm.onsubmit=async e=>{e.preventDefault();go.disabled=true;try{await move(order,previous,why.value.trim());}catch(error){say(error.message,true);go.disabled=false;}};
}
if(!row.children.length)row.append(node('span','Pedido concluído.','muted'));
const text=node('textarea');text.placeholder='Motivo da correção';text.setAttribute('aria-label','Motivo da correção');text.maxLength=1000;text.required=true;
const confirm=node('button','Enviar para correção','btn danger');
reason.append(text,confirm);
reason.onsubmit=async e=>{e.preventDefault();confirm.disabled=true;try{await move(order,'cor',text.value.trim());}catch(error){say(error.message,true);confirm.disabled=false;}};
box.append(row,reason,backForm);return box;
}
const PRINT_TEXT={pending:'Na fila para gerar',rendering:'Gerando arquivo…',ready:'PDF gerado',manual:'Preparar à mão',failed:'Falhou ao gerar'};
function itemCard(order,item,index){
const row=(order.print_files||[]).find(r=>r.item_index===index);
const box=node('div',undefined,'item');
if(row&&(row.status==='manual'||row.status==='failed'))box.classList.add('attention');
const preview=node('div',undefined,'preview');
if(item.production?.placements)preview.append(layoutPreview(item.production));
const body=node('div',undefined,'body');
const title=node('div',undefined,'title');title.append(node('b',PRODUCT[item.mode]),node('span',metres(item.billed_metres),'mono'));
const spec=item.production;
const line=spec?(spec.placements.length+' peças em '+spec.film_width_cm+' × '+spec.height_cm+' cm · nota '+item.grade):('nota '+item.grade);
body.append(title,node('div',line,'line'));
const file=node('div',undefined,'file');
if(row){
const tone=row.status==='ready'?'ok':row.status==='manual'||row.status==='failed'?'warn':'muted';
const original=row.status==='ready'&&row.detail?.source==='original';
const label=node('span',(original?'Arquivo grande: o original é o arquivo de impressão':PRINT_TEXT[row.status])+
(row.status==='ready'&&row.name?' · '+row.name:'')+(row.status==='manual'&&row.detail.reason?': '+row.detail.reason:''),tone);
file.append(tone==='ok'?icon(ICON_OK):tone==='warn'?icon(ICON_WARN):'',label);
if(row.status==='ready'&&row.detail&&!original){body.append(file,node('div',[row.detail.film_width_cm+' × '+row.detail.height_cm+' cm',
row.detail.min_dpi?'menor resolução '+row.detail.min_dpi+' DPI':'',row.detail.vector_sources?'PDF vetorial':''].filter(Boolean).join(' · '),'line'));}
else body.append(file);
}else body.append(node('div','Arquivo de impressão ainda não gerado','line'));
const buttons=node('div',undefined,'buttons');
if(row?.status==='ready'&&row.detail?.source!=='original')buttons.append(downloadButton('Baixar PDF',download(row.upload_id)));
item.uploads.forEach((uid,i)=>buttons.append(downloadButton(item.uploads.length>1?'Original '+(i+1):'Baixar original',download(uid),'btn ghost')));
if(spec?.placements)buttons.append(button('Manifesto',()=>manifest(spec),'btn ghost'));
if(['rec','tra'].includes(order.state)&&(!row||row.status==='manual'||row.status==='failed'))
buttons.append(button('Gerar novamente',async()=>{await api('/orders/'+order.id+'/print-files',{});await load();},'btn ghost'));
body.append(buttons);
box.append(preview,body);return box;
}
function finalsSection(order,revisions,section){
section.replaceChildren(node('p','Arquivos finais','eyebrow'));
for(const file of revisions){
const rev=node('div',undefined,'rev');
rev.append(node('b',(file.kind==='final'?'Final':'Correção do cliente')+' · item '+(file.item_index+1)+' · '+file.name),
node('span',(file.expired?'expirado':file.active?'atual':'substituído')+' · '+when(file.created_at)+' · '+file.note,'faint'));
if(!file.expired){const b=downloadButton('Baixar',download(file.upload_id),'btn ghost');b.style.alignSelf='flex-start';rev.append(b);}
section.append(rev);
}
if(!['rec','tra','cor'].includes(order.state)){if(!revisions.length)section.append(node('p','Nenhum arquivo final registrado.','faint'));return;}
// A generated print file reproduces the approved layout: offer it first. Not
// after a customer correction, which replaced the artwork it was made from.
const corrected=revisions.some(file=>file.kind==='correction');
const form=node('form'); 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 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 slot=node('label',undefined,'slot');slot.append('Item '+(index+1)+' · '+PRODUCT[item.mode]);
const note=node('input');note.placeholder='Nota da revisão';note.required=true;note.maxLength=1000;note.style.width='100%';form.append(note); const input=node('input');input.type='file';input.multiple=true;input.required=true;input.dataset.finalItem=index;
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 generated=corrected?null:(order.print_files||[]).find(r=>r.item_index===index&&r.status==='ready');
const submit=node('button','Aprovar arquivos finais');submit.type='submit';form.append(submit); if(generated){
const use=node('input');use.type='checkbox';use.checked=true;use.dataset.useGenerated=index;input.required=false;input.hidden=true;
use.onchange=()=>{input.hidden=use.checked;input.required=!use.checked;};
const choice=node('label',undefined,'check');choice.append(use,(generated.detail?.source==='original'?'Usar o original como arquivo final (':'Usar o PDF gerado (')+generated.name+')');
slot.append(choice);input.generated=()=>use.checked?generated.upload_id:null;
}
slot.append(input);form.append(slot);return input;
});
const note=node('input');note.placeholder='Nota da revisão';note.required=true;note.maxLength=1000;
const check=node('input');check.type='checkbox';check.required=true;check.dataset.confirm='';
const confirm=node('label',undefined,'check');confirm.append(check,'Arquivos conferidos');
const submit=node('button','Aprovar arquivos finais','btn primary');
form.append(note,confirm,submit);
form.onsubmit=async event=>{event.preventDefault();submit.disabled=true;try{ form.onsubmit=async event=>{event.preventDefault();submit.disabled=true;try{
const refs=[]; const refs=[];
for(const [index,input] of inputs.entries())for(const file of input.files){ for(const [index,input] of inputs.entries()){
const uid=await dtfUpload(file,{api,startPath:'/orders/'+order.id+'/uploads',scope:'operator:'+order.id+':'+order.version,progress:text=>$('status').textContent=text}); const generated=input.generated?.();
if(generated){refs.push({item_index:index,upload_id:generated});continue;}
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=>say(text)});
refs.push({item_index:index,upload_id:uid}); refs.push({item_index:index,upload_id:uid});
} }
}
await api('/orders/'+order.id+'/final-files',{version:order.version,files:refs,note:note.value}); await api('/orders/'+order.id+'/final-files',{version:order.version,files:refs,note:note.value});
await load(); await load();
}catch(error){$('status').textContent=error.message;submit.disabled=false;}}; }catch(error){say(error.message,true);submit.disabled=false;}};
container.append(form); section.append(form);
} }
function historySection(order,rows,section){
const list=node('ol',undefined,'timeline');
for(const r of [...rows].reverse()){
const li=node('li');li.style.setProperty('--c',STAGE_COLORS[r.to_state]);
const text=node('div');text.append(node('b',r.back?'Voltou para '+board.states[r.to_state]:board.states[r.from_state]+' → '+board.states[r.to_state]));
if(r.reason)text.append(node('span',' · '+r.reason,'muted'));
text.append(node('small',when(r.created_at)+' · '+r.operator));li.append(text);list.append(li);
}
const first=node('li');first.style.setProperty('--c','var(--rec)');
const t=node('div');t.append(node('b','Pedido recebido e pago'),node('small',when(order.created_at)+' · '+(board.environment==='local'&&order.payment?.provider==='fake'?'pagamento de teste':'Site')));
first.append(t);list.append(first);
section.replaceChildren(node('p','Histórico','eyebrow'),list);
}
// ---- Quotes --------------------------------------------------------------
function quoteMetres(q){return (q.approved||q.draft).items.reduce((s,i)=>s+Number(i.billed_metres||i.metres),0);}
let quoteRows=[], quoteTotal=0, quotePage=1, quoteSize=20, currentQuote=null, quoteLoading=0;
async function loadQuotes(){
const token=++quoteLoading;
const page=await api('/quotes?'+new URLSearchParams({kind:quoteKind,limit:String(quoteSize),offset:String((quotePage-1)*quoteSize)}));
if(token!==quoteLoading)return;
quoteRows=page.quotes;quoteTotal=page.total;
if(quotePage>1&&!quoteRows.length&&quoteTotal){quotePage=Math.ceil(quoteTotal/quoteSize);return loadQuotes();}
renderQuotes();
}
function renderQuotes(){
const kinds=[['pending','A revisar',board.pending_total],['approved','Aprovadas, aguardando pagamento',board.approved_total]];
$('quote-filters').replaceChildren(...kinds.map(([key,label,count])=>{
const b=node('button',label+' · '+count,'chip');b.type='button';b.setAttribute('aria-pressed',String(quoteKind===key));
b.onclick=()=>{quoteKind=key;quotePage=1;currentQuote=null;loadQuotes().catch(e=>say(e.message,true));};return b;}));
if(!currentQuote||(!quoteRows.some(q=>q.id===currentQuote.id)&&currentQuote.status==='pending_review'))currentQuote=quoteRows[0]||null;
else currentQuote=quoteRows.find(q=>q.id===currentQuote.id)||currentQuote;
const list=quoteRows.map(q=>{
const b=node('button',undefined,'qitem');b.type='button';b.dataset.quotePick=q.id;b.setAttribute('aria-current',String(q.id===currentQuote?.id));
const left=node('div');left.append(node('b',q.draft.customer.mail),
node('span',q.draft.items.map(i=>SHORT[i.mode]).join(' + ')+' · '+metres(quoteMetres(q))));
if(q.draft.items.some(i=>i.production?.version!==2))left.append(node('span','Montagem antiga: peça nova cotação','flag'));
else if(q.draft.items.some(i=>i.quality_status==='warning'))left.append(node('span','Ressalva de resolução aceita pelo cliente','flag'));
if(q.auto_approved)left.append(node('span','Aprovada automaticamente'));
else if(q.review_reason)left.append(node('span','Revisão manual: '+q.review_reason,'flag'));
const right=node('div',undefined,'right');
right.append(node('b',q.approved?money(q.approved.total_cents):q.id.slice(0,8),'mono'),node('span',ago(q.created_at)));
b.append(left,right);b.onclick=()=>{currentQuote=q;renderQuotes();};return b;
});
if(!list.length)list.push(node('p',quoteKind==='pending'?'Nenhuma cotação para revisar.':'Nenhuma cotação aprovada aguardando pagamento.','faint'));
if(quoteTotal>quoteSize||quotePage>1)list.push(pager({page:quotePage,size:quoteSize,total:quoteTotal,noun:'cotações',compact:true,
onPage:n=>{quotePage=n;loadQuotes().catch(e=>say(e.message,true));},
onSize:n=>{quoteSize=n;quotePage=1;loadQuotes().catch(e=>say(e.message,true));}}));
$('quote-list').replaceChildren(...list);
$('reviews').replaceChildren(currentQuote?review(currentQuote):node('p','Selecione uma cotação.','note'));
}
function review(quote){
const card=node('article',undefined,'review');card.style.display='contents';card.dataset.quote=quote.id;
const head=node('div',undefined,'head');const title=node('div');
title.append(node('b',quote.draft.customer.mail),node('div','Cotação '+quote.id.slice(0,8)+' · enviada '+ago(quote.created_at)+' · '+
(quote.draft.destination?'entrega em '+quote.draft.destination.city+'/'+quote.draft.destination.state:'retirada em Franca'),'muted'));
head.append(title);
const content=node('div',undefined,'content');
const foot=node('div',undefined,'foot');
if(quote.status==='pending_review'&&quote.draft.items.some(item=>item.production?.version!==2)){
content.append(node('p','Montagem antiga. Solicite uma nova cotação ao cliente.','muted'));
card.append(head,content);return card;
}
if(quote.status!=='pending_review'){
content.append(node('p',quote.status==='expired'?'Cotação expirada. O cliente precisa solicitar outra.':
'Aprovada: '+money(quote.approved.total_cents)+' · aguardando pagamento','muted'));
for(const item of quote.draft.items)content.append(quoteItem(item,null));
card.append(head,content);
if(quote.status==='approved'){
// Tries the whole production flow without a payment.
foot.append(button('Criar pedido de teste',async()=>{
if(!confirm('Criar um pedido de TESTE para esta cotação? Não cobra nada e não vai para o Tiny nem para o WhatsApp.'))return;
const order=await api('/quotes/'+quote.id+'/test-order',{});
say('Pedido de teste #'+order.number+' criado em Arte recebida.');await load();
},'btn ghost'));
card.append(foot);
}
return card;
}
const form=node('form');form.style.display='contents';
const edits=quote.draft.items.map(item=>{const fields={};content.append(quoteItem(item,fields));
return ()=>({...item,metres:fields.metres.value,grade:Number(fields.grade.value)});});
const check=node('input');check.type='checkbox';check.required=true;
const confirm=node('label',undefined,'check');confirm.append(check,'Arquivos, metragem e nota conferidos');
const submit=node('button','Aprovar cotação','btn primary');
foot.append(confirm,submit);
form.append(content,foot);
form.onsubmit=async e=>{e.preventDefault();submit.disabled=true;
try{const approved=await api('/quotes/'+quote.id+'/approve',{items:edits.map(fn=>fn())});
// Keep showing the quote just approved while its list refreshes.
currentQuote={...quote,approved,status:'approved'};say('Cotação aprovada.');await load();}
catch(error){say(error.message,true);submit.disabled=false;}};
card.append(head,form);return card;
}
function quoteItem(item,fields){
const box=node('div',undefined,'item');
const preview=node('div',undefined,'preview');preview.style.width='160px';
if(item.production?.placements)preview.append(layoutPreview(item.production));
const body=node('div',undefined,'body');
const title=node('div',undefined,'title');
const quality=item.quality_status==='ok'?'qualidade ok':item.quality_status==='warning'?'ressalva '+(item.quality_acknowledged?'aceita':'não aceita'):'qualidade não verificada';
title.append(node('b',PRODUCT[item.mode]),node('span',quality+' · nota '+item.grade,item.quality_status==='ok'?'ok':'warn'));
body.append(title);
if(item.production)body.append(node('div',item.production.sources.length+' arquivo(s) · '+item.production.placements.length+' peças · '+
item.production.film_width_cm+' × '+item.production.height_cm+' cm','line'));
if(fields){
const grid=node('div',undefined,'fields');
const asked=node('label','Metros do cliente');const a=node('input');a.value=item.metres;a.readOnly=true;asked.append(a);
const m=node('label','Metros conferidos');fields.metres=node('input');Object.assign(fields.metres,{type:'number',min:'0.001',max:'12000',step:'any',value:item.metres,required:true});m.append(fields.metres);
const g=node('label','Nota conferida');fields.grade=node('input');Object.assign(fields.grade,{type:'number',min:'0',max:'100',step:'1',value:item.grade,required:true});g.append(fields.grade);
grid.append(asked,m,g);body.append(grid);
}
const buttons=node('div',undefined,'buttons');
item.uploads.forEach((uid,i)=>buttons.append(downloadButton(item.uploads.length>1?'Original '+(i+1):'Baixar original',download(uid),'btn ghost')));
if(item.production?.placements)buttons.append(button('Manifesto',()=>manifest(item.production),'btn ghost'));
body.append(buttons);box.append(preview,body);return box;
}
// ---- Payments and integrations ------------------------------------------
const tinyResult=new URLSearchParams(location.search).get('tiny');
const tinyNotice=tinyResult?(tinyResult==='connected'?' · Tiny conectado.':' · Não foi possível conectar o Tiny. Tente de novo.'):'';
if(tinyResult)history.replaceState(null,'',location.pathname);
function tinyHeader(){
const t=board.tiny||{};const el=$('tiny');
if(!t.configured){el.textContent='';return;}
const trouble=!t.connected||t.problem;
el.textContent=!t.connected?'Tiny não conectado':t.problem?'Tiny: verificar conexão':'Tiny conectado';
el.style.setProperty('--dot',trouble?'var(--warn)':'var(--fin)');
}
const TINY_PROBLEMS={refused:'O Tiny recusou a renovação da conexão. Conecte de novo.',
expired:'A conexão com o Tiny expirou. Conecte de novo.',
'renewal-failing':'A última renovação falhou; o sistema tenta de novo sozinho.',
expiring:'A conexão expira em breve e não está sendo renovada.'};
function tinyDetail(t){
if(!t.connected_by)return 'Conecte com uma conta do Tiny.';
const parts=[t.connected_by+' · conectado em '+when(t.connected_at),'renovado em '+when(t.renewed_at),
t.offline?'sem expiração diária':t.expires_at?'válido até '+when(t.expires_at):'',
'envio de pedidos '+(t.orders_enabled?'ativo':'desativado')].filter(Boolean);
const problem=TINY_PROBLEMS[t.problem];
return (problem?problem+(t.failed_at&&t.problem!=='expired'?' (falha em '+when(t.failed_at)+')':'')+' · ':'')+parts.join(' · ');
}
function integration(name,state,tone,text,actions=[]){
const box=node('div',undefined,'integration');
const head=node('div',undefined,'head');const dot=node('span',undefined,'dot');
const colors={ok:['var(--fin)','var(--ok)'],warn:['var(--warn)','var(--warn)'],off:['#5B6168','var(--muted)']}[tone];
dot.style.setProperty('--dot',colors[0]);const em=node('em',state);em.style.setProperty('--dotx',colors[1]);
head.append(dot,node('b',name),em);box.append(head,node('p',text));
if(actions.length){const b=node('div',undefined,'buttons');b.append(...actions);box.append(b);}
return box;
}
// The page bar under every list: page size, range, first/previous/page/next/last.
const PAGE_SIZES=[10,20,50,100];
function pager({page,size,total,noun,onPage,onSize,compact=false}){
const pages=Math.max(1,Math.ceil(total/size));
const bar=node('div',undefined,'pager'+(compact?' compact':''));
const sizeLabel=node('label',undefined,'pager-size');
const select=node('select');select.setAttribute('aria-label','Itens por página');
for(const n of PAGE_SIZES){const o=node('option',String(n));o.value=String(n);o.selected=n===size;select.append(o);}
select.onchange=()=>onSize(Number(select.value));
sizeLabel.append('Mostrar',select,'por página');
const nav=node('div',undefined,'pager-nav');
const from=total?(page-1)*size+1:0, to=Math.min(total,page*size);
const go=(label,aria,target,disabled)=>{const b=node('button',label,'pg');b.type='button';b.setAttribute('aria-label',aria);b.disabled=disabled;b.onclick=()=>onPage(target);return b;};
const pageLabel=node('label',undefined,'pager-page');
const input=node('input');input.type='number';input.min='1';input.max=String(pages);input.value=String(page);input.setAttribute('aria-label','Página');
input.onchange=()=>onPage(Math.min(pages,Math.max(1,Math.round(Number(input.value))||1)));
pageLabel.append(compact?'':'Página',input,'de '+pages);
nav.append(go('«','Primeira página',1,page<=1),go('‹','Página anterior',page-1,page<=1),pageLabel,
go('›','Próxima página',page+1,page>=pages),go('»','Última página',pages,page>=pages));
bar.append(sizeLabel,node('span',(compact?'':'Mostrando ')+from+' a '+to+' de '+total+(compact?'':' '+noun),'pager-range'),nav);
return bar;
}
function chips(container,options,current,pick){
container.replaceChildren(...options.map(([key,label])=>{
const b=node('button',label,'chip');b.type='button';b.setAttribute('aria-pressed',String(current===key));
b.onclick=()=>pick(key);return b;}));
}
function moreButton(container,has,fn){
if(!has)return;
const b=button('Carregar mais',fn,'btn ghost');b.style.alignSelf='flex-start';b.style.marginTop='8px';container.append(b);
}
// Payments needing a person: paged, open or resolved (history).
let issueState='open', issueRows=[], issueTotal=0, issuePage=1, issueSize=20, issueLoading=0;
async function loadIssues(){
const token=++issueLoading;
const page=await api('/payment-events?'+new URLSearchParams({state:issueState,limit:String(issueSize),offset:String((issuePage-1)*issueSize)}));
if(token!==issueLoading)return;
issueRows=page.issues;issueTotal=page.total;
if(issuePage>1&&!issueRows.length&&issueTotal){issuePage=Math.ceil(issueTotal/issueSize);return loadIssues();}
renderIssues();
}
function renderIssues(){
const filters=node('div',undefined,'chips');filters.style.marginBottom='12px';
chips(filters,[['open','Abertos'],['resolved','Resolvidos'],['all','Todos']],issueState,key=>{issueState=key;issuePage=1;loadIssues().catch(e=>say(e.message,true));});
const list=node('div');
if(!issueRows.length)list.append(node('p',issueState==='resolved'?'Nenhum pagamento resolvido.':'Nenhum pagamento pendente.','faint'));
for(const issue of issueRows){
const box=node('div',undefined,'issue');
if(issue.resolved_at)box.classList.add('done');
box.append(issue.resolved_at?icon(ICON_OK):icon(ICON_WARN));box.firstChild.style.color=issue.resolved_at?'var(--ok)':'var(--warn)';
const text=node('div',undefined,'text');
text.append(node('b',(issue.amount_cents==null?'Pagamento sem valor informado':'Pagamento de '+money(issue.amount_cents))+': '+outcomeText(issue.outcome)),
node('span',issue.provider+' · evento '+issue.event_id+' · cotação '+(issue.reference||'—')+' · '+when(issue.received_at)));
if(issue.resolved_at){
text.append(node('span','Resolvido por '+issue.resolved_by+' em '+when(issue.resolved_at)+': '+issue.resolution));
box.append(text);
}else{
const form=node('form');const note=node('input');note.placeholder='Resolução';note.setAttribute('aria-label','Resolução');note.required=true;note.minLength=3;note.maxLength=1000;
const save=node('button','Registrar resolução','btn warn');form.append(note,save);
form.onsubmit=async e=>{e.preventDefault();save.disabled=true;
try{await api('/payment-events/'+issue.id+'/resolve',{note:note.value});say('Resolução registrada.');await load();}
catch(error){say(error.message,true);save.disabled=false;}};
box.append(text,form);
}
list.append(box);
}
const bar=pager({page:issuePage,size:issueSize,total:issueTotal,noun:'pagamentos',
onPage:n=>{issuePage=n;loadIssues().catch(e=>say(e.message,true));},
onSize:n=>{issueSize=n;issuePage=1;loadIssues().catch(e=>say(e.message,true));}});
$('payments').replaceChildren(filters,list,bar);
}
// The integration send log: filtered and paged on the server.
let eventFilter={provider:'',status:'',event:'',order:''}, eventRows=[], eventTotal=0, eventPage=1, eventSize=20, eventLoading=0, eventTimer=null;
async function loadEvents(){
const token=++eventLoading;
const params=new URLSearchParams({limit:String(eventSize),offset:String((eventPage-1)*eventSize)});
for(const [key,value] of Object.entries(eventFilter))if(value)params.set(key,value);
const page=await api('/events?'+params);
if(token!==eventLoading)return;
eventRows=page.events;eventTotal=page.total;
if(eventPage>1&&!eventRows.length&&eventTotal){eventPage=Math.ceil(eventTotal/eventSize);return loadEvents();}
renderEvents();
}
function eventFilters(){
const bar=$('event-filters');if(bar.dataset.ready)return;bar.dataset.ready='1';
const select=(label,key,options)=>{
const field=node('label',undefined,'field');field.append(label);
const s=node('select');for(const [value,text] of options){const o=node('option',text);o.value=value;s.append(o);}
s.onchange=()=>{eventFilter[key]=s.value;eventPage=1;loadEvents().catch(e=>say(e.message,true));};
field.append(s);return field;
};
const order=node('label',undefined,'field');order.append('Pedido');
const input=node('input');input.type='number';input.min='1';input.placeholder='Nº';input.setAttribute('inputmode','numeric');
input.oninput=()=>{clearTimeout(eventTimer);eventTimer=setTimeout(()=>{eventFilter.order=input.value.trim();eventPage=1;loadEvents().catch(e=>say(e.message,true));},350);};
order.append(input);
bar.append(
select('Destino','provider',[['','Todos'],['tiny','Tiny'],['whatsapp','WhatsApp']]),
select('Situação','status',[['','Todas'],['delivered','Enviados'],['queued','Na fila'],['failing','Com erro']]),
select('Evento','event',[['','Todos'],...Object.entries(EVENTS)]),
order);
}
function eventResult(e){
const receipt=e.receipt||{};
if(e.delivered_at){
const situacao=receipt.situacao?' · '+receipt.situacao:'';
if(receipt.status==='created')return ['Criado no Tiny nº '+receipt.tiny_number+situacao,'good'];
if(receipt.status==='already-created')return ['Já existia no Tiny nº '+receipt.tiny_number+' (reenvio)'+situacao,'good'];
if(receipt.status==='status-updated')return ['Tiny nº '+receipt.tiny_number+situacao,'good'];
if(receipt.status==='status-unchanged')return ['Tiny nº '+receipt.tiny_number+' já estava'+situacao.replace(' · ',' em '),''];
if(receipt.status==='not-applicable')return ['Não se aplica ao Tiny',''];
return ['Registrado, sem envio',''];
}
if(e.last_error)return ['Nova tentativa · '+e.last_error+' (tentativa '+e.attempts+')','retry'];
return ['Na fila',''];
}
function renderEvents(){
const rows=eventRows.map(e=>{
const tr=node('tr');const [result,cls]=eventResult(e);
tr.append(node('td',when(e.delivered_at||e.available_at),'muted'),node('td','#'+e.payload.number,'mono'),
node('td',{tiny:'Tiny',whatsapp:'WhatsApp'}[e.provider]||e.provider),
node('td',EVENTS[e.payload.event]||e.payload.event),node('td',result,cls));
return tr;
});
if(!rows.length){const tr=node('tr');const td=node('td','Nenhum envio encontrado.','faint');td.colSpan=5;tr.append(td);rows.push(tr);}
$('events').replaceChildren(...rows);
$('events-more').replaceChildren(pager({page:eventPage,size:eventSize,total:eventTotal,noun:'envios',
onPage:n=>{eventPage=n;loadEvents().catch(e=>say(e.message,true));},
onSize:n=>{eventSize=n;eventPage=1;loadEvents().catch(e=>say(e.message,true));}}));
}
function renderIntegrations(){
const t=board.tiny||{};const cards=[];
if(!t.configured)cards.push(integration('Tiny','Não configurado','off','Credenciais ausentes no servidor.'));
else{
const connect=button(t.connected?'Reconectar':'Conectar Tiny',async()=>{const r=await api('/tiny/connect',{});location.href=r.url;},t.connected?'btn ghost':'btn');
const actions=[connect];
if(t.connected){
actions.unshift(button('Testar conexão',async()=>{const r=await api('/tiny/test',{});
say(r.ok?'Conexão com o Tiny ok: pedidos, contatos, os 4 produtos e a retirada conferidos.':'Tiny: '+Object.entries(r.results).filter(([,v])=>v!=='ok').map(([k,v])=>k+': '+v).join(' · '),!r.ok);}));}
cards.push(integration('Tiny',!t.connected?'Não conectado':t.problem?'Verificar':'Conectado',
t.connected&&!t.problem?'ok':'warn',tinyDetail(t),actions));
}
const mp=board.providers?.payment;
const mpActions=mp==='mercadopago'?[button('Verificar conta',async()=>{
const r=await api('/mercadopago/check',{});
const conta=r.account.error?'conta: '+r.account.error:'conta '+r.account.nickname+' ('+r.account.id+') · '+
(r.account.test_user?'usuário de teste':'não é usuário de teste');
const bin=Array.isArray(r.bin)?(r.bin.length?r.bin.map(o=>o.method+' '+o.type+' · '+o.issuer+' · até '+Math.max(...o.installments)+'x').join('; '):'cartão de teste não reconhecido'):'cartão: '+r.bin.error;
const w=r.webhooks||{};
const avisos='avisos nas últimas 24 h: '+w.accepted_24h+' aceitos, '+w.refused_24h+' recusados pela assinatura'+
(w.last?' · último em '+new Date(w.last.received_at).toLocaleString('pt-BR')+' ('+w.last.status+')':' · nenhum recebido ainda');
say('Mercado Pago: credencial '+(r.token==='test'?'de teste':'de produção')+' · '+conta+' · '+bin+' · '+avisos,false,60000);
},'btn ghost')]:[];
cards.push(integration('Mercado Pago',mp==='mercadopago'?'Ativo':'Não configurado',mp==='mercadopago'?'ok':'off',
mp==='mercadopago'?'PIX e cartão ativos.':'Aguardando credenciais.',mpActions));
const fr=board.providers?.freight||{};
const kg=v=>String(v).replace('.',',')+' kg';
cards.push(fr.provider==='jadlog'
? integration('Frete','Ativo','ok','Entrega pela Jadlog e retirada em Franca. Pacote: '+kg(fr.base_kg)+
' + '+kg(fr.per_metre_kg)+' por metro · prazo da Jadlog + '+fr.production_days+' dia(s) de produção.')
: integration('Frete','Não configurado','off','Somente retirada.'));
cards.push(integration('WhatsApp','Não configurado','off','Mensagens registradas, sem envio.'));
const bk=board.providers?.backup||{};
const ontem=bk.last_ok&&Date.now()-new Date(bk.last_ok)<26*3600e3;
const tam=bk.bytes?' · '+(bk.bytes<1048576?Math.max(1,Math.round(bk.bytes/1024))+' KB':(bk.bytes/1048576).toFixed(1).replace('.',',')+' MB'):'';
cards.push(integration('Backup do banco',!bk.last_ok&&!bk.failed?'Não configurado':ontem&&!bk.failed?'Em dia':'Verificar',
!bk.last_ok&&!bk.failed?'off':ontem&&!bk.failed?'ok':'warn',
(bk.last_ok?'Último backup em '+when(bk.last_ok)+tam+'.':'Nenhum backup feito ainda.')+
(bk.failed?' A última tentativa falhou ('+when(bk.failed_at)+'): '+bk.failed:'')+' Cópia diária, criptografada, fora do servidor.'));
$('integrations').replaceChildren(...cards);
eventFilters();
}
// ---- Wiring --------------------------------------------------------------
for(const b of document.querySelectorAll('[data-tab]'))b.onclick=()=>showTab(b.dataset.tab);
$('search').oninput=e=>{search=e.target.value.trim().toLowerCase();renderBoard();if(search)showTab('board');};
$('login').onsubmit=async e=>{e.preventDefault();try{await api('/login',{email:$('email').value,password:$('password').value});await load();}catch(error){say(error.message,true);}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();

152
web/portal.css Normal file
View File

@@ -0,0 +1,152 @@
/* Minha conta: the Site's look (tokens and shell from site-shell.css and
site-v2.css) for sign-in, the overview, the orders and the details. */
[hidden]{display:none!important}
body{background:var(--cinza)}
.topo .acts a[aria-current="page"]{color:var(--laranja-escuro)}
#quem{max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
.ped{padding-top:28px;padding-bottom:64px;min-height:70vh}
.ped h1{font-size:30px;font-weight:800;margin-bottom:14px}
.pedAviso{margin-bottom:14px;padding:12px 16px;border-radius:var(--raio2);background:var(--aviso-fundo);border:1px solid var(--aviso-linha);font-size:15px}
.pedMsg{margin-bottom:14px;padding:10px 14px;border-radius:var(--raio2);background:var(--verde-fundo);color:var(--verde);font-size:15px}
.pedMsg.erro{background:#FDE8E6;color:#B42318}
.pedMsg:empty{display:none}
/* Buttons, as on the Site. */
.bPri{display:inline-flex;align-items:center;justify-content:center;gap:8px;height:48px;padding:0 22px;border:0;border-radius:var(--raio2);background:var(--laranja);color:var(--preto);font-family:"Inter",sans-serif;font-weight:800;font-size:16px;text-decoration:none;cursor:pointer}
.bPri:hover:not(:disabled){background:#FFB53D}
.bPri.bMini{height:38px;padding:0 16px;font-size:15px}
.bSec{display:inline-flex;align-items:center;justify-content:center;gap:7px;height:40px;padding:0 16px;border:1px solid #D6D6D6;border-radius:var(--raio2);background:#fff;color:var(--preto);font-family:"Inter",sans-serif;font-weight:700;font-size:15px;cursor:pointer}
.bSec:hover:not(:disabled){border-color:var(--preto)}
.bSec .ic{width:18px;height:18px}
.bLink{align-self:flex-start;margin-top:12px;padding:0;border:0;background:none;color:var(--laranja-escuro);font-family:inherit;font-weight:600;font-size:15px;cursor:pointer;text-decoration:underline;text-underline-offset:3px}
button:disabled{opacity:.6;cursor:default}
/* Forms. */
.acessoGrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:16px;align-items:start}
.bloco{display:flex;flex-direction:column;margin-bottom:16px;padding:22px;border:1px solid var(--linha);border-radius:var(--raio);background:#fff}
.bloco h2{font-size:20px;font-weight:800}
.nota2{margin:4px 0 8px;font-size:14.5px;color:var(--fraco)}
.nota2 a{color:var(--laranja-escuro)}
.bloco label{margin:10px 0 5px;font-size:14px;font-weight:600;color:var(--texto2)}
.bloco label .obs{font-weight:400;color:var(--fraco)}
.bloco input,.busca2 input,.busca2 select,.correcaoForm textarea{width:100%;height:46px;padding:0 13px;border:1px solid #D6D6D6;border-radius:var(--raio2);font:inherit;font-size:16px;background:#fff;color:var(--preto)}
.bloco input[readonly]{background:var(--cinza);color:var(--texto2)}
.bloco input:focus,.busca2 input:focus,.busca2 select:focus,.correcaoForm textarea:focus{outline:none;border-color:var(--laranja);box-shadow:0 0 0 3px rgba(255,168,26,.2)}
.bloco>.bPri,.bloco>.bSec{margin-top:16px;align-self:flex-start}
.duas{display:grid;grid-template-columns:1fr 1fr;gap:0 12px}
.tres{display:grid;grid-template-columns:1fr 1fr 90px;gap:0 12px}
.duas>div,.tres>div{display:flex;flex-direction:column}
/* The account area: menu on the side, one view beside it. */
.area{display:grid;grid-template-columns:210px minmax(0,1fr);gap:28px;align-items:start}
.lateral{position:sticky;top:90px;display:flex;flex-direction:column;gap:2px;padding:10px;border:1px solid var(--linha);border-radius:var(--raio);background:#fff}
.lateral a,.lateral button{display:block;padding:10px 12px;border:0;border-radius:var(--raio2);background:none;color:var(--texto2);font:inherit;font-size:15.5px;text-align:left;text-decoration:none;cursor:pointer}
.lateral a:hover,.lateral button:hover{background:var(--cinza);color:var(--preto)}
.lateral a.on{background:var(--aviso-fundo);color:var(--laranja-escuro);font-weight:700}
.lateral button{margin-top:6px;border-top:1px solid var(--linha);border-radius:0 0 var(--raio2) var(--raio2)}
.conta{margin:-8px 0 16px;font-size:15px;color:var(--fraco)}
.acessoIntro{margin:-6px 0 14px;font-size:15px;color:var(--texto2)}
.acessoIntro:empty{display:none}
.tituloGrupo{margin:22px 0 10px;font-size:18px;font-weight:800}
/* Overview. */
.metricas{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px}
.metrica{display:flex;flex-direction:column;gap:4px;padding:16px 18px;border:1px solid var(--linha);border-radius:var(--raio);background:#fff;color:var(--texto2);font-size:14.5px;text-decoration:none}
.metrica:hover{border-color:var(--laranja)}
.metrica b{font-family:"Inter",sans-serif;font-size:28px;font-weight:800;color:var(--preto)}
.metrica.alerta{border-color:#F3B7A9;background:#FFF6F4}
.verTodos{display:inline-block;margin-top:12px;color:var(--laranja-escuro);font-weight:700;text-decoration:none}
/* Orders: filters, rows, pages. */
.filtros{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px}
.chipF{height:36px;padding:0 14px;border:1px solid #D6D6D6;border-radius:999px;background:#fff;color:var(--texto2);font:inherit;font-size:14.5px;font-weight:600;cursor:pointer}
.chipF:hover{border-color:var(--preto)}
.chipF.on{border-color:var(--laranja);background:var(--aviso-fundo);color:var(--laranja-escuro)}
.busca2{display:grid;grid-template-columns:minmax(0,1fr) 200px;gap:10px;margin-bottom:12px}
.lista{border:1px solid var(--linha);border-radius:var(--raio);background:#fff;overflow:hidden}
.lista:empty{display:none}
.item+.item{border-top:1px solid var(--linha)}
.item.pendente{background:#FFFCF6}
.linha{display:grid;grid-template-columns:90px minmax(0,1fr) auto 110px 90px;gap:14px;align-items:center;padding:14px 18px;cursor:pointer}
.item.pendente .linha{cursor:default}
.linha:hover{background:var(--cinza)}
.linha .num{font-family:"Inter",sans-serif;font-size:16px}
.oque{display:flex;flex-direction:column;min-width:0}
.oque span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:15px}
.oque .data{font-size:14px;color:var(--fraco)}
.estadoW{display:flex;align-items:center;gap:6px}
.estado{padding:4px 10px;border-radius:999px;font-family:"Inter",sans-serif;font-size:13.5px;font-weight:700;background:var(--cinza);color:var(--texto2);white-space:nowrap}
.via{font-size:13px;font-weight:700;color:var(--fraco)}
.est-fin{background:var(--verde-fundo);color:var(--verde)}
.est-cor{background:#FDE8E6;color:#B42318}
.est-pay{background:#FFF1D6;color:#8A4B00}
.est-rev{background:#EEF1F5;color:#45505E}
.est-imp,.est-fil,.est-tra,.est-rec{background:#E7F0FB;color:#1D4E89}
.linha .valor{font-family:"Inter",sans-serif;font-size:16px;text-align:right}
.acao{display:flex;justify-content:flex-end}
.acao .bSec{height:36px;padding:0 14px}
.paginas{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:12px}
.pags{display:flex;gap:6px}
.pag{min-width:38px;height:38px;border:1px solid #D6D6D6;border-radius:var(--raio2);background:#fff;font:inherit;font-weight:700;cursor:pointer}
.pag.on{border-color:var(--laranja);background:var(--aviso-fundo);color:var(--laranja-escuro);cursor:default}
.vazio{display:flex;flex-direction:column;align-items:flex-start;gap:12px;padding:26px 22px;font-size:16px;color:var(--texto2)}
/* An order opened in the list. */
.detalhe{padding:4px 18px 18px;border-top:1px dashed var(--linha);background:#fff}
.trilha{display:flex;list-style:none;margin:18px 0 6px;padding:0}
.trilha li{flex:1;position:relative;display:flex;flex-direction:column;align-items:center;gap:6px;text-align:center;font-size:14px;color:var(--fraco)}
.trilha li::before{content:"";position:absolute;top:14px;left:-50%;width:100%;height:2px;background:#E0E0E0;z-index:0}
.trilha li:first-child::before{display:none}
.trilha li.feito::before,.trilha li.atual::before{background:var(--verde)}
.trilha .marca{position:relative;z-index:1;width:30px;height:30px;border-radius:50%;display:flex;align-items:center;justify-content:center;border:2px solid #D0D0D0;background:#fff;font-family:"Inter",sans-serif;font-weight:700;font-size:14px}
.trilha .marca .ic{width:16px;height:16px;stroke-width:3}
.trilha li.feito{color:var(--verde)}
.trilha li.feito .marca{background:var(--verde-fundo);border-color:var(--verde-fundo);color:var(--verde)}
.trilha li.atual{color:var(--preto)}
.trilha li.atual .marca{background:var(--laranja);border-color:var(--laranja)}
.trilha b{font-weight:600}
.trilha li.atual b{font-weight:800}
.pedAlerta{display:flex;gap:10px;align-items:flex-start;margin:16px 0 4px;padding:12px 14px;border-radius:var(--raio2);background:#FDE8E6;color:#7A1C10;font-size:15px}
.pedAlerta .ic{flex:none;width:20px;height:20px;margin-top:1px;color:#B42318}
.itensPed{list-style:none;margin:14px 0 0;padding:0;border-top:1px solid var(--linha)}
.itensPed li{display:flex;gap:12px;align-items:baseline;padding:10px 0;border-bottom:1px solid var(--linha);font-size:15.5px}
.itensPed .nome{flex:1;font-weight:600}
.itensPed .qt{color:var(--fraco)}
.itensPed b{min-width:96px;text-align:right;font-family:"Inter",sans-serif}
.itensPed .frete .nome{font-weight:400;color:var(--texto2)}
.entregaPed{display:flex;align-items:center;gap:8px;margin-top:12px;font-size:15px;color:var(--texto2)}
.entregaPed .ic{flex:none;width:19px;height:19px;color:var(--laranja-escuro)}
.secao{margin-top:16px}
.secao h4{margin-bottom:8px;font-size:16px;font-weight:800}
.historico{list-style:none;margin:0;padding:0 0 0 14px;border-left:2px solid var(--linha)}
.historico li{position:relative;padding:0 0 12px 12px;font-size:15px}
.historico li::before{content:"";position:absolute;left:-20px;top:6px;width:10px;height:10px;border-radius:50%;background:var(--laranja)}
.historico li b{margin-right:8px}
.historico li span{color:var(--fraco)}
.historico .motivo{margin-top:4px;padding:8px 10px;border-radius:8px;background:#FDE8E6;font-size:14.5px}
.arquivo{display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid var(--linha)}
.arquivo>div{flex:1;min-width:0;display:flex;flex-direction:column}
.arquivo b{overflow-wrap:anywhere;font-size:15px}
.arquivo span{font-size:14px;color:var(--fraco)}
.correcaoForm{display:flex;flex-direction:column;padding:16px;border-radius:var(--raio2);background:var(--cinza)}
.correcaoForm label{margin:10px 0 5px;font-size:14px;font-weight:600;color:var(--texto2)}
.correcaoForm textarea{height:auto;padding:10px 13px;resize:vertical}
.correcaoForm .bPri{margin-top:14px;align-self:flex-start}
@media (max-width:820px){
.area{grid-template-columns:1fr;gap:16px}
.lateral{position:static;flex-direction:row;flex-wrap:wrap}
.lateral button{margin:0;border:0;border-radius:var(--raio2)}
.linha{grid-template-columns:minmax(0,1fr) auto;gap:6px 12px}
.linha .num{grid-column:1}
.linha .oque{grid-column:1/-1;grid-row:2}
.linha .estadoW{grid-column:2;grid-row:1;justify-self:end}
.linha .valor{grid-column:1;grid-row:3;text-align:left}
.linha .acao{grid-column:2;grid-row:3}
}
@media (max-width:600px){
.ped h1{font-size:26px}
.trilha b{font-size:12.5px}
.duas,.tres,.busca2{grid-template-columns:1fr}
}

View File

@@ -1,11 +1,169 @@
<!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> <!DOCTYPE html>
<style> <html lang="pt-BR">
*{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} <head>
</style></head><body> <meta charset="utf-8">
<header><a class="brand" href="/">DROPSTAR</a><a href="/">Enviar arte</a><span>Minha conta · DTF</span><button id="logout" hidden>Sair</button></header> <meta name="viewport" content="width=device-width, initial-scale=1">
<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> <title>Minha conta · DTF Dropstar</title>
<div id="account"></div><section id="auth" class="grid"> <link rel="preconnect" href="https://fonts.googleapis.com">
<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> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<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> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
</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> <link rel="stylesheet" href="/site-shell.css">
<script src="/privacy.js"></script><script src="/upload.js"></script><script src="/portal.js"></script></body></html> <link rel="stylesheet" href="/site-v2.css">
<link rel="stylesheet" href="/portal.css">
</head>
<body>
<div class="tarja">
ENVIE SEU ARQUIVO DTF PELO SITE · DTF TÊXTIL COM O MENOR PREÇO DO METRO EM FRANCA-SP
</div>
<header class="topo"><div class="w">
<a class="logo" href="https://www.dropstaratacado.com.br" aria-label="Dropstar Atacado"><img src="https://cdn.vnda.com.br/dropstar/2025/12/17/logo-header-15125700-16129080.png?v=1766009286" alt="Dropstar"></a>
<div class="acts">
<a href="/conta" aria-current="page"><svg class="ic" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg><span class="actTx" id="quem">Minha conta</span></a>
<a href="/carrinho"><svg class="ic" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/><path d="M3 6h18"/><path d="M16 10a4 4 0 0 1-8 0"/></svg><span class="actTx">Carrinho</span></a>
</div>
</div></header>
<main class="ped w">
<p class="pedAviso" id="dev-notice" hidden>Ambiente de desenvolvimento local. Pagamentos e notificações são simulados. Use apenas dados de teste.</p>
<p class="pedMsg" id="message" role="status"></p>
<!-- /conta/entrar -->
<section class="acesso" id="auth" hidden aria-labelledby="tEntrar">
<h1 id="tEntrar">Minha conta</h1>
<div class="acessoGrid">
<form class="bloco" id="login">
<h2>Entrar</h2>
<p class="nota2">Para ver seus pedidos e seus dados.</p>
<label for="email">E-mail</label>
<input id="email" type="email" autocomplete="username" required>
<label for="password">Senha</label>
<input id="password" type="password" autocomplete="current-password" required>
<button class="bPri">Entrar</button>
<button type="button" class="bLink" id="esqueci">Esqueci minha senha</button>
<p class="nota2" id="esqueciTx" hidden>A recuperação por e-mail chega em breve. Enquanto isso, fale com a Dropstar pelo WhatsApp <a href="https://wa.me/message/NJ76CLIOUGXLB1" target="_blank" rel="noopener">16 99428-0056</a>.</p>
</form>
<form class="bloco" id="register">
<h2>Criar conta</h2>
<p class="nota2">Os pedidos feitos neste navegador passam para a sua conta.</p>
<div class="duas">
<div><label for="cnpj">CNPJ</label><input id="cnpj" inputmode="numeric" autocomplete="off" required></div>
<div><label for="phone">WhatsApp</label><input id="phone" inputmode="numeric" autocomplete="tel" required></div>
</div>
<label for="register-email">E-mail</label>
<input id="register-email" type="email" autocomplete="email" required>
<label for="register-password">Senha <span class="obs">12 caracteres ou mais</span></label>
<input id="register-password" type="password" minlength="12" maxlength="128" autocomplete="new-password" required>
<button class="bPri">Criar conta</button>
</form>
</div>
</section>
<!-- /conta, /conta/pedidos, /conta/dados -->
<div class="area" id="area" hidden>
<nav class="lateral" aria-label="Minha conta">
<a href="/conta" data-view="geral" data-conta>Visão geral</a>
<a href="/conta/pedidos" data-view="pedidos">Pedidos</a>
<a href="/conta/dados" data-view="dados" data-conta>Dados da conta</a>
<a href="/conta/entrar" data-view="entrar" id="navEntrar">Entrar</a>
<button type="button" id="logout" hidden>Sair</button>
</nav>
<section class="vista" id="vGeral" hidden aria-labelledby="tGeral">
<h1 id="tGeral">Olá</h1>
<p class="conta" id="account"></p>
<div class="metricas" id="metricas"></div>
<h2 class="tituloGrupo">Último pedido</h2>
<div class="lista" id="ultimo"></div>
<a class="verTodos" href="/conta/pedidos" data-view="pedidos">Ver todos os pedidos ›</a>
</section>
<section class="vista" id="vPedidos" hidden aria-labelledby="tPedidos">
<h1 id="tPedidos">Pedidos</h1>
<p class="acessoIntro" id="guest"></p>
<div class="filtros" id="filtros" role="group" aria-label="Filtrar pedidos"></div>
<div class="busca2">
<label class="sr" for="fNumero">Número do pedido</label>
<input id="fNumero" inputmode="numeric" placeholder="Nº do pedido">
<label class="sr" for="fPeriodo">Período</label>
<select id="fPeriodo">
<option value="30">Últimos 30 dias</option>
<option value="90">Últimos 90 dias</option>
<option value="365">Último ano</option>
<option value="0" selected>Todo o período</option>
</select>
</div>
<div class="lista" id="orders"></div>
<div class="paginas" id="paginas"></div>
</section>
<section class="vista" id="vDados" hidden aria-labelledby="tDados">
<h1 id="tDados">Dados da conta</h1>
<form class="bloco" id="fPerfil">
<h2>Empresa e entrega</h2>
<div class="duas">
<div><label for="dCnpj">CNPJ</label><input id="dCnpj" readonly aria-readonly="true"></div>
<div><label for="dZap">WhatsApp</label><input id="dZap" inputmode="numeric" autocomplete="tel" required></div>
</div>
<p class="nota2">Endereço de entrega: preenche o carrinho quando você escolhe receber em casa. Deixe em branco para não guardar.</p>
<div class="duas">
<div><label for="dCep">CEP</label><input id="dCep" inputmode="numeric" maxlength="9" placeholder="00000-000"></div>
<div><label for="dNome">Quem recebe</label><input id="dNome" maxlength="120"></div>
</div>
<label for="dRua">Rua / avenida</label><input id="dRua" maxlength="160">
<div class="duas">
<div><label for="dNum">Número</label><input id="dNum" maxlength="20"></div>
<div><label for="dComp">Complemento (opcional)</label><input id="dComp" maxlength="80"></div>
</div>
<div class="tres">
<div><label for="dBairro">Bairro</label><input id="dBairro" maxlength="80"></div>
<div><label for="dCidade">Cidade</label><input id="dCidade" maxlength="80"></div>
<div><label for="dUf">UF</label><input id="dUf" maxlength="2"></div>
</div>
<button class="bPri">Salvar</button>
</form>
<form class="bloco" id="fEmail">
<h2>E-mail</h2>
<p class="nota2">É o seu acesso e para onde vai a nota fiscal.</p>
<label for="dMail">E-mail</label><input id="dMail" type="email" autocomplete="email" required>
<label for="dMailSenha">Senha atual, para confirmar</label><input id="dMailSenha" type="password" autocomplete="current-password" required>
<button class="bSec">Alterar e-mail</button>
</form>
<form class="bloco" id="fSenha">
<h2>Senha</h2>
<label for="dAtual">Senha atual</label><input id="dAtual" type="password" autocomplete="current-password" required>
<label for="dNova">Nova senha <span class="obs">12 caracteres ou mais</span></label><input id="dNova" type="password" minlength="12" maxlength="128" autocomplete="new-password" required>
<button class="bSec">Alterar senha</button>
<p class="nota2">Os outros aparelhos conectados saem da conta.</p>
</form>
</section>
</div>
</main>
<footer><div class="w">
<div class="frow">
<span class="fmarca"><img src="https://cdn.vnda.com.br/dropstar/2025/12/17/logo-header-15125700-16129080.png?v=1766009286" alt="Dropstar">DTF</span>
<div class="flinks">
<a href="https://www.dropstaratacado.com.br" target="_blank" rel="noopener">Loja de atacado</a>
<a href="https://www.dropstaratacado.com.br/p/termos" target="_blank" rel="noopener">Trocas e privacidade</a>
<a href="https://www.dropstaratacado.com.br/p/atendimento" target="_blank" rel="noopener">Fale conosco</a>
<a href="https://www.instagram.com/dropstar.ofc/" target="_blank" rel="noopener">Instagram</a>
</div>
<div class="fcont">
<a href="https://wa.me/message/NJ76CLIOUGXLB1" target="_blank" rel="noopener">16 99428-0056</a>
· arquivos 16 99230-2546<br>
<a href="mailto:atendimentodropstar@gmail.com">atendimentodropstar@gmail.com</a><br>
Segunda a sexta, 07h às 17h
</div>
</div>
<div class="fbase">
<span>© Dropstar Atacado · CNPJ 55.786.635/0001-58 · Franca-SP</span>
<span>Visa · Master · Elo · Amex · Pix · Boleto</span>
<span>têxtil 57 cm · UV 28,5 cm</span>
</div>
</div></footer>
<script src="/privacy.js"></script><script src="/upload.js"></script><script src="/portal.js"></script>
</body>
</html>

View File

@@ -1,61 +1,325 @@
/* Minha conta: sign-in, the overview, the orders (filtered, a page at a time,
with the cart waiting for payment among them) and the account's details.
One page; the address says which part shows (/conta, /conta/entrar,
/conta/pedidos, /conta/dados). */
const $=id=>document.getElementById(id); const $=id=>document.getElementById(id);
const node=(tag,text)=>{const el=document.createElement(tag);if(text!==undefined)el.textContent=text;return el;}; const node=(tag,text,className)=>{const el=document.createElement(tag);if(text!==undefined&&text!==null)el.textContent=text;if(className)el.className=className;return el;};
const money=c=>(c/100).toLocaleString('pt-BR',{style:'currency',currency:'BRL'}); const money=c=>(c/100).toLocaleString('pt-BR',{style:'currency',currency:'BRL'});
let scope,states={}; const quando=d=>new Date(d).toLocaleString('pt-BR',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'});
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;} const dia=d=>new Date(d).toLocaleDateString('pt-BR',{day:'2-digit',month:'2-digit',year:'2-digit'});
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;} const metros=m=>Number(m).toLocaleString('pt-BR',{minimumFractionDigits:2,maximumFractionDigits:2})+' m';
function forgetCheckout(){for(const k of ['dtf-quote','dtf-request-key','dtf-request-body'])localStorage.removeItem(k);} const soDigitos=v=>String(v||'').replace(/\D/g,'');
async function load(){ const fmtCnpj=v=>soDigitos(v).replace(/^(\d{2})(\d{3})(\d{3})(\d{4})(\d{2})$/,'$1.$2.$3/$4-$5');
try{ const fmtZap=v=>{const d=soDigitos(v);return d.length===11?'('+d.slice(0,2)+') '+d.slice(2,7)+'-'+d.slice(7):d.length===10?'('+d.slice(0,2)+') '+d.slice(2,6)+'-'+d.slice(6):v;};
scope=(await api('/session')).cart_scope; const fmtCep=v=>{const d=soDigitos(v).slice(0,8);return d.length>5?d.slice(0,5)+'-'+d.slice(5):d;};
const account=await api('/account/me'); // Lucide shapes (ISC licence), the same as on the Site.
$('auth').hidden=!!account.customer;$('logout').hidden=!account.customer; const ICONES={
$('account').textContent=account.customer?'Conta: '+account.customer.mail:''; baixar:'<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/><path d="M12 15V3"/>',
$('guest').textContent=account.customer?'':'Você está vendo apenas os pedidos desta sessão. Crie uma conta para acessar seus pedidos em outro navegador.'; local:'<path d="M20 10c0 4.99-5.54 10.19-7.4 11.8a1 1 0 0 1-1.2 0C9.54 20.19 4 14.99 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/>',
const data=await api('/customer/orders');states=data.states; caminhao:'<path d="M14 18V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v11a1 1 0 0 0 1 1h2"/><path d="M15 18H9"/><path d="M19 18h2a1 1 0 0 0 1-1v-3.65a1 1 0 0 0-.22-.62l-3.48-4.35A1 1 0 0 0 17.52 8H14"/><circle cx="17" cy="18" r="2"/><circle cx="7" cy="18" r="2"/>',
$('quotes').replaceChildren(); ok:'<path d="M20 6 9 17l-5-5"/>',
for(const quote of data.quotes){ alerta:'<path d="M12 7v6"/><path d="M12 17h.01"/>',
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.')); function icone(nome){
const link=node('a','Abrir cotação no Site');link.href='/?quote='+quote.id;card.append(link);$('quotes').append(card); const span=document.createElement('span');
} span.innerHTML='<svg class="ic" viewBox="0 0 24 24" aria-hidden="true" focusable="false">'+ICONES[nome]+'</svg>';
$('orders').replaceChildren(); return span.firstChild;
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 PRODUTO={file:'DTF Têxtil · folha montada',avulsa:'DTF Têxtil · artes avulsas',
uvfile:'DTF UV · folha montada',uv:'DTF UV · artes avulsas'};
const TRILHA=['rec','tra','fil','imp','fin'];
const TAMANHO=10;
let scope,states={},environment='',conta=null;
let filtro={status:'all',number:'',days:0,page:1};
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;}
const aviso=(text,erro)=>{$('message').textContent=text||'';$('message').classList.toggle('erro',!!erro);};
function button(text,fn,className='bSec'){const b=node('button',text,className);b.type='button';b.onclick=async event=>{event?.stopPropagation?.();b.disabled=true;try{await fn();}catch(e){aviso(e.message,true);}finally{b.disabled=false;}};return b;}
function forgetCheckout(){for(const k of ['dtf-quote','dtf-request-key','dtf-request-body'])localStorage.removeItem(k);}
// ---- Addresses -----------------------------------------------------------
function vista(){
const p=location.pathname.replace(/\/+$/,'');
if(p==='/conta/entrar') return 'entrar';
if(p==='/conta/dados') return 'dados';
if(p==='/conta') return 'geral';
return 'pedidos'; // /conta/pedidos, and the old /portal.html
}
function ir(caminho){history.pushState({},'',caminho);mostra().catch(e=>aviso(e.message,true));}
window.addEventListener('popstate',()=>mostra().catch(e=>aviso(e.message,true)));
document.addEventListener('click',event=>{
const a=event.target.closest('a[data-view]');
if(!a||event.metaKey||event.ctrlKey||event.shiftKey)return;
event.preventDefault();ir(a.getAttribute('href'));
});
async function mostra(){
aviso('');
const v=vista(), proximo=new URLSearchParams(location.search).get('next');
// Overview and details are the account's; orders also work for a guest.
if((v==='geral'||v==='dados')&&!conta){history.replaceState({},'','/conta/entrar?next='+encodeURIComponent(location.pathname));return mostra();}
if(v==='entrar'&&conta){history.replaceState({},'',proximo&&/^\/conta(\/[a-z]+)?$/.test(proximo)?proximo:'/conta');return mostra();}
$('auth').hidden=v!=='entrar';
$('area').hidden=v==='entrar';
$('vGeral').hidden=v!=='geral';$('vPedidos').hidden=v!=='pedidos';$('vDados').hidden=v!=='dados';
for(const a of document.querySelectorAll('.lateral a'))a.classList.toggle('on',a.dataset.view===v);
for(const a of document.querySelectorAll('.lateral [data-conta]'))a.hidden=!conta;
$('navEntrar').hidden=!!conta;$('logout').hidden=!conta;
document.title=({entrar:'Entrar',geral:'Minha conta',pedidos:'Pedidos',dados:'Dados da conta'})[v]+' · DTF Dropstar';
if(v==='geral')await geral();
else if(v==='pedidos')await pedidos();
else if(v==='dados')dados();
}
// ---- Orders --------------------------------------------------------------
function trilha(state){
const ol=node('ol',null,'trilha');
const atual=TRILHA.indexOf(state);
TRILHA.forEach((s,i)=>{
const feito=i<atual||state==='fin';
const li=node('li',null,feito?'feito':i===atual?'atual':'');
const marca=node('span',null,'marca');
if(feito)marca.append(icone('ok'));else marca.textContent=String(i+1);
li.append(marca,node('b',states[s]||s));ol.append(li);
});
return ol;
}
function estado(e){
if(e.kind==='quote')return e.state==='review'?['Em conferência','est-rev']:['Aguardando pagamento','est-pay'];
return [states[e.state]||e.state,'est-'+e.state];
}
function resumoItens(items){
if(!items.length)return '';
const total=items.reduce((t,i)=>t+Number(i.billed_metres||0),0);
return (items.length===1?PRODUTO[items[0].mode]||items[0].mode:items.length+' itens')+(total?' · '+metros(total):'');
}
function linha(e){
const row=node('div',null,'linha');
const [rotulo,cls]=estado(e);
const num=node('b',e.kind==='quote'?'Carrinho':'#'+e.number,'num');
const oque=node('div',null,'oque');oque.append(node('span',resumoItens(e.items)),node('span',dia(e.created_at),'data'));
const st=node('span',null,'estadoW');st.append(node('span',rotulo,'estado '+cls));
if(e.kind==='quote'&&e.pay_with==='pix')st.append(node('span','PIX','via'));
const valor=node('b',e.total_cents!=null?money(e.total_cents):'—','valor');
const acao=node('div',null,'acao');
row.append(num,oque,st,valor,acao);
const wrap=node('div',null,'item'+(e.kind==='quote'?' pendente':'')+(e.state==='cor'?' correcao':''));
wrap.append(row);
if(e.kind==='quote'){
if(e.state==='pay'){
const pagar=node('a','Pagar','bPri bMini');
pagar.href=(e.pay_with==='pix'?'/pagamento/pix':'/pagamento')+'?quote='+e.id;
acao.append(pagar);
}
return wrap;
}
wrap.id='order-'+e.id;
const detalhe=node('div',null,'detalhe');detalhe.hidden=true;
const alterna=async()=>{
if(!detalhe.hidden){detalhe.hidden=true;ver.textContent='Ver';return;}
await details(e.id,detalhe,e.created_at);detalhe.hidden=false;ver.textContent='Fechar';
};
const ver=button('Ver',alterna);
acao.append(ver);
row.addEventListener('click',event=>{if(!event.target.closest('button,a'))ver.click();});
wrap.append(detalhe);
wrap.abrir=async()=>{if(detalhe.hidden)await alterna();};
return wrap;
}
function filtros(counts,groups){
const box=$('filtros');box.replaceChildren();
const todos=Object.values(counts).reduce((a,b)=>a+b,0);
for(const [key,label] of [['all','Todos'],...Object.entries(groups)]){
const n=key==='all'?todos:counts[key];
const chip=node('button',label+(n?' · '+n:''),'chipF'+(filtro.status===key?' on':''));chip.type='button';
chip.setAttribute('aria-pressed',String(filtro.status===key));
chip.onclick=()=>{filtro={...filtro,status:key,page:1};pedidos().catch(x=>aviso(x.message,true));};
box.append(chip);
}
}
function paginas(total){
const box=$('paginas');box.replaceChildren();
const n=Math.ceil(total/TAMANHO);
if(n<=1){if(total)box.append(node('span',total+(total===1?' pedido':' pedidos'),'nota2'));return;}
const de=(filtro.page-1)*TAMANHO+1, ate=Math.min(total,filtro.page*TAMANHO);
box.append(node('span',de+'–'+ate+' de '+total+' pedidos','nota2'));
const nav=node('div',null,'pags');
const vai=p=>{filtro.page=p;pedidos().then(()=>$('vPedidos').scrollIntoView({block:'start'})).catch(x=>aviso(x.message,true));};
const b=(t,p,label)=>{const x=node('button',t,'pag'+(p===filtro.page?' on':''));x.type='button';if(label)x.setAttribute('aria-label',label);
if(p===filtro.page)x.setAttribute('aria-current','page');else if(p<1||p>n)x.disabled=true;else x.onclick=()=>vai(p);nav.append(x);};
b('‹',filtro.page-1,'Página anterior');
for(let p=Math.max(1,filtro.page-2);p<=Math.min(n,filtro.page+2);p++)b(String(p),p);
b('›',filtro.page+1,'Próxima página');
box.append(nav);
}
async function pedidos(){
$('guest').textContent=conta?'':'Você está vendo os pedidos feitos neste navegador. Entre ou crie uma conta para vê-los em qualquer lugar.';
const q=new URLSearchParams({status:filtro.status,days:String(filtro.days),page:String(filtro.page),size:String(TAMANHO)});
if(filtro.number)q.set('number',filtro.number);
const data=await api('/customer/orders?'+q);states=data.states;
filtros(data.counts,data.groups);
const lista=$('orders');lista.replaceChildren();
if(!data.items.length){
const livre=filtro.status==='all'&&!filtro.number&&!filtro.days;
const vazio=node('div',null,'vazio');
vazio.append(node('p',livre?'Seus pedidos aparecem aqui.':'Nenhum pedido com esse filtro.'));
if(livre){const a=node('a','Enviar uma arte','bPri');a.href='/#envio';vazio.append(a);}
lista.append(vazio);
}
for(const e of data.items)lista.append(linha(e));
paginas(data.total);
const aberto=new URLSearchParams(location.search).get('order');
if(aberto&&/^[0-9a-f-]{36}$/.test(aberto)){
let alvo=$('order-'+aberto);
if(!alvo){
// Not on this page of the list: shown on its own, above it.
try{const o=await api('/customer/orders/'+aberto);
alvo=linha({kind:'order',id:o.id,number:o.number,state:o.state,total_cents:o.snapshot.total_cents,
created_at:o.created_at,items:o.snapshot.items});lista.prepend(alvo);}catch(e){alvo=null;}
}
if(alvo){await alvo.abrir();alvo.scrollIntoView({block:'start'});}
}
}
let buscaTimer;
$('fNumero').addEventListener('input',e=>{e.target.value=soDigitos(e.target.value);clearTimeout(buscaTimer);
buscaTimer=setTimeout(()=>{filtro={...filtro,number:e.target.value,page:1};pedidos().catch(x=>aviso(x.message,true));},350);});
$('fPeriodo').addEventListener('change',e=>{filtro={...filtro,days:Number(e.target.value),page:1};pedidos().catch(x=>aviso(x.message,true));});
function itensPedido(snapshot){
const ul=node('ul',null,'itensPed');
for(const item of snapshot.items){
const li=node('li');
li.append(node('span',PRODUTO[item.mode]||item.mode,'nome'),node('span',metros(item.billed_metres),'qt'),node('b',money(item.total_cents)));
ul.append(li);
}
const f=snapshot.freight;
if(f&&f.total_cents){const li=node('li',null,'frete');li.append(node('span','Frete','nome'),node('span',f.days?'até '+f.days+' dias úteis':'','qt'),node('b',money(f.total_cents)));ul.append(li);}
return ul;
}
function entrega(snapshot){
const d=snapshot.destination, p=node('p',null,'entregaPed');
p.append(icone(d?'caminhao':'local'),node('span',d?'Entrega pela Jadlog · '+d.street+', '+d.number+' · '+d.city+'/'+d.state:'Retirada em Franca · Av. Major Elias Motta, 1281'));
return p;
}
async function details(id,container,criado){
const order=await api('/customer/orders/'+id);container.replaceChildren(); 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))); if(order.state==='cor'){
const history=node('ol'); const p=node('p',null,'pedAlerta');p.append(icone('alerta'),node('span','A equipe precisa de uma correção na sua arte. Veja o motivo abaixo e envie o arquivo corrigido.'));
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(p);
container.append(history); }else container.append(trilha(order.state));
container.append(itensPedido(order.snapshot),entrega(order.snapshot));
const hist=node('div',null,'secao');hist.append(node('h4','Andamento'));
const ol=node('ol',null,'historico');
// Payment is where the order starts: the first step is not a movement.
if(criado){const li=node('li');li.append(node('b',states.rec||'Arte recebida'),node('span',quando(criado)));ol.append(li);}
for(const h of order.history){
const li=node('li');li.append(node('b',states[h.to_state]),node('span',quando(h.created_at)));
if(h.reason)li.append(node('p',h.reason,'motivo'));
ol.append(li);
}
hist.append(ol);container.append(hist);
if(order.files.length){
const arq=node('div',null,'secao');arq.append(node('h4','Arquivos'));
for(const file of order.files){ 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'))); const row=node('div',null,'arquivo');
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();})); const info=node('div');
container.append(row); info.append(node('b',file.name),node('span',(file.kind==='final'?'Arquivo final':'Correção')+' · item '+(file.item_index+1)+' · '+
(file.expired?'expirado':!file.active?'substituído':'disponível até '+dia(file.expires_at))));
row.append(info);
if(file.active&&!file.expired){
const b=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();});
b.prepend(icone('baixar'));row.append(b);
}
arq.append(row);
}
container.append(arq);
} }
if(order.state==='cor'){ if(order.state==='cor'){
const form=node('form');form.append(node('h3','Enviar arte corrigida')); const form=node('form',null,'secao correcaoForm');form.append(node('h4','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 inputs=order.snapshot.items.map((item,index)=>{
const note=node('textarea');note.placeholder='Descreva a correção';note.required=true;note.maxLength=1000;form.append(note); const label=node('label',(index+1)+'. '+(PRODUTO[item.mode]||item.mode));
const submit=node('button','Enviar correção');submit.type='submit';form.append(submit); const input=node('input');input.type='file';input.multiple=true;input.dataset.item=index;
form.append(label,input);return input;
});
const nl=node('label','O que foi corrigido');const note=node('textarea');note.required=true;note.maxLength=1000;note.rows=3;
form.append(nl,note);
const submit=node('button','Enviar correção','bPri');submit.type='submit';form.append(submit);
form.onsubmit=async event=>{event.preventDefault();submit.disabled=true;try{ 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})}); 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=>aviso(text)})});
if(!files.length)throw new Error('Selecione pelo menos um arquivo corrigido.'); 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}); 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); aviso('Correção enviada. A equipe confere e segue com o pedido.');await details(id,container,criado);
}catch(error){$('message').textContent=error.message;}finally{submit.disabled=false;}}; }catch(error){aviso(error.message,true);}finally{submit.disabled=false;}};
container.append(form); container.append(form);
} }
} }
// ---- Overview ------------------------------------------------------------
async function geral(){
$('account').textContent='CNPJ '+fmtCnpj(conta.cnpj)+' · '+conta.mail;
const data=await api('/customer/orders?page=1&size=1');states=data.states;
const box=$('metricas');box.replaceChildren();
for(const [key,label] of [['pay','Aguardando pagamento'],['prod','Em produção'],['cor','Correção'],['fin','Finalizados']]){
if(key==='cor'&&!data.counts.cor)continue;
const a=node('a',null,'metrica'+(key==='cor'?' alerta':''));a.href='/conta/pedidos';a.dataset.view='pedidos';
a.addEventListener('click',()=>{filtro={status:key,number:'',days:0,page:1};});
a.append(node('span',label),node('b',String(data.counts[key])));
box.append(a);
}
const ultimo=$('ultimo');ultimo.replaceChildren();
if(data.items.length)ultimo.append(linha(data.items[0]));
else{const v=node('div',null,'vazio');v.append(node('p','Seu primeiro pedido aparece aqui.'));const a=node('a','Enviar uma arte','bPri');a.href='/#envio';v.append(a);ultimo.append(v);}
}
// ---- Account details -----------------------------------------------------
const CAMPOS_END=[['dNome','recipient'],['dRua','street'],['dNum','number'],['dComp','complement'],['dBairro','district'],['dCidade','city'],['dUf','state']];
function dados(){
$('dCnpj').value=fmtCnpj(conta.cnpj);$('dZap').value=fmtZap(conta.zap);$('dMail').value=conta.mail;
const end=conta.address||{};
$('dCep').value=fmtCep(end.postal_code||'');
for(const [id,k] of CAMPOS_END)$(id).value=end[k]||'';
$('dMailSenha').value='';$('dAtual').value='';$('dNova').value='';
}
$('dZap').addEventListener('input',e=>{e.target.value=fmtZap(soDigitos(e.target.value).slice(0,11));});
$('dCep').addEventListener('input',async e=>{
e.target.value=fmtCep(e.target.value);
const cep=soDigitos(e.target.value);
if(cep.length!==8)return;
try{const a=await api('/cep/'+cep);
for(const [id,v] of [['dRua',a.street],['dBairro',a.district],['dCidade',a.city],['dUf',a.state]])if(v)$(id).value=v;
(['dNome','dRua','dNum'].map($).find(el=>!el.value.trim())||$('dNum')).focus();
}catch(_){/* sem consulta: o cliente preenche */}
});
$('fPerfil').onsubmit=async event=>{
event.preventDefault();
const cep=soDigitos($('dCep').value);
const campos=Object.fromEntries(CAMPOS_END.map(([id,k])=>[k,$(id).value.trim()]));
const algum=cep||Object.values(campos).some(Boolean);
const address=algum?{...campos,state:campos.state.toUpperCase(),postal_code:cep}:null;
try{
const r=await api('/account/profile',{zap:$('dZap').value,address});
conta=r.customer;aviso('Dados salvos.');
}catch(e){aviso(e.status===422?(algum?'Confira o WhatsApp e o endereço: todos os campos, menos o complemento, são necessários.':'Confira o WhatsApp.'):e.message,true);}
};
$('fEmail').onsubmit=async event=>{
event.preventDefault();
try{const r=await api('/account/email',{email:$('dMail').value,password:$('dMailSenha').value});
conta=r.customer;$('dMailSenha').value='';$('quem').textContent=conta.mail;aviso('E-mail alterado. Use o novo e-mail para entrar.');}
catch(e){aviso(e.status===422?'Confira o e-mail.':e.message,true);}
};
$('fSenha').onsubmit=async event=>{
event.preventDefault();
try{await api('/account/password',{current:$('dAtual').value,new:$('dNova').value});
$('dAtual').value='';$('dNova').value='';aviso('Senha alterada.');}
catch(e){aviso(e.status===422?'A nova senha precisa ter 12 caracteres ou mais.':e.message,true);}
};
// ---- Signing in ----------------------------------------------------------
async function migrateGuestCart(previous,next){ async function migrateGuestCart(previous,next){
if(previous===next)return; 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);}); 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);});
@@ -63,14 +327,25 @@ async function migrateGuestCart(previous,next){
database.close(); database.close();
} }
async function authenticate(path,body){ async function authenticate(path,body){
const previous=scope,guest=!(await api('/account/me')).customer; const previous=scope,guest=!conta;
await api(path,body);forgetCheckout();$('message').textContent='Acesso confirmado.'; const r=await api(path,body);forgetCheckout();
const next=(await api('/session')).cart_scope; const next=(await api('/session')).cart_scope;scope=next;
if(guest)try{await migrateGuestCart(previous,next);}catch{$('message').textContent='Acesso confirmado. Não foi possível transferir o carrinho salvo neste navegador.';} if(guest)try{await migrateGuestCart(previous,next);}catch{aviso('Não foi possível transferir o carrinho salvo neste navegador.',true);}
await load();$('password').value='';$('register-password').value=''; conta=r.customer;$('quem').textContent=conta.mail;
$('password').value='';$('register-password').value='';
await mostra();
} }
$('login').onsubmit=async event=>{event.preventDefault();try{await authenticate('/account/login',{email:$('email').value,password:$('password').value});}catch(e){$('message').textContent=e.message;}}; $('login').onsubmit=async event=>{event.preventDefault();try{await authenticate('/account/login',{email:$('email').value,password:$('password').value});}catch(e){aviso(e.message,true);}};
$('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;}}; $('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){aviso(e.status===422?'Confira o CNPJ, o WhatsApp e o e-mail. A senha precisa ter 12 caracteres ou mais.':e.message,true);}};
$('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.';}}; $('esqueci').onclick=()=>{$('esqueciTx').hidden=!$('esqueciTx').hidden;};
$('refresh').onclick=load; $('logout').onclick=async()=>{try{await window.dtfForgetPrivateData();await api('/account/logout',{});location.assign('/conta/entrar');}catch(error){aviso('Não foi possível limpar os dados deste navegador. Feche as abas do Site e limpe os dados do site nas configurações do navegador.',true);}};
load();
(async()=>{
try{
const session=await api('/session');scope=session.cart_scope;environment=session.environment;
$('dev-notice').hidden=environment!=='local';
conta=(await api('/account/me')).customer;
if(conta)$('quem').textContent=conta.mail;
await mostra();
}catch(error){aviso(error.message,true);}
})();

View File

@@ -15,8 +15,13 @@ function itemPodeEnviar(item){
return item.qualityStatus==='ok' || item.qualityStatus==='unverified' || return item.qualityStatus==='ok' || item.qualityStatus==='unverified' ||
(item.qualityStatus==='warning' && item.qualityAcknowledged===true); (item.qualityStatus==='warning' && item.qualityAcknowledged===true);
} }
// Os itens marcados no carrinho são os que vão para o pagamento; um item
// desmarcado fica guardado para depois. O item aberto no produto sempre vai.
function itensAPagar(){
return [...pedido.filter(it=>it.marcado!==false),...(itemAtual?[itemAtual]:[])];
}
function cartPodeEnviar(){ function cartPodeEnviar(){
const items=[...pedido,...(itemAtual?[itemAtual]:[])]; const items=itensAPagar();
return items.length>0 && items.every(itemPodeEnviar); return items.length>0 && items.every(itemPodeEnviar);
} }
function limpaPaineis(){ function limpaPaineis(){
@@ -28,34 +33,50 @@ function limpaPaineis(){
// O carrinho junta itens de produtos diferentes e cobra uma vez só. // O carrinho junta itens de produtos diferentes e cobra uma vez só.
function pintaPedido(){ function pintaPedido(){
$('itens').innerHTML = pedido.map((it,i)=> $('itens').innerHTML = pedido.map((it,i)=>
'<div class="item"><div class="nm2"><b>'+escapeHTML(it.tit)+'</b><span>'+escapeHTML(it.desc)+'</span></div>'+ '<div class="item'+(it.marcado===false?' fora':'')+'">'+
'<label class="marca"><input type="checkbox" data-sel="'+i+'"'+(it.marcado===false?'':' checked')+
' aria-label="Incluir '+escapeHTML(it.tit)+' no pagamento"></label>'+
'<span class="miniItem">'+(it.mini ? '<img src="'+escapeHTML(it.mini)+'" alt="">' : '<i>'+escapeHTML(it.ext||'')+'</i>')+
(it.arquivos>1 ? '<em>+'+(it.arquivos-1)+'</em>' : '')+'</span>'+
'<div class="nm2"><b>'+escapeHTML(it.tit)+'</b><span>'+escapeHTML(it.desc)+'</span></div>'+
'<div class="vl2">'+rs(it.total)+'</div>'+ '<div class="vl2">'+rs(it.total)+'</div>'+
'<button class="x" data-rmi="'+i+'" aria-label="Remover">×</button></div>').join(''); '<button type="button" class="x" data-rmi="'+i+'" aria-label="Remover '+escapeHTML(it.tit)+'">'+icone('lixeira')+'<span>Remover</span></button></div>').join('');
$('itens').querySelectorAll('[data-rmi]').forEach(b=>b.addEventListener('click',()=>{ $('itens').querySelectorAll('[data-sel]').forEach(c=>c.addEventListener('change',()=>{
pedido.splice(+b.dataset.rmi,1); pintaPedido(); pedido[+c.dataset.sel].marcado=c.checked; pintaPedido();
})); }));
const itens = pedido.reduce((t,it)=>t+it.total,0) + (itemAtual? itemAtual.total : 0); $('itens').querySelectorAll('[data-rmi]').forEach(b=>b.addEventListener('click',()=>{
const i=+b.dataset.rmi, [item]=pedido.splice(i,1);
pintaPedido();
avisaDesfazer(item.tit+' saiu do carrinho.',()=>{
pedido.splice(Math.min(i,pedido.length),0,item); pintaPedido();
});
}));
$('bEsvaziar').hidden = pedido.length+(itemAtual?1:0) < 2;
const aPagar = itensAPagar();
const itens = aPagar.reduce((t,it)=>t+it.total,0);
const frete = entrega.tipo==='frete' && entrega.cotado ? entrega.valor : 0; const frete = entrega.tipo==='frete' && entrega.cotado ? entrega.valor : 0;
const soma = itens + frete; const soma = itens + frete;
const n = pedido.length + (itemAtual?1:0); const n = aPagar.length, noCarrinho = pedido.length + (itemAtual?1:0);
const eco = [...pedido, ...(itemAtual?[itemAtual]:[])] const eco = aPagar
.reduce((t,it)=>t+(TABELA[it.modo]-it.unit)*it.cob, 0); .reduce((t,it)=>t+(TABELA[it.modo]-it.unit)*it.cob, 0);
$('resLin').innerHTML = $('resLin').innerHTML =
'<div class="l"><span>'+n+(n===1?' item':' itens')+'</span><b>'+rs(itens+eco)+'</b></div>'+ '<div class="l"><span>'+n+(n===1?' item':' itens')+'</span><b>'+rs(itens+eco)+'</b></div>'+
(eco>0.01? '<div class="l" style="color:var(--verde-ml)"><span>Desconto pela nota</span>'+ (eco>0.01? '<div class="l" style="color:var(--verde-ml)"><span>Desconto pela nota</span>'+
'<b style="color:var(--verde-ml)">− '+rs(eco)+'</b></div>' : '')+ '<b style="color:var(--verde-ml)">− '+rs(eco)+'</b></div>' : '')+
'<div class="l"><span>Frete</span><b>'+ '<div class="l"><span>Frete'+(entrega.tipo==='frete' && entrega.cotado && entrega.dias
? ' · até '+entrega.dias+' dias úteis' : '')+'</span><b>'+
(entrega.tipo==='retira' ? '<span style="color:var(--verde-ml)">Grátis</span>' (entrega.tipo==='retira' ? '<span style="color:var(--verde-ml)">Grátis</span>'
: entrega.cotado ? rs(frete) : 'a cotar')+'</b></div>'+ : entrega.cotado ? rs(frete) : 'a cotar')+'</b></div>'+
'<div class="l"><span>Revisão e reencaixe</span>'+ '<div class="l"><span>Revisão e reencaixe</span>'+
'<b><span style="color:var(--verde-ml)">Grátis</span></b></div>'; '<b><span style="color:var(--verde-ml)">Grátis</span></b></div>';
$('carrTot').textContent=rs(soma); $('carrTot').textContent=rs(soma);
$('bPagar').textContent = (pedido.length + (itemAtual?1:0))>1 $('bPagarTx').textContent = n>1
? 'Pagar os '+(pedido.length+(itemAtual?1:0))+' itens de uma vez' ? 'Pagar os '+n+' itens de uma vez'
: 'Ir para o pagamento'; : 'Ir para o pagamento';
$('bPagar').disabled = !cartPodeEnviar() || !entrega.cotado || !clienteOk(); $('bPagar').disabled = !cartPodeEnviar() || !entrega.cotado || !clienteOk() || !enderecoOk();
if(pedido.length || itemAtual) $('carr').classList.add('on'); if(pedido.length || itemAtual) $('carr').classList.add('on');
if($('cartLink')) $('cartLink').textContent='Carrinho ('+n+')'; if($('cartN')){ $('cartN').textContent=noCarrinho>99?'99+':noCarrinho; $('cartN').hidden=!noCarrinho;
$('cartLink').setAttribute('aria-label','Carrinho, '+noCarrinho+(noCarrinho===1?' item':' itens')); }
window.dispatchEvent(new Event('dtf-cart-changed')); window.dispatchEvent(new Event('dtf-cart-changed'));
} }
@@ -85,7 +106,7 @@ function carrinho(){
measurement:x.med?'file':'customer'} measurement:x.med?'file':'customer'}
: {kind:'artwork',width_cm:x.cm,length_cm:+(x.cm*propDe(x)).toFixed(4), : {kind:'artwork',width_cm:x.cm,length_cm:+(x.cm*propDe(x)).toFixed(4),
copies:x.q,rotation_degrees:x.giro||0,mirrored:!!x.esp, copies:x.q,rotation_degrees:x.giro||0,mirrored:!!x.esp,
measurement:x.src?'file':'customer'}); measurement:(x.src||x.semPrevia)?'file':'customer'});
let layout; let layout;
if(ehFolha()){ if(ehFolha()){
let y=0; let y=0;
@@ -105,6 +126,9 @@ function carrinho(){
} }
itemAtual={modo, tit:MODOS[modo].tit, nota, metros, cob, unit, total:tot, itemAtual={modo, tit:MODOS[modo].tit, nota, metros, cob, unit, total:tot,
localFiles:(ehFolha()?folhas:artes).map(x=>x.f), localFiles:(ehFolha()?folhas:artes).map(x=>x.f),
mini:(ehFolha()?folhas:artes).map(x=>x.miniSrc).find(Boolean)||null,
ext:extDe(((ehFolha()?folhas:artes)[0]||{f:{name:''}}).f.name),
arquivos:(ehFolha()?folhas:artes).length,
production:{version:2,film_width_cm:larguraFilme(),sources,...layout}, production:{version:2,film_width_cm:larguraFilme(),sources,...layout},
qualityStatus:$('ciente').classList.contains('recusa')?'rejected': qualityStatus:$('ciente').classList.contains('recusa')?'rejected':
$('ciente').classList.contains('on')?'warning':nota===0?'unverified':'ok', $('ciente').classList.contains('on')?'warning':nota===0?'unverified':'ok',
@@ -114,8 +138,23 @@ function carrinho(){
previa(); previa();
if(nota<90){ if(nota<90){
const piso=pisoEscada(), ganho=(base-piso)*cob; const piso=pisoEscada(), ganho=(base-piso)*cob;
if(ganho>0.5) qm('<b>Dá para chegar ao melhor preço.</b> Corrigindo os pontos e chegando a 90, '+ if(ganho>0.5) qm('<b>Dá para pagar menos.</b> Com a arte em 300 DPI, '+
'o metro cai para '+rs(piso)+' — são '+rs(ganho)+' a menos neste pedido.','var(--verde)'); 'o metro cai para '+rs(piso)+' — são '+rs(ganho)+' a menos neste pedido.','var(--verde)');
} }
} }
// Removing is one click, and a wrong click is undone from the notice that follows.
let desfazerAcao=null, desfazerTimer=null;
function avisaDesfazer(texto,acao){
desfazerAcao=acao; clearTimeout(desfazerTimer);
$('desfazerTxt').textContent=texto; $('desfazer').hidden=false;
desfazerTimer=setTimeout(fechaDesfazer,8000);
}
function fechaDesfazer(){ clearTimeout(desfazerTimer); desfazerAcao=null; $('desfazer').hidden=true; }
addEventListener('dtf-page-changed',fechaDesfazer);
$('desfazerBtn').addEventListener('click',()=>{ const acao=desfazerAcao; fechaDesfazer(); if(acao) acao(); });
$('bEsvaziar').addEventListener('click',()=>{
const antes=pedido.slice();
pedido=[]; invalidaItemAtual(); pintaPedido();
avisaDesfazer('Carrinho esvaziado.',()=>{ pedido=antes; pintaPedido(); });
});
function qm(t,c){ const m=$('qmsg'); m.style.display='block'; m.style.color=c||'var(--verde)'; m.innerHTML=t; } function qm(t,c){ const m=$('qmsg'); m.style.display='block'; m.style.color=c||'var(--verde)'; m.innerHTML=t; }

41
web/site-compra.js Normal file
View File

@@ -0,0 +1,41 @@
/* Site DTF — the product page's buy box: price ladder, price per metre, billed length,
total and the add-to-cart button, beside the live sheet. It shows the item
the other site-*.js files already priced (itemAtual) and never computes a
price of its own, so the box and the cart always agree. */
(function(){
const vals=$('compraVals'), add=$('bAdd');
function pintaCompra(){
const it=itemAtual;
vals.hidden=!it;
add.disabled=!it || !itemPodeEnviar(it);
if(!it) return;
const semNota=!it.nota;
// The price ladder in resolution: nota = DPI ÷ 3, so 90 is 270 DPI.
const faixas=FAIXAS[it.modo], ativa=faixas.findIndex(([min])=>it.nota>=min);
$('cEscada').innerHTML=faixas.map(([min,v],i)=>
'<li'+(i===ativa?' class="on" aria-current="true"':'')+'><span>'+
(min? min*3+'+'+(i? '' : ' DPI') : semNota? 'cheio' : 'menos')+'</span>'+
'<b>'+rs(v).slice(3)+'</b></li>').join('');
const analisadas=ehFolha(it.modo) && folhas.length && folhas.every(x=>x.an);
$('cMetroLbl').textContent = semNota ? 'Metro · preço cheio'
: analisadas && folhas.every(x=>x.an.vetor) ? 'Metro · arte vetorial'
: analisadas ? 'Metro · '+Math.min(...folhas.map(x=>x.an.dpi))+' DPI'
: 'Metro';
$('cMetro').textContent=rs(it.unit);
$('cMetragem').textContent=fmtM(it.cob)+' m';
const economia=(TABELA[it.modo]-it.unit)*it.cob;
$('cEco').textContent = economia>0.005 ? rs(economia)+' a menos pela resolução da arte' : '';
const partes=ehFolha(it.modo) ? Math.ceil(it.metros/20) : 1;
$('cPartes').textContent = partes>1 ? ' Sai em '+partes+' partes de até 20 m.' : '';
const sobraCm=Math.round((it.cob-it.metros)*100);
$('cObs').textContent =
it.cob===MINIMO_M && it.metros<MINIMO_M
? (ehFolha(it.modo) ? 'Pedido mínimo de 1 m.'
: 'Pedido mínimo de 1 m: ainda cabem '+sobraCm+' cm de artes sem custo.')
: '';
$('cTotal').textContent=rs(it.total);
}
add.addEventListener('click',()=>adicionaAoCarrinho());
addEventListener('dtf-cart-changed',pintaCompra);
pintaCompra();
})();

View File

@@ -5,28 +5,43 @@
const $=id=>document.getElementById(id); const $=id=>document.getElementById(id);
const rs=v=>'R$ '+v.toFixed(2).replace('.',','); const rs=v=>'R$ '+v.toFixed(2).replace('.',',');
const fmt=b=>b<1048576?(b/1024).toFixed(0)+' KB':(b/1048576).toFixed(1).replace('.',',')+' MB'; // Ícones (Lucide, licença ISC) desenhados no próprio HTML: nada a carregar, e a
// cor vem do texto em volta. Os que ficam no HTML estático são os mesmos.
const ICONES={
conta:'<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
sacola:'<path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/><path d="M3 6h18"/><path d="M16 10a4 4 0 0 1-8 0"/>',
enviar:'<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m17 8-5-5-5 5"/><path d="M12 3v12"/>',
girar:'<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/>',
espelhar:'<path d="m3 7 5 5-5 5V7"/><path d="m21 7-5 5 5 5V7"/><path d="M12 20v2"/><path d="M12 14v2"/><path d="M12 8v2"/><path d="M12 2v2"/>',
fechar:'<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
adicionar:'<path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/><path d="M3 6h18"/><path d="M12 10v6"/><path d="M9 13h6"/>',
cadeado:'<path d="M5 11h14a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2Z"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
lixeira:'<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2"/><path d="M10 11v6"/><path d="M14 11v6"/>',
local:'<path d="M20 10c0 4.99-5.54 10.19-7.4 11.8a1 1 0 0 1-1.2 0C9.54 20.19 4 14.99 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/>',
caminhao:'<path d="M14 18V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v11a1 1 0 0 0 1 1h2"/><path d="M15 18H9"/><path d="M19 18h2a1 1 0 0 0 1-1v-3.65a1 1 0 0 0-.22-.62l-3.48-4.35A1 1 0 0 0 17.52 8H14"/><circle cx="17" cy="18" r="2"/><circle cx="7" cy="18" r="2"/>',
cartao:'<path d="M4 5h16a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2Z"/><path d="M2 10h20"/>',
qrcode:'<path d="M3 3h5v5H3z"/><path d="M16 3h5v5h-5z"/><path d="M3 16h5v5H3z"/><path d="M21 16h-3a2 2 0 0 0-2 2v3"/><path d="M21 21v.01"/><path d="M12 7v3a2 2 0 0 1-2 2H7"/><path d="M3 12h.01"/><path d="M12 3h.01"/><path d="M12 16v.01"/><path d="M16 12h1"/><path d="M21 12v.01"/><path d="M12 21v-1"/>',
copiar:'<path d="M10 8h10a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H10a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2Z"/><path d="M4 16a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2"/>',
relogio:'<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>',
ok:'<path d="M20 6 9 17l-5-5"/>',
okCirculo:'<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>',
alerta:'<path d="M12 7v6"/><path d="M12 17h.01"/>',
refazer:'<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>',
};
const icone=n=>'<svg class="ic" viewBox="0 0 24 24" aria-hidden="true" focusable="false">'+ICONES[n]+'</svg>';
const fmt=b=>b<1048576?(b/1024).toFixed(0)+' KB'
:b<1073741824?(b/1048576).toFixed(1).replace('.',',')+' MB':(b/1073741824).toFixed(1).replace('.',',')+' GB';
// ── modos // ── modos
const MODOS={ const MODOS={
file: {tit:'Arquivo por metro', lg:'DTF têxtil · 57 cm', larg:57, file: {tit:'Arquivo por metro', lg:'DTF têxtil · 57 cm', larg:57, multi:true, folha:true,
preco:'tabela R$ 19,90/m · até R$ 14,90', multi:true, folha:true, zt:'Arraste sua folha montada aqui', zs:'PNG, JPG ou PDF · 57 cm de largura'},
sub:'Sua folha montada. Revisamos, corrigimos e reencaixamos antes de imprimir.', avulsa:{tit:'Artes avulsas', lg:'DTF têxtil · 57 cm', larg:57, multi:true,
zt:'Arraste suas folhas montadas',
zs:'uma ou várias · PNG, JPG, TIFF ou PDF · PSD, AI e CDR passam por tratamento'},
avulsa:{tit:'Artes avulsas', lg:'DTF têxtil · 57 cm', larg:57,
preco:'tabela R$ 29,90/m · até R$ 24,90', multi:true,
sub:'Cada arte num arquivo. Montamos a folha com o melhor encaixe.',
zt:'Arraste suas artes aqui', zt:'Arraste suas artes aqui',
zs:'PNG ou JPG com fundo transparente · informe a largura de cada uma'}, zs:'PNG ou JPG com fundo transparente · informe a largura de cada uma'},
uvfile:{tit:'Arquivo por metro · UV', lg:'Adesivo UV · 28,5 cm', larg:28.5, uvfile:{tit:'Arquivo por metro · UV', lg:'Adesivo UV · 28,5 cm', larg:28.5, multi:true, folha:true,
preco:'tabela R$ 85,90/m · até R$ 69,90', multi:true, folha:true, zt:'Arraste sua folha de UV aqui', zs:'PNG, JPG ou PDF · 28,5 cm de largura'},
sub:'Sua folha de UV montada. Revisamos, corrigimos e reencaixamos antes de imprimir.', uv: {tit:'Artes avulsas · UV', lg:'Adesivo UV · 28,5 cm', larg:28.5, multi:true,
zt:'Arraste suas folhas de UV',
zs:'uma ou várias · PNG, JPG, TIFF ou PDF · PSD, AI e CDR passam por tratamento'},
uv: {tit:'Artes avulsas · UV', lg:'Adesivo UV · 28,5 cm', larg:28.5,
preco:'tabela R$ 99,90/m · até R$ 83,90', multi:true,
sub:'Cada arte de UV num arquivo. Montamos a folha com o melhor encaixe.',
zt:'Arraste suas artes de UV', zt:'Arraste suas artes de UV',
zs:'PNG ou JPG com fundo transparente · informe a largura de cada arte'} zs:'PNG ou JPG com fundo transparente · informe a largura de cada arte'}
}; };
@@ -75,9 +90,8 @@ let pedido=[]; // itens já fechados · o pagamento
// A escolha de entrega vale para o pedido inteiro, não por item. No Tiny, retirada // A escolha de entrega vale para o pedido inteiro, não por item. No Tiny, retirada
// grava forma_envio X, transportador DropStar e frete por conta do remetente — igual // grava forma_envio X, transportador DropStar e frete por conta do remetente — igual
// ao que a expedição já recebe hoje, sem criar processo novo. // ao que a expedição já recebe hoje, sem criar processo novo.
let entrega={tipo:'retira', cep:'', valor:0, cotado:true}; let entrega={tipo:'retira', cep:'', valor:0, cotado:true, end:{}};
let cliente={cnpj:'', zap:'', mail:''}; let cliente={cnpj:'', zap:'', mail:''};
let caminho='auto'; // o que o cliente diz que vai mandar
// CNPJ com os dois dígitos verificadores · máscara sem conta não vale nada // CNPJ com os dois dígitos verificadores · máscara sem conta não vale nada
function cnpjOk(v){ function cnpjOk(v){
@@ -93,6 +107,36 @@ function cnpjOk(v){
const zapOk=v=>{ const d=(v||'').replace(/\D/g,''); return d.length===10||d.length===11; }; const zapOk=v=>{ const d=(v||'').replace(/\D/g,''); return d.length===10||d.length===11; };
const mailOk=v=>/^[^\s@]+@[^\s@]+\.[a-z]{2,}$/i.test((v||'').trim()); const mailOk=v=>/^[^\s@]+@[^\s@]+\.[a-z]{2,}$/i.test((v||'').trim());
const clienteOk=()=>cnpjOk(cliente.cnpj)&&zapOk(cliente.zap)&&mailOk(cliente.mail); const clienteOk=()=>cnpjOk(cliente.cnpj)&&zapOk(cliente.zap)&&mailOk(cliente.mail);
// Frete cotado pelo CEP não entrega nada sozinho: a transportadora e o Tiny
// precisam do endereço completo antes do pagamento.
const UFS='AC AL AP AM BA CE DF ES GO MA MT MS MG PA PB PR PE PI RJ RN RS RO RR SC SP SE TO'.split(' ');
const enderecoOk=()=>{
if(entrega.tipo!=='frete') return true;
const e=entrega.end||{}, t=v=>(v||'').trim();
return t(e.nome).length>=2 && t(e.rua).length>=2 && t(e.num).length>=1 &&
t(e.bairro).length>=2 && t(e.cidade).length>=2 && UFS.includes(t(e.uf).toUpperCase());
};
const destinoApi=()=>entrega.tipo!=='frete' ? null : {
recipient:entrega.end.nome.trim(), street:entrega.end.rua.trim(), number:entrega.end.num.trim(),
complement:(entrega.end.comp||'').trim(), district:entrega.end.bairro.trim(),
city:entrega.end.cidade.trim(), state:entrega.end.uf.trim().toUpperCase(), postal_code:entrega.cep};
// Tempo de envio antes de medir a internet de quem compra: uma faixa entre um
// upload lento (10 Mbps) e um rápido (150 Mbps). O arquivo vai direto para o
// armazenamento, então é o upload do cliente que manda.
const ENVIO_LENTO=10e6/8, ENVIO_RAPIDO=150e6/8;
const duracao=s=>{
const min=Math.ceil(s/60);
return s<60 ? 'menos de 1 min' : min<60 ? min+' min' : Math.floor(min/60)+' h'+(min%60? ' '+min%60+' min' : '');
};
const faixaEnvio=bytes=>bytes/ENVIO_RAPIDO<60 ? 'até '+duracao(bytes/ENVIO_LENTO)
: 'de '+duracao(bytes/ENVIO_RAPIDO)+' a '+duracao(bytes/ENVIO_LENTO);
// Quanto falta para ler um arquivo grande no navegador, pelo ritmo até aqui.
// A leitura é quase linear, então o tempo gasto diz o que resta.
function faltaLeitura(l){
if(!l || !(l.p>0.02)) return 'calculando o tempo…';
const s=(Date.now()-l.inicio)/1000*(1-l.p)/l.p;
return s<10 ? 'faltam '+Math.max(1,Math.ceil(s))+' s' : s<60 ? 'faltam cerca de '+Math.ceil(s/5)*5+' s' : 'faltam cerca de '+duracao(s);
}
const larguraFilme=()=>MODOS[modo].larg; const larguraFilme=()=>MODOS[modo].larg;
// cobrança proporcional: arredonda para cima em 10 cm · 2,75 m vira 2,80 m // cobrança proporcional: arredonda para cima em 10 cm · 2,75 m vira 2,80 m
const cobrar=m=>Math.max(MINIMO_M, Math.ceil(m*10)/10); const cobrar=m=>Math.max(MINIMO_M, Math.ceil(m*10)/10);

View File

@@ -1,4 +1,4 @@
/* Site DTF — Carousel, path selector, delivery choice and input masks. /* Site DTF — Carousel, delivery choice and input masks.
Extracted verbatim from the single inline script in web/index.html. Extracted verbatim from the single inline script in web/index.html.
Loaded as classic scripts in the order listed there: they share one global Loaded as classic scripts in the order listed there: they share one global
scope and run top to bottom, exactly as the original did. */ scope and run top to bottom, exactly as the original did. */
@@ -7,15 +7,36 @@
// Por isso a nota NÃO muda aqui. E a metragem nova só existe depois que o motor // Por isso a nota NÃO muda aqui. E a metragem nova só existe depois que o motor
// roda na folha de verdade, no servidor: o navegador recebe a folha fechada, // roda na folha de verdade, no servidor: o navegador recebe a folha fechada,
// não as artes separadas, então não há como recalcular aqui sem inventar número. // não as artes separadas, então não há como recalcular aqui sem inventar número.
// Loose artworks still show the mounted sheet below; a ready sheet goes
// straight to the cart.
$('qOk').addEventListener('click',()=>{ $('qOk').addEventListener('click',()=>{
if($('qOk').disabled) return; if($('qOk').disabled) return;
if(!$('prev').classList.contains('on')){ adicionaAoCarrinho(); return; }
const ok=$('ciente').classList.contains('on'); const ok=$('ciente').classList.contains('on');
qm(ok? '<b>Registrado.</b> Você confirmou que viu a ressalva de resolução.' qm(ok? '<b>Registrado.</b> Você confirmou que viu a ressalva de resolução.'
: '<b>Certo.</b> Seu pedido está montado abaixo.','var(--tx)'); : '<b>Certo.</b> Confira a folha montada abaixo.','var(--tx)');
$('carr').scrollIntoView({behavior:'smooth',block:'nearest'}); $('prev').scrollIntoView({behavior:'smooth',block:'start'});
}); });
$('pOk').addEventListener('click',()=>$('carr').scrollIntoView({behavior:'smooth'})); // The finished item joins the order and the product page starts over empty.
function guardaItem(){
if(itemAtual && !itemPodeEnviar(itemAtual)){
$('ciente').scrollIntoView({behavior:'smooth',block:'center'});
return false;
}
if(itemAtual){ pedido.push(itemAtual); itemAtual=null; }
$('atual').style.display='none'; $('carrLin').innerHTML='';
$('foco').classList.remove('on');
['qual','prev'].forEach(id=>$(id).classList.remove('on'));
modo=null; artes=[]; folhas=[]; metros=0; nota=0; montagemCm=0;
pintaPedido();
return true;
}
function adicionaAoCarrinho(){
if(!itemAtual){ $('compra').scrollIntoView({behavior:'smooth',block:'nearest'}); return; }
if(guardaItem()) vaiPara(CARRINHO);
}
$('pOk').addEventListener('click',adicionaAoCarrinho);
$('pMais').addEventListener('click',()=>$('foco').scrollIntoView({behavior:'smooth'})); $('pMais').addEventListener('click',()=>$('foco').scrollIntoView({behavior:'smooth'}));
$('pVolta').addEventListener('click',()=>{ $('pVolta').addEventListener('click',()=>{
folhas=[]; artes=[]; reencaixado=false; pintaFolha(); pintaArtes(); folhas=[]; artes=[]; reencaixado=false; pintaFolha(); pintaArtes();
@@ -52,42 +73,13 @@ $('pVolta').addEventListener('click',()=>{
$('cPro').addEventListener('click',()=>ir(atual()+1)); $('cPro').addEventListener('click',()=>ir(atual()+1));
})(); })();
// A declaração de formato só orienta: filtra o seletor e mostra o preço esperado.
// Se o arquivo que chegar contrariar, o arquivo ganha e o site avisa.
const AUTO=/[.](png|jpe?g|webp|pdf)$/i;
function pintaCaminhos(){
if(!$('caminhos')) return;
const ehF=modo&&ehFolha();
$('caminhos').style.display = ehF? '' : 'none';
if(!ehF) return;
document.querySelectorAll('#caminhos .cam').forEach(b=>b.classList.toggle('on', b.dataset.cam===caminho));
$('camA').textContent='a partir de '+rs(pisoEscada());
$('camB').textContent=rs(TABELA[modo])+' fixo';
$('inp').accept = caminho==='auto' ? '.png,.jpg,.jpeg,.pdf'
: '.tif,.tiff,.psd,.psb,.ai,.cdr';
$('zSub').textContent = caminho==='auto'
? 'PNG, JPG ou PDF · conferência automática, nota na tela'
: 'CDR, AI, PSD ou TIFF · sem conferência, metro pela tabela';
}
document.querySelectorAll('#caminhos .cam').forEach(b=>b.addEventListener('click',()=>{
caminho=b.dataset.cam; avisoCam(''); pintaCaminhos();
}));
function avisoCam(txt){
const el=$('trocouCam'); if(!el) return;
el.innerHTML=txt; el.style.display = txt? '' : 'none';
}
function pintaEntrega(){ function pintaEntrega(){
document.querySelectorAll('.opE').forEach(b=> document.querySelectorAll('.opE').forEach(b=>
b.classList.toggle('on', b.dataset.ent===entrega.tipo)); b.classList.toggle('on', b.dataset.ent===entrega.tipo));
$('cep').classList.toggle('on', entrega.tipo==='frete'); $('cep').classList.toggle('on', entrega.tipo==='frete');
$('vFrete').textContent = entrega.tipo!=='frete' ? '—' $('vFrete').textContent = entrega.tipo!=='frete' ? '—'
: entrega.cotado ? rs(entrega.valor) : 'a cotar'; : entrega.cotado ? rs(entrega.valor) : 'a cotar';
$('avisoE').innerHTML = entrega.tipo==='retira' $('bPagar').disabled = !entrega.cotado || !clienteOk() || !enderecoOk() || !cartPodeEnviar();
? 'DTF é impresso depois que você paga. Avisamos no WhatsApp quando estiver '+
'<b>pronto para retirar</b> — não venha antes do aviso.'
: '';
$('bPagar').disabled = !entrega.cotado || !clienteOk() || !cartPodeEnviar();
pintaPedido(); pintaPedido();
} }
@@ -131,6 +123,14 @@ $('zMenos').addEventListener('click',()=>{
if(zoomI>0){ zoomI--; pintaZoom(); previaAoVivo(); } if(zoomI>0){ zoomI--; pintaZoom(); previaAoVivo(); }
}); });
pintaZoom(); pintaZoom();
// A ready sheet has nothing to zoom: "Ampliar" shows the same preview bigger.
$('bAmpliar').addEventListener('click',()=>{
const previas=$('vArea').querySelector('.folhaPrevias'); if(!previas) return;
$('ampliaArea').replaceChildren(previas.cloneNode(true));
$('ampliaDlg').showModal();
});
$('ampliaFecha').addEventListener('click',()=>$('ampliaDlg').close());
$('ampliaDlg').addEventListener('click',e=>{ if(e.target===$('ampliaDlg')) $('ampliaDlg').close(); });
document.querySelectorAll('.opE').forEach(b=>b.addEventListener('click',()=>{ document.querySelectorAll('.opE').forEach(b=>b.addEventListener('click',()=>{
entrega.tipo=b.dataset.ent; entrega.tipo=b.dataset.ent;
@@ -138,39 +138,73 @@ document.querySelectorAll('.opE').forEach(b=>b.addEventListener('click',()=>{
entrega.valor=0; entrega.valor=0;
if(entrega.tipo==='frete') $('cepMsg').innerHTML=''; if(entrega.tipo==='frete') $('cepMsg').innerHTML='';
pintaEntrega(); pintaEntrega();
// A CEP already typed is quoted again as soon as delivery is chosen.
if(entrega.tipo==='frete' && entrega.cep.length===8 && window.dtfFreight) window.dtfFreight();
})); }));
function fmtCep(v){ v=String(v||'').replace(/\D/g,'').slice(0,8); return v.length>5 ? v.slice(0,5)+'-'+v.slice(5) : v; }
// The address of a complete CEP fills the form; only the number is left to type.
let cepBuscado='';
async function buscaEndereco(cep){
if(cep===cepBuscado || !window.dtfApi) return;
cepBuscado=cep;
try{
const a=await window.dtfApi('/cep/'+cep);
if(entrega.cep!==cep) return;
for(const [id,chave,valor] of [['eRua','rua',a.street],['eBairro','bairro',a.district],
['eCidade','cidade',a.city],['eUf','uf',a.state]]){
if(!valor) continue;
$(id).value=valor; entrega.end={...(entrega.end||{}),[chave]:valor};
}
pintaEntrega();
const vazio=['eNome','eRua','eNum'].map($).find(el=>!el.value.trim());
if(vazio && document.activeElement===$('cepIn')) vazio.focus();
}catch(e){ /* sem consulta: o cliente preenche o endereço */ }
}
$('cepIn').addEventListener('input',e=>{ $('cepIn').addEventListener('input',e=>{
const v=e.target.value.replace(/\D/g,'').slice(0,8); const v=e.target.value.replace(/\D/g,'').slice(0,8);
e.target.value = v.length>5 ? v.slice(0,5)+'-'+v.slice(5) : v; e.target.value = fmtCep(v);
entrega.cep=v; entrega.cotado=false; entrega.valor=0; pintaEntrega(); if(v===entrega.cep) return;
entrega.cep=v; entrega.cotado=false; entrega.valor=0; entrega.dias=null; pintaEntrega();
if(v.length===8){ buscaEndereco(v); if(window.dtfFreight) window.dtfFreight(); }
}); });
$('bCep').addEventListener('click',async()=>{ for(const [id,chave] of [['eNome','nome'],['eRua','rua'],['eNum','num'],['eComp','comp'],
if(entrega.cep.length!==8){ $('cepMsg').innerHTML='CEP incompleto.'; return; } ['eBairro','bairro'],['eCidade','cidade'],['eUf','uf']]){
if(window.dtfFreight){ await window.dtfFreight(); return; } $(id).addEventListener('input',e=>{
// A cotação vem da transportadora. Enquanto a integração não existir, o pedido não if(chave==='uf') e.target.value=e.target.value.replace(/[^a-z]/gi,'').toUpperCase();
// fecha por frete: o valor precisa estar fechado antes do Mercado Pago. entrega.end={...(entrega.end||{}),[chave]:e.target.value};
$('cepMsg').innerHTML='<b style="color:#FFB63D">Cotação de frete ainda não integrada.</b><br>'+
'O valor precisa vir da transportadora antes do pagamento — senão você pagaria um '+
'total que não é o final. Por enquanto, retire em Franca ou fale conosco no WhatsApp.';
pintaEntrega(); pintaEntrega();
}); });
}
$('bMais').addEventListener('click',()=>{ $('bMais').addEventListener('click',()=>{ if(guardaItem()) vaiPara('/#envio'); });
if(itemAtual && !itemPodeEnviar(itemAtual)){
$('qual').scrollIntoView({behavior:'smooth',block:'nearest'});
return;
}
if(itemAtual){ pedido.push(itemAtual); itemAtual=null; }
$('atual').style.display='none'; $('carrLin').innerHTML='';
$('foco').classList.remove('on'); $('cards').style.display='';
['qual','prev'].forEach(id=>$(id).classList.remove('on'));
modo=null; artes=[]; folhas=[]; metros=0; nota=0; montagemCm=0;
pintaPedido();
document.getElementById('envio').scrollIntoView({behavior:'smooth'});
});
$('bPagar').addEventListener('click',()=>{ $('bPagar').addEventListener('click',()=>{
if(window.dtfCheckout) window.dtfCheckout(); if(window.dtfCheckout) window.dtfCheckout();
else alert('O pedido online está indisponível no momento. Tente novamente em instantes.'); else alert('O pedido online está indisponível no momento. Tente novamente em instantes.');
}); });
pintaEntrega(); pintaEntrega();
// Dúvidas: every question stays in the row of links; one answer is open at a
// time, below the row, and its own link closes it again.
(function(){
const req=$('req'); if(!req) return;
const itens=[...req.querySelectorAll('details')];
const barra=document.createElement('div'); barra.className='reqLinks';
const botoes=itens.map(d=>{
const b=document.createElement('button');
b.type='button'; b.textContent=d.querySelector('summary').textContent.trim();
b.addEventListener('click',()=>{
const abrir=!d.open;
itens.forEach(x=>{ x.open=false; });
d.open=abrir;
});
return b;
});
const pinta=()=>botoes.forEach((b,i)=>{
b.setAttribute('aria-expanded',String(itens[i].open));
b.classList.toggle('on',itens[i].open);
});
itens.forEach(d=>d.addEventListener('toggle',pinta));
barra.append(...botoes);
req.querySelector('.reqcab').after(barra);
pinta();
})();

View File

@@ -3,32 +3,41 @@
Loaded as classic scripts in the order listed there: they share one global Loaded as classic scripts in the order listed there: they share one global
scope and run top to bottom, exactly as the original did. */ scope and run top to bottom, exactly as the original did. */
// ── escolha // ── escolha
document.querySelectorAll('.ec').forEach(b=>b.addEventListener('click',()=>abrir(b.dataset.modo))); document.querySelectorAll('.ec').forEach(b=>b.addEventListener('click',()=>escolheProduto(b.dataset.modo)));
// Back on the home and into the same product: the artworks sent are still there.
function escolheProduto(m){
if(modo===m && $('foco').classList.contains('on')) vaiPara(PAGINAS[m]);
else abrir(m);
}
function abrir(m){ function abrir(m){
modo=m; artes=[]; folhas=[]; metros=0; nota=0; reencaixado=false; modo=m; artes=[]; folhas=[]; metros=0; nota=0; reencaixado=false;
montagemCm=0; montagemCm=0;
avisoTipo(''); avisoTipo('');
pintaModo(); pintaModo();
$('lista').innerHTML=''; $('rA').style.display='none'; recusa([]); $('lista').innerHTML=''; $('rA').style.display='none'; recusa([]);
recemChegada=null; caminho='auto'; avisoCam(''); pintaCaminhos(); recemChegada=null;
$('vMt').textContent='—'; previaAoVivo(); agendaAvaliacao(); $('vMt').textContent='—'; previaAoVivo(); agendaAvaliacao();
limpaPaineis(); // o carrinho só some se o pedido estiver vazio limpaPaineis(); // o carrinho só some se o pedido estiver vazio
$('foco').scrollIntoView({behavior:'smooth',block:'start'}); vaiPara(PAGINAS[m]);
} }
function pintaModo(){ function pintaModo(){
const m=modo; const m=modo;
const c=MODOS[m]; const c=MODOS[m];
$('cards').style.display='none'; $('foco').classList.add('on'); $('cards').style.display='none'; $('foco').classList.add('on');
$('fTit').textContent=c.tit; $('fLg').textContent=c.lg; $('fTit').textContent=c.tit; $('fLg').textContent=c.lg;
$('fPreco').textContent=c.preco; $('fSub').textContent=c.sub;
$('zTit').textContent=c.zt; $('zSub').textContent=c.zs; $('zTit').textContent=c.zt; $('zSub').textContent=c.zs;
$('zManual').hidden=!ehFolha(m);
$('zOutra').textContent = ehFolha(m) ? 'Adicionar outra folha' : 'Adicionar mais artes';
$('zona').classList.remove('compacta');
$('inp').multiple=c.multi; $('inp').multiple=c.multi;
$('inp').accept = ehFolha(m) ? '.png,.jpg,.jpeg,.pdf' // The file decides the price: PNG, JPG and PDF are checked here, the others by hand.
$('inp').accept = ehFolha(m) ? '.png,.jpg,.jpeg,.pdf,.tif,.tiff,.psd,.psb,.ai,.cdr'
: '.png,.jpg,.jpeg,.webp'; : '.png,.jpg,.jpeg,.webp';
$('catFoco').className='cat '+(ehFolha(m)?'file':'av'); $('catFoco').className='cat '+(ehFolha(m)?'file':'av');
$('larguraFolhaPronta').textContent=String(c.larg).replace('.',','); $('larguraFolhaPronta').textContent=String(c.larg).replace('.',',');
$('foco2').classList.remove('sofila'); // a caixa de requisitos vale nos 4 modos $('foco2').classList.remove('sofila'); // a caixa de requisitos vale nos 4 modos
$('montcabTit').textContent = ehFolha(m) ? 'Sua folha' : 'Montagem ao vivo'; $('montcabTit').textContent = ehFolha(m) ? 'Sua folha' : 'Montagem ao vivo';
$('qOk').innerHTML = ehFolha(m) ? icone('adicionar')+'<span>Adicionar ao carrinho</span>' : 'Ver a folha montada';
pintaTipoEnvio(); pintaTipoEnvio();
} }
// O par folha montada / artes separadas fica visível e com preço nos dois lados, // O par folha montada / artes separadas fica visível e com preço nos dois lados,
@@ -40,8 +49,8 @@ function pintaTipoEnvio(){
if(el.hidden) return; if(el.hidden) return;
const folha = ehFolha() ? modo : PAR[modo]; const folha = ehFolha() ? modo : PAR[modo];
const avulsa = ehFolha() ? PAR[modo] : modo; const avulsa = ehFolha() ? PAR[modo] : modo;
$('tipoFolhaPreco').textContent='a partir de '+rs(pisoEscada(folha)); $('tipoFolhaPreco').textContent=rs(pisoEscada(folha))+' a '+rs(TABELA[folha]).slice(3)+' /m';
$('tipoAvulsaPreco').textContent='a partir de '+rs(pisoEscada(avulsa))+' · montagem inclusa'; $('tipoAvulsaPreco').textContent='+ '+rs(MONTAGEM[avulsa])+' /m';
$('larguraFolhaPronta').textContent=String(MODOS[folha].larg).replace('.',','); $('larguraFolhaPronta').textContent=String(MODOS[folha].larg).replace('.',',');
const atual = ehFolha() ? 'folha' : 'avulsa'; const atual = ehFolha() ? 'folha' : 'avulsa';
el.querySelectorAll('.cam').forEach(b=>{ el.querySelectorAll('.cam').forEach(b=>{
@@ -81,15 +90,15 @@ function trocaTipo(destino){
if(!destino || modo===destino) return; if(!destino || modo===destino) return;
folhas=[]; artes=[]; recemChegada=null; metros=0; nota=0; montagemCm=0; folhas=[]; artes=[]; recemChegada=null; metros=0; nota=0; montagemCm=0;
modo=destino; modo=destino;
history.replaceState(history.state,'',PAGINAS[modo]); document.title=tituloPagina();
$('lista').innerHTML=''; $('rA').style.display='none'; recusa([]); $('lista').innerHTML=''; $('rA').style.display='none'; recusa([]);
caminho='auto'; avisoCam(''); pintaModo(); limpaPaineis(); agendaAvaliacao(); previaAoVivo();
pintaModo(); pintaCaminhos(); limpaPaineis(); agendaAvaliacao(); previaAoVivo();
} }
$('bVoltar').addEventListener('click',()=>{ $('bVoltar').addEventListener('click',()=>{
itemAtual=null; $('atual').style.display='none'; $('carrLin').innerHTML=''; pintaPedido(); itemAtual=null; modo=null; $('atual').style.display='none'; $('carrLin').innerHTML=''; pintaPedido();
$('foco').classList.remove('on'); $('cards').style.display=''; $('foco').classList.remove('on');
['qual','prev'].forEach(id=>$(id).classList.remove('on')); ['qual','prev'].forEach(id=>$(id).classList.remove('on'));
if(!pedido.length) $('carr').classList.remove('on'); if(!pedido.length) $('carr').classList.remove('on');
document.getElementById('envio').scrollIntoView({behavior:'smooth'}); vaiPara('/#envio');
}); });

View File

@@ -12,6 +12,18 @@ function carregarImagem(file){
fr.readAsDataURL(file); fr.readAsDataURL(file);
}); });
} }
// The image each artwork was measured with, loaded once and reused on every
// repaint: the packing used to read and decode the whole file again each
// time. Kept outside the artwork, which is saved with the cart.
const imagensDasArtes=new WeakMap();
function imagemDaArte(a){
if(!a.src) return Promise.resolve(null);
const guardada=imagensDasArtes.get(a);
if(guardada && guardada.src===a.src) return guardada.img;
const img=new Promise(res=>{ const im=new Image(); im.onload=()=>res(im); im.onerror=()=>res(null); im.src=a.src; });
imagensDasArtes.set(a,{src:a.src,img});
return img;
}
// A folha precisa parecer uma folha: borda, largura marcada e régua de metros. // A folha precisa parecer uma folha: borda, largura marcada e régua de metros.
// Sem isso o cliente não tem noção de escala e acha que está mal encaixado. // Sem isso o cliente não tem noção de escala e acha que está mal encaixado.
function molduraFolha(ctx,W,H,larguraCm,metrosReais){ function molduraFolha(ctx,W,H,larguraCm,metrosReais){
@@ -199,12 +211,14 @@ function desenhaMontagem(area,lista,mts,W,fora,aoTerminar){
return; return;
} }
Promise.all(itens.map(a=>carregarImagem(a.f).then(img=>({a,img})))).then(imagens=>{ Promise.all(itens.map(a=>imagemDaArte(a).then(img=>({a,img})))).then(imagens=>{
if(area.montagemVersao!==versao) return; if(area.montagemVersao!==versao) return;
const cargas=imagens.flatMap(c=>Array.from({length:c.a.q||1},(_,copyIndex)=>({...c,copyIndex}))); const cargas=imagens.flatMap(c=>Array.from({length:c.a.q||1},(_,copyIndex)=>({...c,copyIndex})));
cargas.forEach(c=>{ cargas.forEach(c=>{
c.w = c.a.cm; // o motor trabalha em centímetros c.w = c.a.cm; // o motor trabalha em centímetros
c.h = c.w * (c.img ? (c.a.giro? c.img.width/c.img.height : c.img.height/c.img.width) : 1); // The file's own proportion: a large artwork is drawn from a smaller copy.
const prop = c.a.prop>0 ? c.a.prop : c.img ? c.img.height/c.img.width : 1;
c.h = c.w * (c.a.giro? 1/prop : prop);
c.travado = !!c.a.giro; // giro do cliente manda no encaixe c.travado = !!c.a.giro; // giro do cliente manda no encaixe
}); });
const enc=encaixar(cargas,filme); const enc=encaixar(cargas,filme);
@@ -272,24 +286,43 @@ function previaAoVivo(){
clearTimeout(tMont); clearTimeout(tMont);
montagemLayout=null; montagemLayout=null;
$('vArea').montagemVersao=($('vArea').montagemVersao||0)+1; $('vArea').montagemVersao=($('vArea').montagemVersao||0)+1;
if(!ehFolha()) $('bAmpliar').hidden=true;
if(ehFolha()){ if(ehFolha()){
$('vUso').innerHTML=''; $('vUso').innerHTML='';
const tot=folhaTotalM(); const tot=folhaTotalM();
$('vMt').textContent = tot>0 ? fmtM(cobrar(tot))+' m' : '—'; $('vMt').textContent = tot>0 ? fmtM(cobrar(tot))+' m' : '—';
const medidas=folhas.filter(x=>x.med); const medidas=folhas.filter(x=>x.med);
const previews=folhas.filter(x=>x.previewSrc); // Sheets still being read show a placeholder; a sheet printed N times is
// shown N times (up to REPETE_MAX, then a count).
const REPETE_MAX=6;
const previews=folhas.filter(x=>x.previewSrc || (x.pct!=null && x.med && x.med.grande));
if(previews.length){ if(previews.length){
const painel=document.createElement('div'); painel.className='folhaPrevias'; const painel=document.createElement('div'); painel.className='folhaPrevias';
previews.forEach((x,index)=>{ previews.forEach((x,index)=>{
const figura=document.createElement('figure'); figura.className='folhaPrevia'; const figura=document.createElement('figure'); figura.className='folhaPrevia';
const imagem=new Image(); imagem.src=x.previewSrc; imagem.alt=x.f.name;
const legenda=document.createElement('figcaption'); const legenda=document.createElement('figcaption');
const vezes=Math.max(1,x.rep||1);
legenda.textContent=(previews.length>1 ? (index+1)+'. ' : '')+x.f.name+ legenda.textContent=(previews.length>1 ? (index+1)+'. ' : '')+x.f.name+
(x.med ? ' · '+n1(x.med.larg)+' × '+n1(x.med.alt)+' cm' : ''); (x.med ? ' · '+n1(x.med.larg)+' × '+n1(x.med.alt)+' cm' : '')+(vezes>1 ? ' · '+vezes+' cópias' : '');
figura.append(imagem,legenda); painel.append(figura); if(!x.previewSrc){
const esqueleto=document.createElement('div'); esqueleto.className='skel folhaSkel';
esqueleto.style.aspectRatio=x.med ? x.med.larg+' / '+Math.min(x.med.alt,x.med.larg*4) : '1 / 2';
esqueleto.append(Object.assign(document.createElement('span'),{textContent:'Gerando a prévia da folha…'}));
figura.append(esqueleto,legenda); painel.append(figura); return;
}
const pilha=document.createElement('div'); pilha.className='folhaPilha';
for(let k=0;k<Math.min(vezes,REPETE_MAX);k++){
const imagem=new Image(); imagem.src=x.previewSrc; imagem.alt=k? '' : x.f.name;
imagem.style.maxHeight=Math.floor(520/Math.min(vezes,REPETE_MAX))+'px'; pilha.append(imagem);
}
if(vezes>REPETE_MAX) pilha.append(Object.assign(document.createElement('div'),
{className:'folhaMais', textContent:'+ '+(vezes-REPETE_MAX)+' cópia'+(vezes-REPETE_MAX>1?'s':'')}));
figura.append(pilha,legenda); painel.append(figura);
}); });
$('vArea').replaceChildren(painel); $('vArea').replaceChildren(painel);
$('bAmpliar').hidden=false;
}else{ }else{
$('bAmpliar').hidden=true;
$('vArea').innerHTML = medidas.length $('vArea').innerHTML = medidas.length
? '<div class="semmont"><b>'+(medidas.length>1 ? '<div class="semmont"><b>'+(medidas.length>1
? medidas.length+' folhas · '+fmtM(tot)+' m' ? medidas.length+' folhas · '+fmtM(tot)+' m'
@@ -302,7 +335,9 @@ function previaAoVivo(){
return; return;
} }
tMont=setTimeout(()=>{ tMont=setTimeout(()=>{
const prontas=artes.filter(a=>a.cm>0 && a.cm<=larguraFilme() && a.src); // o produto pode ter sido fechado nesses 140 ms (item foi para o carrinho)
if(!modo) return;
const prontas=artes.filter(a=>a.cm>0 && a.cm<=larguraFilme() && (a.src||a.semPrevia));
const W=Math.round(300*ZOOMS[zoomI]); const W=Math.round(300*ZOOMS[zoomI]);
$('vArea').classList.toggle('ampliado', zoomI>0); $('vArea').classList.toggle('ampliado', zoomI>0);
desenhaMontagem($('vArea'), prontas, 0, W, $('vUso'), r=>{ desenhaMontagem($('vArea'), prontas, 0, W, $('vUso'), r=>{

22
web/site-pages.js Normal file
View File

@@ -0,0 +1,22 @@
/* Site DTF — the Site's pages: the home, one page per product, the cart and
the payment page.
They share this document, so artworks already loaded in the browser survive
moving between them; each page has its own address, so Back, Forward, reload
and shared links behave as in any store. nginx serves index.html for these
addresses. Loaded in <head> so the right page is the first one painted;
site-steps.js does the navigation. */
const PAGINAS={file:'/arquivo-por-metro', avulsa:'/artes-avulsas',
uvfile:'/uv-arquivo-por-metro', uv:'/uv-artes-avulsas'};
const CARRINHO='/carrinho', PAGAMENTO='/pagamento', PAGAMENTO_PIX='/pagamento/pix';
function paginaDe(caminho){
caminho=caminho.replace(/\/+$/,'')||'/';
if(caminho===CARRINHO) return {pagina:'carrinho'};
if(caminho===PAGAMENTO) return {pagina:'pagamento'};
if(caminho===PAGAMENTO_PIX) return {pagina:'pix'};
const m=Object.keys(PAGINAS).find(k=>PAGINAS[k]===caminho);
return m ? {pagina:'produto',modo:m} : {pagina:'inicio'};
}
// Portal links, old and new, open a quote on the payment page.
if(['/',CARRINHO].includes(location.pathname.replace(/\/+$/,'')||'/') && new URLSearchParams(location.search).has('quote'))
history.replaceState(null,'',PAGAMENTO+location.search);
document.documentElement.dataset.rota=paginaDe(location.pathname).pagina;

View File

@@ -139,9 +139,166 @@ async function rasterizarPdf(file, larguraCm, alturaCm){
return result||{erro:'não deu para conferir',semWorker:temWorker===false}; return result||{erro:'não deu para conferir',semWorker:temWorker===false};
} }
// Sheets of several GB are the normal order. The browser cannot decode an image
// that size (it would freeze or crash the tab), and it does not need to: the
// grade comes from the width in pixels, which PNG, JPEG and WebP store in their
// first bytes. Above GRANDE_BYTES only those bytes are read.
const GRANDE_BYTES=150*1048576;
async function dimensoesImagem(file){
const b=new Uint8Array(await file.slice(0,Math.min(file.size,4*1048576)).arrayBuffer());
const u16=(i,le)=>le? b[i]|(b[i+1]<<8) : (b[i]<<8)|b[i+1];
const u32=(i)=>((b[i]<<24)>>>0)+(b[i+1]<<16)+(b[i+2]<<8)+b[i+3];
if(b[0]===0x89 && b[1]===0x50 && b[2]===0x4E && b[3]===0x47) // PNG · IHDR
return {w:u32(16), h:u32(20)};
if(b[0]===0xFF && b[1]===0xD8){ // JPEG · SOFn
let i=2;
while(i+9<b.length){
if(b[i]!==0xFF){ i++; continue; }
const m=b[i+1], len=u16(i+2);
if(m>=0xC0 && m<=0xCF && ![0xC4,0xC8,0xCC].includes(m)) return {w:u16(i+7), h:u16(i+5)};
i+=2+len;
}
return null;
}
const tag=String.fromCharCode(...b.slice(8,16));
if(String.fromCharCode(...b.slice(0,4))==='RIFF' && tag.startsWith('WEBP')){ // WebP
if(tag==='WEBPVP8X') return {w:1+(b[24]|(b[25]<<8)|(b[26]<<16)), h:1+(b[27]|(b[28]<<8)|(b[29]<<16))};
if(tag==='WEBPVP8L'){ const n=b[21]|(b[22]<<8)|(b[23]<<16)|(b[24]<<24); return {w:(n&0x3FFF)+1, h:((n>>14)&0x3FFF)+1}; }
if(tag==='WEBPVP8 ') return {w:u16(26,true)&0x3FFF, h:u16(28,true)&0x3FFF};
}
return null;
}
// A small copy of a large PNG, read as a stream: the file is inflated in
// pieces and only every n-th pixel of every n-th row is kept, so memory stays
// at a few MB whatever the file's size. It is the preview, and what the sheet
// check (pieces, gaps, background) reads. 8- and 16-bit greyscale, RGB,
// palette and alpha; interlaced files return null.
async function previaPngGrande(file, larguraAlvo, progresso){
const leitor=file.stream().getReader();
let pedacos=[], guardado=0, lidos=0, fim=false;
// A queue of the file's pieces: take() never copies more than it returns.
const enche=async n=>{
while(guardado<n && !fim){
const {done,value}=await leitor.read();
if(done){ fim=true; break; }
pedacos.push(value); guardado+=value.length; lidos+=value.length;
progresso && progresso(lidos/file.size);
}
return guardado>=n;
};
const tira=n=>{
const out=new Uint8Array(n); let k=0;
while(k<n){ const p=pedacos[0], m=Math.min(p.length,n-k);
out.set(p.subarray(0,m),k); k+=m;
if(m===p.length) pedacos.shift(); else pedacos[0]=p.subarray(m); }
guardado-=n; return out;
};
const u32=b=>((b[0]<<24)>>>0)+(b[1]<<16)+(b[2]<<8)+b[3];
if(!await enche(8)) return null; tira(8);
// 1 · header chunks, up to the first image data
let w=0,h=0,prof=0,tipo=-1,paleta=null,trns=null, idat=0;
for(;;){
if(!await enche(8)) return null;
const cab=tira(8), len=u32(cab), nome=String.fromCharCode(...cab.subarray(4,8));
if(nome==='IDAT'){ idat=len; break; }
if(!await enche(len+4)) return null;
const d=tira(len); tira(4);
if(nome==='IHDR'){ w=u32(d); h=u32(d.subarray(4)); prof=d[8]; tipo=d[9];
if(d[12]!==0 || ![8,16].includes(prof) || !{0:1,2:1,3:1,4:1,6:1}[tipo]) return null; }
else if(nome==='PLTE') paleta=d;
else if(nome==='tRNS') trns=d;
else if(nome==='IEND') return null;
}
if(!(w>0 && h>0) || (tipo===3 && (!paleta || prof!==8))) return null;
// 2 · image data streamed into the inflater, a piece at a time
const inflador=new DecompressionStream('deflate'), escritor=inflador.writable.getWriter();
const alimenta=(async()=>{
let resta=idat;
for(;;){
while(resta>0){
if(!guardado && !await enche(1)) { await escritor.close(); return; }
const n=Math.min(resta,guardado); await escritor.write(tira(n)); resta-=n;
}
if(!await enche(12)) break;
tira(4); // CRC of the chunk just sent
const cab=tira(8), len=u32(cab), nome=String.fromCharCode(...cab.subarray(4,8));
if(nome==='IDAT'){ resta=len; continue; }
break; // anything after the image data
}
await escritor.close();
})();
alimenta.catch(()=>{});
// 3 · rows unfiltered one at a time; every passo-th pixel is kept
const canais={0:1,2:3,3:1,4:2,6:4}[tipo], b=prof/8, bpp=canais*b, linha=w*bpp;
const passo=Math.max(1,Math.ceil(w/larguraAlvo));
const cw=Math.ceil(w/passo), ch=Math.ceil(h/passo);
const cv=document.createElement('canvas'); cv.width=cw; cv.height=ch;
const ctx=cv.getContext('2d'), img=ctx.createImageData(cw,1), px=img.data;
// Each row is gathered whole (filter byte + data), then unfiltered in one
// tight loop for its filter type.
let ant=new Uint8Array(linha), atual=new Uint8Array(linha), y=0;
const bruta=new Uint8Array(linha+1); let cheio=0;
const desfiltra=()=>{
const f=bruta[0], d=bruta.subarray(1);
if(f===0) atual.set(d);
else if(f===1){ for(let i=0;i<bpp;i++) atual[i]=d[i]; for(let i=bpp;i<linha;i++) atual[i]=d[i]+atual[i-bpp]; }
else if(f===2){ for(let i=0;i<linha;i++) atual[i]=d[i]+ant[i]; }
else if(f===3){ for(let i=0;i<bpp;i++) atual[i]=d[i]+(ant[i]>>1);
for(let i=bpp;i<linha;i++) atual[i]=d[i]+((atual[i-bpp]+ant[i])>>1); }
else { for(let i=0;i<bpp;i++) atual[i]=d[i]+ant[i];
for(let i=bpp;i<linha;i++){ const a=atual[i-bpp], up=ant[i], c=ant[i-bpp], p=a+up-c;
const pa=p>a?p-a:a-p, pb=p>up?p-up:up-p, pc=p>c?p-c:c-p;
atual[i]=d[i]+(pa<=pb&&pa<=pc?a:pb<=pc?up:c); } }
};
const leitorInflado=inflador.readable.getReader();
for(;;){
const {done,value}=await leitorInflado.read(); if(done) break;
let j=0;
while(j<value.length){
const n=Math.min(linha+1-cheio, value.length-j);
bruta.set(value.subarray(j,j+n),cheio); cheio+=n; j+=n;
if(cheio<linha+1) break;
cheio=0;
// Every row is unfiltered: Up, Average and Paeth read the previous one.
desfiltra();
if(y%passo===0 && y/passo<ch){
for(let xx=0,k=0;xx<w;xx+=passo,k+=4){
const i=xx*bpp;
if(tipo===3){ const q=atual[i]; px[k]=paleta[q*3]; px[k+1]=paleta[q*3+1]; px[k+2]=paleta[q*3+2];
px[k+3]=trns&&q<trns.length?trns[q]:255; }
else if(tipo===0){ px[k]=px[k+1]=px[k+2]=atual[i]; px[k+3]=255; }
else if(tipo===4){ px[k]=px[k+1]=px[k+2]=atual[i]; px[k+3]=atual[i+b]; }
else { px[k]=atual[i]; px[k+1]=atual[i+b]; px[k+2]=atual[i+2*b]; px[k+3]=tipo===6?atual[i+3*b]:255; }
}
ctx.putImageData(img,0,y/passo);
}
const t=ant; ant=atual; atual=t; y++;
}
}
await alimenta.catch(()=>{});
leitor.cancel().catch(()=>{});
return y>=h ? cv : null;
}
// Large PDFs are read in ranges, never loaded whole into memory.
async function fontePdf(lib, file){
if(file.size<=GRANDE_BYTES) return {data:await file.arrayBuffer()};
const inicio=new Uint8Array(await file.slice(0,262144).arrayBuffer());
const t=new lib.PDFDataRangeTransport(file.size, inicio);
t.requestDataRange=(de,ate)=>{ file.slice(de,ate).arrayBuffer().then(buf=>t.onDataRange(de,new Uint8Array(buf))); };
return {range:t, rangeChunkSize:262144, disableAutoFetch:true, disableStream:true};
}
function medirFolha(file){ function medirFolha(file){
return new Promise(res=>{ return new Promise(res=>{
const nome=(file.name||'').toLowerCase(); const nome=(file.name||'').toLowerCase();
if(RENDERIZA.test(nome) && file.size>GRANDE_BYTES){
dimensoesImagem(file).then(d=>{
if(!d || !(d.w>0) || !(d.h>0)) return res(null);
const L=larguraFilme();
res({larg:L, alt:+(d.h/d.w*L).toFixed(1), fonte:'dimensões do arquivo',
dpiFolha:Math.round(d.w/(L/2.54)), px:d.w, grande:true});
}).catch(()=>res(null));
return;
}
if(RENDERIZA.test(nome)){ if(RENDERIZA.test(nome)){
carregarImagem(file).then(img=>{ carregarImagem(file).then(img=>{
if(!img || !img.width) return res(null); if(!img || !img.width) return res(null);
@@ -160,7 +317,7 @@ function medirFolha(file){
let doc=null; let doc=null;
carregarPdfJs().then(async lib=>{ carregarPdfJs().then(async lib=>{
if(!lib) throw new Error('Não foi possível carregar o leitor de PDF.'); if(!lib) throw new Error('Não foi possível carregar o leitor de PDF.');
doc=await lib.getDocument({data:await file.arrayBuffer(), doc=await lib.getDocument({...await fontePdf(lib,file),
disableFontFace:true, isEvalSupported:false, useSystemFonts:false, disableFontFace:true, isEvalSupported:false, useSystemFonts:false,
verbosity:0}).promise; verbosity:0}).promise;
if(doc.numPages!==1) if(doc.numPages!==1)
@@ -195,103 +352,144 @@ function medirFolha(file){
}); });
} }
function textoProgresso(x){
if(x.med && x.med.grande) return 'gerando a prévia e conferindo a arte · '+faltaLeitura(x.leitura);
return x.pct<50?'lendo o arquivo…':x.pct<70?'medindo a folha…':'conferindo a arte…';
}
// The file card's thumbnail (52 x 96 px, drawn at twice that for sharp screens).
function miniatura(fonte){
const w=fonte.naturalWidth||fonte.width, h=fonte.naturalHeight||fonte.height;
if(!(w>0 && h>0)) return null;
const s=Math.min(1,104/w,192/h), cv=document.createElement('canvas');
cv.width=Math.max(1,Math.round(w*s)); cv.height=Math.max(1,Math.round(h*s));
cv.getContext('2d').drawImage(fonte,0,0,cv.width,cv.height);
return cv.toDataURL('image/webp',0.8);
}
// Moves one sheet's progress bar in place: repainting the whole list and the
// preview on every step made "Sua folha" flicker while a large file was read.
function pintaProgressoFolha(x){
const el=$('lista').querySelector('.rep[data-folha="'+folhas.indexOf(x)+'"]');
const barra=el && el.querySelector('.prog i'), texto=el && el.querySelector('.progT');
if(!barra){ pintaFolha(); return; }
barra.style.width=x.pct+'%'; if(texto) texto.textContent=textoProgresso(x);
}
// What the check found, as chips: ok (verde), av (atenção), er (resolva você),
// fix (arrumamos de graça), nt (a equipe confere). Details stay one click away.
function chipsDaFolha(a){
const chip=(k,t)=>'<li class="chip '+k+'">'+t+'</li>';
const dpi = a.vetor ? chip('ok','Arte vetorial')
: chip(a.dpi>=DPI_AVISA?'ok':a.dpi>=DPI_RECUSA?'av':'er', a.dpi+' DPI');
if(a.grande) return dpi+chip('nt','Peças, resíduos e fundo: a equipe confere');
return dpi+
(a.fundoChapado? chip('er','Fundo chapado') : chip('ok','Fundo transparente'))+
(a.residuos? chip('er',a.residuos+' resíduo'+(a.residuos>1?'s':'')) : chip('ok','Sem resíduos'))+
(a.encostadas? chip('fix',a.encostadas+' peças coladas · separamos grátis') : chip('ok','Peças separadas'));
}
function avisosDaFolha(a){
if(a.grande) return '';
return (a.fundoChapado? '<p class="avisoF"><b>O fundo não é transparente.</b> '+
'Branco parecendo transparente sai branco no filme. Exporte com fundo transparente.</p>' : '')+
(a.residuos? '<p class="avisoF"><b>'+a.residuos+' pontinho'+(a.residuos>1?'s soltos serão impressos':' solto será impresso')+
'.</b> A impressora imprime qualquer resíduo de recorte. Apague no arquivo e envie de novo.</p>' : '');
}
function detalhesDaFolha(a,i,aberto){
const linhas = a.grande
? ['Resolução '+a.dpi+' DPI, lida do arquivo',
'Arquivo grande: peças, resíduos e fundo são conferidos pela equipe']
: [a.artes+' peça'+(a.artes===1?'':'s')+' · '+a.aproveitamento+'% da folha com arte',
'Resolução '+a.dpi+' DPI · '+(a.fonteDpi==='imagens'? 'medida imagem por imagem dentro do PDF'
: a.fonteDpi==='vetor'? 'arte vetorial, sem imagem embutida' : 'lida do arquivo'),
'Conferido numa grade de '+n1(a.grade)+' mm'];
return '<details class="confDet" data-det="'+i+'"'+(aberto?' open':'')+'>'+
'<summary>Ver detalhes da conferência</summary>'+
linhas.map(l=>'<span>'+l+'</span>').join('')+'</details>';
}
function pintaFolha(){ function pintaFolha(){
invalidaItemAtual(); invalidaItemAtual();
const L=larguraFilme(); const L=larguraFilme();
pintaTipoEnvio(); // trava o seletor enquanto houver folha pintaTipoEnvio(); // trava o seletor enquanto houver folha
$('zona').classList.toggle('compacta', folhas.length>0);
if(!folhas.length){ $('lista').innerHTML=''; agendaAvaliacao(); previaAoVivo(); return; } if(!folhas.length){ $('lista').innerHTML=''; agendaAvaliacao(); previaAoVivo(); return; }
$('lista').innerHTML = folhas.map((x,i)=>{ const tot=folhaTotalM();
$('lista').innerHTML = '<div class="listaCab"><h4>Seus arquivos</h4><span>'+
folhas.length+' folha'+(folhas.length>1?'s':'')+(tot>0? ' · '+fmtM(tot)+' m' : '')+'</span></div>'+
folhas.map((x,i)=>{
const lido=!!x.med, larga=lido && x.med.larg>L, suspeito=lido && x.med.foraDoPadrao; const lido=!!x.med, larga=lido && x.med.larg>L, suspeito=lido && x.med.foraDoPadrao;
const sub=(x.m||0)*(x.rep||1); const sub=(x.m||0)*(x.rep||1), a=lido && x.an;
let medida=''; let medida='', manual='';
if(lido){ if(lido){
const a=x.an; medida='<div class="repMed"><b>'+n1(x.med.larg)+' × '+n1(x.med.alt)+' cm</b>'+
const conferido = a '<span>'+escapeHTML(extDe(x.f.name))+' · '+fmt(x.f.size)+'</span></div>'+
? '<div class="conf"><b>Conferido de verdade</b>'+ (a? '<ul class="chips">'+chipsDaFolha(a)+'</ul>'+avisosDaFolha(a)+detalhesDaFolha(a,i,x.detAberto) : '')+
'<span>'+a.artes+' peça'+(a.artes===1?'':'s')+' · '+a.aproveitamento+ (suspeito? '<p class="avisoF"><b>Este PDF passa do tamanho que o formato comporta.</b> '+
'% da folha virou arte · '+
(a.residuos? a.residuos+' resíduo'+(a.residuos>1?'s':'') : 'sem resíduo')+' · '+
(a.fundoChapado? 'fundo chapado' : 'fundo transparente')+'</span>'+
'<span>resolução '+a.dpi+' DPI · '+
(a.fonteDpi==='imagens'? 'medida imagem por imagem dentro do PDF'
: a.fonteDpi==='vetor'? 'arte vetorial, sem imagem embutida'
: 'lida do arquivo')+
' · grade de '+n1(a.grade)+' mm</span></div>'
: '';
medida='<div class="med'+(larga||suspeito?' alerta':'')+'">'+
'<b>'+n1(x.med.larg)+' × '+n1(x.med.alt)+' cm</b>'+
'<span>'+x.med.fonte+' · '+fmtM(x.m)+' m de folha</span>'+
conferido+
(suspeito? '<em><b>Este PDF passa do tamanho que o formato comporta.</b> '+
'A especificação para em 508 cm e o seu tem '+n1(x.med.alt)+' cm. '+ 'A especificação para em 508 cm e o seu tem '+n1(x.med.alt)+' cm. '+
'Ele abre certo só em quem o gerou — aqui vai precisar de tratamento. '+ 'Ele abre certo só em quem o gerou — aqui vai precisar de tratamento. '+
'Manda em <b>PNG ou TIFF</b>, ou divide em partes de até 5 m, que sai na hora.</em>':'')+ 'Manda em <b>PNG ou TIFF</b>, ou divide em partes de até 5 m, que sai na hora.</p>':'')+
(larga? '<em><b>Esta folha não roda neste filme.</b> Ela tem '+n1(x.med.larg)+ (larga? '<p class="avisoF"><b>Esta folha não roda neste filme.</b> Ela tem '+n1(x.med.larg)+
' cm de largura e o filme aqui tem '+n1(L)+' cm. Não dá para reduzir: '+ ' cm de largura e o filme aqui tem '+n1(L)+' cm. Não dá para reduzir: '+
'encolher a folha encolheria cada arte junto.'+ 'encolher a folha encolheria cada arte junto.'+
(x.med.larg<=57.5 (x.med.larg<=57.5
? ' Ela cabe no <b>DTF têxtil de 57 cm</b> — troque de produto ali em cima.' ? ' Ela cabe no <b>DTF têxtil de 57 cm</b> — troque de produto ali em cima.'
: ' Remonte a folha em '+n1(L)+' cm e suba de novo.')+'</em>':'')+ : ' Remonte a folha em '+n1(L)+' cm e suba de novo.')+'</p>':'');
'</div>';
}else if(x.measurementError){ }else if(x.measurementError){
medida='<div class="med alerta"><b>Este PDF não pode ser orçado</b><span>'+ medida='<span class="repPeso">'+escapeHTML(extDe(x.f.name))+' · '+fmt(x.f.size)+'</span>'+
escapeHTML(x.measurementError)+'</span></div>'; '<p class="avisoF"><b>Este PDF não pode ser orçado.</b> '+escapeHTML(x.measurementError)+'</p>';
}else{ }else{
const teto=TABELA[modo], piso=pisoEscada(); const teto=TABELA[modo], piso=pisoEscada();
const dif=(x.m>0)? (teto-piso)*cobrar(x.m*(x.rep||1)) : 0; const dif=(x.m>0)? (teto-piso)*cobrar(x.m*(x.rep||1)) : 0;
medida='<div class="med pede">'+ medida='<span class="chipSo">Conferência manual · preço cheio</span>'+
'<b>'+escapeHTML(extDe(x.f.name))+' não abre no navegador</b>'+ '<p class="repTxt">O navegador não abre '+escapeHTML(extDe(x.f.name))+', então alguém da equipe '+
'<span>Sem conseguir conferir o arquivo, não temos como dar nota — e sem nota '+ 'confere antes de imprimir e o metro sai pela tabela, <b>'+rs(teto)+'</b>. '+
'o metro sai pela tabela, <b>'+rs(teto)+'</b>. Alguém vai abrir esse arquivo na mão '+ 'Exportando em <b>PNG ou PDF</b>, o metro pode cair até <b>'+rs(piso)+'</b>'+
'antes de imprimir.</span>'+ (dif>0.5? ' — <b>'+rs(dif)+' a menos</b> só nesta folha' : '')+'.</p>';
'<div class="troca">Exportando a mesma folha em <b>PNG ou PDF</b>, a conferência é '+ manual='<div class="campo"><label for="comp'+i+'">Comprimento</label>'+
'automática e o metro pode cair até <b>'+rs(piso)+'</b>'+ '<span class="un"><input type="number" id="comp'+i+'" min="0.5" max="60" step="0.01" data-comp="'+i+'" '+
(dif>0.5? ' — <b>'+rs(dif)+' a menos</b> só nesta folha' : '')+'.</div>'+ 'placeholder="0,00" value="'+(x.m? x.m.toFixed(2):'')+'"><i>m</i></span></div>';
'<span style="margin-top:9px">Informe o comprimento para seguir:</span>'+
'<input type="number" min="0.5" max="60" step="0.01" data-comp="'+i+'" '+
'placeholder="metros" value="'+(x.m? x.m.toFixed(2):'')+'">'+
'</div>';
} }
const barra = x.pct!=null const barra = x.pct!=null
? '<div class="prog"><i style="width:'+x.pct+'%"></i></div>'+ ? '<div class="prog"><i style="width:'+x.pct+'%"></i></div>'+
'<span class="progT">'+(x.pct<50?'lendo o arquivo…': '<span class="progT">'+textoProgresso(x)+'</span>'
x.pct<70?'medindo a folha…':'conferindo a arte…')+'</span>'
: x.semAnalise : x.semAnalise
? '<div class="progF"><b>Não conseguimos conferir esta folha.</b> '+escapeHTML(x.semAnalise)+ ? '<div class="progF"><b>Não conseguimos conferir esta folha.</b> '+escapeHTML(x.semAnalise)+
'. Seguimos com o preço de tabela — se quiser a nota e o desconto, exporte a mesma '+ '. Seguimos com o preço de tabela — se quiser a nota e o desconto, exporte a mesma '+
'folha em <b>PNG</b>, que a conferência é bem mais leve.</div>' 'folha em <b>PNG</b>, que a conferência é bem mais leve.</div>'
: ''; : '';
return '<div class="rep"><div class="l1"><b>'+ // A small thumbnail made once: the list is rebuilt on every change, and the
(folhas.length>1? (i+1)+'. ':'')+escapeHTML(x.f.name)+'</b>'+ // full preview of a file up to 150 MB would be re-parsed each time.
'<span style="color:var(--fraco);font-size:10.5px">'+fmt(x.f.size)+'</span>'+ const mini = x.miniSrc
'<button data-rmf="'+i+'">×</button></div>'+ ? '<img src="'+escapeHTML(x.miniSrc)+'" alt="">'
barra+medida+ : '<span>'+escapeHTML(extDe(x.f.name))+'</span>';
const copias = x.m>0 || manual ?
'<div class="cps">'+manual+
(x.m>0 ? (x.m>0 ?
'<div class="cps" style="margin-top:10px">'+ '<div class="campo"><label for="repf'+i+'">Cópias</label>'+
'<div><label>quantas vezes repetir</label>'+ '<span class="passo"><button type="button" data-passo="-1" data-i="'+i+'" aria-label="Menos uma cópia">−</button>'+
'<input type="number" min="1" max="200" data-repf="'+i+'" value="'+x.rep+'"></div>'+ '<input type="number" id="repf'+i+'" min="1" max="200" data-repf="'+i+'" value="'+x.rep+'">'+
'<div class="conta"><b>'+fmtM(sub)+' m</b>'+ '<button type="button" data-passo="1" data-i="'+i+'" aria-label="Mais uma cópia">+</button></span></div>'+
(x.rep>1? ' · '+x.rep+' × '+fmtM(x.m)+' m':'')+'</div>'+ '<div class="conta">'+(x.rep>1? x.rep+' × '+fmtM(x.m)+' m = ' : '')+'<b>'+fmtM(sub)+' m</b></div>' : '')+
'</div>' : '')+ '</div>' : '';
return '<div class="rep" data-folha="'+i+'">'+
'<div class="repTopo"><div class="miniF">'+mini+'</div><div class="repInfo">'+
'<div class="l1"><b>'+(folhas.length>1? (i+1)+'. ':'')+escapeHTML(x.f.name)+'</b>'+
'<button data-rmf="'+i+'" aria-label="Remover '+escapeHTML(x.f.name)+'">'+icone('fechar')+'</button></div>'+
(lido || x.measurementError ? '' : '<span class="repPeso">'+escapeHTML(extDe(x.f.name))+' · '+fmt(x.f.size)+'</span>')+
barra+medida+
'</div></div>'+copias+
'</div>'; '</div>';
}).join(''); }).join('');
// total do pedido, quando há mais de uma folha
const tot=folhaTotalM(), partes=Math.ceil(tot/20);
if(folhas.length>1 && tot>0){
$('lista').innerHTML += '<div class="somaF">'+
'<span>'+folhas.length+' folhas neste pedido</span>'+
'<b>'+fmtM(tot)+' m no total</b>'+
(partes>1? '<em>vai em '+partes+' partes de até 20 m</em>':'')+'</div>';
}else if(partes>1 && tot>0){
$('lista').innerHTML += '<div class="somaF"><span>folha longa</span>'+
'<b>'+fmtM(tot)+' m</b><em>vai em '+partes+' partes de até 20 m</em></div>';
}
$('lista').querySelectorAll('[data-rmf]').forEach(b=>b.addEventListener('click',()=>{ $('lista').querySelectorAll('[data-rmf]').forEach(b=>b.addEventListener('click',()=>{
folhas.splice(+b.dataset.rmf,1); pintaFolha(); folhas.splice(+b.dataset.rmf,1); pintaFolha();
})); }));
$('lista').querySelectorAll('[data-det]').forEach(d=>d.addEventListener('toggle',()=>{
folhas[+d.dataset.det].detAberto=d.open;
}));
$('lista').querySelectorAll('[data-passo]').forEach(b=>b.addEventListener('click',()=>{
const el=$('repf'+b.dataset.i);
el.value=Math.max(1,Math.min(200,(+el.value||1)+(+b.dataset.passo)));
el.dispatchEvent(new Event('change'));
}));
$('lista').querySelectorAll('[data-repf]').forEach(el=>el.addEventListener('change',e=>{ $('lista').querySelectorAll('[data-repf]').forEach(el=>el.addEventListener('change',e=>{
folhas[+el.dataset.repf].rep=Math.max(1,Math.min(200,+e.target.value||1)); pintaFolha(); folhas[+el.dataset.repf].rep=Math.max(1,Math.min(200,+e.target.value||1)); pintaFolha();
})); }));
@@ -319,78 +517,84 @@ function sugestao(a){
if(economiaCm<0.5) return null; // menos de meio centímetro não é notícia if(economiaCm<0.5) return null; // menos de meio centímetro não é notícia
return {alvo, de:n, para:n+1, fileiras:fa-fd, cm:economiaCm}; return {alvo, de:n, para:n+1, fileiras:fa-fd, cm:economiaCm};
} }
// A cota em escala: o cliente ve a peca dentro da largura do filme, quantas cabem
// lado a lado e quanto sobra. Numero em texto ninguem le; retangulo todo mundo entende.
function vizPeca(a){
const BASE=300, L=larguraFilme();
const e0=BASE/L, alt0=a.cm*propDe(a)*e0;
const k = alt0>132 ? 132/alt0 : 1; // tira nunca fica alta demais
const e = e0*k;
const n=cabemNaLinha(a.cm), pw=a.cm*e, ph=a.cm*propDe(a)*e, gp=GAP_CM*e;
const sobra=L-(n*a.cm+(n-1)*GAP_CM);
const ext=escapeHTML(a.f.name.split('.').pop().toUpperCase());
let pecas='';
for(let i=0;i<n;i++) pecas+='<div class="pc" style="width:'+pw.toFixed(1)+'px;height:'+
ph.toFixed(1)+'px">'+(a.src?'<img src="'+a.src+'" alt="" style="transform:'+
(a.giro?'rotate(90deg) ':'')+(a.esp?'scaleX(-1)':'')+'">':'<span>'+ext+'</span>')+'</div>';
const Wt=(L*e+6).toFixed(1); // +6 = borda e respiro da tira
return '<div class="viz">'+
'<div class="vizRow">'+
'<div class="vizH" style="height:'+ph.toFixed(1)+'px"><span>'+n1(a.cm*propDe(a))+' cm</span></div>'+
'<div class="vizTira" style="width:'+Wt+'px">'+
'<div class="vizL"><i></i><b>'+n1(L)+' cm de filme</b><i></i></div>'+
'<div class="tira" style="height:'+ph.toFixed(1)+'px;gap:'+gp.toFixed(1)+'px">'+pecas+
(sobra>0.25?'<div class="folga"><span>sobra '+n1(sobra)+' cm</span></div>':'')+
'</div>'+
'</div>'+
'</div>'+
'<div class="vizPe"><b>'+n1(a.cm)+' cm</b> de largura · <b>'+n+
(n>1?' peças':' peça')+'</b> por fileira'+
(sobra>0.25?' · sobram <b>'+n1(sobra)+' cm</b>':' · largura toda aproveitada')+'</div>'+
'</div>';
}
const n1=v=>v.toFixed(1).replace('.',','); const n1=v=>v.toFixed(1).replace('.',',');
// Moves one artwork's progress in place, without repainting the list.
function pintaProgressoArte(a){
const el=$('lista').querySelector('[data-arte="'+artes.indexOf(a)+'"]');
const barra=el && el.querySelector('.prog i'), texto=el && el.querySelector('.progT');
if(!barra){ pintaArtes(); return; }
barra.style.width=a.lendo+'%'; if(texto) texto.textContent='arquivo grande · gerando a prévia · '+faltaLeitura(a.leitura);
}
function pintaArtes(){ function pintaArtes(){
invalidaItemAtual(); invalidaItemAtual();
$('lista').innerHTML=artes.map((a,i)=>{ $('zona').classList.toggle('compacta', artes.length>0);
let barra='', dica='', calc='<span>informe a largura para ver a qualidade</span>'; // The same card as a finished sheet: thumbnail, size, a DPI chip, then the
if(a.decodeError){ // width (with the usual sizes), copies, rotation and mirroring.
calc='<span class="er">Não conseguimos ler esta imagem; exporte novamente em PNG ou JPG</span>'; const copiasTot=artes.reduce((t,a)=>t+(a.q||1),0);
$('lista').innerHTML=(artes.length? '<div class="listaCab"><h4>Suas artes</h4><span>'+
artes.length+' arte'+(artes.length>1?'s':'')+' · '+copiasTot+' cópia'+(copiasTot>1?'s':'')+'</span></div>' : '')+
artes.map((a,i)=>{
let barra='', dica='', chips='', aviso='', medida='<b class="pede">Informe a largura</b>';
if(a.lendo!=null){
barra='<div class="prog"><i style="width:'+a.lendo+'%"></i></div>'+
'<span class="progT">arquivo grande · gerando a prévia · '+faltaLeitura(a.leitura)+'</span>';
}else if(a.decodeError){
aviso='<p class="avisoF"><b>Não conseguimos ler esta imagem.</b> Exporte novamente em PNG ou JPG.</p>';
}else if(a.cm>larguraFilme()){ }else if(a.cm>larguraFilme()){
calc='<span class="er">Largura maior que o filme de '+n1(larguraFilme())+' cm</span>'; aviso='<p class="avisoF"><b>Largura maior que o filme.</b> O filme aqui tem '+n1(larguraFilme())+' cm.</p>';
}else if(a.cm>0 && a.src){ }else if(a.cm>0 && (a.src||a.semPrevia)){
const dpi=Math.round(dpiDe(a)), n=Math.max(6,Math.min(100,Math.round(dpi/300*100))); const dpi=Math.round(dpiDe(a));
barra='<div class="qb2"><i class="'+cls(n)+'" style="width:'+n+'%"></i></div>'; medida='<b>'+n1(a.cm)+' × '+n1(a.cm*propDe(a))+' cm</b>';
calc='<span class="'+cls(n)+'"><b>'+n+'%</b> · '+dpi+' DPI</span><span>'+ chips='<ul class="chips"><li class="chip '+(dpi>=DPI_AVISA?'ok':dpi>=DPI_RECUSA?'av':'er')+'">'+dpi+' DPI</li>'+
a.q+' × '+n1(a.cm)+' × '+n1(a.cm*propDe(a))+' cm</span>'; '<li class="chip nt">'+cabemNaLinha(a.cm)+' por fileira</li></ul>';
const sug=sugestao(a); const sug=sugestao(a);
dica = sug // One hint per artwork: resolution first, since it changes the price;
? '<div class="dica"><b>Reduzindo para '+n1(sug.alvo)+' cm você economiza '+ // otherwise a width that saves sheet.
n1(sug.cm)+' cm de folha</b>Passam de '+sug.de+' para '+sug.para+ const maxCm=Math.floor(a.px/DPI_AVISA*2.54*10)/10;
' por fileira, e as suas '+a.q+' peças ocupam '+sug.fileiras+ dica = dpi<DPI_AVISA && maxCm>0 && maxCm<a.cm
' fileira'+(sug.fileiras>1?'s':'')+' a menos.<button data-usar="'+i+'" data-cm="'+ ? '<div class="dica av">Pequena para '+n1(a.cm)+' cm. Em '+DPI_AVISA+' DPI ela sai com até '+
sug.alvo+'">Usar sugerido</button></div>' n1(maxCm)+' cm, ou envie um arquivo maior.</div>'
: sug
? '<div class="dica">Com '+n1(sug.alvo)+' cm cabem '+sug.para+' por fileira e a folha encolhe '+
n1(sug.cm)+' cm.<button data-usar="'+i+'" data-cm="'+sug.alvo+'">Usar '+n1(sug.alvo)+' cm</button></div>'
: ''; : '';
dica = vizPeca(a) + dica;
} }
return '<div class="art'+(a===recemChegada?' nova':'')+'"><div class="l1"><b>'+escapeHTML(a.f.name)+'</b>'+ if(a.semPrevia && !a.decodeError)
'<span style="color:var(--fraco);font-size:10.5px">'+ aviso+='<p class="repTxt"><b>Sem prévia para '+escapeHTML(extDe(a.f.name))+' deste tamanho.</b> '+
(a.px>0?a.px+' × '+a.py+' px':'a ler')+'</span>'+ 'A arte entra na folha pela medida, e a equipe confere antes de imprimir. Em PNG, a prévia aparece.</p>';
'<button data-rm="'+i+'">×</button></div><div class="cps">'+ const mini = a.miniSrc ? '<img src="'+escapeHTML(a.miniSrc)+'" alt="">' : '<span>'+escapeHTML(extDe(a.f.name))+'</span>';
'<div><label>largura na peça</label><input type="number" min="1" max="'+larguraFilme()+ const sub = a.cm>0 && (a.src||a.semPrevia) ? (a.q>1? a.q+' × ' : '')+'<b>'+n1(a.cm)+' × '+n1(a.cm*propDe(a))+' cm</b>' : '';
'" placeholder="cm" data-cm="'+i+'" value="'+(a.cm||'')+'"></div>'+ return '<div class="rep art2'+(a===recemChegada?' nova':'')+'" data-arte="'+i+'">'+
'<div><label>quantas vezes</label><input type="number" min="1" placeholder="1" data-q="'+i+ '<div class="repTopo"><div class="miniF">'+mini+'</div><div class="repInfo">'+
'" value="'+a.q+'"></div></div>'+ '<div class="l1"><b>'+escapeHTML(a.f.name)+'</b>'+(a===recemChegada?'<em class="novaT">nova</em>':'')+
'<div class="atalhos">'+ '<button data-rm="'+i+'" aria-label="Remover '+escapeHTML(a.f.name)+'">'+icone('fechar')+'</button></div>'+
(TAMANHOS[larguraFilme()]||[]).map(t=>'<button class="tm'+ '<div class="repMed">'+medida+'<span>'+(a.px>0? a.px+' × '+a.py+' px · ' : '')+
(Math.abs(a.cm-t)<0.05?' on':'')+'" data-tam="'+i+'" data-t="'+t+'">'+ escapeHTML(extDe(a.f.name))+' · '+fmt(a.f.size)+'</span></div>'+
n1(t)+' cm</button>').join('')+ barra+chips+aviso+dica+
'<span class="sep"></span>'+ '</div></div>'+
'<button class="gr'+(a.giro?' on':'')+'" data-giro="'+i+'" title="Girar 90°">⟳</button>'+ '<div class="cps">'+
'<button class="gr'+(a.esp?' on':'')+'" data-esp="'+i+'" title="Espelhar">⇋</button>'+ '<div class="campo"><label for="cm'+i+'">Largura</label>'+
'</div>'+barra+'<div class="calc">'+calc+'</div>'+dica+'</div>'; '<span class="un"><input type="number" id="cm'+i+'" min="1" max="'+larguraFilme()+'" step="0.1" '+
'placeholder="0" data-cm="'+i+'" value="'+(a.cm||'')+'"><i>cm</i></span></div>'+
'<div class="campo"><label for="q'+i+'">Cópias</label>'+
'<span class="passo"><button type="button" data-passoa="-1" data-i="'+i+'" aria-label="Menos uma cópia">−</button>'+
'<input type="number" id="q'+i+'" min="1" max="200" data-q="'+i+'" value="'+a.q+'">'+
'<button type="button" data-passoa="1" data-i="'+i+'" aria-label="Mais uma cópia">+</button></span></div>'+
'<span class="giros">'+
'<button class="gr'+(a.giro?' on':'')+'" data-giro="'+i+'" title="Girar 90°" aria-label="Girar 90°">'+icone('girar')+'</button>'+
'<button class="gr'+(a.esp?' on':'')+'" data-esp="'+i+'" title="Espelhar" aria-label="Espelhar">'+icone('espelhar')+'</button></span>'+
(sub? '<div class="conta">'+sub+'</div>' : '')+
'<div class="atalhos">'+(TAMANHOS[larguraFilme()]||[]).map(t=>'<button class="tm'+
(Math.abs(a.cm-t)<0.05?' on':'')+'" data-tam="'+i+'" data-t="'+t+'">'+n1(t)+' cm</button>').join('')+'</div>'+
'</div>'+
'</div>';
}).join(''); }).join('');
$('lista').querySelectorAll('[data-passoa]').forEach(b=>b.addEventListener('click',()=>{
const el=$('q'+b.dataset.i);
el.value=Math.max(1,Math.min(200,(+el.value||1)+(+b.dataset.passoa)));
el.dispatchEvent(new Event('change'));
}));
$('lista').querySelectorAll('[data-rm]').forEach(b=>b.addEventListener('click',()=>{artes.splice(+b.dataset.rm,1);pintaArtes();})); $('lista').querySelectorAll('[data-rm]').forEach(b=>b.addEventListener('click',()=>{artes.splice(+b.dataset.rm,1);pintaArtes();}));
$('lista').querySelectorAll('[data-cm]').forEach(i=>i.addEventListener('change',()=>{artes[+i.dataset.cm].cm=+i.value||0;pintaArtes();})); $('lista').querySelectorAll('[data-cm]').forEach(i=>i.addEventListener('change',()=>{artes[+i.dataset.cm].cm=+i.value||0;pintaArtes();}));
$('lista').querySelectorAll('[data-q]').forEach(i=>i.addEventListener('change',()=>{artes[+i.dataset.q].q=Math.max(1,Math.min(200,Math.floor(+i.value||1)));pintaArtes();})); $('lista').querySelectorAll('[data-q]').forEach(i=>i.addEventListener('change',()=>{artes[+i.dataset.q].q=Math.max(1,Math.min(200,Math.floor(+i.value||1)));pintaArtes();}));
@@ -417,7 +621,7 @@ function pintaArtes(){
previaAoVivo(); previaAoVivo();
} }
function resumoArtes(){ function resumoArtes(){
const r=$('rA'), prontas=artes.filter(a=>a.cm>0 && a.cm<=larguraFilme() && a.src); const r=$('rA'), prontas=artes.filter(a=>a.cm>0 && a.cm<=larguraFilme() && (a.src||a.semPrevia));
if(!prontas.length){ r.style.display='none'; return; } if(!prontas.length){ r.style.display='none'; return; }
const m=montagemCm/100; // a mesma folha que aparece ao lado const m=montagemCm/100; // a mesma folha que aparece ao lado
const notas=prontas.map(a=>Math.max(6,Math.min(100,Math.round(dpiDe(a)/300*100)))); const notas=prontas.map(a=>Math.max(6,Math.min(100,Math.round(dpiDe(a)/300*100))));

View File

@@ -45,15 +45,16 @@ function avaliar(){
? 'média por área das imagens dentro do PDF · pior em '+ ? 'média por área das imagens dentro do PDF · pior em '+
Math.min(...ans.map(a=>a.res? a.res.pior : a.dpi))+' DPI' Math.min(...ans.map(a=>a.res? a.res.pior : a.dpi))+' DPI'
: 'resolução do arquivo · arte ampliada antes de exportar não aparece aqui'], : 'resolução do arquivo · arte ampliada antes de exportar não aparece aqui'],
['ok', soma('artes')+' peças na folha', ans.some(a=>a.grande) ? ['ok','Arquivo grande','peças, resíduos e fundo conferidos pela equipe'] : null,
ans.some(a=>a.grande) ? null : ['ok', soma('artes')+' peças na folha',
Math.round(soma('aproveitamento')/ans.length)+'% da folha virou arte'], Math.round(soma('aproveitamento')/ans.length)+'% da folha virou arte'],
soma('residuos') ? ['er','Resíduo de recorte', ans.some(a=>a.grande) ? null : soma('residuos') ? ['er','Resíduo de recorte',
soma('residuos')+' ponto'+(soma('residuos')>1?'s':'')+' abaixo de 2 mm · a impressora imprime'] soma('residuos')+' ponto'+(soma('residuos')>1?'s':'')+' abaixo de 2 mm · a impressora imprime']
: ['ok','Sem resíduo de recorte','nada solto na folha'], : ['ok','Sem resíduo de recorte','nada solto na folha'],
soma('encostadas') ? ['fix', soma('encostadas')+' peças a menos de 5 mm', ans.some(a=>a.grande) ? null : soma('encostadas') ? ['fix', soma('encostadas')+' peças a menos de 5 mm',
'se forem artes diferentes, separamos com 5 mm sem custo'] 'se forem artes diferentes, separamos com 5 mm sem custo']
: ['ok','Espaço entre peças','nenhuma abaixo de 6 mm'], : ['ok','Espaço entre peças','nenhuma abaixo de 6 mm'],
ans.some(a=>a.fundoChapado) ? ['er','Fundo chapado','a folha está sem transparência'] ans.some(a=>a.grande) ? null : ans.some(a=>a.fundoChapado) ? ['er','Fundo chapado','a folha está sem transparência']
: ['ok','Fundo transparente','canal alfa conferido'], : ['ok','Fundo transparente','canal alfa conferido'],
null null
].filter(Boolean); ].filter(Boolean);
@@ -62,7 +63,8 @@ function avaliar(){
return {pronto:true}; return {pronto:true};
} }
if(!artes.length) return {pronto:false, falta:'Arraste suas artes para começar.'}; if(!artes.length) return {pronto:false, falta:'Arraste suas artes para começar.'};
if(artes.some(a=>a.decodeError || !a.src || !(a.px>0) || !(a.py>0))) if(artes.some(a=>a.lendo!=null)) return {pronto:false, falta:'Gerando a prévia das artes grandes…'};
if(artes.some(a=>a.decodeError || !(a.src||a.semPrevia) || !(a.px>0) || !(a.py>0)))
return {pronto:false, falta:'Não conseguimos ler uma das imagens. Exporte-a novamente em PNG ou JPG antes de continuar.'}; return {pronto:false, falta:'Não conseguimos ler uma das imagens. Exporte-a novamente em PNG ou JPG antes de continuar.'};
if(artes.some(a=>!Number.isFinite(a.cm) || a.cm>larguraFilme())) if(artes.some(a=>!Number.isFinite(a.cm) || a.cm>larguraFilme()))
return {pronto:false, falta:'A largura da arte precisa caber no filme de '+ return {pronto:false, falta:'A largura da arte precisa caber no filme de '+
@@ -88,6 +90,8 @@ let tAval=null;
function agendaAvaliacao(){ function agendaAvaliacao(){
clearTimeout(tAval); clearTimeout(tAval);
tAval=setTimeout(()=>{ tAval=setTimeout(()=>{
// o produto pode ter sido fechado nesses 220 ms (item foi para o carrinho)
if(!modo) return;
const r=avaliar(); const r=avaliar();
const el=$('aoVivo'); const el=$('aoVivo');
if(!el) return; if(!el) return;
@@ -113,7 +117,7 @@ function mostraNota(itens,n,titulo,sub){
$('qbi').style.width=(semNota?0:n)+'%'; $('qbi').style.background=c; $('qbi').style.width=(semNota?0:n)+'%'; $('qbi').style.background=c;
$('qitens').innerHTML=itens.map(i=> $('qitens').innerHTML=itens.map(i=>
'<div class="qi '+i[0]+'"><span class="c">'+ '<div class="qi '+i[0]+'"><span class="c">'+
(i[0]==='ok'?'✓':i[0]==='av'?'!':i[0]==='fix'?'↻':'×')+'</span>'+ icone(i[0]==='ok'?'ok':i[0]==='av'?'alerta':i[0]==='fix'?'refazer':'fechar')+'</span>'+
'<div><b>'+escapeHTML(i[1])+'</b><span>'+escapeHTML(i[2])+'</span></div></div>').join(''); '<div><b>'+escapeHTML(i[1])+'</b><span>'+escapeHTML(i[2])+'</span></div></div>').join('');
$('qmsg').style.display='none'; $('qmsg').style.display='none';
ressalvaResolucao(); ressalvaResolucao();

87
web/site-shell.css Normal file
View File

@@ -0,0 +1,87 @@
/* Site DTF — what every page shares: the tokens, the base type, the stripe,
header, category menu and footer. The Site (index.html) and the order page
(portal.html) both load it before their own styles. */
:root{
/* tokens do site institucional */
--font-family-base:"Roboto",sans-serif; --font-family-title:"Inter",sans-serif;
--font-color-base:#03060b; --font-color-title:#03060b;
--font-weight-base:400; --font-weight-title:700; --font-weight-subtitle:600; --font-weight-button:600;
--font-size-title:3rem; --font-size-subtitle:2rem; --font-size-button:1.4rem;
--font-size-small:1.2rem; --font-size-base:1.4rem; --font-size-medium:1.6rem; --font-size-big:1.8rem;
--line-height:1.5;
--white:#fff; --black:#03060b;
--gray-1:#f9f9f9; --gray-2:#c3c3c3; --gray-3:#707070; --gray-4:#363636;
--primary-color:#ffa81a; --secondary-color:#ff7f0a;
--white-transparency:#ffffffb3; --transition:all .4s ease;
/* apelidos usados nesta pagina, mapeados nos tokens acima */
--marinho:var(--black); --papel:var(--white); --cinza:var(--gray-1);
--tx:var(--font-color-base); --fraco:var(--gray-3); --linha:#e6e6e6;
--laranja:var(--primary-color); --laranja2:var(--secondary-color);
--vermelho:#D94A28; --verde:#2E9E5B; --verde-ml:#00A650; --verde-claro:#57D68A;
--sombra:0 1px 2px rgba(3,6,11,.05), 0 4px 14px rgba(3,6,11,.05);
--sombra-alta:0 2px 6px rgba(3,6,11,.07), 0 10px 30px rgba(3,6,11,.11)}
*{box-sizing:border-box;margin:0;padding:0}
html{scroll-behavior:smooth;font-size:62.5%}
body{background:var(--white);color:var(--font-color-base);font-family:var(--font-family-base);
font-size:var(--font-size-base);font-weight:var(--font-weight-base);line-height:var(--line-height);
-webkit-font-smoothing:antialiased}
h1,h2,h3{font-family:var(--font-family-title);color:var(--font-color-title);
font-weight:var(--font-weight-title);letter-spacing:normal;line-height:1.15}
.w{max-width:1020px;margin:0 auto;padding:0 22px}
/* tarja rotativa, como no site */
.tarja{background:var(--laranja);color:#111;text-align:center;padding:8px 16px;
font-family:"Inter",sans-serif;font-weight:700;font-size:14.5px;letter-spacing:.02em}
.tarja a{color:#111;text-decoration:none}
/* topo */
.topo{background:#fff;border-bottom:1px solid var(--linha);position:sticky;top:0;z-index:60}
.topo .w{display:flex;align-items:center;gap:22px;padding-top:15px;padding-bottom:15px}
.logo{display:flex;align-items:center;text-decoration:none;white-space:nowrap;flex:none}
.logo img{height:26px;width:auto;display:block}
.logotexto{font-family:"Inter",sans-serif;font-weight:700;font-size:19px;color:var(--black);
letter-spacing:-.03em}
.logotexto i{font-style:normal;color:var(--laranja)}
.acts{margin-left:auto;display:flex;gap:18px;align-items:center;font-size:15px}
.acts a{color:var(--tx);text-decoration:none;display:flex;align-items:center;gap:6px}
.acts a:hover{color:var(--laranja)}
/* menu */
.menu{background:#fff;border-bottom:1px solid var(--linha)}
.menu .w{display:flex;gap:0;flex-wrap:wrap}
.mi{position:relative}
.mi>a{display:block;padding:13px 16px;font-family:"Inter",sans-serif;font-weight:600;
font-size:15px;color:var(--tx);text-decoration:none;white-space:nowrap}
.mi:hover>a{color:var(--laranja)}
.mi.on>a{color:var(--laranja);box-shadow:inset 0 -2px 0 var(--laranja)}
.sub{display:none;position:absolute;top:100%;left:0;background:#fff;border:1px solid var(--linha);
border-radius:0 0 9px 9px;min-width:224px;padding:7px 0;z-index:70;
box-shadow:0 10px 26px rgba(0,0,0,.09)}
.mi:hover .sub,.mi:focus-within .sub{display:block}
.sub a{display:block;padding:8px 17px;font-size:15px;color:var(--fraco);text-decoration:none}
.sub a:hover{color:var(--laranja);background:var(--cinza)}
@media(max-width:820px){
.menu .w{overflow-x:auto;flex-wrap:nowrap}
.sub{display:none!important}
}
footer{background:#000;color:#8D9AB0;padding:26px 0 20px;font-size:15px}
.frow{display:flex;gap:26px;align-items:flex-start;flex-wrap:wrap;padding-bottom:18px}
.fmarca{display:flex;align-items:center;gap:9px;font-family:"Inter",sans-serif;
font-weight:700;font-size:15px;color:var(--laranja);letter-spacing:.06em;white-space:nowrap}
.fmarca img{height:22px;width:auto;display:block;filter:brightness(0) invert(1)}
footer a{color:#8D9AB0;text-decoration:none}
footer a:hover{color:var(--laranja2)}
.flinks{display:flex;gap:18px;flex-wrap:wrap;font-size:15px}
.fcont{margin-left:auto;text-align:right;font-size:15px;line-height:1.7}
@media(max-width:720px){.fcont{margin-left:0;text-align:left}}
.fbase{border-top:1px solid rgba(255,255,255,.1);padding-top:14px;display:flex;
justify-content:space-between;gap:12px;flex-wrap:wrap;font-size:13.5px;color:#5A6570}
/* The footer sits at the bottom of the screen even when the page is short. */
body{display:flex;flex-direction:column;min-height:100vh}
body>footer{margin-top:auto}
/* In a column, auto side margins shrink a box to its content: the page's
centred blocks keep their full width, up to their max-width, as before. */
body>.w{width:100%}

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