From 9be8f320f79675bed046d3b76225ad3f6e727651 Mon Sep 17 00:00:00 2001 From: "matthias.lotz" Date: Fri, 26 Dec 2025 21:35:22 +0100 Subject: [PATCH] =?UTF-8?q?T=C3=A4gliche=20Statistiken=20mit=20Drill-Down?= =?UTF-8?q?=20und=20Jahresvergleich?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tägliche Statistiken (DailyStats) implementiert mit 2034 Datensätzen - Wochenende vs. Wochentag farblich unterschieden in Tagesansicht - Jahr/Monat Felder für Jahresvergleich hinzugefügt - Button 'Tagesansicht' in Kanban-Dashboard für Drill-Down zu täglichen Statistiken - Alle Dashboard-Graphen auf Jahresvergleich umgestellt (Monat × Jahr, nicht gestapelt) - Jahresvergleich, Ø Nutzer/Tag, Wiederholungstäter, Nutzer pro Monat - alle mit Jahresvergleich --- open_workshop_report/__manifest__.py | 2 + open_workshop_report/generate_daily_stats.py | 59 +++++++++ open_workshop_report/models/__init__.py | 1 + open_workshop_report/models/daily_stats.py | 121 ++++++++++++++++++ open_workshop_report/models/usage_stats.py | 96 +++++++++++++- .../security/ir.model.access.csv | 2 + .../views/daily_stats_views.xml | 67 ++++++++++ .../views/dashboard_views.xml | 45 ++++--- .../views/specialized_dashboards.xml | 119 +++++++++++++++++ .../views/usage_stats_views.xml | 28 ++-- 10 files changed, 510 insertions(+), 30 deletions(-) create mode 100644 open_workshop_report/generate_daily_stats.py create mode 100644 open_workshop_report/models/daily_stats.py create mode 100644 open_workshop_report/views/daily_stats_views.xml create mode 100644 open_workshop_report/views/specialized_dashboards.xml diff --git a/open_workshop_report/__manifest__.py b/open_workshop_report/__manifest__.py index f720958..6ccffd9 100644 --- a/open_workshop_report/__manifest__.py +++ b/open_workshop_report/__manifest__.py @@ -25,6 +25,8 @@ 'data/cron_data.xml', 'views/dashboard_views.xml', 'views/usage_stats_views.xml', + 'views/daily_stats_views.xml', + 'views/specialized_dashboards.xml', 'reports/usage_report_templates.xml', 'reports/usage_reports.xml', ], diff --git a/open_workshop_report/generate_daily_stats.py b/open_workshop_report/generate_daily_stats.py new file mode 100644 index 0000000..60da825 --- /dev/null +++ b/open_workshop_report/generate_daily_stats.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Generiere Tagesstatistiken für Open Workshop +""" +import sys +sys.path.insert(0, '/odoo') + +import odoo +from datetime import datetime, timedelta + +# Initialisiere Odoo +odoo.tools.config['db_name'] = 'hh18' +odoo.tools.config.parse_config(['-c', '/etc/odoo/odoo-dev.conf']) + +# Registry laden +registry = odoo.registry('hh18') +with registry.cursor() as cr: + env = odoo.api.Environment(cr, odoo.SUPERUSER_ID, {}) + + DailyStats = env['open_workshop.daily.stats'] + + # Finde älteste und neuste Session + Session = env['pos.session'] + sessions = Session.search([('state', '=', 'closed')], order='start_at', limit=1) + if not sessions: + print('Keine Sessions gefunden!') + sys.exit(1) + + start_date = sessions[0].start_at.date() + end_date = datetime.now().date() + + print(f'Generiere Tagesstatistiken von {start_date} bis {end_date}') + + current_date = start_date + created_count = 0 + updated_count = 0 + + while current_date <= end_date: + # Prüfe ob schon vorhanden + existing = DailyStats.search([('date', '=', current_date)], limit=1) + if existing: + # Neu berechnen + existing._compute_stats() + updated_count += 1 + else: + DailyStats.create({'date': current_date}) + created_count += 1 + + if (created_count + updated_count) % 100 == 0: + print(f' Verarbeitet: {created_count + updated_count} Tage...') + cr.commit() + + current_date += timedelta(days=1) + + cr.commit() + print(f'\n✅ {created_count} neue Tagesstatistiken erstellt') + print(f'✅ {updated_count} Tagesstatistiken aktualisiert') + print(f'✅ Gesamt: {created_count + updated_count} Tage') diff --git a/open_workshop_report/models/__init__.py b/open_workshop_report/models/__init__.py index 7281618..02c7ffa 100644 --- a/open_workshop_report/models/__init__.py +++ b/open_workshop_report/models/__init__.py @@ -1 +1,2 @@ from . import usage_stats +from . import daily_stats diff --git a/open_workshop_report/models/daily_stats.py b/open_workshop_report/models/daily_stats.py new file mode 100644 index 0000000..b310305 --- /dev/null +++ b/open_workshop_report/models/daily_stats.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- +from odoo import api, fields, models, _ +from datetime import datetime, timedelta + + +class DailyStats(models.Model): + """Tägliche Nutzungsstatistiken""" + _name = 'open_workshop.daily.stats' + _description = 'Tägliche Nutzungsstatistiken' + _order = 'date desc' + + date = fields.Date('Datum', required=True, index=True) + year = fields.Integer('Jahr', compute='_compute_weekday', store=True, group_operator='') + month = fields.Integer('Monat', compute='_compute_weekday', store=True) + month_name = fields.Char('Monat Name', compute='_compute_weekday', store=True) + weekday = fields.Selection([ + ('0', 'Montag'), + ('1', 'Dienstag'), + ('2', 'Mittwoch'), + ('3', 'Donnerstag'), + ('4', 'Freitag'), + ('5', 'Samstag'), + ('6', 'Sonntag'), + ], string='Wochentag', compute='_compute_weekday', store=True) + + is_weekend = fields.Char('Tag-Typ', compute='_compute_weekday', store=True, group_expand='_group_expand_weekend') + + # Buchungen + total_bookings = fields.Integer('Buchungen gesamt', compute='_compute_stats', store=True) + total_bookings_no_heroes = fields.Integer('Buchungen ohne Thekenhelden', compute='_compute_stats', store=True) + + # Nutzer + unique_users = fields.Integer('Nutzer gesamt', compute='_compute_stats', store=True) + unique_users_no_heroes = fields.Integer('Nutzer ohne Thekenhelden', compute='_compute_stats', store=True) + + # Monatszugehörigkeit + month_stats_id = fields.Many2one('open_workshop.usage.stats', string='Monatsstatistik', + compute='_compute_month_stats', store=True) + + @api.depends('date') + def _compute_weekday(self): + month_names = { + 1: 'Januar', 2: 'Februar', 3: 'März', 4: 'April', + 5: 'Mai', 6: 'Juni', 7: 'Juli', 8: 'August', + 9: 'September', 10: 'Oktober', 11: 'November', 12: 'Dezember' + } + for record in self: + wd = record.date.weekday() + record.weekday = str(wd) + record.is_weekend = 'Wochenende' if wd >= 5 else 'Wochentag' # Sa=5, So=6 + record.year = record.date.year + record.month = record.date.month + record.month_name = month_names.get(record.date.month, '') + + def _group_expand_weekend(self, weekends, domain, order): + """Stelle sicher dass beide Kategorien immer angezeigt werden""" + return ['Wochentag', 'Wochenende'] + + @api.depends('date') + def _compute_month_stats(self): + """Verknüpfe mit Monatsstatistik""" + for record in self: + month_stat = self.env['open_workshop.usage.stats'].search([ + ('period_type', '=', 'month'), + ('period_start', '<=', record.date), + ('period_end', '>=', record.date), + ], limit=1) + record.month_stats_id = month_stat + + @api.depends('date') + def _compute_stats(self): + for record in self: + start_datetime = datetime.combine(record.date, datetime.min.time()) + end_datetime = datetime.combine(record.date, datetime.max.time()) + + # Alle POS Orders an diesem Tag + orders = self.env['pos.order'].search([ + ('date_order', '>=', start_datetime), + ('date_order', '<=', end_datetime), + ('partner_id', '!=', False), + ]) + + record.total_bookings = len(orders) + + # Thekenheld-Kategorie + hero_category = self.env['res.partner.category'].search([ + '|', ('name', 'ilike', 'thekenheld'), ('name', 'ilike', 'thk') + ], limit=1) + + if hero_category: + orders_no_heroes = orders.filtered(lambda o: hero_category not in o.partner_id.category_id) + partners_no_heroes = orders_no_heroes.mapped('partner_id') + else: + orders_no_heroes = orders + partners_no_heroes = orders.mapped('partner_id') + + record.total_bookings_no_heroes = len(orders_no_heroes) + record.unique_users = len(orders.mapped('partner_id')) + record.unique_users_no_heroes = len(partners_no_heroes) + + @api.model + def generate_daily_stats_for_month(self, year, month): + """Generiere Tagesstatistiken für einen Monat""" + start_date = datetime(year, month, 1).date() + if month == 12: + end_date = datetime(year + 1, 1, 1).date() - timedelta(days=1) + else: + end_date = datetime(year, month + 1, 1).date() - timedelta(days=1) + + current_date = start_date + created_count = 0 + + while current_date <= end_date: + # Prüfe ob schon vorhanden + existing = self.search([('date', '=', current_date)], limit=1) + if not existing: + self.create({'date': current_date}) + created_count += 1 + current_date += timedelta(days=1) + + return created_count diff --git a/open_workshop_report/models/usage_stats.py b/open_workshop_report/models/usage_stats.py index cec7926..e1c6b09 100644 --- a/open_workshop_report/models/usage_stats.py +++ b/open_workshop_report/models/usage_stats.py @@ -22,17 +22,60 @@ class UsageStats(models.Model): ('year', 'Jahr'), ], string='Periodentyp', default='month', required=True) - # Statistiken - total_sessions = fields.Integer('Gesamt Nutzungen', compute='_compute_stats', store=True) + # Für Jahresvergleich + year = fields.Integer('Jahr', compute='_compute_year', store=True) + month = fields.Integer('Monat', compute='_compute_year', store=True) + month_name = fields.Char('Monat Name', compute='_compute_year', store=True) + + # Statistiken - Alle Nutzer + total_sessions = fields.Integer('Gesamt Buchungen', compute='_compute_stats', store=True) total_revenue = fields.Monetary('Gesamtumsatz', compute='_compute_stats', store=True, currency_field='currency_id') avg_per_day = fields.Float('Durchschnitt pro Tag', compute='_compute_stats', store=True, digits=(5, 2)) unique_users = fields.Integer('Einzigartige Nutzer', compute='_compute_stats', store=True) + # Statistiken - Ohne Thekenhelden + total_bookings_no_heroes = fields.Integer('Buchungen ohne Thekenhelden', compute='_compute_stats', store=True) + unique_users_no_heroes = fields.Integer('Nutzer ohne Thekenhelden', compute='_compute_stats', store=True) + avg_per_day_no_heroes = fields.Float('Ø Nutzer pro Tag ohne Thekenhelden', compute='_compute_stats', store=True, digits=(10, 2)) + + # Wiederholungstäter + repeat_users = fields.Integer('Wiederholungstäter', compute='_compute_stats', store=True, + help='Nutzer die mehr als 1x im Zeitraum gebucht haben') + repeat_users_no_heroes = fields.Integer('Wiederholungstäter ohne Thekenhelden', compute='_compute_stats', store=True) + currency_id = fields.Many2one('res.currency', string='Währung', default=lambda self: self.env.company.currency_id) # POS Session IDs für diese Periode session_ids = fields.Many2many('pos.session', string='POS Sessions', compute='_compute_stats', store=True) + + # Tägliche Statistiken für diese Periode + daily_stats_ids = fields.One2many('open_workshop.daily.stats', compute='_compute_daily_stats', string='Tägliche Statistiken') + + @api.depends('period_start', 'period_end') + def _compute_daily_stats(self): + """Lädt die täglichen Statistiken für diese Periode""" + for record in self: + record.daily_stats_ids = self.env['open_workshop.daily.stats'].search([ + ('date', '>=', record.period_start), + ('date', '<=', record.period_end) + ], order='date asc') + + def action_view_details(self): + """Öffnet die Detailansicht mit Tages-Diagramm""" + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': f'Tagesansicht: {self.period_name}', + 'res_model': 'open_workshop.daily.stats', + 'view_mode': 'graph,list', + 'domain': [('date', '>=', self.period_start), ('date', '<=', self.period_end)], + 'context': { + 'search_default_period': 1, + 'default_date': self.period_start, + }, + 'target': 'current', + } @api.depends('period_start', 'period_end', 'period_type') def _compute_period_name(self): @@ -46,6 +89,18 @@ class UsageStats(models.Model): else: record.period_name = f"{record.period_start.strftime('%d.%m.%Y')} - {record.period_end.strftime('%d.%m.%Y')}" + @api.depends('period_start') + def _compute_year(self): + month_names = { + 1: 'Januar', 2: 'Februar', 3: 'März', 4: 'April', + 5: 'Mai', 6: 'Juni', 7: 'Juli', 8: 'August', + 9: 'September', 10: 'Oktober', 11: 'November', 12: 'Dezember' + } + for record in self: + record.year = record.period_start.year + record.month = record.period_start.month + record.month_name = month_names.get(record.period_start.month, '') + @api.depends('period_start', 'period_end') def _compute_stats(self): for record in self: @@ -68,12 +123,45 @@ class UsageStats(models.Model): days = (record.period_end - record.period_start).days + 1 record.avg_per_day = record.total_sessions / days if days > 0 else 0.0 - # Einzigartige Nutzer (über POS Orders) + # Hole Thekenheld-Kategorie + hero_category = self.env['res.partner.category'].search([ + '|', ('name', 'ilike', 'thekenheld'), ('name', 'ilike', 'thk') + ], limit=1) + + # Alle POS Orders im Zeitraum orders = self.env['pos.order'].search([ ('session_id', 'in', sessions.ids), ('partner_id', '!=', False), ]) - record.unique_users = len(orders.mapped('partner_id')) + + # Einzigartige Nutzer (alle) + all_partners = orders.mapped('partner_id') + record.unique_users = len(all_partners) + + # Filter: Ohne Thekenhelden + if hero_category: + partners_no_heroes = all_partners.filtered(lambda p: hero_category not in p.category_id) + orders_no_heroes = orders.filtered(lambda o: hero_category not in o.partner_id.category_id) + else: + partners_no_heroes = all_partners + orders_no_heroes = orders + + record.unique_users_no_heroes = len(partners_no_heroes) + record.total_bookings_no_heroes = len(orders_no_heroes) + record.avg_per_day_no_heroes = record.unique_users_no_heroes / days if days > 0 else 0.0 + + # Wiederholungstäter: Partner die mehr als 1 Order haben + partner_order_count = {} + for order in orders: + partner_id = order.partner_id.id + partner_order_count[partner_id] = partner_order_count.get(partner_id, 0) + 1 + record.repeat_users = len([p for p, count in partner_order_count.items() if count > 1]) + + partner_order_count_no_heroes = {} + for order in orders_no_heroes: + partner_id = order.partner_id.id + partner_order_count_no_heroes[partner_id] = partner_order_count_no_heroes.get(partner_id, 0) + 1 + record.repeat_users_no_heroes = len([p for p, count in partner_order_count_no_heroes.items() if count > 1]) @api.model def generate_monthly_stats(self, year=None, month=None): diff --git a/open_workshop_report/security/ir.model.access.csv b/open_workshop_report/security/ir.model.access.csv index 33298c9..007ac45 100644 --- a/open_workshop_report/security/ir.model.access.csv +++ b/open_workshop_report/security/ir.model.access.csv @@ -1,3 +1,5 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_usage_stats_user,access.usage.stats.user,model_open_workshop_usage_stats,base.group_user,1,0,0,0 access_usage_stats_manager,access.usage.stats.manager,model_open_workshop_usage_stats,point_of_sale.group_pos_manager,1,1,1,1 +access_daily_stats_user,access.daily.stats.user,model_open_workshop_daily_stats,base.group_user,1,0,0,0 +access_daily_stats_manager,access.daily.stats.manager,model_open_workshop_daily_stats,point_of_sale.group_pos_manager,1,1,1,1 diff --git a/open_workshop_report/views/daily_stats_views.xml b/open_workshop_report/views/daily_stats_views.xml new file mode 100644 index 0000000..8030abc --- /dev/null +++ b/open_workshop_report/views/daily_stats_views.xml @@ -0,0 +1,67 @@ + + + + + open_workshop.daily.stats.tree + open_workshop.daily.stats + + + + + + + + + + + + + open_workshop.daily.stats.graph + open_workshop.daily.stats + + + + + + + + + + + + open_workshop.daily.stats.pivot + open_workshop.daily.stats + + + + + + + + + + + + + Tägliche Nutzung + open_workshop.daily.stats + graph,pivot,list + {} + +

+ Keine täglichen Statistiken vorhanden +

+

+ Tägliche Statistiken werden automatisch generiert. +

+
+
+ + + + +
diff --git a/open_workshop_report/views/dashboard_views.xml b/open_workshop_report/views/dashboard_views.xml index 43f99fe..b7caf04 100644 --- a/open_workshop_report/views/dashboard_views.xml +++ b/open_workshop_report/views/dashboard_views.xml @@ -5,39 +5,52 @@ open_workshop.usage.dashboard open_workshop.usage.stats - - - - + + + + + + -
+
-
Nutzungen
- +
Buchungen (ohne Theken)
+
-
⌀/Tag
- +
Nutzer (ohne Theken)
+
-
Nutzer
- +
⌀/Tag (ohne Theken)
+
+
Wiederholungstäter
+ +
+
+
+
Umsatz
+
+
+ +
+
@@ -46,24 +59,24 @@ - + Nutzungs-Dashboard open_workshop.usage.stats - kanban,graph,pivot + kanban,form,graph,pivot - diff --git a/open_workshop_report/views/specialized_dashboards.xml b/open_workshop_report/views/specialized_dashboards.xml new file mode 100644 index 0000000..ff82805 --- /dev/null +++ b/open_workshop_report/views/specialized_dashboards.xml @@ -0,0 +1,119 @@ + + + + + open_workshop.usage.stats.graph.yearly + open_workshop.usage.stats + + + + + + + + + + + + open_workshop.usage.stats.graph.avg.trend + open_workshop.usage.stats + + + + + + + + + + + + open_workshop.usage.stats.graph.repeat + open_workshop.usage.stats + + + + + + + + + + + + open_workshop.usage.stats.graph.users + open_workshop.usage.stats + + + + + + + + + + + + Jahresvergleich Buchungen + open_workshop.usage.stats + graph + + [('period_type', '=', 'month')] + + + + + Ø Nutzer pro Tag (Trend) + open_workshop.usage.stats + graph + + [('period_type', '=', 'month')] + + + + + Wiederholungstäter + open_workshop.usage.stats + graph + + [('period_type', '=', 'month')] + + + + + Nutzer pro Monat + open_workshop.usage.stats + graph + + [('period_type', '=', 'month')] + + + + + + + + + + + + + + + diff --git a/open_workshop_report/views/usage_stats_views.xml b/open_workshop_report/views/usage_stats_views.xml index d00e67c..2b6d475 100644 --- a/open_workshop_report/views/usage_stats_views.xml +++ b/open_workshop_report/views/usage_stats_views.xml @@ -9,9 +9,10 @@ - - - + + + + @@ -33,12 +34,19 @@ - + + + + + + + + @@ -73,14 +81,14 @@ - + open_workshop.usage.stats.graph.bar open_workshop.usage.stats - + - + @@ -90,10 +98,10 @@ open_workshop.usage.stats.graph.line open_workshop.usage.stats - + - - + +