diff --git a/pos_event_sale/README.rst b/pos_event_sale/README.rst new file mode 100644 index 0000000000..2195281ef9 --- /dev/null +++ b/pos_event_sale/README.rst @@ -0,0 +1,122 @@ +==================== +Point of Sale Events +==================== + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fpos-lightgray.png?logo=github + :target: https://github.com/OCA/pos/tree/15.0/pos_event_sale + :alt: OCA/pos +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/pos-15-0/pos-15-0-pos_event_sale + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/184/15.0 + :alt: Try me on Runbot + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Sell events tickets from the Point of Sale + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Go to *Point of Sale > Configuration > Point of Sale* and enable *Sell Events* +on the desired PoS. + +Optionally, you can choose to filter on a list of Event Types. + +Enable *Available in PoS* on all event products related to event tickets you +want to sell in PoS. If the ticket product is not available on the pos, +the ticket won't be available neither. + +Usage +===== + + +- Click on the Add Event button, or on any Event product. + +.. image:: https://raw.githubusercontent.com/OCA/pos/15.0/pos_event_sale/static/description/add_event_button.png + +- Use the calendar widget to filter the events, and click on one. + +.. image:: https://raw.githubusercontent.com/OCA/pos/15.0/pos_event_sale/static/description/event_calendar.png + +- Add as many Tickets as you want + +.. image:: https://raw.githubusercontent.com/OCA/pos/15.0/pos_event_sale/static/description/ticket_selector.png + +Known issues / Roadmap +====================== + + +* Handle event registration details. It could be another popup right + before going to payment (similar to core `event_sale` workflow). + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Camptocamp + +Contributors +~~~~~~~~~~~~ + +* `Camptocamp `_ + + * Iván Todorovich + +* `Moka Tourisme `_ + + * Grégory Schreiner + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-ivantodorovich| image:: https://github.com/ivantodorovich.png?size=40px + :target: https://github.com/ivantodorovich + :alt: ivantodorovich + +Current `maintainer `__: + +|maintainer-ivantodorovich| + +This module is part of the `OCA/pos `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/pos_event_sale/__init__.py b/pos_event_sale/__init__.py new file mode 100644 index 0000000000..5299067493 --- /dev/null +++ b/pos_event_sale/__init__.py @@ -0,0 +1,3 @@ +from .hooks import post_init_hook, pre_init_hook +from . import models +from . import reports diff --git a/pos_event_sale/__manifest__.py b/pos_event_sale/__manifest__.py new file mode 100644 index 0000000000..2c14a6494c --- /dev/null +++ b/pos_event_sale/__manifest__.py @@ -0,0 +1,40 @@ +# Copyright 2021 Camptocamp (https://www.camptocamp.com). +# @author Iván Todorovich +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Point of Sale Events", + "summary": "Sell events from Point of Sale", + "author": "Camptocamp, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/pos", + "category": "Marketing", + "version": "16.0.1.0.0", + "license": "AGPL-3", + "maintainers": ["ivantodorovich"], + "depends": ["event_sale", "point_of_sale"], + "data": [ + "security/security.xml", + "reports/report_pos_order.xml", + "views/event_registration.xml", + "views/event_event.xml", + "views/pos_order.xml", + "views/res_config_settings.xml", + ], + "assets": { + "point_of_sale.assets": [ + "web/static/lib/fullcalendar/core/main.css", + "web/static/lib/fullcalendar/daygrid/main.css", + "web/static/lib/fullcalendar/core/main.js", + "web/static/lib/fullcalendar/daygrid/main.js", + "web/static/lib/fullcalendar/interaction/main.js", + "pos_event_sale/static/src/js/**/*.js", + "pos_event_sale/static/src/scss/**/*.scss", + "pos_event_sale/static/src/xml/**/*.xml", + ], + "web.assets_tests": [ + "pos_event_sale/static/tests/tours/**/*", + ], + }, + "pre_init_hook": "pre_init_hook", + "post_init_hook": "post_init_hook", +} diff --git a/pos_event_sale/hooks.py b/pos_event_sale/hooks.py new file mode 100644 index 0000000000..b355da6d21 --- /dev/null +++ b/pos_event_sale/hooks.py @@ -0,0 +1,45 @@ +# Copyright 2022 Moka Tourisme (https://www.mokatourisme.fr). +# @author Iván Todorovich +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging + +from odoo import SUPERUSER_ID, api +from odoo.tools.sql import column_exists, create_column + +_logger = logging.getLogger(__name__) + + +def pre_init_hook(cr): + """Store currency_id in database for each existing pos_order_line""" + if not column_exists(cr, "pos_order_line", "currency_id"): + _logger.info("Pre-computing pos.order.line.currency_id..") + create_column(cr, "pos_order_line", "currency_id", "int4") + cr.execute( + """ + WITH pos_order_line_currency AS ( + SELECT + pol.id AS id, + COALESCE(aj.currency_id, rc.currency_id) AS currency_id + FROM pos_order_line AS pol + JOIN pos_order AS po ON pol.order_id = po.id + JOIN pos_session AS ps ON po.session_id = ps.id + JOIN pos_config AS pc ON ps.config_id = pc.id + JOIN res_company AS rc ON pc.company_id = rc.id + LEFT JOIN account_journal AS aj ON pc.journal_id = aj.id + ) + UPDATE pos_order_line + SET currency_id = pos_order_line_currency.currency_id + FROM pos_order_line_currency + WHERE pos_order_line.id = pos_order_line_currency.id + """ + ) + + +def post_init_hook(cr, __): + """Set the Event Registration product available for POS""" + env = api.Environment(cr, SUPERUSER_ID, {}) + product = env.ref("event_sale.product_product_event", raise_if_not_found=False) + if product: + _logger.info("Setting default Event Product as available in Point of Sale..") + product.available_in_pos = True diff --git a/pos_event_sale/i18n/fr.po b/pos_event_sale/i18n/fr.po new file mode 100644 index 0000000000..acb50f80be --- /dev/null +++ b/pos_event_sale/i18n/fr.po @@ -0,0 +1,473 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * pos_event_sale +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 15.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2022-05-14 15:23+0000\n" +"PO-Revision-Date: 2022-05-14 15:23+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/js/Popups/EventAvailabilityBadge.js:0 +#, python-format +msgid "%d remaining" +msgstr "%d restant(s)" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/xml/ControlButtons/AddEventButton.xml:0 +#: code:addons/pos_event_sale/static/src/xml/EventSelectorPopup/EventSelectorPopup.xml:0 +#, python-format +msgid "Add Event" +msgstr "Billetterie" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_event_event__pos_order_line_ids +#: model:ir.model.fields,field_description:pos_event_sale.field_event_session__pos_order_line_ids +msgid "All the PoS Order Lines pointing to this event" +msgstr "" + +#. module: pos_event_sale +#: model_terms:ir.ui.view,arch_db:pos_event_sale.view_pos_pos_form +msgid "Attendees" +msgstr "Participants" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_pos_config__iface_available_event_stage_ids +msgid "Available Event Stages" +msgstr "" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_pos_config__iface_available_event_tag_ids +msgid "Available Event Tags" +msgstr "" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_pos_config__iface_available_event_type_ids +msgid "Available Event Types" +msgstr "" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_event_event_ticket__available_in_pos +#: model:ir.model.fields,field_description:pos_event_sale.field_event_type_ticket__available_in_pos +msgid "Available in POS" +msgstr "Disponible dans le PdV" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/xml/ReceiptScreen/EventRegistrationReceipt.xml:0 +#, python-format +msgid "Barcode" +msgstr "Code barre" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/xml/EventSelectorPopup/EventList.xml:0 +#, python-format +msgid "" +"Besides the Event filters on the Point of Sale configuration,\n" +" make sure products related to the event tickets are " +"also available\n" +" for this Point of Sale, otherwise they won't be " +"shown here." +msgstr "" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/xml/EventSelectorPopup/EventSelectorPopup.xml:0 +#, python-format +msgid "Cancel" +msgstr "Annuler" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/xml/EventTicketsPopup/EventTicketsPopup.xml:0 +#, python-format +msgid "Close" +msgstr "Fermer" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/js/Popups/EventSelectorPopup/EventFilters.js:0 +#, python-format +msgid "Country" +msgstr "" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_pos_order_line__currency_id +msgid "Currency" +msgstr "Devise" + +#. module: pos_event_sale +#: model_terms:ir.ui.view,arch_db:pos_event_sale.pos_config_view_form +msgid "Display a warning when available seats is below" +msgstr "Afficher une alerte quand les places sont en dessous de" + +#. module: pos_event_sale +#: model:ir.model.fields,help:pos_event_sale.field_pos_config__iface_event_seats_available_warning +msgid "Display a warning when available seats is below this quantity." +msgstr "" +"Afficher une alerte quand les places sont en dessous de cette quantité." + +#. module: pos_event_sale +#: model:ir.model,name:pos_event_sale.model_event_event +#: model:ir.model.fields,field_description:pos_event_sale.field_pos_order_line__event_id +#: model:ir.model.fields,field_description:pos_event_sale.field_report_pos_order__event_id +#: model_terms:ir.ui.view,arch_db:pos_event_sale.view_report_pos_order_search +msgid "Event" +msgstr "Évènement" + +#. module: pos_event_sale +#: model:ir.model,name:pos_event_sale.model_event_registration +msgid "Event Registration" +msgstr "Inscription à l'événement" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_pos_order__event_registrations_count +msgid "Event Registration Count" +msgstr "" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_pos_order__event_registration_ids +#: model:ir.model.fields,field_description:pos_event_sale.field_pos_order_line__event_registration_ids +msgid "Event Registrations" +msgstr "Inscriptions à l'événement" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_pos_config__iface_event_seats_available_warning +msgid "Event Seats Available Warning" +msgstr "" + +#. module: pos_event_sale +#: model_terms:ir.ui.view,arch_db:pos_event_sale.pos_config_view_form +msgid "Event Stages" +msgstr "Étapes d'Événement" + +#. module: pos_event_sale +#: model_terms:ir.ui.view,arch_db:pos_event_sale.pos_config_view_form +msgid "Event Tags" +msgstr "Étiquettes d'Événement" + +#. module: pos_event_sale +#: model:ir.model,name:pos_event_sale.model_event_type_ticket +msgid "Event Template Ticket" +msgstr "" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_pos_order_line__event_ticket_id +#: model:ir.model.fields,field_description:pos_event_sale.field_report_pos_order__event_ticket_id +#: model_terms:ir.ui.view,arch_db:pos_event_sale.view_report_pos_order_search +msgid "Event Ticket" +msgstr "Billet d'événement" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/js/db.js:0 +#, python-format +msgid "Event Ticket not found: %d" +msgstr "Billet d'événement non trouvé : %d" + +#. module: pos_event_sale +#: model_terms:ir.ui.view,arch_db:pos_event_sale.pos_config_view_form +msgid "Event Types" +msgstr "" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/js/Screens/ProductScreen.js:0 +#, python-format +msgid "Event availability error" +msgstr "" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/js/db.js:0 +#, python-format +msgid "Event not found: %d" +msgstr "Événement non trouvé : %d" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_pos_config__iface_event_sale +msgid "Events" +msgstr "Événements" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/xml/EventTicketsPopup/EventTicketItem.xml:0 +#, python-format +msgid "Free" +msgstr "Gratuit" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/xml/EventSelectorPopup/EventItem.xml:0 +#: code:addons/pos_event_sale/static/src/xml/ReceiptScreen/EventRegistrationReceipt.xml:0 +#, python-format +msgid "From" +msgstr "Du" + +#. module: pos_event_sale +#: model:ir.model.fields,help:pos_event_sale.field_pos_config__iface_available_event_stage_ids +msgid "" +"Limit the available events for this Point of Sale.\n" +"Leave empty to load all stages." +msgstr "" + +#. module: pos_event_sale +#: model:ir.model.fields,help:pos_event_sale.field_pos_config__iface_available_event_tag_ids +msgid "" +"Limit the available events for this Point of Sale.\n" +"Leave empty to load all tags." +msgstr "" + +#. module: pos_event_sale +#: model:ir.model.fields,help:pos_event_sale.field_pos_config__iface_available_event_type_ids +msgid "" +"Limit the available events for this Point of Sale.\n" +"Leave empty to load all types." +msgstr "" + +#. module: pos_event_sale +#: model_terms:ir.ui.view,arch_db:pos_event_sale.pos_config_view_form +msgid "Load" +msgstr "" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_pos_config__iface_event_load_days_after +msgid "Load future events (days)" +msgstr "" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_pos_config__iface_event_load_days_before +msgid "Load past events (days)" +msgstr "" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/xml/ReceiptScreen/EventRegistrationReceipt.xml:0 +#, python-format +msgid "Logo" +msgstr "" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/js/Popups/EventSelectorPopup/EventFilters.js:0 +#, python-format +msgid "Name" +msgstr "" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/js/models/Orderline.js:0 +#, python-format +msgid "Not enough available seats for %s" +msgstr "Plus de places disponibles pour %s" + +#. module: pos_event_sale +#: model:ir.model.fields,help:pos_event_sale.field_pos_config__iface_event_load_days_before +msgid "" +"Number of days before today, to load past events.\n" +"Set to -1 to load all past events." +msgstr "" + +#. module: pos_event_sale +#: model:ir.model.fields,help:pos_event_sale.field_pos_config__iface_event_load_days_after +msgid "" +"Number of days in the future to load events.\n" +"Set to -1 to load all future events." +msgstr "" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/js/Popups/EventAvailabilityBadge.js:0 +#, python-format +msgid "Oversold" +msgstr "Surbooké" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_event_registration__pos_order_id +#: model_terms:ir.ui.view,arch_db:pos_event_sale.event_registration_ticket_view_form +msgid "POS Order" +msgstr "" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_event_registration__pos_order_line_id +msgid "POS Order Line" +msgstr "" + +#. module: pos_event_sale +#: model_terms:ir.ui.view,arch_db:pos_event_sale.view_event_form_inherit_ticket +msgid "POS Sales" +msgstr "" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_event_event__pos_price_subtotal +#: model:ir.model.fields,field_description:pos_event_sale.field_event_session__pos_price_subtotal +msgid "POS Sales (Tax Excluded)" +msgstr "" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_event_registration__pos_config_id +msgid "Point of Sale" +msgstr "" + +#. module: pos_event_sale +#: model:ir.model,name:pos_event_sale.model_pos_config +msgid "Point of Sale Configuration" +msgstr "Paramétrage du point de vente" + +#. module: pos_event_sale +#: model:ir.model,name:pos_event_sale.model_pos_order_line +msgid "Point of Sale Order Lines" +msgstr "Lignes des commandes du point de vente" + +#. module: pos_event_sale +#: model:ir.model,name:pos_event_sale.model_pos_order +msgid "Point of Sale Orders" +msgstr "Commandes du point de vente" + +#. module: pos_event_sale +#: model:ir.model,name:pos_event_sale.model_report_pos_order +msgid "Point of Sale Orders Report" +msgstr "Rapport sur les commandes au point de vente" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/js/Popups/EventSelectorPopup/EventSelectorPopup.js:0 +#, python-format +msgid "Product" +msgstr "" + +#. module: pos_event_sale +#: code:addons/pos_event_sale/models/pos_order_line.py:0 +#, python-format +msgid "Refunded on %s" +msgstr "Remboursé sur %s" + +#. module: pos_event_sale +#: model:ir.model.fields,help:pos_event_sale.field_pos_config__iface_event_sale +msgid "Sell events on this point of sale." +msgstr "" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/js/Popups/EventAvailabilityBadge.js:0 +#, python-format +msgid "Sold out" +msgstr "Complet" + +#. module: pos_event_sale +#: model_terms:ir.ui.view,arch_db:pos_event_sale.pos_config_view_form +msgid "Stages" +msgstr "Étapes" + +#. module: pos_event_sale +#: model_terms:ir.ui.view,arch_db:pos_event_sale.pos_config_view_form +msgid "Tags" +msgstr "Étiquettes" + +#. module: pos_event_sale +#: model:ir.model.fields,help:pos_event_sale.field_event_registration__pos_config_id +msgid "The physical point of sale you will use." +msgstr "Le Point de Vente physique que vous allez utiliser." + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/xml/EventTicketsPopup/EventTicketList.xml:0 +#, python-format +msgid "There are no available event tickets for this event." +msgstr "Il n'y a aucune billet disponible pour cet événement." + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/xml/EventSelectorPopup/EventList.xml:0 +#, python-format +msgid "There are no events on these dates." +msgstr "Il n'y a pas d'événement sur ces dates." + +#. module: pos_event_sale +#: model:ir.model.fields,help:pos_event_sale.field_event_event_ticket__available_in_pos +#: model:ir.model.fields,help:pos_event_sale.field_event_type_ticket__available_in_pos +msgid "" +"This is configured on the related Product.\n" +"\n" +"Please note that for the ticket to be available in the Point of Sale, the " +"ticket's product has to be available there, too." +msgstr "" +"Configuré sur l'article associé.\n" +"\n" +"Veuillez noter que vous qu'un ticket soit disponible en Point de Vente, " +"l'article du billet doit y être disponible également." + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/xml/EventSelectorPopup/EventItem.xml:0 +#: code:addons/pos_event_sale/static/src/xml/ReceiptScreen/EventRegistrationReceipt.xml:0 +#, python-format +msgid "To" +msgstr "Au" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/js/Popups/EventSelectorPopup/EventCalendar.js:0 +#, python-format +msgid "Today" +msgstr "Aujourd'hui" + +#. module: pos_event_sale +#: model_terms:ir.ui.view,arch_db:pos_event_sale.view_event_form_inherit_ticket +msgid "Total POS Sales for this event" +msgstr "" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/js/Popups/EventSelectorPopup/EventFilters.js:0 +#, python-format +msgid "Type" +msgstr "" + +#. module: pos_event_sale +#: model_terms:ir.ui.view,arch_db:pos_event_sale.pos_config_view_form +msgid "Types" +msgstr "" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/js/models/Order.js:0 +#, python-format +msgid "" +"Unable to check event tickets availability. Check the internet connection " +"then try again." +msgstr "" +"Impossible de vérifier les disponibilités. Veuillez vérifier votre connexion " +"internet et réessayez." + +#. module: pos_event_sale +#: model_terms:ir.ui.view,arch_db:pos_event_sale.view_pos_pos_form +msgid "View Event Attendees" +msgstr "Voir les participants" + +#. module: pos_event_sale +#: model_terms:ir.ui.view,arch_db:pos_event_sale.pos_config_view_form +msgid "and" +msgstr "et" + +#. module: pos_event_sale +#: model_terms:ir.ui.view,arch_db:pos_event_sale.pos_config_view_form +msgid "days after today" +msgstr "jours après aujourd'hui" + +#. module: pos_event_sale +#: model_terms:ir.ui.view,arch_db:pos_event_sale.pos_config_view_form +msgid "days before today," +msgstr "jours avant aujourd'hui," diff --git a/pos_event_sale/i18n/it.po b/pos_event_sale/i18n/it.po new file mode 100644 index 0000000000..702baf9630 --- /dev/null +++ b/pos_event_sale/i18n/it.po @@ -0,0 +1,488 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * pos_event_sale +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 15.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2023-04-10 15:22+0000\n" +"Last-Translator: mymage \n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.14.1\n" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/js/Popups/EventAvailabilityBadge.js:0 +#, python-format +msgid "%d remaining" +msgstr "Resto %d" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/xml/ControlButtons/AddEventButton.xml:0 +#: code:addons/pos_event_sale/static/src/xml/EventSelectorPopup/EventSelectorPopup.xml:0 +#, python-format +msgid "Add Event" +msgstr "Aggiungi evento" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_event_event__pos_order_line_ids +#: model:ir.model.fields,field_description:pos_event_sale.field_event_session__pos_order_line_ids +msgid "All the PoS Order Lines pointing to this event" +msgstr "Tutte le righe ordine PoS che puntano a questo evento" + +#. module: pos_event_sale +#: model_terms:ir.ui.view,arch_db:pos_event_sale.view_pos_pos_form +msgid "Attendees" +msgstr "Partecipanti" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_pos_config__iface_available_event_stage_ids +msgid "Available Event Stages" +msgstr "Fasi evento disponibile" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_pos_config__iface_available_event_tag_ids +msgid "Available Event Tags" +msgstr "Etichette evento disponibile" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_pos_config__iface_available_event_type_ids +msgid "Available Event Types" +msgstr "Tipi evento disponibili" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_event_event_ticket__available_in_pos +#: model:ir.model.fields,field_description:pos_event_sale.field_event_type_ticket__available_in_pos +msgid "Available in POS" +msgstr "Disponibile nel POS" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/xml/ReceiptScreen/EventRegistrationReceipt.xml:0 +#, python-format +msgid "Barcode" +msgstr "Codice a barre" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/xml/EventSelectorPopup/EventList.xml:0 +#, python-format +msgid "" +"Besides the Event filters on the Point of Sale configuration,\n" +" make sure products related to the event tickets are " +"also available\n" +" for this Point of Sale, otherwise they won't be " +"shown here." +msgstr "" +"Oltre ai filtri evento sulla configurazione del punto vendita,\n" +" assicurati che i prodotti relativi ai biglietti " +"dell'evento siano disponibili\n" +" anche per questo punto vendita, altrimenti non " +"verranno mostrati qui." + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/xml/EventSelectorPopup/EventSelectorPopup.xml:0 +#, python-format +msgid "Cancel" +msgstr "Annulla" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/xml/EventTicketsPopup/EventTicketsPopup.xml:0 +#, python-format +msgid "Close" +msgstr "Chiudi" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/js/Popups/EventSelectorPopup/EventFilters.js:0 +#, python-format +msgid "Country" +msgstr "Nazione" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_pos_order_line__currency_id +msgid "Currency" +msgstr "Valuta" + +#. module: pos_event_sale +#: model_terms:ir.ui.view,arch_db:pos_event_sale.pos_config_view_form +msgid "Display a warning when available seats is below" +msgstr "Visualizza un avviso quando i posti disponibili sono sotto" + +#. module: pos_event_sale +#: model:ir.model.fields,help:pos_event_sale.field_pos_config__iface_event_seats_available_warning +msgid "Display a warning when available seats is below this quantity." +msgstr "" +"Visualizza un avviso quando i posti disponibili sono sotto questa quantità." + +#. module: pos_event_sale +#: model:ir.model,name:pos_event_sale.model_event_event +#: model:ir.model.fields,field_description:pos_event_sale.field_pos_order_line__event_id +#: model:ir.model.fields,field_description:pos_event_sale.field_report_pos_order__event_id +#: model_terms:ir.ui.view,arch_db:pos_event_sale.view_report_pos_order_search +msgid "Event" +msgstr "Evento" + +#. module: pos_event_sale +#: model:ir.model,name:pos_event_sale.model_event_registration +msgid "Event Registration" +msgstr "Iscrizione evento" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_pos_order__event_registrations_count +msgid "Event Registration Count" +msgstr "Conteggio iscrizione evento" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_pos_order__event_registration_ids +#: model:ir.model.fields,field_description:pos_event_sale.field_pos_order_line__event_registration_ids +msgid "Event Registrations" +msgstr "Iscrizioni evento" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_pos_config__iface_event_seats_available_warning +msgid "Event Seats Available Warning" +msgstr "Avviso posti evento disponibili" + +#. module: pos_event_sale +#: model_terms:ir.ui.view,arch_db:pos_event_sale.pos_config_view_form +msgid "Event Stages" +msgstr "Fasi evento" + +#. module: pos_event_sale +#: model_terms:ir.ui.view,arch_db:pos_event_sale.pos_config_view_form +msgid "Event Tags" +msgstr "Etichette evento" + +#. module: pos_event_sale +#: model:ir.model,name:pos_event_sale.model_event_type_ticket +msgid "Event Template Ticket" +msgstr "Modello biglietto evento" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_pos_order_line__event_ticket_id +#: model:ir.model.fields,field_description:pos_event_sale.field_report_pos_order__event_ticket_id +#: model_terms:ir.ui.view,arch_db:pos_event_sale.view_report_pos_order_search +msgid "Event Ticket" +msgstr "Biglietto evento" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/js/db.js:0 +#, python-format +msgid "Event Ticket not found: %d" +msgstr "Biglietto evento non trovato: %d" + +#. module: pos_event_sale +#: model_terms:ir.ui.view,arch_db:pos_event_sale.pos_config_view_form +msgid "Event Types" +msgstr "Tipi evento" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/js/Screens/ProductScreen.js:0 +#, python-format +msgid "Event availability error" +msgstr "Errore disponibilità evento" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/js/db.js:0 +#, python-format +msgid "Event not found: %d" +msgstr "Evento non trovato: %d" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_pos_config__iface_event_sale +msgid "Events" +msgstr "Eventi" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/xml/EventTicketsPopup/EventTicketItem.xml:0 +#, python-format +msgid "Free" +msgstr "Gratuito" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/xml/EventSelectorPopup/EventItem.xml:0 +#: code:addons/pos_event_sale/static/src/xml/ReceiptScreen/EventRegistrationReceipt.xml:0 +#, python-format +msgid "From" +msgstr "Dal" + +#. module: pos_event_sale +#: model:ir.model.fields,help:pos_event_sale.field_pos_config__iface_available_event_stage_ids +msgid "" +"Limit the available events for this Point of Sale.\n" +"Leave empty to load all stages." +msgstr "" +"Limita gli eventi disponibili per questo punto vendita.\n" +"Lasciare vuoto per caricate tutte le fasi." + +#. module: pos_event_sale +#: model:ir.model.fields,help:pos_event_sale.field_pos_config__iface_available_event_tag_ids +msgid "" +"Limit the available events for this Point of Sale.\n" +"Leave empty to load all tags." +msgstr "" +"Limita gli eventi disponibili per questo punto vendita.\n" +"Lasciare vuoto per caricate tutte le etichette." + +#. module: pos_event_sale +#: model:ir.model.fields,help:pos_event_sale.field_pos_config__iface_available_event_type_ids +msgid "" +"Limit the available events for this Point of Sale.\n" +"Leave empty to load all types." +msgstr "" +"Limita gli eventi disponibili per questo punto vendita.\n" +"Lasciare vuoto per caricate tutti i tipi." + +#. module: pos_event_sale +#: model_terms:ir.ui.view,arch_db:pos_event_sale.pos_config_view_form +msgid "Load" +msgstr "Carica" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_pos_config__iface_event_load_days_after +msgid "Load future events (days)" +msgstr "Carica eventi futuri (giorni)" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_pos_config__iface_event_load_days_before +msgid "Load past events (days)" +msgstr "Carica eventi trascorsi (giorni)" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/xml/ReceiptScreen/EventRegistrationReceipt.xml:0 +#, python-format +msgid "Logo" +msgstr "Logo" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/js/Popups/EventSelectorPopup/EventFilters.js:0 +#, python-format +msgid "Name" +msgstr "Nome" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/js/models/Orderline.js:0 +#, python-format +msgid "Not enough available seats for %s" +msgstr "Non ci sono abbastanza posti disponibili per %s" + +#. module: pos_event_sale +#: model:ir.model.fields,help:pos_event_sale.field_pos_config__iface_event_load_days_before +msgid "" +"Number of days before today, to load past events.\n" +"Set to -1 to load all past events." +msgstr "" +"Numero di giorni prima di oggi per caricare eventi trascorsi.\n" +"Impostare a -1 per caricare tutti gli eventi trascorsi." + +#. module: pos_event_sale +#: model:ir.model.fields,help:pos_event_sale.field_pos_config__iface_event_load_days_after +msgid "" +"Number of days in the future to load events.\n" +"Set to -1 to load all future events." +msgstr "" +"Numero di giorni futuri per caricare eventi.\n" +"Impostare a -1 per caricare tutti gli eventi futuri." + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/js/Popups/EventAvailabilityBadge.js:0 +#, python-format +msgid "Oversold" +msgstr "Vendita eccessiva" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_event_registration__pos_order_id +#: model_terms:ir.ui.view,arch_db:pos_event_sale.event_registration_ticket_view_form +msgid "POS Order" +msgstr "Ordine POS" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_event_registration__pos_order_line_id +msgid "POS Order Line" +msgstr "Riga ordine POS" + +#. module: pos_event_sale +#: model_terms:ir.ui.view,arch_db:pos_event_sale.view_event_form_inherit_ticket +msgid "POS Sales" +msgstr "Vendite POS" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_event_event__pos_price_subtotal +#: model:ir.model.fields,field_description:pos_event_sale.field_event_session__pos_price_subtotal +msgid "POS Sales (Tax Excluded)" +msgstr "Vendite POS (escluse tasse)" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_event_registration__pos_config_id +msgid "Point of Sale" +msgstr "Punto vendita" + +#. module: pos_event_sale +#: model:ir.model,name:pos_event_sale.model_pos_config +msgid "Point of Sale Configuration" +msgstr "Configurazione punto vendita" + +#. module: pos_event_sale +#: model:ir.model,name:pos_event_sale.model_pos_order_line +msgid "Point of Sale Order Lines" +msgstr "Righe ordine punto vendita" + +#. module: pos_event_sale +#: model:ir.model,name:pos_event_sale.model_pos_order +msgid "Point of Sale Orders" +msgstr "Ordini punto vendita" + +#. module: pos_event_sale +#: model:ir.model,name:pos_event_sale.model_report_pos_order +msgid "Point of Sale Orders Report" +msgstr "Resoconto ordini punto vendita" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/js/Popups/EventSelectorPopup/EventSelectorPopup.js:0 +#, python-format +msgid "Product" +msgstr "Prodotto" + +#. module: pos_event_sale +#: code:addons/pos_event_sale/models/pos_order_line.py:0 +#, python-format +msgid "Refunded on %s" +msgstr "Rimborsato il %s" + +#. module: pos_event_sale +#: model:ir.model.fields,help:pos_event_sale.field_pos_config__iface_event_sale +msgid "Sell events on this point of sale." +msgstr "Vendita eventi in questo punto vendita." + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/js/Popups/EventAvailabilityBadge.js:0 +#, python-format +msgid "Sold out" +msgstr "Esaurito" + +#. module: pos_event_sale +#: model_terms:ir.ui.view,arch_db:pos_event_sale.pos_config_view_form +msgid "Stages" +msgstr "Fasi" + +#. module: pos_event_sale +#: model_terms:ir.ui.view,arch_db:pos_event_sale.pos_config_view_form +msgid "Tags" +msgstr "Etichette" + +#. module: pos_event_sale +#: model:ir.model.fields,help:pos_event_sale.field_event_registration__pos_config_id +msgid "The physical point of sale you will use." +msgstr "Il punto vendita fisico che verrà utilizzato." + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/xml/EventTicketsPopup/EventTicketList.xml:0 +#, python-format +msgid "There are no available event tickets for this event." +msgstr "Non ci sono biglietti disponibili per questo evento." + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/xml/EventSelectorPopup/EventList.xml:0 +#, python-format +msgid "There are no events on these dates." +msgstr "Non ci sono eventi in questa data." + +#. module: pos_event_sale +#: model:ir.model.fields,help:pos_event_sale.field_event_event_ticket__available_in_pos +#: model:ir.model.fields,help:pos_event_sale.field_event_type_ticket__available_in_pos +msgid "" +"This is configured on the related Product.\n" +"\n" +"Please note that for the ticket to be available in the Point of Sale, the " +"ticket's product has to be available there, too." +msgstr "" +"Questo è configurato nel prodotto collegato.\n" +"\n" +"Notare che perché il biglieto sia disponibile nel punto vendita, anche il " +"prodotto del biglietto deve essere disponibile nel punto vendita." + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/xml/EventSelectorPopup/EventItem.xml:0 +#: code:addons/pos_event_sale/static/src/xml/ReceiptScreen/EventRegistrationReceipt.xml:0 +#, python-format +msgid "To" +msgstr "Al" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/js/Popups/EventSelectorPopup/EventCalendar.js:0 +#, python-format +msgid "Today" +msgstr "Oggi" + +#. module: pos_event_sale +#: model_terms:ir.ui.view,arch_db:pos_event_sale.view_event_form_inherit_ticket +msgid "Total POS Sales for this event" +msgstr "Vendite POS totali per questo evento" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/js/Popups/EventSelectorPopup/EventFilters.js:0 +#, python-format +msgid "Type" +msgstr "Tipo" + +#. module: pos_event_sale +#: model_terms:ir.ui.view,arch_db:pos_event_sale.pos_config_view_form +msgid "Types" +msgstr "Tipi" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/js/models/Order.js:0 +#, python-format +msgid "" +"Unable to check event tickets availability. Check the internet connection " +"then try again." +msgstr "" +"Impossibile controllare disponibilità eventi. Controllare la connessione di " +"rete e riprovare." + +#. module: pos_event_sale +#: model_terms:ir.ui.view,arch_db:pos_event_sale.view_pos_pos_form +msgid "View Event Attendees" +msgstr "Visualizza partecipanti evento" + +#. module: pos_event_sale +#: model_terms:ir.ui.view,arch_db:pos_event_sale.pos_config_view_form +msgid "and" +msgstr "e" + +#. module: pos_event_sale +#: model_terms:ir.ui.view,arch_db:pos_event_sale.pos_config_view_form +msgid "days after today" +msgstr "giorni dopo oggi" + +#. module: pos_event_sale +#: model_terms:ir.ui.view,arch_db:pos_event_sale.pos_config_view_form +msgid "days before today," +msgstr "giorni prima di oggi," diff --git a/pos_event_sale/i18n/pos_event_sale.pot b/pos_event_sale/i18n/pos_event_sale.pot new file mode 100644 index 0000000000..10774c07f3 --- /dev/null +++ b/pos_event_sale/i18n/pos_event_sale.pot @@ -0,0 +1,460 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * pos_event_sale +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 15.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/js/Popups/EventAvailabilityBadge.js:0 +#, python-format +msgid "%d remaining" +msgstr "" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/xml/ControlButtons/AddEventButton.xml:0 +#: code:addons/pos_event_sale/static/src/xml/EventSelectorPopup/EventSelectorPopup.xml:0 +#, python-format +msgid "Add Event" +msgstr "" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_event_event__pos_order_line_ids +#: model:ir.model.fields,field_description:pos_event_sale.field_event_session__pos_order_line_ids +msgid "All the PoS Order Lines pointing to this event" +msgstr "" + +#. module: pos_event_sale +#: model_terms:ir.ui.view,arch_db:pos_event_sale.view_pos_pos_form +msgid "Attendees" +msgstr "" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_pos_config__iface_available_event_stage_ids +msgid "Available Event Stages" +msgstr "" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_pos_config__iface_available_event_tag_ids +msgid "Available Event Tags" +msgstr "" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_pos_config__iface_available_event_type_ids +msgid "Available Event Types" +msgstr "" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_event_event_ticket__available_in_pos +#: model:ir.model.fields,field_description:pos_event_sale.field_event_type_ticket__available_in_pos +msgid "Available in POS" +msgstr "" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/xml/ReceiptScreen/EventRegistrationReceipt.xml:0 +#, python-format +msgid "Barcode" +msgstr "" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/xml/EventSelectorPopup/EventList.xml:0 +#, python-format +msgid "" +"Besides the Event filters on the Point of Sale configuration,\n" +" make sure products related to the event tickets are also available\n" +" for this Point of Sale, otherwise they won't be shown here." +msgstr "" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/xml/EventSelectorPopup/EventSelectorPopup.xml:0 +#, python-format +msgid "Cancel" +msgstr "" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/xml/EventTicketsPopup/EventTicketsPopup.xml:0 +#, python-format +msgid "Close" +msgstr "" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/js/Popups/EventSelectorPopup/EventFilters.js:0 +#, python-format +msgid "Country" +msgstr "" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_pos_order_line__currency_id +msgid "Currency" +msgstr "" + +#. module: pos_event_sale +#: model_terms:ir.ui.view,arch_db:pos_event_sale.pos_config_view_form +msgid "Display a warning when available seats is below" +msgstr "" + +#. module: pos_event_sale +#: model:ir.model.fields,help:pos_event_sale.field_pos_config__iface_event_seats_available_warning +msgid "Display a warning when available seats is below this quantity." +msgstr "" + +#. module: pos_event_sale +#: model:ir.model,name:pos_event_sale.model_event_event +#: model:ir.model.fields,field_description:pos_event_sale.field_pos_order_line__event_id +#: model:ir.model.fields,field_description:pos_event_sale.field_report_pos_order__event_id +#: model_terms:ir.ui.view,arch_db:pos_event_sale.view_report_pos_order_search +msgid "Event" +msgstr "" + +#. module: pos_event_sale +#: model:ir.model,name:pos_event_sale.model_event_registration +msgid "Event Registration" +msgstr "" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_pos_order__event_registrations_count +msgid "Event Registration Count" +msgstr "" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_pos_order__event_registration_ids +#: model:ir.model.fields,field_description:pos_event_sale.field_pos_order_line__event_registration_ids +msgid "Event Registrations" +msgstr "" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_pos_config__iface_event_seats_available_warning +msgid "Event Seats Available Warning" +msgstr "" + +#. module: pos_event_sale +#: model_terms:ir.ui.view,arch_db:pos_event_sale.pos_config_view_form +msgid "Event Stages" +msgstr "" + +#. module: pos_event_sale +#: model_terms:ir.ui.view,arch_db:pos_event_sale.pos_config_view_form +msgid "Event Tags" +msgstr "" + +#. module: pos_event_sale +#: model:ir.model,name:pos_event_sale.model_event_type_ticket +msgid "Event Template Ticket" +msgstr "" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_pos_order_line__event_ticket_id +#: model:ir.model.fields,field_description:pos_event_sale.field_report_pos_order__event_ticket_id +#: model_terms:ir.ui.view,arch_db:pos_event_sale.view_report_pos_order_search +msgid "Event Ticket" +msgstr "" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/js/db.js:0 +#, python-format +msgid "Event Ticket not found: %d" +msgstr "" + +#. module: pos_event_sale +#: model_terms:ir.ui.view,arch_db:pos_event_sale.pos_config_view_form +msgid "Event Types" +msgstr "" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/js/Screens/ProductScreen.js:0 +#, python-format +msgid "Event availability error" +msgstr "" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/js/db.js:0 +#, python-format +msgid "Event not found: %d" +msgstr "" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_pos_config__iface_event_sale +msgid "Events" +msgstr "" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/xml/EventTicketsPopup/EventTicketItem.xml:0 +#, python-format +msgid "Free" +msgstr "" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/xml/EventSelectorPopup/EventItem.xml:0 +#: code:addons/pos_event_sale/static/src/xml/ReceiptScreen/EventRegistrationReceipt.xml:0 +#, python-format +msgid "From" +msgstr "" + +#. module: pos_event_sale +#: model:ir.model.fields,help:pos_event_sale.field_pos_config__iface_available_event_stage_ids +msgid "" +"Limit the available events for this Point of Sale.\n" +"Leave empty to load all stages." +msgstr "" + +#. module: pos_event_sale +#: model:ir.model.fields,help:pos_event_sale.field_pos_config__iface_available_event_tag_ids +msgid "" +"Limit the available events for this Point of Sale.\n" +"Leave empty to load all tags." +msgstr "" + +#. module: pos_event_sale +#: model:ir.model.fields,help:pos_event_sale.field_pos_config__iface_available_event_type_ids +msgid "" +"Limit the available events for this Point of Sale.\n" +"Leave empty to load all types." +msgstr "" + +#. module: pos_event_sale +#: model_terms:ir.ui.view,arch_db:pos_event_sale.pos_config_view_form +msgid "Load" +msgstr "" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_pos_config__iface_event_load_days_after +msgid "Load future events (days)" +msgstr "" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_pos_config__iface_event_load_days_before +msgid "Load past events (days)" +msgstr "" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/xml/ReceiptScreen/EventRegistrationReceipt.xml:0 +#, python-format +msgid "Logo" +msgstr "" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/js/Popups/EventSelectorPopup/EventFilters.js:0 +#, python-format +msgid "Name" +msgstr "" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/js/models/Orderline.js:0 +#, python-format +msgid "Not enough available seats for %s" +msgstr "" + +#. module: pos_event_sale +#: model:ir.model.fields,help:pos_event_sale.field_pos_config__iface_event_load_days_before +msgid "" +"Number of days before today, to load past events.\n" +"Set to -1 to load all past events." +msgstr "" + +#. module: pos_event_sale +#: model:ir.model.fields,help:pos_event_sale.field_pos_config__iface_event_load_days_after +msgid "" +"Number of days in the future to load events.\n" +"Set to -1 to load all future events." +msgstr "" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/js/Popups/EventAvailabilityBadge.js:0 +#, python-format +msgid "Oversold" +msgstr "" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_event_registration__pos_order_id +#: model_terms:ir.ui.view,arch_db:pos_event_sale.event_registration_ticket_view_form +msgid "POS Order" +msgstr "" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_event_registration__pos_order_line_id +msgid "POS Order Line" +msgstr "" + +#. module: pos_event_sale +#: model_terms:ir.ui.view,arch_db:pos_event_sale.view_event_form_inherit_ticket +msgid "POS Sales" +msgstr "" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_event_event__pos_price_subtotal +#: model:ir.model.fields,field_description:pos_event_sale.field_event_session__pos_price_subtotal +msgid "POS Sales (Tax Excluded)" +msgstr "" + +#. module: pos_event_sale +#: model:ir.model.fields,field_description:pos_event_sale.field_event_registration__pos_config_id +msgid "Point of Sale" +msgstr "" + +#. module: pos_event_sale +#: model:ir.model,name:pos_event_sale.model_pos_config +msgid "Point of Sale Configuration" +msgstr "" + +#. module: pos_event_sale +#: model:ir.model,name:pos_event_sale.model_pos_order_line +msgid "Point of Sale Order Lines" +msgstr "" + +#. module: pos_event_sale +#: model:ir.model,name:pos_event_sale.model_pos_order +msgid "Point of Sale Orders" +msgstr "" + +#. module: pos_event_sale +#: model:ir.model,name:pos_event_sale.model_report_pos_order +msgid "Point of Sale Orders Report" +msgstr "" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/js/Popups/EventSelectorPopup/EventSelectorPopup.js:0 +#, python-format +msgid "Product" +msgstr "" + +#. module: pos_event_sale +#: code:addons/pos_event_sale/models/pos_order_line.py:0 +#, python-format +msgid "Refunded on %s" +msgstr "" + +#. module: pos_event_sale +#: model:ir.model.fields,help:pos_event_sale.field_pos_config__iface_event_sale +msgid "Sell events on this point of sale." +msgstr "" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/js/Popups/EventAvailabilityBadge.js:0 +#, python-format +msgid "Sold out" +msgstr "" + +#. module: pos_event_sale +#: model_terms:ir.ui.view,arch_db:pos_event_sale.pos_config_view_form +msgid "Stages" +msgstr "" + +#. module: pos_event_sale +#: model_terms:ir.ui.view,arch_db:pos_event_sale.pos_config_view_form +msgid "Tags" +msgstr "" + +#. module: pos_event_sale +#: model:ir.model.fields,help:pos_event_sale.field_event_registration__pos_config_id +msgid "The physical point of sale you will use." +msgstr "" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/xml/EventTicketsPopup/EventTicketList.xml:0 +#, python-format +msgid "There are no available event tickets for this event." +msgstr "" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/xml/EventSelectorPopup/EventList.xml:0 +#, python-format +msgid "There are no events on these dates." +msgstr "" + +#. module: pos_event_sale +#: model:ir.model.fields,help:pos_event_sale.field_event_event_ticket__available_in_pos +#: model:ir.model.fields,help:pos_event_sale.field_event_type_ticket__available_in_pos +msgid "" +"This is configured on the related Product.\n" +"\n" +"Please note that for the ticket to be available in the Point of Sale, the ticket's product has to be available there, too." +msgstr "" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/xml/EventSelectorPopup/EventItem.xml:0 +#: code:addons/pos_event_sale/static/src/xml/ReceiptScreen/EventRegistrationReceipt.xml:0 +#, python-format +msgid "To" +msgstr "" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/js/Popups/EventSelectorPopup/EventCalendar.js:0 +#, python-format +msgid "Today" +msgstr "" + +#. module: pos_event_sale +#: model_terms:ir.ui.view,arch_db:pos_event_sale.view_event_form_inherit_ticket +msgid "Total POS Sales for this event" +msgstr "" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/js/Popups/EventSelectorPopup/EventFilters.js:0 +#, python-format +msgid "Type" +msgstr "" + +#. module: pos_event_sale +#: model_terms:ir.ui.view,arch_db:pos_event_sale.pos_config_view_form +msgid "Types" +msgstr "" + +#. module: pos_event_sale +#. openerp-web +#: code:addons/pos_event_sale/static/src/js/models/Order.js:0 +#, python-format +msgid "" +"Unable to check event tickets availability. Check the internet connection " +"then try again." +msgstr "" + +#. module: pos_event_sale +#: model_terms:ir.ui.view,arch_db:pos_event_sale.view_pos_pos_form +msgid "View Event Attendees" +msgstr "" + +#. module: pos_event_sale +#: model_terms:ir.ui.view,arch_db:pos_event_sale.pos_config_view_form +msgid "and" +msgstr "" + +#. module: pos_event_sale +#: model_terms:ir.ui.view,arch_db:pos_event_sale.pos_config_view_form +msgid "days after today" +msgstr "" + +#. module: pos_event_sale +#: model_terms:ir.ui.view,arch_db:pos_event_sale.pos_config_view_form +msgid "days before today," +msgstr "" diff --git a/pos_event_sale/models/__init__.py b/pos_event_sale/models/__init__.py new file mode 100644 index 0000000000..1212a0ef85 --- /dev/null +++ b/pos_event_sale/models/__init__.py @@ -0,0 +1,9 @@ +from . import event_event +from . import event_mail +from . import event_registration +from . import event_ticket +from . import pos_order +from . import pos_order_line +from . import pos_config +from . import pos_session +from . import res_config_settings diff --git a/pos_event_sale/models/event_event.py b/pos_event_sale/models/event_event.py new file mode 100644 index 0000000000..5724de2781 --- /dev/null +++ b/pos_event_sale/models/event_event.py @@ -0,0 +1,77 @@ +# Copyright 2021 Camptocamp (https://www.camptocamp.com). +# @author Iván Todorovich +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class Event(models.Model): + _inherit = "event.event" + + pos_order_line_ids = fields.One2many( + comodel_name="pos.order.line", + inverse_name="event_id", + groups="point_of_sale.group_pos_user", + string="All the PoS Order Lines pointing to this event", + readonly=True, + ) + pos_price_subtotal = fields.Monetary( + string="POS Sales (Tax Excluded)", + compute="_compute_pos_price_subtotal", + groups="point_of_sale.group_pos_user", + ) + + @api.depends( + "currency_id", + "pos_order_line_ids.price_subtotal", + "pos_order_line_ids.currency_id", + "pos_order_line_ids.company_id", + "pos_order_line_ids.order_id.date_order", + ) + def _compute_pos_price_subtotal(self): + """Compute POS Sales Amount + + This method is similar to upstream's :meth:`~_compute_sale_price_subtotal` + only here we consider sales coming from pos.order.line(s). + + In theory we could merge them both together to compute a total Sales amount, + coming from both sale.order and pos.order. However, in order to provide two + separate smart buttons with proper information, one for each model, it's better + to split them. + """ + date_now = fields.Datetime.now() + sale_price_by_event = {} + if self.ids: + line_ids = self.env["pos.order.line"].search( + [ + ("event_id", "in", self.ids), + ("order_id.state", "!=", "cancel"), + ("price_subtotal", "!=", 0), + ] + ) + for line_id in line_ids: + sale_price = line_id.event_id.currency_id._convert( + line_id.price_subtotal, + line_id.currency_id, + line_id.event_id.company_id, + date_now, + ) + if line_id.event_id.id in sale_price_by_event: + sale_price_by_event[line_id.event_id.id] += sale_price + else: + sale_price_by_event[line_id.event_id.id] = sale_price + + for rec in self: + rec.pos_price_subtotal = sale_price_by_event.get( + rec._origin.id or rec.id, 0 + ) + + def action_view_pos_orders(self): + action = self.env["ir.actions.actions"]._for_xml_id( + "point_of_sale.action_pos_pos_form" + ) + action["domain"] = [ + ("state", "!=", "cancel"), + ("lines.event_id", "in", self.ids), + ] + return action diff --git a/pos_event_sale/models/event_mail.py b/pos_event_sale/models/event_mail.py new file mode 100644 index 0000000000..4b56d651b8 --- /dev/null +++ b/pos_event_sale/models/event_mail.py @@ -0,0 +1,22 @@ +############################################################################## +# Copyright (c) 2023 braintec AG (https://braintec.com) +# All Rights Reserved +# +# Licensed under the AGPL-3.0 (http://www.gnu.org/licenses/agpl.html) +# See LICENSE file for full licensing details. +############################################################################## + +from odoo import models + + +class EventMail(models.Model): + _inherit = "event.mail" + + def _create_missing_mail_registrations(self, registrations): + """Create mail registrations just for those partners with email. + + This way we also prevent long delays in the POS, at the time of the order validation. + """ + return super()._create_missing_mail_registrations( + registrations.filtered("email") + ) diff --git a/pos_event_sale/models/event_registration.py b/pos_event_sale/models/event_registration.py new file mode 100644 index 0000000000..bd0cf5167e --- /dev/null +++ b/pos_event_sale/models/event_registration.py @@ -0,0 +1,78 @@ +# Copyright 2021 Camptocamp (https://www.camptocamp.com). +# @author Iván Todorovich +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models +from odoo.tools import float_is_zero + + +class EventRegistration(models.Model): + _inherit = "event.registration" + + pos_order_line_id = fields.Many2one( + comodel_name="pos.order.line", + string="POS Order Line", + ondelete="cascade", + readonly=True, + copy=False, + ) + pos_order_id = fields.Many2one( + comodel_name="pos.order", + string="POS Order", + related="pos_order_line_id.order_id", + store=True, + ondelete="cascade", + copy=False, + ) + pos_config_id = fields.Many2one( + comodel_name="pos.config", + string="Point of Sale", + related="pos_order_id.config_id", + ) + + @api.depends("pos_order_line_id.currency_id", "pos_order_line_id.price_subtotal") + def _compute_payment_status(self): + # Override to compute it for registrations created from PoS orders. + # The original method only considers Sales Orders. + res = super()._compute_payment_status() + for rec in self: + if not rec.pos_order_line_id: + continue + if float_is_zero( + rec.pos_order_line_id.price_subtotal, + precision_digits=rec.pos_order_line_id.currency_id.rounding, + ): + rec.payment_status = "free" + elif rec.is_paid: + rec.payment_status = "paid" + else: + rec.payment_status = "to_pay" + return res + + def _check_auto_confirmation(self): + # OVERRIDE to disable auto confirmation for registrations created from + # PoS orders. We confirm them explicitly when the orders are paid. + if any(rec.pos_order_line_id for rec in self): + return False + return super()._check_auto_confirmation() + + @api.model_create_multi + def create(self, vals_list): + # Override to post the origin-link message. + # There's a similar implementation for Sales Orders in module `event_sale`. + records = super().create(vals_list) + for rec in records.filtered("pos_order_id"): + rec.message_post_with_view( + "mail.message_origin_link", + values={"self": rec, "origin": rec.pos_order_id.session_id}, + subtype_id=self.env.ref("mail.mt_note").id, + ) + return records + + def action_view_pos_order(self): + action = self.env["ir.actions.actions"]._for_xml_id( + "point_of_sale.action_pos_pos_form" + ) + action["views"] = [(False, "form")] + action["res_id"] = self.pos_order_id.id + return action diff --git a/pos_event_sale/models/event_ticket.py b/pos_event_sale/models/event_ticket.py new file mode 100644 index 0000000000..0a89803477 --- /dev/null +++ b/pos_event_sale/models/event_ticket.py @@ -0,0 +1,16 @@ +# Copyright 2021 Camptocamp (https://www.camptocamp.com). +# @author Iván Todorovich +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class EventTypeTicket(models.Model): + _inherit = "event.type.ticket" + + available_in_pos = fields.Boolean( + related="product_id.available_in_pos", + help="This is configured on the related Product.\n\n" + "Please note that for the ticket to be available in the Point of Sale, " + "the ticket's product has to be available there, too.", + ) diff --git a/pos_event_sale/models/pos_config.py b/pos_event_sale/models/pos_config.py new file mode 100644 index 0000000000..983f89cb90 --- /dev/null +++ b/pos_event_sale/models/pos_config.py @@ -0,0 +1,55 @@ +# Copyright 2021 Camptocamp (https://www.camptocamp.com). +# @author Iván Todorovich +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class PosConfig(models.Model): + _inherit = "pos.config" + + iface_event_sale = fields.Boolean( + "Events", + help="Sell events on this point of sale.", + default=True, + ) + iface_available_event_stage_ids = fields.Many2many( + "event.stage", + string="Available Event Stages", + help="Limit the available events for this Point of Sale.\n" + "Leave empty to load all stages.", + default=lambda self: self._default_available_event_stage_ids(), + ) + iface_available_event_type_ids = fields.Many2many( + "event.type", + string="Available Event Types", + help="Limit the available events for this Point of Sale.\n" + "Leave empty to load all types.", + ) + iface_available_event_tag_ids = fields.Many2many( + "event.tag", + string="Available Event Tags", + help="Limit the available events for this Point of Sale.\n" + "Leave empty to load all tags.", + ) + iface_event_seats_available_warning = fields.Integer( + "Event Seats Available Warning", + help="Display a warning when available seats is below this quantity.", + default=10, + ) + iface_event_load_days_before = fields.Integer( + string="Load past events (days)", + help="Number of days before today, to load past events.\n" + "Set to -1 to load all past events.", + default=0, + ) + iface_event_load_days_after = fields.Integer( + string="Load future events (days)", + help="Number of days in the future to load events.\n" + "Set to -1 to load all future events.", + default=-1, + ) + + @api.model + def _default_available_event_stage_ids(self): + return self.env["event.stage"].search([("pipe_end", "=", False)]) diff --git a/pos_event_sale/models/pos_order.py b/pos_event_sale/models/pos_order.py new file mode 100644 index 0000000000..c9dbc82359 --- /dev/null +++ b/pos_event_sale/models/pos_order.py @@ -0,0 +1,56 @@ +# Copyright 2021 Camptocamp (https://www.camptocamp.com). +# @author Iván Todorovich +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class PosOrder(models.Model): + _inherit = "pos.order" + + event_registrations_count = fields.Integer( + string="Event Registration Count", + compute="_compute_event_registrations_count", + ) + event_registration_ids = fields.One2many( + "event.registration", + "pos_order_id", + string="Event Registrations", + readonly=True, + ) + + def _compute_event_registrations_count(self): + count = self.env["event.registration"]._read_group( + [("pos_order_id", "in", self.ids)], + fields=["pos_order_id"], + groupby=["pos_order_id"], + ) + count_map = {x["pos_order_id"][0]: x["pos_order_id_count"] for x in count} + for rec in self: + rec.event_registrations_count = count_map.get(rec.id, 0) + + def action_open_event_registrations(self): + action = self.env["ir.actions.actions"]._for_xml_id( + "event.event_registration_action_tree" + ) + action["domain"] = [("pos_order_id", "in", self.ids)] + return action + + def action_pos_order_paid(self): + res = super().action_pos_order_paid() + self.lines._cancel_negated_event_registrations() + self.lines._cancel_refunded_event_registrations() + to_confirm = self.event_registration_ids.filtered(lambda r: r.state == "draft") + to_confirm.action_confirm() + to_confirm._action_set_paid() + return res + + def action_pos_order_cancel(self): + res = super().action_pos_order_cancel() + to_cancel = self.event_registration_ids.filtered(lambda r: r.state != "done") + to_cancel.action_cancel() + return res + + def unlink(self): + self.event_registration_ids.unlink() + return super().unlink() diff --git a/pos_event_sale/models/pos_order_line.py b/pos_event_sale/models/pos_order_line.py new file mode 100644 index 0000000000..44e6404044 --- /dev/null +++ b/pos_event_sale/models/pos_order_line.py @@ -0,0 +1,140 @@ +# Copyright 2021 Camptocamp (https://www.camptocamp.com). +# @author Iván Todorovich +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models + + +class PosOrderLine(models.Model): + _inherit = "pos.order.line" + + event_ticket_id = fields.Many2one( + comodel_name="event.event.ticket", + string="Event Ticket", + readonly=True, + ) + event_id = fields.Many2one( + comodel_name="event.event", + string="Event", + related="event_ticket_id.event_id", + store=True, + readonly=True, + ) + event_registration_ids = fields.One2many( + comodel_name="event.registration", + inverse_name="pos_order_line_id", + string="Event Registrations", + readonly=True, + ) + # Make currency_id stored in order to compute + # :meth:`event_event._compute_sale_price_subtotal` + currency_id = fields.Many2one(store=True) + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + records._create_event_registrations() + return records + + def _prepare_event_registration_vals(self): + self.ensure_one() + vals = { + "pos_order_line_id": self.id, + "event_ticket_id": self.event_ticket_id.id, + "event_id": self.event_id.id, + "partner_id": self.order_id.partner_id.id, + "name": self.order_id.partner_id.name, + "email": self.order_id.partner_id.email, + "state": "draft", + } + return vals + + def _prepare_refund_data(self, refund_order, PosOrderLineLot): + # OVERRIDE to add the event the event.ticket info to refunds + res = super()._prepare_refund_data(refund_order, PosOrderLineLot) + if self.event_ticket_id: + res["event_ticket_id"] = self.event_ticket_id.id + return res + + def _create_event_registrations(self): + """Create the missing event.registrations for this order line""" + registrations = self.env["event.registration"] + for line in self: + if not line.event_ticket_id: # pragma: no cover + continue + qty_existing = len( + line.event_registration_ids.filtered(lambda r: r.state != "cancel") + ) + qty_to_create = max(0, int(line.qty) - qty_existing) + if not qty_to_create: # pragma: no cover + continue + vals = line._prepare_event_registration_vals() + vals_list = [vals.copy() for __ in range(0, qty_to_create)] + registrations += self.env["event.registration"].create(vals_list) + return registrations + + def _cancel_refunded_event_registrations(self): + """Cancel refunded event registrations""" + for line in self: + if not line.event_ticket_id or not line.refunded_orderline_id: + continue + to_cancel_qty = max(0, -int(line.qty)) + open_registrations = ( + line.refunded_orderline_id.event_registration_ids.filtered( + lambda reg: reg.state == "open" + ) + ) + to_cancel_registrations = open_registrations[-to_cancel_qty:] + to_cancel_registrations.action_cancel() + for registration in to_cancel_registrations: + registration.message_post( + body=_("Refunded on %s", line.order_id.session_id.name) + ) + + def _find_event_registrations_to_negate(self): + """Find the registrations that could be negated by this order line""" + self.ensure_one() + return self.order_id.event_registration_ids.filtered( + lambda r: ( + r.event_ticket_id + and r.event_ticket_id == self.event_ticket_id + and r.state == "draft" + ) + ) + + def _cancel_negated_event_registrations(self): + """Cancel negated event registrations + + This happens when the order contains a negative event line + that is not a refund of another order's line. + + For example: + + * Line 1: 10 tickets for event A + * Line 2: -5 tickets for event A + + In this case, we need to cancel 5 registrations of the first + order line, as they are negated by the second line. + + These registrations are never confirmed. They go from `draft` + to `cancel` directly. + """ + to_process = self.filtered( + lambda line: ( + line.event_ticket_id + and int(line.qty) < 0 + and not line.refunded_orderline_id + ) + ) + for line in to_process: + qty_to_cancel = max(0, -int(line.qty)) + registrations = line._find_event_registrations_to_negate() + registrations = registrations[-qty_to_cancel:] + registrations.action_cancel() + + def _export_for_ui(self, orderline): + # OVERRIDE to add event_ticket_id + res = super()._export_for_ui(orderline) + res["event_ticket_id"] = orderline.event_ticket_id.id + res["event_registration_ids"] = orderline.event_registration_ids.ids + return res diff --git a/pos_event_sale/models/pos_session.py b/pos_event_sale/models/pos_session.py new file mode 100644 index 0000000000..cb084aa0f1 --- /dev/null +++ b/pos_event_sale/models/pos_session.py @@ -0,0 +1,148 @@ +############################################################################## +# Copyright (c) 2023 braintec AG (https://braintec.com) +# All Rights Reserved +# +# Licensed under the AGPL-3.0 (http://www.gnu.org/licenses/agpl.html). +# See LICENSE file for full licensing details. +############################################################################## + +from odoo import api, fields, models +from odoo.tools import date_utils + + +class PosSession(models.Model): + _inherit = "pos.session" + + @api.model + def _pos_ui_models_to_load(self): + models_to_load = super()._pos_ui_models_to_load() + models_to_load.extend( + [ + "event.event", + "event.event.ticket", + "event.tag.category", + "event.tag", + ] + ) + return models_to_load + + def _get_pos_ui_event_event(self, params): + if self.config_id.iface_event_sale: + return self.env["event.event"].search_read(**params["search_params"]) + return [] + + def _loader_params_event_event(self): + domain = [ + ("company_id", "in", (False, self.config_id.company_id[0].id)), + ("event_ticket_ids.product_id.active", "=", True), + ("event_ticket_ids.available_in_pos", "=", True), + ] + + if self.config_id.iface_available_event_stage_ids: + event_stage_ids = self.config_id.iface_available_event_stage_ids + domain.append(("stage_id", "in", event_stage_ids.ids)) + + if self.config_id.iface_available_event_type_ids: + event_type_ids = self.config_id.iface_available_event_type_ids + domain.append(("event_type_id", "in", event_type_ids)) + + if self.config_id.iface_available_event_tag_ids: + event_tag_ids = self.config_id.iface_available_event_tag_ids + domain.append(("tag_ids", "in", event_tag_ids)) + + if self.config_id.iface_event_load_days_before >= 0: + date_end = date_utils.subtract( + fields.Date.today(), self.config_id.iface_event_load_days_before, "days" + ) + domain.append(("date_end", ">=", date_end)) + + if self.config_id.iface_event_load_days_after >= 0: + date_start = date_utils.add( + fields.Date.today(), self.config_id.iface_event_load_days_after, "days" + ) + domain.append(("date_start", "<=", date_start)) + + fields_list = [ + "name", + "display_name", + "event_type_id", + "tag_ids", + "country_id", + "date_begin", + "date_end", + "date_tz", + "seats_limited", + "seats_available", + ] + + return { + "search_params": { + "domain": domain, + "fields": fields_list, + }, + } + + def _get_pos_ui_event_event_ticket(self, params): + if self.config_id.iface_event_sale: + return self.env["event.event.ticket"].search_read(**params["search_params"]) + return [] + + def _loader_params_event_event_ticket(self): + domain = [ + ("product_id.active", "=", True), + ("available_in_pos", "=", True), + ] + + fields_list = [ + "name", + "description", + "event_id", + "product_id", + "price", + "seats_limited", + "seats_available", + ] + + return { + "search_params": { + "domain": domain, + "fields": fields_list, + }, + } + + def _get_pos_ui_event_tag_category(self, params): + if self.config_id.iface_event_sale: + return self.env["event.tag.category"].search_read(**params["search_params"]) + return [] + + def _loader_params_event_tag_category(self): + return { + "search_params": { + "domain": [], + "fields": [ + "name", + ], + }, + } + + def _get_pos_ui_event_tag(self, params): + if self.config_id.iface_event_sale: + return self.env["event.tag"].search_read(**params["search_params"]) + return [] + + def _loader_params_event_tag(self): + return { + "search_params": { + "domain": [], + "fields": [ + "name", + "category_id", + "color", + ], + }, + } + + def _loader_params_product_product(self): + params = super()._loader_params_product_product() + params["search_params"]["fields"].append("detailed_type") + return params diff --git a/pos_event_sale/models/res_config_settings.py b/pos_event_sale/models/res_config_settings.py new file mode 100644 index 0000000000..051b2ab545 --- /dev/null +++ b/pos_event_sale/models/res_config_settings.py @@ -0,0 +1,31 @@ +# Copyright 2021 Camptocamp (https://www.camptocamp.com). +# @author Iván Todorovich +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + pos_iface_event_sale = fields.Boolean( + related="pos_config_id.iface_event_sale", readonly=False + ) + pos_iface_available_event_stage_ids = fields.Many2many( + related="pos_config_id.iface_available_event_stage_ids", readonly=False + ) + pos_iface_available_event_type_ids = fields.Many2many( + related="pos_config_id.iface_available_event_type_ids", readonly=False + ) + pos_iface_available_event_tag_ids = fields.Many2many( + related="pos_config_id.iface_available_event_tag_ids", readonly=False + ) + pos_iface_event_seats_available_warning = fields.Integer( + related="pos_config_id.iface_event_seats_available_warning", readonly=False + ) + pos_iface_event_load_days_before = fields.Integer( + related="pos_config_id.iface_event_load_days_before", readonly=False + ) + pos_iface_event_load_days_after = fields.Integer( + related="pos_config_id.iface_event_load_days_after", readonly=False + ) diff --git a/pos_event_sale/readme/CONFIGURE.rst b/pos_event_sale/readme/CONFIGURE.rst new file mode 100644 index 0000000000..d8855e87c1 --- /dev/null +++ b/pos_event_sale/readme/CONFIGURE.rst @@ -0,0 +1,8 @@ +Go to *Point of Sale > Configuration > Point of Sale* and enable *Sell Events* +on the desired PoS. + +Optionally, you can choose to filter on a list of Event Types. + +Enable *Available in PoS* on all event products related to event tickets you +want to sell in PoS. If the ticket product is not available on the pos, +the ticket won't be available neither. diff --git a/pos_event_sale/readme/CONTRIBUTORS.rst b/pos_event_sale/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..044ed46391 --- /dev/null +++ b/pos_event_sale/readme/CONTRIBUTORS.rst @@ -0,0 +1,7 @@ +* `Camptocamp `_ + + * Iván Todorovich + +* `Moka Tourisme `_ + + * Grégory Schreiner diff --git a/pos_event_sale/readme/DESCRIPTION.rst b/pos_event_sale/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..830eafc160 --- /dev/null +++ b/pos_event_sale/readme/DESCRIPTION.rst @@ -0,0 +1 @@ +Sell events tickets from the Point of Sale diff --git a/pos_event_sale/readme/ROADMAP.rst b/pos_event_sale/readme/ROADMAP.rst new file mode 100644 index 0000000000..ae936fa16a --- /dev/null +++ b/pos_event_sale/readme/ROADMAP.rst @@ -0,0 +1,3 @@ + +* Handle event registration details. It could be another popup right + before going to payment (similar to core `event_sale` workflow). diff --git a/pos_event_sale/readme/USAGE.rst b/pos_event_sale/readme/USAGE.rst new file mode 100644 index 0000000000..e9ff6256c8 --- /dev/null +++ b/pos_event_sale/readme/USAGE.rst @@ -0,0 +1,12 @@ + +- Click on the Add Event button, or on any Event product. + +.. image:: ../static/description/add_event_button.png + +- Use the calendar widget to filter the events, and click on one. + +.. image:: ../static/description/event_calendar.png + +- Add as many Tickets as you want + +.. image:: ../static/description/ticket_selector.png diff --git a/pos_event_sale/reports/__init__.py b/pos_event_sale/reports/__init__.py new file mode 100644 index 0000000000..a22a048147 --- /dev/null +++ b/pos_event_sale/reports/__init__.py @@ -0,0 +1 @@ +from . import report_pos_order diff --git a/pos_event_sale/reports/report_pos_order.py b/pos_event_sale/reports/report_pos_order.py new file mode 100644 index 0000000000..101d4ad8a3 --- /dev/null +++ b/pos_event_sale/reports/report_pos_order.py @@ -0,0 +1,43 @@ +# Copyright 2021 Camptocamp (https://www.camptocamp.com). +# @author Iván Todorovich +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class PosOrderReport(models.Model): + _inherit = "report.pos.order" + + event_ticket_id = fields.Many2one( + comodel_name="event.event.ticket", + string="Event Ticket", + readonly=True, + ) + event_id = fields.Many2one( + comodel_name="event.event", + string="Event", + readonly=True, + ) + + def _select(self): + res = super()._select() + return f""" + {res}, + l.event_ticket_id AS event_ticket_id, + event_event_ticket.event_id AS event_id + """ + + def _from(self): + res = super()._from() + return f""" + {res} + LEFT JOIN event_event_ticket ON (l.event_ticket_id = event_event_ticket.id) + """ + + def _group_by(self): + res = super()._group_by() + return f""" + {res}, + l.event_ticket_id, + event_event_ticket.event_id + """ diff --git a/pos_event_sale/reports/report_pos_order.xml b/pos_event_sale/reports/report_pos_order.xml new file mode 100644 index 0000000000..41e4232ad5 --- /dev/null +++ b/pos_event_sale/reports/report_pos_order.xml @@ -0,0 +1,28 @@ + + + + + + report.pos.order + + + + + + + + + + diff --git a/pos_event_sale/security/security.xml b/pos_event_sale/security/security.xml new file mode 100644 index 0000000000..30cbcbc4df --- /dev/null +++ b/pos_event_sale/security/security.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/pos_event_sale/static/description/add_event_button.png b/pos_event_sale/static/description/add_event_button.png new file mode 100644 index 0000000000..92965907cf Binary files /dev/null and b/pos_event_sale/static/description/add_event_button.png differ diff --git a/pos_event_sale/static/description/event_calendar.png b/pos_event_sale/static/description/event_calendar.png new file mode 100644 index 0000000000..8b1a3e9151 Binary files /dev/null and b/pos_event_sale/static/description/event_calendar.png differ diff --git a/pos_event_sale/static/description/icon.png b/pos_event_sale/static/description/icon.png new file mode 100644 index 0000000000..3a0328b516 Binary files /dev/null and b/pos_event_sale/static/description/icon.png differ diff --git a/pos_event_sale/static/description/index.html b/pos_event_sale/static/description/index.html new file mode 100644 index 0000000000..1043b2ae1a --- /dev/null +++ b/pos_event_sale/static/description/index.html @@ -0,0 +1,462 @@ + + + + + + +Point of Sale Events + + + +
+

