From 1b48e659a88f29adc02db834405bca1ca303626b Mon Sep 17 00:00:00 2001 From: Christian Date: Mon, 26 Jan 2026 17:17:14 +0100 Subject: [PATCH] feat: Show 12-month matrix with empty cells for missing invoices - Generate all 12 months automatically (last 12 months from today) - Display empty cells with 'missing' status for months without invoices - Makes it easy to spot billing gaps - Empty cells show 0 kr and null invoice_number --- app/services/subscription_matrix.py | 85 ++++++++++++++++++++++------- 1 file changed, 66 insertions(+), 19 deletions(-) diff --git a/app/services/subscription_matrix.py b/app/services/subscription_matrix.py index 2c63cbd..da8a8ae 100644 --- a/app/services/subscription_matrix.py +++ b/app/services/subscription_matrix.py @@ -140,13 +140,39 @@ class SubscriptionMatrixService: months: Number of months to include Returns: - List of product records with aggregated monthly data + List of product records with aggregated monthly data (including empty months) """ + # Generate list of all months to display (last N months from today) + all_months = self._generate_month_range(months) + # Structure: {product_number: {year_month: {amount, status, invoice_number, period_from, period_to}}} product_matrix = defaultdict(dict) product_names = {} # Cache product names + # Initialize all products with empty cells for all months try: + for invoice in invoices: + lines = invoice.get('lines', []) + for line in lines: + product_number = line.get('product', {}).get('productNumber') + product_name = line.get('product', {}).get('name') or line.get('description') + + if product_number and product_number not in product_names: + product_names[product_number] = product_name or f"Product {product_number}" + + # Initialize all products with all months (empty) + for product_number in product_names.keys(): + for year_month in all_months: + product_matrix[product_number][year_month] = { + "amount": 0.0, + "status": "missing", + "invoice_number": None, + "period_from": None, + "period_to": None, + "period_label": None + } + + # Now fill in data from invoices for invoice in invoices: invoice_number = invoice.get('bookedInvoiceNumber') or invoice.get('draftInvoiceNumber') # Determine status based on invoice type/endpoint it came from @@ -214,24 +240,15 @@ class SubscriptionMatrixService: # Calculate period label period_label = self._format_period_label(period_from, period_to) - # Initialize cell if doesn't exist - if year_month not in product_matrix[product_number]: - product_matrix[product_number][year_month] = { - "amount": 0.0, - "status": "missing", - "invoice_number": None, - "period_from": None, - "period_to": None - } - - # Update cell - cell = product_matrix[product_number][year_month] - cell["amount"] = amount # Take last amount (or sum if multiple?) - cell["status"] = self._determine_status(invoice_status) - cell["invoice_number"] = invoice_number - cell["period_from"] = period_from.isoformat().split('T')[0] - cell["period_to"] = period_to.isoformat().split('T')[0] - cell["period_label"] = period_label + # Update cell (it should already exist from initialization) + if year_month in product_matrix.get(product_number, {}): + cell = product_matrix[product_number][year_month] + cell["amount"] = amount # Take last amount (or sum if multiple?) + cell["status"] = self._determine_status(invoice_status) + cell["invoice_number"] = invoice_number + cell["period_from"] = period_from.isoformat().split('T')[0] + cell["period_to"] = period_to.isoformat().split('T')[0] + cell["period_label"] = period_label except Exception as e: logger.error(f"❌ Error aggregating data: {e}") @@ -268,6 +285,36 @@ class SubscriptionMatrixService: return products + @staticmethod + def _generate_month_range(num_months: int) -> List[str]: + """ + Generate list of month keys for the last N months + + Args: + num_months: Number of months to generate (e.g., 12) + + Returns: + Sorted list of 'YYYY-MM' strings going back from today + """ + months = [] + today = datetime.now() + + for i in range(num_months): + # Go back i months from today using calendar arithmetic + year = today.year + month = today.month - i + + # Adjust year and month if month goes negative + while month <= 0: + month += 12 + year -= 1 + + month_key = f"{year:04d}-{month:02d}" + months.append(month_key) + + # Return sorted chronologically (oldest first) + return sorted(months) + @staticmethod def _format_period_label(period_from: datetime, period_to: datetime) -> str: """Format period as readable label (e.g., 'Jan', 'Jan-Mar', etc)"""