feat: approve priced carts at checkout so customers can pay at once
All checks were successful
Build and deploy / Validate source (push) Successful in 8s
Build and deploy / Integration suite on a real stack (push) Successful in 2m22s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images (push) Successful in 1m34s

Every quote waited for an operator before it could be paid, so an order
placed at night waited for the morning. A cart the Site priced is now
approved when the quote is created, through the same server pricing the
operator's approval uses (app/quote_review.py). Orders above
QUOTE_AUTO_MAX_METRES (50 m) and items claiming a discount on art the Site
could not analyse still wait for review; the Kanban shows which quotes were
approved automatically and why the others wait.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cauê Faleiros
2026-09-28 11:02:56 -03:00
parent 9bea187ea4
commit 275ebf72c4
15 changed files with 200 additions and 51 deletions

View File

@@ -86,7 +86,7 @@ jobs:
# the generator's geometry. The provider suites use a fake transport: they
# prove the documented contract, not the integration.
- name: Print-file geometry and provider adapters
run: $COMPOSE exec -T api python -m unittest tests.test_printfile tests.test_mercadopago tests.test_tiny tests.test_jadlog -v
run: $COMPOSE exec -T api python -m unittest tests.test_printfile tests.test_mercadopago tests.test_tiny tests.test_jadlog tests.test_quote_review -v
- name: Runtime and retention regressions
run: |

View File

@@ -8,17 +8,16 @@ from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi.responses import RedirectResponse
from psycopg.types.json import Jsonb
from ..core import db
from ..core.auth import (COOKIE_SECURE, DUMMY_PASSWORD_HASH, audit, client_ip, operator,
login_failed, password_matches, throttle)
from ..core.models import Move, OperatorLogin, Resolution, Review
from ..core.pricing import price
from ..printjobs import queue as queue_print_files
from .. import quote_review
from .. import tiny
from ..runtime import (BACK, BOARD_FINISHED_LIMIT, BOARD_QUOTE_LIMIT, ENVIRONMENT, STATES, TRANSITIONS, payment,
enqueue, freight, quote_view, storage, upload_row)
enqueue, quote_view, storage)
from ..scanning import require_clean
router = APIRouter()
@@ -197,31 +196,7 @@ def approve(uid: UUID, body: Review, user=Depends(operator)):
row = c.execute('SELECT * FROM dtf_local.quotes WHERE id=%s FOR UPDATE', (uid,)).fetchone()
if not row:
raise HTTPException(404, 'Quote not found')
if row['approved']:
raise HTTPException(409, 'Approved quotes are immutable; request a new quote')
draft = row['draft']
if 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']):
raise HTTPException(422, 'Review must cover every item')
items = []
for item, original in zip(body.items, draft['items']):
if item.mode != original['mode'] or list(map(str,item.uploads)) != original['uploads']:
raise HTTPException(422, 'Product mode and attached files cannot change during review')
if item.production.model_dump(mode='json') != original['production'] or item.quality_status != original['quality_status'] or item.quality_acknowledged != original['quality_acknowledged']:
raise HTTPException(422, 'Production instructions and customer acknowledgement cannot change during commercial review')
for upload_id in item.uploads:
require_clean(upload_row(c, upload_id, row['owner']))
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'])
approved = {'customer': draft['customer'], 'items': items, 'freight': quoted_freight,
'destination': draft.get('destination'),
'total_cents': sum(i['total_cents'] for i in items)+quoted_freight['total_cents']}
c.execute('UPDATE dtf_local.quotes SET approved=%s, reviewed_by=%s, approved_at=now() WHERE id=%s', (Jsonb(approved),user,uid))
return approved
return quote_review.approve(c, row, body.items, user)
@router.post('/api/operator/orders/{uid}/move')
def move(uid: UUID, body: Move, user=Depends(operator)):

View File

