add spreadsheet_dashboard_pos_cooperative & pos_dashboard_cooperative

This commit is contained in:
Luis 2026-06-05 19:55:00 +02:00
parent 86101218e9
commit 5247938b86
34 changed files with 7974 additions and 0 deletions

View file

@ -0,0 +1,29 @@
# POS Dashboard Cooperative
Real-time BI dashboards for cooperative supermarkets, built on top of
Point of Sale data.
This module extends `report.pos.order` with three new dimensions:
| Field | Description |
|---|---|
| `is_member_at_date` | True if the customer held a `share.line` whose `effective_date` was on or before the order date. |
| `categ_root_id` | Product family (root of `product.category` parent_path). |
| `categ_parent_id` | Product subfamily (parent of the leaf category). |
| `date_day` | Order date truncated to day. |
| `hour_of_day` | Hour of the order (`EXTRACT(HOUR FROM date_order)`). |
| `day_of_week` | Day of week, 1 = Monday … 7 = Sunday (ISO). |
Pre-configured menus under *Point of Sale → Reporting → Cooperative
Dashboards* cover overview, members vs. non-members, categories, top
products, and time distributions. Four tiles surface the headline KPIs
on the `web_dashboard_tile` board.
For spreadsheet dashboards published in Odoo's *Dashboards* menu, install
the companion module `spreadsheet_dashboard_pos_cooperative`.
See `readme/` for the OCA-style structured documentation.
## License
AGPL-3.0 or later.

View file

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

View file

@ -0,0 +1,27 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{
"name": "POS Dashboard Cooperative",
"version": "16.0.1.0.0",
"summary": "Real-time BI dashboards for cooperative supermarkets (POS)",
"license": "AGPL-3",
"author": "Criptomart, Odoo Community Association (OCA)",
"website": "https://github.com/OCA/pos",
"category": "Point of Sale",
"depends": [
"point_of_sale",
"cooperator",
"pos_margin",
"web_dashboard_tile",
"web_pivot_computed_measure",
],
"data": [
"report/pos_order_report_views.xml",
"views/pos_order_report_member_views.xml",
"views/pos_order_report_category_views.xml",
"views/pos_order_report_time_views.xml",
"views/dashboard_tiles.xml",
"views/menus.xml",
],
"installable": True,
}

View file

@ -0,0 +1,151 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * pos_dashboard_cooperative
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 16.0\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-06-05 12:00+0000\n"
"Last-Translator: Criptomart\n"
"Language-Team: Spanish\n"
"Language: es\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. module: pos_dashboard_cooperative
#: model:ir.actions.act_window,name:pos_dashboard_cooperative.action_pos_dashboard_by_day_of_week
msgid "Sales by Day of Week"
msgstr "Ventas por día de la semana"
#. module: pos_dashboard_cooperative
#: model:ir.actions.act_window,name:pos_dashboard_cooperative.action_pos_dashboard_by_hour
msgid "Sales by Hour of Day"
msgstr "Ventas por hora del día"
#. module: pos_dashboard_cooperative
#: model:ir.actions.act_window,name:pos_dashboard_cooperative.action_pos_dashboard_categories
msgid "Sales by Category"
msgstr "Ventas por categoría"
#. module: pos_dashboard_cooperative
#: model:ir.actions.act_window,name:pos_dashboard_cooperative.action_pos_dashboard_members
msgid "Members vs Non-members"
msgstr "Socios vs no socios"
#. module: pos_dashboard_cooperative
#: model:ir.actions.act_window,name:pos_dashboard_cooperative.action_pos_dashboard_overview
msgid "Cooperative POS Overview"
msgstr "Vista general TPV cooperativa"
#. module: pos_dashboard_cooperative
#: model:ir.actions.act_window,name:pos_dashboard_cooperative.action_pos_dashboard_top_members
msgid "Top Member Buyers"
msgstr "Top socias compradoras"
#. module: pos_dashboard_cooperative
#: model:ir.actions.act_window,name:pos_dashboard_cooperative.action_pos_dashboard_top_products
msgid "Top Products"
msgstr "Top productos"
#. module: pos_dashboard_cooperative
#: model:ir.model,name:pos_dashboard_cooperative.model_report_pos_order
msgid "Point of Sale Orders Report"
msgstr "Informe de pedidos del TPV"
#. module: pos_dashboard_cooperative
#: model:ir.model.fields,field_description:pos_dashboard_cooperative.field_report_pos_order__categ_parent_id
msgid "Product Subfamily (parent)"
msgstr "Subfamilia de producto (padre)"
#. module: pos_dashboard_cooperative
#: model:ir.model.fields,field_description:pos_dashboard_cooperative.field_report_pos_order__categ_root_id
msgid "Product Family (root)"
msgstr "Familia de producto (raíz)"
#. module: pos_dashboard_cooperative
#: model:ir.model.fields,field_description:pos_dashboard_cooperative.field_report_pos_order__date_day
msgid "Order Day"
msgstr "Día del pedido"
#. module: pos_dashboard_cooperative
#: model:ir.model.fields,field_description:pos_dashboard_cooperative.field_report_pos_order__day_of_week
msgid "Day of Week (1=Mon, 7=Sun)"
msgstr "Día de la semana (1=lun, 7=dom)"
#. module: pos_dashboard_cooperative
#: model:ir.model.fields,field_description:pos_dashboard_cooperative.field_report_pos_order__hour_of_day
msgid "Hour of Day"
msgstr "Hora del día"
#. module: pos_dashboard_cooperative
#: model:ir.model.fields,field_description:pos_dashboard_cooperative.field_report_pos_order__is_member_at_date
msgid "Member at Order Date"
msgstr "Socio en la fecha del pedido"
#. module: pos_dashboard_cooperative
#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_by_day
msgid "By Day of Week"
msgstr "Por día de la semana"
#. module: pos_dashboard_cooperative
#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_by_hour
msgid "By Hour of Day"
msgstr "Por hora del día"
#. module: pos_dashboard_cooperative
#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_categories
msgid "By Category"
msgstr "Por categoría"
#. module: pos_dashboard_cooperative
#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_cooperative
msgid "Cooperative Dashboards"
msgstr "Tableros de la cooperativa"
#. module: pos_dashboard_cooperative
#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_members
msgid "Members vs Non-members"
msgstr "Socios vs no socios"
#. module: pos_dashboard_cooperative
#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_overview
msgid "Overview"
msgstr "Vista general"
#. module: pos_dashboard_cooperative
#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_top_members
msgid "Top Members"
msgstr "Top socias"
#. module: pos_dashboard_cooperative
#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_top_products
msgid "Top Products"
msgstr "Top productos"
#. module: pos_dashboard_cooperative
#: model:tile.category,name:pos_dashboard_cooperative.tile_category_cooperative
msgid "Cooperative POS"
msgstr "TPV cooperativa"
#. module: pos_dashboard_cooperative
#: model:tile.tile,name:pos_dashboard_cooperative.tile_pos_margin_last_30_days
msgid "Margin last 30 days (€)"
msgstr "Margen últimos 30 días (€)"
#. module: pos_dashboard_cooperative
#: model:tile.tile,name:pos_dashboard_cooperative.tile_pos_sales_last_30_days
msgid "Sales last 30 days (€)"
msgstr "Ventas últimos 30 días (€)"
#. module: pos_dashboard_cooperative
#: model:tile.tile,name:pos_dashboard_cooperative.tile_pos_sales_members_last_30_days
msgid "Members sales last 30 days (€)"
msgstr "Ventas a socias últimos 30 días (€)"
#. module: pos_dashboard_cooperative
#: model:tile.tile,name:pos_dashboard_cooperative.tile_pos_sales_non_members_last_30_days
msgid "Non-members sales last 30 days (€)"
msgstr "Ventas a no socias últimos 30 días (€)"

