236053cd7f
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.
314 lines
16 KiB
Markdown
314 lines
16 KiB
Markdown
---
|
||
name: erp-data-reconciliation
|
||
description: |
|
||
Analyze and reconcile financial/accounting data between two ERP systems
|
||
(e.g. SAP/Coop and Uniconta) using Excel exports. Covers file inspection,
|
||
column mapping, Danish-format normalization, match-engine design, and
|
||
iterative build workflow.
|
||
trigger: |
|
||
User asks to reconcile, match, compare, or afstemme data between two
|
||
accounting/ERP systems, typically via Excel files. Also relevant when
|
||
importing/exporting financial ledgers, kontoudtog, vendor/customer
|
||
statements, or bonus calculations across systems.
|
||
---
|
||
|
||
# ERP Data Reconciliation Skill
|
||
|
||
## Overview
|
||
|
||
Reconciling data between two ERP/accounting systems is a multi-phase task.
|
||
The typical deliverable is a webapp or script that can:
|
||
1. Import Excel files from both systems
|
||
2. Normalize dates, amounts, and text
|
||
3. Match postings between systems
|
||
4. Show unmatched, partial matches, and differences
|
||
5. Support manual approval/rejection
|
||
6. Generate reports
|
||
|
||
## Phase 1: File Analysis (CRITICAL — do not skip)
|
||
|
||
Before writing any import code, run a structured analysis of the Excel files.
|
||
|
||
### Steps
|
||
1. List all files in both source directories
|
||
2. For each file, identify:
|
||
- Sheet names
|
||
- Actual header row location (may NOT be row 1!)
|
||
- Column names and data types
|
||
- Date columns and their formats
|
||
- Amount columns and their formats
|
||
- Text columns
|
||
- Reference/invoice/document columns
|
||
- Null percentages
|
||
- Duplicate rows
|
||
- Data range (min/max dates)
|
||
|
||
### Key Pitfalls
|
||
- **Header row is NOT always row 1.** Uniconta exports often have account
|
||
info in rows 1-2, blank row 3, headers in row 4.
|
||
- **Amounts mixed formats.** Danish Excel files often have amounts as both
|
||
Excel floats AND as Danish text strings like `-2.543.803,93`.
|
||
Always normalize using a robust parser:
|
||
```python
|
||
def parse_danish_number(val):
|
||
if pd.isna(val): return float('nan')
|
||
if isinstance(val, (int, float)): return float(val)
|
||
s = str(val).strip()
|
||
if not s: return float('nan')
|
||
if '.' in s and ',' in s:
|
||
s = s.replace('.', '').replace(',', '.')
|
||
elif ',' in s: s = s.replace(',', '.')
|
||
return float(s)
|
||
```
|
||
- **Invoice/reference numbers may NOT match between systems.** This is the
|
||
#1 cause of failed reconciliation. Always check overlap BEFORE assuming
|
||
IDs can be used as join keys. Use amount overlap as a quick viability check.
|
||
|
||
### Analysis Script Template
|
||
Use or adapt `scripts/analyze_excel.py` from the Coop-Uniconta project:
|
||
- Reads all sheets in all files
|
||
- Prints column stats, null counts, sample values
|
||
- Detects Danish number formats
|
||
- Identifies empty/duplicate rows
|
||
|
||
## Phase 2: Determine Match Strategy
|
||
|
||
After analysis, document the match strategy BEFORE coding the engine.
|
||
|
||
### Decision Tree
|
||
1. **Do invoice/reference numbers overlap?**
|
||
- If yes → use `Faktura + abs(Amount) + Date` as PRIMARY signal (two-phase).
|
||
First phase: Reference==Faktura + abs(Amount) within tolerance (±5 DKK) + Date±21d.
|
||
Second phase: abs(Amount) + Date±21d for remaining rows.
|
||
- If no → use `Amount + Date + Text similarity`
|
||
2. **Are dates exact or within tolerance?**
|
||
- Same day → exact
|
||
- ±1-3 days → high confidence partial
|
||
- ±7 days → medium confidence
|
||
- ±21 days → general match tolerance
|
||
- >21 days → fuzzy/manual only
|
||
3. **Is text available on both sides?**
|
||
- Both have text → Levenshtein/rapidfuzz similarity score
|
||
- Only one side has text → amount+date only
|
||
4. **Do signs differ?**
|
||
- Same sign → same direction transaction
|
||
- Opposite sign → same transaction viewed from creditor vs debtor side.
|
||
Match on `abs(amount)` but label "MODSAT" in output.
|
||
*Example: Coop RE (payment) is negative; Uniconta Faktura is positive.*
|
||
|
||
### Tolerance Matching (V6 innovation)
|
||
|
||
Instead of exact amount match (±0.01), use a tolerance (±5 DKK):
|
||
- Catches rounding differences between systems
|
||
- Catches partial payments (one system may record 440.000, the other 430.732)
|
||
- Uses bucket-based indexing for fast lookup:
|
||
```python
|
||
AMT_TOL = 5.0
|
||
bucket = round(abs_amount / AMT_TOL) * AMT_TOL
|
||
# Search adjacent buckets: [bucket-AMT_TOL, bucket, bucket+AMT_TOL]
|
||
```
|
||
|
||
### Coop Fakturaliste + Uniconta Betalingsfordeling as the Simple Forward Workflow
|
||
|
||
When the user wants the process to be as simple as possible, prefer a forward-looking workflow based on:
|
||
1. `Payment_advice`
|
||
2. latest Uniconta Coop debtor/payment allocation export
|
||
3. the complete Coop invoice list
|
||
|
||
Model the invoice list and payment allocation as separate import sources. Use invoice number as the primary join between Coop invoices and Uniconta allocation lines, and calculate paid/open/partial status from payment/settlement lines rather than only from original invoice postings. Treat the payment allocation as an explanation layer between a bank deposit and many invoice/credit lines.
|
||
|
||
See `references/coop-invoice-payment-allocation.md` for the concrete Django model/import/status pattern, UI shape, and tests.
|
||
|
||
### Coop Payment Advice / Settlement Files as a Fourth Source
|
||
|
||
When the user provides Coop betalingsadviseringer/opgørelser for individual bank
|
||
payments, treat them as a separate reconciliation source, not just attachments.
|
||
They are often the missing bridge:
|
||
|
||
`Bank deposit → Coop payment advice total → advice invoice/credit lines → Coop ledger / Uniconta invoices`
|
||
|
||
For Coop-Uniconta style systems, the import UI should expose the full operational set, not just "Coop" and "Uniconta":
|
||
1. Uniconta Coop Debitor / payment allocation
|
||
2. Uniconta/ERP invoice list: all Coop invoices/credit notes for the year
|
||
3. Coop account statement / kontoudtog
|
||
4. Bank statement containing all Coop postings
|
||
5. Coop Payment_advice documents, uploadable continuously as they are collected
|
||
|
||
Recommended source/model names:
|
||
- `coop_settlement` or `coop_payment_advice`
|
||
- `PaymentAdvice` / `PaymentAdviceDocument` / `SettlementFile`: file name, unique id/bilag number, file type
|
||
(PDF/XLSX), document date, payment date, total amount, period, parse status, matched bank transaction, raw
|
||
text/raw JSON.
|
||
- `PaymentAdviceLine` / `SettlementLine`: invoice number, invoice date, company
|
||
code, amount, currency, line type (invoice/credit/bonus/modregning/unknown),
|
||
links to bank/Coop/Uniconta transactions, match status, explanation.
|
||
|
||
If the user does not yet have all payment advice documents, still allow upload now. Store raw PDF text or Excel row JSON with best-effort date/amount/reference/classification so later parsers can be tightened without re-requesting files.
|
||
|
||
Parser patterns observed:
|
||
- XLSX files may have header row 3 with columns `Fakturanummer`, `Fakturadato`,
|
||
`Firmakode`, `Beløb`, `Valuta` and supplier metadata in rows 1-2.
|
||
- Text PDFs often extract as repeated triples:
|
||
invoice number line, `dd.mm.yyyy firmakode` line, amount/`DKK` line.
|
||
- Some PDFs split sign into a separate `-` column/line; treat `123,45`, `-`,
|
||
`DKK` as `-123,45`.
|
||
- Some PDFs are bank statement screenshots, not payment advice. Classify and do
|
||
not parse them as settlement lines.
|
||
- Some files include manual middle calculations or special layouts. Keep raw text
|
||
and mark parse confidence/status instead of silently forcing a schema.
|
||
|
||
Matching/reporting logic:
|
||
1. Match payment-advice total to bank deposit using ±5 DKK tolerance.
|
||
2. Then match advice lines to Uniconta by invoice number first, then amount/date.
|
||
3. Match advice lines to Coop ledger by reference/doc number; in the Coop-Uniconta
|
||
project this was a very strong signal.
|
||
4. Treat payment-advice matches as an evidence/explanation layer, not as automatic
|
||
ledger settlement. An invoice can remain open in Uniconta while Payment_advice
|
||
proves Coop included it in a settlement, credit, bonus, modregning, or manual
|
||
middle calculation.
|
||
5. Report per bank deposit/open item: bank date/amount, matched advice file,
|
||
difference, line count, Uniconta hit count, Coop hit count, credit/bonus/
|
||
modregning total, advice documents/vouchers/classifications, and status OK /
|
||
partial / needs review.
|
||
6. Keep 2026 or out-of-period advice files in the database, but exclude or label
|
||
them separately when current bank/Uniconta data only covers 2024-2025.
|
||
|
||
### Invoice Status: Net-based + Deduplication
|
||
|
||
**Critical pitfall:** The same payment may exist in BOTH systems (Coop RE and Uniconta Betaling). If you sum both, you get double the actual paid amount.
|
||
|
||
**Solution:** Calculate net payments per invoice:
|
||
```python
|
||
# Net Coop payments (NOT abs-sum!)
|
||
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
|
||
|
||
# Net Uniconta payments
|
||
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
|
||
|
||
# Deduplicate: if both exist, use only one
|
||
if coop_paid > 0 and uni_paid > 0:
|
||
if abs(coop_paid - uni_paid) <= AMT_TOL:
|
||
betalt = coop_paid # Same payment recorded twice
|
||
else:
|
||
betalt = max(coop_paid, uni_paid) # Partial, take largest
|
||
```
|
||
|
||
**Also handle:** Positive Coop RE amounts are credit memos (modregninger) that should be SUBTRACTED from the net, not added with abs().
|
||
|
||
### Match Scoring Formula (default)
|
||
```
|
||
Score = 0
|
||
# Amount match (40%)
|
||
if abs(a-b) < AMT_TOL: Score += 40
|
||
|
||
# Date match (35%)
|
||
days = abs(d1 - d2).days
|
||
if days == 0: Score += 35
|
||
elif days <= 3: Score += 25
|
||
elif days <= 7: Score += 15
|
||
elif days <= 14: Score += 5
|
||
|
||
# Text match (25%)
|
||
if both_have_text:
|
||
similarity = fuzzy_ratio(text1, text2) # 0-100
|
||
if similarity > 80: Score += 25
|
||
elif similarity > 50: Score += 12
|
||
|
||
# Reference bonus (extra)
|
||
if reference_match: Score += 40
|
||
|
||
# Thresholds
|
||
if Score >= 85: exact_match, auto_approvable
|
||
if Score >= 60: partial_match, requires_manual
|
||
if Score >= 40: fuzzy_match, requires_manual
|
||
```
|
||
|
||
| Table | Key Fields |
|
||
|-------|-----------|
|
||
| `ImportBatch` | source (coop/uniconta), filename, imported_at, raw_row_count |
|
||
| `Posting` | batch_id, source, fiscal_year, posting_date, amount, original_text, normalized_text, reference, document_number, original_data (JSON) |
|
||
| `Match` | posting_a_id, posting_b_id, score, match_type (exact/partial/fuzzy/manual), status (proposed/approved/rejected), matched_by (auto/user), matched_at |
|
||
| `AuditLog` | user, action, match_id, old_status, new_status, comment, timestamp |
|
||
|
||
## Phase 4: Build Iteratively
|
||
|
||
Follow this order strictly:
|
||
1. Analysis scripts and documentation → `ANALYSE.md`
|
||
2. Import parser with preview → validate before commit
|
||
3. Normalization layer (dates, amounts, text)
|
||
4. Match engine (exact → partial → fuzzy)
|
||
5. Simple web UI (dashboard → import → matching table)
|
||
6. Manual approve/reject + audit log
|
||
7. Reports and exports
|
||
8. Bonus calculation (if needed)
|
||
9. Tests + README + Docker
|
||
|
||
### Revisor/Auditor Package Pattern
|
||
|
||
When the user asks for output that can be sent to an accountant/revisor, produce a deterministic evidence package rather than relying on dashboard screenshots. First verify and remove duplicate import runs after a DB backup; then export a Markdown summary plus an Excel workbook with source files, month totals, open invoice/credit residuals, unexplained bank items, ignored bank items, payment advice rows, and raw normalized postings. Use explicit status language such as `AFSTEMT_MED_ÅBNE_POSTER` when the bank layer reconciles but invoice/credit-note residuals remain.
|
||
|
||
See `references/auditor-pack-and-dedup.md` for the duplicate-import cleanup sequence, auditor workbook sheet shape, status semantics, and verification checklist.
|
||
|
||
### Idempotent SourceFile Imports
|
||
|
||
For accounting import UIs, especially Coop `Payment_advice` bulk uploads, make uploads idempotent. Add a SHA256 `content_hash` on the source-file model, compute it before creating a new row, skip identical reuploads, and update/reparse an existing same-named source file when the content changed. Wrap child-row replacement in a database transaction and test both duplicate-skip and update-replace paths so repeated user uploads do not inflate reconciliation totals.
|
||
|
||
See `references/idempotent-source-file-imports.md` for the Django model/view/parser pattern, UI counters, bulk upload setting, and regression checklist.
|
||
|
||
## Danish-specific Conventions
|
||
|
||
- Dates: use ISO format internally (`YYYY-MM-DD`), but display in Danish
|
||
(`dd-MM-yyyy`) per user preference. Parse ambiguous Danish text dates with day-first semantics (`dayfirst=True`).
|
||
- Amounts: always normalize to float/Decimal; display with 2 decimals, Danish comma.
|
||
- Fiscal year: calendar year (1/1 – 31/12) unless specified otherwise.
|
||
- For Coop workflows, model invoice year/month separately from payment year/month: Coop can have ~3 months of credit, so December invoices may be paid in March of the following fiscal year. Reports must not treat that as an error by default.
|
||
- Text: normalize by lowercasing, stripping extra spaces, removing punctuation before fuzzy comparison.
|
||
- Regulation/bonus/fee/fine classification should be explicit and reviewable. Start with keyword classification (`bonus`, `rabat`, `gebyr`, `bøde`, `regulering`, `korrektion`, `modregning`) but keep raw data and manual review because wording/layout varies.
|
||
|
||
## Django Accounting UI Display Formatting
|
||
|
||
When the reconciliation deliverable is a Django webapp, treat amount display as part of the reconciliation contract rather than a cosmetic detail. Use one shared template filter/localization utility for money columns in dashboards, match tables, invoice/payment status pages, and reports.
|
||
|
||
For Danish accounting UIs, monetary amounts should normally render with `.` thousands separators, `,` decimal separators, exactly two decimals, and a leading minus sign for negative values (for example `1.234.567,89`, `-1.234.567,89`, `0,00`). Prefer `Decimal`-based formatting over Python floats. Replace monetary `|floatformat:2` usage, but do not reformat percentages, match scores, counts, or raw imported/audit JSON unless explicitly requested.
|
||
|
||
Minimum verification for UI formatting changes:
|
||
- Add a unit/template test for the formatting helper.
|
||
- Add at least one rendered-page regression test proving formatted money appears and raw `1234567.89`-style output is absent.
|
||
- Confirm templates using the filter load its tag module, and restart the web container if needed.
|
||
|
||
See `references/django-accounting-ui-formatting.md` for the concrete Django template-filter implementation, search targets, Docker test commands, and pitfalls.
|
||
|
||
## References
|
||
- See `references/coop-uniconta-analysis.md` for the concrete discovery
|
||
patterns from the Coop ↔ Uniconta project (header row locations,
|
||
column mappings, SAP document type codes, etc.).
|
||
- See `references/coop-uniconta-match-engine.md` for the 5-phase match engine
|
||
implementation, tolerance matching, net-based invoice status, and score formulas.
|
||
- See `references/bank-reconciliation.md` for three-source reconciliation extending
|
||
Coop ↔ Uniconta with bank CSV — month-level validation, bonus reconciliation,
|
||
and many-to-one (bank payment = sum of Coop invoices) matching strategy.
|
||
- See `references/coop-payment-advice.md` for using Coop betalingsadviseringer
|
||
(PDF/XLSX settlement files) as the bridge between bank deposits and invoice
|
||
lines, including parser patterns, coverage metrics, and reporting shape.
|
||
- See `references/three-way-django-analysis-engine.md` for Django implementation
|
||
structure: separate analysis module, batch import command, three-way report
|
||
outputs, interpretation pitfalls, and minimum tests.
|
||
- See `references/coop-invoice-payment-allocation.md` for the simplified forward
|
||
workflow using Coop invoice lists + Uniconta payment allocation + Payment_advice,
|
||
including separate models, invoice paid/open status logic, employee-expense
|
||
exceptions, UI badges, and minimum tests.
|
||
- See `references/coop-uniconta-import-ui-and-payment-advice.md` for the five-source
|
||
import UI pattern, flexible PDF/Excel Payment_advice raw import, monthly dashboard
|
||
fields, 3-month Coop credit handling, and regression tests.
|
||
- See `references/auditor-pack-and-dedup.md` for revisor-ready exports, duplicate
|
||
import cleanup after DB backup, status semantics such as `AFSTEMT_MED_ÅBNE_POSTER`,
|
||
and Excel/Markdown verification.
|
||
- See `references/idempotent-source-file-imports.md` for SHA256-based idempotent
|
||
accounting source-file uploads, update-vs-skip semantics, Django transaction
|
||
boundaries, bulk upload limits, and regression tests.
|
||
- See `references/django-accounting-ui-formatting.md` for Django money-formatting
|
||
implementation details, template search targets, regression tests, and pitfalls. |