fix: harden week-two ordering, artwork and operations

This commit is contained in:
Cauê Faleiros
2026-09-23 10:40:18 -03:00
parent ccc25a2d5d
commit 24013458c9
43 changed files with 1064 additions and 228 deletions

View File

@@ -1,7 +1,7 @@
# Sistema DTF 24h — Altus Group # Sistema DTF 24h — Altus Group
> **Active local milestone (2026-09-11):** Read [docs/CONTEXT.md](docs/CONTEXT.md) first. > **Active local milestone (2026-09-23):** Read [docs/CONTEXT.md](docs/CONTEXT.md) first.
> Start with `docker compose up --build`, then open the [Site](http://localhost:8080) > Start with `docker compose -f compose.local.yaml up --build`, then open the [Site](http://localhost:8080)
> and [Kanban](http://localhost:8081). Optional configuration: copy `.env.example` > and [Kanban](http://localhost:8081). Optional configuration: copy `.env.example`
> to `.env`. Follow [docs/LOCAL_SETUP.md](docs/LOCAL_SETUP.md) for the complete test flow, > to `.env`. Follow [docs/LOCAL_SETUP.md](docs/LOCAL_SETUP.md) for the complete test flow,
> local login, health checks, and troubleshooting. See > local login, health checks, and troubleshooting. See

View File

@@ -12,7 +12,7 @@ from ..artwork import submit_files
from ..core.auth import operator from ..core.auth import operator
from ..core.models import ArtworkSubmission, UploadStart from ..core.models import ArtworkSubmission, UploadStart
from ..runtime import file_rows, operator_identity from ..runtime import file_rows, operator_identity
from .uploads import begin_upload, complete_upload, part_url, upload_status from .uploads import begin_upload, cancel_upload, complete_upload, part_url, upload_status
router = APIRouter() router = APIRouter()
@@ -36,6 +36,10 @@ def final_part(uid: UUID, number: int, user=Depends(operator)):
def final_complete(uid: UUID, user=Depends(operator)): def final_complete(uid: UUID, user=Depends(operator)):
return complete_upload(uid,session_id=operator_identity(user)) return complete_upload(uid,session_id=operator_identity(user))
@router.delete('/api/operator/uploads/{uid}')
def final_cancel(uid: UUID, user=Depends(operator)):
return cancel_upload(uid,session_id=operator_identity(user))
@router.get('/api/operator/orders/{oid}/files') @router.get('/api/operator/orders/{oid}/files')
def operator_files(oid: UUID, user=Depends(operator)): def operator_files(oid: UUID, user=Depends(operator)):
with db.connect() as c: with db.connect() as c:

View File

@@ -1,10 +1,9 @@
"""Health, session bootstrap and freight quoting.""" """Health, session bootstrap and freight quoting."""
import os
from fastapi import APIRouter, HTTPException, Request, Response from fastapi import APIRouter, HTTPException, Request, Response
from ..core import db from ..core import db
from ..core.auth import client_ip, owner, new_session, rate_limit from ..core.auth import client_ip, owner, new_session, rate_limit
from ..core.limits import upload_limit_bytes
from ..core.models import Freight from ..core.models import Freight
from ..runtime import ENVIRONMENT, GUEST_SESSION_LIMIT, PART_BYTES, freight, storage from ..runtime import ENVIRONMENT, GUEST_SESSION_LIMIT, PART_BYTES, freight, storage
@@ -33,7 +32,7 @@ def session(request: Request, response: Response):
with db.connect() as c: with db.connect() as c:
session_id = new_session(c, response) session_id = new_session(c, response)
return {'environment': ENVIRONMENT, 'cart_scope': str(session_id), 'part_bytes': PART_BYTES, return {'environment': ENVIRONMENT, 'cart_scope': str(session_id), 'part_bytes': PART_BYTES,
'max_upload_bytes': int(os.environ.get('MAX_UPLOAD_BYTES', '5368709120'))} 'max_upload_bytes': upload_limit_bytes()}
@router.post('/api/freight') @router.post('/api/freight')
def quote_freight(body: Freight): def quote_freight(body: Freight):

View File

@@ -3,9 +3,10 @@ import hashlib
import os import os
import secrets import secrets
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Literal
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from psycopg.types.json import Jsonb from psycopg.types.json import Jsonb
from ..core import db from ..core import db
@@ -65,15 +66,44 @@ def board(user=Depends(operator)):
finished = c.execute("SELECT * FROM dtf_local.orders WHERE state='fin' ORDER BY created_at DESC LIMIT %s", finished = c.execute("SELECT * FROM dtf_local.orders WHERE state='fin' ORDER BY created_at DESC LIMIT %s",
(BOARD_FINISHED_LIMIT,)).fetchall() (BOARD_FINISHED_LIMIT,)).fetchall()
finished_total = c.execute("SELECT count(*) AS n FROM dtf_local.orders WHERE state='fin'").fetchone()['n'] 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 pending = 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 LEFT JOIN dtf_local.orders o ON o.quote_id=q.id
ORDER BY q.created_at LIMIT %s''', (BOARD_QUOTE_LIMIT,)).fetchall() WHERE o.id IS NULL AND q.approved IS NULL
ORDER BY q.created_at DESC,q.id DESC LIMIT %s''', (BOARD_QUOTE_LIMIT,)).fetchall()
approved = 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 AND q.approved IS NOT NULL
ORDER BY q.created_at DESC,q.id DESC LIMIT 20''').fetchall()
pending_total = c.execute('''SELECT count(*) AS n FROM dtf_local.quotes q
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id
WHERE o.id IS NULL AND q.approved IS NULL''').fetchone()['n']
approved_total = c.execute('''SELECT count(*) AS n FROM dtf_local.quotes q
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id
WHERE o.id IS NULL AND q.approved IS NOT NULL''').fetchone()['n']
return {'states': STATES, 'transitions': TRANSITIONS, return {'states': STATES, 'transitions': TRANSITIONS,
'orders': active + list(reversed(finished)), 'orders': active + list(reversed(finished)),
'finished_shown': len(finished), 'finished_total': finished_total, 'finished_shown': len(finished), 'finished_total': finished_total,
'quotes': [quote_view(c, q) for q in quotes], 'quotes': [quote_view(c, q) for q in pending + approved],
'pending_total': pending_total, 'approved_total': approved_total,
'events': c.execute('SELECT * FROM dtf_local.outbox ORDER BY id DESC LIMIT 100').fetchall()} 'events': c.execute('SELECT * FROM dtf_local.outbox ORDER BY id DESC LIMIT 100').fetchall()}
@router.get('/api/operator/quotes')
def quote_page(kind: Literal['pending','approved'], before_created_at: datetime | None = None,
before_id: UUID | None = None, limit: int = Query(default=50, ge=1, le=100),
user=Depends(operator)):
if (before_created_at is None) != (before_id is None):
raise HTTPException(422, 'Both quote cursor fields are required')
approved_filter = 'q.approved IS NULL' if kind == 'pending' else 'q.approved IS NOT NULL'
cursor = 'AND (q.created_at,q.id)<(%s,%s)' if before_created_at else ''
params = ((before_created_at,before_id) if before_created_at else ()) + (limit+1,)
with db.connect() as c:
rows = c.execute(f'''SELECT q.* FROM dtf_local.quotes q
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id
WHERE o.id IS NULL AND {approved_filter} {cursor}
ORDER BY q.created_at DESC,q.id DESC LIMIT %s''', params).fetchall()
return {'quotes':[quote_view(c,row) for row in rows[:limit]],
'has_more':len(rows)>limit}
@router.post('/api/operator/quotes/{uid}/approve') @router.post('/api/operator/quotes/{uid}/approve')
def approve(uid: UUID, body: Review, user=Depends(operator)): def approve(uid: UUID, body: Review, user=Depends(operator)):
with db.connect() as c: with db.connect() as c:
@@ -83,15 +113,22 @@ def approve(uid: UUID, body: Review, user=Depends(operator)):
if row['approved']: if row['approved']:
raise HTTPException(409, 'Approved quotes are immutable; request a new quote') raise HTTPException(409, 'Approved quotes are immutable; request a new quote')
draft = row['draft'] draft = row['draft']
if any(item.get('production', {}).get('version') != 2 for item in draft['items']):
raise HTTPException(409, 'Quote uses an obsolete production layout; customer must request a new quote')
if len(body.items) != len(draft['items']): if len(body.items) != len(draft['items']):
raise HTTPException(422, 'Review must cover every item') raise HTTPException(422, 'Review must cover every item')
items = [] items = []
for item, original in zip(body.items, draft['items']): for item, original in zip(body.items, draft['items']):
if item.mode != original['mode'] or list(map(str,item.uploads)) != original['uploads']: 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') raise HTTPException(422, 'Product mode and attached files cannot change during review')
if item.production.model_dump(mode='json') != original['production'] or item.quality_status != original['quality_status'] or item.quality_acknowledged != original['quality_acknowledged']:
raise HTTPException(422, 'Production instructions and customer acknowledgement cannot change during commercial review')
for upload_id in item.uploads: for upload_id in item.uploads:
require_clean(upload_row(c, upload_id, row['owner'])) require_clean(upload_row(c, upload_id, row['owner']))
items.append({**price(item.mode, str(item.metres), item.grade), 'uploads': original['uploads']}) items.append({**price(item.mode, str(item.metres), item.grade),
'uploads': original['uploads'], 'production': original['production'],
'quality_status': original['quality_status'],
'quality_acknowledged': original['quality_acknowledged']})
quoted_freight = freight.quote(**draft['freight']) quoted_freight = freight.quote(**draft['freight'])
approved = {'customer': draft['customer'], 'items': items, 'freight': quoted_freight, approved = {'customer': draft['customer'], 'items': items, 'freight': quoted_freight,
'total_cents': sum(i['total_cents'] for i in items)+quoted_freight['total_cents']} 'total_cents': sum(i['total_cents'] for i in items)+quoted_freight['total_cents']}

View File

@@ -26,8 +26,8 @@ def dev_paid(body: Pay, session_id=Depends(owner)):
quote = payments.approved_quote(c, body.quote_id, session_id) quote = payments.approved_quote(c, body.quote_id, session_id)
except payments.PaymentRefused as refusal: except payments.PaymentRefused as refusal:
# The quote may already be paid; that is not a refusal. # The quote may already be paid; that is not a refusal.
existing = c.execute('SELECT * FROM dtf_local.orders WHERE quote_id=%s', existing = c.execute('SELECT * FROM dtf_local.orders WHERE quote_id=%s AND owner=%s',
(body.quote_id,)).fetchone() (body.quote_id, session_id)).fetchone()
if existing: if existing:
return existing return existing
raise HTTPException(404 if 'not found' in str(refusal) else 409, str(refusal)) raise HTTPException(404 if 'not found' in str(refusal) else 409, str(refusal))

View File

@@ -1,6 +1,7 @@
"""Quotes: the customer's cart, and the operator-reviewed version of it.""" """Quotes: the customer's cart, and the operator-reviewed version of it."""
import hashlib import hashlib
import json import json
from decimal import Decimal
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
@@ -16,6 +17,9 @@ router = APIRouter()
@router.post('/api/quotes') @router.post('/api/quotes')
def create_quote(body: QuoteRequest, session_id=Depends(owner)): def create_quote(body: QuoteRequest, session_id=Depends(owner)):
for item in body.items:
if abs(item.metres*100-item.production.height_cm) > Decimal('0.02'):
raise HTTPException(422, 'Quoted metres do not match the submitted layout height')
draft = body.model_dump(mode='json', exclude={'request_key'}) draft = body.model_dump(mode='json', exclude={'request_key'})
digest = hashlib.sha256(json.dumps(draft, sort_keys=True).encode()).hexdigest() digest = hashlib.sha256(json.dumps(draft, sort_keys=True).encode()).hexdigest()
try: try:

View File

@@ -12,6 +12,7 @@ from fastapi import APIRouter, Depends, HTTPException
from ..core import db from ..core import db
from ..core.auth import audit, owner, rate_limit from ..core.auth import audit, owner, rate_limit
from ..core.limits import upload_limit_bytes
from ..core.models import UploadStart from ..core.models import UploadStart
from ..runtime import PART_BYTES, storage, upload_row from ..runtime import PART_BYTES, storage, upload_row
@@ -19,8 +20,8 @@ router = APIRouter()
@router.post('/api/uploads') @router.post('/api/uploads')
def begin_upload(body: UploadStart, session_id=Depends(owner)): def begin_upload(body: UploadStart, session_id=Depends(owner)):
if body.size > int(os.environ.get('MAX_UPLOAD_BYTES', '5368709120')): if body.size > upload_limit_bytes():
raise HTTPException(413, 'File exceeds the upload limit') raise HTTPException(413, 'File exceeds the malware scan limit; select a smaller file')
uid = uuid4() uid = uuid4()
key = f'originals/{uid}' key = f'originals/{uid}'
rate_limit('upload-start', str(session_id), 60, 900) rate_limit('upload-start', str(session_id), 60, 900)
@@ -30,14 +31,16 @@ def begin_upload(body: UploadStart, session_id=Depends(owner)):
usage = c.execute('''SELECT COALESCE(sum(size),0) AS total, usage = c.execute('''SELECT COALESCE(sum(size),0) AS total,
COALESCE(sum(size) FILTER(WHERE owner=%s),0) AS owned, COALESCE(sum(size) FILTER(WHERE owner=%s),0) AS owned,
count(*) FILTER(WHERE owner=%s AND NOT complete) AS pending count(*) FILTER(WHERE owner=%s AND NOT complete) AS pending
FROM dtf_local.uploads WHERE purged_at IS NULL''', (session_id,session_id)).fetchone() 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 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['owned']+body.size > int(os.environ.get('OWNER_UPLOAD_QUOTA_BYTES','10737418240')) or
usage['pending'] >= int(os.environ.get('MAX_PENDING_UPLOADS','10'))): usage['pending'] >= int(os.environ.get('MAX_PENDING_UPLOADS','10'))):
audit('upload_quota_rejected') audit('upload_quota_rejected')
raise HTTPException(429, 'Local storage quota or pending upload limit reached') raise HTTPException(429, 'Local storage quota or pending upload limit reached')
multipart = storage.begin(key) 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)', c.execute('''INSERT INTO dtf_local.uploads(id,owner,name,size,object_key,multipart_id,expires_at)
VALUES(%s,%s,%s,%s,%s,%s,now()+interval '1 hour')''',
(uid, session_id, body.name, body.size, key, multipart)) (uid, session_id, body.name, body.size, key, multipart))
return {'id': uid, 'part_bytes': PART_BYTES} return {'id': uid, 'part_bytes': PART_BYTES}
@@ -81,5 +84,16 @@ def complete_upload(uid: UUID, session_id=Depends(owner)):
existing_size = storage.size(row['object_key']) existing_size = storage.size(row['object_key'])
if existing_size != row['size']: if existing_size != row['size']:
raise HTTPException(409, 'Stored size differs from declared size') raise HTTPException(409, 'Stored size differs from declared size')
c.execute('UPDATE dtf_local.uploads SET complete=true WHERE id=%s', (uid,)) c.execute("UPDATE dtf_local.uploads SET complete=true,expires_at=now()+interval '30 days' WHERE id=%s", (uid,))
return {'id': uid, 'complete': True} return {'id': uid, 'complete': True}
@router.delete('/api/uploads/{uid}')
def cancel_upload(uid: UUID, session_id=Depends(owner)):
with db.connect() as c:
row=upload_row(c,uid,session_id,lock=True)
if row['complete']:
raise HTTPException(409, 'Completed upload cannot be cancelled')
storage.discard(row['object_key'],row['multipart_id'],False)
c.execute('UPDATE dtf_local.uploads SET purged_at=now() WHERE id=%s',(uid,))
audit('upload_cancelled', upload=str(uid))
return {'id':uid,'cancelled':True}

View File

@@ -38,6 +38,11 @@ def submit_files(c, order, body, identity, kind, actor):
require_clean(upload) require_clean(upload)
if c.execute('SELECT id FROM dtf_local.order_files WHERE upload_id=%s', (ref.upload_id,)).fetchone(): 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.') raise HTTPException(409, 'File is already attached. Upload a new revision.')
# A new customer correction supersedes every final prepared from earlier
# artwork, including finals uploaded while this order was in correction.
if kind == 'correction':
c.execute("UPDATE dtf_local.order_files SET active=false WHERE order_id=%s AND kind IN ('correction','final')", (order['id'],))
else:
c.execute('UPDATE dtf_local.order_files SET active=false WHERE order_id=%s AND kind=%s', (order['id'],kind)) 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: for ref in body.files:
c.execute('INSERT INTO dtf_local.order_files(id,order_id,upload_id,item_index,kind,note,created_by) VALUES(%s,%s,%s,%s,%s,%s,%s)', c.execute('INSERT INTO dtf_local.order_files(id,order_id,upload_id,item_index,kind,note,created_by) VALUES(%s,%s,%s,%s,%s,%s,%s)',

13
app/core/limits.py Normal file
View File

@@ -0,0 +1,13 @@
"""Limits shared by upload admission and the malware scanner."""
import os
CLAMAV_STREAM_MAX_BYTES = 128 * 1024 * 1024 # infra/clamd.conf
def scan_limit_bytes():
return min(CLAMAV_STREAM_MAX_BYTES, int(os.environ.get('SCAN_MAX_BYTES', '134217728')))
def upload_limit_bytes():
transport = int(os.environ.get('MAX_UPLOAD_BYTES', '5368709120'))
return min(transport, scan_limit_bytes())

View File

@@ -2,7 +2,7 @@ import re
from decimal import Decimal from decimal import Decimal
from typing import Literal from typing import Literal
from uuid import UUID from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
class StrictModel(BaseModel): class StrictModel(BaseModel):
model_config = ConfigDict(extra='forbid', allow_inf_nan=False) model_config = ConfigDict(extra='forbid', allow_inf_nan=False)
@@ -56,11 +56,80 @@ class UploadStart(StrictModel):
raise ValueError('Unsupported artwork file extension') raise ValueError('Unsupported artwork file extension')
return value return value
class ProductionSource(StrictModel):
upload_id: UUID
kind: Literal['sheet', 'artwork']
width_cm: Decimal = Field(gt=0, le=57)
length_cm: Decimal = Field(gt=0, le=6000)
copies: int = Field(ge=1, le=200, strict=True)
rotation_degrees: Literal[0, 90] = 0
mirrored: bool = False
measurement: Literal['file', 'customer']
class ProductionPlacement(StrictModel):
source_index: int = Field(ge=0, le=19, strict=True)
copy_index: int = Field(ge=0, le=199, strict=True)
x_cm: Decimal = Field(ge=0, le=57)
y_cm: Decimal = Field(ge=0, le=1200000)
width_cm: Decimal = Field(gt=0, le=57)
length_cm: Decimal = Field(gt=0, le=6000)
rotation_degrees: Literal[0, 90, 180, 270]
mirrored: bool
class ProductionSpec(StrictModel):
version: Literal[2]
film_width_cm: Decimal
height_cm: Decimal = Field(gt=0, le=1200000)
sources: list[ProductionSource] = Field(min_length=1, max_length=20)
placements: list[ProductionPlacement] = Field(min_length=1, max_length=4000)
class Item(StrictModel): class Item(StrictModel):
mode: Literal['file','avulsa','uvfile','uv'] mode: Literal['file','avulsa','uvfile','uv']
metres: Decimal = Field(gt=0, le=12000) metres: Decimal = Field(gt=0, le=12000)
grade: int = Field(ge=0, le=100, strict=True) grade: int = Field(ge=0, le=100, strict=True)
uploads: list[UUID] = Field(min_length=1, max_length=20) uploads: list[UUID] = Field(min_length=1, max_length=20)
production: ProductionSpec
quality_status: Literal['ok', 'warning', 'unverified']
quality_acknowledged: bool
@model_validator(mode='after')
def production_matches_uploads(self):
if [source.upload_id for source in self.production.sources] != self.uploads:
raise ValueError('Production sources must match uploaded files in order')
is_sheet = self.mode in ('file', 'uvfile')
film_width = Decimal('28.5') if self.mode in ('uvfile', 'uv') else Decimal('57')
if self.production.film_width_cm != film_width:
raise ValueError('Production film width does not match the product')
for source in self.production.sources:
if (source.kind == 'sheet') != is_sheet or source.width_cm > film_width:
raise ValueError('Production source does not fit the selected product')
if is_sheet and (source.rotation_degrees or source.mirrored):
raise ValueError('Finished sheets cannot be rotated or mirrored by the layout')
expected={(index,copy) for index,source in enumerate(self.production.sources)
for copy in range(source.copies)}
placed=set()
tolerance=Decimal('0.02')
for placement in self.production.placements:
key=(placement.source_index,placement.copy_index)
if key not in expected or key in placed:
raise ValueError('Production placement has a missing or duplicate source copy')
placed.add(key)
source=self.production.sources[placement.source_index]
auto_rotation=(placement.rotation_degrees-source.rotation_degrees)%360
if auto_rotation not in (0,90) or placement.mirrored != source.mirrored:
raise ValueError('Production placement changes the source transform')
width,length=(source.width_cm,source.length_cm) if auto_rotation==0 else (source.length_cm,source.width_cm)
if abs(placement.width_cm-width)>tolerance or abs(placement.length_cm-length)>tolerance:
raise ValueError('Production placement changes the source size')
if placement.x_cm+placement.width_cm>film_width+tolerance or placement.y_cm+placement.length_cm>self.production.height_cm+tolerance:
raise ValueError('Production placement is outside the film')
if is_sheet and (placement.x_cm or auto_rotation):
raise ValueError('Finished sheets must retain their original orientation')
if placed != expected:
raise ValueError('Production layout does not cover every source copy')
if self.quality_status == 'warning' and not self.quality_acknowledged:
raise ValueError('Resolution warning must be acknowledged')
return self
class QuoteRequest(StrictModel): class QuoteRequest(StrictModel):
request_key: UUID request_key: UUID

View File

@@ -33,6 +33,8 @@ def approved_quote(c, quote_id, owner=None):
raise PaymentRefused('quote not found') raise PaymentRefused('quote not found')
if not row['approved']: if not row['approved']:
raise PaymentRefused('quote was never reviewed') raise PaymentRefused('quote was never reviewed')
if any(item.get('production', {}).get('version') != 2 for item in row['approved']['items']):
raise PaymentRefused('quote uses an obsolete production layout; request a new quote')
if row['approved_at'] < datetime.now(timezone.utc) - timedelta(hours=QUOTE_VALID_HOURS): if row['approved_at'] < datetime.now(timezone.utc) - timedelta(hours=QUOTE_VALID_HOURS):
raise PaymentRefused('quote expired before payment') raise PaymentRefused('quote expired before payment')
return row return row
@@ -94,7 +96,7 @@ def apply(c, event):
# underpayment would ship artwork that was not paid for, and overpayment # underpayment would ship artwork that was not paid for, and overpayment
# means something is wrong that a person should look at. # means something is wrong that a person should look at.
expected = quote['approved']['total_cents'] expected = quote['approved']['total_cents']
if event.amount_cents is not None and event.amount_cents != expected: if type(event.amount_cents) is not int or event.amount_cents != expected:
audit('payment_amount_mismatch', quote=str(quote_id), audit('payment_amount_mismatch', quote=str(quote_id),
expected_cents=expected, paid_cents=event.amount_cents) expected_cents=expected, paid_cents=event.amount_cents)
return f'refused: paid {event.amount_cents} but quote total is {expected}' return f'refused: paid {event.amount_cents} but quote total is {expected}'

View File

@@ -58,7 +58,8 @@ def upload_row(c, upload_id, session_id, lock=False):
def quote_view(c, row): def quote_view(c, row):
order = c.execute('SELECT id,number,state FROM dtf_local.orders WHERE quote_id=%s', (row['id'],)).fetchone() order = c.execute('SELECT id,number,state FROM dtf_local.orders WHERE quote_id=%s', (row['id'],)).fetchone()
expired = row['approved_at'] and row['approved_at'] < datetime.now(timezone.utc)-timedelta(hours=24) expired = row['approved_at'] and row['approved_at'] < datetime.now(timezone.utc)-timedelta(hours=24)
return {'id': row['id'], 'draft': row['draft'], 'approved': row['approved'], return {'id': row['id'], 'created_at': row['created_at'],
'draft': row['draft'], 'approved': row['approved'],
'status': 'paid' if order else 'expired' if expired else 'approved' if row['approved'] else 'pending_review', 'status': 'paid' if order else 'expired' if expired else 'approved' if row['approved'] else 'pending_review',
'order': order} 'order': order}

View File

@@ -1,11 +1,11 @@
"""Local ClamAV boundary. Unknown/error/over-limit results NEVER release artwork.""" """Local ClamAV boundary. Unknown/error/over-limit results NEVER release artwork."""
import os
import socket import socket
import struct import struct
import time import time
from fastapi import HTTPException from fastapi import HTTPException
from .core.auth import audit from .core.auth import audit
from .core.db import connect from .core.db import connect
from .core.limits import scan_limit_bytes
def require_clean(row): def require_clean(row):
if not row['complete'] or row['scan_state'] != 'clean': if not row['complete'] or row['scan_state'] != 'clean':
@@ -30,7 +30,7 @@ class ClamAV:
return self.command(b'VERSION').decode('utf-8','replace') return self.command(b'VERSION').decode('utf-8','replace')
def scan(self, stream, size): def scan(self, stream, size):
if size > min(134217728, int(os.environ.get('SCAN_MAX_BYTES','134217728'))): if size > scan_limit_bytes():
return 'rejected', 'File exceeds the malware scan limit' return 'rejected', 'File exceeds the malware scan limit'
with socket.create_connection(('scanner',3310),timeout=10) as sock: with socket.create_connection(('scanner',3310),timeout=10) as sock:
sock.settimeout(150) sock.settimeout(150)

View File

@@ -8,7 +8,7 @@ services:
build: build:
context: . context: .
dockerfile: infra/Dockerfile dockerfile: infra/Dockerfile
command: python -m app.staging_readiness /config/staging.env command: python -m ops.staging_readiness /config/staging.env
volumes: volumes:
- ./staging/staging.env:/config/staging.env:ro - ./staging/staging.env:/config/staging.env:ro
network_mode: none network_mode: none

View File

@@ -21,6 +21,7 @@ WORKDIR /app
COPY infra/requirements.txt infra/requirements.lock /app/infra/ COPY infra/requirements.txt infra/requirements.lock /app/infra/
RUN python -m pip install --no-cache-dir --require-hashes -r infra/requirements.lock RUN python -m pip install --no-cache-dir --require-hashes -r infra/requirements.lock
COPY app /app/app COPY app /app/app
COPY ops /app/ops
RUN useradd --uid 10001 --create-home --shell /usr/sbin/nologin dtf RUN useradd --uid 10001 --create-home --shell /usr/sbin/nologin dtf
USER 10001:10001 USER 10001:10001
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PYTHONPATH=/app ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PYTHONPATH=/app

View File

@@ -176,6 +176,8 @@ def config_errors(values):
errors.append('OWNER_UPLOAD_QUOTA_BYTES cannot exceed STORAGE_QUOTA_BYTES') errors.append('OWNER_UPLOAD_QUOTA_BYTES cannot exceed STORAGE_QUOTA_BYTES')
if numeric.get('SCAN_MAX_BYTES', 0) > numeric.get('MAX_UPLOAD_BYTES', 0): if numeric.get('SCAN_MAX_BYTES', 0) > numeric.get('MAX_UPLOAD_BYTES', 0):
errors.append('SCAN_MAX_BYTES cannot exceed MAX_UPLOAD_BYTES') errors.append('SCAN_MAX_BYTES cannot exceed MAX_UPLOAD_BYTES')
if numeric.get('SCAN_MAX_BYTES', 0) > 128 * 1024 * 1024:
errors.append('SCAN_MAX_BYTES cannot exceed the configured ClamAV 128 MiB stream limit')
ports = {} ports = {}
for name in ('SITE_PORT', 'KANBAN_PORT'): for name in ('SITE_PORT', 'KANBAN_PORT'):
try: try:

View File

@@ -112,6 +112,7 @@ class ProductionPreflightTests(unittest.TestCase):
self.assertTrue(any('distinct external Swarm secret' in error for error in errors)) self.assertTrue(any('distinct external Swarm secret' in error for error in errors))
self.assertIn('OWNER_UPLOAD_QUOTA_BYTES cannot exceed STORAGE_QUOTA_BYTES', errors) self.assertIn('OWNER_UPLOAD_QUOTA_BYTES cannot exceed STORAGE_QUOTA_BYTES', errors)
self.assertIn('SCAN_MAX_BYTES cannot exceed MAX_UPLOAD_BYTES', errors) self.assertIn('SCAN_MAX_BYTES cannot exceed MAX_UPLOAD_BYTES', errors)
self.assertTrue(any('configured ClamAV 128 MiB stream limit' in error for error in errors))
if __name__ == '__main__': if __name__ == '__main__':

View File

@@ -297,11 +297,12 @@ as references and are not imported or started by Compose.
files can be quoted, commercially approved, paid, downloaded, attached as final files can be quoted, commercially approved, paid, downloaded, attached as final
files, or admitted to the print queue. Rejected/error files remain blocked and files, or admitted to the print queue. Rejected/error files remain blocked and
expire within three days. The isolated scanner uses signatures bundled in its expire within three days. The isolated scanner uses signatures bundled in its
pinned image and has no external network route. The transport accepts files up pinned image and has no external network route. The multipart transport could
to 5 GiB, but the local scan/release limit is 128 MiB; larger files remain carry 5 GiB, but API and customer admission stop at the effective 128 MiB
blocked. This malware gate is not print pre-flight or artwork validation. scan/release limit by default. Larger files require a new scan/release design.
This malware gate is not print pre-flight or artwork validation.
- A retention worker removes expired object bytes but keeps order/file metadata: - A retention worker removes expired object bytes but keeps order/file metadata:
incomplete uploads after one day, originals within seven days of manual final incomplete uploads after a one-hour reservation lease, originals within seven days of manual final
artwork approval, and attached final/correction files within 30 days of the artwork approval, and attached final/correction files within 30 days of the
order's first upload. Storage lifecycle is also a 30-day backstop. order's first upload. Storage lifecycle is also a 30-day backstop.
- Structured security events are written to logs and PostgreSQL. The local - Structured security events are written to logs and PostgreSQL. The local
@@ -327,7 +328,7 @@ as references and are not imported or started by Compose.
not been deployed. Its fail-closed preflight intentionally rejects the current not been deployed. Its fail-closed preflight intentionally rejects the current
source until production adapters, Docker-secret file loading, approved inputs, source until production adapters, Docker-secret file loading, approved inputs,
restore rehearsal, image scans, and human security approval are complete. restore rehearsal, image scans, and human security approval are complete.
- `python3 -m app.backup create-and-verify` creates a private, Git-ignored bundle - `python3 -m ops.backup create-and-verify` creates a private, Git-ignored bundle
containing a PostgreSQL dump plus every complete, unexpired object already marked containing a PostgreSQL dump plus every complete, unexpired object already marked
`clean`. SHA-256 manifests protect both parts. Verification restores the database `clean`. SHA-256 manifests protect both parts. Verification restores the database
under a UUID name and the object bytes under a UUID MinIO prefix, hashes the under a UUID name and the object bytes under a UUID MinIO prefix, hashes the

View File

@@ -161,8 +161,8 @@ test is unmistakable:
```bash ```bash
python3 -m tests.security_test python3 -m tests.security_test
python3 -m tests.scanning_test python3 -m tests.scanning_test
docker compose exec -T api python3 -m tests.runtime_security_test docker compose -f compose.local.yaml exec -T api python3 -m tests.runtime_security_test
docker compose exec -T api python3 -m tests.retention_test docker compose -f compose.local.yaml exec -T api python3 -m tests.retention_test
``` ```
`local.scanning_test` stores an EICAR fixture as `SECURITY-EICAR.cdr`; ClamAV `local.scanning_test` stores an EICAR fixture as `SECURITY-EICAR.cdr`; ClamAV
@@ -183,14 +183,15 @@ test order for inspection. Both integration scripts read `.env` automatically.
## Configuration and storage ## Configuration and storage
`.env.example` lists local ports, database/MinIO values, operator login, adapter `.env.example` lists local ports, database/MinIO values, operator login, adapter
selection, mock freight amount, maximum file size (5 GiB), and multipart size selection, mock freight amount, transport ceiling (5 GiB), and multipart size
(8 MiB by default). The malware scanner releases only files up to 128 MiB by (8 MiB by default). The API and customer picker admit only files within the
default (`SCAN_MAX_BYTES`); larger uploads remain blocked even though the malware scanner's effective 128 MiB limit by default (`SCAN_MAX_BYTES`); the
multipart transport supports 5 GiB. `S3_ENDPOINT=http://storage:9000`, database larger-file path remains a Week 2 decision. `S3_ENDPOINT=http://storage:9000`, database
hostname `db`, hostname `db`,
and the internal service ports are fixed Compose wiring. The public S3 endpoint and the internal service ports are fixed Compose wiring. The public S3 endpoint
must resolve from the browser; keep `http://localhost:9000` for this stack. must resolve from the browser; keep `http://localhost:9000` for this stack.
Parts use 15-minute presigned URLs and uploads must finish within one day. Parts use 15-minute presigned URLs and unfinished reservations expire after
one hour. Clients can cancel a reservation through the upload DELETE endpoint.
Only fake integration adapters and `s3-local` storage are accepted. Startup fails Only fake integration adapters and `s3-local` storage are accepted. Startup fails
if a production adapter/environment or nonlocal S3 endpoint is selected. if a production adapter/environment or nonlocal S3 endpoint is selected.
@@ -199,7 +200,8 @@ and MinIO also join a network that permits loopback port publishing.
Objects are private, use UUID keys rather than filenames, and persist in a named Objects are private, use UUID keys rather than filenames, and persist in a named
volume. MinIO lifecycle rules expire objects after 30 days and abandon incomplete volume. MinIO lifecycle rules expire objects after 30 days and abandon incomplete
multipart uploads after one day; the API also blocks expired downloads. Order multipart uploads after one day as a backstop; the API lease and worker release
unfinished reservations after one hour. The API also blocks expired downloads. Order
history remains in PostgreSQL. Completed files start in `pending`; unknown, history remains in PostgreSQL. Completed files start in `pending`; unknown,
scanner-error, over-limit, encrypted/unsafe, and malware results fail closed. scanner-error, over-limit, encrypted/unsafe, and malware results fail closed.
Only `clean` files can cross quote, payment, download, final-approval, and queue Only `clean` files can cross quote, payment, download, final-approval, and queue
@@ -217,7 +219,7 @@ again. The operator can still inspect order records.
## Local backup and restore check ## Local backup and restore check
```bash ```bash
python3 -m app.backup create-and-verify python3 -m ops.backup create-and-verify
``` ```
This writes a private four-file bundle in `backups/` (ignored by Git and Docker This writes a private four-file bundle in `backups/` (ignored by Git and Docker
@@ -234,7 +236,7 @@ checks database counts, bundle hashes, every archived object's hash, and the byt
downloaded after restore, then removes only the temporary database and objects. It downloaded after restore, then removes only the temporary database and objects. It
never restores over active data. Keep every bundle file private: it contains never restores over active data. Keep every bundle file private: it contains
customer data, password hashes, and customer artwork. To verify it again, run customer data, password hashes, and customer artwork. To verify it again, run
`python3 -m app.backup verify` followed by the printed `python3 -m ops.backup verify` followed by the printed
`backups/...manifest.json` path. Legacy database-only `.dump` backups remain `backups/...manifest.json` path. Legacy database-only `.dump` backups remain
verifiable. Scheduling, offsite copies, and a production restore runbook remain verifiable. Scheduling, offsite copies, and a production restore runbook remain
unfinished. unfinished.
@@ -242,7 +244,7 @@ unfinished.
After building the current image, test retention with synthetic files: After building the current image, test retention with synthetic files:
```bash ```bash
docker compose exec -T api python3 -m tests.retention_test docker compose -f compose.local.yaml exec -T api python3 -m tests.retention_test
``` ```
This checks that expired bytes are removed while unexpired files survive. It This checks that expired bytes are removed while unexpired files survive. It
@@ -251,14 +253,14 @@ cleans up its own synthetic object bytes and retains their metadata.
## Operations and troubleshooting ## Operations and troubleshooting
```bash ```bash
docker compose logs --tail=100 api worker scanner docker compose -f compose.local.yaml logs --tail=100 api worker scanner
docker compose restart api worker docker compose -f compose.local.yaml restart api worker
docker compose ps docker compose -f compose.local.yaml ps
docker compose down docker compose -f compose.local.yaml down
``` ```
`down` stops the stack and preserves named database/storage volumes. Restart `down` stops the stack and preserves named database/storage volumes. Restart
with `docker compose up -d --wait`. Do not add `--volumes` unless you intend to with `docker compose -f compose.local.yaml up -d --wait`. Do not add `--volumes` unless you intend to
permanently erase all local orders and artwork. No reset is required for tests. permanently erase all local orders and artwork. No reset is required for tests.
Seven long-running services have Docker health checks; the database and storage Seven long-running services have Docker health checks; the database and storage
@@ -270,7 +272,7 @@ exposes `/minio/health/ready`.
Run the redacted local alert summary inside the API network namespace: Run the redacted local alert summary inside the API network namespace:
```bash ```bash
docker compose exec -T api python3 -m app.security_status docker compose -f compose.local.yaml exec -T api python3 -m ops.security_status
``` ```
Exit status 1 means attention is required. Review blocked artwork, rate limits, Exit status 1 means attention is required. Review blocked artwork, rate limits,
@@ -312,7 +314,7 @@ a direct pin, regenerate the lock in the same Python 3.12 environment and rebuil
```bash ```bash
./infra/lock_dependencies.sh ./infra/lock_dependencies.sh
docker compose up --build -d --wait docker compose -f compose.local.yaml up --build -d --wait
``` ```
The generator downloads public package metadata in a disposable container and The generator downloads public package metadata in a disposable container and

View File

@@ -0,0 +1,85 @@
# DTF remediation register — 2026-09-22
This is the action list for all 37 findings in [the September 21 review](REVIEW-2026-09-21.md). That review contains the evidence and severity for each ID. This register includes the September 22 payment review. It is a plan, not evidence that a finding has been closed in production.
**Current position:** We are in Week 2. The local fixes for foreign-quote order disclosure and approval without a verified amount are implemented and tested, but are not committed or deployed. The first order-correctness slice now covers parts of findings 2, 5–8, and 11; see the progress note below. The remaining work and production verification stay open. Payment webhook work is partial progress on finding 31, not completion of real payments.
**Local progress, 2026-09-22:** Browser and API regressions covered stale editor items, DPI refusal and warning acknowledgement, changed-cart quote actions, oversized width, and finals invalidated by a later correction. A versioned per-file source specification survived quote review into the order snapshot. At that point exact placement coordinates and a generated print file were still missing. None of these changes is a production release.
**Local progress, 2026-09-23:** Specification v2 adds per-copy film coordinates, validates every copy and the quote height, and keeps a downloadable layout manifest in the approved order. The board now pages pending and approved unpaid quotes; a local regression reached all 105 pending and 22 approved fixture quotes. Final print-file generation, completed-order search, and quote cancellation/expiry lifecycle remain open.
**Operational progress, 2026-09-23:** Finding 23's entrypoints are repaired locally. The staging gate passed in a network-disabled image with non-secret fixture data; `ops.security_status` ran in the API image and correctly reported stale local signatures; `ops.backup create-and-verify` restored database counts and 78 clean objects in isolated temporary targets; the production API image built and imported `ops`. This verifies the commands, not production offsite recovery (finding 30) or a fresh scanner (finding 17).
**Quality progress, 2026-09-23:** Findings 9 and 10 are partly repaired: failed image decoding blocks checkout, mixed analyzed/manual sheets stay at table price, and rotated DPI uses the correct pixel axis. PDF measurement now uses the PDF.js page model with effective crop, rotation, UserUnit and page count; invalid or multi-page files block quoting. The same-origin PDF worker and isolated browser checks pass. Unsupported image operators, representative print-file evidence and final printability remain open.
**Upload progress, 2026-09-23:** The API and customer picker now reject files above the effective ClamAV stream limit before transfer, and the session advertises that limit. Unfinished reservations have a one-hour lease and a customer/operator cancel endpoint; owner-scoped cancellation has an integration check. Quota remains reserved until the object is actually purged, so a failed cleanup cannot admit unaccounted storage. PDF rendering now destroys the parser job when its timeout fires, covered by a browser check. This closes the misleading upload-then-quarantine path locally but does not satisfy the agreed large-file capability in finding 3. A tested large-file scan/release design, stronger anonymous admission controls (finding 16), and remaining browser resource bounds (finding 12) are still required.
## Gates
| Gate | Meaning |
|---|---|
| **W2** | Fix during Week 2 before calling the corresponding client workflow complete. These defects can be worked on while provider contracts are clarified. |
| **Upload** | Resolve before inviting the public to upload customer artwork. |
| **Paid** | Resolve before enabling live checkout or accepting a real paid order. |
| **Release** | Resolve before declaring the deployed production system ready. |
| **Incremental** | Improve alongside feature work; it does not justify a standalone rewrite. |
The gates are cumulative: a live release must pass W2, Upload, Paid, and Release checks. Decisions labelled **business** require an agreed product rule; engineering can build and test the surrounding flow in parallel. Where a deployment risk is conditional, verify the actual topology and document the result before closing it.
## Complete finding-to-action map
| Review ID | Gate | Required action and closure evidence |
|---|---|---|
| 1 | Paid | Implement real payment, freight, ERP, and notification adapters with sandbox acceptance and reconciliation; remove fake adapters from the live path. |
| 2 | W2 | Store a versioned per-file production specification and approved layout on quote and order; prove the factory can reproduce the purchased job. |
| 3 | Upload; business | Agree the advertised maximum and implement a scan/release path that actually supports it; reject unsupported sizes before transfer. |
| 4 | W2 | Give quotes an explicit lifecycle and paginated/searchable operator view; verify the 101st actionable quote remains visible. |
| 5 | W2 | Invalidate or revision-bind finals when a new correction arrives; test a correction submitted after a final was uploaded. |
| 6 | W2 | Make quality eligibility a checkout gate and store any required acknowledgement against the artwork revision. |
| 7 | W2 | Clear the current cart item immediately when artwork is removed or becomes invalid; test the submitted payload. |
| 8 | W2 | Bind checkout to an immutable quoted cart snapshot; require re-quote after any material edit, including same-price edits. |
| 9 | W2 | Measure PDF pages through the parser's page model; handle every supported page or reject multi-page/unsupported geometry explicitly. |
| 10 | W2 | Require quality evidence per billable source; make undecodable/unknown sources explicit and correct rotation-sensitive DPI calculations. |
| 11 | W2 | Validate physical dimensions before packing; never silently scale a requested print size. |
| 12 | Upload | Bound browser decoding, copy count, PDF work, and preview size; cancel obsolete work and test representative large inputs. |
| 13 | Paid | Capture and validate a full delivery-address snapshot, then connect it to freight quote and order fulfilment. |
| 14 | Paid; business | Set written auto-approval rules and manual-exception criteria; prove eligible orders can complete after hours without an operator. |
| 15 | W2; business | Define accepted print output, generate it from the approved versioned layout, and compare produced geometry/metres with the quote. If scope changes, update the client commitment and site claims explicitly. |
| 16 | Upload | Limit anonymous reservation capacity and lifetime; add cancellation and cleanup, then test quota-exhaustion behavior. |
| 17 | Upload | Update ClamAV signatures on a controlled schedule; surface signature age and fail the intake gate when stale. |
| 18 | Release | Trust only the actual proxy hop, restrict origin access, and test real client IP/rate limits through the deployed Swarm topology. |
| 19 | Release | Wire file-backed secrets into the active stack; give each service only necessary credentials and remove unused bootstrap secrets. |
| 20 | Release | Recheck operator `active` atomically when issuing and using sessions; test disable-versus-login concurrency and document password-change session policy. |
| 21 | Paid | Provide email verification and customer recovery/guest continuity, and make checkout's account-creation claim match reality. |
| 22 | Release; business | Agree retention/export/deletion rules for profiles, quotes, orders, payloads, artwork, and backups; implement and verify them. |
| 23 | W2 | Repair staging, backup, and security commands after the directory move; smoke-test them in the images and Compose files actually shipped. |
| 24 | Release | Set and test PostgreSQL node placement/persistence for the intended Swarm size, plus recovery after host failure. |
| 25 | Release | Scan before promotion, publish immutable paired API/web image identities, and deploy exactly the scanned release. |
| 26 | Release | Make preflight enforce the active stack contract and provider behavior; verify Portainer/deployment convergence after promotion. |
| 27 | Release | Run browser tests in a network where signed storage URLs work; fail CI when Chrome or the test endpoint is unavailable. |
| 28 | Release | Isolate each CI Compose project, ports, networks, and volumes; serialize release promotion and test overlapping runs. |
| 29 | Release | Separate liveness/readiness, monitor provider backlog, cleanup, scanner freshness and backup age; test alert routing and rollback acceptance. |
| 30 | Release; business | Set recovery objectives, make consistent encrypted offsite backups, and rehearse restore of database plus required live artwork. |
| 31 | Paid | Finish durable payment intent, idempotent webhook handling, status/refund rules, reconciliation, and ordered outbox/dead-letter recovery; test provider-success/database-failure cases. Signed event work is only partial progress. |
| 32 | Upload | Bound upload concurrency, decouple upload from scan waiting, measure queue latency, and distinguish transient scan errors from rejected content. |
| 33 | Release | Introduce ordered schema migrations and core constraints/relationships; test both clean install and upgrade from the existing schema. |
| 34 | Incremental | Replace shared mutable browser cart state as part of IDs 2/7/8; then extract reusable business operations from routes and add bounded DB connection management where load measurements warrant it. |
| 35 | Release | Add representative artwork, real PDF, failure/retry, migration, operational-command, and exact-release acceptance tests. |
| 36 | W2 | Correct executable setup/Portainer/security instructions and PDF generator paths; check generated output against current scope. |
| 37 | Release | Inventory and scan every deployed image and vendored asset, pin release dependencies, and set a controlled refresh process. Do not describe the existing PDF.js advisory as a proven exploit. |
## Execution order
1. **Correct the customer/order model now:** IDs 2, 4–11, 23, and 36. Keep an immutable quote revision through payment, production output, and correction approval. Close each defect with a focused regression test and a real artwork example where geometry matters.
2. **Set the missing product rules while coding continues:** IDs 3, 14, 15, 22, and 30. Obtain representative files, accepted print format, auto-approval thresholds, retention rules, recovery objectives, and provider sandbox access. Do not collect credentials in this document.
3. **Make public intake safe:** IDs 3, 12, 16, 17, and 32. Test the declared upload size end to end, including scan, release, quota, browser memory, and timeout behavior.
4. **Complete live commerce:** IDs 1, 13, 14, 15, 21, and 31. Build freight/address, payment/reconciliation, ERP, and notification flows; test duplicates, outages, refunds, and human exceptions in provider sandboxes.
5. **Prove the deployed system:** IDs 18–20, 22, 24–30, 33, 35, and 37. Run the exact images and stack, exercise migration, backup/restore, monitoring, secrets, CI and release rollback. Improve ID 34 as the affected areas are changed.
## Who supplies what
- **Engineering:** implement and test the code, schema, operational commands, CI gates, provider adapters, and recovery runbooks; gather evidence for each closure. This work can start with the order/cart defects without waiting for provider access.
- **Business/client:** approve unattended-pricing exceptions, final print-file format and samples, the real maximum file size, shipping services and policy, privacy retention, and recovery objectives. The detailed worksheet is [production inputs](PRODUCTION_INPUTS.md).
- **Provider/operations owners:** supply sandbox accounts and configuration through the approved secret channel, plus the real deployment topology, backup destination, alert recipients, and release/rollback ownership. No credentials belong in this register or the repository.
**Closure rule:** A checkbox or passing mocked flow is insufficient. For each ID, keep the original evidence, record the implemented change and test, then verify in the environment that carries the risk. The [working roadmap](ROADMAP.md) tracks Week 2 delivery status; this register tracks the full defect disposition.

View File

@@ -7,18 +7,37 @@
> Update the **Current step** line and the item status every time something moves. > Update the **Current step** line and the item status every time something moves.
> Add new findings at the bottom of the relevant block rather than rewriting history. > Add new findings at the bottom of the relevant block rather than rewriting history.
**Current step:** Block 0 closed; Block 2 closed except 2.9–2.11; 4.1, 4.2, 4.5, **Current step (2026-09-23, Week 2):** Payment safety fixes 2.13 and 2.14 and
5.1, 5.3, 5.4 and 5.8 done. Remaining work needs decisions (3.1, 3.2, 3.3) or the local order-correctness work in 3.6/3.9 have passed integration checks.
client inputs (1.1, 1.2, 2.10). Block 1 still waits on client inputs for Production specification v2 now records each copy's film coordinates and is
1.1/1.2. kept through the approved order; 4.6 now pages pending and approved unpaid
quotes, including a tested 101st pending quote. Operational entrypoints in
5.12 are repaired and locally exercised. Image decoding, mixed-sheet grading,
rotation-sensitive DPI, and PDF page geometry are corrected in 3.9/4.4.
Next address the upload/scanner safety gate and unsupported PDF image evidence.
The customer/API upload admission now stops above the scanner's effective limit
before transfer; the 5 GiB large-file product path still needs agreement and
implementation.
Unfinished upload reservations now expire after one hour or can be cancelled
explicitly; anonymous admission and browser resource bounds stay open.
Generated print output, lifecycle/recovery, and provider work remain open.
Obtain decisions for unattended pricing, print-file acceptance and large files,
plus sandbox inputs for freight and Mercado Pago. Week 2 delivery items 1.1–1.5
remain open; 1.6 is complete.
> Paths in closed items are written as they were when the finding was made. > Paths in closed items are written as they were when the finding was made.
> The repository was laid out by role on 2026-09-21 (`local/` became `app/`, > The repository was laid out by role on 2026-09-21 (`local/` became `app/`,
> with `tests/`, `ops/`, `infra/` and `web/` beside it); the history is left > with `tests/`, `ops/`, `infra/` and `web/` beside it); the history is left
> as recorded rather than rewritten. > as recorded rather than rewritten.
**Last audit:** 2026-09-18, full read of `app/`, `dtf-site.html`, `deploy/`, **Last audit:** 2026-09-18, full read of the then-current tree. The 2026-09-21
`.gitea/`, docs and legacy prototypes. Findings below carry their audit IDs. full review is `docs/REVIEW-2026-09-21.md`; the 2026-09-22 review and payment
probes added new findings to Blocks 2–5 below. Historical paths in closed items
remain as recorded.
**Full remediation register:** `docs/REMEDIATION-2026-09-22.md` maps every one
of the 37 review findings to an action and a release gate. Use it alongside
this Week 2 tracker; a green milestone here does not close the production gate.
| Status | Meaning | | Status | Meaning |
|---|---| |---|---|
@@ -29,6 +48,29 @@ client inputs (1.1, 1.2, 2.10). Block 1 still waits on client inputs for
--- ---
## Week 2 execution sequence
This sequence keeps the client commitments in Block 1 visible while correcting
defects that would make those commitments unsafe or impossible to operate.
Do not mark a provider item complete from a fake-adapter test or a healthy page.
| Order | Work | Exit evidence |
|---|---|---|
| 1. Immediate safety — done 2026-09-22 | Close 2.13 and 2.14; cover foreign quote IDs, missing/invalid amounts, duplicates and valid approvals. | Local integration checks pass and no other customer's order is returned. |
| 2. Order correctness | Fix 3.6 and 4.6: one current cart/quote snapshot, versioned per-file production instructions, correction/final revision binding, visible actionable quotes. | The approved quote, order and final file can be traced back to the same reviewed layout; edits cannot buy an old cart. |
| 3. Resolve product contracts | Decide 3.1–3.3: which quotes may auto-approve, what generates the print file, and which sizes the upload and scanner can release. | Written acceptance rules and representative artwork/large-file cases before enabling unattended payment. |
| 4. Week 2 integrations | Add destination data and real freight first, then Mercado Pago payment intents/webhooks/reconciliation, then Tiny/Olist order creation. Keep the four agreed WhatsApp events in the same delivery contract. | Sandbox flows and failure/retry cases pass; no fake provider is presented as production ready. |
| 5. Operability and release | Repair 5.12–5.14, signatures, proxy trust and backup/restore; gate browser tests and the exact deployed images. | Fresh install, upgrade, recovery and deployed release checks pass with alert ownership recorded. |
Client inputs needed for steps 3–4 are listed in `docs/PRODUCTION_INPUTS.md`.
Engineering can complete steps 1–2 and repair local operational commands while
those inputs are gathered. The full disposition of architecture, security,
quality, operational, and delivery findings is in
`docs/REMEDIATION-2026-09-22.md`; all release gates there must be met before
accepting real customer work.
---
## Block 0 · Broken right now ## Block 0 · Broken right now
Nothing in this block is optional. Until it is closed, the system cannot be Nothing in this block is optional. Until it is closed, the system cannot be
@@ -158,29 +200,28 @@ Ports 8090/8091/8010 were used; 8080 was held by an unrelated preview server.
From the report already sent. These are dated promises, not backlog. From the report already sent. These are dated promises, not backlog.
- `[~]` 1.1 — Mercado Pago transparent checkout, signed and idempotent webhooks. - `[~]` 1.1 — Mercado Pago transparent checkout, signed and idempotent webhooks.
**The provider-independent half is built** (2026-09-22): `POST /api/payments/webhook` **Current foundation (2026-09-22):** a fake signer exercises signature rejection,
verifies a signature before parsing, records every delivery under the provider's event-ID deduplication, amount comparison and transactional order creation.
own event id, and applies it in one transaction. A duplicate is a no-op, a This is not a Mercado Pago integration. Complete a durable payment intent,
re-sent approval finds the order already there, and an approval whose amount provider payment ID and currency binding, real verification and status lookup,
disagrees with the reviewed quote is refused rather than shipped. Order creation delayed/duplicate event handling, refund/cancellation rules and reconciliation.
moved to `app/payments.py` so the webhook and the local checkout cannot drift. A refused paid event must be visible for operator resolution rather than silently
Exercised end to end by `tests/payment_test.py` against a fake signer. treated as finished. See 2.14 and 3.7. Requires sandbox access, webhook
administration, event mapping and an approved refund policy.
What remains needs the client: a sandbox account, webhook administration, the
event/status mapping and the refund policy. In code it is one adapter supplying
`create`, `verify` and `parse` — nothing in the service changes.
- `[ ]` 1.2 — Real freight quotation. **Blocked on client inputs** (see - `[ ]` 1.2 — Real freight quotation. **Blocked on client inputs** (see
`PRODUCTION_INPUTS.md`): source platform, credentials, origin CEP, services, `PRODUCTION_INPUTS.md`): source platform, credentials, origin CEP, services,
packaging weight/dimensions per length, subsidy policy. packaging weight/dimensions per length, subsidy policy.
- `[ ]` 1.3 — Idempotent Tiny/Olist order creation with order-number traceability. - `[ ]` 1.3 — Idempotent Tiny/Olist order creation with order-number traceability.
Confirm endpoints, tag behaviour and rate limits first. Confirm endpoints, tag behaviour and rate limits first.
- `[ ]` 1.4 — Final print-file generation (see 3.2 — this is the same problem). - `[ ]` 1.4 — Final print-file generation (see 3.2 and 3.6: production instructions
must survive checkout before an output engine can reproduce the approved job).
- `[ ]` 1.5 — Main Kanban production states consolidated. - `[ ]` 1.5 — Main Kanban production states consolidated.
- `[ ]` 1.6 — **Block 0.2 + 0.3**, promised as "início da próxima semana". - `[x]` 1.6 — **Block 0.2 + 0.3** were completed and verified on 2026-09-18.
`[!]` The production compose currently blocks `dev_paid` (`ENVIRONMENT != 'local'`) `[!]` The production compose currently blocks `dev_paid` (`ENVIRONMENT != 'local'`)
and ships only fake adapters, so the deployed system cannot take an order at all. and ships only fake adapters, so the deployed system cannot take an order at all.
1.1 is what unblocks it. Real freight, payment initiation and verified provider events are required to
unblock it; the fake webhook alone does not.
--- ---
@@ -335,8 +376,36 @@ proving control of the e-mail. Needs a transactional mail provider — **client
### `[ ]` 2.11 — LGPD `(F15)` ### `[ ]` 2.11 — LGPD `(F15)`
CNPJ, phone and e-mail are kept indefinitely in `accounts.profile` and CNPJ, phone and e-mail are kept indefinitely in `accounts.profile` and
`orders.snapshot`. Artwork has a 30-day policy; personal data has none, and there is `orders.snapshot`. Artwork has a 30-day policy; personal data has no defined
no privacy notice, consent record or deletion path. retention, export or deletion path. The Site links an external privacy notice;
confirm that it covers this processing and define the required records and
customer rights flow before production activation.
### `[x]` 2.13 — A foreign paid quote ID exposes an order (2026-09-22 review)
The `dev-paid` refusal fallback fetched `orders` by `quote_id` without `owner`.
A separate local customer session received the full paid order when supplied
another customer's quote ID. `app/api/orders.py` now includes the owner in the
fallback query. The local payment integration test confirms a foreign ID returns
404 while the owner can still retrieve the already-paid order.
### `[x]` 2.14 — A signed approval without a paid amount creates an order
`app/payments.py` compared amounts only when the event contained one. A local
signed `approved` event without `amount_cents` created an order. The service now
requires an actual integer amount equal to the approved total; local integration
tests cover missing, non-integer, underpaid and correct values. Currency and
provider payment identity belong to the wider contract in 3.7; this gate does
not complete 1.1.
### `[~]` 2.15 — Public intake controls need operational proof
Unfinished reservations now have a one-hour lease and owner-scoped cancellation;
anonymous admission capacity still needs a firm bound. Keep ClamAV signatures
current and alert on stale data; scope reverse-proxy IP trust
to the actual hop and verify it through Swarm ingress. These are separate
controls, but all must work before public large-file intake is considered safe.
The production topology has not been verified by the repository review.
--- ---
@@ -372,8 +441,10 @@ what was promised in the meeting and what exists.
### `[?]` 3.3 — The 5 GB problem is unsolved `(F19)` ### `[?]` 3.3 — The 5 GB problem is unsolved `(F19)`
Transport accepts 5 GiB; `SCAN_MAX_BYTES` / ClamAV `StreamMaxLength` release only Transport accepts 5 GiB; `SCAN_MAX_BYTES` / ClamAV `StreamMaxLength` release only
≤ 128 MiB. Files above that are quarantined permanently with no path forward. This ≤ 128 MiB. As of 2026-09-23, customer selection and API reservation reject files
is exactly the risk Jorge raised in the meeting. above the effective scan limit before transfer, and the Site displays the current
limit. This prevents a doomed upload; it does not deliver the promised 5 GiB path.
This is exactly the risk Jorge raised in the meeting.
**Decide:** raise the scan ceiling with a resource/timeout design, or define an **Decide:** raise the scan ceiling with a resource/timeout design, or define an
explicit large-file path (staged scan, sampled scan, operator override with audit). explicit large-file path (staged scan, sampled scan, operator override with audit).
@@ -384,11 +455,69 @@ explicit large-file path (staged scan, sampled scan, operator override with audi
round-trip per part → ~640 sequential API calls for a 5 GB file, through an nginx round-trip per part → ~640 sequential API calls for a 5 GB file, through an nginx
`limit_req` of 20r/s. Add parallelism (4–6 in flight) and batch presigning. `limit_req` of 20r/s. Add parallelism (4–6 in flight) and batch presigning.
### `[ ]` 3.5 — Payment ordering `(F27)` ### `[~]` 3.5 — Payment ordering `(F27)`
`dev_paid` charges before persisting the order and passes no idempotency key. The local fake `pay` call now runs inside the order transaction, and the inbound
Harmless with `FakePayment`; with Mercado Pago that ordering is how you get double webhook records and applies a delivery transactionally. This does not make an
charges. Fix as part of 1.1. external charge atomic with PostgreSQL: a provider can succeed while the database
write fails, or deliver the approval later. Add a durable payment intent,
provider idempotency key and reconciliation as part of 1.1 and 3.7.
### `[~]` 3.6 — Preserve and bind the order the customer actually reviewed
The browser's width, copies, rotation, mirroring and repetitions are absent from
the API item, so the factory cannot reproduce the priced layout. Removing an
artwork can leave a stale cart item; editing after quote creation can leave the
old quote payable; a new correction can leave an obsolete final active. Persist
a versioned per-file production specification, tie the displayed cart to its
immutable quote, and tie final approval to the latest correction revision.
Cover the real editor-to-quote-to-final journey, not only the pricing table.
**Local progress 2026-09-23:** The Site includes per-upload width, length,
copies, rotation, mirroring, measurement source, and the exact placement of
each copy in production specification v2. The API checks coverage, dimensions,
film bounds, and quote height; commercial review cannot replace the layout.
The order snapshot and downloadable Kanban manifest retain it. Browser quote actions are disabled when
the cart differs, including same-price changes. Editor changes invalidate the
current cart item immediately; a new customer correction deactivates prior
finals. Browser and local API regressions pass. **Still open:** generate and
validate the final print file from the approved source revision, and
make quote/cart continuity work across devices through a server-authoritative
confirmation flow. Current quote binding is a browser guard.
### `[?]` 3.7 — Complete payment state and reconciliation rules
Event-ID deduplication does not establish which provider payment settled which
quote. Define intent creation, provider transaction ID, currency, paid-at time,
pending/rejected/refunded/cancelled states, late approval after quote expiry,
overpayment and provider success followed by database failure. Record refused
paid events for resolution. Decide who reconciles them and when production must
stop or refund. Implement with 1.1 after the checkout/refund policy is approved.
### `[ ]` 3.8 — Collect a deliverable destination before charging freight
The quote has a shipping service and CEP but no recipient, street, number,
city/state or delivery snapshot. Add and validate these fields with 1.2, then
bind the chosen service and final freight amount to the payment intent.
### `[~]` 3.9 — Make artwork quality and geometry evidence explicit
Reject or route for review when PDF page count/geometry, image decoding or DPI
cannot be established. Do not infer pixels from compressed file size, grade a
mixed item from only the readable files, silently shrink oversized artwork, or
allow a displayed DPI rejection to proceed through checkout. Use representative
real artwork in acceptance checks.
**Local progress 2026-09-22:** DPI refusal and warning acknowledgement now gate
the cart and quote API records the acknowledgement. Oversized loose-art width
is rejected in the UI, API production contract, and packer. On 2026-09-23,
unreadable loose images are blocked, mixed analyzed/manual sheets receive no
automatic grade or discount, and rotated DPI uses the pixel dimension that
corresponds to printed width. PDF dimensions now come from the parsed page
model, with page count, crop, rotation, and UserUnit checks; multi-page and
malformed PDFs block quoting. Isolated browser checks cover those cases and
same-origin PDF rendering. Unsupported PDF image operators and representative
print-file evidence still need correction before this item can close.
--- ---
@@ -403,15 +532,22 @@ charges. Fix as part of 1.1.
window of recent finished ones (`BOARD_FINISHED_LIMIT`, default 50) and the true window of recent finished ones (`BOARD_FINISHED_LIMIT`, default 50) and the true
finished total. An operator can never lose a card they could act on; only terminal finished total. An operator can never lose a card they could act on; only terminal
ones are trimmed. The Kanban column reads "Finalizado · 50 de 213" when truncated, ones are trimmed. The Kanban column reads "Finalizado · 50 de 213" when truncated,
so the count is not mistaken for an all-time total. Pending quotes are capped too. so the count is not mistaken for an all-time total. Pending quotes now have
a paginated view; older completed orders still need search in 4.6.
- `[ ]` 4.3 — Scan throughput `(F21)`: one `scan_loop` thread, `worker` at - `[ ]` 4.3 — Scan throughput `(F21)`: one `scan_loop` thread, `worker` at
`replicas: 1`, ClamAV `MaxThreads 2`, browser gives up after 150s. `replicas: 1`, ClamAV `MaxThreads 2`, browser gives up after 150s.
- `[ ]` 4.4 — Quality grade fallback `(F22)`: when `carregarImagem` fails, - `[x]` 4.4 — Failed image decoding no longer infers pixels from compressed
`px(f)=Math.sqrt(f.size/1024)*95` stands — a DPI inferred from **file size in file size. Unreadable loose images cannot enter the cart or receive a grade;
bytes** — and it drives up to a 25% discount. Fail closed instead. an isolated browser regression covers the failure path (2026-09-23).
- `[x]` 4.5 — Dead config `(F28)`: resolved by deleting `deploy/stack.yaml` in 2.12. - `[x]` 4.5 — Dead config `(F28)`: resolved by deleting `deploy/stack.yaml` in 2.12.
`CLAMD_HOST` no longer appears anywhere; `scanning.py` reaching `'scanner'` `CLAMD_HOST` no longer appears anywhere; `scanning.py` reaching `'scanner'`
directly is now simply how it works, not a contradiction. directly is now simply how it works, not a contradiction.
- `[~]` 4.6 — Older unpaid quotes can disappear behind the board limit.
On 2026-09-23 the board began showing newest pending and approved unpaid
quotes separately, with cursor pagination and counts; a local regression
retrieved all 105 pending and 22 approved fixture quotes and cleaned them up.
**Still open:** an explicit terminal state for abandoned/expired quotes and
search/history for older completed orders.
--- ---
@@ -480,6 +616,21 @@ charges. Fix as part of 1.1.
days. The copy now states 30 days, says a later order needs the file again, and days. The copy now states 30 days, says a later order needs the file again, and
keeps only the true part: order history remains in the account. Policy unchanged; keeps only the true part: order history remains in the account. Policy unchanged;
the promise was corrected to match it. the promise was corrected to match it.
- `[x]` 5.12 — Repair operational entrypoints after the `local/` split.
On 2026-09-23, staging and both API images package `ops/`; staging calls
`ops.staging_readiness`; local backup calls `ops.storage_backup` through
`compose.local.yaml`; documented security and backup commands use the real
modules. Verified a network-disabled staging pass with non-secret fixture
data, a production API image import, local security status, and a local
backup/restore of the database plus 78 clean objects. Production offsite
recovery and signature freshness remain separate open items.
- `[ ]` 5.13 — Define production recovery: scheduled encrypted offsite database
and object backups, a consistent snapshot boundary, Swarm data placement and
a restore rehearsal that opens every required live order file.
- `[ ]` 5.14 — Promote and verify one immutable release. Scan before publishing
mutable tags, make the source preflight validate the active stack, require the
browser tests to run, test clean install and upgrade, and check application
readiness after Portainer redeploys. Isolate concurrent CI stacks.
--- ---

View File

@@ -77,14 +77,15 @@ the signatures bundled into that image. On closeout it reported ClamAV
below the seven-day alert threshold. below the seven-day alert threshold.
The multipart transport supports uploads up to 5 GiB, but `SCAN_MAX_BYTES` and The multipart transport supports uploads up to 5 GiB, but `SCAN_MAX_BYTES` and
ClamAV stream limits release at most 128 MiB by default. Larger files remain ClamAV stream limits release at most 128 MiB by default. As of 2026-09-23 the
blocked. Supporting larger files requires a deliberate resource/timeout design, API and customer picker reject larger files before transfer. Supporting them
not simply increasing the upload limit. requires a deliberate resource/timeout design, not simply increasing the
transport limit.
Run: Run:
```bash ```bash
docker compose exec -T api python3 -m app.security_status docker compose -f compose.local.yaml exec -T api python3 -m ops.security_status
``` ```
An exit status of 1 requires review. At closeout, attention was expected because An exit status of 1 requires review. At closeout, attention was expected because

View File

@@ -8,6 +8,7 @@ WORKDIR /app
COPY infra/requirements.txt infra/requirements.lock /app/infra/ COPY infra/requirements.txt infra/requirements.lock /app/infra/
RUN pip install --no-cache-dir --require-hashes -r infra/requirements.lock RUN pip install --no-cache-dir --require-hashes -r infra/requirements.lock
COPY app /app/app COPY app /app/app
COPY ops /app/ops
# The local image carries the suites so they can run inside the stack network. # The local image carries the suites so they can run inside the stack network.
# deploy/Dockerfile.api deliberately does not: tests are not part of what ships. # deploy/Dockerfile.api deliberately does not: tests are not part of what ships.
COPY tests /app/tests COPY tests /app/tests

View File

@@ -9,9 +9,10 @@ from uuid import uuid4
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
BACKUPS = ROOT / 'backups' BACKUPS = ROOT / 'backups'
COMPOSE = ['docker','compose','-f','compose.local.yaml']
def docker(script, *args, **kwargs): def docker(script, *args, **kwargs):
return subprocess.run(['docker','compose','exec','-T','db','sh','-c',script,'sh',*args], return subprocess.run([*COMPOSE,'exec','-T','db','sh','-c',script,'sh',*args],
cwd=ROOT,check=True,**kwargs) cwd=ROOT,check=True,**kwargs)
def checksum(path): def checksum(path):
@@ -21,7 +22,7 @@ def checksum(path):
return digest.hexdigest() return digest.hexdigest()
def compose_exec(service, *command, **kwargs): def compose_exec(service, *command, **kwargs):
return subprocess.run(['docker','compose','exec','-T',service,*command], return subprocess.run([*COMPOSE,'exec','-T',service,*command],
cwd=ROOT,check=True,**kwargs) cwd=ROOT,check=True,**kwargs)
def create(): def create():
@@ -38,7 +39,7 @@ def create():
docker('pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --format=custom',stdout=stream) docker('pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --format=custom',stdout=stream)
with objects.open('xb') as stream: with objects.open('xb') as stream:
created.append(objects);objects.chmod(0o600) created.append(objects);objects.chmod(0o600)
result=compose_exec('api','python','-m','local.storage_backup','export', result=compose_exec('api','python','-m','ops.storage_backup','export',
stdout=stream,stderr=subprocess.PIPE) stdout=stream,stderr=subprocess.PIPE)
summaries=[line.removeprefix('DTF_BACKUP_SUMMARY ') for line in summaries=[line.removeprefix('DTF_BACKUP_SUMMARY ') for line in
result.stderr.decode().splitlines() if line.startswith('DTF_BACKUP_SUMMARY ')] result.stderr.decode().splitlines() if line.startswith('DTF_BACKUP_SUMMARY ')]
@@ -111,7 +112,7 @@ def verify(path):
if had_legacy:legacy_sidecar.write_bytes(previous) if had_legacy:legacy_sidecar.write_bytes(previous)
else:legacy_sidecar.unlink(missing_ok=True) else:legacy_sidecar.unlink(missing_ok=True)
with objects.open('rb') as stream: with objects.open('rb') as stream:
compose_exec('api','python','-m','local.storage_backup','verify',stdin=stream) compose_exec('api','python','-m','ops.storage_backup','verify',stdin=stream)
print('PASS: combined database and clean-object backup verified. Active data was untouched.') print('PASS: combined database and clean-object backup verified. Active data was untouched.')
if __name__=='__main__': if __name__=='__main__':

View File

@@ -96,5 +96,5 @@ def main(path: Path) -> int:
if __name__ == '__main__': if __name__ == '__main__':
if len(sys.argv) != 2: if len(sys.argv) != 2:
raise SystemExit('usage: python -m local.staging_readiness PATH') raise SystemExit('usage: python -m ops.staging_readiness PATH')
raise SystemExit(main(Path(sys.argv[1]))) raise SystemExit(main(Path(sys.argv[1])))

View File

@@ -188,5 +188,5 @@ def verify_archive():
if __name__ == '__main__': if __name__ == '__main__':
if len(sys.argv) != 2 or sys.argv[1] not in ('export', 'verify'): if len(sys.argv) != 2 or sys.argv[1] not in ('export', 'verify'):
raise SystemExit('usage: python -m local.storage_backup export|verify') raise SystemExit('usage: python -m ops.storage_backup export|verify')
export_archive() if sys.argv[1] == 'export' else verify_archive() export_archive() if sys.argv[1] == 'export' else verify_archive()

View File

@@ -13,6 +13,30 @@ const html=await readFile('web/index.html','utf8');
const hashes=[...html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)] const hashes=[...html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)]
.filter(m=>! /\bsrc\s*=/i.test(m[1])) .filter(m=>! /\bsrc\s*=/i.test(m[1]))
.map(m=>"'sha256-"+createHash('sha256').update(m[2]).digest('base64')+"'"); .map(m=>"'sha256-"+createHash('sha256').update(m[2]).digest('base64')+"'");
function pdfFixture({pages=1,media='[0 0 720 360]',crop='',rotate=0,unit=1}={}){
const objects=[
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids ['+Array.from({length:pages},(_,i)=>i+3+' 0 R').join(' ')+
'] /Count '+pages+' /MediaBox '+media+' >>',
...Array.from({length:pages},()=> '<< /Type /Page /Parent 2 0 R'+
(crop?' /CropBox '+crop:'')+(rotate?' /Rotate '+rotate:'')+
(unit!==1?' /UserUnit '+unit:'')+' >>')
];
const chunks=['%PDF-1.6\n%\xE2\xE3\xCF\xD3\n'], offsets=[0];
let length=Buffer.byteLength(chunks[0],'latin1');
for(let i=0;i<objects.length;i++){
offsets.push(length);
const part=(i+1)+' 0 obj\n'+objects[i]+'\nendobj\n';
chunks.push(part);length+=Buffer.byteLength(part,'latin1');
}
const xref=length;
chunks.push('xref\n0 '+(objects.length+1)+'\n0000000000 65535 f \n');
for(const offset of offsets.slice(1))chunks.push(String(offset).padStart(10,'0')+' 00000 n \n');
chunks.push('trailer\n<< /Size '+(objects.length+1)+
' /Root 1 0 R >>\nstartxref\n'+xref+'\n%%EOF\n');
return Buffer.from(chunks.join(''),'latin1');
}
const pdfData=options=>JSON.stringify(pdfFixture(options).toString('base64'));
const server=createServer(async(req,res)=>{ const server=createServer(async(req,res)=>{
if(req.url.startsWith('/api/')){ if(req.url.startsWith('/api/')){
res.setHeader('Content-Type','application/json'); res.setHeader('Content-Type','application/json');
@@ -27,7 +51,7 @@ const server=createServer(async(req,res)=>{
// Serve any script the page asks for, resolved inside web/, 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 // a hardcoded list: the Site's behaviour is split across several files and a
// list would silently 404 the next one added. // list would silently 404 the next one added.
if(/^\/[\w.-]+\.js$/.test(req.url)){ if(/^\/[\w.-]+\.js$/.test(req.url) || /^\/vendor\/pdf\.(worker\.)?min\.js$/.test(req.url)){
try{ try{
const body=await readFile(resolve('web'+req.url)); const body=await readFile(resolve('web'+req.url));
res.setHeader('Content-Type','text/javascript');res.end(body);return; res.setHeader('Content-Type','text/javascript');res.end(body);return;
@@ -39,6 +63,7 @@ await new Promise(r=>server.listen(0,'127.0.0.1',r));
const profile=await mkdtemp(tmpdir()+'/dtf-artwork-'); const profile=await mkdtemp(tmpdir()+'/dtf-artwork-');
const chrome=spawn(process.env.CHROME_BIN||'/usr/bin/google-chrome-stable',[ const chrome=spawn(process.env.CHROME_BIN||'/usr/bin/google-chrome-stable',[
'--headless=new','--disable-gpu','--no-first-run','--no-default-browser-check', '--headless=new','--disable-gpu','--no-first-run','--no-default-browser-check',
...(process.env.CHROME_NO_SANDBOX==='1'?['--no-sandbox']:[]),
'--remote-debugging-port=0','--user-data-dir='+profile,'about:blank' '--remote-debugging-port=0','--user-data-dir='+profile,'about:blank'
],{stdio:['ignore','ignore','pipe']}); ],{stdio:['ignore','ignore','pipe']});
let stderr='',ws,next=0; let stderr='',ws,next=0;
@@ -128,6 +153,9 @@ try{
assert.deepEqual(await evaluate(`({shown:!$('tipoEnvio').hidden, assert.deepEqual(await evaluate(`({shown:!$('tipoEnvio').hidden,
on:[...document.querySelectorAll('#tipoEnvio .cam.on')].map(b=>b.dataset.tipo)})`), on:[...document.querySelectorAll('#tipoEnvio .cam.on')].map(b=>b.dataset.tipo)})`),
{shown:true,on:['folha']}); {shown:true,on:['folha']});
await evaluate(`(()=>{const f=new File(['x'],'oversize.cdr');Object.defineProperty(f,'size',{value:128*1048576+1});sel([f]);})()`);
assert.equal(await evaluate(`folhas.length===0 && $('recusa').textContent.includes('128 MB')`),true,
'files beyond the scanner limit are rejected before browser analysis');
await evaluate(`pickTipo('avulsa')`); await evaluate(`pickTipo('avulsa')`);
assert.equal(await evaluate('modo'),'avulsa'); assert.equal(await evaluate('modo'),'avulsa');
await evaluate('sendImage()'); await evaluate('sendImage()');
@@ -135,6 +163,14 @@ try{
assert.deepEqual(await evaluate(`({mode:modo,sheets:folhas.length,width:artes[0].cm,quantity:artes[0].q})`),{mode:'avulsa',sheets:0,width:0,quantity:1}); assert.deepEqual(await evaluate(`({mode:modo,sheets:folhas.length,width:artes[0].cm,quantity:artes[0].q})`),{mode:'avulsa',sheets:0,width:0,quantity:1});
await fill('[data-cm]',20);await fill('[data-q]',6); await fill('[data-cm]',20);await fill('[data-q]',6);
let layout=await packed(6); let layout=await packed(6);
await waitFor(()=>evaluate(`itemAtual?.production?.sources[0]?.copies===6`),'per-file production record');
assert.deepEqual(await evaluate(`({version:itemAtual.production.version,source:itemAtual.production.sources[0]})`),
{version:2,source:{kind:'artwork',width_cm:20,length_cm:40,copies:6,
rotation_degrees:0,mirrored:false,measurement:'file'}});
assert.equal(await evaluate('itemAtual.production.placements.length'),6);
assert.equal(await evaluate('itemAtual.production.height_cm'),121);
assert.deepEqual(await evaluate('itemAtual.production.placements.map(p=>[p.source_index,p.copy_index,p.x_cm,p.y_cm])'),
[[0,0,0,0],[0,1,20.5,0],[0,2,0,40.5],[0,3,20.5,40.5],[0,4,0,81],[0,5,20.5,81]]);
assert.equal(layout.heading,'Montagem ao vivo');assert.equal(layout.height,121); assert.equal(layout.heading,'Montagem ao vivo');assert.equal(layout.height,121);
assert.equal(layout.billed,1.3); assert.equal(layout.billed,1.3);
assert.deepEqual(layout.pos.map(p=>[p.x,p.y]),[[0,0],[20.5,0],[0,40.5],[20.5,40.5],[0,81],[20.5,81]]); assert.deepEqual(layout.pos.map(p=>[p.x,p.y]),[[0,0],[20.5,0],[0,40.5],[20.5,40.5],[0,81],[20.5,81]]);
@@ -149,6 +185,32 @@ try{
await click('[data-esp]'); await click('[data-esp]');
await waitFor(()=>evaluate(`$('vArea').querySelector('canvas').toDataURL()!==${JSON.stringify(beforeMirror)}`),'mirror updates pixels'); await waitFor(()=>evaluate(`$('vArea').querySelector('canvas').toDataURL()!==${JSON.stringify(beforeMirror)}`),'mirror updates pixels');
assert.equal(await evaluate('metros'),0.235); assert.equal(await evaluate('metros'),0.235);
await waitFor(()=>evaluate(`itemAtual?.production?.sources[0]?.mirrored===true`),'transforms in production record');
assert.deepEqual(await evaluate(`({rotation:itemAtual.production.sources[0].rotation_degrees,copies:itemAtual.production.sources[0].copies})`),
{rotation:90,copies:9});
await waitFor(()=>evaluate(`itemAtual?.qualityStatus==='rejected'`),'low resolution refusal');
assert.equal(await evaluate('cartPodeEnviar()'),false);
await fill('[data-cm]',3);
await waitFor(()=>evaluate(`itemAtual?.qualityStatus==='ok'`),'rotated DPI uses image height');
assert.equal(await evaluate('Math.round(dpiDe(artes[0]))'),339);
await click('[data-giro]');
await waitFor(()=>evaluate(`itemAtual?.qualityStatus==='warning'`),'unrotated resolution warning');
assert.equal(await evaluate('Math.round(dpiDe(artes[0]))'),169);
assert.equal(await evaluate('cartPodeEnviar()'),false);
await click('#cienteOk');
assert.equal(await evaluate('cartPodeEnviar()'),true);
await fill('[data-q]',8);
assert.equal(await evaluate('itemAtual===null'),true,'editing invalidates the old cart immediately');
await waitFor(()=>evaluate(`itemAtual?.qualityStatus==='warning'`),'edited warning');
assert.equal(await evaluate('cartPodeEnviar()'),false,'acknowledgement does not survive an edit');
await fill('[data-cm]',58);
await waitFor(()=>evaluate(`itemAtual===null && artes[0].cm===58`),'oversized width rejected');
assert.equal(await evaluate('cartPodeEnviar()'),false);
assert.equal(await evaluate(`(()=>{try{encaixar([{w:58,h:10,img:null}],57);return false}catch(error){return error instanceof RangeError}})()`),true,
'packing engine must not shrink an oversized source');
await click('[data-rm]');
assert.equal(await evaluate(`artes.length===0 && itemAtual===null && !cartPodeEnviar()`),true,
'removed artwork cannot remain in the cart');
// A transparent asymmetric image exposes masks that ignore user transforms. // A transparent asymmetric image exposes masks that ignore user transforms.
// Its top-left quarter becomes bottom-right after a mirror and 90° turn. // Its top-left quarter becomes bottom-right after a mirror and 90° turn.
assert.equal(await evaluate(`(()=>{ assert.equal(await evaluate(`(()=>{
@@ -169,6 +231,8 @@ try{
await evaluate(`(()=>{const el=$('lista').querySelector('[data-repf]');el.value=2;el.dispatchEvent(new Event('change',{bubbles:true}));})()`); await evaluate(`(()=>{const el=$('lista').querySelector('[data-repf]');el.value=2;el.dispatchEvent(new Event('change',{bubbles:true}));})()`);
await waitFor(()=>evaluate('metros===0.684'),'ready-sheet repetitions'); await waitFor(()=>evaluate('metros===0.684'),'ready-sheet repetitions');
assert.equal(await evaluate(`document.querySelectorAll('.folhaPrevia').length`),1); assert.equal(await evaluate(`document.querySelectorAll('.folhaPrevia').length`),1);
await evaluate(`(()=>{folhas.push({f:new File(['manual'],'manual.cdr'),med:null,rep:1,m:1,an:null});pintaFolha();})()`);
await waitFor(()=>evaluate(`itemAtual?.nota===0 && itemAtual?.unit===TABELA[modo]`),'mixed analyzed and manual sheets use table pricing');
// An image too small to span the film is refused, never silently repriced. // An image too small to span the film is refused, never silently repriced.
await click('#bVoltar');await click('[data-modo="file"]'); await click('#bVoltar');await click('[data-modo="file"]');
await evaluate('sendImage()'); await evaluate('sendImage()');
@@ -182,6 +246,33 @@ try{
await waitFor(()=>evaluate(`!!$('lista').querySelector('[data-comp]')`),'manual ready sheet'); await waitFor(()=>evaluate(`!!$('lista').querySelector('[data-comp]')`),'manual ready sheet');
await evaluate(`(()=>{const el=$('lista').querySelector('[data-comp]');el.value=1.01;el.dispatchEvent(new Event('change',{bubbles:true}));})()`); await evaluate(`(()=>{const el=$('lista').querySelector('[data-comp]');el.value=1.01;el.dispatchEvent(new Event('change',{bubbles:true}));})()`);
await waitFor(()=>evaluate('itemAtual?.total===21.89'),'unchanged manual pricing'); await waitFor(()=>evaluate('itemAtual?.total===21.89'),'unchanged manual pricing');
await evaluate(`folhas[0].semAnalise='<img src=x onerror=alert(1)>';pintaFolha()`);
assert.equal(await evaluate(`!$('lista').querySelector('img') && $('lista').textContent.includes('<img src=x')`),true,
'PDF parser errors are text, never executable markup');
// Parsed geometry honors inherited MediaBox, CropBox, rotation and UserUnit.
// Extra pages and unreadable PDFs must never fall through to manual pricing.
const pdfFile=(data,name='sheet.pdf')=>`new File([Uint8Array.from(atob(${data}),c=>c.charCodeAt(0))],${JSON.stringify(name)},{type:'application/pdf'})`;
const rotated=await evaluate(`medirFolha(${pdfFile(pdfData({media:'[0 0 720 1440]',crop:'[0 0 360 720]',rotate:90,unit:2}))})`);
assert.deepEqual(rotated,{larg:50.8,alt:25.4,fonte:'página do PDF · UserUnit 2'});
const rendered=await evaluate(`rasterizarPdf(${pdfFile(pdfData({media:'[0 0 720 1440]',crop:'[0 0 360 720]',rotate:90,unit:2}))},50.8,25.4).then(r=>({ok:!!r.tela,error:r.erro||null}))`);
assert.deepEqual(rendered,{ok:true,error:null});
const multi=await evaluate(`medirFolha(${pdfFile(pdfData({pages:2}))})`);
assert.equal(multi.rejected,true);assert.match(multi.reason,/2 páginas/);
const beyondSpec=await evaluate(`medirFolha(${pdfFile(pdfData({media:'[0 0 20000 720]'}))})`);
assert.equal(beyondSpec.rejected,true);assert.match(beyondSpec.reason,/508 cm/);
const broken=await evaluate(`medirFolha(new File(['broken'],'broken.pdf',{type:'application/pdf'}))`);
assert.equal(broken.rejected,true);
await click('#bVoltar');await click('[data-modo="file"]');
await evaluate(`sel([${pdfFile(pdfData({pages:2}),'two-pages.pdf')}])`);
await waitFor(()=>evaluate(`folhas.length===1 && !!folhas[0].measurementError`),'multipage PDF refusal');
assert.equal(await evaluate(`avaliar().pronto`),false);
assert.equal(await evaluate(`cartPodeEnviar()`),false);
assert.match(await evaluate(`$('lista').textContent`),/não pode ser orçado/);
await click('#bVoltar');await click('[data-modo="avulsa"]');
await evaluate(`sel([new File(['not an image'],'broken.png',{type:'image/png'})])`);
await waitFor(()=>evaluate('artes.length===1 && artes[0].decodeError===true'),'failed image decode');
await fill('[data-cm]',20);
await waitFor(()=>evaluate('itemAtual===null && !cartPodeEnviar()'),'undecodable image cannot be quoted');
// PNG drag/drop follows exactly the same artwork path; UV stays UV. Reaching it // PNG drag/drop follows exactly the same artwork path; UV stays UV. Reaching it
// from a by-metre product is one declared click, and it is reversible. // from a by-metre product is one declared click, and it is reversible.
for(const [mode,expected] of [['file','avulsa'],['avulsa','avulsa'],['uvfile','uv'],['uv','uv']]){ for(const [mode,expected] of [['file','avulsa'],['avulsa','avulsa'],['uvfile','uv'],['uv','uv']]){
@@ -201,6 +292,21 @@ try{
await fill('[data-q]',7); await fill('[data-q]',7);
await evaluate(`carregarImagem=realLoad;delayed[0]()`); await evaluate(`carregarImagem=realLoad;delayed[0]()`);
await packed(7); await packed(7);
const timeoutCleanup=await evaluate(`(async()=>{
const realLoader=carregarPdfJs, realTimer=setTimeout, realWorker=temWorker;
let destroyed=false, fail;
carregarPdfJs=async()=>({getDocument:()=>({
promise:new Promise((_,reject)=>{fail=reject}),
destroy:()=>{destroyed=true;fail(new Error('cancelled'));return Promise.resolve()}
})});
temWorker=true;
window.setTimeout=(fn,ms)=>realTimer(fn,ms===30000?1:ms);
try{
const result=await rasterizarPdf({arrayBuffer:async()=>new ArrayBuffer(1)},10,10);
return {timedOut:result.erro==='demorou demais neste navegador',destroyed};
}finally{carregarPdfJs=realLoader;window.setTimeout=realTimer;temWorker=realWorker;}
})()`);
assert.deepEqual(timeoutCleanup,{timedOut:true,destroyed:true});
// Inspect the supplied local artwork, when requested, using the real file input. // Inspect the supplied local artwork, when requested, using the real file input.
if(process.env.ARTWORK_FILE){ if(process.env.ARTWORK_FILE){
await click('#bVoltar');await click('[data-modo="file"]'); await click('#bVoltar');await click('[data-modo="file"]');

View File

@@ -14,6 +14,7 @@ try {
const profile=await mkdtemp(tmpdir()+'/dtf-browser-'); const profile=await mkdtemp(tmpdir()+'/dtf-browser-');
const chrome=spawn(process.env.CHROME_BIN||'/usr/bin/google-chrome-stable',[ const chrome=spawn(process.env.CHROME_BIN||'/usr/bin/google-chrome-stable',[
'--headless=new','--disable-gpu','--no-first-run','--no-default-browser-check', '--headless=new','--disable-gpu','--no-first-run','--no-default-browser-check',
...(process.env.CHROME_NO_SANDBOX==='1'?['--no-sandbox']:[]),
'--remote-debugging-port=0','--user-data-dir='+profile,'about:blank' '--remote-debugging-port=0','--user-data-dir='+profile,'about:blank'
],{stdio:['ignore','ignore','pipe']}); ],{stdio:['ignore','ignore','pipe']});
const pause=ms=>new Promise(r=>setTimeout(r,ms)); const pause=ms=>new Promise(r=>setTimeout(r,ms));
@@ -41,7 +42,9 @@ try{
await p.call('Emulation.setDeviceMetricsOverride',{width:1440,height:1000,deviceScaleFactor:1,mobile:false}); await p.call('Emulation.setDeviceMetricsOverride',{width:1440,height:1000,deviceScaleFactor:1,mobile:false});
await waitFor(()=>p.eval('document.readyState === "complete"'),'page load');return p; await waitFor(()=>p.eval('document.readyState === "complete"'),'page load');return p;
} }
const site=await page('http://localhost:'+(process.env.SITE_PORT||8080)); const siteOrigin=process.env.SITE_BROWSER_ORIGIN||'http://localhost:'+(process.env.SITE_PORT||8080);
const kanbanOrigin=process.env.KANBAN_BROWSER_ORIGIN||'http://localhost:'+(process.env.KANBAN_PORT||8081);
const site=await page(siteOrigin);
await waitFor(()=>site.eval('typeof window.dtfCheckout === "function"'),'checkout bridge'); await waitFor(()=>site.eval('typeof window.dtfCheckout === "function"'),'checkout bridge');
// Prove escaping itself, independently of the CSP's second line of defense. // Prove escaping itself, independently of the CSP's second line of defense.
await site.call('Page.setBypassCSP',{enabled:true}); await site.call('Page.setBypassCSP',{enabled:true});
@@ -74,9 +77,14 @@ try{
assert.equal(await site.eval('pedido[0].total'),21.89); assert.equal(await site.eval('pedido[0].total'),21.89);
assert.equal(await site.eval('document.getElementById("bPagar").disabled'),false); assert.equal(await site.eval('document.getElementById("bPagar").disabled'),false);
await site.click('#bPagar'); await site.click('#bPagar');
try{
await waitFor(async()=> (await site.text()).includes('Arquivos enviados.'),'browser upload and quote',45000); await waitFor(async()=> (await site.text()).includes('Arquivos enviados.'),'browser upload and quote',45000);
}catch(error){
console.error('Checkout status:',await site.eval('document.getElementById("checkoutStatus")?.textContent'));
throw error;
}
const qid=await site.eval('localStorage.getItem("dtf-quote")'); const qid=await site.eval('localStorage.getItem("dtf-quote")');
const kanban=await page('http://localhost:'+(process.env.KANBAN_PORT||8081)); const kanban=await page(kanbanOrigin);
await kanban.fill('#email',process.env.OPERATOR_EMAIL||'operator@example.test'); await kanban.fill('#email',process.env.OPERATOR_EMAIL||'operator@example.test');
await kanban.fill('#password',process.env.OPERATOR_PASSWORD||'local-operator-only'); await kanban.fill('#password',process.env.OPERATOR_PASSWORD||'local-operator-only');
await kanban.eval('document.getElementById("login").requestSubmit()'); await kanban.eval('document.getElementById("login").requestSubmit()');
@@ -85,6 +93,14 @@ try{
assert.equal(await kanban.eval('document.getElementById("password").value'),''); assert.equal(await kanban.eval('document.getElementById("password").value'),'');
await kanban.eval(`(()=>{const card=[...document.querySelectorAll('.review')].find(x=>x.textContent.includes(${JSON.stringify(qid.slice(0,8))}));card.querySelector('[type=checkbox]').click();card.querySelector('form').requestSubmit();})()`); await kanban.eval(`(()=>{const card=[...document.querySelectorAll('.review')].find(x=>x.textContent.includes(${JSON.stringify(qid.slice(0,8))}));card.querySelector('[type=checkbox]').click();card.querySelector('form').requestSubmit();})()`);
await waitFor(async()=> (await kanban.text()).includes('Aprovada:'),'quote approval'); await waitFor(async()=> (await kanban.text()).includes('Aprovada:'),'quote approval');
assert.equal(await site.eval('pedido[0].production.sources[0].copies'),1);
await site.eval('pedido[0].production.sources[0].copies=2;pintaPedido()');
await waitFor(async()=> (await site.text()).includes('O carrinho mudou'),'same-price production edit invalidates quote');
await site.eval('pedido[0].production.sources[0].copies=1;pintaPedido()');
await site.fill('#fMail','changed-browser@example.test');
await waitFor(async()=> (await site.text()).includes('O carrinho mudou'),'quote invalidated by cart edit');
assert.equal(await site.eval('[...document.querySelectorAll("button")].some(x=>x.textContent==="Criar pedido de teste")'),false);
await site.fill('#fMail','local-browser@example.test');
await site.eval('window.dtfCheckout()'); await site.eval('window.dtfCheckout()');
await waitFor(async()=> (await site.text()).includes('Total validado no servidor:'),'approved quote displayed'); await waitFor(async()=> (await site.text()).includes('Total validado no servidor:'),'approved quote displayed');
await site.eval('[...document.querySelectorAll("button")].find(x=>x.textContent==="Criar pedido de teste").click()'); await site.eval('[...document.querySelectorAll("button")].find(x=>x.textContent==="Criar pedido de teste").click()');
@@ -92,6 +108,8 @@ try{
await kanban.click('#refresh'); await kanban.click('#refresh');
await waitFor(()=>kanban.eval(`board.orders.some(o=>o.quote_id===${JSON.stringify(qid)})`),'paid card'); await waitFor(()=>kanban.eval(`board.orders.some(o=>o.quote_id===${JSON.stringify(qid)})`),'paid card');
const oid=await kanban.eval(`board.orders.find(o=>o.quote_id===${JSON.stringify(qid)}).id`); const oid=await kanban.eval(`board.orders.find(o=>o.quote_id===${JSON.stringify(qid)}).id`);
assert.deepEqual(await kanban.eval(`(()=>{const spec=board.orders.find(o=>o.id===${JSON.stringify(oid)}).snapshot.items[0].production;const source=spec.sources[0];return {kind:source.kind,copies:source.copies,length:Number(source.length_cm),height:Number(spec.height_cm),placed:spec.placements.length}})()`),
{kind:'sheet',copies:1,length:101,height:101,placed:1});
// Click real transition buttons, including rerender after each move. // Click real transition buttons, including rerender after each move.
for(const [title,state] of [['Arte tratada','tra'],['Fila de impressão','fil'],['Imprimindo','imp'],['Finalizado','fin']]){ for(const [title,state] of [['Arte tratada','tra'],['Fila de impressão','fil'],['Imprimindo','imp'],['Finalizado','fin']]){
if(state==='fil'){ if(state==='fil'){
@@ -115,7 +133,7 @@ try{
await waitFor(()=>site.eval('window.scrollY===0'),'screenshot scroll position'); await waitFor(()=>site.eval('window.scrollY===0'),'screenshot scroll position');
await site.screenshot('output/local/site.png'); await site.screenshot('output/local/site.png');
await kanban.screenshot('output/local/kanban.png'); await kanban.screenshot('output/local/kanban.png');
const portal=await page('http://localhost:'+(process.env.SITE_PORT||8080)+'/portal.html?order='+oid); const portal=await page(siteOrigin+'/portal.html?order='+oid);
await waitFor(async()=> (await portal.text()).includes('Finalizado'),'customer order tracking'); await waitFor(async()=> (await portal.text()).includes('Finalizado'),'customer order tracking');
await portal.fill('#cnpj','11222333000181');await portal.fill('#phone','11999999999'); await portal.fill('#cnpj','11222333000181');await portal.fill('#phone','11999999999');
await portal.fill('#register-email','browser-'+Date.now()+'@example.test'); await portal.fill('#register-email','browser-'+Date.now()+'@example.test');
@@ -127,7 +145,7 @@ try{
// A logout must clear draft file blobs and metadata, including other open Site tabs. // A logout must clear draft file blobs and metadata, including other open Site tabs.
await portal.eval(`(async()=>{const db=await new Promise((resolve,reject)=>{const r=indexedDB.open('dtf-local-cart',1);r.onsuccess=()=>resolve(r.result);r.onerror=reject;});await new Promise((resolve,reject)=>{const tx=db.transaction('cart','readwrite');tx.objectStore('cart').put({items:[new File(['private'],'private.cdr')],expires:Date.now()+86400000},'security-fixture');tx.oncomplete=resolve;tx.onerror=reject;});db.close();})()`); await portal.eval(`(async()=>{const db=await new Promise((resolve,reject)=>{const r=indexedDB.open('dtf-local-cart',1);r.onsuccess=()=>resolve(r.result);r.onerror=reject;});await new Promise((resolve,reject)=>{const tx=db.transaction('cart','readwrite');tx.objectStore('cart').put({items:[new File(['private'],'private.cdr')],expires:Date.now()+86400000},'security-fixture');tx.oncomplete=resolve;tx.onerror=reject;});db.close();})()`);
await portal.click('#logout'); await portal.click('#logout');
await waitFor(()=>portal.eval('document.getElementById("logout").hidden'),'customer logout'); await waitFor(()=>portal.eval('document.getElementById("logout")?.hidden===true'),'customer logout');
let stored; let stored;
await waitFor(async()=>{stored=await portal.eval(`(async()=>{try{const db=await new Promise((resolve,reject)=>{const r=indexedDB.open('dtf-local-cart',1);r.onupgradeneeded=()=>r.result.createObjectStore('cart');r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(r.error);});const n=await new Promise((resolve,reject)=>{const r=db.transaction('cart').objectStore('cart').count();r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(r.error);});db.close();return n;}catch{return -1;}})()`);return stored>=0;},'IndexedDB available after Clear-Site-Data'); await waitFor(async()=>{stored=await portal.eval(`(async()=>{try{const db=await new Promise((resolve,reject)=>{const r=indexedDB.open('dtf-local-cart',1);r.onupgradeneeded=()=>r.result.createObjectStore('cart');r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(r.error);});const n=await new Promise((resolve,reject)=>{const r=db.transaction('cart').objectStore('cart').count();r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(r.error);});db.close();return n;}catch{return -1;}})()`);return stored>=0;},'IndexedDB available after Clear-Site-Data');
assert.equal(stored,0); assert.equal(stored,0);

View File

@@ -12,7 +12,7 @@ from urllib.error import HTTPError
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
from uuid import uuid4 from uuid import uuid4
from tests.smoke_test import BASE, Client, upload_bytes, with_host from tests.smoke_test import BASE, Client, upload_bytes, item_spec, with_host
SECRET = os.environ.get('PAYMENT_WEBHOOK_SECRET', 'local-webhook-secret').encode() SECRET = os.environ.get('PAYMENT_WEBHOOK_SECRET', 'local-webhook-secret').encode()
@@ -37,7 +37,7 @@ def reviewed_quote():
customer = Client() customer = Client()
customer.call('/session') customer.call('/session')
uid = upload_bytes(customer, b'PAYMENT WEBHOOK TEST') uid = upload_bytes(customer, b'PAYMENT WEBHOOK TEST')
item = {'mode': 'file', 'metres': '1.01', 'grade': 0, 'uploads': [uid]} item = item_spec('file', '1.01', 0, uid)
profile = {'cnpj': '11222333000181', 'zap': '11999999999', profile = {'cnpj': '11222333000181', 'zap': '11999999999',
'mail': 'payment-' + uuid4().hex[:8] + '@example.test'} 'mail': 'payment-' + uuid4().hex[:8] + '@example.test'}
quote = customer.call('/quotes', {'request_key': str(uuid4()), 'customer': profile, quote = customer.call('/quotes', {'request_key': str(uuid4()), 'customer': profile,
@@ -62,7 +62,13 @@ def run():
deliver({'event_id': 'short-' + uuid4().hex, 'reference': quote_id, deliver({'event_id': 'short-' + uuid4().hex, 'reference': quote_id,
'status': 'approved', 'amount_cents': total - 100}) 'status': 'approved', 'amount_cents': total - 100})
assert not customer.call('/quotes/' + quote_id)['order'], 'underpayment created an order' assert not customer.call('/quotes/' + quote_id)['order'], 'underpayment created an order'
print('PASS: an amount that disagrees with the reviewed quote is refused') deliver({'event_id': 'missing-amount-' + uuid4().hex, 'reference': quote_id,
'status': 'approved'})
assert not customer.call('/quotes/' + quote_id)['order'], 'missing paid amount created an order'
deliver({'event_id': 'invalid-amount-' + uuid4().hex, 'reference': quote_id,
'status': 'approved', 'amount_cents': str(total)})
assert not customer.call('/quotes/' + quote_id)['order'], 'non-integer paid amount created an order'
print('PASS: a missing, invalid or mismatched paid amount is refused')
# The real thing, then the same delivery again, and a second event for the # The real thing, then the same delivery again, and a second event for the
# same quote: a provider does all three. # same quote: a provider does all three.
@@ -81,6 +87,12 @@ def run():
assert customer.call('/quotes/' + quote_id)['order']['id'] == order['id'], 'a second order appeared' assert customer.call('/quotes/' + quote_id)['order']['id'] == order['id'], 'a second order appeared'
print('PASS: one order from a repeated and re-sent approval') print('PASS: one order from a repeated and re-sent approval')
assert customer.call('/orders/dev-paid', {'quote_id': quote_id})['id'] == order['id']
other = Client()
other.call('/session')
other.call('/orders/dev-paid', {'quote_id': quote_id}, expected=404)
print('PASS: another customer cannot retrieve the paid order by quote id')
# The customer is told once, not once per delivery. # The customer is told once, not once per delivery.
board = Client() board = Client()
events = board.call('/operator/board', operator=True)['events'] events = board.call('/operator/board', operator=True)['events']

View File

@@ -0,0 +1,60 @@
"""The 101st pending quote and older approved quotes remain reachable on the board."""
from uuid import uuid4
from urllib.parse import urlencode
from psycopg.types.json import Jsonb
from app.core import db
from tests.smoke_test import Client
def run():
owner = uuid4()
pending_ids = [uuid4() for _ in range(105)]
approved_ids = [uuid4() for _ in range(22)]
created = pending_ids + approved_ids
draft = {'customer': {'mail': 'pagination-fixture@example.test'},
'items': [], 'freight': {'service': 'pickup'}}
try:
with db.connect() as c:
for uid in pending_ids:
c.execute('''INSERT INTO dtf_local.quotes
(id,owner,request_key,request_hash,draft,created_at)
VALUES(%s,%s,%s,%s,%s,now()+interval '1 hour')''',
(uid, owner, uuid4(), 'pagination-fixture', Jsonb(draft)))
for uid in approved_ids:
c.execute('''INSERT INTO dtf_local.quotes
(id,owner,request_key,request_hash,draft,approved,approved_at,created_at)
VALUES(%s,%s,%s,%s,%s,%s,now(),now()+interval '1 hour')''',
(uid, owner, uuid4(), 'pagination-fixture', Jsonb(draft),
Jsonb({'items': [], 'total_cents': 0})))
client = Client()
board = client.call('/operator/board', operator=True)
assert board['pending_total'] >= 105
assert board['approved_total'] >= 22
for kind, fixture_ids in [('pending', pending_ids), ('approved', approved_ids)]:
first = [q for q in board['quotes'] if (q['approved'] is None) == (kind == 'pending')]
seen = {q['id'] for q in first}
assert len(first) == (100 if kind == 'pending' else 20)
last = first[-1]
for _ in range(5):
path = '/operator/quotes?' + urlencode({
'kind': kind, 'limit': 50,
'before_created_at': last['created_at'], 'before_id': last['id']})
page = client.call(path, operator=True)
assert page['quotes'], 'An older quote page disappeared'
assert not seen.intersection(q['id'] for q in page['quotes']), 'Quote page repeated rows'
seen.update(q['id'] for q in page['quotes'])
if set(map(str, fixture_ids)) <= seen:
break
last = page['quotes'][-1]
assert set(map(str, fixture_ids)) <= seen, f'{kind} quotes were hidden by the board limit'
print('PASS: 105 pending and 22 approved quotes remain reachable across board pages')
finally:
with db.connect() as c:
c.execute('DELETE FROM dtf_local.quotes WHERE id=ANY(%s)', (created,))
if __name__ == '__main__':
run()

View File

@@ -1,6 +1,6 @@
"""Harmless EICAR anti-malware test and blocked download/quote regressions.""" """Harmless EICAR anti-malware test and blocked download/quote regressions."""
from uuid import uuid4 from uuid import uuid4
from tests.smoke_test import Client, upload_bytes from tests.smoke_test import Client, upload_bytes, item_spec
def run(): def run():
customer=Client();customer.call('/session') customer=Client();customer.call('/session')
@@ -9,7 +9,7 @@ def run():
uid=upload_bytes(customer,marker,name='SECURITY-EICAR.cdr',expected_scan='rejected') uid=upload_bytes(customer,marker,name='SECURITY-EICAR.cdr',expected_scan='rejected')
customer.call('/operator/uploads/'+uid+'/download',operator=True,expected=409) customer.call('/operator/uploads/'+uid+'/download',operator=True,expected=409)
customer.call('/quotes',{'request_key':str(uuid4()),'customer':{'cnpj':'11222333000181','zap':'11999999999','mail':'security@example.test'}, customer.call('/quotes',{'request_key':str(uuid4()),'customer':{'cnpj':'11222333000181','zap':'11999999999','mail':'security@example.test'},
'items':[{'mode':'file','metres':'1','grade':0,'uploads':[uid]}],'freight':{'service':'pickup'}},expected=409) 'items':[item_spec('file','1',0,uid)],'freight':{'service':'pickup'}},expected=409)
clean=upload_bytes(customer,b'Harmless local artwork fixture',name='SECURITY-CLEAN.cdr') clean=upload_bytes(customer,b'Harmless local artwork fixture',name='SECURITY-CLEAN.cdr')
customer.call('/operator/uploads/'+clean+'/download',operator=True) customer.call('/operator/uploads/'+clean+'/download',operator=True)
print('PASS: real ClamAV detects EICAR; rejected artwork cannot be downloaded or quoted; clean artwork is released.') print('PASS: real ClamAV detects EICAR; rejected artwork cannot be downloaded or quoted; clean artwork is released.')

View File

@@ -37,14 +37,15 @@ class Client:
self.jar=http.cookiejar.CookieJar() self.jar=http.cookiejar.CookieJar()
self.opener=build_opener(HTTPCookieProcessor(self.jar)) self.opener=build_opener(HTTPCookieProcessor(self.jar))
self.operator_client=None self.operator_client=None
def call(self,path,body=None,operator=False,expected=200): def call(self,path,body=None,operator=False,expected=200,method=None):
headers=with_host({'Content-Type':'application/json'}) headers=with_host({'Content-Type':'application/json'})
if operator: if operator:
if self.operator_client is None: if self.operator_client is None:
self.operator_client=Client() self.operator_client=Client()
self.operator_client.call('/operator/login',{'email':os.environ.get('OPERATOR_EMAIL','operator@example.test'),'password':os.environ.get('OPERATOR_PASSWORD','local-operator-only')}) self.operator_client.call('/operator/login',{'email':os.environ.get('OPERATOR_EMAIL','operator@example.test'),'password':os.environ.get('OPERATOR_PASSWORD','local-operator-only')})
return self.operator_client.call(path,body,expected=expected) return self.operator_client.call(path,body,expected=expected,method=method)
request=Request(BASE+'/api'+path,data=None if body is None else json.dumps(body).encode(),headers=headers) request=Request(BASE+'/api'+path,data=None if body is None else json.dumps(body).encode(),
headers=headers,method=method)
try: try:
with self.opener.open(request,timeout=30) as response: with self.opener.open(request,timeout=30) as response:
assert response.status==expected,(path,response.status,expected) assert response.status==expected,(path,response.status,expected)
@@ -78,10 +79,32 @@ def upload_bytes(client, content, name='LOCAL-TEST.cdr', order_id=None, expected
wait_scan(client,uid,operator,expected_scan) wait_scan(client,uid,operator,expected_scan)
return uid return uid
def item_spec(mode, metres, grade, uid):
film_width=28.5 if mode in ('uvfile','uv') else 57
length_cm=float(metres)*100
return {'mode':mode,'metres':str(metres),'grade':grade,'uploads':[uid],
'production':{'version':2,'film_width_cm':film_width,'height_cm':length_cm,
'sources':[{'upload_id':uid,
'kind':'sheet' if mode in ('file','uvfile') else 'artwork',
'width_cm':film_width,'length_cm':length_cm,'copies':1,
'rotation_degrees':0,'mirrored':False,'measurement':'customer'}],
'placements':[{'source_index':0,'copy_index':0,'x_cm':0,'y_cm':0,
'width_cm':film_width,'length_cm':length_cm,
'rotation_degrees':0,'mirrored':False}]},
'quality_status':'unverified' if grade==0 else 'ok',
'quality_acknowledged':False}
def run(): def run():
client=Client();other=Client() client=Client();other=Client()
config=client.call('/session');other.call('/session') config=client.call('/session');other.call('/session')
assert client.call('/health')['integrations']=='fake' assert client.call('/health')['integrations']=='fake'
assert 0 < config['max_upload_bytes'] <= 128 * 1024 * 1024
client.call('/uploads',{'name':'too-large.cdr',
'size':config['max_upload_bytes']+1},expected=413)
cancelled=client.call('/uploads',{'name':'CANCELLED-PART.cdr','size':3})['id']
other.call('/uploads/'+cancelled,expected=404,method='DELETE')
assert client.call('/uploads/'+cancelled,method='DELETE')['cancelled']
client.call('/uploads/'+cancelled,expected=410)
client.call('/operator/board',expected=401) client.call('/operator/board',expected=401)
block=config['part_bytes'];content=b'DTF local multipart test\n'+b'x'*block block=config['part_bytes'];content=b'DTF local multipart test\n'+b'x'*block
uid=client.call('/uploads',{'name':'LOCAL-SMOKE-ONLY.cdr','size':len(content)})['id'] uid=client.call('/uploads',{'name':'LOCAL-SMOKE-ONLY.cdr','size':len(content)})['id']
@@ -104,10 +127,15 @@ def run():
except HTTPError as exc:assert exc.code==403 except HTTPError as exc:assert exc.code==403
print('PASS: multipart resume, incomplete rejection, immutable completion, ownership, private/downloaded bytes') print('PASS: multipart resume, incomplete rejection, immutable completion, ownership, private/downloaded bytes')
items=[{'mode':m,'metres':'2.75','grade':90,'uploads':[uid]} for m in ('file','avulsa','uvfile','uv')] items=[item_spec(m,'2.75',90,uid) for m in ('file','avulsa','uvfile','uv')]
draft={'request_key':str(uuid4()),'customer':{'cnpj':'11222333000181','zap':'11999999999','mail':'local-smoke@example.test'}, draft={'request_key':str(uuid4()),'customer':{'cnpj':'11222333000181','zap':'11999999999','mail':'local-smoke@example.test'},
'items':items,'freight':{'service':'mock-standard','postal_code':'14400000'}} 'items':items,'freight':{'service':'mock-standard','postal_code':'14400000'}}
client.call('/quotes',{**draft,'total_cents':1},expected=422) client.call('/quotes',{**draft,'total_cents':1},expected=422)
client.call('/quotes',{**draft,'items':[{k:v for k,v in items[0].items() if k!='production'}]},expected=422)
outside={**items[0],'production':{**items[0]['production'],
'placements':[{**items[0]['production']['placements'][0],'x_cm':1}]}}
client.call('/quotes',{**draft,'items':[outside]},expected=422)
client.call('/quotes',{**draft,'items':[{**items[0],'metres':'1.00'}]},expected=422)
client.call('/quotes',{**draft,'customer':{**draft['customer'],'cnpj':'11111111111111'}},expected=422) client.call('/quotes',{**draft,'customer':{**draft['customer'],'cnpj':'11111111111111'}},expected=422)
quote=client.call('/quotes',draft) quote=client.call('/quotes',draft)
assert client.call('/quotes',draft)['id']==quote['id'] assert client.call('/quotes',draft)['id']==quote['id']
@@ -116,6 +144,9 @@ def run():
other.call('/quotes/'+qid,expected=404) other.call('/quotes/'+qid,expected=404)
client.call('/orders/dev-paid',{'quote_id':qid},expected=409) client.call('/orders/dev-paid',{'quote_id':qid},expected=409)
client.call('/operator/quotes/'+qid+'/approve',{'items':items},expected=401) client.call('/operator/quotes/'+qid+'/approve',{'items':items},expected=401)
altered={**items[0],'production':{**items[0]['production'],
'sources':[{**items[0]['production']['sources'][0],'measurement':'file'}]}}
client.call('/operator/quotes/'+qid+'/approve',{'items':[altered,*items[1:]]},operator=True,expected=422)
# Reviewer corrects a browser-supplied grade and length. Browser values are proposals. # Reviewer corrects a browser-supplied grade and length. Browser values are proposals.
corrected=[{**items[0],'metres':'1.01','grade':0},*items[1:]] corrected=[{**items[0],'metres':'1.01','grade':0},*items[1:]]
approved=client.call('/operator/quotes/'+qid+'/approve',{'items':corrected},operator=True) approved=client.call('/operator/quotes/'+qid+'/approve',{'items':corrected},operator=True)

View File

@@ -1,12 +1,12 @@
"""Customer identity, correction and final-file trust boundaries against local stack.""" """Customer identity, correction and final-file trust boundaries against local stack."""
from uuid import uuid4 from uuid import uuid4
from urllib.request import urlopen from urllib.request import urlopen
from tests.smoke_test import Client, upload_bytes from tests.smoke_test import Client, upload_bytes, item_spec
def run(): def run():
customer=Client();other=Client();customer.call('/session');other.call('/session') customer=Client();other=Client();customer.call('/session');other.call('/session')
uid=upload_bytes(customer,b'LOCAL ORIGINAL ONLY') uid=upload_bytes(customer,b'LOCAL ORIGINAL ONLY')
item={'mode':'file','metres':'1.01','grade':0,'uploads':[uid]} item=item_spec('file','1.01',0,uid)
profile={'cnpj':'11222333000181','zap':'11999999999','mail':'workflow-'+uuid4().hex[:8]+'@example.test'} profile={'cnpj':'11222333000181','zap':'11999999999','mail':'workflow-'+uuid4().hex[:8]+'@example.test'}
q=customer.call('/quotes',{'request_key':str(uuid4()),'customer':profile,'items':[item],'freight':{'service':'pickup'}}) q=customer.call('/quotes',{'request_key':str(uuid4()),'customer':profile,'items':[item],'freight':{'service':'pickup'}})
customer.call('/operator/quotes/'+q['id']+'/approve',{'items':[item]},operator=True) customer.call('/operator/quotes/'+q['id']+'/approve',{'items':[item]},operator=True)
@@ -47,6 +47,10 @@ def run():
guest=Client();guest.call('/session');guest.call('/customer/orders/'+oid+'/files/'+final['id']+'/download',expected=404) guest=Client();guest.call('/session');guest.call('/customer/orders/'+oid+'/files/'+final['id']+'/download',expected=404)
version=move('fil',version);version=move('imp',version);version=move('cor',version) version=move('fil',version);version=move('imp',version);version=move('cor',version)
customer.call('/customer/orders/'+oid+'/files/'+final['id']+'/download',expected=404) customer.call('/customer/orders/'+oid+'/files/'+final['id']+'/download',expected=404)
premature_final=upload_bytes(customer,b'FINAL BEFORE CUSTOMER CORRECTION',order_id=oid)
version=customer.call('/operator/orders/'+oid+'/final-files',
{'version':version,'files':[{'item_index':0,'upload_id':premature_final}],
'note':'Prepared before customer sent the new correction'},operator=True)['version']
correction_id=upload_bytes(customer,b'LOCAL CORRECTED ORIGINAL') correction_id=upload_bytes(customer,b'LOCAL CORRECTED ORIGINAL')
payload={'version':version,'files':[{'item_index':0,'upload_id':correction_id}],'note':'Replaced the artwork as requested'} payload={'version':version,'files':[{'item_index':0,'upload_id':correction_id}],'note':'Replaced the artwork as requested'}
guest.call('/customer/orders/'+oid+'/corrections',payload,expected=404) guest.call('/customer/orders/'+oid+'/corrections',payload,expected=404)
@@ -54,6 +58,7 @@ def run():
version=customer.call('/customer/orders/'+oid+'/corrections',payload)['version'] version=customer.call('/customer/orders/'+oid+'/corrections',payload)['version']
files=customer.call('/operator/orders/'+oid+'/files',operator=True) files=customer.call('/operator/orders/'+oid+'/files',operator=True)
assert any(f['kind']=='correction' and f['active'] and f['upload_id']==correction_id for f in files) assert any(f['kind']=='correction' and f['active'] and f['upload_id']==correction_id for f in files)
assert not any(f['kind']=='final' and f['active'] for f in files), 'new correction kept a stale final active'
version=move('tra',version) version=move('tra',version)
customer.call('/operator/orders/'+oid+'/move',{'state':'fil','version':version},operator=True,expected=409) customer.call('/operator/orders/'+oid+'/move',{'state':'fil','version':version},operator=True,expected=409)
new_final=upload_bytes(customer,b'LOCAL FINAL VERSION TWO',order_id=oid) new_final=upload_bytes(customer,b'LOCAL FINAL VERSION TWO',order_id=oid)

View File

@@ -6,6 +6,23 @@
let draftId = localStorage.getItem('dtf-quote'); let draftId = localStorage.getItem('dtf-quote');
let requestKey = localStorage.getItem('dtf-request-key'); let requestKey = localStorage.getItem('dtf-request-key');
let requestBody = localStorage.getItem('dtf-request-body'); let requestBody = localStorage.getItem('dtf-request-body');
let quotedCart = localStorage.getItem('dtf-quote-cart');
let refreshVersion = 0;
function cartSnapshot() {
const items=[...pedido,...(itemAtual?[itemAtual]:[])];
return JSON.stringify({customer:cliente,delivery:entrega,items:items.map(item=>({
mode:item.modo,metres:item.metros,grade:item.nota,production:item.production,
quality:item.qualityStatus,
acknowledged:item.qualityAcknowledged,
files:(item.localFiles||[]).map(file=>({name:file.name,size:file.size,lastModified:file.lastModified}))
}))});
}
function clearDraft() {
draftId=null;quotedCart=null;requestKey=null;requestBody=null;
for(const key of ['dtf-quote','dtf-quote-cart','dtf-request-key','dtf-request-body'])
localStorage.removeItem(key);
actions.replaceChildren();
}
const api = async (path, body) => { const api = async (path, body) => {
const response = await fetch('/api'+path, { const response = await fetch('/api'+path, {
credentials: 'same-origin', headers: {'Content-Type':'application/json'}, credentials: 'same-origin', headers: {'Content-Type':'application/json'},
@@ -19,6 +36,12 @@
return data; return data;
}; };
const ready = api('/session'); const ready = api('/session');
ready.then(session=>{
window.dtfUploadMaxBytes=session.max_upload_bytes;
const limit=document.getElementById('zLimite');
if(limit)limit.textContent='Até '+(session.max_upload_bytes/1048576).toFixed(0)+
' MB por arquivo enquanto a verificação de segurança para arquivos grandes é preparada.';
}).catch(()=>{});
window.dtfSessionReady=ready; window.dtfSessionReady=ready;
window.dtfApi=api; window.dtfApi=api;
ready.catch(error => { status.textContent = error.message; }); ready.catch(error => { status.textContent = error.message; });
@@ -50,8 +73,10 @@
if (busy) return; if (busy) return;
if (draftId) { await refresh(); status.scrollIntoView({behavior:'smooth',block:'nearest'}); return; } if (draftId) { await refresh(); status.scrollIntoView({behavior:'smooth',block:'nearest'}); return; }
if (!clienteOk() || !entrega.cotado) return; if (!clienteOk() || !entrega.cotado) return;
if (!cartPodeEnviar()) return message('Revise a qualidade e confirme a ressalva de cada item antes de enviar o pedido.');
const cart = [...pedido,...(itemAtual?[itemAtual]:[])]; const cart = [...pedido,...(itemAtual?[itemAtual]:[])];
if (!cart.length) return message('Adicione um item ao pedido.'); if (!cart.length) return message('Adicione um item ao pedido.');
const initialCart=cartSnapshot();
busy = true; busy = true;
$('bPagar').disabled = true; $('bPagar').disabled = true;
try { try {
@@ -62,9 +87,15 @@
if (!item.localFiles?.length) throw new Error('Selecione novamente os arquivos deste item.'); if (!item.localFiles?.length) throw new Error('Selecione novamente os arquivos deste item.');
const uploads=[]; const uploads=[];
for (const file of item.localFiles) uploads.push(await upload(file)); for (const file of item.localFiles) uploads.push(await upload(file));
items.push({mode:item.modo, metres:String(item.metros), grade:item.nota, uploads}); if (item.production?.version!==2 || item.production.sources?.length!==uploads.length)
throw new Error('A montagem deste item precisa ser refeita antes da cotação.');
items.push({mode:item.modo, metres:String(item.metros), grade:item.nota, uploads,
production:{...item.production,sources:item.production.sources.map((source,index)=>({
upload_id:uploads[index],...source}))},
quality_status:item.qualityStatus,quality_acknowledged:item.qualityAcknowledged});
} }
const content = {customer:{...cliente},items,freight:{service:entrega.tipo==='retira'?'pickup':'mock-standard',postal_code:entrega.tipo==='retira'?'':entrega.cep}}; const content = {customer:{...cliente},items,freight:{service:entrega.tipo==='retira'?'pickup':'mock-standard',postal_code:entrega.tipo==='retira'?'':entrega.cep}};
if (cartSnapshot()!==initialCart) throw new Error('O carrinho mudou durante o envio. Confira os itens e envie de novo.');
const serialized = JSON.stringify(content); const serialized = JSON.stringify(content);
if (!requestKey || serialized !== requestBody) { if (!requestKey || serialized !== requestBody) {
requestKey = crypto.randomUUID(); requestBody = serialized; requestKey = crypto.randomUUID(); requestBody = serialized;
@@ -72,6 +103,7 @@
localStorage.setItem('dtf-request-body',requestBody); localStorage.setItem('dtf-request-body',requestBody);
} }
const quote = await api('/quotes',{request_key:requestKey,...content}); const quote = await api('/quotes',{request_key:requestKey,...content});
quotedCart=initialCart;localStorage.setItem('dtf-quote-cart',quotedCart);
draftId=quote.id; localStorage.setItem('dtf-quote',draftId); draftId=quote.id; localStorage.setItem('dtf-quote',draftId);
await refresh(); await refresh();
status.scrollIntoView({behavior:'smooth',block:'nearest'}); status.scrollIntoView({behavior:'smooth',block:'nearest'});
@@ -80,10 +112,17 @@
}; };
async function refresh() { async function refresh() {
if (!draftId) return; if (!draftId) return;
const version=++refreshVersion, shownId=draftId;
try { try {
await ready; await ready;
const quote=await api('/quotes/'+draftId); const quote=await api('/quotes/'+shownId);
if(version!==refreshVersion || draftId!==shownId) return;
actions.replaceChildren(); actions.replaceChildren();
if (quote.status!=='paid' && (!quotedCart || quotedCart!==cartSnapshot())) {
message('O carrinho mudou ou não está disponível neste navegador. A cotação anterior continua separada; envie o carrinho atual para uma nova revisão.');
button('Enviar carrinho atual',()=>{clearDraft();window.dtfCheckout();});
return;
}
if (quote.status==='pending_review') { if (quote.status==='pending_review') {
message('Arquivos enviados. No Kanban, confira metragem e nota e aprove a cotação '+draftId.slice(0,8)+'.'); message('Arquivos enviados. No Kanban, confira metragem e nota e aprove a cotação '+draftId.slice(0,8)+'.');
} else if (quote.status==='approved') { } else if (quote.status==='approved') {
@@ -93,6 +132,7 @@
return; return;
} }
button('Criar pedido de teste',async event=>{ button('Criar pedido de teste',async event=>{
if (!quotedCart || quotedCart!==cartSnapshot()) { await refresh(); return; }
event.target.disabled=true; event.target.disabled=true;
try { try {
const order=await api('/orders/dev-paid',{quote_id:draftId}); const order=await api('/orders/dev-paid',{quote_id:draftId});
@@ -104,18 +144,23 @@
}); });
} else if (quote.status==='paid') { } else if (quote.status==='paid') {
message('Pedido #'+quote.order.number+' · etapa: '+quote.order.state+'.'); 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();}); button('Novo pedido',()=>{clearDraft();location.reload();});
} else { } else {
message('Cotação expirada. Envie o carrinho para uma nova revisão.'); message('Cotação expirada. Envie o carrinho para uma nova revisão.');
button('Nova cotação',()=>{draftId=null;requestKey=null;localStorage.removeItem('dtf-quote');localStorage.removeItem('dtf-request-key');actions.replaceChildren();}); button('Nova cotação',clearDraft);
} }
} catch(error) { } catch(error) {
if(version!==refreshVersion || draftId!==shownId) return;
message(error.message); message(error.message);
actions.replaceChildren(); 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();}); button('Limpar referência e tentar de novo',clearDraft);
} }
} }
const quoteFromPortal=new URLSearchParams(location.search).get('quote'); const quoteFromPortal=new URLSearchParams(location.search).get('quote');
if(quoteFromPortal && /^[0-9a-f-]{36}$/.test(quoteFromPortal)){draftId=quoteFromPortal;localStorage.setItem('dtf-quote',draftId);} if(quoteFromPortal && /^[0-9a-f-]{36}$/.test(quoteFromPortal)){
if(draftId!==quoteFromPortal){quotedCart=null;localStorage.removeItem('dtf-quote-cart');}
draftId=quoteFromPortal;localStorage.setItem('dtf-quote',draftId);
}
window.addEventListener('dtf-cart-changed',()=>{if(draftId) refresh();});
refresh(); refresh();
})(); })();

View File

@@ -1035,6 +1035,7 @@ footer a:hover{color:var(--laranja2)}
<div class="zona" id="zona" tabindex="0" role="button"> <div class="zona" id="zona" tabindex="0" role="button">
<div class="ico">↑</div> <div class="ico">↑</div>
<b id="zTit">Arraste aqui</b><span id="zSub"></span> <b id="zTit">Arraste aqui</b><span id="zSub"></span>
<span id="zLimite">Até 128 MB por arquivo enquanto a verificação de segurança para arquivos grandes é preparada.</span>
<span class="bt">ou escolher no computador</span> <span class="bt">ou escolher no computador</span>
</div> </div>
<div class="recusa" id="recusa" style="display:none"></div> <div class="recusa" id="recusa" style="display:none"></div>
@@ -1098,9 +1099,8 @@ footer a:hover{color:var(--laranja2)}
<li class="s"><b>PDF é o que conferimos melhor.</b> Nele medimos a resolução de <li class="s"><b>PDF é o que conferimos melhor.</b> Nele medimos a resolução de
cada imagem dentro da folha, uma por uma. Se a sua arte é boa, é onde a nota cada imagem dentro da folha, uma por uma. Se a sua arte é boa, é onde a nota
aparece com mais precisão</li> aparece com mais precisão</li>
<li class="a"><b>PDF só até 5 m.</b> O limite é do próprio formato: a página do PDF <li class="a"><b>PDF grande.</b> Sem UserUnit, a página passa do limite de 508 cm
para em 508 cm. Acima disso o arquivo sai fora do padrão e só abre certo em quem do formato; divida a folha ou mande PNG. Envie um PDF de uma página por folha.</li>
o gerou — divida em partes ou mande PNG</li>
<li class="a"><b>TIFF, PSD, AI e CDR</b> a gente aceita, mas ninguém consegue <li class="a"><b>TIFF, PSD, AI e CDR</b> a gente aceita, mas ninguém consegue
conferir sozinho: alguém abre na mão, sai mais devagar e o metro vai pela conferir sozinho: alguém abre na mão, sai mais devagar e o metro vai pela
tabela, sem desconto de nota</li> tabela, sem desconto de nota</li>

View File

@@ -3,6 +3,8 @@ const $ = id=>document.getElementById(id);
// Remove credentials saved by older local builds. Only HttpOnly sessions now. // Remove credentials saved by older local builds. Only HttpOnly sessions now.
sessionStorage.removeItem('dtf-operator'); sessionStorage.removeItem('dtf-operator');
let board; let board;
let extraQuotes={pending:[],approved:[]};
let moreQuotes={pending:false,approved:false};
const money=cents=>(cents/100).toLocaleString('pt-BR',{style:'currency',currency:'BRL'}); const money=cents=>(cents/100).toLocaleString('pt-BR',{style:'currency',currency:'BRL'});
function node(tag,text,className){const e=document.createElement(tag);if(text!==undefined)e.textContent=text;if(className)e.className=className;return e;} 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;} 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;}
@@ -16,25 +18,67 @@ function files(container,items){
const link=node('a');link.href=result.url;link.referrerPolicy='no-referrer';link.download=result.name;link.click(); const link=node('a');link.href=result.url;link.referrerPolicy='no-referrer';link.download=result.name;link.click();
})); }));
} }
function productionLines(container,item){
if(!item.production){container.append(node('p','Instruções de produção ausentes; não produzir este item.','meta'));return;}
container.append(node('p','Especificação v'+item.production.version+' · qualidade '+item.quality_status+
(item.quality_status==='warning'?' · ressalva '+(item.quality_acknowledged?'aceita':'não aceita'):''),'meta'));
if(item.production.placements){
container.append(node('p',item.production.placements.length+' peças posicionadas em '+
item.production.film_width_cm+' × '+item.production.height_cm+' cm de filme','meta'));
const download=node('button','Baixar manifesto da montagem');
download.type='button';
download.onclick=()=>{
const url=URL.createObjectURL(new Blob([JSON.stringify(item.production,null,2)],{type:'application/json'}));
const link=node('a');link.href=url;
link.download='dtf-layout-'+item.production.sources[0].upload_id.slice(0,8)+'.json';
link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);
};
container.append(download);
}
item.production.sources.forEach((source,index)=>container.append(node('p',
(index+1)+'. '+source.kind+' · '+source.copies+' × '+source.width_cm+' × '+source.length_cm+
' cm · giro '+source.rotation_degrees+'°'+(source.mirrored?' · espelhada':'')+
' · medida '+source.measurement+' · arquivo '+source.upload_id.slice(0,8),'meta')));
}
async function load(){ async function load(){
try{ try{
board=await api('/board');$('login').hidden=true; board=await api('/board');
extraQuotes={pending:[],approved:[]};
moreQuotes={pending:board.pending_total>board.quotes.filter(q=>!q.approved).length,
approved:board.approved_total>board.quotes.filter(q=>!!q.approved).length};
$('login').hidden=true;
$('status').textContent='Atualizado às '+new Date().toLocaleTimeString(); $('status').textContent='Atualizado às '+new Date().toLocaleTimeString();
render(); render();
}catch(e){$('status').textContent=e.message;$('login').hidden=false;} }catch(e){$('status').textContent=e.message;$('login').hidden=false;}
} }
async function loadMoreQuotes(kind){
const shown=[...board.quotes.filter(q=>kind==='pending'?!q.approved:!!q.approved),...extraQuotes[kind]];
const last=shown.at(-1);
const params=new URLSearchParams({kind,limit:'50'});
if(last){params.set('before_created_at',last.created_at);params.set('before_id',last.id);}
const page=await api('/quotes?'+params);
const known=new Set(shown.map(q=>q.id));
extraQuotes[kind].push(...page.quotes.filter(q=>!known.has(q.id)));
moreQuotes[kind]=page.has_more;
render();
}
function render(){ function render(){
$('reviews').replaceChildren(); $('reviews').replaceChildren();
if(board.quotes.length)$('reviews').append(node('h2','Cotações · conferência comercial')); if(board.pending_total || board.approved_total)
for(const quote of board.quotes){ $('reviews').append(node('h2','Cotações · '+board.pending_total+' pendentes · '+
board.approved_total+' aprovadas sem pedido'));
for(const quote of [...board.quotes,...extraQuotes.pending,...extraQuotes.approved]){
const card=node('article',undefined,'review'); const card=node('article',undefined,'review');
card.append(node('b',quote.id.slice(0,8)+' · '+quote.draft.customer.mail)); card.append(node('b',quote.id.slice(0,8)+' · '+quote.draft.customer.mail));
if(quote.status!=='pending_review'){ if(quote.status==='pending_review' && quote.draft.items.some(item=>item.production?.version!==2)){
card.append(node('p','Cotação com montagem antiga. Peça ao cliente para enviar uma nova cotação.'));
}else if(quote.status!=='pending_review'){
card.append(node('p',quote.status==='expired'?'Cotação expirada. O cliente precisa solicitar outra.':'Aprovada: '+money(quote.approved.total_cents)+'. Volte ao Site, atualize o pedido e confirme o pagamento local.')); 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{ }else{
const form=node('form'); const form=node('form');
const edits=quote.draft.items.map((item,index)=>{ const edits=quote.draft.items.map((item,index)=>{
const row=node('div');row.append(node('strong',(index+1)+'. '+item.mode+' ')); const row=node('div');row.append(node('strong',(index+1)+'. '+item.mode+' '));
productionLines(row,item);
const metres=node('input');metres.type='number';metres.min='0.001';metres.max='12000';metres.step='any';metres.value=item.metres;metres.required=true; const 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 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); 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);
@@ -47,6 +91,10 @@ function render(){
} }
const downloads=node('div',undefined,'actions');files(downloads,quote.draft.items);card.append(downloads);$('reviews').append(card); const downloads=node('div',undefined,'actions');files(downloads,quote.draft.items);card.append(downloads);$('reviews').append(card);
} }
for(const [kind,label] of [['pending','Carregar cotações pendentes mais antigas'],
['approved','Carregar cotações aprovadas mais antigas']]){
if(moreQuotes[kind])$('reviews').append(action(label,()=>loadMoreQuotes(kind)));
}
$('kan').replaceChildren(); $('kan').replaceChildren();
const colors=['#00b8da','#e0357c','#efb700','#edebe6','#e0642a','#48b072']; const colors=['#00b8da','#e0357c','#efb700','#edebe6','#e0642a','#48b072'];
Object.entries(board.states).forEach(([state,title],index)=>{ Object.entries(board.states).forEach(([state,title],index)=>{
@@ -60,7 +108,10 @@ function render(){
for(const order of orders){ for(const order of orders){
const card=node('article',undefined,'cd');card.draggable=true;card.dataset.order=order.id; 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))); 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')); for(const item of order.snapshot.items){
card.append(node('p',item.mode+' · '+item.billed_metres+' m · nota '+item.grade,'meta'));
productionLines(card,item);
}
const actions=node('div',undefined,'actions');files(actions,order.snapshot.items); const actions=node('div',undefined,'actions');files(actions,order.snapshot.items);
const artwork=node('div'); const artwork=node('div');
actions.append(action('Arquivos de produção',()=>artworkPanel(order,artwork))); actions.append(action('Arquivos de produção',()=>artworkPanel(order,artwork)));

View File

@@ -4,7 +4,23 @@
scope and run top to bottom, exactly as the original did. */ scope and run top to bottom, exactly as the original did. */
// ── carrinho // ── carrinho
let itemAtual=null; let itemAtual=null;
function invalidaItemAtual(){
if(!itemAtual) return;
itemAtual=null;
$('atual').style.display='none';
$('carrLin').innerHTML='';
pintaPedido();
}
function itemPodeEnviar(item){
return item.qualityStatus==='ok' || item.qualityStatus==='unverified' ||
(item.qualityStatus==='warning' && item.qualityAcknowledged===true);
}
function cartPodeEnviar(){
const items=[...pedido,...(itemAtual?[itemAtual]:[])];
return items.length>0 && items.every(itemPodeEnviar);
}
function limpaPaineis(){ function limpaPaineis(){
invalidaItemAtual();
['qual','prev'].forEach(id=>$(id).classList.remove('on')); ['qual','prev'].forEach(id=>$(id).classList.remove('on'));
if(!pedido.length){ $('carr').classList.remove('on'); } if(!pedido.length){ $('carr').classList.remove('on'); }
} }
@@ -37,6 +53,7 @@ function pintaPedido(){
$('bPagar').textContent = (pedido.length + (itemAtual?1:0))>1 $('bPagar').textContent = (pedido.length + (itemAtual?1:0))>1
? 'Pagar os '+(pedido.length+(itemAtual?1:0))+' itens de uma vez' ? 'Pagar os '+(pedido.length+(itemAtual?1:0))+' itens de uma vez'
: 'Ir para o pagamento'; : 'Ir para o pagamento';
$('bPagar').disabled = !cartPodeEnviar() || !entrega.cotado || !clienteOk();
if(pedido.length || itemAtual) $('carr').classList.add('on'); if(pedido.length || itemAtual) $('carr').classList.add('on');
if($('cartLink')) $('cartLink').textContent='Carrinho ('+n+')'; if($('cartLink')) $('cartLink').textContent='Carrinho ('+n+')';
window.dispatchEvent(new Event('dtf-cart-changed')); window.dispatchEvent(new Event('dtf-cart-changed'));
@@ -62,8 +79,36 @@ function carrinho(){
'<div class="l" style="color:var(--laranja2)"><span>Revisão e reencaixe</span><b>grátis</b></div>'; '<div class="l" style="color:var(--laranja2)"><span>Revisão e reencaixe</span><b>grátis</b></div>';
$('carrLin').innerHTML=l; $('carrLin').innerHTML=l;
$('atual').style.display=''; $('atual').style.display='';
const sources=(ehFolha()?folhas:artes).map(x=>ehFolha()
? {kind:'sheet',width_cm:x.med?.larg||larguraFilme(),length_cm:+(x.m*100).toFixed(4),
copies:x.rep||1,rotation_degrees:0,mirrored:false,
measurement:x.med?'file':'customer'}
: {kind:'artwork',width_cm:x.cm,length_cm:+(x.cm*propDe(x)).toFixed(4),
copies:x.q,rotation_degrees:x.giro||0,mirrored:!!x.esp,
measurement:x.src?'file':'customer'});
let layout;
if(ehFolha()){
let y=0;
const placements=[];
sources.forEach((source,source_index)=>{
for(let copy_index=0;copy_index<source.copies;copy_index++){
placements.push({source_index,copy_index,x_cm:0,y_cm:+y.toFixed(4),
width_cm:source.width_cm,length_cm:source.length_cm,
rotation_degrees:0,mirrored:false});
y+=source.length_cm;
}
});
layout={height_cm:+y.toFixed(4),placements};
}else{
if(!montagemLayout) return;
layout=montagemLayout;
}
itemAtual={modo, tit:MODOS[modo].tit, nota, metros, cob, unit, total:tot, itemAtual={modo, tit:MODOS[modo].tit, nota, metros, cob, unit, total:tot,
localFiles:(ehFolha()?folhas:artes).map(x=>x.f), localFiles:(ehFolha()?folhas:artes).map(x=>x.f),
production:{version:2,film_width_cm:larguraFilme(),sources,...layout},
qualityStatus:$('ciente').classList.contains('recusa')?'rejected':
$('ciente').classList.contains('on')?'warning':nota===0?'unverified':'ok',
qualityAcknowledged:false,
desc:fmtM(cob)+' m · '+rs(unit)+'/m · nota '+nota}; desc:fmtM(cob)+' m · '+rs(unit)+'/m · nota '+nota};
pintaPedido(); pintaPedido();
previa(); previa();
@@ -74,4 +119,3 @@ function carrinho(){
} }
} }
function qm(t,c){ const m=$('qmsg'); m.style.display='block'; m.style.color=c||'var(--verde)'; m.innerHTML=t; } function qm(t,c){ const m=$('qmsg'); m.style.display='block'; m.style.color=c||'var(--verde)'; m.innerHTML=t; }

View File

@@ -69,6 +69,7 @@ const folhaTotalM=()=>folhas.reduce((t,x)=>t+(x.m||0)*(x.rep||1),0);
// Cada modo por metro tem o seu par de artes avulsas, e o seletor anda nos dois sentidos. // 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'}; const PAR={file:'avulsa', avulsa:'file', uvfile:'uv', uv:'uvfile'};
let montagemCm=0; // altura da folha montada pelo motor, em cm let montagemCm=0; // altura da folha montada pelo motor, em cm
let montagemLayout=null; // posições aprovadas de cada cópia, em cm
const ZOOMS=[1,1.5,2,3,4]; let zoomI=0; // ampliação da montagem ao vivo 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ó 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 // A escolha de entrega vale para o pedido inteiro, não por item. No Tiny, retirada
@@ -100,9 +101,7 @@ const precoBase=n=>
(FAIXAS[modo].find(f=>n>=f[0])||FAIXAS[modo][FAIXAS[modo].length-1])[1]; (FAIXAS[modo].find(f=>n>=f[0])||FAIXAS[modo][FAIXAS[modo].length-1])[1];
const descontoMetragem=()=>0; // sem progressão por quantidade const descontoMetragem=()=>0; // sem progressão por quantidade
const cls=n=>n>=85?'ok':n>=50?'av':'er'; const cls=n=>n>=85?'ok':n>=50?'av':'er';
const dpiDe=a=>a.cm>0? a.px/(a.cm/2.54) : 0; const dpiDe=a=>a.cm>0? (a.giro?a.py:a.px)/(a.cm/2.54) : 0;
// girar 90° inverte a proporção · espelhar não muda medida, só o desenho // 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 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 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)));

View File

@@ -87,7 +87,7 @@ function pintaEntrega(){
? 'DTF é impresso depois que você paga. Avisamos no WhatsApp quando estiver '+ ? 'DTF é impresso depois que você paga. Avisamos no WhatsApp quando estiver '+
'<b>pronto para retirar</b> — não venha antes do aviso.' '<b>pronto para retirar</b> — não venha antes do aviso.'
: ''; : '';
$('bPagar').disabled = !entrega.cotado || !clienteOk(); $('bPagar').disabled = !entrega.cotado || !clienteOk() || !cartPodeEnviar();
pintaPedido(); pintaPedido();
} }
@@ -156,6 +156,10 @@ $('bCep').addEventListener('click',async()=>{
}); });
$('bMais').addEventListener('click',()=>{ $('bMais').addEventListener('click',()=>{
if(itemAtual && !itemPodeEnviar(itemAtual)){
$('qual').scrollIntoView({behavior:'smooth',block:'nearest'});
return;
}
if(itemAtual){ pedido.push(itemAtual); itemAtual=null; } if(itemAtual){ pedido.push(itemAtual); itemAtual=null; }
$('atual').style.display='none'; $('carrLin').innerHTML=''; $('atual').style.display='none'; $('carrLin').innerHTML='';
$('foco').classList.remove('on'); $('cards').style.display=''; $('foco').classList.remove('on'); $('cards').style.display='';
@@ -170,4 +174,3 @@ $('bPagar').addEventListener('click',()=>{
else alert('O pedido online está indisponível no momento. Tente novamente em instantes.'); else alert('O pedido online está indisponível no momento. Tente novamente em instantes.');
}); });
pintaEntrega(); pintaEntrega();

View File

@@ -134,6 +134,8 @@ function encaixar(pecas,W){
} }
const postas=[]; const postas=[];
if(pecas.some(p=>!Number.isFinite(p.w) || p.w<=0 || p.w>W))
throw new RangeError('A largura solicitada não cabe no filme.');
const ordem=[...pecas].sort((a,b)=>(b.w*b.h)-(a.w*a.h)); const ordem=[...pecas].sort((a,b)=>(b.w*b.h)-(a.w*a.h));
ordem.forEach(p=>{ ordem.forEach(p=>{
@@ -173,10 +175,8 @@ function encaixar(pecas,W){
} }
}); });
if(!melhor){ if(!melhor){
const k=Math.min(1, W/p.w); // cabe na largura, nem que seja reduzindo const m=mascara(p.img,p.w,p.h,p.a?.giro||0,p.a?.esp);
const w=p.w*k, h=p.h*k; melhor={cx:0,cy:linhas,m,x:0,y:linhas*CEL,w:p.w,h:p.h,rot:0,ref:p};
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 // a folga só existe entre peças, não é folha cobrada
ocupar(melhor.m,melhor.cx,melhor.cy); ocupar(melhor.m,melhor.cx,melhor.cy);
@@ -190,7 +190,7 @@ function encaixar(pecas,W){
function desenhaMontagem(area,lista,mts,W,fora,aoTerminar){ function desenhaMontagem(area,lista,mts,W,fora,aoTerminar){
const versao=area.montagemVersao=(area.montagemVersao||0)+1; const versao=area.montagemVersao=(area.montagemVersao||0)+1;
const filme=larguraFilme(), PX_CM=W/filme; const filme=larguraFilme(), PX_CM=W/filme;
const itens=lista.map(a=>({...a})); const itens=lista.map((a,sourceIndex)=>({...a,sourceIndex}));
if(fora) fora.innerHTML=''; if(fora) fora.innerHTML='';
if(!itens.length){ if(!itens.length){
area.innerHTML='<div class="semmont"><b>A montagem aparece aqui</b>'+ area.innerHTML='<div class="semmont"><b>A montagem aparece aqui</b>'+
@@ -201,7 +201,7 @@ function desenhaMontagem(area,lista,mts,W,fora,aoTerminar){
Promise.all(itens.map(a=>carregarImagem(a.f).then(img=>({a,img})))).then(imagens=>{ Promise.all(itens.map(a=>carregarImagem(a.f).then(img=>({a,img})))).then(imagens=>{
if(area.montagemVersao!==versao) return; if(area.montagemVersao!==versao) return;
const cargas=imagens.flatMap(c=>Array.from({length:c.a.q||1},()=>({...c}))); const cargas=imagens.flatMap(c=>Array.from({length:c.a.q||1},(_,copyIndex)=>({...c,copyIndex})));
cargas.forEach(c=>{ cargas.forEach(c=>{
c.w = c.a.cm; // o motor trabalha em centímetros c.w = c.a.cm; // o motor trabalha em centímetros
c.h = c.w * (c.img ? (c.a.giro? c.img.width/c.img.height : c.img.height/c.img.width) : 1); c.h = c.w * (c.img ? (c.a.giro? c.img.width/c.img.height : c.img.height/c.img.width) : 1);
@@ -255,7 +255,12 @@ function desenhaMontagem(area,lista,mts,W,fora,aoTerminar){
+ (girou? ' · <b style="display:inline">'+girou+ + (girou? ' · <b style="display:inline">'+girou+
(girou>1?' artes giradas':' arte girada')+' 90°</b> para caber melhor':''); (girou>1?' artes giradas':' arte girada')+' 90°</b> para caber melhor':'');
(fora||area).appendChild(el); (fora||area).appendChild(el);
if(aoTerminar) aoTerminar({alturaCm, uso, girou}); if(aoTerminar) aoTerminar({alturaCm, uso, girou,
placements:enc.pos.map(p=>({source_index:p.ref.a.sourceIndex,
copy_index:p.ref.copyIndex,x_cm:p.x,y_cm:p.y,
width_cm:+p.w.toFixed(4),length_cm:+p.h.toFixed(4),
rotation_degrees:(p.rot+(p.ref.a.giro||0))%360,
mirrored:!!p.ref.a.esp}))});
}); });
} }
@@ -265,6 +270,7 @@ function previaArtes(){ desenhaMontagem($('pArea'), artes, metros, 560); }
let tMont=null; let tMont=null;
function previaAoVivo(){ function previaAoVivo(){
clearTimeout(tMont); clearTimeout(tMont);
montagemLayout=null;
$('vArea').montagemVersao=($('vArea').montagemVersao||0)+1; $('vArea').montagemVersao=($('vArea').montagemVersao||0)+1;
if(ehFolha()){ if(ehFolha()){
$('vUso').innerHTML=''; $('vUso').innerHTML='';
@@ -296,11 +302,13 @@ function previaAoVivo(){
return; return;
} }
tMont=setTimeout(()=>{ tMont=setTimeout(()=>{
const prontas=artes.filter(a=>a.cm>0); const prontas=artes.filter(a=>a.cm>0 && a.cm<=larguraFilme() && a.src);
const W=Math.round(300*ZOOMS[zoomI]); const W=Math.round(300*ZOOMS[zoomI]);
$('vArea').classList.toggle('ampliado', zoomI>0); $('vArea').classList.toggle('ampliado', zoomI>0);
desenhaMontagem($('vArea'), prontas, 0, W, $('vUso'), r=>{ desenhaMontagem($('vArea'), prontas, 0, W, $('vUso'), r=>{
montagemCm = r.alturaCm; // altura real da folha montada montagemCm = r.alturaCm; // altura real da folha montada
montagemLayout=r.placements ? {height_cm:+r.alturaCm.toFixed(4),
placements:r.placements} : null;
$('vMt').textContent = r.alturaCm? fmtM(cobrar(r.alturaCm/100))+' m' : '—'; $('vMt').textContent = r.alturaCm? fmtM(cobrar(r.alturaCm/100))+' m' : '—';
resumoArtes(); // o resumo lê a mesma folha resumoArtes(); // o resumo lê a mesma folha
agendaAvaliacao(); // a nota só sai com a metragem pronta agendaAvaliacao(); // a nota só sai com a metragem pronta
@@ -312,12 +320,12 @@ function previaAoVivo(){
function motivoNota(){ function motivoNota(){
if(!nota) return 'sem nota ainda · a conferência roda na sala antes de imprimir'; if(!nota) return 'sem nota ainda · a conferência roda na sala antes de imprimir';
if(!ehFolha()){ if(!ehFolha()){
const ruins=artes.filter(a=>a.cm>0 && (a.px/(a.cm/2.54))<300); const ruins=artes.filter(a=>a.cm>0 && dpiDe(a)<300);
if(!ruins.length) return 'nota '+nota+' · todas as artes em 300 DPI ou mais'; 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); const pior=ruins.reduce((p,a)=>dpiDe(a)<dpiDe(p)?a:p);
return 'nota '+nota+' · '+ruins.length+(ruins.length>1?' artes abaixo':' arte abaixo')+ return 'nota '+nota+' · '+ruins.length+(ruins.length>1?' artes abaixo':' arte abaixo')+
' de 300 DPI · a menor é "'+pior.f.name+'" com '+ ' 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'; Math.round(dpiDe(pior))+' DPI em '+pior.cm.toFixed(1).replace('.',',')+' cm';
} }
return nota>=90 ? 'nota '+nota+' · resolução ótima em toda a folha' 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ê'; : 'nota '+nota+' · a resolução é o único ponto — o resto corrigimos por você';
@@ -350,4 +358,3 @@ function previa(){
catch(e){ $('pArea').innerHTML='<div class="semprev"><b>Prévia indisponível</b>'+ catch(e){ $('pArea').innerHTML='<div class="semprev"><b>Prévia indisponível</b>'+
'O arquivo será processado normalmente.</div>'; } 'O arquivo será processado normalmente.</div>'; }
} }

View File

@@ -10,9 +10,8 @@
const PDFJS_URL='/vendor/pdf.min.js'; const PDFJS_URL='/vendor/pdf.min.js';
const PDFJS_WORKER=new URL('/vendor/pdf.worker.min.js', location.origin).href; const PDFJS_WORKER=new URL('/vendor/pdf.worker.min.js', location.origin).href;
let pdfLibP=null, temWorker=null; let pdfLibP=null, temWorker=null;
// Alguns navegadores e iframes bloqueiam Worker de blob. Descobrimos antes de // Check whether this browser can create a same-origin Worker. The worker file
// tentar, porque sem worker o PDF é lido na thread principal e precisa de outra // is served alongside this script and does not need a blob URL.
// estratégia: grade mais grossa e mais tempo.
function testaWorker(url){ function testaWorker(url){
try{ const w=new Worker(url); w.terminate(); return true; }catch(e){ return false; } try{ const w=new Worker(url); w.terminate(); return true; }catch(e){ return false; }
} }
@@ -25,10 +24,8 @@ function carregarPdfJs(){
sc.src=PDFJS_URL; sc.src=PDFJS_URL;
sc.onload=()=>{ sc.onload=()=>{
try{ try{
const codigo='importScripts("'+PDFJS_WORKER+'");'; temWorker=testaWorker(PDFJS_WORKER);
const url=URL.createObjectURL(new Blob([codigo],{type:'application/javascript'})); window.pdfjsLib.GlobalWorkerOptions.workerSrc=PDFJS_WORKER;
temWorker=testaWorker(url);
window.pdfjsLib.GlobalWorkerOptions.workerSrc = temWorker? url : PDFJS_WORKER;
}catch(e){ }catch(e){
temWorker=false; temWorker=false;
try{ window.pdfjsLib.GlobalWorkerOptions.workerSrc=PDFJS_WORKER; }catch(e2){} try{ window.pdfjsLib.GlobalWorkerOptions.workerSrc=PDFJS_WORKER; }catch(e2){}
@@ -61,7 +58,7 @@ function dpiDasImagens(page, ops){
const larguraPt=Math.hypot(ctm[0],ctm[1]); const larguraPt=Math.hypot(ctm[0],ctm[1]);
if(larguraPt<1) continue; if(larguraPt<1) continue;
const areaPt=Math.abs(ctm[0]*ctm[3]-ctm[1]*ctm[2]); const areaPt=Math.abs(ctm[0]*ctm[3]-ctm[1]*ctm[2]);
dpis.push({dpi: im.width/(larguraPt/72), area: areaPt}); dpis.push({dpi: im.width/(larguraPt*page.userUnit/72), area: areaPt});
} }
} }
return dpis; return dpis;
@@ -88,43 +85,58 @@ function resumoDpi(lista){
} }
// Rasteriza a primeira página a 1 mm por pixel e devolve a tela para a análise // Rasteriza a primeira página a 1 mm por pixel e devolve a tela para a análise
function rasterizarPdf(file, larguraCm, alturaCm){ async function rasterizarPdf(file, larguraCm, alturaCm){
let motivo=null; const lib=await carregarPdfJs();
const trabalho=carregarPdfJs().then(lib=>{ if(!lib) return {erro:'não foi possível carregar o leitor de PDF'};
if(!lib){ motivo='não foi possível carregar o leitor de PDF'; return null; } // Decide after the worker check; the old timeout always used 30 seconds.
// sem worker tudo roda na thread principal · grade mais grossa para caber const espera=temWorker===false ? 90000 : 30000;
const folga=temWorker===false ? 2.2 : 1; const folga=temWorker===false ? 2.2 : 1;
return file.arrayBuffer().then(buf=>lib.getDocument({ let loading=null, doc=null, rendering=null, expired=false, timer=null;
data:buf, disableFontFace:true, isEvalSupported:false, useSystemFonts:false, const trabalho=(async()=>{
verbosity:0 // sem worker é fallback, não erro try{
}).promise).then(doc=> const buf=await file.arrayBuffer();
doc.getPage(1).then(page=>{ if(expired) return null;
loading=lib.getDocument({data:buf, disableFontFace:true,
isEvalSupported:false, useSystemFonts:false, verbosity:0});
doc=await loading.promise;
if(expired || doc.numPages!==1) return null;
const page=await doc.getPage(1);
if(expired) return null;
const vp1=page.getViewport({scale:1}); const vp1=page.getViewport({scale:1});
const passo=passoDe(larguraCm, alturaCm||larguraCm)*folga; const passo=passoDe(larguraCm, alturaCm||larguraCm)*folga;
const alvo=Math.max(8,Math.round(larguraCm/passo)); const alvo=Math.max(8,Math.round(larguraCm/passo));
const esc=alvo/vp1.width; const vp=page.getViewport({scale:alvo/vp1.width});
const vp=page.getViewport({scale:esc});
const cv=document.createElement('canvas'); const cv=document.createElement('canvas');
cv.width=Math.max(8,Math.round(vp.width)); cv.height=Math.max(8,Math.round(vp.height)); 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}); const ctx=cv.getContext('2d',{willReadFrequently:true});
return page.render({canvasContext:ctx, viewport:vp, background:'rgba(0,0,0,0)'}) rendering=page.render({canvasContext:ctx, viewport:vp, background:'rgba(0,0,0,0)'});
.promise.then(()=>page.getOperatorList()).then(ops=>{ await rendering.promise;
if(expired) return null;
const ops=await page.getOperatorList();
if(expired) return null;
let dpis=[]; let dpis=[];
try{ dpis=dpiDasImagens(page,ops); }catch(e){} try{ dpis=dpiDasImagens(page,ops); }catch(e){}
const r=resumoDpi(dpis); const r=resumoDpi(dpis);
return {tela:cv, dpi:r? r.ponderado:null, res:r}; return {tela:cv, dpi:r? r.ponderado:null, res:r};
}catch(e){
if(expired) return null;
return {erro:(e&&e.message)||'erro ao abrir o PDF', semWorker:temWorker===false};
}finally{
if(doc) doc.destroy().catch(()=>{});
else if(loading) loading.destroy().catch(()=>{});
}
})();
const tardio=new Promise(resolve=>{
timer=setTimeout(()=>{
expired=true;
try{ if(rendering) rendering.cancel(); }catch(e){}
if(loading) loading.destroy().catch(()=>{});
resolve({erro:'demorou demais neste navegador',semWorker:temWorker===false});
},espera);
}); });
}) const result=await Promise.race([trabalho,tardio]);
); clearTimeout(timer);
}).catch(e=>{ motivo=(e&&e.message)? e.message : 'erro ao abrir o PDF'; return null; }); return result||{erro:'não deu para conferir',semWorker:temWorker===false};
// 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){ function medirFolha(file){
@@ -143,29 +155,40 @@ function medirFolha(file){
return; return;
} }
if(/\.pdf$/.test(nome)){ if(/\.pdf$/.test(nome)){
const pedaco=(inicio,fim)=>new Promise(r=>{ // PDF boxes can be inherited, compressed, rotated, and scaled by UserUnit.
const fr=new FileReader(); // Searching raw bytes for /MediaBox can read the wrong object or miss it.
fr.onload=()=>r(new TextDecoder('latin1').decode(new Uint8Array(fr.result))); let doc=null;
fr.onerror=()=>r(''); carregarPdfJs().then(async lib=>{
fr.readAsArrayBuffer(file.slice(inicio,fim)); if(!lib) throw new Error('Não foi possível carregar o leitor de PDF.');
}); doc=await lib.getDocument({data:await file.arrayBuffer(),
const M=4*1048576; disableFontFace:true, isEvalSupported:false, useSystemFonts:false,
Promise.all([pedaco(0,M), pedaco(Math.max(0,file.size-M), file.size)]).then(([a,b])=>{ verbosity:0}).promise;
const txt=a+b; if(doc.numPages!==1)
const re=/\/MediaBox\s*\[\s*(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)/; return {rejected:true, reason:'O PDF tem '+doc.numPages+
const m=txt.match(re); ' páginas. Envie cada folha como um PDF de uma página para calcular o preço correto.'};
if(!m) return res(null); const page=await doc.getPage(1);
// UserUnit escala a página inteira · sem ele, 1 unidade = 1/72 pol const view=page.view, unit=page.userUnit, vp=page.getViewport({scale:1});
const uu=txt.match(/\/UserUnit\s+([\d.]+)/); if(!Array.isArray(view) || view.length!==4 ||
const esc=uu? Math.max(1,Math.min(75000,+uu[1])) : 1; !Number.isFinite(unit) || unit<=0 ||
const ptW=Math.abs(+m[3]-+m[1]), ptH=Math.abs(+m[4]-+m[2]); !Number.isFinite(vp.width) || !Number.isFinite(vp.height) ||
if(!(ptW>0&&ptH>0)) return res(null); vp.width<=0 || vp.height<=0)
const w=ptW*PT_CM*esc, h=ptH*PT_CM*esc; return {rejected:true, reason:'Não conseguimos determinar o tamanho desta página PDF. Exporte-a novamente.'};
// acima de 14.400 unidades sem UserUnit o arquivo está fora da especificação const ptW=Math.abs(view[2]-view[0]), ptH=Math.abs(view[3]-view[1]);
const foraDoPadrao = !uu && (ptW>PDF_MAX_PT || ptH>PDF_MAX_PT); if(!(ptW>0 && ptH>0) ||
res({larg:+w.toFixed(1), alt:+h.toFixed(1), (unit===1 && (ptW>PDF_MAX_PT || ptH>PDF_MAX_PT)))
fonte:'página do PDF'+(uu? ' · UserUnit '+esc : ''), foraDoPadrao}); return {rejected:true, reason:'A página passa do limite de 508 cm sem UserUnit. Divida a folha ou exporte em PNG.'};
}); // This pinned PDF.js build exposes the page's rotated view in points;
// userUnit is separate and must scale both physical dimensions.
const w=vp.width*unit*PT_CM, h=vp.height*unit*PT_CM;
if(!Number.isFinite(w) || !Number.isFinite(h) || w<=0 || h<=0 || h>6000)
return {rejected:true, reason:'O tamanho desta página PDF não é suportado. Divida a folha em partes menores.'};
// Keep precise geometry for the fit and quote; the UI rounds only
// when displaying dimensions.
return {larg:+w.toFixed(3), alt:+h.toFixed(3),
fonte:'página do PDF'+(unit!==1 ? ' · UserUnit '+unit : '')};
}).then(res).catch(()=>res({rejected:true,
reason:'Não conseguimos ler este PDF. Exporte-o novamente como PDF de uma página ou PNG.'}))
.finally(()=>{ if(doc) doc.destroy().catch(()=>{}); });
return; return;
} }
res(null); res(null);
@@ -173,12 +196,13 @@ function medirFolha(file){
} }
function pintaFolha(){ function pintaFolha(){
invalidaItemAtual();
const L=larguraFilme(); const L=larguraFilme();
pintaTipoEnvio(); // trava o seletor enquanto houver folha pintaTipoEnvio(); // trava o seletor enquanto houver folha
if(!folhas.length){ $('lista').innerHTML=''; agendaAvaliacao(); previaAoVivo(); return; } if(!folhas.length){ $('lista').innerHTML=''; agendaAvaliacao(); previaAoVivo(); return; }
$('lista').innerHTML = folhas.map((x,i)=>{ $('lista').innerHTML = folhas.map((x,i)=>{
const lido=!!x.med, larga=lido && x.med.larg>L+0.5, suspeito=lido && x.med.foraDoPadrao; const lido=!!x.med, larga=lido && x.med.larg>L, suspeito=lido && x.med.foraDoPadrao;
const sub=(x.m||0)*(x.rep||1); const sub=(x.m||0)*(x.rep||1);
let medida=''; let medida='';
if(lido){ if(lido){
@@ -210,6 +234,9 @@ function pintaFolha(){
? ' Ela cabe no <b>DTF têxtil de 57 cm</b> — troque de produto ali em cima.' ? ' Ela cabe no <b>DTF têxtil de 57 cm</b> — troque de produto ali em cima.'
: ' Remonte a folha em '+n1(L)+' cm e suba de novo.')+'</em>':'')+ : ' Remonte a folha em '+n1(L)+' cm e suba de novo.')+'</em>':'')+
'</div>'; '</div>';
}else if(x.measurementError){
medida='<div class="med alerta"><b>Este PDF não pode ser orçado</b><span>'+
escapeHTML(x.measurementError)+'</span></div>';
}else{ }else{
const teto=TABELA[modo], piso=pisoEscada(); const teto=TABELA[modo], piso=pisoEscada();
const dif=(x.m>0)? (teto-piso)*cobrar(x.m*(x.rep||1)) : 0; const dif=(x.m>0)? (teto-piso)*cobrar(x.m*(x.rep||1)) : 0;
@@ -231,7 +258,7 @@ function pintaFolha(){
'<span class="progT">'+(x.pct<50?'lendo o arquivo…': '<span class="progT">'+(x.pct<50?'lendo o arquivo…':
x.pct<70?'medindo a folha…':'conferindo a arte…')+'</span>' x.pct<70?'medindo a folha…':'conferindo a arte…')+'</span>'
: x.semAnalise : x.semAnalise
? '<div class="progF"><b>Não conseguimos conferir esta folha.</b> '+x.semAnalise+ ? '<div class="progF"><b>Não conseguimos conferir esta folha.</b> '+escapeHTML(x.semAnalise)+
'. Seguimos com o preço de tabela — se quiser a nota e o desconto, exporte a mesma '+ '. Seguimos com o preço de tabela — se quiser a nota e o desconto, exporte a mesma '+
'folha em <b>PNG</b>, que a conferência é bem mais leve.</div>' 'folha em <b>PNG</b>, que a conferência é bem mais leve.</div>'
: ''; : '';
@@ -271,6 +298,8 @@ function pintaFolha(){
$('lista').querySelectorAll('[data-comp]').forEach(el=>el.addEventListener('change',e=>{ $('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(); folhas[+el.dataset.comp].m=Math.max(0,Math.min(60,+e.target.value||0)); pintaFolha();
})); }));
$('lista').querySelectorAll('[data-repf], [data-comp]').forEach(el=>
el.addEventListener('input',invalidaItemAtual));
agendaAvaliacao(); previaAoVivo(); agendaAvaliacao(); previaAoVivo();
} }
@@ -323,10 +352,15 @@ function vizPeca(a){
const n1=v=>v.toFixed(1).replace('.',','); const n1=v=>v.toFixed(1).replace('.',',');
function pintaArtes(){ function pintaArtes(){
invalidaItemAtual();
$('lista').innerHTML=artes.map((a,i)=>{ $('lista').innerHTML=artes.map((a,i)=>{
let barra='', dica='', calc='<span>informe a largura para ver a qualidade</span>'; let barra='', dica='', calc='<span>informe a largura para ver a qualidade</span>';
if(a.cm>0){ if(a.decodeError){
const dpi=Math.round(a.px/(a.cm/2.54)), n=Math.max(6,Math.min(100,Math.round(dpi/300*100))); calc='<span class="er">Não conseguimos ler esta imagem; exporte novamente em PNG ou JPG</span>';
}else if(a.cm>larguraFilme()){
calc='<span class="er">Largura maior que o filme de '+n1(larguraFilme())+' cm</span>';
}else if(a.cm>0 && a.src){
const dpi=Math.round(dpiDe(a)), 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>'; 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>'+ calc='<span class="'+cls(n)+'"><b>'+n+'%</b> · '+dpi+' DPI</span><span>'+
a.q+' × '+n1(a.cm)+' × '+n1(a.cm*propDe(a))+' cm</span>'; a.q+' × '+n1(a.cm)+' × '+n1(a.cm*propDe(a))+' cm</span>';
@@ -341,7 +375,8 @@ function pintaArtes(){
dica = vizPeca(a) + dica; dica = vizPeca(a) + dica;
} }
return '<div class="art'+(a===recemChegada?' nova':'')+'"><div class="l1"><b>'+escapeHTML(a.f.name)+'</b>'+ 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>'+ '<span style="color:var(--fraco);font-size:10.5px">'+
(a.px>0?a.px+' × '+a.py+' px':'a ler')+'</span>'+
'<button data-rm="'+i+'">×</button></div><div class="cps">'+ '<button data-rm="'+i+'">×</button></div><div class="cps">'+
'<div><label>largura na peça</label><input type="number" min="1" max="'+larguraFilme()+ '<div><label>largura na peça</label><input type="number" min="1" max="'+larguraFilme()+
'" placeholder="cm" data-cm="'+i+'" value="'+(a.cm||'')+'"></div>'+ '" placeholder="cm" data-cm="'+i+'" value="'+(a.cm||'')+'"></div>'+
@@ -358,11 +393,12 @@ function pintaArtes(){
}).join(''); }).join('');
$('lista').querySelectorAll('[data-rm]').forEach(b=>b.addEventListener('click',()=>{artes.splice(+b.dataset.rm,1);pintaArtes();})); $('lista').querySelectorAll('[data-rm]').forEach(b=>b.addEventListener('click',()=>{artes.splice(+b.dataset.rm,1);pintaArtes();}));
$('lista').querySelectorAll('[data-cm]').forEach(i=>i.addEventListener('change',()=>{artes[+i.dataset.cm].cm=+i.value||0;pintaArtes();})); $('lista').querySelectorAll('[data-cm]').forEach(i=>i.addEventListener('change',()=>{artes[+i.dataset.cm].cm=+i.value||0;pintaArtes();}));
$('lista').querySelectorAll('[data-q]').forEach(i=>i.addEventListener('change',()=>{artes[+i.dataset.q].q=Math.max(1,Math.floor(+i.value||1));pintaArtes();})); $('lista').querySelectorAll('[data-q]').forEach(i=>i.addEventListener('change',()=>{artes[+i.dataset.q].q=Math.max(1,Math.min(200,Math.floor(+i.value||1)));pintaArtes();}));
$('lista').querySelectorAll('[data-cm], [data-q]').forEach(i=>i.addEventListener('input',()=>{ $('lista').querySelectorAll('[data-cm], [data-q]').forEach(i=>i.addEventListener('input',()=>{
const largura=i.hasAttribute('data-cm'); const largura=i.hasAttribute('data-cm');
const a=artes[+(largura?i.dataset.cm:i.dataset.q)]; 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)); a[largura?'cm':'q']=largura?Math.max(0,+i.value||0):Math.max(1,Math.min(200,Math.floor(+i.value||1)));
invalidaItemAtual();
previaAoVivo(); previaAoVivo();
})); }));
$('lista').querySelectorAll('[data-usar]').forEach(b=>b.addEventListener('click',()=>{ $('lista').querySelectorAll('[data-usar]').forEach(b=>b.addEventListener('click',()=>{
@@ -381,10 +417,10 @@ function pintaArtes(){
previaAoVivo(); previaAoVivo();
} }
function resumoArtes(){ function resumoArtes(){
const r=$('rA'), prontas=artes.filter(a=>a.cm>0); const r=$('rA'), prontas=artes.filter(a=>a.cm>0 && a.cm<=larguraFilme() && a.src);
if(!prontas.length){ r.style.display='none'; return; } if(!prontas.length){ r.style.display='none'; return; }
const m=montagemCm/100; // a mesma folha que aparece ao lado const m=montagemCm/100; // a mesma folha que aparece ao lado
const notas=prontas.map(a=>Math.max(6,Math.min(100,Math.round((a.px/(a.cm/2.54))/300*100)))); const notas=prontas.map(a=>Math.max(6,Math.min(100,Math.round(dpiDe(a)/300*100))));
const media=Math.round(notas.reduce((s,n)=>s+n,0)/notas.length), pior=Math.min(...notas); const media=Math.round(notas.reduce((s,n)=>s+n,0)/notas.length), pior=Math.min(...notas);
const falta=artes.length-prontas.length; const falta=artes.length-prontas.length;
r.style.display='block'; r.className='resumoA on'; r.style.display='block'; r.className='resumoA on';
@@ -397,4 +433,3 @@ function resumoArtes(){
' m'+(cobrar(m)===MINIMO_M?' · mínimo':'')+'</b></div>':''):'')+ ' 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>':''); (falta?'<div class="r" style="color:var(--laranja)"><span>faltam informar</span><b>'+falta+'</b></div>':'');
} }

View File

@@ -8,7 +8,9 @@
function avaliar(){ function avaliar(){
if(ehFolha()){ if(ehFolha()){
if(!folhas.length) return {pronto:false, falta:'Arraste sua folha montada para começar.'}; 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); const pdfInvalido=folhas.find(x=>x.measurementError);
if(pdfInvalido) return {pronto:false, falta:pdfInvalido.f.name+': '+pdfInvalido.measurementError};
const larga=folhas.find(x=>x.med && x.med.larg > larguraFilme());
if(larga) return {pronto:false, falta:'A folha '+larga.f.name+' tem '+n1(larga.med.larg)+ 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.'}; ' cm e não cabe no filme de '+n1(larguraFilme())+' cm.'};
const semMedida=folhas.filter(x=>!(x.m>0)).length; const semMedida=folhas.filter(x=>!(x.m>0)).length;
@@ -17,12 +19,14 @@ function avaliar(){
metros=folhaTotalM(); metros=folhaTotalM();
reencaixado=false; reencaixado=false;
const ans=folhas.map(x=>x.an).filter(Boolean); const ans=folhas.map(x=>x.an).filter(Boolean);
if(!ans.length){ if(ans.length!==folhas.length){
// sem análise possível (PDF, CDR, PSD): não inventa nota // A nota precisa cobrir cada folha faturada. Uma folha sem análise não
const exts=[...new Set(folhas.map(f=>extDe(f.f.name)))].join(', '); // herda a nota/desconto de outra folha que o navegador conseguiu abrir.
const ausentes=folhas.filter(f=>!f.an);
const exts=[...new Set(ausentes.map(f=>extDe(f.f.name)))].join(', ');
mostraNota([ mostraNota([
['av','Sem nota · '+exts+' não abre no navegador', ['av','Sem nota · '+exts+' sem análise completa',
'não temos como conferir a arte antes de imprimir'], ausentes.length+' de '+folhas.length+' folha(s) precisam de conferência manual'],
['er','Metro pela tabela · '+rs(TABELA[modo]), ['er','Metro pela tabela · '+rs(TABELA[modo]),
'exportando em PNG ou PDF, cai até '+rs(pisoEscada())], 'exportando em PNG ou PDF, cai até '+rs(pisoEscada())],
['fix','Alguém abre seu arquivo','a conferência é feita na mão, na sala'] ['fix','Alguém abre seu arquivo','a conferência é feita na mão, na sala']
@@ -58,13 +62,18 @@ function avaliar(){
return {pronto:true}; return {pronto:true};
} }
if(!artes.length) return {pronto:false, falta:'Arraste suas artes para começar.'}; if(!artes.length) return {pronto:false, falta:'Arraste suas artes para começar.'};
if(artes.some(a=>a.decodeError || !a.src || !(a.px>0) || !(a.py>0)))
return {pronto:false, falta:'Não conseguimos ler uma das imagens. Exporte-a novamente em PNG ou JPG antes de continuar.'};
if(artes.some(a=>!Number.isFinite(a.cm) || a.cm>larguraFilme()))
return {pronto:false, falta:'A largura da arte precisa caber no filme de '+
n1(larguraFilme())+' cm. Reduza a medida solicitada antes de continuar.'};
const sem=artes.filter(a=>!a.cm).length; const sem=artes.filter(a=>!a.cm).length;
if(sem) return {pronto:false, falta:'Informe a largura de '+sem+' arte'+(sem>1?'s':'')+ if(sem) return {pronto:false, falta:'Informe a largura de '+sem+' arte'+(sem>1?'s':'')+
' para ver a nota e o preço.'}; ' para ver a nota e o preço.'};
// metragem = altura da folha depois de montada, com os 5 mm entre peças // metragem = altura da folha depois de montada, com os 5 mm entre peças
metros=+(montagemCm/100).toFixed(4); metros=+(montagemCm/100).toFixed(4);
if(!(metros>0)) return {pronto:false, falta:'Montando a folha…'}; 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 notas=artes.map(a=>Math.max(6,Math.min(100,Math.round(dpiDe(a)/300*100))));
const media=Math.round(notas.reduce((s,n)=>s+n,0)/notas.length), pior=Math.min(...notas); const media=Math.round(notas.reduce((s,n)=>s+n,0)/notas.length), pior=Math.min(...notas);
mostraNota([ mostraNota([
['ok','Montagem por nossa conta','5 mm garantidos'], ['ok','Montagem por nossa conta','5 mm garantidos'],
@@ -182,5 +191,8 @@ function ressalvaResolucao(){
$('cienteOk').addEventListener('change',e=>{ $('cienteOk').addEventListener('change',e=>{
if($('ciente').classList.contains('recusa')) return; // recusa não se aceita if($('ciente').classList.contains('recusa')) return; // recusa não se aceita
$('qOk').disabled=!e.target.checked; $('qOk').disabled=!e.target.checked;
if(itemAtual && itemAtual.qualityStatus==='warning'){
itemAtual.qualityAcknowledged=e.target.checked;
pintaEntrega();
}
}); });

View File

@@ -37,6 +37,16 @@ function sel(fs){
const fora = fs.filter(f=>!regra.test(f.name)); const fora = fs.filter(f=>!regra.test(f.name));
fs = fs.filter(f=>regra.test(f.name)); fs = fs.filter(f=>regra.test(f.name));
recusa(fora); recusa(fora);
const max=window.dtfUploadMaxBytes||128*1048576;
const grandes=fs.filter(f=>f.size>max);
if(grandes.length){
const el=$('recusa');
el.style.display='block';
el.innerHTML='<b>Arquivo acima do limite de '+(max/1048576).toFixed(0)+
' MB.</b> A verificação de segurança ainda não consegue liberar arquivos maiores. '+
'Divida ou compacte a arte antes de enviar.';
fs=fs.filter(f=>f.size<=max);
}
if(!fs.length) return; if(!fs.length) return;
if(ehFolha()){ if(ehFolha()){
const auto=fs.filter(f=>AUTO.test(f.name)).length; const auto=fs.filter(f=>AUTO.test(f.name)).length;
@@ -60,6 +70,9 @@ function sel(fs){
novas.forEach(x=>{ novas.forEach(x=>{
x.pct=5; pintaFolha(); x.pct=5; pintaFolha();
medirFolha(x.f).then(md=>{ medirFolha(x.f).then(md=>{
if(md && md.rejected){
x.measurementError=md.reason; x.pct=null; pintaFolha(); return;
}
if(md && md.dpiFolha!=null && md.dpiFolha<DPI_RECUSA) return recusaFolha(x, md); if(md && md.dpiFolha!=null && md.dpiFolha<DPI_RECUSA) return recusaFolha(x, md);
x.med=md; if(md) x.m=md.alt/100; x.pct=55; pintaFolha(); x.med=md; if(md) x.m=md.alt/100; x.pct=55; pintaFolha();
if(md && RENDERIZA.test(x.f.name)){ if(md && RENDERIZA.test(x.f.name)){
@@ -93,7 +106,7 @@ function sel(fs){
}); });
return; return;
} }
const novos=fs.map(f=>({f,px:px(f),cm:0,q:1,prop:1,giro:0,esp:false})); const novos=fs.map(f=>({f,px:0,py:0,cm:0,q:1,prop:1,giro:0,esp:false}));
artes=novos.concat(artes); // a mais recente fica no topo da fila artes=novos.concat(artes); // a mais recente fica no topo da fila
recemChegada=novos[0]; recemChegada=novos[0];
pintaArtes(); pintaArtes();
@@ -102,11 +115,12 @@ function sel(fs){
} }
function medir(a){ function medir(a){
return carregarImagem(a.f).then(img=>{ return carregarImagem(a.f).then(img=>{
if(img){ a.prop=img.height/img.width; a.px=img.width; a.src=img.src; } else a.prop=a.prop||1; if(img){ a.prop=img.height/img.width; a.px=img.width; a.py=img.height; a.src=img.src; }
else a.decodeError=true;
return a; return a;
}); });
} }
// A metragem sai do arquivo, nunca do peso em bytes. // A metragem sai do arquivo, nunca do peso em bytes.
// imagem → proporção da imagem aplicada à largura do filme // imagem → proporção da imagem aplicada à largura do filme
// PDF → MediaBox da primeira página, em pontos (1 pt = 1/72 pol) // PDF → página única lida pelo PDF.js, com boxes, rotação e UserUnit
// TIFF, PSD, AI, CDR → o navegador não abre; o cliente informa o comprimento // TIFF, PSD, AI, CDR → o navegador não abre; o cliente informa o comprimento