Browse Source

Merge branch 'CybroOdoo:18.0' into api_rest

pull/401/head
Bernat Roig 2 weeks ago
committed by GitHub
parent
commit
6dd01568c1
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 0
      base_advanced_report_templates/static/description/icon.png
  2. 0
      cts_theme_perfume/static/description/icon .png
  3. 11
      gym_mgmt_system/__manifest__.py
  4. 12
      gym_mgmt_system/data/gym_membership_cron.xml
  5. 57
      gym_mgmt_system/data/trainer_skill_data.xml
  6. 11
      gym_mgmt_system/doc/RELEASE_NOTES.md
  7. 4
      gym_mgmt_system/models/__init__.py
  8. 66
      gym_mgmt_system/models/account_move.py
  9. 63
      gym_mgmt_system/models/account_payment.py
  10. 198
      gym_mgmt_system/models/gym_attendance.py
  11. 444
      gym_mgmt_system/models/gym_membership.py
  12. 46
      gym_mgmt_system/models/hr_employee.py
  13. 39
      gym_mgmt_system/models/sale_order.py
  14. 11
      gym_mgmt_system/security/ir.model.access.csv
  15. 33
      gym_mgmt_system/static/description/index.html
  16. 105
      gym_mgmt_system/views/gym_attendance_views.xml
  17. 59
      gym_mgmt_system/views/gym_membership_history_views.xml
  18. 89
      gym_mgmt_system/views/gym_membership_views.xml
  19. 6
      gym_mgmt_system/views/gym_menu.xml
  20. 98
      gym_mgmt_system/views/hr_employee_views.xml
  21. 1
      gym_mgmt_system/views/membership_plan_views.xml
  22. 2
      gym_mgmt_system/views/trainer_skill_views.xml
  23. 1
      gym_mgmt_system/wizard/__init__.py
  24. 160
      gym_mgmt_system/wizard/gym_membership_extension.py
  25. 57
      gym_mgmt_system/wizard/gym_membership_extension.xml
  26. 2
      pos_kitchen_screen_odoo/__manifest__.py
  27. 5
      pos_kitchen_screen_odoo/doc/RELEASE_NOTES.md
  28. 277
      pos_kitchen_screen_odoo/models/pos_orders.py
  29. BIN
      pos_kitchen_screen_odoo/static/description/assets/screenshots/pos12.png
  30. BIN
      pos_kitchen_screen_odoo/static/description/assets/screenshots/pos13.png
  31. BIN
      pos_kitchen_screen_odoo/static/description/assets/screenshots/pos14.png
  32. 90
      pos_kitchen_screen_odoo/static/description/index.html
  33. 5
      pos_kitchen_screen_odoo/static/src/js/fields_load.js
  34. 94
      pos_kitchen_screen_odoo/static/src/js/order_button.js
  35. 1
      pos_kitchen_screen_odoo/static/src/js/order_pay.js
  36. 2
      sale_order_discount_approval_odoo/__manifest__.py
  37. 4
      sale_order_discount_approval_odoo/doc/RELEASE_NOTES.md
  38. 2
      sale_order_discount_approval_odoo/models/__init__.py
  39. 43
      sale_order_discount_approval_odoo/models/sale_order.py
  40. BIN
      sale_order_discount_approval_odoo/static/description/assets/screenshots/s09.png
  41. 30
      sale_order_discount_approval_odoo/static/description/index.html

0
base_advanced_report_templates/static/description/Icon.png → base_advanced_report_templates/static/description/icon.png

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

0
cts_theme_perfume/static/description/Icon .png → cts_theme_perfume/static/description/icon .png

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

11
gym_mgmt_system/__manifest__.py

@ -21,7 +21,7 @@
#############################################################################
{
'name': 'GYM Management System',
'version': '18.0.1.0.0',
'version': '18.0.2.0.0',
'category': 'Industries',
'summary': 'GYM Management System For Managing '
'Membership, Member, Workout Plan, etc',
@ -33,16 +33,21 @@
'company': 'Cybrosys Techno Solutions',
'maintainer': 'Cybrosys Techno Solutions',
'website': "https://www.cybrosys.com",
'depends': [ 'mail', 'contacts', 'hr',
'product', 'membership', 'sale_management',
'depends': [ 'base','mail', 'contacts', 'hr',
'product', 'membership', 'sale_management','hr_skills',
],
'data': [
'security/gym_mgmt_system_groups.xml',
'security/ir.model.access.csv',
'security/gym_mgmt_system_security.xml',
'data/ir_sequence_data.xml',
'data/gym_membership_cron.xml',
'data/trainer_skill_data.xml',
'wizard/assign_workout.xml',
'wizard/gym_membership_extension.xml',
'views/gym_menu.xml',
'views/product_template_views.xml',
'views/gym_attendance_views.xml',
'views/res_partner_views.xml',
'views/exercise_for_views.xml',
'views/gym_exercise_views.xml',

12
gym_mgmt_system/data/gym_membership_cron.xml

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="ir_cron_expire_memberships" model="ir.cron">
<field name="name">Auto Expire Gym Memberships</field>
<field name="model_id" ref="model_gym_membership"/>
<field name="state">code</field>
<field name="code">model._cron_expire_memberships()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active" eval="True"/>
</record>
</odoo>

57
gym_mgmt_system/data/trainer_skill_data.xml

@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="skill_type_gym" model="hr.skill.type">
<field name="name">Gym</field>
<field name="is_gym_skill">True</field>
</record>
<record id="skill_strength_training" model="hr.skill">
<field name="name">Strength Training</field>
<field name="skill_type_id" ref="skill_type_gym"/>
</record>
<record id="skill_cardio_training" model="hr.skill">
<field name="name">Cardio Training</field>
<field name="skill_type_id" ref="skill_type_gym"/>
</record>
<record id="skill_yoga" model="hr.skill">
<field name="name">Yoga</field>
<field name="skill_type_id" ref="skill_type_gym"/>
</record>
<record id="skill_crossfit" model="hr.skill">
<field name="name">CrossFit</field>
<field name="skill_type_id" ref="skill_type_gym"/>
</record>
<record id="skill_pilates" model="hr.skill">
<field name="name">Pilates</field>
<field name="skill_type_id" ref="skill_type_gym"/>
</record>
<record id="skill_martial_arts" model="hr.skill">
<field name="name">Martial Arts</field>
<field name="skill_type_id" ref="skill_type_gym"/>
</record>
<record id="skill_swimming" model="hr.skill">
<field name="name">Swimming</field>
<field name="skill_type_id" ref="skill_type_gym"/>
</record>
<record id="skill_nutrition_coaching" model="hr.skill">
<field name="name">Nutrition Coaching</field>
<field name="skill_type_id" ref="skill_type_gym"/>
</record>
<record id="skill_rehabilitation" model="hr.skill">
<field name="name">Rehabilitation Training</field>
<field name="skill_type_id" ref="skill_type_gym"/>
</record>
<record id="skill_group_fitness" model="hr.skill">
<field name="name">Group Fitness</field>
<field name="skill_type_id" ref="skill_type_gym"/>
</record>
</odoo>

11
gym_mgmt_system/doc/RELEASE_NOTES.md

@ -4,3 +4,14 @@
#### Version 18.0.1.0.0
#### ADD
- Initial commit for GYM Management System
#### 08.09.2025
#### Version 18.0.2.0.0
#### ADD
- Membership States – Added states (Draft, Confirmed, Active, Paused, Expired) with automatic transitions.
- Pause & Resume – Implemented pause/resume functionality with auto checkout on pause.
- Extend Membership – Added extend button with options (same plan, custom days, upgrade via wizard).
- Attendance Section – Added Quick Check-in, Attendance Records, and state validations.
- Trainer Skills Integration – Linked skills to Employee (Trainer) form with dedicated tab.
- Configured BMI & BMR calculations in Measurement History
- Fix exercises field in workout plan

4
gym_mgmt_system/models/__init__.py

@ -19,6 +19,7 @@
# If not, see <http://www.gnu.org/licenses/>.
#
#############################################################################
from . import exercise_for
from . import gym_exercise
from . import gym_membership
@ -33,3 +34,6 @@ from . import trainer_skill
from . import workout_days
from . import workout_plan
from . import workout_plan_option
from . import account_move
from . import account_payment
from . import gym_attendance

66
gym_mgmt_system/models/account_move.py

@ -0,0 +1,66 @@
# -*- coding: utf-8 -*-
#############################################################################
#
# Cybrosys Technologies Pvt. Ltd.
#
# Copyright (C) 2025-TODAY Cybrosys Technologies(<https://www.cybrosys.com>)
# Author: Abbas P (<https://www.cybrosys.com>)
#
# You can modify it under the terms of the GNU LESSER
# GENERAL PUBLIC LICENSE (AGPL v3), Version 3.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU LESSER GENERAL PUBLIC LICENSE (AGPL v3) for more details.
#
# You should have received a copy of the GNU LESSER GENERAL PUBLIC LICENSE
# (AGPL v3) along with this program.
# If not, see <http://www.gnu.org/licenses/>.
#
#############################################################################
from odoo import models
class AccountMove(models.Model):
_inherit = "account.move"
def _activate_gym_memberships(self):
"""Activate gym memberships when invoice is paid"""
for move in self:
if move.move_type == "out_invoice" and move.payment_state == "paid":
sale_order = None
if move.invoice_origin:
sale_order = self.env["sale.order"].search([
("name", "=", move.invoice_origin)
], limit=1)
if not sale_order:
sale_lines = move.invoice_line_ids.mapped('sale_line_ids')
if sale_lines:
sale_order = sale_lines[0].order_id
if sale_order:
memberships = self.env["gym.membership"].search([
("sale_order_id", "=", sale_order.id),
("state", "=", "confirm")
])
for membership in memberships:
membership.state = "active"
class AccountMoveLine(models.Model):
_inherit = "account.move.line"
def reconcile(self):
"""Check for membership activation after payment reconciliation"""
res = super().reconcile()
invoice_moves = self.mapped('move_id').filtered(
lambda m: m.move_type == 'out_invoice' and m.payment_state == 'paid'
)
invoice_moves._activate_gym_memberships()
return res

63
gym_mgmt_system/models/account_payment.py

@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
#############################################################################
#
# Cybrosys Technologies Pvt. Ltd.
#
# Copyright (C) 2025-TODAY Cybrosys Technologies(<https://www.cybrosys.com>)
# Author: Abbas P (<https://www.cybrosys.com>)
#
# You can modify it under the terms of the GNU LESSER
# GENERAL PUBLIC LICENSE (AGPL v3), Version 3.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU LESSER GENERAL PUBLIC LICENSE (AGPL v3) for more details.
#
# You should have received a copy of the GNU LESSER GENERAL PUBLIC LICENSE
# (AGPL v3) along with this program.
# If not, see <http://www.gnu.org/licenses/>.
#
#############################################################################
from odoo import models
import logging
_logger = logging.getLogger(__name__)
class AccountPayment(models.Model):
_inherit = "account.payment"
def action_post(self):
"""Check for membership activation after payment is posted"""
res = super().action_post()
for payment in self:
if payment.partner_type == 'customer' and payment.state == 'posted':
_logger.info(f"Payment {payment.name} posted, checking for memberships to activate")
reconciled_invoice_lines = payment.line_ids.mapped(
'matched_debit_ids.debit_move_id') + payment.line_ids.mapped('matched_credit_ids.credit_move_id')
invoices = reconciled_invoice_lines.mapped('move_id').filtered(lambda m: m.move_type == 'out_invoice')
for invoice in invoices:
if invoice.payment_state == 'paid':
_logger.info(f"Invoice {invoice.name} is now fully paid")
sale_order = None
if invoice.invoice_origin:
sale_order = self.env["sale.order"].search([
("name", "=", invoice.invoice_origin)
], limit=1)
if sale_order:
memberships = self.env["gym.membership"].search([
("sale_order_id", "=", sale_order.id),
("state", "=", "confirm")
])
for membership in memberships:
membership.state = "active"
_logger.info(
f"Activated membership {membership.reference} due to payment {payment.name}")
return res

198
gym_mgmt_system/models/gym_attendance.py

@ -0,0 +1,198 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class GymAttendance(models.Model):
"""Simple Gym Attendance Model"""
_name = 'gym.attendance'
_description = 'Gym Attendance'
_order = 'check_in desc'
_rec_name = 'member_id'
member_id = fields.Many2one('res.partner', string='Member', required=True,
domain="[('is_gym_member', '=', True)]")
check_in = fields.Datetime(string='Check In', required=True,
default=fields.Datetime.now)
check_out = fields.Datetime(string='Check Out')
duration = fields.Float(string='Duration (Hours)',
compute='_compute_duration', store=True)
state = fields.Selection([
('checked_in', 'Checked In'),
('checked_out', 'Checked Out')
], string='State', compute='_compute_state', store=True)
@api.depends('check_out')
def _compute_state(self):
for record in self:
record.state = 'checked_out' if record.check_out else 'checked_in'
@api.depends('check_in', 'check_out')
def _compute_duration(self):
for record in self:
if record.check_in and record.check_out:
delta = record.check_out - record.check_in
record.duration = delta.total_seconds() / 3600
else:
record.duration = 0.0
@api.model
def create(self, vals):
"""Override create to validate BEFORE creating the record"""
if 'member_id' in vals:
member_id = vals['member_id']
existing_checkin = self.search([
('member_id', '=', member_id),
('check_out', '=', False)
])
if existing_checkin:
member_name = self.env['res.partner'].browse(member_id).name
raise UserError(_('%s is already checked in at %s. Please check out first.') %
(member_name, existing_checkin.check_in.strftime('%Y-%m-%d %H:%M:%S')))
member = self.env['res.partner'].browse(member_id)
validation = self._validate_member_can_checkin(member)
if not validation['can_checkin']:
raise UserError(validation['message'])
return super(GymAttendance, self).create(vals)
def write(self, vals):
"""Override write to validate member changes"""
if 'member_id' in vals:
existing_checkin = self.env['gym.attendance'].search([
('member_id', '=', vals['member_id']),
('check_out', '=', False),
('id', 'not in', self.ids)
])
if existing_checkin:
member_name = self.env['res.partner'].browse(vals['member_id']).name
raise UserError(_('%s is already checked in. Cannot change to this member.') % member_name)
return super(GymAttendance, self).write(vals)
def action_check_in(self):
"""Check in a member - simplified since validation is now in create()"""
self.ensure_one()
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'message': f'Welcome {self.member_id.name}! Check-in successful.',
'type': 'success',
'sticky': False,
}
}
def _validate_member_can_checkin(self, member):
"""Validate if member can check in - returns dict with can_checkin boolean and message"""
active_memberships = self.env['gym.membership'].search([
('member_id', '=', member.id),
('state', '=', 'active')
])
active_membership = None
if active_memberships:
if len(active_memberships) == 1:
active_membership = active_memberships
else:
active_membership = max(active_memberships,
key=lambda m: m.effective_end_date or fields.Date.today())
if active_membership:
if active_membership.effective_end_date and active_membership.effective_end_date < fields.Date.today():
active_membership.action_expire()
else:
return {
'can_checkin': True,
'message': _('Check-in allowed.')
}
any_membership = self.env['gym.membership'].search([
('member_id', '=', member.id)
], order='id desc', limit=1)
if not any_membership:
return {
'can_checkin': False,
'message': _('No membership found for this member.')
}
if any_membership.state == 'paused':
return {
'can_checkin': False,
'message': _(
'Cannot check in. Your latest membership is PAUSED.\n'
'Please resume your membership to check in.'
)
}
elif any_membership.state == 'expired':
return {
'can_checkin': False,
'message': _(
'Cannot check in. Your latest membership has EXPIRED.\n'
'Please renew your membership to continue.'
)
}
elif any_membership.state in ['draft', 'confirm']:
return {
'can_checkin': False,
'message': _(
'Cannot check in. Your membership is not yet active.\n'
'Please wait for activation or contact support.'
)
}
else:
return {
'can_checkin': False,
'message': _(
'Cannot check in. Membership status: %s'
) % any_membership.state.title()
}
def action_check_out(self):
"""Check out manually"""
self.ensure_one()
if self.check_out:
raise UserError(_('Already checked out.'))
self.check_out = fields.Datetime.now()
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'message': f'Goodbye {self.member_id.name}! Duration: {self.duration:.2f} hours',
'type': 'success',
'sticky': False,
}
}
@api.model
def quick_checkin(self, member_id):
"""Method for quick check-in from external calls"""
member = self.env['res.partner'].browse(member_id)
if not member.exists():
raise UserError(_('Member not found.'))
attendance = self.create({
'member_id': member_id,
'check_in': fields.Datetime.now(),
})
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'message': f'Welcome {member.name}! Check-in successful.',
'type': 'success',
'sticky': False,
}
}

