[IMP] pos_loyalty: loyalty, gift card and ewallet programs

* Allow multiple loyalty programs.
* Improve gift card workflow: https://app.excalidraw.com/l/65VNwvy7c4X/1Xt93llULvo
* Integration with the ewallet programs: https://app.excalidraw.com/l/65VNwvy7c4X/4B9AEOSbaku

Note that we prevent refunding eWallet and gift card top up and
reward products. If for instance the user wants to refund gift cards
they'll have to transfer the gift card to ewallet by topping up the
customer's ewallet and paying with the gift card to be refunded.

closes odoo/odoo#100891

Task-ids: 2959620, 2959661
X-original-commit: fa06e3225650ed9d1a96709b0398e7f33313acf5
Signed-off-by: Trinh Jacky (trj) <trj@odoo.com>
Signed-off-by: Joseph Caburnay (jcb) <jcb@odoo.com>
This commit is contained in:
Joseph Caburnay
2022-09-23 00:39:01 +02:00
parent 3f91a17f1c
commit fa20b34d74
54 changed files with 1095 additions and 581 deletions
+3
View File
@@ -69,6 +69,9 @@ class LoyaltyCard(models.Model):
self.ensure_one()
return None
def _has_source_order(self):
return False
def action_coupon_send(self):
""" Open a window to compose an email, with the default template returned by `_get_default_template`
message loaded by default
+2 -1
View File
@@ -88,7 +88,8 @@ odoo.define('point_of_sale.Chrome', function(require) {
});
onError((error) => {
console.error(error);
// error is an OwlError object.
console.error(error.cause);
});
this.props.setupIsDone(this);
@@ -29,6 +29,7 @@ odoo.define('point_of_sale.TextInputPopup', function(require) {
title: '',
body: '',
startingValue: '',
placeholder: '',
};
Registries.Component.add(TextInputPopup);
@@ -98,7 +98,7 @@ odoo.define('point_of_sale.PartnerListScreen', function(require) {
return res
}
get isBalanceDisplayed() {
return this.env.pos.config.module_pos_loyalty;
return false;
}
get partnerLink() {
return `/web#model=res.partner&id=${this.state.editModeProps.partner.id}`;
@@ -176,7 +176,7 @@ odoo.define('point_of_sale.PartnerListScreen', function(require) {
}
async searchPartner() {
let result = await this.getNewPartners();
this.env.pos.db.add_partners(result);
this.env.pos.addPartners(result);
if (!this.env.pos.isEveryPartnerLoaded) await this.env.pos.updateIsEveryPartnerLoaded();
this.render(true);
return result;
@@ -190,15 +190,19 @@ odoo.define('point_of_sale.TicketScreen', function (require) {
}
async _onDoRefund() {
const order = this.getSelectedSyncedOrder();
if (this._doesOrderHaveSoleItem(order)) {
if (!this._prepareAutoRefundOnOrder(order)) {
// Don't proceed on refund if preparation returned false.
return;
}
}
if (!order) {
this._state.ui.highlightHeaderNote = !this._state.ui.highlightHeaderNote;
return;
}
if (this._doesOrderHaveSoleItem(order)) {
this._prepareAutoRefundOnOrder(order);
}
const partner = order.get_partner();
const allToRefundDetails = this._getRefundableDetails(partner);
@@ -415,13 +419,14 @@ odoo.define('point_of_sale.TicketScreen', function (require) {
_prepareAutoRefundOnOrder(order) {
const selectedOrderlineId = this.getSelectedOrderlineId();
const orderline = order.orderlines.find((line) => line.id == selectedOrderlineId);
if (!orderline) return;
if (!orderline) return false;
const toRefundDetail = this._getToRefundDetail(orderline);
const refundableQty = orderline.get_quantity() - orderline.refunded_qty;
if (this.env.pos.isProductQtyZero(refundableQty - 1)) {
toRefundDetail.qty = 1;
}
return true;
}
/**
* Returns the corresponding toRefundDetail of the given orderline.
+6 -6
View File
@@ -193,7 +193,7 @@ class PosGlobalState extends PosModel {
this._loadPoSConfig();
this.bills = loadedData['pos.bill'];
this.partners = loadedData['res.partner'];
this._loadResPartner();
this.addPartners(this.partners);
this.picking_type = loadedData['stock.picking.type'];
this.user = loadedData['res.users'];
this.pricelists = loadedData['product.pricelist'];
@@ -222,8 +222,8 @@ class PosGlobalState extends PosModel {
_loadPoSConfig() {
this.db.set_uuid(this.config.uuid);
}
_loadResPartner() {
this.db.add_partners(this.partners);
addPartners(partners) {
return this.db.add_partners(partners);
}
_assignApplicableItems(pricelist, correspondingProduct, pricelistItem) {
if (!(pricelist.id in correspondingProduct.applicablePricelistItems)) {
@@ -343,7 +343,7 @@ class PosGlobalState extends PosModel {
shadow: true,
})
.then(partners => {
if (this.db.add_partners(partners)) { // check if the partners we got were real updates
if (this.addPartners(partners)) { // check if the partners we got were real updates
resolve();
} else {
reject('Failed in updating partners.');
@@ -492,7 +492,7 @@ class PosGlobalState extends PosModel {
timeout: 3000,
shadow: true,
});
this.env.pos.db.add_partners(fetchedPartners);
this.addPartners(fetchedPartners);
}
}
async _loadMissingPartners(orders) {
@@ -540,7 +540,7 @@ class PosGlobalState extends PosModel {
],
context: this.env.session.user_context,
}, { shadow: true });
this.db.add_partners(partners);
this.addPartners(partners);
i += 1;
} while(partners.length);
}
@@ -6,11 +6,11 @@
<header class="title">
<t t-esc="props.title" />
</header>
<div class="div">
<div class="body">
<p>
<t t-esc="props.body" />
</p>
<input type="text" t-model="state.inputValue" t-ref="input" />
<input type="text" t-model="state.inputValue" t-ref="input" t-att-placeholder="props.placeholder"/>
</div>
<div class="footer">
<div class="button confirm highlight" t-on-click="confirm">
@@ -80,15 +80,16 @@ odoo.define('point_of_sale.tour.ProductScreenTourMethods', function (require) {
return keys.split(' ').map(generateStep);
}
clickPayButton() {
return [
{ content: 'click pay button', trigger: '.product-screen .actionpad .button.pay' },
{
clickPayButton(shouldCheck = true) {
const steps = [{ content: 'click pay button', trigger: '.product-screen .actionpad .button.pay' }];
if (shouldCheck) {
steps.push({
content: 'now in payment screen',
trigger: '.pos-content .payment-screen',
run: () => {},
},
];
});
}
return steps;
}
clickPartnerButton() {
@@ -1,4 +1,4 @@
odoo.define('pos_restaurant.tour.TextInputPopupTourMethods', function (require) {
odoo.define('point_of_sale.tour.TextInputPopupTourMethods', function (require) {
'use strict';
const { createTourMethods } = require('point_of_sale.tour.utils');
+2 -12
View File
@@ -26,21 +26,11 @@
'assets': {
'point_of_sale.assets': [
'pos_loyalty/static/src/css/Loyalty.scss',
'pos_loyalty/static/src/js/Loyalty.js',
'pos_loyalty/static/src/js/Orderline.js',
'pos_loyalty/static/src/js/OrderSummary.js',
'pos_loyalty/static/src/js/OrderWidget.js',
'pos_loyalty/static/src/js/PaymentScreen.js',
'pos_loyalty/static/src/js/ProductScreen.js',
'pos_loyalty/static/src/js/ControlButtons/GiftCardButton.js',
'pos_loyalty/static/src/js/ControlButtons/ResetProgramsButton.js',
'pos_loyalty/static/src/js/ControlButtons/PromoCodeButton.js',
'pos_loyalty/static/src/js/ControlButtons/RewardButton.js',
'pos_loyalty/static/src/js/Popups/GiftCardPopup.js',
'pos_loyalty/static/src/js/**/*',
'pos_loyalty/static/src/xml/**/*',
],
'web.assets_tests': [
'pos_loyalty/static/src/js/tours/**/*',
'pos_loyalty/static/src/tours/**/*',
],
},
'license': 'LGPL-3',
@@ -4,4 +4,8 @@
<field name="available_in_pos">True</field>
<field name="taxes_id" eval="False"/>
</record>
<record id="loyalty.ewallet_product_50" model="product.product">
<field name="available_in_pos">True</field>
<field name="taxes_id" eval="False"/>
</record>
</odoo>
+1
View File
@@ -5,6 +5,7 @@ from . import barcode_rule
from . import loyalty_card
from . import loyalty_mail
from . import loyalty_program
from . import loyalty_reward
from . import loyalty_rule
from . import pos_config
from . import pos_order_line
@@ -9,6 +9,9 @@ class LoyaltyCard(models.Model):
source_pos_order_id = fields.Many2one('pos.order', "PoS Order Reference",
help="PoS order where this coupon was generated.")
def _has_source_order(self):
return super()._has_source_order() or bool(self.source_pos_order_id)
def _get_default_template(self):
self.ensure_one()
if self.source_pos_order_id:
+1 -1
View File
@@ -10,7 +10,7 @@ class LoyaltyProgram(models.Model):
# 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)
pos_config_ids = fields.Many2many('pos.config', compute="_compute_pos_config_ids", store=True, readonly=False, help="Restrict publishing to those shops.")
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,
@@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class LoyaltyReward(models.Model):
_inherit = 'loyalty.reward'
def _get_discount_product_values(self):
res = super()._get_discount_product_values()
for vals in res:
vals.update({'taxes_id': False})
return res
+1
View File
@@ -104,5 +104,6 @@ class PosConfig(models.Model):
'coupon_id': coupon.id,
'coupon_partner_id': coupon.partner_id.id,
'points': coupon.points,
'has_source_order': coupon._has_source_order(),
},
}
+23 -3
View File
@@ -64,7 +64,7 @@ class PosOrder(models.Model):
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}
coupons_to_create = {k: v for k, v in coupon_data.items() if k < 0 and not v.get('giftCardId')}
coupon_create_vals = [{
'program_id': p['program_id'],
'partner_id': p.get('partner_id', False),
@@ -72,8 +72,22 @@ class PosOrder(models.Model):
'points': 0,
'source_pos_order_id': self.id,
} for p in coupons_to_create.values()]
# Pos users don't have the create permission
new_coupons = self.env['loyalty.card'].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
@@ -93,7 +107,8 @@ class PosOrder(models.Model):
# Reports per program
report_per_program = {}
coupon_per_report = defaultdict(list)
for coupon in new_coupons:
# Important to include the updated gift cards so that it can be printed. Check coupon_report.
for coupon in new_coupons | updated_gift_cards:
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
@@ -116,7 +131,12 @@ class PosOrder(models.Model):
'program_name': coupon.program_id.name,
'expiration_date': coupon.expiration_date,
'code': coupon.code,
} for coupon in new_coupons if coupon.program_id.applies_on == 'future'],
} for coupon in new_coupons if (
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']
)],
'coupon_report': coupon_per_report,
}
@@ -28,3 +28,9 @@ class PosOrderLine(models.Model):
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
+42 -67
View File
@@ -22,7 +22,7 @@ class PosSession(models.Model):
'search_params': {
'domain': [('id', 'in', self.config_id._get_program_ids().ids)],
'fields': ['name', 'trigger', 'applies_on', 'program_type', 'date_to',
'limit_usage', 'max_usage', 'is_nominative', 'portal_point_name'],
'limit_usage', 'max_usage', 'is_nominative', 'portal_visible', 'portal_point_name', 'trigger_product_ids'],
},
}
@@ -56,45 +56,6 @@ class PosSession(models.Model):
def _get_pos_ui_loyalty_reward(self, params):
return self.env['loyalty.reward'].search_read(**params['search_params'])
def _get_pos_ui_res_partner(self, params):
result = super()._get_pos_ui_res_partner(params)
return self._load_pos_ui_loyalty_points(result)
def get_pos_ui_res_partner_by_params(self, custom_search_params):
partners = super().get_pos_ui_res_partner_by_params(custom_search_params)
return self._load_pos_ui_loyalty_points(partners)
def _load_pos_ui_loyalty_points(self, result):
# In order to make loyalty programs work offline we load the partner's point into
# a non-existant field 'loyalty_points'.
loyalty_programs = self.config_id._get_program_ids().filtered(lambda program: program.program_type == 'loyalty')
if loyalty_programs:
# collect ids in a list, group by id and default points to 0
partner_ids = []
res_by_id = {}
for res in result:
partner_ids.append(res['id'])
res_by_id[res['id']] = res
res['loyalty_points'] = 0
res['loyalty_card_id'] = False
# Direct query to avoid loading loyalty cards in the cache for no reason.
# There is no context where we would need to flush.
query = self.env['loyalty.card']._search(
[('program_id', '=', loyalty_programs[0].id), ('partner_id', 'in', partner_ids)]
)
# query can be falsy
if not query:
return result
query_str, params = query.select('id', 'partner_id', 'points')
self.env.cr.execute(query_str, params)
for res in self.env.cr.dictfetchall():
# The result of where_calc also includes partner_id is null.
if not res.get('partner_id'):
continue
res_by_id[res['partner_id']]['loyalty_points'] = res['points']
res_by_id[res['partner_id']]['loyalty_card_id'] = res['id']
return result
def _loader_params_product_product(self):
result = super(PosSession, self)._loader_params_product_product()
config = self.config_id
@@ -115,34 +76,48 @@ class PosSession(models.Model):
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)
config = loaded_data['pos.config']
if not loaded_data.get('loyalty.program'):
return
programs = loaded_data['loyalty.program']
loyalty_programs, coupons, promotions, gift_cards = [], [], [], []
for program in programs:
if (program['program_type'] == 'loyalty'):
loyalty_programs.append(program)
if (program['program_type'] == 'coupon'):
coupons.append(program)
if (program['program_type'] == 'promotion'):
promotions.append(program)
if (program['program_type'] == 'gift_card'):
gift_cards.append(program)
# 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'])
# NOTE: the following keys are only used in PoS frontend
# TODO: remove them
loyalty_program = loyalty_programs[0] if len(loyalty_programs) > 0 else False
config['loyalty_program_id'] = loyalty_program and (loyalty_program['id'], loyalty_program['name'])
config['module_pos_loyalty'] = bool(loyalty_program)
config['coupon_program_ids'] = [(coupon['id'], coupon['name']) for coupon in coupons]
config['promo_program_ids'] = [(promotion['id'], promotion['name']) for promotion in promotions]
config['use_coupon_programs'] = len(coupons) + len(promotions) > 0
gift_card = gift_cards[0] if len(gift_cards) > 0 else False
config['gift_card_program_id'] = gift_card and (gift_card['id'], gift_card['name'])
config['use_gift_card'] = bool(gift_card)
loaded_data['product_id_to_program_ids'] = product_id_to_program_ids
@@ -1,28 +0,0 @@
/** @odoo-module **/
import PosComponent from 'point_of_sale.PosComponent';
import ProductScreen from 'point_of_sale.ProductScreen';
import Registries from 'point_of_sale.Registries';
import { useListener } from "@web/core/utils/hooks";
export class GiftCardButton extends PosComponent {
setup() {
super.setup()
useListener('click', this.onClick);
}
async onClick() {
this.showPopup('GiftCardPopup', {});
}
}
GiftCardButton.template = 'GiftCardButton';
ProductScreen.addControlButton({
component: GiftCardButton,
condition: function () {
return this.env.pos.config.use_gift_card;
},
});
Registries.Component.add(GiftCardButton);
@@ -12,12 +12,16 @@ export class PromoCodeButton extends PosComponent {
}
async onClick() {
const { confirmed, payload: code } = await this.showPopup('TextInputPopup', {
title: this.env._t('Enter Promotion or Coupon Code'),
let { confirmed, payload: code } = await this.showPopup('TextInputPopup', {
title: this.env._t('Enter Code'),
startingValue: '',
placeholder: this.env._t('Gift card or Discount code'),
});
if (confirmed && code !== '') {
this.env.pos.get_order().activateCode(code);
if (confirmed) {
code = code.trim();
if (code !== '') {
this.env.pos.get_order().activateCode(code);
}
}
}
}
@@ -27,7 +31,7 @@ PromoCodeButton.template = 'PromoCodeButton';
ProductScreen.addControlButton({
component: PromoCodeButton,
condition: function () {
return this.env.pos.config.use_coupon_programs;
return this.env.pos.programs.some(p => ['coupon', 'promotion', 'gift_card'].includes(p.program_type));
}
});
@@ -21,7 +21,7 @@ ResetProgramsButton.template = 'ResetProgramsButton';
ProductScreen.addControlButton({
component: ResetProgramsButton,
condition: function () {
return this.env.pos.config.use_coupon_programs;
return this.env.pos.programs.some(p => ['coupon', 'promotion'].includes(p.program_type));
}
});
@@ -28,7 +28,13 @@ export class RewardButton extends PosComponent {
_getPotentialRewards() {
const order = this.env.pos.get_order();
const rewards = order ? order.getClaimableRewards() : [];
// Claimable rewards excluding those from eWallet programs.
// eWallet rewards are handled in the eWalletButton.
let rewards = [];
if (order) {
const claimableRewards = order.getClaimableRewards();
rewards = claimableRewards.filter(({ reward }) => reward.program_id.program_type !== 'ewallet');
}
const discountRewards = rewards.filter(({ reward }) => reward.reward_type == 'discount');
const freeProductRewards = rewards.filter(({ reward }) => reward.reward_type == 'product');
const potentialFreeProductRewards = order.getPotentialFreeProductRewards();
@@ -119,7 +125,7 @@ RewardButton.template = 'RewardButton';
ProductScreen.addControlButton({
component: RewardButton,
condition: function() {
return this.env.pos.config.use_coupon_programs || this.env.pos.config.loyalty_program_id || this.env.pos.config.use_gift_card;
return this.env.pos.programs.length > 0;
}
});
@@ -0,0 +1,98 @@
/** @odoo-module **/
import PosComponent from 'point_of_sale.PosComponent';
import ProductScreen from 'point_of_sale.ProductScreen';
import Registries from 'point_of_sale.Registries';
export class eWalletButton extends PosComponent {
_getEWalletRewards(order) {
const claimableRewards = order.getClaimableRewards();
const eWalletRewards = claimableRewards.filter(({ reward }) => reward.program_id.program_type == 'ewallet');
return eWalletRewards;
}
_getEWalletPrograms() {
return this.env.pos.programs.filter((p) => p.program_type == 'ewallet');
}
async _onClickWalletButton() {
const order = this.env.pos.get_order();
const eWalletPrograms = this.env.pos.programs.filter((p) => p.program_type == 'ewallet');
const orderTotal = order.get_total_with_tax();
const eWalletRewards = this._getEWalletRewards(order);
if (orderTotal < 0 && eWalletPrograms.length >= 1) {
let selectedProgram = null;
if (eWalletPrograms.length == 1) {
selectedProgram = eWalletPrograms[0];
} else {
const { confirmed, payload } = await this.showPopup('SelectionPopup', {
title: this.env._t('Refund with eWallet'),
list: eWalletPrograms.map((program) => ({
id: program.id,
item: program,
label: program.name,
})),
});
if (confirmed) {
selectedProgram = payload;
}
}
if (selectedProgram) {
const eWalletProduct = this.env.pos.db.get_product_by_id(selectedProgram.trigger_product_ids[0]);
order.add_product(eWalletProduct, {
price: -orderTotal,
merge: false,
eWalletGiftCardProgram: selectedProgram,
});
}
} else if (eWalletRewards.length >= 1) {
let eWalletReward = null;
if (eWalletRewards.length == 1) {
eWalletReward = eWalletRewards[0];
} else {
const { confirmed, payload } = await this.showPopup('SelectionPopup', {
title: this.env._t('Use eWallet to pay'),
list: eWalletRewards.map(({ reward, coupon_id }) => ({
id: reward.id,
item: { reward, coupon_id },
label: `${reward.description} (${reward.program_id.name})`,
})),
});
if (confirmed) {
eWalletReward = payload;
}
}
if (eWalletReward) {
const result = order._applyReward(eWalletReward.reward, eWalletReward.coupon_id, {});
if (result !== true) {
// Returned an error
this.showPopup('ErrorPopup', {
title: this.env._t('Error'),
body: result,
});
}
order._updateRewards();
}
}
}
_shouldBeHighlighted(orderTotal, eWalletPrograms, eWalletRewards) {
return (orderTotal < 0 && eWalletPrograms.length >= 1) || eWalletRewards.length >= 1;
}
_getText(orderTotal, eWalletPrograms, eWalletRewards) {
if (orderTotal < 0 && eWalletPrograms.length >= 1) {
return this.env._t('eWallet Refund');
} else if (eWalletRewards.length >= 1) {
return this.env._t('eWallet Pay');
} else {
return this.env._t('eWallet');
}
}
}
eWalletButton.template = 'point_of_sale.eWalletButton';
ProductScreen.addControlButton({
component: eWalletButton,
condition: function () {
return this.env.pos.programs.filter((p) => p.program_type == 'ewallet').length > 0;
},
});
Registries.Component.add(eWalletButton);
+105 -93
View File
@@ -78,11 +78,12 @@ export class PosLoyaltyCard {
const PosLoyaltyGlobalState = (PosGlobalState) => class PosLoyaltyGlobalState extends PosGlobalState {
//@override
async _processData(loadedData) {
this.couponCache = {};
await super._processData(loadedData);
this.productId2ProgramIds = loadedData['product_id_to_program_ids'];
this.programs = loadedData['loyalty.program'] || []; //TODO: rename to `loyaltyPrograms` etc
this.rules = loadedData['loyalty.rule'] || [];
this.rewards = loadedData['loyalty.reward'] || [];
this.couponCache = {};
this._loadLoyaltyData();
}
_loadLoyaltyData() {
@@ -168,21 +169,34 @@ const PosLoyaltyGlobalState = (PosGlobalState) => class PosLoyaltyGlobalState ex
* @param {int} partnerId
*/
async fetchLoyaltyCard(programId, partnerId) {
if (programId === this.config.loyalty_program_id[0]) {
// In this case we actually have it loaded separately to avoid needing to be online
// for the loyalty program functionalities
// see `_get_pos_ui_res_partner` in pos_session.py
const partner = this.db.get_partner_by_id(partnerId);
return new PosLoyaltyCard(null, partner.loyalty_card_id, programId, partnerId, partner.loyalty_points);
}
for (const coupon of Object.values(this.couponCache)) {
if (coupon.partner_id === partnerId && coupon.program_id === programId) {
return coupon;
}
}
const dbCoupon = await this.fetchCoupons([['partner_id', '=', partnerId], ['program_id', '=', programId]])[0];
const fetchedCoupons = await this.fetchCoupons([['partner_id', '=', partnerId], ['program_id', '=', programId]]);
const dbCoupon = fetchedCoupons.length > 0 ? fetchedCoupons[0] : null;
return dbCoupon || new PosLoyaltyCard(null, null, programId, partnerId, 0);
}
getLoyaltyCards(partner) {
const loyaltyCards = [];
for (const coupon of Object.values(this.couponCache)) {
if (coupon.partner_id === partner.id) {
loyaltyCards.push(coupon);
}
}
return loyaltyCards;
}
addPartners(partners) {
const result = super.addPartners(partners);
// cache the loyalty cards of the partners
for (const partner of partners) {
for (const [couponId, { code, program_id, points }] of Object.entries(partner.loyalty_cards || {})) {
this.couponCache[couponId] = new PosLoyaltyCard(code, parseInt(couponId, 10), program_id, partner.id, points);
}
}
return result;
}
}
Registries.Model.extend(PosGlobalState, PosLoyaltyGlobalState);
@@ -196,6 +210,8 @@ const PosLoyaltyOrderline = (Orderline) => class PosLoyaltyOrderline extends Ord
result.reward_identifier_code = this.reward_identifier_code;
result.points_cost = this.points_cost;
result.giftBarcode = this.giftBarcode;
result.giftCardId = this.giftCardId;
result.eWalletGiftCardProgramId = this.eWalletGiftCardProgram ? this.eWalletGiftCardProgram.id : null;
return result;
}
init_from_JSON(json) {
@@ -208,8 +224,10 @@ const PosLoyaltyOrderline = (Orderline) => class PosLoyaltyOrderline extends Ord
this.coupon_id = this.order.oldCouponMapping[json.coupon_id] || json.coupon_id;
this.reward_identifier_code = json.reward_identifier_code;
this.points_cost = json.points_cost;
this.giftBarcode = json.giftBarcode;
}
this.giftBarcode = json.giftBarcode;
this.giftCardId = json.giftCardId;
this.eWalletGiftCardProgram = this.pos.program_by_id[json.eWalletGiftCardProgramId];
super.init_from_JSON(...arguments);
}
set_quantity(quantity, keep_price) {
@@ -230,9 +248,14 @@ const PosLoyaltyOrderline = (Orderline) => class PosLoyaltyOrderline extends Ord
}
return super.set_quantity(...arguments);
}
//to override
ignoreLoyaltyPoints() {
return false;
getEWalletGiftCardProgramType() {
return this.eWalletGiftCardProgram && this.eWalletGiftCardProgram.program_type
}
ignoreLoyaltyPoints({ program }) {
return (
['gift_card', 'ewallet'].includes(program.program_type) &&
this.eWalletGiftCardProgram.id !== program.id
);
}
}
Registries.Model.extend(Orderline, PosLoyaltyOrderline);
@@ -258,7 +281,12 @@ const PosLoyaltyOrder = (Order) => class PosLoyaltyOrder extends Order {
// Remapping of coupon_id for both couponPointChanges and Orderline.coupon_id
this.oldCouponMapping = {};
if (this.couponPointChanges) {
for (const pe of Object.values(this.couponPointChanges)) {
for (const [key, pe] of Object.entries(this.couponPointChanges)) {
if (!this.pos.program_by_id[pe.program_id]) {
// Remove points changes for programs that are not available anymore.
delete this.couponPointChanges[key];
continue;
}
if (pe.coupon_id > 0) {
continue;
}
@@ -283,12 +311,15 @@ const PosLoyaltyOrder = (Order) => class PosLoyaltyOrder extends Order {
set_partner(partner) {
const oldPartner = this.get_partner();
super.set_partner(partner);
if (oldPartner && oldPartner.loyalty_card_id && oldPartner.loyalty_card_id in this.couponPointChanges) {
// Remove the coupon from the tracked couponPointChanges, and it will be replaced
// with the correct one in `_updatePrograms`.
delete this.couponPointChanges[oldPartner.loyalty_card_id];
}
if (oldPartner !== this.get_partner()) {
if (this.couponPointChanges && oldPartner !== this.get_partner()) {
// Remove couponPointChanges for cards in is_nominative programs.
// This makes sure that counting of points on loyalty and ewallet programs is updated after partner changes.
const loyaltyProgramIds = new Set(this.pos.programs.filter(program => program.is_nominative).map(program => program.id));
for (const [key, pointChange] of Object.entries(this.couponPointChanges)) {
if (loyaltyProgramIds.has(pointChange.program_id)) {
delete this.couponPointChanges[key];
}
}
this._updateRewards();
}
}
@@ -302,52 +333,21 @@ const PosLoyaltyOrder = (Order) => class PosLoyaltyOrder extends Order {
*/
export_for_printing() {
const result = super.export_for_printing(...arguments);
if (this.pos.config.loyalty_program_id && this.get_partner()) {
const loyaltyProgram = this.pos.program_by_id[this.pos.config.loyalty_program_id[0]];
result.loyalty = {
name: loyaltyProgram.name,
point_name: loyaltyProgram.portal_point_name,
partner_name: this.get_partner().name,
newPoints: this.getLoyaltyPoints(),
};
if (this.get_partner()) {
result.loyaltyStats = this.getLoyaltyPoints();
}
result.new_coupon_info = this.new_coupon_info;
return result;
}
/**
* Apply changes to loyalty program points directly to support offline loyalty.
*
* @override
*/
finalize() {
const partner = this.get_partner();
const loyaltyProgramId = this.pos.config.loyalty_program_id ? this.pos.config.loyalty_program_id[0]: 0;
if (partner && loyaltyProgramId) {
for (const pe of Object.values(this.couponPointChanges)) {
if (pe.program_id === loyaltyProgramId) {
partner.loyalty_points += pe.points;
break;
}
}
for (const line of this.get_orderlines()) {
if (line.reward_id) {
const reward = this.pos.reward_by_id[line.reward_id];
if (reward.program_id.id !== loyaltyProgramId) {
continue;
}
partner.loyalty_points -= line.points_cost;
}
}
}
super.finalize(...arguments);
}
//@override
_get_ignored_product_ids_total_discount() {
const productIds = super._get_ignored_product_ids_total_discount(...arguments);
if (this.pos.config.gift_card_program_id) {
const program = this.pos.program_by_id[this.pos.config.gift_card_program_id[0]];
const giftCardPrograms = this.pos.programs.filter(p => p.program_type === 'gift_card');
for (const program of giftCardPrograms) {
const giftCardProductId = [...program.rules[0].valid_product_ids][0];
productIds.push(giftCardProductId);
if (giftCardProductId) {
productIds.push(giftCardProductId);
}
}
return productIds;
}
@@ -394,6 +394,8 @@ const PosLoyaltyOrder = (Order) => class PosLoyaltyOrder extends Order {
line.price_manually_set = true;
}
line.giftBarcode = options.giftBarcode;
line.giftCardId = options.giftCardId;
line.eWalletGiftCardProgram = options.eWalletGiftCardProgram;
}
add_product(product, options) {
super.add_product(...arguments);
@@ -452,9 +454,6 @@ const PosLoyaltyOrder = (Order) => class PosLoyaltyOrder extends Order {
})}).catch(() => {/* catch the reject of dp when calling `add` to avoid unhandledrejection */});
}
async _updateLoyaltyPrograms() {
if (this.partner && !(this.partner.loyalty_card_id in this.pos.couponCache)) {
this.pos.couponCache[this.partner.loyalty_card_id] = new PosLoyaltyCard(null, this.partner.loyalty_card_id, this.pos.config.loyalty_program_id[0], this.partner.id, this.partner.loyalty_points);
}
await this._checkMissingCoupons();
await this._updatePrograms();
}
@@ -588,44 +587,45 @@ const PosLoyaltyOrder = (Order) => class PosLoyaltyOrder extends Order {
});
}
/**
* @returns {Object} containing the following keys: `won`, `spent`, `total` and `name` for the points name
* @typedef {{ won: number, spend: number, total: number, balance: number, name: string}} LoyaltyPoints
* @typedef {{ couponId: number, program: object, points: LoyaltyPoints}} LoyaltyStat
* @returns {Array<LoyaltyStat>}
*/
getLoyaltyPoints() {
let won = 0;
let spent = 0;
let total = 0;
const partner = this.get_partner();
if (partner && this.pos.config.loyalty_program_id) {
let couponId = partner.loyalty_card_id;
let balance = partner.loyalty_points;
for (const pe of Object.values(this.couponPointChanges)) {
if (pe.program_id === this.pos.config.loyalty_program_id[0]) {
if (!couponId) {
couponId = pe.coupon_id;
}
won += pe.points - this._getPointsCorrection(this.pos.program_by_id[pe.program_id]);
break;
}
// map: couponId -> LoyaltyPoints
const loyaltyPoints = {};
for (const pointChange of Object.values(this.couponPointChanges)) {
const { coupon_id, points, program_id } = pointChange;
const program = this.pos.program_by_id[program_id];
if (program.program_type !== 'loyalty') {
// Not a loyalty program, skip
continue;
}
if (couponId !== 0) {
const loyaltyCard = this.pos.couponCache[coupon_id] || /* or new card */ { id: coupon_id, balance: 0 };
let [won, spent, total] = [0, 0, 0];
let balance = loyaltyCard.balance;
won += points - this._getPointsCorrection(program);
if (coupon_id !== 0) {
for (const line of this._get_reward_lines()) {
if (line.coupon_id === couponId) {
if (line.coupon_id === coupon_id) {
spent += line.points_cost;
}
}
}
total = balance + won - spent;
const name = program.portal_visible ? program.portal_point_name : _t('Points');
loyaltyPoints[coupon_id] = {
won: parseFloat(won.toFixed(2)),
spent: parseFloat(spent.toFixed(2)),
// Display total when order is ongoing.
total: parseFloat(total.toFixed(2)),
// Display balance when order is done.
balance: parseFloat(balance.toFixed(2)),
name,
program,
};
}
let name = _t('Points');
if (this.pos.config.loyalty_program_id) {
name = this.pos.program_by_id[this.pos.config.loyalty_program_id[0]].portal_point_name;
}
return {
won: parseFloat(won.toFixed(2)),
spent: parseFloat(spent.toFixed(2)),
total: parseFloat(total.toFixed(2)),
name: name,
}
return Object.entries(loyaltyPoints).map(([couponId, points]) => ({ couponId, points, program: points.program }));
}
/**
* The points in the couponPointChanges for free product reward is not correct.
@@ -756,10 +756,9 @@ const PosLoyaltyOrder = (Order) => class PosLoyaltyOrder extends Order {
const qtyPerProduct = {};
let orderedProductPaid = 0;
for (const line of orderLines) {
if (((!line.reward_product_id && (rule.any_product || rule.valid_product_ids.has(line.get_product().id))) ||
(line.reward_product_id && (rule.any_product || rule.valid_product_ids.has(line.reward_product_id)))) &&
!line.ignoreLoyaltyPoints()){
!line.ignoreLoyaltyPoints({ program })){
// We only count reward products from the same program to avoid unwanted feedback loops
if (line.reward_product_id) {
const reward = this.pos.reward_by_id[line.reward_id];
@@ -778,6 +777,8 @@ const PosLoyaltyOrder = (Order) => class PosLoyaltyOrder extends Order {
}
}
if (totalProductQty < rule.minimum_qty) {
// Should also count the points from negative quantities.
// For example, when refunding an ewallet payment. See TicketScreen override in this addon.
continue;
}
if (program.applies_on === 'future' && rule.reward_point_split && rule.reward_point_mode !== 'order') {
@@ -787,14 +788,14 @@ const PosLoyaltyOrder = (Order) => class PosLoyaltyOrder extends Order {
} else if (rule.reward_point_mode === 'money') {
for (const line of orderLines) {
if (line.is_reward_line || !(rule.valid_product_ids.has(line.get_product().id)) || line.get_quantity() <= 0
|| line.ignoreLoyaltyPoints()) {
|| line.ignoreLoyaltyPoints({ program })) {
continue;
}
const pointsPerUnit = round_precision(rule.reward_point_amount * line.get_price_with_tax() / line.get_quantity(), 0.01);
if (pointsPerUnit > 0) {
splitPoints.push(...Array.apply(null, Array(line.get_quantity())).map(() => {
if (line.giftBarcode && line.get_quantity() == 1) {
return {points: pointsPerUnit, barcode: line.giftBarcode};
return {points: pointsPerUnit, barcode: line.giftBarcode, giftCardId: line.giftCardId };
}
return {points: pointsPerUnit}
}));
@@ -1410,6 +1411,17 @@ const PosLoyaltyOrder = (Order) => class PosLoyaltyOrder extends Order {
kwargs: { context: session.user_context },
});
if (successful) {
// Allow rejecting a gift card that is not yet paid.
const program = this.pos.program_by_id[payload.program_id];
if (program && program.program_type === 'gift_card' && !payload.has_source_order) {
const { confirmed } = await Gui.showPopup('ConfirmPopup', {
title: _t('Unpaid gift card'),
body: _t('This gift card is not linked to any order. Do you really want to apply its reward?'),
});
if (!confirmed) {
return _t('Unpaid gift card rejected.');
}
}
const coupon = new PosLoyaltyCard(code, payload.coupon_id, payload.program_id, payload.partner_id, payload.points);
this.pos.couponCache[coupon.id] = coupon;
this.codeActivatedCoupons.push(coupon);
@@ -6,9 +6,6 @@ import Registries from 'point_of_sale.Registries';
export const PosLoyaltyOrderSummary = (OrderSummary) =>
class PosLoyaltyOrderSummary extends OrderSummary {
getLoyaltyPoints() {
if (!this.env.pos.config.loyalty_program_id) {
return {};
}
const order = this.env.pos.get_order();
return order.getLoyaltyPoints();
}
@@ -32,7 +32,7 @@ export const PosLoyaltyOrderWidget = (OrderWidget) =>
};
});
return {
activePrograms,
activePrograms: [...new Set(activePrograms)],
activeCoupons,
activeRewards,
}
@@ -8,6 +8,21 @@ export const PosLoyaltyOrderline = (Orderline) =>
get addedClasses() {
return Object.assign({'program-reward': this.props.line.is_reward_line}, super.addedClasses);
}
_isGiftCardOrEWalletReward() {
const coupon = this.env.pos.couponCache[this.props.line.coupon_id];
if (coupon) {
const program = this.env.pos.program_by_id[coupon.program_id]
return ['ewallet', 'gift_card'].includes(program.program_type) && this.props.line.is_reward_line;
}
return false;
}
_getGiftCardOrEWalletBalance() {
const coupon = this.env.pos.couponCache[this.props.line.coupon_id];
if (coupon) {
return this.env.pos.format_currency(coupon.balance);
}
return this.env.pos.format_currency(0);
}
};
Registries.Component.extend(Orderline, PosLoyaltyOrderline);
@@ -0,0 +1,22 @@
odoo.define('pos_loyalty.PartnerLine', function (require) {
'use strict';
const PartnerLine = require('point_of_sale.PartnerLine');
const Registries = require('point_of_sale.Registries');
const PosLoyaltyPartnerLine = (PartnerLine) =>
class extends PartnerLine {
_getLoyaltyPointsRepr(loyaltyCard) {
const program = this.env.pos.program_by_id[loyaltyCard.program_id];
const balanceRepr = this.env.pos.format_pr(loyaltyCard.balance, 0.01);
if (program.portal_visible) {
return `${balanceRepr} ${program.portal_point_name}`;
}
return _.str.sprintf(this.env._t('%s Points'), balanceRepr);
}
};
Registries.Component.extend(PartnerLine, PosLoyaltyPartnerLine);
return PartnerLine;
});
@@ -0,0 +1,21 @@
odoo.define('pos_loyalty.PartnerListScreen', function (require) {
'use strict';
const PartnerListScreen = require('point_of_sale.PartnerListScreen');
const Registries = require('point_of_sale.Registries');
const PosLoyaltyPartnerListScreen = (PartnerListScreen) =>
class extends PartnerListScreen {
/**
* Needs to be set to true to show the loyalty points in the partner list.
* @override
*/
get isBalanceDisplayed() {
return true;
}
};
Registries.Component.extend(PartnerListScreen, PosLoyaltyPartnerListScreen);
return PartnerListScreen;
});
@@ -14,7 +14,7 @@ export const PosLoyaltyPaymentScreen = (PaymentScreen) =>
for (const pe of Object.values(this.currentOrder.couponPointChanges)) {
if (pe.coupon_id > 0) {
pointChanges[pe.coupon_id] = pe.points;
} else if (pe.barcode) {
} else if (pe.barcode && !pe.giftCardId) {
// New coupon with a specific code, validate that it does not exist
newCodes.push(pe.barcode);
}
@@ -78,22 +78,15 @@ export const PosLoyaltyPaymentScreen = (PaymentScreen) =>
async _postPushOrderResolve(order, server_ids) {
// Compile data for our function
const rewardLines = order._get_reward_lines();
const loyaltyProgramId = this.env.pos.config.loyalty_program_id ? this.env.pos.config.loyalty_program_id[0] : 0;
const partner = order.get_partner();
let partnerLoyaltyCardId = partner ? partner.loyalty_card_id : 0;
let couponData = Object.values(order.couponPointChanges).reduce((agg, pe) => {
agg[pe.coupon_id] = Object.assign({}, pe, {
points: pe.points - order._getPointsCorrection(this.env.pos.program_by_id[pe.program_id]),
});
const program = this.env.pos.program_by_id[pe.program_id];
if (program.is_nominative && order.get_partner()) {
if (program.is_nominative && partner) {
agg[pe.coupon_id].partner_id = partner.id;
}
if (program.id === loyaltyProgramId) {
if (!partnerLoyaltyCardId) {
partnerLoyaltyCardId = pe.coupon_id;
}
}
return agg;
}, {});
for (const line of rewardLines) {
@@ -140,9 +133,6 @@ export const PosLoyaltyPaymentScreen = (PaymentScreen) =>
dbCoupon = new PosLoyaltyCard(
couponUpdate.code, couponUpdate.id, couponUpdate.program_id, couponUpdate.partner_id, couponUpdate.points);
}
if (couponUpdate.old_id === partnerLoyaltyCardId) {
partner.loyalty_card_id = couponUpdate.id;
}
delete this.env.pos.couponCache[couponUpdate.old_id];
this.env.pos.couponCache[couponUpdate.id] = dbCoupon;
}
@@ -1,165 +0,0 @@
/** @odoo-module **/
import AbstractAwaitablePopup from 'point_of_sale.AbstractAwaitablePopup';
import Registries from 'point_of_sale.Registries';
import { useBarcodeReader } from 'point_of_sale.custom_hooks';
import Core from 'web.core';
const _t = Core._t;
const { useState, onPatched, useComponent } = owl;
export class GiftCardPopup extends AbstractAwaitablePopup {
setup() {
super.setup();
this.confirmFunctions = {
'create_set': this.generateBarcode.bind(this),
'scan_set': this.scanAndUseGiftCard.bind(this),
'scan_use': this.scanAndUseGiftCard.bind(this),
'pay': this.payWithGiftCard.bind(this),
'balance': this.showRemainingAmount.bind(this),
};
this.state = useState({
giftCardConfig: this.env.pos.config.gift_card_settings,
showMenu: true,
error: '',
context: '',
amountToSet: 0,
code: '',
});
useBarcodeReader({
gift_card: this._onScan,
}, true);
}
//@override
async confirm() {
if (!this.state.showMenu) {
this.confirmFunctions[this.state.context]();
}
}
get code() {
return this.state.code.trim();
}
useAutoFocus(state) {
const component = useComponent();
let hasFocused = false;
function autofocus() {
if (!state.showMenu) {
// Should autofocus here but only if it hasn't autofocus yet.
if (!hasFocused) {
const elem = component.el.querySelector(`.gift-card-input-code`);
if (elem)
elem.focus();
hasFocused = true;
}
} else {
// When changing showBarcodeGeneration to false, we reset hasFocused.
hasFocused = false;
}
}
onPatched(autofocus);
}
switchToMenu() {
this.state.showMenu = true;
this.state.context = '';
this.state.amountToSet = 0;
this.state.code = '';
this.state.error = '';
}
switchToBarcode() {
this.state.context = this.state.giftCardConfig;
this.state.showMenu = false;
}
switchToPay() {
this.state.showMenu = false;
this.state.context = 'pay';
}
switchToBalance() {
this.state.showMenu = false;
this.state.context = 'balance';
}
_onScan(code) {
this.state.code = code.base_code;
}
_getGiftCardProduct() {
const pos = this.env.pos;
const program = pos.program_by_id[pos.config.gift_card_program_id[0]];
return pos.db.product_by_id[[...program.rules[0].valid_product_ids][0]];
}
addGiftCardProduct() {
const pos = this.env.pos;
const order = pos.get_order();
const product = this._getGiftCardProduct();
order.add_product(product, {
price: this.state.amountToSet,
quantity: 1,
merge: false,
giftBarcode: this.code || false,
});
}
async generateBarcode() {
this.addGiftCardProduct(false);
this.confirm();
}
scanAndUseGiftCard() {
if (this.state.giftCardConfig === "scan_use") {
// Use the default price of the first product to match
this.state.amountToSet = this._getGiftCardProduct().price;
}
this.addGiftCardProduct();
this.confirm();
}
async payWithGiftCard() {
if (!this.code) {
this.state.error = _t('No gift card code set');
return;
}
// This should load and enable the coupon and automatic rewards since the gift_card program is supposed to only have one
// (as long as there are enough points).
const res = await this.env.pos.get_order().activateCode(this.code);
if (res !== true) {
this.state.error = res;
}
this.confirm();
}
async showRemainingAmount() {
this.state.amountToSet = 0;
if (!this.code) {
this.state.error = _t('No gift card code set');
return;
}
const coupon = await this.rpc({
model: 'loyalty.card',
method: 'search_read',
args: [
[['code', '=', this.code], ['program_id', '=', this.env.pos.config.gift_card_program_id[0]]],
['points'],
],
});
if (!coupon || !coupon.length) {
this.state.error = _t('No gift card found');
} else {
this.state.amountToSet = coupon[0].points;
}
}
}
GiftCardPopup.template = 'GiftCardPopup';
Registries.Component.add(GiftCardPopup);
@@ -12,12 +12,128 @@ export const PosLoyaltyProductScreen = (ProductScreen) =>
coupon: this._onCouponScan,
});
}
async _onClickPay() {
const order = this.env.pos.get_order();
const eWalletLine = order.get_orderlines().find(line => line.getEWalletGiftCardProgramType() === 'ewallet');
if (eWalletLine && !order.get_partner()) {
const {confirmed} = await this.showPopup('ConfirmPopup', {
title: this.env._t('Customer needed'),
body: this.env._t('eWallet requires a customer to be selected'),
});
if (confirmed) {
const { confirmed, payload: newPartner } = await this.showTempScreen(
'PartnerListScreen',
{ partner: null }
);
if (confirmed) {
order.set_partner(newPartner);
order.updatePricelist(newPartner);
}
}
} else {
return super._onClickPay(...arguments);
}
}
/**
* Sets up the options for the gift card product.
* @param {object} program
* @param {object} options
* @returns {Promise<boolean>} whether to proceed with adding the product or not
*/
async _setupGiftCardOptions(program, options) {
options.quantity = 1;
options.merge = false;
options.eWalletGiftCardProgram = program;
// If gift card program setting is 'scan_use', ask for the code.
if (this.env.pos.config.gift_card_settings == 'scan_use') {
const { confirmed, payload: code } = await this.showPopup('TextInputPopup', {
title: this.env._t('Generate a Gift Card'),
startingValue: '',
placeholder: this.env._t('Enter the gift card code'),
});
if (!confirmed) {
return false;
}
const trimmedCode = code.trim();
if (trimmedCode && trimmedCode.startsWith('044')) {
// check if the code exist in the database
// if so, use its balance, otherwise, use the unit price of the gift card product
const fetchedGiftCard = await this.rpc({
model: 'loyalty.card',
method: 'search_read',
args: [
[['code', '=', trimmedCode], ['program_id', '=', program.id]],
['points', 'source_pos_order_id'],
],
});
// There should be maximum one gift card for a given code.
const giftCard = fetchedGiftCard[0];
if (giftCard && giftCard.source_pos_order_id) {
this.showPopup('ErrorPopup', {
title: this.env._t('This gift card has already been sold'),
body: this.env._t('You cannot sell a gift card that has already been sold.'),
});
return false;
}
options.giftBarcode = trimmedCode;
if (giftCard) {
// Use the balance of the gift card as the price of the orderline.
// NOTE: No need to convert the points to price because when opening a session,
// the gift card programs are made sure to have 1 point = 1 currency unit.
options.price = giftCard.points;
options.giftCardId = giftCard.id;
}
} else {
this.showNotification('Please enter a valid gift card code.');
return false;
}
}
return true;
}
async setupEWalletOptions(program, options) {
options.quantity = 1;
options.merge = false;
options.eWalletGiftCardProgram = program;
return true;
}
/**
* If the product is a potential reward, also apply the reward.
* @override
*/
async _addProduct(product, options) {
const linkedProgramIds = this.env.pos.productId2ProgramIds[product.id] || [];
const linkedPrograms = linkedProgramIds.map(id => this.env.pos.program_by_id[id]);
let selectedProgram = null;
if (linkedPrograms.length > 1) {
const { confirmed, payload: program } = await this.showPopup('SelectionPopup', {
title: this.env._t('Select program'),
list: linkedPrograms.map((program) => ({
id: program.id,
item: program,
label: program.name,
})),
});
if (confirmed) {
selectedProgram = program;
} else {
// Do nothing here if the selection is cancelled.
return;
}
} else if (linkedPrograms.length === 1) {
selectedProgram = linkedPrograms[0];
}
if (selectedProgram && selectedProgram.program_type == 'gift_card') {
const shouldProceed = await this._setupGiftCardOptions(selectedProgram, options);
if (!shouldProceed) {
return;
}
} else if (selectedProgram && selectedProgram.program_type == 'ewallet') {
const shouldProceed = await this.setupEWalletOptions(selectedProgram, options);
if (!shouldProceed) {
return;
}
}
const order = this.env.pos.get_order();
const potentialRewards = order.getPotentialFreeProductRewards();
let rewardsToApply = [];
@@ -37,11 +153,20 @@ export const PosLoyaltyProductScreen = (ProductScreen) =>
}
_onCouponScan(code) {
// IMPROVEMENT: Ability to understand if the scanned code is to be paid or to be redeemed.
this.currentOrder.activateCode(code.base_code);
}
async _updateSelectedOrderline(event) {
const selectedLine = this.currentOrder.get_selected_orderline();
if (event.detail.key === '-') {
if (selectedLine.eWalletGiftCardProgram) {
// Do not allow negative quantity or price in a gift card or ewallet orderline.
// Refunding gift card or ewallet is not supported.
this.showNotification(this.env._t('You cannot set negative quantity or price to gift card or ewallet.'), 4000);
return;
}
}
if (selectedLine && selectedLine.is_reward_line && !selectedLine.manual_reward &&
(event.detail.key === 'Backspace' || event.detail.key === 'Delete')) {
const reward = this.env.pos.reward_by_id[selectedLine.reward_id];
@@ -0,0 +1,50 @@
/** @odoo-module **/
import TicketScreen from 'point_of_sale.TicketScreen';
import Registries from 'point_of_sale.Registries';
import NumberBuffer from 'point_of_sale.NumberBuffer';
/**
* Prevent refunding ewallet/gift card lines.
*/
export const PosLoyaltyTicketScreen = (TicketScreen) =>
class PosLoyaltyTicketScreen extends TicketScreen {
_onUpdateSelectedOrderline() {
const order = this.getSelectedSyncedOrder();
const selectedOrderlineId = this.getSelectedOrderlineId();
const orderline = order.orderlines.find((line) => line.id == selectedOrderlineId);
if (orderline && this._isEWalletGiftCard(orderline)) {
this._showNotAllowedRefundNotification();
return NumberBuffer.reset();
}
return super._onUpdateSelectedOrderline(...arguments);
}
_prepareAutoRefundOnOrder(order) {
const selectedOrderlineId = this.getSelectedOrderlineId();
const orderline = order.orderlines.find((line) => line.id == selectedOrderlineId);
if (this._isEWalletGiftCard(orderline)) {
this._showNotAllowedRefundNotification();
return false;
}
return super._prepareAutoRefundOnOrder(...arguments);
}
_showNotAllowedRefundNotification() {
this.showNotification(this.env._t("Refunding a top up or reward product for an eWallet or gift card program is not allowed."), 5000);
}
_isEWalletGiftCard(orderline) {
const linkedProgramIds = this.env.pos.productId2ProgramIds[orderline.product.id];
if (linkedProgramIds) {
return linkedProgramIds.length > 0;
}
if (orderline.is_reward_line) {
const reward = this.env.pos.reward_by_id[orderline.reward_id];
const program = reward && reward.program_id;
if (program && ['gift_card', 'ewallet'].includes(program.program_type)) {
return true;
}
}
return false;
}
};
Registries.Component.extend(TicketScreen, PosLoyaltyTicketScreen);
@@ -0,0 +1,105 @@
/** @odoo-module **/
import { PosLoyalty } from 'pos_loyalty.tour.PosCouponTourMethods';
import { ProductScreen } from 'point_of_sale.tour.ProductScreenTourMethods';
import { TicketScreen } from 'point_of_sale.tour.TicketScreenTourMethods';
import { Chrome } from 'point_of_sale.tour.ChromeTourMethods';
import { PartnerListScreen } from 'point_of_sale.tour.PartnerListScreenTourMethods';
import { getSteps, startSteps } from 'point_of_sale.tour.utils';
import Tour from 'web_tour.tour';
//#region EWalletProgramTour1
startSteps();
ProductScreen.do.confirmOpeningPopup();
ProductScreen.do.clickHomeCategory();
// Topup 50$ for partner_aaa
ProductScreen.do.clickDisplayedProduct('Top-up eWallet');
PosLoyalty.check.orderTotalIs('50.00');
ProductScreen.do.clickPayButton(false);
// If there's no partner, we asked to redirect to the partner list screen.
Chrome.do.confirmPopup();
PartnerListScreen.check.isShown();
PartnerListScreen.do.clickPartner('AAAAAAA');
PosLoyalty.exec.finalizeOrder('Cash', '50');
// Topup 10$ for partner_bbb
ProductScreen.do.clickPartnerButton();
ProductScreen.do.clickCustomer('BBBBBBB');
ProductScreen.exec.addOrderline('Top-up eWallet', '1', '10');
PosLoyalty.check.orderTotalIs('10.00');
PosLoyalty.exec.finalizeOrder('Cash', '10');
Tour.register('EWalletProgramTour1', { test: true, url: '/pos/web' }, getSteps());
//#endregion
//#region EWalletProgramTour2
const getEWalletText = (suffix) => 'eWallet' + (suffix !== '' ? ` ${suffix}` : '');
startSteps();
ProductScreen.do.clickHomeCategory();
ProductScreen.exec.addOrderline('Whiteboard Pen', '2', '6', '12.00');
PosLoyalty.check.eWalletButtonState({ highlighted: false });
ProductScreen.do.clickPartnerButton();
ProductScreen.do.clickCustomer('AAAAAAA');
PosLoyalty.check.eWalletButtonState({ highlighted: true, text: getEWalletText('Pay') });
PosLoyalty.do.clickEWalletButton(getEWalletText('Pay'));
PosLoyalty.check.orderTotalIs('0.00');
PosLoyalty.exec.finalizeOrder('Cash', '0');
// Consume partner_bbb's full eWallet.
ProductScreen.do.clickPartnerButton();
ProductScreen.do.clickCustomer('BBBBBBB');
PosLoyalty.check.eWalletButtonState({ highlighted: false });
ProductScreen.exec.addOrderline('Desk Pad', '6', '6', '36.00');
PosLoyalty.check.eWalletButtonState({ highlighted: true, text: getEWalletText('Pay') });
PosLoyalty.do.clickEWalletButton(getEWalletText('Pay'));
PosLoyalty.check.orderTotalIs('26.00');
PosLoyalty.exec.finalizeOrder('Cash', '26');
// Switching partners should work.
ProductScreen.do.clickPartnerButton();
ProductScreen.do.clickCustomer('BBBBBBB');
ProductScreen.exec.addOrderline('Desk Pad', '2', '19', '38.00');
PosLoyalty.check.eWalletButtonState({ highlighted: false });
ProductScreen.do.clickPartnerButton();
ProductScreen.do.clickCustomer('AAAAAAA');
PosLoyalty.check.eWalletButtonState({ highlighted: true, text: getEWalletText('Pay') });
PosLoyalty.do.clickEWalletButton(getEWalletText('Pay'));
PosLoyalty.check.orderTotalIs('0.00');
ProductScreen.do.clickPartnerButton();
ProductScreen.do.clickCustomer('BBBBBBB');
PosLoyalty.check.eWalletButtonState({ highlighted: false });
PosLoyalty.check.orderTotalIs('38.00');
ProductScreen.do.clickPartnerButton();
ProductScreen.do.clickCustomer('AAAAAAA');
PosLoyalty.check.eWalletButtonState({ highlighted: true, text: getEWalletText('Pay') });
PosLoyalty.do.clickEWalletButton(getEWalletText('Pay'));
PosLoyalty.check.orderTotalIs('0.00');
PosLoyalty.exec.finalizeOrder('Cash', '0');
// Refund with eWallet.
// - Make an order to refund.
ProductScreen.do.clickPartnerButton();
ProductScreen.do.clickCustomer('BBBBBBB');
ProductScreen.exec.addOrderline('Whiteboard Pen', '1', '20', '20.00');
PosLoyalty.check.orderTotalIs('20.00');
PosLoyalty.exec.finalizeOrder('Cash', '20');
// - Refund order.
ProductScreen.do.clickRefund();
TicketScreen.check.filterIs('Paid');
TicketScreen.do.selectOrder('-0004');
TicketScreen.check.partnerIs('BBBBBBB');
TicketScreen.do.confirmRefund();
ProductScreen.check.isShown();
PosLoyalty.check.eWalletButtonState({ highlighted: true, text: getEWalletText('Refund') });
PosLoyalty.do.clickEWalletButton(getEWalletText('Refund'));
PosLoyalty.check.orderTotalIs('0.00');
PosLoyalty.exec.finalizeOrder('Cash', '0');
Tour.register('EWalletProgramTour2', { test: true, url: '/pos/web' }, getSteps());
//#endregion
@@ -0,0 +1,51 @@
/** @odoo-module **/
import { PosLoyalty } from 'pos_loyalty.tour.PosCouponTourMethods';
import { ProductScreen } from 'point_of_sale.tour.ProductScreenTourMethods';
import { TextInputPopup } from 'point_of_sale.tour.TextInputPopupTourMethods';
import { getSteps, startSteps } from 'point_of_sale.tour.utils';
import Tour from 'web_tour.tour';
//#region GiftCardProgramCreateSetTour1
startSteps();
ProductScreen.do.confirmOpeningPopup();
ProductScreen.do.clickHomeCategory();
ProductScreen.do.clickDisplayedProduct('Gift Card');
PosLoyalty.check.orderTotalIs('50.00');
PosLoyalty.exec.finalizeOrder('Cash', '50');
Tour.register('GiftCardProgramCreateSetTour1', { test: true, url: '/pos/web' }, getSteps());
//#endregion
//#region GiftCardProgramCreateSetTour2
startSteps();
ProductScreen.do.clickHomeCategory();
ProductScreen.do.clickDisplayedProduct('Whiteboard Pen');
PosLoyalty.do.enterCode('044123456');
PosLoyalty.check.orderTotalIs('0.00');
PosLoyalty.exec.finalizeOrder('Cash', '0');
Tour.register('GiftCardProgramCreateSetTour2', { test: true, url: '/pos/web' }, getSteps());
//#endregion
//#region GiftCardProgramScanUseTour
startSteps();
ProductScreen.do.confirmOpeningPopup();
ProductScreen.do.clickHomeCategory();
// Pay the 5$ gift card.
ProductScreen.do.clickDisplayedProduct('Gift Card');
TextInputPopup.check.isShown();
TextInputPopup.do.inputText('044123456');
TextInputPopup.do.clickConfirm();
PosLoyalty.check.orderTotalIs('5.00');
PosLoyalty.exec.finalizeOrder('Cash', '5');
// Partially use the gift card. (4$)
ProductScreen.exec.addOrderline('Desk Pad', '2', '2', '4.0');
PosLoyalty.do.enterCode('044123456');
PosLoyalty.check.orderTotalIs('0.00');
PosLoyalty.exec.finalizeOrder('Cash', '0');
// Use the remaining of the gift card. (5$ - 4$ = 1$)
ProductScreen.exec.addOrderline('Whiteboard Pen', '6', '6', '36.0');
PosLoyalty.do.enterCode('044123456');
PosLoyalty.check.orderTotalIs('35.00');
PosLoyalty.exec.finalizeOrder('Cash', '35');
Tour.register('GiftCardProgramScanUseTour', { test: true, url: '/pos/web' }, getSteps());
//#endregion
@@ -0,0 +1,70 @@
/** @odoo-module **/
import { PosLoyalty } from 'pos_loyalty.tour.PosCouponTourMethods';
import { ProductScreen } from 'point_of_sale.tour.ProductScreenTourMethods';
import { SelectionPopup } from 'point_of_sale.tour.SelectionPopupTourMethods';
import { getSteps, startSteps } from 'point_of_sale.tour.utils';
import Tour from 'web_tour.tour';
const getEWalletText = (suffix) => 'eWallet' + (suffix !== '' ? ` ${suffix}` : '');
startSteps();
// One card for gift_card_1.
ProductScreen.do.confirmOpeningPopup();
ProductScreen.do.clickHomeCategory();
ProductScreen.do.clickDisplayedProduct('Gift Card');
SelectionPopup.check.hasSelectionItem('gift_card_1');
SelectionPopup.check.hasSelectionItem('gift_card_2');
SelectionPopup.do.clickItem('gift_card_1');
ProductScreen.do.pressNumpad('Price');
ProductScreen.do.pressNumpad('1 0');
PosLoyalty.check.orderTotalIs('10.00');
PosLoyalty.exec.finalizeOrder('Cash', '10');
// One card for gift_card_1.
ProductScreen.do.clickDisplayedProduct('Gift Card');
SelectionPopup.do.clickItem('gift_card_2');
ProductScreen.do.pressNumpad('Price');
ProductScreen.do.pressNumpad('2 0');
PosLoyalty.check.orderTotalIs('20.00');
PosLoyalty.exec.finalizeOrder('Cash', '20');
// Top up ewallet_1 for AAAAAAA.
ProductScreen.do.clickDisplayedProduct('Top-up eWallet');
SelectionPopup.check.hasSelectionItem('ewallet_1');
SelectionPopup.check.hasSelectionItem('ewallet_2');
SelectionPopup.do.clickItem('ewallet_1');
ProductScreen.do.clickPartnerButton();
ProductScreen.do.clickCustomer('AAAAAAA');
ProductScreen.do.pressNumpad('Price');
ProductScreen.do.pressNumpad('3 0');
PosLoyalty.check.orderTotalIs('30.00');
PosLoyalty.exec.finalizeOrder('Cash', '30');
// Top up ewallet_2 for AAAAAAA.
ProductScreen.do.clickDisplayedProduct('Top-up eWallet');
SelectionPopup.do.clickItem('ewallet_2');
ProductScreen.do.clickPartnerButton();
ProductScreen.do.clickCustomer('AAAAAAA');
ProductScreen.do.pressNumpad('Price');
ProductScreen.do.pressNumpad('4 0');
PosLoyalty.check.orderTotalIs('40.00');
PosLoyalty.exec.finalizeOrder('Cash', '40');
// Top up ewallet_1 for BBBBBBB.
ProductScreen.do.clickDisplayedProduct('Top-up eWallet');
SelectionPopup.do.clickItem('ewallet_1');
ProductScreen.do.clickPartnerButton();
ProductScreen.do.clickCustomer('BBBBBBB');
PosLoyalty.check.orderTotalIs('50.00');
PosLoyalty.exec.finalizeOrder('Cash', '50');
// Consume 12$ from ewallet_1 of AAAAAAA.
ProductScreen.exec.addOrderline('Whiteboard Pen', '2', '6', '12.00');
PosLoyalty.check.eWalletButtonState({ highlighted: false });
ProductScreen.do.clickPartnerButton();
ProductScreen.do.clickCustomer('AAAAAAA');
PosLoyalty.check.eWalletButtonState({ highlighted: true, text: getEWalletText('Pay') });
PosLoyalty.do.clickEWalletButton(getEWalletText('Pay'));
SelectionPopup.check.hasSelectionItem('ewallet_1');
SelectionPopup.check.hasSelectionItem('ewallet_2');
SelectionPopup.do.clickItem('ewallet_1');
PosLoyalty.check.orderTotalIs('0.00');
PosLoyalty.exec.finalizeOrder('Cash', '0');
Tour.register('MultipleGiftWalletProgramsTour', { test: true, url: '/pos/web' }, getSteps());
@@ -67,6 +67,9 @@ odoo.define('pos_loyalty.tour.PosCouponTourMethods', function (require) {
},
];
}
clickEWalletButton(text = 'eWallet') {
return [{ trigger: `.control-button:contains("${text}")` }];
}
claimReward(rewardName) {
return [
{
@@ -135,6 +138,16 @@ odoo.define('pos_loyalty.tour.PosCouponTourMethods', function (require) {
},
];
}
eWalletButtonState({ highlighted, text = 'eWallet' }) {
return [
{
trigger: highlighted
? `.control-button.highlight:contains("${text}")`
: `.control-button:contains("${text}"):not(:has(.highlight))`,
run: function () {}, // it's a check
},
];
}
customerIs(name) {
return [
{
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="GiftCardButton" owl="1">
<span class="control-button">
<i class="fa fa-gift"></i>
<span> </span>
<span>Gift Card</span>
</span>
</t>
</templates>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="point_of_sale.eWalletButton" owl="1">
<t t-set="_order" t-value="env.pos.get_order()" />
<t t-set="_orderTotal" t-value="_order.get_total_with_tax()" />
<t t-set="_eWalletPrograms" t-value="_getEWalletPrograms()" />
<t t-set="_eWalletRewards" t-value="_getEWalletRewards(_order)" />
<span class="control-button" t-att-class="_shouldBeHighlighted(_orderTotal, _eWalletPrograms, _eWalletRewards) ? 'highlight' : ''" t-on-click="_onClickWalletButton">
<i class="fa fa-credit-card"></i>
<span> </span>
<span><t t-esc="_getText(_orderTotal, _eWalletPrograms, _eWalletRewards)" /></span>
</span>
</t>
</templates>
@@ -3,27 +3,31 @@
<t t-name="pos_coupon.OrderReceipt" t-inherit="point_of_sale.OrderReceipt" t-inherit-mode="extension" owl="1">
<xpath expr="//div[hasclass('pos-receipt')]//div[hasclass('before-footer')]" position="inside">
<t t-if='receipt.loyalty'>
<div class='loyalty'>
<span class="pos-receipt-center-align">
<div>--------------------------------</div>
<br/>
<div t-esc='receipt.loyalty.name' class="pos-receipt-title"/>
<t t-if='receipt.loyaltyStats'>
<t t-foreach="receipt.loyaltyStats" t-as="_loyaltyStat" t-key="_loyaltyStat.couponId">
<!-- Show only if portal_visible. -->
<div t-if="_loyaltyStat.program.portal_visible" class='loyalty'>
<span class="pos-receipt-center-align">
<div>--------------------------------</div>
<br/>
<div t-esc='_loyaltyStat.program.name' class="pos-receipt-title" />
<div t-esc='receipt.partner.name' />
<br />
</span>
<t t-if='_loyaltyStat.points.won'>
<div><t t-esc="_loyaltyStat.points.name"/> Won: <span t-esc='_loyaltyStat.points.won' class="pos-receipt-right-align"/></div>
</t>
<t t-if='_loyaltyStat.points.spent'>
<div><t t-esc="_loyaltyStat.points.name"/> Spent: <span t-esc='_loyaltyStat.points.spent' class="pos-receipt-right-align"/></div>
</t>
<!-- Don't use points.total, it's wrong in this context (after the order synced). -->
<!-- Show balance as it's updated during _postPushOrderResolve. -->
<t t-if='_loyaltyStat.points.balance'>
<div>Balance <t t-esc="_loyaltyStat.points.name"/>: <span t-esc='_loyaltyStat.points.balance' class="pos-receipt-right-align"/></div>
</t>
<br />
<div><t t-esc='receipt.loyalty.partner_name' /></div>
<br/>
</span>
<t t-if='receipt.loyalty.newPoints.won'>
<div><t t-esc="receipt.loyalty.point_name"/> Won: <span t-esc='receipt.loyalty.newPoints.won' class="pos-receipt-right-align"/></div>
</t>
<t t-if='receipt.loyalty.newPoints.spent'>
<div><t t-esc="receipt.loyalty.point_name"/> Spent: <span t-esc='receipt.loyalty.newPoints.spent' class="pos-receipt-right-align"/></div>
</t>
<t t-if='receipt.loyalty.newPoints.total'>
<div>Total <t t-esc="receipt.loyalty.point_name"/>: <span t-esc='receipt.loyalty.newPoints.total' class="pos-receipt-right-align"/></div>
</t>
<br />
</div>
</div>
</t>
</t>
<t t-if="receipt.new_coupon_info and receipt.new_coupon_info.length !== 0">
<div class="pos-coupon-rewards">
@@ -1,31 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="OrderSummary" t-inherit="point_of_sale.OrderSummary" t-inherit-mode="extension" owl="1">
<xpath expr="//div[hasclass('summary')]//div[hasclass('line')]" position="before">
<t t-set="newPoints" t-value="getLoyaltyPoints()"/>
<t t-if="newPoints.won || newPoints.spent">
<div class='loyalty-points'>
<div class='loyalty-points-title'>
<t t-esc="newPoints.name"/>
</div>
<t t-if='newPoints.won'>
<div class="loyalty-points-won">
<span class='value'>+<t t-esc='newPoints.won'/></span>
<xpath expr="//div[hasclass('summary')]" position="after">
<div class="summary clearfix">
<t t-set="_loyaltyStats" t-value="getLoyaltyPoints()"/>
<t t-foreach="_loyaltyStats" t-as="_loyaltyStat" t-key="_loyaltyStat.couponId">
<t t-if="_loyaltyStat.points.won || _loyaltyStat.points.spent">
<div class='loyalty-points'>
<div class='loyalty-points-title'>
<t t-esc="_loyaltyStat.points.name"/>
</div>
<t t-if='_loyaltyStat.points.won'>
<div class="loyalty-points-won">
<span class='value'>+<t t-esc='_loyaltyStat.points.won'/></span>
</div>
</t>
<t t-if='_loyaltyStat.points.spent'>
<div class="loyalty-points-spent">
<span class='value'>-<t t-esc='_loyaltyStat.points.spent'/></span>
</div>
</t>
<div class='loyalty-points-total'>
<span class='value'><t t-esc='_loyaltyStat.points.total'/></span>
</div>
</div>
</t>
<t t-if='newPoints.spent'>
<div class="loyalty-points-spent">
<span class='value'>-<t t-esc='newPoints.spent'/></span>
</div>
<t t-else="">
<div></div>
</t>
<div class='loyalty-points-total'>
<span class='value'><t t-esc='newPoints.total'/></span>
</div>
</div>
</t>
<t t-else="">
<div></div>
</t>
</t>
</div>
</xpath>
</t>
</templates>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_loyalty.Orderline" t-inherit="point_of_sale.Orderline" t-inherit-mode="extension" owl="1">
<xpath expr="//ul[hasclass('info-list')]" position="attributes">
<attribute name="t-if">!_isGiftCardOrEWalletReward()</attribute>
</xpath>
<xpath expr="//ul[hasclass('info-list')]" position="after">
<ul t-else="" class="info-list">
Current Balance: <span t-esc="_getGiftCardOrEWalletBalance()"/>
</ul>
</xpath>
</t>
</templates>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_loyalty.PartnerLine" t-inherit="point_of_sale.PartnerLine" t-inherit-mode="extension" owl="1">
<xpath expr="//td[hasclass('partner-line-balance')]" position="inside">
<t t-set="_loyaltyCards" t-value="env.pos.getLoyaltyCards(props.partner)" />
<t t-foreach="_loyaltyCards" t-as="_loyaltyCard" t-key="_loyaltyCard.id">
<div class="pos-right-align">
<t t-esc="_getLoyaltyPointsRepr(_loyaltyCard)"/>
</div>
</t>
</xpath>
</t>
</templates>
@@ -1,106 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="GiftCardPopup" owl="1">
<Draggable>
<div class="popup popup-textarea">
<header class="title drag-handle">
Gift Card
</header>
<main class="gift-card-popup">
<div class="gift-card-error" t-if="state.error != ''">
<span t-esc="state.error"/>
</div>
<div t-if="state.showMenu" class="gift-card-container">
<span t-if="state.giftCardConfig == 'create_set'"
class="gift-card-button button"
t-on-click="switchToBarcode">Generate barcode</span>
<span t-if="state.giftCardConfig == 'scan_set'"
class="gift-card-button button"
t-on-click="switchToBarcode">Scan and set price on gift card</span>
<span t-if="state.giftCardConfig == 'scan_use'"
class="gift-card-button button"
t-on-click="switchToBarcode">Scan gift card</span>
</div>
<div t-if="!state.showMenu" class="gift-card-container">
<div class="gift-card-container" t-if="state.context == 'create_set'">
<span>Amount of the gift card:</span>
<div class="gift-card-input-container">
<input t-model.number="state.amountToSet" class="gift-card-input-amount" type="text"/>
<span class="currency">
<t t-esc="env.pos.getCurrencySymbol()" />
</span>
</div>
</div>
<div class="gift-card-container" t-if="state.context == 'scan_set'">
<div class="gift-card-input-container" style="margin: 10px">
Gift Card Barcode:
<input t-model="state.code" class="gift-card-input-code" type="text"/>
</div>
<div class="gift-card-input-container">
Amount of the gift card:
<input t-model.number="state.amountToSet" class="gift-card-input-amount" type="text"/>
<span class="currency">
<t t-esc="env.pos.getCurrencySymbol()" />
</span>
</div>
</div>
<div class="gift-card-container" t-if="state.context == 'scan_use'">
<div class="gift-card-input-container">
Gift Card Barcode:
<input t-model="state.code" class="gift-card-input-code" type="text"/>
</div>
</div>
</div>
<div class="gift-card-container" t-if="!state.showMenu &amp;&amp; state.context == 'pay'">
<div class="gift-card-input-container">
Gift Card Barcode:
<input t-model="state.code" class="gift-card-input-code" type="text"/>
</div>
</div>
<div class="gift-card-container" t-if="!state.showMenu &amp;&amp; state.context == 'balance'">
<div class="gift-card-input-container" style="margin: 10px">
Gift Card Barcode:
<input t-model="state.code" class="gift-card-input-code" type="text"/>
</div>
<div>
Remaining amount of the gift card:
<t t-esc="state.amountToSet"/>
<span class="currency">
<t t-esc="env.pos.getCurrencySymbol()" />
</span>
</div>
</div>
<div class="gift-card-container" t-if="state.showMenu" style="flex-direction: row;">
<div class="button gift-card-button-confirm" t-on-click="switchToPay">
Use a gift card
</div>
<div class="button gift-card-button-confirm" t-on-click="switchToBalance">
Check a gift card
</div>
</div>
</main>
<footer class="footer gift-card-footer">
<div class="button cancel gift-card-footer-button" t-on-click="cancel">
Close
</div>
<div t-if="!state.showMenu" class="button cancel gift-card-footer-button" t-on-click="switchToMenu">
Back
</div>
<div t-if="!state.showMenu" class="button confirm gift-card-footer-button" t-on-click="confirm">
Confirm
</div>
</footer>
</div>
</Draggable>
</t>
</templates>
+168 -1
View File
@@ -117,7 +117,19 @@ class TestUi(TestPointOfSaleHttpCommon):
})
self.main_pos_config.open_ui()
def create_programs(self, details):
"""
Create loyalty programs based on the details given.
:param details: list of tuple ('name': str, 'program_type': 'gift_card' or 'ewallet')
"""
LoyaltyProgram = self.env['loyalty.program']
programs = {} # map: name -> program
for (name, program_type) in details:
program_id = LoyaltyProgram.create_from_template(program_type)['res_id']
program = LoyaltyProgram.browse(program_id)
program.write({'name': name})
programs[name] = program
return programs
def test_pos_loyalty_tour_basic(self):
"""PoS Loyalty Basic Tour"""
@@ -414,3 +426,158 @@ class TestUi(TestPointOfSaleHttpCommon):
"PosLoyaltyTour3",
login="accountman",
)
def test_gift_card_program_create_set(self):
"""
Test for gift card program when pos.config.gift_card_settings == 'create_set'.
"""
LoyaltyProgram = self.env['loyalty.program']
# Deactivate all other programs to avoid interference
(LoyaltyProgram.search([])).write({'pos_ok': False})
# But activate the gift_card_product_50 because it's shared among new gift card programs.
self.env.ref('loyalty.gift_card_product_50').write({'active': True})
# Create gift card program
gift_card_program = self.create_programs([('arbitrary_name', 'gift_card')])['arbitrary_name']
# Change the gift card program settings
self.main_pos_config.write({'gift_card_settings': 'create_set'})
# Run the tour to create a gift card
self.start_tour(
"/pos/web?config_id=%d" % self.main_pos_config.id,
"GiftCardProgramCreateSetTour1",
login="accountman",
)
# Check that gift cards are created
self.assertEqual(len(gift_card_program.coupon_ids), 1)
# Change the code to 044123456 so that we can use it in the next tour.
# Make sure it starts with 044 because it's the prefix of the loyalty cards.
gift_card_program.coupon_ids.code = '044123456'
# Run the tour to use the gift card
self.start_tour(
"/pos/web?config_id=%d" % self.main_pos_config.id,
"GiftCardProgramCreateSetTour2",
login="accountman",
)
# Check that gift cards are used
self.assertEqual(gift_card_program.coupon_ids.points, 46.8)
def test_gift_card_program_scan_use(self):
"""
Test for gift card program with pos.config.gift_card_settings == 'scan_use'.
- The gift card coupon codes are known before opening pos.
- They will be scanned and paid by the customer which links the coupon to the order.
- Meaning, it's paid.
- Then it will be scanned for usage.
"""
LoyaltyProgram = self.env['loyalty.program']
# Deactivate all other programs to avoid interference
(LoyaltyProgram.search([])).write({'pos_ok': False})
# But activate the gift_card_product_50 because it's shared among new gift card programs.
self.env.ref('loyalty.gift_card_product_50').write({'active': True})
# Create gift card program
gift_card_program = self.create_programs([('arbitrary_name', 'gift_card')])['arbitrary_name']
# Change the gift card program settings
self.main_pos_config.write({'gift_card_settings': 'scan_use'})
# Generate 5$ gift card.
self.env["loyalty.generate.wizard"].with_context(
{"active_id": gift_card_program.id}
).create({"coupon_qty": 1, 'points_granted': 5}).generate_coupons()
# Change the code of the gift card.
gift_card_program.coupon_ids.code = '044123456'
# Run the tour. It will pay the gift card and use it.
self.start_tour(
"/pos/web?config_id=%d" % self.main_pos_config.id,
"GiftCardProgramScanUseTour",
login="accountman",
)
# Check that gift cards are used
self.assertAlmostEqual(gift_card_program.coupon_ids.points, 0, places=2)
# 3 order should be created.
self.assertEqual(len(self.main_pos_config.current_session_id.order_ids), 3)
def test_ewallet_program(self):
"""
Test for ewallet program.
- Collect points in EWalletProgramTour1.
- Use points in EWalletProgramTour2.
"""
LoyaltyProgram = self.env['loyalty.program']
# Deactivate all other programs to avoid interference
(LoyaltyProgram.search([])).write({'pos_ok': False})
# But activate the ewallet_product_50 because it's shared among new ewallet programs.
self.env.ref('loyalty.ewallet_product_50').write({'active': True})
# Create ewallet program
ewallet_program = self.create_programs([('arbitrary_name', 'ewallet')])['arbitrary_name']
# Create test partners
partner_aaa = self.env['res.partner'].create({'name': 'AAAAAAA'})
partner_bbb = self.env['res.partner'].create({'name': 'BBBBBBB'})
# Run the tour to topup ewallets.
self.start_tour(
"/pos/web?config_id=%d" % self.main_pos_config.id,
"EWalletProgramTour1",
login="accountman",
)
# Check that ewallets are created for partner_aaa.
ewallet_aaa = self.env['loyalty.card'].search([('partner_id', '=', partner_aaa.id), ('program_id', '=', ewallet_program.id)])
self.assertEqual(len(ewallet_aaa), 1)
self.assertAlmostEqual(ewallet_aaa.points, 50, places=2)
# Check that ewallets are created for partner_bbb.
ewallet_bbb = self.env['loyalty.card'].search([('partner_id', '=', partner_bbb.id), ('program_id', '=', ewallet_program.id)])
self.assertEqual(len(ewallet_bbb), 1)
self.assertAlmostEqual(ewallet_bbb.points, 10, places=2)
# Run the tour consume ewallets.
self.start_tour(
"/pos/web?config_id=%d" % self.main_pos_config.id,
"EWalletProgramTour2",
login="accountman",
)
# Check that ewallets are consumed for partner_aaa.
self.assertAlmostEqual(ewallet_aaa.points, 0, places=2)
# Check final balance after consumption and refund eWallet for partner_bbb.
self.assertAlmostEqual(ewallet_bbb.points, 20, places=2)
def test_multiple_gift_wallet_programs(self):
"""
Test for multiple gift_card and ewallet programs.
"""
LoyaltyProgram = self.env['loyalty.program']
# Deactivate all other programs to avoid interference
(LoyaltyProgram.search([])).write({'pos_ok': False})
# But activate the gift_card_product_50 and ewallet_product_50 because they're shared among new programs.
self.env.ref('loyalty.gift_card_product_50').write({'active': True})
self.env.ref('loyalty.ewallet_product_50').write({'active': True})
# Create programs
programs = self.create_programs([
('gift_card_1', 'gift_card'),
('gift_card_2', 'gift_card'),
('ewallet_1', 'ewallet'),
('ewallet_2', 'ewallet')
])
# Change the gift card program settings
self.main_pos_config.write({'gift_card_settings': 'create_set'})
# Create test partners
partner_aaa = self.env['res.partner'].create({'name': 'AAAAAAA'})
partner_bbb = self.env['res.partner'].create({'name': 'BBBBBBB'})
# Run the tour to topup ewallets.
self.start_tour(
"/pos/web?config_id=%d" % self.main_pos_config.id,
"MultipleGiftWalletProgramsTour",
login="accountman",
)
# Check the created gift cards.
self.assertEqual(len(programs['gift_card_1'].coupon_ids), 1)
self.assertAlmostEqual(programs['gift_card_1'].coupon_ids.points, 10)
self.assertEqual(len(programs['gift_card_2'].coupon_ids), 1)
self.assertAlmostEqual(programs['gift_card_2'].coupon_ids.points, 20)
# Check the created ewallets.
ewallet_1_aaa = self.env['loyalty.card'].search([('partner_id', '=', partner_aaa.id), ('program_id', '=', programs['ewallet_1'].id)])
self.assertEqual(len(ewallet_1_aaa), 1)
self.assertAlmostEqual(ewallet_1_aaa.points, 18, places=2)
ewallet_2_aaa = self.env['loyalty.card'].search([('partner_id', '=', partner_aaa.id), ('program_id', '=', programs['ewallet_2'].id)])
self.assertEqual(len(ewallet_2_aaa), 1)
self.assertAlmostEqual(ewallet_2_aaa.points, 40, places=2)
ewallet_1_bbb = self.env['loyalty.card'].search([('partner_id', '=', partner_bbb.id), ('program_id', '=', programs['ewallet_1'].id)])
self.assertEqual(len(ewallet_1_bbb), 1)
self.assertAlmostEqual(ewallet_1_bbb.points, 50, places=2)
ewallet_2_bbb = self.env['loyalty.card'].search([('partner_id', '=', partner_bbb.id), ('program_id', '=', programs['ewallet_2'].id)])
self.assertEqual(len(ewallet_2_bbb), 1)
self.assertAlmostEqual(ewallet_2_bbb.points, 0, places=2)
@@ -7,7 +7,7 @@
<field name="inherit_id" ref="loyalty.loyalty_program_view_form"/>
<field name="arch" type="xml">
<field name="mail_template_id" position="after">
<field name="pos_report_print_id" attrs="{'invisible': [('program_type', 'not in', ('gift_card', 'ewallet'))]}" />
<field name="pos_report_print_id" attrs="{'invisible': [('program_type', '!=', 'gift_card')]}" />
</field>
<xpath expr="//label[@for='available_on']" position="attributes">
<attribute name="invisible">0</attribute>
@@ -3,7 +3,7 @@ odoo.define('pos_restaurant.tour.FloorScreen', function (require) {
const { Chrome } = require('pos_restaurant.tour.ChromeTourMethods');
const { FloorScreen } = require('pos_restaurant.tour.FloorScreenTourMethods');
const { TextInputPopup } = require('pos_restaurant.tour.TextInputPopupTourMethods');
const { TextInputPopup } = require('point_of_sale.tour.TextInputPopupTourMethods');
const { NumberPopup } = require('point_of_sale.tour.NumberPopupTourMethods');
const { ProductScreen } = require('pos_restaurant.tour.ProductScreenTourMethods');
const { getSteps, startSteps } = require('point_of_sale.tour.utils');
@@ -6,11 +6,11 @@ import Registries from 'point_of_sale.Registries';
export const PosSaleLoyaltyOrderline = (Orderline) => class PosSaleLoyaltyOrderline extends Orderline {
//@override
ignoreLoyaltyPoints() {
ignoreLoyaltyPoints(args) {
if (this.sale_order_origin_id) {
return true;
}
return super.ignoreLoyaltyPoints(...arguments);
return super.ignoreLoyaltyPoints(args);
}
//@override
setQuantityFromSOL(saleOrderLine) {
@@ -29,3 +29,6 @@ class LoyaltyCard(models.Model):
count_per_coupon = {r['coupon_id'][0]: r['coupon_id_count'] for r in read_group_res}
for card in self:
card.use_count += count_per_coupon.get(card.id, 0)
def _has_source_order(self):
return super()._has_source_order() or bool(self.order_id)