11"""Revenue forecasting based on contracts, time allocation, and invoices."""
22
3+ import calendar
34import datetime
45from decimal import Decimal
56from typing import List , Optional
67
78import pandas
89from pandas import DataFrame
910
11+ from .fx import primary_currency
1012from .model import Contract , Invoice , Project
13+ from .tax_reserves import convert_invoice
1114from .time import TimeUnit
1215from .timetracking import event_hours
1316
@@ -159,19 +162,21 @@ def _invoiced_ranges_by_tag(invoices: List[Invoice]) -> dict:
159162 return ranges
160163
161164
162- def monthly_revenue_from_calendar (
163- time_data : DataFrame ,
165+ def revenue_from_calendar (
166+ time_data : Optional [ DataFrame ] ,
164167 projects : List [Project ],
165168 start_date : datetime .date ,
166169 end_date : datetime .date ,
167170 invoices : Optional [List [Invoice ]] = None ,
171+ freq : str = "M" ,
168172) -> DataFrame :
169- """Derive monthly revenue from calendar time-tracking events.
173+ """Derive revenue per time bucket from calendar time-tracking events.
170174
171175 The calendar DataFrame is the source of truth for hours worked (past)
172176 and hours planned (future). Filters *time_data* for events in
173- [start_date, end_date], groups by month and project tag, then converts
174- hours to revenue via contract rates.
177+ [start_date, end_date], groups by *freq* period and project tag, then
178+ converts hours to revenue via contract rates. *freq* is a pandas
179+ period alias — "W", "M" or "Y".
175180
176181 If *invoices* is given, hours already captured in a timesheet attached
177182 to a (non-cancelled) invoice are excluded, keyed by the timesheet's own
@@ -180,18 +185,19 @@ def monthly_revenue_from_calendar(
180185 "planned" for the month it was done, and again as "invoiced" for the
181186 month the invoice was actually raised.
182187
183- Returns a DataFrame with columns: month , project, revenue, contract_id, hours.
188+ Returns a DataFrame with columns: period , project, revenue, contract_id, hours.
184189 """
190+ empty = DataFrame (columns = ["period" , "project" , "revenue" , "contract_id" , "hours" ])
185191 if time_data is None or time_data .empty :
186- return DataFrame ( columns = [ "month" , "project" , "revenue" , "contract_id" , "hours" ])
192+ return empty
187193
188194 tag_to_project = {p .tag : p for p in projects if p .tag and p .contract }
189195
190196 index_dates = time_data .index .date
191197 mask = (index_dates >= start_date ) & (index_dates <= end_date )
192198 filtered = time_data [mask ]
193199 if filtered .empty :
194- return DataFrame ( columns = [ "month" , "project" , "revenue" , "contract_id" , "hours" ])
200+ return empty
195201
196202 if invoices :
197203 invoiced_ranges = _invoiced_ranges_by_tag (invoices )
@@ -203,11 +209,11 @@ def monthly_revenue_from_calendar(
203209 ]
204210 filtered = filtered [[not v for v in already_invoiced ]]
205211 if filtered .empty :
206- return DataFrame ( columns = [ "month" , "project" , "revenue" , "contract_id" , "hours" ])
212+ return empty
207213
208214 records = []
209215 df = filtered .copy ()
210- df ["_month " ] = pandas .to_datetime (df .index ).to_period ("M" ).to_timestamp ()
216+ df ["_period " ] = pandas .to_datetime (df .index ).to_period (freq ).to_timestamp ()
211217 df ["_hours" ] = df .apply (
212218 lambda row : event_hours (
213219 row ,
@@ -216,7 +222,7 @@ def monthly_revenue_from_calendar(
216222 axis = 1 ,
217223 )
218224
219- grouped = df .groupby (["_month " , "tag" ]).agg (hours = ("_hours" , "sum" )).reset_index ()
225+ grouped = df .groupby (["_period " , "tag" ]).agg (hours = ("_hours" , "sum" )).reset_index ()
220226 for _ , row in grouped .iterrows ():
221227 tag = row ["tag" ]
222228 project = tag_to_project .get (tag )
@@ -228,7 +234,7 @@ def monthly_revenue_from_calendar(
228234 revenue = float (Decimal (str (billable_units )) * contract .rate )
229235 records .append (
230236 {
231- "month " : row ["_month " ],
237+ "period " : row ["_period " ],
232238 "project" : project .title ,
233239 "revenue" : revenue ,
234240 "contract_id" : contract .id ,
@@ -237,10 +243,25 @@ def monthly_revenue_from_calendar(
237243 )
238244
239245 if not records :
240- return DataFrame ( columns = [ "month" , "project" , "revenue" , "contract_id" , "hours" ])
246+ return empty
241247 return DataFrame (records )
242248
243249
250+ def monthly_revenue_from_calendar (
251+ time_data : Optional [DataFrame ],
252+ projects : List [Project ],
253+ start_date : datetime .date ,
254+ end_date : datetime .date ,
255+ invoices : Optional [List [Invoice ]] = None ,
256+ ) -> DataFrame :
257+ """Monthly view of :func:`revenue_from_calendar`, keyed by ``month``.
258+
259+ Returns a DataFrame with columns: month, project, revenue, contract_id, hours.
260+ """
261+ df = revenue_from_calendar (time_data , projects , start_date , end_date , invoices = invoices , freq = "M" )
262+ return df .rename (columns = {"period" : "month" })
263+
264+
244265def cash_flow_projection (
245266 revenue_forecast : DataFrame ,
246267 contracts : List [Contract ],
@@ -326,3 +347,166 @@ def revenue_curve_with_calendar(
326347 combined = combined .sort_values ("month" ).reset_index (drop = True )
327348 combined ["cumulative_revenue" ] = combined ["revenue" ].cumsum ()
328349 return combined
350+
351+
352+ # Bucket sizes per granularity: pandas period alias, buckets per window, and
353+ # how many of those buckets sit in the future when viewing the present.
354+ _GRANULARITY = {
355+ "week" : ("W" , 13 , 3 ),
356+ "month" : ("M" , 16 , 3 ),
357+ "year" : ("Y" , 0 , 0 ),
358+ }
359+
360+
361+ def revenue_window (
362+ granularity : str ,
363+ offset : int = 0 ,
364+ today : Optional [datetime .date ] = None ,
365+ ) -> tuple :
366+ """Start and end date of the visible window for a paged revenue chart.
367+
368+ *offset* pages the window: 0 is the window containing today, -1 the one
369+ before it, and so on. A month window spans 16 buckets and a week window
370+ 13, both reaching three buckets into the future at offset 0 so planned
371+ work is visible. Any 16 consecutive months contain a January, so the
372+ month view always has a year boundary on screen.
373+ """
374+ today = today or datetime .date .today ()
375+ freq , size , ahead = _GRANULARITY [granularity ]
376+ if size == 0 :
377+ raise ValueError (f"{ granularity } is not a paged granularity" )
378+
379+ current = pandas .Period (today , freq = freq )
380+ last = current + ahead + offset * size
381+ first = last - (size - 1 )
382+ return first .start_time .date (), last .end_time .date ()
383+
384+
385+ def _data_extent (
386+ invoices : List [Invoice ],
387+ time_data : Optional [DataFrame ],
388+ ) -> tuple :
389+ """Earliest and latest date covered by invoices or calendar events."""
390+ dates = [inv .date for inv in invoices if not inv .cancelled and inv .date ]
391+ if time_data is not None and not time_data .empty :
392+ dates .append (time_data .index .min ().date ())
393+ dates .append (time_data .index .max ().date ())
394+ if not dates :
395+ return None , None
396+ return min (dates ), max (dates )
397+
398+
399+ def _bucket_label (start : datetime .date , granularity : str ) -> str :
400+ if granularity == "week" :
401+ return f"W{ start .isocalendar ()[1 ]:02d} "
402+ if granularity == "year" :
403+ return str (start .year )
404+ return calendar .month_abbr [start .month ]
405+
406+
407+ def revenue_series (
408+ invoices : List [Invoice ],
409+ projects : List [Project ],
410+ time_data : Optional [DataFrame ],
411+ granularity : str = "month" ,
412+ offset : int = 0 ,
413+ country : str = "" ,
414+ today : Optional [datetime .date ] = None ,
415+ ) -> dict :
416+ """Received, invoiced and planned revenue per bucket for one chart window.
417+
418+ Reconciles the two revenue sources the dashboard chart needs into a
419+ single series so the frontend does not have to join them: invoices give
420+ ``received`` (paid) and ``invoiced`` (sent but unpaid) keyed by invoice
421+ date, while the calendar gives ``planned`` — tracked or scheduled work
422+ that no timesheet has billed yet, in the past as well as the future.
423+
424+ *granularity* is "week", "month" or "year". Week and month windows are
425+ paged with *offset*; the year window always spans the full data extent.
426+ Empty buckets are included so the time axis stays continuous.
427+ """
428+ if granularity not in _GRANULARITY :
429+ raise ValueError (f"unknown granularity: { granularity } " )
430+
431+ today = today or datetime .date .today ()
432+ freq = _GRANULARITY [granularity ][0 ]
433+ currency = primary_currency (country )
434+ extent_start , extent_end = _data_extent (invoices , time_data )
435+
436+ if granularity == "year" :
437+ first_year = min (extent_start .year if extent_start else today .year , today .year )
438+ last_year = max (extent_end .year if extent_end else today .year , today .year )
439+ window_start = datetime .date (first_year , 1 , 1 )
440+ window_end = datetime .date (last_year , 12 , 31 )
441+ else :
442+ window_start , window_end = revenue_window (granularity , offset , today = today )
443+
444+ periods = pandas .period_range (start = window_start , end = window_end , freq = freq )
445+ buckets = {
446+ p : {
447+ "bucket" : p .start_time .date ().isoformat (),
448+ "bucket_end" : p .end_time .date ().isoformat (),
449+ "label" : _bucket_label (p .start_time .date (), granularity ),
450+ "year" : p .start_time .year ,
451+ "received" : 0.0 ,
452+ "invoiced" : 0.0 ,
453+ "planned" : 0.0 ,
454+ "invoice_count" : 0 ,
455+ "hours" : 0.0 ,
456+ }
457+ for p in periods
458+ }
459+
460+ for inv in invoices :
461+ if inv .cancelled or not inv .date :
462+ continue
463+ period = pandas .Period (inv .date , freq = freq )
464+ bucket = buckets .get (period )
465+ if bucket is None :
466+ continue
467+ converted = convert_invoice (inv , currency )
468+ if converted is None :
469+ continue
470+ if inv .paid :
471+ bucket ["received" ] += float (converted [0 ])
472+ bucket ["invoice_count" ] += 1
473+ elif inv .sent :
474+ bucket ["invoiced" ] += float (converted [0 ])
475+ bucket ["invoice_count" ] += 1
476+
477+ cal = revenue_from_calendar (time_data , projects , window_start , window_end , invoices = invoices , freq = freq )
478+ if not cal .empty :
479+ grouped = cal .groupby ("period" ).agg (revenue = ("revenue" , "sum" ), hours = ("hours" , "sum" )).reset_index ()
480+ for _ , row in grouped .iterrows ():
481+ bucket = buckets .get (pandas .Period (row ["period" ], freq = freq ))
482+ if bucket is None :
483+ continue
484+ bucket ["planned" ] += max (0.0 , float (row ["revenue" ]))
485+ bucket ["hours" ] += float (row ["hours" ])
486+
487+ current_period = pandas .Period (today , freq = freq )
488+ rows = []
489+ previous_year = None
490+ for period in periods :
491+ row = buckets [period ]
492+ row ["is_current" ] = period == current_period
493+ row ["is_future" ] = period > current_period
494+ row ["is_year_start" ] = previous_year is not None and row ["year" ] != previous_year
495+ row ["total" ] = round (row ["received" ] + row ["invoiced" ] + row ["planned" ], 2 )
496+ for key in ("received" , "invoiced" , "planned" , "hours" ):
497+ row [key ] = round (row [key ], 2 )
498+ previous_year = row ["year" ]
499+ rows .append (row )
500+
501+ paged = granularity != "year"
502+ return {
503+ "granularity" : granularity ,
504+ "offset" : offset ,
505+ "currency" : currency ,
506+ "window_start" : window_start .isoformat (),
507+ "window_end" : window_end .isoformat (),
508+ "buckets" : rows ,
509+ "total" : round (sum (r ["total" ] for r in rows ), 2 ),
510+ "has_earlier" : bool (paged and extent_start and extent_start < window_start ),
511+ "has_later" : bool (paged and offset < 0 ),
512+ }
0 commit comments