444
gym_mgmt_system/models/gym_membership.py

@ -20,6 +20,11 @@
#
#############################################################################
from odoo import api, fields, models, _
from odoo.exceptions import UserError, ValidationError
from datetime import date, timedelta
import logging
_logger = logging.getLogger(__name__)
class GymMembership(models.Model):
@ -42,8 +47,25 @@ class GymMembership(models.Model):
required=True, tracking=True,
domain="[('membership_date_from', "
"'!=',False)]")
paid_amount = fields.Float(string="Paid Amount", tracking=True,
help="The amount paid for the membership")
membership_duration = fields.Integer(
string='Plan Duration (Days)',
compute='_compute_membership_duration',
)
paid_amount = fields.Monetary(
string="Paid Amount",
compute="_compute_paid_amount",
store=True,
currency_field="company_currency_id",
tracking=True,
)
company_currency_id = fields.Many2one(
"res.currency",
related="company_id.currency_id",
readonly=True
)
membership_fees = fields.Float(string="Membership Fees", tracking=True,
help="The membership fees",
related="membership_scheme_id.list_price")
@ -51,6 +73,8 @@ class GymMembership(models.Model):
ondelete='cascade', copy=False,
help="Order reference",
readonly=True)
# Original dates from membership scheme
membership_date_from = fields.Date(string='Membership Start Date',
related="membership_scheme_id."
"membership_date_from",
@ -61,22 +85,79 @@ class GymMembership(models.Model):
"date_to",
help='Date until which membership remains'
'active.')
# NEW FIELDS FOR PAUSE/EXTEND FUNCTIONALITY
# Effective dates (calculated based on pauses and extensions)
effective_start_date = fields.Date(string='Effective Start Date',
compute='_compute_effective_dates',
help='Actual start date considering pauses')
effective_end_date = fields.Date(string='Effective End Date',
compute='_compute_effective_dates',
help='Actual end date considering pauses and extensions')
# Pause tracking fields
current_pause_start = fields.Date(string='Current Pause Start Date',
help='Start date of current pause period')
total_paused_days = fields.Integer(string='Total Paused Days', default=0,
help='Total number of days this membership has been paused')
pause_history_ids = fields.One2many('gym.membership.pause', 'membership_id',
string='Pause History')
# Extension tracking
extension_history_ids = fields.One2many('gym.membership.extension', 'membership_id',
string='Extension History')
total_extended_days = fields.Integer(string='Total Extended Days', default=0,
help='Total number of days this membership has been extended')
company_id = fields.Many2one('res.company', string='Company',
default=lambda self: self.env.company,
help='The field hold the company id')
state = fields.Selection([
('draft', 'Draft'),
('confirm', 'Confirm'),
('confirm', 'Confirmed'),
('active', 'Active'),
('paused', 'Paused'),
('expired', 'Expired'),
('cancelled', 'Cancelled')
], default='draft', string='Status',
tracking=True,
help="The status of record defined here")
extension_count = fields.Integer(
string='Extension Count',
compute='_compute_extension_count',
help='Number of times this membership has been extended'
)
can_extend = fields.Boolean(
string='Can Extend',
compute='_compute_can_extend'
)
_sql_constraints = [
('membership_date_greater',
'check(membership_date_to >= membership_date_from)',
'Error ! Ending Date cannot be set before Beginning Date.')
]
@api.depends('state', 'effective_end_date')
def _compute_can_extend(self):
"""Compute if membership can be extended (only if expired)"""
for rec in self:
rec.can_extend = rec.state == 'expired'
@api.depends('membership_date_from', 'membership_date_to', 'total_paused_days', 'total_extended_days')
def _compute_effective_dates(self):
"""Compute effective start and end dates based on pauses and extensions"""
for rec in self:
rec.effective_start_date = rec.membership_date_from
if rec.membership_date_to:
# Add paused days and extended days to the original end date
additional_days = rec.total_paused_days + rec.total_extended_days
rec.effective_end_date = rec.membership_date_to + timedelta(days=additional_days)
else:
rec.effective_end_date = rec.membership_date_to
@api.model_create_multi
def create(self, vals_list):
"""Sequence number for membership """
@ -85,3 +166,360 @@ class GymMembership(models.Model):
vals['reference'] = self.env['ir.sequence'].next_by_code(
'gym.membership') or 'New'
return super(GymMembership, self).create(vals_list)
def action_confirm(self):
for rec in self:
rec.state = 'confirm'
def action_set_active(self):
for rec in self:
rec.state = 'active'
def action_pause(self):
"""Pause the membership - only from active state"""
for rec in self:
if rec.state != 'active':
raise UserError(_('Only active memberships can be paused.'))
# IMPORTANT: Auto check-out member if currently checked in
current_attendance = self.env['gym.attendance'].search([
('member_id', '=', rec.member_id.id),
('check_out', '=', False) # Find records without check-out
], limit=1)
if current_attendance:
# Force check-out with current timestamp
current_attendance.write({
'check_out': fields.Datetime.now()
})
# Log the automatic check-out
rec.message_post(
body=_('Member automatically checked out at %s due to membership pause.') %
fields.Datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
message_type='notification'
)
# Record the pause start date
rec.current_pause_start = date.today()
rec.state = 'paused'
# Log the pause action
rec.message_post(
body=_('Membership paused on %s') % date.today().strftime('%Y-%m-%d'),
message_type='notification'
)
def action_resume(self):
"""Resume the membership from paused state"""
for rec in self:
if rec.state != 'paused':
raise UserError(_('Only paused memberships can be resumed.'))
if not rec.current_pause_start:
raise UserError(_('No pause start date found.'))
# Calculate paused days
pause_end = date.today()
paused_days = (pause_end - rec.current_pause_start).days
# Create pause history record
self.env['gym.membership.pause'].create({
'membership_id': rec.id,
'pause_start': rec.current_pause_start,
'pause_end': pause_end,
'days_paused': paused_days,
})
# Update total paused days
rec.total_paused_days += paused_days
# Clear current pause start and set to active
rec.current_pause_start = False
rec.state = 'active'
# Log the resume action
rec.message_post(
body=_('Membership resumed on %s. Paused for %s days.') % (
pause_end.strftime('%Y-%m-%d'), paused_days
),
message_type='notification'
)
def _auto_checkout_member(self, reason="membership status change"):
"""Helper method to automatically check out a member"""
self.ensure_one()
# Find any active attendance (not checked out)
current_attendance = self.env['gym.attendance'].search([
('member_id', '=', self.member_id.id),
('check_out', '=', False)
], limit=1)
if current_attendance:
# Set check-out time
current_attendance.write({
'check_out': fields.Datetime.now()
})
# Log the action
self.message_post(
body=_('Member automatically checked out at %s due to %s') %
(fields.Datetime.now().strftime('%Y-%m-%d %H:%M:%S'), reason),
message_type='notification'
)
return True
return False
def action_expire(self):
"""Expire the membership and auto check-out if needed"""
for rec in self:
# Auto check-out before expiring
rec._auto_checkout_member("membership expiry")
# Set to expired
rec.state = 'expired'
# Log expiry
rec.message_post(
body=_('Membership expired on %s') % date.today().strftime('%Y-%m-%d'),
message_type='notification'
)
def action_cancel(self):
"""Cancel the membership and auto check-out if needed"""
for rec in self:
# Auto check-out before cancelling
rec._auto_checkout_member("membership cancellation")
# Set to cancelled
rec.state = 'cancelled'
def action_extend_membership(self):
"""Open wizard to extend membership - only for expired memberships"""
self.ensure_one()
if self.state != 'expired':
raise UserError(_('Only expired memberships can be extended.'))
# Check if already extended recently (optional business rule)
recent_extension = self.env['gym.membership.extension'].search([
('membership_id', '=', self.id),
('extension_date', '>', fields.Date.today() - timedelta(days=30)) # Within last 30 days
], limit=1)
if recent_extension:
raise UserError(_(
'This membership was already extended on %s. '
'You cannot extend again within 30 days of the last extension.'
) % recent_extension.extension_date.strftime('%Y-%m-%d'))
return {
'name': _('Extend Membership'),
'type': 'ir.actions.act_window',
'res_model': 'gym.membership.extend.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_membership_id': self.id,
'default_member_id': self.member_id.id,
}
}
def force_checkout_inactive_members(self):
"""
Manual method to force check-out all members who are checked in
but have inactive memberships (paused, expired, cancelled)
"""
# Find all inactive memberships
inactive_memberships = self.search([
('state', 'in', ['paused', 'expired', 'cancelled'])
])
checkout_count = 0
for membership in inactive_memberships:
current_attendance = self.env['gym.attendance'].search([
('member_id', '=', membership.member_id.id),
('check_out', '=', False)
])
if current_attendance:
current_attendance.write({
'check_out': fields.Datetime.now()
})
checkout_count += 1
membership.message_post(
body=_('Forced check-out at %s due to inactive membership status: %s') %
(fields.Datetime.now().strftime('%Y-%m-%d %H:%M:%S'), membership.state),
message_type='notification'
)
if checkout_count > 0:
message = _('%s members were automatically checked out due to inactive memberships.') % checkout_count
notification_type = 'success'
else:
message = _('No members needed to be checked out.')
notification_type = 'info'
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'message': message,
'type': notification_type,
'sticky': False,
}
}
def _check_and_activate_membership(self):
for membership in self:
invoices = membership.sale_order_id.invoice_ids.filtered(lambda inv: inv.move_type == 'out_invoice')
if invoices and all(inv.payment_state == "paid" for inv in invoices):
membership.state = "active"
@api.depends('sale_order_id.invoice_ids.payment_state', 'sale_order_id.invoice_ids.amount_residual')
def _compute_paid_amount(self):
for membership in self:
total_paid = 0.0
if membership.sale_order_id:
invoices = membership.sale_order_id.invoice_ids.filtered(
lambda inv: inv.move_type == "out_invoice" and inv.state == "posted"
)
for invoice in invoices:
total_paid += (invoice.amount_total - invoice.amount_residual)
membership.paid_amount = total_paid
# Cron job to automatically expire memberships
@api.model
def _cron_expire_memberships(self):
"""Cron job to automatically set memberships to expired when effective end date passes"""
today = fields.Date.today()
_logger.info(f"Starting membership expiry cron job for date: {today}")
# Only check active and paused memberships that have actually expired
active_memberships = self.search([
('state', 'in', ['active', 'paused']),
('effective_end_date', '!=', False), # Must have an end date
('effective_end_date', '<', today) # End date must be in the past
])
expired_count = 0
_logger.info(f"Found {len(active_memberships)} memberships to check for expiry")
for membership in active_memberships:
try:
# Double-check the date to avoid errors
if membership.effective_end_date and membership.effective_end_date < today:
_logger.info(
f"Expiring membership {membership.reference} - End date: {membership.effective_end_date}")
# Auto check-out before expiring
membership._auto_checkout_member("automatic membership expiry")
# Set to expired
membership.state = 'expired'
membership.message_post(
body=_('Membership automatically expired on %s (End date: %s)') %
(today.strftime('%Y-%m-%d'), membership.effective_end_date.strftime('%Y-%m-%d')),
message_type='notification'
)
expired_count += 1
except Exception as e:
_logger.error(f"Error expiring membership {membership.reference}: {str(e)}")
continue
# Log the result for debugging
_logger.info(f"Cron job completed. Expired {expired_count} memberships")
@api.depends('membership_date_from', 'membership_date_to')
def _compute_membership_duration(self):
for rec in self:
if rec.membership_date_from and rec.membership_date_to:
rec.membership_duration = (rec.membership_date_to - rec.membership_date_from).days + 1
else:
rec.membership_duration = 0
def complete_extension(self, days_extended, extension_amount, sale_order_id=None):
"""Complete the extension process and reactivate membership"""
self.ensure_one()
if self.state != 'expired':
raise UserError(_('Only expired memberships can be extended.'))
if days_extended <= 0:
raise UserError(_('Days extended must be greater than 0.'))
# Create extension history record
extension_record = self.env['gym.membership.extension'].create({
'membership_id': self.id,
'extension_date': fields.Date.today(),
'days_extended': days_extended,
'extension_amount': extension_amount,
'sale_order_id': sale_order_id,
'notes': f'Membership extended by {days_extended} days for ${extension_amount}'
})
# Update total extended days
self.total_extended_days += days_extended
# IMPORTANT: Reactivate the membership after extension
self.state = 'active'
# Force recomputation of effective dates
self._compute_effective_dates()
# Log the extension
self.message_post(
body=_('Membership extended by %s days on %s. Amount paid: %s. Membership reactivated. New end date: %s') %
(days_extended, fields.Date.today().strftime('%Y-%m-%d'),
extension_amount, self.effective_end_date.strftime('%Y-%m-%d')),
message_type='notification'
)
return extension_record
@api.depends('extension_history_ids')
def _compute_extension_count(self):
for rec in self:
rec.extension_count = len(rec.extension_history_ids)
@api.depends('state', 'extension_count')
def _compute_can_extend(self):
"""Compute if membership can be extended"""
for rec in self:
max_extensions = 3 # Business rule: max 3 extensions per membership
rec.can_extend = (
rec.state == 'expired' and
rec.extension_count < max_extensions
)
class GymMembershipPause(models.Model):
"""Model to track pause history"""
_name = 'gym.membership.pause'
_description = 'Gym Membership Pause History'
_order = 'pause_start desc'
membership_id = fields.Many2one('gym.membership', string='Membership', required=True, ondelete='cascade')
pause_start = fields.Date(string='Pause Start Date', required=True)
pause_end = fields.Date(string='Pause End Date', required=True)
days_paused = fields.Integer(string='Days Paused', required=True)
notes = fields.Text(string='Notes')
class GymMembershipExtension(models.Model):
"""Model to track extension history"""
_name = 'gym.membership.extension'
_description = 'Gym Membership Extension History'
_order = 'extension_date desc'
membership_id = fields.Many2one('gym.membership', string='Membership', required=True, ondelete='cascade')
extension_date = fields.Date(string='Extension Date', required=True, default=fields.Date.today)
days_extended = fields.Integer(string='Days Extended', required=True)
extension_amount = fields.Float(string='Extension Amount', required=True)
sale_order_id = fields.Many2one('sale.order', string='Extension Sale Order')
notes = fields.Text(string='Notes')

