v0.0.2: pre-productie (Bootstrap 5.3.3, LightPdf klant-PDF, server-setup en release-script)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
bffa053c05
commit
7d302dc405
@@ -2,8 +2,9 @@
|
||||
|
||||
Starten: python app.py
|
||||
E-mail ophalen: python app.py sync (bijv. elke 10 minuten via cron)
|
||||
Demoleads: python app.py demo (standaardmodellen uit demo/modellen als gekozen model)
|
||||
"""
|
||||
__version__ = '0.0.1'
|
||||
__version__ = '0.0.2'
|
||||
|
||||
import csv
|
||||
import io
|
||||
@@ -52,6 +53,18 @@ PRODUCT_LABELS = {'name': 'Naam', 'sku': 'SKU', 'description': 'Omschrijving', '
|
||||
'supplier': 'Leverancier', 'price': 'Prijs', 'vat': 'Btw', 'active': 'Actief', 'created': 'Aangemaakt'}
|
||||
INLINE_MIME = {'image/png', 'image/jpeg', 'image/gif', 'image/webp', 'application/pdf'}
|
||||
|
||||
# Viewer voor woningontwerpen: de engine van de configurator, gekopieerd met tools/configurator-sync.sh
|
||||
# (zelfde volgorde als light.html, zonder PDF en light-UI). Demo-modellen: tools/export-models.js.
|
||||
ENGINE = os.path.join(BASE, 'static', 'configurator')
|
||||
VIEWER_SCRIPTS = ['js/vendor/three.min.js', 'js/vendor/OrbitControls.js', 'js/core/geometry.js',
|
||||
'js/core/view-layers.js', 'js/core/ui-kit.js', 'js/core/skybox.js', 'js/wall/kozijn-model.js',
|
||||
'js/wall/wall-model.js', 'js/floor/floor-model.js', 'js/roof/roof-model.js', 'js/roof/post-model.js',
|
||||
'js/lighting/lighting-model.js', 'js/elektra/elektra-model.js', 'js/sandwich/sandwich-model.js',
|
||||
'js/wall/wall.js', 'js/floor/floor.js', 'js/unit/unit-model.js', 'js/room/room-model.js',
|
||||
'js/interior/fixture-model.js', 'js/house/chimney-model.js', 'js/house/house-model.js',
|
||||
'js/cost/cost-defaults.js', 'js/cost/cost-csv.js', 'js/cost/cost-model.js']
|
||||
DEMO_MODELS = os.path.join(BASE, 'demo', 'modellen')
|
||||
|
||||
SETTING_KEYS = ['company_name', 'company_address', 'company_postcode_city', 'company_phone',
|
||||
'company_email', 'company_website', 'company_kvk', 'company_btw', 'company_iban',
|
||||
'quote_prefix', 'quote_valid_days', 'quote_intro', 'quote_footer',
|
||||
@@ -81,6 +94,12 @@ app = Flask(__name__)
|
||||
app.secret_key = _secret_key()
|
||||
app.config.update(MAX_CONTENT_LENGTH=25 * 1024 * 1024, SESSION_COOKIE_SAMESITE='Lax',
|
||||
PERMANENT_SESSION_LIFETIME=timedelta(days=30))
|
||||
# Achter nginx/Apache met HTTPS (CRM_PROXY=1): schema en host uit X-Forwarded-*, zodat links naar buiten
|
||||
# (portaaluitnodiging, API-URL) https://domein zijn, en sessiecookies alleen over HTTPS.
|
||||
if os.environ.get('CRM_PROXY'):
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
|
||||
app.config.update(SESSION_COOKIE_SECURE=True)
|
||||
|
||||
|
||||
def now():
|
||||
@@ -109,7 +128,10 @@ MIGRATIONS = {
|
||||
'products': {'supplier': "TEXT DEFAULT ''", 'updated_at': 'TEXT',
|
||||
'supplier_id': 'INTEGER REFERENCES relations(id) ON DELETE SET NULL'},
|
||||
'contacts': {'relation_id': 'INTEGER REFERENCES relations(id) ON DELETE SET NULL',
|
||||
'relation_role': "TEXT DEFAULT ''"},
|
||||
'relation_role': "TEXT DEFAULT ''",
|
||||
'design_file_id': 'INTEGER REFERENCES files(id) ON DELETE SET NULL'},
|
||||
'files': {'title': "TEXT DEFAULT ''", 'kind': "TEXT DEFAULT ''", 'design_id': 'INTEGER'},
|
||||
'quotes': {'bestek': "TEXT DEFAULT ''", 'design_file_id': 'INTEGER REFERENCES files(id) ON DELETE SET NULL'},
|
||||
}
|
||||
|
||||
|
||||
@@ -133,6 +155,18 @@ def init_db():
|
||||
"INSERT INTO relations (kind, type, name, created_at, updated_at) VALUES ('partner', 'Leverancier', ?, ?, ?)",
|
||||
(name, now(), now())).lastrowid
|
||||
con.execute('UPDATE products SET supplier_id=? WHERE supplier=? AND supplier_id IS NULL', (rid, name))
|
||||
# woningontwerpen van vóór het gekozen model: titel uit de JSON, het nieuwste wordt het gekozen model
|
||||
for fid, cid, stored in con.execute("SELECT id, contact_id, stored FROM files WHERE original LIKE '%.json' "
|
||||
"AND (title IS NULL OR title='') ORDER BY created_at DESC, id DESC").fetchall():
|
||||
try:
|
||||
with open(os.path.join(UPLOADS, os.path.basename(stored)), encoding='utf-8-sig') as f:
|
||||
design = json.load(f)
|
||||
except (ValueError, OSError):
|
||||
continue
|
||||
if isinstance(design, dict) and design.get('type') == 'house-design':
|
||||
title = str((design.get('project') or {}).get('naam') or '').strip() or 'Woningontwerp'
|
||||
con.execute('UPDATE files SET title=? WHERE id=?', (title, fid))
|
||||
con.execute('UPDATE contacts SET design_file_id=? WHERE id=? AND design_file_id IS NULL', (fid, cid))
|
||||
con.execute('PRAGMA journal_mode=WAL')
|
||||
con.commit()
|
||||
con.close()
|
||||
@@ -337,27 +371,62 @@ def save_upload(fs, contact_id=None):
|
||||
path = os.path.join(UPLOADS, stored)
|
||||
fs.save(path)
|
||||
if contact_id:
|
||||
db().execute('INSERT INTO files (contact_id, stored, original, mime, size, created_at) '
|
||||
'VALUES (?, ?, ?, ?, ?, ?)',
|
||||
(contact_id, stored, fs.filename[:200], fs.mimetype or '', os.path.getsize(path), now()))
|
||||
cur = db().execute('INSERT INTO files (contact_id, stored, original, mime, size, created_at) '
|
||||
'VALUES (?, ?, ?, ?, ?, ?)',
|
||||
(contact_id, stored, fs.filename[:200], fs.mimetype or '', os.path.getsize(path), now()))
|
||||
if ext == '.json':
|
||||
note_design(contact_id, path)
|
||||
note_design(contact_id, path, cur.lastrowid)
|
||||
return stored
|
||||
|
||||
|
||||
def note_design(contact_id, path):
|
||||
"""Een woningontwerp uit de configurator krijgt een regel in de tijdlijn."""
|
||||
def read_design(path):
|
||||
"""Het woningontwerp (house-design-JSON uit de configurator) in een bestand, of None."""
|
||||
try:
|
||||
with open(path, encoding='utf-8-sig') as f:
|
||||
design = json.load(f)
|
||||
except (ValueError, OSError):
|
||||
return None
|
||||
return design if isinstance(design, dict) and design.get('type') == 'house-design' else None
|
||||
|
||||
|
||||
def design_title(design):
|
||||
return str((design.get('project') or {}).get('naam') or '').strip() or 'Woningontwerp'
|
||||
|
||||
|
||||
def note_design(contact_id, path, file_id):
|
||||
"""Een woningontwerp uit de configurator wordt het gekozen model van het contact (het nieuwste telt),
|
||||
met een regel in de tijdlijn."""
|
||||
design = read_design(path)
|
||||
if not design:
|
||||
return
|
||||
if isinstance(design, dict) and design.get('type') == 'house-design':
|
||||
project = design.get('project') or {}
|
||||
parts = [f"Woningontwerp: {project.get('naam') or 'zonder naam'}",
|
||||
' '.join(filter(None, [project.get('straat'), project.get('nummer'), project.get('plaats')])),
|
||||
project.get('opmerking') or '']
|
||||
log(contact_id, 'file', '\n'.join(p for p in parts if p))
|
||||
project = design.get('project') or {}
|
||||
title = design_title(design)
|
||||
db().execute('UPDATE files SET title=? WHERE id=?', (title, file_id))
|
||||
db().execute('UPDATE contacts SET design_file_id=?, updated_at=? WHERE id=?', (file_id, now(), contact_id))
|
||||
parts = [f'Woningontwerp: {title} (gekozen model)',
|
||||
' '.join(filter(None, [project.get('straat'), project.get('nummer'), project.get('plaats')])),
|
||||
project.get('opmerking') or '']
|
||||
log(contact_id, 'file', '\n'.join(p for p in parts if p))
|
||||
|
||||
|
||||
PDF_KINDS = {'werk': 'werktekening', 'klant': 'klant-PDF'}
|
||||
|
||||
|
||||
def design_pdfs(design_ids):
|
||||
"""PDF's (werktekening, klant) per woningontwerp: {design_id: {kind: {url, date, id}}}."""
|
||||
out = {}
|
||||
if design_ids:
|
||||
for f in q(f"SELECT * FROM files WHERE kind IN ('werk', 'klant') AND design_id IN ({', '.join('?' * len(design_ids))})",
|
||||
*design_ids):
|
||||
out.setdefault(f['design_id'], {})[f['kind']] = {
|
||||
'url': url_for('upload_file', name=f['stored']), 'date': fmt_dt(f['created_at']), 'id': f['id']}
|
||||
return out
|
||||
|
||||
|
||||
def contact_designs(contact_id):
|
||||
"""Alle woningontwerpen bij een contact, nieuwste eerst."""
|
||||
return [f for f in q('SELECT * FROM files WHERE contact_id=? AND original LIKE ? ORDER BY created_at DESC, id DESC',
|
||||
contact_id, '%.json') if f['title']]
|
||||
|
||||
|
||||
def save_design_json(design, contact_id):
|
||||
@@ -366,9 +435,9 @@ def save_design_json(design, contact_id):
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
json.dump(design, f, ensure_ascii=False)
|
||||
name = secure_filename(str((design.get('project') or {}).get('naam') or 'ontwerp')) or 'ontwerp'
|
||||
db().execute('INSERT INTO files (contact_id, stored, original, mime, size, created_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
(contact_id, stored, f'{name}.json', 'application/json', os.path.getsize(path), now()))
|
||||
note_design(contact_id, path)
|
||||
cur = db().execute('INSERT INTO files (contact_id, stored, original, mime, size, created_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
(contact_id, stored, f'{name}.json', 'application/json', os.path.getsize(path), now()))
|
||||
note_design(contact_id, path, cur.lastrowid)
|
||||
|
||||
|
||||
def remove_upload(stored):
|
||||
@@ -637,8 +706,9 @@ def dashboard():
|
||||
def contacts():
|
||||
term = request.args.get('q', '').strip()
|
||||
args = {k: request.args.get(k, '') for k in ('type', 'status', 'owner')}
|
||||
sql = ('SELECT c.*, u.name AS user_name, r.name AS relation_name, r.kind AS relation_kind FROM contacts c '
|
||||
'LEFT JOIN users u ON u.id=c.assigned_user_id LEFT JOIN relations r ON r.id=c.relation_id WHERE 1=1')
|
||||
sql = ('SELECT c.*, u.name AS user_name, r.name AS relation_name, r.kind AS relation_kind, f.title AS design_title '
|
||||
'FROM contacts c LEFT JOIN users u ON u.id=c.assigned_user_id LEFT JOIN relations r ON r.id=c.relation_id '
|
||||
'LEFT JOIN files f ON f.id=c.design_file_id WHERE 1=1')
|
||||
params = []
|
||||
if term:
|
||||
sql += (' AND (c.name LIKE ? OR c.email LIKE ? OR c.company LIKE ? OR c.phone LIKE ? OR c.city LIKE ?'
|
||||
@@ -713,6 +783,16 @@ def contact_view(cid):
|
||||
c = q1('SELECT c.*, u.name AS user_name, r.name AS relation_name, r.kind AS relation_kind, r.type AS relation_type '
|
||||
'FROM contacts c LEFT JOIN users u ON u.id=c.assigned_user_id LEFT JOIN relations r ON r.id=c.relation_id '
|
||||
'WHERE c.id=?', cid) or abort(404)
|
||||
designs = contact_designs(cid)
|
||||
design = next((f for f in designs if f['id'] == c['design_file_id']), None)
|
||||
design_meta = {}
|
||||
if design:
|
||||
d = read_design(os.path.join(UPLOADS, design['stored'])) or {}
|
||||
project = d.get('project') or {}
|
||||
design_meta = {'units': len(d.get('units') or []), 'exported': (d.get('exportedAt') or '')[:19],
|
||||
'address': ' '.join(filter(None, [project.get('straat'), project.get('nummer'),
|
||||
project.get('plaats')])),
|
||||
'note': project.get('opmerking') or '', 'version': d.get('configurator') or ''}
|
||||
return render_template(
|
||||
'contact.html', c=c, extra=json.loads(c['extra'] or '{}'),
|
||||
activities=q('SELECT a.*, u.name AS user_name FROM activities a LEFT JOIN users u ON u.id=a.user_id '
|
||||
@@ -721,6 +801,8 @@ def contact_view(cid):
|
||||
emails=q('SELECT * FROM emails WHERE contact_id=? ORDER BY date DESC LIMIT 50', cid),
|
||||
tasks=q('SELECT t.*, u.name AS user_name FROM tasks t LEFT JOIN users u ON u.id=t.user_id '
|
||||
'WHERE t.contact_id=? ORDER BY t.done, t.due_date IS NULL, t.due_date', cid),
|
||||
designs=designs, design=design, design_meta=design_meta,
|
||||
pdfs=design_pdfs([design['id']]).get(design['id'], {}) if design else {}, PDF_KINDS=PDF_KINDS,
|
||||
quotes=quotes_with_totals('WHERE qu.contact_id=?', cid),
|
||||
portal_link=url_for('portal_invite', token=c['portal_token'], _external=True) if c['portal_token'] else '',
|
||||
klant_relations=q("SELECT id, name, type FROM relations WHERE kind='klant' ORDER BY name"),
|
||||
@@ -864,12 +946,75 @@ def upload_file(name):
|
||||
@app.post('/files/<int:fid>/delete')
|
||||
def file_delete(fid):
|
||||
f = get_or_404('files', fid)
|
||||
chosen = f['contact_id'] and scalar('SELECT design_file_id IS ? FROM contacts WHERE id=?', fid, f['contact_id'])
|
||||
for pdf in q('SELECT stored FROM files WHERE design_id=?', fid):
|
||||
remove_upload(pdf['stored'])
|
||||
db().execute('DELETE FROM files WHERE design_id=?', (fid,))
|
||||
remove_upload(f['stored'])
|
||||
db().execute('DELETE FROM files WHERE id=?', (fid,))
|
||||
if chosen:
|
||||
rest = contact_designs(f['contact_id'])
|
||||
db().execute('UPDATE contacts SET design_file_id=? WHERE id=?', (rest[0]['id'] if rest else None, f['contact_id']))
|
||||
db().commit()
|
||||
return redirect(url_for('contact_view', cid=f['contact_id']))
|
||||
|
||||
|
||||
@app.post('/contacts/<int:cid>/design')
|
||||
def contact_design(cid):
|
||||
"""Gekozen model: een van de woningontwerpen bij het contact (of geen)."""
|
||||
c = get_or_404('contacts', cid)
|
||||
fid = request.form.get('file_id', type=int)
|
||||
f = next((d for d in contact_designs(cid) if d['id'] == fid), None)
|
||||
if (f['id'] if f else None) != c['design_file_id']:
|
||||
db().execute('UPDATE contacts SET design_file_id=?, updated_at=? WHERE id=?', (f['id'] if f else None, now(), cid))
|
||||
log(cid, 'status', f"Gekozen model: {f['title']}" if f else 'Geen gekozen model meer')
|
||||
db().commit()
|
||||
return redirect(url_for('contact_view', cid=cid))
|
||||
|
||||
|
||||
@app.route('/files/<int:fid>/viewer')
|
||||
def design_viewer(fid):
|
||||
"""3D-viewer van een woningontwerp (in een iframe op de contactpagina, of los)."""
|
||||
f = get_or_404('files', fid)
|
||||
design = read_design(os.path.join(UPLOADS, os.path.basename(f['stored']))) or abort(404)
|
||||
pdfs = design_pdfs([fid]).get(fid, {})
|
||||
cfg = {'pdfUrl': url_for('design_pdf', fid=fid) if f['contact_id'] else '', 'pdfs': pdfs,
|
||||
'auto': len(pdfs) < len(PDF_KINDS)}
|
||||
resp = make_response(render_template(
|
||||
'viewer.html', design=design, name=f['title'] or design_title(design), scripts=VIEWER_SCRIPTS, cfg=cfg,
|
||||
engine=os.path.exists(os.path.join(ENGINE, 'js', 'house', 'house-model.js'))))
|
||||
resp.headers['X-Frame-Options'] = 'SAMEORIGIN'
|
||||
return resp
|
||||
|
||||
|
||||
@app.post('/files/<int:fid>/pdf')
|
||||
def design_pdf(fid):
|
||||
"""PDF van een woningontwerp, gemaakt door de viewer (HousePdf van de configurator). Per ontwerp één werktekening
|
||||
(intern) en één klant-PDF (ook in het portaal); een nieuwe vervangt de vorige."""
|
||||
f = get_or_404('files', fid)
|
||||
kind, fs = request.form.get('kind'), request.files.get('pdf')
|
||||
if kind not in PDF_KINDS or not fs or not f['contact_id'] or not f['title']:
|
||||
return jsonify(ok=False, error='Ongeldige PDF'), 400
|
||||
head = fs.stream.read(5)
|
||||
fs.stream.seek(0)
|
||||
if head != b'%PDF-':
|
||||
return jsonify(ok=False, error='Geen PDF'), 400
|
||||
for old in q('SELECT * FROM files WHERE design_id=? AND kind=?', fid, kind):
|
||||
remove_upload(old['stored'])
|
||||
db().execute('DELETE FROM files WHERE id=?', (old['id'],))
|
||||
stored = uuid.uuid4().hex + '.pdf'
|
||||
fs.save(os.path.join(UPLOADS, stored))
|
||||
name = secure_filename(f"{f['title']} {PDF_KINDS[kind]}") or 'ontwerp'
|
||||
cur = db().execute('INSERT INTO files (contact_id, stored, original, mime, size, created_at, title, kind, design_id) '
|
||||
'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
(f['contact_id'], stored, f'{name}.pdf', 'application/pdf',
|
||||
os.path.getsize(os.path.join(UPLOADS, stored)), now(), '', kind, fid))
|
||||
log(f['contact_id'], 'file', f"{PDF_KINDS[kind][:1].upper() + PDF_KINDS[kind][1:]} gemaakt van ontwerp {f['title']}")
|
||||
db().commit()
|
||||
row = q1('SELECT * FROM files WHERE id=?', cur.lastrowid)
|
||||
return jsonify(ok=True, url=url_for('upload_file', name=stored), date=fmt_dt(row['created_at']), id=row['id'])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- relaties
|
||||
|
||||
def relation_form_data():
|
||||
@@ -969,6 +1114,9 @@ def relation_view(rid):
|
||||
'WHERE c.relation_id=? ORDER BY c.name', rid)
|
||||
ids = [m['id'] for m in members] or [0]
|
||||
marks = ', '.join('?' * len(ids))
|
||||
designs = q(f'SELECT f.*, c.name AS contact_name, c.design_file_id = f.id AS chosen FROM files f '
|
||||
f"JOIN contacts c ON c.id=f.contact_id WHERE f.contact_id IN ({marks}) AND f.title<>'' "
|
||||
f"AND f.original LIKE '%.json' ORDER BY chosen DESC, f.created_at DESC", *ids)
|
||||
return render_template(
|
||||
'relation.html', r=r, members=members,
|
||||
candidates=q('SELECT id, name, email FROM contacts WHERE relation_id IS NULL ORDER BY name LIMIT 500'),
|
||||
@@ -978,7 +1126,8 @@ def relation_view(rid):
|
||||
f'WHERE a.contact_id IN ({marks}) ORDER BY a.created_at DESC LIMIT 30', *ids),
|
||||
tasks=q(f'SELECT t.*, c.name AS contact_name FROM tasks t JOIN contacts c ON c.id=t.contact_id '
|
||||
f'WHERE t.done=0 AND t.contact_id IN ({marks}) ORDER BY t.due_date IS NULL, t.due_date', *ids),
|
||||
products=q('SELECT * FROM products WHERE supplier_id=? ORDER BY active DESC, name', rid))
|
||||
products=q('SELECT * FROM products WHERE supplier_id=? ORDER BY active DESC, name', rid),
|
||||
designs=designs, pdfs=design_pdfs([d['id'] for d in designs]), PDF_KINDS=PDF_KINDS)
|
||||
|
||||
|
||||
@app.post('/relations/<int:rid>/member')
|
||||
@@ -1442,6 +1591,8 @@ def quote_new():
|
||||
if cid:
|
||||
log(cid, 'quote', f'Offerte {number} aangemaakt')
|
||||
db().commit()
|
||||
if request.form.get('bestek'):
|
||||
return redirect(url_for('quote_bestek', qid=cur.lastrowid))
|
||||
return redirect(url_for('quote_edit', qid=cur.lastrowid))
|
||||
|
||||
|
||||
@@ -1473,7 +1624,8 @@ def quote_edit(qid):
|
||||
flash('Offerte opgeslagen.')
|
||||
return redirect(url_for('quote_edit', qid=qid))
|
||||
return render_template(
|
||||
'quote_edit.html', qt=qt,
|
||||
'quote_edit.html', qt=qt, bestek=quote_bestek_data(qt),
|
||||
designs=contact_designs(qt['contact_id']) if qt['contact_id'] else [],
|
||||
lines=q('SELECT * FROM quote_lines WHERE quote_id=? ORDER BY position', qid),
|
||||
contacts=q('SELECT id, name, company FROM contacts ORDER BY name'),
|
||||
products=q('SELECT * FROM products WHERE active=1 ORDER BY name'),
|
||||
@@ -1485,7 +1637,7 @@ def render_quote(qt, portal=False):
|
||||
contact = q1('SELECT c.*, r.name AS relation_name, r.kind AS relation_kind FROM contacts c '
|
||||
'LEFT JOIN relations r ON r.id=c.relation_id WHERE c.id=?', qt['contact_id']) if qt['contact_id'] else None
|
||||
return render_template('quote_print.html', qt=qt, lines=lines, c=contact, t=quote_totals(lines),
|
||||
s=settings_dict(), portal=portal)
|
||||
s=settings_dict(), portal=portal, bestek=quote_bestek_data(qt))
|
||||
|
||||
|
||||
@app.route('/quotes/<int:qid>/print')
|
||||
@@ -1493,6 +1645,160 @@ def quote_print(qid):
|
||||
return render_quote(get_or_404('quotes', qid))
|
||||
|
||||
|
||||
# Bestek uit het woningontwerp: de berekening draait in de browser op de engine van de configurator
|
||||
# (static/bestek.js); hier worden het bestek bewaard en de offerteregels gemaakt.
|
||||
|
||||
def quote_bestek_data(qt):
|
||||
try:
|
||||
data = json.loads(qt['bestek'] or 'null')
|
||||
except ValueError:
|
||||
return None
|
||||
return data if isinstance(data, dict) and isinstance(data.get('chapters'), list) else None
|
||||
|
||||
|
||||
def fmt_qty(value, unit=''):
|
||||
value = round(float(value or 0), 2 if unit == 'm³' else 1)
|
||||
return f'{value:g}'.replace('.', ',')
|
||||
|
||||
|
||||
def clean_bestek(raw):
|
||||
"""Bestek uit het formulier controleren en tot de bekende velden terugbrengen."""
|
||||
try:
|
||||
data = json.loads(raw or '')
|
||||
except ValueError:
|
||||
return None
|
||||
if not isinstance(data, dict) or not isinstance(data.get('chapters'), list):
|
||||
return None
|
||||
def product(p):
|
||||
if not isinstance(p, dict) or not str(p.get('sku') or '').strip():
|
||||
return None
|
||||
return {k: str(p.get(k) or '').strip()[:limit] for k, limit in
|
||||
(('sku', 80), ('name', 200), ('description', 1000), ('supplier', 200), ('article', 80))}
|
||||
|
||||
def line(l):
|
||||
out = {'label': str(l.get('label') or '')[:300], 'qty': num(l.get('qty')), 'unit': str(l.get('unit') or '')[:20],
|
||||
'price': round(num(l.get('price')), 2), 'unitPrice': round(num(l.get('unitPrice')), 4)}
|
||||
if product(l.get('product')):
|
||||
out['product'] = product(l['product'])
|
||||
return out
|
||||
|
||||
chapters = [{'key': str(ch.get('key') or '')[:40], 'name': str(ch.get('name') or '')[:120],
|
||||
'price': round(num(ch.get('price')), 2),
|
||||
'lines': [line(l) for l in ch.get('lines') or [] if isinstance(l, dict)],
|
||||
'info': [line(l) for l in ch.get('info') or [] if isinstance(l, dict)]}
|
||||
for ch in data['chapters'] if isinstance(ch, dict)]
|
||||
if not chapters:
|
||||
return None
|
||||
totals = data.get('totals') if isinstance(data.get('totals'), dict) else {}
|
||||
design = data.get('design') if isinstance(data.get('design'), dict) else {}
|
||||
return {'version': 1, 'computedAt': str(data.get('computedAt') or now())[:30], 'vat': num(data.get('vat'), 21),
|
||||
'units': int(num(data.get('units'))), 'configurator': str(data.get('configurator') or '')[:20],
|
||||
'design': {'file_id': design.get('file_id'), 'title': str(design.get('title') or '')[:120]},
|
||||
'totals': {k: round(num(totals.get(k)), 2) for k in ('exBtw', 'btw', 'inclBtw')}, 'chapters': chapters}
|
||||
|
||||
|
||||
def bestek_rows(b, mode='post'):
|
||||
"""Offerteregels uit een bestek: dicts met desc, qty, unit, price (per eenheid) en product (of None).
|
||||
|
||||
post: elke post met zijn hoeveelheid (42 spots = 42 st. × prijs per spot); hoofdstuk: één regel per hoofdstuk.
|
||||
De prijs per eenheid is afgerond op centen; het verschil met het hoofdstuk gaat naar de laatste regel van
|
||||
1 stuk/post zonder product (bevestiging en bewerking, plaatsen met kraan), wat dan nog over is naar de
|
||||
laatste zo'n regel van de offerte, zodat het totaal precies dat van de configurator is."""
|
||||
def absorb(rows, target):
|
||||
diff = round(target - sum(r['qty'] * r['price'] for r in rows), 2)
|
||||
spot = next((r for r in reversed(rows) if r['qty'] == 1 and not r['product']), None) or \
|
||||
next((r for r in reversed(rows) if r['qty'] == 1), None)
|
||||
if spot and diff:
|
||||
spot['price'] = round(spot['price'] + diff, 2)
|
||||
|
||||
if mode == 'hoofdstuk':
|
||||
rows = [{'desc': ch['name'] + '\n' + ', '.join(f"{l['label']} {fmt_qty(l['qty'], l['unit'])} {l['unit']}".strip()
|
||||
for l in ch['lines']),
|
||||
'qty': 1, 'unit': 'post', 'price': ch['price'], 'product': None} for ch in b['chapters']]
|
||||
else:
|
||||
rows = []
|
||||
for ch in b['chapters']:
|
||||
part = [{'desc': f"{l['label']}\n{ch['name']}", 'qty': l['qty'] or 1, 'unit': l['unit'],
|
||||
'price': round(l.get('unitPrice') or (l['price'] / l['qty'] if l['qty'] else l['price']), 2),
|
||||
'product': l.get('product')} for l in ch['lines']]
|
||||
absorb(part, ch['price'])
|
||||
rows += part
|
||||
absorb(rows, b['totals'].get('exBtw') or sum(ch['price'] for ch in b['chapters']))
|
||||
return rows
|
||||
|
||||
|
||||
def bestek_product(row, vat, update_price, source):
|
||||
"""Product bij een bestekpost (SKU = code uit de kosten van de configurator); bestaat het nog niet, dan wordt
|
||||
het aangemaakt met de prijs per eenheid van deze berekening."""
|
||||
p = row['product']
|
||||
existing = find_product(p['sku'])
|
||||
if existing is None:
|
||||
description = '\n'.join(filter(None, [p['description'], f"Artikelnummer leverancier: {p['article']}" if p['article'] else '']))
|
||||
return upsert_product({'sku': p['sku'], 'name': p['name'] or row['desc'].split('\n')[0], 'unit': row['unit'],
|
||||
'price': row['price'], 'vat': vat, 'description': description, 'supplier': p['supplier']},
|
||||
source)[2]
|
||||
if update_price:
|
||||
upsert_product({'sku': p['sku'], 'price': row['price']}, source, existing=existing)
|
||||
return existing['id']
|
||||
|
||||
|
||||
@app.route('/quotes/<int:qid>/bestek')
|
||||
def quote_bestek(qid):
|
||||
qt = get_or_404('quotes', qid)
|
||||
contact = q1('SELECT * FROM contacts WHERE id=?', qt['contact_id']) if qt['contact_id'] else None
|
||||
designs = contact_designs(contact['id']) if contact else []
|
||||
want = request.args.get('file', type=int) or qt['design_file_id'] or (contact['design_file_id'] if contact else None)
|
||||
f = next((d for d in designs if d['id'] == want), designs[0] if designs else None)
|
||||
if not f:
|
||||
flash('Er is geen woningontwerp bij het contact van deze offerte. Kies eerst een klant met een gekozen model.', 'error')
|
||||
return redirect(url_for('quote_edit', qid=qid))
|
||||
design = read_design(os.path.join(UPLOADS, os.path.basename(f['stored']))) or abort(404)
|
||||
return render_template('bestek.html', qt=qt, c=contact, design=design, design_file=f, designs=designs,
|
||||
scripts=VIEWER_SCRIPTS, has_lines=scalar('SELECT COUNT(*) FROM quote_lines WHERE quote_id=?', qid),
|
||||
engine=os.path.exists(os.path.join(ENGINE, 'js', 'house', 'house-model.js')))
|
||||
|
||||
|
||||
@app.post('/quotes/<int:qid>/bestek')
|
||||
def quote_bestek_save(qid):
|
||||
qt = get_or_404('quotes', qid)
|
||||
b = clean_bestek(request.form.get('bestek'))
|
||||
if not b:
|
||||
flash('Het bestek kon niet worden overgenomen.', 'error')
|
||||
return redirect(url_for('quote_bestek', qid=qid))
|
||||
fid = request.form.get('file_id', type=int)
|
||||
if qt['contact_id'] and fid not in {d['id'] for d in contact_designs(qt['contact_id'])}:
|
||||
fid = None
|
||||
vat = b['vat']
|
||||
rows = bestek_rows(b, request.form.get('mode', 'post'))
|
||||
if request.form.get('replace'):
|
||||
db().execute('DELETE FROM quote_lines WHERE quote_id=?', (qid,))
|
||||
start = scalar('SELECT COALESCE(MAX(position), -1) + 1 FROM quote_lines WHERE quote_id=?', qid)
|
||||
source, linked = f"Bestek offerte {qt['number']}", 0
|
||||
for pos, r in enumerate(rows, start):
|
||||
pid = bestek_product(r, vat, bool(request.form.get('update_prices')), source) if r['product'] else None
|
||||
linked += bool(pid)
|
||||
db().execute('INSERT INTO quote_lines (quote_id, position, product_id, description, qty, unit, price, vat) '
|
||||
'VALUES (?, ?, ?, ?, ?, ?, ?, ?)', (qid, pos, pid, r['desc'], r['qty'], r['unit'], r['price'], vat))
|
||||
title = qt['title'] or (f"Woning {b['design']['title']}" if b['design']['title'] else '')
|
||||
db().execute('UPDATE quotes SET bestek=?, design_file_id=?, title=?, updated_at=? WHERE id=?',
|
||||
(json.dumps(b, ensure_ascii=False), fid, title, now(), qid))
|
||||
if qt['contact_id']:
|
||||
log(qt['contact_id'], 'quote', f"Offerte {qt['number']}: bestek uit ontwerp {b['design']['title']} "
|
||||
f"({euro(b['totals']['exBtw'])} excl. btw)")
|
||||
db().commit()
|
||||
flash(f'Bestek overgenomen: {len(rows)} regel(s), waarvan {linked} gekoppeld aan een product.')
|
||||
return redirect(url_for('quote_edit', qid=qid))
|
||||
|
||||
|
||||
@app.post('/quotes/<int:qid>/bestek/delete')
|
||||
def quote_bestek_delete(qid):
|
||||
get_or_404('quotes', qid)
|
||||
db().execute("UPDATE quotes SET bestek='', design_file_id=NULL, updated_at=? WHERE id=?", (now(), qid))
|
||||
db().commit()
|
||||
flash('Bestek losgekoppeld; de offerteregels zijn gebleven.')
|
||||
return redirect(url_for('quote_edit', qid=qid))
|
||||
|
||||
|
||||
@app.post('/quotes/<int:qid>/delete')
|
||||
def quote_delete(qid):
|
||||
get_or_404('quotes', qid)
|
||||
@@ -1573,7 +1879,7 @@ def portal_home():
|
||||
return render_template(
|
||||
'portal.html', c=g.portal,
|
||||
quotes=quotes_with_totals("WHERE qu.contact_id=? AND qu.status<>'concept'", cid),
|
||||
files=q('SELECT * FROM files WHERE contact_id=? ORDER BY created_at DESC', cid),
|
||||
files=q("SELECT * FROM files WHERE contact_id=? AND kind<>'werk' ORDER BY created_at DESC", cid),
|
||||
messages=q("SELECT * FROM activities WHERE contact_id=? AND type='portal' AND body LIKE 'Bericht:%' "
|
||||
"ORDER BY created_at DESC LIMIT 20", cid),
|
||||
s=settings_dict())
|
||||
@@ -1626,7 +1932,7 @@ def portal_message():
|
||||
|
||||
@app.route('/portal/files/<name>')
|
||||
def portal_file(name):
|
||||
f = q1('SELECT * FROM files WHERE stored=? AND contact_id=?', name, g.portal['id'])
|
||||
f = q1("SELECT * FROM files WHERE stored=? AND contact_id=? AND kind<>'werk'", name, g.portal['id'])
|
||||
if f is None:
|
||||
abort(404)
|
||||
return serve_upload(name, f['original'])
|
||||
@@ -1748,7 +2054,52 @@ def api_products():
|
||||
return jsonify(ok=True, **result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- demo
|
||||
|
||||
DEMO_LEADS = [
|
||||
('Lorem Ipsum', 'Utrecht', 'nieuw', 'amstel'),
|
||||
('Dolor Sitamet', 'Amersfoort', 'contact', 'wetering'),
|
||||
('Consectetur Adipiscing', 'Zwolle', 'nieuw', 'ijssel'),
|
||||
('Sed Doeiusmod', 'Nijmegen', 'offerte', 'waal'),
|
||||
('Tempor Incididunt', 'Maastricht', 'contact', 'maas'),
|
||||
('Labore Etdolore', 'Arnhem', 'nieuw', 'rijn'),
|
||||
('Magna Aliqua', 'Meppel', 'offerte', 'reest'),
|
||||
('Enim Adminim', 'Breukelen', 'nieuw', 'vecht'),
|
||||
]
|
||||
DEMO_MESSAGE = ('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Wij zoeken een mantelzorgwoning '
|
||||
'voor in de tuin; graag meer informatie over het model en de mogelijkheden.')
|
||||
|
||||
|
||||
def seed_demo():
|
||||
"""Demoleads met elk een standaardmodel als gekozen model (demo/modellen, uit tools/export-models.js).
|
||||
Bestaat een demolead al (zelfde e-mailadres), dan wordt hij overgeslagen."""
|
||||
admin = q1("SELECT id FROM users WHERE role='admin' AND active=1 ORDER BY id")
|
||||
made = 0
|
||||
for i, (name, city, status, model) in enumerate(DEMO_LEADS):
|
||||
path = os.path.join(DEMO_MODELS, f'{model}.json')
|
||||
design = read_design(path)
|
||||
if not design:
|
||||
print(f'{model}.json ontbreekt in {DEMO_MODELS}; draai node tools/export-models.js')
|
||||
continue
|
||||
email = name.lower().replace(' ', '.') + '@example.nl'
|
||||
cid, outcome = upsert_contact(
|
||||
{'name': name, 'email': email, 'phone': f'06-{12345670 + i}', 'city': city, 'status': status},
|
||||
{'model': design_title(design)}, update_existing=False,
|
||||
defaults={'source': 'Demo', 'assigned': admin['id'] if admin else None})
|
||||
if outcome != 'created':
|
||||
continue
|
||||
log(cid, 'form', f'Formulier ontvangen via Demo\n{DEMO_MESSAGE}')
|
||||
save_design_json(design, cid)
|
||||
made += 1
|
||||
db().commit()
|
||||
print(f'{made} demolead(s) aangemaakt')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) > 1 and sys.argv[1] == 'demo':
|
||||
with app.app_context():
|
||||
seed_demo()
|
||||
sys.exit(0)
|
||||
if len(sys.argv) > 1 and sys.argv[1] == 'sync':
|
||||
with app.app_context():
|
||||
n, err = do_sync()
|
||||
|
||||
Reference in New Issue
Block a user