fix: extract address/company from invoice footer dash-format (KONI Accounting style)

This commit is contained in:
Christian 2026-03-01 12:22:14 +01:00
parent a8970701ab
commit 04acdecb91
2 changed files with 145 additions and 31 deletions

View File

@ -1 +1 @@
2.2.6
2.2.7

View File

@ -396,6 +396,35 @@ async def extract_vendor_suggestion(email_id: int):
return bare
return raw.strip()[:20]
# Kendte faktureringsplatforme der sender på vegne af leverandører
PLATFORM_DOMAINS = {
'e-conomic.com', 'e-conomic.dk', 'dinero.dk', 'billy.dk',
'uniconta.com', 'visma.com', 'simplybilling.dk', 'fakturasend.dk',
'invoicecloud.com', 'invoiced.com', 'stripe.com', 'paypal.com',
}
# Placeholder-adresser (e-conomic og lignende skabeloner)
PLACEHOLDER_ADDRESSES = {
'vejnavn 1, 1234 by', 'vejnavn 1,1234 by', 'vejnavn 1',
'1234 by', 'adresse 1', 'eksempel 1', 'gadenavn 1',
}
def is_placeholder_cvr(cvr: str) -> bool:
"""Filtrer åbenlyse placeholder/dummy CVRs"""
known_fakes = {
'12345678', '87654321', '00000000', '11111111', '22222222',
'33333333', '44444444', '55555555', '66666666', '77777777',
'88888888', '99999999', '12341234', '11223344', '99887766',
}
if cvr in known_fakes:
return True
if len(set(cvr)) == 1:
return True
digits = [int(c) for c in cvr]
if all(digits[i+1] - digits[i] == 1 for i in range(7)):
return True
return False
def extract_cvr(text: str, own_cvr: str = '') -> Optional[str]:
patterns = [
# Med label
@ -408,7 +437,7 @@ async def extract_vendor_suggestion(email_id: int):
for pat in patterns:
for m in re.finditer(pat, text, re.IGNORECASE):
val = m.group(1)
if val != own_cvr and val.isdigit():
if val != own_cvr and val.isdigit() and not is_placeholder_cvr(val):
return val
return None
@ -429,47 +458,89 @@ async def extract_vendor_suggestion(email_id: int):
return None
def extract_address(text: str) -> Optional[str]:
# Dansk postnummer 4 cifre + by
# Format 1: "Vejnavn 12K[, etage/side][ -/,] 4000 By"
# Håndterer: "Jernbanegade 12K, st.tv - 4000 Roskilde"
# "Nørregade 5, 1. sal, 8000 Aarhus"
# "Industrivej 3 - 2200 København N"
m = re.search(
r'([A-ZÆØÅ][a-zæøåA-ZÆØÅ\-\.]+(?:\s+\d+[A-Za-z]?(?:,?\s*(?:st|tv|th|\d+\.?\s*(?:sal|etage)?))?)?,?\s*\d{4}\s+[A-ZÆØÅ][a-zæøåA-ZÆØÅ\s\-]+)',
r'([A-ZÆØÅ][a-zæøåA-ZÆØÅ\-\.]{2,}\s+\d+[A-Za-z]?'
r'(?:[,\s]+[a-zæøåA-ZÆØÅ0-9][a-zæøåA-ZÆØÅ0-9\.\s]{0,15}?)?'
r'(?:[,\s]+|\s*[-]\s*)\d{4}\s+[A-ZÆØÅ][a-zæøåA-ZÆØÅ]{2,})',
text
)
if m:
return m.group(0).strip()
# Fallback: vejnavn + husnummer + postnummer
# Format 2: vejnavn-suffix (vej/gade/alle osv.) + husnummer + postnummer
m = re.search(
r'([A-ZÆØÅ][a-zæøåA-ZÆØÅ]+(?:vej|gade|alle|vænge|torv|plads|stræde|boulevard|have|bakke|skov|park|strand|mark|eng)\s*\d+[A-Za-z]?\s*,?\s*\d{4})',
r'([A-ZÆØÅ][a-zæøåA-ZÆØÅ]+(?:vej|gade|alle|vænge|torv|plads|stræde|boulevard|have|bakke|skov|park|strand|mark|eng)'
r'\s*\d+[A-Za-z]?(?:[,\s]+|\s*[-]\s*)\d{4}(?:\s+[A-ZÆØÅ][a-zæøåA-ZÆØÅ]+)?)',
text, re.IGNORECASE
)
if m:
return m.group(0).strip()
# Format 3: find postnummer + by, tag kontekst foran
m = re.search(r'(\d{4}\s+[A-ZÆØÅ][a-zæøåA-ZÆØÅ]{2,})', text)
if m:
start = max(0, m.start() - 50)
snippet = text[start:m.end()].strip().lstrip('-, ')
return snippet
return None
def is_platform_or_spam(domain: str) -> bool:
"""Returnerer True hvis domænet tilhører en platform/mailsystem"""
spam = {'gmail.com', 'hotmail.com', 'outlook.com', 'yahoo.com', 'live.com', 'icloud.com'}
return domain in spam or domain in PLATFORM_DOMAINS or 'bmc' in domain
def extract_domain(text: str, sender_email: str = '') -> Optional[str]:
# Eksplicit www
m = re.search(r'(?:www\.|https?://)([\w\-]+\.[\w\-]+(?:\.[\w]{2,6})?)', text, re.IGNORECASE)
if m:
return m.group(1).lower()
# Emailadresser i teksten (ikke @bmcnetworks)
dom = m.group(1).lower()
if not is_platform_or_spam(dom):
return dom
# Emailadresser i teksten
for em in re.finditer(r'[\w.\-+]+@([\w\-]+\.[\w\-]+(?:\.[\w]{2,6})?)', text):
dom = em.group(1).lower()
if 'bmc' not in dom and 'gmail' not in dom and 'outlook' not in dom and 'hotmail' not in dom:
if not is_platform_or_spam(dom):
return dom
# Sender email
# Sender email (kun hvis ikke platform)
if sender_email and '@' in sender_email:
dom = sender_email.split('@')[1].lower()
if 'gmail' not in dom and 'outlook' not in dom and 'hotmail' not in dom:
if not is_platform_or_spam(dom):
return dom
return None
def extract_company_name(text: str, sender_name: str = '') -> Optional[str]:
"""Prøv at finde firmanavn via CVR-nær tekst eller typiske DK-firmasuffikser"""
"""Find firmanavn via DK-suffikser, footer-mønster eller CVR-nær tekst"""
# Prioritet 1: navne med DK juridiske suffikser
m = re.search(
r'\b([\w\s\-&\'\.]+(?:A/S|ApS|IVS|I/S|K/S|P/S|GmbH|Ltd\.?|LLC|AB|AS))\b',
text
)
if m:
return m.group(1).strip()
# Prioritet 2: typisk faktura-footer format:
# "FirmaNavn - Adresse - Postnr By - CVR-nr.: XXXXXXXX"
m = re.search(
r'^([A-ZÆØÅ][A-Za-zæøåÆØÅ\s\-&\'\.]{2,50}?)\s*[-]\s*[A-ZÆØÅ][a-zæøåA-ZÆØÅ]',
text, re.MULTILINE
)
if m:
name = m.group(1).strip()
if len(name) > 2 and not any(w in name.lower() for w in ('tlf', 'tel', 'mail', 'bank', 'cvr', 'mobil')):
return name
# Prioritet 3: tekst lige FORAN "CVR" på samme linje
m = re.search(
r'([A-ZÆØÅ][A-Za-zæøåÆØÅ\s\-&\'\.]{2,50}?)\s*[-,]?\s*(?:CVR|Cvr)',
text
)
if m:
name = m.group(1).strip().rstrip('-, ')
if len(name) > 2:
return name
return sender_name or None
# ── Hoved-logik ─────────────────────────────────────────────────────────
@ -521,14 +592,30 @@ async def extract_vendor_suggestion(email_id: int):
sender_name = email.get('sender_name') or ''
sender_email = email.get('sender_email') or ''
# Er afsenderen en faktureringsplatform? (e-conomic etc.)
sender_domain = sender_email.split('@')[1].lower() if '@' in sender_email else ''
is_platform_sender = sender_domain in PLATFORM_DOMAINS
# Brug ikke sender_email som leverandør-email når det er en platform
# Prøv i stedet at finde en rigtig email i teksten
vendor_email = None
if not is_platform_sender and sender_email:
vendor_email = sender_email
else:
for em in re.finditer(r'[\w.\-+]+@([\w\-]+\.[\w\-]+(?:\.[\w]{2,6})?)', focused_text):
dom = em.group(1).lower()
if dom not in PLATFORM_DOMAINS and 'bmc' not in dom:
vendor_email = em.group(0)
break
# ── Regex udtræk ────────────────────────────────────────────────────
suggestion = {
"name": extract_company_name(focused_text, sender_name) or sender_name,
"email": sender_email,
"email": vendor_email,
"cvr_number": extract_cvr(focused_text, own_cvr),
"phone": extract_phones(focused_text),
"address": extract_address(focused_text),
"domain": extract_domain(focused_text, sender_email),
"domain": extract_domain(focused_text, sender_email if not is_platform_sender else ''),
"source": "regex"
}
@ -540,35 +627,50 @@ async def extract_vendor_suggestion(email_id: int):
# Send kun den fokuserede tekst (max 4000 tegn) til AI
ai_text = focused_text[:4000]
# Pre-filtrer hints inden AI ser dem (undgå at sende placeholders som hints)
hint_cvr = suggestion.get('cvr_number')
if hint_cvr and is_placeholder_cvr(hint_cvr):
hint_cvr = None
hint_addr = suggestion.get('address')
if hint_addr and any(ph in hint_addr.lower() for ph in PLACEHOLDER_ADDRESSES):
hint_addr = None
hint_domain = suggestion.get('domain')
if hint_domain and is_platform_or_spam(hint_domain):
hint_domain = None
prompt = f"""Du er en ekspert i at udtrække firmaoplysninger fra danske fakturaer og e-mails.
OPGAVE: Find LEVERANDØRENS firmaoplysninger i teksten nedenfor.
Leverandøren er AFSENDEREN - IKKE BMC Networks og IKKE køber.
Leverandøren er AFSENDEREN (sælger/udsteder) - IKKE BMC Networks og IKKE køber/modtager.
VIGTIGT: E-mails kan være sendt via faktureringsplatforme som e-conomic, Dinero, Billy osv.
I fald er leverandøren den virksomhed DER EJER fakturaen - IKKE platformen selv.
Ignorer alle data tilhørende: e-conomic.com, dinero.dk, billy.dk, uniconta.com
RETURNER KUN DETTE JSON - ingen forklaring, ingen markdown:
{{
"name": "Firmanavn ApS",
"cvr_number": "12345678",
"address": "Vejnavn 1, 2000 By",
"cvr_number": "87654321",
"address": "Rigtig Vej 5, 2200 København",
"phone": "12345678",
"email": "kontakt@firma.dk",
"domain": "firma.dk"
}}
REGLER:
- name: Firmanavn med A/S, ApS, IVS osv. - IKKE BMC Networks
- cvr_number: Præcis 8 cifre efter "CVR", "CVR-nr", "Moms" eller "DK" - IGNORER {own_cvr}
- address: Fuld adresse med postnummer og by (dansk format: "Vejnavn 1, 1234 By")
- name: Firmanavn med A/S, ApS, IVS osv. - IKKE BMC Networks, IKKE e-conomic
- cvr_number: Præcis 8 cifre efter "CVR", "CVR-nr", "Moms" eller "DK" - IGNORER {own_cvr}, IGNORER 12345678 (placeholder)
- address: Fuld RIGTIG adresse med postnummer og by - IGNORER "Vejnavn 1, 1234 By" (placeholder)
- phone: Telefonnummer - foretrukket format: "+45 XXXX XXXX" eller "XXXX XXXX"
- email: Kontakt-email til firmaet (IKKE afsender-email hvis den er personlig)
- domain: Hjemmeside-domæne f.eks. "firma.dk" eller "www.firma.dk"
- email: Kontakt-email til firmaet - IKKE e-conomic.com, IKKE post@e-conomic.com
- domain: Hjemmeside-domæne - IKKE e-conomic.com, IKKE faktureringsplatform
- Sæt null for felter der IKKE kan findes med sikkerhed
KENDTE REGEX-RESULTATER (brug som hjælp, ret dem hvis de er forkerte):
- cvr: {suggestion.get('cvr_number') or 'ikke fundet'}
KENDTE REGEX-RESULTATER (brug som hjælp - disse er allerede filtrerede):
- cvr: {hint_cvr or 'ikke fundet'}
- phone: {suggestion.get('phone') or 'ikke fundet'}
- address: {suggestion.get('address') or 'ikke fundet'}
- domain: {suggestion.get('domain') or 'ikke fundet'}
- address: {hint_addr or 'ikke fundet'}
- domain: {hint_domain or 'ikke fundet'}
TEKST:
{ai_text}
@ -591,15 +693,27 @@ JSON:"""
except Exception as e:
logger.warning(f"⚠️ AI udtræk fejlede, bruger regex-resultat: {e}")
# Rens: fjern domæner der tilhører kendte mailservere
spam_domains = {'gmail.com', 'hotmail.com', 'outlook.com', 'yahoo.com', 'live.com', 'icloud.com'}
if suggestion.get('domain') in spam_domains:
# Rens: fjern platform/spam domæner
if suggestion.get('domain') and is_platform_or_spam(suggestion['domain']):
suggestion['domain'] = None
# Fjern own_cvr hvis den snegte sig ind
if suggestion.get('cvr_number') == own_cvr:
# Fjern own_cvr og placeholder CVRs
if suggestion.get('cvr_number') == own_cvr or (
suggestion.get('cvr_number') and is_placeholder_cvr(suggestion['cvr_number'])
):
suggestion['cvr_number'] = None
# Fjern placeholder-adresser (e-conomic og lignende skabeloner)
addr_lower = (suggestion.get('address') or '').lower().strip()
if any(ph in addr_lower for ph in PLACEHOLDER_ADDRESSES):
suggestion['address'] = None
# Fjern platform email hvis AI satte den alligevel
if suggestion.get('email') and '@' in suggestion.get('email', ''):
em_domain = suggestion['email'].split('@')[-1].lower()
if em_domain in PLATFORM_DOMAINS:
suggestion['email'] = None
return suggestion
except HTTPException: