initiale Version von Open Workshop Report

This commit is contained in:
Matthias Lotz 2025-12-26 19:14:17 +01:00
parent 0135035a54
commit d6a98cfbd0
13 changed files with 670 additions and 0 deletions

View File

@ -0,0 +1,2 @@
from . import models
from . import reports

View File

@ -0,0 +1,39 @@
{
'name': 'Open Workshop Reports',
'license': 'AGPL-3',
'version': '18.0.1.0.0',
'summary': 'Nutzungsstatistiken und Reports für Open Workshop',
'description': """
Open Workshop Reports
=====================
Dieses Modul bietet:
- Dashboard mit Nutzungsstatistiken
- Pivot-Tabellen und Diagramme für POS Sessions
- Monatliche/Historische Auswertungen
- PDF-Reports für Team-Meetings
""",
'depends': [
'base',
'point_of_sale',
'open_workshop_pos',
],
'author': 'matthias.lotz',
'category': 'Point of Sale',
'data': [
'security/ir.model.access.csv',
'data/cron_data.xml',
'views/dashboard_views.xml',
'views/usage_stats_views.xml',
'reports/usage_report_templates.xml',
'reports/usage_reports.xml',
],
'assets': {
'web.assets_backend': [
'open_workshop_report/static/src/css/dashboard.css',
],
},
'installable': True,
'application': False,
'auto_install': False,
}

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Cron Job: Generate Monthly Usage Statistics -->
<record id="ir_cron_generate_usage_stats" model="ir.cron">
<field name="name">Generate Monthly Usage Statistics</field>
<field name="model_id" ref="model_open_workshop_usage_stats"/>
<field name="state">code</field>
<field name="code">model.cron_generate_monthly_stats()</field>
<field name="interval_number">1</field>
<field name="interval_type">months</field>
<field name="active" eval="True"/>
</record>
</odoo>

View File

