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.
212 lines
6.4 KiB
Python
212 lines
6.4 KiB
Python
"""
|
|
Generic Excel file analyzer for ERP reconciliation projects.
|
|
|
|
Usage:
|
|
python analyze_excel.py <directory> [--output analysis.json]
|
|
|
|
This script:
|
|
1. Scans a directory for .xlsx files
|
|
2. Identifies sheets, headers, data ranges
|
|
3. Detects Danish number formats
|
|
4. Prints null counts and sample values
|
|
5. Saves structured JSON for downstream processing
|
|
|
|
To customize for a project, adjust:
|
|
- EXPECTED_COLS: columns that must exist for a row to be considered a header
|
|
- AMOUNT_COL_PATTERNS: regex patterns for amount columns
|
|
- DATE_COL_PATTERNS: regex patterns for date columns
|
|
"""
|
|
import pandas as pd
|
|
import openpyxl
|
|
import os
|
|
import json
|
|
import re
|
|
from collections import OrderedDict
|
|
|
|
# --- CUSTOMIZE PER PROJECT ---
|
|
EXPECTED_COLS = {'Dato', 'Faktura', 'Bilag', 'Tekst', 'Beløb'}
|
|
AMOUNT_COL_PATTERNS = [
|
|
r'(?i)bel[oø]b',
|
|
r'(?i)amount',
|
|
r'(?i)sum',
|
|
r'(?i)rest',
|
|
r'(?i)forfalden',
|
|
]
|
|
DATE_COL_PATTERNS = [
|
|
r'(?i)dato',
|
|
r'(?i)date',
|
|
r'(?i)posting',
|
|
r'(?i)entry',
|
|
]
|
|
REFERENCE_COL_PATTERNS = [
|
|
r'(?i)reference',
|
|
r'(?i)faktura',
|
|
r'(?i)bilag',
|
|
r'(?i)document number',
|
|
r'(?i)invoice',
|
|
]
|
|
# --- END CUSTOMIZE ---
|
|
|
|
|
|
def classify_column(col_name):
|
|
"""Guess the semantic type of a column from its name."""
|
|
cn = str(col_name)
|
|
for p in AMOUNT_COL_PATTERNS:
|
|
if re.search(p, cn):
|
|
return 'amount'
|
|
for p in DATE_COL_PATTERNS:
|
|
if re.search(p, cn):
|
|
return 'date'
|
|
for p in REFERENCE_COL_PATTERNS:
|
|
if re.search(p, cn):
|
|
return 'reference'
|
|
return 'other'
|
|
|
|
|
|
def find_header_row(filepath, sheet=0, required_cols=None):
|
|
"""Find the first row that looks like a header."""
|
|
if required_cols is None:
|
|
required_cols = EXPECTED_COLS
|
|
wb = openpyxl.load_workbook(filepath, data_only=True)
|
|
ws = wb[wb.sheetnames[sheet]]
|
|
for i, row in enumerate(ws.iter_rows(values_only=True), start=0):
|
|
vals = set(str(v) for v in row if v is not None)
|
|
if required_cols.issubset(vals):
|
|
return i
|
|
return None
|
|
|
|
|
|
def parse_danish_number(val):
|
|
"""Normalize Danish text numbers like '-2.543.803,93' to float."""
|
|
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')
|
|
# Danish format: thousands sep = '.', decimal = ','
|
|
if '.' in s and ',' in s:
|
|
s = s.replace('.', '').replace(',', '.')
|
|
elif ',' in s:
|
|
s = s.replace(',', '.')
|
|
try:
|
|
return float(s)
|
|
except ValueError:
|
|
return float('nan')
|
|
|
|
|
|
def analyze_file(filepath):
|
|
info = {
|
|
"filepath": filepath,
|
|
"filename": os.path.basename(filepath),
|
|
"size_kb": round(os.path.getsize(filepath) / 1024, 1),
|
|
"sheets": OrderedDict(),
|
|
}
|
|
xl = pd.ExcelFile(filepath)
|
|
for sheet in xl.sheet_names:
|
|
info["sheets"][sheet] = analyze_sheet(filepath, sheet)
|
|
return info
|
|
|
|
|
|
def analyze_sheet(filepath, sheet):
|
|
# Try default header=0 first; if columns look wrong, try header detection
|
|
df = pd.read_excel(filepath, sheet_name=sheet, engine='openpyxl')
|
|
header_row = None
|
|
# Simple heuristic: if ALL columns are unnamed, try detecting header
|
|
unnamed = [c for c in df.columns if 'Unnamed' in str(c)]
|
|
if len(unnamed) > len(df.columns) * 0.5:
|
|
hr = find_header_row(filepath, sheet)
|
|
if hr is not None:
|
|
df = pd.read_excel(filepath, sheet_name=sheet, engine='openpyxl', header=hr)
|
|
header_row = hr
|
|
|
|
cols = OrderedDict()
|
|
for col in df.columns:
|
|
col_type = classify_column(str(col))
|
|
is_date = pd.api.types.is_datetime64_any_dtype(df[col])
|
|
is_num = pd.api.types.is_numeric_dtype(df[col])
|
|
null_count = int(df[col].isna().sum())
|
|
unique_count = int(df[col].nunique())
|
|
samples = []
|
|
for v in df[col].dropna().head(5):
|
|
if len(samples) >= 3:
|
|
break
|
|
try:
|
|
if is_date:
|
|
samples.append(str(pd.Timestamp(v).date()))
|
|
else:
|
|
samples.append(str(v)[:60])
|
|
except Exception:
|
|
samples.append(str(v)[:60])
|
|
|
|
# Detect danish number format
|
|
danish_flag = False
|
|
if is_num:
|
|
for v in df[col].dropna().head(10):
|
|
if isinstance(v, str) and (',' in v or ('.' in v and ',' in v.replace('.', ''))):
|
|
danish_flag = True
|
|
break
|
|
|
|
cols[str(col)] = {
|
|
"semantic_type": col_type,
|
|
"pandas_dtype": str(df[col].dtype),
|
|
"null_count": null_count,
|
|
"null_pct": round(null_count / len(df) * 100, 1),
|
|
"unique_count": unique_count,
|
|
"is_date": bool(is_date),
|
|
"is_numeric": bool(is_num),
|
|
"danish_number_format": danish_flag,
|
|
"sample_values": samples,
|
|
}
|
|
|
|
empty_rows = int((df.isna().all(axis=1)).sum())
|
|
dupes = int(df.duplicated().sum())
|
|
|
|
# Try to find date range
|
|
date_range = None
|
|
for col, meta in cols.items():
|
|
if meta["is_date"]:
|
|
non_null = df[col].dropna()
|
|
if len(non_null) > 0:
|
|
date_range = {
|
|
"min": str(non_null.min().date()),
|
|
"max": str(non_null.max().date()),
|
|
"column": col,
|
|
}
|
|
break
|
|
|
|
return {
|
|
"rows": len(df),
|
|
"columns": len(df.columns),
|
|
"detected_header_row": header_row,
|
|
"empty_rows": empty_rows,
|
|
"duplicate_rows": dupes,
|
|
"date_range": date_range,
|
|
"columns": cols,
|
|
}
|
|
|
|
|
|
def main():
|
|
import argparse
|
|
parser = argparse.ArgumentParser(description="Analyze Excel files for data reconciliation")
|
|
parser.add_argument("directory", help="Directory containing .xlsx files")
|
|
parser.add_argument("--output", default="excel_analysis.json", help="Output JSON file")
|
|
args = parser.parse_args()
|
|
|
|
results = OrderedDict()
|
|
for f in sorted(os.listdir(args.directory)):
|
|
if f.endswith('.xlsx') and not f.startswith('~'):
|
|
fp = os.path.join(args.directory, f)
|
|
key = os.path.splitext(f)[0]
|
|
results[key] = analyze_file(fp)
|
|
print(f"Analyzed: {f}")
|
|
|
|
with open(args.output, 'w', encoding='utf-8') as fh:
|
|
json.dump(results, fh, indent=2, ensure_ascii=False)
|
|
print(f"\nSaved: {args.output}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|