View file

@ -0,0 +1,149 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * pos_dashboard_cooperative
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 16.0\n"
"Report-Msgid-Bugs-To: \n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: pos_dashboard_cooperative
#: model:ir.actions.act_window,name:pos_dashboard_cooperative.action_pos_dashboard_by_day_of_week
msgid "Sales by Day of Week"
msgstr ""
#. module: pos_dashboard_cooperative
#: model:ir.actions.act_window,name:pos_dashboard_cooperative.action_pos_dashboard_by_hour
msgid "Sales by Hour of Day"
msgstr ""
#. module: pos_dashboard_cooperative
#: model:ir.actions.act_window,name:pos_dashboard_cooperative.action_pos_dashboard_categories
msgid "Sales by Category"
msgstr ""
#. module: pos_dashboard_cooperative
#: model:ir.actions.act_window,name:pos_dashboard_cooperative.action_pos_dashboard_members
msgid "Members vs Non-members"
msgstr ""
#. module: pos_dashboard_cooperative
#: model:ir.actions.act_window,name:pos_dashboard_cooperative.action_pos_dashboard_overview
msgid "Cooperative POS Overview"
msgstr ""
#. module: pos_dashboard_cooperative
#: model:ir.actions.act_window,name:pos_dashboard_cooperative.action_pos_dashboard_top_members
msgid "Top Member Buyers"
msgstr ""
#. module: pos_dashboard_cooperative
#: model:ir.actions.act_window,name:pos_dashboard_cooperative.action_pos_dashboard_top_products
msgid "Top Products"
msgstr ""
#. module: pos_dashboard_cooperative
#: model:ir.model,name:pos_dashboard_cooperative.model_report_pos_order
msgid "Point of Sale Orders Report"
msgstr ""
#. module: pos_dashboard_cooperative
#: model:ir.model.fields,field_description:pos_dashboard_cooperative.field_report_pos_order__categ_parent_id
msgid "Product Subfamily (parent)"
msgstr ""
#. module: pos_dashboard_cooperative
#: model:ir.model.fields,field_description:pos_dashboard_cooperative.field_report_pos_order__categ_root_id
msgid "Product Family (root)"
msgstr ""
#. module: pos_dashboard_cooperative
#: model:ir.model.fields,field_description:pos_dashboard_cooperative.field_report_pos_order__date_day
msgid "Order Day"
msgstr ""
#. module: pos_dashboard_cooperative
#: model:ir.model.fields,field_description:pos_dashboard_cooperative.field_report_pos_order__day_of_week
msgid "Day of Week (1=Mon, 7=Sun)"
msgstr ""
#. module: pos_dashboard_cooperative
#: model:ir.model.fields,field_description:pos_dashboard_cooperative.field_report_pos_order__hour_of_day
msgid "Hour of Day"
msgstr ""
#. module: pos_dashboard_cooperative
#: model:ir.model.fields,field_description:pos_dashboard_cooperative.field_report_pos_order__is_member_at_date
msgid "Member at Order Date"
msgstr ""
#. module: pos_dashboard_cooperative
#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_by_day
msgid "By Day of Week"
msgstr ""
#. module: pos_dashboard_cooperative
#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_by_hour
msgid "By Hour of Day"
msgstr ""
#. module: pos_dashboard_cooperative
#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_categories
msgid "By Category"
msgstr ""
#. module: pos_dashboard_cooperative
#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_cooperative
msgid "Cooperative Dashboards"
msgstr ""
#. module: pos_dashboard_cooperative
#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_members
msgid "Members vs Non-members"
msgstr ""
#. module: pos_dashboard_cooperative
#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_overview
msgid "Overview"
msgstr ""
#. module: pos_dashboard_cooperative
#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_top_members
msgid "Top Members"
msgstr ""
#. module: pos_dashboard_cooperative
#: model:ir.ui.menu,name:pos_dashboard_cooperative.menu_pos_dashboard_top_products
msgid "Top Products"
msgstr ""
#. module: pos_dashboard_cooperative
#: model:tile.category,name:pos_dashboard_cooperative.tile_category_cooperative
msgid "Cooperative POS"
msgstr ""
#. module: pos_dashboard_cooperative
#: model:tile.tile,name:pos_dashboard_cooperative.tile_pos_margin_last_30_days
msgid "Margin last 30 days (€)"
msgstr ""
#. module: pos_dashboard_cooperative
#: model:tile.tile,name:pos_dashboard_cooperative.tile_pos_sales_last_30_days
msgid "Sales last 30 days (€)"
msgstr ""
#. module: pos_dashboard_cooperative
#: model:tile.tile,name:pos_dashboard_cooperative.tile_pos_sales_members_last_30_days
msgid "Members sales last 30 days (€)"
msgstr ""
#. module: pos_dashboard_cooperative
#: model:tile.tile,name:pos_dashboard_cooperative.tile_pos_sales_non_members_last_30_days
msgid "Non-members sales last 30 days (€)"
msgstr ""