@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""
Skript zum Generieren von Nutzungsstatistiken aus vorhandenen POS-Sessions
Aufruf: docker compose exec odoo-dev python3 /mnt/extra-addons/open_workshop/open_workshop_report/generate_stats.py
"""
import sys
sys.path.insert(0, '/usr/lib/python3/dist-packages')
import odoo
from odoo import api, SUPERUSER_ID
from datetime import datetime, timedelta
from dateutil.relativedelta import relativedelta
# Konfiguration
DB_NAME = 'hh18'
CONFIG_FILE = '/etc/odoo/odoo-dev.conf'
# Odoo initialisieren
odoo.tools.config.parse_config(['-c', CONFIG_FILE])
odoo.cli.server.report_configuration()
# Registry laden
registry = odoo.registry(DB_NAME)
def generate_monthly_stats():
"""Generiert monatliche Statistiken für die letzten 12 Monate"""
with registry.cursor() as cr:
env = api.Environment(cr, SUPERUSER_ID, {})
UsageStats = env['open_workshop.usage.stats']
# Finde älteste und neueste Session
cr.execute("""
SELECT MIN(start_at), MAX(stop_at)
FROM pos_session
WHERE state = 'closed'
""")
result = cr.fetchone()
if not result or not result[0]:
print("Keine geschlossenen POS-Sessions gefunden!")
return
oldest_date = result[0]
newest_date = result[1] or datetime.now()
print(f"Generiere Statistiken von {oldest_date} bis {newest_date}")
# Generiere Statistiken für jeden Monat
current_date = oldest_date.replace(day=1)
end_date = newest_date.replace(day=1)
stats_created = 0
while current_date <= end_date:
period_start = current_date
period_end = (current_date + relativedelta(months=1)) - timedelta(seconds=1)
period_name = current_date.strftime('%B %Y')
# Prüfe ob Statistik bereits existiert
existing = UsageStats.search([
('period_start', '=', period_start),
('period_type', '=', 'month')
], limit=1)
if not existing:
# Finde alle Sessions in diesem Monat
sessions = env['pos.session'].search([
('state', '=', 'closed'),
('start_at', '>=', period_start),
('start_at', '<=', period_end)
])
if sessions:
# Erstelle Statistik
UsageStats.create({
'period_name': period_name,
'period_type': 'month',
'period_start': period_start,
'period_end': period_end,
'session_ids': [(6, 0, sessions.ids)]
})
stats_created += 1
print(f"{period_name}: {len(sessions)} Sessions")
else:
print(f"- {period_name}: Keine Sessions")
else:
print(f"{period_name}: Bereits vorhanden")
# Nächster Monat
current_date = current_date + relativedelta(months=1)
cr.commit()
print(f"\n{stats_created} Statistik-Einträge erstellt!")
if __name__ == '__main__':
try:
generate_monthly_stats()
except Exception as e:
print(f"❌ Fehler: {e}")
import traceback
traceback.print_exc()
sys.exit(1)

View File

@ -0,0 +1 @@
from . import usage_stats

View File

@ -0,0 +1,114 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models, _
from datetime import datetime, timedelta
from dateutil.relativedelta import relativedelta
class UsageStats(models.Model):
"""Nutzungsstatistiken für Open Workshop"""
_name = 'open_workshop.usage.stats'
_description = 'Open Workshop Nutzungsstatistiken'
_order = 'period_start desc'
_rec_name = 'period_name'
period_name = fields.Char('Zeitraum', compute='_compute_period_name', store=True)
period_start = fields.Date('Beginn', required=True)
period_end = fields.Date('Ende', required=True)
period_type = fields.Selection([
('day', 'Tag'),
('week', 'Woche'),
('month', 'Monat'),
('quarter', 'Quartal'),
('year', 'Jahr'),
], string='Periodentyp', default='month', required=True)
# Statistiken
total_sessions = fields.Integer('Gesamt Nutzungen', 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)
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)
@api.depends('period_start', 'period_end', 'period_type')
def _compute_period_name(self):
for record in self:
if record.period_type == 'month':
record.period_name = record.period_start.strftime('%B %Y')
elif record.period_type == 'week':
record.period_name = f"KW {record.period_start.strftime('%W/%Y')}"
elif record.period_type == 'day':
record.period_name = record.period_start.strftime('%d.%m.%Y')
else:
record.period_name = f"{record.period_start.strftime('%d.%m.%Y')} - {record.period_end.strftime('%d.%m.%Y')}"
@api.depends('period_start', 'period_end')
def _compute_stats(self):
for record in self:
# Konvertiere Date zu Datetime für die Suche
start_datetime = datetime.combine(record.period_start, datetime.min.time())
end_datetime = datetime.combine(record.period_end, datetime.max.time())
# Suche alle POS Sessions in diesem Zeitraum
sessions = self.env['pos.session'].search([
('start_at', '>=', start_datetime),
('start_at', '<=', end_datetime),
('state', 'in', ['closed', 'closing_control']),
])
record.session_ids = sessions
record.total_sessions = len(sessions)
record.total_revenue = sum(sessions.mapped('total_payments_amount'))
# Berechne Durchschnitt pro Tag
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)
orders = self.env['pos.order'].search([
('session_id', 'in', sessions.ids),
('partner_id', '!=', False),
])
record.unique_users = len(orders.mapped('partner_id'))
@api.model
def generate_monthly_stats(self, year=None, month=None):
"""Generiere Statistiken für einen bestimmten Monat"""
if not year:
year = datetime.now().year
if not month:
month = datetime.now().month
# Berechne Start und Ende des Monats
period_start = datetime(year, month, 1).date()
if month == 12:
period_end = datetime(year + 1, 1, 1).date() - timedelta(days=1)
else:
period_end = datetime(year, month + 1, 1).date() - timedelta(days=1)
# Suche oder erstelle Statistik-Eintrag
stats = self.search([
('period_start', '=', period_start),
('period_end', '=', period_end),
('period_type', '=', 'month'),
], limit=1)
if not stats:
stats = self.create({
'period_start': period_start,
'period_end': period_end,
'period_type': 'month',
})
return stats
@api.model
def cron_generate_monthly_stats(self):
"""Cron-Job: Generiere Statistiken für letzten Monat"""
today = datetime.now()
last_month = today - relativedelta(months=1)
self.generate_monthly_stats(last_month.year, last_month.month)

View File

@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
# Reports are loaded via XML data files

View File

@ -0,0 +1,121 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Main Report Template -->
<template id="usage_report_document">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="o">
<t t-call="web.external_layout">
<div class="page">
<!-- Header -->
<div class="text-center mb-4">
<h2>Nutzungsstatistik Hobbyhimmel</h2>
<h3 t-field="o.period_name"/>
<p class="text-muted">
<span t-field="o.period_start"/> bis <span t-field="o.period_end"/>
</p>
</div>
<!-- KPI Summary Boxes -->
<div class="row mb-4">
<div class="col-3">
<div class="card text-center">
<div class="card-body">
<h1 class="text-primary"><t t-esc="o.total_sessions"/></h1>
<p class="text-muted">Gesamt Nutzungen</p>
</div>
</div>
</div>
<div class="col-3">
<div class="card text-center">
<div class="card-body">
<h1 class="text-success"><t t-esc="'%.1f' % o.avg_per_day"/></h1>
<p class="text-muted">⌀ pro Tag</p>
</div>
</div>
</div>
<div class="col-3">
<div class="card text-center">
<div class="card-body">
<h1 class="text-info"><t t-esc="o.unique_users"/></h1>
<p class="text-muted">Einz. Nutzer</p>
</div>
</div>
</div>
<div class="col-3">
<div class="card text-center">
<div class="card-body">
<h1 class="text-warning">
<t t-esc="'%.2f' % o.total_revenue"/>
<t t-esc="o.currency_id.symbol"/>
</h1>
<p class="text-muted">Umsatz</p>
</div>
</div>
</div>
</div>
<!-- Chart Placeholder -->
<div class="row mb-4">
<div class="col-12">
<h4>Nutzungsverlauf</h4>
<div class="alert alert-info">
<i class="fa fa-info-circle"/>
Für detaillierte Diagramme öffnen Sie bitte die Dashboard-Ansicht in Odoo.
</div>
</div>
</div>
<!-- Sessions Table -->
<div class="row">
<div class="col-12">
<h4>POS Sessions Detail</h4>
<table class="table table-sm table-bordered">
<thead class="table-primary">
<tr>
<th>Session</th>
<th>Start</th>
<th>Ende</th>
<th>Benutzer</th>
<th class="text-end">Umsatz</th>
</tr>
</thead>
<tbody>
<tr t-foreach="o.session_ids" t-as="session">
<td><span t-field="session.name"/></td>
<td><span t-field="session.start_at" t-options="{'widget': 'datetime'}"/></td>
<td><span t-field="session.stop_at" t-options="{'widget': 'datetime'}"/></td>
<td><span t-field="session.user_id"/></td>
<td class="text-end">
<span t-field="session.total_payments_amount"
t-options="{'widget': 'monetary', 'display_currency': o.currency_id}"/>
</td>
</tr>
</tbody>
<tfoot>
<tr class="table-secondary">
<th colspan="4" class="text-end">Gesamt:</th>
<th class="text-end">
<span t-field="o.total_revenue"
t-options="{'widget': 'monetary', 'display_currency': o.currency_id}"/>
</th>
</tr>
</tfoot>
</table>
</div>
</div>
<!-- Footer -->
<div class="row mt-4">
<div class="col-12 text-center text-muted">
<small>
Generiert am <span t-esc="context_timestamp(datetime.datetime.now()).strftime('%d.%m.%Y %H:%M')"/>
</small>
</div>
</div>
</div>
</t>
</t>
</t>
</template>
</odoo>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- PDF Report Definition -->
<record id="action_usage_report_pdf" model="ir.actions.report">
<field name="name">Nutzungsstatistik PDF</field>
<field name="model">open_workshop.usage.stats</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">open_workshop_report.usage_report_document</field>
<field name="report_file">open_workshop_report.usage_report_document</field>
<field name="binding_model_id" ref="model_open_workshop_usage_stats"/>
<field name="binding_type">report</field>
</record>
</odoo>

View File

@ -0,0 +1,3 @@
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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_usage_stats_user access.usage.stats.user model_open_workshop_usage_stats base.group_user 1 0 0 0
3 access_usage_stats_manager access.usage.stats.manager model_open_workshop_usage_stats point_of_sale.group_pos_manager 1 1 1 1

View File

@ -0,0 +1,66 @@
/* Dashboard Styling for Open Workshop Usage Stats */
.o_dashboard {
background: #f9f9f9;
padding: 20px;
}
.o_dashboard .o_aggregate {
background: white;
border-radius: 8px;
padding: 20px;
margin: 10px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
text-align: center;
transition: transform 0.2s;
}
.o_dashboard .o_aggregate:hover {
transform: translateY(-5px);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.o_dashboard .o_aggregate .o_value {
font-size: 2.5em;
font-weight: bold;
margin-bottom: 5px;
}
.o_dashboard .o_aggregate .o_label {
color: #666;
font-size: 0.9em;
text-transform: uppercase;
letter-spacing: 1px;
}
.o_dashboard .o_aggregate.total_sessions .o_value {
color: #0066cc;
}
.o_dashboard .o_aggregate.avg_per_day .o_value {
color: #28a745;
}
.o_dashboard .o_aggregate.unique_users .o_value {
color: #17a2b8;
}
.o_dashboard .o_aggregate.total_revenue .o_value {
color: #ffc107;
}
.o_dashboard .o_graph_view,
.o_dashboard .o_pivot_view {
background: white;
border-radius: 8px;
padding: 20px;
margin: 10px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.o_dashboard h3 {
color: #333;
border-bottom: 2px solid #0066cc;
padding-bottom: 10px;
margin-bottom: 20px;
}

View File

@ -0,0 +1,69 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Kanban Dashboard View -->
<record id="view_usage_dashboard" model="ir.ui.view">
<field name="name">open_workshop.usage.dashboard</field>
<field name="model">open_workshop.usage.stats</field>
<field name="arch" type="xml">
<kanban class="o_kanban_dashboard">
<field name="total_sessions"/>
<field name="avg_per_day"/>
<field name="unique_users"/>
<field name="total_revenue"/>
<field name="period_name"/>
<templates>
<t t-name="kanban-box">
<div class="oe_kanban_global_click">
<div class="o_kanban_record_top">
<strong><field name="period_name"/></strong>
</div>
<div class="o_kanban_record_body">
<div class="row">
<div class="col-6">
<div class="text-muted">Nutzungen</div>
<strong><field name="total_sessions"/></strong>
</div>
<div class="col-6">
<div class="text-muted">⌀/Tag</div>
<strong><field name="avg_per_day"/></strong>
</div>
</div>
<div class="row mt-2">
<div class="col-6">
<div class="text-muted">Nutzer</div>
<strong><field name="unique_users"/></strong>
</div>
<div class="col-6">
<div class="text-muted">Umsatz</div>
<strong><field name="total_revenue" widget="monetary"/></strong>
</div>
</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<!-- Dashboard Action -->
<record id="action_usage_dashboard" model="ir.actions.act_window">
<field name="name">Nutzungs-Dashboard</field>
<field name="res_model">open_workshop.usage.stats</field>
<field name="view_mode">kanban,graph,pivot</field>
<field name="view_id" ref="view_usage_dashboard"/>
</record>
<!-- Submenu unter Point of Sale > Berichtswesen -->
<menuitem id="menu_open_workshop_reports"
name="Open Workshop"
parent="point_of_sale.menu_point_rep"
sequence="100"/>
<!-- Dashboard Menu -->
<menuitem id="menu_usage_dashboard"
name="Nutzungs-Dashboard"
parent="menu_open_workshop_reports"
action="action_usage_dashboard"
sequence="1"/>
</odoo>

View File

@ -0,0 +1,123 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Tree View -->
<record id="view_usage_stats_tree" model="ir.ui.view">
<field name="name">open_workshop.usage.stats.tree</field>
<field name="model">open_workshop.usage.stats</field>
<field name="arch" type="xml">
<list string="Nutzungsstatistiken" create="false">
<field name="period_name"/>
<field name="period_start"/>
<field name="period_end"/>
<field name="total_sessions" string="Gesamt Nutzungen"/>
<field name="avg_per_day" string="⌀ pro Tag"/>
<field name="unique_users" string="Einz. Nutzer"/>
<field name="total_revenue" widget="monetary"/>
</list>
</field>
</record>
<!-- Form View -->
<record id="view_usage_stats_form" model="ir.ui.view">
<field name="name">open_workshop.usage.stats.form</field>
<field name="model">open_workshop.usage.stats</field>
<field name="arch" type="xml">
<form string="Nutzungsstatistik" create="false">
<sheet>
<div class="oe_title">
<h1><field name="period_name"/></h1>
</div>
<group>
<group string="Zeitraum">
<field name="period_type"/>
<field name="period_start"/>
<field name="period_end"/>
</group>
<group string="Statistiken">
<field name="total_sessions"/>
<field name="avg_per_day"/>
<field name="unique_users"/>
<field name="total_revenue"/>
</group>
</group>
<notebook>
<page string="POS Sessions">
<field name="session_ids" readonly="1">
<list>
<field name="name"/>
<field name="start_at"/>
<field name="stop_at"/>
<field name="user_id"/>
<field name="total_payments_amount" widget="monetary"/>
</list>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- Pivot View -->
<record id="view_usage_stats_pivot" model="ir.ui.view">
<field name="name">open_workshop.usage.stats.pivot</field>
<field name="model">open_workshop.usage.stats</field>
<field name="arch" type="xml">
<pivot string="Nutzungsstatistiken" sample="1">
<field name="period_start" type="row" interval="month"/>
<field name="period_type" type="col"/>
<field name="total_sessions" type="measure"/>
<field name="avg_per_day" type="measure"/>
<field name="total_revenue" type="measure"/>
</pivot>
</field>
</record>
<!-- Graph View - Bar Chart -->
<record id="view_usage_stats_graph_bar" model="ir.ui.view">
<field name="name">open_workshop.usage.stats.graph.bar</field>
<field name="model">open_workshop.usage.stats</field>
<field name="arch" type="xml">
<graph string="Nutzungen pro Monat" type="bar" sample="1" stacked="0">
<field name="period_start" interval="month"/>
<field name="total_sessions" type="measure"/>
</graph>
</field>
</record>
<!-- Graph View - Line Chart (Historie) -->
<record id="view_usage_stats_graph_line" model="ir.ui.view">
<field name="name">open_workshop.usage.stats.graph.line</field>
<field name="model">open_workshop.usage.stats</field>
<field name="arch" type="xml">
<graph string="Nutzungen (Historie)" type="line" sample="1">
<field name="period_start" interval="month"/>
<field name="total_sessions" type="measure"/>
<field name="avg_per_day" type="measure"/>
</graph>
</field>
</record>
<!-- Action -->
<record id="action_usage_stats" model="ir.actions.act_window">
<field name="name">Nutzungsstatistiken</field>
<field name="res_model">open_workshop.usage.stats</field>
<field name="view_mode">graph,pivot,list,form</field>
<field name="context">{'group_by': ['period_start:month']}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Keine Statistiken vorhanden
</p>
<p>
Klicken Sie auf "Statistiken generieren" um monatliche Nutzungsstatistiken zu erstellen.
</p>
</field>
</record>
<!-- Menu -->
<menuitem id="menu_usage_stats"
name="Nutzungsstatistiken"
parent="menu_open_workshop_reports"
action="action_usage_stats"
sequence="10"/>
</odoo>