33from __future__ import annotations
44
55import datetime as dt
6- from typing import Any
6+ from typing import Any , Literal
77
88from sqlalchemy .orm import Session
99
1010from models .article import Article
1111from services import article_service
1212from services .pricing_service import fetch_card_prices
1313
14+ Period = Literal ["daily" , "weekly" , "monthly" ]
15+
1416
1517def _as_utc (value : dt .datetime | None ) -> dt .datetime | None :
1618 if value is None :
@@ -47,36 +49,125 @@ def _hours_to_sell(article: Article) -> float | None:
4749 return max (0.0 , delta .total_seconds () / 3600.0 )
4850
4951
52+ def _bucket_key (value : dt .datetime , period : Period ) -> dt .datetime :
53+ """Truncate a UTC datetime to the start of its daily/weekly/monthly bucket."""
54+ if period == "monthly" :
55+ return value .replace (day = 1 , hour = 0 , minute = 0 , second = 0 , microsecond = 0 )
56+ base = value .replace (hour = 0 , minute = 0 , second = 0 , microsecond = 0 )
57+ if period == "weekly" :
58+ # Monday as week start
59+ return base - dt .timedelta (days = base .weekday ())
60+ return base
61+
62+
63+ def _iter_buckets (start : dt .datetime , end : dt .datetime , period : Period ) -> list [dt .datetime ]:
64+ """Inclusive list of bucket starts covering [start, end]."""
65+ if start > end :
66+ start , end = end , start
67+ cursor = _bucket_key (start , period )
68+ last = _bucket_key (end , period )
69+ buckets : list [dt .datetime ] = []
70+ safety = 0
71+ while cursor <= last and safety < 5000 :
72+ buckets .append (cursor )
73+ if period == "monthly" :
74+ year , month = cursor .year , cursor .month + 1
75+ if month > 12 :
76+ month = 1
77+ year += 1
78+ cursor = cursor .replace (year = year , month = month , day = 1 )
79+ elif period == "weekly" :
80+ cursor = cursor + dt .timedelta (days = 7 )
81+ else :
82+ cursor = cursor + dt .timedelta (days = 1 )
83+ safety += 1
84+ return buckets
85+
86+
5087def compute_dashboard_stats (
5188 db : Session ,
5289 user_id : int ,
5390 * ,
5491 include_market : bool = False ,
92+ range_start : dt .datetime | None = None ,
93+ range_end : dt .datetime | None = None ,
94+ period : Period = "daily" ,
5595) -> dict [str , Any ]:
56- """Profit windows, rankings, Vinted revenue, optional sum of Cardmarket refs for inventory."""
96+ """Profit, revenue, channel split and per-period revenue timeline.
97+
98+ The ``range_start``/``range_end``/``period`` arguments drive the windowed
99+ metrics ("profit_period_eur", "revenue_period_eur", split per channel and
100+ the ``revenue_timeline`` used by the chart). Totals (profit_total_eur,
101+ inventory…) ignore the range and reflect the whole catalogue.
102+ """
57103 now = dt .datetime .now (dt .UTC )
58- week_start = now - dt .timedelta (days = 7 )
59- month_start = now - dt .timedelta (days = 30 )
104+ if range_end is None :
105+ range_end = now
106+ if range_start is None :
107+ range_start = range_end - dt .timedelta (days = 14 )
108+ range_start = _as_utc (range_start ) or now
109+ range_end = _as_utc (range_end ) or now
110+ if range_start > range_end :
111+ range_start , range_end = range_end , range_start
112+ # Make the end inclusive (cover the full last day)
113+ range_end_inclusive = range_end .replace (hour = 23 , minute = 59 , second = 59 , microsecond = 999_999 )
60114
61115 rows = article_service .list_articles_for_user (db , user_id )
62116 sold = [a for a in rows if a .is_sold ]
63117
64- def in_week (a : Article ) -> bool :
118+ def in_range (a : Article ) -> bool :
65119 sold_at = _as_utc (a .sold_at )
66- return bool (sold_at and sold_at >= week_start )
120+ return bool (sold_at and range_start <= sold_at <= range_end_inclusive )
67121
68- def in_month (a : Article ) -> bool :
69- sold_at = _as_utc (a .sold_at )
70- return bool (sold_at and sold_at >= month_start )
122+ sold_in_range = [a for a in sold if in_range (a )]
71123
72- profit_week = sum (_profit_eur (a ) for a in sold if in_week (a ))
73- profit_month = sum (_profit_eur (a ) for a in sold if in_month (a ))
74- profit_total = sum (_profit_eur (a ) for a in sold )
124+ profit_period = sum (_profit_eur (a ) for a in sold_in_range )
125+ revenue_period = sum (
126+ p for a in sold_in_range for p in [_sale_proceeds_eur (a )] if p is not None
127+ )
128+ period_sales_count = len (sold_in_range )
75129
76- vinted_revenue = sum (
130+ profit_total = sum (_profit_eur (a ) for a in sold )
131+ vinted_revenue_total = sum (
77132 p for a in sold for p in [_sale_proceeds_eur (a )] if p is not None
78133 )
79134
135+ # Channel breakdown over the selected range (Vinted vs eBay)
136+ vinted_count = sum (1 for a in sold_in_range if a .sale_source == "vinted" )
137+ ebay_count = sum (1 for a in sold_in_range if a .sale_source == "ebay" )
138+ vinted_revenue_period = sum (
139+ p
140+ for a in sold_in_range
141+ if a .sale_source == "vinted"
142+ for p in [_sale_proceeds_eur (a )]
143+ if p is not None
144+ )
145+ ebay_revenue_period = sum (
146+ p
147+ for a in sold_in_range
148+ if a .sale_source == "ebay"
149+ for p in [_sale_proceeds_eur (a )]
150+ if p is not None
151+ )
152+
153+ # Same split across all-time (used for the Vinted vs eBay pie)
154+ vinted_count_total = sum (1 for a in sold if a .sale_source == "vinted" )
155+ ebay_count_total = sum (1 for a in sold if a .sale_source == "ebay" )
156+ vinted_revenue_total_split = sum (
157+ p
158+ for a in sold
159+ if a .sale_source == "vinted"
160+ for p in [_sale_proceeds_eur (a )]
161+ if p is not None
162+ )
163+ ebay_revenue_total_split = sum (
164+ p
165+ for a in sold
166+ if a .sale_source == "ebay"
167+ for p in [_sale_proceeds_eur (a )]
168+ if p is not None
169+ )
170+
80171 top_profitable = sorted (sold , key = _profit_eur , reverse = True )[:5 ]
81172 with_duration = [(a , _hours_to_sell (a )) for a in sold ]
82173 with_duration = [(a , h ) for a , h in with_duration if h is not None ]
@@ -88,28 +179,30 @@ def in_month(a: Article) -> bool:
88179 inventory_sell_total_eur = sum (
89180 float (a .sell_price ) for a in unsold if a .sell_price is not None
90181 )
182+ inventory_estimated_profit_eur = sum (
183+ float (a .sell_price ) - float (a .purchase_price )
184+ for a in unsold
185+ if a .sell_price is not None
186+ )
91187
92- # Cumulative revenue by month (sell price of sold articles)
93- monthly_revenue : dict [str , float ] = {}
94- for a in sold :
188+ # Revenue timeline: bucketed per period within the selected range
189+ buckets = _iter_buckets (range_start , range_end_inclusive , period )
190+ bucket_totals : dict [dt .datetime , float ] = {b : 0.0 for b in buckets }
191+ for a in sold_in_range :
95192 sold_at = _as_utc (a .sold_at )
96193 proceeds = _sale_proceeds_eur (a )
97194 if sold_at is None or proceeds is None :
98195 continue
99- key = sold_at .strftime ("%Y-%m" )
100- monthly_revenue [key ] = monthly_revenue .get (key , 0.0 ) + proceeds
101- revenue_timeline : list [dict [str , Any ]] = []
102- cumulative_rev = 0.0
103- for month_key in sorted (monthly_revenue .keys ()):
104- incremental = monthly_revenue [month_key ]
105- cumulative_rev += incremental
106- revenue_timeline .append (
107- {
108- "month" : month_key ,
109- "revenue_month_eur" : round (incremental , 2 ),
110- "revenue_cumulative_eur" : round (cumulative_rev , 2 ),
111- }
112- )
196+ key = _bucket_key (sold_at , period )
197+ if key in bucket_totals :
198+ bucket_totals [key ] += proceeds
199+ revenue_timeline : list [dict [str , Any ]] = [
200+ {
201+ "date" : b .isoformat (),
202+ "revenue_eur" : round (v , 2 ),
203+ }
204+ for b , v in bucket_totals .items ()
205+ ]
113206
114207 recent_sales = sorted (
115208 sold ,
@@ -154,13 +247,32 @@ def in_month(a: Article) -> bool:
154247 market_sum_unsold += v
155248
156249 return {
157- "profit_week_eur" : round (profit_week , 2 ),
158- "profit_month_eur" : round (profit_month , 2 ),
250+ "range" : {
251+ "start" : range_start .isoformat (),
252+ "end" : range_end .isoformat (),
253+ "period" : period ,
254+ },
255+ "profit_period_eur" : round (profit_period , 2 ),
256+ "revenue_period_eur" : round (revenue_period , 2 ),
257+ "period_sales_count" : period_sales_count ,
159258 "profit_total_eur" : round (profit_total , 2 ),
160- "vinted_revenue_eur" : round (vinted_revenue , 2 ),
259+ "vinted_revenue_eur" : round (vinted_revenue_total , 2 ),
260+ "channel_split_period" : {
261+ "vinted_count" : vinted_count ,
262+ "ebay_count" : ebay_count ,
263+ "vinted_revenue_eur" : round (vinted_revenue_period , 2 ),
264+ "ebay_revenue_eur" : round (ebay_revenue_period , 2 ),
265+ },
266+ "channel_split_total" : {
267+ "vinted_count" : vinted_count_total ,
268+ "ebay_count" : ebay_count_total ,
269+ "vinted_revenue_eur" : round (vinted_revenue_total_split , 2 ),
270+ "ebay_revenue_eur" : round (ebay_revenue_total_split , 2 ),
271+ },
161272 "inventory_count" : inventory_count ,
162273 "inventory_purchase_total_eur" : round (inventory_purchase_total_eur , 2 ),
163274 "inventory_sell_total_eur" : round (inventory_sell_total_eur , 2 ),
275+ "inventory_estimated_profit_eur" : round (inventory_estimated_profit_eur , 2 ),
164276 "estimated_cardmarket_inventory_eur" : round (market_sum , 2 ) if market_sum is not None else None ,
165277 "estimated_cardmarket_unsold_eur" : round (market_sum_unsold , 2 )
166278 if market_sum_unsold is not None
0 commit comments