View file

@ -0,0 +1,7 @@
No specific configuration is required after installation. The module
auto-installs ``pos_margin``, ``web_dashboard_tile`` and
``web_pivot_computed_measure`` if they are not already present.
To control who sees the new dashboards, adjust the standard POS
security groups (``point_of_sale.group_pos_manager``) — the new analyses
inherit the access rules already in place for ``report.pos.order``.

View file

@ -0,0 +1 @@
* Criptomart <https://criptomart.net>

View file

@ -0,0 +1,24 @@
Real-time Business Intelligence dashboards for cooperative supermarkets
built on top of Point of Sale data. Extends the standard
``report.pos.order`` view to expose new dimensions that the standard POS
analysis lacks:
* **Cooperative member dimension** — ``is_member_at_date`` flags each
ticket line according to the customer's share status *at the order
date*, so historic comparisons (members vs. non-members) reflect the
reality of the moment.
* **Category hierarchy** — ``categ_root_id`` (family) and
``categ_parent_id`` (subfamily) derived from
``product.category.parent_path``, ready to drive drilldowns.
* **Time dimensions** — ``date_day``, ``hour_of_day``, ``day_of_week``
for daily/weekly/hourly distributions.
Pre-configured menu entries under *Point of Sale → Reporting →
Cooperative Dashboards* cover overview, members vs. non-members,
categories, top products, day-of-week and hour-of-day analyses. Four
dashboard tiles surface the most important KPIs on the
``web_dashboard_tile`` board.
The companion module ``spreadsheet_dashboard_pos_cooperative`` publishes
spreadsheet dashboards in Odoo's *Dashboards* menu that consume the
same data.

View file

@ -0,0 +1,12 @@
* Materialize the SQL view into a regular table populated by a nightly
cron, in case real-time aggregation becomes too slow on very large
POS datasets.
* Refine the ``is_member_at_date`` calculation by consuming
``subscription.register`` to subtract sell-back and transfer
operations from the share balance. The current implementation simply
checks for the existence of a ``share.line`` dated on or before the
order date, which over-reports memberships when shares are returned.
* Add multi-company test coverage once a clean multi-company test
fixture is available.
* Add Galician, Catalan, French, Dutch and Croatian translations
(currently shipped with Spanish only).

View file

