commit aa4a78f48156b579047a8c50c500d62493aa1ea2 Author: Suherdy Yacob Date: Sat Mar 21 13:34:06 2026 +0700 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9352055 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.py[cod] +*$py.class +__pycache__/ +*.log +.vscode/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..85284cd --- /dev/null +++ b/README.md @@ -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. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/__manifest__.py b/__manifest__.py new file mode 100644 index 0000000..bf75ef6 --- /dev/null +++ b/__manifest__.py @@ -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', +} diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..e1262ca --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,2 @@ +from . import pos_kds_report +from . import pos_prep_state diff --git a/models/pos_kds_report.py b/models/pos_kds_report.py new file mode 100644 index 0000000..580ac04 --- /dev/null +++ b/models/pos_kds_report.py @@ -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 diff --git a/models/pos_prep_state.py b/models/pos_prep_state.py new file mode 100644 index 0000000..9e2e5b3 --- /dev/null +++ b/models/pos_prep_state.py @@ -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) diff --git a/security/ir.model.access.csv b/security/ir.model.access.csv new file mode 100644 index 0000000..b77898a --- /dev/null +++ b/security/ir.model.access.csv @@ -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 diff --git a/views/pos_kds_report_views.xml b/views/pos_kds_report_views.xml new file mode 100644 index 0000000..4554aa3 --- /dev/null +++ b/views/pos_kds_report_views.xml @@ -0,0 +1,131 @@ + + + + + + + pos.kds.report.line.pivot + pos.kds.report.line + + + + + + + + + + + + + pos.kds.report.line.graph + pos.kds.report.line + + + + + + + + + + + pos.kds.report.line.search + pos.kds.report.line + + + + + + + + + + + + + + + + + + + + + + + KDS Product Analysis + pos.kds.report.line + pivot,graph + + {'search_default_group_by_kds': 1} + + + + + + + pos.kds.report.order.pivot + pos.kds.report.order + + + + + + + + + + + pos.kds.report.order.graph + pos.kds.report.order + + + + + + + + + + pos.kds.report.order.search + pos.kds.report.order + + + + + + + + + + + + + + + + + KDS Order Analysis + pos.kds.report.order + pivot,graph + + {'search_default_group_by_kds': 1} + + + + + + + + + +