[IMP] point_of_sale, pos_restaurant: Add cross-orders feature for point_of_sale and pos_restaurant

Currently the below use-cases are hardly supportable, though quite common:

Retail: Sell product in one shop and return it in another one
Restaurant: Managing payment when having multiple checkout desks,
 and multiple waiters (only taking orders)

This because currently:

PoS orders are only known by the cashier desk (pos.config) they were created from
One floor map can only be linked to one cashier desk at a time

More over, with the Self-Service coming along the way
(where kiosk orders must be accessible from a cashier desk),
this need must be supported.

For restaurant, floor plans can be linked to multiple cashier desks,
ongoing orders are shared between trusted PoS config
(through floor plans for restaurant and according to
the setting "Trusted PoS config" for the retails),
and past orders can be accessible from any desks within a same DB.

closes odoo/odoo#109216

Related: odoo/upgrade#4393
Related: odoo/enterprise#37955
Signed-off-by: Trinh Jacky (trj) <trj@odoo.com>
This commit is contained in:
Adrien Guilliams (adgu)
2023-03-10 18:54:52 +01:00
parent ffbc3be76f
commit 53f9e5845f
31 changed files with 827 additions and 364 deletions
+23 -1
View File
@@ -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
+216 -9
View File
@@ -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
+38 -6
View File
@@ -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'
@@ -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)
@@ -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;
}
@@ -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;
},
});
@@ -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()
@@ -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;
@@ -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]);
}
+241 -3
View File
@@ -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 })
);
+10 -4
View File
@@ -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 {
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="point_of_sale.SaveButton" owl="1">
<div class="control-button" t-on-click="onClick">
<i class="fa fa-upload" role="img" aria-label="Save" title="Save" />
Save
</div>
</t>
</templates>
@@ -40,12 +40,21 @@
<OrderReceipt order="currentOrder"/>
</div>
</div>
<div class="button next validation" t-att-class="{ highlight: !locked }"
t-on-click="orderDone">
<div class="pay-circle">
<i class="fa fa-chevron-right" role="img" aria-label="Pay" title="Pay" />
<div class="validation-buttons">
<div class="button next validation" t-att-class="{ highlight: !locked }"
t-on-click="orderDone" name="done">
<div class="pay-circle">
<i class="fa fa-chevron-right" role="img" aria-label="Pay" title="Pay" />
</div>
New Order
</div>
<div t-if="isResumeVisible()" class="button next validation" t-att-class="{ highlight: !locked }"
t-on-click="resumeOrder" name="resume">
<div class="pay-circle">
<i class="fa fa-chevron-right" role="img" aria-label="Pay" title="Pay" />
</div>
Resume Order
</div>
New Order
</div>
</div>
</div>
@@ -68,8 +68,8 @@ ProductScreen.check.isShown();
// Check that the Paid filter will show the 2 synced orders.
Chrome.do.clickTicketButton();
TicketScreen.do.selectFilter("Paid");
TicketScreen.check.nthRowContains(2, "Brandon Freeman");
TicketScreen.check.nthRowContains(3, "-0003");
TicketScreen.check.nthRowContains(3, "Brandon Freeman");
TicketScreen.check.nthRowContains(2, "-0003");
// Invoice order
TicketScreen.do.selectOrder("-0003");
TicketScreen.check.orderWidgetIsNotEmpty();
@@ -7,7 +7,7 @@ class Do {
return [
{
content: "go to next screen",
trigger: ".receipt-screen .button.next.highlight",
trigger: ".receipt-screen .button.next.highlight[name='done']",
},
];
}
@@ -130,6 +130,14 @@ class Check {
},
];
}
contains( string) {
return [
{
trigger: `.ticket-screen .orders:contains("${string}")`,
run: () => {},
},
];
}
noNewTicketButton() {
return [
{
@@ -79,6 +79,17 @@
<setting string="Margins &amp; Costs" help="Show margins &amp; costs on product information">
<field name="pos_is_margins_costs_accessible_to_every_user"/>
</setting>
<setting string="Share Open Orders" help="Allow to access each other's active orders" id="is_order_shared" attrs="{'invisible': [('pos_module_pos_restaurant', '=', True)]}">
<div class="content-group">
<div class="row mt16">
<label string="Trusted POS" for="pos_trusted_config_ids" class="col-lg-3 o_light_label" />
<field name="pos_trusted_config_ids" widget="many2many_tags" options="{'no_create':'true'}" placeholder="Select PoS to start sharing orders"/>
</div>
<div>
<button name="%(point_of_sale.action_pos_config_tree)d" icon="fa-arrow-right" type="action" string="Point of Sales" class="btn-link"/>
</div>
</div>
</setting>
</block>
<block title="Accounting" id="pos_accounting_section">
+14
View File
@@ -28,3 +28,17 @@ class PosOrder(models.Model):
'employee_id': order.employee_id.id,
})
return result
def _get_fields_for_draft_order(self):
fields = super(PosOrder, self)._get_fields_for_draft_order()
fields.append('employee_id')
return fields
@api.model
def get_draft_share_order_ids(self, config_id):
orders = super(PosOrder, self).get_draft_share_order_ids(config_id)
for order in orders:
if order['employee_id']:
order['employee_id'] = order['employee_id'][0]
return orders
@@ -210,7 +210,7 @@
<record id="floor_main" model="restaurant.floor">
<field name="name">Main Floor</field>
<field name="background_color">rgb(136,137,242)</field>
<field name="pos_config_id" ref="pos_restaurant.pos_config_restaurant"/>
<field name="pos_config_ids" eval="[(6, 0, [ref('pos_restaurant.pos_config_restaurant')])]"/>
</record>
<record id="table_01" model="restaurant.table">
@@ -350,7 +350,7 @@
<record id="floor_patio" model="restaurant.floor">
<field name="name">Patio</field>
<field name="background_color">rgb(130, 233, 171)</field>
<field name="pos_config_id" ref="pos_restaurant.pos_config_restaurant"/>
<field name="pos_config_ids" eval="[(6, 0, [ref('pos_restaurant.pos_config_restaurant')])]"/>
</record>
<!-- Patio: Left table row -->
+2 -2
View File
@@ -10,7 +10,7 @@ class PosConfig(models.Model):
iface_splitbill = fields.Boolean(string='Bill Splitting', help='Enables Bill Splitting in the Point of Sale.')
iface_printbill = fields.Boolean(string='Bill Printing', help='Allows to print the Bill before payment.')
iface_orderline_notes = fields.Boolean(string='Kitchen Notes', help='Allow custom kitchen notes on Orderlines.', default=True)
floor_ids = fields.One2many('restaurant.floor', 'pos_config_id', string='Restaurant Floors', help='The restaurant floors served by this point of sale.')
floor_ids = fields.Many2many('restaurant.floor', string='Restaurant Floors', help='The restaurant floors served by this point of sale.')
printer_ids = fields.Many2many('restaurant.printer', 'pos_config_printer_rel', 'config_id', 'printer_id', string='Order Printers')
is_table_management = fields.Boolean('Floors & Tables')
is_order_printer = fields.Boolean('Order Printer')
@@ -26,7 +26,7 @@ class PosConfig(models.Model):
def get_tables_order_count(self):
""" """
self.ensure_one()
tables = self.env['restaurant.table'].search([('floor_id.pos_config_id', 'in', self.ids)])
tables = self.env['restaurant.table'].search([('floor_id.pos_config_ids', '=', self.id)])
domain = [('state', '=', 'draft'), ('table_id', 'in', tables.ids)]
order_stats = self.env['pos.order'].read_group(domain, ['table_id'], 'table_id')
+11 -164
View File
@@ -24,144 +24,23 @@ class PosOrder(models.Model):
customer_count = fields.Integer(string='Guests', help='The amount of customers that have been served by this order.', readonly=True)
multiprint_resume = fields.Char(string='Multiprint Resume', help="Last printed state of the order")
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 _get_fields_for_order_line(self):
fields = super(PosOrder, self)._get_fields_for_order_line()
fields.extend([
'id',
'discount',
'product_id',
'price_unit',
'order_id',
'qty',
'note',
'uuid',
'mp_skip',
'full_product_name',
'customer_note',
'price_extra',
])
return fields
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 = super(PosOrder, self)._prepare_order_line(order_line)
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_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_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',
'customer_count',
'fiscal_position_id',
fields = super()._get_fields_for_draft_order()
fields.extend([
'table_id',
'to_invoice',
'customer_count',
'multiprint_resume',
'access_token',
'ticket_code',
]
])
return fields
def _get_domain_for_draft_orders(self, table_ids):
""" Get the domain to search for draft orders on a table.
@@ -189,48 +68,16 @@ class PosOrder(models.Model):
self._get_order_lines(table_orders)
self._get_payment_lines(table_orders)
for order in table_orders:
order['pos_session_id'] = order['session_id'][0]
order['uid'] = search(r"\d{5,}-\d{3,}-\d{4,}", order['pos_reference']).group(0)
order['name'] = order['pos_reference']
order['creation_date'] = order['create_date']
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 order['table_id']:
order['table_id'] = order['table_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']
self._prepare_order(table_orders)
return table_orders
@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
def _prepare_order(self, orders):
super(PosOrder, self)._prepare_order(orders)
for order in orders:
if order['table_id']:
order['table_id'] = order['table_id'][0]
def set_tip(self, tip_line_vals):
"""Set tip to `self` based on values in `tip_line_vals`."""
@@ -12,7 +12,7 @@ class RestaurantFloor(models.Model):
_order = "sequence, name"
name = fields.Char('Floor Name', required=True)
pos_config_id = fields.Many2one('pos.config', string='Point of Sale')
pos_config_ids = fields.Many2many('pos.config', string='Point of Sales', domain="[('module_pos_restaurant', '=', True)]")
background_image = fields.Binary('Background Image')
background_color = fields.Char('Background Color', help='The background color of the floor in a html-compatible format', default='rgb(210, 210, 210)')
table_ids = fields.One2many('restaurant.table', 'floor_id', string='Tables')
@@ -21,7 +21,7 @@ class RestaurantFloor(models.Model):
@api.ondelete(at_uninstall=False)
def _unlink_except_active_pos_session(self):
confs = self.mapped('pos_config_id').filtered(lambda c: c.is_table_management == True)
confs = self.mapped('pos_config_ids').filtered(lambda c: c.is_table_management)
opened_session = self.env['pos.session'].search([('config_id', 'in', confs.ids), ('state', '!=', 'closed')])
if opened_session:
error_msg = _("You cannot remove a floor that is used in a PoS session, close the session(s) first: \n")
@@ -34,12 +34,11 @@ class RestaurantFloor(models.Model):
def write(self, vals):
for floor in self:
if floor.pos_config_id.has_active_session and (vals.get('pos_config_id') or vals.get('active')) :
raise UserError(
'Please close and validate the following open PoS Session before modifying this floor.\n'
'Open session: %s' % (' '.join(floor.pos_config_id.mapped('name')),))
if vals.get('pos_config_id') and floor.pos_config_id.id and vals.get('pos_config_id') != floor.pos_config_id.id:
raise UserError(_('The %s is already used in another Pos Config.', floor.name))
for config in floor.pos_config_ids:
if config.has_active_session and (vals.get('pos_config_ids') or vals.get('active')):
raise UserError(
'Please close and validate the following open PoS Session before modifying this floor.\n'
'Open session: %s' % (' '.join(config.mapped('name')),))
return super(RestaurantFloor, self).write(vals)
@@ -81,7 +80,7 @@ class RestaurantTable(models.Model):
@api.ondelete(at_uninstall=False)
def _unlink_except_active_pos_session(self):
confs = self.mapped('floor_id').mapped('pos_config_id').filtered(lambda c: c.is_table_management == True)
confs = self.mapped('floor_id').mapped('pos_config_ids').filtered(lambda c: c.is_table_management)
opened_session = self.env['pos.session'].search([('config_id', 'in', confs.ids), ('state', '!=', 'closed')])
if opened_session:
error_msg = _("You cannot remove a table that is used in a PoS session, close the session(s) first.")
+1 -2
View File
@@ -19,9 +19,8 @@ class PosSession(models.Model):
def _loader_params_restaurant_floor(self):
return {
'search_params': {
'domain': [('pos_config_id', '=', self.config_id.id)],
'domain': [('pos_config_ids', '=', self.config_id.id)],
'fields': ['name', 'background_color', 'table_ids', 'sequence'],
'order': 'sequence',
},
}
@@ -6,10 +6,7 @@ from odoo import fields, models, api
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
def _get_floors_domain(self):
return ['|', ('pos_config_id', 'in', self.pos_config_id.ids), ('pos_config_id', '=', False)]
pos_floor_ids = fields.One2many(related='pos_config_id.floor_ids', readonly=False, domain=lambda self: self._get_floors_domain())
pos_floor_ids = fields.Many2many(related='pos_config_id.floor_ids', readonly=False)
pos_iface_orderline_notes = fields.Boolean(compute='_compute_pos_module_pos_restaurant', store=True, readonly=False)
pos_iface_printbill = fields.Boolean(compute='_compute_pos_module_pos_restaurant', store=True, readonly=False)
pos_iface_splitbill = fields.Boolean(compute='_compute_pos_module_pos_restaurant', store=True, readonly=False)
@@ -6,23 +6,6 @@ import { BackToFloorButton } from "./BackToFloorButton";
patch(Navbar.components, "pos_restaurant.Navbar components", { BackToFloorButton });
patch(Navbar.prototype, "pos_restaurant.Navbar", {
async onTicketButtonClick() {
if (
this.env.pos.config.iface_floorplan &&
!this.isTicketScreenShown &&
!this.env.pos.table
) {
try {
this.env.pos.setLoadingOrderState(true);
await this.env.pos._syncAllOrdersFromServer();
} finally {
this.env.pos.setLoadingOrderState(false);
this.pos.showScreen("TicketScreen");
}
} else {
return this._super(...arguments);
}
},
/**
* If no table is set to pos, which means the current main screen
* is floor screen, then the order count should be based on all the orders.
@@ -36,4 +19,10 @@ patch(Navbar.prototype, "pos_restaurant.Navbar", {
}
return this._super(...arguments);
},
_shouldLoadOrders() {
return (this._super() || (
this.env.pos.config.iface_floorplan &&
!this.env.pos.table
));
},
});
@@ -27,6 +27,13 @@ patch(ReceiptScreen.prototype, "pos_restaurant.ReceiptScreen", {
this._super(...arguments);
}
},
isResumeVisible() {
if (this.env.pos.config.iface_floorplan &&
this.env.pos.table) {
return this.env.pos.getTableOrders(this.env.pos.table.id).length > 1;
}
return this._super(...arguments);
},
//@override
get nextScreen() {
if (this.env.pos.config.iface_floorplan) {
@@ -16,7 +16,9 @@ patch(TicketScreen.prototype, "pos_restaurant.TicketScreen", {
});
},
getTable(order) {
return `${order.getTable().floor.name} (${order.getTable().name})`;
if (order.getTable()) {
return `${order.getTable().floor.name} (${order.getTable().name})`;
}
},
//@override
_getSearchFields() {
+46 -118
View File
@@ -7,15 +7,11 @@ import { Printer } from "@point_of_sale/js/printers";
import { patch } from "@web/core/utils/patch";
const QWeb = core.qweb;
// const TIMEOUT = 7500;
patch(PosGlobalState.prototype, "pos_restaurant.PosGlobalState", {
setup() {
this._super(...arguments);
this.orderToTransfer = null; // table transfer feature
this.ordersToUpdateSet = new Set(); // used to know which orders need to be sent to the back end when syncing
this.transferredOrdersSet = new Set(); // used to know which orders has been transferred but not sent to the back end yet
this.loadingOrderState = false; // used to prevent orders fetched to be put in the update set during the reactive change
this.printers_category_ids_set = new Set();
},
//@override
@@ -30,25 +26,6 @@ patch(PosGlobalState.prototype, "pos_restaurant.PosGlobalState", {
}
},
//@override
_onReactiveOrderUpdated(order) {
this._super(...arguments);
if (this.config.iface_floorplan && !this.loadingOrderState) {
this.ordersToUpdateSet.add(order);
}
},
//@override
removeOrder(order, removeFromServer = true) {
this._super(...arguments);
if (this.config.iface_floorplan && 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);
}
}
},
//@override
async after_load_server_data() {
var res = await this._super(...arguments);
if (this.config.iface_floorplan) {
@@ -64,12 +41,6 @@ patch(PosGlobalState.prototype, "pos_restaurant.PosGlobalState", {
this._super(...arguments);
}
},
//@override
add_new_order() {
const order = this._super(...arguments);
this.ordersToUpdateSet.add(order);
return order;
},
isInterfacePrinter() {
return this.env.pos.config.iface_printers;
},
@@ -90,12 +61,6 @@ patch(PosGlobalState.prototype, "pos_restaurant.PosGlobalState", {
}
return reactiveOrder;
},
//@override
async load_orders() {
this.loadingOrderState = true;
await this._super(...arguments);
this.loadingOrderState = false;
},
_loadRestaurantPrinter(printers) {
this.unwatched.printers = [];
// list of product categories that belong to one or more order printer
@@ -146,54 +111,9 @@ patch(PosGlobalState.prototype, "pos_restaurant.PosGlobalState", {
const ordersResponse = await this._save_to_server(ordersToSync, { draft: true });
const tableOrders = [...this.ordersToUpdateSet].map((order) => order);
ordersResponse.forEach((orderResponseData) =>
this._updateTableOrder(orderResponseData, tableOrders)
this._updateOrder(orderResponseData, tableOrders)
);
},
// created this hook for modularity
_updateTableOrder(ordersResponseData, tableOrders) {
const order = tableOrders.find((order) => order.name === ordersResponseData.pos_reference);
order.server_id = ordersResponseData.id;
return order;
},
/**
* Remove the deleted orders from the backend.
* @throw error
*/
async _removeOrdersFromServer() {
const removedOrdersIds = this.db.get_ids_to_remove_from_server();
if (removedOrdersIds.length === 0) {
return;
}
this.set_synch("connecting", removedOrdersIds.length);
try {
// FIXME POSREF timeout
// const timeout = TIMEOUT * removedOrdersIds.length;
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;
}
},
// to override
_postRemoveFromServer(serverIds, data) {
this.db.set_ids_removed_from_server(serverIds);
},
/**
* Replace all the orders of a table by orders fetched from the backend
* @param tableId ID of the table
@@ -202,46 +122,51 @@ patch(PosGlobalState.prototype, "pos_restaurant.PosGlobalState", {
async _syncTableOrdersFromServer(tableId) {
await this._removeOrdersFromServer(); // in case we were offline and we deleted orders in the mean time
const ordersJsons = await this._getTableOrdersFromServer([tableId]);
await this._addPricelists(ordersJsons);
await this._addFiscalPositions(ordersJsons);
const tableOrders = this.getTableOrders(tableId);
this._replaceOrders(tableOrders, ordersJsons);
},
async _syncAllOrdersFromServer() {
await this._removeOrdersFromServer(); // in case we were offline and we deleted orders in the mean time
const tableIds = [].concat(
...this.floors.map((floor) => floor.tables.map((table) => table.id))
);
const ordersJsons = await this._getTableOrdersFromServer(tableIds); // get all orders
await this._syncTableOrdersToServer(); // to prevent losing the transferred orders
const allOrders = [...this.get_order_list()];
this._replaceOrders(allOrders, ordersJsons);
},
_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 (order.server_id && !order.finalized && !this.transferredOrdersSet.has(order)) {
this.removeOrder(order, false);
}
});
newOrdersJsons.forEach((json) => {
// Because of the offline feature, some draft orders fetched from the backend will appear
// to belong in different table, but in fact they are already moved.
const transferredOrder = [...this.transferredOrdersSet].find(
(order) => order.uid === json.uid
async _getOrdersJson() {
if (this.config.iface_floorplan) {
const tableIds = [].concat(
...this.floors.map((floor) => floor.tables.map((table) => table.id))
);
const isSameTable = transferredOrder && transferredOrder.tableId === json.tableId;
if (isSameTable) {
// this means we transferred back to the original table, we'll prioritize the server state
this.removeOrder(transferredOrder, false);
}
if (!transferredOrder || isSameTable) {
const order = this.createReactiveOrder(json);
this.orders.add(order);
}
});
await this._syncTableOrdersToServer(); // to prevent losing the transferred orders
const ordersJsons = await this._getTableOrdersFromServer(tableIds); // get all orders
return ordersJsons;
} else {
return await this._super();
}
},
setLoadingOrderState(bool) {
this.loadingOrderState = bool;
_shouldRemoveOrder(order) {
return this._super(...arguments) && !this.transferredOrdersSet.has(order);
},
_shouldCreateOrder(json) {
return (!this._transferredOrder(json) || this._isSameTable(json)) && (!this.selectedOrder || this._super(...arguments));
},
_shouldRemoveSelectedOrder(removeSelected) {
return this.selectedOrder && this._super(...arguments);
},
_isSelectedOrder(json) {
return !this.selectedOrder || this._super(...arguments);
},
_isSameTable(json) {
const transferredOrder = this._transferredOrder(json);
return transferredOrder && transferredOrder.tableId === json.tableId;
},
_transferredOrder(json) {
return [...this.transferredOrdersSet].find(
(order) => order.uid === json.uid
);
},
_createOrder(json) {
const transferredOrder = this._transferredOrder(json)
if (this._isSameTable(json)) {
// this means we transferred back to the original table, we'll prioritize the server state
this.removeOrder(transferredOrder, false);
}
return this._super(...arguments);
},
loadRestaurantFloor() {
// we do this in the front end due to the circular/recursive reference needed
@@ -314,6 +239,9 @@ patch(PosGlobalState.prototype, "pos_restaurant.PosGlobalState", {
}
return new Printer(url, this);
},
isOpenOrderShareable() {
return this._super(...arguments) || this.config.iface_floorplan;
},
});
// New orders are now associated with the current table, if any.
@@ -321,7 +249,7 @@ patch(Order.prototype, "pos_restaurant.Order", {
setup(options) {
this._super(...arguments);
if (this.pos.config.module_pos_restaurant) {
if (this.pos.config.iface_floorplan && !this.tableId && !options.json) {
if (this.pos.config.iface_floorplan && !this.tableId && !options.json && this.pos.table) {
this.tableId = this.pos.table.id;
}
this.customerCount = this.customerCount || 1;
@@ -372,7 +300,7 @@ patch(Order.prototype, "pos_restaurant.Order", {
export_for_printing() {
const json = this._super(...arguments);
if (this.pos.config.module_pos_restaurant) {
if (this.pos.config.iface_floorplan) {
if (this.pos.config.iface_floorplan && this.getTable()) {
json.table = this.getTable().name;
}
json.customer_count = this.getCustomerCount();
@@ -55,7 +55,7 @@ ProductScreen.check.totalAmountIs("8.0");
Chrome.do.backToFloor();
FloorScreen.check.orderCountSyncedInTableIs("5", "1");
Chrome.do.clickTicketButton();
TicketScreen.check.nthRowContains("3", "Tipping");
TicketScreen.check.nthRowContains("4", "Tipping");
// Tip 20% on order1
TicketScreen.do.selectOrder("-0001");
+2 -2
View File
@@ -51,7 +51,7 @@ class TestFrontend(odoo.tests.HttpCase):
main_floor = self.env['restaurant.floor'].create({
'name': 'Main Floor',
'pos_config_id': pos_config.id,
'pos_config_ids': [(4, pos_config.id)],
})
table_05 = self.env['restaurant.table'].create({
@@ -79,7 +79,7 @@ class TestFrontend(odoo.tests.HttpCase):
second_floor = self.env['restaurant.floor'].create({
'name': 'Second Floor',
'pos_config_id': pos_config.id,
'pos_config_ids': [(4, pos_config.id)],
})
table_01 = self.env['restaurant.table'].create({
@@ -12,7 +12,7 @@
<field name="active" invisible="1"/>
<group col="4">
<field name="name" />
<field name="pos_config_id" />
<field name="pos_config_ids" widget="many2many_tags"/>
<field name="background_color" groups="base.group_no_one" />
</group>
<field name="table_ids">
@@ -34,7 +34,7 @@
<tree string="Restaurant Floors">
<field name="sequence" widget="handle" />
<field name="name" />
<field name="pos_config_id" />
<field name="pos_config_ids" widget="many2many_tags"/>
</tree>
</field>
</record>
@@ -56,12 +56,12 @@
<field name="arch" type="xml">
<kanban class="o_kanban_mobile">
<field name="name"/>
<field name="pos_config_id" />
<field name="pos_config_ids" />
<templates>
<t t-name="kanban-box">
<div t-attf-class="oe_kanban_global_click">
<div><strong>Floor Name: </strong><t t-esc="record.name.value"/></div>
<div><strong>Point of Sale: </strong><t t-esc="record.pos_config_id.value"/></div>
<div><strong>Point of Sales: </strong><t t-esc="record.pos_config_ids.value"/></div>
</div>
</t>
</templates>