@ -0,0 +1,39 @@
Open *Point of Sale → Reporting → Cooperative Dashboards* and pick one
of the predefined analyses:
* **Overview** — sales, margin and order count grouped by month.
* **Members vs Non-members** — same axes split by cooperative
membership at the order date.
* **Top Members** — sales aggregated per cooperative member, sort by
total to obtain the top buyers.
* **By Category** — sales by product family (root category); drill down
to subfamily and product from the *Group By* menu.
* **Top Products** — sales per product.
* **By Day of Week / By Hour of Day** — temporal distributions.
In every view, additional dimensions are available from the *Filters*
and *Group By* menus: ``Members``, ``Non members``, ``Member``,
``Product Family``, ``Product Subfamily``, ``Day of Week``,
``Hour of Day``.
Time dimensions
===============
* ``day_of_week`` is the ISO day number (``EXTRACT(ISODOW FROM
date_order)``): **1 = Monday, 2 = Tuesday, 3 = Wednesday, 4 =
Thursday, 5 = Friday, 6 = Saturday, 7 = Sunday**.
* ``hour_of_day`` is the integer hour in the order's timezone
(``EXTRACT(HOUR FROM date_order)``), 0..23.
These are deliberately exposed as integers rather than translated
labels so they aggregate cleanly in pivots, charts and spreadsheet
formulas. Map the numbers to weekday names in the consumer (for
example, in the o-spreadsheet dashboards) if a localized label is
needed.
Pivot views support the ratios defined by ``web_pivot_computed_measure``
(e.g. average ticket = total sales / order count). Click *Insert
Computed Measure* in the pivot's *Measures* menu.
For curated dashboards rendered in Odoo's *Dashboards* menu, install
the companion module ``spreadsheet_dashboard_pos_cooperative``.

View file

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

View file

@ -0,0 +1,130 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import fields, models
class PosOrderReport(models.Model):
_inherit = "report.pos.order"
# --- Time dimensions ----------------------------------------------------
date_day = fields.Date(
string="Order Day",
readonly=True,
)
hour_of_day = fields.Integer(
string="Hour of Day",
readonly=True,
group_operator=None,
)
day_of_week = fields.Integer(
string="Day of Week (1=Mon, 7=Sun)",
readonly=True,
group_operator=None,
)
# --- Category hierarchy -------------------------------------------------
# Family = root category, subfamily = direct parent of categ_id.
# Computed from product.category.parent_path (materialized path
# "1/3/7/"), which is maintained automatically by Odoo (_parent_store).
categ_root_id = fields.Many2one(
"product.category",
string="Product Family (root)",
readonly=True,
)
categ_root_id_int = fields.Integer(
string="Product Family ID (root)",
readonly=True,
group_operator=None,
)
categ_parent_id = fields.Many2one(
"product.category",
string="Product Subfamily (parent)",
readonly=True,
)
categ_parent_id_int = fields.Integer(
string="Product Subfamily ID (parent)",
readonly=True,
group_operator=None,
)
product_categ_id_int = fields.Integer(
string="Product Category ID (leaf)",
readonly=True,
group_operator=None,
)
product_id_int = fields.Integer(
string="Product ID",
readonly=True,
group_operator=None,
)
# --- Cooperative member dimension --------------------------------------
# True when the customer of the ticket held at least one share line whose
# effective_date was on or before the order date, in the same company.
#
# NOTE: this is an approximation. cooperator does not store an end date
# on share.line; share buy-backs and transfers are recorded only in
# subscription.register. A future version may refine the calculation by
# consuming subscription.register to substract sell_back/transfer
# operations, at the cost of a more expensive subquery.
is_member_at_date = fields.Boolean(
string="Member at Order Date",
readonly=True,
)
def _select(self):
return (
super()._select()
+ """,
s.date_order::date AS date_day,
EXTRACT(HOUR FROM (s.date_order AT TIME ZONE 'UTC') AT TIME ZONE 'Europe/Madrid')::int AS hour_of_day,
EXTRACT(ISODOW FROM s.date_order)::int AS day_of_week,
cat_root.id AS categ_root_id,
cat_root.id AS categ_root_id_int,
pt_categ.parent_id AS categ_parent_id,
pt_categ.parent_id AS categ_parent_id_int,
pt.categ_id AS product_categ_id_int,
pt.id AS product_id_int,
CASE WHEN EXISTS (
SELECT 1 FROM share_line sl
WHERE sl.partner_id = s.partner_id
AND sl.company_id = s.company_id
AND sl.effective_date IS NOT NULL
AND sl.effective_date <= s.date_order::date
) THEN TRUE ELSE FALSE END AS is_member_at_date
"""
)
def _from(self):
# Extra joins to resolve:
# - pt_categ: direct category of the product (used for parent_id and
# parent_path lookup).
# - cat_root: root category, found by taking the first id in
# parent_path. parent_path looks like "1/3/7/".
return (
super()._from()
+ """
LEFT JOIN product_category pt_categ ON pt_categ.id = pt.categ_id
LEFT JOIN product_category cat_root
ON cat_root.id = NULLIF(
split_part(pt_categ.parent_path, '/', 1), ''
)::int
"""
)
def _group_by(self):
# Append non-aggregated expressions added in _select() that are not
# already in the parent GROUP BY. s.date_order is already there
# (point_of_sale.report.pos_order_report._group_by), so the time
# dimensions derived from it (date_day, hour_of_day, day_of_week)
# are functionally dependent and don't need to be listed.
# is_member_at_date is a scalar subquery; not needed either.
return (
super()._group_by()
+ """,
cat_root.id,
pt_categ.parent_id,
pt.categ_id,
pt.id
"""
)

