first commit

This commit is contained in:
Suherdy Yacob 2026-03-21 13:34:06 +07:00
commit aa4a78f481
9 changed files with 345 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
*.py[cod]
*$py.class
__pycache__/
*.log
.vscode/

18
README.md Normal file
View File

@ -0,0 +1,18 @@
# POS KDS Tracker and Report
## Overview
Standard Odoo 19 deletes POS Preparation Display (KDS) records a day after completion, preventing historical analysis of preparation times. This module creates a persistent log of completed orders and order lines from the Kitchen Display System (KDS) to enable detailed historical reporting.
## Features
- Records the state of the Kitchen Display System or Preparation Display permanently.
- Generates reports showing the average time to complete an order.
- Generates reports showing the average time to complete certain products or product categories.
- Allows filtering and grouping globally for a POS Shop or individually per KDS.
## Installation
Install out-of-the-box like a standard Odoo module on an Odoo 19 environment possessing POS Enterprise (`pos_enterprise`).
## Usage
1. Open the Point of Sale and place orders to KDS.
2. Complete stages in the Preparation Display.
3. Go to **Point of Sale > Reporting > KDS Product Analysis** or **KDS Order Analysis** to view Pivot and Graph summaries.

1
__init__.py Normal file
View File

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

21
__manifest__.py Normal file
View File

@ -0,0 +1,21 @@
{
'name': 'POS KDS Tracker and Report',
'version': '1.0',
'category': 'Sales/Point of Sale',
'summary': 'Tracks Kitchen Display System completion times for analysis',
'description': """
This module tracks and persists the completion times of KDS orders and lines.
It enables detailed historical reporting on the average time to complete an order,
product, or product category across POS Shops and specific Kitchen Display Systems.
""",
'author': 'Your Name',
'depends': ['point_of_sale', 'pos_enterprise'],
'data': [
'security/ir.model.access.csv',
'views/pos_kds_report_views.xml',
],
'installable': True,
'application': False,
'auto_install': False,
'license': 'LGPL-3',
}

2
models/__init__.py Normal file
View File

@ -0,0 +1,2 @@
from . import pos_kds_report
from . import pos_prep_state

61
models/pos_kds_report.py Normal file
View File

@ -0,0 +1,61 @@
from odoo import fields, models, api
class PosKdsReportLine(models.Model):
_name = 'pos.kds.report.line'
_description = 'KDS Line Completion Report'
_order = 'completion_datetime desc'
pos_order_id = fields.Many2one('pos.order', string='Order', required=True, ondelete='cascade')
pos_order_line_id = fields.Many2one('pos.order.line', string='Order Line', required=True, ondelete='cascade')
product_id = fields.Many2one('product.product', string='Product', required=True)
pos_category_id = fields.Many2one('pos.category', string='POS Category', compute='_compute_pos_category', store=True)
pos_config_id = fields.Many2one('pos.config', string='POS Shop', compute='_compute_pos_config', store=True)
prep_display_id = fields.Many2one('pos.prep.display', string='Preparation Display', required=True)
preparation_time = fields.Integer('Preparation Time (s)', help="Seconds taken to prepare")
service_time = fields.Integer('Service Time (s)', help="Seconds taken to serve")
completion_time = fields.Integer('Completion Time (s)', help="Total seconds taken to complete (prep + service)")
completion_datetime = fields.Datetime('Completion Date', default=fields.Datetime.now)
@api.depends('product_id')
def _compute_pos_category(self):
for record in self:
record.pos_category_id = record.product_id.pos_categ_ids[:1] if record.product_id.pos_categ_ids else False
@api.depends('pos_order_id')
def _compute_pos_config(self):
for record in self:
record.pos_config_id = record.pos_order_id.session_id.config_id
def name_get(self):
result = []
for record in self:
name = f"{record.pos_order_id.name} - {record.product_id.name}"
result.append((record.id, name))
return result
class PosKdsReportOrder(models.Model):
_name = 'pos.kds.report.order'
_description = 'KDS Order Completion Report'
_order = 'completion_datetime desc'
pos_order_id = fields.Many2one('pos.order', string='Order', required=True, ondelete='cascade')
pos_config_id = fields.Many2one('pos.config', string='POS Shop', compute='_compute_pos_config', store=True)
prep_display_id = fields.Many2one('pos.prep.display', string='Preparation Display', required=True)
completion_time = fields.Integer('Completion Time (s)', help="Max completion time across all lines of the order on this display")
completion_datetime = fields.Datetime('Completion Date', default=fields.Datetime.now)
@api.depends('pos_order_id')
def _compute_pos_config(self):
for record in self:
record.pos_config_id = record.pos_order_id.session_id.config_id
def name_get(self):
result = []
for record in self:
name = f"{record.pos_order_id.name} ({record.prep_display_id.name})"
result.append((record.id, name))
return result