@@ -1,4 +1,4 @@
"""Quotes: the customer's cart, and the operator-reviewed version of it."""
"""Quotes: the customer's cart, approved at once when it can be (app/quote_review.py)."""
import hashlib
import json
from decimal import Decimal
@@ -10,6 +10,7 @@ from psycopg.types.json import Jsonb
from ..core import db
from ..core.auth import owner
from ..core.models import QuoteRequest
from .. import quote_review
from ..runtime import freight, quote_view, require_delivery_available, upload_row
from ..scanning import require_clean
@@ -37,10 +38,14 @@ def create_quote(body: QuoteRequest, session_id=Depends(owner)):
uid = uuid4()
c.execute('INSERT INTO dtf_local.quotes(id,owner,request_key,request_hash,draft) VALUES(%s,%s,%s,%s,%s) ON CONFLICT(owner,request_key) DO NOTHING',
(uid, session_id, body.request_key, digest, Jsonb(draft)))
row = c.execute('SELECT * FROM dtf_local.quotes WHERE owner=%s AND request_key=%s', (session_id, body.request_key)).fetchone()
row = c.execute('SELECT * FROM dtf_local.quotes WHERE owner=%s AND request_key=%s FOR UPDATE', (session_id, body.request_key)).fetchone()
if row['request_hash'] != digest:
raise HTTPException(409, 'Request key already used for a different cart')
return {'id': row['id'], 'status': 'pending_review'}
reason = quote_review.review_reason(draft)
if row['id'] == uid and reason is None:
quote_review.approve(c, row, body.items, quote_review.AUTO)
return {'id': row['id'], 'status': 'approved'}
return {'id': row['id'], 'status': 'approved' if row['approved'] else 'pending_review'}
@router.get('/api/quotes/{uid}')
def get_quote(uid: UUID, session_id=Depends(owner)):

81
app/quote_review.py Normal file
View File

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

View File

@@ -70,11 +70,14 @@ def upload_row(c, upload_id, session_id, lock=False):
def quote_view(c, row):
from .quote_review import review_reason # it imports this module
order = c.execute('SELECT id,number,state FROM dtf_local.orders WHERE quote_id=%s', (row['id'],)).fetchone()
expired = row['approved_at'] and row['approved_at'] < datetime.now(timezone.utc)-timedelta(hours=24)
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',
'auto_approved': row.get('reviewed_by') == 'auto',
'review_reason': None if row['approved'] else review_reason(row['draft']),
'order': order}

View File

@@ -37,6 +37,9 @@ x-app: &app
MP_NOTIFICATION_URL: ${MP_NOTIFICATION_URL:-}
MP_PUBLIC_KEY: ${MP_PUBLIC_KEY:-}
FREIGHT_ADAPTER: fake
# Carts the Site priced are approved at checkout; larger ones wait for review.
QUOTE_AUTO_APPROVE: ${QUOTE_AUTO_APPROVE:-true}
QUOTE_AUTO_MAX_METRES: ${QUOTE_AUTO_MAX_METRES:-50}
TINY_ADAPTER: ${TINY_ADAPTER:-fake}
TINY_CLIENT_ID: ${TINY_CLIENT_ID:-}
TINY_CLIENT_SECRET: ${TINY_CLIENT_SECRET:-}

View File

@@ -35,6 +35,11 @@ x-app-environment: &app-environment
# provider is connected. Never set it to a value anyone could guess.
PAYMENT_WEBHOOK_SECRET: ${PAYMENT_WEBHOOK_SECRET:-}
FREIGHT_ADAPTER: fake
# A cart the Site priced is approved at checkout and can be paid at once;
# orders above QUOTE_AUTO_MAX_METRES, or with a grade the Site did not
# compute, wait for an operator on the Kanban (app/quote_review.py).
QUOTE_AUTO_APPROVE: ${QUOTE_AUTO_APPROVE:-true}
QUOTE_AUTO_MAX_METRES: ${QUOTE_AUTO_MAX_METRES:-50}
# Order creation in Tiny stays off until it has been tested against the
# client's account (Tiny has no sandbox). The application credentials can be
# set now: they let an operator connect Tiny from the Kanban, and the worker