View file

@ -0,0 +1,125 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2026 Criptomart - License AGPL-3 -->
<odoo>
<!--
Extend the core search view to expose:
- Member dimension filters (members / non-members)
- Category hierarchy group-bys (family, subfamily)
- Time dimension group-bys (day of week, hour of day)
- Filters by company (since this module is multi-company aware)
-->
<record id="report_pos_order_view_search" model="ir.ui.view">
<field name="name">report.pos.order.search.cooperative</field>
<field name="model">report.pos.order</field>
<field name="inherit_id" ref="point_of_sale.view_report_pos_order_search"/>
<field name="arch" type="xml">
<xpath expr="//filter[@name='not_invoiced']" position="after">
<separator/>
<filter
string="Members"
name="members"
domain="[('is_member_at_date', '=', True)]"
/>
<filter
string="Non members"
name="non_members"
domain="[('is_member_at_date', '=', False)]"
/>
</xpath>
<xpath expr="//group" position="inside">
<separator/>
<filter
string="Member"
name="group_member"
context="{'group_by': 'is_member_at_date'}"
/>
<filter
string="Product Family"
name="group_categ_root"
context="{'group_by': 'categ_root_id'}"
/>
<filter
string="Product Subfamily"
name="group_categ_parent"
context="{'group_by': 'categ_parent_id'}"
/>
<separator/>
<filter
string="Day of Week"
name="group_day_of_week"
context="{'group_by': 'day_of_week'}"
/>
<filter
string="Hour of Day"
name="group_hour_of_day"
context="{'group_by': 'hour_of_day'}"
/>
</xpath>
</field>
</record>
<!--
Extend the core pivot view to include the most useful measures for
a cooperative supermarket dashboard: price total (sales), margin,
margin rate and order count. Members vs non-members can then be
compared by adding the "Member" group-by from the search bar.
-->
<record id="report_pos_order_view_pivot" model="ir.ui.view">
<field name="name">report.pos.order.pivot.cooperative</field>
<field name="model">report.pos.order</field>
<field name="inherit_id" ref="point_of_sale.view_report_pos_order_pivot"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='price_total']" position="after">
<field name="margin" type="measure"/>
<field name="margin_rate" type="measure"/>
</xpath>
</field>
</record>
<!--
Extend the core tree view to expose the new dimensions as optional
columns so users can toggle them without changing the default UI.
-->
<record id="report_pos_order_view_tree" model="ir.ui.view">
<field name="name">report.pos.order.tree.cooperative</field>
<field name="model">report.pos.order</field>
<field name="inherit_id" ref="point_of_sale.report_pos_order_view_tree"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='product_categ_id']" position="after">
<field name="categ_root_id" optional="hide"/>
<field name="categ_parent_id" optional="hide"/>
<field name="is_member_at_date" optional="show"/>
<field name="hour_of_day" optional="hide"/>
<field name="day_of_week" optional="hide"/>
</xpath>
</field>
</record>
<!--
Cooperative-flavoured action over report.pos.order. Defaults to a
graph view grouped by month, with the most relevant measures
pre-selected. Pivot + tree are also available.
-->
<record id="action_pos_dashboard_overview" model="ir.actions.act_window">
<field name="name">Cooperative POS Overview</field>
<field name="res_model">report.pos.order</field>
<field name="view_mode">graph,pivot,tree</field>
<field name="search_view_id"
ref="point_of_sale.view_report_pos_order_search"/>
<field name="context">{
'search_default_order_month': 1,
'group_by_no_leaf': 1,
'group_by': [],
}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No data yet!
</p><p>
Sell some products at the Point of Sale to populate this
cooperative dashboard.
</p>
</field>
</record>
</odoo>

View file

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

View file