46
gym_mgmt_system/models/hr_employee.py

@ -19,16 +19,48 @@
# If not, see <http://www.gnu.org/licenses/>.
#
#############################################################################
from odoo import fields, models
from odoo import api, fields, models, _
class HrEmployee(models.Model):
"""Inherited the model hr employee for adding some field to check whether he
is trainer or not."""
"""Inherited the model hr employee for adding gym trainer field."""
_inherit = 'hr.employee'
is_trainer = fields.Boolean(string='Gym Trainer',
help="The employee is trainer ")
exercise_for_ids = fields.Many2many("trainer.skill",
string="Specialization",
help="Skill of the trainer")
help="The employee is trainer")
gym_skill_ids = fields.Many2many(
'hr.skill',
string='Gym Specializations',
compute='_compute_gym_skills',
help="Gym-related skills of the trainer (read-only display)"
)
@api.depends('skill_ids')
def _compute_gym_skills(self):
"""Get only gym-related skills from all employee skills"""
for employee in self:
try:
if employee.skill_ids:
gym_skills = employee.skill_ids.filtered(
lambda emp_skill: emp_skill.skill_id and emp_skill.skill_id.skill_type_id.is_gym_skill
)
employee.gym_skill_ids = gym_skills.mapped('skill_id')
else:
employee.gym_skill_ids = False
except Exception:
employee.gym_skill_ids = False
class HrSkillType(models.Model):
"""Extend HR Skill Type for gym category"""
_inherit = 'hr.skill.type'
is_gym_skill = fields.Boolean(string='Is Gym Skill', default=False)
class HrSkill(models.Model):
"""Extend HR Skill for gym skills"""
_inherit = 'hr.skill'
is_gym_skill = fields.Boolean(related='skill_type_id.is_gym_skill', store=True)

39
gym_mgmt_system/models/sale_order.py

@ -19,23 +19,46 @@
# If not, see <http://www.gnu.org/licenses/>.
#
#############################################################################
from odoo import models, _
# -*- coding: utf-8 -*-
from odoo import api, fields, models, _
class SaleOrder(models.Model):
"""Inherit the sale.order model for supering the action confirm."""
_inherit = "sale.order"
is_membership_extension = fields.Boolean(
string='Is Membership Extension',
default=False,
help='If True, this sale order is for extending an existing membership and should not create a new membership record.'
)
def action_confirm(self):
""" Membership created directly from sale order confirmed """
product = self.env['product.product'].search([('membership_date_from', '!=', False),
('id', '=', self.order_line.product_id.id)])
"""Membership created directly from sale order confirmed - FIXED VERSION"""
if self.is_membership_extension:
# Log that this is an extension order
self.message_post(
body=_('This is a membership extension order. No new membership record will be created.'),
message_type='notification'
)
# Call super but skip membership creation logic
return super().action_confirm()
# Original logic for NEW memberships only
product = self.env['product.product'].search([
('membership_date_from', '!=', False),
('id', 'in', self.order_line.product_id.ids)
])
for record in product:
self.env['gym.membership'].create([
{'member_id': self.partner_id.id,
membership = self.env['gym.membership'].create({
'member_id': self.partner_id.id,
'membership_date_from': record.membership_date_from,
'membership_scheme_id': self.order_line.product_id.id,
'membership_scheme_id': record.id,
'sale_order_id': self.id,
}])
'state': 'confirm',
})
self.partner_id.is_gym_member = True
return super().action_confirm()

