diff --git a/bemade_partner_email_domain/__manifest__.py b/bemade_partner_email_domain/__manifest__.py index c4f3d87..4009236 100644 --- a/bemade_partner_email_domain/__manifest__.py +++ b/bemade_partner_email_domain/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Automated Partner Association by Email Domain', - 'version': '17.0.0.0.0', + 'version': '17.0.0.0.1', 'category': 'Extra Tools', 'summary': 'Automatically associates partners with companies using matching email domains', 'description': """ diff --git a/bemade_partner_email_domain/models/res_partner.py b/bemade_partner_email_domain/models/res_partner.py index 9247a78..7fe3451 100644 --- a/bemade_partner_email_domain/models/res_partner.py +++ b/bemade_partner_email_domain/models/res_partner.py @@ -1,4 +1,5 @@ from odoo import api, fields, models, Command +from odoo.tools.sql import SQL import uuid from odoo.addons.website.controllers.main import Website @@ -37,7 +38,6 @@ class Partner(models.Model): ) template.with_context(links=links).send_mail(self.id, force_send=True) - # @api.onchange('email') def _check_parent_from_email_domain(self): for rec in self: if rec.parent_id: @@ -52,21 +52,27 @@ class Partner(models.Model): # If there's no '@' symbol, the email address is invalid return False - # Loop while there's more than one part in the domain (e.g., subdomain.domain.tld) - while "." in email_domain: - # If the current email domain matches the main domain, return True - company_domain = self.env["res.partner"].search( - [("email_domain", "ilike", email_domain)] + sql = """ + SELECT id + FROM res_partner + WHERE email_domain IS NOT NULL + AND email_domain = RIGHT(%s, LENGTH(email_domain)) + AND company_id = %s + """ + self.env.cr.execute( + SQL(sql, email_domain, self.env.company and self.env.company.id or None) + ) + res = self.env.cr.fetchall() + if len(res) > 1: + rec._send_selection_email( + self.env["res.partner"].search( + [("id", "in", [result[0] for result in res])] + ) ) - if company_domain: - if len(company_domain) > 1: - rec._send_selection_email(company_domain) - else: - rec.parent_id = company_domain.id - return - - # If not, drop the part before the first '.' to check the next level - email_domain = email_domain.split(".", 1)[1] + elif len(res) == 1: + rec.write({"parent_id": res[0][0]}) + else: + continue @api.model_create_multi def create(self, vals_list): diff --git a/delivery_carrier_partner_account/__init__.py b/delivery_carrier_partner_account/__init__.py new file mode 100644 index 0000000..9b42961 --- /dev/null +++ b/delivery_carrier_partner_account/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizard diff --git a/delivery_carrier_partner_account/__manifest__.py b/delivery_carrier_partner_account/__manifest__.py new file mode 100644 index 0000000..67e78c4 --- /dev/null +++ b/delivery_carrier_partner_account/__manifest__.py @@ -0,0 +1,45 @@ +# +# Bemade Inc. +# +# Copyright (C) 2023-June Bemade Inc. (). +# Author: Marc Durepos (Contact : marc@bemade.org) +# +# This program is under the terms of the GNU Lesser General Public License, +# version 3. +# +# For full license details, see https://www.gnu.org/licenses/lgpl-3.0.en.html. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +# DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. +# +{ + "name": "Carrier Accounts by Partner", + "version": "17.0.0.1.0", + "summary": "Add one or many carrier accounts per partner", + "category": "Delivery", + "author": "Bemade Inc.", + "website": "http://www.bemade.org", + "license": "LGPL-3", + "depends": [ + "incrementing_sequence_mixin", + "stock_delivery", + ], + "data": [ + "security/ir.model.access.csv", + "data/actions.xml", + "views/res_partner_views.xml", + "views/delivery_carrier_views.xml", + "views/delivery_carrier_account_views.xml", + "views/sale_order_views.xml", + "views/stock_picking_views.xml", + "wizard/choose_delivery_carrier_views.xml", + ], + "assets": {}, + "installable": True, + "auto_install": False, +} diff --git a/delivery_carrier_partner_account/data/actions.xml b/delivery_carrier_partner_account/data/actions.xml new file mode 100644 index 0000000..08db8a3 --- /dev/null +++ b/delivery_carrier_partner_account/data/actions.xml @@ -0,0 +1,11 @@ + + + + Carrier Accounts + delivery.carrier.account + tree,form + current + [('delivery_carrier_id', '=', context.get("carrier_id"))] + {'default_delivery_carrier_id': active_id} + + \ No newline at end of file diff --git a/delivery_carrier_partner_account/models/__init__.py b/delivery_carrier_partner_account/models/__init__.py new file mode 100644 index 0000000..4a359e6 --- /dev/null +++ b/delivery_carrier_partner_account/models/__init__.py @@ -0,0 +1,5 @@ +from . import carrier_account_mixin +from . import delivery_carrier_account +from . import res_partner +from . import sales_order +from . import stock_picking diff --git a/delivery_carrier_partner_account/models/carrier_account_mixin.py b/delivery_carrier_partner_account/models/carrier_account_mixin.py new file mode 100644 index 0000000..5b43ce1 --- /dev/null +++ b/delivery_carrier_partner_account/models/carrier_account_mixin.py @@ -0,0 +1,175 @@ +from odoo import models, fields, api, _ +from odoo.exceptions import UserError + + +class CarrierAccountMixin(models.AbstractModel): + """ + Carrier Account Mixin. + + This class provides functionality for handling carrier accounts within an order + system. It ensures that the correct carrier account is used based on the + delivery billing mode (collect, third party, prepaid). It also provides methods + to compute and validate carrier accounts according to the selected carrier and + partners involved in the order. + + Most implementations should override the sender_id and recipient_id fields with + related fields that simply point to the res.partner record that is appropriate. For + example, the sender_id for a sales order would be company_id.partner_id and its + recipient_id would be partner_id. + """ + + _name = "carrier.account.mixin" + _description = "Carrier Account Mixin" + + sender_id = fields.Many2one(comodel_name="res.partner", string="Sender") + recipient_id = fields.Many2one(comodel_name="res.partner", string="Recipient") + carrier_id = fields.Many2one(comodel_name="delivery.carrier", string="Carrier") + + delivery_billing_mode = fields.Selection( + [ + ("no charge", "No Charge"), + ("prepaid", "Prepaid"), + ("collect", "Collect"), + ("third party", "Third Party"), + ], + help=_( + """ + Prepaid: The shipper will pay the carrier (and may bill the client). + Collect: The recipient will be billed (account information needed) + Third Party: A third party will be billed (account information needed) + """ + ), + string="Delivery Billing Mode", + ) + + carrier_account_id = fields.Many2one( + comodel_name="delivery.carrier.account", + ondelete="restrict", + compute="_compute_carrier_account_id", + inverse="_inverse_carrier_account_id", + store=True, + compute_sudo=True, + string="Carrier Account", + ) + + carrier_account_owner_id = fields.Many2one( + comodel_name="res.partner", + related="carrier_account_id.partner_id", + string="Carrier Account Owner", + ) + + valid_carrier_account_ids = fields.One2many( + comodel_name="delivery.carrier.account", + compute="_compute_valid_carrier_account_ids", + compute_sudo=True, + string="Valid Carrier Accounts", + ) + + @api.depends("delivery_billing_mode", "carrier_id", "recipient_id", "sender_id") + def _compute_valid_carrier_account_ids(self): + for rec in self: + if rec.delivery_billing_mode == "collect": + rec.valid_carrier_account_ids = ( + (rec.recipient_id | rec.recipient_id.commercial_partner_id) + .mapped("carrier_account_ids") + .filtered( + lambda account: account.delivery_carrier_id == rec.carrier_id + ) + ) + if rec.delivery_billing_mode == "third party": + rec.valid_carrier_account_ids = self.env[ + "delivery.carrier.account" + ].search( + [ + ("delivery_carrier_id", "=", rec.carrier_id.id), + ( + "partner_id", + "not in", + [ + rec.sender_id.id, + rec.recipient_id.id, + rec.recipient_id.commercial_partner_id.id, + ], + ), + ] + ) + if rec.delivery_billing_mode == "prepaid": + rec.valid_carrier_account_ids = ( + rec.sender_id.carrier_account_ids.filtered( + lambda account: account.delivery_carrier_id == rec.carrier_id + ) + ) + if rec.delivery_billing_mode == "no charge": + rec.valid_carrier_account_ids = self.env["delivery.carrier.account"] + if not rec.delivery_billing_mode: + rec.valid_carrier_account_ids = self.env["delivery.carrier.account"] + + @api.depends("delivery_billing_mode", "carrier_id", "valid_carrier_account_ids") + def _compute_carrier_account_id(self): + """Compute the carrier account to use for this record if one is not set or if + the current one doesn't match the carrier_id selected. + + When delivery_billing_mode is collect, we need to choose a carrier account that + matches both the carrier_id and the partner_id or its commercial partner. + + When it is third party, any account matching the carrier_id is fine. + + When it is prepaid, we select the company's account. + """ + for rec in self: + if rec.delivery_billing_mode == "collect": + if rec.carrier_account_id not in rec.valid_carrier_account_ids: + if ( + rec.recipient_id.default_carrier_account_id.delivery_carrier_id + == rec.carrier_id + ): + rec.carrier_account_id = ( + rec.recipient_id.default_carrier_account_id + ) + elif rec.valid_carrier_account_ids: + rec.carrier_account_id = rec.valid_carrier_account_ids[0] + else: + raise UserError( + "The client does not have an account with the selected carrier." + ) + if rec.delivery_billing_mode == "third party": + if rec.carrier_account_id not in rec.valid_carrier_account_ids: + rec.carrier_account_id = False + if rec.delivery_billing_mode == "prepaid": + rec.carrier_account_id = ( + self.env["delivery.carrier.account"] + .search([("partner_id", "=", rec.sender_id.id)]) + .filtered( + lambda account: account.delivery_carrier_id == rec.carrier_id + ) + ) + if ( + rec.delivery_billing_mode == "no charge" + or not rec.delivery_billing_mode + ): + rec.carrier_account_id = False + + @api.constrains("carrier_account_id") + def _check_account_id(self): + for rec in self: + if ( + not rec.delivery_billing_mode + or rec.delivery_billing_mode == "no charge" + ): + if rec.carrier_account_id: + raise UserError( + _("No carrier account should be set for no charge delivery.") + ) + continue + # We allow empty carrier account for third party since we can't always + # set it automatically. + if ( + rec.delivery_billing_mode == "third party" + and not rec.carrier_account_id + ): + continue + if rec.carrier_account_id not in rec.valid_carrier_account_ids: + raise UserError(_("Invalid carrier account selected.")) + + def _inverse_carrier_account_id(self): + pass diff --git a/delivery_carrier_partner_account/models/delivery_carrier_account.py b/delivery_carrier_partner_account/models/delivery_carrier_account.py new file mode 100644 index 0000000..eec90d2 --- /dev/null +++ b/delivery_carrier_partner_account/models/delivery_carrier_account.py @@ -0,0 +1,30 @@ +from odoo import models, fields, api + + +class DeliveryCarrierAccount(models.Model): + _name = "delivery.carrier.account" + _description = "Delivery Carrier Account" + _inherit = ["mail.thread", "mail.activity.mixin"] + + delivery_carrier_id = fields.Many2one( + comodel_name="delivery.carrier", + string="Delivery Carriers", + required=True, + ondelete="restrict", + ) + + account_number = fields.Char( + required=True, + tracking=1, + ) + + partner_id = fields.Many2one( + comodel_name="res.partner", + required=True, + ondelete="cascade", + ) + + @api.depends("account_number") + def _compute_display_name(self): + for record in self: + record.display_name = record.account_number diff --git a/delivery_carrier_partner_account/models/res_partner.py b/delivery_carrier_partner_account/models/res_partner.py new file mode 100644 index 0000000..e0306fe --- /dev/null +++ b/delivery_carrier_partner_account/models/res_partner.py @@ -0,0 +1,18 @@ +from odoo import models, fields + + +class Partner(models.Model): + _inherit = "res.partner" + + carrier_account_ids = fields.One2many( + comodel_name="delivery.carrier.account", + inverse_name="partner_id", + tracking=2, + string="Carrier Accounts", + ) + + default_carrier_account_id = fields.Many2one( + comodel_name="delivery.carrier.account", + tracking=1, + ondelete="restrict", + ) diff --git a/delivery_carrier_partner_account/models/sales_order.py b/delivery_carrier_partner_account/models/sales_order.py new file mode 100644 index 0000000..19c3a0e --- /dev/null +++ b/delivery_carrier_partner_account/models/sales_order.py @@ -0,0 +1,30 @@ +from odoo import models, fields, api, _ + + +class SalesOrder(models.Model): + _inherit = ["sale.order", "carrier.account.mixin"] + _name = "sale.order" + + recipient_id = fields.Many2one( + comodel_name="res.partner", + related="partner_id", + ) + sender_id = fields.Many2one( + comodel_name="res.partner", + related="company_id.partner_id", + ) + + @api.model + def write(self, values): + res = super().write(values) + # If carrier account ID changes for a confirmed order, change it on its + # pending pickings as well. + if "carrier_account_id" in values: + for rec in self.filtered( + lambda order: order.state not in ["draft", "sent"] + ): + for picking in rec.picking_ids.filtered( + lambda pick: pick.state not in ("done", "cancel") + ): + picking.carrier_account_id = rec.carrier_account_id + return res diff --git a/delivery_carrier_partner_account/models/stock_picking.py b/delivery_carrier_partner_account/models/stock_picking.py new file mode 100644 index 0000000..0c7dd62 --- /dev/null +++ b/delivery_carrier_partner_account/models/stock_picking.py @@ -0,0 +1,31 @@ +from odoo import models, fields, api + + +class Picking(models.Model): + _inherit = ["stock.picking", "carrier.account.mixin"] + _name = "stock.picking" + + recipient_id = fields.Many2one( + comodel_name="res.partner", + related="partner_id", + ) + sender_id = fields.Many2one( + comodel_name="res.partner", + related="company_id.partner_id", + ) + + # Override to base it on the sale order field initially and when changed + delivery_billing_mode = fields.Selection( + compute="_compute_delivery_billing_mode", + inverse="_inverse_delivery_billing_mode", + store=True, + ) + + @api.depends("sale_id", "sale_id.delivery_billing_mode") + def _compute_delivery_billing_mode(self): + for rec in self: + rec.delivery_billing_mode = rec.sale_id.delivery_billing_mode + rec.carrier_account_id = rec.sale_id.carrier_account_id + + def _inverse_delivery_billing_mode(self): + pass diff --git a/delivery_carrier_partner_account/security/ir.model.access.csv b/delivery_carrier_partner_account/security/ir.model.access.csv new file mode 100644 index 0000000..78b338f --- /dev/null +++ b/delivery_carrier_partner_account/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +delivery_carrier_partner_account.access_delivery_carrier_account,access_delivery_carrier_account,delivery_carrier_partner_account.model_delivery_carrier_account,base.group_user,1,1,1,1 diff --git a/delivery_carrier_partner_account/tests/__init__.py b/delivery_carrier_partner_account/tests/__init__.py new file mode 100644 index 0000000..94c2ac7 --- /dev/null +++ b/delivery_carrier_partner_account/tests/__init__.py @@ -0,0 +1,4 @@ +from . import test_carrier_account_common +from . import test_carrier_account_mixin +from . import test_choose_delivery_carrier +from . import test_sale_order diff --git a/delivery_carrier_partner_account/tests/test_carrier_account_common.py b/delivery_carrier_partner_account/tests/test_carrier_account_common.py new file mode 100644 index 0000000..cfff7cf --- /dev/null +++ b/delivery_carrier_partner_account/tests/test_carrier_account_common.py @@ -0,0 +1,80 @@ +from odoo.tests import TransactionCase, tagged +from odoo import Command + + +@tagged("-at_install", "post_install") +class TestCarrierAccountCommon(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.client_partner = cls.env["res.partner"].create( + { + "name": "Test partner", + } + ) + cls.random_partner = cls.env["res.partner"].create( + { + "name": "Third Party", + } + ) + cls.delivery_carrier_1 = cls.env.ref("delivery.free_delivery_carrier") + cls.delivery_carrier_2 = cls.env.ref("delivery.delivery_local_delivery") + cls.client_account_1 = cls.env["delivery.carrier.account"].create( + { + "partner_id": cls.client_partner.id, + "delivery_carrier_id": cls.delivery_carrier_1.id, + "account_number": "1234567890", + } + ) + cls.client_account_2 = cls.env["delivery.carrier.account"].create( + { + "partner_id": cls.client_partner.id, + "delivery_carrier_id": cls.delivery_carrier_2.id, + "account_number": "0987654321", + } + ) + cls.sender_account_1 = cls.env["delivery.carrier.account"].create( + { + "partner_id": cls.env.company.partner_id.id, + "delivery_carrier_id": cls.delivery_carrier_1.id, + "account_number": "hijklmn", + } + ) + cls.sender_account_2 = cls.env["delivery.carrier.account"].create( + { + "partner_id": cls.env.company.partner_id.id, + "delivery_carrier_id": cls.delivery_carrier_2.id, + "account_number": "abcdefg", + } + ) + cls.third_party_account_1 = cls.env["delivery.carrier.account"].create( + { + "partner_id": cls.random_partner.id, + "delivery_carrier_id": cls.delivery_carrier_1.id, + "account_number": "8910111213", + } + ) + cls.third_party_account_2 = cls.env["delivery.carrier.account"].create( + { + "partner_id": cls.random_partner.id, + "delivery_carrier_id": cls.delivery_carrier_2.id, + "account_number": "zzzzzzzzz", + } + ) + + def _create_sale_order(self, billing_mode, carrier, account): + vals = { + "partner_id": self.client_partner.id, + "carrier_id": carrier.id, + "delivery_billing_mode": billing_mode, + "order_line": [ + Command.create( + { + "product_id": self.env.ref("product.product_product_4").id, + } + ) + ], + } + if account: + vals["carrier_account_id"] = account.id + return self.env["sale.order"].create(vals) diff --git a/delivery_carrier_partner_account/tests/test_carrier_account_mixin.py b/delivery_carrier_partner_account/tests/test_carrier_account_mixin.py new file mode 100644 index 0000000..bbf6b2e --- /dev/null +++ b/delivery_carrier_partner_account/tests/test_carrier_account_mixin.py @@ -0,0 +1,87 @@ +from .test_carrier_account_common import TestCarrierAccountCommon +from odoo.exceptions import UserError + + +class TestCarrierAccountMixin(TestCarrierAccountCommon): + def test_compute_account_collect_order(self): + order = self._create_sale_order( + "collect", + self.delivery_carrier_1, + False, + ) + self.assertEqual(order.carrier_account_id, self.client_account_1) + + def test_compute_account_prepaid_order(self): + picking = self.env["stock.picking"].create( + { + "partner_id": self.client_partner.id, + "carrier_id": self.delivery_carrier_2.id, + "picking_type_id": self.env.ref("stock.warehouse0").out_type_id.id, + "delivery_billing_mode": "prepaid", + } + ) + self.assertEqual(picking.carrier_account_id, self.sender_account_2) + + def test_compute_account_third_party_order(self): + picking = self.env["stock.picking"].create( + { + "partner_id": self.client_partner.id, + "carrier_id": self.delivery_carrier_2.id, + "picking_type_id": self.env.ref("stock.warehouse0").out_type_id.id, + "delivery_billing_mode": "prepaid", + } + ) + # No need to assert we have an account selected here. Tested elsewhere. + picking.delivery_billing_mode = "third party" + self.assertFalse(picking.carrier_account_id) + + def test_changing_account_on_confirmed_sale_changes_picking(self): + new_account = self.env["delivery.carrier.account"].create( + { + "partner_id": self.client_partner.id, + "delivery_carrier_id": self.delivery_carrier_1.id, + "account_number": "1234567891", + } + ) + order = self._create_sale_order("collect", self.delivery_carrier_1, False) + order.action_confirm() + order.carrier_account_id = new_account + self.assertEqual(order.picking_ids.carrier_account_id, new_account) + + def test_incorrect_collect_account(self): + with self.assertRaises(UserError): + self._create_sale_order( + "collect", + self.delivery_carrier_1, + self.sender_account_1, + ) + with self.assertRaises(UserError): + self._create_sale_order( + "collect", + self.delivery_carrier_1, + self.third_party_account_1, + ) + + def test_incorrect_prepaid_account(self): + with self.assertRaises(UserError): + self._create_sale_order( + "prepaid", + self.delivery_carrier_1, + self.client_account_1, + ) + with self.assertRaises(UserError): + self._create_sale_order( + "prepaid", + self.delivery_carrier_1, + self.third_party_account_1, + ) + + def test_incorrect_third_party_account(self): + with self.assertRaises(UserError): + self._create_sale_order( + "third party", self.delivery_carrier_1, self.client_account_1 + ) + with self.assertRaises(UserError): + self._create_sale_order( + "third party", self.delivery_carrier_1, self.sender_account_1 + ) diff --git a/delivery_carrier_partner_account/tests/test_choose_delivery_carrier.py b/delivery_carrier_partner_account/tests/test_choose_delivery_carrier.py new file mode 100644 index 0000000..8449a87 --- /dev/null +++ b/delivery_carrier_partner_account/tests/test_choose_delivery_carrier.py @@ -0,0 +1,23 @@ +from .test_carrier_account_common import TestCarrierAccountCommon + + +class TestChooseDeliveryCarrier(TestCarrierAccountCommon): + def test_sale_order_add_transport(self): + order = self.env["sale.order"].create( + { + "partner_id": self.client_partner.id, + } + ) + wizard_action = order.action_open_delivery_wizard() + + wizard = ( + self.env[wizard_action["res_model"]] + .with_context(wizard_action["context"]) + .create({}) + ) + wizard.carrier_id = self.delivery_carrier_1 + wizard.delivery_billing_mode = "collect" + wizard.button_confirm() + self.assertEqual(order.carrier_id, self.delivery_carrier_1) + self.assertEqual(order.carrier_account_id, self.client_account_1) + self.assertEqual(order.delivery_billing_mode, "collect") diff --git a/delivery_carrier_partner_account/tests/test_sale_order.py b/delivery_carrier_partner_account/tests/test_sale_order.py new file mode 100644 index 0000000..288cc98 --- /dev/null +++ b/delivery_carrier_partner_account/tests/test_sale_order.py @@ -0,0 +1,8 @@ +from .test_carrier_account_common import TestCarrierAccountCommon + + +class TestSalesOrder(TestCarrierAccountCommon): + def test_sales_order_creation_with_default_account(self): + self.client_partner.property_delivery_carrier_id = self.delivery_carrier_1 + self.client_partner.default_carrier_account_id = self.client_account_1 + self.env["sale.order"].create({"partner_id": self.client_partner.id}) diff --git a/delivery_carrier_partner_account/views/delivery_carrier_account_views.xml b/delivery_carrier_partner_account/views/delivery_carrier_account_views.xml new file mode 100644 index 0000000..b47ba37 --- /dev/null +++ b/delivery_carrier_partner_account/views/delivery_carrier_account_views.xml @@ -0,0 +1,14 @@ + + + + delivery.carrier.account.view.tree + delivery.carrier.account + + + + + + + + + \ No newline at end of file diff --git a/delivery_carrier_partner_account/views/delivery_carrier_views.xml b/delivery_carrier_partner_account/views/delivery_carrier_views.xml new file mode 100644 index 0000000..3a068a8 --- /dev/null +++ b/delivery_carrier_partner_account/views/delivery_carrier_views.xml @@ -0,0 +1,18 @@ + + + + + delivery.carrier + delivery.carrier.view.form + + +