Point of Sale Events

+ + +

Beta License: AGPL-3 OCA/pos Translate me on Weblate Try me on Runbot

+

Sell events tickets from the Point of Sale

+

Table of contents

+ +
+

Configuration

+

Go to Point of Sale > Configuration > Point of Sale and enable Sell Events +on the desired PoS.

+

Optionally, you can choose to filter on a list of Event Types.

+

Enable Available in PoS on all event products related to event tickets you +want to sell in PoS. If the ticket product is not available on the pos, +the ticket won’t be available neither.

+
+
+

Usage

+
    +
  • Click on the Add Event button, or on any Event product.
  • +
+https://raw.githubusercontent.com/OCA/pos/15.0/pos_event_sale/static/description/add_event_button.png +
    +
  • Use the calendar widget to filter the events, and click on one.
  • +
+https://raw.githubusercontent.com/OCA/pos/15.0/pos_event_sale/static/description/event_calendar.png +
    +
  • Add as many Tickets as you want
  • +
+https://raw.githubusercontent.com/OCA/pos/15.0/pos_event_sale/static/description/ticket_selector.png +
+
+

Known issues / Roadmap

+
    +
  • Handle event registration details. It could be another popup right +before going to payment (similar to core event_sale workflow).
  • +
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Camptocamp
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainer:

