diff --git a/CHANGELOG b/CHANGELOG index a5533e47a0..0239f87843 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,5 @@ +* Update unit price of stock moves based on posted invoice lines + Version 5.6.0 - 2020-05-04 * Bug fixes (see mercurial logs for details) * Keep stock moves when crediting an invoice diff --git a/__init__.py b/__init__.py index f34c1d692d..31e9a0ed38 100644 --- a/__init__.py +++ b/__init__.py @@ -8,6 +8,7 @@ from . import stock def register(): Pool.register( + account.Invoice, account.InvoiceLineStockMove, account.InvoiceLine, stock.Move, diff --git a/account.py b/account.py index ae6bc92e30..8641bc2f42 100644 --- a/account.py +++ b/account.py @@ -1,11 +1,26 @@ # This file is part of Tryton. The COPYRIGHT file at the top level of # this repository contains the full copyright notices and license terms. -from trytond.model import ModelSQL, fields +from trytond.model import ModelSQL, ModelView, Workflow, fields from trytond.pool import Pool, PoolMeta from trytond.pyson import Eval, If from trytond.transaction import Transaction +class Invoice(metaclass=PoolMeta): + __name__ = 'account.invoice' + + @classmethod + @ModelView.button + @Workflow.transition('posted') + def post(cls, invoices): + pool = Pool() + Move = pool.get('stock.move') + super().post(invoices) + moves = sum((l.stock_moves for i in invoices for l in i.lines), ()) + if moves: + Move.__queue__.update_unit_price(moves) + + class InvoiceLineStockMove(ModelSQL): 'Invoice Line - Stock Move' __name__ = 'account.invoice.line-stock.move' diff --git a/doc/index.rst b/doc/index.rst index a4ffa1eca9..347bc61e3d 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -3,3 +3,5 @@ Account Invoice Stock Module The account invoice stock module adds link between invoice lines and stock moves. +The unit price of the stock move is updated with the average price of the +posted invoice lines that are linked to it. diff --git a/setup.py b/setup.py index 7641a8db65..8a940cc2b7 100755 --- a/setup.py +++ b/setup.py @@ -57,6 +57,7 @@ for dep in info.get('depends', []): requires.append(get_require_version('trytond_%s' % dep)) requires.append(get_require_version('trytond')) +tests_require = [get_require_version('proteus')] dependency_links = [] if minor_version % 2: dependency_links.append('https://trydevpi.tryton.org/') @@ -136,4 +137,5 @@ setup(name=name, """, test_suite='tests', test_loader='trytond.test_loader:Loader', + tests_require=tests_require, ) diff --git a/stock.py b/stock.py index b2eb81d4bb..31ca8c36e7 100644 --- a/stock.py +++ b/stock.py @@ -1,10 +1,14 @@ # This file is part of Tryton. The COPYRIGHT file at the top level of # this repository contains the full copyright notices and license terms. -from trytond.model import fields +from decimal import Decimal + +from trytond.model import ModelView, Workflow, fields from trytond.pool import Pool, PoolMeta from trytond.transaction import Transaction from trytond.pyson import Eval +from trytond.modules.product import round_price + class Move(metaclass=PoolMeta): __name__ = 'stock.move' @@ -14,6 +18,7 @@ class Move(metaclass=PoolMeta): domain=[ ('product.default_uom_category', '=', Eval('product_uom_category', -1)), + ('type', '=', 'line'), ['OR', ('invoice.type', 'in', Eval('invoice_types', [])), ('invoice_type', 'in', Eval('invoice_types', [])), @@ -63,6 +68,40 @@ class Move(metaclass=PoolMeta): default.setdefault('invoice_lines', None) return super().copy(moves, default=default) + @classmethod + @ModelView.button + @Workflow.transition('done') + def do(cls, moves): + super().do(moves) + cls.update_unit_price(moves) + + @classmethod + def update_unit_price(cls, moves): + for move in moves: + if move.state == 'done': + unit_price = move._compute_unit_price() + if unit_price != move.unit_price: + move.unit_price = unit_price + cls.save(moves) + + def _compute_unit_price(self): + pool = Pool() + UoM = pool.get('product.uom') + Currency = pool.get('currency.currency') + amount, quantity = 0, 0 + for line in self.invoice_lines: + if line.invoice and line.invoice.state in {'posted', 'paid'}: + with Transaction().set_context(date=self.effective_date): + amount += Currency.compute( + line.invoice.currency, line.amount, self.currency) + quantity += UoM.compute_qty( + line.unit, line.quantity, self.uom) + if not quantity: + unit_price = self.unit_price + else: + unit_price = round_price(amount / Decimal(str(quantity))) + return unit_price + class ShipmentOut(metaclass=PoolMeta): __name__ = 'stock.shipment.out' diff --git a/tests/scenario_account_invoice_stock.rst b/tests/scenario_account_invoice_stock.rst new file mode 100644 index 0000000000..b0a688643b --- /dev/null +++ b/tests/scenario_account_invoice_stock.rst @@ -0,0 +1,140 @@ +======================== +Invoice - Stock Scenario +======================== + +Imports:: + + >>> from decimal import Decimal + >>> from proteus import Model, Wizard + >>> from trytond.tests.tools import activate_modules + >>> from trytond.modules.company.tests.tools import ( + ... create_company, get_company) + >>> from trytond.modules.account.tests.tools import ( + ... create_fiscalyear, create_chart, get_accounts) + >>> from trytond.modules.account_invoice.tests.tools import ( + ... set_fiscalyear_invoice_sequences) + +Install account_invoice_stock:: + + >>> config = activate_modules('account_invoice_stock') + +Create company:: + + >>> _ = create_company() + >>> company = get_company() + +Create fiscal year:: + + >>> fiscalyear = set_fiscalyear_invoice_sequences( + ... create_fiscalyear(company)) + >>> fiscalyear.click('create_period') + +Create chart of accounts:: + + >>> _ = create_chart(company) + >>> accounts = get_accounts(company) + +Create a party:: + + >>> Party = Model.get('party.party') + >>> party = Party(name="Party") + >>> party.save() + +Create an account category:: + + >>> ProductCategory = Model.get('product.category') + >>> account_category = ProductCategory(name="Account Category") + >>> account_category.accounting = True + >>> account_category.account_expense = accounts['expense'] + >>> account_category.account_revenue = accounts['revenue'] + >>> account_category.save() + +Create a product:: + + >>> ProductUom = Model.get('product.uom') + >>> unit, = ProductUom.find([('name', '=', 'Unit')]) + >>> ProductTemplate = Model.get('product.template') + >>> template = ProductTemplate() + >>> template.name = 'product' + >>> template.default_uom = unit + >>> template.type = 'goods' + >>> template.list_price = Decimal('40') + >>> template.account_category = account_category + >>> template.save() + >>> product, = template.products + +Get stock locations:: + + >>> Location = Model.get('stock.location') + >>> output_loc, = Location.find([('code', '=', 'OUT')]) + >>> customer_loc, = Location.find([('code', '=', 'CUS')]) + +Create a shipment:: + + >>> Shipment = Model.get('stock.shipment.out') + >>> Move = Model.get('stock.move') + >>> shipment = Shipment() + >>> shipment.customer = party + >>> move = shipment.outgoing_moves.new() + >>> move.product = product + >>> move.quantity = 10 + >>> move.from_location = output_loc + >>> move.to_location = customer_loc + >>> move.unit_price = Decimal('40.0000') + >>> shipment.click('wait') + >>> shipment.state + 'waiting' + >>> move, = shipment.outgoing_moves + +Create an invoice for half the quantity with higher price:: + + >>> Invoice = Model.get('account.invoice') + >>> invoice = Invoice(type='out') + >>> invoice.party = party + >>> line = invoice.lines.new() + >>> line.product = product + >>> line.quantity = 5 + >>> line.unit_price = Decimal('50.0000') + >>> line.stock_moves.append(Move(move.id)) + >>> invoice.click('post') + >>> invoice.state + 'posted' + +Check move unit price is not changed:: + + >>> move.reload() + >>> move.unit_price + Decimal('40.0000') + +Ship the products:: + + >>> shipment.click('assign_force') + >>> shipment.click('pack') + >>> shipment.click('done') + >>> shipment.state + 'done' + +Check move unit price has been updated:: + + >>> move.reload() + >>> move.unit_price + Decimal('50.0000') + +Create a second invoice for the remaining quantity cheaper:: + + >>> invoice = Invoice(type='out') + >>> invoice.party = party + >>> line = invoice.lines.new() + >>> line.product = product + >>> line.quantity = 5 + >>> line.unit_price = Decimal('40.0000') + >>> line.stock_moves.append(Move(move.id)) + >>> invoice.click('post') + >>> invoice.state + 'posted' + +Check move unit price has been updated again:: + + >>> move.reload() + >>> move.unit_price + Decimal('45.0000') diff --git a/tests/test_account_invoice_stock.py b/tests/test_account_invoice_stock.py index 125849383a..1dfeea1c74 100644 --- a/tests/test_account_invoice_stock.py +++ b/tests/test_account_invoice_stock.py @@ -1,8 +1,11 @@ # This file is part of Tryton. The COPYRIGHT file at the top level of # this repository contains the full copyright notices and license terms. +import doctest import unittest import trytond.tests.test_tryton from trytond.tests.test_tryton import ModuleTestCase +from trytond.tests.test_tryton import doctest_teardown +from trytond.tests.test_tryton import doctest_checker class AccountInvoiceStockTestCase(ModuleTestCase): @@ -14,4 +17,9 @@ def suite(): suite = trytond.tests.test_tryton.suite() suite.addTests(unittest.TestLoader().loadTestsFromTestCase( AccountInvoiceStockTestCase)) + suite.addTests(doctest.DocFileSuite( + 'scenario_account_invoice_stock.rst', + tearDown=doctest_teardown, encoding='utf-8', + checker=doctest_checker, + optionflags=doctest.REPORT_ONLY_FIRST_FAILURE)) return suite