[MIG] bemade_margin_vendor_pricelist to 18.0
This commit is contained in:
parent
2d0577d7e3
commit
7f62a24819
10 changed files with 762 additions and 0 deletions
1
bemade_margin_vendor_pricelist/__init__.py
Normal file
1
bemade_margin_vendor_pricelist/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import models
|
||||
31
bemade_margin_vendor_pricelist/__manifest__.py
Normal file
31
bemade_margin_vendor_pricelist/__manifest__.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
#
|
||||
# Bemade Inc.
|
||||
#
|
||||
# Copyright (C) September 2023 Bemade Inc. (<https://www.bemade.org>).
|
||||
# Author: Marc Durepos (Contact : marc@bemade.org)
|
||||
#
|
||||
# This program is under the terms of the Odoo Proprietary License v1.0 (OPL-1)
|
||||
# It is forbidden to publish, distribute, sublicense, or sell copies of the Software
|
||||
# or modified copies of the Software.
|
||||
#
|
||||
# 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": "Sales Margin on Vendor Price",
|
||||
"version": "18.0.0.0.5",
|
||||
"summary": "Enables calculation of sales margins based on vendor pricelists.",
|
||||
"description": """Adds a new method for calculating """,
|
||||
"author": "Bemade Inc.",
|
||||
"website": "https://www.bemade.org",
|
||||
"license": "LGPL-3",
|
||||
"depends": ["sale_stock_margin", "sale_management", "stock"],
|
||||
"data": ["views/sale_order.xml"],
|
||||
"installable": True,
|
||||
"auto_install": False,
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * bemade_margin_vendor_pricelist
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server 15.0+e\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-10-19 15:08+0000\n"
|
||||
"PO-Revision-Date: 2023-10-19 15:08+0000\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: bemade_margin_vendor_pricelist
|
||||
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order__margin_actual
|
||||
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__margin_actual
|
||||
msgid "Margin"
|
||||
msgstr ""
|
||||
|
||||
#. module: bemade_margin_vendor_pricelist
|
||||
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order__margin_percent_actual
|
||||
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__margin_percent_actual
|
||||
msgid "Margin (%)"
|
||||
msgstr ""
|
||||
|
||||
#. module: bemade_margin_vendor_pricelist
|
||||
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__margin_percent_vendor
|
||||
msgid "Margin (%) on Vendor Price"
|
||||
msgstr ""
|
||||
|
||||
#. module: bemade_margin_vendor_pricelist
|
||||
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__margin_vendor
|
||||
msgid "Margin on Vendor Price"
|
||||
msgstr ""
|
||||
|
||||
#. module: bemade_margin_vendor_pricelist
|
||||
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__purchase_price_actual
|
||||
msgid "Purchase Price"
|
||||
msgstr ""
|
||||
|
||||
#. module: bemade_margin_vendor_pricelist
|
||||
#: model:ir.model,name:bemade_margin_vendor_pricelist.model_sale_order
|
||||
msgid "Sales Order"
|
||||
msgstr ""
|
||||
|
||||
#. module: bemade_margin_vendor_pricelist
|
||||
#: model:ir.model,name:bemade_margin_vendor_pricelist.model_sale_order_line
|
||||
msgid "Sales Order Line"
|
||||
msgstr ""
|
||||
|
||||
#. module: bemade_margin_vendor_pricelist
|
||||
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__purchase_price_vendor
|
||||
msgid "Vendor Price"
|
||||
msgstr ""
|
||||
58
bemade_margin_vendor_pricelist/i18n/fr.po
Normal file
58
bemade_margin_vendor_pricelist/i18n/fr.po
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * bemade_margin_vendor_pricelist
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server 15.0+e\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-10-19 15:08+0000\n"
|
||||
"PO-Revision-Date: 2023-10-19 15:08+0000\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: bemade_margin_vendor_pricelist
|
||||
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order__margin_actual
|
||||
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__margin_actual
|
||||
msgid "Margin"
|
||||
msgstr "Marge"
|
||||
|
||||
#. module: bemade_margin_vendor_pricelist
|
||||
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order__margin_percent_actual
|
||||
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__margin_percent_actual
|
||||
msgid "Margin (%)"
|
||||
msgstr "Marge (%)"
|
||||
|
||||
#. module: bemade_margin_vendor_pricelist
|
||||
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__margin_percent_vendor
|
||||
msgid "Margin (%) on Vendor Price"
|
||||
msgstr "Marge (%) sur le prix du fournisseur"
|
||||
|
||||
#. module: bemade_margin_vendor_pricelist
|
||||
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__margin_vendor
|
||||
msgid "Margin on Vendor Price"
|
||||
msgstr "Marge sur le prix du fournisseur"
|
||||
|
||||
#. module: bemade_margin_vendor_pricelist
|
||||
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__purchase_price_actual
|
||||
msgid "Purchase Price"
|
||||
msgstr "Prix d'achat"
|
||||
|
||||
#. module: bemade_margin_vendor_pricelist
|
||||
#: model:ir.model,name:bemade_margin_vendor_pricelist.model_sale_order
|
||||
msgid "Sales Order"
|
||||
msgstr "Commande de vente"
|
||||
|
||||
#. module: bemade_margin_vendor_pricelist
|
||||
#: model:ir.model,name:bemade_margin_vendor_pricelist.model_sale_order_line
|
||||
msgid "Sales Order Line"
|
||||
msgstr "Article de commande de vente"
|
||||
|
||||
#. module: bemade_margin_vendor_pricelist
|
||||
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__purchase_price_vendor
|
||||
msgid "Vendor Price"
|
||||
msgstr "Prix du fournisseur"
|
||||
2
bemade_margin_vendor_pricelist/models/__init__.py
Normal file
2
bemade_margin_vendor_pricelist/models/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from . import sale_order
|
||||
from . import sale_order_line
|
||||
23
bemade_margin_vendor_pricelist/models/sale_order.py
Normal file
23
bemade_margin_vendor_pricelist/models/sale_order.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
from odoo import models, fields, api, _
|
||||
from odoo.tools.float_utils import float_is_zero, float_compare
|
||||
|
||||
|
||||
class SaleOrder(models.Model):
|
||||
_inherit = 'sale.order'
|
||||
|
||||
gross_profit = fields.Monetary("Gross Profit", compute='_compute_margin_actual', store=False)
|
||||
|
||||
gross_profit_percent = fields.Float(
|
||||
string='Gross Profit (%)',
|
||||
compute='_compute_margin_actual',
|
||||
store=False,
|
||||
group_operator='avg'
|
||||
)
|
||||
|
||||
@api.depends('order_line.gross_profit', 'amount_untaxed')
|
||||
def _compute_margin_actual(self):
|
||||
for order in self:
|
||||
order.gross_profit = sum(order.order_line.mapped('gross_profit'))
|
||||
order.gross_profit_percent = order.amount_untaxed and order.gross_profit / order.amount_untaxed
|
||||
|
||||
|
||||
141
bemade_margin_vendor_pricelist/models/sale_order_line.py
Normal file
141
bemade_margin_vendor_pricelist/models/sale_order_line.py
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
from odoo import models, fields, api, _
|
||||
from odoo.tools.float_utils import float_is_zero, float_compare
|
||||
|
||||
|
||||
class SaleOrderLine(models.Model):
|
||||
_inherit = "sale.order.line"
|
||||
|
||||
purchase_price_vendor = fields.Float(
|
||||
compute="_compute_purchase_price_vendor",
|
||||
string="Vendor Price",
|
||||
groups="base.group_user",
|
||||
digits="Product Price",
|
||||
)
|
||||
|
||||
purchase_price_actual = fields.Float(
|
||||
compute="_compute_actual_gp",
|
||||
digits="Product Price",
|
||||
groups="base.group_user",
|
||||
string="Purchase Price",
|
||||
)
|
||||
|
||||
gross_profit = fields.Float(
|
||||
compute="_compute_actual_gp",
|
||||
digits="Product Price",
|
||||
groups="base.group_user",
|
||||
string="Gross Profit",
|
||||
)
|
||||
|
||||
gross_profit_percent = fields.Float(
|
||||
compute="_compute_actual_gp",
|
||||
groups="base.group_user",
|
||||
string="Gross Profit (%)",
|
||||
)
|
||||
|
||||
@api.depends(
|
||||
"purchase_price",
|
||||
"purchase_price_vendor",
|
||||
"move_ids.product_id",
|
||||
"move_ids.product_id.qty_available",
|
||||
"move_ids.state",
|
||||
"qty_to_deliver",
|
||||
)
|
||||
def _compute_actual_gp(self):
|
||||
"""We want to use the gross profit based on average inventory valuation when the
|
||||
sale order line will be completely fulfilled (or has been fulfilled) from stock.
|
||||
For product not yet in stock we want to use the vendor price. We can also have
|
||||
blended calculations (partly on vendor price, partly on stock valuation). This
|
||||
occurs when an order has been or would be partially fulfilled from available
|
||||
stock.
|
||||
:return:
|
||||
"""
|
||||
non_product_lines = self.filtered(lambda r: not r.product_id)
|
||||
non_product_lines.purchase_price_actual = 0.0
|
||||
non_product_lines.gross_profit = 0.0
|
||||
non_product_lines.gross_profit_percent = 0.0
|
||||
for line in self - non_product_lines:
|
||||
stock_missing = line._determine_missing_stock()
|
||||
if float_is_zero(
|
||||
stock_missing, precision_rounding=line.product_uom.rounding
|
||||
):
|
||||
# everything is coming from stock, use inventory valuation
|
||||
line.purchase_price_actual = line.purchase_price
|
||||
elif (
|
||||
float_compare(
|
||||
line.product_uom_qty,
|
||||
stock_missing,
|
||||
precision_rounding=line.product_uom.rounding,
|
||||
)
|
||||
== 0
|
||||
):
|
||||
# everything is coming from the vendor, use vendor pricing
|
||||
line.purchase_price_actual = line.purchase_price_vendor
|
||||
else:
|
||||
# we have a mix, use blended pricing
|
||||
qty_from_stock = line.product_uom_qty - stock_missing
|
||||
line.purchase_price_actual = (
|
||||
(
|
||||
stock_missing * line.purchase_price_vendor
|
||||
+ qty_from_stock * line.purchase_price
|
||||
)
|
||||
/ line.product_uom_qty
|
||||
if line.product_uom_qty != 0
|
||||
else 0
|
||||
)
|
||||
line.gross_profit = line.price_subtotal - (
|
||||
line.purchase_price_actual * line.product_uom_qty
|
||||
)
|
||||
line.gross_profit_percent = (
|
||||
line.price_subtotal and line.gross_profit / line.price_subtotal
|
||||
if line.price_subtotal != 0
|
||||
else 0
|
||||
)
|
||||
|
||||
def _determine_missing_stock(self) -> float:
|
||||
"""Compute how much stock is missing to meet an order line's demand. In the
|
||||
case of a quotation line, available stock is checked as if the order were to be
|
||||
placed immediately.
|
||||
|
||||
:return: The quantity missing from available stock to fulfill the line, in
|
||||
the unit of measure matching self.product_uom.
|
||||
"""
|
||||
self.ensure_one()
|
||||
is_order = self.order_id.state == "sale"
|
||||
qty_available = max(0, self.product_id.qty_available)
|
||||
if is_order and self.qty_to_deliver <= 0:
|
||||
return 0
|
||||
elif is_order and self.qty_to_deliver > 0:
|
||||
reserved = sum(
|
||||
[
|
||||
q.reserved_quantity
|
||||
for q in self.move_ids.mapped("move_line_ids").mapped("quant_id")
|
||||
]
|
||||
)
|
||||
return max(0, self.qty_to_deliver - reserved - qty_available)
|
||||
else:
|
||||
# This is a quotation, don't bother with stock reservations
|
||||
# Also, if available is negative for some reason,
|
||||
return max(0, self.product_uom_qty - qty_available)
|
||||
|
||||
@api.depends("product_id", "product_id.seller_ids", "product_id.seller_ids.price")
|
||||
def _compute_purchase_price_vendor(self):
|
||||
for line in self:
|
||||
product = line.product_id
|
||||
suppinfos = product.seller_ids.sorted("sequence")
|
||||
if not suppinfos:
|
||||
line.purchase_price_vendor = 0.0
|
||||
continue
|
||||
suppinfo = suppinfos[0]
|
||||
purch_currency = suppinfo.currency_id
|
||||
to_cur = line.currency_id or line.order_id.currency_id
|
||||
line.purchase_price_vendor = (
|
||||
purch_currency._convert(
|
||||
from_amount=suppinfo.price,
|
||||
to_currency=to_cur,
|
||||
company=line.company_id or self.env.company,
|
||||
date=line.order_id.date_order or fields.Date.today(),
|
||||
round=False,
|
||||
)
|
||||
if to_cur and suppinfo.price
|
||||
else suppinfo.price
|
||||
)
|
||||
1
bemade_margin_vendor_pricelist/tests/__init__.py
Normal file
1
bemade_margin_vendor_pricelist/tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import test_margins
|
||||
390
bemade_margin_vendor_pricelist/tests/test_margins.py
Normal file
390
bemade_margin_vendor_pricelist/tests/test_margins.py
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
from odoo.tests import TransactionCase, tagged
|
||||
from unittest.mock import patch, PropertyMock
|
||||
|
||||
|
||||
@tagged("-at_install", "post_install")
|
||||
class TestSaleOrderLine(TransactionCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
# Create a test vendor
|
||||
cls.vendor = cls.env["res.partner"].create(
|
||||
{
|
||||
"name": "Test Vendor",
|
||||
"supplier_rank": 1,
|
||||
}
|
||||
)
|
||||
|
||||
# Create a test product
|
||||
cls.product = cls.env["product.product"].create(
|
||||
{
|
||||
"name": "Test Product",
|
||||
"type": "consu",
|
||||
"is_storable": True,
|
||||
"list_price": 100.0,
|
||||
"standard_price": 80.0,
|
||||
}
|
||||
)
|
||||
|
||||
# Link supplier info to the product
|
||||
cls.supplierinfo = cls.env["product.supplierinfo"].create(
|
||||
{
|
||||
"partner_id": cls.vendor.id,
|
||||
"product_tmpl_id": cls.product.product_tmpl_id.id,
|
||||
"min_qty": 1,
|
||||
"price": 75.0, # Vendor price
|
||||
"currency_id": cls.env.ref(
|
||||
"base.USD"
|
||||
).id, # Assuming USD for simplicity
|
||||
}
|
||||
)
|
||||
|
||||
# Create a test sale order
|
||||
cls.sale_order = cls.env["sale.order"].create(
|
||||
{
|
||||
"partner_id": cls.env.ref("base.res_partner_1").id,
|
||||
# Assuming a default partner
|
||||
}
|
||||
)
|
||||
|
||||
# Create a test sale order line
|
||||
cls.sale_order_line = cls.env["sale.order.line"].create(
|
||||
{
|
||||
"order_id": cls.sale_order.id,
|
||||
"product_id": cls.product.id,
|
||||
"product_uom_qty": 10,
|
||||
"price_unit": 120.0,
|
||||
}
|
||||
)
|
||||
|
||||
def test_purchase_price_all_from_stock(self):
|
||||
"""Test when all quantity is fulfilled from stock."""
|
||||
with patch.object(
|
||||
self.sale_order_line.product_id.__class__,
|
||||
"qty_available",
|
||||
new_callable=PropertyMock,
|
||||
) as mock_qty_available:
|
||||
mock_qty_available.return_value = 10 # All available
|
||||
|
||||
# Recompute the fields
|
||||
self.sale_order_line._compute_actual_gp()
|
||||
|
||||
# Assert purchase_price_actual equals purchase_price (from stock)
|
||||
self.assertEqual(
|
||||
self.sale_order_line.purchase_price_actual,
|
||||
self.sale_order_line.purchase_price,
|
||||
"Purchase price should be from stock when all quantity is available.",
|
||||
)
|
||||
|
||||
# Assert gross_profit is calculated correctly
|
||||
expected_gross_profit = self.sale_order_line.price_subtotal - (
|
||||
self.sale_order_line.purchase_price_actual
|
||||
* self.sale_order_line.product_uom_qty
|
||||
)
|
||||
self.assertAlmostEqual(
|
||||
self.sale_order_line.gross_profit,
|
||||
expected_gross_profit,
|
||||
msg="Gross profit should be correctly calculated from stock purchase price.",
|
||||
)
|
||||
# Ensure the SO is correct as well
|
||||
self.assertSaleOrderComputesLikeSaleOrderLine()
|
||||
|
||||
def assertSaleOrderComputesLikeSaleOrderLine(self):
|
||||
self.assertAlmostEqual(
|
||||
self.sale_order_line.gross_profit,
|
||||
self.sale_order.gross_profit,
|
||||
)
|
||||
self.assertAlmostEqual(
|
||||
self.sale_order_line.gross_profit_percent,
|
||||
self.sale_order.gross_profit_percent,
|
||||
)
|
||||
|
||||
def test_purchase_price_all_from_vendor(self):
|
||||
"""Test when all quantity is fulfilled from vendor."""
|
||||
with patch.object(
|
||||
self.sale_order_line.product_id.__class__,
|
||||
"qty_available",
|
||||
new_callable=PropertyMock,
|
||||
) as mock_qty_available:
|
||||
mock_qty_available.return_value = 0
|
||||
|
||||
# Assert purchase_price_actual equals purchase_price_vendor (from vendor)
|
||||
self.assertEqual(
|
||||
self.sale_order_line.purchase_price_actual,
|
||||
self.sale_order_line.purchase_price_vendor,
|
||||
"Purchase price should be from vendor when all quantity is missing from stock.",
|
||||
)
|
||||
|
||||
# Assert gross_profit is calculated correctly
|
||||
expected_gross_profit = self.sale_order_line.price_subtotal - (
|
||||
self.sale_order_line.purchase_price_actual
|
||||
* self.sale_order_line.product_uom_qty
|
||||
)
|
||||
self.assertAlmostEqual(
|
||||
self.sale_order_line.gross_profit,
|
||||
expected_gross_profit,
|
||||
msg="Gross profit should be correctly calculated from vendor purchase price.",
|
||||
)
|
||||
# Ensure the SO is correct as well
|
||||
self.assertSaleOrderComputesLikeSaleOrderLine()
|
||||
|
||||
def test_purchase_price_mixed(self):
|
||||
"""Test when quantity is partially fulfilled from stock and partially from vendor."""
|
||||
with patch.object(
|
||||
self.sale_order_line.product_id.__class__,
|
||||
"qty_available",
|
||||
new_callable=PropertyMock,
|
||||
) as mock_qty_available:
|
||||
mock_qty_available.return_value = 5
|
||||
# Calculate expected blended purchase price
|
||||
qty_from_vendor = 5
|
||||
qty_from_stock = 5
|
||||
expected_purchase_price = (
|
||||
(qty_from_vendor * self.sale_order_line.purchase_price_vendor)
|
||||
+ (qty_from_stock * self.sale_order_line.purchase_price)
|
||||
) / self.sale_order_line.product_uom_qty
|
||||
|
||||
self.assertAlmostEqual(
|
||||
self.sale_order_line.purchase_price_actual,
|
||||
expected_purchase_price,
|
||||
msg="Purchase price should be a blend of stock and vendor prices.",
|
||||
)
|
||||
|
||||
# Assert gross_profit is calculated correctly
|
||||
expected_gross_profit = self.sale_order_line.price_subtotal - (
|
||||
expected_purchase_price * self.sale_order_line.product_uom_qty
|
||||
)
|
||||
self.assertAlmostEqual(
|
||||
self.sale_order_line.gross_profit,
|
||||
expected_gross_profit,
|
||||
msg="Gross profit should be correctly calculated from blended purchase price.",
|
||||
)
|
||||
# Ensure the SO is correct as well
|
||||
self.assertSaleOrderComputesLikeSaleOrderLine()
|
||||
|
||||
def test_negative_available_stock_zero_qty_ordered(self):
|
||||
"""We had a division by zero error with a zero product_uom_qty field
|
||||
and a negative available stock. This test aims to root out this and
|
||||
other possible causes of a div by zero error."""
|
||||
with patch.object(
|
||||
self.sale_order_line.product_id.__class__,
|
||||
"qty_available",
|
||||
new_callable=PropertyMock,
|
||||
) as mock_qty_available:
|
||||
mock_qty_available.return_value = -1 # Negative available stock
|
||||
|
||||
# Set product_uom_qty to zero
|
||||
self.sale_order_line.product_uom_qty = 0
|
||||
# Recompute the fields
|
||||
self.sale_order_line._compute_actual_gp()
|
||||
|
||||
# Assert gross_profit is 0
|
||||
self.assertEqual(
|
||||
self.sale_order_line.gross_profit,
|
||||
0.0,
|
||||
"Gross profit should be 0 when product_uom_qty is zero.",
|
||||
)
|
||||
|
||||
# Assert gross_profit_percent is 0
|
||||
self.assertEqual(
|
||||
self.sale_order_line.gross_profit_percent,
|
||||
0.0,
|
||||
"Gross profit percent should be 0 when product_uom_qty is zero.",
|
||||
)
|
||||
# Ensure the SO is correct as well
|
||||
self.assertSaleOrderComputesLikeSaleOrderLine()
|
||||
|
||||
def test_purchase_price_all_from_stock_confirmed(self):
|
||||
"""Test when all quantity is fulfilled from stock."""
|
||||
self.sale_order.action_confirm()
|
||||
with patch.object(
|
||||
self.sale_order_line.product_id.__class__,
|
||||
"qty_available",
|
||||
new_callable=PropertyMock,
|
||||
) as mock_qty_available:
|
||||
mock_qty_available.return_value = 10 # All available
|
||||
|
||||
# Recompute the fields
|
||||
self.sale_order_line._compute_actual_gp()
|
||||
|
||||
# Assert purchase_price_actual equals purchase_price (from stock)
|
||||
self.assertEqual(
|
||||
self.sale_order_line.purchase_price_actual,
|
||||
self.sale_order_line.purchase_price,
|
||||
"Purchase price should be from stock when all quantity is available.",
|
||||
)
|
||||
|
||||
# Assert gross_profit is calculated correctly
|
||||
expected_gross_profit = self.sale_order_line.price_subtotal - (
|
||||
self.sale_order_line.purchase_price_actual
|
||||
* self.sale_order_line.product_uom_qty
|
||||
)
|
||||
self.assertAlmostEqual(
|
||||
self.sale_order_line.gross_profit,
|
||||
expected_gross_profit,
|
||||
msg="Gross profit should be correctly calculated from stock purchase price.",
|
||||
)
|
||||
# Ensure the SO is correct as well
|
||||
self.assertSaleOrderComputesLikeSaleOrderLine()
|
||||
|
||||
def test_purchase_price_all_from_vendor_confirmed(self):
|
||||
"""Test when all quantity is fulfilled from vendor."""
|
||||
self.sale_order.action_confirm()
|
||||
with patch.object(
|
||||
self.sale_order_line.product_id.__class__,
|
||||
"qty_available",
|
||||
new_callable=PropertyMock,
|
||||
) as mock_qty_available:
|
||||
mock_qty_available.return_value = 0
|
||||
|
||||
# Assert purchase_price_actual equals purchase_price_vendor (from vendor)
|
||||
self.assertEqual(
|
||||
self.sale_order_line.purchase_price_actual,
|
||||
self.sale_order_line.purchase_price_vendor,
|
||||
"Purchase price should be from vendor when all quantity is missing from stock.",
|
||||
)
|
||||
|
||||
# Assert gross_profit is calculated correctly
|
||||
expected_gross_profit = self.sale_order_line.price_subtotal - (
|
||||
self.sale_order_line.purchase_price_actual
|
||||
* self.sale_order_line.product_uom_qty
|
||||
)
|
||||
self.assertAlmostEqual(
|
||||
self.sale_order_line.gross_profit,
|
||||
expected_gross_profit,
|
||||
msg="Gross profit should be correctly calculated from vendor purchase price.",
|
||||
)
|
||||
# Ensure the SO is correct as well
|
||||
self.assertSaleOrderComputesLikeSaleOrderLine()
|
||||
|
||||
def test_purchase_price_mixed_confirmed(self):
|
||||
"""Test when quantity is partially fulfilled from stock and partially from vendor."""
|
||||
self.sale_order.action_confirm()
|
||||
with patch.object(
|
||||
self.sale_order_line.product_id.__class__,
|
||||
"qty_available",
|
||||
new_callable=PropertyMock,
|
||||
) as mock_qty_available:
|
||||
mock_qty_available.return_value = 5
|
||||
# Calculate expected blended purchase price
|
||||
qty_from_vendor = 5
|
||||
qty_from_stock = 5
|
||||
expected_purchase_price = (
|
||||
(qty_from_vendor * self.sale_order_line.purchase_price_vendor)
|
||||
+ (qty_from_stock * self.sale_order_line.purchase_price)
|
||||
) / self.sale_order_line.product_uom_qty
|
||||
|
||||
self.assertAlmostEqual(
|
||||
self.sale_order_line.purchase_price_actual,
|
||||
expected_purchase_price,
|
||||
msg="Purchase price should be a blend of stock and vendor prices.",
|
||||
)
|
||||
|
||||
# Assert gross_profit is calculated correctly
|
||||
expected_gross_profit = self.sale_order_line.price_subtotal - (
|
||||
expected_purchase_price * self.sale_order_line.product_uom_qty
|
||||
)
|
||||
self.assertAlmostEqual(
|
||||
self.sale_order_line.gross_profit,
|
||||
expected_gross_profit,
|
||||
msg="Gross profit should be correctly calculated from blended purchase price.",
|
||||
)
|
||||
# Ensure the SO is correct as well
|
||||
self.assertSaleOrderComputesLikeSaleOrderLine()
|
||||
|
||||
def test_negative_available_stock_zero_qty_ordered_confirmed(self):
|
||||
"""We had a division by zero error with a zero product_uom_qty field
|
||||
and a negative available stock. This test aims to root out this and
|
||||
other possible causes of a div by zero error."""
|
||||
self.sale_order.action_confirm()
|
||||
with patch.object(
|
||||
self.sale_order_line.product_id.__class__,
|
||||
"qty_available",
|
||||
new_callable=PropertyMock,
|
||||
) as mock_qty_available:
|
||||
mock_qty_available.return_value = -1 # Negative available stock
|
||||
|
||||
# Set product_uom_qty to zero
|
||||
self.sale_order_line.product_uom_qty = 0
|
||||
# Recompute the fields
|
||||
self.sale_order_line._compute_actual_gp()
|
||||
|
||||
# Assert gross_profit is 0
|
||||
self.assertEqual(
|
||||
self.sale_order_line.gross_profit,
|
||||
0.0,
|
||||
"Gross profit should be 0 when product_uom_qty is zero.",
|
||||
)
|
||||
|
||||
# Assert gross_profit_percent is 0
|
||||
self.assertEqual(
|
||||
self.sale_order_line.gross_profit_percent,
|
||||
0.0,
|
||||
"Gross profit percent should be 0 when product_uom_qty is zero.",
|
||||
)
|
||||
# Ensure the SO is correct as well
|
||||
self.assertSaleOrderComputesLikeSaleOrderLine()
|
||||
|
||||
def test_determine_missing_stock_all_reserved(self):
|
||||
"""Test the actual _determine_missing_stock method when all stock is reserved."""
|
||||
# Set initial stock for the product
|
||||
location = self.env["stock.warehouse"].search([])[0].lot_stock_id
|
||||
quant = self.env["stock.quant"].create(
|
||||
{
|
||||
"product_id": self.product.id,
|
||||
"location_id": location.id,
|
||||
"inventory_quantity": 10,
|
||||
"product_uom_id": self.product.uom_id.id,
|
||||
}
|
||||
)
|
||||
quant.action_apply_inventory()
|
||||
|
||||
self.sale_order.action_confirm()
|
||||
# Force the move to be reserved
|
||||
self.sale_order.picking_ids.action_assign()
|
||||
|
||||
# Now, call _determine_missing_stock without mocking
|
||||
missing_stock = self.sale_order_line._determine_missing_stock()
|
||||
|
||||
# Assert that missing_stock is 0
|
||||
self.assertEqual(
|
||||
missing_stock,
|
||||
0,
|
||||
"_determine_missing_stock should return 0 when all stock is reserved.",
|
||||
)
|
||||
|
||||
# Additionally, verify that purchase_price_actual is from stock
|
||||
self.sale_order_line._compute_actual_gp()
|
||||
|
||||
self.assertEqual(
|
||||
self.sale_order_line.purchase_price_actual,
|
||||
self.sale_order_line.purchase_price,
|
||||
"Purchase price should be from stock when all stock is reserved.",
|
||||
)
|
||||
|
||||
# Assert gross_profit is calculated correctly
|
||||
expected_gross_profit = self.sale_order_line.price_subtotal - (
|
||||
self.sale_order_line.purchase_price_actual
|
||||
* self.sale_order_line.product_uom_qty
|
||||
)
|
||||
self.assertAlmostEqual(
|
||||
self.sale_order_line.gross_profit,
|
||||
expected_gross_profit,
|
||||
msg="Gross profit should be correctly calculated when all stock is reserved.",
|
||||
)
|
||||
|
||||
# Assert gross_profit_percent is calculated correctly
|
||||
if self.sale_order_line.price_subtotal:
|
||||
expected_gross_profit_percent = (
|
||||
self.sale_order_line.gross_profit / self.sale_order_line.price_subtotal
|
||||
)
|
||||
else:
|
||||
expected_gross_profit_percent = 0.0
|
||||
|
||||
self.assertAlmostEqual(
|
||||
self.sale_order_line.gross_profit_percent,
|
||||
expected_gross_profit_percent,
|
||||
msg="Gross profit percent should be correctly calculated when all stock is reserved.",
|
||||
)
|
||||
# Ensure the SO is correct as well
|
||||
self.assertSaleOrderComputesLikeSaleOrderLine()
|
||||
57
bemade_margin_vendor_pricelist/views/sale_order.xml
Normal file
57
bemade_margin_vendor_pricelist/views/sale_order.xml
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
<data>
|
||||
<record model="ir.ui.view" id="sale_margin_sale_order_inherit">
|
||||
<field name="name">sale.order.margin.view.form.inherit</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="inherit_id" ref="sale_margin.sale_margin_sale_order"/>
|
||||
<field name="arch" type="xml">
|
||||
<field name="margin" position="replace">
|
||||
<field name="margin" invisible="True"/>
|
||||
<field name="gross_profit" class="oe_inline"/>
|
||||
</field>
|
||||
<label for="margin" position="replace">
|
||||
<label for="gross_profit" groups="base.group_user"/>
|
||||
</label>
|
||||
<field name="margin_percent" position="replace">
|
||||
<field name="margin_percent" invisible="True"/>
|
||||
<field name="gross_profit_percent" nolabel="1" class="oe_inline"
|
||||
widget="percentage" groups="base.group_user"/>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
<record model="ir.ui.view" id="sale_margin_sale_order_line_inherit">
|
||||
<field name="name">sale.order.line.margin.view.form.inherit</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="inherit_id" ref="sale_margin.sale_margin_sale_order_line"/>
|
||||
<field name="arch" type="xml">
|
||||
<field name="purchase_price" position="replace">
|
||||
<field name="purchase_price" invisible="True"/>
|
||||
<field name="purchase_price_actual" groups="base.group_user"/>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
<record model="ir.ui.view" id="sale_margin_sale_order_line_form_inherit">
|
||||
<field name="name">sale.order.view.form</field>
|
||||
<field name="inherit_id" ref="sale_margin.sale_margin_sale_order_line_form"/>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="arch" type="xml">
|
||||
<field name="purchase_price" position="replace">
|
||||
<field name="purchase_price" column_invisible="True"/>
|
||||
<field name="purchase_price_actual" optional="hide"/>
|
||||
</field>
|
||||
<field name="margin" position="replace">
|
||||
<field name="margin" column_invisible="True"/>
|
||||
<field name="gross_profit" optional="hide"/>
|
||||
</field>
|
||||
<field name="margin_percent" position="replace">
|
||||
<field name="margin_percent" column_invisible="True"/>
|
||||
<field name="gross_profit_percent" optional="hide"
|
||||
widget="percentage" groups="base.group_user"/>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
<!-- For now, we don't override the pivot and graph views because our fields
|
||||
are not stored and this will cause performance issues. -->
|
||||
</data>
|
||||
</odoo>
|
||||
Loading…
Reference in a new issue