fix: extract address/company from invoice footer dash-format (KONI Accounting style)
This commit is contained in:
parent
a8970701ab
commit
04acdecb91
@ -396,6 +396,35 @@ async def extract_vendor_suggestion(email_id: int):
|
|||||||
return bare
|
return bare
|
||||||
return raw.strip()[:20]
|
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]:
|
def extract_cvr(text: str, own_cvr: str = '') -> Optional[str]:
|
||||||
patterns = [
|
patterns = [
|
||||||
# Med label
|
# Med label
|
||||||
@ -408,7 +437,7 @@ async def extract_vendor_suggestion(email_id: int):
|
|||||||
for pat in patterns:
|
for pat in patterns:
|
||||||
for m in re.finditer(pat, text, re.IGNORECASE):
|
for m in re.finditer(pat, text, re.IGNORECASE):
|
||||||
val = m.group(1)
|
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 val
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@ -429,47 +458,89 @@ async def extract_vendor_suggestion(email_id: int):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def extract_address(text: str) -> Optional[str]:
|
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(
|
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
|
text
|
||||||
)
|
)
|
||||||
if m:
|
if m:
|
||||||
return m.group(0).strip()
|
return m.group(0).strip()
|
||||||
# Fallback: vejnavn + husnummer + postnummer
|
# Format 2: vejnavn-suffix (vej/gade/alle osv.) + husnummer + postnummer
|
||||||
m = re.search(
|
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
|
text, re.IGNORECASE
|
||||||
)
|
)
|
||||||
if m:
|
if m:
|
||||||
return m.group(0).strip()
|
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
|
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]:
|
def extract_domain(text: str, sender_email: str = '') -> Optional[str]:
|
||||||
# Eksplicit www
|
# Eksplicit www
|
||||||
m = re.search(r'(?:www\.|https?://)([\w\-]+\.[\w\-]+(?:\.[\w]{2,6})?)', text, re.IGNORECASE)
|
m = re.search(r'(?:www\.|https?://)([\w\-]+\.[\w\-]+(?:\.[\w]{2,6})?)', text, re.IGNORECASE)
|
||||||
if m:
|
if m:
|
||||||
return m.group(1).lower()
|
dom = m.group(1).lower()
|
||||||
# Emailadresser i teksten (ikke @bmcnetworks)
|
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):
|
for em in re.finditer(r'[\w.\-+]+@([\w\-]+\.[\w\-]+(?:\.[\w]{2,6})?)', text):
|
||||||
dom = em.group(1).lower()
|
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
|
return dom
|
||||||
# Sender email
|
# Sender email (kun hvis ikke platform)
|
||||||
if sender_email and '@' in sender_email:
|
if sender_email and '@' in sender_email:
|
||||||
dom = sender_email.split('@')[1].lower()
|
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 dom
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def extract_company_name(text: str, sender_name: str = '') -> Optional[str]:
|
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(
|
m = re.search(
|
||||||
r'\b([\w\s\-&\'\.]+(?:A/S|ApS|IVS|I/S|K/S|P/S|GmbH|Ltd\.?|LLC|AB|AS))\b',
|
r'\b([\w\s\-&\'\.]+(?:A/S|ApS|IVS|I/S|K/S|P/S|GmbH|Ltd\.?|LLC|AB|AS))\b',
|
||||||
text
|
text
|
||||||
)
|
)
|
||||||
if m:
|
if m:
|
||||||
return m.group(1).strip()
|
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
|
return sender_name or None
|
||||||
|
|
||||||
# ── Hoved-logik ─────────────────────────────────────────────────────────
|
# ── Hoved-logik ─────────────────────────────────────────────────────────
|
||||||
@ -521,14 +592,30 @@ async def extract_vendor_suggestion(email_id: int):
|
|||||||
sender_name = email.get('sender_name') or ''
|
sender_name = email.get('sender_name') or ''
|
||||||
sender_email = email.get('sender_email') 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 ────────────────────────────────────────────────────
|
# ── Regex udtræk ────────────────────────────────────────────────────
|
||||||
suggestion = {
|
suggestion = {
|
||||||
"name": extract_company_name(focused_text, sender_name) or sender_name,
|
"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),
|
"cvr_number": extract_cvr(focused_text, own_cvr),
|
||||||
"phone": extract_phones(focused_text),
|
"phone": extract_phones(focused_text),
|
||||||
"address": extract_address(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"
|
"source": "regex"
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -540,35 +627,50 @@ async def extract_vendor_suggestion(email_id: int):
|
|||||||
# Send kun den fokuserede tekst (max 4000 tegn) til AI
|
# Send kun den fokuserede tekst (max 4000 tegn) til AI
|
||||||
ai_text = focused_text[:4000]
|
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.
|
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.
|
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 så 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:
|
RETURNER KUN DETTE JSON - ingen forklaring, ingen markdown:
|
||||||
{{
|
{{
|
||||||
"name": "Firmanavn ApS",
|
"name": "Firmanavn ApS",
|
||||||
"cvr_number": "12345678",
|
"cvr_number": "87654321",
|
||||||
"address": "Vejnavn 1, 2000 By",
|
"address": "Rigtig Vej 5, 2200 København",
|
||||||
"phone": "12345678",
|
"phone": "12345678",
|
||||||
"email": "kontakt@firma.dk",
|
"email": "kontakt@firma.dk",
|
||||||
"domain": "firma.dk"
|
"domain": "firma.dk"
|
||||||
}}
|
}}
|
||||||
|
|
||||||
REGLER:
|
REGLER:
|
||||||
- name: Firmanavn med A/S, ApS, IVS osv. - IKKE BMC Networks
|
- 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}
|
- cvr_number: Præcis 8 cifre efter "CVR", "CVR-nr", "Moms" eller "DK" - IGNORER {own_cvr}, IGNORER 12345678 (placeholder)
|
||||||
- address: Fuld adresse med postnummer og by (dansk format: "Vejnavn 1, 1234 By")
|
- address: Fuld RIGTIG adresse med postnummer og by - IGNORER "Vejnavn 1, 1234 By" (placeholder)
|
||||||
- phone: Telefonnummer - foretrukket format: "+45 XXXX XXXX" eller "XXXX XXXX"
|
- phone: Telefonnummer - foretrukket format: "+45 XXXX XXXX" eller "XXXX XXXX"
|
||||||
- email: Kontakt-email til firmaet (IKKE afsender-email hvis den er personlig)
|
- email: Kontakt-email til firmaet - IKKE e-conomic.com, IKKE post@e-conomic.com
|
||||||
- domain: Hjemmeside-domæne f.eks. "firma.dk" eller "www.firma.dk"
|
- domain: Hjemmeside-domæne - IKKE e-conomic.com, IKKE faktureringsplatform
|
||||||
- Sæt null for felter der IKKE kan findes med sikkerhed
|
- Sæt null for felter der IKKE kan findes med sikkerhed
|
||||||
|
|
||||||
KENDTE REGEX-RESULTATER (brug som hjælp, ret dem hvis de er forkerte):
|
KENDTE REGEX-RESULTATER (brug som hjælp - disse er allerede filtrerede):
|
||||||
- cvr: {suggestion.get('cvr_number') or 'ikke fundet'}
|
- cvr: {hint_cvr or 'ikke fundet'}
|
||||||
- phone: {suggestion.get('phone') or 'ikke fundet'}
|
- phone: {suggestion.get('phone') or 'ikke fundet'}
|
||||||
- address: {suggestion.get('address') or 'ikke fundet'}
|
- address: {hint_addr or 'ikke fundet'}
|
||||||
- domain: {suggestion.get('domain') or 'ikke fundet'}
|
- domain: {hint_domain or 'ikke fundet'}
|
||||||
|
|
||||||
TEKST:
|
TEKST:
|
||||||
{ai_text}
|
{ai_text}
|
||||||
@ -591,15 +693,27 @@ JSON:"""
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"⚠️ AI udtræk fejlede, bruger regex-resultat: {e}")
|
logger.warning(f"⚠️ AI udtræk fejlede, bruger regex-resultat: {e}")
|
||||||
|
|
||||||
# Rens: fjern domæner der tilhører kendte mailservere
|
# Rens: fjern platform/spam domæner
|
||||||
spam_domains = {'gmail.com', 'hotmail.com', 'outlook.com', 'yahoo.com', 'live.com', 'icloud.com'}
|
if suggestion.get('domain') and is_platform_or_spam(suggestion['domain']):
|
||||||
if suggestion.get('domain') in spam_domains:
|
|
||||||
suggestion['domain'] = None
|
suggestion['domain'] = None
|
||||||
|
|
||||||
# Fjern own_cvr hvis den snegte sig ind
|
# Fjern own_cvr og placeholder CVRs
|
||||||
if suggestion.get('cvr_number') == own_cvr:
|
if suggestion.get('cvr_number') == own_cvr or (
|
||||||
|
suggestion.get('cvr_number') and is_placeholder_cvr(suggestion['cvr_number'])
|
||||||
|
):
|
||||||
suggestion['cvr_number'] = None
|
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
|
return suggestion
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user