feat: optimize 2-stage KDS reporting and implement average-based duration measures
This commit is contained in:
parent
c4201055fd
commit
613ffa197f
35
README.md
35
README.md
@ -1,21 +1,44 @@
|
|||||||
# POS KDS Tracker and Report
|
# POS KDS Tracker and Report
|
||||||
|
|
||||||
## Overview
|
## 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.
|
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, multi-dimensional historical reporting.
|
||||||
|
|
||||||
Additionally, this module preserves timer continuity for orders and lines on the Kitchen Preparation Display UI when they are recalled or resetted back to preparation stages.
|
Additionally, this module preserves timer continuity for orders and lines on the Kitchen Preparation Display UI when they are recalled or resetted back to preparation stages, and fully optimizes reporting metrics for 2-stage KDS configurations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
- **Historical Persistence**: Permanently records Kitchen Display System (KDS) states and times, overcoming Odoo's default 1-day deletion behavior.
|
|
||||||
- **Average Preparation Time Reports**: Generates pivot and graph reports showing the average preparation time globally across all POS Shops, categories, products, or individual Kitchen Displays.
|
### 1. Timer Continuity on Reset/Recall (UI & Backend)
|
||||||
- **Timer Continuity on Reset/Recall**: When an order or order line is resetted or recalled back from Completed to a preparation stage, the UI timer automatically continues counting upwards from its last elapsed time instead of resetting back to `0`.
|
- When an order or order line is recalled or resetted back from **Completed** to a preparation stage, the KDS UI timer **continues counting upwards** from its last accumulated elapsed time instead of resetting back to `0`.
|
||||||
- **Advanced Filtering and Grouping**: Multi-dimension analysis by POS Shop, individual KDS Display, Product Category, Product, or Status.
|
- The backend automatically stores, updates, and broadcasts the accumulated KDS elapsed time (`kds_accumulated_time`) via the Odoo Bus to keep the display perfectly in sync.
|
||||||
|
|
||||||
|
### 2. 2-Stage KDS Optimization
|
||||||
|
- For simplified 2-stage KDS setups (**To Prepare** $\rightarrow$ **Completed**), the module automatically ensures:
|
||||||
|
$$\text{Preparation Time} = \text{Service Time} = \text{Completion Time}$$
|
||||||
|
- This prevents the total elapsed time from being doubled in reports when no intermediate serving/ready stage exists.
|
||||||
|
|
||||||
|
### 3. Comprehensive Dual Measures (Sum & Avg)
|
||||||
|
- Generates pivot and graph reports showing both **Sum** and **Average** metrics natively for every duration:
|
||||||
|
- **Completion Time (s)** (Sum) & **Average Completion Time (s)** (Avg)
|
||||||
|
- **Preparation Time (s)** (Sum) & **Average Preparation Time (s)** (Avg)
|
||||||
|
- **Service Time (s)** (Sum) & **Average Service Time (s)** (Avg)
|
||||||
|
- **Average Completion Time** is loaded by default as the main measure in pivot and graph views, but all other options can be fully toggled or combined under the *Measures* dropdown list.
|
||||||
|
|
||||||
|
### 4. Automatic Historical Backfill Migration
|
||||||
|
- Upgrading the module automatically executes a post-install data migration.
|
||||||
|
- It copies historical sums into the new average-specific database columns, preventing the average columns from appearing blank for pre-existing records.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
Install out-of-the-box like a standard Odoo module on an Odoo 19 environment possessing POS Enterprise (`pos_enterprise`).
|
Install out-of-the-box like a standard Odoo module on an Odoo 19 environment possessing POS Enterprise (`pos_enterprise`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
1. Open the Point of Sale and place orders to the KDS.
|
1. Open the Point of Sale and place orders to the KDS.
|
||||||
2. Complete stages in the Preparation Display.
|
2. Complete stages in the Preparation Display.
|
||||||
3. If an order or line needs to be recalled or resetted back to preparation, the display UI timer will resume incrementing starting from the previous session's elapsed time.
|
3. If an order or line needs to be recalled or resetted back to preparation, the display UI timer will resume incrementing starting from the previous session's elapsed time.
|
||||||
4. Go to **Point of Sale > Reporting > KDS Product Analysis** or **KDS Order Analysis** to view Pivot and Graph summaries.
|
4. Go to **Point of Sale > Reporting > KDS Product Analysis** or **KDS Order Analysis** to view Pivot and Graph summaries.
|
||||||
|
5. In the pivot table view, toggle **Measures** to add, remove, or compare **Sum** and **Average** durations dynamically.
|
||||||
|
|||||||
@ -20,9 +20,12 @@ class PosKdsReportLine(models.Model):
|
|||||||
('cancelled', 'Cancelled')
|
('cancelled', 'Cancelled')
|
||||||
], string='Status', default='done', index=True)
|
], string='Status', default='done', index=True)
|
||||||
|
|
||||||
preparation_time = fields.Integer('Preparation Time (s)', help="Seconds taken to prepare")
|
preparation_time = fields.Integer('Preparation Time (s)', group_operator='sum', help="Seconds taken to prepare")
|
||||||
service_time = fields.Integer('Service Time (s)', help="Seconds taken to serve")
|
preparation_time_avg = fields.Integer('Average Preparation Time (s)', group_operator='avg', help="Average seconds taken to prepare")
|
||||||
completion_time = fields.Integer('Completion Time (s)', help="Total seconds taken to complete (prep + service)")
|
service_time = fields.Integer('Service Time (s)', group_operator='sum', help="Seconds taken to serve")
|
||||||
|
service_time_avg = fields.Integer('Average Service Time (s)', group_operator='avg', help="Average seconds taken to serve")
|
||||||
|
completion_time = fields.Integer('Completion Time (s)', group_operator='sum', help="Total seconds taken to complete (prep + service)")
|
||||||
|
completion_time_avg = fields.Integer('Average Completion Time (s)', group_operator='avg', help="Average total seconds taken to complete")
|
||||||
completion_datetime = fields.Datetime('Completion Date', default=fields.Datetime.now, index=True)
|
completion_datetime = fields.Datetime('Completion Date', default=fields.Datetime.now, index=True)
|
||||||
|
|
||||||
@api.depends('product_id')
|
@api.depends('product_id')
|
||||||
@ -40,6 +43,22 @@ class PosKdsReportLine(models.Model):
|
|||||||
for record in self:
|
for record in self:
|
||||||
record.display_name = f"{record.pos_order_id.name} - {record.product_id.name}"
|
record.display_name = f"{record.pos_order_id.name} - {record.product_id.name}"
|
||||||
|
|
||||||
|
def init(self):
|
||||||
|
super().init()
|
||||||
|
self.env.cr.execute("""
|
||||||
|
UPDATE pos_kds_report_line
|
||||||
|
SET preparation_time_avg = COALESCE(preparation_time, 0)
|
||||||
|
WHERE preparation_time_avg IS NULL OR preparation_time_avg = 0;
|
||||||
|
|
||||||
|
UPDATE pos_kds_report_line
|
||||||
|
SET service_time_avg = COALESCE(service_time, 0)
|
||||||
|
WHERE service_time_avg IS NULL OR service_time_avg = 0;
|
||||||
|
|
||||||
|
UPDATE pos_kds_report_line
|
||||||
|
SET completion_time_avg = COALESCE(completion_time, 0)
|
||||||
|
WHERE completion_time_avg IS NULL OR completion_time_avg = 0;
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
class PosKdsReportOrder(models.Model):
|
class PosKdsReportOrder(models.Model):
|
||||||
_name = 'pos.kds.report.order'
|
_name = 'pos.kds.report.order'
|
||||||
@ -57,9 +76,12 @@ class PosKdsReportOrder(models.Model):
|
|||||||
('cancelled', 'Cancelled')
|
('cancelled', 'Cancelled')
|
||||||
], string='Status', default='done', index=True)
|
], string='Status', default='done', index=True)
|
||||||
|
|
||||||
preparation_time = fields.Integer('Preparation Time (s)', help="Max preparation time across lines")
|
preparation_time = fields.Integer('Preparation Time (s)', group_operator='sum', help="Max preparation time across lines")
|
||||||
service_time = fields.Integer('Service Time (s)', help="Max service time across lines")
|
preparation_time_avg = fields.Integer('Average Preparation Time (s)', group_operator='avg', help="Average preparation time across lines")
|
||||||
completion_time = fields.Integer('Completion Time (s)', help="Max completion time across all lines of the order on this display")
|
service_time = fields.Integer('Service Time (s)', group_operator='sum', help="Max service time across lines")
|
||||||
|
service_time_avg = fields.Integer('Average Service Time (s)', group_operator='avg', help="Average service time across lines")
|
||||||
|
completion_time = fields.Integer('Completion Time (s)', group_operator='sum', help="Max completion time across all lines of the order on this display")
|
||||||
|
completion_time_avg = fields.Integer('Average Completion Time (s)', group_operator='avg', help="Average completion time across all lines of the order on this display")
|
||||||
completion_datetime = fields.Datetime('Completion Date', default=fields.Datetime.now, index=True)
|
completion_datetime = fields.Datetime('Completion Date', default=fields.Datetime.now, index=True)
|
||||||
|
|
||||||
@api.depends('pos_order_id')
|
@api.depends('pos_order_id')
|
||||||
@ -71,3 +93,19 @@ class PosKdsReportOrder(models.Model):
|
|||||||
def _compute_display_name(self):
|
def _compute_display_name(self):
|
||||||
for record in self:
|
for record in self:
|
||||||
record.display_name = f"{record.pos_order_id.name} ({record.prep_display_id.name})"
|
record.display_name = f"{record.pos_order_id.name} ({record.prep_display_id.name})"
|
||||||
|
|
||||||
|
def init(self):
|
||||||
|
super().init()
|
||||||
|
self.env.cr.execute("""
|
||||||
|
UPDATE pos_kds_report_order
|
||||||
|
SET preparation_time_avg = COALESCE(preparation_time, 0)
|
||||||
|
WHERE preparation_time_avg IS NULL OR preparation_time_avg = 0;
|
||||||
|
|
||||||
|
UPDATE pos_kds_report_order
|
||||||
|
SET service_time_avg = COALESCE(service_time, 0)
|
||||||
|
WHERE service_time_avg IS NULL OR service_time_avg = 0;
|
||||||
|
|
||||||
|
UPDATE pos_kds_report_order
|
||||||
|
SET completion_time_avg = COALESCE(completion_time, 0)
|
||||||
|
WHERE completion_time_avg IS NULL OR completion_time_avg = 0;
|
||||||
|
""")
|
||||||
|
|||||||
@ -108,7 +108,13 @@ class PosPreparationState(models.Model):
|
|||||||
|
|
||||||
prep_time = max(0, prep_time)
|
prep_time = max(0, prep_time)
|
||||||
svc_time = max(0, svc_time)
|
svc_time = max(0, svc_time)
|
||||||
comp_time = prep_time + svc_time
|
|
||||||
|
stage_count = self.env['pos.prep.stage'].search_count([('prep_display_id', '=', display_id)])
|
||||||
|
if stage_count <= 2:
|
||||||
|
comp_time = prep_time
|
||||||
|
svc_time = prep_time
|
||||||
|
else:
|
||||||
|
comp_time = prep_time + svc_time
|
||||||
|
|
||||||
line_report = KdsLineReport.search([
|
line_report = KdsLineReport.search([
|
||||||
('pos_order_line_id', '=', order_line.id),
|
('pos_order_line_id', '=', order_line.id),
|
||||||
@ -121,8 +127,11 @@ class PosPreparationState(models.Model):
|
|||||||
'product_id': order_line.product_id.id,
|
'product_id': order_line.product_id.id,
|
||||||
'prep_display_id': display_id,
|
'prep_display_id': display_id,
|
||||||
'preparation_time': prep_time,
|
'preparation_time': prep_time,
|
||||||
|
'preparation_time_avg': prep_time,
|
||||||
'service_time': svc_time,
|
'service_time': svc_time,
|
||||||
|
'service_time_avg': svc_time,
|
||||||
'completion_time': comp_time,
|
'completion_time': comp_time,
|
||||||
|
'completion_time_avg': comp_time,
|
||||||
'completion_datetime': fields.Datetime.now(),
|
'completion_datetime': fields.Datetime.now(),
|
||||||
'state': 'done',
|
'state': 'done',
|
||||||
}
|
}
|
||||||
@ -132,15 +141,21 @@ class PosPreparationState(models.Model):
|
|||||||
# Note: We only accumulate if the current session's time is valid.
|
# Note: We only accumulate if the current session's time is valid.
|
||||||
new_prep = line_report.preparation_time + prep_time
|
new_prep = line_report.preparation_time + prep_time
|
||||||
new_svc = line_report.service_time + svc_time
|
new_svc = line_report.service_time + svc_time
|
||||||
new_comp = new_prep + new_svc
|
if stage_count <= 2:
|
||||||
|
new_comp = new_prep
|
||||||
|
else:
|
||||||
|
new_comp = new_prep + new_svc
|
||||||
|
|
||||||
_logger.info("KDS Re-Completed (Accumulating): Order %s, Line %s: %ss + %ss = %ss",
|
_logger.info("KDS Re-Completed (Accumulating): Order %s, Line %s: %ss + %ss = %ss",
|
||||||
order_name, order_line.product_id.name, line_report.completion_time, comp_time, new_comp)
|
order_name, order_line.product_id.name, line_report.completion_time, comp_time, new_comp)
|
||||||
|
|
||||||
line_report.write({
|
line_report.write({
|
||||||
'preparation_time': new_prep,
|
'preparation_time': new_prep,
|
||||||
|
'preparation_time_avg': new_prep,
|
||||||
'service_time': new_svc,
|
'service_time': new_svc,
|
||||||
|
'service_time_avg': new_svc,
|
||||||
'completion_time': new_comp,
|
'completion_time': new_comp,
|
||||||
|
'completion_time_avg': new_comp,
|
||||||
'completion_datetime': vals['completion_datetime'],
|
'completion_datetime': vals['completion_datetime'],
|
||||||
'state': 'done',
|
'state': 'done',
|
||||||
})
|
})
|
||||||
@ -162,8 +177,10 @@ class PosPreparationState(models.Model):
|
|||||||
# We use the max completion time across all lines of this order for this display.
|
# We use the max completion time across all lines of this order for this display.
|
||||||
# If we just updated a line to 219s, and order report was 204s, it becomes 219s.
|
# If we just updated a line to 219s, and order report was 204s, it becomes 219s.
|
||||||
if comp_time > order_report.completion_time or order_report.state != 'done':
|
if comp_time > order_report.completion_time or order_report.state != 'done':
|
||||||
|
new_comp_time = max(comp_time, order_report.completion_time)
|
||||||
order_report.write({
|
order_report.write({
|
||||||
'completion_time': max(comp_time, order_report.completion_time),
|
'completion_time': new_comp_time,
|
||||||
|
'completion_time_avg': new_comp_time,
|
||||||
'state': 'done',
|
'state': 'done',
|
||||||
'completion_datetime': vals['completion_datetime'],
|
'completion_datetime': vals['completion_datetime'],
|
||||||
})
|
})
|
||||||
@ -172,8 +189,11 @@ class PosPreparationState(models.Model):
|
|||||||
'pos_order_id': order_id,
|
'pos_order_id': order_id,
|
||||||
'prep_display_id': display_id,
|
'prep_display_id': display_id,
|
||||||
'preparation_time': prep_time, # Simplified: will be updated by other lines if needed
|
'preparation_time': prep_time, # Simplified: will be updated by other lines if needed
|
||||||
|
'preparation_time_avg': prep_time,
|
||||||
'service_time': svc_time,
|
'service_time': svc_time,
|
||||||
|
'service_time_avg': svc_time,
|
||||||
'completion_time': comp_time,
|
'completion_time': comp_time,
|
||||||
|
'completion_time_avg': comp_time,
|
||||||
'state': 'done',
|
'state': 'done',
|
||||||
'completion_datetime': vals['completion_datetime'],
|
'completion_datetime': vals['completion_datetime'],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
<field name="pos_category_id" type="row"/>
|
<field name="pos_category_id" type="row"/>
|
||||||
<field name="product_id" type="row"/>
|
<field name="product_id" type="row"/>
|
||||||
<field name="state" type="col"/>
|
<field name="state" type="col"/>
|
||||||
<field name="preparation_time" type="measure" operator="avg"/>
|
<field name="completion_time_avg" type="measure"/>
|
||||||
</pivot>
|
</pivot>
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
@ -23,7 +23,7 @@
|
|||||||
<graph string="KDS Product Analysis" type="bar" sample="1">
|
<graph string="KDS Product Analysis" type="bar" sample="1">
|
||||||
<field name="pos_category_id" type="row"/>
|
<field name="pos_category_id" type="row"/>
|
||||||
<field name="product_id" type="row"/>
|
<field name="product_id" type="row"/>
|
||||||
<field name="preparation_time" type="measure" operator="avg"/>
|
<field name="completion_time_avg" type="measure"/>
|
||||||
</graph>
|
</graph>
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
@ -76,7 +76,7 @@
|
|||||||
<field name="completion_datetime" type="row" interval="day"/>
|
<field name="completion_datetime" type="row" interval="day"/>
|
||||||
<field name="prep_display_id" type="row"/>
|
<field name="prep_display_id" type="row"/>
|
||||||
<field name="state" type="col"/>
|
<field name="state" type="col"/>
|
||||||
<field name="preparation_time" type="measure" operator="avg"/>
|
<field name="completion_time_avg" type="measure"/>
|
||||||
</pivot>
|
</pivot>
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
@ -87,7 +87,7 @@
|
|||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<graph string="KDS Order Analysis" type="line" sample="1">
|
<graph string="KDS Order Analysis" type="line" sample="1">
|
||||||
<field name="completion_datetime" type="row" interval="day"/>
|
<field name="completion_datetime" type="row" interval="day"/>
|
||||||
<field name="preparation_time" type="measure" operator="avg"/>
|
<field name="completion_time_avg" type="measure"/>
|
||||||
</graph>
|
</graph>
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user