From 34ec1a8230f8b7c0e05932f7b37e8bf891f9052a Mon Sep 17 00:00:00 2001 From: Marc Durepos Date: Wed, 20 Nov 2024 09:15:59 -0500 Subject: [PATCH] new module delivery_carrier_partner_account started --- delivery_carrier_partner_account/__init__.py | 1 + .../__manifest__.py | 36 ++++ .../models/__init__.py | 5 + .../models/carrier_account_mixin.py | 174 ++++++++++++++++++ .../models/delivery_carrier_account.py | 27 +++ .../models/res_partner.py | 18 ++ .../models/sales_order.py | 30 +++ .../models/stock_picking.py | 31 ++++ .../security/ir.model.access.csv | 2 + .../tests/__init__.py | 1 + .../tests/test_carrier_account_mixin.py | 164 +++++++++++++++++ 11 files changed, 489 insertions(+) create mode 100644 delivery_carrier_partner_account/__init__.py create mode 100644 delivery_carrier_partner_account/__manifest__.py create mode 100644 delivery_carrier_partner_account/models/__init__.py create mode 100644 delivery_carrier_partner_account/models/carrier_account_mixin.py create mode 100644 delivery_carrier_partner_account/models/delivery_carrier_account.py create mode 100644 delivery_carrier_partner_account/models/res_partner.py create mode 100644 delivery_carrier_partner_account/models/sales_order.py create mode 100644 delivery_carrier_partner_account/models/stock_picking.py create mode 100644 delivery_carrier_partner_account/security/ir.model.access.csv create mode 100644 delivery_carrier_partner_account/tests/__init__.py create mode 100644 delivery_carrier_partner_account/tests/test_carrier_account_mixin.py diff --git a/delivery_carrier_partner_account/__init__.py b/delivery_carrier_partner_account/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/delivery_carrier_partner_account/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/delivery_carrier_partner_account/__manifest__.py b/delivery_carrier_partner_account/__manifest__.py new file mode 100644 index 0000000..23efd98 --- /dev/null +++ b/delivery_carrier_partner_account/__manifest__.py @@ -0,0 +1,36 @@ +# +# 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"], + "assets": {}, + "installable": True, + "auto_install": False, +} 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..60be26c --- /dev/null +++ b/delivery_carrier_partner_account/models/carrier_account_mixin.py @@ -0,0 +1,174 @@ +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) + """ + ), + tracking=1, + 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" + ) and rec.carrier_account_id: + raise UserError( + _("No carrier account should be set for no charge delivery.") + ) + # 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..9f65100 --- /dev/null +++ b/delivery_carrier_partner_account/models/delivery_carrier_account.py @@ -0,0 +1,27 @@ +from odoo import models, fields + + +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, + readonly=True, + ondelete="restrict", + ) + + account_number = fields.Char( + required=True, + tracking=1, + ) + + partner_id = fields.Many2one( + comodel_name="res.partner", + required=True, + readonly=True, + ondelete="cascade", + ) 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..26d511d --- /dev/null +++ b/delivery_carrier_partner_account/tests/__init__.py @@ -0,0 +1 @@ +from . import test_carrier_account_mixin 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..0066dcf --- /dev/null +++ b/delivery_carrier_partner_account/tests/test_carrier_account_mixin.py @@ -0,0 +1,164 @@ +from odoo.tests import TransactionCase, tagged, mute_logger +from odoo import Command +from odoo.exceptions import UserError + + +@tagged("-at_install", "post_install") +class TestCarrierAccountMixin(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 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 + ) + + 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)