[REF] point_of_sale,*pos*: remove Backbone.js, single loading request

This commit accomplishes multiple objectives:

1. Loading of model data for the POS UI is now done in single
request (with some exemptions).
2. Backbone.js is removed and is replaced by reactivity.js
authored by the js framework team.
3. Better organization of assets in the `__manifest__.py`

Data Loading
------------

One request per model -- that's always been how the data is loaded
in POS UI. Now, we move the aggregation of data to the backend which
simplified the data loading. In the frontend, data loading is
initiated in `load_server_data` which makes the single rpc call
to the `load_pos_data` defined in `pos.session` model.

There remains some rpc calls during loading but the number is
greatly reduced.

This change result to faster loading of the POS UI. However,
there is no more progress bar during the loading.

Notable entry points:

- .js: `PosGlobalState.load_server_data`
- .py: `pos.session.load_pos_data`

It is possible to make customizations in the loading, but is done in
several steps:

1. Override `_pos_ui_models_to_load` to include the new model to load.
2. Define `_loader_params_<model_name>` to define the `search_params` and
optional `context`.
3. Define `_get_pos_ui_<model_name>` to return the data that will be
included in the full `loaded_data`. Perform the organization in this method
whenever necessary.
4. Override `_pos_data_process` for further post processing. The final
form of `loaded_data` in this method will be sent to the frontend.

Removal of Backbone.js
----------------------

POS is no longer dependent from `Backbone.js`, but it's replaced by a
new dependency -- `reactivity.js`. It's invented by the js framework
team which will be available in owl v2.

The consequences of this change are the following:

1. The old `PosModel` is renamed to `PosGlobalState`.
2. The name of the other models are kept (except Paymentline which
is renamed to Payment).
3. The name `PosModel` is now used as the base model of the data models.
This is symmetric to our use of `PosComponent`.
4. The instance of `PosGlobalState` is made reactive in `Chrome` (the
root component). As a result, whatever mutation made in that instance,
`Chrome` will rerender. But rendering is batched so multiple mutations
in a single sychronous function call will only result to a single
render call.
5. `PosModel`s can be extended in the `Registries` the same way as we
extend the `PosComponent`s.
```js
// E.g.
Registries.Component.extend(Chrome, PosResChrome);
Registries.Model.extend(Order, PosResOrder);
```

Reorganization of assets declaration
------------------------------------

This is a simple change that replaced the explicit enumeration
of loaded .js and .xml files. Also, we now have regions in the
manifest separating:

1. Augmentations of dependencies
2. Assets for `point_of_sale.index`
3. Assets for the qunit test (soon will be improved)

Other notable changes
---------------------

- Automatic call to `Order.save_to_db` is batched.
- Instances of `devices.DeviceProxy` (`proxy`), `devices.JobQueue`
(`proxy_queue`) and `BarcodeReader` (`barcode_reader`) are moved
to `point_of_sale.env`. Together with `posbus` and `posMutex`
(previously `flush_mutex`).
- `ActivePrograms` is now passed a props.
- `cashier` is no longer saved to `localStorage`.
- `pos_cache` is overhauled.
- `cache` is taken out from `PosDB` to prevent infinite rendering
loop whenever `cache` is mutated. It's now called `CACHE` in
the `db.js` file.
- reactive version of `posmodel` is exposed globally when in debug mode.
This means that calling when calling a mutator, the UI will react.
- Added control buttons in the `ProductScreen` is sorted during `Chrome`
setup. As a result, we don't need to worry on the loading order of the
control buttons components.
- Model can be simply instantiated using `BaseModel.create(obj)`. This
takes into account the extensions.
- `pos_coupon`: rewards are now updated whenever set_order is called.
- Component unit tests were removed. It wasn't useful and it just blocks
the development. We'll be introducing better unit testing later.

closes odoo/odoo#82461

