refactor: give the frontend its own directory and split the API into routers
The Site's page sat at the repository root while its scripts lived in local/static, a split with no reason behind it. They are together in web/ now, with the page as index.html, which is also what the image serves. app.py held the adapters, the configuration, the shared query helpers and nineteen routes; customer.py held fourteen more but could not import from it without a cycle, so it was wired by passing nine callables into install_routes. Configuration and shared helpers move to local/runtime.py, the rules for attaching artwork to an order move to local/artwork.py where a customer correction and an operator final-file set can share them, and the routes become seven routers under local/api. app.py is 48 lines that create the application, apply the middleware and include them. Routers import downwards only. Three faults came out of the extraction and are worth recording, because each passed a check that looked sufficient. ast reports a function's line at the def, so every decorator on the line above fell outside the extracted range: twelve routes and the security middleware were defined but never registered, and the files still imported and parsed cleanly. Names the old closure renamed on the way in, and a Jsonb import, were missing in three modules. A name-resolution pass over every new module found those; the route count matching the original exactly, 32, is what confirmed the first. The release gate's marker for the fake payment adapter pointed at app.py and the adapter moved to runtime.py, so the gate passed while the condition it guards was unchanged. That is the same silent decay 2.5 set out to fix. A test now asserts every marker still matches something in its file, so the next move fails loudly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
FROM python:3.12-slim@sha256:2f17fc044b579bab302c2e8054d3a686e2cb9a83de48e70534b94cd8ebbe06a9 AS policy
|
||||
WORKDIR /build
|
||||
COPY dtf-site.html /build/dtf-site.html
|
||||
COPY web /build/web
|
||||
COPY local /build/local
|
||||
RUN python local/compile_web.py
|
||||
|
||||
@@ -10,5 +10,5 @@ RUN apk upgrade --no-cache
|
||||
ENV WEB_INDEX=index.html
|
||||
COPY --from=policy /build/default.conf.template /etc/nginx/templates/default.conf.template
|
||||
ENV S3_PUBLIC_ENDPOINT=http://localhost:9000
|
||||
COPY dtf-site.html /usr/share/nginx/html/index.html
|
||||
COPY local/static/ /usr/share/nginx/html/
|
||||
COPY web/ /usr/share/nginx/html/
|
||||
|
||||
|
||||
2
local/api/__init__.py
Normal file
2
local/api/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""HTTP routers, one per resource. They import from local.runtime, never
|
||||
from each other or from local.app."""
|
||||
49
local/api/artwork.py
Normal file
49
local/api/artwork.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Operator artwork handling: final files, and the uploads that carry them.
|
||||
|
||||
These reuse the customer upload routes with the operator's derived identity, so
|
||||
one transport serves both and there is only one place where parts are signed.
|
||||
"""
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from .. import db
|
||||
from ..artwork import submit_files
|
||||
from ..auth import operator
|
||||
from ..models import ArtworkSubmission, UploadStart
|
||||
from ..runtime import file_rows, operator_identity
|
||||
from .uploads import begin_upload, complete_upload, part_url, upload_status
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post('/api/operator/orders/{oid}/uploads')
|
||||
def begin_final(oid: UUID, body: UploadStart, user=Depends(operator)):
|
||||
with db.connect() as c:
|
||||
row = c.execute('SELECT state FROM dtf_local.orders WHERE id=%s', (oid,)).fetchone()
|
||||
if not row: raise HTTPException(404, 'Order not found')
|
||||
if row['state'] not in ('rec','tra','cor'): raise HTTPException(409, 'Order is not in artwork review')
|
||||
return begin_upload(body, session_id=operator_identity(user))
|
||||
|
||||
@router.get('/api/operator/uploads/{uid}')
|
||||
def final_status(uid: UUID, user=Depends(operator)):
|
||||
return upload_status(uid,session_id=operator_identity(user))
|
||||
|
||||
@router.post('/api/operator/uploads/{uid}/parts/{number}')
|
||||
def final_part(uid: UUID, number: int, user=Depends(operator)):
|
||||
return part_url(uid,number,session_id=operator_identity(user))
|
||||
|
||||
@router.post('/api/operator/uploads/{uid}/complete')
|
||||
def final_complete(uid: UUID, user=Depends(operator)):
|
||||
return complete_upload(uid,session_id=operator_identity(user))
|
||||
|
||||
@router.get('/api/operator/orders/{oid}/files')
|
||||
def operator_files(oid: UUID, user=Depends(operator)):
|
||||
with db.connect() as c:
|
||||
return file_rows(c,oid)
|
||||
|
||||
@router.post('/api/operator/orders/{oid}/final-files')
|
||||
def final_files(oid: UUID, body: ArtworkSubmission, user=Depends(operator)):
|
||||
with db.connect() as c:
|
||||
order = c.execute('SELECT * FROM dtf_local.orders WHERE id=%s FOR UPDATE', (oid,)).fetchone()
|
||||
if not order: raise HTTPException(404, 'Order not found')
|
||||
return submit_files(c,order,body,operator_identity(user),'final',user)
|
||||
116
local/api/customer.py
Normal file
116
local/api/customer.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""Customer accounts, their orders, and the corrections they submit."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from psycopg.errors import UniqueViolation
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from .. import db
|
||||
from ..artwork import submit_files
|
||||
from ..auth import (DUMMY_PASSWORD_HASH, audit, client_ip, new_session, owner,
|
||||
password_hash, password_matches, session_row, throttle, transfer_guest)
|
||||
from ..models import ArtworkSubmission, Login, Register
|
||||
from ..runtime import STATES, file_rows, owned_order, storage
|
||||
from ..scanning import require_clean
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def current(request):
|
||||
try: return session_row(request)
|
||||
except HTTPException: return None
|
||||
|
||||
|
||||
@router.post('/api/account/register')
|
||||
def register(body: Register, request: Request, response: Response):
|
||||
email = body.customer.mail.strip().lower()
|
||||
throttle(email, request)
|
||||
previous = current(request)
|
||||
encoded = password_hash(body.password)
|
||||
identity = uuid4()
|
||||
profile = body.customer.model_dump()
|
||||
profile['mail'] = email
|
||||
try:
|
||||
with db.connect() as c:
|
||||
if previous and c.execute('SELECT id FROM dtf_local.accounts WHERE id=%s', (previous['owner'],)).fetchone():
|
||||
raise HTTPException(409, 'Sign out before registering another account')
|
||||
c.execute('INSERT INTO dtf_local.accounts(id,email,password_hash,profile) VALUES(%s,%s,%s,%s)', (identity,email,encoded,Jsonb(profile)))
|
||||
if previous: transfer_guest(c, previous, identity)
|
||||
new_session(c, response, identity)
|
||||
except UniqueViolation:
|
||||
raise HTTPException(409, 'An account already exists; sign in')
|
||||
audit('account_registered', account=str(identity))
|
||||
return {'customer': profile}
|
||||
|
||||
@router.post('/api/account/login')
|
||||
def login(body: Login, request: Request, response: Response):
|
||||
email = body.email.strip().lower()
|
||||
throttle(email, request)
|
||||
with db.connect() as c:
|
||||
account = c.execute('SELECT * FROM dtf_local.accounts WHERE email=%s', (email,)).fetchone()
|
||||
# Comparable password work even when the email is absent.
|
||||
stored = account['password_hash'] if account else DUMMY_PASSWORD_HASH
|
||||
matches = password_matches(body.password, stored)
|
||||
if not account or not matches:
|
||||
audit('customer_login_failed', ip=client_ip(request))
|
||||
raise HTTPException(401, 'Invalid email or password')
|
||||
previous = current(request)
|
||||
with db.connect() as c:
|
||||
if not stored.startswith('scrypt-v2$'):
|
||||
c.execute('UPDATE dtf_local.accounts SET password_hash=%s WHERE id=%s', (password_hash(body.password),account['id']))
|
||||
if previous:
|
||||
transfer_guest(c, previous, account['id'])
|
||||
c.execute('DELETE FROM dtf_local.sessions WHERE id=%s', (previous['id'],))
|
||||
new_session(c, response, account['id'])
|
||||
audit('customer_login_success', account=str(account['id']))
|
||||
return {'customer': account['profile']}
|
||||
|
||||
@router.post('/api/account/logout')
|
||||
def logout(request: Request, response: Response):
|
||||
previous = current(request)
|
||||
if previous:
|
||||
with db.connect() as c:
|
||||
c.execute('DELETE FROM dtf_local.sessions WHERE id=%s', (previous['id'],))
|
||||
response.delete_cookie('dtf_session', httponly=True, samesite='strict')
|
||||
response.headers['Clear-Site-Data'] = '"storage"'
|
||||
audit('customer_logout')
|
||||
return {'ok': True}
|
||||
|
||||
@router.get('/api/account/me')
|
||||
def me(identity=Depends(owner)):
|
||||
with db.connect() as c:
|
||||
row = c.execute('SELECT profile FROM dtf_local.accounts WHERE id=%s', (identity,)).fetchone()
|
||||
return {'customer': row['profile'] if row else None}
|
||||
|
||||
@router.get('/api/customer/orders')
|
||||
def orders(identity=Depends(owner)):
|
||||
with db.connect() as c:
|
||||
rows = c.execute('SELECT id,number,state,version,snapshot,created_at,updated_at FROM dtf_local.orders WHERE owner=%s ORDER BY created_at DESC', (identity,)).fetchall()
|
||||
quotes = c.execute('''SELECT q.id,q.approved,q.approved_at,q.created_at FROM dtf_local.quotes q
|
||||
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id WHERE q.owner=%s AND o.id IS NULL ORDER BY q.created_at DESC''', (identity,)).fetchall()
|
||||
return {'orders': rows, 'quotes': quotes, 'states': STATES}
|
||||
|
||||
@router.get('/api/customer/orders/{oid}')
|
||||
def detail(oid: UUID, identity=Depends(owner)):
|
||||
with db.connect() as c:
|
||||
row = owned_order(c, oid, identity)
|
||||
history = c.execute('SELECT from_state,to_state,reason,created_at FROM dtf_local.movements WHERE order_id=%s ORDER BY id', (oid,)).fetchall()
|
||||
return {'id': row['id'], 'number': row['number'], 'state': row['state'], 'version': row['version'],
|
||||
'snapshot': row['snapshot'], 'history': history, 'files': file_rows(c,oid)}
|
||||
|
||||
@router.post('/api/customer/orders/{oid}/corrections')
|
||||
def correction(oid: UUID, body: ArtworkSubmission, identity=Depends(owner)):
|
||||
with db.connect() as c:
|
||||
order = owned_order(c, oid, identity, lock=True)
|
||||
return submit_files(c,order,body,identity,'correction','customer')
|
||||
|
||||
@router.get('/api/customer/orders/{oid}/files/{fid}/download')
|
||||
def customer_download(oid: UUID, fid: UUID, identity=Depends(owner)):
|
||||
with db.connect() as c:
|
||||
owned_order(c,oid,identity)
|
||||
row = c.execute('SELECT u.* FROM dtf_local.order_files f JOIN dtf_local.uploads u ON u.id=f.upload_id WHERE f.id=%s AND f.order_id=%s AND f.active', (fid,oid)).fetchone()
|
||||
if not row: raise HTTPException(404, 'Active file not found')
|
||||
if row['expires_at'] <= datetime.now(timezone.utc): raise HTTPException(410, 'File expired')
|
||||
require_clean(row)
|
||||
return {'url':storage.download(row['object_key'],row['name']), 'name':row['name']}
|
||||
43
local/api/health.py
Normal file
43
local/api/health.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Health, session bootstrap and freight quoting."""
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response
|
||||
|
||||
from .. import db
|
||||
from ..auth import client_ip, owner, new_session, rate_limit
|
||||
from ..models import Freight
|
||||
from ..runtime import ENVIRONMENT, GUEST_SESSION_LIMIT, PART_BYTES, freight, storage
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get('/health')
|
||||
@router.get('/api/health')
|
||||
def health():
|
||||
try:
|
||||
with db.connect() as c:
|
||||
c.execute('SELECT 1')
|
||||
storage.health()
|
||||
except Exception:
|
||||
raise HTTPException(503, 'Database or storage unavailable')
|
||||
return {'status': 'ok', 'environment': ENVIRONMENT,
|
||||
'storage': 'minio' if ENVIRONMENT == 'local' else 'r2', 'integrations': 'fake'}
|
||||
|
||||
@router.get('/api/session')
|
||||
def session(request: Request, response: Response):
|
||||
try:
|
||||
session_id = owner(request)
|
||||
except HTTPException:
|
||||
# Per source, not per deployment: keyed on the environment name this was a
|
||||
# single global bucket, so ~8 new visitors a minute exhausted it site-wide.
|
||||
rate_limit('guest-sessions', client_ip(request), GUEST_SESSION_LIMIT, 900)
|
||||
with db.connect() as c:
|
||||
session_id = new_session(c, response)
|
||||
return {'environment': ENVIRONMENT, 'cart_scope': str(session_id), 'part_bytes': PART_BYTES,
|
||||
'max_upload_bytes': int(os.environ.get('MAX_UPLOAD_BYTES', '5368709120'))}
|
||||
|
||||
@router.post('/api/freight')
|
||||
def quote_freight(body: Freight):
|
||||
try:
|
||||
return freight.quote(body.service, body.postal_code)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(422, str(exc))
|
||||
146
local/api/operator.py
Normal file
146
local/api/operator.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""Kanban: sign-in, the board, commercial review and card movement."""
|
||||
import hashlib
|
||||
import os
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from .. import db
|
||||
from ..auth import (COOKIE_SECURE, DUMMY_PASSWORD_HASH, audit, client_ip, operator,
|
||||
password_matches, throttle)
|
||||
from ..models import Move, OperatorLogin, Review
|
||||
from ..pricing import price
|
||||
from ..runtime import (BOARD_FINISHED_LIMIT, BOARD_QUOTE_LIMIT, STATES, TRANSITIONS,
|
||||
enqueue, freight, quote_view, storage, upload_row)
|
||||
from ..scanning import require_clean
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post('/api/operator/login')
|
||||
def operator_login(body: OperatorLogin, request: Request, response: Response):
|
||||
email = body.email
|
||||
throttle('operator:'+email, request)
|
||||
with db.connect() as c:
|
||||
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():
|
||||
raise HTTPException(503, 'No Kanban operator account is configured')
|
||||
# Comparable password work whether or not the account exists or is active.
|
||||
stored = account['password_hash'] if account else DUMMY_PASSWORD_HASH
|
||||
matches = password_matches(body.password, stored)
|
||||
if not account or not account['active'] or not matches:
|
||||
audit('operator_login_failed', ip=client_ip(request), operator=email)
|
||||
raise HTTPException(401, 'Invalid operator login')
|
||||
token = secrets.token_urlsafe(32)
|
||||
with db.connect() as c:
|
||||
previous = hashlib.sha256(request.cookies.get('dtf_operator','').encode()).hexdigest()
|
||||
c.execute('DELETE FROM dtf_local.operator_sessions WHERE token_hash=%s', (previous,))
|
||||
c.execute('INSERT INTO dtf_local.operator_sessions(token_hash,username) VALUES(%s,%s)',
|
||||
(hashlib.sha256(token.encode()).hexdigest(), email))
|
||||
c.execute('UPDATE dtf_local.operators SET last_login_at=now() WHERE id=%s', (account['id'],))
|
||||
response.set_cookie('dtf_operator', token, httponly=True, secure=COOKIE_SECURE,
|
||||
samesite='strict', path='/api/operator', max_age=28800)
|
||||
audit('operator_login_success', operator=email)
|
||||
return {'ok': True}
|
||||
|
||||
@router.post('/api/operator/logout')
|
||||
def operator_logout(request: Request, response: Response):
|
||||
with db.connect() as c:
|
||||
digest = hashlib.sha256(request.cookies.get('dtf_operator','').encode()).hexdigest()
|
||||
c.execute('DELETE FROM dtf_local.operator_sessions WHERE token_hash=%s', (digest,))
|
||||
response.delete_cookie('dtf_operator', path='/api/operator', httponly=True,
|
||||
secure=COOKIE_SECURE, samesite='strict')
|
||||
audit('operator_logout')
|
||||
return {'ok': True}
|
||||
|
||||
@router.get('/api/operator/board')
|
||||
def board(user=Depends(operator)):
|
||||
with db.connect() as c:
|
||||
# Everything still in progress, however old: an operator must never lose a
|
||||
# card they can act on. Finished orders are terminal and only accumulate,
|
||||
# so the board carries a recent window of them and reports the true total.
|
||||
active = c.execute("SELECT * FROM dtf_local.orders WHERE state<>'fin' ORDER BY created_at").fetchall()
|
||||
finished = c.execute("SELECT * FROM dtf_local.orders WHERE state='fin' ORDER BY created_at DESC LIMIT %s",
|
||||
(BOARD_FINISHED_LIMIT,)).fetchall()
|
||||
finished_total = c.execute("SELECT count(*) AS n FROM dtf_local.orders WHERE state='fin'").fetchone()['n']
|
||||
quotes = c.execute('''SELECT q.* FROM dtf_local.quotes q
|
||||
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id WHERE o.id IS NULL
|
||||
ORDER BY q.created_at LIMIT %s''', (BOARD_QUOTE_LIMIT,)).fetchall()
|
||||
return {'states': STATES, 'transitions': TRANSITIONS,
|
||||
'orders': active + list(reversed(finished)),
|
||||
'finished_shown': len(finished), 'finished_total': finished_total,
|
||||
'quotes': [quote_view(c, q) for q in quotes],
|
||||
'events': c.execute('SELECT * FROM dtf_local.outbox ORDER BY id DESC LIMIT 100').fetchall()}
|
||||
|
||||
@router.post('/api/operator/quotes/{uid}/approve')
|
||||
def approve(uid: UUID, body: Review, user=Depends(operator)):
|
||||
with db.connect() as c:
|
||||
row = c.execute('SELECT * FROM dtf_local.quotes WHERE id=%s FOR UPDATE', (uid,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, 'Quote not found')
|
||||
if row['approved']:
|
||||
raise HTTPException(409, 'Approved quotes are immutable; request a new quote')
|
||||
draft = row['draft']
|
||||
if len(body.items) != len(draft['items']):
|
||||
raise HTTPException(422, 'Review must cover every item')
|
||||
items = []
|
||||
for item, original in zip(body.items, draft['items']):
|
||||
if item.mode != original['mode'] or list(map(str,item.uploads)) != original['uploads']:
|
||||
raise HTTPException(422, 'Product mode and attached files cannot change during review')
|
||||
for upload_id in item.uploads:
|
||||
require_clean(upload_row(c, upload_id, row['owner']))
|
||||
items.append({**price(item.mode, str(item.metres), item.grade), 'uploads': original['uploads']})
|
||||
quoted_freight = freight.quote(**draft['freight'])
|
||||
approved = {'customer': draft['customer'], 'items': items, 'freight': quoted_freight,
|
||||
'total_cents': sum(i['total_cents'] for i in items)+quoted_freight['total_cents']}
|
||||
c.execute('UPDATE dtf_local.quotes SET approved=%s, reviewed_by=%s, approved_at=now() WHERE id=%s', (Jsonb(approved),user,uid))
|
||||
return approved
|
||||
|
||||
@router.post('/api/operator/orders/{uid}/move')
|
||||
def move(uid: UUID, body: Move, user=Depends(operator)):
|
||||
with db.connect() as c:
|
||||
row = c.execute('SELECT * FROM dtf_local.orders WHERE id=%s FOR UPDATE', (uid,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, 'Order not found')
|
||||
if body.version != row['version']:
|
||||
raise HTTPException(409, 'Order changed; refresh the board')
|
||||
if body.state == row['state']:
|
||||
return row
|
||||
if body.state not in TRANSITIONS[row['state']]:
|
||||
raise HTTPException(409, 'Move is not allowed from this state')
|
||||
if body.state == 'cor' and not body.reason.strip():
|
||||
raise HTTPException(422, 'Correction requires a reason')
|
||||
if body.state in ('fil','imp'):
|
||||
coverage = c.execute('SELECT DISTINCT f.item_index FROM dtf_local.order_files f JOIN dtf_local.uploads u ON u.id=f.upload_id WHERE f.order_id=%s AND f.kind=\'final\' AND f.active AND u.expires_at>now() AND u.purged_at IS NULL AND u.scan_state=\'clean\'', (uid,)).fetchall()
|
||||
if {r['item_index'] for r in coverage} != set(range(len(row['snapshot']['items']))):
|
||||
raise HTTPException(409, 'Approve a complete final-file set for every item before queueing')
|
||||
if body.state == 'cor':
|
||||
c.execute("UPDATE dtf_local.order_files SET active=false WHERE order_id=%s AND kind='final'", (uid,))
|
||||
c.execute('INSERT INTO dtf_local.movements(order_id,from_state,to_state,operator,reason) VALUES(%s,%s,%s,%s,%s)',
|
||||
(uid,row['state'],body.state,user,body.reason))
|
||||
changed = c.execute('UPDATE dtf_local.orders SET state=%s, version=version+1, updated_at=now() WHERE id=%s RETURNING *', (body.state,uid)).fetchone()
|
||||
events = {'imp':'production_started','cor':'correction_needed','fin':'ready'}
|
||||
if body.state in events:
|
||||
for provider in ('tiny','whatsapp'):
|
||||
enqueue(c, f'{uid}:{changed["version"]}:{provider}', provider,
|
||||
{'order_id':str(uid), 'number':row['number'], 'event':events[body.state], 'reason':body.reason,
|
||||
'customer_path': f'/portal.html?order={uid}'})
|
||||
return changed
|
||||
|
||||
@router.get('/api/operator/orders/{uid}/history')
|
||||
def history(uid: UUID, user=Depends(operator)):
|
||||
with db.connect() as c:
|
||||
return c.execute('SELECT * FROM dtf_local.movements WHERE order_id=%s ORDER BY id', (uid,)).fetchall()
|
||||
|
||||
@router.get('/api/operator/uploads/{uid}/download')
|
||||
def download(uid: UUID, user=Depends(operator)):
|
||||
with db.connect() as c:
|
||||
row = c.execute('SELECT * FROM dtf_local.uploads WHERE id=%s AND complete', (uid,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, 'Completed upload not found')
|
||||
if row['expires_at'] <= datetime.now(timezone.utc):
|
||||
raise HTTPException(410, 'Artwork retention expired')
|
||||
require_clean(row)
|
||||
return {'name':row['name'], 'url':storage.download(row['object_key'],row['name']), 'expires_in':300}
|
||||
41
local/api/orders.py
Normal file
41
local/api/orders.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Paid orders. Local development payment only; no provider is wired yet."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from .. import db
|
||||
from ..auth import owner
|
||||
from ..models import Pay
|
||||
from ..runtime import ENVIRONMENT, enqueue, payment, upload_row
|
||||
from ..scanning import require_clean
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post('/api/orders/dev-paid')
|
||||
def dev_paid(body: Pay, session_id=Depends(owner)):
|
||||
if ENVIRONMENT != 'local':
|
||||
raise HTTPException(503, 'Checkout is not configured yet')
|
||||
with db.connect() as c:
|
||||
row = c.execute('SELECT * FROM dtf_local.quotes WHERE id=%s AND owner=%s FOR UPDATE', (body.quote_id,session_id)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, 'Quote not found')
|
||||
existing = c.execute('SELECT * FROM dtf_local.orders WHERE quote_id=%s', (body.quote_id,)).fetchone()
|
||||
if existing:
|
||||
return existing
|
||||
if not row['approved']:
|
||||
raise HTTPException(409, 'An operator must verify length and grade first')
|
||||
if row['approved_at'] < datetime.now(timezone.utc)-timedelta(hours=24):
|
||||
raise HTTPException(409, 'Quote expired; request a new quote')
|
||||
approved = row['approved']
|
||||
for item in approved['items']:
|
||||
for upload_id in item['uploads']:
|
||||
require_clean(upload_row(c, UUID(upload_id), session_id))
|
||||
paid = payment.pay(str(body.quote_id), approved['total_cents'])
|
||||
result = c.execute('INSERT INTO dtf_local.orders(id,quote_id,owner,snapshot,payment) VALUES(%s,%s,%s,%s,%s) RETURNING *',
|
||||
(uuid4(),body.quote_id,session_id,Jsonb(approved),Jsonb(paid))).fetchone()
|
||||
for provider in ('tiny','whatsapp'):
|
||||
enqueue(c, f"{result['id']}:paid:{provider}", provider,
|
||||
{'order_id': str(result['id']), 'number': result['number'], 'event': 'payment_approved', 'order': approved})
|
||||
return result
|
||||
46
local/api/quotes.py
Normal file
46
local/api/quotes.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Quotes: the customer's cart, and the operator-reviewed version of it."""
|
||||
import hashlib
|
||||
import json
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from .. import db
|
||||
from ..auth import owner
|
||||
from ..models import QuoteRequest
|
||||
from ..runtime import freight, quote_view, upload_row
|
||||
from ..scanning import require_clean
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post('/api/quotes')
|
||||
def create_quote(body: QuoteRequest, session_id=Depends(owner)):
|
||||
draft = body.model_dump(mode='json', exclude={'request_key'})
|
||||
digest = hashlib.sha256(json.dumps(draft, sort_keys=True).encode()).hexdigest()
|
||||
try:
|
||||
freight.quote(body.freight.service, body.freight.postal_code)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(422, str(exc))
|
||||
with db.connect() as c:
|
||||
for item in body.items:
|
||||
for uid in item.uploads:
|
||||
row = upload_row(c, uid, session_id)
|
||||
if not row['complete']:
|
||||
raise HTTPException(409, 'Complete every upload before requesting a quote')
|
||||
require_clean(row)
|
||||
uid = uuid4()
|
||||
c.execute('INSERT INTO dtf_local.quotes(id,owner,request_key,request_hash,draft) VALUES(%s,%s,%s,%s,%s) ON CONFLICT(owner,request_key) DO NOTHING',
|
||||
(uid, session_id, body.request_key, digest, Jsonb(draft)))
|
||||
row = c.execute('SELECT * FROM dtf_local.quotes WHERE owner=%s AND request_key=%s', (session_id, body.request_key)).fetchone()
|
||||
if row['request_hash'] != digest:
|
||||
raise HTTPException(409, 'Request key already used for a different cart')
|
||||
return {'id': row['id'], 'status': 'pending_review'}
|
||||
|
||||
@router.get('/api/quotes/{uid}')
|
||||
def get_quote(uid: UUID, session_id=Depends(owner)):
|
||||
with db.connect() as c:
|
||||
row = c.execute('SELECT * FROM dtf_local.quotes WHERE id=%s AND owner=%s', (uid,session_id)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, 'Quote not found')
|
||||
return quote_view(c, row)
|
||||
85
local/api/uploads.py
Normal file
85
local/api/uploads.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Customer uploads: reservation, signed parts, completion.
|
||||
|
||||
The operator artwork routes reuse these functions directly with a different
|
||||
identity, which is why they are plain functions with a session_id argument.
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from botocore.exceptions import ClientError
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from .. import db
|
||||
from ..auth import audit, owner, rate_limit
|
||||
from ..models import UploadStart
|
||||
from ..runtime import PART_BYTES, storage, upload_row
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post('/api/uploads')
|
||||
def begin_upload(body: UploadStart, session_id=Depends(owner)):
|
||||
if body.size > int(os.environ.get('MAX_UPLOAD_BYTES', '5368709120')):
|
||||
raise HTTPException(413, 'File exceeds the upload limit')
|
||||
uid = uuid4()
|
||||
key = f'originals/{uid}'
|
||||
rate_limit('upload-start', str(session_id), 60, 900)
|
||||
with db.connect() as c:
|
||||
# Serialize reservations across API processes, including changing guest IDs.
|
||||
c.execute('SELECT pg_advisory_xact_lock(804208)')
|
||||
usage = c.execute('''SELECT COALESCE(sum(size),0) AS total,
|
||||
COALESCE(sum(size) FILTER(WHERE owner=%s),0) AS owned,
|
||||
count(*) FILTER(WHERE owner=%s AND NOT complete) AS pending
|
||||
FROM dtf_local.uploads WHERE purged_at IS NULL''', (session_id,session_id)).fetchone()
|
||||
if (usage['total']+body.size > int(os.environ.get('STORAGE_QUOTA_BYTES','53687091200')) or
|
||||
usage['owned']+body.size > int(os.environ.get('OWNER_UPLOAD_QUOTA_BYTES','10737418240')) or
|
||||
usage['pending'] >= int(os.environ.get('MAX_PENDING_UPLOADS','10'))):
|
||||
audit('upload_quota_rejected')
|
||||
raise HTTPException(429, 'Local storage quota or pending upload limit reached')
|
||||
multipart = storage.begin(key)
|
||||
c.execute('INSERT INTO dtf_local.uploads(id,owner,name,size,object_key,multipart_id) VALUES(%s,%s,%s,%s,%s,%s)',
|
||||
(uid, session_id, body.name, body.size, key, multipart))
|
||||
return {'id': uid, 'part_bytes': PART_BYTES}
|
||||
|
||||
@router.get('/api/uploads/{uid}')
|
||||
def upload_status(uid: UUID, session_id=Depends(owner)):
|
||||
with db.connect() as c:
|
||||
row = upload_row(c, uid, session_id)
|
||||
parts = [] if row['complete'] else storage.parts(row['object_key'], row['multipart_id'])
|
||||
return {'id': uid, 'complete': row['complete'], 'part_bytes': PART_BYTES,
|
||||
'scan_state':row['scan_state'], 'scan_reason':row['scan_reason'],
|
||||
'parts': [p['PartNumber'] for p in parts]}
|
||||
|
||||
@router.post('/api/uploads/{uid}/parts/{part}')
|
||||
def part_url(uid: UUID, part: int, session_id=Depends(owner)):
|
||||
with db.connect() as c:
|
||||
row = upload_row(c, uid, session_id)
|
||||
if row['complete'] or not 1 <= part <= math.ceil(row['size'] / PART_BYTES):
|
||||
raise HTTPException(409, 'Invalid part or completed upload')
|
||||
size = min(PART_BYTES, row['size']-(part-1)*PART_BYTES)
|
||||
return {'url': storage.part_url(row['object_key'], row['multipart_id'], part, size)}
|
||||
|
||||
@router.post('/api/uploads/{uid}/complete')
|
||||
def complete_upload(uid: UUID, session_id=Depends(owner)):
|
||||
with db.connect() as c:
|
||||
row = upload_row(c, uid, session_id, lock=True)
|
||||
if row['complete']:
|
||||
return {'id': uid, 'complete': True}
|
||||
try:
|
||||
existing_size = storage.size(row['object_key'])
|
||||
except ClientError as exc:
|
||||
if exc.response['ResponseMetadata']['HTTPStatusCode'] != 404:
|
||||
raise
|
||||
existing_size = None
|
||||
if existing_size is None:
|
||||
parts = storage.parts(row['object_key'], row['multipart_id'])
|
||||
expected = math.ceil(row['size'] / PART_BYTES)
|
||||
if [p['PartNumber'] for p in parts] != list(range(1, expected+1)) or any(
|
||||
p['Size'] != min(PART_BYTES, row['size'] - i*PART_BYTES) for i,p in enumerate(parts)):
|
||||
raise HTTPException(409, 'Parts are missing or their sizes do not match')
|
||||
storage.complete(row['object_key'], row['multipart_id'], parts)
|
||||
existing_size = storage.size(row['object_key'])
|
||||
if existing_size != row['size']:
|
||||
raise HTTPException(409, 'Stored size differs from declared size')
|
||||
c.execute('UPDATE dtf_local.uploads SET complete=true WHERE id=%s', (uid,))
|
||||
return {'id': uid, 'complete': True}
|
||||
362
local/app.py
362
local/app.py
@@ -1,51 +1,20 @@
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import secrets
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID, uuid4
|
||||
"""The DTF Portal/API service: assembly only.
|
||||
|
||||
from botocore.exceptions import ClientError
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request, Response
|
||||
Configuration and shared helpers are in local.runtime; every route lives in a
|
||||
router under local.api. This module creates the application, applies the
|
||||
cross-cutting middleware, and includes them.
|
||||
"""
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from . import db
|
||||
from .secrets import load as load_secret_files
|
||||
from .adapters import FakeFreight, FakePayment, LocalS3Storage, require_runtime
|
||||
from .models import Freight, Move, Pay, QuoteRequest, Review, UploadStart, OperatorLogin
|
||||
from .pricing import price
|
||||
from .auth import COOKIE_SECURE, DUMMY_PASSWORD_HASH, client_ip, owner, session_row, new_session, operator, password_matches, throttle, audit, rate_limit
|
||||
from .scanning import require_clean
|
||||
from .auth import audit, client_ip
|
||||
from .runtime import ALLOWED_HOSTS, ALLOWED_ORIGINS, storage
|
||||
from .api import artwork, customer, health, operator, orders, quotes, uploads
|
||||
|
||||
load_secret_files()
|
||||
require_runtime()
|
||||
storage = LocalS3Storage()
|
||||
payment = FakePayment()
|
||||
freight = FakeFreight()
|
||||
ENVIRONMENT = os.environ.get('APP_ENV', 'local')
|
||||
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_ORIGINS = [origin for origin in os.environ.get('ALLOWED_ORIGINS', PUBLIC_ORIGIN).split(',') if origin]
|
||||
# Per source and generous: a browser needs one session and keeps the cookie, but
|
||||
# offices and mobile carriers put many real customers behind one address, so a
|
||||
# tight per-IP ceiling would lock out the same people the old global one did.
|
||||
GUEST_SESSION_LIMIT = int(os.environ.get('GUEST_SESSION_LIMIT', '240'))
|
||||
# The board returned every order ever created. Finished ones are terminal, so
|
||||
# they were pure growth: at a few hundred a day the response and the page both
|
||||
# degrade with no operator benefit.
|
||||
BOARD_FINISHED_LIMIT = int(os.environ.get('BOARD_FINISHED_LIMIT', '50'))
|
||||
BOARD_QUOTE_LIMIT = int(os.environ.get('BOARD_QUOTE_LIMIT', '100'))
|
||||
PART_BYTES = int(os.environ.get('UPLOAD_PART_BYTES', '8388608'))
|
||||
if not 5242880 <= PART_BYTES <= 67108864:
|
||||
raise RuntimeError('UPLOAD_PART_BYTES must be between 5 and 64 MiB')
|
||||
STATES = {'rec': 'Arte recebida', 'tra': 'Arte tratada', 'fil': 'Fila de impressão',
|
||||
'imp': 'Imprimindo', 'cor': 'Correção', 'fin': 'Finalizado'}
|
||||
TRANSITIONS = {'rec': ['tra','cor'], 'tra': ['fil','cor'], 'fil': ['imp','cor'],
|
||||
'imp': ['fin','cor'], 'cor': ['rec','tra'], 'fin': []}
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
@@ -54,45 +23,10 @@ async def lifespan(app):
|
||||
storage.health()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title='DTF Portal/API', lifespan=lifespan, docs_url=None, redoc_url=None)
|
||||
app.add_middleware(TrustedHostMiddleware, allowed_hosts=ALLOWED_HOSTS)
|
||||
|
||||
@app.post('/api/operator/login')
|
||||
def operator_login(body: OperatorLogin, request: Request, response: Response):
|
||||
email = body.email
|
||||
throttle('operator:'+email, request)
|
||||
with db.connect() as c:
|
||||
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():
|
||||
raise HTTPException(503, 'No Kanban operator account is configured')
|
||||
# Comparable password work whether or not the account exists or is active.
|
||||
stored = account['password_hash'] if account else DUMMY_PASSWORD_HASH
|
||||
matches = password_matches(body.password, stored)
|
||||
if not account or not account['active'] or not matches:
|
||||
audit('operator_login_failed', ip=client_ip(request), operator=email)
|
||||
raise HTTPException(401, 'Invalid operator login')
|
||||
token = secrets.token_urlsafe(32)
|
||||
with db.connect() as c:
|
||||
previous = hashlib.sha256(request.cookies.get('dtf_operator','').encode()).hexdigest()
|
||||
c.execute('DELETE FROM dtf_local.operator_sessions WHERE token_hash=%s', (previous,))
|
||||
c.execute('INSERT INTO dtf_local.operator_sessions(token_hash,username) VALUES(%s,%s)',
|
||||
(hashlib.sha256(token.encode()).hexdigest(), email))
|
||||
c.execute('UPDATE dtf_local.operators SET last_login_at=now() WHERE id=%s', (account['id'],))
|
||||
response.set_cookie('dtf_operator', token, httponly=True, secure=COOKIE_SECURE,
|
||||
samesite='strict', path='/api/operator', max_age=28800)
|
||||
audit('operator_login_success', operator=email)
|
||||
return {'ok': True}
|
||||
|
||||
@app.post('/api/operator/logout')
|
||||
def operator_logout(request: Request, response: Response):
|
||||
with db.connect() as c:
|
||||
digest = hashlib.sha256(request.cookies.get('dtf_operator','').encode()).hexdigest()
|
||||
c.execute('DELETE FROM dtf_local.operator_sessions WHERE token_hash=%s', (digest,))
|
||||
response.delete_cookie('dtf_operator', path='/api/operator', httponly=True,
|
||||
secure=COOKIE_SECURE, samesite='strict')
|
||||
audit('operator_logout')
|
||||
return {'ok': True}
|
||||
|
||||
@app.middleware('http')
|
||||
async def safe_headers(request, call_next):
|
||||
if request.method not in ('GET','HEAD','OPTIONS'):
|
||||
@@ -108,273 +42,7 @@ async def safe_headers(request, call_next):
|
||||
response.headers['Referrer-Policy'] = 'no-referrer'
|
||||
return response
|
||||
|
||||
@app.get('/health')
|
||||
@app.get('/api/health')
|
||||
def health():
|
||||
try:
|
||||
with db.connect() as c:
|
||||
c.execute('SELECT 1')
|
||||
storage.health()
|
||||
except Exception:
|
||||
raise HTTPException(503, 'Database or storage unavailable')
|
||||
return {'status': 'ok', 'environment': ENVIRONMENT,
|
||||
'storage': 'minio' if ENVIRONMENT == 'local' else 'r2', 'integrations': 'fake'}
|
||||
|
||||
@app.get('/api/session')
|
||||
def session(request: Request, response: Response):
|
||||
try:
|
||||
session_id = owner(request)
|
||||
except HTTPException:
|
||||
# Per source, not per deployment: keyed on the environment name this was a
|
||||
# single global bucket, so ~8 new visitors a minute exhausted it site-wide.
|
||||
rate_limit('guest-sessions', client_ip(request), GUEST_SESSION_LIMIT, 900)
|
||||
with db.connect() as c:
|
||||
session_id = new_session(c, response)
|
||||
return {'environment': ENVIRONMENT, 'cart_scope': str(session_id), 'part_bytes': PART_BYTES,
|
||||
'max_upload_bytes': int(os.environ.get('MAX_UPLOAD_BYTES', '5368709120'))}
|
||||
|
||||
@app.post('/api/freight')
|
||||
def quote_freight(body: Freight):
|
||||
try:
|
||||
return freight.quote(body.service, body.postal_code)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(422, str(exc))
|
||||
|
||||
def upload_row(c, upload_id, session_id, lock=False):
|
||||
row = c.execute('SELECT * FROM dtf_local.uploads WHERE id=%s AND owner=%s' +
|
||||
(' FOR UPDATE' if lock else ''), (upload_id, session_id)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, 'Upload not found')
|
||||
days = 30 if row['complete'] else 1
|
||||
if row['purged_at'] or row['expires_at'] <= datetime.now(timezone.utc) or row['created_at'] < datetime.now(timezone.utc) - timedelta(days=days):
|
||||
raise HTTPException(410, 'Upload expired; select the file again')
|
||||
return row
|
||||
|
||||
@app.post('/api/uploads')
|
||||
def begin_upload(body: UploadStart, session_id=Depends(owner)):
|
||||
if body.size > int(os.environ.get('MAX_UPLOAD_BYTES', '5368709120')):
|
||||
raise HTTPException(413, 'File exceeds the upload limit')
|
||||
uid = uuid4()
|
||||
key = f'originals/{uid}'
|
||||
rate_limit('upload-start', str(session_id), 60, 900)
|
||||
with db.connect() as c:
|
||||
# Serialize reservations across API processes, including changing guest IDs.
|
||||
c.execute('SELECT pg_advisory_xact_lock(804208)')
|
||||
usage = c.execute('''SELECT COALESCE(sum(size),0) AS total,
|
||||
COALESCE(sum(size) FILTER(WHERE owner=%s),0) AS owned,
|
||||
count(*) FILTER(WHERE owner=%s AND NOT complete) AS pending
|
||||
FROM dtf_local.uploads WHERE purged_at IS NULL''', (session_id,session_id)).fetchone()
|
||||
if (usage['total']+body.size > int(os.environ.get('STORAGE_QUOTA_BYTES','53687091200')) or
|
||||
usage['owned']+body.size > int(os.environ.get('OWNER_UPLOAD_QUOTA_BYTES','10737418240')) or
|
||||
usage['pending'] >= int(os.environ.get('MAX_PENDING_UPLOADS','10'))):
|
||||
audit('upload_quota_rejected')
|
||||
raise HTTPException(429, 'Local storage quota or pending upload limit reached')
|
||||
multipart = storage.begin(key)
|
||||
c.execute('INSERT INTO dtf_local.uploads(id,owner,name,size,object_key,multipart_id) VALUES(%s,%s,%s,%s,%s,%s)',
|
||||
(uid, session_id, body.name, body.size, key, multipart))
|
||||
return {'id': uid, 'part_bytes': PART_BYTES}
|
||||
|
||||
@app.get('/api/uploads/{uid}')
|
||||
def upload_status(uid: UUID, session_id=Depends(owner)):
|
||||
with db.connect() as c:
|
||||
row = upload_row(c, uid, session_id)
|
||||
parts = [] if row['complete'] else storage.parts(row['object_key'], row['multipart_id'])
|
||||
return {'id': uid, 'complete': row['complete'], 'part_bytes': PART_BYTES,
|
||||
'scan_state':row['scan_state'], 'scan_reason':row['scan_reason'],
|
||||
'parts': [p['PartNumber'] for p in parts]}
|
||||
|
||||
@app.post('/api/uploads/{uid}/parts/{part}')
|
||||
def part_url(uid: UUID, part: int, session_id=Depends(owner)):
|
||||
with db.connect() as c:
|
||||
row = upload_row(c, uid, session_id)
|
||||
if row['complete'] or not 1 <= part <= math.ceil(row['size'] / PART_BYTES):
|
||||
raise HTTPException(409, 'Invalid part or completed upload')
|
||||
size = min(PART_BYTES, row['size']-(part-1)*PART_BYTES)
|
||||
return {'url': storage.part_url(row['object_key'], row['multipart_id'], part, size)}
|
||||
|
||||
@app.post('/api/uploads/{uid}/complete')
|
||||
def complete_upload(uid: UUID, session_id=Depends(owner)):
|
||||
with db.connect() as c:
|
||||
row = upload_row(c, uid, session_id, lock=True)
|
||||
if row['complete']:
|
||||
return {'id': uid, 'complete': True}
|
||||
try:
|
||||
existing_size = storage.size(row['object_key'])
|
||||
except ClientError as exc:
|
||||
if exc.response['ResponseMetadata']['HTTPStatusCode'] != 404:
|
||||
raise
|
||||
existing_size = None
|
||||
if existing_size is None:
|
||||
parts = storage.parts(row['object_key'], row['multipart_id'])
|
||||
expected = math.ceil(row['size'] / PART_BYTES)
|
||||
if [p['PartNumber'] for p in parts] != list(range(1, expected+1)) or any(
|
||||
p['Size'] != min(PART_BYTES, row['size'] - i*PART_BYTES) for i,p in enumerate(parts)):
|
||||
raise HTTPException(409, 'Parts are missing or their sizes do not match')
|
||||
storage.complete(row['object_key'], row['multipart_id'], parts)
|
||||
existing_size = storage.size(row['object_key'])
|
||||
if existing_size != row['size']:
|
||||
raise HTTPException(409, 'Stored size differs from declared size')
|
||||
c.execute('UPDATE dtf_local.uploads SET complete=true WHERE id=%s', (uid,))
|
||||
return {'id': uid, 'complete': True}
|
||||
|
||||
@app.post('/api/quotes')
|
||||
def create_quote(body: QuoteRequest, session_id=Depends(owner)):
|
||||
draft = body.model_dump(mode='json', exclude={'request_key'})
|
||||
digest = hashlib.sha256(json.dumps(draft, sort_keys=True).encode()).hexdigest()
|
||||
try:
|
||||
freight.quote(body.freight.service, body.freight.postal_code)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(422, str(exc))
|
||||
with db.connect() as c:
|
||||
for item in body.items:
|
||||
for uid in item.uploads:
|
||||
row = upload_row(c, uid, session_id)
|
||||
if not row['complete']:
|
||||
raise HTTPException(409, 'Complete every upload before requesting a quote')
|
||||
require_clean(row)
|
||||
uid = uuid4()
|
||||
c.execute('INSERT INTO dtf_local.quotes(id,owner,request_key,request_hash,draft) VALUES(%s,%s,%s,%s,%s) ON CONFLICT(owner,request_key) DO NOTHING',
|
||||
(uid, session_id, body.request_key, digest, Jsonb(draft)))
|
||||
row = c.execute('SELECT * FROM dtf_local.quotes WHERE owner=%s AND request_key=%s', (session_id, body.request_key)).fetchone()
|
||||
if row['request_hash'] != digest:
|
||||
raise HTTPException(409, 'Request key already used for a different cart')
|
||||
return {'id': row['id'], 'status': 'pending_review'}
|
||||
|
||||
def quote_view(c, row):
|
||||
order = c.execute('SELECT id,number,state FROM dtf_local.orders WHERE quote_id=%s', (row['id'],)).fetchone()
|
||||
expired = row['approved_at'] and row['approved_at'] < datetime.now(timezone.utc)-timedelta(hours=24)
|
||||
return {'id': row['id'], 'draft': row['draft'], 'approved': row['approved'],
|
||||
'status': 'paid' if order else 'expired' if expired else 'approved' if row['approved'] else 'pending_review',
|
||||
'order': order}
|
||||
|
||||
@app.get('/api/quotes/{uid}')
|
||||
def get_quote(uid: UUID, session_id=Depends(owner)):
|
||||
with db.connect() as c:
|
||||
row = c.execute('SELECT * FROM dtf_local.quotes WHERE id=%s AND owner=%s', (uid,session_id)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, 'Quote not found')
|
||||
return quote_view(c, row)
|
||||
|
||||
def enqueue(c, event_key, provider, payload):
|
||||
c.execute('INSERT INTO dtf_local.outbox(event_key,provider,payload) VALUES(%s,%s,%s) ON CONFLICT(event_key) DO NOTHING',
|
||||
(event_key, provider, Jsonb(payload)))
|
||||
|
||||
@app.post('/api/orders/dev-paid')
|
||||
def dev_paid(body: Pay, session_id=Depends(owner)):
|
||||
if ENVIRONMENT != 'local':
|
||||
raise HTTPException(503, 'Checkout is not configured yet')
|
||||
with db.connect() as c:
|
||||
row = c.execute('SELECT * FROM dtf_local.quotes WHERE id=%s AND owner=%s FOR UPDATE', (body.quote_id,session_id)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, 'Quote not found')
|
||||
existing = c.execute('SELECT * FROM dtf_local.orders WHERE quote_id=%s', (body.quote_id,)).fetchone()
|
||||
if existing:
|
||||
return existing
|
||||
if not row['approved']:
|
||||
raise HTTPException(409, 'An operator must verify length and grade first')
|
||||
if row['approved_at'] < datetime.now(timezone.utc)-timedelta(hours=24):
|
||||
raise HTTPException(409, 'Quote expired; request a new quote')
|
||||
approved = row['approved']
|
||||
for item in approved['items']:
|
||||
for upload_id in item['uploads']:
|
||||
require_clean(upload_row(c, UUID(upload_id), session_id))
|
||||
paid = payment.pay(str(body.quote_id), approved['total_cents'])
|
||||
result = c.execute('INSERT INTO dtf_local.orders(id,quote_id,owner,snapshot,payment) VALUES(%s,%s,%s,%s,%s) RETURNING *',
|
||||
(uuid4(),body.quote_id,session_id,Jsonb(approved),Jsonb(paid))).fetchone()
|
||||
for provider in ('tiny','whatsapp'):
|
||||
enqueue(c, f"{result['id']}:paid:{provider}", provider,
|
||||
{'order_id': str(result['id']), 'number': result['number'], 'event': 'payment_approved', 'order': approved})
|
||||
return result
|
||||
|
||||
@app.get('/api/operator/board')
|
||||
def board(user=Depends(operator)):
|
||||
with db.connect() as c:
|
||||
# Everything still in progress, however old: an operator must never lose a
|
||||
# card they can act on. Finished orders are terminal and only accumulate,
|
||||
# so the board carries a recent window of them and reports the true total.
|
||||
active = c.execute("SELECT * FROM dtf_local.orders WHERE state<>'fin' ORDER BY created_at").fetchall()
|
||||
finished = c.execute("SELECT * FROM dtf_local.orders WHERE state='fin' ORDER BY created_at DESC LIMIT %s",
|
||||
(BOARD_FINISHED_LIMIT,)).fetchall()
|
||||
finished_total = c.execute("SELECT count(*) AS n FROM dtf_local.orders WHERE state='fin'").fetchone()['n']
|
||||
quotes = c.execute('''SELECT q.* FROM dtf_local.quotes q
|
||||
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id WHERE o.id IS NULL
|
||||
ORDER BY q.created_at LIMIT %s''', (BOARD_QUOTE_LIMIT,)).fetchall()
|
||||
return {'states': STATES, 'transitions': TRANSITIONS,
|
||||
'orders': active + list(reversed(finished)),
|
||||
'finished_shown': len(finished), 'finished_total': finished_total,
|
||||
'quotes': [quote_view(c, q) for q in quotes],
|
||||
'events': c.execute('SELECT * FROM dtf_local.outbox ORDER BY id DESC LIMIT 100').fetchall()}
|
||||
|
||||
@app.post('/api/operator/quotes/{uid}/approve')
|
||||
def approve(uid: UUID, body: Review, user=Depends(operator)):
|
||||
with db.connect() as c:
|
||||
row = c.execute('SELECT * FROM dtf_local.quotes WHERE id=%s FOR UPDATE', (uid,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, 'Quote not found')
|
||||
if row['approved']:
|
||||
raise HTTPException(409, 'Approved quotes are immutable; request a new quote')
|
||||
draft = row['draft']
|
||||
if len(body.items) != len(draft['items']):
|
||||
raise HTTPException(422, 'Review must cover every item')
|
||||
items = []
|
||||
for item, original in zip(body.items, draft['items']):
|
||||
if item.mode != original['mode'] or list(map(str,item.uploads)) != original['uploads']:
|
||||
raise HTTPException(422, 'Product mode and attached files cannot change during review')
|
||||
for upload_id in item.uploads:
|
||||
require_clean(upload_row(c, upload_id, row['owner']))
|
||||
items.append({**price(item.mode, str(item.metres), item.grade), 'uploads': original['uploads']})
|
||||
quoted_freight = freight.quote(**draft['freight'])
|
||||
approved = {'customer': draft['customer'], 'items': items, 'freight': quoted_freight,
|
||||
'total_cents': sum(i['total_cents'] for i in items)+quoted_freight['total_cents']}
|
||||
c.execute('UPDATE dtf_local.quotes SET approved=%s, reviewed_by=%s, approved_at=now() WHERE id=%s', (Jsonb(approved),user,uid))
|
||||
return approved
|
||||
|
||||
@app.post('/api/operator/orders/{uid}/move')
|
||||
def move(uid: UUID, body: Move, user=Depends(operator)):
|
||||
with db.connect() as c:
|
||||
row = c.execute('SELECT * FROM dtf_local.orders WHERE id=%s FOR UPDATE', (uid,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, 'Order not found')
|
||||
if body.version != row['version']:
|
||||
raise HTTPException(409, 'Order changed; refresh the board')
|
||||
if body.state == row['state']:
|
||||
return row
|
||||
if body.state not in TRANSITIONS[row['state']]:
|
||||
raise HTTPException(409, 'Move is not allowed from this state')
|
||||
if body.state == 'cor' and not body.reason.strip():
|
||||
raise HTTPException(422, 'Correction requires a reason')
|
||||
if body.state in ('fil','imp'):
|
||||
coverage = c.execute('SELECT DISTINCT f.item_index FROM dtf_local.order_files f JOIN dtf_local.uploads u ON u.id=f.upload_id WHERE f.order_id=%s AND f.kind=\'final\' AND f.active AND u.expires_at>now() AND u.purged_at IS NULL AND u.scan_state=\'clean\'', (uid,)).fetchall()
|
||||
if {r['item_index'] for r in coverage} != set(range(len(row['snapshot']['items']))):
|
||||
raise HTTPException(409, 'Approve a complete final-file set for every item before queueing')
|
||||
if body.state == 'cor':
|
||||
c.execute("UPDATE dtf_local.order_files SET active=false WHERE order_id=%s AND kind='final'", (uid,))
|
||||
c.execute('INSERT INTO dtf_local.movements(order_id,from_state,to_state,operator,reason) VALUES(%s,%s,%s,%s,%s)',
|
||||
(uid,row['state'],body.state,user,body.reason))
|
||||
changed = c.execute('UPDATE dtf_local.orders SET state=%s, version=version+1, updated_at=now() WHERE id=%s RETURNING *', (body.state,uid)).fetchone()
|
||||
events = {'imp':'production_started','cor':'correction_needed','fin':'ready'}
|
||||
if body.state in events:
|
||||
for provider in ('tiny','whatsapp'):
|
||||
enqueue(c, f'{uid}:{changed["version"]}:{provider}', provider,
|
||||
{'order_id':str(uid), 'number':row['number'], 'event':events[body.state], 'reason':body.reason,
|
||||
'customer_path': f'/portal.html?order={uid}'})
|
||||
return changed
|
||||
|
||||
@app.get('/api/operator/orders/{uid}/history')
|
||||
def history(uid: UUID, user=Depends(operator)):
|
||||
with db.connect() as c:
|
||||
return c.execute('SELECT * FROM dtf_local.movements WHERE order_id=%s ORDER BY id', (uid,)).fetchall()
|
||||
|
||||
@app.get('/api/operator/uploads/{uid}/download')
|
||||
def download(uid: UUID, user=Depends(operator)):
|
||||
with db.connect() as c:
|
||||
row = c.execute('SELECT * FROM dtf_local.uploads WHERE id=%s AND complete', (uid,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, 'Completed upload not found')
|
||||
if row['expires_at'] <= datetime.now(timezone.utc):
|
||||
raise HTTPException(410, 'Artwork retention expired')
|
||||
require_clean(row)
|
||||
return {'name':row['name'], 'url':storage.download(row['object_key'],row['name']), 'expires_in':300}
|
||||
|
||||
from .customer import install_routes
|
||||
install_routes(app, operator, storage, begin_upload, upload_status, part_url, complete_upload, upload_row, STATES)
|
||||
# Order is not significant: no two routers declare the same path.
|
||||
for module in (health, uploads, quotes, orders, operator, customer, artwork):
|
||||
app.include_router(module.router)
|
||||
|
||||
50
local/artwork.py
Normal file
50
local/artwork.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Attaching artwork to an order: the rules shared by customers and operators.
|
||||
|
||||
A customer submits a correction and an operator submits the final set; both go
|
||||
through the same checks, so the rule about what may be attached, when, and what
|
||||
it does to retention lives in one place.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from .runtime import upload_row
|
||||
from .scanning import require_clean
|
||||
|
||||
def submit_files(c, order, body, identity, kind, actor):
|
||||
if order['version'] != body.version:
|
||||
raise HTTPException(409, 'Order changed. Refresh before submitting files.')
|
||||
if kind == 'final' and order['state'] not in ('rec','tra','cor'):
|
||||
raise HTTPException(409, 'Final files can only change during artwork review')
|
||||
if kind == 'correction' and order['state'] != 'cor':
|
||||
raise HTTPException(409, 'This order is not awaiting artwork correction')
|
||||
if len({f.upload_id for f in body.files}) != len(body.files):
|
||||
raise HTTPException(422, 'Each uploaded file must appear once')
|
||||
count = len(order['snapshot']['items'])
|
||||
if any(f.item_index >= count for f in body.files):
|
||||
raise HTTPException(422, 'Invalid order item')
|
||||
if kind == 'final' and {f.item_index for f in body.files} != set(range(count)):
|
||||
raise HTTPException(422, 'Final-file set must cover every order item')
|
||||
original_ids = [UUID(uid) for item in order['snapshot']['items'] for uid in item['uploads']]
|
||||
first = c.execute('SELECT min(created_at) AS first FROM dtf_local.uploads WHERE id=ANY(%s)', (original_ids,)).fetchone()['first']
|
||||
expiry = first + timedelta(days=30)
|
||||
if expiry <= datetime.now(timezone.utc):
|
||||
raise HTTPException(410, 'Order artwork retention has expired')
|
||||
for ref in body.files:
|
||||
upload = upload_row(c, ref.upload_id, identity, lock=True)
|
||||
if not upload['complete']:
|
||||
raise HTTPException(409, 'Complete all uploads first')
|
||||
require_clean(upload)
|
||||
if c.execute('SELECT id FROM dtf_local.order_files WHERE upload_id=%s', (ref.upload_id,)).fetchone():
|
||||
raise HTTPException(409, 'File is already attached. Upload a new revision.')
|
||||
c.execute('UPDATE dtf_local.order_files SET active=false WHERE order_id=%s AND kind=%s', (order['id'],kind))
|
||||
for ref in body.files:
|
||||
c.execute('INSERT INTO dtf_local.order_files(id,order_id,upload_id,item_index,kind,note,created_by) VALUES(%s,%s,%s,%s,%s,%s,%s)',
|
||||
(uuid4(),order['id'],ref.upload_id,ref.item_index,kind,body.note,actor))
|
||||
c.execute('UPDATE dtf_local.uploads SET expires_at=LEAST(expires_at,%s) WHERE id=%s', (expiry,ref.upload_id))
|
||||
c.execute('UPDATE dtf_local.orders SET version=version+1,updated_at=now() WHERE id=%s', (order['id'],))
|
||||
if kind == 'final':
|
||||
# Artwork approval, not commercial quote approval, starts original cleanup.
|
||||
c.execute("UPDATE dtf_local.uploads SET expires_at=LEAST(expires_at,now()+interval '7 days') WHERE id=ANY(%s)", (original_ids,))
|
||||
return {'ok': True, 'version': order['version']+1, 'expires_at': expiry}
|
||||
@@ -9,7 +9,7 @@ import {mkdtemp, readFile, writeFile, mkdir} from 'node:fs/promises';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {resolve} from 'node:path';
|
||||
|
||||
const html=await readFile('dtf-site.html','utf8');
|
||||
const html=await readFile('web/index.html','utf8');
|
||||
const hashes=[...html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)]
|
||||
.filter(m=>! /\bsrc\s*=/i.test(m[1]))
|
||||
.map(m=>"'sha256-"+createHash('sha256').update(m[2]).digest('base64')+"'");
|
||||
@@ -24,12 +24,12 @@ const server=createServer(async(req,res)=>{
|
||||
res.setHeader('Content-Security-Policy',`default-src 'self'; script-src 'self' ${hashes.join(' ')}; script-src-attr 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; object-src 'none'`);
|
||||
res.end(html);return;
|
||||
}
|
||||
// Serve any script the page asks for, resolved inside local/static, rather than
|
||||
// Serve any script the page asks for, resolved inside web/, rather than
|
||||
// a hardcoded list: the Site's behaviour is split across several files and a
|
||||
// list would silently 404 the next one added.
|
||||
if(/^\/[\w.-]+\.js$/.test(req.url)){
|
||||
try{
|
||||
const body=await readFile(resolve('local/static'+req.url));
|
||||
const body=await readFile(resolve('web'+req.url));
|
||||
res.setHeader('Content-Type','text/javascript');res.end(body);return;
|
||||
}catch{ res.writeHead(404);res.end();return; }
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import re
|
||||
|
||||
root = Path('/build')
|
||||
hashes = []
|
||||
for html in [root / 'dtf-site.html', *(root / 'local/static').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):
|
||||
if not re.search(r'\bsrc\s*=', attributes, re.I) and script.strip():
|
||||
hashes.append("'sha256-" + base64.b64encode(hashlib.sha256(script.encode()).digest()).decode() + "'")
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
"""Customer portal and manual artwork handoff, composed into the local API."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID, uuid4, uuid5, NAMESPACE_URL
|
||||
from fastapi import Depends, HTTPException, Request, Response
|
||||
from psycopg.errors import UniqueViolation
|
||||
from psycopg.types.json import Jsonb
|
||||
from . import db
|
||||
from .auth import owner, session_row, new_session, password_hash, password_matches, transfer_guest, throttle, DUMMY_PASSWORD_HASH, audit, client_ip
|
||||
from .models import Register, Login, UploadStart, ArtworkSubmission
|
||||
from .scanning import require_clean
|
||||
|
||||
def install_routes(app, operator, storage, begin, status, part, complete, upload_row, states):
|
||||
def current(request):
|
||||
try: return session_row(request)
|
||||
except HTTPException: return None
|
||||
|
||||
@app.post('/api/account/register')
|
||||
def register(body: Register, request: Request, response: Response):
|
||||
email = body.customer.mail.strip().lower()
|
||||
throttle(email, request)
|
||||
previous = current(request)
|
||||
encoded = password_hash(body.password)
|
||||
identity = uuid4()
|
||||
profile = body.customer.model_dump()
|
||||
profile['mail'] = email
|
||||
try:
|
||||
with db.connect() as c:
|
||||
if previous and c.execute('SELECT id FROM dtf_local.accounts WHERE id=%s', (previous['owner'],)).fetchone():
|
||||
raise HTTPException(409, 'Sign out before registering another account')
|
||||
c.execute('INSERT INTO dtf_local.accounts(id,email,password_hash,profile) VALUES(%s,%s,%s,%s)', (identity,email,encoded,Jsonb(profile)))
|
||||
if previous: transfer_guest(c, previous, identity)
|
||||
new_session(c, response, identity)
|
||||
except UniqueViolation:
|
||||
raise HTTPException(409, 'An account already exists; sign in')
|
||||
audit('account_registered', account=str(identity))
|
||||
return {'customer': profile}
|
||||
|
||||
@app.post('/api/account/login')
|
||||
def login(body: Login, request: Request, response: Response):
|
||||
email = body.email.strip().lower()
|
||||
throttle(email, request)
|
||||
with db.connect() as c:
|
||||
account = c.execute('SELECT * FROM dtf_local.accounts WHERE email=%s', (email,)).fetchone()
|
||||
# Comparable password work even when the email is absent.
|
||||
stored = account['password_hash'] if account else DUMMY_PASSWORD_HASH
|
||||
matches = password_matches(body.password, stored)
|
||||
if not account or not matches:
|
||||
audit('customer_login_failed', ip=client_ip(request))
|
||||
raise HTTPException(401, 'Invalid email or password')
|
||||
previous = current(request)
|
||||
with db.connect() as c:
|
||||
if not stored.startswith('scrypt-v2$'):
|
||||
c.execute('UPDATE dtf_local.accounts SET password_hash=%s WHERE id=%s', (password_hash(body.password),account['id']))
|
||||
if previous:
|
||||
transfer_guest(c, previous, account['id'])
|
||||
c.execute('DELETE FROM dtf_local.sessions WHERE id=%s', (previous['id'],))
|
||||
new_session(c, response, account['id'])
|
||||
audit('customer_login_success', account=str(account['id']))
|
||||
return {'customer': account['profile']}
|
||||
|
||||
@app.post('/api/account/logout')
|
||||
def logout(request: Request, response: Response):
|
||||
previous = current(request)
|
||||
if previous:
|
||||
with db.connect() as c:
|
||||
c.execute('DELETE FROM dtf_local.sessions WHERE id=%s', (previous['id'],))
|
||||
response.delete_cookie('dtf_session', httponly=True, samesite='strict')
|
||||
response.headers['Clear-Site-Data'] = '"storage"'
|
||||
audit('customer_logout')
|
||||
return {'ok': True}
|
||||
|
||||
@app.get('/api/account/me')
|
||||
def me(identity=Depends(owner)):
|
||||
with db.connect() as c:
|
||||
row = c.execute('SELECT profile FROM dtf_local.accounts WHERE id=%s', (identity,)).fetchone()
|
||||
return {'customer': row['profile'] if row else None}
|
||||
|
||||
def owned_order(c, oid, identity, lock=False):
|
||||
row = c.execute('SELECT * FROM dtf_local.orders WHERE id=%s AND owner=%s'+(' FOR UPDATE' if lock else ''), (oid,identity)).fetchone()
|
||||
if not row: raise HTTPException(404, 'Order not found')
|
||||
return row
|
||||
|
||||
def file_rows(c, oid):
|
||||
return c.execute('''SELECT f.id,f.upload_id,f.item_index,f.kind,f.active,f.note,f.created_at,
|
||||
u.name,u.size,u.expires_at,(u.expires_at<=now()) AS expired
|
||||
FROM dtf_local.order_files f JOIN dtf_local.uploads u ON u.id=f.upload_id
|
||||
WHERE order_id=%s ORDER BY f.created_at''', (oid,)).fetchall()
|
||||
|
||||
@app.get('/api/customer/orders')
|
||||
def orders(identity=Depends(owner)):
|
||||
with db.connect() as c:
|
||||
rows = c.execute('SELECT id,number,state,version,snapshot,created_at,updated_at FROM dtf_local.orders WHERE owner=%s ORDER BY created_at DESC', (identity,)).fetchall()
|
||||
quotes = c.execute('''SELECT q.id,q.approved,q.approved_at,q.created_at FROM dtf_local.quotes q
|
||||
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id WHERE q.owner=%s AND o.id IS NULL ORDER BY q.created_at DESC''', (identity,)).fetchall()
|
||||
return {'orders': rows, 'quotes': quotes, 'states': states}
|
||||
|
||||
@app.get('/api/customer/orders/{oid}')
|
||||
def detail(oid: UUID, identity=Depends(owner)):
|
||||
with db.connect() as c:
|
||||
row = owned_order(c, oid, identity)
|
||||
history = c.execute('SELECT from_state,to_state,reason,created_at FROM dtf_local.movements WHERE order_id=%s ORDER BY id', (oid,)).fetchall()
|
||||
return {'id': row['id'], 'number': row['number'], 'state': row['state'], 'version': row['version'],
|
||||
'snapshot': row['snapshot'], 'history': history, 'files': file_rows(c,oid)}
|
||||
|
||||
def submit_files(c, order, body, identity, kind, actor):
|
||||
if order['version'] != body.version:
|
||||
raise HTTPException(409, 'Order changed. Refresh before submitting files.')
|
||||
if kind == 'final' and order['state'] not in ('rec','tra','cor'):
|
||||
raise HTTPException(409, 'Final files can only change during artwork review')
|
||||
if kind == 'correction' and order['state'] != 'cor':
|
||||
raise HTTPException(409, 'This order is not awaiting artwork correction')
|
||||
if len({f.upload_id for f in body.files}) != len(body.files):
|
||||
raise HTTPException(422, 'Each uploaded file must appear once')
|
||||
count = len(order['snapshot']['items'])
|
||||
if any(f.item_index >= count for f in body.files):
|
||||
raise HTTPException(422, 'Invalid order item')
|
||||
if kind == 'final' and {f.item_index for f in body.files} != set(range(count)):
|
||||
raise HTTPException(422, 'Final-file set must cover every order item')
|
||||
original_ids = [UUID(uid) for item in order['snapshot']['items'] for uid in item['uploads']]
|
||||
first = c.execute('SELECT min(created_at) AS first FROM dtf_local.uploads WHERE id=ANY(%s)', (original_ids,)).fetchone()['first']
|
||||
expiry = first + timedelta(days=30)
|
||||
if expiry <= datetime.now(timezone.utc):
|
||||
raise HTTPException(410, 'Order artwork retention has expired')
|
||||
for ref in body.files:
|
||||
upload = upload_row(c, ref.upload_id, identity, lock=True)
|
||||
if not upload['complete']:
|
||||
raise HTTPException(409, 'Complete all uploads first')
|
||||
require_clean(upload)
|
||||
if c.execute('SELECT id FROM dtf_local.order_files WHERE upload_id=%s', (ref.upload_id,)).fetchone():
|
||||
raise HTTPException(409, 'File is already attached. Upload a new revision.')
|
||||
c.execute('UPDATE dtf_local.order_files SET active=false WHERE order_id=%s AND kind=%s', (order['id'],kind))
|
||||
for ref in body.files:
|
||||
c.execute('INSERT INTO dtf_local.order_files(id,order_id,upload_id,item_index,kind,note,created_by) VALUES(%s,%s,%s,%s,%s,%s,%s)',
|
||||
(uuid4(),order['id'],ref.upload_id,ref.item_index,kind,body.note,actor))
|
||||
c.execute('UPDATE dtf_local.uploads SET expires_at=LEAST(expires_at,%s) WHERE id=%s', (expiry,ref.upload_id))
|
||||
c.execute('UPDATE dtf_local.orders SET version=version+1,updated_at=now() WHERE id=%s', (order['id'],))
|
||||
if kind == 'final':
|
||||
# Artwork approval, not commercial quote approval, starts original cleanup.
|
||||
c.execute("UPDATE dtf_local.uploads SET expires_at=LEAST(expires_at,now()+interval '7 days') WHERE id=ANY(%s)", (original_ids,))
|
||||
return {'ok': True, 'version': order['version']+1, 'expires_at': expiry}
|
||||
|
||||
@app.post('/api/customer/orders/{oid}/corrections')
|
||||
def correction(oid: UUID, body: ArtworkSubmission, identity=Depends(owner)):
|
||||
with db.connect() as c:
|
||||
order = owned_order(c, oid, identity, lock=True)
|
||||
return submit_files(c,order,body,identity,'correction','customer')
|
||||
|
||||
@app.get('/api/customer/orders/{oid}/files/{fid}/download')
|
||||
def customer_download(oid: UUID, fid: UUID, identity=Depends(owner)):
|
||||
with db.connect() as c:
|
||||
owned_order(c,oid,identity)
|
||||
row = c.execute('SELECT u.* FROM dtf_local.order_files f JOIN dtf_local.uploads u ON u.id=f.upload_id WHERE f.id=%s AND f.order_id=%s AND f.active', (fid,oid)).fetchone()
|
||||
if not row: raise HTTPException(404, 'Active file not found')
|
||||
if row['expires_at'] <= datetime.now(timezone.utc): raise HTTPException(410, 'File expired')
|
||||
require_clean(row)
|
||||
return {'url':storage.download(row['object_key'],row['name']), 'name':row['name']}
|
||||
|
||||
def operator_identity(user):
|
||||
return uuid5(NAMESPACE_URL, 'dtf-local-operator:'+user)
|
||||
|
||||
@app.post('/api/operator/orders/{oid}/uploads')
|
||||
def begin_final(oid: UUID, body: UploadStart, user=Depends(operator)):
|
||||
with db.connect() as c:
|
||||
row = c.execute('SELECT state FROM dtf_local.orders WHERE id=%s', (oid,)).fetchone()
|
||||
if not row: raise HTTPException(404, 'Order not found')
|
||||
if row['state'] not in ('rec','tra','cor'): raise HTTPException(409, 'Order is not in artwork review')
|
||||
return begin(body, session_id=operator_identity(user))
|
||||
|
||||
@app.get('/api/operator/uploads/{uid}')
|
||||
def final_status(uid: UUID, user=Depends(operator)):
|
||||
return status(uid,session_id=operator_identity(user))
|
||||
|
||||
@app.post('/api/operator/uploads/{uid}/parts/{number}')
|
||||
def final_part(uid: UUID, number: int, user=Depends(operator)):
|
||||
return part(uid,number,session_id=operator_identity(user))
|
||||
|
||||
@app.post('/api/operator/uploads/{uid}/complete')
|
||||
def final_complete(uid: UUID, user=Depends(operator)):
|
||||
return complete(uid,session_id=operator_identity(user))
|
||||
|
||||
@app.get('/api/operator/orders/{oid}/files')
|
||||
def operator_files(oid: UUID, user=Depends(operator)):
|
||||
with db.connect() as c:
|
||||
return file_rows(c,oid)
|
||||
|
||||
@app.post('/api/operator/orders/{oid}/final-files')
|
||||
def final_files(oid: UUID, body: ArtworkSubmission, user=Depends(operator)):
|
||||
with db.connect() as c:
|
||||
order = c.execute('SELECT * FROM dtf_local.orders WHERE id=%s FOR UPDATE', (oid,)).fetchone()
|
||||
if not order: raise HTTPException(404, 'Order not found')
|
||||
return submit_files(c,order,body,operator_identity(user),'final',user)
|
||||
85
local/runtime.py
Normal file
85
local/runtime.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Composition root, and the pieces every router needs.
|
||||
|
||||
app.py held the adapters, the configuration, the shared query helpers and all
|
||||
nineteen of its routes, so customer.py could not import from it without a cycle
|
||||
and was wired instead by passing nine callables into install_routes. Everything
|
||||
shared lives here: routers import downwards, never from each other.
|
||||
"""
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import uuid5, NAMESPACE_URL
|
||||
|
||||
from fastapi import HTTPException
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from .adapters import FakeFreight, FakePayment, LocalS3Storage, require_runtime
|
||||
from .secrets import load as load_secret_files
|
||||
|
||||
# Secret files must resolve before any configuration below is read.
|
||||
load_secret_files()
|
||||
require_runtime()
|
||||
|
||||
storage = LocalS3Storage()
|
||||
payment = FakePayment()
|
||||
freight = FakeFreight()
|
||||
ENVIRONMENT = os.environ.get('APP_ENV', 'local')
|
||||
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_ORIGINS = [origin for origin in os.environ.get('ALLOWED_ORIGINS', PUBLIC_ORIGIN).split(',') if origin]
|
||||
# Per source and generous: a browser needs one session and keeps the cookie, but
|
||||
# offices and mobile carriers put many real customers behind one address, so a
|
||||
# tight per-IP ceiling would lock out the same people the old global one did.
|
||||
GUEST_SESSION_LIMIT = int(os.environ.get('GUEST_SESSION_LIMIT', '240'))
|
||||
# The board returned every order ever created. Finished ones are terminal, so
|
||||
# they were pure growth: at a few hundred a day the response and the page both
|
||||
# degrade with no operator benefit.
|
||||
BOARD_FINISHED_LIMIT = int(os.environ.get('BOARD_FINISHED_LIMIT', '50'))
|
||||
BOARD_QUOTE_LIMIT = int(os.environ.get('BOARD_QUOTE_LIMIT', '100'))
|
||||
PART_BYTES = int(os.environ.get('UPLOAD_PART_BYTES', '8388608'))
|
||||
if not 5242880 <= PART_BYTES <= 67108864:
|
||||
raise RuntimeError('UPLOAD_PART_BYTES must be between 5 and 64 MiB')
|
||||
STATES = {'rec': 'Arte recebida', 'tra': 'Arte tratada', 'fil': 'Fila de impressão',
|
||||
'imp': 'Imprimindo', 'cor': 'Correção', 'fin': 'Finalizado'}
|
||||
TRANSITIONS = {'rec': ['tra','cor'], 'tra': ['fil','cor'], 'fil': ['imp','cor'],
|
||||
'imp': ['fin','cor'], 'cor': ['rec','tra'], 'fin': []}
|
||||
|
||||
|
||||
def upload_row(c, upload_id, session_id, lock=False):
|
||||
row = c.execute('SELECT * FROM dtf_local.uploads WHERE id=%s AND owner=%s' +
|
||||
(' FOR UPDATE' if lock else ''), (upload_id, session_id)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, 'Upload not found')
|
||||
days = 30 if row['complete'] else 1
|
||||
if row['purged_at'] or row['expires_at'] <= datetime.now(timezone.utc) or row['created_at'] < datetime.now(timezone.utc) - timedelta(days=days):
|
||||
raise HTTPException(410, 'Upload expired; select the file again')
|
||||
return row
|
||||
|
||||
|
||||
def quote_view(c, row):
|
||||
order = c.execute('SELECT id,number,state FROM dtf_local.orders WHERE quote_id=%s', (row['id'],)).fetchone()
|
||||
expired = row['approved_at'] and row['approved_at'] < datetime.now(timezone.utc)-timedelta(hours=24)
|
||||
return {'id': row['id'], 'draft': row['draft'], 'approved': row['approved'],
|
||||
'status': 'paid' if order else 'expired' if expired else 'approved' if row['approved'] else 'pending_review',
|
||||
'order': order}
|
||||
|
||||
|
||||
def enqueue(c, event_key, provider, payload):
|
||||
c.execute('INSERT INTO dtf_local.outbox(event_key,provider,payload) VALUES(%s,%s,%s) ON CONFLICT(event_key) DO NOTHING',
|
||||
(event_key, provider, Jsonb(payload)))
|
||||
|
||||
|
||||
def owned_order(c, oid, identity, lock=False):
|
||||
row = c.execute('SELECT * FROM dtf_local.orders WHERE id=%s AND owner=%s'+(' FOR UPDATE' if lock else ''), (oid,identity)).fetchone()
|
||||
if not row: raise HTTPException(404, 'Order not found')
|
||||
return row
|
||||
|
||||
|
||||
def file_rows(c, oid):
|
||||
return c.execute('''SELECT f.id,f.upload_id,f.item_index,f.kind,f.active,f.note,f.created_at,
|
||||
u.name,u.size,u.expires_at,(u.expires_at<=now()) AS expired
|
||||
FROM dtf_local.order_files f JOIN dtf_local.uploads u ON u.id=f.upload_id
|
||||
WHERE order_id=%s ORDER BY f.created_at''', (oid,)).fetchall()
|
||||
|
||||
|
||||
def operator_identity(user):
|
||||
return uuid5(NAMESPACE_URL, 'dtf-local-operator:'+user)
|
||||
@@ -1,47 +0,0 @@
|
||||
/* Browser-local cart recovery. Files are stored only for an unfinished cart, for 24h. */
|
||||
(() => {
|
||||
let database,scope,timer,restoring=true;
|
||||
let signedOut=false;
|
||||
window.addEventListener('dtf-private-data-cleared',()=>{signedOut=true;clearTimeout(timer);pedido=[];itemAtual=null;folhas=[];artes=[];});
|
||||
const notice=document.createElement('p');notice.style.cssText='font:13px system-ui;color:#56616d;padding:8px 20px';
|
||||
document.getElementById('carr').prepend(notice);
|
||||
function transaction(mode,fn){return new Promise((resolve,reject)=>{const tx=database.transaction('cart',mode);const request=fn(tx.objectStore('cart'));let result;request.onsuccess=()=>result=request.result;tx.oncomplete=()=>resolve(result);tx.onerror=()=>reject(tx.error);tx.onabort=()=>reject(tx.error);});}
|
||||
async function save(){
|
||||
if(signedOut||restoring||!database||!scope)return;
|
||||
const items=[...pedido,...(itemAtual?[itemAtual]:[])];
|
||||
try{
|
||||
if(!items.length){await transaction('readwrite',store=>store.delete(scope));return;}
|
||||
await transaction('readwrite',store=>store.put({items,customer:{...cliente},delivery:{...entrega},expires:Date.now()+86400000},scope));
|
||||
notice.textContent='Carrinho salvo neste navegador por 24 horas. Você pode remover itens e adicionar outros após recarregar.';
|
||||
}catch(error){notice.textContent='Não foi possível salvar o carrinho neste navegador. Mantenha esta página aberta até enviar o pedido.';}
|
||||
}
|
||||
window.dtfClearCart=async()=>{clearTimeout(timer);if(database&&scope)await transaction('readwrite',store=>store.delete(scope));notice.textContent='';};
|
||||
window.addEventListener('dtf-cart-changed',()=>{clearTimeout(timer);timer=setTimeout(save,400);});
|
||||
window.addEventListener('pagehide',()=>{clearTimeout(timer);save();});
|
||||
setInterval(async()=>{
|
||||
if(!database||signedOut)return;
|
||||
try{const keys=await transaction('readonly',s=>s.getAllKeys());for(const key of keys){const value=await transaction('readonly',s=>s.get(key));if(value&&value.expires<Date.now())await transaction('readwrite',s=>s.delete(key));}}catch{/* Browser may close storage during navigation. */}
|
||||
},60000);
|
||||
(async()=>{
|
||||
try{
|
||||
scope=(await window.dtfSessionReady).cart_scope;
|
||||
database=await new Promise((resolve,reject)=>{const req=indexedDB.open('dtf-local-cart',1);req.onupgradeneeded=()=>req.result.createObjectStore('cart');req.onsuccess=()=>resolve(req.result);req.onerror=()=>reject(req.error);});
|
||||
const records=await transaction('readonly',store=>store.getAllKeys());
|
||||
for(const key of records){const value=await transaction('readonly',store=>store.get(key));if(value.expires<Date.now())await transaction('readwrite',store=>store.delete(key));}
|
||||
const saved=await transaction('readonly',store=>store.get(scope));
|
||||
if(saved && !pedido.length && !itemAtual){
|
||||
pedido=saved.items;cliente=saved.customer;entrega=saved.delivery;
|
||||
// Freight must be quoted again; restored browser values are never final.
|
||||
if(entrega.tipo==='frete'){entrega.cotado=false;entrega.valor=0;}
|
||||
for(const [id,key] of [['fCnpj','cnpj'],['fZap','zap'],['fMail','mail']])$(id).value=cliente[key];
|
||||
$('cepIn').value=entrega.cep;
|
||||
$('atual').style.display='none';pintaEntrega();
|
||||
notice.textContent='Carrinho recuperado. Remova e adicione novamente um item se precisar alterar sua montagem.';
|
||||
}else{
|
||||
const account=await window.dtfApi('/account/me');
|
||||
if(account.customer && !cliente.mail){cliente={...account.customer};for(const [id,key] of [['fCnpj','cnpj'],['fZap','zap'],['fMail','mail']])$(id).value=cliente[key];pintaEntrega();}
|
||||
}
|
||||
}catch(error){notice.textContent='Recuperação de carrinho indisponível; o pedido ainda pode ser feito nesta sessão.';}
|
||||
finally{restoring=false;}
|
||||
})();
|
||||
})();
|
||||
@@ -1,121 +0,0 @@
|
||||
/* Checkout bridge only: approved commercial functions in dtf-site.html stay intact. */
|
||||
(() => {
|
||||
const status = document.getElementById('checkoutStatus');
|
||||
const actions = document.getElementById('checkoutActions');
|
||||
let busy = false;
|
||||
let draftId = localStorage.getItem('dtf-quote');
|
||||
let requestKey = localStorage.getItem('dtf-request-key');
|
||||
let requestBody = localStorage.getItem('dtf-request-body');
|
||||
const api = async (path, body) => {
|
||||
const response = await fetch('/api'+path, {
|
||||
credentials: 'same-origin', headers: {'Content-Type':'application/json'},
|
||||
...(body === undefined ? {} : {method:'POST', body:JSON.stringify(body)})
|
||||
});
|
||||
const text = await response.text();
|
||||
let data;
|
||||
try { data = text ? JSON.parse(text) : {}; }
|
||||
catch (_) { throw new Error(response.ok ? 'Resposta inválida do serviço.' : 'O serviço está indisponível. Tente novamente em instantes.'); }
|
||||
if (!response.ok) { const error=new Error(typeof data.detail === 'string' ? data.detail : 'Confira os dados do pedido ('+response.status+').');error.status=response.status;throw error; }
|
||||
return data;
|
||||
};
|
||||
const ready = api('/session');
|
||||
window.dtfSessionReady=ready;
|
||||
window.dtfApi=api;
|
||||
ready.catch(error => { status.textContent = error.message; });
|
||||
function message(text) { status.textContent = text; }
|
||||
function button(label, handler) {
|
||||
const el = document.createElement('button');
|
||||
el.textContent = label;
|
||||
el.style.cssText = 'margin:8px 8px 0 0;padding:8px 14px;cursor:pointer';
|
||||
el.onclick = handler;
|
||||
actions.append(el);
|
||||
return el;
|
||||
}
|
||||
async function upload(file) {
|
||||
const session=await ready;
|
||||
return window.dtfUpload(file,{api,progress:message,scope:session.cart_scope});
|
||||
}
|
||||
window.dtfFreight = async () => {
|
||||
const cep = entrega.cep;
|
||||
try {
|
||||
const result = await api('/freight',{service:'mock-standard',postal_code:cep});
|
||||
if (entrega.cep !== cep || entrega.tipo !== 'frete') return;
|
||||
entrega.valor = result.total_cents/100;
|
||||
entrega.cotado = true;
|
||||
$('cepMsg').textContent = 'Frete estimado: '+rs(entrega.valor)+'.';
|
||||
pintaEntrega();
|
||||
} catch(error) { $('cepMsg').textContent = error.message; }
|
||||
};
|
||||
window.dtfCheckout = async () => {
|
||||
if (busy) return;
|
||||
if (draftId) { await refresh(); status.scrollIntoView({behavior:'smooth',block:'nearest'}); return; }
|
||||
if (!clienteOk() || !entrega.cotado) return;
|
||||
const cart = [...pedido,...(itemAtual?[itemAtual]:[])];
|
||||
if (!cart.length) return message('Adicione um item ao pedido.');
|
||||
busy = true;
|
||||
$('bPagar').disabled = true;
|
||||
try {
|
||||
await ready;
|
||||
if((await api('/session')).cart_scope !== (await ready).cart_scope) throw new Error('Sua conta ou sessão mudou. Recarregue a página antes de enviar o carrinho.');
|
||||
const items=[];
|
||||
for (const item of cart) {
|
||||
if (!item.localFiles?.length) throw new Error('Selecione novamente os arquivos deste item.');
|
||||
const uploads=[];
|
||||
for (const file of item.localFiles) uploads.push(await upload(file));
|
||||
items.push({mode:item.modo, metres:String(item.metros), grade:item.nota, uploads});
|
||||
}
|
||||
const content = {customer:{...cliente},items,freight:{service:entrega.tipo==='retira'?'pickup':'mock-standard',postal_code:entrega.tipo==='retira'?'':entrega.cep}};
|
||||
const serialized = JSON.stringify(content);
|
||||
if (!requestKey || serialized !== requestBody) {
|
||||
requestKey = crypto.randomUUID(); requestBody = serialized;
|
||||
localStorage.setItem('dtf-request-key',requestKey);
|
||||
localStorage.setItem('dtf-request-body',requestBody);
|
||||
}
|
||||
const quote = await api('/quotes',{request_key:requestKey,...content});
|
||||
draftId=quote.id; localStorage.setItem('dtf-quote',draftId);
|
||||
await refresh();
|
||||
status.scrollIntoView({behavior:'smooth',block:'nearest'});
|
||||
} catch(error) { message(error.message); }
|
||||
finally { busy=false; pintaEntrega(); }
|
||||
};
|
||||
async function refresh() {
|
||||
if (!draftId) return;
|
||||
try {
|
||||
await ready;
|
||||
const quote=await api('/quotes/'+draftId);
|
||||
actions.replaceChildren();
|
||||
if (quote.status==='pending_review') {
|
||||
message('Arquivos enviados. No Kanban, confira metragem e nota e aprove a cotação '+draftId.slice(0,8)+'.');
|
||||
} else if (quote.status==='approved') {
|
||||
message('Total validado no servidor: '+rs(quote.approved.total_cents/100)+' · inclui frete. Cotação válida por 24 horas.');
|
||||
if ((await ready).environment !== 'local') {
|
||||
message('Cotação revisada. O pagamento online ainda não está disponível.');
|
||||
return;
|
||||
}
|
||||
button('Criar pedido de teste',async event=>{
|
||||
event.target.disabled=true;
|
||||
try {
|
||||
const order=await api('/orders/dev-paid',{quote_id:draftId});
|
||||
pedido=[]; itemAtual=null; limpaPaineis();
|
||||
await window.dtfClearCart?.();
|
||||
message('Pedido #'+order.number+' criado e disponível no Kanban.');
|
||||
await refresh();
|
||||
} 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',()=>{localStorage.removeItem('dtf-quote');localStorage.removeItem('dtf-request-key');localStorage.removeItem('dtf-request-body');location.reload();});
|
||||
} else {
|
||||
message('Cotação expirada. Envie o carrinho para uma nova revisão.');
|
||||
button('Nova cotação',()=>{draftId=null;requestKey=null;localStorage.removeItem('dtf-quote');localStorage.removeItem('dtf-request-key');actions.replaceChildren();});
|
||||
}
|
||||
} catch(error) {
|
||||
message(error.message);
|
||||
actions.replaceChildren();
|
||||
button('Limpar referência e tentar de novo',()=>{draftId=null;requestKey=null;localStorage.removeItem('dtf-quote');localStorage.removeItem('dtf-request-key');actions.replaceChildren();});
|
||||
}
|
||||
}
|
||||
const quoteFromPortal=new URLSearchParams(location.search).get('quote');
|
||||
if(quoteFromPortal && /^[0-9a-f-]{36}$/.test(quoteFromPortal)){draftId=quoteFromPortal;localStorage.setItem('dtf-quote',draftId);}
|
||||
refresh();
|
||||
})();
|
||||
@@ -1,21 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>DTF · Kanban local</title>
|
||||
<style>
|
||||
:root{--bg:#0B0D10;--card:#15181D;--card2:#1C2026;--linha:#282C34;--tx:#EDEBE6;--fraco:#a1a6af;--ciano:#00B8DA}
|
||||
*{box-sizing:border-box}body{background:var(--bg);color:var(--tx);font:14px/1.5 system-ui,sans-serif;padding:20px;margin:0}
|
||||
h1{font-size:24px;margin:0}h2{font-size:18px}header{display:flex;align-items:center;gap:18px;flex-wrap:wrap;margin-bottom:20px}
|
||||
.aviso{background:#152026;border-left:3px solid var(--ciano);padding:12px;color:#b9cbd2;margin:16px 0}
|
||||
button,input,select{font:inherit;border:1px solid #56616d;border-radius:5px;padding:8px;background:var(--card2);color:var(--tx)}
|
||||
button{cursor:pointer}button:hover{border-color:var(--ciano)}button:disabled{opacity:.5}input[type=number]{width:100px}
|
||||
label{display:inline-flex;gap:8px;align-items:center;margin:5px}#kan{display:grid;grid-template-columns:repeat(6,minmax(220px,1fr));gap:10px;overflow-x:auto;padding-bottom:16px}
|
||||
.col{background:var(--card);border:1px solid var(--linha);border-radius:9px;min-height:250px;padding:10px}.col h2{font-size:14px;border-bottom:2px solid var(--cc);padding-bottom:10px}.col.alvo{border-color:var(--ciano)}
|
||||
.cd,.review{background:var(--card2);border:1px solid var(--linha);border-left:3px solid var(--cc,var(--ciano));border-radius:6px;padding:12px;margin-bottom:10px}.cd{cursor:grab}.cd p{overflow-wrap:anywhere}.meta{color:var(--fraco);font-size:12px}.actions{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}.error{color:#ffad83}#status{min-height:24px}details{margin:16px 0}pre{white-space:pre-wrap;overflow-wrap:anywhere}a{color:var(--ciano)}
|
||||
</style></head><body>
|
||||
<header><h1>Kanban DTF</h1><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>
|
||||
<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>
|
||||
<p id="status" role="status"></p>
|
||||
<section id="reviews"></section><div id="kan"></div>
|
||||
<details><summary>Eventos locais de integração</summary><pre id="events"></pre></details>
|
||||
<script src="/upload.js"></script><script src="/kanban.js?v=operator-email-1"></script></body></html>
|
||||
@@ -1,112 +0,0 @@
|
||||
/* Reuses the prototype palette, column names and drag/drop interaction; no machine controls. */
|
||||
const $ = id=>document.getElementById(id);
|
||||
// Remove credentials saved by older local builds. Only HttpOnly sessions now.
|
||||
sessionStorage.removeItem('dtf-operator');
|
||||
let board;
|
||||
const money=cents=>(cents/100).toLocaleString('pt-BR',{style:'currency',currency:'BRL'});
|
||||
function node(tag,text,className){const e=document.createElement(tag);if(text!==undefined)e.textContent=text;if(className)e.className=className;return e;}
|
||||
function action(text,fn){const b=node('button',text);b.onclick=async()=>{b.disabled=true;try{await fn();}catch(e){$('status').textContent=e.message;}finally{b.disabled=false;}};return b;}
|
||||
async function api(path,body){
|
||||
const r=await fetch('/api/operator'+path,{headers:{'Content-Type':'application/json'},...(body===undefined?{}:{method:'POST',body:JSON.stringify(body)})});
|
||||
const d=await r.json();if(!r.ok){const error=new Error(typeof d.detail==='string'?d.detail:'Confira os campos ('+r.status+').');error.status=r.status;throw error;}return d;
|
||||
}
|
||||
function files(container,items){
|
||||
for(const uid of [...new Set(items.flatMap(i=>i.uploads))])container.append(action('Baixar original '+uid.slice(0,6),async()=>{
|
||||
const result=await api('/uploads/'+uid+'/download');
|
||||
const link=node('a');link.href=result.url;link.referrerPolicy='no-referrer';link.download=result.name;link.click();
|
||||
}));
|
||||
}
|
||||
async function load(){
|
||||
try{
|
||||
board=await api('/board');$('login').hidden=true;
|
||||
$('status').textContent='Atualizado às '+new Date().toLocaleTimeString();
|
||||
render();
|
||||
}catch(e){$('status').textContent=e.message;$('login').hidden=false;}
|
||||
}
|
||||
function render(){
|
||||
$('reviews').replaceChildren();
|
||||
if(board.quotes.length)$('reviews').append(node('h2','Cotações · conferência comercial'));
|
||||
for(const quote of board.quotes){
|
||||
const card=node('article',undefined,'review');
|
||||
card.append(node('b',quote.id.slice(0,8)+' · '+quote.draft.customer.mail));
|
||||
if(quote.status!=='pending_review'){
|
||||
card.append(node('p',quote.status==='expired'?'Cotação expirada. O cliente precisa solicitar outra.':'Aprovada: '+money(quote.approved.total_cents)+'. Volte ao Site, atualize o pedido e confirme o pagamento local.'));
|
||||
}else{
|
||||
const form=node('form');
|
||||
const edits=quote.draft.items.map((item,index)=>{
|
||||
const row=node('div');row.append(node('strong',(index+1)+'. '+item.mode+' '));
|
||||
const metres=node('input');metres.type='number';metres.min='0.001';metres.max='12000';metres.step='any';metres.value=item.metres;metres.required=true;
|
||||
const grade=node('input');grade.type='number';grade.min='0';grade.max='100';grade.step='1';grade.value=item.grade;grade.required=true;
|
||||
const ml=node('label','Metros conferidos');ml.append(metres);const gl=node('label','Nota conferida');gl.append(grade);row.append(ml,gl);form.append(row);
|
||||
return ()=>({...item,metres:metres.value,grade:Number(grade.value)});
|
||||
});
|
||||
const check=node('input');check.type='checkbox';check.required=true;const label=node('label','Conferi os arquivos, a metragem total (incluindo repetições/montagem) e a nota.');label.prepend(check);form.append(label);
|
||||
const submit=node('button','Aprovar cotação');submit.type='submit';form.append(submit);
|
||||
form.onsubmit=async e=>{e.preventDefault();submit.disabled=true;try{await api('/quotes/'+quote.id+'/approve',{items:edits.map(fn=>fn())});await load();}catch(error){$('status').textContent=error.message;submit.disabled=false;}};
|
||||
card.append(form);
|
||||
}
|
||||
const downloads=node('div',undefined,'actions');files(downloads,quote.draft.items);card.append(downloads);$('reviews').append(card);
|
||||
}
|
||||
$('kan').replaceChildren();
|
||||
const colors=['#00b8da','#e0357c','#efb700','#edebe6','#e0642a','#48b072'];
|
||||
Object.entries(board.states).forEach(([state,title],index)=>{
|
||||
const column=node('section',undefined,'col');column.dataset.state=state;column.style.setProperty('--cc',colors[index]);
|
||||
const orders=board.orders.filter(o=>o.state===state);
|
||||
// Finished orders are a recent window, not the whole history: say so rather
|
||||
// than let the count read as an all-time total.
|
||||
const count=state==='fin'&&board.finished_total>orders.length
|
||||
? orders.length+' de '+board.finished_total : String(orders.length);
|
||||
column.append(node('h2',title+' · '+count));
|
||||
for(const order of orders){
|
||||
const card=node('article',undefined,'cd');card.draggable=true;card.dataset.order=order.id;
|
||||
card.append(node('b','#'+order.number+' · Pago local'),node('p',order.snapshot.customer.mail,'meta'),node('p',money(order.snapshot.total_cents)));
|
||||
for(const item of order.snapshot.items)card.append(node('p',item.mode+' · '+item.billed_metres+' m · nota '+item.grade,'meta'));
|
||||
const actions=node('div',undefined,'actions');files(actions,order.snapshot.items);
|
||||
const artwork=node('div');
|
||||
actions.append(action('Arquivos de produção',()=>artworkPanel(order,artwork)));
|
||||
actions.append(action('Histórico',async()=>{const rows=await api('/orders/'+order.id+'/history');alert(rows.map(r=>board.states[r.from_state]+' → '+board.states[r.to_state]+' · '+r.operator+(r.reason?' · '+r.reason:'')).join('\n')||'Pedido recebido.');}));
|
||||
for(const next of board.transitions[state])actions.append(action('→ '+board.states[next],()=>move(order,next)));
|
||||
card.append(actions,artwork);card.ondragstart=e=>e.dataTransfer.setData('text/plain',order.id);column.append(card);
|
||||
}
|
||||
column.ondragover=e=>{e.preventDefault();column.classList.add('alvo');};column.ondragleave=()=>column.classList.remove('alvo');
|
||||
column.ondrop=async e=>{e.preventDefault();column.classList.remove('alvo');const order=board.orders.find(o=>o.id===e.dataTransfer.getData('text/plain'));if(order)try{await move(order,state);}catch(error){$('status').textContent=error.message;}};
|
||||
$('kan').append(column);
|
||||
});
|
||||
$('events').textContent=board.events.map(e=>e.provider+' · '+e.payload.event+' · pedido #'+e.payload.number+' · '+(e.delivered_at?'registrado localmente':'pendente')+' · tentativas '+e.attempts).join('\n')||'Nenhum evento.';
|
||||
}
|
||||
async function move(order,state){
|
||||
let reason='';if(state==='cor'){reason=prompt('Motivo da correção:');if(!reason)return;}
|
||||
await api('/orders/'+order.id+'/move',{state,version:order.version,reason});await load();
|
||||
}
|
||||
$('login').onsubmit=async e=>{e.preventDefault();try{await api('/login',{email:$('email').value,password:$('password').value});await load();}catch(error){$('status').textContent=error.message;}finally{$('password').value='';}};
|
||||
$('logout').onclick=async()=>{await api('/logout',{});for(const key of Object.keys(localStorage))if(key.startsWith('dtf-'))localStorage.removeItem(key);location.reload();};
|
||||
$('refresh').onclick=load;
|
||||
load();
|
||||
|
||||
async function artworkPanel(order,container){
|
||||
container.replaceChildren();
|
||||
const revisions=await api('/orders/'+order.id+'/files');
|
||||
for(const file of revisions){
|
||||
const row=node('p',(file.kind==='final'?'Final':'Correção do cliente')+' · item '+(file.item_index+1)+' · '+file.name+' · '+(file.expired?'expirado':file.active?'atual':'substituído'),'meta');
|
||||
row.append(node('p',file.note));
|
||||
if(!file.expired)row.append(action('Baixar '+file.name,async()=>{const result=await api('/uploads/'+file.upload_id+'/download');const link=node('a');link.href=result.url;link.download=result.name;link.referrerPolicy='no-referrer';link.click();}));
|
||||
container.append(row);
|
||||
}
|
||||
if(!['rec','tra','cor'].includes(order.state))return;
|
||||
const form=node('form');
|
||||
form.append(node('p','Enviar um conjunto final completo. Pode haver várias partes por item. Um novo conjunto substitui o anterior.'));
|
||||
const inputs=order.snapshot.items.map((item,index)=>{const label=node('label','Item '+(index+1)+' · '+item.mode);label.style.display='block';const input=node('input');input.type='file';input.multiple=true;input.required=true;input.dataset.finalItem=index;input.style.width='100%';label.append(input);form.append(label);return input;});
|
||||
const note=node('input');note.placeholder='Nota da revisão';note.required=true;note.maxLength=1000;note.style.width='100%';form.append(note);
|
||||
const check=node('input');check.type='checkbox';check.required=true;const label=node('label','Conferi estes arquivos finais para impressão manual.');label.prepend(check);form.append(label);
|
||||
const submit=node('button','Aprovar arquivos finais');submit.type='submit';form.append(submit);
|
||||
form.onsubmit=async event=>{event.preventDefault();submit.disabled=true;try{
|
||||
const refs=[];
|
||||
for(const [index,input] of inputs.entries())for(const file of input.files){
|
||||
const uid=await dtfUpload(file,{api,startPath:'/orders/'+order.id+'/uploads',scope:'operator:'+order.id+':'+order.version,progress:text=>$('status').textContent=text});
|
||||
refs.push({item_index:index,upload_id:uid});
|
||||
}
|
||||
await api('/orders/'+order.id+'/final-files',{version:order.version,files:refs,note:note.value});
|
||||
await load();
|
||||
}catch(error){$('status').textContent=error.message;submit.disabled=false;}};
|
||||
container.append(form);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<!doctype html><html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Dropstar · Meus pedidos DTF</title>
|
||||
<style>
|
||||
*{box-sizing:border-box}body{margin:0;background:#f6f7f9;color:#20252d;font:15px/1.5 system-ui,sans-serif}header{background:white;border-bottom:1px solid #ddd;padding:20px max(20px,calc((100% - 1100px)/2));display:flex;gap:24px;align-items:center;flex-wrap:wrap}.brand{color:#ef8500;font-size:24px;font-style:italic;font-weight:900}a{color:#8a4e00}main{max-width:1100px;margin:24px auto;padding:0 20px}h1{font-size:28px}h2{font-size:21px}h3{font-size:17px}.notice{background:#fff1d4;border-left:4px solid #ffa900;padding:12px 16px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:20px}.card{background:white;border:1px solid #ddd;border-radius:10px;padding:20px;margin:16px 0}.card p{overflow-wrap:anywhere}.muted{color:#596471}label{display:block;margin:10px 0}input,textarea,button{font:inherit;border:1px solid #9aa0a8;border-radius:6px;padding:10px}input:not([type=file]),textarea{width:100%}button{background:#ffa900;border-color:#ffa900;cursor:pointer}button:disabled{opacity:.6}form{margin:10px 0}.actions{display:flex;gap:10px;flex-wrap:wrap;align-items:center}#message{min-height:24px;color:#9b3800}li{margin:8px 0}.tag{background:#eef2f6;padding:5px 10px;border-radius:6px}details{margin:12px 0}[hidden]{display:none!important}
|
||||
</style></head><body>
|
||||
<header><a class="brand" href="/">DROPSTAR</a><a href="/">Enviar arte</a><span>Minha conta · DTF</span><button id="logout" hidden>Sair</button></header>
|
||||
<main><h1>Meus pedidos DTF</h1><p class="notice">Ambiente de desenvolvimento local. Pagamentos e notificações são simulados. Use apenas dados de teste.</p><p id="message" role="status"></p>
|
||||
<div id="account"></div><section id="auth" class="grid">
|
||||
<form id="login" class="card"><h2>Entrar</h2><label>E-mail <input id="email" type="email" autocomplete="username" required></label><label>Senha <input id="password" type="password" autocomplete="current-password" required></label><button>Entrar</button></form>
|
||||
<form id="register" class="card"><h2>Criar conta local</h2><label>CNPJ <input id="cnpj" required></label><label>WhatsApp <input id="phone" required></label><label>E-mail <input id="register-email" type="email" autocomplete="email" required></label><label>Senha (12 caracteres ou mais) <input id="register-password" type="password" minlength="12" maxlength="128" autocomplete="new-password" required></label><button>Criar conta</button><p class="muted">Pedidos desta sessão serão associados à sua conta. Não há envio de e-mail nem recuperação de senha nesta versão local.</p></form>
|
||||
</section><div class="actions"><h2>Acompanhamento</h2><button id="refresh">Atualizar pedidos</button></div><p id="guest" class="muted"></p><section id="quotes"></section><section id="orders"></section></main>
|
||||
<script src="/privacy.js"></script><script src="/upload.js"></script><script src="/portal.js"></script></body></html>
|
||||
@@ -1,76 +0,0 @@
|
||||
const $=id=>document.getElementById(id);
|
||||
const node=(tag,text)=>{const el=document.createElement(tag);if(text!==undefined)el.textContent=text;return el;};
|
||||
const money=c=>(c/100).toLocaleString('pt-BR',{style:'currency',currency:'BRL'});
|
||||
let scope,states={};
|
||||
async function api(path,body){const r=await fetch('/api'+path,{headers:{'Content-Type':'application/json'},...(body===undefined?{}:{method:'POST',body:JSON.stringify(body)})});const d=await r.json();if(!r.ok){const error=new Error(typeof d.detail==='string'?d.detail:'Confira os campos informados.');error.status=r.status;throw error;}return d;}
|
||||
function button(text,fn){const b=node('button',text);b.onclick=async()=>{b.disabled=true;try{await fn();}catch(e){$('message').textContent=e.message;}finally{b.disabled=false;}};return b;}
|
||||
function forgetCheckout(){for(const k of ['dtf-quote','dtf-request-key','dtf-request-body'])localStorage.removeItem(k);}
|
||||
async function load(){
|
||||
try{
|
||||
scope=(await api('/session')).cart_scope;
|
||||
const account=await api('/account/me');
|
||||
$('auth').hidden=!!account.customer;$('logout').hidden=!account.customer;
|
||||
$('account').textContent=account.customer?'Conta: '+account.customer.mail:'';
|
||||
$('guest').textContent=account.customer?'':'Você está vendo apenas os pedidos desta sessão. Crie uma conta para acessar seus pedidos em outro navegador.';
|
||||
const data=await api('/customer/orders');states=data.states;
|
||||
$('quotes').replaceChildren();
|
||||
for(const quote of data.quotes){
|
||||
const card=node('article');card.className='card';card.append(node('h3','Cotação '+quote.id.slice(0,8)));
|
||||
card.append(node('p',quote.approved?'Total aprovado: '+money(quote.approved.total_cents):'Aguardando conferência de metragem e nota.'));
|
||||
const link=node('a','Abrir cotação no Site');link.href='/?quote='+quote.id;card.append(link);$('quotes').append(card);
|
||||
}
|
||||
$('orders').replaceChildren();
|
||||
if(!data.orders.length)$('orders').append(node('p','Nenhum pedido pago nesta conta ou sessão.'));
|
||||
for(const order of data.orders){
|
||||
const card=node('article');card.className='card';card.id='order-'+order.id;
|
||||
card.append(node('h3','Pedido #'+order.number),node('p',states[order.state]+' · '+money(order.snapshot.total_cents)+' · pagamento local'));
|
||||
card.append(node('p',new Date(order.created_at).toLocaleString('pt-BR')));
|
||||
const content=node('div');
|
||||
card.append(button('Ver detalhes e arquivos',()=>details(order.id,content)),content);$('orders').append(card);
|
||||
if(new URLSearchParams(location.search).get('order')===order.id)await details(order.id,content);
|
||||
}
|
||||
}catch(error){$('message').textContent=error.message;}
|
||||
}
|
||||
async function details(id,container){
|
||||
const order=await api('/customer/orders/'+id);container.replaceChildren();
|
||||
for(const [i,item] of order.snapshot.items.entries())container.append(node('p',(i+1)+'. '+item.mode+' · '+item.billed_metres+' m · nota '+item.grade+' · '+money(item.total_cents)));
|
||||
const history=node('ol');
|
||||
for(const h of order.history)history.append(node('li',new Date(h.created_at).toLocaleString('pt-BR')+' · '+states[h.to_state]+(h.reason?' — '+h.reason:'')));
|
||||
container.append(history);
|
||||
for(const file of order.files){
|
||||
const row=node('p',(file.kind==='final'?'Arquivo final':'Correção')+' · item '+(file.item_index+1)+' · '+file.name+' · '+(file.expired?'expirado':!file.active?'substituído':'disponível até '+new Date(file.expires_at).toLocaleDateString('pt-BR')));
|
||||
if(file.active&&!file.expired)row.append(button('Baixar',async()=>{const d=await api('/customer/orders/'+id+'/files/'+file.id+'/download');const link=node('a');link.href=d.url;link.download=d.name;link.referrerPolicy='no-referrer';link.click();}));
|
||||
container.append(row);
|
||||
}
|
||||
if(order.state==='cor'){
|
||||
const form=node('form');form.append(node('h3','Enviar arte corrigida'));
|
||||
const inputs=order.snapshot.items.map((item,index)=>{const label=node('label','Item '+(index+1)+' · '+item.mode);const input=node('input');input.type='file';input.multiple=true;input.dataset.item=index;label.append(input);form.append(label);return input;});
|
||||
const note=node('textarea');note.placeholder='Descreva a correção';note.required=true;note.maxLength=1000;form.append(note);
|
||||
const submit=node('button','Enviar correção');submit.type='submit';form.append(submit);
|
||||
form.onsubmit=async event=>{event.preventDefault();submit.disabled=true;try{
|
||||
const files=[];for(const [index,input] of inputs.entries())for(const file of input.files)files.push({item_index:index,upload_id:await dtfUpload(file,{api,scope:scope+':correction:'+id+':'+order.version,progress:text=>$('message').textContent=text})});
|
||||
if(!files.length)throw new Error('Selecione pelo menos um arquivo corrigido.');
|
||||
await api('/customer/orders/'+id+'/corrections',{version:order.version,files,note:note.value});
|
||||
$('message').textContent='Correção enviada. Aguarde a conferência da equipe.';await details(id,container);
|
||||
}catch(error){$('message').textContent=error.message;}finally{submit.disabled=false;}};
|
||||
container.append(form);
|
||||
}
|
||||
}
|
||||
async function migrateGuestCart(previous,next){
|
||||
if(previous===next)return;
|
||||
const database=await new Promise((resolve,reject)=>{const r=indexedDB.open('dtf-local-cart',1);r.onupgradeneeded=()=>r.result.createObjectStore('cart');r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(r.error);});
|
||||
await new Promise((resolve,reject)=>{const tx=database.transaction('cart','readwrite'),store=tx.objectStore('cart'),req=store.get(previous);req.onsuccess=()=>{if(req.result){store.put(req.result,next);store.delete(previous);}};tx.oncomplete=resolve;tx.onerror=()=>reject(tx.error);});
|
||||
database.close();
|
||||
}
|
||||
async function authenticate(path,body){
|
||||
const previous=scope,guest=!(await api('/account/me')).customer;
|
||||
await api(path,body);forgetCheckout();$('message').textContent='Acesso confirmado.';
|
||||
const next=(await api('/session')).cart_scope;
|
||||
if(guest)try{await migrateGuestCart(previous,next);}catch{$('message').textContent='Acesso confirmado. Não foi possível transferir o carrinho salvo neste navegador.';}
|
||||
await load();$('password').value='';$('register-password').value='';
|
||||
}
|
||||
$('login').onsubmit=async event=>{event.preventDefault();try{await authenticate('/account/login',{email:$('email').value,password:$('password').value});}catch(e){$('message').textContent=e.message;}};
|
||||
$('register').onsubmit=async event=>{event.preventDefault();try{await authenticate('/account/register',{customer:{cnpj:$('cnpj').value,zap:$('phone').value,mail:$('register-email').value},password:$('register-password').value});}catch(e){$('message').textContent=e.message;}};
|
||||
$('logout').onclick=async()=>{try{await window.dtfForgetPrivateData();await api('/account/logout',{});location.reload();}catch(error){$('message').textContent='Não foi possível concluir a limpeza local. Feche as abas do Site e limpe os dados deste site no navegador.';}};
|
||||
$('refresh').onclick=load;
|
||||
load();
|
||||
@@ -1,15 +0,0 @@
|
||||
/* Clear draft files and metadata across Site tabs when signing out. */
|
||||
(() => {
|
||||
const signal='dtf-privacy-reset';
|
||||
function notify(){window.dispatchEvent(new Event('dtf-private-data-cleared'));}
|
||||
window.dtfForgetPrivateData=async()=>{
|
||||
notify();
|
||||
for(const key of Object.keys(localStorage))if(key.startsWith('dtf-'))localStorage.removeItem(key);
|
||||
localStorage.setItem(signal,crypto.randomUUID());
|
||||
const db=await new Promise((resolve,reject)=>{const r=indexedDB.open('dtf-local-cart',1);r.onupgradeneeded=()=>r.result.createObjectStore('cart');r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(r.error);});
|
||||
try{await new Promise((resolve,reject)=>{const tx=db.transaction('cart','readwrite');tx.objectStore('cart').clear();tx.oncomplete=resolve;tx.onerror=()=>reject(tx.error);});}finally{db.close();}
|
||||
};
|
||||
window.addEventListener('storage',event=>{
|
||||
if(event.key===signal && event.newValue){notify();location.reload();}
|
||||
});
|
||||
})();
|
||||
@@ -1,77 +0,0 @@
|
||||
/* Site DTF — Cart totals and the order summary.
|
||||
Extracted verbatim from the single inline script in dtf-site.html.
|
||||
Loaded as classic scripts in the order listed there: they share one global
|
||||
scope and run top to bottom, exactly as the original did. */
|
||||
// ── carrinho
|
||||
let itemAtual=null;
|
||||
function limpaPaineis(){
|
||||
['qual','prev'].forEach(id=>$(id).classList.remove('on'));
|
||||
if(!pedido.length){ $('carr').classList.remove('on'); }
|
||||
}
|
||||
|
||||
// O carrinho junta itens de produtos diferentes e cobra uma vez só.
|
||||
function pintaPedido(){
|
||||
$('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="vl2">'+rs(it.total)+'</div>'+
|
||||
'<button class="x" data-rmi="'+i+'" aria-label="Remover">×</button></div>').join('');
|
||||
$('itens').querySelectorAll('[data-rmi]').forEach(b=>b.addEventListener('click',()=>{
|
||||
pedido.splice(+b.dataset.rmi,1); pintaPedido();
|
||||
}));
|
||||
const itens = pedido.reduce((t,it)=>t+it.total,0) + (itemAtual? itemAtual.total : 0);
|
||||
const frete = entrega.tipo==='frete' && entrega.cotado ? entrega.valor : 0;
|
||||
const soma = itens + frete;
|
||||
const n = pedido.length + (itemAtual?1:0);
|
||||
const eco = [...pedido, ...(itemAtual?[itemAtual]:[])]
|
||||
.reduce((t,it)=>t+(TABELA[it.modo]-it.unit)*it.cob, 0);
|
||||
$('resLin').innerHTML =
|
||||
'<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>'+
|
||||
'<b style="color:var(--verde-ml)">− '+rs(eco)+'</b></div>' : '')+
|
||||
'<div class="l"><span>Frete</span><b>'+
|
||||
(entrega.tipo==='retira' ? '<span style="color:var(--verde-ml)">Grátis</span>'
|
||||
: entrega.cotado ? rs(frete) : 'a cotar')+'</b></div>'+
|
||||
'<div class="l"><span>Revisão e reencaixe</span>'+
|
||||
'<b><span style="color:var(--verde-ml)">Grátis</span></b></div>';
|
||||
$('carrTot').textContent=rs(soma);
|
||||
$('bPagar').textContent = (pedido.length + (itemAtual?1:0))>1
|
||||
? 'Pagar os '+(pedido.length+(itemAtual?1:0))+' itens de uma vez'
|
||||
: 'Ir para o pagamento';
|
||||
if(pedido.length || itemAtual) $('carr').classList.add('on');
|
||||
if($('cartLink')) $('cartLink').textContent='Carrinho ('+n+')';
|
||||
window.dispatchEvent(new Event('dtf-cart-changed'));
|
||||
}
|
||||
|
||||
function carrinho(){
|
||||
const base=precoBase(nota), cob=cobrar(metros);
|
||||
const unit=base, tot=cob*unit, sobra=+(cob-metros).toFixed(2);
|
||||
$('carr').classList.add('on');
|
||||
let l='<div class="l"><span>'+MODOS[modo].tit+'</span><b>'+rs(base)+'/m</b></div>';
|
||||
{
|
||||
const teto=TABELA[modo], eco=(teto-base)*cob;
|
||||
l = '<div class="l"><span>'+MODOS[modo].tit+' · tabela</span><b>'+rs(teto)+'/m</b></div>';
|
||||
l += eco>0.01
|
||||
? '<div class="l" style="color:#7FD4A0"><span>Desconto pela nota '+nota+'</span><b>−'+rs(eco)+'</b></div>'
|
||||
: '<div class="l" style="color:#8D9AB0"><span>Nota '+nota+' · sem desconto</span><b>—</b></div>';
|
||||
}
|
||||
if(!ehFolha()) l += '<div class="l" style="color:#8D9AB0"><span>Montagem da folha</span>'+
|
||||
'<b>inclusa · '+rs(MONTAGEM[modo])+'/m</b></div>';
|
||||
l += '<div class="l"><span>Metragem'+(sobra>0.001?' · arredondada para 10 cm':'')+
|
||||
'</span><b>'+fmtM(cob)+' m</b></div>';
|
||||
l += '<div class="l"><span>Preço final do metro</span><b>'+rs(unit)+'</b></div>'+
|
||||
'<div class="l" style="color:var(--laranja2)"><span>Revisão e reencaixe</span><b>grátis</b></div>';
|
||||
$('carrLin').innerHTML=l;
|
||||
$('atual').style.display='';
|
||||
itemAtual={modo, tit:MODOS[modo].tit, nota, metros, cob, unit, total:tot,
|
||||
localFiles:(ehFolha()?folhas:artes).map(x=>x.f),
|
||||
desc:fmtM(cob)+' m · '+rs(unit)+'/m · nota '+nota};
|
||||
pintaPedido();
|
||||
previa();
|
||||
if(nota<90){
|
||||
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, '+
|
||||
'o metro cai para '+rs(piso)+' — são '+rs(ganho)+' a menos neste pedido.','var(--verde)');
|
||||
}
|
||||
}
|
||||
function qm(t,c){ const m=$('qmsg'); m.style.display='block'; m.style.color=c||'var(--verde)'; m.innerHTML=t; }
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
/* Site DTF — Product tables, commercial ladders, shared state and field validation.
|
||||
Extracted verbatim from the single inline script in dtf-site.html.
|
||||
Loaded as classic scripts in the order listed there: they share one global
|
||||
scope and run top to bottom, exactly as the original did. */
|
||||
|
||||
const $=id=>document.getElementById(id);
|
||||
const rs=v=>'R$ '+v.toFixed(2).replace('.',',');
|
||||
const fmt=b=>b<1048576?(b/1024).toFixed(0)+' KB':(b/1048576).toFixed(1).replace('.',',')+' MB';
|
||||
|
||||
// ── modos
|
||||
const MODOS={
|
||||
file: {tit:'Arquivo por metro', lg:'DTF têxtil · 57 cm', larg:57,
|
||||
preco:'tabela R$ 19,90/m · até R$ 14,90', multi:true, folha:true,
|
||||
sub:'Sua folha montada. Revisamos, corrigimos e reencaixamos antes de imprimir.',
|
||||
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',
|
||||
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,
|
||||
preco:'tabela R$ 85,90/m · até R$ 69,90', multi:true, folha:true,
|
||||
sub:'Sua folha de UV montada. Revisamos, corrigimos e reencaixamos antes de imprimir.',
|
||||
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',
|
||||
zs:'PNG ou JPG com fundo transparente · informe a largura de cada arte'}
|
||||
};
|
||||
// Quatro escadas. A de artes avulsas e a da folha pronta mais a montagem:
|
||||
// R$ 10,00 no têxtil, R$ 14,00 no UV, constante em todas as faixas.
|
||||
const FAIXAS={
|
||||
file: [[90,14.90],[75,16.20],[60,17.50],[40,18.70],[0,19.90]],
|
||||
avulsa:[[90,24.90],[75,26.20],[60,27.50],[40,28.70],[0,29.90]],
|
||||
uvfile:[[90,69.90],[75,73.90],[60,77.90],[40,81.90],[0,85.90]],
|
||||
uv: [[90,83.90],[75,87.90],[60,91.90],[40,95.90],[0,99.90]]
|
||||
};
|
||||
const TABELA={file:19.90, avulsa:29.90, uvfile:85.90, uv:99.90}; // teto de cada escada
|
||||
const MONTAGEM={avulsa:10.00, uv:14.00}; // o que a montagem soma sobre a folha pronta
|
||||
const ehFolha=m=>!!MODOS[m||modo].folha;
|
||||
const pisoEscada=m=>FAIXAS[m||modo][0][1];
|
||||
const MINIMO_M=1, GAP_CM=0.5, RODAPE_CM=5;
|
||||
const RENDERIZA=/[.](png|jpe?g|webp|gif|bmp)$/i;
|
||||
// Folha pronta: dá para medir PNG, JPG e PDF. PSD, AI, TIFF e CDR entram, mas
|
||||
// o navegador não abre — o cliente informa o comprimento e o arquivo passa por
|
||||
// tratamento antes de imprimir.
|
||||
const ACEITA={
|
||||
folha: /[.](png|jpe?g|tiff?|pdf|psd|psb|ai|cdr)$/i,
|
||||
avulsa: /[.](png|jpe?g|webp)$/i
|
||||
};
|
||||
const MEDE=/[.](png|jpe?g|webp|gif|bmp|pdf)$/i;
|
||||
const DPI_RECUSA=96, DPI_AVISA=300; // régua dos designers · 02/09/2026
|
||||
// A especificação do PDF limita a página a 14.400 unidades — 200 pol, 508 cm.
|
||||
// Passar disso só é válido com UserUnit (PDF 1.6+), que a maioria dos programas
|
||||
// não escreve: o arquivo sai fora do padrão e só o gerador lê direito.
|
||||
const PDF_MAX_PT=14400, PT_CM=2.54/72;
|
||||
const extDe=n=>(n.split('.').pop()||'').toUpperCase();
|
||||
let CLIENTE='SEU NOME AQUI', PEDIDO='000000';
|
||||
|
||||
let modo=null, artes=[], recemChegada=null, metros=0, nota=0, reencaixado=false;
|
||||
let folhas=[]; // [{f, med, rep, m}] · o cliente pode subir várias
|
||||
const folhaTotalM=()=>folhas.reduce((t,x)=>t+(x.m||0)*(x.rep||1),0);
|
||||
// O cliente declara o que está mandando escolhendo o produto, em vez de o site
|
||||
// adivinhar pelo formato do arquivo. A declaração de folha montada é conferida na
|
||||
// medição: uma imagem sem pixel para ocupar a largura do filme não é uma folha.
|
||||
// Cada modo por metro tem o seu par de artes avulsas, e o seletor anda nos dois sentidos.
|
||||
const PAR={file:'avulsa', avulsa:'file', uvfile:'uv', uv:'uvfile'};
|
||||
let montagemCm=0; // altura da folha montada pelo motor, em cm
|
||||
const ZOOMS=[1,1.5,2,3,4]; let zoomI=0; // ampliação da montagem ao vivo
|
||||
let pedido=[]; // itens já fechados · o pagamento é um só
|
||||
// A escolha de entrega vale para o pedido inteiro, não por item. No Tiny, retirada
|
||||
// grava forma_envio X, transportador DropStar e frete por conta do remetente — igual
|
||||
// ao que a expedição já recebe hoje, sem criar processo novo.
|
||||
let entrega={tipo:'retira', cep:'', valor:0, cotado:true};
|
||||
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
|
||||
function cnpjOk(v){
|
||||
const d=(v||'').replace(/\D/g,'');
|
||||
if(d.length!==14 || /^(\d)\1{13}$/.test(d)) return false;
|
||||
const calc=(base,pesos)=>{
|
||||
const soma=base.split('').reduce((t,n,i)=>t+ +n*pesos[i],0);
|
||||
const r=soma%11; return r<2? 0 : 11-r;
|
||||
};
|
||||
const p1=[5,4,3,2,9,8,7,6,5,4,3,2], p2=[6,...p1];
|
||||
return calc(d.slice(0,12),p1)===+d[12] && calc(d.slice(0,13),p2)===+d[13];
|
||||
}
|
||||
const zapOk=v=>{ const d=(v||'').replace(/\D/g,''); return d.length===10||d.length===11; };
|
||||
const mailOk=v=>/^[^\s@]+@[^\s@]+\.[a-z]{2,}$/i.test((v||'').trim());
|
||||
const clienteOk=()=>cnpjOk(cliente.cnpj)&&zapOk(cliente.zap)&&mailOk(cliente.mail);
|
||||
const larguraFilme=()=>MODOS[modo].larg;
|
||||
// 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 fmtM=m=>m.toFixed(2).replace('.',',');
|
||||
const precoBase=n=>
|
||||
(FAIXAS[modo].find(f=>n>=f[0])||FAIXAS[modo][FAIXAS[modo].length-1])[1];
|
||||
const descontoMetragem=()=>0; // sem progressão por quantidade
|
||||
const cls=n=>n>=85?'ok':n>=50?'av':'er';
|
||||
const dpiDe=a=>a.cm>0? a.px/(a.cm/2.54) : 0;
|
||||
// girar 90° inverte a proporção · espelhar não muda medida, só o desenho
|
||||
const propDe=a=>{ const p=a.prop||1; return a.giro? 1/p : p; };
|
||||
const TAMANHOS={57:[10,15,20,25,28.2], 28.5:[5,8,10,14,28.2]};
|
||||
const px=f=>Math.round(Math.min(6000,Math.max(600,Math.sqrt(f.size/1024)*95)));
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
/* Site DTF — Carousel, path selector, delivery choice and input masks.
|
||||
Extracted verbatim from the single inline script in dtf-site.html.
|
||||
Loaded as classic scripts in the order listed there: they share one global
|
||||
scope and run top to bottom, exactly as the original did. */
|
||||
// ── decisões
|
||||
// Remanejar conserta encaixe, não resolução — e a nota mede só resolução.
|
||||
// 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,
|
||||
// não as artes separadas, então não há como recalcular aqui sem inventar número.
|
||||
$('qOk').addEventListener('click',()=>{
|
||||
if($('qOk').disabled) return;
|
||||
const ok=$('ciente').classList.contains('on');
|
||||
qm(ok? '<b>Registrado.</b> Você confirmou que viu a ressalva de resolução.'
|
||||
: '<b>Certo.</b> Seu pedido está montado abaixo.','var(--tx)');
|
||||
$('carr').scrollIntoView({behavior:'smooth',block:'nearest'});
|
||||
});
|
||||
|
||||
$('pOk').addEventListener('click',()=>$('carr').scrollIntoView({behavior:'smooth'}));
|
||||
$('pMais').addEventListener('click',()=>$('foco').scrollIntoView({behavior:'smooth'}));
|
||||
$('pVolta').addEventListener('click',()=>{
|
||||
folhas=[]; artes=[]; reencaixado=false; pintaFolha(); pintaArtes();
|
||||
limpaPaineis(); // o carrinho só some se o pedido estiver vazio
|
||||
$('foco').scrollIntoView({behavior:'smooth'});
|
||||
});
|
||||
// carrossel
|
||||
(function(){
|
||||
const tr=$('trilho'); if(!tr) return;
|
||||
const slides=[...tr.children], pontos=$('cPontos');
|
||||
slides.forEach((_,i)=>{
|
||||
const b=document.createElement('button');
|
||||
b.className='pt'+(i?'':' on'); b.setAttribute('aria-label','Cartão '+(i+1));
|
||||
b.addEventListener('click',()=>ir(i)); pontos.appendChild(b);
|
||||
});
|
||||
function ir(i){
|
||||
i=Math.max(0,Math.min(slides.length-1,i));
|
||||
tr.scrollTo({left:slides[i].offsetLeft-tr.offsetLeft,behavior:'smooth'});
|
||||
}
|
||||
function atual(){
|
||||
const meio=tr.scrollLeft+tr.clientWidth/2;
|
||||
let a=0,d=Infinity;
|
||||
slides.forEach((s,i)=>{
|
||||
const c=s.offsetLeft-tr.offsetLeft+s.offsetWidth/2;
|
||||
if(Math.abs(c-meio)<d){ d=Math.abs(c-meio); a=i; }
|
||||
});
|
||||
return a;
|
||||
}
|
||||
tr.addEventListener('scroll',()=>{
|
||||
const a=atual();
|
||||
[...pontos.children].forEach((p,i)=>p.classList.toggle('on',i===a));
|
||||
},{passive:true});
|
||||
$('cAnt').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(){
|
||||
document.querySelectorAll('.opE').forEach(b=>
|
||||
b.classList.toggle('on', b.dataset.ent===entrega.tipo));
|
||||
$('cep').classList.toggle('on', entrega.tipo==='frete');
|
||||
$('vFrete').textContent = entrega.tipo!=='frete' ? '—'
|
||||
: 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();
|
||||
pintaPedido();
|
||||
}
|
||||
|
||||
// máscaras e conferência dos campos do cliente
|
||||
function liga(id,fmt,val,chave,erro,msg){
|
||||
const el=$(id), er=$(erro);
|
||||
el.addEventListener('input',e=>{
|
||||
e.target.value=fmt(e.target.value);
|
||||
cliente[chave]=e.target.value;
|
||||
el.classList.remove('ruim','bom'); er.textContent='';
|
||||
if(val(e.target.value)) el.classList.add('bom');
|
||||
pintaEntrega();
|
||||
});
|
||||
el.addEventListener('blur',()=>{
|
||||
if(!el.value){ el.classList.remove('ruim','bom'); er.textContent=''; return; }
|
||||
const ok=val(el.value);
|
||||
el.classList.toggle('ruim',!ok); el.classList.toggle('bom',ok);
|
||||
er.textContent = ok? '' : msg;
|
||||
});
|
||||
}
|
||||
liga('fCnpj', v=>{
|
||||
const d=v.replace(/\D/g,'').slice(0,14);
|
||||
return d.replace(/^(\d{2})(\d)/,'$1.$2').replace(/^(\d{2})\.(\d{3})(\d)/,'$1.$2.$3')
|
||||
.replace(/\.(\d{3})(\d)/,'.$1/$2').replace(/(\d{4})(\d)/,'$1-$2');
|
||||
}, cnpjOk, 'cnpj', 'eCnpj', 'CNPJ inválido · confira os números');
|
||||
liga('fZap', v=>{
|
||||
const d=v.replace(/\D/g,'').slice(0,11);
|
||||
return d.length>6 ? '('+d.slice(0,2)+') '+d.slice(2,d.length-4)+'-'+d.slice(-4)
|
||||
: d.length>2 ? '('+d.slice(0,2)+') '+d.slice(2) : d;
|
||||
}, zapOk, 'zap', 'eZap', 'Faltam dígitos no número');
|
||||
liga('fMail', v=>v.trim(), mailOk, 'mail', 'eMail', 'E-mail inválido');
|
||||
function pintaZoom(){
|
||||
$('zTxt').textContent=Math.round(ZOOMS[zoomI]*100)+'%';
|
||||
$('zMenos').disabled = zoomI===0;
|
||||
$('zMais').disabled = zoomI===ZOOMS.length-1;
|
||||
}
|
||||
$('zMais').addEventListener('click',()=>{
|
||||
if(zoomI<ZOOMS.length-1){ zoomI++; pintaZoom(); previaAoVivo(); }
|
||||
});
|
||||
$('zMenos').addEventListener('click',()=>{
|
||||
if(zoomI>0){ zoomI--; pintaZoom(); previaAoVivo(); }
|
||||
});
|
||||
pintaZoom();
|
||||
|
||||
document.querySelectorAll('.opE').forEach(b=>b.addEventListener('click',()=>{
|
||||
entrega.tipo=b.dataset.ent;
|
||||
entrega.cotado = entrega.tipo==='retira';
|
||||
entrega.valor=0;
|
||||
if(entrega.tipo==='frete') $('cepMsg').innerHTML='';
|
||||
pintaEntrega();
|
||||
}));
|
||||
$('cepIn').addEventListener('input',e=>{
|
||||
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;
|
||||
entrega.cep=v; entrega.cotado=false; entrega.valor=0; pintaEntrega();
|
||||
});
|
||||
$('bCep').addEventListener('click',async()=>{
|
||||
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){ 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',()=>{
|
||||
if(window.dtfCheckout) window.dtfCheckout();
|
||||
else alert('O pedido online está indisponível no momento. Tente novamente em instantes.');
|
||||
});
|
||||
pintaEntrega();
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
/* Site DTF — Choosing a product, and the declared sheet/loose-artwork switch.
|
||||
Extracted verbatim from the single inline script in dtf-site.html.
|
||||
Loaded as classic scripts in the order listed there: they share one global
|
||||
scope and run top to bottom, exactly as the original did. */
|
||||
// ── escolha
|
||||
document.querySelectorAll('.ec').forEach(b=>b.addEventListener('click',()=>abrir(b.dataset.modo)));
|
||||
function abrir(m){
|
||||
modo=m; artes=[]; folhas=[]; metros=0; nota=0; reencaixado=false;
|
||||
montagemCm=0;
|
||||
avisoTipo('');
|
||||
pintaModo();
|
||||
$('lista').innerHTML=''; $('rA').style.display='none'; recusa([]);
|
||||
recemChegada=null; caminho='auto'; avisoCam(''); pintaCaminhos();
|
||||
$('vMt').textContent='—'; previaAoVivo(); agendaAvaliacao();
|
||||
limpaPaineis(); // o carrinho só some se o pedido estiver vazio
|
||||
$('foco').scrollIntoView({behavior:'smooth',block:'start'});
|
||||
}
|
||||
function pintaModo(){
|
||||
const m=modo;
|
||||
const c=MODOS[m];
|
||||
$('cards').style.display='none'; $('foco').classList.add('on');
|
||||
$('fTit').textContent=c.tit; $('fLg').textContent=c.lg;
|
||||
$('fPreco').textContent=c.preco; $('fSub').textContent=c.sub;
|
||||
$('zTit').textContent=c.zt; $('zSub').textContent=c.zs;
|
||||
$('inp').multiple=c.multi;
|
||||
$('inp').accept = ehFolha(m) ? '.png,.jpg,.jpeg,.pdf'
|
||||
: '.png,.jpg,.jpeg,.webp';
|
||||
$('catFoco').className='cat '+(ehFolha(m)?'file':'av');
|
||||
$('larguraFolhaPronta').textContent=String(c.larg).replace('.',',');
|
||||
$('foco2').classList.remove('sofila'); // a caixa de requisitos vale nos 4 modos
|
||||
$('montcabTit').textContent = ehFolha(m) ? 'Sua folha' : 'Montagem ao vivo';
|
||||
pintaTipoEnvio();
|
||||
}
|
||||
// O par folha montada / artes separadas fica visível e com preço nos dois lados,
|
||||
// nos dois modos: nenhuma troca acontece sem o cliente ver o que muda, e sempre
|
||||
// dá para voltar atrás sem recomeçar o pedido.
|
||||
function pintaTipoEnvio(){
|
||||
const el=$('tipoEnvio'); if(!el) return;
|
||||
el.hidden=!modo;
|
||||
if(el.hidden) return;
|
||||
const folha = ehFolha() ? modo : PAR[modo];
|
||||
const avulsa = ehFolha() ? PAR[modo] : modo;
|
||||
$('tipoFolhaPreco').textContent='a partir de '+rs(pisoEscada(folha));
|
||||
$('tipoAvulsaPreco').textContent='a partir de '+rs(pisoEscada(avulsa))+' · montagem inclusa';
|
||||
$('larguraFolhaPronta').textContent=String(MODOS[folha].larg).replace('.',',');
|
||||
const atual = ehFolha() ? 'folha' : 'avulsa';
|
||||
el.querySelectorAll('.cam').forEach(b=>{
|
||||
b.classList.toggle('on', b.dataset.tipo===atual);
|
||||
b.disabled=folhas.length>0 || artes.length>0; // já tem arquivo: a escolha está feita
|
||||
});
|
||||
}
|
||||
function avisoTipo(txt){
|
||||
const el=$('avisoTipo'); if(!el) return;
|
||||
el.innerHTML=txt; el.style.display = txt? '' : 'none';
|
||||
}
|
||||
// A imagem foi declarada folha montada mas não tem resolução para isso. Devolve o
|
||||
// arquivo com o motivo e o caminho certo — sem trocar preço por conta própria.
|
||||
function recusaFolha(x, md){
|
||||
const arquivo=x.f, destino = modo==='uvfile' ? 'uv' : 'avulsa';
|
||||
folhas=folhas.filter(y=>y!==x);
|
||||
pintaFolha();
|
||||
avisoTipo('<b>'+escapeHTML(arquivo.name)+' não é uma folha montada.</b> '+
|
||||
'Para ocupar os '+n1(larguraFilme())+' cm do filme ela teria só '+md.dpiFolha+
|
||||
' DPI, e o mínimo para imprimir é '+DPI_RECUSA+'. '+
|
||||
'Se for uma arte para montarmos, use <b>'+MODOS[destino].tit+'</b>, '+
|
||||
'a partir de '+rs(pisoEscada(destino))+'/m. '+
|
||||
'<button type="button" id="usarAvulsa">Enviar como '+MODOS[destino].tit.toLowerCase()+'</button>');
|
||||
const botao=$('usarAvulsa');
|
||||
if(botao) botao.addEventListener('click',()=>{
|
||||
avisoTipo(''); trocaTipo(PAR[modo]); sel([arquivo]);
|
||||
});
|
||||
}
|
||||
document.querySelectorAll('#tipoEnvio .cam').forEach(b=>b.addEventListener('click',()=>{
|
||||
if(b.disabled) return;
|
||||
const querFolha=b.dataset.tipo==='folha';
|
||||
if(querFolha===ehFolha()) return;
|
||||
avisoTipo(''); trocaTipo(PAR[modo]); // escolha explícita do cliente
|
||||
}));
|
||||
// Troca declarada, nunca silenciosa: o cliente já viu os dois preços no seletor.
|
||||
function trocaTipo(destino){
|
||||
if(!destino || modo===destino) return;
|
||||
folhas=[]; artes=[]; recemChegada=null; metros=0; nota=0; montagemCm=0;
|
||||
modo=destino;
|
||||
$('lista').innerHTML=''; $('rA').style.display='none'; recusa([]);
|
||||
caminho='auto'; avisoCam('');
|
||||
pintaModo(); pintaCaminhos(); limpaPaineis(); agendaAvaliacao(); previaAoVivo();
|
||||
}
|
||||
$('bVoltar').addEventListener('click',()=>{
|
||||
itemAtual=null; $('atual').style.display='none'; $('carrLin').innerHTML=''; pintaPedido();
|
||||
$('foco').classList.remove('on'); $('cards').style.display='';
|
||||
['qual','prev'].forEach(id=>$(id).classList.remove('on'));
|
||||
if(!pedido.length) $('carr').classList.remove('on');
|
||||
document.getElementById('envio').scrollIntoView({behavior:'smooth'});
|
||||
});
|
||||
|
||||
@@ -1,353 +0,0 @@
|
||||
/* Site DTF — Masks, the nesting engine, and drawing the assembled film.
|
||||
Extracted verbatim from the single inline script in dtf-site.html.
|
||||
Loaded as classic scripts in the order listed there: they share one global
|
||||
scope and run top to bottom, exactly as the original did. */
|
||||
// ── imagens e prévia
|
||||
function carregarImagem(file){
|
||||
return new Promise(res=>{
|
||||
if(!RENDERIZA.test(file.name)) return res(null);
|
||||
const fr=new FileReader();
|
||||
fr.onload=()=>{ const im=new Image(); im.onload=()=>res(im); im.onerror=()=>res(null); im.src=fr.result; };
|
||||
fr.onerror=()=>res(null);
|
||||
fr.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
// 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.
|
||||
function molduraFolha(ctx,W,H,larguraCm,metrosReais){
|
||||
const REG=34; // faixa da régua, à direita
|
||||
ctx.save();
|
||||
// fundo da régua
|
||||
ctx.fillStyle='#FAFAF8'; ctx.fillRect(W,0,REG,H);
|
||||
ctx.strokeStyle='#D5DAE3'; ctx.lineWidth=1;
|
||||
ctx.beginPath(); ctx.moveTo(W+.5,0); ctx.lineTo(W+.5,H); ctx.stroke();
|
||||
// marcas a cada 10 cm, número a cada metro
|
||||
const pxPorM = H/Math.max(metrosReais,0.01);
|
||||
ctx.font='9px "Roboto", sans-serif'; ctx.textAlign='left';
|
||||
for(let cm=0; cm<=metrosReais*100; cm+=10){
|
||||
const y=cm/100*pxPorM;
|
||||
if(y>H) break;
|
||||
const metro = cm%100===0;
|
||||
ctx.strokeStyle = metro? '#B4BAC5' : '#E1E5EC';
|
||||
ctx.beginPath(); ctx.moveTo(W+1,y+.5); ctx.lineTo(W+(metro?12:6),y+.5); ctx.stroke();
|
||||
if(metro && cm>0){
|
||||
ctx.fillStyle='#8A8F98';
|
||||
ctx.fillText((cm/100)+' m', W+14, y+3);
|
||||
}
|
||||
}
|
||||
// borda da folha
|
||||
ctx.strokeStyle='#B4BAC5'; ctx.lineWidth=1;
|
||||
ctx.strokeRect(.5,.5,W-1,H-1);
|
||||
// cota da largura, no topo
|
||||
ctx.fillStyle='#FFFFFFDD'; ctx.fillRect(0,0,W,18);
|
||||
ctx.strokeStyle='#8A8F98'; ctx.lineWidth=1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(8,9); ctx.lineTo(W-8,9);
|
||||
ctx.moveTo(8,5); ctx.lineTo(8,13);
|
||||
ctx.moveTo(W-8,5); ctx.lineTo(W-8,13);
|
||||
ctx.stroke();
|
||||
const t=String(larguraCm).replace('.',',')+' cm';
|
||||
ctx.font='600 10px "Roboto", sans-serif'; ctx.textAlign='center';
|
||||
const tw=ctx.measureText(t).width;
|
||||
ctx.fillStyle='#FAFAF8'; ctx.fillRect(W/2-tw/2-6,2,tw+12,14);
|
||||
ctx.fillStyle='#5A6570'; ctx.fillText(t,W/2,13);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
const LOGO_URL='https://cdn.vnda.com.br/dropstar/2025/12/17/logo-header-15125700-16129080.png?v=1766009286';
|
||||
const logoImg=new Image(); logoImg.crossOrigin='anonymous'; logoImg.src=LOGO_URL;
|
||||
function marcaDagua(ctx,w,h){
|
||||
ctx.save(); ctx.globalAlpha=.14;
|
||||
const passo=Math.max(90,w/2.1);
|
||||
ctx.translate(w/2,h/2); ctx.rotate(-Math.PI/7); ctx.translate(-w/2,-h/2);
|
||||
if(logoImg.complete && logoImg.naturalWidth){
|
||||
const lw=Math.max(70,w/3.4), lh=lw*(logoImg.naturalHeight/logoImg.naturalWidth);
|
||||
for(let y=-h;y<h*2;y+=passo) for(let x=-w;x<w*2;x+=passo*1.7)
|
||||
ctx.drawImage(logoImg,x,y,lw,lh);
|
||||
}else{
|
||||
ctx.fillStyle='#111';
|
||||
ctx.font='700 '+Math.max(13,Math.round(w/13))+'px Inter, sans-serif'; ctx.textAlign='center';
|
||||
for(let y=-h;y<h*2;y+=passo) for(let x=-w;x<w*2;x+=passo*1.5) ctx.fillText('PRÉVIA',x,y);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
const CEL=GAP_CM; // célula da máscara, em cm = a folga de 5 mm
|
||||
// Dilata 1 célula para a direita e para baixo. Como toda peça é dilatada igual,
|
||||
// duas vizinhas ficam separadas por exatamente 1 célula — os 5 mm da regra.
|
||||
function dilatar(bruto,cw,ch,cheias){
|
||||
const cw2=cw+1, ch2=ch+1;
|
||||
const out=new Uint8Array(cw2*ch2);
|
||||
for(let y=0;y<ch;y++) for(let x=0;x<cw;x++){
|
||||
if(!bruto[y*cw+x]) continue;
|
||||
out[y*cw2+x]=1; out[y*cw2+x+1]=1; out[(y+1)*cw2+x]=1; out[(y+1)*cw2+x+1]=1;
|
||||
}
|
||||
return {cw:cw2,ch:ch2,bits:out,cheias}; // cheias = área de arte, sem a folga
|
||||
}
|
||||
function mascara(img,w,h,rot,esp=false){
|
||||
const cw=Math.max(1,Math.ceil(w/CEL)), ch=Math.max(1,Math.ceil(h/CEL));
|
||||
const cv=document.createElement('canvas');
|
||||
cv.width=cw; cv.height=ch;
|
||||
const ctx=cv.getContext('2d',{willReadFrequently:true});
|
||||
let bruto, cheias;
|
||||
if(!img){ // sem imagem: caixa cheia
|
||||
bruto=new Uint8Array(cw*ch).fill(1); cheias=cw*ch;
|
||||
return dilatar(bruto,cw,ch,cheias);
|
||||
}
|
||||
ctx.save();
|
||||
ctx.translate(cw/2,ch/2);
|
||||
ctx.rotate(rot*Math.PI/180);
|
||||
if(esp) ctx.scale(-1,1);
|
||||
const dw=rot%180===90?ch:cw, dh=rot%180===90?cw:ch;
|
||||
ctx.drawImage(img,-dw/2,-dh/2,dw,dh);
|
||||
ctx.restore();
|
||||
const d=ctx.getImageData(0,0,cw,ch).data;
|
||||
bruto=new Uint8Array(cw*ch); cheias=0;
|
||||
for(let i=0,j=3;i<bruto.length;i++,j+=4){ bruto[i] = d[j]>16 ? 1 : 0; cheias+=bruto[i]; }
|
||||
return dilatar(bruto,cw,ch,cheias);
|
||||
}
|
||||
|
||||
// Encaixe pela forma, como o motor do servidor: mapa de ocupação real,
|
||||
// máscara por arte e candidatos numa grade ao longo da largura.
|
||||
// pecas em centímetros: {w,h,img}. W é a largura do filme em cm.
|
||||
function encaixar(pecas,W){
|
||||
const cols=Math.ceil(W/CEL)+1; // +1 = a coluna de folga da dilatação
|
||||
let linhas=0; const mapa=[];
|
||||
function garantir(ate){ while(linhas<ate){ mapa.push(new Uint8Array(cols)); linhas++; } }
|
||||
|
||||
function colide(m,cx,cy){
|
||||
if(cx<0||cy<0||cx+m.cw>cols) return true;
|
||||
for(let y=0;y<m.ch;y++){
|
||||
const gy=cy+y;
|
||||
if(gy>=linhas) continue; // vazio abaixo do que já existe
|
||||
const L=mapa[gy], off=y*m.cw;
|
||||
for(let x=0;x<m.cw;x++)
|
||||
if(m.bits[off+x] && L[cx+x]) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function ocupar(m,cx,cy){
|
||||
garantir(cy+m.ch);
|
||||
for(let y=0;y<m.ch;y++){
|
||||
const L=mapa[cy+y], off=y*m.cw;
|
||||
for(let x=0;x<m.cw;x++) if(m.bits[off+x]) L[cx+x]=1;
|
||||
}
|
||||
}
|
||||
|
||||
const postas=[];
|
||||
const ordem=[...pecas].sort((a,b)=>(b.w*b.h)-(a.w*a.h));
|
||||
|
||||
ordem.forEach(p=>{
|
||||
const oris=[{w:p.w,h:p.h,rot:0}];
|
||||
if(!p.mPronta && !p.travado && Math.abs(p.w-p.h)>1 && p.h<=W) oris.push({w:p.h,h:p.w,rot:90});
|
||||
|
||||
let melhor=null;
|
||||
oris.forEach(o=>{
|
||||
if(o.w>W) return;
|
||||
// a máscara segue a medida da orientação escolhida · girada, largura e altura trocam
|
||||
const m = (p.mPronta && o.rot===0) ? p.mPronta : mascara(p.img, o.w, o.h,
|
||||
o.rot+(p.a?.giro||0), p.a?.esp);
|
||||
const xs=new Set([0]), ys=new Set([0]);
|
||||
postas.forEach(q=>{ xs.add(q.cx+q.m.cw); xs.add(q.cx); ys.add(q.cy+q.m.ch); ys.add(q.cy); });
|
||||
const passoX=Math.max(1,Math.round(cols/34));
|
||||
for(let x=0;x+m.cw<=cols;x+=passoX) xs.add(x);
|
||||
xs.add(cols-m.cw);
|
||||
const passoY=Math.max(1,Math.round(Math.max(linhas,1)/50));
|
||||
for(let y=0;y<=linhas;y+=passoY) ys.add(y);
|
||||
const XS=[...xs].filter(x=>x>=0 && x+m.cw<=cols && x*CEL+o.w<=W+0.01).sort((a,b)=>a-b);
|
||||
const YS=[...ys].filter(y=>y>=0).sort((a,b)=>a-b);
|
||||
|
||||
for(const cy of YS){
|
||||
if(melhor && cy*CEL > melhor.y + CEL) break;
|
||||
for(const cx of XS){
|
||||
if(cx*CEL + o.w > W + 0.01) continue; // não deixa a peça sair do filme
|
||||
if(colide(m,cx,cy)) continue;
|
||||
const cand={cx,cy,m,x:cx*CEL,y:cy*CEL,w:o.w,h:o.h,rot:o.rot,ref:p};
|
||||
// menor y, depois menor x · em empate, mantém a orientação original.
|
||||
// Desempatar por largura gira tudo, e texto deitado na prévia parece erro.
|
||||
const ganha = !melhor || cand.y<melhor.y ||
|
||||
(cand.y===melhor.y && cand.x<melhor.x) ||
|
||||
(cand.y===melhor.y && cand.x===melhor.x && cand.rot===0 && melhor.rot!==0);
|
||||
if(ganha) melhor=cand;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
if(!melhor){
|
||||
const k=Math.min(1, W/p.w); // cabe na largura, nem que seja reduzindo
|
||||
const w=p.w*k, h=p.h*k;
|
||||
const m=mascara(p.img,w,h,p.a?.giro||0,p.a?.esp);
|
||||
melhor={cx:0,cy:linhas,m,x:0,y:linhas*CEL,w,h,rot:0,ref:p};
|
||||
}
|
||||
// a folga só existe entre peças, não é folha cobrada
|
||||
ocupar(melhor.m,melhor.cx,melhor.cy);
|
||||
postas.push(melhor);
|
||||
});
|
||||
|
||||
const altura=Math.max(...postas.map(p=>p.y+p.h),1);
|
||||
return {pos:postas, altura};
|
||||
}
|
||||
|
||||
function desenhaMontagem(area,lista,mts,W,fora,aoTerminar){
|
||||
const versao=area.montagemVersao=(area.montagemVersao||0)+1;
|
||||
const filme=larguraFilme(), PX_CM=W/filme;
|
||||
const itens=lista.map(a=>({...a}));
|
||||
if(fora) fora.innerHTML='';
|
||||
if(!itens.length){
|
||||
area.innerHTML='<div class="semmont"><b>A montagem aparece aqui</b>'+
|
||||
'<span>informe a largura de cada arte e a folha se desenha sozinha</span></div>';
|
||||
if(aoTerminar) aoTerminar({alturaCm:0, uso:0, girou:0});
|
||||
return;
|
||||
}
|
||||
|
||||
Promise.all(itens.map(a=>carregarImagem(a.f).then(img=>({a,img})))).then(imagens=>{
|
||||
if(area.montagemVersao!==versao) return;
|
||||
const cargas=imagens.flatMap(c=>Array.from({length:c.a.q||1},()=>({...c})));
|
||||
cargas.forEach(c=>{
|
||||
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);
|
||||
c.travado = !!c.a.giro; // giro do cliente manda no encaixe
|
||||
});
|
||||
const enc=encaixar(cargas,filme);
|
||||
const alturaCm=enc.altura;
|
||||
const pos=enc.pos.map(p=>({...p, x:p.x*PX_CM, y:p.y*PX_CM, w:p.w*PX_CM, h:p.h*PX_CM}));
|
||||
const altura=alturaCm*PX_CM;
|
||||
const usado=Math.max(altura,10);
|
||||
const REG=34;
|
||||
const cv=document.createElement('canvas');
|
||||
cv.width=W+REG; cv.height=Math.round(usado);
|
||||
const ctx=cv.getContext('2d');
|
||||
ctx.fillStyle='#fff'; ctx.fillRect(0,0,cv.width,cv.height);
|
||||
const pal=['#C3CEDD','#B7A9C8','#A9B7C8','#A8BFA9','#C8C0A0','#CBB6B6'];
|
||||
let girou=0;
|
||||
pos.forEach((p,j)=>{
|
||||
const c=p.ref;
|
||||
if(p.rot===90) girou++;
|
||||
ctx.save();
|
||||
const giro = (p.rot===90?90:0) + (c.a && c.a.giro? 90:0);
|
||||
ctx.translate(p.x+p.w/2, p.y+p.h/2);
|
||||
ctx.rotate(giro*Math.PI/180);
|
||||
if(c.a && c.a.esp) ctx.scale(-1,1);
|
||||
const dw = giro%180===90 ? p.h : p.w, dh = giro%180===90 ? p.w : p.h;
|
||||
if(c.img) ctx.drawImage(c.img,-dw/2,-dh/2,dw,dh);
|
||||
else{ ctx.fillStyle=pal[j%pal.length]; ctx.fillRect(-dw/2,-dh/2,dw,dh); }
|
||||
ctx.restore();
|
||||
if(!c.img){
|
||||
ctx.fillStyle='#5A6570'; ctx.font='11px "Roboto", sans-serif'; ctx.textAlign='center';
|
||||
ctx.fillText(c.a.f.name.split('.').pop().toUpperCase(),p.x+p.w/2,p.y+p.h/2+4);
|
||||
}
|
||||
});
|
||||
molduraFolha(ctx,W,usado,filme,alturaCm/100);
|
||||
marcaDagua(ctx,W,cv.height);
|
||||
area.innerHTML=''; area.appendChild(cv);
|
||||
|
||||
// O que virou arte é a área pintada, não a soma das caixas: com encaixe por forma
|
||||
// as caixas se sobrepõem de propósito e a soma delas passa de 100%.
|
||||
const areaArteCm=enc.pos.reduce((s,p)=>
|
||||
s + (p.m && p.m.cheias!=null ? p.m.cheias*CEL*CEL : p.w*p.h), 0);
|
||||
const uso=Math.max(1,Math.min(100,
|
||||
Math.round(areaArteCm/(filme*Math.max(alturaCm,0.1))*100)));
|
||||
const el=document.createElement('div');
|
||||
el.className = uso>=70? 'usoBom' : 'usoRuim';
|
||||
el.innerHTML = (uso>=70
|
||||
? '<b>'+uso+'% da folha virou arte</b> bom aproveitamento'
|
||||
: '<b>'+uso+'% da folha virou arte</b> dá para melhorar: artes de larguras parecidas '+
|
||||
'se encaixam melhor lado a lado')
|
||||
+ (girou? ' · <b style="display:inline">'+girou+
|
||||
(girou>1?' artes giradas':' arte girada')+' 90°</b> para caber melhor':'');
|
||||
(fora||area).appendChild(el);
|
||||
if(aoTerminar) aoTerminar({alturaCm, uso, girou});
|
||||
});
|
||||
}
|
||||
|
||||
function previaArtes(){ desenhaMontagem($('pArea'), artes, metros, 560); }
|
||||
|
||||
// montagem ao vivo, ao lado da fila · redesenha 140 ms depois da ultima mexida
|
||||
let tMont=null;
|
||||
function previaAoVivo(){
|
||||
clearTimeout(tMont);
|
||||
$('vArea').montagemVersao=($('vArea').montagemVersao||0)+1;
|
||||
if(ehFolha()){
|
||||
$('vUso').innerHTML='';
|
||||
const tot=folhaTotalM();
|
||||
$('vMt').textContent = tot>0 ? fmtM(cobrar(tot))+' m' : '—';
|
||||
const medidas=folhas.filter(x=>x.med);
|
||||
const previews=folhas.filter(x=>x.previewSrc);
|
||||
if(previews.length){
|
||||
const painel=document.createElement('div'); painel.className='folhaPrevias';
|
||||
previews.forEach((x,index)=>{
|
||||
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');
|
||||
legenda.textContent=(previews.length>1 ? (index+1)+'. ' : '')+x.f.name+
|
||||
(x.med ? ' · '+n1(x.med.larg)+' × '+n1(x.med.alt)+' cm' : '');
|
||||
figura.append(imagem,legenda); painel.append(figura);
|
||||
});
|
||||
$('vArea').replaceChildren(painel);
|
||||
}else{
|
||||
$('vArea').innerHTML = medidas.length
|
||||
? '<div class="semmont"><b>'+(medidas.length>1
|
||||
? medidas.length+' folhas · '+fmtM(tot)+' m'
|
||||
: n1(medidas[0].med.larg)+' × '+n1(medidas[0].med.alt)+' cm')+'</b>'+
|
||||
'<span>'+(medidas.length>1? 'medidas lidas de cada arquivo' : medidas[0].med.fonte)+
|
||||
' · vão para a máquina do jeito que você montou</span></div>'
|
||||
: '<div class="semmont"><b>Sua folha aparece aqui</b>'+
|
||||
'<span>a medida é lida do próprio arquivo, não do peso dele</span></div>';
|
||||
}
|
||||
return;
|
||||
}
|
||||
tMont=setTimeout(()=>{
|
||||
const prontas=artes.filter(a=>a.cm>0);
|
||||
const W=Math.round(300*ZOOMS[zoomI]);
|
||||
$('vArea').classList.toggle('ampliado', zoomI>0);
|
||||
desenhaMontagem($('vArea'), prontas, 0, W, $('vUso'), r=>{
|
||||
montagemCm = r.alturaCm; // altura real da folha montada
|
||||
$('vMt').textContent = r.alturaCm? fmtM(cobrar(r.alturaCm/100))+' m' : '—';
|
||||
resumoArtes(); // o resumo lê a mesma folha
|
||||
agendaAvaliacao(); // a nota só sai com a metragem pronta
|
||||
});
|
||||
$('vArea').scrollTop=0;
|
||||
},140);
|
||||
}
|
||||
|
||||
function motivoNota(){
|
||||
if(!nota) return 'sem nota ainda · a conferência roda na sala antes de imprimir';
|
||||
if(!ehFolha()){
|
||||
const ruins=artes.filter(a=>a.cm>0 && (a.px/(a.cm/2.54))<300);
|
||||
if(!ruins.length) return 'nota '+nota+' · todas as artes em 300 DPI ou mais';
|
||||
const pior=ruins.reduce((p,a)=>(a.px/(a.cm/2.54))<(p.px/(p.cm/2.54))?a:p);
|
||||
return 'nota '+nota+' · '+ruins.length+(ruins.length>1?' artes abaixo':' arte abaixo')+
|
||||
' de 300 DPI · a menor é "'+pior.f.name+'" com '+
|
||||
Math.round(pior.px/(pior.cm/2.54))+' DPI em '+pior.cm.toFixed(1).replace('.',',')+' cm';
|
||||
}
|
||||
return nota>=90 ? 'nota '+nota+' · resolução ótima em toda a folha'
|
||||
: 'nota '+nota+' · a resolução é o único ponto — o resto corrigimos por você';
|
||||
}
|
||||
function previa(){
|
||||
// Na folha pronta o painel não tem o que mostrar: a folha vem fechada, ninguém
|
||||
// olhou dentro dela. Largura e metragem — o que de fato medimos — já aparecem
|
||||
// na coluna ao lado e no carrinho.
|
||||
if(ehFolha()){ $('prev').classList.remove('on'); return; }
|
||||
const cob=cobrar(metros), sobra=+(cob-metros).toFixed(2);
|
||||
$('pMt').innerHTML=fmtM(cob)+'<small> m</small>';
|
||||
$('pSub').textContent='folha montada por nós com as artes que você enviou';
|
||||
const linhas=[
|
||||
['var(--verde)','✓','Largura',larguraFilme()+' cm · dentro do padrão da máquina'],
|
||||
['var(--verde)','✓','Espaço entre artes','5 mm garantidos'],
|
||||
[nota>=75?'var(--verde)':'var(--laranja)', nota>=75?'✓':'!','Qualidade da folha',motivoNota()],
|
||||
['var(--verde)','✓','Metragem proporcional',
|
||||
'sua folha tem '+fmtM(metros)+' m e você paga '+fmtM(cob)+
|
||||
' m · arredondamos só até os 10 cm mais próximos'],
|
||||
];
|
||||
linhas.push(['var(--verde)','+','Identificação do pedido',
|
||||
'aplicamos seu nome e o número do pedido no fim da folha · por nossa conta']);
|
||||
$('pInfo').innerHTML=linhas.map(l=>
|
||||
'<div class="li"><span class="c" style="background:'+l[0]+
|
||||
(l[0]==='var(--laranja)'?';color:#241703':'')+'">'+l[1]+'</span>'+
|
||||
'<div><b>'+escapeHTML(l[2])+'</b><span>'+escapeHTML(l[3])+'</span></div></div>').join('');
|
||||
$('prev').classList.add('on');
|
||||
$('pArea').innerHTML='<div class="carregando">montando a prévia…</div>';
|
||||
try{ previaArtes(); }
|
||||
catch(e){ $('pArea').innerHTML='<div class="semprev"><b>Prévia indisponível</b>'+
|
||||
'O arquivo será processado normalmente.</div>'; }
|
||||
}
|
||||
|
||||
@@ -1,400 +0,0 @@
|
||||
/* Site DTF — PDF measurement and rasterisation, and the DPI of images inside one.
|
||||
Extracted verbatim from the single inline script in dtf-site.html.
|
||||
Loaded as classic scripts in the order listed there: they share one global
|
||||
scope and run top to bottom, exactly as the original did. */
|
||||
// ── PDF também é conferido: rasteriza a página e mede o DPI das imagens de dentro.
|
||||
// Arte vetorial não tem resolução — nesses casos a nota é máxima, e isso é correto.
|
||||
// Served from this origin, not a CDN: the Site keeps working when a third party
|
||||
// does not, and the CSP can name only 'self' for scripts and workers. Provenance
|
||||
// and hashes are recorded in local/static/vendor/README.md.
|
||||
const PDFJS_URL='/vendor/pdf.min.js';
|
||||
const PDFJS_WORKER=new URL('/vendor/pdf.worker.min.js', location.origin).href;
|
||||
let pdfLibP=null, temWorker=null;
|
||||
// Alguns navegadores e iframes bloqueiam Worker de blob. Descobrimos antes de
|
||||
// tentar, porque sem worker o PDF é lido na thread principal e precisa de outra
|
||||
// estratégia: grade mais grossa e mais tempo.
|
||||
function testaWorker(url){
|
||||
try{ const w=new Worker(url); w.terminate(); return true; }catch(e){ return false; }
|
||||
}
|
||||
function carregarPdfJs(){
|
||||
if(typeof window==='undefined') return Promise.resolve(null);
|
||||
if(window.pdfjsLib) return Promise.resolve(window.pdfjsLib);
|
||||
if(pdfLibP) return pdfLibP;
|
||||
pdfLibP=new Promise(res=>{
|
||||
const sc=document.createElement('script');
|
||||
sc.src=PDFJS_URL;
|
||||
sc.onload=()=>{
|
||||
try{
|
||||
const codigo='importScripts("'+PDFJS_WORKER+'");';
|
||||
const url=URL.createObjectURL(new Blob([codigo],{type:'application/javascript'}));
|
||||
temWorker=testaWorker(url);
|
||||
window.pdfjsLib.GlobalWorkerOptions.workerSrc = temWorker? url : PDFJS_WORKER;
|
||||
}catch(e){
|
||||
temWorker=false;
|
||||
try{ window.pdfjsLib.GlobalWorkerOptions.workerSrc=PDFJS_WORKER; }catch(e2){}
|
||||
}
|
||||
res(window.pdfjsLib||null);
|
||||
};
|
||||
sc.onerror=()=>res(null);
|
||||
document.head.appendChild(sc);
|
||||
});
|
||||
return pdfLibP;
|
||||
}
|
||||
|
||||
// DPI real de cada imagem embutida: acompanha a matriz de transformação e compara
|
||||
// o tamanho em que a imagem foi colocada com quantos pixels ela tem.
|
||||
function dpiDasImagens(page, ops){
|
||||
const mul=(a,b)=>[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]];
|
||||
const O=window.pdfjsLib.OPS;
|
||||
let ctm=[1,0,0,1,0,0]; const pilha=[]; const dpis=[];
|
||||
for(let i=0;i<ops.fnArray.length;i++){
|
||||
const fn=ops.fnArray[i], ar=ops.argsArray[i];
|
||||
if(fn===O.save) pilha.push(ctm.slice());
|
||||
else if(fn===O.restore) ctm=pilha.pop()||[1,0,0,1,0,0];
|
||||
else if(fn===O.transform) ctm=mul(ctm,ar);
|
||||
else if(fn===O.paintImageXObject||fn===O.paintJpegXObject||fn===O.paintImageXObjectRepeat){
|
||||
let im=null;
|
||||
try{ im=page.objs.get(ar[0]); }catch(e){}
|
||||
if(!im||!im.width) continue;
|
||||
const larguraPt=Math.hypot(ctm[0],ctm[1]);
|
||||
if(larguraPt<1) continue;
|
||||
const areaPt=Math.abs(ctm[0]*ctm[3]-ctm[1]*ctm[2]);
|
||||
dpis.push({dpi: im.width/(larguraPt/72), area: areaPt});
|
||||
}
|
||||
}
|
||||
return dpis;
|
||||
}
|
||||
|
||||
// A régua de 96 DPI vale por arte, não pela folha inteira. Numa folha de 250 peças,
|
||||
// um selo pequeno em 65 DPI não pode barrar o pedido: o que decide é quanto da
|
||||
// área impressa está abaixo do limite.
|
||||
function resumoDpi(lista){
|
||||
if(!lista.length) return null;
|
||||
const total=lista.reduce((t,x)=>t+x.area,0) || 1;
|
||||
const abaixoPiso=lista.filter(x=>x.dpi<DPI_RECUSA);
|
||||
const abaixoAviso=lista.filter(x=>x.dpi<DPI_AVISA);
|
||||
const pond=lista.reduce((t,x)=>t+x.dpi*x.area,0)/total;
|
||||
return {
|
||||
pior: Math.round(Math.min(...lista.map(x=>x.dpi))),
|
||||
ponderado: Math.round(pond),
|
||||
fracBaixa: abaixoPiso.reduce((t,x)=>t+x.area,0)/total,
|
||||
fracAviso: abaixoAviso.reduce((t,x)=>t+x.area,0)/total,
|
||||
quantasBaixas: abaixoPiso.length,
|
||||
quantasAviso: abaixoAviso.length,
|
||||
total: lista.length
|
||||
};
|
||||
}
|
||||
|
||||
// Rasteriza a primeira página a 1 mm por pixel e devolve a tela para a análise
|
||||
function rasterizarPdf(file, larguraCm, alturaCm){
|
||||
let motivo=null;
|
||||
const trabalho=carregarPdfJs().then(lib=>{
|
||||
if(!lib){ motivo='não foi possível carregar o leitor de PDF'; return null; }
|
||||
// sem worker tudo roda na thread principal · grade mais grossa para caber
|
||||
const folga = temWorker===false ? 2.2 : 1;
|
||||
return file.arrayBuffer().then(buf=>lib.getDocument({
|
||||
data:buf, disableFontFace:true, isEvalSupported:false, useSystemFonts:false,
|
||||
verbosity:0 // sem worker é fallback, não erro
|
||||
}).promise).then(doc=>
|
||||
doc.getPage(1).then(page=>{
|
||||
const vp1=page.getViewport({scale:1});
|
||||
const passo=passoDe(larguraCm, alturaCm||larguraCm)*folga;
|
||||
const alvo=Math.max(8, Math.round(larguraCm/passo));
|
||||
const esc=alvo/vp1.width;
|
||||
const vp=page.getViewport({scale:esc});
|
||||
const cv=document.createElement('canvas');
|
||||
cv.width=Math.max(8,Math.round(vp.width)); cv.height=Math.max(8,Math.round(vp.height));
|
||||
const ctx=cv.getContext('2d',{willReadFrequently:true});
|
||||
return page.render({canvasContext:ctx, viewport:vp, background:'rgba(0,0,0,0)'})
|
||||
.promise.then(()=>page.getOperatorList()).then(ops=>{
|
||||
let dpis=[];
|
||||
try{ dpis=dpiDasImagens(page,ops); }catch(e){}
|
||||
const r=resumoDpi(dpis);
|
||||
return {tela:cv, dpi: r? r.ponderado : null, res:r};
|
||||
});
|
||||
})
|
||||
);
|
||||
}).catch(e=>{ motivo=(e&&e.message)? e.message : 'erro ao abrir o PDF'; return null; });
|
||||
// sem worker o processo é lento: damos mais tempo antes de desistir
|
||||
const espera = temWorker===false ? 90000 : 30000;
|
||||
const tarde=new Promise(r=>setTimeout(()=>{ motivo='demorou demais neste navegador'; r('tempo'); }, espera));
|
||||
return Promise.race([trabalho, tarde]).then(r=>{
|
||||
const saida = r==='tempo'? null : r;
|
||||
if(!saida) return {erro: motivo||'não deu para conferir', semWorker: temWorker===false};
|
||||
return saida;
|
||||
});
|
||||
}
|
||||
|
||||
function medirFolha(file){
|
||||
return new Promise(res=>{
|
||||
const nome=(file.name||'').toLowerCase();
|
||||
if(RENDERIZA.test(nome)){
|
||||
carregarImagem(file).then(img=>{
|
||||
if(!img || !img.width) return res(null);
|
||||
const L=larguraFilme();
|
||||
// Uma imagem só é folha montada se tiver pixel para ocupar a largura do
|
||||
// filme. Sem esta conferência, qualquer arte pequena vira "folha" e leva
|
||||
// o preço por metro sem nunca ter sido montada.
|
||||
res({larg:L, alt:+(img.height/img.width*L).toFixed(1), fonte:'proporção da imagem',
|
||||
dpiFolha:Math.round(img.width/(L/2.54)), px:img.width});
|
||||
});
|
||||
return;
|
||||
}
|
||||
if(/\.pdf$/.test(nome)){
|
||||
const pedaco=(inicio,fim)=>new Promise(r=>{
|
||||
const fr=new FileReader();
|
||||
fr.onload=()=>r(new TextDecoder('latin1').decode(new Uint8Array(fr.result)));
|
||||
fr.onerror=()=>r('');
|
||||
fr.readAsArrayBuffer(file.slice(inicio,fim));
|
||||
});
|
||||
const M=4*1048576;
|
||||
Promise.all([pedaco(0,M), pedaco(Math.max(0,file.size-M), file.size)]).then(([a,b])=>{
|
||||
const txt=a+b;
|
||||
const re=/\/MediaBox\s*\[\s*(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)/;
|
||||
const m=txt.match(re);
|
||||
if(!m) return res(null);
|
||||
// UserUnit escala a página inteira · sem ele, 1 unidade = 1/72 pol
|
||||
const uu=txt.match(/\/UserUnit\s+([\d.]+)/);
|
||||
const esc=uu? Math.max(1,Math.min(75000,+uu[1])) : 1;
|
||||
const ptW=Math.abs(+m[3]-+m[1]), ptH=Math.abs(+m[4]-+m[2]);
|
||||
if(!(ptW>0&&ptH>0)) return res(null);
|
||||
const w=ptW*PT_CM*esc, h=ptH*PT_CM*esc;
|
||||
// acima de 14.400 unidades sem UserUnit o arquivo está fora da especificação
|
||||
const foraDoPadrao = !uu && (ptW>PDF_MAX_PT || ptH>PDF_MAX_PT);
|
||||
res({larg:+w.toFixed(1), alt:+h.toFixed(1),
|
||||
fonte:'página do PDF'+(uu? ' · UserUnit '+esc : ''), foraDoPadrao});
|
||||
});
|
||||
return;
|
||||
}
|
||||
res(null);
|
||||
});
|
||||
}
|
||||
|
||||
function pintaFolha(){
|
||||
const L=larguraFilme();
|
||||
pintaTipoEnvio(); // trava o seletor enquanto houver folha
|
||||
if(!folhas.length){ $('lista').innerHTML=''; agendaAvaliacao(); previaAoVivo(); return; }
|
||||
|
||||
$('lista').innerHTML = folhas.map((x,i)=>{
|
||||
const lido=!!x.med, larga=lido && x.med.larg>L+0.5, suspeito=lido && x.med.foraDoPadrao;
|
||||
const sub=(x.m||0)*(x.rep||1);
|
||||
let medida='';
|
||||
if(lido){
|
||||
const a=x.an;
|
||||
const conferido = a
|
||||
? '<div class="conf"><b>Conferido de verdade</b>'+
|
||||
'<span>'+a.artes+' peça'+(a.artes===1?'':'s')+' · '+a.aproveitamento+
|
||||
'% 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. '+
|
||||
'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>':'')+
|
||||
(larga? '<em><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: '+
|
||||
'encolher a folha encolheria cada arte junto.'+
|
||||
(x.med.larg<=57.5
|
||||
? ' 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>':'')+
|
||||
'</div>';
|
||||
}else{
|
||||
const teto=TABELA[modo], piso=pisoEscada();
|
||||
const dif=(x.m>0)? (teto-piso)*cobrar(x.m*(x.rep||1)) : 0;
|
||||
medida='<div class="med pede">'+
|
||||
'<b>'+escapeHTML(extDe(x.f.name))+' não abre no navegador</b>'+
|
||||
'<span>Sem conseguir conferir o arquivo, não temos como dar nota — e sem nota '+
|
||||
'o metro sai pela tabela, <b>'+rs(teto)+'</b>. Alguém vai abrir esse arquivo na mão '+
|
||||
'antes de imprimir.</span>'+
|
||||
'<div class="troca">Exportando a mesma folha em <b>PNG ou PDF</b>, a conferência é '+
|
||||
'automática e o metro pode cair até <b>'+rs(piso)+'</b>'+
|
||||
(dif>0.5? ' — <b>'+rs(dif)+' a menos</b> só nesta folha' : '')+'.</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
|
||||
? '<div class="prog"><i style="width:'+x.pct+'%"></i></div>'+
|
||||
'<span class="progT">'+(x.pct<50?'lendo o arquivo…':
|
||||
x.pct<70?'medindo a folha…':'conferindo a arte…')+'</span>'
|
||||
: x.semAnalise
|
||||
? '<div class="progF"><b>Não conseguimos conferir esta folha.</b> '+x.semAnalise+
|
||||
'. 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>'
|
||||
: '';
|
||||
return '<div class="rep"><div class="l1"><b>'+
|
||||
(folhas.length>1? (i+1)+'. ':'')+escapeHTML(x.f.name)+'</b>'+
|
||||
'<span style="color:var(--fraco);font-size:10.5px">'+fmt(x.f.size)+'</span>'+
|
||||
'<button data-rmf="'+i+'">×</button></div>'+
|
||||
barra+medida+
|
||||
(x.m>0 ?
|
||||
'<div class="cps" style="margin-top:10px">'+
|
||||
'<div><label>quantas vezes repetir</label>'+
|
||||
'<input type="number" min="1" max="200" data-repf="'+i+'" value="'+x.rep+'"></div>'+
|
||||
'<div class="conta"><b>'+fmtM(sub)+' m</b>'+
|
||||
(x.rep>1? ' · '+x.rep+' × '+fmtM(x.m)+' m':'')+'</div>'+
|
||||
'</div>' : '')+
|
||||
'</div>';
|
||||
}).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',()=>{
|
||||
folhas.splice(+b.dataset.rmf,1); pintaFolha();
|
||||
}));
|
||||
$('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();
|
||||
}));
|
||||
$('lista').querySelectorAll('[data-comp]').forEach(el=>el.addEventListener('change',e=>{
|
||||
folhas[+el.dataset.comp].m=Math.max(0,Math.min(60,+e.target.value||0)); pintaFolha();
|
||||
}));
|
||||
agendaAvaliacao(); previaAoVivo();
|
||||
}
|
||||
|
||||
// sugestão de largura
|
||||
const cabemNaLinha=w=>Math.max(1,Math.floor((larguraFilme()+GAP_CM)/(w+GAP_CM)));
|
||||
const larguraPara=n=>(larguraFilme()-(n-1)*GAP_CM)/n;
|
||||
// Só sugere se a folha realmente encolher. Caber mais um por fileira não adianta
|
||||
// nada se a quantidade pedida continuar ocupando o mesmo número de fileiras.
|
||||
function sugestao(a){
|
||||
const w=a.cm, q=Math.max(1,a.q||1), prop=propDe(a);
|
||||
const n=cabemNaLinha(w), alvo=Math.floor(larguraPara(n+1)*10)/10;
|
||||
if(!(alvo>0) || alvo>=w) return null;
|
||||
if((w-alvo)/w>0.12) return null; // não corta mais que 12% da peça
|
||||
const fa=Math.ceil(q/n), fd=Math.ceil(q/(n+1));
|
||||
if(fd>=fa) return null; // mesmo número de fileiras: não muda nada
|
||||
const economiaCm=fa*w*prop - fd*alvo*prop;
|
||||
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};
|
||||
}
|
||||
// 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('.',',');
|
||||
|
||||
function pintaArtes(){
|
||||
$('lista').innerHTML=artes.map((a,i)=>{
|
||||
let barra='', dica='', calc='<span>informe a largura para ver a qualidade</span>';
|
||||
if(a.cm>0){
|
||||
const dpi=Math.round(a.px/(a.cm/2.54)), n=Math.max(6,Math.min(100,Math.round(dpi/300*100)));
|
||||
barra='<div class="qb2"><i class="'+cls(n)+'" style="width:'+n+'%"></i></div>';
|
||||
calc='<span class="'+cls(n)+'"><b>'+n+'%</b> · '+dpi+' DPI</span><span>'+
|
||||
a.q+' × '+n1(a.cm)+' × '+n1(a.cm*propDe(a))+' cm</span>';
|
||||
const sug=sugestao(a);
|
||||
dica = sug
|
||||
? '<div class="dica"><b>Reduzindo para '+n1(sug.alvo)+' cm você economiza '+
|
||||
n1(sug.cm)+' cm de folha</b>Passam de '+sug.de+' para '+sug.para+
|
||||
' por fileira, e as suas '+a.q+' peças ocupam '+sug.fileiras+
|
||||
' fileira'+(sug.fileiras>1?'s':'')+' a menos.<button data-usar="'+i+'" data-cm="'+
|
||||
sug.alvo+'">Usar sugerido</button></div>'
|
||||
: '';
|
||||
dica = vizPeca(a) + dica;
|
||||
}
|
||||
return '<div class="art'+(a===recemChegada?' nova':'')+'"><div class="l1"><b>'+escapeHTML(a.f.name)+'</b>'+
|
||||
'<span style="color:var(--fraco);font-size:10.5px">'+a.px+' px</span>'+
|
||||
'<button data-rm="'+i+'">×</button></div><div class="cps">'+
|
||||
'<div><label>largura na peça</label><input type="number" min="1" max="'+larguraFilme()+
|
||||
'" placeholder="cm" data-cm="'+i+'" value="'+(a.cm||'')+'"></div>'+
|
||||
'<div><label>quantas vezes</label><input type="number" min="1" placeholder="1" data-q="'+i+
|
||||
'" value="'+a.q+'"></div></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('')+
|
||||
'<span class="sep"></span>'+
|
||||
'<button class="gr'+(a.giro?' on':'')+'" data-giro="'+i+'" title="Girar 90°">⟳</button>'+
|
||||
'<button class="gr'+(a.esp?' on':'')+'" data-esp="'+i+'" title="Espelhar">⇋</button>'+
|
||||
'</div>'+barra+'<div class="calc">'+calc+'</div>'+dica+'</div>';
|
||||
}).join('');
|
||||
$('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-q]').forEach(i=>i.addEventListener('change',()=>{artes[+i.dataset.q].q=Math.max(1,Math.floor(+i.value||1));pintaArtes();}));
|
||||
$('lista').querySelectorAll('[data-cm], [data-q]').forEach(i=>i.addEventListener('input',()=>{
|
||||
const largura=i.hasAttribute('data-cm');
|
||||
const a=artes[+(largura?i.dataset.cm:i.dataset.q)];
|
||||
a[largura?'cm':'q']=largura?Math.max(0,+i.value||0):Math.max(1,Math.floor(+i.value||1));
|
||||
previaAoVivo();
|
||||
}));
|
||||
$('lista').querySelectorAll('[data-usar]').forEach(b=>b.addEventListener('click',()=>{
|
||||
artes[+b.dataset.usar].cm=+b.dataset.cm; pintaArtes();
|
||||
}));
|
||||
$('lista').querySelectorAll('[data-tam]').forEach(b=>b.addEventListener('click',()=>{
|
||||
artes[+b.dataset.tam].cm=+b.dataset.t; pintaArtes();
|
||||
}));
|
||||
$('lista').querySelectorAll('[data-giro]').forEach(b=>b.addEventListener('click',()=>{
|
||||
const a=artes[+b.dataset.giro]; a.giro=a.giro?0:90; pintaArtes();
|
||||
}));
|
||||
$('lista').querySelectorAll('[data-esp]').forEach(b=>b.addEventListener('click',()=>{
|
||||
const a=artes[+b.dataset.esp]; a.esp=!a.esp; pintaArtes();
|
||||
}));
|
||||
resumoArtes();
|
||||
previaAoVivo();
|
||||
}
|
||||
function resumoArtes(){
|
||||
const r=$('rA'), prontas=artes.filter(a=>a.cm>0);
|
||||
if(!prontas.length){ r.style.display='none'; return; }
|
||||
const m=montagemCm/100; // a mesma folha que aparece ao lado
|
||||
const notas=prontas.map(a=>Math.max(6,Math.min(100,Math.round((a.px/(a.cm/2.54))/300*100))));
|
||||
const media=Math.round(notas.reduce((s,n)=>s+n,0)/notas.length), pior=Math.min(...notas);
|
||||
const falta=artes.length-prontas.length;
|
||||
r.style.display='block'; r.className='resumoA on';
|
||||
r.innerHTML='<div class="r"><span>artes com tamanho</span><b>'+prontas.length+' de '+artes.length+'</b></div>'+
|
||||
'<div class="r"><span>qualidade média</span><b style="color:'+
|
||||
(media>=85?'var(--verde)':media>=50?'var(--laranja)':'var(--vermelho)')+'">'+media+'%</b></div>'+
|
||||
(pior<media?'<div class="r"><span>a pior arte</span><b>'+pior+'%</b></div>':'')+
|
||||
(m>0?'<div class="r"><span>folha montada</span><b>'+fmtM(m)+' m</b></div>'+
|
||||
(cobrar(m)>m+0.001?'<div class="r"><span>cobrança</span><b>'+fmtM(cobrar(m))+
|
||||
' m'+(cobrar(m)===MINIMO_M?' · mínimo':'')+'</b></div>':''):'')+
|
||||
(falta?'<div class="r" style="color:var(--laranja)"><span>faltam informar</span><b>'+falta+'</b></div>':'');
|
||||
}
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
/* Site DTF — The grade shown to the customer, and the reservations attached to it.
|
||||
Extracted verbatim from the single inline script in dtf-site.html.
|
||||
Loaded as classic scripts in the order listed there: they share one global
|
||||
scope and run top to bottom, exactly as the original did. */
|
||||
// ── enviar
|
||||
// Avalia sozinho conforme o cliente sobe e preenche. Devolve o que ainda falta,
|
||||
// para a fila dizer em texto em vez de travar num botão.
|
||||
function avaliar(){
|
||||
if(ehFolha()){
|
||||
if(!folhas.length) return {pronto:false, falta:'Arraste sua folha montada para começar.'};
|
||||
const larga=folhas.find(x=>x.med && x.med.larg > larguraFilme()+0.5);
|
||||
if(larga) return {pronto:false, falta:'A folha '+larga.f.name+' tem '+n1(larga.med.larg)+
|
||||
' cm e não cabe no filme de '+n1(larguraFilme())+' cm.'};
|
||||
const semMedida=folhas.filter(x=>!(x.m>0)).length;
|
||||
if(semMedida) return {pronto:false, falta:'Informe o comprimento de '+semMedida+
|
||||
' folha'+(semMedida>1?'s':'')+'.'};
|
||||
metros=folhaTotalM();
|
||||
reencaixado=false;
|
||||
const ans=folhas.map(x=>x.an).filter(Boolean);
|
||||
if(!ans.length){
|
||||
// sem análise possível (PDF, CDR, PSD): não inventa nota
|
||||
const exts=[...new Set(folhas.map(f=>extDe(f.f.name)))].join(', ');
|
||||
mostraNota([
|
||||
['av','Sem nota · '+exts+' não abre no navegador',
|
||||
'não temos como conferir a arte antes de imprimir'],
|
||||
['er','Metro pela tabela · '+rs(TABELA[modo]),
|
||||
'exportando em PNG ou PDF, cai até '+rs(pisoEscada())],
|
||||
['fix','Alguém abre seu arquivo','a conferência é feita na mão, na sala']
|
||||
], 0, folhas.length>1? folhas.length+' folhas' : folhas[0].f.name,
|
||||
larguraFilme()+' cm · '+fmtM(metros)+' m · preço de tabela');
|
||||
return {pronto:true};
|
||||
}
|
||||
const dpi=Math.min(...ans.map(a=>a.dpi));
|
||||
const n=Math.max(6,Math.min(100,Math.round(dpi/DPI_AVISA*100)));
|
||||
const soma=(k)=>ans.reduce((t,a)=>t+a[k],0);
|
||||
const itens=[
|
||||
ans.every(a=>a.vetor)
|
||||
? ['ok','Arte vetorial','sem imagem embutida · imprime em qualquer tamanho']
|
||||
: [dpi>=DPI_AVISA?'ok':dpi>=DPI_RECUSA?'av':'er','Resolução '+dpi+' DPI',
|
||||
ans.some(a=>a.fonteDpi==='imagens')
|
||||
? 'média por área das imagens dentro do PDF · pior em '+
|
||||
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'],
|
||||
['ok', soma('artes')+' peças na folha',
|
||||
Math.round(soma('aproveitamento')/ans.length)+'% da folha virou arte'],
|
||||
soma('residuos') ? ['er','Resíduo de recorte',
|
||||
soma('residuos')+' ponto'+(soma('residuos')>1?'s':'')+' abaixo de 2 mm · a impressora imprime']
|
||||
: ['ok','Sem resíduo de recorte','nada solto na folha'],
|
||||
soma('encostadas') ? ['fix', soma('encostadas')+' peças a menos de 5 mm',
|
||||
'se forem artes diferentes, separamos com 5 mm sem custo']
|
||||
: ['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']
|
||||
: ['ok','Fundo transparente','canal alfa conferido'],
|
||||
null
|
||||
].filter(Boolean);
|
||||
mostraNota(itens, n, folhas.length>1? folhas.length+' folhas' : folhas[0].f.name,
|
||||
larguraFilme()+' cm · '+fmtM(metros)+' m');
|
||||
return {pronto:true};
|
||||
}
|
||||
if(!artes.length) return {pronto:false, falta:'Arraste suas artes para começar.'};
|
||||
const sem=artes.filter(a=>!a.cm).length;
|
||||
if(sem) return {pronto:false, falta:'Informe a largura de '+sem+' arte'+(sem>1?'s':'')+
|
||||
' para ver a nota e o preço.'};
|
||||
// metragem = altura da folha depois de montada, com os 5 mm entre peças
|
||||
metros=+(montagemCm/100).toFixed(4);
|
||||
if(!(metros>0)) return {pronto:false, falta:'Montando a folha…'};
|
||||
const notas=artes.map(a=>Math.max(6,Math.min(100,Math.round((a.px/(a.cm/2.54))/300*100))));
|
||||
const media=Math.round(notas.reduce((s,n)=>s+n,0)/notas.length), pior=Math.min(...notas);
|
||||
mostraNota([
|
||||
['ok','Montagem por nossa conta','5 mm garantidos'],
|
||||
[cls(pior),'Pior arte em '+pior+'%',pior>=85?'todas em boa resolução':'reenviar maior sai mais barato']
|
||||
],media,artes.length+' arte'+(artes.length>1?'s':''),
|
||||
'folha montada por nós · '+fmtM(metros)+' m');
|
||||
return {pronto:true};
|
||||
}
|
||||
|
||||
// redesenha 220 ms depois da última mexida, para não avaliar a cada tecla
|
||||
let tAval=null;
|
||||
function agendaAvaliacao(){
|
||||
clearTimeout(tAval);
|
||||
tAval=setTimeout(()=>{
|
||||
const r=avaliar();
|
||||
const el=$('aoVivo');
|
||||
if(!el) return;
|
||||
if(r.pronto){
|
||||
el.className='aoVivo pronto';
|
||||
el.innerHTML='<span class="pt2"></span><span><b>Nota e preço calculados.</b> '+
|
||||
'Confira abaixo e feche o pedido.</span>';
|
||||
}else{
|
||||
el.className='aoVivo';
|
||||
el.innerHTML='<span class="pt2"></span><span>'+escapeHTML(r.falta)+'</span>';
|
||||
limpaPaineis(); // o carrinho só some se o pedido estiver vazio
|
||||
}
|
||||
},220);
|
||||
}
|
||||
function mostraNota(itens,n,titulo,sub){
|
||||
nota=n;
|
||||
$('qual').classList.add('on');
|
||||
$('qNome').textContent=titulo; $('qSub').textContent=sub;
|
||||
const semNota = n===0;
|
||||
$('pct').innerHTML = semNota? '—<small>/100</small>' : n+'<small>/100</small>';
|
||||
const c = semNota? 'var(--fraco)' : n>=85?'var(--verde)':n>=50?'var(--laranja)':'var(--vermelho)';
|
||||
$('pct').style.color=c;
|
||||
$('qbi').style.width=(semNota?0:n)+'%'; $('qbi').style.background=c;
|
||||
$('qitens').innerHTML=itens.map(i=>
|
||||
'<div class="qi '+i[0]+'"><span class="c">'+
|
||||
(i[0]==='ok'?'✓':i[0]==='av'?'!':i[0]==='fix'?'↻':'×')+'</span>'+
|
||||
'<div><b>'+escapeHTML(i[1])+'</b><span>'+escapeHTML(i[2])+'</span></div></div>').join('');
|
||||
$('qmsg').style.display='none';
|
||||
ressalvaResolucao();
|
||||
carrinho();
|
||||
}
|
||||
|
||||
// A resolução do arquivo não é a das artes dentro dele: um PDF em 300 DPI
|
||||
// pode ter uma imagem inserida em 180, e é essa que sai serrilhada.
|
||||
function ressalvaResolucao(){
|
||||
const box=$('ciente'), chk=$('cienteOk');
|
||||
let baixas=[], geral=300, recusadas=[];
|
||||
if(ehFolha()){
|
||||
// Uma régua só: o número que precifica é o número que bloqueia.
|
||||
// Recusa quando a média por área fica abaixo de 96 DPI — a folha inteira não
|
||||
// vale o filme. Se a média passa mas há peças ruins, é ressalva com aceite.
|
||||
folhas.forEach((x,k)=>{
|
||||
if(!x.an) return;
|
||||
const r=x.an.res, cm=x.med? x.med.larg : larguraFilme();
|
||||
const nome=(folhas.length>1? (k+1)+'. ':'')+x.f.name;
|
||||
const extra = r ? {pior:r.pior, de:r.total,
|
||||
quantas:r.quantasBaixas, parte:Math.round(r.fracBaixa*100),
|
||||
quantasAviso:r.quantasAviso, parteAviso:Math.round(r.fracAviso*100)} : {};
|
||||
if(x.an.dpi<DPI_RECUSA) recusadas.push(Object.assign({nome, dpi:x.an.dpi, cm}, extra));
|
||||
else if(x.an.dpi<DPI_AVISA || (r&&r.quantasAviso))
|
||||
baixas.push(Object.assign({nome, dpi:x.an.dpi, cm}, extra));
|
||||
});
|
||||
geral = folhas.some(x=>x.an) ? Math.max(...folhas.filter(x=>x.an).map(x=>x.an.dpi)) : 300;
|
||||
}else{
|
||||
const comTamanho=artes.filter(a=>a.cm>0);
|
||||
recusadas=comTamanho.filter(a=>dpiDe(a)<DPI_RECUSA)
|
||||
.map(a=>({nome:a.f.name, dpi:Math.round(dpiDe(a)), cm:a.cm}));
|
||||
baixas=comTamanho.filter(a=>dpiDe(a)>=DPI_RECUSA && dpiDe(a)<DPI_AVISA)
|
||||
.map(a=>({nome:a.f.name, dpi:Math.round(dpiDe(a)), cm:a.cm}));
|
||||
const todas=comTamanho.map(a=>dpiDe(a));
|
||||
geral = todas.length? Math.round(Math.max(...todas)) : 300;
|
||||
}
|
||||
// Abaixo de 96 DPI a sala não imprime. Não é desconto, é recusa —
|
||||
// e não existe aceite que destrave.
|
||||
if(recusadas.length){
|
||||
$('cienteTxt').innerHTML=
|
||||
'<p><b>A média da folha ficou abaixo de '+DPI_RECUSA+' DPI.</b> Nesse ponto a peça '+
|
||||
'sai serrilhada a ponto de não valer o filme. Reenvie maior ou reduza a largura.</p>'+
|
||||
'<div class="lista">'+recusadas.map(b=>'<div><b>'+escapeHTML(b.nome)+'</b> — '+
|
||||
(b.quantas
|
||||
? 'média de '+b.dpi+' DPI · pior imagem em '+b.pior+' DPI · '+b.quantas+
|
||||
' de '+b.de+' imagens abaixo de '+DPI_RECUSA+' DPI, cobrindo '+b.parte+'% da arte'
|
||||
: b.dpi+' DPI em '+n1(b.cm)+' cm · precisa de '+
|
||||
Math.ceil(DPI_AVISA*b.cm/2.54)+' px de largura')+'</div>')
|
||||
.join('')+'</div>';
|
||||
$('cienteTit').textContent='Resolução abaixo do que imprimimos';
|
||||
box.className='ciente on recusa'; chk.checked=false; $('qOk').disabled=true;
|
||||
return;
|
||||
}
|
||||
$('cienteTit').textContent='Ressalva de resolução';
|
||||
box.className='ciente';
|
||||
if(!baixas.length){
|
||||
box.classList.remove('on'); chk.checked=false; $('qOk').disabled=false; return;
|
||||
}
|
||||
const menor=baixas.reduce((p,b)=>b.dpi<p.dpi?b:p);
|
||||
const media=Math.round(baixas.reduce((s,b)=>s+b.dpi,0)/baixas.length);
|
||||
$('cienteTxt').innerHTML=
|
||||
'<p><b>Parte da arte está abaixo de '+DPI_AVISA+' DPI.</b> Essas peças podem sair '+
|
||||
'com traço serrilhado, e isso não tem conserto depois de impresso. '+
|
||||
'O resto da folha sai normal.</p>'+
|
||||
'<div class="lista">'+baixas.map(b=>'<div><b>'+escapeHTML(b.nome)+'</b> — '+
|
||||
(b.quantasAviso
|
||||
? 'média de '+b.dpi+' DPI · '+b.quantasAviso+' de '+b.de+' imagens abaixo de '+
|
||||
DPI_AVISA+' DPI, cobrindo '+b.parteAviso+'% da arte'+
|
||||
(b.quantas? ' · dessas, '+b.quantas+' abaixo de '+DPI_RECUSA+' DPI ('+
|
||||
b.parte+'% da arte)' : '')
|
||||
: b.dpi+' DPI'+(b.cm? ' em '+n1(b.cm)+' cm':''))+
|
||||
'</div>').join('')+'</div>';
|
||||
box.className='ciente on'; chk.checked=false; $('qOk').disabled=true;
|
||||
}
|
||||
$('cienteOk').addEventListener('change',e=>{
|
||||
if($('ciente').classList.contains('recusa')) return; // recusa não se aceita
|
||||
$('qOk').disabled=!e.target.checked;
|
||||
});
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
/* Site DTF — Reading a finished sheet from its alpha channel: pieces, gaps, waste.
|
||||
Extracted verbatim from the single inline script in dtf-site.html.
|
||||
Loaded as classic scripts in the order listed there: they share one global
|
||||
scope and run top to bottom, exactly as the original did. */
|
||||
// ── Análise real da folha: lê o canal alfa, separa cada ilha de arte e mede.
|
||||
// Grade de 1 mm. Só roda em arquivo que o navegador abre; PDF precisa do servidor.
|
||||
const MM=0.1; // grade fina: 1 mm
|
||||
const CELULAS_MAX=1200000; // teto de custo da análise
|
||||
// Folha grande vira grade mais grossa. Sem isso, 57 x 480 cm dá 2,7 milhões de
|
||||
// células e o navegador congela antes de terminar.
|
||||
function passoDe(larguraCm, alturaCm){
|
||||
const fino=(larguraCm/MM)*(alturaCm/MM);
|
||||
return fino<=CELULAS_MAX ? MM : MM*Math.sqrt(fino/CELULAS_MAX);
|
||||
}
|
||||
|
||||
// Dilata a máscara em r células · usada para agrupar por distância real, não por caixa
|
||||
function inchar(bits,cw,ch,r){
|
||||
const a=new Uint8Array(cw*ch), b=new Uint8Array(cw*ch);
|
||||
for(let y=0;y<ch;y++){ const o=y*cw;
|
||||
for(let x=0;x<cw;x++){ if(!bits[o+x]) continue;
|
||||
const x0=Math.max(0,x-r), x1=Math.min(cw-1,x+r);
|
||||
for(let k=x0;k<=x1;k++) a[o+k]=1; } }
|
||||
for(let x=0;x<cw;x++)
|
||||
for(let y=0;y<ch;y++){ if(!a[y*cw+x]) continue;
|
||||
const y0=Math.max(0,y-r), y1=Math.min(ch-1,y+r);
|
||||
for(let k=y0;k<=y1;k++) b[k*cw+x]=1; }
|
||||
return b;
|
||||
}
|
||||
// Componentes conexos de 8 vizinhos · devolve o mapa de rótulos e quantos grupos
|
||||
function grupos(bits,cw,ch){
|
||||
const marca=new Int32Array(cw*ch).fill(-1), fila=new Int32Array(cw*ch);
|
||||
let n=0;
|
||||
for(let p0=0;p0<bits.length;p0++){
|
||||
if(!bits[p0]||marca[p0]>=0) continue;
|
||||
const id=n++; let ini=0,fim=0; fila[fim++]=p0; marca[p0]=id;
|
||||
while(ini<fim){
|
||||
const p=fila[ini++], x=p%cw, y=(p/cw)|0;
|
||||
for(let dy=-1;dy<=1;dy++) for(let dx=-1;dx<=1;dx++){
|
||||
const nx=x+dx, ny=y+dy;
|
||||
if(nx<0||ny<0||nx>=cw||ny>=ch) continue;
|
||||
const q=ny*cw+nx;
|
||||
if(bits[q]&&marca[q]<0){ marca[q]=id; fila[fim++]=q; }
|
||||
}
|
||||
}
|
||||
}
|
||||
return {marca,n};
|
||||
}
|
||||
|
||||
// ── O que a folha permite medir, e só isso.
|
||||
// Peça = pixels a menos de 2 mm entre si · letras de uma palavra ficam juntas.
|
||||
// Encostadas = peças que só se unem quando a folga chega a 6 mm.
|
||||
const R_PECA=1, R_PERTO=2; // peça: 2 mm · aviso de encostadas: 4 mm
|
||||
function analisarFolha(img, larguraCm){
|
||||
const alturaCmEst=larguraCm*img.height/img.width;
|
||||
const passo=passoDe(larguraCm, alturaCmEst);
|
||||
const cw=Math.max(8,Math.round(larguraCm/passo));
|
||||
const ch=Math.max(8,Math.round(cw*img.height/img.width));
|
||||
const cv=document.createElement('canvas'); cv.width=cw; cv.height=ch;
|
||||
const ctx=cv.getContext('2d',{willReadFrequently:true});
|
||||
ctx.clearRect(0,0,cw,ch); ctx.drawImage(img,0,0,cw,ch);
|
||||
const d=ctx.getImageData(0,0,cw,ch).data;
|
||||
|
||||
const bits=new Uint8Array(cw*ch);
|
||||
let opacos=0;
|
||||
for(let i=0,j=3;i<bits.length;i++,j+=4) if(d[j]>10){ bits[i]=1; opacos++; }
|
||||
|
||||
// peças: agrupa pela distância real entre pixels
|
||||
const gp=grupos(inchar(bits,cw,ch,R_PECA), cw, ch);
|
||||
const caixas=[];
|
||||
for(let p=0;p<bits.length;p++){
|
||||
if(!bits[p]) continue;
|
||||
const id=gp.marca[p]; if(id<0) continue;
|
||||
const x=p%cw, y=(p/cw)|0;
|
||||
const c=caixas[id] || (caixas[id]={x0:x,x1:x,y0:y,y1:y,area:0,id});
|
||||
if(x<c.x0)c.x0=x; if(x>c.x1)c.x1=x; if(y<c.y0)c.y0=y; if(y>c.y1)c.y1=y;
|
||||
c.area++;
|
||||
}
|
||||
const pecasTodas=caixas.filter(Boolean);
|
||||
const limRes=Math.max(1, Math.round(0.2/passo)); // 2 mm na grade em uso
|
||||
const residuos=pecasTodas.filter(o=>(o.x1-o.x0)<limRes && (o.y1-o.y0)<limRes);
|
||||
const artes=pecasTodas.filter(o=>!((o.x1-o.x0)<limRes && (o.y1-o.y0)<limRes));
|
||||
|
||||
// encostadas: peças diferentes que se unem quando a folga chega a 6 mm
|
||||
const gper=grupos(inchar(bits,cw,ch,R_PERTO), cw, ch);
|
||||
const porPerto=new Map();
|
||||
for(let p=0;p<bits.length;p++){
|
||||
if(!bits[p]) continue;
|
||||
const a=gp.marca[p], b=gper.marca[p];
|
||||
if(a<0||b<0) continue;
|
||||
if(!porPerto.has(b)) porPerto.set(b,new Set());
|
||||
porPerto.get(b).add(a);
|
||||
}
|
||||
let encostadas=0;
|
||||
porPerto.forEach(set=>{ if(set.size>1) encostadas+=set.size; });
|
||||
|
||||
return {
|
||||
dpi: Math.round(img.width/(larguraCm/2.54)),
|
||||
artes: artes.length,
|
||||
residuos: residuos.length,
|
||||
encostadas,
|
||||
fundoChapado: opacos/(cw*ch) > 0.985,
|
||||
aproveitamento: Math.max(1,Math.min(100,Math.round(opacos/(cw*ch)*100))),
|
||||
alturaCm: ch*passo,
|
||||
grade: +(passo*10).toFixed(1) // mm por célula analisada
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
/* Site DTF — Accepting files: the drop zone, the accept rules, first measurement.
|
||||
Extracted verbatim from the single inline script in dtf-site.html.
|
||||
Loaded as classic scripts in the order listed there: they share one global
|
||||
scope and run top to bottom, exactly as the original did. */
|
||||
// ── upload
|
||||
// Untrusted filenames and recovered cart text must never become HTML.
|
||||
function escapeHTML(value){
|
||||
return String(value).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
function recusa(lista){
|
||||
const el=$('recusa'); if(!el) return;
|
||||
if(!lista.length){ el.style.display='none'; el.innerHTML=''; return; }
|
||||
const exts=escapeHTML([...new Set(lista.map(f=>extDe(f.name)))].join(', '));
|
||||
el.style.display='block';
|
||||
el.innerHTML = ehFolha()
|
||||
? '<b>'+exts+' não dá para enviar aqui.</b> A folha pronta aceita PNG, JPG, TIFF, '+
|
||||
'PDF, PSD, AI e CDR.'
|
||||
: '<b>'+exts+' não dá para enviar aqui.</b> Artes avulsas precisam de PNG ou JPG com '+
|
||||
'fundo transparente — é o que a montagem enxerga para encaixar. '+
|
||||
'Se o seu arquivo é CDR, AI ou PSD, exporte em PNG ou use <b>Arquivo por metro</b>.';
|
||||
}
|
||||
const Z=$('zona'), I=$('inp');
|
||||
Z.addEventListener('click',()=>I.click());
|
||||
Z.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){I.click();e.preventDefault();}});
|
||||
['dragover','dragenter'].forEach(ev=>Z.addEventListener(ev,e=>{e.preventDefault();Z.classList.add('sobre')}));
|
||||
['dragleave','drop'].forEach(ev=>Z.addEventListener(ev,e=>{
|
||||
e.preventDefault(); Z.classList.remove('sobre');
|
||||
if(ev==='drop') sel([...e.dataTransfer.files]);
|
||||
}));
|
||||
I.addEventListener('change',()=>sel([...I.files]));
|
||||
function sel(fs){
|
||||
if(!fs.length) return;
|
||||
// Pixels cannot tell a loose artwork from a finished sheet, so the customer
|
||||
// declares it by choosing the product in the selector above. The declaration is
|
||||
// verified in medirFolha; the file never changes product or price on its own.
|
||||
const regra = ehFolha() ? ACEITA.folha : ACEITA.avulsa;
|
||||
const fora = fs.filter(f=>!regra.test(f.name));
|
||||
fs = fs.filter(f=>regra.test(f.name));
|
||||
recusa(fora);
|
||||
if(!fs.length) return;
|
||||
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}));
|
||||
folhas=folhas.concat(novas); // soma, não substitui
|
||||
pintaFolha();
|
||||
novas.forEach(x=>{
|
||||
x.pct=5; pintaFolha();
|
||||
medirFolha(x.f).then(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();
|
||||
if(md && RENDERIZA.test(x.f.name)){
|
||||
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'; }
|
||||
catch(e){} }
|
||||
x.pct=null; pintaFolha();
|
||||
});
|
||||
}else if(md && /\.pdf$/i.test(x.f.name)){
|
||||
x.pct=70; pintaFolha();
|
||||
rasterizarPdf(x.f, md.larg, md.alt).then(r=>{
|
||||
if(r && r.erro){
|
||||
x.semAnalise = r.erro + (r.semWorker
|
||||
? ' · este navegador não deixa o leitor rodar em segundo plano'
|
||||
: '');
|
||||
}
|
||||
if(r && r.tela){
|
||||
try{
|
||||
x.an=analisarFolha(r.tela, md.larg);
|
||||
// vetor puro não tem resolução · nota máxima, e está certo
|
||||
x.an.dpi = r.dpi==null ? DPI_AVISA : r.dpi;
|
||||
x.an.vetor = r.dpi==null;
|
||||
x.an.fonteDpi = r.dpi==null ? 'vetor' : 'imagens';
|
||||
x.an.res = r.res || null;
|
||||
}catch(e){}
|
||||
}
|
||||
x.pct=null; pintaFolha();
|
||||
});
|
||||
}else{ x.pct=null; pintaFolha(); }
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
const novos=fs.map(f=>({f,px:px(f),cm:0,q:1,prop:1,giro:0,esp:false}));
|
||||
artes=novos.concat(artes); // a mais recente fica no topo da fila
|
||||
recemChegada=novos[0];
|
||||
pintaArtes();
|
||||
$('lista').scrollIntoView({behavior:'smooth',block:'nearest'});
|
||||
Promise.all(novos.map(medir)).then(pintaArtes);
|
||||
}
|
||||
function medir(a){
|
||||
return carregarImagem(a.f).then(img=>{
|
||||
if(img){ a.prop=img.height/img.width; a.px=img.width; a.src=img.src; } else a.prop=a.prop||1;
|
||||
return a;
|
||||
});
|
||||
}
|
||||
// A metragem sai do arquivo, nunca do peso em bytes.
|
||||
// imagem → proporção da imagem aplicada à largura do filme
|
||||
// PDF → MediaBox da primeira página, em pontos (1 pt = 1/72 pol)
|
||||
// TIFF, PSD, AI, CDR → o navegador não abre; o cliente informa o comprimento
|
||||
@@ -1,30 +0,0 @@
|
||||
/* Shared direct multipart transport for customer originals/corrections and operator finals. */
|
||||
window.dtfUpload = async (file, {api, prefix='/uploads', startPath=prefix, progress=()=>{}, scope='guest', resume=true}) => {
|
||||
const samples=new Blob([file.slice(0,65536),file.slice(Math.max(0,file.size-65536))]);
|
||||
const hash=Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',await samples.arrayBuffer())),b=>b.toString(16).padStart(2,'0')).join('');
|
||||
const key='dtf-upload:'+JSON.stringify([scope,file.name,file.size,file.lastModified,hash]);
|
||||
let id=resume?localStorage.getItem(key):null,state;
|
||||
if(id){try{state=await api(prefix+'/'+id);}catch(error){if(![404,410].includes(error.status))throw error;id=null;}}
|
||||
if(!id){state=await api(startPath,{name:file.name,size:file.size});id=state.id;if(resume)localStorage.setItem(key,id);}
|
||||
async function waitForScan(){
|
||||
for(let attempt=0;attempt<150;attempt++){
|
||||
const checked=await api(prefix+'/'+id);
|
||||
if(checked.scan_state==='clean')return id;
|
||||
if(['rejected','error'].includes(checked.scan_state))throw new Error(checked.scan_reason||'Arquivo bloqueado pela verificação de segurança.');
|
||||
progress('Verificando segurança de '+file.name+'…');
|
||||
await new Promise(resolve=>setTimeout(resolve,1000));
|
||||
}
|
||||
throw new Error('Verificação de segurança pendente. Tente novamente para consultar o resultado.');
|
||||
}
|
||||
if(state.complete)return waitForScan();
|
||||
const done=new Set(state.parts||[]),size=state.part_bytes;
|
||||
for(let offset=0,part=1;offset<file.size;offset+=size,part++){
|
||||
if(done.has(part))continue;
|
||||
progress('Enviando '+file.name+' · parte '+part+'/'+Math.ceil(file.size/size));
|
||||
const signed=await api(prefix+'/'+id+'/parts/'+part,{});
|
||||
const response=await fetch(signed.url,{method:'PUT',body:file.slice(offset,offset+size)});
|
||||
if(!response.ok)throw new Error('Upload interrompido. Tente novamente para retomar.');
|
||||
}
|
||||
await api(prefix+'/'+id+'/complete',{});
|
||||
return waitForScan();
|
||||
};
|
||||
33
local/static/vendor/README.md
vendored
33
local/static/vendor/README.md
vendored
@@ -1,33 +0,0 @@
|
||||
# Vendored third-party assets
|
||||
|
||||
Served from this repository rather than a CDN, so the Site does not depend on a
|
||||
third party being reachable and honest at the moment a customer opens it, and so
|
||||
the Content-Security-Policy can name only `'self'` for scripts and workers.
|
||||
|
||||
## pdf.js 3.11.174
|
||||
|
||||
Used by the by-metre flow to measure and rasterise a PDF sheet in the browser.
|
||||
|
||||
| File | SHA-256 |
|
||||
|---|---|
|
||||
| `pdf.min.js` | `5b5799e6f8c680663207ac5b42ee14eed2a406fa7af48f50c154f0c0b1566946` |
|
||||
| `pdf.worker.min.js` | `feabdf309770ed24bba31a5467836cdc8cf639c705af27d52b585b041bb8527b` |
|
||||
|
||||
Downloaded from `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/` and
|
||||
verified against the SRI digests cdnjs publishes for that release:
|
||||
|
||||
```
|
||||
pdf.min.js sha512-q+4liFwdPC/bNdhUpZx6aXDx/h77yEQtn4I1slHydcbZK34nLaR3cAeYSJshoxIOq3mjEf7xJE8YWIUHMn+oCQ==
|
||||
pdf.worker.min.js sha512-BbrZ76UNZq5BhH7LL7pn9A4TKQpQeNCHOo65/akfelcIBbcVvYWOFQKPXIrykE3qZxYjmDX573oa4Ywsc7rpTw==
|
||||
```
|
||||
|
||||
To verify or refresh, compare against that API before replacing anything:
|
||||
|
||||
```bash
|
||||
curl -s "https://api.cdnjs.com/libraries/pdf.js/<version>?fields=sri"
|
||||
```
|
||||
|
||||
**Version note.** 3.11.174 is old. It is affected by GHSA-wgrm-67xf-hhpq, whose
|
||||
documented workaround is `isEvalSupported: false`; `dtf-site.html` already passes
|
||||
that, so the known path is closed. Upgrading is worthwhile but is an API change,
|
||||
not a file swap, and belongs with its own browser testing — see `ROADMAP.md` 2.7.
|
||||
22
local/static/vendor/pdf.min.js
vendored
22
local/static/vendor/pdf.min.js
vendored
File diff suppressed because one or more lines are too long
22
local/static/vendor/pdf.worker.min.js
vendored
22
local/static/vendor/pdf.worker.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -9,7 +9,7 @@ class PricingTests(unittest.TestCase):
|
||||
def test_every_grade_against_actual_site_javascript(self):
|
||||
# The ladders live with the rest of the Site's behaviour; this test exists
|
||||
# to prove the server agrees with whatever the customer is shown.
|
||||
source = Path('local/static/site-config.js').read_text()
|
||||
source = Path('web/site-config.js').read_text()
|
||||
tiers = source.split('const FAIXAS=')[1].split('\n};',1)[0]+'\n}'
|
||||
calculator = source.split('const cobrar=')[1].split(';',1)[0]
|
||||
js = f'const FAIXAS={tiers};const MINIMO_M=1;const cobrar={calculator};'
|
||||
|
||||
Reference in New Issue
Block a user