From 442f856bb8e041ddbdee8db84577d19123f2e4d2 Mon Sep 17 00:00:00 2001 From: "Jacky (trj)" Date: Thu, 8 Jul 2021 14:14:13 +0000 Subject: [PATCH] [IMP] point_of_sale: opening cash control and closing session in POS UI The opening cash control has been refactored and the closing of the session is now happening in the POS UI. This also leads to a cash control in the UI during the closing. The advanced cash control is no longer a setting to activate in the config by the user but a computed field based on the presence of a cash payment method. We force the user to always have it now. The opening cash control has been revamped with the addition of a new money calculator (`MoneyDetails`) allowing the user to easily compute how much money does he have based on his bills. The closing control has been converted into a popup which allows the users to get an overview of his session details (about orders, payments, payment methods, cash moves, ...). If the `cash_control` has been set to `True`, the user still needs to count his money by introducing it or using the new calculator. When trying to close the session, if it fails, depending on the error the user get, he can be redirected to the back end to manually close the session. At this point, the user is not able to open the POS UI. (In order to fully bring the closing of the session in the front end, all the error handlings need to be brought to the front end as well which takes a lot more time.) Rescue session can only be closed through the back end and the closing cash control is automatically being computed without the user's involvement. This avoid any cash profit/loss in the journals. The session chatter is being logged with all the details regarding the opening and closing. This give the user a better view of his cash flow in a specific session. task-2456424 closes odoo/odoo#73464 Signed-off-by: pimodoo --- addons/point_of_sale/__manifest__.py | 8 + .../point_of_sale/data/point_of_sale_data.xml | 91 +++++++ addons/point_of_sale/models/__init__.py | 1 + addons/point_of_sale/models/pos_bill.py | 16 ++ addons/point_of_sale/models/pos_config.py | 87 +------ addons/point_of_sale/models/pos_session.py | 225 ++++++++++++++---- .../security/ir.model.access.csv | 2 +- .../src/css/popups/cash_opening_popup.css | 36 +++ .../src/css/popups/closing_pos_popup.css | 133 +++++++++++ .../static/src/css/popups/common.css | 21 ++ .../src/css/popups/money_details_popup.css | 36 +++ addons/point_of_sale/static/src/css/pos.css | 61 +---- .../src/js/ChromeWidgets/HeaderButton.js | 19 +- .../static/src/js/Misc/CurrencyAmount.js | 13 + .../src/js/Popups/AbstractAwaitablePopup.js | 4 +- .../static/src/js/Popups/CashOpeningPopup.js | 65 +++-- .../static/src/js/Popups/ClosePosPopup.js | 165 +++++++++++++ .../static/src/js/Popups/MoneyDetailsPopup.js | 69 ++++++ .../js/Screens/ProductScreen/ProductScreen.js | 2 +- addons/point_of_sale/static/src/js/models.js | 14 ++ .../src/xml/ChromeWidgets/CashMoveButton.xml | 2 +- .../src/xml/ChromeWidgets/HeaderButton.xml | 5 +- .../static/src/xml/Misc/CurrencyAmount.xml | 10 + .../src/xml/Popups/CashOpeningPopup.xml | 57 ++--- .../static/src/xml/Popups/ClosePosPopup.xml | 101 ++++++++ .../src/xml/Popups/MoneyDetailsPopup.xml | 30 +++ .../static/tests/tours/Chrome.tour.js | 1 + .../tests/tours/ProductConfigurator.tour.js | 1 + .../static/tests/tours/TicketScreen.tour.js | 1 + .../tours/helpers/ProductScreenTourMethods.js | 3 + .../static/tests/tours/point_of_sale.js | 8 +- .../static/tests/unit/test_ChromeWidgets.js | 21 -- addons/point_of_sale/tests/common.py | 13 +- .../point_of_sale/tests/test_anglo_saxon.py | 13 +- .../tests/test_point_of_sale_flow.py | 17 +- .../tests/test_pos_basic_config.py | 42 +--- addons/point_of_sale/tests/test_pos_margin.py | 4 +- .../views/account_statement_view.xml | 24 -- .../views/point_of_sale_dashboard.xml | 6 +- addons/point_of_sale/views/pos_bill_view.xml | 45 ++++ .../point_of_sale/views/pos_config_view.xml | 24 +- .../point_of_sale/views/pos_session_view.xml | 80 +------ .../static/src/js/tours/PosCoupon1.tour.js | 1 + addons/pos_hr/static/tests/tours/PosHrTour.js | 1 + .../tests/tours/SplitBillScreen.tour.js | 1 + .../static/tests/tours/TicketScreen.tour.js | 1 + .../static/tests/tours/TipScreen.tour.js | 1 + .../static/tests/tours/pos_restaurant.js | 1 + 48 files changed, 1119 insertions(+), 463 deletions(-) create mode 100644 addons/point_of_sale/models/pos_bill.py create mode 100644 addons/point_of_sale/static/src/css/popups/cash_opening_popup.css create mode 100644 addons/point_of_sale/static/src/css/popups/closing_pos_popup.css create mode 100644 addons/point_of_sale/static/src/css/popups/common.css create mode 100644 addons/point_of_sale/static/src/css/popups/money_details_popup.css create mode 100644 addons/point_of_sale/static/src/js/Misc/CurrencyAmount.js create mode 100644 addons/point_of_sale/static/src/js/Popups/ClosePosPopup.js create mode 100644 addons/point_of_sale/static/src/js/Popups/MoneyDetailsPopup.js create mode 100644 addons/point_of_sale/static/src/xml/Misc/CurrencyAmount.xml create mode 100644 addons/point_of_sale/static/src/xml/Popups/ClosePosPopup.xml create mode 100644 addons/point_of_sale/static/src/xml/Popups/MoneyDetailsPopup.xml create mode 100644 addons/point_of_sale/views/pos_bill_view.xml diff --git a/addons/point_of_sale/__manifest__.py b/addons/point_of_sale/__manifest__.py index 33af46e6b72..2d39e2d11be 100644 --- a/addons/point_of_sale/__manifest__.py +++ b/addons/point_of_sale/__manifest__.py @@ -31,6 +31,7 @@ 'views/pos_payment_method_views.xml', 'views/pos_payment_views.xml', 'views/pos_config_view.xml', + 'views/pos_bill_view.xml', 'views/pos_session_view.xml', 'views/point_of_sale_sequence.xml', 'data/point_of_sale_data.xml', @@ -60,6 +61,10 @@ 'point_of_sale/static/src/css/keyboard.css', 'point_of_sale/static/src/css/pos_receipts.css', 'point_of_sale/static/src/css/popups/product_info_popup.css', + 'point_of_sale/static/src/css/popups/common.css', + 'point_of_sale/static/src/css/popups/cash_opening_popup.css', + 'point_of_sale/static/src/css/popups/closing_pos_popup.css', + 'point_of_sale/static/src/css/popups/money_details_popup.css', 'web/static/src/legacy/scss/fontawesome_overridden.scss', 'point_of_sale/static/lib/html2canvas.js', 'point_of_sale/static/lib/backbone/backbone.js', @@ -124,6 +129,7 @@ 'point_of_sale/static/src/js/ChromeWidgets/SaleDetailsButton.js', 'point_of_sale/static/src/js/ChromeWidgets/CashMoveButton.js', 'point_of_sale/static/src/js/ChromeWidgets/TicketButton.js', + 'point_of_sale/static/src/js/Misc/CurrencyAmount.js', 'point_of_sale/static/src/js/Misc/Draggable.js', 'point_of_sale/static/src/js/Misc/NotificationSound.js', 'point_of_sale/static/src/js/Misc/IndependentToOrderScreen.js', @@ -148,6 +154,8 @@ 'point_of_sale/static/src/js/Popups/CashOpeningPopup.js', 'point_of_sale/static/src/js/Popups/ProductInfoPopup.js', 'point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/OrderlineCustomerNoteButton.js', + 'point_of_sale/static/src/js/Popups/ClosePosPopup.js', + 'point_of_sale/static/src/js/Popups/MoneyDetailsPopup.js', 'point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SetPricelistButton.js', 'point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SetFiscalPositionButton.js', 'point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/ProductInfoButton.js', diff --git a/addons/point_of_sale/data/point_of_sale_data.xml b/addons/point_of_sale/data/point_of_sale_data.xml index ea61776fb73..7e3b34d16b3 100644 --- a/addons/point_of_sale/data/point_of_sale_data.xml +++ b/addons/point_of_sale/data/point_of_sale_data.xml @@ -46,6 +46,97 @@ True + + + 0.01 + 0.01 + + + + + 0.02 + 0.02 + + + + + 0.05 + 0.05 + + + + + 0.10 + 0.10 + + + + + 0.20 + 0.20 + + + + + 0.50 + 0.50 + + + + + 1.00 + 1.00 + + + + + 2.00 + 2.00 + + + + + 5.00 + 5.00 + + + + + 10.00 + 10.00 + + + + + 20.00 + 20.00 + + + + + 50.00 + 50.00 + + + + + 100.00 + 100.00 + + + + + 200.00 + 200.00 + + + + + 500.00 + 500.00 + + + diff --git a/addons/point_of_sale/models/__init__.py b/addons/point_of_sale/models/__init__.py index 83aaa1cdc15..2123edf1ca5 100644 --- a/addons/point_of_sale/models/__init__.py +++ b/addons/point_of_sale/models/__init__.py @@ -21,3 +21,4 @@ from . import stock_picking from . import stock_warehouse from . import pos_payment from . import pos_payment_method +from . import pos_bill diff --git a/addons/point_of_sale/models/pos_bill.py b/addons/point_of_sale/models/pos_bill.py new file mode 100644 index 00000000000..0374a1c6dcd --- /dev/null +++ b/addons/point_of_sale/models/pos_bill.py @@ -0,0 +1,16 @@ +from odoo import api, fields, models + + +class Bill(models.Model): + _name = "pos.bill" + _order = "value" + _description = "Coins/Bills" + + name = fields.Char("Name") + value = fields.Float("Coin/Bill Value", required=True, digits=0) + pos_config_ids = fields.Many2many("pos.config") + + @api.model + def name_create(self, name): + result = super().create({"name": name, "value": float(name)}) + return result.name_get()[0] diff --git a/addons/point_of_sale/models/pos_config.py b/addons/point_of_sale/models/pos_config.py index 464063ed22b..e26e3e43789 100644 --- a/addons/point_of_sale/models/pos_config.py +++ b/addons/point_of_sale/models/pos_config.py @@ -9,69 +9,6 @@ from odoo import api, fields, models, tools, _ from odoo.exceptions import ValidationError, UserError -class AccountBankStmtCashWizard(models.Model): - _inherit = 'account.bank.statement.cashbox' - - @api.depends('pos_config_ids') - @api.depends_context('current_currency_id') - def _compute_currency(self): - super(AccountBankStmtCashWizard, self)._compute_currency() - for cashbox in self: - if cashbox.pos_config_ids: - cashbox.currency_id = cashbox.pos_config_ids[0].currency_id.id - elif self.env.context.get('current_currency_id'): - cashbox.currency_id = self.env.context.get('current_currency_id') - - pos_config_ids = fields.One2many('pos.config', 'default_cashbox_id') - is_a_template = fields.Boolean(default=False) - - @api.model - def default_get(self, fields): - vals = super(AccountBankStmtCashWizard, self).default_get(fields) - if 'cashbox_lines_ids' not in fields: - return vals - config_id = self.env.context.get('default_pos_id') - if config_id: - config = self.env['pos.config'].browse(config_id) - if config.last_session_closing_cashbox.cashbox_lines_ids: - lines = config.last_session_closing_cashbox.cashbox_lines_ids - else: - lines = config.default_cashbox_id.cashbox_lines_ids - if self.env.context.get('balance', False) == 'start': - vals['cashbox_lines_ids'] = [[0, 0, {'coin_value': line.coin_value, 'number': line.number, 'subtotal': line.subtotal}] for line in lines] - else: - vals['cashbox_lines_ids'] = [[0, 0, {'coin_value': line.coin_value, 'number': 0, 'subtotal': 0.0}] for line in lines] - return vals - - def _validate_cashbox(self): - super(AccountBankStmtCashWizard, self)._validate_cashbox() - session_id = self.env.context.get('pos_session_id') - if session_id: - current_session = self.env['pos.session'].browse(session_id) - if current_session.state == 'new_session': - current_session.write({'state': 'opening_control'}) - - def set_default_cashbox(self): - self.ensure_one() - current_session = self.env['pos.session'].browse(self.env.context['pos_session_id']) - lines = current_session.config_id.default_cashbox_id.cashbox_lines_ids - context = dict(self._context) - self.cashbox_lines_ids.unlink() - self.cashbox_lines_ids = [[0, 0, {'coin_value': line.coin_value, 'number': line.number, 'subtotal': line.subtotal}] for line in lines] - - return { - 'name': _('Cash Control'), - 'view_type': 'form', - 'view_mode': 'form', - 'res_model': 'account.bank.statement.cashbox', - 'view_id': self.env.ref('point_of_sale.view_account_bnk_stmt_cashbox_footer').id, - 'type': 'ir.actions.act_window', - 'context': context, - 'target': 'new', - 'res_id': self.id, - } - - class PosConfig(models.Model): _name = 'pos.config' _description = 'Point of Sale Configuration' @@ -145,7 +82,8 @@ class PosConfig(models.Model): help="The product categories will be displayed with pictures.") restrict_price_control = fields.Boolean(string='Restrict Price Modifications to Managers', help="Only users with Manager access rights for PoS app can modify the product prices on orders.") - cash_control = fields.Boolean(string='Advanced Cash Control', help="Check the amount of the cashbox at opening and closing.") + cash_control = fields.Boolean(string='Advanced Cash Control', compute='_compute_cash_control', help="Check the amount of the cashbox at opening and closing.") + set_maximum_difference = fields.Boolean('Set Maximum Difference', help="Set a maximum difference allowed between the expected and counted cash during the closing of the session.") receipt_header = fields.Text(string='Receipt Header', help="A short text that will be inserted as a header in the printed receipt.") receipt_footer = fields.Text(string='Receipt Footer', help="A short text that will be inserted as a footer in the printed receipt.") proxy_ip = fields.Char(string='IP Address', size=45, @@ -165,7 +103,6 @@ class PosConfig(models.Model): number_of_opened_session = fields.Integer(string="Number of Opened Session", compute='_compute_current_session') last_session_closing_cash = fields.Float(compute='_compute_last_session') last_session_closing_date = fields.Date(compute='_compute_last_session') - last_session_closing_cashbox = fields.Many2one('account.bank.statement.cashbox', compute='_compute_last_session') pos_session_username = fields.Char(compute='_compute_current_session_user') pos_session_state = fields.Char(compute='_compute_current_session_user') pos_session_duration = fields.Char(compute='_compute_current_session_user') @@ -192,7 +129,7 @@ class PosConfig(models.Model): help="This product is used as reference on customer receipts.") fiscal_position_ids = fields.Many2many('account.fiscal.position', string='Fiscal Positions', help='This is useful for restaurants with onsite and take-away services that imply specific tax rates.') default_fiscal_position_id = fields.Many2one('account.fiscal.position', string='Default Fiscal Position') - default_cashbox_id = fields.Many2one('account.bank.statement.cashbox', string='Default Balance') + default_bill_ids = fields.Many2many('pos.bill', string="Coins/Bills") use_pricelist = fields.Boolean("Use a pricelist.") tax_regime = fields.Boolean("Tax Regime") tax_regime_selection = fields.Boolean("Tax Regime Selection value") @@ -242,6 +179,11 @@ class PosConfig(models.Model): limited_partners_amount = fields.Integer(default=100) partner_load_background = fields.Boolean() + @api.depends('payment_method_ids') + def _compute_cash_control(self): + for config in self: + config.cash_control = bool(config.payment_method_ids.filtered('is_cash_count')) + @api.depends('use_pricelist', 'available_pricelist_ids') def _compute_allowed_pricelist_ids(self): for config in self: @@ -297,14 +239,11 @@ class PosConfig(models.Model): pos_config.last_session_closing_date = session[0]['stop_at'].astimezone(timezone).date() if session[0]['cash_register_id']: pos_config.last_session_closing_cash = session[0]['cash_register_balance_end_real'] - pos_config.last_session_closing_cashbox = self.env['account.bank.statement'].browse(session[0]['cash_register_id'][0]).cashbox_end_id else: pos_config.last_session_closing_cash = 0 - pos_config.last_session_closing_cashbox = False else: pos_config.last_session_closing_cash = 0 pos_config.last_session_closing_date = False - pos_config.last_session_closing_cashbox = False @api.depends('session_ids') def _compute_current_session_user(self): @@ -336,12 +275,6 @@ class PosConfig(models.Model): for config in self: config.iface_customer_facing_display = config.iface_customer_facing_display_via_proxy or config.iface_customer_facing_display_local - @api.constrains('cash_control') - def _check_session_state(self): - open_session = self.env['pos.session'].search([('config_id', 'in', self.ids), ('state', '!=', 'closed')], limit=1) - if open_session: - raise ValidationError(_("You are not allowed to change the cash control status while a session is already opened.")) - @api.constrains('rounding_method') def _check_rounding_method_strategy(self): for config in self: @@ -536,7 +469,7 @@ class PosConfig(models.Model): return result def _get_forbidden_change_fields(self): - forbidden_keys = ['module_pos_hr', 'cash_control', 'module_pos_restaurant', 'available_pricelist_ids', + forbidden_keys = ['module_pos_hr', 'module_pos_restaurant', 'available_pricelist_ids', 'limit_categories', 'iface_available_categ_ids', 'use_pricelist', 'module_pos_discount', 'payment_method_ids', 'iface_tipproduc'] return forbidden_keys @@ -609,7 +542,7 @@ class PosConfig(models.Model): """ self.ensure_one() # check all constraints, raises if any is not met - self._validate_fields(set(self._fields) - {"cash_control"}) + self._validate_fields(self._fields) return { 'type': 'ir.actions.act_url', 'url': self._get_pos_base_url() + '?config_id=%d' % self.id, diff --git a/addons/point_of_sale/models/pos_session.py b/addons/point_of_sale/models/pos_session.py index dfc30556bc3..faaf4bf3b25 100644 --- a/addons/point_of_sale/models/pos_session.py +++ b/addons/point_of_sale/models/pos_session.py @@ -49,6 +49,7 @@ class PosSession(models.Model): sequence_number = fields.Integer(string='Order Sequence Number', help='A sequence number that is incremented with each order', default=1) login_number = fields.Integer(string='Login Sequence Number', help='A sequence number that is incremented each time a user resumes the pos session', default=0) + opening_notes = fields.Text(string="Opening Notes") cash_control = fields.Boolean(compute='_compute_cash_all', string='Has Cash Control', compute_sudo=True) cash_journal_id = fields.Many2one('account.journal', compute='_compute_cash_all', string='Cash Journal', store=True) cash_register_id = fields.Many2one('account.bank.statement', compute='_compute_cash_all', string='Cash Register', store=True) @@ -253,7 +254,7 @@ class PosSession(models.Model): def action_pos_session_open(self): # second browse because we need to refetch the data from the DB for cash_register_id # we only open sessions that haven't already been opened - for session in self.filtered(lambda session: session.state in ('new_session', 'opening_control')): + for session in self.filtered(lambda session: session.state == 'opening_control'): values = {} if not session.start_at: values['start_at'] = fields.Datetime.now() @@ -277,6 +278,18 @@ class PosSession(models.Model): session.write({'state': 'closing_control', 'stop_at': fields.Datetime.now()}) if not session.config_id.cash_control: return session.action_pos_session_close(balancing_account, amount_to_balance) + # If the session is in rescue, we only compute the payments in the cash register + # It is not yet possible to close a rescue session through the front end, see `close_session_from_ui` + if session.rescue and session.config_id.cash_control: + default_cash_payment_method_id = self.payment_method_ids.filtered(lambda pm: pm.type == 'cash')[0] + orders = self.order_ids.filtered(lambda o: o.state == 'paid' or o.state == 'invoiced') + total_cash = sum( + orders.payment_ids.filtered(lambda p: p.payment_method_id == default_cash_payment_method_id).mapped('amount') + ) + self.cash_register_balance_start + + session.cash_register_id.balance_end_real = total_cash + + return session.action_pos_session_validate(balancing_account, amount_to_balance) def _check_pos_session_balance(self): for session in self: @@ -284,29 +297,16 @@ class PosSession(models.Model): if (statement != session.cash_register_id) and (statement.balance_end != statement.balance_end_real): statement.write({'balance_end_real': statement.balance_end}) - def action_pos_session_validate(self): + def action_pos_session_validate(self, balancing_account=False, amount_to_balance=0): self._check_pos_session_balance() - return self.action_pos_session_close() + return self.action_pos_session_close(balancing_account, amount_to_balance) def action_pos_session_close(self, balancing_account=False, amount_to_balance=0): # Session without cash payment method will not have a cash register. # However, there could be other payment methods, thus, session still # needs to be validated. self._check_bank_statement_state() - if not self.cash_register_id: - return self._validate_session(balancing_account, amount_to_balance) - - if self.cash_control and abs(self.cash_register_difference) > self.config_id.amount_authorized_diff: - # Only pos manager can close statements with cash_register_difference greater than amount_authorized_diff. - if not self.user_has_groups("point_of_sale.group_pos_manager"): - raise UserError(_( - "Your ending balance is too different from the theoretical cash closing (%.2f), " - "the maximum allowed is: %.2f. You can contact your manager to force it." - ) % (self.cash_register_difference, self.config_id.amount_authorized_diff)) - else: - return self._warning_balance_closing() - else: - return self._validate_session(balancing_account, amount_to_balance) + return self._validate_session(balancing_account, amount_to_balance) def _validate_session(self, balancing_account=False, amount_to_balance=0): self.ensure_one() @@ -378,6 +378,142 @@ class PosSession(models.Model): 'context': {**self.env.context, 'active_ids': self.ids, 'active_model': 'pos.session'}, } + def close_session_from_ui(self): + """Calling this method will try to close the session. + + If successful, it returns {'successful': True} + Otherwise, it returns {'successful': False, 'message': str, 'redirect': bool}. + 'redirect' is a boolean used to know whether we redirect the user to the back end or not. + When necessary, error (i.e. UserError, AccessError) is raised which should redirect the user to the back end. + """ + self.ensure_one() + # Even if this is called in `post_closing_cash_details`, we need to call this here too for case + # where cash_control = False + check_closing_session = self._cannot_close_session() + if check_closing_session: + return check_closing_session + + # For now we won't simply do + # self._check_pos_session_balance() + # self._check_bank_statement_state() + # validate_result = self._validate_session() + # because some functions are being used and overridden in other modules... + # so we'll try to use the original flow as of now for the moment + validate_result = self.action_pos_session_closing_control() + + # If an error is raised, the user will still be redirected to the back end to manually close the session. + # If the return result is a dict, this means that normally we have a redirection or a wizard => we redirect the user + if isinstance(validate_result, dict): + # imbalance accounting entry + return { + 'successful': False, + 'message': validate_result.get('name'), + 'redirect': True + } + + self.message_post(body='Point of Sale Session ended') + + return {'successful': True} + + def update_closing_control_state_session(self, notes): + # Prevent the session to be opened again. + self.write({'state': 'closing_control', 'stop_at': fields.Datetime.now()}) + self._post_cash_details_message('Closing', self.cash_register_difference, notes) + + def post_closing_cash_details(self, counted_cash): + """ + Calling this method will try store the cash details during the session closing. + + :param counted_cash: float, the total cash the user counted from its cash register + If successful, it returns {'successful': True} + Otherwise, it returns {'successful': False, 'message': str, 'redirect': bool}. + 'redirect' is a boolean used to know whether we redirect the user to the back end or not. + When necessary, error (i.e. UserError, AccessError) is raised which should redirect the user to the back end. + """ + self.ensure_one() + check_closing_session = self._cannot_close_session() + if check_closing_session: + return check_closing_session + + if not self.cash_register_id: + # The user is blocked anyway, this user error is mostly for developers that try to call this function + raise UserError(_("There is no cash register in this session.")) + + self.cash_register_id.balance_end_real = counted_cash + + # No need to check cash_control because it should be True at this point + if self.config_id.set_maximum_difference and abs(self.cash_register_difference) > self.config_id.amount_authorized_diff: + if not self.user_has_groups("point_of_sale.group_pos_manager"): + # We are not raising this as an error because we want the details to persist. + # It will become the starting point on next attempt to close the session. + return { + 'successful': False, + 'message': _( + "Your ending balance is too different from the theoretical cash closing (%.2f), " + "the maximum allowed is: %.2f.\n You can contact your manager to force it." + ) % (self.cash_register_difference, self.config_id.amount_authorized_diff), + 'redirect': False + } + + return {'successful': True} + + def _cannot_close_session(self): + """ + Add check in this method if you want to return or raise an error when trying to either post cash details + or close the session. Raising an error will always redirect the user to the back end. + It should return {'successful': False, 'message': str, 'redirect': bool} if we can't close the session + """ + if any(order.state == 'draft' for order in self.order_ids): + return {'successful': False, 'message': _("You cannot close the POS when orders are still in draft"), 'redirect': False} + if self.state == 'closed': + return {'successful': False, 'message': _("This session is already closed."), 'redirect': True} + + def get_closing_control_data(self): + self.ensure_one() + orders = self.order_ids.filtered(lambda o: o.state == 'paid' or o.state == 'invoiced') + payments = orders.payment_ids.filtered(lambda p: p.payment_method_id.type != "pay_later") + pay_later_payments = orders.payment_ids - payments + cash_payment_method_ids = self.payment_method_ids.filtered(lambda pm: pm.type == 'cash') + default_cash_payment_method_id = cash_payment_method_ids[0] if cash_payment_method_ids else None + total_default_cash_payment_amount = sum(payments.filtered(lambda p: p.payment_method_id == default_cash_payment_method_id).mapped('amount')) if default_cash_payment_method_id else 0 + other_payment_method_ids = self.payment_method_ids - default_cash_payment_method_id if default_cash_payment_method_id else self.payment_method_ids + cash_in_count = 0 + cash_out_count = 0 + cash_in_out_list = [] + for cash_move in self.cash_register_id.line_ids.sorted('create_date'): + if cash_move.amount > 0: + cash_in_count += 1 + name = f'Cash in {cash_in_count}' + else: + cash_out_count += 1 + name = f'Cash out {cash_out_count}' + cash_in_out_list.append({ + 'name': cash_move.payment_ref if cash_move.payment_ref else name, + 'amount': cash_move.amount + }) + + return { + 'orders_details': { + 'quantity': len(orders), + 'amount': sum(orders.mapped('amount_total')) + }, + 'payments_amount': sum(payments.mapped('amount')), + 'pay_later_amount': sum(pay_later_payments.mapped('amount')), + 'opening_notes': self.opening_notes, + 'default_cash_details': { + 'name': default_cash_payment_method_id.name, + 'amount': self.cash_register_id.balance_start + total_default_cash_payment_amount + + sum(self.cash_register_id.line_ids.mapped('amount')), + 'opening': self.cash_register_id.balance_start, + 'payment_amount': total_default_cash_payment_amount, + 'moves': cash_in_out_list + } if default_cash_payment_method_id else None, + 'other_payment_methods': [{ + 'name': pm.name, + 'amount': sum(orders.payment_ids.filtered(lambda p: p.payment_method_id == pm).mapped('amount')) + } for pm in other_payment_method_ids] + } + def _create_picking_at_end_of_session(self): self.ensure_one() lines_grouped_by_dest_location = {} @@ -1230,23 +1366,29 @@ class PosSession(models.Model): 'url': self.config_id._get_pos_base_url() + '?config_id=%d' % self.config_id.id, } - def open_cashbox_pos(self): - self.ensure_one() - action = self.cash_register_id.open_cashbox_id() - action['view_id'] = self.env.ref('point_of_sale.view_account_bnk_stmt_cashbox_footer').id - action['context']['pos_session_id'] = self.id - action['context']['default_pos_id'] = self.config_id.id - return action - def set_cashbox_pos(self, cashbox_value, notes): self.state = 'opened' + self.opening_notes = notes + difference = cashbox_value - self.cash_register_id.balance_start self.cash_register_id.balance_start = cashbox_value + self._post_cash_details_message('Opening', difference, notes) + + def _post_cash_details_message(self, state, difference, notes): + message = "" + if difference: + message = f"{state} difference: " \ + f"{self.currency_id.symbol + ' ' if self.currency_id.position == 'before' else ''}" \ + f"{self.currency_id.round(difference)} " \ + f"{self.currency_id.symbol if self.currency_id.position == 'after' else ''}
" if notes: + message += notes.replace('\n', '
') + if message: self.env['mail.message'].create({ - 'body': notes, + 'body': message, 'model': 'account.bank.statement', 'res_id': self.cash_register_id.id, }) + self.message_post(body=message) def action_view_order(self): return { @@ -1277,23 +1419,6 @@ class PosSession(models.Model): ) ) - def _warning_balance_closing(self): - self.ensure_one() - - context = dict(self._context) - context['session_id'] = self.id - - return { - 'name': _('Balance control'), - 'view_type': 'form', - 'view_mode': 'form', - 'res_model': 'closing.balance.confirm.wizard', - 'views': [(False, 'form')], - 'type': 'ir.actions.act_window', - 'context': context, - 'target': 'new' - } - def _check_if_no_draft_orders(self): draft_orders = self.order_ids.filtered(lambda order: order.state == 'draft') if draft_orders: @@ -1310,7 +1435,11 @@ class PosSession(models.Model): .with_context({'active_model': 'pos.session', 'active_ids': self.ids})\ .create({'amount': sign * amount, 'name': reason})\ .run() - self.message_post(body='
\n'.join([f"Cash {extras['translatedType']}", f'- Amount: {extras["formattedAmount"]}', f'- Reason: {reason}'])) + message_content = [f"Cash {extras['translatedType']}", f'- Amount: {extras["formattedAmount"]}'] + if reason: + message_content.append(f'- Reason: {reason}') + self.message_post(body='
\n'.join(message_content)) + class ProcurementGroup(models.Model): _inherit = 'procurement.group' @@ -1321,11 +1450,3 @@ class ProcurementGroup(models.Model): self.env['pos.session']._alert_old_session() if use_new_cursor: self.env.cr.commit() - -class ClosingBalanceConfirm(models.TransientModel): - _name = 'closing.balance.confirm.wizard' - _description = 'This wizard is used to display a warning message if the manager wants to close a session with a too high difference between real and expected closing balance' - - def confirm_closing_balance(self): - current_session = self.env['pos.session'].browse(self._context['session_id']) - return current_session._validate_session() diff --git a/addons/point_of_sale/security/ir.model.access.csv b/addons/point_of_sale/security/ir.model.access.csv index fce7173b119..0197886321c 100644 --- a/addons/point_of_sale/security/ir.model.access.csv +++ b/addons/point_of_sale/security/ir.model.access.csv @@ -47,9 +47,9 @@ access_decimal_precision_user,decimal.precision,base.model_decimal_precision,gro access_pos_payment_user,pos.payment user,model_pos_payment,group_pos_user,1,1,1,1 access_pos_payment_method_user,pos.payment.method user,model_pos_payment_method,group_pos_user,1,0,0,0 access_pos_payment_method_manager,pos.payment.method manager,model_pos_payment_method,group_pos_manager,1,1,1,1 -access_closing_balance_confirm_wizard,access.closing.balance.confirm.wizard,model_closing_balance_confirm_wizard,point_of_sale.group_pos_manager,1,1,1,0 access_pos_details_wizard,access.pos.details.wizard,model_pos_details_wizard,point_of_sale.group_pos_manager,1,1,1,0 access_pos_make_payment,access.pos.make.payment,model_pos_make_payment,point_of_sale.group_pos_manager,1,1,1,0 access_money_in_out_wizard,access.money.in.out.wizard,model_cash_box_out,point_of_sale.group_pos_user,1,1,1,0 access_pos_close_session_wizard,access.pos.close.session.wizard,model_pos_close_session_wizard,point_of_sale.group_pos_user,1,1,1,0 access_account_cash_rounding_pos_user,account.cash.rounding pos_user,account.model_account_cash_rounding,group_pos_user,1,0,0,0 +access_pos_bill,pos.bill pos_user,model_pos_bill,group_pos_user,1,1,1,1 diff --git a/addons/point_of_sale/static/src/css/popups/cash_opening_popup.css b/addons/point_of_sale/static/src/css/popups/cash_opening_popup.css new file mode 100644 index 00000000000..aa1a4b97e7b --- /dev/null +++ b/addons/point_of_sale/static/src/css/popups/cash_opening_popup.css @@ -0,0 +1,36 @@ +.pos .opening-cash-control .body{ + margin: 40px; +} + +.pos .opening-cash-control .opening-cash-section { + display: flex; + justify-content: space-between; + margin: 40px 0; +} + +.pos .opening-cash-section .info-title { + font-size: 20px; + font-weight: bold; +} + +.pos .opening-cash-section .cash-input-sub-section { + display: flex; +} + +.pos .cash-input-sub-section .pos-input { + width: 85px; +} + +.pos .cash-input-sub-section .button.icon { + margin: 0; + float: unset; +} + +.pos .opening-cash-control .opening-cash-notes { + font-style: italic; + font-weight: 350; + width: calc(100% - 20px); /* textarea has a padding of 10px */ + line-height: 20px; + resize: none; + height: 150px; +} diff --git a/addons/point_of_sale/static/src/css/popups/closing_pos_popup.css b/addons/point_of_sale/static/src/css/popups/closing_pos_popup.css new file mode 100644 index 00000000000..0d5c3591bcb --- /dev/null +++ b/addons/point_of_sale/static/src/css/popups/closing_pos_popup.css @@ -0,0 +1,133 @@ +.pos .close-pos-popup { + max-width: 800px !important; + max-height: 800px; +} + +.pos .close-pos-popup .body{ + margin: 0; + padding: 15px 50px; +} + +.pos .close-pos-popup .session-overview { + display: flex; + margin-bottom: 30px; +} + +.pos .close-pos-popup .overview-info { + width: 50%; + display: flex; +} + +.pos .close-pos-popup .overview-info .info-value { + margin-left: 50px; +} + +.pos .close-pos-popup .overview-info span { + margin-bottom: 10px; +} + +.pos .close-pos-popup .column-left { + display: flex; + flex-direction: column; + text-align: left; +} + +.pos .close-pos-popup .session-overview .opening-note { + margin-left: 70px; + color: darkgrey; + text-align: left; + font-size: 16px; + width: 250px; + max-height: 90px; + padding-left: 10px; + border-left: solid 3px darkgrey; + overflow-y: auto; + word-break: break-word; +} + +.pos .close-pos-popup .payment-methods-overview { + max-height: 370px; + overflow: auto; +} + +.pos .close-pos-popup .payment-methods-overview table { + width: 100%; + text-align: left; +} + +.pos .close-pos-popup .payment-methods-overview table tr { + height: 35px +} + +.pos .close-pos-popup .flex { + display: flex; +} + +.pos .close-pos-popup .payment-methods-overview table .pos-input { + width: 85px; +} + +.pos .close-pos-popup .payment-methods-overview table .warning { + color: red; + font-weight: bold; +} + +.pos .close-pos-popup .body .button.icon { + margin: 0; + float: unset; +} + +.pos .close-pos-popup .payment-methods-overview table .cash-sign { + width: 10px; + margin-right: 5px; +} + +.pos .close-pos-popup .payment-methods-overview .cash-overview { + border-left: solid 2px #555555; +} + +.pos .close-pos-popup .payment-methods-overview .cash-overview tr td:first-child { + padding-left: 10px; +} + +.pos .close-pos-popup .closing-notes { + font-style: italic; + font-weight: 350; + width: calc(100% - 20px); /* textarea has a padding of 10px */ + line-height: 20px; + resize: none; + height: 70px; + margin: 20px 0; +} + +.pos .close-pos-popup .accept-closing { + text-align: left; +} + +.pos .close-pos-popup .accept-closing.invisible { + visibility: hidden; +} + +.pos .close-pos-popup .accept-closing label, #accept { + cursor: pointer; +} + +.pos .close-pos-popup #accept { + all: revert; + margin-right: 10px; +} + +.pos .close-pos-popup .footer .button { + width: 150px; +} + +.pos .close-pos-popup .footer .button.disabled { + cursor: default; + background: bottom; + color: unset; + border: solid 1px rgba(60, 60, 60, 0.1); +} + +.pos .close-pos-popup .footer .button.disabled:active { + border: solid 1px rgba(60, 60, 60, 0.1); +} diff --git a/addons/point_of_sale/static/src/css/popups/common.css b/addons/point_of_sale/static/src/css/popups/common.css new file mode 100644 index 00000000000..9d206dd5ba6 --- /dev/null +++ b/addons/point_of_sale/static/src/css/popups/common.css @@ -0,0 +1,21 @@ +/* Input style used in cash control popups */ +.pos .popup .pos-input { + text-align: center; + font-size: 18px; + color: #555555; + background: none; + min-height: 0; + border-radius: unset; + box-shadow: none; + padding: 0; + cursor: pointer; + width: 50px; + margin-right: 15px; + border-bottom: solid 2px; +} + +.pos .popup .pos-input:focus { + box-shadow: none ; + border-color: blue; + font-weight: bold; +} diff --git a/addons/point_of_sale/static/src/css/popups/money_details_popup.css b/addons/point_of_sale/static/src/css/popups/money_details_popup.css new file mode 100644 index 00000000000..5f23d374ac0 --- /dev/null +++ b/addons/point_of_sale/static/src/css/popups/money_details_popup.css @@ -0,0 +1,36 @@ +.pos .popup.money-details { + width: 350px; + max-height: 400px; +} + +.pos .money-details.invisible { + display: none; +} + +.pos .money-details .body { + max-height: 306px; +} + +.pos .money-details .money-details-title { + text-align: left; + margin-bottom: 15px; + font-weight: bold; +} + +.pos .money-details .money-details-info { + margin-bottom: 20px; + display: flex; + justify-content: space-between; + overflow: auto; + max-height: 235px; + padding: 0 15px; +} + +.pos .money-details .money-details-value { + display: flex; + margin: 10px 0; +} + +.pos .money-details .total-section { + font-weight: bold; +} diff --git a/addons/point_of_sale/static/src/css/pos.css b/addons/point_of_sale/static/src/css/pos.css index f53c6bd20d3..66b81720ceb 100644 --- a/addons/point_of_sale/static/src/css/pos.css +++ b/addons/point_of_sale/static/src/css/pos.css @@ -497,11 +497,6 @@ td { background: rgba(0,0,0,0.2); color:#EEE; } -.pos .pos-rightheader .header-button.confirm { - background: #359766; - color: white; - font-weight: bold; -} /* c) The notifications indicator */ @@ -3423,6 +3418,19 @@ td { display: none; } +.cash-move-button { + display: flex; + align-items: center; + padding: 0 15px; + font-size: medium; + color: white; +} + +.cash-move-button:hover { + background: rgb(104,69,95); + cursor: pointer; +} + /* Product Screen Search Bar */ .search-bar-portal { @@ -3600,49 +3608,6 @@ td { } } -.pos .cashbox-button { - height: 54px; - width: 54px; - font-weight: bold; - vertical-align: middle; - color: #555555; - border-radius: 0; - border: none; - border-right: 1px solid; - border-bottom: 1px solid; - border-color: #bfbfbf; - transition: all 150ms linear; -} - -.pos .cashbox-input { - line-height: 74px; - border: solid 1px rgb(202, 202, 202); - border-top-width: 0px; - cursor: pointer; - background: white; - display: flex; - justify-content: space-between; -} - -.cashbox-input .input-title { - margin-left: 10px; - margin-right: 10px; - font-weight: bold; - font-size: 19px; -} - -.cashbox-input .input-amount { - margin-left: 10px; - margin-right: 10px; - padding-right: 8px; -} - -.cashbox-notes { - font-style: italic; - font-weight: 350; - width: 95%; -} - /** * Notification */ diff --git a/addons/point_of_sale/static/src/js/ChromeWidgets/HeaderButton.js b/addons/point_of_sale/static/src/js/ChromeWidgets/HeaderButton.js index 84036ecbc75..b394f967b5a 100644 --- a/addons/point_of_sale/static/src/js/ChromeWidgets/HeaderButton.js +++ b/addons/point_of_sale/static/src/js/ChromeWidgets/HeaderButton.js @@ -1,31 +1,14 @@ odoo.define('point_of_sale.HeaderButton', function(require) { 'use strict'; - const { useState } = owl; const PosComponent = require('point_of_sale.PosComponent'); const Registries = require('point_of_sale.Registries'); // Previously HeaderButtonWidget // This is the close session button class HeaderButton extends PosComponent { - constructor() { - super(...arguments); - this.state = useState({ label: 'Close' }); - this.confirmed = null; - } - get translatedLabel() { - return this.env._t(this.state.label); - } onClick() { - if (!this.confirmed) { - this.state.label = 'Confirm'; - this.confirmed = setTimeout(() => { - this.state.label = 'Close'; - this.confirmed = null; - }, 2000); - } else { - this.trigger('close-pos'); - } + this.showPopup('ClosePosPopup'); } } HeaderButton.template = 'HeaderButton'; diff --git a/addons/point_of_sale/static/src/js/Misc/CurrencyAmount.js b/addons/point_of_sale/static/src/js/Misc/CurrencyAmount.js new file mode 100644 index 00000000000..e7ced094b69 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Misc/CurrencyAmount.js @@ -0,0 +1,13 @@ +odoo.define('point_of_sale.CurrencyAmount', function(require) { + 'use strict'; + + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + class CurrencyAmount extends PosComponent {} + CurrencyAmount.template = 'CurrencyAmount'; + + Registries.Component.add(CurrencyAmount); + + return CurrencyAmount; +}); diff --git a/addons/point_of_sale/static/src/js/Popups/AbstractAwaitablePopup.js b/addons/point_of_sale/static/src/js/Popups/AbstractAwaitablePopup.js index 6cdd6a04bab..fbbcaa10d71 100644 --- a/addons/point_of_sale/static/src/js/Popups/AbstractAwaitablePopup.js +++ b/addons/point_of_sale/static/src/js/Popups/AbstractAwaitablePopup.js @@ -32,7 +32,9 @@ odoo.define('point_of_sale.AbstractAwaitablePopup', function (require) { class AbstractAwaitablePopup extends PosComponent { constructor() { super(...arguments); - useExternalListener(window, 'keyup', this._cancelAtEscape); + if (!this.props.notEscapable) { + useExternalListener(window, 'keyup', this._cancelAtEscape); + } } async confirm() { this.props.resolve({ confirmed: true, payload: await this.getPayload() }); diff --git a/addons/point_of_sale/static/src/js/Popups/CashOpeningPopup.js b/addons/point_of_sale/static/src/js/Popups/CashOpeningPopup.js index 53578f4689a..0c5f4e82be1 100644 --- a/addons/point_of_sale/static/src/js/Popups/CashOpeningPopup.js +++ b/addons/point_of_sale/static/src/js/Popups/CashOpeningPopup.js @@ -1,10 +1,7 @@ odoo.define('point_of_sale.CashOpeningPopup', function(require) { 'use strict'; - const { useState, useRef} = owl.hooks; - const { useListener } = require('web.custom_hooks'); - const NumberBuffer = require('point_of_sale.NumberBuffer'); - const PosComponent = require('point_of_sale.PosComponent'); + const { useState, useRef } = owl.hooks; const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup'); const Registries = require('point_of_sale.Registries'); @@ -12,52 +9,44 @@ odoo.define('point_of_sale.CashOpeningPopup', function(require) { class CashOpeningPopup extends AbstractAwaitablePopup { constructor() { super(...arguments); - this.cashBoxValue = this.env.pos.bank_statement.balance_start || 0;; - this.currency = this.env.pos.currency; + this.manualInputCashCount = null; this.state = useState({ notes: "", + openingCash: this.env.pos.bank_statement.balance_start || 0, }); - useListener('numpad-click-input', this._updateCashAmount); - useListener('update-cash', this._updateCashAmount); - this.inputRef = useRef('input'); - NumberBuffer.use({ - nonKeyboardInputEvent: 'numpad-click-input', - triggerAtInput: 'update-cash', - useWithBarcode: false, - }); + this.moneyDetailsRef = useRef('moneyDetails'); + } + openDetailsPopup() { + if (this.moneyDetailsRef.comp.isClosed()){ + this.moneyDetailsRef.comp.openPopup(); + this.state.openingCash = 0; + this.state.notes = ""; + if (this.manualInputCashCount) { + this.moneyDetailsRef.comp.reset(); + } + } } - startSession() { - this.env.pos.bank_statement.balance_start = parseFloat(this.cashBoxValue); + this.env.pos.bank_statement.balance_start = this.state.openingCash; this.env.pos.pos_session.state = 'opened'; this.rpc({ model: 'pos.session', method: 'set_cashbox_pos', - args: [this.env.pos.pos_session.id, parseFloat(this.cashBoxValue), this.state.notes], + args: [this.env.pos.pos_session.id, this.state.openingCash, this.state.notes], }); - this.trigger('close-popup'); + this.cancel(); // close popup } - - sendInput(value) { - this.trigger('numpad-click-input', { value }); - } - - async _updateCashAmount(event) { - let value = event.detail.value ? event.detail.value : event.detail.key - if(value !== "Backspace") { - if(this.cashBoxValue === 0) { - this.cashBoxValue = value !== "."? value: this.cashBoxValue + value; - } else { - this.cashBoxValue += value; - } - } else { - if(this.cashBoxValue.length > 1) { - this.cashBoxValue = this.cashBoxValue.substring(0, this.cashBoxValue.length -1) - } else { - this.cashBoxValue = 0; - } + updateCashOpening(event) { + const { total, moneyDetailsNotes } = event.detail; + this.state.openingCash = total; + if (moneyDetailsNotes) { + this.state.notes = moneyDetailsNotes; } - this.render(); + this.manualInputCashCount = false; + } + handleInputChange() { + this.manualInputCashCount = true; + this.state.notes = ""; } } diff --git a/addons/point_of_sale/static/src/js/Popups/ClosePosPopup.js b/addons/point_of_sale/static/src/js/Popups/ClosePosPopup.js new file mode 100644 index 00000000000..41d8880f68a --- /dev/null +++ b/addons/point_of_sale/static/src/js/Popups/ClosePosPopup.js @@ -0,0 +1,165 @@ +odoo.define('point_of_sale.ClosePosPopup', function(require) { + 'use strict'; + + const { useState, useRef } = owl.hooks; + const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup'); + const Registries = require('point_of_sale.Registries'); + const { identifyError } = require('point_of_sale.utils'); + const { ConnectionLostError, ConnectionAbortedError} = require('@web/core/network/rpc_service') + + /** + * This popup needs to be self-dependent because it needs to be called from different place. + */ + class ClosePosPopup extends AbstractAwaitablePopup { + constructor() { + super(...arguments); + this.manualInputCashCount = true; + this.cashControl = this.env.pos.config.cash_control; + this.moneyDetailsRef = useRef('moneyDetails'); + this.closeSessionClicked = false; + this.moneyDetails = null; + this.state = useState({}); + } + async willStart() { + try { + const closingData = await this.rpc({ + model: 'pos.session', + method: 'get_closing_control_data', + args: [[this.env.pos.pos_session.id]] + }); + this.ordersDetails = closingData.orders_details; + this.paymentsAmount = closingData.payments_amount; + this.payLaterAmount = closingData.pay_later_amount; + this.openingNotes = closingData.opening_notes; + this.defaultCashDetails = closingData.default_cash_details; + this.otherPaymentMethods = closingData.other_payment_methods; + + // component state and refs definition + const state = {notes: '', acceptClosing: false}; + if (this.cashControl) { + state[this.defaultCashDetails.name] = {counted: 0, difference: -this.defaultCashDetails.amount}; + } + if (this.otherPaymentMethods.length > 0) { + this.otherPaymentMethods.forEach(pm => { + state[pm.name] = {counted: this.env.pos.round_decimals_currency(pm.amount), difference: 0} + }) + } + Object.assign(this.state, state); + } catch (error) { + this.error = error; + } + } + /* + * Since this popup need to be self dependent, in case of an error, the popup need to be closed on its own. + */ + mounted() { + if (this.error) { + this.cancel(); + if (identifyError(this.error) instanceof ConnectionLostError) { + this.showPopup('ErrorPopup', { + title: this.env._t('Network Error'), + body: this.env._t('Please check your internet connection and try again.'), + }); + } else { + throw this.error; + } + } + } + openDetailsPopup() { + if (this.moneyDetailsRef.comp.isClosed()){ + this.moneyDetailsRef.comp.openPopup(); + this.state[this.defaultCashDetails.name].counted = 0; + this.state.notes = ''; + if (this.manualInputCashCount) { + this.moneyDetailsRef.comp.reset(); + } + } + } + handleInputChange() { + this.manualInputCashCount = true; + this.state.notes = ''; + this.state[this.defaultCashDetails.name].difference = + this.env.pos.round_decimals_currency(this.state[this.defaultCashDetails.name].counted - this.defaultCashDetails.amount); + } + updateCountedCash(event) { + const { total, moneyDetailsNotes, moneyDetails } = event.detail; + this.state[this.defaultCashDetails.name].counted = total; + this.state[this.defaultCashDetails.name].difference = + this.env.pos.round_decimals_currency(this.state[[this.defaultCashDetails.name]].counted - this.defaultCashDetails.amount); + if (moneyDetailsNotes) { + this.state.notes = moneyDetailsNotes; + } + this.manualInputCashCount = false; + this.moneyDetails = moneyDetails; + } + canCloseSession() { + return !this.cashControl || !this.state[this.defaultCashDetails.name].difference || this.state.acceptClosing; + } + closePos() { + this.trigger('close-pos'); + } + async closeSession() { + if (this.canCloseSession() && !this.closeSessionClicked) { + this.closeSessionClicked = true; + let response; + if (this.cashControl) { + response = await this.rpc({ + model: 'pos.session', + method: 'post_closing_cash_details', + args: [this.env.pos.pos_session.id], + kwargs: { + counted_cash: this.state[this.defaultCashDetails.name].counted, + } + }) + if (!response.successful) { + return this.handleClosingError(response); + } + } + await this.rpc({ + model: 'pos.session', + method: 'update_closing_control_state_session', + args: [this.env.pos.pos_session.id, this.state.notes] + }) + try { + response = await this.rpc({ + model: 'pos.session', + method: 'close_session_from_ui', + args: [this.env.pos.pos_session.id], + }); + if (!response.successful) { + return this.handleClosingError(response); + } + window.location = '/web#action=point_of_sale.action_client_pos_menu'; + } catch (error) { + const iError = identifyError(error); + if (iError instanceof ConnectionLostError || iError instanceof ConnectionAbortedError) { + await this.showPopup('ErrorPopup', { + title: this.env._t('Network Error'), + body: this.env._t('Cannot close the session when offline.'), + }); + } else { + await this.showPopup('ErrorPopup', { + title: this.env._t('Closing session error'), + body: this.env._t( + 'An error has occurred when trying to close the session.\n' + + 'You will be redirected to the back-end to manually close the session.') + }) + window.location = '/web#action=point_of_sale.action_client_pos_menu'; + } + } + this.closeSessionClicked = false; + } + } + async handleClosingError(response) { + await this.showPopup('ErrorPopup', {title: 'Error', body: response.message}); + if (response.redirect) { + window.location = '/web#action=point_of_sale.action_client_pos_menu'; + } + } + } + + ClosePosPopup.template = 'ClosePosPopup'; + Registries.Component.add(ClosePosPopup); + + return ClosePosPopup; +}); diff --git a/addons/point_of_sale/static/src/js/Popups/MoneyDetailsPopup.js b/addons/point_of_sale/static/src/js/Popups/MoneyDetailsPopup.js new file mode 100644 index 00000000000..c2f71652936 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Popups/MoneyDetailsPopup.js @@ -0,0 +1,69 @@ +odoo.define('point_of_sale.MoneyDetailsPopup', function(require) { + 'use strict'; + + const { useState } = owl.hooks; + const PosComponent = require('point_of_sale.PosComponent'); + const Registries = require('point_of_sale.Registries'); + + /** + * Even if this component has a "confirm and cancel"-like buttons, this should not be an AbstractAwaitablePopup. + * We currently cannot show two popups at the same time, what we do is mount this component with its parent + * and hide it with some css. The confirm button will just trigger an event to the parent. + */ + class MoneyDetailsPopup extends PosComponent { + constructor() { + super(...arguments); + this.currency = this.env.pos.currency; + this.state = useState({ + moneyDetails: Object.fromEntries(this.env.pos.bills.map(bill => ([bill.value, 0]))), + total: 0, + }); + } + get firstHalfMoneyDetails() { + const moneyDetailsKeys = Object.keys(this.state.moneyDetails).sort((a, b) => a - b); + return moneyDetailsKeys.slice(0, moneyDetailsKeys.length/2); + } + get lastHalfMoneyDetails() { + const moneyDetailsKeys = Object.keys(this.state.moneyDetails).sort((a, b) => a - b); + return moneyDetailsKeys.slice(moneyDetailsKeys.length/2, moneyDetailsKeys.length); + } + isClosed() { + return this.el.classList.contains('invisible') + } + openPopup() { + this.el.classList.remove('invisible'); + } + updateMoneyDetailsAmount() { + let total = Object.entries(this.state.moneyDetails).reduce((total, money) => total + money[0] * money[1], 0); + this.state.total = this.env.pos.round_decimals_currency(total); + } + _closePopup() { + this.el.classList.add('invisible'); + } + confirm() { + let moneyDetailsNotes = this.state.total ? 'Money details: \n' : null; + this.env.pos.bills.forEach(bill => { + if (this.state.moneyDetails[bill.value]) { + moneyDetailsNotes += ` - ${this.state.moneyDetails[bill.value]} x ${this.env.pos.format_currency(bill.value)}\n`; + } + }) + const payload = { total: this.state.total, moneyDetailsNotes, moneyDetails: { ...this.state.moneyDetails }}; + this.trigger('money-details-validated', payload); + this._closePopup(); + } + reset() { + for (let key in this.state.moneyDetails) { this.state.moneyDetails[key] = 0 } + this.state.total = 0; + } + discard() { + this.reset(); + this._closePopup(); + } + } + + MoneyDetailsPopup.template = 'MoneyDetailsPopup'; + Registries.Component.add(MoneyDetailsPopup); + + return MoneyDetailsPopup; + +}); 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 c1522915bfe..d8f6de0f94d 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 @@ -44,7 +44,7 @@ odoo.define('point_of_sale.ProductScreen', function(require) { } mounted() { if(this.env.pos.config.cash_control && this.env.pos.pos_session.state == 'opening_control') { - Gui.showPopup('CashOpeningPopup'); + Gui.showPopup('CashOpeningPopup', {notEscapable: true}); } this.env.pos.on('change:selectedClient', this.render, this); } diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js index 3bd7f3e17db..364d3e3cb2f 100644 --- a/addons/point_of_sale/static/src/js/models.js +++ b/addons/point_of_sale/static/src/js/models.js @@ -350,6 +350,15 @@ exports.PosModel = Backbone.Model.extend({ } }, },{ + model: 'pos.bill', + fields: ['name', 'value'], + domain: function (self) { + return [['id', 'in', self.config.default_bill_ids]]; + }, + loaded: function (self, bills) { + self.bills = bills; + }, + }, { model: 'res.partner', label: 'load_partners', fields: ['name','street','city','state_id','country_id','vat','lang', @@ -1518,6 +1527,11 @@ exports.PosModel = Backbone.Model.extend({ return value.toFixed(decimals); }, + round_decimals_currency(value) { + const decimals = this.currency.decimals; + return parseFloat(round_di(value, decimals).toFixed(decimals)); + }, + /** * (value = 1.0000, decimals = 2) => '1' * (value = 1.1234, decimals = 2) => '1.12' diff --git a/addons/point_of_sale/static/src/xml/ChromeWidgets/CashMoveButton.xml b/addons/point_of_sale/static/src/xml/ChromeWidgets/CashMoveButton.xml index 595599c111c..3dfb83bb67d 100644 --- a/addons/point_of_sale/static/src/xml/ChromeWidgets/CashMoveButton.xml +++ b/addons/point_of_sale/static/src/xml/ChromeWidgets/CashMoveButton.xml @@ -2,7 +2,7 @@ -
+
Cash In/Out
diff --git a/addons/point_of_sale/static/src/xml/ChromeWidgets/HeaderButton.xml b/addons/point_of_sale/static/src/xml/ChromeWidgets/HeaderButton.xml index 19d9c7c841c..db30ad680d6 100644 --- a/addons/point_of_sale/static/src/xml/ChromeWidgets/HeaderButton.xml +++ b/addons/point_of_sale/static/src/xml/ChromeWidgets/HeaderButton.xml @@ -2,9 +2,8 @@ -
- +
+ Close
diff --git a/addons/point_of_sale/static/src/xml/Misc/CurrencyAmount.xml b/addons/point_of_sale/static/src/xml/Misc/CurrencyAmount.xml new file mode 100644 index 00000000000..2ec97ff57f8 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Misc/CurrencyAmount.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/addons/point_of_sale/static/src/xml/Popups/CashOpeningPopup.xml b/addons/point_of_sale/static/src/xml/Popups/CashOpeningPopup.xml index 61b6e5bdfb0..2f0e6857cf6 100644 --- a/addons/point_of_sale/static/src/xml/Popups/CashOpeningPopup.xml +++ b/addons/point_of_sale/static/src/xml/Popups/CashOpeningPopup.xml @@ -1,57 +1,28 @@ -