# Coop ↔ Uniconta Match Engine Reference (Aktualiseret 2025-05-26) Konkrete match-engine-mønstre fra iteration V1 → V6.1. --- ## Match-filosofi: Faser i prioriteret rækkefølge Fakturanummer-match (Reference ↔ Faktura) er det STÆRKESTE signal. Beløb+dato er sekundært. Sekvens: ### V6.1 (anbefalet) **Fase 1: Reference + abs(beløb) + dato** - Coop `Reference` == Uniconta `Faktura` (identiske 4-5 cifrede tal) - `abs(Coop.Amount) == abs(Uniconta.Amount)` ±5 kr (tolerance for afrunding) - Dato ±21 dage - Konteringstype-bonus (RE↔Faktura, RG↔Kreditnota, KG↔Kreditnota) - Score ~250 - Resultat 2024-2025: ~2.421 matches **Fase 2: ZV↔Faktura (Coop positive fakturaer)** - Coop `Document Type == ZV` (positive fakturaer udstedt til kunder) - Match mod Uniconta `Konteringstype == Faktura` - `abs(beløb)` ±5 kr + dato ±21 dage - Bonus: `Document Number == Faktura` nummer - Resultat 2024-2025: ~173 matches **Fase 3: RE↔Betaling uden reference** - Coop `Document Type == RE` (betalinger) uden fakturanummer - Match mod Uniconta `Konteringstype == Betaling` - `abs(beløb)` ±5 kr + dato ±21 dage - Bonus: tekst-lighed ("coop", "0010014808") - Resultat 2024-2025: ~192 matches **Fase 4: Generel abs(beløb) + dato (fallback)** - `abs(beløb)` matcher ±5 kr - Dato ±21 dage - Konteringstype-bonus, tekst-lighed, reference-bonus - Score ≥ 40 - Resultat 2024-2025: ~1.939 matches **Fase 5: Mange-til-én** - Sum af 2-3 Uniconta-poster == Coop-postering ±5 kr - Dato ±14 dage, max 25 kandidater - Resultat 2024-2025: ~276 matches **Total matches 2024-2025: 5.001 ud af 7.328 Coop / 6.717 Uniconta (~68%)** --- ## Tolerance matching (V6 innovation) I stedet for eksakt beløbs-match med ±0,01 kr, brug ±5 kr tolerance: ```python AMT_TOL = 5.0 # DKK tolerance # Index-building: bucket per tolerance interval def build_index(transactions, max_days=21): idx = defaultdict(lambda: defaultdict(list)) for t in transactions: for delta in range(-max_days, max_days + 1): d = t['date'] + timedelta(days=delta) bucket = round(t['abs_amount'] / AMT_TOL) * AMT_TOL idx[d][bucket].append(t) return idx # Lookup: søg naboliggende buckets bucket = round(c['abs_amount'] / AMT_TOL) * AMT_TOL for b in [bucket - AMT_TOL, bucket, bucket + AMT_TOL]: candidates.extend(idx.get(c['date'], {}).get(b, [])) ``` **Fordel:** Fanger afrundingsforskelle og delvise betalinger som ellers ville være uafstemte. **Bivirkning:** Kan skabe flere kandidater → brug score-threshold og konteringstype-filter til at vælge den bedste. --- ## Fortegn: MODSAT er normalt og korrekt | Coop type | Fortegn | Uniconta type | Fortegn | Match-mode | |-----------|---------|---------------|---------|------------| | RE (betaling) | Negativ | Faktura | Positiv | abs(beløb) | | ZV (faktura) | Positiv | Betaling | Negativ | abs(beløb) | | RG (kreditnota) | Positiv | Kreditnota | Negativ | abs(beløb) | | KG (rabat) | Positiv | Kreditnota | Negativ | abs(beløb) | ```python # Match via abs(), men marker med fortegn-kolonne amt_match = abs(coop_amount - uniconta_amount) <= AMT_TOL fortegn = "SAMME" if (coop_amount > 0) == (uniconta_amount > 0) else "MODSAT" ``` --- ## Filtrering før match **Afgræns til regnskabsår** i stedet for at filtrere på Doc_Type: - 2024+2025 fjerner 8.861 Coop og 5.684 Uniconta poster - Primært primo/XX/saldo-poster fra 2023 og 2026 - Langt mere effektiv end Doc_Type-filtrering | Filter | Kilde | Hvad | Konsekvens | |--------|-------|------|------------| | År 2024-2025 | Begge | Regnskabsår-afgrænsning | 56% færre poster | | `Primo` | Uniconta | Startsaldo | Fjernes ved års-filter | | `Afslutning` | Uniconta | Årsafslutning | Fjernes ved års-filter | --- ## Mange-til-én: Praktisk approach Én Coop-postering kan svare til SUM af flere Uniconta-poster. - **AVOID:** `itertools.combinations` over 7.328 × 6.717 rækker = O(n²) timeout - **USE:** Index Uniconta per `(dato, abs_beløb)` og søg kombinationer indenfor ±14 dage - **LIMIT:** Maks 3 Uniconta-poster per Coop-postering, max 25 kandidater - **TOLERANCE:** ±5 kr (ikke ±0,01 kr) - Resultat: ~276 matches i 2024-2025 — større effekt end V5.1 --- ## Faktura-status: Netto-baseret + deduplikering ### Problemet: Dobbelt-registrering Samme betaling kan findes som både: - **Coop RE** (betaling) med fakturanummer i Reference - **Uniconta Betaling** med fakturanummer i Faktura-kolonnen Hvis man summerer begge får man **dobbelt så meget betalt** som faktisk. ### Løsning: Netto-sum per faktura ```python # 1. Beregn NETTO Coop betalinger per faktura # (positive RE trækker fra — det er modregninger) coop_net = sum(c['amount'] for c in coop if c['reference'] == fnr and c['doc_type'] in ('RE', 'ZP')) coop_paid = abs(coop_net) if coop_net < 0 else 0 # 2. Beregn NETTO Uniconta betalinger per faktora uni_net = sum(u['amount'] for u in uni if u['faktura'] == fnr and u['konteringstype'] == 'Betaling') uni_paid = abs(uni_net) if uni_net < 0 else 0 # 3. Dedupliker: hvis begge findes, brug kun én if coop_paid > 0 and uni_paid > 0: if abs(coop_paid - uni_paid) <= AMT_TOL: betalt = coop_paid # Samme betaling, tag én else: betalt = max(coop_paid, uni_paid) # Delvise, tag største ``` ### MR8M-modregninger Nogle fakturaer har både negativ og positiv RE med samme reference: - Faktura 47140: RE -8.984 + RE +8.984 (MR8M modregning) = netto 0 - Med `abs()` ville dette tælle som 17.968 betalt (FORKERT) - Med netto-sum: `abs(-8.984 + 8.984) = 0` (KORREKT) --- ## Score-formel (V6.1 konkret) ``` # Fase 1 (Reference-match) Score = 250 - dato_diff * 4 - abs_amount_diff * 2 # Fase 2 (ZV↔Faktura) Score = 150 - dato_diff * 3 - abs_amount_diff * 2 if doc_number == faktura: +50 if tekst_identisk: +25 if tekst_delvis: +12 Threshold: ≥ 60 # Fase 3 (RE↔Betaling) Score = 120 - dato_diff * 3 - abs_amount_diff * 2 if reference == faktura: +40 if "coop" in tekst: +15 Threshold: ≥ 60 # Fase 4 (Generel) Score = 100 - dato_diff * 3 - abs_amount_diff * 0.5 if konteringstype_match: +25 if tekst_identisk: +25 if tekst_delvis: +12 if reference_match: +40 Threshold: ≥ 40 # Fase 5 (Mange-til-én) Score = 150 (fast, kræver manuel godkendelse) ``` --- ## Rapport-struktur (Excel-ark) Rækkefølgen er vigtig — brugeren vil se opsummering først: 0. `0_Opsummering` — nøgletal, antal matches, totalbeløb, difference, faktura-status 1. `1_Faktura_Status` — alle Uniconta fakturaer med BETALT/DELVIST/UBETALT 2. `2_Matches` — alle matchede par med type, score, fortegn, beløbs-diff, dato-diff 3. `3_Alle_Coop` — alle rå Coop-rækker med match-status kolonne 4. `4_Alle_Uniconta` — alle rå Uniconta-rækker med match-status kolonne 5. `5_Kun_Coop` — uafstemte Coop-poster 6. `6_Kun_Uniconta` — uafstemte Uniconta-poster 7. `7_Maaneds_Oversigt` — månedlig opsummering pr. år **Nøgle:** Ark 3 og 4 bevarer overblik — brugeren vil se ALLE poster. Ark 5+6 viser kun dem der stadig mangler. --- ## Bevar original rådata Exportér ALTID: - Original række-nummer (Excel-rækkeindeks) - Filnavn og ark-navn - Rå JSON med alle kolonner Så brugeren kan spore enhver match tilbage til kildefilen. --- ## Performance-tips - V6.1 kører på ~10 sekunder for 7.328 Coop + 6.717 Uniconta poster - Brug `defaultdict(lambda: defaultdict(list))` til dato→beløb→post indeks - Uniconta parsing: `pd.read_excel(file, header=3)` for at læse fra række 4 - Python 3.9 med pandas 2.3.3 — set `PYTHONPATH` hvis user-site-packages