diff --git a/addons/point_of_sale/models/pos_config.py b/addons/point_of_sale/models/pos_config.py
index 1c278245df7..73e828c317a 100644
--- a/addons/point_of_sale/models/pos_config.py
+++ b/addons/point_of_sale/models/pos_config.py
@@ -5,7 +5,7 @@ from datetime import datetime
from uuid import uuid4
import pytz
-from odoo import api, fields, models, tools, _
+from odoo import api, fields, models, _
from odoo.exceptions import ValidationError, UserError
@@ -175,6 +175,9 @@ class PosConfig(models.Model):
limited_partners_amount = fields.Integer(default=100)
partner_load_background = fields.Boolean(default=True)
auto_validate_terminal_payment = fields.Boolean(default=True, help="Automatically validates orders paid with a payment terminal.")
+ trusted_config_ids = fields.Many2many("pos.config", relation="pos_config_trust_relation", column1="is_trusting",
+ column2="is_trusted", string="Trusted Point of Sale Configurations",
+ domain="[('id', '!=', pos_config_id), ('module_pos_restaurant', '=', False)]")
@api.depends('payment_method_ids')
def _compute_cash_control(self):
@@ -364,6 +367,13 @@ class PosConfig(models.Model):
if len(cash_method.journal_id.pos_payment_method_ids) > 1:
raise ValidationError(_("You cannot use the same journal on multiples cash payment methods."))
+ @api.constrains('trusted_config_ids')
+ def _check_trusted_config_ids_currency(self):
+ for config in self:
+ for trusted_config in config.trusted_config_ids:
+ if trusted_config.currency_id != config.currency_id:
+ raise ValidationError(_("You cannot share open orders with configuration that does not use the same currency."))
+
def name_get(self):
result = []
for config in self:
@@ -411,6 +421,12 @@ class PosConfig(models.Model):
forbidden_fields = []
for key in self._get_forbidden_change_fields():
if key in vals.keys():
+ if key == 'use_pricelist' and vals[key]:
+ continue
+ if key == 'available_pricelist_ids':
+ removed_pricelist = set(self.available_pricelist_ids.ids) - set(vals[key][0][2])
+ if len(removed_pricelist) == 0:
+ continue
field_name = self._fields[key].get_description(self.env)["string"]
forbidden_fields.append(field_name)
if len(forbidden_fields) > 0:
@@ -703,3 +719,9 @@ class PosConfig(models.Model):
'res_id': self.id,
'context': {'pos_config_open_modal': True},
}
+
+ def _add_trusted_config_id(self, config_id):
+ self.trusted_config_ids += config_id
+
+ def _remove_trusted_config_id(self, config_id):
+ self.trusted_config_ids -= config_id
diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py
index bfc824d2600..7a662a7d722 100644
--- a/addons/point_of_sale/models/pos_order.py
+++ b/addons/point_of_sale/models/pos_order.py
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
-from datetime import timedelta
+from datetime import datetime
from markupsafe import Markup
from functools import partial
from itertools import groupby
@@ -1004,14 +1004,213 @@ class PosOrder(models.Model):
mail = self.env['mail.mail'].sudo().create(self._prepare_mail_values(name, client, ticket))
mail.send()
+ def _get_order_lines(self, orders):
+ """Add pos_order_lines to the orders.
+
+ The function doesn't return anything but adds the results directly to the orders.
+
+ :param orders: orders for which the order_lines are to be requested.
+ :type orders: pos.order.
+ """
+ order_lines = self.env['pos.order.line'].search_read(
+ domain=[('order_id', 'in', [to['id'] for to in orders])],
+ fields=self._get_fields_for_order_line())
+
+ if order_lines != []:
+ self._get_pack_lot_lines(order_lines)
+
+ extended_order_lines = []
+ for order_line in order_lines:
+ extended_order_lines.append([0, 0, self._prepare_order_line(order_line)])
+
+ for order_id, order_lines in groupby(extended_order_lines, key=lambda x: x[2]['order_id']):
+ next(order for order in orders if order['id'] == order_id[0])['lines'] = list(order_lines)
+
+ def _get_pack_lot_lines(self, order_lines):
+ """Add pack_lot_lines to the order_lines.
+
+ The function doesn't return anything but adds the results directly to the order_lines.
+
+ :param order_lines: order_lines for which the pack_lot_lines are to be requested.
+ :type order_lines: pos.order.line.
+ """
+ pack_lots = self.env['pos.pack.operation.lot'].search_read(
+ domain=[('pos_order_line_id', 'in', [order_line['id'] for order_line in order_lines])],
+ fields=[
+ 'id',
+ 'lot_name',
+ 'pos_order_line_id'
+ ])
+ for pack_lot in pack_lots:
+ pack_lot['order_line'] = pack_lot['pos_order_line_id'][0]
+ pack_lot['server_id'] = pack_lot['id']
+
+ del pack_lot['pos_order_line_id']
+ del pack_lot['id']
+
+ for order_line_id, pack_lot_ids in groupby(pack_lots, key=lambda x: x['order_line']):
+ next(order_line for order_line in order_lines if order_line['id'] == order_line_id)['pack_lot_ids'] = list(pack_lot_ids)
+
+ def _prepare_order_line(self, order_line):
+ """Method that will allow the cleaning of values to send the correct information.
+ :param order_line: order_line that will be cleaned.
+ :type order_line: pos.order.line.
+ :returns: dict -- dict representing the order line's values.
+ """
+ order_line["product_id"] = order_line["product_id"][0]
+ order_line["server_id"] = order_line["id"]
+
+ del order_line["id"]
+ if not "pack_lot_ids" in order_line:
+ order_line["pack_lot_ids"] = []
+ else:
+ order_line["pack_lot_ids"] = [[0, 0, lot] for lot in order_line["pack_lot_ids"]]
+ return order_line
+
+ def _get_fields_for_payment_lines(self):
+ return [
+ 'id',
+ 'amount',
+ 'pos_order_id',
+ 'payment_method_id',
+ 'card_type',
+ 'cardholder_name',
+ 'transaction_id',
+ 'payment_status'
+ ]
+
+ def _get_payments_lines_list(self, orders):
+ payment_lines = self.env['pos.payment'].search_read(
+ domain=[('pos_order_id', 'in', [po['id'] for po in orders])],
+ fields=self._get_fields_for_payment_lines())
+
+ extended_payment_lines = []
+ for payment_line in payment_lines:
+ payment_line['server_id'] = payment_line['id']
+ payment_line['payment_method_id'] = payment_line['payment_method_id'][0]
+
+ del payment_line['id']
+ extended_payment_lines.append([0, 0, payment_line])
+ return extended_payment_lines
+
+ def _get_payment_lines(self, orders):
+ """Add account_bank_statement_lines to the orders.
+
+ The function doesn't return anything but adds the results directly to the orders.
+
+ :param orders: orders for which the payment_lines are to be requested.
+ :type orders: pos.order.
+ """
+ extended_payment_lines = self._get_payments_lines_list(orders)
+ for order_id, payment_lines in groupby(extended_payment_lines, key=lambda x: x[2]['pos_order_id']):
+ next(order for order in orders if order['id'] == order_id[0])['statement_ids'] = list(payment_lines)
+
+ def _get_fields_for_draft_order(self):
+ return [
+ 'id',
+ 'pricelist_id',
+ 'partner_id',
+ 'sequence_number',
+ 'session_id',
+ 'pos_reference',
+ 'create_uid',
+ 'create_date',
+ 'fiscal_position_id',
+ 'to_invoice',
+ 'access_token',
+ 'ticket_code',
+ ]
+
+ @api.model
+ def get_draft_share_order_ids(self, config_id):
+ """Search for 'draft' orders that satisfy the given domain."""
+ config = self.env['pos.config'].browse(config_id)
+ default_domain = ['&', ('state', '=', 'draft'), '|', ('config_id', '=', config_id), ('config_id', 'in', config.trusted_config_ids.ids)]
+ orders = self.search_read(
+ domain=default_domain,
+ fields=self._get_fields_for_draft_order())
+
+ self._get_order_lines(orders)
+ self._get_payment_lines(orders)
+
+ self._prepare_order(orders)
+
+ return orders
+
+ def is_already_paid(self):
+ return self.state == "paid"
+
+ @api.model
+ def _prepare_order(self, orders):
+ timezone = pytz.timezone(self._context.get('tz') or self.env.user.tz or 'UTC')
+ for order in orders:
+ order['pos_session_id'] = order['session_id'][0]
+ order['uid'] = re.search(r"\d{5,}-\d{3,}-\d{4,}", order['pos_reference']).group(0)
+ order['name'] = order['pos_reference']
+ order['creation_date'] = order['create_date'].astimezone(timezone)
+ order['server_id'] = order['id']
+ if order['fiscal_position_id']:
+ order['fiscal_position_id'] = order['fiscal_position_id'][0]
+ if order['pricelist_id']:
+ order['pricelist_id'] = order['pricelist_id'][0]
+ if order['partner_id']:
+ order['partner_id'] = order['partner_id'][0]
+
+ if not 'lines' in order:
+ order['lines'] = []
+ if not 'statement_ids' in order:
+ order['statement_ids'] = []
+
+ del order['id']
+ del order['session_id']
+ del order['pos_reference']
+ del order['create_date']
+
+ @api.model
+ def remove_from_ui(self, server_ids):
+ """ Remove orders from the frontend PoS application
+
+ Remove orders from the server by id.
+ :param server_ids: list of the id's of orders to remove from the server.
+ :type server_ids: list.
+ :returns: list -- list of db-ids for the removed orders.
+ """
+ orders = self.search([('id', 'in', server_ids), ('state', '=', 'draft')])
+ orders.write({'state': 'cancel'})
+ # TODO Looks like delete cascade is a better solution.
+ orders.mapped('payment_ids').sudo().unlink()
+ orders.sudo().unlink()
+ return orders.ids
+
@api.model
def search_paid_order_ids(self, config_id, domain, limit, offset):
"""Search for 'paid' orders that satisfy the given domain, limit and offset."""
- default_domain = ['&', ('config_id', '=', config_id), '!', '|', ('state', '=', 'draft'), ('state', '=', 'cancelled')]
- real_domain = AND([domain, default_domain])
- ids = self.search(AND([domain, default_domain]), limit=limit, offset=offset).ids
+ default_domain = [('state', '!=', 'draft'), ('state', '!=', 'cancelled')]
+ if domain == []:
+ real_domain = AND([[['config_id', '=', config_id]], default_domain])
+ else:
+ real_domain = AND([domain, default_domain])
+ orders = self.search(real_domain, limit=limit, offset=offset)
+ # We clean here the orders that does not have the same currency.
+ # As we cannot use currency_id in the domain (because it is not a stored field),
+ # we must do it after the search.
+ pos_config = self.env['pos.config'].browse(config_id)
+ orders.filtered(lambda order: order.currency_id == pos_config.currency_id)
+ orderlines = self.env['pos.order.line'].search(['|', ('refunded_orderline_id.order_id', 'in', orders.ids), ('order_id', 'in', orders.ids)])
+
+ # We will return to the frontend the ids and the date of their last modification
+ # so that it can compare to the last time it fetched the orders and can ask to fetch
+ # orders that are not up-to-date.
+ # The date of their last modification is either the last time one of its orderline has changed,
+ # or the last time a refunded orderline related to it has changed.
+ orders_info = defaultdict(lambda: datetime.min)
+ for orderline in orderlines:
+ key_order = orderline.order_id.id if orderline.order_id in orders \
+ else orderline.refunded_orderline_id.order_id.id
+ if orders_info[key_order] < orderline.write_date:
+ orders_info[key_order] = orderline.write_date
totalCount = self.search_count(real_domain)
- return {'ids': ids, 'totalCount': totalCount}
+ return {'ordersInfo': list(orders_info.items()), 'totalCount': totalCount}
def _export_for_ui(self, order):
timezone = pytz.timezone(self._context.get('tz') or self.env.user.tz or 'UTC')
@@ -1044,10 +1243,18 @@ class PosOrder(models.Model):
def _get_fields_for_order_line(self):
"""This function is here to be overriden"""
- return []
-
- def _prepare_order_line(self, order_line):
- return order_line
+ fields = [
+ 'id',
+ 'discount',
+ 'product_id',
+ 'price_unit',
+ 'order_id',
+ 'qty',
+ 'full_product_name',
+ 'customer_note',
+ 'price_extra',
+ ]
+ return fields
def export_for_ui(self):
""" Returns a list of dict with each item having similar signature as the return of
diff --git a/addons/point_of_sale/models/pos_session.py b/addons/point_of_sale/models/pos_session.py
index 5690412b5f9..9cd526bb013 100644
--- a/addons/point_of_sale/models/pos_session.py
+++ b/addons/point_of_sale/models/pos_session.py
@@ -1862,12 +1862,7 @@ class PosSession(models.Model):
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, self._product_pricelist_item_fields()):
- pricelist_by_id[item['pricelist_id'][0]]['items'].append(item)
-
- return pricelists
+ return self._prepare_product_pricelists(pricelists)
def _loader_params_product_category(self):
return {'search_params': {'domain': [], 'fields': ['name', 'parent_id']}}
@@ -2082,6 +2077,43 @@ class PosSession(models.Model):
else:
return list(total_sold_per_user_per_category[0].items()), list(total_refund_per_user_per_category[0].items())
+ def get_pos_ui_product_pricelists_by_ids(self, pricelist_ids):
+ params = self._loader_params_product_pricelist()
+ params['search_params']['domain'] = [('id', 'in', pricelist_ids)]
+ pricelists = self.env['product.pricelist'].search_read(**params['search_params'])
+ for pricelist in pricelists:
+ if not self.config_id.use_pricelist:
+ self.config_id.use_pricelist = True
+ pricelist_id = self.env['product.pricelist'].browse(pricelist['id'])
+ self.config_id.available_pricelist_ids += pricelist_id
+ pricelist['items'] = []
+
+ return self._prepare_product_pricelists(pricelists)
+
+ def _prepare_product_pricelists(self, pricelists):
+ 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, self._product_pricelist_item_fields()):
+ pricelist_by_id[item['pricelist_id'][0]]['items'].append(item)
+
+ return pricelists
+
+ def get_pos_ui_account_fiscal_positions_by_ids(self, fp_ids):
+ params = self._loader_params_account_fiscal_position()
+ params['search_params']['domain'] = [('id', 'in', fp_ids)]
+ fps = self.env['account.fiscal.position'].search_read(**params['search_params'])
+ fiscal_position_tax_ids = sum([fpos['tax_ids'] for fpos in fps], [])
+ fiscal_position_tax = self.env['account.fiscal.position.tax'].search_read([('id', 'in', fiscal_position_tax_ids)])
+ fiscal_position_by_id = {fpt['id']: fpt for fpt in fiscal_position_tax}
+ for fiscal_position in fps:
+ if not self.config_id.tax_regime_selection:
+ self.config_id.tax_regime_selection = True
+ fiscal_position_id = self.env['account.fiscal.position'].browse(fiscal_position['id'])
+ self.config_id.fiscal_position_ids += fiscal_position_id
+ fiscal_position['fiscal_position_taxes_by_id'] = {tax_id: fiscal_position_by_id[tax_id] for tax_id in fiscal_position['tax_ids']}
+
+ return fps
+
class ProcurementGroup(models.Model):
_inherit = 'procurement.group'
diff --git a/addons/point_of_sale/models/res_config_settings.py b/addons/point_of_sale/models/res_config_settings.py
index a2564d155f7..057d6ea5dea 100644
--- a/addons/point_of_sale/models/res_config_settings.py
+++ b/addons/point_of_sale/models/res_config_settings.py
@@ -105,6 +105,7 @@ class ResConfigSettings(models.TransientModel):
pos_warehouse_id = fields.Many2one(related='pos_config_id.warehouse_id', readonly=False, string="Warehouse (PoS)")
point_of_sale_use_ticket_qr_code = fields.Boolean(related='company_id.point_of_sale_use_ticket_qr_code', readonly=False)
pos_auto_validate_terminal_payment = fields.Boolean(related='pos_config_id.auto_validate_terminal_payment', readonly=False, string="Automatically validates orders paid with a payment terminal.")
+ pos_trusted_config_ids = fields.Many2many(related='pos_config_id.trusted_config_ids', readonly=False)
point_of_sale_ticket_unique_code = fields.Boolean(related='company_id.point_of_sale_ticket_unique_code', readonly=False)
@api.model_create_multi
@@ -313,3 +314,13 @@ class ResConfigSettings(models.TransientModel):
res_config.pos_iface_customer_facing_display_via_proxy = False
else:
res_config.pos_iface_customer_facing_display_via_proxy = res_config.pos_config_id.iface_customer_facing_display_via_proxy
+
+ @api.onchange('pos_trusted_config_ids')
+ def _onchange_trusted_config_ids(self):
+ for config in self:
+ removed_trusted_configs = set(config.pos_config_id.trusted_config_ids.ids) - set(config.pos_trusted_config_ids.ids)
+ for old in config.pos_config_id.trusted_config_ids:
+ if config.pos_config_id.id not in old.trusted_config_ids.ids:
+ old._add_trusted_config_id(config.pos_config_id)
+ if old.id in removed_trusted_configs:
+ old._remove_trusted_config_id(config.pos_config_id)
diff --git a/addons/point_of_sale/static/src/app/navbar/navbar.js b/addons/point_of_sale/static/src/app/navbar/navbar.js
index 21cf1d283aa..b2be2916d3d 100644
--- a/addons/point_of_sale/static/src/app/navbar/navbar.js
+++ b/addons/point_of_sale/static/src/app/navbar/navbar.js
@@ -31,6 +31,7 @@ export class Navbar extends Component {
this.pos = usePos();
this.debug = useService("debug");
this.popup = useService("popup");
+ this.notification = useService("pos_notification");
}
get customerFacingDisplayButtonIsShown() {
return this.env.pos.config.iface_customer_facing_display;
@@ -40,14 +41,34 @@ export class Navbar extends Component {
this.popup.add(CashMovePopup);
}
- onTicketButtonClick() {
+ async onTicketButtonClick() {
if (this.isTicketScreenShown) {
this.pos.closeScreen();
} else {
- this.pos.showScreen("TicketScreen");
+ if (this._shouldLoadOrders()) {
+ try {
+ this.env.pos.setLoadingOrderState(true);
+ const message = await this.env.pos._syncAllOrdersFromServer();
+ if (message) {
+ this.notification.add(
+ _.str.sprintf(message),
+ 5000
+ );
+ }
+ } finally {
+ this.env.pos.setLoadingOrderState(false);
+ this.pos.showScreen("TicketScreen");
+ }
+ } else {
+ this.pos.showScreen("TicketScreen");
+ }
}
}
+ _shouldLoadOrders() {
+ return this.env.pos.config.trusted_config_ids.length > 0;
+ }
+
get isTicketScreenShown() {
return this.pos.mainScreen.component === TicketScreen;
}
diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SaveButton.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SaveButton.js
new file mode 100644
index 00000000000..158039256c6
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SaveButton.js
@@ -0,0 +1,51 @@
+/** @odoo-module */
+
+import { Component } from "@odoo/owl";
+import { ProductScreen } from "@point_of_sale/js/Screens/ProductScreen/ProductScreen";
+import { useService } from "@web/core/utils/hooks";
+
+export class SaveButton extends Component {
+ static template = "point_of_sale.SaveButton";
+
+ setup() {
+ super.setup();
+ this.notification = useService("pos_notification");
+ }
+ onClick() {
+ const orderline = this.env.pos.get_order().get_selected_orderline();
+ if (!orderline) {
+ this.notification.add(
+ _.str.sprintf(
+ this.env._t('You cannot save an empty order')
+ ),
+ 3000
+ );
+ return;
+ }
+ this._selectEmptyOrder();
+ this.notification.add(
+ _.str.sprintf(
+ this.env._t('Order saved for later')
+ ),
+ 3000
+ );
+ }
+ _selectEmptyOrder() {
+ const orders = this.env.pos.get_order_list();
+ let emptyOrders = orders.filter((order) => order.is_empty());
+ if (emptyOrders.length > 0) {
+ this.env.pos.sendDraftToServer();
+ this.env.pos.set_order(emptyOrders[0]);
+ } else {
+ this.env.pos.add_new_order();
+ }
+ }
+
+}
+
+ProductScreen.addControlButton({
+ component: SaveButton,
+ condition: function () {
+ return this.env.pos.config.trusted_config_ids.length > 0;
+ },
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductScreen.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductScreen.js
index 44e57cb3f34..307593bc885 100644
--- a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductScreen.js
+++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductScreen.js
@@ -11,6 +11,7 @@ import { ErrorPopup } from "@point_of_sale/js/Popups/ErrorPopup";
import { ErrorBarcodePopup } from "@point_of_sale/js/Popups/ErrorBarcodePopup";
import { ConfirmPopup } from "@point_of_sale/js/Popups/ConfirmPopup";
import { ControlButtonPopup } from "@point_of_sale/js/Popups/ControlButtonPopup";
+import { ConnectionLostError } from "@web/core/network/rpc_service";
import { ActionpadWidget } from "./ActionpadWidget";
import { MobileOrderWidget } from "../../Misc/MobileOrderWidget";
@@ -35,6 +36,7 @@ export class ProductScreen extends ControlButtonsMixin(Component) {
this.pos = usePos();
this.popup = useService("popup");
this.orm = useService("orm");
+ this.notification = useService("pos_notification");
this.numberBuffer = useService("number_buffer");
onMounted(this.onMounted);
// Call `reset` when the `onMounted` callback in `numberBuffer.use` is done.
@@ -232,6 +234,34 @@ export class ProductScreen extends ControlButtonsMixin(Component) {
}
}
async onClickPay() {
+ if (this.env.pos.get_order().server_id) {
+ try {
+ const isPaid = await this.orm.call("pos.order", "is_already_paid", [
+ this.env.pos.get_order().server_id,
+ ]);
+ if (isPaid) {
+ const searchDetails = { fieldName: "RECEIPT_NUMBER", searchTerm: this.env.pos.get_order().uid }
+ this.pos.showScreen("TicketScreen", {
+ ui: { filter: "SYNCED", searchDetails }
+ });
+ this.notification.add(
+ _.str.sprintf(
+ this.env._t('The order has been already paid.')
+ ),
+ 3000
+ );
+ this.env.pos.removeOrder(this.env.pos.get_order(), false);
+ this.env.pos.add_new_order();
+ return;
+ }
+ } catch (error) {
+ if (!(error.message instanceof ConnectionLostError)) {
+ throw error;
+ }
+ // Reject error in a separate stack to display the offline popup, but continue the flow
+ Promise.reject(error);
+ }
+ }
if (
this.env.pos
.get_order()
diff --git a/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/ReceiptScreen.js b/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/ReceiptScreen.js
index a05035d6501..7a8b2e5727b 100644
--- a/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/ReceiptScreen.js
+++ b/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/ReceiptScreen.js
@@ -100,6 +100,9 @@ export class ReceiptScreen extends AbstractReceiptScreen {
get nextScreen() {
return { name: "ProductScreen" };
}
+ get ticketScreen() {
+ return { name: "TicketScreen" };
+ }
whenClosing() {
this.orderDone();
}
@@ -131,6 +134,15 @@ export class ReceiptScreen extends AbstractReceiptScreen {
this.env.pos.send_current_order_to_customer_facing_display();
}
}
+ resumeOrder() {
+ this.env.pos.removeOrder(this.currentOrder);
+ this.env.pos.selectNextOrder();
+ const { name, props } = this.ticketScreen;
+ this.pos.showScreen(name, props);
+ }
+ isResumeVisible() {
+ return this.env.pos.get_order_list().length > 1;
+ }
async printReceipt() {
this.buttonPrintReceipt.el.className = "fa fa-fw fa-spin fa-circle-o-notch";
const currentOrder = this.currentOrder;
diff --git a/addons/point_of_sale/static/src/js/Screens/TicketScreen/TicketScreen.js b/addons/point_of_sale/static/src/js/Screens/TicketScreen/TicketScreen.js
index 5691a3f8590..7fc6911cf64 100644
--- a/addons/point_of_sale/static/src/js/Screens/TicketScreen/TicketScreen.js
+++ b/addons/point_of_sale/static/src/js/Screens/TicketScreen/TicketScreen.js
@@ -4,7 +4,7 @@ import { Order } from "@point_of_sale/js/models";
import { IndependentToOrderScreen } from "@point_of_sale/js/Misc/IndependentToOrderScreen";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
-import { parse } from "web.field_utils";
+import { parse, format } from "web.field_utils";
import { ErrorPopup } from "@point_of_sale/js/Popups/ErrorPopup";
import { ConfirmPopup } from "@point_of_sale/js/Popups/ConfirmPopup";
@@ -144,6 +144,9 @@ export class TicketScreen extends IndependentToOrderScreen {
}
this.env.pos.removeOrder(order);
}
+ if (this.env.pos.isOpenOrderShareable()) {
+ this.env.pos._removeOrdersFromServer();
+ }
}
async onNextPage() {
if (this._state.syncedOrders.currentPage < this._getLastPage()) {
@@ -543,6 +546,9 @@ export class TicketScreen extends IndependentToOrderScreen {
};
}
_setOrder(order) {
+ if (this.env.pos.isOpenOrderShareable()) {
+ this.env.pos.sendDraftToServer();
+ }
this.env.pos.set_order(order);
this.close();
}
@@ -650,16 +656,18 @@ export class TicketScreen extends IndependentToOrderScreen {
const limit = this._state.syncedOrders.nPerPage;
const offset =
(this._state.syncedOrders.currentPage - 1) * this._state.syncedOrders.nPerPage;
- const { ids, totalCount } = await this.orm.call("pos.order", "search_paid_order_ids", [], {
+ const { ordersInfo, totalCount } = await this.orm.call("pos.order", "search_paid_order_ids", [], {
config_id: this.env.pos.config.id,
domain,
limit,
offset,
});
- const idsNotInCache = ids.filter((id) => !(id in this._state.syncedOrders.cache));
- if (idsNotInCache.length > 0) {
+ const idsNotInCache = ordersInfo.filter((orderInfo) => !(orderInfo[0] in this._state.syncedOrders.cache));
+ const idsNotUpToDate = ordersInfo.filter((orderInfo) => format.datetime(moment(orderInfo[1]), {}, {}) > format.datetime(moment(this._state.syncedOrders.cacheDate), {}, { timezone: false }));
+ const idsToLoad = idsNotInCache.concat(idsNotUpToDate).map((info) => info[0]);
+ if (idsToLoad.length > 0) {
const fetchedOrders = await this.orm.call("pos.order", "export_for_ui", [
- idsNotInCache,
+ idsToLoad,
]);
// Check for missing products and partners and load them in the PoS
await this.env.pos._loadMissingProducts(fetchedOrders);
@@ -672,7 +680,11 @@ export class TicketScreen extends IndependentToOrderScreen {
{ pos: this.env.pos, json: order }
);
});
+ //Update the datetime indicator of the cache refresh
+ this._state.syncedOrders.cacheDate = new Date();
}
+
+ const ids = ordersInfo.map((info) => info[0]);
this._state.syncedOrders.totalCount = totalCount;
this._state.syncedOrders.toShow = ids.map((id) => this._state.syncedOrders.cache[id]);
}
diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js
index 14ebdcacd63..fc239199a80 100644
--- a/addons/point_of_sale/static/src/js/models.js
+++ b/addons/point_of_sale/static/src/js/models.js
@@ -128,6 +128,7 @@ export class PosGlobalState extends PosModel {
toShow: [],
nPerPage: 80,
totalCount: null,
+ cacheDate: null,
},
ui: {
selectedSyncedOrderId: null,
@@ -139,6 +140,9 @@ export class PosGlobalState extends PosModel {
},
};
+ this.ordersToUpdateSet = new Set(); // used to know which orders need to be sent to the back end when syncing
+ this.loadingOrderState = false; // used to prevent orders fetched to be put in the update set during the reactive change
+
// these dynamic attributes can be watched for change by other models or widgets
Object.assign(this, {
synch: { status: "connected", pending: 0 },
@@ -382,7 +386,7 @@ export class PosGlobalState extends PosModel {
* Remove the order passed in params from the list of orders
* @param order
*/
- removeOrder(order) {
+ removeOrder(order, removeFromServer = true) {
this.orders.remove(order);
this.db.remove_unpaid_order(order);
for (const line of order.get_orderlines()) {
@@ -390,6 +394,14 @@ export class PosGlobalState extends PosModel {
delete this.toRefundLines[line.refunded_orderline_id];
}
}
+ if (this.isOpenOrderShareable() && removeFromServer) {
+ if (this.ordersToUpdateSet.has(order)) {
+ this.ordersToUpdateSet.delete(order);
+ }
+ if (order.server_id && !order.finalized) {
+ this.db.set_order_to_remove_from_server(order);
+ }
+ }
}
/**
@@ -407,6 +419,9 @@ export class PosGlobalState extends PosModel {
}
_onReactiveOrderUpdated(order) {
order.save_to_db();
+ if (this.isOpenOrderShareable()) {
+ this.ordersToUpdateSet.add(order);
+ }
}
createReactiveOrder(json) {
const options = { pos: this };
@@ -425,11 +440,44 @@ export class PosGlobalState extends PosModel {
}
// creates a new empty order and sets it as the current order
add_new_order() {
+ if (this.isOpenOrderShareable()) {
+ this.sendDraftToServer();
+ }
const order = this.createReactiveOrder();
this.orders.add(order);
this.selectedOrder = order;
return order;
}
+ selectNextOrder() {
+ if (this.orders.length > 0) {
+ this.selectedOrder = this.orders[0];
+ } else {
+ this.add_new_order();
+ }
+ }
+ async sendDraftToServer() {
+ const ordersUidsToSync = [...this.ordersToUpdateSet].map((order) => order.uid);
+ const ordersToSync = this.db.get_unpaid_orders_to_sync(ordersUidsToSync);
+ const ordersResponse = await this._save_to_server(ordersToSync, { draft: true });
+ const orders = [...this.ordersToUpdateSet].map((order) => order);
+ ordersResponse.forEach((orderResponseData) =>
+ this._updateOrder(orderResponseData, orders)
+ );
+ this.ordersToUpdateSet.clear();
+ }
+ addOrderToUpdateSet() {
+ this.ordersToUpdateSet.add(this.selectedOrder)
+ }
+ // created this hook for modularity
+ _updateOrder(ordersResponseData, orders) {
+ const order = orders.find(
+ (order) => order.name === ordersResponseData.pos_reference
+ );
+ if (order) {
+ order.server_id = ordersResponseData.id;
+ return order;
+ }
+ }
/**
* Load the locally saved unpaid orders for this PoS Config.
*
@@ -438,6 +486,7 @@ export class PosGlobalState extends PosModel {
* Only if tho order has orderlines.
*/
async load_orders() {
+ this.loadingOrderState = true;
var jsons = this.db.get_unpaid_orders();
await this._loadMissingProducts(jsons);
await this._loadMissingPartners(jsons);
@@ -470,6 +519,7 @@ export class PosGlobalState extends PosModel {
this.orders.add(order);
}
}
+ this.loadingOrderState = false;
}
async _loadMissingProducts(orders) {
const missingProductIds = new Set([]);
@@ -558,6 +608,188 @@ export class PosGlobalState extends PosModel {
i += 1;
} while (partners.length);
}
+ setLoadingOrderState(bool) {
+ this.loadingOrderState = bool;
+ }
+ async _removeOrdersFromServer() {
+ const removedOrdersIds = this.db.get_ids_to_remove_from_server();
+ if (removedOrdersIds.length === 0) {
+ return;
+ }
+
+ this.set_synch("connecting", removedOrdersIds.length);
+ try {
+ const removeOrdersResponseData = await this.env.services.orm.silent.call(
+ "pos.order",
+ "remove_from_ui",
+ [removedOrdersIds]
+ );
+ this.set_synch("connected");
+ this._postRemoveFromServer(removedOrdersIds, removeOrdersResponseData);
+ } catch (reason) {
+ const error = reason.message;
+ if (error.code === 200) {
+ // Business Logic Error, not a connection problem
+ //if warning do not need to display traceback!!
+ if (error.data.exception_type == "warning") {
+ delete error.data.debug;
+ }
+ }
+ // important to throw error here and let the rendering component handle the error
+ console.warn("Failed to remove orders:", removedOrdersIds);
+ throw error;
+ }
+ }
+ _postRemoveFromServer(serverIds, data) {
+ this.db.set_ids_removed_from_server(serverIds);
+ }
+ _replaceOrders(ordersToReplace, newOrdersJsons) {
+ ordersToReplace.forEach((order) => {
+ // We don't remove the validated orders because we still want to see them in the ticket screen.
+ // Orders in 'ReceiptScreen' or 'TipScreen' are validated orders.
+ if (this._shouldRemoveOrder(order)) {
+ this.removeOrder(order, false);
+ }
+ });
+ let removeSelected = true;
+ newOrdersJsons.forEach((json) => {
+ let isSelectedOrder = this._createOrder(json);
+ if (removeSelected && isSelectedOrder) {
+ removeSelected = false;
+ }
+ });
+ if (this._shouldRemoveSelectedOrder(removeSelected)) {
+ this._removeSelectedOrder();
+ }
+ }
+ _shouldRemoveOrder(order) {
+ return (!this.selectedOrder || (this.selectedOrder.uid != order.uid)) && order.server_id && !order.finalized;
+ }
+ _shouldRemoveSelectedOrder(removeSelected) {
+ return removeSelected && this.selectedOrder.server_id && !this.selectedOrder.finalized;
+ }
+ _shouldCreateOrder(json) {
+ return json.uid != this.selectedOrder.uid;
+ }
+ _isSelectedOrder(json) {
+ return json.uid == this.selectedOrder.uid;
+ }
+ _createOrder(json) {
+ if (this._shouldCreateOrder(json)) {
+ const order = this.createReactiveOrder(json);
+ this.orders.add(order);
+ }
+ return this._isSelectedOrder(json);
+ }
+ _removeSelectedOrder() {
+ this.removeOrder(this.selectedOrder, false);
+ const orderList = this.get_order_list();
+ if (orderList.length != 0) {
+ this.set_order(orderList[0]);
+ }
+ }
+ async _syncAllOrdersFromServer() {
+ await this._removeOrdersFromServer();
+ const ordersJson = await this._getOrdersJson();
+ let message = null;
+ message = await this._addPricelists(ordersJson);
+ let messageFp = null;
+ messageFp = await this._addFiscalPositions(ordersJson);
+ if (messageFp) {
+ if (message) {
+ message += "\n" + messageFp;
+ } else {
+ message = messageFp;
+ }
+ }
+ const allOrders = [...this.get_order_list()];
+ this._replaceOrders(allOrders, ordersJson);
+ this.sortOrders();
+ return message;
+ }
+ async _getOrdersJson() {
+ return await this.env.services.orm.call("pos.order", "get_draft_share_order_ids", [], {
+ config_id: this.config.id,
+ });
+ }
+ async _addPricelists(ordersJson) {
+ let pricelistsToGet = [];
+ ordersJson.forEach((order) => {
+ let found = false;
+ for (let pricelist of this.pricelists) {
+ if (pricelist.id === order.pricelist_id) {
+ found = true;
+ break;
+ }
+ }
+ if (!found && order.pricelist_id) {
+ pricelistsToGet.push(order.pricelist_id);
+ }
+ });
+ let message = null;
+ if (pricelistsToGet.length > 0) {
+ const pricelistsJson = await this._getPricelistJson(pricelistsToGet);
+ message = this._addPosPricelists(pricelistsJson);
+ }
+ return message;
+ }
+ async _getPricelistJson(pricelistsToGet) {
+ return await this.env.services.orm.call("pos.session", "get_pos_ui_product_pricelists_by_ids", [
+ [odoo.pos_session_id],
+ pricelistsToGet,
+ ]);
+ }
+ _addPosPricelists(pricelistsJson) {
+ if (!this.config.use_pricelist) {
+ this.config.use_pricelist = true;
+ }
+ this.pricelists.push(...pricelistsJson);
+ let message = "";
+ const pricelistsNames = pricelistsJson.map((pricelist) => {
+ return pricelist.display_name;
+ })
+ message = _.str.sprintf(_t("%s fiscal position(s) added to the configuration."), pricelistsNames.join(", "));
+ return message;
+ }
+ async _addFiscalPositions(ordersJson) {
+ let fiscalPositionToGet = [];
+ ordersJson.forEach((order) => {
+ let found = false;
+ for (let fp of this.fiscal_positions) {
+ if (fp.id === order.fiscal_position_id) {
+ found = true;
+ break;
+ }
+ }
+ if (!found && order.fiscal_position_id) {
+ fiscalPositionToGet.push(order.fiscal_position_id);
+ }
+ });
+ let message = null;
+ if (fiscalPositionToGet.length > 0) {
+ const fiscalPositionJson = await this._getFiscalPositionJson(fiscalPositionToGet);
+ message = this._addPosFiscalPosition(fiscalPositionJson);
+ }
+ return message;
+ }
+ async _getFiscalPositionJson(fiscalPositionToGet) {
+ return await this.env.services.orm.call("pos.session", "get_pos_ui_account_fiscal_positions_by_ids", [
+ [odoo.pos_session_id],
+ fiscalPositionToGet,
+ ]);
+ }
+ _addPosFiscalPosition(fiscalPositionJson) {
+ this.fiscal_positions.push(...fiscalPositionJson);
+ let message = "";
+ const fiscalPositionNames = fiscalPositionJson.map((fp) => {
+ return fp.display_name;
+ })
+ message = _.str.sprintf(_t("%s fiscal position(s) added to the configuration."), fiscalPositionNames.join(", "));
+ return message;
+ }
+ sortOrders() {
+ this.orders.sort((a, b) => (a.name > b.name) ? 1 : -1)
+ }
async getProductInfo(product, quantity) {
const order = this.get_order();
// check back-end method `get_product_info_pos` to see what it returns
@@ -648,6 +880,9 @@ export class PosGlobalState extends PosModel {
set_start_order() {
if (this.orders.length && !this.selectedOrder) {
this.selectedOrder = this.orders[0];
+ if (this.isOpenOrderShareable()) {
+ this.ordersToUpdateSet.add(this.orders[0]);
+ }
} else {
this.add_new_order();
}
@@ -1401,6 +1636,9 @@ export class PosGlobalState extends PosModel {
this.db.update_partners(partnerWithUpdatedTotalDue);
return partnerWithUpdatedTotalDue;
}
+ isOpenOrderShareable() {
+ return this.config.trusted_config_ids.length > 0;
+ }
}
PosGlobalState.prototype.electronic_payment_interfaces = {};
@@ -1671,7 +1909,7 @@ export class Orderline extends PosModel {
} catch {
console.error(
"ERROR: attempting to recover product ID",
- options.json.product_id,
+ options.json.product_id[0],
"not available in the point of sale. Correct the product or clean the browser cache."
);
}
@@ -2676,7 +2914,7 @@ export class Order extends PosModel {
var orderlines = json.lines;
for (var i = 0; i < orderlines.length; i++) {
var orderline = orderlines[i][2];
- if (this.pos.db.get_product_by_id(orderline.product_id)) {
+ if (orderline.product_id && this.pos.db.get_product_by_id(orderline.product_id)) {
this.add_orderline(
new Orderline({}, { pos: this.pos, order: this, json: orderline })
);
diff --git a/addons/point_of_sale/static/src/scss/pos.scss b/addons/point_of_sale/static/src/scss/pos.scss
index 67afa8191eb..c00a2a770e6 100644
--- a/addons/point_of_sale/static/src/scss/pos.scss
+++ b/addons/point_of_sale/static/src/scss/pos.scss
@@ -1313,11 +1313,18 @@ td {
.screen .top-content .button.highlight:hover {
background: rgb(120,210,165);
}
-
+.screen .validation-buttons {
+ display: flex;
+ width: 100%;
+ align-items: flex-end;
+ justify-content: center;
+ gap: 2px;
+}
.screen .button.next.validation {
position: sticky;
bottom: 0;
height: auto;
+ width: 100%;
padding: map-get($spacers, 5) 0;
}
.screen .button.next:not(.highlight) {
@@ -1726,9 +1733,8 @@ td {
/* c) The receipt screen */
.pos .receipt-screen .screen-content {
- display: grid;
- grid-template-columns: 1fr;
- grid-template-rows: auto 1fr auto;
+ display: flex;
+ justify-content: space-between;
}
.pos .receipt-screen .left-content {
diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ControlButtons/SaveButton.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ControlButtons/SaveButton.xml
new file mode 100644
index 00000000000..2b937f00ec0
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ControlButtons/SaveButton.xml
@@ -0,0 +1,11 @@
+
+