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
This commit is contained in:
Christian 2026-01-27 00:49:23 +01:00
parent 8ec12819f7
commit fbe43b82e1

View File

@ -178,6 +178,7 @@ class SubscriptionMatrixService:
if other_ref: if other_ref:
invoice_text_parts.append(str(other_ref)) invoice_text_parts.append(str(other_ref))
invoice_text = " ".join(invoice_text_parts).lower() invoice_text = " ".join(invoice_text_parts).lower()
invoice_period = self._extract_period_from_text(invoice_text)
# Process invoice lines # Process invoice lines
lines = invoice.get('lines', []) lines = invoice.get('lines', [])
@ -194,13 +195,14 @@ class SubscriptionMatrixService:
): ):
continue continue
if not product_number: if not product_number and not product_name:
logger.debug(f"Skipping line without product number: {line}") logger.debug(f"Skipping line without product number: {line}")
continue continue
product_key = product_number or (product_name or "").strip().lower()
# Cache product name # Cache product name
if product_number not in product_names: if product_key not in product_names:
product_names[product_number] = product_name or f"Product {product_number}" product_names[product_key] = product_name or f"Product {product_number}"
# Extract period from line # Extract period from line
period_from_str = line.get('period', {}).get('from') 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 no period on line, use invoice date as month
if not period_from_str: 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 period_from_str = invoice_date
# Assume monthly billing if no end date # Assume monthly billing if no end date
period_from = datetime.fromisoformat(invoice_date.replace('Z', '+00:00')) 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) period_label = self._format_period_label(period_from, period_to)
# Initialize product if first time within range # 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: for month_key in all_months:
product_matrix[product_number][month_key] = { product_matrix[product_key][month_key] = {
"amount": 0.0, "amount": 0.0,
"status": "missing", "status": "missing",
"invoice_number": None, "invoice_number": None,
@ -258,9 +265,9 @@ class SubscriptionMatrixService:
} }
# Update cell # Update cell
cell = product_matrix[product_number][year_month] cell = product_matrix[product_key][year_month]
cell["amount"] = amount # Take last amount (or sum if multiple?) cell["amount"] += amount
cell["status"] = self._determine_status(invoice_status) cell["status"] = self._merge_status(cell["status"], self._determine_status(invoice_status))
cell["invoice_number"] = invoice_number cell["invoice_number"] = invoice_number
cell["period_from"] = period_from.isoformat().split('T')[0] cell["period_from"] = period_from.isoformat().split('T')[0]
cell["period_to"] = period_to.isoformat().split('T')[0] cell["period_to"] = period_to.isoformat().split('T')[0]
@ -331,6 +338,54 @@ class SubscriptionMatrixService:
# Return sorted chronologically (oldest first) # Return sorted chronologically (oldest first)
return sorted(months) 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 @staticmethod
def _format_period_label(period_from: datetime, period_to: datetime) -> str: def _format_period_label(period_from: datetime, period_to: datetime) -> str:
"""Format period as readable label (e.g., 'Jan', 'Jan-Mar', etc)""" """Format period as readable label (e.g., 'Jan', 'Jan-Mar', etc)"""