Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef95dcc327 | ||
|
|
0b86fe79fc | ||
|
|
24ee4e9eab | ||
|
|
223854e392 | ||
|
|
0b55ebce7f | ||
|
|
bee7f8f245 | ||
|
|
c5db1375de | ||
|
|
3d8bca3af7 | ||
|
|
f3fc733352 | ||
|
|
4ce44feb70 | ||
|
|
01b94da36d | ||
|
|
25fa890e92 | ||
|
|
4386fc0737 | ||
|
|
70a76feb23 | ||
|
|
20982a945f | ||
|
|
37715ef223 | ||
|
|
b7e2ea12d0 | ||
|
|
933bd30cbd | ||
|
|
eb254da501 | ||
|
|
8786a33c8d | ||
|
|
20403c5132 | ||
|
|
1b57313496 | ||
|
|
a02711ef2b | ||
|
|
8dddf0fa25 | ||
|
|
f4aacb5776 | ||
|
|
64e47ee481 | ||
|
|
2b313a1cc8 | ||
|
|
7116ebe50a | ||
|
|
9e69c48d2d | ||
|
|
64c1260a9f | ||
|
|
b5bb03cbb4 | ||
|
|
2495edf188 | ||
|
|
f5fed013ab | ||
|
|
593ddf82c8 | ||
|
|
8b5aafa299 | ||
|
|
e47f88a6d6 | ||
|
|
5f2be7ea20 | ||
|
|
4437232d27 | ||
|
|
8bc57195e8 | ||
|
|
0a575a3be7 | ||
|
|
15abd589a8 | ||
|
|
32c060e1b0 | ||
|
|
6f5d183365 | ||
|
|
2215da8d5e | ||
|
|
88e65290e1 | ||
|
|
690b15ece1 | ||
|
|
e20c8673bb | ||
|
|
4de2151e9a | ||
|
|
641aafc87d | ||
|
|
875a7ef9c7 | ||
|
|
c436936fed | ||
|
|
0ed6de4bd5 | ||
|
|
eae3dad306 | ||
|
|
7b6675d4d4 | ||
|
|
4df74221d2 | ||
|
|
43043c361c | ||
|
|
536510b148 | ||
|
|
a1877bdf0a | ||
|
|
275ebf72c4 | ||
|
|
9bea187ea4 | ||
|
|
bd56170248 | ||
|
|
60b7336b37 | ||
|
|
04e4cbc953 | ||
|
|
aec5b1d054 | ||
|
|
8b42e684ca | ||
|
|
e768dcb489 | ||
|
|
122c645f72 | ||
|
|
988b252f9d | ||
|
|
56021d8451 | ||
|
|
b6a90411f1 | ||
|
|
cbbbdb351a | ||
|
|
b81ff8d03d | ||
|
|
177882e77b | ||
|
|
2e03b0362b | ||
|
|
99a558ddd9 |
@@ -37,6 +37,11 @@ TINY_ADAPTER=fake
|
|||||||
# TINY_PRODUCT_UV_FOLHA=...
|
# TINY_PRODUCT_UV_FOLHA=...
|
||||||
# TINY_PRODUCT_UV_AVULSA=...
|
# TINY_PRODUCT_UV_AVULSA=...
|
||||||
# TINY_ADAPTER=tiny # only once connected and tested: creates real orders
|
# TINY_ADAPTER=tiny # only once connected and tested: creates real orders
|
||||||
|
# Jadlog price quotes go in jadlog.env, not here (see app/jadlog_probe.py):
|
||||||
|
# JADLOG_TOKEN=...
|
||||||
|
# JADLOG_CNPJ=... # the "Usuário" Jadlog issued, the CNPJ that contracts freight
|
||||||
|
# JADLOG_CONTA=... # conta corrente
|
||||||
|
# JADLOG_CONTRATO= # only if Jadlog issued a contract number
|
||||||
WHATSAPP_ADAPTER=fake
|
WHATSAPP_ADAPTER=fake
|
||||||
STORAGE_ADAPTER=s3-local
|
STORAGE_ADAPTER=s3-local
|
||||||
MOCK_FREIGHT_CENTS=1500
|
MOCK_FREIGHT_CENTS=1500
|
||||||
|
|||||||
@@ -86,13 +86,14 @@ jobs:
|
|||||||
# the generator's geometry. The provider suites use a fake transport: they
|
# the generator's geometry. The provider suites use a fake transport: they
|
||||||
# prove the documented contract, not the integration.
|
# prove the documented contract, not the integration.
|
||||||
- name: Print-file geometry and provider adapters
|
- name: Print-file geometry and provider adapters
|
||||||
run: $COMPOSE exec -T api python -m unittest tests.test_printfile tests.test_mercadopago tests.test_tiny -v
|
run: $COMPOSE exec -T api python -m unittest tests.test_printfile tests.test_mercadopago tests.test_tiny tests.test_jadlog 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 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.
|
||||||
@@ -106,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()
|
||||||
@@ -206,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: |
|
||||||
|
|||||||
@@ -17,9 +17,16 @@ 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 ('FREIGHT', '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')
|
tiny = os.environ.get('TINY_ADAPTER')
|
||||||
if tiny == 'tiny':
|
if tiny == 'tiny':
|
||||||
from .tiny import required_settings
|
from .tiny import required_settings
|
||||||
@@ -106,6 +113,11 @@ class FakePayment:
|
|||||||
|
|
||||||
def create(self, quote_id: str, total_cents: int, customer: dict, method: dict | None = None) -> dict:
|
def create(self, quote_id: str, total_cents: int, customer: dict, method: dict | None = None) -> dict:
|
||||||
kind = (method or {}).get('type', 'pix')
|
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}',
|
return {'provider': 'fake', 'id': f'local-{quote_id}-{kind}',
|
||||||
'status': 'pending', 'total_cents': total_cents}
|
'status': 'pending', 'total_cents': total_cents}
|
||||||
|
|
||||||
@@ -143,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():
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
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
|
||||||
|
|
||||||
@@ -10,7 +12,7 @@ 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,
|
||||||
login_failed, 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
|
||||||
|
|
||||||
@@ -57,7 +59,7 @@ def login(body: Login, request: Request, response: Response):
|
|||||||
if not account or not matches:
|
if not account or not matches:
|
||||||
login_failed(email)
|
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$'):
|
||||||
@@ -86,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)):
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
"""Health, session bootstrap and freight quoting."""
|
"""Health, session bootstrap, freight quoting and the address of a CEP."""
|
||||||
import os
|
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, payment, storage
|
from ..runtime import (ENVIRONMENT, GUEST_SESSION_LIMIT, PART_BYTES, freight, payment,
|
||||||
|
require_delivery_available, storage)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -36,11 +39,34 @@ def session(request: Request, response: 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(), 'payment_provider': payment.name,
|
'max_upload_bytes': upload_limit_bytes(), 'payment_provider': payment.name,
|
||||||
# Public by design: Mercado Pago's card form needs it in the browser.
|
# 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 ''}
|
'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 ''}
|
||||||
|
|||||||
@@ -8,17 +8,16 @@ from uuid import UUID
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from psycopg.types.json import Jsonb
|
|
||||||
|
|
||||||
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,
|
||||||
login_failed, password_matches, throttle)
|
login_failed, password_matches, throttle)
|
||||||
from ..core.models import Move, OperatorLogin, Resolution, Review
|
from ..core.models import Move, OperatorLogin, Resolution, Review
|
||||||
from ..core.pricing import price
|
|
||||||
from ..printjobs import queue as queue_print_files
|
from ..printjobs import queue as queue_print_files
|
||||||
|
from .. import payments, quote_review
|
||||||
from .. import tiny
|
from .. import tiny
|
||||||
from ..runtime import (BOARD_FINISHED_LIMIT, BOARD_QUOTE_LIMIT, STATES, TRANSITIONS,
|
from ..runtime import (BACK, BOARD_FINISHED_LIMIT, BOARD_QUOTE_LIMIT, ENVIRONMENT, STATES, TRANSITIONS, payment,
|
||||||
enqueue, freight, quote_view, storage, upload_row)
|
enqueue, freight, quote_view, storage)
|
||||||
from ..scanning import require_clean
|
from ..scanning import require_clean
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -30,14 +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)
|
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()
|
||||||
@@ -60,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:
|
||||||
@@ -84,41 +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']
|
||||||
orders = active + list(reversed(finished))
|
orders = with_print_files(c, active + list(reversed(finished)))
|
||||||
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']]
|
|
||||||
# A paid notification that did not become an order is money received
|
# A paid notification that did not become an order is money received
|
||||||
# for nothing the factory will make. It stays on the board until a
|
# for nothing the factory will make. The board carries the count; the
|
||||||
# person records what was done about it.
|
# Pagamentos tab pages through them until someone records a resolution.
|
||||||
refused = c.execute('''SELECT id,provider,event_id,reference,status,amount_cents,received_at,outcome
|
issues_total = c.execute('''SELECT count(*) AS n FROM dtf_local.payment_events
|
||||||
FROM dtf_local.payment_events WHERE (outcome LIKE 'refused%' OR outcome LIKE 'attention%') AND resolved_at IS NULL
|
WHERE (outcome LIKE 'refused%' OR outcome LIKE 'attention%') AND resolved_at IS NULL''').fetchone()['n']
|
||||||
ORDER BY received_at LIMIT 100''').fetchall()
|
backup = backup_status(c)
|
||||||
return {'states': STATES, 'transitions': TRANSITIONS,
|
return {'states': STATES, 'transitions': TRANSITIONS, 'back': BACK,
|
||||||
'orders': orders, 'payment_issues': refused, 'tiny': tiny_status(),
|
'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)):
|
||||||
@@ -126,31 +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,
|
|
||||||
'destination': draft.get('destination'),
|
|
||||||
'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)):
|
||||||
@@ -159,28 +242,41 @@ 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')
|
@router.post('/api/operator/orders/{uid}/print-files')
|
||||||
@@ -224,13 +320,55 @@ def tiny_connect(user=Depends(operator)):
|
|||||||
audit('tiny_connect_started', operator=user)
|
audit('tiny_connect_started', operator=user)
|
||||||
return {'url': tiny.TinyAuth().authorize_url(user)}
|
return {'url': tiny.TinyAuth().authorize_url(user)}
|
||||||
|
|
||||||
@router.get('/api/operator/tiny/callback')
|
@router.post('/api/operator/tiny/test')
|
||||||
def tiny_callback(code: str = Query(max_length=4096), state: str = Query(max_length=128)):
|
def tiny_test(user=Depends(operator)):
|
||||||
"""Tiny's redirect back. Cross-site, so the operator cookie is absent: the
|
"""Read one order and one contact from Tiny. Creates nothing."""
|
||||||
single-use state an operator created is what authorises it."""
|
|
||||||
if not tiny.configured():
|
if not tiny.configured():
|
||||||
raise HTTPException(503, 'Tiny application is not configured')
|
raise HTTPException(503, 'Tiny application is not configured')
|
||||||
try:
|
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)
|
who = tiny.TinyAuth().complete(code, state)
|
||||||
except tiny.TinyError:
|
except tiny.TinyError:
|
||||||
audit('tiny_connect_failed')
|
audit('tiny_connect_failed')
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ and an unverified delivery is recorded and refused rather than retried.
|
|||||||
"""
|
"""
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from psycopg.types.json import Jsonb
|
from psycopg.types.json import Jsonb
|
||||||
|
|
||||||
@@ -21,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')
|
||||||
@@ -58,6 +61,17 @@ def event_provider():
|
|||||||
return payment.name
|
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')
|
@router.post('/api/payments/intent')
|
||||||
def intent(body: PaymentIntent, session_id=Depends(owner)):
|
def intent(body: PaymentIntent, session_id=Depends(owner)):
|
||||||
"""Start paying an approved quote: a PIX code, or a card token from the
|
"""Start paying an approved quote: a PIX code, or a card token from the
|
||||||
@@ -71,23 +85,59 @@ def intent(body: PaymentIntent, session_id=Depends(owner)):
|
|||||||
raise HTTPException(404 if 'not found' in str(refusal) else 409, str(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():
|
if c.execute('SELECT 1 FROM dtf_local.orders WHERE quote_id=%s', (body.quote_id,)).fetchone():
|
||||||
raise HTTPException(409, 'Quote is already paid')
|
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
|
# Never a second charge: an approved payment is waiting for its
|
||||||
# notification to become the order, and a card in review may still be.
|
# 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
|
if c.execute('''SELECT 1 FROM dtf_local.payment_intents WHERE quote_id=%s
|
||||||
AND (status='approved' OR (method='card' AND status='pending'))''', (body.quote_id,)).fetchone():
|
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')
|
raise HTTPException(409, 'A payment for this quote is already approved or in review')
|
||||||
|
method = body.method.model_dump()
|
||||||
if body.method.type == 'pix':
|
if body.method.type == 'pix':
|
||||||
existing = c.execute('''SELECT * FROM dtf_local.payment_intents WHERE quote_id=%s AND method='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()
|
AND status='pending' ORDER BY created_at DESC LIMIT 1''', (body.quote_id,)).fetchone()
|
||||||
if existing:
|
if existing and not existing['expired']:
|
||||||
return existing['response']
|
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:
|
try:
|
||||||
created = payment.create(str(body.quote_id), quote['approved']['total_cents'],
|
created = payment.create(str(body.quote_id), quote['approved']['total_cents'],
|
||||||
quote['approved']['customer'], body.method.model_dump())
|
quote['approved']['customer'], method)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(422, str(exc))
|
raise HTTPException(422, str(exc))
|
||||||
except Exception:
|
except httpx.HTTPStatusError as exc:
|
||||||
audit('payment_intent_failed', quote=str(body.quote_id))
|
# 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')
|
raise HTTPException(502, 'Payment provider unavailable; try again')
|
||||||
c.execute('''INSERT INTO dtf_local.payment_intents(id,quote_id,provider,provider_payment_id,method,
|
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)
|
status,amount_cents,response) VALUES(%s,%s,%s,%s,%s,%s,%s,%s)
|
||||||
|
|||||||
@@ -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()
|
||||||
try:
|
reason = quote_review.review_reason(draft)
|
||||||
freight.quote(body.freight.service, body.freight.postal_code)
|
if reason is not None:
|
||||||
except ValueError as exc:
|
# Waits for review: check the delivery now, priced at approval.
|
||||||
raise HTTPException(422, str(exc))
|
try:
|
||||||
|
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:
|
||||||
|
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)):
|
||||||
|
|||||||
@@ -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')''',
|
||||||
@@ -57,8 +58,11 @@ def upload_status(uid: UUID, session_id=Depends(owner)):
|
|||||||
def part_url(uid: UUID, part: int, session_id=Depends(owner)):
|
def part_url(uid: UUID, part: int, session_id=Depends(owner)):
|
||||||
with db.connect() as c:
|
with db.connect() as c:
|
||||||
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}')
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ from uuid import UUID, uuid4
|
|||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from .printjobs import generated_identity
|
|
||||||
from .runtime import upload_row
|
from .runtime import upload_row
|
||||||
from .scanning import require_clean
|
from .scanning import require_clean
|
||||||
|
|
||||||
@@ -30,7 +29,8 @@ def generated_owner(c, order, ref, kind):
|
|||||||
if c.execute("SELECT 1 FROM dtf_local.order_files WHERE order_id=%s AND kind='correction' LIMIT 1",
|
if c.execute("SELECT 1 FROM dtf_local.order_files WHERE order_id=%s AND kind='correction' LIMIT 1",
|
||||||
(order['id'],)).fetchone():
|
(order['id'],)).fetchone():
|
||||||
raise HTTPException(409, 'A customer correction replaced the artwork this file was generated from')
|
raise HTTPException(409, 'A customer correction replaced the artwork this file was generated from')
|
||||||
return generated_identity(order['id'])
|
# 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:
|
||||||
@@ -67,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}
|
||||||
|
|||||||
@@ -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,7 +92,7 @@ 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
|
ACCOUNT_FAILURES = 10
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ def throttle(email, request):
|
|||||||
WHERE key=%s AND started_at >= now()-interval '900 seconds'""", (key,)).fetchone()
|
WHERE key=%s AND started_at >= now()-interval '900 seconds'""", (key,)).fetchone()
|
||||||
if row and row['attempts'] >= ACCOUNT_FAILURES:
|
if row and row['attempts'] >= ACCOUNT_FAILURES:
|
||||||
audit('rate_limit', scope='auth-account')
|
audit('rate_limit', scope='auth-account')
|
||||||
raise HTTPException(429, 'Too many requests. Try again later.', headers={'Retry-After': '900'})
|
raise HTTPException(429, 'Muitas tentativas. Tente de novo mais tarde.', headers={'Retry-After': '900'})
|
||||||
|
|
||||||
def login_failed(email):
|
def login_failed(email):
|
||||||
"""Count a failed sign-in (or registration attempt) against the account."""
|
"""Count a failed sign-in (or registration attempt) against the account."""
|
||||||
@@ -117,10 +117,10 @@ def login_failed(email):
|
|||||||
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']
|
||||||
|
|||||||
@@ -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())
|
|
||||||
|
|||||||
@@ -41,9 +41,15 @@ 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',
|
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']
|
'PR','PE','PI','RJ','RN','RS','RO','RR','SC','SP','SE','TO']
|
||||||
|
|
||||||
@@ -186,6 +192,10 @@ class PaymentMethod(StrictModel):
|
|||||||
payment_method_id: str | None = Field(default=None, max_length=40, pattern=r'^[a-z_]+$')
|
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)
|
installments: int = Field(default=1, ge=1, le=12, strict=True)
|
||||||
issuer_id: str | None = Field(default=None, max_length=40)
|
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')
|
@model_validator(mode='after')
|
||||||
def card_needs_token(self):
|
def card_needs_token(self):
|
||||||
@@ -209,6 +219,35 @@ 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
@@ -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
@@ -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
@@ -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())
|
||||||
@@ -21,6 +21,7 @@ import hmac
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from decimal import Decimal, InvalidOperation
|
from decimal import Decimal, InvalidOperation
|
||||||
from typing import Mapping
|
from typing import Mapping
|
||||||
|
|
||||||
@@ -35,6 +36,9 @@ MAX_SIGNATURE_AGE = 30 * 60
|
|||||||
STATUSES = {'approved': 'approved', 'pending': 'pending', 'in_process': 'pending',
|
STATUSES = {'approved': 'approved', 'pending': 'pending', 'in_process': 'pending',
|
||||||
'authorized': 'pending', 'in_mediation': 'pending', 'rejected': 'rejected',
|
'authorized': 'pending', 'in_mediation': 'pending', 'rejected': 'rejected',
|
||||||
'cancelled': 'cancelled', 'refunded': 'refunded', 'charged_back': 'refunded'}
|
'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:
|
class MercadoPagoPayment:
|
||||||
@@ -70,31 +74,51 @@ class MercadoPagoPayment:
|
|||||||
'identification': {'type': 'CNPJ', 'number': customer['cnpj']}}}
|
'identification': {'type': 'CNPJ', 'number': customer['cnpj']}}}
|
||||||
if self.notification_url:
|
if self.notification_url:
|
||||||
body['notification_url'] = self.notification_url
|
body['notification_url'] = self.notification_url
|
||||||
|
expires_at = None
|
||||||
if method['type'] == 'pix':
|
if method['type'] == 'pix':
|
||||||
body['payment_method_id'] = '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':
|
elif method['type'] == 'card':
|
||||||
body.update(token=method['token'], payment_method_id=method['payment_method_id'],
|
body.update(token=method['token'], payment_method_id=method['payment_method_id'],
|
||||||
installments=int(method.get('installments', 1)))
|
installments=int(method.get('installments', 1)))
|
||||||
if method.get('issuer_id'):
|
# 3-D Secure (the bank's confirmation) only for debit, which needs
|
||||||
body['issuer_id'] = method['issuer_id']
|
# 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:
|
else:
|
||||||
raise ValueError('Unsupported payment method')
|
raise ValueError('Unsupported payment method')
|
||||||
# A PIX retry must return the same code. A card retry after a decline
|
# A PIX retry must return the same code; a new one, after the last
|
||||||
# is a new attempt with a new token, so the token is part of the key;
|
# expired, is the next attempt. A card retry after a decline is a new
|
||||||
# the intent route refuses new attempts once one is approved or in review.
|
# attempt with a new token, so the token is part of the key; the intent
|
||||||
key = f'dtf-quote-{quote_id}-pix' if method['type'] == 'pix' else \
|
# 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]}"
|
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 = self.http.post('/v1/payments', json=body, headers={'X-Idempotency-Key': key})
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
payment = response.json()
|
payment = response.json()
|
||||||
transaction = (payment.get('point_of_interaction') or {}).get('transaction_data') or {}
|
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']),
|
return {'provider': self.name, 'id': str(payment['id']),
|
||||||
'status': STATUSES.get(payment.get('status'), 'pending'),
|
'status': STATUSES.get(payment.get('status'), 'pending'),
|
||||||
'status_detail': payment.get('status_detail'),
|
'status_detail': payment.get('status_detail'),
|
||||||
'total_cents': total_cents,
|
'total_cents': total_cents,
|
||||||
'pix_qr_code': transaction.get('qr_code'),
|
'pix_qr_code': transaction.get('qr_code'),
|
||||||
'pix_qr_code_base64': transaction.get('qr_code_base64'),
|
'pix_qr_code_base64': transaction.get('qr_code_base64'),
|
||||||
'ticket_url': transaction.get('ticket_url')}
|
'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:
|
def lookup(self, payment_id: str) -> dict:
|
||||||
response = self.http.get(f'/v1/payments/{payment_id}')
|
response = self.http.get(f'/v1/payments/{payment_id}')
|
||||||
@@ -134,7 +158,15 @@ class MercadoPagoPayment:
|
|||||||
payment_id = str((query or {}).get('data.id') or (data.get('data') or {}).get('id') or '')
|
payment_id = str((query or {}).get('data.id') or (data.get('data') or {}).get('id') or '')
|
||||||
if not payment_id.isdigit():
|
if not payment_id.isdigit():
|
||||||
return None
|
return None
|
||||||
payment = self.lookup(payment_id)
|
try:
|
||||||
|
payment = self.lookup(payment_id)
|
||||||
|
except httpx.HTTPStatusError as error:
|
||||||
|
# Signed by Mercado Pago but about no payment of ours, such as the
|
||||||
|
# panel's "Simular notificação". Anything else is raised so that a
|
||||||
|
# real notification is retried.
|
||||||
|
if error.response.status_code == 404:
|
||||||
|
return None
|
||||||
|
raise
|
||||||
return event_from_payment(payment, notification_id=str(data.get('id', '')))
|
return event_from_payment(payment, notification_id=str(data.get('id', '')))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
65
app/mercadopago_probe.py
Normal 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())
|
||||||
@@ -41,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.
|
||||||
|
|
||||||
@@ -60,7 +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']))
|
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'],
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ Image.MAX_IMAGE_PIXELS = None
|
|||||||
|
|
||||||
|
|
||||||
class Unsupported(Exception):
|
class Unsupported(Exception):
|
||||||
"""This item cannot be generated automatically; the reason is for the operator."""
|
"""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):
|
class Name(str):
|
||||||
@@ -153,14 +154,14 @@ class SourceImage:
|
|||||||
self.image = Image.open(path)
|
self.image = Image.open(path)
|
||||||
self.format = self.image.format
|
self.format = self.image.format
|
||||||
except Image.DecompressionBombError as exc:
|
except Image.DecompressionBombError as exc:
|
||||||
raise Unsupported(f'"{name}" is too large to generate automatically') from exc
|
raise Unsupported(f'"{name}" é grande demais para gerar automaticamente') from exc
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise Unsupported(f'"{name}" is not an image this generator can read') from exc
|
raise Unsupported(f'"{name}" não é uma imagem que o gerador consiga ler') from exc
|
||||||
if self.format not in SUPPORTED_FORMATS:
|
if self.format not in SUPPORTED_FORMATS:
|
||||||
raise Unsupported(f'"{name}" is {self.format or "an unknown format"}; '
|
raise Unsupported(f'"{name}" está em {self.format or "formato desconhecido"}; '
|
||||||
'only JPEG, PNG, WebP and TIFF are generated automatically')
|
'só JPEG, PNG, WebP, TIFF e PDF são gerados automaticamente')
|
||||||
if getattr(self.image, 'n_frames', 1) > 1 and self.format != 'TIFF':
|
if getattr(self.image, 'n_frames', 1) > 1 and self.format != 'TIFF':
|
||||||
raise Unsupported(f'"{name}" is animated or has several frames')
|
raise Unsupported(f'"{name}" é animado ou tem vários quadros')
|
||||||
# Browsers draw a photo upright according to its EXIF orientation, and
|
# Browsers draw a photo upright according to its EXIF orientation, and
|
||||||
# the Site measured it that way, so the print must too.
|
# the Site measured it that way, so the print must too.
|
||||||
try:
|
try:
|
||||||
@@ -181,8 +182,8 @@ class SourceImage:
|
|||||||
return self._embed_jpeg(pdf)
|
return self._embed_jpeg(pdf)
|
||||||
width, height = self.image.size
|
width, height = self.image.size
|
||||||
if width * height > MAX_DECODED_PIXELS:
|
if width * height > MAX_DECODED_PIXELS:
|
||||||
raise Unsupported(f'"{self.name}" has {width} x {height} px, more than the '
|
raise Unsupported(f'"{self.name}" tem {width} × {height} px, mais do que o '
|
||||||
'generator decodes; prepare this item by hand')
|
'gerador processa; prepare este item à mão')
|
||||||
return self._embed_pixels(pdf)
|
return self._embed_pixels(pdf)
|
||||||
|
|
||||||
def _colorspace(self, pdf, mode):
|
def _colorspace(self, pdf, mode):
|
||||||
@@ -235,7 +236,7 @@ class SourceImage:
|
|||||||
elif mode in ('P', 'PA', 'RGB', 'RGBA', 'RGBa'):
|
elif mode in ('P', 'PA', 'RGB', 'RGBA', 'RGBa'):
|
||||||
color_mode = 'RGB'
|
color_mode = 'RGB'
|
||||||
else:
|
else:
|
||||||
raise Unsupported(f'"{self.name}" uses the {mode} colour mode, which is not generated automatically')
|
raise Unsupported(f'"{self.name}" usa o modo de cor {mode}, que não é gerado automaticamente')
|
||||||
if has_alpha:
|
if has_alpha:
|
||||||
image = image.convert('RGBA' if color_mode == 'RGB' else 'LA')
|
image = image.convert('RGBA' if color_mode == 'RGB' else 'LA')
|
||||||
width, height = image.size
|
width, height = image.size
|
||||||
@@ -304,29 +305,29 @@ class PdfPage:
|
|||||||
try:
|
try:
|
||||||
self.pdf = pikepdf.open(path)
|
self.pdf = pikepdf.open(path)
|
||||||
except pikepdf.PasswordError as exc:
|
except pikepdf.PasswordError as exc:
|
||||||
raise Unsupported(f'"{name}" is password-protected') from exc
|
raise Unsupported(f'"{name}" está protegido por senha') from exc
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise Unsupported(f'"{name}" is not a PDF this generator can read') from exc
|
raise Unsupported(f'"{name}" não é um PDF que o gerador consiga ler') from exc
|
||||||
try:
|
try:
|
||||||
pages = len(self.pdf.pages)
|
pages = len(self.pdf.pages)
|
||||||
if pages != 1:
|
if pages != 1:
|
||||||
raise Unsupported(f'"{name}" has {pages} pages; only single-page PDFs are generated automatically')
|
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.page = self.pdf.pages[0]
|
||||||
self.rotation = int(self.page.rotation) % 360
|
self.rotation = int(self.page.rotation) % 360
|
||||||
if self.rotation not in (0, 90, 180, 270):
|
if self.rotation not in (0, 90, 180, 270):
|
||||||
raise Unsupported(f'"{name}" has an unsupported page rotation')
|
raise Unsupported(f'"{name}" tem uma rotação de página não suportada')
|
||||||
box = [float(v) for v in self.page.cropbox]
|
box = [float(v) for v in self.page.cropbox]
|
||||||
except Unsupported:
|
except Unsupported:
|
||||||
self.pdf.close()
|
self.pdf.close()
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.pdf.close()
|
self.pdf.close()
|
||||||
raise Unsupported(f'"{name}" has a page this generator cannot read') from exc
|
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.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])
|
self.w, self.h = abs(box[2] - box[0]), abs(box[3] - box[1])
|
||||||
if self.w <= 0 or self.h <= 0:
|
if self.w <= 0 or self.h <= 0:
|
||||||
self.pdf.close()
|
self.pdf.close()
|
||||||
raise Unsupported(f'"{name}" has an empty page')
|
raise Unsupported(f'"{name}" tem uma página vazia')
|
||||||
# As displayed, which is what the proportions are checked against.
|
# 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)
|
self.size = (self.h, self.w) if self.rotation in (90, 270) else (self.w, self.h)
|
||||||
|
|
||||||
@@ -373,7 +374,7 @@ def check_layout(item, sizes, vector=()):
|
|||||||
height = Decimal(str(production['height_cm']))
|
height = Decimal(str(production['height_cm']))
|
||||||
billed = Decimal(str(item['billed_metres'])) * 100
|
billed = Decimal(str(item['billed_metres'])) * 100
|
||||||
if height > billed + HEIGHT_TOLERANCE_CM:
|
if height > billed + HEIGHT_TOLERANCE_CM:
|
||||||
raise Unsupported(f'layout is {height} cm long but only {billed} cm were billed')
|
raise Unsupported(f'a montagem tem {height} cm, mas só {billed} cm foram cobrados')
|
||||||
lowest = None
|
lowest = None
|
||||||
for placement in production['placements']:
|
for placement in production['placements']:
|
||||||
width_px, height_px = sizes[placement['source_index']]
|
width_px, height_px = sizes[placement['source_index']]
|
||||||
@@ -384,8 +385,8 @@ def check_layout(item, sizes, vector=()):
|
|||||||
drift = abs((width_px / height_px) / (width_cm / length_cm) - 1)
|
drift = abs((width_px / height_px) / (width_cm / length_cm) - 1)
|
||||||
if drift > ASPECT_TOLERANCE:
|
if drift > ASPECT_TOLERANCE:
|
||||||
source = production['sources'][placement['source_index']]
|
source = production['sources'][placement['source_index']]
|
||||||
raise Unsupported(f'file {placement["source_index"] + 1} has proportions that do not match '
|
raise Unsupported(f'o arquivo {placement["source_index"] + 1} tem proporções diferentes '
|
||||||
f'the quoted {source["width_cm"]} x {source["length_cm"]} cm')
|
f'das cotadas ({source["width_cm"]} × {source["length_cm"]} cm)')
|
||||||
if placement['source_index'] in vector:
|
if placement['source_index'] in vector:
|
||||||
continue
|
continue
|
||||||
dpi = width_px / (width_cm / 2.54)
|
dpi = width_px / (width_cm / 2.54)
|
||||||
@@ -405,7 +406,7 @@ def render(item, files, out, title):
|
|||||||
"""
|
"""
|
||||||
production = item['production']
|
production = item['production']
|
||||||
if production.get('version') != 2:
|
if production.get('version') != 2:
|
||||||
raise Unsupported('item uses an obsolete production layout')
|
raise Unsupported('o item usa uma montagem antiga')
|
||||||
sources = []
|
sources = []
|
||||||
try:
|
try:
|
||||||
for index in range(len(production['sources'])):
|
for index in range(len(production['sources'])):
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ 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
|
order, already marked clean: its only inputs are artwork that passed the
|
||||||
malware scan, and the bytes are written here. The operator still decides
|
malware scan, and the bytes are written here. The operator still decides
|
||||||
whether it becomes the final file; generation never approves anything.
|
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 logging
|
||||||
import os
|
import os
|
||||||
@@ -25,6 +31,9 @@ from .printfile import Unsupported, render
|
|||||||
|
|
||||||
CLAIM_TIMEOUT = timedelta(minutes=15)
|
CLAIM_TIMEOUT = timedelta(minutes=15)
|
||||||
MAX_ATTEMPTS = 3
|
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):
|
def generated_identity(order_id):
|
||||||
@@ -60,13 +69,26 @@ def render_one(storage):
|
|||||||
c.execute('''UPDATE dtf_local.print_files SET status='rendering', claimed_at=now(),
|
c.execute('''UPDATE dtf_local.print_files SET status='rendering', claimed_at=now(),
|
||||||
attempts=attempts+1 WHERE id=%s''', (job['id'],))
|
attempts=attempts+1 WHERE id=%s''', (job['id'],))
|
||||||
item = job['snapshot']['items'][job['item_index']]
|
item = job['snapshot']['items'][job['item_index']]
|
||||||
uploads = c.execute('''SELECT id,name,object_key,scan_state,purged_at,expires_at,
|
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)''',
|
(expires_at<=now()) AS expired FROM dtf_local.uploads WHERE id=ANY(%s)''',
|
||||||
([UUID(u) for u in item['uploads']],)).fetchall()
|
([UUID(u) for u in item['uploads']],)).fetchall()
|
||||||
# Every generated file shares its order's artwork retention deadline.
|
# 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)',
|
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']
|
([UUID(u) for u in item['uploads']],)).fetchone()['e']
|
||||||
by_id = {str(row['id']): row for row in uploads}
|
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:
|
try:
|
||||||
result = produce(storage, job, item, by_id)
|
result = produce(storage, job, item, by_id)
|
||||||
except Unsupported as reason:
|
except Unsupported as reason:
|
||||||
@@ -96,14 +118,33 @@ def render_one(storage):
|
|||||||
return True
|
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):
|
def produce(storage, job, item, uploads):
|
||||||
"""Fetch the item's artwork and render it. Returns (pdf path, name, size, evidence)."""
|
"""Fetch the item's artwork and render it. Returns (pdf path, name, size, evidence)."""
|
||||||
for upload_id in item['uploads']:
|
for upload_id in item['uploads']:
|
||||||
row = uploads.get(upload_id)
|
row = uploads.get(upload_id)
|
||||||
if not row or row['scan_state'] != 'clean':
|
if not row or row['scan_state'] != 'clean':
|
||||||
raise Unsupported('an original file is missing or not cleared by the malware scan')
|
raise Unsupported('um arquivo original está ausente ou não foi liberado pelo antivírus')
|
||||||
if row['purged_at'] or row['expired']:
|
if row['purged_at'] or row['expired']:
|
||||||
raise Unsupported('an original file has passed its retention period')
|
raise Unsupported('um arquivo original passou do prazo de guarda')
|
||||||
number = job['number']
|
number = job['number']
|
||||||
name = f'pedido-{number}-item-{job["item_index"] + 1}.pdf'
|
name = f'pedido-{number}-item-{job["item_index"] + 1}.pdf'
|
||||||
with tempfile.TemporaryDirectory(prefix='print-') as scratch:
|
with tempfile.TemporaryDirectory(prefix='print-') as scratch:
|
||||||
|
|||||||
89
app/quote_review.py
Normal 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
|
||||||
@@ -25,7 +25,11 @@ if os.environ.get('PAYMENT_ADAPTER') == 'mercadopago':
|
|||||||
payment = MercadoPagoPayment()
|
payment = MercadoPagoPayment()
|
||||||
else:
|
else:
|
||||||
payment = FakePayment()
|
payment = FakePayment()
|
||||||
freight = FakeFreight()
|
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]
|
||||||
@@ -46,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):
|
||||||
@@ -60,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}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,9 +99,12 @@ 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:
|
||||||
stream=storage.client.get_object(Bucket=storage.bucket,Key=row['object_key'])['Body']
|
if row['size'] > scan_limit_bytes():
|
||||||
try:state,reason=scanner.scan(stream,row['size'])
|
state,reason=check_large(storage,row)
|
||||||
finally:stream.close()
|
else:
|
||||||
|
stream=storage.client.get_object(Bucket=storage.bucket,Key=row['object_key'])['Body']
|
||||||
|
try:state,reason=scanner.scan(stream,row['size'])
|
||||||
|
finally:stream.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
state,reason='error','Malware scanner unavailable; file remains blocked'
|
state,reason='error','Malware scanner unavailable; file remains blocked'
|
||||||
c.execute("""UPDATE dtf_local.uploads SET scan_state=%s,scan_reason=%s,scanned_at=now(),
|
c.execute("""UPDATE dtf_local.uploads SET scan_state=%s,scan_reason=%s,scanned_at=now(),
|
||||||
|
|||||||
@@ -107,6 +107,13 @@ CREATE TABLE IF NOT EXISTS dtf_local.provider_tokens (
|
|||||||
connected_by text NOT NULL, connected_at timestamptz NOT NULL DEFAULT now(),
|
connected_by text NOT NULL, connected_at timestamptz NOT NULL DEFAULT now(),
|
||||||
updated_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
|
-- Single-use states for an operator-started OAuth connection. They protect the
|
||||||
-- callback, which arrives cross-site without the operator's cookie.
|
-- callback, which arrives cross-site without the operator's cookie.
|
||||||
CREATE TABLE IF NOT EXISTS dtf_local.oauth_states (
|
CREATE TABLE IF NOT EXISTS dtf_local.oauth_states (
|
||||||
@@ -127,6 +134,18 @@ CREATE TABLE IF NOT EXISTS dtf_local.print_files (
|
|||||||
UNIQUE(order_id,item_index)
|
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);
|
||||||
|
|
||||||
-- Indexes follow the queries the application actually issues. Only these; every
|
-- Indexes follow the queries the application actually issues. Only these; every
|
||||||
@@ -173,6 +192,9 @@ CREATE INDEX IF NOT EXISTS payment_events_unprocessed ON dtf_local.payment_event
|
|||||||
CREATE INDEX IF NOT EXISTS print_files_open ON dtf_local.print_files(created_at)
|
CREATE INDEX IF NOT EXISTS print_files_open ON dtf_local.print_files(created_at)
|
||||||
WHERE status IN ('pending','rendering');
|
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
|
-- The Kanban lists payment events a person must act on (money without an
|
||||||
-- order, or a reversed payment on an existing order) until resolved.
|
-- 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_at timestamptz;
|
||||||
|
|||||||
236
app/tiny.py
@@ -19,6 +19,13 @@ Idempotency: the outbox may deliver the same event more than once. Every order
|
|||||||
carries numeroOrdemCompra = "DTF-<order number>", and before creating one the
|
carries numeroOrdemCompra = "DTF-<order number>", and before creating one the
|
||||||
customer's recent orders are searched for that number, so a second delivery
|
customer's recent orders are searched for that number, so a second delivery
|
||||||
finds the first order instead of creating another.
|
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 os
|
||||||
import secrets
|
import secrets
|
||||||
@@ -41,7 +48,24 @@ PRODUCTS = {'file': 'DTF Têxtil 57 cm · folha montada',
|
|||||||
'uv': 'DTF UV 28,5 cm · artes avulsas'}
|
'uv': 'DTF UV 28,5 cm · artes avulsas'}
|
||||||
# How far back to look for an order a previous delivery may already have made.
|
# How far back to look for an order a previous delivery may already have made.
|
||||||
SEARCH_DAYS = 7
|
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
|
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.
|
# Brazil has had no daylight saving since 2019; the order date is the local day.
|
||||||
BRASILIA = timezone(timedelta(hours=-3))
|
BRASILIA = timezone(timedelta(hours=-3))
|
||||||
|
|
||||||
@@ -71,6 +95,10 @@ def required_settings():
|
|||||||
return ['TINY_CLIENT_ID', 'TINY_CLIENT_SECRET', 'TINY_REDIRECT_URI'] + list(PRODUCT_SETTINGS.values())
|
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 -------------------------------------------------------------------
|
# OAuth -------------------------------------------------------------------
|
||||||
|
|
||||||
class TinyAuth:
|
class TinyAuth:
|
||||||
@@ -86,44 +114,78 @@ class TinyAuth:
|
|||||||
self.http = httpx.Client(timeout=20, transport=transport)
|
self.http = httpx.Client(timeout=20, transport=transport)
|
||||||
self.clock = clock
|
self.clock = clock
|
||||||
|
|
||||||
def authorize_url(self, operator):
|
def authorize_url(self, operator, offline=True):
|
||||||
"""Start a connection. The state is single-use, short-lived, and only an
|
"""Start a connection. The state is single-use, short-lived, and only an
|
||||||
authenticated operator can create one, which is what protects the
|
authenticated operator can create one, which is what protects the
|
||||||
callback: Tiny's redirect back is cross-site, so the operator's
|
callback: Tiny's redirect back is cross-site, so the operator's
|
||||||
SameSite=Strict cookie does not travel with it."""
|
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)
|
state = secrets.token_urlsafe(32)
|
||||||
with self.connect() as c:
|
with self.connect() as c:
|
||||||
c.execute("DELETE FROM dtf_local.oauth_states WHERE expires_at<now()")
|
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)
|
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)))
|
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,
|
return f'{AUTH}/auth?' + urlencode({'response_type': 'code', 'client_id': self.client_id,
|
||||||
'redirect_uri': self.redirect_uri, 'scope': 'openid',
|
'redirect_uri': self.redirect_uri,
|
||||||
|
'scope': 'openid offline_access' if offline else 'openid',
|
||||||
'state': state})
|
'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):
|
def complete(self, code, state):
|
||||||
"""Exchange the authorisation code; returns the operator who started it."""
|
"""Exchange the authorisation code; returns the operator who started it."""
|
||||||
with self.connect() as c:
|
with self.connect() as c:
|
||||||
row = c.execute('''DELETE FROM dtf_local.oauth_states WHERE state=%s AND provider='tiny'
|
operator = self._claim(c, state)
|
||||||
AND expires_at>now() RETURNING operator''', (state,)).fetchone()
|
|
||||||
if not row:
|
|
||||||
raise TinyError('Unknown or expired authorisation state')
|
|
||||||
tokens = self._token({'grant_type': 'authorization_code', 'code': code,
|
tokens = self._token({'grant_type': 'authorization_code', 'code': code,
|
||||||
'redirect_uri': self.redirect_uri})
|
'redirect_uri': self.redirect_uri})
|
||||||
self._store(c, tokens, row['operator'])
|
self._store(c, tokens, operator)
|
||||||
return row['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):
|
def status(self):
|
||||||
with self.connect() as c:
|
with self.connect() as c:
|
||||||
row = c.execute('''SELECT connected_by,connected_at,refresh_expires_at
|
row = c.execute('''SELECT connected_by,connected_at,refresh_expires_at,updated_at,offline,
|
||||||
FROM dtf_local.provider_tokens WHERE provider='tiny' ''').fetchone()
|
refresh_failed_at,refused_at FROM dtf_local.provider_tokens WHERE provider='tiny' ''').fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
return {'connected': False}
|
return {'connected': False}
|
||||||
expired = row['refresh_expires_at'] and row['refresh_expires_at'] <= datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
return {'connected': not expired, 'connected_by': row['connected_by'],
|
expires = row['refresh_expires_at']
|
||||||
'connected_at': row['connected_at'], 'expires_at': 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):
|
def access_token(self):
|
||||||
"""A valid access token, refreshing (and rotating) under a row lock."""
|
"""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:
|
with self.connect() as c:
|
||||||
row = c.execute('''SELECT * FROM dtf_local.provider_tokens WHERE provider='tiny'
|
row = c.execute('''SELECT * FROM dtf_local.provider_tokens WHERE provider='tiny'
|
||||||
FOR UPDATE''').fetchone()
|
FOR UPDATE''').fetchone()
|
||||||
@@ -132,11 +194,25 @@ class TinyAuth:
|
|||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
if row['access_expires_at'] > now + timedelta(seconds=60):
|
if row['access_expires_at'] > now + timedelta(seconds=60):
|
||||||
return row['access_token']
|
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:
|
if row['refresh_expires_at'] and row['refresh_expires_at'] <= now:
|
||||||
raise TinyNotConnected('The Tiny connection expired; connect again on the Kanban')
|
raise TinyNotConnected('The Tiny connection expired; connect again on the Kanban')
|
||||||
tokens = self._token({'grant_type': 'refresh_token', 'refresh_token': row['refresh_token']})
|
try:
|
||||||
self._store(c, tokens, row['connected_by'], refreshed=True)
|
tokens = self._token({'grant_type': 'refresh_token', 'refresh_token': row['refresh_token']})
|
||||||
return tokens['access_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):
|
def _token(self, form):
|
||||||
response = self.http.post(f'{AUTH}/token', data={**form, 'client_id': self.client_id,
|
response = self.http.post(f'{AUTH}/token', data={**form, 'client_id': self.client_id,
|
||||||
@@ -152,18 +228,21 @@ class TinyAuth:
|
|||||||
def _store(self, c, tokens, operator, refreshed=False):
|
def _store(self, c, tokens, operator, refreshed=False):
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
access_expires = now + timedelta(seconds=int(tokens.get('expires_in', 300)))
|
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_in = tokens.get('refresh_expires_in')
|
||||||
refresh_expires = now + timedelta(seconds=int(refresh_in)) if refresh_in else None
|
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,
|
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)
|
access_expires_at,refresh_expires_at,connected_by,connected_at,updated_at,offline)
|
||||||
VALUES('tiny',%s,%s,%s,%s,%s,now(),now())
|
VALUES('tiny',%s,%s,%s,%s,%s,now(),now(),%s)
|
||||||
ON CONFLICT(provider) DO UPDATE SET access_token=EXCLUDED.access_token,
|
ON CONFLICT(provider) DO UPDATE SET access_token=EXCLUDED.access_token,
|
||||||
refresh_token=EXCLUDED.refresh_token, access_expires_at=EXCLUDED.access_expires_at,
|
refresh_token=EXCLUDED.refresh_token, access_expires_at=EXCLUDED.access_expires_at,
|
||||||
refresh_expires_at=EXCLUDED.refresh_expires_at, updated_at=now(),
|
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_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''',
|
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,
|
(tokens['access_token'], tokens['refresh_token'], access_expires, refresh_expires,
|
||||||
operator, refreshed, refreshed))
|
operator, offline, refreshed, refreshed))
|
||||||
|
|
||||||
|
|
||||||
# Orders ------------------------------------------------------------------
|
# Orders ------------------------------------------------------------------
|
||||||
@@ -212,6 +291,9 @@ def order_payload(payload, contact_id, today=None):
|
|||||||
'valorFrete': money(freight['total_cents']),
|
'valorFrete': money(freight['total_cents']),
|
||||||
'observacoes': f"Pedido DTF #{payload['number']}" + (' · retirada em Franca' if pickup else ''),
|
'observacoes': f"Pedido DTF #{payload['number']}" + (' · retirada em Franca' if pickup else ''),
|
||||||
'observacoesInternas': f"Site DTF · pago · {payload['order_id']}"}
|
'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')
|
destination = order.get('destination')
|
||||||
if destination:
|
if destination:
|
||||||
pedido['enderecoEntrega'] = {**address(destination), 'enderecoNro': destination['number'],
|
pedido['enderecoEntrega'] = {**address(destination), 'enderecoNro': destination['number'],
|
||||||
@@ -223,6 +305,53 @@ def order_payload(payload, contact_id, today=None):
|
|||||||
return pedido
|
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:
|
class TinyOrders:
|
||||||
def __init__(self, auth=None, transport=None, today=None):
|
def __init__(self, auth=None, transport=None, today=None):
|
||||||
missing = [name for name in required_settings() if not os.environ.get(name)]
|
missing = [name for name in required_settings() if not os.environ.get(name)]
|
||||||
@@ -249,10 +378,10 @@ class TinyOrders:
|
|||||||
return entry['id']
|
return entry['id']
|
||||||
return self.request('POST', '/contatos', json=contact_payload(order))['id']
|
return self.request('POST', '/contatos', json=contact_payload(order))['id']
|
||||||
|
|
||||||
def find(self, payload):
|
def find(self, payload, days=SEARCH_DAYS):
|
||||||
"""An order a previous delivery already created, or None."""
|
"""An order a previous delivery already created, or None."""
|
||||||
wanted = purchase_order(payload['number'])
|
wanted = purchase_order(payload['number'])
|
||||||
since = ((self.today or local_today()) - timedelta(days=SEARCH_DAYS)).isoformat()
|
since = ((self.today or local_today()) - timedelta(days=days)).isoformat()
|
||||||
found = self.request('GET', '/pedidos', params={'cpfCnpj': payload['order']['customer']['cnpj'],
|
found = self.request('GET', '/pedidos', params={'cpfCnpj': payload['order']['customer']['cnpj'],
|
||||||
'dataInicial': since, 'limit': 100})
|
'dataInicial': since, 'limit': 100})
|
||||||
for entry in found.get('itens') or []:
|
for entry in found.get('itens') or []:
|
||||||
@@ -261,16 +390,55 @@ class TinyOrders:
|
|||||||
return detail
|
return detail
|
||||||
return None
|
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):
|
def deliver(self, event_key, payload):
|
||||||
if payload.get('event') != 'payment_approved':
|
event = payload.get('event')
|
||||||
# Production progress is not written to Tiny; only the sale is.
|
if event == 'payment_approved':
|
||||||
return {'provider': 'tiny', 'event_key': event_key, 'status': 'not-applicable',
|
return self.sale(event_key, payload)
|
||||||
'event': payload.get('event')}
|
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)
|
existing = self.find(payload)
|
||||||
if existing:
|
if existing:
|
||||||
return {'provider': 'tiny', 'event_key': event_key, 'status': 'already-created',
|
receipt = {'provider': 'tiny', 'event_key': event_key, 'status': 'already-created',
|
||||||
'tiny_id': str(existing.get('id')), 'tiny_number': str(existing.get('numeroPedido'))}
|
'tiny_id': str(existing.get('id')), 'tiny_number': str(existing.get('numeroPedido'))}
|
||||||
contact_id = self.contact(payload['order'])
|
tiny_id, situacao = existing.get('id'), existing.get('situacao')
|
||||||
created = self.request('POST', '/pedidos', json=order_payload(payload, contact_id, self.today))
|
else:
|
||||||
return {'provider': 'tiny', 'event_key': event_key, 'status': 'created',
|
contact_id = self.contact(payload['order'])
|
||||||
'tiny_id': str(created.get('id')), 'tiny_number': str(created.get('numeroPedido'))}
|
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
@@ -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())
|
||||||
@@ -36,7 +36,17 @@ x-app: &app
|
|||||||
MP_WEBHOOK_SECRET: ${MP_WEBHOOK_SECRET:-}
|
MP_WEBHOOK_SECRET: ${MP_WEBHOOK_SECRET:-}
|
||||||
MP_NOTIFICATION_URL: ${MP_NOTIFICATION_URL:-}
|
MP_NOTIFICATION_URL: ${MP_NOTIFICATION_URL:-}
|
||||||
MP_PUBLIC_KEY: ${MP_PUBLIC_KEY:-}
|
MP_PUBLIC_KEY: ${MP_PUBLIC_KEY:-}
|
||||||
FREIGHT_ADAPTER: fake
|
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_ADAPTER: ${TINY_ADAPTER:-fake}
|
||||||
TINY_CLIENT_ID: ${TINY_CLIENT_ID:-}
|
TINY_CLIENT_ID: ${TINY_CLIENT_ID:-}
|
||||||
TINY_CLIENT_SECRET: ${TINY_CLIENT_SECRET:-}
|
TINY_CLIENT_SECRET: ${TINY_CLIENT_SECRET:-}
|
||||||
@@ -54,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]
|
||||||
@@ -138,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}
|
||||||
@@ -187,6 +200,28 @@ 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: .
|
||||||
@@ -195,6 +230,7 @@ services:
|
|||||||
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.
|
# Empty unless testing Mercado Pago's card form; see docs/LOCAL_SETUP.md.
|
||||||
PAYMENT_CSP_SOURCES: ${PAYMENT_CSP_SOURCES:-}
|
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
|
||||||
@@ -219,6 +255,7 @@ services:
|
|||||||
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_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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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@ ${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}; 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;
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ POSTGRES_VOLUME=TBD
|
|||||||
OPERATOR_EMAIL=TBD
|
OPERATOR_EMAIL=TBD
|
||||||
|
|
||||||
PAYMENT_ADAPTER=TBD
|
PAYMENT_ADAPTER=TBD
|
||||||
|
# With PAYMENT_ADAPTER=mercadopago. MP_ACCESS_TOKEN and MP_WEBHOOK_SECRET are
|
||||||
|
# secrets: enter them in Portainer only, never in this file.
|
||||||
|
MP_NOTIFICATION_URL=https://<SITE_DOMAIN>/api/payments/webhook
|
||||||
|
MP_PUBLIC_KEY=
|
||||||
FREIGHT_ADAPTER=TBD
|
FREIGHT_ADAPTER=TBD
|
||||||
TINY_ADAPTER=TBD
|
TINY_ADAPTER=TBD
|
||||||
# Tiny API v3 application (Configurações > Geral > Aplicativos in Tiny). The
|
# Tiny API v3 application (Configurações > Geral > Aplicativos in Tiny). The
|
||||||
@@ -37,6 +41,9 @@ TINY_PRODUCT_TEXTIL_FOLHA=TBD
|
|||||||
TINY_PRODUCT_TEXTIL_AVULSA=TBD
|
TINY_PRODUCT_TEXTIL_AVULSA=TBD
|
||||||
TINY_PRODUCT_UV_FOLHA=TBD
|
TINY_PRODUCT_UV_FOLHA=TBD
|
||||||
TINY_PRODUCT_UV_AVULSA=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
|
||||||
@@ -44,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
|
||||||
|
|||||||
@@ -19,12 +19,41 @@ 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
|
# 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
|
# 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
|
# set now: they let an operator connect Tiny from the Kanban, and the worker
|
||||||
@@ -37,6 +66,13 @@ x-app-environment: &app-environment
|
|||||||
TINY_PRODUCT_TEXTIL_AVULSA: ${TINY_PRODUCT_TEXTIL_AVULSA:-}
|
TINY_PRODUCT_TEXTIL_AVULSA: ${TINY_PRODUCT_TEXTIL_AVULSA:-}
|
||||||
TINY_PRODUCT_UV_FOLHA: ${TINY_PRODUCT_UV_FOLHA:-}
|
TINY_PRODUCT_UV_FOLHA: ${TINY_PRODUCT_UV_FOLHA:-}
|
||||||
TINY_PRODUCT_UV_AVULSA: ${TINY_PRODUCT_UV_AVULSA:-}
|
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}
|
||||||
@@ -46,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:
|
||||||
@@ -95,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]
|
||||||
@@ -144,6 +183,34 @@ 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:
|
||||||
@@ -153,6 +220,10 @@ services:
|
|||||||
# Mercado Pago's card form loads from these origins; empty keeps the
|
# Mercado Pago's card form loads from these origins; empty keeps the
|
||||||
# Site at script-src 'self'. Set together with the Mercado Pago adapter.
|
# Site at script-src 'self'. Set together with the Mercado Pago adapter.
|
||||||
PAYMENT_CSP_SOURCES: ${PAYMENT_CSP_SOURCES:-}
|
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
|
||||||
@@ -176,6 +247,7 @@ services:
|
|||||||
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_CSP_SOURCES: ""
|
||||||
|
PAYMENT_CHALLENGE_SOURCES: ""
|
||||||
networks: [backend]
|
networks: [backend]
|
||||||
ports:
|
ports:
|
||||||
- target: 8080
|
- target: 8080
|
||||||
@@ -193,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
@@ -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.
|
||||||
@@ -62,25 +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. The text fixture is not an image, so
|
for every item, enter a review note, tick the confirmation and click
|
||||||
its print file shows **preparar à mão**; with a PNG or JPEG artwork the
|
**Aprovar arquivos finais**. Use the harmless fixture again only for this
|
||||||
generated PDF is preselected instead (see "Print files").
|
local test. The text fixture is not an image, so its print file shows
|
||||||
Continue through **Fila de impressão →
|
**Preparar à mão**; with a PNG, JPEG or PDF artwork the generated file is
|
||||||
Imprimindo → Finalizado**. A move to **Correção** requires a reason; it can
|
preselected instead (see "Print files"). Continue with the panel's main
|
||||||
return to **Arte recebida** or **Arte tratada**. Finalizado is terminal locally.
|
button through **Fila de impressão → Imprimindo → Finalizado**. **Pedir
|
||||||
8. Inspect **Histórico** and **Eventos locais de integração**. Worker receipts
|
correção** asks for the reason the customer will see; from **Correção** the
|
||||||
should say recorded locally. Download links expire after five minutes;
|
order returns to **Arte recebida** or **Arte tratada**. A move made by
|
||||||
request another link from Kanban when needed.
|
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.
|
||||||
@@ -318,8 +328,14 @@ PAYMENT_CSP_SOURCES=https://sdk.mercadopago.com https://*.mercadopago.com https:
|
|||||||
```
|
```
|
||||||
|
|
||||||
`PAYMENT_CSP_SOURCES` is empty by default, which keeps the Site at
|
`PAYMENT_CSP_SOURCES` is empty by default, which keeps the Site at
|
||||||
`script-src 'self'`. Once a payment for a quote is approved, or a card payment
|
`script-src 'self'`. The payment page offers credit card (the default), debit
|
||||||
is in review, the API refuses any further attempt for that quote. The order is created only by the signed
|
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
|
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
|
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
|
webhook, so a local run needs a public HTTPS tunnel to the Site port. A paid
|
||||||
|
|||||||
@@ -93,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**:
|
||||||
|
|||||||
212
docs/ROADMAP.md
@@ -7,16 +7,24 @@
|
|||||||
> 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-24, Week 2):** Client inputs for Mercado Pago and
|
**Current step (2026-09-25, last day of Week 2):** Still waiting on the client
|
||||||
freight were requested on 2026-09-24; Tiny access is already with the client.
|
for Mercado Pago credentials and webhook access (1.1) and freight data (1.2).
|
||||||
Built without them: server-side print-file generation (1.4), the delivery
|
Tiny is connected in production and reads orders, but the connected user
|
||||||
address (3.8), the Kanban's payment-issue and print-file views (1.5), and
|
cannot read contacts (403) and the client's catalogue has no per-metre UV
|
||||||
Mercado Pago and Tiny adapters written from the public API documentation and
|
product; both wait on the client (1.3). Built and deployed without them: server-side print-file
|
||||||
tested against fake transports (1.1, 1.3). None of the provider work is a
|
generation (1.4), the delivery address (3.8), the Kanban's payment-issue and
|
||||||
verified integration. The complete CI integration sequence passed locally on
|
print-file views and its redesign (1.5), the Site redesign (5.17), and Mercado
|
||||||
2026-09-24 (all API suites including the new `print_file_test`, adapter and
|
Pago and Tiny adapters written from the public API documentation and tested
|
||||||
generator unit suites, retention, runtime security, and both browser suites),
|
against fake transports. None of the provider work is a verified integration.
|
||||||
run with Docker Engine in WSL against a fresh build; pending a Gitea runner run.
|
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
|
**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.
|
||||||
@@ -239,9 +247,33 @@ From the report already sent. These are dated promises, not backlog.
|
|||||||
quote cannot be charged twice. The Site CSP gains the Mercado Pago origins
|
quote cannot be charged twice. The Site CSP gains the Mercado Pago origins
|
||||||
only through `PAYMENT_CSP_SOURCES`, empty by default. Not yet rendered
|
only through `PAYMENT_CSP_SOURCES`, empty by default. Not yet rendered
|
||||||
against a real public key; sandbox run and refund policy remain.
|
against a real public key; sandbox run and refund policy remain.
|
||||||
|
**Account setup (2026-09-28):** the client's application is Checkout
|
||||||
|
Transparente on the Payments API, webhook event "Pagamentos (legacy)" only,
|
||||||
|
URL `https://dtf.agenciacompor.com.br/api/payments/webhook`. The production
|
||||||
|
compose now takes `PAYMENT_ADAPTER` and `MP_*` from Portainer (it hard-coded
|
||||||
|
the fake adapter), so the sandbox runs on production with test credentials;
|
||||||
|
the Site has no real customers yet. A verified notification whose payment
|
||||||
|
does not exist (the panel's "Simular notificação") is acknowledged instead
|
||||||
|
of answering 500, which would have made Mercado Pago retry it.
|
||||||
- `[ ]` 1.2 — Real freight quotation. **Blocked on client inputs** (see
|
- `[ ]` 1.2 — Real freight quotation. **Blocked on client inputs** (see
|
||||||
`PRODUCTION_INPUTS.md`): source platform, credentials, origin CEP, services,
|
`PRODUCTION_INPUTS.md`): source platform, credentials, origin CEP, services,
|
||||||
packaging weight/dimensions per length, subsidy policy.
|
packaging weight/dimensions per length, subsidy policy.
|
||||||
|
**Received (2026-09-25):** Jadlog, API access (user/CNPJ, client code,
|
||||||
|
token, account), origin CEP 14402-310, service .PACKAGE (modalidade 3),
|
||||||
|
home delivery, billed by contract. Still missing: packaging weight and
|
||||||
|
dimensions per length and how freight is charged to the customer (asked
|
||||||
|
2026-09-28). `app/jadlog.py` prices one package from the manual (v2.3);
|
||||||
|
`python -m app.jadlog_probe` prices test weights to six regions, read-only,
|
||||||
|
to confirm token, account and contract on the client's account. Not yet run.
|
||||||
|
**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.
|
- `[~]` 1.3 — Idempotent Tiny/Olist order creation with order-number traceability.
|
||||||
Confirm endpoints, tag behaviour and rate limits first.
|
Confirm endpoints, tag behaviour and rate limits first.
|
||||||
**Groundwork (2026-09-24), API v3 by decision:** OAuth2 against Tiny's
|
**Groundwork (2026-09-24), API v3 by decision:** OAuth2 against Tiny's
|
||||||
@@ -261,6 +293,69 @@ From the report already sent. These are dated promises, not backlog.
|
|||||||
real orders. Still to confirm on the client's account: plan (Construa+),
|
real orders. Still to confirm on the client's account: plan (Construa+),
|
||||||
product ids, token lifetimes, whether pickup needs a transportador, and
|
product ids, token lifetimes, whether pickup needs a transportador, and
|
||||||
rate limits.
|
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
|
- `[~]` 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).
|
||||||
**Built (2026-09-24):** each paid item gets a PDF the width of the film and
|
**Built (2026-09-24):** each paid item gets a PDF the width of the film and
|
||||||
@@ -287,6 +382,17 @@ download, approve as final, queue; hand-preparation routing and retry).
|
|||||||
print-file status per item, and a panel lists payments that need a person
|
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
|
(money without an order, refunds after an order) until resolved. Confirm
|
||||||
with the operation that these are the main states before closing.
|
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'`)
|
||||||
@@ -482,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
|
||||||
@@ -509,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
|
||||||
@@ -520,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
|
||||||
@@ -710,9 +852,31 @@ print-file evidence still need correction before this item can close.
|
|||||||
attempt counts against the source address and only failures count against
|
attempt counts against the source address and only failures count against
|
||||||
the account; registration still counts every attempt. The security suite's
|
the account; registration still counts every attempt. The security suite's
|
||||||
lockout check (ten failures, then 429) is unchanged and passes.
|
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.
|
||||||
|
**2026-09-29:** database part built. The `backup` service dumps daily,
|
||||||
|
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
|
- `[ ]` 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
|
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,
|
release gate; the source preflight is advisory unless enforced by variable,
|
||||||
@@ -831,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.
|
||||||
|
|||||||
BIN
docs/guia-operador-kanban.pdf
Normal file
BIN
docs/guia-site-dtf.pdf
Normal file
8
docs/guias/imprimir.sh
Executable file
@@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Print the guide sources in docs/guias/ to the PDFs in docs/ with headless
|
||||||
|
# Chrome (A4, no browser header or footer). Needs Chrome and the DejaVu fonts.
|
||||||
|
set -eu
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
CHROME=${CHROME:-$(command -v google-chrome-stable || command -v google-chrome || command -v chromium)}
|
||||||
|
"$CHROME" --headless --disable-gpu --no-pdf-header-footer --run-all-compositor-stages-before-draw \
|
||||||
|
--print-to-pdf="$PWD/../guia-operador-kanban.pdf" "file://$PWD/operador/guia-operador-kanban.html"
|
||||||
320
docs/guias/operador/guia-operador-kanban.html
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<!-- Source of docs/guia-operador-kanban.pdf. Print with docs/guias/imprimir.sh.
|
||||||
|
Screenshots in img/ come from a throwaway stack with fictional orders. -->
|
||||||
|
<html lang="pt-BR">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Kanban DTF · Guia do operador</title>
|
||||||
|
<style>
|
||||||
|
@page { size: A4; margin: 0; }
|
||||||
|
:root {
|
||||||
|
--navy: #0B1320; --orange: #F08A24; --ink: #1B2331; --muted: #6B7280;
|
||||||
|
--line: #D6DCE4; --head: #EAF0F8; --note: #FFF5E8; --note-line: #F4C58E;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; padding: 0; }
|
||||||
|
body { font-family: 'DejaVu Sans', sans-serif; color: var(--ink); font-size: 9.7pt; line-height: 1.55;
|
||||||
|
-webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
.page { width: 210mm; height: 297mm; position: relative; padding: 26mm 18mm 20mm; overflow: hidden;
|
||||||
|
page-break-after: always; break-after: page; }
|
||||||
|
.page:last-child { page-break-after: auto; break-after: auto; }
|
||||||
|
.running { position: absolute; top: 9mm; left: 18mm; right: 18mm; font-size: 7.4pt; color: var(--muted);
|
||||||
|
border-bottom: 0.6pt solid #E3E7ED; padding-bottom: 2.4mm; }
|
||||||
|
.folio { position: absolute; bottom: 9mm; right: 18mm; font-size: 7.4pt; color: var(--muted); }
|
||||||
|
.kicker { color: var(--orange); font-weight: bold; font-size: 7.6pt; letter-spacing: 0.04em; margin: 0 0 1mm; }
|
||||||
|
h1 { font-size: 20.5pt; line-height: 1.2; margin: 0 0 2mm; color: #101826; }
|
||||||
|
h1::after { content: ""; display: block; width: 18mm; height: 1.4mm; background: var(--orange); margin-top: 3mm; }
|
||||||
|
h2 { font-size: 11.5pt; margin: 7mm 0 2.5mm; color: #101826; }
|
||||||
|
p { margin: 0 0 2.6mm; }
|
||||||
|
b { font-weight: bold; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin: 2mm 0 3mm; font-size: 8.6pt; line-height: 1.45; }
|
||||||
|
th, td { border: 0.6pt solid var(--line); padding: 2mm 2.4mm; vertical-align: top; text-align: left; }
|
||||||
|
th { background: var(--head); font-weight: bold; }
|
||||||
|
td.n { color: var(--orange); font-weight: bold; font-size: 11pt; width: 7mm; text-align: center; }
|
||||||
|
td.k { font-weight: bold; }
|
||||||
|
.note { background: var(--note); border: 0.6pt solid var(--note-line); border-radius: 2mm; padding: 3mm 3.6mm;
|
||||||
|
font-size: 8.6pt; margin: 3mm 0; }
|
||||||
|
ul { margin: 0 0 3mm; padding-left: 4.5mm; }
|
||||||
|
ul li { margin-bottom: 1.1mm; }
|
||||||
|
ul li::marker { color: var(--orange); }
|
||||||
|
ol { margin: 0 0 3mm; padding-left: 5mm; }
|
||||||
|
ol li { margin-bottom: 1.3mm; }
|
||||||
|
ol li::marker { color: var(--orange); font-weight: bold; }
|
||||||
|
.chip { display: inline-block; border: 0.6pt solid #C9D0DA; background: #F4F6F9; border-radius: 1.2mm;
|
||||||
|
padding: 0 1.6mm; font-size: 7.6pt; line-height: 1.6; white-space: nowrap; }
|
||||||
|
.shot { display: block; width: 100%; border-radius: 1.6mm; margin: 2mm 0 1mm; }
|
||||||
|
.caption { font-size: 7.2pt; color: var(--muted); margin: 0 0 3mm; }
|
||||||
|
.split { display: flex; gap: 6mm; align-items: flex-start; }
|
||||||
|
.split > div { flex: 1; }
|
||||||
|
.split > .side { flex: 0 0 79.5mm; }
|
||||||
|
.quote { font-style: normal; }
|
||||||
|
|
||||||
|
/* Cover */
|
||||||
|
.cover { background: var(--navy); color: #F3F5F8; padding: 0 18mm; }
|
||||||
|
.cover .block { position: absolute; left: 18mm; right: 18mm; top: 158mm; }
|
||||||
|
.cover .kicker { margin-bottom: 4mm; }
|
||||||
|
.cover .title { font-size: 27pt; font-weight: bold; line-height: 1.25; margin: 0 0 4mm; color: #fff; }
|
||||||
|
.cover .lead { font-size: 9.6pt; color: #C8CFDA; margin: 0 0 8mm; }
|
||||||
|
.cover table { font-size: 8pt; margin: 0 0 8mm; }
|
||||||
|
.cover th, .cover td { border-color: #2A3547; background: transparent; color: #E6EAF0; padding: 3mm 3.6mm; }
|
||||||
|
.cover th { color: var(--orange); }
|
||||||
|
.cover .version { font-size: 8pt; color: #A9B2C0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- 1 · Capa -->
|
||||||
|
<section class="page cover">
|
||||||
|
<div class="block">
|
||||||
|
<p class="kicker">GUIA DE OPERAÇÃO</p>
|
||||||
|
<p class="title">Kanban DTF<br>Guia do operador</p>
|
||||||
|
<p class="lead">Como conferir, produzir e entregar os pedidos que chegam pelo Site DTF.</p>
|
||||||
|
<table>
|
||||||
|
<tr><th style="width:50%">Acesso</th><th>Quem usa</th></tr>
|
||||||
|
<tr><td>dtf.kanban.agenciacompor.com.br<br>e-mail e senha do operador</td>
|
||||||
|
<td>Equipe da sala de DTF e atendimento</td></tr>
|
||||||
|
</table>
|
||||||
|
<p class="version">Versão de 25 de setembro de 2026</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 2 · Visão geral -->
|
||||||
|
<section class="page">
|
||||||
|
<div class="running">DTF Dropstar - guia do operador</div>
|
||||||
|
<p class="kicker">VISÃO GERAL</p>
|
||||||
|
<h1>O caminho de um pedido</h1>
|
||||||
|
<p>O cliente monta a folha e envia as artes pelo Site DTF. Daí em diante, tudo acontece no Kanban: a equipe
|
||||||
|
confere a cotação, o cliente paga, e o pedido percorre as etapas de produção até ficar pronto para retirada.</p>
|
||||||
|
<table>
|
||||||
|
<tr><td class="n">1</td><td class="k" style="width:31mm">Cliente envia</td>
|
||||||
|
<td>Monta a folha no Site, vê a nota e o preço e envia o pedido para conferência.</td></tr>
|
||||||
|
<tr><td class="n">2</td><td class="k">Equipe confere a cotação</td>
|
||||||
|
<td>Na aba <b>Cotações</b>: confere arquivos, metragem e nota e aprova.</td></tr>
|
||||||
|
<tr><td class="n">3</td><td class="k">Cliente paga</td>
|
||||||
|
<td>O total aprovado aparece no Site. O pagamento é pelo Mercado Pago (PIX ou cartão).</td></tr>
|
||||||
|
<tr><td class="n">4</td><td class="k">Pedido entra na produção</td>
|
||||||
|
<td>Com o pagamento aprovado, o pedido aparece em <b>Arte recebida</b>, com o PDF de impressão já gerado.</td></tr>
|
||||||
|
<tr><td class="n">5</td><td class="k">Equipe produz</td>
|
||||||
|
<td>Aprova o arquivo final, baixa, importa no FlexiPRINT e move o card a cada etapa.</td></tr>
|
||||||
|
<tr><td class="n">6</td><td class="k">Pedido pronto</td>
|
||||||
|
<td>Em <b>Finalizado</b>, o pedido fica pronto para retirada e o cliente é avisado (página 7).</td></tr>
|
||||||
|
</table>
|
||||||
|
<h2>As quatro abas</h2>
|
||||||
|
<table>
|
||||||
|
<tr><th style="width:31mm">Aba</th><th>Para que serve</th></tr>
|
||||||
|
<tr><td class="k">Produção</td><td>O quadro com os pedidos pagos, uma coluna por etapa.</td></tr>
|
||||||
|
<tr><td class="k">Cotações</td><td>Pedidos enviados pelo Site esperando conferência. O número ao lado indica quantos faltam revisar.</td></tr>
|
||||||
|
<tr><td class="k">Pagamentos</td><td>Pagamentos que o sistema não conseguiu ligar a um pedido. Normalmente fica vazia.</td></tr>
|
||||||
|
<tr><td class="k">Integrações</td><td>Situação do Tiny, Mercado Pago, frete e WhatsApp, e o registro de tudo o que foi enviado a eles.</td></tr>
|
||||||
|
</table>
|
||||||
|
<div class="note">O quadro não se atualiza sozinho. Clique em <b>Atualizar</b>, no canto superior direito, para ver pedidos e cotações novos.</div>
|
||||||
|
<div class="folio">Página 2</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 3 · Entrar e ler o quadro -->
|
||||||
|
<section class="page">
|
||||||
|
<div class="running">DTF Dropstar - guia do operador</div>
|
||||||
|
<p class="kicker">PRODUÇÃO</p>
|
||||||
|
<h1>Entrar e ler o quadro</h1>
|
||||||
|
<div class="split">
|
||||||
|
<div>
|
||||||
|
<p>Acesse <b>dtf.kanban.agenciacompor.com.br</b> e entre com seu e-mail e senha. Cada operador tem o próprio
|
||||||
|
acesso: é o nome dele que fica registrado em cada movimento do pedido.</p>
|
||||||
|
<p>Depois de várias senhas erradas seguidas, o acesso fica bloqueado por 15 minutos.</p>
|
||||||
|
</div>
|
||||||
|
<div class="side"><img class="shot" src="img/login.png" alt="Tela de entrada do Kanban"></div>
|
||||||
|
</div>
|
||||||
|
<img class="shot" src="img/quadro.png" alt="Quadro de produção">
|
||||||
|
<p class="caption">Quadro de produção com três pedidos em etapas diferentes.</p>
|
||||||
|
<h2>O que cada card mostra</h2>
|
||||||
|
<ul>
|
||||||
|
<li><b>#número</b> do pedido e há quanto tempo ele está parado na etapa.</li>
|
||||||
|
<li>E-mail do cliente, produto e metragem cobrada (ex.: <span class="chip">Têxtil avulsa · 1,00 m</span>).</li>
|
||||||
|
<li>Situação do arquivo: <span class="chip">PDF pronto</span> (gerado, falta aprovar), <span class="chip">Final aprovado</span>
|
||||||
|
(pode ir para a fila) ou <span class="chip">Preparar à mão</span> (o sistema não conseguiu gerar; veja a página 6).</li>
|
||||||
|
<li><span class="chip">Retirada</span> ou <span class="chip">Entrega · UF</span>.</li>
|
||||||
|
<li>Um aviso <b>Parado há mais de 4 h</b> aparece quando o pedido não anda.</li>
|
||||||
|
</ul>
|
||||||
|
<p>Os filtros <b>Têxtil</b>, <b>UV</b> e <b>Preparar à mão</b> mostram só esses pedidos. A busca encontra pedido, cliente ou CNPJ.</p>
|
||||||
|
<div class="folio">Página 3</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 4 · Cotações -->
|
||||||
|
<section class="page">
|
||||||
|
<div class="running">DTF Dropstar - guia do operador</div>
|
||||||
|
<p class="kicker">COTAÇÕES</p>
|
||||||
|
<h1>Conferir e aprovar uma cotação</h1>
|
||||||
|
<img class="shot" src="img/cotacoes.png" alt="Aba Cotações">
|
||||||
|
<p class="caption">À esquerda, as cotações a revisar; à direita, a cotação selecionada.</p>
|
||||||
|
<ol>
|
||||||
|
<li>Abra a aba <b>Cotações</b> e clique na cotação em <b>A revisar</b>.</li>
|
||||||
|
<li>Confira a montagem na miniatura. Se precisar, abra os arquivos em <b>Original</b> ou a lista de peças em <b>Manifesto</b>.</li>
|
||||||
|
<li>Confira <b>Metros conferidos</b> e <b>Nota conferida</b>. Os valores do cliente já vêm preenchidos; mude só se
|
||||||
|
estiverem errados. A nota define o preço do metro.</li>
|
||||||
|
<li>Marque <b>Arquivos, metragem e nota conferidos</b> e clique em <b>Aprovar cotação</b>.</li>
|
||||||
|
</ol>
|
||||||
|
<p>O cliente vê o total aprovado no Site e tem <b>24 horas</b> para pagar. As aprovadas ficam em
|
||||||
|
<b>Aprovadas, aguardando pagamento</b> até o pagamento chegar.</p>
|
||||||
|
<div class="note"><b>Ressalva de resolução aceita pelo cliente</b>: alguma arte tem menos de 300 DPI e o cliente confirmou
|
||||||
|
que quer imprimir assim. <b>Montagem antiga</b>: a cotação foi feita numa versão anterior do Site; peça ao cliente
|
||||||
|
para enviar de novo.</div>
|
||||||
|
<div class="folio">Página 4</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 5 · Abrir um pedido -->
|
||||||
|
<section class="page">
|
||||||
|
<div class="running">DTF Dropstar - guia do operador</div>
|
||||||
|
<p class="kicker">PRODUÇÃO</p>
|
||||||
|
<h1>Abrir um pedido</h1>
|
||||||
|
<img class="shot" src="img/painel.png" alt="Painel do pedido">
|
||||||
|
<p class="caption">Clique em qualquer card para abrir o painel do pedido.</p>
|
||||||
|
<table>
|
||||||
|
<tr><th style="width:36mm">Parte do painel</th><th>O que tem</th></tr>
|
||||||
|
<tr><td class="k">Topo</td><td>Número, etapa atual, data do pagamento e os botões para mover o pedido.</td></tr>
|
||||||
|
<tr><td class="k">Cliente e Entrega</td><td>E-mail, CNPJ e WhatsApp do cliente; endereço ou <b>Retirada em Franca</b>.</td></tr>
|
||||||
|
<tr><td class="k">Itens e arquivos de impressão</td><td>Montagem de cada item, peças, tamanho da folha e nota. <b>Baixar PDF</b>
|
||||||
|
baixa o arquivo pronto para o FlexiPRINT; <b>Original</b> baixa o arquivo que o cliente enviou; <b>Manifesto</b>
|
||||||
|
lista as peças e suas posições.</td></tr>
|
||||||
|
<tr><td class="k">Arquivos finais</td><td>O arquivo que vai ser impresso, aprovado por um operador (próxima página).</td></tr>
|
||||||
|
<tr><td class="k">Histórico</td><td>Cada movimento do pedido, com hora, operador e motivo.</td></tr>
|
||||||
|
</table>
|
||||||
|
<div class="folio">Página 5</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 6 · Arquivo final -->
|
||||||
|
<section class="page">
|
||||||
|
<div class="running">DTF Dropstar - guia do operador</div>
|
||||||
|
<p class="kicker">PRODUÇÃO</p>
|
||||||
|
<h1>Aprovar o arquivo final</h1>
|
||||||
|
<div class="split">
|
||||||
|
<div>
|
||||||
|
<p>Antes de ir para a <b>Fila de impressão</b>, todo item precisa de um arquivo final aprovado. O sistema já gera
|
||||||
|
um PDF com a montagem que o cliente viu e pagou.</p>
|
||||||
|
<ol>
|
||||||
|
<li>Baixe o PDF em <b>Baixar PDF</b> e confira.</li>
|
||||||
|
<li>Deixe marcado <b>Usar o PDF gerado</b>. Se preferir usar outro arquivo, desmarque e envie o seu.</li>
|
||||||
|
<li>Escreva uma <b>Nota da revisão</b> (ex.: “Conferido, pronto para imprimir”).</li>
|
||||||
|
<li>Marque <b>Arquivos conferidos</b> e clique em <b>Aprovar arquivos finais</b>.</li>
|
||||||
|
</ol>
|
||||||
|
<p>O card passa a mostrar <span class="chip">Final aprovado</span>.</p>
|
||||||
|
</div>
|
||||||
|
<div class="side"><img class="shot" src="img/arquivo-final.png" alt="Aprovação do arquivo final"></div>
|
||||||
|
</div>
|
||||||
|
<h2>Quando o PDF não é gerado</h2>
|
||||||
|
<table>
|
||||||
|
<tr><th style="width:40mm">No card ou no item</th><th>O que fazer</th></tr>
|
||||||
|
<tr><td><span class="chip">Na fila para gerar</span></td><td>Aguarde alguns segundos e clique em <b>Atualizar</b>.</td></tr>
|
||||||
|
<tr><td><span class="chip">Preparar à mão</span></td><td>O cliente enviou um formato que o gerador não lê (CDR, AI, PSD, TIFF)
|
||||||
|
ou pediu correção. Baixe o <b>Original</b>, prepare o arquivo no seu programa e envie-o em <b>Arquivos finais</b>.</td></tr>
|
||||||
|
<tr><td><span class="chip">Falhou ao gerar</span></td><td>Clique em <b>Gerar novamente</b>. Se falhar de novo, prepare à mão.</td></tr>
|
||||||
|
</table>
|
||||||
|
<div class="folio">Página 6</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 7 · Mover pelas etapas -->
|
||||||
|
<section class="page">
|
||||||
|
<div class="running">DTF Dropstar - guia do operador</div>
|
||||||
|
<p class="kicker">PRODUÇÃO</p>
|
||||||
|
<h1>Mover o pedido pelas etapas</h1>
|
||||||
|
<p>Use o botão laranja <b>Mover para…</b> no topo do painel ou arraste o card para a coluna seguinte. Ao arrastar,
|
||||||
|
só as colunas permitidas ficam destacadas.</p>
|
||||||
|
<table>
|
||||||
|
<tr><th style="width:27mm">Etapa</th><th>Significa</th><th style="width:43mm">Aviso ao cliente</th></tr>
|
||||||
|
<tr><td class="k">Arte recebida</td><td>Pedido pago, arquivo gerado. Ainda não conferido.</td><td>Pedido aprovado (automático)</td></tr>
|
||||||
|
<tr><td class="k">Arte tratada</td><td>Arquivo conferido e ajustado, se preciso.</td><td>—</td></tr>
|
||||||
|
<tr><td class="k">Fila de impressão</td><td>Pronto para imprimir. Exige o arquivo final aprovado.</td><td>—</td></tr>
|
||||||
|
<tr><td class="k">Imprimindo</td><td>Na máquina.</td><td>—</td></tr>
|
||||||
|
<tr><td class="k">Finalizado</td><td>Impresso e pronto para retirada ou envio.</td><td>Pedido pronto para retirada</td></tr>
|
||||||
|
<tr><td class="k">Correção</td><td>Algo no arquivo precisa ser resolvido pelo cliente.</td><td>O motivo aparece em Minha conta, no Site (página 8)</td></tr>
|
||||||
|
</table>
|
||||||
|
<div class="note">Os avisos por WhatsApp saem pelo número e pelas mensagens que a Dropstar já usa, a partir da situação
|
||||||
|
do pedido no Tiny: o pedido pago fica <b>Aprovado</b> e, em <b>Finalizado</b>, o pedido de retirada fica
|
||||||
|
<b>Pronto para envio</b>. Cada aviso sai <b>uma vez só</b>, mesmo que o pedido volte uma etapa e avance de novo.
|
||||||
|
As outras etapas não mudam o pedido no Tiny. Enquanto essa integração não estiver ativa, avise o cliente como hoje.</div>
|
||||||
|
<h2>Moveu errado? Volte uma etapa</h2>
|
||||||
|
<img class="shot" src="img/voltar-etapa.png" alt="Botão Voltar para">
|
||||||
|
<p class="caption">Botão <b>Voltar para…</b>: pede um motivo interno e não avisa o cliente.</p>
|
||||||
|
<p>Clique em <b>Voltar para [etapa anterior]</b>, escreva o motivo (ex.: “movi por engano”) e confirme. O retorno fica
|
||||||
|
no histórico como <b>Voltou para…</b>. O cliente não vê esse retorno nem o motivo.</p>
|
||||||
|
<div class="folio">Página 7</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 8 · Correção -->
|
||||||
|
<section class="page">
|
||||||
|
<div class="running">DTF Dropstar - guia do operador</div>
|
||||||
|
<p class="kicker">PRODUÇÃO</p>
|
||||||
|
<h1>Pedir correção ao cliente</h1>
|
||||||
|
<img class="shot" src="img/pedir-correcao.png" alt="Botão Pedir correção" style="width:78%">
|
||||||
|
<p class="caption">Botão <b>Pedir correção</b>: o motivo vai para o cliente.</p>
|
||||||
|
<ol>
|
||||||
|
<li>No painel do pedido, clique em <b>Pedir correção</b>.</li>
|
||||||
|
<li>Escreva o motivo de forma clara para o cliente (ex.: “A arte escudo.png está com fundo branco. Envie com fundo transparente.”).</li>
|
||||||
|
<li>Clique em <b>Enviar para correção</b>. O pedido vai para a coluna <b>Correção</b>.</li>
|
||||||
|
</ol>
|
||||||
|
<p>O cliente vê o motivo em <b>Minha conta</b>, no Site, e envia por lá o arquivo corrigido. Esse aviso ainda não sai
|
||||||
|
pelo WhatsApp: avise o cliente de que há uma correção pendente. O arquivo corrigido aparece em <b>Arquivos finais</b>
|
||||||
|
como <b>Correção do cliente</b>. Confira, mova o pedido para <b>Arte recebida</b> ou <b>Arte tratada</b> e siga o fluxo normal.</p>
|
||||||
|
<div class="note">Correção é para problemas que só o cliente resolve (resolução, fundo, arte errada). Ajustes que a equipe
|
||||||
|
faz sozinha não precisam de correção: trate o arquivo em <b>Arte tratada</b>.</div>
|
||||||
|
<h2>Histórico</h2>
|
||||||
|
<img class="shot" src="img/historico.png" alt="Histórico do pedido" style="width:78%">
|
||||||
|
<p class="caption">Cada movimento fica registrado com data, hora e operador.</p>
|
||||||
|
<div class="folio">Página 8</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 9 · Pagamentos e integrações -->
|
||||||
|
<section class="page">
|
||||||
|
<div class="running">DTF Dropstar - guia do operador</div>
|
||||||
|
<p class="kicker">PAGAMENTOS E INTEGRAÇÕES</p>
|
||||||
|
<h1>Pagamentos com problema e integrações</h1>
|
||||||
|
<p>A aba <b>Pagamentos</b> lista os pagamentos que chegaram mas não puderam virar pedido sozinhos. Exemplos:</p>
|
||||||
|
<ul>
|
||||||
|
<li>valor pago diferente do total da cotação;</li>
|
||||||
|
<li>cotação vencida antes do pagamento (passou das 24 h);</li>
|
||||||
|
<li>pagamento estornado ou cancelado depois que o pedido já existia.</li>
|
||||||
|
</ul>
|
||||||
|
<p>Resolva com o cliente ou no Mercado Pago e clique em <b>Registrar resolução</b>, descrevendo o que foi feito.
|
||||||
|
O item passa para <b>Resolvidos</b>.</p>
|
||||||
|
<img class="shot" src="img/pagamentos.png" alt="Aba Pagamentos">
|
||||||
|
<h2>Integrações</h2>
|
||||||
|
<img class="shot" src="img/integracoes.png" alt="Aba Integrações">
|
||||||
|
<p class="caption">Imagem de um ambiente sem integrações ativas. Em produção, cada cartão mostra se a integração está conectada.</p>
|
||||||
|
<ul>
|
||||||
|
<li><b>Tiny</b>: o cartão mostra quem conectou, quando a conexão foi renovada e até quando vale; o sistema renova
|
||||||
|
sozinho. Se aparecer <b>Não conectado</b> ou <b>Verificar</b>, clique em <b>Conectar Tiny</b> ou <b>Reconectar</b> e
|
||||||
|
entre com o usuário do Tiny indicado pela Dropstar. <b>Testar conexão</b> confere pedidos, contatos, os produtos
|
||||||
|
e a forma de envio de retirada, sem criar nada.</li>
|
||||||
|
<li><b>Registro de envios</b>: tudo o que o sistema mandou ao Tiny e ao WhatsApp, com filtros por destino, situação,
|
||||||
|
evento e pedido. <b>Com erro</b> indica um envio que falhou; o sistema tenta de novo sozinho, mas o erro precisa de atenção.</li>
|
||||||
|
</ul>
|
||||||
|
<div class="folio">Página 9</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 10 · Dúvidas -->
|
||||||
|
<section class="page">
|
||||||
|
<div class="running">DTF Dropstar - guia do operador</div>
|
||||||
|
<p class="kicker">DÚVIDAS FREQUENTES</p>
|
||||||
|
<h1>Mensagens e o que fazer</h1>
|
||||||
|
<table>
|
||||||
|
<tr><th style="width:62mm">Mensagem ou situação</th><th>O que fazer</th></tr>
|
||||||
|
<tr><td>“O pedido mudou. Clique em Atualizar.”</td><td>Outra pessoa mexeu no pedido ao mesmo tempo. Clique em <b>Atualizar</b> e repita a ação.</td></tr>
|
||||||
|
<tr><td>“Aprove os arquivos finais de todos os itens antes de colocar na fila.”</td><td>Aprove o arquivo final de cada item (página 6) e tente de novo.</td></tr>
|
||||||
|
<tr><td>“Informe o motivo da correção.” / “Informe por que o pedido está voltando de etapa.”</td><td>Preencha o motivo antes de confirmar.</td></tr>
|
||||||
|
<tr><td>Cotação com <b>Montagem antiga</b></td><td>Peça ao cliente para enviar o pedido de novo pelo Site.</td></tr>
|
||||||
|
<tr><td>Card com <b>Parado há mais de 4 h</b></td><td>Veja se o pedido está esperando alguém ou se esqueceram de movê-lo.</td></tr>
|
||||||
|
<tr><td>Arquivo aparece como <b>expirado</b></td><td>Os arquivos ficam guardados por 30 dias. Depois disso, o cliente precisa enviar de novo.</td></tr>
|
||||||
|
<tr><td>No topo, <b>Tiny: verificar conexão</b> ou <b>Tiny não conectado</b></td><td>Abra <b>Integrações</b> e siga a mensagem do cartão do Tiny. Se pedir, clique em <b>Reconectar</b>.</td></tr>
|
||||||
|
<tr><td>A internet da fábrica caiu</td><td>O Site continua recebendo pedidos e pagamentos. Quando a conexão voltar, clique em <b>Atualizar</b>.</td></tr>
|
||||||
|
</table>
|
||||||
|
<h2>Boas práticas</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Mova o card assim que a etapa mudar: é o que avisa o cliente e mede o tempo de produção.</li>
|
||||||
|
<li>Escreva motivos que outra pessoa entenda sem perguntar.</li>
|
||||||
|
<li>Use sempre o seu acesso: o histórico mostra quem fez cada movimento.</li>
|
||||||
|
<li>Clique em <b>Sair</b> ao deixar o computador.</li>
|
||||||
|
</ul>
|
||||||
|
<div class="folio">Página 10</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
docs/guias/operador/img/arquivo-final.png
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
docs/guias/operador/img/cotacoes.png
Normal file
|
After Width: | Height: | Size: 191 KiB |
BIN
docs/guias/operador/img/historico.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
docs/guias/operador/img/integracoes.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
docs/guias/operador/img/login.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
docs/guias/operador/img/pagamentos.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
docs/guias/operador/img/painel.png
Normal file
|
After Width: | Height: | Size: 288 KiB |
BIN
docs/guias/operador/img/pedir-correcao.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
docs/guias/operador/img/quadro.png
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
docs/guias/operador/img/voltar-etapa.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
@@ -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/
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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@ ${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}; 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 {
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
@@ -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
@@ -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()
|
||||||
@@ -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;}
|
||||||
|
|||||||
@@ -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']
|
||||||
|
|
||||||
|
|
||||||
@@ -61,7 +61,22 @@ def run():
|
|||||||
# Starting a PIX twice returns the same one. A card in review blocks every
|
# Starting a PIX twice returns the same one. A card in review blocks every
|
||||||
# further attempt, so one quote can never be charged twice.
|
# further attempt, so one quote can never be charged twice.
|
||||||
pix = customer.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'pix'}})
|
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']
|
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)
|
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}
|
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})
|
||||||
@@ -109,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)}'
|
||||||
@@ -125,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()
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from uuid import uuid4
|
|||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from tests.payment_test import deliver
|
from tests.payment_test import deliver
|
||||||
from tests.smoke_test import Client, upload_bytes
|
from tests.smoke_test import Client, approved_quote as approval, upload_bytes
|
||||||
|
|
||||||
PT_PER_CM = 72 / 2.54
|
PT_PER_CM = 72 / 2.54
|
||||||
CUSTOMER = {'cnpj': '11222333000181', 'zap': '11999999999', 'mail': 'print-test@example.test'}
|
CUSTOMER = {'cnpj': '11222333000181', 'zap': '11999999999', 'mail': 'print-test@example.test'}
|
||||||
@@ -53,7 +53,7 @@ def loose_item(uid, copies=2):
|
|||||||
def approved_quote(client, item):
|
def approved_quote(client, item):
|
||||||
quote = client.call('/quotes', {'request_key': str(uuid4()), 'customer': CUSTOMER,
|
quote = client.call('/quotes', {'request_key': str(uuid4()), 'customer': CUSTOMER,
|
||||||
'items': [item], 'freight': {'service': 'pickup'}})
|
'items': [item], 'freight': {'service': 'pickup'}})
|
||||||
approved = client.call('/operator/quotes/' + quote['id'] + '/approve', {'items': [item]}, operator=True)
|
approved = approval(client, quote, [item])
|
||||||
return quote['id'], approved['total_cents']
|
return quote['id'], approved['total_cents']
|
||||||
|
|
||||||
|
|
||||||
@@ -126,7 +126,7 @@ def run():
|
|||||||
manual = upload_bytes(client, b'LOCAL PRINT TEST - NOT AN IMAGE', name='LOCAL-PRINT-TEST.cdr')
|
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 = paid_order(client, loose_item(manual, copies=1))
|
||||||
order, row = wait_print(client, order['id'], 'manual')
|
order, row = wait_print(client, order['id'], 'manual')
|
||||||
assert 'not an image' in row['detail']['reason'], row
|
assert 'não é uma imagem' in row['detail']['reason'], row
|
||||||
rows = client.call('/operator/orders/' + order['id'] + '/print-files', {}, operator=True)
|
rows = client.call('/operator/orders/' + order['id'] + '/print-files', {}, operator=True)
|
||||||
assert rows[0]['status'] == 'pending'
|
assert rows[0]['status'] == 'pending'
|
||||||
wait_print(client, order['id'], 'manual')
|
wait_print(client, order['id'], 'manual')
|
||||||
@@ -141,17 +141,43 @@ def run():
|
|||||||
outcome = deliver({'event_id': event_id, 'reference': quote_id,
|
outcome = deliver({'event_id': event_id, 'reference': quote_id,
|
||||||
'status': 'approved', 'amount_cents': total - 1})
|
'status': 'approved', 'amount_cents': total - 1})
|
||||||
assert outcome['outcome'].startswith('refused'), outcome
|
assert outcome['outcome'].startswith('refused'), outcome
|
||||||
issues = client.call('/operator/board', operator=True)['payment_issues']
|
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)
|
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'},
|
client.call('/operator/payment-events/' + issue['id'] + '/resolve', {'note': 'no'},
|
||||||
operator=True, expected=422)
|
operator=True, expected=422)
|
||||||
client.call('/operator/payment-events/' + issue['id'] + '/resolve',
|
client.call('/operator/payment-events/' + issue['id'] + '/resolve',
|
||||||
{'note': 'Local test: refunded the underpayment'}, operator=True)
|
{'note': 'Local test: refunded the underpayment'}, operator=True)
|
||||||
client.call('/operator/payment-events/' + issue['id'] + '/resolve',
|
client.call('/operator/payment-events/' + issue['id'] + '/resolve',
|
||||||
{'note': 'Local test: second resolution'}, operator=True, expected=404)
|
{'note': 'Local test: second resolution'}, operator=True, expected=404)
|
||||||
issues = client.call('/operator/board', operator=True)['payment_issues']
|
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)
|
assert not any(i['event_id'] == event_id for i in issues)
|
||||||
print('PASS: refused paid notification is listed until an operator resolves it')
|
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__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'{}')
|
||||||
|
|||||||
@@ -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,7 +134,8 @@ 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',
|
'destination':{'recipient':'Local Smoke Ltda','street':'Rua de Teste','number':'100',
|
||||||
@@ -146,6 +154,8 @@ 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'},'destination':None},expected=409)
|
client.call('/quotes',{**draft,'freight':{'service':'pickup'},'destination':None},expected=409)
|
||||||
qid=quote['id']
|
qid=quote['id']
|
||||||
@@ -159,9 +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':''}
|
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.
|
||||||
@@ -185,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
@@ -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
@@ -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
@@ -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()
|
||||||
@@ -8,6 +8,7 @@ import hashlib
|
|||||||
import hmac
|
import hmac
|
||||||
import json
|
import json
|
||||||
import unittest
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@@ -36,6 +37,10 @@ class MercadoPagoTests(unittest.TestCase):
|
|||||||
self.requests.append(request)
|
self.requests.append(request)
|
||||||
if request.method == 'GET':
|
if request.method == 'GET':
|
||||||
payment_id = request.url.path.rsplit('/', 1)[-1]
|
payment_id = request.url.path.rsplit('/', 1)[-1]
|
||||||
|
if payment_id == '500':
|
||||||
|
return httpx.Response(500, json={'message': 'internal_error'})
|
||||||
|
if payment_id not in self.payments:
|
||||||
|
return httpx.Response(404, json={'message': 'Payment not found'})
|
||||||
return httpx.Response(200, json=self.payments[payment_id])
|
return httpx.Response(200, json=self.payments[payment_id])
|
||||||
body = json.loads(request.content)
|
body = json.loads(request.content)
|
||||||
payment = {'id': 555, 'status': 'pending', 'status_detail': 'pending_waiting_transfer',
|
payment = {'id': 555, 'status': 'pending', 'status_detail': 'pending_waiting_transfer',
|
||||||
@@ -78,6 +83,15 @@ class MercadoPagoTests(unittest.TestCase):
|
|||||||
self.assertEqual(self.requests[-1].headers['authorization'], 'Bearer TEST-token')
|
self.assertEqual(self.requests[-1].headers['authorization'], 'Bearer TEST-token')
|
||||||
self.assertIsNone(self.mp.parse(json.dumps({'type': 'merchant_order', 'data': {'id': '1'}}).encode()))
|
self.assertIsNone(self.mp.parse(json.dumps({'type': 'merchant_order', 'data': {'id': '1'}}).encode()))
|
||||||
|
|
||||||
|
def test_notification_for_an_unknown_payment_is_acknowledged(self):
|
||||||
|
# The panel's "Simular notificação" sends a payment id that does not
|
||||||
|
# exist. Raising would answer 500 and Mercado Pago would retry for ever.
|
||||||
|
body = json.dumps({'id': 43, 'type': 'payment', 'data': {'id': '123456'}}).encode()
|
||||||
|
self.assertIsNone(self.mp.parse(body, {'data.id': '123456', 'type': 'payment'}))
|
||||||
|
# Any other failure still raises, so a real notification is retried.
|
||||||
|
with self.assertRaises(httpx.HTTPStatusError):
|
||||||
|
self.mp.parse(json.dumps({'type': 'payment', 'data': {'id': '500'}}).encode(), {'data.id': '500'})
|
||||||
|
|
||||||
def test_amounts_outside_brl_centavos_are_not_trusted(self):
|
def test_amounts_outside_brl_centavos_are_not_trusted(self):
|
||||||
for payment in ({'currency_id': 'USD', 'transaction_amount': 10},
|
for payment in ({'currency_id': 'USD', 'transaction_amount': 10},
|
||||||
{'currency_id': 'BRL', 'transaction_amount': 10.001},
|
{'currency_id': 'BRL', 'transaction_amount': 10.001},
|
||||||
@@ -97,11 +111,20 @@ class MercadoPagoTests(unittest.TestCase):
|
|||||||
request = self.requests[-1]
|
request = self.requests[-1]
|
||||||
body = json.loads(request.content)
|
body = json.loads(request.content)
|
||||||
self.assertEqual(request.headers['x-idempotency-key'],
|
self.assertEqual(request.headers['x-idempotency-key'],
|
||||||
'dtf-quote-11111111-2222-3333-4444-555555555555-pix')
|
'dtf-quote-11111111-2222-3333-4444-555555555555-pix-1')
|
||||||
self.assertEqual((body['payment_method_id'], body['transaction_amount']), ('pix', 123.45))
|
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['external_reference'], '11111111-2222-3333-4444-555555555555')
|
||||||
self.assertEqual(body['notification_url'], 'https://dtf.example/api/payments/webhook')
|
self.assertEqual(body['notification_url'], 'https://dtf.example/api/payments/webhook')
|
||||||
self.assertEqual((created['pix_qr_code'], created['status']), ('000201PIX', 'pending'))
|
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):
|
def test_card_payment_uses_the_browser_token_only(self):
|
||||||
self.mp.create('q', 1000, {'mail': 'a@example.test', 'cnpj': '11222333000181'},
|
self.mp.create('q', 1000, {'mail': 'a@example.test', 'cnpj': '11222333000181'},
|
||||||
@@ -116,6 +139,31 @@ class MercadoPagoTests(unittest.TestCase):
|
|||||||
{'type': 'card', 'token': 'tok_def', 'payment_method_id': 'visa'})
|
{'type': 'card', 'token': 'tok_def', 'payment_method_id': 'visa'})
|
||||||
self.assertNotEqual(self.requests[-1].headers['x-idempotency-key'], first_key)
|
self.assertNotEqual(self.requests[-1].headers['x-idempotency-key'], first_key)
|
||||||
self.assertTrue(first_key.startswith('dtf-quote-q-card-'))
|
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__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
@@ -115,19 +115,19 @@ class PrintFileTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_refuses_what_it_cannot_reproduce(self):
|
def test_refuses_what_it_cannot_reproduce(self):
|
||||||
png = self.save(quadrants(), 'a.png')
|
png = self.save(quadrants(), 'a.png')
|
||||||
with self.assertRaisesRegex(Unsupported, 'proportions'):
|
with self.assertRaisesRegex(Unsupported, 'proporções'):
|
||||||
self.render(item([placement(0, 0, 20, 20)], [source(20, 20)], 20), [png])
|
self.render(item([placement(0, 0, 20, 20)], [source(20, 20)], 20), [png])
|
||||||
with self.assertRaisesRegex(Unsupported, 'billed'):
|
with self.assertRaisesRegex(Unsupported, 'cobrados'):
|
||||||
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 150, billed='1.0'), [png])
|
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')
|
fake_pdf = os.path.join(self.dir.name, 'art.pdf')
|
||||||
with open(fake_pdf, 'wb') as handle:
|
with open(fake_pdf, 'wb') as handle:
|
||||||
handle.write(b'%PDF-1.4\n%%EOF\n')
|
handle.write(b'%PDF-1.4\n%%EOF\n')
|
||||||
with self.assertRaisesRegex(Unsupported, 'not a PDF'):
|
with self.assertRaisesRegex(Unsupported, 'não é um PDF'):
|
||||||
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [fake_pdf])
|
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [fake_pdf])
|
||||||
cdr = os.path.join(self.dir.name, 'art.cdr')
|
cdr = os.path.join(self.dir.name, 'art.cdr')
|
||||||
with open(cdr, 'wb') as handle:
|
with open(cdr, 'wb') as handle:
|
||||||
handle.write(b'not artwork')
|
handle.write(b'not artwork')
|
||||||
with self.assertRaisesRegex(Unsupported, 'not an image'):
|
with self.assertRaisesRegex(Unsupported, 'não é uma imagem'):
|
||||||
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [cdr])
|
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [cdr])
|
||||||
|
|
||||||
def test_long_layouts_scale_user_space_instead_of_splitting(self):
|
def test_long_layouts_scale_user_space_instead_of_splitting(self):
|
||||||
@@ -247,12 +247,12 @@ class PdfSourceTests(unittest.TestCase):
|
|||||||
self.assertEqual(detail['min_dpi'], round(80 / (20 / 2.54)))
|
self.assertEqual(detail['min_dpi'], round(80 / (20 / 2.54)))
|
||||||
|
|
||||||
def test_refuses_pdfs_it_cannot_place(self):
|
def test_refuses_pdfs_it_cannot_place(self):
|
||||||
with self.assertRaisesRegex(Unsupported, '2 pages'):
|
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)])
|
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [self.pdf('two.pdf', pages=2)])
|
||||||
with self.assertRaisesRegex(Unsupported, 'password'):
|
with self.assertRaisesRegex(Unsupported, 'senha'):
|
||||||
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10),
|
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10),
|
||||||
[self.pdf('locked.pdf', password='secret')])
|
[self.pdf('locked.pdf', password='secret')])
|
||||||
with self.assertRaisesRegex(Unsupported, 'proportions'):
|
with self.assertRaisesRegex(Unsupported, 'proporções'):
|
||||||
self.render(item([placement(0, 0, 20, 20)], [source(20, 20)], 20), [self.pdf('square.pdf')])
|
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')
|
@unittest.skipIf(fitz is None, 'PyMuPDF is not installed')
|
||||||
|
|||||||
38
tests/test_quote_review.py
Normal 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()
|
||||||
@@ -23,7 +23,9 @@ PAID = {'order_id': 'b6f1c0de-0000-4000-8000-000000000001', 'number': 42, 'event
|
|||||||
'state': 'SP', 'postal_code': '14400000'},
|
'state': 'SP', 'postal_code': '14400000'},
|
||||||
'total_cents': 8472}}
|
'total_cents': 8472}}
|
||||||
PRODUCTS = {'TINY_PRODUCT_TEXTIL_FOLHA': '101', 'TINY_PRODUCT_TEXTIL_AVULSA': '102',
|
PRODUCTS = {'TINY_PRODUCT_TEXTIL_FOLHA': '101', 'TINY_PRODUCT_TEXTIL_AVULSA': '102',
|
||||||
'TINY_PRODUCT_UV_FOLHA': '103', 'TINY_PRODUCT_UV_AVULSA': '104'}
|
'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:
|
class FakeAuth:
|
||||||
@@ -31,39 +33,70 @@ class FakeAuth:
|
|||||||
return 'access-1'
|
return 'access-1'
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.dict(os.environ, PRODUCTS)
|
class FakeTinyCase(unittest.TestCase):
|
||||||
class TinyTests(unittest.TestCase):
|
"""A Tiny account in memory: contacts, orders, products and formas de envio."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.contacts = []
|
self.contacts = []
|
||||||
self.orders = []
|
self.orders = []
|
||||||
self.calls = []
|
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):
|
def handler(request):
|
||||||
path = request.url.path.removeprefix('/public-api/v3')
|
path = request.url.path.removeprefix('/public-api/v3')
|
||||||
self.calls.append((request.method, path))
|
self.calls.append((request.method, path))
|
||||||
assert request.headers['authorization'] == 'Bearer access-1'
|
assert request.headers['authorization'] == 'Bearer access-1'
|
||||||
if request.method == 'GET' and path == '/contatos':
|
if request.method == 'GET' and path == '/contatos':
|
||||||
cnpj = request.url.params['cpfCnpj']
|
cnpj = request.url.params.get('cpfCnpj')
|
||||||
return httpx.Response(200, json={'itens': [c for c in self.contacts if c['cpfCnpj'] == cnpj]})
|
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':
|
if request.method == 'POST' and path == '/contatos':
|
||||||
contact = {**json.loads(request.content), 'id': 500 + len(self.contacts)}
|
contact = {**json.loads(request.content), 'id': 500 + len(self.contacts)}
|
||||||
self.contacts.append(contact)
|
self.contacts.append(contact)
|
||||||
return httpx.Response(200, json={'id': contact['id']})
|
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':
|
if request.method == 'GET' and path == '/pedidos':
|
||||||
return httpx.Response(200, json={'itens': [{'id': o['id']} for o in self.orders]})
|
return httpx.Response(200, json={'itens': [{'id': o['id']} for o in self.orders]})
|
||||||
if request.method == 'GET' and path.startswith('/pedidos/'):
|
if request.method == 'GET' and path.startswith('/pedidos/'):
|
||||||
wanted = int(path.rsplit('/', 1)[-1])
|
wanted = int(path.rsplit('/', 1)[-1])
|
||||||
return httpx.Response(200, json=next(o for o in self.orders if o['id'] == wanted))
|
return httpx.Response(200, json=next(o for o in self.orders if o['id'] == wanted))
|
||||||
if request.method == 'POST' and path == '/pedidos':
|
if request.method == 'POST' and path == '/pedidos':
|
||||||
order = {**json.loads(request.content), 'id': 9000 + len(self.orders),
|
order = {'situacao': 0, **json.loads(request.content), 'id': 9000 + len(self.orders),
|
||||||
'numeroPedido': str(100 + len(self.orders))}
|
'numeroPedido': str(100 + len(self.orders))}
|
||||||
self.orders.append(order)
|
self.orders.append(order)
|
||||||
return httpx.Response(200, json={'id': order['id'], 'numeroPedido': order['numeroPedido']})
|
return httpx.Response(200, json={'id': order['id'], 'numeroPedido': order['numeroPedido']})
|
||||||
return httpx.Response(404)
|
return httpx.Response(404)
|
||||||
|
|
||||||
|
self.handler = handler
|
||||||
self.tiny = TinyOrders(auth=FakeAuth(), transport=httpx.MockTransport(handler),
|
self.tiny = TinyOrders(auth=FakeAuth(), transport=httpx.MockTransport(handler),
|
||||||
today=date(2026, 9, 24))
|
today=date(2026, 9, 24))
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PRODUCTS)
|
||||||
|
class TinyTests(FakeTinyCase):
|
||||||
def test_payload_carries_contact_products_address_and_freight(self):
|
def test_payload_carries_contact_products_address_and_freight(self):
|
||||||
pedido = order_payload(PAID, 777, date(2026, 9, 24))
|
pedido = order_payload(PAID, 777, date(2026, 9, 24))
|
||||||
self.assertEqual((pedido['idContato'], pedido['numeroOrdemCompra'], pedido['data']),
|
self.assertEqual((pedido['idContato'], pedido['numeroOrdemCompra'], pedido['data']),
|
||||||
@@ -106,11 +139,158 @@ class TinyTests(unittest.TestCase):
|
|||||||
self.tiny.deliver('k3', PAID)
|
self.tiny.deliver('k3', PAID)
|
||||||
self.assertNotIn(('POST', '/pedidos'), self.calls)
|
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):
|
def test_rate_limit_is_a_retryable_failure(self):
|
||||||
tiny = TinyOrders(auth=FakeAuth(), transport=httpx.MockTransport(lambda r: httpx.Response(429)))
|
tiny = TinyOrders(auth=FakeAuth(), transport=httpx.MockTransport(lambda r: httpx.Response(429)))
|
||||||
with self.assertRaisesRegex(TinyError, 'rate limit'):
|
with self.assertRaisesRegex(TinyError, 'rate limit'):
|
||||||
tiny.deliver('k4', PAID)
|
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__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -33,8 +33,12 @@ def run():
|
|||||||
|
|
||||||
def exercise():
|
def exercise():
|
||||||
issued = []
|
issued = []
|
||||||
|
server = {'mode': 'session', 'calls': 0}
|
||||||
|
|
||||||
def token_server(request):
|
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()}
|
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')
|
assert (form['client_id'], form['client_secret']) == ('test-client', 'test-secret')
|
||||||
if form['grant_type'] == 'authorization_code':
|
if form['grant_type'] == 'authorization_code':
|
||||||
@@ -44,8 +48,13 @@ def exercise():
|
|||||||
return httpx.Response(400, json={'error': 'invalid_grant'})
|
return httpx.Response(400, json={'error': 'invalid_grant'})
|
||||||
n = len(issued) + 1
|
n = len(issued) + 1
|
||||||
issued.append(f'refresh-{n}')
|
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,
|
return httpx.Response(200, json={'access_token': f'access-{n}', 'expires_in': 14400,
|
||||||
'refresh_token': f'refresh-{n}', 'refresh_expires_in': 86400})
|
'refresh_token': f'refresh-{n}', 'refresh_expires_in': 86400,
|
||||||
|
'scope': 'openid profile'})
|
||||||
|
|
||||||
auth = TinyAuth(transport=httpx.MockTransport(token_server))
|
auth = TinyAuth(transport=httpx.MockTransport(token_server))
|
||||||
assert auth.status() == {'connected': False}
|
assert auth.status() == {'connected': False}
|
||||||
@@ -59,6 +68,7 @@ def exercise():
|
|||||||
query = {k: v[0] for k, v in parse_qs(url.query).items()}
|
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 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['redirect_uri'].endswith('/api/operator/tiny/callback') and len(query['state']) > 30
|
||||||
|
assert query['scope'] == 'openid offline_access'
|
||||||
try:
|
try:
|
||||||
auth.complete('good-code', 'forged-state')
|
auth.complete('good-code', 'forged-state')
|
||||||
raise AssertionError('a state no operator created must be refused')
|
raise AssertionError('a state no operator created must be refused')
|
||||||
@@ -72,6 +82,7 @@ def exercise():
|
|||||||
pass
|
pass
|
||||||
status = auth.status()
|
status = auth.status()
|
||||||
assert status['connected'] and status['connected_by'] == 'operator@example.test'
|
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'
|
assert auth.access_token() == 'access-1'
|
||||||
print('PASS: operator-started state is required, single-use, and stores the connection')
|
print('PASS: operator-started state is required, single-use, and stores the connection')
|
||||||
|
|
||||||
@@ -85,6 +96,23 @@ def exercise():
|
|||||||
assert auth.access_token() == 'access-2'
|
assert auth.access_token() == 'access-2'
|
||||||
print('PASS: expiring access token refreshed once, refresh token rotated and stored')
|
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.
|
# A refused refresh (revoked in Tiny) asks for a new connection, not a retry loop.
|
||||||
with connect() as c:
|
with connect() as c:
|
||||||
c.execute("""UPDATE dtf_local.provider_tokens SET access_expires_at=now(), refresh_token='revoked'
|
c.execute("""UPDATE dtf_local.provider_tokens SET access_expires_at=now(), refresh_token='revoked'
|
||||||
@@ -94,12 +122,63 @@ def exercise():
|
|||||||
raise AssertionError('a refused refresh must report the connection as lost')
|
raise AssertionError('a refused refresh must report the connection as lost')
|
||||||
except TinyNotConnected:
|
except TinyNotConnected:
|
||||||
pass
|
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:
|
with connect() as c:
|
||||||
c.execute("UPDATE dtf_local.provider_tokens SET refresh_expires_at=%s WHERE provider='tiny'",
|
c.execute("UPDATE dtf_local.provider_tokens SET refresh_expires_at=%s WHERE provider='tiny'",
|
||||||
(datetime.now(timezone.utc) - timedelta(seconds=1),))
|
(datetime.now(timezone.utc) - timedelta(seconds=1),))
|
||||||
assert not auth.status()['connected']
|
assert not auth.status()['connected']
|
||||||
print('PASS: revoked or expired connections report that Tiny must be connected again')
|
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__':
|
if __name__ == '__main__':
|
||||||
run()
|
run()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
43
web/cart.js
@@ -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,19 +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;
|
|
||||||
entrega.end=entrega.end||{};
|
|
||||||
for(const [id,key] of [['eNome','nome'],['eRua','rua'],['eNum','num'],['eComp','comp'],
|
|
||||||
['eBairro','bairro'],['eCidade','cidade'],['eUf','uf']])$(id).value=entrega.end[key]||'';
|
|
||||||
$('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{
|
}
|
||||||
const account=await window.dtfApi('/account/me');
|
// A signed-in customer's details come from the account, with the saved
|
||||||
if(account.customer && !cliente.mail){cliente={...account.customer};for(const [id,key] of [['fCnpj','cnpj'],['fZap','zap'],['fMail','mail']])$(id).value=cliente[key];pintaEntrega();}
|
// delivery address, whether or not a cart was recovered.
|
||||||
|
const account=await window.dtfApi('/account/me');
|
||||||
|
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;}
|
||||||
|
|||||||
516
web/checkout.js
@@ -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,43 +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 (!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;
|
||||||
@@ -84,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=[];
|
||||||
@@ -95,7 +273,9 @@
|
|||||||
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();
|
const destination = destinoApi();
|
||||||
if (destination) content.destination = destination;
|
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.');
|
||||||
@@ -108,66 +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 ((await ready).payment_provider === 'mercadopago') {
|
||||||
button('Pagar com PIX',async event=>{
|
if (naPix()) await paginaPix(quote, version);
|
||||||
if (!quotedCart || quotedCart!==cartSnapshot()) { await refresh(); return; }
|
else await escolhaPagamento(quote, version);
|
||||||
event.target.disabled=true;
|
|
||||||
try { showPix(await api('/payments/intent',{quote_id:draftId,method:{type:'pix'}})); }
|
|
||||||
catch(error) { message(error.message); event.target.disabled=false; }
|
|
||||||
});
|
|
||||||
if ((await ready).payment_public_key) {
|
|
||||||
button('Pagar com cartão',async event=>{
|
|
||||||
if (!quotedCart || quotedCart!==cartSnapshot()) { await refresh(); return; }
|
|
||||||
event.target.disabled=true;
|
|
||||||
try { await showCard(quote.approved.total_cents); }
|
|
||||||
catch(error) { message(error.message); event.target.disabled=false; }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
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;
|
||||||
@@ -176,40 +354,149 @@
|
|||||||
button('Limpar referência e tentar de novo',clearDraft);
|
button('Limpar referência e tentar de novo',clearDraft);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// PIX: the provider's QR code and copy-and-paste code. The order is created
|
// The order is created by Mercado Pago's notification, never by this page,
|
||||||
// by the provider's notification, not by this page, so the page only waits.
|
// so after paying the page only waits for the order to exist.
|
||||||
let pixTimer=null;
|
let waitTimer=null;
|
||||||
function showPix(intent) {
|
function waitForOrder() {
|
||||||
actions.replaceChildren();
|
clearInterval(waitTimer);
|
||||||
if (!intent.pix_qr_code) { message('Não foi possível gerar o PIX. Tente de novo em instantes.'); return; }
|
waitTimer=setInterval(async()=>{
|
||||||
message('Pague o PIX de '+rs(intent.total_cents/100)+'. O pedido entra na produção assim que o pagamento for confirmado.');
|
|
||||||
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=220;img.height=220;img.style.display='block';
|
|
||||||
actions.append(img);
|
|
||||||
}
|
|
||||||
const code=document.createElement('input');
|
|
||||||
code.readOnly=true;code.value=intent.pix_qr_code;code.style.cssText='width:100%;margin-top:8px;padding:8px';
|
|
||||||
actions.append(code);
|
|
||||||
button('Copiar código PIX',async()=>{ try{ await navigator.clipboard.writeText(intent.pix_qr_code); message('Código copiado.'); }catch(_){ code.select(); } });
|
|
||||||
clearInterval(pixTimer);
|
|
||||||
pixTimer=setInterval(async()=>{
|
|
||||||
try {
|
try {
|
||||||
const quote=await api('/quotes/'+draftId);
|
const quote=await api('/quotes/'+draftId);
|
||||||
if (quote.status==='paid') {
|
if (quote.status==='paid') {
|
||||||
clearInterval(pixTimer);
|
clearInterval(waitTimer);
|
||||||
pedido=[]; itemAtual=null; limpaPaineis();
|
await pagos();
|
||||||
await window.dtfClearCart?.();
|
|
||||||
await refresh();
|
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(_) {}
|
} catch(_) {}
|
||||||
},5000);
|
},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
|
// 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
|
// Mercado Pago's secure fields and becomes a one-time token; the number never
|
||||||
// reaches this page's code or our server. As with PIX, the order is created
|
// reaches this page's code or our server.
|
||||||
// by the provider's notification, so the page only waits for it.
|
|
||||||
let sdkLoading=null;
|
let sdkLoading=null;
|
||||||
function loadMercadoPago() {
|
function loadMercadoPago() {
|
||||||
if (window.MercadoPago) return Promise.resolve();
|
if (window.MercadoPago) return Promise.resolve();
|
||||||
@@ -222,33 +509,64 @@
|
|||||||
});
|
});
|
||||||
return sdkLoading;
|
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;
|
let cardBrick=null;
|
||||||
async function showCard(totalCents) {
|
function unmountCard() {
|
||||||
await loadMercadoPago();
|
if (cardBrick) { try { cardBrick.unmount(); } catch(_) {} cardBrick=null; }
|
||||||
const session=await ready;
|
}
|
||||||
actions.replaceChildren();
|
async function showCard(approved, into, version, tipo) {
|
||||||
const holder=document.createElement('div');
|
const holder=node('div');
|
||||||
holder.id='cardPaymentBrick';
|
holder.id='cardPaymentBrick';
|
||||||
holder.style.cssText='max-width:520px;margin-top:8px';
|
const espera=node('div',null,'skel skelBloco');
|
||||||
actions.append(holder);
|
into.append(espera,holder);
|
||||||
message('Pagamento com cartão: '+rs(totalCents/100)+'.');
|
try { await loadMercadoPago(); }
|
||||||
if (cardBrick) { try { await cardBrick.unmount(); } catch(_) {} }
|
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'});
|
const mp=new window.MercadoPago(session.payment_public_key,{locale:'pt-BR'});
|
||||||
cardBrick=await mp.bricks().create('cardPayment','cardPaymentBrick',{
|
cardBrick=await mp.bricks().create('cardPayment','cardPaymentBrick',{
|
||||||
initialization:{amount:totalCents/100, payer:{email:cliente.mail}},
|
initialization:{amount:approved.total_cents/100, payer:{email:approved.customer.mail}},
|
||||||
customization:{paymentMethods:{maxInstallments:12}},
|
// 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:{
|
callbacks:{
|
||||||
onReady:()=>{},
|
onReady:()=>{ espera.remove(); },
|
||||||
onError:error=>{ console.error(error); message('Erro no formulário do cartão. Confira os dados e tente de novo.'); },
|
onError:error=>{ console.error(error); message('Erro no formulário do cartão. Confira os dados e tente de novo.'); },
|
||||||
onSubmit:async data=>{
|
onSubmit:async data=>{
|
||||||
const result=await api('/payments/intent',{quote_id:draftId,method:{
|
if (!cartOk()) { await refresh(); throw new Error('cart changed'); }
|
||||||
type:'card', token:data.token, payment_method_id:data.payment_method_id,
|
message('');
|
||||||
installments:Number(data.installments)||1,
|
let result;
|
||||||
issuer_id:data.issuer_id==null?null:String(data.issuer_id)}});
|
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') {
|
if (result.status==='approved' || result.status==='pending') {
|
||||||
message(result.status==='approved'
|
unmountCard();
|
||||||
? 'Pagamento aprovado. Seu pedido entra na produção em instantes.'
|
into.replaceChildren(node('p',result.status==='approved'
|
||||||
: 'Pagamento em análise pelo Mercado Pago. Avisamos assim que for confirmado.');
|
? 'Pagamento aprovado. Confirmando seu pedido…'
|
||||||
|
: 'Pagamento em análise pelo Mercado Pago. Esta página atualiza assim que ele for confirmado.','pagNota'));
|
||||||
waitForOrder();
|
waitForOrder();
|
||||||
} else {
|
} else {
|
||||||
message('Pagamento recusado pelo Mercado Pago ('+(result.status_detail||result.status)+'). '+
|
message('Pagamento recusado pelo Mercado Pago ('+(result.status_detail||result.status)+'). '+
|
||||||
@@ -258,26 +576,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
if (!holder.isConnected || version!==refreshVersion) unmountCard();
|
||||||
function waitForOrder() {
|
|
||||||
clearInterval(pixTimer);
|
|
||||||
pixTimer=setInterval(async()=>{
|
|
||||||
try {
|
|
||||||
const quote=await api('/quotes/'+draftId);
|
|
||||||
if (quote.status==='paid') {
|
|
||||||
clearInterval(pixTimer);
|
|
||||||
pedido=[]; itemAtual=null; limpaPaineis();
|
|
||||||
await window.dtfClearCart?.();
|
|
||||||
await refresh();
|
|
||||||
}
|
|
||||||
} catch(_) {}
|
|
||||||
},5000);
|
|
||||||
}
|
}
|
||||||
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();
|
||||||
})();
|
})();
|
||||||
|
|||||||
1077
web/index.html
209
web/kanban.css
Normal 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}
|
||||||
@@ -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&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}.print{margin-top:8px;padding-top:8px;border-top:1px solid var(--linha)}.print button{margin-left:6px}#tiny{font-size:12px;color:var(--fraco);margin-right:8px}#tiny button{margin-left:6px}#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><span id="tiny"></span><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="payments"></section><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=pdf-print-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>
|
||||||
|
|||||||
905
web/kanban.js
@@ -1,249 +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){
|
|
||||||
for(const uid of [...new Set(items.flatMap(i=>i.uploads))])container.append(action('Baixar original '+uid.slice(0,6),async()=>{
|
|
||||||
const result=await api('/uploads/'+uid+'/download');
|
|
||||||
const link=node('a');link.href=result.url;link.referrerPolicy='no-referrer';link.download=result.name;link.click();
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
function productionLines(container,item){
|
|
||||||
if(!item.production){container.append(node('p','Instruções de produção ausentes; não produzir este item.','meta'));return;}
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
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')));
|
|
||||||
}
|
|
||||||
const PRINT_STATUS={pending:'na fila para gerar',rendering:'gerando…',ready:'pronto',
|
|
||||||
manual:'preparar à mão',failed:'falhou ao gerar'};
|
|
||||||
function download(uid){return async()=>{
|
function download(uid){return 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 printFiles(card,order){
|
function manifest(production){
|
||||||
const rows=order.print_files||[];
|
const url=URL.createObjectURL(new Blob([JSON.stringify(production,null,2)],{type:'application/json'}));
|
||||||
const box=node('div',undefined,'print');
|
const link=node('a');link.href=url;link.download='dtf-layout-'+production.sources[0].upload_id.slice(0,8)+'.json';
|
||||||
box.append(node('b','Arquivo de impressão'));
|
link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);
|
||||||
order.snapshot.items.forEach((item,index)=>{
|
|
||||||
const row=rows.find(r=>r.item_index===index);
|
|
||||||
const line=node('p','Item '+(index+1)+' · '+(row?PRINT_STATUS[row.status]:'não gerado'),'meta');
|
|
||||||
if(row?.status==='ready'){
|
|
||||||
line.textContent+=' · '+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':'');
|
|
||||||
line.append(action('Baixar PDF',download(row.upload_id)));
|
|
||||||
}
|
|
||||||
if(row?.status==='manual')line.append(node('span',' · '+row.detail.reason));
|
|
||||||
box.append(line);
|
|
||||||
});
|
|
||||||
const retry=order.snapshot.items.some((_,index)=>{const row=rows.find(r=>r.item_index===index);
|
|
||||||
return !row||row.status==='manual'||row.status==='failed';});
|
|
||||||
if(retry&&['rec','tra'].includes(order.state))
|
|
||||||
box.append(action('Gerar arquivos de impressão',async()=>{await api('/orders/'+order.id+'/print-files',{});await load();}));
|
|
||||||
card.append(box);
|
|
||||||
}
|
|
||||||
function paymentIssues(){
|
|
||||||
$('payments').replaceChildren();
|
|
||||||
if(!board.payment_issues?.length)return;
|
|
||||||
$('payments').append(node('h2','Pagamentos que precisam de atenção · '+board.payment_issues.length));
|
|
||||||
$('payments').append(node('p','Um pagamento chegou sem virar pedido (valor diferente, cotação vencida) ou foi '+
|
|
||||||
'estornado depois do pedido. Confira no painel do provedor, resolva com o cliente e registre o que foi feito.','meta'));
|
|
||||||
for(const issue of board.payment_issues){
|
|
||||||
const card=node('article',undefined,'review');
|
|
||||||
card.append(node('b',issue.provider+' · evento '+issue.event_id),
|
|
||||||
node('p',(issue.amount_cents==null?'valor não informado':money(issue.amount_cents))+' · cotação '+
|
|
||||||
(issue.reference||'—')+' · '+new Date(issue.received_at).toLocaleString('pt-BR'),'meta'),
|
|
||||||
node('p',issue.outcome));
|
|
||||||
card.append(action('Registrar resolução',async()=>{
|
|
||||||
const note=prompt('O que foi feito? (ex.: estornado no Mercado Pago em 25/09)');if(!note)return;
|
|
||||||
await api('/payment-events/'+issue.id+'/resolve',{note});await load();
|
|
||||||
}));
|
|
||||||
$('payments').append(card);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 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()+tinyNotice;
|
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();
|
|
||||||
}
|
}
|
||||||
// The one-time authorisation of this system in the client's Tiny. Shown only
|
function render(){
|
||||||
// when the Tiny application is configured on the server.
|
$('who').textContent=board.operator||'';
|
||||||
function tinyStatus(){
|
$('count-board').textContent=String(board.orders.filter(o=>o.state!=='fin').length);
|
||||||
const box=$('tiny');box.replaceChildren();
|
$('badge-quotes').textContent=board.pending_total?String(board.pending_total):'';
|
||||||
const tiny=board.tiny||{};
|
$('badge-payments').textContent=board.payment_issues_total?String(board.payment_issues_total):'';
|
||||||
if(!tiny.configured)return;
|
tinyHeader();renderBoard();renderIntegrations();
|
||||||
box.append(node('span',tiny.connected
|
// Lists live on their own pages of the API; refresh the one being looked at.
|
||||||
? 'Tiny conectado'+(tiny.orders_enabled?'':' · envio de pedidos desligado')
|
const lists={quotes:loadQuotes,payments:loadIssues,integrations:loadEvents};
|
||||||
: 'Tiny não conectado'));
|
if(lists[tab])lists[tab]().catch(e=>say(e.message,true));
|
||||||
box.append(action(tiny.connected?'Reconectar Tiny':'Conectar Tiny',async()=>{
|
if(openOrder){const order=board.orders.find(o=>o.id===openOrder);if(order)renderPanel(order);else closePanel();}
|
||||||
const result=await api('/tiny/connect',{});location.href=result.url;
|
}
|
||||||
|
|
||||||
|
// ---- Production board ----------------------------------------------------
|
||||||
|
|
||||||
|
function printState(order){
|
||||||
|
if(order.state==='fil'||order.state==='imp')return {text:'Final aprovado',tone:'ok'};
|
||||||
|
if(order.state==='fin')return {text:'Concluído',tone:'ok'};
|
||||||
|
if(order.state==='cor')return {text:'Aguardando o cliente',tone:''};
|
||||||
|
const rows=order.print_files||[], items=order.snapshot.items.length;
|
||||||
|
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;
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
const tinyResult=new URLSearchParams(location.search).get('tiny');
|
// An operator's test order: no payment, no Tiny, no WhatsApp.
|
||||||
const tinyNotice=tinyResult?(tinyResult==='connected'?' · Tiny conectado.':' · Não foi possível conectar o Tiny. Tente de novo.'):'';
|
const isTest=order=>order.payment?.provider==='teste';
|
||||||
if(tinyResult)history.replaceState(null,'',location.pathname);
|
function card(order){
|
||||||
function render(){
|
const c=node('button',undefined,'card');c.type='button';c.dataset.card=order.id;c.draggable=true;
|
||||||
tinyStatus();
|
if(order.id===openOrder)c.classList.add('open');
|
||||||
paymentIssues();
|
const top=node('div',undefined,'row');
|
||||||
$('reviews').replaceChildren();
|
top.append(node('span','#'+order.number,'num'),...(isTest(order)?[node('span','TESTE','tag-teste')]:[]),node('span',ago(order.updated_at),'age'));
|
||||||
if(board.pending_total || board.approved_total)
|
const tags=node('div',undefined,'tags');
|
||||||
$('reviews').append(node('h2','Cotações · '+board.pending_total+' pendentes · '+
|
for(const item of order.snapshot.items)tags.append(node('span',SHORT[item.mode]+' · '+metres(item.billed_metres),'tag'));
|
||||||
board.approved_total+' aprovadas sem pedido'));
|
const foot=node('div',undefined,'foot');const p=printState(order);
|
||||||
for(const quote of [...board.quotes,...extraQuotes.pending,...extraQuotes.approved]){
|
const status=node('span',p.text,p.tone);status.style.display='flex';status.style.alignItems='center';status.style.gap='5px';
|
||||||
const card=node('article',undefined,'review');
|
if(p.tone==='ok')status.prepend(icon(ICON_OK));if(p.tone==='warn')status.prepend(icon(ICON_WARN));
|
||||||
card.append(node('b',quote.id.slice(0,8)+' · '+quote.draft.customer.mail));
|
const dest=order.snapshot.destination;
|
||||||
if(quote.status==='pending_review' && quote.draft.items.some(item=>item.production?.version!==2)){
|
const where=node('span',dest?'Entrega · '+dest.state:'Retirada','where');where.prepend(icon(dest?ICON_TRUCK:ICON_PIN));
|
||||||
card.append(node('p','Cotação com montagem antiga. Peça ao cliente para enviar uma nova cotação.'));
|
foot.append(status,where);
|
||||||
}else if(quote.status!=='pending_review'){
|
c.append(top,node('div',order.snapshot.customer.mail,'cust'),tags,foot);
|
||||||
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.'));
|
if(['rec','tra','fil','imp'].includes(order.state)&&Date.now()-new Date(order.updated_at)>LATE_HOURS*3600000)
|
||||||
}else{
|
c.append(node('div','Parado há mais de '+LATE_HOURS+' h','late'));
|
||||||
const form=node('form');
|
c.onclick=()=>openPanel(order.id);
|
||||||
const edits=quote.draft.items.map((item,index)=>{
|
c.ondragstart=e=>{
|
||||||
const row=node('div');row.append(node('strong',(index+1)+'. '+item.mode+' '));
|
e.dataTransfer.setData('text/plain',order.id);
|
||||||
productionLines(row,item);
|
const forward=board.transitions[order.state].filter(s=>s!=='cor');
|
||||||
const metres=node('input');metres.type='number';metres.min='0.001';metres.max='12000';metres.step='any';metres.value=item.metres;metres.required=true;
|
for(const col of document.querySelectorAll('.col[data-state]'))col.classList.toggle('allowed',forward.includes(col.dataset.state));
|
||||||
const grade=node('input');grade.type='number';grade.min='0';grade.max='100';grade.step='1';grade.value=item.grade;grade.required=true;
|
say(forward.length?'Mover para '+forward.map(s=>board.states[s]).join(' ou '):'Pedido finalizado',false,60000);
|
||||||
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)});
|
c.ondragend=()=>{for(const col of document.querySelectorAll('.col'))col.classList.remove('allowed','target');say('');};
|
||||||
});
|
return c;
|
||||||
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+' · '+(order.payment?.provider==='fake'?'Pago local':'Pago')),node('p',order.snapshot.customer.mail,'meta'),node('p',money(order.snapshot.total_cents)));
|
|
||||||
const to=order.snapshot.destination;
|
|
||||||
card.append(node('p',to?'Entrega: '+to.recipient+' · '+to.street+', '+to.number+(to.complement?' '+to.complement:'')+
|
|
||||||
' · '+to.district+' · '+to.city+'/'+to.state+' · CEP '+to.postal_code:'Retirada em Franca','meta'));
|
|
||||||
for(const item of order.snapshot.items){
|
|
||||||
card.append(node('p',item.mode+' · '+item.billed_metres+' m · nota '+item.grade,'meta'));
|
|
||||||
productionLines(card,item);
|
|
||||||
}
|
|
||||||
printFiles(card,order);
|
|
||||||
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){
|
async function loadOlderFinished(){
|
||||||
let reason='';if(state==='cor'){reason=prompt('Motivo da correção:');if(!reason)return;}
|
const shown=board.orders.filter(o=>o.state==='fin').sort((a,b)=>a.created_at<b.created_at?-1:1);
|
||||||
await api('/orders/'+order.id+'/move',{state,version:order.version,reason});await load();
|
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();
|
||||||
}
|
}
|
||||||
$('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){
|
// ---- Order panel ---------------------------------------------------------
|
||||||
container.replaceChildren();
|
|
||||||
const revisions=await api('/orders/'+order.id+'/files');
|
function openPanel(id){openOrder=id;const order=board.orders.find(o=>o.id===id);$('drawer').hidden=false;renderPanel(order);renderBoard();}
|
||||||
for(const file of revisions){
|
function closePanel(){openOrder=null;$('drawer').hidden=true;$('panel').replaceChildren();if(board)renderBoard();}
|
||||||
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');
|
$('scrim').onclick=closePanel;
|
||||||
row.append(node('p',file.note));
|
document.addEventListener('keydown',e=>{if(e.key==='Escape'&&openOrder)closePanel();});
|
||||||
if(!file.expired)row.append(action('Baixar '+file.name,download(file.upload_id)));
|
|
||||||
container.append(row);
|
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 form=node('form');
|
const previous=board.back?.[order.state];
|
||||||
form.append(node('p','Enviar um conjunto final completo. Pode haver várias partes por item. Um novo conjunto substitui o anterior.'));
|
const backForm=node('form',undefined,'reason');backForm.hidden=true;
|
||||||
// A generated print file reproduces the approved layout; offer it first, and
|
if(previous){
|
||||||
// keep the upload for items that need hand preparation. Not after a
|
const b=node('button','Voltar para '+board.states[previous],'btn ghost');b.type='button';b.dataset.back=previous;
|
||||||
// correction: the file was made from artwork the customer has replaced.
|
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 corrected=revisions.some(file=>file.kind==='correction');
|
||||||
|
const form=node('form');
|
||||||
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 slot=node('label',undefined,'slot');slot.append('Item '+(index+1)+' · '+PRODUCT[item.mode]);
|
||||||
const input=node('input');input.type='file';input.multiple=true;input.required=true;input.dataset.finalItem=index;input.style.width='100%';
|
const input=node('input');input.type='file';input.multiple=true;input.required=true;input.dataset.finalItem=index;
|
||||||
const generated=corrected?null:(order.print_files||[]).find(r=>r.item_index===index&&r.status==='ready');
|
const generated=corrected?null:(order.print_files||[]).find(r=>r.item_index===index&&r.status==='ready');
|
||||||
if(generated){
|
if(generated){
|
||||||
const use=node('input');use.type='checkbox';use.checked=true;input.required=false;input.hidden=true;
|
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;};
|
use.onchange=()=>{input.hidden=use.checked;input.required=!use.checked;};
|
||||||
const choice=node('label',' Usar o arquivo gerado ('+generated.name+')');choice.prepend(use);choice.style.display='block';
|
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+')');
|
||||||
label.append(choice);input.generated=()=>use.checked?generated.upload_id:null;
|
slot.append(choice);input.generated=()=>use.checked?generated.upload_id:null;
|
||||||
}
|
}
|
||||||
label.append(input);form.append(label);return input;});
|
slot.append(input);form.append(slot);return input;
|
||||||
const note=node('input');note.placeholder='Nota da revisão';note.required=true;note.maxLength=1000;note.style.width='100%';form.append(note);
|
});
|
||||||
const check=node('input');check.type='checkbox';check.required=true;const label=node('label','Conferi estes arquivos finais para impressão manual.');label.prepend(check);form.append(label);
|
const note=node('input');note.placeholder='Nota da revisão';note.required=true;note.maxLength=1000;
|
||||||
const submit=node('button','Aprovar arquivos finais');submit.type='submit';form.append(submit);
|
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 [index,input] of inputs.entries()){
|
||||||
const generated=input.generated?.();
|
const generated=input.generated?.();
|
||||||
if(generated){refs.push({item_index:index,upload_id:generated});continue;}
|
if(generated){refs.push({item_index:index,upload_id:generated});continue;}
|
||||||
for(const file of input.files){
|
for(const file of input.files){
|
||||||
const uid=await dtfUpload(file,{api,startPath:'/orders/'+order.id+'/uploads',scope:'operator:'+order.id+':'+order.version,progress:text=>$('status').textContent=text});
|
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&"eTotal){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)&¤tQuote.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'&"e.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
@@ -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}
|
||||||
|
}
|
||||||
180
web/portal.html
@@ -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>
|
||||||
|
|||||||
387
web/portal.js
@@ -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));
|
||||||
for(const file of order.files){
|
container.append(itensPedido(order.snapshot),entrega(order.snapshot));
|
||||||
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 hist=node('div',null,'secao');hist.append(node('h4','Andamento'));
|
||||||
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 ol=node('ol',null,'historico');
|
||||||
container.append(row);
|
// 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){
|
||||||
|
const row=node('div',null,'arquivo');
|
||||||
|
const info=node('div');
|
||||||
|
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);}
|
||||||
|
})();
|
||||||
|
|||||||
@@ -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() || !enderecoOk();
|
$('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
@@ -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();
|
||||||
|
})();
|
||||||
@@ -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'}
|
||||||
};
|
};
|
||||||
@@ -77,7 +92,6 @@ let pedido=[]; // itens já fechados · o pagamento
|
|||||||
// 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, end:{}};
|
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){
|
||||||
@@ -106,6 +120,23 @@ const destinoApi=()=>entrega.tipo!=='frete' ? null : {
|
|||||||
recipient:entrega.end.nome.trim(), street:entrega.end.rua.trim(), number:entrega.end.num.trim(),
|
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(),
|
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};
|
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);
|
||||||
|
|||||||
146
web/site-flow.js
@@ -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,44 +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'
|
|
||||||
? '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() || !enderecoOk() || !cartPodeEnviar();
|
$('bPagar').disabled = !entrega.cotado || !clienteOk() || !enderecoOk() || !cartPodeEnviar();
|
||||||
$('eEnd').textContent = entrega.tipo==='frete' && entrega.cotado && !enderecoOk()
|
|
||||||
? 'Preencha o endereço de entrega para seguir.' : '';
|
|
||||||
pintaPedido();
|
pintaPedido();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,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;
|
||||||
@@ -140,11 +138,34 @@ 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(); }
|
||||||
});
|
});
|
||||||
for(const [id,chave] of [['eNome','nome'],['eRua','rua'],['eNum','num'],['eComp','comp'],
|
for(const [id,chave] of [['eNome','nome'],['eRua','rua'],['eNum','num'],['eComp','comp'],
|
||||||
['eBairro','bairro'],['eCidade','cidade'],['eUf','uf']]){
|
['eBairro','bairro'],['eCidade','cidade'],['eUf','uf']]){
|
||||||
@@ -154,33 +175,36 @@ for(const [id,chave] of [['eNome','nome'],['eRua','rua'],['eNum','num'],['eComp'
|
|||||||
pintaEntrega();
|
pintaEntrega();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
$('bCep').addEventListener('click',async()=>{
|
$('bMais').addEventListener('click',()=>{ if(guardaItem()) vaiPara('/#envio'); });
|
||||||
if(entrega.cep.length!==8){ $('cepMsg').innerHTML='CEP incompleto.'; return; }
|
|
||||||
if(window.dtfFreight){ await window.dtfFreight(); return; }
|
|
||||||
// A cotação vem da transportadora. Enquanto a integração não existir, o pedido não
|
|
||||||
// fecha por frete: o valor precisa estar fechado antes do Mercado Pago.
|
|
||||||
$('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();
|
|
||||||
});
|
|
||||||
|
|
||||||
$('bMais').addEventListener('click',()=>{
|
|
||||||
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();
|
||||||
|
})();
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
464
web/site-pdf.js
@@ -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>';
|
||||||
(x.m>0 ?
|
const copias = x.m>0 || manual ?
|
||||||
'<div class="cps" style="margin-top:10px">'+
|
'<div class="cps">'+manual+
|
||||||
'<div><label>quantas vezes repetir</label>'+
|
(x.m>0 ?
|
||||||
'<input type="number" min="1" max="200" data-repf="'+i+'" value="'+x.rep+'"></div>'+
|
'<div class="campo"><label for="repf'+i+'">Cópias</label>'+
|
||||||
'<div class="conta"><b>'+fmtM(sub)+' m</b>'+
|
'<span class="passo"><button type="button" data-passo="-1" data-i="'+i+'" aria-label="Menos uma cópia">−</button>'+
|
||||||
(x.rep>1? ' · '+x.rep+' × '+fmtM(x.m)+' m':'')+'</div>'+
|
'<input type="number" id="repf'+i+'" min="1" max="200" data-repf="'+i+'" value="'+x.rep+'">'+
|
||||||
'</div>' : '')+
|
'<button type="button" data-passo="1" data-i="'+i+'" aria-label="Mais uma cópia">+</button></span></div>'+
|
||||||
|
'<div class="conta">'+(x.rep>1? x.rep+' × '+fmtM(x.m)+' m = ' : '')+'<b>'+fmtM(sub)+' m</b></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))));
|
||||||
|
|||||||
@@ -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
@@ -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%}
|
||||||
142
web/site-steps.js
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
/* Site DTF — moving between the Site's pages (addresses in site-pages.js), the
|
||||||
|
order progress bar, the product card prices and the phone total bar. It only
|
||||||
|
switches which page is shown and reads the page's state (the open product,
|
||||||
|
the cart, the checkout status); orders, prices and files stay with the other
|
||||||
|
site-*.js files. */
|
||||||
|
// Card prices come from the same table the checkout uses, so they cannot drift.
|
||||||
|
document.querySelectorAll('[data-de]').forEach(el=>{ el.textContent=rs(pisoEscada(el.dataset.de)); });
|
||||||
|
document.querySelectorAll('[data-ate]').forEach(el=>{ el.textContent=rs(TABELA[el.dataset.ate]); });
|
||||||
|
|
||||||
|
const TITULO_INICIO=document.title;
|
||||||
|
function tituloPagina(){
|
||||||
|
const p=paginaDe(location.pathname);
|
||||||
|
if(p.pagina==='carrinho') return 'Carrinho · DTF Dropstar';
|
||||||
|
if(p.pagina==='pagamento') return 'Pagamento · DTF Dropstar';
|
||||||
|
if(p.pagina==='pix') return 'Pagamento com PIX · DTF Dropstar';
|
||||||
|
if(p.pagina==='produto') return MODOS[p.modo].tit+' · DTF Dropstar';
|
||||||
|
return TITULO_INICIO;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shows the page for the current address; `ancora` scrolls to a section,
|
||||||
|
// otherwise the page opens at `y` (the saved position on Back and Forward).
|
||||||
|
function mostraPagina(ancora,y,suave){
|
||||||
|
const p=paginaDe(location.pathname);
|
||||||
|
if(p.pagina==='produto' && modo!==p.modo) abrir(p.modo);
|
||||||
|
document.documentElement.dataset.rota=p.pagina;
|
||||||
|
$('cards').style.display='';
|
||||||
|
document.title=tituloPagina();
|
||||||
|
window.dispatchEvent(new Event('dtf-page-changed'));
|
||||||
|
const el=ancora && document.getElementById(ancora);
|
||||||
|
if(el) el.scrollIntoView({behavior:suave?'smooth':'auto',block:'start'});
|
||||||
|
else window.scrollTo(0,y||0);
|
||||||
|
}
|
||||||
|
function vaiPara(url){
|
||||||
|
const u=new URL(url,location.href);
|
||||||
|
const ancora=u.hash.slice(1)||null;
|
||||||
|
if(u.pathname+u.search===location.pathname+location.search){
|
||||||
|
mostraPagina(ancora,0,true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try{ history.replaceState({...(history.state||{}),y:scrollY},''); }catch(e){}
|
||||||
|
history.pushState({y:0},'',u.pathname+u.search+u.hash);
|
||||||
|
mostraPagina(ancora,0,false);
|
||||||
|
}
|
||||||
|
|
||||||
|
(function(){
|
||||||
|
if('scrollRestoration' in history) history.scrollRestoration='manual';
|
||||||
|
addEventListener('popstate',e=>mostraPagina(e.state?null:location.hash.slice(1)||null,e.state?.y));
|
||||||
|
|
||||||
|
// Links to the Site's own pages move inside this document; everything else
|
||||||
|
// (the account portal, the store, e-mail) is a normal link.
|
||||||
|
document.addEventListener('click',e=>{
|
||||||
|
const a=e.target.closest && e.target.closest('a[href]');
|
||||||
|
if(!a || e.defaultPrevented || e.button || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || a.target) return;
|
||||||
|
const u=new URL(a.href,location.href);
|
||||||
|
if(u.origin!==location.origin) return;
|
||||||
|
const caminho=u.pathname.replace(/\/+$/,'')||'/';
|
||||||
|
if(caminho!=='/' && caminho!=='/index.html' && paginaDe(caminho).pagina==='inicio') return;
|
||||||
|
e.preventDefault(); vaiPara(u.pathname+u.search+u.hash);
|
||||||
|
});
|
||||||
|
|
||||||
|
const passos=$('passos'), barra=$('barraM'), vazio=$('carrVazio');
|
||||||
|
const foco=$('foco'), carr=$('carr'), status=$('checkoutStatus'), acoes=$('checkoutActions');
|
||||||
|
const rota=()=>document.documentElement.dataset.rota;
|
||||||
|
const temCheckout=()=>!!(status.textContent||'').trim() || acoes.children.length>0;
|
||||||
|
|
||||||
|
// The store header is sticky; the progress bar and the side panels sit below it.
|
||||||
|
function alturas(){
|
||||||
|
const topo=document.querySelector('.topo')?.offsetHeight||0;
|
||||||
|
document.documentElement.style.setProperty('--topo',topo+'px');
|
||||||
|
document.documentElement.style.setProperty('--topo-passos',(topo+(passos.hidden?0:passos.offsetHeight)+16)+'px');
|
||||||
|
}
|
||||||
|
function naTela(el){
|
||||||
|
const r=el.getBoundingClientRect();
|
||||||
|
return r.height>0 && r.top<innerHeight && r.bottom>0;
|
||||||
|
}
|
||||||
|
// Where the phone bar's button leads: the add-to-cart button on a product
|
||||||
|
// page, the order summary in the cart.
|
||||||
|
function alvoBarra(){
|
||||||
|
if(rota()==='produto' && itemAtual) return $('bAdd');
|
||||||
|
if(rota()==='carrinho' && (pedido.length || itemAtual) && !temCheckout())
|
||||||
|
return document.querySelector('.resumo');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let pendente=false;
|
||||||
|
function pinta(){
|
||||||
|
pendente=false;
|
||||||
|
const r=rota();
|
||||||
|
// With nothing in it, the cart page says so instead of showing an empty form.
|
||||||
|
const semItens = !pedido.length && !itemAtual;
|
||||||
|
const carrinhoVazio = r==='carrinho' && semItens && !temCheckout();
|
||||||
|
document.documentElement.toggleAttribute('data-sem-itens', semItens);
|
||||||
|
vazio.hidden = !carrinhoVazio;
|
||||||
|
passos.hidden = r==='inicio' || carrinhoVazio;
|
||||||
|
alturas();
|
||||||
|
if(!passos.hidden){
|
||||||
|
const atual = r==='produto' ? 1 : r==='pagamento' || r==='pix' ? 3 : 2;
|
||||||
|
passos.querySelectorAll('[data-passo]').forEach(a=>{
|
||||||
|
const n=Number(a.dataset.passo);
|
||||||
|
a.classList.toggle('atual',n===atual); a.classList.toggle('feito',n<atual);
|
||||||
|
const marca=a.querySelector('span');
|
||||||
|
if(marca) marca.innerHTML = n<atual ? icone('ok') : String(n);
|
||||||
|
if(n===atual) a.setAttribute('aria-current','step'); else a.removeAttribute('aria-current');
|
||||||
|
});
|
||||||
|
passos.querySelector('[data-passo="1"]').href =
|
||||||
|
modo && foco.classList.contains('on') ? PAGINAS[modo] : '/#envio';
|
||||||
|
}
|
||||||
|
const alvo=alvoBarra();
|
||||||
|
const mostrar=!!alvo && !naTela(alvo);
|
||||||
|
barra.hidden=!mostrar;
|
||||||
|
if(mostrar){
|
||||||
|
$('barraMTot').textContent=$('carrTot').textContent;
|
||||||
|
$('barraMSub').textContent= r==='produto' ? 'Total até agora' : 'Seu pedido';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function agenda(){ if(!pendente){ pendente=true; requestAnimationFrame(pinta); } }
|
||||||
|
|
||||||
|
const obs=new MutationObserver(agenda);
|
||||||
|
obs.observe(foco,{attributes:true,attributeFilter:['class']});
|
||||||
|
obs.observe(carr,{attributes:true,attributeFilter:['class']});
|
||||||
|
obs.observe($('prev'),{attributes:true,attributeFilter:['class']});
|
||||||
|
obs.observe(status,{childList:true,characterData:true,subtree:true});
|
||||||
|
obs.observe(acoes,{childList:true});
|
||||||
|
obs.observe($('carrTot'),{childList:true,characterData:true,subtree:true});
|
||||||
|
addEventListener('scroll',agenda,{passive:true});
|
||||||
|
addEventListener('resize',agenda);
|
||||||
|
addEventListener('dtf-cart-changed',agenda);
|
||||||
|
addEventListener('dtf-page-changed',agenda);
|
||||||
|
|
||||||
|
// Payment is a step only once the order has been sent.
|
||||||
|
passos.querySelector('[data-passo="3"]').addEventListener('click',e=>{
|
||||||
|
let enviado=false;
|
||||||
|
try{ enviado=!!localStorage.getItem('dtf-quote'); }catch(_){}
|
||||||
|
if(!enviado){ e.preventDefault(); e.stopPropagation(); }
|
||||||
|
},true);
|
||||||
|
$('barraMBtn').addEventListener('click',e=>{
|
||||||
|
e.preventDefault();
|
||||||
|
alvoBarra()?.scrollIntoView({behavior:'smooth',block:'center'});
|
||||||
|
});
|
||||||
|
|
||||||
|
try{ history.replaceState({...(history.state||{}),y:0},''); }catch(e){}
|
||||||
|
mostraPagina(location.hash.slice(1)||null,0);
|
||||||
|
})();
|
||||||
@@ -37,33 +37,17 @@ function sel(fs){
|
|||||||
const fora = fs.filter(f=>!regra.test(f.name));
|
const fora = fs.filter(f=>!regra.test(f.name));
|
||||||
fs = fs.filter(f=>regra.test(f.name));
|
fs = fs.filter(f=>regra.test(f.name));
|
||||||
recusa(fora);
|
recusa(fora);
|
||||||
const max=window.dtfUploadMaxBytes||128*1048576;
|
const max=window.dtfUploadMaxBytes||5*1073741824;
|
||||||
const grandes=fs.filter(f=>f.size>max);
|
const grandes=fs.filter(f=>f.size>max);
|
||||||
if(grandes.length){
|
if(grandes.length){
|
||||||
const el=$('recusa');
|
const el=$('recusa');
|
||||||
el.style.display='block';
|
el.style.display='block';
|
||||||
el.innerHTML='<b>Arquivo acima do limite de '+(max/1048576).toFixed(0)+
|
el.innerHTML='<b>Arquivo acima do limite de '+(max/1073741824).toFixed(0)+
|
||||||
' MB.</b> A verificação de segurança ainda não consegue liberar arquivos maiores. '+
|
' GB.</b> Divida a folha em partes menores antes de enviar.';
|
||||||
'Divida ou compacte a arte antes de enviar.';
|
|
||||||
fs=fs.filter(f=>f.size<=max);
|
fs=fs.filter(f=>f.size<=max);
|
||||||
}
|
}
|
||||||
if(!fs.length) return;
|
if(!fs.length) return;
|
||||||
if(ehFolha()){
|
if(ehFolha()){
|
||||||
const auto=fs.filter(f=>AUTO.test(f.name)).length;
|
|
||||||
const manual=fs.length-auto;
|
|
||||||
if(caminho==='auto' && manual){
|
|
||||||
caminho='tabela';
|
|
||||||
avisoCam('<b>Trocamos o caminho para você.</b> Você marcou conferência automática, '+
|
|
||||||
'mas '+(manual>1? manual+' arquivos vieram':'o arquivo veio')+' em formato que não '+
|
|
||||||
'abrimos. O metro vai pela tabela, '+rs(TABELA[modo])+'.');
|
|
||||||
pintaCaminhos();
|
|
||||||
}else if(caminho==='tabela' && auto){
|
|
||||||
caminho='auto';
|
|
||||||
avisoCam('<b>Boa notícia.</b> Você marcou o caminho sem conferência, mas '+
|
|
||||||
(auto>1? 'os arquivos vieram':'o arquivo veio')+' em formato que a gente confere. '+
|
|
||||||
'Vamos avaliar e o metro pode cair até '+rs(pisoEscada())+'.');
|
|
||||||
pintaCaminhos();
|
|
||||||
}
|
|
||||||
const novas=fs.map(f=>({f, med:null, rep:1, m:0, semAnalise:null}));
|
const novas=fs.map(f=>({f, med:null, rep:1, m:0, semAnalise:null}));
|
||||||
folhas=folhas.concat(novas); // soma, não substitui
|
folhas=folhas.concat(novas); // soma, não substitui
|
||||||
pintaFolha();
|
pintaFolha();
|
||||||
@@ -75,12 +59,36 @@ function sel(fs){
|
|||||||
}
|
}
|
||||||
if(md && md.dpiFolha!=null && md.dpiFolha<DPI_RECUSA) return recusaFolha(x, md);
|
if(md && md.dpiFolha!=null && md.dpiFolha<DPI_RECUSA) return recusaFolha(x, md);
|
||||||
x.med=md; if(md) x.m=md.alt/100; x.pct=55; pintaFolha();
|
x.med=md; if(md) x.m=md.alt/100; x.pct=55; pintaFolha();
|
||||||
if(md && RENDERIZA.test(x.f.name)){
|
if(md && md.grande){
|
||||||
|
// Graded at once from its size in pixels. A PNG is then read as a
|
||||||
|
// stream into a small copy: the preview, and the sheet check.
|
||||||
|
x.an={dpi:md.dpiFolha, grande:true, artes:0, residuos:0, encostadas:0,
|
||||||
|
fundoChapado:false, aproveitamento:0, alturaCm:md.alt, fonteDpi:'arquivo'};
|
||||||
|
if(!/\.png$/i.test(x.f.name)){ x.pct=null; pintaFolha(); return; }
|
||||||
|
x.pct=60; x.leitura={inicio:Date.now(), p:0}; pintaFolha();
|
||||||
|
let mostrado=0;
|
||||||
|
previaPngGrande(x.f, 600, p=>{
|
||||||
|
const pct=60+Math.floor(p*38);
|
||||||
|
x.leitura.p=p;
|
||||||
|
if(pct-mostrado>=1){ mostrado=pct; x.pct=pct; pintaProgressoFolha(x); }
|
||||||
|
}).then(cv=>{
|
||||||
|
if(cv && folhas.includes(x)){
|
||||||
|
x.previewSrc=cv.toDataURL('image/webp',0.85); x.miniSrc=miniatura(cv);
|
||||||
|
try{
|
||||||
|
const an=analisarFolha(cv, md.larg);
|
||||||
|
x.an={...an, dpi:md.dpiFolha, fonteDpi:'arquivo', grande:false};
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
}).catch(()=>{}).finally(()=>{ x.pct=null; pintaFolha(); });
|
||||||
|
}else if(md && RENDERIZA.test(x.f.name)){
|
||||||
carregarImagem(x.f).then(img=>{
|
carregarImagem(x.f).then(img=>{
|
||||||
if(img){ x.previewSrc=img.src; try{ x.an=analisarFolha(img, md.larg); if(x.an) x.an.fonteDpi='arquivo'; }
|
if(img){ x.previewSrc=img.src; x.miniSrc=miniatura(img); try{ x.an=analisarFolha(img, md.larg); if(x.an) x.an.fonteDpi='arquivo'; }
|
||||||
catch(e){} }
|
catch(e){} }
|
||||||
x.pct=null; pintaFolha();
|
x.pct=null; pintaFolha();
|
||||||
});
|
});
|
||||||
|
}else if(md && /\.pdf$/i.test(x.f.name) && x.f.size>GRANDE_BYTES){
|
||||||
|
x.semAnalise='PDF grande: a resolução das imagens de dentro é conferida pela equipe';
|
||||||
|
x.pct=null; pintaFolha();
|
||||||
}else if(md && /\.pdf$/i.test(x.f.name)){
|
}else if(md && /\.pdf$/i.test(x.f.name)){
|
||||||
x.pct=70; pintaFolha();
|
x.pct=70; pintaFolha();
|
||||||
rasterizarPdf(x.f, md.larg, md.alt).then(r=>{
|
rasterizarPdf(x.f, md.larg, md.alt).then(r=>{
|
||||||
@@ -90,6 +98,7 @@ function sel(fs){
|
|||||||
: '');
|
: '');
|
||||||
}
|
}
|
||||||
if(r && r.tela){
|
if(r && r.tela){
|
||||||
|
x.miniSrc=miniatura(r.tela);
|
||||||
try{
|
try{
|
||||||
x.an=analisarFolha(r.tela, md.larg);
|
x.an=analisarFolha(r.tela, md.larg);
|
||||||
// vetor puro não tem resolução · nota máxima, e está certo
|
// vetor puro não tem resolução · nota máxima, e está certo
|
||||||
@@ -114,8 +123,30 @@ function sel(fs){
|
|||||||
Promise.all(novos.map(medir)).then(pintaArtes);
|
Promise.all(novos.map(medir)).then(pintaArtes);
|
||||||
}
|
}
|
||||||
function medir(a){
|
function medir(a){
|
||||||
|
// A large artwork is measured from its header and packed from a small copy
|
||||||
|
// read as a stream (previaPngGrande); the file itself is never decoded.
|
||||||
|
if(a.f.size>GRANDE_BYTES){
|
||||||
|
return dimensoesImagem(a.f).then(async d=>{
|
||||||
|
if(!d || !(d.w>0) || !(d.h>0)){ a.decodeError=true; return a; }
|
||||||
|
a.px=d.w; a.py=d.h; a.prop=d.h/d.w; a.grande=true;
|
||||||
|
// JPG and WebP cannot be read in parts: the art is placed by its
|
||||||
|
// measurements, as a full box, and the team checks it before printing.
|
||||||
|
if(!/\.png$/i.test(a.f.name)){ a.semPrevia=true; return a; }
|
||||||
|
a.lendo=0; a.leitura={inicio:Date.now(), p:0}; pintaArtes();
|
||||||
|
let mostrado=0;
|
||||||
|
const cv=await previaPngGrande(a.f, 1200, p=>{
|
||||||
|
const pct=Math.floor(p*100);
|
||||||
|
a.leitura.p=p;
|
||||||
|
if(pct-mostrado>=1){ mostrado=pct; a.lendo=pct; pintaProgressoArte(a); }
|
||||||
|
}).catch(()=>null);
|
||||||
|
a.lendo=null;
|
||||||
|
if(cv && artes.includes(a)){ a.src=cv.toDataURL('image/webp',0.9); a.miniSrc=miniatura(cv); }
|
||||||
|
else if(!cv) a.decodeError=true;
|
||||||
|
return a;
|
||||||
|
}).catch(()=>{ a.decodeError=true; return a; });
|
||||||
|
}
|
||||||
return carregarImagem(a.f).then(img=>{
|
return carregarImagem(a.f).then(img=>{
|
||||||
if(img){ a.prop=img.height/img.width; a.px=img.width; a.py=img.height; a.src=img.src; }
|
if(img){ a.prop=img.height/img.width; a.px=img.width; a.py=img.height; a.src=img.src; a.miniSrc=miniatura(img); }
|
||||||
else a.decodeError=true;
|
else a.decodeError=true;
|
||||||
return a;
|
return a;
|
||||||
});
|
});
|
||||||
|
|||||||
493
web/site-v2.css
Normal file
@@ -0,0 +1,493 @@
|
|||||||
|
/* Site DTF — redesign (2026-09). Loaded after the page's own styles and only
|
||||||
|
restyles: every element and class the scripts use keeps its meaning. The
|
||||||
|
previous look is in git tag ui-v1. */
|
||||||
|
:root{
|
||||||
|
--laranja:#FFA81A; --laranja2:#FF7F0A; --laranja-escuro:#C25E00;
|
||||||
|
--preto:#03060B; --texto2:#45484D; --fraco:#5C5F64; --linha:#E6E6E6; --cinza:#F9F9F9;
|
||||||
|
--verde:#1E7A45; --verde-ml:#1E7A45; --verde-fundo:#E3F5EA; --aviso-fundo:#FFF8EC; --aviso-linha:#F6D9A6;
|
||||||
|
--raio:14px; --raio2:10px;
|
||||||
|
}
|
||||||
|
body{background:#fff}
|
||||||
|
.w{max-width:1200px;padding:0 24px}
|
||||||
|
h1,h2,h3,h4{font-family:"Inter",sans-serif}
|
||||||
|
.et{font-family:"Inter",sans-serif;font-weight:800;font-size:15px;letter-spacing:.06em;text-transform:uppercase;color:var(--laranja-escuro)}
|
||||||
|
|
||||||
|
/* Pages (site-pages.js): the home, a product page and the cart show only their own parts. */
|
||||||
|
html[data-rota="inicio"] :is(#passos,#foco,#qual,#prev,#carr,.checkout,#carrVazio),
|
||||||
|
html[data-rota="produto"] :is(.hero2,.etapas2,#cards,#carr,.checkout,#carrVazio,#info),
|
||||||
|
html[data-rota="carrinho"] :is(.hero2,.etapas2,#cards,#foco,#qual,#prev,#info,.pagCab,.pagResumo){display:none!important}
|
||||||
|
html:is([data-rota="pagamento"],[data-rota="pix"]) :is(.hero2,.etapas2,#cards,#foco,#qual,#prev,#info,#carr,#carrVazio,#desfazer){display:none!important}
|
||||||
|
html[data-sem-itens] #carr{display:none!important}
|
||||||
|
html:not([data-rota="inicio"]) #envio{padding-top:0;padding-bottom:64px;min-height:70vh}
|
||||||
|
#envio,#foco,#qual,#prev,#checkoutStatus{scroll-margin-top:var(--topo-passos,128px)}
|
||||||
|
.carrVazio{max-width:520px;margin:48px auto 0;padding:0 16px;text-align:center;display:flex;flex-direction:column;align-items:center;gap:14px}
|
||||||
|
.carrVazio[hidden]{display:none}
|
||||||
|
.carrVazio h2{font-size:28px;font-weight:800;letter-spacing:-.02em}
|
||||||
|
.carrVazio p{color:var(--fraco);font-size:17.5px}
|
||||||
|
|
||||||
|
/* Hero */
|
||||||
|
.hero2{background:var(--preto);color:#fff;padding:64px 0}
|
||||||
|
.hero2-grid{display:grid;grid-template-columns:minmax(0,1.05fr) minmax(0,1fr);gap:56px;align-items:center}
|
||||||
|
.hero2-text{display:flex;flex-direction:column;gap:24px}
|
||||||
|
.chips{display:flex;gap:10px;flex-wrap:wrap}
|
||||||
|
.chip2{font-family:"Inter",sans-serif;font-weight:700;font-size:14.5px;letter-spacing:.04em;text-transform:uppercase;padding:6px 12px;border-radius:6px;border:1px solid #3A3D42;color:#E4E4E4}
|
||||||
|
.chip2.destaque{background:var(--laranja);border-color:var(--laranja);color:var(--preto);font-weight:800}
|
||||||
|
.hero2 h1{color:#fff;font-size:clamp(34px,4.6vw,58px);line-height:1.04;font-weight:900;letter-spacing:-.03em}
|
||||||
|
.hero2 h1 em{font-style:normal;color:var(--laranja)}
|
||||||
|
.hero2-text>p{font-size:19px;line-height:1.55;color:#C9CCD1;max-width:560px}
|
||||||
|
.hero2-cta{display:flex;align-items:center;gap:18px;flex-wrap:wrap}
|
||||||
|
.hero2-cta span{font-size:16px;color:#9EA3AA}
|
||||||
|
.btn-laranja{height:56px;padding:0 28px;border-radius:var(--raio2);background:var(--laranja);color:var(--preto);font-family:"Inter",sans-serif;font-weight:800;font-size:18px;display:inline-flex;align-items:center;gap:10px;text-decoration:none}
|
||||||
|
.btn-laranja:hover{background:#FFB53D}
|
||||||
|
.hero2-folha{background:#14171B;border:1px solid #262A2F;border-radius:18px;padding:22px;display:flex;flex-direction:column;gap:14px}
|
||||||
|
.hf-cab{display:flex;align-items:baseline;gap:10px}
|
||||||
|
.hf-cab b{font-family:"Inter",sans-serif;font-size:16.5px}
|
||||||
|
.hf-cab span{margin-left:auto;font-size:15px;color:#9EA3AA}
|
||||||
|
.hf-area{position:relative;height:260px;border-radius:10px;background:#0B0D10;border:1px dashed #3A3D42;overflow:hidden}
|
||||||
|
.hf-area i{position:absolute;border-radius:6px;opacity:.88}
|
||||||
|
.hf-num{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}
|
||||||
|
.hf-num span{display:block;font-size:14.5px;color:#9EA3AA}
|
||||||
|
.hf-num b{font-family:"Inter",sans-serif;font-size:19px}
|
||||||
|
.hf-num .verde{color:#57D68A}.hf-num .laranja{color:var(--laranja)}
|
||||||
|
|
||||||
|
/* Steps strip */
|
||||||
|
.etapas2{padding:32px 0;border-bottom:1px solid var(--linha)}
|
||||||
|
.etapas2 .w{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:24px}
|
||||||
|
.etapas2 .w>div{display:flex;gap:14px;align-items:flex-start}
|
||||||
|
.etapas2 span{width:36px;height:36px;flex-shrink:0;border-radius:999px;background:#FFF1DB;color:var(--laranja-escuro);font-family:"Inter",sans-serif;font-weight:800;display:flex;align-items:center;justify-content:center}
|
||||||
|
.etapas2 b{font-family:"Inter",sans-serif;font-size:17px}
|
||||||
|
.etapas2 p{margin-top:3px;font-size:16px;color:var(--fraco)}
|
||||||
|
section+section{border-top:0}
|
||||||
|
|
||||||
|
/* Product chooser */
|
||||||
|
#envio{padding-top:56px}
|
||||||
|
.cards-cab{display:flex;align-items:flex-end;gap:24px;flex-wrap:wrap}
|
||||||
|
.cards-cab h2{margin-top:8px;font-size:clamp(28px,3vw,38px);font-weight:800;letter-spacing:-.02em}
|
||||||
|
.cards-cab p{margin-left:auto;max-width:440px;font-size:16.5px;color:var(--fraco)}
|
||||||
|
.escolha{grid-template-columns:repeat(2,minmax(0,1fr));gap:20px;margin-top:28px}
|
||||||
|
.ec{text-align:left;padding:26px;gap:14px;border:1px solid var(--linha);border-radius:16px;box-shadow:none;cursor:pointer;font:inherit;color:inherit}
|
||||||
|
.ec::before,.ec::after{display:none}
|
||||||
|
.ec:hover{border-color:var(--laranja);box-shadow:0 10px 30px rgba(3,6,11,.08);transform:translateY(-2px)}
|
||||||
|
.ec.f,.ec.f:hover{border-color:var(--linha)}
|
||||||
|
.ec.f:hover{border-color:var(--laranja)}
|
||||||
|
.ec-top{display:flex;align-items:center;gap:10px}
|
||||||
|
.ec-kind{font-family:"Inter",sans-serif;font-weight:700;font-size:14.5px;letter-spacing:.05em;color:#707070}
|
||||||
|
.marc2{margin-left:auto;font-family:"Inter",sans-serif;font-weight:800;font-size:13.5px;letter-spacing:.05em;text-transform:uppercase;padding:4px 10px;border-radius:999px;background:var(--preto);color:var(--laranja)}
|
||||||
|
.ec h3{font-size:25px;font-weight:800;letter-spacing:-.01em;color:var(--preto)}
|
||||||
|
.ec>p{font-size:16.5px;line-height:1.5;color:var(--texto2);flex:1}
|
||||||
|
.ec-pe{display:flex;align-items:flex-end;gap:12px;padding-top:14px;border-top:1px solid #EFEFEF}
|
||||||
|
.ec-preco{display:flex;flex-direction:column;gap:2px}
|
||||||
|
.ec-preco span{font-size:15px;color:#707070}
|
||||||
|
.ec-preco em{font-style:normal}
|
||||||
|
.ec-preco b{font-family:"Inter",sans-serif;font-size:29px;font-weight:900;letter-spacing:-.02em;color:var(--preto)}
|
||||||
|
.ec-go{margin-left:auto;height:46px;padding:0 18px;border-radius:var(--raio2);background:var(--preto);color:#fff;font-family:"Inter",sans-serif;font-weight:700;font-size:16.5px;display:flex;align-items:center;white-space:nowrap}
|
||||||
|
.ec:hover .ec-go{background:var(--laranja);color:var(--preto)}
|
||||||
|
|
||||||
|
/* Progress bar */
|
||||||
|
.passos{position:sticky;top:var(--topo,64px);z-index:30;display:flex;align-items:center;justify-content:center;gap:14px;margin:0 0 22px;padding:14px 16px;background:rgba(255,255,255,.96);backdrop-filter:blur(6px);border-bottom:1px solid var(--linha);font-family:"Inter",sans-serif;font-size:16px}
|
||||||
|
.passos[hidden]{display:none}
|
||||||
|
.passos a{display:flex;align-items:center;gap:8px;color:#707070;font-weight:600;text-decoration:none}
|
||||||
|
.passos a span{width:26px;height:26px;border-radius:999px;border:2px solid #D0D0D0;display:flex;align-items:center;justify-content:center;font-size:15px}
|
||||||
|
.passos a.atual{color:var(--preto);font-weight:800}
|
||||||
|
.passos a.atual span{background:var(--laranja);border-color:var(--laranja)}
|
||||||
|
.passos a.feito{color:var(--verde)}
|
||||||
|
.passos a.feito span{background:var(--verde-fundo);border-color:var(--verde-fundo);color:var(--verde)}
|
||||||
|
.passos i{width:48px;height:2px;background:#E0E0E0}
|
||||||
|
|
||||||
|
/* Product page (Montagem): artworks on the left; on the right a buy box that
|
||||||
|
stays in view with the live sheet, the grade, the price, the total and the
|
||||||
|
add-to-cart button. The old quality and preview panels are not shown: their
|
||||||
|
parts live in the buy box, and the rest repeated it (the findings list too:
|
||||||
|
each row already says what was checked). */
|
||||||
|
#qual,#prev,#rA,.qb2,#qitens{display:none!important}
|
||||||
|
.voltar{height:auto;padding:0;border:0;background:none;box-shadow:none;font-size:16px;font-weight:500;color:var(--fraco);margin:24px 0 14px}
|
||||||
|
.voltar:hover{color:var(--laranja-escuro)}
|
||||||
|
.focoGrid{grid-template-columns:minmax(0,1fr);gap:20px}
|
||||||
|
.cat,.cat.file{border:0;background:transparent;box-shadow:none;overflow:visible}
|
||||||
|
.cabfoco,.cat.file .cabfoco{padding:0 0 24px!important;background:transparent;border:0;display:flex;flex-direction:column;gap:20px}
|
||||||
|
.cabfoco>div:first-child{display:flex;flex-direction:column-reverse;gap:4px}
|
||||||
|
.cabfoco .lg{font-size:15px;font-weight:800;color:var(--laranja-escuro);letter-spacing:.06em;text-transform:uppercase}
|
||||||
|
.cabfoco h3{font-size:36px;font-weight:800;letter-spacing:-.02em;line-height:1.1}
|
||||||
|
.tipoEnvio{margin:0}
|
||||||
|
.tipoEnvio:not([hidden]){display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;max-width:calc(100% - 452px)}
|
||||||
|
.tipoEnvio .cam,.tipoEnvio .cam.on{padding:14px 16px;min-height:0;border-radius:12px;border:1px solid var(--linha);background:#F9F9F9;box-shadow:none;align-items:flex-start}
|
||||||
|
.tipoEnvio .cam.on{padding:13px 15px;border:2px solid var(--preto);background:#fff}
|
||||||
|
.tipoEnvio .cam .mk2{display:none}
|
||||||
|
.tipoEnvio .cam div{display:grid;grid-template-columns:minmax(0,1fr) auto;column-gap:12px;row-gap:4px;align-items:baseline}
|
||||||
|
.tipoEnvio .cam b{margin:0;font-size:16.5px;font-weight:700;color:var(--texto2)}
|
||||||
|
.tipoEnvio .cam.on b{color:var(--preto);font-weight:800}
|
||||||
|
.tipoEnvio .cam em{margin:0;font-size:15px;font-weight:500;color:var(--fraco);white-space:nowrap}
|
||||||
|
.tipoEnvio .cam.on em{font-weight:600;color:var(--preto)}
|
||||||
|
.tipoEnvio .cam div>span{grid-column:1/-1;font-size:15px;color:var(--fraco);line-height:1.4}
|
||||||
|
.tipoEnvio .cam.on div>span{color:var(--texto2)}
|
||||||
|
.tipoEnvio .cam:disabled{cursor:default}
|
||||||
|
.tipoEnvio .cam:disabled:not(.on){opacity:.55}
|
||||||
|
@media(max-width:1100px){.tipoEnvio:not([hidden]){max-width:none}}
|
||||||
|
.cat .in{padding:0!important}
|
||||||
|
.foco2{grid-template-columns:minmax(0,1fr) 420px;gap:32px}
|
||||||
|
.fila{display:flex;flex-direction:column;gap:12px}
|
||||||
|
.fila>*{margin:0}
|
||||||
|
.cam{border-radius:var(--raio2)}
|
||||||
|
.cam.on{border-color:var(--laranja);background:var(--aviso-fundo)}
|
||||||
|
#larguraFolhaPronta{display:inline;font-size:inherit;color:inherit}
|
||||||
|
.zona{display:grid;grid-template-columns:44px minmax(0,1fr);column-gap:16px;row-gap:2px;align-items:center;text-align:left;padding:16px 20px;margin:0;border:2px dashed #F2B25A;background:var(--aviso-fundo);border-radius:var(--raio)}
|
||||||
|
.zona .ico{grid-row:1/4;display:flex;align-items:center;justify-content:center;width:44px;height:44px;line-height:1;margin:0;border-radius:10px;background:var(--laranja);color:var(--preto);font-size:20px;box-shadow:none}
|
||||||
|
.zona b{font-size:16.5px;margin:0}
|
||||||
|
.zona span{max-width:none;margin:0;font-size:15px;color:var(--fraco)}
|
||||||
|
.zona .bt{display:none}
|
||||||
|
.zona:hover,.zona.sobre{border-color:var(--laranja2);box-shadow:none}
|
||||||
|
.zona .zManual{grid-column:1/-1;margin-top:12px;padding-top:12px;border-top:1px solid var(--aviso-linha);color:var(--texto2);line-height:1.5}
|
||||||
|
.zona .zManual[hidden]{display:none}
|
||||||
|
.zona .zManual b{display:inline;font-size:15px}
|
||||||
|
.zona .zOutra{display:none}
|
||||||
|
.zona.compacta{display:flex;justify-content:center;align-items:center;min-height:56px;padding:0 20px;border:1.5px dashed #D0D0D0;background:#fff;cursor:pointer}
|
||||||
|
.zona.compacta>*{display:none}
|
||||||
|
.zona.compacta .zOutra{display:inline;font-size:16px;color:var(--fraco)}
|
||||||
|
.zona.compacta .zOutra b{display:inline;font-size:16px;font-weight:600;color:var(--preto)}
|
||||||
|
.zona.compacta .zOutra b::before{content:"+";margin-right:8px;font-weight:700}
|
||||||
|
.zona.compacta:hover,.zona.compacta.sobre{border-color:var(--laranja2)}
|
||||||
|
#lista{display:flex;flex-direction:column;gap:10px}
|
||||||
|
#lista:empty{display:none}
|
||||||
|
.art{margin:0;padding:14px 16px;border-radius:12px;background:#fff;border:1px solid var(--linha)}
|
||||||
|
.art.nova{background:#fff;box-shadow:0 0 0 2px rgba(255,168,26,.45)}
|
||||||
|
.art .l1 b{font-size:16.5px;font-weight:700}
|
||||||
|
.art label{font-size:14.5px}
|
||||||
|
.art input{height:42px;font-size:16.5px;font-weight:600;border-radius:8px}
|
||||||
|
.art .cps{grid-template-columns:repeat(2,minmax(0,160px))}
|
||||||
|
.listaCab{display:flex;justify-content:space-between;align-items:baseline;gap:12px}
|
||||||
|
.listaCab h4{margin:0;font-size:19px;font-weight:700}
|
||||||
|
.listaCab span{font-size:15px;color:var(--fraco)}
|
||||||
|
.rep{margin:0;padding:20px;border-radius:14px;background:#fff;border:1px solid var(--linha);display:flex;flex-direction:column;gap:16px}
|
||||||
|
.repTopo{display:flex;gap:16px;align-items:flex-start}
|
||||||
|
.miniF{flex:none;width:52px;height:96px;border-radius:6px;border:1px solid #D6D6D6;background:#F4F4F2;display:flex;align-items:center;justify-content:center;overflow:hidden}
|
||||||
|
.miniF img{width:100%;height:100%;object-fit:contain;display:block}
|
||||||
|
.miniF span{font-size:13.5px;font-weight:800;color:var(--texto2)}
|
||||||
|
.repInfo{flex:1;min-width:0;display:flex;flex-direction:column;gap:6px}
|
||||||
|
.rep .l1{margin:0}
|
||||||
|
.rep .l1 b{font-size:16px;font-weight:600;color:var(--preto)}
|
||||||
|
.rep .l1 button{width:32px;height:32px;margin:-6px -6px -6px 0;border-radius:8px;font-size:19px}
|
||||||
|
.rep .l1 button:hover{background:#F1F1F1;color:var(--preto)}
|
||||||
|
.repMed{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap}
|
||||||
|
.repMed b{font-family:"Inter",sans-serif;font-size:22px;font-weight:700;letter-spacing:-.01em}
|
||||||
|
.repMed span,.repPeso{font-size:15px;color:var(--fraco)}
|
||||||
|
.chips{list-style:none;margin:4px 0 0;padding:0;display:flex;flex-wrap:wrap;gap:8px}
|
||||||
|
.chip{display:inline-flex;align-items:center;gap:6px;padding:5px 10px;border-radius:999px;font-size:15px;font-weight:600;line-height:1.2}
|
||||||
|
.chip::before{font-weight:800}
|
||||||
|
.chip.ok{background:var(--verde-fundo);color:var(--verde)}.chip.ok::before{content:"✓"}
|
||||||
|
.chip.av{background:#FFF1DB;color:#8A4B00}.chip.av::before{content:"!"}
|
||||||
|
.chip.er{background:#FDE8E6;color:#B3261E}.chip.er::before{content:"×"}
|
||||||
|
.chip.fix{background:#E8F0FB;color:#1F4E8C}.chip.fix::before{content:"✓"}
|
||||||
|
.chip.nt{background:#F1F1F1;color:var(--texto2)}
|
||||||
|
.chipSo{align-self:flex-start;padding:5px 10px;border-radius:999px;background:#F1F1F1;color:var(--texto2);font-size:15px;font-weight:600}
|
||||||
|
.avisoF{margin:4px 0 0;padding:10px 12px;border-radius:10px;background:#FDE8E6;font-size:15px;line-height:1.5;color:var(--preto)}
|
||||||
|
.avisoF b{color:#B3261E}
|
||||||
|
.repTxt{margin:2px 0 0;font-size:15.5px;line-height:1.5;color:var(--texto2)}
|
||||||
|
.repTxt b{color:var(--preto)}
|
||||||
|
.confDet{font-size:15px}
|
||||||
|
.confDet summary{display:inline;cursor:pointer;list-style:none;color:var(--laranja-escuro);font-weight:500}
|
||||||
|
.confDet summary::-webkit-details-marker{display:none}
|
||||||
|
.confDet summary:hover{text-decoration:underline}
|
||||||
|
.confDet[open] summary{display:inline-block;margin-bottom:6px}
|
||||||
|
.confDet span{display:block;color:var(--texto2);line-height:1.6}
|
||||||
|
.rep .cps{display:flex;align-items:center;gap:16px 24px;flex-wrap:wrap;margin:0;padding-top:16px;border-top:1px solid #EFEFEF}
|
||||||
|
.rep .campo{display:flex;align-items:center;gap:12px}
|
||||||
|
.rep .campo label{margin:0;font-size:16px;font-weight:600;color:var(--preto)}
|
||||||
|
.passo{display:inline-flex;border:1px solid #D6D6D6;border-radius:10px;overflow:hidden}
|
||||||
|
.passo button{width:44px;height:44px;border:0;background:#F9F9F9;font-size:19px;color:var(--preto);cursor:pointer}
|
||||||
|
.passo button:hover{background:#EFEFEF}
|
||||||
|
.rep .passo input{width:56px;height:44px;padding:0;border:0;border-radius:0;text-align:center;font-size:17.5px;font-weight:700;-moz-appearance:textfield;appearance:textfield}
|
||||||
|
.passo input::-webkit-inner-spin-button,.passo input::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}
|
||||||
|
.rep .un{display:inline-flex;align-items:center;gap:6px;height:44px;padding:0 12px;border:1px solid #D6D6D6;border-radius:10px}
|
||||||
|
.rep .un input{width:64px;padding:0;border:0;font-size:17.5px;font-weight:700;text-align:right}
|
||||||
|
.rep .un i{font-style:normal;font-size:16px;color:var(--fraco)}
|
||||||
|
.rep .conta{margin-left:auto;font-size:16px;color:var(--texto2)}
|
||||||
|
.rep .conta b{font-family:"Inter",sans-serif;font-size:17.5px;color:var(--preto)}
|
||||||
|
.calc{font-size:15px;margin-top:8px}
|
||||||
|
.dica{display:flex;align-items:center;gap:12px;margin-top:10px;padding:10px 12px;border:0;border-radius:8px;background:var(--verde-fundo);font-size:15px;color:#14532D}
|
||||||
|
.dica.av{background:var(--aviso-fundo);color:#5A3A00}
|
||||||
|
.dica button{display:inline-flex;width:auto;margin:0 0 0 auto;flex:none;height:34px;align-items:center;padding:0 12px;border-radius:7px;border:1px solid var(--verde);background:#fff;color:var(--verde);box-shadow:none;font-size:15px}
|
||||||
|
.dica button:hover{background:var(--verde);color:#fff;box-shadow:none}
|
||||||
|
.req{position:static;padding:8px 0 0;border:0;border-radius:0;background:none;box-shadow:none;display:flex;flex-wrap:wrap;align-items:baseline;gap:8px 20px}
|
||||||
|
.reqcab{margin:0;font-size:15px;font-weight:400;color:var(--fraco)}
|
||||||
|
.req details,.req details:first-of-type{border:0}
|
||||||
|
.reqLinks{display:contents}
|
||||||
|
.reqLinks button{padding:0;border:0;background:none;font:inherit;font-size:15px;font-weight:500;color:var(--laranja-escuro);cursor:pointer}
|
||||||
|
.reqLinks button:hover{text-decoration:underline}
|
||||||
|
.reqLinks button.on{font-weight:700;color:var(--preto);text-decoration:underline;text-underline-offset:4px;text-decoration-color:var(--laranja)}
|
||||||
|
/* The links above open and close the answers; a closed one is not shown. */
|
||||||
|
.req details:not([open]){display:none}
|
||||||
|
.req summary{display:inline;padding:0;font-size:15px;font-weight:500;color:var(--laranja-escuro)}
|
||||||
|
.req summary::before{display:none}
|
||||||
|
.req details[open]{order:1;flex-basis:100%;padding:14px 16px 4px;border-radius:12px;background:var(--cinza)}
|
||||||
|
.req details[open] summary{display:block;margin-bottom:6px;font-size:16px;font-weight:700;color:var(--preto);text-decoration:none}
|
||||||
|
.req li{font-size:15px}
|
||||||
|
.req .obs2{font-size:14.5px}
|
||||||
|
|
||||||
|
/* The buy box */
|
||||||
|
.mont{position:sticky;top:var(--topo-passos,128px);padding:20px;border-radius:16px;background:#fff;box-shadow:none;display:flex;flex-direction:column;gap:12px}
|
||||||
|
.mont>*{margin:0}
|
||||||
|
.montcab b{font-size:18px;font-weight:800}
|
||||||
|
.montpe{font-size:14.5px;text-align:center}
|
||||||
|
.compra{display:flex;flex-direction:column;gap:12px;padding-top:16px;border-top:1px solid #EFEFEF}
|
||||||
|
.compra .aoVivo{margin:0}
|
||||||
|
.compra .aoVivo.pronto{display:none}
|
||||||
|
#compraVals{display:flex;flex-direction:column;gap:10px;font-size:16.5px}
|
||||||
|
#compraVals[hidden]{display:none}
|
||||||
|
.cat.file .zoomB{display:none}
|
||||||
|
.ampliar{margin-left:auto;height:32px;padding:0 12px;border-radius:8px;border:1px solid #D6D6D6;background:#fff;font-size:15px;font-weight:600;color:var(--preto);cursor:pointer}
|
||||||
|
.ampliar:hover{border-color:var(--preto)}
|
||||||
|
.ampliar[hidden]{display:none}
|
||||||
|
.cat.file #vArea{max-height:340px}
|
||||||
|
.cEscada{display:flex;flex-direction:column;gap:8px;margin-bottom:4px}
|
||||||
|
.cEscada>span{font-size:15px;font-weight:600}
|
||||||
|
.cEscada ol{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:4px}
|
||||||
|
.cEscada li{padding:8px 2px;border-radius:8px;background:#F4F4F2;border:1.5px solid transparent;display:flex;flex-direction:column;align-items:center;gap:2px}
|
||||||
|
.cEscada li span{font-size:13.5px;color:var(--fraco);white-space:nowrap}
|
||||||
|
.cEscada li b{font-size:15px;font-weight:500;color:var(--texto2)}
|
||||||
|
.cEscada li.on{background:var(--verde-fundo);border-color:var(--verde)}
|
||||||
|
.cEscada li.on span,.cEscada li.on b{font-weight:700;color:var(--verde)}
|
||||||
|
.cEscada li.on:last-child{background:#F1F1F1;border-color:var(--preto)}
|
||||||
|
.cEscada li.on:last-child span,.cEscada li.on:last-child b{color:var(--preto)}
|
||||||
|
.cLin>span:first-child{color:var(--texto2)}
|
||||||
|
.cLin small{font-size:14.5px;color:var(--fraco)}
|
||||||
|
.cEco{margin:-6px 0 0;text-align:right;font-size:15px;font-weight:600;color:var(--verde)}
|
||||||
|
.cEco:empty{display:none}
|
||||||
|
#cPartes{display:block}
|
||||||
|
.ampliaDlg{width:min(960px,calc(100vw - 32px));max-height:calc(100vh - 48px);padding:0;border:0;border-radius:16px;box-shadow:0 20px 60px rgba(3,6,11,.3)}
|
||||||
|
.ampliaDlg::backdrop{background:rgba(3,6,11,.55)}
|
||||||
|
.ampliaCab{position:sticky;top:0;z-index:1;display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--linha);background:#fff}
|
||||||
|
.ampliaCab b{font-size:17.5px;font-weight:800}
|
||||||
|
.ampliaCab button{width:40px;height:40px;border:0;border-radius:8px;background:#F1F1F1;font-size:20px;cursor:pointer}
|
||||||
|
#ampliaArea .folhaPrevias{display:grid;gap:16px;padding:16px;background:#F8F9FA}
|
||||||
|
#ampliaArea .folhaPrevia{margin:0;background:#fff;border:1px solid var(--linha);border-radius:8px;overflow:hidden}
|
||||||
|
#ampliaArea .folhaPilha{display:flex;flex-direction:column;align-items:center;gap:4px}
|
||||||
|
#ampliaArea .folhaPilha img{display:block;width:auto;max-width:100%;height:auto;max-height:calc(100vh - 180px)!important}
|
||||||
|
#ampliaArea figcaption{padding:8px 12px;font-size:15px;color:var(--texto2);border-top:1px solid var(--linha)}
|
||||||
|
.cLin{display:flex;align-items:baseline;gap:8px}
|
||||||
|
.cLin>span:last-child,.cLin>b{margin-left:auto;display:flex;gap:8px;align-items:baseline}
|
||||||
|
.cLin s{font-size:15px;color:#707070}
|
||||||
|
.cObs{margin:-4px 0 0;font-size:15px;color:var(--verde)}
|
||||||
|
.cObs:empty{display:none}
|
||||||
|
.cTot{display:flex;align-items:baseline;padding-top:12px;border-top:1px solid #EFEFEF}
|
||||||
|
.cTot span{font-family:"Inter",sans-serif;font-weight:700}
|
||||||
|
.cTot b{margin-left:auto;font-family:"Inter",sans-serif;font-size:30px;font-weight:900;letter-spacing:-.01em}
|
||||||
|
#qmsg{font-size:15px;line-height:1.45}
|
||||||
|
.compra .ciente{margin:0;padding:12px 14px}
|
||||||
|
.compra .ciente h4{font-size:15.5px}
|
||||||
|
.compra .ciente p,.compra .ciente .lista{font-size:15px}
|
||||||
|
.cAdd{height:54px;border-radius:var(--raio2);border:0;background:var(--laranja);color:var(--preto);font-family:"Inter",sans-serif;font-weight:800;font-size:17.5px;cursor:pointer}
|
||||||
|
.cAdd:hover:not(:disabled){background:#FFB53D}
|
||||||
|
.cAdd:disabled{background:#EDEDED;color:#8A8D92;cursor:not-allowed}
|
||||||
|
.cPe{margin:0;font-size:15px;color:var(--fraco);text-align:center}
|
||||||
|
@media(max-width:1100px){.foco2{grid-template-columns:1fr}.mont{position:static}}
|
||||||
|
@media(max-width:620px){.montcab b{font-size:16.5px}
|
||||||
|
.cabfoco h3{font-size:28px}
|
||||||
|
.tipoEnvio .cam div{grid-template-columns:1fr}
|
||||||
|
.tipoEnvio .cam div>span{display:none}
|
||||||
|
.rep{padding:16px}
|
||||||
|
.repTopo{gap:12px}
|
||||||
|
.miniF{width:40px;height:80px}
|
||||||
|
.repMed b{font-size:20px}
|
||||||
|
.rep .conta{margin-left:0}}
|
||||||
|
|
||||||
|
/* The quality, preview, cart and checkout panels sit directly in #envio,
|
||||||
|
outside .w; they share the page column so nothing runs edge to edge. */
|
||||||
|
#envio>.qual,#envio>.prev,#envio>.carr,#envio>.checkout{width:min(1152px,calc(100% - 48px));margin-left:auto;margin-right:auto}
|
||||||
|
@media(max-width:820px){#envio>.qual,#envio>.prev,#envio>.carr,#envio>.checkout{width:calc(100% - 32px)}}
|
||||||
|
|
||||||
|
/* Cart */
|
||||||
|
.itensCab{display:flex;align-items:baseline;gap:12px;margin-bottom:11px}
|
||||||
|
.itensCab h4{margin:0}
|
||||||
|
.esvaziar{margin-left:auto;border:0;background:none;padding:6px 0;font-size:15.5px;font-weight:600;color:var(--fraco);cursor:pointer;text-decoration:underline;text-underline-offset:3px}
|
||||||
|
.esvaziar:hover{color:#B8391C}
|
||||||
|
.esvaziar[hidden]{display:none}
|
||||||
|
.item{align-items:center}
|
||||||
|
.item .marca{flex:none;display:flex;align-items:center;cursor:pointer}
|
||||||
|
.item .marca input{width:20px;height:20px;margin:0;accent-color:var(--laranja);cursor:pointer}
|
||||||
|
.miniItem{position:relative;flex:none;width:52px;height:52px;border-radius:8px;border:1px solid var(--linha);background:repeating-conic-gradient(#F1F1F1 0 25%,#fff 0 50%) 0 0/12px 12px;display:flex;align-items:center;justify-content:center;overflow:hidden}
|
||||||
|
.miniItem img{max-width:100%;max-height:100%;object-fit:contain}
|
||||||
|
.miniItem i{font-style:normal;font-size:12px;font-weight:700;color:var(--fraco)}
|
||||||
|
.miniItem em{position:absolute;right:2px;bottom:2px;padding:0 4px;border-radius:5px;background:rgba(3,6,11,.75);color:#fff;font-style:normal;font-size:11px;font-weight:700}
|
||||||
|
.item.fora .miniItem,.item.fora .nm2,.item.fora .vl2{opacity:.45}
|
||||||
|
.item .x{flex:none;height:36px;padding:0 12px;border-radius:8px;border:1px solid #D6D6D6;background:#fff;color:var(--texto2);font-size:15px;font-weight:600;font-family:inherit;cursor:pointer}
|
||||||
|
.item .x:hover{border-color:#B8391C;color:#B8391C}
|
||||||
|
.desfazer{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:80;display:flex;align-items:center;gap:16px;padding:12px 14px 12px 18px;border-radius:12px;background:var(--preto);color:#fff;font-size:16px;box-shadow:0 10px 30px rgba(3,6,11,.25);max-width:calc(100% - 32px);box-sizing:border-box}
|
||||||
|
.desfazer[hidden]{display:none}
|
||||||
|
.desfazer button{flex:none;height:36px;padding:0 14px;border-radius:8px;border:0;background:var(--laranja);color:var(--preto);font-family:"Inter",sans-serif;font-weight:800;font-size:16px;cursor:pointer}
|
||||||
|
@media(max-width:820px){.desfazer{bottom:100px}}
|
||||||
|
.carrAviso{grid-column:1/-1;font-size:15px;color:var(--fraco)}
|
||||||
|
.carrAviso:empty{display:none}
|
||||||
|
.carr{border-radius:16px}
|
||||||
|
.carr h3{font-size:24px;font-weight:800;letter-spacing:-.02em}
|
||||||
|
.cxE{border-radius:var(--raio);border-color:var(--linha)}
|
||||||
|
.cxE h4,.resumo h4{font-family:"Inter",sans-serif;font-weight:800;font-size:18px}
|
||||||
|
.cp input,.cepL input{height:46px;border-radius:var(--raio2)}
|
||||||
|
.cp input:focus,.cepL input:focus{border-color:var(--laranja2);box-shadow:0 0 0 3px rgba(255,168,26,.18)}
|
||||||
|
.opE{border-radius:12px}
|
||||||
|
.opE.on{border:2px solid var(--laranja);background:var(--aviso-fundo)}
|
||||||
|
.resumo{position:sticky;top:var(--topo-passos,128px);border-radius:16px}
|
||||||
|
.tot b{font-family:"Inter",sans-serif;font-weight:900}
|
||||||
|
#bPagar{height:auto;min-height:54px;padding:8px 14px;line-height:1.25;border-radius:var(--raio2);background:var(--laranja);color:var(--preto);border:0;font-family:"Inter",sans-serif;font-weight:800;font-size:17.5px}
|
||||||
|
#bPagar:hover:not(:disabled){background:#FFB53D}
|
||||||
|
#bPagar:disabled{background:#EDEDED;color:#8A8D92}
|
||||||
|
#bMais{height:46px;border-radius:var(--raio2);font-family:"Inter",sans-serif;font-weight:600}
|
||||||
|
.checkout{max-width:760px;margin:20px auto 0}
|
||||||
|
.checkout #checkoutStatus:not(:empty){padding:16px 18px;border-radius:var(--raio);background:var(--verde-fundo);border:1px solid #BFE6CE;color:#14532D;font-size:16.5px}
|
||||||
|
#checkoutActions{margin-top:12px}
|
||||||
|
/* Only our own fields: Mercado Pago's card form (its instalment radios) keeps its own styles. */
|
||||||
|
#checkoutActions .pixBox input{height:44px;border-radius:var(--raio2);border:1px solid #D6D6D6;padding:0 12px}
|
||||||
|
/* The payment pages: paying on the left, the order summary on the right */
|
||||||
|
html:is([data-rota="pagamento"],[data-rota="pix"]) .checkout{max-width:1040px;margin-top:8px}
|
||||||
|
html:is([data-rota="pagamento"],[data-rota="pix"]) .pagGrid{display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:32px;align-items:start}
|
||||||
|
.ic{width:21px;height:21px;flex:none;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;vertical-align:middle}
|
||||||
|
.acts a .ic{width:23px;height:23px}
|
||||||
|
.cartIc{position:relative;display:inline-flex}
|
||||||
|
.acts #cartLink{gap:11px}
|
||||||
|
.cartN{position:absolute;top:-7px;right:-9px;min-width:17px;height:17px;padding:0 4px;border-radius:9px;background:var(--laranja);color:var(--preto);font-size:13px;font-weight:800;line-height:17px;text-align:center}
|
||||||
|
.cartN[hidden]{display:none}
|
||||||
|
.zona .ico .ic{width:23px;height:23px}
|
||||||
|
.art2 .giros .gr{display:inline-flex;align-items:center;justify-content:center}
|
||||||
|
.art2 .giros .gr .ic{width:22px;height:22px}
|
||||||
|
.rep .l1 button{display:inline-flex;align-items:center;justify-content:center}
|
||||||
|
.rep .l1 button .ic{width:20px;height:20px}
|
||||||
|
#ampliaFecha .ic{width:23px;height:23px}
|
||||||
|
@media (max-width:600px){.acts .actTx{display:none}.acts{gap:16px}}
|
||||||
|
.cAdd,#bPagar,#pOk,#qOk{display:flex;align-items:center;justify-content:center;gap:8px}
|
||||||
|
#pOk{display:inline-flex}
|
||||||
|
.cAdd .ic,#bPagar .ic{width:22px;height:22px}
|
||||||
|
.item .x{display:inline-flex;align-items:center;gap:6px}
|
||||||
|
.item .x .ic{width:18px;height:18px}
|
||||||
|
.opE b .ic{width:19px;height:19px;margin:-2px 6px 0 0;color:var(--laranja-escuro)}
|
||||||
|
.metodo .ic{width:23px;height:23px;color:var(--texto2)}
|
||||||
|
.codigo .pagBtn{display:inline-flex;align-items:center;gap:7px}
|
||||||
|
.codigo .pagBtn .ic{width:20px;height:20px}
|
||||||
|
.pixBox .prazo{display:flex;align-items:center;gap:8px}
|
||||||
|
.pixBox .prazo .ic{width:23px;height:23px}
|
||||||
|
.confirmado h4{display:flex;align-items:center;gap:8px}
|
||||||
|
.confirmado h4 .ic{width:24px;height:24px}
|
||||||
|
.envioArq .ic{width:19px;height:19px;margin:-2px 6px 0 0}
|
||||||
|
.qi .c .ic{width:15px;height:15px;stroke-width:3}
|
||||||
|
.passos a span .ic{width:17px;height:17px;stroke-width:3}
|
||||||
|
.obr{color:#B42318;font-weight:700}
|
||||||
|
.envioNota{display:block;margin-top:4px;font-size:14px;color:var(--fraco)}
|
||||||
|
.envioArq{font-size:15px;color:var(--texto2);margin-top:14px;line-height:1.5}
|
||||||
|
.envioArq.ok{color:var(--verde)}
|
||||||
|
.envioArq.erro{color:#B42318}
|
||||||
|
.envioDeNovo{margin-left:4px;padding:0;border:0;background:none;color:inherit;font:inherit;font-weight:700;text-decoration:underline;cursor:pointer}
|
||||||
|
.envioBarra{height:6px;border-radius:999px;background:#EDEDED;overflow:hidden;margin-bottom:6px}
|
||||||
|
.envioBarra i{display:block;height:100%;background:var(--laranja);border-radius:999px;transition:width .4s}
|
||||||
|
.cartEnvio{font-size:15px;font-weight:700;color:var(--laranja-escuro);margin-left:6px}
|
||||||
|
.cartEnvio:empty{display:none}
|
||||||
|
.envioArq:empty{display:none}
|
||||||
|
.pagCab{display:flex;align-items:baseline;justify-content:space-between;gap:16px;margin-bottom:20px}
|
||||||
|
.pagCab h3{font-size:26px;font-weight:800;letter-spacing:-.02em}
|
||||||
|
.pagVolta{font-size:16px;color:var(--texto2)}
|
||||||
|
#checkoutActions > h4,#checkoutActions .pixBox > h4{font-size:18px;font-weight:800;margin-bottom:12px}
|
||||||
|
.pagNota{font-size:16px;color:var(--texto2);margin:4px 0 16px;line-height:1.5}
|
||||||
|
.pagResumo{position:sticky;top:var(--topo-passos,128px);border:1px solid var(--linha);border-radius:16px;background:#fff;padding:20px 22px}
|
||||||
|
.pagResumo h4{font-size:18px;font-weight:800;margin-bottom:10px}
|
||||||
|
.pagResumo .l{display:flex;justify-content:space-between;gap:12px;font-size:16px;padding:5px 0}
|
||||||
|
.pagResumo .l span{color:var(--texto2)}
|
||||||
|
.pagResumo .tot{display:flex;justify-content:space-between;align-items:baseline;border-top:1px solid #EFEFEF;margin-top:10px;padding-top:12px}
|
||||||
|
.pagResumo .tot b{font-family:"Inter",sans-serif;font-weight:900;font-size:24px}
|
||||||
|
.metodos{display:grid;grid-template-columns:1fr;gap:10px;max-width:420px;margin-bottom:20px}
|
||||||
|
.metodo{height:56px;display:flex;align-items:center;gap:12px;padding:0 16px;border:2px solid var(--linha);border-radius:var(--raio2);background:#fff;color:var(--preto);font-family:"Inter",sans-serif;font-weight:700;font-size:16.5px;text-align:left;cursor:pointer}
|
||||||
|
.metodo::before{content:"";width:18px;height:18px;flex-shrink:0;border-radius:50%;border:2px solid #B5B8BD;box-sizing:border-box}
|
||||||
|
.metodo[aria-checked="true"]{border-color:var(--laranja);background:var(--aviso-fundo)}
|
||||||
|
.metodo[aria-checked="true"]::before{border:6px solid var(--laranja)}
|
||||||
|
.pagBtn{height:48px;padding:0 24px;margin:0 10px 0 0;border-radius:var(--raio2);border:0;background:var(--laranja);color:var(--preto);font-family:"Inter",sans-serif;font-weight:800;font-size:16.5px;cursor:pointer}
|
||||||
|
.pagBtn:hover:not(:disabled){background:#FFB53D}
|
||||||
|
.pagBtn:disabled{background:#EDEDED;color:#8A8D92;cursor:default}
|
||||||
|
.pagBtn.sec{background:#fff;border:1px solid #D6D6D6}
|
||||||
|
.desafio{display:block;width:100%;max-width:520px;height:560px;border:1px solid var(--linha);border-radius:12px;background:#fff}
|
||||||
|
.pixBox{display:flex;flex-direction:column;align-items:flex-start;gap:12px}
|
||||||
|
.pixBox img{border:1px solid var(--linha);border-radius:12px;padding:8px;background:#fff}
|
||||||
|
.pixBox .codigo{display:flex;gap:10px;width:100%}
|
||||||
|
.pixBox .codigo input{flex:1;min-width:0;font-size:15px}
|
||||||
|
.pixBox .aguarda{font-size:16px;color:var(--texto2)}
|
||||||
|
.pixBox .prazo{font-family:"Inter",sans-serif;font-weight:800;font-size:19px;font-variant-numeric:tabular-nums}
|
||||||
|
.confirmado{border:1px solid #BFE6CE;background:var(--verde-fundo);border-radius:16px;padding:24px}
|
||||||
|
.confirmado h4{font-size:22px;color:#14532D;margin-bottom:8px}
|
||||||
|
.confirmado p{font-size:16.5px;color:#14532D;margin-bottom:18px;line-height:1.5}
|
||||||
|
@media(max-width:820px){
|
||||||
|
html:is([data-rota="pagamento"],[data-rota="pix"]) .pagGrid{grid-template-columns:1fr;gap:20px}
|
||||||
|
.pagResumo{position:static;order:-1}
|
||||||
|
.metodos{grid-template-columns:1fr}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Price and benefits */
|
||||||
|
.info2{background:var(--cinza);padding:64px 0}
|
||||||
|
.info2-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:56px;align-items:start}
|
||||||
|
.info2-preco{display:flex;flex-direction:column;gap:16px}
|
||||||
|
.info2 h2{font-size:clamp(26px,3vw,34px);font-weight:800;letter-spacing:-.02em}
|
||||||
|
.info2-preco>p{font-size:16.5px;color:var(--texto2)}
|
||||||
|
.info2 table{border-collapse:collapse;background:#fff;border:1px solid var(--linha);border-radius:12px;overflow:hidden;font-size:16.5px}
|
||||||
|
.info2 th{text-align:left;background:var(--preto);color:#fff;font-family:"Inter",sans-serif;font-size:15px;padding:12px 18px}
|
||||||
|
.info2 td{padding:11px 18px;border-top:1px solid #EFEFEF}
|
||||||
|
.info2 td.melhor{font-weight:700;color:var(--verde)}
|
||||||
|
.info2 .nota2{font-size:15px;color:#707070}
|
||||||
|
.info2-vant{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}
|
||||||
|
.info2-vant>div{padding:22px;border-radius:var(--raio);background:#fff;border:1px solid var(--linha);display:flex;flex-direction:column;gap:6px}
|
||||||
|
.info2-vant b{font-family:"Inter",sans-serif;font-size:17.5px}
|
||||||
|
.info2-vant b.n{font-size:32px;font-weight:900;color:var(--laranja2)}
|
||||||
|
.info2-vant span{font-size:16px;color:var(--fraco)}
|
||||||
|
|
||||||
|
/* Phone total bar */
|
||||||
|
.barraM{display:none}
|
||||||
|
@media (max-width:820px){
|
||||||
|
.barraM:not([hidden]){display:flex;position:fixed;left:0;right:0;bottom:0;z-index:40;align-items:center;gap:12px;padding:12px 16px calc(12px + env(safe-area-inset-bottom));background:#fff;border-top:1px solid var(--linha);box-shadow:0 -6px 20px rgba(3,6,11,.08)}
|
||||||
|
.barraM div{display:flex;flex-direction:column}
|
||||||
|
.barraM span{font-size:14.5px;color:#707070}
|
||||||
|
.barraM b{font-family:"Inter",sans-serif;font-size:21px;font-weight:900}
|
||||||
|
.barraM a{margin-left:auto;height:50px;padding:0 22px;border-radius:var(--raio2);background:var(--laranja);color:var(--preto);font-family:"Inter",sans-serif;font-weight:800;font-size:17.5px;display:flex;align-items:center;text-decoration:none}
|
||||||
|
.w{padding:0 16px}
|
||||||
|
.hero2{padding:40px 0}
|
||||||
|
.hero2-grid,.info2-grid{grid-template-columns:1fr;gap:28px}
|
||||||
|
.etapas2 .w{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||||
|
.escolha{grid-template-columns:1fr}
|
||||||
|
.cards-cab p{margin-left:0}
|
||||||
|
.ec-pe{flex-wrap:wrap}
|
||||||
|
.ec-go{margin-left:0;width:100%;justify-content:center}
|
||||||
|
.focoGrid{grid-template-columns:1fr}
|
||||||
|
.mont,.resumo{position:static}
|
||||||
|
.passos{gap:8px;font-size:15px;margin:0 0 16px;padding:10px 16px}
|
||||||
|
.passos i{width:14px}
|
||||||
|
.passos a{flex-direction:column;gap:4px;text-align:center}
|
||||||
|
.info2-vant{grid-template-columns:1fr}
|
||||||
|
footer{padding-bottom:84px}
|
||||||
|
}
|
||||||
|
@media (max-width:480px){
|
||||||
|
.etapas2 .w{grid-template-columns:1fr}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skeleton placeholders where the page really waits */
|
||||||
|
.skel{position:relative;border-radius:8px;background:linear-gradient(90deg,#EEEFF1 25%,#F7F7F8 37%,#EEEFF1 63%);background-size:400% 100%;animation:skel 1.4s ease infinite}
|
||||||
|
@keyframes skel{0%{background-position:100% 50%}100%{background-position:0 50%}}
|
||||||
|
@media (prefers-reduced-motion:reduce){.skel{animation:none}}
|
||||||
|
.folhaSkel{display:flex;align-items:center;justify-content:center;max-height:520px;min-height:180px;width:100%}
|
||||||
|
.folhaSkel span{font-size:15px;color:var(--fraco);background:#fffc;padding:4px 10px;border-radius:999px}
|
||||||
|
#vArea .folhaPilha{display:flex;flex-direction:column;gap:4px;max-height:520px;overflow:auto;background:#fff}
|
||||||
|
#vArea .folhaPilha img{width:100%;height:auto;display:block}
|
||||||
|
.folhaMais{font-size:14.5px;color:var(--texto2);text-align:center;padding:8px}
|
||||||
|
.skelLinha{height:14px;margin:8px 0}
|
||||||
|
.skelLinha.curta{width:40%}
|
||||||
|
.skelBloco{height:160px;margin-top:8px}
|
||||||
|
|
||||||
|
/* Artes avulsas: the file card of a finished sheet, plus width, rotation and sizes */
|
||||||
|
.rep.nova{box-shadow:0 0 0 2px rgba(255,168,26,.45)}
|
||||||
|
.novaT{margin-left:8px;padding:2px 7px;border-radius:999px;background:#FFF1DB;color:#8A4B00;font-size:13.5px;font-style:normal;font-weight:800;text-transform:uppercase;letter-spacing:.04em}
|
||||||
|
.repMed .pede{font-size:17.5px;color:var(--texto2)}
|
||||||
|
.art2 .giros{display:inline-flex;gap:6px}
|
||||||
|
.art2 .giros .gr{width:44px;height:44px;border:1px solid #D6D6D6;border-radius:10px;background:#fff;font-size:17.5px;cursor:pointer}
|
||||||
|
.art2 .giros .gr.on{border-color:var(--laranja);background:var(--aviso-fundo)}
|
||||||
|
.art2 .atalhos{flex-basis:100%;display:flex;flex-wrap:wrap;gap:8px;margin:0}
|
||||||
|
.art2 .atalhos .tm{height:34px;padding:0 12px;border:1px solid #D6D6D6;border-radius:8px;background:#fff;font-size:15px;font-weight:600;cursor:pointer}
|
||||||
|
.art2 .atalhos .tm.on{border-color:var(--verde);background:var(--verde-fundo);color:var(--verde)}
|
||||||
|
.art2 .dica{margin-top:4px}
|
||||||
|
.rep .l1 .novaT{margin-right:auto}
|
||||||
|
|
||||||
|
/* On a computer the Site is drawn at 80%: the sizes above were chosen looking
|
||||||
|
at it that way, and at 100% everything felt too big. Phones and tablets
|
||||||
|
keep their own sizes. The zoom scales viewport units too, so the page's
|
||||||
|
full height is 125vh there (site-shell.css keeps the footer at the bottom). */
|
||||||
|
@media (min-width:1100px){html{zoom:.8}body{min-height:125vh}}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/* Shared direct multipart transport for customer originals/corrections and operator finals. */
|
/* Shared direct multipart transport for customer originals/corrections and operator finals. */
|
||||||
window.dtfUpload = async (file, {api, prefix='/uploads', startPath=prefix, progress=()=>{}, scope='guest', resume=true}) => {
|
window.dtfUpload = async (file, {api, prefix='/uploads', startPath=prefix, progress=()=>{}, onBytes=()=>{}, scope='guest', resume=true}) => {
|
||||||
const samples=new Blob([file.slice(0,65536),file.slice(Math.max(0,file.size-65536))]);
|
const samples=new Blob([file.slice(0,65536),file.slice(Math.max(0,file.size-65536))]);
|
||||||
const hash=Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',await samples.arrayBuffer())),b=>b.toString(16).padStart(2,'0')).join('');
|
const hash=Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',await samples.arrayBuffer())),b=>b.toString(16).padStart(2,'0')).join('');
|
||||||
const key='dtf-upload:'+JSON.stringify([scope,file.name,file.size,file.lastModified,hash]);
|
const key='dtf-upload:'+JSON.stringify([scope,file.name,file.size,file.lastModified,hash]);
|
||||||
@@ -7,7 +7,11 @@ window.dtfUpload = async (file, {api, prefix='/uploads', startPath=prefix, progr
|
|||||||
if(id){try{state=await api(prefix+'/'+id);}catch(error){if(![404,410].includes(error.status))throw error;id=null;}}
|
if(id){try{state=await api(prefix+'/'+id);}catch(error){if(![404,410].includes(error.status))throw error;id=null;}}
|
||||||
if(!id){state=await api(startPath,{name:file.name,size:file.size});id=state.id;if(resume)localStorage.setItem(key,id);}
|
if(!id){state=await api(startPath,{name:file.name,size:file.size});id=state.id;if(resume)localStorage.setItem(key,id);}
|
||||||
async function waitForScan(){
|
async function waitForScan(){
|
||||||
for(let attempt=0;attempt<150;attempt++){
|
onBytes(file.size);
|
||||||
|
// The antivirus streams the whole file: about a second per 2 MB, and at
|
||||||
|
// least two and a half minutes.
|
||||||
|
const attempts=Math.max(150,Math.ceil(file.size/2e6));
|
||||||
|
for(let attempt=0;attempt<attempts;attempt++){
|
||||||
const checked=await api(prefix+'/'+id);
|
const checked=await api(prefix+'/'+id);
|
||||||
if(checked.scan_state==='clean')return id;
|
if(checked.scan_state==='clean')return id;
|
||||||
if(['rejected','error'].includes(checked.scan_state))throw new Error(checked.scan_reason||'Arquivo bloqueado pela verificação de segurança.');
|
if(['rejected','error'].includes(checked.scan_state))throw new Error(checked.scan_reason||'Arquivo bloqueado pela verificação de segurança.');
|
||||||
@@ -18,12 +22,15 @@ window.dtfUpload = async (file, {api, prefix='/uploads', startPath=prefix, progr
|
|||||||
}
|
}
|
||||||
if(state.complete)return waitForScan();
|
if(state.complete)return waitForScan();
|
||||||
const done=new Set(state.parts||[]),size=state.part_bytes;
|
const done=new Set(state.parts||[]),size=state.part_bytes;
|
||||||
|
onBytes(Math.min(file.size,done.size*size));
|
||||||
for(let offset=0,part=1;offset<file.size;offset+=size,part++){
|
for(let offset=0,part=1;offset<file.size;offset+=size,part++){
|
||||||
if(done.has(part))continue;
|
if(done.has(part))continue;
|
||||||
progress('Enviando '+file.name+' · parte '+part+'/'+Math.ceil(file.size/size));
|
progress('Enviando '+file.name+' · parte '+part+'/'+Math.ceil(file.size/size));
|
||||||
const signed=await api(prefix+'/'+id+'/parts/'+part,{});
|
const signed=await api(prefix+'/'+id+'/parts/'+part,{});
|
||||||
const response=await fetch(signed.url,{method:'PUT',body:file.slice(offset,offset+size)});
|
const response=await fetch(signed.url,{method:'PUT',body:file.slice(offset,offset+size)});
|
||||||
if(!response.ok)throw new Error('Upload interrompido. Tente novamente para retomar.');
|
if(!response.ok)throw new Error('Upload interrompido. Tente novamente para retomar.');
|
||||||
|
done.add(part);
|
||||||
|
onBytes(Math.min(file.size,done.size*size));
|
||||||
}
|
}
|
||||||
await api(prefix+'/'+id+'/complete',{});
|
await api(prefix+'/'+id+'/complete',{});
|
||||||
return waitForScan();
|
return waitForScan();
|
||||||
|
|||||||