+

ivantodorovich

+

This module is part of the OCA/pos project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/pos_event_sale/static/description/ticket_selector.png b/pos_event_sale/static/description/ticket_selector.png new file mode 100644 index 0000000000..630bba1401 Binary files /dev/null and b/pos_event_sale/static/description/ticket_selector.png differ diff --git a/pos_event_sale/static/src/js/ControlButtons/AddEventButton.js b/pos_event_sale/static/src/js/ControlButtons/AddEventButton.js new file mode 100644 index 0000000000..ebb51abc23 --- /dev/null +++ b/pos_event_sale/static/src/js/ControlButtons/AddEventButton.js @@ -0,0 +1,34 @@ +/* + Copyright 2021 Camptocamp (https://www.camptocamp.com). + @author Iván Todorovich + License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +*/ +odoo.define("pos_event_sale.AddEventButton", function (require) { + "use strict"; + + const PosComponent = require("point_of_sale.PosComponent"); + const ProductScreen = require("point_of_sale.ProductScreen"); + const {useListener} = require("@web/core/utils/hooks"); + const Registries = require("point_of_sale.Registries"); + + class AddEventButton extends PosComponent { + setup() { + super.setup(); + useListener("click", this.onClick); + } + async onClick() { + await this.showPopup("EventSelectorPopup", {}); + } + } + AddEventButton.template = "AddEventButton"; + + ProductScreen.addControlButton({ + component: AddEventButton, + condition: function () { + return this.env.pos.config.iface_event_sale; + }, + }); + + Registries.Component.add(AddEventButton); + return AddEventButton; +}); diff --git a/pos_event_sale/static/src/js/Popups/EventAvailabilityBadge.js b/pos_event_sale/static/src/js/Popups/EventAvailabilityBadge.js new file mode 100644 index 0000000000..31177a0b67 --- /dev/null +++ b/pos_event_sale/static/src/js/Popups/EventAvailabilityBadge.js @@ -0,0 +1,43 @@ +/* + Copyright 2021 Camptocamp SA (https://www.camptocamp.com). + @author Iván Todorovich + License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +*/ +odoo.define("pos_event_sale.EventAvailabilityBadge", function (require) { + "use strict"; + + const PosComponent = require("point_of_sale.PosComponent"); + const Registries = require("point_of_sale.Registries"); + + class EventAvailabilityBadge extends PosComponent { + get renderInfo() { + const seatsAvailable = this.props.seatsAvailable; + const warningThreshold = + this.env.pos.config.iface_event_seats_available_warning; + if (seatsAvailable < 0) { + return { + label: this.env._t("Oversold"), + addedClasses: {"bg-danger": true}, + }; + } else if (seatsAvailable === 0) { + return { + label: this.env._t("Sold out"), + addedClasses: {"bg-danger": true}, + }; + } else if (warningThreshold && seatsAvailable <= warningThreshold) { + return { + label: _.str.sprintf(this.env._t("%d remaining"), seatsAvailable), + addedClasses: {"bg-warning": true}, + }; + } + return { + label: false, + addedClasses: {oe_hidden: true}, + }; + } + } + EventAvailabilityBadge.template = "EventAvailabilityBadge"; + + Registries.Component.add(EventAvailabilityBadge); + return EventAvailabilityBadge; +}); diff --git a/pos_event_sale/static/src/js/Popups/EventSelectorPopup/EventCalendar.js b/pos_event_sale/static/src/js/Popups/EventSelectorPopup/EventCalendar.js new file mode 100644 index 0000000000..0273ef4dc6 --- /dev/null +++ b/pos_event_sale/static/src/js/Popups/EventSelectorPopup/EventCalendar.js @@ -0,0 +1,120 @@ +/* + Copyright 2021 Camptocamp SA (https://www.camptocamp.com). + @author Iván Todorovich + License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +*/ +odoo.define("pos_event_sale.EventCalendar", function (require) { + "use strict"; + + const PosComponent = require("point_of_sale.PosComponent"); + const Registries = require("point_of_sale.Registries"); + const {onMounted, onWillUnmount, onWillUpdateProps} = owl; + + class EventCalendar extends PosComponent { + /** + * @param {Object} props.eventsByDate Mapping events to their dates. + */ + setup() { + super.setup(); + this.eventsByDate = this.props.eventsByDate; + onMounted(this.mounted); + onWillUnmount(this.willUnmount); + onWillUpdateProps(this.willUpdateProps); + } + /** + * Documentation here: https://fullcalendar.io/docs/v4/ + * + * @returns fullcalendar options + */ + _getCalendarOptions() { + return { + locale: moment.locale(), + plugins: ["interaction", "dayGrid"], + header: { + left: "title", + center: "", + right: "prev,next today", + }, + buttonText: { + today: this.env._t("Today"), + }, + selectable: true, + unselectAuto: false, + longPressDelay: 0, + height: "auto", + select: ({start, end}) => { + this.trigger("select-dates", {start, end}); + }, + dayRender: ({date, el}) => { + if (this.hasEventsOnDate(date)) { + el.classList.add("has-events"); + } else { + el.classList.remove("has-events"); + } + }, + }; + } + /** + * @returns {Boolean} True if there are events on this date + * @param {Date} date + */ + hasEventsOnDate(date) { + const datekey = moment(date).format("YYYY-MM-DD"); + return this.eventsByDate[datekey] && this.eventsByDate[datekey].length; + } + /** + * Forces a re-render of the DayGrid cells. + */ + renderDayGrid() { + // NOTE: This code is taken from fullcalendar's private implementation + // because there's no public API to do this, apparently. + const dayGrid = this.calendar.view.dayGrid; + const {rowCnt, colCnt} = dayGrid; + const {cells} = dayGrid.props; + const {dateEnv} = dayGrid.context; + for (let row = 0; row < rowCnt; row++) { + for (let col = 0; col < colCnt; col++) { + this.calendar.publiclyTrigger("dayRender", [ + { + date: dateEnv.toDate(cells[row][col].date), + el: dayGrid.getCellEl(row, col), + view: dayGrid.view, + }, + ]); + } + } + } + /** + * @override + */ + mounted() { + this.calendar = new window.FullCalendar.Calendar( + this.el, + this._getCalendarOptions() + ); + this.calendar.render(); + // Select the current date + this.calendar.select(moment().startOf("day").toDate()); + } + /** + * @override + */ + willUnmount() { + this.calendar.destroy(); + } + /** + * @override + */ + willUpdateProps(nextProps) { + const {eventsByDate} = nextProps; + if (this.eventsByDate !== eventsByDate) { + this.eventsByDate = eventsByDate; + this.renderDayGrid(); + } + } + } + EventCalendar.template = "EventCalendar"; + + Registries.Component.add(EventCalendar); + return EventCalendar; +}); diff --git a/pos_event_sale/static/src/js/Popups/EventSelectorPopup/EventFilters.js b/pos_event_sale/static/src/js/Popups/EventSelectorPopup/EventFilters.js new file mode 100644 index 0000000000..f21e0fdac2 --- /dev/null +++ b/pos_event_sale/static/src/js/Popups/EventSelectorPopup/EventFilters.js @@ -0,0 +1,148 @@ +/* + Copyright 2023 Camptocamp SA (https://www.camptocamp.com). + @author Iván Todorovich + License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +*/ +odoo.define("pos_event_sale.EventFilters", function (require) { + "use strict"; + + const PosComponent = require("point_of_sale.PosComponent"); + const Registries = require("point_of_sale.Registries"); + const {useState} = owl; + + class EventFilters extends PosComponent { + setup() { + super.setup(); + this.state = useState({ + filters: this.props.filters, + }); + } + /** + * @property {Object} Configuration for SearchBar component + */ + get searchBarConfig() { + return { + searchFields: new Map([ + ["display_name", this.env._t("Name")], + ["event_type_id", this.env._t("Type")], + ["country_id", this.env._t("Country")], + ]), + filter: { + show: false, + options: new Map(), + }, + defaultSearchDetails: { + fieldName: "display_name", + searchTerm: "", + }, + }; + } + /** + * @property {Array} List of event tag filters to display + */ + get eventTagFilters() { + const filters = []; + for (const category of this.env.pos.db.event_tags) { + filters.push({ + id: category.id, + label: category.name, + options: category.tag_ids.map((tag) => { + return { + label: tag.name, + value: tag.id, + checked: Boolean( + this.state.filters.find( + (filter) => + filter.kind === "tag" && + filter.data.tagID === tag.id + ) + ), + }; + }), + }); + } + return filters; + } + /** + * Remove applied filters that match the given conditions + * + * @param {Function} condition + */ + removeFilters(condition) { + this.state.filters = this.state.filters.filter( + (filter) => !condition(filter) + ); + } + /** + * @event + * @param {Event} event + * @param {String} event.detail.fieldName + * @param {String} event.detail.searchTerm + */ + onSearch(event) { + const {fieldName, searchTerm} = event.detail; + // Clear existing search filters for this fieldName + this.removeFilters( + (filter) => + filter.kind === "search" && filter.data.fieldName === fieldName + ); + // Add the new search filter + if (searchTerm) { + this.state.filters.push({ + kind: "search", + data: {fieldName, searchTerm}, + label: this.searchBarConfig.searchFields.get(fieldName), + value: searchTerm, + }); + } + this.trigger("change", this.state.filters); + } + /** + * @event + * @param {Event} event + * @param {Array} event.detail List of tag IDs + * @param {Object} tagFilter The tag category + */ + onTagsFilterChange(event, tagFilter) { + const tags = event.detail; + // Clear existing tag filters for this category not in the new selected list + this.removeFilters( + (filter) => + filter.kind === "tag" && + filter.data.categoryID === tagFilter.id && + !tags.includes(filter.data.tagID) + ); + // Add new tag filters for this category + for (const tagID of tags) { + if ( + this.state.filters.find( + (filter) => filter.kind === "tag" && filter.data.tagID === tagID + ) + ) { + continue; + } + this.state.filters.push({ + kind: "tag", + data: {tagID, categoryID: tagFilter.id}, + label: tagFilter.label, + value: tagFilter.options.find((option) => option.value === tagID) + .label, + }); + } + this.trigger("change", this.state.filters); + } + /** + * @event + * @param {Event} event + * @param {Number} index Filter index + */ + onRemoveFilter(event, index) { + this.state.filters.splice(index, 1); + this.trigger("change", this.state.filters); + } + } + EventFilters.template = "EventFilters"; + + Registries.Component.add(EventFilters); + return EventFilters; +}); diff --git a/pos_event_sale/static/src/js/Popups/EventSelectorPopup/EventItem.js b/pos_event_sale/static/src/js/Popups/EventSelectorPopup/EventItem.js new file mode 100644 index 0000000000..9e8d5b119c --- /dev/null +++ b/pos_event_sale/static/src/js/Popups/EventSelectorPopup/EventItem.js @@ -0,0 +1,50 @@ +/* + Copyright 2021 Camptocamp SA (https://www.camptocamp.com). + @author Iván Todorovich + License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +*/ +odoo.define("pos_event_sale.EventItem", function (require) { + "use strict"; + + const {useState} = owl; + const PosComponent = require("point_of_sale.PosComponent"); + const Registries = require("point_of_sale.Registries"); + const {onWillRender} = owl; + + class EventItem extends PosComponent { + /** + * @param {Object} props + * @param {Object} props.event + */ + setup() { + super.setup(); + this.state = useState({ + seatsAvailable: this.props.event.getSeatsAvailableReal(), + }); + onWillRender(this.willRender); + } + willRender() { + this.state.seatsAvailable = this.props.event.getSeatsAvailableReal(); + } + get disabled() { + return this.state.seatsAvailable <= 0; + } + get addedClasses() { + return { + disabled: this.disabled, + }; + } + formatDate(date) { + return moment(date).format("lll"); + } + clickEvent() { + if (!this.disabled) { + this.trigger("click-event", this.props); + } + } + } + EventItem.template = "EventItem"; + + Registries.Component.add(EventItem); + return EventItem; +}); diff --git a/pos_event_sale/static/src/js/Popups/EventSelectorPopup/EventList.js b/pos_event_sale/static/src/js/Popups/EventSelectorPopup/EventList.js new file mode 100644 index 0000000000..887732853c --- /dev/null +++ b/pos_event_sale/static/src/js/Popups/EventSelectorPopup/EventList.js @@ -0,0 +1,17 @@ +/* + Copyright 2021 Camptocamp SA (https://www.camptocamp.com). + @author Iván Todorovich + License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +*/ +odoo.define("pos_event_sale.EventList", function (require) { + "use strict"; + + const PosComponent = require("point_of_sale.PosComponent"); + const Registries = require("point_of_sale.Registries"); + + class EventList extends PosComponent {} + EventList.template = "EventList"; + + Registries.Component.add(EventList); + return EventList; +}); diff --git a/pos_event_sale/static/src/js/Popups/EventSelectorPopup/EventSelectorPopup.js b/pos_event_sale/static/src/js/Popups/EventSelectorPopup/EventSelectorPopup.js new file mode 100644 index 0000000000..2aefa4b38b --- /dev/null +++ b/pos_event_sale/static/src/js/Popups/EventSelectorPopup/EventSelectorPopup.js @@ -0,0 +1,190 @@ +/* + Copyright 2021 Camptocamp SA (https://www.camptocamp.com). + @author Iván Todorovich + License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +*/ +odoo.define("pos_event_sale.EventSelectorPopup", function (require) { + "use strict"; + + const {useState} = owl; + const {useListener} = require("@web/core/utils/hooks"); + const {getDatesInRange} = require("pos_event_sale.utils"); + const AbstractAwaitablePopup = require("point_of_sale.AbstractAwaitablePopup"); + const Registries = require("point_of_sale.Registries"); + const {onWillStart} = owl; + + class EventSelectorPopup extends AbstractAwaitablePopup { + /** + * @param {Object} props + * @param {Object} props.product Filter available events by product. + * + * Resolve to { confirmed, payload } when used with showPopup method. + * @confirmed {Boolean} + * @payload {Object} Selected event. + */ + setup() { + super.setup(); + this.state = useState({ + selectedStartDate: moment().startOf("day").toDate(), + selectedEndDate: moment().endOf("day").toDate(), + filters: [], + }); + useListener("select-dates", this.selectDates); + useListener("click-event", this.clickEvent); + // If there's a product, get all events related to this product + // If not, show all available events (use case: Add Event button) + if (this.props.product) { + this.state.filters.push({ + kind: "product", + data: this.props.product.id, + label: this.env._t("Product"), + value: this.props.product.display_name, + }); + } + // Cached properties + this._events = null; + this._eventsByDate = null; + onWillStart(this.willStart); + } + /** + * @override + * + * Optional update of available seats. Fails silently if no internet connection. + * A final availability check is done before paying the order anyways. + */ + async willStart() { + try { + await this.env.pos.db.updateEventSeatsAvailable({ + event_ids: this.env.pos.db.events.map((event) => event.id), + options: { + timeout: 1000, + shadow: true, + }, + }); + } catch (error) { + console.debug(error); + } + } + /** + * Compiles a filter specification into a filter function suitable to filter events. + * + * @param {Object} filter Filter specification + * @param {String} filter.kind Type of filter (e.g.: "product", "name", "tag") + * @param {String} filter.label Human-readable label + * @param {String} filter.value Human-readable value + * @param {any} filter.data + * @returns {Function} Filter function to apply to events. + */ + _compileFilter(filter) { + if (filter.kind === "product") { + const productEvents = this.env.pos.db.getEventsByProductID(filter.data); + return (event) => productEvents.includes(event); + } + if (filter.kind === "search") { + const {fieldName, searchTerm} = filter.data; + const searchTerms = searchTerm.split(" "); + const searchString = (source, terms) => { + const sourceLower = source.toLowerCase(); + return terms.every((term) => + sourceLower.includes(term.toLowerCase()) + ); + }; + /* eslint-disable no-shadow */ + const fieldGetterChar = (event, fieldName) => event[fieldName] || ""; + const fieldGetterMany2one = (event, fieldName) => + event[fieldName] ? event[fieldName][1] : ""; + const fieldGetter = fieldName.endsWith("_id") + ? fieldGetterMany2one + : fieldGetterChar; + return (event) => + searchString(fieldGetter(event, fieldName), searchTerms); + } + if (filter.kind === "tag") { + const {tagID} = filter.data; + return (event) => event.tag_ids && event.tag_ids.includes(tagID); + } + } + /** + * Compile filters + * + * @returns {Function} Filter function to apply to events. + */ + _compileFilters() { + const filterFunctions = this.state.filters.map((filter) => + this._compileFilter(filter) + ); + const filterFunction = (event) => + filterFunctions.every((filter) => filter(event)); + return filterFunction; + } + /** + * @property {Array} events List of filtered events + */ + get events() { + this._events = this.env.pos.db.events.filter(this._compileFilters()); + return this._events; + } + /** + * @property {Object} eventsByDate Mapping of dates and filtered events + */ + get eventsByDate() { + this._eventsByDate = {}; + for (const event of this.events) { + for (const eventDate of event.getEventDates()) { + const key = moment(eventDate).format("YYYY-MM-DD"); + this._eventsByDate[key] = this._eventsByDate[key] || []; + this._eventsByDate[key].push(event); + } + } + return this._eventsByDate; + } + /** + * @property {Array} eventsToDisplay List of events displayed in EventList + */ + get eventsToDisplay() { + const dates = getDatesInRange( + this.state.selectedStartDate, + this.state.selectedEndDate + ); + const keys = dates.map((date) => moment(date).format("YYYY-MM-DD")); + const events = []; + for (const key of keys) { + if (this.eventsByDate[key] && this.eventsByDate[key].length) { + events.push(...this.eventsByDate[key]); + } + } + return _.unique(events); + } + /** + * @event + * @param {Event} event + */ + selectDates(event) { + const {start, end} = event.detail; + this.state.selectedStartDate = start; + this.state.selectedEndDate = moment(end).subtract(1, "seconds").toDate(); + } + /** + * @event + * @param {Event} ev + */ + async clickEvent(ev) { + const {event} = ev.detail; + await this.showPopup("EventTicketsPopup", {event}); + } + /** + * @event + * @param {Event} event + */ + onFiltersChange(event) { + this._events = null; + this._eventsByDate = null; + this.state.filters = event.detail; + this.render(); + } + } + EventSelectorPopup.template = "EventSelectorPopup"; + + Registries.Component.add(EventSelectorPopup); + return EventSelectorPopup; +}); diff --git a/pos_event_sale/static/src/js/Popups/EventTicketsPopup/EventTicketItem.js b/pos_event_sale/static/src/js/Popups/EventTicketsPopup/EventTicketItem.js new file mode 100644 index 0000000000..24cb8bef36 --- /dev/null +++ b/pos_event_sale/static/src/js/Popups/EventTicketsPopup/EventTicketItem.js @@ -0,0 +1,73 @@ +/* + Copyright 2021 Camptocamp SA (https://www.camptocamp.com). + @author Iván Todorovich + License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +*/ +odoo.define("pos_event_sale.EventTicketItem", function (require) { + "use strict"; + + const {useState} = owl; + const PosComponent = require("point_of_sale.PosComponent"); + const Registries = require("point_of_sale.Registries"); + const {onWillRender} = owl; + + class EventTicketItem extends PosComponent { + /** + * @param {Object} props + * @param {Object} props.eventTicket + */ + setup() { + super.setup(); + this.state = useState({ + orderedQty: this.props.eventTicket.getOrderedQuantity(), + seatsAvailable: this.props.eventTicket.getSeatsAvailableReal(), + }); + onWillRender(this.willRendered); + } + willRendered() { + this._updateQuantities(); + } + get imageUrl() { + const product_id = this.props.eventTicket.product_id[0]; + const product = this.env.pos.db.get_product_by_id(product_id); + return `/web/image?model=product.product&field=image_128&id=${product.id}&write_date=${product.write_date}&unique=1`; + } + get pricelist() { + const current_order = this.env.pos.get_order(); + if (current_order) { + return current_order.pricelist; + } + return this.env.pos.default_pricelist; + } + get price() { + const eventTicket = this.props.eventTicket; + return eventTicket + .getProduct() + .get_price(this.pricelist, 1, eventTicket.getPriceExtra()); + } + get priceFormatted() { + return this.env.pos.format_currency(this.price, "Product Price"); + } + get disabled() { + return this.state.seatsAvailable <= 0; + } + get addedClasses() { + return { + disabled: this.disabled, + }; + } + clickEventTicket() { + if (!this.disabled) { + this.trigger("click-event-ticket", this.props.eventTicket); + } + } + _updateQuantities() { + this.state.seatsAvailable = this.props.eventTicket.getSeatsAvailableReal(); + this.state.orderedQty = this.props.eventTicket.getOrderedQuantity(); + } + } + EventTicketItem.template = "EventTicketItem"; + + Registries.Component.add(EventTicketItem); + return EventTicketItem; +}); diff --git a/pos_event_sale/static/src/js/Popups/EventTicketsPopup/EventTicketList.js b/pos_event_sale/static/src/js/Popups/EventTicketsPopup/EventTicketList.js new file mode 100644 index 0000000000..f996258cf1 --- /dev/null +++ b/pos_event_sale/static/src/js/Popups/EventTicketsPopup/EventTicketList.js @@ -0,0 +1,17 @@ +/* + Copyright 2021 Camptocamp SA (https://www.camptocamp.com). + @author Iván Todorovich + License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +*/ +odoo.define("pos_event_sale.EventTicketList", function (require) { + "use strict"; + + const PosComponent = require("point_of_sale.PosComponent"); + const Registries = require("point_of_sale.Registries"); + + class EventTicketList extends PosComponent {} + EventTicketList.template = "EventTicketList"; + + Registries.Component.add(EventTicketList); + return EventTicketList; +}); diff --git a/pos_event_sale/static/src/js/Popups/EventTicketsPopup/EventTicketsPopup.js b/pos_event_sale/static/src/js/Popups/EventTicketsPopup/EventTicketsPopup.js new file mode 100644 index 0000000000..5f6de7f641 --- /dev/null +++ b/pos_event_sale/static/src/js/Popups/EventTicketsPopup/EventTicketsPopup.js @@ -0,0 +1,57 @@ +/* + Copyright 2021 Camptocamp SA (https://www.camptocamp.com). + @author Iván Todorovich + License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +*/ +odoo.define("pos_event_sale.EventTicketsPopup", function (require) { + "use strict"; + + const {useListener} = require("@web/core/utils/hooks"); + const AbstractAwaitablePopup = require("point_of_sale.AbstractAwaitablePopup"); + const Registries = require("point_of_sale.Registries"); + + class EventTicketsPopup extends AbstractAwaitablePopup { + /** + * @param {Object} props + * @param {Object} props.event + */ + setup() { + super.setup(); + useListener("click-event-ticket", this._clickEventTicket); + } + get title() { + return this.props.event.name; + } + get eventTicketsToDisplay() { + return this.props.event.getEventTickets(); + } + get currentOrder() { + return this.env.pos.get_order(); + } + backToOrder() { + this.env.posbus.trigger("close-popup", { + popupId: this.props.id, + response: {confirmed: false, payload: null}, + }); + } + _getAddProductOptions(eventTicket) { + return eventTicket._prepareOrderlineOptions(); + } + _clickEventTicket(ev) { + const eventTicket = ev.detail; + if (!this.currentOrder) { + this.env.pos.add_new_order(); + } + const product = eventTicket.getProduct(); + const options = this._getAddProductOptions(eventTicket); + if (!options) { + return; + } + this.currentOrder.add_product(product, options); + } + } + EventTicketsPopup.template = "EventTicketsPopup"; + + Registries.Component.add(EventTicketsPopup); + return EventTicketsPopup; +}); diff --git a/pos_event_sale/static/src/js/Screens/AbstractReceiptScreen.js b/pos_event_sale/static/src/js/Screens/AbstractReceiptScreen.js new file mode 100644 index 0000000000..027d35c1b5 --- /dev/null +++ b/pos_event_sale/static/src/js/Screens/AbstractReceiptScreen.js @@ -0,0 +1,47 @@ +/* + Copyright 2023 Camptocamp SA (https://www.camptocamp.com). + License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +*/ +odoo.define("pos_event_sale.AbstractReceiptScreen", function (require) { + "use strict"; + + const AbstractReceiptScreen = require("point_of_sale.AbstractReceiptScreen"); + const Registries = require("point_of_sale.Registries"); + + /* eslint-disable no-shadow */ + const PosEventSaleAbstractReceiptScreen = (AbstractReceiptScreen) => + class extends AbstractReceiptScreen { + /** + * Prints the event registration receipts through the printer proxy. + * Doesn't do anything if there's no proxy printer. + * + * @returns {Boolean} + */ + async _printEventRegistrations() { + if (this.env.pos.proxy && this.env.pos.proxy.printer) { + const $receipts = this.el.getElementsByClassName( + "event-registration-receipt" + ); + for (const $receipt of $receipts) { + const printResult = + await this.env.pos.proxy.printer.print_receipt( + $receipt.outerHTML + ); + if (!printResult.successful) { + console.error("Unable to print event registration receipt"); + console.debug(printResult); + return false; + } + } + return true; + } + return false; + } + }; + + Registries.Component.extend( + AbstractReceiptScreen, + PosEventSaleAbstractReceiptScreen + ); + return AbstractReceiptScreen; +}); diff --git a/pos_event_sale/static/src/js/Screens/EventRegistrationReceipt.js b/pos_event_sale/static/src/js/Screens/EventRegistrationReceipt.js new file mode 100644 index 0000000000..39283baa96 --- /dev/null +++ b/pos_event_sale/static/src/js/Screens/EventRegistrationReceipt.js @@ -0,0 +1,49 @@ +/* + Copyright 2021 Camptocamp (https://www.camptocamp.com). + @author Iván Todorovich + License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +*/ +odoo.define("pos_event_sale.EventRegistrationReceipt", function (require) { + "use strict"; + + const PosComponent = require("point_of_sale.PosComponent"); + const Registries = require("point_of_sale.Registries"); + const {onWillUpdateProps} = owl; + + class EventRegistrationReceipt extends PosComponent { + setup() { + super.setup(); + this._receiptEnv = this.props.order.getOrderReceiptEnv(); + onWillUpdateProps(this.willUpdateProps); + } + willUpdateProps(nextProps) { + this._receiptEnv = nextProps.order.getOrderReceiptEnv(); + } + get receiptEnv() { + return this._receiptEnv; + } + get receipt() { + return this.receiptEnv.receipt; + } + get registration() { + return this.props.registration; + } + get event() { + const [event_id] = this.registration.event_id || [false]; + return event_id ? this.env.pos.db.getEventByID(event_id) : undefined; + } + get eventTicket() { + const [event_ticket_id] = this.registration.event_ticket_id || [false]; + return event_ticket_id + ? this.env.pos.db.getEventTicketByID(event_ticket_id) + : undefined; + } + formatDate(date) { + return moment(date).format("lll"); + } + } + EventRegistrationReceipt.template = "EventRegistrationReceipt"; + + Registries.Component.add(EventRegistrationReceipt); + return EventRegistrationReceipt; +}); diff --git a/pos_event_sale/static/src/js/Screens/PaymentScreen.js b/pos_event_sale/static/src/js/Screens/PaymentScreen.js new file mode 100644 index 0000000000..cb11c1eaa4 --- /dev/null +++ b/pos_event_sale/static/src/js/Screens/PaymentScreen.js @@ -0,0 +1,34 @@ +/* + Copyright 2021 Camptocamp (https://www.camptocamp.com). + @author Iván Todorovich + License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +*/ +odoo.define("pos_event_sale.PaymentScreen", function (require) { + "use strict"; + + const PaymentScreen = require("point_of_sale.PaymentScreen"); + const Registries = require("point_of_sale.Registries"); + const session = require("web.session"); + + const PosEventSalePaymentScreen = (PaymentScreen) => + class extends PaymentScreen { + async _postPushOrderResolve(order, server_ids) { + if (order.hasEvents()) { + order.event_registrations = await this.rpc({ + model: "event.registration", + method: "search_read", + domain: [ + ["pos_order_id", "in", server_ids], + ["state", "=", "open"], + ], + kwargs: {context: session.user_context}, + }); + } + return super._postPushOrderResolve(order, server_ids); + } + }; + + Registries.Component.extend(PaymentScreen, PosEventSalePaymentScreen); + + return PaymentScreen; +}); diff --git a/pos_event_sale/static/src/js/Screens/ProductInfoButton.js b/pos_event_sale/static/src/js/Screens/ProductInfoButton.js new file mode 100644 index 0000000000..a56889bcad --- /dev/null +++ b/pos_event_sale/static/src/js/Screens/ProductInfoButton.js @@ -0,0 +1,23 @@ +odoo.define("pos_event_sale.ProductInfoButton", function (require) { + "use strict"; + + const ProductInfoButton = require("point_of_sale.ProductInfoButton"); + const Registries = require("point_of_sale.Registries"); + + /* eslint-disable no-shadow */ + const PosEventSaleProductInfoButton = (ProductInfoButton) => + class extends ProductInfoButton { + async onClick() { + const orderline = this.env.pos.get_order().get_selected_orderline(); + if (orderline) { + if (orderline.get_product().detailed_type == "event") { + return; + } + } + return super.onClick(); + } + }; + + Registries.Component.extend(ProductInfoButton, PosEventSaleProductInfoButton); + return ProductInfoButton; +}); diff --git a/pos_event_sale/static/src/js/Screens/ProductScreen.js b/pos_event_sale/static/src/js/Screens/ProductScreen.js new file mode 100644 index 0000000000..c86cb557b8 --- /dev/null +++ b/pos_event_sale/static/src/js/Screens/ProductScreen.js @@ -0,0 +1,43 @@ +/* + Copyright 2021 Camptocamp SA (https://www.camptocamp.com). + @author Iván Todorovich + License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +*/ +odoo.define("pos_event_sale.ProductScreen", function (require) { + "use strict"; + + const ProductScreen = require("point_of_sale.ProductScreen"); + const Registries = require("point_of_sale.Registries"); + + const PosEventSaleProductScreen = (ProductScreen) => + class extends ProductScreen { + async _clickProduct(event) { + const product = event.detail; + if ( + this.env.pos.config.iface_event_sale && + product.detailed_type === "event" + ) { + return this.showPopup("EventSelectorPopup", {product}); + } + return super._clickProduct(event); + } + _onClickPay() { + // Update and check order events availability before + // going to the payment screen. Prevent paying if error. + if (this.currentOrder) { + this.currentOrder + .updateAndCheckEventAvailability() + .then(() => super._onClickPay(...arguments)) + .catch((error) => { + this.showPopup("ErrorPopup", { + title: this.env._t("Event availability error"), + body: error.message || String(error), + }); + }); + } + } + }; + + Registries.Component.extend(ProductScreen, PosEventSaleProductScreen); + return ProductScreen; +}); diff --git a/pos_event_sale/static/src/js/Screens/ReceiptScreen.js b/pos_event_sale/static/src/js/Screens/ReceiptScreen.js new file mode 100644 index 0000000000..087ec8952e --- /dev/null +++ b/pos_event_sale/static/src/js/Screens/ReceiptScreen.js @@ -0,0 +1,27 @@ +/* + Copyright 2021 Camptocamp (https://www.camptocamp.com). + @author Iván Todorovich + License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +*/ +odoo.define("pos_event_sale.ReceiptScreen", function (require) { + "use strict"; + + const ReceiptScreen = require("point_of_sale.ReceiptScreen"); + const Registries = require("point_of_sale.Registries"); + + /* eslint-disable no-shadow */ + const PosEventSaleReceiptScreen = (ReceiptScreen) => + class extends ReceiptScreen { + /** + * @override + */ + async printReceipt() { + const res = await super.printReceipt(); + await this._printEventRegistrations(); + return res; + } + }; + + Registries.Component.extend(ReceiptScreen, PosEventSaleReceiptScreen); + return ReceiptScreen; +}); diff --git a/pos_event_sale/static/src/js/Screens/ReprintReceiptScreen.js b/pos_event_sale/static/src/js/Screens/ReprintReceiptScreen.js new file mode 100644 index 0000000000..03c126bbd7 --- /dev/null +++ b/pos_event_sale/static/src/js/Screens/ReprintReceiptScreen.js @@ -0,0 +1,50 @@ +/* + Copyright 2023 Camptocamp (https://www.camptocamp.com). + License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +*/ +odoo.define("pos_event_sale.ReprintReceiptScreen", function (require) { + "use strict"; + + const ReprintReceiptScreen = require("point_of_sale.ReprintReceiptScreen"); + const Registries = require("point_of_sale.Registries"); + const session = require("web.session"); + const {onWillStart} = owl; + + /* eslint-disable no-shadow */ + const PosEventSaleReprintReceiptScreen = (ReprintReceiptScreen) => + class extends ReprintReceiptScreen { + setup() { + super.setup(); + onWillStart(this.willStart); + } + + /** + * @override + */ + async willStart() { + const order = this.props.order; + if (order.backendId && order.hasEvents()) { + order.event_registrations = await this.rpc({ + model: "event.registration", + method: "search_read", + domain: [ + ["pos_order_id", "=", order.backendId], + ["state", "=", "open"], + ], + kwargs: {context: session.user_context}, + }); + } + } + /** + * @override + */ + async _printReceipt() { + const res = await super._printReceipt(); + await this._printEventRegistrations(); + return res; + } + }; + + Registries.Component.extend(ReprintReceiptScreen, PosEventSaleReprintReceiptScreen); + return ReprintReceiptScreen; +}); diff --git a/pos_event_sale/static/src/js/Screens/TicketScreen.js b/pos_event_sale/static/src/js/Screens/TicketScreen.js new file mode 100644 index 0000000000..9259c17095 --- /dev/null +++ b/pos_event_sale/static/src/js/Screens/TicketScreen.js @@ -0,0 +1,34 @@ +/* + Copyright 2022 Moka Tourisme (https://www.mokatourisme.fr). + @author Iván Todorovich + License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +*/ +odoo.define("pos_event_sale.TicketScreen", function (require) { + "use strict"; + + const TicketScreen = require("point_of_sale.TicketScreen"); + const Registries = require("point_of_sale.Registries"); + + const PosEventSaleTicketScreen = (TicketScreen) => + class extends TicketScreen { + /** + * @override + */ + _getToRefundDetail(orderline) { + const res = super._getToRefundDetail(...arguments); + res.orderline.event_ticket_id = orderline.event_ticket_id; + return res; + } + /** + * @override + */ + _prepareRefundOrderlineOptions(toRefundDetail) { + const res = super._prepareRefundOrderlineOptions(...arguments); + res.extras.event_ticket_id = toRefundDetail.orderline.event_ticket_id; + return res; + } + }; + + Registries.Component.extend(TicketScreen, PosEventSaleTicketScreen); + return TicketScreen; +}); diff --git a/pos_event_sale/static/src/js/Widgets/MultiSelectButton.js b/pos_event_sale/static/src/js/Widgets/MultiSelectButton.js new file mode 100644 index 0000000000..b9722c0356 --- /dev/null +++ b/pos_event_sale/static/src/js/Widgets/MultiSelectButton.js @@ -0,0 +1,88 @@ +/* + Copyright 2023 Camptocamp (https://www.camptocamp.com). + @author Iván Todorovich + License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +*/ +/* eslint-disable no-unused-vars */ +odoo.define("pos_event_sale.MultiSelectButton", function (require) { + "use strict"; + + const {useState, useExternalListener} = owl; + const PosComponent = require("point_of_sale.PosComponent"); + const Registries = require("point_of_sale.Registries"); + + class MultiSelectButton extends PosComponent { + setup() { + super.setup(); + useExternalListener(window, "click", this.onWindowClick, true); + useExternalListener(window, "keydown", this.onWindowKeydown); + this.state = useState({open: false}); + } + get items() { + return this.props.items; + } + get checked() { + return this.items.filter((item) => item.checked); + } + get values() { + return this.checked.map((item) => item.value); + } + get icon() { + return this.props.icon; + } + get label() { + return this.props.string; + } + toggleOptions() { + this.state.open = !this.state.open; + } + hideOptions() { + if (this.state.open) this.state.open = false; + } + showOptions() { + if (!this.state.open) this.state.open = true; + } + /** + * @event + * @param {Event} event + */ + onClick(event) { + this.toggleOptions(); + } + /** + * @event + * @param {Event} event + */ + onWindowClick(event) { + if ( + !this.el.contains(event.target) && + !this.el.contains(document.activeElement) + ) { + this.hideOptions(); + } + } + /** + * @event + * @param {Event} event + */ + onWindowKeydown(event) { + if (event.key === "Escape") { + this.hideOptions(); + } + } + /** + * @event + * @param {Object} item + */ + onClickItem(item) { + item.checked = !item.checked; + if (this.props.hideOnClick) this.hideOptions(); + this.render(); + this.trigger("change", this.values); + } + } + MultiSelectButton.template = "MultiSelectButton"; + + Registries.Component.add(MultiSelectButton); + return MultiSelectButton; +}); diff --git a/pos_event_sale/static/src/js/db.js b/pos_event_sale/static/src/js/db.js new file mode 100644 index 0000000000..d690daf367 --- /dev/null +++ b/pos_event_sale/static/src/js/db.js @@ -0,0 +1,207 @@ +/* + Copyright 2021 Camptocamp (https://www.camptocamp.com). + @author Iván Todorovich + License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +*/ +odoo.define("pos_event_sale.db", function (require) { + "use strict"; + + const PosDB = require("point_of_sale.DB"); + const rpc = require("web.rpc"); + const {_t} = require("web.core"); + + PosDB.include({ + /** + * @override + */ + init: function () { + this._super.apply(this, arguments); + this.events = []; + this.event_by_id = {}; + this.event_ticket_by_id = {}; + this.event_ticket_by_event_id = {}; + this.event_ticket_by_product_id = {}; + }, + /** + * Adds or updates events loaded in the PoS. + * This method is called on startup, and when updating the event availability. + * It keeps the access map up-to-date, and computes some fields. + * + * @param {Array} events + */ + addEvents: function (events) { + /* eslint-disable no-param-reassign */ + if (!(events instanceof Array)) { + events = [events]; + } + for (const event of events) { + // Localize dates + if (event.date_begin) { + event.date_begin = moment.utc(event.date_begin).toDate(); + } + if (event.date_end) { + event.date_end = moment.utc(event.date_end).toDate(); + } + // Sanitize seats_available and seats_max for unlimited events + // This avoids checking for seats_limited every time. + if (!event.seats_limited) { + event.seats_max = Infinity; + event.seats_available = Infinity; + } + // Add or update local record + // Use object.assign to update current Object, if it already exists + if (this.event_by_id[event.id]) { + Object.assign(this.event_by_id[event.id], event); + } else { + this.event_by_id[event.id] = event; + this.events.push(event); + } + } + }, + /** + * Adds or updates event tickets loaded in the PoS. + * This method is called on startup, and when updating the event availability. + * It keeps the access map up-to-date, and computes some fields. + * + * @param {Array} tickets + */ + addEventTickets: function (tickets) { + /* eslint-disable no-param-reassign */ + if (!(tickets instanceof Array)) { + tickets = [tickets]; + } + for (const ticket of tickets) { + // Sanitize seats_available and seats_max for unlimited tickets + // This avoids checking for seats_limited every time. + if (!ticket.seats_limited) { + ticket.seats_max = Infinity; + ticket.seats_available = Infinity; + } + // Add or update local record + // Use object.assign to update current Object, if it already exists + if (this.event_ticket_by_id[ticket.id]) { + Object.assign(this.event_ticket_by_id[ticket.id], ticket); + } else { + // Ignore ticket updates with missing fields. + // This can happen during the seats availability update. + if (!ticket.event_id) { + continue; + } + // Map event ticket by id + this.event_ticket_by_id[ticket.id] = ticket; + // Map event ticket by event id + if (!this.event_ticket_by_event_id[ticket.event_id[0]]) { + this.event_ticket_by_event_id[ticket.event_id[0]] = []; + } + this.event_ticket_by_event_id[ticket.event_id[0]].push(ticket); + // Map event ticket by product id + if (!this.event_ticket_by_product_id[ticket.product_id[0]]) { + this.event_ticket_by_product_id[ticket.product_id[0]] = []; + } + this.event_ticket_by_product_id[ticket.product_id[0]].push(ticket); + } + } + }, + /** + * @param {Number|Array} event_id + * @param {Boolean} raiseIfNotFound + * @returns the event or list of events if you pass a list of ids. + */ + getEventByID: function (event_id, raiseIfNotFound = true) { + if (event_id instanceof Array) { + return event_id + .map((id) => this.getEventByID(id, raiseIfNotFound)) + .filter(Boolean); + } + const event = this.event_by_id[event_id]; + if (!event && raiseIfNotFound) { + throw new Error(_.str.sprintf(_t("Event not found: %d"), event_id)); + } + return event; + }, + /** + * @param {Number|Array} ticket_id + * @param {Boolean} raiseIfNotFound + * @returns the event ticket or list of event tickets if you pass a list of ids. + */ + getEventTicketByID: function (ticket_id, raiseIfNotFound = true) { + if (ticket_id instanceof Array) { + return ticket_id + .map((id) => this.getEventTicketByID(id, raiseIfNotFound)) + .filter(Boolean); + } + const ticket = this.event_ticket_by_id[ticket_id]; + if (!ticket && raiseIfNotFound) { + throw new Error( + _.str.sprintf(_t("Event Ticket not found: %d"), ticket_id) + ); + } + return ticket; + }, + getEventTicketsByEventID: function (event_id) { + return this.event_ticket_by_event_id[event_id] || []; + }, + getEventsByProductID: function (product_id) { + const tickets = this.getEventTicketsByProductID(product_id); + return _.unique(tickets.map((ticket) => ticket.getEvent())); + }, + getEventTicketsByProductID: function (product_id) { + return this.event_ticket_by_product_id[product_id] || []; + }, + /** + * @returns List of event.event fields to read during availability checks. + */ + _getUpdateEventSeatsAvailableFieldsEventEvent: function () { + return ["id", "seats_limited", "seats_available"]; + }, + /** + * @returns List of event.event.ticket fields to read during availability checks. + */ + _getUpdateEventSeatsAvailableFieldsEventTicket: function () { + return ["id", "seats_limited", "seats_available"]; + }, + /** + * Updates the event seats_available fields from the backend. + * Updates both event.event and their related event.ticket records. + * + * @param {Object} options + * @param {Array} options.event_ids + * @param {Object} options.options passed to rpc.query. Optional + * @returns A promise + */ + updateEventSeatsAvailable: function ({event_ids = [], options = {}}) { + // Update event.event seats_available + const d1 = rpc + .query( + { + model: "event.event", + method: "search_read", + args: [ + [["id", "in", event_ids]], + this._getUpdateEventSeatsAvailableFieldsEventEvent(), + ], + }, + options + ) + .then((events) => this.addEvents(events)); + // Update event.event.ticket seats_available + const d2 = rpc + .query( + { + model: "event.event.ticket", + method: "search_read", + args: [ + [["event_id", "in", event_ids]], + this._getUpdateEventSeatsAvailableFieldsEventTicket(), + ], + }, + options + ) + .then((tickets) => this.addEventTickets(tickets)); + // Resolve when both finish + return Promise.all([d1, d2]); + }, + }); + + return PosDB; +}); diff --git a/pos_event_sale/static/src/js/models/EventEvent.js b/pos_event_sale/static/src/js/models/EventEvent.js new file mode 100644 index 0000000000..119fcecbc6 --- /dev/null +++ b/pos_event_sale/static/src/js/models/EventEvent.js @@ -0,0 +1,91 @@ +/* +Copyright 2021 Camptocamp (https://www.camptocamp.com). +@author Iván Todorovich +License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +*/ +odoo.define("pos_event_sale.EventEvent", function (require) { + "use strict"; + + const Registries = require("point_of_sale.Registries"); + const PosModel = require("pos_event_sale.PosModel"); + const {getDatesInRange} = require("pos_event_sale.utils"); + + class EventEvent extends PosModel { + getEventTickets() { + return this.pos.db.getEventTicketsByEventID(this.id); + } + + /** + * Computes the total ordered quantity for this event. + * + * @param {Order} options.order defaults to the current order + * @returns {Number} ordered quantity + */ + getOrderedQuantity({order} = {}) { + /* eslint-disable no-param-reassign */ + order = order ? order : this.pos.get_order(); + if (!order) { + return 0; + } + return order + .get_orderlines() + .filter((line) => line.getEvent() === this) + .reduce((sum, line) => sum + line.quantity, 0); + } + + /** + * Computes the available places, considering all the ordered quantities in + * the current order. + * + * Please note it doesn't check the available seats against the backend. + * For a real availability check see updateAndCheckEventAvailability. + * + * @param {Object} options - Sent to getOrderedQuantity + * @returns {Number} available seats + */ + getSeatsAvailable(options) { + return this.seats_limited + ? this.seats_available - this.getOrderedQuantity(options) + : this.seats_available; + } + + /** + * Computes the total available places according to event ticket limits. + * + * Please note it doesn't check the available seats against the backend. + * For a real availability check see updateAndCheckEventAvailability. + * + * @param {Object} options - Sent to the ticket's getSeatsAvailable + * @returns {Number} available seats + */ + getTicketSeatsAvailable(options) { + return this.getEventTickets() + .map((ticket) => ticket.getSeatsAvailable(options)) + .reduce((sum, qty) => sum + qty, 0); + } + + /** + * Similar to getSeatsAvailable, but also checks its ticket's availability. + * It's useful to display the real availability in the UI, that accounts for + * both the event and the tickets availability. + * + * @param {Object} options - Sent to getOrderedQuantity + * @returns {Number} available seats + */ + getSeatsAvailableReal(options) { + const ticketSeatsAvailable = this.getTicketSeatsAvailable(options); + const eventSeatsAvailable = this.getSeatsAvailable(options); + return Math.min(ticketSeatsAvailable, eventSeatsAvailable); + } + + /** + * @returns {[Date]} List of Dates for which this event is available + */ + getEventDates() { + return getDatesInRange(this.date_begin, this.date_end); + } + } + + Registries.Model.add(EventEvent); + return EventEvent; +}); diff --git a/pos_event_sale/static/src/js/models/EventTicket.js b/pos_event_sale/static/src/js/models/EventTicket.js new file mode 100644 index 0000000000..ab1ecf90f1 --- /dev/null +++ b/pos_event_sale/static/src/js/models/EventTicket.js @@ -0,0 +1,85 @@ +/* +Copyright 2021 Camptocamp (https://www.camptocamp.com). +@author Iván Todorovich +License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +*/ +odoo.define("pos_event_sale.EventTicket", function (require) { + "use strict"; + + const Registries = require("point_of_sale.Registries"); + const PosModel = require("pos_event_sale.PosModel"); + + class EventTicket extends PosModel { + getEvent() { + return this.pos.db.getEventByID(this.event_id[0]); + } + + getProduct() { + return this.pos.db.get_product_by_id(this.product_id[0]); + } + + getPriceExtra() { + return this.price - this.getProduct().lst_price; + } + + _prepareOrderlineOptions() { + return { + price_extra: this.getPriceExtra(), + extras: { + event_ticket_id: this.id, + }, + }; + } + + /** + * Computes the total ordered quantity for this event ticket. + * + * @param {Object} options + * @param {Order} options.order defaults to the current order + * @returns {Number} ordered quantity + */ + getOrderedQuantity({order} = {}) { + /* eslint-disable no-param-reassign */ + order = order ? order : this.pos.get_order(); + if (!order) { + return 0; + } + return order + .get_orderlines() + .filter((line) => line.getEventTicket() === this) + .reduce((sum, line) => sum + line.quantity, 0); + } + + /** + * Computes the available places, considering all the ordered quantities in + * the current order. + * + * Please note it doesn't check the available seats against the backend. + * For a real availability check see updateAndCheckEventAvailability. + * + * @param {Object} options - Sent to getOrderedQuantity + * @returns {Number} available seats + */ + getSeatsAvailable(options) { + return this.seats_limited + ? this.seats_available - this.getOrderedQuantity(options) + : this.seats_available; + } + + /** + * Similar to getSeatsAvailable, but also checks the event's availability. + * + * @param {Object} options - Sent to getOrderedQuantity + * @returns {Number} available seats + */ + getSeatsAvailableReal(options) { + const event = this.getEvent(); + const ticketSeatsAvailable = this.getSeatsAvailable(options); + const eventSeatsAvailable = event.getSeatsAvailable(options); + return Math.min(ticketSeatsAvailable, eventSeatsAvailable); + } + } + + Registries.Model.add(EventTicket); + return EventTicket; +}); diff --git a/pos_event_sale/static/src/js/models/Order.js b/pos_event_sale/static/src/js/models/Order.js new file mode 100644 index 0000000000..59ec3376b3 --- /dev/null +++ b/pos_event_sale/static/src/js/models/Order.js @@ -0,0 +1,115 @@ +/* + Copyright 2021 Camptocamp (https://www.camptocamp.com). + @author Iván Todorovich + License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +*/ +odoo.define("pos_event_sale.Order", function (require) { + "use strict"; + + const core = require("web.core"); + const _t = core._t; + const Registries = require("point_of_sale.Registries"); + var {Order} = require("point_of_sale.models"); + + // Extend the Pos global state to load events + const PosEventSaleOrder = (Order) => + class PosEventSaleOrder extends Order { + /** + * @returns {Orderlines} linked to event tickets + */ + getEventOrderlines() { + return this.get_orderlines().filter((line) => line.event_ticket_id); + } + + /** + * @returns Array of {event.ticket} included in this order + */ + getEventTickets() { + return _.unique( + this.getEventOrderlines().map((line) => line.getEventTicket()) + ); + } + + /** + * @returns Array of {event.event} included in this order + */ + getEvents() { + return _.unique( + this.getEventOrderlines().map((line) => line.getEvent()) + ); + } + + /** + * @returns {Boolean} + */ + hasEvents() { + return this.getEventTickets().length > 0; + } + + /** + * Please note it doesn't check the available seats against the backend. + * For a real availability check see updateAndCheckEventAvailability. + * + * @raise {Exception} if the order includes events without enough available seats. + */ + checkEventAvailability() { + const lines = this.getEventOrderlines(); + for (const line of lines) { + line.checkEventAvailability(); + } + } + + /** + * Updates and check the ordered events availability + * Requires an active internet connection. + * + * @returns Promise that resolves if all is ok. + */ + async updateAndCheckEventAvailability() { + const tickets = this.getEventTickets(); + const limitedTickets = tickets.filter( + (ticket) => ticket.seats_limited || ticket.getEvent().seats_limited + ); + const limitedEventIds = _.unique( + limitedTickets.map((ticket) => ticket.getEvent().id) + ); + // Nothing to check! + if (!limitedEventIds.length) { + return true; + } + // Update event's available seats from backend + try { + await this.pos.db.updateEventSeatsAvailable({ + event_ids: limitedEventIds, + }); + } catch (error) { + throw new Error( + _t( + "Unable to check event tickets availability. Check the internet connection then try again." + ) + ); + } + // Check ordered event's availability + this.checkEventAvailability(); + } + + /** + * @override + */ + wait_for_push_order() { + const res = super.wait_for_push_order.apply(this, arguments); + return Boolean(res || this.hasEvents()); + } + + /** + * @override + */ + export_for_printing() { + const res = super.export_for_printing.apply(this, arguments); + res.event_registrations = this.event_registrations; + return res; + } + }; + + Registries.Model.extend(Order, PosEventSaleOrder); +}); diff --git a/pos_event_sale/static/src/js/models/Orderline.js b/pos_event_sale/static/src/js/models/Orderline.js new file mode 100644 index 0000000000..97e71de1f4 --- /dev/null +++ b/pos_event_sale/static/src/js/models/Orderline.js @@ -0,0 +1,170 @@ +/* + Copyright 2021 Camptocamp (https://www.camptocamp.com). + @author Iván Todorovich + License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +*/ +odoo.define("pos_event_sale.Orderline", function (require) { + "use strict"; + + const utils = require("web.utils"); + const core = require("web.core"); + const Registries = require("point_of_sale.Registries"); + const {Orderline} = require("point_of_sale.models"); + const _t = core._t; + const round_di = utils.round_decimals; + + const PosEventSaleOrderLine = (Orderline) => + class extends Orderline { + /** + * @returns the event.ticket object + */ + getEventTicket() { + if (this.event_ticket_id) { + return this.pos.db.getEventTicketByID(this.event_ticket_id); + } + } + + /** + * @returns the event object related to this line event.ticket + */ + getEvent() { + if (this.event_ticket_id) { + return this.getEventTicket().getEvent(); + } + } + + /** + * @returns {String} The full event description for this order line + */ + getEventSaleDescription() { + const event = this.getEvent(); + const ticket = this.getEventTicket(); + if (ticket && event) { + return `${event.display_name} (${ticket.name})`; + } + return ""; + } + + /** + * Please note it doesn't check the available seats against the backend. + * For a real availability check see updateAndCheckEventAvailability. + * + * @returns {Boolean} + */ + _checkEventAvailability() { + const ticket = this.getEventTicket(); + if (!ticket) { + return; + } + // If it's negative, we've oversold + return ticket.getSeatsAvailableReal({order: this.order}) >= 0; + } + + /** + * Please note it doesn't check the available seats against the backend. + * For a real availability check see updateAndCheckEventAvailability. + * + * @throws {Error} + */ + checkEventAvailability() { + if (!this._checkEventAvailability()) { + throw new Error( + _.str.sprintf( + _t("Not enough available seats for %s"), + this.getEventSaleDescription() + ) + ); + } + } + + /** + * @override + */ + get_lst_price() { + if (this.event_ticket_id) { + return this.getEventTicket().price; + } + return super.get_lst_price.apply(this, arguments); + } + + /** + * @override + */ + set_lst_price(price) { + if (this.event_ticket_id) { + this.order.assert_editable(); + this.getEventTicket().price = round_di( + parseFloat(price) || 0, + this.pos.dp["Product Price"] + ); + this.trigger("change", this); + } + return super.set_lst_price.apply(this, arguments); + } + + /** + * @override + */ + can_be_merged_with(orderline) { + if (this.event_ticket_id !== orderline.event_ticket_id) { + return false; + } + return super.can_be_merged_with.apply(this, arguments); + } + + /** + * @override + */ + get_full_product_name() { + if (this.full_product_name) { + return this.full_product_name; + } + if (this.event_ticket_id) { + return this.getEventSaleDescription(); + } + return super.get_full_product_name.apply(this, arguments); + } + + /** + * @override + */ + clone() { + const res = super.clone.apply(this, arguments); + res.event_ticket_id = this.event_ticket_id; + return res; + } + + /** + * @override + */ + init_from_JSON(json) { + super.init_from_JSON.apply(this, arguments); + this.event_ticket_id = json.event_ticket_id; + } + + /** + * @override + */ + export_as_JSON() { + const res = super.export_as_JSON.apply(this, arguments); + res.event_ticket_id = this.event_ticket_id; + return res; + } + + /** + * @override + */ + export_for_printing() { + const res = super.export_for_printing.apply(this, arguments); + if (this.event_ticket_id) { + res.event = this.getEvent(); + res.event_ticket = this.getEventTicket(); + } + return res; + } + }; + + Registries.Model.extend(Orderline, PosEventSaleOrderLine); + + return Orderline; +}); diff --git a/pos_event_sale/static/src/js/models/PosGlobalState.js b/pos_event_sale/static/src/js/models/PosGlobalState.js new file mode 100644 index 0000000000..3136574a8a --- /dev/null +++ b/pos_event_sale/static/src/js/models/PosGlobalState.js @@ -0,0 +1,151 @@ +/* + Copyright 2022 Camptocamp (https://www.camptocamp.com). + @author Iván Todorovich + License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +*/ +odoo.define("pos_event_sale.models", function (require) { + "use strict"; + + const Registries = require("point_of_sale.Registries"); + const {PosGlobalState} = require("point_of_sale.models"); + const EventEvent = require("pos_event_sale.EventEvent"); + const EventTicket = require("pos_event_sale.EventTicket"); + + // Extend the Pos global state to load events + const PosEventSalePosGlobalState = (PosGlobalState) => + class extends PosGlobalState { + async _processData(loadedData) { + await super._processData(loadedData); + this._loadEventEvent(loadedData["event.event"]); + this._loadEventTicket(loadedData["event.event.ticket"]); + this._loadEventTagCategory(loadedData["event.tag.category"]); + this._loadEventTag(loadedData["event.tag"]); + } + + _loadEventEvent(events) { + const modelEvents = events.map((event) => { + event.pos = this; + return EventEvent.create(event); + }); + this.db.addEvents(modelEvents); + } + + _loadEventTicket(tickets) { + const modelTickets = tickets.map((ticket) => { + ticket.pos = this; + return EventTicket.create(ticket); + }); + this.db.addEventTickets(modelTickets); + } + + _loadEventTagCategory(eventTagCategories) { + this.db.event_tag_category_by_id = {}; + this.db.event_tags = eventTagCategories; + for (const eventTagCategory of eventTagCategories) { + eventTagCategory.tag_ids = []; + this.db.event_tag_category_by_id[eventTagCategory.id] = + eventTagCategory; + } + } + + _loadEventTag(eventTags) { + for (const eventTag of eventTags) { + const category = + this.db.event_tag_category_by_id[eventTag.category_id[0]]; + if (category) { + category.tag_ids.push(eventTag); + } + } + } + + /** + * @override + */ + async _loadMissingProducts(orders) { + return Promise.all([ + super._loadMissingProducts.apply(this, arguments), + this._loadMissingEvents(orders), + this._loadMissingEventTickets(orders), + ]); + } + + /** + * Load missing event data from orders that may be loaded from + * localStorage or from export_for_ui. + */ + async _loadMissingEvents(orders) { + const missingEventIds = []; + for (const order of orders) { + for (const line of order.lines) { + const eventId = line[2].event_id; + if (eventId && !missingEventIds.includes(eventId)) { + if (!this.db.getEventByID(eventId, false)) { + missingEventIds.push(eventId); + } + } + } + } + if (!missingEventIds.length) { + return; + } + const eventModel = this.models.find( + (model) => model.model === "event.event" + ); + const events = await this.rpc({ + model: eventModel.model, + method: "read", + args: [missingEventIds, eventModel.fields], + context: this.session.user_context, + }); + eventModel.loaded(this, events); + } + + /** + * Load missing event.ticket data from orders that may be loaded from + * localStorage or from export_for_ui. + */ + async _loadMissingEventTickets(orders) { + const missingEventTicketIds = []; + for (const order of orders) { + for (const line of order.lines) { + const eventTicketId = line[2].event_ticket_id; + if ( + eventTicketId && + !missingEventTicketIds.includes(eventTicketId) + ) { + if (!this.db.getEventTicketByID(eventTicketId, false)) { + missingEventTicketIds.push(eventTicketId); + } + } + } + } + if (!missingEventTicketIds.length) { + return; + } + const eventTicketModel = this.models.find( + (model) => model.model === "event.event.ticket" + ); + const eventTickets = await this.rpc({ + model: eventTicketModel.model, + method: "read", + args: [missingEventTicketIds, eventTicketModel.fields], + context: this.session.user_context, + }); + eventTicketModel.loaded(this, eventTickets); + } + + /** + * Prevent race condition on clicking twice the payment screen validate button + */ + _flush_orders(orders) { + if (!orders || !orders.length || orders[0] === undefined) { + return Promise.resolve([]); + } + return super._flush_orders(...arguments); + } + }; + + Registries.Model.extend(PosGlobalState, PosEventSalePosGlobalState); + + return PosGlobalState; +}); diff --git a/pos_event_sale/static/src/js/models/PosModel.js b/pos_event_sale/static/src/js/models/PosModel.js new file mode 100644 index 0000000000..1771bbea68 --- /dev/null +++ b/pos_event_sale/static/src/js/models/PosModel.js @@ -0,0 +1,16 @@ +/* + Copyright 2023 Braintec (https://www.braintec.com). + @author David Moreno + License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +*/ +odoo.define("pos_event_sale.PosModel", function (require) { + "use strict"; + + // Since PosModel is not exported, and because we want our new models to have the + // same structure as the native ones, we're forced to find a solution to get the + // original PosModel class accessible + const {Packlotline} = require("point_of_sale.models"); + + // This will return the PosModel class + return Object.getPrototypeOf(Packlotline); +}); diff --git a/pos_event_sale/static/src/js/utils.js b/pos_event_sale/static/src/js/utils.js new file mode 100644 index 0000000000..5673ea68b7 --- /dev/null +++ b/pos_event_sale/static/src/js/utils.js @@ -0,0 +1,22 @@ +/* + Copyright 2021 Camptocamp (https://www.camptocamp.com). + @author Iván Todorovich + License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +*/ +odoo.define("pos_event_sale.utils", function () { + "use strict"; + + /** + * @param {Date} start + * @param {Date} end + * @returns Array of dates between start and end, without time + */ + function getDatesInRange(start, end) { + const days = moment(end).diff(moment(start), "days") + 1; + return [...Array(days).keys()].map((dayn) => + moment(start).add(dayn, "days").startOf("day").toDate() + ); + } + + return {getDatesInRange}; +}); diff --git a/pos_event_sale/static/src/scss/event_registration_receipt.scss b/pos_event_sale/static/src/scss/event_registration_receipt.scss new file mode 100644 index 0000000000..a11a0da74b --- /dev/null +++ b/pos_event_sale/static/src/scss/event_registration_receipt.scss @@ -0,0 +1,49 @@ +/* + Copyright 2021 Camptocamp SA (https://www.camptocamp.com). + @author Iván Todorovich + License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +*/ + +@media print { + .pos .pos-receipt-container > div { + display: block !important; + page-break-before: always !important; + } +} + +.pos-receipt { + &.event-registration-receipt { + .pos-receipt-event-registration { + text-align: center; + + .event-info { + margin: 1rem 1.5rem; + text-align: left; + + & > div { + margin-bottom: 0.5rem; + } + + i.fa { + margin-right: 0.25rem; + min-width: 1em; + text-align: center; + + & + span { + margin-right: 0.5rem; + } + } + } + + .event-registration-barcode > div { + margin-top: 3rem; + margin-bottom: 1rem; + text-align: center; + + .barcode { + width: 100%; + } + } + } + } +} diff --git a/pos_event_sale/static/src/scss/pos_event_sale.scss b/pos_event_sale/static/src/scss/pos_event_sale.scss new file mode 100644 index 0000000000..7f6076b2bf --- /dev/null +++ b/pos_event_sale/static/src/scss/pos_event_sale.scss @@ -0,0 +1,488 @@ +/* +Copyright 2021 Camptocamp SA (https://www.camptocamp.com). +@author Iván Todorovich +License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +*/ + +$pos-event-sale-primary-color: #6ec89b !default; + +.pos { + .modal-dialog .popup.event-selector-popup { + width: 90%; + height: 90%; + min-width: 600px; + max-width: 1000px; + min-height: 600px; + max-height: 1200px; + + .body { + height: calc(100% - 160px); + display: flex; + flex-direction: column; + } + + .event-selector { + display: flex; + flex-direction: row; + flex-wrap: wrap; + overflow: hidden; + height: 100%; + + section { + height: 100%; + display: flex; + flex-direction: column; + flex-basis: 100%; + flex: 1; + + &:not(:last-child) { + border-right: dashed 1px rgb(215, 215, 215); + } + } + } + + .o_calendar_widget { + font-size: 0.8rem; + padding: 20px; + + .has-events { + background-color: #fff; + box-shadow: inset 2px 2px 0px 0px $pos-event-sale-primary-color, + inset -2px 0px 0px 0px $pos-event-sale-primary-color, + inset 0px -2px 0px 0px $pos-event-sale-primary-color; + } + + .fc-highlight { + background-color: $pos-event-sale-primary-color; + opacity: 0.5; + } + + .fc-day-number { + padding: 10px; + text-align: center; + } + + .fc-widget-header { + padding: 2px 0; + } + + .fc-bg td:not(.fc-other-month):not(.has-events) { + background-color: #d9d9d9; + } + } + + .event-filters { + font-size: 14px; + + .filters { + margin-bottom: 16px; + display: flex; + gap: 0.5em; + + .pos-search-bar { + vertical-align: middle; + white-space: nowrap; + position: relative; + display: flex; + font-size: 14px; + flex: 1; + + .search { + display: flex; + position: relative; + flex: 1; + + input { + width: 0; + height: 40px; + color: #63717f; + font-size: inherit; + padding-left: 40px; + border: solid 1px rgb(209, 209, 209); + flex: 1; + box-shadow: none !important; + + &:focus { + outline: none; + } + } + + .search-icon { + position: absolute; + left: 15px; + top: 14px; + z-index: 1; + color: #4f5b66; + } + + ul { + background: white; + position: absolute; + top: calc(100% + 5px); + right: 2px; + left: 2px; + box-shadow: 1px 1px 3px grey; + z-index: 1000; + + li { + color: rgb(1, 160, 157); + margin: 0.2em 0; + padding-top: 0.2em; + padding-bottom: 0.2em; + padding-left: 0.5em; + text-indent: 0; + text-align: left; + + &:before { + display: none; + } + + &:hover { + background: #ddd; + } + + .field { + font-style: italic; + } + + .term { + font-weight: bold; + } + + &.highlight { + background: #ddd; + } + } + } + } + + .radius-right { + border-top-right-radius: 5px; + border-bottom-right-radius: 5px; + } + + .radius-left { + border-top-left-radius: 5px; + border-bottom-left-radius: 5px; + } + + .fa { + font-size: medium; + } + + .filter { + height: 40px; + background: white; + padding-top: 1px; + padding-bottom: 1px; + padding-left: 15px; + padding-right: 40px; + border: solid 1px rgb(209, 209, 209); + border-left: none; + position: relative; + display: flex; + align-items: center; + max-width: 150px; + + &:hover { + color: #868686; + } + + .options { + display: block; + position: absolute; + top: calc(100% + 5px); + right: 0; + z-index: 1; + box-shadow: 1px 1px 5px grey; + padding: 0.5em 0; + background: white; + color: #555555; + + ul.options { + li { + padding: 0.2em 1.2em; + border-top: none; + display: flex; + justify-content: start; + align-items: center; + + &.indented { + text-indent: 1em; + } + + &:hover { + background-color: #ddd; + } + } + } + } + + .down-icon { + position: absolute; + right: 13px; + top: 12px; + } + } + } + } + .applied { + margin-bottom: 16px; + display: flex; + flex-wrap: wrap; + gap: 0.5em; + + .filter { + display: flex; + background: rgba($pos-event-sale-primary-color, 0.6); + border: solid 1px $pos-event-sale-primary-color; + + .facet { + display: flex; + align-items: center; + color: white; + background: $pos-event-sale-primary-color; + padding: 0.25em 0.5em; + white-space: nowrap; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + } + .value { + display: flex; + align-items: center; + padding: 0.25em 0.5em; + white-space: nowrap; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + } + i { + display: flex; + align-items: center; + padding-right: 0.25em; + } + } + } + } + + .event-list-container { + padding: 5px; + font-size: 0.8rem; + font-weight: normal; + height: 100%; + + .scrollable-y { + height: 100%; + } + + article.event { + position: relative; + vertical-align: top; + text-align: left; + font-size: 1rem; + margin: 8px !important; + padding: 1rem; + background: #fff; + border: 1px solid #e2e2e2; + border-radius: 3px; + border-bottom-width: 3px; + cursor: pointer; + + &.disabled { + opacity: 0.5; + cursor: default; + } + + .event-title { + font-weight: bold; + } + + .event-subtitle { + color: gray; + } + + .event-info { + margin-top: 0.5rem; + font-size: 0.75rem; + line-height: 1.1rem; + + i, + span { + margin-right: 0.25rem; + } + + i.fa { + min-width: 1em; + text-align: center; + } + } + + .event-availability-tag { + position: absolute; + top: 2px; + right: 2px; + } + } + } + + .alert-debug { + padding: 0.75em; + margin: 0.75em; + border-radius: 0.25em; + background: #2c3e50; + color: white; + } + } + + .modal-dialog .popup.event-tickets-popup { + width: 600px; + font-weight: normal; + + main { + max-height: 600px; + } + + article.event-ticket { + vertical-align: top; + position: relative; + font-size: 0.8rem; + text-align: left; + margin: 8px !important; + height: 3rem; + padding: 0.75rem; + padding-left: 6rem; + padding-right: 100px; + background: #fff; + border: 1px solid #e2e2e2; + border-radius: 3px; + border-bottom-width: 3px; + cursor: pointer; + + img.ticket-image { + position: absolute; + top: 0; + left: 0; + height: 100%; + } + + .ticket-name { + font-weight: bold; + } + + &.disabled { + opacity: 0.5; + cursor: default; + } + + .event-availability-tag { + position: absolute; + top: 22px; + left: 2px; + } + + .price-tag { + position: absolute; + top: 2px; + left: 2px; + vertical-align: top; + color: white; + background: #7f82ac; + font-size: 0.75rem; + padding: 2px 5px; + border-radius: 2px; + } + } + } + + .event-availability-tag { + vertical-align: top; + font-size: 0.75rem; + color: white; + line-height: 13px; + padding: 2px 5px; + border-radius: 2px; + background: black; + + &.bg-danger { + background: red; + } + + &.bg-warning { + background: orange; + } + } + + .multi-select-button { + position: relative; + display: inline-block; + font-size: 14px; + + & > button { + padding: 0 0.5em; + margin: 0; + height: 100%; + line-height: 40px; + text-align: center; + font-size: inherit; + font-weight: bold; + cursor: pointer; + color: #555; + border: solid 1px rgba(60, 60, 60, 0.1); + border-radius: 2px; + background: rgba(0, 0, 0, 0.05); + transition: all 150ms linear; + + & > i { + margin-right: 0.5em; + } + } + + & > ul { + display: block; + background: white; + box-shadow: 1px 1px 5px grey; + padding: 0.5em 0; + position: absolute; + top: calc(100% + 5px); + right: 0; + min-width: 6em; + max-height: 60vh; + z-index: 1000; + + & > li { + position: relative; + padding: 0.5em 1.5em; + text-indent: 0px !important; + white-space: nowrap; + max-width: 25em; + overflow-x: hidden; + text-overflow: ellipsis; + + &:hover { + background-color: rgba(0, 0, 0, 0.08); + } + + &::before { + position: absolute !important; + top: 1em !important; + left: 0.6em !important; + bottom: auto !important; + right: auto !important; + font-size: 1em !important; + font: 0.7em/1em FontAwesome !important; + content: "" !important; + } + + &.selected::before { + content: "\f00c" !important; + color: #017e84; + } + } + } + } + + div.button.next.validation { + cursor: pointer; + } +} diff --git a/pos_event_sale/static/src/xml/ControlButtons/AddEventButton.xml b/pos_event_sale/static/src/xml/ControlButtons/AddEventButton.xml new file mode 100644 index 0000000000..3f0e5b4863 --- /dev/null +++ b/pos_event_sale/static/src/xml/ControlButtons/AddEventButton.xml @@ -0,0 +1,16 @@ + + + + + + + + Add Event + + + + diff --git a/pos_event_sale/static/src/xml/EventAvailabilityBadge.xml b/pos_event_sale/static/src/xml/EventAvailabilityBadge.xml new file mode 100644 index 0000000000..b92150b40d --- /dev/null +++ b/pos_event_sale/static/src/xml/EventAvailabilityBadge.xml @@ -0,0 +1,17 @@ + + + + + + + + + diff --git a/pos_event_sale/static/src/xml/EventSelectorPopup/EventCalendar.xml b/pos_event_sale/static/src/xml/EventSelectorPopup/EventCalendar.xml new file mode 100644 index 0000000000..e22aafa161 --- /dev/null +++ b/pos_event_sale/static/src/xml/EventSelectorPopup/EventCalendar.xml @@ -0,0 +1,13 @@ + + + + + +
+ + + diff --git a/pos_event_sale/static/src/xml/EventSelectorPopup/EventFilters.xml b/pos_event_sale/static/src/xml/EventSelectorPopup/EventFilters.xml new file mode 100644 index 0000000000..509d900e23 --- /dev/null +++ b/pos_event_sale/static/src/xml/EventSelectorPopup/EventFilters.xml @@ -0,0 +1,43 @@ + + + + + +
+
+ + +
+
+ +
+ + + +
+
+
+
+
+ +
diff --git a/pos_event_sale/static/src/xml/EventSelectorPopup/EventItem.xml b/pos_event_sale/static/src/xml/EventSelectorPopup/EventItem.xml new file mode 100644 index 0000000000..8815ed6ef5 --- /dev/null +++ b/pos_event_sale/static/src/xml/EventSelectorPopup/EventItem.xml @@ -0,0 +1,52 @@ + + + + + +
+
+ +
+
+ +
+
+
+ + + From + + +
+
+ + + To + + +
+
+ +
+
+ +
diff --git a/pos_event_sale/static/src/xml/EventSelectorPopup/EventList.xml b/pos_event_sale/static/src/xml/EventSelectorPopup/EventList.xml new file mode 100644 index 0000000000..d9d6c313bc --- /dev/null +++ b/pos_event_sale/static/src/xml/EventSelectorPopup/EventList.xml @@ -0,0 +1,29 @@ + + + + + +
+
+
+ + + +
+
+

There are no events on these dates.

+

+ Besides the Event filters on the Point of Sale configuration, + make sure products related to the event tickets are also available + for this Point of Sale, otherwise they won't be shown here. +

+
+
+
+
+ +
diff --git a/pos_event_sale/static/src/xml/EventSelectorPopup/EventSelectorPopup.xml b/pos_event_sale/static/src/xml/EventSelectorPopup/EventSelectorPopup.xml new file mode 100644 index 0000000000..28d31a6e6b --- /dev/null +++ b/pos_event_sale/static/src/xml/EventSelectorPopup/EventSelectorPopup.xml @@ -0,0 +1,36 @@ + + + + + + + + + diff --git a/pos_event_sale/static/src/xml/EventTicketsPopup/EventTicketItem.xml b/pos_event_sale/static/src/xml/EventTicketsPopup/EventTicketItem.xml new file mode 100644 index 0000000000..a147603792 --- /dev/null +++ b/pos_event_sale/static/src/xml/EventTicketsPopup/EventTicketItem.xml @@ -0,0 +1,43 @@ + + + + + +
+ + + + Free + + +
+ +
+
+ +
+
+
+ +
diff --git a/pos_event_sale/static/src/xml/EventTicketsPopup/EventTicketList.xml b/pos_event_sale/static/src/xml/EventTicketsPopup/EventTicketList.xml new file mode 100644 index 0000000000..227b73732c --- /dev/null +++ b/pos_event_sale/static/src/xml/EventTicketsPopup/EventTicketList.xml @@ -0,0 +1,28 @@ + + + + + +
+
+
+ + + +
+
+

There are no available event tickets for this event.

+
+
+
+
+ +
diff --git a/pos_event_sale/static/src/xml/EventTicketsPopup/EventTicketsPopup.xml b/pos_event_sale/static/src/xml/EventTicketsPopup/EventTicketsPopup.xml new file mode 100644 index 0000000000..aa6b128925 --- /dev/null +++ b/pos_event_sale/static/src/xml/EventTicketsPopup/EventTicketsPopup.xml @@ -0,0 +1,28 @@ + + + + + + + + + diff --git a/pos_event_sale/static/src/xml/ProductScreen/ProductItem.xml b/pos_event_sale/static/src/xml/ProductScreen/ProductItem.xml new file mode 100644 index 0000000000..4267809f84 --- /dev/null +++ b/pos_event_sale/static/src/xml/ProductScreen/ProductItem.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + diff --git a/pos_event_sale/static/src/xml/ReceiptScreen/EventRegistrationReceipt.xml b/pos_event_sale/static/src/xml/ReceiptScreen/EventRegistrationReceipt.xml new file mode 100644 index 0000000000..dd24c11338 --- /dev/null +++ b/pos_event_sale/static/src/xml/ReceiptScreen/EventRegistrationReceipt.xml @@ -0,0 +1,87 @@ + + + + + +
+ + + + + +
+
+ +

+ +

+
+
+ + + +
+
+ +
+
+
+ + + From + + +
+
+ + + To + + +
+
+
+
+ +
+
+ +
+ Barcode +
+
+
+ + + +
+
+
+ +
+
+ +
+
+
+ +
+
+ +
diff --git a/pos_event_sale/static/src/xml/ReceiptScreen/ReceiptScreen.xml b/pos_event_sale/static/src/xml/ReceiptScreen/ReceiptScreen.xml new file mode 100644 index 0000000000..8c8c71fe30 --- /dev/null +++ b/pos_event_sale/static/src/xml/ReceiptScreen/ReceiptScreen.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + diff --git a/pos_event_sale/static/src/xml/ReprintReceiptScreen/ReprintReceiptScreen.xml b/pos_event_sale/static/src/xml/ReprintReceiptScreen/ReprintReceiptScreen.xml new file mode 100644 index 0000000000..a8d0d9c967 --- /dev/null +++ b/pos_event_sale/static/src/xml/ReprintReceiptScreen/ReprintReceiptScreen.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + diff --git a/pos_event_sale/static/src/xml/Widgets/MultiSelectButton.xml b/pos_event_sale/static/src/xml/Widgets/MultiSelectButton.xml new file mode 100644 index 0000000000..e45115dcf8 --- /dev/null +++ b/pos_event_sale/static/src/xml/Widgets/MultiSelectButton.xml @@ -0,0 +1,33 @@ + + + + + +
+ + +
+
+ +
diff --git a/pos_event_sale/static/tests/tours/EventSale.tour.js b/pos_event_sale/static/tests/tours/EventSale.tour.js new file mode 100644 index 0000000000..58b12d3ab2 --- /dev/null +++ b/pos_event_sale/static/tests/tours/EventSale.tour.js @@ -0,0 +1,61 @@ +/* + Copyright 2021 Camptocamp SA (https://www.camptocamp.com). + @author Iván Todorovich + License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +*/ +odoo.define("pos_event_sale.tour.EventSale", function (require) { + "use strict"; + + const {ProductScreen} = require("pos_event_sale.tour.ProductScreenTourMethods"); + const {PaymentScreen} = require("point_of_sale.tour.PaymentScreenTourMethods"); + const {ReceiptScreen} = require("point_of_sale.tour.ReceiptScreenTourMethods"); + const {EventSelector} = require("pos_event_sale.tour.EventSelectorTourMethods"); + const {TicketSelector} = require("pos_event_sale.tour.TicketSelectorTourMethods"); + const {getSteps, startSteps} = require("point_of_sale.tour.utils"); + const Tour = require("web_tour.tour"); + + startSteps(); + + // Go by default to home category + ProductScreen.do.confirmOpeningPopup(); + ProductScreen.do.clickHomeCategory(); + + // Add event.. + ProductScreen.do.clickDisplayedProduct("Event Registration"); + EventSelector.check.isShown(); + EventSelector.do.clickDisplayedEvent("Les Misérables"); + + // Add tickets to Order + TicketSelector.check.isShown(); + TicketSelector.do.clickDisplayedTicket("Standard"); + ProductScreen.check.selectedOrderlineHas( + "Les Misérables (Standard)", + "1.0", + "15.00" + ); + TicketSelector.do.clickDisplayedTicket("Standard"); + ProductScreen.check.selectedOrderlineHas( + "Les Misérables (Standard)", + "2.0", + "30.00" + ); + TicketSelector.do.clickDisplayedTicket("Kids"); + ProductScreen.check.selectedOrderlineHas("Les Misérables (Kids)", "1.0", "0.00"); + TicketSelector.do.close(); + EventSelector.do.close(); + + // Payment + ProductScreen.do.clickPayButton(); + PaymentScreen.check.isShown(); + PaymentScreen.do.clickPaymentMethod("Cash"); + // PaymentScreen.do.pressNumpad("3 0"); + PaymentScreen.check.remainingIs("0.0"); + PaymentScreen.check.validateButtonIsHighlighted(true); + PaymentScreen.do.clickValidate(); + ReceiptScreen.check.isShown(); + ReceiptScreen.check.totalAmountContains("30.0"); + ReceiptScreen.do.clickNextOrder(); + ProductScreen.check.isShown(); + + Tour.register("EventSaleTour", {test: true, url: "/pos/ui"}, getSteps()); +}); diff --git a/pos_event_sale/static/tests/tours/EventSaleAvailability.tour.js b/pos_event_sale/static/tests/tours/EventSaleAvailability.tour.js new file mode 100644 index 0000000000..9f1a6e4c55 --- /dev/null +++ b/pos_event_sale/static/tests/tours/EventSaleAvailability.tour.js @@ -0,0 +1,81 @@ +/* + Copyright 2021 Camptocamp SA (https://www.camptocamp.com). + @author Iván Todorovich + License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +*/ +odoo.define("pos_event_sale.tour.EventSaleAvailability", function (require) { + "use strict"; + + const {ProductScreen} = require("pos_event_sale.tour.ProductScreenTourMethods"); + const {PaymentScreen} = require("point_of_sale.tour.PaymentScreenTourMethods"); + const {ReceiptScreen} = require("point_of_sale.tour.ReceiptScreenTourMethods"); + const {EventSelector} = require("pos_event_sale.tour.EventSelectorTourMethods"); + const {TicketSelector} = require("pos_event_sale.tour.TicketSelectorTourMethods"); + const {getSteps, startSteps} = require("point_of_sale.tour.utils"); + const Tour = require("web_tour.tour"); + + startSteps(); + + // Add event through Add Event button + ProductScreen.do.confirmOpeningPopup(); + ProductScreen.do.clickAddEventButton(); + EventSelector.check.isShown(); + EventSelector.check.eventHasAvailabilityLabel("Les Misérables", "5 remaining"); + EventSelector.do.clickDisplayedEvent("Les Misérables"); + + TicketSelector.check.isShown(); + TicketSelector.check.ticketHasAvailabilityLabel("Kids", "3 remaining"); + TicketSelector.check.ticketHasAvailabilityLabel("Standard", "5 remaining"); + + // Attempt to add more than the limit + // While adding Kids tickets, we also get closer to the event limit + TicketSelector.do.clickDisplayedTicket("Kids"); + TicketSelector.check.ticketHasAvailabilityLabel("Kids", "2 remaining"); + TicketSelector.check.ticketHasAvailabilityLabel("Standard", "4 remaining"); + TicketSelector.do.clickDisplayedTicket("Kids"); + TicketSelector.check.ticketHasAvailabilityLabel("Kids", "1 remaining"); + TicketSelector.check.ticketHasAvailabilityLabel("Standard", "3 remaining"); + TicketSelector.do.clickDisplayedTicket("Kids"); + TicketSelector.check.ticketHasAvailabilityLabel("Kids", "Sold out"); + TicketSelector.check.ticketHasAvailabilityLabel("Standard", "2 remaining"); + ProductScreen.check.selectedOrderlineHas("Les Misérables (Kids)", "3.0", "0.00"); + + // Clicking even more won't add orderlines + TicketSelector.do.clickDisplayedTicket("Kids"); + ProductScreen.check.selectedOrderlineHas("Les Misérables (Kids)", "3.0", "0.00"); + + // Consume the rest of Standard tickets + TicketSelector.do.clickDisplayedTicket("Standard"); + TicketSelector.do.clickDisplayedTicket("Standard"); + ProductScreen.check.selectedOrderlineHas( + "Les Misérables (Standard)", + "2.0", + "30.00" + ); + TicketSelector.check.ticketHasAvailabilityLabel("Standard", "Sold out"); + TicketSelector.do.close(); + EventSelector.do.close(); + + // Finish order + ProductScreen.do.clickPayButton(); + PaymentScreen.check.isShown(); + PaymentScreen.do.clickPaymentMethod("Cash"); + // PaymentScreen.do.pressNumpad("3 0"); + PaymentScreen.check.remainingIs("0.0"); + PaymentScreen.do.clickValidate(); + ReceiptScreen.check.isShown(); + ReceiptScreen.do.clickNextOrder(); + ProductScreen.check.isShown(); + + // // As the event is sold out now, we shouldn't be able to sell more + ProductScreen.do.clickAddEventButton(); + EventSelector.check.isShown(); + EventSelector.check.eventHasAvailabilityLabel("Les Misérables", "Sold out"); + EventSelector.do.close(); + + Tour.register( + "EventSaleAvailabilityTour", + {test: true, url: "/pos/ui"}, + getSteps() + ); +}); diff --git a/pos_event_sale/static/tests/tours/helpers/EventSelectorTourMethods.js b/pos_event_sale/static/tests/tours/helpers/EventSelectorTourMethods.js new file mode 100644 index 0000000000..15686b0ac0 --- /dev/null +++ b/pos_event_sale/static/tests/tours/helpers/EventSelectorTourMethods.js @@ -0,0 +1,63 @@ +/* +Copyright 2021 Camptocamp SA (https://www.camptocamp.com). +@author Iván Todorovich +License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +*/ +odoo.define("pos_event_sale.tour.EventSelectorTourMethods", function (require) { + /* eslint-disable no-empty-function */ + "use strict"; + + const {createTourMethods} = require("point_of_sale.tour.utils"); + + class Do { + clickDisplayedEvent(eventName) { + return [ + { + content: `clicking event ${eventName}`, + trigger: `.event-selector-popup .event-name:contains("${eventName}")`, + }, + ]; + } + close() { + return [ + { + content: `Close ticket selector`, + trigger: `.event-selector-popup .button.cancel`, + }, + ]; + } + } + + class Check { + isShown() { + return [ + { + content: "Event selector is shown", + trigger: ".event-selector-popup:not(.oe_hidden)", + run: () => {}, + }, + ]; + } + eventIsDisplayed(eventName) { + return [ + { + content: `'${eventName}' should be displayed`, + trigger: `.event-selector-popup .event-name:contains("${eventName}")`, + run: () => {}, + }, + ]; + } + eventHasAvailabilityLabel(eventName, label) { + return [ + ...this.eventIsDisplayed(eventName), + { + content: `'${eventName}' should contain label ${label}`, + trigger: `.event-selector-popup .event:has(.event-name:contains("${eventName}")) .event-availability-tag:contains("${label}")`, + run: () => {}, + }, + ]; + } + } + + return createTourMethods("EventSelector", Do, Check); +}); diff --git a/pos_event_sale/static/tests/tours/helpers/ProductScreenTourMethods.js b/pos_event_sale/static/tests/tours/helpers/ProductScreenTourMethods.js new file mode 100644 index 0000000000..f7acc23db4 --- /dev/null +++ b/pos_event_sale/static/tests/tours/helpers/ProductScreenTourMethods.js @@ -0,0 +1,35 @@ +/* + Copyright 2022 Camptocamp SA (https://www.camptocamp.com). + @author Iván Todorovich + License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +*/ +odoo.define("pos_event_sale.tour.ProductScreenTourMethods", function (require) { + "use strict"; + + const {createTourMethods} = require("point_of_sale.tour.utils"); + const {Do, Check, Execute} = require("point_of_sale.tour.ProductScreenTourMethods"); + + class DoExt extends Do { + clickAddEventButton() { + return [ + { + content: "click 'More...' button", + trigger: "div.control-button:contains('More...')", + skip_trigger: + ".control-buttons .control-button span:contains('Add Event')", + }, + { + content: "click add event button", + trigger: + '.control-buttons .control-button span:contains("Add Event")', + }, + ]; + } + } + + class CheckExt extends Check {} + + class ExecuteExt extends Execute {} + + return createTourMethods("ProductScreen", DoExt, CheckExt, ExecuteExt); +}); diff --git a/pos_event_sale/static/tests/tours/helpers/TicketSelectorTourMethods.js b/pos_event_sale/static/tests/tours/helpers/TicketSelectorTourMethods.js new file mode 100644 index 0000000000..469ebdddfa --- /dev/null +++ b/pos_event_sale/static/tests/tours/helpers/TicketSelectorTourMethods.js @@ -0,0 +1,63 @@ +/* + Copyright 2021 Camptocamp SA (https://www.camptocamp.com). + @author Iván Todorovich + License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +*/ +odoo.define("pos_event_sale.tour.TicketSelectorTourMethods", function (require) { + /* eslint-disable no-empty-function */ + "use strict"; + + const {createTourMethods} = require("point_of_sale.tour.utils"); + + class Do { + clickDisplayedTicket(ticketName) { + return [ + { + content: `clicking event.ticket ${ticketName}`, + trigger: `.event-tickets-popup .ticket-name:contains("${ticketName}")`, + }, + ]; + } + close() { + return [ + { + content: `Close`, + trigger: `.event-tickets-popup .button.cancel`, + }, + ]; + } + } + + class Check { + isShown() { + return [ + { + content: "Event tickets is shown", + trigger: ".event-tickets-popup:not(:has(.oe_hidden))", + run: () => {}, + }, + ]; + } + ticketIsDisplayed(ticketName) { + return [ + { + content: `'${ticketName}' should be displayed`, + trigger: `.event-tickets-popup .ticket-name:contains("${ticketName}")`, + run: () => {}, + }, + ]; + } + ticketHasAvailabilityLabel(ticketName, label) { + return [ + ...this.ticketIsDisplayed(ticketName), + { + content: `'${ticketName}' should contain label ${label}`, + trigger: `.event-tickets-popup .event-ticket:has(.ticket-name:contains("${ticketName}")) .event-availability-tag:contains("${label}")`, + run: () => {}, + }, + ]; + } + } + + return createTourMethods("TicketSelector", Do, Check); +}); diff --git a/pos_event_sale/tests/__init__.py b/pos_event_sale/tests/__init__.py new file mode 100644 index 0000000000..a97e1da728 --- /dev/null +++ b/pos_event_sale/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_backend +from . import test_frontend diff --git a/pos_event_sale/tests/common.py b/pos_event_sale/tests/common.py new file mode 100644 index 0000000000..39cb05f283 --- /dev/null +++ b/pos_event_sale/tests/common.py @@ -0,0 +1,228 @@ +# Copyright 2023 Camptocamp SA (https://www.camptocamp.com). +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from random import randint + +from odoo import fields + +from odoo.addons.point_of_sale.tests.common import TestPoSCommon +from odoo.addons.point_of_sale.tests.test_frontend import TestPointOfSaleHttpCommon + + +class PosEventMixin: + @classmethod + def setUpData(cls): + # Configure product + cls.product_event = cls.env.ref("event_sale.product_product_event") + cls.product_event.active = True + cls.product_event.available_in_pos = True + # Create event + cls.event = cls.env["event.event"].create( + { + "name": "Les Misérables", + "event_type_id": cls.env.ref("event.event_type_0").id, + "date_begin": fields.Datetime.start_of(fields.Datetime.now(), "day"), + "date_end": fields.Datetime.end_of(fields.Datetime.now(), "day"), + "stage_id": cls.env.ref("event.event_stage_booked").id, + "seats_limited": True, + "seats_max": 10, + } + ) + cls.ticket_kids = cls.env["event.event.ticket"].create( + { + "name": "Kids", + "product_id": cls.product_event.id, + "event_id": cls.event.id, + "price": 0.0, + "seats_limited": True, + "seats_max": 5, + } + ) + cls.ticket_regular = cls.env["event.event.ticket"].create( + { + "name": "Standard", + "product_id": cls.product_event.id, + "event_id": cls.event.id, + "price": 15.0, + } + ) + + +class TestPoSEventCommon(TestPoSCommon, PosEventMixin): + @classmethod + def setUpClass(cls, **kwargs): + super().setUpClass(**kwargs) + cls.setUpData() + cls.env.user.groups_id += cls.env.ref("event.group_event_user") + cls.basic_config.iface_event_sale = True + cls.config = cls.basic_config + # Open session + cls.config.open_ui() + cls.pos_session = cls.config.current_session_id + cls.currency = cls.pos_session.currency_id + cls.pricelist = cls.pos_session.config_id.pricelist_id + cls.pos_session.set_cashbox_pos(0.0, None) + # Used to generate unique order ids + cls._nextId = 0 + + @classmethod + def _create_order_line_data(cls, product=None, quantity=1.0, discount=0.0, fp=None): + cls._nextId += 1 + price_unit = cls.pricelist._get_product_price(product, quantity, False) + tax_ids = fp.map_tax(product.taxes_id) if fp else product.taxes_id + price_unit_after_discount = price_unit * (1 - discount / 100.0) + tax_values = ( + tax_ids.compute_all(price_unit_after_discount, cls.currency, quantity) + if tax_ids + else { + "total_excluded": price_unit * quantity, + "total_included": price_unit * quantity, + } + ) + return { + "discount": discount, + "id": cls._nextId, + "pack_lot_ids": [], + "price_unit": price_unit, + "product_id": product.id, + "price_subtotal": tax_values["total_excluded"], + "price_subtotal_incl": tax_values["total_included"], + "qty": quantity, + "tax_ids": [(6, 0, tax_ids.ids)], + } + + @classmethod + def _create_event_order_line_data( + cls, ticket=None, quantity=1.0, discount=0.0, fp=None + ): + cls._nextId += 1 + product = ticket.product_id + product_lst_price = product.lst_price + product_price = cls.pricelist._get_product_price(product, quantity, False) + price_unit = product_price / product_lst_price * ticket.price + tax_ids = ( + fp.map_tax(ticket.product_id.taxes_id) if fp else ticket.product_id.taxes_id + ) + price_unit_after_discount = price_unit * (1 - discount / 100.0) + tax_values = ( + tax_ids.compute_all(price_unit_after_discount, cls.currency, quantity) + if tax_ids + else { + "total_excluded": price_unit * quantity, + "total_included": price_unit * quantity, + } + ) + return { + "discount": discount, + "id": cls._nextId, + "pack_lot_ids": [], + "price_unit": price_unit, + "product_id": ticket.product_id.id, + "price_subtotal": tax_values["total_excluded"], + "price_subtotal_incl": tax_values["total_included"], + "qty": quantity, + "tax_ids": [(6, 0, tax_ids.ids)], + "event_ticket_id": ticket.id, + } + + @classmethod + def _create_random_uid(cls): + return "%05d-%03d-%04d" % (randint(1, 99999), randint(1, 999), randint(1, 9999)) + + @classmethod + def _create_order_data( + cls, + lines=None, + event_lines=None, + partner=False, + is_invoiced=False, + payments=None, + uid=None, + ): + """Create a dictionary mocking data created by the frontend""" + default_fiscal_position = cls.config.default_fiscal_position_id + fiscal_position = ( + partner.property_account_position_id if partner else default_fiscal_position + ) + uid = uid or cls._create_random_uid() + # Lines + order_lines = [] + if lines: + order_lines.extend( + [ + cls._create_order_line_data(**line, fp=fiscal_position) + for line in lines + ] + ) + if event_lines: + order_lines.extend( + [ + cls._create_event_order_line_data(**line, fp=fiscal_position) + for line in event_lines + ] + ) + # Payments + total_amount_incl = sum(line["price_subtotal_incl"] for line in order_lines) + if payments is None: + default_cash_pm = cls.config.payment_method_ids.filtered( + lambda pm: pm.is_cash_count + )[:1] + if not default_cash_pm: + raise Exception( + "There should be a cash payment method set in the pos.config." + ) + payments = [ + dict( + amount=total_amount_incl, + name=fields.Datetime.now(), + payment_method_id=default_cash_pm.id, + ) + ] + else: + payments = [ + dict(amount=amount, name=fields.Datetime.now(), payment_method_id=pm.id) + for pm, amount in payments + ] + # Order data + total_amount_base = sum(line["price_subtotal"] for line in order_lines) + return { + "data": { + "amount_paid": sum(payment["amount"] for payment in payments), + "amount_return": 0, + "amount_tax": total_amount_incl - total_amount_base, + "amount_total": total_amount_incl, + "creation_date": fields.Datetime.to_string(fields.Datetime.now()), + "fiscal_position_id": fiscal_position.id, + "pricelist_id": cls.config.pricelist_id.id, + "lines": [(0, 0, line) for line in order_lines], + "name": "Order %s" % uid, + "partner_id": partner and partner.id, + "pos_session_id": cls.pos_session.id, + "sequence_number": 2, + "statement_ids": [(0, 0, payment) for payment in payments], + "uid": uid, + "user_id": cls.env.user.id, + "to_invoice": is_invoiced, + }, + "id": uid, + "to_invoice": is_invoiced, + } + + @classmethod + def _create_from_ui(cls, order_list, draft=False): + if not isinstance(order_list, (list, tuple)): + order_list = [order_list] + order_data_list = [cls._create_order_data(**order) for order in order_list] + res = cls.env["pos.order"].create_from_ui(order_data_list, draft=draft) + order_ids = [order["id"] for order in res] + return cls.env["pos.order"].browse(order_ids) + + +class TestPoSEventHttpCommon(TestPointOfSaleHttpCommon, PosEventMixin): + @classmethod + def setUpClass(cls, **kwargs): + super().setUpClass(**kwargs) + cls.setUpData() + cls.env.user.groups_id += cls.env.ref("event.group_event_user") + cls.main_pos_config.iface_event_sale = True + cls.main_pos_config.open_ui() diff --git a/pos_event_sale/tests/test_backend.py b/pos_event_sale/tests/test_backend.py new file mode 100644 index 0000000000..1497a162c9 --- /dev/null +++ b/pos_event_sale/tests/test_backend.py @@ -0,0 +1,89 @@ +# Copyright 2023 Camptocamp SA (https://www.camptocamp.com). +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.tests import tagged + +from .common import TestPoSEventCommon + + +@tagged("post_install", "-at_install") +class TestPoSEvent(TestPoSEventCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.order = cls._create_from_ui( + dict( + event_lines=[ + dict(ticket=cls.ticket_kids, quantity=2), + dict(ticket=cls.ticket_regular, quantity=1), + ] + ) + ) + + def test_action_open_registrations(self): + action = self.order.action_open_event_registrations() + self.assertEqual(action["type"], "ir.actions.act_window") + + def test_event_pos_price_subtotal(self): + self.assertEqual(self.event.pos_price_subtotal, self.order.amount_total) + + def test_event_action_view_pos_orders(self): + action = self.event.action_view_pos_orders() + self.assertEqual(self.env["pos.order"].search(action["domain"]), self.order) + + def test_order_refund(self): + refund = self.env["pos.order"].browse(self.order.refund()["res_id"]) + self.env["pos.make.payment"].with_context( + active_ids=refund.ids, + active_id=refund.id, + active_model=refund._name, + ).create( + { + "payment_method_id": self.config.payment_method_ids[0].id, + "amount": refund.amount_total, + } + ).check() + self.assertTrue( + all(reg.state == "cancel" for reg in self.order.event_registration_ids) + ) + + def test_order_cancel(self): + done_registrations = self.order.event_registration_ids[-2:] + open_registrations = self.order.event_registration_ids - done_registrations + done_registrations.action_set_done() + self.order.action_pos_order_cancel() + self.assertTrue( + all(reg.state == "cancel" for reg in open_registrations), + "Open registrations should be cancelled with the order", + ) + self.assertTrue( + all(reg.state == "done" for reg in done_registrations), + "Done registrations should remain done", + ) + + def test_order_with_negated_registrations(self): + order = self._create_from_ui( + dict( + event_lines=[ + dict(ticket=self.ticket_kids, quantity=2), + dict(ticket=self.ticket_kids, quantity=-2), + dict(ticket=self.ticket_regular, quantity=1), + ] + ) + ) + kids_registrations = order.event_registration_ids.filtered( + lambda r: r.event_ticket_id == self.ticket_kids + ) + self.assertEqual(len(kids_registrations), 2) + self.assertTrue( + all(reg.state == "cancel" for reg in kids_registrations), + "Kids registrations should be cancelled (negated)", + ) + regular_registrations = order.event_registration_ids.filtered( + lambda r: r.event_ticket_id == self.ticket_regular + ) + self.assertEqual(len(regular_registrations), 1) + self.assertTrue( + all(reg.state == "open" for reg in regular_registrations), + "Regular registrations should be confirmed", + ) diff --git a/pos_event_sale/tests/test_frontend.py b/pos_event_sale/tests/test_frontend.py new file mode 100644 index 0000000000..213f7d2f6e --- /dev/null +++ b/pos_event_sale/tests/test_frontend.py @@ -0,0 +1,31 @@ +# Copyright 2021 Camptocamp SA (https://www.camptocamp.com). +# @author Iván Todorovich +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.tests import tagged + +from .common import TestPoSEventHttpCommon + + +@tagged("post_install", "-at_install") +class TestPoSEventHttp(TestPoSEventHttpCommon): + def test_pos_event_sale_basic_tour(self): + self.start_tour( + f"/pos/ui?config_id={self.main_pos_config.id}", + "EventSaleTour", + login="accountman", + ) + order = self.env["pos.order"].search([], order="id desc", limit=1) + self.assertTrue( + all(reg.state == "open" for reg in order.event_registration_ids), + "Registrations should be confirmed", + ) + + def test_pos_event_sale_availability_tour(self): + self.event.seats_max = 5 + self.ticket_kids.seats_max = 3 + self.start_tour( + f"/pos/ui?config_id={self.main_pos_config.id}", + "EventSaleAvailabilityTour", + login="accountman", + ) diff --git a/pos_event_sale/views/event_event.xml b/pos_event_sale/views/event_event.xml new file mode 100644 index 0000000000..67a06d1fcf --- /dev/null +++ b/pos_event_sale/views/event_event.xml @@ -0,0 +1,28 @@ + + + + + event.event + + + + + + + + diff --git a/pos_event_sale/views/event_registration.xml b/pos_event_sale/views/event_registration.xml new file mode 100644 index 0000000000..3699b9a208 --- /dev/null +++ b/pos_event_sale/views/event_registration.xml @@ -0,0 +1,48 @@ + + + + + + event.registration + + +
+
+ + + + + +
+ + + event.registration + + + + + + + + +
diff --git a/pos_event_sale/views/pos_order.xml b/pos_event_sale/views/pos_order.xml new file mode 100644 index 0000000000..734c318dea --- /dev/null +++ b/pos_event_sale/views/pos_order.xml @@ -0,0 +1,44 @@ + + + + + pos.order + + +
+ +
+ + + + +
+
+
diff --git a/pos_event_sale/views/res_config_settings.xml b/pos_event_sale/views/res_config_settings.xml new file mode 100644 index 0000000000..f9482da5e2 --- /dev/null +++ b/pos_event_sale/views/res_config_settings.xml @@ -0,0 +1,143 @@ + + + + + res.config.settings.view.form.inherit.point_of_sale + res.config.settings + + +
+
+
+ +
+
+
+
+
+
+
+
diff --git a/setup/pos_event_sale/odoo/addons/pos_event_sale b/setup/pos_event_sale/odoo/addons/pos_event_sale new file mode 120000 index 0000000000..e63e1f6277 --- /dev/null +++ b/setup/pos_event_sale/odoo/addons/pos_event_sale @@ -0,0 +1 @@ +../../../../pos_event_sale \ No newline at end of file diff --git a/setup/pos_event_sale/setup.py b/setup/pos_event_sale/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/pos_event_sale/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)