11
gym_mgmt_system/security/ir.model.access.csv

@ -41,3 +41,14 @@ access_trainer_skill_member,access.trainer.skill,model_trainer_skill,gym_mgmt_sy
access_my_workout_plan_operator,access.my.workout.plan,model_my_workout_plan,gym_mgmt_system.group_gym_operator,1,1,1,1
access_my_workout_plan_trainer,access.my.workout.plan,model_my_workout_plan,gym_mgmt_system.group_gym_trainer,1,1,1,1
access_my_workout_plan_member,access.my.workout.plan,model_my_workout_plan,gym_mgmt_system.group_gym_member,1,0,0,0
access_gym_membership_pause_operator,access.gym.membership.pause operator,model_gym_membership_pause,gym_mgmt_system.group_gym_operator,1,1,1,1
access_gym_membership_pause_trainer,access.gym.membership.pause trainer,model_gym_membership_pause,gym_mgmt_system.group_gym_trainer,1,0,0,0
access_membership_pause_member,access.gym.membership.pause member,model_gym_membership_pause,gym_mgmt_system.group_gym_member,1,0,0,0
access_membership_extension_operator,access.gym.membership.extension operator,model_gym_membership_extension,gym_mgmt_system.group_gym_operator,1,1,1,1
access_membership_extension_trainer,access.gym.membership.extension trainer,model_gym_membership_extension,gym_mgmt_system.group_gym_trainer,1,0,0,0
access_membership_extension_member,access.gym.membership.extension member,model_gym_membership_extension,gym_mgmt_system.group_gym_member,1,0,0,0
access_membership_extend_wizard_operator,access.gym.membership.extend.wizard operator,model_gym_membership_extend_wizard,gym_mgmt_system.group_gym_operator,1,1,1,1
access_gym_attendance_operator,access.gym.attendance.operator,model_gym_attendance,gym_mgmt_system.group_gym_operator,1,1,1,1
access_gym_attendance_trainer,access.gym.attendance.trainer,model_gym_attendance,gym_mgmt_system.group_gym_trainer,1,0,0,0
access_gym_attendance_member,access.gym.attendance.member,model_gym_attendance,gym_mgmt_system.group_gym_member,1,0,0,0

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
41 access_my_workout_plan_operator access.my.workout.plan model_my_workout_plan gym_mgmt_system.group_gym_operator 1 1 1 1
42 access_my_workout_plan_trainer access.my.workout.plan model_my_workout_plan gym_mgmt_system.group_gym_trainer 1 1 1 1
43 access_my_workout_plan_member access.my.workout.plan model_my_workout_plan gym_mgmt_system.group_gym_member 1 0 0 0
44 access_gym_membership_pause_operator access.gym.membership.pause operator model_gym_membership_pause gym_mgmt_system.group_gym_operator 1 1 1 1
45 access_gym_membership_pause_trainer access.gym.membership.pause trainer model_gym_membership_pause gym_mgmt_system.group_gym_trainer 1 0 0 0
46 access_membership_pause_member access.gym.membership.pause member model_gym_membership_pause gym_mgmt_system.group_gym_member 1 0 0 0
47 access_membership_extension_operator access.gym.membership.extension operator model_gym_membership_extension gym_mgmt_system.group_gym_operator 1 1 1 1
48 access_membership_extension_trainer access.gym.membership.extension trainer model_gym_membership_extension gym_mgmt_system.group_gym_trainer 1 0 0 0
49 access_membership_extension_member access.gym.membership.extension member model_gym_membership_extension gym_mgmt_system.group_gym_member 1 0 0 0
50 access_membership_extend_wizard_operator access.gym.membership.extend.wizard operator model_gym_membership_extend_wizard gym_mgmt_system.group_gym_operator 1 1 1 1
51 access_gym_attendance_operator access.gym.attendance.operator model_gym_attendance gym_mgmt_system.group_gym_operator 1 1 1 1
52 access_gym_attendance_trainer access.gym.attendance.trainer model_gym_attendance gym_mgmt_system.group_gym_trainer 1 0 0 0
53 access_gym_attendance_member access.gym.attendance.member model_gym_attendance gym_mgmt_system.group_gym_member 1 0 0 0
54

33
gym_mgmt_system/static/description/index.html

@ -1038,6 +1038,39 @@
</div>
</div>
</div>
<div class="row pt-5 m-0">
<div class="col-md-3">
<h4 style="font-size:16px; font-weight:600; color:#514F4F; margin:0; line-height:26px;">
Latest Release 18.0.2.0.0
</h4>
<span style="font-size:14px; color:#7A7979; display:block; margin-bottom:20px;">
8th Sep, 2025
</span>
</div>
<div class="col-md-8">
<div style="padding:0 0 40px">
<div style="margin:0 0 10px">
<div style="display:inline-block; padding:0px 8px; color:#514F4F; background-color:#FFD8D8; border-radius:20px">
Add
</div>
</div>
<div class="d-flex m-0"
style="color:#7A7979;">
<ul class="pl-3 mb-0">
<li>Membership States – Added states (Draft, Confirmed, Active, Paused, Expired) with automatic transitions.</li>
<li>Pause & Resume – Implemented pause/resume functionality with auto checkout on pause.</li>
<li>Extend Membership – Added extend button with options (same plan, custom days, upgrade via wizard)<li>
<li>Attendance Section – Added Quick Check-in, Attendance Records, and state validations.</li>
<li>Trainer Skills Integration – Linked skills to Employee (Trainer) form with dedicated tab.</li>
<li>Corrected BMI & BMR calculations in Measurement History</li>
<li>Fix exercises field in workout plan</li>
</ul>
</div>
</div>
<div style="padding:0 0 0; border-bottom:1px solid #E3E3E3">
</div>
</div>
</div>
</div>
</div>
</div>

105
gym_mgmt_system/views/gym_attendance_views.xml

@ -0,0 +1,105 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="gym_attendance_view_form" model="ir.ui.view">
<field name="name">gym.attendance.view.form</field>
<field name="model">gym.attendance</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_check_in" string="Check In" type="object"
class="btn-primary" invisible="check_in"/>
<button name="action_check_out" string="Check Out" type="object"
class="btn-warning" invisible="check_out or not check_in"/>
<field name="state" widget="statusbar"/>
</header>
<sheet>
<group>
<group>
<field name="member_id"/>
<field name="check_in"/>
</group>
<group>
<field name="check_out"/>
<field name="duration" widget="float_time"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="gym_attendance_view_tree" model="ir.ui.view">
<field name="name">gym.attendance.view.tree</field>
<field name="model">gym.attendance</field>
<field name="arch" type="xml">
<list>
<field name="member_id"/>
<field name="check_in"/>
<field name="check_out"/>
<field name="duration" widget="float_time"/>
<field name="state" decoration-success="state == 'checked_out'"
decoration-info="state == 'checked_in'"/>
<button name="action_check_out" type="object" string="Check Out"
class="btn-sm btn-warning" icon="fa-sign-out"
invisible="check_out or not check_in"/>
</list>
</field>
</record>
<record id="gym_attendance_quick_checkin_form" model="ir.ui.view">
<field name="name">gym.attendance.quick.checkin.form</field>
<field name="model">gym.attendance</field>
<field name="arch" type="xml">
<form>
<sheet>
<div class="oe_title">
<h1>Quick Check-In</h1>
</div>
<group>
<field name="member_id" placeholder="Select Member..."/>
</group>
<footer>
<button name="action_check_in" string="Check In" type="object" class="btn-primary"/>
<button string="Cancel" special="cancel" class="btn-secondary"/>
</footer>
</sheet>
</form>
</field>
</record>
<record id="gym_attendance_action" model="ir.actions.act_window">
<field name="name">Gym Attendance</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">gym.attendance</field>
<field name="view_mode">list,form</field>
</record>
<record id="gym_attendance_quick_checkin_action" model="ir.actions.act_window">
<field name="name">Quick Check-In</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">gym.attendance</field>
<field name="view_mode">form</field>
<field name="view_id" ref="gym_attendance_quick_checkin_form"/>
<field name="target">new</field>
</record>
<menuitem
id="gym_attendance_menu"
name="Attendance"
parent="gym_member_root"
sequence="45"/>
<menuitem
id="gym_attendance_records_menu"
name="Attendance Records"
parent="gym_attendance_menu"
action="gym_attendance_action"
sequence="10"/>
<menuitem
id="gym_attendance_quick_checkin_menu"
name="Quick Check-In"
parent="gym_attendance_menu"
action="gym_attendance_quick_checkin_action"
sequence="5"/>
</odoo>

59
gym_mgmt_system/views/gym_membership_history_views.xml

@ -0,0 +1,59 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="gym_membership_pause_view_tree" model="ir.ui.view">
<field name="name">gym.membership.pause.view.tree</field>
<field name="model">gym.membership.pause</field>
<field name="arch" type="xml">
<list string="Pause History" create="false" edit="false">
<field name="membership_id"/>
<field name="pause_start"/>
<field name="pause_end"/>
<field name="days_paused"/>
<field name="notes"/>
</list>
</field>
</record>
<record id="gym_membership_extension_view_tree" model="ir.ui.view">
<field name="name">gym.membership.extension.view.tree</field>
<field name="model">gym.membership.extension</field>
<field name="arch" type="xml">
<list string="Extension History" create="false" edit="false">
<field name="membership_id"/>
<field name="extension_date"/>
<field name="days_extended"/>
<field name="extension_amount"/>
<field name="sale_order_id"/>
<field name="notes"/>
</list>
</field>
</record>
<record id="gym_membership_pause_action" model="ir.actions.act_window">
<field name="name">Membership Pause History</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">gym.membership.pause</field>
<field name="view_mode">list</field>
</record>
<record id="gym_membership_extension_action" model="ir.actions.act_window">
<field name="name">Membership Extension History</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">gym.membership.extension</field>
<field name="view_mode">list</field>
</record>
<menuitem
id="gym_membership_pause_history_menu"
name="Pause History"
parent="gym_member_root"
action="gym_membership_pause_action"
sequence="31"/>
<menuitem
id="gym_membership_extension_history_menu"
name="Extension History"
parent="gym_member_root"
action="gym_membership_extension_action"
sequence="32"/>
</odoo>

89
gym_mgmt_system/views/gym_membership_views.xml

