"""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