loyalty_program.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578
  1. # -*- coding: utf-8 -*-
  2. # Part of Odoo. See LICENSE file for full copyright and licensing details.
  3. from collections import defaultdict
  4. from odoo import _, api, fields, models
  5. from odoo.exceptions import UserError, ValidationError
  6. from uuid import uuid4
  7. class LoyaltyProgram(models.Model):
  8. _name = 'loyalty.program'
  9. _description = 'Loyalty Program'
  10. _order = 'sequence'
  11. _rec_name = 'name'
  12. name = fields.Char('Program Name', required=True, translate=True)
  13. active = fields.Boolean(default=True)
  14. sequence = fields.Integer(copy=False)
  15. company_id = fields.Many2one('res.company', 'Company', default=lambda self: self.env.company)
  16. currency_id = fields.Many2one('res.currency', 'Currency', compute='_compute_currency_id',
  17. readonly=False, required=True, store=True, precompute=True)
  18. currency_symbol = fields.Char(related='currency_id.symbol')
  19. total_order_count = fields.Integer("Total Order Count", compute="_compute_total_order_count")
  20. rule_ids = fields.One2many('loyalty.rule', 'program_id', 'Conditional rules', copy=True,
  21. compute='_compute_from_program_type', readonly=False, store=True)
  22. reward_ids = fields.One2many('loyalty.reward', 'program_id', 'Rewards', copy=True,
  23. compute='_compute_from_program_type', readonly=False, store=True)
  24. communication_plan_ids = fields.One2many('loyalty.mail', 'program_id', copy=True,
  25. compute='_compute_from_program_type', readonly=False, store=True)
  26. # These fields are used for the simplified view of gift_card and ewallet
  27. mail_template_id = fields.Many2one('mail.template', compute='_compute_mail_template_id', inverse='_inverse_mail_template_id', string="Email template", readonly=False)
  28. trigger_product_ids = fields.Many2many(related='rule_ids.product_ids', readonly=False)
  29. coupon_ids = fields.One2many('loyalty.card', 'program_id')
  30. coupon_count = fields.Integer(compute='_compute_coupon_count')
  31. coupon_count_display = fields.Char(compute='_compute_coupon_count_display', string="Items")
  32. program_type = fields.Selection([
  33. ('coupons', 'Coupons'),
  34. ('gift_card', 'Gift Card'),
  35. ('loyalty', 'Loyalty Cards'),
  36. ('promotion', 'Promotions'),
  37. ('ewallet', 'eWallet'),
  38. ('promo_code', 'Discount Code'),
  39. ('buy_x_get_y', 'Buy X Get Y'),
  40. ('next_order_coupons', 'Next Order Coupons')],
  41. default='promotion', required=True,
  42. )
  43. date_to = fields.Date(string='Validity')
  44. limit_usage = fields.Boolean(string='Limit Usage')
  45. max_usage = fields.Integer()
  46. # Dictates when the points can be used:
  47. # current: if the order gives enough points on that order, the reward may directly be claimed, points lost otherwise
  48. # future: if the order gives enough points on that order, a coupon is generated for a next order
  49. # both: points are accumulated on the coupon to claim rewards, the reward may directly be claimed
  50. applies_on = fields.Selection([
  51. ('current', 'Current order'),
  52. ('future', 'Future orders'),
  53. ('both', 'Current & Future orders')], default='current', required=True,
  54. compute='_compute_from_program_type', readonly=False, store=True,
  55. )
  56. trigger = fields.Selection([
  57. ('auto', 'Automatic'),
  58. ('with_code', 'Use a code')],
  59. compute='_compute_from_program_type', readonly=False, store=True,
  60. help="""
  61. Automatic: Customers will be eligible for a reward automatically in their cart.
  62. Use a code: Customers will be eligible for a reward if they enter a code.
  63. """
  64. )
  65. portal_visible = fields.Boolean(default=False,
  66. help="""
  67. Show in web portal, PoS customer ticket, eCommerce checkout, the number of points available and used by reward.
  68. """)
  69. portal_point_name = fields.Char(default='Points', translate=True,
  70. compute='_compute_portal_point_name', readonly=False, store=True)
  71. is_nominative = fields.Boolean(compute='_compute_is_nominative')
  72. is_payment_program = fields.Boolean(compute='_compute_is_payment_program')
  73. payment_program_discount_product_id = fields.Many2one(
  74. 'product.product',
  75. string='Discount Product',
  76. compute='_compute_payment_program_discount_product_id',
  77. readonly=True,
  78. help="Product used in the sales order to apply the discount."
  79. )
  80. # Technical field used for a label
  81. available_on = fields.Boolean("Available On", store=False,
  82. help="""
  83. Manage where your program should be available for use.
  84. """
  85. )
  86. _sql_constraints = [
  87. ('check_max_usage', 'CHECK (limit_usage = False OR max_usage > 0)',
  88. 'Max usage must be strictly positive if a limit is used.'),
  89. ]
  90. @api.constrains('reward_ids')
  91. def _constrains_reward_ids(self):
  92. if self.env.context.get('loyalty_skip_reward_check'):
  93. return
  94. if any(not program.reward_ids for program in self):
  95. raise ValidationError(_('A program must have at least one reward.'))
  96. def _compute_total_order_count(self):
  97. self.total_order_count = 0
  98. @api.depends('coupon_count', 'program_type')
  99. def _compute_coupon_count_display(self):
  100. program_items_name = self._program_items_name()
  101. for program in self:
  102. program.coupon_count_display = "%i %s" % (program.coupon_count or 0, program_items_name[program.program_type] or '')
  103. @api.depends("communication_plan_ids.mail_template_id")
  104. def _compute_mail_template_id(self):
  105. for program in self:
  106. program.mail_template_id = program.communication_plan_ids.mail_template_id[:1]
  107. def _inverse_mail_template_id(self):
  108. for program in self:
  109. if program.program_type not in ("gift_card", "ewallet"):
  110. continue
  111. if not program.mail_template_id:
  112. program.communication_plan_ids = [(5, 0, 0)]
  113. elif not program.communication_plan_ids:
  114. program.communication_plan_ids = self.env['loyalty.mail'].create({
  115. 'program_id': program.id,
  116. 'trigger': 'create',
  117. 'mail_template_id': program.mail_template_id.id,
  118. })
  119. else:
  120. program.communication_plan_ids.write({
  121. 'trigger': 'create',
  122. 'mail_template_id': program.mail_template_id.id,
  123. })
  124. @api.depends('company_id')
  125. def _compute_currency_id(self):
  126. for program in self:
  127. program.currency_id = program.company_id.currency_id or program.currency_id
  128. @api.depends('coupon_ids')
  129. def _compute_coupon_count(self):
  130. read_group_data = self.env['loyalty.card']._read_group([('program_id', 'in', self.ids)], ['program_id'], ['program_id'])
  131. count_per_program = {r['program_id'][0]: r['program_id_count'] for r in read_group_data}
  132. for program in self:
  133. program.coupon_count = count_per_program.get(program.id, 0)
  134. @api.depends('program_type', 'applies_on')
  135. def _compute_is_nominative(self):
  136. for program in self:
  137. program.is_nominative = program.applies_on == 'both' or\
  138. (program.program_type == 'ewallet' and program.applies_on == 'future')
  139. @api.depends('program_type')
  140. def _compute_is_payment_program(self):
  141. for program in self:
  142. program.is_payment_program = program.program_type in ('gift_card', 'ewallet')
  143. @api.depends('reward_ids.discount_line_product_id')
  144. def _compute_payment_program_discount_product_id(self):
  145. for program in self:
  146. if program.is_payment_program:
  147. program.payment_program_discount_product_id = program.reward_ids[0].discount_line_product_id
  148. else:
  149. program.payment_program_discount_product_id = False
  150. @api.model
  151. def _program_items_name(self):
  152. return {
  153. 'coupons': _('Coupons'),
  154. 'promotion': _('Promos'),
  155. 'gift_card': _('Gift Cards'),
  156. 'loyalty': _('Loyalty Cards'),
  157. 'ewallet': _('eWallets'),
  158. 'promo_code': _('Discounts'),
  159. 'buy_x_get_y': _('Promos'),
  160. 'next_order_coupons': _('Coupons'),
  161. }
  162. @api.model
  163. def _program_type_default_values(self):
  164. # All values to change when program_type changes
  165. # NOTE: any field used in `rule_ids`, `reward_ids` and `communication_plan_ids` MUST be present in the kanban view for it to work properly.
  166. first_sale_product = self.env['product.product'].search([('sale_ok', '=', True)], limit=1)
  167. return {
  168. 'coupons': {
  169. 'applies_on': 'current',
  170. 'trigger': 'with_code',
  171. 'portal_visible': False,
  172. 'portal_point_name': _('Coupon point(s)'),
  173. 'rule_ids': [(5, 0, 0)],
  174. 'reward_ids': [(5, 0, 0), (0, 0, {
  175. 'required_points': 1,
  176. 'discount': 10,
  177. })],
  178. 'communication_plan_ids': [(5, 0, 0), (0, 0, {
  179. 'trigger': 'create',
  180. 'mail_template_id': (self.env.ref('loyalty.mail_template_loyalty_card', raise_if_not_found=False) or self.env['mail.template']).id,
  181. })],
  182. },
  183. 'promotion': {
  184. 'applies_on': 'current',
  185. 'trigger': 'auto',
  186. 'portal_visible': False,
  187. 'portal_point_name': _('Promo point(s)'),
  188. 'rule_ids': [(5, 0, 0), (0, 0, {
  189. 'reward_point_amount': 1,
  190. 'reward_point_mode': 'order',
  191. 'minimum_amount': 50,
  192. 'minimum_qty': 0,
  193. })],
  194. 'reward_ids': [(5, 0, 0), (0, 0, {
  195. 'required_points': 1,
  196. 'discount': 10,
  197. })],
  198. 'communication_plan_ids': [(5, 0, 0)],
  199. },
  200. 'gift_card': {
  201. 'applies_on': 'future',
  202. 'trigger': 'auto',
  203. 'portal_visible': True,
  204. 'portal_point_name': self.env.company.currency_id.symbol,
  205. 'rule_ids': [(5, 0, 0), (0, 0, {
  206. 'reward_point_amount': 1,
  207. 'reward_point_mode': 'money',
  208. 'reward_point_split': True,
  209. 'product_ids': self.env.ref('loyalty.gift_card_product_50', raise_if_not_found=False),
  210. 'minimum_qty': 0,
  211. })],
  212. 'reward_ids': [(5, 0, 0), (0, 0, {
  213. 'reward_type': 'discount',
  214. 'discount_mode': 'per_point',
  215. 'discount': 1,
  216. 'discount_applicability': 'order',
  217. 'required_points': 1,
  218. 'description': _('Gift Card'),
  219. })],
  220. 'communication_plan_ids': [(5, 0, 0), (0, 0, {
  221. 'trigger': 'create',
  222. 'mail_template_id': (self.env.ref('loyalty.mail_template_gift_card', raise_if_not_found=False) or self.env['mail.template']).id,
  223. })],
  224. },
  225. 'loyalty': {
  226. 'applies_on': 'both',
  227. 'trigger': 'auto',
  228. 'portal_visible': True,
  229. 'portal_point_name': _('Loyalty point(s)'),
  230. 'rule_ids': [(5, 0, 0), (0, 0, {
  231. 'reward_point_mode': 'money',
  232. })],
  233. 'reward_ids': [(5, 0, 0), (0, 0, {
  234. 'discount': 5,
  235. 'required_points': 200,
  236. })],
  237. 'communication_plan_ids': [(5, 0, 0)],
  238. },
  239. 'ewallet': {
  240. 'trigger': 'auto',
  241. 'applies_on': 'future',
  242. 'portal_visible': True,
  243. 'portal_point_name': self.env.company.currency_id.symbol,
  244. 'rule_ids': [(5, 0, 0), (0, 0, {
  245. 'reward_point_amount': '1',
  246. 'reward_point_mode': 'money',
  247. 'product_ids': self.env.ref('loyalty.ewallet_product_50', raise_if_not_found=False),
  248. })],
  249. 'reward_ids': [(5, 0, 0), (0, 0, {
  250. 'reward_type': 'discount',
  251. 'discount_mode': 'per_point',
  252. 'discount': 1,
  253. 'discount_applicability': 'order',
  254. 'required_points': 1,
  255. 'description': _('eWallet'),
  256. })],
  257. 'communication_plan_ids': [(5, 0, 0)],
  258. },
  259. 'promo_code': {
  260. 'applies_on': 'current',
  261. 'trigger': 'with_code',
  262. 'portal_visible': False,
  263. 'portal_point_name': _('Discount point(s)'),
  264. 'rule_ids': [(5, 0, 0), (0, 0, {
  265. 'mode': 'with_code',
  266. 'code': 'PROMO_CODE_' + str(uuid4())[:4], # We should try not to trigger any unicity constraint
  267. 'minimum_qty': 0,
  268. })],
  269. 'reward_ids': [(5, 0, 0), (0, 0, {
  270. 'discount_applicability': 'specific',
  271. 'discount_product_ids': first_sale_product,
  272. 'discount_mode': 'percent',
  273. 'discount': 10,
  274. })],
  275. 'communication_plan_ids': [(5, 0, 0)],
  276. },
  277. 'buy_x_get_y': {
  278. 'applies_on': 'current',
  279. 'trigger': 'auto',
  280. 'portal_visible': False,
  281. 'portal_point_name': _('Credit(s)'),
  282. 'rule_ids': [(5, 0, 0), (0, 0, {
  283. 'reward_point_mode': 'unit',
  284. 'product_ids': first_sale_product,
  285. 'minimum_qty': 2,
  286. })],
  287. 'reward_ids': [(5, 0, 0), (0, 0, {
  288. 'reward_type': 'product',
  289. 'reward_product_id': first_sale_product.id,
  290. 'required_points': 2,
  291. })],
  292. 'communication_plan_ids': [(5, 0, 0)],
  293. },
  294. 'next_order_coupons': {
  295. 'applies_on': 'future',
  296. 'trigger': 'auto',
  297. 'portal_visible': True,
  298. 'portal_point_name': _('Coupon point(s)'),
  299. 'rule_ids': [(5, 0, 0), (0, 0, {
  300. 'minimum_amount': 100,
  301. 'minimum_qty': 0,
  302. })],
  303. 'reward_ids': [(5, 0, 0), (0, 0, {
  304. 'reward_type': 'discount',
  305. 'discount_mode': 'percent',
  306. 'discount': 15,
  307. 'discount_applicability': 'order',
  308. })],
  309. 'communication_plan_ids': [(5, 0, 0), (0, 0, {
  310. 'trigger': 'create',
  311. 'mail_template_id': (
  312. self.env.ref('loyalty.mail_template_loyalty_card', raise_if_not_found=False)
  313. or self.env['mail.template']
  314. ).id,
  315. })],
  316. },
  317. }
  318. @api.depends('program_type')
  319. def _compute_from_program_type(self):
  320. program_type_defaults = self._program_type_default_values()
  321. grouped_programs = defaultdict(lambda: self.env['loyalty.program'])
  322. for program in self:
  323. grouped_programs[program.program_type] |= program
  324. for program_type, programs in grouped_programs.items():
  325. if program_type in program_type_defaults:
  326. programs.write(program_type_defaults[program_type])
  327. @api.depends("currency_id", "program_type")
  328. def _compute_portal_point_name(self):
  329. for program in self:
  330. if program.program_type not in ('ewallet', 'gift_card'):
  331. continue
  332. program.portal_point_name = program.currency_id.symbol or ''
  333. def _get_valid_products(self, products):
  334. '''
  335. Returns a dict containing the products that match per rule of the program
  336. '''
  337. rule_products = dict()
  338. for rule in self.rule_ids:
  339. domain = rule._get_valid_product_domain()
  340. if domain:
  341. rule_products[rule] = products.filtered_domain(domain)
  342. else:
  343. rule_products[rule] = products
  344. return rule_products
  345. def action_open_loyalty_cards(self):
  346. self.ensure_one()
  347. action = self.env['ir.actions.act_window']._for_xml_id("loyalty.loyalty_card_action")
  348. action['name'] = self._program_items_name()[self.program_type]
  349. action['display_name'] = action['name']
  350. action['context'] = {
  351. 'program_type': self.program_type,
  352. 'program_item_name': self._program_items_name()[self.program_type],
  353. 'default_program_id': self.id,
  354. # For the wizard
  355. 'default_mode': self.program_type == 'ewallet' and 'selected' or 'anonymous',
  356. }
  357. return action
  358. @api.ondelete(at_uninstall=False)
  359. def _unlink_except_active(self):
  360. if any(program.active for program in self):
  361. raise UserError(_('You can not delete a program in an active state'))
  362. def toggle_active(self):
  363. res = super().toggle_active()
  364. # Propagate active state to children
  365. for program in self.with_context(active_test=False):
  366. program.rule_ids.active = program.active
  367. program.reward_ids.active = program.active
  368. program.communication_plan_ids.active = program.active
  369. program.reward_ids.with_context(active_test=True).discount_line_product_id.active = program.active
  370. return res
  371. def write(self, vals):
  372. # There is an issue when we change the program type, since we clear the rewards and create new ones.
  373. # The orm actually does it in this order upon writing, triggering the constraint before creating the new rewards.
  374. # However we can check that the result of reward_ids would actually be empty or not, and if not, skip the constraint.
  375. if 'reward_ids' in vals and self._fields['reward_ids'].convert_to_cache(vals['reward_ids'], self):
  376. self = self.with_context(loyalty_skip_reward_check=True)
  377. # We need add the program type to the context to avoid getting the default value
  378. # ('discount') for reward type when calling the `default_get` method of
  379. #`loyalty.reward`.
  380. if 'program_type' in vals:
  381. self = self.with_context(program_type=vals['program_type'])
  382. return super().write(vals)
  383. else:
  384. for program in self:
  385. program = program.with_context(program_type=program.program_type)
  386. super(LoyaltyProgram, program).write(vals)
  387. return True
  388. else:
  389. return super().write(vals)
  390. @api.model
  391. def get_program_templates(self):
  392. '''
  393. Returns the templates to be used for promotional programs.
  394. '''
  395. ctx_menu_type = self.env.context.get('menu_type')
  396. if ctx_menu_type == 'gift_ewallet':
  397. return {
  398. 'gift_card': {
  399. 'title': _("Gift Card"),
  400. 'description': _("Sell Gift Cards, that can be used to purchase products."),
  401. 'icon': 'gift_card',
  402. },
  403. 'ewallet': {
  404. 'title': _("eWallet"),
  405. 'description': _("Fill in your eWallet, and use it to pay future orders."),
  406. 'icon': 'ewallet',
  407. },
  408. }
  409. return {
  410. 'promotion': {
  411. 'title': _("Promotion Program"),
  412. 'description': _(
  413. "Define promotions to apply automatically on your customers' orders."
  414. ),
  415. 'icon': 'promotional_program',
  416. },
  417. 'promo_code': {
  418. 'title': _("Discount Code"),
  419. 'description': _(
  420. "Share a discount code with your customers to create a purchase incentive."
  421. ),
  422. 'icon': 'promo_code',
  423. },
  424. 'buy_x_get_y': {
  425. 'title': _("Buy X Get Y"),
  426. 'description': _(
  427. "Offer Y to your customers if they are buying X; for example, 2+1 free."
  428. ),
  429. 'icon': '2_plus_1',
  430. },
  431. 'next_order_coupons': {
  432. 'title': _("Next Order Coupons"),
  433. 'description': _(
  434. "Reward your customers for a purchase with a coupon to use on their next order."
  435. ),
  436. 'icon': 'coupons',
  437. },
  438. 'loyalty': {
  439. 'title': _("Loyalty Cards"),
  440. 'description': _("Win points with each purchase, and use points to get gifts."),
  441. 'icon': 'loyalty_cards',
  442. },
  443. 'coupons': {
  444. 'title': _("Coupons"),
  445. 'description': _("Generate and share unique coupons with your customers."),
  446. 'icon': 'coupons',
  447. },
  448. 'fidelity': {
  449. 'title': _("Fidelity Cards"),
  450. 'description': _("Buy 10 products, and get 10$ discount on the 11th one."),
  451. 'icon': 'fidelity_cards',
  452. },
  453. }
  454. @api.model
  455. def create_from_template(self, template_id):
  456. '''
  457. Creates the program from the template id defined in `get_program_templates`.
  458. Returns an action leading to that new record.
  459. '''
  460. template_values = self._get_template_values()
  461. if template_id not in template_values:
  462. return False
  463. program = self.create(template_values[template_id])
  464. action = {}
  465. if self.env.context.get('menu_type') == 'gift_ewallet':
  466. action = self.env['ir.actions.act_window']._for_xml_id('loyalty.loyalty_program_gift_ewallet_action')
  467. action['views'] = [[False, 'form']]
  468. else:
  469. action = self.env['ir.actions.act_window']._for_xml_id('loyalty.loyalty_program_discount_loyalty_action')
  470. view_id = self.env.ref('loyalty.loyalty_program_view_form').id
  471. action['views'] = [[view_id, 'form']]
  472. action['view_mode'] = 'form'
  473. action['res_id'] = program.id
  474. return action
  475. @api.model
  476. def _get_template_values(self):
  477. '''
  478. Returns the values to create a program using the template keys defined above.
  479. '''
  480. program_type_defaults = self._program_type_default_values()
  481. # For programs that require a product get the first sellable.
  482. product = self.env['product.product'].search([('sale_ok', '=', True)], limit=1)
  483. return {
  484. 'gift_card': {
  485. 'name': _('Gift Card'),
  486. 'program_type': 'gift_card',
  487. **program_type_defaults['gift_card']
  488. },
  489. 'ewallet': {
  490. 'name': _('eWallet'),
  491. 'program_type': 'ewallet',
  492. **program_type_defaults['ewallet'],
  493. },
  494. 'loyalty': {
  495. 'name': _('Loyalty Cards'),
  496. 'program_type': 'loyalty',
  497. **program_type_defaults['loyalty'],
  498. },
  499. 'coupons': {
  500. 'name': _('Coupons'),
  501. 'program_type': 'coupons',
  502. **program_type_defaults['coupons'],
  503. },
  504. 'promotion': {
  505. 'name': _('Promotional Program'),
  506. 'program_type': 'promotion',
  507. **program_type_defaults['promotion'],
  508. },
  509. 'promo_code': {
  510. 'name': _('Discount code'),
  511. 'program_type': 'promo_code',
  512. **program_type_defaults['promo_code'],
  513. },
  514. 'buy_x_get_y': {
  515. 'name': _('2+1 Free'),
  516. 'program_type': 'buy_x_get_y',
  517. **program_type_defaults['buy_x_get_y'],
  518. },
  519. 'next_order_coupons': {
  520. 'name': _('Next Order Coupons'),
  521. 'program_type': 'next_order_coupons',
  522. **program_type_defaults['next_order_coupons'],
  523. },
  524. 'fidelity': {
  525. 'name': _('Fidelity Cards'),
  526. 'program_type': 'loyalty',
  527. 'applies_on': 'both',
  528. 'trigger': 'auto',
  529. 'rule_ids': [(0, 0, {
  530. 'reward_point_mode': 'unit',
  531. 'product_ids': product,
  532. })],
  533. 'reward_ids': [(0, 0, {
  534. 'discount_mode': 'per_order',
  535. 'required_points': 11,
  536. 'discount_applicability': 'specific',
  537. 'discount_product_ids': product,
  538. 'discount': 10,
  539. })]
  540. },
  541. }