@ -5,10 +5,29 @@
<field name="name">gym.membership.view.form</field>
<field name="model">gym.membership</field>
<field name="arch" type="xml">
<form>
<form create="false" edit="false" delete="false">
<header>
<field name="state" widget="statusbar"
options="{'clickable':'1'}"/>
options="{'clickable': true}"/>
<button name="action_set_active" string="Activate" type="object"
class="btn-primary"
invisible="state != 'confirm'"/>
<button name="action_pause" string="Pause" type="object"
class="btn-primary"
invisible="state != 'active'"/>
<button name="action_resume" string="Resume" type="object"
class="btn-primary"
invisible="state != 'paused'"/>
<button name="action_extend_membership" string="Extend" type="object"
class="btn-primary"
invisible="not can_extend"
help="Extend this expired membership"/>
<div class="alert alert-info"
invisible="can_extend or state != 'expired'">
<strong>Extension Limit Reached:</strong>
This membership has been extended <field name="extension_count"/> times.
Maximum extensions allowed: 3.
</div>
</header>
<sheet>
<div class="oe_title">
@ -21,39 +40,90 @@
<field name="member_id"/>
<field name="membership_scheme_id"/>
<field name="paid_amount"/>
<field name="membership_duration" readonly="1"/>
</group>
<group>
<field name="membership_fees"/>
<field name="sale_order_id" readonly="1"/>
<label for="membership_date_from"
string="Membership Duration"/>
<field name="extension_count" readonly="1" invisible="extension_count == 0"/>
<field name="can_extend" invisible="1"/>
<label for="membership_date_from" string="Original Duration"/>
<div class="o_row">
<field name="membership_date_from"
required="1"/>
<field name="membership_date_from" required="1"/>
-
<field name="membership_date_to" required="1"/>
</div>
<label for="effective_start_date" string="Effective Duration"/>
<div class="o_row">
<field name="effective_start_date" readonly="1"/>
-
<field name="effective_end_date" readonly="1"/>
</div>
</group>
</group>
<group string="Pause &amp; Extension Information"
invisible="state not in ['paused', 'active', 'expired']">
<group>
<field name="current_pause_start" readonly="1"
invisible="state != 'paused'"/>
<field name="total_paused_days" readonly="1"/>
</group>
<group>
<field name="total_extended_days" readonly="1"/>
<field name="can_extend" readonly="1"/>
</group>
</group>
<notebook>
<page string="Pause History" invisible="not pause_history_ids">
<field name="pause_history_ids" readonly="1">
<list create="false" edit="false">
<field name="pause_start"/>
<field name="pause_end"/>
<field name="days_paused"/>
<field name="notes"/>
</list>
</field>
</page>
<page string="Extension History" invisible="not extension_history_ids">
<field name="extension_history_ids" readonly="1">
<list create="false" edit="false">
<field name="extension_date"/>
<field name="days_extended"/>
<field name="extension_amount"/>
<field name="notes"/>
</list>
</field>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<!--Tree view for gym membership-->
<record id="gym_membership_view_tree" model="ir.ui.view">
<field name="name">gym.membership.view.tree</field>
<field name="model">gym.membership</field>
<field name="arch" type="xml">
<list default_order="reference desc">
<list default_order="reference desc" create="false" edit="false" delete="false">
<field name="reference"/>
<field name="member_id"/>
<field name="membership_scheme_id"/>
<field name="membership_fees"/>
<field name="state"/>
<field name="effective_end_date"/>
<field name="total_paused_days"/>
<field name="state" decoration-info="state == 'active'"
decoration-warning="state == 'paused'"
decoration-danger="state == 'expired'"/>
</list>
</field>
</record>
<!--Action for gym membership-->
<record id="gym_membership_action" model="ir.actions.act_window">
<field name="name">Gym Membership</field>
@ -66,6 +136,7 @@
</p>
</field>
</record>
<!-- Menu -->
<menuitem
id="gym_membership_menu_action"

6
gym_mgmt_system/views/gym_menu.xml

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<menuitem id="gym_member_root"
name="Gym Management"
sequence="50"/>
</odoo>

98
gym_mgmt_system/views/hr_employee_views.xml

@ -1,48 +1,92 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Action for gym trainer -->
<record id="hr_employee_action" model="ir.actions.act_window">
<record id="view_employee_form_trainer" model="ir.ui.view">
<field name="name">hr.employee.view.form.inherited.gym.trainer</field>
<field name="model">hr.employee</field>
<field name="inherit_id" ref="hr.view_employee_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='mobile_phone']" position="after">
<field name="is_trainer"/>
</xpath>
<xpath expr="//page[@name='hr_settings']" position="after">
<page name="trainer_skills" string="Trainer Skills" invisible="not is_trainer">
<group string="Current Gym Skills">
<field name="gym_skill_ids" widget="many2many_tags" readonly="1"/>
</group>
<group string="All Employee Skills">
<field name="skill_ids" nolabel="1" context="{'default_skill_type_id': False}"/>
</group>
</page>
</xpath>
</field>
</record>
<record id="hr_employee_trainer_action" model="ir.actions.act_window">
<field name="name">Trainers</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">hr.employee</field>
<field name="view_mode">kanban,list,form</field>
<field name="domain">[('is_trainer','=','True')]</field>
<field name="context">{'default_is_trainer':'True'}</field>
<field name="domain">[('is_trainer','=',True)]</field>
<field name="context">{'default_is_trainer': True}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create your first Trainer!
</p>
</field>
</record>
<!--form view for trainer -->
<record id="view_employee_form" model="ir.ui.view">
<field name="name">hr.employee.view.form.inherited.gym.mgmt.system
</field>
<field name="model">hr.employee</field>
<field name="inherit_id" ref="hr.view_employee_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='mobile_phone']" position="after">
<field name="is_trainer"/>
<field name="exercise_for_ids" widget="many2many_tags"
invisible="is_trainer == False"/>
</xpath>
</field>
</record>
<!-- Menu and Submenu -->
<menuitem
id="gym_trainer_root"
<!-- Menu items -->
<menuitem id="gym_trainer_root"
name="Trainers"
parent="gym_mgmt_system_menu_root"
sequence="20"/>
<menuitem
id="gym_trainer_menu"
<menuitem id="gym_trainer_menu"
name="Trainers"
parent="gym_trainer_root"
action="hr_employee_action"
action="hr_employee_trainer_action"
sequence="10"/>
<menuitem id="gym_trainer_skill_menu"
name="Trainer skill"
<record id="gym_skills_action" model="ir.actions.act_window">
<field name="name">Trainer Skills</field>
<field name="res_model">hr.skill</field>
<field name="view_mode">list,form</field>
<field name="domain">[('skill_type_id.is_gym_skill', '=', True)]</field>
<field name="context">{}</field>
</record>
<menuitem id="gym_skills_menu"
name="Trainer Skills"
parent="gym_trainer_root"
action="trainer_skill_action"
action="gym_skills_action"
sequence="20"/>
<record id="view_gym_skill_level_tree" model="ir.ui.view">
<field name="name">hr.skill.level.view.tree.gym</field>
<field name="model">hr.skill.level</field>
<field name="arch" type="xml">
<list string="Trainer Skill Levels" editable="bottom">
<field name="name"/>
<field name="level_progress" widget="progressbar" options="{'editable': true}"/>
<field name="default_level" widget="boolean_toggle"/>
</list>
</field>
</record>
<!-- Add the missing action for skill levels -->
<record id="gym_skill_levels_action" model="ir.actions.act_window">
<field name="name">Trainer Skill Levels</field>
<field name="res_model">hr.skill.level</field>
<field name="view_mode">list,form</field>
<field name="view_id" ref="view_gym_skill_level_tree"/>
<field name="context">{}</field>
</record>
<menuitem id="gym_skill_levels_menu"
name="Trainer Skill Levels"
parent="gym_trainer_root"
action="gym_skill_levels_action"
sequence="30"/>
</odoo>

1
gym_mgmt_system/views/membership_plan_views.xml

@ -135,7 +135,6 @@
<field name="view_id" ref="gym_membership_products_kanban"/>
<field name="act_window_id" ref="action_gym_membership_products"/>
</record>
<!--Menu -->
<menuitem name="Membership plan"
id="membership_products_menu"
parent="gym_member_root"

2
gym_mgmt_system/views/trainer_skill_views.xml

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Tree view for trainer skill -->
<record id="trainer_skill_view_tree" model="ir.ui.view">
<field name="name">trainer.skill.view.tree</field>
<field name="model">trainer.skill</field>
@ -12,7 +11,6 @@
</list>
</field>
</record>
<!-- Action for trainer skill -->
<record id="trainer_skill_action" model="ir.actions.act_window">
<field name="name">Trainer skill</field>
<field name="type">ir.actions.act_window</field>

1
gym_mgmt_system/wizard/__init__.py

@ -20,3 +20,4 @@
#
#############################################################################
from . import assign_workout
from . import gym_membership_extension

160
gym_mgmt_system/wizard/gym_membership_extension.py

@ -0,0 +1,160 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from datetime import date, timedelta
class GymMembershipExtendWizard(models.TransientModel):
"""Wizard to extend gym membership"""
_name = 'gym.membership.extend.wizard'
_description = 'Extend Gym Membership Wizard'
membership_id = fields.Many2one('gym.membership', string='Membership', required=True)
member_id = fields.Many2one('res.partner', string='Member', required=True)
current_end_date = fields.Date(string='Current End Date', related='membership_id.effective_end_date')
extension_type = fields.Selection([
('same_plan', 'Extend with Same Plan Duration'),
('custom_days', 'Custom Number of Days'),
('new_plan', 'Change to New Membership Plan')
], string='Extension Type', required=True, default='same_plan')
same_plan_duration = fields.Integer(string='Plan Duration (Days)', compute='_compute_same_plan_duration',
store=True)
custom_days = fields.Integer(string='Number of Days to Extend', default=30)
new_membership_plan_id = fields.Many2one('product.product', string='New Membership Plan',
domain="[('membership_date_from', '!=', False)]")
new_plan_duration = fields.Integer(string='New Plan Duration (Days)')
extension_days = fields.Integer(string='Total Extension Days', compute='_compute_extension_details', store=True)
new_end_date = fields.Date(string='New End Date', compute='_compute_extension_details', store=True)
extension_amount = fields.Float(string='Extension Amount', compute='_compute_extension_details', store=True)
notes = fields.Text(string='Notes')
@api.depends('membership_id.membership_duration')
def _compute_same_plan_duration(self):
"""Compute the same plan duration from membership"""
for wizard in self:
wizard.same_plan_duration = wizard.membership_id.membership_duration or 0
@api.depends('extension_type', 'custom_days', 'new_membership_plan_id', 'same_plan_duration')
def _compute_extension_details(self):
"""Compute extension days, new end date and amount"""
for wizard in self:
extension_days = 0
extension_amount = 0.0
if wizard.extension_type == 'same_plan':
if wizard.membership_id.membership_scheme_id:
original_plan = wizard.membership_id.membership_scheme_id
if original_plan.membership_date_from and original_plan.membership_date_to:
extension_days = (original_plan.membership_date_to - original_plan.membership_date_from).days
extension_amount = original_plan.list_price
elif wizard.extension_type == 'custom_days':
extension_days = wizard.custom_days
if wizard.membership_id.membership_scheme_id and extension_days > 0:
original_plan = wizard.membership_id.membership_scheme_id
if original_plan.membership_date_from and original_plan.membership_date_to:
original_days = (original_plan.membership_date_to - original_plan.membership_date_from).days
if original_days > 0:
daily_rate = original_plan.list_price / original_days
extension_amount = daily_rate * extension_days
elif wizard.extension_type == 'new_plan':
if wizard.new_membership_plan_id:
new_plan = wizard.new_membership_plan_id
if new_plan.membership_date_from and new_plan.membership_date_to:
extension_days = (new_plan.membership_date_to - new_plan.membership_date_from).days
extension_amount = new_plan.list_price
wizard.extension_days = extension_days
wizard.extension_amount = extension_amount
if wizard.current_end_date and extension_days > 0:
wizard.new_end_date = wizard.current_end_date + timedelta(days=extension_days)
else:
wizard.new_end_date = wizard.current_end_date
@api.onchange('new_membership_plan_id')
def _onchange_new_membership_plan_id(self):
"""Update new plan duration when plan changes"""
if self.new_membership_plan_id:
plan = self.new_membership_plan_id
if plan.membership_date_from and plan.membership_date_to:
self.new_plan_duration = (plan.membership_date_to - plan.membership_date_from).days
def action_extend_membership(self):
"""Process the membership extension"""
self.ensure_one()
if self.extension_days <= 0:
raise UserError(_('Extension days must be greater than 0.'))
if self.extension_amount <= 0:
raise UserError(_('Extension amount must be greater than 0.'))
sale_order = self._create_extension_sale_order()
self.membership_id.complete_extension(
days_extended=self.extension_days,
extension_amount=self.extension_amount,
sale_order_id=sale_order.id
)
if self.extension_type == 'new_plan' and self.new_membership_plan_id:
self.membership_id.membership_scheme_id = self.new_membership_plan_id.id
return {
'name': _('Extension Sale Order'),
'type': 'ir.actions.act_window',
'res_model': 'sale.order',
'res_id': sale_order.id,
'view_mode': 'form',
'target': 'current',
}
def _create_extension_sale_order(self):
"""Create sale order for membership extension using a service product"""
extension_product = self._get_or_create_extension_service_product()
if self.extension_type == 'new_plan' and self.new_membership_plan_id:
product_name = f"Membership Extension - {self.new_membership_plan_id.name} ({self.extension_days} days)"
elif self.extension_type == 'same_plan':
product_name = f"Membership Extension - {self.membership_id.membership_scheme_id.name} ({self.extension_days} days)"
else:
product_name = f"Membership Extension - {self.extension_days} days"
sale_order_vals = {
'partner_id': self.member_id.id,
'date_order': fields.Datetime.now(),
'origin': f'Extension of {self.membership_id.reference}',
'order_line': [(0, 0, {
'product_id': extension_product.id,
'name': product_name,
'product_uom_qty': 1,
'price_unit': self.extension_amount,
})]
}
sale_order = self.env['sale.order'].create(sale_order_vals)
return sale_order
def _get_or_create_extension_service_product(self):
"""Get or create a generic service product for extensions (NOT a membership product)"""
extension_product = self.env['product.product'].search([
('name', '=', 'Membership Extension Service'),
('type', '=', 'service'),
('membership_date_from', '=', False),
('membership_date_to', '=', False)
], limit=1)
if not extension_product:
extension_product = self.env['product.product'].create({
'name': 'Membership Extension Service',
'type': 'service',
'list_price': 0.0,
'sale_ok': True,
'purchase_ok': False,
})
return extension_product