@ -0,0 +1,215 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from datetime import date, timedelta
import odoo
from odoo.tests.common import TransactionCase
@odoo.tests.tagged("post_install", "-at_install")
class TestPosDashboardCooperative(TransactionCase):
"""Validate the extra dimensions added by pos_dashboard_cooperative on
top of point_of_sale.report.pos.order:
- time dimensions (date_day, hour_of_day, day_of_week)
- product category hierarchy (categ_root_id, categ_parent_id)
- cooperative member historic flag (is_member_at_date)
These tests use a minimal hand-built pos.session to stay independent
from any existing data in the database and from the heavy
TestPoSCommon setup machinery, which requires a clean accounting
setup.
"""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.company = cls.env.ref("base.main_company")
# Two-level category hierarchy: family -> subfamily -> leaf
cls.family_root = cls.env["product.category"].create(
{"name": "DASHBOARD-FAMILY-ROOT"}
)
cls.subfamily = cls.env["product.category"].create(
{"name": "DASHBOARD-SUBFAMILY", "parent_id": cls.family_root.id}
)
cls.categ_leaf = cls.env["product.category"].create(
{"name": "DASHBOARD-LEAF", "parent_id": cls.subfamily.id}
)
# Product sold at the POS in every test.
cls.product = cls.env["product.product"].create(
{
"name": "DASHBOARD-PRODUCT",
"type": "consu",
"available_in_pos": True,
"list_price": 10.0,
"standard_price": 6.0,
"categ_id": cls.categ_leaf.id,
}
)
# Share product needed by cooperator.share.line.
cls.share_product = cls.env["product.product"].create(
{
"name": "DASHBOARD-SHARE",
"is_share": True,
"type": "service",
"list_price": 25.0,
}
)
# Two partners: one cooperative member (share dated in the past)
# and one non-member.
cls.member_partner = cls.env["res.partner"].create(
{"name": "DASHBOARD-MEMBER"}
)
cls.non_member_partner = cls.env["res.partner"].create(
{"name": "DASHBOARD-NON-MEMBER"}
)
cls.env["share.line"].create(
{
"partner_id": cls.member_partner.id,
"share_product_id": cls.share_product.id,
"share_number": 1,
"share_unit_price": 25.0,
"effective_date": date.today() - timedelta(days=365),
"company_id": cls.company.id,
}
)
# Reuse an existing POS config to avoid bootstrapping payment
# methods and journals from scratch. Any installed config is
# acceptable since we never go through the POS UI.
cls.pos_config = cls.env["pos.config"].search(
[("company_id", "=", cls.company.id)], limit=1
)
assert cls.pos_config, "A pos.config in the main company is required."
if not cls.pos_config.current_session_id:
cls.pos_config.open_ui()
cls.pos_session = cls.pos_config.current_session_id
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _create_pos_order(self, partner):
return self.env["pos.order"].create(
{
"session_id": self.pos_session.id,
"partner_id": partner.id,
"company_id": self.company.id,
"lines": [
(
0,
0,
{
"name": "DASHBOARD-OL",
"product_id": self.product.id,
"price_unit": 10.0,
"discount": 0,
"qty": 1.0,
"price_subtotal": 10.0,
"price_subtotal_incl": 10.0,
"total_cost": 6.0,
},
)
],
"amount_total": 10.0,
"amount_tax": 0.0,
"amount_paid": 0.0,
"amount_return": 0.0,
}
)
def _reports_for(self, partner):
return (
self.env["report.pos.order"]
.sudo()
.search(
[
("product_id", "=", self.product.id),
("partner_id", "=", partner.id),
],
order="id",
)
)
# ------------------------------------------------------------------
# Tests
# ------------------------------------------------------------------
def test_member_flag_for_member_partner(self):
"""Partner with a share line predating the order is flagged as
member."""
self._create_pos_order(self.member_partner)
reports = self._reports_for(self.member_partner)
self.assertTrue(reports)
self.assertTrue(reports[0].is_member_at_date)
def test_member_flag_for_non_member_partner(self):
"""Partner without any share line is not flagged as member."""
self._create_pos_order(self.non_member_partner)
reports = self._reports_for(self.non_member_partner)
self.assertTrue(reports)
self.assertFalse(reports[0].is_member_at_date)
def test_member_flag_is_historic(self):
"""Share line dated AFTER the order date does not mark the partner
as member for that order."""
future_member = self.env["res.partner"].create(
{"name": "DASHBOARD-FUTURE-MEMBER"}
)
self._create_pos_order(future_member)
self.env["share.line"].create(
{
"partner_id": future_member.id,
"share_product_id": self.share_product.id,
"share_number": 1,
"share_unit_price": 25.0,
"effective_date": date.today() + timedelta(days=30),
"company_id": self.company.id,
}
)
reports = self._reports_for(future_member)
self.assertTrue(reports)
self.assertFalse(
reports[0].is_member_at_date,
"Member flag must reflect the situation at order date.",
)
def test_category_hierarchy_is_resolved(self):
"""categ_root_id and categ_parent_id reflect the product category
ancestors derived from parent_path."""
self._create_pos_order(self.member_partner)
reports = self._reports_for(self.member_partner)
self.assertTrue(reports)
report = reports[0]
self.assertEqual(report.categ_root_id, self.family_root)
self.assertEqual(report.categ_parent_id, self.subfamily)
def test_time_dimensions_are_populated(self):
"""date_day matches order date; hour_of_day / day_of_week are in
valid ranges."""
self._create_pos_order(self.member_partner)
reports = self._reports_for(self.member_partner)
self.assertTrue(reports)
report = reports[0]
self.assertIsNotNone(report.date_day)
self.assertGreaterEqual(report.hour_of_day, 0)
self.assertLess(report.hour_of_day, 24)
self.assertGreaterEqual(report.day_of_week, 1)
self.assertLessEqual(report.day_of_week, 7)
def test_read_group_by_member_flag(self):
"""Aggregations grouped by the member flag return one row per
member status when both kinds of partners have purchased."""
self._create_pos_order(self.member_partner)
self._create_pos_order(self.non_member_partner)
results = self.env["report.pos.order"].read_group(
[("product_id", "=", self.product.id)],
["price_total", "is_member_at_date"],
["is_member_at_date"],
)
statuses = {r["is_member_at_date"] for r in results}
self.assertIn(True, statuses)
self.assertIn(False, statuses)

