fix: harden week-two ordering, artwork and operations
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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']}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
13
app/core/limits.py
Normal 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())
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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}'
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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__':
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
85
docs/REMEDIATION-2026-09-22.md
Normal file
85
docs/REMEDIATION-2026-09-22.md
Normal 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.
|
||||||
215
docs/ROADMAP.md
215
docs/ROADMAP.md
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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__':
|
||||||
|
|||||||
@@ -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])))
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"]');
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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']
|
||||||
|
|||||||
60
tests/quote_pagination_test.py
Normal file
60
tests/quote_pagination_test.py
Normal 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()
|
||||||
@@ -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.')
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|
||||||
|
|||||||
@@ -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)));
|
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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>'; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
167
web/site-pdf.js
167
web/site-pdf.js
@@ -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>':'');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user