| """Period helpers — floral wholesale is highly seasonal, so YoY / same-period |
| comparisons are first-class. Everything returns ISO date strings (yyyy-mm-dd). |
| |
| `today` is injectable for testing/reproducibility. |
| """ |
| import datetime as dt |
|
|
|
|
| def _d(x): |
| return x.isoformat() |
|
|
|
|
| def today(): |
| return dt.date.today() |
|
|
|
|
| def ytd(t=None): |
| """(start, end) for year-to-date through `t`.""" |
| t = t or today() |
| return _d(dt.date(t.year, 1, 1)), _d(t) |
|
|
|
|
| def ytd_last_year(t=None): |
| """Same calendar window one year earlier (Jan 1 LY → same month/day LY). |
| Clamps Feb 29 → Feb 28 in non-leap years.""" |
| t = t or today() |
| try: |
| end = dt.date(t.year - 1, t.month, t.day) |
| except ValueError: |
| end = dt.date(t.year - 1, t.month, t.day - 1) |
| return _d(dt.date(t.year - 1, 1, 1)), end if isinstance(end, str) else _d(end) |
|
|
|
|
| def ltm(t=None): |
| """Trailing 12 months ending `t`.""" |
| t = t or today() |
| return _d(t - dt.timedelta(days=365)), _d(t) |
|
|
|
|
| def prior_ltm(t=None): |
| t = t or today() |
| return _d(t - dt.timedelta(days=730)), _d(t - dt.timedelta(days=365)) |
|
|
|
|
| def mtd(t=None): |
| t = t or today() |
| return _d(dt.date(t.year, t.month, 1)), _d(t) |
|
|
|
|
| def wtd(t=None): |
| """(Monday of this ISO week, t) — week-to-date.""" |
| t = t or today() |
| return _d(t - dt.timedelta(days=t.weekday())), _d(t) |
|
|
|
|
| def qtd(t=None): |
| """(first day of this calendar quarter, t) — quarter-to-date.""" |
| t = t or today() |
| q_start_month = ((t.month - 1) // 3) * 3 + 1 |
| return _d(dt.date(t.year, q_start_month, 1)), _d(t) |
|
|
|
|
| def quarter_range(t=None): |
| """(first day, last day) of the calendar quarter containing t.""" |
| t = t or today() |
| qs = ((t.month - 1) // 3) * 3 + 1 |
| first = dt.date(t.year, qs, 1) |
| nxt = dt.date(t.year + (1 if qs + 3 > 12 else 0), ((qs + 2) % 12) + 1, 1) |
| return _d(first), _d(nxt - dt.timedelta(days=1)) |
|
|
|
|
| def shift_year(date_from, date_to, weeks=False): |
| """Same window one year earlier. For week-aligned comparisons (weeks=True) shift by exactly 52 |
| weeks (364 days) so the weekday lines up; otherwise shift by calendar year (clamping Feb 29).""" |
| f = dt.date.fromisoformat(date_from) |
| t = dt.date.fromisoformat(date_to) |
| if weeks: |
| return _d(f - dt.timedelta(days=364)), _d(t - dt.timedelta(days=364)) |
|
|
| def back(d): |
| try: |
| return dt.date(d.year - 1, d.month, d.day) |
| except ValueError: |
| return dt.date(d.year - 1, d.month, d.day - 1) |
| return _d(back(f)), _d(back(t)) |
|
|
|
|
| def last_n_days(n, t=None): |
| t = t or today() |
| return _d(t - dt.timedelta(days=n)), _d(t) |
|
|
|
|
| def year_range(year): |
| return f'{year}-01-01', f'{year}-12-31' |
|
|
|
|
| def month_range(year, month): |
| """(first day, last day) of a given calendar month, ISO strings.""" |
| first = dt.date(year, month, 1) |
| nxt = dt.date(year + (1 if month == 12 else 0), 1 if month == 12 else month + 1, 1) |
| return _d(first), _d(nxt - dt.timedelta(days=1)) |
|
|
|
|
| def period_options(t=None, n_months=15): |
| """Ordered {label: (date_from, date_to)} for the close/reconciliation period picker.""" |
| t = t or today() |
| opts = {} |
| opts['This month (to date)'] = (_d(dt.date(t.year, t.month, 1)), _d(t)) |
| lm_year, lm_month = (t.year, t.month - 1) if t.month > 1 else (t.year - 1, 12) |
| lf, lt = month_range(lm_year, lm_month) |
| opts[f'Last month ({lf[:7]})'] = (lf, lt) |
| opts['Quarter to date'] = (_d(dt.date(t.year, ((t.month - 1) // 3) * 3 + 1, 1)), _d(t)) |
| opts['Year to date'] = (_d(dt.date(t.year, 1, 1)), _d(t)) |
| for label, mf, mt in reversed(month_starts(n_months, t)): |
| if label == f'{t.year}-{t.month:02d}' or label == lf[:7]: |
| continue |
| opts[label] = (mf, mt) |
| return opts |
|
|
|
|
| def month_starts(n_back=13, t=None): |
| """List of (yyyy-mm, start, end) for the last n_back months including current.""" |
| t = t or today() |
| out = [] |
| y, m = t.year, t.month |
| for _ in range(n_back): |
| start = dt.date(y, m, 1) |
| end = (dt.date(y + (m // 12), (m % 12) + 1, 1) - dt.timedelta(days=1)) |
| out.append((f'{y}-{m:02d}', _d(start), _d(end))) |
| m -= 1 |
| if m == 0: |
| m = 12 |
| y -= 1 |
| return list(reversed(out)) |
|
|
|
|
| def week_starts(n_back=12, t=None): |
| """List of (label, start, end) for the last n_back ISO weeks (Mon–Sun) including the current |
| (partial) week. label = the Monday as 'MM-DD' (compact, chart-friendly); end is clamped to t |
| for the current week so weekly totals never include future days.""" |
| t = t or today() |
| this_mon = t - dt.timedelta(days=t.weekday()) |
| out = [] |
| for i in range(n_back): |
| mon = this_mon - dt.timedelta(weeks=i) |
| sun = mon + dt.timedelta(days=6) |
| end = min(sun, t) |
| out.append((f'{mon.month:02d}-{mon.day:02d}', _d(mon), _d(end))) |
| return list(reversed(out)) |
|
|
|
|
| def yoy_pct(this_v, last_v): |
| if last_v: |
| return (this_v - last_v) / last_v * 100.0 |
| return None |
|
|