View file

@ -0,0 +1,104 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2026 Criptomart - License AGPL-3 -->
<odoo noupdate="1">
<!--
Tile categories shown in the dashboard.
Two categories: a "members" one (members only) and a generic one
for global sales KPIs.
-->
<record id="tile_category_cooperative" model="tile.category">
<field name="name">Cooperative POS</field>
<field name="sequence">100</field>
<field name="active" eval="True"/>
</record>
<!--
Tile: Total sales (excluding refunds).
Sums price_total over the last 30 days. Uses paid/done/invoiced
states to exclude drafts and cancellations.
NOTE on accuracy: tile.tile counts/aggregates records of the
target model. report.pos.order is at line granularity, so:
- SUM(price_total) is correctly the total amount sold.
- COUNT() would count *lines*, not tickets. To count tickets we
would need DISTINCT order_id which tile.tile does not support.
Use the pivot views for ticket-accurate metrics; tiles are
intentionally limited to monetary KPIs that aggregate by simple
SUM over lines.
-->
<record id="tile_pos_sales_last_30_days" model="tile.tile">
<field name="name">Sales last 30 days (€)</field>
<field name="sequence">10</field>
<field name="category_id" ref="tile_category_cooperative"/>
<field name="model_id" ref="point_of_sale.model_report_pos_order"/>
<field name="action_id" ref="action_pos_dashboard_overview"/>
<field name="primary_function">sum</field>
<field name="primary_field_id"
ref="point_of_sale.field_report_pos_order__price_total"/>
<field name="primary_format">{:,.2f} €</field>
<field
name="domain"
>[('state', 'in', ['paid', 'done', 'invoiced']),
('date', '&gt;=', (context_today - relativedelta(days=30)).strftime('%Y-%m-%d'))]</field>
<field name="background_color">#2E7D32</field>
<field name="font_color">#FFFFFF</field>
</record>
<record id="tile_pos_margin_last_30_days" model="tile.tile">
<field name="name">Margin last 30 days (€)</field>
<field name="sequence">20</field>
<field name="category_id" ref="tile_category_cooperative"/>
<field name="model_id" ref="point_of_sale.model_report_pos_order"/>
<field name="action_id" ref="action_pos_dashboard_overview"/>
<field name="primary_function">sum</field>
<field name="primary_field_id"
ref="point_of_sale.field_report_pos_order__margin"/>
<field name="primary_format">{:,.2f} €</field>
<field
name="domain"
>[('state', 'in', ['paid', 'done', 'invoiced']),
('date', '&gt;=', (context_today - relativedelta(days=30)).strftime('%Y-%m-%d'))]</field>
<field name="background_color">#1565C0</field>
<field name="font_color">#FFFFFF</field>
</record>
<record id="tile_pos_sales_members_last_30_days" model="tile.tile">
<field name="name">Members sales last 30 days (€)</field>
<field name="sequence">30</field>
<field name="category_id" ref="tile_category_cooperative"/>
<field name="model_id" ref="point_of_sale.model_report_pos_order"/>
<field name="action_id" ref="action_pos_dashboard_members"/>
<field name="primary_function">sum</field>
<field name="primary_field_id"
ref="point_of_sale.field_report_pos_order__price_total"/>
<field name="primary_format">{:,.2f} €</field>
<field
name="domain"
>[('state', 'in', ['paid', 'done', 'invoiced']),
('is_member_at_date', '=', True),
('date', '&gt;=', (context_today - relativedelta(days=30)).strftime('%Y-%m-%d'))]</field>
<field name="background_color">#EF6C00</field>
<field name="font_color">#FFFFFF</field>
</record>
<record id="tile_pos_sales_non_members_last_30_days" model="tile.tile">
<field name="name">Non-members sales last 30 days (€)</field>
<field name="sequence">40</field>
<field name="category_id" ref="tile_category_cooperative"/>
<field name="model_id" ref="point_of_sale.model_report_pos_order"/>
<field name="action_id" ref="action_pos_dashboard_members"/>
<field name="primary_function">sum</field>
<field name="primary_field_id"
ref="point_of_sale.field_report_pos_order__price_total"/>
<field name="primary_format">{:,.2f} €</field>
<field
name="domain"
>[('state', 'in', ['paid', 'done', 'invoiced']),
('is_member_at_date', '=', False),
('date', '&gt;=', (context_today - relativedelta(days=30)).strftime('%Y-%m-%d'))]</field>
<field name="background_color">#6A1B9A</field>
<field name="font_color">#FFFFFF</field>
</record>
</odoo>

View file

