From fbe43b82e1ad4361656bf4aa0a6f81c9534e56d0 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 27 Jan 2026 00:49:23 +0100 Subject: [PATCH] fix: Group products and parse period from invoice title - Group identical product lines on same row - Parse month from invoice title/notes (e.g., 'Periode May 2025') - Assign lines to correct month from title - Sum amounts per month and merge statuses --- app/services/subscription_matrix.py | 73 +++++++++++++++++++++++++---- 1 file changed, 64 insertions(+), 9 deletions(-) diff --git a/app/services/subscription_matrix.py b/app/services/subscription_matrix.py index ad73554..9d87127 100644 --- a/app/services/subscription_matrix.py +++ b/app/services/subscription_matrix.py @@ -178,6 +178,7 @@ class SubscriptionMatrixService: if other_ref: invoice_text_parts.append(str(other_ref)) invoice_text = " ".join(invoice_text_parts).lower() + invoice_period = self._extract_period_from_text(invoice_text) # Process invoice lines lines = invoice.get('lines', []) @@ -194,13 +195,14 @@ class SubscriptionMatrixService: ): continue - if not product_number: + if not product_number and not product_name: logger.debug(f"Skipping line without product number: {line}") continue + product_key = product_number or (product_name or "").strip().lower() # Cache product name - if product_number not in product_names: - product_names[product_number] = product_name or f"Product {product_number}" + if product_key not in product_names: + product_names[product_key] = product_name or f"Product {product_number}" # Extract period from line period_from_str = line.get('period', {}).get('from') @@ -208,7 +210,12 @@ class SubscriptionMatrixService: # If no period on line, use invoice date as month if not period_from_str: - if invoice_date: + if invoice_period: + period_from = invoice_period + period_to = self._end_of_month(period_from) + period_from_str = period_from.isoformat().split('T')[0] + period_to_str = period_to.isoformat().split('T')[0] + elif invoice_date: period_from_str = invoice_date # Assume monthly billing if no end date period_from = datetime.fromisoformat(invoice_date.replace('Z', '+00:00')) @@ -246,9 +253,9 @@ class SubscriptionMatrixService: period_label = self._format_period_label(period_from, period_to) # Initialize product if first time within range - if product_number not in product_matrix: + if product_key not in product_matrix: for month_key in all_months: - product_matrix[product_number][month_key] = { + product_matrix[product_key][month_key] = { "amount": 0.0, "status": "missing", "invoice_number": None, @@ -258,9 +265,9 @@ class SubscriptionMatrixService: } # 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 = product_matrix[product_key][year_month] + cell["amount"] += amount + cell["status"] = self._merge_status(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] @@ -330,6 +337,54 @@ class SubscriptionMatrixService: # Return sorted chronologically (oldest first) return sorted(months) + + @staticmethod + def _end_of_month(dt: datetime) -> datetime: + """Return last day of the month for a given date.""" + next_month = dt.replace(day=28) + timedelta(days=4) + return next_month - timedelta(days=next_month.day) + + @staticmethod + def _extract_period_from_text(text: str) -> Optional[datetime]: + """Extract month/year from invoice title/notes text (e.g., 'Periode May 2025').""" + if not text: + return None + month_map = { + "january": 1, "jan": 1, "januar": 1, + "february": 2, "feb": 2, "februar": 2, + "march": 3, "mar": 3, "marts": 3, + "april": 4, "apr": 4, + "may": 5, "maj": 5, + "june": 6, "jun": 6, "juni": 6, + "july": 7, "jul": 7, "juli": 7, + "august": 8, "aug": 8, + "september": 9, "sep": 9, "sept": 9, + "october": 10, "oct": 10, "okt": 10, "oktober": 10, + "november": 11, "nov": 11, + "december": 12, "dec": 12 + } + tokens = text.replace(".", " ").replace("/", " ").split() + for i, token in enumerate(tokens[:-1]): + month = month_map.get(token.lower()) + if month: + year_token = tokens[i + 1] + if year_token.isdigit() and len(year_token) == 4: + return datetime(int(year_token), month, 1) + return None + + @staticmethod + def _merge_status(existing: str, incoming: str) -> str: + """Keep the most 'complete' status when multiple invoices hit same cell.""" + priority = { + "missing": 0, + "draft": 1, + "invoiced": 2, + "paid": 3, + "credited": 2 + } + existing_key = existing or "missing" + incoming_key = incoming or "missing" + return incoming_key if priority.get(incoming_key, 0) >= priority.get(existing_key, 0) else existing_key @staticmethod def _format_period_label(period_from: datetime, period_to: datetime) -> str: