19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:29:53 +01:00
parent 6e54c1af6c
commit 3ca647e428
1087 changed files with 132065 additions and 108499 deletions

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import barcode_rule
@ -11,4 +10,6 @@ from . import pos_config
from . import pos_order_line
from . import pos_order
from . import pos_session
from . import res_config_settings
from . import product_product
from . import product_template
from . import res_partner

View file

@ -1,13 +1,26 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
from odoo import fields, models, api
class LoyaltyCard(models.Model):
_inherit = 'loyalty.card'
_name = 'loyalty.card'
_inherit = ['loyalty.card', 'pos.load.mixin']
source_pos_order_id = fields.Many2one('pos.order', "PoS Order Reference",
help="PoS order where this coupon was generated.")
source_pos_order_partner_id = fields.Many2one(
'res.partner', "PoS Order Customer",
related="source_pos_order_id.partner_id")
@api.model
def _load_pos_data_domain(self, data, config):
return False
@api.model
def _load_pos_data_fields(self, config):
return ['partner_id', 'code', 'points', 'points_display', 'program_id', 'expiration_date', 'write_date']
def _has_source_order(self):
return super()._has_source_order() or bool(self.source_pos_order_id)
@ -18,8 +31,8 @@ class LoyaltyCard(models.Model):
return self.env.ref('pos_loyalty.mail_coupon_template', False)
return super()._get_default_template()
def _get_mail_partner(self):
return super()._get_mail_partner() or self.sudo().source_pos_order_id.partner_id
def _mail_get_partner_fields(self, introspect_fields=False):
return super()._mail_get_partner_fields(introspect_fields=introspect_fields) + ['source_pos_order_partner_id']
def _get_signature(self):
return self.source_pos_order_id.user_id.signature or super()._get_signature()
@ -27,7 +40,28 @@ class LoyaltyCard(models.Model):
def _compute_use_count(self):
super()._compute_use_count()
read_group_res = self.env['pos.order.line']._read_group(
[('coupon_id', 'in', self.ids)], ['id'], ['coupon_id'])
count_per_coupon = {r['coupon_id'][0]: r['coupon_id_count'] for r in read_group_res}
[('coupon_id', 'in', self.ids)], ['coupon_id'], ['__count'])
count_per_coupon = {coupon.id: count for coupon, count in read_group_res}
for card in self:
card.use_count += count_per_coupon.get(card.id, 0)
@api.model
def get_gift_card_status(self, gift_code, config_id):
card = self.search([('code', '=', gift_code)], limit=1)
is_valid = card.exists() and (not card.expiration_date or card.expiration_date > fields.Date.today()) and card.points > 0
is_valid = is_valid and (card.program_id.program_type == 'gift_card') and not card.partner_id
is_valid = is_valid and len([id for id in card.history_ids.mapped('order_id') if id != 0]) == 0
card_fields = self._load_pos_data_fields(config_id)
return {
'status': bool(is_valid) or not card.exists(),
'data': {
'loyalty.card': card.read(card_fields, load=False),
}
}
@api.model
def get_loyalty_card_partner_by_code(self, code):
return self.env['loyalty.card'].search([
('code', '=', code),
('program_type', '=', 'loyalty'),
], limit=1).partner_id or False

View file

@ -3,6 +3,7 @@
from odoo import fields, models
class LoyaltyMail(models.Model):
_inherit = 'loyalty.mail'

View file

@ -1,21 +1,41 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, api, fields, models
from odoo.tools import unique
from odoo.exceptions import UserError
class LoyaltyProgram(models.Model):
_inherit = 'loyalty.program'
_name = 'loyalty.program'
_inherit = ['loyalty.program', 'pos.load.mixin']
# NOTE: `pos_config_ids` satisfies an excpeptional use case: when no PoS is specified, the loyalty program is
# applied to every PoS. You can access the loyalty programs of a PoS using _get_program_ids() of pos.config
pos_config_ids = fields.Many2many('pos.config', compute="_compute_pos_config_ids", store=True, readonly=False, string="Point of Sales", help="Restrict publishing to those shops.")
pos_config_ids = fields.Many2many('pos.config', compute="_compute_pos_config_ids", store=True, readonly=False, string="Point of Sales", help="Restrict publishing to those shops. Note: A program will only be used in the shops using the same currency as the program.")
pos_order_count = fields.Integer("PoS Order Count", compute='_compute_pos_order_count')
pos_ok = fields.Boolean("Point of Sale", default=True)
pos_report_print_id = fields.Many2one('ir.actions.report', string="Print Report", domain=[('model', '=', 'loyalty.card')], compute='_compute_pos_report_print_id', inverse='_inverse_pos_report_print_id', readonly=False,
help="This is used to print the generated gift cards from PoS.")
@api.model
def _load_pos_data_domain(self, data, config):
return [('id', 'in', config._get_program_ids().ids)]
@api.model
def _load_pos_data_fields(self, config):
return [
'name', 'trigger', 'applies_on', 'program_type', 'pricelist_ids', 'date_from',
'date_to', 'limit_usage', 'max_usage', 'total_order_count', 'is_nominative',
'portal_visible', 'portal_point_name', 'trigger_product_ids', 'rule_ids', 'reward_ids'
]
@api.model
def _load_pos_data_read(self, records, config):
return super()._load_pos_data_read(records.sudo(), config)
def _unrelevant_records(self, config):
valid_record = config._get_program_ids()
return self.filtered(lambda record: record.id not in valid_record.ids).ids
@api.depends("communication_plan_ids.pos_report_print_id")
def _compute_pos_report_print_id(self):
for program in self:
@ -30,7 +50,11 @@ class LoyaltyProgram(models.Model):
if not program.mail_template_id:
mail_template_label = program._fields.get('mail_template_id').get_description(self.env)['string']
pos_report_print_label = program._fields.get('pos_report_print_id').get_description(self.env)['string']
raise UserError(_("You must set '%s' before setting '%s'.", mail_template_label, pos_report_print_label))
raise UserError(_(
"You must set '%(mail_template)s' before setting '%(report)s'.",
mail_template=mail_template_label,
report=pos_report_print_label,
))
else:
if not program.communication_plan_ids:
program.communication_plan_ids = self.env['loyalty.mail'].create({
@ -53,30 +77,21 @@ class LoyaltyProgram(models.Model):
def _compute_pos_order_count(self):
query = """
WITH reward_to_orders_count AS (
SELECT reward.id AS lr_id,
COUNT(DISTINCT pos_order.id) AS orders_count
FROM pos_order_line line
JOIN pos_order ON line.order_id = pos_order.id
JOIN loyalty_reward reward ON line.reward_id = reward.id
GROUP BY lr_id
),
program_to_reward AS (
SELECT reward.id AS reward_id,
program.id AS program_id
FROM loyalty_program program
JOIN loyalty_reward reward ON reward.program_id = program.id
WHERE program.id = ANY (%s)
)
SELECT program_to_reward.program_id,
SUM(reward_to_orders_count.orders_count)
FROM program_to_reward
LEFT JOIN reward_to_orders_count ON reward_to_orders_count.lr_id = program_to_reward.reward_id
GROUP BY program_to_reward.program_id
SELECT program.id, SUM(orders_count)
FROM loyalty_program program
JOIN loyalty_reward reward ON reward.program_id = program.id
JOIN LATERAL (
SELECT COUNT(DISTINCT orders.id) AS orders_count
FROM pos_order orders
JOIN pos_order_line order_lines ON order_lines.order_id = orders.id
WHERE order_lines.reward_id = reward.id
) agg ON TRUE
WHERE program.id = ANY(%s)
GROUP BY program.id
"""
self._cr.execute(query, (self.ids,))
res = self._cr.dictfetchall()
res = {k['program_id']: k['sum'] for k in res}
self.env.cr.execute(query, (self.ids,))
res = self.env.cr.dictfetchall()
res = {k['id']: k['sum'] for k in res}
for rec in self:
rec.pos_order_count = res.get(rec.id) or 0
@ -85,16 +100,3 @@ class LoyaltyProgram(models.Model):
super()._compute_total_order_count()
for program in self:
program.total_order_count += program.pos_order_count
def action_view_pos_orders(self):
self.ensure_one()
pos_order_ids = list(unique(r['order_id'] for r in\
self.env['pos.order.line'].search_read([('reward_id', 'in', self.reward_ids.ids)], fields=['order_id'])))
return {
'name': _("PoS Orders"),
'view_mode': 'tree,form',
'res_model': 'pos.order',
'type': 'ir.actions.act_window',
'domain': [('id', 'in', pos_order_ids)],
'context': dict(self._context, create=False),
}

View file

@ -1,10 +1,16 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
from odoo import models, api
from odoo.fields import Domain
import ast
import json
class LoyaltyReward(models.Model):
_inherit = 'loyalty.reward'
_name = 'loyalty.reward'
_inherit = ['loyalty.reward', 'pos.load.mixin']
def _get_discount_product_values(self):
res = super()._get_discount_product_values()
@ -12,6 +18,75 @@ class LoyaltyReward(models.Model):
vals.update({'taxes_id': False})
return res
@api.model
def _load_pos_data_domain(self, data, config):
reward_product_tag_domain = [
('reward_product_tag_id', '!=', False),
'|',
('reward_product_tag_id.product_template_ids.active', '=', True),
('reward_product_tag_id.product_product_ids.active', '=', True),
]
return Domain.AND([
[('program_id', 'in', config._get_program_ids().ids)],
Domain.OR([
[('reward_type', '!=', 'product')],
[('reward_product_id.active', '=', True)],
reward_product_tag_domain,
]),
])
@api.model
def _load_pos_data_fields(self, config):
return ['description', 'program_id', 'reward_type', 'required_points', 'clear_wallet', 'currency_id',
'discount', 'discount_mode', 'discount_applicability', 'all_discount_product_ids', 'is_global_discount',
'discount_max_amount', 'discount_line_product_id', 'reward_product_id',
'multi_product', 'reward_product_ids', 'reward_product_qty', 'reward_product_uom_id', 'reward_product_domain']
@api.model
def _load_pos_data_read(self, records, config):
read_records = super()._load_pos_data_read(records, config)
for reward in read_records:
reward['reward_product_domain'] = self._replace_ilike_with_in(reward['reward_product_domain'])
return read_records
def _get_reward_product_domain_fields(self, config):
fields = set()
search_domain = [('program_id', 'in', config._get_program_ids().ids)]
domains = self.search_read(search_domain, fields=['reward_product_domain'], load=False)
for domain in filter(lambda d: d['reward_product_domain'] != "null", domains):
domain = json.loads(domain['reward_product_domain'])
for condition in self._parse_domain(domain).values():
field_name, _, _ = condition
fields.add(field_name)
return fields
def _replace_ilike_with_in(self, domain_str):
if domain_str == "null":
return domain_str
domain = json.loads(domain_str)
for index, condition in self._parse_domain(domain).items():
field_name, operator, value = condition
field = self.env['product.product']._fields.get(field_name)
if field and field.type == 'many2one' and operator in ('ilike', 'not ilike'):
comodel = self.env[field.comodel_name]
matching_ids = list(comodel._search([('display_name', 'ilike', value)]))
new_operator = 'in' if operator == 'ilike' else 'not in'
domain[index] = [field_name, new_operator, matching_ids]
return json.dumps(domain)
def _parse_domain(self, domain):
parsed_domain = {}
for index, condition in enumerate(domain):
if isinstance(condition, (list, tuple)) and len(condition) == 3:
parsed_domain[index] = condition
return parsed_domain
def unlink(self):
if len(self) == 1 and self.env['pos.order.line'].sudo().search_count([('reward_id', 'in', self.ids)], limit=1):
return self.action_archive()

View file

@ -1,12 +1,12 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
from odoo.osv import expression
from odoo.tools import ustr
from odoo.fields import Domain
class LoyaltyRule(models.Model):
_inherit = 'loyalty.rule'
_name = 'loyalty.rule'
_inherit = ['loyalty.rule', 'pos.load.mixin']
valid_product_ids = fields.Many2many(
'product.product', "Valid Products", compute='_compute_valid_product_ids',
@ -19,25 +19,31 @@ class LoyaltyRule(models.Model):
"This is automatically generated when the promo code is changed."
)
@api.depends('product_ids', 'product_category_id', 'product_tag_id') #TODO later: product tags
@api.model
def _load_pos_data_domain(self, data, config):
return [('program_id', 'in', config._get_program_ids().ids)]
@api.model
def _load_pos_data_fields(self, config):
return ['program_id', 'valid_product_ids', 'any_product', 'currency_id',
'reward_point_amount', 'reward_point_split', 'reward_point_mode',
'minimum_qty', 'minimum_amount', 'minimum_amount_tax_mode', 'mode', 'code']
@api.depends('product_ids', 'product_category_id', 'product_tag_id', 'product_domain') # TODO later: product tags
def _compute_valid_product_ids(self):
domain_products = {}
for rule in self:
if rule.product_ids or\
rule.product_category_id or\
rule.product_tag_id or\
rule.product_domain not in ('[]', "[['sale_ok', '=', True]]"):
domain = rule._get_valid_product_domain()
domain = expression.AND([[('available_in_pos', '=', True)], domain])
product_ids = domain_products.get(ustr(domain))
if product_ids is None:
product_ids = self.env['product.product'].search(domain, order="id")
domain_products[ustr(domain)] = product_ids
rule.valid_product_ids = product_ids
rule.any_product = False
for key, rules in self.grouped(lambda rule: (
tuple(rule.product_ids.ids),
rule.product_category_id.id,
rule.product_tag_id.id,
'' if rule.product_domain in ('[]', "[['sale_ok', '=', True]]") else rule.product_domain,
)).items():
if any(key):
domain = Domain.AND([[('available_in_pos', '=', True)], rules[:1]._get_valid_product_domain()])
rules.valid_product_ids = self.env['product.product'].search(domain, order="id")
rules.any_product = False
else:
rule.any_product = True
rule.valid_product_ids = self.env['product.product']
rules.valid_product_ids = self.env['product.product']
rules.any_product = True
@api.depends('code')
def _compute_promo_barcode(self):

View file

@ -4,22 +4,22 @@
from odoo import _, fields, models
from odoo.exceptions import UserError
class PosConfig(models.Model):
_inherit = 'pos.config'
gift_card_settings = fields.Selection(
[
("create_set", "Generate PDF cards"),
("scan_use", "Scan existing cards"),
],
string="Gift Cards settings",
default="create_set",
help="Defines the way you want to set your gift cards.",
)
# NOTE: this funtions acts as a m2m field with loyalty.program model. We do this to handle an excpetional use case:
# When no PoS is specified at a loyalty program form, this program is applied to every PoS (instead of none)
def _get_program_ids(self):
return self.env['loyalty.program'].search(['&', ('pos_ok', '=', True), '|', ('pos_config_ids', '=', self.id), ('pos_config_ids', '=', False)])
today = fields.Date.context_today(self)
return self.env['loyalty.program'].search([
('pos_ok', '=', True),
'|', ('pos_config_ids', '=', self.id), ('pos_config_ids', '=', False),
'|', ('date_from', '=', False), ('date_from', '<=', today),
'|', ('date_to', '=', False), ('date_to', '>=', today),
'|', ('pricelist_ids', '=', False), ('pricelist_ids', 'in', self.available_pricelist_ids.ids),
('currency_id', '=', self.currency_id.id)
]).filtered(lambda p: not p.limit_usage or p.sudo().total_order_count < p.max_usage)
def _check_before_creating_new_session(self):
self.ensure_one()
@ -49,7 +49,7 @@ class PosConfig(models.Model):
if invalid_reward_products_msg:
prefix_error_msg = _("To continue, make the following reward products available in Point of Sale.")
raise UserError(f"{prefix_error_msg}\n{invalid_reward_products_msg}")
raise UserError(f"{prefix_error_msg}\n{invalid_reward_products_msg}") # pylint: disable=missing-gettext
if gift_card_programs:
for gc_program in gift_card_programs:
# Do not allow a gift card program with more than one rule or reward, and check that they make sense
@ -63,23 +63,23 @@ class PosConfig(models.Model):
reward = gc_program.reward_ids
if reward.reward_type != 'discount' or reward.discount_mode != 'per_point' or reward.discount != 1:
raise UserError(_('Invalid gift card program reward. Use 1 currency per point discount.'))
if self.gift_card_settings == "create_set":
if not gc_program.mail_template_id:
raise UserError(_('There is no email template on the gift card program and your pos is set to print them.'))
if not gc_program.pos_report_print_id:
raise UserError(_('There is no print report on the gift card program and your pos is set to print them.'))
if not gc_program.mail_template_id:
raise UserError(_('There is no email template on the gift card program and your pos is set to print them.'))
if not gc_program.pos_report_print_id:
raise UserError(_('There is no print report on the gift card program and your pos is set to print them.'))
return super()._check_before_creating_new_session()
def use_coupon_code(self, code, creation_date, partner_id):
def use_coupon_code(self, code, creation_date, partner_id, pricelist_id):
self.ensure_one()
# Points desc so that in coupon mode one could use a coupon multiple times
coupon = self.env['loyalty.card'].search(
[('program_id', 'in', self._get_program_ids().ids),
'|', ('partner_id', 'in', (False, partner_id)), ('program_type', '=', 'gift_card'),
('code', '=', code)],
order='points desc', limit=1)
if not coupon or not coupon.program_id.active:
'|', ('partner_id', 'in', (False, partner_id)), ('program_type', '=', 'gift_card'),
('code', '=', code)],
order='partner_id, points desc', limit=1)
program = coupon.program_id
if not coupon or not program.active:
return {
'successful': False,
'payload': {
@ -87,29 +87,42 @@ class PosConfig(models.Model):
},
}
check_date = fields.Date.from_string(creation_date[:11])
if (coupon.expiration_date and coupon.expiration_date < check_date) or\
(coupon.program_id.date_to and coupon.program_id.date_to < fields.Date.context_today(self)) or\
(coupon.program_id.limit_usage and coupon.program_id.total_order_count >= coupon.program_id.max_usage):
today_date = fields.Date.context_today(self)
error_message = False
if (
(coupon.expiration_date and coupon.expiration_date < check_date)
or (program.date_to and program.date_to < today_date)
or (program.limit_usage and program.sudo().total_order_count >= program.max_usage)
):
error_message = _("This coupon is expired (%s).", code)
elif program.date_from and program.date_from > today_date:
error_message = _("This coupon is not yet valid (%s).", code)
elif (
not program.reward_ids or
not any(r.required_points <= coupon.points for r in program.reward_ids)
):
error_message = _("No reward can be claimed with this coupon.")
elif program.pricelist_ids and pricelist_id not in program.pricelist_ids.ids:
error_message = _("This coupon is not available with the current pricelist.")
elif coupon and program.program_type == 'promo_code':
error_message = _("This programs requires a code to be applied.")
if error_message:
return {
'successful': False,
'payload': {
'error_message': _('This coupon is expired (%s).', code),
},
}
if not coupon.program_id.reward_ids or not any(reward.required_points <= coupon.points for reward in coupon.program_id.reward_ids):
return {
'successful': False,
'payload': {
'error_message': _('No reward can be claimed with this coupon.'),
'error_message': error_message,
},
}
return {
'successful': True,
'payload': {
'program_id': coupon.program_id.id,
'program_id': program.id,
'coupon_id': coupon.id,
'coupon_partner_id': coupon.partner_id.id,
'points': coupon.points,
'points_display': coupon.points_display,
'has_source_order': coupon._has_source_order(),
},
}

View file

@ -6,6 +6,7 @@ from odoo import _, models
from odoo.tools import float_compare
import base64
class PosOrder(models.Model):
_inherit = 'pos.order'
@ -52,6 +53,26 @@ class PosOrder(models.Model):
'payload': {},
}
def add_loyalty_history_lines(self, coupon_data, coupon_updates):
id_mapping = {item['old_id']: int(item['id']) for item in coupon_updates}
history_lines_create_vals = []
for coupon in coupon_data:
card_id = id_mapping.get(int(coupon['card_id']), False) or int(coupon['card_id'])
if not self.env['loyalty.card'].browse(card_id).exists():
continue
issued = coupon['won']
cost = coupon['spent']
if (issued or cost) and card_id > 0:
history_lines_create_vals.append({
'card_id': card_id,
'order_model': self._name,
'order_id': self.id,
'description': _('Onsite %s', self.display_name),
'used': cost,
'issued': issued,
})
self.env['loyalty.history'].create(history_lines_create_vals)
def confirm_coupon_programs(self, coupon_data):
"""
This is called after the order is created.
@ -60,44 +81,38 @@ class PosOrder(models.Model):
It will also return the points of all concerned coupons to be updated in the cache.
"""
get_partner_id = lambda partner_id: partner_id and self.env['res.partner'].browse(partner_id).exists() and partner_id or False
# Keys are stringified when using rpc
coupon_data = {int(k): v for k, v in coupon_data.items()}
self._check_existing_loyalty_cards(coupon_data)
self._remove_duplicate_coupon_data(coupon_data)
self._process_existing_gift_cards(coupon_data)
# Map negative id to newly created ids.
coupon_new_id_map = {k: k for k in coupon_data.keys() if k > 0}
# Create the coupons that were awarded by the order.
coupons_to_create = {k: v for k, v in coupon_data.items() if k < 0 and not v.get('giftCardId')}
coupons_to_create = {k: v for k, v in coupon_data.items() if k < 0 and (v.get('points') or v.get('line_codes'))}
coupon_create_vals = [{
'program_id': p['program_id'],
'partner_id': p.get('partner_id', False),
'code': p.get('barcode') or self.env['loyalty.card']._generate_code(),
'partner_id': get_partner_id(p.get('partner_id', self.partner_id.id)),
'code': p.get('code') or p.get('barcode') or self.env['loyalty.card']._generate_code(),
'points': 0,
'expiration_date': p.get('date_to', False),
'source_pos_order_id': self.id,
'expiration_date': p.get('expiration_date')
} for p in coupons_to_create.values()]
# Pos users don't have the create permission
new_coupons = self.env['loyalty.card'].with_context(action_no_send_mail=True).sudo().create(coupon_create_vals)
# We update the gift card that we sold when the gift_card_settings = 'scan_use'.
gift_cards_to_update = [v for v in coupon_data.values() if v.get('giftCardId')]
updated_gift_cards = self.env['loyalty.card']
for coupon_vals in gift_cards_to_update:
gift_card = self.env['loyalty.card'].browse(coupon_vals.get('giftCardId'))
gift_card.write({
'points': coupon_vals['points'],
'source_pos_order_id': self.id,
'partner_id': coupon_vals.get('partner_id', False),
})
updated_gift_cards |= gift_card
# Map the newly created coupons
for old_id, new_id in zip(coupons_to_create.keys(), new_coupons):
coupon_new_id_map[new_id.id] = old_id
all_coupons = self.env['loyalty.card'].browse(coupon_new_id_map.keys()).exists()
# We need a sudo here because this can trigger `_compute_order_count` that require access to `sale.order.line`
all_coupons = self.env['loyalty.card'].sudo().browse(coupon_new_id_map.keys()).exists()
lines_per_reward_code = defaultdict(lambda: self.env['pos.order.line'])
for line in self.lines:
if not line.reward_identifier_code:
@ -115,12 +130,32 @@ class PosOrder(models.Model):
report_per_program = {}
coupon_per_report = defaultdict(list)
# Important to include the updated gift cards so that it can be printed. Check coupon_report.
for coupon in new_coupons | updated_gift_cards:
for coupon in new_coupons:
if coupon.program_id not in report_per_program:
report_per_program[coupon.program_id] = coupon.program_id.communication_plan_ids.\
filtered(lambda c: c.trigger == 'create').pos_report_print_id
for report in report_per_program[coupon.program_id]:
coupon_per_report[report.id].append(coupon.id)
# Adding loyalty history lines
loyalty_points = [
{
'order_id': self.id,
'card_id': coupon_id,
'spent': -coupon_vals['points'] if coupon_vals['points'] < 0 else 0,
'won': coupon_vals['points'] if coupon_vals['points'] > 0 else 0,
}
for coupon_id, coupon_vals in coupon_data.items()
]
coupon_updates = [
{
'id': coupon.id,
'old_id': coupon_new_id_map[coupon.id],
}
for coupon in all_coupons
]
self.add_loyalty_history_lines(loyalty_points, coupon_updates)
return {
'coupon_updates': [{
'old_id': coupon_new_id_map[coupon.id],
@ -132,7 +167,7 @@ class PosOrder(models.Model):
} for coupon in all_coupons if coupon.program_id.is_nominative],
'program_updates': [{
'program_id': program.id,
'usages': program.total_order_count,
'usages': program.sudo().total_order_count,
} for program in all_coupons.program_id],
'new_coupon_info': [{
'program_name': coupon.program_id.name,
@ -142,82 +177,117 @@ class PosOrder(models.Model):
coupon.program_id.applies_on == 'future'
# Don't send the coupon code for the gift card and ewallet programs.
# It should not be printed in the ticket.
and coupon.program_id.program_type not in ['gift_card', 'ewallet']
and coupon.program_id.sudo().program_type not in ['gift_card', 'ewallet']
)],
'coupon_report': coupon_per_report,
}
def _process_existing_gift_cards(self, coupon_data):
updated_gift_cards = self.env['loyalty.card']
coupon_key_to_remove = []
for coupon_id, coupon_vals in coupon_data.items():
program_id = self.env['loyalty.program'].browse(coupon_vals['program_id'])
if program_id.program_type == 'gift_card':
updated = False
gift_card = self.env['loyalty.card'].search([
('|'),
('code', '=', coupon_vals.get('code', '')),
('id', '=', coupon_vals.get('coupon_id', False))
])
if not gift_card.exists():
continue
if not gift_card.partner_id and self.partner_id:
updated = True
gift_card.partner_id = self.partner_id
gift_card.history_ids.create({
'card_id': gift_card.id,
'description': _('Assigning partner %s', self.partner_id.name),
'used': 0,
'issued': gift_card.points,
})
if len([id for id in gift_card.history_ids.mapped('order_id') if id != 0]) == 0:
updated = True
gift_card.source_pos_order_id = self.id
gift_card.history_ids.create({
'card_id': gift_card.id,
'order_model': self._name,
'order_id': self.id,
'description': _('Assigning order %s', self.display_name),
'used': 0,
'issued': gift_card.points,
})
if coupon_vals.get('points') != gift_card.points:
# Coupon vals contains negative points
updated = True
new_value = gift_card.points + coupon_vals['points']
gift_card.points = new_value
gift_card.history_ids.create({
'card_id': gift_card.id,
'order_model': self._name,
'order_id': self.id,
'description': _('Onsite %s', self.display_name),
'used': -coupon_vals['points'] if coupon_vals['points'] < 0 else 0,
'issued': coupon_vals['points'] if coupon_vals['points'] > 0 else 0,
})
if updated:
updated_gift_cards |= gift_card
coupon_key_to_remove.append(coupon_id)
for key in coupon_key_to_remove:
coupon_data.pop(key, None)
return updated_gift_cards
def _check_existing_loyalty_cards(self, coupon_data):
coupon_key_to_modify = []
for coupon_id, coupon_vals in coupon_data.items():
partner_id = coupon_vals.get('partner_id', False)
if partner_id:
partner_coupons = self.env['loyalty.card'].search(
[('partner_id', '=', partner_id), ('program_type', '=', 'loyalty')])
existing_coupon_for_program = partner_coupons.filtered(lambda c: c.program_id.id == coupon_vals['program_id'])
existing_coupon_for_program = self.env['loyalty.card'].search(
[('partner_id', '=', partner_id), ('program_type', 'in', ['loyalty', 'ewallet']), ('program_id', '=', coupon_vals['program_id'])])
if existing_coupon_for_program:
coupon_vals['coupon_id'] = existing_coupon_for_program[0].id
coupon_key_to_modify.append([coupon_id, existing_coupon_for_program[0].id])
for old_key, new_key in coupon_key_to_modify:
coupon_data[new_key] = coupon_data.pop(old_key)
def _remove_duplicate_coupon_data(self, coupon_data):
# to prevent duplicates, it is necessary to check if the history line already exists
items_to_remove = []
for coupon_id, coupon_vals in coupon_data.items():
existing_history = self.env['loyalty.history'].search_count([
('card_id.program_id', '=', coupon_vals['program_id']),
('order_model', '=', self._name),
('order_id', '=', self.id),
])
if existing_history:
items_to_remove.append(coupon_id)
for item in items_to_remove:
coupon_data.pop(item)
def _get_fields_for_order_line(self):
fields = super(PosOrder, self)._get_fields_for_order_line()
fields.extend(['is_reward_line', 'reward_id', 'coupon_id', 'reward_identifier_code', 'points_cost'])
return fields
def _prepare_order_line(self, order_line):
order_line = super()._prepare_order_line(order_line)
for f in ['reward_id', 'coupon_id']:
if order_line.get(f):
order_line[f] = order_line[f][0]
return order_line
def _add_activated_coupon_to_draft_orders(self, table_orders):
table_orders = super()._add_activated_coupon_to_draft_orders(table_orders)
for order in table_orders:
activated_coupon = []
rewards_list = [{
'reward_id': orderline[2]['reward_id'],
'coupon_id': orderline[2]['coupon_id']
} for orderline in order['lines'] if orderline[2]['is_reward_line'] and orderline[2]['reward_id']
]
order_reward_ids = self.env['loyalty.reward'].browse(set([reward_id['reward_id'] for reward_id in rewards_list]))
for reward in rewards_list:
order_reward_id = order_reward_ids.filtered(lambda order_reward: order_reward.id == reward['reward_id'])
if order_reward_id:
if order_reward_id.program_type in ['gift_card', 'ewallet']:
coupon_id = self.env['loyalty.card'].search([('id', '=', reward['coupon_id'])])
activated_coupon.append({
'balance': coupon_id.points,
'code': coupon_id.code,
'id': coupon_id.id,
'program_id': coupon_id.program_id.id,
})
order['codeActivatedCoupons'] = activated_coupon
return table_orders
def _add_mail_attachment(self, name, ticket):
attachment = super()._add_mail_attachment(name, ticket)
def _add_mail_attachment(self, name, ticket, basic_receipt):
attachment = super()._add_mail_attachment(name, ticket, basic_receipt)
gift_card_programs = self.config_id._get_program_ids().filtered(lambda p: p.program_type == 'gift_card' and
p.pos_report_print_id)
if gift_card_programs:
gift_cards = self.env['loyalty.card'].search([('source_pos_order_id', '=', self.id),
('program_id', 'in', gift_card_programs.mapped('id'))])
('program_id', 'in', gift_card_programs.ids)])
if gift_cards:
for program in gift_card_programs:
filtered_gift_cards = gift_cards.filtered(lambda gc: gc.program_id == program)
if filtered_gift_cards:
action_report = program.pos_report_print_id
report = action_report._render_qweb_pdf(action_report.report_name, filtered_gift_cards.mapped('id'))
report = action_report._render_qweb_pdf(action_report.report_name, filtered_gift_cards.ids)
filename = name + '.pdf'
gift_card_pdf = self.env['ir.attachment'].create({
'name': filename,

View file

@ -1,7 +1,8 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
from odoo import fields, models, api
class PosOrderLine(models.Model):
_inherit = 'pos.order.line'
@ -10,7 +11,7 @@ class PosOrderLine(models.Model):
help="Whether this line is part of a reward or not.")
reward_id = fields.Many2one(
'loyalty.reward', "Reward", ondelete='restrict',
help="The reward associated with this line.")
help="The reward associated with this line.", index='btree_not_null')
coupon_id = fields.Many2one(
'loyalty.card', "Coupon", ondelete='restrict',
help="The coupon used to claim that reward.")
@ -19,18 +20,8 @@ class PosOrderLine(models.Model):
""")
points_cost = fields.Float(help="How many point this reward cost on the coupon.")
def _order_line_fields(self, line, session_id=None):
res = super()._order_line_fields(line, session_id)
# coupon_id may be negative in case of new coupons, they will be added after validating the order.
if 'coupon_id' in res[2] and res[2]['coupon_id'] < 1:
res[2].pop('coupon_id')
return res
def _is_not_sellable_line(self):
return super().is_not_sellable_line() or self.reward_id
def _export_for_ui(self, orderline):
result = super()._export_for_ui(orderline)
result['is_reward_line'] = orderline.is_reward_line
result['reward_id'] = orderline.reward_id.id
return result
@api.model
def _load_pos_data_fields(self, config):
params = super()._load_pos_data_fields(config)
params += ['is_reward_line', 'reward_id', 'reward_identifier_code', 'points_cost', 'coupon_id']
return params

View file

@ -1,152 +1,13 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
from odoo.osv.expression import AND
import ast
import json
from odoo import models, api
class PosSession(models.Model):
_inherit = 'pos.session'
def _pos_ui_models_to_load(self):
result = super()._pos_ui_models_to_load()
if self.config_id._get_program_ids():
result += [
'loyalty.program',
'loyalty.rule',
'loyalty.reward',
]
return result
def _loader_params_loyalty_program(self):
return {
'search_params': {
'domain': [('id', 'in', self.config_id._get_program_ids().ids)],
'fields': ['name', 'trigger', 'applies_on', 'program_type', 'date_to', 'total_order_count',
'limit_usage', 'max_usage', 'is_nominative', 'portal_visible', 'portal_point_name', 'trigger_product_ids'],
},
}
def _loader_params_loyalty_rule(self):
return {
'search_params': {
'domain': [('program_id', 'in', self.config_id._get_program_ids().ids)],
'fields': ['program_id', 'valid_product_ids', 'any_product', 'currency_id',
'reward_point_amount', 'reward_point_split', 'reward_point_mode',
'minimum_qty', 'minimum_amount', 'minimum_amount_tax_mode', 'mode', 'code'],
}
}
def _loader_params_loyalty_reward(self):
domain_products = self.env['loyalty.reward']._get_active_products_domain()
return {
'search_params': {
'domain': AND([[('program_id', 'in', self.config_id._get_program_ids().ids)], domain_products]),
'fields': ['description', 'program_id', 'reward_type', 'required_points', 'clear_wallet', 'currency_id',
'discount', 'discount_mode', 'discount_applicability', 'all_discount_product_ids', 'is_global_discount',
'discount_max_amount', 'discount_line_product_id',
'multi_product', 'reward_product_ids', 'reward_product_qty', 'reward_product_uom_id', 'reward_product_domain'],
}
}
def _get_pos_ui_loyalty_program(self, params):
return self.env['loyalty.program'].search_read(**params['search_params'])
def _get_pos_ui_loyalty_rule(self, params):
return self.env['loyalty.rule'].search_read(**params['search_params'])
def _get_pos_ui_loyalty_reward(self, params):
rewards = self.env['loyalty.reward'].search_read(**params['search_params'])
for reward in rewards:
reward['reward_product_domain'] = self._replace_ilike_with_in(reward['reward_product_domain'])
return rewards
def _replace_ilike_with_in(self, domain_str):
if domain_str == "null":
return domain_str
domain = ast.literal_eval(domain_str)
for index, condition in enumerate(domain):
if isinstance(condition, (list, tuple)) and len(condition) == 3:
field_name, operator, value = condition
field = self.env['product.product']._fields.get(field_name)
if field and field.type == 'many2one' and operator in ('ilike', 'not ilike'):
comodel = self.env[field.comodel_name]
matching_ids = list(comodel._name_search(value, [], operator, limit=None))
new_operator = 'in' if operator == 'ilike' else 'not in'
domain[index] = [field_name, new_operator, matching_ids]
return json.dumps(domain)
def _get_pos_ui_product_product(self, params):
result = super()._get_pos_ui_product_product(params)
self = self.with_context(**params['context'])
rewards = self.config_id._get_program_ids().reward_ids
products = rewards.discount_line_product_id | rewards.reward_product_ids
products |= self.config_id._get_program_ids().filtered(lambda p: p.program_type == 'ewallet').trigger_product_ids
# Only load products that are not already in the result
products = list(set(products.ids) - set(product['id'] for product in result))
products = self.env['product.product'].search_read([('id', 'in', products)], fields=params['search_params']['fields'])
self._process_pos_ui_product_product(products)
result.extend(products)
return result
def _get_pos_ui_res_partner(self, params):
partners = super()._get_pos_ui_res_partner(params)
self._set_loyalty_cards(partners)
return partners
def get_pos_ui_res_partner_by_params(self, custom_search_params):
partners = super().get_pos_ui_res_partner_by_params(custom_search_params)
self._set_loyalty_cards(partners)
return partners
def _set_loyalty_cards(self, partners):
# Map partner_id to its loyalty cards from all loyalty programs.
loyalty_programs = self.config_id._get_program_ids().filtered(lambda p: p.program_type == 'loyalty')
loyalty_card_fields = ['points', 'code', 'program_id']
partner_id_to_loyalty_card = {}
for group in self.env['loyalty.card'].read_group(
domain=[('partner_id', 'in', [p['id'] for p in partners]), ('program_id', 'in', loyalty_programs.ids)],
fields=[f"{field_name}:array_agg" for field_name in loyalty_card_fields] + ["ids:array_agg(id)"],
groupby=['partner_id']
):
loyalty_cards = {}
for i in range(group['partner_id_count']):
loyalty_cards[group['ids'][i]] = {field_name: group[field_name][i] for field_name in loyalty_card_fields}
partner_id_to_loyalty_card[group['partner_id'][0]] = loyalty_cards
# Assign loyalty cards to each partner to load.
for partner in partners:
partner['loyalty_cards'] = partner_id_to_loyalty_card.get(partner['id'], {})
return partners
def _pos_data_process(self, loaded_data):
super()._pos_data_process(loaded_data)
# Additional post processing to link gift card and ewallet programs
# to their rules' products.
# Important because points from their products are only counted once.
product_id_to_program_ids = {}
for program in self.config_id._get_program_ids():
if program.program_type in ['gift_card', 'ewallet']:
for product in program.trigger_product_ids:
product_id_to_program_ids.setdefault(product['id'], [])
product_id_to_program_ids[product['id']].append(program['id'])
loaded_data['product_id_to_program_ids'] = product_id_to_program_ids
product_product_fields = self.env['product.product'].fields_get(self._loader_params_product_product()['search_params']['fields'])
loaded_data['field_types'] = {
'product.product': {f:v['type'] for f, v in product_product_fields.items()}
}
def _loader_params_product_product(self):
params = super()._loader_params_product_product()
# this is usefull to evaluate reward domain in frontend
params['search_params']['fields'].append('all_product_tag_ids')
return params
@api.model
def _load_pos_data_models(self, config):
data = super()._load_pos_data_models(config)
data += ['loyalty.program', 'loyalty.rule', 'loyalty.reward', 'loyalty.card']
return data

View file

@ -0,0 +1,18 @@
from odoo import api, models
class ProductProduct(models.Model):
_inherit = 'product.product'
@api.model
def _load_pos_data_fields(self, config):
params = super()._load_pos_data_fields(config)
params += ['all_product_tag_ids']
# add missing product fields used in the reward_product_domain
missing_fields = self.env['loyalty.reward']._get_reward_product_domain_fields(config) - set(params)
if missing_fields:
params.extend([field for field in missing_fields if field in self._fields])
return params

View file

@ -0,0 +1,45 @@
from odoo import models
class ProductTemplate(models.Model):
_inherit = 'product.template'
def _load_pos_data_search_read(self, data, config):
read_data = super()._load_pos_data_search_read(data, config)
rewards = config._get_program_ids().reward_ids
reward_products = rewards.discount_line_product_id | rewards.reward_product_ids | rewards.reward_product_id
trigger_products = config._get_program_ids().trigger_product_ids
loyalty_product_tmpl_ids = set((reward_products | trigger_products)._filtered_access('read').product_tmpl_id.ids)
already_loaded_product_tmpl_ids = {template['id'] for template in read_data}
missing_product_tmpl_ids = list(loyalty_product_tmpl_ids - already_loaded_product_tmpl_ids)
fields = self.env['product.template']._load_pos_data_fields(config)
missing_product_templates = self.env['product.template'].browse(missing_product_tmpl_ids).read(fields=fields, load=False)
product_ids_to_hide = reward_products.product_tmpl_id - self.env['product.template'].browse(already_loaded_product_tmpl_ids)
if self.env.context.get('pos_limited_loading', True):
# Filter out products that can be loaded in the PoS but are not loaded yet
product_ids_to_hide = product_ids_to_hide - product_ids_to_hide.filtered_domain(self._load_pos_data_domain(data, config))
config_data = data['pos.config'][0]
config_data['_pos_special_products_ids'] += product_ids_to_hide.product_variant_id.ids
# Identify special loyalty products (e.g., gift cards, e-wallets) to be displayed in the POS
loyality_products = config.get_record_by_ref([
'loyalty.gift_card_product_50',
'loyalty.ewallet_product_50',
])
special_display_products = self.env['product.product'].search([('id', 'in', loyality_products)])
# Include trigger products from loyalty programs of type 'gift_card' or 'ewallet'
special_display_products += self.env['loyalty.program'].search([
('program_type', 'in', ['ewallet']),
('pos_config_ids', 'in', [False, config.id]),
]).trigger_product_ids._filtered_access('read')
config_data['_pos_special_display_products_ids'] = special_display_products.product_tmpl_id.ids
read_data.extend(missing_product_templates)
return read_data

View file

@ -1,9 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import fields, models, api
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
pos_gift_card_settings = fields.Selection(related='pos_config_id.gift_card_settings', readonly=False)

View file

@ -0,0 +1,7 @@
from odoo import fields, models
class ResPartner(models.Model):
_inherit = 'res.partner'
loyalty_card_count = fields.Integer(groups='base.group_user,point_of_sale.group_pos_user')