@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2026 Criptomart - License AGPL-3 -->
<odoo>
<menuitem
id="menu_pos_dashboard_cooperative"
name="Cooperative Dashboards"
parent="point_of_sale.menu_point_rep"
sequence="50"
/>
<menuitem
id="menu_pos_dashboard_overview"
name="Overview"
parent="menu_pos_dashboard_cooperative"
action="action_pos_dashboard_overview"
sequence="10"
/>
<menuitem
id="menu_pos_dashboard_members"
name="Members vs Non-members"
parent="menu_pos_dashboard_cooperative"
action="action_pos_dashboard_members"
sequence="20"
/>
<menuitem
id="menu_pos_dashboard_top_members"
name="Top Members"
parent="menu_pos_dashboard_cooperative"
action="action_pos_dashboard_top_members"
sequence="30"
/>
<menuitem
id="menu_pos_dashboard_categories"
name="By Category"
parent="menu_pos_dashboard_cooperative"
action="action_pos_dashboard_categories"
sequence="40"
/>
<menuitem
id="menu_pos_dashboard_top_products"
name="Top Products"
parent="menu_pos_dashboard_cooperative"
action="action_pos_dashboard_top_products"
sequence="50"
/>
<menuitem
id="menu_pos_dashboard_by_day"
name="By Day of Week"
parent="menu_pos_dashboard_cooperative"
action="action_pos_dashboard_by_day_of_week"
sequence="60"
/>
<menuitem
id="menu_pos_dashboard_by_hour"
name="By Hour of Day"
parent="menu_pos_dashboard_cooperative"
action="action_pos_dashboard_by_hour"
sequence="70"
/>
</odoo>

View file

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2026 Criptomart - License AGPL-3 -->
<odoo>
<!--
Category analysis: sales broken down by family (root), subfamily
(parent of leaf), and leaf category. Designed to answer the
"category traction" and "top families" questions from the BI brief.
-->
<record id="action_pos_dashboard_categories" model="ir.actions.act_window">
<field name="name">Sales by Category</field>
<field name="res_model">report.pos.order</field>
<field name="view_mode">pivot,graph,tree</field>
<field name="search_view_id"
ref="point_of_sale.view_report_pos_order_search"/>
<field name="context">{
'search_default_group_categ_root': 1,
'search_default_order_month': 1,
'group_by_no_leaf': 1,
}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No data yet!
</p><p>
Add Subfamily or Product group-bys to drill down. Useful
to detect tractor categories.
</p>
</field>
</record>
<record id="action_pos_dashboard_top_products" model="ir.actions.act_window">
<field name="name">Top Products</field>
<field name="res_model">report.pos.order</field>
<field name="view_mode">pivot,tree,graph</field>
<field name="search_view_id"
ref="point_of_sale.view_report_pos_order_search"/>
<field name="context">{
'group_by': ['product_id'],
'group_by_no_leaf': 1,
}</field>
</record>
</odoo>

View file

@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2026 Criptomart - License AGPL-3 -->
<odoo>
<!--
Members vs non-members comparison.
Opens the analysis pre-grouped by member flag and by month, with
sales / orders / margin as primary measures.
-->
<record id="action_pos_dashboard_members" model="ir.actions.act_window">
<field name="name">Members vs Non-members</field>
<field name="res_model">report.pos.order</field>
<field name="view_mode">pivot,graph,tree</field>
<field name="search_view_id"
ref="point_of_sale.view_report_pos_order_search"/>
<field name="context">{
'search_default_group_member': 1,
'search_default_order_month': 1,
'group_by_no_leaf': 1,
}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No data yet!
</p><p>
This view contrasts cooperative members and non-members on
the same axes (sales, ticket count, margin).
</p>
</field>
</record>
<!--
Detail per member: top buyers, per-partner KPIs.
Filters to members only and groups by partner.
-->
<record id="action_pos_dashboard_top_members" model="ir.actions.act_window">
<field name="name">Top Member Buyers</field>
<field name="res_model">report.pos.order</field>
<field name="view_mode">pivot,tree,graph</field>
<field name="search_view_id"
ref="point_of_sale.view_report_pos_order_search"/>
<field name="domain">[('is_member_at_date', '=', True)]</field>
<field name="context">{
'search_default_members': 1,
'group_by': ['partner_id'],
'group_by_no_leaf': 1,
}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No data yet!
</p><p>
Sales aggregated per cooperative member. Sort by total
price to obtain the top buyers.
</p>
</field>
</record>
</odoo>

View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2026 Criptomart - License AGPL-3 -->
<odoo>
<!--
Time-based analyses: distribution of sales across days of the week
and hours of the day, to size shifts and detect peaks.
-->
<record id="action_pos_dashboard_by_day_of_week" model="ir.actions.act_window">
<field name="name">Sales by Day of Week</field>
<field name="res_model">report.pos.order</field>
<field name="view_mode">graph,pivot,tree</field>
<field name="search_view_id"
ref="point_of_sale.view_report_pos_order_search"/>
<field name="context">{
'search_default_group_day_of_week': 1,
'group_by_no_leaf': 1,
}</field>
</record>
<record id="action_pos_dashboard_by_hour" model="ir.actions.act_window">
<field name="name">Sales by Hour of Day</field>
<field name="res_model">report.pos.order</field>
<field name="view_mode">graph,pivot,tree</field>
<field name="search_view_id"
ref="point_of_sale.view_report_pos_order_search"/>
<field name="context">{
'search_default_group_hour_of_day': 1,
'group_by_no_leaf': 1,
}</field>
</record>
</odoo>