103
models/pos_prep_state.py Normal file
View File

@ -0,0 +1,103 @@
from odoo import models
class PosPreparationState(models.Model):
_inherit = 'pos.prep.state'
def _update_kds_report(self, pdis_state):
if not pdis_state.prep_line_id.pos_order_line_id:
return
order_line = pdis_state.prep_line_id.pos_order_line_id
prep_display = pdis_state.stage_id.prep_display_id
is_completed = False
if len(pdis_state.stage_id.prep_display_id.stage_ids) > 1:
if pdis_state.stage_id.is_stage_position(-1):
is_completed = True
elif pdis_state.stage_id.is_stage_position(-2) and not pdis_state.todo:
is_completed = True
else:
if pdis_state.stage_id.is_stage_position(0) and not pdis_state.todo:
is_completed = True
is_reset = False
if pdis_state.stage_id.is_stage_position(0) and pdis_state.todo:
is_reset = True
line_report = self.env['pos.kds.report.line'].search([
('pos_order_line_id', '=', order_line.id),
('prep_display_id', '=', prep_display.id)
], limit=1)
if is_completed:
prep_time = max(0, order_line.preparation_time)
svc_time = max(0, order_line.service_time)
comp_time = prep_time + svc_time
vals = {
'pos_order_id': order_line.order_id.id,
'pos_order_line_id': order_line.id,
'product_id': order_line.product_id.id,
'prep_display_id': prep_display.id,
'preparation_time': prep_time,
'service_time': svc_time,
'completion_time': comp_time,
}
if line_report:
line_report.write(vals)
else:
self.env['pos.kds.report.line'].create(vals)
# Update order-level report
order_report = self.env['pos.kds.report.order'].search([
('pos_order_id', '=', order_line.order_id.id),
('prep_display_id', '=', prep_display.id)
], limit=1)
# Re-calculate max completion time for the order on this KDS
all_line_reports = self.env['pos.kds.report.line'].search([
('pos_order_id', '=', order_line.order_id.id),
('prep_display_id', '=', prep_display.id)
])
max_comp_time = max(all_line_reports.mapped('completion_time')) if all_line_reports else comp_time
order_vals = {
'pos_order_id': order_line.order_id.id,
'prep_display_id': prep_display.id,
'completion_time': max_comp_time,
}
if order_report:
order_report.write(order_vals)
else:
self.env['pos.kds.report.order'].create(order_vals)
elif is_reset:
if line_report:
line_report.unlink()
# We don't necessarily delete the order record since other lines might still be complete.
# But we could re-calculate the max if needed. Let's re-eval.
all_line_reports = self.env['pos.kds.report.line'].search([
('pos_order_id', '=', order_line.order_id.id),
('prep_display_id', '=', prep_display.id)
])
order_report = self.env['pos.kds.report.order'].search([
('pos_order_id', '=', order_line.order_id.id),
('prep_display_id', '=', prep_display.id)
], limit=1)
if order_report:
if not all_line_reports:
order_report.unlink()
else:
max_comp_time = max(all_line_reports.mapped('completion_time'))
order_report.write({'completion_time': max_comp_time})
def _record_status_change_prep_time(self, pdis_state):
super()._record_status_change_prep_time(pdis_state)
self._update_kds_report(pdis_state)
def _record_stage_change_prep_time(self, pdis_state, old_last_stage_change, prep_order_completion_time):
super()._record_stage_change_prep_time(pdis_state, old_last_stage_change, prep_order_completion_time)
self._update_kds_report(pdis_state)

View File

@ -0,0 +1,3 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_pos_kds_report_line,pos.kds.report.line,model_pos_kds_report_line,point_of_sale.group_pos_user,1,1,1,1
access_pos_kds_report_order,pos.kds.report.order,model_pos_kds_report_order,point_of_sale.group_pos_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_pos_kds_report_line pos.kds.report.line model_pos_kds_report_line point_of_sale.group_pos_user 1 1 1 1
3 access_pos_kds_report_order pos.kds.report.order model_pos_kds_report_order point_of_sale.group_pos_user 1 1 1 1

View File

