Flask + SQLite, in de Van Emous-huisstijl (tokens uit het design system, GSAP-effecten volgens de motion-regels, uit bij prefers-reduced-motion). - Leads en contactpersonen: status, toewijzen, verwachte waarde, omzetten naar klant, tijdlijn, bestanden, taken/opvolging - Relaties (zoals accounts in EspoCRM): klantbundels (huishouden/familie) en partners met type (leverancier, aannemer, ...); leveranciers van producten worden automatisch partners - API voor formulieren (POST /api/leads) met dubbele-leaddetectie, bestanden en configurator-ontwerpen (house-design JSON) - Import van CSV/JSON (leads, relaties, producten) met kolomkoppeling; configurator-formaat - Producten met SKU als vaste sleutel, mutatiehistorie, POST /api/products, CSV-export; offerteregels houden hun eigen prijs - Offertes met btw per tarief en afdrukversie (PDF via de browser) - Inbox via IMAP/SMTP, gekoppeld aan contacten - Gebruikers (beheerder/medewerker) en klantportaal (offertes accepteren, bestanden, berichten) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
131 lines
6.1 KiB
Python
131 lines
6.1 KiB
Python
"""CSV/JSON inlezen voor de import, en het herkennen van kolomnamen.
|
|
|
|
CSV: scheidingsteken `;`, `,` of tab (automatisch), UTF-8 met of zonder BOM (of Windows-1252),
|
|
regels die met `#` beginnen worden overgeslagen — zoals de CSV's van de configurator.
|
|
JSON: een lijst met objecten, een object met daarin een lijst (bijv. {"contacten": [...]}) of één object.
|
|
"""
|
|
import csv
|
|
import json
|
|
|
|
FIELD_ALIASES = {
|
|
'name': ['name', 'naam', 'full_name', 'fullname', 'volledige_naam', 'your-name', 'klant', 'klantnaam'],
|
|
'first_name': ['first_name', 'firstname', 'voornaam'],
|
|
'last_name': ['last_name', 'lastname', 'achternaam'],
|
|
'email': ['email', 'e-mail', 'e_mail', 'emailadres', 'e-mailadres', 'mail', 'your-email'],
|
|
'phone': ['phone', 'telefoon', 'tel', 'telefoonnummer', 'phone_number', 'mobiel', 'your-phone'],
|
|
'company': ['company', 'bedrijf', 'bedrijfsnaam', 'organisatie'],
|
|
'relation': ['relatie', 'relation', 'account', 'huishouden', 'familie', 'account_name'],
|
|
'relation_role': ['rol', 'role', 'relatie_rol', 'functie'],
|
|
'address': ['address', 'adres', 'straat', 'street'],
|
|
'postcode': ['postcode', 'zip', 'zipcode', 'postal_code'],
|
|
'city': ['city', 'plaats', 'woonplaats', 'stad'],
|
|
'message': ['message', 'bericht', 'opmerking', 'opmerkingen', 'vraag', 'comments', 'your-message'],
|
|
'source': ['source', 'bron', 'formulier', 'form', 'form_name', 'lead_source'],
|
|
'status': ['status', 'fase'],
|
|
'value': ['value', 'waarde', 'bedrag', 'budget', 'verwachte_waarde'],
|
|
'notes': ['notes', 'notities', 'notitie'],
|
|
}
|
|
|
|
PRODUCT_ALIASES = {
|
|
'name': ['name', 'naam', 'product', 'productnaam', 'titel', 'omschrijving'],
|
|
'sku': ['sku', 'code', 'artikelnummer', 'artikelnr', 'artikel'],
|
|
'description': ['description', 'beschrijving', 'toelichting', 'details'],
|
|
'unit': ['unit', 'eenheid'],
|
|
'price': ['price', 'prijs', 'waarde', 'verkoopprijs', 'prijs_excl'],
|
|
'vat': ['vat', 'btw', 'btw%', 'btw_percentage'],
|
|
'supplier': ['supplier', 'leverancier'],
|
|
'active': ['active', 'actief'],
|
|
}
|
|
|
|
RELATION_ALIASES = {
|
|
'name': ['name', 'naam', 'bedrijf', 'bedrijfsnaam', 'relatie', 'organisatie', 'leverancier', 'familie'],
|
|
'kind': ['kind', 'soort'],
|
|
'type': ['type', 'categorie', 'branche'],
|
|
'email': ['email', 'e-mail', 'emailadres', 'e-mailadres', 'mail'],
|
|
'phone': ['phone', 'telefoon', 'tel', 'telefoonnummer'],
|
|
'website': ['website', 'url', 'site', 'web'],
|
|
'address': ['address', 'adres', 'straat'],
|
|
'postcode': ['postcode', 'zip', 'zipcode'],
|
|
'city': ['city', 'plaats', 'woonplaats', 'vestigingsplaats'],
|
|
'kvk': ['kvk', 'kvk-nummer', 'kvk_nummer', 'kvknummer', 'coc'],
|
|
'notes': ['notes', 'notities', 'opmerking', 'opmerkingen'],
|
|
}
|
|
|
|
TARGETS = {
|
|
'contacts': [('', '— overslaan —'), ('name', 'Naam'), ('first_name', 'Voornaam'), ('last_name', 'Achternaam'),
|
|
('email', 'E-mail'), ('phone', 'Telefoon'), ('company', 'Bedrijf'), ('address', 'Adres'),
|
|
('postcode', 'Postcode'), ('city', 'Plaats'), ('source', 'Bron'), ('status', 'Status'),
|
|
('value', 'Verwachte waarde'), ('notes', 'Notities'), ('message', 'Bericht (in tijdlijn)'),
|
|
('relation', 'Relatie (naam)'), ('relation_role', 'Rol binnen relatie'),
|
|
('extra', 'Extra veld (bewaren)')],
|
|
'relations': [('', '— overslaan —'), ('name', 'Naam'), ('kind', 'Soort (klant/partner)'), ('type', 'Type'),
|
|
('email', 'E-mail'), ('phone', 'Telefoon'), ('website', 'Website'), ('address', 'Adres'),
|
|
('postcode', 'Postcode'), ('city', 'Plaats'), ('kvk', 'KvK-nummer'), ('notes', 'Notities')],
|
|
'products': [('', '— overslaan —'), ('name', 'Naam'), ('sku', 'Artikelnummer / code'),
|
|
('description', 'Omschrijving'), ('unit', 'Eenheid'), ('price', 'Prijs excl. btw'),
|
|
('vat', 'Btw %'), ('supplier', 'Leverancier'), ('active', 'Actief (ja/nee)')],
|
|
}
|
|
|
|
|
|
class ImportFout(ValueError):
|
|
pass
|
|
|
|
|
|
def _key(name):
|
|
return str(name).strip().lower().replace(' ', '_')
|
|
|
|
|
|
def lookup(aliases):
|
|
return {alias: field for field, names in aliases.items() for alias in names}
|
|
|
|
|
|
def guess(cols, kind):
|
|
"""Stel per kolom een doelveld voor; elk doelveld maar één keer (de eerste kolom wint)."""
|
|
table = lookup({'contacts': FIELD_ALIASES, 'products': PRODUCT_ALIASES, 'relations': RELATION_ALIASES}[kind])
|
|
used, mapping = set(), []
|
|
for col in cols:
|
|
target = table.get(_key(col), '')
|
|
if target in used:
|
|
target = ''
|
|
if target:
|
|
used.add(target)
|
|
mapping.append(target or ('extra' if kind == 'contacts' else ''))
|
|
return mapping
|
|
|
|
|
|
def read(path):
|
|
"""Geef (kolommen, rijen) terug; rijen zijn dicts."""
|
|
with open(path, 'rb') as f:
|
|
raw = f.read()
|
|
try:
|
|
text = raw.decode('utf-8-sig')
|
|
except UnicodeDecodeError:
|
|
text = raw.decode('cp1252')
|
|
|
|
if path.endswith('.json'):
|
|
try:
|
|
data = json.loads(text)
|
|
except json.JSONDecodeError as e:
|
|
raise ImportFout(f'Geen geldige JSON: {e}') from e
|
|
if isinstance(data, dict):
|
|
if data.get('type') == 'house-design':
|
|
raise ImportFout('Dit is een ontwerp uit de configurator. Upload het bij een contact onder Bestanden.')
|
|
lists = [v for v in data.values() if isinstance(v, list)]
|
|
data = lists[0] if lists else [data]
|
|
if not isinstance(data, list):
|
|
raise ImportFout('Verwacht een lijst met objecten.')
|
|
rows = [r for r in data if isinstance(r, dict)]
|
|
cols = []
|
|
for row in rows:
|
|
cols += [k for k in row if k not in cols]
|
|
return cols, rows
|
|
|
|
lines = [line for line in text.splitlines() if line.strip() and not line.lstrip().startswith('#')]
|
|
if not lines:
|
|
raise ImportFout('Het bestand is leeg.')
|
|
delimiter = max([';', ',', '\t'], key=lines[0].count)
|
|
reader = csv.reader(lines, delimiter=delimiter)
|
|
cols = [c.strip() for c in next(reader)]
|
|
rows = [dict(zip(cols, (v.strip() for v in r))) for r in reader if any(v.strip() for v in r)]
|
|
return cols, rows
|