[IMP] point_of_sale,pos_*: allow users to manage orders in the pos frontend

We now allow invoicing of finalized orders in an open session in this first
iteration of order management in the pos frontend. Additionally, receipt
reprinting is also introduced. With this feature, we can now reprint the
receipt of old orders.

closes odoo/odoo#51141

Task-id: 1981354
Related: odoo/enterprise#11690
Related: odoo/upgrade#1472
Signed-off-by: pimodoo <pimodoo@users.noreply.github.com>
This commit is contained in:
Joseph Caburnay
2020-07-17 10:00:04 +00:00
parent 39ff9d95c2
commit b2f8170d88
89 changed files with 2511 additions and 328 deletions
+11
View File
@@ -55,6 +55,16 @@
'static/src/xml/Screens/ClientListScreen/ClientDetails.xml',
'static/src/xml/Screens/ClientListScreen/ClientDetailsEdit.xml',
'static/src/xml/Screens/ClientListScreen/ClientListScreen.xml',
'static/src/xml/Screens/OrderManagementScreen/ControlButtons/InvoiceButton.xml',
'static/src/xml/Screens/OrderManagementScreen/ControlButtons/ReprintReceiptButton.xml',
'static/src/xml/Screens/OrderManagementScreen/OrderManagementScreen.xml',
'static/src/xml/Screens/OrderManagementScreen/MobileOrderManagementScreen.xml',
'static/src/xml/Screens/OrderManagementScreen/OrderManagementControlPanel.xml',
'static/src/xml/Screens/OrderManagementScreen/OrderList.xml',
'static/src/xml/Screens/OrderManagementScreen/OrderRow.xml',
'static/src/xml/Screens/OrderManagementScreen/OrderDetails.xml',
'static/src/xml/Screens/OrderManagementScreen/OrderlineDetails.xml',
'static/src/xml/Screens/OrderManagementScreen/ReprintReceiptScreen.xml',
'static/src/xml/Screens/PaymentScreen/PSNumpadInputButton.xml',
'static/src/xml/Screens/PaymentScreen/PaymentScreenNumpad.xml',
'static/src/xml/Screens/PaymentScreen/PaymentScreenElectronicPayment.xml',
@@ -83,6 +93,7 @@
'static/src/xml/ChromeWidgets/CashierName.xml',
'static/src/xml/ChromeWidgets/ProxyStatus.xml',
'static/src/xml/ChromeWidgets/SyncNotification.xml',
'static/src/xml/ChromeWidgets/OrderManagementButton.xml',
'static/src/xml/ChromeWidgets/HeaderButton.xml',
'static/src/xml/ChromeWidgets/SaleDetailsButton.xml',
'static/src/xml/SaleDetailsReport.xml',
+10 -1
View File
@@ -201,7 +201,7 @@ class PosConfig(models.Model):
module_pos_discount = fields.Boolean("Global Discounts")
module_pos_loyalty = fields.Boolean("Loyalty Program")
module_pos_mercury = fields.Boolean(string="Integrated Card Payments")
module_pos_reprint = fields.Boolean(string="Reprint Receipt")
manage_orders = fields.Boolean(string="Manage Orders")
is_posbox = fields.Boolean("PosBox")
is_header_or_footer = fields.Boolean("Header & Footer")
module_pos_hr = fields.Boolean(help="Show employee login screen")
@@ -217,6 +217,7 @@ class PosConfig(models.Model):
cash_rounding = fields.Boolean(string="Cash Rounding")
only_round_cash_method = fields.Boolean(string="Only apply rounding on cash")
has_active_session = fields.Boolean(compute='_compute_current_session')
show_allow_invoicing_alert = fields.Boolean(compute="_compute_show_allow_invoicing_alert")
@api.depends('use_pricelist', 'available_pricelist_ids')
def _compute_allowed_pricelist_ids(self):
@@ -260,6 +261,14 @@ class PosConfig(models.Model):
pos_config.current_session_id = session and session[0].id or False
pos_config.current_session_state = session and session[0].state or False
@api.depends('module_account', 'manage_orders')
def _compute_show_allow_invoicing_alert(self):
for pos_config in self:
if not pos_config.manage_orders:
pos_config.show_allow_invoicing_alert = False
else:
pos_config.show_allow_invoicing_alert = not pos_config.module_account
@api.depends('session_ids')
def _compute_last_session(self):
PosSession = self.env['pos.session']
+66 -1
View File
@@ -177,6 +177,7 @@ class PosOrder(models.Model):
'amount': -pos_order['amount_return'],
'payment_date': fields.Date.context_today(self),
'payment_method_id': cash_payment_method.id,
'is_change': True,
}
order.add_payment(return_payment_vals)
@@ -253,7 +254,6 @@ class PosOrder(models.Model):
to_invoice = fields.Boolean('To invoice')
is_invoiced = fields.Boolean('Is Invoiced', compute='_compute_is_invoiced')
@api.depends('account_move')
def _compute_is_invoiced(self):
for order in self:
@@ -572,6 +572,48 @@ class PosOrder(models.Model):
orders.sudo().unlink()
return orders.ids
@api.model
def search_paid_order_ids(self, config_id, domain, limit, offset):
"""Search for 'paid' orders that satisfy the given domain, limit and offset."""
default_domain = ['&', ('config_id', '=', config_id), '!', '|', ('state', '=', 'draft'), ('state', '=', 'cancelled')]
real_domain = AND([domain, default_domain])
ids = self.search(AND([domain, default_domain]), limit=limit, offset=offset).ids
totalCount = self.search_count(real_domain)
return {'ids': ids, 'totalCount': totalCount}
def _export_for_ui(self, order):
timezone = pytz.timezone(self._context.get('tz') or self.env.user.tz or 'UTC')
return {
'lines': [[0, 0, line] for line in order.lines.export_for_ui()],
'statement_ids': [[0, 0, payment] for payment in order.payment_ids.export_for_ui()],
'name': order.pos_reference,
'uid': order.pos_reference[6:],
'amount_paid': order.amount_paid,
'amount_total': order.amount_total,
'amount_tax': order.amount_tax,
'amount_return': order.amount_return,
'pos_session_id': order.session_id.id,
'is_session_closed': order.session_id.state == 'closed',
'pricelist_id': order.pricelist_id.id,
'partner_id': order.partner_id.id,
'user_id': order.user_id.id,
'sequence_number': order.sequence_number,
'creation_date': order.date_order.astimezone(timezone),
'fiscal_position_id': order.fiscal_position_id.id,
'to_invoice': order.to_invoice,
'state': order.state,
'account_move': order.account_move.id,
'id': order.id,
}
def export_for_ui(self):
""" Returns a list of dict with each item having similar signature as the return of
`export_as_JSON` of models.Order. This is useful for back-and-forth communication
between the pos frontend and backend.
"""
return self.mapped(self._export_for_ui) if self else []
class PosOrderLine(models.Model):
_name = "pos.order.line"
_description = "Point of Sale Order Lines"
@@ -702,6 +744,22 @@ class PosOrderLine(models.Model):
for line in self:
line.tax_ids_after_fiscal_position = line.order_id.fiscal_position_id.map_tax(line.tax_ids, line.product_id, line.order_id.partner_id)
def _export_for_ui(self, orderline):
return {
'qty': orderline.qty,
'price_unit': orderline.price_unit,
'price_subtotal': orderline.price_subtotal,
'price_subtotal_incl': orderline.price_subtotal_incl,
'product_id': orderline.product_id.id,
'discount': orderline.discount,
'tax_ids': [[6, False, orderline.tax_ids.mapped(lambda tax: tax.id)]],
'id': orderline.id,
'pack_lot_ids': [[0, 0, lot] for lot in orderline.pack_lot_ids.export_for_ui()],
}
def export_for_ui(self):
return self.mapped(self._export_for_ui) if self else []
class PosOrderLineLot(models.Model):
_name = "pos.pack.operation.lot"
@@ -713,6 +771,13 @@ class PosOrderLineLot(models.Model):
lot_name = fields.Char('Lot Name')
product_id = fields.Many2one('product.product', related='pos_order_line_id.product_id', readonly=False)
def _export_for_ui(self, lot):
return {
'lot_name': lot.lot_name,
}
def export_for_ui(self):
return self.mapped(self._export_for_ui) if self else []
class ReportSaleDetails(models.AbstractModel):
@@ -27,6 +27,8 @@ class PosPayment(models.Model):
card_type = fields.Char('Type of card used')
transaction_id = fields.Char('Payment Transaction ID')
payment_status = fields.Char('Payment Status')
ticket = fields.Char('Payment Receipt Info')
is_change = fields.Boolean(string='Is this payment change?', default=False)
@api.model
def name_get(self):
@@ -37,3 +39,17 @@ class PosPayment(models.Model):
else:
res.append((payment.id, formatLang(self.env, payment.amount, currency_obj=payment.currency_id)))
return res
def _export_for_ui(self, payment):
return {
'payment_method_id': payment.payment_method_id.id,
'amount': payment.amount,
'payment_status': payment.payment_status,
'card_type': payment.card_type,
'transaction_id': payment.transaction_id,
'ticket': payment.ticket,
'is_change': payment.is_change,
}
def export_for_ui(self):
return self.mapped(self._export_for_ui) if self else []
+179
View File
@@ -2838,3 +2838,182 @@ td {
cursor: -moz-grabbing;
cursor: -webkit-grabbing;
}
/* Order Management */
.order-row {
display: flex;
flex-direction: row;
justify-content: space-evenly;
background-color: rgb(230,230,230);
}
.order-list .order-row:hover {
color: white;
background-color: rgb(110,200,155);
font-weight: bold;
}
.order-row.highlight {
color: white;
background-color: rgb(110,200,155);
font-weight: bold;
}
.order-row.lighter {
background-color: #f5f5f5;
cursor: pointer;
}
.order-row .header {
font-size: medium;
font-weight: bolder;
flex-grow: 1;
flex-basis: 0;
text-align: left;
padding: 10px 10px;
background-color: #cecece;
border-bottom: solid 1px;
border-top: solid 1px
}
.order-row .header.total {
text-align: right;
}
.order-row .item {
font-size: medium;
flex-grow: 1;
flex-basis: 0;
text-align: left;
padding: 10px 10px;
border-bottom: solid rgb(150,150,150) 1px;
}
.order-row .item.total {
text-align: right;
}
.order-management-screen .flex-container {
display: flex;
flex-direction: column;
height: 100%;
}
.order-management-screen .orders {
display: flex;
flex-direction: column;
}
.order-management-screen .order-list {
flex: 1;
overflow-y: auto;
}
.order-management-screen .control-panel {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin: 0.5rem;
}
.order-management-screen .control-panel .item {
font-size: medium;
}
.order-management-screen .control-panel .item .page-controls {
display: flex;
flex-direction: row;
align-items: center;
font-size: x-large;
}
.order-management-screen .control-panel .page-controls > div {
border: darkgray solid 1px;
border-radius: 2px;
}
.order-management-screen .control-panel .page-controls > div:hover {
color: rgb(110,200,155);
}
.order-management-screen .control-panel .page-controls .previous {
margin-right: 0.2rem;
}
.order-management-screen .control-panel .page-controls .next {
margin-left: 0.2rem;
}
.order-management-screen .control-panel .search-box {
flex: 1;
position: relative;
text-align: center;
margin: 0.2rem;
}
.order-management-screen .control-panel .search-box .clear {
position: relative;
right: 25px;
cursor: pointer;
color: #808080;
font-size: 13px;
}
.order-management-screen .control-panel .search-box .icon {
position: relative;
left: 25px;
color: #808080;
font-size: 13px;
}
.order-management-screen .control-panel .search-box input {
border: 1px solid #cecbcb;
padding: 10px 30px;
margin: auto;
background-color: white;
border-radius: 20px;
font-family: "Lato","Lucida Grande", Helvetica, Verdana, Arial;
font-size: 13px;
}
.order-management-screen .control-panel .search-box input:focus {
outline: none;
box-shadow: 0px 0px 0px 2px rgb(153, 153, 255) inset;
color: rgb(153, 153, 255);
}
.order-management-screen .control-panel .button {
line-height: 32px;
padding: 3px 13px;
font-size: 20px;
background: rgb(230, 230, 230);
border-radius: 3px;
border: solid 1px rgb(209, 209, 209);
cursor: pointer;
transition: all 150ms linear;
}
.order-management-screen .control-panel .button:hover {
background: #efefef;
}
.order-management-screen .back-to-list {
font-size: large;
padding: 10px;
background-color: #6EC89B;
color: white;
}
.order-receipt {
color: white;
font-size: medium;
text-align: center;
}
.order-receipt .title {
font-size: large;
}
+24 -10
View File
@@ -1,7 +1,7 @@
odoo.define('point_of_sale.Chrome', function(require) {
'use strict';
const { useState, useRef } = owl.hooks;
const { useState, useRef, useContext } = owl.hooks;
const { debounce } = owl.utils;
const { loadCSS } = require('web.ajax');
const { useListener } = require('web.custom_hooks');
@@ -11,6 +11,8 @@ odoo.define('point_of_sale.Chrome', function(require) {
const NumberBuffer = require('point_of_sale.NumberBuffer');
const PopupControllerMixin = require('point_of_sale.PopupControllerMixin');
const Registries = require('point_of_sale.Registries');
const IndependentToOrderScreen = require('point_of_sale.IndependentToOrderScreen');
const contexts = require('point_of_sale.PosContext');
// This is kind of a trick.
// We get a reference to the whole exports so that
@@ -34,6 +36,8 @@ odoo.define('point_of_sale.Chrome', function(require) {
useListener('set-sync-status', this._onSetSyncStatus);
NumberBuffer.activate();
this.chromeContext = useContext(contexts.chrome);
this.state = useState({
uiState: 'LOADING', // 'LOADING' | 'READY' | 'CLOSING'
debugWidgetIsShown: true,
@@ -138,10 +142,13 @@ odoo.define('point_of_sale.Chrome', function(require) {
if (_.isEmpty(this.env.pos.db.product_by_category_id)) {
this._loadDemoData();
}
this.env.pos.push_orders(); // push order in the background, no need to await
// Allow using the app even if not all the images are loaded.
// Basically, preload the images in the background.
setTimeout(() => this._preloadImages());
setTimeout(() => {
// push order in the background, no need to await
this.env.pos.push_orders();
// Allow using the app even if not all the images are loaded.
// Basically, preload the images in the background.
this._preloadImages();
});
} catch (error) {
let title = 'Unknown Error',
body;
@@ -202,14 +209,21 @@ odoo.define('point_of_sale.Chrome', function(require) {
__closeTempScreen() {
this.tempScreen.isShown = false;
}
__showScreen({ detail: { name, props } }) {
__showScreen({ detail: { name, props = {} } }) {
const component = this.constructor.components[name];
// 1. Set the information of the screen to display.
this.mainScreen.name = name;
this.mainScreen.component = this.constructor.components[name];
this.mainScreenProps = props || {};
// 2. Save the screen to the order.
this.mainScreen.component = component;
this.mainScreenProps = props;
// 2. Set some options
this.chromeContext.showOrderSelector = !component.hideOrderSelector;
// 3. Save the screen to the order.
// - This screen is shown when the order is selected.
this._setScreenData(name, props);
if (!(component.prototype instanceof IndependentToOrderScreen)) {
this._setScreenData(name, props);
}
}
/**
* Set the latest screen to the current order. This is done so that
@@ -0,0 +1,36 @@
odoo.define('point_of_sale.OrderManagementButton', function (require) {
'use strict';
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
const { isRpcError } = require('point_of_sale.utils');
class OrderManagementButton extends PosComponent {
async onClick() {
try {
// ping the server, if no error, show the screen
await this.rpc({
model: 'pos.order',
method: 'browse',
args: [[]],
kwargs: { context: this.env.session.user_context },
});
this.showScreen('OrderManagementScreen');
} catch (error) {
if (isRpcError(error) && error.message.code < 0) {
this.showPopup('ErrorPopup', {
title: this.env._t('Network Error'),
body: this.env._t('Cannot access order management screen if offline.'),
});
} else {
throw error;
}
}
}
}
OrderManagementButton.template = 'OrderManagementButton';
Registries.Component.add(OrderManagementButton);
return OrderManagementButton;
});
@@ -1,11 +1,18 @@
odoo.define('point_of_sale.OrderSelector', function(require) {
'use strict';
const { useContext } = owl.hooks;
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
const contexts = require('point_of_sale.PosContext');
// Previously OrderSelectorWidget
class OrderSelector extends PosComponent {
constructor() {
super(...arguments);
// this is used in its template
this.chromeContext = useContext(contexts.chrome);
}
mounted() {
this.env.pos.get('orders').on('add remove change', () => this.render(), this);
this.env.pos.on('change:selectedOrder', () => this.render(), this);
@@ -0,0 +1,84 @@
odoo.define('point_of_sale.ControlButtonsMixin', function (require) {
'use strict';
const Registries = require('point_of_sale.Registries');
/**
* Component that has this mixin allows the use of `addControlButton`.
* All added control buttons that satisfies the condition can be accessed
* thru the `controlButtons` field of the Component's instance. These
* control buttons can then be rendered in the Component.
* @param {Function} x superclass
*/
const ControlButtonsMixin = (x) => {
class Extended extends x {
get controlButtons() {
return this.constructor.controlButtons
.filter((cb) => {
return cb.condition.bind(this)();
})
.map((cb) =>
Object.assign({}, cb, { component: Registries.Component.get(cb.component) })
);
}
}
Extended.controlButtons = [];
/**
* @param {Object} controlButton
* @param {Function} controlButton.component
* Base class that is added in the Registries.Component.
* @param {Function} controlButton.condition zero argument function that is bound
* to the instance of ProductScreen, such that `this.env.pos` can be used
* inside the function.
* @param {Array} [controlButton.position] array of two elements
* [locator, relativeTo]
* locator: string -> any of ('before', 'after', 'replace')
* relativeTo: string -> other controlButtons component name
*/
Extended.addControlButton = function (controlButton) {
// We set the name first.
if (!controlButton.name) {
controlButton.name = controlButton.component.name;
}
// If no position is set, we just push it to the array.
if (!controlButton.position) {
this.controlButtons.push(controlButton);
} else {
// Find where to put the new controlButton.
const [locator, relativeTo] = controlButton.position;
let whereIndex = -1;
for (let i = 0; i < this.controlButtons.length; i++) {
if (this.controlButtons[i].name === relativeTo) {
if (['before', 'replace'].includes(locator)) {
whereIndex = i;
} else if (locator === 'after') {
whereIndex = i + 1;
}
break;
}
}
// If found where to put, then perform the necessary mutation of
// the buttons array.
// Else, we just push this controlButton to the array.
if (whereIndex > -1) {
this.controlButtons.splice(
whereIndex,
locator === 'replace' ? 1 : 0,
controlButton
);
} else {
let warningMessage =
`'${controlButton.name}' has invalid 'position' ([${locator}, ${relativeTo}]).` +
'It is pushed to the controlButtons stack instead.';
console.warn(warningMessage);
this.controlButtons.push(controlButton);
}
}
};
return Extended;
};
return ControlButtonsMixin;
});
@@ -0,0 +1,66 @@
odoo.define('point_of_sale.AbstractReceiptScreen', function (require) {
'use strict';
const { useRef } = owl.hooks;
const { nextFrame } = require('point_of_sale.utils');
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
/**
* This relies on the assumption that there is a reference to
* `order-receipt` so it is important to declare a `t-ref` to
* `order-receipt` in the template of the Component that extends
* this abstract component.
*/
class AbstractReceiptScreen extends PosComponent {
constructor() {
super(...arguments);
this.orderReceipt = useRef('order-receipt');
}
async _printReceipt() {
if (this.env.pos.proxy.printer) {
const printResult = await this.env.pos.proxy.printer.print_receipt(this.orderReceipt.el.outerHTML);
if (printResult.successful) {
return true;
} else {
const { confirmed } = await this.showPopup('ConfirmPopup', {
title: printResult.message.title,
body: 'Do you want to print using the web printer?',
});
if (confirmed) {
// We want to call the _printWeb when the popup is fully gone
// from the screen which happens after the next animation frame.
await nextFrame();
return await this._printWeb();
}
return false;
}
} else {
return await this._printWeb();
}
}
/**
* https://stackoverflow.com/questions/21285902/printing-a-part-of-webpage-with-javascript
*/
async _printWeb() {
try {
const isPrinted = document.execCommand('print', false, null);
if (!isPrinted) window.print();
return true;
} catch (err) {
await this.showPopup('ErrorPopup', {
title: this.env._t('Printing is not supported on some browsers'),
body: this.env._t(
'Printing is not supported on some browsers due to no default printing protocol ' +
'is available. It is possible to print your tickets by making use of an IoT Box.'
),
});
return false;
}
}
}
Registries.Component.add(AbstractReceiptScreen);
return AbstractReceiptScreen;
});
@@ -0,0 +1,18 @@
odoo.define('point_of_sale.IndependentToOrderScreen', function (require) {
'use strict';
const PosComponent = require('point_of_sale.PosComponent');
class IndependentToOrderScreen extends PosComponent {
close() {
// To close this order-indenpendent screen, we forcefully trigger change
// on the selectedOrder attribute, which then shows the screen of the
// current order.
this.env.pos.trigger('change:selectedOrder', this.env.pos, this.env.pos.get_order(), {
silent: true,
});
}
}
return IndependentToOrderScreen;
});
@@ -0,0 +1,12 @@
odoo.define('point_of_sale.PosContext', function (require) {
'use strict';
const { Context } = owl;
// Create global context objects
// e.g. component.env.device = new Context({ isMobile: false });
return {
orderManagement: new Context({ searchString: '', selectedOrder: null }),
chrome: new Context({ showOrderSelector: true }),
};
});
@@ -0,0 +1,155 @@
odoo.define('point_of_sale.InvoiceButton', function (require) {
'use strict';
const { useListener } = require('web.custom_hooks');
const { useContext } = owl.hooks;
const { isRpcError } = require('point_of_sale.utils');
const PosComponent = require('point_of_sale.PosComponent');
const OrderManagementScreen = require('point_of_sale.OrderManagementScreen');
const OrderFetcher = require('point_of_sale.OrderFetcher');
const Registries = require('point_of_sale.Registries');
const contexts = require('point_of_sale.PosContext');
class InvoiceButton extends PosComponent {
constructor() {
super(...arguments);
useListener('click', this._onClick);
this.orderManagementContext = useContext(contexts.orderManagement);
}
get selectedOrder() {
return this.orderManagementContext.selectedOrder;
}
set selectedOrder(value) {
this.orderManagementContext.selectedOrder = value;
}
get isAlreadyInvoiced() {
if (!this.selectedOrder) return false;
return Boolean(this.selectedOrder.account_move);
}
get commandName() {
if (!this.selectedOrder) {
return 'Invoice';
} else {
return this.isAlreadyInvoiced
? 'Reprint Invoice'
: this.selectedOrder.isFromClosedSession
? 'Cannot Invoice'
: 'Invoice';
}
}
get isHighlighted() {
return this.selectedOrder && !this.isAlreadyInvoiced && !this.selectedOrder.isFromClosedSession;
}
async _downloadInvoice(orderId) {
try {
await this.env.pos.do_action('point_of_sale.pos_invoice_report', {
additional_context: {
active_ids: [orderId],
},
});
} catch (error) {
if (error instanceof Error) {
throw error;
} else {
// NOTE: error here is most probably undefined
this.showPopup('ErrorPopup', {
title: this.env._t('Network Error'),
body: this.env._t('Unable to download invoice.'),
});
}
}
}
async _invoiceOrder() {
const order = this.selectedOrder;
if (!order) return;
const orderId = order.backendId;
// Part 0.1. If already invoiced, print the invoice.
if (this.isAlreadyInvoiced) {
await this._downloadInvoice(orderId);
return;
}
// Part 0.2. Check if order belongs to an active session.
// If not, do not allow invoicing.
if (order.isFromClosedSession) {
this.showPopup('ErrorPopup', {
title: this.env._t('Session is closed'),
body: this.env._t('Cannot invoice order from closed session.'),
});
return;
}
// Part 1: Handle missing client.
// Write to pos.order the selected client.
if (!order.get_client()) {
const { confirmed: confirmedPopup } = await this.showPopup('ConfirmPopup', {
title: 'Need customer to invoice',
body: 'Do you want to open the customer list to select customer?',
});
if (!confirmedPopup) return;
const { confirmed: confirmedTempScreen, payload: newClient } = await this.showTempScreen(
'ClientListScreen'
);
if (!confirmedTempScreen) return;
await this.rpc({
model: 'pos.order',
method: 'write',
args: [[orderId], { partner_id: newClient.id }],
kwargs: { context: this.env.session.user_context },
});
}
// Part 2: Invoice the order.
await this.rpc(
{
model: 'pos.order',
method: 'action_pos_order_invoice',
args: [orderId],
kwargs: { context: this.env.session.user_context },
},
{
timeout: 30000,
shadow: true,
}
);
// Part 3: Download invoice.
await this._downloadInvoice(orderId);
// Invalidate the cache then fetch the updated order.
OrderFetcher.invalidateCache([orderId]);
await OrderFetcher.fetch();
this.selectedOrder = OrderFetcher.get(this.selectedOrder.backendId);
}
async _onClick() {
try {
await this._invoiceOrder();
} catch (error) {
if (isRpcError(error) && error.message.code < 0) {
this.showPopup('ErrorPopup', {
title: this.env._t('Network Error'),
body: this.env._t('Unable to invoice order.'),
});
} else {
throw error;
}
}
}
}
InvoiceButton.template = 'InvoiceButton';
OrderManagementScreen.addControlButton({
component: InvoiceButton,
condition: function () {
return this.env.pos.config.module_account;
},
});
Registries.Component.add(InvoiceButton);
return InvoiceButton;
});
@@ -0,0 +1,48 @@
odoo.define('point_of_sale.ReprintReceiptButton', function (require) {
'use strict';
const { useListener } = require('web.custom_hooks');
const { useContext } = owl.hooks;
const PosComponent = require('point_of_sale.PosComponent');
const OrderManagementScreen = require('point_of_sale.OrderManagementScreen');
const Registries = require('point_of_sale.Registries');
const OrderReceipt = require('point_of_sale.OrderReceipt');
const contexts = require('point_of_sale.PosContext');
class ReprintReceiptButton extends PosComponent {
constructor() {
super(...arguments);
useListener('click', this._onClick);
this.orderManagementContext = useContext(contexts.orderManagement);
}
async _onClick() {
const order = this.orderManagementContext.selectedOrder;
if (!order) return;
if (this.env.pos.proxy.printer) {
const fixture = document.createElement('div');
const orderReceipt = new (Registries.Component.get(OrderReceipt))(this, { order });
await orderReceipt.mount(fixture);
const receiptHtml = orderReceipt.el.outerHTML;
const printResult = await this.env.pos.proxy.printer.print_receipt(receiptHtml);
if (!printResult.successful) {
this.showTempScreen('ReprintReceiptScreen', { order: order });
}
} else {
this.showTempScreen('ReprintReceiptScreen', { order: order });
}
}
}
ReprintReceiptButton.template = 'ReprintReceiptButton';
OrderManagementScreen.addControlButton({
component: ReprintReceiptButton,
condition: function () {
return true;
},
});
Registries.Component.add(ReprintReceiptButton);
return ReprintReceiptButton;
});
@@ -0,0 +1,25 @@
odoo.define('point_of_sale.MobileOrderManagementScreen', function (require) {
const OrderManagementScreen = require('point_of_sale.OrderManagementScreen');
const Registries = require('point_of_sale.Registries');
const { useListener } = require('web.custom_hooks');
const { useState } = owl.hooks;
const MobileOrderManagementScreen = (OrderManagementScreen) => {
class MobileOrderManagementScreen extends OrderManagementScreen {
constructor() {
super(...arguments);
useListener('click-order', this._onShowDetails)
this.mobileState = useState({ showDetails: false });
}
_onShowDetails() {
this.mobileState.showDetails = true;
}
}
MobileOrderManagementScreen.template = 'MobileOrderManagementScreen';
return MobileOrderManagementScreen;
};
Registries.Component.addByExtending(MobileOrderManagementScreen, OrderManagementScreen);
return MobileOrderManagementScreen;
});
@@ -0,0 +1,29 @@
odoo.define('point_of_sale.OrderDetails', function (require) {
'use strict';
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
/**
* @props {models.Order} order
*/
class OrderDetails extends PosComponent {
get order() {
return this.props.order;
}
get orderlines() {
return this.order ? this.order.orderlines.models : [];
}
get total() {
return this.env.pos.format_currency(this.order ? this.order.get_total_with_tax() : 0);
}
get tax() {
return this.env.pos.format_currency(this.order ? this.order.get_total_tax() : 0)
}
}
OrderDetails.template = 'OrderDetails';
Registries.Component.add(OrderDetails);
return OrderDetails;
});
@@ -0,0 +1,193 @@
odoo.define('point_of_sale.OrderFetcher', function (require) {
'use strict';
const { EventBus } = owl.core;
const { Gui } = require('point_of_sale.Gui');
const { isRpcError } = require('point_of_sale.utils');
const models = require('point_of_sale.models');
class OrderFetcher extends EventBus {
constructor() {
super();
this.currentPage = 1;
this.ordersToShow = [];
this.cache = {};
this.totalCount = 0;
}
get nActiveOrders() {
return this.activeOrders.length;
}
get lastPageFullOfActiveOrders() {
return Math.trunc(this.nActiveOrders / this.nPerPage);
}
get remainingActiveOrders() {
return this.nActiveOrders % this.nPerPage;
}
/**
* for nPerPage = 10
* +--------+----------+
* | nItems | lastPage |
* +--------+----------+
* | 2 | 1 |
* | 10 | 1 |
* | 11 | 2 |
* | 30 | 3 |
* | 35 | 4 |
* +--------+----------+
*/
get lastPage() {
const nItems = this.nActiveOrders + this.totalCount;
return Math.trunc(nItems / (this.nPerPage + 1)) + 1;
}
/**
* Calling this methods populates the `ordersToShow` then trigger `update` event.
* @related get
*
* NOTE: This is tightly-coupled with pagination. So if the current page contains all
* active orders, it will not fetch anything from the server but only sets `ordersToShow`
* to the active orders that fits the current page.
*/
async fetch() {
try {
let limit, offset;
let start, end;
if (this.currentPage <= this.lastPageFullOfActiveOrders) {
// Show only active orders.
start = (this.currentPage - 1) * this.nPerPage;
end = this.currentPage * this.nPerPage;
this.ordersToShow = this.activeOrders.slice(start, end);
} else if (this.currentPage === this.lastPageFullOfActiveOrders + 1) {
// Show partially the remaining active orders and
// some orders from the backend.
offset = 0;
limit = this.nPerPage - this.remainingActiveOrders;
start = (this.currentPage - 1) * this.nPerPage;
end = this.nActiveOrders;
this.ordersToShow = [
...this.activeOrders.slice(start, end),
...(await this._fetch(limit, offset)),
];
} else {
// Show orders from the backend.
offset =
this.nPerPage -
this.remainingActiveOrders +
(this.currentPage - (this.lastPageFullOfActiveOrders + 1) - 1) *
this.nPerPage;
limit = this.nPerPage;
this.ordersToShow = await this._fetch(limit, offset);
}
this.trigger('update');
} catch (error) {
if (isRpcError(error) && error.message.code < 0) {
Gui.showPopup('ErrorPopup', {
title: this.comp.env._t('Network Error'),
body: this.comp.env._t('Unable to fetch orders if offline.'),
});
Gui.setSyncStatus('error');
} else {
throw error;
}
}
}
/**
* This returns the orders from the backend that needs to be shown.
* If the order is already in cache, the full information about that
* order is not fetched anymore, instead, we use info from cache.
*
* @param {number} limit
* @param {number} offset
*/
async _fetch(limit, offset) {
const { ids, totalCount } = await this._getOrderIdsForCurrentPage(limit, offset);
const idsNotInCache = ids.filter((id) => !(id in this.cache));
if (idsNotInCache.length > 0) {
const fetchedOrders = await this._fetchOrders(idsNotInCache);
// Cache these fetched orders so that next time, no need to fetch
// them again, unless invalidated. See `invalidateCache`.
fetchedOrders.forEach((order) => {
this.cache[order.id] = new models.Order(
{},
{ pos: this.comp.env.pos, json: order }
);
});
}
this.totalCount = totalCount;
return ids.map((id) => this.cache[id]);
}
async _getOrderIdsForCurrentPage(limit, offset) {
return await this.rpc({
model: 'pos.order',
method: 'search_paid_order_ids',
kwargs: { config_id: this.configId, domain: this.searchDomain ? this.searchDomain : [], limit, offset },
context: this.comp.env.session.user_context,
});
}
async _fetchOrders(ids) {
return await this.rpc({
model: 'pos.order',
method: 'export_for_ui',
args: [ids],
context: this.comp.env.session.user_context,
});
}
nextPage() {
if (this.currentPage < this.lastPage) {
this.currentPage += 1;
this.fetch();
}
}
prevPage() {
if (this.currentPage > 1) {
this.currentPage -= 1;
this.fetch();
}
}
/**
* NOTE: If there is searchDomain, do not show the active orders.
* Unless we want the searchDomain to be applicable to the active orders
* as well. Don't we?
*
* @param {integer|undefined} id id of the cached order
* @returns {Array<models.Order>}
*/
get(id) {
if (id) return this.cache[id];
if (this.searchDomain) {
return this.ordersToShow.filter((order) => order.locked);
} else {
return this.ordersToShow;
}
}
setSearchDomain(searchDomain) {
this.searchDomain = searchDomain;
}
setComponent(comp) {
this.comp = comp;
this.activeOrders = this.comp.env.pos.get('orders').models;
return this;
}
setConfigId(configId) {
this.configId = configId;
}
setNPerPage(val) {
this.nPerPage = val;
}
setPage(page) {
this.currentPage = page;
}
invalidateCache(ids) {
for (let id of ids) {
delete this.cache[id];
}
}
async rpc() {
Gui.setSyncStatus('connecting');
const result = await this.comp.rpc(...arguments);
Gui.setSyncStatus('connected');
return result;
}
}
return new OrderFetcher();
});
@@ -0,0 +1,31 @@
odoo.define('point_of_sale.OrderList', function (require) {
'use strict';
const { useState } = owl.hooks;
const { useListener } = require('web.custom_hooks');
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
/**
* @props {models.Order} [initHighlightedOrder] initially highligted order
* @props {Array<models.Order>} orders
*/
class OrderList extends PosComponent {
constructor() {
super(...arguments);
useListener('click-order', this._onClickOrder);
this.state = useState({ highlightedOrder: this.props.initHighlightedOrder || null });
}
get highlightedOrder() {
return this.state.highlightedOrder;
}
_onClickOrder({ detail: order }) {
this.state.highlightedOrder = order;
}
}
OrderList.template = 'OrderList';
Registries.Component.add(OrderList);
return OrderList;
});
@@ -0,0 +1,124 @@
odoo.define('point_of_sale.OrderManagementControlPanel', function (require) {
'use strict';
const { useContext } = owl.hooks;
const { useAutofocus, useListener } = require('web.custom_hooks');
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
const OrderFetcher = require('point_of_sale.OrderFetcher');
const contexts = require('point_of_sale.PosContext');
// NOTE: These are constants so that they are only instantiated once
// and they can be used efficiently by the OrderManagementControlPanel.
const VALID_SEARCH_TAGS = new Set(['date', 'customer', 'client', 'name', 'order']);
const FIELD_MAP = {
date: 'date_order',
customer: 'partner_id.display_name',
client: 'partner_id.display_name',
name: 'pos_reference',
order: 'pos_reference',
};
const SEARCH_FIELDS = ['pos_reference', 'partner_id.display_name', 'date_order'];
function getDomainForSingleCondition(fields, toSearch) {
const orSymbols = Array(fields.length - 1).fill('|');
return orSymbols.concat(fields.map((field) => [field, 'ilike', `%${toSearch}%`]));
}
/**
* @emits close-screen
* @emits prev-page
* @emits next-page
* @emits search
*/
class OrderManagementControlPanel extends PosComponent {
constructor() {
super(...arguments);
// We are using context because we want the `searchString` to be alive
// even if this component is destroyed (unmounted).
this.orderManagementContext = useContext(contexts.orderManagement);
useListener('clear-search', this._onClearSearch);
useAutofocus({ selector: 'input' });
}
onInputKeydown(event) {
if (event.key === 'Enter') {
this.trigger('search', this._computeDomain());
}
}
get showPageControls() {
return OrderFetcher.lastPage > 1;
}
get pageNumber() {
const currentPage = OrderFetcher.currentPage;
const lastPage = OrderFetcher.lastPage;
return isNaN(lastPage) ? '' : `(${currentPage}/${lastPage})`;
}
get validSearchTags() {
return VALID_SEARCH_TAGS;
}
get fieldMap() {
return FIELD_MAP;
}
get searchFields() {
return SEARCH_FIELDS;
}
/**
* E.g. 1
* ```
* searchString = 'Customer 1'
* result = [
* '|',
* '|',
* ['pos_reference', 'ilike', '%Customer 1%'],
* ['partner_id.display_name', 'ilike', '%Customer 1%'],
* ['date_order', 'ilike', '%Customer 1%']
* ]
* ```
*
* E.g. 2
* ```
* searchString = 'date: 2020-05'
* result = [
* ['date_order', 'ilike', '%2020-05%']
* ]
* ```
*
* E.g. 3
* ```
* searchString = 'customer: Steward, date: 2020-05-01'
* result = [
* ['partner_id.display_name', 'ilike', '%Steward%'],
* ['date_order', 'ilike', '%2020-05-01%']
* ]
* ```
*/
_computeDomain() {
const input = this.orderManagementContext.searchString.trim();
if (!input) return;
const searchConditions = this.orderManagementContext.searchString.split(/[,&]\s*/);
if (searchConditions.length === 1) {
let cond = searchConditions[0].split(/:\s*/);
if (cond.length === 1) {
return getDomainForSingleCondition(this.searchFields, cond[0]);
}
}
const domain = [];
for (let cond of searchConditions) {
let [tag, value] = cond.split(/:\s*/);
if (!this.validSearchTags.has(tag)) continue;
domain.push([this.fieldMap[tag], 'ilike', `%${value}%`]);
}
return domain;
}
_onClearSearch() {
this.orderManagementContext.searchString = '';
this.onInputKeydown({ key: 'Enter' });
}
}
OrderManagementControlPanel.template = 'OrderManagementControlPanel';
Registries.Component.add(OrderManagementControlPanel);
return OrderManagementControlPanel;
});
@@ -0,0 +1,102 @@
odoo.define('point_of_sale.OrderManagementScreen', function (require) {
'use strict';
const { useContext } = owl.hooks;
const { useListener } = require('web.custom_hooks');
const ControlButtonsMixin = require('point_of_sale.ControlButtonsMixin');
const NumberBuffer = require('point_of_sale.NumberBuffer');
const Registries = require('point_of_sale.Registries');
const OrderFetcher = require('point_of_sale.OrderFetcher');
const IndependentToOrderScreen = require('point_of_sale.IndependentToOrderScreen');
const contexts = require('point_of_sale.PosContext');
class OrderManagementScreen extends ControlButtonsMixin(IndependentToOrderScreen) {
constructor() {
super(...arguments);
useListener('close-screen', this.close);
useListener('set-numpad-mode', this._setNumpadMode);
useListener('click-order', this._onClickOrder);
useListener('next-page', this._onNextPage);
useListener('prev-page', this._onPrevPage);
useListener('search', this._onSearch);
NumberBuffer.use({
nonKeyboardInputEvent: 'numpad-click-input',
useWithBarcode: true,
});
this.numpadMode = 'quantity';
OrderFetcher.setComponent(this);
OrderFetcher.setConfigId(this.env.pos.config_id);
this.orderManagementContext = useContext(contexts.orderManagement);
}
mounted() {
OrderFetcher.on('update', this, this.render);
this.env.pos.get('orders').on('add remove', this.render, this);
// calculate how many can fit in the screen.
// It is based on the height of the header element.
// So the result is only accurate if each row is just single line.
const flexContainer = this.el.querySelector('.flex-container');
const cpEl = this.el.querySelector('.control-panel');
const headerEl = this.el.querySelector('.order-row.header');
const val = Math.trunc(
(flexContainer.offsetHeight - cpEl.offsetHeight - headerEl.offsetHeight) /
headerEl.offsetHeight
);
OrderFetcher.setNPerPage(val);
// Fetch the order after mounting so that order management screen
// is shown while fetching.
setTimeout(() => OrderFetcher.fetch(), 0);
}
willUnmount() {
OrderFetcher.off('update', this);
this.env.pos.get('orders').off('add remove', null, this);
}
get selectedClient() {
const order = this.orderManagementContext.selectedOrder;
return order ? order.get_client() : null;
}
get orders() {
return OrderFetcher.get();
}
async _setNumpadMode(event) {
const { mode } = event.detail;
this.numpadMode = mode;
NumberBuffer.reset();
}
_onNextPage() {
OrderFetcher.nextPage();
}
_onPrevPage() {
OrderFetcher.prevPage();
}
_onSearch({ detail: domain }) {
OrderFetcher.setSearchDomain(domain);
OrderFetcher.setPage(1);
OrderFetcher.fetch();
}
_onClickOrder({ detail: clickedOrder }) {
if (!clickedOrder || clickedOrder.locked) {
this.orderManagementContext.selectedOrder = clickedOrder;
} else {
this._setOrder(clickedOrder);
}
}
/**
* @param {models.Order} order
*/
_setOrder(order) {
if (order === this.env.pos.get_order()) {
this.close();
} else {
this.env.pos.set_order(order);
}
}
}
OrderManagementScreen.template = 'OrderManagementScreen';
OrderManagementScreen.hideOrderSelector = true;
Registries.Component.add(OrderManagementScreen);
return OrderManagementScreen;
});
@@ -0,0 +1,42 @@
odoo.define('point_of_sale.OrderRow', function (require) {
'use strict';
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
/**
* @props {models.Order} order
* @props columns
* @emits click-order
*/
class OrderRow extends PosComponent {
get order() {
return this.props.order;
}
get highlighted() {
const highlightedOrder = this.props.highlightedOrder;
return !highlightedOrder ? false : highlightedOrder.backendId === this.props.order.backendId;
}
// Column getters //
get name() {
return this.order.get_name();
}
get date() {
return moment(this.order.validation_date).format('YYYY-MM-DD hh:mm A');
}
get customer() {
const customer = this.order.get('client');
return customer ? customer.name : null;
}
get total() {
return this.env.pos.format_currency(this.order.get_total_with_tax());
}
}
OrderRow.template = 'OrderRow';
Registries.Component.add(OrderRow);
return OrderRow;
});
@@ -0,0 +1,55 @@
odoo.define('point_of_sale.OrderlineDetails', function (require) {
'use strict';
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
const { format } = require('web.field_utils');
const { round_precision: round_pr } = require('web.utils');
/**
* @props {pos.order.line} line
*/
class OrderlineDetails extends PosComponent {
get line() {
const line = this.props.line;
const formatQty = (line) => {
const quantity = line.get_quantity();
const unit = line.get_unit();
const decimals = this.env.pos.dp['Product Unit of Measure'];
const rounding = Math.max(unit.rounding, Math.pow(10, -decimals));
const roundedQuantity = round_pr(quantity, rounding);
return format.float(roundedQuantity, { digits: [69, decimals] });
};
return {
productName: line.get_product().display_name,
totalPrice: line.get_price_with_tax(),
quantity: formatQty(line),
unit: line.get_unit().name,
unitPrice: line.get_unit_price(),
};
}
get productName() {
return this.line.productName;
}
get totalPrice() {
return this.env.pos.format_currency(this.line.totalPrice);
}
get quantity() {
return this.line.quantity;
}
get unitPrice() {
return this.line.unitPrice;
}
get unit() {
return this.line.unit;
}
get pricePerUnit() {
return ` ${this.unit} at ${this.unitPrice} / ${this.unit}`;
}
}
OrderlineDetails.template = 'OrderlineDetails';
Registries.Component.add(OrderlineDetails);
return OrderlineDetails;
});
@@ -0,0 +1,23 @@
odoo.define('point_of_sale.ReprintReceiptScreen', function (require) {
'use strict';
const AbstractReceiptScreen = require('point_of_sale.AbstractReceiptScreen');
const Registries = require('point_of_sale.Registries');
const ReprintReceiptScreen = (AbstractReceiptScreen) => {
class ReprintReceiptScreen extends AbstractReceiptScreen {
confirm() {
this.props.resolve({ confirmed: true, payload: null });
this.trigger('close-temp-screen');
}
tryReprint() {
this._printReceipt();
}
}
ReprintReceiptScreen.template = 'ReprintReceiptScreen';
return ReprintReceiptScreen;
};
Registries.Component.addByExtending(ReprintReceiptScreen, AbstractReceiptScreen);
return ReprintReceiptScreen;
});
@@ -6,8 +6,8 @@ odoo.define('point_of_sale.ActionpadWidget', function(require) {
/**
* @props client
* @trigger click-customer
* @trigger click-pay
* @emits click-customer
* @emits click-pay
*/
class ActionpadWidget extends PosComponent {
get isLongName() {
@@ -2,12 +2,13 @@ odoo.define('point_of_sale.ProductScreen', function(require) {
'use strict';
const PosComponent = require('point_of_sale.PosComponent');
const ControlButtonsMixin = require('point_of_sale.ControlButtonsMixin');
const NumberBuffer = require('point_of_sale.NumberBuffer');
const { useListener } = require('web.custom_hooks');
const Registries = require('point_of_sale.Registries');
const { onChangeOrder, useBarcodeReader } = require('point_of_sale.custom_hooks');
class ProductScreen extends PosComponent {
class ProductScreen extends ControlButtonsMixin(PosComponent) {
constructor() {
super(...arguments);
useListener('update-selected-orderline', this._updateSelectedOrderline);
@@ -53,15 +54,6 @@ odoo.define('point_of_sale.ProductScreen', function(require) {
get currentOrder() {
return this.env.pos.get_order();
}
get controlButtons() {
return ProductScreen.controlButtons
.filter((cb) => {
return cb.condition.bind(this)();
})
.map((cb) =>
Object.assign({}, cb, { component: Registries.Component.get(cb.component) })
);
}
async _clickProduct(event) {
if (!this.currentOrder) {
this.env.pos.add_new_order();
@@ -281,63 +273,6 @@ odoo.define('point_of_sale.ProductScreen', function(require) {
}
ProductScreen.template = 'ProductScreen';
ProductScreen.controlButtons = [];
/**
* @param {Object} controlButton
* @param {Function} controlButton.component
* Can be any base class or base class callback that is added in the Registries.Component.
* @param {Function} controlButton.condition zero argument function that is bound
* to the instance of ProductScreen, such that `this.env.pos` can be used
* inside the function.
* @param {Array} [controlButton.position] array of two elements
* [locator, relativeTo]
* locator: string -> any of ('before', 'after', 'replace')
* relativeTo: string -> other controlButtons component name
*/
ProductScreen.addControlButton = function(controlButton) {
// We set the name first.
if (!controlButton.name) {
controlButton.name = controlButton.component.name;
}
// If no position is set, we just push it to the array.
if (!controlButton.position) {
this.controlButtons.push(controlButton);
} else {
// Find where to put the new controlButton.
const [locator, relativeTo] = controlButton.position;
let whereIndex = -1;
for (let i = 0; i < this.controlButtons.length; i++) {
if (this.controlButtons[i].name === relativeTo) {
if (['before', 'replace'].includes(locator)) {
whereIndex = i;
} else if (locator === 'after') {
whereIndex = i + 1;
}
break;
}
}
// If found where to put, then perform the necessary mutation of
// the buttons array.
// Else, we just push this controlButton to the array.
if (whereIndex > -1) {
this.controlButtons.splice(
whereIndex,
locator === 'replace' ? 1 : 0,
controlButton
);
} else {
let warningMessage =
`'${controlButton.name}' has invalid 'position' ([${locator}, ${relativeTo}]).` +
'It is pushed to the controlButtons stack instead.';
console.warn(warningMessage);
this.controlButtons.push(controlButton);
}
}
};
Registries.Component.add(ProductScreen);
return ProductScreen;
@@ -1,132 +1,92 @@
odoo.define('point_of_sale.ReceiptScreen', function(require) {
odoo.define('point_of_sale.ReceiptScreen', function (require) {
'use strict';
const core = require('web.core');
const { useRef, useState } = owl.hooks;
const PosComponent = require('point_of_sale.PosComponent');
const { useState } = owl.hooks;
const { useErrorHandlers, onChangeOrder } = require('point_of_sale.custom_hooks');
const Registries = require('point_of_sale.Registries');
const AbstractReceiptScreen = require('point_of_sale.AbstractReceiptScreen');
const _t = core._t;
class ReceiptScreen extends PosComponent {
/**
* Optional props:
* printInvoiceIsShown: Boolean
*/
constructor() {
super(...arguments);
useErrorHandlers();
onChangeOrder(null, (newOrder) => newOrder && this.render());
this.state = useState({ printInvoiceIsShown: this.props.printInvoiceIsShown });
this.orderReceipt = useRef('order-receipt');
}
mounted() {
// Here, we send a task to the event loop that handles
// the printing of the receipt when the component is mounted.
// We are doing this because we want the receipt screen to be
// displayed regardless of what happen to the handleAutoPrint
// call.
setTimeout(async () => await this.handleAutoPrint(), 0);
}
get change() {
return this.env.pos.format_currency(this.currentOrder.get_change());
}
get currentOrder() {
return this.env.pos.get_order();
}
get nextScreen() {
return { name: 'ProductScreen' };
}
/**
* This function is called outside the rendering call stack. This way,
* we don't block the displaying of ReceiptScreen when it is mounted; additionally,
* any error that can happen during the printing does not affect the rendering.
*/
async handleAutoPrint() {
if (this._shouldAutoPrint() && !this.currentOrder.is_to_email()) {
await this.printReceipt();
if (this.currentOrder._printed && this._shouldCloseImmediately()) {
this.orderDone();
}
const ReceiptScreen = (AbstractReceiptScreen) => {
class ReceiptScreen extends AbstractReceiptScreen {
/**
* Optional props:
* printInvoiceIsShown: Boolean
*/
constructor() {
super(...arguments);
useErrorHandlers();
onChangeOrder(null, (newOrder) => newOrder && this.render());
this.state = useState({ printInvoiceIsShown: this.props.printInvoiceIsShown });
}
}
orderDone() {
this.currentOrder.finalize();
const { name, props } = this.nextScreen;
this.showScreen(name, props);
}
async printReceipt() {
if (this.env.pos.proxy.printer) {
const printResult = await this.env.pos.proxy.printer.print_receipt(
this.orderReceipt.el.outerHTML
);
if (printResult.successful) {
this.currentOrder._printed = true;
} else {
await this.showPopup('ErrorPopup', {
title: printResult.message.title,
body: printResult.message.body,
});
}
} else {
await this._printWeb();
mounted() {
// Here, we send a task to the event loop that handles
// the printing of the receipt when the component is mounted.
// We are doing this because we want the receipt screen to be
// displayed regardless of what happen to the handleAutoPrint
// call.
setTimeout(async () => await this.handleAutoPrint(), 0);
}
}
async onPrintInvoice() {
// The button element of this event handler appears when the order
// failed to sync and is to be invoiced. What we do here is to try
// to sync again to eventually print the invoice.
try {
await this.env.pos.push_and_invoice_order(this.currentOrder);
this.state.printInvoiceIsShown = false;
} catch (error) {
if (error instanceof Error) {
throw error;
} else {
await this._handlePushOrderError(error);
}
get change() {
return this.env.pos.format_currency(this.currentOrder.get_change());
}
}
_shouldAutoPrint() {
return this.env.pos.config.iface_print_auto && !this.currentOrder._printed;
}
_shouldCloseImmediately() {
var invoiced_finalized = this.currentOrder.is_to_invoice()
? this.currentOrder.finalized
: true;
return (
this.env.pos.proxy.printer &&
this.env.pos.config.iface_print_skip_screen &&
invoiced_finalized
);
}
async _printWeb() {
if ($.browser.safari) {
document.execCommand('print', false, null);
} else {
try {
window.print();
this.currentOrder._printed = true;
} catch (err) {
if (navigator.userAgent.toLowerCase().indexOf('android') > -1) {
await this.showPopup('ErrorPopup', {
title: _t('Printing is not supported on some android browsers'),
body: _t(
'Printing is not supported on some android browsers due to no default printing protocol ' +
'is available. It is possible to print your tickets by making use of an IoT Box.'
),
});
} else {
throw err;
get currentOrder() {
return this.env.pos.get_order();
}
get nextScreen() {
return { name: 'ProductScreen' };
}
/**
* This function is called outside the rendering call stack. This way,
* we don't block the displaying of ReceiptScreen when it is mounted; additionally,
* any error that can happen during the printing does not affect the rendering.
*/
async handleAutoPrint() {
if (this._shouldAutoPrint() && !this.currentOrder.is_to_email()) {
await this.printReceipt();
if (this.currentOrder._printed && this._shouldCloseImmediately()) {
this.orderDone();
}
}
}
orderDone() {
this.currentOrder.finalize();
const { name, props } = this.nextScreen;
this.showScreen(name, props);
}
async printReceipt() {
const isPrinted = await this._printReceipt();
if (isPrinted) {
this.currentOrder._printed = true;
}
}
async onPrintInvoice() {
// The button element of this event handler appears when the order
// failed to sync and is to be invoiced. What we do here is to try
// to sync again to eventually print the invoice.
try {
await this.env.pos.push_and_invoice_order(this.currentOrder);
this.state.printInvoiceIsShown = false;
} catch (error) {
if (error instanceof Error) {
throw error;
} else {
await this._handlePushOrderError(error);
}
}
}
_shouldAutoPrint() {
return this.env.pos.config.iface_print_auto && !this.currentOrder._printed;
}
_shouldCloseImmediately() {
var invoiced_finalized = this.currentOrder.is_to_invoice() ? this.currentOrder.finalized : true;
return this.env.pos.proxy.printer && this.env.pos.config.iface_print_skip_screen && invoiced_finalized;
}
}
}
ReceiptScreen.template = 'ReceiptScreen';
ReceiptScreen.template = 'ReceiptScreen';
return ReceiptScreen;
};
Registries.Component.add(ReceiptScreen);
Registries.Component.addByExtending(ReceiptScreen, AbstractReceiptScreen);
return ReceiptScreen;
});
+26 -10
View File
@@ -2312,10 +2312,12 @@ exports.Paymentline = Backbone.Model.extend({
init_from_JSON: function(json){
this.amount = json.amount;
this.payment_method = this.pos.payment_methods_by_id[json.payment_method_id];
this.name = this.payment_method.name;
this.payment_status = json.payment_status;
this.ticket = json.ticket;
this.card_type = json.card_type;
this.transaction_id = json.transaction_id;
this.is_change = json.is_change;
},
//sets the amount of money on this payment line
set_amount: function(value){
@@ -2391,7 +2393,8 @@ exports.Paymentline = Backbone.Model.extend({
return {
cid: this.cid,
amount: this.get_amount(),
payment_method: this.payment_method.name,
name: this.name,
ticket: this.ticket,
};
},
});
@@ -2410,7 +2413,7 @@ exports.Order = Backbone.Model.extend({
var self = this;
options = options || {};
this.init_locked = true;
this.locked = false;
this.pos = options.pos;
this.selected_orderline = undefined;
this.selected_paymentline = undefined;
@@ -2453,13 +2456,12 @@ exports.Order = Backbone.Model.extend({
this.paymentlines.on('remove', this.pos.send_current_order_to_customer_facing_display, this.pos);
}
this.init_locked = false;
this.save_to_db();
return this;
},
save_to_db: function(){
if (!this.temporary && !this.init_locked) {
if (!this.temporary && !this.locked) {
this.pos.db.save_unpaid_order(this);
}
},
@@ -2536,6 +2538,14 @@ exports.Order = Backbone.Model.extend({
this.select_paymentline(newpaymentline);
}
}
// Tag this order as 'locked' if it is already paid.
this.locked = ['paid', 'done', 'invoiced'].includes(json.state);
this.state = json.state;
this.amount_return = json.amount_return;
this.account_move = json.account_move;
this.backendId = json.id;
this.isFromClosedSession = json.is_session_closed;
},
export_as_JSON: function() {
var orderLines, paymentLines;
@@ -2552,7 +2562,7 @@ exports.Order = Backbone.Model.extend({
amount_paid: this.get_total_paid() - this.get_change(),
amount_total: this.get_total_with_tax(),
amount_tax: this.get_total_tax(),
amount_return: this.get_change(),
amount_return: this.amount_return ? this.amount_return : this.get_change(),
lines: orderLines,
statement_ids: paymentLines,
pos_session_id: this.pos_session_id,
@@ -2580,10 +2590,16 @@ exports.Order = Backbone.Model.extend({
orderlines.push(orderline.export_for_printing());
});
var paymentlines = [];
this.paymentlines.each(function(paymentline){
paymentlines.push(paymentline.export_for_printing());
});
// If order is locked (paid), the 'change' is saved as negative payment,
// and is flagged with is_change = true. A receipt that is printed first
// time doesn't show this negative payment so we filter it out.
var paymentlines = this.paymentlines.models
.filter(function (paymentline) {
return !paymentline.is_change;
})
.map(function (paymentline) {
return paymentline.export_for_printing();
});
var client = this.get('client');
var cashier = this.pos.get_cashier();
var company = this.pos.company;
@@ -2619,7 +2635,7 @@ exports.Order = Backbone.Model.extend({
total_discount: this.get_total_discount(),
rounding_applied: this.get_rounding_applied(),
tax_details: this.get_tax_details(),
change: this.get_change(),
change: this.locked ? this.amount_return : this.get_change(),
name : this.get_name(),
client: client ? client : null ,
invoice_id: null, //TODO
@@ -68,7 +68,7 @@ var PrinterMixin = {
}
// rpc call is okay but printing failed because
// IoT box can't find a printer.
if (!sendPrintResult) {
if (!sendPrintResult || (sendPrintResult && !sendPrintResult.result)) {
this.receipt_queue.length = 0;
return this.printResultGenerator.IoTResultError();
}
+29 -3
View File
@@ -1,4 +1,4 @@
odoo.define('point_of_sale.utils', function(require) {
odoo.define('point_of_sale.utils', function (require) {
'use strict';
function getFileAsText(file) {
@@ -7,7 +7,7 @@ odoo.define('point_of_sale.utils', function(require) {
reject();
} else {
const reader = new FileReader();
reader.addEventListener('load', function() {
reader.addEventListener('load', function () {
resolve(reader.result);
});
reader.addEventListener('abort', reject);
@@ -17,5 +17,31 @@ odoo.define('point_of_sale.utils', function(require) {
});
}
return { getFileAsText };
/**
* This global variable is used by nextFrame to store the timer and
* be able to cancel it before another request for animation frame.
*/
let timer = null;
/**
* Wait for the next animation frame to finish.
*/
const nextFrame = () => {
return new Promise((resolve) => {
cancelAnimationFrame(timer);
timer = requestAnimationFrame(() => {
resolve();
});
});
};
function isRpcError(error) {
return (
!(error instanceof Error) &&
error.message &&
[100, 200, 404, -32098].includes(error.message.code)
);
}
return { getFileAsText, nextFrame, isRpcError };
});
@@ -16,6 +16,7 @@
<t t-if="!env.isMobile">
<CashierName />
</t>
<OrderManagementButton t-if="env.pos.config.manage_orders" />
<SaleDetailsButton t-if="env.pos.proxy.printer" />
<ProxyStatus t-if="env.pos.config.use_proxy" />
<ClientScreenButton t-if="clientScreenButtonIsShown" />
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="OrderManagementButton" owl="1">
<div class="oe_status order-management" t-on-click="onClick">
<div class="oe_icon oe_green">
<i class="fa fa-fw fa-search" role="img" aria-label="Order Management Button" title="Order Management Button"></i>
</div>
</div>
</t>
</templates>
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="OrderSelector" owl="1">
<div class="order-selector">
<div class="order-selector" t-name="OrderSelector" owl="1">
<t t-if="chromeContext.showOrderSelector">
<span class="orders">
<t t-foreach="orderList" t-as="order" t-key="order.cid">
<t t-if="order === env.pos.get_order()">
@@ -28,7 +28,7 @@
<span class="order-button square deleteorder-button" t-on-click="deleteCurrentOrder">
<i class="fa fa-minus" role="img" aria-label="Delete order" title="Delete order" />
</span>
</div>
</t>
</t>
</div>
</templates>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="InvoiceButton" owl="1">
<div class="control-button" t-att-class="{ highlight: isHighlighted }">
<i class="fa fa-file-pdf-o"></i>
<span> </span>
<span><t t-esc="commandName"></t></span>
</div>
</t>
</templates>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="ReprintReceiptButton" owl="1">
<div class="control-button">
<i class="fa fa-print"></i>
<span> </span>
<span>Print Receipt</span>
</div>
</t>
</templates>
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<div t-name="MobileOrderManagementScreen" class="screen-full-width" owl="1">
<div t-if="mobileState.showDetails" class="leftpane">
<OrderDetails order="orderManagementContext.selectedOrder" />
<div class="pads">
<div class="control-buttons">
<t t-foreach="controlButtons" t-as="cb" t-key="cb.name">
<t t-component="cb.component" t-key="cb.name" />
</t>
</div>
<div class="subpads">
<ActionpadWidget client="selectedClient" />
<NumpadWidget />
</div>
</div>
<div class="back-to-list" t-on-click="mobileState.showDetails = false">
<span>Back to list</span>
</div>
</div>
<div t-else="" class="rightpane">
<div class="flex-container">
<OrderManagementControlPanel />
<OrderList orders="orders" initHighlightedOrder="orderManagementContext.selectedOrder" />
</div>
</div>
</div>
</templates>
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="OrderDetails" owl="1">
<div class="order-container">
<div t-ref="scrollable" class="order-scroller touch-scrollable">
<div class="order">
<t t-if="!props.order">
<div class="order-empty">
<i class="fa fa-shopping-cart" role="img" aria-label="Shopping cart"
title="Shopping cart" />
<h1>Select an order</h1>
</div>
</t>
<t t-elif="orderlines.length === 0">
<div class="order-empty">
<i class="fa fa-shopping-cart" role="img" aria-label="Shopping cart"
title="Shopping cart" />
<h1>Order is empty</h1>
</div>
</t>
<t t-else="">
<ul class="orderlines">
<t t-foreach="orderlines" t-as="orderline" t-key="orderline.id">
<OrderlineDetails line="orderline" />
</t>
</ul>
<OrderSummary total="total" tax="tax" />
</t>
</div>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="OrderList" owl="1">
<div class="orders">
<div class="order-row header">
<div class="header name">Order</div>
<div class="header date">Date</div>
<div class="header customer">Customer</div>
<div class="header total">Total</div>
</div>
<div class="order-list">
<t t-foreach="props.orders" t-as="order" t-key="order.cid">
<OrderRow order="order" highlightedOrder="highlightedOrder" />
</t>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="OrderManagementControlPanel" owl="1">
<div class="control-panel">
<div class="item button back" t-on-click="trigger('close-screen')">
<i class="fa fa-angle-double-left"></i>
<span> Back</span>
</div>
<div class="item search-box">
<span class="icon">
<i class="fa fa-search" />
</span>
<input type="text" t-model="orderManagementContext.searchString" t-on-keydown="onInputKeydown" placeholder="E.g. customer: Steward, date: 2020-05-09" />
<span class="clear" t-on-click="trigger('clear-search')">
<i class="fa fa-remove" />
</span>
</div>
<div t-if="showPageControls" class="item">
<div class="page-controls">
<div class="previous" t-on-click="trigger('prev-page')">
<i class="fa fa-fw fa-caret-left" role="img" aria-label="Previous Order List" title="Previous Order List"></i>
</div>
<div class="next" t-on-click="trigger('next-page')">
<i class="fa fa-fw fa-caret-right" role="img" aria-label="Next Order List" title="Next Order List"></i>
</div>
</div>
<div class="page">
<span><t t-esc="pageNumber" /></span>
</div>
</div>
<div t-else="" class="item"></div>
</div>
</t>
</templates>
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="OrderManagementScreen" owl="1">
<div class="order-management-screen screen" t-att-class="{ oe_hidden: !props.isShown }">
<div t-if="!env.isMobile" class="screen-full-width">
<div class="leftpane">
<OrderDetails order="orderManagementContext.selectedOrder" />
<div class="pads">
<div class="control-buttons">
<t t-foreach="controlButtons" t-as="cb" t-key="cb.name">
<t t-component="cb.component" t-key="cb.name" />
</t>
</div>
<div class="subpads">
<ActionpadWidget client="selectedClient" />
<NumpadWidget />
</div>
</div>
</div>
<div class="rightpane">
<div class="flex-container">
<OrderManagementControlPanel />
<OrderList orders="orders" initHighlightedOrder="orderManagementContext.selectedOrder" />
</div>
</div>
</div>
<MobileOrderManagementScreen t-else="" />
</div>
</t>
</templates>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="OrderRow" owl="1">
<div class="order-row"
t-att-class="{ highlight: highlighted, lighter: !props.order.locked }"
t-on-click="trigger('click-order', props.order)">
<div class="item name"><t t-esc="name" /></div>
<div class="item date"><t t-esc="date" /></div>
<div class="item customer"><t t-esc="customer" /></div>
<div class="item total"><t t-esc="total" /></div>
</div>
</t>
</templates>
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="OrderlineDetails" owl="1">
<li class="orderline">
<span class="product-name">
<t t-esc="productName" />
</span>
<span class="price">
<t t-esc="totalPrice" />
</span>
<li class="info">
<strong>
<t t-esc="quantity" />
</strong>
<span><t t-esc="pricePerUnit" /></span>
</li>
</li>
</t>
</templates>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="ReprintReceiptScreen" owl="1">
<div class="receipt-screen screen">
<div class="screen-content">
<div class="top-content">
<span class="button back" t-on-click="confirm">
<i class="fa fa-angle-double-left"></i>
<span> </span>
<span>Back</span>
</span>
</div>
<div class="centered-content">
<div class="button print" t-on-click="tryReprint">
<i class="fa fa-print"></i> Print Receipt
</div>
<div class="pos-receipt-container">
<OrderReceipt order="props.order" t-ref="order-receipt" />
</div>
</div>
</div>
</div>
</t>
</templates>
@@ -9,7 +9,7 @@
<span class="value">
<t t-esc="props.total" />
</span>
<div class="subentry">
<div t-if="props.tax" class="subentry">
Taxes:
<span class="value">
<t t-esc="props.tax" />
@@ -124,10 +124,10 @@
<!-- Payment Lines -->
<t t-foreach="paymentlines" t-as="line" t-key="line.cid">
<t t-foreach="receipt.paymentlines" t-as="line" t-key="line.cid">
<div>
<t t-esc="line.name" />
<span t-esc="env.pos.format_currency_no_symbol(line.get_amount())" class="pos-receipt-right-align"/>
<span t-esc="env.pos.format_currency_no_symbol(line.amount)" class="pos-receipt-right-align"/>
</div>
</t>
<br/>
@@ -174,7 +174,7 @@
</div>
<div class="after-footer">
<t t-foreach="paymentlines" t-as="line">
<t t-foreach="receipt.paymentlines" t-as="line">
<t t-if="line.ticket">
<br />
<div class="pos-payment-terminal-receipt">
@@ -12,17 +12,17 @@ odoo.define('point_of_sale.tour.Chrome', function (require) {
// Order 1 is at Product Screen
ProductScreen.do.clickHomeCategory();
ProductScreen.exec.order('Desk Pad', '1', '2');
ProductScreen.exec.addOrderline('Desk Pad', '1', '2');
// Order 2 is at Payment Screen
Chrome.do.newOrder();
ProductScreen.exec.order('Monitor Stand', '3', '4');
ProductScreen.exec.addOrderline('Monitor Stand', '3', '4');
ProductScreen.do.clickPayButton();
PaymentScreen.check.isShown();
// Order 3 is at Receipt Screen
Chrome.do.newOrder();
ProductScreen.exec.order('Whiteboard Pen', '5', '6');
ProductScreen.exec.addOrderline('Whiteboard Pen', '5', '6');
ProductScreen.do.clickPayButton();
PaymentScreen.do.clickPaymentMethod('Bank');
PaymentScreen.do.clickValidate();
@@ -71,7 +71,7 @@ odoo.define('point_of_sale.tour.Chrome', function (require) {
ReceiptScreen.do.clickNextOrder();
// Invoice an order
ProductScreen.exec.order('Whiteboard Pen', '5', '6');
ProductScreen.exec.addOrderline('Whiteboard Pen', '5', '6');
ProductScreen.do.clickCustomerButton();
ProductScreen.do.clickCustomer('Nicole Ford');
ProductScreen.do.clickSetCustomer();
@@ -0,0 +1,135 @@
odoo.define('point_of_sale.tour.OrderManagementScreen', function (require) {
'use strict';
const { OrderManagementScreen } = require('point_of_sale.tour.OrderManagementScreenTourMethods');
const { ProductScreen } = require('point_of_sale.tour.ProductScreenTourMethods');
const { PaymentScreen } = require('point_of_sale.tour.PaymentScreenTourMethods');
const { ClientListScreen } = require('point_of_sale.tour.ClientListScreenTourMethods');
const { Chrome } = require('point_of_sale.tour.ChromeTourMethods');
const { makeFullOrder } = require('point_of_sale.tour.CompositeTourMethods');
const { getSteps, startSteps } = require('point_of_sale.tour.utils');
var Tour = require('web_tour.tour');
// signal to start generating steps
// when finished, steps can be taken from getSteps
startSteps();
// Go by default to home category
ProductScreen.do.clickHomeCategory();
// make one order and check if it can be seen from the management screen.
// order 0001
makeFullOrder({ orderlist: [['Whiteboard Pen', '5', '6']], payment: ['Cash', '30'] });
Chrome.do.clickOrderManagementButton();
OrderManagementScreen.check.isShown();
OrderManagementScreen.check.orderlistHas({ orderName: '-0001', total: '30' });
OrderManagementScreen.do.clickBack();
// make multiple orders and check them in the management screen.
// order 0002
makeFullOrder({
orderlist: [
['Desk Pad', '1', '2'],
['Monitor Stand', '3', '4'],
['Whiteboard Pen', '5', '6'],
],
payment: ['Bank', '44'],
});
// order 0003
makeFullOrder({
orderlist: [
['Desk Pad', '1', '2'],
['Whiteboard Pen', '5', '6'],
],
customer: 'Colleen Diaz',
payment: ['Cash', '50'],
});
// order 0004
makeFullOrder({
orderlist: [
['Monitor Stand', '3', '4'],
['Whiteboard Pen', '5', '6'],
],
payment: ['Bank', '42'],
});
Chrome.do.clickOrderManagementButton();
OrderManagementScreen.check.isShown();
OrderManagementScreen.check.orderlistHas({ orderName: '-0002', total: '44' });
OrderManagementScreen.check.orderlistHas({
orderName: '0003',
total: '32',
customer: 'Colleen Diaz',
});
OrderManagementScreen.check.orderlistHas({ orderName: '-0004', total: '42' });
// click the currently active order
OrderManagementScreen.do.clickOrder('-0005');
ProductScreen.check.isShown();
// Add 2 orders, they should appear in order management screen
// order 0006
Chrome.do.newOrder();
ProductScreen.exec.addOrderline('Whiteboard Pen', '66', '6');
// order 0007, should be at payment screen
Chrome.do.newOrder();
ProductScreen.exec.addOrderline('Monitor Stand', '55', '5');
ProductScreen.do.clickCustomerButton();
ClientListScreen.exec.setClient('Azure Interior');
ProductScreen.do.clickPayButton();
Chrome.do.clickOrderManagementButton();
OrderManagementScreen.check.orderlistHas({ orderName: '-0006', total: '396' });
OrderManagementScreen.check.orderlistHas({
orderName: '-0007',
total: '275',
customer: 'Azure Interior',
});
// select a paid order, order row should be highlighted and should show order details
OrderManagementScreen.do.clickOrder('-0004');
OrderManagementScreen.check.highlightedOrderRowHas('-0004');
OrderManagementScreen.check.orderDetailsHas({
lines: [
{ product: 'Monitor Stand', quantity: '3' },
{ product: 'Whiteboard Pen', quantity: '5' },
],
total: '42',
});
OrderManagementScreen.do.clickOrder('-0001');
OrderManagementScreen.check.highlightedOrderRowHas('-0001');
// 0004 should not be highlighted anymore
OrderManagementScreen.check.orderRowIsNotHighlighted('-0004');
OrderManagementScreen.check.orderDetailsHas({
lines: [{ product: 'Whiteboard Pen', quantity: '5' }],
total: '30',
});
// Select a paid order then invoice it. The selected order should remain selected
// and will contain a new customer. After invoice, the current customer should be removed.
// TODO: enable the following steps once the issue in invoicing is solved.
// OrderManagementScreen.do.clickInvoiceButton();
// Chrome.do.confirmPopup();
// ClientListScreen.check.isShown();
// ClientListScreen.exec.setClient('Jesse Brown');
// OrderManagementScreen.check.highlightedOrderRowHas('Jesse Brown');
// Check if order 0007 is selected, it should be at payment screen
OrderManagementScreen.do.clickOrder('-0007');
PaymentScreen.check.isShown();
Chrome.do.clickOrderManagementButton();
OrderManagementScreen.check.isShown();
OrderManagementScreen.do.clickOrder('-0003');
OrderManagementScreen.do.clickPrintReceiptButton();
OrderManagementScreen.check.reprintReceiptIsShown();
OrderManagementScreen.check.receiptChangeIs('18.0');
OrderManagementScreen.check.receiptOrderDataContains('-0003');
OrderManagementScreen.check.receiptAmountIs('32.0');
OrderManagementScreen.do.closeReceipt();
OrderManagementScreen.check.isNotHidden();
Tour.register('OrderManagementScreenTour', { test: true, url: '/pos/web' }, getSteps());
});
@@ -8,7 +8,7 @@ odoo.define('point_of_sale.tour.PaymentScreen', function (require) {
startSteps();
ProductScreen.exec.order('Letter Tray', '10');
ProductScreen.exec.addOrderline('Letter Tray', '10');
ProductScreen.do.clickPayButton();
PaymentScreen.check.emptyPaymentlines('52.8');
@@ -10,7 +10,7 @@ odoo.define('point_of_sale.tour.ReceiptScreen', function (require) {
startSteps();
// pay exact amount
ProductScreen.exec.order('Letter Tray', '10');
ProductScreen.exec.addOrderline('Letter Tray', '10');
ProductScreen.do.clickPayButton();
PaymentScreen.do.clickPaymentMethod('Bank');
PaymentScreen.do.clickValidate();
@@ -20,9 +20,9 @@ odoo.define('point_of_sale.tour.ReceiptScreen', function (require) {
// pay more than total price
ProductScreen.do.clickHomeCategory();
ProductScreen.exec.order('Desk Pad', '6', '5.0');
ProductScreen.exec.order('Whiteboard Pen', '6', '6.1');
ProductScreen.exec.order('Monitor Stand', '6', '1.3');
ProductScreen.exec.addOrderline('Desk Pad', '6', '5.0');
ProductScreen.exec.addOrderline('Whiteboard Pen', '6', '6.1');
ProductScreen.exec.addOrderline('Monitor Stand', '6', '1.3');
ProductScreen.do.clickPayButton();
PaymentScreen.do.clickPaymentMethod('Cash');
PaymentScreen.do.pressNumpad('8 0 0');
@@ -36,7 +36,20 @@ odoo.define('point_of_sale.tour.ChromeTourMethods', function (require) {
},
];
}
clickOrderManagementButton() {
return [
{
content: 'check order management button is shown',
trigger: '.pos .pos-rightheader .order-management',
run: () => {},
},
{
content: 'click order management button',
trigger: '.pos .pos-rightheader .order-management',
},
];
}
}
return { Do, Chrome: createTourMethods('Chrome', Do) };
return createTourMethods('Chrome', Do);
});
@@ -0,0 +1,57 @@
odoo.define('point_of_sale.tour.ClientListScreenTourMethods', function (require) {
'use strict';
const { createTourMethods } = require('point_of_sale.tour.utils');
class Do {
clickClient(name) {
return [
{
content: `click client '${name}' from client list screen`,
trigger: `.clientlist-screen .client-list-contents .client-line td:contains("${name}")`,
},
{
content: `check if client '${name}' is highlighted`,
trigger: `.clientlist-screen .client-list-contents .client-line.highlight td:contains("${name}")`,
run: () => {},
},
];
}
clickSet() {
return [
{
content: 'check if set button shown',
trigger: '.clientlist-screen .button.next.highlight',
run: () => {},
},
{
content: 'click set button',
trigger: '.clientlist-screen .button.next.highlight',
},
];
}
}
class Check {
isShown() {
return [
{
content: 'client list screen is shown',
trigger: '.pos-content .clientlist-screen',
run: () => {},
},
];
}
}
class Execute {
setClient(name) {
const steps = [];
steps.push(...this._do.clickClient(name));
steps.push(...this._do.clickSet());
return steps;
}
}
return createTourMethods('ClientListScreen', Do, Check, Execute);
});
@@ -0,0 +1,23 @@
odoo.define('point_of_sale.tour.CompositeTourMethods', function (require) {
'use strict';
const { ProductScreen } = require('point_of_sale.tour.ProductScreenTourMethods');
const { ReceiptScreen } = require('point_of_sale.tour.ReceiptScreenTourMethods');
const { PaymentScreen } = require('point_of_sale.tour.PaymentScreenTourMethods');
const { ClientListScreen } = require('point_of_sale.tour.ClientListScreenTourMethods');
function makeFullOrder({ orderlist, customer, payment, ntimes = 1 }) {
for (let i = 0; i < ntimes; i++) {
ProductScreen.exec.addMultiOrderlines(...orderlist);
if (customer) {
ProductScreen.do.clickCustomerButton();
ClientListScreen.exec.setClient(customer);
}
ProductScreen.do.clickPayButton();
PaymentScreen.exec.pay(...payment);
ReceiptScreen.exec.nextOrder();
}
}
return { makeFullOrder };
});
@@ -26,5 +26,5 @@ odoo.define('point_of_sale.tour.ErrorPopupTourMethods', function (require) {
}
}
return { Do, Check, ErrorPopup: createTourMethods('ErrorPopup', Do, Check) };
return createTourMethods('ErrorPopup', Do, Check);
});
@@ -65,5 +65,5 @@ odoo.define('point_of_sale.tour.NumberPopupTourMethods', function (require) {
}
}
return { Do, Check, NumberPopup: createTourMethods('NumberPopup', Do, Check) };
return createTourMethods('NumberPopup', Do, Check);
});
@@ -0,0 +1,176 @@
odoo.define('point_of_sale.tour.OrderManagementScreenTourMethods', function (require) {
'use strict';
const { createTourMethods } = require('point_of_sale.tour.utils');
class Do {
clickBack() {
return [
{
content: 'order management screen, click back button',
trigger: '.order-management-screen .control-panel .button.back',
},
];
}
clickOrder(name) {
return [
{
content: `clicking order '${name}' from orderlist`,
trigger: `.order-management-screen .order-list .order-row .item:contains("${name}")`,
},
];
}
clickInvoiceButton() {
return [
{
content: 'click invoice button',
trigger: '.order-management-screen .control-button span:contains("Invoice")',
},
];
}
clickPrintReceiptButton() {
return [
{
content: 'click reprint receipt button',
trigger: '.order-management-screen .control-button span:contains("Print Receipt")'
}
]
}
clickCustomerButton() {
return [
{
content: 'click customer button',
trigger: '.order-management-screen .actionpad .button.set-customer',
},
];
}
closeReceipt() {
return [
{
content: 'close receipt',
trigger: '.receipt-screen .button.back',
}
]
}
}
class Check {
isShown() {
return [
{
content: 'order management screen is shown',
trigger: '.pos .pos-content .order-management-screen',
run: () => {},
},
];
}
orderlistHas({ orderName, total, customer }) {
const steps = [];
steps.push({
content: `order list has row having: name '${orderName}', total '${total}'`,
trigger: `.order-list .order-row .item:contains("${orderName}") ~ .item:contains("${total}")`,
run: () => {},
});
if (customer) {
steps.push({
content: `order list has row having: name '${orderName}', customer '${customer}'`,
trigger: `.order-list .order-row .item:contains("${orderName}") ~ .item:contains("${customer}")`,
run: () => {},
});
}
return steps;
}
highlightedOrderRowHas(name) {
return [
{
content: `order '${name}' in orderlist is highligted`,
trigger: `.order-list .order-row.highlight:has(> .item:contains("${name}"))`,
run: () => {},
},
];
}
orderRowIsNotHighlighted(name) {
return [
{
content: `order '${name}' in orderlist is not highligted`,
trigger: `.order-list .order-row:not(:has(.highlight)):has(> .item:contains("${name}"))`,
run: () => {},
},
];
}
orderDetailsHas({ lines, total }) {
const steps = [];
for (let { product, quantity } of lines) {
steps.push({
content: `order details has product '${product}' and quantity '${quantity}'`,
trigger: `.orderlines .product-name:contains("${product}") ~ .info strong:contains("${quantity}")`,
run: () => {},
});
}
if (total) {
steps.push({
content: `order details has total amount of ${total}`,
trigger: `.order-container .summary .total .value:contains("${total}")`,
run: () => {},
});
}
return steps;
}
customerIs(name) {
return [
{
content: `set customer is '${name}'`,
trigger: `.order-management-screen .actionpad .set-customer:contains("${name}")`,
run: () => {},
},
];
}
reprintReceiptIsShown() {
return [
{
content: 'reprint receipt screen is shown',
trigger: '.pos .receipt-screen',
run: () => {},
}
]
}
receiptChangeIs(amount) {
return [
{
content: `receipt change is ${amount}`,
trigger: `.pos-receipt-amount.receipt-change:contains("${amount}")`,
run: () => {},
}
]
}
receiptOrderDataContains(orderInfo) {
return [
{
content: `order data contains ${orderInfo}`,
trigger: `.pos-receipt-order-data:contains("${orderInfo}")`,
run: () => {},
}
]
}
receiptAmountIs(amount) {
return [
{
content: `receipt amount is ${amount}`,
trigger: `.pos-receipt-amount:contains("${amount}")`,
run: () => {},
}
]
}
isNotHidden() {
return [
{
content: 'order management screen is not hidden',
trigger: `.order-management-screen:not(:has(.oe_hidden))`,
run: () => {},
}
]
}
}
return createTourMethods('OrderManagementScreen', Do, Check);
});
@@ -191,9 +191,18 @@ odoo.define('point_of_sale.tour.PaymentScreenTourMethods', function (require) {
}
}
return {
Do,
Check,
PaymentScreen: createTourMethods('PaymentScreen', Do, Check),
};
class Execute {
pay(method, amount) {
const steps = [];
steps.push(...this._do.clickPaymentMethod(method));
for (let char of amount.split('')) {
steps.push(...this._do.pressNumpad(char));
}
steps.push(...this._check.validateButtonIsHighlighted());
steps.push(...this._do.clickValidate());
return steps;
}
}
return createTourMethods('PaymentScreen', Do, Check, Execute);
});
@@ -181,10 +181,19 @@ odoo.define('point_of_sale.tour.ProductScreenTourMethods', function (require) {
},
];
}
totalAmountIs(amount) {
return [
{
content: `order total amount is '${amount}'`,
trigger: `.order-container .order .summary .value:contains("${amount}")`,
run: () => {},
},
];
}
}
class Execute {
order(productName, quantity, price) {
addOrderline(productName, quantity, price) {
const res = this._do.clickDisplayedProduct(productName);
if (price) {
res.push(...this._do.pressNumpad('Price'));
@@ -200,12 +209,14 @@ odoo.define('point_of_sale.tour.ProductScreenTourMethods', function (require) {
}
return res;
}
addMultiOrderlines(...list) {
const steps = [];
for (let [product, qty, price] of list) {
steps.push(...this.addOrderline(product, qty, price));
}
return steps;
}
}
return {
Do,
Check,
Execute,
ProductScreen: createTourMethods('ProductScreen', Do, Check, Execute),
};
return createTourMethods('ProductScreen', Do, Check, Execute);
});
@@ -51,9 +51,11 @@ odoo.define('point_of_sale.tour.ReceiptScreenTourMethods', function (require) {
}
}
return {
Do,
Check,
ReceiptScreen: createTourMethods('ReceiptScreen', Do, Check),
};
class Execute {
nextOrder() {
return [...this._check.isShown(), ...this._do.clickNextOrder()];
}
}
return createTourMethods('ReceiptScreen', Do, Check, Execute);
});
@@ -35,5 +35,5 @@ odoo.define('point_of_sale.tour.SelectionPopupTourMethods', function (require) {
}
}
return { Do, Check, SelectionPopup: createTourMethods('SelectionPopup', Do, Check) };
return createTourMethods('SelectionPopup', Do, Check);
});
@@ -97,7 +97,11 @@ odoo.define('point_of_sale.tour.utils', function (require) {
// we proxy get of the method to decorate the method call
const proxyHandler = {
get(target, key) {
return new Proxy(target[key].bind(target), methodProxyHandler);
const method = target[key];
if (!method) {
throw new Error(`Tour method '${key}' is not available.`);
}
return new Proxy(method.bind(target), methodProxyHandler);
},
};
@@ -131,12 +135,17 @@ odoo.define('point_of_sale.tour.utils', function (require) {
methods.exec._do = methods.do;
methods.exec._check = methods.check;
return {
do: new Proxy(methods.do, proxyHandler),
check: new Proxy(methods.check, proxyHandler),
exec: new Proxy(methods.exec, proxyHandler),
_do: methods.do,
_check: methods.check,
_exec: methods.exec,
Do,
Check,
Execute,
[name]: {
do: new Proxy(methods.do, proxyHandler),
check: new Proxy(methods.check, proxyHandler),
exec: new Proxy(methods.exec, proxyHandler),
_do: methods.do,
_check: methods.check,
_exec: methods.exec,
},
};
}
@@ -54,6 +54,7 @@ odoo.define('point_of_sale.tests.ChromeWidgets', function (require) {
class Parent extends PopupControllerMixin(PosComponent) {}
Parent.env = makePosTestEnv();
Parent.env.chrome = new owl.Context({ showOrderSelector: true });
Parent.template = xml/* html */ `
<div>
<OrderSelector></OrderSelector>
@@ -432,3 +432,8 @@ class TestUi(TestPointOfSaleHttpCommon):
n_paid = self.env['pos.order'].search_count([('state', '=', 'paid')])
self.assertEqual(n_invoiced, 1, 'There should be 1 invoiced order.')
self.assertEqual(n_paid, 2, 'There should be 2 paid order.')
def test_03_order_management(self):
self.main_pos_config.write({ 'manage_orders': True, 'module_account': True })
self.main_pos_config.open_session_cb(check_coa=False)
self.start_tour("/pos/web?config_id=%d" % self.main_pos_config.id, 'OrderManagementScreenTour', login="admin", step_delay=50)
@@ -15,6 +15,7 @@
<script type="text/javascript" src="/point_of_sale/static/src/js/utils.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/ClassRegistry.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/PosComponent.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/PosContext.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/ComponentRegistry.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/Registries.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/db.js"></script>
@@ -23,6 +24,7 @@
<script type="text/javascript" src="/point_of_sale/static/src/js/barcode_reader.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/Gui.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/PopupControllerMixin.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/ControlButtonsMixin.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/Chrome.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/devices.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/payment.js"></script>
@@ -32,6 +34,17 @@
<script type="text/javascript" src="/point_of_sale/static/src/js/Screens/ClientListScreen/ClientDetails.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/Screens/ClientListScreen/ClientDetailsEdit.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/Screens/ClientListScreen/ClientListScreen.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/Screens/OrderManagementScreen/ControlButtons/InvoiceButton.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/Screens/OrderManagementScreen/ControlButtons/ReprintReceiptButton.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderFetcher.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderManagementScreen.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/Screens/OrderManagementScreen/MobileOrderManagementScreen.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderManagementControlPanel.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderList.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderRow.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderDetails.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderlineDetails.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/Screens/OrderManagementScreen/ReprintReceiptScreen.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/Screens/PaymentScreen/PSNumpadInputButton.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenNumpad.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenElectronicPayment.js"></script>
@@ -60,10 +73,13 @@
<script type="text/javascript" src="/point_of_sale/static/src/js/ChromeWidgets/CashierName.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/ChromeWidgets/ProxyStatus.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/ChromeWidgets/SyncNotification.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/ChromeWidgets/OrderManagementButton.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/ChromeWidgets/HeaderButton.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/ChromeWidgets/SaleDetailsButton.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/Misc/Draggable.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/Misc/NotificationSound.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/Misc/IndependentToOrderScreen.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/Misc/AbstractReceiptScreen.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/ChromeWidgets/DebugWidget.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/Popups/AbstractAwaitablePopup.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/Popups/ErrorPopup.js"></script>
@@ -7,14 +7,18 @@
<script type="text/javascript" src="/point_of_sale/static/tests/tours/helpers/utils.js"></script>
<script type="text/javascript" src="/point_of_sale/static/tests/tours/helpers/ProductScreenTourMethods.js"></script>
<script type="text/javascript" src="/point_of_sale/static/tests/tours/helpers/PaymentScreenTourMethods.js"></script>
<script type="text/javascript" src="/point_of_sale/static/tests/tours/helpers/OrderManagementScreenTourMethods.js"></script>
<script type="text/javascript" src="/point_of_sale/static/tests/tours/helpers/ClientListScreenTourMethods.js"></script>
<script type="text/javascript" src="/point_of_sale/static/tests/tours/helpers/ReceiptScreenTourMethods.js"></script>
<script type="text/javascript" src="/point_of_sale/static/tests/tours/helpers/ChromeTourMethods.js"></script>
<script type="text/javascript" src="/point_of_sale/static/tests/tours/helpers/NumberPopupTourMethods.js"></script>
<script type="text/javascript" src="/point_of_sale/static/tests/tours/helpers/ErrorPopupTourMethods.js"></script>
<script type="text/javascript" src="/point_of_sale/static/tests/tours/helpers/SelectionPopupTourMethods.js"></script>
<script type="text/javascript" src="/point_of_sale/static/tests/tours/helpers/CompositeTourMethods.js"></script>
<script type="text/javascript" src="/point_of_sale/static/tests/tours/point_of_sale.js"></script>
<script type="text/javascript" src="/point_of_sale/static/tests/tours/ProductScreen.tour.js"></script>
<script type="text/javascript" src="/point_of_sale/static/tests/tours/PaymentScreen.tour.js"></script>
<script type="text/javascript" src="/point_of_sale/static/tests/tours/OrderManagementScreen.tour.js"></script>
<script type="text/javascript" src="/point_of_sale/static/tests/tours/ReceiptScreen.tour.js"></script>
<script type="text/javascript" src="/point_of_sale/static/tests/tours/Chrome.tour.js"></script>
</xpath>
+15 -16
View File
@@ -37,6 +37,7 @@
<field name="company_has_template" invisible="1"/>
<field name="allowed_pricelist_ids" invisible="1"/>
<field name="has_active_session" invisible="1"/>
<field name="show_allow_invoicing_alert" invisible="1"/>
<div class="oe_title" id="title">
<label for="name" class="oe_edit_only"/>
<h1><field name="name"/></h1>
@@ -81,6 +82,20 @@
</div>
<h2 name="order">PoS Interface</h2>
<div class="row mt16 o_settings_container">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="manage_orders" />
</div>
<div class="o_setting_right_pane">
<label for="manage_orders" />
<div class="text-muted">
Reprint receipt, refund and exchange orders
</div>
<div class="o_notification_alert alert alert-warning" attrs="{'invisible':[('show_allow_invoicing_alert', '=', False)]}" role="alert">
Allow invoicing of paid orders by activating 'Invoicing' in the 'Bills &amp; Receipts' section.
</div>
</div>
</div>
<div id="category_reference" class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="iface_display_categ_images"/>
@@ -454,22 +469,6 @@
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box pos_reprint_receipt">
<div class="o_setting_left_pane">
<field name='module_pos_reprint'/>
</div>
<div class="o_setting_right_pane">
<label for="module_pos_reprint"/>
<div class="text-muted">
Allow cashier to reprint receipts
</div>
<div class="content-group" attrs="{'invisible': [('module_pos_reprint', '=', False)]}">
<div class="mt16" id="btn_use_pos_reprint">
<button string="Save" type="object" name="execute" class="oe_highlight"/>
</div>
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box" id="iface_invoicing">
<div class="o_setting_left_pane">
<field name="module_account"/>
@@ -63,5 +63,5 @@ odoo.define('pos_hr.tour.PosHrTourMethods', function (require) {
}
}
return { Do, Check, Execute, PosHr: createTourMethods('PosHr', Do, Check, Execute) };
return createTourMethods('PosHr', Do, Check, Execute);
});
@@ -119,6 +119,12 @@ pos_model.Paymentline = pos_model.Paymentline.extend({
is_done: function () {
var res = _paylineproto.is_done.apply(this);
return res && !this.mercury_swipe_pending;
},
export_for_printing: function () {
const result = _paylineproto.export_for_printing.apply(this, arguments);
result.mercury_data = this.mercury_data;
result.mercury_auth_code = this.mercury_auth_code;
return result;
}
});
@@ -2,7 +2,7 @@
<templates id="template" xml:space="preserve">
<t t-name="pos_mercury.OrderReceipt" t-inherit="point_of_sale.OrderReceipt" t-inherit-mode="extension" owl="1">
<xpath expr="//t[@t-foreach='paymentlines']" position="inside">
<xpath expr="//t[@t-foreach='receipt.paymentlines']" position="inside">
<t t-if="line.mercury_data">
<div class="pos-receipt-left-padding">
<span>APPROVAL CODE: </span>
+2 -1
View File
@@ -27,6 +27,8 @@ This module adds several features to the Point of Sale that are specific to rest
],
'qweb': [
'static/src/xml/Resizeable.xml',
'static/src/xml/Screens/OrderManagementScreen/OrderList.xml',
'static/src/xml/Screens/OrderManagementScreen/OrderRow.xml',
'static/src/xml/Screens/ProductScreen/ControlButtons/OrderlineNoteButton.xml',
'static/src/xml/Screens/ProductScreen/ControlButtons/TableGuestsButton.xml',
'static/src/xml/Screens/ProductScreen/ControlButtons/PrintBillButton.xml',
@@ -44,7 +46,6 @@ This module adds several features to the Point of Sale that are specific to rest
'static/src/xml/Screens/FloorScreen/EditableTable.xml',
'static/src/xml/ChromeWidgets/OrderSelector.xml',
'static/src/xml/ChromeWidgets/BackToFloorButton.xml',
'static/src/xml/Chrome.xml',
'static/src/xml/multiprint.xml',
],
'demo': [
@@ -189,3 +189,8 @@ class PosOrder(models.Model):
order_fields['customer_count'] = ui_order.get('customer_count', 0)
order_fields['multiprint_resume'] = ui_order.get('multiprint_resume', False)
return order_fields
def _export_for_ui(self, order):
result = super(PosOrder, self)._export_for_ui(order)
result['table_id'] = order.table_id.id
return result
@@ -45,7 +45,7 @@ odoo.define('pos_restaurant.chrome', function (require) {
*/
_showSavedScreen(pos, newSelectedOrder) {
if (!newSelectedOrder) {
this.showScreen('FloorScreen', { floor: pos.table.floor });
this.showScreen('FloorScreen', { floor: pos.table ? pos.table.floor : null });
} else {
super._showSavedScreen(pos, newSelectedOrder);
}
@@ -68,7 +68,8 @@ odoo.define('pos_restaurant.chrome', function (require) {
if (this.tempScreen.isShown) {
this.trigger('close-temp-screen');
}
this.showScreen('FloorScreen', { floor: this.env.pos.table.floor });
const table = this.env.pos.table;
this.showScreen('FloorScreen', { floor: table ? table.floor : null });
}
_shouldResetIdleTimer() {
return this.env.pos.config.iface_floorplan && this.mainScreen.name !== 'FloorScreen';
@@ -289,6 +289,7 @@ odoo.define('pos_restaurant.FloorScreen', function (require) {
}
}
FloorScreen.template = 'FloorScreen';
FloorScreen.hideOrderSelector = true;
Registries.Component.add(FloorScreen);
@@ -0,0 +1,29 @@
odoo.define('pos_restaurant.OrderManagementScreen', function (require) {
'use strict';
const OrderManagementScreen = require('point_of_sale.OrderManagementScreen');
const Registries = require('point_of_sale.Registries');
const PosResOrderManagementScreen = (OrderManagementScreen) =>
class extends OrderManagementScreen {
/**
* @override
*/
_setOrder(order) {
if (this.env.pos.config.module_pos_restaurant) {
const currentOrder = this.env.pos.get_order();
if (currentOrder && currentOrder.uid === order.uid) {
this.close();
} else {
this.env.pos.set_table(order.table, order);
}
} else {
super._setOrder(order);
}
}
};
Registries.Component.extend(OrderManagementScreen, PosResOrderManagementScreen);
return OrderManagementScreen;
});
@@ -0,0 +1,17 @@
odoo.define('pos_restaurant.OrderRow', function (require) {
'use strict';
const OrderRow = require('point_of_sale.OrderRow');
const Registries = require('point_of_sale.Registries');
const PosResOrderRow = (OrderRow) =>
class extends OrderRow {
get table() {
return this.order.table ? this.order.table.name : '';
}
};
Registries.Component.extend(OrderRow, PosResOrderRow);
return OrderRow;
});
+13 -8
View File
@@ -45,9 +45,9 @@ models.load_models({
// New orders are now associated with the current table, if any.
var _super_order = models.Order.prototype;
models.Order = models.Order.extend({
initialize: function() {
initialize: function(attr,options) {
_super_order.initialize.apply(this,arguments);
if (!this.table) {
if (!this.table && !options.json) {
this.table = this.pos.table;
}
this.customer_count = this.customer_count || 1;
@@ -192,16 +192,20 @@ models.PosModel = models.PosModel.extend({
}
},
set_order_on_table: function() {
/**
* @param {models.Order} order order to set
*/
set_order_on_table: function(order) {
var orders = this.get_order_list();
if (orders.length) {
this.set_order(orders[0]); // and go to the first one ...
order = order ? orders.find((o) => o.uid === order.uid) : null;
this.set_order(order || orders[0]);
} else {
this.add_new_order(); // or create a new order with the current table
}
},
sync_to_server: function(table) {
sync_to_server: function(table, order) {
var self = this;
var ids_to_remove = this.db.get_ids_to_remove_from_server();
@@ -229,7 +233,7 @@ models.PosModel = models.PosModel.extend({
}).catch(function(reason){
self.set_synch('error');
}).finally(function(){
self.set_order_on_table();
self.set_order_on_table(order);
});
},
@@ -247,8 +251,9 @@ models.PosModel = models.PosModel.extend({
*
* Switch table and make sure all nececery syncing tasks are done.
* @param {object} table.
* @param {models.Order|undefined} order if provided, set to this order
*/
set_table: function(table) {
set_table: function(table, order) {
if(!table){
this.sync_from_server(table, this.get_order_list(), this.get_order_with_uid());
this.set_order(null);
@@ -262,7 +267,7 @@ models.PosModel = models.PosModel.extend({
this.set_order(null);
} else {
this.table = table;
this.sync_to_server(table);
this.sync_to_server(table, order);
}
},
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="Chrome" t-inherit="point_of_sale.Chrome" t-inherit-mode="extension" owl="1">
<xpath expr="//OrderSelector" position="attributes">
<attribute name="t-if">mainScreen.name !== 'FloorScreen'</attribute>
</xpath>
<xpath expr="//OrderSelector" position="after">
<div t-else="" class="order-selector"></div>
</xpath>
</t>
</templates>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="OrderList" t-inherit="point_of_sale.OrderList" t-inherit-mode="extension" owl="1">
<xpath expr="//div[hasclass('order-row')]//div[hasclass('customer')]" position="after">
<div t-if="env.pos.config.module_pos_restaurant" class="header table">
Table
</div>
</xpath>
</t>
</templates>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="OrderRow" t-inherit="point_of_sale.OrderRow" t-inherit-mode="extension" owl="1">
<xpath expr="//div[hasclass('order-row')]//div[hasclass('customer')]" position="after">
<div t-if="env.pos.config.module_pos_restaurant" class="item table">
<t t-esc="table" />
</div>
</xpath>
</t>
</templates>
@@ -16,7 +16,7 @@ odoo.define('pos_restaurant.tour.ControlButtons', function (require) {
// Test TransferOrderButton
FloorScreen.do.clickTable('T2');
ProductScreen.exec.order('Water', '5.0', '2.0');
ProductScreen.exec.addOrderline('Water', '5.0', '2.0');
ProductScreen.do.clickTransferButton();
FloorScreen.do.clickTable('T4');
ProductScreen.do.clickOrderline('Water', '5.0', '2.0');
@@ -37,7 +37,7 @@ odoo.define('pos_restaurant.tour.ControlButtons', function (require) {
TextAreaPopup.do.inputText('test note');
TextAreaPopup.do.clickConfirm();
ProductScreen.check.orderlineHasNote('Water', '5.0', 'test note');
ProductScreen.exec.order('Water', '8', '1.1');
ProductScreen.exec.addOrderline('Water', '8', '1.1');
ProductScreen.check.selectedOrderlineHas('Water', '8', '8.80');
// Test PrintBillButton
@@ -0,0 +1,121 @@
odoo.define('pos_restaurant.tour.OrderManagementScreen', function (require) {
'use strict';
// This tour tests the integration of OrderManagementScreen
// to some pos_restaurant features.
const { makeFullOrder } = require('point_of_sale.tour.CompositeTourMethods');
const { Chrome } = require('pos_restaurant.tour.ChromeTourMethods');
const {
OrderManagementScreen,
} = require('point_of_sale.tour.OrderManagementScreenTourMethods');
const { FloorScreen } = require('pos_restaurant.tour.FloorScreenTourMethods');
const { ProductScreen } = require('pos_restaurant.tour.ProductScreenTourMethods');
const { getSteps, startSteps } = require('point_of_sale.tour.utils');
var Tour = require('web_tour.tour');
// signal to start generating steps
// when finished, steps can be taken from getSteps
startSteps();
FloorScreen.do.clickTable('T2');
// make one order and check if it can be seen from the management screen.
makeFullOrder({ orderlist: [['Minute Maid', '5', '6']], payment: ['Cash', '30'] });
Chrome.do.clickOrderManagementButton();
OrderManagementScreen.check.isShown();
OrderManagementScreen.check.orderlistHas({ orderName: '-0001', total: '30' });
// go back to create multiple unpaid orders
OrderManagementScreen.do.clickBack();
// create 2 unpaid orders on T2 (orders 0002 and 0003)
FloorScreen.do.clickTable('T2');
ProductScreen.exec.addMultiOrderlines(
['Coca-Cola', '1', '2'],
['Water', '3', '4'],
['Minute Maid', '5', '6']
);
Chrome.do.newOrder();
ProductScreen.exec.addMultiOrderlines(['Coca-Cola', '1', '2'], ['Minute Maid', '5', '6']);
Chrome.do.backToFloor();
// create 2 unpaid orders on T5 (orders 0004 and 0005)
FloorScreen.do.clickTable('T5');
ProductScreen.exec.addMultiOrderlines(
['Coca-Cola', '7', '8'],
['Water', '9', '10'],
['Minute Maid', '11', '12']
);
Chrome.do.newOrder();
ProductScreen.exec.addMultiOrderlines(['Coca-Cola', '13', '14'], ['Minute Maid', '15', '16']);
Chrome.do.backToFloor();
// check that the unpaid orders are listed in the management screen
Chrome.do.clickOrderManagementButton();
OrderManagementScreen.check.isShown();
OrderManagementScreen.check.orderlistHas({ orderName: '-0002', total: '44.0' });
OrderManagementScreen.check.orderlistHas({ orderName: '-0003', total: '32.0' });
OrderManagementScreen.check.orderlistHas({ orderName: '-0004', total: '278.0' });
OrderManagementScreen.check.orderlistHas({ orderName: '-0005', total: '422.0' });
// select order 0003 and check if it is really in T2
OrderManagementScreen.do.clickOrder('-0003');
ProductScreen.check.isShown();
ProductScreen.check.totalAmountIs('32');
Chrome.check.backToFloorTextIs('Main Floor', 'T2');
Chrome.do.clickOrderManagementButton();
// select order 0005 and check it it is really in T5
OrderManagementScreen.check.isShown();
OrderManagementScreen.do.clickOrder('-0005');
ProductScreen.check.totalAmountIs('422');
Chrome.check.backToFloorTextIs('Main Floor', 'T5');
// go back to floor screen and start order management from there
Chrome.do.backToFloor();
FloorScreen.check.selectedFloorIs('Main Floor');
Chrome.do.clickOrderManagementButton();
OrderManagementScreen.check.isShown();
// select order 0002 and check if it is really in T2
OrderManagementScreen.do.clickOrder('-0002');
ProductScreen.check.isShown();
ProductScreen.check.totalAmountIs('44');
Chrome.check.backToFloorTextIs('Main Floor', 'T2');
Chrome.do.clickOrderManagementButton();
OrderManagementScreen.check.isShown();
// select order 0004 and check if it is really in T5
OrderManagementScreen.do.clickOrder('-0004');
ProductScreen.check.isShown();
ProductScreen.check.totalAmountIs('278');
Chrome.check.backToFloorTextIs('Main Floor', 'T5');
// transfer order 0004 to T2
ProductScreen.do.clickTransferButton();
FloorScreen.do.clickTable('T2');
ProductScreen.check.isShown();
Chrome.do.clickOrderManagementButton();
OrderManagementScreen.check.isShown();
// select order 0004 and check if it is now in T2
OrderManagementScreen.do.clickOrder('-0004');
ProductScreen.check.isShown();
ProductScreen.check.totalAmountIs('278');
Chrome.check.backToFloorTextIs('Main Floor', 'T2');
Chrome.do.clickOrderManagementButton();
OrderManagementScreen.check.isShown();
// finally, select order 0002
OrderManagementScreen.do.clickOrder('-0002');
ProductScreen.check.isShown();
ProductScreen.check.totalAmountIs('44');
Chrome.check.backToFloorTextIs('Main Floor', 'T2');
Tour.register('PosResOrderManagementScreenTour', { test: true, url: '/pos/web' }, getSteps());
});
@@ -14,9 +14,9 @@ odoo.define('pos_restaurant.tour.SplitBillScreen', function (require) {
startSteps();
FloorScreen.do.clickTable('T2');
ProductScreen.exec.order('Water', '5', '1.2');
ProductScreen.exec.order('Minute Maid', '3', '1.2');
ProductScreen.exec.order('Coca-Cola', '1', '1.2');
ProductScreen.exec.addOrderline('Water', '5', '1.2');
ProductScreen.exec.addOrderline('Minute Maid', '3', '1.2');
ProductScreen.exec.addOrderline('Coca-Cola', '1', '1.2');
ProductScreen.do.clickSplitBillButton();
// Check if the screen contains all the orderlines
@@ -26,5 +26,5 @@ odoo.define('pos_restaurant.tour.BillScreenTourMethods', function (require) {
}
}
return { Do, Check, BillScreen: createTourMethods('BillScreen', Do, Check) };
return createTourMethods('BillScreen', Do, Check);
});
@@ -29,8 +29,5 @@ odoo.define('pos_restaurant.tour.ChromeTourMethods', function (require) {
class Execute {}
return {
Do: DoExt,
Chrome: createTourMethods('Chrome', DoExt, Check, Execute),
};
return createTourMethods('Chrome', DoExt, Check, Execute);
});
@@ -129,10 +129,5 @@ odoo.define('pos_restaurant.tour.FloorScreenTourMethods', function (require) {
class Execute {}
return {
Do,
Check,
Execute,
FloorScreen: createTourMethods('FloorScreen', Do, Check, Execute),
};
return createTourMethods('FloorScreen', Do, Check, Execute);
});
@@ -66,10 +66,5 @@ odoo.define('pos_restaurant.tour.ProductScreenTourMethods', function (require) {
class ExecuteExt extends Execute {}
return {
Do: DoExt,
Check: CheckExt,
Execute: ExecuteExt,
ProductScreen: createTourMethods('ProductScreen', DoExt, CheckExt, ExecuteExt),
};
return createTourMethods('ProductScreen', DoExt, CheckExt, ExecuteExt);
});
@@ -61,10 +61,5 @@ odoo.define('pos_restaurant.tour.SplitBillScreenTourMethods', function (require)
class Execute {}
return {
Do,
Check,
Execute,
SplitBillScreen: createTourMethods('SplitBillScreen', Do, Check, Execute),
};
return createTourMethods('SplitBillScreen', Do, Check, Execute);
});
@@ -35,5 +35,5 @@ odoo.define('pos_restaurant.tour.TextAreaPopupTourMethods', function (require) {
}
}
return { Do, Check, TextAreaPopup: createTourMethods('TextAreaPopup', Do, Check) };
return createTourMethods('TextAreaPopup', Do, Check);
});
@@ -35,5 +35,5 @@ odoo.define('pos_restaurant.tour.TextInputPopupTourMethods', function (require)
}
}
return { Do, Check, TextInputPopup: createTourMethods('TextInputPopup', Do, Check) };
return createTourMethods('TextInputPopup', Do, Check);
});
@@ -149,6 +149,9 @@ class TestFrontend(odoo.tests.HttpCase):
'taxes_id': [(6, 0, [])],
})
pricelist = self.env['product.pricelist'].create({'name': 'Restaurant Pricelist'})
pos_config.write({'pricelist_id': pricelist.id})
self.pos_config = pos_config
def test_01_pos_restaurant(self):
@@ -171,3 +174,8 @@ class TestFrontend(odoo.tests.HttpCase):
self.start_tour("/pos/web?config_id=%d" % self.pos_config.id, 'SplitBillScreenTour', login="admin", step_delay=50)
self.start_tour("/pos/web?config_id=%d" % self.pos_config.id, 'ControlButtonsTour', login="admin", step_delay=50)
self.start_tour("/pos/web?config_id=%d" % self.pos_config.id, 'FloorScreenTour', login="admin", step_delay=50)
def test_03_order_management_integration(self):
self.pos_config.write({'manage_orders': True})
self.pos_config.with_user(self.env.ref('base.user_admin')).open_session_cb(check_coa=False)
self.start_tour("/pos/web?config_id=%d" % self.pos_config.id, 'PosResOrderManagementScreenTour', login="admin", step_delay=50)
@@ -22,6 +22,8 @@
<script type="text/javascript" src="/pos_restaurant/static/src/js/Screens/FloorScreen/EditBar.js"></script>
<script type="text/javascript" src="/pos_restaurant/static/src/js/Screens/FloorScreen/TableWidget.js"></script>
<script type="text/javascript" src="/pos_restaurant/static/src/js/Screens/FloorScreen/EditableTable.js"></script>
<script type="text/javascript" src="/pos_restaurant/static/src/js/Screens/OrderManagementScreen/OrderManagementScreen.js"></script>
<script type="text/javascript" src="/pos_restaurant/static/src/js/Screens/OrderManagementScreen/OrderRow.js"></script>
<script type="text/javascript" src="/pos_restaurant/static/src/js/ChromeWidgets/OrderSelector.js"></script>
<script type="text/javascript" src="/pos_restaurant/static/src/js/ChromeWidgets/BackToFloorButton.js"></script>
<script type="text/javascript" src="/pos_restaurant/static/src/js/Chrome.js"></script>
@@ -51,6 +53,7 @@
<script type="text/javascript" src="/pos_restaurant/static/tests/tours/SplitBillScreen.tour.js"></script>
<script type="text/javascript" src="/pos_restaurant/static/tests/tours/ControlButtons.tour.js"></script>
<script type="text/javascript" src="/pos_restaurant/static/tests/tours/FloorScreen.tour.js"></script>
<script type="text/javascript" src="/pos_restaurant/static/tests/tours/OrderManagementScreen.tour.js"></script>
</xpath>
</template>