[IMP]: point_of_sale: enable margin on orders

Margin has been added to both Order and OrderLine allowing the user to have a better view on his profitability. Margin also has been added to the order report as a measure.

Related task id: 2455794

closes odoo/odoo#66689

Signed-off-by: pimodoo <pimodoo@users.noreply.github.com>
This commit is contained in:
Jacky (trj)
2021-08-03 09:14:04 +00:00
parent 56559941b5
commit 563071cc6b
7 changed files with 503 additions and 2 deletions
+84 -1
View File
@@ -142,6 +142,7 @@ class PosOrder(models.Model):
except Exception as e:
_logger.error('Could not fully process the POS Order: %s', tools.ustr(e))
pos_order._create_order_picking()
pos_order._compute_total_cost_in_real_time()
if pos_order.to_invoice and pos_order.state == 'paid':
pos_order.action_pos_order_invoice()
@@ -230,6 +231,10 @@ class PosOrder(models.Model):
amount_paid = fields.Float(string='Paid', states={'draft': [('readonly', False)]},
readonly=True, digits=0, required=True)
amount_return = fields.Float(string='Returned', digits=0, required=True, readonly=True)
margin = fields.Monetary(string="Margin", compute='_compute_margin')
margin_percent = fields.Float(string="Margin (%)", compute='_compute_margin', digits=(12, 4))
is_total_cost_computed = fields.Boolean(compute='_compute_is_total_cost_computed',
help="Allows to know if all the total cost of the order lines have already been computed")
lines = fields.One2many('pos.order.line', 'order_id', string='Order Lines', states={'draft': [('readonly', False)]}, readonly=True, copy=True)
company_id = fields.Many2one('res.company', string='Company', required=True, readonly=True)
pricelist_id = fields.Many2one('product.pricelist', string='Pricelist', required=True, states={
@@ -291,6 +296,45 @@ class PosOrder(models.Model):
for order in self:
order.currency_rate = self.env['res.currency']._get_conversion_rate(order.company_id.currency_id, order.currency_id, order.company_id, order.date_order)
@api.depends('lines.is_total_cost_computed')
def _compute_is_total_cost_computed(self):
for order in self:
order.is_total_cost_computed = not False in order.lines.mapped('is_total_cost_computed')
def _compute_total_cost_in_real_time(self):
"""
Compute the total cost of the order when it's processed by the server. It will compute the total cost of all the lines
if it's possible. If a margin of one of the order's lines cannot be computed (because of session_id.update_stock_at_closing),
then the margin of said order is not computed (it will be computed when closing the session).
"""
for order in self:
lines = order.lines
if not order._should_create_picking_real_time():
storable_fifo_avco_lines = lines.filtered(lambda l: l._is_product_storable_fifo_avco())
lines -= storable_fifo_avco_lines
stock_moves = order.picking_ids.move_lines
lines._compute_total_cost(stock_moves)
def _compute_total_cost_at_session_closing(self, stock_moves):
"""
Compute the margin at the end of the session. This method should be called to compute the remaining lines margin
containing a storable product with a fifo/avco cost method and then compute the order margin
"""
for order in self:
storable_fifo_avco_lines = order.lines.filtered(lambda l: l._is_product_storable_fifo_avco())
storable_fifo_avco_lines._compute_total_cost(stock_moves)
@api.depends('lines.margin', 'is_total_cost_computed')
def _compute_margin(self):
for order in self:
if order.is_total_cost_computed:
order.margin = sum(order.lines.mapped('margin'))
amount_untaxed = order.currency_id.round(sum(line.price_subtotal for line in order.lines))
order.margin_percent = not float_is_zero(amount_untaxed, order.currency_id.rounding) and order.margin / amount_untaxed or 0
else:
order.margin = 0
order.margin_percent = 0
@api.onchange('payment_ids', 'lines')
def _onchange_amount_all(self):
for order in self:
@@ -566,12 +610,15 @@ class PosOrder(models.Model):
return self.env['pos.order'].search_read(domain = [('id', 'in', order_ids)], fields = ['id', 'pos_reference'])
def _should_create_picking_real_time(self):
return not self.session_id.update_stock_at_closing or (self.company_id.anglo_saxon_accounting and self.to_invoice)
def _create_order_picking(self):
self.ensure_one()
if self.to_ship:
self.lines._launch_stock_rule_from_pos_order_lines()
else:
if not self.session_id.update_stock_at_closing or (self.company_id.anglo_saxon_accounting and self.to_invoice):
if self._should_create_picking_real_time():
picking_type = self.config_id.picking_type_id
if self.partner_id.property_stock_customer:
destination_id = self.partner_id.property_stock_customer.id
@@ -600,6 +647,7 @@ class PosOrder(models.Model):
'amount_tax': -self.amount_tax,
'amount_total': -self.amount_total,
'amount_paid': 0,
'is_total_cost_computed': False
}
def refund(self):
@@ -770,6 +818,10 @@ class PosOrderLine(models.Model):
readonly=True, required=True)
price_subtotal_incl = fields.Float(string='Subtotal', digits=0,
readonly=True, required=True)
margin = fields.Monetary(string="Margin", compute='_compute_margin')
margin_percent = fields.Float(string="Margin (%)", compute='_compute_margin', digits=(12, 4))
total_cost = fields.Float(string='Total cost', digits='Product Price', readonly=True)
is_total_cost_computed = fields.Boolean(help="Allows to know if the total cost has already been computed or not")
discount = fields.Float(string='Discount (%)', digits=0, default=0.0)
order_id = fields.Many2one('pos.order', string='Order Ref', ondelete='cascade', required=True)
tax_ids = fields.Many2many('account.tax', string='Taxes', readonly=True)
@@ -801,6 +853,7 @@ class PosOrderLine(models.Model):
'price_subtotal': -self.price_subtotal,
'price_subtotal_incl': -self.price_subtotal_incl,
'pack_lot_ids': PosOrderLineLot,
'is_total_cost_computed': False
}
@api.model
@@ -952,6 +1005,36 @@ class PosOrderLine(models.Model):
pickings_to_confirm.action_confirm()
return True
def _is_product_storable_fifo_avco(self):
self.ensure_one()
return self.product_id.type == 'product' and self.product_id.cost_method in ['fifo', 'average']
def _compute_total_cost(self, stock_moves):
"""
Compute the total cost of the order lines.
:param stock_moves: recordset of `stock.move`, used for fifo/avco lines
"""
for line in self.filtered(lambda l: not l.is_total_cost_computed):
product = line.product_id
if line._is_product_storable_fifo_avco() and stock_moves:
product_cost = product._compute_average_price(0, line.qty, stock_moves.filtered(lambda ml: ml.product_id == product))
else:
product_cost = product.standard_price
line.total_cost = line.qty * product.cost_currency_id._convert(
from_amount=product_cost,
to_currency=line.currency_id,
company=line.company_id or self.env.company,
date=line.order_id.date_order or fields.Date.today(),
round=False,
)
line.is_total_cost_computed = True
@api.depends('price_subtotal', 'total_cost')
def _compute_margin(self):
for line in self:
line.margin = line.price_subtotal - line.total_cost
line.margin_percent = not float_is_zero(line.price_subtotal, line.currency_id.rounding) and line.margin / line.price_subtotal or 0
class PosOrderLineLot(models.Model):
_name = "pos.pack.operation.lot"
@@ -319,6 +319,7 @@ class PosSession(models.Model):
self._check_if_no_draft_orders()
if self.update_stock_at_closing:
self._create_picking_at_end_of_session()
self.order_ids.filtered(lambda o: not o.is_total_cost_computed)._compute_total_cost_at_session_closing(self.picking_ids.move_lines)
try:
data = self.with_company(self.company_id)._create_account_move(balancing_account, amount_to_balance)
except AccessError as e:
@@ -35,6 +35,7 @@ class PosOrderReport(models.Model):
pos_categ_id = fields.Many2one('pos.category', string='PoS Category', readonly=True)
pricelist_id = fields.Many2one('product.pricelist', string='Pricelist', readonly=True)
session_id = fields.Many2one('pos.session', string='Session', readonly=True)
margin = fields.Float(string='Margin', readonly=True)
def _select(self):
return """
@@ -64,7 +65,8 @@ class PosOrderReport(models.Model):
pt.pos_categ_id,
s.pricelist_id,
s.session_id,
s.account_move IS NOT NULL AS invoiced
s.account_move IS NOT NULL AS invoiced,
SUM(l.price_subtotal - l.total_cost / CASE COALESCE(s.currency_rate, 0) WHEN 0 THEN 1.0 ELSE s.currency_rate END) AS margin
"""
def _from(self):
+1
View File
@@ -9,6 +9,7 @@ from . import test_point_of_sale
from . import test_pos_setup
from . import test_pos_basic_config
from . import test_pos_products_with_tax
from . import test_pos_margin
from . import test_pos_multiple_sale_accounts
from . import test_pos_multiple_receivable_accounts
from . import test_pos_other_currency_config
@@ -0,0 +1,398 @@
# -*- coding: utf-8 -*-
import odoo
from odoo.addons.point_of_sale.tests.common import TestPoSCommon
@odoo.tests.tagged('post_install', '-at_install')
class TestPosMargin(TestPoSCommon):
"""
Test the margin computation on orders with basic configuration
The tests contain the base scenarios.
"""
def setUp(self):
super(TestPosMargin, self).setUp()
self.config = self.basic_config
self.stock_location = self.env['stock.warehouse'].create({
'partner_id': self.env.user.partner_id.id,
'name': 'Stock location',
'code': 'WH'
}).lot_stock_id
self.customer_location = self.env.ref('stock.stock_location_customers')
self.supplier_location = self.env.ref('stock.stock_location_suppliers')
self.uom_unit = self.env.ref('uom.product_uom_unit')
def test_positive_margin(self):
"""
Test margin where it should be more than zero
"""
product1 = self.create_product('Product 1', self.categ_basic, 10, 5)
product2 = self.create_product('Product 2', self.categ_basic, 50, 30)
# open a session
self.open_new_session()
# create orders
orders = [self.create_ui_order_data([(product1, 1)]),
self.create_ui_order_data([(product2, 1)]),
self.create_ui_order_data([(product1, 2), (product2, 2)])]
# sync orders
self.env['pos.order'].create_from_ui(orders)
# check margins
self.assertEqual(self.pos_session.order_ids[0].margin, 5)
self.assertEqual(self.pos_session.order_ids[1].margin, 20)
self.assertEqual(self.pos_session.order_ids[2].margin, 50)
# check margins percent
self.assertEqual(self.pos_session.order_ids[0].margin_percent, 0.5)
self.assertEqual(self.pos_session.order_ids[1].margin_percent, 0.4)
self.assertEqual(round(self.pos_session.order_ids[2].margin_percent, 2), 0.42)
# close session
self.pos_session.action_pos_session_validate()
def test_negative_margin(self):
"""
Test margin where it should be less than zero
"""
product1 = self.create_product('Product 1', self.categ_basic, 10, 15)
product2 = self.create_product('Product 2', self.categ_basic, 50, 100)
# open a session
self.open_new_session()
# create orders
orders = [self.create_ui_order_data([(product1, 1)]),
self.create_ui_order_data([(product2, 1)]),
self.create_ui_order_data([(product1, 2), (product2, 2)])]
# sync orders
self.env['pos.order'].create_from_ui(orders)
# check margins
self.assertEqual(self.pos_session.order_ids[0].margin, -5)
self.assertEqual(self.pos_session.order_ids[1].margin, -50)
self.assertEqual(self.pos_session.order_ids[2].margin, -110)
# check margins percent
self.assertEqual(self.pos_session.order_ids[0].margin_percent, -0.5)
self.assertEqual(self.pos_session.order_ids[1].margin_percent, -1)
self.assertEqual(round(self.pos_session.order_ids[2].margin_percent, 2), -0.92)
# close session
self.pos_session.action_pos_session_validate()
def test_full_margin(self):
"""
Test margin where the product cost is always 0
"""
product1 = self.create_product('Product 1', self.categ_basic, 10)
product2 = self.create_product('Product 2', self.categ_basic, 50)
# open a session
self.open_new_session()
# create orders
orders = [self.create_ui_order_data([(product1, 1)]),
self.create_ui_order_data([(product2, 1)]),
self.create_ui_order_data([(product1, 2), (product2, 2)])]
# sync orders
self.env['pos.order'].create_from_ui(orders)
# check margins
self.assertEqual(self.pos_session.order_ids[0].margin, 10)
self.assertEqual(self.pos_session.order_ids[1].margin, 50)
self.assertEqual(self.pos_session.order_ids[2].margin, 120)
# check margins percent
self.assertEqual(self.pos_session.order_ids[0].margin_percent, 1)
self.assertEqual(self.pos_session.order_ids[1].margin_percent, 1)
self.assertEqual(self.pos_session.order_ids[2].margin_percent, 1)
# close session
self.pos_session.action_pos_session_validate()
def test_tax_margin(self):
"""
Test margin with tax on products
Product 1 price without tax = 10
Product 2 price without tax = 50
"""
product1 = self.create_product('Product 1', self.categ_basic, 10, 5, self.taxes['tax7'].ids)
product2 = self.create_product('Product 2', self.categ_basic, 55, 30, self.taxes['tax10'].ids)
# open a session
self.open_new_session()
# create orders
orders = [self.create_ui_order_data([(product1, 1)]),
self.create_ui_order_data([(product2, 1)]),
self.create_ui_order_data([(product1, 2), (product2, 2)])]
# sync orders
self.env['pos.order'].create_from_ui(orders)
# check margins
self.assertEqual(self.pos_session.order_ids[0].margin, 5)
self.assertEqual(self.pos_session.order_ids[1].margin, 20)
self.assertEqual(self.pos_session.order_ids[2].margin, 50)
# check margins percent
self.assertEqual(self.pos_session.order_ids[0].margin_percent, 0.5)
self.assertEqual(self.pos_session.order_ids[1].margin_percent, 0.4)
self.assertEqual(round(self.pos_session.order_ids[2].margin_percent, 2), 0.42)
# close session
self.pos_session.action_pos_session_validate()
def test_other_currency_margin(self):
"""
Test margin with tax on products and with different currency
The currency rate is 0.5 so the product price is halved in this currency.
"""
# change the config
current_config = self.config
self.config = self.other_currency_config
# same parameters as test_positive_margin
product1 = self.create_product('Product 1', self.categ_basic, 10, 5)
product2 = self.create_product('Product 2', self.categ_basic, 50, 30)
# open a session
self.open_new_session()
# create orders
orders = [self.create_ui_order_data([(product1, 1)]),
self.create_ui_order_data([(product2, 1)]),
self.create_ui_order_data([(product1, 2), (product2, 2)])]
# sync orders
self.env['pos.order'].create_from_ui(orders)
# check margins in the config currency
self.assertEqual(self.pos_session.order_ids[0].margin, 2.5)
self.assertEqual(self.pos_session.order_ids[1].margin, 10)
self.assertEqual(self.pos_session.order_ids[2].margin, 25)
# check margins percent which should be the same as test_positive_margin
self.assertEqual(self.pos_session.order_ids[0].margin_percent, 0.5)
self.assertEqual(self.pos_session.order_ids[1].margin_percent, 0.4)
self.assertEqual(round(self.pos_session.order_ids[2].margin_percent, 2), 0.42)
# close session
self.pos_session.action_pos_session_validate()
# set the config back
self.config = current_config
def test_tax_and_other_currency_margin(self):
"""
Test margin with different currency between products and config with taxes.
Product 1 price without tax = 10
Product 2 price without tax = 50
The currency rate is 0.5 so the product price is halved in this currency.
"""
# change the config
current_config = self.config
self.config = self.other_currency_config
product1 = self.create_product('Product 1', self.categ_basic, 10, 5, self.taxes['tax7'].ids)
product2 = self.create_product('Product 2', self.categ_basic, 55, 30, self.taxes['tax10'].ids)
# open a session
self.open_new_session()
# create orders
orders = [self.create_ui_order_data([(product1, 1)]),
self.create_ui_order_data([(product2, 1)]),
self.create_ui_order_data([(product1, 2), (product2, 2)])]
# sync orders
self.env['pos.order'].create_from_ui(orders)
# check margins in the config currency
self.assertEqual(self.pos_session.order_ids[0].margin, 2.5)
self.assertEqual(self.pos_session.order_ids[1].margin, 10)
self.assertEqual(self.pos_session.order_ids[2].margin, 25)
# check margins percent which should be the same as test_tax_margin
self.assertEqual(self.pos_session.order_ids[0].margin_percent, 0.5)
self.assertEqual(self.pos_session.order_ids[1].margin_percent, 0.4)
self.assertEqual(self.pos_session.order_ids[2].margin_percent, 0.4167)
# close session
self.pos_session.action_pos_session_validate()
# set the config back
self.config = current_config
def test_return_margin(self):
"""
Test margin where we return product (negative line quantity)
"""
product1 = self.create_product('Product 1', self.categ_basic, 10, 5)
product2 = self.create_product('Product 2', self.categ_basic, 50, 30)
# open a session
self.open_new_session()
# create orders
orders = [self.create_ui_order_data([(product1, -1)]),
self.create_ui_order_data([(product2, -1)]),
self.create_ui_order_data([(product1, -2), (product2, -2)])]
# sync orders
self.env['pos.order'].create_from_ui(orders)
# check margins
self.assertEqual(self.pos_session.order_ids[0].margin, -5)
self.assertEqual(self.pos_session.order_ids[1].margin, -20)
self.assertEqual(self.pos_session.order_ids[2].margin, -50)
# check margins percent
self.assertEqual(self.pos_session.order_ids[0].margin_percent, 0.5)
self.assertEqual(self.pos_session.order_ids[1].margin_percent, 0.4)
self.assertEqual(round(self.pos_session.order_ids[2].margin_percent, 2), 0.42)
# close session
self.pos_session.action_pos_session_validate()
def test_fifo_margin_real_time(self):
"""
Test margin where there is product in FIFO with stock update in real time
"""
product1 = self.create_product('Product 1', self.categ_anglo, 10, 5)
product2 = self.create_product('Product 2', self.categ_basic, 50, 30)
move1 = self.env['stock.move'].create({
'name': 'IN 2 unit @ 3 per unit',
'location_id': self.supplier_location.id,
'location_dest_id': self.stock_location.id,
'product_id': product1.id,
'product_uom': self.uom_unit.id,
'product_uom_qty': 2,
'price_unit': 3,
}).sudo()
move1._action_confirm()
move1._action_assign()
move1.move_line_ids.qty_done = 2
move1._action_done()
move2 = self.env['stock.move'].create({
'name': 'IN 1 unit @ 7 per unit',
'location_id': self.supplier_location.id,
'location_dest_id': self.stock_location.id,
'product_id': product1.id,
'product_uom': self.uom_unit.id,
'product_uom_qty': 1,
'price_unit': 7,
}).sudo()
move2._action_confirm()
move2._action_assign()
move2.move_line_ids.qty_done = 1
move2._action_done()
# open a session
self.open_new_session()
# create orders
orders = [self.create_ui_order_data([(product1, 1), (product2, 1)]),
self.create_ui_order_data([(product1, 2)])]
# sync orders
self.env['pos.order'].create_from_ui(orders)
# check margins
self.assertEqual(self.pos_session.order_ids[0].margin, 27)
self.assertEqual(self.pos_session.order_ids[1].margin, 10)
# check margins percent
self.assertEqual(self.pos_session.order_ids[0].margin_percent, 0.45)
self.assertEqual(self.pos_session.order_ids[1].margin_percent, 0.5)
# close session
self.pos_session.action_pos_session_validate()
def test_avco_margin_closing_time(self):
"""
Test margin where there is product in AVCO with stock update in closing
"""
self.categ_anglo.property_cost_method = 'average'
product1 = self.create_product('Product 1', self.categ_anglo, 10, 5)
product2 = self.create_product('Product 2', self.categ_basic, 50, 30)
self.env.company.point_of_sale_update_stock_quantities = 'closing'
move1 = self.env['stock.move'].create({
'name': 'IN 2 unit @ 3 per unit',
'location_id': self.supplier_location.id,
'location_dest_id': self.stock_location.id,
'product_id': product1.id,
'product_uom': self.uom_unit.id,
'product_uom_qty': 2,
'price_unit': 3,
}).sudo()
move1._action_confirm()
move1._action_assign()
move1.move_line_ids.qty_done = 2
move1._action_done()
move2 = self.env['stock.move'].create({
'name': 'IN 1 unit @ 6 per unit',
'location_id': self.supplier_location.id,
'location_dest_id': self.stock_location.id,
'product_id': product1.id,
'product_uom': self.uom_unit.id,
'product_uom_qty': 1,
'price_unit': 6,
}).sudo()
move2._action_confirm()
move2._action_assign()
move2.move_line_ids.qty_done = 1
move2._action_done()
# open a session
self.open_new_session()
# create orders
orders = [self.create_ui_order_data([(product1, 1), (product2, 1)]),
self.create_ui_order_data([(product1, 2)])]
# sync orders
self.env['pos.order'].create_from_ui(orders)
# check margins which are not really computed so it should be 0
self.assertEqual(self.pos_session.order_ids[0].margin, 0)
self.assertEqual(self.pos_session.order_ids[1].margin, 0)
# check margins percent (same as above)
self.assertEqual(self.pos_session.order_ids[1].margin_percent, 0)
self.assertEqual(self.pos_session.order_ids[1].margin_percent, 0)
# close session
self.pos_session.action_pos_session_validate()
# check margins
self.assertEqual(self.pos_session.order_ids[0].margin, 26)
self.assertEqual(self.pos_session.order_ids[1].margin, 12)
# check margins percent
self.assertEqual(self.pos_session.order_ids[0].margin_percent, 0.4333)
self.assertEqual(self.pos_session.order_ids[1].margin_percent, 0.6)
self.env.company.point_of_sale_update_stock_quantities = 'real'
@@ -51,6 +51,10 @@
<field name="customer_note" optional="hide"/>
<field name="product_uom_id" string="UoM" groups="uom.group_uom"/>
<field name="price_unit" widget="monetary"/>
<field name="is_total_cost_computed" invisible="1"/>
<field name="total_cost" attrs="{'invisible': [('is_total_cost_computed','=', False)]}" optional="hide" widget="monetary"/>
<field name="margin" attrs="{'invisible': [('is_total_cost_computed','=', False)]}" optional="hide" widget="monetary"/>
<field name="margin_percent" attrs="{'invisible': [('is_total_cost_computed','=', False)]}" optional="hide" widget="percentage"/>
<field name="discount" string="Disc.%" widget="monetary"/>
<field name="tax_ids_after_fiscal_position" widget="many2many_tags" string="Taxes"/>
<field name="tax_ids" widget="many2many_tags" invisible="1"/>
@@ -93,6 +97,15 @@
class="oe_subtotal_footer_separator"
widget="monetary"
attrs="{'invisible': [('amount_paid','=', 'amount_total')]}"/>
<label for="margin"/>
<div class="text-nowrap">
<field name="margin" class="oe_inline" attrs="{'invisible': [('is_total_cost_computed','=', False)]}"/>
<span class="oe_inline" attrs="{'invisible': [('is_total_cost_computed','=', False)]}">
(<field name="margin_percent" nolabel="1" class="oe_inline" widget="percentage"/>)
</span>
<span attrs="{'invisible': [('is_total_cost_computed','=', True)]}">TBD</span>
</div>
<field name="is_total_cost_computed" invisible="1"/>
<field name="currency_id" invisible="1"/>
</group>
<div class="oe_clear"/>
@@ -189,6 +202,8 @@
<field name="arch" type="xml">
<pivot string="PoS Orders" sample="1">
<field name="date_order" type="row"/>
<field name="margin"/>
<field name="margin_percent" invisible="1"/>
<field name="amount_total" type="measure"/>
</pivot>
</field>
@@ -57,6 +57,7 @@ class PosMakePayment(models.TransientModel):
if order._is_pos_order_paid():
order.action_pos_order_paid()
order._create_order_picking()
order._compute_total_cost_in_real_time()
return {'type': 'ir.actions.act_window_close'}
return self.launch_payment()