Initial import: 10 Radix skills across 3 categories
Kategoriserer danske regnskabs-/ERP-skills, ERP-data-afstemnings-
skill og to devops-workflows (gitea-issue-agent, kanban-workflows)
som Radix-medlemmers AI-agenter kan dele.
Struktur:
skills/<kategori>/<skill-navn>/SKILL.md
skills/<kategori>/<skill-navn>/{references,templates,scripts}/
Indekseret af index.json (genereret af scripts/rebuild_index.py).
Kør `python3 scripts/rebuild_index.py --check` i CI for at fange
synkroniseringsfejl.
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user