v0.0.1: eerste versie van de Van Emous CRM
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>
This commit is contained in:
+130
@@ -0,0 +1,130 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user