57
gym_mgmt_system/wizard/gym_membership_extension.xml

@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="gym_membership_extend_wizard_view_form" model="ir.ui.view">
<field name="name">gym.membership.extend.wizard.view.form</field>
<field name="model">gym.membership.extend.wizard</field>
<field name="arch" type="xml">
<form string="Extend Membership">
<group>
<group>
<field name="membership_id" readonly="1"/>
<field name="member_id" readonly="1"/>
<field name="current_end_date" readonly="1"/>
</group>
<group>
<field name="extension_type" widget="radio"/>
</group>
</group>
<group>
<group invisible="extension_type != 'same_plan'">
<field name="same_plan_duration" readonly="1" string="Plan Duration (Days)"/>
</group>
<group invisible="extension_type != 'custom_days'">
<field name="custom_days"/>
</group>
<group invisible="extension_type != 'new_plan'">
<field name="new_membership_plan_id"/>
<field name="new_plan_duration" readonly="1"/>
</group>
</group>
<separator string="Extension Summary"/>
<group>
<group>
<field name="extension_days" readonly="1"/>
<field name="new_end_date" readonly="1"/>
</group>
<group>
<field name="extension_amount" readonly="1"/>
</group>
</group>
<group>
<field name="notes" placeholder="Optional notes about this extension..."/>
</group>
<footer>
<button name="action_extend_membership" string="Create Extension Order"
type="object" class="btn-primary"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>

2
pos_kitchen_screen_odoo/__manifest__.py