@ -0,0 +1,131 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- VIEWS FOR KDS PRODUCT REPORT (pos.kds.report.line) -->
<record id="view_pos_kds_report_line_pivot" model="ir.ui.view">
<field name="name">pos.kds.report.line.pivot</field>
<field name="model">pos.kds.report.line</field>
<field name="arch" type="xml">
<pivot string="KDS Product Analysis" display_quantity="1" sample="1">
<field name="pos_category_id" type="row"/>
<field name="product_id" type="row"/>
<field name="completion_time" type="measure" operator="avg"/>
<field name="preparation_time" type="measure" operator="avg"/>
<field name="service_time" type="measure" operator="avg"/>
</pivot>
</field>
</record>
<record id="view_pos_kds_report_line_graph" model="ir.ui.view">
<field name="name">pos.kds.report.line.graph</field>
<field name="model">pos.kds.report.line</field>
<field name="arch" type="xml">
<graph string="KDS Product Analysis" type="bar" sample="1">
<field name="pos_category_id" type="row"/>
<field name="product_id" type="row"/>
<field name="completion_time" type="measure" operator="avg"/>
</graph>
</field>
</record>
<record id="view_pos_kds_report_line_search" model="ir.ui.view">
<field name="name">pos.kds.report.line.search</field>
<field name="model">pos.kds.report.line</field>
<field name="arch" type="xml">
<search string="KDS Product Analysis">
<field name="pos_order_id"/>
<field name="product_id"/>
<field name="pos_category_id"/>
<field name="prep_display_id"/>
<field name="pos_config_id"/>
<filter string="My KDS Displays" name="my_kds" domain="[('prep_display_id', '!=', False)]"/>
<group>
<filter string="Date" name="group_by_date" domain="[]" context="{'group_by':'completion_datetime:day'}"/>
<filter string="Product Category" name="group_by_category" domain="[]" context="{'group_by':'pos_category_id'}"/>
<filter string="Product" name="group_by_product" domain="[]" context="{'group_by':'product_id'}"/>
<filter string="KDS Display" name="group_by_kds" domain="[]" context="{'group_by':'prep_display_id'}"/>
<filter string="POS Shop" name="group_by_pos" domain="[]" context="{'group_by':'pos_config_id'}"/>
</group>
</search>
</field>
</record>
<record id="action_pos_kds_report_line" model="ir.actions.act_window">
<field name="name">KDS Product Analysis</field>
<field name="res_model">pos.kds.report.line</field>
<field name="view_mode">pivot,graph</field>
<field name="search_view_id" ref="view_pos_kds_report_line_search"/>
<field name="context">{'search_default_group_by_kds': 1}</field>
</record>
<!-- VIEWS FOR KDS ORDER REPORT (pos.kds.report.order) -->
<record id="view_pos_kds_report_order_pivot" model="ir.ui.view">
<field name="name">pos.kds.report.order.pivot</field>
<field name="model">pos.kds.report.order</field>
<field name="arch" type="xml">
<pivot string="KDS Order Analysis" display_quantity="1" sample="1">
<field name="completion_datetime" type="row" interval="day"/>
<field name="prep_display_id" type="row"/>
<field name="completion_time" type="measure" operator="avg"/>
</pivot>
</field>
</record>
<record id="view_pos_kds_report_order_graph" model="ir.ui.view">
<field name="name">pos.kds.report.order.graph</field>
<field name="model">pos.kds.report.order</field>
<field name="arch" type="xml">
<graph string="KDS Order Analysis" type="line" sample="1">
<field name="completion_datetime" type="row" interval="day"/>
<field name="completion_time" type="measure" operator="avg"/>
</graph>
</field>
</record>
<record id="view_pos_kds_report_order_search" model="ir.ui.view">
<field name="name">pos.kds.report.order.search</field>
<field name="model">pos.kds.report.order</field>
<field name="arch" type="xml">
<search string="KDS Order Analysis">
<field name="pos_order_id"/>
<field name="prep_display_id"/>
<field name="pos_config_id"/>
<group>
<filter string="Date" name="group_by_date" domain="[]" context="{'group_by':'completion_datetime:day'}"/>
<filter string="KDS Display" name="group_by_kds" domain="[]" context="{'group_by':'prep_display_id'}"/>
<filter string="POS Shop" name="group_by_pos" domain="[]" context="{'group_by':'pos_config_id'}"/>
</group>
</search>
</field>
</record>
<record id="action_pos_kds_report_order" model="ir.actions.act_window">
<field name="name">KDS Order Analysis</field>
<field name="res_model">pos.kds.report.order</field>
<field name="view_mode">pivot,graph</field>
<field name="search_view_id" ref="view_pos_kds_report_order_search"/>
<field name="context">{'search_default_group_by_kds': 1}</field>
</record>
<!-- MENUS -->
<menuitem id="menu_pos_kds_report_line"
name="KDS Product Analysis"
parent="point_of_sale.menu_point_rep"
action="action_pos_kds_report_line"
sequence="50"/>
<menuitem id="menu_pos_kds_report_order"
name="KDS Order Analysis"
parent="point_of_sale.menu_point_rep"
action="action_pos_kds_report_order"
sequence="55"/>
</odoo>