View File

@@ -579,7 +579,19 @@ The production topology has not been verified by the repository review.
## Block 3 · Architecture — needs a decision before code
### `[?]` 3.1 — Manual quote approval contradicts the 24h business case `(F16)`
### `[~]` 3.1 — Manual quote approval contradicts the 24h business case `(F16)`
**Decided 2026-09-28 (the user: "we are an e-commerce"):** a cart the Site
priced is approved when the quote is created and can be paid at once
(`app/quote_review.py`, the same pricing the operator's approval uses). A
person reviews only orders above `QUOTE_AUTO_MAX_METRES` (50 m) and items
that claim a grade the Site could not have computed (unanalysed art with a
discount). The Kanban lists automatic approvals and the reason for each
manual one. **Still open:** the grade and layout are the browser's (3.2,
3.9), so a customer who edits the page can claim a better grade, up to the
top tier's discount; the server must compute the grade before this closes.
Previously:
Payment requires `quotes.approved`, set only by an authenticated operator. The
meeting's premise was that the 17h30 order waiting until 5am is what costs the

View File

@@ -97,7 +97,8 @@ try{
assert.equal(await site.eval('document.getElementById("bPagar").disabled'),false);
await site.click('#bPagar');
try{
await waitFor(async()=> (await site.text()).includes('Arquivos enviados.'),'browser upload and quote',45000);
// A cart the Site can price is approved at once: the customer pays now.
await waitFor(async()=> (await site.text()).includes('Total validado no servidor:'),'browser upload and automatic approval',45000);
}catch(error){
console.error('Checkout status:',await site.eval('document.getElementById("checkoutStatus")?.textContent'));
throw error;
@@ -107,16 +108,15 @@ try{
await kanban.fill('#email',process.env.OPERATOR_EMAIL||'operator@example.test');
await kanban.fill('#password',process.env.OPERATOR_PASSWORD||'local-operator-only');
await kanban.eval('document.getElementById("login").requestSubmit()');
// Quotes live in their own tab; the review pane shows the one picked.
// Quotes live in their own tab; an automatic approval is listed as such.
await waitFor(()=>kanban.eval('!document.getElementById("app").hidden'),'Kanban sign-in');
await kanban.click('[data-tab="quotes"]');
await waitFor(()=>kanban.eval(`!!document.querySelector('[data-quote-pick="${qid}"]')`),'quote listed on Kanban');
await kanban.click(`[data-quote-pick="${qid}"]`);
await waitFor(async()=> (await kanban.text()).includes(qid.slice(0,8)),'quote on Kanban');
assert.equal(await kanban.eval('sessionStorage.getItem("dtf-operator")'),null);
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 waitFor(async()=> (await kanban.text()).includes('Aprovada:'),'quote approval');
await kanban.click('[data-tab="quotes"]');
await waitFor(()=>kanban.eval('document.querySelectorAll("#quote-filters button").length===2'),'quote filters');
await kanban.eval('document.querySelectorAll("#quote-filters button")[1].click()');
await waitFor(()=>kanban.eval(`!!document.querySelector('[data-quote-pick="${qid}"]')`),'approved quote listed on Kanban');
assert.equal(await kanban.eval(`document.querySelector('[data-quote-pick="${qid}"]').textContent.includes('Aprovada automaticamente')`),true);
assert.equal(await site.eval('pedido[0].production.sources[0].copies'),1);
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');
@@ -188,7 +188,7 @@ try{
assert.equal(stored,0);
assert.deepEqual(portal.errors,[]);
assert.deepEqual(site.errors,[]);assert.deepEqual(kanban.errors,[]);
console.log('PASS: browser Site upload → operator quote → local paid order → all main Kanban states → reload persistence. Order '+oid);
console.log('PASS: browser Site upload → automatic approval → local paid order → all main Kanban states → reload persistence. Order '+oid);
console.log('Screenshots: output/local/site.png and output/local/kanban.png');
console.log('PASS: filename XSS escaping with CSP bypassed, no stored operator password, logout clears browser file blobs.');
}catch(error){console.error(error);if(stderr)console.error(stderr.slice(-1500));process.exitCode=1;}

View File

@@ -12,7 +12,7 @@ from urllib.error import HTTPError
from urllib.request import Request, urlopen
from uuid import uuid4
from tests.smoke_test import BASE, Client, upload_bytes, item_spec, with_host
from tests.smoke_test import BASE, Client, approved_quote, upload_bytes, item_spec, with_host
SECRET = os.environ.get('PAYMENT_WEBHOOK_SECRET', 'local-webhook-secret').encode()
@@ -42,8 +42,7 @@ def reviewed_quote():
'mail': 'payment-' + uuid4().hex[:8] + '@example.test'}
quote = customer.call('/quotes', {'request_key': str(uuid4()), 'customer': profile,
'items': [item], 'freight': {'service': 'pickup'}})
approved = customer.call('/operator/quotes/' + quote['id'] + '/approve',
{'items': [item]}, operator=True)
approved = approved_quote(customer, quote, [item])
return customer, quote['id'], approved['total_cents']

View File

@@ -17,7 +17,7 @@ from uuid import uuid4
from PIL import Image
from tests.payment_test import deliver
from tests.smoke_test import Client, upload_bytes
from tests.smoke_test import Client, approved_quote as approval, upload_bytes
PT_PER_CM = 72 / 2.54
CUSTOMER = {'cnpj': '11222333000181', 'zap': '11999999999', 'mail': 'print-test@example.test'}
@@ -53,7 +53,7 @@ def loose_item(uid, copies=2):
def approved_quote(client, item):
quote = client.call('/quotes', {'request_key': str(uuid4()), 'customer': CUSTOMER,
'items': [item], 'freight': {'service': 'pickup'}})
approved = client.call('/operator/quotes/' + quote['id'] + '/approve', {'items': [item]}, operator=True)
approved = approval(client, quote, [item])
return quote['id'], approved['total_cents']

View File

@@ -94,6 +94,12 @@ def item_spec(mode, metres, grade, uid):
'quality_status':'unverified' if grade==0 else 'ok',
'quality_acknowledged':False}
def approved_quote(client, quote, items):
"""The approval a customer pays against: automatic, or by the operator."""
if quote['status']=='approved':
return client.call('/quotes/'+quote['id'])['approved']
return client.call('/operator/quotes/'+quote['id']+'/approve',{'items':items},operator=True)
def run():
client=Client();other=Client()
config=client.call('/session');other.call('/session')
@@ -127,7 +133,8 @@ def run():
except HTTPError as exc:assert exc.code==403
print('PASS: multipart resume, incomplete rejection, immutable completion, ownership, private/downloaded bytes')
items=[item_spec(m,'2.75',90,uid) for m in ('file','avulsa','uvfile','uv')]
# Above QUOTE_AUTO_MAX_METRES (50 m by default), so a person reviews it.
items=[item_spec(m,'13',90,uid) for m in ('file','avulsa','uvfile','uv')]
draft={'request_key':str(uuid4()),'customer':{'cnpj':'11222333000181','zap':'11999999999','mail':'local-smoke@example.test'},
'items':items,'freight':{'service':'mock-standard','postal_code':'14400000'},
'destination':{'recipient':'Local Smoke Ltda','street':'Rua de Teste','number':'100',
@@ -146,6 +153,8 @@ def run():
client.call('/quotes',{**draft,'items':[{**items[0],'metres':'1.00'}]},expected=422)
client.call('/quotes',{**draft,'customer':{**draft['customer'],'cnpj':'11111111111111'}},expected=422)
quote=client.call('/quotes',draft)
assert quote['status']=='pending_review'
assert client.call('/quotes/'+quote['id'])['review_reason']=='Pedido acima de 50 m'
assert client.call('/quotes',draft)['id']==quote['id']
client.call('/quotes',{**draft,'freight':{'service':'pickup'},'destination':None},expected=409)
qid=quote['id']
@@ -159,9 +168,26 @@ def run():
corrected=[{**items[0],'metres':'1.01','grade':0},*items[1:]]
approved=client.call('/operator/quotes/'+qid+'/approve',{'items':corrected},operator=True)
assert approved['items'][0]['total_cents']==2189
assert approved['total_cents']==2189+6972+19572+23492+int(os.environ.get('MOCK_FREIGHT_CENTS','1500'))
assert approved['total_cents']==2189+32370+90870+109070+int(os.environ.get('MOCK_FREIGHT_CENTS','1500'))
assert approved['destination']=={**draft['destination'],'complement':''}
client.call('/operator/quotes/'+qid+'/approve',{'items':items},operator=True,expected=409)
assert not client.call('/quotes/'+qid)['auto_approved']
# A cart the Site priced is approved at once and can be paid straight away,
# at the server's own prices; no operator can then change it.
small={**draft,'request_key':str(uuid4()),'items':[item_spec('avulsa','2.75',90,uid)]}
auto=client.call('/quotes',small)
assert auto['status']=='approved'
seen=client.call('/quotes/'+auto['id'])
assert seen['auto_approved'] and seen['review_reason'] is None
assert seen['approved']['total_cents']==6972+int(os.environ.get('MOCK_FREIGHT_CENTS','1500'))
assert client.call('/quotes',small)['status']=='approved'
client.call('/operator/quotes/'+auto['id']+'/approve',{'items':small['items']},operator=True,expected=409)
# The Site grades only art it analysed; a discount on unanalysed art waits for a person.
claimed={**item_spec('avulsa','2.75',0,uid),'grade':90}
held=client.call('/quotes',{**small,'request_key':str(uuid4()),'items':[claimed]})
assert held['status']=='pending_review'
assert client.call('/quotes/'+held['id'])['review_reason']=='Nota informada sem análise da arte'
print('PASS: priced carts are approved at checkout; large or inconsistent ones wait for review')
client.call('/orders/dev-paid',{'quote_id':qid,'total_cents':1},expected=422)
other.call('/orders/dev-paid',{'quote_id':qid},expected=404)
# Concurrent retries must produce precisely one payment/order/outbox pair.

View File

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

View File

@@ -1,7 +1,7 @@
"""Customer identity, correction and final-file trust boundaries against local stack."""
from uuid import uuid4
from urllib.request import urlopen
from tests.smoke_test import Client, upload_bytes, item_spec
from tests.smoke_test import Client, approved_quote, upload_bytes, item_spec
def run():
customer=Client();other=Client();customer.call('/session');other.call('/session')
@@ -9,7 +9,7 @@ def run():
item=item_spec('file','1.01',0,uid)
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'}})
customer.call('/operator/quotes/'+q['id']+'/approve',{'items':[item]},operator=True)
approved_quote(customer,q,[item])
order=customer.call('/orders/dev-paid',{'quote_id':q['id']});oid=order['id']
before=list(customer.jar)[0].value
password='local-test-password-'+uuid4().hex

View File

@@ -407,6 +407,8 @@ function renderQuotes(){
node('span',q.draft.items.map(i=>SHORT[i.mode]).join(' + ')+' · '+metres(quoteMetres(q))));
if(q.draft.items.some(i=>i.production?.version!==2))left.append(node('span','Montagem antiga: peça nova cotação','flag'));
else if(q.draft.items.some(i=>i.quality_status==='warning'))left.append(node('span','Ressalva de resolução aceita pelo cliente','flag'));
if(q.auto_approved)left.append(node('span','Aprovada automaticamente'));
else if(q.review_reason)left.append(node('span','Revisão manual: '+q.review_reason,'flag'));
const right=node('div',undefined,'right');
right.append(node('b',q.approved?money(q.approved.total_cents):q.id.slice(0,8),'mono'),node('span',ago(q.created_at)));
b.append(left,right);b.onclick=()=>{currentQuote=q;renderQuotes();};return b;