@ -21,7 +21,7 @@
############################################################################
{
'name': 'POS Kitchen Screen',
'version': '18.0.1.1.4',
'version': '18.0.1.2.0',
'category': 'Point Of Sale',
'summary': 'POS Kitchen Screen facilitates sending certain orders '
'automatically to the kitchen.The POS Kitchen Screen allows for '

5
pos_kitchen_screen_odoo/doc/RELEASE_NOTES.md

@ -34,3 +34,8 @@
#### Version 18.0.1.1.4
#### BUG FIX
-Fixed the issue where a completed kitchen order reverted to 'Cooking' status after the payment was processed.
#### 03.09.2025
#### Version 18.0.1.2.0
#### BUG FIX
-Fixed the issue where, after completing an order and selecting the same floor again, the previously submitted order was duplicated in the POS order line.

277
pos_kitchen_screen_odoo/models/pos_orders.py

@ -56,16 +56,12 @@ class PosOrder(models.Model):
[('id', 'in', product_ids)]).mapped(
'prepair_time_minutes')
vals['avg_prepare_time'] = max(prepare_times)
existing_order = self.search(
[("pos_reference", "=", vals.get("pos_reference"))], limit=1)
if existing_order:
continue
if not vals.get("order_status"):
vals["order_status"] = 'draft'
# Ensure name is always set
if not vals.get('name'):
if vals.get('order_id'):
config = self.env['pos.order'].browse(
@ -76,12 +72,9 @@ class PosOrder(models.Model):
else:
vals['name'] = self.env['ir.sequence'].next_by_code(
'pos.order') or '/'
processed_vals_to_create.append(vals)
res = super().create(
processed_vals_to_create) if processed_vals_to_create else self.browse()
orders_to_notify = []
for order in res:
kitchen_screen = self.env["kitchen.screen"].search(
@ -95,14 +88,12 @@ class PosOrder(models.Model):
in order_line.product_id.pos_categ_ids):
order_line.is_cooking = True
has_kitchen_items = True
if has_kitchen_items:
order.is_cooking = True
order.order_ref = order.name # Set order_ref here
if order.order_status != 'draft':
order.order_status = 'draft'
orders_to_notify.append(order)
self.env.cr.commit()
for order in orders_to_notify:
message = {
@ -115,107 +106,71 @@ class PosOrder(models.Model):
}
channel = f'pos_order_created_{order.config_id.id}'
self.env["bus.bus"]._sendone(channel, "notification", message)
return res
def write(self, vals):
"""Override write function for adding order status in vals"""
original_statuses = {order.id: order.order_status for order in self}
message = {
'res_model': self._name,
'message': 'pos_order_created'
}
self.env["bus.bus"]._sendone('pos_order_created', "notification",
message)
res = super(PosOrder, self).write(vals)
for order in self:
if order.order_status == "waiting" and vals.get(
"order_status") != "ready":
vals["order_status"] = order.order_status
if vals.get("state") == "paid" and order.name == "/":
vals["name"] = self._compute_order_name()
kitchen_screen = self.env["kitchen.screen"].search(
[("pos_config_id", "=", order.config_id.id)], limit=1
)
if kitchen_screen:
has_kitchen_items = False
for line_data in order.lines:
if line_data.product_id.pos_categ_ids and any(
for line in order.lines:
if line.product_id.pos_categ_ids and any(
cat.id in kitchen_screen.pos_categ_ids.ids
for cat in line_data.product_id.pos_categ_ids
):
for cat in line.product_id.pos_categ_ids):
if not line.is_cooking:
line.write({
'is_cooking': True,
'order_status': line.order_status or 'draft'
})
has_kitchen_items = True
break
# Update vals instead of direct assignment to avoid recursive write
if has_kitchen_items and not order.is_cooking:
vals.update({
order.write({
'is_cooking': True,
'order_status': vals.get('order_status',
'draft') if not order.order_status else order.order_status
'order_status': order.order_status or 'draft'
})
elif not has_kitchen_items and order.is_cooking:
vals.update({'is_cooking': False})
# Send notification only if order_status changed
if has_kitchen_items and order.id in original_statuses and 'order_status' in vals:
if original_statuses[order.id] != vals.get('order_status'):
message = {
'res_model': self._name,
'message': 'pos_order_updated',
'order_id': order.id,
'config_id': order.config_id.id,
'new_status': vals.get('order_status')
'lines': order.lines.read([
'id', 'product_id', 'qty', 'order_status', 'is_cooking'
])
}
channel = f'pos_order_created_{order.config_id.id}'
self.env["bus.bus"]._sendone(channel, "notification",
message)
return super(PosOrder, self).write(vals)
self.env["bus.bus"]._sendone(channel, "notification", message)
return res
@api.model
def get_details(self, shop_id, *args, **kwargs):
"""Method to fetch kitchen orders for display on the kitchen screen."""
kitchen_screen = self.env["kitchen.screen"].sudo().search(
[("pos_config_id", "=", shop_id)])
if not kitchen_screen:
return {"orders": [], "order_lines": []}
pos_orders = self.env["pos.order"].search([
("is_cooking", "=", True),
("config_id", "=", shop_id),
("state", "not in", ["cancel", "paid"]),
("order_status", "!=", "cancel"),
"|", "|",
("order_status", "=", "draft"),
("order_status", "=", "waiting"),
("order_status", "=", "ready")
("state", "not in", ["cancel"]),
("order_status", "in", ["draft", "waiting", "ready"])
], order="date_order")
# Additional filtering to exclude paid orders with 'ready' status
pos_orders = pos_orders.filtered(lambda order: not (
order.state == "paid" and order.order_status == "ready"))
pos_lines = pos_orders.lines.filtered(
lambda line: line.is_cooking and any(
categ.id in kitchen_screen.pos_categ_ids.ids
for categ in line.product_id.pos_categ_ids
)
)
values = {"orders": pos_orders.read(), "order_lines": pos_lines.read()}
user_tz_str = self.env.user.tz or 'UTC'
user_tz = pytz.timezone(user_tz_str)
utc = pytz.utc
for value in values['orders']:
if value.get('table_id'):
value['floor'] = value['table_id'][1].split(',')[0].strip()
date_str = value['date_order']
try:
if isinstance(date_str, str):
@ -223,7 +178,6 @@ class PosOrder(models.Model):
utc_dt = utc.localize(utc_dt)
else:
utc_dt = utc.localize(value['date_order'])
local_dt = utc_dt.astimezone(user_tz)
value['hour'] = local_dt.hour
value['formatted_minutes'] = f"{local_dt.minute:02d}"
@ -232,13 +186,11 @@ class PosOrder(models.Model):
value['hour'] = 0
value['minutes'] = 0
value['formatted_minutes'] = "00"
return values
def action_pos_order_paid(self):
"""Inherited method called when a POS order transitions to 'paid' state."""
res = super().action_pos_order_paid()
kitchen_screen = self.env["kitchen.screen"].search(
[("pos_config_id", "=", self.config_id.id)], limit=1
)
@ -251,16 +203,13 @@ class PosOrder(models.Model):
order_line.product_id.pos_categ_ids):
order_line.write({'is_cooking': True})
has_kitchen_items = True
if has_kitchen_items:
vals.update({
'is_cooking': True,
'order_ref': self.name,
# Only set order_status to 'draft' if it’s not already set
'order_status': self.order_status or 'draft'
'order_status': 'ready'
})
self.write(vals)
message = {
'res_model': self._name,
'message': 'pos_order_created',
@ -269,7 +218,6 @@ class PosOrder(models.Model):
}
channel = f'pos_order_created_{self.config_id.id}'
self.env["bus.bus"]._sendone(channel, "notification", message)
return res
@api.onchange("order_status")
@ -303,7 +251,6 @@ class PosOrder(models.Model):
self.order_status = "cancel"
for line in self.lines:
line.order_status = "cancel"
message = {
'res_model': self._name,
'message': 'pos_order_cancelled',
@ -324,7 +271,6 @@ class PosOrder(models.Model):
if line.product_id.pos_categ_ids and any(
cat.id in kitchen_screen.pos_categ_ids.ids for cat in line.product_id.pos_categ_ids):
line.order_status = "ready"
message = {
'res_model': self._name,
'message': 'pos_order_completed',
@ -341,13 +287,10 @@ class PosOrder(models.Model):
[('pos_reference', '=', str(order_name))], limit=1)
if not pos_order:
return False
kitchen_screen = self.env['kitchen.screen'].sudo().search(
[("pos_config_id", "=", pos_order.config_id.id)], limit=1)
if not kitchen_screen:
return False
unhandled_categories = []
for line in pos_order.lines:
if line.product_id.pos_categ_ids and not any(
@ -356,119 +299,122 @@ class PosOrder(models.Model):
[c.name for c in line.product_id.pos_categ_ids if c.id not in kitchen_screen.pos_categ_ids.ids])
if unhandled_categories:
return {'category': ", ".join(list(set(unhandled_categories)))}
if pos_order.order_status not in ['ready', 'cancel']:
return True
else:
return False
@api.model
def create_or_update_kitchen_order(self, orders_data):
"""Create new kitchen order or update existing one with new items."""
for order_data in orders_data:
def process_order_for_kitchen(self, order_data):
"""Process already created POS order for kitchen screen display."""
pos_reference = order_data.get('pos_reference')
existing_order = self.search(
[('pos_reference', '=', pos_reference)], limit=1)
config_id = order_data.get('config_id')
pos_order = self.search([
('name', '=', f"Order {pos_reference}"),
('config_id', '=', config_id)
], limit=1)
if not pos_order:
return False
kitchen_screen = self.env["kitchen.screen"].search([
("pos_config_id", "=", order_data.get('config_id'))
("pos_config_id", "=", config_id)
], limit=1)
if not kitchen_screen:
continue
if existing_order:
if existing_order.order_status in ['ready', 'cancel']:
continue
current_status = existing_order.order_status or 'draft'
existing_line_products = {line.product_id.id: line for line in existing_order.lines}
for line_data in order_data.get('lines', []):
line_vals = line_data[2]
product_id = line_vals.get('product_id')
qty = line_vals.get('qty', 1)
product = self.env['product.product'].browse(product_id)
if not (product.pos_categ_ids and any(
return False
kitchen_lines = []
for line in pos_order.lines:
product = line.product_id
if product.pos_categ_ids and any(
cat.id in kitchen_screen.pos_categ_ids.ids
for cat in product.pos_categ_ids
)):
continue
if product_id in existing_line_products:
existing_line = existing_line_products[product_id]
if existing_line.qty != qty:
existing_line.write({'qty': qty})
else:
self.env['pos.order.line'].create({
'order_id': existing_order.id,
'product_id': product_id,
'qty': qty,
'price_unit': line_vals.get('price_unit'),
'price_subtotal': line_vals.get('price_subtotal'),
'price_subtotal_incl': line_vals.get('price_subtotal_incl'),
'discount': line_vals.get('discount', 0),
'full_product_name': line_vals.get('full_product_name'),
for cat in product.pos_categ_ids):
kitchen_lines.append(line)
if not kitchen_lines:
return False
for line in kitchen_lines:
line.write({
'is_cooking': True,
'order_status': current_status,
'note': line_vals.get('note', ''),
'order_status': 'draft'
})
existing_order.write({
'amount_total': order_data.get('amount_total'),
'amount_tax': order_data.get('amount_tax'),
pos_order.write({
'is_cooking': True,
'order_status': 'draft'
})
message = {
'res_model': 'pos.order',
'message': 'pos_order_updated',
'config_id': config_id,
'order_id': pos_order.id,
'pos_reference': pos_reference
}
channel = f'pos_order_created_{config_id}'
self.env["bus.bus"]._sendone(channel, "notification", message)
return True
else:
kitchen_lines = []
for line_data in order_data.get('lines', []):
line_vals = line_data[2]
product_id = line_vals.get('product_id')
product = self.env['product.product'].browse(product_id)
if product.pos_categ_ids and any(
@api.model
def get_kitchen_orders(self, config_id):
"""Get all orders that have kitchen items for the kitchen screen."""
kitchen_screen = self.env["kitchen.screen"].search([
("pos_config_id", "=", config_id)
], limit=1)
if not kitchen_screen:
return []
kitchen_orders = self.search([
('config_id', '=', config_id),
('is_cooking', '=', True),
('order_status', 'not in', ['ready', 'cancel'])
])
orders_data = []
for order in kitchen_orders:
# Get only kitchen lines
kitchen_lines = order.lines.filtered(lambda l:
l.product_id.pos_categ_ids and any(
cat.id in kitchen_screen.pos_categ_ids.ids
for cat in product.pos_categ_ids
):
kitchen_lines.append([0, 0, {
'product_id': product_id,
'qty': line_vals.get('qty', 1),
'price_unit': line_vals.get('price_unit'),
'price_subtotal': line_vals.get('price_subtotal'),
'price_subtotal_incl': line_vals.get('price_subtotal_incl'),
'discount': line_vals.get('discount', 0),
'full_product_name': line_vals.get('full_product_name'),
'is_cooking': True,
'order_status': order_data.get('order_status', 'draft'),
'note': line_vals.get('note', ''),
}])
for cat in
l.product_id.pos_categ_ids
)
)
if kitchen_lines:
self.create({
'pos_reference': pos_reference,
'session_id': order_data.get('session_id'),
'amount_total': order_data.get('amount_total'),
'amount_paid': order_data.get('amount_paid', 0),
'amount_return': order_data.get('amount_return', 0),
'amount_tax': order_data.get('amount_tax'),
'lines': kitchen_lines,
'is_cooking': True,
'order_status': order_data.get('order_status', 'draft'),
'company_id': order_data.get('company_id'),
'table_id': order_data.get('table_id'),
'config_id': order_data.get('config_id'),
'state': 'draft',
'name': self.env['ir.sequence'].next_by_code('pos.order') or '/',
line_data = []
for line in kitchen_lines:
line_data.append({
'id': line.id,
'product_id': line.product_id.id,
'product_name': line.product_id.name,
'qty': line.qty,
'note': line.note or '',
'order_status': line.order_status or 'draft'
})
orders_data.append({
'id': order.id,
'pos_reference': order.pos_reference,
'name': order.name,
'table_id': order.table_id.id if order.table_id else False,
'table_name': order.table_id.name if order.table_id else '',
'order_status': order.order_status,
'lines': line_data,
'date_order': order.date_order,
'amount_total': order.amount_total
})
return orders_data
@api.model
def update_kitchen_order_status(self, order_id, status):
"""Update kitchen order status."""
order = self.browse(order_id)
if order.exists():
order.write({'order_status': status})
kitchen_lines = order.lines.filtered(lambda l: l.is_cooking)
kitchen_lines.write({'order_status': status})
message = {
'res_model': 'pos.order',
'message': 'pos_order_updated',
'config_id': order_data.get('config_id')
'message': 'kitchen_order_status_updated',
'config_id': order.config_id.id,
'order_id': order.id,
'status': status
}
channel = f'pos_order_created_{order_data.get("config_id")}'
channel = f'pos_order_created_{order.config_id.id}'
self.env["bus.bus"]._sendone(channel, "notification", message)
return True
return False
@api.model
def check_order_status(self, dummy_param, order_reference):
@ -476,7 +422,6 @@ class PosOrder(models.Model):
pos_order = self.env['pos.order'].sudo().search([
('pos_reference', '=', str(order_reference))
], limit=1)
if not pos_order:
return True
return pos_order.order_status in ['draft', 'waiting']

BIN
pos_kitchen_screen_odoo/static/description/assets/screenshots/pos12.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

BIN
pos_kitchen_screen_odoo/static/description/assets/screenshots/pos13.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
pos_kitchen_screen_odoo/static/description/assets/screenshots/pos14.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

90
pos_kitchen_screen_odoo/static/description/index.html

@ -832,6 +832,96 @@
</div>
</div>
</div>
<div class="position-relative mb-4"
style="border-radius:10px; background-color:#f4f4f4">
<div class="p-md-5 p-3 position-relative">
<div class="row">
<div class="col-md-12">
<h1 style="font-weight:bold; font-size:calc(1.1rem + 1vw); line-height:120%; text-align:center; text-transform:capitalize; font-size: 40px;
font-weight: 700;">
<!-- <span style="color:#121212; font-size:calc(1.1rem + 1vw)">Store Backup to-->
<!---->
<!-- </span>-->
<!-- <span style="color: var(&#45;&#45;primary-color); font-size:calc(1.1rem + 1vw)">AmazonS3.</span>-->
</h1>
</div>
<div class="col-md-12 mb-4">
<p style="font-weight:400; font-size:16px; line-height:150%; text-align:center; color:var(--text-color-light)">
Adding preparation time to the product variant</p>
</div>
<div class="col-md-12 text-center">
<div class="d-inline-block p-3 shadow-sm"
style="background-color:#fff; border-radius:10px">
<img alt="" class="img-fluid"
loading="lazy"
src="./assets/screenshots/pos12.png"
style="min-height: 1px;">
</div>
</div>
</div>
</div>
</div>
<div class="position-relative mb-4"
style="border-radius:10px; background-color:#f4f4f4">
<div class="p-md-5 p-3 position-relative">
<div class="row">
<div class="col-md-12">
<h1 style="font-weight:bold; font-size:calc(1.1rem + 1vw); line-height:120%; text-align:center; text-transform:capitalize; font-size: 40px;
font-weight: 700;">
<!-- <span style="color:#121212; font-size:calc(1.1rem + 1vw)">Store Backup to-->
<!---->
<!-- </span>-->
<!-- <span style="color: var(&#45;&#45;primary-color); font-size:calc(1.1rem + 1vw)">AmazonS3.</span>-->
</h1>
</div>
<div class="col-md-12 mb-4">
<p style="font-weight:400; font-size:16px; line-height:150%; text-align:center; color:var(--text-color-light)">
Enable the Change Stage field to update the cooking stage automatically once the preparation time is completed.</p>
</div>
<div class="col-md-12 text-center">
<div class="d-inline-block p-3 shadow-sm"
style="background-color:#fff; border-radius:10px">
<img alt="" class="img-fluid"
loading="lazy"
src="./assets/screenshots/pos13.png"
style="min-height: 1px;">
</div>
</div>
</div>
</div>
</div>
<div class="position-relative mb-4"
style="border-radius:10px; background-color:#f4f4f4">
<div class="p-md-5 p-3 position-relative">
<div class="row">
<div class="col-md-12">
<h1 style="font-weight:bold; font-size:calc(1.1rem + 1vw); line-height:120%; text-align:center; text-transform:capitalize; font-size: 40px;
font-weight: 700;">
<!-- <span style="color:#121212; font-size:calc(1.1rem + 1vw)">Store Backup to-->
<!---->
<!-- </span>-->
<!-- <span style="color: var(&#45;&#45;primary-color); font-size:calc(1.1rem + 1vw)">AmazonS3.</span>-->
</h1>
</div>
<div class="col-md-12 mb-4">
<p style="font-weight:400; font-size:16px; line-height:150%; text-align:center; color:var(--text-color-light)">
In the kitchen screen, after adding a product with preparation time,
it will be displayed under the Cooking stage. Once accepted,
it moves to the Ready stage and the countdown starts.
After the countdown is completed, it automatically moves to the Completed stage.</p>
</div>
<div class="col-md-12 text-center">
<div class="d-inline-block p-3 shadow-sm"
style="background-color:#fff; border-radius:10px">
<img alt="" class="img-fluid"
loading="lazy"
src="./assets/screenshots/pos14.png"
style="min-height: 1px;">
</div>
</div>
</div>
</div>
</div>
</div>
<div aria-labelledby="feature-tab"
class="tab-pane fade show py-1" id="feature"

5
pos_kitchen_screen_odoo/static/src/js/fields_load.js

@ -15,6 +15,11 @@ patch(PosStore.prototype, {
this.pos_orders = loadedData['pos.order'];
this.pos_order_lines = loadedData['pos.order.line'];
},
createNewOrder() {
const order = super.createNewOrder(...arguments);
return order
}
});

94
pos_kitchen_screen_odoo/static/src/js/order_button.js

@ -6,25 +6,26 @@ import { AlertDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { _t } from "@web/core/l10n/translation";
import { TicketScreen } from "@point_of_sale/app/screens/ticket_screen/ticket_screen";
/**
* @props partner
*/
patch(ActionpadWidget.prototype, {
setup() {
setup() {
super.setup();
this.orm = useService("orm");
console.log("ActionpadWidget")
},
get swapButton() {
return (
this.pos.config.module_pos_restaurant && this.pos.mainScreen.component !== TicketScreen
);
},
get currentOrder() {
return this.pos.get_order();
},
get swapButtonClasses() {
return {
"highlight btn-primary justify-content-between": this.displayCategoryCount.length,
@ -32,11 +33,11 @@ setup() {
altlight: !this.hasChangesToPrint && this.currentOrder?.hasSkippedChanges(),
};
},
async submitOrder() {
var line = []
var self = this;
if (!this.clicked) {
this.clicked = true;
if (!this.uiState.clicked) {
this.uiState.clicked = true;
try {
await self.orm.call("pos.order", "check_order_status", ["", this.pos.get_order().pos_reference]).then(function(result){
if (result == false){
@ -50,60 +51,29 @@ setup() {
self.kitchen_order_status = true
}
});
if (self.kitchen_order_status){
await this.pos.sendOrderInPreparationUpdateLastChange(this.currentOrder);
for (const orders of this.pos.get_order().lines) {
let actualQty = orders.qty || orders.quantity || orders.get_quantity() || 1;
line.push([0, 0, {
'qty': actualQty,
'price_unit': orders.price_unit,
'price_subtotal': orders.price_subtotal,
'price_subtotal_incl': orders.price_subtotal_incl,
'discount': orders.discount,
'product_id': orders.product_id.id,
'tax_ids': [
[6, 0, orders.tax_ids.map((tax) => tax.id)]
],
'id': orders.id,
'pack_lot_ids': [],
'full_product_name': orders.product_id.display_name,
'price_extra': orders.price_extra,
'name': orders.product_id.display_name,
'is_cooking': true,
'note': orders.note
}])
}
const date = new Date(self.currentOrder.date_order.replace(' ', 'T'));
var orders = [{
'pos_reference': this.pos.get_order().pos_reference,
'session_id': this.pos.get_order().session_id.id,
'amount_total': this.pos.get_order().amount_total,
'amount_paid': this.pos.get_order().amount_paid,
'amount_return': this.pos.get_order().amount_return,
'amount_tax': this.pos.get_order().amount_tax,
'lines': line,
'is_cooking': true,
'order_status': 'draft',
'company_id': this.pos.company.id,
'hour': date.getHours(),
'minutes': date.getMinutes(),
'table_id': this.pos.get_order().table_id.id,
'floor': this.pos.get_order().table_id.floor_id.name,
'config_id': this.pos.get_order().config_id.id
}]
await self.orm.call("pos.order", "create_or_update_kitchen_order", [orders]);
await this.processOrderForKitchen();
this.env.bus.trigger('pos-kitchen-screen-update');
}
} finally {
this.clicked = false;
this.uiState.clicked = false;
}
}
},
async processOrderForKitchen() {
var self = this;
const orderData = {
'pos_reference': this.pos.get_order().pos_reference,
'config_id': this.pos.get_order().config_id.id,
'table_id': this.pos.get_order().table_id.id,
'session_id': this.pos.get_order().session_id.id
};
this.pos.syncAllOrders()
await self.orm.call("pos.order", "process_order_for_kitchen", [orderData]);
},
hasQuantity(order) {
if (!order) {
return false;
@ -113,6 +83,7 @@ setup() {
);
}
},
get highlightPay() {
return (
this.currentOrder?.lines?.length &&
@ -120,14 +91,22 @@ setup() {
this.hasQuantity(this.currentOrder)
);
},
get hasChangesToPrint() {
let hasChange = this.pos.getOrderChanges();
hasChange =
hasChange.generalNote == ""
? true // for the case when removed all general note
: hasChange.count || hasChange.generalNote || hasChange.modeUpdate;
return hasChange;
},
get categoryCount() {
const orderChanges = this.getOrderChanges();
const linesChanges = orderChanges.orderlines;
const categories = Object.values(linesChanges).reduce((acc, curr) => {
const categories =
this.models["product.product"].get(curr.product_id)?.pos_categ_ids || [];
for (const category of categories.slice(0, 1)) {
if (!acc[category.id]) {
acc[category.id] = {
@ -138,18 +117,18 @@ setup() {
acc[category.id].count += curr.quantity;
}
}
return acc;
}, {});
return [
...Object.values(categories),
...("generalNote" in orderChanges ? [{ count: 1, name: _t("General Note") }] : []),
];
},
get displayCategoryCount() {
return this.pos.categoryCount.slice(0, 4);
},
get isCategoryCountOverflow() {
if (this.pos.categoryCount.length > 4) {
return true;
@ -157,3 +136,4 @@ setup() {
return false;
},
});

1
pos_kitchen_screen_odoo/static/src/js/order_pay.js

@ -20,6 +20,7 @@ import {
patch(PosStore.prototype, {
async setup(env) {
await super.setup(...arguments);
console.log("PosStore",PosStore)
this.kitchen = true;

2
sale_order_discount_approval_odoo/__manifest__.py

@ -21,7 +21,7 @@
#############################################################################
{
'name': 'Sale Order Discount Approval',
'version': '18.0.1.0.0',
'version': '18.0.1.0.1',
'category': 'Sales',
'summary': 'Sale Order Discount Approval based on the Allowed discount values',
'description': """Module for discount approval of sales orders if

4
sale_order_discount_approval_odoo/doc/RELEASE_NOTES.md

@ -4,3 +4,7 @@
#### Version 18.0.1.0.0
#### ADD
- Initial commit for Sale Order Discount Approval
#### 14.10.2024
#### Version 18.0.1.0.1
#### UPDATE
- Updated the workflow to set the approval stage by also comparing the global discount.

2
sale_order_discount_approval_odoo/models/__init__.py

@ -3,7 +3,7 @@
#
# Cybrosys Technologies Pvt. Ltd.
#
# Copyright (C) 2024-TODAY Cybrosys Technologies(<https://www.cybrosys.com>).
# Copyright (C) 2025-TODAY Cybrosys Technologies(<https://www.cybrosys.com>).
# Author: Cybrosys Techno Solutions(<https://www.cybrosys.com>)
#
# You can modify it under the terms of the GNU AFFERO

43
sale_order_discount_approval_odoo/models/sale_order.py

@ -35,19 +35,46 @@ class SaleOrder(models.Model):
help='The discount approver')
def action_confirm(self):
"""Method for confirming the sale order discount and sending mail for
the approver if approval limit crossed"""
"""Confirm sale order with discount approval check.
- If line/global discount exceeds user limit set 'waiting_for_approval'
- Else proceed normally.
"""
self.ensure_one()
res = super().action_confirm()
to_approve = False
discount_vals = self.order_line.mapped('discount')
user_discount = self.env.user.allow_discount
discount_product = self.env.company.sale_discount_product_id
approval_users = self.env.ref(
'sale_order_discount_approval_odoo.sale_order_discount_approval_odoo_group_manager').users
user_discount = self.env.user.allow_discount
if self.env.user.is_discount_control == True:
for rec in discount_vals:
if rec > user_discount:
# -------------------------------
# 1. Line-based discount check
# -------------------------------
if self.env.user.is_discount_control:
for line in self.order_line:
if line.discount > user_discount:
to_approve = True
break
# -------------------------------
# 2. Global discount check
# -------------------------------
if not to_approve and discount_product:
discount_line = self.order_line.filtered(lambda l: l.product_id == discount_product)
if discount_line:
discount_amount = abs(discount_line.price_unit)
# base total before discount
base_total = sum(
l.product_uom_qty * l.price_unit for l in self.order_line if l.product_id != discount_product)
if base_total > 0:
global_discount_pct = (discount_amount / base_total) * 100
if global_discount_pct > user_discount:
to_approve = True
# -------------------------------
# 3. Apply result
# -------------------------------
if to_approve:
action_id = self.env.ref(
'sale.action_quotations_with_onboarding').id
@ -75,6 +102,8 @@ class SaleOrder(models.Model):
self.state = 'waiting_for_approval'
return res
def action_waiting_approval(self):
"""Method for approving the sale order discount"""
self.approval_user_id = self.env.user.id

BIN
sale_order_discount_approval_odoo/static/description/assets/screenshots/s09.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

30
sale_order_discount_approval_odoo/static/description/index.html

@ -654,6 +654,36 @@
</div>
</div>
</div>
<div class="position-relative mb-4"
style="border-radius:10px; background-color:#f4f4f4">
<div class="p-md-5 p-3 position-relative">
<div class="row">
<div class="col-md-12">
<h1 style="font-weight:bold; font-size:calc(1.1rem + 1vw); line-height:120%; text-align:center; text-transform:capitalize; font-size: 40px;
font-weight: 700;">
<span style="color:#121212; font-size:calc(1.1rem + 1vw)">Check for
</span>
<span style="color: var(--primary-color); font-size:calc(1.1rem + 1vw)">Global Discount.</span>
</h1>
</div>
<div class="col-md-12 mb-4">
<p style="font-weight:400; font-size:16px; line-height:150%; text-align:center; color:var(--text-color-light)">
Discount will be checked for approval also
</p>
</div>
<div class="col-md-12 text-center">
<div class="d-inline-block p-3 shadow-sm"
style="background-color:#fff; border-radius:10px">
<img alt="" class="img-fluid"
loading="lazy"
src="./assets/screenshots/s09.png"
style="min-height: 1px;">
</div>
</div>
</div>
</div>
</div>
</div>
<div aria-labelledby="feature-tab"
class="tab-pane fade show py-1" id="feature"

Loading…
Cancel
Save