Finance-Accounts/ar-aging-app/backend/app/core/validation.py

74 lines
3.3 KiB
Python

"""File-coverage validation for a reporting month: wrong month, overlaps, gaps, coverage.
Multi-marketplace aware: overlap/gap checks compare only files of the SAME marketplace
(each market ships its own full-month file), and a few days of boundary spillover is
tolerated (Amazon exports use UTC/PST cuts, so a January file legitimately starts with
Dec-31 evening rows).
"""
from __future__ import annotations
from datetime import date, timedelta
from .xlsx_reader import FileMeta
# Rows this close to the month boundary are normal timezone spillover, not a wrong month.
BOUNDARY_GRACE_DAYS = 3
def validate_coverage(metas: list[FileMeta], month_end: date | None) -> list[dict]:
exc: list[dict] = []
if not month_end:
return exc
first = month_end.replace(day=1)
lo = first - timedelta(days=BOUNDARY_GRACE_DAYS)
hi = month_end + timedelta(days=BOUNDARY_GRACE_DAYS)
dated = [(m.filename, m.min_date, m.max_date, m.marketplace or "")
for m in metas if m.min_date and m.max_date]
# (1) files clearly outside the reporting month — prevents mixing months.
for fn, mn, mx, _mkt in dated:
if mx < lo or mn > hi:
exc.append({"category": "wrong_month", "severity": "error",
"detail": f"{fn} covers {mn}..{mx}, outside reporting month "
f"{first:%Y-%m}", "source": fn})
elif mn < lo or mx > hi:
exc.append({"category": "wrong_month", "severity": "warning",
"detail": f"{fn} extends well beyond the reporting month "
f"({mn}..{mx} vs {first}..{month_end})", "source": fn})
# (2)+(3) overlaps and gaps — only within the SAME marketplace.
by_mkt: dict[str, list[tuple[str, date, date]]] = {}
for fn, mn, mx, mkt in dated:
by_mkt.setdefault(mkt, []).append((fn, mn, mx))
for mkt, entries in by_mkt.items():
if len(entries) < 2:
continue
ds = sorted(entries, key=lambda x: x[1])
for i in range(1, len(ds)):
prev, cur = ds[i - 1], ds[i]
if cur[1] <= prev[2]:
exc.append({"category": "overlapping_files", "severity": "warning",
"detail": f"{cur[0]} ({cur[1]}..{cur[2]}) overlaps "
f"{prev[0]} ({prev[1]}..{prev[2]})", "source": cur[0]})
else:
gs, ge = prev[2] + timedelta(days=1), cur[1] - timedelta(days=1)
if gs <= ge:
exc.append({"category": "missing_dates", "severity": "warning",
"detail": f"Missing transactions {gs}..{ge} between "
f"{prev[0]} and {cur[0]}", "source": ""})
# (4) whole-month coverage across all files.
if dated:
allmin = min(x[1] for x in dated)
allmax = max(x[2] for x in dated)
if allmin > first:
exc.append({"category": "missing_dates", "severity": "warning",
"detail": f"No transactions before {allmin}; month starts {first}",
"source": ""})
if allmax < month_end:
exc.append({"category": "missing_dates", "severity": "warning",
"detail": f"No transactions after {allmax}; month ends {month_end}",
"source": ""})
return exc