Related: odoo/enterprise#23344
Signed-off-by: Masereel Pierre <pim@odoo.com>
Co-authored-by: Jacky (trj) <trj@odoo.com>
This commit is contained in:
Joseph Caburnay
2022-01-27 21:50:25 +00:00
committed by Pierre Masereel
co-authored by Jacky
parent daf5963845
commit 8fb53c53c3
166 changed files with 3354 additions and 6405 deletions
+8
View File
@@ -105,6 +105,7 @@ class AccountTax(models.Model):
sequence = fields.Integer(required=True, default=1,
help="The sequence field is used to define order in which the tax lines are applied.")
amount = fields.Float(required=True, digits=(16, 4), default=0.0)
real_amount = fields.Float(string='Real amount to apply', compute='_compute_real_amount', store=True)
description = fields.Char(string='Label on Invoices')
price_include = fields.Boolean(string='Included in Price', default=False,
help="Check this if the price you use on the product and invoices includes this tax.")
@@ -296,6 +297,13 @@ class AccountTax(models.Model):
if self.price_include:
self.include_base_amount = True
@api.depends('invoice_repartition_line_ids', 'amount', 'invoice_repartition_line_ids.factor')
def _compute_real_amount(self):
for tax in self:
tax_repartition_lines = tax.invoice_repartition_line_ids.filtered(lambda x: x.repartition_type == 'tax')
total_factor = sum(tax_repartition_lines.mapped('factor'))
tax.real_amount = tax.amount * total_factor
def _compute_amount(self, base_amount, price_unit, quantity=1.0, product=None, partner=None):
""" Returns the amount of a single tax. base_amount is the actual amount on which the tax is applied, which is
price_unit * quantity eventually affected by previous taxes (if tax is include_base_amount XOR price_include)
+19 -17
View File
@@ -1,32 +1,34 @@
odoo.define('l10n_co_pos.pos', function (require) {
"use strict";
var models = require('point_of_sale.models');
var { PosGlobalState, Order } = require('point_of_sale.models');
const Registries = require('point_of_sale.Registries');
models.PosModel = models.PosModel.extend({
is_colombian_country: function () {
const L10nCoPosGlobalState = (PosGlobalState) => class L10nCoPosGlobalState extends PosGlobalState {
is_colombian_country() {
return this.company.country.code === 'CO';
},
});
}
}
Registries.Model.extend(PosGlobalState, L10nCoPosGlobalState);
var _super_order = models.Order.prototype;
models.Order = models.Order.extend({
export_for_printing: function () {
var result = _super_order.export_for_printing.apply(this, arguments);
const L10nCoPosOrder = (Order) => class L10nCoPosOrder extends Order {
export_for_printing() {
var result = super.export_for_printing(...arguments);
result.l10n_co_dian = this.get_l10n_co_dian();
return result;
},
set_l10n_co_dian: function (l10n_co_dian) {
}
set_l10n_co_dian(l10n_co_dian) {
this.l10n_co_dian = l10n_co_dian;
},
get_l10n_co_dian: function () {
}
get_l10n_co_dian() {
return this.l10n_co_dian;
},
wait_for_push_order: function () {
var result = _super_order.wait_for_push_order.apply(this, arguments);
}
wait_for_push_order() {
var result = super.wait_for_push_order(...arguments);
result = Boolean(result || this.pos.is_colombian_country());
return result;
}
});
}
Registries.Model.extend(Order, L10nCoPosOrder);
});
@@ -1,20 +0,0 @@
odoo.define('l10n_fr_pos_cert.NumpadWidget', function(require) {
'use strict';
const NumpadWidget = require('point_of_sale.NumpadWidget');
const Registries = require('point_of_sale.Registries');
const PosFrNumpadWidget = NumpadWidget => class extends NumpadWidget {
get hasPriceControlRights() {
if (this.env.pos.is_french_country()) {
return false;
} else {
return super.hasPriceControlRights;
}
}
};
Registries.Component.extend(NumpadWidget, PosFrNumpadWidget);
return NumpadWidget;
});
+49 -43
View File
@@ -2,18 +2,14 @@ odoo.define('l10n_fr_pos_cert.pos', function (require) {
"use strict";
const { Gui } = require('point_of_sale.Gui');
var models = require('point_of_sale.models');
var rpc = require('web.rpc');
var session = require('web.session');
var { PosGlobalState, Order, Orderline } = require('point_of_sale.models');
var core = require('web.core');
var utils = require('web.utils');
const Registries = require('point_of_sale.Registries');
var _t = core._t;
var round_di = utils.round_decimals;
var _super_posmodel = models.PosModel.prototype;
models.PosModel = models.PosModel.extend({
is_french_country: function(){
const L10nFrPosGlobalState = (PosGlobalState) => class L10nFrPosGlobalState extends PosGlobalState {
is_french_country(){
var french_countries = ['FR', 'MF', 'MQ', 'NC', 'PF', 'RE', 'GF', 'GP', 'TF'];
if (!this.company.country) {
Gui.showPopup("ErrorPopup", {
@@ -23,38 +19,45 @@ models.PosModel = models.PosModel.extend({
return false;
}
return _.contains(french_countries, this.company.country.code);
},
}
disallowLineQuantityChange() {
let result = _super_posmodel.disallowLineQuantityChange.bind(this)();
let result = super.disallowLineQuantityChange(...arguments);
return this.is_french_country() || result;
}
});
cashierHasPriceControlRights() {
if (this.is_french_country()) {
return false;
} else {
return super.cashierHasPriceControlRights();
}
}
}
Registries.Model.extend(PosGlobalState, L10nFrPosGlobalState);
var _super_order = models.Order.prototype;
models.Order = models.Order.extend({
initialize: function() {
_super_order.initialize.apply(this,arguments);
const L10nFrOrder = (Order) => class L10nFrOrder extends Order {
constructor() {
super(...arguments);
this.l10n_fr_hash = this.l10n_fr_hash || false;
this.save_to_db();
},
export_for_printing: function() {
var result = _super_order.export_for_printing.apply(this,arguments);
}
export_for_printing() {
var result = super.export_for_printing(...arguments);
result.l10n_fr_hash = this.get_l10n_fr_hash();
return result;
},
set_l10n_fr_hash: function (l10n_fr_hash){
}
set_l10n_fr_hash (l10n_fr_hash){
this.l10n_fr_hash = l10n_fr_hash;
},
get_l10n_fr_hash: function() {
}
get_l10n_fr_hash() {
return this.l10n_fr_hash;
},
wait_for_push_order: function() {
var result = _super_order.wait_for_push_order.apply(this,arguments);
}
wait_for_push_order() {
var result = super.wait_for_push_order(...arguments);
result = Boolean(result || this.pos.is_french_country());
return result;
},
destroy: function(option) {
}
destroy (option) {
// SUGGESTION: It's probably more appropriate to apply this restriction
// in the TicketScreen.
if (option && option.reason == 'abandon' && this.pos.is_french_country() && this.get_orderlines().length) {
@@ -63,23 +66,26 @@ models.Order = models.Order.extend({
'body': _t("Deleting of orders is not allowed."),
});
} else {
_super_order.destroy.apply(this, arguments);
}
},
});
var orderline_super = models.Orderline.prototype;
models.Orderline = models.Orderline.extend({
can_be_merged_with: function(orderline) {
let order = this.pos.get_order();
let lastId = order.orderlines.last().cid;
if(this.pos.is_french_country() && (order.orderlines._byId[lastId].product.id !== orderline.product.id || order.orderlines._byId[lastId].quantity < 0)) {
return false;
} else {
return orderline_super.can_be_merged_with.apply(this, arguments);
super.destroy(...arguments);
}
}
});
}
Registries.Model.extend(Order, L10nFrOrder);
const L10nFrOrderline = (Orderline) => class L10nFrOrderline extends Orderline {
can_be_merged_with(orderline) {
let order = this.pos.get_order();
let orderlines = order.orderlines;
let lastOrderline = order.orderlines.at(orderlines.length - 1);
if(this.pos.is_french_country() && (lastOrderline.product.id !== orderline.product.id || lastOrderline.quantity < 0)) {
return false;
} else {
return super.can_be_merged_with(...arguments);
}
}
}
Registries.Model.extend(Orderline, L10nFrOrderline);
});
+1
View File
@@ -2,3 +2,4 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import pos_order
from . import pos_session
+13
View File
@@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class PosSession(models.Model):
_inherit = 'pos.session'
def _loader_params_product_product(self):
result = super()._loader_params_product_product()
result['search_params']['fields'].append('l10n_in_hsn_code')
return result
+8 -8
View File
@@ -1,17 +1,17 @@
odoo.define('l10n_in_pos.receipt', function (require) {
"use strict";
var models = require('point_of_sale.models');
var { Orderline } = require('point_of_sale.models');
const Registries = require('point_of_sale.Registries');
models.load_fields('product.product', 'l10n_in_hsn_code');
var _super_orderline = models.Orderline.prototype;
models.Orderline = models.Orderline.extend({
export_for_printing: function() {
var line = _super_orderline.export_for_printing.apply(this,arguments);
const L10nInOrderline = (Orderline) => class L10nInOrderline extends Orderline {
export_for_printing() {
var line = super.export_for_printing(...arguments);
line.l10n_in_hsn_code = this.get_product().l10n_in_hsn_code;
return line;
},
});
}
}
Registries.Model.extend(Orderline, L10nInOrderline);
});
+11 -10
View File
@@ -2,20 +2,20 @@ odoo.define('l10n_sa_pos.pos', function (require) {
"use strict";
const { Gui } = require('point_of_sale.Gui');
var models = require('point_of_sale.models');
var { Order } = require('point_of_sale.models');
var rpc = require('web.rpc');
var session = require('web.session');
var core = require('web.core');
var utils = require('web.utils');
var Registries = require('point_of_sale.Registries');
var _t = core._t;
var round_di = utils.round_decimals;
var _super_order = models.Order.prototype;
models.Order = models.Order.extend({
export_for_printing: function() {
var result = _super_order.export_for_printing.apply(this,arguments);
const PosL10nSAOrder = (Order) => class PosL10nSAOrder extends Order {
export_for_printing() {
var result = super.export_for_printing(...arguments);
if (this.pos.company.country.code === 'SA') {
const codeWriter = new window.ZXing.BrowserQRCodeSvgWriter()
let qr_values = this.compute_sa_qr_code(result.company.name, result.company.vat, result.date.isostring, result.total_with_tax, result.total_tax);
@@ -23,7 +23,7 @@ models.Order = models.Order.extend({
result.qr_code = "data:image/svg+xml;base64,"+ window.btoa(qr_code_svg);
}
return result;
},
}
compute_sa_qr_code(name, vat, date_isostring, amount_total, amount_tax) {
/* Generate the qr code for Saudi e-invoicing. Specs are available at the following link at page 23
https://zatca.gov.sa/ar/E-Invoicing/SystemsDevelopers/Documents/20210528_ZATCA_Electronic_Invoice_Security_Features_Implementation_Standards_vShared.pdf
@@ -41,7 +41,7 @@ models.Order = models.Order.extend({
binary += String.fromCharCode(str_to_encode[i]);
}
return btoa(binary);
},
}
_compute_qr_code_field(tag, field) {
const textEncoder = new TextEncoder();
@@ -49,8 +49,9 @@ models.Order = models.Order.extend({
const name_tag_encoding = [tag];
const name_length_encoding = [name_byte_array.length];
return name_tag_encoding.concat(name_length_encoding, name_byte_array);
},
});
}
}
Registries.Model.extend(Order, PosL10nSAOrder);
});
+70 -120
View File
@@ -51,9 +51,61 @@
'application': True,
'website': 'https://www.odoo.com/app/point-of-sale-shop',
'assets': {
## In general, you DON'T NEED to declare new assets here, just put the files in the proper directory.
## NOTABLE EXCEPTION: List the new .css files in the `point_of_sale.assets` bundle taking into consideration
## the order of the .css files.
##
## 1. When defining new component, put the .js files in `point_of_sale/static/src/js/`
## and the corresponding .xml files in `point_of_sale/static/src/xml/`
## * POS is setup to automatically include the .xml files in `web.assets_qweb` and the `.js` files
## * in `point_of_sale.assets`.
## 2. When adding new tour tests, put the .js files in `point_of_sale/static/tests/tours/`.
## 3. When adding new qunit tests, put the .js files in `point_of_sale/static/tests/unit/`.
##
## If your use case doesn't fit anything above, you might need to properly understand each "asset bundle"
## defined here and check how they are used in the following "index templates":
## 1. point_of_sale.index
## -> This is the POS UI, accessible by opening a session.
## 2. point_of_sale.qunit_suite
## -> This is the unit test, accessible by clicking the "Run Point of Sale JS Tests" button
## in the "debug" button from the backend interface.
#####################################
## Augmentation of existing assets ##
#####################################
'web.assets_backend': [
'point_of_sale/static/src/scss/pos_dashboard.scss',
'point_of_sale/static/src/backend/tours/point_of_sale.js',
'point_of_sale/static/src/backend/debug_manager.js',
'point_of_sale/static/src/backend/web_overrides/pos_config_form.js',
],
'web.assets_tests': [
'point_of_sale/static/tests/tours/**/*',
],
'web.assets_qweb': [
'point_of_sale/static/src/xml/**/*.xml',
],
####################################################
## Exclusive POS Assets 1: For running the POS UI ##
####################################################
'point_of_sale.pos_assets_backend_style': [
"web/static/src/core/ui/**/*.scss",
],
# TODO: We need to control this asset bundle.
# We can reduce the size of loaded assets in POS UI by selectively
# loading the `web` assets. We should only include what POS needs.
'point_of_sale.pos_assets_backend': [
('include', 'web.assets_common'),
('include', 'web.assets_backend'),
('remove', 'web/static/src/webclient/menus/menu_service.js'),
('remove', 'web/static/src/core/errors/error_handlers.js'),
('remove', 'web/static/src/legacy/legacy_rpc_error_handler.js'),
],
# This bundle includes the main pos assets.
'point_of_sale.assets': [
'web/static/fonts/fonts.scss',
'web/static/lib/fontawesome/css/font-awesome.css',
@@ -66,119 +118,25 @@
'point_of_sale/static/src/css/popups/closing_pos_popup.css',
'point_of_sale/static/src/css/popups/money_details_popup.css',
'web/static/src/legacy/scss/fontawesome_overridden.scss',
'point_of_sale/static/lib/html2canvas.js',
'point_of_sale/static/lib/backbone/backbone.js',
'point_of_sale/static/lib/waitfont.js',
'point_of_sale/static/lib/sha1.js',
'point_of_sale/static/src/js/utils.js',
'point_of_sale/static/src/js/ClassRegistry.js',
'point_of_sale/static/src/js/PosComponent.js',
'point_of_sale/static/src/js/PosContext.js',
'point_of_sale/static/src/js/ComponentRegistry.js',
'point_of_sale/static/src/js/Registries.js',
'point_of_sale/static/src/js/db.js',
'point_of_sale/static/src/js/models.js',
'point_of_sale/static/src/js/barcode_reader.js',
'point_of_sale/static/src/js/printers.js',
'point_of_sale/static/src/js/Gui.js',
'point_of_sale/static/src/js/PopupControllerMixin.js',
'point_of_sale/static/src/js/ControlButtonsMixin.js',
'point_of_sale/static/src/js/Chrome.js',
'point_of_sale/static/src/js/devices.js',
'point_of_sale/static/src/js/payment.js',
'point_of_sale/static/src/js/custom_hooks.js',
'point_of_sale/static/src/js/Screens/ProductScreen/ProductScreen.js',
'point_of_sale/static/src/js/Screens/ClientListScreen/ClientLine.js',
'point_of_sale/static/src/js/Screens/ClientListScreen/ClientDetailsEdit.js',
'point_of_sale/static/src/js/Screens/ClientListScreen/ClientListScreen.js',
'point_of_sale/static/src/js/Screens/TicketScreen/ControlButtons/InvoiceButton.js',
'point_of_sale/static/src/js/Screens/TicketScreen/ControlButtons/ReprintReceiptButton.js',
'point_of_sale/static/src/js/Screens/TicketScreen/OrderDetails.js',
'point_of_sale/static/src/js/Screens/TicketScreen/OrderlineDetails.js',
'point_of_sale/static/src/js/Screens/TicketScreen/ReprintReceiptScreen.js',
'point_of_sale/static/src/js/Screens/TicketScreen/TicketScreen.js',
'point_of_sale/static/src/js/Screens/PaymentScreen/PSNumpadInputButton.js',
'point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenNumpad.js',
'point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenElectronicPayment.js',
'point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenPaymentLines.js',
'point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreenStatus.js',
'point_of_sale/static/src/js/Screens/PaymentScreen/PaymentMethodButton.js',
'point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreen.js',
'point_of_sale/static/src/js/Screens/ProductScreen/Orderline.js',
'point_of_sale/static/src/js/Screens/ProductScreen/OrderSummary.js',
'point_of_sale/static/src/js/Screens/ProductScreen/OrderWidget.js',
'point_of_sale/static/src/js/Screens/ProductScreen/NumpadWidget.js',
'point_of_sale/static/src/js/Screens/ProductScreen/ActionpadWidget.js',
'point_of_sale/static/src/js/Screens/ProductScreen/CategoryBreadcrumb.js',
'point_of_sale/static/src/js/Screens/ProductScreen/CategoryButton.js',
'point_of_sale/static/src/js/Screens/ProductScreen/CategorySimpleButton.js',
'point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidgetControlPanel.js',
'point_of_sale/static/src/js/Screens/ProductScreen/ProductItem.js',
'point_of_sale/static/src/js/Screens/ProductScreen/ProductList.js',
'point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidget.js',
'point_of_sale/static/src/js/Screens/ReceiptScreen/WrappedProductNameLines.js',
'point_of_sale/static/src/js/Screens/ReceiptScreen/OrderReceipt.js',
'point_of_sale/static/src/js/Screens/ReceiptScreen/ReceiptScreen.js',
'point_of_sale/static/src/js/Screens/ScaleScreen/ScaleScreen.js',
'point_of_sale/static/src/js/ChromeWidgets/CashierName.js',
'point_of_sale/static/src/js/ChromeWidgets/ProxyStatus.js',
'point_of_sale/static/src/js/ChromeWidgets/SyncNotification.js',
'point_of_sale/static/src/js/ChromeWidgets/HeaderButton.js',
'point_of_sale/static/src/js/ChromeWidgets/SaleDetailsButton.js',
'point_of_sale/static/src/js/ChromeWidgets/CashMoveButton.js',
'point_of_sale/static/src/js/ChromeWidgets/TicketButton.js',
'point_of_sale/static/src/js/Misc/CurrencyAmount.js',
'point_of_sale/static/src/js/Misc/Draggable.js',
'point_of_sale/static/src/js/Misc/NotificationSound.js',
'point_of_sale/static/src/js/Misc/IndependentToOrderScreen.js',
'point_of_sale/static/src/js/Misc/AbstractReceiptScreen.js',
'point_of_sale/static/src/js/Misc/SearchBar.js',
'point_of_sale/static/src/js/ChromeWidgets/DebugWidget.js',
'point_of_sale/static/src/js/Popups/AbstractAwaitablePopup.js',
'point_of_sale/static/src/js/Popups/ErrorPopup.js',
'point_of_sale/static/src/js/Popups/ErrorBarcodePopup.js',
'point_of_sale/static/src/js/Popups/ConfirmPopup.js',
'point_of_sale/static/src/js/Popups/CashMovePopup.js',
'point_of_sale/static/src/js/Popups/ControlButtonPopup.js',
'point_of_sale/static/src/js/Popups/TextInputPopup.js',
'point_of_sale/static/src/js/Popups/TextAreaPopup.js',
'point_of_sale/static/src/js/Popups/ErrorTracebackPopup.js',
'point_of_sale/static/src/js/Popups/SelectionPopup.js',
'point_of_sale/static/src/js/Popups/EditListInput.js',
'point_of_sale/static/src/js/Popups/EditListPopup.js',
'point_of_sale/static/src/js/Popups/NumberPopup.js',
'point_of_sale/static/src/js/Popups/OfflineErrorPopup.js',
'point_of_sale/static/src/js/Popups/OrderImportPopup.js',
'point_of_sale/static/src/js/Popups/ProductConfiguratorPopup.js',
'point_of_sale/static/src/js/Popups/CashOpeningPopup.js',
'point_of_sale/static/src/js/Popups/ProductInfoPopup.js',
'point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/OrderlineCustomerNoteButton.js',
'point_of_sale/static/src/js/Popups/ClosePosPopup.js',
'point_of_sale/static/src/js/Popups/MoneyDetailsPopup.js',
'point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SetPricelistButton.js',
'point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SetFiscalPositionButton.js',
'point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/ProductInfoButton.js',
'point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/RefundButton.js',
'point_of_sale/static/src/js/ChromeWidgets/ClientScreenButton.js',
'point_of_sale/static/src/js/Misc/NumberBuffer.js',
'point_of_sale/static/src/js/Misc/MobileOrderWidget.js',
'point_of_sale/static/src/js/Notification.js',
# Here includes the lib and POS UI assets.
'point_of_sale/static/lib/**/*.js',
'point_of_sale/static/src/js/**/*.js',
],
'web.assets_backend': [
'point_of_sale/static/src/scss/pos_dashboard.scss',
'point_of_sale/static/src/js/tours/point_of_sale.js',
'point_of_sale/static/src/js/debug_manager.js',
'point_of_sale/static/src/js/web_overrides/pos_config_form.js',
],
'point_of_sale.pos_assets_backend': [
('include', 'web.assets_backend'),
('remove', 'web/static/src/webclient/menus/menu_service.js'),
('remove', 'web/static/src/core/errors/error_handlers.js'),
('remove', 'web/static/src/legacy/legacy_rpc_error_handler.js'),
],
'point_of_sale.pos_assets_backend_style': [
"web/static/src/core/ui/**/*.scss",
# This bundle contains the code responsible for starting the POS UI.
# It is practically the entry point.
'point_of_sale.assets_backend_prod_only': [
'point_of_sale/static/src/entry/chrome_adapter.js',
'point_of_sale/static/src/entry/main.js',
'web/static/src/start.js',
'web/static/src/legacy/legacy_setup.js',
],
#########################################################
## Exclusive POS Assets 2: For running the QUnit tests ##
#########################################################
# This bundle includes the helper assets for the unit testing.
'point_of_sale.tests_assets': [
'web/static/lib/qunit/qunit-2.9.1.css',
'web/static/lib/qunit/qunit-2.9.1.js',
@@ -201,18 +159,10 @@
('include', 'web.frontend_legacy'),
],
# This bundle includes the unit tests.
'point_of_sale.qunit_suite_tests': [
'point_of_sale/static/tests/unit/**/*',
],
'point_of_sale.assets_backend_prod_only': [
'point_of_sale/static/src/js/chrome_adapter.js',
'point_of_sale/static/src/js/main.js',
'web/static/src/start.js',
'web/static/src/legacy/legacy_setup.js',
],
'web.assets_qweb': [
'point_of_sale/static/src/xml/**/*',
],
},
'license': 'LGPL-3',
}
+2
View File
@@ -57,6 +57,7 @@ class PosController(http.Controller):
context = {
'session_info': session_info,
'login_number': pos_session.login(),
'pos_session_id': pos_session.id,
}
response = request.render('point_of_sale.index', context)
response.headers['Cache-Control'] = 'no-store'
@@ -74,6 +75,7 @@ class PosController(http.Controller):
session_info['user_context']['allowed_company_ids'] = pos_session.company_id.ids
context = {
'session_info': session_info,
'pos_session_id': pos_session.id,
}
return request.render('point_of_sale.qunit_suite', qcontext=context)
@@ -24,12 +24,3 @@ class AccountTax(models.Model):
'You must close the POS sessions before modifying the tax.'
))
return super(AccountTax, self).write(vals)
def get_real_tax_amount(self):
tax_list = []
for tax in self:
tax_repartition_lines = tax.invoice_repartition_line_ids.filtered(lambda x: x.repartition_type == 'tax')
total_factor = sum(tax_repartition_lines.mapped('factor'))
real_amount = tax.amount * total_factor
tax_list.append({'id': tax.id, 'amount': real_amount})
return tax_list
+1 -1
View File
@@ -682,7 +682,7 @@ class PosOrder(models.Model):
if (existing_order and existing_order.state == 'draft') or not existing_order:
order_ids.append(self._process_order(order, draft, existing_order))
return self.env['pos.order'].search_read(domain = [('id', 'in', order_ids)], fields = ['id', 'pos_reference'])
return self.env['pos.order'].search_read(domain=[('id', 'in', order_ids)], fields=['id', 'pos_reference', 'account_move'], load=False)
def _should_create_picking_real_time(self):
return not self.session_id.update_stock_at_closing or (self.company_id.anglo_saxon_accounting and self.to_invoice)
+420
View File
@@ -3,10 +3,13 @@
from collections import defaultdict
from datetime import timedelta
from itertools import groupby
from odoo import api, fields, models, _, Command
from odoo.exceptions import AccessError, UserError, ValidationError
from odoo.tools import float_is_zero, float_compare
from odoo.osv.expression import AND, OR
from odoo.service.common import exp_version
class PosSession(models.Model):
_name = 'pos.session'
@@ -1550,6 +1553,423 @@ class PosSession(models.Model):
message_content.append(f'- Reason: {reason}')
self.message_post(body='<br/>\n'.join(message_content))
def get_onboarding_data(self):
return {
"categories": self._load_model('pos.category'),
"products": self._load_model('product.product'),
}
def _load_model(self, model):
model_name = model.replace('.', '_')
loader = getattr(self, '_get_pos_ui_%s' % model_name, None)
params = getattr(self, '_loader_params_%s' % model_name, None)
if loader and params:
return loader(params())
else:
raise NotImplementedError(_("The function to load %s has not been implemented.", model))
def load_pos_data(self):
loaded_data = {}
self = self.with_context(loaded_data=loaded_data)
for model in self._pos_ui_models_to_load():
loaded_data[model] = self._load_model(model)
self._pos_data_process(loaded_data)
return loaded_data
def _get_attributes_by_ptal_id(self):
product_attributes = self.env['product.attribute'].search([('create_variant', '=', 'no_variant')])
product_attributes_by_id = {product_attribute.id: product_attribute for product_attribute in product_attributes}
domain = [('attribute_id', 'in', product_attributes.mapped('id'))]
product_template_attribute_values = self.env['product.template.attribute.value'].search(domain)
key = lambda ptav: (ptav.attribute_line_id.id, ptav.attribute_id.id)
res = {}
for key, group in groupby(sorted(product_template_attribute_values, key=key), key=key):
attribute_line_id, attribute_id = key
values = [{**ptav.product_attribute_value_id.read(['name', 'is_custom', 'html_color'])[0],
'price_extra': ptav.price_extra} for ptav in list(group)]
res[attribute_line_id] = {
'id': attribute_line_id,
'name': product_attributes_by_id[attribute_id].name,
'display_type': product_attributes_by_id[attribute_id].display_type,
'values': values
}
return res
def _pos_data_process(self, loaded_data):
"""
This is where we need to process the data if we can't do it in the loader/getter
"""
loaded_data['version'] = exp_version()
loaded_data['units_by_id'] = {unit['id']: unit for unit in loaded_data['uom.uom']}
loaded_data['taxes_by_id'] = {tax['id']: tax for tax in loaded_data['account.tax']}
for tax in loaded_data['taxes_by_id'].values():
tax['children_tax_ids'] = [loaded_data['taxes_by_id'][id] for id in tax['children_tax_ids']]
for pricelist in loaded_data['product.pricelist']:
if pricelist['id'] == self.config_id.pricelist_id.id:
loaded_data['default_pricelist'] = pricelist
break
fiscal_position_by_id = {fpt['id']: fpt for fpt in self._get_pos_ui_account_fiscal_position_tax(
self._loader_params_account_fiscal_position_tax())}
for fiscal_position in loaded_data['account.fiscal.position']:
fiscal_position['fiscal_position_taxes_by_id'] = {tax_id: fiscal_position_by_id[tax_id] for tax_id in fiscal_position['tax_ids']}
if self.config_id.product_configurator:
loaded_data['attributes_by_ptal_id'] = self._get_attributes_by_ptal_id()
@api.model
def _pos_ui_models_to_load(self):
models_to_load = [
'res.company',
'decimal.precision',
'uom.uom',
'res.country.state',
'res.country',
'res.lang',
'account.tax',
'pos.session',
'pos.config',
'pos.bill',
'res.partner',
'stock.picking.type',
'res.users',
'product.pricelist',
'res.currency',
'pos.category',
'product.product',
'product.packaging',
'account.cash.rounding',
'pos.payment.method',
'account.fiscal.position',
]
if self.config_id.cash_control:
models_to_load.append('account.bank.statement')
return models_to_load
def _loader_params_res_company(self):
return {
'search_params': {
'domain': [('id', '=', self.company_id.id)],
'fields': [
'currency_id', 'email', 'website', 'company_registry', 'vat', 'name', 'phone', 'partner_id',
'country_id', 'state_id', 'tax_calculation_rounding_method'
],
}
}
def _get_pos_ui_res_company(self, params):
company = self.env['res.company'].search_read(**params['search_params'])[0]
params_country = self._loader_params_res_country()
if company['country_id']:
# TODO: this is redundant we have country_id and country
params_country['search_params']['domain'] = [('id', '=', company['country_id'][0])]
company['country'] = self.env['res.country'].search_read(**params_country['search_params'])[0]
else:
company['country'] = None
return company
def _loader_params_decimal_precision(self):
return {'search_params': {'domain': [], 'fields': ['name', 'digits']}}
def _get_pos_ui_decimal_precision(self, params):
decimal_precisions = self.env['decimal.precision'].search_read(**params['search_params'])
return {dp['name']: dp['digits'] for dp in decimal_precisions}
def _loader_params_uom_uom(self):
return {'search_params': {'domain': [], 'fields': []}, 'context': {'active_test': False}}
def _get_pos_ui_uom_uom(self, params):
return self.env['uom.uom'].with_context(**params['context']).search_read(**params['search_params'])
def _loader_params_res_country_state(self):
return {'search_params': {'domain': [], 'fields': ['name', 'country_id']}}
def _get_pos_ui_res_country_state(self, params):
return self.env['res.country.state'].search_read(**params['search_params'])
def _loader_params_res_country(self):
return {'search_params': {'domain': [], 'fields': ['name', 'vat_label', 'code']}}
def _get_pos_ui_res_country(self, params):
return self.env['res.country'].search_read(**params['search_params'])
def _loader_params_res_lang(self):
return {'search_params': {'domain': [], 'fields': ['name', 'code']}}
def _get_pos_ui_res_lang(self, params):
return self.env['res.lang'].search_read(**params['search_params'])
def _loader_params_account_tax(self):
return {
'search_params': {
'domain': [('company_id', '=', self.company_id.id)],
'fields': [
'name', 'real_amount', 'price_include', 'include_base_amount', 'is_base_affected',
'amount_type', 'children_tax_ids'
],
},
}
def _get_pos_ui_account_tax(self, params):
taxes = self.env['account.tax'].search_read(**params['search_params'])
# TODO: rename amount to real_amount in front end
for tax in taxes:
tax['amount'] = tax['real_amount']
del tax['real_amount']
return taxes
def _loader_params_pos_session(self):
return {
'search_params': {
'domain': [('id', '=', self.id)],
'fields': [
'id', 'name', 'user_id', 'config_id', 'start_at', 'stop_at', 'sequence_number',
'payment_method_ids', 'cash_register_id', 'state', 'update_stock_at_closing'
],
},
}
def _get_pos_ui_pos_session(self, params):
return self.env['pos.session'].search_read(**params['search_params'])[0]
def _loader_params_pos_config(self):
return {'search_params': {'domain': [('id', '=', self.config_id.id)], 'fields': []}}
def _get_pos_ui_pos_config(self, params):
config = self.env['pos.config'].search_read(**params['search_params'])[0]
config['use_proxy'] = config['is_posbox'] and (config['iface_electronic_scale'] or config['iface_print_via_proxy']
or config['iface_scan_via_proxy'] or config['iface_customer_facing_display_via_proxy'])
return config
def _loader_params_pos_bill(self):
return {'search_params': {'domain': [('id', 'in', self.config_id.default_bill_ids.ids)], 'fields': ['name', 'value']}}
def _get_pos_ui_pos_bill(self, params):
return self.env['pos.bill'].search_read(**params['search_params'])
def _loader_params_res_partner(self):
return {
'search_params': {
'domain': [],
'fields': [
'name', 'street', 'city', 'state_id', 'country_id', 'vat', 'lang', 'phone', 'zip', 'mobile', 'email',
'barcode', 'write_date', 'property_account_position_id', 'property_product_pricelist'
],
},
}
def _get_pos_ui_res_partner(self, params):
if not self.config_id.limited_partners_loading:
return self.env['res.partner'].search_read(**params['search_params'])
partner_ids = [res[0] for res in self.config_id.get_limited_partners_loading()]
return self.env['res.partner'].browse(partner_ids).read(params['search_params']['fields'])
def _loader_params_stock_picking_type(self):
return {
'search_params': {
'domain': [('id', '=', self.config_id.picking_type_id.id)],
'fields': ['use_create_lots', 'use_existing_lots'],
},
}
def _get_pos_ui_stock_picking_type(self, params):
return self.env['stock.picking.type'].search_read(**params['search_params'])[0]
def _loader_params_res_users(self):
return {
'search_params': {
'domain': [('id', '=', self.env.user.id)],
'fields': ['name', 'groups_id'],
},
}
def _get_pos_ui_res_users(self, params):
user = self.env['res.users'].search_read(**params['search_params'])[0]
user['role'] = 'manager' if any(id == self.config_id.group_pos_manager_id.id for id in user['groups_id']) else 'cashier'
del user['groups_id']
return user
def _loader_params_product_pricelist(self):
if self.config_id.use_pricelist:
domain = [('id', 'in', self.config_id.available_pricelist_ids.ids)]
else:
domain = [('id', '=', self.config_id.pricelist_id.id)]
return {'search_params': {'domain': domain, 'fields': ['name', 'display_name', 'discount_policy']}}
def _get_pos_ui_product_pricelist(self, params):
pricelists = self.env['product.pricelist'].search_read(**params['search_params'])
for pricelist in pricelists:
pricelist['items'] = []
pricelist_by_id = {pricelist['id']: pricelist for pricelist in pricelists}
pricelist_item_domain = [('pricelist_id', 'in', [p['id'] for p in pricelists])]
for item in self.env['product.pricelist.item'].search_read(pricelist_item_domain, []):
pricelist_by_id[item['pricelist_id'][0]]['items'].append(item)
item['base_pricelist'] = pricelist_by_id.get(item['base_pricelist_id'][0]) if item['base_pricelist_id'] else None
return pricelists
def _loader_params_account_bank_statement(self):
return {'search_params': {'domain': [('id', '=', self.cash_register_id.id)], 'fields': ['id', 'balance_start']}}
def _get_pos_ui_account_bank_statement(self, params):
return self.env['account.bank.statement'].search_read(**params['search_params'])[0]
def _loader_params_product_category(self):
return {'search_params': {'domain': [], 'fields': ['name', 'parent_id']}}
def _get_pos_ui_product_category(self, params):
categories = self.env['product.category'].search_read(**params['search_params'])
category_by_id = {category['id']: category for category in categories}
for category in categories:
category['parent'] = category_by_id[category['parent_id'][0]] if category['parent_id'] else None
return categories
def _loader_params_res_currency(self):
return {
'search_params': {
'domain': [('id', '=', self.config_id.currency_id.id)],
'fields': ['name', 'symbol', 'position', 'rounding', 'rate', 'decimal_places'],
},
}
def _get_pos_ui_res_currency(self, params):
return self.env['res.currency'].search_read(**params['search_params'])[0]
def _loader_params_pos_category(self):
domain = []
if self.config_id.limit_categories and self.config_id.iface_available_categ_ids:
domain = [('id', 'in', self.config_id.iface_available_categ_ids.ids)]
return {'search_params': {'domain': domain, 'fields': ['id', 'name', 'parent_id', 'child_id', 'write_date']}}
def _get_pos_ui_pos_category(self, params):
return self.env['pos.category'].search_read(**params['search_params'])
def _loader_params_product_product(self):
domain = [
'&', '&', ('sale_ok', '=', True), ('available_in_pos', '=', True), '|',
('company_id', '=', self.config_id.company_id.id), ('company_id', '=', False)
]
if self.config_id.limit_categories and self.config_id.iface_available_categ_ids:
domain = AND([domain, [('pos_categ_id', 'in', self.config_id.iface_available_categ_ids.ids)]])
if self.config_id.iface_tipproduct:
domain = OR([domain, [('id', '=', self.config_id.tip_product_id.id)]])
return {
'search_params': {
'domain': domain,
'fields': [
'display_name', 'lst_price', 'standard_price', 'categ_id', 'pos_categ_id', 'taxes_id', 'barcode',
'default_code', 'to_weight', 'uom_id', 'description_sale', 'description', 'product_tmpl_id', 'tracking',
'write_date', 'available_in_pos', 'attribute_line_ids', 'active'
],
'order': 'sequence,default_code,name',
},
'context': {'display_default_code': False},
}
def _process_pos_ui_product_product(self, products):
"""
Modify the list of products to add the categories as well as adapt the lst_price
:param products: a list of products
"""
if self.config_id.currency_id != self.company_id.currency_id:
for product in products:
product['lst_price'] = self.company_id.currency_id._convert(product['lst_price'], self.config_id.currency_id,
self.company_id, fields.Date.today())
categories = self._get_pos_ui_product_category(self._loader_params_product_category())
product_category_by_id = {category['id']: category for category in categories}
for product in products:
product['categ'] = product_category_by_id[product['categ_id'][0]]
def _get_pos_ui_product_product(self, params):
self = self.with_context(**params['context'])
if not self.config_id.limited_products_loading:
products = self.env['product.product'].search_read(**params['search_params'])
else:
products = self.config_id.get_limited_products_loading(params['search_params']['fields'])
self._process_pos_ui_product_product(products)
return products
def _loader_params_product_packaging(self):
return {
'search_params': {
'domain': [('barcode', 'not in', ['', False])],
'fields': ['name', 'barcode', 'product_id', 'qty'],
},
}
def _get_pos_ui_product_packaging(self, params):
return self.env['product.packaging'].search_read(**params['search_params'])
def _loader_params_account_cash_rounding(self):
return {
'search_params': {
'domain': [('id', '=', self.config_id.rounding_method.id)],
'fields': ['name', 'rounding', 'rounding_method'],
},
}
def _get_pos_ui_account_cash_rounding(self, params):
return self.env['account.cash.rounding'].search_read(**params['search_params'])
def _loader_params_pos_payment_method(self):
return {
'search_params': {
'domain': [],
'fields': ['name', 'is_cash_count', 'use_payment_terminal', 'split_transactions', 'type'],
'order': 'is_cash_count desc, id',
},
}
def _get_pos_ui_pos_payment_method(self, params):
return self.env['pos.payment.method'].search_read(**params['search_params'])
def _loader_params_account_fiscal_position(self):
return {'search_params': {'domain': [('id', 'in', self.config_id.fiscal_position_ids.ids)], 'fields': []}}
def _get_pos_ui_account_fiscal_position(self, params):
return self.env['account.fiscal.position'].search_read(**params['search_params'])
def _loader_params_account_fiscal_position_tax(self):
loaded_data = self._context.get('loaded_data')
fps = loaded_data['account.fiscal.position']
fiscal_position_tax_ids = sum([fpos['tax_ids'] for fpos in fps], [])
return {'search_params': {'domain': [('id', 'in', fiscal_position_tax_ids)], 'fields': []}}
def _get_pos_ui_account_fiscal_position_tax(self, params):
return self.env['account.fiscal.position.tax'].search_read(**params['search_params'])
def get_pos_ui_product_product_by_params(self, custom_search_params):
"""
:param custom_search_params: a dictionary containing params of a search_read()
"""
params = self._loader_params_product_product()
# custom_search_params will take priority
params['search_params'] = {**params['search_params'], **custom_search_params}
products = self.env['product.product'].search_read(**params['search_params'])
if len(products) > 0:
self._process_pos_ui_product_product(products)
return products
def get_pos_ui_res_partner_by_params(self, custom_search_params):
"""
:param custom_search_params: a dictionary containing params of a search_read()
"""
params = self._loader_params_res_partner()
# custom_search_params will take priority
params['search_params'] = {**params['search_params'], **custom_search_params}
partners = self.env['res.partner'].search_read(**params['search_params'])
return partners
class ProcurementGroup(models.Model):
_inherit = 'procurement.group'
File diff suppressed because it is too large Load Diff
+59 -4
View File
@@ -378,11 +378,8 @@ td {
border-right: 1px solid #875A7B;
}
.pos .status-buttons-portal {
flex: 1;
}
.pos .status-buttons {
flex: 1;
display: flex;
justify-content: flex-end;
height: 100%;
@@ -3621,3 +3618,61 @@ td {
text-align: left;
padding-left: 5px;
}
/* For LoadingScreen */
.lds-ellipsis {
display: inline-block;
position: relative;
width: 80px;
height: 60px;
}
.lds-ellipsis div {
position: absolute;
top: 33px;
width: 13px;
height: 13px;
border-radius: 50%;
background: #fff;
animation-timing-function: cubic-bezier(0, 1, 1, 0);
}
.lds-ellipsis div:nth-child(1) {
left: 8px;
animation: lds-ellipsis1 0.6s infinite;
}
.lds-ellipsis div:nth-child(2) {
left: 8px;
animation: lds-ellipsis2 0.6s infinite;
}
.lds-ellipsis div:nth-child(3) {
left: 32px;
animation: lds-ellipsis2 0.6s infinite;
}
.lds-ellipsis div:nth-child(4) {
left: 56px;
animation: lds-ellipsis3 0.6s infinite;
}
@keyframes lds-ellipsis1 {
0% {
transform: scale(0);
}
100% {
transform: scale(1);
}
}
@keyframes lds-ellipsis3 {
0% {
transform: scale(1);
}
100% {
transform: scale(0);
}
}
@keyframes lds-ellipsis2 {
0% {
transform: translate(0, 0);
}
100% {
transform: translate(24px, 0);
}
}
@@ -3,10 +3,14 @@
import { useService } from "@web/core/utils/hooks";
import Chrome from "point_of_sale.Chrome";
import ProductScreen from "point_of_sale.ProductScreen";
import Registries from "point_of_sale.Registries";
import { reactive, batched, markRaw } from "@point_of_sale/js/reactivity";
import { PosGlobalState } from "point_of_sale.models";
import { configureGui } from "point_of_sale.Gui";
import { useBus } from "@web/core/utils/hooks";
import { registry } from "@web/core/registry";
import env from "point_of_sale.env";
const { Component, debounce, onMounted, useRef, xml } = owl;
@@ -25,9 +29,29 @@ function setupResponsivePlugin(env) {
export class ChromeAdapter extends Component {
setup() {
this.PosChrome = Registries.Component.get(Chrome);
this.legacyActionManager = useService("legacy_action_manager");
ProductScreen.sortControlButtons();
const legacyActionManager = useService("legacy_action_manager");
// Instantiate PosGlobalState here to ensure that every extension
// (or class overloads) is taken into consideration.
const pos = PosGlobalState.create({ env: markRaw(env) });
const batchedCustomerDisplayRender = batched(() => {
reactivePos.send_current_order_to_customer_facing_display();
});
const reactivePos = reactive(pos, batchedCustomerDisplayRender);
env.pos = reactivePos;
env.legacyActionManager = legacyActionManager;
// The proxy requires the instance of PosGlobalState to function properly.
env.proxy.set_pos(reactivePos);
// TODO: Should we continue on exposing posmodel as global variable?
// Expose only the reactive version of `pos` when in debug mode.
window.posmodel = pos.debug ? reactivePos : pos;
this.env = env;
this.env = Component.env;
useBus(this.env.qweb, "update", () => this.render());
setupResponsivePlugin(this.env);
@@ -50,7 +74,10 @@ export class ChromeAdapter extends Component {
configureGui({ component: chrome.comp });
await chrome.comp.start();
registry.category("main_components").add("BlockUI", BlockUiFromRegistry);
// Subscribe to the changes in the models.
batchedCustomerDisplayRender();
});
}
}
ChromeAdapter.template = xml`<PosChrome t-ref="chrome" webClient="legacyActionManager"/>`;
ChromeAdapter.template = xml`<PosChrome t-ref="chrome" />`;
@@ -2,7 +2,7 @@
import { startWebClient } from "@web/start";
import { ChromeAdapter } from "@point_of_sale/js/chrome_adapter";
import { ChromeAdapter } from "@point_of_sale/entry/chrome_adapter";
import Registries from "point_of_sale.Registries";
import { registry } from "@web/core/registry";
@@ -30,6 +30,7 @@ PosApp.components = { ChromeAdapter };
function startPosApp() {
Registries.Component.add(Portal);
Registries.Component.freeze();
Registries.Model.freeze();
startWebClient(PosApp);
}
+88 -94
View File
@@ -4,24 +4,19 @@ odoo.define('point_of_sale.Chrome', function(require) {
const { loadCSS } = require('web.ajax');
const { useListener } = require('web.custom_hooks');
const { BarcodeEvents } = require('barcodes.BarcodeEvents');
const BarcodeParser = require('barcodes.BarcodeParser');
const PosComponent = require('point_of_sale.PosComponent');
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');
const { identifyError, posbus } = require('point_of_sale.utils');
const { identifyError } = require('point_of_sale.utils');
const { odooExceptionTitleMap } = require("@web/core/errors/error_dialogs");
const { ConnectionLostError, ConnectionAbortedError, RPCError } = require('@web/core/network/rpc_service');
const { useBus } = require("@web/core/utils/hooks");
const reactivity = require("@point_of_sale/js/reactivity");
// This is kind of a trick.
// We get a reference to the whole exports so that
// when we create an instance of one of the classes,
// we instantiate the extended one.
const models = require('point_of_sale.models');
const { debounce, useContext, useExternalListener, useRef, useState } = owl;
const { debounce, useExternalListener, useRef, useState } = owl;
/**
* Chrome is the root component of the PoS App.
@@ -36,16 +31,15 @@ odoo.define('point_of_sale.Chrome', function(require) {
useListener('show-temp-screen', this.__showTempScreen);
useListener('close-temp-screen', this.__closeTempScreen);
useListener('close-pos', this._closePos);
useListener('loading-skip-callback', () => this._loadingSkipCallback());
useListener('loading-skip-callback', () => this.env.proxy.stop_searching());
useListener('play-sound', this._onPlaySound);
useListener('set-sync-status', this._onSetSyncStatus);
useListener('show-notification', this._onShowNotification);
useListener('close-notification', this._onCloseNotification);
useBus(posbus, 'start-cash-control', this.openCashControl);
useListener('connect-to-proxy', this.connect_to_proxy);
useBus(this.env.posbus, 'start-cash-control', this.openCashControl);
NumberBuffer.activate();
this.chromeContext = useContext(contexts.chrome);
this.state = useState({
uiState: 'LOADING', // 'LOADING' | 'READY' | 'CLOSING'
debugWidgetIsShown: true,
@@ -56,12 +50,8 @@ odoo.define('point_of_sale.Chrome', function(require) {
isShown: false,
message: '',
duration: 2000,
}
});
this.loading = useState({
message: 'Loading',
skipButtonIsShown: false,
},
loadingSkipButtonIsShown: false,
});
this.mainScreen = useState({ name: null, component: null });
@@ -73,6 +63,8 @@ odoo.define('point_of_sale.Chrome', function(require) {
this.progressbar = useRef('progressbar');
this.previous_touch_y_coordinate = -1;
this.env.pos = reactivity.useState(this.env.pos);
}
// OVERLOADED METHODS //
@@ -135,46 +127,25 @@ odoo.define('point_of_sale.Chrome', function(require) {
*/
async start() {
try {
// Instead of passing chrome to the instantiation the PosModel,
// we inject functions needed by pos.
// This way, we somehow decoupled Chrome from PosModel.
// We can then test PosModel independently from Chrome by supplying
// mocked version of these default attributes.
const posModelDefaultAttributes = {
env: this.env,
rpc: this.rpc.bind(this),
session: this.env.session,
do_action: this.props.webClient.do_action.bind(this.props.webClient),
setLoadingMessage: this.setLoadingMessage.bind(this),
showLoadingSkip: this.showLoadingSkip.bind(this),
setLoadingProgress: this.setLoadingProgress.bind(this),
};
this.env.pos = new models.PosModel(posModelDefaultAttributes);
await this.env.pos.ready;
await this.env.pos.load_server_data();
await this.setupBarcodeParser();
if(this.env.pos.config.use_proxy){
await this.connect_to_proxy();
}
// Load the saved `env.pos.toRefundLines` from localStorage when
// the PosModel is ready.
// the PosGlobalState is ready.
Object.assign(this.env.pos.toRefundLines, this.env.pos.db.load('TO_REFUND_LINES') || {});
this._buildChrome();
this._closeOtherTabs();
this.env.pos.set(
'selectedCategoryId',
this.env.pos.config.iface_start_categ_id
? this.env.pos.config.iface_start_categ_id[0]
: 0
);
this.env.pos.selectedCategoryId = this.env.pos.config.iface_start_categ_id
? this.env.pos.config.iface_start_categ_id[0]
: 0;
this.state.uiState = 'READY';
this.env.pos.on('change:selectedOrder', this._showSavedScreen, this);
this._showStartScreen();
if (_.isEmpty(this.env.pos.db.product_by_category_id)) {
this._loadDemoData();
}
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();
});
setTimeout(() => this._runBackgroundTasks());
} catch (error) {
let title = 'Unknown Error',
body;
@@ -207,6 +178,61 @@ odoo.define('point_of_sale.Chrome', function(require) {
}
}
_runBackgroundTasks() {
// 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();
if (this.env.pos.config.limited_partners_loading && this.env.pos.config.partner_load_background) {
this.env.pos.loadPartnersBackground();
}
if (this.env.pos.config.limited_products_loading && this.env.pos.config.product_load_background) {
this.env.pos.loadProductsBackground().then(() => this.render());
}
}
setupBarcodeParser() {
const barcode_parser = new BarcodeParser({ nomenclature_id: this.env.pos.config.barcode_nomenclature_id });
this.env.barcode_reader.set_barcode_parser(barcode_parser);
return barcode_parser.is_loaded();
}
connect_to_proxy() {
return new Promise(function (resolve, reject) {
this.env.barcode_reader.disconnect_from_proxy();
this.state.loadingSkipButtonIsShown = true;
this.env.proxy.autoconnect({
force_ip: this.env.pos.config.proxy_ip || undefined,
progress: function(prog){},
}).then(
function () {
if (this.env.pos.config.iface_scan_via_proxy) {
this.env.barcode_reader.connect_to_proxy();
}
resolve();
},
function (statusText, url) {
// this should reject so that it can be captured when we wait for pos.ready
// in the chrome component.
// then, if it got really rejected, we can show the error.
if (statusText == 'error' && window.location.protocol == 'https:') {
reject({
title: this.env._t('HTTPS connection to IoT Box failed'),
body: _.str.sprintf(
this.env._t('Make sure you are using IoT Box v18.12 or higher. Navigate to %s to accept the certificate of your IoT Box.'),
url
),
popup: 'alert',
});
} else {
resolve();
}
}
);
});
}
openCashControl() {
if (this.shouldShowCashControl()) {
this.showPopup('CashOpeningPopup', { notEscapable: true });
@@ -223,15 +249,6 @@ odoo.define('point_of_sale.Chrome', function(require) {
const { name, props } = this.startScreen;
this.showScreen(name, props);
}
/**
* Show the screen saved in the order when the `selectedOrder` of pos is changed.
* @param {models.PosModel} pos
* @param {models.Order} newSelectedOrder
*/
_showSavedScreen(pos, newSelectedOrder) {
const { name, props } = this._getSavedScreen(newSelectedOrder);
this.showScreen(name, props);
}
_getSavedScreen(order) {
return order.get_screen_data();
}
@@ -252,10 +269,7 @@ odoo.define('point_of_sale.Chrome', function(require) {
this.mainScreen.component = component;
this.mainScreenProps = props;
// 2. Set some options
this.chromeContext.showOrderSelector = !component.hideOrderSelector;
// 3. Save the screen to the order.
// 2. Save the screen to the order.
// - This screen is shown when the order is selected.
if (!(component.prototype instanceof IndependentToOrderScreen) && name !== "ReprintReceiptScreen") {
this._setScreenData(name, props);
@@ -291,7 +305,7 @@ odoo.define('point_of_sale.Chrome', function(require) {
window.location = '/web#action=point_of_sale.action_client_pos_menu';
} catch (error) {
console.warn(error);
const reason = this.env.pos.get('failed')
const reason = this.env.pos.failed
? this.env._t(
'Some orders could not be submitted to ' +
'the server due to configuration errors. ' +
@@ -312,8 +326,7 @@ odoo.define('point_of_sale.Chrome', function(require) {
});
if (confirmed) {
this.state.uiState = 'CLOSING';
this.loading.skipButtonIsShown = false;
this.setLoadingMessage(this.env._t('Closing ...'));
this.state.loadingSkipButtonIsShown = false;
window.location = '/web#action=point_of_sale.action_client_pos_menu';
}
}
@@ -339,7 +352,8 @@ odoo.define('point_of_sale.Chrome', function(require) {
this.state.sound.src = src;
}
_onSetSyncStatus({ detail: { status, pending }}) {
this.env.pos.set('synch', { status, pending });
this.env.pos.synch.status = status;
this.env.pos.synch.pending = pending;
}
_onShowNotification({ detail: { message, duration } }) {
this.state.notification.isShown = true;
@@ -358,32 +372,6 @@ odoo.define('point_of_sale.Chrome', function(require) {
this.env.pos.db.save('TO_REFUND_LINES', this.env.pos.toRefundLines);
}
// TO PASS AS PARAMETERS //
setLoadingProgress(fac) {
if (this.progressbar.el) {
this.progressbar.el.style.width = `${Math.floor(fac * 100)}%`;
}
}
setLoadingMessage(msg, progress) {
this.loading.message = msg;
if (typeof progress !== 'undefined') {
this.setLoadingProgress(progress);
}
}
/**
* Show Skip button in the loading screen and allow to assign callback
* when the button is pressed.
*
* @param {Function} callback function to call when Skip button is pressed.
*/
showLoadingSkip(callback) {
if (callback) {
this.loading.skipButtonIsShown = true;
this._loadingSkipCallback = callback;
}
}
get isTicketScreenShown() {
return this.mainScreen.name === 'TicketScreen';
}
@@ -403,7 +391,13 @@ odoo.define('point_of_sale.Chrome', function(require) {
await this.rpc({
'route': '/pos/load_onboarding_data',
});
this.env.pos.load_server_data();
const result = await this.rpc({
method: 'get_onboarding_data',
model: 'pos.session',
args: [[odoo.pos_session_id]]
});
this.env.pos.db.add_categories(result['categories']);
this.env.pos._loadProductProduct(result['products']);
}
}
@@ -29,11 +29,11 @@ odoo.define('point_of_sale.CashMoveButton', function (require) {
method: 'try_cash_in_out',
args: [[this.env.pos.pos_session.id], type, amount, reason, extras],
});
if (this.env.pos.proxy.printer) {
if (this.env.proxy.printer) {
const renderedReceipt = this.env.qweb.renderToString('point_of_sale.CashMoveReceipt', {
_receipt: this._getReceiptInfo({ ...payload, translatedType, formattedAmount }),
});
const printResult = await this.env.pos.proxy.printer.print_receipt(renderedReceipt);
const printResult = await this.env.proxy.printer.print_receipt(renderedReceipt);
if (!printResult.successful) {
this.showPopup('ErrorPopup', { title: printResult.message.title, body: printResult.message.body });
}
@@ -11,8 +11,8 @@ odoo.define('point_of_sale.CashierName', function(require) {
return name ? name : '';
}
get avatar() {
const { user_id } = this.env.pos.get_cashier();
const id = user_id && user_id.length ? user_id[0] : -1;
const user_id = this.env.pos.get_cashier_user_id();
const id = user_id ? user_id : -1;
return `/web/image/res.users/${id}/avatar_128`;
}
}
@@ -39,7 +39,7 @@ odoo.define('point_of_sale.ClientScreenButton', function(require) {
async onClickProxy() {
try {
const renderedHtml = await this.env.pos.render_html_for_customer_facing_display();
let ownership = await this.env.pos.proxy.take_ownership_over_client_screen(
let ownership = await this.env.proxy.take_ownership_over_client_screen(
renderedHtml
);
if (typeof ownership === 'string') {
@@ -50,8 +50,8 @@ odoo.define('point_of_sale.ClientScreenButton', function(require) {
} else {
this.state.status = 'warning';
}
if (!this.env.pos.proxy.posbox_supports_display) {
this.env.pos.proxy.posbox_supports_display = true;
if (!this.env.proxy.posbox_supports_display) {
this.env.proxy.posbox_supports_display = true;
this._start();
}
} catch (error) {
@@ -69,9 +69,9 @@ odoo.define('point_of_sale.ClientScreenButton', function(require) {
const self = this;
async function loop() {
if (self.env.pos.proxy.posbox_supports_display) {
if (self.env.proxy.posbox_supports_display) {
try {
let ownership = await self.env.pos.proxy.test_ownership_of_client_screen();
let ownership = await self.env.proxy.test_ownership_of_client_screen();
if (typeof ownership === 'string') {
ownership = JSON.parse(ownership);
}
@@ -90,7 +90,7 @@ odoo.define('point_of_sale.ClientScreenButton', function(require) {
self.state.status = 'failure';
} else {
self.state.status = 'not_found';
self.env.pos.proxy.posbox_supports_display = false;
self.env.proxy.posbox_supports_display = false;
}
setTimeout(loop, 3000);
}
@@ -29,7 +29,7 @@ odoo.define('point_of_sale.DebugWidget', function (require) {
this.animations = {};
for (let eventName of ['open_cashbox', 'print_receipt', 'scale_read']) {
this.eventElementsRef[eventName] = useRef(eventName);
this.env.pos.proxy.add_notification(
this.env.proxy.add_notification(
eventName,
(() => {
if (this.animations[eventName]) {
@@ -57,22 +57,22 @@ odoo.define('point_of_sale.DebugWidget', function (require) {
setWeight() {
var weightInKg = parse.float(this.state.weightInput);
if (!isNaN(weightInKg)) {
this.env.pos.proxy.debug_set_weight(weightInKg);
this.env.proxy.debug_set_weight(weightInKg);
}
}
resetWeight() {
this.state.weightInput = '';
this.env.pos.proxy.debug_reset_weight();
this.env.proxy.debug_reset_weight();
}
async barcodeScan() {
await this.env.pos.barcode_reader.scan(this.state.barcodeInput);
await this.env.barcode_reader.scan(this.state.barcodeInput);
}
async barcodeScanEAN() {
const ean = this.env.pos.barcode_reader.barcode_parser.sanitize_ean(
const ean = this.env.barcode_reader.barcode_parser.sanitize_ean(
this.state.barcodeInput || '0'
);
this.state.barcodeInput = ean;
await this.env.pos.barcode_reader.scan(ean);
await this.env.barcode_reader.scan(ean);
}
async deleteOrders() {
const { confirmed } = await this.showPopup('ConfirmPopup', {
@@ -144,7 +144,7 @@ odoo.define('point_of_sale.DebugWidget', function (require) {
}
}
refreshDisplay() {
this.env.pos.proxy.message('display_refresh', {});
this.env.proxy.message('display_refresh', {});
}
_onBufferUpdate(buffer) {
this.state.buffer = buffer;
@@ -10,7 +10,7 @@ odoo.define('point_of_sale.ProxyStatus', function(require) {
class ProxyStatus extends PosComponent {
constructor() {
super(...arguments);
const initialProxyStatus = this.env.pos.proxy.get('status');
const initialProxyStatus = this.env.proxy.get('status');
this.state = useState({
status: initialProxyStatus.status,
msg: initialProxyStatus.msg,
@@ -19,21 +19,10 @@ odoo.define('point_of_sale.ProxyStatus', function(require) {
this.index = 0;
}
mounted() {
this.env.pos.proxy.on('change:status', this, this._onChangeStatus);
this.env.proxy.on('change:status', this, this._onChangeStatus);
}
willUnmount() {
this.env.pos.proxy.off('change:status', this, this._onChangeStatus);
}
async onClick() {
try {
await this.env.pos.connect_to_proxy();
} catch (error) {
if (error instanceof Error) {
throw error;
} else {
this.showPopup('ErrorPopup', error);
}
}
this.env.proxy.off('change:status', this, this._onChangeStatus);
}
_onChangeStatus(posProxy, statusChange) {
this._setStatus(statusChange.newValue);
@@ -21,7 +21,7 @@ odoo.define('point_of_sale.SaleDetailsButton', function(require) {
pos: this.env.pos,
})
);
const printResult = await this.env.pos.proxy.printer.print_receipt(report);
const printResult = await this.env.proxy.printer.print_receipt(report);
if (!printResult.successful) {
await this.showPopup('ErrorPopup', {
title: printResult.message.title,
@@ -4,28 +4,7 @@ odoo.define('point_of_sale.SyncNotification', function(require) {
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
const { useState } = owl;
// Previously SynchNotificationWidget
class SyncNotification extends PosComponent {
constructor() {
super(...arguments);
const synch = this.env.pos.get('synch');
this.state = useState({ status: synch.status, msg: synch.pending });
}
mounted() {
this.env.pos.on(
'change:synch',
(pos, synch) => {
this.state.status = synch.status;
this.state.msg = synch.pending;
},
this
);
}
willUnmount() {
this.env.pos.on('change:synch', null, this);
}
onClick() {
this.env.pos.push_orders(null, { show_error: true });
}
@@ -3,28 +3,15 @@ odoo.define('point_of_sale.TicketButton', function (require) {
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
const { posbus } = require('point_of_sale.utils');
class TicketButton extends PosComponent {
onClick() {
if (this.props.isTicketScreenShown) {
posbus.trigger('ticket-button-clicked');
this.env.posbus.trigger('ticket-button-clicked');
} else {
this.showScreen('TicketScreen');
}
}
willPatch() {
posbus.off('order-deleted', this);
}
patched() {
posbus.on('order-deleted', this, this.render);
}
mounted() {
posbus.on('order-deleted', this, this.render);
}
willUnmount() {
posbus.off('order-deleted', this);
}
get count() {
if (this.env.pos) {
return this.env.pos.get_order_list().length;
@@ -11,9 +11,12 @@ odoo.define('point_of_sale.ControlButtonsMixin', function (require) {
* @param {Function} x superclass
*/
const ControlButtonsMixin = (x) => {
const controlButtonsToPosition = [];
const sortedControlButtons = [];
class Extended extends x {
get controlButtons() {
return this.constructor.controlButtons
return sortedControlButtons
.filter((cb) => {
return cb.condition.bind(this)();
})
@@ -22,7 +25,6 @@ odoo.define('point_of_sale.ControlButtonsMixin', function (require) {
);
}
}
Extended.controlButtons = [];
/**
* @param {Object} controlButton
* @param {Function} controlButton.component
@@ -43,40 +45,61 @@ odoo.define('point_of_sale.ControlButtonsMixin', function (require) {
// If no position is set, we just push it to the array.
if (!controlButton.position) {
this.controlButtons.push(controlButton);
sortedControlButtons.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);
}
controlButtonsToPosition.push(controlButton);
}
};
/**
* Call this static method to make the added control buttons in proper
* order.
* NOTE: This isn't necessarily a fast algorithm. I doubt that the number
* of control buttons will exceed an order of hundreds, so for practical
* purposes, it is enough.
*/
Extended.sortControlButtons = function () {
function setControlButton(locator, index, cb) {
if (locator == 'replace') {
sortedControlButtons[index] = cb;
} else if (locator == 'before') {
sortedControlButtons.splice(index, 0, cb);
} else if (locator == 'after') {
sortedControlButtons.splice(index + 1, 0, cb);
}
}
function locate(cb) {
const [locator, reference] = cb.position;
const index = sortedControlButtons.findIndex((cb) => cb.name == reference);
return [locator, index, reference];
}
const cbMissingReference = [];
// 1. First pass. If the reference control button isn't there, collect it for second pass.
for (let cb of controlButtonsToPosition) {
const [locator, index] = locate(cb);
if (index == -1) {
cbMissingReference.push(cb);
continue;
}
setControlButton(locator, index, cb);
}
// 2. Second pass.
// If during the first pass, 1 -> 2, 2 -> 3, 3 -> 4, 4 -> 5 and 5 is already
// in the sorted control buttons, then 1, 2, 3 & 4 are put in `cbMissingReference`.
// This only means 2 things about the objects in `cbMissingReference`:
// i) They are referencing the cb after them
// ii) They really have missing reference.
// Thus, we have to iterate the cb with missing reference in reverse.
for (let cb of cbMissingReference.reverse()) {
const [locator, index, reference] = locate(cb);
if (index == -1) {
console.warn(`'${cb.name}' is not properly position because '${reference}' is not found. Is '${reference}' spelled correctly?`);
sortedControlButtons.push(cb);
} else {
setControlButton(locator, index, cb);
}
}
}
return Extended;
};
@@ -19,8 +19,8 @@ odoo.define('point_of_sale.AbstractReceiptScreen', function (require) {
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 (this.env.proxy.printer) {
const printResult = await this.env.proxy.printer.print_receipt(this.orderReceipt.el.outerHTML);
if (printResult.successful) {
return true;
} else {
@@ -4,18 +4,10 @@ odoo.define('point_of_sale.IndependentToOrderScreen', function (require) {
const PosComponent = require('point_of_sale.PosComponent');
class IndependentToOrderScreen extends PosComponent {
/**
* Alias the forceTriggerSelectedOrder method as it also
* means 'closing' this screen.
*/
close() {
this.forceTriggerSelectedOrder();
}
forceTriggerSelectedOrder() {
// Calling this method forcefully trigger change
// on the selectedOrder attribute, which then shows the screen of the
// current order, essentially closing this screen.
this.env.pos.trigger('change:selectedOrder', this.env.pos, this.env.pos.get_order());
const order = this.env.pos.get_order();
const { name: screenName } = order.get_screen_data();
this.showScreen(screenName);
}
}
@@ -5,28 +5,15 @@ odoo.define('point_of_sale.MobileOrderWidget', function(require) {
const Registries = require('point_of_sale.Registries');
class MobileOrderWidget extends PosComponent {
constructor() {
super(...arguments);
this.update();
}
get order() {
return this.env.pos.get_order();
}
mounted() {
this.order.on('change', () => {
this.update();
this.render();
});
this.order.orderlines.on('change', () => {
this.update();
this.render();
});
get total() {
const _total = this.order ? this.order.get_total_with_tax() : 0;
return this.env.pos.format_currency(_total);
}
update() {
const total = this.order ? this.order.get_total_with_tax() : 0;
const tax = this.order ? total - this.order.get_total_without_tax() : 0;
this.total = this.env.pos.format_currency(total);
this.items_number = this.order ? this.order.orderlines.reduce((items_number,line) => items_number + line.quantity, 0) : 0;
get items_number() {
return this.order ? this.order.orderlines.reduce((items_number,line) => items_number + line.quantity, 0) : 0;
}
}
@@ -3,7 +3,6 @@ odoo.define('point_of_sale.ProductInfoPopup', function(require) {
const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup');
const Registries = require('point_of_sale.Registries');
const { posbus } = require('point_of_sale.utils')
const { ConnectionLostError } = require('@web/core/network/rpc_service')
/**
@@ -29,7 +28,7 @@ odoo.define('point_of_sale.ProductInfoPopup', function(require) {
args: [[this.props.product.id],
this.props.product.get_price(order.pricelist, this.props.quantity),
this.props.quantity,
this.env.pos.config_id],
this.env.pos.config.id],
kwargs: {context: this.env.session.user_context},
});
@@ -67,7 +66,7 @@ odoo.define('point_of_sale.ProductInfoPopup', function(require) {
}
}
searchProduct(productName) {
posbus.trigger('search-product-from-info-popup', productName);
this.env.posbus.trigger('search-product-from-info-popup', productName);
this.cancel()
}
_hasMarginsCostsAccessRights() {
@@ -59,6 +59,14 @@ odoo.define('point_of_sale.PosComponent', function (require) {
closeNotification() {
this.trigger('close-notification');
}
render() {
return super.render().then((res) => {
if (this.env.isDebug()) {
console.log('Rendered:', this.constructor.name);
}
return res;
})
}
}
return PosComponent;
@@ -7,6 +7,5 @@ odoo.define('point_of_sale.PosContext', function (require) {
// e.g. component.env.device = new Context({ isMobile: false });
return {
orderManagement: new Context({ searchString: '', selectedOrder: null }),
chrome: new Context({ showOrderSelector: true }),
};
});
@@ -6,6 +6,22 @@ odoo.define('point_of_sale.Registries', function(require) {
*/
const ComponentRegistry = require('point_of_sale.ComponentRegistry');
const ClassRegistry = require('point_of_sale.ClassRegistry');
return { Component: new ComponentRegistry() };
class ModelRegistry extends ClassRegistry {
add(baseClass) {
super.add(baseClass);
/**
* Introduce a static method (`create`) to each base class that can be
* conveniently use to create an instance of the extended version
* of the class.
*/
baseClass.create = (...args) => {
const ExtendedClass = this.get(baseClass);
return new ExtendedClass(...args);
}
}
}
return { Component: new ComponentRegistry(), Model: new ModelRegistry() };
});
@@ -7,7 +7,6 @@ odoo.define('point_of_sale.PaymentScreen', function (require) {
const NumberBuffer = require('point_of_sale.NumberBuffer');
const { useListener } = require('web.custom_hooks');
const Registries = require('point_of_sale.Registries');
const { onChangeOrder } = require('point_of_sale.custom_hooks');
const { isConnectionError } = require('point_of_sale.utils');
class PaymentScreen extends PosComponent {
@@ -21,6 +20,7 @@ odoo.define('point_of_sale.PaymentScreen', function (require) {
useListener('send-payment-cancel', this._sendPaymentCancel);
useListener('send-payment-reverse', this._sendPaymentReverse);
useListener('send-force-done', this._sendForceDone);
useListener('validate-order', () => this.validateOrder(false));
NumberBuffer.use({
// The numberBuffer listens to this event to update its state.
// Basically means 'update the buffer when this event is triggered'
@@ -29,7 +29,6 @@ odoo.define('point_of_sale.PaymentScreen', function (require) {
// Note that the component listens to it.
triggerAtInput: 'update-selected-paymentline',
});
onChangeOrder(this._onPrevOrder, this._onNewOrder);
useErrorHandlers();
this.payment_interface = null;
this.error = false;
@@ -100,7 +99,7 @@ odoo.define('point_of_sale.PaymentScreen', function (require) {
this.render();
}
openCashbox() {
this.env.pos.proxy.printer.open_cashbox();
this.env.proxy.printer.open_cashbox();
}
async addTip() {
// click_tip
@@ -152,18 +151,6 @@ odoo.define('point_of_sale.PaymentScreen', function (require) {
NumberBuffer.reset();
this.render();
}
/**
* Returns false if the current order is empty and has no payments.
* @returns {boolean}
*/
_isValidEmptyOrder() {
const order = this.currentOrder;
if (order.get_orderlines().length == 0) {
return order.get_paymentlines().length != 0;
} else {
return true;
}
}
async validateOrder(isForceValidate) {
if(this.env.pos.config.cash_rounding) {
if(!this.env.pos.get_order().check_paymentlines_rounding()) {
@@ -184,23 +171,47 @@ odoo.define('point_of_sale.PaymentScreen', function (require) {
}
async _finalizeValidation() {
if ((this.currentOrder.is_paid_with_cash() || this.currentOrder.get_change()) && this.env.pos.config.iface_cashdrawer) {
this.env.pos.proxy.printer.open_cashbox();
this.env.proxy.printer.open_cashbox();
}
this.currentOrder.initialize_validation_date();
this.currentOrder.finalized = true;
let syncedOrderBackendIds = [];
let syncOrderResult, hasError;
try {
// 1. Save order to server.
syncOrderResult = await this.env.pos.push_single_order(this.currentOrder);
// 2. Invoice.
if (this.currentOrder.is_to_invoice()) {
syncedOrderBackendIds = await this.env.pos.push_and_invoice_order(
this.currentOrder
if (syncOrderResult.length) {
await this.env.legacyActionManager.do_action('account.account_invoices', {
additional_context: {
active_ids: [syncOrderResult[0].account_move],
},
});
} else {
throw { code: 401, message: 'Backend Invoice', data: { order: this.currentOrder } };
}
}
// 3. Post process.
if (syncOrderResult.length && this.currentOrder.wait_for_push_order()) {
const postPushResult = await this._postPushOrderResolve(
this.currentOrder,
syncOrderResult.map((res) => res.id)
);
} else {
syncedOrderBackendIds = await this.env.pos.push_single_order(this.currentOrder);
if (!postPushResult) {
this.showPopup('ErrorPopup', {
title: this.env._t('Error: no internet connection.'),
body: this.env._t('Some, if not all, post-processing after syncing order failed.'),
});
}
}
} catch (error) {
hasError = true;
if (error.code == 700)
this.error = true;
@@ -221,37 +232,25 @@ odoo.define('point_of_sale.PaymentScreen', function (require) {
throw error;
}
}
}
if (syncedOrderBackendIds.length && this.currentOrder.wait_for_push_order()) {
const result = await this._postPushOrderResolve(
this.currentOrder,
syncedOrderBackendIds
);
if (!result) {
await this.showPopup('ErrorPopup', {
title: this.env._t('Error: no internet connection.'),
body: this.env._t('Some, if not all, post-processing after syncing order failed.'),
} finally {
// Always show the next screen regardless of error since pos has to
// continue working even offline.
this.showScreen(this.nextScreen);
// Ask the user to sync the remaining unsynced orders.
if (!hasError && syncOrderResult && this.env.pos.db.get_orders().length) {
const { confirmed } = await this.showPopup('ConfirmPopup', {
title: this.env._t('Remaining unsynced orders'),
body: this.env._t(
'There are unsynced orders. Do you want to sync these orders?'
),
});
}
}
this.showScreen(this.nextScreen);
// If we succeeded in syncing the current order, and
// there are still other orders that are left unsynced,
// we ask the user if he is willing to wait and sync them.
if (syncedOrderBackendIds.length && this.env.pos.db.get_orders().length) {
const { confirmed } = await this.showPopup('ConfirmPopup', {
title: this.env._t('Remaining unsynced orders'),
body: this.env._t(
'There are unsynced orders. Do you want to sync these orders?'
),
});
if (confirmed) {
// NOTE: Not yet sure if this should be awaited or not.
// If awaited, some operations like changing screen
// might not work.
this.env.pos.push_orders();
if (confirmed) {
// NOTE: Not yet sure if this should be awaited or not.
// If awaited, some operations like changing screen
// might not work.
this.env.pos.push_orders();
}
}
}
}
@@ -364,7 +363,7 @@ odoo.define('point_of_sale.PaymentScreen', function (require) {
return false;
}
if (!this._isValidEmptyOrder()) return false;
if (!this.currentOrder._isValidEmptyOrder()) return false;
return true;
}
@@ -414,19 +413,6 @@ odoo.define('point_of_sale.PaymentScreen', function (require) {
async _sendForceDone({ detail: line }) {
line.set_payment_status('done');
}
_onPrevOrder(prevOrder) {
prevOrder.off('change', null, this);
prevOrder.paymentlines.off('change', null, this);
if (prevOrder) {
prevOrder.stop_electronic_payment();
}
}
async _onNewOrder(newOrder) {
newOrder.on('change', this.render, this);
newOrder.paymentlines.on('change', this.render, this);
NumberBuffer.reset();
await this.render();
}
}
PaymentScreen.template = 'PaymentScreen';
@@ -4,17 +4,7 @@ odoo.define('point_of_sale.PaymentScreenElectronicPayment', function (require) {
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
class PaymentScreenElectronicPayment extends PosComponent {
mounted() {
this.props.line.on('change', this.render, this);
}
willUnmount() {
if (this.props.line) {
// It could be that the line is deleted before unmounting the element.
this.props.line.off('change', null, this);
}
}
}
class PaymentScreenElectronicPayment extends PosComponent {}
PaymentScreenElectronicPayment.template = 'PaymentScreenElectronicPayment';
Registries.Component.add(PaymentScreenElectronicPayment);
@@ -6,21 +6,18 @@ odoo.define('point_of_sale.PaymentScreenStatus', function(require) {
class PaymentScreenStatus extends PosComponent {
get changeText() {
return this.env.pos.format_currency(this.currentOrder.get_change());
return this.env.pos.format_currency(this.props.order.get_change());
}
get totalDueText() {
return this.env.pos.format_currency(
this.currentOrder.get_total_with_tax() + this.currentOrder.get_rounding_applied()
this.props.order.get_total_with_tax() + this.props.order.get_rounding_applied()
);
}
get remainingText() {
return this.env.pos.format_currency(
this.currentOrder.get_due() > 0 ? this.currentOrder.get_due() : 0
this.props.order.get_due() > 0 ? this.props.order.get_due() : 0
);
}
get currentOrder() {
return this.env.pos.get_order();
}
}
PaymentScreenStatus.template = 'PaymentScreenStatus';
@@ -11,10 +11,7 @@ odoo.define('point_of_sale.ActionpadWidget', function(require) {
*/
class ActionpadWidget extends PosComponent {
get isLongName() {
return this.client && this.client.name.length > 10;
}
get client() {
return this.props.client;
return this.props.client && this.props.client.name.length > 10;
}
}
ActionpadWidget.template = 'ActionpadWidget';
@@ -11,14 +11,6 @@ odoo.define('point_of_sale.ProductInfoButton', function(require) {
super(...arguments);
useListener('click', this.onClick);
}
mounted() {
this.env.pos.get('orders').on('add remove change', () => this.render(), this);
this.env.pos.on('change:selectedOrder', () => this.render(), this);
}
willUnmount() {
this.env.pos.get('orders').off('add remove change', null, this);
this.env.pos.off('change:selectedOrder', null, this);
}
onClick() {
const orderline = this.env.pos.get_order().get_selected_orderline();
if (orderline) {
@@ -11,14 +11,6 @@ odoo.define('point_of_sale.SetFiscalPositionButton', function(require) {
super(...arguments);
useListener('click', this.onClick);
}
mounted() {
this.env.pos.get('orders').on('add remove change', () => this.render(), this);
this.env.pos.on('change:selectedOrder', () => this.render(), this);
}
willUnmount() {
this.env.pos.get('orders').off('add remove change', null, this);
this.env.pos.off('change:selectedOrder', null, this);
}
get currentOrder() {
return this.env.pos.get_order();
}
@@ -54,13 +46,12 @@ odoo.define('point_of_sale.SetFiscalPositionButton', function(require) {
}
);
if (confirmed) {
this.currentOrder.fiscal_position = selectedFiscalPosition;
this.currentOrder.set_fiscal_position(selectedFiscalPosition);
// IMPROVEMENT: The following is the old implementation and I believe
// there could be a better way of doing it.
for (let line of this.currentOrder.orderlines.models) {
for (let line of this.currentOrder.orderlines) {
line.set_quantity(line.quantity);
}
this.currentOrder.trigger('change');
}
}
}
@@ -11,14 +11,6 @@ odoo.define('point_of_sale.SetPricelistButton', function(require) {
super(...arguments);
useListener('click', this.onClick);
}
mounted() {
this.env.pos.get('orders').on('add remove change', () => this.render(), this);
this.env.pos.on('change:selectedOrder', () => this.render(), this);
}
willUnmount() {
this.env.pos.get('orders').off('add remove change', null, this);
this.env.pos.off('change:selectedOrder', null, this);
}
get currentOrder() {
return this.env.pos.get_order();
}
@@ -10,30 +10,11 @@ odoo.define('point_of_sale.NumpadWidget', function (require) {
* @prop {boolean} disableSign
* @event set-numpad-mode - triggered when mode button is clicked
* @event numpad-click-input - triggered when numpad button is clicked
*
* IMPROVEMENT: Whenever new-orderline-selected is triggered,
* numpad mode should be set to 'quantity'. Now that the mode state
* is lifted to the parent component, this improvement can be done in
* the parent component.
*/
class NumpadWidget extends PosComponent {
mounted() {
// IMPROVEMENT: This listener shouldn't be here because in core point_of_sale
// there is no way of changing the cashier. Only when pos_hr is installed
// that this listener makes sense.
this.env.pos.on('change:cashier', () => {
if (!this.hasPriceControlRights && this.props.activeMode === 'price') {
this.trigger('set-numpad-mode', { mode: 'quantity' });
}
});
}
willUnmount() {
this.env.pos.on('change:cashier', null, this);
}
get hasPriceControlRights() {
const cashier = this.env.pos.get('cashier') || this.env.pos.get_cashier();
return (
(!this.env.pos.config.restrict_price_control || cashier.role == 'manager') &&
this.env.pos.cashierHasPriceControlRights() &&
!this.props.disabledModes.includes('price')
);
}
@@ -3,8 +3,22 @@ odoo.define('point_of_sale.OrderSummary', function(require) {
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
const { float_is_zero } = require('web.utils');
class OrderSummary extends PosComponent {}
class OrderSummary extends PosComponent {
getTotal() {
return this.env.pos.format_currency(this.props.order.get_total_with_tax());
}
getTax() {
const total = this.props.order.get_total_with_tax();
const totalWithoutTax = this.props.order.get_total_without_tax();
const taxAmount = total - totalWithoutTax;
return {
hasTax: !float_is_zero(taxAmount, this.env.pos.currency.decimal_places),
displayAmount: this.env.pos.format_currency(taxAmount),
};
}
}
OrderSummary.template = 'OrderSummary';
Registries.Component.add(OrderSummary);
@@ -2,18 +2,16 @@ odoo.define('point_of_sale.OrderWidget', function(require) {
'use strict';
const { useListener } = require('web.custom_hooks');
const { onChangeOrder } = require('point_of_sale.custom_hooks');
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
const { onPatched, useRef, useState } = owl;
const { onPatched, useRef } = owl;
class OrderWidget extends PosComponent {
constructor() {
super(...arguments);
useListener('select-line', this._selectLine);
useListener('edit-pack-lot-lines', this._editPackLotLines);
onChangeOrder(this._onPrevOrder, this._onNewOrder);
this.scrollableRef = useRef('scrollable');
this.scrollToBottom = false;
onPatched(() => {
@@ -25,8 +23,6 @@ odoo.define('point_of_sale.OrderWidget', function(require) {
this.scrollToBottom = false;
}
});
this.state = useState({ total: 0, tax: 0 });
this._updateSummary();
}
get order() {
return this.env.pos.get_order();
@@ -66,42 +62,6 @@ odoo.define('point_of_sale.OrderWidget', function(require) {
}
this.order.select_orderline(event.detail.orderline);
}
_onNewOrder(order) {
if (order) {
order.orderlines.on(
'new-orderline-selected',
() => this.trigger('new-orderline-selected'),
this
);
order.orderlines.on('change', this._updateSummary, this);
order.orderlines.on(
'add remove',
() => {
this.scrollToBottom = true;
this._updateSummary();
},
this
);
order.on('change', this.render, this);
}
this._updateSummary();
this.trigger('new-orderline-selected');
}
_onPrevOrder(order) {
if (order) {
order.orderlines.off('new-orderline-selected', null, this);
order.orderlines.off('change', null, this);
order.orderlines.off('add remove', null, this);
order.off('change', null, this);
}
}
_updateSummary() {
const total = this.order ? this.order.get_total_with_tax() : 0;
const tax = this.order ? total - this.order.get_total_without_tax() : 0;
this.state.total = this.env.pos.format_currency(total);
this.state.tax = this.env.pos.format_currency(tax);
this.render();
}
}
OrderWidget.template = 'OrderWidget';
@@ -3,7 +3,6 @@ odoo.define('point_of_sale.ProductItem', function(require) {
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
const models = require('point_of_sale.models');
class ProductItem extends PosComponent {
/**
@@ -6,8 +6,8 @@ odoo.define('point_of_sale.ProductScreen', function(require) {
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');
const { isConnectionError, posbus } = require('point_of_sale.utils');
const { useBarcodeReader } = require('point_of_sale.custom_hooks');
const { isConnectionError } = require('point_of_sale.utils');
const { parse } = require('web.field_utils');
const { onMounted, useState } = owl;
@@ -16,7 +16,6 @@ odoo.define('point_of_sale.ProductScreen', function(require) {
constructor() {
super(...arguments);
useListener('update-selected-orderline', this._updateSelectedOrderline);
useListener('new-orderline-selected', this._newOrderlineSelected);
useListener('set-numpad-mode', this._setNumpadMode);
useListener('click-product', this._clickProduct);
useListener('click-customer', this._onClickCustomer);
@@ -28,8 +27,7 @@ odoo.define('point_of_sale.ProductScreen', function(require) {
client: this._barcodeClientAction,
discount: this._barcodeDiscountAction,
error: this._barcodeErrorAction,
})
onChangeOrder(null, (newOrder) => newOrder && this.render());
});
NumberBuffer.use({
nonKeyboardInputEvent: 'numpad-click-input',
triggerAtInput: 'update-selected-orderline',
@@ -40,16 +38,11 @@ odoo.define('point_of_sale.ProductScreen', function(require) {
// the callbacks in `onMounted` hook.
onMounted(() => NumberBuffer.reset());
this.state = useState({
numpadMode: 'quantity',
mobile_pane: this.props.mobile_pane || 'right',
});
}
mounted() {
posbus.trigger('start-cash-control');
this.env.pos.on('change:selectedClient', this.render, this);
}
willUnmount() {
this.env.pos.off('change:selectedClient', null, this);
this.env.posbus.trigger('start-cash-control');
}
/**
* To be overridden by modules that checks availability of
@@ -60,7 +53,7 @@ odoo.define('point_of_sale.ProductScreen', function(require) {
return true;
}
get client() {
return this.env.pos.get_client();
return this.currentOrder ? this.currentOrder.get_client() : null;
}
get currentOrder() {
return this.env.pos.get_order();
@@ -162,13 +155,14 @@ odoo.define('point_of_sale.ProductScreen', function(require) {
const { mode } = event.detail;
NumberBuffer.capture();
NumberBuffer.reset();
this.state.numpadMode = mode;
this.env.pos.numpadMode = mode;
}
async _updateSelectedOrderline(event) {
if(this.state.numpadMode === 'quantity' && this.env.pos.disallowLineQuantityChange()) {
if (this.env.pos.numpadMode === 'quantity' && this.env.pos.disallowLineQuantityChange()) {
let order = this.env.pos.get_order();
let selectedLine = order.get_selected_orderline();
let lastId = order.orderlines.last().cid;
let orderlines = order.orderlines;
let lastId = orderlines.length !== 0 && orderlines.at(orderlines.length - 1).cid;
let currentQuantity = this.env.pos.get_order().get_selected_orderline().get_quantity();
if(selectedLine.noDecrease) {
@@ -189,27 +183,24 @@ odoo.define('point_of_sale.ProductScreen', function(require) {
let { buffer } = event.detail;
let val = buffer === null ? 'remove' : buffer;
this._setValue(val);
if (val == 'remove') {
NumberBuffer.reset();
this.env.pos.numpadMode = 'quantity';
}
}
}
async _newOrderlineSelected() {
NumberBuffer.reset();
this.state.numpadMode = 'quantity';
}
_setValue(val) {
if (this.currentOrder.get_selected_orderline()) {
if (this.state.numpadMode === 'quantity') {
if (this.env.pos.numpadMode === 'quantity') {
const result = this.currentOrder.get_selected_orderline().set_quantity(val);
if (!result) NumberBuffer.reset();
} else if (this.state.numpadMode === 'discount') {
} else if (this.env.pos.numpadMode === 'discount') {
this.currentOrder.get_selected_orderline().set_discount(val);
} else if (this.state.numpadMode === 'price') {
} else if (this.env.pos.numpadMode === 'price') {
var selected_orderline = this.currentOrder.get_selected_orderline();
selected_orderline.price_manually_set = true;
selected_orderline.set_unit_price(val);
}
if (this.env.pos.config.iface_customer_facing_display) {
this.env.pos.send_current_order_to_customer_facing_display();
}
}
}
async _barcodeProductAction(code) {
@@ -21,15 +21,11 @@ odoo.define('point_of_sale.ProductsWidget', function(require) {
useListener('update-product-list', this._updateProductList);
this.state = useState({ searchWord: '' });
}
mounted() {
this.env.pos.on('change:selectedCategoryId', this.render, this);
}
willUnmount() {
this.env.pos.off('change:selectedCategoryId', null, this);
this.trigger('toggle-mobile-searchbar', false);
}
get selectedCategoryId() {
return this.env.pos.get('selectedCategoryId');
return this.env.pos.selectedCategoryId;
}
get searchWord() {
return this.state.searchWord.trim();
@@ -64,7 +60,7 @@ odoo.define('point_of_sale.ProductsWidget', function(require) {
return this.env.pos.db.get_category_childs_ids(0).length === 0;
}
_switchCategory(event) {
this.env.pos.set('selectedCategoryId', event.detail);
this.env.pos.setSelectedCategoryId(event.detail);
}
_updateSearch(event) {
this.state.searchWord = event.detail;
@@ -5,7 +5,6 @@ odoo.define('point_of_sale.ProductsWidgetControlPanel', function(require) {
const { ConnectionLostError, ConnectionAbortedError } = require('@web/core/network/rpc_service');
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
const { posbus } = require('point_of_sale.utils');
const { debounce, useRef } = owl;
@@ -16,10 +15,10 @@ odoo.define('point_of_sale.ProductsWidgetControlPanel', function(require) {
this.updateSearch = debounce(this.updateSearch, 100);
}
mounted() {
posbus.on('search-product-from-info-popup', this, this.searchProductFromInfo)
this.env.posbus.on('search-product-from-info-popup', this, this.searchProductFromInfo)
}
willUnmount() {
posbus.off('search-product-from-info-popup', this);
this.env.posbus.off('search-product-from-info-popup', this);
}
clearSearch() {
@@ -3,21 +3,20 @@ odoo.define('point_of_sale.ReceiptScreen', function (require) {
const { Printer } = require('point_of_sale.Printer');
const { is_email } = require('web.utils');
const { useErrorHandlers, onChangeOrder } = require('point_of_sale.custom_hooks');
const { useErrorHandlers } = require('point_of_sale.custom_hooks');
const Registries = require('point_of_sale.Registries');
const AbstractReceiptScreen = require('point_of_sale.AbstractReceiptScreen');
const { useContext, useRef } = owl;
const { useRef } = owl;
const ReceiptScreen = (AbstractReceiptScreen) => {
class ReceiptScreen extends AbstractReceiptScreen {
constructor() {
super(...arguments);
useErrorHandlers();
onChangeOrder(null, (newOrder) => newOrder && this.render());
this.orderReceipt = useRef('order-receipt');
const order = this.currentOrder;
const client = order.get_client();
this.orderUiState = useContext(order.uiState.ReceiptScreen);
this.orderUiState = order.uiState.ReceiptScreen;
this.orderUiState.inputEmail = this.orderUiState.inputEmail || (client && client.email) || '';
this.is_email = is_email;
}
@@ -101,7 +100,7 @@ odoo.define('point_of_sale.ReceiptScreen', function (require) {
}
_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;
return this.env.proxy.printer && this.env.pos.config.iface_print_skip_screen && invoiced_finalized;
}
async _sendReceiptToCustomer() {
const printer = new Printer(null, this.env.pos);
@@ -4,12 +4,7 @@ odoo.define('point_of_sale.WrappedProductNameLines', function(require) {
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
class WrappedProductNameLines extends PosComponent {
constructor() {
super(...arguments);
this.line = this.props.line;
}
}
class WrappedProductNameLines extends PosComponent {}
WrappedProductNameLines.template = 'WrappedProductNameLines';
Registries.Component.add(WrappedProductNameLines);
@@ -23,7 +23,7 @@ odoo.define('point_of_sale.ScaleScreen', function(require) {
}
willUnmount() {
// stop the scale reading
this.env.pos.proxy_queue.clear();
this.env.proxy_queue.clear();
}
back() {
this.props.resolve({ confirmed: false, payload: null });
@@ -44,13 +44,13 @@ odoo.define('point_of_sale.ScaleScreen', function(require) {
}
}
_readScale() {
this.env.pos.proxy_queue.schedule(this._setWeight.bind(this), {
this.env.proxy_queue.schedule(this._setWeight.bind(this), {
duration: 500,
repeat: true,
});
}
async _setWeight() {
const reading = await this.env.pos.proxy.scale_read();
const reading = await this.env.proxy.scale_read();
this.state.weight = reading.weight;
}
get _activePricelist() {
@@ -35,7 +35,7 @@ odoo.define('point_of_sale.InvoiceButton', function (require) {
kwargs: { load: false },
});
if (orderWithInvoice && orderWithInvoice.account_move) {
await this.env.pos.do_action('account.account_invoices', {
await this.env.legacyActionManager.do_action('account.account_invoices', {
additional_context: {
active_ids: [orderWithInvoice.account_move],
},
@@ -12,7 +12,7 @@ odoo.define('point_of_sale.OrderDetails', function (require) {
return this.props.order;
}
get orderlines() {
return this.order ? this.order.orderlines.models : [];
return this.order ? this.order.orderlines : [];
}
get total() {
return this.env.pos.format_currency(this.order ? this.order.get_total_with_tax() : 0);
@@ -13,7 +13,7 @@ odoo.define('point_of_sale.ReprintReceiptScreen', function (require) {
this.showScreen('TicketScreen', { reuseSavedUIState: true });
}
async printReceipt() {
if(this.env.pos.proxy.printer && this.env.pos.config.iface_print_skip_screen) {
if(this.env.proxy.printer && this.env.pos.config.iface_print_skip_screen) {
let result = await this._printReceipt();
if(result)
this.showScreen('TicketScreen', { reuseSavedUIState: true });
@@ -1,12 +1,11 @@
odoo.define('point_of_sale.TicketScreen', function (require) {
'use strict';
const models = require('point_of_sale.models');
const { Order } = require('point_of_sale.models');
const Registries = require('point_of_sale.Registries');
const IndependentToOrderScreen = require('point_of_sale.IndependentToOrderScreen');
const NumberBuffer = require('point_of_sale.NumberBuffer');
const { useListener, useAutofocus } = require('web.custom_hooks');
const { posbus } = require('point_of_sale.utils');
const { parse } = require('web.field_utils');
const { useState } = owl;
@@ -48,18 +47,14 @@ odoo.define('point_of_sale.TicketScreen', function (require) {
}
//#region LIFECYCLE METHODS
mounted() {
posbus.on('ticket-button-clicked', this, this.close);
this.env.pos.get('orders').on('add remove change', () => this.render(), this);
this.env.pos.on('change:selectedOrder', () => this.render(), this);
this.env.posbus.on('ticket-button-clicked', this, this.close);
setTimeout(() => {
// Show updated list of synced orders when going back to the screen.
this._onFilterSelected({ detail: { filter: this._state.ui.filter } });
});
}
willUnmount() {
posbus.off('ticket-button-clicked', this);
this.env.pos.get('orders').off('add remove change', null, this);
this.env.pos.off('change:selectedOrder', null, this);
this.env.posbus.off('ticket-button-clicked', this);
}
//#endregion
//#region EVENT HANDLERS
@@ -71,7 +66,6 @@ odoo.define('point_of_sale.TicketScreen', function (require) {
if (this._state.ui.filter == 'SYNCED') {
await this._fetchSyncedOrders();
}
this.render();
}
async _onSearch(event) {
Object.assign(this._state.ui.searchDetails, event.detail);
@@ -79,7 +73,6 @@ odoo.define('point_of_sale.TicketScreen', function (require) {
this._state.syncedOrders.currentPage = 1;
await this._fetchSyncedOrders();
}
this.render();
}
_onClickOrder({ detail: clickedOrder }) {
if (!clickedOrder || clickedOrder.locked) {
@@ -99,10 +92,10 @@ odoo.define('point_of_sale.TicketScreen', function (require) {
} else {
this._setOrder(clickedOrder);
}
this.render();
}
_onCreateNewOrder() {
this.env.pos.add_new_order();
this.showScreen('ProductScreen');
}
async _onDeleteOrder({ detail: order }) {
const screen = order.get_screen_data();
@@ -118,7 +111,6 @@ odoo.define('point_of_sale.TicketScreen', function (require) {
}
if (order && (await this._onBeforeDeleteOrder(order))) {
order.destroy({ reason: 'abandon' });
posbus.trigger('order-deleted');
}
}
async _onNextPage() {
@@ -126,29 +118,25 @@ odoo.define('point_of_sale.TicketScreen', function (require) {
this._state.syncedOrders.currentPage += 1;
await this._fetchSyncedOrders();
}
this.render();
}
async _onPrevPage() {
if (this._state.syncedOrders.currentPage > 1) {
this._state.syncedOrders.currentPage -= 1;
await this._fetchSyncedOrders();
}
this.render();
}
async _onInvoiceOrder({ detail: orderId }) {
this.env.pos._invalidateSyncedOrdersCache([orderId]);
await this._fetchSyncedOrders();
this.render();
}
_onClickOrderline({ detail: orderline }) {
const order = this.getSelectedSyncedOrder();
this._state.ui.selectedOrderlineIds[order.backendId] = orderline.id;
NumberBuffer.reset();
this.render();
}
_onClickRefundOrderUid({ detail: orderUid }) {
// Open the refund order.
const refundOrder = this.env.pos.get('orders').models.find((order) => order.uid == orderUid);
const refundOrder = this.env.pos.orders.find((order) => order.uid == orderUid);
if (refundOrder) {
this._setOrder(refundOrder);
}
@@ -159,7 +147,7 @@ odoo.define('point_of_sale.TicketScreen', function (require) {
if (!order) return NumberBuffer.reset();
const selectedOrderlineId = this.getSelectedOrderlineId();
const orderline = order.orderlines.models.find((line) => line.id == selectedOrderlineId);
const orderline = order.orderlines.find((line) => line.id == selectedOrderlineId);
if (!orderline) return NumberBuffer.reset();
const toRefundDetail = this._getToRefundDetail(orderline);
@@ -189,13 +177,12 @@ odoo.define('point_of_sale.TicketScreen', function (require) {
toRefundDetail.qty = quantity;
}
}
this.render();
}
async _onDoRefund() {
const order = this.getSelectedSyncedOrder();
if (!order) {
this._state.ui.highlightHeaderNote = !this._state.ui.highlightHeaderNote;
return this.render();
return;
}
if (this._doesOrderHaveSoleItem(order)) {
@@ -218,7 +205,7 @@ odoo.define('point_of_sale.TicketScreen', function (require) {
);
if (allToRefundDetails.length == 0) {
this._state.ui.highlightHeaderNote = !this._state.ui.highlightHeaderNote;
return this.render();
return;
}
// The order that will contain the refund orderlines.
@@ -227,7 +214,7 @@ odoo.define('point_of_sale.TicketScreen', function (require) {
const destinationOrder =
this.props.destinationOrder && customer === this.props.destinationOrder.get_client()
? this.props.destinationOrder
: this.env.pos.add_new_order({ silent: true });
: this.env.pos.add_new_order();
// Add orderline for each toRefundDetail to the destinationOrder.
for (const refundDetail of allToRefundDetails) {
@@ -387,7 +374,7 @@ odoo.define('point_of_sale.TicketScreen', function (require) {
}
_prepareAutoRefundOnOrder(order) {
const selectedOrderlineId = this.getSelectedOrderlineId();
const orderline = order.orderlines.models.find((line) => line.id == selectedOrderlineId);
const orderline = order.orderlines.find((line) => line.id == selectedOrderlineId);
if (!orderline) return;
const toRefundDetail = this._getToRefundDetail(orderline);
@@ -431,9 +418,7 @@ odoo.define('point_of_sale.TicketScreen', function (require) {
}
_setOrder(order) {
this.env.pos.set_order(order);
if (order === this.env.pos.get_order()) {
this.close();
}
this.close();
}
_getOrderList() {
return this.env.pos.get_order_list();
@@ -555,7 +540,7 @@ odoo.define('point_of_sale.TicketScreen', function (require) {
// Cache these fetched orders so that next time, no need to fetch
// them again, unless invalidated. See `_onInvoiceOrder`.
fetchedOrders.forEach((order) => {
this._state.syncedOrders.cache[order.id] = new models.Order({}, { pos: this.env.pos, json: order });
this._state.syncedOrders.cache[order.id] = Order.create({}, { pos: this.env.pos, json: order });
});
}
this._state.syncedOrders.totalCount = totalCount;
@@ -18,10 +18,10 @@ var BarcodeReader = core.Class.extend({
init: function (attributes) {
this.mutex = new Mutex();
this.pos = attributes.pos;
this.action_callbacks = {};
this.exclusive_callbacks = {};
this.proxy = attributes.proxy;
this.env = attributes.env;
this.remote_scanning = false;
this.remote_active = 0;
@@ -73,50 +73,9 @@ odoo.define('point_of_sale.custom_hooks', function (require) {
onPatched(autofocus);
}
/**
* Use this hook when you want to do something on previously selected and
* newly selected order when the order changes.
*
* Normally, a component is rendered then the current order is changed. When
* this happens, we want to rerender the component because the new information
* should be reflected in the screen. Additionally, we might want to remove listeners
* to the previous order and attach listeners to the new one. This hook is
* perfect for the described situation.
*
* Internally, this hook performs the following:
* 1. call newOrderCB on mounted
* 2. listen to order changes and perform the following sequence:
* - call prevOrderCB(prevOrder)
* - call newOrderCB(newOrder)
* 3. call prevOrderCB on willUnmount
*
* @param {Function} prevOrderCB apply this callback on the previous order
* @param {Function} newOrderCB apply this callback on the new order
*/
function onChangeOrder(prevOrderCB, newOrderCB) {
const current = Component.current;
prevOrderCB = prevOrderCB ? prevOrderCB.bind(current) : () => {};
newOrderCB = newOrderCB ? newOrderCB.bind(current) : () => {};
onMounted(() => {
current.env.pos.on(
'change:selectedOrder',
async (pos, newOrder) => {
await prevOrderCB(pos.previous('selectedOrder'));
await newOrderCB(newOrder);
},
current
);
newOrderCB(current.env.pos.get_order());
});
onWillUnmount(() => {
current.env.pos.off('change:selectedOrder', null, current);
prevOrderCB(current.env.pos.get_order());
});
}
function useBarcodeReader(callbackMap, exclusive = false) {
const current = Component.current;
const barcodeReader = current.env.pos.barcode_reader;
const barcodeReader = current.env.barcode_reader;
for (let [key, callback] of Object.entries(callbackMap)) {
callbackMap[key] = callback.bind(current);
}
@@ -144,5 +103,5 @@ odoo.define('point_of_sale.custom_hooks', function (require) {
});
}
return { useErrorHandlers, useAutoFocusToLast, onChangeOrder, useBarcodeReader };
return { useErrorHandlers, useAutoFocusToLast, useBarcodeReader };
});
+19 -20
View File
@@ -8,6 +8,18 @@ var utils = require('web.utils');
* - persistent : must stay between reloads ( orders )
*/
/**
* cache the data in memory to avoid roundtrips to the localstorage
*
* NOTE/TODO: Originally, this is a prop of PosDB. However, if we keep it that way,
* caching will result to infinite loop to calling the reactive callbacks.
* Another way to solve the infinite loop is to move the instance of PosDB to env.
* But I'm not sure if there is anything inside the object that needs to be observed,
* so I guess this strategy is good enough for the moment.
*/
const CACHE = {};
var PosDB = core.Class.extend({
name: 'openerp_pos_db', //the prefix of the localstorage data
limit: 100, // the maximum number of results returned by a search
@@ -20,9 +32,6 @@ var PosDB = core.Class.extend({
this.name = this.name + '_' + options.uuid;
}
//cache the data in memory to avoid roundtrips to the localstorage
this.cache = {};
this.product_by_id = {};
this.product_by_barcode = {};
this.product_by_category_id = {};
@@ -139,13 +148,13 @@ var PosDB = core.Class.extend({
},
/* loads a record store from the database. returns default if nothing is found */
load: function(store,deft){
if(this.cache[store] !== undefined){
return this.cache[store];
if(CACHE[store] !== undefined){
return CACHE[store];
}
var data = localStorage[this.name + '_' + store];
if(data !== undefined && data !== ""){
data = JSON.parse(data);
this.cache[store] = data;
CACHE[store] = data;
return data;
}else{
return deft;
@@ -154,7 +163,7 @@ var PosDB = core.Class.extend({
/* saves a record store to the database */
save: function(store,data){
localStorage[this.name + '_' + store] = JSON.stringify(data);
this.cache[store] = data;
CACHE[store] = data;
},
_product_search_string: function(product){
var str = product.display_name;
@@ -411,13 +420,10 @@ var PosDB = core.Class.extend({
* or one of its child categories.
*/
is_product_in_category: function(category_ids, product_id) {
if (!(category_ids instanceof Array)) {
category_ids = [category_ids];
}
var cat = this.get_product_by_id(product_id).pos_categ_id[0];
let cat = this.get_product_by_id(product_id).pos_categ_id[0];
while (cat) {
for (var i = 0; i < category_ids.length; i++) {
if (cat == category_ids[i]) { // The == is important, ids may be strings
for (let cat_id of category_ids) {
if (cat == cat_id) { // The == is important, ids may be strings
return true;
}
}
@@ -555,13 +561,6 @@ var PosDB = core.Class.extend({
});
this.save('unpaid_orders_to_remove', to_remove);
},
set_cashier: function(cashier) {
// Always update if the user is the same as before
this.save('cashier', cashier || null);
},
get_cashier: function() {
return this.load('cashier');
}
});
return PosDB;
@@ -100,13 +100,12 @@ var JobQueue = function(){
// methods are used both to signal an event, and to fetch information.
var ProxyDevice = core.Class.extend(mixins.PropertiesMixin,{
init: function(parent,options){
init: function(options){
mixins.PropertiesMixin.init.call(this);
var self = this;
this.setParent(parent);
options = options || {};
this.pos = parent;
this.env = options.env;
this.weighing = false;
this.debug_weight = 0;
@@ -144,6 +143,10 @@ var ProxyDevice = core.Class.extend(mixins.PropertiesMixin,{
window.hw_proxy = this;
},
set_pos: function(pos) {
this.setParent(pos);
this.pos = pos;
},
set_connection_status: function(status, drivers, msg=''){
var oldstatus = this.get('status');
var newstatus = {};
File diff suppressed because it is too large Load Diff
@@ -20,8 +20,8 @@ var core = require('web.core');
* use_payment_terminal.
*
* If necessary new fields can be loaded on any model:
*
* models.load_fields('pos.payment.method', ['new_field1', 'new_field2']);
* by overriding the loader_params of the models in the back end
* in the `pos.session` model
*/
var PaymentInterface = core.Class.extend({
init: function (pos, payment_method) {
@@ -0,0 +1,21 @@
/* @odoo-module alias=point_of_sale.env */
// This module is basically web.env but with added fields
// that are specific to point_of_sale and extensions.
import env from 'web.env';
import concurrency from 'web.concurrency';
import devices from 'point_of_sale.devices';
import BarcodeReader from 'point_of_sale.BarcodeReader';
// Create new env object base on web.env.
// Mutating this new object won't affect the original object.
let pos_env = Object.create(env);
pos_env.proxy_queue = new devices.JobQueue(); // used to prevent parallels communications to the proxy
pos_env.proxy = new devices.ProxyDevice({ env: pos_env }); // used to communicate to the hardware devices via a local proxy
pos_env.barcode_reader = new BarcodeReader({ env: pos_env, proxy: pos_env.proxy });
pos_env.posbus = new owl.core.EventBus();
pos_env.posMutex = new concurrency.Mutex();
export default pos_env;
@@ -0,0 +1,271 @@
/* @odoo-module */
/* global owl */
// Allows to get the target of a Reactive (used for making a new Reactive from the underlying object)
const TARGET = Symbol('Target');
// Escape hatch to prevent reactivity system to turn something into a reactive
const SKIP = Symbol("Skip");
// Special key to subscribe to, to be notified of key creation/deletion
const KEYCHANGES = Symbol('Key changes');
let silent = false;
export function inSilentContext(cb) {
silent = true;
cb();
silent = false;
}
/**
* Checks whether a given value can be made into a reactive object.
*
* @param value the value to check
* @returns whether the value can be made reactive
*/
function canBeMadeReactive(value) {
return (
typeof value === 'object' &&
value !== null &&
!(value instanceof Date) &&
!(value instanceof Promise) &&
!(value instanceof String) &&
!(value instanceof Number) &&
!(value instanceof Image) &&
!(value instanceof Set)
);
}
/**
* Mark an object or array so that it is ignored by the reactivity system
*
* @param value the value to mark
* @returns the object itself
*/
export function markRaw(value) {
value[SKIP] = true;
return value;
}
/**
* Given a reactive objet, return the raw (non reactive) underlying object
*
* @param value a reactive value
* @returns the underlying value
*/
export function toRaw(value) {
return value[TARGET];
}
const targetToKeysToCallbacks = new WeakMap();
/**
* Observes a given key on a target with an callback. The callback will be
* called when the given key changes on the target.
*
* @param target the target whose key should be observed
* @param key the key to observe (or Symbol(KEYCHANGES) for key creation
* or deletion)
* @param callback the function to call when the key changes
*/
function observeTargetKey(target, key, callback) {
if (!targetToKeysToCallbacks.get(target)) {
targetToKeysToCallbacks.set(target, new Map());
}
const keyToCallbacks = targetToKeysToCallbacks.get(target);
if (!keyToCallbacks.get(key)) {
keyToCallbacks.set(key, new Set());
}
keyToCallbacks.get(key).add(callback);
if (!callbacksToTargets.has(callback)) {
callbacksToTargets.set(callback, new Set());
}
callbacksToTargets.get(callback).add(target);
}
/**
* Notify Reactives that are observing a given target that a key has changed on
* the target.
*
* @param target target whose Reactives should be notified that the target was
* changed.
* @param key the key that changed (or Symbol `KEYCHANGES` if a key was created
* or deleted)
*/
function notifyReactives(target, key) {
if (silent) return;
const keyToCallbacks = targetToKeysToCallbacks.get(target);
if (!keyToCallbacks) {
return;
}
const callbacks = keyToCallbacks.get(key);
if (!callbacks) {
return;
}
// Loop on copy because clearReactivesForCallback will modify the set in place
for (const callback of [...callbacks]) {
clearReactivesForCallback(callback);
callback();
}
}
const callbacksToTargets = new WeakMap();
/**
* Clears all subscriptions of the Reactives associated with a given callback.
*
* @param callback the callback for which the reactives need to be cleared
*/
export function clearReactivesForCallback(callback) {
const targetsToClear = callbacksToTargets.get(callback);
if (!targetsToClear) {
return;
}
for (const target of targetsToClear) {
const observedKeys = targetToKeysToCallbacks.get(target);
if (!observedKeys) {
continue;
}
for (const callbacks of observedKeys.values()) {
callbacks.delete(callback);
}
}
targetsToClear.clear();
}
const reactiveCache = new WeakMap();
/**
* Creates a reactive proxy for an object. Reading data on the reactive object
* subscribes to changes to the data. Writing data on the object will cause the
* notify callback to be called if there are suscriptions to that data. Nested
* objects and arrays are automatically made reactive as well.
*
* Whenever you are notified of a change, all subscriptions are cleared, and if
* you would like to be notified of any further changes, you should go read
* the underlying data again. We assume that if you don't go read it again after
* being notified, it means that you are no longer interested in that data.
*
* Subscriptions:
* + Reading a property on an object will subscribe you to changes in the value
* of that property.
* + Accessing an object keys (eg with Object.keys or with `for..in`) will
* subscribe you to the creation/deletion of keys. Checking the presence of a
* key on the object with 'in' has the same effect.
* - getOwnPropertyDescriptor does not currently subscribe you to the property.
* This is a choice that was made because changing a key's value will trigger
* this trap and we do not want to subscribe by writes. This also means that
* Object.hasOwnProperty doesn't subscribe as it goes through this trap.
*
* @param target the object for which to create a reactive proxy
* @param callback the function to call when an observed property of the
* reactive has changed
* @returns a proxy that tracks changes to it
*/
export function reactive(target, callback) {
if (!canBeMadeReactive(target)) {
throw new Error(`Cannot make the given value reactive`);
}
if (SKIP in target) {
return target;
}
const originalTarget = target[TARGET];
if (originalTarget) {
return reactive(originalTarget, callback);
}
if (!reactiveCache.has(target)) {
reactiveCache.set(target, new Map());
}
const reactivesForTarget = reactiveCache.get(target);
if (!reactivesForTarget.has(callback)) {
const proxy = new Proxy(target, {
get(target, key, proxy) {
if (key === TARGET) {
return target;
}
observeTargetKey(target, key, callback);
const value = Reflect.get(target, key, proxy);
if (!canBeMadeReactive(value)) {
return value;
}
return reactive(value, callback);
},
set(target, key, value, proxy) {
const isNewKey = !Object.hasOwnProperty.call(target, key);
const originalValue = Reflect.get(target, key, proxy);
const ret = Reflect.set(target, key, value, proxy);
if (isNewKey) {
notifyReactives(target, KEYCHANGES);
}
// While Array length may trigger the set trap, it's not actually set by this
// method but is updated behind the scenes, and the trap is not called with the
// new value. We disable the "same-value-optimization" for it because of that.
if (originalValue !== value || (Array.isArray(target) && key === 'length')) {
notifyReactives(target, key);
}
return ret;
},
deleteProperty(target, key) {
const ret = Reflect.deleteProperty(target, key);
notifyReactives(target, KEYCHANGES);
notifyReactives(target, key);
return ret;
},
ownKeys(target) {
observeTargetKey(target, KEYCHANGES, callback);
return Reflect.ownKeys(target);
},
has(target, key) {
// TODOhis observes all key changes instead of only the presence of the argument key
observeTargetKey(target, KEYCHANGES, callback);
return Reflect.has(target, key);
},
});
reactivesForTarget.set(callback, proxy);
}
return reactivesForTarget.get(callback);
}
/**
* Creates a batched version of a callback so that all calls to it in the same
* microtick will only call the original callback once.
*
* @param callback the callback to batch
* @returns a batched version of the original callback
*/
export function batched(callback) {
let called = false;
return async () => {
// This await blocks all calls to the callback here, then releases them sequentially
// in the next microtick. This line decides the granularity of the batch.
await Promise.resolve();
if (!called) {
called = true;
callback();
// wait for all calls in this microtick to fall through before resetting "called"
// so that only the first call to the batched function calls the original callback
await Promise.resolve();
called = false;
}
};
}
export const batchedRenderFunctions = new WeakMap();
/**
* Creates a reactive object that will be observed by the current component.
* Reading data from the returned object (eg during rendering) will cause the
* component to subscribe to that data and be rerendered when it changes.
*
* @param state the state to observe
* @returns a reactive object that will cause the component to re-render on
* relevant changes
* @see reactive
*/
export function useState(state) {
const node = owl.Component.current;
if (!batchedRenderFunctions.has(node)) {
batchedRenderFunctions.set(
node,
batched(() => node.render())
);
}
const render = batchedRenderFunctions.get(node);
const reactiveState = reactive(state, render);
owl.hooks.onWillUnmount(() => clearReactivesForCallback(render));
return reactiveState;
}
+1 -1
View File
@@ -63,5 +63,5 @@ odoo.define('point_of_sale.utils', function (require) {
return errorToHandle || error;
}
return { getFileAsText, nextFrame, posbus: new EventBus(), identifyError, isConnectionError };
return { getFileAsText, nextFrame, identifyError, isConnectionError };
});
+14 -19
View File
@@ -16,20 +16,17 @@
<CashMoveButton t-if="showCashMoveButton() and env.isMobile and !state.mobileSearchBarIsShown" />
<TicketButton isTicketScreenShown="isTicketScreenShown" t-if="env.isMobile and !state.mobileSearchBarIsShown" />
<div class="search-bar-portal" />
<div class="status-buttons-portal" t-att-class="{ oe_hidden: state.mobileSearchBarIsShown }"/>
</div>
</div>
<t t-if="state.uiState === 'READY'">
<Portal target="'.pos .status-buttons-portal'">
<div class="status-buttons">
<div class="status-buttons" t-if="state.uiState === 'READY'" t-att-class="{ oe_hidden: state.mobileSearchBarIsShown }">
<CashierName />
<SaleDetailsButton t-if="env.pos.proxy.printer" />
<SaleDetailsButton t-if="env.proxy.printer" />
<ProxyStatus t-if="env.pos.config.use_proxy" />
<ClientScreenButton t-if="clientScreenButtonIsShown" />
<SyncNotification />
<HeaderButton />
</div>
</Portal>
</div>
</div>
<t t-if="state.uiState === 'READY'">
<div class="pos-content">
<div class="window">
<div class="subwindow">
@@ -43,22 +40,20 @@
</div>
</div>
</div>
<DebugWidget t-if="env.isDebug() and state.debugWidgetIsShown"
t-transition="fade" />
<DebugWidget t-if="env.isDebug() and state.debugWidgetIsShown" t-transition="fade" />
</div>
</t>
<div t-if="['LOADING', 'CLOSING'].includes(state.uiState)" class="loader" t-transition="swing">
<div class="loader-feedback">
<h1 class="message">
<t t-esc="loading.message" />
</h1>
<div class="progressbar">
<div class="progress" t-ref="progressbar"></div>
</div>
<div t-if="loading.skipButtonIsShown" class="button skip" t-on-click="trigger('loading-skip-callback')">
Skip
</div>
<!-- spinner from https://loading.io/css/ -->
<div class="lds-ellipsis"><div></div><div></div><div></div><div></div></div>
<t t-if="state.loadingSkipButtonIsShown">
<h1 class="message">Connecting to devices</h1>
<div class="button skip" t-on-click="trigger('loading-skip-callback')">
Skip
</div>
</t>
</div>
</div>
@@ -2,7 +2,7 @@
<templates id="template" xml:space="preserve">
<t t-name="ProxyStatus" owl="1">
<div class="oe_status js_proxy" t-on-click="onClick">
<div class="oe_status js_proxy" t-on-click="trigger('connect-to-proxy')">
<span t-if="state.msg and !env.isMobile" class="js_msg">
<t t-esc="state.msg" />
</span>
@@ -3,23 +3,23 @@
<t t-name="SyncNotification" owl="1">
<div class="oe_status" t-on-click="onClick">
<span t-if="state.msg" class="js_msg">
<t t-esc="state.msg" />
<span t-if="env.pos.synch.pending" class="js_msg">
<t t-esc="env.pos.synch.pending" />
<span> </span>
</span>
<div t-if="state.status === 'connected'" class="js_connected oe_icon oe_green">
<div t-if="env.pos.synch.status === 'connected'" class="js_connected oe_icon oe_green">
<i class="fa fa-fw fa-wifi" role="img" aria-label="Synchronisation Connected"
title="Synchronisation Connected"></i>
</div>
<div t-if="state.status === 'connecting'" class="js_connecting oe_icon">
<div t-if="env.pos.synch.status === 'connecting'" class="js_connecting oe_icon">
<i class="fa fa-fw fa-spin fa-circle-o-notch" role="img"
aria-label="Synchronisation Connecting" title="Synchronisation Connecting"></i>
</div>
<div t-if="state.status === 'disconnected'" class="js_disconnected oe_icon oe_red">
<div t-if="env.pos.synch.status === 'disconnected'" class="js_disconnected oe_icon oe_red">
<i class="fa fa-fw fa-wifi" role="img" aria-label="Synchronisation Disconnected"
title="Synchronisation Disconnected"></i>
</div>
<div t-if="state.status === 'error'" class="js_error oe_icon oe_red">
<div t-if="env.pos.synch.status === 'error'" class="js_error oe_icon oe_red">
<i class="fa fa-fw fa-warning" role="img" aria-label="Synchronisation Error"
title="Synchronisation Error"></i>
</div>
@@ -58,7 +58,7 @@
<div class="pos_orderlines_list">
<t t-if="order">
<div t-foreach="order.get_orderlines()" t-as="orderline" class="pos_orderlines_item">
<div><div t-attf-style="background-image:url(#{orderline.product.image_base64})"/></div>
<div><div t-attf-style="background-image:url(#{productImages[orderline.product.id]})"/></div>
<div t-esc="orderline.get_full_product_name()"/>
<div t-esc="orderline.get_quantity_str()"/>
<div t-esc="pos.format_currency(orderline.get_display_price())"/>
@@ -12,8 +12,8 @@
<span class="back_text">Back</span>
</div>
<div class="top-content-center"><h1>Payment</h1></div>
<div class="button next" t-att-class="{ highlight: currentOrder.is_paid() and _isValidEmptyOrder() }"
t-on-click="validateOrder(false)">
<div class="button next" t-att-class="{ highlight: currentOrder.is_paid() and currentOrder._isValidEmptyOrder() }"
t-on-click="trigger('validate-order')">
<span class="next_text">Validate</span>
<i class="fa fa-angle-double-right fa-fw"></i>
</div>
@@ -23,7 +23,7 @@
<div class="left-content">
<t t-if="env.isMobile">
<section class="paymentlines-container">
<PaymentScreenStatus paymentLines="paymentLines" />
<PaymentScreenStatus order="currentOrder" />
</section>
</t>
<div class="paymentmethods-container">
@@ -39,7 +39,7 @@
<div class="right-content">
<t t-if="!env.isMobile">
<section class="paymentlines-container">
<PaymentScreenStatus paymentLines="paymentLines" />
<PaymentScreenStatus order="currentOrder" />
</section>
</t>
@@ -50,14 +50,15 @@
<div class="payment-buttons">
<div class="customer-button">
<div class="button" t-att-class="{ highlight: env.pos.get_client() }" t-on-click="selectClient">
<div class="button" t-att-class="{ highlight: currentOrder.get_client() }" t-on-click="selectClient">
<i class="fa fa-user" role="img" aria-label="Customer"
title="Customer" />
<t t-set="_client" t-value="currentOrder.get_client()" />
<span class="js_customer_name">
<t t-if="env.pos.get_client()">
<t t-esc="env.pos.get_client().name" />
<t t-if="_client">
<t t-esc="_client.name" />
</t>
<t t-if="!env.pos.get_client()">
<t t-if="!_client">
Customer
</t>
</span>
@@ -90,7 +91,9 @@
</div>
<t t-if="env.isMobile">
<div class="switchpane">
<button class="btn-switchpane" t-att-class="{ secondary: !(currentOrder.is_paid() and _isValidEmptyOrder()) }" t-on-click="validateOrder(false)">
<button class="btn-switchpane"
t-att-class="{ secondary: !(currentOrder.is_paid() and currentOrder._isValidEmptyOrder()) }"
t-on-click="trigger('validate-order')">
<h1>Validate</h1>
</button>
<button class="btn-switchpane secondary" t-on-click="showScreen('ProductScreen', {mobile_pane: 'left'})">
@@ -2,7 +2,7 @@
<templates id="template" xml:space="preserve">
<t t-name="PaymentScreenStatus" owl="1">
<div t-if="props.paymentLines.length === 0" class="paymentlines-empty">
<div t-if="props.order.get_paymentlines().length === 0" class="paymentlines-empty">
<div class="total">
<t t-esc="totalDueText" />
</div>
@@ -17,7 +17,7 @@
<div class="payment-status-remaining">
<span class="label">Remaining</span>
<span class="amount"
t-att-class="{ highlight: currentOrder.get_due() > 0 }">
t-att-class="{ highlight: props.order.get_due() > 0 }">
<t t-esc="remainingText" />
</span>
</div>
@@ -32,7 +32,7 @@
<div class="payment-status-change">
<span class="label">Change</span>
<span class="amount"
t-att-class="{ highlight: currentOrder.get_change() > 0 }">
t-att-class="{ highlight: props.order.get_change() > 0 }">
<t t-esc="changeText" />
</span>
</div>
@@ -6,8 +6,8 @@
<button class="button set-customer" t-att-class="{'decentered': isLongName}"
t-on-click="trigger('click-customer')">
<t t-if="!env.isMobile"><i class="fa fa-user" role="img" aria-label="Customer" title="Customer" /></t>
<t t-if="client">
<t t-esc="client.name" />
<t t-if="props.client">
<t t-esc="props.client.name" />
</t>
<t t-else="">
Customer
@@ -3,16 +3,18 @@
<t t-name="OrderSummary" owl="1">
<div class="summary clearfix">
<t t-set="_total" t-value="getTotal()" />
<t t-set="_tax" t-value="getTax()" />
<div class="line">
<div class="entry total">
<span class="badge">Total: </span>
<span class="value">
<t t-esc="props.total" />
<t t-esc="_total" />
</span>
<div t-if="props.tax" class="subentry">
<div t-if="_tax.hasTax" class="subentry">
Taxes:
<span class="value">
<t t-esc="props.tax" />
<t t-esc="_tax.displayAmount" />
</span>
</div>
</div>
@@ -20,4 +22,4 @@
</div>
</t>
</templates>
</templates>
@@ -17,7 +17,7 @@
<Orderline line="orderline" />
</t>
</ul>
<OrderSummary total="state.total" tax="state.tax" />
<OrderSummary order="order" />
</t>
</div>
</div>
@@ -66,11 +66,11 @@
</li>
</t>
</ul>
<t t-if="props.line.get_lot_lines()">
<t t-if="props.line.pack_lot_lines">
<ul class="info-list">
<t t-foreach="props.line.get_lot_lines()" t-as="lot" t-key="lot.cid">
<li>
SN <t t-esc="lot.attributes['lot_name']"/>
SN <t t-esc="lot.lot_name"/>
</li>
</t>
</ul>
@@ -20,7 +20,7 @@
<div class="subpads">
<t t-set="_actionName">Payment</t>
<ActionpadWidget client="client" actionName="_actionName" t-on-switchpane="switchPane" />
<NumpadWidget activeMode="state.numpadMode" />
<NumpadWidget activeMode="env.pos.numpadMode" />
</div>
</div>
</div>
@@ -211,7 +211,7 @@
<ul>
<t t-foreach="line.pack_lot_lines" t-as="lot" t-key="lot.cid">
<li>
SN <t t-esc="lot.attributes['lot_name']"/>
SN <t t-esc="lot.lot_name"/>
</li>
</t>
</ul>
@@ -2,31 +2,28 @@ odoo.define('point_of_sale.test_env', async function (require) {
'use strict';
/**
* Many components in PoS are dependent on the PosModel instance (pos).
* Many components in PoS are dependent on the PosGlobalState instance (pos).
* Therefore, for unit tests that require pos in the Components' env, we
* prepared here a test env maker (makePosTestEnv) based on
* makeTestEnvironment of web.
*/
const makeTestEnvironment = require('web.test_env');
const env = require('web.env');
const models = require('point_of_sale.models');
const env = require('point_of_sale.env');
const { PosGlobalState } = require('point_of_sale.models');
const Registries = require('point_of_sale.Registries');
const cleanup = require("@web/../tests/helpers/cleanup");
// We override this method in the pos unit tests to prevent the unnecessary error in the web tests.
cleanup.registerCleanup = () => {}
const { Portal } = owl;
Registries.Component.add(Portal);
await env.session.is_bound;
const pos = new models.PosModel({
rpc: env.services.rpc,
session: env.session,
do_action: async () => {},
setLoadingMessage: () => {},
setLoadingProgress: () => {},
showLoadingSkip: () => {},
});
await pos.ready;
const pos = PosGlobalState.create({ env });
await pos.load_server_data();
/**
* @param {Object} env default env
@@ -35,13 +32,7 @@ odoo.define('point_of_sale.test_env', async function (require) {
*/
function makePosTestEnv(env = {}, providedRPC = null, providedDoAction = null) {
env = Object.assign(env, { pos });
let posEnv = makeTestEnvironment(env, providedRPC);
// Replace rpc in the PosModel instance after loading
// data from the server so that every succeeding rpc calls
// made by pos are mocked by the providedRPC.
pos.rpc = posEnv.rpc;
pos.do_action = providedDoAction;
return posEnv;
return makeTestEnvironment(env, providedRPC);
}
return makePosTestEnv;
@@ -1,69 +0,0 @@
odoo.define('point_of_sale.tests.ChromeWidgets', function (require) {
'use strict';
const PosComponent = require('point_of_sale.PosComponent');
const PopupControllerMixin = require('point_of_sale.PopupControllerMixin');
const testUtils = require('web.test_utils');
const makePosTestEnv = require('point_of_sale.test_env');
const { xml } = owl;
QUnit.module('unit tests for Chrome Widgets', {});
QUnit.test('CashierName', async function (assert) {
assert.expect(1);
class Parent extends PosComponent {}
Parent.env = makePosTestEnv();
Parent.template = xml/* html */ `
<div><CashierName></CashierName></div>
`;
Parent.env.pos.employee.name = 'Test Employee';
const parent = new Parent();
await parent.mount(testUtils.prepareTarget());
assert.strictEqual(parent.el.querySelector('span.username').innerText, 'Test Employee');
parent.unmount();
parent.destroy();
});
QUnit.test('SyncNotification', async function (assert) {
assert.expect(5);
class Parent extends PosComponent {}
Parent.env = makePosTestEnv();
Parent.template = xml/* html */ `
<div>
<SyncNotification></SyncNotification>
</div>
`;
const pos = Parent.env.pos;
pos.set('synch', { status: 'connected', pending: false });
const parent = new Parent();
await parent.mount(testUtils.prepareTarget());
assert.ok(parent.el.querySelector('i.fa').parentElement.classList.contains('js_connected'));
pos.set('synch', { status: 'connecting', pending: false });
await testUtils.nextTick();
assert.ok(parent.el.querySelector('i.fa').parentElement.classList.contains('js_connecting'));
pos.set('synch', { status: 'disconnected', pending: false });
await testUtils.nextTick();
assert.ok(parent.el.querySelector('i.fa').parentElement.classList.contains('js_disconnected'));
pos.set('synch', { status: 'error', pending: false });
await testUtils.nextTick();
assert.ok(parent.el.querySelector('i.fa').parentElement.classList.contains('js_error'));
pos.set('synch', { status: 'error', pending: 10 });
await testUtils.nextTick();
assert.ok(parent.el.querySelector('.js_msg').innerText.includes('10'));
parent.unmount();
parent.destroy();
});
});
@@ -1,310 +0,0 @@
/*global Backbone */
odoo.define('point_of_sale.tests.PaymentScreen', function (require) {
'use strict';
const PosComponent = require('point_of_sale.PosComponent');
const { useListener } = require('web.custom_hooks');
const testUtils = require('web.test_utils');
const makePosTestEnv = require('point_of_sale.test_env');
const { useState, xml } = owl;
QUnit.module('unit tests for PaymentScreen components', {});
QUnit.test('PaymentMethodButton', async function (assert) {
assert.expect(2);
class Parent extends PosComponent {
constructor() {
super(...arguments);
useListener('new-payment-line', this._newPaymentLine);
}
_newPaymentLine() {
assert.step('new-payment-line');
}
}
Parent.env = makePosTestEnv();
Parent.template = xml/* html */ `
<div>
<PaymentMethodButton paymentMethod="{ name: 'Cash', id: 1 }" />
</div>
`;
const parent = new Parent();
await parent.mount(testUtils.prepareTarget());
const button = parent.el.querySelector('.paymentmethod');
await testUtils.dom.click(button);
assert.verifySteps(['new-payment-line']);
parent.unmount();
parent.destroy();
});
QUnit.test('PSNumpadInputButton', async function (assert) {
assert.expect(15);
class Parent extends PosComponent {
constructor({ value, text, changeClassTo }) {
super();
this.state = useState({ value, text, changeClassTo });
useListener('input-from-numpad', this._inputFromNumpad);
}
_inputFromNumpad({ detail: { key } }) {
assert.step(`${key}-input`);
}
setState(obj) {
Object.assign(this.state, obj);
}
}
Parent.env = makePosTestEnv();
Parent.template = xml/* html */ `
<div>
<PSNumpadInputButton value="state.value" text="state.text" changeClassTo="state.changeClassTo" />
</div>
`;
let parent = new Parent({ value: '1' });
await parent.mount(testUtils.prepareTarget());
let button = parent.el.querySelector('button');
assert.ok(button.textContent.includes('1'));
assert.ok(button.classList.contains('number-char'));
await testUtils.dom.click(button);
await testUtils.nextTick();
assert.verifySteps(['1-input']);
parent.setState({ value: '2', text: 'Two' });
await testUtils.nextTick();
assert.ok(button.textContent.includes('Two'));
await testUtils.dom.click(button);
await testUtils.nextTick();
assert.verifySteps(['2-input']);
parent.setState({ value: '+12', text: null, changeClassTo: 'not-number-char' });
await testUtils.nextTick();
assert.ok(button.textContent.includes('+12'));
assert.ok(button.classList.contains('not-number-char'));
// class number-char should have been replaced
assert.notOk(button.classList.contains('number-char'));
await testUtils.dom.click(button);
await testUtils.nextTick();
assert.verifySteps(['+12-input']);
parent.unmount();
parent.destroy();
// using the slot should ignore value and text props of the component
Parent.template = xml/* html */ `
<div>
<PSNumpadInputButton value="state.value" text="state.text" changeClassTo="state.changeClassTo">
<span>UseSlot</span>
</PSNumpadInputButton>
</div>
`;
parent = new Parent({ value: 'slotted', text: 'Text' });
await parent.mount(testUtils.prepareTarget());
button = parent.el.querySelector('button');
assert.ok(button.textContent.includes('UseSlot'));
await testUtils.dom.click(button);
await testUtils.nextTick();
assert.verifySteps(['slotted-input']);
parent.unmount();
parent.destroy();
});
QUnit.test('PaymentScreenPaymentLines', async function (assert) {
assert.expect(12);
class Parent extends PosComponent {
constructor() {
super();
useListener('delete-payment-line', this._onDeletePaymentLine);
useListener('select-payment-line', this._onSelectPaymentLine);
}
get paymentLines() {
return this.order.get_paymentlines();
}
get order() {
return this.env.pos.get_order();
}
mounted() {
this.order.paymentlines.on('change', this.render, this);
}
willUnmount() {
this.order.paymentlines.off('change', null, this);
}
_onDeletePaymentLine() {
assert.step('delete-click');
}
_onSelectPaymentLine() {
assert.step('select-click');
}
}
Parent.env = makePosTestEnv();
Parent.template = xml/* html */ `
<div>
<PaymentScreenPaymentLines paymentLines="paymentLines" />
</div>
`;
let parent = new Parent();
await parent.mount(testUtils.prepareTarget());
const order = parent.env.pos.get_order();
const cashPM = { id: 0, name: 'Cash', is_cash_count: true, use_payment_terminal: false };
const bankPM = { id: 0, name: 'Bank', is_cash_count: false, use_payment_terminal: false };
let paymentline1 = order.add_paymentline(cashPM);
await testUtils.nextTick();
let statusContainer = parent.el.querySelector('.payment-status-container');
let linesEl = parent.el.querySelector('.paymentlines');
assert.ok(linesEl, 'payment lines are shown');
let newLine = linesEl.querySelector('.selected');
assert.ok(newLine, 'the new line is automatically selected');
let paymentline2 = order.add_paymentline(bankPM);
await testUtils.nextTick();
assert.notOk(
linesEl.querySelector('.selected') === newLine,
'the previously added paymentline should not be selected anymore'
);
assert.ok(
linesEl.querySelectorAll('.paymentline:not(.heading)').length === 2,
'there should be two paymentlines'
);
let paymentline3 = order.add_paymentline(cashPM);
await testUtils.nextTick();
assert.ok(
linesEl.querySelectorAll('.paymentline:not(.heading)').length === 3,
'there should be three paymentlines'
);
assert.ok(
linesEl.querySelectorAll('.paymentline.selected').length === 1,
'there should only be one selected paymentline'
);
await testUtils.dom.click(linesEl.querySelector('.paymentline.selected .delete-button'));
await testUtils.nextTick();
assert.verifySteps(['delete-click', 'select-click']);
// click the 2nd payment line
await testUtils.dom.click(linesEl.querySelectorAll('.paymentline:not(.heading)')[1]);
await testUtils.nextTick();
assert.verifySteps(['select-click']);
// remove paymentline3 (the selected)
order.remove_paymentline(paymentline3);
await testUtils.nextTick();
assert.notOk(
linesEl.querySelector('.paymentline.selected'),
'no more selected payment line'
);
order.remove_paymentline(paymentline1);
order.remove_paymentline(paymentline2);
parent.unmount();
parent.destroy();
});
QUnit.test('PaymentScreenElectronicPayment', async function (assert) {
assert.expect(17);
class SimulatedPaymentLine extends Backbone.Model {
constructor() {
super();
this.payment_status = 'pending';
this.can_be_reversed = false;
}
canBeAdjusted() {
return false;
}
setPaymentStatus(status) {
this.payment_status = status;
this.trigger('change');
}
toggleCanBeReversed() {
this.can_be_reversed = !this.can_be_reversed;
this.trigger('change');
}
}
class Parent extends PosComponent {
constructor() {
super();
this.line = new SimulatedPaymentLine();
useListener('send-payment-request', () => assert.step('send-payment-request'));
useListener('send-force-done', () => assert.step('send-force-done'));
useListener('send-payment-cancel', () => assert.step('send-payment-cancel'));
useListener('send-payment-reverse', () => assert.step('send-payment-reverse'));
}
}
Parent.env = makePosTestEnv();
Parent.template = xml/* html */ `
<div>
<PaymentScreenElectronicPayment line="line" />
</div>
`;
let parent = new Parent();
await parent.mount(testUtils.prepareTarget());
assert.ok(parent.el.querySelector('.paymentline .send_payment_request'));
await testUtils.dom.click(parent.el.querySelector('.paymentline .send_payment_request'));
await testUtils.nextTick();
assert.verifySteps(['send-payment-request']);
parent.line.setPaymentStatus('retry');
await testUtils.nextTick();
await testUtils.dom.click(parent.el.querySelector('.paymentline .send_payment_request'));
await testUtils.nextTick();
assert.verifySteps(['send-payment-request']);
parent.line.setPaymentStatus('force_done');
await testUtils.nextTick();
await testUtils.dom.click(parent.el.querySelector('.paymentline .send_force_done'));
await testUtils.nextTick();
assert.verifySteps(['send-force-done']);
parent.line.setPaymentStatus('waitingCard');
await testUtils.nextTick();
await testUtils.dom.click(parent.el.querySelector('.paymentline .send_payment_cancel'));
await testUtils.nextTick();
assert.verifySteps(['send-payment-cancel']);
parent.line.setPaymentStatus('waiting');
await testUtils.nextTick();
assert.ok(parent.el.querySelector('.paymentline i.fa-circle-o-notch'));
parent.line.setPaymentStatus('waitingCancel');
await testUtils.nextTick();
assert.ok(parent.el.querySelector('.paymentline i.fa-circle-o-notch'));
parent.line.setPaymentStatus('reversing');
await testUtils.nextTick();
assert.ok(parent.el.querySelector('.paymentline i.fa-circle-o-notch'));
parent.line.setPaymentStatus('done');
await testUtils.nextTick();
assert.notOk(parent.el.querySelector('.paymentline .send_payment_reversal'));
parent.line.toggleCanBeReversed();
await testUtils.nextTick();
assert.ok(parent.el.querySelector('.paymentline .send_payment_reversal'));
await testUtils.dom.click(parent.el.querySelector('.paymentline .send_payment_reversal'));
await testUtils.nextTick();
assert.verifySteps(['send-payment-reverse']);
parent.line.setPaymentStatus('reversed');
await testUtils.nextTick();
assert.ok(parent.el.querySelector('.paymentline'));
parent.unmount();
parent.destroy();
});
});
@@ -1,603 +0,0 @@
odoo.define('point_of_sale.tests.ProductScreen', function (require) {
'use strict';
const PosComponent = require('point_of_sale.PosComponent');
const Registries = require('point_of_sale.Registries');
const { useListener } = require('web.custom_hooks');
const testUtils = require('web.test_utils');
const makePosTestEnv = require('point_of_sale.test_env');
const { useState, xml } = owl;
QUnit.module('unit tests for ProductScreen components', {});
QUnit.test('ActionpadWidget', async function (assert) {
assert.expect(7);
class Parent extends PosComponent {
constructor() {
super();
useListener('click-customer', () => assert.step('click-customer'));
useListener('click-pay', () => assert.step('click-pay'));
this.state = useState({ client: null });
}
}
Parent.env = makePosTestEnv();
Parent.template = xml/* html */ `
<div>
<ActionpadWidget client="state.client" />
</div>
`;
const parent = new Parent();
await parent.mount(testUtils.prepareTarget());
const setCustomerButton = parent.el.querySelector('button.set-customer');
const payButton = parent.el.querySelector('button.pay');
await testUtils.nextTick();
assert.ok(setCustomerButton.innerText.includes('Customer'));
// change to customer with short name
parent.state.client = { name: 'Test' };
await testUtils.nextTick();
assert.ok(setCustomerButton.innerText.includes('Test'));
// change to customer with long name
parent.state.client = { name: 'Change Customer' };
await testUtils.nextTick();
assert.ok(setCustomerButton.classList.contains('decentered'));
parent.state.client = null;
// click set-customer button
await testUtils.dom.click(setCustomerButton);
await testUtils.nextTick();
assert.verifySteps(['click-customer']);
// click pay button
await testUtils.dom.click(payButton);
await testUtils.nextTick();
assert.verifySteps(['click-pay']);
parent.unmount();
parent.destroy();
});
QUnit.test('NumpadWidget', async function (assert) {
assert.expect(25);
class Parent extends PosComponent {
constructor() {
super(...arguments);
useListener('set-numpad-mode', this.setNumpadMode);
useListener('numpad-click-input', this.numpadClickInput);
this.state = useState({ mode: 'quantity' });
}
setNumpadMode({ detail: { mode } }) {
this.state.mode = mode;
assert.step(mode);
}
numpadClickInput({ detail: { key } }) {
assert.step(key);
}
}
Parent.env = makePosTestEnv();
Parent.template = xml/* html */ `
<div><NumpadWidget activeMode="state.mode"></NumpadWidget></div>
`;
const pos = Parent.env.pos;
// set this old values back after testing
const old_config = pos.config;
const old_cashier = pos.get('cashier');
// set dummy values in pos.config and pos.get('cashier')
pos.config = {
restrict_price_control: false,
manual_discount: true
};
pos.set('cashier', { role: 'manager' });
const parent = new Parent();
await parent.mount(testUtils.prepareTarget());
const modeButtons = parent.el.querySelectorAll('.mode-button');
let qtyButton, discButton, priceButton;
for (let button of modeButtons) {
if (button.textContent.includes('Qty')) {
qtyButton = button;
}
if (button.textContent.includes('Disc')) {
discButton = button;
}
if (button.textContent.includes('Price')) {
priceButton = button;
}
}
// initially, qty button is active
assert.ok(qtyButton.classList.contains('selected-mode'));
assert.ok(!discButton.classList.contains('selected-mode'));
assert.ok(!priceButton.classList.contains('selected-mode'));
await testUtils.dom.click(discButton);
await testUtils.nextTick();
assert.ok(!qtyButton.classList.contains('selected-mode'));
assert.ok(discButton.classList.contains('selected-mode'));
assert.ok(!priceButton.classList.contains('selected-mode'));
assert.verifySteps(['discount']);
await testUtils.dom.click(priceButton);
await testUtils.nextTick();
assert.ok(!qtyButton.classList.contains('selected-mode'));
assert.ok(!discButton.classList.contains('selected-mode'));
assert.ok(priceButton.classList.contains('selected-mode'));
assert.verifySteps(['price']);
const numpadOne = [...parent.el.querySelectorAll('.number-char').values()].find((el) =>
el.textContent.includes('1')
);
const numpadMinus = parent.el.querySelector('.numpad-minus');
const numpadBackspace = parent.el.querySelector('.numpad-backspace');
await testUtils.dom.click(numpadOne);
await testUtils.nextTick();
assert.verifySteps(['1']);
await testUtils.dom.click(numpadMinus);
await testUtils.nextTick();
assert.verifySteps(['-']);
await testUtils.dom.click(numpadBackspace);
await testUtils.nextTick();
assert.verifySteps(['Backspace']);
await testUtils.dom.click(priceButton);
await testUtils.nextTick();
assert.verifySteps(['price']);
// change to price control restriction and the cashier is not manager
pos.config.restrict_price_control = true;
pos.set('cashier', { role: 'not manager' });
await testUtils.nextTick();
assert.ok(priceButton.classList.contains('disabled-mode'));
assert.ok(qtyButton.classList.contains('selected-mode'));
// after the cashier is changed, since it is not a manager,
// the 'set-numpad-mode' is triggered, setting the mode to
// 'quantity'.
assert.verifySteps(['quantity']);
// reset old config and cashier values to pos
pos.config = old_config;
pos.set('cashier', old_cashier);
parent.unmount();
parent.destroy();
});
QUnit.test('ProductsWidgetControlPanel', async function (assert) {
assert.expect(32);
// This test incorporates the following components:
// CategoryBreadcrumb
// CategoryButton
// CategorySimpleButton
// HomeCategoryBreadcrumb
// Create dummy category data
//
// Root
// | Test1
// | | Test2
// | ` Test3
// | | Test5
// | ` Test6
// ` Test4
const rootCategory = { id: 0, name: 'Root', parent: null };
const testCategory1 = { id: 1, name: 'Test1', parent: 0 };
const testCategory2 = { id: 2, name: 'Test2', parent: 1 };
const testCategory3 = { id: 3, name: 'Test3', parent: 1 };
const testCategory4 = { id: 4, name: 'Test4', parent: 0 };
const testCategory5 = { id: 5, name: 'Test5', parent: 3 };
const testCategory6 = { id: 6, name: 'Test6', parent: 3 };
const categories = {
0: rootCategory,
1: testCategory1,
2: testCategory2,
3: testCategory3,
4: testCategory4,
5: testCategory5,
6: testCategory6,
};
class Parent extends PosComponent {
constructor() {
super(...arguments);
this.state = useState({ selectedCategoryId: 0 });
useListener('switch-category', this.switchCategory);
useListener('update-search', this.updateSearch);
useListener('clear-search', this.clearSearch);
}
get breadcrumbs() {
if (this.state.selectedCategoryId === 0) return [];
let current = categories[this.state.selectedCategoryId];
const res = [current];
while (current.parent != 0) {
const toAdd = categories[current.parent];
res.push(toAdd);
current = toAdd;
}
return res.reverse();
}
get subcategories() {
return Object.values(categories).filter(
({ parent }) => parent == this.state.selectedCategoryId
);
}
switchCategory({ detail: id }) {
this.state.selectedCategoryId = id;
assert.step(`${id}`);
}
updateSearch(event) {
assert.step(event.detail);
}
clearSearch() {
assert.step('cleared');
}
}
Parent.env = makePosTestEnv();
Parent.template = xml/* html */ `
<div class="pos">
<div class="search-bar-portal">
<ProductsWidgetControlPanel breadcrumbs="breadcrumbs" subcategories="subcategories" />
</div>
</div>
`;
const pos = Parent.env.pos;
const old_config = pos.config;
// set dummy config
pos.config = { iface_display_categ_images: false };
const parent = new Parent();
await parent.mount(testUtils.prepareTarget());
// The following tests the breadcrumbs and subcategory buttons
// check if HomeCategoryBreadcrumb is rendered
assert.ok(
parent.el.querySelector('.breadcrumb-home'),
'Home category should always be there'
);
let subcategorySpans = [...parent.el.querySelectorAll('.category-simple-button')];
assert.ok(subcategorySpans.length === 2, 'There should be 2 subcategories for Root.');
assert.ok(subcategorySpans.find((span) => span.textContent.includes('Test1')));
assert.ok(subcategorySpans.find((span) => span.textContent.includes('Test4')));
// click Test1
let test1Span = subcategorySpans.find((span) => span.textContent.includes('Test1'));
await testUtils.dom.click(test1Span);
await testUtils.nextTick();
assert.verifySteps(['1']);
assert.ok(
[...parent.el.querySelectorAll('.breadcrumb-button')][1].textContent.includes('Test1')
);
subcategorySpans = [...parent.el.querySelectorAll('.category-simple-button')];
assert.ok(subcategorySpans.length === 2, 'There should be 2 subcategories for Root.');
assert.ok(subcategorySpans.find((span) => span.textContent.includes('Test2')));
assert.ok(subcategorySpans.find((span) => span.textContent.includes('Test3')));
// click Test2
let test2Span = subcategorySpans.find((span) => span.textContent.includes('Test2'));
await testUtils.dom.click(test2Span);
await testUtils.nextTick();
assert.verifySteps(['2']);
subcategorySpans = [...parent.el.querySelectorAll('.category-simple-button')];
assert.ok(subcategorySpans.length === 0, 'Test2 should not have subcategories');
// go back to Test1
let breadcrumb1 = [...parent.el.querySelectorAll('.breadcrumb-button')].find((el) =>
el.textContent.includes('Test1')
);
await testUtils.dom.click(breadcrumb1);
await testUtils.nextTick();
assert.verifySteps(['1']);
// click Test3
subcategorySpans = [...parent.el.querySelectorAll('.category-simple-button')];
let test3Span = subcategorySpans.find((span) => span.textContent.includes('Test3'));
await testUtils.dom.click(test3Span);
await testUtils.nextTick();
assert.verifySteps(['3']);
subcategorySpans = [...parent.el.querySelectorAll('.category-simple-button')];
assert.ok(subcategorySpans.length === 2);
// click Test6
let test6Span = subcategorySpans.find((span) => span.textContent.includes('Test6'));
await testUtils.dom.click(test6Span);
await testUtils.nextTick();
assert.verifySteps(['6']);
let breadcrumbButtons = [...parent.el.querySelectorAll('.breadcrumb-button')];
assert.ok(breadcrumbButtons.length === 4);
// Now check subcategory buttons with images
pos.config.iface_display_categ_images = true;
let breadcrumbHome = parent.el.querySelector('.breadcrumb-home');
await testUtils.dom.click(breadcrumbHome);
await testUtils.nextTick();
assert.verifySteps(['0']);
assert.ok(
!parent.el.querySelector('.category-list').classList.contains('simple'),
'Category list should not have simple class'
);
let categoryButtons = [...parent.el.querySelectorAll('.category-button')];
assert.ok(categoryButtons.length === 2, 'There should be 2 subcategories for Root');
// The following tests the search bar
const wait = (ms) => {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
};
const inputEl = parent.el.querySelector('.search-box input');
await testUtils.dom.triggerEvent(inputEl, 'keyup', { key: 'A' });
// Triggering keyup event doesn't type the key to the input
// so we manually assign the value of the input.
inputEl.value = 'A';
await wait(30);
await testUtils.dom.triggerEvent(inputEl, 'keyup', { key: 'B' });
inputEl.value = 'AB';
await wait(30);
await testUtils.dom.triggerEvent(inputEl, 'keyup', { key: 'C' });
inputEl.value = 'ABC';
await wait(110);
// Only after waiting for more than 100ms that update-search is triggered
// because the method is debounced.
assert.verifySteps(['ABC']);
await testUtils.dom.triggerEvent(inputEl, 'keyup', { key: 'D' });
inputEl.value = 'ABCD';
await wait(110);
assert.verifySteps(['ABCD']);
// clear the search bar
await testUtils.dom.click(parent.el.querySelector('.search-box .clear-icon'));
await testUtils.nextTick();
assert.verifySteps(['cleared']);
assert.ok(inputEl.value === '', 'value of the input element should be empty');
pos.config = old_config;
parent.unmount();
parent.destroy();
});
QUnit.test('ProductList, ProductItem', async function (assert) {
assert.expect(10);
// patch imageUrl and price of ProductItem component
const MockProductItemExt = (X) =>
class extends X {
get imageUrl() {
return 'data:,';
}
get price() {
return this.props.product.price;
}
};
const extension = Registries.Component.extend('ProductItem', MockProductItemExt);
extension.compile();
const dummyProducts = [
{ id: 0, display_name: 'Burger', price: '$10' },
{ id: 1, display_name: 'Water', price: '$2' },
{ id: 2, display_name: 'Chair', price: '$25' },
];
class Parent extends PosComponent {
constructor() {
super(...arguments);
this.state = useState({ searchWord: '', products: dummyProducts });
useListener('click-product', this._clickProduct);
}
_clickProduct({ detail: product }) {
assert.step(product.display_name);
}
}
Parent.env = makePosTestEnv();
Parent.template = xml/* html */ `
<div>
<ProductList products="state.products" searchWord="state.searchWord" />
</div>
`;
const parent = new Parent();
await parent.mount(testUtils.prepareTarget());
// Check if there are 3 products listed
assert.strictEqual(
parent.el.querySelectorAll('article.product').length,
3,
'There should be 3 products listed'
);
// Check contents of product item and click
const product1el = parent.el.querySelector(
'article.product[aria-labelledby="article_product_1"]'
);
assert.ok(product1el.querySelector('.product-img img[alt="Water"]'));
assert.ok(product1el.querySelector('.product-img .price-tag').textContent.includes('$2'));
await testUtils.dom.click(product1el);
await testUtils.nextTick();
assert.verifySteps(['Water']);
// Remove one product, check if only two is listed
parent.state.products.splice(0, 1);
await testUtils.nextTick();
assert.strictEqual(
parent.el.querySelectorAll('article.product').length,
2,
'There should be 2 products listed after removing the first item'
);
// Remove all products, check if empty message is There are no products in this category
parent.state.products.splice(0, parent.state.products.length);
await testUtils.nextTick();
assert.strictEqual(
parent.el.querySelectorAll('article.product').length,
0,
'There should be 0 products listed after removing everything'
);
assert.ok(
parent.el
.querySelector('.product-list-empty p')
.textContent.includes('There are no products in this category.')
);
// change the searchWord to 'something', check if empty message is No results found
parent.state.searchWord = 'something';
await testUtils.nextTick();
assert.ok(
parent.el
.querySelector('.product-list-empty p')
.textContent.includes('No results found for')
);
assert.ok(
parent.el.querySelector('.product-list-empty p b').textContent.includes('something')
);
extension.remove();
parent.unmount();
parent.destroy();
});
QUnit.test('Orderline', async function (assert) {
assert.expect(10);
class Parent extends PosComponent {
constructor(product) {
super();
useListener('select-line', this._selectLine);
useListener('edit-pack-lot-lines', this._editPackLotLines);
this.order.add_product(product);
}
get order() {
return this.env.pos.get_order();
}
get line() {
return this.env.pos.get_order().get_orderlines()[0];
}
_selectLine() {
assert.step('select-line');
}
_editPackLotLines() {
assert.step('edit-pack-lot-lines');
}
willUnmount() {
this.order.remove_orderline(this.line);
}
}
Parent.env = makePosTestEnv();
Parent.template = xml/* html */ `
<div>
<Orderline line="line" />
</div>
`;
const [chair1, chair2] = Parent.env.pos.db.search_product_in_category(0, 'Office Chair');
// patch chair2 to have tracking
chair2.tracking = 'serial';
// 1. Test orderline without lot icon
let parent = new Parent(chair1);
await parent.mount(testUtils.prepareTarget());
let line = parent.el.querySelector('li.orderline');
assert.ok(line);
assert.notOk(line.querySelector('.line-lot-icon'), 'there should be no lot icon');
await testUtils.dom.click(line);
assert.verifySteps(['select-line']);
parent.unmount();
parent.destroy();
// 2. Test orderline with lot icon
parent = new Parent(chair2);
await parent.mount(testUtils.prepareTarget());
line = parent.el.querySelector('li.orderline');
const lotIcon = line.querySelector('.line-lot-icon');
assert.ok(line);
assert.ok(lotIcon, 'there should be lot icon');
await testUtils.dom.click(line);
assert.verifySteps(['select-line']);
await testUtils.dom.click(lotIcon);
assert.verifySteps(['edit-pack-lot-lines']);
parent.unmount();
parent.destroy();
});
QUnit.test('OrderWidget', async function (assert) {
assert.expect(8);
// OrderWidget is dependent on its parent's rerendering
class Parent extends PosComponent {
mounted() {
this.env.pos.on('change:selectedOrder', this.render, this);
}
willUnmount() {
this.env.pos.off('change:selectedOrder', null, this);
}
}
Parent.env = makePosTestEnv();
Parent.template = xml/* html */ `
<div>
<OrderWidget />
</div>
`;
const [chair1, chair2] = Parent.env.pos.db.search_product_in_category(0, 'Office Chair');
let parent = new Parent();
await parent.mount(testUtils.prepareTarget());
// current order is empty
assert.notOk(parent.el.querySelector('.summary'));
assert.ok(parent.el.querySelector('.order-empty'));
// add line to the current order
const order1 = parent.env.pos.get_order();
order1.add_product(chair1);
await testUtils.nextTick();
assert.ok(parent.el.querySelector('.summary'));
assert.notOk(parent.el.querySelector('.order-empty'));
// selected new order, new order is empty
const order2 = parent.env.pos.add_new_order();
await testUtils.nextTick();
assert.notOk(parent.el.querySelector('.summary'));
assert.ok(parent.el.querySelector('.order-empty'));
// add line to the current order
order2.add_product(chair2);
await testUtils.nextTick();
assert.ok(parent.el.querySelector('.summary'));
assert.notOk(parent.el.querySelector('.order-empty'));
parent.env.pos.delete_current_order();
parent.env.pos.delete_current_order();
parent.unmount();
parent.destroy();
});
});
@@ -1,180 +0,0 @@
odoo.define('point_of_sale.test_popups', function(require) {
'use strict';
const Registries = require('point_of_sale.Registries');
const testUtils = require('web.test_utils');
const PosComponent = require('point_of_sale.PosComponent');
const PopupControllerMixin = require('point_of_sale.PopupControllerMixin');
const makePosTestEnv = require('point_of_sale.test_env');
const { xml } = owl;
QUnit.module('unit tests for Popups', {
before() {
class Root extends PopupControllerMixin(PosComponent) { }
Root.template = xml`
<div>
<t t-if="popup.isShown" t-component="popup.component" t-props="popupProps" t-key="popup.name" />
</div>
`;
Root.env = makePosTestEnv();
this.Root = Root;
Registries.Component.freeze();
},
});
QUnit.test('ConfirmPopup', async function(assert) {
assert.expect(6);
const root = new this.Root();
await root.mount(testUtils.prepareTarget());
let promResponse, userResponse;
// Step: show popup and confirm
promResponse = root.showPopup('ConfirmPopup', {});
await testUtils.nextTick();
testUtils.dom.click(root.el.querySelector('.confirm'));
await testUtils.nextTick();
userResponse = await promResponse;
assert.strictEqual(userResponse.confirmed, true);
// Step: show popup then cancel
promResponse = root.showPopup('ConfirmPopup', {});
await testUtils.nextTick();
testUtils.dom.click(root.el.querySelector('.cancel'));
await testUtils.nextTick();
userResponse = await promResponse;
assert.strictEqual(userResponse.confirmed, false);
// Step: check texts
promResponse = root.showPopup('ConfirmPopup', {
title: 'Are you sure?',
body: 'Are you having fun?',
confirmText: 'Hell Yeah!',
cancelText: 'Are you kidding me?',
});
await testUtils.nextTick();
assert.strictEqual(root.el.querySelector('.title').innerText.trim(), 'Are you sure?');
assert.strictEqual(root.el.querySelector('.body').innerText.trim(), 'Are you having fun?');
assert.strictEqual(root.el.querySelector('.confirm').innerText.trim(), 'Hell Yeah!');
assert.strictEqual(
root.el.querySelector('.cancel').innerText.trim(),
'Are you kidding me?'
);
root.unmount();
root.destroy();
});
QUnit.test('NumberPopup', async function(assert) {
assert.expect(8);
const root = new this.Root();
await root.mount(testUtils.prepareTarget());
let promResponse, userResponse;
// Step: show NumberPopup and confirm with empty buffer
promResponse = root.showPopup('NumberPopup', { startingValue: 1 });
await testUtils.nextTick();
testUtils.dom.triggerEvent(root.el.querySelector('.confirm'), 'mousedown');
await testUtils.nextTick();
userResponse = await promResponse;
assert.strictEqual(userResponse.confirmed, true);
assert.strictEqual(userResponse.payload, "1");
// Step: show NumberPopup and cancel
promResponse = root.showPopup('NumberPopup', {});
await testUtils.nextTick();
testUtils.dom.triggerEvent(root.el.querySelector('.cancel'), 'mousedown');
await testUtils.nextTick();
userResponse = await promResponse;
assert.strictEqual(userResponse.confirmed, false);
// Step: show NumberPopup and confirm with filled buffer, new title, new text
promResponse = root.showPopup('NumberPopup', {
title: 'Are you sure?',
confirmText: 'Hell Yeah!',
cancelText: 'Are you kidding me?',
});
await testUtils.nextTick();
let nodes = Array.from(root.el.querySelectorAll('button'));
testUtils.dom.triggerEvent(nodes.find(elem => elem.innerHTML === "7"), 'mousedown');
await testUtils.nextTick();
testUtils.dom.triggerEvent(nodes.find(elem => elem.innerHTML === "+10"), 'mousedown');
await testUtils.nextTick();
assert.strictEqual(root.el.querySelector('.title').innerText.trim(), 'Are you sure?');
assert.strictEqual(root.el.querySelector('.confirm').innerText.trim(), 'Hell Yeah!');
assert.strictEqual(root.el.querySelector('.cancel').innerText.trim(), 'Are you kidding me?');
testUtils.dom.triggerEvent(root.el.querySelector('.confirm'), 'mousedown');
await testUtils.nextTick();
userResponse = await promResponse;
assert.strictEqual(userResponse.confirmed, true);
assert.strictEqual(userResponse.payload, "17");
root.unmount();
root.destroy();
});
QUnit.test('EditListPopup', async function(assert) {
assert.expect(7);
const root = new this.Root();
await root.mount(testUtils.prepareTarget());
let promResponse, userResponse;
// Step: show popup and confirm
promResponse = root.showPopup('EditListPopup', {});
await testUtils.nextTick();
testUtils.dom.click(root.el.querySelector('.confirm'));
await testUtils.nextTick();
userResponse = await promResponse;
assert.strictEqual(userResponse.confirmed, true);
assert.strictEqual(JSON.stringify(userResponse.payload.newArray), JSON.stringify([]));
// Step: show popup and cancel
promResponse = root.showPopup('EditListPopup', {});
await testUtils.nextTick();
testUtils.dom.click(root.el.querySelector('.cancel'));
await testUtils.nextTick();
userResponse = await promResponse;
assert.strictEqual(userResponse.confirmed, false);
// Step: show popup and confirm with a default array
let defaultArray = ["Banana", "Cherry"];
promResponse = root.showPopup('EditListPopup', {
title: "Fruits",
isSingleItem: false,
array: defaultArray,
});
await testUtils.nextTick();
testUtils.dom.click(root.el.querySelector('.confirm'));
await testUtils.nextTick();
userResponse = await promResponse;
assert.strictEqual(userResponse.confirmed, true);
let i = 0;
defaultArray = defaultArray.map((item) => Object.assign({}, { _id: i++ }, { 'text': item}));
assert.strictEqual(JSON.stringify(userResponse.payload.newArray), JSON.stringify(defaultArray));
// Step: show popup and confirm with a new array
promResponse = root.showPopup('EditListPopup', {
title: "Fruits",
isSingleItem: false,
array: ["Banana", "Cherry"],
});
await testUtils.nextTick();
testUtils.dom.click(root.el.querySelector('.fa-trash-o'));
await testUtils.nextTick();
testUtils.dom.click(root.el.querySelector('.confirm'));
await testUtils.nextTick();
userResponse = await promResponse;
assert.strictEqual(userResponse.confirmed, true);
assert.strictEqual(JSON.stringify(userResponse.payload.newArray), JSON.stringify([{ _id: 1, text: "Cherry"}]));
root.unmount();
root.destroy();
});
});
+1
View File
@@ -14,6 +14,7 @@ class WebSuite(HttpCase):
def test_pos_js(self):
# open a session, the /pos/ui controller will redirect to it
self.main_pos_config.open_session_cb()
self.main_pos_config.current_session_id.set_cashbox_pos(0, None)
# point_of_sale desktop test suite
self.browser_js("/pos/ui/tests?mod=web", "", "", login="admin", timeout=1800)
@@ -4,7 +4,6 @@
<template id="point_of_sale.assets_common" name="POS Assets Common">
<t t-call-assets="point_of_sale.pos_assets_backend_style" t-js="false"/>
<t t-call-assets="point_of_sale.assets" t-js="false" />
<t t-call-assets="web.assets_common" t-css="false" />
<t t-call-assets="point_of_sale.pos_assets_backend" t-css="false" />
<t t-call-assets="point_of_sale.assets" t-css="false" />
</template>
@@ -30,6 +30,7 @@
'csrf_token': request.csrf_token(None),
'__session_info__': session_info,
'login_number': login_number,
'pos_session_id': pos_session_id,
'debug': debug,
})"/>;
odoo.loadTemplatesPromise = fetch(`/web/webclient/qweb/${odoo.__session_info__.cache_hashes.qweb}?bundle=web.assets_qweb`).then(doc => doc.text());
@@ -2,7 +2,7 @@
<odoo>
<template id="point_of_sale.qunit_suite_assets">
<t t-call-assets="point_of_sale.pos_assets_backend" t-js="false"/>
<t t-call-assets="web.assets_backend" t-js="false" />
<t t-call-assets="point_of_sale.tests_assets" t-js="false"/>
<t t-call-assets="point_of_sale.tests_assets" t-css="false"/>
<style>
@@ -28,12 +28,13 @@
<t t-set="html_data" t-value="{'style': 'height: 100%;'}"/>
<t t-set="title">Point of Sale Tests</t>
<t t-set="head">
<!-- we need session_info in order to properly instantiate PosModel -->
<!-- we need session_info in order to properly instantiate PosGlobalState -->
<script type="text/javascript">
var odoo = <t t-out="json.dumps({
'csrf_token': request.csrf_token(None),
'__session_info__': session_info,
'debug': debug,
'pos_session_id': pos_session_id,
})"/>;
</script>
+1
View File
@@ -3,3 +3,4 @@
from . import pos_config
from . import pos_payment_method
from . import pos_session
+13
View File
@@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class PosSession(models.Model):
_inherit = 'pos.session'
def _loader_params_pos_payment_method(self):
result = super()._loader_params_pos_payment_method()
result['search_params']['fields'].append('adyen_terminal_identifier')
return result
-1
View File
@@ -3,5 +3,4 @@ var models = require('point_of_sale.models');
var PaymentAdyen = require('pos_adyen.payment');
models.register_payment_method('adyen', PaymentAdyen);
models.load_fields('pos.payment.method', 'adyen_terminal_identifier');
});
+1
View File
@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import pos_cache
from . import pos_session
+1 -23
View File
@@ -26,7 +26,7 @@ class pos_cache(models.Model):
def refresh_cache(self):
for cache in self:
Product = self.env['product.product'].with_user(cache.compute_user_id.id)
products = Product.search(cache.get_product_domain())
products = Product.search(cache.get_product_domain(), order='sequence,default_code,name')
prod_ctx = products.with_context(pricelist=cache.config_id.pricelist_id.id,
display_default_code=False, lang=cache.compute_user_id.lang)
res = prod_ctx.read(cache.get_product_fields())
@@ -64,28 +64,6 @@ class pos_config(models.Model):
limit = self.env['ir.config_parameter'].sudo().get_param('pos_cache.limit_products_per_request', 0)
self.update({'limit_products_per_request': int(limit)})
def get_products_from_cache(self, fields, domain):
fields_str = str(fields)
domain_str = str(domain)
pos_cache = self.env['pos.cache']
cache_for_user = pos_cache.search([
('id', 'in', self.cache_ids.ids),
('compute_user_id', '=', self.env.uid),
('product_domain', '=', domain_str),
('product_fields', '=', fields_str),
])
if not cache_for_user:
cache_for_user = pos_cache.create({
'config_id': self.id,
'product_domain': domain_str,
'product_fields': fields_str,
'compute_user_id': self.env.uid
})
cache_for_user.refresh_cache()
return cache_for_user.cache2json()
def delete_cache(self):
# throw away the old caches
self.cache_ids.unlink()
+53
View File
@@ -0,0 +1,53 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class PosSession(models.Model):
_inherit = 'pos.session'
def get_products_from_cache(self):
loading_info = self._loader_params_product_product()
fields_str = str(loading_info['search_params']['fields'])
domain_str = str([list(item) if isinstance(item, (list, tuple)) else item for item in loading_info['search_params']['domain']])
pos_cache = self.env['pos.cache']
cache_for_user = pos_cache.search([
('id', 'in', self.config_id.cache_ids.ids),
('compute_user_id', '=', self.env.uid),
('product_domain', '=', domain_str),
('product_fields', '=', fields_str),
])
if not cache_for_user:
cache_for_user = pos_cache.create({
'config_id': self.config_id.id,
'product_domain': domain_str,
'product_fields': fields_str,
'compute_user_id': self.env.uid
})
cache_for_user.refresh_cache()
return cache_for_user.cache2json()
def _get_pos_ui_product_product(self, params):
"""
If limited_products_loading is active, prefer the native way of loading products.
Otherwise, replace the way products are loaded.
First, we only load the first 100000 products.
Then, the UI will make further requests of the remaining products.
"""
if self.config_id.limited_products_loading:
return super()._get_pos_ui_product_product(params)
records = self.get_products_from_cache()
self._process_pos_ui_product_product(records)
return records[:100000]
def get_cached_products(self, start, end):
records = self.get_products_from_cache()
self._process_pos_ui_product_product(records)
return records[start:end]
def get_total_products_count(self):
records = self.get_products_from_cache()
return len(records)
+33
View File
@@ -0,0 +1,33 @@
odoo.define('pos_cache.chrome', function (require) {
'use strict';
const Chrome = require('point_of_sale.Chrome');
const Registries = require('point_of_sale.Registries');
function roundUpDiv(y, x) {
const remainder = y % x;
return (y - remainder) / x + (remainder > 0 ? 1 : 0);
}
const PosCacheChrome = (Chrome) => class PosCacheChrome extends Chrome {
_runBackgroundTasks() {
super._runBackgroundTasks();
if (!this.env.pos.config.limited_products_loading) {
this._loadRemainingProducts();
}
}
async _loadRemainingProducts() {
const nInitiallyLoaded = Object.keys(this.env.pos.db.product_by_id).length;
const totalProductsCount = await this.env.pos._getTotalProductsCount();
const nRemaining = totalProductsCount - nInitiallyLoaded;
if (!(nRemaining > 0)) return;
const multiple = 100000;
const nLoops = roundUpDiv(nRemaining, multiple);
for (let i = 0; i < nLoops; i++) {
await this.env.pos._loadCachedProducts(i * multiple + nInitiallyLoaded, (i + 1) * multiple + nInitiallyLoaded);
}
this.showNotification(this.env._t('All products are loaded.'), 5000);
}
};
Registries.Component.extend(Chrome, PosCacheChrome);
});
+20 -82
View File
@@ -1,91 +1,29 @@
odoo.define('pos_cache.pos_cache', function (require) {
"use strict";
var models = require('point_of_sale.models');
var core = require('web.core');
var rpc = require('web.rpc');
var _t = core._t;
var { PosGlobalState } = require('point_of_sale.models');
const Registries = require('point_of_sale.Registries');
var posmodel_super = models.PosModel.prototype;
models.PosModel = models.PosModel.extend({
load_server_data: function () {
var self = this;
var product_index = _.findIndex(this.models, function (model) {
return model.model === "product.product";
const PosCachePosGlobalState = (PosGlobalState) => class PosCachePosGlobalState extends PosGlobalState {
async _getTotalProductsCount() {
return this.env.services.rpc({
model: 'pos.session',
method: 'get_total_products_count',
args: [[odoo.pos_session_id]],
context: this.env.session.user_context,
});
var product_model = self.models[product_index];
// We don't want to load product.product the normal
// uncached way, so get rid of it.
if (product_index !== -1) {
this.models.splice(product_index, 1);
this.product_model = product_model;
}
return posmodel_super.load_server_data.apply(this, arguments).then(function () {
// Give both the fields and domain to pos_cache in the
// backend. This way we don't have to hardcode these
// values in the backend and they automatically stay in
// sync with whatever is defined (and maybe extended by
// other modules) in js.
var product_fields = typeof self.product_model.fields === 'function' ? self.product_model.fields(self) : self.product_model.fields;
var product_domain = typeof self.product_model.domain === 'function' ? self.product_model.domain(self) : self.product_model.domain;
var limit_products_per_request = self.config.limit_products_per_request;
var cur_request = 0;
function next(resolve, reject){
var domain = product_domain;
if (limit_products_per_request){
domain = domain.slice();
// implement offset-limit via id, because "pos.cache"
// doesn't have such fields and we can add them in master
// branch only
domain.unshift(['id', '>', cur_request * limit_products_per_request],
['id', '<=', (cur_request + 1) * limit_products_per_request]);
}
return rpc.query({
model: 'pos.config',
method: 'get_products_from_cache',
args: [self.pos_session.config_id[0], product_fields, domain],
}).then(function (products) {
self.db.add_products(_.map(products, function (product) {
product.categ = _.findWhere(self.product_categories, {'id': product.categ_id[0]});
product.pos = self;
return new models.Product({}, product);
}));
if (limit_products_per_request) {
cur_request++;
// check that we have more products
domain = product_domain.slice();
domain.unshift(['id', '>', cur_request * limit_products_per_request]);
rpc.query({
model: 'product.product',
method: 'search_read',
args: [domain, ['id']],
kwargs: {
limit: 1,
}
}).then(function (products) {
if (products.length){
next(resolve, reject);
} else {
resolve();
}
});
} else {
resolve();
}
});
}
self.setLoadingMessage(_t('Loading') + ' product.product', 1);
return new Promise((resolve, reject) => {
next(resolve, reject);
});
}).then(() => {
self.models.push(self.product_model);
}
async _loadCachedProducts(start, end) {
const products = await this.env.services.rpc({
model: 'pos.session',
method: 'get_cached_products',
args: [[odoo.pos_session_id], start, end],
context: this.env.session.user_context,
});
},
});
this._loadProductProduct(products);
}
}
Registries.Model.extend(PosGlobalState, PosCachePosGlobalState);
});
+1
View File
@@ -30,6 +30,7 @@
'pos_coupon/static/src/js/PaymentScreen.js',
'pos_coupon/static/src/js/ProductScreen.js',
'pos_coupon/static/src/js/ActivePrograms.js',
'pos_coupon/static/src/js/OrderWidget.js',
'pos_coupon/static/src/js/ControlButtons/PromoCodeButton.js',
'pos_coupon/static/src/js/ControlButtons/ResetProgramsButton.js',
],
+1
View File
@@ -6,3 +6,4 @@ from . import pos_order
from . import coupon
from . import coupon_program
from . import barcode_rule
from . import pos_session
+37
View File
@@ -0,0 +1,37 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
from odoo.osv.expression import OR
class PosSession(models.Model):
_inherit = 'pos.session'
def _pos_ui_models_to_load(self):
result = super()._pos_ui_models_to_load()
if self.config_id.use_coupon_programs and len(self.config_id.program_ids) > 0:
new_model = 'coupon.program'
if new_model not in result:
result.append(new_model)
return result
def _loader_params_coupon_program(self):
return {
'search_params': {
'domain': [('id', 'in', self.config_id.program_ids.ids), ('active', '=', True)],
'fields': []
},
}
def _get_pos_ui_coupon_program(self, params):
return self.env['coupon.program'].search_read(**params['search_params'])
def _loader_params_product_product(self):
result = super(PosSession, self)._loader_params_product_product()
if self.config_id.use_coupon_programs and len(self.config_id.program_ids) > 0:
discount_product_ids = self.config_id.program_ids.mapped(lambda program: program.discount_line_product_id.id)
reward_product_ids = self.config_id.program_ids.mapped(lambda program: program.reward_product_id.id)
product_ids = [id for id in [*discount_product_ids, *reward_product_ids] if id]
result['search_params']['domain'] = OR([result['search_params']['domain'], [('id', 'in', product_ids)]])
return result

Some files were not shown because too many files have changed in this diff Show More